create-svc 0.1.8 → 0.1.10
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 +142 -13
- package/package.json +9 -4
- package/src/cli.test.ts +29 -8
- package/src/cli.ts +103 -70
- package/src/naming.test.ts +4 -2
- package/src/naming.ts +9 -1
- package/src/neon.ts +10 -8
- package/src/post-scaffold.ts +7 -28
- package/src/profiles.ts +28 -0
- package/src/scaffold.test.ts +126 -15
- package/src/scaffold.ts +94 -23
- package/src/vault.test.ts +62 -5
- package/src/vault.ts +24 -4
- package/templates/shared/README.md +143 -26
- package/templates/shared/docker-compose.yml +19 -0
- package/templates/shared/scripts/cloudrun/bootstrap.ts +15 -42
- package/templates/shared/scripts/cloudrun/cleanup.ts +17 -31
- package/templates/shared/scripts/cloudrun/config.ts +14 -19
- package/templates/shared/scripts/cloudrun/deploy.ts +19 -10
- package/templates/shared/scripts/cloudrun/integrations.ts +111 -0
- package/templates/shared/scripts/cloudrun/lib.ts +88 -112
- package/templates/shared/scripts/cloudrun/neon.ts +100 -14
- package/templates/shared/service.yaml +44 -1
- package/templates/variants/bun-connectrpc/Dockerfile +1 -0
- package/templates/variants/bun-connectrpc/Makefile +4 -1
- package/templates/variants/bun-connectrpc/gen/protos/chat/v1/chat_pb.ts +1078 -0
- package/templates/variants/bun-connectrpc/migrations/0000_init.sql +63 -0
- package/templates/variants/bun-connectrpc/package.json +17 -0
- package/templates/variants/bun-connectrpc/protos/chat/v1/chat.proto +228 -0
- package/templates/variants/bun-connectrpc/scripts/codegen.ts +31 -1
- package/templates/variants/bun-connectrpc/scripts/migrate.ts +46 -0
- package/templates/variants/bun-connectrpc/src/chat/service.ts +384 -0
- package/templates/variants/bun-connectrpc/src/chat/types.ts +142 -0
- package/templates/variants/bun-connectrpc/src/db/client.ts +15 -0
- package/templates/variants/bun-connectrpc/src/db/repository.ts +479 -0
- package/templates/variants/bun-connectrpc/src/db/schema.ts +75 -0
- package/templates/variants/bun-connectrpc/src/index.ts +294 -22
- package/templates/variants/bun-connectrpc/src/storage.ts +72 -0
- package/templates/variants/bun-connectrpc/src/webhooks.ts +35 -0
- package/templates/variants/bun-connectrpc/test/app.test.ts +14 -13
- package/templates/variants/bun-connectrpc/test/list-messages.integration.test.ts +182 -0
- package/templates/variants/bun-connectrpc/tsconfig.json +2 -1
- package/templates/variants/bun-hono/Makefile +4 -1
- package/templates/variants/bun-hono/migrations/0000_init.sql +63 -0
- package/templates/variants/bun-hono/package.json +13 -0
- package/templates/variants/bun-hono/scripts/migrate.ts +46 -0
- package/templates/variants/bun-hono/src/chat/service.ts +384 -0
- package/templates/variants/bun-hono/src/chat/types.ts +142 -0
- package/templates/variants/bun-hono/src/db/client.ts +15 -0
- package/templates/variants/bun-hono/src/db/repository.ts +479 -0
- package/templates/variants/bun-hono/src/db/schema.ts +75 -0
- package/templates/variants/bun-hono/src/index.ts +254 -8
- package/templates/variants/bun-hono/src/storage.ts +72 -0
- package/templates/variants/bun-hono/src/webhooks.ts +35 -0
- package/templates/variants/bun-hono/test/app.test.ts +60 -6
- package/templates/variants/bun-hono/test/list-messages.integration.test.ts +256 -0
- package/templates/variants/bun-hono/tsconfig.json +1 -0
- package/templates/variants/go-chi/Makefile +6 -2
- package/templates/variants/go-chi/buf.gen.yaml +2 -0
- package/templates/variants/go-chi/cmd/migrate/main.go +101 -0
- package/templates/variants/go-chi/cmd/server/main.go +16 -15
- package/templates/variants/go-chi/go.mod +3 -0
- package/templates/variants/go-chi/internal/app/service.go +763 -71
- package/templates/variants/go-chi/internal/config/config.go +22 -7
- package/templates/variants/go-chi/internal/httpapi/list_messages_integration_test.go +298 -0
- package/templates/variants/go-chi/internal/httpapi/routes.go +245 -43
- package/templates/variants/go-chi/migrations/0000_init.sql +63 -0
- package/templates/variants/go-chi/protos/chat/v1/chat.proto +219 -0
- package/templates/variants/go-chi/test/go.test.ts +4 -1
- package/templates/variants/go-connectrpc/Makefile +6 -2
- package/templates/variants/go-connectrpc/buf.gen.yaml +2 -0
- package/templates/variants/go-connectrpc/cmd/migrate/main.go +101 -0
- package/templates/variants/go-connectrpc/cmd/server/main.go +35 -11
- package/templates/variants/go-connectrpc/gen/chat/v1/chat.pb.go +2512 -0
- package/templates/variants/go-connectrpc/gen/chat/v1/chatv1connect/chat.connect.go +571 -0
- package/templates/variants/go-connectrpc/go.mod +4 -0
- package/templates/variants/go-connectrpc/internal/app/service.go +763 -71
- package/templates/variants/go-connectrpc/internal/config/config.go +22 -7
- package/templates/variants/go-connectrpc/internal/connectapi/handler.go +254 -42
- package/templates/variants/go-connectrpc/internal/connectapi/list_messages_integration_test.go +216 -0
- package/templates/variants/go-connectrpc/internal/httpapi/routes.go +41 -56
- package/templates/variants/go-connectrpc/migrations/0000_init.sql +63 -0
- package/templates/variants/go-connectrpc/protos/chat/v1/chat.proto +232 -0
- package/templates/shared/.env.example +0 -10
- package/templates/variants/go-chi/gen/dns/v1/dns.pb.go +0 -623
- package/templates/variants/go-chi/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
- package/templates/variants/go-chi/internal/connectapi/handler.go +0 -79
- package/templates/variants/go-chi/protos/dns/v1/dns.proto +0 -58
- package/templates/variants/go-connectrpc/gen/dns/v1/dns.pb.go +0 -623
- package/templates/variants/go-connectrpc/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
- package/templates/variants/go-connectrpc/protos/dns/v1/dns.proto +0 -58
|
@@ -2,62 +2,179 @@
|
|
|
2
2
|
|
|
3
3
|
Generated by `create-svc`.
|
|
4
4
|
|
|
5
|
-
This
|
|
5
|
+
This `{{PROFILE}}` profile targets `{{RUNTIME}} + {{FRAMEWORK}}` on Cloud Run with:
|
|
6
6
|
|
|
7
7
|
- one generated `service.yaml` manifest
|
|
8
|
-
-
|
|
9
|
-
-
|
|
8
|
+
- a lightweight `{{EXAMPLE_LABEL}}` example surface
|
|
9
|
+
- local Docker Compose Postgres for first-run development
|
|
10
|
+
- a local `svc-cloudrun` CLI for bootstrap, deploy, and cleanup
|
|
10
11
|
- GCP project bootstrap with billing and quota-project-aware `gcloud` calls
|
|
11
|
-
- Neon
|
|
12
|
+
- Neon-backed remote database provisioning during bootstrap and deploy
|
|
13
|
+
- GCS-backed image attachments
|
|
14
|
+
- typed HTTP webhook ingestion
|
|
15
|
+
- a production API origin at `https://{{API_HOSTNAME}}`
|
|
16
|
+
|
|
17
|
+
The default happy path is standalone. Terraform is optional: advanced users can
|
|
18
|
+
precreate shared foundations and point this package at them, but a generated app
|
|
19
|
+
does not need Terraform state, Terraform plans, a control plane, or a platform
|
|
20
|
+
console to bootstrap and deploy.
|
|
12
21
|
|
|
13
22
|
## Commands
|
|
14
23
|
|
|
15
24
|
```bash
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
{{COMMAND_DEV}}
|
|
26
|
+
{{COMMAND_MIGRATE}}
|
|
27
|
+
{{COMMAND_GEN}}
|
|
28
|
+
{{COMMAND_LINT}}
|
|
29
|
+
{{COMMAND_TEST}}
|
|
30
|
+
{{COMMAND_BOOTSTRAP}}
|
|
31
|
+
{{COMMAND_DEPLOY}}
|
|
32
|
+
{{COMMAND_DEPLOY_PERSONAL}}
|
|
33
|
+
{{COMMAND_DEPLOY_DESTROY}}
|
|
34
|
+
{{COMMAND_CLEANUP}}
|
|
35
|
+
{{COMMAND_CLEANUP_PROJECT}}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Local development
|
|
39
|
+
|
|
40
|
+
The scaffold writes a ready-to-use `.env.local` and includes a local Postgres service in `docker-compose.yml`.
|
|
41
|
+
|
|
42
|
+
First local run:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
docker compose up -d
|
|
46
|
+
{{COMMAND_MIGRATE}}
|
|
47
|
+
{{COMMAND_DEV}}
|
|
24
48
|
```
|
|
25
49
|
|
|
26
|
-
|
|
50
|
+
Local runtime uses:
|
|
51
|
+
|
|
52
|
+
- `DATABASE_URL` from `.env.local`, pointed at Docker Compose Postgres
|
|
53
|
+
- `ATTACHMENT_BUCKET`
|
|
54
|
+
- `ATTACHMENT_PUBLIC_BASE_URL`
|
|
55
|
+
|
|
56
|
+
The local attachment settings are generated so startup works out of the box. Attachment upload endpoints still rely on GCS credentials when you exercise signed-upload flows locally.
|
|
57
|
+
|
|
58
|
+
## Remote provisioning
|
|
27
59
|
|
|
28
60
|
The generated Cloud Run config lives in [scripts/cloudrun/config.ts](scripts/cloudrun/config.ts).
|
|
29
61
|
|
|
30
|
-
Bootstrap and
|
|
62
|
+
Bootstrap, deploy, and cleanup use:
|
|
31
63
|
|
|
64
|
+
- known-good CLIs first, especially `gcloud`
|
|
32
65
|
- `gcloud`
|
|
33
|
-
- `
|
|
34
|
-
-
|
|
66
|
+
- `NEON_API_KEY`, or a working Vault login via `VAULT_ADDR` plus `VAULT_TOKEN`, `VAULT_TOKEN_FILE`, or `~/.vault-token`
|
|
67
|
+
- the package-local CLI via `npx --no-install svc-cloudrun ...`
|
|
35
68
|
|
|
36
|
-
|
|
69
|
+
Local provisioning intentionally prefers known-good CLIs over SDKs for Google Cloud operations.
|
|
37
70
|
|
|
38
|
-
|
|
71
|
+
Authenticate `gcloud` on the machine before running provisioning commands:
|
|
39
72
|
|
|
40
73
|
```bash
|
|
41
|
-
|
|
74
|
+
gcloud auth login
|
|
42
75
|
```
|
|
43
76
|
|
|
44
|
-
|
|
77
|
+
If you also want local Application Default Credentials available for other tools, run:
|
|
45
78
|
|
|
46
|
-
|
|
79
|
+
```bash
|
|
80
|
+
gcloud auth application-default login
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The generated backend scripts still use `gcloud` as the primary control plane even when ADC is present.
|
|
84
|
+
|
|
85
|
+
For the Neon admin credential, prefer a normal Vault login flow:
|
|
47
86
|
|
|
48
87
|
```bash
|
|
49
88
|
vault login
|
|
50
|
-
export VAULT_TOKEN="$(vault print token)"
|
|
51
89
|
```
|
|
52
90
|
|
|
53
|
-
|
|
91
|
+
The scaffold will use, in order:
|
|
92
|
+
|
|
93
|
+
1. `VAULT_TOKEN`
|
|
94
|
+
2. `VAULT_TOKEN_FILE`
|
|
95
|
+
3. `~/.vault-token`
|
|
54
96
|
|
|
55
97
|
That keeps stable settings in the repo and keeps the token out of `~/.zshrc`.
|
|
56
98
|
|
|
57
|
-
Optional Vault overrides for Neon admin key lookup:
|
|
99
|
+
Optional remote-only Vault overrides for Neon admin key lookup:
|
|
58
100
|
|
|
59
101
|
- `VAULT_SECRET_MOUNT` default `secret`
|
|
60
|
-
- `VAULT_NEON_API_KEY_PATH` default `
|
|
61
|
-
- `VAULT_NEON_API_KEY_FIELD` default `
|
|
102
|
+
- `VAULT_NEON_API_KEY_PATH` default `prod/providers/neon`
|
|
103
|
+
- `VAULT_NEON_API_KEY_FIELD` default `api_key`
|
|
62
104
|
|
|
63
105
|
The generator only stores application-facing connection material in Secret Manager. Neon admin credentials stay local to bootstrap and deploy.
|
|
106
|
+
|
|
107
|
+
For attachments, the generated Cloud Run manifest injects:
|
|
108
|
+
|
|
109
|
+
- `ATTACHMENT_BUCKET`
|
|
110
|
+
- `ATTACHMENT_PUBLIC_BASE_URL`
|
|
111
|
+
|
|
112
|
+
Webhook signature hooks are provider-specific and optional in v1. The generic adapter will honor:
|
|
113
|
+
|
|
114
|
+
- `WEBHOOK_<PROVIDER>_SECRET`
|
|
115
|
+
|
|
116
|
+
## One-command production bootstrap
|
|
117
|
+
|
|
118
|
+
The one-command production bootstrap path is designed for a fresh standalone service.
|
|
119
|
+
|
|
120
|
+
When generated through `create-svc` with `--bootstrap`, the intended flow is:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
bun create svc {{SERVICE_NAME}} --bootstrap --yes
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
That command scaffolds this package, runs bootstrap, deploys the production
|
|
127
|
+
Cloud Run service, and fails loudly with resumable instructions if a required
|
|
128
|
+
cloud or provider credential is missing. The generated package can also be run
|
|
129
|
+
manually:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
{{COMMAND_BOOTSTRAP}}
|
|
133
|
+
{{COMMAND_DEPLOY}}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Bootstrap reads provider credentials from environment variables or from Vault
|
|
137
|
+
when `VAULT_ADDR` and a Vault token are available. Runtime secrets are delivered
|
|
138
|
+
to Cloud Run through app-project Secret Manager.
|
|
139
|
+
|
|
140
|
+
## Production API
|
|
141
|
+
|
|
142
|
+
The main environment is intended to be public at:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
https://{{API_HOSTNAME}}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Preview and personal environments keep using deterministic Cloud Run hostnames in v1 so the generated backend stays easy to use inside standalone repos or monorepos.
|
|
149
|
+
|
|
150
|
+
## Generated backend domain
|
|
151
|
+
|
|
152
|
+
The generated microservice profile is a waitlist/launch service example. It is
|
|
153
|
+
kept deliberately small so the integration plumbing is easy to remove or adapt:
|
|
154
|
+
|
|
155
|
+
- public launch/waitlist submission
|
|
156
|
+
- protected admin APIs
|
|
157
|
+
- logo or social-image upload plumbing
|
|
158
|
+
- provider webhook ingestion
|
|
159
|
+
- Pro entitlement checks for paid capabilities
|
|
160
|
+
|
|
161
|
+
The current backend plumbing includes:
|
|
162
|
+
|
|
163
|
+
- `users`
|
|
164
|
+
- `conversations`
|
|
165
|
+
- `conversation_participants`
|
|
166
|
+
- `messages`
|
|
167
|
+
- `attachments`
|
|
168
|
+
- `webhook_events`
|
|
169
|
+
|
|
170
|
+
HTTP variants expose REST endpoints for chat CRUD, attachment upload/finalize, and `/webhooks/:provider`.
|
|
171
|
+
|
|
172
|
+
ConnectRPC variants expose typed unary chat and attachment RPCs, while keeping webhook ingress on plain HTTP.
|
|
173
|
+
|
|
174
|
+
Transcript reads are newest-first and cursor-paginated:
|
|
175
|
+
|
|
176
|
+
- REST: `GET /v1/conversations/{conversationId}/messages?cursor=...&limit=...`
|
|
177
|
+
- ConnectRPC: `ListMessages(conversation_id, cursor, limit)`
|
|
178
|
+
- default page size `50`, max page size `100`
|
|
179
|
+
- message payloads include lightweight attachment metadata only
|
|
180
|
+
{{LOCAL_INTROSPECTION_NOTE}}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
services:
|
|
2
|
+
postgres:
|
|
3
|
+
image: postgres:16-alpine
|
|
4
|
+
environment:
|
|
5
|
+
POSTGRES_DB: {{LOCAL_DATABASE_NAME}}
|
|
6
|
+
POSTGRES_USER: {{LOCAL_DATABASE_USER}}
|
|
7
|
+
POSTGRES_PASSWORD: {{LOCAL_DATABASE_PASSWORD}}
|
|
8
|
+
ports:
|
|
9
|
+
- "127.0.0.1:{{LOCAL_DATABASE_PORT}}:5432"
|
|
10
|
+
volumes:
|
|
11
|
+
- postgres-data:/var/lib/postgresql/data
|
|
12
|
+
healthcheck:
|
|
13
|
+
test: ["CMD-SHELL", "pg_isready -U {{LOCAL_DATABASE_USER}} -d {{LOCAL_DATABASE_NAME}}"]
|
|
14
|
+
interval: 5s
|
|
15
|
+
timeout: 5s
|
|
16
|
+
retries: 10
|
|
17
|
+
|
|
18
|
+
volumes:
|
|
19
|
+
postgres-data:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { config
|
|
2
|
-
import {
|
|
1
|
+
import { config } from "./config";
|
|
2
|
+
import { publishProviderRuntimeSecrets } from "./integrations";
|
|
3
|
+
import { ensureDatabase, getConnectionUri, resolveNeonConfig } from "./neon";
|
|
3
4
|
import {
|
|
4
5
|
addSecretVersion,
|
|
5
6
|
attachBilling,
|
|
@@ -8,79 +9,51 @@ import {
|
|
|
8
9
|
ensureProjectRole,
|
|
9
10
|
ensureSecretAccessor,
|
|
10
11
|
ensureServiceAccount,
|
|
11
|
-
|
|
12
|
-
ensureWorkloadIdentityPool,
|
|
13
|
-
ensureWorkloadIdentityProvider,
|
|
12
|
+
ensureStorageBucket,
|
|
14
13
|
gcloud,
|
|
15
14
|
requireCommand,
|
|
15
|
+
requireGcloudAuth,
|
|
16
16
|
resolveDeploymentTarget,
|
|
17
17
|
runMain,
|
|
18
18
|
runStep,
|
|
19
|
-
setGithubVariable,
|
|
20
|
-
workloadIdentityPoolResource,
|
|
21
|
-
workloadIdentityProviderResource,
|
|
22
19
|
} from "./lib";
|
|
23
20
|
|
|
24
21
|
export async function bootstrap() {
|
|
25
22
|
requireCommand("gcloud");
|
|
26
|
-
|
|
23
|
+
requireGcloudAuth();
|
|
27
24
|
|
|
28
25
|
await runStep("Ensuring GCP project", () => ensureProject());
|
|
29
26
|
await runStep("Attaching billing", () => attachBilling());
|
|
30
27
|
await runStep("Enabling required GCP APIs", () => gcloud(["services", "enable", ...config.requiredApis, "--project", config.project.id]));
|
|
31
28
|
|
|
32
|
-
await runStep("Ensuring runtime
|
|
29
|
+
await runStep("Ensuring runtime service account", () => {
|
|
33
30
|
ensureServiceAccount(config.runtimeServiceAccount);
|
|
34
|
-
ensureServiceAccount(config.deployerServiceAccount);
|
|
35
31
|
});
|
|
36
32
|
|
|
37
33
|
await runStep("Ensuring Artifact Registry repository", () => ensureArtifactRepository());
|
|
34
|
+
await runStep("Ensuring attachment storage bucket", () => ensureStorageBucket());
|
|
38
35
|
|
|
39
36
|
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
37
|
ensureProjectRole(`serviceAccount:${config.runtimeServiceAccount}`, "roles/secretmanager.secretAccessor");
|
|
45
|
-
ensureServiceAccountRole(config.runtimeServiceAccount, `serviceAccount:${config.deployerServiceAccount}`, "roles/iam.serviceAccountUser");
|
|
46
38
|
});
|
|
47
39
|
|
|
48
|
-
await runStep("
|
|
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
|
-
});
|
|
57
|
-
|
|
58
|
-
if (!config.neon.projectId || !config.neon.baseBranchId) {
|
|
59
|
-
throw new Error("Neon project and base branch must be configured before bootstrap");
|
|
60
|
-
}
|
|
40
|
+
const neon = await runStep("Resolving Neon defaults", () => resolveNeonConfig());
|
|
61
41
|
|
|
62
42
|
const target = resolveDeploymentTarget("main");
|
|
63
|
-
await runStep("Ensuring Neon database", () => ensureDatabase(
|
|
43
|
+
await runStep("Ensuring Neon database", () => ensureDatabase(neon.projectId, neon.baseBranchId, neon.databaseName));
|
|
64
44
|
|
|
65
45
|
await runStep("Publishing database secret", async () => {
|
|
66
46
|
const connectionUri = await getConnectionUri(
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
47
|
+
neon.projectId,
|
|
48
|
+
neon.baseBranchId,
|
|
49
|
+
neon.databaseName,
|
|
50
|
+
neon.roleName
|
|
71
51
|
);
|
|
72
52
|
addSecretVersion(target.databaseSecretName, connectionUri);
|
|
73
53
|
ensureSecretAccessor(target.databaseSecretName, `serviceAccount:${config.runtimeServiceAccount}`);
|
|
74
54
|
});
|
|
75
55
|
|
|
76
|
-
await runStep("
|
|
77
|
-
for (const [name, value] of Object.entries(githubVariables)) {
|
|
78
|
-
setGithubVariable(name, value);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
setGithubVariable("GCP_WIF_PROVIDER", workloadIdentityProviderResource());
|
|
82
|
-
setGithubVariable("GCP_DEPLOYER_SERVICE_ACCOUNT", config.deployerServiceAccount);
|
|
83
|
-
});
|
|
56
|
+
await runStep("Publishing provider runtime secrets", () => publishProviderRuntimeSecrets(target));
|
|
84
57
|
}
|
|
85
58
|
|
|
86
59
|
if (import.meta.main) {
|
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
import { log } from "@clack/prompts";
|
|
2
|
-
import { config
|
|
3
|
-
import { deleteBranch, deleteDatabase, listBranches } from "./neon";
|
|
2
|
+
import { config } from "./config";
|
|
3
|
+
import { deleteBranch, deleteDatabase, listBranches, resolveNeonConfig } from "./neon";
|
|
4
4
|
import {
|
|
5
|
-
deleteGithubRepository,
|
|
6
|
-
deleteGithubVariable,
|
|
7
5
|
deleteProject,
|
|
6
|
+
deleteProductionDomainMapping,
|
|
8
7
|
deleteSecret,
|
|
9
8
|
deleteService,
|
|
10
9
|
deleteServiceAccount,
|
|
11
|
-
deleteWorkloadIdentityProvider,
|
|
12
10
|
listCloudRunServices,
|
|
13
11
|
listSecrets,
|
|
14
12
|
parseCleanupArgs,
|
|
15
13
|
requireCommand,
|
|
14
|
+
requireGcloudAuth,
|
|
16
15
|
runMain,
|
|
17
16
|
runStep,
|
|
18
17
|
} from "./lib";
|
|
@@ -27,9 +26,12 @@ function matchesSecretResource(name: string) {
|
|
|
27
26
|
|
|
28
27
|
export async function cleanup(args = Bun.argv.slice(2)) {
|
|
29
28
|
requireCommand("gcloud");
|
|
29
|
+
requireGcloudAuth();
|
|
30
30
|
|
|
31
31
|
const options = parseCleanupArgs(args);
|
|
32
32
|
|
|
33
|
+
await runStep(`Deleting production domain mapping ${config.domain.hostname}`, () => deleteProductionDomainMapping());
|
|
34
|
+
|
|
33
35
|
const services = await runStep("Finding Cloud Run services", () => listCloudRunServices());
|
|
34
36
|
const serviceNames = services.filter(matchesServiceResource);
|
|
35
37
|
await runStep("Deleting Cloud Run services", () => {
|
|
@@ -46,52 +48,36 @@ export async function cleanup(args = Bun.argv.slice(2)) {
|
|
|
46
48
|
}
|
|
47
49
|
});
|
|
48
50
|
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
+
try {
|
|
52
|
+
const neon = await runStep("Resolving Neon defaults", () => resolveNeonConfig());
|
|
53
|
+
const branches = await runStep("Finding Neon branches", () => listBranches(neon.projectId));
|
|
51
54
|
const disposableBranches = branches.filter(
|
|
52
|
-
(branch
|
|
55
|
+
(branch: { name: string }) =>
|
|
56
|
+
branch.name.startsWith(`${neon.previewBranchPrefix}-`) || branch.name.startsWith(`${neon.personalBranchPrefix}-`)
|
|
53
57
|
);
|
|
54
58
|
|
|
55
59
|
await runStep("Deleting Neon preview and personal branches", async () => {
|
|
56
60
|
for (const branch of disposableBranches) {
|
|
57
|
-
await deleteBranch(
|
|
61
|
+
await deleteBranch(neon.projectId, branch.id);
|
|
58
62
|
}
|
|
59
63
|
});
|
|
60
64
|
|
|
61
|
-
await runStep("Deleting Neon service database", () =>
|
|
62
|
-
|
|
63
|
-
);
|
|
64
|
-
} else {
|
|
65
|
+
await runStep("Deleting Neon service database", () => deleteDatabase(neon.projectId, neon.baseBranchId, neon.databaseName));
|
|
66
|
+
} catch (error) {
|
|
65
67
|
log.step("Skipping Neon cleanup because Neon is not configured");
|
|
68
|
+
log.step(error instanceof Error ? error.message : String(error));
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
await runStep("Deleting service-specific identity resources", () => {
|
|
69
|
-
deleteWorkloadIdentityProvider();
|
|
70
72
|
deleteServiceAccount(config.runtimeServiceAccount);
|
|
71
|
-
deleteServiceAccount(config.deployerServiceAccount);
|
|
72
73
|
});
|
|
73
74
|
|
|
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
75
|
if (options.destroyProject) {
|
|
91
76
|
await runStep(`Deleting GCP project ${config.project.id}`, () => deleteProject());
|
|
92
77
|
return `Deleted project ${config.project.id}`;
|
|
93
78
|
}
|
|
94
79
|
|
|
80
|
+
log.step(`Production API hostname released: ${config.domain.hostname}`);
|
|
95
81
|
return `Cleanup finished for ${config.serviceName}`;
|
|
96
82
|
}
|
|
97
83
|
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
export const config = {
|
|
2
2
|
serviceName: "{{SERVICE_NAME}}",
|
|
3
|
+
profile: "{{PROFILE}}",
|
|
4
|
+
example: {
|
|
5
|
+
kind: "{{EXAMPLE_KIND}}",
|
|
6
|
+
domain: "{{EXAMPLE_DOMAIN}}",
|
|
7
|
+
label: "{{EXAMPLE_LABEL}}",
|
|
8
|
+
},
|
|
3
9
|
runtime: "{{RUNTIME}}",
|
|
4
10
|
framework: "{{FRAMEWORK}}",
|
|
5
11
|
region: "{{REGION}}",
|
|
6
12
|
artifactRepository: "cloud-run",
|
|
7
13
|
runtimeServiceAccount: "{{RUNTIME_SERVICE_ACCOUNT}}",
|
|
8
|
-
deployerServiceAccount: "{{DEPLOYER_SERVICE_ACCOUNT}}",
|
|
9
|
-
workloadIdentityPoolId: "{{WIF_POOL_ID}}",
|
|
10
|
-
workloadIdentityProviderId: "{{WIF_PROVIDER_ID}}",
|
|
11
14
|
project: {
|
|
12
15
|
mode: "{{GCP_PROJECT_MODE}}",
|
|
13
16
|
id: "{{PROJECT_ID}}",
|
|
@@ -16,10 +19,13 @@ export const config = {
|
|
|
16
19
|
billingAccount: "{{BILLING_ACCOUNT}}",
|
|
17
20
|
quotaProjectId: "{{QUOTA_PROJECT_ID}}",
|
|
18
21
|
},
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
domain: {
|
|
23
|
+
hostname: "{{API_HOSTNAME}}",
|
|
24
|
+
baseDomain: "{{API_BASE_DOMAIN}}",
|
|
25
|
+
},
|
|
26
|
+
storage: {
|
|
27
|
+
attachmentBucket: "{{ATTACHMENT_BUCKET}}",
|
|
28
|
+
attachmentPublicBaseUrl: "{{ATTACHMENT_PUBLIC_BASE_URL}}",
|
|
23
29
|
},
|
|
24
30
|
neon: {
|
|
25
31
|
projectId: "{{NEON_PROJECT_ID}}",
|
|
@@ -38,20 +44,9 @@ export const config = {
|
|
|
38
44
|
"iamcredentials.googleapis.com",
|
|
39
45
|
"secretmanager.googleapis.com",
|
|
40
46
|
"serviceusage.googleapis.com",
|
|
47
|
+
"storage.googleapis.com",
|
|
41
48
|
"sts.googleapis.com",
|
|
42
49
|
],
|
|
43
50
|
} as const;
|
|
44
51
|
|
|
45
|
-
export const githubVariables = {
|
|
46
|
-
GCP_PROJECT_ID: "{{PROJECT_ID}}",
|
|
47
|
-
GCP_REGION: "{{REGION}}",
|
|
48
|
-
CLOUD_RUN_SERVICE: "{{SERVICE_NAME}}",
|
|
49
|
-
CREATE_SVC_RUNTIME: "{{RUNTIME}}",
|
|
50
|
-
CREATE_SVC_FRAMEWORK: "{{FRAMEWORK}}",
|
|
51
|
-
NEON_PROJECT_ID: "{{NEON_PROJECT_ID}}",
|
|
52
|
-
NEON_BASE_BRANCH_ID: "{{NEON_BASE_BRANCH_ID}}",
|
|
53
|
-
NEON_DATABASE_NAME: "{{NEON_DATABASE_NAME}}",
|
|
54
|
-
} as const;
|
|
55
|
-
|
|
56
52
|
export type DeployEnvironment = "main" | "preview" | "personal";
|
|
57
|
-
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { config } from "./config";
|
|
2
2
|
import { bootstrap } from "./bootstrap";
|
|
3
|
-
import {
|
|
3
|
+
import { publishProviderRuntimeSecrets } from "./integrations";
|
|
4
|
+
import { deleteBranch, ensureBranch, ensureDatabase, getConnectionUri, listBranches, resolveNeonConfig } from "./neon";
|
|
4
5
|
import {
|
|
5
6
|
addSecretVersion,
|
|
6
7
|
deleteService,
|
|
7
8
|
ensureArtifactRepository,
|
|
9
|
+
ensureProductionDomainMapping,
|
|
8
10
|
ensureSecretAccessor,
|
|
9
11
|
gcloud,
|
|
10
12
|
imageUrl,
|
|
@@ -13,7 +15,7 @@ import {
|
|
|
13
15
|
resolveDeploymentTarget,
|
|
14
16
|
runMain,
|
|
15
17
|
runStep,
|
|
16
|
-
|
|
18
|
+
serviceOrigin,
|
|
17
19
|
writeRenderedManifest,
|
|
18
20
|
} from "./lib";
|
|
19
21
|
|
|
@@ -27,6 +29,7 @@ export async function deploy(args = Bun.argv.slice(2)) {
|
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
const target = resolveDeploymentTarget(options.environment, options.name);
|
|
32
|
+
const neon = await runStep("Resolving Neon defaults", () => resolveNeonConfig());
|
|
30
33
|
|
|
31
34
|
if (options.destroy) {
|
|
32
35
|
if (options.environment === "main") {
|
|
@@ -35,10 +38,10 @@ export async function deploy(args = Bun.argv.slice(2)) {
|
|
|
35
38
|
|
|
36
39
|
await runStep(`Deleting Cloud Run service ${target.serviceName}`, () => deleteService(target.serviceName));
|
|
37
40
|
await runStep(`Deleting Neon branch ${target.branchName}`, async () => {
|
|
38
|
-
const branches = await listBranches(
|
|
39
|
-
const branch = branches.find((candidate) => candidate.name === target.branchName);
|
|
41
|
+
const branches = await listBranches(neon.projectId);
|
|
42
|
+
const branch = branches.find((candidate: { name: string }) => candidate.name === target.branchName);
|
|
40
43
|
if (branch) {
|
|
41
|
-
await deleteBranch(
|
|
44
|
+
await deleteBranch(neon.projectId, branch.id);
|
|
42
45
|
}
|
|
43
46
|
});
|
|
44
47
|
return `Destroyed ${target.serviceName}`;
|
|
@@ -46,21 +49,23 @@ export async function deploy(args = Bun.argv.slice(2)) {
|
|
|
46
49
|
|
|
47
50
|
await runStep("Ensuring Artifact Registry repository", () => ensureArtifactRepository());
|
|
48
51
|
|
|
49
|
-
let branchId =
|
|
52
|
+
let branchId: string = neon.baseBranchId;
|
|
50
53
|
if (options.environment !== "main") {
|
|
51
54
|
const branch = await runStep(`Ensuring Neon branch ${target.branchName}`, () =>
|
|
52
|
-
ensureBranch(
|
|
55
|
+
ensureBranch(neon.projectId, target.branchName, neon.baseBranchId)
|
|
53
56
|
);
|
|
54
57
|
branchId = branch.id;
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
await runStep("Publishing environment database secret", async () => {
|
|
58
|
-
await ensureDatabase(
|
|
59
|
-
const connectionUri = await getConnectionUri(
|
|
61
|
+
await ensureDatabase(neon.projectId, branchId, neon.databaseName);
|
|
62
|
+
const connectionUri = await getConnectionUri(neon.projectId, branchId, neon.databaseName, neon.roleName);
|
|
60
63
|
addSecretVersion(target.databaseSecretName, connectionUri);
|
|
61
64
|
ensureSecretAccessor(target.databaseSecretName, `serviceAccount:${config.runtimeServiceAccount}`);
|
|
62
65
|
});
|
|
63
66
|
|
|
67
|
+
await runStep("Publishing environment provider secrets", () => publishProviderRuntimeSecrets(target));
|
|
68
|
+
|
|
64
69
|
const image = imageUrl();
|
|
65
70
|
await runStep("Building container image", () =>
|
|
66
71
|
gcloud(["builds", "submit", "--project", config.project.id, "--region", config.region, "--tag", image])
|
|
@@ -89,7 +94,11 @@ export async function deploy(args = Bun.argv.slice(2)) {
|
|
|
89
94
|
])
|
|
90
95
|
);
|
|
91
96
|
|
|
92
|
-
|
|
97
|
+
if (target.environment === "main") {
|
|
98
|
+
await runStep(`Ensuring production domain mapping for ${config.domain.hostname}`, () => ensureProductionDomainMapping(target.serviceName));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return serviceOrigin(target);
|
|
93
102
|
}
|
|
94
103
|
|
|
95
104
|
if (import.meta.main) {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { config } from "./config";
|
|
4
|
+
import {
|
|
5
|
+
addSecretVersion,
|
|
6
|
+
ensureSecretAccessor,
|
|
7
|
+
runtimeSecretNames,
|
|
8
|
+
type DeploymentTarget,
|
|
9
|
+
} from "./lib";
|
|
10
|
+
|
|
11
|
+
type ProviderSecret = {
|
|
12
|
+
envName: keyof ReturnType<typeof runtimeSecretNames>;
|
|
13
|
+
provider: string;
|
|
14
|
+
field: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const PROVIDER_SECRETS: ProviderSecret[] = [
|
|
18
|
+
{ envName: "CLERK_SECRET_KEY", provider: "clerk", field: "secret_key" },
|
|
19
|
+
{ envName: "CLERK_WEBHOOK_SECRET", provider: "clerk", field: "webhook_secret" },
|
|
20
|
+
{ envName: "STRIPE_SECRET_KEY", provider: "stripe", field: "secret_key" },
|
|
21
|
+
{ envName: "STRIPE_WEBHOOK_SECRET", provider: "stripe", field: "webhook_secret" },
|
|
22
|
+
{ envName: "REVENUECAT_API_KEY", provider: "revenuecat", field: "api_key" },
|
|
23
|
+
{ envName: "REVENUECAT_WEBHOOK_SECRET", provider: "revenuecat", field: "webhook_secret" },
|
|
24
|
+
{ envName: "RESEND_API_KEY", provider: "resend", field: "api_key" },
|
|
25
|
+
{ envName: "POSTHOG_API_KEY", provider: "posthog", field: "api_key" },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export async function publishProviderRuntimeSecrets(target: DeploymentTarget) {
|
|
29
|
+
const secretNames = runtimeSecretNames(target);
|
|
30
|
+
const missing: string[] = [];
|
|
31
|
+
|
|
32
|
+
for (const secret of PROVIDER_SECRETS) {
|
|
33
|
+
const value = await resolveProviderSecret(secret);
|
|
34
|
+
if (!value) {
|
|
35
|
+
missing.push(formatMissingSecret(secret));
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
addSecretVersion(secretNames[secret.envName], value);
|
|
40
|
+
ensureSecretAccessor(secretNames[secret.envName], `serviceAccount:${config.runtimeServiceAccount}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (missing.length > 0) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
[
|
|
46
|
+
"Provider bootstrap credentials are required for the strict production bootstrap path.",
|
|
47
|
+
"Set the missing environment variables or write the matching Vault fields, then rerun the same bootstrap/deploy command.",
|
|
48
|
+
...missing.map((item) => `- ${item}`),
|
|
49
|
+
].join("\n")
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function resolveProviderSecret(secret: ProviderSecret) {
|
|
55
|
+
const direct = process.env[secret.envName]?.trim();
|
|
56
|
+
if (direct) {
|
|
57
|
+
return direct;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const addr = process.env.VAULT_ADDR?.trim() ?? "";
|
|
61
|
+
const token = await resolveVaultToken();
|
|
62
|
+
if (!addr || !token) {
|
|
63
|
+
return "";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const mount = process.env.VAULT_SECRET_MOUNT?.trim() ?? "secret";
|
|
67
|
+
const path = providerVaultPath(secret.provider);
|
|
68
|
+
const normalizedAddr = addr.replace(/\/+$/g, "");
|
|
69
|
+
const normalizedMount = mount.replace(/^\/+|\/+$/g, "");
|
|
70
|
+
const response = await fetch(`${normalizedAddr}/v1/${normalizedMount}/data/${path}`, {
|
|
71
|
+
headers: {
|
|
72
|
+
"X-Vault-Token": token,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
if (!response.ok) {
|
|
77
|
+
return "";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const payload = (await response.json()) as {
|
|
81
|
+
data?: {
|
|
82
|
+
data?: Record<string, string | undefined>;
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
return payload.data?.data?.[secret.field]?.trim() ?? "";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function resolveVaultToken() {
|
|
90
|
+
const direct = process.env.VAULT_TOKEN?.trim();
|
|
91
|
+
if (direct) {
|
|
92
|
+
return direct;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const tokenFile = process.env.VAULT_TOKEN_FILE?.trim() || join(process.env.HOME?.trim() || homedir(), ".vault-token");
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
return (await Bun.file(tokenFile).text()).trim();
|
|
99
|
+
} catch {
|
|
100
|
+
return "";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function providerVaultPath(provider: string) {
|
|
105
|
+
const override = process.env[`VAULT_${provider.toUpperCase()}_PATH`]?.trim();
|
|
106
|
+
return (override || `prod/providers/${provider}`).replace(/^\/+/g, "");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function formatMissingSecret(secret: ProviderSecret) {
|
|
110
|
+
return `${secret.envName} or Vault secret/${providerVaultPath(secret.provider)} field ${secret.field}`;
|
|
111
|
+
}
|