@envsync-cloud/deploy-cli 0.8.5 → 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 +209 -1
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -86,6 +86,11 @@ function buildRuntimeEnv(config, generated) {
86
86
  ENVSYNC_LANDING_ENABLED: oss ? "false" : "true",
87
87
  ENVSYNC_SINGLE_ORG_MODE: oss ? "true" : "false",
88
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",
89
94
  DB_AUTO_MIGRATE: "false",
90
95
  PORT: `${config.services.api_port}`,
91
96
  DATABASE_HOST: "postgres",
@@ -449,6 +454,7 @@ function renderStack(config, runtimeEnv, generated, mode, paths) {
449
454
  const includeRuntimeInfra = mode !== "base";
450
455
  const includeAppServices = mode === "full";
451
456
  const landingEnabled = !isOssConfig(config);
457
+ const apiLicenseVolume = landingEnabled ? "\n volumes:\n - /etc/envsync/license:/etc/envsync/license:ro" : "";
452
458
  const deployment = createSteadyApiDeploymentState(config, generated);
453
459
  const stackName = config.services.stack_name;
454
460
  const s3RouterName = `${stackName}-s3-router`;
@@ -695,6 +701,7 @@ ${renderEnvList({
695
701
  ENVSYNC_DEPLOY_SLOT: "blue",
696
702
  ENVSYNC_DEPLOY_RELEASE_VERSION: deployment.slots.blue.release_version || config.release.version
697
703
  })}
704
+ ${apiLicenseVolume}
698
705
  networks: [envsync]
699
706
  deploy:
700
707
  replicas: ${slotHasApiDeployment(deployment.slots.blue) ? 1 : 0}
@@ -707,6 +714,7 @@ ${renderEnvList({
707
714
  ENVSYNC_DEPLOY_SLOT: "green",
708
715
  ENVSYNC_DEPLOY_RELEASE_VERSION: deployment.slots.green.release_version || config.release.version
709
716
  })}
717
+ ${apiLicenseVolume}
710
718
  networks: [envsync]
711
719
  deploy:
712
720
  replicas: ${slotHasApiDeployment(deployment.slots.green) ? 1 : 0}` : ""}
@@ -786,6 +794,11 @@ var BACKUPS_ROOT = path2.join(HOST_ROOT, "backups");
786
794
  var REPO_ROOT = process.env.ENVSYNC_REPO_ROOT ?? path2.join(HOST_ROOT, "repo");
787
795
  var DEPLOY_ENV = path2.join(ETC_ROOT, "deploy.env");
788
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");
789
802
  var VERSIONS_LOCK = path2.join(DEPLOY_ROOT, "versions.lock.json");
790
803
  var STACK_FILE = path2.join(DEPLOY_ROOT, "docker-stack.yaml");
791
804
  var BOOTSTRAP_BASE_STACK_FILE = path2.join(DEPLOY_ROOT, "docker-stack.bootstrap.base.yaml");
@@ -857,6 +870,7 @@ var CLICKSTACK_SELFHOST_TEAM_NAME = "EnvSync Self-Hosted Team";
857
870
  var SEMVER_VERSION_RE = /^\d+\.\d+\.\d+$/;
858
871
  var currentOptions = { dryRun: false, force: false };
859
872
  var DEFAULT_SOURCE_REPO_URL = "https://github.com/EnvSync-Cloud/envsync.git";
873
+ var DEFAULT_ENTERPRISE_LICENSE_SERVER_URL = "https://license.envsync.cloud";
860
874
  var MANAGED_VERSIONED_IMAGE_PREFIXES = {
861
875
  api: "ghcr.io/envsync-cloud/envsync-api:",
862
876
  keycloak: "envsync-keycloak:",
@@ -980,6 +994,9 @@ function exists2(target) {
980
994
  function randomSecret(bytes = 24) {
981
995
  return randomBytes(bytes).toString("hex");
982
996
  }
997
+ function deterministicInstallFingerprint(rootDomain, stackName) {
998
+ return `envsync-${createHash("sha256").update(`${rootDomain}:${stackName}`).digest("hex").slice(0, 32)}`;
999
+ }
983
1000
  function randomStrongPassword() {
984
1001
  return `EnvSync!${randomBytes(8).toString("hex")}Aa1`;
985
1002
  }
@@ -1116,6 +1133,9 @@ function renderHelpBlock() {
1116
1133
  " validate-topology Validate Enterprise edition topology rules",
1117
1134
  " upgrade [version] Pin a target release and deploy it",
1118
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",
1119
1139
  " backup Create a managed self-host backup archive",
1120
1140
  " restore <archive> Restore a backup archive into the managed self-host roots",
1121
1141
  "",
@@ -1191,6 +1211,7 @@ function printOperatorOverview() {
1191
1211
  "`envsync-deploy bootstrap --force`",
1192
1212
  "`envsync-deploy deploy`",
1193
1213
  "`envsync-deploy upgrade`",
1214
+ "`envsync-deploy license issue-cert`",
1194
1215
  "`envsync-deploy health --json`",
1195
1216
  "`envsync-deploy plan-topology --json`",
1196
1217
  "`envsync-deploy backup`",
@@ -1279,6 +1300,7 @@ function normalizeConfig(raw) {
1279
1300
  const { release_channel: _legacyReleaseChannel, ...rest } = raw;
1280
1301
  const rootDomain = requireDefined(raw.domain?.root_domain, "domain.root_domain");
1281
1302
  const acmeEmail = requireDefined(raw.domain?.acme_email, "domain.acme_email");
1303
+ const stackName = requireDefined(raw.services?.stack_name, "services.stack_name");
1282
1304
  return {
1283
1305
  ...rest,
1284
1306
  edition: raw.edition ?? "enterprise",
@@ -1303,7 +1325,7 @@ function normalizeConfig(raw) {
1303
1325
  otel_agent: raw.images?.otel_agent ?? "otel/opentelemetry-collector-contrib:0.111.0"
1304
1326
  },
1305
1327
  services: {
1306
- stack_name: requireDefined(raw.services?.stack_name, "services.stack_name"),
1328
+ stack_name: stackName,
1307
1329
  api_port: requireDefined(raw.services?.api_port, "services.api_port"),
1308
1330
  public_http_port: raw.services?.public_http_port ?? 80,
1309
1331
  public_https_port: raw.services?.public_https_port ?? 443,
@@ -1351,6 +1373,16 @@ function normalizeConfig(raw) {
1351
1373
  maintenance_mode_enabled: raw.upgrade?.maintenance_mode_enabled ?? true,
1352
1374
  db_snapshot_on_api_upgrade: raw.upgrade?.db_snapshot_on_api_upgrade ?? true,
1353
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
1354
1386
  }
1355
1387
  };
1356
1388
  }
@@ -2668,6 +2700,7 @@ async function cmdPreinstall() {
2668
2700
  ensureDir(RELEASES_ROOT);
2669
2701
  ensureDir(BACKUPS_ROOT);
2670
2702
  ensureDir(ETC_ROOT);
2703
+ ensureDir(LICENSE_ROOT);
2671
2704
  ensureDir(TRAEFIK_STATE_ROOT);
2672
2705
  run("bash", ["-lc", "command -v apt-get >/dev/null"]);
2673
2706
  run("sudo", ["apt-get", "update"]);
@@ -2700,7 +2733,12 @@ async function cmdSetup() {
2700
2733
  const publicAuth = await ask("Expose auth.<domain> publicly (true/false)", "true") === "true";
2701
2734
  const publicObs = await ask("Expose obs.<domain> publicly (true/false)", "true") === "true";
2702
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");
2703
2740
  const config = {
2741
+ edition: "enterprise",
2704
2742
  source: defaultSourceConfig(releaseVersion),
2705
2743
  release: {
2706
2744
  version: releaseVersion
@@ -2762,6 +2800,13 @@ async function cmdSetup() {
2762
2800
  maintenance_mode_enabled: true,
2763
2801
  db_snapshot_on_api_upgrade: true,
2764
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
2765
2810
  }
2766
2811
  };
2767
2812
  saveDesiredConfig(config);
@@ -2777,6 +2822,7 @@ async function cmdBootstrap() {
2777
2822
  const runtimeEnv = buildRuntimeEnv(config, nextGenerated);
2778
2823
  logReleaseContext(config);
2779
2824
  assertSwarmManager();
2825
+ await ensureEnterpriseCertificateBundle(config);
2780
2826
  if (currentOptions.dryRun) {
2781
2827
  logWarn("Dry-run mode: bootstrap reset will be previewed but not executed.");
2782
2828
  }
@@ -3238,6 +3284,154 @@ function sha256File(filePath) {
3238
3284
  hash.update(fs2.readFileSync(filePath));
3239
3285
  return hash.digest("hex");
3240
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
+ }
3241
3435
  function stackVolumeName(config, name) {
3242
3436
  return `${config.services.stack_name}_${name}`;
3243
3437
  }
@@ -3349,6 +3543,9 @@ async function cmdBackup() {
3349
3543
  for (const volume of STACK_VOLUMES) {
3350
3544
  backupDockerVolume(stackVolumeName(config, volume), path2.join(staged, "volumes", volume));
3351
3545
  }
3546
+ if (!isOssConfig2(config)) {
3547
+ logDryRun(`Would include Enterprise license material from ${LICENSE_ROOT}`);
3548
+ }
3352
3549
  logDryRun(`Would write manifest ${manifestPath}`);
3353
3550
  logDryRun(`Would create archive ${tarPath}`);
3354
3551
  logSuccess("Backup dry-run completed");
@@ -3376,6 +3573,9 @@ async function cmdBackup() {
3376
3573
  writeFile(path2.join(staged, "keycloak-realm.envsync.json"), fs2.readFileSync(KEYCLOAK_REALM_FILE, "utf8"));
3377
3574
  writeFile(path2.join(staged, "otel-agent.yaml"), fs2.readFileSync(OTEL_AGENT_CONF, "utf8"));
3378
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
+ }
3379
3579
  const volumesDir = path2.join(staged, "volumes");
3380
3580
  for (const volume of STACK_VOLUMES) {
3381
3581
  backupDockerVolume(stackVolumeName(config, volume), path2.join(volumesDir, volume));
@@ -3452,6 +3652,11 @@ async function cmdRestore(archivePath, autoDeploy = false) {
3452
3652
  writeFile(KEYCLOAK_REALM_FILE, fs2.readFileSync(path2.join(restoreRoot, "keycloak-realm.envsync.json"), "utf8"));
3453
3653
  writeFile(OTEL_AGENT_CONF, fs2.readFileSync(path2.join(restoreRoot, "otel-agent.yaml"), "utf8"));
3454
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
+ }
3455
3660
  const config = loadConfig();
3456
3661
  for (const volume of STACK_VOLUMES) {
3457
3662
  restoreDockerVolume(stackVolumeName(config, volume), path2.join(restoreRoot, "volumes", volume));
@@ -3513,6 +3718,9 @@ async function main() {
3513
3718
  case "upgrade-deps":
3514
3719
  await cmdUpgradeDeps();
3515
3720
  break;
3721
+ case "license":
3722
+ await cmdLicense(positionals[0]);
3723
+ break;
3516
3724
  case "backup":
3517
3725
  await cmdBackup();
3518
3726
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@envsync-cloud/deploy-cli",
3
- "version": "0.8.5",
3
+ "version": "0.8.7",
4
4
  "description": "CLI for self-hosted EnvSync deployment on Docker Swarm",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,7 +41,7 @@
41
41
  "secrets"
42
42
  ],
43
43
  "dependencies": {
44
- "@envsync-cloud/deploy-core": "0.8.5",
44
+ "@envsync-cloud/deploy-core": "workspace:*",
45
45
  "chalk": "^5.6.2",
46
46
  "yaml": "^1.10.2"
47
47
  },