@arcote.tech/arc-cli 0.6.2 → 0.7.1
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/dist/index.js +1696 -1663
- package/package.json +7 -7
- package/src/builder/access-extractor.ts +64 -46
- package/src/builder/build-cache.ts +3 -1
- package/src/builder/chunk-planner.ts +107 -0
- package/src/builder/dependency-collector.ts +83 -41
- package/src/builder/framework-peers.ts +81 -0
- package/src/builder/module-builder.ts +322 -106
- package/src/commands/platform-build.ts +2 -1
- package/src/commands/platform-deploy.ts +121 -64
- package/src/commands/platform-dev.ts +11 -100
- package/src/commands/platform-start.ts +4 -90
- package/src/deploy/ansible.ts +23 -3
- package/src/deploy/assets/ansible/site.yml +23 -7
- package/src/deploy/assets.ts +23 -7
- package/src/deploy/bootstrap.ts +270 -10
- package/src/deploy/caddyfile.ts +19 -23
- package/src/deploy/compose.ts +44 -27
- package/src/deploy/config.ts +67 -3
- package/src/deploy/deploy-env.ts +129 -0
- package/src/deploy/env-file.ts +103 -0
- package/src/deploy/htpasswd.ts +28 -0
- package/src/deploy/image-template.ts +74 -0
- package/src/deploy/image.ts +243 -0
- package/src/deploy/registry.ts +79 -0
- package/src/deploy/ssh.ts +52 -122
- package/src/deploy/survey.ts +64 -0
- package/src/index.ts +20 -13
- package/src/platform/server.ts +119 -94
- package/src/platform/shared.ts +139 -292
- package/src/platform/startup.ts +159 -0
- package/runtime/Dockerfile +0 -29
- package/runtime/build-and-push.sh +0 -23
- package/runtime/entrypoint.sh +0 -58
- package/src/commands/build-shell.ts +0 -152
- package/src/deploy/remote-sync.ts +0 -321
- package/src/platform/deploy-api.ts +0 -400
package/src/deploy/bootstrap.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawn } from "bun";
|
|
1
2
|
import { mkdirSync, writeFileSync } from "fs";
|
|
2
3
|
import { tmpdir } from "os";
|
|
3
4
|
import { join } from "path";
|
|
@@ -5,12 +6,39 @@ import { runAnsible } from "./ansible";
|
|
|
5
6
|
import { generateCaddyfile } from "./caddyfile";
|
|
6
7
|
import { generateCompose } from "./compose";
|
|
7
8
|
import type { DeployConfig } from "./config";
|
|
9
|
+
import { generateHtpasswd } from "./htpasswd";
|
|
8
10
|
import { runTerraform } from "./terraform";
|
|
9
11
|
import { saveDeployConfig } from "./config";
|
|
10
12
|
import { ok, log, err } from "../platform/shared";
|
|
11
13
|
import { writeStateMarker, STATE_MARKER_PATH } from "./remote-state";
|
|
12
14
|
import type { RemoteState } from "./remote-state";
|
|
13
|
-
import { assertExec, canSsh, scpUpload, waitForSsh } from "./ssh";
|
|
15
|
+
import { assertExec, baseSshArgs, canSsh, scpUpload, sshExec, waitForSsh } from "./ssh";
|
|
16
|
+
import type { DeployTarget } from "./config";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Wait until *any* of the supplied SSH targets accepts a connection. Polls
|
|
20
|
+
* all targets in parallel each round; the first one that succeeds wins.
|
|
21
|
+
* Useful when we don't know whether the host is on its first ansible-less
|
|
22
|
+
* boot (root only) or already hardened (deploy user only).
|
|
23
|
+
*/
|
|
24
|
+
async function waitForAnySsh(
|
|
25
|
+
targets: DeployTarget[],
|
|
26
|
+
opts: { timeoutMs?: number; intervalMs?: number } = {},
|
|
27
|
+
): Promise<void> {
|
|
28
|
+
const timeout = opts.timeoutMs ?? 300_000;
|
|
29
|
+
const interval = opts.intervalMs ?? 5_000;
|
|
30
|
+
const start = Date.now();
|
|
31
|
+
while (Date.now() - start < timeout) {
|
|
32
|
+
const results = await Promise.all(targets.map((t) => canSsh(t)));
|
|
33
|
+
if (results.some(Boolean)) return;
|
|
34
|
+
await Bun.sleep(interval);
|
|
35
|
+
}
|
|
36
|
+
throw new Error(
|
|
37
|
+
`Timed out waiting for SSH on ${targets
|
|
38
|
+
.map((t) => `${t.user}@${t.host}`)
|
|
39
|
+
.join(" or ")}`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
14
42
|
|
|
15
43
|
// ---------------------------------------------------------------------------
|
|
16
44
|
// Bootstrap orchestrator.
|
|
@@ -33,6 +61,8 @@ export interface BootstrapInputs {
|
|
|
33
61
|
cliVersion: string;
|
|
34
62
|
/** sha256 of deploy.arc.json — used for the remote state marker. */
|
|
35
63
|
configHash: string;
|
|
64
|
+
/** Force the ansible run even when the host is already bootstrapped. */
|
|
65
|
+
forceAnsible?: boolean;
|
|
36
66
|
}
|
|
37
67
|
|
|
38
68
|
export async function bootstrap(inputs: BootstrapInputs): Promise<void> {
|
|
@@ -64,16 +94,31 @@ export async function bootstrap(inputs: BootstrapInputs): Promise<void> {
|
|
|
64
94
|
saveDeployConfig(rootDir, cfg);
|
|
65
95
|
|
|
66
96
|
log("Waiting for SSH to come up...");
|
|
67
|
-
|
|
97
|
+
// On a brand-new VM only root exists; on a re-applied (no-op) terraform
|
|
98
|
+
// the deploy user already exists and root login is disabled by ansible
|
|
99
|
+
// hardening. Probe both — succeed on whichever lands first.
|
|
100
|
+
await waitForAnySsh([
|
|
101
|
+
{ ...cfg.target, user: "root" },
|
|
102
|
+
{ ...cfg.target, user: cfg.target.user },
|
|
103
|
+
]);
|
|
68
104
|
ok("SSH reachable");
|
|
69
105
|
}
|
|
70
106
|
|
|
71
|
-
|
|
107
|
+
// Ansible only runs on fresh hosts (unreachable / no-docker) by default —
|
|
108
|
+
// it's idempotent but slow (~30–60s) and the host config rarely drifts.
|
|
109
|
+
// `--force-bootstrap` re-runs it on demand (after editing the embedded
|
|
110
|
+
// playbook, or to recover from manual host edits).
|
|
111
|
+
const needAnsible =
|
|
112
|
+
state.kind === "unreachable" ||
|
|
113
|
+
state.kind === "no-docker" ||
|
|
114
|
+
inputs.forceAnsible === true;
|
|
115
|
+
|
|
116
|
+
if (needAnsible) {
|
|
72
117
|
log("Running Ansible bootstrap (Docker + firewall + SSH hardening)...");
|
|
73
118
|
// Run as root whenever the configured user can't SSH (covers both freshly
|
|
74
119
|
// provisioned VMs and second-attempt deploys after ansible failure).
|
|
75
120
|
const deployUserWorks =
|
|
76
|
-
state.kind
|
|
121
|
+
state.kind !== "unreachable" && (await canSsh(cfg.target));
|
|
77
122
|
const asRoot = !deployUserWorks;
|
|
78
123
|
await runAnsible({
|
|
79
124
|
target: cfg.target,
|
|
@@ -83,7 +128,21 @@ export async function bootstrap(inputs: BootstrapInputs): Promise<void> {
|
|
|
83
128
|
ok("Host bootstrapped");
|
|
84
129
|
}
|
|
85
130
|
|
|
86
|
-
|
|
131
|
+
// Force upStack whenever:
|
|
132
|
+
// - stack isn't fully ready, OR
|
|
133
|
+
// - marker is missing (legacy v0.5 deploy with no .arc-state.json), OR
|
|
134
|
+
// - configHash differs from last bootstrap (deploy.arc.json changed), OR
|
|
135
|
+
// - registry container isn't running (e.g. legacy stack predates v0.7)
|
|
136
|
+
// Without this, an old v0.5 stack (no registry container) is classified as
|
|
137
|
+
// "ready" and bootstrap is skipped — then `docker login` on the next step
|
|
138
|
+
// hits a vhost that doesn't exist and fails with a TLS error.
|
|
139
|
+
const needUpStack =
|
|
140
|
+
state.kind !== "ready" ||
|
|
141
|
+
state.marker === null ||
|
|
142
|
+
state.marker.configHash !== inputs.configHash ||
|
|
143
|
+
!(await isRegistryRunning(cfg));
|
|
144
|
+
|
|
145
|
+
if (needUpStack) {
|
|
87
146
|
await upStack(inputs);
|
|
88
147
|
ok("Docker stack up");
|
|
89
148
|
}
|
|
@@ -96,16 +155,50 @@ export async function bootstrap(inputs: BootstrapInputs): Promise<void> {
|
|
|
96
155
|
});
|
|
97
156
|
}
|
|
98
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Returns true iff `registry` service is up in /opt/arc/docker-compose.yml.
|
|
160
|
+
* Used by bootstrap to detect legacy v0.5 stacks that have no registry
|
|
161
|
+
* container and need a fresh stack write + restart.
|
|
162
|
+
*/
|
|
163
|
+
async function isRegistryRunning(cfg: DeployConfig): Promise<boolean> {
|
|
164
|
+
const res = await sshExec(
|
|
165
|
+
cfg.target,
|
|
166
|
+
`cd ${cfg.target.remoteDir} && docker compose ps --status running --format '{{.Service}}' 2>/dev/null || true`,
|
|
167
|
+
{ quiet: true },
|
|
168
|
+
);
|
|
169
|
+
return res.stdout
|
|
170
|
+
.split("\n")
|
|
171
|
+
.map((s) => s.trim())
|
|
172
|
+
.includes("registry");
|
|
173
|
+
}
|
|
174
|
+
|
|
99
175
|
async function upStack(inputs: BootstrapInputs): Promise<void> {
|
|
100
176
|
const { cfg } = inputs;
|
|
101
177
|
const workDir = join(tmpdir(), "arc-deploy", `stack-${Date.now()}`);
|
|
102
178
|
mkdirSync(workDir, { recursive: true });
|
|
103
179
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
180
|
+
// Pre-flight DNS — without registry.<domain> resolving to the host, Caddy's
|
|
181
|
+
// ACME challenge for the registry vhost will fail. Better to bail with a
|
|
182
|
+
// clear message than let the operator chase TLS retries for 10 minutes.
|
|
183
|
+
await assertRegistryDnsResolves(cfg);
|
|
184
|
+
|
|
185
|
+
// Generate htpasswd locally from the password env var. Never write the
|
|
186
|
+
// plaintext password to disk; only the bcrypt hash leaves this process.
|
|
187
|
+
const password = process.env[cfg.registry.passwordEnv];
|
|
188
|
+
if (!password) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`Registry password env var ${cfg.registry.passwordEnv} is not set. ` +
|
|
191
|
+
`Set it (e.g. \`export ${cfg.registry.passwordEnv}=...\`) before bootstrap.`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
const htpasswdLine = await generateHtpasswd(
|
|
195
|
+
cfg.registry.username,
|
|
196
|
+
password,
|
|
108
197
|
);
|
|
198
|
+
writeFileSync(join(workDir, "htpasswd"), htpasswdLine);
|
|
199
|
+
|
|
200
|
+
writeFileSync(join(workDir, "Caddyfile"), generateCaddyfile(cfg));
|
|
201
|
+
writeFileSync(join(workDir, "docker-compose.yml"), generateCompose({ cfg }));
|
|
109
202
|
|
|
110
203
|
// Ensure remoteDir exists
|
|
111
204
|
await assertExec(
|
|
@@ -118,6 +211,10 @@ async function upStack(inputs: BootstrapInputs): Promise<void> {
|
|
|
118
211
|
`mkdir -p ${cfg.target.remoteDir}/${name}`,
|
|
119
212
|
);
|
|
120
213
|
}
|
|
214
|
+
await assertExec(
|
|
215
|
+
cfg.target,
|
|
216
|
+
`mkdir -p ${cfg.target.remoteDir}/registry-auth`,
|
|
217
|
+
);
|
|
121
218
|
|
|
122
219
|
await scpUpload(
|
|
123
220
|
cfg.target,
|
|
@@ -129,9 +226,172 @@ async function upStack(inputs: BootstrapInputs): Promise<void> {
|
|
|
129
226
|
join(workDir, "docker-compose.yml"),
|
|
130
227
|
`${cfg.target.remoteDir}/docker-compose.yml`,
|
|
131
228
|
);
|
|
229
|
+
await scpUpload(
|
|
230
|
+
cfg.target,
|
|
231
|
+
join(workDir, "htpasswd"),
|
|
232
|
+
`${cfg.target.remoteDir}/registry-auth/htpasswd`,
|
|
233
|
+
);
|
|
132
234
|
|
|
235
|
+
// Ensure /opt/arc/.env exists so docker compose doesn't error on var
|
|
236
|
+
// substitution. Per-env ARC_IMAGE_<ENV> lines are written by deploy.
|
|
133
237
|
await assertExec(
|
|
134
238
|
cfg.target,
|
|
135
|
-
`
|
|
239
|
+
`touch ${cfg.target.remoteDir}/.env`,
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
// Pre-register the deploy user with the private registry so containers can
|
|
243
|
+
// pull. We do this AFTER `docker compose up -d caddy registry` runs (below)
|
|
244
|
+
// — the registry needs to be reachable for `docker login` to succeed.
|
|
245
|
+
await assertExec(
|
|
246
|
+
cfg.target,
|
|
247
|
+
`cd ${cfg.target.remoteDir} && docker compose pull --ignore-pull-failures caddy registry && docker compose up -d caddy registry`,
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// Wait for the registry vhost to respond (Caddy ACME issuance takes a few
|
|
251
|
+
// seconds on first start), then docker login on the host. This caches
|
|
252
|
+
// credentials in /home/<user>/.docker/config.json so subsequent `docker
|
|
253
|
+
// compose pull arc-<env>` from the per-deploy step can fetch app images.
|
|
254
|
+
await sshDockerLogin(cfg);
|
|
255
|
+
|
|
256
|
+
// Bring up any arc-<env> services whose images are already published.
|
|
257
|
+
// The :? fallback in compose makes services with no ARC_IMAGE_<ENV> set
|
|
258
|
+
// fail their up step — we filter those out by reading .env first.
|
|
259
|
+
const knownEnvs = await listConfiguredEnvs(cfg);
|
|
260
|
+
if (knownEnvs.length > 0) {
|
|
261
|
+
await assertExec(
|
|
262
|
+
cfg.target,
|
|
263
|
+
`cd ${cfg.target.remoteDir} && docker compose up -d ${knownEnvs
|
|
264
|
+
.map((e) => `arc-${e}`)
|
|
265
|
+
.join(" ")}`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Log in to the private registry from the deploy user's account on the host.
|
|
272
|
+
* Required so `docker compose pull arc-<env>` works on subsequent deploys
|
|
273
|
+
* (compose runs docker from the deploy user's perspective; that user's
|
|
274
|
+
* `.docker/config.json` is where the registry token lives).
|
|
275
|
+
*/
|
|
276
|
+
async function sshDockerLogin(cfg: DeployConfig): Promise<void> {
|
|
277
|
+
const password = process.env[cfg.registry.passwordEnv];
|
|
278
|
+
if (!password) {
|
|
279
|
+
throw new Error(
|
|
280
|
+
`Registry password env var ${cfg.registry.passwordEnv} is not set on the deploy host (CLI machine).`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
// Stream password over SSH stdin — never reach the command line (no shell
|
|
284
|
+
// history, no `ps`, no double-shell-escape bugs). The remote shell pipes
|
|
285
|
+
// its own stdin straight into `docker login --password-stdin`.
|
|
286
|
+
const cmd = `docker login ${cfg.registry.domain} -u ${cfg.registry.username} --password-stdin`;
|
|
287
|
+
const proc = spawn({
|
|
288
|
+
cmd: [
|
|
289
|
+
"ssh",
|
|
290
|
+
...baseSshArgs(cfg.target),
|
|
291
|
+
`${cfg.target.user}@${cfg.target.host}`,
|
|
292
|
+
"--",
|
|
293
|
+
cmd,
|
|
294
|
+
],
|
|
295
|
+
stdin: "pipe",
|
|
296
|
+
stdout: "pipe",
|
|
297
|
+
stderr: "pipe",
|
|
298
|
+
});
|
|
299
|
+
if (proc.stdin) {
|
|
300
|
+
await (proc.stdin as any).write(new TextEncoder().encode(password));
|
|
301
|
+
await (proc.stdin as any).end?.();
|
|
302
|
+
}
|
|
303
|
+
const exit = await proc.exited;
|
|
304
|
+
if (exit !== 0) {
|
|
305
|
+
const stderr = await new Response(proc.stderr).text();
|
|
306
|
+
throw new Error(
|
|
307
|
+
`Server-side docker login failed (exit ${exit}): ${stderr.trim()}`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Read /opt/arc/.env on the host and return env names that have an
|
|
314
|
+
* `ARC_IMAGE_<ENV>=...` line set. Used by bootstrap to decide which arc-<env>
|
|
315
|
+
* services can safely be started (the others would fail compose var
|
|
316
|
+
* substitution with `:?`).
|
|
317
|
+
*/
|
|
318
|
+
async function listConfiguredEnvs(cfg: DeployConfig): Promise<string[]> {
|
|
319
|
+
const res = await sshExec(
|
|
320
|
+
cfg.target,
|
|
321
|
+
`cat ${cfg.target.remoteDir}/.env 2>/dev/null || true`,
|
|
322
|
+
{ quiet: true },
|
|
136
323
|
);
|
|
324
|
+
const set = new Set<string>();
|
|
325
|
+
for (const line of res.stdout.split("\n")) {
|
|
326
|
+
const m = line.match(/^ARC_IMAGE_([A-Z0-9_]+)=/);
|
|
327
|
+
if (!m) continue;
|
|
328
|
+
const lowerName = m[1].toLowerCase().replace(/_/g, "-");
|
|
329
|
+
if (lowerName in cfg.envs) set.add(lowerName);
|
|
330
|
+
}
|
|
331
|
+
return [...set];
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Verify that `<registry.domain>` resolves to the target host's IP. If DNS
|
|
336
|
+
* isn't propagated yet, Caddy's ACME challenge for the registry vhost will
|
|
337
|
+
* fail repeatedly. Fail fast with an actionable hint instead.
|
|
338
|
+
*/
|
|
339
|
+
async function assertRegistryDnsResolves(cfg: DeployConfig): Promise<void> {
|
|
340
|
+
// Source of truth for "is the DNS update live?" is the authoritative NS for
|
|
341
|
+
// the apex domain — public resolvers (8.8.8.8 / 1.1.1.1) cache for minutes
|
|
342
|
+
// after a record change and disagree among themselves during propagation.
|
|
343
|
+
// Let's Encrypt ACME validates against the authoritative NS too, so this
|
|
344
|
+
// matches what Caddy will see when it tries to issue the cert.
|
|
345
|
+
const apex = apexDomain(cfg.registry.domain);
|
|
346
|
+
let nameservers = await digQuery("8.8.8.8", "NS", apex);
|
|
347
|
+
nameservers = nameservers.map((s) => s.replace(/\.$/, ""));
|
|
348
|
+
|
|
349
|
+
// Sources to query, in order: authoritative NS, then public resolvers.
|
|
350
|
+
// Accept the first source where any answer matches target.host.
|
|
351
|
+
const sources = [...nameservers, "1.1.1.1", "8.8.8.8"];
|
|
352
|
+
let lastAnswers: string[] = [];
|
|
353
|
+
|
|
354
|
+
for (const source of sources) {
|
|
355
|
+
const answers = await digQuery(source, "A", cfg.registry.domain);
|
|
356
|
+
if (answers.length === 0) continue;
|
|
357
|
+
lastAnswers = answers;
|
|
358
|
+
if (answers.includes(cfg.target.host)) return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (lastAnswers.length === 0) {
|
|
362
|
+
throw new Error(
|
|
363
|
+
`Registry DNS not configured: ${cfg.registry.domain} doesn't resolve. ` +
|
|
364
|
+
`Add an A record pointing to ${cfg.target.host} and re-run deploy.`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
throw new Error(
|
|
368
|
+
`Registry DNS mismatch: ${cfg.registry.domain} resolves to [${lastAnswers.join(", ")}], ` +
|
|
369
|
+
`but target host is ${cfg.target.host}. Update the A record before continuing.`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function apexDomain(host: string): string {
|
|
374
|
+
// Naive eTLD+1 extraction: last 2 labels. Works for `.pl`, `.com`, etc.
|
|
375
|
+
// For `.co.uk` style TLDs the authoritative NS query still returns the
|
|
376
|
+
// correct NS — dig handles the SOA chase upstream.
|
|
377
|
+
const parts = host.split(".");
|
|
378
|
+
return parts.slice(-2).join(".");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function digQuery(
|
|
382
|
+
server: string,
|
|
383
|
+
type: "A" | "NS",
|
|
384
|
+
name: string,
|
|
385
|
+
): Promise<string[]> {
|
|
386
|
+
const proc = spawn({
|
|
387
|
+
cmd: ["dig", `@${server}`, "+short", "+time=3", "+tries=1", type, name],
|
|
388
|
+
stdout: "pipe",
|
|
389
|
+
stderr: "ignore",
|
|
390
|
+
});
|
|
391
|
+
const exit = await proc.exited;
|
|
392
|
+
if (exit !== 0) return [];
|
|
393
|
+
return (await new Response(proc.stdout).text())
|
|
394
|
+
.split("\n")
|
|
395
|
+
.map((s) => s.trim())
|
|
396
|
+
.filter(Boolean);
|
|
137
397
|
}
|
package/src/deploy/caddyfile.ts
CHANGED
|
@@ -3,18 +3,16 @@ import type { DeployConfig } from "./config";
|
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
4
|
// Caddyfile generator
|
|
5
5
|
//
|
|
6
|
-
// Two kinds of
|
|
6
|
+
// Two kinds of vhosts:
|
|
7
7
|
//
|
|
8
|
-
// 1. Public
|
|
9
|
-
//
|
|
10
|
-
//
|
|
8
|
+
// 1. Public env blocks (80/443) — one per env, routed by Host header.
|
|
9
|
+
// Plain reverse-proxy to arc-<env>:5005. No /api/deploy/* paths exist
|
|
10
|
+
// in v0.7 (deploy goes through docker push, not HTTP), so there's
|
|
11
|
+
// nothing to block at the Caddy level.
|
|
11
12
|
//
|
|
12
|
-
// 2.
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// API from off-host; the listener is bound to loopback inside the
|
|
16
|
-
// Caddy container, and docker-compose publishes 127.0.0.1:2019:2019.
|
|
17
|
-
// CLI reaches it via `ssh -L`.
|
|
13
|
+
// 2. Private Docker Registry vhost — proxies registry.<domain> to the
|
|
14
|
+
// in-cluster `registry:2` service. htpasswd basic auth is enforced on
|
|
15
|
+
// the registry container side; Caddy just terminates TLS.
|
|
18
16
|
// ---------------------------------------------------------------------------
|
|
19
17
|
|
|
20
18
|
export function generateCaddyfile(cfg: DeployConfig): string {
|
|
@@ -35,24 +33,22 @@ export function generateCaddyfile(cfg: DeployConfig): string {
|
|
|
35
33
|
// Public blocks — one per env
|
|
36
34
|
for (const [name, env] of Object.entries(cfg.envs)) {
|
|
37
35
|
lines.push(`${env.domain} {${tlsDirective}`);
|
|
38
|
-
lines.push(" @deploy path /api/deploy /api/deploy/*");
|
|
39
|
-
lines.push(" respond @deploy 404");
|
|
40
|
-
lines.push("");
|
|
41
36
|
lines.push(` reverse_proxy arc-${name}:5005`);
|
|
42
37
|
lines.push("}");
|
|
43
38
|
lines.push("");
|
|
44
39
|
}
|
|
45
40
|
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
lines.push("
|
|
41
|
+
// Private Docker Registry — Caddy proxies HTTPS termination + Let's Encrypt;
|
|
42
|
+
// the registry:2 container handles its own htpasswd basic auth upstream.
|
|
43
|
+
// 5 GiB request body cap fits real app images comfortably (default 100MB
|
|
44
|
+
// triggers 413 on the first layer push).
|
|
45
|
+
lines.push(`${cfg.registry.domain} {${tlsDirective}`);
|
|
46
|
+
lines.push(" reverse_proxy registry:5000 {");
|
|
47
|
+
lines.push(" header_up Host {host}");
|
|
48
|
+
lines.push(" }");
|
|
49
|
+
lines.push(" request_body {");
|
|
50
|
+
lines.push(" max_size 5GB");
|
|
51
|
+
lines.push(" }");
|
|
56
52
|
lines.push("}");
|
|
57
53
|
|
|
58
54
|
return lines.join("\n") + "\n";
|
package/src/deploy/compose.ts
CHANGED
|
@@ -1,41 +1,32 @@
|
|
|
1
1
|
import type { DeployConfig } from "./config";
|
|
2
2
|
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
|
-
// docker-compose.yml generator
|
|
4
|
+
// docker-compose.yml generator
|
|
5
5
|
//
|
|
6
6
|
// Services:
|
|
7
7
|
// - caddy (public 80/443, loopback 127.0.0.1:2019 for deploy tunnel)
|
|
8
|
-
// - arc-<env> per entry in deploy.arc.json envs
|
|
8
|
+
// - arc-<env> per entry in deploy.arc.json envs (bind-mounts project dir)
|
|
9
9
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// on volume state. No user code or node_modules on host disk (everything
|
|
14
|
-
// lives in named volumes, populated via /api/deploy/* multipart pushes).
|
|
10
|
+
// No custom images: vanilla `caddy:2-alpine` and `oven/bun:1-alpine` from
|
|
11
|
+
// Docker Hub. The Arc CLI and user's built artifacts come via the volume
|
|
12
|
+
// mount at /opt/arc/<env>/ — rsynced by the deploy command.
|
|
15
13
|
// ---------------------------------------------------------------------------
|
|
16
14
|
|
|
17
15
|
export interface ComposeOptions {
|
|
18
16
|
cfg: DeployConfig;
|
|
19
|
-
/** CLI version used by entrypoint.sh to `bun add @arcote.tech/arc-cli@VER`. */
|
|
20
|
-
cliVersion: string;
|
|
21
17
|
}
|
|
22
18
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
export function generateCompose({ cfg, cliVersion }: ComposeOptions): string {
|
|
19
|
+
export function generateCompose({ cfg }: ComposeOptions): string {
|
|
26
20
|
const lines: string[] = [];
|
|
27
21
|
lines.push("# Generated by `arc platform deploy` — do not edit by hand.");
|
|
28
22
|
lines.push("");
|
|
29
23
|
lines.push("services:");
|
|
30
|
-
|
|
31
|
-
// Caddy
|
|
32
24
|
lines.push(" caddy:");
|
|
33
25
|
lines.push(" image: caddy:2-alpine");
|
|
34
26
|
lines.push(" restart: unless-stopped");
|
|
35
27
|
lines.push(" ports:");
|
|
36
28
|
lines.push(' - "80:80"');
|
|
37
29
|
lines.push(' - "443:443"');
|
|
38
|
-
lines.push(' - "127.0.0.1:2019:2019"');
|
|
39
30
|
lines.push(" volumes:");
|
|
40
31
|
lines.push(" - ./Caddyfile:/etc/caddy/Caddyfile:ro");
|
|
41
32
|
lines.push(" - caddy_data:/data");
|
|
@@ -44,28 +35,57 @@ export function generateCompose({ cfg, cliVersion }: ComposeOptions): string {
|
|
|
44
35
|
lines.push(" - arc-net");
|
|
45
36
|
lines.push("");
|
|
46
37
|
|
|
47
|
-
//
|
|
38
|
+
// Private Docker Registry — `arc platform deploy` pushes app images here,
|
|
39
|
+
// arc-<env> containers pull from here. htpasswd auth file is uploaded by
|
|
40
|
+
// bootstrap (generated locally from the password env var).
|
|
41
|
+
lines.push(" registry:");
|
|
42
|
+
lines.push(" image: registry:2");
|
|
43
|
+
lines.push(" restart: unless-stopped");
|
|
44
|
+
lines.push(" volumes:");
|
|
45
|
+
lines.push(" - registry_data:/var/lib/registry");
|
|
46
|
+
lines.push(" - ./registry-auth/htpasswd:/auth/htpasswd:ro");
|
|
47
|
+
lines.push(" environment:");
|
|
48
|
+
lines.push(" REGISTRY_AUTH: htpasswd");
|
|
49
|
+
lines.push(' REGISTRY_AUTH_HTPASSWD_REALM: "Arc Registry"');
|
|
50
|
+
lines.push(" REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd");
|
|
51
|
+
// Large image layers (framework peers + arc-cli bundle + chunks) need a
|
|
52
|
+
// generous upload limit. Default 100MB triggers 413 on real apps.
|
|
53
|
+
lines.push(' REGISTRY_HTTP_HOST: "https://' + cfg.registry.domain + '"');
|
|
54
|
+
lines.push(" networks:");
|
|
55
|
+
lines.push(" - arc-net");
|
|
56
|
+
lines.push(" expose:");
|
|
57
|
+
lines.push(' - "5000"');
|
|
58
|
+
lines.push("");
|
|
59
|
+
|
|
48
60
|
for (const [name, env] of Object.entries(cfg.envs)) {
|
|
61
|
+
const upperName = name.toUpperCase().replace(/-/g, "_");
|
|
49
62
|
lines.push(` arc-${name}:`);
|
|
50
|
-
|
|
63
|
+
// Image ref comes from /opt/arc/.env, written per-deploy with the content
|
|
64
|
+
// hash of the latest build. Default to a placeholder so `docker compose
|
|
65
|
+
// pull caddy registry` doesn't fail with `:?` interpolation errors on this
|
|
66
|
+
// service before the first deploy ever sets ARC_IMAGE_<ENV>.
|
|
67
|
+
lines.push(
|
|
68
|
+
` image: \${ARC_IMAGE_${upperName}:-arc-${name}:not-deployed}`,
|
|
69
|
+
);
|
|
70
|
+
lines.push(` container_name: arc-${name}`);
|
|
51
71
|
lines.push(" restart: unless-stopped");
|
|
52
72
|
lines.push(" volumes:");
|
|
53
|
-
|
|
73
|
+
// Only the data volume — user code lives entirely inside the image.
|
|
74
|
+
// SQLite + uploads persist across redeploys.
|
|
54
75
|
lines.push(` - arc-data-${name}:/app/.arc/data`);
|
|
55
|
-
lines.push(" - arc-cli-cache:/app/.arc/cli");
|
|
56
|
-
lines.push(" - arc-bun-cache:/root/.bun/install/cache");
|
|
57
76
|
lines.push(" environment:");
|
|
58
77
|
lines.push(" PORT: 5005");
|
|
59
|
-
lines.push(' ARC_DEPLOY_API: "1"');
|
|
60
|
-
lines.push(` ARC_CLI_VERSION: ${JSON.stringify(cliVersion)}`);
|
|
61
78
|
const userEnv = env.envVars ?? {};
|
|
62
79
|
if (!("NODE_ENV" in userEnv)) {
|
|
63
80
|
lines.push(" NODE_ENV: production");
|
|
64
81
|
}
|
|
82
|
+
// PORT is reserved — user envVars can't override.
|
|
83
|
+
const reserved = new Set(["PORT"]);
|
|
65
84
|
for (const [k, v] of Object.entries(userEnv)) {
|
|
66
|
-
if (
|
|
85
|
+
if (reserved.has(k)) continue;
|
|
67
86
|
lines.push(` ${k}: ${JSON.stringify(v)}`);
|
|
68
87
|
}
|
|
88
|
+
// ENTRYPOINT + CMD come from the image — no `command:` override needed.
|
|
69
89
|
lines.push(" networks:");
|
|
70
90
|
lines.push(" - arc-net");
|
|
71
91
|
lines.push(" expose:");
|
|
@@ -73,17 +93,14 @@ export function generateCompose({ cfg, cliVersion }: ComposeOptions): string {
|
|
|
73
93
|
lines.push("");
|
|
74
94
|
}
|
|
75
95
|
|
|
76
|
-
// Networks + volumes
|
|
77
96
|
lines.push("networks:");
|
|
78
97
|
lines.push(" arc-net:");
|
|
79
98
|
lines.push("");
|
|
80
99
|
lines.push("volumes:");
|
|
81
100
|
lines.push(" caddy_data:");
|
|
82
101
|
lines.push(" caddy_config:");
|
|
83
|
-
lines.push("
|
|
84
|
-
lines.push(" arc-bun-cache:");
|
|
102
|
+
lines.push(" registry_data:");
|
|
85
103
|
for (const [name] of Object.entries(cfg.envs)) {
|
|
86
|
-
lines.push(` arc-platform-${name}:`);
|
|
87
104
|
lines.push(` arc-data-${name}:`);
|
|
88
105
|
}
|
|
89
106
|
|
package/src/deploy/config.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
|
+
import { applyDeployGlobals, loadDeployEnvFiles } from "./env-file";
|
|
3
4
|
|
|
4
5
|
// ---------------------------------------------------------------------------
|
|
5
6
|
// deploy.arc.json — single source of truth for deployment configuration.
|
|
@@ -54,10 +55,20 @@ export interface DeployProvision {
|
|
|
54
55
|
ansible?: DeployProvisionAnsible;
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
export interface DeployRegistry {
|
|
59
|
+
/** Full domain — Caddy reverse-proxies this to the registry:5000 container. */
|
|
60
|
+
domain: string;
|
|
61
|
+
/** htpasswd basic-auth username. Default: `deploy`. */
|
|
62
|
+
username: string;
|
|
63
|
+
/** Name of env var holding the htpasswd password. Never inline the secret. */
|
|
64
|
+
passwordEnv: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
57
67
|
export interface DeployConfig {
|
|
58
68
|
target: DeployTarget;
|
|
59
69
|
envs: Record<string, DeployEnv>;
|
|
60
70
|
caddy: DeployCaddy;
|
|
71
|
+
registry: DeployRegistry;
|
|
61
72
|
provision?: DeployProvision;
|
|
62
73
|
}
|
|
63
74
|
|
|
@@ -76,8 +87,17 @@ export function deployConfigExists(rootDir: string): boolean {
|
|
|
76
87
|
}
|
|
77
88
|
|
|
78
89
|
/**
|
|
79
|
-
* Load deploy.arc.json
|
|
80
|
-
*
|
|
90
|
+
* Load deploy.arc.json + side-car env files (deploy.arc.env for globals,
|
|
91
|
+
* deploy.arc.<env>.env for per-env secrets), expand `${VAR}` references
|
|
92
|
+
* against process.env, and validate shape.
|
|
93
|
+
*
|
|
94
|
+
* Resolution order (last wins):
|
|
95
|
+
* 1. deploy.arc.json `envs.<name>.envVars` (declared in config)
|
|
96
|
+
* 2. deploy.arc.<name>.env (sidecar file, gitignored)
|
|
97
|
+
* 3. existing process.env values (CI/CD secret store)
|
|
98
|
+
*
|
|
99
|
+
* Globals from `deploy.arc.env` populate process.env (without overriding
|
|
100
|
+
* existing values), so terraform/dockerLogin/ansible see them naturally.
|
|
81
101
|
*/
|
|
82
102
|
export function loadDeployConfig(rootDir: string): DeployConfig {
|
|
83
103
|
const path = deployConfigPath(rootDir);
|
|
@@ -91,8 +111,33 @@ export function loadDeployConfig(rootDir: string): DeployConfig {
|
|
|
91
111
|
} catch (e) {
|
|
92
112
|
throw new Error(`Invalid JSON in ${DEPLOY_CONFIG_FILE}: ${(e as Error).message}`);
|
|
93
113
|
}
|
|
114
|
+
|
|
115
|
+
// Read env names from raw JSON (pre-validation) to know which sidecar
|
|
116
|
+
// files to look for. Validation runs next.
|
|
117
|
+
const envNames = isObject(parsed) && isObject(parsed.envs)
|
|
118
|
+
? Object.keys(parsed.envs)
|
|
119
|
+
: [];
|
|
120
|
+
const envFiles = loadDeployEnvFiles(rootDir, envNames);
|
|
121
|
+
|
|
122
|
+
// Globals → process.env (without overriding) so downstream code can read
|
|
123
|
+
// HCLOUD_TOKEN, ARC_REGISTRY_PASSWORD etc. as if they were exported in shell.
|
|
124
|
+
applyDeployGlobals(envFiles.globals);
|
|
125
|
+
|
|
94
126
|
const expanded = expandEnvVars(parsed, process.env);
|
|
95
|
-
|
|
127
|
+
const validated = validateDeployConfig(expanded);
|
|
128
|
+
|
|
129
|
+
// Merge sidecar per-env vars into cfg.envs[name].envVars.
|
|
130
|
+
// Existing keys (declared in deploy.arc.json) win over sidecar — config is
|
|
131
|
+
// the source of truth for variable NAMES, sidecar provides VALUES for
|
|
132
|
+
// anything not pinned otherwise.
|
|
133
|
+
for (const [envName, vars] of Object.entries(envFiles.perEnv)) {
|
|
134
|
+
if (!(envName in validated.envs)) continue;
|
|
135
|
+
const env = validated.envs[envName];
|
|
136
|
+
const merged: Record<string, string> = { ...vars, ...(env.envVars ?? {}) };
|
|
137
|
+
validated.envs[envName] = { ...env, envVars: merged };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return validated;
|
|
96
141
|
}
|
|
97
142
|
|
|
98
143
|
export function saveDeployConfig(rootDir: string, cfg: DeployConfig): void {
|
|
@@ -139,6 +184,20 @@ export function validateDeployConfig(input: unknown): DeployConfig {
|
|
|
139
184
|
const target = requireObject(input, "target");
|
|
140
185
|
const envs = requireObject(input, "envs");
|
|
141
186
|
const caddy = requireObject(input, "caddy");
|
|
187
|
+
const registry = requireObject(input, "registry");
|
|
188
|
+
|
|
189
|
+
const registryDomain = requireString(registry, "registry.domain");
|
|
190
|
+
if (!/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(registryDomain)) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
`deploy.arc.json: registry.domain "${registryDomain}" doesn't look like a domain`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
const passwordEnv = requireString(registry, "registry.passwordEnv");
|
|
196
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(passwordEnv)) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`deploy.arc.json: registry.passwordEnv "${passwordEnv}" must be an UPPER_SNAKE_CASE env var name`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
142
201
|
|
|
143
202
|
const validated: DeployConfig = {
|
|
144
203
|
target: {
|
|
@@ -152,6 +211,11 @@ export function validateDeployConfig(input: unknown): DeployConfig {
|
|
|
152
211
|
caddy: {
|
|
153
212
|
email: requireString(caddy, "caddy.email"),
|
|
154
213
|
},
|
|
214
|
+
registry: {
|
|
215
|
+
domain: registryDomain,
|
|
216
|
+
username: optionalString(registry, "registry.username") ?? "deploy",
|
|
217
|
+
passwordEnv,
|
|
218
|
+
},
|
|
155
219
|
};
|
|
156
220
|
|
|
157
221
|
const envKeys = Object.keys(envs);
|