@percepta/create 3.1.0 → 3.1.3
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/README.md +15 -10
- package/dist/{chunk-7NPWSTCY.js → chunk-CO3YWUD6.js} +31 -2
- package/dist/{chunk-WMJT7CB5.js → chunk-V5EJIUBJ.js} +5 -2
- package/dist/index.js +93 -73
- package/dist/{init-NP6GRXLL.js → init-EQZ2TCSJ.js} +2 -2
- package/dist/{status-BTHGN6QH.js → status-QW5TQDYY.js} +1 -1
- package/dist/{sync-3Q27L7XZ.js → sync-RLBZDOFB.js} +1 -1
- package/dist/{upstream-C5KFAHVR.js → upstream-TQFVPMEG.js} +1 -1
- package/package.json +3 -2
- package/templates/monorepo/.dockerignore +18 -0
- package/templates/monorepo/gitignore.template +1 -0
- package/templates/webapp/.github/workflows/__APP_NAME__-ryvn-release.yaml +6 -2
- package/templates/webapp/.github/workflows/__APP_NAME__-terraform-ryvn-release.yaml +98 -0
- package/templates/webapp/AGENTS.md +17 -5
- package/templates/webapp/Dockerfile +16 -7
- package/templates/webapp/README.md +64 -2
- package/templates/webapp/agent-skills/deploy.md +50 -51
- package/templates/webapp/agent-skills/inngest.md +4 -4
- package/templates/webapp/agent-skills/langfuse.md +15 -14
- package/templates/webapp/agent-skills/llm.md +59 -0
- package/templates/webapp/agent-skills/oneshot.md +14 -1
- package/templates/webapp/agent-skills/ryvn.md +1 -1
- package/templates/webapp/deploy/README.md +41 -16
- package/templates/webapp/deploy/ryvn/__APP_NAME__-terraform.service.yaml +10 -0
- package/templates/webapp/deploy/ryvn/__APP_NAME__.service.yaml +2 -2
- package/templates/webapp/deploy/ryvn/environments/percepta-test/installations/__APP_NAME__-terraform.env.percepta-test.serviceinstallation.yaml +11 -0
- package/templates/webapp/deploy/ryvn/environments/percepta-test/installations/__APP_NAME__.env.percepta-test.serviceinstallation.yaml +60 -11
- package/templates/webapp/env.example.template +20 -2
- package/templates/webapp/eslint.config.mjs +7 -0
- package/templates/webapp/gitignore.template +1 -0
- package/templates/webapp/next.config.ts +9 -0
- package/templates/webapp/package.json.template +6 -2
- package/templates/webapp/scripts/deploy-percepta-test.ts +837 -0
- package/templates/webapp/scripts/migrate.ts +3 -0
- package/templates/webapp/scripts/open-ryvn-deploy-pr.ts +152 -32
- package/templates/webapp/scripts/seed.ts +1 -1
- package/templates/webapp/scripts/setup-database.ts +2 -1
- package/templates/webapp/scripts/start.sh +3 -2
- package/templates/webapp/scripts/with-local-env.ts +75 -0
- package/templates/webapp/src/app/(app)/layout.tsx +1 -5
- package/templates/webapp/src/app/(auth)/auth/signin/CredentialsSignInForm.tsx +11 -1
- package/templates/webapp/src/app/(auth)/auth/signup/CredentialsSignUpForm.tsx +113 -0
- package/templates/webapp/src/app/(auth)/auth/signup/page.tsx +30 -0
- package/templates/webapp/src/app/global-error.tsx +1 -1
- package/templates/webapp/src/components/FaroProvider.tsx +2 -4
- package/templates/webapp/src/components/form/FormItem.tsx +2 -2
- package/templates/webapp/src/config/getEnvConfig.ts +14 -0
- package/templates/webapp/src/drizzle/db.ts +2 -1
- package/templates/webapp/src/drizzle/migrations/0000_eager_grandmaster.sql +3 -3
- package/templates/webapp/src/drizzle/migrations/meta/0000_snapshot.json +7 -19
- package/templates/webapp/src/drizzle/ssl.ts +5 -0
- package/templates/webapp/src/instrumentation.ts +102 -10
- package/templates/webapp/src/lib/auth/index.ts +1 -1
- package/templates/webapp/src/lib/auth-client.ts +1 -1
- package/templates/webapp/src/services/llm/LLMService.ts +88 -0
- package/templates/webapp/src/services/llm/LlmProviderService.ts +85 -0
- package/templates/webapp/src/services/observability/initFaro.ts +1 -1
- package/templates/webapp/terraform/schema/main.tf +4 -0
- package/templates/webapp/terraform/schema/outputs.tf +9 -0
- package/templates/webapp/terraform/schema/variables.tf +19 -0
- package/templates/webapp/terraform/schema/versions.tf +38 -0
- package/templates/webapp/.github/workflows/__APP_NAME__-terraform.yml +0 -28
|
@@ -0,0 +1,837 @@
|
|
|
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
|
+
}
|
|
85
|
+
|
|
86
|
+
interface VariableGroup {
|
|
87
|
+
name?: string;
|
|
88
|
+
variables?: Array<{
|
|
89
|
+
key: string;
|
|
90
|
+
sensitive?: boolean;
|
|
91
|
+
}>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseArgs(argv: string[]): Options {
|
|
95
|
+
const options: Options = {
|
|
96
|
+
yes: false,
|
|
97
|
+
skipRepo: false,
|
|
98
|
+
skipPush: false,
|
|
99
|
+
skipWorkflows: false,
|
|
100
|
+
skipSecrets: false,
|
|
101
|
+
ref: "main",
|
|
102
|
+
timeoutMinutes: DEFAULT_TIMEOUT_MINUTES,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
for (let index = 0; index < argv.length; index++) {
|
|
106
|
+
const arg = argv[index];
|
|
107
|
+
if (arg === "--") {
|
|
108
|
+
continue;
|
|
109
|
+
} else if (arg === "--yes" || arg === "-y") {
|
|
110
|
+
options.yes = true;
|
|
111
|
+
} else if (arg === "--skip-repo") {
|
|
112
|
+
options.skipRepo = true;
|
|
113
|
+
} else if (arg === "--skip-push") {
|
|
114
|
+
options.skipPush = true;
|
|
115
|
+
} else if (arg === "--skip-workflows") {
|
|
116
|
+
options.skipWorkflows = true;
|
|
117
|
+
} else if (arg === "--skip-secrets") {
|
|
118
|
+
options.skipSecrets = true;
|
|
119
|
+
} else if (arg === "--ref") {
|
|
120
|
+
const value = argv[index + 1];
|
|
121
|
+
if (!value) throw new Error("--ref requires a branch or ref");
|
|
122
|
+
options.ref = value;
|
|
123
|
+
index++;
|
|
124
|
+
} else if (arg === "--timeout-minutes") {
|
|
125
|
+
const value = Number(argv[index + 1]);
|
|
126
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
127
|
+
throw new Error("--timeout-minutes requires a positive number");
|
|
128
|
+
}
|
|
129
|
+
options.timeoutMinutes = value;
|
|
130
|
+
index++;
|
|
131
|
+
} else {
|
|
132
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return options;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function run(
|
|
140
|
+
command: string,
|
|
141
|
+
args: string[],
|
|
142
|
+
cwd: string,
|
|
143
|
+
stdio: "inherit" | "pipe" = "inherit",
|
|
144
|
+
): string {
|
|
145
|
+
const result = execFileSync(command, args, {
|
|
146
|
+
cwd,
|
|
147
|
+
encoding: "utf8",
|
|
148
|
+
stdio,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return typeof result === "string" ? result.trim() : "";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function runJson<T>(command: string, args: string[], cwd: string): T {
|
|
155
|
+
const outputText = run(command, args, cwd, "pipe");
|
|
156
|
+
return JSON.parse(outputText) as T;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function assertCommand(command: string): void {
|
|
160
|
+
try {
|
|
161
|
+
run(command, ["--version"], process.cwd(), "pipe");
|
|
162
|
+
} catch {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`Required command not found or not authenticated: ${command}`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function findUp(startDir: string, fileName: string): string | null {
|
|
170
|
+
let dir = startDir;
|
|
171
|
+
while (true) {
|
|
172
|
+
if (existsSync(path.join(dir, fileName))) return dir;
|
|
173
|
+
const parent = path.dirname(dir);
|
|
174
|
+
if (parent === dir) return null;
|
|
175
|
+
dir = parent;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function parseGitHubRepo(remoteUrl: string): string | null {
|
|
180
|
+
const match = remoteUrl.match(
|
|
181
|
+
/github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?$/,
|
|
182
|
+
);
|
|
183
|
+
return match?.[1] ?? null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function getRemoteRepoSlug(monorepoRoot: string): string | null {
|
|
187
|
+
try {
|
|
188
|
+
return parseGitHubRepo(
|
|
189
|
+
run("git", ["remote", "get-url", "origin"], monorepoRoot, "pipe"),
|
|
190
|
+
);
|
|
191
|
+
} catch {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function confirm(message: string): Promise<boolean> {
|
|
197
|
+
const rl = createInterface({ input, output });
|
|
198
|
+
try {
|
|
199
|
+
const answer = (await rl.question(`${message} [Y/n] `))
|
|
200
|
+
.trim()
|
|
201
|
+
.toLowerCase();
|
|
202
|
+
return answer === "" || answer === "y" || answer === "yes";
|
|
203
|
+
} finally {
|
|
204
|
+
rl.close();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function confirmOrThrow(
|
|
209
|
+
message: string,
|
|
210
|
+
options: Options,
|
|
211
|
+
): Promise<void> {
|
|
212
|
+
if (options.yes) return;
|
|
213
|
+
if (!process.stdin.isTTY) {
|
|
214
|
+
throw new Error(`${message} Re-run with --yes to confirm.`);
|
|
215
|
+
}
|
|
216
|
+
if (!(await confirm(message))) {
|
|
217
|
+
throw new Error("Deployment cancelled.");
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function sleep(ms: number): Promise<void> {
|
|
222
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function ensureGitHubRepo(
|
|
226
|
+
monorepoRoot: string,
|
|
227
|
+
options: Options,
|
|
228
|
+
): Promise<string> {
|
|
229
|
+
const remoteRepoSlug = getRemoteRepoSlug(monorepoRoot);
|
|
230
|
+
const repoSlug = remoteRepoSlug ?? REPO_SLUG;
|
|
231
|
+
|
|
232
|
+
if (remoteRepoSlug && remoteRepoSlug !== REPO_SLUG) {
|
|
233
|
+
console.log(`Using GitHub repo from origin remote: ${remoteRepoSlug}`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (options.skipRepo) return repoSlug;
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
run("gh", ["repo", "view", repoSlug], monorepoRoot, "pipe");
|
|
240
|
+
} catch {
|
|
241
|
+
await confirmOrThrow(
|
|
242
|
+
`GitHub repo ${repoSlug} was not found. Create it and push this repo?`,
|
|
243
|
+
options,
|
|
244
|
+
);
|
|
245
|
+
run(
|
|
246
|
+
"gh",
|
|
247
|
+
[
|
|
248
|
+
"repo",
|
|
249
|
+
"create",
|
|
250
|
+
repoSlug,
|
|
251
|
+
"--private",
|
|
252
|
+
"--source",
|
|
253
|
+
monorepoRoot,
|
|
254
|
+
"--remote",
|
|
255
|
+
"origin",
|
|
256
|
+
"--push",
|
|
257
|
+
],
|
|
258
|
+
monorepoRoot,
|
|
259
|
+
);
|
|
260
|
+
return repoSlug;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (!remoteRepoSlug) {
|
|
264
|
+
run(
|
|
265
|
+
"git",
|
|
266
|
+
["remote", "add", "origin", `git@github.com:${repoSlug}.git`],
|
|
267
|
+
monorepoRoot,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return repoSlug;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function ensureCleanAndPushed(monorepoRoot: string, options: Options): void {
|
|
275
|
+
if (options.skipPush) return;
|
|
276
|
+
|
|
277
|
+
const status = run("git", ["status", "--porcelain"], monorepoRoot, "pipe");
|
|
278
|
+
if (status.length > 0) {
|
|
279
|
+
throw new Error(
|
|
280
|
+
[
|
|
281
|
+
"The git worktree has uncommitted changes.",
|
|
282
|
+
"Commit the app before deploying so GitHub Actions builds the exact code you expect.",
|
|
283
|
+
status,
|
|
284
|
+
].join("\n"),
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const branch = run("git", ["branch", "--show-current"], monorepoRoot, "pipe");
|
|
289
|
+
if (!branch) {
|
|
290
|
+
throw new Error("Could not determine the current git branch.");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
run(
|
|
294
|
+
"git",
|
|
295
|
+
["push", "-u", "origin", `${branch}:${options.ref}`],
|
|
296
|
+
monorepoRoot,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function upsertService(manifestPath: string, monorepoRoot: string): void {
|
|
301
|
+
run("ryvn", ["replace", "-f", manifestPath], monorepoRoot);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function getInstallation(
|
|
305
|
+
name: string,
|
|
306
|
+
monorepoRoot: string,
|
|
307
|
+
): Installation | null {
|
|
308
|
+
try {
|
|
309
|
+
return runJson<Installation>(
|
|
310
|
+
"ryvn",
|
|
311
|
+
["get", "installation", name, "-e", ENVIRONMENT, "-o", "json"],
|
|
312
|
+
monorepoRoot,
|
|
313
|
+
);
|
|
314
|
+
} catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function getVariableGroup(
|
|
320
|
+
name: string,
|
|
321
|
+
monorepoRoot: string,
|
|
322
|
+
): VariableGroup | null {
|
|
323
|
+
try {
|
|
324
|
+
return runJson<VariableGroup>(
|
|
325
|
+
"ryvn",
|
|
326
|
+
["get", "variable-group", name, "-e", ENVIRONMENT, "-o", "json"],
|
|
327
|
+
monorepoRoot,
|
|
328
|
+
);
|
|
329
|
+
} catch {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function installationExists(name: string, monorepoRoot: string): boolean {
|
|
335
|
+
return getInstallation(name, monorepoRoot) !== null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function assertHealthyPlatformInstallation(
|
|
339
|
+
installation: Installation | null,
|
|
340
|
+
name: string,
|
|
341
|
+
label: string,
|
|
342
|
+
): string | null {
|
|
343
|
+
if (installation === null) {
|
|
344
|
+
return `${label} (${name}) was not found in ${ENVIRONMENT}.`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (
|
|
348
|
+
installation.status !== "UP_TO_DATE" ||
|
|
349
|
+
installation.health !== "HEALTHY"
|
|
350
|
+
) {
|
|
351
|
+
return `${label} (${name}) is ${installation.status ?? "unknown"}/${installation.health ?? "unknown"}, expected UP_TO_DATE/HEALTHY.`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function assertPlatformVariableGroup(
|
|
358
|
+
variableGroup: VariableGroup | null,
|
|
359
|
+
name: string,
|
|
360
|
+
label: string,
|
|
361
|
+
requiredVariables: readonly string[],
|
|
362
|
+
requiredSensitiveVariables: readonly string[],
|
|
363
|
+
): string | null {
|
|
364
|
+
if (variableGroup === null) {
|
|
365
|
+
return `${label} variable group (${name}) was not found in ${ENVIRONMENT}.`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const variables = new Map(
|
|
369
|
+
(variableGroup.variables ?? []).map((variable) => [variable.key, variable]),
|
|
370
|
+
);
|
|
371
|
+
const missing = requiredVariables.filter((key) => !variables.has(key));
|
|
372
|
+
if (missing.length > 0) {
|
|
373
|
+
return `${label} variable group (${name}) is missing ${missing.join(", ")}.`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const notSensitive = requiredSensitiveVariables.filter(
|
|
377
|
+
(key) => variables.get(key)?.sensitive !== true,
|
|
378
|
+
);
|
|
379
|
+
if (notSensitive.length > 0) {
|
|
380
|
+
return `${label} variable group (${name}) must mark ${notSensitive.join(", ")} as sensitive.`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function verifyExistingPlatform(monorepoRoot: string): void {
|
|
387
|
+
console.log(`Checking existing ${ENVIRONMENT} platform dependencies...`);
|
|
388
|
+
|
|
389
|
+
const installationFailures = REQUIRED_PLATFORM_INSTALLATIONS.map(
|
|
390
|
+
(dependency) =>
|
|
391
|
+
assertHealthyPlatformInstallation(
|
|
392
|
+
getInstallation(dependency.name, monorepoRoot),
|
|
393
|
+
dependency.name,
|
|
394
|
+
dependency.label,
|
|
395
|
+
),
|
|
396
|
+
).filter((failure): failure is string => failure !== null);
|
|
397
|
+
const variableGroupFailures = REQUIRED_PLATFORM_VARIABLE_GROUPS.map(
|
|
398
|
+
(dependency) =>
|
|
399
|
+
assertPlatformVariableGroup(
|
|
400
|
+
getVariableGroup(dependency.name, monorepoRoot),
|
|
401
|
+
dependency.name,
|
|
402
|
+
dependency.label,
|
|
403
|
+
dependency.requiredVariables,
|
|
404
|
+
dependency.requiredSensitiveVariables,
|
|
405
|
+
),
|
|
406
|
+
).filter((failure): failure is string => failure !== null);
|
|
407
|
+
const failures = [...installationFailures, ...variableGroupFailures];
|
|
408
|
+
|
|
409
|
+
if (failures.length > 0) {
|
|
410
|
+
throw new Error(
|
|
411
|
+
[
|
|
412
|
+
`Cannot deploy ${APP_NAME} into ${ENVIRONMENT} because required platform services are missing or unhealthy.`,
|
|
413
|
+
...failures,
|
|
414
|
+
"This helper performs the existing-environment deploy motion only. Stand up the platform services first, then rerun deploy:percepta-test.",
|
|
415
|
+
].join("\n"),
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
for (const dependency of REQUIRED_PLATFORM_INSTALLATIONS) {
|
|
420
|
+
console.log(`Found ${dependency.label}: ${dependency.name}`);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
for (const dependency of REQUIRED_PLATFORM_VARIABLE_GROUPS) {
|
|
424
|
+
console.log(`Found ${dependency.label} variable group: ${dependency.name}`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function upsertInstallation(
|
|
429
|
+
name: string,
|
|
430
|
+
manifestPath: string,
|
|
431
|
+
monorepoRoot: string,
|
|
432
|
+
): void {
|
|
433
|
+
if (installationExists(name, monorepoRoot)) {
|
|
434
|
+
run("ryvn", ["replace", "-f", manifestPath], monorepoRoot);
|
|
435
|
+
} else {
|
|
436
|
+
run("ryvn", ["create", "-f", manifestPath], monorepoRoot);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function triggerWorkflow(
|
|
441
|
+
repoSlug: string,
|
|
442
|
+
workflowFile: string,
|
|
443
|
+
monorepoRoot: string,
|
|
444
|
+
options: Options,
|
|
445
|
+
): Promise<void> {
|
|
446
|
+
if (options.skipWorkflows) return;
|
|
447
|
+
|
|
448
|
+
console.log(`Running ${workflowFile}...`);
|
|
449
|
+
run(
|
|
450
|
+
"gh",
|
|
451
|
+
["workflow", "run", workflowFile, "--repo", repoSlug, "--ref", options.ref],
|
|
452
|
+
monorepoRoot,
|
|
453
|
+
);
|
|
454
|
+
await sleep(3_000);
|
|
455
|
+
|
|
456
|
+
let runs = runJson<WorkflowRun[]>(
|
|
457
|
+
"gh",
|
|
458
|
+
[
|
|
459
|
+
"run",
|
|
460
|
+
"list",
|
|
461
|
+
"--repo",
|
|
462
|
+
repoSlug,
|
|
463
|
+
"--workflow",
|
|
464
|
+
workflowFile,
|
|
465
|
+
"--branch",
|
|
466
|
+
options.ref,
|
|
467
|
+
"--event",
|
|
468
|
+
"workflow_dispatch",
|
|
469
|
+
"--limit",
|
|
470
|
+
"1",
|
|
471
|
+
"--json",
|
|
472
|
+
"databaseId,status,conclusion,url",
|
|
473
|
+
],
|
|
474
|
+
monorepoRoot,
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
if (runs.length === 0) {
|
|
478
|
+
runs = runJson<WorkflowRun[]>(
|
|
479
|
+
"gh",
|
|
480
|
+
[
|
|
481
|
+
"run",
|
|
482
|
+
"list",
|
|
483
|
+
"--repo",
|
|
484
|
+
repoSlug,
|
|
485
|
+
"--workflow",
|
|
486
|
+
workflowFile,
|
|
487
|
+
"--branch",
|
|
488
|
+
options.ref,
|
|
489
|
+
"--limit",
|
|
490
|
+
"1",
|
|
491
|
+
"--json",
|
|
492
|
+
"databaseId,status,conclusion,url",
|
|
493
|
+
],
|
|
494
|
+
monorepoRoot,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const runInfo = runs.at(0);
|
|
499
|
+
if (runInfo === undefined) {
|
|
500
|
+
throw new Error(`Could not find a GitHub Actions run for ${workflowFile}`);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
console.log(`Watching ${runInfo.url}`);
|
|
504
|
+
run(
|
|
505
|
+
"gh",
|
|
506
|
+
[
|
|
507
|
+
"run",
|
|
508
|
+
"watch",
|
|
509
|
+
String(runInfo.databaseId),
|
|
510
|
+
"--repo",
|
|
511
|
+
repoSlug,
|
|
512
|
+
"--exit-status",
|
|
513
|
+
],
|
|
514
|
+
monorepoRoot,
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function latestTask(tasks: InstallationTask[]): InstallationTask | null {
|
|
519
|
+
return (
|
|
520
|
+
[...tasks].sort(
|
|
521
|
+
(a, b) =>
|
|
522
|
+
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
|
523
|
+
)[0] ?? null
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function waitForTerraformApply(
|
|
528
|
+
monorepoRoot: string,
|
|
529
|
+
options: Options,
|
|
530
|
+
): Promise<void> {
|
|
531
|
+
const deadline = Date.now() + options.timeoutMinutes * 60_000;
|
|
532
|
+
const approved = new Set<string>();
|
|
533
|
+
|
|
534
|
+
while (Date.now() < deadline) {
|
|
535
|
+
const tasks = runJson<InstallationTask[]>(
|
|
536
|
+
"ryvn",
|
|
537
|
+
[
|
|
538
|
+
"get",
|
|
539
|
+
"installation-task",
|
|
540
|
+
TERRAFORM_SERVICE_NAME,
|
|
541
|
+
"-e",
|
|
542
|
+
ENVIRONMENT,
|
|
543
|
+
"-o",
|
|
544
|
+
"json",
|
|
545
|
+
],
|
|
546
|
+
monorepoRoot,
|
|
547
|
+
);
|
|
548
|
+
const newest = latestTask(tasks);
|
|
549
|
+
const failed = tasks.find((task) => task.status === "FAILED");
|
|
550
|
+
if (failed) {
|
|
551
|
+
throw new Error(
|
|
552
|
+
`${failed.description ?? failed.taskType} failed for ${TERRAFORM_SERVICE_NAME}.`,
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const approvablePlan = tasks.find(
|
|
557
|
+
(task) =>
|
|
558
|
+
task.isApprovable &&
|
|
559
|
+
task.taskType === "terraformPlanV1" &&
|
|
560
|
+
!approved.has(task.associatedTaskId),
|
|
561
|
+
);
|
|
562
|
+
if (approvablePlan) {
|
|
563
|
+
await confirmOrThrow(
|
|
564
|
+
`Approve Terraform plan for ${TERRAFORM_SERVICE_NAME}?`,
|
|
565
|
+
options,
|
|
566
|
+
);
|
|
567
|
+
run(
|
|
568
|
+
"ryvn",
|
|
569
|
+
[
|
|
570
|
+
"task",
|
|
571
|
+
"approve",
|
|
572
|
+
approvablePlan.associatedTaskId,
|
|
573
|
+
"--reason",
|
|
574
|
+
`Deploy ${APP_NAME} schema to ${ENVIRONMENT}`,
|
|
575
|
+
],
|
|
576
|
+
monorepoRoot,
|
|
577
|
+
);
|
|
578
|
+
approved.add(approvablePlan.associatedTaskId);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (
|
|
582
|
+
newest?.status === "COMPLETED" &&
|
|
583
|
+
newest.taskType === "terraformApplyV1"
|
|
584
|
+
) {
|
|
585
|
+
console.log(`${TERRAFORM_SERVICE_NAME} is applied.`);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
await sleep(5_000);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
throw new Error(
|
|
593
|
+
`${TERRAFORM_SERVICE_NAME} did not finish within ${options.timeoutMinutes} minutes.`,
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function parseEnvFile(
|
|
598
|
+
contents: string,
|
|
599
|
+
): Array<{ name: string; value: string }> {
|
|
600
|
+
return contents
|
|
601
|
+
.split("\n")
|
|
602
|
+
.map((line) => line.trim())
|
|
603
|
+
.filter((line) => line.length > 0 && !line.startsWith("#"))
|
|
604
|
+
.map((line) => {
|
|
605
|
+
const equalsIndex = line.indexOf("=");
|
|
606
|
+
if (equalsIndex === -1) return null;
|
|
607
|
+
const name = line.slice(0, equalsIndex).trim();
|
|
608
|
+
let value = line.slice(equalsIndex + 1).trim();
|
|
609
|
+
if (
|
|
610
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
611
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
612
|
+
) {
|
|
613
|
+
value = value.slice(1, -1);
|
|
614
|
+
}
|
|
615
|
+
return name ? { name, value } : null;
|
|
616
|
+
})
|
|
617
|
+
.filter(
|
|
618
|
+
(entry): entry is { name: string; value: string } => entry !== null,
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async function patchInstallationSecrets(
|
|
623
|
+
packageDir: string,
|
|
624
|
+
monorepoRoot: string,
|
|
625
|
+
options: Options,
|
|
626
|
+
): Promise<void> {
|
|
627
|
+
if (options.skipSecrets) return;
|
|
628
|
+
|
|
629
|
+
const secretsPath = path.join(
|
|
630
|
+
packageDir,
|
|
631
|
+
"deploy",
|
|
632
|
+
"ryvn",
|
|
633
|
+
`${ENVIRONMENT}.secrets.env`,
|
|
634
|
+
);
|
|
635
|
+
if (!existsSync(secretsPath)) {
|
|
636
|
+
console.log(
|
|
637
|
+
`No secrets file found at ${secretsPath}; skipping secret patch.`,
|
|
638
|
+
);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const secrets = parseEnvFile(await readFile(secretsPath, "utf8"));
|
|
643
|
+
if (secrets.length === 0) return;
|
|
644
|
+
|
|
645
|
+
const secretEnv = secrets.map(({ name, value }) => ({
|
|
646
|
+
key: name,
|
|
647
|
+
value,
|
|
648
|
+
isSecret: true,
|
|
649
|
+
}));
|
|
650
|
+
// Strategic merge preserves existing non-secret env entries while upserting
|
|
651
|
+
// these generated secrets by key.
|
|
652
|
+
const patch = JSON.stringify({ spec: { env: secretEnv, secrets } });
|
|
653
|
+
const result = spawnSync(
|
|
654
|
+
"ryvn",
|
|
655
|
+
[
|
|
656
|
+
"update",
|
|
657
|
+
"installation",
|
|
658
|
+
APP_NAME,
|
|
659
|
+
"-e",
|
|
660
|
+
ENVIRONMENT,
|
|
661
|
+
"--type",
|
|
662
|
+
"strategic",
|
|
663
|
+
"--patch-file",
|
|
664
|
+
"-",
|
|
665
|
+
],
|
|
666
|
+
{
|
|
667
|
+
cwd: monorepoRoot,
|
|
668
|
+
input: patch,
|
|
669
|
+
stdio: ["pipe", "inherit", "inherit"],
|
|
670
|
+
encoding: "utf8",
|
|
671
|
+
},
|
|
672
|
+
);
|
|
673
|
+
|
|
674
|
+
if (result.status !== 0) {
|
|
675
|
+
throw new Error(`Failed to patch secrets for ${APP_NAME}.`);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async function waitForHealthy(
|
|
680
|
+
monorepoRoot: string,
|
|
681
|
+
options: Options,
|
|
682
|
+
): Promise<void> {
|
|
683
|
+
const deadline = Date.now() + options.timeoutMinutes * 60_000;
|
|
684
|
+
|
|
685
|
+
while (Date.now() < deadline) {
|
|
686
|
+
const installation = runJson<Installation>(
|
|
687
|
+
"ryvn",
|
|
688
|
+
["get", "installation", APP_NAME, "-e", ENVIRONMENT, "-o", "json"],
|
|
689
|
+
monorepoRoot,
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
if (
|
|
693
|
+
installation.status === "UP_TO_DATE" &&
|
|
694
|
+
installation.health === "HEALTHY"
|
|
695
|
+
) {
|
|
696
|
+
console.log(`${APP_NAME} is healthy.`);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
console.log(
|
|
701
|
+
`${APP_NAME} status: ${installation.status ?? "unknown"}, health: ${installation.health ?? "unknown"}`,
|
|
702
|
+
);
|
|
703
|
+
await sleep(10_000);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
throw new Error(
|
|
707
|
+
`${APP_NAME} did not become healthy within ${options.timeoutMinutes} minutes.`,
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
async function fetchDeployUrl(
|
|
712
|
+
pathname: string,
|
|
713
|
+
init: RequestInit = {},
|
|
714
|
+
): Promise<Response> {
|
|
715
|
+
const controller = new AbortController();
|
|
716
|
+
const timeout = setTimeout(() => controller.abort(), 10_000);
|
|
717
|
+
try {
|
|
718
|
+
return await fetch(`${DEPLOY_URL}${pathname}`, {
|
|
719
|
+
...init,
|
|
720
|
+
signal: controller.signal,
|
|
721
|
+
});
|
|
722
|
+
} finally {
|
|
723
|
+
clearTimeout(timeout);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
async function verifyEndpoint(pathname: string): Promise<void> {
|
|
728
|
+
const response = await fetchDeployUrl(pathname);
|
|
729
|
+
if (!response.ok) {
|
|
730
|
+
throw new Error(`${pathname} returned HTTP ${response.status}`);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async function verifyAppRoute(): Promise<void> {
|
|
735
|
+
const response = await fetchDeployUrl("/", { redirect: "manual" });
|
|
736
|
+
if (response.ok) return;
|
|
737
|
+
|
|
738
|
+
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
739
|
+
const location = response.headers.get("location");
|
|
740
|
+
let redirectPathname: string | null = null;
|
|
741
|
+
if (location) {
|
|
742
|
+
try {
|
|
743
|
+
redirectPathname = new URL(location, DEPLOY_URL).pathname;
|
|
744
|
+
} catch {
|
|
745
|
+
redirectPathname = null;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
if (redirectPathname === "/auth/signin") return;
|
|
749
|
+
|
|
750
|
+
throw new Error(
|
|
751
|
+
`/ redirected to ${location ?? "an empty location"} instead of /auth/signin`,
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
throw new Error(`/ returned HTTP ${response.status}`);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async function verifyDeployment(): Promise<void> {
|
|
759
|
+
await verifyEndpoint("/api/healthz");
|
|
760
|
+
await verifyEndpoint("/api/readyz");
|
|
761
|
+
await verifyAppRoute();
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
async function main(): Promise<void> {
|
|
765
|
+
const options = parseArgs(process.argv.slice(2));
|
|
766
|
+
assertCommand("git");
|
|
767
|
+
assertCommand("gh");
|
|
768
|
+
assertCommand("ryvn");
|
|
769
|
+
|
|
770
|
+
const packageDir = path.resolve(
|
|
771
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
772
|
+
"..",
|
|
773
|
+
);
|
|
774
|
+
const monorepoRoot = findUp(packageDir, "pnpm-workspace.yaml") ?? packageDir;
|
|
775
|
+
verifyExistingPlatform(monorepoRoot);
|
|
776
|
+
|
|
777
|
+
const repoSlug = await ensureGitHubRepo(monorepoRoot, options);
|
|
778
|
+
ensureCleanAndPushed(monorepoRoot, options);
|
|
779
|
+
|
|
780
|
+
const ryvnDir = path.join(packageDir, "deploy", "ryvn");
|
|
781
|
+
const serviceManifest = path.join(ryvnDir, `${APP_NAME}.service.yaml`);
|
|
782
|
+
const terraformServiceManifest = path.join(
|
|
783
|
+
ryvnDir,
|
|
784
|
+
`${TERRAFORM_SERVICE_NAME}.service.yaml`,
|
|
785
|
+
);
|
|
786
|
+
const installationManifest = path.join(
|
|
787
|
+
ryvnDir,
|
|
788
|
+
"environments",
|
|
789
|
+
ENVIRONMENT,
|
|
790
|
+
"installations",
|
|
791
|
+
`${APP_NAME}.env.${ENVIRONMENT}.serviceinstallation.yaml`,
|
|
792
|
+
);
|
|
793
|
+
const terraformInstallationManifest = path.join(
|
|
794
|
+
ryvnDir,
|
|
795
|
+
"environments",
|
|
796
|
+
ENVIRONMENT,
|
|
797
|
+
"installations",
|
|
798
|
+
`${TERRAFORM_SERVICE_NAME}.env.${ENVIRONMENT}.serviceinstallation.yaml`,
|
|
799
|
+
);
|
|
800
|
+
|
|
801
|
+
console.log(`Creating/updating Ryvn services in ${ENVIRONMENT}...`);
|
|
802
|
+
upsertService(terraformServiceManifest, monorepoRoot);
|
|
803
|
+
upsertService(serviceManifest, monorepoRoot);
|
|
804
|
+
|
|
805
|
+
await triggerWorkflow(
|
|
806
|
+
repoSlug,
|
|
807
|
+
`${TERRAFORM_SERVICE_NAME}-ryvn-release.yaml`,
|
|
808
|
+
monorepoRoot,
|
|
809
|
+
options,
|
|
810
|
+
);
|
|
811
|
+
upsertInstallation(
|
|
812
|
+
TERRAFORM_SERVICE_NAME,
|
|
813
|
+
terraformInstallationManifest,
|
|
814
|
+
monorepoRoot,
|
|
815
|
+
);
|
|
816
|
+
await waitForTerraformApply(monorepoRoot, options);
|
|
817
|
+
|
|
818
|
+
await triggerWorkflow(
|
|
819
|
+
repoSlug,
|
|
820
|
+
`${APP_NAME}-ryvn-release.yaml`,
|
|
821
|
+
monorepoRoot,
|
|
822
|
+
options,
|
|
823
|
+
);
|
|
824
|
+
upsertInstallation(APP_NAME, installationManifest, monorepoRoot);
|
|
825
|
+
await patchInstallationSecrets(packageDir, monorepoRoot, options);
|
|
826
|
+
await waitForHealthy(monorepoRoot, options);
|
|
827
|
+
|
|
828
|
+
await verifyDeployment();
|
|
829
|
+
|
|
830
|
+
console.log("");
|
|
831
|
+
console.log(`Deployed ${APP_NAME}: ${DEPLOY_URL}`);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
void main().catch((error) => {
|
|
835
|
+
console.error(error instanceof Error ? error.message : error);
|
|
836
|
+
process.exit(1);
|
|
837
|
+
});
|