create-projx 1.7.6 → 1.8.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/{baseline-2PBT5JBT.js → baseline-ACCRNHE4.js} +2 -2
- package/dist/{chunk-N66CVDEV.js → chunk-GZUJ235K.js} +4 -10
- package/dist/{chunk-NPBLDU4H.js → chunk-NXIFRWTX.js} +58 -10
- package/dist/index.js +27 -15
- package/dist/{utils-QQUKUZKQ.js → utils-NVIN3FSZ.js} +3 -3
- 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
|
@@ -395,17 +395,11 @@ async function writeProjxConfig(cwd, data) {
|
|
|
395
395
|
if (!Array.isArray(out.skip)) out.skip = [];
|
|
396
396
|
await writeFile(path, JSON.stringify(out, null, 2) + "\n");
|
|
397
397
|
}
|
|
398
|
-
var
|
|
398
|
+
var INSTANCE_AWARE_SHARED = [
|
|
399
399
|
"docker-compose.yml",
|
|
400
|
-
"README.md",
|
|
401
|
-
".githooks/pre-commit",
|
|
402
400
|
".github/workflows/ci.yml",
|
|
403
|
-
"
|
|
404
|
-
"scripts/
|
|
405
|
-
"scripts/check-bundle-size.sh",
|
|
406
|
-
"scripts/setup.sh",
|
|
407
|
-
"scripts/setup-docker.sh",
|
|
408
|
-
"scripts/setup-ssl.sh"
|
|
401
|
+
".githooks/pre-commit",
|
|
402
|
+
"scripts/setup.sh"
|
|
409
403
|
];
|
|
410
404
|
var DEFAULT_COMPONENT_SKIP_PATTERNS = {
|
|
411
405
|
fastapi: ["pyproject.toml"],
|
|
@@ -627,7 +621,7 @@ export {
|
|
|
627
621
|
upsertComponentMarker,
|
|
628
622
|
readProjxConfig,
|
|
629
623
|
writeProjxConfig,
|
|
630
|
-
|
|
624
|
+
INSTANCE_AWARE_SHARED,
|
|
631
625
|
DEFAULT_COMPONENT_SKIP_PATTERNS,
|
|
632
626
|
discoverComponentPaths,
|
|
633
627
|
discoverComponentsFromMarkers,
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
2
|
DEFAULT_COMPONENT_SKIP_PATTERNS,
|
|
3
|
-
DEFAULT_ROOT_SKIP_PATTERNS,
|
|
4
3
|
copyComponent,
|
|
5
4
|
copyStaticFiles,
|
|
6
5
|
readComponentMarker,
|
|
@@ -13,7 +12,7 @@ import {
|
|
|
13
12
|
toSnake,
|
|
14
13
|
upsertComponentMarker,
|
|
15
14
|
writeProjxConfig
|
|
16
|
-
} from "./chunk-
|
|
15
|
+
} from "./chunk-GZUJ235K.js";
|
|
17
16
|
|
|
18
17
|
// src/baseline.ts
|
|
19
18
|
import { existsSync, writeFileSync, unlinkSync } from "fs";
|
|
@@ -90,6 +89,39 @@ async function generateCiYml(vars) {
|
|
|
90
89
|
async function generateReadme(vars) {
|
|
91
90
|
return renderShared("README.md.ejs", withInstances(vars));
|
|
92
91
|
}
|
|
92
|
+
function infraTemplateVars(vars) {
|
|
93
|
+
const projectName = vars.projectName;
|
|
94
|
+
const productionDomain = vars.productionDomain ?? `${projectName}.example.com`;
|
|
95
|
+
const awsRegion = vars.awsRegion ?? "us-east-1";
|
|
96
|
+
const githubOwner = vars.githubOwner ?? "TODO";
|
|
97
|
+
const ecrRepos = vars.ecrRepos ?? [
|
|
98
|
+
`${projectName}/backend`,
|
|
99
|
+
`${projectName}/frontend`
|
|
100
|
+
];
|
|
101
|
+
const hasBackendMigrations = vars.components.some(
|
|
102
|
+
(c) => c === "fastify" || c === "express"
|
|
103
|
+
);
|
|
104
|
+
const hasAdminPanelMigrations = vars.components.includes("admin-panel");
|
|
105
|
+
return {
|
|
106
|
+
...vars,
|
|
107
|
+
productionDomain,
|
|
108
|
+
awsRegion,
|
|
109
|
+
githubOwner,
|
|
110
|
+
ecrRepos,
|
|
111
|
+
hasBackendMigrations,
|
|
112
|
+
hasAdminPanelMigrations,
|
|
113
|
+
displayName: projectName.charAt(0).toUpperCase() + projectName.slice(1)
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
async function generateRollback(vars) {
|
|
117
|
+
return renderShared("rollback.sh.ejs", infraTemplateVars(vars));
|
|
118
|
+
}
|
|
119
|
+
async function generateCodeowners(vars) {
|
|
120
|
+
return renderShared("codeowners.ejs", infraTemplateVars(vars));
|
|
121
|
+
}
|
|
122
|
+
async function generateRunbook(vars) {
|
|
123
|
+
return renderShared("runbook.md.ejs", infraTemplateVars(vars));
|
|
124
|
+
}
|
|
93
125
|
function generateVscodeSettings(vars) {
|
|
94
126
|
const settings = {};
|
|
95
127
|
if (vars.components.includes("fastapi")) {
|
|
@@ -128,7 +160,7 @@ function generateVscodeSettings(vars) {
|
|
|
128
160
|
// src/baseline.ts
|
|
129
161
|
var BASELINE_REF = "refs/projx/baseline";
|
|
130
162
|
async function migrateComponentMarkers(cwd, components, componentPaths, applyDefaults) {
|
|
131
|
-
const { readComponentMarker: readComponentMarker2, writeComponentMarker } = await import("./utils-
|
|
163
|
+
const { readComponentMarker: readComponentMarker2, writeComponentMarker } = await import("./utils-NVIN3FSZ.js");
|
|
132
164
|
for (const component of components) {
|
|
133
165
|
const dir = componentPaths[component];
|
|
134
166
|
const markerDir = join2(cwd, dir);
|
|
@@ -159,9 +191,7 @@ async function writeManagedProjx(cwd, version, vars, applyDefaults) {
|
|
|
159
191
|
if (typeof vars.orm === "string" && !merged.orm) {
|
|
160
192
|
merged.orm = vars.orm;
|
|
161
193
|
}
|
|
162
|
-
if (applyDefaults
|
|
163
|
-
const userSkip = Array.isArray(merged.skip) ? merged.skip : [];
|
|
164
|
-
merged.skip = [.../* @__PURE__ */ new Set([...userSkip, ...DEFAULT_ROOT_SKIP_PATTERNS])];
|
|
194
|
+
if (applyDefaults) {
|
|
165
195
|
merged.defaultsApplied = true;
|
|
166
196
|
}
|
|
167
197
|
await writeProjxConfig(cwd, merged);
|
|
@@ -419,10 +449,8 @@ async function writeTemplateToDir(dest, repoDir, components, componentPaths, var
|
|
|
419
449
|
}
|
|
420
450
|
const hasBackend = components.includes("fastapi") || components.includes("fastify") || components.includes("express");
|
|
421
451
|
const userSkip = rootSkip ?? [];
|
|
422
|
-
const defaultRootSkip = applyDefaults ? DEFAULT_ROOT_SKIP_PATTERNS : [];
|
|
423
|
-
const effectiveSkip = [.../* @__PURE__ */ new Set([...userSkip, ...defaultRootSkip])];
|
|
424
452
|
const shouldWrite = (file) => {
|
|
425
|
-
if (!matchesSkip(file,
|
|
453
|
+
if (!matchesSkip(file, userSkip)) return true;
|
|
426
454
|
return !existsSync(join2(realCwd, file));
|
|
427
455
|
};
|
|
428
456
|
if (hasBackend || components.includes("frontend") || components.includes("admin-panel")) {
|
|
@@ -457,6 +485,26 @@ async function writeTemplateToDir(dest, repoDir, components, componentPaths, var
|
|
|
457
485
|
);
|
|
458
486
|
await chmod(join2(dest, "scripts/setup.sh"), 493);
|
|
459
487
|
}
|
|
488
|
+
if (vars.components.includes("infra")) {
|
|
489
|
+
if (shouldWrite("scripts/rollback.sh")) {
|
|
490
|
+
await mkdir(join2(dest, "scripts"), { recursive: true });
|
|
491
|
+
await writeFile(
|
|
492
|
+
join2(dest, "scripts/rollback.sh"),
|
|
493
|
+
await generateRollback(vars)
|
|
494
|
+
);
|
|
495
|
+
await chmod(join2(dest, "scripts/rollback.sh"), 493);
|
|
496
|
+
}
|
|
497
|
+
if (shouldWrite(".github/CODEOWNERS")) {
|
|
498
|
+
await mkdir(join2(dest, ".github"), { recursive: true });
|
|
499
|
+
await writeFile(
|
|
500
|
+
join2(dest, ".github/CODEOWNERS"),
|
|
501
|
+
await generateCodeowners(vars)
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
if (shouldWrite("RUNBOOK.md")) {
|
|
505
|
+
await writeFile(join2(dest, "RUNBOOK.md"), await generateRunbook(vars));
|
|
506
|
+
}
|
|
507
|
+
}
|
|
460
508
|
await copyStaticFiles(repoDir, dest);
|
|
461
509
|
if (shouldWrite(".vscode/settings.json")) {
|
|
462
510
|
await mkdir(join2(dest, ".vscode"), { recursive: true });
|
|
@@ -613,7 +661,7 @@ function ormNodeDockerfileSource(manifest, vars) {
|
|
|
613
661
|
ENV PATH="$PNPM_HOME:$PATH"
|
|
614
662
|
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
|
|
615
663
|
` : 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 ./`;
|
|
664
|
+
const buildCopy = extraConfigCopy ? `COPY package.json tsconfig.json tsconfig.build.json ${extraConfigCopy} ./` : `COPY package.json tsconfig.json tsconfig.build.json ./`;
|
|
617
665
|
const migrateStage = migrateCmd ? `FROM build AS migrate
|
|
618
666
|
CMD ["sh", "-c", "${exec} ${migrateCmd}"]
|
|
619
667
|
|
package/dist/index.js
CHANGED
|
@@ -9,12 +9,12 @@ import {
|
|
|
9
9
|
matchesSkip,
|
|
10
10
|
saveBaselineRef,
|
|
11
11
|
writeTemplateToDir
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-NXIFRWTX.js";
|
|
13
13
|
import {
|
|
14
14
|
COMPONENTS,
|
|
15
15
|
COMPONENT_MARKER,
|
|
16
|
-
DEFAULT_ROOT_SKIP_PATTERNS,
|
|
17
16
|
EXCLUDE,
|
|
17
|
+
INSTANCE_AWARE_SHARED,
|
|
18
18
|
KNOWN_FEATURES,
|
|
19
19
|
ORM_PROVIDERS,
|
|
20
20
|
PACKAGE_MANAGERS,
|
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
toSnake,
|
|
38
38
|
writeComponentMarker,
|
|
39
39
|
writeProjxConfig
|
|
40
|
-
} from "./chunk-
|
|
40
|
+
} from "./chunk-GZUJ235K.js";
|
|
41
41
|
|
|
42
42
|
// src/index.ts
|
|
43
43
|
import { existsSync as existsSync11 } from "fs";
|
|
@@ -903,7 +903,7 @@ function hasUncommittedChanges(cwd) {
|
|
|
903
903
|
async function findPinnedFilesWithUpdates(cwd, repoDir, components, componentPaths, vars, version, componentSkips, rootSkip) {
|
|
904
904
|
const { mkdtemp: mkdtemp2, rm: rm2, readFile: readFile8 } = await import("fs/promises");
|
|
905
905
|
const { tmpdir: tmpdir2 } = await import("os");
|
|
906
|
-
const { writeTemplateToDir: writeTemplateToDir2 } = await import("./baseline-
|
|
906
|
+
const { writeTemplateToDir: writeTemplateToDir2 } = await import("./baseline-ACCRNHE4.js");
|
|
907
907
|
const config = await readProjxConfig(cwd);
|
|
908
908
|
const rootPinned = Array.isArray(config.skip) ? config.skip : [];
|
|
909
909
|
const componentPinned = [];
|
|
@@ -1088,6 +1088,17 @@ import { copyFileSync as copyFileSync2, existsSync as existsSync4 } from "fs";
|
|
|
1088
1088
|
import { readFile as readFile4 } from "fs/promises";
|
|
1089
1089
|
import { join as join4 } from "path";
|
|
1090
1090
|
import * as p4 from "@clack/prompts";
|
|
1091
|
+
function reportPinnedShared(cwd, rootSkip, wiringTarget) {
|
|
1092
|
+
const pinned = INSTANCE_AWARE_SHARED.filter(
|
|
1093
|
+
(f) => matchesSkip(f, rootSkip) && existsSync4(join4(cwd, f))
|
|
1094
|
+
);
|
|
1095
|
+
if (pinned.length === 0) return;
|
|
1096
|
+
p4.log.warn(
|
|
1097
|
+
`${pinned.length} shared file(s) are in .projx "skip" and were left untouched \u2014 wire ${wiringTarget} in by hand, or unpin then re-run add:`
|
|
1098
|
+
);
|
|
1099
|
+
for (const f of pinned) p4.log.info(` ${f}`);
|
|
1100
|
+
p4.log.info(`Unpin: npx create-projx unpin ${pinned.join(" ")}`);
|
|
1101
|
+
}
|
|
1091
1102
|
async function add(cwd, newComponents, localRepo, skipInstall = false, customName, features) {
|
|
1092
1103
|
p4.intro("projx add");
|
|
1093
1104
|
const isLocal = !!localRepo;
|
|
@@ -1164,6 +1175,12 @@ async function add(cwd, newComponents, localRepo, skipInstall = false, customNam
|
|
|
1164
1175
|
await readFile4(join4(repoDir, "cli/package.json"), "utf-8")
|
|
1165
1176
|
);
|
|
1166
1177
|
const version = pkg.version;
|
|
1178
|
+
const rootSkip = Array.isArray(config.skip) ? config.skip : [];
|
|
1179
|
+
const componentSkips = {};
|
|
1180
|
+
for (const inst of existingInstances) {
|
|
1181
|
+
const m = await readComponentMarker(join4(cwd, inst.path));
|
|
1182
|
+
if (m?.skip && m.skip.length > 0) componentSkips[inst.type] = m.skip;
|
|
1183
|
+
}
|
|
1167
1184
|
const spinner6 = p4.spinner();
|
|
1168
1185
|
spinner6.start("Adding components");
|
|
1169
1186
|
await writeTemplateToDir(
|
|
@@ -1173,9 +1190,10 @@ async function add(cwd, newComponents, localRepo, skipInstall = false, customNam
|
|
|
1173
1190
|
paths,
|
|
1174
1191
|
vars,
|
|
1175
1192
|
version,
|
|
1176
|
-
{ realCwd: cwd }
|
|
1193
|
+
{ componentSkips, rootSkip, realCwd: cwd }
|
|
1177
1194
|
);
|
|
1178
1195
|
spinner6.stop("Components added.");
|
|
1196
|
+
reportPinnedShared(cwd, rootSkip, toAdd.join(", "));
|
|
1179
1197
|
if (features && Object.keys(features).length > 0) {
|
|
1180
1198
|
const featSpinner = p4.spinner();
|
|
1181
1199
|
featSpinner.start("Applying features");
|
|
@@ -1246,14 +1264,7 @@ async function addInstance(cwd, type, customName, config, existing, localRepo, s
|
|
|
1246
1264
|
await readFile4(join4(repoDir, "cli/package.json"), "utf-8")
|
|
1247
1265
|
);
|
|
1248
1266
|
const version = pkg.version;
|
|
1249
|
-
const
|
|
1250
|
-
".github/workflows/ci.yml",
|
|
1251
|
-
".githooks/pre-commit",
|
|
1252
|
-
"scripts/setup.sh",
|
|
1253
|
-
"docker-compose.yml"
|
|
1254
|
-
]);
|
|
1255
|
-
const rawSkip = Array.isArray(config.skip) ? config.skip : [];
|
|
1256
|
-
const rootSkip = rawSkip.filter((p10) => !INSTANCE_AWARE_ROOT.has(p10));
|
|
1267
|
+
const rootSkip = Array.isArray(config.skip) ? config.skip : [];
|
|
1257
1268
|
const componentSkips = {};
|
|
1258
1269
|
for (const inst of existingInstances) {
|
|
1259
1270
|
const m = await readComponentMarker(join4(cwd, inst.path));
|
|
@@ -1275,6 +1286,7 @@ async function addInstance(cwd, type, customName, config, existing, localRepo, s
|
|
|
1275
1286
|
[newInstance]
|
|
1276
1287
|
);
|
|
1277
1288
|
spinner6.stop(`Scaffolded ${customName}/.`);
|
|
1289
|
+
reportPinnedShared(cwd, rootSkip, customName);
|
|
1278
1290
|
if (result.status === "merged") {
|
|
1279
1291
|
p4.log.success(
|
|
1280
1292
|
`${result.mergedFiles?.length ?? 0} root file(s) merged cleanly.`
|
|
@@ -1553,7 +1565,7 @@ async function init(cwd, localRepo) {
|
|
|
1553
1565
|
);
|
|
1554
1566
|
let pm = "npm";
|
|
1555
1567
|
if (hasJs) {
|
|
1556
|
-
const detected2 =
|
|
1568
|
+
const detected2 = detectPackageManagerFromComponents(cwd, paths);
|
|
1557
1569
|
if (detected2) {
|
|
1558
1570
|
pm = detected2;
|
|
1559
1571
|
p5.log.info(`Detected package manager: ${pm}`);
|
|
@@ -1660,7 +1672,7 @@ async function writeBareProjx(cwd, localRepo, isLocal, pm) {
|
|
|
1660
1672
|
version: pkg.version,
|
|
1661
1673
|
createdAt: today,
|
|
1662
1674
|
updatedAt: today,
|
|
1663
|
-
skip: [
|
|
1675
|
+
skip: [],
|
|
1664
1676
|
defaultsApplied: true
|
|
1665
1677
|
};
|
|
1666
1678
|
if (pm) config.packageManager = pm;
|
|
@@ -2,8 +2,8 @@ import {
|
|
|
2
2
|
COMPONENTS,
|
|
3
3
|
COMPONENT_MARKER,
|
|
4
4
|
DEFAULT_COMPONENT_SKIP_PATTERNS,
|
|
5
|
-
DEFAULT_ROOT_SKIP_PATTERNS,
|
|
6
5
|
EXCLUDE,
|
|
6
|
+
INSTANCE_AWARE_SHARED,
|
|
7
7
|
KNOWN_FEATURES,
|
|
8
8
|
ORM_PROVIDERS,
|
|
9
9
|
PACKAGE_MANAGERS,
|
|
@@ -36,13 +36,13 @@ import {
|
|
|
36
36
|
upsertComponentMarker,
|
|
37
37
|
writeComponentMarker,
|
|
38
38
|
writeProjxConfig
|
|
39
|
-
} from "./chunk-
|
|
39
|
+
} from "./chunk-GZUJ235K.js";
|
|
40
40
|
export {
|
|
41
41
|
COMPONENTS,
|
|
42
42
|
COMPONENT_MARKER,
|
|
43
43
|
DEFAULT_COMPONENT_SKIP_PATTERNS,
|
|
44
|
-
DEFAULT_ROOT_SKIP_PATTERNS,
|
|
45
44
|
EXCLUDE,
|
|
45
|
+
INSTANCE_AWARE_SHARED,
|
|
46
46
|
KNOWN_FEATURES,
|
|
47
47
|
ORM_PROVIDERS,
|
|
48
48
|
PACKAGE_MANAGERS,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-projx",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
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 %>` |
|