@anthusai/papyrus 1.18.0 → 1.19.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.
@@ -12,13 +12,12 @@ import fs from "node:fs";
12
12
  import { spawnSync } from "node:child_process";
13
13
  import path from "node:path";
14
14
  import { createRequire } from "node:module";
15
- import { buildSpecFor } from "../infra/build-specs.js";
16
- import { parseSiteConfig, resolveStackName } from "../infra/site-config.js";
17
15
 
18
16
  const USAGE = [
19
17
  "Usage: papyrus-infra synth --site infra/site.json [--out cdk.out] [--account A] [--region R]",
20
18
  " papyrus-infra synth --account-stack github-oidc [--out cdk.out] [--account A] [--region R]",
21
19
  " papyrus-infra print-buildspec --site infra/site.json --app cms-production|cms-staging|reader",
20
+ " papyrus-infra deploy-edge --stack NAME --template FILE [--adoption-template FILE --adopt-resources FILE] [--parameter KEY=VALUE]... [--delete-function NAME] [--only-branch main] [--only-app-id ID] [--region R]",
22
21
  " papyrus-infra deploy --site infra/site.json [--only-branch main] [--only-app-id ID] [--out cdk.out] [--account A] [--region R]",
23
22
  ].join("\n");
24
23
 
@@ -28,11 +27,48 @@ const opt = (name, fallback) => {
28
27
  return i >= 0 ? args[i + 1] : fallback;
29
28
  };
30
29
  const command = args[0];
31
- if (command !== "synth" && command !== "print-buildspec" && command !== "deploy") {
30
+ if (command !== "synth" && command !== "print-buildspec" && command !== "deploy" && command !== "deploy-edge") {
32
31
  console.error(USAGE);
33
32
  process.exit(command ? 1 : 0);
34
33
  }
35
34
 
35
+ if (command === "deploy-edge") {
36
+ const buildAppId = process.env.AWS_APP_ID;
37
+ const buildBranch = process.env.AWS_BRANCH;
38
+ const owningAppId = opt("only-app-id");
39
+ if (!buildAppId || !buildBranch) {
40
+ console.log("papyrus-infra deploy-edge: not running inside an Amplify build, skipping");
41
+ process.exit(0);
42
+ }
43
+ if (buildBranch !== opt("only-branch", "main") || (owningAppId && buildAppId !== owningAppId)) {
44
+ console.log(`papyrus-infra deploy-edge: ${buildAppId}/${buildBranch} does not own the stack, skipping`);
45
+ process.exit(0);
46
+ }
47
+ const parameterValues = {};
48
+ args.forEach((argument, index) => {
49
+ if (argument !== "--parameter") return;
50
+ const [name, ...valueParts] = args[index + 1].split("=");
51
+ parameterValues[name] = valueParts.join("=");
52
+ });
53
+ const adoptResourcesFile = opt("adopt-resources");
54
+ try {
55
+ const { deployEdgeStack } = await import("../src/edge-stack-deploy.mjs");
56
+ deployEdgeStack({
57
+ stackName: opt("stack"),
58
+ region: opt("region", process.env.AWS_REGION ?? "us-east-1"),
59
+ templateFile: opt("template"),
60
+ parameterValues,
61
+ adoptionTemplateFile: opt("adoption-template"),
62
+ adoptionResources: adoptResourcesFile ? JSON.parse(fs.readFileSync(adoptResourcesFile, "utf8")) : [],
63
+ orphanedFunctionName: opt("delete-function"),
64
+ });
65
+ } catch (error) {
66
+ console.error(`papyrus-infra deploy-edge: ${error instanceof Error ? error.message : error}`);
67
+ process.exit(1);
68
+ }
69
+ process.exit(0);
70
+ }
71
+
36
72
  const accountStack = opt("account-stack");
37
73
  if (accountStack !== undefined) {
38
74
  if (command !== "synth" || accountStack !== "github-oidc") {
@@ -49,7 +85,7 @@ if (accountStack !== undefined) {
49
85
  const { App } = createRequire(path.join(process.cwd(), "noop.js"))("aws-cdk-lib");
50
86
  const app = new App({ outdir: path.resolve(opt("out", "cdk.out")) });
51
87
  new GithubOidcProviderStack(app, "GithubOidcProviderStack", {
52
- env: { account: opt("account", "335163751677"), region: opt("region", "us-east-1") },
88
+ env: { account: opt("account", process.env.CDK_DEFAULT_ACCOUNT ?? "335163751677"), region: opt("region", process.env.CDK_DEFAULT_REGION ?? "us-east-1") },
53
89
  stackName: "github-oidc-provider",
54
90
  });
55
91
  app.synth();
@@ -57,6 +93,8 @@ if (accountStack !== undefined) {
57
93
  process.exit(0);
58
94
  }
59
95
 
96
+ const { buildSpecFor } = await import("../infra/build-specs.js");
97
+ const { parseSiteConfig, resolveStackName } = await import("../infra/site-config.js");
60
98
  let config;
61
99
  try {
62
100
  config = parseSiteConfig(JSON.parse(fs.readFileSync(path.resolve(opt("site", "infra/site.json")), "utf8")));
@@ -94,6 +132,8 @@ if (command === "deploy") {
94
132
  process.exit(0);
95
133
  }
96
134
  const outputDirectory = path.resolve(opt("out", process.env.CDK_OUTDIR ?? "cdk.out"));
135
+ const explicitAccount = opt("account");
136
+ const explicitRegion = opt("region");
97
137
  const synthCommand = [
98
138
  process.execPath,
99
139
  path.resolve(process.argv[1]),
@@ -102,14 +142,12 @@ if (command === "deploy") {
102
142
  path.resolve(opt("site", "infra/site.json")),
103
143
  "--out",
104
144
  outputDirectory,
105
- "--account",
106
- opt("account", process.env.CDK_DEFAULT_ACCOUNT ?? "335163751677"),
107
- "--region",
108
- opt("region", process.env.CDK_DEFAULT_REGION ?? "us-east-1"),
145
+ ...(explicitAccount ? ["--account", explicitAccount] : []),
146
+ ...(explicitRegion ? ["--region", explicitRegion] : []),
109
147
  ].map((part) => JSON.stringify(part)).join(" ");
110
148
  const deployment = spawnSync(
111
149
  "npx",
112
- ["--yes", "aws-cdk@2", "deploy", "--app", synthCommand, "--require-approval", "never", `AmplifyAppShell-${config.siteId}`],
150
+ ["--yes", "aws-cdk@2", "deploy", "--app", synthCommand, "--require-approval", "never", "--output", outputDirectory, `AmplifyAppShell-${config.siteId}`],
113
151
  { stdio: "inherit" },
114
152
  );
115
153
  process.exit(deployment.status ?? 1);
@@ -126,7 +164,7 @@ try {
126
164
  const { App } = createRequire(path.join(process.cwd(), "noop.js"))("aws-cdk-lib");
127
165
  const app = new App({ outdir: path.resolve(opt("out", "cdk.out")) });
128
166
  new AmplifyAppShellStack(app, `AmplifyAppShell-${config.siteId}`, config, {
129
- env: { account: opt("account", "335163751677"), region: opt("region", "us-east-1") },
167
+ env: { account: opt("account", process.env.CDK_DEFAULT_ACCOUNT ?? "335163751677"), region: opt("region", process.env.CDK_DEFAULT_REGION ?? "us-east-1") },
130
168
  stackName: resolveStackName(config),
131
169
  });
132
170
  app.synth();
package/package.json CHANGED
@@ -379,7 +379,7 @@
379
379
  "optional": true
380
380
  }
381
381
  },
382
- "version": "1.18.0",
382
+ "version": "1.19.0",
383
383
  "dependencies": {
384
384
  "@aws-amplify/adapter-nextjs": "^1.7.3",
385
385
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.18.0",
2
+ "version": "1.19.0",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",
@@ -0,0 +1,104 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+
5
+ const NO_CHANGES_REASONS = ["didn't contain changes", "No updates are to be performed"];
6
+ const FUNCTION_STILL_ATTACHED_PAUSE_SECONDS = 20;
7
+ const FUNCTION_DELETE_ATTEMPTS = 6;
8
+
9
+ function runAws(awsArguments, { allowFailure = false } = {}) {
10
+ const completed = spawnSync("aws", awsArguments, { encoding: "utf8" });
11
+ if (completed.status !== 0 && !allowFailure) {
12
+ throw new Error(`aws ${awsArguments.slice(0, 2).join(" ")} failed: ${completed.stderr || completed.stdout}`);
13
+ }
14
+ return completed;
15
+ }
16
+
17
+ function runAwsJson(awsArguments) {
18
+ const completed = runAws([...awsArguments, "--output", "json"]);
19
+ return completed.stdout.trim() === "" ? {} : JSON.parse(completed.stdout);
20
+ }
21
+
22
+ function logicalIdsInStack(stackName, region) {
23
+ const completed = runAws(["cloudformation", "list-stack-resources", "--stack-name", stackName, "--region", region, "--output", "json"], { allowFailure: true });
24
+ if (completed.status !== 0) return new Set();
25
+ return new Set(JSON.parse(completed.stdout).StackResourceSummaries.map((summary) => summary.LogicalResourceId));
26
+ }
27
+
28
+ function templateParameterFlags(parameterValues, templateFile) {
29
+ const templateText = fs.readFileSync(templateFile, "utf8");
30
+ const parametersSection = templateText.split("\nResources:")[0].split("\nParameters:\n")[1] ?? "";
31
+ const parameterNames = [...parametersSection.matchAll(/^ ([A-Za-z0-9]+):\s*$/gm)].map((match) => match[1]);
32
+ return parameterNames.map((name) =>
33
+ name in parameterValues
34
+ ? `ParameterKey=${name},ParameterValue=${parameterValues[name]}`
35
+ : `ParameterKey=${name},UsePreviousValue=true`,
36
+ );
37
+ }
38
+
39
+ export function applyChangeSet({ stackName, region, templateFile, parameterValues, adoptions }) {
40
+ const changeSetType = adoptions ? "IMPORT" : "UPDATE";
41
+ const changeSetName = `papyrus-infra-${Date.now()}`;
42
+ const createArguments = [
43
+ "cloudformation", "create-change-set",
44
+ "--stack-name", stackName,
45
+ "--change-set-name", changeSetName,
46
+ "--change-set-type", changeSetType,
47
+ "--template-body", `file://${path.resolve(templateFile)}`,
48
+ "--capabilities", "CAPABILITY_IAM",
49
+ "--region", region,
50
+ "--parameters", ...templateParameterFlags(parameterValues, templateFile),
51
+ ];
52
+ if (adoptions) createArguments.push("--resources-to-import", JSON.stringify(adoptions));
53
+ runAws(createArguments);
54
+ const waiting = runAws(["cloudformation", "wait", "change-set-create-complete", "--stack-name", stackName, "--change-set-name", changeSetName, "--region", region], { allowFailure: true });
55
+ const description = runAwsJson(["cloudformation", "describe-change-set", "--stack-name", stackName, "--change-set-name", changeSetName, "--region", region]);
56
+ if (waiting.status !== 0) {
57
+ const reason = description.StatusReason ?? "";
58
+ runAws(["cloudformation", "delete-change-set", "--stack-name", stackName, "--change-set-name", changeSetName, "--region", region], { allowFailure: true });
59
+ if (NO_CHANGES_REASONS.some((phrase) => reason.includes(phrase))) {
60
+ console.log(`papyrus-infra deploy-edge: ${changeSetType} for ${stackName} has no changes`);
61
+ return false;
62
+ }
63
+ throw new Error(`change set ${changeSetName} failed: ${reason}`);
64
+ }
65
+ console.log(`papyrus-infra deploy-edge: ${changeSetType} ${changeSetName}: ${(description.Changes ?? []).map((change) => `${change.ResourceChange.Action} ${change.ResourceChange.LogicalResourceId}`).join(", ")}`);
66
+ runAws(["cloudformation", "execute-change-set", "--stack-name", stackName, "--change-set-name", changeSetName, "--region", region]);
67
+ const waiter = adoptions ? "stack-import-complete" : "stack-update-complete";
68
+ runAws(["cloudformation", "wait", waiter, "--stack-name", stackName, "--region", region]);
69
+ return true;
70
+ }
71
+
72
+ function sleepSeconds(seconds) {
73
+ spawnSync("sleep", [String(seconds)]);
74
+ }
75
+
76
+ export function deleteOrphanedFunction({ functionName }) {
77
+ const description = runAws(["cloudfront", "describe-function", "--name", functionName, "--output", "json"], { allowFailure: true });
78
+ if (description.status !== 0) {
79
+ console.log(`papyrus-infra deploy-edge: function ${functionName} is already gone`);
80
+ return;
81
+ }
82
+ const entityTag = JSON.parse(description.stdout).ETag;
83
+ for (let attempt = 1; attempt <= FUNCTION_DELETE_ATTEMPTS; attempt += 1) {
84
+ const deletion = runAws(["cloudfront", "delete-function", "--name", functionName, "--if-match", entityTag], { allowFailure: true });
85
+ if (deletion.status === 0) {
86
+ console.log(`papyrus-infra deploy-edge: deleted function ${functionName}`);
87
+ return;
88
+ }
89
+ if (attempt < FUNCTION_DELETE_ATTEMPTS) sleepSeconds(FUNCTION_STILL_ATTACHED_PAUSE_SECONDS);
90
+ else console.warn(`papyrus-infra deploy-edge: could not delete ${functionName} yet, the next build retries: ${deletion.stderr}`);
91
+ }
92
+ }
93
+
94
+ export function deployEdgeStack(options) {
95
+ const { stackName, region, templateFile, parameterValues, adoptionTemplateFile, adoptionResources, orphanedFunctionName } = options;
96
+ const logicalIdsPresent = logicalIdsInStack(stackName, region);
97
+ const pendingAdoptions = (adoptionResources ?? []).filter((resource) => !logicalIdsPresent.has(resource.LogicalResourceId));
98
+ if (pendingAdoptions.length > 0) {
99
+ if (!adoptionTemplateFile) throw new Error("resources need adopting but no --adoption-template was given");
100
+ applyChangeSet({ stackName, region, templateFile: adoptionTemplateFile, parameterValues, adoptions: pendingAdoptions });
101
+ }
102
+ applyChangeSet({ stackName, region, templateFile, parameterValues, adoptions: undefined });
103
+ if (orphanedFunctionName) deleteOrphanedFunction({ functionName: orphanedFunctionName });
104
+ }