@arcote.tech/arc-cli 0.7.19 → 0.7.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc-cli",
3
- "version": "0.7.19",
3
+ "version": "0.7.20",
4
4
  "description": "CLI tool for Arc framework",
5
5
  "module": "index.ts",
6
6
  "main": "dist/index.js",
@@ -12,13 +12,13 @@
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 --external '@opentelemetry/*' && chmod +x dist/index.js"
13
13
  },
14
14
  "dependencies": {
15
- "@arcote.tech/arc": "^0.7.19",
16
- "@arcote.tech/arc-ds": "^0.7.19",
17
- "@arcote.tech/arc-react": "^0.7.19",
18
- "@arcote.tech/arc-host": "^0.7.19",
19
- "@arcote.tech/arc-adapter-db-sqlite": "^0.7.19",
20
- "@arcote.tech/arc-adapter-db-postgres": "^0.7.19",
21
- "@arcote.tech/arc-otel": "^0.7.19",
15
+ "@arcote.tech/arc": "^0.7.20",
16
+ "@arcote.tech/arc-ds": "^0.7.20",
17
+ "@arcote.tech/arc-react": "^0.7.20",
18
+ "@arcote.tech/arc-host": "^0.7.20",
19
+ "@arcote.tech/arc-adapter-db-sqlite": "^0.7.20",
20
+ "@arcote.tech/arc-adapter-db-postgres": "^0.7.20",
21
+ "@arcote.tech/arc-otel": "^0.7.20",
22
22
  "@opentelemetry/api": "^1.9.0",
23
23
  "@opentelemetry/api-logs": "^0.57.0",
24
24
  "@opentelemetry/core": "^1.30.0",
@@ -31,7 +31,7 @@
31
31
  "@opentelemetry/sdk-trace-base": "^1.30.0",
32
32
  "@opentelemetry/sdk-trace-node": "^1.30.0",
33
33
  "@opentelemetry/semantic-conventions": "^1.27.0",
34
- "@arcote.tech/platform": "^0.7.19",
34
+ "@arcote.tech/platform": "^0.7.20",
35
35
  "@clack/prompts": "^0.9.0",
36
36
  "commander": "^11.1.0",
37
37
  "chokidar": "^3.5.3",
@@ -133,6 +133,9 @@ export async function bootstrap(inputs: BootstrapInputs): Promise<void> {
133
133
  // - stack isn't fully ready, OR
134
134
  // - marker is missing (legacy v0.5 deploy with no .arc-state.json), OR
135
135
  // - configHash differs from last bootstrap (deploy.arc.json changed), OR
136
+ // - the CLI version changed (generators evolve — compose/Caddyfile/
137
+ // observability configs must be re-rendered + re-uploaded even when
138
+ // deploy.arc.json itself is unchanged), OR
136
139
  // - registry container isn't running (e.g. legacy stack predates v0.7)
137
140
  // Without this, an old v0.5 stack (no registry container) is classified as
138
141
  // "ready" and bootstrap is skipped — then `docker login` on the next step
@@ -141,6 +144,7 @@ export async function bootstrap(inputs: BootstrapInputs): Promise<void> {
141
144
  state.kind !== "ready" ||
142
145
  state.marker === null ||
143
146
  state.marker.configHash !== inputs.configHash ||
147
+ state.marker.cliVersion !== inputs.cliVersion ||
144
148
  !(await isRegistryRunning(cfg));
145
149
 
146
150
  if (needUpStack) {
@@ -156,7 +160,7 @@ export async function bootstrap(inputs: BootstrapInputs): Promise<void> {
156
160
  // without forcing a full bootstrap.
157
161
  if (cfg.observability?.enabled) {
158
162
  log("Ensuring observability sidecars are running...");
159
- const obsServices = ["otel-collector", "tempo", "loki", "prometheus", "grafana"];
163
+ const obsServices = ["otel-collector", "tempo", "loki", "prometheus", "alloy", "grafana"];
160
164
  await assertExec(
161
165
  cfg.target,
162
166
  `cd ${cfg.target.remoteDir} && docker compose pull --ignore-pull-failures ${obsServices.join(" ")} && docker compose up -d ${obsServices.join(" ")}`,
@@ -307,10 +311,11 @@ async function upStack(inputs: BootstrapInputs): Promise<void> {
307
311
  // `observability/` directory referenced by compose bind-mounts.
308
312
  if (observabilityFiles && observabilityHtpasswd) {
309
313
  // Make sure both the top-level `observability/` dir and the nested
310
- // `grafana-dashboards/` exist before scp tries to land files there.
314
+ // `grafana-dashboards/` + `grafana-alerting/` exist before scp tries
315
+ // to land files there.
311
316
  await assertExec(
312
317
  cfg.target,
313
- `mkdir -p ${cfg.target.remoteDir}/observability/grafana-dashboards`,
318
+ `mkdir -p ${cfg.target.remoteDir}/observability/grafana-dashboards ${cfg.target.remoteDir}/observability/grafana-alerting`,
314
319
  );
315
320
  // Make sure local nested dir exists too — `generateObservabilityConfigs`
316
321
  // returns relative paths like `observability/grafana-dashboards/x.json`
@@ -20,23 +20,48 @@ export function generateCaddyfile(cfg: DeployConfig): string {
20
20
  cfg.caddy.email === "internal" ? "" : `\n email ${cfg.caddy.email}`;
21
21
  const tlsDirective =
22
22
  cfg.caddy.email === "internal" ? "\n tls internal" : "";
23
+ const observability = cfg.observability?.enabled === true;
24
+
25
+ // Access logs land on stdout as JSON → Alloy tails the container → Loki
26
+ // (`{compose_service="caddy"}`). Off by default in Caddy, hence per-vhost.
27
+ const logDirective = observability
28
+ ? [" log {", " output stdout", " format json", " }"]
29
+ : [];
23
30
 
24
31
  const lines: string[] = [];
25
32
  lines.push("# Generated by `arc platform deploy` — do not edit by hand.");
26
33
  lines.push("");
27
34
  lines.push("{");
28
35
  lines.push(" admin off");
36
+ if (observability) {
37
+ // Per-request HTTP metrics (caddy_http_*). Top-level global option —
38
+ // requires Caddy >= 2.9 (the `servers > metrics` form is deprecated).
39
+ lines.push(" metrics {");
40
+ lines.push(" per_host");
41
+ lines.push(" }");
42
+ }
29
43
  if (email) lines.push(` ${email.trim()}`);
30
44
  lines.push("}");
31
45
  lines.push("");
32
46
 
47
+ // Exposition endpoint for Prometheus (scrapes caddy:2020 on arc-net).
48
+ // Plain HTTP, never exposed publicly; works with `admin off` (only the
49
+ // admin-API /metrics endpoint dies with the admin interface).
50
+ if (observability) {
51
+ lines.push(":2020 {");
52
+ lines.push(" metrics");
53
+ lines.push("}");
54
+ lines.push("");
55
+ }
56
+
33
57
  // Public blocks — one per env. When observability is on, add a `/otel/*`
34
58
  // path that forwards browser-side OTLP/HTTP to the collector. Strips the
35
59
  // /otel prefix so the collector sees the same /v1/{traces,logs,metrics}
36
60
  // paths it would receive from same-network senders.
37
61
  for (const [name, env] of Object.entries(cfg.envs)) {
38
62
  lines.push(`${env.domain} {${tlsDirective}`);
39
- if (cfg.observability?.enabled) {
63
+ lines.push(...logDirective);
64
+ if (observability) {
40
65
  lines.push(" handle_path /otel/* {");
41
66
  lines.push(" reverse_proxy otel-collector:4318");
42
67
  lines.push(" }");
@@ -54,13 +79,11 @@ export function generateCaddyfile(cfg: DeployConfig): string {
54
79
  // the apex of the first env's domain (e.g. observability.app.example.com
55
80
  // when the primary env is app.example.com). Caddy issues a separate
56
81
  // 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}`);
82
+ if (observability) {
83
+ const domain = observabilityDomain(cfg);
84
+ if (domain) {
85
+ lines.push(`${domain} {${tlsDirective}`);
86
+ lines.push(...logDirective);
64
87
  // Basic-auth credentials live in the same htpasswd file used for the
65
88
  // registry — bootstrap appends an "admin" line with bcrypted password.
66
89
  lines.push(" basic_auth {");
@@ -77,6 +100,7 @@ export function generateCaddyfile(cfg: DeployConfig): string {
77
100
  // 5 GiB request body cap fits real app images comfortably (default 100MB
78
101
  // triggers 413 on the first layer push).
79
102
  lines.push(`${cfg.registry.domain} {${tlsDirective}`);
103
+ lines.push(...logDirective);
80
104
  lines.push(" reverse_proxy registry:5000 {");
81
105
  lines.push(" header_up Host {host}");
82
106
  lines.push(" }");
@@ -88,6 +112,17 @@ export function generateCaddyfile(cfg: DeployConfig): string {
88
112
  return lines.join("\n") + "\n";
89
113
  }
90
114
 
115
+ /** Public hostname of the Grafana UI (`<subdomain>.<apex-of-first-env>`), or
116
+ * null when observability is off / no envs exist. Shared by the Caddyfile
117
+ * vhost and Grafana's GF_SERVER_ROOT_URL (compose.ts). */
118
+ export function observabilityDomain(cfg: DeployConfig): string | null {
119
+ if (!cfg.observability?.enabled) return null;
120
+ const firstEnv = Object.values(cfg.envs)[0];
121
+ if (!firstEnv) return null;
122
+ const subdomain = cfg.observability.subdomain ?? "observability";
123
+ return `${subdomain}.${apexOf(firstEnv.domain)}`;
124
+ }
125
+
91
126
  /** Apex of a (possibly-subdomain) host. `app.example.com` → `example.com`,
92
127
  * `example.com` → `example.com`, `example.co.uk` → `co.uk` (approximate —
93
128
  * good enough for the observability subdomain). */
@@ -1,3 +1,4 @@
1
+ import { observabilityDomain } from "./caddyfile";
1
2
  import type { DeployConfig } from "./config";
2
3
 
3
4
  // ---------------------------------------------------------------------------
@@ -21,6 +22,17 @@ export interface ComposeOptions {
21
22
  cfg: DeployConfig;
22
23
  }
23
24
 
25
+ /** json-file log rotation for every service — without it container logs grow
26
+ * unbounded and eventually fill the disk. Alloy tails these same files via
27
+ * the Docker API, and rotation is handled transparently. */
28
+ function pushLogging(lines: string[]): void {
29
+ lines.push(" logging:");
30
+ lines.push(" driver: json-file");
31
+ lines.push(" options:");
32
+ lines.push(' max-size: "10m"');
33
+ lines.push(' max-file: "3"');
34
+ }
35
+
24
36
  export function generateCompose({ cfg }: ComposeOptions): string {
25
37
  const lines: string[] = [];
26
38
  lines.push("# Generated by `arc platform deploy` — do not edit by hand.");
@@ -29,6 +41,7 @@ export function generateCompose({ cfg }: ComposeOptions): string {
29
41
  lines.push(" caddy:");
30
42
  lines.push(" image: caddy:2-alpine");
31
43
  lines.push(" restart: unless-stopped");
44
+ pushLogging(lines);
32
45
  lines.push(" ports:");
33
46
  lines.push(' - "80:80"');
34
47
  lines.push(' - "443:443"');
@@ -46,6 +59,10 @@ export function generateCompose({ cfg }: ComposeOptions): string {
46
59
  lines.push(" - caddy_config:/config");
47
60
  lines.push(" networks:");
48
61
  lines.push(" - arc-net");
62
+ if (cfg.observability?.enabled) {
63
+ lines.push(" expose:");
64
+ lines.push(' - "2020" # Prometheus metrics endpoint (Caddyfile :2020 site)');
65
+ }
49
66
  lines.push("");
50
67
 
51
68
  // Private Docker Registry — `arc platform deploy` pushes app images here,
@@ -54,6 +71,7 @@ export function generateCompose({ cfg }: ComposeOptions): string {
54
71
  lines.push(" registry:");
55
72
  lines.push(" image: registry:2");
56
73
  lines.push(" restart: unless-stopped");
74
+ pushLogging(lines);
57
75
  lines.push(" volumes:");
58
76
  lines.push(" - registry_data:/var/lib/registry");
59
77
  lines.push(" - ./registry-auth/htpasswd:/auth/htpasswd:ro");
@@ -81,6 +99,15 @@ export function generateCompose({ cfg }: ComposeOptions): string {
81
99
  );
82
100
  lines.push(` container_name: arc-${name}`);
83
101
  lines.push(" restart: unless-stopped");
102
+ pushLogging(lines);
103
+ // The host server exposes GET /health (packages/host healthHandler).
104
+ // Image base is oven/bun:1-alpine — busybox wget is available.
105
+ lines.push(" healthcheck:");
106
+ lines.push(' test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5005/health"]');
107
+ lines.push(" interval: 30s");
108
+ lines.push(" timeout: 5s");
109
+ lines.push(" retries: 3");
110
+ lines.push(" start_period: 20s");
84
111
  if (usePostgres) {
85
112
  lines.push(" depends_on:");
86
113
  lines.push(` arc-db-${name}:`);
@@ -149,6 +176,7 @@ export function generateCompose({ cfg }: ComposeOptions): string {
149
176
  lines.push(` image: ${image}`);
150
177
  lines.push(` container_name: arc-db-${name}`);
151
178
  lines.push(" restart: unless-stopped");
179
+ pushLogging(lines);
152
180
  lines.push(" environment:");
153
181
  lines.push(" POSTGRES_USER: arc");
154
182
  lines.push(" POSTGRES_DB: arc");
@@ -186,11 +214,17 @@ export function generateCompose({ cfg }: ComposeOptions): string {
186
214
  lines.push(" image: otel/opentelemetry-collector-contrib:0.114.0");
187
215
  lines.push(" container_name: arc-otel-collector");
188
216
  lines.push(" restart: unless-stopped");
217
+ pushLogging(lines);
218
+ // Root needed for the hostmetrics (/hostfs) + docker_stats (docker.sock)
219
+ // receivers — the image's default user 10001 can read neither.
220
+ lines.push(" user: \"0:0\"");
189
221
  lines.push(" command: [\"--config=/etc/otelcol-contrib/config.yaml\"]");
190
222
  lines.push(" volumes:");
191
223
  lines.push(
192
224
  " - ./observability/otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml:ro",
193
225
  );
226
+ lines.push(" - /:/hostfs:ro # hostmetrics root_path");
227
+ lines.push(" - /var/run/docker.sock:/var/run/docker.sock:ro # docker_stats");
194
228
  lines.push(" networks: [arc-net]");
195
229
  lines.push(" expose:");
196
230
  lines.push(" - \"4317\" # OTLP gRPC");
@@ -206,6 +240,7 @@ export function generateCompose({ cfg }: ComposeOptions): string {
206
240
  lines.push(" image: grafana/tempo:2.6.1");
207
241
  lines.push(" container_name: arc-tempo");
208
242
  lines.push(" restart: unless-stopped");
243
+ pushLogging(lines);
209
244
  lines.push(" command: [\"-config.file=/etc/tempo.yaml\"]");
210
245
  lines.push(" user: \"0\" # tempo writes to /var/tempo, owned by root in the image");
211
246
  lines.push(" volumes:");
@@ -221,6 +256,7 @@ export function generateCompose({ cfg }: ComposeOptions): string {
221
256
  lines.push(" image: grafana/loki:3.3.2");
222
257
  lines.push(" container_name: arc-loki");
223
258
  lines.push(" restart: unless-stopped");
259
+ pushLogging(lines);
224
260
  lines.push(" command: [\"-config.file=/etc/loki/local-config.yaml\"]");
225
261
  lines.push(" user: \"0\"");
226
262
  lines.push(" volumes:");
@@ -236,6 +272,7 @@ export function generateCompose({ cfg }: ComposeOptions): string {
236
272
  lines.push(" image: prom/prometheus:v2.55.1");
237
273
  lines.push(" container_name: arc-prometheus");
238
274
  lines.push(" restart: unless-stopped");
275
+ pushLogging(lines);
239
276
  lines.push(" command:");
240
277
  lines.push(" - \"--config.file=/etc/prometheus/prometheus.yml\"");
241
278
  lines.push(" - \"--storage.tsdb.path=/prometheus\"");
@@ -250,11 +287,38 @@ export function generateCompose({ cfg }: ComposeOptions): string {
250
287
  lines.push(" - \"9090\" # HTTP API + remote_write receiver");
251
288
  lines.push("");
252
289
 
290
+ // Grafana Alloy — tails stdout/stderr of every container (Docker API)
291
+ // and pushes to Loki. Complements in-app OTLP logs with infra-container
292
+ // output and app crash logs.
293
+ lines.push(" alloy:");
294
+ lines.push(" image: grafana/alloy:v1.16.1");
295
+ lines.push(" container_name: arc-alloy");
296
+ lines.push(" restart: unless-stopped");
297
+ pushLogging(lines);
298
+ lines.push(" user: \"0\" # docker.sock access");
299
+ lines.push(" command:");
300
+ lines.push(" - run");
301
+ lines.push(" - --server.http.listen-addr=0.0.0.0:12345");
302
+ lines.push(" - --storage.path=/var/lib/alloy/data");
303
+ lines.push(" - /etc/alloy/config.alloy");
304
+ lines.push(" volumes:");
305
+ lines.push(" - ./observability/alloy-config.alloy:/etc/alloy/config.alloy:ro");
306
+ lines.push(" - /var/run/docker.sock:/var/run/docker.sock:ro");
307
+ lines.push(" - alloy_data:/var/lib/alloy/data");
308
+ lines.push(" networks: [arc-net]");
309
+ lines.push(" expose:");
310
+ lines.push(" - \"12345\" # Alloy self-metrics (Prom scrape)");
311
+ lines.push(" depends_on:");
312
+ lines.push(" - loki");
313
+ lines.push("");
314
+
253
315
  const adminPasswordEnv = cfg.observability.adminPasswordEnv ?? "ARC_OBSERVABILITY_PASSWORD";
316
+ const grafanaDomain = observabilityDomain(cfg);
254
317
  lines.push(" grafana:");
255
318
  lines.push(" image: grafana/grafana:11.4.0");
256
319
  lines.push(" container_name: arc-grafana");
257
320
  lines.push(" restart: unless-stopped");
321
+ pushLogging(lines);
258
322
  lines.push(" environment:");
259
323
  lines.push(" GF_SECURITY_ADMIN_USER: admin");
260
324
  lines.push(
@@ -262,6 +326,11 @@ export function generateCompose({ cfg }: ComposeOptions): string {
262
326
  );
263
327
  lines.push(" GF_USERS_ALLOW_SIGN_UP: \"false\"");
264
328
  lines.push(" GF_AUTH_ANONYMOUS_ENABLED: \"false\"");
329
+ if (grafanaDomain) {
330
+ // Correct absolute links in alert notifications + UI redirects behind
331
+ // the Caddy reverse proxy.
332
+ lines.push(` GF_SERVER_ROOT_URL: "https://${grafanaDomain}"`);
333
+ }
265
334
  // Subpath-less — the upstream URL is served from root via Caddy reverse
266
335
  // proxy on a dedicated subdomain.
267
336
  lines.push(" volumes:");
@@ -278,6 +347,9 @@ export function generateCompose({ cfg }: ComposeOptions): string {
278
347
  lines.push(
279
348
  " - ./observability/grafana-dashboards:/etc/grafana/provisioning/dashboards/arc:ro",
280
349
  );
350
+ lines.push(
351
+ " - ./observability/grafana-alerting:/etc/grafana/provisioning/alerting:ro",
352
+ );
281
353
  lines.push(" - grafana_data:/var/lib/grafana");
282
354
  lines.push(" networks: [arc-net]");
283
355
  lines.push(" expose:");
@@ -308,6 +380,7 @@ export function generateCompose({ cfg }: ComposeOptions): string {
308
380
  lines.push(" loki_data:");
309
381
  lines.push(" prometheus_data:");
310
382
  lines.push(" grafana_data:");
383
+ lines.push(" alloy_data:");
311
384
  }
312
385
 
313
386
  return lines.join("\n") + "\n";
@@ -109,6 +109,11 @@ export interface DeployObservability {
109
109
  logs?: string;
110
110
  metrics?: string;
111
111
  };
112
+ /** Webhook URL for Grafana alert notifications (Slack/Discord/anything that
113
+ * accepts a POST). When absent, alert rules are still provisioned but no
114
+ * contact point / notification policy is created — alerts are visible in
115
+ * the Grafana UI only. */
116
+ alertWebhookUrl?: string;
112
117
  }
113
118
 
114
119
  export interface DeployRegistry {
@@ -344,6 +349,15 @@ export function validateDeployConfig(input: unknown): DeployConfig {
344
349
  metrics: optionalString(retentionRaw, "observability.retention.metrics"),
345
350
  };
346
351
  }
352
+ const alertWebhookUrl = optionalString(
353
+ observabilityRaw,
354
+ "observability.alertWebhookUrl",
355
+ );
356
+ if (alertWebhookUrl !== undefined && !/^https?:\/\/.+/.test(alertWebhookUrl)) {
357
+ throw new Error(
358
+ `deploy.arc.json: observability.alertWebhookUrl must be an http(s) URL (got "${alertWebhookUrl}")`,
359
+ );
360
+ }
347
361
  validated.observability = {
348
362
  enabled: enabledRaw,
349
363
  subdomain: optionalString(observabilityRaw, "observability.subdomain") ?? "observability",
@@ -351,6 +365,7 @@ export function validateDeployConfig(input: unknown): DeployConfig {
351
365
  optionalString(observabilityRaw, "observability.adminPasswordEnv") ??
352
366
  "ARC_OBSERVABILITY_PASSWORD",
353
367
  retention,
368
+ alertWebhookUrl,
354
369
  };
355
370
  }
356
371