@arcote.tech/arc-cli 0.7.4 → 0.7.6
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 +30719 -4826
- package/package.json +9 -7
- package/src/commands/platform-deploy.ts +23 -0
- package/src/deploy/bootstrap.ts +106 -0
- package/src/deploy/caddyfile.ts +45 -2
- package/src/deploy/compose.ts +220 -17
- package/src/deploy/config.ts +87 -1
- package/src/deploy/env-file.ts +41 -1
- package/src/deploy/observability-configs.ts +293 -0
- package/src/platform/server.ts +78 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcote.tech/arc-cli",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.6",
|
|
4
4
|
"description": "CLI tool for Arc framework",
|
|
5
5
|
"module": "index.ts",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -12,12 +12,14 @@
|
|
|
12
12
|
"build": "bun build --target=bun ./src/index.ts --outdir=dist --external @arcote.tech/arc --external @arcote.tech/arc-ds --external @arcote.tech/arc-react --external @arcote.tech/platform && chmod +x dist/index.js"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@arcote.tech/arc": "^0.7.
|
|
16
|
-
"@arcote.tech/arc-ds": "^0.7.
|
|
17
|
-
"@arcote.tech/arc-react": "^0.7.
|
|
18
|
-
"@arcote.tech/arc-host": "^0.7.
|
|
19
|
-
"@arcote.tech/arc-adapter-db-sqlite": "^0.7.
|
|
20
|
-
"@arcote.tech/
|
|
15
|
+
"@arcote.tech/arc": "^0.7.6",
|
|
16
|
+
"@arcote.tech/arc-ds": "^0.7.6",
|
|
17
|
+
"@arcote.tech/arc-react": "^0.7.6",
|
|
18
|
+
"@arcote.tech/arc-host": "^0.7.6",
|
|
19
|
+
"@arcote.tech/arc-adapter-db-sqlite": "^0.7.6",
|
|
20
|
+
"@arcote.tech/arc-adapter-db-postgres": "^0.7.6",
|
|
21
|
+
"@arcote.tech/arc-otel": "^0.7.6",
|
|
22
|
+
"@arcote.tech/platform": "^0.7.6",
|
|
21
23
|
"@clack/prompts": "^0.9.0",
|
|
22
24
|
"commander": "^11.1.0",
|
|
23
25
|
"chokidar": "^3.5.3",
|
|
@@ -2,12 +2,14 @@ import { existsSync, readFileSync } from "fs";
|
|
|
2
2
|
import { dirname, join } from "path";
|
|
3
3
|
import { fileURLToPath } from "url";
|
|
4
4
|
import { bootstrap } from "../deploy/bootstrap";
|
|
5
|
+
import { postgresEnvs } from "../deploy/compose";
|
|
5
6
|
import {
|
|
6
7
|
deployConfigExists,
|
|
7
8
|
loadDeployConfig,
|
|
8
9
|
saveDeployConfig,
|
|
9
10
|
} from "../deploy/config";
|
|
10
11
|
import { updateEnvDeployment } from "../deploy/deploy-env";
|
|
12
|
+
import { ensurePersistedSecret } from "../deploy/env-file";
|
|
11
13
|
import { buildImage, sanitizeImageName } from "../deploy/image";
|
|
12
14
|
import { detectRemoteState } from "../deploy/remote-state";
|
|
13
15
|
import { dockerLogin, dockerPush } from "../deploy/registry";
|
|
@@ -74,6 +76,27 @@ export async function platformDeploy(
|
|
|
74
76
|
}
|
|
75
77
|
cfg = loadDeployConfig(ws.rootDir);
|
|
76
78
|
|
|
79
|
+
// For every env opted into postgres, make sure the sidecar password is
|
|
80
|
+
// materialised in `deploy.arc.<env>.env` (gitignored) and in process.env.
|
|
81
|
+
// Compose generation interpolates `${ARC_PG_PASSWORD_<UPPER>}` later; this
|
|
82
|
+
// step has to happen before bootstrap renders + uploads compose.yml.
|
|
83
|
+
const pgEnvs = postgresEnvs(cfg);
|
|
84
|
+
for (const pg of pgEnvs) {
|
|
85
|
+
ensurePersistedSecret(ws.rootDir, pg.name, pg.passwordKey, () =>
|
|
86
|
+
crypto.randomUUID().replace(/-/g, ""),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Observability stack — one Grafana password per deployment (stack-wide,
|
|
91
|
+
// not per-env). Persisted in `deploy.arc.env` (globals) so subsequent
|
|
92
|
+
// deploys reuse the same login.
|
|
93
|
+
if (cfg.observability?.enabled) {
|
|
94
|
+
const key = cfg.observability.adminPasswordEnv ?? "ARC_OBSERVABILITY_PASSWORD";
|
|
95
|
+
ensurePersistedSecret(ws.rootDir, "globals", key, () =>
|
|
96
|
+
crypto.randomUUID().replace(/-/g, ""),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
77
100
|
// Filter envs if an arg was provided
|
|
78
101
|
const targetEnvs = envArg
|
|
79
102
|
? envArg in cfg.envs
|
package/src/deploy/bootstrap.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { generateCaddyfile } from "./caddyfile";
|
|
|
7
7
|
import { generateCompose } from "./compose";
|
|
8
8
|
import type { DeployConfig } from "./config";
|
|
9
9
|
import { generateHtpasswd } from "./htpasswd";
|
|
10
|
+
import { generateObservabilityConfigs } from "./observability-configs";
|
|
10
11
|
import { runTerraform } from "./terraform";
|
|
11
12
|
import { saveDeployConfig } from "./config";
|
|
12
13
|
import { ok, log, err } from "../platform/shared";
|
|
@@ -160,6 +161,31 @@ export async function bootstrap(inputs: BootstrapInputs): Promise<void> {
|
|
|
160
161
|
* Used by bootstrap to detect legacy v0.5 stacks that have no registry
|
|
161
162
|
* container and need a fresh stack write + restart.
|
|
162
163
|
*/
|
|
164
|
+
/**
|
|
165
|
+
* Atomically set `KEY=value` in a remote `.env`-style file. Replaces an
|
|
166
|
+
* existing line with the same key or appends one. Same awk pattern used by
|
|
167
|
+
* deploy-env.ts to update ARC_IMAGE_<ENV>; values are shell-escaped so
|
|
168
|
+
* passwords containing `$`, `"`, etc. survive intact.
|
|
169
|
+
*/
|
|
170
|
+
async function writeEnvLine(
|
|
171
|
+
target: DeployTarget,
|
|
172
|
+
envPath: string,
|
|
173
|
+
key: string,
|
|
174
|
+
value: string,
|
|
175
|
+
): Promise<void> {
|
|
176
|
+
const escapedValue = value.replace(/"/g, '\\"').replace(/\$/g, "\\$");
|
|
177
|
+
const script = [
|
|
178
|
+
`touch ${envPath} && `,
|
|
179
|
+
`awk -v line="${key}=${escapedValue}" -v key="${key}=" '`,
|
|
180
|
+
` BEGIN { replaced=0 } `,
|
|
181
|
+
` $0 ~ "^"key { print line; replaced=1; next } `,
|
|
182
|
+
` { print } `,
|
|
183
|
+
` END { if (!replaced) print line } `,
|
|
184
|
+
`' ${envPath} > ${envPath}.tmp && mv ${envPath}.tmp ${envPath}`,
|
|
185
|
+
].join("");
|
|
186
|
+
await assertExec(target, script);
|
|
187
|
+
}
|
|
188
|
+
|
|
163
189
|
async function isRegistryRunning(cfg: DeployConfig): Promise<boolean> {
|
|
164
190
|
const res = await sshExec(
|
|
165
191
|
cfg.target,
|
|
@@ -200,6 +226,30 @@ async function upStack(inputs: BootstrapInputs): Promise<void> {
|
|
|
200
226
|
writeFileSync(join(workDir, "Caddyfile"), generateCaddyfile(cfg));
|
|
201
227
|
writeFileSync(join(workDir, "docker-compose.yml"), generateCompose({ cfg }));
|
|
202
228
|
|
|
229
|
+
// Observability config files + Caddy basic-auth htpasswd for Grafana.
|
|
230
|
+
// Bundled together so a deploy without `observability.enabled` skips it
|
|
231
|
+
// all (no orphan files left on the remote).
|
|
232
|
+
let observabilityFiles: Record<string, string> | null = null;
|
|
233
|
+
let observabilityHtpasswd: string | null = null;
|
|
234
|
+
if (cfg.observability?.enabled) {
|
|
235
|
+
observabilityFiles = generateObservabilityConfigs(cfg);
|
|
236
|
+
const adminPasswordEnv =
|
|
237
|
+
cfg.observability.adminPasswordEnv ?? "ARC_OBSERVABILITY_PASSWORD";
|
|
238
|
+
const adminPassword = process.env[adminPasswordEnv];
|
|
239
|
+
if (!adminPassword) {
|
|
240
|
+
throw new Error(
|
|
241
|
+
`bootstrap: ${adminPasswordEnv} not set — observability needs a Grafana admin password.`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
// Caddy basic_auth import file uses `username bcrypt_hash` syntax.
|
|
245
|
+
observabilityHtpasswd = await generateHtpasswd("admin", adminPassword);
|
|
246
|
+
mkdirSync(join(workDir, "observability"), { recursive: true });
|
|
247
|
+
for (const [relPath, contents] of Object.entries(observabilityFiles)) {
|
|
248
|
+
writeFileSync(join(workDir, relPath), contents);
|
|
249
|
+
}
|
|
250
|
+
writeFileSync(join(workDir, "observability-htpasswd"), observabilityHtpasswd);
|
|
251
|
+
}
|
|
252
|
+
|
|
203
253
|
// Ensure remoteDir exists
|
|
204
254
|
await assertExec(
|
|
205
255
|
cfg.target,
|
|
@@ -232,6 +282,30 @@ async function upStack(inputs: BootstrapInputs): Promise<void> {
|
|
|
232
282
|
`${cfg.target.remoteDir}/registry-auth/htpasswd`,
|
|
233
283
|
);
|
|
234
284
|
|
|
285
|
+
// Upload observability assets when enabled. Caddyfile imports
|
|
286
|
+
// `observability-htpasswd` from /etc/caddy (volume-mounted), so it has
|
|
287
|
+
// to land in the same dir as Caddyfile on the host. The collector /
|
|
288
|
+
// tempo / loki / prometheus / grafana configs go into a sibling
|
|
289
|
+
// `observability/` directory referenced by compose bind-mounts.
|
|
290
|
+
if (observabilityFiles && observabilityHtpasswd) {
|
|
291
|
+
await assertExec(
|
|
292
|
+
cfg.target,
|
|
293
|
+
`mkdir -p ${cfg.target.remoteDir}/observability`,
|
|
294
|
+
);
|
|
295
|
+
for (const relPath of Object.keys(observabilityFiles)) {
|
|
296
|
+
await scpUpload(
|
|
297
|
+
cfg.target,
|
|
298
|
+
join(workDir, relPath),
|
|
299
|
+
`${cfg.target.remoteDir}/${relPath}`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
await scpUpload(
|
|
303
|
+
cfg.target,
|
|
304
|
+
join(workDir, "observability-htpasswd"),
|
|
305
|
+
`${cfg.target.remoteDir}/observability-htpasswd`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
235
309
|
// Ensure /opt/arc/.env exists so docker compose doesn't error on var
|
|
236
310
|
// substitution. Per-env ARC_IMAGE_<ENV> lines are written by deploy.
|
|
237
311
|
await assertExec(
|
|
@@ -239,6 +313,38 @@ async function upStack(inputs: BootstrapInputs): Promise<void> {
|
|
|
239
313
|
`touch ${cfg.target.remoteDir}/.env`,
|
|
240
314
|
);
|
|
241
315
|
|
|
316
|
+
// Propagate the Grafana admin password to /opt/arc/.env when observability
|
|
317
|
+
// is enabled. Compose interpolates `${ARC_OBSERVABILITY_PASSWORD:?}` into
|
|
318
|
+
// the grafana service's GF_SECURITY_ADMIN_PASSWORD env.
|
|
319
|
+
if (cfg.observability?.enabled) {
|
|
320
|
+
const key =
|
|
321
|
+
cfg.observability.adminPasswordEnv ?? "ARC_OBSERVABILITY_PASSWORD";
|
|
322
|
+
const value = process.env[key];
|
|
323
|
+
if (!value) {
|
|
324
|
+
throw new Error(
|
|
325
|
+
`bootstrap: ${key} not set — observability needs a Grafana admin password.`,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
await writeEnvLine(cfg.target, `${cfg.target.remoteDir}/.env`, key, value);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Propagate postgres sidecar passwords to /opt/arc/.env so compose can
|
|
332
|
+
// interpolate `${ARC_PG_PASSWORD_<UPPER>}` for both the pg container and
|
|
333
|
+
// arc-<env>'s DATABASE_URL. Local source is `deploy.arc.<env>.env` /
|
|
334
|
+
// process.env (populated by `ensurePersistedSecret` in platform-deploy).
|
|
335
|
+
// Idempotent: re-runs with the same value are no-ops.
|
|
336
|
+
for (const [name, env] of Object.entries(cfg.envs)) {
|
|
337
|
+
if (env.db?.type !== "postgres") continue;
|
|
338
|
+
const key = `ARC_PG_PASSWORD_${name.toUpperCase().replace(/-/g, "_")}`;
|
|
339
|
+
const value = process.env[key];
|
|
340
|
+
if (!value) {
|
|
341
|
+
throw new Error(
|
|
342
|
+
`bootstrap: ${key} not set — postgres env "${name}" requires a password`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
await writeEnvLine(cfg.target, `${cfg.target.remoteDir}/.env`, key, value);
|
|
346
|
+
}
|
|
347
|
+
|
|
242
348
|
// Pre-register the deploy user with the private registry so containers can
|
|
243
349
|
// pull. We do this AFTER `docker compose up -d caddy registry` runs (below)
|
|
244
350
|
// — the registry needs to be reachable for `docker login` to succeed.
|
package/src/deploy/caddyfile.ts
CHANGED
|
@@ -30,14 +30,48 @@ export function generateCaddyfile(cfg: DeployConfig): string {
|
|
|
30
30
|
lines.push("}");
|
|
31
31
|
lines.push("");
|
|
32
32
|
|
|
33
|
-
// Public blocks — one per env
|
|
33
|
+
// Public blocks — one per env. When observability is on, add a `/otel/*`
|
|
34
|
+
// path that forwards browser-side OTLP/HTTP to the collector. Strips the
|
|
35
|
+
// /otel prefix so the collector sees the same /v1/{traces,logs,metrics}
|
|
36
|
+
// paths it would receive from same-network senders.
|
|
34
37
|
for (const [name, env] of Object.entries(cfg.envs)) {
|
|
35
38
|
lines.push(`${env.domain} {${tlsDirective}`);
|
|
36
|
-
|
|
39
|
+
if (cfg.observability?.enabled) {
|
|
40
|
+
lines.push(" handle_path /otel/* {");
|
|
41
|
+
lines.push(" reverse_proxy otel-collector:4318");
|
|
42
|
+
lines.push(" }");
|
|
43
|
+
lines.push(" handle {");
|
|
44
|
+
lines.push(` reverse_proxy arc-${name}:5005`);
|
|
45
|
+
lines.push(" }");
|
|
46
|
+
} else {
|
|
47
|
+
lines.push(` reverse_proxy arc-${name}:5005`);
|
|
48
|
+
}
|
|
37
49
|
lines.push("}");
|
|
38
50
|
lines.push("");
|
|
39
51
|
}
|
|
40
52
|
|
|
53
|
+
// Observability subdomain — Grafana UI behind Caddy basic-auth. Reuses
|
|
54
|
+
// the apex of the first env's domain (e.g. observability.app.example.com
|
|
55
|
+
// when the primary env is app.example.com). Caddy issues a separate
|
|
56
|
+
// ACME certificate for this hostname.
|
|
57
|
+
if (cfg.observability?.enabled) {
|
|
58
|
+
const firstEnv = Object.values(cfg.envs)[0];
|
|
59
|
+
if (firstEnv) {
|
|
60
|
+
const subdomain = cfg.observability.subdomain ?? "observability";
|
|
61
|
+
const apex = apexOf(firstEnv.domain);
|
|
62
|
+
const observabilityDomain = `${subdomain}.${apex}`;
|
|
63
|
+
lines.push(`${observabilityDomain} {${tlsDirective}`);
|
|
64
|
+
// Basic-auth credentials live in the same htpasswd file used for the
|
|
65
|
+
// registry — bootstrap appends an "admin" line with bcrypted password.
|
|
66
|
+
lines.push(" basic_auth {");
|
|
67
|
+
lines.push(" import /etc/caddy/observability-htpasswd");
|
|
68
|
+
lines.push(" }");
|
|
69
|
+
lines.push(" reverse_proxy grafana:3000");
|
|
70
|
+
lines.push("}");
|
|
71
|
+
lines.push("");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
41
75
|
// Private Docker Registry — Caddy proxies HTTPS termination + Let's Encrypt;
|
|
42
76
|
// the registry:2 container handles its own htpasswd basic auth upstream.
|
|
43
77
|
// 5 GiB request body cap fits real app images comfortably (default 100MB
|
|
@@ -53,3 +87,12 @@ export function generateCaddyfile(cfg: DeployConfig): string {
|
|
|
53
87
|
|
|
54
88
|
return lines.join("\n") + "\n";
|
|
55
89
|
}
|
|
90
|
+
|
|
91
|
+
/** Apex of a (possibly-subdomain) host. `app.example.com` → `example.com`,
|
|
92
|
+
* `example.com` → `example.com`, `example.co.uk` → `co.uk` (approximate —
|
|
93
|
+
* good enough for the observability subdomain). */
|
|
94
|
+
function apexOf(host: string): string {
|
|
95
|
+
const parts = host.split(".");
|
|
96
|
+
if (parts.length <= 2) return host;
|
|
97
|
+
return parts.slice(-2).join(".");
|
|
98
|
+
}
|
package/src/deploy/compose.ts
CHANGED
|
@@ -3,13 +3,18 @@ import type { DeployConfig } from "./config";
|
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
4
|
// docker-compose.yml generator
|
|
5
5
|
//
|
|
6
|
-
// Services:
|
|
7
|
-
// - caddy
|
|
8
|
-
// -
|
|
6
|
+
// Services emitted, in order:
|
|
7
|
+
// - caddy (public 80/443, loopback 127.0.0.1:2019 for deploy tunnel)
|
|
8
|
+
// - registry (private image store on arc-net, fronted by Caddy auth)
|
|
9
|
+
// - arc-<env> (one per entry in deploy.arc.json envs)
|
|
10
|
+
// - arc-db-<env> (one per env that opts into `db: { type: "postgres" }`)
|
|
9
11
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
12
|
+
// SQLite envs persist `/app/.arc/data` in a named volume per env. Postgres
|
|
13
|
+
// envs run a `postgres:16-alpine` sidecar on the docker network — the arc
|
|
14
|
+
// container connects via `DATABASE_URL`, no port is exposed to the host.
|
|
15
|
+
//
|
|
16
|
+
// No custom images: vanilla `caddy:2-alpine`, `registry:2`, `postgres:*`,
|
|
17
|
+
// and `oven/bun:1-alpine` from Docker Hub.
|
|
13
18
|
// ---------------------------------------------------------------------------
|
|
14
19
|
|
|
15
20
|
export interface ComposeOptions {
|
|
@@ -29,6 +34,14 @@ export function generateCompose({ cfg }: ComposeOptions): string {
|
|
|
29
34
|
lines.push(' - "443:443"');
|
|
30
35
|
lines.push(" volumes:");
|
|
31
36
|
lines.push(" - ./Caddyfile:/etc/caddy/Caddyfile:ro");
|
|
37
|
+
if (cfg.observability?.enabled) {
|
|
38
|
+
// Bind-mounted in even when the file is absent (Caddy reads it lazily
|
|
39
|
+
// on observability vhost requests). Always-defined avoids compose
|
|
40
|
+
// bouncing the container when observability is later disabled.
|
|
41
|
+
lines.push(
|
|
42
|
+
" - ./observability-htpasswd:/etc/caddy/observability-htpasswd:ro",
|
|
43
|
+
);
|
|
44
|
+
}
|
|
32
45
|
lines.push(" - caddy_data:/data");
|
|
33
46
|
lines.push(" - caddy_config:/config");
|
|
34
47
|
lines.push(" networks:");
|
|
@@ -48,8 +61,6 @@ export function generateCompose({ cfg }: ComposeOptions): string {
|
|
|
48
61
|
lines.push(" REGISTRY_AUTH: htpasswd");
|
|
49
62
|
lines.push(' REGISTRY_AUTH_HTPASSWD_REALM: "Arc Registry"');
|
|
50
63
|
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
64
|
lines.push(' REGISTRY_HTTP_HOST: "https://' + cfg.registry.domain + '"');
|
|
54
65
|
lines.push(" networks:");
|
|
55
66
|
lines.push(" - arc-net");
|
|
@@ -58,7 +69,8 @@ export function generateCompose({ cfg }: ComposeOptions): string {
|
|
|
58
69
|
lines.push("");
|
|
59
70
|
|
|
60
71
|
for (const [name, env] of Object.entries(cfg.envs)) {
|
|
61
|
-
const upperName = name
|
|
72
|
+
const upperName = upperEnvName(name);
|
|
73
|
+
const usePostgres = env.db?.type === "postgres";
|
|
62
74
|
lines.push(` arc-${name}:`);
|
|
63
75
|
// Image ref comes from /opt/arc/.env, written per-deploy with the content
|
|
64
76
|
// hash of the latest build. Default to a placeholder so `docker compose
|
|
@@ -69,18 +81,52 @@ export function generateCompose({ cfg }: ComposeOptions): string {
|
|
|
69
81
|
);
|
|
70
82
|
lines.push(` container_name: arc-${name}`);
|
|
71
83
|
lines.push(" restart: unless-stopped");
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
84
|
+
if (usePostgres) {
|
|
85
|
+
lines.push(" depends_on:");
|
|
86
|
+
lines.push(` arc-db-${name}:`);
|
|
87
|
+
lines.push(" condition: service_healthy");
|
|
88
|
+
}
|
|
89
|
+
// SQLite envs need a writable volume for `/app/.arc/data`. Postgres
|
|
90
|
+
// envs keep their state in the sidecar volume; no host-side bind needed.
|
|
91
|
+
if (!usePostgres) {
|
|
92
|
+
lines.push(" volumes:");
|
|
93
|
+
lines.push(` - arc-data-${name}:/app/.arc/data`);
|
|
94
|
+
}
|
|
76
95
|
lines.push(" environment:");
|
|
77
96
|
lines.push(" PORT: 5005");
|
|
78
97
|
const userEnv = env.envVars ?? {};
|
|
79
98
|
if (!("NODE_ENV" in userEnv)) {
|
|
80
99
|
lines.push(" NODE_ENV: production");
|
|
81
100
|
}
|
|
82
|
-
|
|
83
|
-
|
|
101
|
+
if (usePostgres) {
|
|
102
|
+
// Connection string follows the docker-internal DNS name + the
|
|
103
|
+
// password sourced from `deploy.arc.<env>.env`. `arc platform deploy`
|
|
104
|
+
// ensures `ARC_PG_PASSWORD_<UPPER>` exists in that file before
|
|
105
|
+
// assembling compose, so the `${VAR:?}` interpolation never fails.
|
|
106
|
+
lines.push(
|
|
107
|
+
` DATABASE_URL: "postgresql://arc:\${ARC_PG_PASSWORD_${upperName}:?missing ARC_PG_PASSWORD_${upperName}}@arc-db-${name}:5432/arc"`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (cfg.observability?.enabled) {
|
|
111
|
+
// OTel SDK reads OTEL_EXPORTER_OTLP_ENDPOINT and stripts /v1/<signal>
|
|
112
|
+
// onto it. service.name + deployment.environment land as resource
|
|
113
|
+
// attributes, which Grafana uses for service-level filtering.
|
|
114
|
+
lines.push(" ARC_OTEL_ENABLED: \"true\"");
|
|
115
|
+
lines.push(" OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318");
|
|
116
|
+
lines.push(` OTEL_SERVICE_NAME: arc-${name}`);
|
|
117
|
+
lines.push(
|
|
118
|
+
` OTEL_RESOURCE_ATTRIBUTES: "service.namespace=arc,deployment.environment=${name}"`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
// PORT + DATABASE_URL + OTEL_* are reserved — user envVars can't override them.
|
|
122
|
+
const reserved = new Set([
|
|
123
|
+
"PORT",
|
|
124
|
+
"DATABASE_URL",
|
|
125
|
+
"ARC_OTEL_ENABLED",
|
|
126
|
+
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
|
127
|
+
"OTEL_SERVICE_NAME",
|
|
128
|
+
"OTEL_RESOURCE_ATTRIBUTES",
|
|
129
|
+
]);
|
|
84
130
|
for (const [k, v] of Object.entries(userEnv)) {
|
|
85
131
|
if (reserved.has(k)) continue;
|
|
86
132
|
lines.push(` ${k}: ${JSON.stringify(v)}`);
|
|
@@ -93,6 +139,137 @@ export function generateCompose({ cfg }: ComposeOptions): string {
|
|
|
93
139
|
lines.push("");
|
|
94
140
|
}
|
|
95
141
|
|
|
142
|
+
// Postgres sidecars (after the arc services so the YAML reads top-down:
|
|
143
|
+
// app -> its database). One service per opted-in env.
|
|
144
|
+
for (const [name, env] of Object.entries(cfg.envs)) {
|
|
145
|
+
if (env.db?.type !== "postgres") continue;
|
|
146
|
+
const upperName = upperEnvName(name);
|
|
147
|
+
const image = env.db.image ?? "postgres:16-alpine";
|
|
148
|
+
lines.push(` arc-db-${name}:`);
|
|
149
|
+
lines.push(` image: ${image}`);
|
|
150
|
+
lines.push(` container_name: arc-db-${name}`);
|
|
151
|
+
lines.push(" restart: unless-stopped");
|
|
152
|
+
lines.push(" environment:");
|
|
153
|
+
lines.push(" POSTGRES_USER: arc");
|
|
154
|
+
lines.push(" POSTGRES_DB: arc");
|
|
155
|
+
lines.push(
|
|
156
|
+
` POSTGRES_PASSWORD: \${ARC_PG_PASSWORD_${upperName}:?missing ARC_PG_PASSWORD_${upperName}}`,
|
|
157
|
+
);
|
|
158
|
+
lines.push(" volumes:");
|
|
159
|
+
lines.push(` - arc-pgdata-${name}:/var/lib/postgresql/data`);
|
|
160
|
+
lines.push(" healthcheck:");
|
|
161
|
+
lines.push(' test: ["CMD-SHELL", "pg_isready -U arc -d arc"]');
|
|
162
|
+
lines.push(" interval: 5s");
|
|
163
|
+
lines.push(" timeout: 3s");
|
|
164
|
+
lines.push(" retries: 20");
|
|
165
|
+
lines.push(" networks:");
|
|
166
|
+
lines.push(" - arc-net");
|
|
167
|
+
// No `ports:` — the database is only reachable from other containers
|
|
168
|
+
// on `arc-net`. Exposing 5432 to the host would defeat the isolation.
|
|
169
|
+
lines.push("");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Observability sidecars — only when `observability.enabled`. Five
|
|
173
|
+
// services come up together: otel-collector receives OTLP from app
|
|
174
|
+
// containers (and from the browser via the public /otel route), fans out
|
|
175
|
+
// to Tempo/Loki/Prometheus; Grafana ties them into a single UI fronted
|
|
176
|
+
// by Caddy basic-auth on a separate subdomain.
|
|
177
|
+
if (cfg.observability?.enabled) {
|
|
178
|
+
lines.push(" otel-collector:");
|
|
179
|
+
lines.push(" image: otel/opentelemetry-collector-contrib:0.114.0");
|
|
180
|
+
lines.push(" container_name: arc-otel-collector");
|
|
181
|
+
lines.push(" restart: unless-stopped");
|
|
182
|
+
lines.push(" command: [\"--config=/etc/otelcol-contrib/config.yaml\"]");
|
|
183
|
+
lines.push(" volumes:");
|
|
184
|
+
lines.push(
|
|
185
|
+
" - ./observability/otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml:ro",
|
|
186
|
+
);
|
|
187
|
+
lines.push(" networks: [arc-net]");
|
|
188
|
+
lines.push(" expose:");
|
|
189
|
+
lines.push(" - \"4317\" # OTLP gRPC");
|
|
190
|
+
lines.push(" - \"4318\" # OTLP HTTP");
|
|
191
|
+
lines.push(" - \"8888\" # collector self-metrics (Prom scrape)");
|
|
192
|
+
lines.push(" depends_on:");
|
|
193
|
+
lines.push(" - tempo");
|
|
194
|
+
lines.push(" - loki");
|
|
195
|
+
lines.push(" - prometheus");
|
|
196
|
+
lines.push("");
|
|
197
|
+
|
|
198
|
+
lines.push(" tempo:");
|
|
199
|
+
lines.push(" image: grafana/tempo:2.6.1");
|
|
200
|
+
lines.push(" container_name: arc-tempo");
|
|
201
|
+
lines.push(" restart: unless-stopped");
|
|
202
|
+
lines.push(" command: [\"-config.file=/etc/tempo.yaml\"]");
|
|
203
|
+
lines.push(" user: \"0\" # tempo writes to /var/tempo, owned by root in the image");
|
|
204
|
+
lines.push(" volumes:");
|
|
205
|
+
lines.push(" - ./observability/tempo.yaml:/etc/tempo.yaml:ro");
|
|
206
|
+
lines.push(" - tempo_data:/var/tempo");
|
|
207
|
+
lines.push(" networks: [arc-net]");
|
|
208
|
+
lines.push(" expose:");
|
|
209
|
+
lines.push(" - \"3200\" # HTTP API for Grafana");
|
|
210
|
+
lines.push(" - \"4317\" # OTLP from collector");
|
|
211
|
+
lines.push("");
|
|
212
|
+
|
|
213
|
+
lines.push(" loki:");
|
|
214
|
+
lines.push(" image: grafana/loki:3.3.2");
|
|
215
|
+
lines.push(" container_name: arc-loki");
|
|
216
|
+
lines.push(" restart: unless-stopped");
|
|
217
|
+
lines.push(" command: [\"-config.file=/etc/loki/local-config.yaml\"]");
|
|
218
|
+
lines.push(" user: \"0\"");
|
|
219
|
+
lines.push(" volumes:");
|
|
220
|
+
lines.push(" - ./observability/loki-config.yaml:/etc/loki/local-config.yaml:ro");
|
|
221
|
+
lines.push(" - loki_data:/loki");
|
|
222
|
+
lines.push(" networks: [arc-net]");
|
|
223
|
+
lines.push(" expose:");
|
|
224
|
+
lines.push(" - \"3100\" # HTTP API");
|
|
225
|
+
lines.push("");
|
|
226
|
+
|
|
227
|
+
lines.push(" prometheus:");
|
|
228
|
+
lines.push(" image: prom/prometheus:v2.55.1");
|
|
229
|
+
lines.push(" container_name: arc-prometheus");
|
|
230
|
+
lines.push(" restart: unless-stopped");
|
|
231
|
+
lines.push(" command:");
|
|
232
|
+
lines.push(" - \"--config.file=/etc/prometheus/prometheus.yml\"");
|
|
233
|
+
lines.push(" - \"--storage.tsdb.path=/prometheus\"");
|
|
234
|
+
lines.push(" - \"--web.enable-remote-write-receiver\"");
|
|
235
|
+
lines.push(" - \"--enable-feature=exemplar-storage\"");
|
|
236
|
+
lines.push(" volumes:");
|
|
237
|
+
lines.push(" - ./observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro");
|
|
238
|
+
lines.push(" - prometheus_data:/prometheus");
|
|
239
|
+
lines.push(" networks: [arc-net]");
|
|
240
|
+
lines.push(" expose:");
|
|
241
|
+
lines.push(" - \"9090\" # HTTP API + remote_write receiver");
|
|
242
|
+
lines.push("");
|
|
243
|
+
|
|
244
|
+
const adminPasswordEnv = cfg.observability.adminPasswordEnv ?? "ARC_OBSERVABILITY_PASSWORD";
|
|
245
|
+
lines.push(" grafana:");
|
|
246
|
+
lines.push(" image: grafana/grafana:11.4.0");
|
|
247
|
+
lines.push(" container_name: arc-grafana");
|
|
248
|
+
lines.push(" restart: unless-stopped");
|
|
249
|
+
lines.push(" environment:");
|
|
250
|
+
lines.push(" GF_SECURITY_ADMIN_USER: admin");
|
|
251
|
+
lines.push(
|
|
252
|
+
` GF_SECURITY_ADMIN_PASSWORD: \${${adminPasswordEnv}:?missing ${adminPasswordEnv}}`,
|
|
253
|
+
);
|
|
254
|
+
lines.push(" GF_USERS_ALLOW_SIGN_UP: \"false\"");
|
|
255
|
+
lines.push(" GF_AUTH_ANONYMOUS_ENABLED: \"false\"");
|
|
256
|
+
// Subpath-less — the upstream URL is served from root via Caddy reverse
|
|
257
|
+
// proxy on a dedicated subdomain.
|
|
258
|
+
lines.push(" volumes:");
|
|
259
|
+
lines.push(
|
|
260
|
+
" - ./observability/grafana-datasources.yaml:/etc/grafana/provisioning/datasources/datasources.yaml:ro",
|
|
261
|
+
);
|
|
262
|
+
lines.push(" - grafana_data:/var/lib/grafana");
|
|
263
|
+
lines.push(" networks: [arc-net]");
|
|
264
|
+
lines.push(" expose:");
|
|
265
|
+
lines.push(" - \"3000\" # Grafana UI (behind Caddy basic-auth)");
|
|
266
|
+
lines.push(" depends_on:");
|
|
267
|
+
lines.push(" - tempo");
|
|
268
|
+
lines.push(" - loki");
|
|
269
|
+
lines.push(" - prometheus");
|
|
270
|
+
lines.push("");
|
|
271
|
+
}
|
|
272
|
+
|
|
96
273
|
lines.push("networks:");
|
|
97
274
|
lines.push(" arc-net:");
|
|
98
275
|
lines.push("");
|
|
@@ -100,9 +277,35 @@ export function generateCompose({ cfg }: ComposeOptions): string {
|
|
|
100
277
|
lines.push(" caddy_data:");
|
|
101
278
|
lines.push(" caddy_config:");
|
|
102
279
|
lines.push(" registry_data:");
|
|
103
|
-
for (const [name] of Object.entries(cfg.envs)) {
|
|
104
|
-
|
|
280
|
+
for (const [name, env] of Object.entries(cfg.envs)) {
|
|
281
|
+
if (env.db?.type === "postgres") {
|
|
282
|
+
lines.push(` arc-pgdata-${name}:`);
|
|
283
|
+
} else {
|
|
284
|
+
lines.push(` arc-data-${name}:`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (cfg.observability?.enabled) {
|
|
288
|
+
lines.push(" tempo_data:");
|
|
289
|
+
lines.push(" loki_data:");
|
|
290
|
+
lines.push(" prometheus_data:");
|
|
291
|
+
lines.push(" grafana_data:");
|
|
105
292
|
}
|
|
106
293
|
|
|
107
294
|
return lines.join("\n") + "\n";
|
|
108
295
|
}
|
|
296
|
+
|
|
297
|
+
function upperEnvName(name: string): string {
|
|
298
|
+
return name.toUpperCase().replace(/-/g, "_");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Env names whose `deploy.arc.<env>.env` file must contain a generated
|
|
302
|
+
* `ARC_PG_PASSWORD_<UPPER>` before compose can be rendered. */
|
|
303
|
+
export function postgresEnvs(cfg: DeployConfig): { name: string; passwordKey: string }[] {
|
|
304
|
+
const out: { name: string; passwordKey: string }[] = [];
|
|
305
|
+
for (const [name, env] of Object.entries(cfg.envs)) {
|
|
306
|
+
if (env.db?.type !== "postgres") continue;
|
|
307
|
+
out.push({ name, passwordKey: `ARC_PG_PASSWORD_${upperEnvName(name)}` });
|
|
308
|
+
}
|
|
309
|
+
return out;
|
|
310
|
+
}
|
|
311
|
+
|