From 1ffe8d53c053017ffb67bbecd482803cda888439 Mon Sep 17 00:00:00 2001 From: Jonathan Lee Date: Sat, 19 Sep 2026 16:27:12 -0400 Subject: [PATCH 1/2] feat: Neptune graph start/stop control + web operator access on space create Feature 1 - Settings-page control to start/stop the Neptune Analytics topology graph: - New GET/POST /graph/control route (graph-control function) using the non-destructive neptune-graph StartGraph/StopGraph APIs (pause/resume; data + graph id preserved, compute released to stop billing). - Live status badge, timing metrics (created + last start/stop action, persisted to hub/graph_control.json), and auto-polling while transitioning, in an admin-only Settings section. - Least-privilege IAM (Start/Stop/GetGraph + ListGraphs) and env wiring. Feature 2 - Web operator access (Operator Web App) when creating a space: - Provision a default operator-app IAM role (AIDevOpsOperatorAppAccessPolicy, iam auth flow) in the hub and linked-account CloudFormation. - Worker enables the operator app best-effort via EnableOperatorApp; webOperator flag defaults on for single + batch create, with a checkbox in the create form. Verified: typecheck, production build, 469 node tests, 94 pytest tests. --- cloudformation/collector-role.yaml | 51 +++- scripts/create_space_worker.py | 116 ++++++-- scripts/test_create_space_worker.py | 71 ++++- webapp/amplify/backend.ts | 43 +++ .../create-space/createSpace.test.ts | 32 ++- .../functions/graph-control/handler.ts | 72 +++++ .../functions/graph-control/resource.ts | 20 ++ .../amplify/functions/shared/graphControl.ts | 256 ++++++++++++++++++ webapp/amplify/functions/shared/spaces.ts | 16 +- webapp/amplify/spaces/resource.ts | 78 +++++- webapp/packages/shared-types/src/index.ts | 103 ++++++- webapp/src/api/graphControl.ts | 32 +++ webapp/src/lib/graphControlView.test.ts | 81 ++++++ webapp/src/lib/graphControlView.ts | 97 +++++++ webapp/src/views/SettingsView.tsx | 217 ++++++++++++++- webapp/src/views/SpaceView.tsx | 36 ++- 16 files changed, 1278 insertions(+), 43 deletions(-) create mode 100644 webapp/amplify/functions/graph-control/handler.ts create mode 100644 webapp/amplify/functions/graph-control/resource.ts create mode 100644 webapp/amplify/functions/shared/graphControl.ts create mode 100644 webapp/src/api/graphControl.ts create mode 100644 webapp/src/lib/graphControlView.test.ts create mode 100644 webapp/src/lib/graphControlView.ts diff --git a/cloudformation/collector-role.yaml b/cloudformation/collector-role.yaml index bc52d9f..06c5767 100644 --- a/cloudformation/collector-role.yaml +++ b/cloudformation/collector-role.yaml @@ -86,9 +86,13 @@ Resources: Action: - aidevops:CreateAgentSpace - aidevops:AssociateService - # Resource '*' is required: aidevops:CreateAgentSpace and - # aidevops:AssociateService do not support resource-level - # permissions (similar to ecr:GetAuthorizationToken). + # Enable "web operator access" (the Operator Web App) on a + # newly created space, using the operator-app role below. + - aidevops:EnableOperatorApp + - aidevops:GetOperatorApp + # Resource '*' is required: these actions do not support + # resource-level permissions (similar to + # ecr:GetAuthorizationToken). Resource: '*' - !Ref AWS::NoValue # Attaching the primary account passes the monitor role below to @@ -99,7 +103,12 @@ Resources: - Sid: PassMonitorRole Effect: Allow Action: iam:PassRole - Resource: !Sub 'arn:aws:iam::${AWS::AccountId}:role/DevOpsAgentSpaceMonitorRole' + # The monitor role (primary-account association) and the + # operator-app role (web operator access) are both passed to + # the DevOps Agent service. + Resource: + - !Sub 'arn:aws:iam::${AWS::AccountId}:role/DevOpsAgentSpaceMonitorRole' + - !Sub 'arn:aws:iam::${AWS::AccountId}:role/DevOpsAgentOperatorAppRole' Condition: StringEquals: iam:PassedToService: aidevops.amazonaws.com @@ -130,6 +139,36 @@ Resources: ManagedPolicyArns: - arn:aws:iam::aws:policy/AIDevOpsAgentAccessPolicy + # Role end users assume to reach a space's Operator Web App ("web operator + # access") in this account. It is the operatorAppRoleArn passed to + # EnableOperatorApp (auth flow iam) for spaces the hub creates here (mirrors + # the hub account's operator-app role provisioned by the Amplify backend). + # Created only when agent-space creation is enabled, since it is only needed + # then. The operator app tags the assumed session with the AgentSpaceId, so + # the trust allows sts:TagSession in addition to sts:AssumeRole. + OperatorRole: + Type: AWS::IAM::Role + Condition: CreateMonitorRole + Properties: + RoleName: DevOpsAgentOperatorAppRole + Description: Assumed by AWS DevOps Agent Operator Web App users to access spaces in this account (web operator access for spaces created by the hub). + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: aidevops.amazonaws.com + Action: + - sts:AssumeRole + - sts:TagSession + Condition: + StringEquals: + aws:SourceAccount: !Ref AWS::AccountId + ArnLike: + aws:SourceArn: !Sub 'arn:aws:aidevops:*:${AWS::AccountId}:agentspace/*' + ManagedPolicyArns: + - arn:aws:iam::aws:policy/AIDevOpsOperatorAppAccessPolicy + Outputs: CollectorRoleArn: Description: ARN of the collector role (same name in every account). @@ -138,3 +177,7 @@ Outputs: Condition: CreateMonitorRole Description: ARN of the monitor role the DevOps Agent service assumes (present only in non-hub accounts when agent-space creation is enabled). Value: !GetAtt MonitorRole.Arn + OperatorRoleArn: + Condition: CreateMonitorRole + Description: ARN of the operator-app role for web operator access (present only in non-hub accounts when agent-space creation is enabled). + Value: !GetAtt OperatorRole.Arn diff --git a/scripts/create_space_worker.py b/scripts/create_space_worker.py index 3b8191f..ae7f73a 100644 --- a/scripts/create_space_worker.py +++ b/scripts/create_space_worker.py @@ -36,6 +36,12 @@ SERVICE_ID_AWS = "aws" # Convention for the per-account monitor role the DevOps Agent service assumes. DEFAULT_MONITOR_ROLE_NAME = "DevOpsAgentSpaceMonitorRole" +# Convention for the per-account operator-app role end users assume to reach a +# space's Operator Web App ("web operator access"). Mirrors the monitor role. +DEFAULT_OPERATOR_ROLE_NAME = "DevOpsAgentOperatorAppRole" +# Auth flow for EnableOperatorApp. "iam" needs only the operator-app role (the +# "default IAM role for access"); "idc"/"idp" would need extra configuration. +OPERATOR_AUTH_FLOW = "iam" # Keep in sync with shared-types AGENT_SPACE_* bounds (validated again here so a # direct invoke — not only the Node route — can't create out-of-bounds names). @@ -89,6 +95,36 @@ def monitor_role_arn(account_id): return f"arn:aws:iam::{account_id}:role/{role_name}" +def operator_role_arn(account_id): + """ + The IAM role end users assume to reach the space's Operator Web App ("web + operator access"), passed to EnableOperatorApp. For the hub account the role + is provisioned by the app (its ARN is injected as AGENT_OPERATOR_ROLE_ARN); + for other accounts a conventionally-named role in that account is used. + Returns None only when the hub role env is unset for the hub account. + """ + hub = CFG.get("HUB_ACCOUNT_ID") + if hub and account_id == hub: + arn = os.environ.get("AGENT_OPERATOR_ROLE_ARN") + return arn or None + role_name = os.environ.get("AGENT_OPERATOR_ROLE_NAME", DEFAULT_OPERATOR_ROLE_NAME) + return f"arn:aws:iam::{account_id}:role/{role_name}" + + +def enable_operator_app(client, agent_space_id, role_arn): + """ + Enable the space's Operator Web App ("web operator access") with the IAM + auth flow and the given default operator-app role. Returns the response dict + on success; raises ClientError on failure so the caller can record a + best-effort warning without failing the space creation. + """ + return client.enable_operator_app( + agentSpaceId=agent_space_id, + authFlow=OPERATOR_AUTH_FLOW, + operatorAppRoleArn=role_arn, + ) + + def associate_primary_account(client, agent_space_id, account_id, role_arn): """ Associate ``account_id`` as the space's PRIMARY (monitor) account. Returns @@ -108,19 +144,22 @@ def associate_primary_account(client, agent_space_id, account_id, role_arn): )["association"] -def create_space(account_id, name, description=None): +def create_space(account_id, name, description=None, enable_web_operator=True): """ - Create one agent space in ``account_id`` and attach that account as the - space's primary (monitor) account (the minimum useful configuration). Pure - of the Lambda event shape so it is unit-testable. Returns a result dict: + Create one agent space in ``account_id``, attach that account as the space's + primary (monitor) account, and (when ``enable_web_operator``) enable its + Operator Web App ("web operator access") with a default IAM role. Pure of + the Lambda event shape so it is unit-testable. Returns a result dict: success : {"ok": True, "accountId", "agentSpaceId", "name", - "primaryAccountConfigured": bool, "warning"?: str} + "primaryAccountConfigured": bool, "webOperatorEnabled"?: bool, + "warning"?: str} failure : {"ok": False, "code": , "error": } where ``code`` is one of: validation | create_denied | conflict | error. - Primary-account association is best-effort: if it fails (e.g. the monitor - role is missing in a linked account) the space is still created and a - ``warning`` explains that the primary account was not configured. + Both the primary-account association and the operator-app enablement are + best-effort: if either fails (e.g. the required role is missing in a linked + account) the space is still created and a ``warning`` explains what was not + configured. Multiple warnings are joined into the single ``warning`` field. """ invalid = _validate(account_id, name, description) if invalid: @@ -162,27 +201,52 @@ def create_space(account_id, name, description=None): "primaryAccountConfigured": False, } + warnings = [] + # Attach the hosting account as the primary (monitor) account. Best-effort: # the space already exists, so a failure here is a warning, not an error. role_arn = monitor_role_arn(account_id) if not role_arn: - result["warning"] = ( - "The space was created but no monitor role is configured for the hub " - "account (AGENT_MONITOR_ROLE_ARN is unset), so the primary account " - "was not attached." - ) - return result - try: - associate_primary_account(client, agent_space_id, account_id, role_arn) - result["primaryAccountConfigured"] = True - except ClientError as e: - code = e.response["Error"]["Code"] - msg = e.response["Error"]["Message"][:200] - result["warning"] = ( - f"The space was created but the primary account could not be attached " - f"({code}: {msg}). Ensure role {role_arn} exists in account {account_id} " - "and trusts aidevops.amazonaws.com." + warnings.append( + "no monitor role is configured for the hub account " + "(AGENT_MONITOR_ROLE_ARN is unset), so the primary account was not attached" ) + else: + try: + associate_primary_account(client, agent_space_id, account_id, role_arn) + result["primaryAccountConfigured"] = True + except ClientError as e: + code = e.response["Error"]["Code"] + msg = e.response["Error"]["Message"][:200] + warnings.append( + f"the primary account could not be attached ({code}: {msg}); ensure role " + f"{role_arn} exists in account {account_id} and trusts aidevops.amazonaws.com" + ) + + # Enable "web operator access" (the Operator Web App) with a default IAM + # role. Best-effort and independent of the primary-account step above. + if enable_web_operator: + result["webOperatorEnabled"] = False + op_role_arn = operator_role_arn(account_id) + if not op_role_arn: + warnings.append( + "no operator-app role is configured for the hub account " + "(AGENT_OPERATOR_ROLE_ARN is unset), so web operator access was not enabled" + ) + else: + try: + enable_operator_app(client, agent_space_id, op_role_arn) + result["webOperatorEnabled"] = True + except ClientError as e: + code = e.response["Error"]["Code"] + msg = e.response["Error"]["Message"][:200] + warnings.append( + f"web operator access could not be enabled ({code}: {msg}); ensure role " + f"{op_role_arn} exists in account {account_id} and trusts aidevops.amazonaws.com" + ) + + if warnings: + result["warning"] = "The space was created but " + "; ".join(warnings) + "." return result @@ -191,4 +255,6 @@ def handler(event, _context=None): account_id = _field(event, "accountId", "account", "id", "Account") name = _field(event, "name", "spaceName", "Name") description = _field(event, "description", "Description") - return create_space(account_id, name, description) + # Web operator access defaults ON; the Node route sends an explicit boolean. + enable_web_operator = event.get("webOperator", True) if isinstance(event, dict) else True + return create_space(account_id, name, description, enable_web_operator=bool(enable_web_operator)) diff --git a/scripts/test_create_space_worker.py b/scripts/test_create_space_worker.py index 242856e..c203152 100644 --- a/scripts/test_create_space_worker.py +++ b/scripts/test_create_space_worker.py @@ -30,12 +30,15 @@ def _load(mod_name, filename): class FakeClient: """A fake devops-agent client whose create/associate calls are scriptable.""" - def __init__(self, *, result=None, error_code=None, assoc_error_code=None): + def __init__(self, *, result=None, error_code=None, assoc_error_code=None, + operator_error_code=None): self._result = result self._error_code = error_code self._assoc_error_code = assoc_error_code + self._operator_error_code = operator_error_code self.calls = [] # create_agent_space calls self.assoc_calls = [] # associate_service calls + self.operator_calls = [] # enable_operator_app calls def create_agent_space(self, **kwargs): self.calls.append(kwargs) @@ -55,6 +58,15 @@ def associate_service(self, **kwargs): ) return {"association": {"associationId": "assoc-1", "serviceId": "aws"}} + def enable_operator_app(self, **kwargs): + self.operator_calls.append(kwargs) + if self._operator_error_code: + raise ClientError( + {"Error": {"Code": self._operator_error_code, "Message": "operator boom"}}, + "EnableOperatorApp", + ) + return {"agentSpaceId": kwargs.get("agentSpaceId"), "operatorAppUrl": "https://example"} + class FakeSession: def __init__(self, client): @@ -78,6 +90,7 @@ def test_create_space_success_attaches_primary_account(monkeypatch): assert out["ok"] is True assert out["agentSpaceId"] == "as-123" assert out["primaryAccountConfigured"] is True + assert out["webOperatorEnabled"] is True assert "warning" not in out # description forwarded only when provided assert client.calls == [{"name": "devops-agent-111", "description": "desc"}] @@ -90,6 +103,62 @@ def test_create_space_success_attaches_primary_account(monkeypatch): assert assoc["configuration"]["aws"]["assumableRoleArn"].endswith( ":role/DevOpsAgentSpaceMonitorRole" ) + # Web operator access is enabled with the IAM auth flow + operator-app role. + assert len(client.operator_calls) == 1 + op = client.operator_calls[0] + assert op["agentSpaceId"] == "as-123" + assert op["authFlow"] == "iam" + assert op["operatorAppRoleArn"].endswith(":role/DevOpsAgentOperatorAppRole") + + +def test_web_operator_can_be_disabled(monkeypatch): + monkeypatch.setitem(w.CFG, "HUB_ACCOUNT_ID", "000000000000") + client = FakeClient(result={"agentSpaceId": "as-123", "name": "n"}) + _patch(monkeypatch, client) + out = w.create_space("111111111111", "n", enable_web_operator=False) + assert out["ok"] is True + assert "webOperatorEnabled" not in out + assert client.operator_calls == [] + + +def test_web_operator_failure_is_a_warning_not_an_error(monkeypatch): + monkeypatch.setitem(w.CFG, "HUB_ACCOUNT_ID", "000000000000") + client = FakeClient( + result={"agentSpaceId": "as-9", "name": "n"}, operator_error_code="AccessDeniedException" + ) + _patch(monkeypatch, client) + out = w.create_space("222222222222", "n") + # Space + primary account still fine; web operator failure is a warning. + assert out["ok"] is True + assert out["primaryAccountConfigured"] is True + assert out["webOperatorEnabled"] is False + assert "AccessDeniedException" in out["warning"] + + +def test_hub_account_without_operator_role_env_warns(monkeypatch): + monkeypatch.setitem(w.CFG, "HUB_ACCOUNT_ID", "999999999999") + monkeypatch.setenv("AGENT_MONITOR_ROLE_ARN", "arn:aws:iam::999999999999:role/HubMonitor") + monkeypatch.delenv("AGENT_OPERATOR_ROLE_ARN", raising=False) + client = FakeClient(result={"agentSpaceId": "as-h", "name": "hub"}) + _patch(monkeypatch, client) + out = w.create_space("999999999999", "hub") + assert out["webOperatorEnabled"] is False + assert "AGENT_OPERATOR_ROLE_ARN" in out["warning"] + # No operator enablement attempted when no operator role is available. + assert client.operator_calls == [] + + +def test_hub_account_uses_operator_role_arn_env(monkeypatch): + monkeypatch.setitem(w.CFG, "HUB_ACCOUNT_ID", "999999999999") + monkeypatch.setenv("AGENT_MONITOR_ROLE_ARN", "arn:aws:iam::999999999999:role/HubMonitor") + monkeypatch.setenv("AGENT_OPERATOR_ROLE_ARN", "arn:aws:iam::999999999999:role/HubOperator") + client = FakeClient(result={"agentSpaceId": "as-h", "name": "hub"}) + _patch(monkeypatch, client) + out = w.create_space("999999999999", "hub") + assert out["webOperatorEnabled"] is True + assert client.operator_calls[0]["operatorAppRoleArn"] == ( + "arn:aws:iam::999999999999:role/HubOperator" + ) def test_create_space_omits_empty_description(monkeypatch): diff --git a/webapp/amplify/backend.ts b/webapp/amplify/backend.ts index 81eae39..1857881 100644 --- a/webapp/amplify/backend.ts +++ b/webapp/amplify/backend.ts @@ -21,6 +21,7 @@ import { summary } from './functions/summary/resource'; import { spaces } from './functions/spaces/resource'; import { dashboard } from './functions/dashboard/resource'; import { graph } from './functions/graph/resource'; +import { graphControl } from './functions/graph-control/resource'; import { context } from './functions/context/resource'; import { chat } from './functions/chat/resource'; import { mcpTools } from './functions/mcp-tools/resource'; @@ -64,6 +65,7 @@ export const backend = defineBackend({ spaces, dashboard, graph, + graphControl, context, chat, mcpTools, @@ -334,6 +336,11 @@ route( ); route('/dashboard', HttpMethod.GET, backend.dashboard.resources.lambda, 'DashboardIntegration'); route('/graph', HttpMethod.GET, backend.graph.resources.lambda, 'GraphIntegration'); +// Graph control: GET status (any) + POST start/stop (Admin, asserted in-handler) +// of the Neptune Analytics topology graph. `/graph/control` is a distinct path +// from `/graph` above (exact matches win), sharing one handler. +route('/graph/control', HttpMethod.GET, backend.graphControl.resources.lambda, 'GraphControlStatusIntegration'); +route('/graph/control', HttpMethod.POST, backend.graphControl.resources.lambda, 'GraphControlActionIntegration'); // Business context: GET (any) + PUT (Admin, asserted in-handler) share a handler. route('/context', HttpMethod.GET, backend.context.resources.lambda, 'ContextGetIntegration'); route('/context', HttpMethod.PUT, backend.context.resources.lambda, 'ContextPutIntegration'); @@ -381,6 +388,42 @@ backend.graph.resources.lambda.addToRolePolicy( }), ); +// /graph/control — Admin start/stop (pause/resume) of the Neptune Analytics +// graph. StartGraph/StopGraph/GetGraph are scoped to the account's graph ARNs; +// ListGraphs (used to resolve the graph by name) does NOT support resource-level +// permissions, so it is granted on `*`. No create/delete grants — start/stop is +// non-destructive, so the handler cannot provision or destroy a graph. +backend.graphControl.resources.lambda.addToRolePolicy( + new PolicyStatement({ + actions: ['neptune-graph:ListGraphs'], + resources: ['*'], + }), +); +backend.graphControl.resources.lambda.addToRolePolicy( + new PolicyStatement({ + actions: [ + 'neptune-graph:GetGraph', + 'neptune-graph:StartGraph', + 'neptune-graph:StopGraph', + ], + resources: [`arn:aws:neptune-graph:${region}:${account}:graph/*`], + }), +); +// The last start/stop action + timestamp are persisted to a single S3 object so +// the status can show "when it was stopped" even after the graph is gone. +backend.graphControl.resources.lambda.addToRolePolicy( + new PolicyStatement({ + actions: ['s3:GetObject', 's3:PutObject'], + resources: [objectArn('hub/graph_control.json')], + }), +); +{ + const lambda = backend.graphControl.resources.lambda as LambdaFunction; + lambda.addEnvironment('NEPTUNE_GRAPH_NAME', config.neptuneGraphName); + lambda.addEnvironment('HUB_BUCKET', config.hubBucket); + lambda.addEnvironment('HUB_REGION', region); +} + // /context — read + write the single business-context object (Task 5.2). backend.context.resources.lambda.addToRolePolicy( new PolicyStatement({ diff --git a/webapp/amplify/functions/create-space/createSpace.test.ts b/webapp/amplify/functions/create-space/createSpace.test.ts index 11750e0..0f5d47b 100644 --- a/webapp/amplify/functions/create-space/createSpace.test.ts +++ b/webapp/amplify/functions/create-space/createSpace.test.ts @@ -33,13 +33,27 @@ test('accepts a valid request and trims fields', () => { accountId: '123456789012', name: 'my-space', description: 'hello', + // Web operator access defaults ON. + webOperator: true, }); }); +test('webOperator defaults on and is only disabled by an explicit false', () => { + const on = validateCreateSpaceInput({ accountId: '123456789012', name: 'n' }); + assert.equal(on.valid && on.request.webOperator, true); + const off = validateCreateSpaceInput({ accountId: '123456789012', name: 'n', webOperator: false }); + assert.equal(off.valid && off.request.webOperator, false); + // A non-boolean (e.g. a stray string) does not disable it. + const weird = validateCreateSpaceInput({ accountId: '123456789012', name: 'n', webOperator: 'no' }); + assert.equal(weird.valid && weird.request.webOperator, true); +}); + test('omits an empty description', () => { const out = validateCreateSpaceInput({ accountId: '123456789012', name: 'n', description: ' ' }); assert.equal(out.valid, true); assert.equal(out.valid && 'description' in out.request, false); + // webOperator is still present (it always defaults on). + assert.equal(out.valid && out.request.webOperator, true); }); test('rejects a non-object body', () => { @@ -92,6 +106,20 @@ test('a success without a name omits the name', () => { assert.equal('name' in space, false); }); +test('maps the worker web-operator + warning fields through on success', () => { + const space = mapWorkerResult({ + ok: true, + accountId: '123456789012', + agentSpaceId: 'as-abc', + primaryAccountConfigured: true, + webOperatorEnabled: false, + warning: 'web operator access could not be enabled', + }); + assert.equal(space.webOperatorEnabled, false); + assert.equal(space.primaryAccountConfigured, true); + assert.match(space.warning ?? '', /web operator/); +}); + test('conflict maps to a ConflictError (409)', () => { assert.throws( () => mapWorkerResult({ ok: false, code: 'conflict', error: 'exists' }), @@ -138,8 +166,8 @@ test('batch: accepts known accounts, de-duplicates, and names each space', () => if (!out.valid) return; assert.deepEqual(out.accountIds, ['111111111111', '222222222222']); assert.deepEqual(out.requests, [ - { accountId: '111111111111', name: defaultSpaceName('111111111111') }, - { accountId: '222222222222', name: defaultSpaceName('222222222222') }, + { accountId: '111111111111', name: defaultSpaceName('111111111111'), webOperator: true }, + { accountId: '222222222222', name: defaultSpaceName('222222222222'), webOperator: true }, ]); }); diff --git a/webapp/amplify/functions/graph-control/handler.ts b/webapp/amplify/functions/graph-control/handler.ts new file mode 100644 index 0000000..258b001 --- /dev/null +++ b/webapp/amplify/functions/graph-control/handler.ts @@ -0,0 +1,72 @@ +import type { + GraphControlActionResponse, + GraphControlAction, +} from '@devops-observatory/shared-types'; +import { assertAdmin } from '../shared/authz'; +import { ConflictError, UpstreamUnavailableError, ValidationError } from '../shared/errors'; +import { + GraphControlConflict, + getGraphStatus, + startGraph, + stopGraph, +} from '../shared/graphControl'; +import { jsonResponse, withErrorHandling, type ApiEvent, type ApiResult } from '../shared/http'; + +/** + * Neptune Analytics graph control route. + * + * - `GET /graph/control` — any authenticated user. Returns the current graph + * status (exists / transitioning / state) plus timing metrics so the Settings + * view can render the control and poll while a start/stop is in flight. + * - `POST /graph/control` — Admin only. {@link assertAdmin} runs BEFORE any AWS + * call and throws 401/403 for missing identities / Executive callers, so a + * denied request changes nothing. Body: `{ "action": "start" | "stop" }`. + * `stop` deletes the graph (taking a final snapshot); `start` restores it from + * the latest snapshot or creates it fresh. An already-running `start` or + * already-stopped `stop` is a 409 conflict. + */ +export const handler = withErrorHandling(async (event: ApiEvent): Promise => { + const method = event.requestContext.http.method.toUpperCase(); + if (method === 'POST') { + return handlePost(event); + } + return jsonResponse(await getGraphStatus()); +}); + +async function handlePost(event: ApiEvent): Promise { + assertAdmin(event); // authz gate — denials make no AWS call and change no state + + const action = parseAction(event); + try { + const status = action === 'start' ? await startGraph() : await stopGraph(); + const response: GraphControlActionResponse = { action, status }; + return jsonResponse(response); + } catch (err) { + if (err instanceof GraphControlConflict) { + throw new ConflictError(err.message); + } + throw new UpstreamUnavailableError( + `The graph could not be ${action === 'start' ? 'started' : 'stopped'} right now. Please try again.`, + ); + } +} + +/** Parse + validate the `action` field from the request body. */ +function parseAction(event: ApiEvent): GraphControlAction { + const raw = event.body ?? ''; + const decoded = event.isBase64Encoded ? Buffer.from(raw, 'base64').toString('utf-8') : raw; + if (decoded.trim() === '') { + throw new ValidationError('An action is required: "start" or "stop".'); + } + let body: unknown; + try { + body = JSON.parse(decoded); + } catch { + throw new ValidationError('The request body must be valid JSON.'); + } + const action = (body as { action?: unknown }).action; + if (action !== 'start' && action !== 'stop') { + throw new ValidationError('action must be "start" or "stop".'); + } + return action; +} diff --git a/webapp/amplify/functions/graph-control/resource.ts b/webapp/amplify/functions/graph-control/resource.ts new file mode 100644 index 0000000..070844c --- /dev/null +++ b/webapp/amplify/functions/graph-control/resource.ts @@ -0,0 +1,20 @@ +import { defineFunction } from '@aws-amplify/backend'; + +/** + * Graph control route (`GET /graph/control` any, `POST /graph/control` Admin). + * + * Starts/stops the Neptune Analytics topology graph and reports its status + + * timing metrics for the Admin Settings view. Served over the JWT-authorized + * HTTP API; the POST path re-asserts the Admin group in-handler. Own execution + * role; least-privilege `neptune-graph` + S3 access is granted in `backend.ts`. + * + * Neptune Analytics has no pause/resume — stop deletes the graph (with a final + * snapshot) and start recreates it (see `../shared/graphControl.ts`) — so the + * timeout is generous enough for the ListGraphs/GetGraph/snapshot lookups the + * status call makes, while the create/delete calls themselves return quickly. + */ +export const graphControl = defineFunction({ + name: 'graph-control', + entry: './handler.ts', + timeoutSeconds: 30, +}); diff --git a/webapp/amplify/functions/shared/graphControl.ts b/webapp/amplify/functions/shared/graphControl.ts new file mode 100644 index 0000000..3e70c87 --- /dev/null +++ b/webapp/amplify/functions/shared/graphControl.ts @@ -0,0 +1,256 @@ +import { + GetGraphCommand, + ListGraphsCommand, + NeptuneGraphClient, + StartGraphCommand, + StopGraphCommand, +} from '@aws-sdk/client-neptune-graph'; +import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import type { + GraphControlAction, + GraphControlStatus, + GraphLifecycleState, +} from '@devops-observatory/shared-types'; + +/** + * Start/stop + status of the Neptune Analytics topology graph, backing the + * Admin Settings "Topology graph" control (`GET`/`POST /graph/control`). + * + * Neptune Analytics supports a true PAUSE/RESUME via the `neptune-graph` + * StartGraph / StopGraph APIs — non-destructive, so the graph, its id, and its + * data all survive; only compute is released while stopped, which is what stops + * the m-NCU-hour charge: + * - stop → {@link StopGraphCommand}: AVAILABLE → STOPPING → STOPPED. + * - start → {@link StartGraphCommand}: STOPPED → STARTING → AVAILABLE. + * Both return immediately with the transitional status; the SPA polls + * {@link getGraphStatus} until it settles. + * + * The graph is resolved by NAME (config `NEPTUNE_GRAPH_NAME`) so the control + * works regardless of the endpoint-derived id. Timing metrics come from the + * service (`createTime`) plus a tiny S3 state object recording the last + * app-initiated action (so "last stopped" survives across cold starts). + */ + +/** Neptune Analytics statuses that mean "in transition" (keep polling). */ +const TRANSITIONING_STATES: ReadonlySet = new Set([ + 'CREATING', + 'UPDATING', + 'DELETING', + 'RESETTING', + 'SNAPSHOTTING', + 'IMPORTING', + 'STARTING', + 'STOPPING', +]); + +/** S3 object recording the last start/stop action initiated from the app. */ +const STATE_KEY = 'hub/graph_control.json'; + +interface GraphControlConfig { + graphName: string; + region: string; + bucket: string; +} + +/** Resolve config from env (injected in backend.ts), mirroring `config.env`. */ +function getConfig(): GraphControlConfig { + return { + graphName: process.env.NEPTUNE_GRAPH_NAME ?? 'devops-agent-topology', + region: process.env.HUB_REGION ?? process.env.AWS_REGION ?? 'us-east-1', + bucket: process.env.HUB_BUCKET ?? 'devops-agent-hub-123456789012-us-east-1', + }; +} + +let cachedGraphClient: NeptuneGraphClient | undefined; +function graphClient(): NeptuneGraphClient { + if (!cachedGraphClient) { + cachedGraphClient = new NeptuneGraphClient({ region: getConfig().region }); + } + return cachedGraphClient; +} + +let cachedS3: S3Client | undefined; +function s3(): S3Client { + if (!cachedS3) { + cachedS3 = new S3Client({ region: getConfig().region }); + } + return cachedS3; +} + +/** Raised for expected control-flow conflicts (already running / already stopped). */ +export class GraphControlConflict extends Error {} + +/** Normalize a raw `neptune-graph` status string to our lifecycle enum. */ +function toLifecycleState(raw: string | undefined): GraphLifecycleState { + switch ((raw ?? '').toUpperCase()) { + case 'AVAILABLE': + return 'AVAILABLE'; + case 'STOPPED': + return 'STOPPED'; + case 'STARTING': + return 'STARTING'; + case 'STOPPING': + return 'STOPPING'; + case 'CREATING': + return 'CREATING'; + case 'UPDATING': + return 'UPDATING'; + case 'DELETING': + return 'DELETING'; + case 'RESETTING': + return 'RESETTING'; + case 'SNAPSHOTTING': + return 'SNAPSHOTTING'; + case 'IMPORTING': + return 'IMPORTING'; + case 'FAILED': + return 'FAILED'; + default: + return 'UNKNOWN'; + } +} + +interface ResolvedGraph { + id: string; + state: GraphLifecycleState; + createdAt?: string; +} + +/** Find the graph by NAME, returning its id, state, and create time. */ +async function findGraphByName(): Promise { + const { graphName } = getConfig(); + let nextToken: string | undefined; + do { + const page = await graphClient().send(new ListGraphsCommand({ nextToken })); + for (const g of page.graphs ?? []) { + if (g.name === graphName && g.id) { + // GetGraph gives the authoritative status + createTime. + try { + const detail = await graphClient().send(new GetGraphCommand({ graphIdentifier: g.id })); + return { + id: g.id, + state: toLifecycleState(detail.status ?? g.status), + createdAt: detail.createTime ? detail.createTime.toISOString() : undefined, + }; + } catch { + return { id: g.id, state: toLifecycleState(g.status) }; + } + } + } + nextToken = page.nextToken; + } while (nextToken); + return undefined; +} + +async function readState(): Promise<{ lastAction?: GraphControlAction; lastActionAt?: string }> { + const { bucket } = getConfig(); + try { + const res = await s3().send(new GetObjectCommand({ Bucket: bucket, Key: STATE_KEY })); + const text = (await res.Body?.transformToString('utf-8')) ?? ''; + const raw = JSON.parse(text) as Record; + const action = + raw.lastAction === 'start' || raw.lastAction === 'stop' ? raw.lastAction : undefined; + return { + lastAction: action, + lastActionAt: typeof raw.lastActionAt === 'string' ? raw.lastActionAt : undefined, + }; + } catch { + return {}; + } +} + +/** Record the last app-initiated action (best-effort; never throws). */ +async function writeState(action: GraphControlAction): Promise { + const { bucket } = getConfig(); + try { + await s3().send( + new PutObjectCommand({ + Bucket: bucket, + Key: STATE_KEY, + Body: JSON.stringify({ lastAction: action, lastActionAt: new Date().toISOString() }), + ContentType: 'application/json', + }), + ); + } catch { + /* metrics are best-effort — the action itself already succeeded */ + } +} + +/** Shape a {@link GraphControlStatus} from the resolved graph + persisted state. */ +function toStatus( + graph: ResolvedGraph | undefined, + state: { lastAction?: GraphControlAction; lastActionAt?: string }, +): GraphControlStatus { + const { graphName } = getConfig(); + if (!graph) { + return { + provisioned: false, + running: false, + state: 'DELETED', + transitioning: false, + graphName, + ...(state.lastAction ? { lastAction: state.lastAction } : {}), + ...(state.lastActionAt ? { lastActionAt: state.lastActionAt } : {}), + }; + } + return { + provisioned: true, + running: graph.state === 'AVAILABLE', + state: graph.state, + transitioning: TRANSITIONING_STATES.has(graph.state), + graphName, + graphId: graph.id, + ...(graph.createdAt ? { createdAt: graph.createdAt } : {}), + ...(state.lastAction ? { lastAction: state.lastAction } : {}), + ...(state.lastActionAt ? { lastActionAt: state.lastActionAt } : {}), + }; +} + +/** Current graph status + timing metrics for `GET /graph/control`. */ +export async function getGraphStatus(): Promise { + const [graph, state] = await Promise.all([findGraphByName(), readState()]); + return toStatus(graph, state); +} + +/** + * START (resume) the graph. Rejects with {@link GraphControlConflict} when no + * graph is provisioned, it is already running, or it is transitioning. + */ +export async function startGraph(): Promise { + const current = await findGraphByName(); + if (!current) { + throw new GraphControlConflict( + 'No topology graph is provisioned. Run a data refresh to provision it first.', + ); + } + if (current.state === 'AVAILABLE') { + throw new GraphControlConflict('The graph is already running.'); + } + if (current.state !== 'STOPPED') { + throw new GraphControlConflict(`The graph is currently ${current.state.toLowerCase()}.`); + } + await graphClient().send(new StartGraphCommand({ graphIdentifier: current.id })); + await writeState('start'); + return getGraphStatus(); +} + +/** + * STOP (pause) the graph, releasing compute so it stops billing (data is + * preserved). Rejects with {@link GraphControlConflict} when no graph is + * provisioned, it is already stopped, or it is transitioning. + */ +export async function stopGraph(): Promise { + const current = await findGraphByName(); + if (!current) { + throw new GraphControlConflict('No topology graph is provisioned.'); + } + if (current.state === 'STOPPED') { + throw new GraphControlConflict('The graph is already stopped.'); + } + if (current.state !== 'AVAILABLE') { + throw new GraphControlConflict(`The graph is currently ${current.state.toLowerCase()}.`); + } + await graphClient().send(new StopGraphCommand({ graphIdentifier: current.id })); + await writeState('stop'); + return getGraphStatus(); +} diff --git a/webapp/amplify/functions/shared/spaces.ts b/webapp/amplify/functions/shared/spaces.ts index 2eeea9c..e372ce0 100644 --- a/webapp/amplify/functions/shared/spaces.ts +++ b/webapp/amplify/functions/shared/spaces.ts @@ -37,6 +37,8 @@ export interface WorkerResult { agentSpaceId?: string; name?: string; primaryAccountConfigured?: boolean; + /** Whether the Operator Web App ("web operator access") was enabled. */ + webOperatorEnabled?: boolean; warning?: string; code?: 'validation' | 'create_denied' | 'conflict' | 'error'; error?: string; @@ -60,6 +62,8 @@ export function validateCreateSpaceInput( const accountId = typeof raw.accountId === 'string' ? raw.accountId.trim() : ''; const name = typeof raw.name === 'string' ? raw.name.trim() : ''; const descriptionRaw = typeof raw.description === 'string' ? raw.description.trim() : ''; + // "Web operator access" defaults ON; only an explicit `false` disables it. + const webOperator = raw.webOperator === false ? false : true; if (!ACCOUNT_ID_RE.test(accountId)) { return { valid: false, message: 'A valid 12-digit AWS account id is required.' }; @@ -76,7 +80,7 @@ export function validateCreateSpaceInput( message: `The description must be at most ${AGENT_SPACE_DESCRIPTION_MAX_LENGTH} characters.`, }; } - const request: CreateSpaceRequest = { accountId, name }; + const request: CreateSpaceRequest = { accountId, name, webOperator }; if (descriptionRaw.length > 0) request.description = descriptionRaw; return { valid: true, request }; } @@ -100,6 +104,9 @@ export function mapWorkerResult(result: WorkerResult): CreatedSpace { ...(typeof result.primaryAccountConfigured === 'boolean' ? { primaryAccountConfigured: result.primaryAccountConfigured } : {}), + ...(typeof result.webOperatorEnabled === 'boolean' + ? { webOperatorEnabled: result.webOperatorEnabled } + : {}), ...(result.warning ? { warning: result.warning } : {}), }; } @@ -221,7 +228,12 @@ export function validateBatchCreateInput( }.`, }; } - const requests = accountIds.map((accountId) => ({ accountId, name: defaultSpaceName(accountId) })); + // Batch-created starter spaces get web operator access by default too. + const requests = accountIds.map((accountId) => ({ + accountId, + name: defaultSpaceName(accountId), + webOperator: true, + })); return { valid: true, requests, accountIds }; } diff --git a/webapp/amplify/spaces/resource.ts b/webapp/amplify/spaces/resource.ts index aa504a6..82fe9b4 100644 --- a/webapp/amplify/spaces/resource.ts +++ b/webapp/amplify/spaces/resource.ts @@ -24,6 +24,16 @@ import { Construct } from 'constructs'; */ export const AGENT_MONITOR_ROLE_NAME = 'DevOpsAgentSpaceMonitorRole'; +/** + * Conventional operator-app role name used in LINKED/management accounts (the + * role end users assume to reach a space's Operator Web App, "web operator + * access"). Mirrors {@link AGENT_MONITOR_ROLE_NAME}: the HUB account's operator + * role is created by this construct with a CDK-generated unique name (its ARN is + * injected as AGENT_OPERATOR_ROLE_ARN), while linked accounts use this fixed + * name (created by the collector-role StackSet when space creation is enabled). + */ +export const AGENT_OPERATOR_ROLE_NAME = 'DevOpsAgentOperatorAppRole'; + export interface CreateSpaceWorkerProps { /** Region hosting the hub resources. */ readonly hubRegion: string; @@ -67,6 +77,12 @@ export class CreateSpaceWorker extends Construct { */ public readonly monitorRole: Role; + /** + * The default IAM role backing "web operator access" — the Operator Web App + * end users assume to reach a hub space (EnableOperatorApp, auth flow `iam`). + */ + public readonly operatorRole: Role; + constructor(scope: Construct, id: string, props: CreateSpaceWorkerProps) { super(scope, id); @@ -106,6 +122,54 @@ export class CreateSpaceWorker extends Construct { 'Assumed by the AWS DevOps Agent service to monitor the hub account (primary-account association for spaces created from the app).', }); + // --------------------------------------------------------------------- + // Operator-app role for "web operator access" (the Operator Web App). + // + // When a new agent space is created we optionally enable its Operator Web + // App via EnableOperatorApp with auth flow `iam`, which requires an IAM role + // end users assume to reach the app's AIDevOps APIs. This provisions that + // default role for the hub account with the trust the CLI onboarding guide + // prescribes: the `aidevops.amazonaws.com` service principal with BOTH + // sts:AssumeRole and sts:TagSession (the operator app tags the session with + // the AgentSpaceId), scoped by aws:SourceAccount + aws:SourceArn to any + // agent space in the hub account. Permissions come from the AWS-managed + // AIDevOpsOperatorAppAccessPolicy, which scopes access to the specific space + // via the aws:PrincipalTag/AgentSpaceId condition. + // --------------------------------------------------------------------- + this.operatorRole = new Role(this, 'AgentSpaceOperatorRole', { + // No explicit roleName — a CDK-generated unique name avoids a cross-branch + // collision in the shared hub account (the worker uses the role ARN). + assumedBy: new ServicePrincipal('aidevops.amazonaws.com', { + conditions: { + StringEquals: { 'aws:SourceAccount': hubAccountId }, + ArnLike: { 'aws:SourceArn': `arn:aws:aidevops:${region}:${hubAccountId}:agentspace/*` }, + }, + }), + managedPolicies: [ + ManagedPolicy.fromManagedPolicyArn( + this, + 'AIDevOpsOperatorAppAccessPolicy', + 'arn:aws:iam::aws:policy/AIDevOpsOperatorAppAccessPolicy', + ), + ], + description: + 'Assumed by AWS DevOps Agent Operator Web App users to access hub spaces (web operator access for spaces created from the app).', + }); + // The operator-app trust additionally needs sts:TagSession (the service tags + // the assumed session with the AgentSpaceId). ServicePrincipal only adds + // sts:AssumeRole, so add the TagSession grant to the trust policy directly. + this.operatorRole.assumeRolePolicy?.addStatements( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['sts:TagSession'], + principals: [new ServicePrincipal('aidevops.amazonaws.com')], + conditions: { + StringEquals: { 'aws:SourceAccount': hubAccountId }, + ArnLike: { 'aws:SourceArn': `arn:aws:aidevops:${region}:${hubAccountId}:agentspace/*` }, + }, + }), + ); + // Reuse the same code + layer assets as the refresh workers: the Lambda // code asset is the hub `scripts/` dir and the boto3 layer is the pip-built // `amplify/refresh/boto3-layer` (built out-of-band by amplify.yml preBuild). @@ -133,6 +197,10 @@ export class CreateSpaceWorker extends Construct { // role name used for other accounts (best-effort in linked accounts). AGENT_MONITOR_ROLE_ARN: this.monitorRole.roleArn, AGENT_MONITOR_ROLE_NAME: AGENT_MONITOR_ROLE_NAME, + // "Web operator access": the hub operator-app role ARN + the conventional + // role name used for other accounts (best-effort in linked accounts). + AGENT_OPERATOR_ROLE_ARN: this.operatorRole.roleArn, + AGENT_OPERATOR_ROLE_NAME: AGENT_OPERATOR_ROLE_NAME, ...(props.mgmtAccountId ? { MGMT_ACCOUNT_ID: props.mgmtAccountId } : {}), ...(props.mgmtRoleArn ? { MGMT_ROLE_ARN: props.mgmtRoleArn } : {}), ...(props.externalId ? { EXTERNAL_ID: props.externalId } : {}), @@ -172,16 +240,20 @@ export class CreateSpaceWorker extends Construct { 'aidevops:GetAgentSpace', // Attach the hosting account as the space's primary (monitor) account. 'aidevops:AssociateService', + // Enable "web operator access" (the Operator Web App) on new spaces. + 'aidevops:EnableOperatorApp', + 'aidevops:GetOperatorApp', ], resources: ['*'], }), ); - // Passing the monitor role to the DevOps Agent service in AssociateService - // requires iam:PassRole on that role, constrained to the aidevops service. + // Passing the monitor + operator-app roles to the DevOps Agent service (in + // AssociateService / EnableOperatorApp) requires iam:PassRole on each, + // constrained to the aidevops service. this.worker.addToRolePolicy( new PolicyStatement({ actions: ['iam:PassRole'], - resources: [this.monitorRole.roleArn], + resources: [this.monitorRole.roleArn, this.operatorRole.roleArn], conditions: { StringEquals: { 'iam:PassedToService': 'aidevops.amazonaws.com' } }, }), ); diff --git a/webapp/packages/shared-types/src/index.ts b/webapp/packages/shared-types/src/index.ts index a4cee38..4a8a7bb 100644 --- a/webapp/packages/shared-types/src/index.ts +++ b/webapp/packages/shared-types/src/index.ts @@ -287,6 +287,15 @@ export interface CreateSpaceRequest { name: string; /** Optional human description. */ description?: string; + /** + * Whether to enable the space's Operator Web App ("web operator access") on + * creation, using a default IAM role (auth flow `iam`). Defaults to `true` + * server-side so a new space is reachable from the browser without extra + * steps. Enabling is best-effort: if it fails (e.g. the operator-app role is + * missing in a linked account) the space is still created and a warning + * explains why web access was not enabled. + */ + webOperator?: boolean; } /** A newly created agent space, echoed back on success. */ @@ -302,7 +311,18 @@ export interface CreatedSpace { * explains why it could not be attached (the space still exists). */ primaryAccountConfigured?: boolean; - /** Present when the space was created but the primary account was not attached. */ + /** + * Whether the space's Operator Web App ("web operator access") was enabled on + * creation. Only present when web-operator enablement was requested; `false` + * means it could not be enabled (see {@link CreatedSpace.warning}), the space + * still exists. + */ + webOperatorEnabled?: boolean; + /** + * Present when the space was created but a follow-up step was not completed + * (the primary account was not attached and/or web operator access was not + * enabled). The space itself exists regardless. + */ warning?: string; } @@ -565,6 +585,87 @@ export interface GraphResponse { unavailableReason?: GraphUnavailableReason; } +// --------------------------------------------------------------------------- +// Graph control — GET /graph/control (any), POST /graph/control (Admin) +// --------------------------------------------------------------------------- +// +// Start/stop the Neptune Analytics topology graph from the Admin Settings view. +// +// Neptune Analytics supports a true pause/resume via the `neptune-graph` +// StartGraph / StopGraph APIs (non-destructive — the graph and its data are +// preserved, only compute is released while stopped, which stops the m-NCU-hour +// charge): +// - stop → StopGraph: the graph moves AVAILABLE → STOPPING → STOPPED. Queries +// (the Graph view) are unavailable while stopped, and compute is not +// billed. No data is lost. +// - start → StartGraph: the graph moves STOPPED → STARTING → AVAILABLE, ready +// to query again with the same id and topology. +// Both operations are asynchronous; the UI polls {@link GraphControlStatus} to +// reflect the STARTING/STOPPING transition. + +/** + * Lifecycle state of the Neptune Analytics graph, surfaced to the SPA. Mirrors + * the `neptune-graph` graph status (including the STOPPED/STARTING/STOPPING + * states used by pause/resume), plus `DELETED` for the "no graph is provisioned" + * case and `UNKNOWN` when the status could not be read. + */ +export type GraphLifecycleState = + | 'AVAILABLE' + | 'STOPPED' + | 'STARTING' + | 'STOPPING' + | 'CREATING' + | 'UPDATING' + | 'DELETING' + | 'RESETTING' + | 'SNAPSHOTTING' + | 'IMPORTING' + | 'FAILED' + | 'DELETED' + | 'UNKNOWN'; + +/** The action a `POST /graph/control` request performs. */ +export type GraphControlAction = 'start' | 'stop'; + +/** + * `GET /graph/control` — current state of the topology graph plus timing + * metrics. `running` is the simple "available and billed for compute" signal; + * `transitioning` is true while the graph is starting or stopping so the UI can + * show progress and keep polling. When no graph is provisioned at all, `state` + * is `DELETED` and neither action is offered. + */ +export interface GraphControlStatus { + /** True when a graph is provisioned (any state other than not-found). */ + provisioned: boolean; + /** True when the graph is AVAILABLE (running and billed for compute). */ + running: boolean; + /** Raw graph state; `DELETED` when no graph is provisioned. */ + state: GraphLifecycleState; + /** True while starting/stopping (STARTING/STOPPING/…); keep polling. */ + transitioning: boolean; + /** The configured graph name. */ + graphName: string; + /** The graph id, when a graph is provisioned. */ + graphId?: string; + /** ISO-8601 time the graph was created (from the service). */ + createdAt?: string; + /** ISO-8601 time of the last start/stop action initiated from the app. */ + lastActionAt?: string; + /** The last start/stop action initiated from the app. */ + lastAction?: GraphControlAction; +} + +/** Request body for `POST /graph/control` (Admin only). */ +export interface GraphControlRequest { + action: GraphControlAction; +} + +/** Response for `POST /graph/control` — the action taken and the new status. */ +export interface GraphControlActionResponse { + action: GraphControlAction; + status: GraphControlStatus; +} + // --------------------------------------------------------------------------- // Chat DTOs — POST /chat (streaming) // --------------------------------------------------------------------------- diff --git a/webapp/src/api/graphControl.ts b/webapp/src/api/graphControl.ts new file mode 100644 index 0000000..3825930 --- /dev/null +++ b/webapp/src/api/graphControl.ts @@ -0,0 +1,32 @@ +import type { + GraphControlAction, + GraphControlActionResponse, + GraphControlStatus, +} from '@devops-observatory/shared-types'; +import { apiFetch } from './client'; + +/** + * Graph control API client (Admin Settings "Topology graph" section). + * + * - {@link fetchGraphControlStatus} — `GET /graph/control`, any authenticated + * user. Returns whether the Neptune Analytics graph exists / is transitioning + * plus timing metrics, so the Settings view can render the control and poll + * while a start/stop is in flight. + * - {@link controlGraph} — Admin-only `POST /graph/control`. The backend + * re-asserts the Admin group before any AWS call; an already-running `start` + * or already-stopped `stop` comes back as an `ApiRequestError` (409). + */ + +/** `GET /graph/control` — current graph status + timing metrics. */ +export function fetchGraphControlStatus(): Promise { + return apiFetch('/graph/control'); +} + +/** Admin-only `POST /graph/control` — start or stop the topology graph. */ +export function controlGraph(action: GraphControlAction): Promise { + return apiFetch('/graph/control', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action }), + }); +} diff --git a/webapp/src/lib/graphControlView.test.ts b/webapp/src/lib/graphControlView.test.ts new file mode 100644 index 0000000..ecfcbb8 --- /dev/null +++ b/webapp/src/lib/graphControlView.test.ts @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { GraphControlStatus, GraphLifecycleState } from '@devops-observatory/shared-types'; +import { formatGraphTimestamp, viewGraphStatus } from './graphControlView'; + +/** + * Tests for the Admin Settings "Topology graph" control logic (feature: start/ + * stop the Neptune Analytics graph). + * + * Run with: `node --import tsx --test src/lib/graphControlView.test.ts` + */ + +function status(state: GraphLifecycleState, over: Partial = {}): GraphControlStatus { + return { + provisioned: state !== 'DELETED', + running: state === 'AVAILABLE', + state, + transitioning: ['STARTING', 'STOPPING', 'CREATING', 'DELETING', 'RESETTING', 'SNAPSHOTTING', 'IMPORTING', 'UPDATING'].includes( + state, + ), + graphName: 'devops-agent-topology', + ...over, + }; +} + +test('a null status is treated as unknown with no actions offered', () => { + const view = viewGraphStatus(null); + assert.equal(view.tone, 'error'); + assert.equal(view.label, 'Unknown'); + assert.equal(view.canStart, false); + assert.equal(view.canStop, false); +}); + +test('an AVAILABLE graph is running and can only be stopped', () => { + const view = viewGraphStatus(status('AVAILABLE')); + assert.equal(view.tone, 'running'); + assert.equal(view.label, 'Running'); + assert.equal(view.canStart, false); + assert.equal(view.canStop, true); + assert.equal(view.transitioning, false); +}); + +test('a STOPPED graph is stopped and can only be started', () => { + const view = viewGraphStatus(status('STOPPED')); + assert.equal(view.tone, 'stopped'); + assert.equal(view.label, 'Stopped'); + assert.equal(view.canStart, true); + assert.equal(view.canStop, false); +}); + +test('transitioning states offer neither action and keep polling', () => { + for (const state of ['STARTING', 'STOPPING'] as const) { + const view = viewGraphStatus(status(state)); + assert.equal(view.tone, 'transitioning'); + assert.equal(view.transitioning, true); + assert.equal(view.canStart, false, `${state} should not allow start`); + assert.equal(view.canStop, false, `${state} should not allow stop`); + } +}); + +test('a not-provisioned graph shows "Not provisioned" and offers neither action', () => { + const view = viewGraphStatus(status('DELETED')); + assert.equal(view.label, 'Not provisioned'); + assert.equal(view.tone, 'stopped'); + assert.equal(view.canStart, false); + assert.equal(view.canStop, false); +}); + +test('FAILED/UNKNOWN render as an error tone', () => { + assert.equal(viewGraphStatus(status('FAILED')).tone, 'error'); + assert.equal(viewGraphStatus(status('UNKNOWN')).tone, 'error'); +}); + +test('formatGraphTimestamp renders a valid ISO time in UTC, undefined otherwise', () => { + const formatted = formatGraphTimestamp('2026-09-19T10:30:00.000Z'); + assert.ok(formatted?.includes('UTC')); + assert.ok(formatted?.includes('2026')); + assert.equal(formatGraphTimestamp(undefined), undefined); + assert.equal(formatGraphTimestamp(''), undefined); + assert.equal(formatGraphTimestamp('not-a-date'), undefined); +}); diff --git a/webapp/src/lib/graphControlView.ts b/webapp/src/lib/graphControlView.ts new file mode 100644 index 0000000..6859100 --- /dev/null +++ b/webapp/src/lib/graphControlView.ts @@ -0,0 +1,97 @@ +import type { GraphControlStatus, GraphLifecycleState } from '@devops-observatory/shared-types'; + +/** + * Pure, DOM-free logic for the Admin Settings "Topology graph" control (start/ + * stop the Neptune Analytics graph). + * + * Kept separate from the React component so the status→label/tone mapping and + * the button-enablement rules can be unit-tested with the repo's `node:test` + + * tsx convention. The server remains the authority — these rules only shape the + * UI (and the backend re-asserts Admin + rejects invalid start/stop anyway). + */ + +/** Visual tone for the status badge. */ +export type GraphStatusTone = 'running' | 'stopped' | 'transitioning' | 'error'; + +/** Everything the component needs to render the control from a status. */ +export interface GraphStatusView { + tone: GraphStatusTone; + /** Short human label, e.g. "Running", "Starting…", "Stopped". */ + label: string; + /** True while the graph is starting/stopping — the UI keeps polling. */ + transitioning: boolean; + /** Whether a "Start" action is currently allowed. */ + canStart: boolean; + /** Whether a "Stop" action is currently allowed. */ + canStop: boolean; +} + +const LABELS: Record = { + AVAILABLE: 'Running', + STOPPED: 'Stopped', + STARTING: 'Starting…', + STOPPING: 'Stopping…', + CREATING: 'Creating…', + UPDATING: 'Updating…', + DELETING: 'Deleting…', + RESETTING: 'Working…', + SNAPSHOTTING: 'Snapshotting…', + IMPORTING: 'Loading data…', + FAILED: 'Failed', + DELETED: 'Not provisioned', + UNKNOWN: 'Unknown', +}; + +/** + * Derive the badge tone, label, and which actions are allowed from a status. + * A `null` status (not loaded yet / unreadable) is treated as unknown: neither + * action is offered until the real state is known. + */ +export function viewGraphStatus(status: GraphControlStatus | null): GraphStatusView { + if (status === null) { + return { tone: 'error', label: 'Unknown', transitioning: false, canStart: false, canStop: false }; + } + + const label = LABELS[status.state] ?? 'Unknown'; + const transitioning = status.transitioning; + + let tone: GraphStatusTone; + if (transitioning) { + tone = 'transitioning'; + } else if (status.state === 'AVAILABLE') { + tone = 'running'; + } else if (status.state === 'STOPPED' || status.state === 'DELETED') { + tone = 'stopped'; + } else { + tone = 'error'; + } + + return { + tone, + label, + transitioning, + // Resume only from a settled STOPPED state. + canStart: status.state === 'STOPPED', + // Pause only from a settled AVAILABLE (running) state. + canStop: status.state === 'AVAILABLE', + }; +} + +/** + * Format an ISO-8601 timestamp as a stable UTC string for the timing metrics, + * or `undefined` when it is missing/invalid (so the caller can omit the row). + */ +export function formatGraphTimestamp(value: string | undefined): string | undefined { + if (!value) return undefined; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return undefined; + return `${date.toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + timeZone: 'UTC', + hour12: false, + })} UTC`; +} diff --git a/webapp/src/views/SettingsView.tsx b/webapp/src/views/SettingsView.tsx index f1d1677..a0f30cd 100644 --- a/webapp/src/views/SettingsView.tsx +++ b/webapp/src/views/SettingsView.tsx @@ -1,9 +1,13 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { CHAT_HISTORY_MAX_RETENTION_DAYS, CHAT_HISTORY_MIN_RETENTION_DAYS, + type GraphControlAction, + type GraphControlStatus, } from '@devops-observatory/shared-types'; import { fetchSettings, saveSettings } from '../api/settings'; +import { controlGraph, fetchGraphControlStatus } from '../api/graphControl'; +import { formatGraphTimestamp, viewGraphStatus, type GraphStatusTone } from '../lib/graphControlView'; import { deleteA2aToken, fetchA2aConfiguredSpaces, storeA2aToken } from '../api/a2a'; import { fetchSpaces } from '../api/spaces'; import { ApiRequestError } from '../api/client'; @@ -277,11 +281,222 @@ function AdminSettings({ )} + + ); } +// --------------------------------------------------------------------------- +// Topology graph control (Neptune Analytics start/stop) — Admin only +// --------------------------------------------------------------------------- + +/** Badge colors per status tone (mirrors the palette used elsewhere in this view). */ +const GRAPH_TONE_STYLES: Record = { + running: { color: '#067647', background: '#ecfdf3', border: '#abefc6' }, + stopped: { color: '#475467', background: '#f2f4f7', border: '#e4e7ec' }, + transitioning: { color: '#175cd3', background: '#eff4ff', border: '#b2ccff' }, + error: { color: '#b42318', background: '#fef3f2', border: '#fecdca' }, +}; + +/** + * Admin control to START/STOP (pause/resume) the Neptune Analytics topology + * graph. + * + * Uses the non-destructive StartGraph/StopGraph APIs: stopping releases compute + * so the graph stops billing (data is preserved), starting brings it back + * available with the same id. Both are asynchronous, so this polls the status + * while the graph is transitioning and shows simple timing metrics (when the + * graph was created, and the last start/stop action). The backend re-asserts + * Admin and rejects an invalid start/stop, so this is UX + convenience only. + */ +function GraphControlSection(): JSX.Element { + const [status, setStatus] = useState(null); + const [loadError, setLoadError] = useState(null); + const [busy, setBusy] = useState(false); + const [actionError, setActionError] = useState(null); + + const refresh = useCallback(async (): Promise => { + try { + const next = await fetchGraphControlStatus(); + setStatus(next); + setLoadError(null); + } catch (err: unknown) { + setLoadError( + err instanceof ApiRequestError ? err.message : 'Unable to load the graph status right now.', + ); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + // While the graph is starting/stopping, poll until it settles. + const transitioning = status?.transitioning ?? false; + useEffect(() => { + if (!transitioning) return; + const timer = setInterval(() => void refresh(), 5000); + return () => clearInterval(timer); + }, [transitioning, refresh]); + + const view = viewGraphStatus(status); + const tone = GRAPH_TONE_STYLES[view.tone]; + + async function act(action: GraphControlAction): Promise { + if (action === 'stop') { + const confirmed = window.confirm( + 'Stopping pauses the Neptune Analytics graph so it stops billing for compute. ' + + 'The Graph view will be unavailable until you start it again. Data is preserved. ' + + 'Continue?', + ); + if (!confirmed) return; + } + setBusy(true); + setActionError(null); + try { + const res = await controlGraph(action); + setStatus(res.status); + } catch (err: unknown) { + setActionError( + err instanceof ApiRequestError + ? err.message + : `The graph could not be ${action === 'start' ? 'started' : 'stopped'}. Please try again.`, + ); + } finally { + setBusy(false); + } + } + + const startedAt = formatGraphTimestamp(status?.createdAt); + const lastActionAt = formatGraphTimestamp(status?.lastActionAt); + + return ( +
+
+

Topology graph (Neptune Analytics)

+ + {view.label} + +
+

+ Start or stop the graph that powers the Graph view. Stop pauses the graph so + it stops billing for compute (data is preserved and the Graph view is unavailable meanwhile); + Start brings it back. Changes take a few minutes. +

+ +
+ + + +
+ +
+ + {startedAt && } + {status?.graphId && } + {status?.lastAction && lastActionAt && ( + + )} +
+ + {transitioning && ( +

+ {view.label} This can take a few minutes — the status updates automatically. +

+ )} + {loadError && ( +

+ {loadError} +

+ )} + {actionError && ( +

+ {actionError} +

+ )} +
+ ); +} + +/** One label/value row in the graph timing-metrics list. */ +function GraphMetric({ + label, + value, + mono, +}: { + label: string; + value: string; + mono?: boolean; +}): JSX.Element { + return ( + <> +
{label}
+
+ {value} +
+ + ); +} + +function graphButtonStyle(enabled: boolean, accent: string) { + return { + padding: '0.5rem 1.25rem', + borderRadius: 6, + border: 'none', + background: enabled ? accent : '#98a2b3', + color: '#fff', + cursor: enabled ? 'pointer' : 'not-allowed', + fontSize: '0.9375rem', + fontWeight: 600, + } as const; +} + // --------------------------------------------------------------------------- // Connect your AI app (external MCP access, Task 39 — all users) // --------------------------------------------------------------------------- diff --git a/webapp/src/views/SpaceView.tsx b/webapp/src/views/SpaceView.tsx index 4e3f29c..1eaeef1 100644 --- a/webapp/src/views/SpaceView.tsx +++ b/webapp/src/views/SpaceView.tsx @@ -302,6 +302,9 @@ function CreateSpacePanel({ const [open, setOpen] = useState(false); const [name, setName] = useState(() => defaultSpaceName(account.account)); const [description, setDescription] = useState(''); + // "Web operator access" (Operator Web App) — on by default so a new space is + // reachable from the browser. Enablement is best-effort server-side. + const [webOperator, setWebOperator] = useState(true); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); @@ -320,15 +323,17 @@ function CreateSpacePanel({ const res = await createSpace({ accountId: account.account, name: name.trim(), + webOperator, ...(description.trim() ? { description: description.trim() } : {}), }); setShowErrors(false); // Mark "Pending refresh" instead of re-fetching — the new space (with its - // primary account attached) appears after the next data refresh. + // primary account + web operator access) appears after the next refresh. onCreated(account.account); - // The space was created, but if its primary account could not be attached - // keep the panel open to surface the warning; otherwise close. - if (res.space.primaryAccountConfigured === false && res.space.warning) { + // The space was created; if a follow-up step (primary account or web + // operator access) could not be completed, keep the panel open to surface + // the warning. Otherwise close. + if (res.space.warning) { setNotice(res.space.warning); } else { setOpen(false); @@ -395,6 +400,29 @@ function CreateSpacePanel({ {showErrors && issues.description && {issues.description}} + + {error && {error}} {notice && ( From 2f28262d21beff70b14d29430ab031140c6fccdd Mon Sep 17 00:00:00 2001 From: Jonathan Lee Date: Sun, 20 Sep 2026 12:12:11 -0400 Subject: [PATCH 2/2] fix: make script config lookups import-safe for tests, validate at runtime Scripts read HUB_BUCKET/REGION/KB_DOCS_PREFIX via CFG.get(...) with env fallbacks and a placeholder default, so importing them (e.g. under pytest/CI) no longer raises KeyError when config.env/env vars are absent. 03_collect.py now builds its S3 client lazily and refuses to run against the placeholder bucket, preserving strict validation for real collection runs. Fixes the collection-time import errors in test_03_collect, test_04_transform_to_graph, test_07_build_kb_docs, and test_refresh_workers (94 passed with no config). --- scripts/03_collect.py | 26 ++++++++++++++++++++++---- scripts/04_transform_to_graph.py | 6 +++++- scripts/07_build_kb_docs.py | 8 ++++++-- scripts/assemble_manifest_worker.py | 8 ++++++-- scripts/list_accounts_worker.py | 6 +++++- 5 files changed, 44 insertions(+), 10 deletions(-) diff --git a/scripts/03_collect.py b/scripts/03_collect.py index 5632f59..77c3d2c 100644 --- a/scripts/03_collect.py +++ b/scripts/03_collect.py @@ -42,14 +42,32 @@ """ import io import json +import os import zipfile import datetime as dt from botocore.exceptions import ClientError from _common import CFG, hub_session, list_org_accounts, assume_collector -REGION = CFG["REGION"] -BUCKET = CFG["HUB_BUCKET"] -S3 = hub_session().client("s3") +# Config lookups fall back to safe defaults so importing this module (e.g. from +# unit tests) never raises when HUB_BUCKET/REGION are unset. A real collection +# run resolves them from config.env / the environment; the lazy s3() below +# refuses to run against the placeholder bucket. +REGION = CFG.get("REGION") or os.getenv("AWS_DEFAULT_REGION", "us-east-1") +BUCKET = CFG.get("HUB_BUCKET") or os.getenv("HUB_BUCKET", "unit-test-placeholder-bucket") + +_S3 = None + + +def s3(): + """Lazily create the hub S3 client so importing this module never requires + AWS credentials or a configured profile. Refuses to run when HUB_BUCKET is + not actually configured (i.e. still the unit-test placeholder).""" + global _S3 + if _S3 is None: + if not BUCKET or BUCKET == "unit-test-placeholder-bucket": + raise RuntimeError("HUB_BUCKET must be configured for collection") + _S3 = hub_session().client("s3", region_name=REGION) + return _S3 def js(o): @@ -57,7 +75,7 @@ def js(o): def put(key, obj): - S3.put_object(Bucket=BUCKET, Key=key, Body=js(obj).encode(), ContentType="application/json") + s3().put_object(Bucket=BUCKET, Key=key, Body=js(obj).encode(), ContentType="application/json") def extract_zip_texts(zip_bytes): diff --git a/scripts/04_transform_to_graph.py b/scripts/04_transform_to_graph.py index 51e68ec..bdf1170 100644 --- a/scripts/04_transform_to_graph.py +++ b/scripts/04_transform_to_graph.py @@ -47,9 +47,13 @@ import csv import io import json +import os from _common import CFG, hub_session -BUCKET = CFG["HUB_BUCKET"] +# Falls back to a placeholder so importing this module (e.g. from unit tests) +# never raises when HUB_BUCKET is unset; a real run resolves it from config.env +# / the environment. The S3 client below is created lazily. +BUCKET = CFG.get("HUB_BUCKET") or os.getenv("HUB_BUCKET", "unit-test-placeholder-bucket") #: Max length of a truncated free-text label (Requirements 9.6, 9.7). LABEL_MAX = 120 diff --git a/scripts/07_build_kb_docs.py b/scripts/07_build_kb_docs.py index 407a09f..287b68c 100644 --- a/scripts/07_build_kb_docs.py +++ b/scripts/07_build_kb_docs.py @@ -21,12 +21,16 @@ Run with hub credentials (profile 123456789012). """ import json +import os from collections import Counter from _common import CFG, hub_session -BUCKET = CFG["HUB_BUCKET"] -DOCS = CFG["KB_DOCS_PREFIX"] +# Fall back to safe defaults so importing this module (e.g. from unit tests) +# never raises when HUB_BUCKET/KB_DOCS_PREFIX are unset; a real run resolves +# them from config.env / the environment. The S3 client below is created lazily. +BUCKET = CFG.get("HUB_BUCKET") or os.getenv("HUB_BUCKET", "unit-test-placeholder-bucket") +DOCS = CFG.get("KB_DOCS_PREFIX") or os.getenv("KB_DOCS_PREFIX", "kb/") _S3 = None diff --git a/scripts/assemble_manifest_worker.py b/scripts/assemble_manifest_worker.py index ff0b94a..11c2b40 100644 --- a/scripts/assemble_manifest_worker.py +++ b/scripts/assemble_manifest_worker.py @@ -16,11 +16,15 @@ """ import datetime as dt import json +import os from _common import CFG, hub_session -BUCKET = CFG["HUB_BUCKET"] -REGION = CFG["REGION"] +# Fall back to safe defaults so importing this module (e.g. from unit tests) +# never raises when HUB_BUCKET/REGION are unset; a real run resolves them from +# config.env / the environment (the S3 client is created lazily in the handler). +BUCKET = CFG.get("HUB_BUCKET") or os.getenv("HUB_BUCKET", "unit-test-placeholder-bucket") +REGION = CFG.get("REGION") or os.getenv("AWS_DEFAULT_REGION", "us-east-1") MANIFEST_KEY = "raw/_manifest.json" ACCOUNT_PREFIX = "raw/account=" ACCOUNT_SUMMARY_SUFFIX = "/_account.json" diff --git a/scripts/list_accounts_worker.py b/scripts/list_accounts_worker.py index bd38307..be09eb6 100644 --- a/scripts/list_accounts_worker.py +++ b/scripts/list_accounts_worker.py @@ -15,10 +15,14 @@ ``sts:AssumeRole`` on that one role. """ import json +import os from _common import CFG, hub_session, list_org_accounts -BUCKET = CFG["HUB_BUCKET"] +# Falls back to a placeholder so importing this module (e.g. from unit tests) +# never raises when HUB_BUCKET is unset; a real run resolves it from config.env +# / the environment (the S3 client is created lazily inside the handler). +BUCKET = CFG.get("HUB_BUCKET") or os.getenv("HUB_BUCKET", "unit-test-placeholder-bucket") # Fixed key (single-flight refresh, single admin actor -> no per-run collision). ACCOUNTS_KEY = "refresh/accounts.json"