@percepta/create 3.4.0 → 3.4.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/package.json +1 -1
- package/templates/monorepo/auth/package.json +2 -1
- package/templates/monorepo/auth/src/principals.ts +11 -0
- package/templates/monorepo/package.json.template +1 -1
- package/templates/webapp/README.md +26 -0
- package/templates/webapp/agent-skills/deploy.md +1 -1
- package/templates/webapp/deploy/README.md +1 -1
- package/templates/webapp/e2e/rbac.spec.ts +136 -0
- package/templates/webapp/eslint.config.mjs +6 -0
- package/templates/webapp/gitignore.template +2 -0
- package/templates/webapp/package.json.template +8 -3
- package/templates/webapp/playwright.config.ts +33 -0
- package/templates/webapp/src/access/access.manifest.ts +5 -11
- package/templates/webapp/src/app/(admin)/admin/page.tsx +64 -149
- package/templates/webapp/src/services/access/AppAccessControl.ts +5 -31
- package/templates/webapp/scripts/deploy-percepta-test.ts +0 -1112
- package/templates/webapp/scripts/open-ryvn-deploy-pr.ts +0 -497
- package/templates/webapp/src/app/(admin)/admin/_components/PrincipalMultiInput.tsx +0 -248
|
@@ -1,1112 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env tsx
|
|
2
|
-
|
|
3
|
-
import { execFileSync, spawnSync } from "node:child_process";
|
|
4
|
-
import { existsSync } from "node:fs";
|
|
5
|
-
import { readFile } from "node:fs/promises";
|
|
6
|
-
import path from "node:path";
|
|
7
|
-
import { stdin as input, stdout as output } from "node:process";
|
|
8
|
-
import { createInterface } from "node:readline/promises";
|
|
9
|
-
import { fileURLToPath } from "node:url";
|
|
10
|
-
|
|
11
|
-
const APP_NAME = "__APP_NAME__";
|
|
12
|
-
const REPO_SLUG = "Percepta-Core/__REPO_NAME__";
|
|
13
|
-
const ENVIRONMENT = "percepta-test";
|
|
14
|
-
const TERRAFORM_SERVICE_NAME = `${APP_NAME}-terraform`;
|
|
15
|
-
const DEPLOY_URL = `https://${APP_NAME}.${ENVIRONMENT}.aitco.dev`;
|
|
16
|
-
const DEFAULT_TIMEOUT_MINUTES = 20;
|
|
17
|
-
|
|
18
|
-
const REQUIRED_PLATFORM_INSTALLATIONS = [
|
|
19
|
-
{
|
|
20
|
-
name: "percepta-internal-terraform",
|
|
21
|
-
label: "shared Postgres",
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
name: "inngest-test",
|
|
25
|
-
label: "shared Inngest",
|
|
26
|
-
},
|
|
27
|
-
{
|
|
28
|
-
name: "otel-collector",
|
|
29
|
-
label: "shared OTEL collector",
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
name: "lgtm-stack-helm",
|
|
33
|
-
label: "shared LGTM stack",
|
|
34
|
-
},
|
|
35
|
-
{
|
|
36
|
-
name: "langfuse",
|
|
37
|
-
label: "shared Langfuse",
|
|
38
|
-
},
|
|
39
|
-
] as const;
|
|
40
|
-
|
|
41
|
-
const REQUIRED_PLATFORM_VARIABLE_GROUPS = [
|
|
42
|
-
{
|
|
43
|
-
name: "demos-commons",
|
|
44
|
-
label: "shared demo config",
|
|
45
|
-
requiredVariables: [
|
|
46
|
-
"ANTHROPIC_API_KEY",
|
|
47
|
-
"LANGFUSE_PUBLIC_KEY",
|
|
48
|
-
"LANGFUSE_SECRET_KEY",
|
|
49
|
-
],
|
|
50
|
-
requiredSensitiveVariables: ["ANTHROPIC_API_KEY", "LANGFUSE_SECRET_KEY"],
|
|
51
|
-
},
|
|
52
|
-
] as const;
|
|
53
|
-
|
|
54
|
-
interface Options {
|
|
55
|
-
yes: boolean;
|
|
56
|
-
skipRepo: boolean;
|
|
57
|
-
skipPush: boolean;
|
|
58
|
-
skipWorkflows: boolean;
|
|
59
|
-
skipSecrets: boolean;
|
|
60
|
-
ref: string;
|
|
61
|
-
timeoutMinutes: number;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
interface WorkflowRun {
|
|
65
|
-
databaseId: number;
|
|
66
|
-
status: string;
|
|
67
|
-
conclusion: string | null;
|
|
68
|
-
url: string;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
interface InstallationTask {
|
|
72
|
-
associatedTaskId: string;
|
|
73
|
-
createdAt: string;
|
|
74
|
-
description?: string;
|
|
75
|
-
isApprovable: boolean;
|
|
76
|
-
status: string;
|
|
77
|
-
taskType: string;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
interface Installation {
|
|
81
|
-
name?: string;
|
|
82
|
-
status?: string;
|
|
83
|
-
health?: string;
|
|
84
|
-
release?: {
|
|
85
|
-
commitSha?: string;
|
|
86
|
-
currentVersion?: string;
|
|
87
|
-
targetVersion?: string;
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
interface VariableGroup {
|
|
92
|
-
name?: string;
|
|
93
|
-
variables?: Array<{
|
|
94
|
-
key: string;
|
|
95
|
-
sensitive?: boolean;
|
|
96
|
-
}>;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
interface Release {
|
|
100
|
-
version?: string;
|
|
101
|
-
commit?: {
|
|
102
|
-
sha?: string;
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
interface ReleaseList {
|
|
107
|
-
releases?: Release[];
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function parseArgs(argv: string[]): Options {
|
|
111
|
-
const options: Options = {
|
|
112
|
-
yes: false,
|
|
113
|
-
skipRepo: false,
|
|
114
|
-
skipPush: false,
|
|
115
|
-
skipWorkflows: false,
|
|
116
|
-
skipSecrets: false,
|
|
117
|
-
ref: "main",
|
|
118
|
-
timeoutMinutes: DEFAULT_TIMEOUT_MINUTES,
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
for (let index = 0; index < argv.length; index++) {
|
|
122
|
-
const arg = argv[index];
|
|
123
|
-
if (arg === "--") {
|
|
124
|
-
continue;
|
|
125
|
-
} else if (arg === "--yes" || arg === "-y") {
|
|
126
|
-
options.yes = true;
|
|
127
|
-
} else if (arg === "--skip-repo") {
|
|
128
|
-
options.skipRepo = true;
|
|
129
|
-
} else if (arg === "--skip-push") {
|
|
130
|
-
options.skipPush = true;
|
|
131
|
-
} else if (arg === "--skip-workflows") {
|
|
132
|
-
options.skipWorkflows = true;
|
|
133
|
-
} else if (arg === "--skip-secrets") {
|
|
134
|
-
options.skipSecrets = true;
|
|
135
|
-
} else if (arg === "--ref") {
|
|
136
|
-
const value = argv[index + 1];
|
|
137
|
-
if (!value) throw new Error("--ref requires a branch or ref");
|
|
138
|
-
options.ref = value;
|
|
139
|
-
index++;
|
|
140
|
-
} else if (arg === "--timeout-minutes") {
|
|
141
|
-
const value = Number(argv[index + 1]);
|
|
142
|
-
if (!Number.isFinite(value) || value <= 0) {
|
|
143
|
-
throw new Error("--timeout-minutes requires a positive number");
|
|
144
|
-
}
|
|
145
|
-
options.timeoutMinutes = value;
|
|
146
|
-
index++;
|
|
147
|
-
} else {
|
|
148
|
-
throw new Error(`Unknown argument: ${arg}`);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
return options;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function run(
|
|
156
|
-
command: string,
|
|
157
|
-
args: string[],
|
|
158
|
-
cwd: string,
|
|
159
|
-
stdio: "inherit" | "pipe" = "inherit",
|
|
160
|
-
): string {
|
|
161
|
-
const result = execFileSync(command, args, {
|
|
162
|
-
cwd,
|
|
163
|
-
encoding: "utf8",
|
|
164
|
-
stdio,
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
return typeof result === "string" ? result.trim() : "";
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function runWithInput(
|
|
171
|
-
command: string,
|
|
172
|
-
args: string[],
|
|
173
|
-
cwd: string,
|
|
174
|
-
inputText: string,
|
|
175
|
-
): string {
|
|
176
|
-
const result = execFileSync(command, args, {
|
|
177
|
-
cwd,
|
|
178
|
-
encoding: "utf8",
|
|
179
|
-
input: inputText,
|
|
180
|
-
stdio: ["pipe", "inherit", "inherit"],
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
return typeof result === "string" ? result.trim() : "";
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function runJson<T>(command: string, args: string[], cwd: string): T {
|
|
187
|
-
const outputText = run(command, args, cwd, "pipe");
|
|
188
|
-
return JSON.parse(outputText) as T;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function assertCommand(command: string): void {
|
|
192
|
-
try {
|
|
193
|
-
run(command, ["--version"], process.cwd(), "pipe");
|
|
194
|
-
} catch {
|
|
195
|
-
throw new Error(
|
|
196
|
-
`Required command not found or not authenticated: ${command}`,
|
|
197
|
-
);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function findUp(startDir: string, fileName: string): string | null {
|
|
202
|
-
let dir = startDir;
|
|
203
|
-
while (true) {
|
|
204
|
-
if (existsSync(path.join(dir, fileName))) return dir;
|
|
205
|
-
const parent = path.dirname(dir);
|
|
206
|
-
if (parent === dir) return null;
|
|
207
|
-
dir = parent;
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function parseGitHubRepo(remoteUrl: string): string | null {
|
|
212
|
-
const match = remoteUrl.match(
|
|
213
|
-
/github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?$/,
|
|
214
|
-
);
|
|
215
|
-
return match?.[1] ?? null;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function getRemoteRepoSlug(monorepoRoot: string): string | null {
|
|
219
|
-
try {
|
|
220
|
-
return parseGitHubRepo(
|
|
221
|
-
run("git", ["remote", "get-url", "origin"], monorepoRoot, "pipe"),
|
|
222
|
-
);
|
|
223
|
-
} catch {
|
|
224
|
-
return null;
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
async function confirm(message: string): Promise<boolean> {
|
|
229
|
-
const rl = createInterface({ input, output });
|
|
230
|
-
try {
|
|
231
|
-
const answer = (await rl.question(`${message} [Y/n] `))
|
|
232
|
-
.trim()
|
|
233
|
-
.toLowerCase();
|
|
234
|
-
return answer === "" || answer === "y" || answer === "yes";
|
|
235
|
-
} finally {
|
|
236
|
-
rl.close();
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
async function confirmOrThrow(
|
|
241
|
-
message: string,
|
|
242
|
-
options: Options,
|
|
243
|
-
): Promise<void> {
|
|
244
|
-
if (options.yes) return;
|
|
245
|
-
if (!process.stdin.isTTY) {
|
|
246
|
-
throw new Error(`${message} Re-run with --yes to confirm.`);
|
|
247
|
-
}
|
|
248
|
-
if (!(await confirm(message))) {
|
|
249
|
-
throw new Error("Deployment cancelled.");
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
function sleep(ms: number): Promise<void> {
|
|
254
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
async function ensureGitHubRepo(
|
|
258
|
-
monorepoRoot: string,
|
|
259
|
-
options: Options,
|
|
260
|
-
): Promise<string> {
|
|
261
|
-
const remoteRepoSlug = getRemoteRepoSlug(monorepoRoot);
|
|
262
|
-
const repoSlug = remoteRepoSlug ?? REPO_SLUG;
|
|
263
|
-
|
|
264
|
-
if (remoteRepoSlug && remoteRepoSlug !== REPO_SLUG) {
|
|
265
|
-
console.log(`Using GitHub repo from origin remote: ${remoteRepoSlug}`);
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
if (options.skipRepo) return repoSlug;
|
|
269
|
-
|
|
270
|
-
try {
|
|
271
|
-
run("gh", ["repo", "view", repoSlug], monorepoRoot, "pipe");
|
|
272
|
-
} catch {
|
|
273
|
-
await confirmOrThrow(
|
|
274
|
-
`GitHub repo ${repoSlug} was not found. Create it and push this repo?`,
|
|
275
|
-
options,
|
|
276
|
-
);
|
|
277
|
-
run(
|
|
278
|
-
"gh",
|
|
279
|
-
[
|
|
280
|
-
"repo",
|
|
281
|
-
"create",
|
|
282
|
-
repoSlug,
|
|
283
|
-
"--internal",
|
|
284
|
-
"--source",
|
|
285
|
-
monorepoRoot,
|
|
286
|
-
"--remote",
|
|
287
|
-
"origin",
|
|
288
|
-
"--push",
|
|
289
|
-
],
|
|
290
|
-
monorepoRoot,
|
|
291
|
-
);
|
|
292
|
-
return repoSlug;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
if (!remoteRepoSlug) {
|
|
296
|
-
run(
|
|
297
|
-
"git",
|
|
298
|
-
["remote", "add", "origin", `git@github.com:${repoSlug}.git`],
|
|
299
|
-
monorepoRoot,
|
|
300
|
-
);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
return repoSlug;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function ensureCleanAndPushed(monorepoRoot: string, options: Options): void {
|
|
307
|
-
if (options.skipPush) return;
|
|
308
|
-
|
|
309
|
-
const status = run("git", ["status", "--porcelain"], monorepoRoot, "pipe");
|
|
310
|
-
if (status.length > 0) {
|
|
311
|
-
throw new Error(
|
|
312
|
-
[
|
|
313
|
-
"The git worktree has uncommitted changes.",
|
|
314
|
-
"Commit the app before deploying so GitHub Actions builds the exact code you expect.",
|
|
315
|
-
status,
|
|
316
|
-
].join("\n"),
|
|
317
|
-
);
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
const branch = run("git", ["branch", "--show-current"], monorepoRoot, "pipe");
|
|
321
|
-
if (!branch) {
|
|
322
|
-
throw new Error("Could not determine the current git branch.");
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
run(
|
|
326
|
-
"git",
|
|
327
|
-
["push", "-u", "origin", `${branch}:${options.ref}`],
|
|
328
|
-
monorepoRoot,
|
|
329
|
-
);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
function upsertService(manifestPath: string, monorepoRoot: string): void {
|
|
333
|
-
run("ryvn", ["replace", "-f", manifestPath], monorepoRoot);
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function getCurrentCommitSha(monorepoRoot: string): string {
|
|
337
|
-
return run("git", ["rev-parse", "HEAD"], monorepoRoot, "pipe");
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
function getInstallation(
|
|
341
|
-
name: string,
|
|
342
|
-
monorepoRoot: string,
|
|
343
|
-
): Installation | null {
|
|
344
|
-
try {
|
|
345
|
-
return runJson<Installation>(
|
|
346
|
-
"ryvn",
|
|
347
|
-
["get", "installation", name, "-e", ENVIRONMENT, "-o", "json"],
|
|
348
|
-
monorepoRoot,
|
|
349
|
-
);
|
|
350
|
-
} catch {
|
|
351
|
-
return null;
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
function getVariableGroup(
|
|
356
|
-
name: string,
|
|
357
|
-
monorepoRoot: string,
|
|
358
|
-
): VariableGroup | null {
|
|
359
|
-
try {
|
|
360
|
-
return runJson<VariableGroup>(
|
|
361
|
-
"ryvn",
|
|
362
|
-
["get", "variable-group", name, "-e", ENVIRONMENT, "-o", "json"],
|
|
363
|
-
monorepoRoot,
|
|
364
|
-
);
|
|
365
|
-
} catch {
|
|
366
|
-
return null;
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
function installationExists(name: string, monorepoRoot: string): boolean {
|
|
371
|
-
return getInstallation(name, monorepoRoot) !== null;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
function isHealthyAtCommit(
|
|
375
|
-
installation: Installation | null,
|
|
376
|
-
commitSha: string,
|
|
377
|
-
): boolean {
|
|
378
|
-
return (
|
|
379
|
-
installation?.status === "UP_TO_DATE" &&
|
|
380
|
-
installation.health === "HEALTHY" &&
|
|
381
|
-
installation.release?.commitSha === commitSha
|
|
382
|
-
);
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
function getReleaseForCommit(
|
|
386
|
-
serviceName: string,
|
|
387
|
-
commitSha: string,
|
|
388
|
-
monorepoRoot: string,
|
|
389
|
-
): Release | null {
|
|
390
|
-
try {
|
|
391
|
-
const releaseList = runJson<ReleaseList>(
|
|
392
|
-
"ryvn",
|
|
393
|
-
["get", "release", "--service", serviceName, "-o", "json"],
|
|
394
|
-
monorepoRoot,
|
|
395
|
-
);
|
|
396
|
-
|
|
397
|
-
return (
|
|
398
|
-
releaseList.releases?.find(
|
|
399
|
-
(release) => release.commit?.sha === commitSha,
|
|
400
|
-
) ?? null
|
|
401
|
-
);
|
|
402
|
-
} catch {
|
|
403
|
-
return null;
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
function assertHealthyPlatformInstallation(
|
|
408
|
-
installation: Installation | null,
|
|
409
|
-
name: string,
|
|
410
|
-
label: string,
|
|
411
|
-
): string | null {
|
|
412
|
-
if (installation === null) {
|
|
413
|
-
return `${label} (${name}) was not found in ${ENVIRONMENT}.`;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
if (
|
|
417
|
-
installation.status !== "UP_TO_DATE" ||
|
|
418
|
-
installation.health !== "HEALTHY"
|
|
419
|
-
) {
|
|
420
|
-
return `${label} (${name}) is ${installation.status ?? "unknown"}/${installation.health ?? "unknown"}, expected UP_TO_DATE/HEALTHY.`;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
return null;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
function assertPlatformVariableGroup(
|
|
427
|
-
variableGroup: VariableGroup | null,
|
|
428
|
-
name: string,
|
|
429
|
-
label: string,
|
|
430
|
-
requiredVariables: readonly string[],
|
|
431
|
-
requiredSensitiveVariables: readonly string[],
|
|
432
|
-
): string | null {
|
|
433
|
-
if (variableGroup === null) {
|
|
434
|
-
return `${label} variable group (${name}) was not found in ${ENVIRONMENT}.`;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
const variables = new Map(
|
|
438
|
-
(variableGroup.variables ?? []).map((variable) => [variable.key, variable]),
|
|
439
|
-
);
|
|
440
|
-
const missing = requiredVariables.filter((key) => !variables.has(key));
|
|
441
|
-
if (missing.length > 0) {
|
|
442
|
-
return `${label} variable group (${name}) is missing ${missing.join(", ")}.`;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
const notSensitive = requiredSensitiveVariables.filter(
|
|
446
|
-
(key) => variables.get(key)?.sensitive !== true,
|
|
447
|
-
);
|
|
448
|
-
if (notSensitive.length > 0) {
|
|
449
|
-
return `${label} variable group (${name}) must mark ${notSensitive.join(", ")} as sensitive.`;
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
return null;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
function verifyExistingPlatform(monorepoRoot: string): void {
|
|
456
|
-
console.log(`Checking existing ${ENVIRONMENT} platform dependencies...`);
|
|
457
|
-
|
|
458
|
-
const installationFailures = REQUIRED_PLATFORM_INSTALLATIONS.map(
|
|
459
|
-
(dependency) =>
|
|
460
|
-
assertHealthyPlatformInstallation(
|
|
461
|
-
getInstallation(dependency.name, monorepoRoot),
|
|
462
|
-
dependency.name,
|
|
463
|
-
dependency.label,
|
|
464
|
-
),
|
|
465
|
-
).filter((failure): failure is string => failure !== null);
|
|
466
|
-
const variableGroupFailures = REQUIRED_PLATFORM_VARIABLE_GROUPS.map(
|
|
467
|
-
(dependency) =>
|
|
468
|
-
assertPlatformVariableGroup(
|
|
469
|
-
getVariableGroup(dependency.name, monorepoRoot),
|
|
470
|
-
dependency.name,
|
|
471
|
-
dependency.label,
|
|
472
|
-
dependency.requiredVariables,
|
|
473
|
-
dependency.requiredSensitiveVariables,
|
|
474
|
-
),
|
|
475
|
-
).filter((failure): failure is string => failure !== null);
|
|
476
|
-
const failures = [...installationFailures, ...variableGroupFailures];
|
|
477
|
-
|
|
478
|
-
if (failures.length > 0) {
|
|
479
|
-
throw new Error(
|
|
480
|
-
[
|
|
481
|
-
`Cannot deploy ${APP_NAME} into ${ENVIRONMENT} because required platform services are missing or unhealthy.`,
|
|
482
|
-
...failures,
|
|
483
|
-
"This helper performs the existing-environment deploy motion only. Stand up the platform services first, then rerun deploy:percepta-test.",
|
|
484
|
-
].join("\n"),
|
|
485
|
-
);
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
for (const dependency of REQUIRED_PLATFORM_INSTALLATIONS) {
|
|
489
|
-
console.log(`Found ${dependency.label}: ${dependency.name}`);
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
for (const dependency of REQUIRED_PLATFORM_VARIABLE_GROUPS) {
|
|
493
|
-
console.log(`Found ${dependency.label} variable group: ${dependency.name}`);
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
function upsertInstallation(
|
|
498
|
-
name: string,
|
|
499
|
-
manifestPath: string,
|
|
500
|
-
monorepoRoot: string,
|
|
501
|
-
expectedCommitSha: string,
|
|
502
|
-
manifestContents?: string,
|
|
503
|
-
): void {
|
|
504
|
-
const existingInstallation = getInstallation(name, monorepoRoot);
|
|
505
|
-
if (isHealthyAtCommit(existingInstallation, expectedCommitSha)) {
|
|
506
|
-
console.log(
|
|
507
|
-
`${name} is already healthy at ${expectedCommitSha.slice(0, 7)}; skipping installation replace.`,
|
|
508
|
-
);
|
|
509
|
-
return;
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
const action = existingInstallation == null ? "create" : "replace";
|
|
513
|
-
try {
|
|
514
|
-
if (existingInstallation == null) {
|
|
515
|
-
if (manifestContents == null) {
|
|
516
|
-
run("ryvn", ["create", "-f", manifestPath], monorepoRoot);
|
|
517
|
-
} else {
|
|
518
|
-
runWithInput(
|
|
519
|
-
"ryvn",
|
|
520
|
-
["create", "-f", "-"],
|
|
521
|
-
monorepoRoot,
|
|
522
|
-
manifestContents,
|
|
523
|
-
);
|
|
524
|
-
}
|
|
525
|
-
} else if (manifestContents == null) {
|
|
526
|
-
run("ryvn", ["replace", "-f", manifestPath], monorepoRoot);
|
|
527
|
-
} else {
|
|
528
|
-
runWithInput(
|
|
529
|
-
"ryvn",
|
|
530
|
-
["replace", "-f", "-"],
|
|
531
|
-
monorepoRoot,
|
|
532
|
-
manifestContents,
|
|
533
|
-
);
|
|
534
|
-
}
|
|
535
|
-
} catch (error) {
|
|
536
|
-
const currentInstallation = getInstallation(name, monorepoRoot);
|
|
537
|
-
if (isHealthyAtCommit(currentInstallation, expectedCommitSha)) {
|
|
538
|
-
console.log(
|
|
539
|
-
`${name} ${action} returned an error, but Ryvn now reports it healthy at ${expectedCommitSha.slice(0, 7)}; continuing.`,
|
|
540
|
-
);
|
|
541
|
-
return;
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
throw error;
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
function printFailedWorkflowLog(
|
|
549
|
-
repoSlug: string,
|
|
550
|
-
runId: number,
|
|
551
|
-
monorepoRoot: string,
|
|
552
|
-
): void {
|
|
553
|
-
try {
|
|
554
|
-
console.log("");
|
|
555
|
-
console.log(`Failed workflow log for ${runId}:`);
|
|
556
|
-
run(
|
|
557
|
-
"gh",
|
|
558
|
-
["run", "view", String(runId), "--repo", repoSlug, "--log-failed"],
|
|
559
|
-
monorepoRoot,
|
|
560
|
-
);
|
|
561
|
-
} catch {
|
|
562
|
-
console.log(`Could not fetch failed workflow logs for ${runId}.`);
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
async function ensureReleaseForCurrentCommit(
|
|
567
|
-
serviceName: string,
|
|
568
|
-
repoSlug: string,
|
|
569
|
-
workflowFile: string,
|
|
570
|
-
monorepoRoot: string,
|
|
571
|
-
options: Options,
|
|
572
|
-
commitSha: string,
|
|
573
|
-
): Promise<Release> {
|
|
574
|
-
const existingRelease = getReleaseForCommit(
|
|
575
|
-
serviceName,
|
|
576
|
-
commitSha,
|
|
577
|
-
monorepoRoot,
|
|
578
|
-
);
|
|
579
|
-
if (existingRelease != null) {
|
|
580
|
-
console.log(
|
|
581
|
-
`${serviceName} release ${existingRelease.version ?? "unknown"} already exists for ${commitSha.slice(0, 7)}.`,
|
|
582
|
-
);
|
|
583
|
-
return existingRelease;
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
await triggerWorkflow(
|
|
587
|
-
serviceName,
|
|
588
|
-
repoSlug,
|
|
589
|
-
workflowFile,
|
|
590
|
-
monorepoRoot,
|
|
591
|
-
options,
|
|
592
|
-
commitSha,
|
|
593
|
-
);
|
|
594
|
-
|
|
595
|
-
const release = getReleaseForCommit(serviceName, commitSha, monorepoRoot);
|
|
596
|
-
if (release == null) {
|
|
597
|
-
throw new Error(
|
|
598
|
-
`No Ryvn release for ${serviceName} at ${commitSha} after ${workflowFile}.`,
|
|
599
|
-
);
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
return release;
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
function assertReleaseExistsForSkippedWorkflow(
|
|
606
|
-
serviceName: string,
|
|
607
|
-
commitSha: string,
|
|
608
|
-
monorepoRoot: string,
|
|
609
|
-
): Release {
|
|
610
|
-
const release = getReleaseForCommit(serviceName, commitSha, monorepoRoot);
|
|
611
|
-
if (release == null) {
|
|
612
|
-
throw new Error(
|
|
613
|
-
`--skip-workflows was set, but no ${serviceName} Ryvn release exists for commit ${commitSha}. Rerun without --skip-workflows or confirm the release was created.`,
|
|
614
|
-
);
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
return release;
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
function logReleaseSummary(serviceName: string, release: Release): void {
|
|
621
|
-
console.log(
|
|
622
|
-
`${serviceName} release ready: ${release.version ?? "unknown version"}.`,
|
|
623
|
-
);
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
function requireReleaseForCurrentCommit(
|
|
627
|
-
serviceName: string,
|
|
628
|
-
repoSlug: string,
|
|
629
|
-
workflowFile: string,
|
|
630
|
-
monorepoRoot: string,
|
|
631
|
-
options: Options,
|
|
632
|
-
commitSha: string,
|
|
633
|
-
): Promise<Release> {
|
|
634
|
-
if (options.skipWorkflows) {
|
|
635
|
-
return Promise.resolve(
|
|
636
|
-
assertReleaseExistsForSkippedWorkflow(
|
|
637
|
-
serviceName,
|
|
638
|
-
commitSha,
|
|
639
|
-
monorepoRoot,
|
|
640
|
-
),
|
|
641
|
-
);
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
return ensureReleaseForCurrentCommit(
|
|
645
|
-
serviceName,
|
|
646
|
-
repoSlug,
|
|
647
|
-
workflowFile,
|
|
648
|
-
monorepoRoot,
|
|
649
|
-
options,
|
|
650
|
-
commitSha,
|
|
651
|
-
);
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
function isRetryableReleaseFailure(error: unknown): boolean {
|
|
655
|
-
return error instanceof Error;
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
async function triggerWorkflow(
|
|
659
|
-
serviceName: string,
|
|
660
|
-
repoSlug: string,
|
|
661
|
-
workflowFile: string,
|
|
662
|
-
monorepoRoot: string,
|
|
663
|
-
options: Options,
|
|
664
|
-
commitSha: string,
|
|
665
|
-
): Promise<void> {
|
|
666
|
-
if (options.skipWorkflows) return;
|
|
667
|
-
|
|
668
|
-
console.log(`Running ${workflowFile}...`);
|
|
669
|
-
run(
|
|
670
|
-
"gh",
|
|
671
|
-
["workflow", "run", workflowFile, "--repo", repoSlug, "--ref", options.ref],
|
|
672
|
-
monorepoRoot,
|
|
673
|
-
);
|
|
674
|
-
await sleep(3_000);
|
|
675
|
-
|
|
676
|
-
let runs = runJson<WorkflowRun[]>(
|
|
677
|
-
"gh",
|
|
678
|
-
[
|
|
679
|
-
"run",
|
|
680
|
-
"list",
|
|
681
|
-
"--repo",
|
|
682
|
-
repoSlug,
|
|
683
|
-
"--workflow",
|
|
684
|
-
workflowFile,
|
|
685
|
-
"--branch",
|
|
686
|
-
options.ref,
|
|
687
|
-
"--event",
|
|
688
|
-
"workflow_dispatch",
|
|
689
|
-
"--limit",
|
|
690
|
-
"1",
|
|
691
|
-
"--json",
|
|
692
|
-
"databaseId,status,conclusion,url",
|
|
693
|
-
],
|
|
694
|
-
monorepoRoot,
|
|
695
|
-
);
|
|
696
|
-
|
|
697
|
-
if (runs.length === 0) {
|
|
698
|
-
runs = runJson<WorkflowRun[]>(
|
|
699
|
-
"gh",
|
|
700
|
-
[
|
|
701
|
-
"run",
|
|
702
|
-
"list",
|
|
703
|
-
"--repo",
|
|
704
|
-
repoSlug,
|
|
705
|
-
"--workflow",
|
|
706
|
-
workflowFile,
|
|
707
|
-
"--branch",
|
|
708
|
-
options.ref,
|
|
709
|
-
"--limit",
|
|
710
|
-
"1",
|
|
711
|
-
"--json",
|
|
712
|
-
"databaseId,status,conclusion,url",
|
|
713
|
-
],
|
|
714
|
-
monorepoRoot,
|
|
715
|
-
);
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
const runInfo = runs.at(0);
|
|
719
|
-
if (runInfo === undefined) {
|
|
720
|
-
throw new Error(`Could not find a GitHub Actions run for ${workflowFile}`);
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
console.log(`Watching ${runInfo.url}`);
|
|
724
|
-
try {
|
|
725
|
-
run(
|
|
726
|
-
"gh",
|
|
727
|
-
[
|
|
728
|
-
"run",
|
|
729
|
-
"watch",
|
|
730
|
-
String(runInfo.databaseId),
|
|
731
|
-
"--repo",
|
|
732
|
-
repoSlug,
|
|
733
|
-
"--exit-status",
|
|
734
|
-
],
|
|
735
|
-
monorepoRoot,
|
|
736
|
-
);
|
|
737
|
-
} catch (error) {
|
|
738
|
-
printFailedWorkflowLog(repoSlug, runInfo.databaseId, monorepoRoot);
|
|
739
|
-
|
|
740
|
-
const release = getReleaseForCommit(serviceName, commitSha, monorepoRoot);
|
|
741
|
-
if (release != null && isRetryableReleaseFailure(error)) {
|
|
742
|
-
console.log(
|
|
743
|
-
`${workflowFile} reported a failure, but ${serviceName} release ${release.version ?? "unknown"} exists for ${commitSha.slice(0, 7)}; continuing.`,
|
|
744
|
-
);
|
|
745
|
-
return;
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
throw error;
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
function latestTask(tasks: InstallationTask[]): InstallationTask | null {
|
|
753
|
-
return (
|
|
754
|
-
[...tasks].sort(
|
|
755
|
-
(a, b) =>
|
|
756
|
-
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
|
757
|
-
)[0] ?? null
|
|
758
|
-
);
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
async function waitForTerraformApply(
|
|
762
|
-
monorepoRoot: string,
|
|
763
|
-
options: Options,
|
|
764
|
-
): Promise<void> {
|
|
765
|
-
const deadline = Date.now() + options.timeoutMinutes * 60_000;
|
|
766
|
-
const approved = new Set<string>();
|
|
767
|
-
|
|
768
|
-
while (Date.now() < deadline) {
|
|
769
|
-
const tasks = runJson<InstallationTask[]>(
|
|
770
|
-
"ryvn",
|
|
771
|
-
[
|
|
772
|
-
"get",
|
|
773
|
-
"installation-task",
|
|
774
|
-
TERRAFORM_SERVICE_NAME,
|
|
775
|
-
"-e",
|
|
776
|
-
ENVIRONMENT,
|
|
777
|
-
"-o",
|
|
778
|
-
"json",
|
|
779
|
-
],
|
|
780
|
-
monorepoRoot,
|
|
781
|
-
);
|
|
782
|
-
const newest = latestTask(tasks);
|
|
783
|
-
const failed = tasks.find((task) => task.status === "FAILED");
|
|
784
|
-
if (failed) {
|
|
785
|
-
throw new Error(
|
|
786
|
-
`${failed.description ?? failed.taskType} failed for ${TERRAFORM_SERVICE_NAME}.`,
|
|
787
|
-
);
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
const approvablePlan = tasks.find(
|
|
791
|
-
(task) =>
|
|
792
|
-
task.isApprovable &&
|
|
793
|
-
task.taskType === "terraformPlanV1" &&
|
|
794
|
-
!approved.has(task.associatedTaskId),
|
|
795
|
-
);
|
|
796
|
-
if (approvablePlan) {
|
|
797
|
-
await confirmOrThrow(
|
|
798
|
-
`Approve Terraform plan for ${TERRAFORM_SERVICE_NAME}?`,
|
|
799
|
-
options,
|
|
800
|
-
);
|
|
801
|
-
run(
|
|
802
|
-
"ryvn",
|
|
803
|
-
[
|
|
804
|
-
"task",
|
|
805
|
-
"approve",
|
|
806
|
-
approvablePlan.associatedTaskId,
|
|
807
|
-
"--reason",
|
|
808
|
-
`Deploy ${APP_NAME} schema to ${ENVIRONMENT}`,
|
|
809
|
-
],
|
|
810
|
-
monorepoRoot,
|
|
811
|
-
);
|
|
812
|
-
approved.add(approvablePlan.associatedTaskId);
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
if (
|
|
816
|
-
newest?.status === "COMPLETED" &&
|
|
817
|
-
newest.taskType === "terraformApplyV1"
|
|
818
|
-
) {
|
|
819
|
-
console.log(`${TERRAFORM_SERVICE_NAME} is applied.`);
|
|
820
|
-
return;
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
await sleep(5_000);
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
throw new Error(
|
|
827
|
-
`${TERRAFORM_SERVICE_NAME} did not finish within ${options.timeoutMinutes} minutes.`,
|
|
828
|
-
);
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
function parseEnvFile(
|
|
832
|
-
contents: string,
|
|
833
|
-
): Array<{ name: string; value: string }> {
|
|
834
|
-
return contents
|
|
835
|
-
.split("\n")
|
|
836
|
-
.map((line) => line.trim())
|
|
837
|
-
.filter((line) => line.length > 0 && !line.startsWith("#"))
|
|
838
|
-
.map((line) => {
|
|
839
|
-
const equalsIndex = line.indexOf("=");
|
|
840
|
-
if (equalsIndex === -1) return null;
|
|
841
|
-
const name = line.slice(0, equalsIndex).trim();
|
|
842
|
-
let value = line.slice(equalsIndex + 1).trim();
|
|
843
|
-
if (
|
|
844
|
-
(value.startsWith('"') && value.endsWith('"')) ||
|
|
845
|
-
(value.startsWith("'") && value.endsWith("'"))
|
|
846
|
-
) {
|
|
847
|
-
value = value.slice(1, -1);
|
|
848
|
-
}
|
|
849
|
-
return name ? { name, value } : null;
|
|
850
|
-
})
|
|
851
|
-
.filter(
|
|
852
|
-
(entry): entry is { name: string; value: string } => entry !== null,
|
|
853
|
-
);
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
async function readInstallationSecrets(
|
|
857
|
-
packageDir: string,
|
|
858
|
-
options: Options,
|
|
859
|
-
): Promise<Array<{ name: string; value: string }>> {
|
|
860
|
-
if (options.skipSecrets) return [];
|
|
861
|
-
|
|
862
|
-
const secretsPath = path.join(
|
|
863
|
-
packageDir,
|
|
864
|
-
"deploy",
|
|
865
|
-
"ryvn",
|
|
866
|
-
`${ENVIRONMENT}.secrets.env`,
|
|
867
|
-
);
|
|
868
|
-
if (!existsSync(secretsPath)) {
|
|
869
|
-
console.log(
|
|
870
|
-
`No secrets file found at ${secretsPath}; skipping secret patch.`,
|
|
871
|
-
);
|
|
872
|
-
return [];
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
return parseEnvFile(await readFile(secretsPath, "utf8"));
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
function appendInstallationSecrets(
|
|
879
|
-
manifestContents: string,
|
|
880
|
-
secrets: Array<{ name: string; value: string }>,
|
|
881
|
-
): string {
|
|
882
|
-
if (secrets.length === 0) return manifestContents;
|
|
883
|
-
|
|
884
|
-
const marker = " variableGroups:\n";
|
|
885
|
-
if (!manifestContents.includes(marker)) {
|
|
886
|
-
throw new Error("Installation manifest is missing variableGroups marker.");
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
const secretYaml = [
|
|
890
|
-
" secrets:",
|
|
891
|
-
...secrets.flatMap(({ name, value }) => [
|
|
892
|
-
` - name: ${name}`,
|
|
893
|
-
` value: ${JSON.stringify(value)}`,
|
|
894
|
-
]),
|
|
895
|
-
"",
|
|
896
|
-
].join("\n");
|
|
897
|
-
|
|
898
|
-
return manifestContents.replace(marker, `${secretYaml}${marker}`);
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
function patchExistingInstallationSecrets(
|
|
902
|
-
secrets: Array<{ name: string; value: string }>,
|
|
903
|
-
monorepoRoot: string,
|
|
904
|
-
): void {
|
|
905
|
-
if (secrets.length === 0) return;
|
|
906
|
-
|
|
907
|
-
const patch = JSON.stringify({ spec: { secrets } });
|
|
908
|
-
const result = spawnSync(
|
|
909
|
-
"ryvn",
|
|
910
|
-
[
|
|
911
|
-
"update",
|
|
912
|
-
"installation",
|
|
913
|
-
APP_NAME,
|
|
914
|
-
"-e",
|
|
915
|
-
ENVIRONMENT,
|
|
916
|
-
"--type",
|
|
917
|
-
"strategic",
|
|
918
|
-
"--patch-file",
|
|
919
|
-
"-",
|
|
920
|
-
],
|
|
921
|
-
{
|
|
922
|
-
cwd: monorepoRoot,
|
|
923
|
-
input: patch,
|
|
924
|
-
stdio: ["pipe", "inherit", "inherit"],
|
|
925
|
-
encoding: "utf8",
|
|
926
|
-
},
|
|
927
|
-
);
|
|
928
|
-
|
|
929
|
-
if (result.status !== 0) {
|
|
930
|
-
throw new Error(`Failed to patch generated app secrets for ${APP_NAME}.`);
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
async function waitForHealthy(
|
|
935
|
-
monorepoRoot: string,
|
|
936
|
-
options: Options,
|
|
937
|
-
): Promise<void> {
|
|
938
|
-
const deadline = Date.now() + options.timeoutMinutes * 60_000;
|
|
939
|
-
|
|
940
|
-
while (Date.now() < deadline) {
|
|
941
|
-
const installation = runJson<Installation>(
|
|
942
|
-
"ryvn",
|
|
943
|
-
["get", "installation", APP_NAME, "-e", ENVIRONMENT, "-o", "json"],
|
|
944
|
-
monorepoRoot,
|
|
945
|
-
);
|
|
946
|
-
|
|
947
|
-
if (
|
|
948
|
-
installation.status === "UP_TO_DATE" &&
|
|
949
|
-
installation.health === "HEALTHY"
|
|
950
|
-
) {
|
|
951
|
-
console.log(`${APP_NAME} is healthy.`);
|
|
952
|
-
return;
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
console.log(
|
|
956
|
-
`${APP_NAME} status: ${installation.status ?? "unknown"}, health: ${installation.health ?? "unknown"}`,
|
|
957
|
-
);
|
|
958
|
-
await sleep(10_000);
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
throw new Error(
|
|
962
|
-
`${APP_NAME} did not become healthy within ${options.timeoutMinutes} minutes.`,
|
|
963
|
-
);
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
async function fetchDeployUrl(
|
|
967
|
-
pathname: string,
|
|
968
|
-
init: RequestInit = {},
|
|
969
|
-
): Promise<Response> {
|
|
970
|
-
const controller = new AbortController();
|
|
971
|
-
const timeout = setTimeout(() => controller.abort(), 10_000);
|
|
972
|
-
try {
|
|
973
|
-
return await fetch(`${DEPLOY_URL}${pathname}`, {
|
|
974
|
-
...init,
|
|
975
|
-
signal: controller.signal,
|
|
976
|
-
});
|
|
977
|
-
} finally {
|
|
978
|
-
clearTimeout(timeout);
|
|
979
|
-
}
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
async function verifyEndpoint(pathname: string): Promise<void> {
|
|
983
|
-
const response = await fetchDeployUrl(pathname);
|
|
984
|
-
if (!response.ok) {
|
|
985
|
-
throw new Error(`${pathname} returned HTTP ${response.status}`);
|
|
986
|
-
}
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
async function verifyAppRoute(): Promise<void> {
|
|
990
|
-
const response = await fetchDeployUrl("/", { redirect: "manual" });
|
|
991
|
-
if (response.ok) return;
|
|
992
|
-
|
|
993
|
-
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
994
|
-
const location = response.headers.get("location");
|
|
995
|
-
let redirectPathname: string | null = null;
|
|
996
|
-
if (location) {
|
|
997
|
-
try {
|
|
998
|
-
redirectPathname = new URL(location, DEPLOY_URL).pathname;
|
|
999
|
-
} catch {
|
|
1000
|
-
redirectPathname = null;
|
|
1001
|
-
}
|
|
1002
|
-
}
|
|
1003
|
-
if (redirectPathname === "/auth/signin") return;
|
|
1004
|
-
|
|
1005
|
-
throw new Error(
|
|
1006
|
-
`/ redirected to ${location ?? "an empty location"} instead of /auth/signin`,
|
|
1007
|
-
);
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
throw new Error(`/ returned HTTP ${response.status}`);
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
async function verifyDeployment(): Promise<void> {
|
|
1014
|
-
await verifyEndpoint("/api/healthz");
|
|
1015
|
-
await verifyEndpoint("/api/readyz");
|
|
1016
|
-
await verifyAppRoute();
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
async function main(): Promise<void> {
|
|
1020
|
-
const options = parseArgs(process.argv.slice(2));
|
|
1021
|
-
assertCommand("git");
|
|
1022
|
-
assertCommand("gh");
|
|
1023
|
-
assertCommand("ryvn");
|
|
1024
|
-
|
|
1025
|
-
const packageDir = path.resolve(
|
|
1026
|
-
path.dirname(fileURLToPath(import.meta.url)),
|
|
1027
|
-
"..",
|
|
1028
|
-
);
|
|
1029
|
-
const monorepoRoot = findUp(packageDir, "pnpm-workspace.yaml") ?? packageDir;
|
|
1030
|
-
verifyExistingPlatform(monorepoRoot);
|
|
1031
|
-
|
|
1032
|
-
const repoSlug = await ensureGitHubRepo(monorepoRoot, options);
|
|
1033
|
-
ensureCleanAndPushed(monorepoRoot, options);
|
|
1034
|
-
const commitSha = getCurrentCommitSha(monorepoRoot);
|
|
1035
|
-
|
|
1036
|
-
const ryvnDir = path.join(packageDir, "deploy", "ryvn");
|
|
1037
|
-
const serviceManifest = path.join(ryvnDir, `${APP_NAME}.service.yaml`);
|
|
1038
|
-
const terraformServiceManifest = path.join(
|
|
1039
|
-
ryvnDir,
|
|
1040
|
-
`${TERRAFORM_SERVICE_NAME}.service.yaml`,
|
|
1041
|
-
);
|
|
1042
|
-
const installationManifest = path.join(
|
|
1043
|
-
ryvnDir,
|
|
1044
|
-
"environments",
|
|
1045
|
-
ENVIRONMENT,
|
|
1046
|
-
"installations",
|
|
1047
|
-
`${APP_NAME}.env.${ENVIRONMENT}.serviceinstallation.yaml`,
|
|
1048
|
-
);
|
|
1049
|
-
const terraformInstallationManifest = path.join(
|
|
1050
|
-
ryvnDir,
|
|
1051
|
-
"environments",
|
|
1052
|
-
ENVIRONMENT,
|
|
1053
|
-
"installations",
|
|
1054
|
-
`${TERRAFORM_SERVICE_NAME}.env.${ENVIRONMENT}.serviceinstallation.yaml`,
|
|
1055
|
-
);
|
|
1056
|
-
|
|
1057
|
-
console.log(`Creating/updating Ryvn services in ${ENVIRONMENT}...`);
|
|
1058
|
-
upsertService(terraformServiceManifest, monorepoRoot);
|
|
1059
|
-
upsertService(serviceManifest, monorepoRoot);
|
|
1060
|
-
|
|
1061
|
-
const terraformRelease = await requireReleaseForCurrentCommit(
|
|
1062
|
-
TERRAFORM_SERVICE_NAME,
|
|
1063
|
-
repoSlug,
|
|
1064
|
-
`${TERRAFORM_SERVICE_NAME}-ryvn-release.yaml`,
|
|
1065
|
-
monorepoRoot,
|
|
1066
|
-
options,
|
|
1067
|
-
commitSha,
|
|
1068
|
-
);
|
|
1069
|
-
logReleaseSummary(TERRAFORM_SERVICE_NAME, terraformRelease);
|
|
1070
|
-
upsertInstallation(
|
|
1071
|
-
TERRAFORM_SERVICE_NAME,
|
|
1072
|
-
terraformInstallationManifest,
|
|
1073
|
-
monorepoRoot,
|
|
1074
|
-
commitSha,
|
|
1075
|
-
);
|
|
1076
|
-
await waitForTerraformApply(monorepoRoot, options);
|
|
1077
|
-
|
|
1078
|
-
const appRelease = await requireReleaseForCurrentCommit(
|
|
1079
|
-
APP_NAME,
|
|
1080
|
-
repoSlug,
|
|
1081
|
-
`${APP_NAME}-ryvn-release.yaml`,
|
|
1082
|
-
monorepoRoot,
|
|
1083
|
-
options,
|
|
1084
|
-
commitSha,
|
|
1085
|
-
);
|
|
1086
|
-
logReleaseSummary(APP_NAME, appRelease);
|
|
1087
|
-
const secrets = await readInstallationSecrets(packageDir, options);
|
|
1088
|
-
if (installationExists(APP_NAME, monorepoRoot)) {
|
|
1089
|
-
patchExistingInstallationSecrets(secrets, monorepoRoot);
|
|
1090
|
-
}
|
|
1091
|
-
upsertInstallation(
|
|
1092
|
-
APP_NAME,
|
|
1093
|
-
installationManifest,
|
|
1094
|
-
monorepoRoot,
|
|
1095
|
-
commitSha,
|
|
1096
|
-
appendInstallationSecrets(
|
|
1097
|
-
await readFile(installationManifest, "utf8"),
|
|
1098
|
-
secrets,
|
|
1099
|
-
),
|
|
1100
|
-
);
|
|
1101
|
-
await waitForHealthy(monorepoRoot, options);
|
|
1102
|
-
|
|
1103
|
-
await verifyDeployment();
|
|
1104
|
-
|
|
1105
|
-
console.log("");
|
|
1106
|
-
console.log(`Deployed ${APP_NAME}: ${DEPLOY_URL}`);
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
void main().catch((error) => {
|
|
1110
|
-
console.error(error instanceof Error ? error.message : error);
|
|
1111
|
-
process.exit(1);
|
|
1112
|
-
});
|