@envsync-cloud/deploy-cli 0.6.2 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -7
- package/dist/index.js +483 -53
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -40,13 +40,13 @@ bunx @envsync-cloud/deploy-cli <command>
|
|
|
40
40
|
```text
|
|
41
41
|
envsync-deploy preinstall
|
|
42
42
|
envsync-deploy setup
|
|
43
|
-
envsync-deploy bootstrap
|
|
44
|
-
envsync-deploy deploy
|
|
43
|
+
envsync-deploy bootstrap [--dry-run]
|
|
44
|
+
envsync-deploy deploy [--dry-run]
|
|
45
45
|
envsync-deploy health [--json]
|
|
46
|
-
envsync-deploy upgrade
|
|
47
|
-
envsync-deploy upgrade-deps
|
|
48
|
-
envsync-deploy backup
|
|
49
|
-
envsync-deploy restore <archive>
|
|
46
|
+
envsync-deploy upgrade [--dry-run]
|
|
47
|
+
envsync-deploy upgrade-deps [--dry-run]
|
|
48
|
+
envsync-deploy backup [--dry-run]
|
|
49
|
+
envsync-deploy restore <archive> [--dry-run]
|
|
50
50
|
```
|
|
51
51
|
|
|
52
52
|
## Quick Start
|
|
@@ -63,6 +63,8 @@ Write the desired self-hosted config:
|
|
|
63
63
|
npx @envsync-cloud/deploy-cli setup
|
|
64
64
|
```
|
|
65
65
|
|
|
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
|
+
|
|
66
68
|
Bootstrap infra, migrations, RustFS, and OpenFGA:
|
|
67
69
|
|
|
68
70
|
```bash
|
|
@@ -77,7 +79,7 @@ npx @envsync-cloud/deploy-cli deploy
|
|
|
77
79
|
|
|
78
80
|
The staged flow is:
|
|
79
81
|
- `setup` writes desired config
|
|
80
|
-
- `bootstrap` starts infra and persists generated runtime env state
|
|
82
|
+
- `bootstrap` starts base infra, runs OpenFGA and miniKMS migrations, starts runtime infra, and persists generated runtime env state
|
|
81
83
|
- `deploy` starts the pending API and frontend services
|
|
82
84
|
|
|
83
85
|
Check service health:
|
|
@@ -98,6 +100,13 @@ Restore from an existing backup archive:
|
|
|
98
100
|
npx @envsync-cloud/deploy-cli restore /path/to/envsync-backup.tar.gz
|
|
99
101
|
```
|
|
100
102
|
|
|
103
|
+
Preview mutating commands without changing the host:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
npx @envsync-cloud/deploy-cli bootstrap --dry-run
|
|
107
|
+
npx @envsync-cloud/deploy-cli deploy --dry-run
|
|
108
|
+
```
|
|
109
|
+
|
|
101
110
|
## Links
|
|
102
111
|
|
|
103
112
|
- Repository: https://github.com/EnvSync-Cloud/envsync
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { spawnSync } from "child_process";
|
|
|
6
6
|
import fs from "fs";
|
|
7
7
|
import path from "path";
|
|
8
8
|
import readline from "readline";
|
|
9
|
+
import chalk from "chalk";
|
|
9
10
|
var HOST_ROOT = "/opt/envsync";
|
|
10
11
|
var DEPLOY_ROOT = "/opt/envsync/deploy";
|
|
11
12
|
var RELEASES_ROOT = "/opt/envsync/releases";
|
|
@@ -17,6 +18,7 @@ var DEPLOY_ENV = "/etc/envsync/deploy.env";
|
|
|
17
18
|
var DEPLOY_YAML = "/etc/envsync/deploy.yaml";
|
|
18
19
|
var VERSIONS_LOCK = "/opt/envsync/deploy/versions.lock.json";
|
|
19
20
|
var STACK_FILE = "/opt/envsync/deploy/docker-stack.yaml";
|
|
21
|
+
var BOOTSTRAP_BASE_STACK_FILE = "/opt/envsync/deploy/docker-stack.bootstrap.base.yaml";
|
|
20
22
|
var BOOTSTRAP_STACK_FILE = "/opt/envsync/deploy/docker-stack.bootstrap.yaml";
|
|
21
23
|
var TRAEFIK_DYNAMIC_FILE = "/opt/envsync/deploy/traefik-dynamic.yaml";
|
|
22
24
|
var KEYCLOAK_REALM_FILE = "/opt/envsync/deploy/keycloak-realm.envsync.json";
|
|
@@ -45,6 +47,34 @@ var REQUIRED_BOOTSTRAP_ENV_KEYS = [
|
|
|
45
47
|
"OPENFGA_STORE_ID",
|
|
46
48
|
"OPENFGA_MODEL_ID"
|
|
47
49
|
];
|
|
50
|
+
var SEMVER_VERSION_RE = /^\d+\.\d+\.\d+$/;
|
|
51
|
+
var currentOptions = { dryRun: false };
|
|
52
|
+
function formatShellArg(arg) {
|
|
53
|
+
if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(arg)) return arg;
|
|
54
|
+
return JSON.stringify(arg);
|
|
55
|
+
}
|
|
56
|
+
function formatCommand(cmd, args) {
|
|
57
|
+
return [cmd, ...args].map(formatShellArg).join(" ");
|
|
58
|
+
}
|
|
59
|
+
function logSection(title) {
|
|
60
|
+
console.log(`
|
|
61
|
+
${chalk.bold.blue(title)}`);
|
|
62
|
+
}
|
|
63
|
+
function logStep(message) {
|
|
64
|
+
console.log(`${chalk.cyan("[step]")} ${message}`);
|
|
65
|
+
}
|
|
66
|
+
function logInfo(message) {
|
|
67
|
+
console.log(`${chalk.blue("[info]")} ${message}`);
|
|
68
|
+
}
|
|
69
|
+
function logSuccess(message) {
|
|
70
|
+
console.log(`${chalk.green("[ok]")} ${message}`);
|
|
71
|
+
}
|
|
72
|
+
function logDryRun(message) {
|
|
73
|
+
console.log(`${chalk.magenta("[dry-run]")} ${message}`);
|
|
74
|
+
}
|
|
75
|
+
function logCommand(cmd, args) {
|
|
76
|
+
console.log(chalk.dim(`$ ${formatCommand(cmd, args)}`));
|
|
77
|
+
}
|
|
48
78
|
function run(cmd, args, opts = {}) {
|
|
49
79
|
const result = spawnSync(cmd, args, {
|
|
50
80
|
cwd: opts.cwd,
|
|
@@ -83,6 +113,13 @@ function writeFile(target, content, mode) {
|
|
|
83
113
|
fs.writeFileSync(target, content, "utf8");
|
|
84
114
|
if (mode != null) fs.chmodSync(target, mode);
|
|
85
115
|
}
|
|
116
|
+
function writeFileMaybe(target, content, mode) {
|
|
117
|
+
if (currentOptions.dryRun) {
|
|
118
|
+
logDryRun(`Would write ${target}`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
writeFile(target, content, mode);
|
|
122
|
+
}
|
|
86
123
|
function exists(target) {
|
|
87
124
|
return fs.existsSync(target);
|
|
88
125
|
}
|
|
@@ -203,16 +240,120 @@ function getDeployCliVersion() {
|
|
|
203
240
|
return process.env.npm_package_version ?? "0.0.0";
|
|
204
241
|
}
|
|
205
242
|
}
|
|
206
|
-
function
|
|
243
|
+
function assertSemverVersion(version, label = "release version") {
|
|
244
|
+
if (!SEMVER_VERSION_RE.test(version)) {
|
|
245
|
+
throw new Error(`Invalid ${label} '${version}'. Expected an exact semver like 0.6.2.`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
function versionedImages(version) {
|
|
249
|
+
assertSemverVersion(version);
|
|
250
|
+
return {
|
|
251
|
+
api: `ghcr.io/envsync-cloud/envsync-api:${version}`,
|
|
252
|
+
keycloak: `envsync-keycloak:${version}`,
|
|
253
|
+
web: `ghcr.io/envsync-cloud/envsync-web-static:${version}`,
|
|
254
|
+
landing: `ghcr.io/envsync-cloud/envsync-landing-static:${version}`
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
function defaultSourceConfig(version) {
|
|
207
258
|
return {
|
|
208
259
|
repo_url: "https://github.com/EnvSync-Cloud/envsync.git",
|
|
209
|
-
ref: `v${
|
|
260
|
+
ref: `v${version}`
|
|
210
261
|
};
|
|
211
262
|
}
|
|
263
|
+
function resolveReleaseVersion(raw) {
|
|
264
|
+
const releaseVersion = raw.release?.version;
|
|
265
|
+
if (releaseVersion) {
|
|
266
|
+
assertSemverVersion(releaseVersion);
|
|
267
|
+
return releaseVersion;
|
|
268
|
+
}
|
|
269
|
+
if (typeof raw.release_channel === "string" && raw.release_channel.length > 0) {
|
|
270
|
+
if (SEMVER_VERSION_RE.test(raw.release_channel)) {
|
|
271
|
+
return raw.release_channel;
|
|
272
|
+
}
|
|
273
|
+
if (raw.release_channel === "stable" || raw.release_channel === "latest") {
|
|
274
|
+
throw new Error(
|
|
275
|
+
"Legacy release channel config is no longer supported for self-hosted installs. Set an exact release version in /etc/envsync/deploy.yaml."
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
throw new Error(`Invalid legacy release channel '${raw.release_channel}'. Set an exact release version in /etc/envsync/deploy.yaml.`);
|
|
279
|
+
}
|
|
280
|
+
return getDeployCliVersion();
|
|
281
|
+
}
|
|
282
|
+
function requireDefined(value, label) {
|
|
283
|
+
if (value === void 0) {
|
|
284
|
+
throw new Error(`Missing ${label} in ${DEPLOY_YAML}. Run setup again.`);
|
|
285
|
+
}
|
|
286
|
+
return value;
|
|
287
|
+
}
|
|
212
288
|
function normalizeConfig(raw) {
|
|
289
|
+
const version = resolveReleaseVersion(raw);
|
|
290
|
+
const derivedImages = versionedImages(version);
|
|
291
|
+
const { release_channel: _legacyReleaseChannel, ...rest } = raw;
|
|
292
|
+
const rootDomain = requireDefined(raw.domain?.root_domain, "domain.root_domain");
|
|
293
|
+
const acmeEmail = requireDefined(raw.domain?.acme_email, "domain.acme_email");
|
|
213
294
|
return {
|
|
214
|
-
...
|
|
215
|
-
source:
|
|
295
|
+
...rest,
|
|
296
|
+
source: {
|
|
297
|
+
repo_url: raw.source?.repo_url ?? "https://github.com/EnvSync-Cloud/envsync.git",
|
|
298
|
+
ref: `v${version}`
|
|
299
|
+
},
|
|
300
|
+
release: {
|
|
301
|
+
version
|
|
302
|
+
},
|
|
303
|
+
domain: {
|
|
304
|
+
root_domain: rootDomain,
|
|
305
|
+
acme_email: acmeEmail
|
|
306
|
+
},
|
|
307
|
+
images: {
|
|
308
|
+
api: derivedImages.api,
|
|
309
|
+
keycloak: derivedImages.keycloak,
|
|
310
|
+
web: derivedImages.web,
|
|
311
|
+
landing: derivedImages.landing,
|
|
312
|
+
clickstack: raw.images?.clickstack ?? "clickhouse/clickstack-all-in-one:latest",
|
|
313
|
+
traefik: raw.images?.traefik ?? "traefik:v3.1",
|
|
314
|
+
otel_agent: raw.images?.otel_agent ?? "otel/opentelemetry-collector-contrib:0.111.0"
|
|
315
|
+
},
|
|
316
|
+
services: {
|
|
317
|
+
stack_name: requireDefined(raw.services?.stack_name, "services.stack_name"),
|
|
318
|
+
api_port: requireDefined(raw.services?.api_port, "services.api_port"),
|
|
319
|
+
clickstack_ui_port: requireDefined(raw.services?.clickstack_ui_port, "services.clickstack_ui_port"),
|
|
320
|
+
clickstack_otlp_http_port: requireDefined(raw.services?.clickstack_otlp_http_port, "services.clickstack_otlp_http_port"),
|
|
321
|
+
clickstack_otlp_grpc_port: requireDefined(raw.services?.clickstack_otlp_grpc_port, "services.clickstack_otlp_grpc_port"),
|
|
322
|
+
keycloak_port: requireDefined(raw.services?.keycloak_port, "services.keycloak_port"),
|
|
323
|
+
rustfs_port: requireDefined(raw.services?.rustfs_port, "services.rustfs_port"),
|
|
324
|
+
rustfs_console_port: requireDefined(raw.services?.rustfs_console_port, "services.rustfs_console_port")
|
|
325
|
+
},
|
|
326
|
+
auth: {
|
|
327
|
+
keycloak_realm: requireDefined(raw.auth?.keycloak_realm, "auth.keycloak_realm"),
|
|
328
|
+
admin_user: requireDefined(raw.auth?.admin_user, "auth.admin_user"),
|
|
329
|
+
admin_password: requireDefined(raw.auth?.admin_password, "auth.admin_password"),
|
|
330
|
+
web_client_id: requireDefined(raw.auth?.web_client_id, "auth.web_client_id"),
|
|
331
|
+
api_client_id: requireDefined(raw.auth?.api_client_id, "auth.api_client_id"),
|
|
332
|
+
cli_client_id: requireDefined(raw.auth?.cli_client_id, "auth.cli_client_id")
|
|
333
|
+
},
|
|
334
|
+
observability: {
|
|
335
|
+
retention_days: requireDefined(raw.observability?.retention_days, "observability.retention_days"),
|
|
336
|
+
public_obs: requireDefined(raw.observability?.public_obs, "observability.public_obs")
|
|
337
|
+
},
|
|
338
|
+
backup: {
|
|
339
|
+
output_dir: requireDefined(raw.backup?.output_dir, "backup.output_dir"),
|
|
340
|
+
encrypted: requireDefined(raw.backup?.encrypted, "backup.encrypted")
|
|
341
|
+
},
|
|
342
|
+
smtp: {
|
|
343
|
+
host: requireDefined(raw.smtp?.host, "smtp.host"),
|
|
344
|
+
port: requireDefined(raw.smtp?.port, "smtp.port"),
|
|
345
|
+
secure: requireDefined(raw.smtp?.secure, "smtp.secure"),
|
|
346
|
+
user: requireDefined(raw.smtp?.user, "smtp.user"),
|
|
347
|
+
pass: requireDefined(raw.smtp?.pass, "smtp.pass"),
|
|
348
|
+
from: requireDefined(raw.smtp?.from, "smtp.from")
|
|
349
|
+
},
|
|
350
|
+
exposure: {
|
|
351
|
+
public_auth: requireDefined(raw.exposure?.public_auth, "exposure.public_auth"),
|
|
352
|
+
public_obs: requireDefined(raw.exposure?.public_obs, "exposure.public_obs"),
|
|
353
|
+
mailpit_enabled: requireDefined(raw.exposure?.mailpit_enabled, "exposure.mailpit_enabled"),
|
|
354
|
+
s3_public: requireDefined(raw.exposure?.s3_public, "exposure.s3_public"),
|
|
355
|
+
s3_console_public: requireDefined(raw.exposure?.s3_console_public, "exposure.s3_console_public")
|
|
356
|
+
}
|
|
216
357
|
};
|
|
217
358
|
}
|
|
218
359
|
function emptyGeneratedState() {
|
|
@@ -550,8 +691,10 @@ function renderOtelAgentConfig(config) {
|
|
|
550
691
|
" exporters: [otlphttp/clickstack]"
|
|
551
692
|
].join("\n") + "\n";
|
|
552
693
|
}
|
|
553
|
-
function renderStack(config, runtimeEnv,
|
|
694
|
+
function renderStack(config, runtimeEnv, mode) {
|
|
554
695
|
const hosts = domainMap(config.domain.root_domain);
|
|
696
|
+
const includeRuntimeInfra = mode !== "base";
|
|
697
|
+
const includeAppServices = mode === "full";
|
|
555
698
|
const apiEnvironment = {
|
|
556
699
|
...runtimeEnv,
|
|
557
700
|
OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-agent:4318",
|
|
@@ -644,7 +787,7 @@ ${renderEnvList({
|
|
|
644
787
|
volumes:
|
|
645
788
|
- keycloak_db_data:/var/lib/postgresql/data
|
|
646
789
|
networks: [envsync]
|
|
647
|
-
|
|
790
|
+
${includeRuntimeInfra ? `
|
|
648
791
|
keycloak:
|
|
649
792
|
image: ${config.images.keycloak}
|
|
650
793
|
entrypoint: ["/bin/sh", "-lc"]
|
|
@@ -659,8 +802,9 @@ ${renderEnvList({
|
|
|
659
802
|
KC_BOOTSTRAP_ADMIN_USERNAME: config.auth.admin_user,
|
|
660
803
|
KC_BOOTSTRAP_ADMIN_PASSWORD: config.auth.admin_password,
|
|
661
804
|
KC_HTTP_ENABLED: "true",
|
|
805
|
+
KC_HEALTH_ENABLED: "true",
|
|
662
806
|
KC_PROXY_HEADERS: "xforwarded",
|
|
663
|
-
|
|
807
|
+
KC_HOSTNAME_STRICT: "false"
|
|
664
808
|
})}
|
|
665
809
|
volumes:
|
|
666
810
|
- ${KEYCLOAK_REALM_FILE}:/opt/keycloak/data/import/realm.json:ro
|
|
@@ -671,7 +815,7 @@ ${renderEnvList({
|
|
|
671
815
|
- traefik.http.routers.keycloak.rule=Host(\`${hosts.auth}\`)
|
|
672
816
|
- traefik.http.routers.keycloak.entrypoints=websecure
|
|
673
817
|
- traefik.http.routers.keycloak.tls.certresolver=letsencrypt
|
|
674
|
-
- traefik.http.services.keycloak.loadbalancer.server.port=8080
|
|
818
|
+
- traefik.http.services.keycloak.loadbalancer.server.port=8080` : ""}
|
|
675
819
|
|
|
676
820
|
openfga_db:
|
|
677
821
|
image: postgres:17
|
|
@@ -684,7 +828,7 @@ ${renderEnvList({
|
|
|
684
828
|
volumes:
|
|
685
829
|
- openfga_db_data:/var/lib/postgresql/data
|
|
686
830
|
networks: [envsync]
|
|
687
|
-
|
|
831
|
+
${includeRuntimeInfra ? `
|
|
688
832
|
openfga:
|
|
689
833
|
image: openfga/openfga:v1.12.0
|
|
690
834
|
command: run
|
|
@@ -695,7 +839,7 @@ ${renderEnvList({
|
|
|
695
839
|
OPENFGA_HTTP_ADDR: "0.0.0.0:8090",
|
|
696
840
|
OPENFGA_GRPC_ADDR: "0.0.0.0:8091"
|
|
697
841
|
})}
|
|
698
|
-
networks: [envsync]
|
|
842
|
+
networks: [envsync]` : ""}
|
|
699
843
|
|
|
700
844
|
minikms_db:
|
|
701
845
|
image: postgres:17
|
|
@@ -708,7 +852,7 @@ ${renderEnvList({
|
|
|
708
852
|
volumes:
|
|
709
853
|
- minikms_db_data:/var/lib/postgresql/data
|
|
710
854
|
networks: [envsync]
|
|
711
|
-
|
|
855
|
+
${includeRuntimeInfra ? `
|
|
712
856
|
minikms:
|
|
713
857
|
image: ghcr.io/envsync-cloud/minikms:sha-735dfe8
|
|
714
858
|
environment:
|
|
@@ -719,7 +863,7 @@ ${renderEnvList({
|
|
|
719
863
|
MINIKMS_GRPC_ADDR: "0.0.0.0:50051",
|
|
720
864
|
MINIKMS_TLS_ENABLED: "false"
|
|
721
865
|
})}
|
|
722
|
-
networks: [envsync]
|
|
866
|
+
networks: [envsync]` : ""}
|
|
723
867
|
|
|
724
868
|
clickstack:
|
|
725
869
|
image: ${config.images.clickstack}
|
|
@@ -789,39 +933,59 @@ volumes:
|
|
|
789
933
|
}
|
|
790
934
|
function writeDeployArtifacts(config, generated) {
|
|
791
935
|
const runtimeEnv = buildRuntimeEnv(config, generated);
|
|
792
|
-
|
|
793
|
-
|
|
936
|
+
logStep("Rendering deploy artifacts");
|
|
937
|
+
writeFileMaybe(DEPLOY_ENV, renderEnvFile(runtimeEnv), 384);
|
|
938
|
+
writeFileMaybe(
|
|
794
939
|
INTERNAL_CONFIG_JSON,
|
|
795
940
|
JSON.stringify({ config, generated: mergeGeneratedState(runtimeEnv, generated) }, null, 2) + "\n"
|
|
796
941
|
);
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
942
|
+
writeFileMaybe(VERSIONS_LOCK, JSON.stringify(config.images, null, 2) + "\n");
|
|
943
|
+
writeFileMaybe(KEYCLOAK_REALM_FILE, renderKeycloakRealm(config, runtimeEnv));
|
|
944
|
+
writeFileMaybe(TRAEFIK_DYNAMIC_FILE, renderTraefikDynamicConfig(config));
|
|
945
|
+
writeFileMaybe(BOOTSTRAP_BASE_STACK_FILE, renderStack(config, runtimeEnv, "base"));
|
|
946
|
+
writeFileMaybe(BOOTSTRAP_STACK_FILE, renderStack(config, runtimeEnv, "bootstrap"));
|
|
947
|
+
writeFileMaybe(STACK_FILE, renderStack(config, runtimeEnv, "full"));
|
|
948
|
+
writeFileMaybe(NGINX_WEB_CONF, renderNginxConf("web"));
|
|
949
|
+
writeFileMaybe(NGINX_LANDING_CONF, renderNginxConf("landing"));
|
|
950
|
+
writeFileMaybe(OTEL_AGENT_CONF, renderOtelAgentConfig(config));
|
|
951
|
+
logSuccess(currentOptions.dryRun ? "Deploy artifacts previewed" : "Deploy artifacts written");
|
|
805
952
|
}
|
|
806
953
|
function saveDesiredConfig(config) {
|
|
807
954
|
const internal = readInternalState();
|
|
808
955
|
const generated = mergeGeneratedState(loadGeneratedEnv(), internal?.generated);
|
|
809
|
-
|
|
810
|
-
|
|
956
|
+
logStep(`Saving desired config to ${DEPLOY_YAML}`);
|
|
957
|
+
writeFileMaybe(DEPLOY_YAML, toYaml(config) + "\n");
|
|
958
|
+
writeFileMaybe(
|
|
811
959
|
INTERNAL_CONFIG_JSON,
|
|
812
960
|
JSON.stringify({ config, generated }, null, 2) + "\n"
|
|
813
961
|
);
|
|
962
|
+
logSuccess(currentOptions.dryRun ? "Desired config previewed" : "Desired config saved");
|
|
814
963
|
}
|
|
815
964
|
function ensureRepoCheckout(config) {
|
|
965
|
+
logStep(`Ensuring pinned repo checkout at ${config.source.ref}`);
|
|
966
|
+
if (currentOptions.dryRun) {
|
|
967
|
+
logDryRun(`Would ensure repo checkout at ${REPO_ROOT}`);
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
816
970
|
ensureDir(REPO_ROOT);
|
|
817
971
|
if (!exists(path.join(REPO_ROOT, ".git"))) {
|
|
972
|
+
logCommand("git", ["clone", config.source.repo_url, REPO_ROOT]);
|
|
818
973
|
run("git", ["clone", config.source.repo_url, REPO_ROOT]);
|
|
819
974
|
}
|
|
975
|
+
logCommand("git", ["remote", "set-url", "origin", config.source.repo_url]);
|
|
820
976
|
run("git", ["remote", "set-url", "origin", config.source.repo_url], { cwd: REPO_ROOT });
|
|
977
|
+
logCommand("git", ["fetch", "--tags", "--force", "origin"]);
|
|
821
978
|
run("git", ["fetch", "--tags", "--force", "origin"], { cwd: REPO_ROOT });
|
|
979
|
+
logCommand("git", ["checkout", "--force", config.source.ref]);
|
|
822
980
|
run("git", ["checkout", "--force", config.source.ref], { cwd: REPO_ROOT });
|
|
981
|
+
logSuccess(`Pinned repo checkout ready at ${config.source.ref}`);
|
|
823
982
|
}
|
|
824
983
|
function extractStaticBundle(image, targetDir) {
|
|
984
|
+
logStep(`Extracting static bundle from ${image}`);
|
|
985
|
+
if (currentOptions.dryRun) {
|
|
986
|
+
logDryRun(`Would extract ${image} into ${targetDir}`);
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
825
989
|
ensureDir(targetDir);
|
|
826
990
|
const containerId = run("docker", ["create", image], { quiet: true }).trim();
|
|
827
991
|
try {
|
|
@@ -829,17 +993,69 @@ function extractStaticBundle(image, targetDir) {
|
|
|
829
993
|
} finally {
|
|
830
994
|
run("docker", ["rm", "-f", containerId], { quiet: true });
|
|
831
995
|
}
|
|
996
|
+
logSuccess(`Static bundle extracted to ${targetDir}`);
|
|
832
997
|
}
|
|
833
998
|
function buildKeycloakImage(imageTag, repoRoot = REPO_ROOT) {
|
|
834
999
|
const buildContext = path.join(repoRoot, "packages/envsync-keycloak-theme");
|
|
835
1000
|
if (!exists(path.join(buildContext, "Dockerfile"))) {
|
|
836
1001
|
throw new Error(`Missing Keycloak Docker build context at ${buildContext}`);
|
|
837
1002
|
}
|
|
1003
|
+
logStep(`Building Keycloak image ${imageTag}`);
|
|
1004
|
+
if (currentOptions.dryRun) {
|
|
1005
|
+
logDryRun(`Would build ${imageTag} from ${buildContext}`);
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
logCommand("docker", ["build", "-t", imageTag, buildContext]);
|
|
838
1009
|
run("docker", ["build", "-t", imageTag, buildContext]);
|
|
1010
|
+
logSuccess(`Built Keycloak image ${imageTag}`);
|
|
839
1011
|
}
|
|
840
1012
|
function stackNetworkName(config) {
|
|
841
1013
|
return `${config.services.stack_name}_envsync`;
|
|
842
1014
|
}
|
|
1015
|
+
function assertSwarmManager() {
|
|
1016
|
+
if (currentOptions.dryRun) {
|
|
1017
|
+
logDryRun("Skipping Docker Swarm manager validation");
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
logStep("Validating Docker Swarm manager state");
|
|
1021
|
+
const state = tryRun("docker", ["info", "--format", "{{.Swarm.LocalNodeState}}|{{.Swarm.ControlAvailable}}"], { quiet: true }).trim();
|
|
1022
|
+
if (state !== "active|true") {
|
|
1023
|
+
throw new Error("Docker Swarm is not initialized on this node. Run 'docker swarm init' or 'envsync-deploy preinstall' first.");
|
|
1024
|
+
}
|
|
1025
|
+
logSuccess("Docker Swarm manager is ready");
|
|
1026
|
+
}
|
|
1027
|
+
function waitForCommand(config, label, image, command, timeoutSeconds = 120, env = {}, volumes = []) {
|
|
1028
|
+
if (currentOptions.dryRun) {
|
|
1029
|
+
logDryRun(`Would wait for ${label}`);
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
logStep(`Waiting for ${label}`);
|
|
1033
|
+
const deadline = Date.now() + timeoutSeconds * 1e3;
|
|
1034
|
+
while (Date.now() < deadline) {
|
|
1035
|
+
const args = ["run", "--rm", "--network", stackNetworkName(config)];
|
|
1036
|
+
for (const volume of volumes) {
|
|
1037
|
+
args.push("-v", volume);
|
|
1038
|
+
}
|
|
1039
|
+
for (const [key, value] of Object.entries(env)) {
|
|
1040
|
+
args.push("-e", `${key}=${value}`);
|
|
1041
|
+
}
|
|
1042
|
+
args.push(image, "sh", "-lc", command);
|
|
1043
|
+
if (commandSucceeds("docker", args)) {
|
|
1044
|
+
logSuccess(`${label} is ready`);
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
sleepSeconds(2);
|
|
1048
|
+
}
|
|
1049
|
+
throw new Error(`Timed out waiting for ${label}`);
|
|
1050
|
+
}
|
|
1051
|
+
function waitForPostgresService(config, label, host, user, password) {
|
|
1052
|
+
waitForCommand(config, `${label} database readiness`, "postgres:17", `pg_isready -h ${host} -U ${user}`, 120, {
|
|
1053
|
+
PGPASSWORD: password
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
function waitForRedisService(config) {
|
|
1057
|
+
waitForCommand(config, "redis readiness", "redis:7", "redis-cli -h redis ping | grep PONG");
|
|
1058
|
+
}
|
|
843
1059
|
function waitForTcpService(config, label, host, port, timeoutSeconds = 120) {
|
|
844
1060
|
const deadline = Date.now() + timeoutSeconds * 1e3;
|
|
845
1061
|
while (Date.now() < deadline) {
|
|
@@ -853,7 +1069,104 @@ function waitForTcpService(config, label, host, port, timeoutSeconds = 120) {
|
|
|
853
1069
|
}
|
|
854
1070
|
throw new Error(`Timed out waiting for ${label} at ${host}:${port}`);
|
|
855
1071
|
}
|
|
1072
|
+
function waitForHttpService(config, label, url, timeoutSeconds = 120) {
|
|
1073
|
+
waitForCommand(config, `${label} HTTP readiness`, "alpine:3.20", `wget -q -O /dev/null ${JSON.stringify(url)}`, timeoutSeconds);
|
|
1074
|
+
}
|
|
1075
|
+
function runOpenFgaMigrate(config, runtimeEnv) {
|
|
1076
|
+
logStep("Running OpenFGA datastore migrations");
|
|
1077
|
+
if (currentOptions.dryRun) {
|
|
1078
|
+
logDryRun("Would run OpenFGA datastore migrations");
|
|
1079
|
+
logCommand("docker", [
|
|
1080
|
+
"run",
|
|
1081
|
+
"--rm",
|
|
1082
|
+
"--network",
|
|
1083
|
+
stackNetworkName(config),
|
|
1084
|
+
"-e",
|
|
1085
|
+
"OPENFGA_DATASTORE_ENGINE=postgres",
|
|
1086
|
+
"-e",
|
|
1087
|
+
`OPENFGA_DATASTORE_URI=postgres://openfga:${runtimeEnv.OPENFGA_DB_PASSWORD}@openfga_db:5432/openfga?sslmode=disable`,
|
|
1088
|
+
"openfga/openfga:v1.12.0",
|
|
1089
|
+
"migrate"
|
|
1090
|
+
]);
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
run("docker", [
|
|
1094
|
+
"run",
|
|
1095
|
+
"--rm",
|
|
1096
|
+
"--network",
|
|
1097
|
+
stackNetworkName(config),
|
|
1098
|
+
"-e",
|
|
1099
|
+
"OPENFGA_DATASTORE_ENGINE=postgres",
|
|
1100
|
+
"-e",
|
|
1101
|
+
`OPENFGA_DATASTORE_URI=postgres://openfga:${runtimeEnv.OPENFGA_DB_PASSWORD}@openfga_db:5432/openfga?sslmode=disable`,
|
|
1102
|
+
"openfga/openfga:v1.12.0",
|
|
1103
|
+
"migrate"
|
|
1104
|
+
]);
|
|
1105
|
+
logSuccess("OpenFGA datastore migrations completed");
|
|
1106
|
+
}
|
|
1107
|
+
function runMiniKmsMigrate(config, runtimeEnv) {
|
|
1108
|
+
logStep("Running miniKMS datastore migrations");
|
|
1109
|
+
if (currentOptions.dryRun) {
|
|
1110
|
+
logDryRun("Would run miniKMS datastore migrations");
|
|
1111
|
+
logCommand("docker", [
|
|
1112
|
+
"run",
|
|
1113
|
+
"--rm",
|
|
1114
|
+
"--network",
|
|
1115
|
+
stackNetworkName(config),
|
|
1116
|
+
"-e",
|
|
1117
|
+
`PGPASSWORD=${runtimeEnv.MINIKMS_DB_PASSWORD}`,
|
|
1118
|
+
"-v",
|
|
1119
|
+
`${path.join(REPO_ROOT, "docker/minikms/migrations")}:/migrations:ro`,
|
|
1120
|
+
"postgres:17",
|
|
1121
|
+
"sh",
|
|
1122
|
+
"-lc",
|
|
1123
|
+
"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"
|
|
1124
|
+
]);
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
run("docker", [
|
|
1128
|
+
"run",
|
|
1129
|
+
"--rm",
|
|
1130
|
+
"--network",
|
|
1131
|
+
stackNetworkName(config),
|
|
1132
|
+
"-e",
|
|
1133
|
+
`PGPASSWORD=${runtimeEnv.MINIKMS_DB_PASSWORD}`,
|
|
1134
|
+
"-v",
|
|
1135
|
+
`${path.join(REPO_ROOT, "docker/minikms/migrations")}:/migrations:ro`,
|
|
1136
|
+
"postgres:17",
|
|
1137
|
+
"sh",
|
|
1138
|
+
"-lc",
|
|
1139
|
+
"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"
|
|
1140
|
+
]);
|
|
1141
|
+
logSuccess("miniKMS datastore migrations completed");
|
|
1142
|
+
}
|
|
856
1143
|
function runBootstrapInit(config) {
|
|
1144
|
+
logStep("Running API bootstrap init");
|
|
1145
|
+
if (currentOptions.dryRun) {
|
|
1146
|
+
logDryRun("Would run API bootstrap init and persist generated OpenFGA IDs");
|
|
1147
|
+
logCommand("docker", [
|
|
1148
|
+
"run",
|
|
1149
|
+
"--rm",
|
|
1150
|
+
"--network",
|
|
1151
|
+
stackNetworkName(config),
|
|
1152
|
+
"--env-file",
|
|
1153
|
+
DEPLOY_ENV,
|
|
1154
|
+
"-e",
|
|
1155
|
+
"SKIP_ROOT_ENV=1",
|
|
1156
|
+
"-e",
|
|
1157
|
+
"SKIP_ROOT_ENV_WRITE=1",
|
|
1158
|
+
config.images.api,
|
|
1159
|
+
"bun",
|
|
1160
|
+
"run",
|
|
1161
|
+
"scripts/prod-init.ts",
|
|
1162
|
+
"--json",
|
|
1163
|
+
"--no-write-root-env"
|
|
1164
|
+
]);
|
|
1165
|
+
return {
|
|
1166
|
+
openfgaStoreId: "",
|
|
1167
|
+
openfgaModelId: ""
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
857
1170
|
const output = run(
|
|
858
1171
|
"docker",
|
|
859
1172
|
[
|
|
@@ -880,6 +1193,7 @@ function runBootstrapInit(config) {
|
|
|
880
1193
|
if (!result.openfgaStoreId || !result.openfgaModelId) {
|
|
881
1194
|
throw new Error("Bootstrap init did not return OpenFGA IDs");
|
|
882
1195
|
}
|
|
1196
|
+
logSuccess("API bootstrap init completed");
|
|
883
1197
|
return {
|
|
884
1198
|
openfgaStoreId: result.openfgaStoreId,
|
|
885
1199
|
openfgaModelId: result.openfgaModelId
|
|
@@ -967,9 +1281,12 @@ async function cmdPreinstall() {
|
|
|
967
1281
|
run("bash", ["-lc", "curl -fsSL https://acme-v02.api.letsencrypt.org/directory >/dev/null"]);
|
|
968
1282
|
}
|
|
969
1283
|
async function cmdSetup() {
|
|
1284
|
+
logSection("Setup");
|
|
970
1285
|
const rootDomain = await ask("Root domain", "example.com");
|
|
971
1286
|
const acmeEmail = await ask("ACME email", `admin@${rootDomain}`);
|
|
972
|
-
const
|
|
1287
|
+
const releaseVersion = await ask("Release version", getDeployCliVersion());
|
|
1288
|
+
assertSemverVersion(releaseVersion, "release version");
|
|
1289
|
+
const releaseImages = versionedImages(releaseVersion);
|
|
973
1290
|
const adminUser = await ask("Keycloak admin user", "admin");
|
|
974
1291
|
const adminPassword = await ask("Keycloak admin password", randomSecret(12));
|
|
975
1292
|
const smtpHost = await ask("SMTP host", "smtp.example.com");
|
|
@@ -983,13 +1300,16 @@ async function cmdSetup() {
|
|
|
983
1300
|
const publicObs = await ask("Expose obs.<domain> publicly (true/false)", "true") === "true";
|
|
984
1301
|
const mailpitEnabled = await ask("Enable mailpit (true/false)", "false") === "true";
|
|
985
1302
|
const config = {
|
|
986
|
-
source: defaultSourceConfig(),
|
|
1303
|
+
source: defaultSourceConfig(releaseVersion),
|
|
1304
|
+
release: {
|
|
1305
|
+
version: releaseVersion
|
|
1306
|
+
},
|
|
987
1307
|
domain: { root_domain: rootDomain, acme_email: acmeEmail },
|
|
988
1308
|
images: {
|
|
989
|
-
api:
|
|
990
|
-
keycloak:
|
|
991
|
-
web:
|
|
992
|
-
landing:
|
|
1309
|
+
api: releaseImages.api,
|
|
1310
|
+
keycloak: releaseImages.keycloak,
|
|
1311
|
+
web: releaseImages.web,
|
|
1312
|
+
landing: releaseImages.landing,
|
|
993
1313
|
clickstack: "clickhouse/clickstack-all-in-one:latest",
|
|
994
1314
|
traefik: "traefik:v3.1",
|
|
995
1315
|
otel_agent: "otel/opentelemetry-collector-contrib:0.111.0"
|
|
@@ -1034,29 +1354,59 @@ async function cmdSetup() {
|
|
|
1034
1354
|
mailpit_enabled: mailpitEnabled,
|
|
1035
1355
|
s3_public: true,
|
|
1036
1356
|
s3_console_public: true
|
|
1037
|
-
}
|
|
1038
|
-
release_channel: channel
|
|
1357
|
+
}
|
|
1039
1358
|
};
|
|
1040
1359
|
saveDesiredConfig(config);
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1360
|
+
logSuccess(`Config written to ${DEPLOY_YAML}`);
|
|
1361
|
+
logInfo(`Pinned source checkout: ${config.source.repo_url} @ ${config.source.ref}`);
|
|
1362
|
+
logInfo("Create these DNS records:");
|
|
1044
1363
|
console.log(JSON.stringify(domainMap(rootDomain), null, 2));
|
|
1045
1364
|
}
|
|
1046
1365
|
async function cmdBootstrap() {
|
|
1366
|
+
logSection("Bootstrap");
|
|
1047
1367
|
const { config, generated } = loadState();
|
|
1048
1368
|
const nextGenerated = ensureGeneratedRuntimeState(generated);
|
|
1369
|
+
const runtimeEnv = buildRuntimeEnv(config, nextGenerated);
|
|
1370
|
+
logInfo(`Release version: ${config.release.version}`);
|
|
1371
|
+
assertSwarmManager();
|
|
1049
1372
|
ensureRepoCheckout(config);
|
|
1050
1373
|
writeDeployArtifacts(config, nextGenerated);
|
|
1051
1374
|
buildKeycloakImage(config.images.keycloak);
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1375
|
+
if (currentOptions.dryRun) {
|
|
1376
|
+
logDryRun(`Would deploy base bootstrap stack for ${config.services.stack_name}`);
|
|
1377
|
+
logCommand("docker", ["stack", "deploy", "-c", BOOTSTRAP_BASE_STACK_FILE, config.services.stack_name]);
|
|
1378
|
+
} else {
|
|
1379
|
+
logStep("Deploying base bootstrap stack");
|
|
1380
|
+
logCommand("docker", ["stack", "deploy", "-c", BOOTSTRAP_BASE_STACK_FILE, config.services.stack_name]);
|
|
1381
|
+
run("docker", ["stack", "deploy", "-c", BOOTSTRAP_BASE_STACK_FILE, config.services.stack_name]);
|
|
1382
|
+
logSuccess("Base bootstrap stack deployed");
|
|
1383
|
+
}
|
|
1384
|
+
waitForPostgresService(config, "postgres", "postgres", "postgres", "envsync-postgres");
|
|
1385
|
+
waitForRedisService(config);
|
|
1055
1386
|
waitForTcpService(config, "rustfs", "rustfs", 9e3);
|
|
1056
|
-
|
|
1057
|
-
|
|
1387
|
+
waitForPostgresService(config, "keycloak", "keycloak_db", "keycloak", runtimeEnv.KEYCLOAK_ADMIN_PASSWORD);
|
|
1388
|
+
waitForPostgresService(config, "openfga", "openfga_db", "openfga", runtimeEnv.OPENFGA_DB_PASSWORD);
|
|
1389
|
+
waitForPostgresService(config, "minikms", "minikms_db", "postgres", runtimeEnv.MINIKMS_DB_PASSWORD);
|
|
1390
|
+
runOpenFgaMigrate(config, runtimeEnv);
|
|
1391
|
+
runMiniKmsMigrate(config, runtimeEnv);
|
|
1392
|
+
if (currentOptions.dryRun) {
|
|
1393
|
+
logDryRun(`Would deploy runtime bootstrap stack for ${config.services.stack_name}`);
|
|
1394
|
+
logCommand("docker", ["stack", "deploy", "-c", BOOTSTRAP_STACK_FILE, config.services.stack_name]);
|
|
1395
|
+
} else {
|
|
1396
|
+
logStep("Deploying runtime bootstrap stack");
|
|
1397
|
+
logCommand("docker", ["stack", "deploy", "-c", BOOTSTRAP_STACK_FILE, config.services.stack_name]);
|
|
1398
|
+
run("docker", ["stack", "deploy", "-c", BOOTSTRAP_STACK_FILE, config.services.stack_name]);
|
|
1399
|
+
logSuccess("Runtime bootstrap stack deployed");
|
|
1400
|
+
}
|
|
1401
|
+
waitForHttpService(config, "keycloak", "http://keycloak:8080/health/ready", 180);
|
|
1402
|
+
waitForHttpService(config, "openfga", "http://openfga:8090/stores");
|
|
1058
1403
|
waitForTcpService(config, "minikms", "minikms", 50051);
|
|
1059
1404
|
const initResult = runBootstrapInit(config);
|
|
1405
|
+
if (currentOptions.dryRun) {
|
|
1406
|
+
logDryRun("Skipping generated OpenFGA ID persistence in preview mode");
|
|
1407
|
+
logSuccess("Bootstrap dry-run completed");
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1060
1410
|
const bootstrappedGenerated = normalizeGeneratedState({
|
|
1061
1411
|
openfga: {
|
|
1062
1412
|
store_id: initResult.openfgaStoreId,
|
|
@@ -1068,21 +1418,46 @@ async function cmdBootstrap() {
|
|
|
1068
1418
|
}
|
|
1069
1419
|
});
|
|
1070
1420
|
writeDeployArtifacts(config, bootstrappedGenerated);
|
|
1071
|
-
|
|
1421
|
+
logSuccess("Bootstrap completed");
|
|
1072
1422
|
}
|
|
1073
1423
|
async function cmdDeploy() {
|
|
1424
|
+
logSection("Deploy");
|
|
1074
1425
|
const { config, generated } = loadState();
|
|
1426
|
+
logInfo(`Release version: ${config.release.version}`);
|
|
1427
|
+
assertSwarmManager();
|
|
1075
1428
|
assertBootstrapState(generated);
|
|
1429
|
+
if (!currentOptions.dryRun) {
|
|
1430
|
+
const services = listStackServices(config);
|
|
1431
|
+
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") {
|
|
1432
|
+
throw new Error("Bootstrap has not completed successfully. Run 'envsync-deploy bootstrap' again.");
|
|
1433
|
+
}
|
|
1434
|
+
} else {
|
|
1435
|
+
logDryRun("Skipping runtime bootstrap service validation");
|
|
1436
|
+
}
|
|
1076
1437
|
ensureRepoCheckout(config);
|
|
1077
1438
|
writeDeployArtifacts(config, generated);
|
|
1078
1439
|
buildKeycloakImage(config.images.keycloak);
|
|
1079
|
-
|
|
1080
|
-
|
|
1440
|
+
if (currentOptions.dryRun) {
|
|
1441
|
+
logDryRun(`Would ensure ${RELEASES_ROOT}/web/current exists`);
|
|
1442
|
+
logDryRun(`Would ensure ${RELEASES_ROOT}/landing/current exists`);
|
|
1443
|
+
} else {
|
|
1444
|
+
ensureDir(`${RELEASES_ROOT}/web/current`);
|
|
1445
|
+
ensureDir(`${RELEASES_ROOT}/landing/current`);
|
|
1446
|
+
}
|
|
1081
1447
|
extractStaticBundle(config.images.web, `${RELEASES_ROOT}/web/current`);
|
|
1082
1448
|
extractStaticBundle(config.images.landing, `${RELEASES_ROOT}/landing/current`);
|
|
1083
|
-
|
|
1084
|
-
|
|
1449
|
+
writeFileMaybe(`${RELEASES_ROOT}/web/current/runtime-config.js`, renderFrontendRuntimeConfig(config));
|
|
1450
|
+
writeFileMaybe(`${RELEASES_ROOT}/landing/current/runtime-config.js`, renderFrontendRuntimeConfig(config));
|
|
1451
|
+
if (currentOptions.dryRun) {
|
|
1452
|
+
logDryRun(`Would deploy full stack for ${config.services.stack_name}`);
|
|
1453
|
+
logCommand("docker", ["stack", "deploy", "-c", STACK_FILE, config.services.stack_name]);
|
|
1454
|
+
logSuccess("Deploy dry-run completed");
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
logStep("Deploying full stack");
|
|
1458
|
+
logCommand("docker", ["stack", "deploy", "-c", STACK_FILE, config.services.stack_name]);
|
|
1085
1459
|
run("docker", ["stack", "deploy", "-c", STACK_FILE, config.services.stack_name]);
|
|
1460
|
+
logSuccess("Deploy completed");
|
|
1086
1461
|
}
|
|
1087
1462
|
async function cmdHealth(asJson) {
|
|
1088
1463
|
const { config, generated } = loadState();
|
|
@@ -1123,17 +1498,28 @@ async function cmdHealth(asJson) {
|
|
|
1123
1498
|
console.log(JSON.stringify(checks, null, 2));
|
|
1124
1499
|
}
|
|
1125
1500
|
async function cmdUpgrade() {
|
|
1501
|
+
logSection("Upgrade");
|
|
1126
1502
|
const { config } = loadState();
|
|
1127
|
-
config.images
|
|
1503
|
+
config.images = {
|
|
1504
|
+
...config.images,
|
|
1505
|
+
...versionedImages(config.release.version)
|
|
1506
|
+
};
|
|
1128
1507
|
saveDesiredConfig(config);
|
|
1508
|
+
if (currentOptions.dryRun) {
|
|
1509
|
+
logDryRun(`Would upgrade stack to release ${config.release.version}`);
|
|
1510
|
+
}
|
|
1129
1511
|
await cmdDeploy();
|
|
1130
1512
|
}
|
|
1131
1513
|
async function cmdUpgradeDeps() {
|
|
1514
|
+
logSection("Upgrade Dependencies");
|
|
1132
1515
|
const { config } = loadState();
|
|
1133
1516
|
config.images.traefik = "traefik:v3.1";
|
|
1134
1517
|
config.images.clickstack = "clickhouse/clickstack-all-in-one:latest";
|
|
1135
1518
|
config.images.otel_agent = "otel/opentelemetry-collector-contrib:0.111.0";
|
|
1136
1519
|
saveDesiredConfig(config);
|
|
1520
|
+
if (currentOptions.dryRun) {
|
|
1521
|
+
logDryRun("Would refresh dependency image tags and redeploy");
|
|
1522
|
+
}
|
|
1137
1523
|
await cmdDeploy();
|
|
1138
1524
|
}
|
|
1139
1525
|
function sha256File(filePath) {
|
|
@@ -1145,6 +1531,11 @@ function stackVolumeName(config, name) {
|
|
|
1145
1531
|
return `${config.services.stack_name}_${name}`;
|
|
1146
1532
|
}
|
|
1147
1533
|
function backupDockerVolume(volumeName, targetDir) {
|
|
1534
|
+
logStep(`Backing up Docker volume ${volumeName}`);
|
|
1535
|
+
if (currentOptions.dryRun) {
|
|
1536
|
+
logDryRun(`Would back up ${volumeName} into ${targetDir}`);
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1148
1539
|
ensureDir(targetDir);
|
|
1149
1540
|
run("docker", [
|
|
1150
1541
|
"run",
|
|
@@ -1158,8 +1549,14 @@ function backupDockerVolume(volumeName, targetDir) {
|
|
|
1158
1549
|
"-lc",
|
|
1159
1550
|
"cd /from && tar -czf /to/volume.tar.gz ."
|
|
1160
1551
|
]);
|
|
1552
|
+
logSuccess(`Backed up Docker volume ${volumeName}`);
|
|
1161
1553
|
}
|
|
1162
1554
|
function restoreDockerVolume(volumeName, sourceDir) {
|
|
1555
|
+
logStep(`Restoring Docker volume ${volumeName}`);
|
|
1556
|
+
if (currentOptions.dryRun) {
|
|
1557
|
+
logDryRun(`Would restore ${volumeName} from ${sourceDir}`);
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1163
1560
|
run("docker", ["volume", "create", volumeName], { quiet: true });
|
|
1164
1561
|
run("docker", [
|
|
1165
1562
|
"run",
|
|
@@ -1173,20 +1570,35 @@ function restoreDockerVolume(volumeName, sourceDir) {
|
|
|
1173
1570
|
"-lc",
|
|
1174
1571
|
"cd /to && tar -xzf /from/volume.tar.gz"
|
|
1175
1572
|
]);
|
|
1573
|
+
logSuccess(`Restored Docker volume ${volumeName}`);
|
|
1176
1574
|
}
|
|
1177
1575
|
async function cmdBackup() {
|
|
1576
|
+
logSection("Backup");
|
|
1178
1577
|
const { config } = loadState();
|
|
1179
|
-
ensureDir(config.backup.output_dir);
|
|
1180
1578
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:]/g, "-");
|
|
1181
1579
|
const archiveBase = path.join(config.backup.output_dir, `envsync-backup-${timestamp}`);
|
|
1182
1580
|
const manifestPath = `${archiveBase}.manifest.json`;
|
|
1183
1581
|
const tarPath = `${archiveBase}.tar.gz`;
|
|
1184
1582
|
const staged = path.join(BACKUPS_ROOT, `staging-${timestamp}`);
|
|
1583
|
+
logInfo(`Backup archive target: ${tarPath}`);
|
|
1584
|
+
if (currentOptions.dryRun) {
|
|
1585
|
+
logDryRun(`Would stage backup files in ${staged}`);
|
|
1586
|
+
for (const volume of STACK_VOLUMES) {
|
|
1587
|
+
backupDockerVolume(stackVolumeName(config, volume), path.join(staged, "volumes", volume));
|
|
1588
|
+
}
|
|
1589
|
+
logDryRun(`Would write manifest ${manifestPath}`);
|
|
1590
|
+
logDryRun(`Would create archive ${tarPath}`);
|
|
1591
|
+
logSuccess("Backup dry-run completed");
|
|
1592
|
+
console.log(tarPath);
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
ensureDir(config.backup.output_dir);
|
|
1185
1596
|
ensureDir(staged);
|
|
1186
1597
|
writeFile(path.join(staged, "deploy.env"), fs.readFileSync(DEPLOY_ENV, "utf8"));
|
|
1187
1598
|
writeFile(path.join(staged, "deploy.yaml"), fs.readFileSync(DEPLOY_YAML, "utf8"));
|
|
1188
1599
|
writeFile(path.join(staged, "config.json"), fs.readFileSync(INTERNAL_CONFIG_JSON, "utf8"));
|
|
1189
1600
|
writeFile(path.join(staged, "versions.lock.json"), fs.readFileSync(VERSIONS_LOCK, "utf8"));
|
|
1601
|
+
writeFile(path.join(staged, "docker-stack.bootstrap.base.yaml"), fs.readFileSync(BOOTSTRAP_BASE_STACK_FILE, "utf8"));
|
|
1190
1602
|
writeFile(path.join(staged, "docker-stack.bootstrap.yaml"), fs.readFileSync(BOOTSTRAP_STACK_FILE, "utf8"));
|
|
1191
1603
|
writeFile(path.join(staged, "docker-stack.yaml"), fs.readFileSync(STACK_FILE, "utf8"));
|
|
1192
1604
|
writeFile(path.join(staged, "traefik-dynamic.yaml"), fs.readFileSync(TRAEFIK_DYNAMIC_FILE, "utf8"));
|
|
@@ -1205,17 +1617,28 @@ async function cmdBackup() {
|
|
|
1205
1617
|
volumes: STACK_VOLUMES.map((volume) => stackVolumeName(config, volume))
|
|
1206
1618
|
};
|
|
1207
1619
|
writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
|
|
1620
|
+
logSuccess("Backup completed");
|
|
1208
1621
|
console.log(tarPath);
|
|
1209
1622
|
}
|
|
1210
1623
|
async function cmdRestore(archivePath) {
|
|
1211
1624
|
if (!archivePath) throw new Error("restore requires a .tar.gz path");
|
|
1625
|
+
logSection("Restore");
|
|
1212
1626
|
const restoreRoot = path.join(BACKUPS_ROOT, `restore-${Date.now()}`);
|
|
1627
|
+
logInfo(`Restore archive: ${archivePath}`);
|
|
1628
|
+
if (currentOptions.dryRun) {
|
|
1629
|
+
logDryRun(`Would extract ${archivePath} into ${restoreRoot}`);
|
|
1630
|
+
logDryRun(`Would restore deploy files into ${DEPLOY_ROOT} and ${ETC_ROOT}`);
|
|
1631
|
+
logDryRun("Would restore all managed Docker volumes from the archive");
|
|
1632
|
+
logSuccess("Restore dry-run completed");
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1213
1635
|
ensureDir(restoreRoot);
|
|
1214
1636
|
run("bash", ["-lc", `tar -xzf ${JSON.stringify(archivePath)} -C ${JSON.stringify(restoreRoot)}`]);
|
|
1215
1637
|
writeFile(DEPLOY_ENV, fs.readFileSync(path.join(restoreRoot, "deploy.env"), "utf8"), 384);
|
|
1216
1638
|
writeFile(DEPLOY_YAML, fs.readFileSync(path.join(restoreRoot, "deploy.yaml"), "utf8"));
|
|
1217
1639
|
writeFile(INTERNAL_CONFIG_JSON, fs.readFileSync(path.join(restoreRoot, "config.json"), "utf8"));
|
|
1218
1640
|
writeFile(VERSIONS_LOCK, fs.readFileSync(path.join(restoreRoot, "versions.lock.json"), "utf8"));
|
|
1641
|
+
writeFile(BOOTSTRAP_BASE_STACK_FILE, fs.readFileSync(path.join(restoreRoot, "docker-stack.bootstrap.base.yaml"), "utf8"));
|
|
1219
1642
|
writeFile(BOOTSTRAP_STACK_FILE, fs.readFileSync(path.join(restoreRoot, "docker-stack.bootstrap.yaml"), "utf8"));
|
|
1220
1643
|
writeFile(STACK_FILE, fs.readFileSync(path.join(restoreRoot, "docker-stack.yaml"), "utf8"));
|
|
1221
1644
|
writeFile(TRAEFIK_DYNAMIC_FILE, fs.readFileSync(path.join(restoreRoot, "traefik-dynamic.yaml"), "utf8"));
|
|
@@ -1225,11 +1648,16 @@ async function cmdRestore(archivePath) {
|
|
|
1225
1648
|
for (const volume of STACK_VOLUMES) {
|
|
1226
1649
|
restoreDockerVolume(stackVolumeName(config, volume), path.join(restoreRoot, "volumes", volume));
|
|
1227
1650
|
}
|
|
1228
|
-
|
|
1651
|
+
logSuccess("Restore completed. Run 'envsync-deploy deploy' to start services.");
|
|
1229
1652
|
}
|
|
1230
1653
|
async function main() {
|
|
1231
|
-
const
|
|
1232
|
-
const
|
|
1654
|
+
const argv = process.argv.slice(2);
|
|
1655
|
+
const command = argv[0];
|
|
1656
|
+
const args = argv.slice(1);
|
|
1657
|
+
currentOptions = {
|
|
1658
|
+
dryRun: args.includes("--dry-run")
|
|
1659
|
+
};
|
|
1660
|
+
const positionals = args.filter((arg) => arg !== "--dry-run");
|
|
1233
1661
|
switch (command) {
|
|
1234
1662
|
case "preinstall":
|
|
1235
1663
|
await cmdPreinstall();
|
|
@@ -1244,7 +1672,7 @@ async function main() {
|
|
|
1244
1672
|
await cmdDeploy();
|
|
1245
1673
|
break;
|
|
1246
1674
|
case "health":
|
|
1247
|
-
await cmdHealth(
|
|
1675
|
+
await cmdHealth(positionals[0] === "--json");
|
|
1248
1676
|
break;
|
|
1249
1677
|
case "upgrade":
|
|
1250
1678
|
await cmdUpgrade();
|
|
@@ -1256,10 +1684,12 @@ async function main() {
|
|
|
1256
1684
|
await cmdBackup();
|
|
1257
1685
|
break;
|
|
1258
1686
|
case "restore":
|
|
1259
|
-
await cmdRestore(
|
|
1687
|
+
await cmdRestore(positionals[0] ?? "");
|
|
1260
1688
|
break;
|
|
1261
1689
|
default:
|
|
1262
|
-
console.log(
|
|
1690
|
+
console.log(
|
|
1691
|
+
"Usage: envsync-deploy <preinstall|setup|bootstrap|deploy|health|upgrade|upgrade-deps|backup|restore> [--dry-run]"
|
|
1692
|
+
);
|
|
1263
1693
|
process.exit(command ? 1 : 0);
|
|
1264
1694
|
}
|
|
1265
1695
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@envsync-cloud/deploy-cli",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4",
|
|
4
4
|
"description": "CLI for self-hosted EnvSync deployment on Docker Swarm",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -39,6 +39,9 @@
|
|
|
39
39
|
"cli",
|
|
40
40
|
"secrets"
|
|
41
41
|
],
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"chalk": "^5.6.2"
|
|
44
|
+
},
|
|
42
45
|
"devDependencies": {
|
|
43
46
|
"tsup": "^8.5.0",
|
|
44
47
|
"typescript": "^5.9.2"
|