create-svc 0.1.18 → 0.1.19
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
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,8 @@ 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("CLOUDFLARE_API_TOKEN");
|
|
90
94
|
expect(deployScript).toContain('"domain-mappings",');
|
|
91
95
|
expect(deployScript).toContain('"--region",');
|
|
92
96
|
expect(deployScript).toContain("assertProductionDomainAvailable");
|
|
@@ -152,6 +156,7 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
|
|
|
152
156
|
expect(localEnv).toContain(`DATABASE_URL=postgres://postgres:postgres@127.0.0.1:${localPort}/dns_api?sslmode=disable`);
|
|
153
157
|
expect(localEnv).toContain("VAULT_AUTHCTL_ACCESS_PATH=prod/apps/auth/authctl/cloudflare-access");
|
|
154
158
|
expect(localEnv).toContain("VAULT_NEON_API_KEY_PATH=prod/providers/neon");
|
|
159
|
+
expect(localEnv).toContain("VAULT_CLOUDFLARE_API_TOKEN_PATH=prod/providers/cloudflare");
|
|
155
160
|
expect(localEnv).not.toContain("ATTACHMENT_PUBLIC_BASE_URL=");
|
|
156
161
|
|
|
157
162
|
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",
|
|
@@ -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;
|
|
@@ -485,12 +486,13 @@ export function ensureProductionDomainMapping(serviceName: string) {
|
|
|
485
486
|
if (existing) {
|
|
486
487
|
const mappedService = existing.spec?.routeName ?? existing.status?.resourceRecords?.[0]?.rrdata;
|
|
487
488
|
if (!mappedService || mappedService === serviceName) {
|
|
489
|
+
ensureCloudflareDnsRecord(existing);
|
|
488
490
|
return;
|
|
489
491
|
}
|
|
490
492
|
throw new Error(`${config.domain.hostname} is already mapped to ${mappedService}; refusing to take it over`);
|
|
491
493
|
}
|
|
492
494
|
|
|
493
|
-
gcloud([
|
|
495
|
+
const result = gcloud([
|
|
494
496
|
"beta",
|
|
495
497
|
"run",
|
|
496
498
|
"domain-mappings",
|
|
@@ -504,6 +506,8 @@ export function ensureProductionDomainMapping(serviceName: string) {
|
|
|
504
506
|
"--region",
|
|
505
507
|
config.region,
|
|
506
508
|
]);
|
|
509
|
+
const created = parseDomainMappingOutput(result.stdout) ?? describeProductionDomainMapping();
|
|
510
|
+
ensureCloudflareDnsRecord(created);
|
|
507
511
|
}
|
|
508
512
|
|
|
509
513
|
export function describeProductionDomainMapping():
|
|
@@ -561,6 +565,7 @@ export function assertServiceNameAvailable(serviceName: string) {
|
|
|
561
565
|
}
|
|
562
566
|
|
|
563
567
|
export function deleteProductionDomainMapping() {
|
|
568
|
+
deleteCloudflareDnsRecord();
|
|
564
569
|
gcloud(["beta", "run", "domain-mappings", "delete", "--domain", config.domain.hostname, "--project", config.project.id, "--quiet"], {
|
|
565
570
|
allowFailure: true,
|
|
566
571
|
});
|
|
@@ -573,6 +578,165 @@ export function listCloudRunServices() {
|
|
|
573
578
|
.filter(Boolean);
|
|
574
579
|
}
|
|
575
580
|
|
|
581
|
+
function parseDomainMappingOutput(stdout: string) {
|
|
582
|
+
if (!stdout.trim().startsWith("{")) {
|
|
583
|
+
return undefined;
|
|
584
|
+
}
|
|
585
|
+
try {
|
|
586
|
+
return JSON.parse(stdout) as ReturnType<typeof describeProductionDomainMapping>;
|
|
587
|
+
} catch {
|
|
588
|
+
return undefined;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function ensureCloudflareDnsRecord(
|
|
593
|
+
mapping:
|
|
594
|
+
| { status?: { resourceRecords?: Array<{ name?: string; rrdata?: string; type?: string }> } }
|
|
595
|
+
| undefined
|
|
596
|
+
) {
|
|
597
|
+
const desired = desiredCloudflareRecord(mapping);
|
|
598
|
+
const zoneId = cloudflareZoneId();
|
|
599
|
+
const records = listCloudflareDnsRecords(zoneId, config.domain.hostname);
|
|
600
|
+
const conflicting = records.find((record) => record.type !== desired.type);
|
|
601
|
+
if (conflicting) {
|
|
602
|
+
throw new Error(
|
|
603
|
+
`Cloudflare DNS record ${config.domain.hostname} already exists as ${conflicting.type}; remove or update it before provisioning`
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
const existing = records.find((record) => record.type === desired.type);
|
|
607
|
+
if (!existing) {
|
|
608
|
+
cloudflareFetch("POST", `/zones/${zoneId}/dns_records`, desired);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (existing.content === desired.content && existing.proxied === desired.proxied) {
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
cloudflareFetch("PUT", `/zones/${zoneId}/dns_records/${existing.id}`, desired);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function deleteCloudflareDnsRecord() {
|
|
618
|
+
const token = resolveCloudflareApiToken({ required: false });
|
|
619
|
+
if (!token) {
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
const zoneId = cloudflareZoneId(token);
|
|
623
|
+
const records = listCloudflareDnsRecords(zoneId, config.domain.hostname, token);
|
|
624
|
+
for (const record of records) {
|
|
625
|
+
cloudflareFetch("DELETE", `/zones/${zoneId}/dns_records/${record.id}`, undefined, token);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function desiredCloudflareRecord(
|
|
630
|
+
mapping:
|
|
631
|
+
| { status?: { resourceRecords?: Array<{ name?: string; rrdata?: string; type?: string }> } }
|
|
632
|
+
| undefined
|
|
633
|
+
) {
|
|
634
|
+
const cname = mapping?.status?.resourceRecords?.find((record) => record.type === "CNAME" && record.rrdata);
|
|
635
|
+
const content = (cname?.rrdata ?? "ghs.googlehosted.com.").replace(/\.$/, "");
|
|
636
|
+
return {
|
|
637
|
+
type: "CNAME",
|
|
638
|
+
name: config.domain.hostname,
|
|
639
|
+
content,
|
|
640
|
+
ttl: CLOUDFLARE_DNS_TTL_AUTO,
|
|
641
|
+
proxied: false,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function cloudflareZoneId(token = resolveCloudflareApiToken({ required: true })) {
|
|
646
|
+
const response = cloudflareFetch("GET", `/zones?name=${encodeURIComponent(config.domain.baseDomain)}`, undefined, token);
|
|
647
|
+
const zone = response.result?.[0] as { id?: string } | undefined;
|
|
648
|
+
if (!zone?.id) {
|
|
649
|
+
throw new Error(`Cloudflare zone not found for ${config.domain.baseDomain}`);
|
|
650
|
+
}
|
|
651
|
+
return zone.id;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function listCloudflareDnsRecords(zoneId: string, name: string, token = resolveCloudflareApiToken({ required: true })) {
|
|
655
|
+
const response = cloudflareFetch(
|
|
656
|
+
"GET",
|
|
657
|
+
`/zones/${zoneId}/dns_records?name=${encodeURIComponent(name)}&per_page=100`,
|
|
658
|
+
undefined,
|
|
659
|
+
token
|
|
660
|
+
);
|
|
661
|
+
return (response.result ?? []) as Array<{ id: string; type: string; content: string; proxied: boolean }>;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function cloudflareFetch(method: string, path: string, body?: unknown, token = resolveCloudflareApiToken({ required: true })) {
|
|
665
|
+
const response = fetchJsonSync(`${config.domain.cloudflareApiBaseUrl}${path}`, {
|
|
666
|
+
method,
|
|
667
|
+
headers: {
|
|
668
|
+
authorization: `Bearer ${token}`,
|
|
669
|
+
"content-type": "application/json",
|
|
670
|
+
accept: "application/json",
|
|
671
|
+
},
|
|
672
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
673
|
+
});
|
|
674
|
+
if (response.status < 200 || response.status >= 300) {
|
|
675
|
+
throw new Error(`Cloudflare ${method} ${path} failed: ${response.status} ${response.body}`);
|
|
676
|
+
}
|
|
677
|
+
const parsed = response.body ? JSON.parse(response.body) : {};
|
|
678
|
+
if (parsed.success === false) {
|
|
679
|
+
throw new Error(`Cloudflare ${method} ${path} failed: ${response.body}`);
|
|
680
|
+
}
|
|
681
|
+
return parsed;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function resolveCloudflareApiToken(options: { required: boolean }) {
|
|
685
|
+
const direct = process.env.CLOUDFLARE_API_TOKEN?.trim();
|
|
686
|
+
if (direct) {
|
|
687
|
+
return direct;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const vault = Bun.which("vault");
|
|
691
|
+
if (vault) {
|
|
692
|
+
const path = process.env.VAULT_CLOUDFLARE_API_TOKEN_PATH?.trim() || config.domain.cloudflareVaultPath;
|
|
693
|
+
const field = process.env.VAULT_CLOUDFLARE_API_TOKEN_FIELD?.trim() || config.domain.cloudflareVaultField;
|
|
694
|
+
const result = Bun.spawnSync(
|
|
695
|
+
[vault, "kv", "get", `-mount=${process.env.VAULT_SECRET_MOUNT || "secret"}`, `-field=${field}`, path],
|
|
696
|
+
{
|
|
697
|
+
cwd: process.cwd(),
|
|
698
|
+
env: process.env,
|
|
699
|
+
stdout: "pipe",
|
|
700
|
+
stderr: "pipe",
|
|
701
|
+
}
|
|
702
|
+
);
|
|
703
|
+
if (result.success && result.stdout) {
|
|
704
|
+
return decoder.decode(result.stdout).trim();
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (!options.required) {
|
|
709
|
+
return "";
|
|
710
|
+
}
|
|
711
|
+
throw new Error(
|
|
712
|
+
[
|
|
713
|
+
"CLOUDFLARE_API_TOKEN is required to create DNS records for the production Cloud Run domain.",
|
|
714
|
+
`Set CLOUDFLARE_API_TOKEN or store it at secret/${config.domain.cloudflareVaultPath} field ${config.domain.cloudflareVaultField}.`,
|
|
715
|
+
].join(" ")
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function fetchJsonSync(url: string, init: { method: string; headers: Record<string, string>; body?: string }) {
|
|
720
|
+
const script = [
|
|
721
|
+
"const url = process.argv[1];",
|
|
722
|
+
"const init = JSON.parse(process.argv[2]);",
|
|
723
|
+
"const response = await fetch(url, init);",
|
|
724
|
+
"const body = await response.text();",
|
|
725
|
+
"console.log(JSON.stringify({ status: response.status, body }));",
|
|
726
|
+
].join("\n");
|
|
727
|
+
const result = Bun.spawnSync([process.execPath, "--eval", script, url, JSON.stringify(init)], {
|
|
728
|
+
cwd: process.cwd(),
|
|
729
|
+
env: process.env,
|
|
730
|
+
stdout: "pipe",
|
|
731
|
+
stderr: "pipe",
|
|
732
|
+
});
|
|
733
|
+
if (!result.success) {
|
|
734
|
+
const stderr = result.stderr ? decoder.decode(result.stderr).trim() : "";
|
|
735
|
+
throw new Error(`Cloudflare request process failed\n${stderr}`);
|
|
736
|
+
}
|
|
737
|
+
return JSON.parse(decoder.decode(result.stdout).trim()) as { status: number; body: string };
|
|
738
|
+
}
|
|
739
|
+
|
|
576
740
|
export function describeCloudRunService(serviceName: string): GcpResourceWithLabels | undefined {
|
|
577
741
|
const result = gcloud(
|
|
578
742
|
["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",
|