@anthusai/papyrus 1.18.0-next.1 → 1.18.0-next.2
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/bin/papyrus-infra.mjs +41 -3
- package/package.json +1 -1
- package/routes.manifest.json +1 -1
- package/src/edge-stack-deploy.mjs +104 -0
package/bin/papyrus-infra.mjs
CHANGED
|
@@ -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") {
|
|
@@ -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")));
|
package/package.json
CHANGED
package/routes.manifest.json
CHANGED
|
@@ -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
|
+
}
|