@envsync-cloud/deploy-cli 0.8.6 → 0.8.7

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 (2) hide show
  1. package/dist/index.js +211 -1
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ #!/usr/bin/env node
2
+
1
3
  // src/index.ts
2
4
  import { createHash, randomBytes } from "crypto";
3
5
  import { spawnSync } from "child_process";
@@ -84,6 +86,11 @@ function buildRuntimeEnv(config, generated) {
84
86
  ENVSYNC_LANDING_ENABLED: oss ? "false" : "true",
85
87
  ENVSYNC_SINGLE_ORG_MODE: oss ? "true" : "false",
86
88
  ENVSYNC_LICENSE_ENFORCEMENT: oss ? "false" : "true",
89
+ ENVSYNC_LICENSE_MODE: oss ? "none" : "certificate",
90
+ ENVSYNC_LICENSE_BUNDLE_PATH: oss ? "" : "/etc/envsync/license/enterprise-license-bundle.json",
91
+ ENVSYNC_LICENSE_CERT_PATH: oss ? "" : "/etc/envsync/license/enterprise-cert.pem",
92
+ ENVSYNC_LICENSE_KEY_PATH: oss ? "" : "/etc/envsync/license/enterprise-key.pem",
93
+ ENVSYNC_LICENSE_ROOT_CA_CERT_PATH: oss ? "" : "/etc/envsync/license/root-ca.pem",
87
94
  DB_AUTO_MIGRATE: "false",
88
95
  PORT: `${config.services.api_port}`,
89
96
  DATABASE_HOST: "postgres",
@@ -447,6 +454,7 @@ function renderStack(config, runtimeEnv, generated, mode, paths) {
447
454
  const includeRuntimeInfra = mode !== "base";
448
455
  const includeAppServices = mode === "full";
449
456
  const landingEnabled = !isOssConfig(config);
457
+ const apiLicenseVolume = landingEnabled ? "\n volumes:\n - /etc/envsync/license:/etc/envsync/license:ro" : "";
450
458
  const deployment = createSteadyApiDeploymentState(config, generated);
451
459
  const stackName = config.services.stack_name;
452
460
  const s3RouterName = `${stackName}-s3-router`;
@@ -693,6 +701,7 @@ ${renderEnvList({
693
701
  ENVSYNC_DEPLOY_SLOT: "blue",
694
702
  ENVSYNC_DEPLOY_RELEASE_VERSION: deployment.slots.blue.release_version || config.release.version
695
703
  })}
704
+ ${apiLicenseVolume}
696
705
  networks: [envsync]
697
706
  deploy:
698
707
  replicas: ${slotHasApiDeployment(deployment.slots.blue) ? 1 : 0}
@@ -705,6 +714,7 @@ ${renderEnvList({
705
714
  ENVSYNC_DEPLOY_SLOT: "green",
706
715
  ENVSYNC_DEPLOY_RELEASE_VERSION: deployment.slots.green.release_version || config.release.version
707
716
  })}
717
+ ${apiLicenseVolume}
708
718
  networks: [envsync]
709
719
  deploy:
710
720
  replicas: ${slotHasApiDeployment(deployment.slots.green) ? 1 : 0}` : ""}
@@ -784,6 +794,11 @@ var BACKUPS_ROOT = path2.join(HOST_ROOT, "backups");
784
794
  var REPO_ROOT = process.env.ENVSYNC_REPO_ROOT ?? path2.join(HOST_ROOT, "repo");
785
795
  var DEPLOY_ENV = path2.join(ETC_ROOT, "deploy.env");
786
796
  var DEPLOY_YAML = path2.join(ETC_ROOT, "deploy.yaml");
797
+ var LICENSE_ROOT = path2.join(ETC_ROOT, "license");
798
+ var LICENSE_BUNDLE_FILE = path2.join(LICENSE_ROOT, "enterprise-license-bundle.json");
799
+ var LICENSE_CERT_FILE = path2.join(LICENSE_ROOT, "enterprise-cert.pem");
800
+ var LICENSE_KEY_FILE = path2.join(LICENSE_ROOT, "enterprise-key.pem");
801
+ var LICENSE_ROOT_CA_FILE = path2.join(LICENSE_ROOT, "root-ca.pem");
787
802
  var VERSIONS_LOCK = path2.join(DEPLOY_ROOT, "versions.lock.json");
788
803
  var STACK_FILE = path2.join(DEPLOY_ROOT, "docker-stack.yaml");
789
804
  var BOOTSTRAP_BASE_STACK_FILE = path2.join(DEPLOY_ROOT, "docker-stack.bootstrap.base.yaml");
@@ -855,6 +870,7 @@ var CLICKSTACK_SELFHOST_TEAM_NAME = "EnvSync Self-Hosted Team";
855
870
  var SEMVER_VERSION_RE = /^\d+\.\d+\.\d+$/;
856
871
  var currentOptions = { dryRun: false, force: false };
857
872
  var DEFAULT_SOURCE_REPO_URL = "https://github.com/EnvSync-Cloud/envsync.git";
873
+ var DEFAULT_ENTERPRISE_LICENSE_SERVER_URL = "https://license.envsync.cloud";
858
874
  var MANAGED_VERSIONED_IMAGE_PREFIXES = {
859
875
  api: "ghcr.io/envsync-cloud/envsync-api:",
860
876
  keycloak: "envsync-keycloak:",
@@ -978,6 +994,9 @@ function exists2(target) {
978
994
  function randomSecret(bytes = 24) {
979
995
  return randomBytes(bytes).toString("hex");
980
996
  }
997
+ function deterministicInstallFingerprint(rootDomain, stackName) {
998
+ return `envsync-${createHash("sha256").update(`${rootDomain}:${stackName}`).digest("hex").slice(0, 32)}`;
999
+ }
981
1000
  function randomStrongPassword() {
982
1001
  return `EnvSync!${randomBytes(8).toString("hex")}Aa1`;
983
1002
  }
@@ -1114,6 +1133,9 @@ function renderHelpBlock() {
1114
1133
  " validate-topology Validate Enterprise edition topology rules",
1115
1134
  " upgrade [version] Pin a target release and deploy it",
1116
1135
  " upgrade-deps Refresh dependency images and redeploy",
1136
+ " license issue-cert Issue and install an Enterprise certificate bundle",
1137
+ " license renew-cert Renew and install the Enterprise certificate bundle",
1138
+ " license validate-cert Validate the installed Enterprise certificate bundle files",
1117
1139
  " backup Create a managed self-host backup archive",
1118
1140
  " restore <archive> Restore a backup archive into the managed self-host roots",
1119
1141
  "",
@@ -1189,6 +1211,7 @@ function printOperatorOverview() {
1189
1211
  "`envsync-deploy bootstrap --force`",
1190
1212
  "`envsync-deploy deploy`",
1191
1213
  "`envsync-deploy upgrade`",
1214
+ "`envsync-deploy license issue-cert`",
1192
1215
  "`envsync-deploy health --json`",
1193
1216
  "`envsync-deploy plan-topology --json`",
1194
1217
  "`envsync-deploy backup`",
@@ -1277,6 +1300,7 @@ function normalizeConfig(raw) {
1277
1300
  const { release_channel: _legacyReleaseChannel, ...rest } = raw;
1278
1301
  const rootDomain = requireDefined(raw.domain?.root_domain, "domain.root_domain");
1279
1302
  const acmeEmail = requireDefined(raw.domain?.acme_email, "domain.acme_email");
1303
+ const stackName = requireDefined(raw.services?.stack_name, "services.stack_name");
1280
1304
  return {
1281
1305
  ...rest,
1282
1306
  edition: raw.edition ?? "enterprise",
@@ -1301,7 +1325,7 @@ function normalizeConfig(raw) {
1301
1325
  otel_agent: raw.images?.otel_agent ?? "otel/opentelemetry-collector-contrib:0.111.0"
1302
1326
  },
1303
1327
  services: {
1304
- stack_name: requireDefined(raw.services?.stack_name, "services.stack_name"),
1328
+ stack_name: stackName,
1305
1329
  api_port: requireDefined(raw.services?.api_port, "services.api_port"),
1306
1330
  public_http_port: raw.services?.public_http_port ?? 80,
1307
1331
  public_https_port: raw.services?.public_https_port ?? 443,
@@ -1349,6 +1373,16 @@ function normalizeConfig(raw) {
1349
1373
  maintenance_mode_enabled: raw.upgrade?.maintenance_mode_enabled ?? true,
1350
1374
  db_snapshot_on_api_upgrade: raw.upgrade?.db_snapshot_on_api_upgrade ?? true,
1351
1375
  keep_failed_upgrade_db_snapshot: raw.upgrade?.keep_failed_upgrade_db_snapshot ?? true
1376
+ },
1377
+ license: raw.license ? {
1378
+ server_url: raw.license.server_url,
1379
+ key: raw.license.key,
1380
+ install_fingerprint: raw.license.install_fingerprint ?? deterministicInstallFingerprint(rootDomain, stackName),
1381
+ certificate_bundle_file: raw.license.certificate_bundle_file,
1382
+ certificate_validity_days: raw.license.certificate_validity_days
1383
+ } : raw.edition === "oss" ? void 0 : {
1384
+ install_fingerprint: deterministicInstallFingerprint(rootDomain, stackName),
1385
+ certificate_validity_days: 1095
1352
1386
  }
1353
1387
  };
1354
1388
  }
@@ -2666,6 +2700,7 @@ async function cmdPreinstall() {
2666
2700
  ensureDir(RELEASES_ROOT);
2667
2701
  ensureDir(BACKUPS_ROOT);
2668
2702
  ensureDir(ETC_ROOT);
2703
+ ensureDir(LICENSE_ROOT);
2669
2704
  ensureDir(TRAEFIK_STATE_ROOT);
2670
2705
  run("bash", ["-lc", "command -v apt-get >/dev/null"]);
2671
2706
  run("sudo", ["apt-get", "update"]);
@@ -2698,7 +2733,12 @@ async function cmdSetup() {
2698
2733
  const publicAuth = await ask("Expose auth.<domain> publicly (true/false)", "true") === "true";
2699
2734
  const publicObs = await ask("Expose obs.<domain> publicly (true/false)", "true") === "true";
2700
2735
  const mailpitEnabled = await ask("Enable mailpit (true/false)", "false") === "true";
2736
+ const licenseServerUrl = await ask("Enterprise license server URL", DEFAULT_ENTERPRISE_LICENSE_SERVER_URL);
2737
+ const licenseKey = licenseServerUrl ? await ask("Enterprise license key", "") : "";
2738
+ const certificateBundleFile = licenseServerUrl ? "" : await ask("Enterprise certificate bundle file (optional)", "");
2739
+ const installFingerprint = deterministicInstallFingerprint(rootDomain, "envsync");
2701
2740
  const config = {
2741
+ edition: "enterprise",
2702
2742
  source: defaultSourceConfig(releaseVersion),
2703
2743
  release: {
2704
2744
  version: releaseVersion
@@ -2760,6 +2800,13 @@ async function cmdSetup() {
2760
2800
  maintenance_mode_enabled: true,
2761
2801
  db_snapshot_on_api_upgrade: true,
2762
2802
  keep_failed_upgrade_db_snapshot: true
2803
+ },
2804
+ license: {
2805
+ server_url: licenseServerUrl || void 0,
2806
+ key: licenseKey || void 0,
2807
+ install_fingerprint: installFingerprint,
2808
+ certificate_bundle_file: certificateBundleFile || void 0,
2809
+ certificate_validity_days: 1095
2763
2810
  }
2764
2811
  };
2765
2812
  saveDesiredConfig(config);
@@ -2775,6 +2822,7 @@ async function cmdBootstrap() {
2775
2822
  const runtimeEnv = buildRuntimeEnv(config, nextGenerated);
2776
2823
  logReleaseContext(config);
2777
2824
  assertSwarmManager();
2825
+ await ensureEnterpriseCertificateBundle(config);
2778
2826
  if (currentOptions.dryRun) {
2779
2827
  logWarn("Dry-run mode: bootstrap reset will be previewed but not executed.");
2780
2828
  }
@@ -3236,6 +3284,154 @@ function sha256File(filePath) {
3236
3284
  hash.update(fs2.readFileSync(filePath));
3237
3285
  return hash.digest("hex");
3238
3286
  }
3287
+ function parseEnterpriseLicenseBundle(raw) {
3288
+ if (!raw || typeof raw !== "object") {
3289
+ throw new Error("Invalid Enterprise license certificate bundle: expected an object.");
3290
+ }
3291
+ const candidate = raw;
3292
+ const requiredStrings = [
3293
+ "certificate_pem",
3294
+ "private_key_pem",
3295
+ "root_ca_pem",
3296
+ "serial_hex",
3297
+ "certificate_fingerprint_sha256",
3298
+ "root_ca_fingerprint_sha256",
3299
+ "issued_at",
3300
+ "expires_at"
3301
+ ];
3302
+ for (const key of requiredStrings) {
3303
+ if (typeof candidate[key] !== "string" || candidate[key].length === 0) {
3304
+ throw new Error(`Invalid Enterprise license certificate bundle: missing ${String(key)}.`);
3305
+ }
3306
+ }
3307
+ if (!candidate.metadata || candidate.metadata.edition !== "enterprise") {
3308
+ throw new Error("Invalid Enterprise license certificate bundle: missing enterprise metadata.");
3309
+ }
3310
+ return candidate;
3311
+ }
3312
+ function readEnterpriseLicenseBundle(bundlePath) {
3313
+ return parseEnterpriseLicenseBundle(JSON.parse(fs2.readFileSync(bundlePath, "utf8")));
3314
+ }
3315
+ function normalizeLicenseServerUrl(serverUrl, route) {
3316
+ const trimmed = serverUrl.replace(/\/+$/, "");
3317
+ return `${trimmed}/v1/certificates/${route}`;
3318
+ }
3319
+ async function requestEnterpriseLicenseCertificate(config, route) {
3320
+ const license = config.license;
3321
+ if (!license?.server_url) {
3322
+ throw new Error(`Missing license.server_url in ${DEPLOY_YAML}. Set it or use license.certificate_bundle_file.`);
3323
+ }
3324
+ if (!license.key) {
3325
+ throw new Error(`Missing license.key in ${DEPLOY_YAML}.`);
3326
+ }
3327
+ const installFingerprint = license.install_fingerprint || deterministicInstallFingerprint(config.domain.root_domain, config.services.stack_name);
3328
+ const response = await fetch(normalizeLicenseServerUrl(license.server_url, route), {
3329
+ method: "POST",
3330
+ headers: {
3331
+ "content-type": "application/json",
3332
+ "x-license-server-access-key": license.key
3333
+ },
3334
+ body: JSON.stringify({
3335
+ license_key: license.key,
3336
+ install_fingerprint: installFingerprint,
3337
+ root_domain: config.domain.root_domain,
3338
+ stack_name: config.services.stack_name,
3339
+ edition: "enterprise",
3340
+ requested_validity_days: license.certificate_validity_days ?? 1095
3341
+ })
3342
+ });
3343
+ const body = await response.json().catch(() => ({}));
3344
+ if (!response.ok || !body.bundle) {
3345
+ throw new Error(body.error || body.message || `License certificate ${route} failed with HTTP ${response.status}`);
3346
+ }
3347
+ return parseEnterpriseLicenseBundle(body.bundle);
3348
+ }
3349
+ function writeEnterpriseLicenseBundle(bundle) {
3350
+ writeFileMaybe(LICENSE_BUNDLE_FILE, JSON.stringify(bundle, null, 2) + "\n", 384);
3351
+ writeFileMaybe(LICENSE_CERT_FILE, bundle.certificate_pem.endsWith("\n") ? bundle.certificate_pem : `${bundle.certificate_pem}
3352
+ `, 384);
3353
+ writeFileMaybe(LICENSE_KEY_FILE, bundle.private_key_pem.endsWith("\n") ? bundle.private_key_pem : `${bundle.private_key_pem}
3354
+ `, 384);
3355
+ writeFileMaybe(LICENSE_ROOT_CA_FILE, bundle.root_ca_pem.endsWith("\n") ? bundle.root_ca_pem : `${bundle.root_ca_pem}
3356
+ `, 420);
3357
+ }
3358
+ async function ensureEnterpriseCertificateBundle(config) {
3359
+ if (isOssConfig2(config)) return;
3360
+ if (exists2(LICENSE_BUNDLE_FILE) && exists2(LICENSE_CERT_FILE) && exists2(LICENSE_KEY_FILE) && exists2(LICENSE_ROOT_CA_FILE)) {
3361
+ validateEnterpriseLicenseBundleFiles(config, false);
3362
+ return;
3363
+ }
3364
+ if (currentOptions.dryRun) {
3365
+ logDryRun(`Would install or issue Enterprise certificate bundle under ${LICENSE_ROOT}`);
3366
+ return;
3367
+ }
3368
+ if (config.license?.certificate_bundle_file) {
3369
+ logStep("Installing Enterprise certificate bundle from configured file");
3370
+ const bundle = readEnterpriseLicenseBundle(config.license.certificate_bundle_file);
3371
+ writeEnterpriseLicenseBundle(bundle);
3372
+ validateEnterpriseLicenseBundleFiles(config, false);
3373
+ return;
3374
+ }
3375
+ if (config.license?.server_url && config.license.key) {
3376
+ logStep("Issuing Enterprise certificate bundle from license server");
3377
+ const bundle = await requestEnterpriseLicenseCertificate(config, "issue");
3378
+ writeEnterpriseLicenseBundle(bundle);
3379
+ validateEnterpriseLicenseBundleFiles(config, false);
3380
+ return;
3381
+ }
3382
+ throw new Error(
3383
+ `Enterprise deployments require a certificate bundle. Configure license.server_url and license.key, set license.certificate_bundle_file, or run 'envsync-deploy license issue-cert'.`
3384
+ );
3385
+ }
3386
+ function validateEnterpriseLicenseBundleFiles(config, verbose = true) {
3387
+ if (isOssConfig2(config)) {
3388
+ if (verbose) logInfo("OSS deployments do not use Enterprise license certificates.");
3389
+ return null;
3390
+ }
3391
+ const bundle = readEnterpriseLicenseBundle(LICENSE_BUNDLE_FILE);
3392
+ if (bundle.metadata.install_fingerprint !== config.license?.install_fingerprint) {
3393
+ throw new Error("Installed Enterprise certificate bundle does not match deploy.yaml license.install_fingerprint.");
3394
+ }
3395
+ if (bundle.metadata.stack_name !== config.services.stack_name) {
3396
+ throw new Error("Installed Enterprise certificate bundle does not match deploy.yaml services.stack_name.");
3397
+ }
3398
+ if (bundle.metadata.root_domain !== config.domain.root_domain) {
3399
+ throw new Error("Installed Enterprise certificate bundle does not match deploy.yaml domain.root_domain.");
3400
+ }
3401
+ for (const filePath of [LICENSE_CERT_FILE, LICENSE_KEY_FILE, LICENSE_ROOT_CA_FILE]) {
3402
+ if (!exists2(filePath)) throw new Error(`Missing Enterprise license file: ${filePath}`);
3403
+ }
3404
+ if (verbose) {
3405
+ logSuccess(`Enterprise certificate bundle is installed at ${LICENSE_ROOT}`);
3406
+ logInfo(`Certificate serial: ${bundle.serial_hex}`);
3407
+ logInfo(`Certificate expires: ${bundle.expires_at}`);
3408
+ }
3409
+ return bundle;
3410
+ }
3411
+ async function cmdLicense(action = "validate-cert") {
3412
+ logSection("Enterprise License");
3413
+ const config = loadConfig();
3414
+ if (isOssConfig2(config)) {
3415
+ logInfo("OSS deployments do not require license verification.");
3416
+ return;
3417
+ }
3418
+ if (action === "issue-cert" || action === "renew-cert") {
3419
+ const route = action === "renew-cert" ? "renew" : "issue";
3420
+ if (currentOptions.dryRun) {
3421
+ logDryRun(`Would ${route} Enterprise certificate bundle and install it under ${LICENSE_ROOT}`);
3422
+ return;
3423
+ }
3424
+ const bundle = config.license?.certificate_bundle_file ? readEnterpriseLicenseBundle(config.license.certificate_bundle_file) : await requestEnterpriseLicenseCertificate(config, route);
3425
+ writeEnterpriseLicenseBundle(bundle);
3426
+ validateEnterpriseLicenseBundleFiles(config);
3427
+ return;
3428
+ }
3429
+ if (action === "validate-cert") {
3430
+ validateEnterpriseLicenseBundleFiles(config);
3431
+ return;
3432
+ }
3433
+ throw new Error("Unknown license action. Expected issue-cert, renew-cert, or validate-cert.");
3434
+ }
3239
3435
  function stackVolumeName(config, name) {
3240
3436
  return `${config.services.stack_name}_${name}`;
3241
3437
  }
@@ -3347,6 +3543,9 @@ async function cmdBackup() {
3347
3543
  for (const volume of STACK_VOLUMES) {
3348
3544
  backupDockerVolume(stackVolumeName(config, volume), path2.join(staged, "volumes", volume));
3349
3545
  }
3546
+ if (!isOssConfig2(config)) {
3547
+ logDryRun(`Would include Enterprise license material from ${LICENSE_ROOT}`);
3548
+ }
3350
3549
  logDryRun(`Would write manifest ${manifestPath}`);
3351
3550
  logDryRun(`Would create archive ${tarPath}`);
3352
3551
  logSuccess("Backup dry-run completed");
@@ -3374,6 +3573,9 @@ async function cmdBackup() {
3374
3573
  writeFile(path2.join(staged, "keycloak-realm.envsync.json"), fs2.readFileSync(KEYCLOAK_REALM_FILE, "utf8"));
3375
3574
  writeFile(path2.join(staged, "otel-agent.yaml"), fs2.readFileSync(OTEL_AGENT_CONF, "utf8"));
3376
3575
  writeFile(path2.join(staged, "clickhouse-listen.xml"), fs2.readFileSync(CLICKSTACK_CLICKHOUSE_CONF, "utf8"));
3576
+ if (!isOssConfig2(config) && exists2(LICENSE_ROOT)) {
3577
+ fs2.cpSync(LICENSE_ROOT, path2.join(staged, "license"), { recursive: true });
3578
+ }
3377
3579
  const volumesDir = path2.join(staged, "volumes");
3378
3580
  for (const volume of STACK_VOLUMES) {
3379
3581
  backupDockerVolume(stackVolumeName(config, volume), path2.join(volumesDir, volume));
@@ -3450,6 +3652,11 @@ async function cmdRestore(archivePath, autoDeploy = false) {
3450
3652
  writeFile(KEYCLOAK_REALM_FILE, fs2.readFileSync(path2.join(restoreRoot, "keycloak-realm.envsync.json"), "utf8"));
3451
3653
  writeFile(OTEL_AGENT_CONF, fs2.readFileSync(path2.join(restoreRoot, "otel-agent.yaml"), "utf8"));
3452
3654
  writeFile(CLICKSTACK_CLICKHOUSE_CONF, fs2.readFileSync(path2.join(restoreRoot, "clickhouse-listen.xml"), "utf8"));
3655
+ const restoredLicenseRoot = path2.join(restoreRoot, "license");
3656
+ if (exists2(restoredLicenseRoot)) {
3657
+ fs2.rmSync(LICENSE_ROOT, { recursive: true, force: true });
3658
+ fs2.cpSync(restoredLicenseRoot, LICENSE_ROOT, { recursive: true });
3659
+ }
3453
3660
  const config = loadConfig();
3454
3661
  for (const volume of STACK_VOLUMES) {
3455
3662
  restoreDockerVolume(stackVolumeName(config, volume), path2.join(restoreRoot, "volumes", volume));
@@ -3511,6 +3718,9 @@ async function main() {
3511
3718
  case "upgrade-deps":
3512
3719
  await cmdUpgradeDeps();
3513
3720
  break;
3721
+ case "license":
3722
+ await cmdLicense(positionals[0]);
3723
+ break;
3514
3724
  case "backup":
3515
3725
  await cmdBackup();
3516
3726
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@envsync-cloud/deploy-cli",
3
- "version": "0.8.6",
3
+ "version": "0.8.7",
4
4
  "description": "CLI for self-hosted EnvSync deployment on Docker Swarm",
5
5
  "type": "module",
6
6
  "bin": {