create-projx 1.7.5 → 1.7.7
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/{baseline-O25CAKIL.js → baseline-DT3CWKOO.js} +2 -2
- package/dist/{chunk-B7PW6QO7.js → chunk-N66CVDEV.js} +37 -0
- package/dist/{chunk-RFHLWYJ4.js → chunk-OH6QQNPO.js} +56 -3
- package/dist/index.js +33 -6
- package/dist/{utils-X2P47QNN.js → utils-QQUKUZKQ.js} +3 -1
- package/package.json +1 -1
- package/src/templates/codeowners.ejs +9 -0
- package/src/templates/docker-compose.yml.ejs +3 -3
- package/src/templates/rollback.sh.ejs +69 -0
- package/src/templates/runbook.md.ejs +64 -0
|
@@ -26,6 +26,42 @@ var COMPONENTS = [
|
|
|
26
26
|
"infra",
|
|
27
27
|
"admin-panel"
|
|
28
28
|
];
|
|
29
|
+
function editDistance(a, b) {
|
|
30
|
+
const rows = a.length + 1;
|
|
31
|
+
const cols = b.length + 1;
|
|
32
|
+
const dist = Array.from(
|
|
33
|
+
{ length: rows },
|
|
34
|
+
() => new Array(cols).fill(0)
|
|
35
|
+
);
|
|
36
|
+
for (let i = 0; i < rows; i++) dist[i][0] = i;
|
|
37
|
+
for (let j = 0; j < cols; j++) dist[0][j] = j;
|
|
38
|
+
for (let i = 1; i < rows; i++) {
|
|
39
|
+
for (let j = 1; j < cols; j++) {
|
|
40
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
41
|
+
dist[i][j] = Math.min(
|
|
42
|
+
dist[i - 1][j] + 1,
|
|
43
|
+
dist[i][j - 1] + 1,
|
|
44
|
+
dist[i - 1][j - 1] + cost
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return dist[a.length][b.length];
|
|
49
|
+
}
|
|
50
|
+
function suggestComponent(input) {
|
|
51
|
+
const needle = input.toLowerCase();
|
|
52
|
+
let best = null;
|
|
53
|
+
let bestDistance = Infinity;
|
|
54
|
+
for (const c of COMPONENTS) {
|
|
55
|
+
const d = editDistance(needle, c);
|
|
56
|
+
if (d < bestDistance) {
|
|
57
|
+
bestDistance = d;
|
|
58
|
+
best = c;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (best === null) return null;
|
|
62
|
+
const maxAllowed = Math.min(3, Math.floor(best.length / 2));
|
|
63
|
+
return bestDistance <= maxAllowed ? best : null;
|
|
64
|
+
}
|
|
29
65
|
var PACKAGE_MANAGERS = ["npm", "pnpm", "yarn", "bun"];
|
|
30
66
|
var ORM_PROVIDERS = [
|
|
31
67
|
"prisma",
|
|
@@ -564,6 +600,7 @@ export {
|
|
|
564
600
|
REPO,
|
|
565
601
|
REPO_URL,
|
|
566
602
|
COMPONENTS,
|
|
603
|
+
suggestComponent,
|
|
567
604
|
PACKAGE_MANAGERS,
|
|
568
605
|
ORM_PROVIDERS,
|
|
569
606
|
pmCommands,
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
toSnake,
|
|
14
14
|
upsertComponentMarker,
|
|
15
15
|
writeProjxConfig
|
|
16
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-N66CVDEV.js";
|
|
17
17
|
|
|
18
18
|
// src/baseline.ts
|
|
19
19
|
import { existsSync, writeFileSync, unlinkSync } from "fs";
|
|
@@ -90,6 +90,39 @@ async function generateCiYml(vars) {
|
|
|
90
90
|
async function generateReadme(vars) {
|
|
91
91
|
return renderShared("README.md.ejs", withInstances(vars));
|
|
92
92
|
}
|
|
93
|
+
function infraTemplateVars(vars) {
|
|
94
|
+
const projectName = vars.projectName;
|
|
95
|
+
const productionDomain = vars.productionDomain ?? `${projectName}.example.com`;
|
|
96
|
+
const awsRegion = vars.awsRegion ?? "us-east-1";
|
|
97
|
+
const githubOwner = vars.githubOwner ?? "TODO";
|
|
98
|
+
const ecrRepos = vars.ecrRepos ?? [
|
|
99
|
+
`${projectName}/backend`,
|
|
100
|
+
`${projectName}/frontend`
|
|
101
|
+
];
|
|
102
|
+
const hasBackendMigrations = vars.components.some(
|
|
103
|
+
(c) => c === "fastify" || c === "express"
|
|
104
|
+
);
|
|
105
|
+
const hasAdminPanelMigrations = vars.components.includes("admin-panel");
|
|
106
|
+
return {
|
|
107
|
+
...vars,
|
|
108
|
+
productionDomain,
|
|
109
|
+
awsRegion,
|
|
110
|
+
githubOwner,
|
|
111
|
+
ecrRepos,
|
|
112
|
+
hasBackendMigrations,
|
|
113
|
+
hasAdminPanelMigrations,
|
|
114
|
+
displayName: projectName.charAt(0).toUpperCase() + projectName.slice(1)
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
async function generateRollback(vars) {
|
|
118
|
+
return renderShared("rollback.sh.ejs", infraTemplateVars(vars));
|
|
119
|
+
}
|
|
120
|
+
async function generateCodeowners(vars) {
|
|
121
|
+
return renderShared("codeowners.ejs", infraTemplateVars(vars));
|
|
122
|
+
}
|
|
123
|
+
async function generateRunbook(vars) {
|
|
124
|
+
return renderShared("runbook.md.ejs", infraTemplateVars(vars));
|
|
125
|
+
}
|
|
93
126
|
function generateVscodeSettings(vars) {
|
|
94
127
|
const settings = {};
|
|
95
128
|
if (vars.components.includes("fastapi")) {
|
|
@@ -128,7 +161,7 @@ function generateVscodeSettings(vars) {
|
|
|
128
161
|
// src/baseline.ts
|
|
129
162
|
var BASELINE_REF = "refs/projx/baseline";
|
|
130
163
|
async function migrateComponentMarkers(cwd, components, componentPaths, applyDefaults) {
|
|
131
|
-
const { readComponentMarker: readComponentMarker2, writeComponentMarker } = await import("./utils-
|
|
164
|
+
const { readComponentMarker: readComponentMarker2, writeComponentMarker } = await import("./utils-QQUKUZKQ.js");
|
|
132
165
|
for (const component of components) {
|
|
133
166
|
const dir = componentPaths[component];
|
|
134
167
|
const markerDir = join2(cwd, dir);
|
|
@@ -457,6 +490,26 @@ async function writeTemplateToDir(dest, repoDir, components, componentPaths, var
|
|
|
457
490
|
);
|
|
458
491
|
await chmod(join2(dest, "scripts/setup.sh"), 493);
|
|
459
492
|
}
|
|
493
|
+
if (vars.components.includes("infra")) {
|
|
494
|
+
if (shouldWrite("scripts/rollback.sh")) {
|
|
495
|
+
await mkdir(join2(dest, "scripts"), { recursive: true });
|
|
496
|
+
await writeFile(
|
|
497
|
+
join2(dest, "scripts/rollback.sh"),
|
|
498
|
+
await generateRollback(vars)
|
|
499
|
+
);
|
|
500
|
+
await chmod(join2(dest, "scripts/rollback.sh"), 493);
|
|
501
|
+
}
|
|
502
|
+
if (shouldWrite(".github/CODEOWNERS")) {
|
|
503
|
+
await mkdir(join2(dest, ".github"), { recursive: true });
|
|
504
|
+
await writeFile(
|
|
505
|
+
join2(dest, ".github/CODEOWNERS"),
|
|
506
|
+
await generateCodeowners(vars)
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
if (shouldWrite("RUNBOOK.md")) {
|
|
510
|
+
await writeFile(join2(dest, "RUNBOOK.md"), await generateRunbook(vars));
|
|
511
|
+
}
|
|
512
|
+
}
|
|
460
513
|
await copyStaticFiles(repoDir, dest);
|
|
461
514
|
if (shouldWrite(".vscode/settings.json")) {
|
|
462
515
|
await mkdir(join2(dest, ".vscode"), { recursive: true });
|
|
@@ -613,7 +666,7 @@ function ormNodeDockerfileSource(manifest, vars) {
|
|
|
613
666
|
ENV PATH="$PNPM_HOME:$PATH"
|
|
614
667
|
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
|
|
615
668
|
` : pmName === "yarn" ? "RUN corepack enable\n" : pmName === "bun" ? "RUN npm install -g bun\n" : "";
|
|
616
|
-
const buildCopy = extraConfigCopy ? `COPY package.json tsconfig.json ${extraConfigCopy} ./` : `COPY package.json tsconfig.json ./`;
|
|
669
|
+
const buildCopy = extraConfigCopy ? `COPY package.json tsconfig.json tsconfig.build.json ${extraConfigCopy} ./` : `COPY package.json tsconfig.json tsconfig.build.json ./`;
|
|
617
670
|
const migrateStage = migrateCmd ? `FROM build AS migrate
|
|
618
671
|
CMD ["sh", "-c", "${exec} ${migrateCmd}"]
|
|
619
672
|
|
package/dist/index.js
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
matchesSkip,
|
|
10
10
|
saveBaselineRef,
|
|
11
11
|
writeTemplateToDir
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-OH6QQNPO.js";
|
|
13
13
|
import {
|
|
14
14
|
COMPONENTS,
|
|
15
15
|
COMPONENT_MARKER,
|
|
@@ -32,11 +32,12 @@ import {
|
|
|
32
32
|
readFileOrNull,
|
|
33
33
|
readProjxConfig,
|
|
34
34
|
render,
|
|
35
|
+
suggestComponent,
|
|
35
36
|
toKebab,
|
|
36
37
|
toSnake,
|
|
37
38
|
writeComponentMarker,
|
|
38
39
|
writeProjxConfig
|
|
39
|
-
} from "./chunk-
|
|
40
|
+
} from "./chunk-N66CVDEV.js";
|
|
40
41
|
|
|
41
42
|
// src/index.ts
|
|
42
43
|
import { existsSync as existsSync11 } from "fs";
|
|
@@ -902,7 +903,7 @@ function hasUncommittedChanges(cwd) {
|
|
|
902
903
|
async function findPinnedFilesWithUpdates(cwd, repoDir, components, componentPaths, vars, version, componentSkips, rootSkip) {
|
|
903
904
|
const { mkdtemp: mkdtemp2, rm: rm2, readFile: readFile8 } = await import("fs/promises");
|
|
904
905
|
const { tmpdir: tmpdir2 } = await import("os");
|
|
905
|
-
const { writeTemplateToDir: writeTemplateToDir2 } = await import("./baseline-
|
|
906
|
+
const { writeTemplateToDir: writeTemplateToDir2 } = await import("./baseline-DT3CWKOO.js");
|
|
906
907
|
const config = await readProjxConfig(cwd);
|
|
907
908
|
const rootPinned = Array.isArray(config.skip) ? config.skip : [];
|
|
908
909
|
const componentPinned = [];
|
|
@@ -4228,7 +4229,20 @@ function parseArgs(argv = process.argv.slice(2)) {
|
|
|
4228
4229
|
if (arg === "--components") {
|
|
4229
4230
|
const val = args[++i];
|
|
4230
4231
|
if (val) {
|
|
4231
|
-
|
|
4232
|
+
const requested = val.split(",").map((c) => c.trim());
|
|
4233
|
+
const invalid = requested.filter(
|
|
4234
|
+
(c) => !COMPONENTS.includes(c)
|
|
4235
|
+
);
|
|
4236
|
+
if (invalid.length > 0) {
|
|
4237
|
+
const hints = invalid.map((c) => {
|
|
4238
|
+
const guess = suggestComponent(c);
|
|
4239
|
+
return guess ? `${c} (did you mean ${guess}?)` : c;
|
|
4240
|
+
}).join(", ");
|
|
4241
|
+
throw new Error(
|
|
4242
|
+
`Invalid --components: ${hints}. Available: ${COMPONENTS.join(", ")}`
|
|
4243
|
+
);
|
|
4244
|
+
}
|
|
4245
|
+
options.components = requested;
|
|
4232
4246
|
}
|
|
4233
4247
|
continue;
|
|
4234
4248
|
}
|
|
@@ -4361,14 +4375,27 @@ async function main() {
|
|
|
4361
4375
|
return;
|
|
4362
4376
|
}
|
|
4363
4377
|
if (command === "add") {
|
|
4364
|
-
const
|
|
4378
|
+
const positionals = extraArgs.filter((a) => !a.startsWith("-"));
|
|
4379
|
+
const components = positionals.filter(
|
|
4365
4380
|
(c) => COMPONENTS.includes(c)
|
|
4366
4381
|
);
|
|
4382
|
+
const unknown = positionals.filter(
|
|
4383
|
+
(c) => !COMPONENTS.includes(c)
|
|
4384
|
+
);
|
|
4385
|
+
if (unknown.length > 0) {
|
|
4386
|
+
for (const u of unknown) {
|
|
4387
|
+
const guess = suggestComponent(u);
|
|
4388
|
+
console.error(
|
|
4389
|
+
guess ? `Error: unknown component ${u} \u2014 did you mean ${guess}?` : `Error: unknown component ${u}. Available: ${COMPONENTS.join(", ")}`
|
|
4390
|
+
);
|
|
4391
|
+
}
|
|
4392
|
+
process.exit(2);
|
|
4393
|
+
}
|
|
4367
4394
|
if (components.length === 0) {
|
|
4368
4395
|
console.error(
|
|
4369
4396
|
`Error: specify components to add. Available: ${COMPONENTS.join(", ")}`
|
|
4370
4397
|
);
|
|
4371
|
-
process.exit(
|
|
4398
|
+
process.exit(2);
|
|
4372
4399
|
}
|
|
4373
4400
|
const customName = extraArgs.find((a) => a.startsWith("--name="))?.slice("--name=".length);
|
|
4374
4401
|
if (customName && components.length > 1) {
|
|
@@ -29,13 +29,14 @@ import {
|
|
|
29
29
|
replaceInDir,
|
|
30
30
|
replaceInFile,
|
|
31
31
|
sharedTemplateDir,
|
|
32
|
+
suggestComponent,
|
|
32
33
|
toKebab,
|
|
33
34
|
toSnake,
|
|
34
35
|
toTitle,
|
|
35
36
|
upsertComponentMarker,
|
|
36
37
|
writeComponentMarker,
|
|
37
38
|
writeProjxConfig
|
|
38
|
-
} from "./chunk-
|
|
39
|
+
} from "./chunk-N66CVDEV.js";
|
|
39
40
|
export {
|
|
40
41
|
COMPONENTS,
|
|
41
42
|
COMPONENT_MARKER,
|
|
@@ -67,6 +68,7 @@ export {
|
|
|
67
68
|
replaceInDir,
|
|
68
69
|
replaceInFile,
|
|
69
70
|
sharedTemplateDir,
|
|
71
|
+
suggestComponent,
|
|
70
72
|
toKebab,
|
|
71
73
|
toSnake,
|
|
72
74
|
toTitle,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-projx",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.7",
|
|
4
4
|
"description": "Scaffold production-grade fullstack projects in seconds. FastAPI, Fastify, Express, React, Flutter, Terraform — with auth, database, CI/CD, E2E tests, and Docker. One command, ready to deploy.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
* @<%= githubOwner %>
|
|
2
|
+
|
|
3
|
+
infra/ @<%= githubOwner %>
|
|
4
|
+
.github/workflows/ @<%= githubOwner %>
|
|
5
|
+
scripts/deploy*.sh @<%= githubOwner %>
|
|
6
|
+
scripts/rollback.sh @<%= githubOwner %>
|
|
7
|
+
<% if (hasBackendMigrations) { %>backend/prisma/migrations/ @<%= githubOwner %>
|
|
8
|
+
<% } %><% if (hasAdminPanelMigrations) { %>admin-panel/internal/db/migrations/ @<%= githubOwner %>
|
|
9
|
+
<% } %>
|
|
@@ -23,7 +23,7 @@ services:
|
|
|
23
23
|
"CMD",
|
|
24
24
|
"python",
|
|
25
25
|
"-c",
|
|
26
|
-
"import urllib.request; urllib.request.urlopen('http://localhost:7860/api/health')",
|
|
26
|
+
"import urllib.request; urllib.request.urlopen('http://localhost:7860/api/health/live')",
|
|
27
27
|
]
|
|
28
28
|
interval: 30s
|
|
29
29
|
timeout: 10s
|
|
@@ -61,7 +61,7 @@ services:
|
|
|
61
61
|
"CMD",
|
|
62
62
|
"node",
|
|
63
63
|
"-e",
|
|
64
|
-
"require('http').get('http://localhost:3000/api/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))",
|
|
64
|
+
"require('http').get('http://localhost:3000/api/health/live', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))",
|
|
65
65
|
]
|
|
66
66
|
interval: 30s
|
|
67
67
|
timeout: 10s
|
|
@@ -99,7 +99,7 @@ services:
|
|
|
99
99
|
"CMD",
|
|
100
100
|
"node",
|
|
101
101
|
"-e",
|
|
102
|
-
"require('http').get('http://localhost:3000/api/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))",
|
|
102
|
+
"require('http').get('http://localhost:3000/api/health/live', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))",
|
|
103
103
|
]
|
|
104
104
|
interval: 30s
|
|
105
105
|
timeout: 10s
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Roll <%= projectName %> back to a previous main-<sha> image tag in ECR.
|
|
3
|
+
#
|
|
4
|
+
# Usage:
|
|
5
|
+
# scripts/rollback.sh <previous-sha>
|
|
6
|
+
#
|
|
7
|
+
# Required env on the operator workstation:
|
|
8
|
+
# AWS_REGION (default <%= awsRegion %>)
|
|
9
|
+
# ECR_REGISTRY (account-id.dkr.ecr.<%= awsRegion %>.amazonaws.com)
|
|
10
|
+
# EC2_INSTANCE_ID (the <%= projectName %>-web instance)
|
|
11
|
+
# DOMAIN (default <%= productionDomain %>)
|
|
12
|
+
|
|
13
|
+
set -euo pipefail
|
|
14
|
+
|
|
15
|
+
if [[ $# -ne 1 ]]; then
|
|
16
|
+
echo "Usage: $0 <previous-sha>" >&2
|
|
17
|
+
exit 2
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
PREV_SHA="$1"
|
|
21
|
+
AWS_REGION="${AWS_REGION:-<%= awsRegion %>}"
|
|
22
|
+
DOMAIN="${DOMAIN:-<%= productionDomain %>}"
|
|
23
|
+
TAG="main-${PREV_SHA}"
|
|
24
|
+
|
|
25
|
+
if [[ -z "${ECR_REGISTRY:-}" ]]; then
|
|
26
|
+
echo "ECR_REGISTRY is required" >&2
|
|
27
|
+
exit 2
|
|
28
|
+
fi
|
|
29
|
+
if [[ -z "${EC2_INSTANCE_ID:-}" ]]; then
|
|
30
|
+
echo "EC2_INSTANCE_ID is required" >&2
|
|
31
|
+
exit 2
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
REPOS=(<% for (const r of ecrRepos) { %>
|
|
35
|
+
<%= r %><% } %>
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
echo "==> Confirming target image exists in ECR for every service"
|
|
39
|
+
for repo in "${REPOS[@]}"; do
|
|
40
|
+
if ! aws ecr describe-images --region "$AWS_REGION" --repository-name "$repo" --image-ids imageTag="$TAG" >/dev/null 2>&1; then
|
|
41
|
+
echo "FATAL: $repo does not have image tag $TAG in ECR" >&2
|
|
42
|
+
exit 3
|
|
43
|
+
fi
|
|
44
|
+
done
|
|
45
|
+
|
|
46
|
+
echo "==> Re-tagging $TAG as main-latest for every service"
|
|
47
|
+
for repo in "${REPOS[@]}"; do
|
|
48
|
+
manifest=$(aws ecr batch-get-image --region "$AWS_REGION" --repository-name "$repo" --image-ids imageTag="$TAG" --query 'images[].imageManifest' --output text)
|
|
49
|
+
if [[ -z "$manifest" ]]; then
|
|
50
|
+
echo "FATAL: failed to read manifest for $repo:$TAG" >&2
|
|
51
|
+
exit 4
|
|
52
|
+
fi
|
|
53
|
+
aws ecr put-image --region "$AWS_REGION" --repository-name "$repo" --image-tag "main-latest" --image-manifest "$manifest" >/dev/null
|
|
54
|
+
echo " re-tagged $repo -> main-latest"
|
|
55
|
+
done
|
|
56
|
+
|
|
57
|
+
echo "==> Triggering deploy on $EC2_INSTANCE_ID with IMAGE_TAG=$TAG"
|
|
58
|
+
COMMAND_ID=$(aws ssm send-command \
|
|
59
|
+
--region "$AWS_REGION" \
|
|
60
|
+
--instance-ids "$EC2_INSTANCE_ID" \
|
|
61
|
+
--document-name AWS-RunShellScript \
|
|
62
|
+
--comment "<%= projectName %> rollback to $TAG" \
|
|
63
|
+
--parameters commands="[\"IMAGE_TAG=$TAG AWS_REGION=$AWS_REGION ECR_REGISTRY=$ECR_REGISTRY DOMAIN=$DOMAIN bash /opt/<%= projectName %>/scripts/deploy-on-ec2.sh\"]" \
|
|
64
|
+
--query 'Command.CommandId' \
|
|
65
|
+
--output text)
|
|
66
|
+
|
|
67
|
+
echo "==> SSM command id: $COMMAND_ID"
|
|
68
|
+
echo "Tail logs with:"
|
|
69
|
+
echo " aws ssm get-command-invocation --region $AWS_REGION --instance-id $EC2_INSTANCE_ID --command-id $COMMAND_ID"
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# <%= displayName %> Runbook
|
|
2
|
+
|
|
3
|
+
Single source of truth for production recovery, deploys, rollbacks, and incident response.
|
|
4
|
+
|
|
5
|
+
## Architecture at a glance
|
|
6
|
+
|
|
7
|
+
- Single EC2 (Ubuntu 24.04, `<%= awsRegion %>`) running Docker Compose
|
|
8
|
+
- RDS Postgres, isolated subnets, PITR enabled, `deletion_protection=true`
|
|
9
|
+
- Container registry: ECR
|
|
10
|
+
- IaC: Terraform in [`infra/`](infra/), state in S3 + DynamoDB lock
|
|
11
|
+
|
|
12
|
+
## Deploy
|
|
13
|
+
|
|
14
|
+
GitHub Actions builds + pushes ECR + SSM SendCommand to the EC2. Operator runs migrate by hand:
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
aws ssm start-session --target $EC2_INSTANCE_ID
|
|
18
|
+
cd /opt/<%= projectName %>
|
|
19
|
+
docker compose run --rm migrate
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Rollback (target RTO < 2 minutes)
|
|
23
|
+
|
|
24
|
+
1. Pick last-known-good `main-<sha>` tag in ECR.
|
|
25
|
+
2. `scripts/rollback.sh <sha>` — re-tags ECR + SSM-invokes deploy.
|
|
26
|
+
3. `curl https://<%= productionDomain %>/api/health` must return 200.
|
|
27
|
+
|
|
28
|
+
## DB restore (point-in-time)
|
|
29
|
+
|
|
30
|
+
Last tested: not yet rehearsed.
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
aws rds restore-db-instance-to-point-in-time \
|
|
34
|
+
--source-db-instance-identifier <%= projectName %>-1 \
|
|
35
|
+
--target-db-instance-identifier <%= projectName %>-1-restore-$(date +%Y%m%d) \
|
|
36
|
+
--restore-time <iso8601-utc>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Then update `/<%= projectName %>/backend/env` `DATABASE_URL` SSM parameter; `docker compose restart backend`.
|
|
40
|
+
|
|
41
|
+
## Certificate renewal
|
|
42
|
+
|
|
43
|
+
Let's Encrypt via certbot at `/etc/cron.d/<%= projectName %>-certbot`, daily 03:00.
|
|
44
|
+
|
|
45
|
+
## Incident channels
|
|
46
|
+
|
|
47
|
+
- Internal alarms: `<%= projectName %>-alerts` SNS topic (subscribe via `var.alert_emails`)
|
|
48
|
+
- Application crashes: Sentry (Node backends: `service_configs` row with purpose `sentry`; FastAPI/frontend: `SENTRY_DSN` / `VITE_SENTRY_DSN` env)
|
|
49
|
+
- Customer-facing: TBD
|
|
50
|
+
|
|
51
|
+
## Known recovery cliffs (open items)
|
|
52
|
+
|
|
53
|
+
- **Single-AZ RDS** — failover takes minutes until `multi_az=true` flipped.
|
|
54
|
+
- **DR untested** — restore procedure above is theoretical until rehearsed.
|
|
55
|
+
- **`docker container prune` + `docker image prune -a` on every deploy** — `scripts/deploy.sh` aggressively cleans the local Docker store after each successful deploy. This keeps the disk small but means **rollback to a previous image always requires a fresh `docker pull`** rather than a local-cache hit. On a slow link or during a registry outage that blocks the pull, rollback time stretches by however long the pull takes. If the rollback target is more than two deploys old it will not be in the registry's hot cache either. **Mitigation:** for the highest-risk deploys (database migrations, auth surface, payment paths), keep the previous container running on a parallel port for the first 15 minutes post-deploy so an in-place revert does not need any pull at all. Skip the prune step on the deploy host for those windows.
|
|
56
|
+
|
|
57
|
+
## Common ops
|
|
58
|
+
|
|
59
|
+
| Task | Command |
|
|
60
|
+
| -------------------------- | ------------------------------------------------------------------------------------ |
|
|
61
|
+
| SSH-equivalent | `aws ssm start-session --target $EC2_INSTANCE_ID` |
|
|
62
|
+
| Tail backend logs | `docker logs --tail 200 -f backend` |
|
|
63
|
+
| Run a one-off migration | `docker compose run --rm migrate` |
|
|
64
|
+
| Re-issue certbot | `sudo certbot certonly --nginx --keep-until-expiring -d <%= productionDomain %>` |
|