@arcote.tech/arc-cli 0.7.5 → 0.7.7
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 +1614 -165
- package/package.json +22 -9
- package/src/builder/dependency-collector.ts +34 -1
- package/src/commands/platform-deploy.ts +16 -0
- package/src/deploy/bootstrap.ts +94 -2
- package/src/deploy/caddyfile.ts +45 -2
- package/src/deploy/compose.ts +147 -2
- package/src/deploy/config.ts +55 -0
- package/src/deploy/env-file.ts +14 -8
- package/src/deploy/htpasswd.ts +20 -0
- package/src/deploy/observability-configs.ts +958 -0
- package/src/platform/server.ts +65 -4
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.7",
|
|
4
4
|
"description": "CLI tool for Arc framework",
|
|
5
5
|
"module": "index.ts",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -9,16 +9,29 @@
|
|
|
9
9
|
"arc": "./dist/index.js"
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
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"
|
|
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 --external '@opentelemetry/*' && 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/arc-adapter-db-postgres": "^0.7.
|
|
21
|
-
"@arcote.tech/
|
|
15
|
+
"@arcote.tech/arc": "^0.7.7",
|
|
16
|
+
"@arcote.tech/arc-ds": "^0.7.7",
|
|
17
|
+
"@arcote.tech/arc-react": "^0.7.7",
|
|
18
|
+
"@arcote.tech/arc-host": "^0.7.7",
|
|
19
|
+
"@arcote.tech/arc-adapter-db-sqlite": "^0.7.7",
|
|
20
|
+
"@arcote.tech/arc-adapter-db-postgres": "^0.7.7",
|
|
21
|
+
"@arcote.tech/arc-otel": "^0.7.7",
|
|
22
|
+
"@opentelemetry/api": "^1.9.0",
|
|
23
|
+
"@opentelemetry/api-logs": "^0.57.0",
|
|
24
|
+
"@opentelemetry/core": "^1.30.0",
|
|
25
|
+
"@opentelemetry/exporter-logs-otlp-http": "^0.57.0",
|
|
26
|
+
"@opentelemetry/exporter-metrics-otlp-http": "^0.57.0",
|
|
27
|
+
"@opentelemetry/exporter-trace-otlp-http": "^0.57.0",
|
|
28
|
+
"@opentelemetry/resources": "^1.30.0",
|
|
29
|
+
"@opentelemetry/sdk-logs": "^0.57.0",
|
|
30
|
+
"@opentelemetry/sdk-metrics": "^1.30.0",
|
|
31
|
+
"@opentelemetry/sdk-trace-base": "^1.30.0",
|
|
32
|
+
"@opentelemetry/sdk-trace-node": "^1.30.0",
|
|
33
|
+
"@opentelemetry/semantic-conventions": "^1.27.0",
|
|
34
|
+
"@arcote.tech/platform": "^0.7.7",
|
|
22
35
|
"@clack/prompts": "^0.9.0",
|
|
23
36
|
"commander": "^11.1.0",
|
|
24
37
|
"chokidar": "^3.5.3",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
3
|
-
import { basename, join } from "path";
|
|
3
|
+
import { basename, dirname, join } from "path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
4
5
|
import { FRAMEWORK_PEERS, FRAMEWORK_PEER_SET } from "./framework-peers";
|
|
5
6
|
import type { WorkspacePackage } from "./module-builder";
|
|
6
7
|
|
|
@@ -46,6 +47,38 @@ export function collectFrameworkDeps(
|
|
|
46
47
|
for (const { name, version } of sharedDeps) {
|
|
47
48
|
versions[name] = version;
|
|
48
49
|
}
|
|
50
|
+
|
|
51
|
+
// OpenTelemetry deps — included unconditionally so deployed images can
|
|
52
|
+
// resolve them at runtime when telemetry is enabled. Cheap install
|
|
53
|
+
// (~5MB) and avoids per-deploy branching on `observability.enabled`.
|
|
54
|
+
// CLI's bundle externalizes @opentelemetry/* to dodge a Bun bundling
|
|
55
|
+
// bug that mixes ES5/ES6 class semantics across these packages.
|
|
56
|
+
// Resolve arc-otel from the CLI bundle's own location: arc-otel is a
|
|
57
|
+
// dep of arc-cli, not of the consumer app, so it isn't reachable from
|
|
58
|
+
// `rootDir`. Bun installs it nested under `.bun/<arc-cli-id>/...`,
|
|
59
|
+
// visible to a resolve call rooted in arc-cli's own dist directory.
|
|
60
|
+
try {
|
|
61
|
+
const cliDir = dirname(fileURLToPath(import.meta.url));
|
|
62
|
+
const arcOtelPkgPath = Bun.resolveSync(
|
|
63
|
+
"@arcote.tech/arc-otel/package.json",
|
|
64
|
+
cliDir,
|
|
65
|
+
);
|
|
66
|
+
const arcOtelPkg = JSON.parse(readFileSync(arcOtelPkgPath, "utf-8"));
|
|
67
|
+
for (const [name, spec] of Object.entries(arcOtelPkg.dependencies ?? {})) {
|
|
68
|
+
if (name.startsWith("@opentelemetry/")) {
|
|
69
|
+
versions[name] = spec as string;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
versions["@arcote.tech/arc-otel"] = `^${arcOtelPkg.version}`;
|
|
73
|
+
} catch (e) {
|
|
74
|
+
// arc-otel not installed alongside the CLI — older CLI release without
|
|
75
|
+
// observability support, or a bundle that stripped the dep. Log once
|
|
76
|
+
// so a misconfigured deploy doesn't silently disable telemetry libs
|
|
77
|
+
// when the operator was expecting them.
|
|
78
|
+
console.warn(
|
|
79
|
+
`[arc-otel] could not resolve @arcote.tech/arc-otel — image will run without telemetry deps: ${(e as Error).message}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
49
82
|
const manifest = {
|
|
50
83
|
name: "arc-platform-framework",
|
|
51
84
|
private: true,
|
|
@@ -87,6 +87,16 @@ export async function platformDeploy(
|
|
|
87
87
|
);
|
|
88
88
|
}
|
|
89
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
|
+
|
|
90
100
|
// Filter envs if an arg was provided
|
|
91
101
|
const targetEnvs = envArg
|
|
92
102
|
? envArg in cfg.envs
|
|
@@ -225,5 +235,11 @@ async function hashDeployConfig(rootDir: string): Promise<string> {
|
|
|
225
235
|
const content = readFileSync(p);
|
|
226
236
|
const hasher = new Bun.CryptoHasher("sha256");
|
|
227
237
|
hasher.update(content);
|
|
238
|
+
// Mix in the CLI version so a new CLI release that changes compose
|
|
239
|
+
// / caddyfile / observability templates forces upStack to re-run even
|
|
240
|
+
// when the user's deploy.arc.json is byte-identical. Without this,
|
|
241
|
+
// template changes ship in the npm package but never reach the host
|
|
242
|
+
// because the marker says "no config drift, skipping bootstrap".
|
|
243
|
+
hasher.update(readCliVersion());
|
|
228
244
|
return hasher.digest("hex").slice(0, 16);
|
|
229
245
|
}
|
package/src/deploy/bootstrap.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { spawn } from "bun";
|
|
2
2
|
import { mkdirSync, writeFileSync } from "fs";
|
|
3
3
|
import { tmpdir } from "os";
|
|
4
|
-
import { join } from "path";
|
|
4
|
+
import { dirname, join } from "path";
|
|
5
5
|
import { runAnsible } from "./ansible";
|
|
6
6
|
import { generateCaddyfile } from "./caddyfile";
|
|
7
7
|
import { generateCompose } from "./compose";
|
|
8
8
|
import type { DeployConfig } from "./config";
|
|
9
|
-
import { generateHtpasswd } from "./htpasswd";
|
|
9
|
+
import { generateCaddyBasicAuthLine, 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";
|
|
@@ -147,6 +148,22 @@ export async function bootstrap(inputs: BootstrapInputs): Promise<void> {
|
|
|
147
148
|
ok("Docker stack up");
|
|
148
149
|
}
|
|
149
150
|
|
|
151
|
+
// Observability sidecars — always reconcile state, independent of
|
|
152
|
+
// upStack. `docker compose up -d` is idempotent: when the containers
|
|
153
|
+
// already match the current compose definition, nothing happens. When
|
|
154
|
+
// the user just turned observability on (config changed but upStack
|
|
155
|
+
// ran in a previous deploy), this brings the new services to life
|
|
156
|
+
// without forcing a full bootstrap.
|
|
157
|
+
if (cfg.observability?.enabled) {
|
|
158
|
+
log("Ensuring observability sidecars are running...");
|
|
159
|
+
const obsServices = ["otel-collector", "tempo", "loki", "prometheus", "grafana"];
|
|
160
|
+
await assertExec(
|
|
161
|
+
cfg.target,
|
|
162
|
+
`cd ${cfg.target.remoteDir} && docker compose pull --ignore-pull-failures ${obsServices.join(" ")} && docker compose up -d ${obsServices.join(" ")}`,
|
|
163
|
+
);
|
|
164
|
+
ok("Observability stack up");
|
|
165
|
+
}
|
|
166
|
+
|
|
150
167
|
// Keep marker fresh
|
|
151
168
|
await writeStateMarker(cfg.target, {
|
|
152
169
|
cliVersion: inputs.cliVersion,
|
|
@@ -225,6 +242,32 @@ async function upStack(inputs: BootstrapInputs): Promise<void> {
|
|
|
225
242
|
writeFileSync(join(workDir, "Caddyfile"), generateCaddyfile(cfg));
|
|
226
243
|
writeFileSync(join(workDir, "docker-compose.yml"), generateCompose({ cfg }));
|
|
227
244
|
|
|
245
|
+
// Observability config files + Caddy basic-auth htpasswd for Grafana.
|
|
246
|
+
// Bundled together so a deploy without `observability.enabled` skips it
|
|
247
|
+
// all (no orphan files left on the remote).
|
|
248
|
+
let observabilityFiles: Record<string, string> | null = null;
|
|
249
|
+
let observabilityHtpasswd: string | null = null;
|
|
250
|
+
if (cfg.observability?.enabled) {
|
|
251
|
+
observabilityFiles = generateObservabilityConfigs(cfg);
|
|
252
|
+
const adminPasswordEnv =
|
|
253
|
+
cfg.observability.adminPasswordEnv ?? "ARC_OBSERVABILITY_PASSWORD";
|
|
254
|
+
const adminPassword = process.env[adminPasswordEnv];
|
|
255
|
+
if (!adminPassword) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
`bootstrap: ${adminPasswordEnv} not set — observability needs a Grafana admin password.`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
// Caddy basic_auth import file uses `username bcrypt_hash` syntax
|
|
261
|
+
// (space-separated), NOT the registry-style htpasswd colon format.
|
|
262
|
+
observabilityHtpasswd = await generateCaddyBasicAuthLine("admin", adminPassword);
|
|
263
|
+
for (const [relPath, contents] of Object.entries(observabilityFiles)) {
|
|
264
|
+
const fullPath = join(workDir, relPath);
|
|
265
|
+
mkdirSync(dirname(fullPath), { recursive: true });
|
|
266
|
+
writeFileSync(fullPath, contents);
|
|
267
|
+
}
|
|
268
|
+
writeFileSync(join(workDir, "observability-htpasswd"), observabilityHtpasswd);
|
|
269
|
+
}
|
|
270
|
+
|
|
228
271
|
// Ensure remoteDir exists
|
|
229
272
|
await assertExec(
|
|
230
273
|
cfg.target,
|
|
@@ -257,6 +300,39 @@ async function upStack(inputs: BootstrapInputs): Promise<void> {
|
|
|
257
300
|
`${cfg.target.remoteDir}/registry-auth/htpasswd`,
|
|
258
301
|
);
|
|
259
302
|
|
|
303
|
+
// Upload observability assets when enabled. Caddyfile imports
|
|
304
|
+
// `observability-htpasswd` from /etc/caddy (volume-mounted), so it has
|
|
305
|
+
// to land in the same dir as Caddyfile on the host. The collector /
|
|
306
|
+
// tempo / loki / prometheus / grafana configs go into a sibling
|
|
307
|
+
// `observability/` directory referenced by compose bind-mounts.
|
|
308
|
+
if (observabilityFiles && observabilityHtpasswd) {
|
|
309
|
+
// Make sure both the top-level `observability/` dir and the nested
|
|
310
|
+
// `grafana-dashboards/` exist before scp tries to land files there.
|
|
311
|
+
await assertExec(
|
|
312
|
+
cfg.target,
|
|
313
|
+
`mkdir -p ${cfg.target.remoteDir}/observability/grafana-dashboards`,
|
|
314
|
+
);
|
|
315
|
+
// Make sure local nested dir exists too — `generateObservabilityConfigs`
|
|
316
|
+
// returns relative paths like `observability/grafana-dashboards/x.json`
|
|
317
|
+
// which mkdirSync below uses for writeFileSync's directory.
|
|
318
|
+
for (const relPath of Object.keys(observabilityFiles)) {
|
|
319
|
+
const localDir = dirname(join(workDir, relPath));
|
|
320
|
+
mkdirSync(localDir, { recursive: true });
|
|
321
|
+
}
|
|
322
|
+
for (const relPath of Object.keys(observabilityFiles)) {
|
|
323
|
+
await scpUpload(
|
|
324
|
+
cfg.target,
|
|
325
|
+
join(workDir, relPath),
|
|
326
|
+
`${cfg.target.remoteDir}/${relPath}`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
await scpUpload(
|
|
330
|
+
cfg.target,
|
|
331
|
+
join(workDir, "observability-htpasswd"),
|
|
332
|
+
`${cfg.target.remoteDir}/observability-htpasswd`,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
260
336
|
// Ensure /opt/arc/.env exists so docker compose doesn't error on var
|
|
261
337
|
// substitution. Per-env ARC_IMAGE_<ENV> lines are written by deploy.
|
|
262
338
|
await assertExec(
|
|
@@ -264,6 +340,21 @@ async function upStack(inputs: BootstrapInputs): Promise<void> {
|
|
|
264
340
|
`touch ${cfg.target.remoteDir}/.env`,
|
|
265
341
|
);
|
|
266
342
|
|
|
343
|
+
// Propagate the Grafana admin password to /opt/arc/.env when observability
|
|
344
|
+
// is enabled. Compose interpolates `${ARC_OBSERVABILITY_PASSWORD:?}` into
|
|
345
|
+
// the grafana service's GF_SECURITY_ADMIN_PASSWORD env.
|
|
346
|
+
if (cfg.observability?.enabled) {
|
|
347
|
+
const key =
|
|
348
|
+
cfg.observability.adminPasswordEnv ?? "ARC_OBSERVABILITY_PASSWORD";
|
|
349
|
+
const value = process.env[key];
|
|
350
|
+
if (!value) {
|
|
351
|
+
throw new Error(
|
|
352
|
+
`bootstrap: ${key} not set — observability needs a Grafana admin password.`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
await writeEnvLine(cfg.target, `${cfg.target.remoteDir}/.env`, key, value);
|
|
356
|
+
}
|
|
357
|
+
|
|
267
358
|
// Propagate postgres sidecar passwords to /opt/arc/.env so compose can
|
|
268
359
|
// interpolate `${ARC_PG_PASSWORD_<UPPER>}` for both the pg container and
|
|
269
360
|
// arc-<env>'s DATABASE_URL. Local source is `deploy.arc.<env>.env` /
|
|
@@ -307,6 +398,7 @@ async function upStack(inputs: BootstrapInputs): Promise<void> {
|
|
|
307
398
|
.join(" ")}`,
|
|
308
399
|
);
|
|
309
400
|
}
|
|
401
|
+
|
|
310
402
|
}
|
|
311
403
|
|
|
312
404
|
/**
|
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
|
@@ -34,6 +34,14 @@ export function generateCompose({ cfg }: ComposeOptions): string {
|
|
|
34
34
|
lines.push(' - "443:443"');
|
|
35
35
|
lines.push(" volumes:");
|
|
36
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
|
+
}
|
|
37
45
|
lines.push(" - caddy_data:/data");
|
|
38
46
|
lines.push(" - caddy_config:/config");
|
|
39
47
|
lines.push(" networks:");
|
|
@@ -99,8 +107,26 @@ export function generateCompose({ cfg }: ComposeOptions): string {
|
|
|
99
107
|
` DATABASE_URL: "postgresql://arc:\${ARC_PG_PASSWORD_${upperName}:?missing ARC_PG_PASSWORD_${upperName}}@arc-db-${name}:5432/arc"`,
|
|
100
108
|
);
|
|
101
109
|
}
|
|
102
|
-
|
|
103
|
-
|
|
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
|
+
]);
|
|
104
130
|
for (const [k, v] of Object.entries(userEnv)) {
|
|
105
131
|
if (reserved.has(k)) continue;
|
|
106
132
|
lines.push(` ${k}: ${JSON.stringify(v)}`);
|
|
@@ -143,6 +169,119 @@ export function generateCompose({ cfg }: ComposeOptions): string {
|
|
|
143
169
|
lines.push("");
|
|
144
170
|
}
|
|
145
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
|
+
const metricsRetention = cfg.observability.retention?.metrics ?? "30d";
|
|
228
|
+
lines.push(" prometheus:");
|
|
229
|
+
lines.push(" image: prom/prometheus:v2.55.1");
|
|
230
|
+
lines.push(" container_name: arc-prometheus");
|
|
231
|
+
lines.push(" restart: unless-stopped");
|
|
232
|
+
lines.push(" command:");
|
|
233
|
+
lines.push(" - \"--config.file=/etc/prometheus/prometheus.yml\"");
|
|
234
|
+
lines.push(" - \"--storage.tsdb.path=/prometheus\"");
|
|
235
|
+
lines.push(` - "--storage.tsdb.retention.time=${metricsRetention}"`);
|
|
236
|
+
lines.push(" - \"--web.enable-remote-write-receiver\"");
|
|
237
|
+
lines.push(" - \"--enable-feature=exemplar-storage\"");
|
|
238
|
+
lines.push(" volumes:");
|
|
239
|
+
lines.push(" - ./observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro");
|
|
240
|
+
lines.push(" - prometheus_data:/prometheus");
|
|
241
|
+
lines.push(" networks: [arc-net]");
|
|
242
|
+
lines.push(" expose:");
|
|
243
|
+
lines.push(" - \"9090\" # HTTP API + remote_write receiver");
|
|
244
|
+
lines.push("");
|
|
245
|
+
|
|
246
|
+
const adminPasswordEnv = cfg.observability.adminPasswordEnv ?? "ARC_OBSERVABILITY_PASSWORD";
|
|
247
|
+
lines.push(" grafana:");
|
|
248
|
+
lines.push(" image: grafana/grafana:11.4.0");
|
|
249
|
+
lines.push(" container_name: arc-grafana");
|
|
250
|
+
lines.push(" restart: unless-stopped");
|
|
251
|
+
lines.push(" environment:");
|
|
252
|
+
lines.push(" GF_SECURITY_ADMIN_USER: admin");
|
|
253
|
+
lines.push(
|
|
254
|
+
` GF_SECURITY_ADMIN_PASSWORD: \${${adminPasswordEnv}:?missing ${adminPasswordEnv}}`,
|
|
255
|
+
);
|
|
256
|
+
lines.push(" GF_USERS_ALLOW_SIGN_UP: \"false\"");
|
|
257
|
+
lines.push(" GF_AUTH_ANONYMOUS_ENABLED: \"false\"");
|
|
258
|
+
// Subpath-less — the upstream URL is served from root via Caddy reverse
|
|
259
|
+
// proxy on a dedicated subdomain.
|
|
260
|
+
lines.push(" volumes:");
|
|
261
|
+
lines.push(
|
|
262
|
+
" - ./observability/grafana-datasources.yaml:/etc/grafana/provisioning/datasources/datasources.yaml:ro",
|
|
263
|
+
);
|
|
264
|
+
// Dashboard auto-provisioning: a single provider config points at the
|
|
265
|
+
// bind-mounted /arc directory; each JSON in that dir becomes a live
|
|
266
|
+
// dashboard inside the "Arc" folder. Updates land within the
|
|
267
|
+
// provider's `updateIntervalSeconds` (30s).
|
|
268
|
+
lines.push(
|
|
269
|
+
" - ./observability/grafana-dashboards.yaml:/etc/grafana/provisioning/dashboards/dashboards.yaml:ro",
|
|
270
|
+
);
|
|
271
|
+
lines.push(
|
|
272
|
+
" - ./observability/grafana-dashboards:/etc/grafana/provisioning/dashboards/arc:ro",
|
|
273
|
+
);
|
|
274
|
+
lines.push(" - grafana_data:/var/lib/grafana");
|
|
275
|
+
lines.push(" networks: [arc-net]");
|
|
276
|
+
lines.push(" expose:");
|
|
277
|
+
lines.push(" - \"3000\" # Grafana UI (behind Caddy basic-auth)");
|
|
278
|
+
lines.push(" depends_on:");
|
|
279
|
+
lines.push(" - tempo");
|
|
280
|
+
lines.push(" - loki");
|
|
281
|
+
lines.push(" - prometheus");
|
|
282
|
+
lines.push("");
|
|
283
|
+
}
|
|
284
|
+
|
|
146
285
|
lines.push("networks:");
|
|
147
286
|
lines.push(" arc-net:");
|
|
148
287
|
lines.push("");
|
|
@@ -157,6 +296,12 @@ export function generateCompose({ cfg }: ComposeOptions): string {
|
|
|
157
296
|
lines.push(` arc-data-${name}:`);
|
|
158
297
|
}
|
|
159
298
|
}
|
|
299
|
+
if (cfg.observability?.enabled) {
|
|
300
|
+
lines.push(" tempo_data:");
|
|
301
|
+
lines.push(" loki_data:");
|
|
302
|
+
lines.push(" prometheus_data:");
|
|
303
|
+
lines.push(" grafana_data:");
|
|
304
|
+
}
|
|
160
305
|
|
|
161
306
|
return lines.join("\n") + "\n";
|
|
162
307
|
}
|
package/src/deploy/config.ts
CHANGED
|
@@ -68,6 +68,35 @@ export interface DeployProvision {
|
|
|
68
68
|
ansible?: DeployProvisionAnsible;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Optional observability stack (otel-collector + Tempo + Loki + Prometheus
|
|
73
|
+
* + Grafana). Stack-wide — applies to every env on the same VPS, accessed
|
|
74
|
+
* through a single Grafana UI at `<subdomain>.<apex-of-app-domain>`.
|
|
75
|
+
*
|
|
76
|
+
* When `enabled: true`, every arc-<env> container gets:
|
|
77
|
+
* - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
|
|
78
|
+
* - OTEL_SERVICE_NAME=arc-<env>
|
|
79
|
+
* - ARC_OTEL_ENABLED=true
|
|
80
|
+
*
|
|
81
|
+
* Sidecar containers come up on the same `arc-net`. Grafana password is
|
|
82
|
+
* stored in `deploy.arc.env` under `adminPasswordEnv`; first deploy auto-
|
|
83
|
+
* generates one if missing.
|
|
84
|
+
*/
|
|
85
|
+
export interface DeployObservability {
|
|
86
|
+
enabled: boolean;
|
|
87
|
+
/** Subdomain under the apex of the first env's domain. Default "observability". */
|
|
88
|
+
subdomain?: string;
|
|
89
|
+
/** Env var name (in `deploy.arc.env`) that holds the Grafana admin password.
|
|
90
|
+
* Default "ARC_OBSERVABILITY_PASSWORD". CLI auto-generates one when absent. */
|
|
91
|
+
adminPasswordEnv?: string;
|
|
92
|
+
/** Optional retention overrides. Defaults: traces 7d, logs 7d, metrics 30d. */
|
|
93
|
+
retention?: {
|
|
94
|
+
traces?: string;
|
|
95
|
+
logs?: string;
|
|
96
|
+
metrics?: string;
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
71
100
|
export interface DeployRegistry {
|
|
72
101
|
/** Full domain — Caddy reverse-proxies this to the registry:5000 container. */
|
|
73
102
|
domain: string;
|
|
@@ -83,6 +112,7 @@ export interface DeployConfig {
|
|
|
83
112
|
caddy: DeployCaddy;
|
|
84
113
|
registry: DeployRegistry;
|
|
85
114
|
provision?: DeployProvision;
|
|
115
|
+
observability?: DeployObservability;
|
|
86
116
|
}
|
|
87
117
|
|
|
88
118
|
export const DEPLOY_CONFIG_FILE = "deploy.arc.json";
|
|
@@ -281,6 +311,31 @@ export function validateDeployConfig(input: unknown): DeployConfig {
|
|
|
281
311
|
validated.envs[name] = { domain, envVars, db };
|
|
282
312
|
}
|
|
283
313
|
|
|
314
|
+
const observabilityRaw = (input as Record<string, unknown>).observability;
|
|
315
|
+
if (observabilityRaw !== undefined) {
|
|
316
|
+
if (!isObject(observabilityRaw)) throw cfgErr("observability", "object");
|
|
317
|
+
const enabledRaw = observabilityRaw.enabled;
|
|
318
|
+
if (typeof enabledRaw !== "boolean") throw cfgErr("observability.enabled", "boolean");
|
|
319
|
+
const retentionRaw = observabilityRaw.retention;
|
|
320
|
+
let retention: DeployObservability["retention"];
|
|
321
|
+
if (retentionRaw !== undefined) {
|
|
322
|
+
if (!isObject(retentionRaw)) throw cfgErr("observability.retention", "object");
|
|
323
|
+
retention = {
|
|
324
|
+
traces: optionalString(retentionRaw, "observability.retention.traces"),
|
|
325
|
+
logs: optionalString(retentionRaw, "observability.retention.logs"),
|
|
326
|
+
metrics: optionalString(retentionRaw, "observability.retention.metrics"),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
validated.observability = {
|
|
330
|
+
enabled: enabledRaw,
|
|
331
|
+
subdomain: optionalString(observabilityRaw, "observability.subdomain") ?? "observability",
|
|
332
|
+
adminPasswordEnv:
|
|
333
|
+
optionalString(observabilityRaw, "observability.adminPasswordEnv") ??
|
|
334
|
+
"ARC_OBSERVABILITY_PASSWORD",
|
|
335
|
+
retention,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
284
339
|
const provision = (input as Record<string, unknown>).provision;
|
|
285
340
|
if (provision !== undefined) {
|
|
286
341
|
if (!isObject(provision)) throw cfgErr("provision", "object");
|
package/src/deploy/env-file.ts
CHANGED
|
@@ -58,24 +58,30 @@ export function applyDeployGlobals(globals: Record<string, string>): void {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
|
-
* Ensure a value exists for `key` in `deploy.arc
|
|
62
|
-
* generate one via `generate()`, append it to the file (creating
|
|
63
|
-
*
|
|
61
|
+
* Ensure a value exists for `key` in a `deploy.arc.*.env` file. If absent,
|
|
62
|
+
* generate one via `generate()`, append it to the file (creating it if
|
|
63
|
+
* needed), and return the value. Pre-existing values (from the file or
|
|
64
64
|
* process.env) are returned unchanged.
|
|
65
65
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
66
|
+
* `scope`:
|
|
67
|
+
* - `"globals"` writes to `deploy.arc.env` (cross-env secrets like the
|
|
68
|
+
* observability Grafana password — one stack-wide value).
|
|
69
|
+
* - An env name writes to `deploy.arc.<envName>.env` (per-env secrets
|
|
70
|
+
* like the Postgres sidecar password — one per deployment env).
|
|
71
|
+
*
|
|
72
|
+
* Used to bootstrap secrets the framework owns end-to-end without forcing
|
|
73
|
+
* the user to hand-edit the file before the first deploy.
|
|
69
74
|
*/
|
|
70
75
|
export function ensurePersistedSecret(
|
|
71
76
|
rootDir: string,
|
|
72
|
-
|
|
77
|
+
scope: "globals" | string,
|
|
73
78
|
key: string,
|
|
74
79
|
generate: () => string,
|
|
75
80
|
): string {
|
|
76
81
|
if (process.env[key]) return process.env[key]!;
|
|
77
82
|
|
|
78
|
-
const
|
|
83
|
+
const fileName = scope === "globals" ? "deploy.arc.env" : `deploy.arc.${scope}.env`;
|
|
84
|
+
const path = join(rootDir, fileName);
|
|
79
85
|
if (existsSync(path)) {
|
|
80
86
|
const existing = parseEnvFile(readFileSync(path, "utf-8"), path);
|
|
81
87
|
if (existing[key]) {
|
package/src/deploy/htpasswd.ts
CHANGED
|
@@ -26,3 +26,23 @@ export async function generateHtpasswd(
|
|
|
26
26
|
});
|
|
27
27
|
return `${user}:${hash}\n`;
|
|
28
28
|
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Caddy's `basic_auth` directive takes a different format than the
|
|
32
|
+
* registry-style htpasswd file: `<user> <bcrypt-hash>` (space-separated,
|
|
33
|
+
* no colon). Newer Caddy syntax also accepts a quoted hash, but the
|
|
34
|
+
* unquoted bcrypt string works in both 2.7 and 2.8+.
|
|
35
|
+
*/
|
|
36
|
+
export async function generateCaddyBasicAuthLine(
|
|
37
|
+
user: string,
|
|
38
|
+
password: string,
|
|
39
|
+
): Promise<string> {
|
|
40
|
+
if (!user || !password) {
|
|
41
|
+
throw new Error("caddy basic_auth: user and password must both be non-empty");
|
|
42
|
+
}
|
|
43
|
+
const hash = await Bun.password.hash(password, {
|
|
44
|
+
algorithm: "bcrypt",
|
|
45
|
+
cost: 10,
|
|
46
|
+
});
|
|
47
|
+
return `${user} ${hash}\n`;
|
|
48
|
+
}
|