@continuous-excellence/ze-great-dashboard-aws 0.1.26 → 0.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,111 @@
1
+ AWSTemplateFormatVersion: '2010-09-09'
2
+ Description: Ze Great Dashboard bootstrap core v1. Administrator-managed; do not grant this authority to CI.
3
+
4
+ Parameters:
5
+ ArtifactBucketName:
6
+ Type: String
7
+ Description: Globally unique bucket for private Lambda artifacts
8
+ ApplicationStackName:
9
+ Type: String
10
+ Description: Stable name of the one dashboard application stack
11
+ DashboardFunctionName:
12
+ Type: String
13
+ Description: Stable Lambda function name used by the application stack
14
+ RuntimeSecretArn:
15
+ Type: String
16
+ Default: ''
17
+ Description: Optional explicitly configured Secrets Manager secret ARN
18
+ ArtifactKmsKeyArn:
19
+ Type: String
20
+ Default: ''
21
+ Description: Optional customer-managed KMS key ARN; SSE-S3 is used when blank
22
+
23
+ Conditions:
24
+ HasRuntimeSecret: !Not [!Equals [!Ref RuntimeSecretArn, '']]
25
+ HasArtifactKmsKey: !Not [!Equals [!Ref ArtifactKmsKeyArn, '']]
26
+
27
+ Resources:
28
+ ArtifactBucket:
29
+ Type: AWS::S3::Bucket
30
+ DeletionPolicy: Retain
31
+ UpdateReplacePolicy: Retain
32
+ Properties:
33
+ BucketName: !Ref ArtifactBucketName
34
+ BucketEncryption:
35
+ ServerSideEncryptionConfiguration:
36
+ - ServerSideEncryptionByDefault:
37
+ SSEAlgorithm: !If [HasArtifactKmsKey, aws:kms, AES256]
38
+ KMSMasterKeyID: !If [HasArtifactKmsKey, !Ref ArtifactKmsKeyArn, !Ref AWS::NoValue]
39
+ BucketKeyEnabled: !If [HasArtifactKmsKey, true, !Ref AWS::NoValue]
40
+ OwnershipControls:
41
+ Rules: [{ ObjectOwnership: BucketOwnerEnforced }]
42
+ PublicAccessBlockConfiguration:
43
+ BlockPublicAcls: true
44
+ BlockPublicPolicy: true
45
+ IgnorePublicAcls: true
46
+ RestrictPublicBuckets: true
47
+
48
+ ArtifactBucketPolicy:
49
+ Type: AWS::S3::BucketPolicy
50
+ Properties:
51
+ Bucket: !Ref ArtifactBucket
52
+ PolicyDocument:
53
+ Version: '2012-10-17'
54
+ Statement:
55
+ - Sid: DenyInsecureTransport
56
+ Effect: Deny
57
+ Principal: '*'
58
+ Action: s3:*
59
+ Resource: [!GetAtt ArtifactBucket.Arn, !Sub '${ArtifactBucket.Arn}/*']
60
+ Condition: { Bool: { 'aws:SecureTransport': false } }
61
+
62
+ CloudFormationExecutionRole:
63
+ Type: AWS::IAM::Role
64
+ DeletionPolicy: Retain
65
+ UpdateReplacePolicy: Retain
66
+ Properties:
67
+ RoleName: !Sub '${ApplicationStackName}-execution'
68
+ Description: !Sub 'Restricted CloudFormation execution role for ${ApplicationStackName}'
69
+ AssumeRolePolicyDocument:
70
+ Version: '2012-10-17'
71
+ Statement: [{ Effect: Allow, Principal: { Service: cloudformation.amazonaws.com }, Action: sts:AssumeRole }]
72
+ Policies:
73
+ - PolicyName: DashboardApplicationResources
74
+ PolicyDocument:
75
+ Version: '2012-10-17'
76
+ Statement:
77
+ - Sid: DashboardLambda
78
+ Effect: Allow
79
+ Action: [lambda:CreateFunction, lambda:GetFunction, lambda:UpdateFunctionCode, lambda:UpdateFunctionConfiguration, lambda:DeleteFunction, lambda:TagResource, lambda:UntagResource]
80
+ Resource: !Sub 'arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:${DashboardFunctionName}'
81
+ - Sid: DashboardLogs
82
+ Effect: Allow
83
+ Action: [logs:CreateLogGroup, logs:DeleteLogGroup, logs:DescribeLogGroups, logs:PutRetentionPolicy, logs:TagResource, logs:UntagResource]
84
+ Resource: !Sub 'arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${DashboardFunctionName}:*'
85
+ - Sid: ReadLambdaArtifact
86
+ Effect: Allow
87
+ Action: s3:GetObject
88
+ Resource: !Sub '${ArtifactBucket.Arn}/lambda/*'
89
+ - Sid: ManageRuntimeRole
90
+ Effect: Allow
91
+ Action: [iam:CreateRole, iam:GetRole, iam:DeleteRole, iam:UpdateAssumeRolePolicy, iam:PutRolePolicy, iam:GetRolePolicy, iam:DeleteRolePolicy, iam:AttachRolePolicy, iam:DetachRolePolicy, iam:ListRolePolicies, iam:ListAttachedRolePolicies, iam:TagRole, iam:UntagRole]
92
+ Resource: !Sub 'arn:${AWS::Partition}:iam::${AWS::AccountId}:role/${DashboardFunctionName}-server'
93
+ - Sid: PassRuntimeRoleToLambdaOnly
94
+ Effect: Allow
95
+ Action: iam:PassRole
96
+ Resource: !Sub 'arn:${AWS::Partition}:iam::${AWS::AccountId}:role/${DashboardFunctionName}-server'
97
+ Condition: { StringEquals: { 'iam:PassedToService': lambda.amazonaws.com } }
98
+ - !If
99
+ - HasRuntimeSecret
100
+ - Sid: ReadConfiguredRuntimeSecret
101
+ Effect: Allow
102
+ Action: [secretsmanager:DescribeSecret, secretsmanager:GetSecretValue]
103
+ Resource: !Ref RuntimeSecretArn
104
+ - !Ref AWS::NoValue
105
+
106
+ Outputs:
107
+ BootstrapContractVersion: { Value: '1' }
108
+ ArtifactBucketName: { Value: !Ref ArtifactBucket }
109
+ ArtifactBucketArn: { Value: !GetAtt ArtifactBucket.Arn }
110
+ CloudFormationExecutionRoleArn: { Value: !GetAtt CloudFormationExecutionRole.Arn }
111
+ ApplicationStackName: { Value: !Ref ApplicationStackName }
@@ -0,0 +1,15 @@
1
+ {
2
+ "region": "us-east-1",
3
+ "core": {
4
+ "stackName": "team-dashboard-bootstrap",
5
+ "artifactBucketName": "team-dashboard-lambda-artifacts-123456789012",
6
+ "applicationStackName": "team-dashboard",
7
+ "dashboardFunctionName": "team-dashboard"
8
+ },
9
+ "githubOidc": {
10
+ "stackName": "team-dashboard-github-bootstrap",
11
+ "providerArn": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com",
12
+ "repository": "example/team-dashboard",
13
+ "environment": "production"
14
+ }
15
+ }
@@ -0,0 +1,55 @@
1
+ AWSTemplateFormatVersion: '2010-09-09'
2
+ Description: Ze Great Dashboard GitHub OIDC adapter v1. Administrator-managed; requires a central OIDC provider.
3
+
4
+ Parameters:
5
+ GitHubOidcProviderArn: { Type: String }
6
+ GitHubRepository: { Type: String, Description: owner/repository }
7
+ GitHubEnvironment: { Type: String, Description: Protected GitHub Environment name }
8
+ ApplicationStackName: { Type: String }
9
+ ArtifactBucketName: { Type: String }
10
+ CloudFormationExecutionRoleArn: { Type: String }
11
+
12
+ Resources:
13
+ GitHubDeployRole:
14
+ Type: AWS::IAM::Role
15
+ DeletionPolicy: Retain
16
+ UpdateReplacePolicy: Retain
17
+ Properties:
18
+ RoleName: !Sub '${ApplicationStackName}-github-deploy'
19
+ MaxSessionDuration: 3600
20
+ AssumeRolePolicyDocument:
21
+ Version: '2012-10-17'
22
+ Statement:
23
+ - Effect: Allow
24
+ Principal: { Federated: !Ref GitHubOidcProviderArn }
25
+ Action: sts:AssumeRoleWithWebIdentity
26
+ Condition:
27
+ StringEquals:
28
+ 'token.actions.githubusercontent.com:aud': sts.amazonaws.com
29
+ 'token.actions.githubusercontent.com:sub': !Sub 'repo:${GitHubRepository}:environment:${GitHubEnvironment}'
30
+ Policies:
31
+ - PolicyName: DeployOneDashboard
32
+ PolicyDocument:
33
+ Version: '2012-10-17'
34
+ Statement:
35
+ - Sid: UploadLambdaArtifacts
36
+ Effect: Allow
37
+ Action: [s3:PutObject, s3:GetObject]
38
+ Resource: !Sub 'arn:${AWS::Partition}:s3:::${ArtifactBucketName}/lambda/*'
39
+ - Sid: InspectArtifactBucket
40
+ Effect: Allow
41
+ Action: [s3:GetBucketLocation, s3:ListBucket]
42
+ Resource: !Sub 'arn:${AWS::Partition}:s3:::${ArtifactBucketName}'
43
+ - Sid: OperateOneApplicationStack
44
+ Effect: Allow
45
+ Action: [cloudformation:CreateChangeSet, cloudformation:DescribeChangeSet, cloudformation:ExecuteChangeSet, cloudformation:DeleteChangeSet, cloudformation:DescribeStacks, cloudformation:DescribeStackEvents, cloudformation:GetTemplate, cloudformation:GetTemplateSummary]
46
+ Resource: !Sub 'arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${ApplicationStackName}/*'
47
+ - Sid: PassCoreExecutionRole
48
+ Effect: Allow
49
+ Action: iam:PassRole
50
+ Resource: !Ref CloudFormationExecutionRoleArn
51
+ Condition: { StringEquals: { 'iam:PassedToService': cloudformation.amazonaws.com } }
52
+
53
+ Outputs:
54
+ BootstrapContractVersion: { Value: '1' }
55
+ GitHubDeployRoleArn: { Value: !GetAtt GitHubDeployRole.Arn }
@@ -0,0 +1,40 @@
1
+ export type BootstrapKind = 'core' | 'github-oidc';
2
+ export type CloudFormationParameterValue = {
3
+ ParameterKey: string;
4
+ ParameterValue: string;
5
+ UsePreviousValue?: boolean;
6
+ };
7
+ export type DeployedBootstrapStack = {
8
+ Parameters?: CloudFormationParameterValue[];
9
+ Outputs?: {
10
+ OutputKey: string;
11
+ OutputValue?: string;
12
+ }[];
13
+ };
14
+ export type BootstrapConfig = {
15
+ region?: string;
16
+ core?: {
17
+ stackName?: string;
18
+ artifactBucketName?: string;
19
+ applicationStackName?: string;
20
+ dashboardFunctionName?: string;
21
+ runtimeSecretArn?: string;
22
+ artifactKmsKeyArn?: string;
23
+ };
24
+ githubOidc?: {
25
+ stackName?: string;
26
+ providerArn?: string;
27
+ repository?: string;
28
+ environment?: string;
29
+ };
30
+ };
31
+ export declare function bootstrapTemplatePath(kind: BootstrapKind): Promise<string>;
32
+ export declare function bootstrapTemplate(kind: BootstrapKind): Promise<string>;
33
+ export declare function bootstrapContractVersion(template: string): string;
34
+ /** Merges a new parameter set with deployed values without silently dropping configuration. */
35
+ export declare function mergeBootstrapParameters(requested: CloudFormationParameterValue[], deployed: CloudFormationParameterValue[]): CloudFormationParameterValue[];
36
+ /** Validates a caller-captured `describe-stacks` result before an upgrade merges it locally. */
37
+ export declare function deployedBootstrapStack(input: unknown, expectedContractVersion: string): DeployedBootstrapStack;
38
+ /** Extracts the adapter contract from a caller-captured core `describe-stacks` result. */
39
+ export declare function coreBootstrapOutputs(stack: DeployedBootstrapStack): Record<string, string>;
40
+ export declare function requiredBootstrapParameters(kind: BootstrapKind, values: Record<string, string | undefined>): CloudFormationParameterValue[];
package/dist/cli.js CHANGED
@@ -1,21 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // packages/aws/src/cli.ts
4
- import { readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
5
- import { fileURLToPath as fileURLToPath2 } from "node:url";
4
+ import { readFile as readFile5, writeFile as writeFile3 } from "node:fs/promises";
5
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
6
6
  import { parse as parse3 } from "yaml";
7
7
 
8
8
  // packages/aws/src/doctor.ts
9
9
  import { execFile as execFile2 } from "node:child_process";
10
- import { readFile as readFile3 } from "node:fs/promises";
10
+ import { readFile as readFile4 } from "node:fs/promises";
11
11
  import { promisify as promisify2 } from "node:util";
12
12
  import { parse as parse2 } from "yaml";
13
13
 
14
14
  // packages/aws/src/index.ts
15
15
  import { execFile } from "node:child_process";
16
- import { cp, mkdir as mkdir2, readFile as readFile2, rm, writeFile as writeFile2 } from "node:fs/promises";
16
+ import { cp, mkdir as mkdir2, readFile as readFile3, rm, writeFile as writeFile2 } from "node:fs/promises";
17
17
  import { join as join2, resolve as resolve2 } from "node:path";
18
- import { fileURLToPath } from "node:url";
18
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
19
19
  import { promisify } from "node:util";
20
20
 
21
21
  // node_modules/fflate/esm/index.mjs
@@ -838,6 +838,80 @@ function sha256(value) {
838
838
  return createHash("sha256").update(value).digest("hex");
839
839
  }
840
840
 
841
+ // packages/aws/src/bootstrap.ts
842
+ import { readFile as readFile2 } from "node:fs/promises";
843
+ import { fileURLToPath } from "node:url";
844
+ var templates = {
845
+ core: "../bootstrap/core-v1.yml",
846
+ "github-oidc": "../bootstrap/github-oidc-v1.yml"
847
+ };
848
+ async function bootstrapTemplatePath(kind) {
849
+ if (!Object.hasOwn(templates, kind)) throw new Error(`Unknown bootstrap template: ${kind}`);
850
+ return fileURLToPath(new URL(templates[kind], import.meta.url));
851
+ }
852
+ async function bootstrapTemplate(kind) {
853
+ return readFile2(await bootstrapTemplatePath(kind), "utf8");
854
+ }
855
+ function bootstrapContractVersion(template) {
856
+ const version = template.match(/BootstrapContractVersion:\s*\{\s*Value:\s*'([^']+)'\s*}/)?.[1];
857
+ if (!version) throw new Error("Bootstrap template has no BootstrapContractVersion output");
858
+ return version;
859
+ }
860
+ function mergeBootstrapParameters(requested, deployed) {
861
+ const supplied = new Map(requested.map((entry) => [entry.ParameterKey, entry.ParameterValue]));
862
+ const duplicates = requested.map((entry) => entry.ParameterKey);
863
+ if (new Set(duplicates).size !== duplicates.length)
864
+ throw new Error("Bootstrap parameter file contains duplicate parameters");
865
+ return deployed.map(({ ParameterKey, ParameterValue }) => ({
866
+ ParameterKey,
867
+ ParameterValue: supplied.get(ParameterKey) ?? ParameterValue
868
+ })).concat(
869
+ requested.filter(
870
+ ({ ParameterKey }) => !deployed.some((entry) => entry.ParameterKey === ParameterKey)
871
+ )
872
+ );
873
+ }
874
+ function deployedBootstrapStack(input, expectedContractVersion) {
875
+ const stack = input && typeof input === "object" && "Stacks" in input ? input.Stacks?.[0] : input;
876
+ if (!stack || typeof stack !== "object") throw new Error("Deployed stack JSON contains no stack");
877
+ const deployed = stack;
878
+ const contract = deployed.Outputs?.find(
879
+ (output) => output.OutputKey === "BootstrapContractVersion"
880
+ )?.OutputValue;
881
+ if (contract !== expectedContractVersion)
882
+ throw new Error(
883
+ `Bootstrap contract mismatch (deployed: ${contract ?? "missing"}; expected: ${expectedContractVersion}); follow the documented migration procedure`
884
+ );
885
+ if (deployed.Parameters && !Array.isArray(deployed.Parameters))
886
+ throw new Error("Deployed stack JSON has invalid Parameters");
887
+ return deployed;
888
+ }
889
+ function coreBootstrapOutputs(stack) {
890
+ const values = Object.fromEntries(
891
+ (stack.Outputs ?? []).filter(
892
+ (output) => Boolean(output.OutputValue)
893
+ ).map((output) => [output.OutputKey, output.OutputValue])
894
+ );
895
+ const required = ["ArtifactBucketName", "ApplicationStackName", "CloudFormationExecutionRoleArn"];
896
+ const missing = required.filter((key) => !values[key]);
897
+ if (missing.length) throw new Error(`Core stack JSON is missing outputs: ${missing.join(", ")}`);
898
+ return values;
899
+ }
900
+ function requiredBootstrapParameters(kind, values) {
901
+ const keys = kind === "core" ? ["ArtifactBucketName", "ApplicationStackName", "DashboardFunctionName"] : [
902
+ "GitHubOidcProviderArn",
903
+ "GitHubRepository",
904
+ "GitHubEnvironment",
905
+ "ApplicationStackName",
906
+ "ArtifactBucketName",
907
+ "CloudFormationExecutionRoleArn"
908
+ ];
909
+ const missing = keys.filter((key) => !values[key]);
910
+ if (missing.length) throw new Error(`Missing required bootstrap values: ${missing.join(", ")}`);
911
+ const optional = kind === "core" ? ["RuntimeSecretArn", "ArtifactKmsKeyArn"] : [];
912
+ return [...keys, ...optional].filter((key) => values[key] !== void 0).map((ParameterKey) => ({ ParameterKey, ParameterValue: values[ParameterKey] ?? "" }));
913
+ }
914
+
841
915
  // packages/aws/src/index.ts
842
916
  var run = promisify(execFile);
843
917
  function deploymentTemplate(template, values) {
@@ -867,7 +941,7 @@ async function packageLambda(options) {
867
941
  await mkdir2(outputDir, { recursive: true });
868
942
  const runtimeDir = join2(outputDir, "lambda");
869
943
  await mkdir2(runtimeDir, { recursive: true });
870
- const lambdaSource = fileURLToPath(new URL("../dist/lambda.mjs", import.meta.url));
944
+ const lambdaSource = fileURLToPath2(new URL("../dist/lambda.mjs", import.meta.url));
871
945
  await cp(lambdaSource, join2(runtimeDir, "index.mjs"));
872
946
  const release = await assembleRelease({
873
947
  boardConfigPath: options.boardConfigPath,
@@ -880,7 +954,7 @@ async function packageLambda(options) {
880
954
  ...release.metadata,
881
955
  artifactChecksums: {
882
956
  ...release.metadata.artifactChecksums,
883
- "index.mjs": sha256(await readFile2(join2(runtimeDir, "index.mjs")))
957
+ "index.mjs": sha256(await readFile3(join2(runtimeDir, "index.mjs")))
884
958
  }
885
959
  };
886
960
  await writeFile2(join2(runtimeDir, "release.json"), `${JSON.stringify(runtimeMetadata, null, 2)}
@@ -895,7 +969,7 @@ async function packageLambda(options) {
895
969
  archiveFiles.map(async (name) => [
896
970
  name,
897
971
  [
898
- strToU8(await readFile2(join2(runtimeDir, name), "utf8")),
972
+ strToU8(await readFile3(join2(runtimeDir, name), "utf8")),
899
973
  { mtime: new Date(1980, 0, 1, 0, 0, 0), level: 9 }
900
974
  ]
901
975
  ])
@@ -903,7 +977,7 @@ async function packageLambda(options) {
903
977
  );
904
978
  await writeFile2(lambdaPath, zipSync(archive));
905
979
  await rm(runtimeDir, { recursive: true, force: true });
906
- const lambdaChecksum = sha256(await readFile2(lambdaPath));
980
+ const lambdaChecksum = sha256(await readFile3(lambdaPath));
907
981
  const deploymentChecksum = sha256(JSON.stringify(runtimeMetadata));
908
982
  const packagedRelease = {
909
983
  ...runtimeMetadata,
@@ -923,7 +997,7 @@ async function packageLambda(options) {
923
997
  }
924
998
  async function publishClientAssets(options) {
925
999
  const assetsDir = resolve2(options.assetsDir);
926
- await readFile2(join2(assetsDir, "index.html"));
1000
+ await readFile3(join2(assetsDir, "index.html"));
927
1001
  const assetPath = `${options.assetsBaseUrl.replace(/\/+$/, "")}/dashboard/${options.version}`;
928
1002
  await run("aws", [
929
1003
  "s3",
@@ -948,8 +1022,8 @@ async function publishClientAssets(options) {
948
1022
  async function deployLambda(options) {
949
1023
  const artifactDir = resolve2(options.artifactDir);
950
1024
  const assetsDir = resolve2(options.assetsDir);
951
- await readFile2(join2(artifactDir, "lambda.zip"));
952
- await readFile2(join2(assetsDir, "index.html"));
1025
+ await readFile3(join2(artifactDir, "lambda.zip"));
1026
+ await readFile3(join2(assetsDir, "index.html"));
953
1027
  if (options.dryRun) return;
954
1028
  const assetPath = await publishClientAssets(options);
955
1029
  await run("aws", [
@@ -974,7 +1048,7 @@ async function deployLambda(options) {
974
1048
  await run("aws", ["lambda", "wait", "function-updated", "--function-name", options.functionName]);
975
1049
  }
976
1050
  async function cloudFormationTemplate() {
977
- return readFile2(fileURLToPath(new URL("../template.yml", import.meta.url)), "utf8");
1051
+ return readFile3(fileURLToPath2(new URL("../template.yml", import.meta.url)), "utf8");
978
1052
  }
979
1053
 
980
1054
  // packages/aws/src/doctor.ts
@@ -1073,7 +1147,7 @@ async function runDoctor(options, dependencies = actualDependencies) {
1073
1147
  let parameterData;
1074
1148
  await check("Parameters/template", async () => {
1075
1149
  const parameters = readParameterValues(
1076
- JSON.parse(await readFile3(options.parametersPath, "utf8"))
1150
+ JSON.parse(await readFile4(options.parametersPath, "utf8"))
1077
1151
  );
1078
1152
  parameterData = await templateContract(parameters);
1079
1153
  return `${options.parametersPath} is compatible`;
@@ -1119,7 +1193,7 @@ var option = (name, fallback) => {
1119
1193
  };
1120
1194
  async function installedPackageVersion() {
1121
1195
  const packageManifest = JSON.parse(
1122
- await readFile4(new URL("../package.json", import.meta.url), "utf8")
1196
+ await readFile5(new URL("../package.json", import.meta.url), "utf8")
1123
1197
  );
1124
1198
  return typeof packageManifest.version === "string" ? packageManifest.version : "";
1125
1199
  }
@@ -1133,7 +1207,7 @@ function parameter(key, value) {
1133
1207
  }
1134
1208
  async function existingParameters(path) {
1135
1209
  try {
1136
- const parsed = JSON.parse(await readFile4(path, "utf8"));
1210
+ const parsed = JSON.parse(await readFile5(path, "utf8"));
1137
1211
  if (!Array.isArray(parsed)) throw new Error(`${path} must contain a JSON parameter array`);
1138
1212
  const values = parsed.map((value) => {
1139
1213
  if (!value || typeof value !== "object" || typeof value.ParameterKey !== "string" || typeof value.ParameterValue !== "string")
@@ -1150,6 +1224,49 @@ async function existingParameters(path) {
1150
1224
  throw error;
1151
1225
  }
1152
1226
  }
1227
+ function bootstrapKind() {
1228
+ const kind = option("--kind");
1229
+ if (kind === "core" || kind === "github-oidc") return kind;
1230
+ throw new Error("--kind must be core or github-oidc");
1231
+ }
1232
+ async function readBootstrapConfig(path) {
1233
+ const parsed = JSON.parse(await readFile5(path, "utf8"));
1234
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
1235
+ throw new Error("--config must contain a JSON object");
1236
+ return parsed;
1237
+ }
1238
+ async function bootstrapConfig() {
1239
+ const path = option("--config");
1240
+ return path ? readBootstrapConfig(path) : {};
1241
+ }
1242
+ function bootstrapValues(config, coreOutputs = {}) {
1243
+ return {
1244
+ ArtifactBucketName: option(
1245
+ "--artifact-bucket",
1246
+ coreOutputs.ArtifactBucketName ?? config.core?.artifactBucketName
1247
+ ),
1248
+ ApplicationStackName: option(
1249
+ "--application-stack",
1250
+ coreOutputs.ApplicationStackName ?? config.core?.applicationStackName
1251
+ ),
1252
+ DashboardFunctionName: option("--function-name", config.core?.dashboardFunctionName),
1253
+ RuntimeSecretArn: option("--runtime-secret-arn", config.core?.runtimeSecretArn),
1254
+ ArtifactKmsKeyArn: option("--artifact-kms-key-arn", config.core?.artifactKmsKeyArn),
1255
+ GitHubOidcProviderArn: option("--github-oidc-provider-arn", config.githubOidc?.providerArn),
1256
+ GitHubRepository: option("--github-repository", config.githubOidc?.repository),
1257
+ GitHubEnvironment: option("--github-environment", config.githubOidc?.environment),
1258
+ CloudFormationExecutionRoleArn: option(
1259
+ "--execution-role-arn",
1260
+ coreOutputs.CloudFormationExecutionRoleArn
1261
+ )
1262
+ };
1263
+ }
1264
+ function bootstrapStackName(kind, config) {
1265
+ return kind === "core" ? config.core?.stackName : config.githubOidc?.stackName;
1266
+ }
1267
+ function shellCommand(command) {
1268
+ return command.map((argument) => `'${argument.replaceAll("'", `'\\"'\\"'`)}'`).join(" ");
1269
+ }
1153
1270
  async function templateParameters() {
1154
1271
  const template = await cloudFormationTemplate();
1155
1272
  const block = template.match(/^Parameters:\n[\s\S]*?(?=^[A-Za-z][A-Za-z0-9]*:\s*$)/m)?.[0];
@@ -1172,7 +1289,7 @@ async function templateParameters() {
1172
1289
  }
1173
1290
  try {
1174
1291
  const packageVersion = await installedPackageVersion();
1175
- const bundledAssets = fileURLToPath2(new URL("../client", import.meta.url));
1292
+ const bundledAssets = fileURLToPath3(new URL("../client", import.meta.url));
1176
1293
  if (args[0] === "deploy") {
1177
1294
  const artifactDir = requiredOption("--artifact-dir");
1178
1295
  const version = requiredOption("--version", packageVersion);
@@ -1219,10 +1336,15 @@ try {
1219
1336
  } else if (args[0] === "parameters") {
1220
1337
  const output = option("--output", "aws-dashboard-parameters.json") ?? "aws-dashboard-parameters.json";
1221
1338
  const existing = await existingParameters(output);
1339
+ const consumerConfigPath = option("--bootstrap-config");
1340
+ const consumerConfig = consumerConfigPath ? await readBootstrapConfig(consumerConfigPath) : void 0;
1222
1341
  const existingValues = Object.fromEntries(
1223
1342
  existing.map(({ ParameterKey, ParameterValue }) => [ParameterKey, ParameterValue])
1224
1343
  );
1225
- const artifactBucket = option("--artifact-bucket", existingValues.LambdaArtifactBucket);
1344
+ const artifactBucket = option(
1345
+ "--artifact-bucket",
1346
+ existingValues.LambdaArtifactBucket ?? consumerConfig?.core?.artifactBucketName
1347
+ );
1226
1348
  if (!artifactBucket) throw new Error("--artifact-bucket is required");
1227
1349
  const { definitions, packageManaged } = await templateParameters();
1228
1350
  const templateKeys = Object.keys(definitions);
@@ -1240,20 +1362,118 @@ try {
1240
1362
  );
1241
1363
  }
1242
1364
  const includeDefaults = args.includes("--include-defaults");
1365
+ const functionName = option("--function-name", consumerConfig?.core?.dashboardFunctionName);
1243
1366
  const parameters = templateKeys.filter(
1244
- (key) => !packageManaged.has(key) && (includeDefaults || !hasDefault(key) || Object.hasOwn(existingValues, key))
1367
+ (key) => !packageManaged.has(key) && (includeDefaults || !hasDefault(key) || Object.hasOwn(existingValues, key) || key === "Name" && Boolean(functionName))
1245
1368
  ).map((key) => {
1246
1369
  const definition = definitions[key];
1247
- const value = key === "LambdaArtifactBucket" ? artifactBucket : existingValues[key] ?? definition?.Default;
1370
+ const value = key === "LambdaArtifactBucket" ? artifactBucket : key === "Name" && functionName ? functionName : existingValues[key] ?? definition?.Default;
1248
1371
  if (value === void 0) throw new Error(`No value for CloudFormation parameter ${key}`);
1249
1372
  return parameter(key, String(value));
1250
1373
  });
1251
1374
  await writeFile3(output, `${JSON.stringify(parameters, null, 2)}
1252
1375
  `);
1253
1376
  console.log(JSON.stringify({ output }));
1377
+ } else if (args[0] === "bootstrap") {
1378
+ const action = args[1];
1379
+ const kind = bootstrapKind();
1380
+ const config = await bootstrapConfig();
1381
+ if (action === "template") {
1382
+ console.log(
1383
+ JSON.stringify({
1384
+ kind,
1385
+ template: await bootstrapTemplatePath(kind),
1386
+ contractVersion: bootstrapContractVersion(await bootstrapTemplate(kind))
1387
+ })
1388
+ );
1389
+ } else if (action === "parameters") {
1390
+ const output = option("--output", `aws-dashboard-bootstrap-${kind}.json`) ?? `aws-dashboard-bootstrap-${kind}.json`;
1391
+ let coreOutputs = {};
1392
+ const coreStackPath = option("--core-stack-json");
1393
+ if (kind === "github-oidc" && coreStackPath) {
1394
+ coreOutputs = coreBootstrapOutputs(
1395
+ deployedBootstrapStack(
1396
+ JSON.parse(await readFile5(coreStackPath, "utf8")),
1397
+ bootstrapContractVersion(await bootstrapTemplate("core"))
1398
+ )
1399
+ );
1400
+ }
1401
+ const supplied = requiredBootstrapParameters(kind, bootstrapValues(config, coreOutputs));
1402
+ let parameters = supplied;
1403
+ const deployedStackPath = option("--deployed-stack-json");
1404
+ if (deployedStackPath) {
1405
+ const stack = deployedBootstrapStack(
1406
+ JSON.parse(await readFile5(deployedStackPath, "utf8")),
1407
+ bootstrapContractVersion(await bootstrapTemplate(kind))
1408
+ );
1409
+ parameters = mergeBootstrapParameters(supplied, stack.Parameters ?? []);
1410
+ }
1411
+ await writeFile3(output, `${JSON.stringify(parameters, null, 2)}
1412
+ `);
1413
+ console.log(
1414
+ JSON.stringify({ output, kind, preservedDeployedValues: Boolean(deployedStackPath) })
1415
+ );
1416
+ } else if (action === "status") {
1417
+ const stackName = requiredOption("--stack-name", bootstrapStackName(kind, config));
1418
+ const region = option("--region", config.region);
1419
+ const awsCommand = [
1420
+ "aws",
1421
+ "cloudformation",
1422
+ "describe-stacks",
1423
+ "--stack-name",
1424
+ stackName,
1425
+ ...region ? ["--region", region] : [],
1426
+ "--no-cli-pager"
1427
+ ];
1428
+ console.log(
1429
+ JSON.stringify({
1430
+ kind,
1431
+ contractVersion: bootstrapContractVersion(await bootstrapTemplate(kind)),
1432
+ awsCommand,
1433
+ shellCommand: args.includes("--format-shell") ? shellCommand(awsCommand) : void 0
1434
+ })
1435
+ );
1436
+ } else if (action === "change-set") {
1437
+ const stackName = requiredOption("--stack-name", bootstrapStackName(kind, config));
1438
+ const changeSetName = requiredOption("--change-set-name");
1439
+ const parametersPath = requiredOption("--parameters");
1440
+ const region = option("--region", config.region);
1441
+ await existingParameters(parametersPath);
1442
+ const awsCommand = [
1443
+ "aws",
1444
+ "cloudformation",
1445
+ "create-change-set",
1446
+ "--stack-name",
1447
+ stackName,
1448
+ "--change-set-name",
1449
+ changeSetName,
1450
+ "--change-set-type",
1451
+ option("--change-set-type", "UPDATE") ?? "UPDATE",
1452
+ "--template-body",
1453
+ `file://${await bootstrapTemplatePath(kind)}`,
1454
+ "--parameters",
1455
+ `file://${parametersPath}`,
1456
+ "--capabilities",
1457
+ "CAPABILITY_NAMED_IAM",
1458
+ ...region ? ["--region", region] : [],
1459
+ "--no-cli-pager"
1460
+ ];
1461
+ console.log(
1462
+ JSON.stringify({
1463
+ kind,
1464
+ reviewRequired: true,
1465
+ awsCommand,
1466
+ shellCommand: args.includes("--format-shell") ? shellCommand(awsCommand) : void 0
1467
+ })
1468
+ );
1469
+ } else {
1470
+ throw new Error(
1471
+ "Usage: ze-great-dashboard-aws bootstrap template|parameters|status|change-set --kind core|github-oidc [options]"
1472
+ );
1473
+ }
1254
1474
  } else if (args[0] !== "package")
1255
1475
  throw new Error(
1256
- "Usage: ze-great-dashboard-aws package|parameters|publish-assets|deploy|doctor [options]"
1476
+ "Usage: ze-great-dashboard-aws package|parameters|bootstrap|publish-assets|deploy|doctor [options]"
1257
1477
  );
1258
1478
  else {
1259
1479
  const boardConfig = option("--board-config");
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { type BootstrapConfig, type BootstrapKind, bootstrapContractVersion, bootstrapTemplate, bootstrapTemplatePath, type CloudFormationParameterValue, coreBootstrapOutputs, type DeployedBootstrapStack, deployedBootstrapStack, mergeBootstrapParameters, requiredBootstrapParameters, } from './bootstrap.js';
1
2
  export type ReleaseMetadata = {
2
3
  dashboardVersion: string;
3
4
  clientAssetUrl: string;
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // packages/aws/src/index.ts
2
2
  import { execFile } from "node:child_process";
3
- import { cp, mkdir as mkdir2, readFile as readFile2, rm, writeFile as writeFile2 } from "node:fs/promises";
3
+ import { cp, mkdir as mkdir2, readFile as readFile3, rm, writeFile as writeFile2 } from "node:fs/promises";
4
4
  import { join as join2, resolve as resolve2 } from "node:path";
5
- import { fileURLToPath } from "node:url";
5
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
6
6
  import { promisify } from "node:util";
7
7
 
8
8
  // node_modules/fflate/esm/index.mjs
@@ -825,6 +825,80 @@ function sha256(value) {
825
825
  return createHash("sha256").update(value).digest("hex");
826
826
  }
827
827
 
828
+ // packages/aws/src/bootstrap.ts
829
+ import { readFile as readFile2 } from "node:fs/promises";
830
+ import { fileURLToPath } from "node:url";
831
+ var templates = {
832
+ core: "../bootstrap/core-v1.yml",
833
+ "github-oidc": "../bootstrap/github-oidc-v1.yml"
834
+ };
835
+ async function bootstrapTemplatePath(kind) {
836
+ if (!Object.hasOwn(templates, kind)) throw new Error(`Unknown bootstrap template: ${kind}`);
837
+ return fileURLToPath(new URL(templates[kind], import.meta.url));
838
+ }
839
+ async function bootstrapTemplate(kind) {
840
+ return readFile2(await bootstrapTemplatePath(kind), "utf8");
841
+ }
842
+ function bootstrapContractVersion(template) {
843
+ const version = template.match(/BootstrapContractVersion:\s*\{\s*Value:\s*'([^']+)'\s*}/)?.[1];
844
+ if (!version) throw new Error("Bootstrap template has no BootstrapContractVersion output");
845
+ return version;
846
+ }
847
+ function mergeBootstrapParameters(requested, deployed) {
848
+ const supplied = new Map(requested.map((entry) => [entry.ParameterKey, entry.ParameterValue]));
849
+ const duplicates = requested.map((entry) => entry.ParameterKey);
850
+ if (new Set(duplicates).size !== duplicates.length)
851
+ throw new Error("Bootstrap parameter file contains duplicate parameters");
852
+ return deployed.map(({ ParameterKey, ParameterValue }) => ({
853
+ ParameterKey,
854
+ ParameterValue: supplied.get(ParameterKey) ?? ParameterValue
855
+ })).concat(
856
+ requested.filter(
857
+ ({ ParameterKey }) => !deployed.some((entry) => entry.ParameterKey === ParameterKey)
858
+ )
859
+ );
860
+ }
861
+ function deployedBootstrapStack(input, expectedContractVersion) {
862
+ const stack = input && typeof input === "object" && "Stacks" in input ? input.Stacks?.[0] : input;
863
+ if (!stack || typeof stack !== "object") throw new Error("Deployed stack JSON contains no stack");
864
+ const deployed = stack;
865
+ const contract = deployed.Outputs?.find(
866
+ (output) => output.OutputKey === "BootstrapContractVersion"
867
+ )?.OutputValue;
868
+ if (contract !== expectedContractVersion)
869
+ throw new Error(
870
+ `Bootstrap contract mismatch (deployed: ${contract ?? "missing"}; expected: ${expectedContractVersion}); follow the documented migration procedure`
871
+ );
872
+ if (deployed.Parameters && !Array.isArray(deployed.Parameters))
873
+ throw new Error("Deployed stack JSON has invalid Parameters");
874
+ return deployed;
875
+ }
876
+ function coreBootstrapOutputs(stack) {
877
+ const values = Object.fromEntries(
878
+ (stack.Outputs ?? []).filter(
879
+ (output) => Boolean(output.OutputValue)
880
+ ).map((output) => [output.OutputKey, output.OutputValue])
881
+ );
882
+ const required = ["ArtifactBucketName", "ApplicationStackName", "CloudFormationExecutionRoleArn"];
883
+ const missing = required.filter((key) => !values[key]);
884
+ if (missing.length) throw new Error(`Core stack JSON is missing outputs: ${missing.join(", ")}`);
885
+ return values;
886
+ }
887
+ function requiredBootstrapParameters(kind, values) {
888
+ const keys = kind === "core" ? ["ArtifactBucketName", "ApplicationStackName", "DashboardFunctionName"] : [
889
+ "GitHubOidcProviderArn",
890
+ "GitHubRepository",
891
+ "GitHubEnvironment",
892
+ "ApplicationStackName",
893
+ "ArtifactBucketName",
894
+ "CloudFormationExecutionRoleArn"
895
+ ];
896
+ const missing = keys.filter((key) => !values[key]);
897
+ if (missing.length) throw new Error(`Missing required bootstrap values: ${missing.join(", ")}`);
898
+ const optional = kind === "core" ? ["RuntimeSecretArn", "ArtifactKmsKeyArn"] : [];
899
+ return [...keys, ...optional].filter((key) => values[key] !== void 0).map((ParameterKey) => ({ ParameterKey, ParameterValue: values[ParameterKey] ?? "" }));
900
+ }
901
+
828
902
  // packages/aws/src/index.ts
829
903
  var run = promisify(execFile);
830
904
  function deploymentTemplate(template, values) {
@@ -854,7 +928,7 @@ async function packageLambda(options) {
854
928
  await mkdir2(outputDir, { recursive: true });
855
929
  const runtimeDir = join2(outputDir, "lambda");
856
930
  await mkdir2(runtimeDir, { recursive: true });
857
- const lambdaSource = fileURLToPath(new URL("../dist/lambda.mjs", import.meta.url));
931
+ const lambdaSource = fileURLToPath2(new URL("../dist/lambda.mjs", import.meta.url));
858
932
  await cp(lambdaSource, join2(runtimeDir, "index.mjs"));
859
933
  const release = await assembleRelease({
860
934
  boardConfigPath: options.boardConfigPath,
@@ -867,7 +941,7 @@ async function packageLambda(options) {
867
941
  ...release.metadata,
868
942
  artifactChecksums: {
869
943
  ...release.metadata.artifactChecksums,
870
- "index.mjs": sha256(await readFile2(join2(runtimeDir, "index.mjs")))
944
+ "index.mjs": sha256(await readFile3(join2(runtimeDir, "index.mjs")))
871
945
  }
872
946
  };
873
947
  await writeFile2(join2(runtimeDir, "release.json"), `${JSON.stringify(runtimeMetadata, null, 2)}
@@ -882,7 +956,7 @@ async function packageLambda(options) {
882
956
  archiveFiles.map(async (name) => [
883
957
  name,
884
958
  [
885
- strToU8(await readFile2(join2(runtimeDir, name), "utf8")),
959
+ strToU8(await readFile3(join2(runtimeDir, name), "utf8")),
886
960
  { mtime: new Date(1980, 0, 1, 0, 0, 0), level: 9 }
887
961
  ]
888
962
  ])
@@ -890,7 +964,7 @@ async function packageLambda(options) {
890
964
  );
891
965
  await writeFile2(lambdaPath, zipSync(archive));
892
966
  await rm(runtimeDir, { recursive: true, force: true });
893
- const lambdaChecksum = sha256(await readFile2(lambdaPath));
967
+ const lambdaChecksum = sha256(await readFile3(lambdaPath));
894
968
  const deploymentChecksum = sha256(JSON.stringify(runtimeMetadata));
895
969
  const packagedRelease = {
896
970
  ...runtimeMetadata,
@@ -910,7 +984,7 @@ async function packageLambda(options) {
910
984
  }
911
985
  async function publishClientAssets(options) {
912
986
  const assetsDir = resolve2(options.assetsDir);
913
- await readFile2(join2(assetsDir, "index.html"));
987
+ await readFile3(join2(assetsDir, "index.html"));
914
988
  const assetPath = `${options.assetsBaseUrl.replace(/\/+$/, "")}/dashboard/${options.version}`;
915
989
  await run("aws", [
916
990
  "s3",
@@ -935,8 +1009,8 @@ async function publishClientAssets(options) {
935
1009
  async function deployLambda(options) {
936
1010
  const artifactDir = resolve2(options.artifactDir);
937
1011
  const assetsDir = resolve2(options.assetsDir);
938
- await readFile2(join2(artifactDir, "lambda.zip"));
939
- await readFile2(join2(assetsDir, "index.html"));
1012
+ await readFile3(join2(artifactDir, "lambda.zip"));
1013
+ await readFile3(join2(assetsDir, "index.html"));
940
1014
  if (options.dryRun) return;
941
1015
  const assetPath = await publishClientAssets(options);
942
1016
  await run("aws", [
@@ -961,11 +1035,18 @@ async function deployLambda(options) {
961
1035
  await run("aws", ["lambda", "wait", "function-updated", "--function-name", options.functionName]);
962
1036
  }
963
1037
  async function cloudFormationTemplate() {
964
- return readFile2(fileURLToPath(new URL("../template.yml", import.meta.url)), "utf8");
1038
+ return readFile3(fileURLToPath2(new URL("../template.yml", import.meta.url)), "utf8");
965
1039
  }
966
1040
  export {
1041
+ bootstrapContractVersion,
1042
+ bootstrapTemplate,
1043
+ bootstrapTemplatePath,
967
1044
  cloudFormationTemplate,
1045
+ coreBootstrapOutputs,
968
1046
  deployLambda,
1047
+ deployedBootstrapStack,
1048
+ mergeBootstrapParameters,
969
1049
  packageLambda,
970
- publishClientAssets
1050
+ publishClientAssets,
1051
+ requiredBootstrapParameters
971
1052
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@continuous-excellence/ze-great-dashboard-aws",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "type": "module",
5
5
  "description": "AWS Lambda and CloudFormation adapter for Ze Great Dashboard.",
6
6
  "keywords": [
@@ -29,6 +29,7 @@
29
29
  "files": [
30
30
  "dist",
31
31
  "client",
32
+ "bootstrap",
32
33
  "template.yml",
33
34
  "LICENSE"
34
35
  ],
package/template.yml CHANGED
@@ -1,5 +1,5 @@
1
1
  AWSTemplateFormatVersion: '2010-09-09'
2
- Description: Provider adapter for a Ze Great Dashboard Lambda deployment
2
+ Description: Ze Great Dashboard private Lambda application (integrate through a customer-managed gateway)
3
3
 
4
4
  Metadata:
5
5
  PackageManagedParameters: [LambdaArtifactKey, DashboardVersion]
@@ -67,17 +67,7 @@ Resources:
67
67
  BOARD_CONFIG_URL: !Ref BoardConfigPath
68
68
  HOST: 0.0.0.0
69
69
  SECRET_REFERENCE: !If [HasSecretReference, !Ref SecretReference, !Ref AWS::NoValue]
70
- ServerUrl:
71
- Type: AWS::Lambda::Url
72
- Properties: { TargetFunctionArn: !GetAtt ServerFunction.Arn, AuthType: NONE }
73
- ServerUrlPermission:
74
- Type: AWS::Lambda::Permission
75
- Properties: { FunctionName: !Ref ServerFunction, Action: lambda:InvokeFunctionUrl, Principal: '*', FunctionUrlAuthType: NONE }
76
- ServerInvokePermission:
77
- Type: AWS::Lambda::Permission
78
- Properties: { FunctionName: !Ref ServerFunction, Action: lambda:InvokeFunction, Principal: '*', InvokedViaFunctionUrl: true }
79
-
80
70
  Outputs:
81
- ServerUrl: { Value: !GetAtt ServerUrl.FunctionUrl }
82
71
  ServerFunctionName: { Value: !Ref ServerFunction }
72
+ ServerFunctionArn: { Value: !GetAtt ServerFunction.Arn }
83
73
  AssetPath: { Value: !Sub '${AssetBaseUrl}/dashboard/${DashboardVersion}' }