@envsync-cloud/deploy-cli 0.6.1 → 0.6.3

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.
Files changed (3) hide show
  1. package/README.md +16 -2
  2. package/dist/index.js +682 -140
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -40,6 +40,7 @@ bunx @envsync-cloud/deploy-cli <command>
40
40
  ```text
41
41
  envsync-deploy preinstall
42
42
  envsync-deploy setup
43
+ envsync-deploy bootstrap
43
44
  envsync-deploy deploy
44
45
  envsync-deploy health [--json]
45
46
  envsync-deploy upgrade
@@ -56,18 +57,31 @@ Prepare the host:
56
57
  npx @envsync-cloud/deploy-cli preinstall
57
58
  ```
58
59
 
59
- Generate deployment state and prompts:
60
+ Write the desired self-hosted config:
60
61
 
61
62
  ```bash
62
63
  npx @envsync-cloud/deploy-cli setup
63
64
  ```
64
65
 
65
- Apply the Docker Swarm stack:
66
+ `setup` requires an exact release version such as `0.6.2`. Channel names like `stable` and `latest` are not accepted for self-hosted installs.
67
+
68
+ Bootstrap infra, migrations, RustFS, and OpenFGA:
69
+
70
+ ```bash
71
+ npx @envsync-cloud/deploy-cli bootstrap
72
+ ```
73
+
74
+ Deploy the pending API and frontend services:
66
75
 
67
76
  ```bash
68
77
  npx @envsync-cloud/deploy-cli deploy
69
78
  ```
70
79
 
80
+ The staged flow is:
81
+ - `setup` writes desired config
82
+ - `bootstrap` starts base infra, runs OpenFGA and miniKMS migrations, starts runtime infra, and persists generated runtime env state
83
+ - `deploy` starts the pending API and frontend services
84
+
71
85
  Check service health:
72
86
 
73
87
  ```bash
package/dist/index.js CHANGED
@@ -17,6 +17,8 @@ var DEPLOY_ENV = "/etc/envsync/deploy.env";
17
17
  var DEPLOY_YAML = "/etc/envsync/deploy.yaml";
18
18
  var VERSIONS_LOCK = "/opt/envsync/deploy/versions.lock.json";
19
19
  var STACK_FILE = "/opt/envsync/deploy/docker-stack.yaml";
20
+ var BOOTSTRAP_BASE_STACK_FILE = "/opt/envsync/deploy/docker-stack.bootstrap.base.yaml";
21
+ var BOOTSTRAP_STACK_FILE = "/opt/envsync/deploy/docker-stack.bootstrap.yaml";
20
22
  var TRAEFIK_DYNAMIC_FILE = "/opt/envsync/deploy/traefik-dynamic.yaml";
21
23
  var KEYCLOAK_REALM_FILE = "/opt/envsync/deploy/keycloak-realm.envsync.json";
22
24
  var NGINX_WEB_CONF = "/opt/envsync/deploy/nginx-web.conf";
@@ -34,6 +36,17 @@ var STACK_VOLUMES = [
34
36
  "clickstack_ch_data",
35
37
  "clickstack_ch_logs"
36
38
  ];
39
+ var REQUIRED_BOOTSTRAP_ENV_KEYS = [
40
+ "S3_SECRET_KEY",
41
+ "KEYCLOAK_WEB_CLIENT_SECRET",
42
+ "KEYCLOAK_API_CLIENT_SECRET",
43
+ "OPENFGA_DB_PASSWORD",
44
+ "MINIKMS_ROOT_KEY",
45
+ "MINIKMS_DB_PASSWORD",
46
+ "OPENFGA_STORE_ID",
47
+ "OPENFGA_MODEL_ID"
48
+ ];
49
+ var SEMVER_VERSION_RE = /^\d+\.\d+\.\d+$/;
37
50
  function run(cmd, args, opts = {}) {
38
51
  const result = spawnSync(cmd, args, {
39
52
  cwd: opts.cwd,
@@ -48,6 +61,22 @@ ${stderr}` : ""}`);
48
61
  }
49
62
  return result.stdout?.toString() ?? "";
50
63
  }
64
+ function tryRun(cmd, args, opts = {}) {
65
+ try {
66
+ return run(cmd, args, opts);
67
+ } catch {
68
+ return "";
69
+ }
70
+ }
71
+ function commandSucceeds(cmd, args, opts = {}) {
72
+ const result = spawnSync(cmd, args, {
73
+ cwd: opts.cwd,
74
+ env: { ...process.env, ...opts.env },
75
+ stdio: "ignore",
76
+ encoding: "utf8"
77
+ });
78
+ return result.status === 0;
79
+ }
51
80
  function ensureDir(dir) {
52
81
  fs.mkdirSync(dir, { recursive: true });
53
82
  }
@@ -126,9 +155,21 @@ function parseSimpleYamlObject(input) {
126
155
  }
127
156
  return root;
128
157
  }
129
- function indentBlock(content, spaces) {
130
- const prefix = " ".repeat(spaces);
131
- return content.split("\n").map((line) => line ? `${prefix}${line}` : line).join("\n");
158
+ function parseEnvFile(content) {
159
+ const out = {};
160
+ for (const line of content.split(/\r?\n/)) {
161
+ const trimmed = line.trim();
162
+ if (!trimmed || trimmed.startsWith("#")) continue;
163
+ const eq = trimmed.indexOf("=");
164
+ if (eq === -1) continue;
165
+ const key = trimmed.slice(0, eq).trim();
166
+ let value = trimmed.slice(eq + 1).trim();
167
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
168
+ value = value.slice(1, -1).replace(/\\n/g, "\n").replace(/\\"/g, '"');
169
+ }
170
+ out[key] = value;
171
+ }
172
+ return out;
132
173
  }
133
174
  async function ask(question, fallback = "") {
134
175
  if (!process.stdin.isTTY) return fallback;
@@ -140,6 +181,9 @@ async function ask(question, fallback = "") {
140
181
  });
141
182
  });
142
183
  }
184
+ function sleepSeconds(seconds) {
185
+ spawnSync("sleep", [`${seconds}`], { stdio: "ignore" });
186
+ }
143
187
  function domainMap(rootDomain) {
144
188
  return {
145
189
  landing: rootDomain,
@@ -152,29 +196,234 @@ function domainMap(rootDomain) {
152
196
  s3Console: `console.s3.${rootDomain}`
153
197
  };
154
198
  }
199
+ function getDeployCliVersion() {
200
+ try {
201
+ const packageJsonPath = new URL("../package.json", import.meta.url);
202
+ const raw = fs.readFileSync(packageJsonPath, "utf8");
203
+ return JSON.parse(raw).version ?? "0.0.0";
204
+ } catch {
205
+ return process.env.npm_package_version ?? "0.0.0";
206
+ }
207
+ }
208
+ function assertSemverVersion(version, label = "release version") {
209
+ if (!SEMVER_VERSION_RE.test(version)) {
210
+ throw new Error(`Invalid ${label} '${version}'. Expected an exact semver like 0.6.2.`);
211
+ }
212
+ }
213
+ function versionedImages(version) {
214
+ assertSemverVersion(version);
215
+ return {
216
+ api: `ghcr.io/envsync-cloud/envsync-api:${version}`,
217
+ keycloak: `envsync-keycloak:${version}`,
218
+ web: `ghcr.io/envsync-cloud/envsync-web-static:${version}`,
219
+ landing: `ghcr.io/envsync-cloud/envsync-landing-static:${version}`
220
+ };
221
+ }
222
+ function defaultSourceConfig(version) {
223
+ return {
224
+ repo_url: "https://github.com/EnvSync-Cloud/envsync.git",
225
+ ref: `v${version}`
226
+ };
227
+ }
228
+ function resolveReleaseVersion(raw) {
229
+ const releaseVersion = raw.release?.version;
230
+ if (releaseVersion) {
231
+ assertSemverVersion(releaseVersion);
232
+ return releaseVersion;
233
+ }
234
+ if (typeof raw.release_channel === "string" && raw.release_channel.length > 0) {
235
+ if (SEMVER_VERSION_RE.test(raw.release_channel)) {
236
+ return raw.release_channel;
237
+ }
238
+ if (raw.release_channel === "stable" || raw.release_channel === "latest") {
239
+ throw new Error(
240
+ "Legacy release channel config is no longer supported for self-hosted installs. Set an exact release version in /etc/envsync/deploy.yaml."
241
+ );
242
+ }
243
+ throw new Error(`Invalid legacy release channel '${raw.release_channel}'. Set an exact release version in /etc/envsync/deploy.yaml.`);
244
+ }
245
+ return getDeployCliVersion();
246
+ }
247
+ function requireDefined(value, label) {
248
+ if (value === void 0) {
249
+ throw new Error(`Missing ${label} in ${DEPLOY_YAML}. Run setup again.`);
250
+ }
251
+ return value;
252
+ }
253
+ function normalizeConfig(raw) {
254
+ const version = resolveReleaseVersion(raw);
255
+ const derivedImages = versionedImages(version);
256
+ const { release_channel: _legacyReleaseChannel, ...rest } = raw;
257
+ const rootDomain = requireDefined(raw.domain?.root_domain, "domain.root_domain");
258
+ const acmeEmail = requireDefined(raw.domain?.acme_email, "domain.acme_email");
259
+ return {
260
+ ...rest,
261
+ source: {
262
+ repo_url: raw.source?.repo_url ?? "https://github.com/EnvSync-Cloud/envsync.git",
263
+ ref: `v${version}`
264
+ },
265
+ release: {
266
+ version
267
+ },
268
+ domain: {
269
+ root_domain: rootDomain,
270
+ acme_email: acmeEmail
271
+ },
272
+ images: {
273
+ api: derivedImages.api,
274
+ keycloak: derivedImages.keycloak,
275
+ web: derivedImages.web,
276
+ landing: derivedImages.landing,
277
+ clickstack: raw.images?.clickstack ?? "clickhouse/clickstack-all-in-one:latest",
278
+ traefik: raw.images?.traefik ?? "traefik:v3.1",
279
+ otel_agent: raw.images?.otel_agent ?? "otel/opentelemetry-collector-contrib:0.111.0"
280
+ },
281
+ services: {
282
+ stack_name: requireDefined(raw.services?.stack_name, "services.stack_name"),
283
+ api_port: requireDefined(raw.services?.api_port, "services.api_port"),
284
+ clickstack_ui_port: requireDefined(raw.services?.clickstack_ui_port, "services.clickstack_ui_port"),
285
+ clickstack_otlp_http_port: requireDefined(raw.services?.clickstack_otlp_http_port, "services.clickstack_otlp_http_port"),
286
+ clickstack_otlp_grpc_port: requireDefined(raw.services?.clickstack_otlp_grpc_port, "services.clickstack_otlp_grpc_port"),
287
+ keycloak_port: requireDefined(raw.services?.keycloak_port, "services.keycloak_port"),
288
+ rustfs_port: requireDefined(raw.services?.rustfs_port, "services.rustfs_port"),
289
+ rustfs_console_port: requireDefined(raw.services?.rustfs_console_port, "services.rustfs_console_port")
290
+ },
291
+ auth: {
292
+ keycloak_realm: requireDefined(raw.auth?.keycloak_realm, "auth.keycloak_realm"),
293
+ admin_user: requireDefined(raw.auth?.admin_user, "auth.admin_user"),
294
+ admin_password: requireDefined(raw.auth?.admin_password, "auth.admin_password"),
295
+ web_client_id: requireDefined(raw.auth?.web_client_id, "auth.web_client_id"),
296
+ api_client_id: requireDefined(raw.auth?.api_client_id, "auth.api_client_id"),
297
+ cli_client_id: requireDefined(raw.auth?.cli_client_id, "auth.cli_client_id")
298
+ },
299
+ observability: {
300
+ retention_days: requireDefined(raw.observability?.retention_days, "observability.retention_days"),
301
+ public_obs: requireDefined(raw.observability?.public_obs, "observability.public_obs")
302
+ },
303
+ backup: {
304
+ output_dir: requireDefined(raw.backup?.output_dir, "backup.output_dir"),
305
+ encrypted: requireDefined(raw.backup?.encrypted, "backup.encrypted")
306
+ },
307
+ smtp: {
308
+ host: requireDefined(raw.smtp?.host, "smtp.host"),
309
+ port: requireDefined(raw.smtp?.port, "smtp.port"),
310
+ secure: requireDefined(raw.smtp?.secure, "smtp.secure"),
311
+ user: requireDefined(raw.smtp?.user, "smtp.user"),
312
+ pass: requireDefined(raw.smtp?.pass, "smtp.pass"),
313
+ from: requireDefined(raw.smtp?.from, "smtp.from")
314
+ },
315
+ exposure: {
316
+ public_auth: requireDefined(raw.exposure?.public_auth, "exposure.public_auth"),
317
+ public_obs: requireDefined(raw.exposure?.public_obs, "exposure.public_obs"),
318
+ mailpit_enabled: requireDefined(raw.exposure?.mailpit_enabled, "exposure.mailpit_enabled"),
319
+ s3_public: requireDefined(raw.exposure?.s3_public, "exposure.s3_public"),
320
+ s3_console_public: requireDefined(raw.exposure?.s3_console_public, "exposure.s3_console_public")
321
+ }
322
+ };
323
+ }
324
+ function emptyGeneratedState() {
325
+ return {
326
+ openfga: {
327
+ store_id: "",
328
+ model_id: ""
329
+ },
330
+ secrets: {
331
+ s3_secret_key: "",
332
+ keycloak_web_client_secret: "",
333
+ keycloak_api_client_secret: "",
334
+ openfga_db_password: "",
335
+ minikms_root_key: "",
336
+ minikms_db_password: ""
337
+ },
338
+ bootstrap: {
339
+ completed_at: ""
340
+ }
341
+ };
342
+ }
343
+ function normalizeGeneratedState(raw) {
344
+ const defaults = emptyGeneratedState();
345
+ return {
346
+ openfga: {
347
+ store_id: raw?.openfga?.store_id ?? defaults.openfga.store_id,
348
+ model_id: raw?.openfga?.model_id ?? defaults.openfga.model_id
349
+ },
350
+ secrets: {
351
+ s3_secret_key: raw?.secrets?.s3_secret_key ?? defaults.secrets.s3_secret_key,
352
+ keycloak_web_client_secret: raw?.secrets?.keycloak_web_client_secret ?? defaults.secrets.keycloak_web_client_secret,
353
+ keycloak_api_client_secret: raw?.secrets?.keycloak_api_client_secret ?? defaults.secrets.keycloak_api_client_secret,
354
+ openfga_db_password: raw?.secrets?.openfga_db_password ?? defaults.secrets.openfga_db_password,
355
+ minikms_root_key: raw?.secrets?.minikms_root_key ?? defaults.secrets.minikms_root_key,
356
+ minikms_db_password: raw?.secrets?.minikms_db_password ?? defaults.secrets.minikms_db_password
357
+ },
358
+ bootstrap: {
359
+ completed_at: raw?.bootstrap?.completed_at ?? defaults.bootstrap.completed_at
360
+ }
361
+ };
362
+ }
363
+ function readInternalState() {
364
+ if (!exists(INTERNAL_CONFIG_JSON)) return null;
365
+ const raw = JSON.parse(fs.readFileSync(INTERNAL_CONFIG_JSON, "utf8"));
366
+ return {
367
+ config: raw.config ? normalizeConfig(raw.config) : void 0,
368
+ generated: normalizeGeneratedState(raw.generated)
369
+ };
370
+ }
155
371
  function loadConfig() {
156
372
  if (!exists(DEPLOY_YAML)) {
157
373
  throw new Error(`Missing deploy config at ${DEPLOY_YAML}. Run setup first.`);
158
374
  }
159
375
  const raw = fs.readFileSync(DEPLOY_YAML, "utf8");
160
376
  if (raw.trimStart().startsWith("{")) {
161
- return JSON.parse(raw);
377
+ return normalizeConfig(JSON.parse(raw));
162
378
  }
163
- return parseSimpleYamlObject(raw);
379
+ return normalizeConfig(parseSimpleYamlObject(raw));
164
380
  }
165
- function saveConfig(config) {
166
- writeFile(DEPLOY_YAML, toYaml(config) + "\n");
167
- writeFile(INTERNAL_CONFIG_JSON, JSON.stringify(config, null, 2) + "\n");
168
- writeFile(DEPLOY_ENV, renderEnv(config), 384);
169
- writeFile(VERSIONS_LOCK, JSON.stringify(config.images, null, 2) + "\n");
170
- writeFile(KEYCLOAK_REALM_FILE, renderKeycloakRealm(config));
171
- writeFile(TRAEFIK_DYNAMIC_FILE, renderTraefikDynamicConfig(config));
172
- writeFile(STACK_FILE, renderStack(config));
173
- writeFile(NGINX_WEB_CONF, renderNginxConf("web"));
174
- writeFile(NGINX_LANDING_CONF, renderNginxConf("landing"));
175
- writeFile(OTEL_AGENT_CONF, renderOtelAgentConfig(config));
381
+ function loadGeneratedEnv() {
382
+ if (!exists(DEPLOY_ENV)) return {};
383
+ return parseEnvFile(fs.readFileSync(DEPLOY_ENV, "utf8"));
176
384
  }
177
- function buildEnvMap(config) {
385
+ function mergeGeneratedState(env, generated) {
386
+ const normalized = normalizeGeneratedState(generated);
387
+ return normalizeGeneratedState({
388
+ openfga: {
389
+ store_id: env.OPENFGA_STORE_ID ?? normalized.openfga.store_id,
390
+ model_id: env.OPENFGA_MODEL_ID ?? normalized.openfga.model_id
391
+ },
392
+ secrets: {
393
+ s3_secret_key: env.S3_SECRET_KEY ?? normalized.secrets.s3_secret_key,
394
+ keycloak_web_client_secret: env.KEYCLOAK_WEB_CLIENT_SECRET ?? normalized.secrets.keycloak_web_client_secret,
395
+ keycloak_api_client_secret: env.KEYCLOAK_API_CLIENT_SECRET ?? normalized.secrets.keycloak_api_client_secret,
396
+ openfga_db_password: env.OPENFGA_DB_PASSWORD ?? normalized.secrets.openfga_db_password,
397
+ minikms_root_key: env.MINIKMS_ROOT_KEY ?? normalized.secrets.minikms_root_key,
398
+ minikms_db_password: env.MINIKMS_DB_PASSWORD ?? normalized.secrets.minikms_db_password
399
+ },
400
+ bootstrap: normalized.bootstrap
401
+ });
402
+ }
403
+ function loadState() {
404
+ const config = loadConfig();
405
+ const internal = readInternalState();
406
+ const generated = mergeGeneratedState(loadGeneratedEnv(), internal?.generated);
407
+ return { config, generated };
408
+ }
409
+ function ensureGeneratedRuntimeState(generated) {
410
+ return normalizeGeneratedState({
411
+ openfga: generated.openfga,
412
+ secrets: {
413
+ s3_secret_key: generated.secrets.s3_secret_key || randomSecret(16),
414
+ keycloak_web_client_secret: generated.secrets.keycloak_web_client_secret || randomSecret(),
415
+ keycloak_api_client_secret: generated.secrets.keycloak_api_client_secret || randomSecret(),
416
+ openfga_db_password: generated.secrets.openfga_db_password || randomSecret(),
417
+ minikms_root_key: generated.secrets.minikms_root_key || randomBytes(32).toString("hex"),
418
+ minikms_db_password: generated.secrets.minikms_db_password || randomSecret()
419
+ },
420
+ bootstrap: generated.bootstrap
421
+ });
422
+ }
423
+ function keycloakImageTag(image) {
424
+ return image.split(":").slice(1).join(":") || "local";
425
+ }
426
+ function buildRuntimeEnv(config, generated) {
178
427
  const hosts = domainMap(config.domain.root_domain);
179
428
  return {
180
429
  NODE_ENV: "production",
@@ -190,7 +439,7 @@ function buildEnvMap(config) {
190
439
  S3_BUCKET: "envsync-bucket",
191
440
  S3_REGION: "us-east-1",
192
441
  S3_ACCESS_KEY: "envsync-rustfs",
193
- S3_SECRET_KEY: randomSecret(16),
442
+ S3_SECRET_KEY: generated.secrets.s3_secret_key,
194
443
  S3_BUCKET_URL: `https://${hosts.s3}`,
195
444
  S3_ENDPOINT: "http://rustfs:9000",
196
445
  REDIS_URL: "redis://redis:6379",
@@ -205,43 +454,40 @@ function buildEnvMap(config) {
205
454
  KEYCLOAK_ADMIN_USER: config.auth.admin_user,
206
455
  KEYCLOAK_ADMIN_PASSWORD: config.auth.admin_password,
207
456
  KEYCLOAK_WEB_CLIENT_ID: config.auth.web_client_id,
208
- KEYCLOAK_WEB_CLIENT_SECRET: randomSecret(),
457
+ KEYCLOAK_WEB_CLIENT_SECRET: generated.secrets.keycloak_web_client_secret,
209
458
  KEYCLOAK_CLI_CLIENT_ID: config.auth.cli_client_id,
210
459
  KEYCLOAK_API_CLIENT_ID: config.auth.api_client_id,
211
- KEYCLOAK_API_CLIENT_SECRET: randomSecret(),
460
+ KEYCLOAK_API_CLIENT_SECRET: generated.secrets.keycloak_api_client_secret,
212
461
  KEYCLOAK_WEB_REDIRECT_URI: `https://${hosts.api}/api/access/web/callback`,
213
462
  KEYCLOAK_WEB_CALLBACK_URL: `https://${hosts.app}/auth/callback`,
214
463
  KEYCLOAK_API_REDIRECT_URI: `https://${hosts.api}/api/access/api/callback`,
215
464
  LANDING_PAGE_URL: `https://${hosts.landing}`,
216
465
  DASHBOARD_URL: `https://${hosts.app}`,
217
466
  OPENFGA_API_URL: "http://openfga:8090",
218
- OPENFGA_STORE_ID: "",
219
- OPENFGA_MODEL_ID: "",
220
- OPENFGA_DB_PASSWORD: randomSecret(),
467
+ OPENFGA_STORE_ID: generated.openfga.store_id,
468
+ OPENFGA_MODEL_ID: generated.openfga.model_id,
469
+ OPENFGA_DB_PASSWORD: generated.secrets.openfga_db_password,
221
470
  MINIKMS_GRPC_ADDR: "minikms:50051",
222
471
  MINIKMS_TLS_ENABLED: "false",
223
- MINIKMS_ROOT_KEY: randomBytes(32).toString("hex"),
472
+ MINIKMS_ROOT_KEY: generated.secrets.minikms_root_key,
224
473
  MINIKMS_DB_USER: "postgres",
225
- MINIKMS_DB_PASSWORD: randomSecret(),
474
+ MINIKMS_DB_PASSWORD: generated.secrets.minikms_db_password,
226
475
  OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-agent:4318",
227
476
  OTEL_SERVICE_NAME: "envsync-api",
228
477
  OTEL_SDK_DISABLED: "false",
229
- CLICKSTACK_URL: `https://${hosts.obs}`
478
+ CLICKSTACK_URL: `https://${hosts.obs}`,
479
+ KEYCLOAK_IMAGE_TAG: keycloakImageTag(config.images.keycloak)
230
480
  };
231
481
  }
232
- function renderEnv(config) {
233
- return Object.entries({
234
- ...buildEnvMap(config),
235
- KEYCLOAK_IMAGE_TAG: config.images.keycloak.split(":").slice(1).join(":") || "local"
236
- }).map(([k, v]) => `${k}=${v}`).join("\n") + "\n";
482
+ function renderEnvFile(env) {
483
+ return Object.entries(env).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `${key}=${value}`).join("\n") + "\n";
237
484
  }
238
- function renderServiceEnvironment(config, overrides = {}) {
239
- return toYaml({ ...buildEnvMap(config), ...overrides }, 0);
485
+ function renderEnvList(values, indent = 6) {
486
+ const prefix = " ".repeat(indent);
487
+ return Object.entries(values).map(([key, value]) => `${prefix}- ${JSON.stringify(`${key}=${String(value)}`)}`).join("\n");
240
488
  }
241
- function renderKeycloakRealm(config) {
489
+ function renderKeycloakRealm(config, runtimeEnv) {
242
490
  const hosts = domainMap(config.domain.root_domain);
243
- const webSecret = extractEnvValue("KEYCLOAK_WEB_CLIENT_SECRET");
244
- const apiSecret = extractEnvValue("KEYCLOAK_API_CLIENT_SECRET");
245
491
  return JSON.stringify(
246
492
  {
247
493
  realm: config.auth.keycloak_realm,
@@ -254,7 +500,7 @@ function renderKeycloakRealm(config) {
254
500
  name: "EnvSync Web",
255
501
  protocol: "openid-connect",
256
502
  publicClient: false,
257
- secret: webSecret,
503
+ secret: runtimeEnv.KEYCLOAK_WEB_CLIENT_SECRET,
258
504
  standardFlowEnabled: true,
259
505
  directAccessGrantsEnabled: false,
260
506
  redirectUris: [
@@ -273,7 +519,7 @@ function renderKeycloakRealm(config) {
273
519
  name: "EnvSync API",
274
520
  protocol: "openid-connect",
275
521
  publicClient: false,
276
- secret: apiSecret,
522
+ secret: runtimeEnv.KEYCLOAK_API_CLIENT_SECRET,
277
523
  standardFlowEnabled: true,
278
524
  redirectUris: [`https://${hosts.api}/api/access/api/callback`],
279
525
  webOrigins: [`https://${hosts.api}`],
@@ -297,11 +543,6 @@ function renderKeycloakRealm(config) {
297
543
  2
298
544
  ) + "\n";
299
545
  }
300
- function extractEnvValue(key) {
301
- const env = exists(DEPLOY_ENV) ? fs.readFileSync(DEPLOY_ENV, "utf8") : "";
302
- const line = env.split(/\r?\n/).find((entry) => entry.startsWith(`${key}=`));
303
- return line?.slice(key.length + 1) ?? "";
304
- }
305
546
  function renderTraefikDynamicConfig(config) {
306
547
  const hosts = domainMap(config.domain.root_domain);
307
548
  return [
@@ -340,17 +581,17 @@ function renderTraefikDynamicConfig(config) {
340
581
  " servers:",
341
582
  " - url: http://web_nginx:8080",
342
583
  " routers:",
343
- ` landing-router:`,
584
+ " landing-router:",
344
585
  ` rule: Host(\`${hosts.landing}\`)`,
345
586
  " service: landing",
346
587
  " entryPoints: [websecure]",
347
588
  " tls: {}",
348
- ` web-router:`,
589
+ " web-router:",
349
590
  ` rule: Host(\`${hosts.app}\`)`,
350
591
  " service: web",
351
592
  " entryPoints: [websecure]",
352
593
  " tls: {}",
353
- ` api-router:`,
594
+ " api-router:",
354
595
  ` rule: Host(\`${hosts.api}\`)`,
355
596
  " service: envsync-api",
356
597
  " entryPoints: [websecure]",
@@ -415,16 +656,19 @@ function renderOtelAgentConfig(config) {
415
656
  " exporters: [otlphttp/clickstack]"
416
657
  ].join("\n") + "\n";
417
658
  }
418
- function renderStack(config) {
659
+ function renderStack(config, runtimeEnv, mode) {
419
660
  const hosts = domainMap(config.domain.root_domain);
420
- const apiEnvironment = renderServiceEnvironment(config, {
661
+ const includeRuntimeInfra = mode !== "base";
662
+ const includeAppServices = mode === "full";
663
+ const apiEnvironment = {
664
+ ...runtimeEnv,
421
665
  OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-agent:4318",
422
666
  KEYCLOAK_URL: "http://keycloak:8080",
423
667
  OPENFGA_API_URL: "http://openfga:8090",
424
668
  MINIKMS_GRPC_ADDR: "minikms:50051",
425
669
  S3_ENDPOINT: "http://rustfs:9000",
426
670
  S3_BUCKET_URL: `https://${hosts.s3}`
427
- });
671
+ };
428
672
  return `
429
673
  version: "3.9"
430
674
  services:
@@ -458,9 +702,11 @@ services:
458
702
  postgres:
459
703
  image: postgres:17
460
704
  environment:
461
- POSTGRES_USER: postgres
462
- POSTGRES_PASSWORD: envsync-postgres
463
- POSTGRES_DB: envsync
705
+ ${renderEnvList({
706
+ POSTGRES_USER: "postgres",
707
+ POSTGRES_PASSWORD: "envsync-postgres",
708
+ POSTGRES_DB: "envsync"
709
+ })}
464
710
  volumes:
465
711
  - postgres_data:/var/lib/postgresql/data
466
712
  networks: [envsync]
@@ -474,10 +720,12 @@ services:
474
720
  rustfs:
475
721
  image: rustfs/rustfs:latest
476
722
  environment:
477
- RUSTFS_DATA_DIR: /data
478
- RUSTFS_ACCESS_KEY: envsync-rustfs
479
- RUSTFS_SECRET_KEY: ${extractEnvValue("S3_SECRET_KEY")}
480
- RUSTFS_CONSOLE_ENABLE: "true"
723
+ ${renderEnvList({
724
+ RUSTFS_DATA_DIR: "/data",
725
+ RUSTFS_ACCESS_KEY: "envsync-rustfs",
726
+ RUSTFS_SECRET_KEY: runtimeEnv.S3_SECRET_KEY,
727
+ RUSTFS_CONSOLE_ENABLE: "true"
728
+ })}
481
729
  volumes:
482
730
  - rustfs_data:/data
483
731
  networks: [envsync]
@@ -496,30 +744,34 @@ services:
496
744
  keycloak_db:
497
745
  image: postgres:17
498
746
  environment:
499
- POSTGRES_USER: keycloak
500
- POSTGRES_PASSWORD: ${extractEnvValue("KEYCLOAK_ADMIN_PASSWORD")}
501
- POSTGRES_DB: keycloak
747
+ ${renderEnvList({
748
+ POSTGRES_USER: "keycloak",
749
+ POSTGRES_PASSWORD: runtimeEnv.KEYCLOAK_ADMIN_PASSWORD,
750
+ POSTGRES_DB: "keycloak"
751
+ })}
502
752
  volumes:
503
753
  - keycloak_db_data:/var/lib/postgresql/data
504
754
  networks: [envsync]
505
-
755
+ ${includeRuntimeInfra ? `
506
756
  keycloak:
507
757
  image: ${config.images.keycloak}
508
758
  entrypoint: ["/bin/sh", "-lc"]
509
759
  command:
510
760
  - /opt/keycloak/bin/kc.sh import --dir /opt/keycloak/data/import --override true && exec /opt/keycloak/bin/kc.sh start-dev
511
761
  environment:
512
- KC_DB: postgres
513
- KC_DB_URL: jdbc:postgresql://keycloak_db:5432/keycloak
514
- KC_DB_USERNAME: keycloak
515
- KC_DB_PASSWORD: ${extractEnvValue("KEYCLOAK_ADMIN_PASSWORD")}
516
- KC_BOOTSTRAP_ADMIN_USERNAME: ${config.auth.admin_user}
517
- KC_BOOTSTRAP_ADMIN_PASSWORD: ${config.auth.admin_password}
518
- KC_HTTP_ENABLED: "true"
519
- KC_PROXY_HEADERS: xforwarded
520
- KC_HOSTNAME: ${hosts.auth}
762
+ ${renderEnvList({
763
+ KC_DB: "postgres",
764
+ KC_DB_URL: "jdbc:postgresql://keycloak_db:5432/keycloak",
765
+ KC_DB_USERNAME: "keycloak",
766
+ KC_DB_PASSWORD: runtimeEnv.KEYCLOAK_ADMIN_PASSWORD,
767
+ KC_BOOTSTRAP_ADMIN_USERNAME: config.auth.admin_user,
768
+ KC_BOOTSTRAP_ADMIN_PASSWORD: config.auth.admin_password,
769
+ KC_HTTP_ENABLED: "true",
770
+ KC_PROXY_HEADERS: "xforwarded",
771
+ KC_HOSTNAME: hosts.auth
772
+ })}
521
773
  volumes:
522
- - ${DEPLOY_ROOT}/keycloak-realm.envsync.json:/opt/keycloak/data/import/realm.json:ro
774
+ - ${KEYCLOAK_REALM_FILE}:/opt/keycloak/data/import/realm.json:ro
523
775
  networks: [envsync]
524
776
  deploy:
525
777
  labels:
@@ -527,46 +779,55 @@ services:
527
779
  - traefik.http.routers.keycloak.rule=Host(\`${hosts.auth}\`)
528
780
  - traefik.http.routers.keycloak.entrypoints=websecure
529
781
  - traefik.http.routers.keycloak.tls.certresolver=letsencrypt
530
- - traefik.http.services.keycloak.loadbalancer.server.port=8080
782
+ - traefik.http.services.keycloak.loadbalancer.server.port=8080` : ""}
531
783
 
532
784
  openfga_db:
533
785
  image: postgres:17
534
786
  environment:
535
- POSTGRES_USER: openfga
536
- POSTGRES_PASSWORD: ${extractEnvValue("OPENFGA_DB_PASSWORD")}
537
- POSTGRES_DB: openfga
787
+ ${renderEnvList({
788
+ POSTGRES_USER: "openfga",
789
+ POSTGRES_PASSWORD: runtimeEnv.OPENFGA_DB_PASSWORD,
790
+ POSTGRES_DB: "openfga"
791
+ })}
538
792
  volumes:
539
793
  - openfga_db_data:/var/lib/postgresql/data
540
794
  networks: [envsync]
541
-
795
+ ${includeRuntimeInfra ? `
542
796
  openfga:
543
797
  image: openfga/openfga:v1.12.0
544
798
  command: run
545
799
  environment:
546
- OPENFGA_DATASTORE_ENGINE: postgres
547
- OPENFGA_DATASTORE_URI: postgres://openfga:${extractEnvValue("OPENFGA_DB_PASSWORD")}@openfga_db:5432/openfga?sslmode=disable
548
- OPENFGA_HTTP_ADDR: 0.0.0.0:8090
549
- OPENFGA_GRPC_ADDR: 0.0.0.0:8091
550
- networks: [envsync]
800
+ ${renderEnvList({
801
+ OPENFGA_DATASTORE_ENGINE: "postgres",
802
+ OPENFGA_DATASTORE_URI: `postgres://openfga:${runtimeEnv.OPENFGA_DB_PASSWORD}@openfga_db:5432/openfga?sslmode=disable`,
803
+ OPENFGA_HTTP_ADDR: "0.0.0.0:8090",
804
+ OPENFGA_GRPC_ADDR: "0.0.0.0:8091"
805
+ })}
806
+ networks: [envsync]` : ""}
551
807
 
552
808
  minikms_db:
553
809
  image: postgres:17
554
810
  environment:
555
- POSTGRES_USER: postgres
556
- POSTGRES_PASSWORD: ${extractEnvValue("MINIKMS_DB_PASSWORD")}
557
- POSTGRES_DB: minikms
811
+ ${renderEnvList({
812
+ POSTGRES_USER: "postgres",
813
+ POSTGRES_PASSWORD: runtimeEnv.MINIKMS_DB_PASSWORD,
814
+ POSTGRES_DB: "minikms"
815
+ })}
558
816
  volumes:
559
817
  - minikms_db_data:/var/lib/postgresql/data
560
818
  networks: [envsync]
561
-
819
+ ${includeRuntimeInfra ? `
562
820
  minikms:
563
821
  image: ghcr.io/envsync-cloud/minikms:sha-735dfe8
564
822
  environment:
565
- MINIKMS_ROOT_KEY: ${extractEnvValue("MINIKMS_ROOT_KEY")}
566
- MINIKMS_DB_URL: postgres://postgres:${extractEnvValue("MINIKMS_DB_PASSWORD")}@minikms_db:5432/minikms?sslmode=disable
567
- MINIKMS_REDIS_URL: redis://redis:6379
568
- MINIKMS_TLS_ENABLED: "false"
569
- networks: [envsync]
823
+ ${renderEnvList({
824
+ MINIKMS_ROOT_KEY: runtimeEnv.MINIKMS_ROOT_KEY,
825
+ MINIKMS_DB_URL: `postgres://postgres:${runtimeEnv.MINIKMS_DB_PASSWORD}@minikms_db:5432/minikms?sslmode=disable`,
826
+ MINIKMS_REDIS_URL: "redis://redis:6379",
827
+ MINIKMS_GRPC_ADDR: "0.0.0.0:50051",
828
+ MINIKMS_TLS_ENABLED: "false"
829
+ })}
830
+ networks: [envsync]` : ""}
570
831
 
571
832
  clickstack:
572
833
  image: ${config.images.clickstack}
@@ -589,6 +850,7 @@ services:
589
850
  volumes:
590
851
  - ${OTEL_AGENT_CONF}:/etc/otel-agent.yaml:ro
591
852
  networks: [envsync]
853
+ ${includeAppServices ? `
592
854
 
593
855
  landing_nginx:
594
856
  image: nginx:1.27-alpine
@@ -607,14 +869,14 @@ services:
607
869
  envsync_api_blue:
608
870
  image: ${config.images.api}
609
871
  environment:
610
- ${indentBlock(apiEnvironment, 6)}
872
+ ${renderEnvList(apiEnvironment)}
611
873
  networks: [envsync]
612
874
 
613
875
  envsync_api_green:
614
876
  image: ${config.images.api}
615
877
  environment:
616
- ${indentBlock(apiEnvironment, 6)}
617
- networks: [envsync]
878
+ ${renderEnvList(apiEnvironment)}
879
+ networks: [envsync]` : ""}
618
880
 
619
881
  networks:
620
882
  envsync:
@@ -633,6 +895,232 @@ volumes:
633
895
  clickstack_ch_logs:
634
896
  `.trimStart();
635
897
  }
898
+ function writeDeployArtifacts(config, generated) {
899
+ const runtimeEnv = buildRuntimeEnv(config, generated);
900
+ writeFile(DEPLOY_ENV, renderEnvFile(runtimeEnv), 384);
901
+ writeFile(
902
+ INTERNAL_CONFIG_JSON,
903
+ JSON.stringify({ config, generated: mergeGeneratedState(runtimeEnv, generated) }, null, 2) + "\n"
904
+ );
905
+ writeFile(VERSIONS_LOCK, JSON.stringify(config.images, null, 2) + "\n");
906
+ writeFile(KEYCLOAK_REALM_FILE, renderKeycloakRealm(config, runtimeEnv));
907
+ writeFile(TRAEFIK_DYNAMIC_FILE, renderTraefikDynamicConfig(config));
908
+ writeFile(BOOTSTRAP_BASE_STACK_FILE, renderStack(config, runtimeEnv, "base"));
909
+ writeFile(BOOTSTRAP_STACK_FILE, renderStack(config, runtimeEnv, "bootstrap"));
910
+ writeFile(STACK_FILE, renderStack(config, runtimeEnv, "full"));
911
+ writeFile(NGINX_WEB_CONF, renderNginxConf("web"));
912
+ writeFile(NGINX_LANDING_CONF, renderNginxConf("landing"));
913
+ writeFile(OTEL_AGENT_CONF, renderOtelAgentConfig(config));
914
+ }
915
+ function saveDesiredConfig(config) {
916
+ const internal = readInternalState();
917
+ const generated = mergeGeneratedState(loadGeneratedEnv(), internal?.generated);
918
+ writeFile(DEPLOY_YAML, toYaml(config) + "\n");
919
+ writeFile(
920
+ INTERNAL_CONFIG_JSON,
921
+ JSON.stringify({ config, generated }, null, 2) + "\n"
922
+ );
923
+ }
924
+ function ensureRepoCheckout(config) {
925
+ ensureDir(REPO_ROOT);
926
+ if (!exists(path.join(REPO_ROOT, ".git"))) {
927
+ run("git", ["clone", config.source.repo_url, REPO_ROOT]);
928
+ }
929
+ run("git", ["remote", "set-url", "origin", config.source.repo_url], { cwd: REPO_ROOT });
930
+ run("git", ["fetch", "--tags", "--force", "origin"], { cwd: REPO_ROOT });
931
+ run("git", ["checkout", "--force", config.source.ref], { cwd: REPO_ROOT });
932
+ }
933
+ function extractStaticBundle(image, targetDir) {
934
+ ensureDir(targetDir);
935
+ const containerId = run("docker", ["create", image], { quiet: true }).trim();
936
+ try {
937
+ run("docker", ["cp", `${containerId}:/app/dist/.`, targetDir]);
938
+ } finally {
939
+ run("docker", ["rm", "-f", containerId], { quiet: true });
940
+ }
941
+ }
942
+ function buildKeycloakImage(imageTag, repoRoot = REPO_ROOT) {
943
+ const buildContext = path.join(repoRoot, "packages/envsync-keycloak-theme");
944
+ if (!exists(path.join(buildContext, "Dockerfile"))) {
945
+ throw new Error(`Missing Keycloak Docker build context at ${buildContext}`);
946
+ }
947
+ run("docker", ["build", "-t", imageTag, buildContext]);
948
+ }
949
+ function stackNetworkName(config) {
950
+ return `${config.services.stack_name}_envsync`;
951
+ }
952
+ function assertSwarmManager() {
953
+ const state = tryRun("docker", ["info", "--format", "{{.Swarm.LocalNodeState}}|{{.Swarm.ControlAvailable}}"], { quiet: true }).trim();
954
+ if (state !== "active|true") {
955
+ throw new Error("Docker Swarm is not initialized on this node. Run 'docker swarm init' or 'envsync-deploy preinstall' first.");
956
+ }
957
+ }
958
+ function waitForCommand(config, label, image, command, timeoutSeconds = 120, env = {}, volumes = []) {
959
+ const deadline = Date.now() + timeoutSeconds * 1e3;
960
+ while (Date.now() < deadline) {
961
+ const args = ["run", "--rm", "--network", stackNetworkName(config)];
962
+ for (const volume of volumes) {
963
+ args.push("-v", volume);
964
+ }
965
+ for (const [key, value] of Object.entries(env)) {
966
+ args.push("-e", `${key}=${value}`);
967
+ }
968
+ args.push(image, "sh", "-lc", command);
969
+ if (commandSucceeds("docker", args)) {
970
+ return;
971
+ }
972
+ sleepSeconds(2);
973
+ }
974
+ throw new Error(`Timed out waiting for ${label}`);
975
+ }
976
+ function waitForPostgresService(config, label, host, user, password) {
977
+ waitForCommand(config, `${label} database readiness`, "postgres:17", `pg_isready -h ${host} -U ${user}`, 120, {
978
+ PGPASSWORD: password
979
+ });
980
+ }
981
+ function waitForRedisService(config) {
982
+ waitForCommand(config, "redis readiness", "redis:7", "redis-cli -h redis ping | grep PONG");
983
+ }
984
+ function waitForTcpService(config, label, host, port, timeoutSeconds = 120) {
985
+ const deadline = Date.now() + timeoutSeconds * 1e3;
986
+ while (Date.now() < deadline) {
987
+ if (commandSucceeds(
988
+ "docker",
989
+ ["run", "--rm", "--network", stackNetworkName(config), "alpine:3.20", "sh", "-lc", `nc -z -w 2 ${host} ${port}`]
990
+ )) {
991
+ return;
992
+ }
993
+ sleepSeconds(2);
994
+ }
995
+ throw new Error(`Timed out waiting for ${label} at ${host}:${port}`);
996
+ }
997
+ function waitForHttpService(config, label, url, timeoutSeconds = 120) {
998
+ waitForCommand(config, `${label} HTTP readiness`, "alpine:3.20", `wget -q -O /dev/null ${JSON.stringify(url)}`, timeoutSeconds);
999
+ }
1000
+ function runOpenFgaMigrate(config, runtimeEnv) {
1001
+ run("docker", [
1002
+ "run",
1003
+ "--rm",
1004
+ "--network",
1005
+ stackNetworkName(config),
1006
+ "-e",
1007
+ "OPENFGA_DATASTORE_ENGINE=postgres",
1008
+ "-e",
1009
+ `OPENFGA_DATASTORE_URI=postgres://openfga:${runtimeEnv.OPENFGA_DB_PASSWORD}@openfga_db:5432/openfga?sslmode=disable`,
1010
+ "openfga/openfga:v1.12.0",
1011
+ "migrate"
1012
+ ]);
1013
+ }
1014
+ function runMiniKmsMigrate(config, runtimeEnv) {
1015
+ run("docker", [
1016
+ "run",
1017
+ "--rm",
1018
+ "--network",
1019
+ stackNetworkName(config),
1020
+ "-e",
1021
+ `PGPASSWORD=${runtimeEnv.MINIKMS_DB_PASSWORD}`,
1022
+ "-v",
1023
+ `${path.join(REPO_ROOT, "docker/minikms/migrations")}:/migrations:ro`,
1024
+ "postgres:17",
1025
+ "sh",
1026
+ "-lc",
1027
+ "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"
1028
+ ]);
1029
+ }
1030
+ function runBootstrapInit(config) {
1031
+ const output = run(
1032
+ "docker",
1033
+ [
1034
+ "run",
1035
+ "--rm",
1036
+ "--network",
1037
+ stackNetworkName(config),
1038
+ "--env-file",
1039
+ DEPLOY_ENV,
1040
+ "-e",
1041
+ "SKIP_ROOT_ENV=1",
1042
+ "-e",
1043
+ "SKIP_ROOT_ENV_WRITE=1",
1044
+ config.images.api,
1045
+ "bun",
1046
+ "run",
1047
+ "scripts/prod-init.ts",
1048
+ "--json",
1049
+ "--no-write-root-env"
1050
+ ],
1051
+ { quiet: true }
1052
+ ).trim();
1053
+ const result = JSON.parse(output);
1054
+ if (!result.openfgaStoreId || !result.openfgaModelId) {
1055
+ throw new Error("Bootstrap init did not return OpenFGA IDs");
1056
+ }
1057
+ return {
1058
+ openfgaStoreId: result.openfgaStoreId,
1059
+ openfgaModelId: result.openfgaModelId
1060
+ };
1061
+ }
1062
+ function hasCompleteBootstrapState(generated) {
1063
+ return REQUIRED_BOOTSTRAP_ENV_KEYS.every((key) => {
1064
+ switch (key) {
1065
+ case "S3_SECRET_KEY":
1066
+ return generated.secrets.s3_secret_key.length > 0;
1067
+ case "KEYCLOAK_WEB_CLIENT_SECRET":
1068
+ return generated.secrets.keycloak_web_client_secret.length > 0;
1069
+ case "KEYCLOAK_API_CLIENT_SECRET":
1070
+ return generated.secrets.keycloak_api_client_secret.length > 0;
1071
+ case "OPENFGA_DB_PASSWORD":
1072
+ return generated.secrets.openfga_db_password.length > 0;
1073
+ case "MINIKMS_ROOT_KEY":
1074
+ return generated.secrets.minikms_root_key.length > 0;
1075
+ case "MINIKMS_DB_PASSWORD":
1076
+ return generated.secrets.minikms_db_password.length > 0;
1077
+ case "OPENFGA_STORE_ID":
1078
+ return generated.openfga.store_id.length > 0;
1079
+ case "OPENFGA_MODEL_ID":
1080
+ return generated.openfga.model_id.length > 0;
1081
+ default:
1082
+ return false;
1083
+ }
1084
+ });
1085
+ }
1086
+ function assertBootstrapState(generated) {
1087
+ if (!hasCompleteBootstrapState(generated)) {
1088
+ throw new Error("Missing bootstrap state. Run 'envsync-deploy bootstrap' first.");
1089
+ }
1090
+ }
1091
+ function parseReplicaHealth(raw) {
1092
+ const match = raw.match(/^(\d+)\/(\d+)$/);
1093
+ if (!match) return raw.trim() ? "degraded" : "missing";
1094
+ const current = Number(match[1]);
1095
+ const desired = Number(match[2]);
1096
+ if (desired === 0) return "missing";
1097
+ if (current === desired) return "healthy";
1098
+ return "degraded";
1099
+ }
1100
+ function listStackServices(config) {
1101
+ const output = tryRun(
1102
+ "docker",
1103
+ ["stack", "services", config.services.stack_name, "--format", "{{.Name}}|{{.Replicas}}"],
1104
+ { quiet: true }
1105
+ );
1106
+ const services = /* @__PURE__ */ new Map();
1107
+ for (const line of output.split(/\r?\n/)) {
1108
+ if (!line.trim()) continue;
1109
+ const [name, replicas] = line.split("|");
1110
+ services.set(name, parseReplicaHealth(replicas ?? ""));
1111
+ }
1112
+ return services;
1113
+ }
1114
+ function serviceHealth(services, name) {
1115
+ return services.get(`${name}`) ?? "missing";
1116
+ }
1117
+ function apiHealth(services, stackName) {
1118
+ const blue = serviceHealth(services, `${stackName}_envsync_api_blue`);
1119
+ const green = serviceHealth(services, `${stackName}_envsync_api_green`);
1120
+ if (blue === "missing" && green === "missing") return "missing";
1121
+ if (blue === "healthy" || green === "healthy") return "healthy";
1122
+ return "degraded";
1123
+ }
636
1124
  async function cmdPreinstall() {
637
1125
  ensureDir(HOST_ROOT);
638
1126
  ensureDir(DEPLOY_ROOT);
@@ -655,7 +1143,9 @@ async function cmdPreinstall() {
655
1143
  async function cmdSetup() {
656
1144
  const rootDomain = await ask("Root domain", "example.com");
657
1145
  const acmeEmail = await ask("ACME email", `admin@${rootDomain}`);
658
- const channel = await ask("Release channel", "stable");
1146
+ const releaseVersion = await ask("Release version", getDeployCliVersion());
1147
+ assertSemverVersion(releaseVersion, "release version");
1148
+ const releaseImages = versionedImages(releaseVersion);
659
1149
  const adminUser = await ask("Keycloak admin user", "admin");
660
1150
  const adminPassword = await ask("Keycloak admin password", randomSecret(12));
661
1151
  const smtpHost = await ask("SMTP host", "smtp.example.com");
@@ -669,12 +1159,16 @@ async function cmdSetup() {
669
1159
  const publicObs = await ask("Expose obs.<domain> publicly (true/false)", "true") === "true";
670
1160
  const mailpitEnabled = await ask("Enable mailpit (true/false)", "false") === "true";
671
1161
  const config = {
1162
+ source: defaultSourceConfig(releaseVersion),
1163
+ release: {
1164
+ version: releaseVersion
1165
+ },
672
1166
  domain: { root_domain: rootDomain, acme_email: acmeEmail },
673
1167
  images: {
674
- api: `ghcr.io/envsync-cloud/envsync-api:${channel}`,
675
- keycloak: `envsync-keycloak:${channel}`,
676
- web: `ghcr.io/envsync-cloud/envsync-web-static:${channel}`,
677
- landing: `ghcr.io/envsync-cloud/envsync-landing-static:${channel}`,
1168
+ api: releaseImages.api,
1169
+ keycloak: releaseImages.keycloak,
1170
+ web: releaseImages.web,
1171
+ landing: releaseImages.landing,
678
1172
  clickstack: "clickhouse/clickstack-all-in-one:latest",
679
1173
  traefik: "traefik:v3.1",
680
1174
  otel_agent: "otel/opentelemetry-collector-contrib:0.111.0"
@@ -719,36 +1213,59 @@ async function cmdSetup() {
719
1213
  mailpit_enabled: mailpitEnabled,
720
1214
  s3_public: true,
721
1215
  s3_console_public: true
722
- },
723
- release_channel: channel
1216
+ }
724
1217
  };
725
- ensureDir(REPO_ROOT);
726
- if (!exists(path.join(REPO_ROOT, ".git"))) {
727
- run("git", ["clone", "https://github.com/EnvSync-Cloud/envsync.git", REPO_ROOT]);
728
- }
729
- saveConfig(config);
1218
+ saveDesiredConfig(config);
730
1219
  console.log(`Config written to ${DEPLOY_YAML}`);
1220
+ console.log(`Pinned source checkout: ${config.source.repo_url} @ ${config.source.ref}`);
731
1221
  console.log("Create these DNS records:");
732
1222
  console.log(JSON.stringify(domainMap(rootDomain), null, 2));
733
1223
  }
734
- function extractStaticBundle(image, targetDir) {
735
- ensureDir(targetDir);
736
- const containerId = run("docker", ["create", image], { quiet: true }).trim();
737
- try {
738
- run("docker", ["cp", `${containerId}:/app/dist/.`, targetDir]);
739
- } finally {
740
- run("docker", ["rm", "-f", containerId], { quiet: true });
741
- }
742
- }
743
- function buildKeycloakImage(imageTag, repoRoot = REPO_ROOT) {
744
- const buildContext = path.join(repoRoot, "packages/envsync-keycloak-theme");
745
- if (!exists(path.join(buildContext, "Dockerfile"))) {
746
- throw new Error(`Missing Keycloak Docker build context at ${buildContext}`);
747
- }
748
- run("docker", ["build", "-t", imageTag, buildContext]);
1224
+ async function cmdBootstrap() {
1225
+ const { config, generated } = loadState();
1226
+ const nextGenerated = ensureGeneratedRuntimeState(generated);
1227
+ const runtimeEnv = buildRuntimeEnv(config, nextGenerated);
1228
+ assertSwarmManager();
1229
+ ensureRepoCheckout(config);
1230
+ writeDeployArtifacts(config, nextGenerated);
1231
+ buildKeycloakImage(config.images.keycloak);
1232
+ run("docker", ["stack", "deploy", "-c", BOOTSTRAP_BASE_STACK_FILE, config.services.stack_name]);
1233
+ waitForPostgresService(config, "postgres", "postgres", "postgres", "envsync-postgres");
1234
+ waitForRedisService(config);
1235
+ waitForTcpService(config, "rustfs", "rustfs", 9e3);
1236
+ waitForPostgresService(config, "keycloak", "keycloak_db", "keycloak", runtimeEnv.KEYCLOAK_ADMIN_PASSWORD);
1237
+ waitForPostgresService(config, "openfga", "openfga_db", "openfga", runtimeEnv.OPENFGA_DB_PASSWORD);
1238
+ waitForPostgresService(config, "minikms", "minikms_db", "postgres", runtimeEnv.MINIKMS_DB_PASSWORD);
1239
+ runOpenFgaMigrate(config, runtimeEnv);
1240
+ runMiniKmsMigrate(config, runtimeEnv);
1241
+ run("docker", ["stack", "deploy", "-c", BOOTSTRAP_STACK_FILE, config.services.stack_name]);
1242
+ waitForHttpService(config, "keycloak", "http://keycloak:8080/realms/master");
1243
+ waitForHttpService(config, "openfga", "http://openfga:8090/stores");
1244
+ waitForTcpService(config, "minikms", "minikms", 50051);
1245
+ const initResult = runBootstrapInit(config);
1246
+ const bootstrappedGenerated = normalizeGeneratedState({
1247
+ openfga: {
1248
+ store_id: initResult.openfgaStoreId,
1249
+ model_id: initResult.openfgaModelId
1250
+ },
1251
+ secrets: nextGenerated.secrets,
1252
+ bootstrap: {
1253
+ completed_at: (/* @__PURE__ */ new Date()).toISOString()
1254
+ }
1255
+ });
1256
+ writeDeployArtifacts(config, bootstrappedGenerated);
1257
+ console.log("Bootstrap completed.");
749
1258
  }
750
1259
  async function cmdDeploy() {
751
- const config = loadConfig();
1260
+ const { config, generated } = loadState();
1261
+ assertSwarmManager();
1262
+ assertBootstrapState(generated);
1263
+ const services = listStackServices(config);
1264
+ 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") {
1265
+ throw new Error("Bootstrap has not completed successfully. Run 'envsync-deploy bootstrap' again.");
1266
+ }
1267
+ ensureRepoCheckout(config);
1268
+ writeDeployArtifacts(config, generated);
752
1269
  buildKeycloakImage(config.images.keycloak);
753
1270
  ensureDir(`${RELEASES_ROOT}/web/current`);
754
1271
  ensureDir(`${RELEASES_ROOT}/landing/current`);
@@ -759,12 +1276,29 @@ async function cmdDeploy() {
759
1276
  run("docker", ["stack", "deploy", "-c", STACK_FILE, config.services.stack_name]);
760
1277
  }
761
1278
  async function cmdHealth(asJson) {
762
- const config = loadConfig();
1279
+ const { config, generated } = loadState();
763
1280
  const hosts = domainMap(config.domain.root_domain);
764
- const services = run("docker", ["stack", "services", config.services.stack_name], { quiet: true });
1281
+ const services = listStackServices(config);
1282
+ const stackName = config.services.stack_name;
1283
+ const bootstrapServices = {
1284
+ postgres: serviceHealth(services, `${stackName}_postgres`),
1285
+ redis: serviceHealth(services, `${stackName}_redis`),
1286
+ rustfs: serviceHealth(services, `${stackName}_rustfs`),
1287
+ keycloak: serviceHealth(services, `${stackName}_keycloak`),
1288
+ openfga: serviceHealth(services, `${stackName}_openfga`),
1289
+ minikms: serviceHealth(services, `${stackName}_minikms`)
1290
+ };
765
1291
  const checks = {
766
- keycloak_image: config.images.keycloak,
767
- services,
1292
+ bootstrap: {
1293
+ completed: hasCompleteBootstrapState(generated) && generated.bootstrap.completed_at.length > 0,
1294
+ completed_at: generated.bootstrap.completed_at || null,
1295
+ services: bootstrapServices
1296
+ },
1297
+ deploy: {
1298
+ api: apiHealth(services, stackName),
1299
+ web: serviceHealth(services, `${stackName}_web_nginx`),
1300
+ landing: serviceHealth(services, `${stackName}_landing_nginx`)
1301
+ },
768
1302
  public: {
769
1303
  landing: `https://${hosts.landing}`,
770
1304
  app: `https://${hosts.app}`,
@@ -773,25 +1307,27 @@ async function cmdHealth(asJson) {
773
1307
  obs: `https://${hosts.obs}`
774
1308
  }
775
1309
  };
776
- if (asJson) console.log(JSON.stringify(checks, null, 2));
777
- else {
778
- console.log(services);
779
- console.log(JSON.stringify(checks.public, null, 2));
1310
+ if (asJson) {
1311
+ console.log(JSON.stringify(checks, null, 2));
1312
+ return;
780
1313
  }
1314
+ console.log(JSON.stringify(checks, null, 2));
781
1315
  }
782
1316
  async function cmdUpgrade() {
783
- const config = loadConfig();
784
- const nextImage = `ghcr.io/envsync-cloud/envsync-api:${config.release_channel}`;
785
- config.images.api = nextImage;
786
- saveConfig(config);
1317
+ const { config } = loadState();
1318
+ config.images = {
1319
+ ...config.images,
1320
+ ...versionedImages(config.release.version)
1321
+ };
1322
+ saveDesiredConfig(config);
787
1323
  await cmdDeploy();
788
1324
  }
789
1325
  async function cmdUpgradeDeps() {
790
- const config = loadConfig();
1326
+ const { config } = loadState();
791
1327
  config.images.traefik = "traefik:v3.1";
792
1328
  config.images.clickstack = "clickhouse/clickstack-all-in-one:latest";
793
1329
  config.images.otel_agent = "otel/opentelemetry-collector-contrib:0.111.0";
794
- saveConfig(config);
1330
+ saveDesiredConfig(config);
795
1331
  await cmdDeploy();
796
1332
  }
797
1333
  function sha256File(filePath) {
@@ -833,7 +1369,7 @@ function restoreDockerVolume(volumeName, sourceDir) {
833
1369
  ]);
834
1370
  }
835
1371
  async function cmdBackup() {
836
- const config = loadConfig();
1372
+ const { config } = loadState();
837
1373
  ensureDir(config.backup.output_dir);
838
1374
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:]/g, "-");
839
1375
  const archiveBase = path.join(config.backup.output_dir, `envsync-backup-${timestamp}`);
@@ -845,14 +1381,15 @@ async function cmdBackup() {
845
1381
  writeFile(path.join(staged, "deploy.yaml"), fs.readFileSync(DEPLOY_YAML, "utf8"));
846
1382
  writeFile(path.join(staged, "config.json"), fs.readFileSync(INTERNAL_CONFIG_JSON, "utf8"));
847
1383
  writeFile(path.join(staged, "versions.lock.json"), fs.readFileSync(VERSIONS_LOCK, "utf8"));
1384
+ writeFile(path.join(staged, "docker-stack.bootstrap.base.yaml"), fs.readFileSync(BOOTSTRAP_BASE_STACK_FILE, "utf8"));
1385
+ writeFile(path.join(staged, "docker-stack.bootstrap.yaml"), fs.readFileSync(BOOTSTRAP_STACK_FILE, "utf8"));
848
1386
  writeFile(path.join(staged, "docker-stack.yaml"), fs.readFileSync(STACK_FILE, "utf8"));
849
1387
  writeFile(path.join(staged, "traefik-dynamic.yaml"), fs.readFileSync(TRAEFIK_DYNAMIC_FILE, "utf8"));
850
1388
  writeFile(path.join(staged, "keycloak-realm.envsync.json"), fs.readFileSync(KEYCLOAK_REALM_FILE, "utf8"));
851
1389
  writeFile(path.join(staged, "otel-agent.yaml"), fs.readFileSync(OTEL_AGENT_CONF, "utf8"));
852
1390
  const volumesDir = path.join(staged, "volumes");
853
1391
  for (const volume of STACK_VOLUMES) {
854
- const target = path.join(volumesDir, volume);
855
- backupDockerVolume(stackVolumeName(config, volume), target);
1392
+ backupDockerVolume(stackVolumeName(config, volume), path.join(volumesDir, volume));
856
1393
  }
857
1394
  run("bash", ["-lc", `tar -czf ${JSON.stringify(tarPath)} -C ${JSON.stringify(staged)} .`]);
858
1395
  const manifest = {
@@ -867,7 +1404,6 @@ async function cmdBackup() {
867
1404
  }
868
1405
  async function cmdRestore(archivePath) {
869
1406
  if (!archivePath) throw new Error("restore requires a .tar.gz path");
870
- const config = loadConfig();
871
1407
  const restoreRoot = path.join(BACKUPS_ROOT, `restore-${Date.now()}`);
872
1408
  ensureDir(restoreRoot);
873
1409
  run("bash", ["-lc", `tar -xzf ${JSON.stringify(archivePath)} -C ${JSON.stringify(restoreRoot)}`]);
@@ -875,14 +1411,17 @@ async function cmdRestore(archivePath) {
875
1411
  writeFile(DEPLOY_YAML, fs.readFileSync(path.join(restoreRoot, "deploy.yaml"), "utf8"));
876
1412
  writeFile(INTERNAL_CONFIG_JSON, fs.readFileSync(path.join(restoreRoot, "config.json"), "utf8"));
877
1413
  writeFile(VERSIONS_LOCK, fs.readFileSync(path.join(restoreRoot, "versions.lock.json"), "utf8"));
1414
+ writeFile(BOOTSTRAP_BASE_STACK_FILE, fs.readFileSync(path.join(restoreRoot, "docker-stack.bootstrap.base.yaml"), "utf8"));
1415
+ writeFile(BOOTSTRAP_STACK_FILE, fs.readFileSync(path.join(restoreRoot, "docker-stack.bootstrap.yaml"), "utf8"));
878
1416
  writeFile(STACK_FILE, fs.readFileSync(path.join(restoreRoot, "docker-stack.yaml"), "utf8"));
879
1417
  writeFile(TRAEFIK_DYNAMIC_FILE, fs.readFileSync(path.join(restoreRoot, "traefik-dynamic.yaml"), "utf8"));
880
1418
  writeFile(KEYCLOAK_REALM_FILE, fs.readFileSync(path.join(restoreRoot, "keycloak-realm.envsync.json"), "utf8"));
881
1419
  writeFile(OTEL_AGENT_CONF, fs.readFileSync(path.join(restoreRoot, "otel-agent.yaml"), "utf8"));
1420
+ const config = loadConfig();
882
1421
  for (const volume of STACK_VOLUMES) {
883
1422
  restoreDockerVolume(stackVolumeName(config, volume), path.join(restoreRoot, "volumes", volume));
884
1423
  }
885
- await cmdDeploy();
1424
+ console.log("Restore completed. Run 'envsync-deploy deploy' to start services.");
886
1425
  }
887
1426
  async function main() {
888
1427
  const command = process.argv[2];
@@ -894,6 +1433,9 @@ async function main() {
894
1433
  case "setup":
895
1434
  await cmdSetup();
896
1435
  break;
1436
+ case "bootstrap":
1437
+ await cmdBootstrap();
1438
+ break;
897
1439
  case "deploy":
898
1440
  await cmdDeploy();
899
1441
  break;
@@ -913,7 +1455,7 @@ async function main() {
913
1455
  await cmdRestore(flag ?? "");
914
1456
  break;
915
1457
  default:
916
- console.log("Usage: envsync-deploy <preinstall|setup|deploy|health|upgrade|upgrade-deps|backup|restore>");
1458
+ console.log("Usage: envsync-deploy <preinstall|setup|bootstrap|deploy|health|upgrade|upgrade-deps|backup|restore>");
917
1459
  process.exit(command ? 1 : 0);
918
1460
  }
919
1461
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@envsync-cloud/deploy-cli",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "CLI for self-hosted EnvSync deployment on Docker Swarm",
5
5
  "type": "module",
6
6
  "bin": {