instance wide configuration (#1010)

TODO:
* [x] add setting initial set of admins during deployment
This commit is contained in:
bmc-msft
2021-06-30 17:13:58 -04:00
committed by GitHub
parent 1e90ed6092
commit 29dda54b83
16 changed files with 535 additions and 6 deletions

View File

@ -25,6 +25,7 @@ Each event will be submitted via HTTP POST to the user provided URL.
* [crash_reported](#crash_reported) * [crash_reported](#crash_reported)
* [file_added](#file_added) * [file_added](#file_added)
* [instance_config_updated](#instance_config_updated)
* [job_created](#job_created) * [job_created](#job_created)
* [job_stopped](#job_stopped) * [job_stopped](#job_stopped)
* [node_created](#node_created) * [node_created](#node_created)
@ -613,6 +614,59 @@ Each event will be submitted via HTTP POST to the user provided URL.
} }
``` ```
### instance_config_updated
#### Example
```json
{
"config": {
"admins": [
"00000000-0000-0000-0000-000000000000"
],
"allow_pool_management": true
}
}
```
#### Schema
```json
{
"definitions": {
"InstanceConfig": {
"properties": {
"admins": {
"items": {
"format": "uuid",
"type": "string"
},
"title": "Admins",
"type": "array"
},
"allow_pool_management": {
"default": true,
"title": "Allow Pool Management",
"type": "boolean"
}
},
"title": "InstanceConfig",
"type": "object"
}
},
"properties": {
"config": {
"$ref": "#/definitions/InstanceConfig"
}
},
"required": [
"config"
],
"title": "EventInstanceConfigUpdated",
"type": "object"
}
```
### job_created ### job_created
#### Example #### Example
@ -4792,6 +4846,18 @@ Each event will be submitted via HTTP POST to the user provided URL.
"title": "EventFileAdded", "title": "EventFileAdded",
"type": "object" "type": "object"
}, },
"EventInstanceConfigUpdated": {
"properties": {
"config": {
"$ref": "#/definitions/InstanceConfig"
}
},
"required": [
"config"
],
"title": "EventInstanceConfigUpdated",
"type": "object"
},
"EventJobCreated": { "EventJobCreated": {
"properties": { "properties": {
"config": { "config": {
@ -5376,10 +5442,30 @@ Each event will be submitted via HTTP POST to the user provided URL.
"regression_reported", "regression_reported",
"file_added", "file_added",
"task_heartbeat", "task_heartbeat",
"node_heartbeat" "node_heartbeat",
"instance_config_updated"
], ],
"title": "EventType" "title": "EventType"
}, },
"InstanceConfig": {
"properties": {
"admins": {
"items": {
"format": "uuid",
"type": "string"
},
"title": "Admins",
"type": "array"
},
"allow_pool_management": {
"default": true,
"title": "Allow Pool Management",
"type": "boolean"
}
},
"title": "InstanceConfig",
"type": "object"
},
"JobConfig": { "JobConfig": {
"properties": { "properties": {
"build": { "build": {
@ -6071,6 +6157,9 @@ Each event will be submitted via HTTP POST to the user provided URL.
}, },
{ {
"$ref": "#/definitions/EventFileAdded" "$ref": "#/definitions/EventFileAdded"
},
{
"$ref": "#/definitions/EventInstanceConfigUpdated"
} }
], ],
"title": "Event" "title": "Event"

View File

@ -0,0 +1,48 @@
#!/usr/bin/env python
#
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
import azure.functions as func
from onefuzztypes.enums import ErrorCode
from onefuzztypes.models import Error
from onefuzztypes.requests import InstanceConfigUpdate
from ..onefuzzlib.config import InstanceConfig
from ..onefuzzlib.endpoint_authorization import call_if_user, can_modify_config
from ..onefuzzlib.events import get_events
from ..onefuzzlib.request import not_ok, ok, parse_request
def get(req: func.HttpRequest) -> func.HttpResponse:
return ok(InstanceConfig.fetch())
def post(req: func.HttpRequest) -> func.HttpResponse:
request = parse_request(InstanceConfigUpdate, req)
if isinstance(request, Error):
return not_ok(request, context="instance_config_update")
config = InstanceConfig.fetch()
if not can_modify_config(req, config):
return not_ok(
Error(code=ErrorCode.INVALID_PERMISSION, errors=["unauthorized"]),
context="instance_config_update",
)
config.update(request.config)
config.save()
return ok(config)
def main(req: func.HttpRequest, dashboard: func.Out[str]) -> func.HttpResponse:
methods = {"GET": get, "POST": post}
method = methods[req.method]
result = call_if_user(req, method)
events = get_events()
if events:
dashboard.set(events)
return result

View File

@ -0,0 +1,26 @@
{
"scriptFile": "__init__.py",
"bindings": [
{
"authLevel": "anonymous",
"type": "httpTrigger",
"direction": "in",
"name": "req",
"methods": [
"get",
"post"
]
},
{
"type": "http",
"direction": "out",
"name": "$return"
},
{
"type": "signalR",
"direction": "out",
"name": "dashboard",
"hubName": "dashboard"
}
]
}

View File

@ -0,0 +1,34 @@
#!/usr/bin/env python
#
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
from typing import Optional, Tuple
from onefuzztypes.events import EventInstanceConfigUpdated
from onefuzztypes.models import InstanceConfig as BASE_CONFIG
from pydantic import Field
from .azure.creds import get_instance_name
from .events import send_event
from .orm import ORMMixin
class InstanceConfig(BASE_CONFIG, ORMMixin):
instance_name: str = Field(default_factory=get_instance_name)
@classmethod
def key_fields(cls) -> Tuple[str, Optional[str]]:
return ("instance_name", None)
@classmethod
def fetch(cls) -> "InstanceConfig":
entry = cls.get(get_instance_name())
if entry is None:
entry = cls()
entry.save()
return entry
def save(self, new: bool = False, require_etag: bool = False) -> None:
super().save(new=new, require_etag=require_etag)
send_event(EventInstanceConfigUpdated(config=self))

View File

@ -4,7 +4,7 @@
# Licensed under the MIT License. # Licensed under the MIT License.
import logging import logging
from typing import Callable from typing import Callable, Optional
from uuid import UUID from uuid import UUID
import azure.functions as func import azure.functions as func
@ -13,6 +13,7 @@ from onefuzztypes.enums import ErrorCode
from onefuzztypes.models import Error, UserInfo from onefuzztypes.models import Error, UserInfo
from .azure.creds import get_scaleset_principal_id from .azure.creds import get_scaleset_principal_id
from .config import InstanceConfig
from .request import not_ok from .request import not_ok
from .user_credentials import parse_jwt_token from .user_credentials import parse_jwt_token
from .workers.pools import Pool from .workers.pools import Pool
@ -43,6 +44,57 @@ def is_agent(token_data: UserInfo) -> bool:
return False return False
def can_modify_config_impl(config: InstanceConfig, user_info: UserInfo) -> bool:
if config.admins is None:
return True
return user_info.object_id in config.admins
def can_modify_config(req: func.HttpRequest, config: InstanceConfig) -> bool:
user_info = parse_jwt_token(req)
if not isinstance(user_info, UserInfo):
return False
return can_modify_config_impl(config, user_info)
def check_can_manage_pools_impl(
config: InstanceConfig, user_info: UserInfo
) -> Optional[Error]:
if config.allow_pool_management:
return None
if config.admins is None:
return Error(code=ErrorCode.UNAUTHORIZED, errors=["pool modification disabled"])
if user_info.object_id in config.admins:
return None
return Error(code=ErrorCode.UNAUTHORIZED, errors=["not authorized to manage pools"])
def check_can_manage_pools(req: func.HttpRequest) -> Optional[Error]:
user_info = parse_jwt_token(req)
if isinstance(user_info, Error):
return user_info
# When there are no admins in the `admins` list, all users are considered
# admins. However, `allow_pool_management` is still useful to protect from
# mistakes.
#
# To make changes while still protecting against accidental changes to
# pools, do the following:
#
# 1. set `allow_pool_management` to `True`
# 2. make the change
# 3. set `allow_pool_management` to `False`
config = InstanceConfig.fetch()
return check_can_manage_pools_impl(config, user_info)
def is_user(token_data: UserInfo) -> bool: def is_user(token_data: UserInfo) -> bool:
return not is_agent(token_data) return not is_agent(token_data)

View File

@ -21,7 +21,7 @@ from ..onefuzzlib.azure.creds import (
from ..onefuzzlib.azure.queue import get_queue_sas from ..onefuzzlib.azure.queue import get_queue_sas
from ..onefuzzlib.azure.storage import StorageType from ..onefuzzlib.azure.storage import StorageType
from ..onefuzzlib.azure.vmss import list_available_skus from ..onefuzzlib.azure.vmss import list_available_skus
from ..onefuzzlib.endpoint_authorization import call_if_user from ..onefuzzlib.endpoint_authorization import call_if_user, check_can_manage_pools
from ..onefuzzlib.events import get_events from ..onefuzzlib.events import get_events
from ..onefuzzlib.request import not_ok, ok, parse_request from ..onefuzzlib.request import not_ok, ok, parse_request
from ..onefuzzlib.workers.pools import Pool from ..onefuzzlib.workers.pools import Pool
@ -78,6 +78,10 @@ def post(req: func.HttpRequest) -> func.HttpResponse:
if isinstance(request, Error): if isinstance(request, Error):
return not_ok(request, context="PoolCreate") return not_ok(request, context="PoolCreate")
answer = check_can_manage_pools(req)
if isinstance(answer, Error):
return not_ok(answer, context="PoolCreate")
pool = Pool.get_by_name(request.name) pool = Pool.get_by_name(request.name)
if isinstance(pool, Pool): if isinstance(pool, Pool):
return not_ok( return not_ok(
@ -130,6 +134,10 @@ def delete(req: func.HttpRequest) -> func.HttpResponse:
if isinstance(request, Error): if isinstance(request, Error):
return not_ok(request, context="PoolDelete") return not_ok(request, context="PoolDelete")
answer = check_can_manage_pools(req)
if isinstance(answer, Error):
return not_ok(answer, context="PoolDelete")
pool = Pool.get_by_name(request.name) pool = Pool.get_by_name(request.name)
if isinstance(pool, Error): if isinstance(pool, Error):
return not_ok(pool, context="pool stop") return not_ok(pool, context="pool stop")

View File

@ -16,7 +16,7 @@ from onefuzztypes.responses import BoolResult
from ..onefuzzlib.azure.creds import get_base_region, get_regions from ..onefuzzlib.azure.creds import get_base_region, get_regions
from ..onefuzzlib.azure.vmss import list_available_skus from ..onefuzzlib.azure.vmss import list_available_skus
from ..onefuzzlib.endpoint_authorization import call_if_user from ..onefuzzlib.endpoint_authorization import call_if_user, check_can_manage_pools
from ..onefuzzlib.events import get_events from ..onefuzzlib.events import get_events
from ..onefuzzlib.request import not_ok, ok, parse_request from ..onefuzzlib.request import not_ok, ok, parse_request
from ..onefuzzlib.workers.pools import Pool from ..onefuzzlib.workers.pools import Pool
@ -48,6 +48,10 @@ def post(req: func.HttpRequest) -> func.HttpResponse:
if isinstance(request, Error): if isinstance(request, Error):
return not_ok(request, context="ScalesetCreate") return not_ok(request, context="ScalesetCreate")
answer = check_can_manage_pools(req)
if isinstance(answer, Error):
return not_ok(answer, context="ScalesetCreate")
# Verify the pool exists # Verify the pool exists
pool = Pool.get_by_name(request.pool_name) pool = Pool.get_by_name(request.pool_name)
if isinstance(pool, Error): if isinstance(pool, Error):
@ -105,6 +109,10 @@ def delete(req: func.HttpRequest) -> func.HttpResponse:
if isinstance(request, Error): if isinstance(request, Error):
return not_ok(request, context="ScalesetDelete") return not_ok(request, context="ScalesetDelete")
answer = check_can_manage_pools(req)
if isinstance(answer, Error):
return not_ok(answer, context="ScalesetDelete")
scaleset = Scaleset.get_by_id(request.scaleset_id) scaleset = Scaleset.get_by_id(request.scaleset_id)
if isinstance(scaleset, Error): if isinstance(scaleset, Error):
return not_ok(scaleset, context="scaleset stop") return not_ok(scaleset, context="scaleset stop")
@ -118,6 +126,10 @@ def patch(req: func.HttpRequest) -> func.HttpResponse:
if isinstance(request, Error): if isinstance(request, Error):
return not_ok(request, context="ScalesetUpdate") return not_ok(request, context="ScalesetUpdate")
answer = check_can_manage_pools(req)
if isinstance(answer, Error):
return not_ok(answer, context="ScalesetUpdate")
scaleset = Scaleset.get_by_id(request.scaleset_id) scaleset = Scaleset.get_by_id(request.scaleset_id)
if isinstance(scaleset, Error): if isinstance(scaleset, Error):
return not_ok(scaleset, context="ScalesetUpdate") return not_ok(scaleset, context="ScalesetUpdate")

View File

@ -0,0 +1,97 @@
#!/usr/bin/env python
#
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
import os
import unittest
from uuid import uuid4
from onefuzztypes.models import UserInfo
from __app__.onefuzzlib.config import InstanceConfig
from __app__.onefuzzlib.endpoint_authorization import (
can_modify_config_impl,
check_can_manage_pools_impl,
)
if "ONEFUZZ_INSTANCE_NAME" not in os.environ:
os.environ["ONEFUZZ_INSTANCE_NAME"] = "test"
class TestAdmin(unittest.TestCase):
def test_modify_config(self) -> None:
user1 = uuid4()
user2 = uuid4()
# no admins set
self.assertTrue(can_modify_config_impl(InstanceConfig(), UserInfo()))
# with oid, but no admin
self.assertTrue(
can_modify_config_impl(InstanceConfig(), UserInfo(object_id=user1))
)
# is admin
self.assertTrue(
can_modify_config_impl(
InstanceConfig(admins=[user1]), UserInfo(object_id=user1)
)
)
# no user oid set
self.assertFalse(
can_modify_config_impl(InstanceConfig(admins=[user1]), UserInfo())
)
# not an admin
self.assertFalse(
can_modify_config_impl(
InstanceConfig(admins=[user1]), UserInfo(object_id=user2)
)
)
def test_manage_pools(self) -> None:
user1 = uuid4()
user2 = uuid4()
# by default, any can modify
self.assertIsNone(
check_can_manage_pools_impl(
InstanceConfig(allow_pool_management=True), UserInfo()
)
)
# with oid, but no admin
self.assertIsNone(
check_can_manage_pools_impl(
InstanceConfig(allow_pool_management=True), UserInfo(object_id=user1)
)
)
# is admin
self.assertIsNone(
check_can_manage_pools_impl(
InstanceConfig(allow_pool_management=False, admins=[user1]),
UserInfo(object_id=user1),
)
)
# no user oid set
self.assertIsNotNone(
check_can_manage_pools_impl(
InstanceConfig(allow_pool_management=False, admins=[user1]), UserInfo()
)
)
# not an admin
self.assertIsNotNone(
check_can_manage_pools_impl(
InstanceConfig(allow_pool_management=False, admins=[user1]),
UserInfo(object_id=user2),
)
)
if __name__ == "__main__":
unittest.main()

View File

@ -1557,6 +1557,22 @@ class ScalesetProxy(Endpoint):
return self._req_model("GET", responses.ProxyList, data=requests.ProxyGet()) return self._req_model("GET", responses.ProxyList, data=requests.ProxyGet())
class InstanceConfigCmd(Endpoint):
"""Interact with Instance Configuration"""
endpoint = "instance_config"
def get(self) -> models.InstanceConfig:
return self._req_model("GET", models.InstanceConfig)
def update(self, config: models.InstanceConfig) -> models.InstanceConfig:
return self._req_model(
"POST",
models.InstanceConfig,
data=requests.InstanceConfigUpdate(config=config),
)
class Command: class Command:
def __init__(self, onefuzz: "Onefuzz", logger: logging.Logger): def __init__(self, onefuzz: "Onefuzz", logger: logging.Logger):
self.onefuzz = onefuzz self.onefuzz = onefuzz
@ -1634,6 +1650,7 @@ class Onefuzz:
self.scalesets = Scaleset(self) self.scalesets = Scaleset(self)
self.nodes = Node(self) self.nodes = Node(self)
self.webhooks = Webhooks(self) self.webhooks = Webhooks(self)
self.instance_config = InstanceConfigCmd(self)
if self._backend.is_feature_enabled(PreviewFeature.job_templates.name): if self._backend.is_feature_enabled(PreviewFeature.job_templates.name):
self.job_templates = JobTemplates(self) self.job_templates = JobTemplates(self)

View File

@ -72,6 +72,7 @@ from registration import (
set_app_audience, set_app_audience,
update_pool_registration, update_pool_registration,
) )
from set_admins import update_admins
# Found by manually assigning the User.Read permission to application # Found by manually assigning the User.Read permission to application
# registration in the admin portal. The values are in the manifest under # registration in the admin portal. The values are in the manifest under
@ -129,6 +130,7 @@ class Client:
multi_tenant_domain: str, multi_tenant_domain: str,
upgrade: bool, upgrade: bool,
subscription_id: Optional[str], subscription_id: Optional[str],
admins: List[UUID]
): ):
self.subscription_id = subscription_id self.subscription_id = subscription_id
self.resource_group = resource_group self.resource_group = resource_group
@ -158,6 +160,7 @@ class Client:
self.migrations = migrations self.migrations = migrations
self.export_appinsights = export_appinsights self.export_appinsights = export_appinsights
self.log_service_principal = log_service_principal self.log_service_principal = log_service_principal
self.admins = admins
machine = platform.machine() machine = platform.machine()
system = platform.system() system = platform.system()
@ -552,12 +555,18 @@ class Client:
) )
def apply_migrations(self) -> None: def apply_migrations(self) -> None:
self.results["deploy"]["func-storage"]["value"]
name = self.results["deploy"]["func-name"]["value"] name = self.results["deploy"]["func-name"]["value"]
key = self.results["deploy"]["func-key"]["value"] key = self.results["deploy"]["func-key"]["value"]
table_service = TableService(account_name=name, account_key=key) table_service = TableService(account_name=name, account_key=key)
migrate(table_service, self.migrations) migrate(table_service, self.migrations)
def set_admins(self) -> None:
name = self.results["deploy"]["func-name"]["value"]
key = self.results["deploy"]["func-key"]["value"]
table_service = TableService(account_name=name, account_key=key)
if self.admins:
update_admins(table_service, self.application_name, self.admins)
def create_queues(self) -> None: def create_queues(self) -> None:
logger.info("creating eventgrid destination queue") logger.info("creating eventgrid destination queue")
@ -916,6 +925,7 @@ def main() -> None:
full_deployment_states = rbac_only_states + [ full_deployment_states = rbac_only_states + [
("apply_migrations", Client.apply_migrations), ("apply_migrations", Client.apply_migrations),
("set_admins", Client.set_admins),
("queues", Client.create_queues), ("queues", Client.create_queues),
("eventgrid", Client.create_eventgrid), ("eventgrid", Client.create_eventgrid),
("tools", Client.upload_tools), ("tools", Client.upload_tools),
@ -1021,6 +1031,12 @@ def main() -> None:
action="store_true", action="store_true",
help="execute only the steps required to create the rbac resources", help="execute only the steps required to create the rbac resources",
) )
parser.add_argument(
"--set_admins",
type=UUID,
nargs="*",
help="set the list of administrators (by OID in AAD)",
)
args = parser.parse_args() args = parser.parse_args()
@ -1048,6 +1064,7 @@ def main() -> None:
multi_tenant_domain=args.multi_tenant_domain, multi_tenant_domain=args.multi_tenant_domain,
upgrade=args.upgrade, upgrade=args.upgrade,
subscription_id=args.subscription_id, subscription_id=args.subscription_id,
admins=args.set_admins,
) )
if args.verbose: if args.verbose:
level = logging.DEBUG level = logging.DEBUG

View File

@ -0,0 +1,60 @@
#!/usr/bin/env python
#
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
import argparse
import json
from typing import List, Optional
from uuid import UUID
from azure.common.client_factory import get_client_from_cli_profile
from azure.cosmosdb.table.tableservice import TableService
from azure.mgmt.storage import StorageManagementClient
TABLE_NAME = "InstanceConfig"
def create_if_missing(table_service: TableService) -> None:
if not table_service.exists(TABLE_NAME):
table_service.create_table(TABLE_NAME)
def update_admins(
table_service: TableService, resource_group: str, admins: List[UUID]
) -> None:
create_if_missing(table_service)
admins_as_str: Optional[List[str]] = None
if admins:
admins_as_str = [str(x) for x in admins]
table_service.insert_or_merge_entity(
TABLE_NAME,
{
"PartitionKey": resource_group,
"RowKey": resource_group,
"admins": json.dumps(admins_as_str),
},
)
def main() -> None:
formatter = argparse.ArgumentDefaultsHelpFormatter
parser = argparse.ArgumentParser(formatter_class=formatter)
parser.add_argument("resource_group")
parser.add_argument("storage_account")
parser.add_argument("admins", type=UUID, nargs="*")
args = parser.parse_args()
client = get_client_from_cli_profile(StorageManagementClient)
storage_keys = client.storage_accounts.list_keys(
args.resource_group, args.storage_account
)
table_service = TableService(
account_name=args.storage_account, account_key=storage_keys.keys[0].value
)
update_admins(table_service, args.resource_group, args.admins)
if __name__ == "__main__":
main()

View File

@ -22,6 +22,7 @@ from onefuzztypes.events import (
Event, Event,
EventCrashReported, EventCrashReported,
EventFileAdded, EventFileAdded,
EventInstanceConfigUpdated,
EventJobCreated, EventJobCreated,
EventJobStopped, EventJobStopped,
EventNodeCreated, EventNodeCreated,
@ -53,6 +54,7 @@ from onefuzztypes.models import (
BlobRef, BlobRef,
CrashTestResult, CrashTestResult,
Error, Error,
InstanceConfig,
JobConfig, JobConfig,
RegressionReport, RegressionReport,
Report, Report,
@ -252,6 +254,7 @@ def main() -> None:
EventFileAdded(container=Container("container-name"), filename="example.txt"), EventFileAdded(container=Container("container-name"), filename="example.txt"),
EventNodeHeartbeat(machine_id=UUID(int=0), pool_name=PoolName("example")), EventNodeHeartbeat(machine_id=UUID(int=0), pool_name=PoolName("example")),
EventTaskHeartbeat(task_id=UUID(int=0), job_id=UUID(int=0), config=task_config), EventTaskHeartbeat(task_id=UUID(int=0), job_id=UUID(int=0), config=task_config),
EventInstanceConfigUpdated(config=InstanceConfig(admins=[UUID(int=0)])),
] ]
# works around `mypy` not handling that Union has `__args__` # works around `mypy` not handling that Union has `__args__`

View File

@ -23,6 +23,7 @@ from .enums import (
from .models import ( from .models import (
AutoScaleConfig, AutoScaleConfig,
Error, Error,
InstanceConfig,
JobConfig, JobConfig,
RegressionReport, RegressionReport,
Report, Report,
@ -200,6 +201,10 @@ class EventFileAdded(BaseEvent):
filename: str filename: str
class EventInstanceConfigUpdated(BaseEvent):
config: InstanceConfig
Event = Union[ Event = Union[
EventJobCreated, EventJobCreated,
EventJobStopped, EventJobStopped,
@ -226,6 +231,7 @@ Event = Union[
EventCrashReported, EventCrashReported,
EventRegressionReported, EventRegressionReported,
EventFileAdded, EventFileAdded,
EventInstanceConfigUpdated,
] ]
@ -255,6 +261,7 @@ class EventType(Enum):
file_added = "file_added" file_added = "file_added"
task_heartbeat = "task_heartbeat" task_heartbeat = "task_heartbeat"
node_heartbeat = "node_heartbeat" node_heartbeat = "node_heartbeat"
instance_config_updated = "instance_config_updated"
EventTypeMap = { EventTypeMap = {
@ -283,6 +290,7 @@ EventTypeMap = {
EventType.crash_reported: EventCrashReported, EventType.crash_reported: EventCrashReported,
EventType.regression_reported: EventRegressionReported, EventType.regression_reported: EventRegressionReported,
EventType.file_added: EventFileAdded, EventType.file_added: EventFileAdded,
EventType.instance_config_updated: EventInstanceConfigUpdated,
} }

View File

@ -856,4 +856,28 @@ class Task(BaseModel):
user_info: Optional[UserInfo] user_info: Optional[UserInfo]
class InstanceConfig(BaseModel):
# initial set of admins can only be set during deployment.
# if admins are set, only admins can update instance configs.
admins: Optional[List[UUID]] = None
# if set, only admins can manage pools or scalesets
allow_pool_management: bool = Field(default=True)
def update(self, config: "InstanceConfig") -> None:
for field in config.__fields__:
# If no admins are set, then ignore setting admins
if field == "admins" and self.admins is None:
continue
if hasattr(self, field):
setattr(self, field, getattr(config, field))
@validator("admins", allow_reuse=True)
def check_admins(cls, value: Optional[List[UUID]]) -> Optional[List[UUID]]:
if value is not None and len(value) == 0:
raise ValueError("admins must be None or contain at least one UUID")
return value
_check_hotfix() _check_hotfix()

View File

@ -20,7 +20,7 @@ from .enums import (
TaskState, TaskState,
) )
from .events import EventType from .events import EventType
from .models import AutoScaleConfig, NotificationConfig from .models import AutoScaleConfig, InstanceConfig, NotificationConfig
from .primitives import Container, PoolName, Region from .primitives import Container, PoolName, Region
@ -253,4 +253,8 @@ class NodeAddSshKey(BaseModel):
public_key: str public_key: str
class InstanceConfigUpdate(BaseModel):
config: InstanceConfig
_check_hotfix() _check_hotfix()

View File

@ -0,0 +1,30 @@
#!/usr/bin/env python
#
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
from uuid import UUID
import unittest
from onefuzztypes.models import InstanceConfig
class TestInstanceConfig(unittest.TestCase):
def test_with_admins(self) -> None:
no_admins = InstanceConfig(admins=None)
with_admins = InstanceConfig(admins=[UUID(int=0)])
with_admins_2 = InstanceConfig(admins=[UUID(int=1)])
no_admins.update(with_admins)
self.assertEqual(no_admins.admins, None)
with_admins.update(with_admins_2)
self.assertEqual(with_admins.admins, with_admins_2.admins)
def test_with_empty_admins(self) -> None:
with self.assertRaises(ValueError):
InstanceConfig.parse_obj({"admins": []})
if __name__ == "__main__":
unittest.main()