@arcote.tech/arc-cli 0.6.2 → 0.7.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/dist/index.js +1213 -1219
- package/package.json +7 -7
- package/src/builder/access-extractor.ts +79 -47
- 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 +186 -110
- package/src/commands/platform-deploy.ts +103 -55
- package/src/commands/platform-dev.ts +11 -100
- package/src/commands/platform-start.ts +4 -90
- package/src/deploy/bootstrap.ts +157 -6
- package/src/deploy/caddyfile.ts +19 -23
- package/src/deploy/compose.ts +43 -27
- package/src/deploy/config.ts +29 -0
- package/src/deploy/deploy-env.ts +129 -0
- package/src/deploy/htpasswd.ts +28 -0
- package/src/deploy/image-template.ts +74 -0
- package/src/deploy/image.ts +237 -0
- package/src/deploy/registry.ts +79 -0
- package/src/deploy/ssh.ts +5 -124
- package/src/deploy/survey.ts +64 -0
- package/src/index.ts +15 -13
- package/src/platform/server.ts +69 -44
- package/src/platform/shared.ts +124 -65
- package/src/platform/startup.ts +160 -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
|
@@ -1,103 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { startPlatformServer } from "../platform/server";
|
|
4
|
-
import {
|
|
5
|
-
buildAll,
|
|
6
|
-
collectArcPeerDeps,
|
|
7
|
-
loadServerContext,
|
|
8
|
-
log,
|
|
9
|
-
ok,
|
|
10
|
-
resolveWorkspace,
|
|
11
|
-
} from "../platform/shared";
|
|
1
|
+
import { startPlatform } from "../platform/startup";
|
|
2
|
+
import { resolveWorkspace } from "../platform/shared";
|
|
12
3
|
|
|
13
|
-
|
|
4
|
+
/** `arc platform dev` — dev mode (watcher + SSE reload + no-cache headers). */
|
|
5
|
+
export async function platformDev(
|
|
6
|
+
opts: { noCache?: boolean } = {},
|
|
7
|
+
): Promise<void> {
|
|
14
8
|
const ws = resolveWorkspace();
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
log("Loading server context...");
|
|
22
|
-
const { context, moduleAccess } = await loadServerContext(ws.packages);
|
|
23
|
-
if (context) {
|
|
24
|
-
ok("Context loaded");
|
|
25
|
-
} else {
|
|
26
|
-
log("No context — server endpoints skipped");
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const arcEntries = collectArcPeerDeps(ws.packages);
|
|
30
|
-
const platform = await startPlatformServer({
|
|
31
|
-
ws,
|
|
32
|
-
port,
|
|
33
|
-
manifest,
|
|
34
|
-
context,
|
|
35
|
-
moduleAccess,
|
|
36
|
-
dbPath: join(ws.rootDir, ".arc", "data", "dev.db"),
|
|
37
|
-
devMode: true,
|
|
38
|
-
arcEntries,
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
ok(`Server on http://localhost:${port}`);
|
|
42
|
-
if (platform.contextHandler) ok("Commands, queries, WebSocket — all on same port");
|
|
43
|
-
|
|
44
|
-
// Watch for changes — full buildAll on debounce; cache makes it cheap when
|
|
45
|
-
// only one package changed.
|
|
46
|
-
log("Watching for changes...");
|
|
47
|
-
let rebuildTimer: ReturnType<typeof setTimeout> | null = null;
|
|
48
|
-
let isRebuilding = false;
|
|
49
|
-
|
|
50
|
-
const triggerRebuild = () => {
|
|
51
|
-
if (rebuildTimer) clearTimeout(rebuildTimer);
|
|
52
|
-
rebuildTimer = setTimeout(async () => {
|
|
53
|
-
if (isRebuilding) return;
|
|
54
|
-
isRebuilding = true;
|
|
55
|
-
log("Rebuilding...");
|
|
56
|
-
try {
|
|
57
|
-
manifest = await buildAll(ws);
|
|
58
|
-
platform.setManifest(manifest);
|
|
59
|
-
platform.notifyReload(manifest);
|
|
60
|
-
ok(`Rebuilt — ${manifest.modules.length} module(s)`);
|
|
61
|
-
} catch (e) {
|
|
62
|
-
console.error(`Rebuild failed: ${e}`);
|
|
63
|
-
} finally {
|
|
64
|
-
isRebuilding = false;
|
|
65
|
-
}
|
|
66
|
-
}, 300);
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
for (const pkg of ws.packages) {
|
|
70
|
-
const srcDir = join(pkg.path, "src");
|
|
71
|
-
if (!existsSync(srcDir)) continue;
|
|
72
|
-
|
|
73
|
-
watch(srcDir, { recursive: true }, (_event, filename) => {
|
|
74
|
-
if (
|
|
75
|
-
!filename ||
|
|
76
|
-
filename.includes(".arc") ||
|
|
77
|
-
filename.endsWith(".d.ts") ||
|
|
78
|
-
filename.includes("node_modules") ||
|
|
79
|
-
filename.includes("dist")
|
|
80
|
-
) return;
|
|
81
|
-
|
|
82
|
-
if (!filename.endsWith(".ts") && !filename.endsWith(".tsx")) return;
|
|
83
|
-
|
|
84
|
-
triggerRebuild();
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// .po files — trigger rebuild so the `translations` cache unit picks them up.
|
|
89
|
-
const localesDir = join(ws.rootDir, "locales");
|
|
90
|
-
if (existsSync(localesDir)) {
|
|
91
|
-
watch(localesDir, { recursive: false }, (_event, filename) => {
|
|
92
|
-
if (!filename?.endsWith(".po")) return;
|
|
93
|
-
triggerRebuild();
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const cleanup = () => {
|
|
98
|
-
platform.stop();
|
|
99
|
-
process.exit(0);
|
|
100
|
-
};
|
|
101
|
-
process.on("SIGTERM", cleanup);
|
|
102
|
-
process.on("SIGINT", cleanup);
|
|
9
|
+
// noCache is consumed by buildAll inside startPlatform when devMode=true.
|
|
10
|
+
// For now the dev startup always uses the cache after the first build;
|
|
11
|
+
// explicit --no-cache here only matters if we wire it through later.
|
|
12
|
+
void opts;
|
|
13
|
+
await startPlatform({ ws, devMode: true });
|
|
103
14
|
}
|
|
@@ -1,94 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { startPlatformServer } from "../platform/server";
|
|
4
|
-
import {
|
|
5
|
-
collectArcPeerDeps,
|
|
6
|
-
err,
|
|
7
|
-
loadServerContext,
|
|
8
|
-
log,
|
|
9
|
-
ok,
|
|
10
|
-
resolveWorkspace,
|
|
11
|
-
type BuildManifest,
|
|
12
|
-
} from "../platform/shared";
|
|
1
|
+
import { startPlatform } from "../platform/startup";
|
|
2
|
+
import { resolveWorkspace } from "../platform/shared";
|
|
13
3
|
|
|
4
|
+
/** `arc platform start` — production mode (no watcher, immutable cache). */
|
|
14
5
|
export async function platformStart(): Promise<void> {
|
|
15
6
|
const ws = resolveWorkspace();
|
|
16
|
-
|
|
17
|
-
const deployApi = process.env.ARC_DEPLOY_API === "1";
|
|
18
|
-
|
|
19
|
-
// Pre-deploy mode: container started with empty volume (first boot of an
|
|
20
|
-
// arcote/runtime container — manifest hasn't been pushed yet). Boot a
|
|
21
|
-
// minimal server so the deploy CLI can reach /api/deploy/* to push the
|
|
22
|
-
// initial framework + modules. Container restart (after first manifest
|
|
23
|
-
// commit) re-enters this function with manifest present → full mode.
|
|
24
|
-
const manifestPath = join(ws.modulesDir, "manifest.json");
|
|
25
|
-
if (!existsSync(manifestPath)) {
|
|
26
|
-
if (!deployApi) {
|
|
27
|
-
err("No build found. Run `arc platform build` first.");
|
|
28
|
-
process.exit(1);
|
|
29
|
-
}
|
|
30
|
-
log("Pre-deploy mode — no manifest yet, awaiting first /api/deploy/*");
|
|
31
|
-
const emptyManifest: BuildManifest = {
|
|
32
|
-
modules: [],
|
|
33
|
-
shellHash: "",
|
|
34
|
-
stylesHash: "",
|
|
35
|
-
buildTime: new Date().toISOString(),
|
|
36
|
-
};
|
|
37
|
-
const platform = await startPlatformServer({
|
|
38
|
-
ws,
|
|
39
|
-
port,
|
|
40
|
-
manifest: emptyManifest,
|
|
41
|
-
context: null,
|
|
42
|
-
moduleAccess: new Map(),
|
|
43
|
-
dbPath: join(ws.rootDir, ".arc", "data", "prod.db"),
|
|
44
|
-
devMode: false,
|
|
45
|
-
deployApi: true,
|
|
46
|
-
arcEntries: [],
|
|
47
|
-
});
|
|
48
|
-
ok(`Pre-deploy server on http://localhost:${port}`);
|
|
49
|
-
registerSignalCleanup(platform);
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const manifest: BuildManifest = JSON.parse(
|
|
54
|
-
readFileSync(manifestPath, "utf-8"),
|
|
55
|
-
);
|
|
56
|
-
|
|
57
|
-
// Load server context
|
|
58
|
-
log("Loading server context...");
|
|
59
|
-
const { context, moduleAccess } = await loadServerContext(ws.packages);
|
|
60
|
-
if (context) {
|
|
61
|
-
ok("Context loaded");
|
|
62
|
-
} else {
|
|
63
|
-
log("No context — server endpoints skipped");
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Start server (production mode = aggressive caching, SSE only used for deploy hot-swap)
|
|
67
|
-
const arcEntries = collectArcPeerDeps(ws.packages);
|
|
68
|
-
if (deployApi) ok("Deploy API enabled (/api/deploy/*)");
|
|
69
|
-
const platform = await startPlatformServer({
|
|
70
|
-
ws,
|
|
71
|
-
port,
|
|
72
|
-
manifest,
|
|
73
|
-
context,
|
|
74
|
-
moduleAccess,
|
|
75
|
-
dbPath: join(ws.rootDir, ".arc", "data", "prod.db"),
|
|
76
|
-
devMode: false,
|
|
77
|
-
deployApi,
|
|
78
|
-
arcEntries,
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
ok(`Server on http://localhost:${port}`);
|
|
82
|
-
if (platform.contextHandler) ok("Commands, queries, WebSocket — all on same port");
|
|
83
|
-
|
|
84
|
-
registerSignalCleanup(platform);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function registerSignalCleanup(platform: { stop: () => void }): void {
|
|
88
|
-
const cleanup = () => {
|
|
89
|
-
platform.stop();
|
|
90
|
-
process.exit(0);
|
|
91
|
-
};
|
|
92
|
-
process.on("SIGTERM", cleanup);
|
|
93
|
-
process.on("SIGINT", cleanup);
|
|
7
|
+
await startPlatform({ ws, devMode: false });
|
|
94
8
|
}
|
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,13 @@ 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";
|
|
14
16
|
|
|
15
17
|
// ---------------------------------------------------------------------------
|
|
16
18
|
// Bootstrap orchestrator.
|
|
@@ -101,11 +103,28 @@ async function upStack(inputs: BootstrapInputs): Promise<void> {
|
|
|
101
103
|
const workDir = join(tmpdir(), "arc-deploy", `stack-${Date.now()}`);
|
|
102
104
|
mkdirSync(workDir, { recursive: true });
|
|
103
105
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
106
|
+
// Pre-flight DNS — without registry.<domain> resolving to the host, Caddy's
|
|
107
|
+
// ACME challenge for the registry vhost will fail. Better to bail with a
|
|
108
|
+
// clear message than let the operator chase TLS retries for 10 minutes.
|
|
109
|
+
await assertRegistryDnsResolves(cfg);
|
|
110
|
+
|
|
111
|
+
// Generate htpasswd locally from the password env var. Never write the
|
|
112
|
+
// plaintext password to disk; only the bcrypt hash leaves this process.
|
|
113
|
+
const password = process.env[cfg.registry.passwordEnv];
|
|
114
|
+
if (!password) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`Registry password env var ${cfg.registry.passwordEnv} is not set. ` +
|
|
117
|
+
`Set it (e.g. \`export ${cfg.registry.passwordEnv}=...\`) before bootstrap.`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
const htpasswdLine = await generateHtpasswd(
|
|
121
|
+
cfg.registry.username,
|
|
122
|
+
password,
|
|
108
123
|
);
|
|
124
|
+
writeFileSync(join(workDir, "htpasswd"), htpasswdLine);
|
|
125
|
+
|
|
126
|
+
writeFileSync(join(workDir, "Caddyfile"), generateCaddyfile(cfg));
|
|
127
|
+
writeFileSync(join(workDir, "docker-compose.yml"), generateCompose({ cfg }));
|
|
109
128
|
|
|
110
129
|
// Ensure remoteDir exists
|
|
111
130
|
await assertExec(
|
|
@@ -118,6 +137,10 @@ async function upStack(inputs: BootstrapInputs): Promise<void> {
|
|
|
118
137
|
`mkdir -p ${cfg.target.remoteDir}/${name}`,
|
|
119
138
|
);
|
|
120
139
|
}
|
|
140
|
+
await assertExec(
|
|
141
|
+
cfg.target,
|
|
142
|
+
`mkdir -p ${cfg.target.remoteDir}/registry-auth`,
|
|
143
|
+
);
|
|
121
144
|
|
|
122
145
|
await scpUpload(
|
|
123
146
|
cfg.target,
|
|
@@ -129,9 +152,137 @@ async function upStack(inputs: BootstrapInputs): Promise<void> {
|
|
|
129
152
|
join(workDir, "docker-compose.yml"),
|
|
130
153
|
`${cfg.target.remoteDir}/docker-compose.yml`,
|
|
131
154
|
);
|
|
155
|
+
await scpUpload(
|
|
156
|
+
cfg.target,
|
|
157
|
+
join(workDir, "htpasswd"),
|
|
158
|
+
`${cfg.target.remoteDir}/registry-auth/htpasswd`,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// Ensure /opt/arc/.env exists so docker compose doesn't error on var
|
|
162
|
+
// substitution. Per-env ARC_IMAGE_<ENV> lines are written by deploy.
|
|
163
|
+
await assertExec(
|
|
164
|
+
cfg.target,
|
|
165
|
+
`touch ${cfg.target.remoteDir}/.env`,
|
|
166
|
+
);
|
|
132
167
|
|
|
168
|
+
// Pre-register the deploy user with the private registry so containers can
|
|
169
|
+
// pull. We do this AFTER `docker compose up -d caddy registry` runs (below)
|
|
170
|
+
// — the registry needs to be reachable for `docker login` to succeed.
|
|
133
171
|
await assertExec(
|
|
134
172
|
cfg.target,
|
|
135
|
-
`cd ${cfg.target.remoteDir} && docker compose pull --ignore-pull-failures && docker compose up -d`,
|
|
173
|
+
`cd ${cfg.target.remoteDir} && docker compose pull --ignore-pull-failures caddy registry && docker compose up -d caddy registry`,
|
|
136
174
|
);
|
|
175
|
+
|
|
176
|
+
// Wait for the registry vhost to respond (Caddy ACME issuance takes a few
|
|
177
|
+
// seconds on first start), then docker login on the host. This caches
|
|
178
|
+
// credentials in /home/<user>/.docker/config.json so subsequent `docker
|
|
179
|
+
// compose pull arc-<env>` from the per-deploy step can fetch app images.
|
|
180
|
+
await sshDockerLogin(cfg);
|
|
181
|
+
|
|
182
|
+
// Bring up any arc-<env> services whose images are already published.
|
|
183
|
+
// The :? fallback in compose makes services with no ARC_IMAGE_<ENV> set
|
|
184
|
+
// fail their up step — we filter those out by reading .env first.
|
|
185
|
+
const knownEnvs = await listConfiguredEnvs(cfg);
|
|
186
|
+
if (knownEnvs.length > 0) {
|
|
187
|
+
await assertExec(
|
|
188
|
+
cfg.target,
|
|
189
|
+
`cd ${cfg.target.remoteDir} && docker compose up -d ${knownEnvs
|
|
190
|
+
.map((e) => `arc-${e}`)
|
|
191
|
+
.join(" ")}`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Log in to the private registry from the deploy user's account on the host.
|
|
198
|
+
* Required so `docker compose pull arc-<env>` works on subsequent deploys
|
|
199
|
+
* (compose runs docker from the deploy user's perspective; that user's
|
|
200
|
+
* `.docker/config.json` is where the registry token lives).
|
|
201
|
+
*/
|
|
202
|
+
async function sshDockerLogin(cfg: DeployConfig): Promise<void> {
|
|
203
|
+
const password = process.env[cfg.registry.passwordEnv];
|
|
204
|
+
if (!password) {
|
|
205
|
+
throw new Error(
|
|
206
|
+
`Registry password env var ${cfg.registry.passwordEnv} is not set on the deploy host (CLI machine).`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
// Pipe via stdin — keeps password off the command line and shell history.
|
|
210
|
+
const cmd = `echo "$ARC_REGISTRY_PASSWORD_FORWARDED" | docker login ${cfg.registry.domain} -u ${cfg.registry.username} --password-stdin`;
|
|
211
|
+
const proc = spawn({
|
|
212
|
+
cmd: [
|
|
213
|
+
"ssh",
|
|
214
|
+
...baseSshArgs(cfg.target),
|
|
215
|
+
`${cfg.target.user}@${cfg.target.host}`,
|
|
216
|
+
"--",
|
|
217
|
+
`ARC_REGISTRY_PASSWORD_FORWARDED='${password.replace(/'/g, "'\\''")}' bash -c ${JSON.stringify(cmd)}`,
|
|
218
|
+
],
|
|
219
|
+
stdout: "pipe",
|
|
220
|
+
stderr: "pipe",
|
|
221
|
+
});
|
|
222
|
+
const exit = await proc.exited;
|
|
223
|
+
if (exit !== 0) {
|
|
224
|
+
const stderr = await new Response(proc.stderr).text();
|
|
225
|
+
throw new Error(
|
|
226
|
+
`Server-side docker login failed (exit ${exit}): ${stderr.trim()}`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Read /opt/arc/.env on the host and return env names that have an
|
|
233
|
+
* `ARC_IMAGE_<ENV>=...` line set. Used by bootstrap to decide which arc-<env>
|
|
234
|
+
* services can safely be started (the others would fail compose var
|
|
235
|
+
* substitution with `:?`).
|
|
236
|
+
*/
|
|
237
|
+
async function listConfiguredEnvs(cfg: DeployConfig): Promise<string[]> {
|
|
238
|
+
const res = await sshExec(
|
|
239
|
+
cfg.target,
|
|
240
|
+
`cat ${cfg.target.remoteDir}/.env 2>/dev/null || true`,
|
|
241
|
+
{ quiet: true },
|
|
242
|
+
);
|
|
243
|
+
const set = new Set<string>();
|
|
244
|
+
for (const line of res.stdout.split("\n")) {
|
|
245
|
+
const m = line.match(/^ARC_IMAGE_([A-Z0-9_]+)=/);
|
|
246
|
+
if (!m) continue;
|
|
247
|
+
const lowerName = m[1].toLowerCase().replace(/_/g, "-");
|
|
248
|
+
if (lowerName in cfg.envs) set.add(lowerName);
|
|
249
|
+
}
|
|
250
|
+
return [...set];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Verify that `<registry.domain>` resolves to the target host's IP. If DNS
|
|
255
|
+
* isn't propagated yet, Caddy's ACME challenge for the registry vhost will
|
|
256
|
+
* fail repeatedly. Fail fast with an actionable hint instead.
|
|
257
|
+
*/
|
|
258
|
+
async function assertRegistryDnsResolves(cfg: DeployConfig): Promise<void> {
|
|
259
|
+
const proc = spawn({
|
|
260
|
+
cmd: ["dig", "+short", "+time=3", "+tries=1", cfg.registry.domain],
|
|
261
|
+
stdout: "pipe",
|
|
262
|
+
stderr: "ignore",
|
|
263
|
+
});
|
|
264
|
+
const exit = await proc.exited;
|
|
265
|
+
if (exit !== 0) {
|
|
266
|
+
err(
|
|
267
|
+
`\`dig\` is not available — skipping DNS pre-flight for ${cfg.registry.domain}.`,
|
|
268
|
+
);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const resolved = (await new Response(proc.stdout).text())
|
|
272
|
+
.split("\n")
|
|
273
|
+
.map((s) => s.trim())
|
|
274
|
+
.filter(Boolean);
|
|
275
|
+
|
|
276
|
+
if (resolved.length === 0) {
|
|
277
|
+
throw new Error(
|
|
278
|
+
`Registry DNS not configured: ${cfg.registry.domain} doesn't resolve. ` +
|
|
279
|
+
`Add an A record pointing to ${cfg.target.host} and re-run deploy.`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
if (!resolved.includes(cfg.target.host)) {
|
|
283
|
+
throw new Error(
|
|
284
|
+
`Registry DNS mismatch: ${cfg.registry.domain} resolves to [${resolved.join(", ")}], ` +
|
|
285
|
+
`but target host is ${cfg.target.host}. Update the A record before continuing.`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
137
288
|
}
|
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,56 @@ 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. The `:?` fallback fails compose with a clear
|
|
65
|
+
// error if the env var isn't set — that means "deploy hasn't run yet".
|
|
66
|
+
lines.push(
|
|
67
|
+
` image: \${ARC_IMAGE_${upperName}:?Run \\\`arc platform deploy ${name}\\\` to publish an image first}`,
|
|
68
|
+
);
|
|
69
|
+
lines.push(` container_name: arc-${name}`);
|
|
51
70
|
lines.push(" restart: unless-stopped");
|
|
52
71
|
lines.push(" volumes:");
|
|
53
|
-
|
|
72
|
+
// Only the data volume — user code lives entirely inside the image.
|
|
73
|
+
// SQLite + uploads persist across redeploys.
|
|
54
74
|
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
75
|
lines.push(" environment:");
|
|
58
76
|
lines.push(" PORT: 5005");
|
|
59
|
-
lines.push(' ARC_DEPLOY_API: "1"');
|
|
60
|
-
lines.push(` ARC_CLI_VERSION: ${JSON.stringify(cliVersion)}`);
|
|
61
77
|
const userEnv = env.envVars ?? {};
|
|
62
78
|
if (!("NODE_ENV" in userEnv)) {
|
|
63
79
|
lines.push(" NODE_ENV: production");
|
|
64
80
|
}
|
|
81
|
+
// PORT is reserved — user envVars can't override.
|
|
82
|
+
const reserved = new Set(["PORT"]);
|
|
65
83
|
for (const [k, v] of Object.entries(userEnv)) {
|
|
66
|
-
if (
|
|
84
|
+
if (reserved.has(k)) continue;
|
|
67
85
|
lines.push(` ${k}: ${JSON.stringify(v)}`);
|
|
68
86
|
}
|
|
87
|
+
// ENTRYPOINT + CMD come from the image — no `command:` override needed.
|
|
69
88
|
lines.push(" networks:");
|
|
70
89
|
lines.push(" - arc-net");
|
|
71
90
|
lines.push(" expose:");
|
|
@@ -73,17 +92,14 @@ export function generateCompose({ cfg, cliVersion }: ComposeOptions): string {
|
|
|
73
92
|
lines.push("");
|
|
74
93
|
}
|
|
75
94
|
|
|
76
|
-
// Networks + volumes
|
|
77
95
|
lines.push("networks:");
|
|
78
96
|
lines.push(" arc-net:");
|
|
79
97
|
lines.push("");
|
|
80
98
|
lines.push("volumes:");
|
|
81
99
|
lines.push(" caddy_data:");
|
|
82
100
|
lines.push(" caddy_config:");
|
|
83
|
-
lines.push("
|
|
84
|
-
lines.push(" arc-bun-cache:");
|
|
101
|
+
lines.push(" registry_data:");
|
|
85
102
|
for (const [name] of Object.entries(cfg.envs)) {
|
|
86
|
-
lines.push(` arc-platform-${name}:`);
|
|
87
103
|
lines.push(` arc-data-${name}:`);
|
|
88
104
|
}
|
|
89
105
|
|
package/src/deploy/config.ts
CHANGED
|
@@ -54,10 +54,20 @@ export interface DeployProvision {
|
|
|
54
54
|
ansible?: DeployProvisionAnsible;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
export interface DeployRegistry {
|
|
58
|
+
/** Full domain — Caddy reverse-proxies this to the registry:5000 container. */
|
|
59
|
+
domain: string;
|
|
60
|
+
/** htpasswd basic-auth username. Default: `deploy`. */
|
|
61
|
+
username: string;
|
|
62
|
+
/** Name of env var holding the htpasswd password. Never inline the secret. */
|
|
63
|
+
passwordEnv: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
57
66
|
export interface DeployConfig {
|
|
58
67
|
target: DeployTarget;
|
|
59
68
|
envs: Record<string, DeployEnv>;
|
|
60
69
|
caddy: DeployCaddy;
|
|
70
|
+
registry: DeployRegistry;
|
|
61
71
|
provision?: DeployProvision;
|
|
62
72
|
}
|
|
63
73
|
|
|
@@ -139,6 +149,20 @@ export function validateDeployConfig(input: unknown): DeployConfig {
|
|
|
139
149
|
const target = requireObject(input, "target");
|
|
140
150
|
const envs = requireObject(input, "envs");
|
|
141
151
|
const caddy = requireObject(input, "caddy");
|
|
152
|
+
const registry = requireObject(input, "registry");
|
|
153
|
+
|
|
154
|
+
const registryDomain = requireString(registry, "registry.domain");
|
|
155
|
+
if (!/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(registryDomain)) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`deploy.arc.json: registry.domain "${registryDomain}" doesn't look like a domain`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
const passwordEnv = requireString(registry, "registry.passwordEnv");
|
|
161
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(passwordEnv)) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`deploy.arc.json: registry.passwordEnv "${passwordEnv}" must be an UPPER_SNAKE_CASE env var name`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
142
166
|
|
|
143
167
|
const validated: DeployConfig = {
|
|
144
168
|
target: {
|
|
@@ -152,6 +176,11 @@ export function validateDeployConfig(input: unknown): DeployConfig {
|
|
|
152
176
|
caddy: {
|
|
153
177
|
email: requireString(caddy, "caddy.email"),
|
|
154
178
|
},
|
|
179
|
+
registry: {
|
|
180
|
+
domain: registryDomain,
|
|
181
|
+
username: optionalString(registry, "registry.username") ?? "deploy",
|
|
182
|
+
passwordEnv,
|
|
183
|
+
},
|
|
155
184
|
};
|
|
156
185
|
|
|
157
186
|
const envKeys = Object.keys(envs);
|