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.
- package/LICENSE +21 -0
- package/README.md +32 -0
- package/index.ts +5 -0
- package/package.json +48 -0
- package/src/cli.ts +300 -0
- package/src/scaffold.test.ts +46 -0
- package/src/scaffold.ts +133 -0
- package/templates/root/.github/workflows/buf-publish.yml +19 -0
- package/templates/root/.github/workflows/ci.yml +26 -0
- package/templates/root/.github/workflows/deploy.yml +22 -0
- package/templates/root/Dockerfile +23 -0
- package/templates/root/README.md +69 -0
- package/templates/root/buf.gen.yaml +10 -0
- package/templates/root/buf.yaml +9 -0
- package/templates/root/cmd/server/main.go +44 -0
- package/templates/root/gen/dns/v1/dns.pb.go +623 -0
- package/templates/root/gen/dns/v1/dnsv1connect/dns.connect.go +192 -0
- package/templates/root/go.mod +10 -0
- package/templates/root/internal/app/service.go +152 -0
- package/templates/root/internal/app/token_source.go +50 -0
- package/templates/root/internal/cloudflare/client.go +160 -0
- package/templates/root/internal/config/config.go +55 -0
- package/templates/root/internal/connectapi/handler.go +79 -0
- package/templates/root/internal/httpapi/routes.go +93 -0
- package/templates/root/internal/vault/client.go +148 -0
- package/templates/root/package.json +12 -0
- package/templates/root/protos/dns/v1/dns.proto +58 -0
- package/templates/root/scripts/cloudrun/bootstrap.ts +65 -0
- package/templates/root/scripts/cloudrun/config.ts +50 -0
- package/templates/root/scripts/cloudrun/deploy.ts +41 -0
- package/templates/root/scripts/cloudrun/lib.ts +244 -0
- package/templates/root/service.yaml +50 -0
- 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
|
+
);
|