@envsync-cloud/deploy-cli 0.8.5
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/LICENSE +21 -0
- package/README.md +151 -0
- package/dist/index.js +3531 -0
- package/package.json +55 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3531 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { createHash, randomBytes } from "crypto";
|
|
5
|
+
import { spawnSync } from "child_process";
|
|
6
|
+
import fs2 from "fs";
|
|
7
|
+
import path2 from "path";
|
|
8
|
+
import readline from "readline";
|
|
9
|
+
import chalk from "chalk";
|
|
10
|
+
import YAML from "yaml";
|
|
11
|
+
import { formatDeploymentPlan, loadDeploymentPlanFromFile } from "@envsync-cloud/deploy-core";
|
|
12
|
+
|
|
13
|
+
// src/render.ts
|
|
14
|
+
function domainMap(rootDomain) {
|
|
15
|
+
return {
|
|
16
|
+
landing: rootDomain,
|
|
17
|
+
app: `app.${rootDomain}`,
|
|
18
|
+
api: `api.${rootDomain}`,
|
|
19
|
+
auth: `auth.${rootDomain}`,
|
|
20
|
+
obs: `obs.${rootDomain}`,
|
|
21
|
+
mail: `mail.${rootDomain}`,
|
|
22
|
+
s3: `s3.${rootDomain}`,
|
|
23
|
+
s3Console: `console.s3.${rootDomain}`
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function publicHttpsOrigin(config, host) {
|
|
27
|
+
return `https://${host}${config.services.public_https_port === 443 ? "" : `:${config.services.public_https_port}`}`;
|
|
28
|
+
}
|
|
29
|
+
function publicHttpsUrl(config, host, urlPath = "") {
|
|
30
|
+
return `${publicHttpsOrigin(config, host)}${urlPath}`;
|
|
31
|
+
}
|
|
32
|
+
function publicBucketUrl(config, host, bucket) {
|
|
33
|
+
return publicHttpsUrl(config, host, `/${bucket}`);
|
|
34
|
+
}
|
|
35
|
+
function publicHttpsOriginVariants(config, host) {
|
|
36
|
+
const canonical = `https://${host}`;
|
|
37
|
+
if (config.services.public_https_port === 443) {
|
|
38
|
+
return [canonical];
|
|
39
|
+
}
|
|
40
|
+
return [canonical, publicHttpsOrigin(config, host)];
|
|
41
|
+
}
|
|
42
|
+
function publicHttpsUrlVariants(config, host, urlPath = "") {
|
|
43
|
+
return publicHttpsOriginVariants(config, host).map((origin) => `${origin}${urlPath}`);
|
|
44
|
+
}
|
|
45
|
+
function keycloakImageTag(image) {
|
|
46
|
+
return image.split(":").slice(1).join(":") || "local";
|
|
47
|
+
}
|
|
48
|
+
function slotServiceName(slot) {
|
|
49
|
+
return `envsync_api_${slot}`;
|
|
50
|
+
}
|
|
51
|
+
function slotHasApiDeployment(state) {
|
|
52
|
+
return state.api_image.length > 0;
|
|
53
|
+
}
|
|
54
|
+
function isOssConfig(config) {
|
|
55
|
+
return config.edition === "oss";
|
|
56
|
+
}
|
|
57
|
+
function createSteadyApiDeploymentState(config, generated) {
|
|
58
|
+
const deployment = generated.deployment;
|
|
59
|
+
const activeSlot = deployment.active_slot;
|
|
60
|
+
const activeState = deployment.slots[activeSlot];
|
|
61
|
+
if (slotHasApiDeployment(activeState)) {
|
|
62
|
+
return deployment;
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
active_slot: activeSlot,
|
|
66
|
+
previous_slot: "",
|
|
67
|
+
maintenance_mode: deployment.maintenance_mode,
|
|
68
|
+
slots: {
|
|
69
|
+
...deployment.slots,
|
|
70
|
+
[activeSlot]: {
|
|
71
|
+
api_image: config.images.api,
|
|
72
|
+
release_version: config.release.version,
|
|
73
|
+
deployed_at: deployment.slots[activeSlot].deployed_at
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function buildRuntimeEnv(config, generated) {
|
|
79
|
+
const hosts = domainMap(config.domain.root_domain);
|
|
80
|
+
const bucketName = "envsync-bucket";
|
|
81
|
+
const oss = isOssConfig(config);
|
|
82
|
+
return {
|
|
83
|
+
NODE_ENV: "production",
|
|
84
|
+
ENVSYNC_EDITION: oss ? "oss" : "enterprise",
|
|
85
|
+
ENVSYNC_MANAGEMENT_ENABLED: oss ? "false" : "true",
|
|
86
|
+
ENVSYNC_LANDING_ENABLED: oss ? "false" : "true",
|
|
87
|
+
ENVSYNC_SINGLE_ORG_MODE: oss ? "true" : "false",
|
|
88
|
+
ENVSYNC_LICENSE_ENFORCEMENT: oss ? "false" : "true",
|
|
89
|
+
DB_AUTO_MIGRATE: "false",
|
|
90
|
+
PORT: `${config.services.api_port}`,
|
|
91
|
+
DATABASE_HOST: "postgres",
|
|
92
|
+
DATABASE_PORT: "5432",
|
|
93
|
+
DATABASE_USER: "postgres",
|
|
94
|
+
DATABASE_PASSWORD: "envsync-postgres",
|
|
95
|
+
DATABASE_NAME: "envsync",
|
|
96
|
+
POSTGRES_USER: "postgres",
|
|
97
|
+
POSTGRES_PASSWORD: "envsync-postgres",
|
|
98
|
+
POSTGRES_DB: "envsync",
|
|
99
|
+
S3_BUCKET: bucketName,
|
|
100
|
+
S3_REGION: "us-east-1",
|
|
101
|
+
S3_ACCESS_KEY: "envsync-rustfs",
|
|
102
|
+
S3_SECRET_KEY: generated.secrets.s3_secret_key,
|
|
103
|
+
S3_BUCKET_URL: publicBucketUrl(config, hosts.s3, bucketName),
|
|
104
|
+
S3_ENDPOINT: "http://rustfs:9000",
|
|
105
|
+
REDIS_URL: "redis://redis:6379",
|
|
106
|
+
SMTP_HOST: config.smtp.host,
|
|
107
|
+
SMTP_PORT: `${config.smtp.port}`,
|
|
108
|
+
SMTP_SECURE: `${config.smtp.secure}`,
|
|
109
|
+
SMTP_USER: config.smtp.user,
|
|
110
|
+
SMTP_PASS: config.smtp.pass,
|
|
111
|
+
SMTP_FROM: config.smtp.from,
|
|
112
|
+
KEYCLOAK_URL: "http://keycloak:8080",
|
|
113
|
+
KEYCLOAK_PUBLIC_URL: publicHttpsUrl(config, hosts.auth),
|
|
114
|
+
KEYCLOAK_REALM: config.auth.keycloak_realm,
|
|
115
|
+
KEYCLOAK_ADMIN_USER: config.auth.admin_user,
|
|
116
|
+
KEYCLOAK_ADMIN_PASSWORD: config.auth.admin_password,
|
|
117
|
+
KEYCLOAK_DB_PASSWORD: generated.secrets.keycloak_db_password || config.auth.admin_password,
|
|
118
|
+
KEYCLOAK_WEB_CLIENT_ID: config.auth.web_client_id,
|
|
119
|
+
KEYCLOAK_WEB_CLIENT_SECRET: generated.secrets.keycloak_web_client_secret,
|
|
120
|
+
KEYCLOAK_CLI_CLIENT_ID: config.auth.cli_client_id,
|
|
121
|
+
KEYCLOAK_API_CLIENT_ID: config.auth.api_client_id,
|
|
122
|
+
KEYCLOAK_API_CLIENT_SECRET: generated.secrets.keycloak_api_client_secret,
|
|
123
|
+
KEYCLOAK_WEB_REDIRECT_URI: publicHttpsUrl(config, hosts.api, "/api/access/web/callback"),
|
|
124
|
+
KEYCLOAK_WEB_CALLBACK_URL: publicHttpsUrl(config, hosts.app, "/auth/callback"),
|
|
125
|
+
KEYCLOAK_API_REDIRECT_URI: publicHttpsUrl(config, hosts.api, "/api/access/api/callback"),
|
|
126
|
+
LANDING_PAGE_URL: oss ? "" : publicHttpsUrl(config, hosts.landing),
|
|
127
|
+
DASHBOARD_URL: publicHttpsUrl(config, hosts.app),
|
|
128
|
+
OPENFGA_API_URL: "http://openfga:8090",
|
|
129
|
+
OPENFGA_STORE_ID: generated.openfga.store_id,
|
|
130
|
+
OPENFGA_MODEL_ID: generated.openfga.model_id,
|
|
131
|
+
OPENFGA_DB_PASSWORD: generated.secrets.openfga_db_password,
|
|
132
|
+
CLICKSTACK_OPERATOR_EMAIL: generated.clickstack.operator_email,
|
|
133
|
+
CLICKSTACK_OPERATOR_PASSWORD: generated.clickstack.operator_password,
|
|
134
|
+
CLICKSTACK_ACCESS_KEY: generated.clickstack.access_key,
|
|
135
|
+
CLICKSTACK_BROWSER_API_KEY: generated.clickstack.browser_api_key,
|
|
136
|
+
MINIKMS_GRPC_ADDR: "minikms:50051",
|
|
137
|
+
MINIKMS_TLS_ENABLED: "false",
|
|
138
|
+
MINIKMS_ROOT_KEY: generated.secrets.minikms_root_key,
|
|
139
|
+
MINIKMS_DB_USER: "postgres",
|
|
140
|
+
MINIKMS_DB_PASSWORD: generated.secrets.minikms_db_password,
|
|
141
|
+
OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-agent:4318",
|
|
142
|
+
OTEL_SERVICE_NAME: "envsync-api",
|
|
143
|
+
OTEL_SDK_DISABLED: "false",
|
|
144
|
+
CLICKSTACK_URL: publicHttpsUrl(config, hosts.obs),
|
|
145
|
+
KEYCLOAK_IMAGE_TAG: keycloakImageTag(config.images.keycloak)
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function renderEnvFile(env) {
|
|
149
|
+
return Object.entries(env).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `${key}=${value}`).join("\n") + "\n";
|
|
150
|
+
}
|
|
151
|
+
function renderEnvList(values, indent = 6) {
|
|
152
|
+
const prefix = " ".repeat(indent);
|
|
153
|
+
return Object.entries(values).map(([key, value]) => `${prefix}- ${JSON.stringify(`${key}=${String(value)}`)}`).join("\n");
|
|
154
|
+
}
|
|
155
|
+
function renderKeycloakRealm(config, runtimeEnv) {
|
|
156
|
+
const hosts = domainMap(config.domain.root_domain);
|
|
157
|
+
const webRedirectUris = [
|
|
158
|
+
...publicHttpsUrlVariants(config, hosts.api, "/api/access/web/callback"),
|
|
159
|
+
...publicHttpsUrlVariants(config, hosts.app, "/auth/callback"),
|
|
160
|
+
...publicHttpsUrlVariants(config, hosts.app)
|
|
161
|
+
];
|
|
162
|
+
const webOrigins = publicHttpsOriginVariants(config, hosts.app);
|
|
163
|
+
const apiRedirectUris = publicHttpsUrlVariants(config, hosts.api, "/api/access/api/callback");
|
|
164
|
+
const apiOrigins = publicHttpsOriginVariants(config, hosts.api);
|
|
165
|
+
return JSON.stringify(
|
|
166
|
+
{
|
|
167
|
+
realm: config.auth.keycloak_realm,
|
|
168
|
+
enabled: true,
|
|
169
|
+
loginTheme: "envsync",
|
|
170
|
+
emailTheme: "envsync",
|
|
171
|
+
clients: [
|
|
172
|
+
{
|
|
173
|
+
clientId: config.auth.web_client_id,
|
|
174
|
+
name: "EnvSync Web",
|
|
175
|
+
protocol: "openid-connect",
|
|
176
|
+
publicClient: false,
|
|
177
|
+
secret: runtimeEnv.KEYCLOAK_WEB_CLIENT_SECRET,
|
|
178
|
+
standardFlowEnabled: true,
|
|
179
|
+
directAccessGrantsEnabled: false,
|
|
180
|
+
redirectUris: [...new Set(webRedirectUris)],
|
|
181
|
+
webOrigins: [...new Set(webOrigins)],
|
|
182
|
+
attributes: {
|
|
183
|
+
"post.logout.redirect.uris": "+"
|
|
184
|
+
},
|
|
185
|
+
defaultClientScopes: ["basic", "web-origins", "profile", "email", "roles"]
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
clientId: config.auth.api_client_id,
|
|
189
|
+
name: "EnvSync API",
|
|
190
|
+
protocol: "openid-connect",
|
|
191
|
+
publicClient: false,
|
|
192
|
+
secret: runtimeEnv.KEYCLOAK_API_CLIENT_SECRET,
|
|
193
|
+
standardFlowEnabled: true,
|
|
194
|
+
redirectUris: [...new Set(apiRedirectUris)],
|
|
195
|
+
webOrigins: [...new Set(apiOrigins)],
|
|
196
|
+
defaultClientScopes: ["basic", "profile", "email", "roles"]
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
clientId: config.auth.cli_client_id,
|
|
200
|
+
name: "EnvSync CLI",
|
|
201
|
+
protocol: "openid-connect",
|
|
202
|
+
publicClient: true,
|
|
203
|
+
standardFlowEnabled: false,
|
|
204
|
+
directAccessGrantsEnabled: false,
|
|
205
|
+
attributes: {
|
|
206
|
+
"oauth2.device.authorization.grant.enabled": "true"
|
|
207
|
+
},
|
|
208
|
+
defaultClientScopes: ["basic", "profile", "email", "roles"]
|
|
209
|
+
}
|
|
210
|
+
]
|
|
211
|
+
},
|
|
212
|
+
null,
|
|
213
|
+
2
|
|
214
|
+
) + "\n";
|
|
215
|
+
}
|
|
216
|
+
function renderTraefikDynamicConfig(config, generated) {
|
|
217
|
+
const hosts = domainMap(config.domain.root_domain);
|
|
218
|
+
const activeSlot = generated.deployment.active_slot;
|
|
219
|
+
const apiServiceName = generated.deployment.maintenance_mode ? "envsync-api-maintenance" : "envsync-api";
|
|
220
|
+
const landingEnabled = !isOssConfig(config);
|
|
221
|
+
const otelAllowedOrigins = [
|
|
222
|
+
...landingEnabled ? publicHttpsOriginVariants(config, hosts.landing) : [],
|
|
223
|
+
...publicHttpsOriginVariants(config, hosts.app)
|
|
224
|
+
];
|
|
225
|
+
return [
|
|
226
|
+
"http:",
|
|
227
|
+
" middlewares:",
|
|
228
|
+
" secure-headers:",
|
|
229
|
+
" headers:",
|
|
230
|
+
" browserXssFilter: true",
|
|
231
|
+
" contentTypeNosniff: true",
|
|
232
|
+
" forceSTSHeader: true",
|
|
233
|
+
" stsSeconds: 31536000",
|
|
234
|
+
" gzip:",
|
|
235
|
+
" compress: {}",
|
|
236
|
+
" otel-cors:",
|
|
237
|
+
" headers:",
|
|
238
|
+
" accessControlAllowOriginList:",
|
|
239
|
+
...otelAllowedOrigins.map((origin) => ` - ${origin}`),
|
|
240
|
+
" accessControlAllowMethods:",
|
|
241
|
+
" - POST",
|
|
242
|
+
" - OPTIONS",
|
|
243
|
+
" accessControlAllowHeaders:",
|
|
244
|
+
" - Content-Type",
|
|
245
|
+
" - content-type",
|
|
246
|
+
" - Content-Encoding",
|
|
247
|
+
" - content-encoding",
|
|
248
|
+
" - Authorization",
|
|
249
|
+
" - authorization",
|
|
250
|
+
" accessControlAllowCredentials: true",
|
|
251
|
+
" accessControlMaxAge: 600",
|
|
252
|
+
" addVaryHeader: true",
|
|
253
|
+
" services:",
|
|
254
|
+
" envsync-api:",
|
|
255
|
+
" loadBalancer:",
|
|
256
|
+
" healthCheck:",
|
|
257
|
+
" path: /health",
|
|
258
|
+
" interval: 5s",
|
|
259
|
+
" timeout: 3s",
|
|
260
|
+
" servers:",
|
|
261
|
+
` - url: http://${slotServiceName(activeSlot)}:4000`,
|
|
262
|
+
" envsync-api-blue:",
|
|
263
|
+
" loadBalancer:",
|
|
264
|
+
" healthCheck:",
|
|
265
|
+
" path: /health",
|
|
266
|
+
" interval: 5s",
|
|
267
|
+
" timeout: 3s",
|
|
268
|
+
" servers:",
|
|
269
|
+
" - url: http://envsync_api_blue:4000",
|
|
270
|
+
" envsync-api-green:",
|
|
271
|
+
" loadBalancer:",
|
|
272
|
+
" healthCheck:",
|
|
273
|
+
" path: /health",
|
|
274
|
+
" interval: 5s",
|
|
275
|
+
" timeout: 3s",
|
|
276
|
+
" servers:",
|
|
277
|
+
" - url: http://envsync_api_green:4000",
|
|
278
|
+
" envsync-api-maintenance:",
|
|
279
|
+
" loadBalancer:",
|
|
280
|
+
" servers:",
|
|
281
|
+
" - url: http://api_maintenance:8080",
|
|
282
|
+
...landingEnabled ? [
|
|
283
|
+
" landing:",
|
|
284
|
+
" loadBalancer:",
|
|
285
|
+
" servers:",
|
|
286
|
+
" - url: http://landing_nginx:8080"
|
|
287
|
+
] : [],
|
|
288
|
+
" web:",
|
|
289
|
+
" loadBalancer:",
|
|
290
|
+
" servers:",
|
|
291
|
+
" - url: http://web_nginx:8080",
|
|
292
|
+
" clickstack-ui:",
|
|
293
|
+
" loadBalancer:",
|
|
294
|
+
" servers:",
|
|
295
|
+
" - url: http://clickstack:8080",
|
|
296
|
+
" clickstack-otlp:",
|
|
297
|
+
" loadBalancer:",
|
|
298
|
+
" servers:",
|
|
299
|
+
` - url: http://clickstack:${config.services.clickstack_otlp_http_port}`,
|
|
300
|
+
" routers:",
|
|
301
|
+
...landingEnabled ? [
|
|
302
|
+
" landing-router:",
|
|
303
|
+
` rule: Host(\`${hosts.landing}\`)`,
|
|
304
|
+
" service: landing",
|
|
305
|
+
" entryPoints: [websecure]",
|
|
306
|
+
" tls:",
|
|
307
|
+
" certResolver: letsencrypt"
|
|
308
|
+
] : [],
|
|
309
|
+
" web-router:",
|
|
310
|
+
` rule: Host(\`${hosts.app}\`)`,
|
|
311
|
+
" service: web",
|
|
312
|
+
" entryPoints: [websecure]",
|
|
313
|
+
" tls:",
|
|
314
|
+
" certResolver: letsencrypt",
|
|
315
|
+
" obs-otlp-router:",
|
|
316
|
+
` rule: Host(\`${hosts.obs}\`) && (PathPrefix(\`/v1/traces\`) || PathPrefix(\`/v1/logs\`) || PathPrefix(\`/v1/metrics\`))`,
|
|
317
|
+
" service: clickstack-otlp",
|
|
318
|
+
" middlewares: [otel-cors]",
|
|
319
|
+
" priority: 100",
|
|
320
|
+
" entryPoints: [websecure]",
|
|
321
|
+
" tls:",
|
|
322
|
+
" certResolver: letsencrypt",
|
|
323
|
+
" obs-api-router:",
|
|
324
|
+
` rule: Host(\`${hosts.obs}\`) && PathPrefix(\`/api\`)`,
|
|
325
|
+
" service: clickstack-ui",
|
|
326
|
+
" priority: 90",
|
|
327
|
+
" entryPoints: [websecure]",
|
|
328
|
+
" tls:",
|
|
329
|
+
" certResolver: letsencrypt",
|
|
330
|
+
" obs-ui-router:",
|
|
331
|
+
` rule: Host(\`${hosts.obs}\`)`,
|
|
332
|
+
" service: clickstack-ui",
|
|
333
|
+
" priority: 10",
|
|
334
|
+
" entryPoints: [websecure]",
|
|
335
|
+
" tls:",
|
|
336
|
+
" certResolver: letsencrypt",
|
|
337
|
+
" api-router:",
|
|
338
|
+
` rule: Host(\`${hosts.api}\`)`,
|
|
339
|
+
` service: ${apiServiceName}`,
|
|
340
|
+
" entryPoints: [websecure]",
|
|
341
|
+
" tls:",
|
|
342
|
+
" certResolver: letsencrypt"
|
|
343
|
+
].join("\n") + "\n";
|
|
344
|
+
}
|
|
345
|
+
function renderNginxConf(kind) {
|
|
346
|
+
return [
|
|
347
|
+
"server {",
|
|
348
|
+
" listen 8080;",
|
|
349
|
+
" server_name _;",
|
|
350
|
+
` root /srv/${kind};`,
|
|
351
|
+
" index index.html;",
|
|
352
|
+
" location = /runtime-config.js {",
|
|
353
|
+
' add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;',
|
|
354
|
+
' add_header Pragma "no-cache" always;',
|
|
355
|
+
' add_header Expires "0" always;',
|
|
356
|
+
" try_files /runtime-config.js =404;",
|
|
357
|
+
" }",
|
|
358
|
+
" location = /index.html {",
|
|
359
|
+
' add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;',
|
|
360
|
+
' add_header Pragma "no-cache" always;',
|
|
361
|
+
' add_header Expires "0" always;',
|
|
362
|
+
" try_files /index.html =404;",
|
|
363
|
+
" }",
|
|
364
|
+
" location / {",
|
|
365
|
+
" try_files $uri $uri/ /index.html;",
|
|
366
|
+
" }",
|
|
367
|
+
"}"
|
|
368
|
+
].join("\n") + "\n";
|
|
369
|
+
}
|
|
370
|
+
function renderApiMaintenanceConf() {
|
|
371
|
+
return [
|
|
372
|
+
"server {",
|
|
373
|
+
" listen 8080;",
|
|
374
|
+
" server_name _;",
|
|
375
|
+
" location / {",
|
|
376
|
+
" default_type application/json;",
|
|
377
|
+
' add_header Cache-Control "no-store" always;',
|
|
378
|
+
` return 503 '{"error":"Upgrade in progress. Please retry shortly."}';`,
|
|
379
|
+
" }",
|
|
380
|
+
"}"
|
|
381
|
+
].join("\n") + "\n";
|
|
382
|
+
}
|
|
383
|
+
function renderFrontendRuntimeConfig(config, generated) {
|
|
384
|
+
const hosts = domainMap(config.domain.root_domain);
|
|
385
|
+
const otelEndpoint = publicHttpsUrl(config, hosts.obs);
|
|
386
|
+
const activeReleaseVersion = generated.deployment.slots[generated.deployment.active_slot].release_version || config.release.version;
|
|
387
|
+
return `window.__ENVSYNC_RUNTIME_CONFIG__ = ${JSON.stringify({
|
|
388
|
+
apiBaseUrl: publicHttpsUrl(config, hosts.api),
|
|
389
|
+
appBaseUrl: publicHttpsUrl(config, hosts.app),
|
|
390
|
+
authBaseUrl: publicHttpsUrl(config, hosts.auth),
|
|
391
|
+
keycloakRealm: config.auth.keycloak_realm,
|
|
392
|
+
webClientId: config.auth.web_client_id,
|
|
393
|
+
apiDocsUrl: publicHttpsUrl(config, hosts.api, "/docs"),
|
|
394
|
+
otelEndpoint,
|
|
395
|
+
hyperdxApiKey: generated.clickstack.browser_api_key || void 0,
|
|
396
|
+
hyperdxUrl: otelEndpoint,
|
|
397
|
+
hyperdxDisabled: generated.clickstack.browser_api_key.length === 0,
|
|
398
|
+
hyperdxAdvancedNetworkCapture: false,
|
|
399
|
+
releaseVersion: activeReleaseVersion,
|
|
400
|
+
activeApiSlot: generated.deployment.active_slot
|
|
401
|
+
}, null, 2)};
|
|
402
|
+
`;
|
|
403
|
+
}
|
|
404
|
+
function renderOtelAgentConfig(config) {
|
|
405
|
+
return [
|
|
406
|
+
"receivers:",
|
|
407
|
+
" otlp:",
|
|
408
|
+
" protocols:",
|
|
409
|
+
" grpc:",
|
|
410
|
+
" endpoint: 0.0.0.0:4317",
|
|
411
|
+
" http:",
|
|
412
|
+
" endpoint: 0.0.0.0:4318",
|
|
413
|
+
"processors:",
|
|
414
|
+
" batch: {}",
|
|
415
|
+
" resource:",
|
|
416
|
+
" attributes:",
|
|
417
|
+
" - key: deployment.environment",
|
|
418
|
+
" value: production",
|
|
419
|
+
" action: upsert",
|
|
420
|
+
"exporters:",
|
|
421
|
+
" otlphttp/clickstack:",
|
|
422
|
+
` endpoint: http://clickstack:${config.services.clickstack_otlp_http_port}`,
|
|
423
|
+
"service:",
|
|
424
|
+
" pipelines:",
|
|
425
|
+
" traces:",
|
|
426
|
+
" receivers: [otlp]",
|
|
427
|
+
" processors: [resource, batch]",
|
|
428
|
+
" exporters: [otlphttp/clickstack]",
|
|
429
|
+
" logs:",
|
|
430
|
+
" receivers: [otlp]",
|
|
431
|
+
" processors: [resource, batch]",
|
|
432
|
+
" exporters: [otlphttp/clickstack]",
|
|
433
|
+
" metrics:",
|
|
434
|
+
" receivers: [otlp]",
|
|
435
|
+
" processors: [resource, batch]",
|
|
436
|
+
" exporters: [otlphttp/clickstack]"
|
|
437
|
+
].join("\n") + "\n";
|
|
438
|
+
}
|
|
439
|
+
function renderClickstackClickHouseConfig() {
|
|
440
|
+
return [
|
|
441
|
+
"<clickhouse>",
|
|
442
|
+
" <listen_host>0.0.0.0</listen_host>",
|
|
443
|
+
" <listen_try>1</listen_try>",
|
|
444
|
+
"</clickhouse>"
|
|
445
|
+
].join("\n") + "\n";
|
|
446
|
+
}
|
|
447
|
+
function renderStack(config, runtimeEnv, generated, mode, paths) {
|
|
448
|
+
const hosts = domainMap(config.domain.root_domain);
|
|
449
|
+
const includeRuntimeInfra = mode !== "base";
|
|
450
|
+
const includeAppServices = mode === "full";
|
|
451
|
+
const landingEnabled = !isOssConfig(config);
|
|
452
|
+
const deployment = createSteadyApiDeploymentState(config, generated);
|
|
453
|
+
const stackName = config.services.stack_name;
|
|
454
|
+
const s3RouterName = `${stackName}-s3-router`;
|
|
455
|
+
const s3ServiceName = `${stackName}-s3-service`;
|
|
456
|
+
const s3ConsoleRouterName = `${stackName}-s3-console-router`;
|
|
457
|
+
const s3ConsoleServiceName = `${stackName}-s3-console-service`;
|
|
458
|
+
const apiEnvironment = {
|
|
459
|
+
...runtimeEnv,
|
|
460
|
+
OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-agent:4318",
|
|
461
|
+
KEYCLOAK_URL: "http://keycloak:8080",
|
|
462
|
+
OPENFGA_API_URL: "http://openfga:8090",
|
|
463
|
+
MINIKMS_GRPC_ADDR: "minikms:50051",
|
|
464
|
+
S3_ENDPOINT: "http://rustfs:9000",
|
|
465
|
+
S3_BUCKET_URL: publicBucketUrl(config, hosts.s3, runtimeEnv.S3_BUCKET)
|
|
466
|
+
};
|
|
467
|
+
return `
|
|
468
|
+
version: "3.9"
|
|
469
|
+
services:
|
|
470
|
+
traefik:
|
|
471
|
+
image: ${config.images.traefik}
|
|
472
|
+
command:
|
|
473
|
+
- --providers.swarm=true
|
|
474
|
+
- --providers.swarm.endpoint=unix:///var/run/docker.sock
|
|
475
|
+
- --providers.swarm.exposedByDefault=false
|
|
476
|
+
- --providers.file.filename=/etc/traefik/dynamic/traefik-dynamic.yaml
|
|
477
|
+
- --entrypoints.web.address=:80
|
|
478
|
+
- --entrypoints.web.http.redirections.entryPoint.to=websecure
|
|
479
|
+
- --entrypoints.web.http.redirections.entryPoint.scheme=https
|
|
480
|
+
- --entrypoints.web.http.redirections.entryPoint.permanent=true
|
|
481
|
+
- --entrypoints.websecure.address=:443
|
|
482
|
+
- --certificatesresolvers.letsencrypt.acme.email=${config.domain.acme_email}
|
|
483
|
+
- --certificatesresolvers.letsencrypt.acme.storage=/var/lib/traefik/acme.json
|
|
484
|
+
- --certificatesresolvers.letsencrypt.acme.httpchallenge=true
|
|
485
|
+
- --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
|
|
486
|
+
ports:
|
|
487
|
+
- target: 80
|
|
488
|
+
published: ${config.services.public_http_port}
|
|
489
|
+
protocol: tcp
|
|
490
|
+
mode: host
|
|
491
|
+
- target: 443
|
|
492
|
+
published: ${config.services.public_https_port}
|
|
493
|
+
protocol: tcp
|
|
494
|
+
mode: host
|
|
495
|
+
volumes:
|
|
496
|
+
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
497
|
+
- ${paths.traefikStateRoot}:/var/lib/traefik
|
|
498
|
+
- ${paths.deployRoot}:/etc/traefik/dynamic:ro
|
|
499
|
+
networks: [envsync]
|
|
500
|
+
|
|
501
|
+
postgres:
|
|
502
|
+
image: postgres:17
|
|
503
|
+
environment:
|
|
504
|
+
${renderEnvList({
|
|
505
|
+
POSTGRES_USER: "postgres",
|
|
506
|
+
POSTGRES_PASSWORD: "envsync-postgres",
|
|
507
|
+
POSTGRES_DB: "envsync"
|
|
508
|
+
})}
|
|
509
|
+
volumes:
|
|
510
|
+
- postgres_data:/var/lib/postgresql/data
|
|
511
|
+
networks: [envsync]
|
|
512
|
+
|
|
513
|
+
redis:
|
|
514
|
+
image: redis:7
|
|
515
|
+
volumes:
|
|
516
|
+
- redis_data:/data
|
|
517
|
+
networks: [envsync]
|
|
518
|
+
|
|
519
|
+
rustfs:
|
|
520
|
+
image: rustfs/rustfs:latest
|
|
521
|
+
environment:
|
|
522
|
+
${renderEnvList({
|
|
523
|
+
RUSTFS_DATA_DIR: "/data",
|
|
524
|
+
RUSTFS_ACCESS_KEY: "envsync-rustfs",
|
|
525
|
+
RUSTFS_SECRET_KEY: runtimeEnv.S3_SECRET_KEY,
|
|
526
|
+
RUSTFS_CONSOLE_ENABLE: "true"
|
|
527
|
+
})}
|
|
528
|
+
volumes:
|
|
529
|
+
- rustfs_data:/data
|
|
530
|
+
networks: [envsync]
|
|
531
|
+
deploy:
|
|
532
|
+
labels:
|
|
533
|
+
- traefik.enable=true
|
|
534
|
+
- traefik.http.routers.${s3RouterName}.rule=Host(\`${hosts.s3}\`)
|
|
535
|
+
- traefik.http.routers.${s3RouterName}.entrypoints=websecure
|
|
536
|
+
- traefik.http.routers.${s3RouterName}.tls.certresolver=letsencrypt
|
|
537
|
+
- traefik.http.routers.${s3RouterName}.service=${s3ServiceName}
|
|
538
|
+
- traefik.http.services.${s3ServiceName}.loadbalancer.server.port=9000
|
|
539
|
+
- traefik.http.routers.${s3ConsoleRouterName}.rule=Host(\`${hosts.s3Console}\`)
|
|
540
|
+
- traefik.http.routers.${s3ConsoleRouterName}.entrypoints=websecure
|
|
541
|
+
- traefik.http.routers.${s3ConsoleRouterName}.tls.certresolver=letsencrypt
|
|
542
|
+
- traefik.http.routers.${s3ConsoleRouterName}.service=${s3ConsoleServiceName}
|
|
543
|
+
- traefik.http.services.${s3ConsoleServiceName}.loadbalancer.server.port=9001
|
|
544
|
+
|
|
545
|
+
keycloak_db:
|
|
546
|
+
image: postgres:17
|
|
547
|
+
environment:
|
|
548
|
+
${renderEnvList({
|
|
549
|
+
POSTGRES_USER: "keycloak",
|
|
550
|
+
POSTGRES_PASSWORD: runtimeEnv.KEYCLOAK_DB_PASSWORD,
|
|
551
|
+
POSTGRES_DB: "keycloak"
|
|
552
|
+
})}
|
|
553
|
+
volumes:
|
|
554
|
+
- keycloak_db_data:/var/lib/postgresql/data
|
|
555
|
+
networks: [envsync]
|
|
556
|
+
${includeRuntimeInfra ? `
|
|
557
|
+
keycloak:
|
|
558
|
+
image: ${config.images.keycloak}
|
|
559
|
+
entrypoint: ["/bin/sh", "-lc"]
|
|
560
|
+
command:
|
|
561
|
+
- /opt/keycloak/bin/kc.sh import --dir /opt/keycloak/data/import --override true && exec /opt/keycloak/bin/kc.sh start --optimized
|
|
562
|
+
environment:
|
|
563
|
+
${renderEnvList({
|
|
564
|
+
KC_DB: "postgres",
|
|
565
|
+
KC_DB_URL: "jdbc:postgresql://keycloak_db:5432/keycloak",
|
|
566
|
+
KC_DB_USERNAME: "keycloak",
|
|
567
|
+
KC_DB_PASSWORD: runtimeEnv.KEYCLOAK_DB_PASSWORD,
|
|
568
|
+
KC_BOOTSTRAP_ADMIN_USERNAME: config.auth.admin_user,
|
|
569
|
+
KC_BOOTSTRAP_ADMIN_PASSWORD: config.auth.admin_password,
|
|
570
|
+
KC_HTTP_ENABLED: "true",
|
|
571
|
+
KC_HEALTH_ENABLED: "true",
|
|
572
|
+
KC_PROXY_HEADERS: "xforwarded",
|
|
573
|
+
KC_HOSTNAME: hosts.auth,
|
|
574
|
+
KC_HOSTNAME_STRICT: "false"
|
|
575
|
+
})}
|
|
576
|
+
configs:
|
|
577
|
+
- source: keycloak_realm
|
|
578
|
+
target: /opt/keycloak/data/import/realm.json
|
|
579
|
+
networks: [envsync]
|
|
580
|
+
deploy:
|
|
581
|
+
labels:
|
|
582
|
+
- traefik.enable=true
|
|
583
|
+
- traefik.http.routers.keycloak.rule=Host(\`${hosts.auth}\`)
|
|
584
|
+
- traefik.http.routers.keycloak.entrypoints=websecure
|
|
585
|
+
- traefik.http.routers.keycloak.tls.certresolver=letsencrypt
|
|
586
|
+
- traefik.http.services.keycloak.loadbalancer.server.port=8080` : ""}
|
|
587
|
+
|
|
588
|
+
openfga_db:
|
|
589
|
+
image: postgres:17
|
|
590
|
+
environment:
|
|
591
|
+
${renderEnvList({
|
|
592
|
+
POSTGRES_USER: "openfga",
|
|
593
|
+
POSTGRES_PASSWORD: runtimeEnv.OPENFGA_DB_PASSWORD,
|
|
594
|
+
POSTGRES_DB: "openfga"
|
|
595
|
+
})}
|
|
596
|
+
volumes:
|
|
597
|
+
- openfga_db_data:/var/lib/postgresql/data
|
|
598
|
+
networks: [envsync]
|
|
599
|
+
${includeRuntimeInfra ? `
|
|
600
|
+
openfga:
|
|
601
|
+
image: openfga/openfga:v1.12.0
|
|
602
|
+
command: run
|
|
603
|
+
environment:
|
|
604
|
+
${renderEnvList({
|
|
605
|
+
OPENFGA_DATASTORE_ENGINE: "postgres",
|
|
606
|
+
OPENFGA_DATASTORE_URI: `postgres://openfga:${runtimeEnv.OPENFGA_DB_PASSWORD}@openfga_db:5432/openfga?sslmode=disable`,
|
|
607
|
+
OPENFGA_HTTP_ADDR: "0.0.0.0:8090",
|
|
608
|
+
OPENFGA_GRPC_ADDR: "0.0.0.0:8091"
|
|
609
|
+
})}
|
|
610
|
+
networks: [envsync]` : ""}
|
|
611
|
+
|
|
612
|
+
minikms_db:
|
|
613
|
+
image: postgres:17
|
|
614
|
+
environment:
|
|
615
|
+
${renderEnvList({
|
|
616
|
+
POSTGRES_USER: "postgres",
|
|
617
|
+
POSTGRES_PASSWORD: runtimeEnv.MINIKMS_DB_PASSWORD,
|
|
618
|
+
POSTGRES_DB: "minikms"
|
|
619
|
+
})}
|
|
620
|
+
volumes:
|
|
621
|
+
- minikms_db_data:/var/lib/postgresql/data
|
|
622
|
+
networks: [envsync]
|
|
623
|
+
${includeRuntimeInfra ? `
|
|
624
|
+
minikms:
|
|
625
|
+
image: ghcr.io/envsync-cloud/minikms:sha-735dfe8
|
|
626
|
+
environment:
|
|
627
|
+
${renderEnvList({
|
|
628
|
+
MINIKMS_ROOT_KEY: runtimeEnv.MINIKMS_ROOT_KEY,
|
|
629
|
+
MINIKMS_DB_URL: `postgres://postgres:${runtimeEnv.MINIKMS_DB_PASSWORD}@minikms_db:5432/minikms?sslmode=disable`,
|
|
630
|
+
MINIKMS_REDIS_URL: "redis://redis:6379",
|
|
631
|
+
MINIKMS_GRPC_ADDR: "0.0.0.0:50051",
|
|
632
|
+
MINIKMS_TLS_ENABLED: "false"
|
|
633
|
+
})}
|
|
634
|
+
networks: [envsync]` : ""}
|
|
635
|
+
|
|
636
|
+
clickstack:
|
|
637
|
+
image: ${config.images.clickstack}
|
|
638
|
+
environment:
|
|
639
|
+
${renderEnvList({
|
|
640
|
+
HYPERDX_APP_URL: publicHttpsUrl(config, hosts.obs),
|
|
641
|
+
HYPERDX_API_URL: publicHttpsUrl(config, hosts.obs),
|
|
642
|
+
FRONTEND_URL: publicHttpsUrl(config, hosts.obs)
|
|
643
|
+
})}
|
|
644
|
+
volumes:
|
|
645
|
+
- clickstack_data:/data/db
|
|
646
|
+
- clickstack_ch_data:/var/lib/clickhouse
|
|
647
|
+
- clickstack_ch_logs:/var/log/clickhouse-server
|
|
648
|
+
configs:
|
|
649
|
+
- source: clickstack_clickhouse_conf
|
|
650
|
+
target: /etc/clickhouse-server/config.d/envsync-listen-host.xml
|
|
651
|
+
networks: [envsync]
|
|
652
|
+
healthcheck:
|
|
653
|
+
disable: true
|
|
654
|
+
|
|
655
|
+
otel-agent:
|
|
656
|
+
image: ${config.images.otel_agent}
|
|
657
|
+
command: ["--config=/etc/otel-agent.yaml"]
|
|
658
|
+
configs:
|
|
659
|
+
- source: otel_agent_conf
|
|
660
|
+
target: /etc/otel-agent.yaml
|
|
661
|
+
networks: [envsync]
|
|
662
|
+
${includeAppServices ? `
|
|
663
|
+
|
|
664
|
+
api_maintenance:
|
|
665
|
+
image: nginx:1.27-alpine
|
|
666
|
+
configs:
|
|
667
|
+
- source: nginx_api_maintenance_conf
|
|
668
|
+
target: /etc/nginx/conf.d/default.conf
|
|
669
|
+
networks: [envsync]
|
|
670
|
+
|
|
671
|
+
${landingEnabled ? `
|
|
672
|
+
landing_nginx:
|
|
673
|
+
image: nginx:1.27-alpine
|
|
674
|
+
configs:
|
|
675
|
+
- source: nginx_landing_conf
|
|
676
|
+
target: /etc/nginx/conf.d/default.conf
|
|
677
|
+
volumes:
|
|
678
|
+
- ${paths.releasesRoot}/landing/current:/srv/landing:ro
|
|
679
|
+
networks: [envsync]` : ""}
|
|
680
|
+
|
|
681
|
+
web_nginx:
|
|
682
|
+
image: nginx:1.27-alpine
|
|
683
|
+
configs:
|
|
684
|
+
- source: nginx_web_conf
|
|
685
|
+
target: /etc/nginx/conf.d/default.conf
|
|
686
|
+
volumes:
|
|
687
|
+
- ${paths.releasesRoot}/web/current:/srv/web:ro
|
|
688
|
+
networks: [envsync]
|
|
689
|
+
|
|
690
|
+
envsync_api_blue:
|
|
691
|
+
image: ${deployment.slots.blue.api_image || config.images.api}
|
|
692
|
+
environment:
|
|
693
|
+
${renderEnvList({
|
|
694
|
+
...apiEnvironment,
|
|
695
|
+
ENVSYNC_DEPLOY_SLOT: "blue",
|
|
696
|
+
ENVSYNC_DEPLOY_RELEASE_VERSION: deployment.slots.blue.release_version || config.release.version
|
|
697
|
+
})}
|
|
698
|
+
networks: [envsync]
|
|
699
|
+
deploy:
|
|
700
|
+
replicas: ${slotHasApiDeployment(deployment.slots.blue) ? 1 : 0}
|
|
701
|
+
|
|
702
|
+
envsync_api_green:
|
|
703
|
+
image: ${deployment.slots.green.api_image || config.images.api}
|
|
704
|
+
environment:
|
|
705
|
+
${renderEnvList({
|
|
706
|
+
...apiEnvironment,
|
|
707
|
+
ENVSYNC_DEPLOY_SLOT: "green",
|
|
708
|
+
ENVSYNC_DEPLOY_RELEASE_VERSION: deployment.slots.green.release_version || config.release.version
|
|
709
|
+
})}
|
|
710
|
+
networks: [envsync]
|
|
711
|
+
deploy:
|
|
712
|
+
replicas: ${slotHasApiDeployment(deployment.slots.green) ? 1 : 0}` : ""}
|
|
713
|
+
|
|
714
|
+
networks:
|
|
715
|
+
envsync:
|
|
716
|
+
driver: overlay
|
|
717
|
+
attachable: true
|
|
718
|
+
|
|
719
|
+
volumes:
|
|
720
|
+
postgres_data:
|
|
721
|
+
redis_data:
|
|
722
|
+
rustfs_data:
|
|
723
|
+
keycloak_db_data:
|
|
724
|
+
openfga_db_data:
|
|
725
|
+
minikms_db_data:
|
|
726
|
+
clickstack_data:
|
|
727
|
+
clickstack_ch_data:
|
|
728
|
+
clickstack_ch_logs:
|
|
729
|
+
|
|
730
|
+
configs:
|
|
731
|
+
keycloak_realm:
|
|
732
|
+
file: ${paths.keycloakRealmFile}
|
|
733
|
+
clickstack_clickhouse_conf:
|
|
734
|
+
file: ${paths.clickstackClickhouseConf}
|
|
735
|
+
otel_agent_conf:
|
|
736
|
+
file: ${paths.otelAgentConf}
|
|
737
|
+
${landingEnabled ? ` nginx_landing_conf:
|
|
738
|
+
file: ${paths.nginxLandingConf}
|
|
739
|
+
` : ""}
|
|
740
|
+
nginx_web_conf:
|
|
741
|
+
file: ${paths.nginxWebConf}
|
|
742
|
+
nginx_api_maintenance_conf:
|
|
743
|
+
file: ${paths.nginxApiMaintenanceConf}
|
|
744
|
+
`.trimStart();
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// src/static-bundle.ts
|
|
748
|
+
import fs from "fs";
|
|
749
|
+
import path from "path";
|
|
750
|
+
function exists(target) {
|
|
751
|
+
return fs.existsSync(target);
|
|
752
|
+
}
|
|
753
|
+
function normalizeExtractedStaticBundle(kind, targetDir) {
|
|
754
|
+
const directIndex = path.join(targetDir, "index.html");
|
|
755
|
+
if (exists(directIndex)) {
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
const childDirs = fs.readdirSync(targetDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => path.join(targetDir, entry.name));
|
|
759
|
+
const candidateRoots = childDirs.filter((dir) => exists(path.join(dir, "index.html")));
|
|
760
|
+
if (candidateRoots.length !== 1) {
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
const nestedRoot = candidateRoots[0];
|
|
764
|
+
for (const entry of fs.readdirSync(nestedRoot)) {
|
|
765
|
+
fs.cpSync(path.join(nestedRoot, entry), path.join(targetDir, entry), { recursive: true });
|
|
766
|
+
}
|
|
767
|
+
fs.rmSync(nestedRoot, { recursive: true, force: true });
|
|
768
|
+
}
|
|
769
|
+
function validateStaticBundle(kind, targetDir) {
|
|
770
|
+
if (exists(path.join(targetDir, "index.html"))) {
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
const entries = exists(targetDir) ? fs.readdirSync(targetDir).slice(0, 20).join(", ") : "";
|
|
774
|
+
throw new Error(
|
|
775
|
+
`Invalid ${kind} static bundle at ${targetDir}: missing index.html${entries ? `. Found: ${entries}` : ""}`
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// src/index.ts
|
|
780
|
+
var HOST_ROOT = process.env.ENVSYNC_HOST_ROOT ?? "/opt/envsync";
|
|
781
|
+
var ETC_ROOT = process.env.ENVSYNC_ETC_ROOT ?? "/etc/envsync";
|
|
782
|
+
var TRAEFIK_STATE_ROOT = process.env.ENVSYNC_TRAEFIK_STATE_ROOT ?? "/var/lib/envsync/traefik";
|
|
783
|
+
var DEPLOY_ROOT = path2.join(HOST_ROOT, "deploy");
|
|
784
|
+
var RELEASES_ROOT = path2.join(HOST_ROOT, "releases");
|
|
785
|
+
var BACKUPS_ROOT = path2.join(HOST_ROOT, "backups");
|
|
786
|
+
var REPO_ROOT = process.env.ENVSYNC_REPO_ROOT ?? path2.join(HOST_ROOT, "repo");
|
|
787
|
+
var DEPLOY_ENV = path2.join(ETC_ROOT, "deploy.env");
|
|
788
|
+
var DEPLOY_YAML = path2.join(ETC_ROOT, "deploy.yaml");
|
|
789
|
+
var VERSIONS_LOCK = path2.join(DEPLOY_ROOT, "versions.lock.json");
|
|
790
|
+
var STACK_FILE = path2.join(DEPLOY_ROOT, "docker-stack.yaml");
|
|
791
|
+
var BOOTSTRAP_BASE_STACK_FILE = path2.join(DEPLOY_ROOT, "docker-stack.bootstrap.base.yaml");
|
|
792
|
+
var BOOTSTRAP_STACK_FILE = path2.join(DEPLOY_ROOT, "docker-stack.bootstrap.yaml");
|
|
793
|
+
var TRAEFIK_DYNAMIC_FILE = path2.join(DEPLOY_ROOT, "traefik-dynamic.yaml");
|
|
794
|
+
var KEYCLOAK_REALM_FILE = path2.join(DEPLOY_ROOT, "keycloak-realm.envsync.json");
|
|
795
|
+
var NGINX_WEB_CONF = path2.join(DEPLOY_ROOT, "nginx-web.conf");
|
|
796
|
+
var NGINX_LANDING_CONF = path2.join(DEPLOY_ROOT, "nginx-landing.conf");
|
|
797
|
+
var NGINX_API_MAINTENANCE_CONF = path2.join(DEPLOY_ROOT, "nginx-api-maintenance.conf");
|
|
798
|
+
var OTEL_AGENT_CONF = path2.join(DEPLOY_ROOT, "otel-agent.yaml");
|
|
799
|
+
var CLICKSTACK_CLICKHOUSE_CONF = path2.join(DEPLOY_ROOT, "clickhouse-listen.xml");
|
|
800
|
+
var INTERNAL_CONFIG_JSON = path2.join(DEPLOY_ROOT, "config.json");
|
|
801
|
+
var UPGRADE_BACKUPS_ROOT = path2.join(BACKUPS_ROOT, "upgrade");
|
|
802
|
+
var DEPLOY_RENDER_PATHS = {
|
|
803
|
+
traefikStateRoot: TRAEFIK_STATE_ROOT,
|
|
804
|
+
deployRoot: DEPLOY_ROOT,
|
|
805
|
+
releasesRoot: RELEASES_ROOT,
|
|
806
|
+
keycloakRealmFile: KEYCLOAK_REALM_FILE,
|
|
807
|
+
clickstackClickhouseConf: CLICKSTACK_CLICKHOUSE_CONF,
|
|
808
|
+
otelAgentConf: OTEL_AGENT_CONF,
|
|
809
|
+
nginxLandingConf: NGINX_LANDING_CONF,
|
|
810
|
+
nginxWebConf: NGINX_WEB_CONF,
|
|
811
|
+
nginxApiMaintenanceConf: NGINX_API_MAINTENANCE_CONF
|
|
812
|
+
};
|
|
813
|
+
var STACK_VOLUMES = [
|
|
814
|
+
"postgres_data",
|
|
815
|
+
"redis_data",
|
|
816
|
+
"rustfs_data",
|
|
817
|
+
"keycloak_db_data",
|
|
818
|
+
"openfga_db_data",
|
|
819
|
+
"minikms_db_data",
|
|
820
|
+
"clickstack_data",
|
|
821
|
+
"clickstack_ch_data",
|
|
822
|
+
"clickstack_ch_logs"
|
|
823
|
+
];
|
|
824
|
+
var REQUIRED_BOOTSTRAP_ENV_KEYS = [
|
|
825
|
+
"S3_SECRET_KEY",
|
|
826
|
+
"KEYCLOAK_WEB_CLIENT_SECRET",
|
|
827
|
+
"KEYCLOAK_API_CLIENT_SECRET",
|
|
828
|
+
"OPENFGA_DB_PASSWORD",
|
|
829
|
+
"MINIKMS_ROOT_KEY",
|
|
830
|
+
"MINIKMS_DB_PASSWORD",
|
|
831
|
+
"OPENFGA_STORE_ID",
|
|
832
|
+
"OPENFGA_MODEL_ID"
|
|
833
|
+
];
|
|
834
|
+
var REQUIRED_CLICKSTACK_SAVED_SEARCHES = [
|
|
835
|
+
"Frontend Errors - Web",
|
|
836
|
+
"Frontend Errors - Landing",
|
|
837
|
+
"API Errors",
|
|
838
|
+
"Org Onboarding Completed",
|
|
839
|
+
"Apps Created",
|
|
840
|
+
"Users Invited",
|
|
841
|
+
"Webhooks Created",
|
|
842
|
+
"Slow API Traces",
|
|
843
|
+
"Frontend API Calls"
|
|
844
|
+
];
|
|
845
|
+
var REQUIRED_CLICKSTACK_TAGS = [
|
|
846
|
+
"envsync",
|
|
847
|
+
"frontend",
|
|
848
|
+
"backend",
|
|
849
|
+
"onboarding",
|
|
850
|
+
"applications",
|
|
851
|
+
"webhooks",
|
|
852
|
+
"errors",
|
|
853
|
+
"performance",
|
|
854
|
+
"alerts"
|
|
855
|
+
];
|
|
856
|
+
var CLICKSTACK_SELFHOST_TEAM_NAME = "EnvSync Self-Hosted Team";
|
|
857
|
+
var SEMVER_VERSION_RE = /^\d+\.\d+\.\d+$/;
|
|
858
|
+
var currentOptions = { dryRun: false, force: false };
|
|
859
|
+
var DEFAULT_SOURCE_REPO_URL = "https://github.com/EnvSync-Cloud/envsync.git";
|
|
860
|
+
var MANAGED_VERSIONED_IMAGE_PREFIXES = {
|
|
861
|
+
api: "ghcr.io/envsync-cloud/envsync-api:",
|
|
862
|
+
keycloak: "envsync-keycloak:",
|
|
863
|
+
web: "ghcr.io/envsync-cloud/envsync-web-static:",
|
|
864
|
+
landing: "ghcr.io/envsync-cloud/envsync-landing-static:"
|
|
865
|
+
};
|
|
866
|
+
function formatShellArg(arg) {
|
|
867
|
+
if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(arg)) return arg;
|
|
868
|
+
return JSON.stringify(arg);
|
|
869
|
+
}
|
|
870
|
+
function formatCommand(cmd, args) {
|
|
871
|
+
return [cmd, ...args].map(formatShellArg).join(" ");
|
|
872
|
+
}
|
|
873
|
+
function logSection(title) {
|
|
874
|
+
console.log(`
|
|
875
|
+
${chalk.bold.blue(title)}`);
|
|
876
|
+
}
|
|
877
|
+
function logStep(message) {
|
|
878
|
+
console.log(`${chalk.cyan("[step]")} ${message}`);
|
|
879
|
+
}
|
|
880
|
+
function logInfo(message) {
|
|
881
|
+
console.log(`${chalk.blue("[info]")} ${message}`);
|
|
882
|
+
}
|
|
883
|
+
function logSuccess(message) {
|
|
884
|
+
console.log(`${chalk.green("[ok]")} ${message}`);
|
|
885
|
+
}
|
|
886
|
+
function logWarn(message) {
|
|
887
|
+
console.log(`${chalk.yellow("[warn]")} ${message}`);
|
|
888
|
+
}
|
|
889
|
+
function logDryRun(message) {
|
|
890
|
+
console.log(`${chalk.magenta("[dry-run]")} ${message}`);
|
|
891
|
+
}
|
|
892
|
+
function cmdEnterpriseTopologyPlan(configPath = DEPLOY_YAML, json = false) {
|
|
893
|
+
const plan = loadDeploymentPlanFromFile(configPath, "enterprise");
|
|
894
|
+
console.log(formatDeploymentPlan(plan, json ? "json" : "yaml"));
|
|
895
|
+
}
|
|
896
|
+
function cmdEnterpriseTopologyValidate(configPath = DEPLOY_YAML, json = false) {
|
|
897
|
+
const plan = loadDeploymentPlanFromFile(configPath, "enterprise");
|
|
898
|
+
if (json) {
|
|
899
|
+
console.log(JSON.stringify({ valid: true, edition: plan.edition, warnings: plan.warnings }, null, 2));
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
logSuccess("Enterprise topology is valid.");
|
|
903
|
+
for (const warning of plan.warnings) {
|
|
904
|
+
logWarn(warning);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
function logCommand(cmd, args) {
|
|
908
|
+
console.log(chalk.dim(`$ ${formatCommand(cmd, args)}`));
|
|
909
|
+
}
|
|
910
|
+
function run(cmd, args, opts = {}) {
|
|
911
|
+
const result = spawnSync(cmd, args, {
|
|
912
|
+
cwd: opts.cwd,
|
|
913
|
+
env: { ...process.env, ...opts.env },
|
|
914
|
+
stdio: opts.quiet ? "pipe" : "inherit",
|
|
915
|
+
encoding: "utf8"
|
|
916
|
+
});
|
|
917
|
+
if (result.status !== 0) {
|
|
918
|
+
const stderr = typeof result.stderr === "string" ? result.stderr : "";
|
|
919
|
+
throw new Error(`Command failed: ${cmd} ${args.join(" ")}${stderr ? `
|
|
920
|
+
${stderr}` : ""}`);
|
|
921
|
+
}
|
|
922
|
+
return result.stdout?.toString() ?? "";
|
|
923
|
+
}
|
|
924
|
+
function tryRun(cmd, args, opts = {}) {
|
|
925
|
+
try {
|
|
926
|
+
return run(cmd, args, opts);
|
|
927
|
+
} catch {
|
|
928
|
+
return "";
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
function commandSucceeds(cmd, args, opts = {}) {
|
|
932
|
+
const result = spawnSync(cmd, args, {
|
|
933
|
+
cwd: opts.cwd,
|
|
934
|
+
env: { ...process.env, ...opts.env },
|
|
935
|
+
stdio: "ignore",
|
|
936
|
+
encoding: "utf8"
|
|
937
|
+
});
|
|
938
|
+
return result.status === 0;
|
|
939
|
+
}
|
|
940
|
+
function runIgnoringAbsent(cmd, args, opts = {}) {
|
|
941
|
+
const result = spawnSync(cmd, args, {
|
|
942
|
+
cwd: opts.cwd,
|
|
943
|
+
env: { ...process.env, ...opts.env },
|
|
944
|
+
stdio: "pipe",
|
|
945
|
+
encoding: "utf8"
|
|
946
|
+
});
|
|
947
|
+
if (result.status === 0) {
|
|
948
|
+
const stdout = typeof result.stdout === "string" ? result.stdout.trim() : "";
|
|
949
|
+
if (stdout) console.log(stdout);
|
|
950
|
+
return true;
|
|
951
|
+
}
|
|
952
|
+
const combined = `${typeof result.stdout === "string" ? result.stdout : ""}
|
|
953
|
+
${typeof result.stderr === "string" ? result.stderr : ""}`.toLowerCase();
|
|
954
|
+
const absentPatterns = (opts.absentPatterns ?? []).map((pattern) => pattern.toLowerCase());
|
|
955
|
+
if (absentPatterns.some((pattern) => combined.includes(pattern))) {
|
|
956
|
+
return false;
|
|
957
|
+
}
|
|
958
|
+
const stderr = typeof result.stderr === "string" ? result.stderr : "";
|
|
959
|
+
throw new Error(`Command failed: ${cmd} ${args.join(" ")}${stderr ? `
|
|
960
|
+
${stderr}` : ""}`);
|
|
961
|
+
}
|
|
962
|
+
function ensureDir(dir) {
|
|
963
|
+
fs2.mkdirSync(dir, { recursive: true });
|
|
964
|
+
}
|
|
965
|
+
function writeFile(target, content, mode) {
|
|
966
|
+
ensureDir(path2.dirname(target));
|
|
967
|
+
fs2.writeFileSync(target, content, "utf8");
|
|
968
|
+
if (mode != null) fs2.chmodSync(target, mode);
|
|
969
|
+
}
|
|
970
|
+
function writeFileMaybe(target, content, mode) {
|
|
971
|
+
if (currentOptions.dryRun) {
|
|
972
|
+
logDryRun(`Would write ${target}`);
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
writeFile(target, content, mode);
|
|
976
|
+
}
|
|
977
|
+
function exists2(target) {
|
|
978
|
+
return fs2.existsSync(target);
|
|
979
|
+
}
|
|
980
|
+
function randomSecret(bytes = 24) {
|
|
981
|
+
return randomBytes(bytes).toString("hex");
|
|
982
|
+
}
|
|
983
|
+
function randomStrongPassword() {
|
|
984
|
+
return `EnvSync!${randomBytes(8).toString("hex")}Aa1`;
|
|
985
|
+
}
|
|
986
|
+
function emptyApiSlotState() {
|
|
987
|
+
return {
|
|
988
|
+
api_image: "",
|
|
989
|
+
release_version: "",
|
|
990
|
+
deployed_at: ""
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
function normalizeApiSlot(value) {
|
|
994
|
+
return value === "green" ? "green" : "blue";
|
|
995
|
+
}
|
|
996
|
+
function normalizeApiSlotState(raw) {
|
|
997
|
+
const defaults = emptyApiSlotState();
|
|
998
|
+
return {
|
|
999
|
+
api_image: raw?.api_image ?? defaults.api_image,
|
|
1000
|
+
release_version: raw?.release_version ?? defaults.release_version,
|
|
1001
|
+
deployed_at: raw?.deployed_at ?? defaults.deployed_at
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
function otherApiSlot(slot) {
|
|
1005
|
+
return slot === "blue" ? "green" : "blue";
|
|
1006
|
+
}
|
|
1007
|
+
function toYaml(value, indent = 0) {
|
|
1008
|
+
return YAML.stringify(value, {
|
|
1009
|
+
indent: Math.max(2, indent || 2)
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
function parseSimpleYamlObject(input) {
|
|
1013
|
+
const parsed = YAML.parse(input);
|
|
1014
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1015
|
+
throw new Error(`Invalid deploy config in ${DEPLOY_YAML}. Expected a YAML object at the document root.`);
|
|
1016
|
+
}
|
|
1017
|
+
return parsed;
|
|
1018
|
+
}
|
|
1019
|
+
function parseEnvFile(content) {
|
|
1020
|
+
const out = {};
|
|
1021
|
+
for (const line of content.split(/\r?\n/)) {
|
|
1022
|
+
const trimmed = line.trim();
|
|
1023
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
1024
|
+
const eq = trimmed.indexOf("=");
|
|
1025
|
+
if (eq === -1) continue;
|
|
1026
|
+
const key = trimmed.slice(0, eq).trim();
|
|
1027
|
+
let value = trimmed.slice(eq + 1).trim();
|
|
1028
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
1029
|
+
value = value.slice(1, -1).replace(/\\n/g, "\n").replace(/\\"/g, '"');
|
|
1030
|
+
}
|
|
1031
|
+
out[key] = value;
|
|
1032
|
+
}
|
|
1033
|
+
return out;
|
|
1034
|
+
}
|
|
1035
|
+
async function ask(question, fallback = "") {
|
|
1036
|
+
if (!process.stdin.isTTY) return fallback;
|
|
1037
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1038
|
+
return await new Promise((resolve) => {
|
|
1039
|
+
rl.question(fallback ? `${question} [${fallback}]: ` : `${question}: `, (answer) => {
|
|
1040
|
+
rl.close();
|
|
1041
|
+
resolve(answer.trim() || fallback);
|
|
1042
|
+
});
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
async function askRequired(question) {
|
|
1046
|
+
if (!process.stdin.isTTY) {
|
|
1047
|
+
throw new Error("Bootstrap confirmation requires an interactive terminal. Re-run with --force to bypass the prompt.");
|
|
1048
|
+
}
|
|
1049
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1050
|
+
return await new Promise((resolve) => {
|
|
1051
|
+
rl.question(`${question} `, (answer) => {
|
|
1052
|
+
rl.close();
|
|
1053
|
+
resolve(answer.trim());
|
|
1054
|
+
});
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
function sleepSeconds(seconds) {
|
|
1058
|
+
spawnSync("sleep", [`${seconds}`], { stdio: "ignore" });
|
|
1059
|
+
}
|
|
1060
|
+
function domainMap2(rootDomain) {
|
|
1061
|
+
return {
|
|
1062
|
+
landing: rootDomain,
|
|
1063
|
+
app: `app.${rootDomain}`,
|
|
1064
|
+
api: `api.${rootDomain}`,
|
|
1065
|
+
auth: `auth.${rootDomain}`,
|
|
1066
|
+
obs: `obs.${rootDomain}`,
|
|
1067
|
+
mail: `mail.${rootDomain}`,
|
|
1068
|
+
s3: `s3.${rootDomain}`,
|
|
1069
|
+
s3Console: `console.s3.${rootDomain}`
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
function publicHttpsOrigin2(config, host) {
|
|
1073
|
+
return `https://${host}${config.services.public_https_port === 443 ? "" : `:${config.services.public_https_port}`}`;
|
|
1074
|
+
}
|
|
1075
|
+
function publicHttpsUrl2(config, host, path3 = "") {
|
|
1076
|
+
return `${publicHttpsOrigin2(config, host)}${path3}`;
|
|
1077
|
+
}
|
|
1078
|
+
function getDeployCliVersion() {
|
|
1079
|
+
try {
|
|
1080
|
+
const packageJsonPath = new URL("../package.json", import.meta.url);
|
|
1081
|
+
const raw = fs2.readFileSync(packageJsonPath, "utf8");
|
|
1082
|
+
return JSON.parse(raw).version ?? "0.0.0";
|
|
1083
|
+
} catch {
|
|
1084
|
+
return process.env.npm_package_version ?? "0.0.0";
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
function hasExplicitRepoOverride() {
|
|
1088
|
+
return typeof process.env.ENVSYNC_REPO_ROOT === "string" && process.env.ENVSYNC_REPO_ROOT.length > 0;
|
|
1089
|
+
}
|
|
1090
|
+
function logReleaseContext(config) {
|
|
1091
|
+
const cliVersion = getDeployCliVersion();
|
|
1092
|
+
logInfo(`Configured release version from ${DEPLOY_YAML}: ${config.release.version}`);
|
|
1093
|
+
logInfo(`Running deploy-cli version: ${cliVersion}`);
|
|
1094
|
+
if (cliVersion !== config.release.version) {
|
|
1095
|
+
logWarn(
|
|
1096
|
+
`The running deploy-cli version does not change the configured release target. This run will deploy the version pinned in ${DEPLOY_YAML}.`
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
function renderHelpBlock() {
|
|
1101
|
+
return [
|
|
1102
|
+
`${chalk.bold("EnvSync Self-Host Deploy CLI")}`,
|
|
1103
|
+
"",
|
|
1104
|
+
`${chalk.dim("Usage")}`,
|
|
1105
|
+
" envsync-deploy <command> [options]",
|
|
1106
|
+
"",
|
|
1107
|
+
`${chalk.dim("Commands")}`,
|
|
1108
|
+
" preinstall Prepare the host with Docker, Swarm, and required packages",
|
|
1109
|
+
" setup Write /etc/envsync/deploy.yaml for a new self-hosted install",
|
|
1110
|
+
" bootstrap Destructively rebuild managed infra and bootstrap runtime state",
|
|
1111
|
+
" deploy Deploy the configured release",
|
|
1112
|
+
" promote [blue|green] Promote the requested or inactive API slot",
|
|
1113
|
+
" rollback Switch traffic back to the previous API slot",
|
|
1114
|
+
" health [--json] Show operator health or machine-readable health JSON",
|
|
1115
|
+
" plan-topology [file] Render the Enterprise topology plan from deploy.yaml",
|
|
1116
|
+
" validate-topology Validate Enterprise edition topology rules",
|
|
1117
|
+
" upgrade [version] Pin a target release and deploy it",
|
|
1118
|
+
" upgrade-deps Refresh dependency images and redeploy",
|
|
1119
|
+
" backup Create a managed self-host backup archive",
|
|
1120
|
+
" restore <archive> Restore a backup archive into the managed self-host roots",
|
|
1121
|
+
"",
|
|
1122
|
+
`${chalk.dim("Options")}`,
|
|
1123
|
+
" --dry-run Preview mutating work without changing the host",
|
|
1124
|
+
" --force Skip destructive confirmations where supported",
|
|
1125
|
+
" --deploy Used with restore to start services after restore"
|
|
1126
|
+
].join("\n");
|
|
1127
|
+
}
|
|
1128
|
+
function buildOperatorOverview() {
|
|
1129
|
+
const statusLines = [
|
|
1130
|
+
`CLI version: ${chalk.cyan(getDeployCliVersion())}`,
|
|
1131
|
+
`Config path: ${chalk.dim(DEPLOY_YAML)}`
|
|
1132
|
+
];
|
|
1133
|
+
const dockerReady = commandSucceeds("docker", ["info"]);
|
|
1134
|
+
statusLines.push(`Docker: ${dockerReady ? chalk.green("available") : chalk.red("not available")}`);
|
|
1135
|
+
if (!exists2(DEPLOY_YAML)) {
|
|
1136
|
+
statusLines.push(`Configured: ${chalk.red("no")}`);
|
|
1137
|
+
return {
|
|
1138
|
+
statusLines,
|
|
1139
|
+
nextSteps: [
|
|
1140
|
+
"`envsync-deploy preinstall` if this host has not been prepared yet",
|
|
1141
|
+
"`envsync-deploy setup` to create the self-host deploy config"
|
|
1142
|
+
]
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
const { config, generated } = loadState();
|
|
1146
|
+
const services = listStackServices(config);
|
|
1147
|
+
const stackRunning = stackExists(config);
|
|
1148
|
+
const bootstrapComplete = hasCompleteBootstrapState(generated) && generated.bootstrap.completed_at.length > 0;
|
|
1149
|
+
const api = apiHealth(services, config.services.stack_name);
|
|
1150
|
+
const web = serviceHealth(services, `${config.services.stack_name}_web_nginx`);
|
|
1151
|
+
const landing = serviceHealth(services, `${config.services.stack_name}_landing_nginx`);
|
|
1152
|
+
statusLines.push(`Configured: ${chalk.green("yes")}`);
|
|
1153
|
+
statusLines.push(`Pinned release: ${chalk.cyan(config.release.version)}`);
|
|
1154
|
+
statusLines.push(`Stack: ${stackRunning ? chalk.green(config.services.stack_name) : chalk.yellow("not running")}`);
|
|
1155
|
+
statusLines.push(`Bootstrap: ${bootstrapComplete ? chalk.green("complete") : chalk.yellow("pending")}`);
|
|
1156
|
+
statusLines.push(`Active API slot: ${chalk.cyan(generated.deployment.active_slot)}`);
|
|
1157
|
+
statusLines.push(`API: ${api === "healthy" ? chalk.green(api) : api === "missing" ? chalk.red(api) : chalk.yellow(api)}`);
|
|
1158
|
+
statusLines.push(`Web: ${web === "healthy" ? chalk.green(web) : web === "missing" ? chalk.red(web) : chalk.yellow(web)}`);
|
|
1159
|
+
statusLines.push(`Landing: ${landing === "healthy" ? chalk.green(landing) : landing === "missing" ? chalk.red(landing) : chalk.yellow(landing)}`);
|
|
1160
|
+
if (!bootstrapComplete) {
|
|
1161
|
+
return {
|
|
1162
|
+
statusLines,
|
|
1163
|
+
nextSteps: [
|
|
1164
|
+
"`envsync-deploy bootstrap` to create infrastructure, runtime secrets, and generated state",
|
|
1165
|
+
"`envsync-deploy bootstrap --force` for non-interactive automation"
|
|
1166
|
+
]
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
if (api !== "healthy" || web !== "healthy" || landing !== "healthy") {
|
|
1170
|
+
return {
|
|
1171
|
+
statusLines,
|
|
1172
|
+
nextSteps: [
|
|
1173
|
+
"`envsync-deploy deploy` to reconcile services to the pinned release",
|
|
1174
|
+
"`envsync-deploy health --json` to inspect exact slot and observability state"
|
|
1175
|
+
]
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
return {
|
|
1179
|
+
statusLines,
|
|
1180
|
+
nextSteps: [
|
|
1181
|
+
"`envsync-deploy health --json` for machine-readable checks",
|
|
1182
|
+
"`envsync-deploy upgrade` to move to the current deploy-cli release",
|
|
1183
|
+
"`envsync-deploy backup` before an upgrade or major change"
|
|
1184
|
+
]
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
function printOperatorOverview() {
|
|
1188
|
+
const overview = buildOperatorOverview();
|
|
1189
|
+
const commonCommands = [
|
|
1190
|
+
"`envsync-deploy setup`",
|
|
1191
|
+
"`envsync-deploy bootstrap --force`",
|
|
1192
|
+
"`envsync-deploy deploy`",
|
|
1193
|
+
"`envsync-deploy upgrade`",
|
|
1194
|
+
"`envsync-deploy health --json`",
|
|
1195
|
+
"`envsync-deploy plan-topology --json`",
|
|
1196
|
+
"`envsync-deploy backup`",
|
|
1197
|
+
"`envsync-deploy restore <archive>`",
|
|
1198
|
+
"`envsync-deploy promote`",
|
|
1199
|
+
"`envsync-deploy rollback`"
|
|
1200
|
+
];
|
|
1201
|
+
const importantNotes = [
|
|
1202
|
+
"`bootstrap` is destructive",
|
|
1203
|
+
"`upgrade` updates the pinned release target automatically",
|
|
1204
|
+
"blue/green keeps the previous API slot for rollback",
|
|
1205
|
+
"self-hosted release targets must be exact semver values"
|
|
1206
|
+
];
|
|
1207
|
+
console.log(chalk.bold("EnvSync Self-Host Deploy CLI"));
|
|
1208
|
+
printHealthSection("Current Status");
|
|
1209
|
+
for (const line of overview.statusLines) {
|
|
1210
|
+
console.log(` ${line}`);
|
|
1211
|
+
}
|
|
1212
|
+
printHealthSection("Recommended Next Step");
|
|
1213
|
+
for (const line of overview.nextSteps) {
|
|
1214
|
+
console.log(` - ${line}`);
|
|
1215
|
+
}
|
|
1216
|
+
printHealthSection("Common Commands");
|
|
1217
|
+
for (const line of commonCommands) {
|
|
1218
|
+
console.log(` - ${line}`);
|
|
1219
|
+
}
|
|
1220
|
+
printHealthSection("Important Notes");
|
|
1221
|
+
for (const line of importantNotes) {
|
|
1222
|
+
console.log(` - ${line}`);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
function assertSemverVersion(version, label = "release version") {
|
|
1226
|
+
if (!SEMVER_VERSION_RE.test(version)) {
|
|
1227
|
+
throw new Error(`Invalid ${label} '${version}'. Expected an exact semver like 0.6.2.`);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
function versionedImages(version) {
|
|
1231
|
+
assertSemverVersion(version);
|
|
1232
|
+
return {
|
|
1233
|
+
api: `ghcr.io/envsync-cloud/envsync-api:${version}`,
|
|
1234
|
+
keycloak: `envsync-keycloak:${version}`,
|
|
1235
|
+
web: `ghcr.io/envsync-cloud/envsync-web-static:${version}`,
|
|
1236
|
+
landing: `ghcr.io/envsync-cloud/envsync-landing-static:${version}`
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
function defaultSourceConfig(version) {
|
|
1240
|
+
return {
|
|
1241
|
+
repo_url: DEFAULT_SOURCE_REPO_URL,
|
|
1242
|
+
ref: `v${version}`
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
function isOssConfig2(config) {
|
|
1246
|
+
return config.edition === "oss";
|
|
1247
|
+
}
|
|
1248
|
+
function isManagedVersionedImage(image, key) {
|
|
1249
|
+
return typeof image === "string" && image.startsWith(MANAGED_VERSIONED_IMAGE_PREFIXES[key]);
|
|
1250
|
+
}
|
|
1251
|
+
function resolveReleaseVersion(raw) {
|
|
1252
|
+
const releaseVersion = raw.release?.version;
|
|
1253
|
+
if (releaseVersion) {
|
|
1254
|
+
assertSemverVersion(releaseVersion);
|
|
1255
|
+
return releaseVersion;
|
|
1256
|
+
}
|
|
1257
|
+
if (typeof raw.release_channel === "string" && raw.release_channel.length > 0) {
|
|
1258
|
+
if (SEMVER_VERSION_RE.test(raw.release_channel)) {
|
|
1259
|
+
return raw.release_channel;
|
|
1260
|
+
}
|
|
1261
|
+
if (raw.release_channel === "stable" || raw.release_channel === "latest") {
|
|
1262
|
+
throw new Error(
|
|
1263
|
+
"Legacy release channel config is no longer supported for self-hosted installs. Set an exact release version in /etc/envsync/deploy.yaml."
|
|
1264
|
+
);
|
|
1265
|
+
}
|
|
1266
|
+
throw new Error(`Invalid legacy release channel '${raw.release_channel}'. Set an exact release version in /etc/envsync/deploy.yaml.`);
|
|
1267
|
+
}
|
|
1268
|
+
return getDeployCliVersion();
|
|
1269
|
+
}
|
|
1270
|
+
function requireDefined(value, label) {
|
|
1271
|
+
if (value === void 0) {
|
|
1272
|
+
throw new Error(`Missing ${label} in ${DEPLOY_YAML}. Run setup again.`);
|
|
1273
|
+
}
|
|
1274
|
+
return value;
|
|
1275
|
+
}
|
|
1276
|
+
function normalizeConfig(raw) {
|
|
1277
|
+
const version = resolveReleaseVersion(raw);
|
|
1278
|
+
const derivedImages = versionedImages(version);
|
|
1279
|
+
const { release_channel: _legacyReleaseChannel, ...rest } = raw;
|
|
1280
|
+
const rootDomain = requireDefined(raw.domain?.root_domain, "domain.root_domain");
|
|
1281
|
+
const acmeEmail = requireDefined(raw.domain?.acme_email, "domain.acme_email");
|
|
1282
|
+
return {
|
|
1283
|
+
...rest,
|
|
1284
|
+
edition: raw.edition ?? "enterprise",
|
|
1285
|
+
source: {
|
|
1286
|
+
repo_url: raw.source?.repo_url ?? DEFAULT_SOURCE_REPO_URL,
|
|
1287
|
+
ref: `v${version}`
|
|
1288
|
+
},
|
|
1289
|
+
release: {
|
|
1290
|
+
version
|
|
1291
|
+
},
|
|
1292
|
+
domain: {
|
|
1293
|
+
root_domain: rootDomain,
|
|
1294
|
+
acme_email: acmeEmail
|
|
1295
|
+
},
|
|
1296
|
+
images: {
|
|
1297
|
+
api: !raw.images?.api || isManagedVersionedImage(raw.images.api, "api") ? derivedImages.api : raw.images.api,
|
|
1298
|
+
keycloak: !raw.images?.keycloak || isManagedVersionedImage(raw.images.keycloak, "keycloak") ? derivedImages.keycloak : raw.images.keycloak,
|
|
1299
|
+
web: !raw.images?.web || isManagedVersionedImage(raw.images.web, "web") ? derivedImages.web : raw.images.web,
|
|
1300
|
+
landing: !raw.images?.landing || isManagedVersionedImage(raw.images.landing, "landing") ? derivedImages.landing : raw.images.landing,
|
|
1301
|
+
clickstack: raw.images?.clickstack ?? "clickhouse/clickstack-all-in-one:latest",
|
|
1302
|
+
traefik: raw.images?.traefik ?? "traefik:v3.6.6",
|
|
1303
|
+
otel_agent: raw.images?.otel_agent ?? "otel/opentelemetry-collector-contrib:0.111.0"
|
|
1304
|
+
},
|
|
1305
|
+
services: {
|
|
1306
|
+
stack_name: requireDefined(raw.services?.stack_name, "services.stack_name"),
|
|
1307
|
+
api_port: requireDefined(raw.services?.api_port, "services.api_port"),
|
|
1308
|
+
public_http_port: raw.services?.public_http_port ?? 80,
|
|
1309
|
+
public_https_port: raw.services?.public_https_port ?? 443,
|
|
1310
|
+
clickstack_ui_port: requireDefined(raw.services?.clickstack_ui_port, "services.clickstack_ui_port"),
|
|
1311
|
+
clickstack_otlp_http_port: requireDefined(raw.services?.clickstack_otlp_http_port, "services.clickstack_otlp_http_port"),
|
|
1312
|
+
clickstack_otlp_grpc_port: requireDefined(raw.services?.clickstack_otlp_grpc_port, "services.clickstack_otlp_grpc_port"),
|
|
1313
|
+
keycloak_port: requireDefined(raw.services?.keycloak_port, "services.keycloak_port"),
|
|
1314
|
+
rustfs_port: requireDefined(raw.services?.rustfs_port, "services.rustfs_port"),
|
|
1315
|
+
rustfs_console_port: requireDefined(raw.services?.rustfs_console_port, "services.rustfs_console_port")
|
|
1316
|
+
},
|
|
1317
|
+
auth: {
|
|
1318
|
+
keycloak_realm: requireDefined(raw.auth?.keycloak_realm, "auth.keycloak_realm"),
|
|
1319
|
+
admin_user: requireDefined(raw.auth?.admin_user, "auth.admin_user"),
|
|
1320
|
+
admin_password: requireDefined(raw.auth?.admin_password, "auth.admin_password"),
|
|
1321
|
+
web_client_id: requireDefined(raw.auth?.web_client_id, "auth.web_client_id"),
|
|
1322
|
+
api_client_id: requireDefined(raw.auth?.api_client_id, "auth.api_client_id"),
|
|
1323
|
+
cli_client_id: requireDefined(raw.auth?.cli_client_id, "auth.cli_client_id")
|
|
1324
|
+
},
|
|
1325
|
+
observability: {
|
|
1326
|
+
retention_days: requireDefined(raw.observability?.retention_days, "observability.retention_days"),
|
|
1327
|
+
public_obs: requireDefined(raw.observability?.public_obs, "observability.public_obs"),
|
|
1328
|
+
alert_webhook_url: raw.observability?.alert_webhook_url,
|
|
1329
|
+
alert_webhook_headers: raw.observability?.alert_webhook_headers ?? {}
|
|
1330
|
+
},
|
|
1331
|
+
backup: {
|
|
1332
|
+
output_dir: requireDefined(raw.backup?.output_dir, "backup.output_dir"),
|
|
1333
|
+
encrypted: requireDefined(raw.backup?.encrypted, "backup.encrypted")
|
|
1334
|
+
},
|
|
1335
|
+
smtp: {
|
|
1336
|
+
host: requireDefined(raw.smtp?.host, "smtp.host"),
|
|
1337
|
+
port: requireDefined(raw.smtp?.port, "smtp.port"),
|
|
1338
|
+
secure: requireDefined(raw.smtp?.secure, "smtp.secure"),
|
|
1339
|
+
user: requireDefined(raw.smtp?.user, "smtp.user"),
|
|
1340
|
+
pass: requireDefined(raw.smtp?.pass, "smtp.pass"),
|
|
1341
|
+
from: requireDefined(raw.smtp?.from, "smtp.from")
|
|
1342
|
+
},
|
|
1343
|
+
exposure: {
|
|
1344
|
+
public_auth: requireDefined(raw.exposure?.public_auth, "exposure.public_auth"),
|
|
1345
|
+
public_obs: requireDefined(raw.exposure?.public_obs, "exposure.public_obs"),
|
|
1346
|
+
mailpit_enabled: requireDefined(raw.exposure?.mailpit_enabled, "exposure.mailpit_enabled"),
|
|
1347
|
+
s3_public: requireDefined(raw.exposure?.s3_public, "exposure.s3_public"),
|
|
1348
|
+
s3_console_public: requireDefined(raw.exposure?.s3_console_public, "exposure.s3_console_public")
|
|
1349
|
+
},
|
|
1350
|
+
upgrade: {
|
|
1351
|
+
maintenance_mode_enabled: raw.upgrade?.maintenance_mode_enabled ?? true,
|
|
1352
|
+
db_snapshot_on_api_upgrade: raw.upgrade?.db_snapshot_on_api_upgrade ?? true,
|
|
1353
|
+
keep_failed_upgrade_db_snapshot: raw.upgrade?.keep_failed_upgrade_db_snapshot ?? true
|
|
1354
|
+
}
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
function emptyGeneratedState() {
|
|
1358
|
+
return {
|
|
1359
|
+
openfga: {
|
|
1360
|
+
store_id: "",
|
|
1361
|
+
model_id: ""
|
|
1362
|
+
},
|
|
1363
|
+
deployment: {
|
|
1364
|
+
active_slot: "blue",
|
|
1365
|
+
previous_slot: "",
|
|
1366
|
+
maintenance_mode: false,
|
|
1367
|
+
slots: {
|
|
1368
|
+
blue: emptyApiSlotState(),
|
|
1369
|
+
green: emptyApiSlotState()
|
|
1370
|
+
}
|
|
1371
|
+
},
|
|
1372
|
+
clickstack: {
|
|
1373
|
+
operator_email: "",
|
|
1374
|
+
operator_password: "",
|
|
1375
|
+
access_key: "",
|
|
1376
|
+
browser_api_key: ""
|
|
1377
|
+
},
|
|
1378
|
+
secrets: {
|
|
1379
|
+
s3_secret_key: "",
|
|
1380
|
+
keycloak_db_password: "",
|
|
1381
|
+
keycloak_web_client_secret: "",
|
|
1382
|
+
keycloak_api_client_secret: "",
|
|
1383
|
+
openfga_db_password: "",
|
|
1384
|
+
minikms_root_key: "",
|
|
1385
|
+
minikms_db_password: ""
|
|
1386
|
+
},
|
|
1387
|
+
bootstrap: {
|
|
1388
|
+
completed_at: ""
|
|
1389
|
+
}
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
function normalizeGeneratedState(raw) {
|
|
1393
|
+
const defaults = emptyGeneratedState();
|
|
1394
|
+
return {
|
|
1395
|
+
openfga: {
|
|
1396
|
+
store_id: raw?.openfga?.store_id ?? defaults.openfga.store_id,
|
|
1397
|
+
model_id: raw?.openfga?.model_id ?? defaults.openfga.model_id
|
|
1398
|
+
},
|
|
1399
|
+
deployment: {
|
|
1400
|
+
active_slot: normalizeApiSlot(raw?.deployment?.active_slot ?? defaults.deployment.active_slot),
|
|
1401
|
+
previous_slot: raw?.deployment?.previous_slot === "blue" || raw?.deployment?.previous_slot === "green" ? raw.deployment.previous_slot : defaults.deployment.previous_slot,
|
|
1402
|
+
maintenance_mode: raw?.deployment?.maintenance_mode ?? defaults.deployment.maintenance_mode,
|
|
1403
|
+
slots: {
|
|
1404
|
+
blue: normalizeApiSlotState(raw?.deployment?.slots?.blue),
|
|
1405
|
+
green: normalizeApiSlotState(raw?.deployment?.slots?.green)
|
|
1406
|
+
}
|
|
1407
|
+
},
|
|
1408
|
+
clickstack: {
|
|
1409
|
+
operator_email: raw?.clickstack?.operator_email ?? defaults.clickstack.operator_email,
|
|
1410
|
+
operator_password: raw?.clickstack?.operator_password ?? defaults.clickstack.operator_password,
|
|
1411
|
+
access_key: raw?.clickstack?.access_key ?? defaults.clickstack.access_key,
|
|
1412
|
+
browser_api_key: raw?.clickstack?.browser_api_key ?? defaults.clickstack.browser_api_key
|
|
1413
|
+
},
|
|
1414
|
+
secrets: {
|
|
1415
|
+
s3_secret_key: raw?.secrets?.s3_secret_key ?? defaults.secrets.s3_secret_key,
|
|
1416
|
+
keycloak_db_password: raw?.secrets?.keycloak_db_password ?? defaults.secrets.keycloak_db_password,
|
|
1417
|
+
keycloak_web_client_secret: raw?.secrets?.keycloak_web_client_secret ?? defaults.secrets.keycloak_web_client_secret,
|
|
1418
|
+
keycloak_api_client_secret: raw?.secrets?.keycloak_api_client_secret ?? defaults.secrets.keycloak_api_client_secret,
|
|
1419
|
+
openfga_db_password: raw?.secrets?.openfga_db_password ?? defaults.secrets.openfga_db_password,
|
|
1420
|
+
minikms_root_key: raw?.secrets?.minikms_root_key ?? defaults.secrets.minikms_root_key,
|
|
1421
|
+
minikms_db_password: raw?.secrets?.minikms_db_password ?? defaults.secrets.minikms_db_password
|
|
1422
|
+
},
|
|
1423
|
+
bootstrap: {
|
|
1424
|
+
completed_at: raw?.bootstrap?.completed_at ?? defaults.bootstrap.completed_at
|
|
1425
|
+
}
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
function readInternalState() {
|
|
1429
|
+
if (!exists2(INTERNAL_CONFIG_JSON)) return null;
|
|
1430
|
+
const raw = JSON.parse(fs2.readFileSync(INTERNAL_CONFIG_JSON, "utf8"));
|
|
1431
|
+
return {
|
|
1432
|
+
config: raw.config ? normalizeConfig(raw.config) : void 0,
|
|
1433
|
+
generated: normalizeGeneratedState(raw.generated)
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
function loadConfig() {
|
|
1437
|
+
if (!exists2(DEPLOY_YAML)) {
|
|
1438
|
+
throw new Error(`Missing deploy config at ${DEPLOY_YAML}. Run setup first.`);
|
|
1439
|
+
}
|
|
1440
|
+
const raw = fs2.readFileSync(DEPLOY_YAML, "utf8");
|
|
1441
|
+
if (raw.trimStart().startsWith("{")) {
|
|
1442
|
+
return normalizeConfig(JSON.parse(raw));
|
|
1443
|
+
}
|
|
1444
|
+
return normalizeConfig(parseSimpleYamlObject(raw));
|
|
1445
|
+
}
|
|
1446
|
+
function loadGeneratedEnv() {
|
|
1447
|
+
if (!exists2(DEPLOY_ENV)) return {};
|
|
1448
|
+
return parseEnvFile(fs2.readFileSync(DEPLOY_ENV, "utf8"));
|
|
1449
|
+
}
|
|
1450
|
+
function mergeGeneratedState(env, generated) {
|
|
1451
|
+
const normalized = normalizeGeneratedState(generated);
|
|
1452
|
+
return normalizeGeneratedState({
|
|
1453
|
+
openfga: {
|
|
1454
|
+
store_id: env.OPENFGA_STORE_ID ?? normalized.openfga.store_id,
|
|
1455
|
+
model_id: env.OPENFGA_MODEL_ID ?? normalized.openfga.model_id
|
|
1456
|
+
},
|
|
1457
|
+
deployment: normalized.deployment,
|
|
1458
|
+
clickstack: {
|
|
1459
|
+
operator_email: env.CLICKSTACK_OPERATOR_EMAIL ?? normalized.clickstack.operator_email,
|
|
1460
|
+
operator_password: env.CLICKSTACK_OPERATOR_PASSWORD ?? normalized.clickstack.operator_password,
|
|
1461
|
+
access_key: env.CLICKSTACK_ACCESS_KEY ?? normalized.clickstack.access_key,
|
|
1462
|
+
browser_api_key: env.CLICKSTACK_BROWSER_API_KEY ?? normalized.clickstack.browser_api_key
|
|
1463
|
+
},
|
|
1464
|
+
secrets: {
|
|
1465
|
+
s3_secret_key: env.S3_SECRET_KEY ?? normalized.secrets.s3_secret_key,
|
|
1466
|
+
keycloak_db_password: env.KEYCLOAK_DB_PASSWORD ?? normalized.secrets.keycloak_db_password,
|
|
1467
|
+
keycloak_web_client_secret: env.KEYCLOAK_WEB_CLIENT_SECRET ?? normalized.secrets.keycloak_web_client_secret,
|
|
1468
|
+
keycloak_api_client_secret: env.KEYCLOAK_API_CLIENT_SECRET ?? normalized.secrets.keycloak_api_client_secret,
|
|
1469
|
+
openfga_db_password: env.OPENFGA_DB_PASSWORD ?? normalized.secrets.openfga_db_password,
|
|
1470
|
+
minikms_root_key: env.MINIKMS_ROOT_KEY ?? normalized.secrets.minikms_root_key,
|
|
1471
|
+
minikms_db_password: env.MINIKMS_DB_PASSWORD ?? normalized.secrets.minikms_db_password
|
|
1472
|
+
},
|
|
1473
|
+
bootstrap: normalized.bootstrap
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
function loadState() {
|
|
1477
|
+
const config = loadConfig();
|
|
1478
|
+
const internal = readInternalState();
|
|
1479
|
+
const generated = mergeGeneratedState(loadGeneratedEnv(), internal?.generated);
|
|
1480
|
+
return { config, generated };
|
|
1481
|
+
}
|
|
1482
|
+
function ensureGeneratedRuntimeState(config, generated) {
|
|
1483
|
+
return normalizeGeneratedState({
|
|
1484
|
+
openfga: generated.openfga,
|
|
1485
|
+
deployment: generated.deployment,
|
|
1486
|
+
clickstack: {
|
|
1487
|
+
operator_email: generated.clickstack.operator_email || `operator@${config.domain.root_domain}`,
|
|
1488
|
+
operator_password: generated.clickstack.operator_password || randomStrongPassword(),
|
|
1489
|
+
access_key: generated.clickstack.access_key || `envsync-selfhost-${config.domain.root_domain}-dashboard-access-key`,
|
|
1490
|
+
browser_api_key: generated.clickstack.browser_api_key
|
|
1491
|
+
},
|
|
1492
|
+
secrets: {
|
|
1493
|
+
s3_secret_key: generated.secrets.s3_secret_key || randomSecret(16),
|
|
1494
|
+
keycloak_db_password: generated.secrets.keycloak_db_password || "",
|
|
1495
|
+
keycloak_web_client_secret: generated.secrets.keycloak_web_client_secret || randomSecret(),
|
|
1496
|
+
keycloak_api_client_secret: generated.secrets.keycloak_api_client_secret || randomSecret(),
|
|
1497
|
+
openfga_db_password: generated.secrets.openfga_db_password || randomSecret(),
|
|
1498
|
+
minikms_root_key: generated.secrets.minikms_root_key || randomBytes(32).toString("hex"),
|
|
1499
|
+
minikms_db_password: generated.secrets.minikms_db_password || randomSecret()
|
|
1500
|
+
},
|
|
1501
|
+
bootstrap: generated.bootstrap
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
function resetBootstrapGeneratedState(generated) {
|
|
1505
|
+
return normalizeGeneratedState({
|
|
1506
|
+
openfga: {
|
|
1507
|
+
store_id: "",
|
|
1508
|
+
model_id: ""
|
|
1509
|
+
},
|
|
1510
|
+
deployment: {
|
|
1511
|
+
active_slot: "blue",
|
|
1512
|
+
previous_slot: "",
|
|
1513
|
+
maintenance_mode: false,
|
|
1514
|
+
slots: {
|
|
1515
|
+
blue: emptyApiSlotState(),
|
|
1516
|
+
green: emptyApiSlotState()
|
|
1517
|
+
}
|
|
1518
|
+
},
|
|
1519
|
+
clickstack: generated.clickstack,
|
|
1520
|
+
secrets: generated.secrets,
|
|
1521
|
+
bootstrap: {
|
|
1522
|
+
completed_at: ""
|
|
1523
|
+
}
|
|
1524
|
+
});
|
|
1525
|
+
}
|
|
1526
|
+
function slotServiceName2(slot) {
|
|
1527
|
+
return `envsync_api_${slot}`;
|
|
1528
|
+
}
|
|
1529
|
+
function serviceStackName(config, serviceName) {
|
|
1530
|
+
return `${config.services.stack_name}_${serviceName}`;
|
|
1531
|
+
}
|
|
1532
|
+
function slotStackServiceName(config, slot) {
|
|
1533
|
+
return serviceStackName(config, slotServiceName2(slot));
|
|
1534
|
+
}
|
|
1535
|
+
function slotHasApiDeployment2(state) {
|
|
1536
|
+
return state.api_image.length > 0;
|
|
1537
|
+
}
|
|
1538
|
+
function createSteadyApiDeploymentState2(config, generated) {
|
|
1539
|
+
const deployment = normalizeGeneratedState(generated).deployment;
|
|
1540
|
+
const activeSlot = deployment.active_slot;
|
|
1541
|
+
const activeState = deployment.slots[activeSlot];
|
|
1542
|
+
if (slotHasApiDeployment2(activeState)) {
|
|
1543
|
+
return deployment;
|
|
1544
|
+
}
|
|
1545
|
+
return {
|
|
1546
|
+
active_slot: activeSlot,
|
|
1547
|
+
previous_slot: "",
|
|
1548
|
+
maintenance_mode: deployment.maintenance_mode,
|
|
1549
|
+
slots: {
|
|
1550
|
+
...deployment.slots,
|
|
1551
|
+
[activeSlot]: {
|
|
1552
|
+
api_image: config.images.api,
|
|
1553
|
+
release_version: config.release.version,
|
|
1554
|
+
deployed_at: deployment.slots[activeSlot].deployed_at
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
function prepareApiSlotStateForTarget(state, config) {
|
|
1560
|
+
return normalizeApiSlotState({
|
|
1561
|
+
...state,
|
|
1562
|
+
api_image: config.images.api,
|
|
1563
|
+
release_version: config.release.version
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
function stampApiSlotState(state, config) {
|
|
1567
|
+
return normalizeApiSlotState({
|
|
1568
|
+
...state,
|
|
1569
|
+
api_image: config.images.api,
|
|
1570
|
+
release_version: config.release.version,
|
|
1571
|
+
deployed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1572
|
+
});
|
|
1573
|
+
}
|
|
1574
|
+
function createPromotionCandidateState(config, generated) {
|
|
1575
|
+
const deployment = createSteadyApiDeploymentState2(config, generated);
|
|
1576
|
+
const activeSlot = deployment.active_slot;
|
|
1577
|
+
const activeState = deployment.slots[activeSlot];
|
|
1578
|
+
if (!slotHasApiDeployment2(activeState) || activeState.api_image === config.images.api) {
|
|
1579
|
+
return null;
|
|
1580
|
+
}
|
|
1581
|
+
const targetSlot = otherApiSlot(activeSlot);
|
|
1582
|
+
return normalizeGeneratedState({
|
|
1583
|
+
...generated,
|
|
1584
|
+
deployment: {
|
|
1585
|
+
active_slot: activeSlot,
|
|
1586
|
+
previous_slot: deployment.previous_slot,
|
|
1587
|
+
maintenance_mode: deployment.maintenance_mode,
|
|
1588
|
+
slots: {
|
|
1589
|
+
...deployment.slots,
|
|
1590
|
+
[targetSlot]: prepareApiSlotStateForTarget(deployment.slots[targetSlot], config)
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
}).deployment;
|
|
1594
|
+
}
|
|
1595
|
+
function createPromotedApiDeploymentState(config, generated) {
|
|
1596
|
+
const candidate = createPromotionCandidateState(config, generated);
|
|
1597
|
+
if (!candidate) {
|
|
1598
|
+
return createSteadyApiDeploymentState2(config, generated);
|
|
1599
|
+
}
|
|
1600
|
+
const currentActive = candidate.active_slot;
|
|
1601
|
+
const targetSlot = otherApiSlot(currentActive);
|
|
1602
|
+
return {
|
|
1603
|
+
active_slot: targetSlot,
|
|
1604
|
+
previous_slot: currentActive,
|
|
1605
|
+
maintenance_mode: candidate.maintenance_mode,
|
|
1606
|
+
slots: {
|
|
1607
|
+
...candidate.slots,
|
|
1608
|
+
[targetSlot]: stampApiSlotState(candidate.slots[targetSlot], config)
|
|
1609
|
+
}
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
function createRolledBackApiDeploymentState(generated) {
|
|
1613
|
+
const deployment = generated.deployment;
|
|
1614
|
+
const rollbackSlot = deployment.previous_slot;
|
|
1615
|
+
if (!rollbackSlot) {
|
|
1616
|
+
throw new Error("No rollback target is available. A previous promoted slot has not been recorded yet.");
|
|
1617
|
+
}
|
|
1618
|
+
if (!slotHasApiDeployment2(deployment.slots[rollbackSlot])) {
|
|
1619
|
+
throw new Error(`Rollback target slot '${rollbackSlot}' has no deployed API image recorded.`);
|
|
1620
|
+
}
|
|
1621
|
+
return {
|
|
1622
|
+
active_slot: rollbackSlot,
|
|
1623
|
+
previous_slot: deployment.active_slot,
|
|
1624
|
+
maintenance_mode: deployment.maintenance_mode,
|
|
1625
|
+
slots: deployment.slots
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
function markActiveApiSlotDeployed(config, deployment) {
|
|
1629
|
+
const activeSlot = deployment.active_slot;
|
|
1630
|
+
if (!slotHasApiDeployment2(deployment.slots[activeSlot])) {
|
|
1631
|
+
return deployment;
|
|
1632
|
+
}
|
|
1633
|
+
return {
|
|
1634
|
+
...deployment,
|
|
1635
|
+
slots: {
|
|
1636
|
+
...deployment.slots,
|
|
1637
|
+
[activeSlot]: stampApiSlotState(deployment.slots[activeSlot], config)
|
|
1638
|
+
}
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
function touchApiSlotDeployment(deployment, slot) {
|
|
1642
|
+
if (!slotHasApiDeployment2(deployment.slots[slot])) {
|
|
1643
|
+
return deployment;
|
|
1644
|
+
}
|
|
1645
|
+
return {
|
|
1646
|
+
...deployment,
|
|
1647
|
+
slots: {
|
|
1648
|
+
...deployment.slots,
|
|
1649
|
+
[slot]: {
|
|
1650
|
+
...deployment.slots[slot],
|
|
1651
|
+
deployed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
function writeDeployArtifacts(config, generated) {
|
|
1657
|
+
const runtimeEnv = buildRuntimeEnv(config, generated);
|
|
1658
|
+
logStep("Rendering deploy artifacts");
|
|
1659
|
+
writeFileMaybe(DEPLOY_ENV, renderEnvFile(runtimeEnv), 384);
|
|
1660
|
+
writeFileMaybe(
|
|
1661
|
+
INTERNAL_CONFIG_JSON,
|
|
1662
|
+
JSON.stringify({ config, generated: mergeGeneratedState(runtimeEnv, generated) }, null, 2) + "\n"
|
|
1663
|
+
);
|
|
1664
|
+
writeFileMaybe(VERSIONS_LOCK, JSON.stringify(config.images, null, 2) + "\n");
|
|
1665
|
+
writeFileMaybe(KEYCLOAK_REALM_FILE, renderKeycloakRealm(config, runtimeEnv));
|
|
1666
|
+
writeFileMaybe(TRAEFIK_DYNAMIC_FILE, renderTraefikDynamicConfig(config, generated));
|
|
1667
|
+
writeFileMaybe(BOOTSTRAP_BASE_STACK_FILE, renderStack(config, runtimeEnv, generated, "base", DEPLOY_RENDER_PATHS));
|
|
1668
|
+
writeFileMaybe(BOOTSTRAP_STACK_FILE, renderStack(config, runtimeEnv, generated, "bootstrap", DEPLOY_RENDER_PATHS));
|
|
1669
|
+
writeFileMaybe(STACK_FILE, renderStack(config, runtimeEnv, generated, "full", DEPLOY_RENDER_PATHS));
|
|
1670
|
+
writeFileMaybe(NGINX_WEB_CONF, renderNginxConf("web"));
|
|
1671
|
+
if (!isOssConfig2(config)) {
|
|
1672
|
+
writeFileMaybe(NGINX_LANDING_CONF, renderNginxConf("landing"));
|
|
1673
|
+
}
|
|
1674
|
+
writeFileMaybe(NGINX_API_MAINTENANCE_CONF, renderApiMaintenanceConf());
|
|
1675
|
+
writeFileMaybe(OTEL_AGENT_CONF, renderOtelAgentConfig(config));
|
|
1676
|
+
writeFileMaybe(CLICKSTACK_CLICKHOUSE_CONF, renderClickstackClickHouseConfig());
|
|
1677
|
+
logSuccess(currentOptions.dryRun ? "Deploy artifacts previewed" : "Deploy artifacts written");
|
|
1678
|
+
}
|
|
1679
|
+
function saveDesiredConfig(config) {
|
|
1680
|
+
const internal = readInternalState();
|
|
1681
|
+
const generated = mergeGeneratedState(loadGeneratedEnv(), internal?.generated);
|
|
1682
|
+
logStep(`Saving desired config to ${DEPLOY_YAML}`);
|
|
1683
|
+
writeFileMaybe(DEPLOY_YAML, toYaml(config) + "\n");
|
|
1684
|
+
writeFileMaybe(
|
|
1685
|
+
INTERNAL_CONFIG_JSON,
|
|
1686
|
+
JSON.stringify({ config, generated }, null, 2) + "\n"
|
|
1687
|
+
);
|
|
1688
|
+
logSuccess(currentOptions.dryRun ? "Desired config previewed" : "Desired config saved");
|
|
1689
|
+
}
|
|
1690
|
+
function ensureRepoCheckout(config) {
|
|
1691
|
+
logStep(`Ensuring pinned repo checkout at ${config.source.ref}`);
|
|
1692
|
+
if (currentOptions.dryRun) {
|
|
1693
|
+
logDryRun(`Would ensure repo checkout at ${REPO_ROOT}`);
|
|
1694
|
+
return;
|
|
1695
|
+
}
|
|
1696
|
+
if (hasExplicitRepoOverride()) {
|
|
1697
|
+
if (!exists2(path2.join(REPO_ROOT, ".git"))) {
|
|
1698
|
+
throw new Error(`ENVSYNC_REPO_ROOT is set but no git repo was found at ${REPO_ROOT}`);
|
|
1699
|
+
}
|
|
1700
|
+
logInfo(`Using local repo override at ${REPO_ROOT}`);
|
|
1701
|
+
logSuccess("Local repo override is ready");
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
ensureDir(REPO_ROOT);
|
|
1705
|
+
if (!exists2(path2.join(REPO_ROOT, ".git"))) {
|
|
1706
|
+
logCommand("git", ["clone", config.source.repo_url, REPO_ROOT]);
|
|
1707
|
+
run("git", ["clone", config.source.repo_url, REPO_ROOT]);
|
|
1708
|
+
}
|
|
1709
|
+
logCommand("git", ["remote", "set-url", "origin", config.source.repo_url]);
|
|
1710
|
+
run("git", ["remote", "set-url", "origin", config.source.repo_url], { cwd: REPO_ROOT });
|
|
1711
|
+
logCommand("git", ["fetch", "--tags", "--force", "origin"]);
|
|
1712
|
+
run("git", ["fetch", "--tags", "--force", "origin"], { cwd: REPO_ROOT });
|
|
1713
|
+
logCommand("git", ["checkout", "--force", config.source.ref]);
|
|
1714
|
+
run("git", ["checkout", "--force", config.source.ref], { cwd: REPO_ROOT });
|
|
1715
|
+
logSuccess(`Pinned repo checkout ready at ${config.source.ref}`);
|
|
1716
|
+
}
|
|
1717
|
+
function extractStaticBundle(kind, image, targetDir) {
|
|
1718
|
+
logStep(`Extracting ${kind} static bundle from ${image}`);
|
|
1719
|
+
if (currentOptions.dryRun) {
|
|
1720
|
+
logDryRun(`Would extract ${kind} bundle from ${image} into ${targetDir}`);
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
fs2.rmSync(targetDir, { recursive: true, force: true });
|
|
1724
|
+
ensureDir(targetDir);
|
|
1725
|
+
const containerId = run("docker", ["create", image], { quiet: true }).trim();
|
|
1726
|
+
try {
|
|
1727
|
+
run("docker", ["cp", `${containerId}:/app/dist/.`, targetDir]);
|
|
1728
|
+
} finally {
|
|
1729
|
+
run("docker", ["rm", "-f", containerId], { quiet: true });
|
|
1730
|
+
}
|
|
1731
|
+
normalizeExtractedStaticBundle(kind, targetDir);
|
|
1732
|
+
validateStaticBundle(kind, targetDir);
|
|
1733
|
+
logSuccess(`${kind} static bundle extracted to ${targetDir}`);
|
|
1734
|
+
}
|
|
1735
|
+
function releaseAssetDir(kind, version) {
|
|
1736
|
+
return path2.join(RELEASES_ROOT, kind, version);
|
|
1737
|
+
}
|
|
1738
|
+
function currentReleaseDir(kind) {
|
|
1739
|
+
return path2.join(RELEASES_ROOT, kind, "current");
|
|
1740
|
+
}
|
|
1741
|
+
function stageFrontendRelease(kind, image, version) {
|
|
1742
|
+
const targetDir = releaseAssetDir(kind, version);
|
|
1743
|
+
extractStaticBundle(kind, image, targetDir);
|
|
1744
|
+
}
|
|
1745
|
+
function writeFrontendRuntimeConfig(targetDir, runtimeConfig) {
|
|
1746
|
+
writeFile(path2.join(targetDir, "runtime-config.js"), runtimeConfig);
|
|
1747
|
+
}
|
|
1748
|
+
function syncFrontendReleaseContents(sourceDir, targetDir) {
|
|
1749
|
+
ensureDir(targetDir);
|
|
1750
|
+
const deferredEntries = /* @__PURE__ */ new Set(["index.html", "runtime-config.js"]);
|
|
1751
|
+
const entries = fs2.readdirSync(sourceDir, { withFileTypes: true });
|
|
1752
|
+
for (const entry of entries) {
|
|
1753
|
+
if (deferredEntries.has(entry.name)) {
|
|
1754
|
+
continue;
|
|
1755
|
+
}
|
|
1756
|
+
fs2.cpSync(path2.join(sourceDir, entry.name), path2.join(targetDir, entry.name), {
|
|
1757
|
+
recursive: true,
|
|
1758
|
+
force: true
|
|
1759
|
+
});
|
|
1760
|
+
}
|
|
1761
|
+
const stagedRuntimeConfig = path2.join(sourceDir, "runtime-config.js");
|
|
1762
|
+
if (exists2(stagedRuntimeConfig)) {
|
|
1763
|
+
fs2.cpSync(stagedRuntimeConfig, path2.join(targetDir, "runtime-config.js"), { force: true });
|
|
1764
|
+
}
|
|
1765
|
+
const stagedIndex = path2.join(sourceDir, "index.html");
|
|
1766
|
+
if (exists2(stagedIndex)) {
|
|
1767
|
+
fs2.cpSync(stagedIndex, path2.join(targetDir, "index.html"), { force: true });
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
function activateFrontendRelease(kind, version, runtimeConfig) {
|
|
1771
|
+
const stagedDir = releaseAssetDir(kind, version);
|
|
1772
|
+
const currentDir = currentReleaseDir(kind);
|
|
1773
|
+
if (currentOptions.dryRun) {
|
|
1774
|
+
logDryRun(`Would activate ${kind} release ${version} into ${currentDir}`);
|
|
1775
|
+
logDryRun(`Would write ${kind} runtime-config.js for release ${version}`);
|
|
1776
|
+
return;
|
|
1777
|
+
}
|
|
1778
|
+
if (!exists2(stagedDir)) {
|
|
1779
|
+
throw new Error(`Missing staged ${kind} release at ${stagedDir}`);
|
|
1780
|
+
}
|
|
1781
|
+
validateStaticBundle(kind, stagedDir);
|
|
1782
|
+
writeFrontendRuntimeConfig(stagedDir, runtimeConfig);
|
|
1783
|
+
ensureDir(currentDir);
|
|
1784
|
+
syncFrontendReleaseContents(stagedDir, currentDir);
|
|
1785
|
+
validateStaticBundle(kind, currentDir);
|
|
1786
|
+
}
|
|
1787
|
+
function createApiDbUpgradeBackup(config, fromVersion, toVersion) {
|
|
1788
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:]/g, "-");
|
|
1789
|
+
const fileName = `envsync-db-preupgrade-${fromVersion || "unknown"}-to-${toVersion}-${timestamp}.dump`;
|
|
1790
|
+
const hostPath = path2.join(UPGRADE_BACKUPS_ROOT, fileName);
|
|
1791
|
+
logStep(`Creating API DB upgrade snapshot ${fileName}`);
|
|
1792
|
+
if (currentOptions.dryRun) {
|
|
1793
|
+
logDryRun(`Would create upgrade DB snapshot at ${hostPath}`);
|
|
1794
|
+
return hostPath;
|
|
1795
|
+
}
|
|
1796
|
+
ensureDir(UPGRADE_BACKUPS_ROOT);
|
|
1797
|
+
run("docker", [
|
|
1798
|
+
"run",
|
|
1799
|
+
"--rm",
|
|
1800
|
+
"--network",
|
|
1801
|
+
stackNetworkName(config),
|
|
1802
|
+
"-e",
|
|
1803
|
+
"PGPASSWORD=envsync-postgres",
|
|
1804
|
+
"-v",
|
|
1805
|
+
`${UPGRADE_BACKUPS_ROOT}:/backup`,
|
|
1806
|
+
"postgres:17",
|
|
1807
|
+
"sh",
|
|
1808
|
+
"-lc",
|
|
1809
|
+
`pg_dump -h postgres -U postgres -d envsync -Fc -f /backup/${fileName}`
|
|
1810
|
+
]);
|
|
1811
|
+
logSuccess(`API DB upgrade snapshot created at ${hostPath}`);
|
|
1812
|
+
return hostPath;
|
|
1813
|
+
}
|
|
1814
|
+
function restoreApiDbUpgradeBackup(config, backupPath) {
|
|
1815
|
+
const fileName = path2.basename(backupPath);
|
|
1816
|
+
logWarn(`Restoring API DB upgrade snapshot ${fileName}`);
|
|
1817
|
+
if (currentOptions.dryRun) {
|
|
1818
|
+
logDryRun(`Would restore upgrade DB snapshot from ${backupPath}`);
|
|
1819
|
+
return;
|
|
1820
|
+
}
|
|
1821
|
+
run("docker", [
|
|
1822
|
+
"run",
|
|
1823
|
+
"--rm",
|
|
1824
|
+
"--network",
|
|
1825
|
+
stackNetworkName(config),
|
|
1826
|
+
"-e",
|
|
1827
|
+
"PGPASSWORD=envsync-postgres",
|
|
1828
|
+
"-v",
|
|
1829
|
+
`${UPGRADE_BACKUPS_ROOT}:/backup:ro`,
|
|
1830
|
+
"postgres:17",
|
|
1831
|
+
"sh",
|
|
1832
|
+
"-lc",
|
|
1833
|
+
`pg_restore --clean --if-exists --no-owner --no-privileges -h postgres -U postgres -d envsync /backup/${fileName}`
|
|
1834
|
+
]);
|
|
1835
|
+
logSuccess(`API DB upgrade snapshot restored from ${backupPath}`);
|
|
1836
|
+
}
|
|
1837
|
+
function activateFrontendReleaseForState(config, state, fallbackVersion = config.release.version) {
|
|
1838
|
+
const version = state.deployment.slots[state.deployment.active_slot].release_version || fallbackVersion;
|
|
1839
|
+
activateFrontendRelease("web", version, renderFrontendRuntimeConfig(config, state));
|
|
1840
|
+
if (!isOssConfig2(config)) {
|
|
1841
|
+
activateFrontendRelease("landing", version, renderFrontendRuntimeConfig(config, state));
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
function buildKeycloakImage(imageTag, repoRoot = REPO_ROOT) {
|
|
1845
|
+
const buildContext = path2.join(repoRoot, "packages/envsync-keycloak-theme");
|
|
1846
|
+
if (!exists2(path2.join(buildContext, "Dockerfile"))) {
|
|
1847
|
+
throw new Error(`Missing Keycloak Docker build context at ${buildContext}`);
|
|
1848
|
+
}
|
|
1849
|
+
logStep(`Building Keycloak image ${imageTag}`);
|
|
1850
|
+
if (currentOptions.dryRun) {
|
|
1851
|
+
logDryRun(`Would build ${imageTag} from ${buildContext}`);
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
logCommand("docker", ["build", "-t", imageTag, buildContext]);
|
|
1855
|
+
run("docker", ["build", "-t", imageTag, buildContext]);
|
|
1856
|
+
logSuccess(`Built Keycloak image ${imageTag}`);
|
|
1857
|
+
}
|
|
1858
|
+
function stackNetworkName(config) {
|
|
1859
|
+
return `${config.services.stack_name}_envsync`;
|
|
1860
|
+
}
|
|
1861
|
+
function assertSwarmManager() {
|
|
1862
|
+
if (currentOptions.dryRun) {
|
|
1863
|
+
logDryRun("Skipping Docker Swarm manager validation");
|
|
1864
|
+
return;
|
|
1865
|
+
}
|
|
1866
|
+
logStep("Validating Docker Swarm manager state");
|
|
1867
|
+
const state = tryRun("docker", ["info", "--format", "{{.Swarm.LocalNodeState}}|{{.Swarm.ControlAvailable}}"], { quiet: true }).trim();
|
|
1868
|
+
if (state !== "active|true") {
|
|
1869
|
+
throw new Error("Docker Swarm is not initialized on this node. Run 'docker swarm init' or 'envsync-deploy preinstall' first.");
|
|
1870
|
+
}
|
|
1871
|
+
logSuccess("Docker Swarm manager is ready");
|
|
1872
|
+
}
|
|
1873
|
+
function waitForCommand(config, label, image, command, timeoutSeconds = 120, env = {}, volumes = []) {
|
|
1874
|
+
if (currentOptions.dryRun) {
|
|
1875
|
+
logDryRun(`Would wait for ${label}`);
|
|
1876
|
+
return;
|
|
1877
|
+
}
|
|
1878
|
+
logStep(`Waiting for ${label}`);
|
|
1879
|
+
const deadline = Date.now() + timeoutSeconds * 1e3;
|
|
1880
|
+
while (Date.now() < deadline) {
|
|
1881
|
+
const args = ["run", "--rm", "--network", stackNetworkName(config)];
|
|
1882
|
+
for (const volume of volumes) {
|
|
1883
|
+
args.push("-v", volume);
|
|
1884
|
+
}
|
|
1885
|
+
for (const [key, value] of Object.entries(env)) {
|
|
1886
|
+
args.push("-e", `${key}=${value}`);
|
|
1887
|
+
}
|
|
1888
|
+
args.push(image, "sh", "-lc", command);
|
|
1889
|
+
if (commandSucceeds("docker", args)) {
|
|
1890
|
+
logSuccess(`${label} is ready`);
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
sleepSeconds(2);
|
|
1894
|
+
}
|
|
1895
|
+
throw new Error(`Timed out waiting for ${label}`);
|
|
1896
|
+
}
|
|
1897
|
+
function waitForPostgresService(config, label, host, user, password) {
|
|
1898
|
+
waitForCommand(config, `${label} database readiness`, "postgres:17", `pg_isready -h ${host} -U ${user}`, 120, {
|
|
1899
|
+
PGPASSWORD: password
|
|
1900
|
+
});
|
|
1901
|
+
}
|
|
1902
|
+
function waitForRedisService(config) {
|
|
1903
|
+
waitForCommand(config, "redis readiness", "redis:7", "redis-cli -h redis ping | grep PONG");
|
|
1904
|
+
}
|
|
1905
|
+
function waitForTcpService(config, label, host, port, timeoutSeconds = 120) {
|
|
1906
|
+
const deadline = Date.now() + timeoutSeconds * 1e3;
|
|
1907
|
+
while (Date.now() < deadline) {
|
|
1908
|
+
if (commandSucceeds(
|
|
1909
|
+
"docker",
|
|
1910
|
+
["run", "--rm", "--network", stackNetworkName(config), "alpine:3.20", "sh", "-lc", `nc -z -w 2 ${host} ${port}`]
|
|
1911
|
+
)) {
|
|
1912
|
+
return;
|
|
1913
|
+
}
|
|
1914
|
+
sleepSeconds(2);
|
|
1915
|
+
}
|
|
1916
|
+
throw new Error(`Timed out waiting for ${label} at ${host}:${port}`);
|
|
1917
|
+
}
|
|
1918
|
+
function waitForHttpService(config, label, url, timeoutSeconds = 120) {
|
|
1919
|
+
if (currentOptions.dryRun) {
|
|
1920
|
+
logDryRun(`Would wait for ${label} at ${url}`);
|
|
1921
|
+
return;
|
|
1922
|
+
}
|
|
1923
|
+
logStep(`Waiting for ${label} on ${url}`);
|
|
1924
|
+
const deadline = Date.now() + timeoutSeconds * 1e3;
|
|
1925
|
+
while (Date.now() < deadline) {
|
|
1926
|
+
if (commandSucceeds("docker", [
|
|
1927
|
+
"run",
|
|
1928
|
+
"--rm",
|
|
1929
|
+
"--network",
|
|
1930
|
+
stackNetworkName(config),
|
|
1931
|
+
"alpine:3.20",
|
|
1932
|
+
"sh",
|
|
1933
|
+
"-lc",
|
|
1934
|
+
`wget -q -O /dev/null ${JSON.stringify(url)}`
|
|
1935
|
+
])) {
|
|
1936
|
+
logSuccess(`${label} is ready`);
|
|
1937
|
+
return;
|
|
1938
|
+
}
|
|
1939
|
+
sleepSeconds(2);
|
|
1940
|
+
}
|
|
1941
|
+
throw new Error(`Timed out waiting for ${label} at ${url}`);
|
|
1942
|
+
}
|
|
1943
|
+
function runOpenFgaMigrate(config, runtimeEnv) {
|
|
1944
|
+
logStep("Running OpenFGA datastore migrations");
|
|
1945
|
+
if (currentOptions.dryRun) {
|
|
1946
|
+
logDryRun("Would run OpenFGA datastore migrations");
|
|
1947
|
+
logCommand("docker", [
|
|
1948
|
+
"run",
|
|
1949
|
+
"--rm",
|
|
1950
|
+
"--network",
|
|
1951
|
+
stackNetworkName(config),
|
|
1952
|
+
"-e",
|
|
1953
|
+
"OPENFGA_DATASTORE_ENGINE=postgres",
|
|
1954
|
+
"-e",
|
|
1955
|
+
`OPENFGA_DATASTORE_URI=postgres://openfga:${runtimeEnv.OPENFGA_DB_PASSWORD}@openfga_db:5432/openfga?sslmode=disable`,
|
|
1956
|
+
"openfga/openfga:v1.12.0",
|
|
1957
|
+
"migrate"
|
|
1958
|
+
]);
|
|
1959
|
+
return;
|
|
1960
|
+
}
|
|
1961
|
+
run("docker", [
|
|
1962
|
+
"run",
|
|
1963
|
+
"--rm",
|
|
1964
|
+
"--network",
|
|
1965
|
+
stackNetworkName(config),
|
|
1966
|
+
"-e",
|
|
1967
|
+
"OPENFGA_DATASTORE_ENGINE=postgres",
|
|
1968
|
+
"-e",
|
|
1969
|
+
`OPENFGA_DATASTORE_URI=postgres://openfga:${runtimeEnv.OPENFGA_DB_PASSWORD}@openfga_db:5432/openfga?sslmode=disable`,
|
|
1970
|
+
"openfga/openfga:v1.12.0",
|
|
1971
|
+
"migrate"
|
|
1972
|
+
]);
|
|
1973
|
+
logSuccess("OpenFGA datastore migrations completed");
|
|
1974
|
+
}
|
|
1975
|
+
function runMiniKmsMigrate(config, runtimeEnv) {
|
|
1976
|
+
logStep("Running miniKMS datastore migrations");
|
|
1977
|
+
if (currentOptions.dryRun) {
|
|
1978
|
+
logDryRun("Would run miniKMS datastore migrations");
|
|
1979
|
+
logCommand("docker", [
|
|
1980
|
+
"run",
|
|
1981
|
+
"--rm",
|
|
1982
|
+
"--network",
|
|
1983
|
+
stackNetworkName(config),
|
|
1984
|
+
"-e",
|
|
1985
|
+
`PGPASSWORD=${runtimeEnv.MINIKMS_DB_PASSWORD}`,
|
|
1986
|
+
"-v",
|
|
1987
|
+
`${path2.join(REPO_ROOT, "docker/minikms/migrations")}:/migrations:ro`,
|
|
1988
|
+
"postgres:17",
|
|
1989
|
+
"sh",
|
|
1990
|
+
"-lc",
|
|
1991
|
+
"psql -h minikms_db -U postgres -d minikms -f /migrations/001_initial_schema.sql && psql -h minikms_db -U postgres -d minikms -f /migrations/002_vault_storage.sql"
|
|
1992
|
+
]);
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1995
|
+
run("docker", [
|
|
1996
|
+
"run",
|
|
1997
|
+
"--rm",
|
|
1998
|
+
"--network",
|
|
1999
|
+
stackNetworkName(config),
|
|
2000
|
+
"-e",
|
|
2001
|
+
`PGPASSWORD=${runtimeEnv.MINIKMS_DB_PASSWORD}`,
|
|
2002
|
+
"-v",
|
|
2003
|
+
`${path2.join(REPO_ROOT, "docker/minikms/migrations")}:/migrations:ro`,
|
|
2004
|
+
"postgres:17",
|
|
2005
|
+
"sh",
|
|
2006
|
+
"-lc",
|
|
2007
|
+
"psql -h minikms_db -U postgres -d minikms -f /migrations/001_initial_schema.sql && psql -h minikms_db -U postgres -d minikms -f /migrations/002_vault_storage.sql"
|
|
2008
|
+
]);
|
|
2009
|
+
logSuccess("miniKMS datastore migrations completed");
|
|
2010
|
+
}
|
|
2011
|
+
function runBootstrapInit(config) {
|
|
2012
|
+
logStep("Running API bootstrap init");
|
|
2013
|
+
if (currentOptions.dryRun) {
|
|
2014
|
+
logDryRun("Would run API bootstrap init and persist generated OpenFGA IDs");
|
|
2015
|
+
logCommand("docker", [
|
|
2016
|
+
"run",
|
|
2017
|
+
"--rm",
|
|
2018
|
+
"--network",
|
|
2019
|
+
stackNetworkName(config),
|
|
2020
|
+
"--env-file",
|
|
2021
|
+
DEPLOY_ENV,
|
|
2022
|
+
"-e",
|
|
2023
|
+
"SKIP_ROOT_ENV=1",
|
|
2024
|
+
"-e",
|
|
2025
|
+
"SKIP_ROOT_ENV_WRITE=1",
|
|
2026
|
+
config.images.api,
|
|
2027
|
+
"bun",
|
|
2028
|
+
"run",
|
|
2029
|
+
"scripts/prod-init.ts",
|
|
2030
|
+
"--json",
|
|
2031
|
+
"--skip-migrations",
|
|
2032
|
+
"--no-write-root-env"
|
|
2033
|
+
]);
|
|
2034
|
+
return {
|
|
2035
|
+
openfgaStoreId: "",
|
|
2036
|
+
openfgaModelId: ""
|
|
2037
|
+
};
|
|
2038
|
+
}
|
|
2039
|
+
const output = run(
|
|
2040
|
+
"docker",
|
|
2041
|
+
[
|
|
2042
|
+
"run",
|
|
2043
|
+
"--rm",
|
|
2044
|
+
"--network",
|
|
2045
|
+
stackNetworkName(config),
|
|
2046
|
+
"--env-file",
|
|
2047
|
+
DEPLOY_ENV,
|
|
2048
|
+
"-e",
|
|
2049
|
+
"SKIP_ROOT_ENV=1",
|
|
2050
|
+
"-e",
|
|
2051
|
+
"SKIP_ROOT_ENV_WRITE=1",
|
|
2052
|
+
config.images.api,
|
|
2053
|
+
"bun",
|
|
2054
|
+
"run",
|
|
2055
|
+
"scripts/prod-init.ts",
|
|
2056
|
+
"--json",
|
|
2057
|
+
"--skip-migrations",
|
|
2058
|
+
"--no-write-root-env"
|
|
2059
|
+
],
|
|
2060
|
+
{ quiet: true }
|
|
2061
|
+
).trim();
|
|
2062
|
+
const result = parseBootstrapInitJson(output);
|
|
2063
|
+
if (!result.openfgaStoreId || !result.openfgaModelId) {
|
|
2064
|
+
throw new Error("Bootstrap init did not return OpenFGA IDs");
|
|
2065
|
+
}
|
|
2066
|
+
logSuccess("API bootstrap init completed");
|
|
2067
|
+
return {
|
|
2068
|
+
openfgaStoreId: result.openfgaStoreId,
|
|
2069
|
+
openfgaModelId: result.openfgaModelId
|
|
2070
|
+
};
|
|
2071
|
+
}
|
|
2072
|
+
function parseClickstackBootstrapJson(output) {
|
|
2073
|
+
const trimmed = output.trim();
|
|
2074
|
+
if (!trimmed) {
|
|
2075
|
+
throw new Error("ClickStack bootstrap returned no JSON output");
|
|
2076
|
+
}
|
|
2077
|
+
const lines = trimmed.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
2078
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
2079
|
+
const candidate = lines[index];
|
|
2080
|
+
if (!candidate.startsWith("{") || !candidate.endsWith("}")) continue;
|
|
2081
|
+
try {
|
|
2082
|
+
return JSON.parse(candidate);
|
|
2083
|
+
} catch {
|
|
2084
|
+
continue;
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
throw new Error(`ClickStack bootstrap returned non-JSON output.
|
|
2088
|
+
Captured stdout:
|
|
2089
|
+
${trimmed}`);
|
|
2090
|
+
}
|
|
2091
|
+
function runClickstackBootstrap(config) {
|
|
2092
|
+
logStep("Running ClickStack self-host bootstrap");
|
|
2093
|
+
const { generated } = loadState();
|
|
2094
|
+
if (currentOptions.dryRun) {
|
|
2095
|
+
logDryRun("Would bootstrap ClickStack sources and dashboards");
|
|
2096
|
+
logCommand("node", [path2.join(REPO_ROOT, "scripts/bootstrap-clickstack-selfhost.mjs")]);
|
|
2097
|
+
return {
|
|
2098
|
+
browserApiKey: generated.clickstack.browser_api_key
|
|
2099
|
+
};
|
|
2100
|
+
}
|
|
2101
|
+
const deadline = Date.now() + 180 * 1e3;
|
|
2102
|
+
let lastError = "unknown error";
|
|
2103
|
+
while (Date.now() < deadline) {
|
|
2104
|
+
try {
|
|
2105
|
+
const output = run("node", [path2.join(REPO_ROOT, "scripts/bootstrap-clickstack-selfhost.mjs")], {
|
|
2106
|
+
env: {
|
|
2107
|
+
ENVSYNC_STACK_NAME: config.services.stack_name,
|
|
2108
|
+
ENVSYNC_ROOT_DOMAIN: config.domain.root_domain,
|
|
2109
|
+
ENVSYNC_CLICKSTACK_OPERATOR_EMAIL: generated.clickstack.operator_email,
|
|
2110
|
+
ENVSYNC_CLICKSTACK_OPERATOR_PASSWORD: generated.clickstack.operator_password,
|
|
2111
|
+
ENVSYNC_CLICKSTACK_ACCESS_KEY: generated.clickstack.access_key,
|
|
2112
|
+
ENVSYNC_CLICKSTACK_BROWSER_API_KEY: generated.clickstack.browser_api_key,
|
|
2113
|
+
ENVSYNC_CLICKSTACK_ALERT_WEBHOOK_URL: config.observability.alert_webhook_url ?? "",
|
|
2114
|
+
ENVSYNC_CLICKSTACK_ALERT_WEBHOOK_HEADERS: JSON.stringify(config.observability.alert_webhook_headers ?? {})
|
|
2115
|
+
},
|
|
2116
|
+
quiet: true
|
|
2117
|
+
});
|
|
2118
|
+
const result = parseClickstackBootstrapJson(output);
|
|
2119
|
+
logSuccess("ClickStack self-host bootstrap completed");
|
|
2120
|
+
return result;
|
|
2121
|
+
} catch (error) {
|
|
2122
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
2123
|
+
sleepSeconds(3);
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
throw new Error(`Timed out bootstrapping ClickStack: ${lastError}`);
|
|
2127
|
+
}
|
|
2128
|
+
function logClickstackCredentials(generated) {
|
|
2129
|
+
logInfo(`ClickStack operator email: ${generated.clickstack.operator_email}`);
|
|
2130
|
+
logInfo(`ClickStack operator password: ${generated.clickstack.operator_password}`);
|
|
2131
|
+
logInfo(`ClickStack access key: ${generated.clickstack.access_key}`);
|
|
2132
|
+
if (generated.clickstack.browser_api_key) {
|
|
2133
|
+
logInfo(`ClickStack browser API key: ${generated.clickstack.browser_api_key}`);
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
function parseBootstrapInitJson(output) {
|
|
2137
|
+
const trimmed = output.trim();
|
|
2138
|
+
if (!trimmed) {
|
|
2139
|
+
throw new Error("Bootstrap init returned no JSON output");
|
|
2140
|
+
}
|
|
2141
|
+
try {
|
|
2142
|
+
return JSON.parse(trimmed);
|
|
2143
|
+
} catch {
|
|
2144
|
+
const lines = trimmed.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
2145
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
2146
|
+
const candidate = lines[index];
|
|
2147
|
+
if (!candidate.startsWith("{") || !candidate.endsWith("}")) continue;
|
|
2148
|
+
try {
|
|
2149
|
+
return JSON.parse(candidate);
|
|
2150
|
+
} catch {
|
|
2151
|
+
continue;
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
throw new Error(
|
|
2155
|
+
`Bootstrap init returned non-JSON output.
|
|
2156
|
+
Captured stdout:
|
|
2157
|
+
${trimmed}`
|
|
2158
|
+
);
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
function readRenderedRuntimeConfig(filePath) {
|
|
2162
|
+
if (!exists2(filePath)) return null;
|
|
2163
|
+
const raw = fs2.readFileSync(filePath, "utf8").trim();
|
|
2164
|
+
const prefix = "window.__ENVSYNC_RUNTIME_CONFIG__ = ";
|
|
2165
|
+
if (!raw.startsWith(prefix) || !raw.endsWith(";")) return null;
|
|
2166
|
+
try {
|
|
2167
|
+
return JSON.parse(raw.slice(prefix.length, -1));
|
|
2168
|
+
} catch {
|
|
2169
|
+
return null;
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
function activeApiImage(config, generated) {
|
|
2173
|
+
const slot = generated.deployment.active_slot;
|
|
2174
|
+
return generated.deployment.slots[slot].api_image || config.images.api;
|
|
2175
|
+
}
|
|
2176
|
+
function parseMigrationCommandJson(output) {
|
|
2177
|
+
const trimmed = output.trim();
|
|
2178
|
+
if (!trimmed) {
|
|
2179
|
+
throw new Error("Migration command returned no JSON output");
|
|
2180
|
+
}
|
|
2181
|
+
const lines = trimmed.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
2182
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
2183
|
+
const candidate = lines[index];
|
|
2184
|
+
if (!candidate.startsWith("{") || !candidate.endsWith("}")) continue;
|
|
2185
|
+
try {
|
|
2186
|
+
return JSON.parse(candidate);
|
|
2187
|
+
} catch {
|
|
2188
|
+
continue;
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
throw new Error(`Migration command returned non-JSON output.
|
|
2192
|
+
Captured stdout:
|
|
2193
|
+
${trimmed}`);
|
|
2194
|
+
}
|
|
2195
|
+
function runApiMigrationJsonCommand(config, image, args) {
|
|
2196
|
+
if (currentOptions.dryRun) {
|
|
2197
|
+
logDryRun(`Would run API migration command in ${image}: ${args.join(" ")}`);
|
|
2198
|
+
return {
|
|
2199
|
+
ok: true,
|
|
2200
|
+
currentHead: null,
|
|
2201
|
+
executedMigrations: []
|
|
2202
|
+
};
|
|
2203
|
+
}
|
|
2204
|
+
const output = run(
|
|
2205
|
+
"docker",
|
|
2206
|
+
[
|
|
2207
|
+
"run",
|
|
2208
|
+
"--rm",
|
|
2209
|
+
"--network",
|
|
2210
|
+
stackNetworkName(config),
|
|
2211
|
+
"--env-file",
|
|
2212
|
+
DEPLOY_ENV,
|
|
2213
|
+
"-e",
|
|
2214
|
+
"SKIP_ROOT_ENV=1",
|
|
2215
|
+
image,
|
|
2216
|
+
"bun",
|
|
2217
|
+
"run",
|
|
2218
|
+
"scripts/migrate.ts",
|
|
2219
|
+
...args,
|
|
2220
|
+
"--json"
|
|
2221
|
+
],
|
|
2222
|
+
{ quiet: true }
|
|
2223
|
+
);
|
|
2224
|
+
return parseMigrationCommandJson(output);
|
|
2225
|
+
}
|
|
2226
|
+
function getApiMigrationHealth(config, generated) {
|
|
2227
|
+
try {
|
|
2228
|
+
const response = runApiMigrationJsonCommand(config, activeApiImage(config, generated), ["head"]);
|
|
2229
|
+
return {
|
|
2230
|
+
migration_head: response.currentHead,
|
|
2231
|
+
auto_migrate_enabled: false
|
|
2232
|
+
};
|
|
2233
|
+
} catch (error) {
|
|
2234
|
+
return {
|
|
2235
|
+
migration_head: null,
|
|
2236
|
+
auto_migrate_enabled: false,
|
|
2237
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2238
|
+
};
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
function resolveClickstackContainerId(config) {
|
|
2242
|
+
const output = tryRun(
|
|
2243
|
+
"docker",
|
|
2244
|
+
[
|
|
2245
|
+
"ps",
|
|
2246
|
+
"--filter",
|
|
2247
|
+
`label=com.docker.swarm.service.name=${config.services.stack_name}_clickstack`,
|
|
2248
|
+
"--format",
|
|
2249
|
+
"{{.ID}}"
|
|
2250
|
+
],
|
|
2251
|
+
{ quiet: true }
|
|
2252
|
+
).trim();
|
|
2253
|
+
return output.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? "";
|
|
2254
|
+
}
|
|
2255
|
+
function runClickstackMongoJson(config, script) {
|
|
2256
|
+
const containerId = resolveClickstackContainerId(config);
|
|
2257
|
+
if (!containerId) return null;
|
|
2258
|
+
try {
|
|
2259
|
+
const payload = Buffer.from(script, "utf8").toString("base64");
|
|
2260
|
+
const output = run(
|
|
2261
|
+
"docker",
|
|
2262
|
+
[
|
|
2263
|
+
"exec",
|
|
2264
|
+
"-e",
|
|
2265
|
+
`HDX_SCRIPT=${payload}`,
|
|
2266
|
+
containerId,
|
|
2267
|
+
"sh",
|
|
2268
|
+
"-lc",
|
|
2269
|
+
`printf '%s' "$HDX_SCRIPT" | base64 -d >/tmp/envsync-clickstack-health.js && mongo hyperdx --quiet /tmp/envsync-clickstack-health.js`
|
|
2270
|
+
],
|
|
2271
|
+
{ quiet: true }
|
|
2272
|
+
).trim();
|
|
2273
|
+
const parsed = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).at(-1);
|
|
2274
|
+
if (!parsed) return null;
|
|
2275
|
+
return JSON.parse(parsed);
|
|
2276
|
+
} catch {
|
|
2277
|
+
return null;
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
function getClickstackSearchState(config) {
|
|
2281
|
+
return runClickstackMongoJson(config, `
|
|
2282
|
+
var team = db.teams.findOne({ name: ${JSON.stringify(CLICKSTACK_SELFHOST_TEAM_NAME)} });
|
|
2283
|
+
if (!team) {
|
|
2284
|
+
print(JSON.stringify({ sourceNames: [], savedSearches: [], dashboardTags: [] }));
|
|
2285
|
+
quit();
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
var sourceNames = db.sources.find({ team: team._id }, { name: 1 }).toArray().map(function(source) {
|
|
2289
|
+
return source.name;
|
|
2290
|
+
}).filter(Boolean);
|
|
2291
|
+
|
|
2292
|
+
var savedSearches = db.savedsearches.find({ team: team._id }, { name: 1, tags: 1 }).toArray().map(function(search) {
|
|
2293
|
+
return {
|
|
2294
|
+
name: search.name,
|
|
2295
|
+
tags: Array.isArray(search.tags) ? search.tags : []
|
|
2296
|
+
};
|
|
2297
|
+
}).filter(function(search) {
|
|
2298
|
+
return Boolean(search.name);
|
|
2299
|
+
});
|
|
2300
|
+
|
|
2301
|
+
var dashboardTags = [];
|
|
2302
|
+
db.dashboards.find({ team: team._id }, { tags: 1 }).toArray().forEach(function(dashboard) {
|
|
2303
|
+
if (Array.isArray(dashboard.tags)) {
|
|
2304
|
+
dashboard.tags.forEach(function(tag) {
|
|
2305
|
+
dashboardTags.push(tag);
|
|
2306
|
+
});
|
|
2307
|
+
}
|
|
2308
|
+
});
|
|
2309
|
+
|
|
2310
|
+
print(JSON.stringify({
|
|
2311
|
+
sourceNames: sourceNames,
|
|
2312
|
+
savedSearches: savedSearches,
|
|
2313
|
+
dashboardTags: dashboardTags
|
|
2314
|
+
}));
|
|
2315
|
+
`);
|
|
2316
|
+
}
|
|
2317
|
+
function hasCompleteBootstrapState(generated) {
|
|
2318
|
+
return REQUIRED_BOOTSTRAP_ENV_KEYS.every((key) => {
|
|
2319
|
+
switch (key) {
|
|
2320
|
+
case "S3_SECRET_KEY":
|
|
2321
|
+
return generated.secrets.s3_secret_key.length > 0;
|
|
2322
|
+
case "KEYCLOAK_WEB_CLIENT_SECRET":
|
|
2323
|
+
return generated.secrets.keycloak_web_client_secret.length > 0;
|
|
2324
|
+
case "KEYCLOAK_API_CLIENT_SECRET":
|
|
2325
|
+
return generated.secrets.keycloak_api_client_secret.length > 0;
|
|
2326
|
+
case "OPENFGA_DB_PASSWORD":
|
|
2327
|
+
return generated.secrets.openfga_db_password.length > 0;
|
|
2328
|
+
case "MINIKMS_ROOT_KEY":
|
|
2329
|
+
return generated.secrets.minikms_root_key.length > 0;
|
|
2330
|
+
case "MINIKMS_DB_PASSWORD":
|
|
2331
|
+
return generated.secrets.minikms_db_password.length > 0;
|
|
2332
|
+
case "OPENFGA_STORE_ID":
|
|
2333
|
+
return generated.openfga.store_id.length > 0;
|
|
2334
|
+
case "OPENFGA_MODEL_ID":
|
|
2335
|
+
return generated.openfga.model_id.length > 0;
|
|
2336
|
+
default:
|
|
2337
|
+
return false;
|
|
2338
|
+
}
|
|
2339
|
+
});
|
|
2340
|
+
}
|
|
2341
|
+
function assertBootstrapState(generated) {
|
|
2342
|
+
if (!hasCompleteBootstrapState(generated)) {
|
|
2343
|
+
throw new Error("Missing bootstrap state. Run 'envsync-deploy bootstrap' first.");
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
function parseReplicaHealth(raw) {
|
|
2347
|
+
const match = raw.match(/^(\d+)\/(\d+)$/);
|
|
2348
|
+
if (!match) return raw.trim() ? "degraded" : "missing";
|
|
2349
|
+
const current = Number(match[1]);
|
|
2350
|
+
const desired = Number(match[2]);
|
|
2351
|
+
if (desired === 0) return "missing";
|
|
2352
|
+
if (current === desired) return "healthy";
|
|
2353
|
+
return "degraded";
|
|
2354
|
+
}
|
|
2355
|
+
function listStackServices(config) {
|
|
2356
|
+
const output = tryRun(
|
|
2357
|
+
"docker",
|
|
2358
|
+
["stack", "services", config.services.stack_name, "--format", "{{.Name}}|{{.Replicas}}"],
|
|
2359
|
+
{ quiet: true }
|
|
2360
|
+
);
|
|
2361
|
+
const services = /* @__PURE__ */ new Map();
|
|
2362
|
+
for (const line of output.split(/\r?\n/)) {
|
|
2363
|
+
if (!line.trim()) continue;
|
|
2364
|
+
const [name, replicas] = line.split("|");
|
|
2365
|
+
services.set(name, parseReplicaHealth(replicas ?? ""));
|
|
2366
|
+
}
|
|
2367
|
+
return services;
|
|
2368
|
+
}
|
|
2369
|
+
function listManagedContainers(config) {
|
|
2370
|
+
const output = tryRun("docker", ["ps", "-aq", "--filter", `name=^/${config.services.stack_name}_`], { quiet: true });
|
|
2371
|
+
return output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
2372
|
+
}
|
|
2373
|
+
function stackExists(config) {
|
|
2374
|
+
const output = tryRun("docker", ["stack", "ls", "--format", "{{.Name}}"], { quiet: true });
|
|
2375
|
+
return output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).includes(config.services.stack_name);
|
|
2376
|
+
}
|
|
2377
|
+
function waitForStackRemoval(config, timeoutSeconds = 60) {
|
|
2378
|
+
if (currentOptions.dryRun) {
|
|
2379
|
+
logDryRun(`Would wait for stack ${config.services.stack_name} to be removed`);
|
|
2380
|
+
return;
|
|
2381
|
+
}
|
|
2382
|
+
const deadline = Date.now() + timeoutSeconds * 1e3;
|
|
2383
|
+
while (Date.now() < deadline) {
|
|
2384
|
+
if (!stackExists(config)) {
|
|
2385
|
+
return;
|
|
2386
|
+
}
|
|
2387
|
+
sleepSeconds(2);
|
|
2388
|
+
}
|
|
2389
|
+
throw new Error(`Timed out waiting for stack ${config.services.stack_name} to be removed`);
|
|
2390
|
+
}
|
|
2391
|
+
async function confirmBootstrapReset(config) {
|
|
2392
|
+
const volumeNames = STACK_VOLUMES.map((volume) => stackVolumeName(config, volume));
|
|
2393
|
+
const containerIds = listManagedContainers(config);
|
|
2394
|
+
const networkName = stackNetworkName(config);
|
|
2395
|
+
logWarn("Bootstrap will delete existing EnvSync Docker resources before rebuilding infra.");
|
|
2396
|
+
logWarn(`Stack: ${config.services.stack_name}`);
|
|
2397
|
+
logWarn(`Network: ${networkName}`);
|
|
2398
|
+
logWarn(`Volumes: ${volumeNames.join(", ")}`);
|
|
2399
|
+
if (containerIds.length > 0) {
|
|
2400
|
+
logWarn(`Containers: ${containerIds.join(", ")}`);
|
|
2401
|
+
} else {
|
|
2402
|
+
logWarn("Containers: none currently matched");
|
|
2403
|
+
}
|
|
2404
|
+
logWarn("This removes existing deployment data for the managed EnvSync services.");
|
|
2405
|
+
if (currentOptions.force) {
|
|
2406
|
+
logWarn("Skipping confirmation because --force was provided.");
|
|
2407
|
+
logSuccess("Destructive bootstrap reset confirmed");
|
|
2408
|
+
return;
|
|
2409
|
+
}
|
|
2410
|
+
const response = await askRequired(chalk.bold.red('Type "yes" to continue:'));
|
|
2411
|
+
if (response !== "yes") {
|
|
2412
|
+
throw new Error("Bootstrap aborted. Confirmation did not match 'yes'.");
|
|
2413
|
+
}
|
|
2414
|
+
logSuccess("Destructive bootstrap reset confirmed");
|
|
2415
|
+
}
|
|
2416
|
+
function cleanupBootstrapState(config) {
|
|
2417
|
+
const volumeNames = STACK_VOLUMES.map((volume) => stackVolumeName(config, volume));
|
|
2418
|
+
const containerIds = listManagedContainers(config);
|
|
2419
|
+
const networkName = stackNetworkName(config);
|
|
2420
|
+
logStep("Removing existing EnvSync deployment resources");
|
|
2421
|
+
if (currentOptions.dryRun) {
|
|
2422
|
+
if (stackExists(config)) {
|
|
2423
|
+
logDryRun(`Would remove stack ${config.services.stack_name}`);
|
|
2424
|
+
logCommand("docker", ["stack", "rm", config.services.stack_name]);
|
|
2425
|
+
} else {
|
|
2426
|
+
logDryRun(`No existing stack named ${config.services.stack_name} found`);
|
|
2427
|
+
}
|
|
2428
|
+
if (containerIds.length > 0) {
|
|
2429
|
+
logDryRun(`Would remove containers: ${containerIds.join(", ")}`);
|
|
2430
|
+
logCommand("docker", ["rm", "-f", ...containerIds]);
|
|
2431
|
+
}
|
|
2432
|
+
logDryRun(`Would remove network ${networkName} if present`);
|
|
2433
|
+
logCommand("docker", ["network", "rm", networkName]);
|
|
2434
|
+
for (const volumeName of volumeNames) {
|
|
2435
|
+
logDryRun(`Would remove volume ${volumeName}`);
|
|
2436
|
+
logCommand("docker", ["volume", "rm", "-f", volumeName]);
|
|
2437
|
+
}
|
|
2438
|
+
logSuccess("Bootstrap cleanup preview completed");
|
|
2439
|
+
return;
|
|
2440
|
+
}
|
|
2441
|
+
if (stackExists(config)) {
|
|
2442
|
+
logCommand("docker", ["stack", "rm", config.services.stack_name]);
|
|
2443
|
+
run("docker", ["stack", "rm", config.services.stack_name]);
|
|
2444
|
+
waitForStackRemoval(config);
|
|
2445
|
+
}
|
|
2446
|
+
const refreshedContainers = listManagedContainers(config);
|
|
2447
|
+
if (refreshedContainers.length > 0) {
|
|
2448
|
+
logCommand("docker", ["rm", "-f", ...refreshedContainers]);
|
|
2449
|
+
const removed = runIgnoringAbsent("docker", ["rm", "-f", ...refreshedContainers], {
|
|
2450
|
+
absentPatterns: ["no such container", "not found"]
|
|
2451
|
+
});
|
|
2452
|
+
if (!removed) {
|
|
2453
|
+
logInfo("Managed containers were already absent");
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
if (commandSucceeds("docker", ["network", "inspect", networkName])) {
|
|
2457
|
+
logCommand("docker", ["network", "rm", networkName]);
|
|
2458
|
+
const removed = runIgnoringAbsent("docker", ["network", "rm", networkName], {
|
|
2459
|
+
absentPatterns: ["network", "not found", "no such network"]
|
|
2460
|
+
});
|
|
2461
|
+
if (!removed) {
|
|
2462
|
+
logInfo(`Network ${networkName} was already absent`);
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
for (const volumeName of volumeNames) {
|
|
2466
|
+
if (commandSucceeds("docker", ["volume", "inspect", volumeName])) {
|
|
2467
|
+
logCommand("docker", ["volume", "rm", "-f", volumeName]);
|
|
2468
|
+
const removed = runIgnoringAbsent("docker", ["volume", "rm", "-f", volumeName], {
|
|
2469
|
+
absentPatterns: ["no such volume", "not found"]
|
|
2470
|
+
});
|
|
2471
|
+
if (!removed) {
|
|
2472
|
+
logInfo(`Volume ${volumeName} was already absent`);
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
logSuccess("Existing EnvSync deployment resources removed");
|
|
2477
|
+
}
|
|
2478
|
+
function serviceHealth(services, name) {
|
|
2479
|
+
return services.get(`${name}`) ?? "missing";
|
|
2480
|
+
}
|
|
2481
|
+
function apiHealth(services, stackName) {
|
|
2482
|
+
const blue = serviceHealth(services, `${stackName}_envsync_api_blue`);
|
|
2483
|
+
const green = serviceHealth(services, `${stackName}_envsync_api_green`);
|
|
2484
|
+
if (blue === "missing" && green === "missing") return "missing";
|
|
2485
|
+
if (blue === "healthy" || green === "healthy") return "healthy";
|
|
2486
|
+
return "degraded";
|
|
2487
|
+
}
|
|
2488
|
+
function waitForApiSlotHealthy(config, slot, timeoutSeconds = 180) {
|
|
2489
|
+
if (currentOptions.dryRun) {
|
|
2490
|
+
logDryRun(`Would wait for API ${slot} slot readiness`);
|
|
2491
|
+
return;
|
|
2492
|
+
}
|
|
2493
|
+
const deadline = Date.now() + timeoutSeconds * 1e3;
|
|
2494
|
+
const serviceName = slotStackServiceName(config, slot);
|
|
2495
|
+
while (Date.now() < deadline) {
|
|
2496
|
+
const services2 = listStackServices(config);
|
|
2497
|
+
if (serviceHealth(services2, serviceName) === "healthy") {
|
|
2498
|
+
logSuccess(`API ${slot} slot is healthy`);
|
|
2499
|
+
return;
|
|
2500
|
+
}
|
|
2501
|
+
sleepSeconds(3);
|
|
2502
|
+
}
|
|
2503
|
+
const services = listStackServices(config);
|
|
2504
|
+
throw new Error(`Timed out waiting for API ${slot} slot to become healthy: ${serviceHealth(services, serviceName)}`);
|
|
2505
|
+
}
|
|
2506
|
+
function waitForHealthyServices(config, checks, timeoutSeconds = 180) {
|
|
2507
|
+
if (currentOptions.dryRun) {
|
|
2508
|
+
logDryRun(`Would wait for ${checks.map((check) => check.label).join(", ")} service readiness`);
|
|
2509
|
+
return;
|
|
2510
|
+
}
|
|
2511
|
+
const deadline = Date.now() + timeoutSeconds * 1e3;
|
|
2512
|
+
while (Date.now() < deadline) {
|
|
2513
|
+
const services2 = listStackServices(config);
|
|
2514
|
+
const pending2 = checks.filter((check) => check.getHealth(services2) !== "healthy");
|
|
2515
|
+
if (pending2.length === 0) {
|
|
2516
|
+
return;
|
|
2517
|
+
}
|
|
2518
|
+
sleepSeconds(3);
|
|
2519
|
+
}
|
|
2520
|
+
const services = listStackServices(config);
|
|
2521
|
+
const pending = checks.map((check) => `${check.label}=${check.getHealth(services)}`).join(", ");
|
|
2522
|
+
throw new Error(`Timed out waiting for deployed services to become healthy: ${pending}`);
|
|
2523
|
+
}
|
|
2524
|
+
function deployRenderedStack(config, label) {
|
|
2525
|
+
if (currentOptions.dryRun) {
|
|
2526
|
+
logDryRun(`Would deploy ${label} stack for ${config.services.stack_name}`);
|
|
2527
|
+
logCommand("docker", ["stack", "deploy", "-c", STACK_FILE, config.services.stack_name]);
|
|
2528
|
+
return;
|
|
2529
|
+
}
|
|
2530
|
+
logStep(`Deploying ${label} stack`);
|
|
2531
|
+
logCommand("docker", ["stack", "deploy", "-c", STACK_FILE, config.services.stack_name]);
|
|
2532
|
+
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
|
2533
|
+
const result = spawnSync("docker", ["stack", "deploy", "-c", STACK_FILE, config.services.stack_name], {
|
|
2534
|
+
env: process.env,
|
|
2535
|
+
stdio: "pipe",
|
|
2536
|
+
encoding: "utf8"
|
|
2537
|
+
});
|
|
2538
|
+
const stdout = typeof result.stdout === "string" ? result.stdout.trim() : "";
|
|
2539
|
+
const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
|
|
2540
|
+
if (stdout) console.log(stdout);
|
|
2541
|
+
if (stderr) console.error(stderr);
|
|
2542
|
+
if (result.status === 0) {
|
|
2543
|
+
logSuccess(`${label} stack deployed`);
|
|
2544
|
+
return;
|
|
2545
|
+
}
|
|
2546
|
+
const combined = `${stdout}
|
|
2547
|
+
${stderr}`.toLowerCase();
|
|
2548
|
+
if (attempt < 5 && combined.includes("update out of sequence")) {
|
|
2549
|
+
logWarn(`Swarm reported an out-of-sequence service update while deploying ${label}; retrying (${attempt}/5)`);
|
|
2550
|
+
sleepSeconds(3);
|
|
2551
|
+
continue;
|
|
2552
|
+
}
|
|
2553
|
+
throw new Error(`Command failed: docker stack deploy -c ${STACK_FILE} ${config.services.stack_name}${stderr ? `
|
|
2554
|
+
${stderr}` : ""}`);
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
function persistGeneratedState(config, generated) {
|
|
2558
|
+
if (currentOptions.dryRun) {
|
|
2559
|
+
logDryRun("Would persist generated deployment state");
|
|
2560
|
+
return;
|
|
2561
|
+
}
|
|
2562
|
+
writeDeployArtifacts(config, generated);
|
|
2563
|
+
}
|
|
2564
|
+
function formatHealthStatus(status) {
|
|
2565
|
+
if (status === true || status === "healthy") {
|
|
2566
|
+
return `${chalk.green("\u25CF")} ${chalk.green(typeof status === "boolean" ? "yes" : status)}`;
|
|
2567
|
+
}
|
|
2568
|
+
if (status === false || status === "missing") {
|
|
2569
|
+
return `${chalk.red("\u25CF")} ${chalk.red(typeof status === "boolean" ? "no" : status)}`;
|
|
2570
|
+
}
|
|
2571
|
+
return `${chalk.yellow("\u25CF")} ${chalk.yellow(status)}`;
|
|
2572
|
+
}
|
|
2573
|
+
function printHealthLine(label, value) {
|
|
2574
|
+
console.log(` ${chalk.dim(label.padEnd(22))} ${value}`);
|
|
2575
|
+
}
|
|
2576
|
+
function printHealthSection(title) {
|
|
2577
|
+
console.log(`
|
|
2578
|
+
${chalk.bold.blue(title)}`);
|
|
2579
|
+
}
|
|
2580
|
+
function printHealthSummary(checks) {
|
|
2581
|
+
printHealthSection("EnvSync Health");
|
|
2582
|
+
printHealthLine("Bootstrap", formatHealthStatus(checks.bootstrap.completed));
|
|
2583
|
+
printHealthLine("Deploy API", formatHealthStatus(checks.deploy.api));
|
|
2584
|
+
printHealthLine("Web", formatHealthStatus(checks.deploy.web));
|
|
2585
|
+
printHealthLine("Landing", formatHealthStatus(checks.deploy.landing));
|
|
2586
|
+
printHealthLine("ClickStack", formatHealthStatus(checks.observability.service));
|
|
2587
|
+
printHealthLine("Active slot", chalk.cyan(checks.deploy.active_slot));
|
|
2588
|
+
printHealthLine("Maintenance mode", checks.deploy.maintenance_mode ? chalk.yellow("enabled") : chalk.green("disabled"));
|
|
2589
|
+
if (checks.deploy.previous_slot) {
|
|
2590
|
+
printHealthLine("Rollback slot", chalk.yellow(checks.deploy.previous_slot));
|
|
2591
|
+
}
|
|
2592
|
+
printHealthSection("Bootstrap");
|
|
2593
|
+
printHealthLine("Completed", formatHealthStatus(checks.bootstrap.completed));
|
|
2594
|
+
printHealthLine("Completed at", checks.bootstrap.completed_at ?? chalk.dim("not completed"));
|
|
2595
|
+
for (const [service, health] of Object.entries(checks.bootstrap.services)) {
|
|
2596
|
+
printHealthLine(service, formatHealthStatus(health));
|
|
2597
|
+
}
|
|
2598
|
+
printHealthSection("Deployment");
|
|
2599
|
+
for (const [slot, data] of Object.entries(checks.deploy.api_slots)) {
|
|
2600
|
+
const heading = `${slot}${data.active ? " (active)" : ""}`;
|
|
2601
|
+
printHealthLine(heading, formatHealthStatus(data.service));
|
|
2602
|
+
if (data.release_version) printHealthLine(`${slot} release`, `v${data.release_version}`);
|
|
2603
|
+
if (data.image) printHealthLine(`${slot} image`, chalk.dim(data.image));
|
|
2604
|
+
if (data.deployed_at) printHealthLine(`${slot} deployed`, data.deployed_at);
|
|
2605
|
+
}
|
|
2606
|
+
printHealthSection("Database");
|
|
2607
|
+
printHealthLine("API migration head", checks.database.api.migration_head ?? chalk.dim("none"));
|
|
2608
|
+
printHealthLine("DB auto migrate", checks.database.api.auto_migrate_enabled ? chalk.red("enabled") : chalk.green("disabled"));
|
|
2609
|
+
if (checks.database.api.error) {
|
|
2610
|
+
printHealthLine("Migration probe", chalk.yellow(checks.database.api.error));
|
|
2611
|
+
}
|
|
2612
|
+
printHealthSection("Observability");
|
|
2613
|
+
printHealthLine("Sessions source", formatHealthStatus(checks.observability.sessions_source.configured));
|
|
2614
|
+
printHealthLine("Saved searches", formatHealthStatus(checks.observability.saved_searches.configured));
|
|
2615
|
+
if (checks.observability.saved_searches.missing.length > 0) {
|
|
2616
|
+
printHealthLine("Missing searches", chalk.yellow(checks.observability.saved_searches.missing.join(", ")));
|
|
2617
|
+
}
|
|
2618
|
+
printHealthLine("Tags", formatHealthStatus(checks.observability.tags.configured));
|
|
2619
|
+
if (checks.observability.tags.missing.length > 0) {
|
|
2620
|
+
printHealthLine("Missing tags", chalk.yellow(checks.observability.tags.missing.join(", ")));
|
|
2621
|
+
}
|
|
2622
|
+
printHealthLine("Replay web", formatHealthStatus(checks.observability.browser_replay_runtime.web.configured));
|
|
2623
|
+
printHealthLine("Replay landing", formatHealthStatus(checks.observability.browser_replay_runtime.landing.configured));
|
|
2624
|
+
printHealthLine("Obs UI", checks.observability.obs_ui.url);
|
|
2625
|
+
printHealthLine("Obs API", checks.observability.obs_api.url);
|
|
2626
|
+
printHealthLine("Obs OTLP", checks.observability.obs_otlp.url);
|
|
2627
|
+
printHealthSection("Frontend Runtime");
|
|
2628
|
+
printHealthLine("Web API", checks.frontend_runtime.web.api_base_url ?? chalk.dim("missing"));
|
|
2629
|
+
printHealthLine("Web App", checks.frontend_runtime.web.app_base_url ?? chalk.dim("missing"));
|
|
2630
|
+
printHealthLine("Web Auth", checks.frontend_runtime.web.auth_base_url ?? chalk.dim("missing"));
|
|
2631
|
+
printHealthLine("Web Docs", checks.frontend_runtime.web.api_docs_url ?? chalk.dim("missing"));
|
|
2632
|
+
if (checks.frontend_runtime.web.release_version) {
|
|
2633
|
+
printHealthLine("Web Release", `v${checks.frontend_runtime.web.release_version}`);
|
|
2634
|
+
}
|
|
2635
|
+
if (checks.frontend_runtime.web.active_api_slot) {
|
|
2636
|
+
printHealthLine("Web Slot", checks.frontend_runtime.web.active_api_slot);
|
|
2637
|
+
}
|
|
2638
|
+
printHealthLine("Landing API", checks.frontend_runtime.landing.api_base_url ?? chalk.dim("missing"));
|
|
2639
|
+
printHealthLine("Landing App", checks.frontend_runtime.landing.app_base_url ?? chalk.dim("missing"));
|
|
2640
|
+
printHealthLine("Landing Auth", checks.frontend_runtime.landing.auth_base_url ?? chalk.dim("missing"));
|
|
2641
|
+
printHealthLine("Landing Docs", checks.frontend_runtime.landing.api_docs_url ?? chalk.dim("missing"));
|
|
2642
|
+
if (checks.frontend_runtime.landing.release_version) {
|
|
2643
|
+
printHealthLine("Landing Release", `v${checks.frontend_runtime.landing.release_version}`);
|
|
2644
|
+
}
|
|
2645
|
+
if (checks.frontend_runtime.landing.active_api_slot) {
|
|
2646
|
+
printHealthLine("Landing Slot", checks.frontend_runtime.landing.active_api_slot);
|
|
2647
|
+
}
|
|
2648
|
+
printHealthSection("Public URLs");
|
|
2649
|
+
for (const [label, url] of Object.entries(checks.public)) {
|
|
2650
|
+
printHealthLine(label, url);
|
|
2651
|
+
}
|
|
2652
|
+
printHealthSection("Next Steps");
|
|
2653
|
+
if (!checks.bootstrap.completed) {
|
|
2654
|
+
printHealthLine("Recommended", chalk.cyan("envsync-deploy bootstrap"));
|
|
2655
|
+
return;
|
|
2656
|
+
}
|
|
2657
|
+
if (checks.deploy.api !== "healthy" || checks.deploy.web !== "healthy" || checks.edition !== "oss" && checks.deploy.landing !== "healthy") {
|
|
2658
|
+
printHealthLine("Recommended", chalk.cyan("envsync-deploy deploy"));
|
|
2659
|
+
return;
|
|
2660
|
+
}
|
|
2661
|
+
printHealthLine("Inspect JSON", chalk.cyan("envsync-deploy health --json"));
|
|
2662
|
+
printHealthLine("Upgrade", chalk.cyan("envsync-deploy upgrade"));
|
|
2663
|
+
printHealthLine("Backup", chalk.cyan("envsync-deploy backup"));
|
|
2664
|
+
}
|
|
2665
|
+
async function cmdPreinstall() {
|
|
2666
|
+
ensureDir(HOST_ROOT);
|
|
2667
|
+
ensureDir(DEPLOY_ROOT);
|
|
2668
|
+
ensureDir(RELEASES_ROOT);
|
|
2669
|
+
ensureDir(BACKUPS_ROOT);
|
|
2670
|
+
ensureDir(ETC_ROOT);
|
|
2671
|
+
ensureDir(TRAEFIK_STATE_ROOT);
|
|
2672
|
+
run("bash", ["-lc", "command -v apt-get >/dev/null"]);
|
|
2673
|
+
run("sudo", ["apt-get", "update"]);
|
|
2674
|
+
run("sudo", ["apt-get", "install", "-y", "docker.io", "docker-compose-v2", "git", "curl", "jq", "openssl", "tar"]);
|
|
2675
|
+
run("sudo", ["systemctl", "enable", "--now", "docker"]);
|
|
2676
|
+
try {
|
|
2677
|
+
run("docker", ["swarm", "init"]);
|
|
2678
|
+
} catch {
|
|
2679
|
+
}
|
|
2680
|
+
run("docker", ["buildx", "version"]);
|
|
2681
|
+
run("bash", ["-lc", "curl -fsSL https://ghcr.io >/dev/null"]);
|
|
2682
|
+
run("bash", ["-lc", "curl -fsSL https://acme-v02.api.letsencrypt.org/directory >/dev/null"]);
|
|
2683
|
+
}
|
|
2684
|
+
async function cmdSetup() {
|
|
2685
|
+
logSection("Setup");
|
|
2686
|
+
const rootDomain = await ask("Root domain", "example.com");
|
|
2687
|
+
const acmeEmail = await ask("ACME email", `admin@${rootDomain}`);
|
|
2688
|
+
const releaseVersion = await ask("Release version", getDeployCliVersion());
|
|
2689
|
+
assertSemverVersion(releaseVersion, "release version");
|
|
2690
|
+
const releaseImages = versionedImages(releaseVersion);
|
|
2691
|
+
const adminUser = await ask("Keycloak admin user", "admin");
|
|
2692
|
+
const adminPassword = await ask("Keycloak admin password", randomSecret(12));
|
|
2693
|
+
const smtpHost = await ask("SMTP host", "smtp.example.com");
|
|
2694
|
+
const smtpPort = Number(await ask("SMTP port", "587"));
|
|
2695
|
+
const smtpSecure = await ask("SMTP secure (true/false)", "true") === "true";
|
|
2696
|
+
const smtpUser = await ask("SMTP user", "");
|
|
2697
|
+
const smtpPass = await ask("SMTP pass", "");
|
|
2698
|
+
const smtpFrom = await ask("SMTP from", `noreply@${rootDomain}`);
|
|
2699
|
+
const retentionDays = Number(await ask("ClickStack retention days", "30"));
|
|
2700
|
+
const publicAuth = await ask("Expose auth.<domain> publicly (true/false)", "true") === "true";
|
|
2701
|
+
const publicObs = await ask("Expose obs.<domain> publicly (true/false)", "true") === "true";
|
|
2702
|
+
const mailpitEnabled = await ask("Enable mailpit (true/false)", "false") === "true";
|
|
2703
|
+
const config = {
|
|
2704
|
+
source: defaultSourceConfig(releaseVersion),
|
|
2705
|
+
release: {
|
|
2706
|
+
version: releaseVersion
|
|
2707
|
+
},
|
|
2708
|
+
domain: { root_domain: rootDomain, acme_email: acmeEmail },
|
|
2709
|
+
images: {
|
|
2710
|
+
api: releaseImages.api,
|
|
2711
|
+
keycloak: releaseImages.keycloak,
|
|
2712
|
+
web: releaseImages.web,
|
|
2713
|
+
landing: releaseImages.landing,
|
|
2714
|
+
clickstack: "clickhouse/clickstack-all-in-one:latest",
|
|
2715
|
+
traefik: "traefik:v3.6.6",
|
|
2716
|
+
otel_agent: "otel/opentelemetry-collector-contrib:0.111.0"
|
|
2717
|
+
},
|
|
2718
|
+
services: {
|
|
2719
|
+
stack_name: "envsync",
|
|
2720
|
+
api_port: 4e3,
|
|
2721
|
+
public_http_port: 80,
|
|
2722
|
+
public_https_port: 443,
|
|
2723
|
+
clickstack_ui_port: 8080,
|
|
2724
|
+
clickstack_otlp_http_port: 4318,
|
|
2725
|
+
clickstack_otlp_grpc_port: 4317,
|
|
2726
|
+
keycloak_port: 8080,
|
|
2727
|
+
rustfs_port: 9e3,
|
|
2728
|
+
rustfs_console_port: 9001
|
|
2729
|
+
},
|
|
2730
|
+
auth: {
|
|
2731
|
+
keycloak_realm: "envsync",
|
|
2732
|
+
admin_user: adminUser,
|
|
2733
|
+
admin_password: adminPassword,
|
|
2734
|
+
web_client_id: "envsync-web",
|
|
2735
|
+
api_client_id: "envsync-api",
|
|
2736
|
+
cli_client_id: "envsync-cli"
|
|
2737
|
+
},
|
|
2738
|
+
observability: {
|
|
2739
|
+
retention_days: retentionDays,
|
|
2740
|
+
public_obs: publicObs
|
|
2741
|
+
},
|
|
2742
|
+
backup: {
|
|
2743
|
+
output_dir: BACKUPS_ROOT,
|
|
2744
|
+
encrypted: true
|
|
2745
|
+
},
|
|
2746
|
+
smtp: {
|
|
2747
|
+
host: smtpHost,
|
|
2748
|
+
port: smtpPort,
|
|
2749
|
+
secure: smtpSecure,
|
|
2750
|
+
user: smtpUser,
|
|
2751
|
+
pass: smtpPass,
|
|
2752
|
+
from: smtpFrom
|
|
2753
|
+
},
|
|
2754
|
+
exposure: {
|
|
2755
|
+
public_auth: publicAuth,
|
|
2756
|
+
public_obs: publicObs,
|
|
2757
|
+
mailpit_enabled: mailpitEnabled,
|
|
2758
|
+
s3_public: true,
|
|
2759
|
+
s3_console_public: true
|
|
2760
|
+
},
|
|
2761
|
+
upgrade: {
|
|
2762
|
+
maintenance_mode_enabled: true,
|
|
2763
|
+
db_snapshot_on_api_upgrade: true,
|
|
2764
|
+
keep_failed_upgrade_db_snapshot: true
|
|
2765
|
+
}
|
|
2766
|
+
};
|
|
2767
|
+
saveDesiredConfig(config);
|
|
2768
|
+
logSuccess(`Config written to ${DEPLOY_YAML}`);
|
|
2769
|
+
logInfo(`Pinned source checkout: ${config.source.repo_url} @ ${config.source.ref}`);
|
|
2770
|
+
logInfo("Create these DNS records:");
|
|
2771
|
+
console.log(JSON.stringify(domainMap2(rootDomain), null, 2));
|
|
2772
|
+
}
|
|
2773
|
+
async function cmdBootstrap() {
|
|
2774
|
+
logSection("Bootstrap");
|
|
2775
|
+
const { config, generated } = loadState();
|
|
2776
|
+
const nextGenerated = ensureGeneratedRuntimeState(config, resetBootstrapGeneratedState(generated));
|
|
2777
|
+
const runtimeEnv = buildRuntimeEnv(config, nextGenerated);
|
|
2778
|
+
logReleaseContext(config);
|
|
2779
|
+
assertSwarmManager();
|
|
2780
|
+
if (currentOptions.dryRun) {
|
|
2781
|
+
logWarn("Dry-run mode: bootstrap reset will be previewed but not executed.");
|
|
2782
|
+
}
|
|
2783
|
+
await confirmBootstrapReset(config);
|
|
2784
|
+
cleanupBootstrapState(config);
|
|
2785
|
+
ensureRepoCheckout(config);
|
|
2786
|
+
writeDeployArtifacts(config, nextGenerated);
|
|
2787
|
+
buildKeycloakImage(config.images.keycloak);
|
|
2788
|
+
if (currentOptions.dryRun) {
|
|
2789
|
+
logDryRun(`Would deploy base bootstrap stack for ${config.services.stack_name}`);
|
|
2790
|
+
logCommand("docker", ["stack", "deploy", "-c", BOOTSTRAP_BASE_STACK_FILE, config.services.stack_name]);
|
|
2791
|
+
} else {
|
|
2792
|
+
logStep("Deploying base bootstrap stack");
|
|
2793
|
+
logCommand("docker", ["stack", "deploy", "-c", BOOTSTRAP_BASE_STACK_FILE, config.services.stack_name]);
|
|
2794
|
+
run("docker", ["stack", "deploy", "-c", BOOTSTRAP_BASE_STACK_FILE, config.services.stack_name]);
|
|
2795
|
+
logSuccess("Base bootstrap stack deployed");
|
|
2796
|
+
}
|
|
2797
|
+
waitForPostgresService(config, "postgres", "postgres", "postgres", "envsync-postgres");
|
|
2798
|
+
waitForRedisService(config);
|
|
2799
|
+
waitForTcpService(config, "rustfs", "rustfs", 9e3);
|
|
2800
|
+
waitForPostgresService(config, "keycloak", "keycloak_db", "keycloak", runtimeEnv.KEYCLOAK_DB_PASSWORD);
|
|
2801
|
+
waitForPostgresService(config, "openfga", "openfga_db", "openfga", runtimeEnv.OPENFGA_DB_PASSWORD);
|
|
2802
|
+
waitForPostgresService(config, "minikms", "minikms_db", "postgres", runtimeEnv.MINIKMS_DB_PASSWORD);
|
|
2803
|
+
runOpenFgaMigrate(config, runtimeEnv);
|
|
2804
|
+
runMiniKmsMigrate(config, runtimeEnv);
|
|
2805
|
+
if (currentOptions.dryRun) {
|
|
2806
|
+
logDryRun(`Would deploy runtime bootstrap stack for ${config.services.stack_name}`);
|
|
2807
|
+
logCommand("docker", ["stack", "deploy", "-c", BOOTSTRAP_STACK_FILE, config.services.stack_name]);
|
|
2808
|
+
} else {
|
|
2809
|
+
logStep("Deploying runtime bootstrap stack");
|
|
2810
|
+
logCommand("docker", ["stack", "deploy", "-c", BOOTSTRAP_STACK_FILE, config.services.stack_name]);
|
|
2811
|
+
run("docker", ["stack", "deploy", "-c", BOOTSTRAP_STACK_FILE, config.services.stack_name]);
|
|
2812
|
+
logSuccess("Runtime bootstrap stack deployed");
|
|
2813
|
+
}
|
|
2814
|
+
waitForHttpService(config, "keycloak management readiness", "http://keycloak:9000/health/ready", 180);
|
|
2815
|
+
waitForHttpService(config, "openfga", "http://openfga:8090/stores");
|
|
2816
|
+
waitForTcpService(config, "minikms", "minikms", 50051);
|
|
2817
|
+
logStep("Running API database migrations");
|
|
2818
|
+
runApiMigrationJsonCommand(config, config.images.api, ["latest"]);
|
|
2819
|
+
logSuccess("API database migrations completed");
|
|
2820
|
+
const initResult = runBootstrapInit(config);
|
|
2821
|
+
const clickstackBootstrapResult = runClickstackBootstrap(config);
|
|
2822
|
+
const persistedGenerated = normalizeGeneratedState({
|
|
2823
|
+
openfga: {
|
|
2824
|
+
store_id: initResult.openfgaStoreId,
|
|
2825
|
+
model_id: initResult.openfgaModelId
|
|
2826
|
+
},
|
|
2827
|
+
deployment: nextGenerated.deployment,
|
|
2828
|
+
clickstack: {
|
|
2829
|
+
...nextGenerated.clickstack,
|
|
2830
|
+
browser_api_key: clickstackBootstrapResult.browserApiKey ?? nextGenerated.clickstack.browser_api_key
|
|
2831
|
+
},
|
|
2832
|
+
secrets: nextGenerated.secrets,
|
|
2833
|
+
bootstrap: nextGenerated.bootstrap
|
|
2834
|
+
});
|
|
2835
|
+
if (!currentOptions.dryRun) {
|
|
2836
|
+
writeDeployArtifacts(config, persistedGenerated);
|
|
2837
|
+
}
|
|
2838
|
+
if (currentOptions.dryRun) {
|
|
2839
|
+
logDryRun("Skipping generated OpenFGA ID persistence in preview mode");
|
|
2840
|
+
logSuccess("Bootstrap dry-run completed");
|
|
2841
|
+
return;
|
|
2842
|
+
}
|
|
2843
|
+
const bootstrappedGenerated = normalizeGeneratedState({
|
|
2844
|
+
openfga: {
|
|
2845
|
+
store_id: initResult.openfgaStoreId,
|
|
2846
|
+
model_id: initResult.openfgaModelId
|
|
2847
|
+
},
|
|
2848
|
+
deployment: nextGenerated.deployment,
|
|
2849
|
+
clickstack: persistedGenerated.clickstack,
|
|
2850
|
+
secrets: nextGenerated.secrets,
|
|
2851
|
+
bootstrap: {
|
|
2852
|
+
completed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2853
|
+
}
|
|
2854
|
+
});
|
|
2855
|
+
writeDeployArtifacts(config, bootstrappedGenerated);
|
|
2856
|
+
logClickstackCredentials(bootstrappedGenerated);
|
|
2857
|
+
logSuccess("Bootstrap completed");
|
|
2858
|
+
}
|
|
2859
|
+
async function cmdDeploy() {
|
|
2860
|
+
logSection("Deploy");
|
|
2861
|
+
const { config, generated } = loadState();
|
|
2862
|
+
logReleaseContext(config);
|
|
2863
|
+
assertSwarmManager();
|
|
2864
|
+
assertBootstrapState(generated);
|
|
2865
|
+
if (!currentOptions.dryRun) {
|
|
2866
|
+
const services = listStackServices(config);
|
|
2867
|
+
if (serviceHealth(services, `${config.services.stack_name}_keycloak`) === "missing" || serviceHealth(services, `${config.services.stack_name}_openfga`) === "missing" || serviceHealth(services, `${config.services.stack_name}_minikms`) === "missing") {
|
|
2868
|
+
logWarn("Bootstrap services are not running. Recreating from persisted bootstrap state.");
|
|
2869
|
+
}
|
|
2870
|
+
} else {
|
|
2871
|
+
logDryRun("Skipping runtime bootstrap service validation");
|
|
2872
|
+
}
|
|
2873
|
+
ensureRepoCheckout(config);
|
|
2874
|
+
buildKeycloakImage(config.images.keycloak);
|
|
2875
|
+
if (!currentOptions.dryRun) {
|
|
2876
|
+
ensureDir(currentReleaseDir("web"));
|
|
2877
|
+
if (!isOssConfig2(config)) {
|
|
2878
|
+
ensureDir(currentReleaseDir("landing"));
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2881
|
+
stageFrontendRelease("web", config.images.web, config.release.version);
|
|
2882
|
+
if (!isOssConfig2(config)) {
|
|
2883
|
+
stageFrontendRelease("landing", config.images.landing, config.release.version);
|
|
2884
|
+
}
|
|
2885
|
+
const originalState = normalizeGeneratedState(generated);
|
|
2886
|
+
let currentState = originalState;
|
|
2887
|
+
let promoted = false;
|
|
2888
|
+
const candidateDeployment = createPromotionCandidateState(config, currentState);
|
|
2889
|
+
if (candidateDeployment) {
|
|
2890
|
+
const targetSlot = otherApiSlot(candidateDeployment.active_slot);
|
|
2891
|
+
const candidateState = normalizeGeneratedState({
|
|
2892
|
+
...currentState,
|
|
2893
|
+
deployment: {
|
|
2894
|
+
...candidateDeployment,
|
|
2895
|
+
maintenance_mode: config.upgrade.maintenance_mode_enabled
|
|
2896
|
+
}
|
|
2897
|
+
});
|
|
2898
|
+
const activeImageBeforeUpgrade = currentState.deployment.slots[currentState.deployment.active_slot].api_image || config.images.api;
|
|
2899
|
+
const activeReleaseBeforeUpgrade = currentState.deployment.slots[currentState.deployment.active_slot].release_version || config.release.version;
|
|
2900
|
+
const preUpgradeHead = runApiMigrationJsonCommand(config, activeImageBeforeUpgrade, ["head"]).currentHead;
|
|
2901
|
+
let snapshotPath = null;
|
|
2902
|
+
let usedSnapshotRestore = false;
|
|
2903
|
+
writeDeployArtifacts(config, candidateState);
|
|
2904
|
+
try {
|
|
2905
|
+
logInfo(`Deploying release ${config.release.version} into inactive API slot ${targetSlot}`);
|
|
2906
|
+
deployRenderedStack(config, `candidate ${targetSlot}`);
|
|
2907
|
+
logInfo(`API migration head before upgrade: ${preUpgradeHead ?? "none"}`);
|
|
2908
|
+
if (config.upgrade.db_snapshot_on_api_upgrade) {
|
|
2909
|
+
snapshotPath = createApiDbUpgradeBackup(config, activeReleaseBeforeUpgrade, config.release.version);
|
|
2910
|
+
}
|
|
2911
|
+
runApiMigrationJsonCommand(config, config.images.api, ["latest"]);
|
|
2912
|
+
const postUpgradeHead = runApiMigrationJsonCommand(config, config.images.api, ["head"]).currentHead;
|
|
2913
|
+
logInfo(`API migration head after upgrade: ${postUpgradeHead ?? "none"}`);
|
|
2914
|
+
waitForApiSlotHealthy(config, targetSlot);
|
|
2915
|
+
currentState = normalizeGeneratedState({
|
|
2916
|
+
...candidateState,
|
|
2917
|
+
deployment: {
|
|
2918
|
+
...createPromotedApiDeploymentState(config, candidateState),
|
|
2919
|
+
maintenance_mode: false
|
|
2920
|
+
}
|
|
2921
|
+
});
|
|
2922
|
+
writeDeployArtifacts(config, currentState);
|
|
2923
|
+
activateFrontendReleaseForState(config, currentState, config.release.version);
|
|
2924
|
+
sleepSeconds(3);
|
|
2925
|
+
promoted = true;
|
|
2926
|
+
if (snapshotPath && !currentOptions.dryRun) {
|
|
2927
|
+
fs2.rmSync(snapshotPath, { force: true });
|
|
2928
|
+
}
|
|
2929
|
+
} catch (error) {
|
|
2930
|
+
const rollbackTarget = preUpgradeHead ?? "zero";
|
|
2931
|
+
try {
|
|
2932
|
+
logWarn(`Candidate deploy failed after migration. Rolling schema back to ${rollbackTarget}.`);
|
|
2933
|
+
runApiMigrationJsonCommand(config, config.images.api, ["rollback_to", rollbackTarget]);
|
|
2934
|
+
} catch (rollbackError) {
|
|
2935
|
+
if (!snapshotPath) {
|
|
2936
|
+
throw new Error(
|
|
2937
|
+
`Deploy failed and migration rollback failed: ${error instanceof Error ? error.message : String(error)}
|
|
2938
|
+
${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`
|
|
2939
|
+
);
|
|
2940
|
+
}
|
|
2941
|
+
logWarn(`Migration rollback failed. Restoring DB snapshot ${snapshotPath}.`);
|
|
2942
|
+
restoreApiDbUpgradeBackup(config, snapshotPath);
|
|
2943
|
+
usedSnapshotRestore = true;
|
|
2944
|
+
}
|
|
2945
|
+
const recoveredState = normalizeGeneratedState({
|
|
2946
|
+
...originalState,
|
|
2947
|
+
deployment: {
|
|
2948
|
+
...originalState.deployment,
|
|
2949
|
+
maintenance_mode: false
|
|
2950
|
+
}
|
|
2951
|
+
});
|
|
2952
|
+
writeDeployArtifacts(config, recoveredState);
|
|
2953
|
+
deployRenderedStack(config, "rollback recovery");
|
|
2954
|
+
waitForApiSlotHealthy(config, recoveredState.deployment.active_slot);
|
|
2955
|
+
if (snapshotPath && !config.upgrade.keep_failed_upgrade_db_snapshot && !currentOptions.dryRun) {
|
|
2956
|
+
fs2.rmSync(snapshotPath, { force: true });
|
|
2957
|
+
}
|
|
2958
|
+
throw new Error(
|
|
2959
|
+
usedSnapshotRestore ? `Deploy failed after DB migration. Previous release was restored using DB snapshot recovery. ${error instanceof Error ? error.message : String(error)}` : `Deploy failed after DB migration. Previous release was restored. ${error instanceof Error ? error.message : String(error)}`
|
|
2960
|
+
);
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
2963
|
+
currentState = normalizeGeneratedState({
|
|
2964
|
+
...currentState,
|
|
2965
|
+
deployment: {
|
|
2966
|
+
...createSteadyApiDeploymentState2(config, currentState),
|
|
2967
|
+
maintenance_mode: false
|
|
2968
|
+
}
|
|
2969
|
+
});
|
|
2970
|
+
writeDeployArtifacts(config, currentState);
|
|
2971
|
+
if (!promoted) {
|
|
2972
|
+
activateFrontendReleaseForState(config, currentState, config.release.version);
|
|
2973
|
+
deployRenderedStack(config, "steady");
|
|
2974
|
+
}
|
|
2975
|
+
waitForHealthyServices(config, [
|
|
2976
|
+
{ label: "traefik", getHealth: (services) => serviceHealth(services, `${config.services.stack_name}_traefik`) },
|
|
2977
|
+
{ label: "keycloak", getHealth: (services) => serviceHealth(services, `${config.services.stack_name}_keycloak`) },
|
|
2978
|
+
{ label: "openfga", getHealth: (services) => serviceHealth(services, `${config.services.stack_name}_openfga`) },
|
|
2979
|
+
{ label: "minikms", getHealth: (services) => serviceHealth(services, `${config.services.stack_name}_minikms`) },
|
|
2980
|
+
{ label: "clickstack", getHealth: (services) => serviceHealth(services, `${config.services.stack_name}_clickstack`) },
|
|
2981
|
+
...isOssConfig2(config) ? [] : [{ label: "landing", getHealth: (services) => serviceHealth(services, `${config.services.stack_name}_landing_nginx`) }],
|
|
2982
|
+
{ label: "web", getHealth: (services) => serviceHealth(services, `${config.services.stack_name}_web_nginx`) },
|
|
2983
|
+
{ label: "api", getHealth: (services) => apiHealth(services, config.services.stack_name) }
|
|
2984
|
+
]);
|
|
2985
|
+
waitForApiSlotHealthy(config, currentState.deployment.active_slot);
|
|
2986
|
+
currentState = normalizeGeneratedState({
|
|
2987
|
+
...currentState,
|
|
2988
|
+
deployment: markActiveApiSlotDeployed(config, currentState.deployment)
|
|
2989
|
+
});
|
|
2990
|
+
persistGeneratedState(config, currentState);
|
|
2991
|
+
logInfo(`Active API slot: ${currentState.deployment.active_slot}`);
|
|
2992
|
+
if (currentState.deployment.previous_slot) {
|
|
2993
|
+
logInfo(`Rollback slot: ${currentState.deployment.previous_slot}`);
|
|
2994
|
+
}
|
|
2995
|
+
logSuccess("Deploy completed");
|
|
2996
|
+
}
|
|
2997
|
+
async function cmdPromote(target) {
|
|
2998
|
+
logSection("Promote");
|
|
2999
|
+
const { config, generated } = loadState();
|
|
3000
|
+
logReleaseContext(config);
|
|
3001
|
+
assertSwarmManager();
|
|
3002
|
+
assertBootstrapState(generated);
|
|
3003
|
+
const currentState = normalizeGeneratedState(generated);
|
|
3004
|
+
const activeSlot = currentState.deployment.active_slot;
|
|
3005
|
+
const targetSlot = target === "blue" || target === "green" ? target : otherApiSlot(activeSlot);
|
|
3006
|
+
if (targetSlot === activeSlot) {
|
|
3007
|
+
logInfo(`API slot ${targetSlot} is already active`);
|
|
3008
|
+
return;
|
|
3009
|
+
}
|
|
3010
|
+
if (!slotHasApiDeployment2(currentState.deployment.slots[targetSlot])) {
|
|
3011
|
+
throw new Error(`Cannot promote API slot '${targetSlot}' because it has no deployed image recorded.`);
|
|
3012
|
+
}
|
|
3013
|
+
const promotedState = normalizeGeneratedState({
|
|
3014
|
+
...currentState,
|
|
3015
|
+
deployment: {
|
|
3016
|
+
active_slot: targetSlot,
|
|
3017
|
+
previous_slot: activeSlot,
|
|
3018
|
+
maintenance_mode: false,
|
|
3019
|
+
slots: currentState.deployment.slots
|
|
3020
|
+
}
|
|
3021
|
+
});
|
|
3022
|
+
writeDeployArtifacts(config, promotedState);
|
|
3023
|
+
if (exists2(releaseAssetDir("web", promotedState.deployment.slots[targetSlot].release_version)) && exists2(releaseAssetDir("landing", promotedState.deployment.slots[targetSlot].release_version))) {
|
|
3024
|
+
activateFrontendReleaseForState(config, promotedState);
|
|
3025
|
+
} else {
|
|
3026
|
+
logWarn(`Missing staged frontend assets for release ${promotedState.deployment.slots[targetSlot].release_version}; leaving current frontend assets unchanged.`);
|
|
3027
|
+
}
|
|
3028
|
+
sleepSeconds(3);
|
|
3029
|
+
waitForApiSlotHealthy(config, targetSlot);
|
|
3030
|
+
const persistedState = normalizeGeneratedState({
|
|
3031
|
+
...promotedState,
|
|
3032
|
+
deployment: touchApiSlotDeployment(promotedState.deployment, targetSlot)
|
|
3033
|
+
});
|
|
3034
|
+
persistGeneratedState(config, persistedState);
|
|
3035
|
+
logInfo(`Active API slot: ${targetSlot}`);
|
|
3036
|
+
logInfo(`Rollback slot: ${activeSlot}`);
|
|
3037
|
+
logSuccess("Promotion completed");
|
|
3038
|
+
}
|
|
3039
|
+
async function cmdRollback() {
|
|
3040
|
+
logSection("Rollback");
|
|
3041
|
+
const { config, generated } = loadState();
|
|
3042
|
+
logReleaseContext(config);
|
|
3043
|
+
assertSwarmManager();
|
|
3044
|
+
assertBootstrapState(generated);
|
|
3045
|
+
const currentState = normalizeGeneratedState(generated);
|
|
3046
|
+
const rollbackState = normalizeGeneratedState({
|
|
3047
|
+
...currentState,
|
|
3048
|
+
deployment: {
|
|
3049
|
+
...createRolledBackApiDeploymentState(currentState),
|
|
3050
|
+
maintenance_mode: false
|
|
3051
|
+
}
|
|
3052
|
+
});
|
|
3053
|
+
writeDeployArtifacts(config, rollbackState);
|
|
3054
|
+
if (exists2(releaseAssetDir("web", rollbackState.deployment.slots[rollbackState.deployment.active_slot].release_version)) && exists2(releaseAssetDir("landing", rollbackState.deployment.slots[rollbackState.deployment.active_slot].release_version))) {
|
|
3055
|
+
activateFrontendReleaseForState(config, rollbackState);
|
|
3056
|
+
} else {
|
|
3057
|
+
logWarn(`Missing staged frontend assets for release ${rollbackState.deployment.slots[rollbackState.deployment.active_slot].release_version}; leaving current frontend assets unchanged.`);
|
|
3058
|
+
}
|
|
3059
|
+
sleepSeconds(3);
|
|
3060
|
+
waitForApiSlotHealthy(config, rollbackState.deployment.active_slot);
|
|
3061
|
+
persistGeneratedState(config, normalizeGeneratedState({
|
|
3062
|
+
...rollbackState,
|
|
3063
|
+
deployment: touchApiSlotDeployment(rollbackState.deployment, rollbackState.deployment.active_slot)
|
|
3064
|
+
}));
|
|
3065
|
+
logInfo(`Active API slot: ${rollbackState.deployment.active_slot}`);
|
|
3066
|
+
if (rollbackState.deployment.previous_slot) {
|
|
3067
|
+
logInfo(`Rollback slot: ${rollbackState.deployment.previous_slot}`);
|
|
3068
|
+
}
|
|
3069
|
+
logSuccess("Rollback completed");
|
|
3070
|
+
}
|
|
3071
|
+
async function cmdHealth(asJson) {
|
|
3072
|
+
const { config, generated } = loadState();
|
|
3073
|
+
const hosts = domainMap2(config.domain.root_domain);
|
|
3074
|
+
const services = listStackServices(config);
|
|
3075
|
+
const stackName = config.services.stack_name;
|
|
3076
|
+
const traefikDynamic = exists2(TRAEFIK_DYNAMIC_FILE) ? fs2.readFileSync(TRAEFIK_DYNAMIC_FILE, "utf8") : "";
|
|
3077
|
+
const webRuntimeConfig = readRenderedRuntimeConfig(path2.join(RELEASES_ROOT, "web", "current", "runtime-config.js"));
|
|
3078
|
+
const landingRuntimeConfig = readRenderedRuntimeConfig(path2.join(RELEASES_ROOT, "landing", "current", "runtime-config.js"));
|
|
3079
|
+
const clickstackSearchState = getClickstackSearchState(config);
|
|
3080
|
+
const sourceNames = new Set(clickstackSearchState?.sourceNames ?? []);
|
|
3081
|
+
const savedSearchNames = new Set((clickstackSearchState?.savedSearches ?? []).map((search) => search.name).filter(Boolean));
|
|
3082
|
+
const savedSearchTags = new Set((clickstackSearchState?.savedSearches ?? []).flatMap((search) => search.tags ?? []));
|
|
3083
|
+
const dashboardTags = new Set(clickstackSearchState?.dashboardTags ?? []);
|
|
3084
|
+
const combinedTags = /* @__PURE__ */ new Set([...savedSearchTags, ...dashboardTags]);
|
|
3085
|
+
const databaseHealth = getApiMigrationHealth(config, generated);
|
|
3086
|
+
const bootstrapServices = {
|
|
3087
|
+
postgres: serviceHealth(services, `${stackName}_postgres`),
|
|
3088
|
+
redis: serviceHealth(services, `${stackName}_redis`),
|
|
3089
|
+
rustfs: serviceHealth(services, `${stackName}_rustfs`),
|
|
3090
|
+
keycloak: serviceHealth(services, `${stackName}_keycloak`),
|
|
3091
|
+
openfga: serviceHealth(services, `${stackName}_openfga`),
|
|
3092
|
+
minikms: serviceHealth(services, `${stackName}_minikms`)
|
|
3093
|
+
};
|
|
3094
|
+
const checks = {
|
|
3095
|
+
edition: config.edition ?? "enterprise",
|
|
3096
|
+
bootstrap: {
|
|
3097
|
+
completed: hasCompleteBootstrapState(generated) && generated.bootstrap.completed_at.length > 0,
|
|
3098
|
+
completed_at: generated.bootstrap.completed_at || null,
|
|
3099
|
+
services: bootstrapServices
|
|
3100
|
+
},
|
|
3101
|
+
deploy: {
|
|
3102
|
+
active_slot: generated.deployment.active_slot,
|
|
3103
|
+
previous_slot: generated.deployment.previous_slot || null,
|
|
3104
|
+
maintenance_mode: generated.deployment.maintenance_mode,
|
|
3105
|
+
api: apiHealth(services, stackName),
|
|
3106
|
+
api_slots: {
|
|
3107
|
+
blue: {
|
|
3108
|
+
service: serviceHealth(services, slotStackServiceName(config, "blue")),
|
|
3109
|
+
image: generated.deployment.slots.blue.api_image || null,
|
|
3110
|
+
release_version: generated.deployment.slots.blue.release_version || null,
|
|
3111
|
+
deployed_at: generated.deployment.slots.blue.deployed_at || null,
|
|
3112
|
+
active: generated.deployment.active_slot === "blue"
|
|
3113
|
+
},
|
|
3114
|
+
green: {
|
|
3115
|
+
service: serviceHealth(services, slotStackServiceName(config, "green")),
|
|
3116
|
+
image: generated.deployment.slots.green.api_image || null,
|
|
3117
|
+
release_version: generated.deployment.slots.green.release_version || null,
|
|
3118
|
+
deployed_at: generated.deployment.slots.green.deployed_at || null,
|
|
3119
|
+
active: generated.deployment.active_slot === "green"
|
|
3120
|
+
}
|
|
3121
|
+
},
|
|
3122
|
+
web: serviceHealth(services, `${stackName}_web_nginx`),
|
|
3123
|
+
landing: serviceHealth(services, `${stackName}_landing_nginx`)
|
|
3124
|
+
},
|
|
3125
|
+
database: {
|
|
3126
|
+
api: databaseHealth
|
|
3127
|
+
},
|
|
3128
|
+
observability: {
|
|
3129
|
+
service: serviceHealth(services, `${stackName}_clickstack`),
|
|
3130
|
+
obs_ui: {
|
|
3131
|
+
url: publicHttpsUrl2(config, hosts.obs),
|
|
3132
|
+
configured: traefikDynamic.includes("obs-ui-router")
|
|
3133
|
+
},
|
|
3134
|
+
obs_api: {
|
|
3135
|
+
url: publicHttpsUrl2(config, hosts.obs, "/api"),
|
|
3136
|
+
configured: traefikDynamic.includes("obs-api-router")
|
|
3137
|
+
},
|
|
3138
|
+
obs_otlp: {
|
|
3139
|
+
url: publicHttpsUrl2(config, hosts.obs, "/v1/traces"),
|
|
3140
|
+
configured: traefikDynamic.includes("obs-otlp-router")
|
|
3141
|
+
},
|
|
3142
|
+
frontend_otel_endpoint: {
|
|
3143
|
+
web: publicHttpsUrl2(config, hosts.obs),
|
|
3144
|
+
landing: publicHttpsUrl2(config, hosts.obs)
|
|
3145
|
+
},
|
|
3146
|
+
browser_replay_runtime: {
|
|
3147
|
+
web: {
|
|
3148
|
+
configured: Boolean(webRuntimeConfig?.hyperdxApiKey && webRuntimeConfig?.hyperdxUrl && !webRuntimeConfig?.hyperdxDisabled),
|
|
3149
|
+
hyperdx_url: webRuntimeConfig?.hyperdxUrl ?? null
|
|
3150
|
+
},
|
|
3151
|
+
landing: {
|
|
3152
|
+
configured: Boolean(landingRuntimeConfig?.hyperdxApiKey && landingRuntimeConfig?.hyperdxUrl && !landingRuntimeConfig?.hyperdxDisabled),
|
|
3153
|
+
hyperdx_url: landingRuntimeConfig?.hyperdxUrl ?? null
|
|
3154
|
+
}
|
|
3155
|
+
},
|
|
3156
|
+
sessions_source: {
|
|
3157
|
+
configured: sourceNames.has("Sessions")
|
|
3158
|
+
},
|
|
3159
|
+
saved_searches: {
|
|
3160
|
+
configured: REQUIRED_CLICKSTACK_SAVED_SEARCHES.every((name) => savedSearchNames.has(name)),
|
|
3161
|
+
required: [...REQUIRED_CLICKSTACK_SAVED_SEARCHES],
|
|
3162
|
+
missing: REQUIRED_CLICKSTACK_SAVED_SEARCHES.filter((name) => !savedSearchNames.has(name))
|
|
3163
|
+
},
|
|
3164
|
+
tags: {
|
|
3165
|
+
configured: REQUIRED_CLICKSTACK_TAGS.every((tag) => combinedTags.has(tag)),
|
|
3166
|
+
required: [...REQUIRED_CLICKSTACK_TAGS],
|
|
3167
|
+
missing: REQUIRED_CLICKSTACK_TAGS.filter((tag) => !combinedTags.has(tag))
|
|
3168
|
+
}
|
|
3169
|
+
},
|
|
3170
|
+
public: {
|
|
3171
|
+
...isOssConfig2(config) ? {} : { landing: publicHttpsUrl2(config, hosts.landing) },
|
|
3172
|
+
app: publicHttpsUrl2(config, hosts.app),
|
|
3173
|
+
api: publicHttpsUrl2(config, hosts.api, "/health"),
|
|
3174
|
+
auth: publicHttpsUrl2(config, hosts.auth, `/realms/${config.auth.keycloak_realm}/.well-known/openid-configuration`),
|
|
3175
|
+
obs: publicHttpsUrl2(config, hosts.obs)
|
|
3176
|
+
},
|
|
3177
|
+
frontend_runtime: {
|
|
3178
|
+
web: {
|
|
3179
|
+
api_base_url: webRuntimeConfig?.apiBaseUrl ?? null,
|
|
3180
|
+
app_base_url: webRuntimeConfig?.appBaseUrl ?? null,
|
|
3181
|
+
auth_base_url: webRuntimeConfig?.authBaseUrl ?? null,
|
|
3182
|
+
api_docs_url: webRuntimeConfig?.apiDocsUrl ?? null,
|
|
3183
|
+
release_version: webRuntimeConfig?.releaseVersion ?? null,
|
|
3184
|
+
active_api_slot: webRuntimeConfig?.activeApiSlot ?? null
|
|
3185
|
+
},
|
|
3186
|
+
landing: {
|
|
3187
|
+
api_base_url: landingRuntimeConfig?.apiBaseUrl ?? null,
|
|
3188
|
+
app_base_url: landingRuntimeConfig?.appBaseUrl ?? null,
|
|
3189
|
+
auth_base_url: landingRuntimeConfig?.authBaseUrl ?? null,
|
|
3190
|
+
api_docs_url: landingRuntimeConfig?.apiDocsUrl ?? null,
|
|
3191
|
+
release_version: landingRuntimeConfig?.releaseVersion ?? null,
|
|
3192
|
+
active_api_slot: landingRuntimeConfig?.activeApiSlot ?? null
|
|
3193
|
+
}
|
|
3194
|
+
}
|
|
3195
|
+
};
|
|
3196
|
+
if (asJson) {
|
|
3197
|
+
console.log(JSON.stringify(checks, null, 2));
|
|
3198
|
+
return;
|
|
3199
|
+
}
|
|
3200
|
+
printHealthSummary(checks);
|
|
3201
|
+
}
|
|
3202
|
+
async function cmdUpgrade(targetVersion) {
|
|
3203
|
+
logSection("Upgrade");
|
|
3204
|
+
const { config } = loadState();
|
|
3205
|
+
const desiredVersion = targetVersion ?? getDeployCliVersion();
|
|
3206
|
+
assertSemverVersion(desiredVersion, "target release version");
|
|
3207
|
+
config.release.version = desiredVersion;
|
|
3208
|
+
config.source = {
|
|
3209
|
+
...config.source,
|
|
3210
|
+
ref: `v${desiredVersion}`
|
|
3211
|
+
};
|
|
3212
|
+
logReleaseContext(config);
|
|
3213
|
+
config.images = {
|
|
3214
|
+
...config.images,
|
|
3215
|
+
...versionedImages(desiredVersion)
|
|
3216
|
+
};
|
|
3217
|
+
saveDesiredConfig(config);
|
|
3218
|
+
if (currentOptions.dryRun) {
|
|
3219
|
+
logDryRun(`Would upgrade stack to release ${desiredVersion}`);
|
|
3220
|
+
}
|
|
3221
|
+
await cmdDeploy();
|
|
3222
|
+
}
|
|
3223
|
+
async function cmdUpgradeDeps() {
|
|
3224
|
+
logSection("Upgrade Dependencies");
|
|
3225
|
+
const { config } = loadState();
|
|
3226
|
+
logReleaseContext(config);
|
|
3227
|
+
config.images.traefik = "traefik:v3.6.6";
|
|
3228
|
+
config.images.clickstack = "clickhouse/clickstack-all-in-one:latest";
|
|
3229
|
+
config.images.otel_agent = "otel/opentelemetry-collector-contrib:0.111.0";
|
|
3230
|
+
saveDesiredConfig(config);
|
|
3231
|
+
if (currentOptions.dryRun) {
|
|
3232
|
+
logDryRun("Would refresh dependency image tags and redeploy");
|
|
3233
|
+
}
|
|
3234
|
+
await cmdDeploy();
|
|
3235
|
+
}
|
|
3236
|
+
function sha256File(filePath) {
|
|
3237
|
+
const hash = createHash("sha256");
|
|
3238
|
+
hash.update(fs2.readFileSync(filePath));
|
|
3239
|
+
return hash.digest("hex");
|
|
3240
|
+
}
|
|
3241
|
+
function stackVolumeName(config, name) {
|
|
3242
|
+
return `${config.services.stack_name}_${name}`;
|
|
3243
|
+
}
|
|
3244
|
+
function hasManagedRuntime(config) {
|
|
3245
|
+
return stackExists(config) || listManagedContainers(config).length > 0;
|
|
3246
|
+
}
|
|
3247
|
+
function stopManagedRuntime(config, label = "Stopping existing EnvSync services") {
|
|
3248
|
+
const containerIds = listManagedContainers(config);
|
|
3249
|
+
const networkName = stackNetworkName(config);
|
|
3250
|
+
logStep(label);
|
|
3251
|
+
if (currentOptions.dryRun) {
|
|
3252
|
+
if (stackExists(config)) {
|
|
3253
|
+
logDryRun(`Would remove stack ${config.services.stack_name}`);
|
|
3254
|
+
logCommand("docker", ["stack", "rm", config.services.stack_name]);
|
|
3255
|
+
}
|
|
3256
|
+
if (containerIds.length > 0) {
|
|
3257
|
+
logDryRun(`Would remove containers: ${containerIds.join(", ")}`);
|
|
3258
|
+
logCommand("docker", ["rm", "-f", ...containerIds]);
|
|
3259
|
+
}
|
|
3260
|
+
logDryRun(`Would remove network ${networkName} if present`);
|
|
3261
|
+
logCommand("docker", ["network", "rm", networkName]);
|
|
3262
|
+
logSuccess("Managed EnvSync runtime stop preview completed");
|
|
3263
|
+
return;
|
|
3264
|
+
}
|
|
3265
|
+
if (stackExists(config)) {
|
|
3266
|
+
logCommand("docker", ["stack", "rm", config.services.stack_name]);
|
|
3267
|
+
run("docker", ["stack", "rm", config.services.stack_name]);
|
|
3268
|
+
waitForStackRemoval(config);
|
|
3269
|
+
}
|
|
3270
|
+
const refreshedContainers = listManagedContainers(config);
|
|
3271
|
+
if (refreshedContainers.length > 0) {
|
|
3272
|
+
logCommand("docker", ["rm", "-f", ...refreshedContainers]);
|
|
3273
|
+
const removed = runIgnoringAbsent("docker", ["rm", "-f", ...refreshedContainers], {
|
|
3274
|
+
absentPatterns: ["no such container", "not found"]
|
|
3275
|
+
});
|
|
3276
|
+
if (!removed) {
|
|
3277
|
+
logInfo("Managed containers were already absent");
|
|
3278
|
+
}
|
|
3279
|
+
}
|
|
3280
|
+
if (commandSucceeds("docker", ["network", "inspect", networkName])) {
|
|
3281
|
+
logCommand("docker", ["network", "rm", networkName]);
|
|
3282
|
+
const removed = runIgnoringAbsent("docker", ["network", "rm", networkName], {
|
|
3283
|
+
absentPatterns: ["network", "not found", "no such network"]
|
|
3284
|
+
});
|
|
3285
|
+
if (!removed) {
|
|
3286
|
+
logInfo(`Network ${networkName} was already absent`);
|
|
3287
|
+
}
|
|
3288
|
+
}
|
|
3289
|
+
logSuccess("Existing EnvSync services stopped");
|
|
3290
|
+
}
|
|
3291
|
+
function backupDockerVolume(volumeName, targetDir) {
|
|
3292
|
+
logStep(`Backing up Docker volume ${volumeName}`);
|
|
3293
|
+
if (currentOptions.dryRun) {
|
|
3294
|
+
logDryRun(`Would back up ${volumeName} into ${targetDir}`);
|
|
3295
|
+
return;
|
|
3296
|
+
}
|
|
3297
|
+
ensureDir(targetDir);
|
|
3298
|
+
run("docker", [
|
|
3299
|
+
"run",
|
|
3300
|
+
"--rm",
|
|
3301
|
+
"-v",
|
|
3302
|
+
`${volumeName}:/from:ro`,
|
|
3303
|
+
"-v",
|
|
3304
|
+
`${targetDir}:/to`,
|
|
3305
|
+
"alpine:3.20",
|
|
3306
|
+
"sh",
|
|
3307
|
+
"-lc",
|
|
3308
|
+
"cd /from && tar -czf /to/volume.tar.gz ."
|
|
3309
|
+
]);
|
|
3310
|
+
logSuccess(`Backed up Docker volume ${volumeName}`);
|
|
3311
|
+
}
|
|
3312
|
+
function restoreDockerVolume(volumeName, sourceDir) {
|
|
3313
|
+
logStep(`Restoring Docker volume ${volumeName}`);
|
|
3314
|
+
if (currentOptions.dryRun) {
|
|
3315
|
+
logDryRun(`Would restore ${volumeName} from ${sourceDir}`);
|
|
3316
|
+
return;
|
|
3317
|
+
}
|
|
3318
|
+
run("docker", ["volume", "create", volumeName], { quiet: true });
|
|
3319
|
+
run("docker", [
|
|
3320
|
+
"run",
|
|
3321
|
+
"--rm",
|
|
3322
|
+
"-v",
|
|
3323
|
+
`${volumeName}:/to`,
|
|
3324
|
+
"-v",
|
|
3325
|
+
`${sourceDir}:/from:ro`,
|
|
3326
|
+
"alpine:3.20",
|
|
3327
|
+
"sh",
|
|
3328
|
+
"-lc",
|
|
3329
|
+
"cd /to && tar -xzf /from/volume.tar.gz"
|
|
3330
|
+
]);
|
|
3331
|
+
logSuccess(`Restored Docker volume ${volumeName}`);
|
|
3332
|
+
}
|
|
3333
|
+
async function cmdBackup() {
|
|
3334
|
+
logSection("Backup");
|
|
3335
|
+
const { config } = loadState();
|
|
3336
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:]/g, "-");
|
|
3337
|
+
const archiveBase = path2.join(config.backup.output_dir, `envsync-backup-${timestamp}`);
|
|
3338
|
+
const manifestPath = `${archiveBase}.manifest.json`;
|
|
3339
|
+
const tarPath = `${archiveBase}.tar.gz`;
|
|
3340
|
+
const staged = path2.join(BACKUPS_ROOT, `staging-${timestamp}`);
|
|
3341
|
+
logInfo(`Backup archive target: ${tarPath}`);
|
|
3342
|
+
if (currentOptions.dryRun) {
|
|
3343
|
+
logDryRun(`Would stage backup files in ${staged}`);
|
|
3344
|
+
if (hasManagedRuntime(config)) {
|
|
3345
|
+
logWarn("Backup would temporarily stop the EnvSync stack to capture consistent volume data.");
|
|
3346
|
+
stopManagedRuntime(config, "Stopping existing EnvSync services for consistent backup");
|
|
3347
|
+
logDryRun("Would redeploy the EnvSync stack after the backup archive is created");
|
|
3348
|
+
}
|
|
3349
|
+
for (const volume of STACK_VOLUMES) {
|
|
3350
|
+
backupDockerVolume(stackVolumeName(config, volume), path2.join(staged, "volumes", volume));
|
|
3351
|
+
}
|
|
3352
|
+
logDryRun(`Would write manifest ${manifestPath}`);
|
|
3353
|
+
logDryRun(`Would create archive ${tarPath}`);
|
|
3354
|
+
logSuccess("Backup dry-run completed");
|
|
3355
|
+
console.log(tarPath);
|
|
3356
|
+
return;
|
|
3357
|
+
}
|
|
3358
|
+
ensureDir(config.backup.output_dir);
|
|
3359
|
+
ensureDir(staged);
|
|
3360
|
+
const resumeRuntimeAfterBackup = hasManagedRuntime(config);
|
|
3361
|
+
let backupCompleted = false;
|
|
3362
|
+
let backupError = null;
|
|
3363
|
+
try {
|
|
3364
|
+
if (resumeRuntimeAfterBackup) {
|
|
3365
|
+
logWarn("Backup will temporarily stop the EnvSync stack to capture consistent volume data.");
|
|
3366
|
+
stopManagedRuntime(config, "Stopping existing EnvSync services for consistent backup");
|
|
3367
|
+
}
|
|
3368
|
+
writeFile(path2.join(staged, "deploy.env"), fs2.readFileSync(DEPLOY_ENV, "utf8"));
|
|
3369
|
+
writeFile(path2.join(staged, "deploy.yaml"), fs2.readFileSync(DEPLOY_YAML, "utf8"));
|
|
3370
|
+
writeFile(path2.join(staged, "config.json"), fs2.readFileSync(INTERNAL_CONFIG_JSON, "utf8"));
|
|
3371
|
+
writeFile(path2.join(staged, "versions.lock.json"), fs2.readFileSync(VERSIONS_LOCK, "utf8"));
|
|
3372
|
+
writeFile(path2.join(staged, "docker-stack.bootstrap.base.yaml"), fs2.readFileSync(BOOTSTRAP_BASE_STACK_FILE, "utf8"));
|
|
3373
|
+
writeFile(path2.join(staged, "docker-stack.bootstrap.yaml"), fs2.readFileSync(BOOTSTRAP_STACK_FILE, "utf8"));
|
|
3374
|
+
writeFile(path2.join(staged, "docker-stack.yaml"), fs2.readFileSync(STACK_FILE, "utf8"));
|
|
3375
|
+
writeFile(path2.join(staged, "traefik-dynamic.yaml"), fs2.readFileSync(TRAEFIK_DYNAMIC_FILE, "utf8"));
|
|
3376
|
+
writeFile(path2.join(staged, "keycloak-realm.envsync.json"), fs2.readFileSync(KEYCLOAK_REALM_FILE, "utf8"));
|
|
3377
|
+
writeFile(path2.join(staged, "otel-agent.yaml"), fs2.readFileSync(OTEL_AGENT_CONF, "utf8"));
|
|
3378
|
+
writeFile(path2.join(staged, "clickhouse-listen.xml"), fs2.readFileSync(CLICKSTACK_CLICKHOUSE_CONF, "utf8"));
|
|
3379
|
+
const volumesDir = path2.join(staged, "volumes");
|
|
3380
|
+
for (const volume of STACK_VOLUMES) {
|
|
3381
|
+
backupDockerVolume(stackVolumeName(config, volume), path2.join(volumesDir, volume));
|
|
3382
|
+
}
|
|
3383
|
+
run("bash", ["-lc", `tar -czf ${JSON.stringify(tarPath)} -C ${JSON.stringify(staged)} .`]);
|
|
3384
|
+
const manifest = {
|
|
3385
|
+
archive: path2.basename(tarPath),
|
|
3386
|
+
sha256: sha256File(tarPath),
|
|
3387
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3388
|
+
stack_name: config.services.stack_name,
|
|
3389
|
+
volumes: STACK_VOLUMES.map((volume) => stackVolumeName(config, volume))
|
|
3390
|
+
};
|
|
3391
|
+
writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
|
|
3392
|
+
backupCompleted = true;
|
|
3393
|
+
} catch (error) {
|
|
3394
|
+
backupError = error;
|
|
3395
|
+
} finally {
|
|
3396
|
+
if (resumeRuntimeAfterBackup) {
|
|
3397
|
+
try {
|
|
3398
|
+
logStep("Restarting EnvSync services after backup");
|
|
3399
|
+
await cmdDeploy();
|
|
3400
|
+
} catch (restartError) {
|
|
3401
|
+
if (backupError) {
|
|
3402
|
+
throw new Error(
|
|
3403
|
+
`Backup failed and automatic service restart also failed: ${backupError instanceof Error ? backupError.message : String(backupError)}
|
|
3404
|
+
${restartError instanceof Error ? restartError.message : String(restartError)}`
|
|
3405
|
+
);
|
|
3406
|
+
}
|
|
3407
|
+
throw new Error(
|
|
3408
|
+
`Backup archive was created, but automatic service restart failed: ${restartError instanceof Error ? restartError.message : String(restartError)}`
|
|
3409
|
+
);
|
|
3410
|
+
}
|
|
3411
|
+
}
|
|
3412
|
+
}
|
|
3413
|
+
if (backupError) {
|
|
3414
|
+
throw backupError instanceof Error ? backupError : new Error(String(backupError));
|
|
3415
|
+
}
|
|
3416
|
+
if (!backupCompleted) {
|
|
3417
|
+
throw new Error("Backup did not complete");
|
|
3418
|
+
}
|
|
3419
|
+
logSuccess("Backup completed");
|
|
3420
|
+
console.log(tarPath);
|
|
3421
|
+
}
|
|
3422
|
+
async function cmdRestore(archivePath, autoDeploy = false) {
|
|
3423
|
+
if (!archivePath) throw new Error("restore requires a .tar.gz path");
|
|
3424
|
+
logSection("Restore");
|
|
3425
|
+
const restoreRoot = path2.join(BACKUPS_ROOT, `restore-${Date.now()}`);
|
|
3426
|
+
logInfo(`Restore archive: ${archivePath}`);
|
|
3427
|
+
if (currentOptions.dryRun) {
|
|
3428
|
+
logDryRun(`Would extract ${archivePath} into ${restoreRoot}`);
|
|
3429
|
+
logDryRun(`Would restore deploy files into ${DEPLOY_ROOT} and ${ETC_ROOT}`);
|
|
3430
|
+
if (hasManagedRuntime(loadState().config)) {
|
|
3431
|
+
stopManagedRuntime(loadState().config, "Stopping existing EnvSync services before restore");
|
|
3432
|
+
}
|
|
3433
|
+
logDryRun("Would restore all managed Docker volumes from the archive");
|
|
3434
|
+
logSuccess("Restore dry-run completed");
|
|
3435
|
+
return;
|
|
3436
|
+
}
|
|
3437
|
+
const currentConfig = loadState().config;
|
|
3438
|
+
if (hasManagedRuntime(currentConfig)) {
|
|
3439
|
+
logWarn("Restore will stop the existing EnvSync stack before replacing managed data volumes.");
|
|
3440
|
+
stopManagedRuntime(currentConfig, "Stopping existing EnvSync services before restore");
|
|
3441
|
+
}
|
|
3442
|
+
ensureDir(restoreRoot);
|
|
3443
|
+
run("bash", ["-lc", `tar -xzf ${JSON.stringify(archivePath)} -C ${JSON.stringify(restoreRoot)}`]);
|
|
3444
|
+
writeFile(DEPLOY_ENV, fs2.readFileSync(path2.join(restoreRoot, "deploy.env"), "utf8"), 384);
|
|
3445
|
+
writeFile(DEPLOY_YAML, fs2.readFileSync(path2.join(restoreRoot, "deploy.yaml"), "utf8"));
|
|
3446
|
+
writeFile(INTERNAL_CONFIG_JSON, fs2.readFileSync(path2.join(restoreRoot, "config.json"), "utf8"));
|
|
3447
|
+
writeFile(VERSIONS_LOCK, fs2.readFileSync(path2.join(restoreRoot, "versions.lock.json"), "utf8"));
|
|
3448
|
+
writeFile(BOOTSTRAP_BASE_STACK_FILE, fs2.readFileSync(path2.join(restoreRoot, "docker-stack.bootstrap.base.yaml"), "utf8"));
|
|
3449
|
+
writeFile(BOOTSTRAP_STACK_FILE, fs2.readFileSync(path2.join(restoreRoot, "docker-stack.bootstrap.yaml"), "utf8"));
|
|
3450
|
+
writeFile(STACK_FILE, fs2.readFileSync(path2.join(restoreRoot, "docker-stack.yaml"), "utf8"));
|
|
3451
|
+
writeFile(TRAEFIK_DYNAMIC_FILE, fs2.readFileSync(path2.join(restoreRoot, "traefik-dynamic.yaml"), "utf8"));
|
|
3452
|
+
writeFile(KEYCLOAK_REALM_FILE, fs2.readFileSync(path2.join(restoreRoot, "keycloak-realm.envsync.json"), "utf8"));
|
|
3453
|
+
writeFile(OTEL_AGENT_CONF, fs2.readFileSync(path2.join(restoreRoot, "otel-agent.yaml"), "utf8"));
|
|
3454
|
+
writeFile(CLICKSTACK_CLICKHOUSE_CONF, fs2.readFileSync(path2.join(restoreRoot, "clickhouse-listen.xml"), "utf8"));
|
|
3455
|
+
const config = loadConfig();
|
|
3456
|
+
for (const volume of STACK_VOLUMES) {
|
|
3457
|
+
restoreDockerVolume(stackVolumeName(config, volume), path2.join(restoreRoot, "volumes", volume));
|
|
3458
|
+
}
|
|
3459
|
+
logSuccess("Restore completed");
|
|
3460
|
+
logInfo(`Restored archive: ${archivePath}`);
|
|
3461
|
+
logInfo(`Restored stack name: ${config.services.stack_name}`);
|
|
3462
|
+
if (autoDeploy) {
|
|
3463
|
+
logInfo("Starting services after restore because --deploy was provided");
|
|
3464
|
+
await cmdDeploy();
|
|
3465
|
+
return;
|
|
3466
|
+
}
|
|
3467
|
+
logInfo("Next: envsync-deploy deploy");
|
|
3468
|
+
}
|
|
3469
|
+
async function main() {
|
|
3470
|
+
const argv = process.argv.slice(2);
|
|
3471
|
+
const command = argv[0];
|
|
3472
|
+
const args = argv.slice(1);
|
|
3473
|
+
currentOptions = {
|
|
3474
|
+
dryRun: args.includes("--dry-run"),
|
|
3475
|
+
force: args.includes("--force")
|
|
3476
|
+
};
|
|
3477
|
+
const positionals = args.filter((arg) => arg !== "--dry-run" && arg !== "--force" && arg !== "--deploy");
|
|
3478
|
+
if (!command) {
|
|
3479
|
+
printOperatorOverview();
|
|
3480
|
+
process.exit(0);
|
|
3481
|
+
}
|
|
3482
|
+
switch (command) {
|
|
3483
|
+
case "preinstall":
|
|
3484
|
+
await cmdPreinstall();
|
|
3485
|
+
break;
|
|
3486
|
+
case "setup":
|
|
3487
|
+
await cmdSetup();
|
|
3488
|
+
break;
|
|
3489
|
+
case "bootstrap":
|
|
3490
|
+
await cmdBootstrap();
|
|
3491
|
+
break;
|
|
3492
|
+
case "deploy":
|
|
3493
|
+
await cmdDeploy();
|
|
3494
|
+
break;
|
|
3495
|
+
case "promote":
|
|
3496
|
+
await cmdPromote(positionals[0]);
|
|
3497
|
+
break;
|
|
3498
|
+
case "rollback":
|
|
3499
|
+
await cmdRollback();
|
|
3500
|
+
break;
|
|
3501
|
+
case "health":
|
|
3502
|
+
await cmdHealth(positionals[0] === "--json");
|
|
3503
|
+
break;
|
|
3504
|
+
case "plan-topology":
|
|
3505
|
+
cmdEnterpriseTopologyPlan(positionals[0], positionals.includes("--json"));
|
|
3506
|
+
break;
|
|
3507
|
+
case "validate-topology":
|
|
3508
|
+
cmdEnterpriseTopologyValidate(positionals[0], positionals.includes("--json"));
|
|
3509
|
+
break;
|
|
3510
|
+
case "upgrade":
|
|
3511
|
+
await cmdUpgrade(positionals[0]);
|
|
3512
|
+
break;
|
|
3513
|
+
case "upgrade-deps":
|
|
3514
|
+
await cmdUpgradeDeps();
|
|
3515
|
+
break;
|
|
3516
|
+
case "backup":
|
|
3517
|
+
await cmdBackup();
|
|
3518
|
+
break;
|
|
3519
|
+
case "restore":
|
|
3520
|
+
await cmdRestore(positionals[0] ?? "", args.includes("--deploy"));
|
|
3521
|
+
break;
|
|
3522
|
+
default:
|
|
3523
|
+
console.error(chalk.red(`Unknown command: ${command}`));
|
|
3524
|
+
console.log(renderHelpBlock());
|
|
3525
|
+
process.exit(1);
|
|
3526
|
+
}
|
|
3527
|
+
}
|
|
3528
|
+
main().catch((err) => {
|
|
3529
|
+
console.error(err instanceof Error ? err.message : err);
|
|
3530
|
+
process.exit(1);
|
|
3531
|
+
});
|