@continuous-excellence/ze-great-dashboard-aws 0.1.32 → 0.2.0

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.
package/dist/cli.js CHANGED
@@ -892,8 +892,8 @@ function coreBootstrapOutputs(stack) {
892
892
  (output) => Boolean(output.OutputValue)
893
893
  ).map((output) => [output.OutputKey, output.OutputValue])
894
894
  );
895
- const required = ["ArtifactBucketName", "ApplicationStackName", "CloudFormationExecutionRoleArn"];
896
- const missing = required.filter((key) => !values[key]);
895
+ const required2 = ["ArtifactBucketName", "ApplicationStackName", "CloudFormationExecutionRoleArn"];
896
+ const missing = required2.filter((key) => !values[key]);
897
897
  if (missing.length) throw new Error(`Core stack JSON is missing outputs: ${missing.join(", ")}`);
898
898
  return values;
899
899
  }
@@ -914,6 +914,383 @@ function requiredBootstrapParameters(kind, values) {
914
914
  return [...keys, ...optional].filter((key) => values[key] !== void 0).map((ParameterKey) => ({ ParameterKey, ParameterValue: values[ParameterKey] ?? "" }));
915
915
  }
916
916
 
917
+ // packages/aws/src/handoff.ts
918
+ var githubSubjectKeys = [
919
+ "repository_owner",
920
+ "repository_owner_id",
921
+ "repository",
922
+ "repository_id",
923
+ "context"
924
+ ];
925
+ function outputValues(stack) {
926
+ return Object.fromEntries(
927
+ (stack.Outputs ?? []).filter(
928
+ (entry) => typeof entry.OutputKey === "string" && typeof entry.OutputValue === "string"
929
+ ).map(({ OutputKey, OutputValue }) => [OutputKey, OutputValue])
930
+ );
931
+ }
932
+ function parameterValues(stack) {
933
+ return Object.fromEntries(
934
+ (stack.Parameters ?? []).filter(
935
+ (entry) => typeof entry.ParameterKey === "string" && typeof entry.ParameterValue === "string"
936
+ ).map(({ ParameterKey, ParameterValue }) => [ParameterKey, ParameterValue])
937
+ );
938
+ }
939
+ function capturedStackIdentity(input) {
940
+ const candidate = input && typeof input === "object" && "Stacks" in input ? input.Stacks?.[0] : input;
941
+ if (!candidate || typeof candidate !== "object") return {};
942
+ const { StackName, StackId } = candidate;
943
+ return {
944
+ ...typeof StackName === "string" ? { StackName } : {},
945
+ ...typeof StackId === "string" ? { StackId } : {}
946
+ };
947
+ }
948
+ function required(value, name) {
949
+ if (!value) throw new Error(`Bootstrap manifest is missing ${name}`);
950
+ return value;
951
+ }
952
+ function region(config) {
953
+ return required(config.region, "region");
954
+ }
955
+ function stackName(config, kind) {
956
+ return required(
957
+ kind === "core" ? config.core?.stackName : config.githubOidc?.stackName,
958
+ `${kind}.stackName`
959
+ );
960
+ }
961
+ function changeSetCommands(input) {
962
+ const name = `${input.kind}-initial-review`;
963
+ const stack = stackName(input.config, input.kind);
964
+ const common = ["--stack-name", stack, "--region", region(input.config)];
965
+ const parameters = [
966
+ "ze-great-dashboard-aws",
967
+ "bootstrap",
968
+ "parameters",
969
+ "--kind",
970
+ input.kind,
971
+ "--config",
972
+ input.configPath,
973
+ "--output",
974
+ input.parameterPath
975
+ ];
976
+ if (input.kind === "github-oidc")
977
+ parameters.push("--core-stack-json", input.coreCapturePath ?? "core-deployed-stack.json");
978
+ return [
979
+ { name: "generate-parameters", args: parameters },
980
+ {
981
+ name: "create-change-set",
982
+ args: [
983
+ "aws",
984
+ "cloudformation",
985
+ "create-change-set",
986
+ ...common,
987
+ "--change-set-name",
988
+ name,
989
+ "--change-set-type",
990
+ "CREATE",
991
+ "--template-body",
992
+ `file://${input.templatePath}`,
993
+ "--parameters",
994
+ `file://${input.parameterPath}`,
995
+ "--capabilities",
996
+ "CAPABILITY_NAMED_IAM",
997
+ "--no-cli-pager"
998
+ ]
999
+ },
1000
+ {
1001
+ name: "wait-for-change-set",
1002
+ args: [
1003
+ "aws",
1004
+ "cloudformation",
1005
+ "wait",
1006
+ "change-set-create-complete",
1007
+ ...common,
1008
+ "--change-set-name",
1009
+ name
1010
+ ]
1011
+ },
1012
+ {
1013
+ name: "review-change-set",
1014
+ args: [
1015
+ "aws",
1016
+ "cloudformation",
1017
+ "describe-change-set",
1018
+ ...common,
1019
+ "--change-set-name",
1020
+ name,
1021
+ "--no-cli-pager"
1022
+ ]
1023
+ },
1024
+ {
1025
+ name: "execute-reviewed-change-set",
1026
+ args: ["aws", "cloudformation", "execute-change-set", ...common, "--change-set-name", name]
1027
+ },
1028
+ {
1029
+ name: "wait-for-stack",
1030
+ args: ["aws", "cloudformation", "wait", "stack-create-complete", ...common]
1031
+ },
1032
+ {
1033
+ name: "capture-stack",
1034
+ captureFile: `${input.kind}-deployed-stack.json`,
1035
+ args: ["aws", "cloudformation", "describe-stacks", ...common, "--no-cli-pager"]
1036
+ }
1037
+ ];
1038
+ }
1039
+ var githubOidcProvider = {
1040
+ name: "github-oidc",
1041
+ async prerequisite(config, runner) {
1042
+ const repository = config.githubOidc?.repository;
1043
+ if (!repository)
1044
+ return {
1045
+ status: "unverified",
1046
+ blocking: false,
1047
+ detail: "GitHub repository is absent from the manifest."
1048
+ };
1049
+ if (!runner)
1050
+ return {
1051
+ status: "unverified",
1052
+ blocking: false,
1053
+ detail: "GitHub OIDC subject setting was not queried."
1054
+ };
1055
+ try {
1056
+ const response = JSON.parse(
1057
+ await runner.execute("gh", ["api", `repos/${repository}/actions/oidc/customization/sub`])
1058
+ );
1059
+ const keys = response.include_claim_keys;
1060
+ if (response.use_default === false && Array.isArray(keys) && githubSubjectKeys.every((key) => keys.includes(key)))
1061
+ return {
1062
+ status: "ready",
1063
+ blocking: false,
1064
+ detail: "GitHub emits the immutable owner/repository-ID OIDC subject required by this adapter."
1065
+ };
1066
+ return {
1067
+ status: "immutable-subject-required",
1068
+ blocking: true,
1069
+ detail: "Coordinate migration: inventory and temporarily make existing name-based trust policies compatible, enable immutable GitHub OIDC subjects as a separate GitHub-admin action, verify existing deployments, then retire legacy trust."
1070
+ };
1071
+ } catch {
1072
+ return {
1073
+ status: "unverified",
1074
+ blocking: false,
1075
+ detail: "GitHub OIDC subject setting could not be verified (gh CLI, authentication, permission, or network unavailable)."
1076
+ };
1077
+ }
1078
+ }
1079
+ };
1080
+ async function contracts() {
1081
+ return {
1082
+ core: bootstrapContractVersion(await bootstrapTemplate("core")),
1083
+ githubOidc: bootstrapContractVersion(await bootstrapTemplate("github-oidc"))
1084
+ };
1085
+ }
1086
+ async function bootstrapHandoff(input) {
1087
+ const expectedContracts = await contracts();
1088
+ const coreCapturePath = input.coreStackPath ?? "core-deployed-stack.json";
1089
+ const core = input.coreStack === void 0 ? void 0 : deployedBootstrapStack(input.coreStack, expectedContracts.core);
1090
+ const oidc = input.githubOidcStack === void 0 ? void 0 : deployedBootstrapStack(input.githubOidcStack, expectedContracts.githubOidc);
1091
+ if (!core) {
1092
+ requiredBootstrapParameters("core", {
1093
+ ArtifactBucketName: input.config.core?.artifactBucketName,
1094
+ ApplicationStackName: input.config.core?.applicationStackName,
1095
+ DashboardFunctionName: input.config.core?.dashboardFunctionName
1096
+ });
1097
+ const templatePath = await bootstrapTemplatePath("core");
1098
+ return {
1099
+ phase: "core",
1100
+ expectedContracts,
1101
+ templatePath,
1102
+ parameterPath: "core-bootstrap.json",
1103
+ requiredCapturedFiles: [],
1104
+ reviewCheckpoints: [
1105
+ "Review every IAM action and CAPABILITY_NAMED_IAM acknowledgement.",
1106
+ "Do not approve retained bucket or role replacements."
1107
+ ],
1108
+ commands: changeSetCommands({
1109
+ kind: "core",
1110
+ config: input.config,
1111
+ configPath: input.configPath,
1112
+ templatePath,
1113
+ parameterPath: "core-bootstrap.json"
1114
+ })
1115
+ };
1116
+ }
1117
+ coreBootstrapOutputs(core);
1118
+ if (!oidc) {
1119
+ requiredBootstrapParameters("github-oidc", {
1120
+ GitHubOidcProviderArn: input.config.githubOidc?.providerArn,
1121
+ GitHubRepository: input.config.githubOidc?.repository,
1122
+ GitHubOwnerId: input.config.githubOidc?.ownerId,
1123
+ GitHubRepositoryId: input.config.githubOidc?.repositoryId,
1124
+ GitHubEnvironment: input.config.githubOidc?.environment,
1125
+ ...coreBootstrapOutputs(core)
1126
+ });
1127
+ const templatePath = await bootstrapTemplatePath("github-oidc");
1128
+ return {
1129
+ phase: "github-oidc",
1130
+ expectedContracts,
1131
+ templatePath,
1132
+ parameterPath: "github-oidc-bootstrap.json",
1133
+ requiredCapturedFiles: [coreCapturePath],
1134
+ reviewCheckpoints: [
1135
+ "Review the exact immutable GitHub OIDC subject, audience, bucket lambda/* prefix, application stack, and execution role.",
1136
+ "Do not execute until the GitHub Environment prerequisite is ready."
1137
+ ],
1138
+ commands: changeSetCommands({
1139
+ kind: "github-oidc",
1140
+ config: input.config,
1141
+ configPath: input.configPath,
1142
+ templatePath,
1143
+ parameterPath: "github-oidc-bootstrap.json",
1144
+ coreCapturePath
1145
+ }),
1146
+ prerequisite: await (input.provider ?? githubOidcProvider).prerequisite(
1147
+ input.config,
1148
+ input.runner
1149
+ )
1150
+ };
1151
+ }
1152
+ const prerequisite = await (input.provider ?? githubOidcProvider).prerequisite(
1153
+ input.config,
1154
+ input.runner
1155
+ );
1156
+ if (prerequisite.status !== "ready")
1157
+ return {
1158
+ phase: "github-environment",
1159
+ expectedContracts,
1160
+ requiredCapturedFiles: [coreCapturePath, "github-oidc-deployed-stack.json"],
1161
+ reviewCheckpoints: [
1162
+ "A GitHub administrator must complete and verify the immutable-subject migration before deployments."
1163
+ ],
1164
+ commands: [],
1165
+ prerequisite
1166
+ };
1167
+ return {
1168
+ phase: "application-gateway",
1169
+ expectedContracts,
1170
+ requiredCapturedFiles: [coreCapturePath, "github-oidc-deployed-stack.json"],
1171
+ reviewCheckpoints: [
1172
+ "Consumer owns gateway selection, private Lambda permission, authentication, and smoke tests."
1173
+ ],
1174
+ commands: [],
1175
+ prerequisite
1176
+ };
1177
+ }
1178
+ function assertEqual(actual, expected, label) {
1179
+ if (actual !== expected)
1180
+ throw new Error(
1181
+ `${label} mismatch (captured: ${actual ?? "missing"}; manifest: ${expected ?? "missing"})`
1182
+ );
1183
+ }
1184
+ async function verifyBootstrap(input) {
1185
+ const expected = await contracts();
1186
+ const core = deployedBootstrapStack(input.coreStack, expected.core);
1187
+ const oidc = deployedBootstrapStack(input.githubOidcStack, expected.githubOidc);
1188
+ for (const [kind, capture] of [
1189
+ ["Core", capturedStackIdentity(input.coreStack)],
1190
+ ["GitHub OIDC", capturedStackIdentity(input.githubOidcStack)]
1191
+ ]) {
1192
+ const expectedName = kind === "Core" ? input.config.core?.stackName : input.config.githubOidc?.stackName;
1193
+ assertEqual(capture.StackName, expectedName, `${kind} stack name`);
1194
+ const capturedRegion = capture.StackId?.match(/^arn:[^:]+:cloudformation:([^:]+):/)?.[1];
1195
+ assertEqual(capturedRegion, input.config.region, `${kind} stack Region`);
1196
+ }
1197
+ const coreOutputs = coreBootstrapOutputs(core);
1198
+ const coreParameters = parameterValues(core);
1199
+ assertEqual(
1200
+ coreParameters.ArtifactBucketName,
1201
+ input.config.core?.artifactBucketName,
1202
+ "Artifact bucket"
1203
+ );
1204
+ assertEqual(
1205
+ coreParameters.ApplicationStackName,
1206
+ input.config.core?.applicationStackName,
1207
+ "Application stack"
1208
+ );
1209
+ assertEqual(
1210
+ coreParameters.DashboardFunctionName,
1211
+ input.config.core?.dashboardFunctionName,
1212
+ "Dashboard function"
1213
+ );
1214
+ assertEqual(
1215
+ coreOutputs.ArtifactBucketName,
1216
+ input.config.core?.artifactBucketName,
1217
+ "Core output artifact bucket"
1218
+ );
1219
+ assertEqual(
1220
+ coreOutputs.ApplicationStackName,
1221
+ input.config.core?.applicationStackName,
1222
+ "Core output application stack"
1223
+ );
1224
+ const oidcParameters = parameterValues(oidc);
1225
+ const github = input.config.githubOidc;
1226
+ const executionRole = required(
1227
+ coreOutputs.CloudFormationExecutionRoleArn,
1228
+ "core CloudFormationExecutionRoleArn output"
1229
+ );
1230
+ for (const [key, value] of Object.entries({
1231
+ GitHubOidcProviderArn: github?.providerArn,
1232
+ GitHubRepository: github?.repository,
1233
+ GitHubOwnerId: github?.ownerId,
1234
+ GitHubRepositoryId: github?.repositoryId,
1235
+ GitHubEnvironment: github?.environment,
1236
+ ApplicationStackName: coreOutputs.ApplicationStackName,
1237
+ ArtifactBucketName: coreOutputs.ArtifactBucketName,
1238
+ CloudFormationExecutionRoleArn: executionRole
1239
+ }))
1240
+ assertEqual(oidcParameters[key], value, `OIDC ${key}`);
1241
+ if (!/^\d+$/.test(github?.ownerId ?? "") || !/^\d+$/.test(github?.repositoryId ?? ""))
1242
+ throw new Error("GitHub immutable owner and repository IDs must be numeric");
1243
+ const [owner, repository, ...extra] = (github?.repository ?? "").split("/");
1244
+ if (!owner || !repository || extra.length)
1245
+ throw new Error("GitHub repository must be owner/repository");
1246
+ const deployRole = outputValues(oidc).GitHubDeployRoleArn;
1247
+ if (!deployRole) throw new Error("GitHub OIDC stack JSON is missing outputs: GitHubDeployRoleArn");
1248
+ const providerArn = github?.providerArn ?? "";
1249
+ const account = providerArn.match(/^arn:[^:]+:iam::(\d+):oidc-provider\//)?.[1];
1250
+ const deployAccount = deployRole.match(/^arn:[^:]+:iam::(\d+):role\//)?.[1];
1251
+ const executionAccount = executionRole.match(/^arn:[^:]+:iam::(\d+):role\//)?.[1];
1252
+ if (!account || account !== deployAccount || account !== executionAccount)
1253
+ throw new Error("Provider and reviewed role ARNs must belong to the same AWS account");
1254
+ return {
1255
+ verified: true,
1256
+ immutableSubject: `repo:${owner}@${github?.ownerId}/${repository}@${github?.repositoryId}:environment:${github?.environment}`,
1257
+ environmentVariables: {
1258
+ AWS_DEPLOY_ROLE_ARN: deployRole,
1259
+ AWS_CLOUDFORMATION_EXECUTION_ROLE_ARN: executionRole
1260
+ },
1261
+ reviewedArns: {
1262
+ githubDeployRoleArn: deployRole,
1263
+ cloudFormationExecutionRoleArn: executionRole
1264
+ },
1265
+ githubEnvironmentInstructions: [
1266
+ [
1267
+ "gh",
1268
+ "variable",
1269
+ "set",
1270
+ "AWS_DEPLOY_ROLE_ARN",
1271
+ "--repo",
1272
+ `${owner}/${repository}`,
1273
+ "--env",
1274
+ github?.environment ?? "",
1275
+ "--body",
1276
+ deployRole
1277
+ ],
1278
+ [
1279
+ "gh",
1280
+ "variable",
1281
+ "set",
1282
+ "AWS_CLOUDFORMATION_EXECUTION_ROLE_ARN",
1283
+ "--repo",
1284
+ `${owner}/${repository}`,
1285
+ "--env",
1286
+ github?.environment ?? "",
1287
+ "--body",
1288
+ executionRole
1289
+ ]
1290
+ ]
1291
+ };
1292
+ }
1293
+
917
1294
  // packages/aws/src/index.ts
918
1295
  var run = promisify(execFile);
919
1296
  function deploymentTemplate(template, values) {
@@ -1318,9 +1695,9 @@ try {
1318
1695
  console.log(JSON.stringify({ published: true, version, assetPath }));
1319
1696
  } else if (args[0] === "doctor") {
1320
1697
  const parametersPath = option("--parameters", "aws-dashboard-parameters.json") ?? "aws-dashboard-parameters.json";
1321
- const region = option("--region", process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-1") ?? "us-east-1";
1698
+ const region2 = option("--region", process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-1") ?? "us-east-1";
1322
1699
  const checks = await runDoctor(
1323
- { parametersPath, region },
1700
+ { parametersPath, region: region2 },
1324
1701
  {
1325
1702
  async execute(command, commandArgs) {
1326
1703
  const { execFile: execFile3 } = await import("node:child_process");
@@ -1380,9 +1757,45 @@ try {
1380
1757
  console.log(JSON.stringify({ output }));
1381
1758
  } else if (args[0] === "bootstrap") {
1382
1759
  const action = args[1];
1383
- const kind = bootstrapKind();
1384
1760
  const config = await bootstrapConfig();
1385
- if (action === "template") {
1761
+ if (action === "handoff") {
1762
+ const configPath = requiredOption("--config");
1763
+ const coreStackPath = option("--core-stack-json");
1764
+ const githubStackPath = option("--github-oidc-stack-json");
1765
+ const runner = {
1766
+ async execute(command, commandArgs) {
1767
+ const { execFile: execFile3 } = await import("node:child_process");
1768
+ const { promisify: promisify3 } = await import("node:util");
1769
+ return (await promisify3(execFile3)(command, commandArgs)).stdout.trim();
1770
+ }
1771
+ };
1772
+ console.log(
1773
+ JSON.stringify(
1774
+ await bootstrapHandoff({
1775
+ config,
1776
+ configPath,
1777
+ coreStack: coreStackPath ? JSON.parse(await readFile5(coreStackPath, "utf8")) : void 0,
1778
+ coreStackPath,
1779
+ githubOidcStack: githubStackPath ? JSON.parse(await readFile5(githubStackPath, "utf8")) : void 0,
1780
+ runner
1781
+ })
1782
+ )
1783
+ );
1784
+ } else if (action === "verify") {
1785
+ requiredOption("--config");
1786
+ const coreStackPath = requiredOption("--core-stack-json");
1787
+ const githubStackPath = requiredOption("--github-oidc-stack-json");
1788
+ console.log(
1789
+ JSON.stringify(
1790
+ await verifyBootstrap({
1791
+ config,
1792
+ coreStack: JSON.parse(await readFile5(coreStackPath, "utf8")),
1793
+ githubOidcStack: JSON.parse(await readFile5(githubStackPath, "utf8"))
1794
+ })
1795
+ )
1796
+ );
1797
+ } else if (action === "template") {
1798
+ const kind = bootstrapKind();
1386
1799
  console.log(
1387
1800
  JSON.stringify({
1388
1801
  kind,
@@ -1391,6 +1804,7 @@ try {
1391
1804
  })
1392
1805
  );
1393
1806
  } else if (action === "parameters") {
1807
+ const kind = bootstrapKind();
1394
1808
  const output = option("--output", `aws-dashboard-bootstrap-${kind}.json`) ?? `aws-dashboard-bootstrap-${kind}.json`;
1395
1809
  let coreOutputs = {};
1396
1810
  const coreStackPath = option("--core-stack-json");
@@ -1418,15 +1832,16 @@ try {
1418
1832
  JSON.stringify({ output, kind, preservedDeployedValues: Boolean(deployedStackPath) })
1419
1833
  );
1420
1834
  } else if (action === "status") {
1421
- const stackName = requiredOption("--stack-name", bootstrapStackName(kind, config));
1422
- const region = option("--region", config.region);
1835
+ const kind = bootstrapKind();
1836
+ const stackName2 = requiredOption("--stack-name", bootstrapStackName(kind, config));
1837
+ const region2 = option("--region", config.region);
1423
1838
  const awsCommand = [
1424
1839
  "aws",
1425
1840
  "cloudformation",
1426
1841
  "describe-stacks",
1427
1842
  "--stack-name",
1428
- stackName,
1429
- ...region ? ["--region", region] : [],
1843
+ stackName2,
1844
+ ...region2 ? ["--region", region2] : [],
1430
1845
  "--no-cli-pager"
1431
1846
  ];
1432
1847
  console.log(
@@ -1438,17 +1853,18 @@ try {
1438
1853
  })
1439
1854
  );
1440
1855
  } else if (action === "change-set") {
1441
- const stackName = requiredOption("--stack-name", bootstrapStackName(kind, config));
1856
+ const kind = bootstrapKind();
1857
+ const stackName2 = requiredOption("--stack-name", bootstrapStackName(kind, config));
1442
1858
  const changeSetName = requiredOption("--change-set-name");
1443
1859
  const parametersPath = requiredOption("--parameters");
1444
- const region = option("--region", config.region);
1860
+ const region2 = option("--region", config.region);
1445
1861
  await existingParameters(parametersPath);
1446
1862
  const awsCommand = [
1447
1863
  "aws",
1448
1864
  "cloudformation",
1449
1865
  "create-change-set",
1450
1866
  "--stack-name",
1451
- stackName,
1867
+ stackName2,
1452
1868
  "--change-set-name",
1453
1869
  changeSetName,
1454
1870
  "--change-set-type",
@@ -1459,7 +1875,7 @@ try {
1459
1875
  `file://${parametersPath}`,
1460
1876
  "--capabilities",
1461
1877
  "CAPABILITY_NAMED_IAM",
1462
- ...region ? ["--region", region] : [],
1878
+ ...region2 ? ["--region", region2] : [],
1463
1879
  "--no-cli-pager"
1464
1880
  ];
1465
1881
  console.log(
@@ -1472,7 +1888,7 @@ try {
1472
1888
  );
1473
1889
  } else {
1474
1890
  throw new Error(
1475
- "Usage: ze-great-dashboard-aws bootstrap template|parameters|status|change-set --kind core|github-oidc [options]"
1891
+ "Usage: ze-great-dashboard-aws bootstrap handoff|verify --config manifest.json [options], or bootstrap template|parameters|status|change-set --kind core|github-oidc [options]"
1476
1892
  );
1477
1893
  }
1478
1894
  } else if (args[0] !== "package")
@@ -0,0 +1,60 @@
1
+ import { type BootstrapConfig } from './bootstrap.js';
2
+ export type BootstrapPhase = 'core' | 'github-oidc' | 'github-environment' | 'application-gateway';
3
+ export type CommandRunner = {
4
+ execute(command: string, args: string[]): Promise<string>;
5
+ };
6
+ export type BootstrapProvider = {
7
+ readonly name: string;
8
+ prerequisite(config: BootstrapConfig, runner?: CommandRunner): Promise<ProviderPrerequisite>;
9
+ };
10
+ export type ProviderPrerequisite = {
11
+ status: 'ready' | 'immutable-subject-required' | 'unverified';
12
+ blocking: boolean;
13
+ detail: string;
14
+ };
15
+ export type HandoffCommand = {
16
+ name: string;
17
+ args: string[];
18
+ captureFile?: string;
19
+ };
20
+ export type BootstrapHandoff = {
21
+ phase: BootstrapPhase;
22
+ expectedContracts: {
23
+ core: string;
24
+ githubOidc: string;
25
+ };
26
+ templatePath?: string;
27
+ parameterPath?: string;
28
+ requiredCapturedFiles: string[];
29
+ reviewCheckpoints: string[];
30
+ commands: HandoffCommand[];
31
+ prerequisite?: ProviderPrerequisite;
32
+ };
33
+ export type BootstrapVerification = {
34
+ verified: true;
35
+ immutableSubject: string;
36
+ environmentVariables: {
37
+ AWS_DEPLOY_ROLE_ARN: string;
38
+ AWS_CLOUDFORMATION_EXECUTION_ROLE_ARN: string;
39
+ };
40
+ reviewedArns: {
41
+ githubDeployRoleArn: string;
42
+ cloudFormationExecutionRoleArn: string;
43
+ };
44
+ githubEnvironmentInstructions: string[][];
45
+ };
46
+ export declare const githubOidcProvider: BootstrapProvider;
47
+ export declare function bootstrapHandoff(input: {
48
+ config: BootstrapConfig;
49
+ configPath: string;
50
+ coreStack?: unknown;
51
+ coreStackPath?: string;
52
+ githubOidcStack?: unknown;
53
+ provider?: BootstrapProvider;
54
+ runner?: CommandRunner;
55
+ }): Promise<BootstrapHandoff>;
56
+ export declare function verifyBootstrap(input: {
57
+ config: BootstrapConfig;
58
+ coreStack: unknown;
59
+ githubOidcStack: unknown;
60
+ }): Promise<BootstrapVerification>;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { type BootstrapConfig, type BootstrapKind, bootstrapContractVersion, bootstrapTemplate, bootstrapTemplatePath, type CloudFormationParameterValue, coreBootstrapOutputs, type DeployedBootstrapStack, deployedBootstrapStack, mergeBootstrapParameters, requiredBootstrapParameters, } from './bootstrap.js';
2
+ export { type BootstrapHandoff, type BootstrapPhase, type BootstrapProvider, type BootstrapVerification, bootstrapHandoff, type CommandRunner, githubOidcProvider, verifyBootstrap, } from './handoff.js';
2
3
  export type ReleaseMetadata = {
3
4
  dashboardVersion: string;
4
5
  clientAssetUrl: string;
package/dist/index.js CHANGED
@@ -879,8 +879,8 @@ function coreBootstrapOutputs(stack) {
879
879
  (output) => Boolean(output.OutputValue)
880
880
  ).map((output) => [output.OutputKey, output.OutputValue])
881
881
  );
882
- const required = ["ArtifactBucketName", "ApplicationStackName", "CloudFormationExecutionRoleArn"];
883
- const missing = required.filter((key) => !values[key]);
882
+ const required2 = ["ArtifactBucketName", "ApplicationStackName", "CloudFormationExecutionRoleArn"];
883
+ const missing = required2.filter((key) => !values[key]);
884
884
  if (missing.length) throw new Error(`Core stack JSON is missing outputs: ${missing.join(", ")}`);
885
885
  return values;
886
886
  }
@@ -901,6 +901,383 @@ function requiredBootstrapParameters(kind, values) {
901
901
  return [...keys, ...optional].filter((key) => values[key] !== void 0).map((ParameterKey) => ({ ParameterKey, ParameterValue: values[ParameterKey] ?? "" }));
902
902
  }
903
903
 
904
+ // packages/aws/src/handoff.ts
905
+ var githubSubjectKeys = [
906
+ "repository_owner",
907
+ "repository_owner_id",
908
+ "repository",
909
+ "repository_id",
910
+ "context"
911
+ ];
912
+ function outputValues(stack) {
913
+ return Object.fromEntries(
914
+ (stack.Outputs ?? []).filter(
915
+ (entry) => typeof entry.OutputKey === "string" && typeof entry.OutputValue === "string"
916
+ ).map(({ OutputKey, OutputValue }) => [OutputKey, OutputValue])
917
+ );
918
+ }
919
+ function parameterValues(stack) {
920
+ return Object.fromEntries(
921
+ (stack.Parameters ?? []).filter(
922
+ (entry) => typeof entry.ParameterKey === "string" && typeof entry.ParameterValue === "string"
923
+ ).map(({ ParameterKey, ParameterValue }) => [ParameterKey, ParameterValue])
924
+ );
925
+ }
926
+ function capturedStackIdentity(input) {
927
+ const candidate = input && typeof input === "object" && "Stacks" in input ? input.Stacks?.[0] : input;
928
+ if (!candidate || typeof candidate !== "object") return {};
929
+ const { StackName, StackId } = candidate;
930
+ return {
931
+ ...typeof StackName === "string" ? { StackName } : {},
932
+ ...typeof StackId === "string" ? { StackId } : {}
933
+ };
934
+ }
935
+ function required(value, name) {
936
+ if (!value) throw new Error(`Bootstrap manifest is missing ${name}`);
937
+ return value;
938
+ }
939
+ function region(config) {
940
+ return required(config.region, "region");
941
+ }
942
+ function stackName(config, kind) {
943
+ return required(
944
+ kind === "core" ? config.core?.stackName : config.githubOidc?.stackName,
945
+ `${kind}.stackName`
946
+ );
947
+ }
948
+ function changeSetCommands(input) {
949
+ const name = `${input.kind}-initial-review`;
950
+ const stack = stackName(input.config, input.kind);
951
+ const common = ["--stack-name", stack, "--region", region(input.config)];
952
+ const parameters = [
953
+ "ze-great-dashboard-aws",
954
+ "bootstrap",
955
+ "parameters",
956
+ "--kind",
957
+ input.kind,
958
+ "--config",
959
+ input.configPath,
960
+ "--output",
961
+ input.parameterPath
962
+ ];
963
+ if (input.kind === "github-oidc")
964
+ parameters.push("--core-stack-json", input.coreCapturePath ?? "core-deployed-stack.json");
965
+ return [
966
+ { name: "generate-parameters", args: parameters },
967
+ {
968
+ name: "create-change-set",
969
+ args: [
970
+ "aws",
971
+ "cloudformation",
972
+ "create-change-set",
973
+ ...common,
974
+ "--change-set-name",
975
+ name,
976
+ "--change-set-type",
977
+ "CREATE",
978
+ "--template-body",
979
+ `file://${input.templatePath}`,
980
+ "--parameters",
981
+ `file://${input.parameterPath}`,
982
+ "--capabilities",
983
+ "CAPABILITY_NAMED_IAM",
984
+ "--no-cli-pager"
985
+ ]
986
+ },
987
+ {
988
+ name: "wait-for-change-set",
989
+ args: [
990
+ "aws",
991
+ "cloudformation",
992
+ "wait",
993
+ "change-set-create-complete",
994
+ ...common,
995
+ "--change-set-name",
996
+ name
997
+ ]
998
+ },
999
+ {
1000
+ name: "review-change-set",
1001
+ args: [
1002
+ "aws",
1003
+ "cloudformation",
1004
+ "describe-change-set",
1005
+ ...common,
1006
+ "--change-set-name",
1007
+ name,
1008
+ "--no-cli-pager"
1009
+ ]
1010
+ },
1011
+ {
1012
+ name: "execute-reviewed-change-set",
1013
+ args: ["aws", "cloudformation", "execute-change-set", ...common, "--change-set-name", name]
1014
+ },
1015
+ {
1016
+ name: "wait-for-stack",
1017
+ args: ["aws", "cloudformation", "wait", "stack-create-complete", ...common]
1018
+ },
1019
+ {
1020
+ name: "capture-stack",
1021
+ captureFile: `${input.kind}-deployed-stack.json`,
1022
+ args: ["aws", "cloudformation", "describe-stacks", ...common, "--no-cli-pager"]
1023
+ }
1024
+ ];
1025
+ }
1026
+ var githubOidcProvider = {
1027
+ name: "github-oidc",
1028
+ async prerequisite(config, runner) {
1029
+ const repository = config.githubOidc?.repository;
1030
+ if (!repository)
1031
+ return {
1032
+ status: "unverified",
1033
+ blocking: false,
1034
+ detail: "GitHub repository is absent from the manifest."
1035
+ };
1036
+ if (!runner)
1037
+ return {
1038
+ status: "unverified",
1039
+ blocking: false,
1040
+ detail: "GitHub OIDC subject setting was not queried."
1041
+ };
1042
+ try {
1043
+ const response = JSON.parse(
1044
+ await runner.execute("gh", ["api", `repos/${repository}/actions/oidc/customization/sub`])
1045
+ );
1046
+ const keys = response.include_claim_keys;
1047
+ if (response.use_default === false && Array.isArray(keys) && githubSubjectKeys.every((key) => keys.includes(key)))
1048
+ return {
1049
+ status: "ready",
1050
+ blocking: false,
1051
+ detail: "GitHub emits the immutable owner/repository-ID OIDC subject required by this adapter."
1052
+ };
1053
+ return {
1054
+ status: "immutable-subject-required",
1055
+ blocking: true,
1056
+ detail: "Coordinate migration: inventory and temporarily make existing name-based trust policies compatible, enable immutable GitHub OIDC subjects as a separate GitHub-admin action, verify existing deployments, then retire legacy trust."
1057
+ };
1058
+ } catch {
1059
+ return {
1060
+ status: "unverified",
1061
+ blocking: false,
1062
+ detail: "GitHub OIDC subject setting could not be verified (gh CLI, authentication, permission, or network unavailable)."
1063
+ };
1064
+ }
1065
+ }
1066
+ };
1067
+ async function contracts() {
1068
+ return {
1069
+ core: bootstrapContractVersion(await bootstrapTemplate("core")),
1070
+ githubOidc: bootstrapContractVersion(await bootstrapTemplate("github-oidc"))
1071
+ };
1072
+ }
1073
+ async function bootstrapHandoff(input) {
1074
+ const expectedContracts = await contracts();
1075
+ const coreCapturePath = input.coreStackPath ?? "core-deployed-stack.json";
1076
+ const core = input.coreStack === void 0 ? void 0 : deployedBootstrapStack(input.coreStack, expectedContracts.core);
1077
+ const oidc = input.githubOidcStack === void 0 ? void 0 : deployedBootstrapStack(input.githubOidcStack, expectedContracts.githubOidc);
1078
+ if (!core) {
1079
+ requiredBootstrapParameters("core", {
1080
+ ArtifactBucketName: input.config.core?.artifactBucketName,
1081
+ ApplicationStackName: input.config.core?.applicationStackName,
1082
+ DashboardFunctionName: input.config.core?.dashboardFunctionName
1083
+ });
1084
+ const templatePath = await bootstrapTemplatePath("core");
1085
+ return {
1086
+ phase: "core",
1087
+ expectedContracts,
1088
+ templatePath,
1089
+ parameterPath: "core-bootstrap.json",
1090
+ requiredCapturedFiles: [],
1091
+ reviewCheckpoints: [
1092
+ "Review every IAM action and CAPABILITY_NAMED_IAM acknowledgement.",
1093
+ "Do not approve retained bucket or role replacements."
1094
+ ],
1095
+ commands: changeSetCommands({
1096
+ kind: "core",
1097
+ config: input.config,
1098
+ configPath: input.configPath,
1099
+ templatePath,
1100
+ parameterPath: "core-bootstrap.json"
1101
+ })
1102
+ };
1103
+ }
1104
+ coreBootstrapOutputs(core);
1105
+ if (!oidc) {
1106
+ requiredBootstrapParameters("github-oidc", {
1107
+ GitHubOidcProviderArn: input.config.githubOidc?.providerArn,
1108
+ GitHubRepository: input.config.githubOidc?.repository,
1109
+ GitHubOwnerId: input.config.githubOidc?.ownerId,
1110
+ GitHubRepositoryId: input.config.githubOidc?.repositoryId,
1111
+ GitHubEnvironment: input.config.githubOidc?.environment,
1112
+ ...coreBootstrapOutputs(core)
1113
+ });
1114
+ const templatePath = await bootstrapTemplatePath("github-oidc");
1115
+ return {
1116
+ phase: "github-oidc",
1117
+ expectedContracts,
1118
+ templatePath,
1119
+ parameterPath: "github-oidc-bootstrap.json",
1120
+ requiredCapturedFiles: [coreCapturePath],
1121
+ reviewCheckpoints: [
1122
+ "Review the exact immutable GitHub OIDC subject, audience, bucket lambda/* prefix, application stack, and execution role.",
1123
+ "Do not execute until the GitHub Environment prerequisite is ready."
1124
+ ],
1125
+ commands: changeSetCommands({
1126
+ kind: "github-oidc",
1127
+ config: input.config,
1128
+ configPath: input.configPath,
1129
+ templatePath,
1130
+ parameterPath: "github-oidc-bootstrap.json",
1131
+ coreCapturePath
1132
+ }),
1133
+ prerequisite: await (input.provider ?? githubOidcProvider).prerequisite(
1134
+ input.config,
1135
+ input.runner
1136
+ )
1137
+ };
1138
+ }
1139
+ const prerequisite = await (input.provider ?? githubOidcProvider).prerequisite(
1140
+ input.config,
1141
+ input.runner
1142
+ );
1143
+ if (prerequisite.status !== "ready")
1144
+ return {
1145
+ phase: "github-environment",
1146
+ expectedContracts,
1147
+ requiredCapturedFiles: [coreCapturePath, "github-oidc-deployed-stack.json"],
1148
+ reviewCheckpoints: [
1149
+ "A GitHub administrator must complete and verify the immutable-subject migration before deployments."
1150
+ ],
1151
+ commands: [],
1152
+ prerequisite
1153
+ };
1154
+ return {
1155
+ phase: "application-gateway",
1156
+ expectedContracts,
1157
+ requiredCapturedFiles: [coreCapturePath, "github-oidc-deployed-stack.json"],
1158
+ reviewCheckpoints: [
1159
+ "Consumer owns gateway selection, private Lambda permission, authentication, and smoke tests."
1160
+ ],
1161
+ commands: [],
1162
+ prerequisite
1163
+ };
1164
+ }
1165
+ function assertEqual(actual, expected, label) {
1166
+ if (actual !== expected)
1167
+ throw new Error(
1168
+ `${label} mismatch (captured: ${actual ?? "missing"}; manifest: ${expected ?? "missing"})`
1169
+ );
1170
+ }
1171
+ async function verifyBootstrap(input) {
1172
+ const expected = await contracts();
1173
+ const core = deployedBootstrapStack(input.coreStack, expected.core);
1174
+ const oidc = deployedBootstrapStack(input.githubOidcStack, expected.githubOidc);
1175
+ for (const [kind, capture] of [
1176
+ ["Core", capturedStackIdentity(input.coreStack)],
1177
+ ["GitHub OIDC", capturedStackIdentity(input.githubOidcStack)]
1178
+ ]) {
1179
+ const expectedName = kind === "Core" ? input.config.core?.stackName : input.config.githubOidc?.stackName;
1180
+ assertEqual(capture.StackName, expectedName, `${kind} stack name`);
1181
+ const capturedRegion = capture.StackId?.match(/^arn:[^:]+:cloudformation:([^:]+):/)?.[1];
1182
+ assertEqual(capturedRegion, input.config.region, `${kind} stack Region`);
1183
+ }
1184
+ const coreOutputs = coreBootstrapOutputs(core);
1185
+ const coreParameters = parameterValues(core);
1186
+ assertEqual(
1187
+ coreParameters.ArtifactBucketName,
1188
+ input.config.core?.artifactBucketName,
1189
+ "Artifact bucket"
1190
+ );
1191
+ assertEqual(
1192
+ coreParameters.ApplicationStackName,
1193
+ input.config.core?.applicationStackName,
1194
+ "Application stack"
1195
+ );
1196
+ assertEqual(
1197
+ coreParameters.DashboardFunctionName,
1198
+ input.config.core?.dashboardFunctionName,
1199
+ "Dashboard function"
1200
+ );
1201
+ assertEqual(
1202
+ coreOutputs.ArtifactBucketName,
1203
+ input.config.core?.artifactBucketName,
1204
+ "Core output artifact bucket"
1205
+ );
1206
+ assertEqual(
1207
+ coreOutputs.ApplicationStackName,
1208
+ input.config.core?.applicationStackName,
1209
+ "Core output application stack"
1210
+ );
1211
+ const oidcParameters = parameterValues(oidc);
1212
+ const github = input.config.githubOidc;
1213
+ const executionRole = required(
1214
+ coreOutputs.CloudFormationExecutionRoleArn,
1215
+ "core CloudFormationExecutionRoleArn output"
1216
+ );
1217
+ for (const [key, value] of Object.entries({
1218
+ GitHubOidcProviderArn: github?.providerArn,
1219
+ GitHubRepository: github?.repository,
1220
+ GitHubOwnerId: github?.ownerId,
1221
+ GitHubRepositoryId: github?.repositoryId,
1222
+ GitHubEnvironment: github?.environment,
1223
+ ApplicationStackName: coreOutputs.ApplicationStackName,
1224
+ ArtifactBucketName: coreOutputs.ArtifactBucketName,
1225
+ CloudFormationExecutionRoleArn: executionRole
1226
+ }))
1227
+ assertEqual(oidcParameters[key], value, `OIDC ${key}`);
1228
+ if (!/^\d+$/.test(github?.ownerId ?? "") || !/^\d+$/.test(github?.repositoryId ?? ""))
1229
+ throw new Error("GitHub immutable owner and repository IDs must be numeric");
1230
+ const [owner, repository, ...extra] = (github?.repository ?? "").split("/");
1231
+ if (!owner || !repository || extra.length)
1232
+ throw new Error("GitHub repository must be owner/repository");
1233
+ const deployRole = outputValues(oidc).GitHubDeployRoleArn;
1234
+ if (!deployRole) throw new Error("GitHub OIDC stack JSON is missing outputs: GitHubDeployRoleArn");
1235
+ const providerArn = github?.providerArn ?? "";
1236
+ const account = providerArn.match(/^arn:[^:]+:iam::(\d+):oidc-provider\//)?.[1];
1237
+ const deployAccount = deployRole.match(/^arn:[^:]+:iam::(\d+):role\//)?.[1];
1238
+ const executionAccount = executionRole.match(/^arn:[^:]+:iam::(\d+):role\//)?.[1];
1239
+ if (!account || account !== deployAccount || account !== executionAccount)
1240
+ throw new Error("Provider and reviewed role ARNs must belong to the same AWS account");
1241
+ return {
1242
+ verified: true,
1243
+ immutableSubject: `repo:${owner}@${github?.ownerId}/${repository}@${github?.repositoryId}:environment:${github?.environment}`,
1244
+ environmentVariables: {
1245
+ AWS_DEPLOY_ROLE_ARN: deployRole,
1246
+ AWS_CLOUDFORMATION_EXECUTION_ROLE_ARN: executionRole
1247
+ },
1248
+ reviewedArns: {
1249
+ githubDeployRoleArn: deployRole,
1250
+ cloudFormationExecutionRoleArn: executionRole
1251
+ },
1252
+ githubEnvironmentInstructions: [
1253
+ [
1254
+ "gh",
1255
+ "variable",
1256
+ "set",
1257
+ "AWS_DEPLOY_ROLE_ARN",
1258
+ "--repo",
1259
+ `${owner}/${repository}`,
1260
+ "--env",
1261
+ github?.environment ?? "",
1262
+ "--body",
1263
+ deployRole
1264
+ ],
1265
+ [
1266
+ "gh",
1267
+ "variable",
1268
+ "set",
1269
+ "AWS_CLOUDFORMATION_EXECUTION_ROLE_ARN",
1270
+ "--repo",
1271
+ `${owner}/${repository}`,
1272
+ "--env",
1273
+ github?.environment ?? "",
1274
+ "--body",
1275
+ executionRole
1276
+ ]
1277
+ ]
1278
+ };
1279
+ }
1280
+
904
1281
  // packages/aws/src/index.ts
905
1282
  var run = promisify(execFile);
906
1283
  function deploymentTemplate(template, values) {
@@ -1041,14 +1418,17 @@ async function cloudFormationTemplate() {
1041
1418
  }
1042
1419
  export {
1043
1420
  bootstrapContractVersion,
1421
+ bootstrapHandoff,
1044
1422
  bootstrapTemplate,
1045
1423
  bootstrapTemplatePath,
1046
1424
  cloudFormationTemplate,
1047
1425
  coreBootstrapOutputs,
1048
1426
  deployLambda,
1049
1427
  deployedBootstrapStack,
1428
+ githubOidcProvider,
1050
1429
  mergeBootstrapParameters,
1051
1430
  packageLambda,
1052
1431
  publishClientAssets,
1053
- requiredBootstrapParameters
1432
+ requiredBootstrapParameters,
1433
+ verifyBootstrap
1054
1434
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@continuous-excellence/ze-great-dashboard-aws",
3
- "version": "0.1.32",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "AWS Lambda and CloudFormation adapter for Ze Great Dashboard.",
6
6
  "keywords": [