create-svc 0.1.0

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.
Files changed (33) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -0
  3. package/index.ts +5 -0
  4. package/package.json +48 -0
  5. package/src/cli.ts +300 -0
  6. package/src/scaffold.test.ts +46 -0
  7. package/src/scaffold.ts +133 -0
  8. package/templates/root/.github/workflows/buf-publish.yml +19 -0
  9. package/templates/root/.github/workflows/ci.yml +26 -0
  10. package/templates/root/.github/workflows/deploy.yml +22 -0
  11. package/templates/root/Dockerfile +23 -0
  12. package/templates/root/README.md +69 -0
  13. package/templates/root/buf.gen.yaml +10 -0
  14. package/templates/root/buf.yaml +9 -0
  15. package/templates/root/cmd/server/main.go +44 -0
  16. package/templates/root/gen/dns/v1/dns.pb.go +623 -0
  17. package/templates/root/gen/dns/v1/dnsv1connect/dns.connect.go +192 -0
  18. package/templates/root/go.mod +10 -0
  19. package/templates/root/internal/app/service.go +152 -0
  20. package/templates/root/internal/app/token_source.go +50 -0
  21. package/templates/root/internal/cloudflare/client.go +160 -0
  22. package/templates/root/internal/config/config.go +55 -0
  23. package/templates/root/internal/connectapi/handler.go +79 -0
  24. package/templates/root/internal/httpapi/routes.go +93 -0
  25. package/templates/root/internal/vault/client.go +148 -0
  26. package/templates/root/package.json +12 -0
  27. package/templates/root/protos/dns/v1/dns.proto +58 -0
  28. package/templates/root/scripts/cloudrun/bootstrap.ts +65 -0
  29. package/templates/root/scripts/cloudrun/config.ts +50 -0
  30. package/templates/root/scripts/cloudrun/deploy.ts +41 -0
  31. package/templates/root/scripts/cloudrun/lib.ts +244 -0
  32. package/templates/root/service.yaml +50 -0
  33. package/templates/root/test/go.test.ts +19 -0
@@ -0,0 +1,244 @@
1
+ import { config, manifestEnv } from "./config";
2
+
3
+ type CommandOptions = {
4
+ allowFailure?: boolean;
5
+ capture?: boolean;
6
+ input?: string;
7
+ };
8
+
9
+ const decoder = new TextDecoder();
10
+
11
+ export function requireCommand(name: string) {
12
+ if (!Bun.which(name)) {
13
+ throw new Error(`missing required command: ${name}`);
14
+ }
15
+ }
16
+
17
+ export function run(command: string, args: string[], options: CommandOptions = {}) {
18
+ const result = Bun.spawnSync([command, ...args], {
19
+ cwd: process.cwd(),
20
+ env: process.env,
21
+ stdin: options.input,
22
+ stdout: options.capture || options.allowFailure ? "pipe" : "inherit",
23
+ stderr: options.capture || options.allowFailure ? "pipe" : "inherit",
24
+ });
25
+
26
+ const stdout = result.stdout ? decoder.decode(result.stdout).trim() : "";
27
+ const stderr = result.stderr ? decoder.decode(result.stderr).trim() : "";
28
+
29
+ if (!result.success && !options.allowFailure) {
30
+ throw new Error([`command failed: ${command} ${args.join(" ")}`, stdout, stderr].filter(Boolean).join("\n"));
31
+ }
32
+
33
+ return {
34
+ success: result.success,
35
+ stdout,
36
+ stderr,
37
+ exitCode: result.exitCode,
38
+ };
39
+ }
40
+
41
+ export function gcloud(args: string[], options: CommandOptions = {}) {
42
+ return run("gcloud", args, options);
43
+ }
44
+
45
+ export function gh(args: string[], options: CommandOptions = {}) {
46
+ return run("gh", args, options);
47
+ }
48
+
49
+ export function ensureServiceAccount(email: string) {
50
+ if (gcloud(["iam", "service-accounts", "describe", email, "--project", config.projectId], { allowFailure: true }).success) {
51
+ return;
52
+ }
53
+
54
+ const accountId = email.split("@")[0] ?? email;
55
+ gcloud(["iam", "service-accounts", "create", accountId, "--project", config.projectId, "--display-name", accountId]);
56
+ }
57
+
58
+ export function ensureProjectRole(member: string, role: string) {
59
+ gcloud(["projects", "add-iam-policy-binding", config.projectId, "--member", member, "--role", role]);
60
+ }
61
+
62
+ export function ensureServiceAccountRole(serviceAccount: string, member: string, role: string) {
63
+ gcloud([
64
+ "iam",
65
+ "service-accounts",
66
+ "add-iam-policy-binding",
67
+ serviceAccount,
68
+ "--project",
69
+ config.projectId,
70
+ "--member",
71
+ member,
72
+ "--role",
73
+ role,
74
+ ]);
75
+ }
76
+
77
+ export function ensureSecret(secretName: string, bootstrapEnv: string) {
78
+ if (gcloud(["secrets", "describe", secretName, "--project", config.projectId], { allowFailure: true }).success) {
79
+ return;
80
+ }
81
+
82
+ const value = process.env[bootstrapEnv]?.trim() ?? "";
83
+ if (!value) {
84
+ throw new Error(`missing bootstrap value for secret ${secretName}; set ${bootstrapEnv}`);
85
+ }
86
+
87
+ gcloud(["secrets", "create", secretName, "--project", config.projectId, "--replication-policy", "automatic"]);
88
+ gcloud(["secrets", "versions", "add", secretName, "--project", config.projectId, "--data-file=-"], { input: value });
89
+ }
90
+
91
+ export function ensureSecretAccessor(secretName: string, member: string) {
92
+ gcloud(["secrets", "add-iam-policy-binding", secretName, "--project", config.projectId, "--member", member, "--role", "roles/secretmanager.secretAccessor"]);
93
+ }
94
+
95
+ export function projectNumber() {
96
+ return gcloud(["projects", "describe", config.projectId, "--format=value(projectNumber)"], { capture: true }).stdout;
97
+ }
98
+
99
+ export function workloadIdentityPoolResource() {
100
+ return `projects/${projectNumber()}/locations/global/workloadIdentityPools/${config.workloadIdentityPoolId}`;
101
+ }
102
+
103
+ export function workloadIdentityProviderResource() {
104
+ return `${workloadIdentityPoolResource()}/providers/${config.workloadIdentityProviderId}`;
105
+ }
106
+
107
+ export function ensureWorkloadIdentityPool() {
108
+ if (
109
+ gcloud(["iam", "workload-identity-pools", "describe", config.workloadIdentityPoolId, "--project", config.projectId, "--location", "global"], {
110
+ allowFailure: true,
111
+ }).success
112
+ ) {
113
+ return;
114
+ }
115
+
116
+ gcloud([
117
+ "iam",
118
+ "workload-identity-pools",
119
+ "create",
120
+ config.workloadIdentityPoolId,
121
+ "--project",
122
+ config.projectId,
123
+ "--location",
124
+ "global",
125
+ "--display-name",
126
+ "GitHub Actions",
127
+ ]);
128
+ }
129
+
130
+ export function ensureWorkloadIdentityProvider() {
131
+ if (
132
+ gcloud(
133
+ [
134
+ "iam",
135
+ "workload-identity-pools",
136
+ "providers",
137
+ "describe",
138
+ config.workloadIdentityProviderId,
139
+ "--project",
140
+ config.projectId,
141
+ "--location",
142
+ "global",
143
+ "--workload-identity-pool",
144
+ config.workloadIdentityPoolId,
145
+ ],
146
+ { allowFailure: true }
147
+ ).success
148
+ ) {
149
+ return;
150
+ }
151
+
152
+ gcloud([
153
+ "iam",
154
+ "workload-identity-pools",
155
+ "providers",
156
+ "create-oidc",
157
+ config.workloadIdentityProviderId,
158
+ "--project",
159
+ config.projectId,
160
+ "--location",
161
+ "global",
162
+ "--workload-identity-pool",
163
+ config.workloadIdentityPoolId,
164
+ "--display-name",
165
+ `${config.serviceName} GitHub`,
166
+ "--issuer-uri",
167
+ "https://token.actions.githubusercontent.com",
168
+ "--attribute-mapping",
169
+ "google.subject=assertion.sub,attribute.actor=assertion.actor,attribute.repository=assertion.repository,attribute.repository_owner=assertion.repository_owner",
170
+ "--attribute-condition",
171
+ `assertion.repository=='${config.githubRepo}'`,
172
+ ]);
173
+ }
174
+
175
+ export function setGithubVariable(name: string, value: string) {
176
+ gh(["variable", "set", name, "--repo", config.githubRepo, "--body", value]);
177
+ }
178
+
179
+ export function setGithubSecret(name: string, value: string) {
180
+ gh(["secret", "set", name, "--repo", config.githubRepo], { input: value });
181
+ }
182
+
183
+ export function ensureArtifactRepository() {
184
+ if (
185
+ gcloud(
186
+ ["artifacts", "repositories", "describe", config.artifactRepository, "--project", config.projectId, "--location", config.region],
187
+ { allowFailure: true }
188
+ ).success
189
+ ) {
190
+ return;
191
+ }
192
+
193
+ gcloud([
194
+ "artifacts",
195
+ "repositories",
196
+ "create",
197
+ config.artifactRepository,
198
+ "--project",
199
+ config.projectId,
200
+ "--location",
201
+ config.region,
202
+ "--repository-format",
203
+ "docker",
204
+ ]);
205
+ }
206
+
207
+ export function imageTag() {
208
+ const gitSha = run("git", ["rev-parse", "--short", "HEAD"], { allowFailure: true, capture: true }).stdout;
209
+ return gitSha || `${Date.now()}`;
210
+ }
211
+
212
+ export function imageUrl(tag = imageTag()) {
213
+ return `${config.region}-docker.pkg.dev/${config.projectId}/${config.artifactRepository}/${config.serviceName}:${tag}`;
214
+ }
215
+
216
+ export async function renderManifest(image: string) {
217
+ const template = await Bun.file(new URL("../../service.yaml", import.meta.url)).text();
218
+ const values = {
219
+ ...manifestEnv,
220
+ IMAGE_URL: image,
221
+ };
222
+
223
+ return template.replace(/\$\{([A-Z0-9_]+)\}/g, (_, key: string) => {
224
+ const value = values[key as keyof typeof values];
225
+ if (!value) {
226
+ throw new Error(`missing manifest value for ${key}`);
227
+ }
228
+ return value;
229
+ });
230
+ }
231
+
232
+ export async function writeRenderedManifest(image: string) {
233
+ const rendered = await renderManifest(image);
234
+ const path = new URL("../../.cloudrun.rendered.yaml", import.meta.url);
235
+ await Bun.write(path, rendered);
236
+ return path;
237
+ }
238
+
239
+ export function serviceUrl() {
240
+ return gcloud(
241
+ ["run", "services", "describe", config.serviceName, "--project", config.projectId, "--region", config.region, "--format=value(status.url)"],
242
+ { capture: true }
243
+ ).stdout;
244
+ }
@@ -0,0 +1,50 @@
1
+ apiVersion: serving.knative.dev/v1
2
+ kind: Service
3
+ metadata:
4
+ name: ${SERVICE_NAME}
5
+ annotations:
6
+ run.googleapis.com/ingress: all
7
+ spec:
8
+ template:
9
+ spec:
10
+ serviceAccountName: ${RUNTIME_SERVICE_ACCOUNT}
11
+ containerConcurrency: 80
12
+ containers:
13
+ - image: ${IMAGE_URL}
14
+ ports:
15
+ - name: h2c
16
+ containerPort: 8080
17
+ env:
18
+ - name: VAULT_ADDR
19
+ value: ${VAULT_ADDR}
20
+ - name: VAULT_SECRET_PATH
21
+ value: ${VAULT_SECRET_PATH}
22
+ - name: VAULT_SECRET_KEY
23
+ value: ${VAULT_SECRET_KEY}
24
+ - name: CLOUDFLARE_ZONE_ID
25
+ value: ${CLOUDFLARE_ZONE_ID}
26
+ - name: VAULT_ROLE_ID_FILE
27
+ value: /var/run/secrets/vault-role-id/value
28
+ - name: VAULT_SECRET_ID_FILE
29
+ value: /var/run/secrets/vault-secret-id/value
30
+ volumeMounts:
31
+ - name: vault-role-id
32
+ mountPath: /var/run/secrets/vault-role-id
33
+ - name: vault-secret-id
34
+ mountPath: /var/run/secrets/vault-secret-id
35
+ volumes:
36
+ - name: vault-role-id
37
+ secret:
38
+ secretName: ${VAULT_ROLE_ID_SECRET}
39
+ items:
40
+ - key: latest
41
+ path: value
42
+ - name: vault-secret-id
43
+ secret:
44
+ secretName: ${VAULT_SECRET_ID_SECRET}
45
+ items:
46
+ - key: latest
47
+ path: value
48
+ traffic:
49
+ - latestRevision: true
50
+ percent: 100
@@ -0,0 +1,19 @@
1
+ import { expect, test } from "bun:test";
2
+
3
+ const decoder = new TextDecoder();
4
+
5
+ test(
6
+ "go test ./...",
7
+ { timeout: 60_000 },
8
+ () => {
9
+ const result = Bun.spawnSync(["go", "test", "./..."], {
10
+ cwd: process.cwd(),
11
+ stdout: "pipe",
12
+ stderr: "pipe",
13
+ env: process.env,
14
+ });
15
+
16
+ const output = [decoder.decode(result.stdout), decoder.decode(result.stderr)].join("").trim();
17
+ expect(result.exitCode, output || "go test ./... failed").toBe(0);
18
+ }
19
+ );