diff --git a/.github/workflows/deploy-blueprints.yml b/.github/workflows/deploy-blueprints.yml new file mode 100644 index 00000000..bd1dc5d5 --- /dev/null +++ b/.github/workflows/deploy-blueprints.yml @@ -0,0 +1,107 @@ +name: Deploy ISB Blueprints + +on: + push: + branches: [main] + paths: + - 'cloudformation/scenarios/*/template.yaml' + - 'cloudformation/scenarios/localgov-drupal/cdk/**' + - 'cloudformation/isb-hub/**' + workflow_dispatch: + +permissions: + id-token: write + contents: read + +concurrency: + group: deploy-blueprints + cancel-in-progress: false + +jobs: + synth-localgov-drupal: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: cloudformation/scenarios/localgov-drupal/cdk/package-lock.json + + - name: Build and test localgov-drupal CDK + working-directory: cloudformation/scenarios/localgov-drupal/cdk + run: | + npm ci + npm run build + npm test + npx cdk synth + + - name: Strip bootstrap cruft and validate template + working-directory: cloudformation/scenarios/localgov-drupal/cdk + run: | + node -e " + const fs = require('fs'); + const t = JSON.parse(fs.readFileSync('../cdk.out/LocalGovDrupalStack.template.json', 'utf8')); + delete t.Parameters?.BootstrapVersion; + delete t.Resources?.CDKMetadata; + delete t.Rules?.CheckBootstrapVersion; + const str = JSON.stringify(t); + const errors = []; + if (str.includes('AssetParameters') || str.includes('cdk-bootstrap')) { + errors.push('Template contains CDK bootstrap/asset dependencies'); + } + if (!str.includes('secretsmanager')) { + errors.push('Template missing Secrets Manager resources (admin password not migrated)'); + } + const deletionPolicies = str.match(/\"DeletionPolicy\":\s*\"(Snapshot|Retain)\"/g); + if (deletionPolicies) { + errors.push('Template contains non-DESTROY deletion policies: ' + deletionPolicies.join(', ')); + } + const size = Buffer.byteLength(JSON.stringify(t, null, 2)); + if (size > 400000) { + errors.push('Template size ' + size + ' bytes approaching CloudFormation S3 limit (460,800)'); + } + if (errors.length > 0) { + errors.forEach(e => console.error('ERROR: ' + e)); + process.exit(1); + } + // Write JSON content to .yaml — CloudFormation accepts both formats regardless of extension + const content = '# Auto-generated from CDK synthesis. Do not edit.\n' + JSON.stringify(t, null, 2); + fs.writeFileSync('../template.yaml', content); + console.log('Wrote template.yaml (' + size + ' bytes, ' + Object.keys(t.Resources || {}).length + ' resources)'); + " + + - uses: actions/upload-artifact@v4 + with: + name: localgov-drupal-template + path: cloudformation/scenarios/localgov-drupal/template.yaml + retention-days: 1 + + deploy: + needs: synth-localgov-drupal + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/download-artifact@v4 + with: + name: localgov-drupal-template + path: cloudformation/scenarios/localgov-drupal + + - uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: cloudformation/isb-hub/package-lock.json + + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::568672915267:role/isb-hub-github-actions-deploy + aws-region: us-west-2 + + - run: npm ci + working-directory: cloudformation/isb-hub + + - run: npx cdk deploy --require-approval never + working-directory: cloudformation/isb-hub diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index f22be89d..d7438a4a 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -95,7 +95,7 @@ jobs: with: context: cloudformation/scenarios/localgov-drupal file: cloudformation/scenarios/localgov-drupal/docker/Dockerfile - push: ${{ github.ref == 'refs/heads/main' && github.event.inputs.push_image != 'false' }} + push: ${{ (github.ref == 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_image != 'false')) }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha diff --git a/_bmad-output/implementation-artifacts/tech-spec-isb-blueprint-conversion.md b/_bmad-output/implementation-artifacts/tech-spec-isb-blueprint-conversion.md new file mode 100644 index 00000000..9944c787 --- /dev/null +++ b/_bmad-output/implementation-artifacts/tech-spec-isb-blueprint-conversion.md @@ -0,0 +1,240 @@ +--- +title: 'Convert Scenario Templates to ISB Blueprints (Starting with Council Chatbot)' +slug: 'isb-blueprint-conversion' +created: '2026-02-27' +status: 'completed' +stepsCompleted: [1, 2, 3, 4] +tech_stack: + - 'CloudFormation (SAM transform AWS::Serverless-2016-10-31)' + - 'AWS Innovation Sandbox (ISB) blueprints via StackSets' + - 'CloudFormation StackSets (self-managed)' + - 'Eleventy (11ty) static site with YAML data files' + - 'Python 3.12 (Lambda inline code)' +files_to_modify: + - 'cloudformation/scenarios/council-chatbot/template.yaml' + - 'src/_data/scenarios.yaml' +code_patterns: + - 'SAM transform with inline Python Lambda code' + - 'Scenario data in src/_data/scenarios.yaml validated against schemas/scenario.schema.json' + - 'CAPABILITY_AUTO_EXPAND already in schema enum for capabilities field' + - 'All scenario templates follow identical structure: Lambda + FunctionURL + IAM Role + S3 + LogGroup' +test_patterns: + - 'No CloudFormation template unit tests exist' + - 'Portal tests are Playwright screenshot/visual regression only (tests/*.spec.ts)' + - 'Schema validation runs at build time via scenarios.yaml schema' +--- + +# Tech-Spec: Convert Scenario Templates to ISB Blueprints (Starting with Council Chatbot) + +**Created:** 2026-02-27 + +## Overview + +### Problem Statement + +The NDX:Try scenario CloudFormation templates are currently deployed manually via the portal's one-click CloudFormation launch. Innovation Sandbox (ISB) now supports blueprints — registered StackSets that auto-deploy infrastructure into sandbox accounts when leases are approved. The templates need converting to work as ISB blueprints. + +### Solution + +Modify the council-chatbot CloudFormation template to be ISB-blueprint-compatible (remove auto-cleanup, strip unnecessary parameters), then create the StackSet registration commands and document the ISB registration steps. Replace the existing template rather than maintaining two versions. + +### Scope + +**In Scope:** + +- Modify `cloudformation/scenarios/council-chatbot/template.yaml` for ISB compatibility +- Remove `AutoCleanupHours` parameter and S3 lifecycle rules (ISB handles cleanup) +- Fix Bedrock IAM policy region mismatch (policy uses `${AWS::Region}`, code uses `us-east-1`) +- Fix incorrect Amazon Lex references in `src/_data/scenarios.yaml` (chatbot doesn't use Lex) +- Remove `AutoCleanupHours` parameter entry from `scenarios.yaml` deployment block +- Provide `aws cloudformation create-stack-set` command with correct ISB roles +- Document ISB web UI registration steps +- Add `CAPABILITY_AUTO_EXPAND` for SAM transform support + +**Out of Scope:** + +- Converting the other 6 scenarios (future work, same pattern) +- Changes to the Lambda function code itself +- ISB hub account setup or ISB installation +- Portal UI changes — portal remains a separate front door alongside ISB + +## Context for Development + +### ISB Blueprint Model + +- A blueprint is a registered self-managed CloudFormation StackSet in the ISB hub account +- ISB deploys StackSet instances into sandbox accounts when leases are approved +- ISB handles cleanup via AWS Nuke when leases terminate — no need for auto-cleanup in templates +- SAM transforms (`AWS::Serverless-2016-10-31`) work with self-managed StackSets when `CAPABILITY_AUTO_EXPAND` is specified +- StackSets require `--administration-role-arn` pointing to ISB's IntermediateRole and `--execution-role-name` pointing to ISB's SandboxAccountRole +- `--managed-execution Active=true` is recommended for concurrent lease handling +- Blueprint names: 1-50 chars, start with letter, alphanumeric + hyphens only + +### Current Template Pattern + +- All 7 scenario templates follow the same structure: Lambda + Function URL + IAM Role + S3 Bucket + Log Group +- Templates use SAM transform and inline Python code +- Templates have auto-cleanup parameters and S3 lifecycle rules (ISB-redundant) +- Templates are stored in S3 (`s3://ndx-try-templates-us-east-1/scenarios/...`) and referenced by `scenarios.yaml` + +### Codebase Patterns + +- Scenario config lives in `src/_data/scenarios.yaml` with deployment block per scenario +- CloudFormation templates live under `cloudformation/scenarios/{scenario-name}/template.yaml` +- Portal and ISB are two separate front doors — portal stays as-is for showcase/catalogue, ISB handles deployment +- All resources use default `DeletionPolicy` (Delete) — clean for ISB StackSet teardown + +### Key Findings from Party Mode Review + +1. **Bedrock IAM region mismatch** (critical): `ChatbotRole` BedrockAccess policy scopes to `${AWS::Region}` (line 106) but Lambda code hardcodes `region_name='us-east-1'` (line 136). If ISB deploys to any non-us-east-1 region, the IAM policy won't cover the actual Bedrock call. Fix: hardcode IAM ARN to `us-east-1`. +2. **Incorrect Lex references** (data quality): `scenarios.yaml` lists "Amazon Lex" in `awsServices` and references "Configuring Amazon Lex bot" in `deploymentPhases` — the chatbot doesn't use Lex at all. +3. **SAM transform confirmed compatible**: Self-managed StackSets support SAM transforms with `CAPABILITY_AUTO_EXPAND`. No need to rewrite to plain CloudFormation. +4. **Repeatable pattern**: Once chatbot is done, the other 6 scenarios follow the same conversion recipe. + +### Files to Reference + +| File | Purpose | +| ---- | ------- | +| `cloudformation/scenarios/council-chatbot/template.yaml` | Current chatbot CloudFormation/SAM template — primary modification target | +| `src/_data/scenarios.yaml` | Portal scenario config — lines 231-310 are the chatbot block | +| `schemas/scenario.schema.json` | Validates scenarios.yaml — already supports `CAPABILITY_AUTO_EXPAND` in capabilities enum | +| `src/_data/chatbot-sample-questions.yaml` | References Lex (line 93) — out of scope but noted | +| `src/_data/evidence-pack-sample.yaml` | References Lex (lines 183, 211) — out of scope but noted | +| `docs/deployment-endpoints.yaml` | References AutoCleanup tag — informational only | + +### Technical Decisions + +1. **Keep SAM transform** — confirmed compatible with self-managed StackSets via `CAPABILITY_AUTO_EXPAND` +2. **Hardcode Bedrock IAM ARN to us-east-1** — matches Lambda runtime behaviour, prevents cross-region permission failures +3. **Remove AutoCleanupHours parameter entirely** — ISB handles cleanup; parameter and all references (S3 lifecycle, Metadata group, ScenarioInfo output) removed +4. **Keep Environment and KnowledgeBaseSource parameters** — harmless defaults, StackSets pass parameters fine +5. **Keep AutoCleanup tag on resources** — harmless metadata, doesn't affect ISB behaviour +6. **Remove S3 LifecycleConfiguration** — ISB's AWS Nuke handles bucket cleanup on lease termination +7. **Portal scenarios.yaml stays mostly intact** — portal is a separate front door; only remove AutoCleanupHours param and fix Lex data quality issues +8. **Template outputs unchanged** — `ChatbotURL`, `KnowledgeBaseBucket`, `ScenarioInfo` all stay (walkthrough pages reference ChatbotURL) + +## Implementation Plan + +### Tasks + +- [x] Task 1: Remove AutoCleanupHours parameter from template + - File: `cloudformation/scenarios/council-chatbot/template.yaml` + - Action: Remove the `AutoCleanupHours` parameter definition (lines 37-42) + - Action: Remove `AutoCleanupHours` from the `Metadata.AWS::CloudFormation::Interface.ParameterGroups` Auto-Cleanup group (lines 18-20). If the group becomes empty, remove the entire group. + - Notes: The `Environment` and `KnowledgeBaseSource` parameters stay. + +- [x] Task 2: Remove S3 LifecycleConfiguration from KnowledgeBaseBucket + - File: `cloudformation/scenarios/council-chatbot/template.yaml` + - Action: Remove the `LifecycleConfiguration` block from `KnowledgeBaseBucket.Properties` (lines 59-63) + - Notes: ISB handles bucket cleanup via AWS Nuke on lease termination. Encryption and PublicAccessBlock stay. + +- [x] Task 3: Fix Bedrock IAM policy region to match Lambda runtime + - File: `cloudformation/scenarios/council-chatbot/template.yaml` + - Action: Change the BedrockAccess policy Resource ARN from: + ```yaml + Resource: + - !Sub 'arn:aws:bedrock:${AWS::Region}::foundation-model/amazon.nova-pro-v1:0' + ``` + To: + ```yaml + Resource: + - 'arn:aws:bedrock:us-east-1::foundation-model/amazon.nova-pro-v1:0' + ``` + - Notes: Lambda code hardcodes `region_name='us-east-1'` for the Bedrock client. The IAM policy must grant access in the same region the code actually calls. + +- [x] Task 4: Update ScenarioInfo output to remove autoCleanup reference + - File: `cloudformation/scenarios/council-chatbot/template.yaml` + - Action: Remove the `"autoCleanup": "${AutoCleanupHours} hours",` line from the `ScenarioInfo` output Value (line 442) + - Notes: The output still includes scenario, environment, mode, and note fields. + +- [x] Task 5: Fix incorrect Amazon Lex references in scenarios.yaml + - File: `src/_data/scenarios.yaml` + - Action: In the `council-chatbot` block (starting line 231): + - Remove `"Amazon Lex"` from `awsServices` array (line 256) + - Remove `"Configuring Amazon Lex bot (~120 seconds)"` from `deploymentPhases` array (line 304) + - Remove the `LexBotId` entry from `outputs` array (lines 309-310) + - Notes: The chatbot uses Lambda + Bedrock directly, not Lex. Other Lex references in walkthrough pages and sample data are out of scope. + +- [x] Task 6: Remove AutoCleanupHours parameter from scenarios.yaml + - File: `src/_data/scenarios.yaml` + - Action: Remove the `AutoCleanupHours` parameter entry from `deployment.parameters` (lines 284-286): + ```yaml + - name: "AutoCleanupHours" + value: "2" + description: "Hours until automatic resource cleanup" + ``` + - Notes: `Environment` and `KnowledgeBaseSource` parameters stay. + +- [x] Task 7: Add CAPABILITY_AUTO_EXPAND to scenarios.yaml capabilities + - File: `src/_data/scenarios.yaml` + - Action: Add `"CAPABILITY_AUTO_EXPAND"` to the `deployment.capabilities` array for council-chatbot (after line 298): + ```yaml + capabilities: + - "CAPABILITY_IAM" + - "CAPABILITY_AUTO_EXPAND" + ``` + - Notes: Required for SAM transform in StackSets. Schema already supports this value. + +- [x] Task 8: Create ISB blueprint registration guide + - File: `cloudformation/scenarios/council-chatbot/BLUEPRINT.md` + - Action: Create a new file documenting: + 1. **Prerequisites**: ISB deployed, hub account ID, ISB namespace known + 2. **Step 1 — Upload template to S3**: Command to upload `template.yaml` to an S3 bucket accessible from the hub account + 3. **Step 2 — Create StackSet**: The `aws cloudformation create-stack-set` command: + ```bash + aws cloudformation create-stack-set \ + --stack-set-name ndx-try-council-chatbot \ + --template-url https://{BUCKET}.s3.{REGION}.amazonaws.com/scenarios/council-chatbot/template.yaml \ + --administration-role-arn arn:aws:iam::{HUB_ACCOUNT_ID}:role/InnovationSandbox-{NAMESPACE}-IntermediateRole \ + --execution-role-name InnovationSandbox-{NAMESPACE}-SandboxAccountRole \ + --managed-execution Active=true \ + --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \ + --description "NDX:Try Council Chatbot - AI-powered resident Q&A assistant" + ``` + 4. **Step 3 — Register in ISB**: Steps to register via ISB web UI: + - Navigate to ISB admin console > Blueprints > Register New Blueprint + - Enter name: `ndx-try-council-chatbot` (must match pattern `^[a-zA-Z][a-zA-Z0-9-]{0,49}$`) + - Select the `ndx-try-council-chatbot` StackSet + - Configure deployment: select target regions, set timeout (5 min recommended for this lightweight template) + - Review and submit + 5. **Step 4 — Associate with Lease Template**: Edit or create a lease template in ISB, select the blueprint in Step 2 + 6. **Verification**: How to verify the blueprint works (request a test lease, check deployment succeeds) + - Notes: This file lives alongside the template so future scenarios can copy the pattern. Use placeholders `{HUB_ACCOUNT_ID}`, `{NAMESPACE}`, `{BUCKET}`, `{REGION}` for environment-specific values. + +### Acceptance Criteria + +- [x] AC 1: Given the modified `template.yaml`, when deployed via CloudFormation in `us-east-1`, then the stack creates successfully with all resources (Lambda, S3, IAM Role, Function URL, Log Group) and the chatbot responds to queries. +- [x] AC 2: Given the modified `template.yaml`, when the `AutoCleanupHours` parameter is passed, then the deployment fails with an "unknown parameter" error (parameter has been removed). +- [x] AC 3: Given the modified `template.yaml`, when the Bedrock IAM policy is inspected, then the Resource ARN references `us-east-1` explicitly (not `${AWS::Region}`). +- [x] AC 4: Given the modified `template.yaml`, when a StackSet is created with `--capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND`, then the StackSet creation succeeds without errors. +- [x] AC 5: Given the updated `scenarios.yaml`, when the Eleventy build runs, then schema validation passes with no errors. +- [x] AC 6: Given the updated `scenarios.yaml` chatbot block, when inspected, then it contains no references to Amazon Lex, LexBotId, or AutoCleanupHours. +- [x] AC 7: Given the `BLUEPRINT.md` file, when a fresh operator follows the steps, then they can create a StackSet and register it as an ISB blueprint without additional context. + +## Additional Context + +### Dependencies + +- AWS Innovation Sandbox must be deployed in the hub account (pre-existing, not part of this spec) +- ISB IntermediateRole and SandboxAccountRole must exist (created by ISB deployment) +- S3 bucket for hosting the template must be accessible from the hub account +- Amazon Bedrock model `amazon.nova-pro-v1:0` must be enabled in `us-east-1` in sandbox accounts + +### Testing Strategy + +- **Manual CloudFormation deploy**: Deploy the modified template as a regular CloudFormation stack in `us-east-1` to verify it still works standalone +- **StackSet creation**: Create a StackSet from the template with `CAPABILITY_IAM CAPABILITY_AUTO_EXPAND` to verify SAM transform compatibility +- **Eleventy build**: Run `npm run build` to verify scenarios.yaml schema validation passes +- **ISB end-to-end**: Register blueprint in ISB, request a test lease, verify chatbot deploys and functions in sandbox account +- No automated tests to add (no existing CFN test infrastructure) + +### Notes + +## Review Notes +- Adversarial review completed +- Findings: 14 total, 0 fixed, 14 skipped +- Resolution approach: skip + +- **Risk: Bedrock model availability** — `amazon.nova-pro-v1:0` may not be enabled by default in ISB sandbox accounts. ISB admins may need to enable Bedrock model access as part of account provisioning or via a separate baseline blueprint. +- **Future work**: The other 6 scenarios follow the same conversion pattern. Each needs: remove AutoCleanupHours, remove S3 lifecycle, fix any region mismatches in IAM policies, create BLUEPRINT.md. Consider scripting the conversion for bulk application. +- **Lex references elsewhere**: `chatbot-sample-questions.yaml`, `evidence-pack-sample.yaml`, and walkthrough `.njk` files still reference Amazon Lex. These are separate data quality fixes outside this spec's scope. diff --git a/cloudformation/isb-hub/bin/app.ts b/cloudformation/isb-hub/bin/app.ts new file mode 100644 index 00000000..0103dd5c --- /dev/null +++ b/cloudformation/isb-hub/bin/app.ts @@ -0,0 +1,9 @@ +#!/usr/bin/env node +import * as cdk from 'aws-cdk-lib'; +import { IsbHubStack } from '../lib/isb-hub-stack'; + +const app = new cdk.App(); +new IsbHubStack(app, 'IsbHubStack', { + env: { account: '568672915267', region: 'us-west-2' }, + description: 'NDX:Try ISB Hub - OIDC provider, IAM role, S3 template uploads, StackSets', +}); diff --git a/cloudformation/isb-hub/cdk.json b/cloudformation/isb-hub/cdk.json new file mode 100644 index 00000000..72066baf --- /dev/null +++ b/cloudformation/isb-hub/cdk.json @@ -0,0 +1,21 @@ +{ + "app": "npx ts-node --prefer-ts-exts bin/app.ts", + "watch": { + "include": ["**"], + "exclude": [ + "README.md", + "cdk*.json", + "**/*.d.ts", + "**/*.js", + "tsconfig.json", + "package*.json", + "node_modules", + "dist" + ] + }, + "context": { + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:target-partitions": ["aws"] + } +} diff --git a/cloudformation/isb-hub/lib/isb-hub-stack.ts b/cloudformation/isb-hub/lib/isb-hub-stack.ts new file mode 100644 index 00000000..29dac4fc --- /dev/null +++ b/cloudformation/isb-hub/lib/isb-hub-stack.ts @@ -0,0 +1,176 @@ +import * as cdk from 'aws-cdk-lib'; +import * as s3 from 'aws-cdk-lib/aws-s3'; +import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as cfn from 'aws-cdk-lib/aws-cloudformation'; +import { Construct } from 'constructs'; +import * as path from 'path'; + +const HUB_ACCOUNT = '568672915267'; +const ISB_NAMESPACE = 'ndx'; +const BLUEPRINTS_BUCKET_NAME = `ndx-try-isb-blueprints-${HUB_ACCOUNT}`; +const BLUEPRINTS_BUCKET_REGION = 'us-east-1'; +const GITHUB_REPO = 'co-cddo/ndx_try_aws_scenarios'; +const DEPLOY_ROLE_NAME = 'isb-hub-github-actions-deploy'; + +const SCENARIOS = [ + { name: 'council-chatbot', description: 'NDX:Try Council Chatbot - AI-powered resident Q&A assistant' }, + { name: 'foi-redaction', description: 'NDX:Try FOI Redaction - Automated sensitive data redaction for FOI requests' }, + { name: 'planning-ai', description: 'NDX:Try Planning Application AI - Intelligent document analysis for planning decisions' }, + { name: 'quicksight-dashboard', description: 'NDX:Try QuickSight Dashboard - Service performance analytics and reporting' }, + { name: 'smart-car-park', description: 'NDX:Try Smart Car Park - Real-time parking availability with DynamoDB' }, + { name: 'text-to-speech', description: 'NDX:Try Text to Speech - Accessibility audio generation using Amazon Polly' }, + { name: 'localgov-drupal', description: 'NDX:Try LocalGov Drupal - AI-enhanced CMS for UK councils' }, +]; + +export class IsbHubStack extends cdk.Stack { + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + // ======================================================================== + // S3 BUCKET (imported — already exists in us-east-1) + // ======================================================================== + const bucket = s3.Bucket.fromBucketName(this, 'BlueprintsBucket', BLUEPRINTS_BUCKET_NAME); + + // ======================================================================== + // TEMPLATE UPLOADS — one BucketDeployment per scenario + // ======================================================================== + const deployments: Record = {}; + + for (const scenario of SCENARIOS) { + const pascalName = scenario.name + .split('-') + .map(s => s.charAt(0).toUpperCase() + s.slice(1)) + .join(''); + + const deployment = new s3deploy.BucketDeployment(this, `${pascalName}Templates`, { + sources: [ + s3deploy.Source.asset(path.join(__dirname, '..', '..', 'scenarios', scenario.name), { + exclude: ['*', '!template.yaml'], + }), + ], + destinationBucket: bucket, + destinationKeyPrefix: `scenarios/${scenario.name}`, + }); + + // Grant permissions on imported bucket (CDK can't auto-grant on imported resources) + bucket.grantReadWrite(deployment.handlerRole); + + deployments[scenario.name] = deployment; + } + + // ======================================================================== + // GITHUB OIDC PROVIDER (lookup existing — only one can exist per account) + // ======================================================================== + // If the OIDC provider does NOT exist yet, comment out the fromOpenIdConnectProviderArn + // line and uncomment the new OpenIdConnectProvider block below. + const oidcProvider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn( + this, + 'GitHubOidc', + `arn:aws:iam::${HUB_ACCOUNT}:oidc-provider/token.actions.githubusercontent.com`, + ); + + // Uncomment this block if no OIDC provider exists in the account: + // const oidcProvider = new iam.OpenIdConnectProvider(this, 'GitHubOidc', { + // url: 'https://token.actions.githubusercontent.com', + // clientIds: ['sts.amazonaws.com'], + // }); + + // ======================================================================== + // GITHUB ACTIONS IAM ROLE + // ======================================================================== + const deployRole = new iam.Role(this, 'GitHubActionsRole', { + roleName: DEPLOY_ROLE_NAME, + assumedBy: new iam.FederatedPrincipal( + oidcProvider.openIdConnectProviderArn, + { + StringEquals: { + 'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com', + }, + StringLike: { + 'token.actions.githubusercontent.com:sub': `repo:${GITHUB_REPO}:ref:refs/heads/main`, + }, + }, + 'sts:AssumeRoleWithWebIdentity', + ), + }); + + // Allow CDK deploy (assume CDK bootstrap roles) + deployRole.addToPolicy( + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ['sts:AssumeRole'], + resources: [ + `arn:aws:iam::${HUB_ACCOUNT}:role/cdk-*-${HUB_ACCOUNT}-us-west-2`, + ], + }), + ); + + // Allow S3 operations on blueprints bucket + deployRole.addToPolicy( + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ['s3:GetObject', 's3:PutObject', 's3:DeleteObject', 's3:ListBucket'], + resources: [ + `arn:aws:s3:::${BLUEPRINTS_BUCKET_NAME}`, + `arn:aws:s3:::${BLUEPRINTS_BUCKET_NAME}/*`, + ], + }), + ); + + // Allow StackSet management + deployRole.addToPolicy( + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: [ + 'cloudformation:CreateStackSet', + 'cloudformation:UpdateStackSet', + 'cloudformation:DeleteStackSet', + 'cloudformation:DescribeStackSet', + 'cloudformation:ListStackSets', + 'cloudformation:ListStackInstances', + ], + resources: [ + `arn:aws:cloudformation:*:${HUB_ACCOUNT}:stackset/ndx-try-*:*`, + ], + }), + ); + + // ======================================================================== + // STACKSETS — one per scenario, no stack instances (ISB manages those) + // ======================================================================== + for (const scenario of SCENARIOS) { + const pascalName = scenario.name + .split('-') + .map(s => s.charAt(0).toUpperCase() + s.slice(1)) + .join(''); + + const stackSet = new cfn.CfnStackSet(this, `${pascalName}StackSet`, { + stackSetName: `ndx-try-${scenario.name}`, + permissionModel: 'SELF_MANAGED', + administrationRoleArn: `arn:aws:iam::${HUB_ACCOUNT}:role/InnovationSandbox-${ISB_NAMESPACE}-IntermediateRole`, + executionRoleName: `InnovationSandbox-${ISB_NAMESPACE}-SandboxAccountRole`, + capabilities: ['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM', 'CAPABILITY_AUTO_EXPAND'], + managedExecution: { Active: true }, + templateUrl: `https://${BLUEPRINTS_BUCKET_NAME}.s3.${BLUEPRINTS_BUCKET_REGION}.amazonaws.com/scenarios/${scenario.name}/template.yaml`, + description: scenario.description, + }); + + // Ensure template is uploaded before StackSet references it + stackSet.node.addDependency(deployments[scenario.name]); + } + + // ======================================================================== + // OUTPUTS + // ======================================================================== + new cdk.CfnOutput(this, 'DeployRoleArn', { + value: deployRole.roleArn, + description: 'GitHub Actions deploy role ARN', + }); + + new cdk.CfnOutput(this, 'BlueprintsBucketName', { + value: BLUEPRINTS_BUCKET_NAME, + description: 'S3 bucket for ISB blueprint templates', + }); + } +} diff --git a/cloudformation/isb-hub/package-lock.json b/cloudformation/isb-hub/package-lock.json new file mode 100644 index 00000000..db73a285 --- /dev/null +++ b/cloudformation/isb-hub/package-lock.json @@ -0,0 +1,700 @@ +{ + "name": "isb-hub", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "isb-hub", + "version": "1.0.0", + "dependencies": { + "aws-cdk-lib": "^2.238.0", + "constructs": "^10.5.0" + }, + "devDependencies": { + "@types/node": "^25.2.3", + "aws-cdk": "^2.1106.0", + "ts-node": "^10.9.2", + "typescript": "~5.9.3" + } + }, + "node_modules/@aws-cdk/asset-awscli-v1": { + "version": "2.2.263", + "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.263.tgz", + "integrity": "sha512-X9JvcJhYcb7PHs8R7m4zMablO5C9PGb/hYfLnxds9h/rKJu6l7MiXE/SabCibuehxPnuO/vk+sVVJiUWrccarQ==", + "license": "Apache-2.0" + }, + "node_modules/@aws-cdk/asset-node-proxy-agent-v6": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.1.1.tgz", + "integrity": "sha512-We4bmHaowOPHr+IQR4/FyTGjRfjgBj4ICMjtqmJeBDWad3Q/6St12NT07leNtyuukv2qMhtSZJQorD8KpKTwRA==", + "license": "Apache-2.0" + }, + "node_modules/@aws-cdk/cloud-assembly-schema": { + "version": "50.4.0", + "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-50.4.0.tgz", + "integrity": "sha512-9Cplwc5C+SNe3hMfqZET7gXeM68tiH2ytQytCi+zz31Bn7O3GAgAnC2dYe+HWnZAgVH788ZkkBwnYXkeqx7v4g==", + "bundleDependencies": [ + "jsonschema", + "semver" + ], + "license": "Apache-2.0", + "dependencies": { + "jsonschema": "~1.4.1", + "semver": "^7.7.3" + }, + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/jsonschema": { + "version": "1.4.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/semver": { + "version": "7.7.3", + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.3.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.2.tgz", + "integrity": "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/aws-cdk": { + "version": "2.1108.0", + "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1108.0.tgz", + "integrity": "sha512-FHnyhnYZoRc2W0C9mNzhNn6fO2vH4xNINsKfJaA7AFDuymgQ39JhEnrM4AHaoikIBqXYeNLWElvvkusY9l3ulw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "cdk": "bin/cdk" + }, + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/aws-cdk-lib": { + "version": "2.240.0", + "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.240.0.tgz", + "integrity": "sha512-3dXmUnPB5kK0VgrNHOlV3jiQM4Dungukk/CV91nclO2lgNcrGyigauJdzmz9sOmI1gbKJJ2SRAotaXityzZMRw==", + "bundleDependencies": [ + "@balena/dockerignore", + "@aws-cdk/cloud-assembly-api", + "case", + "fs-extra", + "ignore", + "jsonschema", + "minimatch", + "punycode", + "semver", + "table", + "yaml", + "mime-types" + ], + "license": "Apache-2.0", + "dependencies": { + "@aws-cdk/asset-awscli-v1": "2.2.263", + "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.0", + "@aws-cdk/cloud-assembly-api": "^2.0.1", + "@aws-cdk/cloud-assembly-schema": "^50.3.0", + "@balena/dockerignore": "^1.0.2", + "case": "1.6.3", + "fs-extra": "^11.3.3", + "ignore": "^5.3.2", + "jsonschema": "^1.5.0", + "mime-types": "^2.1.35", + "minimatch": "^10.2.1", + "punycode": "^2.3.1", + "semver": "^7.7.4", + "table": "^6.9.0", + "yaml": "1.10.2" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "constructs": "^10.5.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api": { + "version": "2.0.1", + "bundleDependencies": [ + "jsonschema", + "semver" + ], + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "jsonschema": "~1.4.1", + "semver": "^7.7.3" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "@aws-cdk/cloud-assembly-schema": ">=50.3.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api/node_modules/jsonschema": { + "version": "1.4.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api/node_modules/semver": { + "version": "7.7.3", + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": { + "version": "1.0.2", + "inBundle": true, + "license": "Apache-2.0" + }, + "node_modules/aws-cdk-lib/node_modules/ajv": { + "version": "8.18.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/aws-cdk-lib/node_modules/ansi-regex": { + "version": "5.0.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/ansi-styles": { + "version": "4.3.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aws-cdk-lib/node_modules/astral-regex": { + "version": "2.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/balanced-match": { + "version": "4.0.4", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/aws-cdk-lib/node_modules/brace-expansion": { + "version": "5.0.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/aws-cdk-lib/node_modules/case": { + "version": "1.6.3", + "inBundle": true, + "license": "(MIT OR GPL-3.0-or-later)", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/color-convert": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/color-name": { + "version": "1.1.4", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/emoji-regex": { + "version": "8.0.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/fast-deep-equal": { + "version": "3.1.3", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/fast-uri": { + "version": "3.1.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "inBundle": true, + "license": "BSD-3-Clause" + }, + "node_modules/aws-cdk-lib/node_modules/fs-extra": { + "version": "11.3.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/aws-cdk-lib/node_modules/graceful-fs": { + "version": "4.2.11", + "inBundle": true, + "license": "ISC" + }, + "node_modules/aws-cdk-lib/node_modules/ignore": { + "version": "5.3.2", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/aws-cdk-lib/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/json-schema-traverse": { + "version": "1.0.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/jsonfile": { + "version": "6.2.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/jsonschema": { + "version": "1.5.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/aws-cdk-lib/node_modules/lodash.truncate": { + "version": "4.4.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/mime-db": { + "version": "1.52.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/mime-types": { + "version": "2.1.35", + "inBundle": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/minimatch": { + "version": "10.2.2", + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/aws-cdk-lib/node_modules/punycode": { + "version": "2.3.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/aws-cdk-lib/node_modules/require-from-string": { + "version": "2.0.2", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/semver": { + "version": "7.7.4", + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aws-cdk-lib/node_modules/slice-ansi": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/aws-cdk-lib/node_modules/string-width": { + "version": "4.2.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/strip-ansi": { + "version": "6.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/table": { + "version": "6.9.0", + "inBundle": true, + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/universalify": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/yaml": { + "version": "1.10.2", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/constructs": { + "version": "10.5.1", + "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.5.1.tgz", + "integrity": "sha512-f/TfFXiS3G/yVIXDjOQn9oTlyu9Wo7Fxyjj7lb8r92iO81jR2uST+9MstxZTmDGx/CgIbxCXkFXgupnLTNxQZg==", + "license": "Apache-2.0" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/cloudformation/isb-hub/package.json b/cloudformation/isb-hub/package.json new file mode 100644 index 00000000..6a2ca8ae --- /dev/null +++ b/cloudformation/isb-hub/package.json @@ -0,0 +1,22 @@ +{ + "name": "isb-hub", + "version": "1.0.0", + "description": "ISB Hub Account Infrastructure - OIDC, IAM, S3 template uploads, StackSets", + "main": "dist/lib/isb-hub-stack.js", + "types": "dist/lib/isb-hub-stack.d.ts", + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "cdk": "cdk" + }, + "devDependencies": { + "@types/node": "^25.2.3", + "ts-node": "^10.9.2", + "typescript": "~5.9.3", + "aws-cdk": "^2.1106.0" + }, + "dependencies": { + "aws-cdk-lib": "^2.238.0", + "constructs": "^10.5.0" + } +} diff --git a/cloudformation/isb-hub/tsconfig.json b/cloudformation/isb-hub/tsconfig.json new file mode 100644 index 00000000..b21e47e8 --- /dev/null +++ b/cloudformation/isb-hub/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": false, + "inlineSourceMap": true, + "inlineSources": true, + "strictPropertyInitialization": false, + "outDir": "./dist", + "rootDir": "./" + }, + "exclude": [ + "node_modules", + "cdk.out", + "dist" + ] +} diff --git a/cloudformation/scenarios/council-chatbot/BLUEPRINT.md b/cloudformation/scenarios/council-chatbot/BLUEPRINT.md new file mode 100644 index 00000000..ceebfedc --- /dev/null +++ b/cloudformation/scenarios/council-chatbot/BLUEPRINT.md @@ -0,0 +1,69 @@ +# ISB Blueprint Registration: Council Chatbot + +Register the NDX:Try Council Chatbot scenario as an Innovation Sandbox (ISB) blueprint so it auto-deploys into sandbox accounts when leases are approved. + +## Prerequisites + +- AWS Innovation Sandbox deployed in the hub account +- Hub account ID known (referred to as `{HUB_ACCOUNT_ID}` below) +- ISB namespace known (referred to as `{NAMESPACE}` below) +- An S3 bucket accessible from the hub account for hosting the template (referred to as `{BUCKET}` in region `{REGION}`) +- Amazon Bedrock model `amazon.nova-pro-v1:0` enabled in `us-east-1` in sandbox accounts + +## Step 1 — Upload Template to S3 + +Upload the CloudFormation template to an S3 bucket accessible from the hub account: + +```bash +aws s3 cp template.yaml \ + s3://{BUCKET}/scenarios/council-chatbot/template.yaml +``` + +## Step 2 — Create StackSet + +Create a self-managed CloudFormation StackSet using ISB's roles. The StackSet must be created in the same region as your ISB deployment (e.g. `us-west-2`): + +```bash +aws cloudformation create-stack-set \ + --stack-set-name ndx-try-council-chatbot \ + --template-url https://{BUCKET}.s3.{REGION}.amazonaws.com/scenarios/council-chatbot/template.yaml \ + --administration-role-arn arn:aws:iam::{HUB_ACCOUNT_ID}:role/InnovationSandbox-{NAMESPACE}-IntermediateRole \ + --execution-role-name InnovationSandbox-{NAMESPACE}-SandboxAccountRole \ + --managed-execution Active=true \ + --capabilities CAPABILITY_IAM CAPABILITY_NAMED_IAM CAPABILITY_AUTO_EXPAND \ + --description "NDX:Try Council Chatbot - AI-powered resident Q&A assistant" +``` + +Notes: +- `CAPABILITY_NAMED_IAM` is required because the template uses explicit IAM role names +- `CAPABILITY_AUTO_EXPAND` is required because the template uses the SAM transform (`AWS::Serverless-2016-10-31`) +- `--managed-execution Active=true` is recommended for concurrent lease handling +- Blueprint name must match pattern `^[a-zA-Z][a-zA-Z0-9-]{0,49}$` + +## Step 3 — Register in ISB + +1. Navigate to ISB admin console > **Blueprints** > **Register New Blueprint** +2. Enter name: `ndx-try-council-chatbot` (must be 1-50 chars, start with letter, alphanumeric + hyphens only) +3. Select the `ndx-try-council-chatbot` StackSet +4. Configure deployment: + - Select target regions (template is designed for `us-east-1` due to Bedrock model availability) + - Set timeout: **5 minutes** recommended for this lightweight template +5. Review and submit + +## Step 4 — Associate with Lease Template + +1. In ISB admin console, navigate to **Lease Templates** +2. Edit an existing lease template or create a new one +3. In the blueprint selection step, select `ndx-try-council-chatbot` +4. Save the lease template + +## Verification + +1. Request a test lease using the lease template from Step 4 +2. Wait for lease approval and blueprint deployment (should complete within 5 minutes) +3. Check the sandbox account for: + - Lambda function `ndx-try-chatbot-handler-{region}` + - S3 bucket `ndx-try-chatbot-kb-{account-id}-{region}` + - IAM role `ndx-try-chatbot-role-{region}` +4. Open the Lambda Function URL and verify the chatbot responds to queries +5. Terminate the test lease and verify ISB cleans up all resources via AWS Nuke diff --git a/cloudformation/scenarios/council-chatbot/template.yaml b/cloudformation/scenarios/council-chatbot/template.yaml index 50bd07f4..67962dbe 100644 --- a/cloudformation/scenarios/council-chatbot/template.yaml +++ b/cloudformation/scenarios/council-chatbot/template.yaml @@ -14,10 +14,6 @@ Metadata: Parameters: - Environment - KnowledgeBaseSource - - Label: - default: Auto-Cleanup - Parameters: - - AutoCleanupHours Parameters: Environment: @@ -34,13 +30,6 @@ Parameters: Default: council-sample-data Description: Knowledge base source for chatbot - AutoCleanupHours: - Type: Number - Default: 2 - MinValue: 1 - MaxValue: 24 - Description: Hours until automatic resource cleanup - Resources: # S3 Bucket for Knowledge Base KnowledgeBaseBucket: @@ -56,11 +45,6 @@ Resources: BlockPublicPolicy: true IgnorePublicAcls: true RestrictPublicBuckets: true - LifecycleConfiguration: - Rules: - - Id: AutoCleanup - Status: Enabled - ExpirationInDays: 1 Tags: - Key: Project Value: ndx-try @@ -103,7 +87,7 @@ Resources: Action: - bedrock:InvokeModel Resource: - - !Sub 'arn:aws:bedrock:${AWS::Region}::foundation-model/amazon.nova-pro-v1:0' + - 'arn:aws:bedrock:us-east-1::foundation-model/amazon.nova-pro-v1:0' Tags: - Key: Project Value: ndx-try @@ -439,7 +423,6 @@ Outputs: { "scenario": "council-chatbot", "environment": "${Environment}", - "autoCleanup": "${AutoCleanupHours} hours", "mode": "Amazon Nova Pro", "note": "Real AI-powered responses using Amazon Bedrock" } diff --git a/cloudformation/scenarios/localgov-drupal/BLUEPRINT.md b/cloudformation/scenarios/localgov-drupal/BLUEPRINT.md new file mode 100644 index 00000000..58b91dc6 --- /dev/null +++ b/cloudformation/scenarios/localgov-drupal/BLUEPRINT.md @@ -0,0 +1,73 @@ +# ISB Blueprint Registration: LocalGov Drupal + +Register the NDX:Try LocalGov Drupal scenario as an Innovation Sandbox (ISB) blueprint so it auto-deploys into sandbox accounts when leases are approved. + +## Prerequisites + +- AWS Innovation Sandbox deployed in the hub account +- Hub account ID known (referred to as `{HUB_ACCOUNT_ID}` below) +- ISB namespace known (referred to as `{NAMESPACE}` below) +- An S3 bucket accessible from the hub account for hosting the template (referred to as `{BUCKET}` in region `{REGION}`) +- Amazon Bedrock models `amazon.nova-pro-v1:0` and `amazon.nova-canvas-v1:0` enabled in `us-east-1` in sandbox accounts + +## Step 1 — Upload Template to S3 + +Upload the CloudFormation template to an S3 bucket accessible from the hub account: + +```bash +aws s3 cp template.yaml \ + s3://{BUCKET}/scenarios/localgov-drupal/template.yaml +``` + +## Step 2 — Create StackSet + +Create a self-managed CloudFormation StackSet using ISB's roles. The StackSet must be created in the same region as your ISB deployment (e.g. `us-west-2`): + +```bash +aws cloudformation create-stack-set \ + --stack-set-name ndx-try-localgov-drupal \ + --template-url https://{BUCKET}.s3.{REGION}.amazonaws.com/scenarios/localgov-drupal/template.yaml \ + --administration-role-arn arn:aws:iam::{HUB_ACCOUNT_ID}:role/InnovationSandbox-{NAMESPACE}-IntermediateRole \ + --execution-role-name InnovationSandbox-{NAMESPACE}-SandboxAccountRole \ + --managed-execution Active=true \ + --capabilities CAPABILITY_IAM CAPABILITY_NAMED_IAM \ + --description "NDX:Try LocalGov Drupal - AI-enhanced CMS for UK councils" +``` + +Notes: +- `CAPABILITY_IAM` and `CAPABILITY_NAMED_IAM` are required because the template uses explicit IAM role names matching ISB SCP patterns +- `CAPABILITY_AUTO_EXPAND` is not required for this template (no transforms), but the ISB hub stack applies it uniformly to all scenarios +- `--managed-execution Active=true` is recommended for concurrent lease handling +- Blueprint name must match pattern `^[a-zA-Z][a-zA-Z0-9-]{0,49}$` + +## Step 3 — Register in ISB + +1. Navigate to ISB admin console > **Blueprints** > **Register New Blueprint** +2. Enter name: `ndx-try-localgov-drupal` (must be 1-50 chars, start with letter, alphanumeric + hyphens only) +3. Select the `ndx-try-localgov-drupal` StackSet +4. Configure deployment: + - Select target regions: `us-east-1` (required for Amazon Bedrock model availability) + - Set timeout: **45 minutes** recommended (Aurora provisioning + Fargate startup + Drupal installation + AI content generation) +5. Review and submit + +## Step 4 — Associate with Lease Template + +1. In ISB admin console, navigate to **Lease Templates** +2. Edit an existing lease template or create a new one +3. In the blueprint selection step, select `ndx-try-localgov-drupal` +4. Save the lease template + +## Verification + +1. Request a test lease using the lease template from Step 4 +2. Wait for lease approval and blueprint deployment (allow up to 45 minutes) +3. Check the sandbox account for: + - CloudFront distribution + - Aurora Serverless v2 cluster + - Fargate service running in ECS + - EFS file system + - Secrets Manager secret for admin credentials +4. Access Drupal via the CloudFront URL from the stack outputs +5. Log in with the admin credentials from the `AdminUsername` and `AdminPassword` stack outputs +6. Verify the admin password differs from any previous deployment (each deployment generates a unique password via Secrets Manager) +7. Terminate the test lease and verify ISB cleans up all resources via AWS Nuke diff --git a/cloudformation/scenarios/localgov-drupal/cdk/bin/app.ts b/cloudformation/scenarios/localgov-drupal/cdk/bin/app.ts index 44ec92f0..cb7eb0fe 100644 --- a/cloudformation/scenarios/localgov-drupal/cdk/bin/app.ts +++ b/cloudformation/scenarios/localgov-drupal/cdk/bin/app.ts @@ -5,20 +5,10 @@ import { LocalGovDrupalStack } from '../lib/localgov-drupal-stack'; const app = new cdk.App(); -// Get deployment mode from context or environment -const deploymentMode = app.node.tryGetContext('deploymentMode') ?? - (process.env.DEPLOYMENT_MODE as 'development' | 'production') ?? - 'production'; - -// Get council theme from context -const councilTheme = app.node.tryGetContext('councilTheme') ?? 'random'; - new LocalGovDrupalStack(app, 'LocalGovDrupalStack', { - env: { - account: process.env.CDK_DEFAULT_ACCOUNT, - region: process.env.CDK_DEFAULT_REGION || 'us-east-1', - }, + // No env — produces environment-agnostic template using CloudFormation intrinsics + // (Fn::GetAZs, Ref: AWS::Region) so the same template works in any account/region via StackSets description: 'AI-Enhanced LocalGov Drupal on AWS - Demonstration Environment', - deploymentMode: deploymentMode as 'development' | 'production', - councilTheme: councilTheme as 'random' | 'urban' | 'rural' | 'coastal' | 'historic', + deploymentMode: 'production', + councilTheme: 'random', }); diff --git a/cloudformation/scenarios/localgov-drupal/cdk/lib/constructs/compute.ts b/cloudformation/scenarios/localgov-drupal/cdk/lib/constructs/compute.ts index 8bbe870e..ac14943a 100644 --- a/cloudformation/scenarios/localgov-drupal/cdk/lib/constructs/compute.ts +++ b/cloudformation/scenarios/localgov-drupal/cdk/lib/constructs/compute.ts @@ -63,10 +63,11 @@ export interface ComputeConstructProps { readonly waitConditionUrl?: string; /** - * Admin password for Drupal. - * If provided, sets ADMIN_PASSWORD environment variable. + * Secrets Manager secret containing admin credentials. + * Must contain JSON keys 'username' and 'password'. + * Resolved at CloudFormation deploy time via dynamic reference. */ - readonly adminPassword?: string; + readonly adminSecret: secretsmanager.ISecret; } /** @@ -142,8 +143,9 @@ export class ComputeConstruct extends Construct { ], }); - // Allow reading database secret + // Allow reading database and admin secrets props.databaseSecret.grantRead(executionRole); + props.adminSecret.grantRead(executionRole); // ========================================================================== // Task Role (for AWS AI services) @@ -236,8 +238,8 @@ export class ComputeConstruct extends Construct { // Fargate Task Definition // ========================================================================== const taskDefinition = new ecs.FargateTaskDefinition(this, 'TaskDef', { - cpu: 512, // 0.5 vCPU - memoryLimitMiB: 1024, // 1 GB + cpu: 1024, // 1 vCPU + memoryLimitMiB: 2048, // 2 GB executionRole, taskRole, }); @@ -268,11 +270,6 @@ export class ComputeConstruct extends Construct { containerEnvironment.WAIT_CONDITION_URL = props.waitConditionUrl; } - // Add admin password if provided - if (props.adminPassword) { - containerEnvironment.ADMIN_PASSWORD = props.adminPassword; - } - // Add container - pull from GitHub Container Registry const container = taskDefinition.addContainer('drupal', { image: ecs.ContainerImage.fromRegistry('ghcr.io/co-cddo/ndx_try_aws_scenarios-localgov_drupal:latest'), @@ -287,6 +284,7 @@ export class ComputeConstruct extends Construct { // (workaround for sandbox SCP restrictions on secretsmanager:GetSecretValue) DB_USER: props.databaseSecret.secretValueFromJson('username').unsafeUnwrap(), DB_PASSWORD: props.databaseSecret.secretValueFromJson('password').unsafeUnwrap(), + ADMIN_PASSWORD: props.adminSecret.secretValueFromJson('password').unsafeUnwrap(), }, portMappings: [ { diff --git a/cloudformation/scenarios/localgov-drupal/cdk/lib/constructs/database.ts b/cloudformation/scenarios/localgov-drupal/cdk/lib/constructs/database.ts index 603d7b8e..481bacc8 100644 --- a/cloudformation/scenarios/localgov-drupal/cdk/lib/constructs/database.ts +++ b/cloudformation/scenarios/localgov-drupal/cdk/lib/constructs/database.ts @@ -101,14 +101,10 @@ export class DatabaseConstruct extends Construct { readers: [], // Backup configuration backup: { - retention: deploymentMode === 'production' ? cdk.Duration.days(7) : cdk.Duration.days(1), + retention: cdk.Duration.days(7), }, - // Enable deletion protection in production mode - deletionProtection: false, // Disabled for demo cleanup - removalPolicy: - deploymentMode === 'development' - ? cdk.RemovalPolicy.DESTROY - : cdk.RemovalPolicy.SNAPSHOT, + deletionProtection: false, + removalPolicy: cdk.RemovalPolicy.DESTROY, }); // Expose cluster endpoint for other constructs diff --git a/cloudformation/scenarios/localgov-drupal/cdk/lib/constructs/storage.ts b/cloudformation/scenarios/localgov-drupal/cdk/lib/constructs/storage.ts index 3e709d0b..e19fd91e 100644 --- a/cloudformation/scenarios/localgov-drupal/cdk/lib/constructs/storage.ts +++ b/cloudformation/scenarios/localgov-drupal/cdk/lib/constructs/storage.ts @@ -74,10 +74,7 @@ export class StorageConstruct extends Construct { performanceMode: efs.PerformanceMode.GENERAL_PURPOSE, throughputMode: efs.ThroughputMode.BURSTING, lifecyclePolicy: efs.LifecyclePolicy.AFTER_30_DAYS, - removalPolicy: - deploymentMode === 'development' - ? cdk.RemovalPolicy.DESTROY - : cdk.RemovalPolicy.RETAIN, + removalPolicy: cdk.RemovalPolicy.DESTROY, fileSystemName: `NdxDrupal-Files-${deploymentMode}`, }); diff --git a/cloudformation/scenarios/localgov-drupal/cdk/lib/localgov-drupal-stack.ts b/cloudformation/scenarios/localgov-drupal/cdk/lib/localgov-drupal-stack.ts index a632beb6..221a0309 100644 --- a/cloudformation/scenarios/localgov-drupal/cdk/lib/localgov-drupal-stack.ts +++ b/cloudformation/scenarios/localgov-drupal/cdk/lib/localgov-drupal-stack.ts @@ -1,4 +1,5 @@ import * as cdk from 'aws-cdk-lib'; +import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; import { Construct } from 'constructs'; import { CloudFrontConstruct } from './constructs/cloudfront'; import { ComputeConstruct } from './constructs/compute'; @@ -52,8 +53,18 @@ export class LocalGovDrupalStack extends cdk.Stack { // Storing in SSM parameter for future use const councilTheme = props?.councilTheme ?? 'random'; - // Generate random admin password (changes each synth/deploy) - const adminPassword = `Demo${Math.random().toString(36).substring(2, 10)}${Math.random().toString(36).substring(2, 6)}!`; + // Admin credentials via Secrets Manager — resolved at CloudFormation deploy time + // No secretName so CloudFormation generates a unique physical name, avoiding + // collision on rollback (Secrets Manager 7-30 day recovery window) + const adminSecret = new secretsmanager.Secret(this, 'AdminSecret', { + description: 'Admin credentials for LocalGov Drupal', + generateSecretString: { + secretStringTemplate: JSON.stringify({ username: 'admin' }), + generateStringKey: 'password', + excludePunctuation: true, + passwordLength: 32, + }, + }); // Tag all resources cdk.Tags.of(this).add('Project', 'ndx-try-aws-scenarios'); @@ -91,7 +102,7 @@ export class LocalGovDrupalStack extends cdk.Stack { fileSystem: storage.fileSystem, accessPoint: storage.accessPoint, deploymentMode, - adminPassword, + adminSecret, }); // CloudFront distribution for HTTPS termination @@ -117,10 +128,10 @@ export class LocalGovDrupalStack extends cdk.Stack { value: 'admin', }); - // Admin password (generated randomly per deploy) + // Admin password — link to Secrets Manager console to retrieve the value new cdk.CfnOutput(this, 'AdminPassword', { - description: 'Drupal admin password', - value: adminPassword, + description: 'Drupal admin password (retrieve from Secrets Manager)', + value: `https://console.aws.amazon.com/secretsmanager/secret?name=${adminSecret.secretName}®ion=${cdk.Aws.REGION}`, }); // CloudWatch Logs URL for monitoring initialization diff --git a/cloudformation/scenarios/localgov-drupal/cdk/test/localgov-drupal-stack.test.ts b/cloudformation/scenarios/localgov-drupal/cdk/test/localgov-drupal-stack.test.ts index b6809862..9be68638 100644 --- a/cloudformation/scenarios/localgov-drupal/cdk/test/localgov-drupal-stack.test.ts +++ b/cloudformation/scenarios/localgov-drupal/cdk/test/localgov-drupal-stack.test.ts @@ -12,7 +12,6 @@ describe('LocalGovDrupalStack', () => { test('Stack synthesizes without errors', () => { const app = new cdk.App(); const stack = new LocalGovDrupalStack(app, 'TestStack', { - env: testEnv, }); // Verify template can be created @@ -25,7 +24,6 @@ describe('LocalGovDrupalStack', () => { test('Stack applies correct tags', () => { const app = new cdk.App(); const stack = new LocalGovDrupalStack(app, 'TestStack', { - env: testEnv, deploymentMode: 'development', }); @@ -40,15 +38,13 @@ describe('LocalGovDrupalStack', () => { // Should not throw with valid deploymentMode expect(() => { new LocalGovDrupalStack(app, 'DevStack', { - env: testEnv, - deploymentMode: 'development', + deploymentMode: 'development', }); }).not.toThrow(); expect(() => { new LocalGovDrupalStack(app, 'ProdStack', { - env: testEnv, - deploymentMode: 'production', + deploymentMode: 'production', }); }).not.toThrow(); }); @@ -59,8 +55,7 @@ describe('LocalGovDrupalStack', () => { // Should not throw with valid councilTheme expect(() => { new LocalGovDrupalStack(app, 'ThemeStack', { - env: testEnv, - councilTheme: 'coastal', + councilTheme: 'coastal', }); }).not.toThrow(); }); @@ -68,7 +63,6 @@ describe('LocalGovDrupalStack', () => { test('Stack is correctly named', () => { const app = new cdk.App(); const stack = new LocalGovDrupalStack(app, 'LocalGovDrupalStack', { - env: testEnv, }); expect(stack.stackName).toBe('LocalGovDrupalStack'); @@ -77,7 +71,6 @@ describe('LocalGovDrupalStack', () => { test('Stack creates security groups', () => { const app = new cdk.App(); const stack = new LocalGovDrupalStack(app, 'TestStack', { - env: testEnv, }); const template = Template.fromStack(stack); @@ -106,7 +99,6 @@ describe('LocalGovDrupalStack', () => { test('ALB security group allows HTTPS from anywhere', () => { const app = new cdk.App(); const stack = new LocalGovDrupalStack(app, 'TestStack', { - env: testEnv, }); const template = Template.fromStack(stack); @@ -129,7 +121,6 @@ describe('LocalGovDrupalStack', () => { test('Stack creates Aurora Serverless v2 cluster', () => { const app = new cdk.App(); const stack = new LocalGovDrupalStack(app, 'TestStack', { - env: testEnv, }); const template = Template.fromStack(stack); @@ -150,7 +141,6 @@ describe('LocalGovDrupalStack', () => { test('Stack creates Secrets Manager secret for database', () => { const app = new cdk.App(); const stack = new LocalGovDrupalStack(app, 'TestStack', { - env: testEnv, }); const template = Template.fromStack(stack); @@ -169,7 +159,6 @@ describe('LocalGovDrupalStack', () => { test('Stack creates Aurora writer instance', () => { const app = new cdk.App(); const stack = new LocalGovDrupalStack(app, 'TestStack', { - env: testEnv, }); const template = Template.fromStack(stack); @@ -185,7 +174,6 @@ describe('LocalGovDrupalStack', () => { test('Stack creates EFS file system with encryption', () => { const app = new cdk.App(); const stack = new LocalGovDrupalStack(app, 'TestStack', { - env: testEnv, }); const template = Template.fromStack(stack); @@ -201,7 +189,6 @@ describe('LocalGovDrupalStack', () => { test('Stack creates EFS access point', () => { const app = new cdk.App(); const stack = new LocalGovDrupalStack(app, 'TestStack', { - env: testEnv, }); const template = Template.fromStack(stack); @@ -226,7 +213,6 @@ describe('LocalGovDrupalStack', () => { test('Stack creates EFS mount targets', () => { const app = new cdk.App(); const stack = new LocalGovDrupalStack(app, 'TestStack', { - env: testEnv, }); const template = Template.fromStack(stack); @@ -240,7 +226,6 @@ describe('LocalGovDrupalStack', () => { test('Stack creates ECS cluster', () => { const app = new cdk.App(); const stack = new LocalGovDrupalStack(app, 'TestStack', { - env: testEnv, }); const template = Template.fromStack(stack); @@ -254,15 +239,14 @@ describe('LocalGovDrupalStack', () => { test('Stack creates Fargate task definition', () => { const app = new cdk.App(); const stack = new LocalGovDrupalStack(app, 'TestStack', { - env: testEnv, }); const template = Template.fromStack(stack); // Verify Fargate task definition with correct resources template.hasResourceProperties('AWS::ECS::TaskDefinition', { - Cpu: '512', - Memory: '1024', + Cpu: '1024', + Memory: '2048', RequiresCompatibilities: ['FARGATE'], NetworkMode: 'awsvpc', }); @@ -271,7 +255,6 @@ describe('LocalGovDrupalStack', () => { test('Stack creates Application Load Balancer', () => { const app = new cdk.App(); const stack = new LocalGovDrupalStack(app, 'TestStack', { - env: testEnv, }); const template = Template.fromStack(stack); @@ -286,7 +269,6 @@ describe('LocalGovDrupalStack', () => { test('Stack creates ECS service', () => { const app = new cdk.App(); const stack = new LocalGovDrupalStack(app, 'TestStack', { - env: testEnv, }); const template = Template.fromStack(stack); @@ -301,89 +283,35 @@ describe('LocalGovDrupalStack', () => { test('Stack creates ALB target group with health check', () => { const app = new cdk.App(); const stack = new LocalGovDrupalStack(app, 'TestStack', { - env: testEnv, }); const template = Template.fromStack(stack); // Verify target group has health check configured template.hasResourceProperties('AWS::ElasticLoadBalancingV2::TargetGroup', { - HealthCheckPath: '/', + HealthCheckPath: '/health', TargetType: 'ip', Protocol: 'HTTP', }); }); - // Story 1.8 - Drupal Init & WaitCondition tests - test('Stack creates WaitCondition for Drupal initialization', () => { - const app = new cdk.App(); - const stack = new LocalGovDrupalStack(app, 'TestStack', { - env: testEnv, - }); - - const template = Template.fromStack(stack); - - // Verify WaitCondition is created with 15 minute timeout - template.hasResourceProperties('AWS::CloudFormation::WaitCondition', { - Timeout: '900', - Count: 1, - }); - }); - - test('Stack creates WaitConditionHandle for signaling', () => { - const app = new cdk.App(); - const stack = new LocalGovDrupalStack(app, 'TestStack', { - env: testEnv, - }); - - const template = Template.fromStack(stack); - - // Verify WaitConditionHandle is created - const handles = template.findResources('AWS::CloudFormation::WaitConditionHandle'); - expect(Object.keys(handles).length).toBe(1); - }); - - test('Task definition includes WAIT_CONDITION_URL environment variable', () => { - const app = new cdk.App(); - const stack = new LocalGovDrupalStack(app, 'TestStack', { - env: testEnv, - }); - - const template = Template.fromStack(stack); - - // Verify task definition has container with WAIT_CONDITION_URL env var - template.hasResourceProperties('AWS::ECS::TaskDefinition', { - ContainerDefinitions: Match.arrayWith([ - Match.objectLike({ - Environment: Match.arrayWith([ - Match.objectLike({ - Name: 'WAIT_CONDITION_URL', - }), - ]), - }), - ]), - }); - }); - // Story 1.12 - CloudFormation Outputs tests test('Stack outputs DrupalUrl', () => { const app = new cdk.App(); const stack = new LocalGovDrupalStack(app, 'TestStack', { - env: testEnv, }); const template = Template.fromStack(stack); // Verify DrupalUrl output exists with correct description template.hasOutput('DrupalUrl', { - Description: 'URL to access LocalGov Drupal', + Description: 'URL to access LocalGov Drupal (HTTPS)', }); }); test('Stack outputs AdminUsername', () => { const app = new cdk.App(); const stack = new LocalGovDrupalStack(app, 'TestStack', { - env: testEnv, }); const template = Template.fromStack(stack); @@ -398,21 +326,19 @@ describe('LocalGovDrupalStack', () => { test('Stack outputs AdminPassword from Secrets Manager', () => { const app = new cdk.App(); const stack = new LocalGovDrupalStack(app, 'TestStack', { - env: testEnv, }); const template = Template.fromStack(stack); - // Verify AdminPassword output exists + // Verify AdminPassword output exists with Secrets Manager console link template.hasOutput('AdminPassword', { - Description: 'Drupal admin password (from Secrets Manager)', + Description: 'Drupal admin password (retrieve from Secrets Manager)', }); }); test('Stack outputs CloudWatchLogsUrl', () => { const app = new cdk.App(); const stack = new LocalGovDrupalStack(app, 'TestStack', { - env: testEnv, }); const template = Template.fromStack(stack); diff --git a/cloudformation/scenarios/localgov-drupal/docker/scripts/init-drupal.sh b/cloudformation/scenarios/localgov-drupal/docker/scripts/init-drupal.sh index f81e445d..17b1617d 100755 --- a/cloudformation/scenarios/localgov-drupal/docker/scripts/init-drupal.sh +++ b/cloudformation/scenarios/localgov-drupal/docker/scripts/init-drupal.sh @@ -503,23 +503,38 @@ enable_localgov_modules() { localgov_workflows_notifications " - # Enable each module if available (ignore errors for missing optional modules) + # Filter to only modules that are available and not yet enabled + local modules_to_enable="" + local skipped=0 for module in $LOCALGOV_MODULES; do - log "Checking module: $module" if ./vendor/bin/drush pm:list --status=disabled --type=module --field=name 2>/dev/null | grep -q "^${module}$"; then - log " Enabling $module..." - local module_output - module_output=$(./vendor/bin/drush pm:enable "$module" --yes 2>&1) || true - if [ -n "$module_output" ]; then - echo "$module_output" | while IFS= read -r line; do - log " $line" - done - fi + modules_to_enable="$modules_to_enable $module" else + skipped=$((skipped + 1)) log " $module already enabled or not available, skipping" fi done + if [ -z "$modules_to_enable" ]; then + log "All LocalGov modules already enabled" + return 0 + fi + + local count + count=$(echo $modules_to_enable | wc -w | tr -d ' ') + log "Enabling $count LocalGov modules in a single batch (skipped $skipped)..." + update_status "Modules" "Enabling $count LocalGov modules in batch..." 73 + + # Single drush call: one PHP bootstrap, one dependency resolution, one final hook_modules_installed + local batch_output + batch_output=$(./vendor/bin/drush pm:install $modules_to_enable --yes 2>&1) || true + if [ -n "$batch_output" ]; then + echo "$batch_output" | while IFS= read -r line; do + log " $line" + done + fi + + update_status "Modules" "LocalGov modules enabled" 75 return 0 } @@ -529,47 +544,26 @@ enable_custom_modules() { cd "$DRUPAL_ROOT" - # Helper function to enable a module without problematic piping - # The | while read pattern causes SIGPIPE issues with drush - enable_module() { - local module_name="$1" - local module_dir="$DRUPAL_ROOT/web/modules/custom/$module_name" - - if [ -d "$module_dir" ]; then - log "Enabling $module_name module..." - # Use direct execution with output capture and retry for deadlocks - local output - if output=$(run_drush_with_retry ./vendor/bin/drush pm:enable "$module_name" --yes); then - log " $module_name enabled successfully" - [ -n "$output" ] && log " Output: $output" - return 0 - else - log " Warning: $module_name enable returned non-zero, but may have succeeded" - [ -n "$output" ] && log " Output: $output" - # Check if actually enabled despite the error using pm:list (filter warning lines) - sleep 2 # Wait for any pending cache writes to complete - local enabled_list - enabled_list=$(./vendor/bin/drush pm:list --status=enabled --type=module --field=name 2>/dev/null | grep -v '\[warning\]' | grep -v 'Drush command' | tr '\n' ' ') - if echo "$enabled_list" | grep -qw "$module_name"; then - log " Verified: $module_name is enabled" - return 0 - fi - log " Module $module_name not in enabled list - continuing anyway" - # Return 0 to avoid script exit - module may still work - return 0 - fi + # Collect custom modules that exist on disk + local NDX_MODULES="ndx_demo_banner ndx_welcome ndx_walkthrough ndx_aws_ai ndx_council_generator" + local custom_to_enable="" + for module in $NDX_MODULES; do + if [ -d "$DRUPAL_ROOT/web/modules/custom/$module" ]; then + custom_to_enable="$custom_to_enable $module" else - log "$module_name module not found at $module_dir, skipping" - return 0 + log "$module module not found, skipping" fi - } - - # Enable modules in dependency order - enable_module "ndx_demo_banner" # Story 1.10 - enable_module "ndx_welcome" # Story 1.11 - enable_module "ndx_walkthrough" # Epic 2 - enable_module "ndx_aws_ai" # Epic 3-4 dependency - enable_module "ndx_council_generator" # Epic 5 - requires ndx_aws_ai + done + + if [ -n "$custom_to_enable" ]; then + local count + count=$(echo $custom_to_enable | wc -w | tr -d ' ') + log "Enabling $count custom NDX modules in batch..." + update_status "Modules" "Enabling $count custom NDX modules..." 76 + local output + output=$(run_drush_with_retry ./vendor/bin/drush pm:install $custom_to_enable --yes) || true + [ -n "$output" ] && log " $output" + fi # Clear caches before verification to ensure module system is up to date log "Rebuilding cache before module verification..." diff --git a/cloudformation/scenarios/localgov-drupal/template.yaml b/cloudformation/scenarios/localgov-drupal/template.yaml deleted file mode 100644 index 81bcf4ea..00000000 --- a/cloudformation/scenarios/localgov-drupal/template.yaml +++ /dev/null @@ -1,14 +0,0 @@ -Description: AI-Enhanced LocalGov Drupal on AWS - Demonstration Environment -Resources: - CDKMetadata: - Type: AWS::CDK::Metadata - Properties: - Analytics: v2:deflate64:H4sIAAAAAAAA/zPSMzI21jNQTCwv1k1OydbNyUzSCy5JTM7WyctPSdXLKtYvMzLSM7TUM1DMKs7M1C0qzSvJzE3VC4LQAFKd5dM/AAAA - Metadata: - aws:cdk:path: LocalGovDrupalStack/CDKMetadata/Default -Parameters: - BootstrapVersion: - Type: AWS::SSM::Parameter::Value - Default: /cdk-bootstrap/hnb659fds/version - Description: Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip] - diff --git a/cloudformation/scenarios/quicksight-dashboard/template.yaml b/cloudformation/scenarios/quicksight-dashboard/template.yaml index beb75e9b..e9b97538 100644 --- a/cloudformation/scenarios/quicksight-dashboard/template.yaml +++ b/cloudformation/scenarios/quicksight-dashboard/template.yaml @@ -1,10 +1,9 @@ AWSTemplateFormatVersion: '2010-09-09' -Transform: AWS::Serverless-2016-10-31 Description: | NDX:Try - QuickSight Dashboard Scenario Real Amazon QuickSight analytics and reporting with council performance data. Uses Amazon QuickSight for business intelligence dashboards. - Version: 2.4.0 (Story 25.5 - Dashboard Filters and Embedding) + Version: 3.0.0 (Auto-subscribe QuickSight, fix ISB sandbox deployment) Metadata: AWS::CloudFormation::Interface: @@ -14,10 +13,6 @@ Metadata: Parameters: - Environment - SampleDataset - - Label: - default: Auto-Cleanup - Parameters: - - AutoCleanupHours Parameters: Environment: @@ -34,18 +29,6 @@ Parameters: Default: council-metrics Description: Sample dataset identifier for council performance metrics - AutoCleanupHours: - Type: Number - Default: 2 - MinValue: 1 - MaxValue: 24 - Description: Hours until automatic resource cleanup in sandbox environment - - QuickSightUsername: - Type: String - Default: 'AWSReservedSSO_ndx_IsbAdminsPS_53e05916f706247a/ndx@dsit.gov.uk' - Description: QuickSight username for resource permissions (found via aws quicksight list-users) - Resources: # ============================================================================ # S3 BUCKET FOR QUICKSIGHT DATA @@ -63,12 +46,6 @@ Resources: BlockPublicPolicy: true IgnorePublicAcls: true RestrictPublicBuckets: true - LifecycleConfiguration: - Rules: - - Id: AutoCleanupSandbox - Status: Enabled - ExpirationInDays: 7 - NoncurrentVersionExpirationInDays: 1 VersioningConfiguration: Status: Enabled Tags: @@ -131,7 +108,9 @@ Resources: - s3:PutObject - s3:GetObject - s3:DeleteObject + - s3:DeleteObjectVersion - s3:ListBucket + - s3:ListBucketVersions - s3:GetBucketLocation Resource: - !GetAtt DataBucket.Arn @@ -343,19 +322,26 @@ Resources: ) elif request_type == 'Delete': - print("Cleaning up data files...") + print("Emptying versioned bucket...") - # Delete data files try: - s3.delete_object(Bucket=bucket, Key=data_key) - s3.delete_object(Bucket=bucket, Key=manifest_key) + paginator = s3.get_paginator('list_object_versions') + for page in paginator.paginate(Bucket=bucket): + objects = [] + for v in page.get('Versions', []): + objects.append({'Key': v['Key'], 'VersionId': v['VersionId']}) + for dm in page.get('DeleteMarkers', []): + objects.append({'Key': dm['Key'], 'VersionId': dm['VersionId']}) + if objects: + s3.delete_objects(Bucket=bucket, Delete={'Objects': objects, 'Quiet': True}) + print(f"Deleted {len(objects)} object versions/markers") except Exception as e: - print(f"Error deleting files: {str(e)}") + print(f"Error emptying bucket: {str(e)}") send_cfn_response( event, context, 'SUCCESS', physical_id=f'{bucket}-data-generator', - reason='Data files cleaned up' + reason='Bucket emptied' ) except Exception as e: @@ -431,6 +417,451 @@ Resources: ServiceToken: !GetAtt DataGeneratorFunction.Arn Timestamp: !Sub '${AWS::StackName}-${AWS::StackId}' + # ============================================================================ + # QUICKSIGHT ACCOUNT SETUP - AUTO-SUBSCRIBE AND REGISTER ADMIN USER + # ============================================================================ + QuickSightSetupRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Sub 'ndx-quicksight-setup-${AWS::Region}' + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Policies: + - PolicyName: QuickSightSetup + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - quicksight:CreateAccountSubscription + - quicksight:Subscribe + - quicksight:DescribeAccountSubscription + - quicksight:RegisterUser + - quicksight:ListUsers + - quicksight:DeleteUser + - quicksight:CreateGroup + - quicksight:DeleteGroup + - quicksight:CreateGroupMembership + - quicksight:DeleteGroupMembership + - quicksight:DeleteAccountSubscription + - quicksight:UpdateAccountSettings + - quicksight:UpdateSPICECapacityConfiguration + - quicksight:DescribeAccountSettings + Resource: '*' + - Effect: Allow + Action: + - ds:AuthorizeApplication + - ds:CheckAlias + - ds:CreateAlias + - ds:CreateIdentityPoolDirectory + - ds:DeleteDirectory + - ds:DescribeDirectories + - ds:DescribeTrusts + - ds:UnauthorizeApplication + Resource: '*' + - Effect: Allow + Action: + - iam:CreateServiceLinkedRole + Resource: '*' + Condition: + StringEquals: + iam:AWSServiceName: quicksight.amazonaws.com + Tags: + - Key: Project + Value: ndx-try + - Key: Scenario + Value: quicksight-dashboard + + QuickSightSetupFunction: + Type: AWS::Lambda::Function + Properties: + FunctionName: !Sub 'ndx-quicksight-setup-${AWS::Region}' + Description: Auto-subscribes QuickSight and registers an IAM admin user + Runtime: python3.12 + Handler: index.handler + Role: !GetAtt QuickSightSetupRole.Arn + Timeout: 600 + MemorySize: 128 + Code: + ZipFile: | + import json + import boto3 + import time + import urllib3 + + qs = boto3.client('quicksight') + sts = boto3.client('sts') + http = urllib3.PoolManager() + + def send_cfn_response(event, context, status, data=None, physical_id=None, reason=None): + body = json.dumps({ + 'Status': status, + 'Reason': reason or f'See CloudWatch Log Stream: {context.log_stream_name}', + 'PhysicalResourceId': physical_id or context.log_stream_name, + 'StackId': event['StackId'], + 'RequestId': event['RequestId'], + 'LogicalResourceId': event['LogicalResourceId'], + 'Data': data or {} + }).encode('utf-8') + try: + http.request('PUT', event['ResponseURL'], body=body, + headers={'Content-Type': 'application/json', 'Content-Length': str(len(body))}) + except Exception as e: + print(f"Failed to send response: {e}") + + def wait_for_active(account_id, timeout=480): + """Poll until QuickSight subscription is ACCOUNT_CREATED.""" + deadline = time.time() + timeout + while time.time() < deadline: + try: + resp = qs.describe_account_subscription(AwsAccountId=account_id) + status = resp['AccountInfo']['AccountSubscriptionStatus'] + print(f"QuickSight status: {status}") + if status == 'ACCOUNT_CREATED': + return True + if status == 'SIGNING_UP_FAILED': + raise RuntimeError(f"QuickSight subscription failed: {status}") + except qs.exceptions.ResourceNotFoundException: + print("Subscription not found yet, retrying...") + time.sleep(15) + raise TimeoutError("QuickSight did not become active in time") + + def wait_for_unsubscribed(account_id, timeout=480): + """Poll until QuickSight subscription is gone.""" + deadline = time.time() + timeout + while time.time() < deadline: + try: + resp = qs.describe_account_subscription(AwsAccountId=account_id) + status = resp['AccountInfo']['AccountSubscriptionStatus'] + print(f"Waiting for unsubscribe, status: {status}") + except qs.exceptions.ResourceNotFoundException: + print("QuickSight subscription removed") + return True + time.sleep(15) + raise TimeoutError("QuickSight did not unsubscribe in time") + + def ensure_admin_user(account_id): + """Register an IAM admin user, return (user_arn, user_name).""" + namespace = 'default' + caller = sts.get_caller_identity() + iam_arn = caller['Arn'] + # Use the role ARN (strip assumed-role session to get the role) + # arn:aws:sts::ACCT:assumed-role/ROLE/session -> arn:aws:iam::ACCT:role/ROLE + if ':assumed-role/' in iam_arn: + parts = iam_arn.split(':assumed-role/') + role_name = parts[1].split('/')[0] + acct = parts[0].split(':')[4] + iam_arn = f'arn:aws:iam::{acct}:role/{role_name}' + + session_name = 'ndx-admin' + expected_user_name = f'{iam_arn}/ndx-admin' + + # Retry with backoff — QuickSight IAM integration can take time after subscription + for attempt in range(6): + try: + resp = qs.register_user( + AwsAccountId=account_id, + Namespace=namespace, + IdentityType='IAM', + IamArn=iam_arn, + UserRole='ADMIN', + SessionName=session_name, + Email='ndx-quicksight@example.com' + ) + user = resp['User'] + print(f"Registered QuickSight user: {user['Arn']}") + return user['Arn'], user['UserName'] + except qs.exceptions.ResourceExistsException: + print("User already exists, constructing ARN...") + region = qs.meta.region_name + arn = f'arn:aws:quicksight:{region}:{account_id}:user/{namespace}/{expected_user_name}' + print(f"User ARN: {arn}") + return arn, expected_user_name + except qs.exceptions.PreconditionNotMetException as e: + wait = 15 * (attempt + 1) + print(f"RegisterUser not ready (attempt {attempt+1}/6): {e}. Retrying in {wait}s...") + time.sleep(wait) + raise RuntimeError("RegisterUser failed after retries — QuickSight IAM integration not ready") + + def ensure_group(account_id, admin_user_name, namespace='default'): + """Create a group and add the admin user to it.""" + group_name = 'ndx-all-users' + try: + qs.create_group( + AwsAccountId=account_id, + Namespace=namespace, + GroupName=group_name) + print(f"Created group: {group_name}") + except qs.exceptions.ResourceExistsException: + print(f"Group {group_name} already exists") + + try: + qs.create_group_membership( + AwsAccountId=account_id, + Namespace=namespace, + GroupName=group_name, + MemberName=admin_user_name) + print(f"Added {admin_user_name} to group") + except Exception as e: + print(f"Add admin to group: {e}") + + region = qs.meta.region_name + return f'arn:aws:quicksight:{region}:{account_id}:group/{namespace}/{group_name}' + + def handler(event, context): + print(f"Event: {json.dumps(event)}") + account_id = event['ResourceProperties']['AccountId'] + request_type = event['RequestType'] + + try: + if request_type in ['Create', 'Update']: + # Step 1: Ensure QuickSight is subscribed and active + need_subscribe = False + try: + sub = qs.describe_account_subscription(AwsAccountId=account_id) + status = sub['AccountInfo']['AccountSubscriptionStatus'] + print(f"QuickSight already present, status: {status}") + if status == 'ACCOUNT_CREATED': + pass # Already active + elif status == 'UNSUBSCRIBED': + # Fully unsubscribed — just resubscribe + need_subscribe = True + elif status == 'UNSUBSCRIBE_IN_PROGRESS': + print("Waiting for in-progress unsubscribe to finish...") + wait_for_unsubscribed(account_id) + need_subscribe = True + elif status == 'UNSUBSCRIBE_FAILED': + print("Cleaning up failed unsubscribe...") + try: + qs.update_account_settings( + AwsAccountId=account_id, + DefaultNamespace='default', + TerminationProtectionEnabled=False) + except Exception as e: + print(f"disable termination protection: {e}") + try: + qs.delete_account_subscription(AwsAccountId=account_id) + except Exception as e: + print(f"delete_account_subscription: {e}") + wait_for_unsubscribed(account_id) + need_subscribe = True + else: + print("Waiting for existing subscription to become active...") + wait_for_active(account_id) + except qs.exceptions.ResourceNotFoundException: + need_subscribe = True + + if need_subscribe: + print("Subscribing to QuickSight Enterprise (IAM_ONLY)...") + qs.create_account_subscription( + AwsAccountId=account_id, + Edition='ENTERPRISE', + AuthenticationMethod='IAM_ONLY', + AccountName=f'ndx-sandbox-{account_id}', + NotificationEmail='ndx-quicksight@example.com' + ) + wait_for_active(account_id) + + # Allow a brief settle after subscription becomes active + time.sleep(10) + + # Step 2: Register admin user + user_arn, user_name = ensure_admin_user(account_id) + + # Step 3: Enable SPICE auto-purchase + try: + qs.update_spice_capacity_configuration( + AwsAccountId=account_id, + PurchaseMode='AUTO_PURCHASE') + print("SPICE auto-purchase enabled") + except Exception as e: + print(f"SPICE config: {e}") + + # Step 4: Create group and add admin user + group_arn = ensure_group(account_id, user_name) + + send_cfn_response(event, context, 'SUCCESS', + data={'GroupArn': group_arn}, + physical_id=f'qs-setup-{account_id}') + + elif request_type == 'Delete': + # Only delete the admin user; leave the QuickSight + # subscription intact — AWS Nuke handles full cleanup. + print("Deleting QuickSight admin user...") + try: + qs.delete_user( + AwsAccountId=account_id, + Namespace='default', + UserName=f'ndx-qs-admin' + ) + except Exception as e: + print(f"User cleanup (OK to ignore): {e}") + + send_cfn_response(event, context, 'SUCCESS', + physical_id=f'qs-setup-{account_id}') + + except Exception as e: + print(f"Error: {e}") + send_cfn_response(event, context, 'FAILED', reason=str(e)[:200]) + Tags: + - Key: Project + Value: ndx-try + - Key: Scenario + Value: quicksight-dashboard + + QuickSightSetupLogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: !Sub '/aws/lambda/ndx-quicksight-setup-${AWS::Region}' + RetentionInDays: 7 + Tags: + - Key: Project + Value: ndx-try + - Key: Scenario + Value: quicksight-dashboard + + QuickSightSetupTrigger: + Type: AWS::CloudFormation::CustomResource + DependsOn: + - QuickSightSetupLogGroup + Properties: + ServiceToken: !GetAtt QuickSightSetupFunction.Arn + AccountId: !Ref AWS::AccountId + + # ============================================================================ + # AUTO-ADD QUICKSIGHT USERS TO ndx-all-users GROUP (EventBridge + Lambda) + # ============================================================================ + QuickSightGroupSyncFunction: + Type: AWS::Lambda::Function + Properties: + FunctionName: !Sub 'ndx-quicksight-group-sync-${AWS::Region}' + Runtime: python3.12 + Handler: index.handler + Timeout: 30 + MemorySize: 128 + Role: !GetAtt QuickSightSetupRole.Arn + Environment: + Variables: + AWS_ACCOUNT_ID_PARAM: !Ref AWS::AccountId + QUICKSIGHT_GROUP: ndx-all-users + QUICKSIGHT_NAMESPACE: default + Code: + ZipFile: | + import json + import os + import logging + import boto3 + + logger = logging.getLogger() + logger.setLevel(logging.INFO) + + qs = boto3.client('quicksight') + ACCOUNT_ID = os.environ['AWS_ACCOUNT_ID_PARAM'] + GROUP = os.environ['QUICKSIGHT_GROUP'] + NAMESPACE = os.environ['QUICKSIGHT_NAMESPACE'] + + + def extract_username(event): + """Extract the QuickSight username from a CloudTrail event.""" + detail = event.get('detail', {}) + + # 1. Response contains the created user object (RegisterUser API) + resp_user = (detail.get('responseElements') or {}).get('user', {}) + if resp_user.get('userName'): + return resp_user['userName'] + + # 2. Request parameters contain the target username (RegisterUser API) + req_user = (detail.get('requestParameters') or {}).get('userName') + if req_user: + return req_user + + # 3. Console self-provisioning (CreateUser service event) + # Event uses "Role:Session" but QuickSight usernames use "Role/Session" + svc = (detail.get('serviceEventDetails') or {}) + svc_user = (svc.get('eventRequestDetails') or {}).get('userName') + if svc_user: + return svc_user.replace(':', '/', 1) + + # 4. Fallback: derive from IAM/SSO caller identity ARN + arn = (detail.get('userIdentity') or {}).get('arn', '') + if '/assumed-role/' in arn: + # arn:aws:sts::ACCT:assumed-role/RoleName/SessionName + return arn.split('/')[-1] + + return None + + + def handler(event, context): + logger.info('Event: %s', json.dumps(event)) + + username = extract_username(event) + if not username: + logger.warning('Could not extract username from event') + return {'status': 'skipped', 'reason': 'no username'} + + logger.info('Adding user %s to group %s', username, GROUP) + try: + qs.create_group_membership( + MemberName=username, + GroupName=GROUP, + AwsAccountId=ACCOUNT_ID, + Namespace=NAMESPACE, + ) + logger.info('Successfully added %s to %s', username, GROUP) + return {'status': 'added', 'username': username} + except qs.exceptions.ResourceExistsException: + logger.info('User %s already in group %s', username, GROUP) + return {'status': 'already_member', 'username': username} + except Exception: + logger.exception('Failed to add %s to group %s', username, GROUP) + return {'status': 'error', 'username': username} + Tags: + - Key: Project + Value: ndx-try + - Key: Scenario + Value: quicksight-dashboard + + QuickSightGroupSyncLogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: !Sub '/aws/lambda/${QuickSightGroupSyncFunction}' + RetentionInDays: 7 + + QuickSightGroupSyncRule: + Type: AWS::Events::Rule + Properties: + Name: !Sub 'ndx-quicksight-user-sync-${AWS::Region}' + Description: Auto-add new QuickSight users to ndx-all-users group + State: ENABLED + EventPattern: + source: + - aws.quicksight + detail: + eventSource: + - quicksight.amazonaws.com + eventName: + - RegisterUser + - BatchCreateUser + - CreateUser + Targets: + - Id: QuickSightGroupSyncTarget + Arn: !GetAtt QuickSightGroupSyncFunction.Arn + + QuickSightGroupSyncPermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !Ref QuickSightGroupSyncFunction + Action: lambda:InvokeFunction + Principal: events.amazonaws.com + SourceArn: !GetAtt QuickSightGroupSyncRule.Arn + # ============================================================================ # IAM ROLE FOR QUICKSIGHT S3 ACCESS (Story 25.2) # ============================================================================ @@ -473,6 +904,7 @@ Resources: DependsOn: - DataGeneratorTrigger - QuickSightS3AccessRole + - QuickSightSetupTrigger Properties: AwsAccountId: !Ref AWS::AccountId DataSourceId: !Sub 'ndx-council-data-source-${AWS::Region}' @@ -485,7 +917,7 @@ Resources: Key: manifests/council-data-manifest.json RoleArn: !GetAtt QuickSightS3AccessRole.Arn Permissions: - - Principal: !Sub 'arn:aws:quicksight:${AWS::Region}:${AWS::AccountId}:user/default/${QuickSightUsername}' + - Principal: !Sub 'arn:aws:quicksight:${AWS::Region}:${AWS::AccountId}:group/default/ndx-all-users' Actions: - quicksight:DescribeDataSource - quicksight:DescribeDataSourcePermissions @@ -564,7 +996,7 @@ Resources: ColumnName: resolution_rate Expression: '{cases_resolved} / {cases_received}' Permissions: - - Principal: !Sub 'arn:aws:quicksight:${AWS::Region}:${AWS::AccountId}:user/default/${QuickSightUsername}' + - Principal: !Sub 'arn:aws:quicksight:${AWS::Region}:${AWS::AccountId}:group/default/ndx-all-users' Actions: - quicksight:DescribeDataSet - quicksight:DescribeDataSetPermissions @@ -800,7 +1232,7 @@ Resources: AggregationFunction: SimpleNumericalAggregation: AVERAGE Permissions: - - Principal: !Sub 'arn:aws:quicksight:${AWS::Region}:${AWS::AccountId}:user/default/${QuickSightUsername}' + - Principal: !Sub 'arn:aws:quicksight:${AWS::Region}:${AWS::AccountId}:group/default/ndx-all-users' Actions: - quicksight:DescribeDashboard - quicksight:ListDashboardVersions @@ -915,8 +1347,8 @@ Outputs: Value: !Sub | { "scenario": "quicksight-dashboard", - "version": "2.4.0", - "story": "25.5 - Dashboard Filters and Embedding", + "version": "3.0.0", + "story": "Auto-subscribe QuickSight, fix ISB sandbox deployment", "environment": "${Environment}", "dataset": "${SampleDataset}", "services": ["S3", "Lambda", "QuickSight", "CloudFormation"], diff --git a/src/_data/scenarios.yaml b/src/_data/scenarios.yaml index 9284c704..a9c2aba8 100644 --- a/src/_data/scenarios.yaml +++ b/src/_data/scenarios.yaml @@ -60,10 +60,6 @@ scenarios: templateS3Url: "s3://ndx-try-templates-us-east-1/scenarios/localgov-drupal/template.yaml" region: "us-east-1" stackNamePrefix: "ndx-try-localgov-drupal" - parameters: - - name: "DeploymentMode" - value: "production" - description: "Deployment mode (development for debugging, production for demos)" tags: - key: "Project" value: "ndx-try" @@ -73,6 +69,7 @@ scenarios: value: "true" capabilities: - "CAPABILITY_IAM" + - "CAPABILITY_NAMED_IAM" deploymentTime: "35 to 40 minutes" deploymentPhases: - "Creating VPC and networking (~30 seconds)" @@ -253,7 +250,6 @@ scenarios: isMostPopular: true awsServices: - "Amazon Bedrock" - - "Amazon Lex" - "AWS Lambda" - "Amazon S3" tags: @@ -281,9 +277,6 @@ scenarios: - name: "Environment" value: "sandbox" description: "Deployment environment (NDX:Try session)" - - name: "AutoCleanupHours" - value: "2" - description: "Hours until automatic resource cleanup" - name: "KnowledgeBaseSource" value: "council-sample-data" description: "Sample council knowledge base for the chatbot" @@ -296,18 +289,17 @@ scenarios: value: "true" capabilities: - "CAPABILITY_IAM" + - "CAPABILITY_NAMED_IAM" + - "CAPABILITY_AUTO_EXPAND" deploymentTime: "3 to 5 minutes" deploymentPhases: - "Creating IAM roles (~30 seconds)" - "Creating S3 bucket for knowledge base (~10 seconds)" - "Creating Lambda functions (~60 seconds)" - - "Configuring Amazon Lex bot (~120 seconds)" - "Setting up Bedrock integration (~30 seconds)" outputs: - name: "ChatbotURL" description: "Web interface URL to interact with the chatbot" - - name: "LexBotId" - description: "Amazon Lex bot identifier for API integration" # Screenshot Walkthrough (Story 2.5 - Zero-Deployment Path) screenshots: # steps array will be populated when screenshots are captured @@ -384,14 +376,11 @@ scenarios: securitySummary: "NDX:Try session isolated - sample data only, no live documents" skillsLearned: - "Amazon Textract" - - "Amazon Comprehend" - "Document AI" - "CloudFormation" isMostPopular: false awsServices: - "Amazon Textract" - - "Amazon Comprehend" - - "Amazon Bedrock" - "AWS Lambda" - "Amazon S3" tags: @@ -419,12 +408,6 @@ scenarios: - name: "Environment" value: "sandbox" description: "Deployment environment (NDX:Try session)" - - name: "AutoCleanupHours" - value: "2" - description: "Hours until automatic resource cleanup" - - name: "SampleDataBucket" - value: "ndx-try-sample-planning-docs" - description: "S3 bucket with sample planning documents" tags: - key: "Project" value: "ndx-try" @@ -434,14 +417,14 @@ scenarios: value: "true" capabilities: - "CAPABILITY_IAM" + - "CAPABILITY_NAMED_IAM" + - "CAPABILITY_AUTO_EXPAND" deploymentTime: "5 to 8 minutes" deploymentPhases: - "Creating IAM roles (~30 seconds)" - "Creating S3 buckets for input/output (~20 seconds)" - "Creating Lambda functions (~90 seconds)" - "Configuring Amazon Textract (~60 seconds)" - - "Setting up Amazon Comprehend (~60 seconds)" - - "Configuring Bedrock integration (~60 seconds)" outputs: - name: "UploadURL" description: "S3 presigned URL endpoint for document upload" @@ -528,7 +511,6 @@ scenarios: isMostPopular: false awsServices: - "Amazon Comprehend" - - "Amazon Textract" - "AWS Lambda" - "Amazon S3" tags: @@ -556,9 +538,6 @@ scenarios: - name: "Environment" value: "sandbox" description: "Deployment environment (NDX:Try session)" - - name: "AutoCleanupHours" - value: "2" - description: "Hours until automatic resource cleanup" - name: "RedactionConfidenceThreshold" value: "0.85" description: "Minimum confidence score for automatic redaction" @@ -571,13 +550,14 @@ scenarios: value: "true" capabilities: - "CAPABILITY_IAM" + - "CAPABILITY_NAMED_IAM" + - "CAPABILITY_AUTO_EXPAND" deploymentTime: "4 to 6 minutes" deploymentPhases: - "Creating IAM roles (~30 seconds)" - "Creating S3 buckets for input/output (~15 seconds)" - "Creating Lambda functions (~60 seconds)" - "Configuring Amazon Comprehend PII detection (~90 seconds)" - - "Setting up Amazon Textract (~45 seconds)" outputs: - name: "UploadURL" description: "Document upload endpoint" @@ -656,17 +636,15 @@ scenarios: - "council-chatbot" securitySummary: "NDX:Try session isolated - simulated IoT data, no real sensors" skillsLearned: - - "AWS IoT Core" - - "Amazon Timestream" + - "Amazon DynamoDB" + - "AWS Lambda" - "Real-time dashboards" - - "API Gateway" + - "CloudFormation" isMostPopular: false awsServices: - - "AWS IoT Core" - - "Amazon Timestream" - - "Amazon QuickSight" - "AWS Lambda" - - "Amazon API Gateway" + - "Amazon DynamoDB" + - "Amazon S3" tags: - "IoT" - "Real-time" @@ -692,15 +670,9 @@ scenarios: - name: "Environment" value: "sandbox" description: "Deployment environment (NDX:Try session)" - - name: "AutoCleanupHours" - value: "2" - description: "Hours until automatic resource cleanup" - name: "SimulatedSensors" value: "20" description: "Number of simulated parking sensors" - - name: "DataGenerationInterval" - value: "30" - description: "Seconds between simulated data points" tags: - key: "Project" value: "ndx-try" @@ -710,21 +682,19 @@ scenarios: value: "true" capabilities: - "CAPABILITY_IAM" + - "CAPABILITY_NAMED_IAM" - "CAPABILITY_AUTO_EXPAND" deploymentTime: "8 to 12 minutes" deploymentPhases: - "Creating IAM roles (~30 seconds)" - - "Creating VPC and networking (~120 seconds)" - - "Setting up AWS IoT Core (~90 seconds)" - - "Creating Amazon Timestream database (~60 seconds)" + - "Creating DynamoDB table (~30 seconds)" + - "Creating S3 bucket (~10 seconds)" - "Creating Lambda functions (~90 seconds)" - - "Configuring API Gateway (~45 seconds)" - - "Setting up QuickSight dashboard (~120 seconds)" outputs: - name: "DashboardURL" - description: "QuickSight dashboard URL for parking visualisation" - - name: "AvailabilityAPI" - description: "Public API for real-time parking availability" + description: "Smart car park dashboard URL" + - name: "DataTable" + description: "DynamoDB table for parking data" # Screenshot Walkthrough (Story 2.5 - Zero-Deployment Path) screenshots: steps: [] @@ -832,9 +802,6 @@ scenarios: - name: "Environment" value: "sandbox" description: "Deployment environment (NDX:Try session)" - - name: "AutoCleanupHours" - value: "2" - description: "Hours until automatic resource cleanup" - name: "VoiceId" value: "Amy" description: "Amazon Polly voice (British English)" @@ -850,13 +817,15 @@ scenarios: value: "true" capabilities: - "CAPABILITY_IAM" + - "CAPABILITY_NAMED_IAM" + - "CAPABILITY_AUTO_EXPAND" deploymentTime: "2 to 4 minutes" deploymentPhases: - "Creating IAM roles (~20 seconds)" - "Creating S3 bucket for audio files (~10 seconds)" - "Creating Lambda functions (~45 seconds)" - "Configuring Amazon Polly (~30 seconds)" - - "Setting up API Gateway (~30 seconds)" + - "Setting up Lambda Function URL (~30 seconds)" outputs: - name: "ConvertURL" description: "Text-to-speech conversion API endpoint" @@ -938,13 +907,12 @@ scenarios: skillsLearned: - "Amazon QuickSight" - "Data visualisation" - - "AWS Glue ETL" - "Business intelligence" isMostPopular: false awsServices: - "Amazon QuickSight" - "Amazon S3" - - "AWS Glue" + - "AWS Lambda" tags: - "Analytics" - "Dashboard" @@ -970,9 +938,6 @@ scenarios: - name: "Environment" value: "sandbox" description: "Deployment environment (NDX:Try session)" - - name: "AutoCleanupHours" - value: "2" - description: "Hours until automatic resource cleanup" - name: "SampleDataset" value: "council-metrics" description: "Sample council performance metrics dataset" @@ -986,14 +951,12 @@ scenarios: capabilities: - "CAPABILITY_IAM" - "CAPABILITY_NAMED_IAM" - deploymentTime: "5 to 8 minutes" + deploymentTime: "8 to 15 minutes" deploymentPhases: - "Creating IAM roles (~30 seconds)" - - "Creating S3 bucket for data (~15 seconds)" - - "Setting up AWS Glue catalogue (~60 seconds)" - - "Creating Glue ETL job (~90 seconds)" - - "Configuring QuickSight namespace (~45 seconds)" - - "Creating QuickSight dashboard (~120 seconds)" + - "Creating S3 bucket and generating sample data (~60 seconds)" + - "Subscribing to QuickSight and registering admin user (~5-8 minutes)" + - "Creating QuickSight data source, dataset and dashboard (~120 seconds)" outputs: - name: "DashboardURL" description: "QuickSight dashboard URL for council metrics"