@arcote.tech/arc-cli 0.7.19 → 0.7.21

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.
@@ -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