create-svc 0.1.7 → 0.1.9
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 +22 -12
- package/package.json +1 -1
- package/src/cli.test.ts +30 -1
- package/src/cli.ts +50 -16
- package/src/scaffold.test.ts +4 -1
- package/src/vault.test.ts +33 -0
- package/src/vault.ts +21 -2
- package/templates/shared/.env.example +2 -2
- package/templates/shared/README.md +20 -14
- package/templates/shared/scripts/cloudrun/bootstrap.ts +50 -35
- package/templates/shared/scripts/cloudrun/cleanup.ts +100 -0
- package/templates/shared/scripts/cloudrun/cli.ts +34 -0
- package/templates/shared/scripts/cloudrun/deploy.ts +49 -34
- package/templates/shared/scripts/cloudrun/lib.ts +156 -19
- package/templates/shared/scripts/cloudrun/neon.ts +31 -2
- package/templates/variants/bun-connectrpc/Makefile +24 -0
- package/templates/variants/bun-connectrpc/package.json +3 -7
- package/templates/variants/bun-hono/Makefile +24 -0
- package/templates/variants/bun-hono/package.json +3 -7
- package/templates/variants/go-chi/Makefile +25 -0
- package/templates/variants/go-chi/package.json +4 -8
- package/templates/variants/go-chi/test/go.test.ts +3 -9
- package/templates/variants/go-connectrpc/Makefile +25 -0
- package/templates/variants/go-connectrpc/package.json +4 -8
- package/templates/variants/go-connectrpc/test/go.test.ts +3 -9
package/README.md
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# create-svc
|
|
2
2
|
|
|
3
|
-
`create-svc` is a Bun-authored scaffold CLI for generating
|
|
3
|
+
`create-svc` is a Bun-authored scaffold CLI for generating Cloud Run services with:
|
|
4
4
|
|
|
5
|
-
-
|
|
6
|
-
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
5
|
+
- `go + chi`
|
|
6
|
+
- `go + connectrpc`
|
|
7
|
+
- `bun + hono`
|
|
8
|
+
- `bun + connectrpc`
|
|
9
|
+
- a real `service.yaml` manifest
|
|
10
|
+
- shared Cloud Run bootstrap, deploy, and cleanup automation
|
|
11
|
+
- Neon-backed main, preview, and personal environments
|
|
10
12
|
|
|
11
13
|
## Usage
|
|
12
14
|
|
|
@@ -15,14 +17,22 @@ bun install
|
|
|
15
17
|
bun run index.ts my-service
|
|
16
18
|
```
|
|
17
19
|
|
|
18
|
-
The
|
|
20
|
+
The generator discovers:
|
|
21
|
+
|
|
22
|
+
- accessible GCP projects
|
|
23
|
+
- open billing accounts
|
|
24
|
+
- Neon defaults from `NEON_API_KEY`, or Vault via `VAULT_ADDR` plus `VAULT_TOKEN`, `VAULT_TOKEN_FILE`, or `~/.vault-token`
|
|
25
|
+
|
|
26
|
+
Generated repos are `Makefile`-first. The shared Cloud Run control plane is exposed as a local CLI bin and invoked by `make`.
|
|
19
27
|
|
|
20
28
|
```bash
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
29
|
+
make dev
|
|
30
|
+
make gen
|
|
31
|
+
make lint
|
|
32
|
+
make test
|
|
33
|
+
make bootstrap
|
|
34
|
+
make deploy
|
|
35
|
+
make cleanup
|
|
26
36
|
```
|
|
27
37
|
|
|
28
38
|
## Development
|
package/package.json
CHANGED
package/src/cli.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import { assertDiscoveryReady, normalizeValidationResult, validateServiceNameInput } from "./cli";
|
|
3
4
|
|
|
4
5
|
test("normalizeValidationResult converts success to undefined", () => {
|
|
5
6
|
expect(normalizeValidationResult(true)).toBeUndefined();
|
|
@@ -8,3 +9,31 @@ test("normalizeValidationResult converts success to undefined", () => {
|
|
|
8
9
|
test("normalizeValidationResult preserves validation errors", () => {
|
|
9
10
|
expect(normalizeValidationResult("Service name is required")).toBe("Service name is required");
|
|
10
11
|
});
|
|
12
|
+
|
|
13
|
+
test("assertDiscoveryReady requires Neon discovery to succeed", () => {
|
|
14
|
+
expect(() =>
|
|
15
|
+
assertDiscoveryReady({
|
|
16
|
+
projects: [],
|
|
17
|
+
billingAccounts: [],
|
|
18
|
+
warnings: [],
|
|
19
|
+
neonError: "Vault secret resolution requires VAULT_ADDR, VAULT_TOKEN, and a secret path",
|
|
20
|
+
})
|
|
21
|
+
).toThrow(
|
|
22
|
+
"Neon discovery is required before scaffolding. Set NEON_API_KEY, or use Vault by providing VAULT_ADDR and either VAULT_TOKEN, VAULT_TOKEN_FILE, or ~/.vault-token. Optional overrides: VAULT_SECRET_MOUNT, VAULT_NEON_API_KEY_PATH, VAULT_NEON_API_KEY_FIELD."
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("validateServiceNameInput rejects a taken target directory", async () => {
|
|
27
|
+
const cwd = process.cwd();
|
|
28
|
+
const root = "/tmp/create-svc-cli-validation";
|
|
29
|
+
await mkdir(root, { recursive: true });
|
|
30
|
+
await mkdir(`${root}/taken-app`, { recursive: true });
|
|
31
|
+
await Bun.write(`${root}/taken-app/keep.txt`, "x");
|
|
32
|
+
|
|
33
|
+
process.chdir(root);
|
|
34
|
+
try {
|
|
35
|
+
expect(validateServiceNameInput("taken-app")).toBe("Directory already exists and is not empty");
|
|
36
|
+
} finally {
|
|
37
|
+
process.chdir(cwd);
|
|
38
|
+
}
|
|
39
|
+
});
|
package/src/cli.ts
CHANGED
|
@@ -5,13 +5,13 @@ import {
|
|
|
5
5
|
intro,
|
|
6
6
|
isCancel,
|
|
7
7
|
log,
|
|
8
|
-
note,
|
|
9
8
|
outro,
|
|
10
9
|
select,
|
|
11
10
|
spinner,
|
|
12
11
|
text,
|
|
13
12
|
} from "@clack/prompts";
|
|
14
13
|
import pc from "picocolors";
|
|
14
|
+
import { readdirSync } from "node:fs";
|
|
15
15
|
import { basename, dirname, resolve } from "node:path";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
17
|
import { runPostScaffoldFlow } from "./post-scaffold";
|
|
@@ -55,6 +55,7 @@ type DiscoveryState = {
|
|
|
55
55
|
neonProjectId?: string;
|
|
56
56
|
neonBaseBranchId?: string;
|
|
57
57
|
neonBaseBranchName?: string;
|
|
58
|
+
neonError?: string;
|
|
58
59
|
warnings: string[];
|
|
59
60
|
};
|
|
60
61
|
|
|
@@ -255,18 +256,19 @@ function parseArgs(argv: string[]): ParsedArgs {
|
|
|
255
256
|
|
|
256
257
|
export async function resolveConfig(args: ParsedArgs): Promise<ScaffoldConfig> {
|
|
257
258
|
const inferredName = slugify(basename(args.directory ?? "my-service"));
|
|
258
|
-
const discoveryPromise = discoverCloudInputs();
|
|
259
259
|
const serviceName = args.yes
|
|
260
260
|
? inferredName
|
|
261
|
-
: await promptText("Service name", inferredName, (value) =>
|
|
261
|
+
: await promptText("Service name", inferredName, (value) => validateServiceNameInput(value, args.directory));
|
|
262
262
|
const directory = args.directory ?? serviceName;
|
|
263
263
|
const targetDir = resolve(process.cwd(), directory);
|
|
264
264
|
await assertTargetDirectoryIsEmpty(targetDir);
|
|
265
265
|
|
|
266
|
+
const discoveryPromise = discoverCloudInputs();
|
|
266
267
|
const defaults = deriveDefaults(serviceName);
|
|
267
268
|
const runtime = await resolveRuntime(args);
|
|
268
269
|
const framework = await resolveFramework(args, runtime);
|
|
269
270
|
const discovery = await discoveryPromise;
|
|
271
|
+
assertDiscoveryReady(discovery);
|
|
270
272
|
const gcpSelection = await resolveGcpSelection(args, defaults, discovery);
|
|
271
273
|
const githubRepo = args.githubRepo ?? defaults.githubRepo;
|
|
272
274
|
const region = args.region ?? DEFAULT_REGION;
|
|
@@ -487,12 +489,20 @@ async function discoverCloudInputs(): Promise<DiscoveryState> {
|
|
|
487
489
|
result.neonBaseBranchId = neonDefaults.baseBranchId;
|
|
488
490
|
result.neonBaseBranchName = neonDefaults.baseBranchName;
|
|
489
491
|
} catch (error) {
|
|
490
|
-
result.
|
|
492
|
+
result.neonError = formatError(error);
|
|
491
493
|
}
|
|
492
494
|
|
|
493
495
|
return result;
|
|
494
496
|
}
|
|
495
497
|
|
|
498
|
+
export function assertDiscoveryReady(discovery: DiscoveryState) {
|
|
499
|
+
if (!discovery.neonError) {
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
throw new Error(formatNeonDiscoveryRequirement(discovery.neonError));
|
|
504
|
+
}
|
|
505
|
+
|
|
496
506
|
function chooseBillingAccount(input: string | undefined, accounts: BillingAccount[]) {
|
|
497
507
|
if (input) {
|
|
498
508
|
return input;
|
|
@@ -536,11 +546,21 @@ function formatError(error: unknown) {
|
|
|
536
546
|
return error instanceof Error ? error.message : String(error);
|
|
537
547
|
}
|
|
538
548
|
|
|
549
|
+
function formatNeonDiscoveryRequirement(reason: string) {
|
|
550
|
+
if (reason.includes("Vault secret resolution requires")) {
|
|
551
|
+
return [
|
|
552
|
+
"Neon discovery is required before scaffolding.",
|
|
553
|
+
"Set NEON_API_KEY, or use Vault by providing VAULT_ADDR and either VAULT_TOKEN, VAULT_TOKEN_FILE, or ~/.vault-token.",
|
|
554
|
+
"Optional overrides: VAULT_SECRET_MOUNT, VAULT_NEON_API_KEY_PATH, VAULT_NEON_API_KEY_FIELD.",
|
|
555
|
+
].join(" ");
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return `Neon discovery is required before scaffolding: ${reason}`;
|
|
559
|
+
}
|
|
560
|
+
|
|
539
561
|
function handleCliError(error: unknown) {
|
|
540
562
|
if (error instanceof DirectoryConflictError) {
|
|
541
|
-
log.error(`
|
|
542
|
-
note(formatConflictEntries(error.entries), "Conflicting files");
|
|
543
|
-
log.message("Either try using a new directory name, or remove the files listed above.");
|
|
563
|
+
log.error(`Target directory already exists and is not empty: ${error.targetDir}`);
|
|
544
564
|
process.exit(1);
|
|
545
565
|
}
|
|
546
566
|
|
|
@@ -548,15 +568,6 @@ function handleCliError(error: unknown) {
|
|
|
548
568
|
process.exit(1);
|
|
549
569
|
}
|
|
550
570
|
|
|
551
|
-
function formatConflictEntries(entries: string[]) {
|
|
552
|
-
const visibleEntries = entries.slice(0, 12);
|
|
553
|
-
const lines = visibleEntries.map((entry) => `- ${entry}`);
|
|
554
|
-
if (entries.length > visibleEntries.length) {
|
|
555
|
-
lines.push(`- ...and ${entries.length - visibleEntries.length} more`);
|
|
556
|
-
}
|
|
557
|
-
return lines.join("\n");
|
|
558
|
-
}
|
|
559
|
-
|
|
560
571
|
async function promptForExistingProject(projects: GcpProject[]) {
|
|
561
572
|
const value = await autocomplete({
|
|
562
573
|
message: "Existing GCP project",
|
|
@@ -601,6 +612,29 @@ export function normalizeValidationResult(result: true | string): string | undef
|
|
|
601
612
|
return result === true ? undefined : result;
|
|
602
613
|
}
|
|
603
614
|
|
|
615
|
+
export function validateServiceNameInput(rawValue: string, directoryOverride?: string) {
|
|
616
|
+
const serviceName = slugify(rawValue);
|
|
617
|
+
if (!serviceName) {
|
|
618
|
+
return "Service name is required";
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const directory = directoryOverride ?? serviceName;
|
|
622
|
+
const targetDir = resolve(process.cwd(), directory);
|
|
623
|
+
|
|
624
|
+
try {
|
|
625
|
+
const entries = readdirSync(targetDir);
|
|
626
|
+
if (entries.length > 0) {
|
|
627
|
+
return "Directory already exists and is not empty";
|
|
628
|
+
}
|
|
629
|
+
} catch (error) {
|
|
630
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
631
|
+
return "Unable to check target directory";
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
return true;
|
|
636
|
+
}
|
|
637
|
+
|
|
604
638
|
function printHelp() {
|
|
605
639
|
log.message(`
|
|
606
640
|
Usage:
|
package/src/scaffold.test.ts
CHANGED
|
@@ -72,7 +72,10 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
|
|
|
72
72
|
expect(mainGo).toContain("NewDNSService");
|
|
73
73
|
} else {
|
|
74
74
|
const packageJson = await Bun.file(join(generatedRoot, "package.json")).text();
|
|
75
|
-
expect(packageJson).toContain('"
|
|
75
|
+
expect(packageJson).toContain('"svc-cloudrun": "./scripts/cloudrun/cli.ts"');
|
|
76
|
+
|
|
77
|
+
const makefile = await Bun.file(join(generatedRoot, "Makefile")).text();
|
|
78
|
+
expect(makefile).toContain("npx --no-install svc-cloudrun");
|
|
76
79
|
|
|
77
80
|
const entrypoint = await Bun.file(join(generatedRoot, "src", "index.ts")).text();
|
|
78
81
|
expect(entrypoint).toContain(variant.framework === "hono" ? "Hono" : "rpc.example.v1.Service/Ping");
|
package/src/vault.test.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { afterEach, expect, mock, test } from "bun:test";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
2
3
|
import { readVaultSecret, resolveNeonApiKey } from "./vault";
|
|
3
4
|
|
|
4
5
|
const originalEnv = { ...process.env };
|
|
@@ -40,3 +41,35 @@ test("readVaultSecret reads KV v2 secret data using existing vault login env", a
|
|
|
40
41
|
})
|
|
41
42
|
).resolves.toBe("vault-token");
|
|
42
43
|
});
|
|
44
|
+
|
|
45
|
+
test("readVaultSecret falls back to ~/.vault-token", async () => {
|
|
46
|
+
const home = "/tmp/create-svc-vault-home";
|
|
47
|
+
process.env.HOME = home;
|
|
48
|
+
process.env.VAULT_ADDR = "https://vault.example.com";
|
|
49
|
+
delete process.env.VAULT_TOKEN;
|
|
50
|
+
|
|
51
|
+
await mkdir(home, { recursive: true });
|
|
52
|
+
await Bun.write(`${home}/.vault-token`, "token-from-file\n");
|
|
53
|
+
|
|
54
|
+
const fetchMock = mock(async () => {
|
|
55
|
+
return new Response(
|
|
56
|
+
JSON.stringify({
|
|
57
|
+
data: {
|
|
58
|
+
data: {
|
|
59
|
+
value: "vault-token",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
}),
|
|
63
|
+
{ status: 200 }
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
globalThis.fetch = fetchMock as typeof fetch;
|
|
68
|
+
|
|
69
|
+
await expect(
|
|
70
|
+
readVaultSecret({
|
|
71
|
+
path: "provider/neon-api-key",
|
|
72
|
+
field: "value",
|
|
73
|
+
})
|
|
74
|
+
).resolves.toBe("vault-token");
|
|
75
|
+
});
|
package/src/vault.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
1
4
|
const DEFAULT_VAULT_SECRET_MOUNT = "secret";
|
|
2
5
|
const DEFAULT_NEON_API_KEY_PATH = "provider/neon-api-key";
|
|
3
6
|
const DEFAULT_NEON_API_KEY_FIELD = "value";
|
|
@@ -24,13 +27,13 @@ export async function resolveNeonApiKey() {
|
|
|
24
27
|
|
|
25
28
|
export async function readVaultSecret(options: VaultSecretOptions = {}) {
|
|
26
29
|
const addr = options.addr ?? process.env.VAULT_ADDR?.trim() ?? "";
|
|
27
|
-
const token = options.token ??
|
|
30
|
+
const token = options.token ?? (await resolveVaultToken());
|
|
28
31
|
const mount = options.mount ?? process.env.VAULT_SECRET_MOUNT?.trim() ?? DEFAULT_VAULT_SECRET_MOUNT;
|
|
29
32
|
const path = options.path?.trim() ?? "";
|
|
30
33
|
const field = options.field?.trim() ?? "value";
|
|
31
34
|
|
|
32
35
|
if (!addr || !token || !path) {
|
|
33
|
-
throw new Error("Vault secret resolution requires VAULT_ADDR,
|
|
36
|
+
throw new Error("Vault secret resolution requires VAULT_ADDR, a Vault token, and a secret path");
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
const normalizedAddr = addr.replace(/\/+$/g, "");
|
|
@@ -61,3 +64,19 @@ export async function readVaultSecret(options: VaultSecretOptions = {}) {
|
|
|
61
64
|
|
|
62
65
|
return value;
|
|
63
66
|
}
|
|
67
|
+
|
|
68
|
+
async function resolveVaultToken() {
|
|
69
|
+
const direct = process.env.VAULT_TOKEN?.trim();
|
|
70
|
+
if (direct) {
|
|
71
|
+
return direct;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const tokenFile = process.env.VAULT_TOKEN_FILE?.trim() || join(homedir(), ".vault-token");
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const value = (await Bun.file(tokenFile).text()).trim();
|
|
78
|
+
return value;
|
|
79
|
+
} catch {
|
|
80
|
+
return "";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -6,5 +6,5 @@ VAULT_SECRET_MOUNT=secret
|
|
|
6
6
|
VAULT_NEON_API_KEY_PATH=provider/neon-api-key
|
|
7
7
|
VAULT_NEON_API_KEY_FIELD=value
|
|
8
8
|
|
|
9
|
-
# Do not commit VAULT_TOKEN. Prefer `vault login
|
|
10
|
-
|
|
9
|
+
# Do not commit VAULT_TOKEN. Prefer `vault login`; the CLI will also use
|
|
10
|
+
# VAULT_TOKEN_FILE or ~/.vault-token when available.
|
|
@@ -5,7 +5,7 @@ Generated by `create-svc`.
|
|
|
5
5
|
This scaffold targets `{{RUNTIME}} + {{FRAMEWORK}}` on Cloud Run with:
|
|
6
6
|
|
|
7
7
|
- one generated `service.yaml` manifest
|
|
8
|
-
-
|
|
8
|
+
- a local `svc-cloudrun` CLI for bootstrap, deploy, and cleanup
|
|
9
9
|
- GitHub Actions for CI, `main` deploys, PR previews, and personal environments
|
|
10
10
|
- GCP project bootstrap with billing and quota-project-aware `gcloud` calls
|
|
11
11
|
- Neon main, preview, and personal branch provisioning
|
|
@@ -13,25 +13,28 @@ This scaffold targets `{{RUNTIME}} + {{FRAMEWORK}}` on Cloud Run with:
|
|
|
13
13
|
## Commands
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
16
|
+
make dev
|
|
17
|
+
make gen
|
|
18
|
+
make lint
|
|
19
|
+
make test
|
|
20
|
+
make bootstrap
|
|
21
|
+
make deploy
|
|
22
|
+
make deploy ARGS="--environment personal --name <slug>"
|
|
23
|
+
make deploy ARGS="--destroy --environment personal --name <slug>"
|
|
24
|
+
make cleanup
|
|
25
|
+
make cleanup ARGS="--repo --project"
|
|
24
26
|
```
|
|
25
27
|
|
|
26
28
|
## Configuration
|
|
27
29
|
|
|
28
30
|
The generated Cloud Run config lives in [scripts/cloudrun/config.ts](scripts/cloudrun/config.ts).
|
|
29
31
|
|
|
30
|
-
Bootstrap and
|
|
32
|
+
Bootstrap, deploy, and cleanup use:
|
|
31
33
|
|
|
32
34
|
- `gcloud`
|
|
33
35
|
- `gh`
|
|
34
|
-
- `NEON_API_KEY`, or a working Vault login via `VAULT_ADDR`
|
|
36
|
+
- `NEON_API_KEY`, or a working Vault login via `VAULT_ADDR` plus `VAULT_TOKEN`, `VAULT_TOKEN_FILE`, or `~/.vault-token`
|
|
37
|
+
- the local repo CLI via `npx --no-install svc-cloudrun ...`
|
|
35
38
|
|
|
36
39
|
## Environment setup
|
|
37
40
|
|
|
@@ -43,14 +46,17 @@ cp .env.example .env.local
|
|
|
43
46
|
|
|
44
47
|
Then edit `.env.local` with your Vault address and secret path overrides.
|
|
45
48
|
|
|
46
|
-
For the token itself, prefer a
|
|
49
|
+
For the token itself, prefer a normal Vault login flow:
|
|
47
50
|
|
|
48
51
|
```bash
|
|
49
52
|
vault login
|
|
50
|
-
export VAULT_TOKEN="$(vault print token)"
|
|
51
53
|
```
|
|
52
54
|
|
|
53
|
-
|
|
55
|
+
The scaffold will use, in order:
|
|
56
|
+
|
|
57
|
+
1. `VAULT_TOKEN`
|
|
58
|
+
2. `VAULT_TOKEN_FILE`
|
|
59
|
+
3. `~/.vault-token`
|
|
54
60
|
|
|
55
61
|
That keeps stable settings in the repo and keeps the token out of `~/.zshrc`.
|
|
56
62
|
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
gcloud,
|
|
15
15
|
requireCommand,
|
|
16
16
|
resolveDeploymentTarget,
|
|
17
|
+
runMain,
|
|
18
|
+
runStep,
|
|
17
19
|
setGithubVariable,
|
|
18
20
|
workloadIdentityPoolResource,
|
|
19
21
|
workloadIdentityProviderResource,
|
|
@@ -23,54 +25,67 @@ export async function bootstrap() {
|
|
|
23
25
|
requireCommand("gcloud");
|
|
24
26
|
requireCommand("gh");
|
|
25
27
|
|
|
26
|
-
ensureProject();
|
|
27
|
-
attachBilling();
|
|
28
|
-
gcloud(["services", "enable", ...config.requiredApis, "--project", config.project.id]);
|
|
28
|
+
await runStep("Ensuring GCP project", () => ensureProject());
|
|
29
|
+
await runStep("Attaching billing", () => attachBilling());
|
|
30
|
+
await runStep("Enabling required GCP APIs", () => gcloud(["services", "enable", ...config.requiredApis, "--project", config.project.id]));
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
await runStep("Ensuring runtime and deployer service accounts", () => {
|
|
33
|
+
ensureServiceAccount(config.runtimeServiceAccount);
|
|
34
|
+
ensureServiceAccount(config.deployerServiceAccount);
|
|
35
|
+
});
|
|
33
36
|
|
|
34
|
-
|
|
35
|
-
ensureProjectRole(`serviceAccount:${config.deployerServiceAccount}`, "roles/cloudbuild.builds.editor");
|
|
36
|
-
ensureProjectRole(`serviceAccount:${config.deployerServiceAccount}`, "roles/artifactregistry.writer");
|
|
37
|
-
ensureProjectRole(`serviceAccount:${config.deployerServiceAccount}`, "roles/serviceusage.serviceUsageConsumer");
|
|
38
|
-
ensureProjectRole(`serviceAccount:${config.runtimeServiceAccount}`, "roles/secretmanager.secretAccessor");
|
|
37
|
+
await runStep("Ensuring Artifact Registry repository", () => ensureArtifactRepository());
|
|
39
38
|
|
|
40
|
-
|
|
39
|
+
await runStep("Granting project roles", () => {
|
|
40
|
+
ensureProjectRole(`serviceAccount:${config.deployerServiceAccount}`, "roles/run.admin");
|
|
41
|
+
ensureProjectRole(`serviceAccount:${config.deployerServiceAccount}`, "roles/cloudbuild.builds.editor");
|
|
42
|
+
ensureProjectRole(`serviceAccount:${config.deployerServiceAccount}`, "roles/artifactregistry.writer");
|
|
43
|
+
ensureProjectRole(`serviceAccount:${config.deployerServiceAccount}`, "roles/serviceusage.serviceUsageConsumer");
|
|
44
|
+
ensureProjectRole(`serviceAccount:${config.runtimeServiceAccount}`, "roles/secretmanager.secretAccessor");
|
|
45
|
+
ensureServiceAccountRole(config.runtimeServiceAccount, `serviceAccount:${config.deployerServiceAccount}`, "roles/iam.serviceAccountUser");
|
|
46
|
+
});
|
|
41
47
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
await runStep("Ensuring Workload Identity setup", () => {
|
|
49
|
+
ensureWorkloadIdentityPool();
|
|
50
|
+
ensureWorkloadIdentityProvider();
|
|
51
|
+
ensureServiceAccountRole(
|
|
52
|
+
config.deployerServiceAccount,
|
|
53
|
+
`principalSet://iam.googleapis.com/${workloadIdentityPoolResource()}/attribute.repository/${config.github.repo}`,
|
|
54
|
+
"roles/iam.workloadIdentityUser"
|
|
55
|
+
);
|
|
56
|
+
});
|
|
49
57
|
|
|
50
58
|
if (!config.neon.projectId || !config.neon.baseBranchId) {
|
|
51
59
|
throw new Error("Neon project and base branch must be configured before bootstrap");
|
|
52
60
|
}
|
|
53
61
|
|
|
54
62
|
const target = resolveDeploymentTarget("main");
|
|
55
|
-
await ensureDatabase(config.neon.projectId, config.neon.baseBranchId, config.neon.databaseName);
|
|
56
|
-
const connectionUri = await getConnectionUri(
|
|
57
|
-
config.neon.projectId,
|
|
58
|
-
config.neon.baseBranchId,
|
|
59
|
-
config.neon.databaseName,
|
|
60
|
-
config.neon.roleName
|
|
61
|
-
);
|
|
62
|
-
addSecretVersion(target.databaseSecretName, connectionUri);
|
|
63
|
-
ensureSecretAccessor(target.databaseSecretName, `serviceAccount:${config.runtimeServiceAccount}`);
|
|
63
|
+
await runStep("Ensuring Neon database", () => ensureDatabase(config.neon.projectId, config.neon.baseBranchId, config.neon.databaseName));
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
await runStep("Publishing database secret", async () => {
|
|
66
|
+
const connectionUri = await getConnectionUri(
|
|
67
|
+
config.neon.projectId,
|
|
68
|
+
config.neon.baseBranchId,
|
|
69
|
+
config.neon.databaseName,
|
|
70
|
+
config.neon.roleName
|
|
71
|
+
);
|
|
72
|
+
addSecretVersion(target.databaseSecretName, connectionUri);
|
|
73
|
+
ensureSecretAccessor(target.databaseSecretName, `serviceAccount:${config.runtimeServiceAccount}`);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await runStep("Configuring GitHub repository variables", () => {
|
|
77
|
+
for (const [name, value] of Object.entries(githubVariables)) {
|
|
78
|
+
setGithubVariable(name, value);
|
|
79
|
+
}
|
|
68
80
|
|
|
69
|
-
|
|
70
|
-
|
|
81
|
+
setGithubVariable("GCP_WIF_PROVIDER", workloadIdentityProviderResource());
|
|
82
|
+
setGithubVariable("GCP_DEPLOYER_SERVICE_ACCOUNT", config.deployerServiceAccount);
|
|
83
|
+
});
|
|
71
84
|
}
|
|
72
85
|
|
|
73
86
|
if (import.meta.main) {
|
|
74
|
-
await
|
|
87
|
+
await runMain("Bootstrap", async () => {
|
|
88
|
+
await bootstrap();
|
|
89
|
+
return `Bootstrap finished for ${config.serviceName}`;
|
|
90
|
+
});
|
|
75
91
|
}
|
|
76
|
-
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { log } from "@clack/prompts";
|
|
2
|
+
import { config, githubVariables } from "./config";
|
|
3
|
+
import { deleteBranch, deleteDatabase, listBranches } from "./neon";
|
|
4
|
+
import {
|
|
5
|
+
deleteGithubRepository,
|
|
6
|
+
deleteGithubVariable,
|
|
7
|
+
deleteProject,
|
|
8
|
+
deleteSecret,
|
|
9
|
+
deleteService,
|
|
10
|
+
deleteServiceAccount,
|
|
11
|
+
deleteWorkloadIdentityProvider,
|
|
12
|
+
listCloudRunServices,
|
|
13
|
+
listSecrets,
|
|
14
|
+
parseCleanupArgs,
|
|
15
|
+
requireCommand,
|
|
16
|
+
runMain,
|
|
17
|
+
runStep,
|
|
18
|
+
} from "./lib";
|
|
19
|
+
|
|
20
|
+
function matchesServiceResource(name: string) {
|
|
21
|
+
return name === config.serviceName || name.startsWith(`${config.serviceName}-pr-`) || name.startsWith(`${config.serviceName}-dev-`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function matchesSecretResource(name: string) {
|
|
25
|
+
return name === `${config.serviceName}-database-url` || name.startsWith(`${config.serviceName}-pr-`) || name.startsWith(`${config.serviceName}-dev-`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function cleanup(args = Bun.argv.slice(2)) {
|
|
29
|
+
requireCommand("gcloud");
|
|
30
|
+
|
|
31
|
+
const options = parseCleanupArgs(args);
|
|
32
|
+
|
|
33
|
+
const services = await runStep("Finding Cloud Run services", () => listCloudRunServices());
|
|
34
|
+
const serviceNames = services.filter(matchesServiceResource);
|
|
35
|
+
await runStep("Deleting Cloud Run services", () => {
|
|
36
|
+
for (const serviceName of serviceNames) {
|
|
37
|
+
deleteService(serviceName);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const secrets = await runStep("Finding service secrets", () => listSecrets());
|
|
42
|
+
const secretNames = secrets.filter(matchesSecretResource);
|
|
43
|
+
await runStep("Deleting service secrets", () => {
|
|
44
|
+
for (const secretName of secretNames) {
|
|
45
|
+
deleteSecret(secretName);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (config.neon.projectId && config.neon.baseBranchId) {
|
|
50
|
+
const branches = await runStep("Finding Neon branches", () => listBranches(config.neon.projectId));
|
|
51
|
+
const disposableBranches = branches.filter(
|
|
52
|
+
(branch) => branch.name.startsWith(`${config.neon.previewBranchPrefix}-`) || branch.name.startsWith(`${config.neon.personalBranchPrefix}-`)
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
await runStep("Deleting Neon preview and personal branches", async () => {
|
|
56
|
+
for (const branch of disposableBranches) {
|
|
57
|
+
await deleteBranch(config.neon.projectId, branch.id);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await runStep("Deleting Neon service database", () =>
|
|
62
|
+
deleteDatabase(config.neon.projectId, config.neon.baseBranchId, config.neon.databaseName)
|
|
63
|
+
);
|
|
64
|
+
} else {
|
|
65
|
+
log.step("Skipping Neon cleanup because Neon is not configured");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await runStep("Deleting service-specific identity resources", () => {
|
|
69
|
+
deleteWorkloadIdentityProvider();
|
|
70
|
+
deleteServiceAccount(config.runtimeServiceAccount);
|
|
71
|
+
deleteServiceAccount(config.deployerServiceAccount);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (Bun.which("gh")) {
|
|
75
|
+
await runStep("Deleting GitHub repository variables", () => {
|
|
76
|
+
for (const name of [...Object.keys(githubVariables), "GCP_WIF_PROVIDER", "GCP_DEPLOYER_SERVICE_ACCOUNT"]) {
|
|
77
|
+
deleteGithubVariable(name);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (options.destroyRepo) {
|
|
82
|
+
await runStep(`Deleting GitHub repository ${config.github.repo}`, () => deleteGithubRepository());
|
|
83
|
+
}
|
|
84
|
+
} else if (options.destroyRepo) {
|
|
85
|
+
throw new Error("gh is required to delete the GitHub repository");
|
|
86
|
+
} else {
|
|
87
|
+
log.step("Skipping GitHub cleanup because gh is not installed");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (options.destroyProject) {
|
|
91
|
+
await runStep(`Deleting GCP project ${config.project.id}`, () => deleteProject());
|
|
92
|
+
return `Deleted project ${config.project.id}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return `Cleanup finished for ${config.serviceName}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (import.meta.main) {
|
|
99
|
+
await runMain("Cleanup", () => cleanup(Bun.argv.slice(2)));
|
|
100
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { bootstrap } from "./bootstrap";
|
|
4
|
+
import { cleanup } from "./cleanup";
|
|
5
|
+
import { deploy } from "./deploy";
|
|
6
|
+
import { runMain } from "./lib";
|
|
7
|
+
|
|
8
|
+
async function main(argv = Bun.argv.slice(2)) {
|
|
9
|
+
const [command, ...rest] = argv;
|
|
10
|
+
|
|
11
|
+
if (command === "bootstrap") {
|
|
12
|
+
await runMain("Bootstrap", async () => {
|
|
13
|
+
await bootstrap();
|
|
14
|
+
return "Bootstrap finished";
|
|
15
|
+
});
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (command === "deploy") {
|
|
20
|
+
await runMain("Deploy", () => deploy(rest));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (command === "cleanup") {
|
|
25
|
+
await runMain("Cleanup", () => cleanup(rest));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
throw new Error("Usage: svc-cloudrun <bootstrap|deploy|cleanup> [args]");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (import.meta.main) {
|
|
33
|
+
await main();
|
|
34
|
+
}
|