create-svc 0.1.18 → 0.1.20
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.ts +19 -1
- package/src/scaffold.test.ts +6 -0
- package/src/scaffold.ts +2 -0
- package/templates/shared/scripts/cloudrun/config.ts +3 -0
- package/templates/shared/scripts/cloudrun/deploy.ts +2 -1
- package/templates/shared/scripts/cloudrun/lib.ts +220 -4
- package/templates/shared/service.config.ts +4 -0
package/package.json
CHANGED
package/src/post-scaffold.ts
CHANGED
|
@@ -20,6 +20,8 @@ type PostScaffoldCommand = {
|
|
|
20
20
|
|
|
21
21
|
const decoder = new TextDecoder();
|
|
22
22
|
const encoder = new TextEncoder();
|
|
23
|
+
const DEPLOYMENT_VERIFY_ATTEMPTS = 36;
|
|
24
|
+
const DEPLOYMENT_VERIFY_DELAY_MS = 10_000;
|
|
23
25
|
|
|
24
26
|
export async function runPostScaffoldFlow(config: ScaffoldConfig, cwd: string) {
|
|
25
27
|
if (config.autoDeploy) {
|
|
@@ -28,7 +30,7 @@ export async function runPostScaffoldFlow(config: ScaffoldConfig, cwd: string) {
|
|
|
28
30
|
run(command.command, command.args, { cwd });
|
|
29
31
|
}
|
|
30
32
|
for (const command of buildDeploymentVerificationCommands(config)) {
|
|
31
|
-
|
|
33
|
+
runWithRetries(command, { cwd, quiet: true }, DEPLOYMENT_VERIFY_ATTEMPTS, DEPLOYMENT_VERIFY_DELAY_MS);
|
|
32
34
|
}
|
|
33
35
|
return { message: "Dependencies installed, service created, service deployed, and production health verified" };
|
|
34
36
|
}
|
|
@@ -36,6 +38,22 @@ export async function runPostScaffoldFlow(config: ScaffoldConfig, cwd: string) {
|
|
|
36
38
|
return { message: "Backend package generated" };
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
function runWithRetries(command: PostScaffoldCommand, options: CommandOptions, attempts: number, delayMs: number) {
|
|
42
|
+
let lastError: unknown;
|
|
43
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
44
|
+
try {
|
|
45
|
+
return run(command.command, command.args, options);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
lastError = error;
|
|
48
|
+
if (attempt === attempts) {
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
Bun.sleepSync(delayMs);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
throw lastError;
|
|
55
|
+
}
|
|
56
|
+
|
|
39
57
|
export function buildDeploymentVerificationCommands(
|
|
40
58
|
config: Pick<ScaffoldConfig, "apiHostname" | "framework" | "runtime"> & Partial<Pick<ScaffoldConfig, "target">>
|
|
41
59
|
): PostScaffoldCommand[] {
|
package/src/scaffold.test.ts
CHANGED
|
@@ -59,6 +59,7 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
|
|
|
59
59
|
expect(serviceConfig).toContain('service_id: "dns-api"');
|
|
60
60
|
expect(serviceConfig).toContain('target: "cloudrun"');
|
|
61
61
|
expect(serviceConfig).toContain('module: "buf.build/anmho/dns-api"');
|
|
62
|
+
expect(serviceConfig).toContain('cloudflare_vault_path: "prod/providers/cloudflare"');
|
|
62
63
|
expect(serviceConfig).toContain('issuer: "https://auth.anmho.com/api/auth"');
|
|
63
64
|
expect(serviceConfig).toContain('audience: "api://dns-api"');
|
|
64
65
|
expect(serviceConfig).toContain('vault_path_prefix: "prod/apps/dns-api/server/oauth-clients"');
|
|
@@ -79,6 +80,7 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
|
|
|
79
80
|
expect(configScript).toContain('baseBranchName: "main"');
|
|
80
81
|
expect(configScript).toContain('previewBranchPrefix: "dns-api-pr"');
|
|
81
82
|
expect(configScript).toContain('hostname: "api.dns-api.anmho.com"');
|
|
83
|
+
expect(configScript).toContain('cloudflareVaultPath: "prod/providers/cloudflare"');
|
|
82
84
|
expect(configScript).not.toContain("github:");
|
|
83
85
|
expect(configScript).not.toContain("attachmentBucket");
|
|
84
86
|
|
|
@@ -87,6 +89,9 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
|
|
|
87
89
|
expect(deployScript).toContain('projectMode === "use_existing"');
|
|
88
90
|
expect(deployScript).toContain("serviceDomain");
|
|
89
91
|
expect(deployScript).toContain("ensureProductionDomainMapping");
|
|
92
|
+
expect(deployScript).toContain("ensureCloudflareDnsRecord");
|
|
93
|
+
expect(deployScript).toContain("gcloudWithRetry");
|
|
94
|
+
expect(deployScript).toContain("CLOUDFLARE_API_TOKEN");
|
|
90
95
|
expect(deployScript).toContain('"domain-mappings",');
|
|
91
96
|
expect(deployScript).toContain('"--region",');
|
|
92
97
|
expect(deployScript).toContain("assertProductionDomainAvailable");
|
|
@@ -152,6 +157,7 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
|
|
|
152
157
|
expect(localEnv).toContain(`DATABASE_URL=postgres://postgres:postgres@127.0.0.1:${localPort}/dns_api?sslmode=disable`);
|
|
153
158
|
expect(localEnv).toContain("VAULT_AUTHCTL_ACCESS_PATH=prod/apps/auth/authctl/cloudflare-access");
|
|
154
159
|
expect(localEnv).toContain("VAULT_NEON_API_KEY_PATH=prod/providers/neon");
|
|
160
|
+
expect(localEnv).toContain("VAULT_CLOUDFLARE_API_TOKEN_PATH=prod/providers/cloudflare");
|
|
155
161
|
expect(localEnv).not.toContain("ATTACHMENT_PUBLIC_BASE_URL=");
|
|
156
162
|
|
|
157
163
|
const ciWorkflow = await Bun.file(join(generatedRoot, ".github", "workflows", "ci.yml")).text();
|
package/src/scaffold.ts
CHANGED
|
@@ -293,6 +293,8 @@ async function writeLocalEnvFile(targetDir: string, replacements: Record<string,
|
|
|
293
293
|
"VAULT_AUTHCTL_ACCESS_CLIENT_SECRET_FIELD=CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_SECRET",
|
|
294
294
|
"VAULT_NEON_API_KEY_PATH=prod/providers/neon",
|
|
295
295
|
"VAULT_NEON_API_KEY_FIELD=api_key",
|
|
296
|
+
"VAULT_CLOUDFLARE_API_TOKEN_PATH=prod/providers/cloudflare",
|
|
297
|
+
"VAULT_CLOUDFLARE_API_TOKEN_FIELD=api_token",
|
|
296
298
|
"",
|
|
297
299
|
].join("\n"),
|
|
298
300
|
replacements
|
|
@@ -22,6 +22,9 @@ export const config = {
|
|
|
22
22
|
domain: {
|
|
23
23
|
hostname: "{{API_HOSTNAME}}",
|
|
24
24
|
baseDomain: "{{API_BASE_DOMAIN}}",
|
|
25
|
+
cloudflareApiBaseUrl: "https://api.cloudflare.com/client/v4",
|
|
26
|
+
cloudflareVaultPath: "prod/providers/cloudflare",
|
|
27
|
+
cloudflareVaultField: "api_token",
|
|
25
28
|
},
|
|
26
29
|
auth: {
|
|
27
30
|
issuer: "https://auth.anmho.com/api/auth",
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
ensureProductionDomainMapping,
|
|
9
9
|
ensureSecretAccessor,
|
|
10
10
|
gcloud,
|
|
11
|
+
gcloudWithRetry,
|
|
11
12
|
imageUrl,
|
|
12
13
|
parseDeployArgs,
|
|
13
14
|
requireCommand,
|
|
@@ -74,7 +75,7 @@ export async function deploy(args = Bun.argv.slice(2)) {
|
|
|
74
75
|
);
|
|
75
76
|
|
|
76
77
|
await runStep("Granting public invoker access", () =>
|
|
77
|
-
|
|
78
|
+
gcloudWithRetry([
|
|
78
79
|
"run",
|
|
79
80
|
"services",
|
|
80
81
|
"add-iam-policy-binding",
|
|
@@ -42,6 +42,7 @@ type CommandResult = {
|
|
|
42
42
|
|
|
43
43
|
const decoder = new TextDecoder();
|
|
44
44
|
const encoder = new TextEncoder();
|
|
45
|
+
const CLOUDFLARE_DNS_TTL_AUTO = 1;
|
|
45
46
|
|
|
46
47
|
export class CommandError extends Error {
|
|
47
48
|
command: string;
|
|
@@ -114,6 +115,22 @@ export function gcloud(args: string[], options: CommandOptions = {}) {
|
|
|
114
115
|
return run("gcloud", normalized, options);
|
|
115
116
|
}
|
|
116
117
|
|
|
118
|
+
export function gcloudWithRetry(args: string[], options: CommandOptions = {}) {
|
|
119
|
+
let lastError: unknown;
|
|
120
|
+
for (let attempt = 1; attempt <= 12; attempt += 1) {
|
|
121
|
+
try {
|
|
122
|
+
return gcloud(args, options);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
lastError = error;
|
|
125
|
+
if (attempt === 12 || !isRetryableGcloudError(error)) {
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
Bun.sleepSync(5_000);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
throw lastError;
|
|
132
|
+
}
|
|
133
|
+
|
|
117
134
|
export async function runStep<T>(label: string, task: () => Promise<T> | T) {
|
|
118
135
|
const indicator = spinner();
|
|
119
136
|
indicator.start(label);
|
|
@@ -175,6 +192,7 @@ export function ensureServiceAccount(email: string) {
|
|
|
175
192
|
|
|
176
193
|
const accountId = email.split("@")[0] ?? email;
|
|
177
194
|
gcloud(["iam", "service-accounts", "create", accountId, "--project", config.project.id, "--display-name", accountId]);
|
|
195
|
+
waitForServiceAccount(email);
|
|
178
196
|
}
|
|
179
197
|
|
|
180
198
|
export function deleteServiceAccount(email: string) {
|
|
@@ -182,11 +200,11 @@ export function deleteServiceAccount(email: string) {
|
|
|
182
200
|
}
|
|
183
201
|
|
|
184
202
|
export function ensureProjectRole(member: string, role: string) {
|
|
185
|
-
|
|
203
|
+
gcloudWithRetry(["projects", "add-iam-policy-binding", config.project.id, "--member", member, "--role", role]);
|
|
186
204
|
}
|
|
187
205
|
|
|
188
206
|
export function ensureServiceAccountRole(serviceAccount: string, member: string, role: string) {
|
|
189
|
-
|
|
207
|
+
gcloudWithRetry([
|
|
190
208
|
"iam",
|
|
191
209
|
"service-accounts",
|
|
192
210
|
"add-iam-policy-binding",
|
|
@@ -228,7 +246,17 @@ export function accessSecretVersion(secretName: string) {
|
|
|
228
246
|
}
|
|
229
247
|
|
|
230
248
|
export function ensureSecretAccessor(secretName: string, member: string) {
|
|
231
|
-
|
|
249
|
+
gcloudWithRetry([
|
|
250
|
+
"secrets",
|
|
251
|
+
"add-iam-policy-binding",
|
|
252
|
+
secretName,
|
|
253
|
+
"--project",
|
|
254
|
+
config.project.id,
|
|
255
|
+
"--member",
|
|
256
|
+
member,
|
|
257
|
+
"--role",
|
|
258
|
+
"roles/secretmanager.secretAccessor",
|
|
259
|
+
]);
|
|
232
260
|
}
|
|
233
261
|
|
|
234
262
|
export function listSecrets() {
|
|
@@ -485,12 +513,13 @@ export function ensureProductionDomainMapping(serviceName: string) {
|
|
|
485
513
|
if (existing) {
|
|
486
514
|
const mappedService = existing.spec?.routeName ?? existing.status?.resourceRecords?.[0]?.rrdata;
|
|
487
515
|
if (!mappedService || mappedService === serviceName) {
|
|
516
|
+
ensureCloudflareDnsRecord(existing);
|
|
488
517
|
return;
|
|
489
518
|
}
|
|
490
519
|
throw new Error(`${config.domain.hostname} is already mapped to ${mappedService}; refusing to take it over`);
|
|
491
520
|
}
|
|
492
521
|
|
|
493
|
-
gcloud([
|
|
522
|
+
const result = gcloud([
|
|
494
523
|
"beta",
|
|
495
524
|
"run",
|
|
496
525
|
"domain-mappings",
|
|
@@ -504,6 +533,8 @@ export function ensureProductionDomainMapping(serviceName: string) {
|
|
|
504
533
|
"--region",
|
|
505
534
|
config.region,
|
|
506
535
|
]);
|
|
536
|
+
const created = parseDomainMappingOutput(result.stdout) ?? describeProductionDomainMapping();
|
|
537
|
+
ensureCloudflareDnsRecord(created);
|
|
507
538
|
}
|
|
508
539
|
|
|
509
540
|
export function describeProductionDomainMapping():
|
|
@@ -561,6 +592,7 @@ export function assertServiceNameAvailable(serviceName: string) {
|
|
|
561
592
|
}
|
|
562
593
|
|
|
563
594
|
export function deleteProductionDomainMapping() {
|
|
595
|
+
deleteCloudflareDnsRecord();
|
|
564
596
|
gcloud(["beta", "run", "domain-mappings", "delete", "--domain", config.domain.hostname, "--project", config.project.id, "--quiet"], {
|
|
565
597
|
allowFailure: true,
|
|
566
598
|
});
|
|
@@ -573,6 +605,190 @@ export function listCloudRunServices() {
|
|
|
573
605
|
.filter(Boolean);
|
|
574
606
|
}
|
|
575
607
|
|
|
608
|
+
function parseDomainMappingOutput(stdout: string) {
|
|
609
|
+
if (!stdout.trim().startsWith("{")) {
|
|
610
|
+
return undefined;
|
|
611
|
+
}
|
|
612
|
+
try {
|
|
613
|
+
return JSON.parse(stdout) as ReturnType<typeof describeProductionDomainMapping>;
|
|
614
|
+
} catch {
|
|
615
|
+
return undefined;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function ensureCloudflareDnsRecord(
|
|
620
|
+
mapping:
|
|
621
|
+
| { status?: { resourceRecords?: Array<{ name?: string; rrdata?: string; type?: string }> } }
|
|
622
|
+
| undefined
|
|
623
|
+
) {
|
|
624
|
+
const desired = desiredCloudflareRecord(mapping);
|
|
625
|
+
const zoneId = cloudflareZoneId();
|
|
626
|
+
const records = listCloudflareDnsRecords(zoneId, config.domain.hostname);
|
|
627
|
+
const conflicting = records.find((record) => record.type !== desired.type);
|
|
628
|
+
if (conflicting) {
|
|
629
|
+
throw new Error(
|
|
630
|
+
`Cloudflare DNS record ${config.domain.hostname} already exists as ${conflicting.type}; remove or update it before provisioning`
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
const existing = records.find((record) => record.type === desired.type);
|
|
634
|
+
if (!existing) {
|
|
635
|
+
cloudflareFetch("POST", `/zones/${zoneId}/dns_records`, desired);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (existing.content === desired.content && existing.proxied === desired.proxied) {
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
cloudflareFetch("PUT", `/zones/${zoneId}/dns_records/${existing.id}`, desired);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function deleteCloudflareDnsRecord() {
|
|
645
|
+
const token = resolveCloudflareApiToken({ required: false });
|
|
646
|
+
if (!token) {
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
const zoneId = cloudflareZoneId(token);
|
|
650
|
+
const records = listCloudflareDnsRecords(zoneId, config.domain.hostname, token);
|
|
651
|
+
for (const record of records) {
|
|
652
|
+
cloudflareFetch("DELETE", `/zones/${zoneId}/dns_records/${record.id}`, undefined, token);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function desiredCloudflareRecord(
|
|
657
|
+
mapping:
|
|
658
|
+
| { status?: { resourceRecords?: Array<{ name?: string; rrdata?: string; type?: string }> } }
|
|
659
|
+
| undefined
|
|
660
|
+
) {
|
|
661
|
+
const cname = mapping?.status?.resourceRecords?.find((record) => record.type === "CNAME" && record.rrdata);
|
|
662
|
+
const content = (cname?.rrdata ?? "ghs.googlehosted.com.").replace(/\.$/, "");
|
|
663
|
+
return {
|
|
664
|
+
type: "CNAME",
|
|
665
|
+
name: config.domain.hostname,
|
|
666
|
+
content,
|
|
667
|
+
ttl: CLOUDFLARE_DNS_TTL_AUTO,
|
|
668
|
+
proxied: false,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function cloudflareZoneId(token = resolveCloudflareApiToken({ required: true })) {
|
|
673
|
+
const response = cloudflareFetch("GET", `/zones?name=${encodeURIComponent(config.domain.baseDomain)}`, undefined, token);
|
|
674
|
+
const zone = response.result?.[0] as { id?: string } | undefined;
|
|
675
|
+
if (!zone?.id) {
|
|
676
|
+
throw new Error(`Cloudflare zone not found for ${config.domain.baseDomain}`);
|
|
677
|
+
}
|
|
678
|
+
return zone.id;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function listCloudflareDnsRecords(zoneId: string, name: string, token = resolveCloudflareApiToken({ required: true })) {
|
|
682
|
+
const response = cloudflareFetch(
|
|
683
|
+
"GET",
|
|
684
|
+
`/zones/${zoneId}/dns_records?name=${encodeURIComponent(name)}&per_page=100`,
|
|
685
|
+
undefined,
|
|
686
|
+
token
|
|
687
|
+
);
|
|
688
|
+
return (response.result ?? []) as Array<{ id: string; type: string; content: string; proxied: boolean }>;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function cloudflareFetch(method: string, path: string, body?: unknown, token = resolveCloudflareApiToken({ required: true })) {
|
|
692
|
+
const response = fetchJsonSync(`${config.domain.cloudflareApiBaseUrl}${path}`, {
|
|
693
|
+
method,
|
|
694
|
+
headers: {
|
|
695
|
+
authorization: `Bearer ${token}`,
|
|
696
|
+
"content-type": "application/json",
|
|
697
|
+
accept: "application/json",
|
|
698
|
+
},
|
|
699
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
700
|
+
});
|
|
701
|
+
if (response.status < 200 || response.status >= 300) {
|
|
702
|
+
throw new Error(`Cloudflare ${method} ${path} failed: ${response.status} ${response.body}`);
|
|
703
|
+
}
|
|
704
|
+
const parsed = response.body ? JSON.parse(response.body) : {};
|
|
705
|
+
if (parsed.success === false) {
|
|
706
|
+
throw new Error(`Cloudflare ${method} ${path} failed: ${response.body}`);
|
|
707
|
+
}
|
|
708
|
+
return parsed;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function resolveCloudflareApiToken(options: { required: boolean }) {
|
|
712
|
+
const direct = process.env.CLOUDFLARE_API_TOKEN?.trim();
|
|
713
|
+
if (direct) {
|
|
714
|
+
return direct;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const vault = Bun.which("vault");
|
|
718
|
+
if (vault) {
|
|
719
|
+
const path = process.env.VAULT_CLOUDFLARE_API_TOKEN_PATH?.trim() || config.domain.cloudflareVaultPath;
|
|
720
|
+
const field = process.env.VAULT_CLOUDFLARE_API_TOKEN_FIELD?.trim() || config.domain.cloudflareVaultField;
|
|
721
|
+
const result = Bun.spawnSync(
|
|
722
|
+
[vault, "kv", "get", `-mount=${process.env.VAULT_SECRET_MOUNT || "secret"}`, `-field=${field}`, path],
|
|
723
|
+
{
|
|
724
|
+
cwd: process.cwd(),
|
|
725
|
+
env: process.env,
|
|
726
|
+
stdout: "pipe",
|
|
727
|
+
stderr: "pipe",
|
|
728
|
+
}
|
|
729
|
+
);
|
|
730
|
+
if (result.success && result.stdout) {
|
|
731
|
+
return decoder.decode(result.stdout).trim();
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (!options.required) {
|
|
736
|
+
return "";
|
|
737
|
+
}
|
|
738
|
+
throw new Error(
|
|
739
|
+
[
|
|
740
|
+
"CLOUDFLARE_API_TOKEN is required to create DNS records for the production Cloud Run domain.",
|
|
741
|
+
`Set CLOUDFLARE_API_TOKEN or store it at secret/${config.domain.cloudflareVaultPath} field ${config.domain.cloudflareVaultField}.`,
|
|
742
|
+
].join(" ")
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function fetchJsonSync(url: string, init: { method: string; headers: Record<string, string>; body?: string }) {
|
|
747
|
+
const script = [
|
|
748
|
+
"const url = process.argv[1];",
|
|
749
|
+
"const init = JSON.parse(process.argv[2]);",
|
|
750
|
+
"const response = await fetch(url, init);",
|
|
751
|
+
"const body = await response.text();",
|
|
752
|
+
"console.log(JSON.stringify({ status: response.status, body }));",
|
|
753
|
+
].join("\n");
|
|
754
|
+
const result = Bun.spawnSync([process.execPath, "--eval", script, url, JSON.stringify(init)], {
|
|
755
|
+
cwd: process.cwd(),
|
|
756
|
+
env: process.env,
|
|
757
|
+
stdout: "pipe",
|
|
758
|
+
stderr: "pipe",
|
|
759
|
+
});
|
|
760
|
+
if (!result.success) {
|
|
761
|
+
const stderr = result.stderr ? decoder.decode(result.stderr).trim() : "";
|
|
762
|
+
throw new Error(`Cloudflare request process failed\n${stderr}`);
|
|
763
|
+
}
|
|
764
|
+
return JSON.parse(decoder.decode(result.stdout).trim()) as { status: number; body: string };
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function waitForServiceAccount(email: string) {
|
|
768
|
+
for (let attempt = 1; attempt <= 12; attempt += 1) {
|
|
769
|
+
if (gcloud(["iam", "service-accounts", "describe", email, "--project", config.project.id], { allowFailure: true }).success) {
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
Bun.sleepSync(5_000);
|
|
773
|
+
}
|
|
774
|
+
throw new Error(`service account ${email} was created but is not yet readable`);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function isRetryableGcloudError(error: unknown) {
|
|
778
|
+
if (!(error instanceof CommandError)) {
|
|
779
|
+
return false;
|
|
780
|
+
}
|
|
781
|
+
const output = `${error.stdout}\n${error.stderr}`.toLowerCase();
|
|
782
|
+
return (
|
|
783
|
+
output.includes("does not exist") ||
|
|
784
|
+
output.includes("not found") ||
|
|
785
|
+
output.includes("permission denied") ||
|
|
786
|
+
output.includes("failed_precondition") ||
|
|
787
|
+
output.includes("try again") ||
|
|
788
|
+
output.includes("retry")
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
|
|
576
792
|
export function describeCloudRunService(serviceName: string): GcpResourceWithLabels | undefined {
|
|
577
793
|
const result = gcloud(
|
|
578
794
|
["run", "services", "describe", serviceName, "--project", config.project.id, "--region", config.region, "--format=json"],
|
|
@@ -7,6 +7,9 @@ export default {
|
|
|
7
7
|
dns: {
|
|
8
8
|
hostname: "{{API_HOSTNAME}}",
|
|
9
9
|
base_domain: "{{API_BASE_DOMAIN}}",
|
|
10
|
+
cloudflare_api_base_url: "https://api.cloudflare.com/client/v4",
|
|
11
|
+
cloudflare_vault_path: "prod/providers/cloudflare",
|
|
12
|
+
cloudflare_vault_field: "api_token",
|
|
10
13
|
},
|
|
11
14
|
ownership: {
|
|
12
15
|
managed_by: "create-service",
|
|
@@ -38,6 +41,7 @@ export default {
|
|
|
38
41
|
vault: {
|
|
39
42
|
mount: "secret",
|
|
40
43
|
neon_path: "prod/providers/neon",
|
|
44
|
+
cloudflare_path: "prod/providers/cloudflare",
|
|
41
45
|
grafana_path: "prod/providers/grafana",
|
|
42
46
|
clerk_m2m_path: "prod/providers/clerk-m2m",
|
|
43
47
|
temporal_path: "prod/providers/temporal",
|