mirror of
https://github.com/microsoft/onefuzz.git
synced 2025-06-15 19:38:11 +00:00
instance wide configuration (#1010)
TODO: * [x] add setting initial set of admins during deployment
This commit is contained in:
@ -25,6 +25,7 @@ Each event will be submitted via HTTP POST to the user provided URL.
|
||||
|
||||
* [crash_reported](#crash_reported)
|
||||
* [file_added](#file_added)
|
||||
* [instance_config_updated](#instance_config_updated)
|
||||
* [job_created](#job_created)
|
||||
* [job_stopped](#job_stopped)
|
||||
* [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
|
||||
|
||||
#### Example
|
||||
@ -4792,6 +4846,18 @@ Each event will be submitted via HTTP POST to the user provided URL.
|
||||
"title": "EventFileAdded",
|
||||
"type": "object"
|
||||
},
|
||||
"EventInstanceConfigUpdated": {
|
||||
"properties": {
|
||||
"config": {
|
||||
"$ref": "#/definitions/InstanceConfig"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"config"
|
||||
],
|
||||
"title": "EventInstanceConfigUpdated",
|
||||
"type": "object"
|
||||
},
|
||||
"EventJobCreated": {
|
||||
"properties": {
|
||||
"config": {
|
||||
@ -5376,10 +5442,30 @@ Each event will be submitted via HTTP POST to the user provided URL.
|
||||
"regression_reported",
|
||||
"file_added",
|
||||
"task_heartbeat",
|
||||
"node_heartbeat"
|
||||
"node_heartbeat",
|
||||
"instance_config_updated"
|
||||
],
|
||||
"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": {
|
||||
"properties": {
|
||||
"build": {
|
||||
@ -6071,6 +6157,9 @@ Each event will be submitted via HTTP POST to the user provided URL.
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/EventFileAdded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/EventInstanceConfigUpdated"
|
||||
}
|
||||
],
|
||||
"title": "Event"
|
||||
|
48
src/api-service/__app__/instance_config/__init__.py
Normal file
48
src/api-service/__app__/instance_config/__init__.py
Normal 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
|
26
src/api-service/__app__/instance_config/function.json
Normal file
26
src/api-service/__app__/instance_config/function.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
34
src/api-service/__app__/onefuzzlib/config.py
Normal file
34
src/api-service/__app__/onefuzzlib/config.py
Normal 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))
|
@ -4,7 +4,7 @@
|
||||
# Licensed under the MIT License.
|
||||
|
||||
import logging
|
||||
from typing import Callable
|
||||
from typing import Callable, Optional
|
||||
from uuid import UUID
|
||||
|
||||
import azure.functions as func
|
||||
@ -13,6 +13,7 @@ from onefuzztypes.enums import ErrorCode
|
||||
from onefuzztypes.models import Error, UserInfo
|
||||
|
||||
from .azure.creds import get_scaleset_principal_id
|
||||
from .config import InstanceConfig
|
||||
from .request import not_ok
|
||||
from .user_credentials import parse_jwt_token
|
||||
from .workers.pools import Pool
|
||||
@ -43,6 +44,57 @@ def is_agent(token_data: UserInfo) -> bool:
|
||||
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:
|
||||
return not is_agent(token_data)
|
||||
|
||||
|
@ -21,7 +21,7 @@ from ..onefuzzlib.azure.creds import (
|
||||
from ..onefuzzlib.azure.queue import get_queue_sas
|
||||
from ..onefuzzlib.azure.storage import StorageType
|
||||
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.request import not_ok, ok, parse_request
|
||||
from ..onefuzzlib.workers.pools import Pool
|
||||
@ -78,6 +78,10 @@ def post(req: func.HttpRequest) -> func.HttpResponse:
|
||||
if isinstance(request, Error):
|
||||
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)
|
||||
if isinstance(pool, Pool):
|
||||
return not_ok(
|
||||
@ -130,6 +134,10 @@ def delete(req: func.HttpRequest) -> func.HttpResponse:
|
||||
if isinstance(request, Error):
|
||||
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)
|
||||
if isinstance(pool, Error):
|
||||
return not_ok(pool, context="pool stop")
|
||||
|
@ -16,7 +16,7 @@ from onefuzztypes.responses import BoolResult
|
||||
|
||||
from ..onefuzzlib.azure.creds import get_base_region, get_regions
|
||||
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.request import not_ok, ok, parse_request
|
||||
from ..onefuzzlib.workers.pools import Pool
|
||||
@ -48,6 +48,10 @@ def post(req: func.HttpRequest) -> func.HttpResponse:
|
||||
if isinstance(request, Error):
|
||||
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
|
||||
pool = Pool.get_by_name(request.pool_name)
|
||||
if isinstance(pool, Error):
|
||||
@ -105,6 +109,10 @@ def delete(req: func.HttpRequest) -> func.HttpResponse:
|
||||
if isinstance(request, Error):
|
||||
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)
|
||||
if isinstance(scaleset, Error):
|
||||
return not_ok(scaleset, context="scaleset stop")
|
||||
@ -118,6 +126,10 @@ def patch(req: func.HttpRequest) -> func.HttpResponse:
|
||||
if isinstance(request, Error):
|
||||
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)
|
||||
if isinstance(scaleset, Error):
|
||||
return not_ok(scaleset, context="ScalesetUpdate")
|
||||
|
97
src/api-service/tests/test_auth_check.py
Normal file
97
src/api-service/tests/test_auth_check.py
Normal 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()
|
@ -1557,6 +1557,22 @@ class ScalesetProxy(Endpoint):
|
||||
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:
|
||||
def __init__(self, onefuzz: "Onefuzz", logger: logging.Logger):
|
||||
self.onefuzz = onefuzz
|
||||
@ -1634,6 +1650,7 @@ class Onefuzz:
|
||||
self.scalesets = Scaleset(self)
|
||||
self.nodes = Node(self)
|
||||
self.webhooks = Webhooks(self)
|
||||
self.instance_config = InstanceConfigCmd(self)
|
||||
|
||||
if self._backend.is_feature_enabled(PreviewFeature.job_templates.name):
|
||||
self.job_templates = JobTemplates(self)
|
||||
|
@ -72,6 +72,7 @@ from registration import (
|
||||
set_app_audience,
|
||||
update_pool_registration,
|
||||
)
|
||||
from set_admins import update_admins
|
||||
|
||||
# Found by manually assigning the User.Read permission to application
|
||||
# registration in the admin portal. The values are in the manifest under
|
||||
@ -129,6 +130,7 @@ class Client:
|
||||
multi_tenant_domain: str,
|
||||
upgrade: bool,
|
||||
subscription_id: Optional[str],
|
||||
admins: List[UUID]
|
||||
):
|
||||
self.subscription_id = subscription_id
|
||||
self.resource_group = resource_group
|
||||
@ -158,6 +160,7 @@ class Client:
|
||||
self.migrations = migrations
|
||||
self.export_appinsights = export_appinsights
|
||||
self.log_service_principal = log_service_principal
|
||||
self.admins = admins
|
||||
|
||||
machine = platform.machine()
|
||||
system = platform.system()
|
||||
@ -552,12 +555,18 @@ class Client:
|
||||
)
|
||||
|
||||
def apply_migrations(self) -> None:
|
||||
self.results["deploy"]["func-storage"]["value"]
|
||||
name = self.results["deploy"]["func-name"]["value"]
|
||||
key = self.results["deploy"]["func-key"]["value"]
|
||||
table_service = TableService(account_name=name, account_key=key)
|
||||
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:
|
||||
logger.info("creating eventgrid destination queue")
|
||||
|
||||
@ -916,6 +925,7 @@ def main() -> None:
|
||||
|
||||
full_deployment_states = rbac_only_states + [
|
||||
("apply_migrations", Client.apply_migrations),
|
||||
("set_admins", Client.set_admins),
|
||||
("queues", Client.create_queues),
|
||||
("eventgrid", Client.create_eventgrid),
|
||||
("tools", Client.upload_tools),
|
||||
@ -1021,6 +1031,12 @@ def main() -> None:
|
||||
action="store_true",
|
||||
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()
|
||||
|
||||
@ -1048,6 +1064,7 @@ def main() -> None:
|
||||
multi_tenant_domain=args.multi_tenant_domain,
|
||||
upgrade=args.upgrade,
|
||||
subscription_id=args.subscription_id,
|
||||
admins=args.set_admins,
|
||||
)
|
||||
if args.verbose:
|
||||
level = logging.DEBUG
|
||||
|
60
src/deployment/set_admins.py
Normal file
60
src/deployment/set_admins.py
Normal 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()
|
@ -22,6 +22,7 @@ from onefuzztypes.events import (
|
||||
Event,
|
||||
EventCrashReported,
|
||||
EventFileAdded,
|
||||
EventInstanceConfigUpdated,
|
||||
EventJobCreated,
|
||||
EventJobStopped,
|
||||
EventNodeCreated,
|
||||
@ -53,6 +54,7 @@ from onefuzztypes.models import (
|
||||
BlobRef,
|
||||
CrashTestResult,
|
||||
Error,
|
||||
InstanceConfig,
|
||||
JobConfig,
|
||||
RegressionReport,
|
||||
Report,
|
||||
@ -252,6 +254,7 @@ def main() -> None:
|
||||
EventFileAdded(container=Container("container-name"), filename="example.txt"),
|
||||
EventNodeHeartbeat(machine_id=UUID(int=0), pool_name=PoolName("example")),
|
||||
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__`
|
||||
|
@ -23,6 +23,7 @@ from .enums import (
|
||||
from .models import (
|
||||
AutoScaleConfig,
|
||||
Error,
|
||||
InstanceConfig,
|
||||
JobConfig,
|
||||
RegressionReport,
|
||||
Report,
|
||||
@ -200,6 +201,10 @@ class EventFileAdded(BaseEvent):
|
||||
filename: str
|
||||
|
||||
|
||||
class EventInstanceConfigUpdated(BaseEvent):
|
||||
config: InstanceConfig
|
||||
|
||||
|
||||
Event = Union[
|
||||
EventJobCreated,
|
||||
EventJobStopped,
|
||||
@ -226,6 +231,7 @@ Event = Union[
|
||||
EventCrashReported,
|
||||
EventRegressionReported,
|
||||
EventFileAdded,
|
||||
EventInstanceConfigUpdated,
|
||||
]
|
||||
|
||||
|
||||
@ -255,6 +261,7 @@ class EventType(Enum):
|
||||
file_added = "file_added"
|
||||
task_heartbeat = "task_heartbeat"
|
||||
node_heartbeat = "node_heartbeat"
|
||||
instance_config_updated = "instance_config_updated"
|
||||
|
||||
|
||||
EventTypeMap = {
|
||||
@ -283,6 +290,7 @@ EventTypeMap = {
|
||||
EventType.crash_reported: EventCrashReported,
|
||||
EventType.regression_reported: EventRegressionReported,
|
||||
EventType.file_added: EventFileAdded,
|
||||
EventType.instance_config_updated: EventInstanceConfigUpdated,
|
||||
}
|
||||
|
||||
|
||||
|
@ -856,4 +856,28 @@ class Task(BaseModel):
|
||||
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()
|
||||
|
@ -20,7 +20,7 @@ from .enums import (
|
||||
TaskState,
|
||||
)
|
||||
from .events import EventType
|
||||
from .models import AutoScaleConfig, NotificationConfig
|
||||
from .models import AutoScaleConfig, InstanceConfig, NotificationConfig
|
||||
from .primitives import Container, PoolName, Region
|
||||
|
||||
|
||||
@ -253,4 +253,8 @@ class NodeAddSshKey(BaseModel):
|
||||
public_key: str
|
||||
|
||||
|
||||
class InstanceConfigUpdate(BaseModel):
|
||||
config: InstanceConfig
|
||||
|
||||
|
||||
_check_hotfix()
|
||||
|
30
src/pytypes/tests/test_instance_config_update.py
Normal file
30
src/pytypes/tests/test_instance_config_update.py
Normal 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()
|
Reference in New Issue
Block a user