create-svc 0.1.15 → 0.1.17
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/src/post-scaffold.test.ts +5 -5
- package/src/post-scaffold.ts +3 -3
- package/src/scaffold.test.ts +11 -2
- package/src/scaffold.ts +8 -0
- package/templates/shared/README.md +11 -7
- package/templates/shared/scripts/authctl.ts +98 -8
- package/templates/shared/scripts/cloudrun/bootstrap.ts +10 -4
- package/templates/shared/scripts/cloudrun/cleanup.ts +168 -34
- package/templates/shared/scripts/cloudrun/cli.ts +3 -2
- package/templates/shared/scripts/cloudrun/lib.ts +2 -1
package/package.json
CHANGED
|
@@ -4,16 +4,16 @@ import { buildDeploymentVerificationCommands, buildPostScaffoldCommands } from "
|
|
|
4
4
|
describe("buildPostScaffoldCommands", () => {
|
|
5
5
|
test("runs create and deploy for HTTP services", () => {
|
|
6
6
|
expect(buildPostScaffoldCommands({ framework: "hono" })).toEqual([
|
|
7
|
-
{ command: "bun", args: ["
|
|
8
|
-
{ command: "bun", args: ["
|
|
7
|
+
{ command: "bun", args: ["./scripts/cloudrun/cli.ts", "create"] },
|
|
8
|
+
{ command: "bun", args: ["./scripts/cloudrun/cli.ts", "deploy"] },
|
|
9
9
|
]);
|
|
10
10
|
});
|
|
11
11
|
|
|
12
12
|
test("builds SDK artifacts before create and deploy for ConnectRPC services", () => {
|
|
13
13
|
expect(buildPostScaffoldCommands({ framework: "connectrpc" })).toEqual([
|
|
14
|
-
{ command: "bun", args: ["
|
|
15
|
-
{ command: "bun", args: ["
|
|
16
|
-
{ command: "bun", args: ["
|
|
14
|
+
{ command: "bun", args: ["./scripts/cloudrun/cli.ts", "sdk", "build"] },
|
|
15
|
+
{ command: "bun", args: ["./scripts/cloudrun/cli.ts", "create"] },
|
|
16
|
+
{ command: "bun", args: ["./scripts/cloudrun/cli.ts", "deploy"] },
|
|
17
17
|
]);
|
|
18
18
|
});
|
|
19
19
|
});
|
package/src/post-scaffold.ts
CHANGED
|
@@ -55,9 +55,9 @@ export function buildDeploymentVerificationCommands(
|
|
|
55
55
|
|
|
56
56
|
export function buildPostScaffoldCommands(config: Pick<ScaffoldConfig, "framework">): PostScaffoldCommand[] {
|
|
57
57
|
return [
|
|
58
|
-
...(config.framework === "connectrpc" ? [{ command: "bun", args: ["
|
|
59
|
-
{ command: "bun", args: ["
|
|
60
|
-
{ command: "bun", args: ["
|
|
58
|
+
...(config.framework === "connectrpc" ? [{ command: "bun", args: ["./scripts/cloudrun/cli.ts", "sdk", "build"] }] : []),
|
|
59
|
+
{ command: "bun", args: ["./scripts/cloudrun/cli.ts", "create"] },
|
|
60
|
+
{ command: "bun", args: ["./scripts/cloudrun/cli.ts", "deploy"] },
|
|
61
61
|
];
|
|
62
62
|
}
|
|
63
63
|
|
package/src/scaffold.test.ts
CHANGED
|
@@ -84,7 +84,7 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
|
|
|
84
84
|
|
|
85
85
|
const deployScript = await Bun.file(join(generatedRoot, "scripts", "cloudrun", "lib.ts")).text();
|
|
86
86
|
expect(deployScript).toContain('--billing-project", config.project.quotaProjectId');
|
|
87
|
-
expect(deployScript).toContain('
|
|
87
|
+
expect(deployScript).toContain('projectMode === "use_existing"');
|
|
88
88
|
expect(deployScript).toContain("serviceDomain");
|
|
89
89
|
expect(deployScript).toContain("ensureProductionDomainMapping");
|
|
90
90
|
expect(deployScript).toContain('"domain-mappings",');
|
|
@@ -96,7 +96,10 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
|
|
|
96
96
|
expect(await Bun.file(join(generatedRoot, "scripts", "cloudrun", "integrations.ts")).exists()).toBeFalse();
|
|
97
97
|
const destroyScript = await Bun.file(join(generatedRoot, "scripts", "cloudrun", "cleanup.ts")).text();
|
|
98
98
|
expect(destroyScript).toContain("assertOwnedResource");
|
|
99
|
-
expect(destroyScript).toContain("
|
|
99
|
+
expect(destroyScript).toContain("Planning resources to destroy");
|
|
100
|
+
expect(destroyScript).toContain("Resources selected for destroy");
|
|
101
|
+
expect(destroyScript).toContain("Destroy cannot continue until resource discovery succeeds");
|
|
102
|
+
expect(destroyScript).toContain("deleteAuthResourceServer");
|
|
100
103
|
expect(destroyScript).toContain("deleteGrafanaResources");
|
|
101
104
|
expect(destroyScript).toContain('gcx", ["resources", "delete"');
|
|
102
105
|
expect(destroyScript).toContain("config.temporal.apiKeySecretName");
|
|
@@ -135,6 +138,7 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
|
|
|
135
138
|
expect(envExample).toContain("AUTH_ENABLED=false");
|
|
136
139
|
expect(envExample).toContain("AUTH_AUDIENCE=api://dns-api");
|
|
137
140
|
expect(envExample).toContain("CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_ID=");
|
|
141
|
+
expect(envExample).toContain("VAULT_AUTHCTL_ACCESS_PATH=prod/apps/auth/authctl/cloudflare-access");
|
|
138
142
|
expect(envExample).toContain("TEMPORAL_API_KEY=");
|
|
139
143
|
expect(envExample).toContain("The base waitlist service does not require");
|
|
140
144
|
expect(envExample).not.toContain("ATTACHMENT_BUCKET=");
|
|
@@ -146,6 +150,8 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
|
|
|
146
150
|
|
|
147
151
|
const localEnv = await Bun.file(join(generatedRoot, ".env.local")).text();
|
|
148
152
|
expect(localEnv).toContain(`DATABASE_URL=postgres://postgres:postgres@127.0.0.1:${localPort}/dns_api?sslmode=disable`);
|
|
153
|
+
expect(localEnv).toContain("VAULT_AUTHCTL_ACCESS_PATH=prod/apps/auth/authctl/cloudflare-access");
|
|
154
|
+
expect(localEnv).toContain("VAULT_NEON_API_KEY_PATH=prod/providers/neon");
|
|
149
155
|
expect(localEnv).not.toContain("ATTACHMENT_PUBLIC_BASE_URL=");
|
|
150
156
|
|
|
151
157
|
const ciWorkflow = await Bun.file(join(generatedRoot, ".github", "workflows", "ci.yml")).text();
|
|
@@ -223,6 +229,9 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
|
|
|
223
229
|
expect(authctlScript).toContain("resource-servers");
|
|
224
230
|
expect(authctlScript).toContain("clients");
|
|
225
231
|
expect(authctlScript).toContain("defaultClientTargetArgs");
|
|
232
|
+
expect(authctlScript).toContain("deleteAuthResourceServer");
|
|
233
|
+
expect(authctlScript).toContain("readAuthctlAccessVaultField");
|
|
234
|
+
expect(authctlScript).toContain("prod/apps/auth/authctl/cloudflare-access");
|
|
226
235
|
expect(authctlScript).toContain('existsSync("./node_modules/.bin/authctl") ? "./node_modules/.bin/authctl" : Bun.which("authctl")');
|
|
227
236
|
expect(authctlScript).not.toContain('defaultAuthResourceServerArgs(), "--yes", "--json"');
|
|
228
237
|
const authScript = await Bun.file(join(generatedRoot, "src", "auth.ts")).text();
|
package/src/scaffold.ts
CHANGED
|
@@ -252,6 +252,14 @@ async function writeLocalEnvFile(targetDir: string, replacements: Record<string,
|
|
|
252
252
|
"",
|
|
253
253
|
"DATABASE_URL=postgres://{{LOCAL_DATABASE_USER}}:{{LOCAL_DATABASE_PASSWORD}}@127.0.0.1:{{LOCAL_DATABASE_PORT}}/{{LOCAL_DATABASE_NAME}}?sslmode=disable",
|
|
254
254
|
"",
|
|
255
|
+
"VAULT_SECRET_MOUNT=secret",
|
|
256
|
+
"VAULT_AUTHCTL_ACCESS_PATH=prod/apps/auth/authctl/cloudflare-access",
|
|
257
|
+
"VAULT_AUTHCTL_ACCESS_BASE_URL_FIELD=AUTH_INTERNAL_BASE_URL",
|
|
258
|
+
"VAULT_AUTHCTL_ACCESS_CLIENT_ID_FIELD=CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_ID",
|
|
259
|
+
"VAULT_AUTHCTL_ACCESS_CLIENT_SECRET_FIELD=CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_SECRET",
|
|
260
|
+
"VAULT_NEON_API_KEY_PATH=prod/providers/neon",
|
|
261
|
+
"VAULT_NEON_API_KEY_FIELD=api_key",
|
|
262
|
+
"",
|
|
255
263
|
].join("\n"),
|
|
256
264
|
replacements
|
|
257
265
|
);
|
|
@@ -104,14 +104,17 @@ The scaffold will use, in order:
|
|
|
104
104
|
|
|
105
105
|
That keeps stable settings in the repo and keeps the token out of `~/.zshrc`.
|
|
106
106
|
|
|
107
|
-
For production auth registration, `authctl`
|
|
108
|
-
|
|
107
|
+
For production auth registration, `authctl` loads the auth service's Cloudflare
|
|
108
|
+
Access service token from Vault by default:
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
110
|
+
- `VAULT_AUTHCTL_ACCESS_PATH` default `prod/apps/auth/authctl/cloudflare-access`
|
|
111
|
+
- `VAULT_AUTHCTL_ACCESS_BASE_URL_FIELD` default `AUTH_INTERNAL_BASE_URL`
|
|
112
|
+
- `VAULT_AUTHCTL_ACCESS_CLIENT_ID_FIELD` default `CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_ID`
|
|
113
|
+
- `VAULT_AUTHCTL_ACCESS_CLIENT_SECRET_FIELD` default `CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_SECRET`
|
|
114
|
+
|
|
115
|
+
Direct `AUTH_INTERNAL_BASE_URL`, `CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_ID`,
|
|
116
|
+
and `CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_SECRET` env values still override
|
|
117
|
+
Vault when set.
|
|
115
118
|
|
|
116
119
|
Before first production create, verify the installed `authctl` exposes the
|
|
117
120
|
resource-server control-plane command:
|
|
@@ -127,6 +130,7 @@ newer before running `{{COMMAND_BOOTSTRAP}}`.
|
|
|
127
130
|
Optional remote-only Vault overrides for Neon admin key lookup:
|
|
128
131
|
|
|
129
132
|
- `VAULT_SECRET_MOUNT` default `secret`
|
|
133
|
+
- `VAULT_AUTHCTL_ACCESS_PATH` default `prod/apps/auth/authctl/cloudflare-access`
|
|
130
134
|
- `VAULT_NEON_API_KEY_PATH` default `prod/providers/neon`
|
|
131
135
|
- `VAULT_NEON_API_KEY_FIELD` default `api_key`
|
|
132
136
|
|
|
@@ -58,10 +58,24 @@ export function runAuthCommand(args: string[]) {
|
|
|
58
58
|
"authctl is installed but does not expose resource-server commands; install @anmho/authctl@0.1.1 or newer before managing auth resource servers"
|
|
59
59
|
);
|
|
60
60
|
}
|
|
61
|
-
if (action === "get" || action === "list") {
|
|
61
|
+
if (action === "get" || action === "list" || action === "delete") {
|
|
62
62
|
if (!command.actions.includes(action)) {
|
|
63
63
|
throw new Error(`authctl ${command.subject} does not expose ${action}`);
|
|
64
64
|
}
|
|
65
|
+
if (action === "delete") {
|
|
66
|
+
authctl([
|
|
67
|
+
command.subject,
|
|
68
|
+
action,
|
|
69
|
+
"--resource-server",
|
|
70
|
+
serviceConfig.auth.resource_server.id,
|
|
71
|
+
"--stage",
|
|
72
|
+
serviceConfig.stage_default,
|
|
73
|
+
"--force",
|
|
74
|
+
"--json",
|
|
75
|
+
...rest,
|
|
76
|
+
]);
|
|
77
|
+
return `Auth resource server deleted: ${serviceConfig.auth.resource_server.id}`;
|
|
78
|
+
}
|
|
65
79
|
authctl([command.subject, action, ...rest]);
|
|
66
80
|
return `Auth resource server ${action} finished`;
|
|
67
81
|
}
|
|
@@ -83,10 +97,32 @@ export function runAuthCommand(args: string[]) {
|
|
|
83
97
|
|
|
84
98
|
export function ensureAuthResourceServer() {
|
|
85
99
|
const command = ensureResourceServerCommandAvailable();
|
|
86
|
-
authctl([command.subject, command.mutationAction, ...defaultAuthResourceServerArgs(), "--json"]);
|
|
100
|
+
authctl([command.subject, command.mutationAction, ...defaultAuthResourceServerArgs(), "--json"], { quiet: true });
|
|
87
101
|
return `Auth resource server ready: ${serviceConfig.auth.resource_server.audience}`;
|
|
88
102
|
}
|
|
89
103
|
|
|
104
|
+
export function deleteAuthResourceServer() {
|
|
105
|
+
const command = resolveResourceServerCommand();
|
|
106
|
+
if (!command?.actions.includes("delete")) {
|
|
107
|
+
return "authctl does not expose resource-server delete; auth resource server was not deleted";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
authctl(
|
|
111
|
+
[
|
|
112
|
+
command.subject,
|
|
113
|
+
"delete",
|
|
114
|
+
"--resource-server",
|
|
115
|
+
serviceConfig.auth.resource_server.id,
|
|
116
|
+
"--stage",
|
|
117
|
+
serviceConfig.stage_default,
|
|
118
|
+
"--force",
|
|
119
|
+
"--json",
|
|
120
|
+
],
|
|
121
|
+
{ quiet: true }
|
|
122
|
+
);
|
|
123
|
+
return `Auth resource server deleted: ${serviceConfig.auth.resource_server.id}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
90
126
|
export function runAuthDoctor(): AuthDoctorResult {
|
|
91
127
|
if (!authctlPath()) {
|
|
92
128
|
return {
|
|
@@ -185,7 +221,7 @@ function resolveResourceServerCommand(): ResourceServerCommand | undefined {
|
|
|
185
221
|
if (!help.success || !output.includes(subject)) {
|
|
186
222
|
continue;
|
|
187
223
|
}
|
|
188
|
-
const actions = ["upsert", "create", "get", "list"].filter((candidate) => output.includes(candidate));
|
|
224
|
+
const actions = ["upsert", "create", "get", "list", "delete"].filter((candidate) => output.includes(candidate));
|
|
189
225
|
const mutationAction = actions.includes("upsert") ? "upsert" : actions.includes("create") ? "create" : undefined;
|
|
190
226
|
if (actions.length > 0) {
|
|
191
227
|
return { subject, mutationAction, actions };
|
|
@@ -202,7 +238,7 @@ function authctl(args: string[], options: { allowFailure?: boolean; quiet?: bool
|
|
|
202
238
|
|
|
203
239
|
const result = Bun.spawnSync([command, ...args], {
|
|
204
240
|
cwd: process.cwd(),
|
|
205
|
-
env:
|
|
241
|
+
env: authctlEnvironment(),
|
|
206
242
|
stdin: "inherit",
|
|
207
243
|
stdout: "pipe",
|
|
208
244
|
stderr: "pipe",
|
|
@@ -232,10 +268,9 @@ function formatAuthctlFailure(args: string[], output: CommandResult) {
|
|
|
232
268
|
return [
|
|
233
269
|
`authctl ${args.join(" ")} failed with exit code ${output.exitCode}`,
|
|
234
270
|
"authctl reached the auth internal API, but Cloudflare Access rejected the request.",
|
|
235
|
-
"
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
' export CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_SECRET="$(vault kv get -mount=secret -field=CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_SECRET prod/apps/auth/authctl/cloudflare-access)"',
|
|
271
|
+
"The service CLI tried to load the authctl Access token from Vault.",
|
|
272
|
+
"Verify `vault login` works and that this path is readable:",
|
|
273
|
+
" secret/prod/apps/auth/authctl/cloudflare-access",
|
|
239
274
|
].join("\n");
|
|
240
275
|
}
|
|
241
276
|
|
|
@@ -245,3 +280,58 @@ function formatAuthctlFailure(args: string[], output: CommandResult) {
|
|
|
245
280
|
function authctlPath() {
|
|
246
281
|
return existsSync("./node_modules/.bin/authctl") ? "./node_modules/.bin/authctl" : Bun.which("authctl");
|
|
247
282
|
}
|
|
283
|
+
|
|
284
|
+
function authctlEnvironment() {
|
|
285
|
+
const env = { ...process.env };
|
|
286
|
+
const fields = [
|
|
287
|
+
{
|
|
288
|
+
envName: "AUTH_INTERNAL_BASE_URL",
|
|
289
|
+
fieldEnvName: "VAULT_AUTHCTL_ACCESS_BASE_URL_FIELD",
|
|
290
|
+
defaultField: "AUTH_INTERNAL_BASE_URL",
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
envName: "CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_ID",
|
|
294
|
+
fieldEnvName: "VAULT_AUTHCTL_ACCESS_CLIENT_ID_FIELD",
|
|
295
|
+
defaultField: "CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_ID",
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
envName: "CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_SECRET",
|
|
299
|
+
fieldEnvName: "VAULT_AUTHCTL_ACCESS_CLIENT_SECRET_FIELD",
|
|
300
|
+
defaultField: "CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_SECRET",
|
|
301
|
+
},
|
|
302
|
+
];
|
|
303
|
+
|
|
304
|
+
for (const field of fields) {
|
|
305
|
+
if (env[field.envName]?.trim()) {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const value = readAuthctlAccessVaultField(env, env[field.fieldEnvName]?.trim() || field.defaultField);
|
|
309
|
+
if (value) {
|
|
310
|
+
env[field.envName] = value;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return env;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function readAuthctlAccessVaultField(env: Record<string, string | undefined>, field: string) {
|
|
318
|
+
const vault = Bun.which("vault");
|
|
319
|
+
if (!vault) {
|
|
320
|
+
return "";
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const mount = env.VAULT_AUTHCTL_ACCESS_MOUNT?.trim() || env.VAULT_SECRET_MOUNT?.trim() || "secret";
|
|
324
|
+
const path = env.VAULT_AUTHCTL_ACCESS_PATH?.trim() || "prod/apps/auth/authctl/cloudflare-access";
|
|
325
|
+
const result = Bun.spawnSync([vault, "kv", "get", `-mount=${mount}`, `-field=${field}`, path], {
|
|
326
|
+
cwd: process.cwd(),
|
|
327
|
+
env,
|
|
328
|
+
stdout: "pipe",
|
|
329
|
+
stderr: "pipe",
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
if (!result.success || !result.stdout) {
|
|
333
|
+
return "";
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return decoder.decode(result.stdout).trim();
|
|
337
|
+
}
|
|
@@ -17,13 +17,13 @@ import {
|
|
|
17
17
|
runStep,
|
|
18
18
|
} from "./lib";
|
|
19
19
|
|
|
20
|
-
export async function bootstrap() {
|
|
20
|
+
export async function bootstrap(options: { skipProjectSetup?: boolean } = {}) {
|
|
21
21
|
requireCommand("gcloud");
|
|
22
22
|
requireGcloudAuth();
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
if (!options.skipProjectSetup) {
|
|
25
|
+
await prepareGcpProject();
|
|
26
|
+
}
|
|
27
27
|
|
|
28
28
|
await runStep("Ensuring runtime service account", () => {
|
|
29
29
|
ensureServiceAccount(config.runtimeServiceAccount);
|
|
@@ -53,6 +53,12 @@ export async function bootstrap() {
|
|
|
53
53
|
await runStep("Publishing Temporal secrets", () => publishTemporalSecrets());
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
export async function prepareGcpProject() {
|
|
57
|
+
await runStep("Ensuring GCP project", () => ensureProject());
|
|
58
|
+
await runStep("Attaching billing", () => attachBilling());
|
|
59
|
+
await runStep("Enabling required GCP APIs", () => gcloud(["services", "enable", ...config.requiredApis, "--project", config.project.id]));
|
|
60
|
+
}
|
|
61
|
+
|
|
56
62
|
function publishTemporalSecrets() {
|
|
57
63
|
const temporal = resolveTemporalRuntimeConfig();
|
|
58
64
|
const apiKey = process.env.TEMPORAL_API_KEY?.trim();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { confirm, isCancel, log } from "@clack/prompts";
|
|
2
|
+
import { deleteAuthResourceServer } from "../authctl";
|
|
2
3
|
import { config } from "./config";
|
|
3
4
|
import { deleteBranch, deleteDatabase, listBranches, resolveNeonConfig } from "./neon";
|
|
4
5
|
import {
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
describeCloudRunService,
|
|
12
13
|
describeProductionDomainMapping,
|
|
13
14
|
describeSecret,
|
|
15
|
+
formatError,
|
|
14
16
|
listCloudRunServices,
|
|
15
17
|
listSecrets,
|
|
16
18
|
parseCleanupArgs,
|
|
@@ -34,18 +36,46 @@ function matchesSecretResource(name: string) {
|
|
|
34
36
|
);
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
type PlannedResource = {
|
|
40
|
+
label: string;
|
|
41
|
+
detail?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type DestroyPlan = {
|
|
45
|
+
resources: PlannedResource[];
|
|
46
|
+
skipped: PlannedResource[];
|
|
47
|
+
blockers: string[];
|
|
48
|
+
hasProductionDomainMapping: boolean;
|
|
49
|
+
serviceNames: string[];
|
|
50
|
+
secretNames: string[];
|
|
51
|
+
neon?: {
|
|
52
|
+
projectId: string;
|
|
53
|
+
baseBranchId: string;
|
|
54
|
+
databaseName: string;
|
|
55
|
+
branches: Array<{ id: string; name: string }>;
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
37
59
|
export async function cleanup(args = Bun.argv.slice(2)) {
|
|
38
60
|
requireCommand("gcloud");
|
|
39
61
|
requireGcloudAuth();
|
|
40
62
|
|
|
41
63
|
const options = parseCleanupArgs(args);
|
|
64
|
+
const plan = await runStep("Planning resources to destroy", () => buildDestroyPlan(options.destroyProject));
|
|
65
|
+
printDestroyPlan(plan);
|
|
66
|
+
if (plan.blockers.length > 0) {
|
|
67
|
+
throw new Error(["Destroy cannot continue until resource discovery succeeds:", ...plan.blockers.map((blocker) => `- ${blocker}`)].join("\n"));
|
|
68
|
+
}
|
|
69
|
+
|
|
42
70
|
await requireDestroyConfirmation(options.force);
|
|
43
71
|
|
|
44
|
-
await runStep(`
|
|
45
|
-
await runStep(`Deleting production domain mapping ${config.domain.hostname}`, () => deleteProductionDomainMapping());
|
|
72
|
+
await runStep(`Deleting auth resource server ${config.serviceName}`, () => deleteAuthResourceServer());
|
|
46
73
|
|
|
47
|
-
|
|
48
|
-
|
|
74
|
+
if (plan.hasProductionDomainMapping) {
|
|
75
|
+
await runStep(`Deleting production domain mapping ${config.domain.hostname}`, () => deleteProductionDomainMapping());
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const serviceNames = plan.serviceNames;
|
|
49
79
|
await runStep("Deleting Cloud Run services", () => {
|
|
50
80
|
for (const serviceName of serviceNames) {
|
|
51
81
|
assertOwnedResource(`Cloud Run service ${serviceName}`, describeCloudRunService(serviceName));
|
|
@@ -53,8 +83,7 @@ export async function cleanup(args = Bun.argv.slice(2)) {
|
|
|
53
83
|
}
|
|
54
84
|
});
|
|
55
85
|
|
|
56
|
-
const
|
|
57
|
-
const secretNames = secrets.filter(matchesSecretResource);
|
|
86
|
+
const secretNames = plan.secretNames;
|
|
58
87
|
await runStep("Deleting service secrets", () => {
|
|
59
88
|
for (const secretName of secretNames) {
|
|
60
89
|
assertOwnedResource(`Secret ${secretName}`, describeSecret(secretName));
|
|
@@ -62,24 +91,15 @@ export async function cleanup(args = Bun.argv.slice(2)) {
|
|
|
62
91
|
}
|
|
63
92
|
});
|
|
64
93
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const branches = await runStep("Finding Neon branches", () => listBranches(neon.projectId));
|
|
68
|
-
const disposableBranches = branches.filter(
|
|
69
|
-
(branch: { name: string }) =>
|
|
70
|
-
branch.name.startsWith(`${neon.previewBranchPrefix}-`) || branch.name.startsWith(`${neon.personalBranchPrefix}-`)
|
|
71
|
-
);
|
|
72
|
-
|
|
94
|
+
const neonPlan = plan.neon;
|
|
95
|
+
if (neonPlan) {
|
|
73
96
|
await runStep("Deleting Neon preview and personal branches", async () => {
|
|
74
|
-
for (const branch of
|
|
75
|
-
await deleteBranch(
|
|
97
|
+
for (const branch of neonPlan.branches) {
|
|
98
|
+
await deleteBranch(neonPlan.projectId, branch.id);
|
|
76
99
|
}
|
|
77
100
|
});
|
|
78
101
|
|
|
79
|
-
await runStep("Deleting Neon service database", () => deleteDatabase(
|
|
80
|
-
} catch (error) {
|
|
81
|
-
log.step("Skipping Neon cleanup because Neon is not configured");
|
|
82
|
-
log.step(error instanceof Error ? error.message : String(error));
|
|
102
|
+
await runStep("Deleting Neon service database", () => deleteDatabase(neonPlan.projectId, neonPlan.baseBranchId, neonPlan.databaseName));
|
|
83
103
|
}
|
|
84
104
|
|
|
85
105
|
await runStep("Deleting Grafana resources", async () => deleteGrafanaResources());
|
|
@@ -97,30 +117,144 @@ export async function cleanup(args = Bun.argv.slice(2)) {
|
|
|
97
117
|
return `Destroy finished for ${config.serviceName}`;
|
|
98
118
|
}
|
|
99
119
|
|
|
100
|
-
async function
|
|
101
|
-
|
|
102
|
-
|
|
120
|
+
async function buildDestroyPlan(destroyProject: boolean): Promise<DestroyPlan> {
|
|
121
|
+
const plan: DestroyPlan = {
|
|
122
|
+
resources: [
|
|
123
|
+
{ label: `Auth resource server ${config.serviceName}`, detail: "stage prod" },
|
|
124
|
+
{ label: `Runtime service account ${config.runtimeServiceAccount}`, detail: "if it exists" },
|
|
125
|
+
],
|
|
126
|
+
skipped: [],
|
|
127
|
+
blockers: [],
|
|
128
|
+
hasProductionDomainMapping: false,
|
|
129
|
+
serviceNames: [],
|
|
130
|
+
secretNames: [],
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
planProductionDomainMapping(plan);
|
|
134
|
+
planCloudRunServices(plan);
|
|
135
|
+
planSecrets(plan);
|
|
136
|
+
await planNeon(plan);
|
|
137
|
+
await planGrafana(plan);
|
|
138
|
+
|
|
139
|
+
if (destroyProject) {
|
|
140
|
+
plan.resources.push({ label: `GCP project ${config.project.id}`, detail: "requested with --project" });
|
|
103
141
|
}
|
|
104
|
-
|
|
105
|
-
|
|
142
|
+
|
|
143
|
+
return plan;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function planProductionDomainMapping(plan: DestroyPlan) {
|
|
147
|
+
try {
|
|
148
|
+
const mapping = describeProductionDomainMapping();
|
|
149
|
+
if (!mapping) {
|
|
150
|
+
plan.skipped.push({ label: `Production domain mapping ${config.domain.hostname}`, detail: "not found" });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const routeName = mapping.spec?.routeName;
|
|
155
|
+
if (routeName !== config.serviceName) {
|
|
156
|
+
plan.blockers.push(`${config.domain.hostname} maps to ${routeName || "an unknown service"}; refusing to delete ambiguous DNS mapping`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
assertOwnedResource(`Cloud Run service ${routeName}`, describeCloudRunService(routeName));
|
|
161
|
+
plan.hasProductionDomainMapping = true;
|
|
162
|
+
plan.resources.push({ label: `Production domain mapping ${config.domain.hostname}`, detail: `routes to ${routeName}` });
|
|
163
|
+
} catch (error) {
|
|
164
|
+
plan.blockers.push(`Production domain mapping ${config.domain.hostname}: ${formatError(error)}`);
|
|
106
165
|
}
|
|
166
|
+
}
|
|
107
167
|
|
|
108
|
-
|
|
109
|
-
|
|
168
|
+
function planCloudRunServices(plan: DestroyPlan) {
|
|
169
|
+
try {
|
|
170
|
+
plan.serviceNames = listCloudRunServices().filter(matchesServiceResource);
|
|
171
|
+
if (plan.serviceNames.length === 0) {
|
|
172
|
+
plan.skipped.push({ label: `Cloud Run services in ${config.project.id}/${config.region}`, detail: "none matched" });
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
for (const serviceName of plan.serviceNames) {
|
|
176
|
+
plan.resources.push({ label: `Cloud Run service ${serviceName}`, detail: `${config.project.id}/${config.region}` });
|
|
177
|
+
}
|
|
178
|
+
} catch (error) {
|
|
179
|
+
plan.blockers.push(`Cloud Run services in ${config.project.id}/${config.region}: ${formatError(error)}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function planSecrets(plan: DestroyPlan) {
|
|
184
|
+
try {
|
|
185
|
+
plan.secretNames = listSecrets().filter(matchesSecretResource);
|
|
186
|
+
if (plan.secretNames.length === 0) {
|
|
187
|
+
plan.skipped.push({ label: `Secret Manager secrets in ${config.project.id}`, detail: "none matched" });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
for (const secretName of plan.secretNames) {
|
|
191
|
+
plan.resources.push({ label: `Secret Manager secret ${secretName}`, detail: config.project.id });
|
|
192
|
+
}
|
|
193
|
+
} catch (error) {
|
|
194
|
+
plan.blockers.push(`Secret Manager secrets in ${config.project.id}: ${formatError(error)}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function planNeon(plan: DestroyPlan) {
|
|
199
|
+
try {
|
|
200
|
+
const neon = await resolveNeonConfig();
|
|
201
|
+
const branches = await listBranches(neon.projectId);
|
|
202
|
+
const disposableBranches = branches.filter(
|
|
203
|
+
(branch: { name: string }) =>
|
|
204
|
+
branch.name.startsWith(`${neon.previewBranchPrefix}-`) || branch.name.startsWith(`${neon.personalBranchPrefix}-`)
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
plan.neon = {
|
|
208
|
+
projectId: neon.projectId,
|
|
209
|
+
baseBranchId: neon.baseBranchId,
|
|
210
|
+
databaseName: neon.databaseName,
|
|
211
|
+
branches: disposableBranches,
|
|
212
|
+
};
|
|
213
|
+
plan.resources.push({ label: `Neon database ${neon.databaseName}`, detail: `${neon.projectId}/${neon.baseBranchName}` });
|
|
214
|
+
for (const branch of disposableBranches) {
|
|
215
|
+
plan.resources.push({ label: `Neon branch ${branch.name}`, detail: neon.projectId });
|
|
216
|
+
}
|
|
217
|
+
} catch (error) {
|
|
218
|
+
plan.skipped.push({ label: "Neon resources", detail: formatError(error) });
|
|
219
|
+
}
|
|
110
220
|
}
|
|
111
221
|
|
|
112
|
-
function
|
|
113
|
-
|
|
114
|
-
|
|
222
|
+
async function planGrafana(plan: DestroyPlan) {
|
|
223
|
+
if (!(await Bun.file("./grafana").exists())) {
|
|
224
|
+
plan.skipped.push({ label: "Grafana resources", detail: "no ./grafana directory" });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (!Bun.which("gcx")) {
|
|
228
|
+
plan.skipped.push({ label: "Grafana resources", detail: "gcx is not installed" });
|
|
115
229
|
return;
|
|
116
230
|
}
|
|
231
|
+
plan.resources.push({ label: "Grafana resources", detail: "./grafana manifests" });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function printDestroyPlan(plan: DestroyPlan) {
|
|
235
|
+
const lines = [
|
|
236
|
+
"Resources selected for destroy:",
|
|
237
|
+
...plan.resources.map((resource) => `- ${resource.label}${resource.detail ? ` (${resource.detail})` : ""}`),
|
|
238
|
+
];
|
|
239
|
+
if (plan.skipped.length > 0) {
|
|
240
|
+
lines.push("", "Skipped or not found:", ...plan.skipped.map((resource) => `- ${resource.label}${resource.detail ? ` (${resource.detail})` : ""}`));
|
|
241
|
+
}
|
|
242
|
+
if (plan.blockers.length > 0) {
|
|
243
|
+
lines.push("", "Discovery blockers:", ...plan.blockers.map((blocker) => `- ${blocker}`));
|
|
244
|
+
}
|
|
245
|
+
log.step(lines.join("\n"));
|
|
246
|
+
}
|
|
117
247
|
|
|
118
|
-
|
|
119
|
-
if (
|
|
120
|
-
|
|
248
|
+
async function deleteGrafanaResources() {
|
|
249
|
+
if (!(await Bun.file("./grafana").exists())) {
|
|
250
|
+
return "No grafana directory configured";
|
|
251
|
+
}
|
|
252
|
+
if (!Bun.which("gcx")) {
|
|
253
|
+
return "gcx is not installed; Grafana resources were not deleted";
|
|
121
254
|
}
|
|
122
255
|
|
|
123
|
-
|
|
256
|
+
run("gcx", ["resources", "delete", "--path", "./grafana", "--yes", "--on-error", "ignore"]);
|
|
257
|
+
return "Grafana resources deleted from local manifests";
|
|
124
258
|
}
|
|
125
259
|
|
|
126
260
|
async function requireDestroyConfirmation(force: boolean) {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { mkdir } from "node:fs/promises";
|
|
4
4
|
import { ensureAuthResourceServer, runAuthCommand, runAuthDoctor } from "../authctl";
|
|
5
|
-
import { bootstrap } from "./bootstrap";
|
|
5
|
+
import { bootstrap, prepareGcpProject } from "./bootstrap";
|
|
6
6
|
import { cleanup } from "./cleanup";
|
|
7
7
|
import { deploy } from "./deploy";
|
|
8
8
|
import { config } from "./config";
|
|
@@ -35,8 +35,9 @@ async function main(argv = Bun.argv.slice(2)) {
|
|
|
35
35
|
await runMain("Create", async () => {
|
|
36
36
|
assertServiceNameAvailable(config.serviceName);
|
|
37
37
|
assertProductionDomainAvailable(config.serviceName);
|
|
38
|
+
await prepareGcpProject();
|
|
38
39
|
await runStep("Registering auth resource server", () => ensureAuthResourceServer());
|
|
39
|
-
await bootstrap();
|
|
40
|
+
await bootstrap({ skipProjectSetup: true });
|
|
40
41
|
const target = resolveDeploymentTarget("main");
|
|
41
42
|
const databaseUrl = await runStep("Reading production database URL", () => accessSecretVersion(target.databaseSecretName));
|
|
42
43
|
await runStep("Applying production migrations", () => runLanguageTask("migrate", { DATABASE_URL: databaseUrl }));
|
|
@@ -161,7 +161,8 @@ export function ensureProject() {
|
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
export function attachBilling() {
|
|
164
|
-
|
|
164
|
+
const projectMode = config.project.mode as "create_new" | "use_existing";
|
|
165
|
+
if (projectMode === "use_existing") {
|
|
165
166
|
return "Using existing project billing";
|
|
166
167
|
}
|
|
167
168
|
gcloud(["beta", "billing", "projects", "link", config.project.id, "--billing-account", config.project.billingAccount]);
|