@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/src/deploy/config.ts
CHANGED
|
@@ -22,11 +22,24 @@ export interface DeployTarget {
|
|
|
22
22
|
sshKey?: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Database choice per environment. Omit to keep the historical default
|
|
27
|
+
* (SQLite in a named volume at `/app/.arc/data`). Selecting `postgres`
|
|
28
|
+
* makes `arc platform deploy` provision a `postgres:16-alpine` sidecar
|
|
29
|
+
* service on the docker network, persist its data in
|
|
30
|
+
* `arc-pgdata-<envName>`, and inject `DATABASE_URL` into the arc container.
|
|
31
|
+
*/
|
|
32
|
+
export type DeployEnvDb =
|
|
33
|
+
| { type: "sqlite" }
|
|
34
|
+
| { type: "postgres"; image?: string };
|
|
35
|
+
|
|
25
36
|
export interface DeployEnv {
|
|
26
37
|
/** Subdomain or full domain routed by Caddy. */
|
|
27
38
|
domain: string;
|
|
28
39
|
/** Extra env vars passed to the arc container. */
|
|
29
40
|
envVars?: Record<string, string>;
|
|
41
|
+
/** Optional storage backend selector. Default = sqlite. */
|
|
42
|
+
db?: DeployEnvDb;
|
|
30
43
|
}
|
|
31
44
|
|
|
32
45
|
export interface DeployCaddy {
|
|
@@ -55,6 +68,35 @@ export interface DeployProvision {
|
|
|
55
68
|
ansible?: DeployProvisionAnsible;
|
|
56
69
|
}
|
|
57
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
|
+
|
|
58
100
|
export interface DeployRegistry {
|
|
59
101
|
/** Full domain — Caddy reverse-proxies this to the registry:5000 container. */
|
|
60
102
|
domain: string;
|
|
@@ -70,6 +112,7 @@ export interface DeployConfig {
|
|
|
70
112
|
caddy: DeployCaddy;
|
|
71
113
|
registry: DeployRegistry;
|
|
72
114
|
provision?: DeployProvision;
|
|
115
|
+
observability?: DeployObservability;
|
|
73
116
|
}
|
|
74
117
|
|
|
75
118
|
export const DEPLOY_CONFIG_FILE = "deploy.arc.json";
|
|
@@ -247,7 +290,50 @@ export function validateDeployConfig(input: unknown): DeployConfig {
|
|
|
247
290
|
envVars[k] = v;
|
|
248
291
|
}
|
|
249
292
|
}
|
|
250
|
-
|
|
293
|
+
const dbRaw = (env as Record<string, unknown>).db;
|
|
294
|
+
let db: DeployEnvDb | undefined;
|
|
295
|
+
if (dbRaw !== undefined) {
|
|
296
|
+
if (!isObject(dbRaw)) throw cfgErr(`envs.${name}.db`, "object");
|
|
297
|
+
const dbType = requireString(dbRaw, `envs.${name}.db.type`);
|
|
298
|
+
if (dbType === "sqlite") {
|
|
299
|
+
db = { type: "sqlite" };
|
|
300
|
+
} else if (dbType === "postgres") {
|
|
301
|
+
db = {
|
|
302
|
+
type: "postgres",
|
|
303
|
+
image: optionalString(dbRaw, `envs.${name}.db.image`),
|
|
304
|
+
};
|
|
305
|
+
} else {
|
|
306
|
+
throw new Error(
|
|
307
|
+
`deploy.arc.json: envs.${name}.db.type must be "sqlite" or "postgres" (got "${dbType}")`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
validated.envs[name] = { domain, envVars, db };
|
|
312
|
+
}
|
|
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
|
+
};
|
|
251
337
|
}
|
|
252
338
|
|
|
253
339
|
const provision = (input as Record<string, unknown>).provision;
|
package/src/deploy/env-file.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "fs";
|
|
1
|
+
import { appendFileSync, existsSync, readFileSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
|
|
4
4
|
// ---------------------------------------------------------------------------
|
|
@@ -57,6 +57,46 @@ export function applyDeployGlobals(globals: Record<string, string>): void {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
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
|
+
* process.env) are returned unchanged.
|
|
65
|
+
*
|
|
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.
|
|
74
|
+
*/
|
|
75
|
+
export function ensurePersistedSecret(
|
|
76
|
+
rootDir: string,
|
|
77
|
+
scope: "globals" | string,
|
|
78
|
+
key: string,
|
|
79
|
+
generate: () => string,
|
|
80
|
+
): string {
|
|
81
|
+
if (process.env[key]) return process.env[key]!;
|
|
82
|
+
|
|
83
|
+
const fileName = scope === "globals" ? "deploy.arc.env" : `deploy.arc.${scope}.env`;
|
|
84
|
+
const path = join(rootDir, fileName);
|
|
85
|
+
if (existsSync(path)) {
|
|
86
|
+
const existing = parseEnvFile(readFileSync(path, "utf-8"), path);
|
|
87
|
+
if (existing[key]) {
|
|
88
|
+
process.env[key] = existing[key];
|
|
89
|
+
return existing[key];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const value = generate();
|
|
94
|
+
const prefix = existsSync(path) && !readFileSync(path, "utf-8").endsWith("\n") ? "\n" : "";
|
|
95
|
+
appendFileSync(path, `${prefix}${key}=${value}\n`);
|
|
96
|
+
process.env[key] = value;
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
|
|
60
100
|
// ---------------------------------------------------------------------------
|
|
61
101
|
// Parser
|
|
62
102
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import type { DeployConfig, DeployObservability } from "./config";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Observability stack config templates.
|
|
5
|
+
//
|
|
6
|
+
// All strings are deterministic for the inputs (cfg + retention) — no random
|
|
7
|
+
// IDs, no timestamps — so re-running deploy with unchanged config is a no-op
|
|
8
|
+
// at the file-write level. Bootstrap diffs filesystem before bouncing
|
|
9
|
+
// services, so this matters.
|
|
10
|
+
//
|
|
11
|
+
// Defaults:
|
|
12
|
+
// - traces: 7d retention (Tempo block storage on local disk)
|
|
13
|
+
// - logs: 7d retention (Loki chunks on local disk)
|
|
14
|
+
// - metrics: 30d retention (Prometheus TSDB on local disk)
|
|
15
|
+
//
|
|
16
|
+
// Tail sampling: every error + every span >500ms + 10% random. Decided in
|
|
17
|
+
// the collector so per-service SDKs can be left at always-on without
|
|
18
|
+
// flooding the backend.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
const DEFAULT_RETENTION = {
|
|
22
|
+
traces: "168h", // 7d
|
|
23
|
+
logs: "168h",
|
|
24
|
+
metrics: "30d",
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
function pickRetention(o: DeployObservability | undefined) {
|
|
28
|
+
return {
|
|
29
|
+
traces: o?.retention?.traces ?? DEFAULT_RETENTION.traces,
|
|
30
|
+
logs: o?.retention?.logs ?? DEFAULT_RETENTION.logs,
|
|
31
|
+
metrics: o?.retention?.metrics ?? DEFAULT_RETENTION.metrics,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** OpenTelemetry Collector — receives OTLP from app containers + browser,
|
|
36
|
+
* applies tail sampling, fans out to Tempo (traces), Loki (logs),
|
|
37
|
+
* Prometheus remote-write (metrics). */
|
|
38
|
+
export function generateOtelCollectorConfig(cfg: DeployConfig): string {
|
|
39
|
+
const envNames = Object.keys(cfg.envs);
|
|
40
|
+
return `# Generated by \`arc platform deploy\` — do not edit by hand.
|
|
41
|
+
receivers:
|
|
42
|
+
otlp:
|
|
43
|
+
protocols:
|
|
44
|
+
grpc:
|
|
45
|
+
endpoint: 0.0.0.0:4317
|
|
46
|
+
http:
|
|
47
|
+
endpoint: 0.0.0.0:4318
|
|
48
|
+
cors:
|
|
49
|
+
allowed_origins:
|
|
50
|
+
${envNames.map((name) => ` - "https://${cfg.envs[name]!.domain}"`).join("\n")}
|
|
51
|
+
allowed_headers:
|
|
52
|
+
- traceparent
|
|
53
|
+
- tracestate
|
|
54
|
+
- content-type
|
|
55
|
+
|
|
56
|
+
processors:
|
|
57
|
+
batch:
|
|
58
|
+
timeout: 5s
|
|
59
|
+
send_batch_size: 512
|
|
60
|
+
send_batch_max_size: 1024
|
|
61
|
+
|
|
62
|
+
# Tail-based sampling — applied after a full trace has been assembled.
|
|
63
|
+
# Errors and slow traces are kept 100%, everything else at 10%.
|
|
64
|
+
tail_sampling:
|
|
65
|
+
decision_wait: 10s
|
|
66
|
+
num_traces: 50000
|
|
67
|
+
expected_new_traces_per_sec: 100
|
|
68
|
+
policies:
|
|
69
|
+
- name: errors
|
|
70
|
+
type: status_code
|
|
71
|
+
status_code: { status_codes: [ERROR] }
|
|
72
|
+
- name: slow
|
|
73
|
+
type: latency
|
|
74
|
+
latency: { threshold_ms: 500 }
|
|
75
|
+
- name: random_10pct
|
|
76
|
+
type: probabilistic
|
|
77
|
+
probabilistic: { sampling_percentage: 10 }
|
|
78
|
+
|
|
79
|
+
# Drop high-cardinality / PII attributes that might slip past app-side
|
|
80
|
+
# sanitization. Belt-and-suspenders before they hit long-term storage.
|
|
81
|
+
attributes:
|
|
82
|
+
actions:
|
|
83
|
+
- key: http.request.header.authorization
|
|
84
|
+
action: delete
|
|
85
|
+
- key: http.request.header.cookie
|
|
86
|
+
action: delete
|
|
87
|
+
|
|
88
|
+
exporters:
|
|
89
|
+
otlp/tempo:
|
|
90
|
+
endpoint: tempo:4317
|
|
91
|
+
tls:
|
|
92
|
+
insecure: true
|
|
93
|
+
|
|
94
|
+
otlphttp/loki:
|
|
95
|
+
endpoint: http://loki:3100/otlp
|
|
96
|
+
tls:
|
|
97
|
+
insecure: true
|
|
98
|
+
|
|
99
|
+
prometheusremotewrite:
|
|
100
|
+
endpoint: http://prometheus:9090/api/v1/write
|
|
101
|
+
tls:
|
|
102
|
+
insecure: true
|
|
103
|
+
|
|
104
|
+
extensions:
|
|
105
|
+
health_check: {}
|
|
106
|
+
zpages: {}
|
|
107
|
+
|
|
108
|
+
service:
|
|
109
|
+
extensions: [health_check, zpages]
|
|
110
|
+
pipelines:
|
|
111
|
+
traces:
|
|
112
|
+
receivers: [otlp]
|
|
113
|
+
processors: [tail_sampling, attributes, batch]
|
|
114
|
+
exporters: [otlp/tempo]
|
|
115
|
+
logs:
|
|
116
|
+
receivers: [otlp]
|
|
117
|
+
processors: [attributes, batch]
|
|
118
|
+
exporters: [otlphttp/loki]
|
|
119
|
+
metrics:
|
|
120
|
+
receivers: [otlp]
|
|
121
|
+
processors: [batch]
|
|
122
|
+
exporters: [prometheusremotewrite]
|
|
123
|
+
`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Grafana Tempo — single-binary mode with local block storage. */
|
|
127
|
+
export function generateTempoConfig(cfg: DeployConfig): string {
|
|
128
|
+
const retention = pickRetention(cfg.observability);
|
|
129
|
+
return `# Generated by \`arc platform deploy\` — do not edit by hand.
|
|
130
|
+
server:
|
|
131
|
+
http_listen_port: 3200
|
|
132
|
+
grpc_listen_port: 9095
|
|
133
|
+
|
|
134
|
+
distributor:
|
|
135
|
+
receivers:
|
|
136
|
+
otlp:
|
|
137
|
+
protocols:
|
|
138
|
+
grpc:
|
|
139
|
+
endpoint: 0.0.0.0:4317
|
|
140
|
+
http:
|
|
141
|
+
endpoint: 0.0.0.0:4318
|
|
142
|
+
|
|
143
|
+
ingester:
|
|
144
|
+
trace_idle_period: 10s
|
|
145
|
+
max_block_bytes: 1048576
|
|
146
|
+
max_block_duration: 5m
|
|
147
|
+
|
|
148
|
+
compactor:
|
|
149
|
+
compaction:
|
|
150
|
+
block_retention: ${retention.traces}
|
|
151
|
+
|
|
152
|
+
storage:
|
|
153
|
+
trace:
|
|
154
|
+
backend: local
|
|
155
|
+
local:
|
|
156
|
+
path: /var/tempo/blocks
|
|
157
|
+
wal:
|
|
158
|
+
path: /var/tempo/wal
|
|
159
|
+
|
|
160
|
+
metrics_generator:
|
|
161
|
+
registry:
|
|
162
|
+
external_labels:
|
|
163
|
+
source: tempo
|
|
164
|
+
storage:
|
|
165
|
+
path: /var/tempo/generator/wal
|
|
166
|
+
remote_write:
|
|
167
|
+
- url: http://prometheus:9090/api/v1/write
|
|
168
|
+
send_exemplars: true
|
|
169
|
+
|
|
170
|
+
overrides:
|
|
171
|
+
defaults:
|
|
172
|
+
metrics_generator:
|
|
173
|
+
processors: [service-graphs, span-metrics]
|
|
174
|
+
`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Loki — single-binary mode, filesystem chunks. */
|
|
178
|
+
export function generateLokiConfig(cfg: DeployConfig): string {
|
|
179
|
+
const retention = pickRetention(cfg.observability);
|
|
180
|
+
return `# Generated by \`arc platform deploy\` — do not edit by hand.
|
|
181
|
+
auth_enabled: false
|
|
182
|
+
|
|
183
|
+
server:
|
|
184
|
+
http_listen_port: 3100
|
|
185
|
+
|
|
186
|
+
common:
|
|
187
|
+
instance_addr: 127.0.0.1
|
|
188
|
+
path_prefix: /loki
|
|
189
|
+
storage:
|
|
190
|
+
filesystem:
|
|
191
|
+
chunks_directory: /loki/chunks
|
|
192
|
+
rules_directory: /loki/rules
|
|
193
|
+
replication_factor: 1
|
|
194
|
+
ring:
|
|
195
|
+
kvstore:
|
|
196
|
+
store: inmemory
|
|
197
|
+
|
|
198
|
+
schema_config:
|
|
199
|
+
configs:
|
|
200
|
+
- from: 2024-01-01
|
|
201
|
+
store: tsdb
|
|
202
|
+
object_store: filesystem
|
|
203
|
+
schema: v13
|
|
204
|
+
index:
|
|
205
|
+
prefix: index_
|
|
206
|
+
period: 24h
|
|
207
|
+
|
|
208
|
+
limits_config:
|
|
209
|
+
retention_period: ${retention.logs}
|
|
210
|
+
allow_structured_metadata: true
|
|
211
|
+
|
|
212
|
+
compactor:
|
|
213
|
+
working_directory: /loki/compactor
|
|
214
|
+
retention_enabled: true
|
|
215
|
+
delete_request_store: filesystem
|
|
216
|
+
`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Prometheus — accepts remote_write from the collector, scrapes itself. */
|
|
220
|
+
export function generatePrometheusConfig(cfg: DeployConfig): string {
|
|
221
|
+
const retention = pickRetention(cfg.observability);
|
|
222
|
+
return `# Generated by \`arc platform deploy\` — do not edit by hand.
|
|
223
|
+
global:
|
|
224
|
+
scrape_interval: 15s
|
|
225
|
+
evaluation_interval: 15s
|
|
226
|
+
|
|
227
|
+
scrape_configs:
|
|
228
|
+
- job_name: prometheus
|
|
229
|
+
static_configs:
|
|
230
|
+
- targets: [localhost:9090]
|
|
231
|
+
- job_name: otel-collector
|
|
232
|
+
static_configs:
|
|
233
|
+
- targets: [otel-collector:8888]
|
|
234
|
+
|
|
235
|
+
storage:
|
|
236
|
+
tsdb:
|
|
237
|
+
retention.time: ${retention.metrics}
|
|
238
|
+
|
|
239
|
+
# Note: remote-write inbound is enabled via the --web.enable-remote-write-receiver
|
|
240
|
+
# command-line flag (set in docker-compose), not here.
|
|
241
|
+
`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Grafana datasource provisioning — Tempo + Loki + Prometheus, all pre-wired. */
|
|
245
|
+
export function generateGrafanaDatasources(): string {
|
|
246
|
+
return `# Generated by \`arc platform deploy\` — do not edit by hand.
|
|
247
|
+
apiVersion: 1
|
|
248
|
+
datasources:
|
|
249
|
+
- name: Tempo
|
|
250
|
+
type: tempo
|
|
251
|
+
access: proxy
|
|
252
|
+
url: http://tempo:3200
|
|
253
|
+
uid: tempo
|
|
254
|
+
jsonData:
|
|
255
|
+
tracesToLogsV2:
|
|
256
|
+
datasourceUid: loki
|
|
257
|
+
spanStartTimeShift: -5m
|
|
258
|
+
spanEndTimeShift: 5m
|
|
259
|
+
serviceMap:
|
|
260
|
+
datasourceUid: prometheus
|
|
261
|
+
- name: Loki
|
|
262
|
+
type: loki
|
|
263
|
+
access: proxy
|
|
264
|
+
url: http://loki:3100
|
|
265
|
+
uid: loki
|
|
266
|
+
jsonData:
|
|
267
|
+
derivedFields:
|
|
268
|
+
- datasourceUid: tempo
|
|
269
|
+
matcherRegex: "trace_id=(\\\\w+)"
|
|
270
|
+
name: TraceID
|
|
271
|
+
url: $\${__value.raw}
|
|
272
|
+
- name: Prometheus
|
|
273
|
+
type: prometheus
|
|
274
|
+
access: proxy
|
|
275
|
+
url: http://prometheus:9090
|
|
276
|
+
uid: prometheus
|
|
277
|
+
isDefault: true
|
|
278
|
+
`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** All config files needed on the host. Returns map of relative-path → contents
|
|
282
|
+
* so bootstrap can write+upload them in one pass. */
|
|
283
|
+
export function generateObservabilityConfigs(
|
|
284
|
+
cfg: DeployConfig,
|
|
285
|
+
): Record<string, string> {
|
|
286
|
+
return {
|
|
287
|
+
"observability/otel-collector-config.yaml": generateOtelCollectorConfig(cfg),
|
|
288
|
+
"observability/tempo.yaml": generateTempoConfig(cfg),
|
|
289
|
+
"observability/loki-config.yaml": generateLokiConfig(cfg),
|
|
290
|
+
"observability/prometheus.yml": generatePrometheusConfig(cfg),
|
|
291
|
+
"observability/grafana-datasources.yaml": generateGrafanaDatasources(),
|
|
292
|
+
};
|
|
293
|
+
}
|
package/src/platform/server.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
import { existsSync, mkdirSync } from "fs";
|
|
12
12
|
import { join } from "path";
|
|
13
13
|
import { readTranslationsConfig } from "../i18n";
|
|
14
|
-
import type
|
|
14
|
+
import { err, ok, type BuildManifest, type WorkspaceInfo } from "./shared";
|
|
15
15
|
import type { BuildManifestGroup, ModuleAccess } from "@arcote.tech/platform";
|
|
16
16
|
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
@@ -52,17 +52,39 @@ export async function initContextHandler(
|
|
|
52
52
|
context: any,
|
|
53
53
|
dbPath: string,
|
|
54
54
|
): Promise<ContextHandler> {
|
|
55
|
+
const factory = await resolveDbAdapterFactory(dbPath);
|
|
56
|
+
const dbAdapter = factory(context);
|
|
57
|
+
const handler = new ContextHandler(context, dbAdapter);
|
|
58
|
+
await handler.init();
|
|
59
|
+
return handler;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Pick the database adapter factory based on runtime configuration.
|
|
64
|
+
*
|
|
65
|
+
* - When `DATABASE_URL` is set (Postgres sidecar generated by deploy, or any
|
|
66
|
+
* external Postgres the operator wired up themselves), connect over the
|
|
67
|
+
* network using the postgres adapter.
|
|
68
|
+
* - Otherwise fall back to the SQLite file at `dbPath`. The directory is
|
|
69
|
+
* created on demand so first-boot in a fresh data volume just works.
|
|
70
|
+
*
|
|
71
|
+
* Both adapters share the `DBAdapterFactory` contract from arc-core, so the
|
|
72
|
+
* caller never needs to know which backend is live.
|
|
73
|
+
*/
|
|
74
|
+
async function resolveDbAdapterFactory(dbPath: string) {
|
|
75
|
+
const databaseUrl = process.env.DATABASE_URL;
|
|
76
|
+
if (databaseUrl && databaseUrl.length > 0) {
|
|
77
|
+
const { createPostgreSQLAdapterFactoryFromUrl } = await import(
|
|
78
|
+
"@arcote.tech/arc-adapter-db-postgres"
|
|
79
|
+
);
|
|
80
|
+
return createPostgreSQLAdapterFactoryFromUrl(databaseUrl);
|
|
81
|
+
}
|
|
55
82
|
const { createBunSQLiteAdapterFactory } = await import(
|
|
56
83
|
"@arcote.tech/arc-adapter-db-sqlite"
|
|
57
84
|
);
|
|
58
|
-
|
|
59
85
|
const dbDir = dbPath.substring(0, dbPath.lastIndexOf("/"));
|
|
60
86
|
if (dbDir) mkdirSync(dbDir, { recursive: true });
|
|
61
|
-
|
|
62
|
-
const dbAdapter = createBunSQLiteAdapterFactory(dbPath)(context);
|
|
63
|
-
const handler = new ContextHandler(context, dbAdapter);
|
|
64
|
-
await handler.init();
|
|
65
|
-
return handler;
|
|
87
|
+
return createBunSQLiteAdapterFactory(dbPath);
|
|
66
88
|
}
|
|
67
89
|
|
|
68
90
|
// ---------------------------------------------------------------------------
|
|
@@ -75,6 +97,21 @@ export function generateShellHtml(
|
|
|
75
97
|
initial?: { file: string; hash: string },
|
|
76
98
|
stylesHash?: string,
|
|
77
99
|
): string {
|
|
100
|
+
// OpenTelemetry config — injected as a global so the browser SDK chunk
|
|
101
|
+
// (lazy-loaded by start-app.ts) can pick it up without a fetch. Endpoint
|
|
102
|
+
// is same-origin so Caddy can apply CORS + auth uniformly.
|
|
103
|
+
const otelConfig = process.env.ARC_OTEL_ENABLED === "true"
|
|
104
|
+
? {
|
|
105
|
+
enabled: true,
|
|
106
|
+
endpoint: "/otel",
|
|
107
|
+
serviceName: process.env.OTEL_SERVICE_NAME ?? `${appName}-browser`,
|
|
108
|
+
environment: process.env.NODE_ENV === "production" ? "production" : "development",
|
|
109
|
+
sampleRate: Number(process.env.ARC_OTEL_BROWSER_SAMPLE_RATE ?? "0.1"),
|
|
110
|
+
}
|
|
111
|
+
: null;
|
|
112
|
+
const otelTag = otelConfig
|
|
113
|
+
? `\n <script>window.__ARC_OTEL_CONFIG=${JSON.stringify(otelConfig)};</script>`
|
|
114
|
+
: "";
|
|
78
115
|
// Initial bundle carries framework, public modules, and PlatformApp re-export.
|
|
79
116
|
// No importmap — single Bun.build with splitting:true inlines + dedups everything
|
|
80
117
|
// across initial and per-token group bundles via auto-emitted chunk-<hash>.js.
|
|
@@ -95,7 +132,7 @@ export function generateShellHtml(
|
|
|
95
132
|
<title>${manifest?.title ?? appName}</title>${manifest?.favicon ? `\n <link rel="icon" href="${manifest.favicon}">` : ""}${manifest ? `\n <link rel="manifest" href="/manifest.json">` : ""}
|
|
96
133
|
<link rel="stylesheet" href="/styles.css${stylesQs}" />
|
|
97
134
|
<link rel="stylesheet" href="/theme.css${stylesQs}" />
|
|
98
|
-
<link rel="modulepreload" href="${initialUrl}"
|
|
135
|
+
<link rel="modulepreload" href="${initialUrl}" />${otelTag}
|
|
99
136
|
</head>
|
|
100
137
|
<body>
|
|
101
138
|
<div id="root"></div>
|
|
@@ -458,6 +495,30 @@ export async function startPlatformServer(
|
|
|
458
495
|
): Promise<PlatformServer> {
|
|
459
496
|
const { ws, port, devMode, context } = opts;
|
|
460
497
|
ensureModuleSigSecret(ws, !!devMode);
|
|
498
|
+
|
|
499
|
+
// OpenTelemetry — only when explicitly enabled (deploy injects the env
|
|
500
|
+
// when `observability.enabled` is set in deploy.arc.json). Dynamic import
|
|
501
|
+
// keeps the OTel SDK out of bundles that don't use it.
|
|
502
|
+
let telemetry: import("@arcote.tech/arc-otel").ArcTelemetry | undefined;
|
|
503
|
+
let telemetryShutdown: (() => Promise<void>) | undefined;
|
|
504
|
+
if (process.env.ARC_OTEL_ENABLED === "true") {
|
|
505
|
+
try {
|
|
506
|
+
const { initServerTelemetry } = await import("@arcote.tech/arc-otel/server");
|
|
507
|
+
const init = initServerTelemetry({
|
|
508
|
+
serviceName: process.env.OTEL_SERVICE_NAME ?? ws.appName,
|
|
509
|
+
environment: "server",
|
|
510
|
+
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
|
511
|
+
mode: devMode ? "development" : "production",
|
|
512
|
+
sampleRate: devMode ? 1.0 : 1.0, // head-based 100%, collector tail-samples
|
|
513
|
+
});
|
|
514
|
+
telemetry = init.telemetry;
|
|
515
|
+
telemetryShutdown = init.shutdown;
|
|
516
|
+
ok("Telemetry enabled — exporting to " + process.env.OTEL_EXPORTER_OTLP_ENDPOINT);
|
|
517
|
+
} catch (e) {
|
|
518
|
+
err(`Failed to init telemetry: ${(e as Error).message}`);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
461
522
|
const moduleAccessMap = opts.moduleAccess ?? new Map();
|
|
462
523
|
let manifest = opts.manifest;
|
|
463
524
|
const getManifest = () => manifest;
|
|
@@ -536,17 +597,14 @@ export async function startPlatformServer(
|
|
|
536
597
|
};
|
|
537
598
|
}
|
|
538
599
|
|
|
539
|
-
// Context available — use createArcServer with platform handlers on top
|
|
540
|
-
|
|
541
|
-
"@arcote.tech/arc-adapter-db-sqlite"
|
|
542
|
-
);
|
|
600
|
+
// Context available — use createArcServer with platform handlers on top.
|
|
601
|
+
// `resolveDbAdapterFactory` picks SQLite or Postgres based on DATABASE_URL.
|
|
543
602
|
const dbPath = opts.dbPath || join(ws.arcDir, "data", "arc.db");
|
|
544
|
-
const
|
|
545
|
-
if (dbDir) mkdirSync(dbDir, { recursive: true });
|
|
603
|
+
const dbAdapterFactory = await resolveDbAdapterFactory(dbPath);
|
|
546
604
|
|
|
547
605
|
const arcServer = await createArcServer({
|
|
548
606
|
context,
|
|
549
|
-
dbAdapterFactory
|
|
607
|
+
dbAdapterFactory,
|
|
550
608
|
port,
|
|
551
609
|
httpHandlers: [
|
|
552
610
|
// Platform-specific handlers (checked AFTER arc handlers)
|
|
@@ -556,6 +614,7 @@ export async function startPlatformServer(
|
|
|
556
614
|
spaFallbackHandler(getShellHtml),
|
|
557
615
|
],
|
|
558
616
|
onWsClose: (clientId) => cleanupClientSubs(clientId),
|
|
617
|
+
telemetry,
|
|
559
618
|
});
|
|
560
619
|
|
|
561
620
|
return {
|
|
@@ -564,6 +623,9 @@ export async function startPlatformServer(
|
|
|
564
623
|
connectionManager: arcServer.connectionManager,
|
|
565
624
|
setManifest,
|
|
566
625
|
notifyReload,
|
|
567
|
-
stop: () =>
|
|
626
|
+
stop: () => {
|
|
627
|
+
arcServer.stop();
|
|
628
|
+
void telemetryShutdown?.();
|
|
629
|
+
},
|
|
568
630
|
};
|
|
569
631
|
}
|