@envsync-cloud/deploy-cli 0.6.3 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -7
- package/dist/index.js +387 -38
- 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
|
|
@@ -71,6 +71,8 @@ Bootstrap infra, migrations, RustFS, and OpenFGA:
|
|
|
71
71
|
npx @envsync-cloud/deploy-cli bootstrap
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
+
`bootstrap` is destructive. It removes the existing EnvSync stack, matching containers, network, and managed volumes before rebuilding, and requires typing `ARE YOU SURE?` to continue.
|
|
75
|
+
|
|
74
76
|
Deploy the pending API and frontend services:
|
|
75
77
|
|
|
76
78
|
```bash
|
|
@@ -79,7 +81,7 @@ npx @envsync-cloud/deploy-cli deploy
|
|
|
79
81
|
|
|
80
82
|
The staged flow is:
|
|
81
83
|
- `setup` writes desired config
|
|
82
|
-
- `bootstrap` starts base infra, runs OpenFGA and miniKMS migrations, starts runtime infra, and persists generated runtime env state
|
|
84
|
+
- `bootstrap` resets the existing EnvSync deployment, then starts base infra, runs OpenFGA and miniKMS migrations, starts runtime infra, and persists generated runtime env state
|
|
83
85
|
- `deploy` starts the pending API and frontend services
|
|
84
86
|
|
|
85
87
|
Check service health:
|
|
@@ -100,6 +102,13 @@ Restore from an existing backup archive:
|
|
|
100
102
|
npx @envsync-cloud/deploy-cli restore /path/to/envsync-backup.tar.gz
|
|
101
103
|
```
|
|
102
104
|
|
|
105
|
+
Preview mutating commands without changing the host:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
npx @envsync-cloud/deploy-cli bootstrap --dry-run
|
|
109
|
+
npx @envsync-cloud/deploy-cli deploy --dry-run
|
|
110
|
+
```
|
|
111
|
+
|
|
103
112
|
## Links
|
|
104
113
|
|
|
105
114
|
- 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";
|
|
@@ -47,6 +48,36 @@ var REQUIRED_BOOTSTRAP_ENV_KEYS = [
|
|
|
47
48
|
"OPENFGA_MODEL_ID"
|
|
48
49
|
];
|
|
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 logWarn(message) {
|
|
73
|
+
console.log(`${chalk.yellow("[warn]")} ${message}`);
|
|
74
|
+
}
|
|
75
|
+
function logDryRun(message) {
|
|
76
|
+
console.log(`${chalk.magenta("[dry-run]")} ${message}`);
|
|
77
|
+
}
|
|
78
|
+
function logCommand(cmd, args) {
|
|
79
|
+
console.log(chalk.dim(`$ ${formatCommand(cmd, args)}`));
|
|
80
|
+
}
|
|
50
81
|
function run(cmd, args, opts = {}) {
|
|
51
82
|
const result = spawnSync(cmd, args, {
|
|
52
83
|
cwd: opts.cwd,
|
|
@@ -85,6 +116,13 @@ function writeFile(target, content, mode) {
|
|
|
85
116
|
fs.writeFileSync(target, content, "utf8");
|
|
86
117
|
if (mode != null) fs.chmodSync(target, mode);
|
|
87
118
|
}
|
|
119
|
+
function writeFileMaybe(target, content, mode) {
|
|
120
|
+
if (currentOptions.dryRun) {
|
|
121
|
+
logDryRun(`Would write ${target}`);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
writeFile(target, content, mode);
|
|
125
|
+
}
|
|
88
126
|
function exists(target) {
|
|
89
127
|
return fs.existsSync(target);
|
|
90
128
|
}
|
|
@@ -181,6 +219,18 @@ async function ask(question, fallback = "") {
|
|
|
181
219
|
});
|
|
182
220
|
});
|
|
183
221
|
}
|
|
222
|
+
async function askRequired(question) {
|
|
223
|
+
if (!process.stdin.isTTY) {
|
|
224
|
+
throw new Error(`${question} confirmation requires an interactive terminal.`);
|
|
225
|
+
}
|
|
226
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
227
|
+
return await new Promise((resolve) => {
|
|
228
|
+
rl.question(`${question} `, (answer) => {
|
|
229
|
+
rl.close();
|
|
230
|
+
resolve(answer.trim());
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
}
|
|
184
234
|
function sleepSeconds(seconds) {
|
|
185
235
|
spawnSync("sleep", [`${seconds}`], { stdio: "ignore" });
|
|
186
236
|
}
|
|
@@ -329,6 +379,7 @@ function emptyGeneratedState() {
|
|
|
329
379
|
},
|
|
330
380
|
secrets: {
|
|
331
381
|
s3_secret_key: "",
|
|
382
|
+
keycloak_db_password: "",
|
|
332
383
|
keycloak_web_client_secret: "",
|
|
333
384
|
keycloak_api_client_secret: "",
|
|
334
385
|
openfga_db_password: "",
|
|
@@ -349,6 +400,7 @@ function normalizeGeneratedState(raw) {
|
|
|
349
400
|
},
|
|
350
401
|
secrets: {
|
|
351
402
|
s3_secret_key: raw?.secrets?.s3_secret_key ?? defaults.secrets.s3_secret_key,
|
|
403
|
+
keycloak_db_password: raw?.secrets?.keycloak_db_password ?? defaults.secrets.keycloak_db_password,
|
|
352
404
|
keycloak_web_client_secret: raw?.secrets?.keycloak_web_client_secret ?? defaults.secrets.keycloak_web_client_secret,
|
|
353
405
|
keycloak_api_client_secret: raw?.secrets?.keycloak_api_client_secret ?? defaults.secrets.keycloak_api_client_secret,
|
|
354
406
|
openfga_db_password: raw?.secrets?.openfga_db_password ?? defaults.secrets.openfga_db_password,
|
|
@@ -391,6 +443,7 @@ function mergeGeneratedState(env, generated) {
|
|
|
391
443
|
},
|
|
392
444
|
secrets: {
|
|
393
445
|
s3_secret_key: env.S3_SECRET_KEY ?? normalized.secrets.s3_secret_key,
|
|
446
|
+
keycloak_db_password: env.KEYCLOAK_DB_PASSWORD ?? normalized.secrets.keycloak_db_password,
|
|
394
447
|
keycloak_web_client_secret: env.KEYCLOAK_WEB_CLIENT_SECRET ?? normalized.secrets.keycloak_web_client_secret,
|
|
395
448
|
keycloak_api_client_secret: env.KEYCLOAK_API_CLIENT_SECRET ?? normalized.secrets.keycloak_api_client_secret,
|
|
396
449
|
openfga_db_password: env.OPENFGA_DB_PASSWORD ?? normalized.secrets.openfga_db_password,
|
|
@@ -411,6 +464,7 @@ function ensureGeneratedRuntimeState(generated) {
|
|
|
411
464
|
openfga: generated.openfga,
|
|
412
465
|
secrets: {
|
|
413
466
|
s3_secret_key: generated.secrets.s3_secret_key || randomSecret(16),
|
|
467
|
+
keycloak_db_password: generated.secrets.keycloak_db_password || "",
|
|
414
468
|
keycloak_web_client_secret: generated.secrets.keycloak_web_client_secret || randomSecret(),
|
|
415
469
|
keycloak_api_client_secret: generated.secrets.keycloak_api_client_secret || randomSecret(),
|
|
416
470
|
openfga_db_password: generated.secrets.openfga_db_password || randomSecret(),
|
|
@@ -453,6 +507,7 @@ function buildRuntimeEnv(config, generated) {
|
|
|
453
507
|
KEYCLOAK_REALM: config.auth.keycloak_realm,
|
|
454
508
|
KEYCLOAK_ADMIN_USER: config.auth.admin_user,
|
|
455
509
|
KEYCLOAK_ADMIN_PASSWORD: config.auth.admin_password,
|
|
510
|
+
KEYCLOAK_DB_PASSWORD: generated.secrets.keycloak_db_password || config.auth.admin_password,
|
|
456
511
|
KEYCLOAK_WEB_CLIENT_ID: config.auth.web_client_id,
|
|
457
512
|
KEYCLOAK_WEB_CLIENT_SECRET: generated.secrets.keycloak_web_client_secret,
|
|
458
513
|
KEYCLOAK_CLI_CLIENT_ID: config.auth.cli_client_id,
|
|
@@ -746,7 +801,7 @@ ${renderEnvList({
|
|
|
746
801
|
environment:
|
|
747
802
|
${renderEnvList({
|
|
748
803
|
POSTGRES_USER: "keycloak",
|
|
749
|
-
POSTGRES_PASSWORD: runtimeEnv.
|
|
804
|
+
POSTGRES_PASSWORD: runtimeEnv.KEYCLOAK_DB_PASSWORD,
|
|
750
805
|
POSTGRES_DB: "keycloak"
|
|
751
806
|
})}
|
|
752
807
|
volumes:
|
|
@@ -763,12 +818,13 @@ ${renderEnvList({
|
|
|
763
818
|
KC_DB: "postgres",
|
|
764
819
|
KC_DB_URL: "jdbc:postgresql://keycloak_db:5432/keycloak",
|
|
765
820
|
KC_DB_USERNAME: "keycloak",
|
|
766
|
-
KC_DB_PASSWORD: runtimeEnv.
|
|
821
|
+
KC_DB_PASSWORD: runtimeEnv.KEYCLOAK_DB_PASSWORD,
|
|
767
822
|
KC_BOOTSTRAP_ADMIN_USERNAME: config.auth.admin_user,
|
|
768
823
|
KC_BOOTSTRAP_ADMIN_PASSWORD: config.auth.admin_password,
|
|
769
824
|
KC_HTTP_ENABLED: "true",
|
|
825
|
+
KC_HEALTH_ENABLED: "true",
|
|
770
826
|
KC_PROXY_HEADERS: "xforwarded",
|
|
771
|
-
|
|
827
|
+
KC_HOSTNAME_STRICT: "false"
|
|
772
828
|
})}
|
|
773
829
|
volumes:
|
|
774
830
|
- ${KEYCLOAK_REALM_FILE}:/opt/keycloak/data/import/realm.json:ro
|
|
@@ -897,40 +953,59 @@ volumes:
|
|
|
897
953
|
}
|
|
898
954
|
function writeDeployArtifacts(config, generated) {
|
|
899
955
|
const runtimeEnv = buildRuntimeEnv(config, generated);
|
|
900
|
-
|
|
901
|
-
|
|
956
|
+
logStep("Rendering deploy artifacts");
|
|
957
|
+
writeFileMaybe(DEPLOY_ENV, renderEnvFile(runtimeEnv), 384);
|
|
958
|
+
writeFileMaybe(
|
|
902
959
|
INTERNAL_CONFIG_JSON,
|
|
903
960
|
JSON.stringify({ config, generated: mergeGeneratedState(runtimeEnv, generated) }, null, 2) + "\n"
|
|
904
961
|
);
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
962
|
+
writeFileMaybe(VERSIONS_LOCK, JSON.stringify(config.images, null, 2) + "\n");
|
|
963
|
+
writeFileMaybe(KEYCLOAK_REALM_FILE, renderKeycloakRealm(config, runtimeEnv));
|
|
964
|
+
writeFileMaybe(TRAEFIK_DYNAMIC_FILE, renderTraefikDynamicConfig(config));
|
|
965
|
+
writeFileMaybe(BOOTSTRAP_BASE_STACK_FILE, renderStack(config, runtimeEnv, "base"));
|
|
966
|
+
writeFileMaybe(BOOTSTRAP_STACK_FILE, renderStack(config, runtimeEnv, "bootstrap"));
|
|
967
|
+
writeFileMaybe(STACK_FILE, renderStack(config, runtimeEnv, "full"));
|
|
968
|
+
writeFileMaybe(NGINX_WEB_CONF, renderNginxConf("web"));
|
|
969
|
+
writeFileMaybe(NGINX_LANDING_CONF, renderNginxConf("landing"));
|
|
970
|
+
writeFileMaybe(OTEL_AGENT_CONF, renderOtelAgentConfig(config));
|
|
971
|
+
logSuccess(currentOptions.dryRun ? "Deploy artifacts previewed" : "Deploy artifacts written");
|
|
914
972
|
}
|
|
915
973
|
function saveDesiredConfig(config) {
|
|
916
974
|
const internal = readInternalState();
|
|
917
975
|
const generated = mergeGeneratedState(loadGeneratedEnv(), internal?.generated);
|
|
918
|
-
|
|
919
|
-
|
|
976
|
+
logStep(`Saving desired config to ${DEPLOY_YAML}`);
|
|
977
|
+
writeFileMaybe(DEPLOY_YAML, toYaml(config) + "\n");
|
|
978
|
+
writeFileMaybe(
|
|
920
979
|
INTERNAL_CONFIG_JSON,
|
|
921
980
|
JSON.stringify({ config, generated }, null, 2) + "\n"
|
|
922
981
|
);
|
|
982
|
+
logSuccess(currentOptions.dryRun ? "Desired config previewed" : "Desired config saved");
|
|
923
983
|
}
|
|
924
984
|
function ensureRepoCheckout(config) {
|
|
985
|
+
logStep(`Ensuring pinned repo checkout at ${config.source.ref}`);
|
|
986
|
+
if (currentOptions.dryRun) {
|
|
987
|
+
logDryRun(`Would ensure repo checkout at ${REPO_ROOT}`);
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
925
990
|
ensureDir(REPO_ROOT);
|
|
926
991
|
if (!exists(path.join(REPO_ROOT, ".git"))) {
|
|
992
|
+
logCommand("git", ["clone", config.source.repo_url, REPO_ROOT]);
|
|
927
993
|
run("git", ["clone", config.source.repo_url, REPO_ROOT]);
|
|
928
994
|
}
|
|
995
|
+
logCommand("git", ["remote", "set-url", "origin", config.source.repo_url]);
|
|
929
996
|
run("git", ["remote", "set-url", "origin", config.source.repo_url], { cwd: REPO_ROOT });
|
|
997
|
+
logCommand("git", ["fetch", "--tags", "--force", "origin"]);
|
|
930
998
|
run("git", ["fetch", "--tags", "--force", "origin"], { cwd: REPO_ROOT });
|
|
999
|
+
logCommand("git", ["checkout", "--force", config.source.ref]);
|
|
931
1000
|
run("git", ["checkout", "--force", config.source.ref], { cwd: REPO_ROOT });
|
|
1001
|
+
logSuccess(`Pinned repo checkout ready at ${config.source.ref}`);
|
|
932
1002
|
}
|
|
933
1003
|
function extractStaticBundle(image, targetDir) {
|
|
1004
|
+
logStep(`Extracting static bundle from ${image}`);
|
|
1005
|
+
if (currentOptions.dryRun) {
|
|
1006
|
+
logDryRun(`Would extract ${image} into ${targetDir}`);
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
934
1009
|
ensureDir(targetDir);
|
|
935
1010
|
const containerId = run("docker", ["create", image], { quiet: true }).trim();
|
|
936
1011
|
try {
|
|
@@ -938,24 +1013,43 @@ function extractStaticBundle(image, targetDir) {
|
|
|
938
1013
|
} finally {
|
|
939
1014
|
run("docker", ["rm", "-f", containerId], { quiet: true });
|
|
940
1015
|
}
|
|
1016
|
+
logSuccess(`Static bundle extracted to ${targetDir}`);
|
|
941
1017
|
}
|
|
942
1018
|
function buildKeycloakImage(imageTag, repoRoot = REPO_ROOT) {
|
|
943
1019
|
const buildContext = path.join(repoRoot, "packages/envsync-keycloak-theme");
|
|
944
1020
|
if (!exists(path.join(buildContext, "Dockerfile"))) {
|
|
945
1021
|
throw new Error(`Missing Keycloak Docker build context at ${buildContext}`);
|
|
946
1022
|
}
|
|
1023
|
+
logStep(`Building Keycloak image ${imageTag}`);
|
|
1024
|
+
if (currentOptions.dryRun) {
|
|
1025
|
+
logDryRun(`Would build ${imageTag} from ${buildContext}`);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
logCommand("docker", ["build", "-t", imageTag, buildContext]);
|
|
947
1029
|
run("docker", ["build", "-t", imageTag, buildContext]);
|
|
1030
|
+
logSuccess(`Built Keycloak image ${imageTag}`);
|
|
948
1031
|
}
|
|
949
1032
|
function stackNetworkName(config) {
|
|
950
1033
|
return `${config.services.stack_name}_envsync`;
|
|
951
1034
|
}
|
|
952
1035
|
function assertSwarmManager() {
|
|
1036
|
+
if (currentOptions.dryRun) {
|
|
1037
|
+
logDryRun("Skipping Docker Swarm manager validation");
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
logStep("Validating Docker Swarm manager state");
|
|
953
1041
|
const state = tryRun("docker", ["info", "--format", "{{.Swarm.LocalNodeState}}|{{.Swarm.ControlAvailable}}"], { quiet: true }).trim();
|
|
954
1042
|
if (state !== "active|true") {
|
|
955
1043
|
throw new Error("Docker Swarm is not initialized on this node. Run 'docker swarm init' or 'envsync-deploy preinstall' first.");
|
|
956
1044
|
}
|
|
1045
|
+
logSuccess("Docker Swarm manager is ready");
|
|
957
1046
|
}
|
|
958
1047
|
function waitForCommand(config, label, image, command, timeoutSeconds = 120, env = {}, volumes = []) {
|
|
1048
|
+
if (currentOptions.dryRun) {
|
|
1049
|
+
logDryRun(`Would wait for ${label}`);
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
logStep(`Waiting for ${label}`);
|
|
959
1053
|
const deadline = Date.now() + timeoutSeconds * 1e3;
|
|
960
1054
|
while (Date.now() < deadline) {
|
|
961
1055
|
const args = ["run", "--rm", "--network", stackNetworkName(config)];
|
|
@@ -967,6 +1061,7 @@ function waitForCommand(config, label, image, command, timeoutSeconds = 120, env
|
|
|
967
1061
|
}
|
|
968
1062
|
args.push(image, "sh", "-lc", command);
|
|
969
1063
|
if (commandSucceeds("docker", args)) {
|
|
1064
|
+
logSuccess(`${label} is ready`);
|
|
970
1065
|
return;
|
|
971
1066
|
}
|
|
972
1067
|
sleepSeconds(2);
|
|
@@ -998,6 +1093,23 @@ function waitForHttpService(config, label, url, timeoutSeconds = 120) {
|
|
|
998
1093
|
waitForCommand(config, `${label} HTTP readiness`, "alpine:3.20", `wget -q -O /dev/null ${JSON.stringify(url)}`, timeoutSeconds);
|
|
999
1094
|
}
|
|
1000
1095
|
function runOpenFgaMigrate(config, runtimeEnv) {
|
|
1096
|
+
logStep("Running OpenFGA datastore migrations");
|
|
1097
|
+
if (currentOptions.dryRun) {
|
|
1098
|
+
logDryRun("Would run OpenFGA datastore migrations");
|
|
1099
|
+
logCommand("docker", [
|
|
1100
|
+
"run",
|
|
1101
|
+
"--rm",
|
|
1102
|
+
"--network",
|
|
1103
|
+
stackNetworkName(config),
|
|
1104
|
+
"-e",
|
|
1105
|
+
"OPENFGA_DATASTORE_ENGINE=postgres",
|
|
1106
|
+
"-e",
|
|
1107
|
+
`OPENFGA_DATASTORE_URI=postgres://openfga:${runtimeEnv.OPENFGA_DB_PASSWORD}@openfga_db:5432/openfga?sslmode=disable`,
|
|
1108
|
+
"openfga/openfga:v1.12.0",
|
|
1109
|
+
"migrate"
|
|
1110
|
+
]);
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1001
1113
|
run("docker", [
|
|
1002
1114
|
"run",
|
|
1003
1115
|
"--rm",
|
|
@@ -1010,8 +1122,28 @@ function runOpenFgaMigrate(config, runtimeEnv) {
|
|
|
1010
1122
|
"openfga/openfga:v1.12.0",
|
|
1011
1123
|
"migrate"
|
|
1012
1124
|
]);
|
|
1125
|
+
logSuccess("OpenFGA datastore migrations completed");
|
|
1013
1126
|
}
|
|
1014
1127
|
function runMiniKmsMigrate(config, runtimeEnv) {
|
|
1128
|
+
logStep("Running miniKMS datastore migrations");
|
|
1129
|
+
if (currentOptions.dryRun) {
|
|
1130
|
+
logDryRun("Would run miniKMS datastore migrations");
|
|
1131
|
+
logCommand("docker", [
|
|
1132
|
+
"run",
|
|
1133
|
+
"--rm",
|
|
1134
|
+
"--network",
|
|
1135
|
+
stackNetworkName(config),
|
|
1136
|
+
"-e",
|
|
1137
|
+
`PGPASSWORD=${runtimeEnv.MINIKMS_DB_PASSWORD}`,
|
|
1138
|
+
"-v",
|
|
1139
|
+
`${path.join(REPO_ROOT, "docker/minikms/migrations")}:/migrations:ro`,
|
|
1140
|
+
"postgres:17",
|
|
1141
|
+
"sh",
|
|
1142
|
+
"-lc",
|
|
1143
|
+
"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"
|
|
1144
|
+
]);
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1015
1147
|
run("docker", [
|
|
1016
1148
|
"run",
|
|
1017
1149
|
"--rm",
|
|
@@ -1026,8 +1158,35 @@ function runMiniKmsMigrate(config, runtimeEnv) {
|
|
|
1026
1158
|
"-lc",
|
|
1027
1159
|
"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
1160
|
]);
|
|
1161
|
+
logSuccess("miniKMS datastore migrations completed");
|
|
1029
1162
|
}
|
|
1030
1163
|
function runBootstrapInit(config) {
|
|
1164
|
+
logStep("Running API bootstrap init");
|
|
1165
|
+
if (currentOptions.dryRun) {
|
|
1166
|
+
logDryRun("Would run API bootstrap init and persist generated OpenFGA IDs");
|
|
1167
|
+
logCommand("docker", [
|
|
1168
|
+
"run",
|
|
1169
|
+
"--rm",
|
|
1170
|
+
"--network",
|
|
1171
|
+
stackNetworkName(config),
|
|
1172
|
+
"--env-file",
|
|
1173
|
+
DEPLOY_ENV,
|
|
1174
|
+
"-e",
|
|
1175
|
+
"SKIP_ROOT_ENV=1",
|
|
1176
|
+
"-e",
|
|
1177
|
+
"SKIP_ROOT_ENV_WRITE=1",
|
|
1178
|
+
config.images.api,
|
|
1179
|
+
"bun",
|
|
1180
|
+
"run",
|
|
1181
|
+
"scripts/prod-init.ts",
|
|
1182
|
+
"--json",
|
|
1183
|
+
"--no-write-root-env"
|
|
1184
|
+
]);
|
|
1185
|
+
return {
|
|
1186
|
+
openfgaStoreId: "",
|
|
1187
|
+
openfgaModelId: ""
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1031
1190
|
const output = run(
|
|
1032
1191
|
"docker",
|
|
1033
1192
|
[
|
|
@@ -1054,6 +1213,7 @@ function runBootstrapInit(config) {
|
|
|
1054
1213
|
if (!result.openfgaStoreId || !result.openfgaModelId) {
|
|
1055
1214
|
throw new Error("Bootstrap init did not return OpenFGA IDs");
|
|
1056
1215
|
}
|
|
1216
|
+
logSuccess("API bootstrap init completed");
|
|
1057
1217
|
return {
|
|
1058
1218
|
openfgaStoreId: result.openfgaStoreId,
|
|
1059
1219
|
openfgaModelId: result.openfgaModelId
|
|
@@ -1111,6 +1271,95 @@ function listStackServices(config) {
|
|
|
1111
1271
|
}
|
|
1112
1272
|
return services;
|
|
1113
1273
|
}
|
|
1274
|
+
function listManagedContainers(config) {
|
|
1275
|
+
const output = tryRun("docker", ["ps", "-aq", "--filter", `name=^/${config.services.stack_name}_`], { quiet: true });
|
|
1276
|
+
return output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
1277
|
+
}
|
|
1278
|
+
function stackExists(config) {
|
|
1279
|
+
const output = tryRun("docker", ["stack", "ls", "--format", "{{.Name}}"], { quiet: true });
|
|
1280
|
+
return output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).includes(config.services.stack_name);
|
|
1281
|
+
}
|
|
1282
|
+
function waitForStackRemoval(config, timeoutSeconds = 60) {
|
|
1283
|
+
if (currentOptions.dryRun) {
|
|
1284
|
+
logDryRun(`Would wait for stack ${config.services.stack_name} to be removed`);
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
const deadline = Date.now() + timeoutSeconds * 1e3;
|
|
1288
|
+
while (Date.now() < deadline) {
|
|
1289
|
+
if (!stackExists(config)) {
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
sleepSeconds(2);
|
|
1293
|
+
}
|
|
1294
|
+
throw new Error(`Timed out waiting for stack ${config.services.stack_name} to be removed`);
|
|
1295
|
+
}
|
|
1296
|
+
async function confirmBootstrapReset(config) {
|
|
1297
|
+
const volumeNames = STACK_VOLUMES.map((volume) => stackVolumeName(config, volume));
|
|
1298
|
+
const containerIds = listManagedContainers(config);
|
|
1299
|
+
const networkName = stackNetworkName(config);
|
|
1300
|
+
logWarn("Bootstrap will delete existing EnvSync Docker resources before rebuilding infra.");
|
|
1301
|
+
logWarn(`Stack: ${config.services.stack_name}`);
|
|
1302
|
+
logWarn(`Network: ${networkName}`);
|
|
1303
|
+
logWarn(`Volumes: ${volumeNames.join(", ")}`);
|
|
1304
|
+
if (containerIds.length > 0) {
|
|
1305
|
+
logWarn(`Containers: ${containerIds.join(", ")}`);
|
|
1306
|
+
} else {
|
|
1307
|
+
logWarn("Containers: none currently matched");
|
|
1308
|
+
}
|
|
1309
|
+
logWarn("This removes existing deployment data for the managed EnvSync services.");
|
|
1310
|
+
const response = await askRequired(chalk.bold.red('Type "ARE YOU SURE?" to continue:'));
|
|
1311
|
+
if (response !== "ARE YOU SURE?") {
|
|
1312
|
+
throw new Error("Bootstrap aborted. Confirmation did not match 'ARE YOU SURE?'.");
|
|
1313
|
+
}
|
|
1314
|
+
logSuccess("Destructive bootstrap reset confirmed");
|
|
1315
|
+
}
|
|
1316
|
+
function cleanupBootstrapState(config) {
|
|
1317
|
+
const volumeNames = STACK_VOLUMES.map((volume) => stackVolumeName(config, volume));
|
|
1318
|
+
const containerIds = listManagedContainers(config);
|
|
1319
|
+
const networkName = stackNetworkName(config);
|
|
1320
|
+
logStep("Removing existing EnvSync deployment resources");
|
|
1321
|
+
if (currentOptions.dryRun) {
|
|
1322
|
+
if (stackExists(config)) {
|
|
1323
|
+
logDryRun(`Would remove stack ${config.services.stack_name}`);
|
|
1324
|
+
logCommand("docker", ["stack", "rm", config.services.stack_name]);
|
|
1325
|
+
} else {
|
|
1326
|
+
logDryRun(`No existing stack named ${config.services.stack_name} found`);
|
|
1327
|
+
}
|
|
1328
|
+
if (containerIds.length > 0) {
|
|
1329
|
+
logDryRun(`Would remove containers: ${containerIds.join(", ")}`);
|
|
1330
|
+
logCommand("docker", ["rm", "-f", ...containerIds]);
|
|
1331
|
+
}
|
|
1332
|
+
logDryRun(`Would remove network ${networkName} if present`);
|
|
1333
|
+
logCommand("docker", ["network", "rm", networkName]);
|
|
1334
|
+
for (const volumeName of volumeNames) {
|
|
1335
|
+
logDryRun(`Would remove volume ${volumeName}`);
|
|
1336
|
+
logCommand("docker", ["volume", "rm", "-f", volumeName]);
|
|
1337
|
+
}
|
|
1338
|
+
logSuccess("Bootstrap cleanup preview completed");
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
if (stackExists(config)) {
|
|
1342
|
+
logCommand("docker", ["stack", "rm", config.services.stack_name]);
|
|
1343
|
+
run("docker", ["stack", "rm", config.services.stack_name]);
|
|
1344
|
+
waitForStackRemoval(config);
|
|
1345
|
+
}
|
|
1346
|
+
const refreshedContainers = listManagedContainers(config);
|
|
1347
|
+
if (refreshedContainers.length > 0) {
|
|
1348
|
+
logCommand("docker", ["rm", "-f", ...refreshedContainers]);
|
|
1349
|
+
run("docker", ["rm", "-f", ...refreshedContainers]);
|
|
1350
|
+
}
|
|
1351
|
+
if (commandSucceeds("docker", ["network", "inspect", networkName])) {
|
|
1352
|
+
logCommand("docker", ["network", "rm", networkName]);
|
|
1353
|
+
run("docker", ["network", "rm", networkName]);
|
|
1354
|
+
}
|
|
1355
|
+
for (const volumeName of volumeNames) {
|
|
1356
|
+
if (commandSucceeds("docker", ["volume", "inspect", volumeName])) {
|
|
1357
|
+
logCommand("docker", ["volume", "rm", "-f", volumeName]);
|
|
1358
|
+
run("docker", ["volume", "rm", "-f", volumeName]);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
logSuccess("Existing EnvSync deployment resources removed");
|
|
1362
|
+
}
|
|
1114
1363
|
function serviceHealth(services, name) {
|
|
1115
1364
|
return services.get(`${name}`) ?? "missing";
|
|
1116
1365
|
}
|
|
@@ -1141,6 +1390,7 @@ async function cmdPreinstall() {
|
|
|
1141
1390
|
run("bash", ["-lc", "curl -fsSL https://acme-v02.api.letsencrypt.org/directory >/dev/null"]);
|
|
1142
1391
|
}
|
|
1143
1392
|
async function cmdSetup() {
|
|
1393
|
+
logSection("Setup");
|
|
1144
1394
|
const rootDomain = await ask("Root domain", "example.com");
|
|
1145
1395
|
const acmeEmail = await ask("ACME email", `admin@${rootDomain}`);
|
|
1146
1396
|
const releaseVersion = await ask("Release version", getDeployCliVersion());
|
|
@@ -1216,33 +1466,62 @@ async function cmdSetup() {
|
|
|
1216
1466
|
}
|
|
1217
1467
|
};
|
|
1218
1468
|
saveDesiredConfig(config);
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1469
|
+
logSuccess(`Config written to ${DEPLOY_YAML}`);
|
|
1470
|
+
logInfo(`Pinned source checkout: ${config.source.repo_url} @ ${config.source.ref}`);
|
|
1471
|
+
logInfo("Create these DNS records:");
|
|
1222
1472
|
console.log(JSON.stringify(domainMap(rootDomain), null, 2));
|
|
1223
1473
|
}
|
|
1224
1474
|
async function cmdBootstrap() {
|
|
1475
|
+
logSection("Bootstrap");
|
|
1225
1476
|
const { config, generated } = loadState();
|
|
1226
1477
|
const nextGenerated = ensureGeneratedRuntimeState(generated);
|
|
1227
1478
|
const runtimeEnv = buildRuntimeEnv(config, nextGenerated);
|
|
1479
|
+
logInfo(`Release version: ${config.release.version}`);
|
|
1228
1480
|
assertSwarmManager();
|
|
1481
|
+
if (currentOptions.dryRun) {
|
|
1482
|
+
logWarn("Dry-run mode: bootstrap reset will be previewed but not executed.");
|
|
1483
|
+
} else {
|
|
1484
|
+
await confirmBootstrapReset(config);
|
|
1485
|
+
}
|
|
1486
|
+
cleanupBootstrapState(config);
|
|
1229
1487
|
ensureRepoCheckout(config);
|
|
1230
1488
|
writeDeployArtifacts(config, nextGenerated);
|
|
1231
1489
|
buildKeycloakImage(config.images.keycloak);
|
|
1232
|
-
|
|
1490
|
+
if (currentOptions.dryRun) {
|
|
1491
|
+
logDryRun(`Would deploy base bootstrap stack for ${config.services.stack_name}`);
|
|
1492
|
+
logCommand("docker", ["stack", "deploy", "-c", BOOTSTRAP_BASE_STACK_FILE, config.services.stack_name]);
|
|
1493
|
+
} else {
|
|
1494
|
+
logStep("Deploying base bootstrap stack");
|
|
1495
|
+
logCommand("docker", ["stack", "deploy", "-c", BOOTSTRAP_BASE_STACK_FILE, config.services.stack_name]);
|
|
1496
|
+
run("docker", ["stack", "deploy", "-c", BOOTSTRAP_BASE_STACK_FILE, config.services.stack_name]);
|
|
1497
|
+
logSuccess("Base bootstrap stack deployed");
|
|
1498
|
+
}
|
|
1233
1499
|
waitForPostgresService(config, "postgres", "postgres", "postgres", "envsync-postgres");
|
|
1234
1500
|
waitForRedisService(config);
|
|
1235
1501
|
waitForTcpService(config, "rustfs", "rustfs", 9e3);
|
|
1236
|
-
waitForPostgresService(config, "keycloak", "keycloak_db", "keycloak", runtimeEnv.
|
|
1502
|
+
waitForPostgresService(config, "keycloak", "keycloak_db", "keycloak", runtimeEnv.KEYCLOAK_DB_PASSWORD);
|
|
1237
1503
|
waitForPostgresService(config, "openfga", "openfga_db", "openfga", runtimeEnv.OPENFGA_DB_PASSWORD);
|
|
1238
1504
|
waitForPostgresService(config, "minikms", "minikms_db", "postgres", runtimeEnv.MINIKMS_DB_PASSWORD);
|
|
1239
1505
|
runOpenFgaMigrate(config, runtimeEnv);
|
|
1240
1506
|
runMiniKmsMigrate(config, runtimeEnv);
|
|
1241
|
-
|
|
1242
|
-
|
|
1507
|
+
if (currentOptions.dryRun) {
|
|
1508
|
+
logDryRun(`Would deploy runtime bootstrap stack for ${config.services.stack_name}`);
|
|
1509
|
+
logCommand("docker", ["stack", "deploy", "-c", BOOTSTRAP_STACK_FILE, config.services.stack_name]);
|
|
1510
|
+
} else {
|
|
1511
|
+
logStep("Deploying runtime bootstrap stack");
|
|
1512
|
+
logCommand("docker", ["stack", "deploy", "-c", BOOTSTRAP_STACK_FILE, config.services.stack_name]);
|
|
1513
|
+
run("docker", ["stack", "deploy", "-c", BOOTSTRAP_STACK_FILE, config.services.stack_name]);
|
|
1514
|
+
logSuccess("Runtime bootstrap stack deployed");
|
|
1515
|
+
}
|
|
1516
|
+
waitForHttpService(config, "keycloak", "http://keycloak:8080/health/ready", 180);
|
|
1243
1517
|
waitForHttpService(config, "openfga", "http://openfga:8090/stores");
|
|
1244
1518
|
waitForTcpService(config, "minikms", "minikms", 50051);
|
|
1245
1519
|
const initResult = runBootstrapInit(config);
|
|
1520
|
+
if (currentOptions.dryRun) {
|
|
1521
|
+
logDryRun("Skipping generated OpenFGA ID persistence in preview mode");
|
|
1522
|
+
logSuccess("Bootstrap dry-run completed");
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1246
1525
|
const bootstrappedGenerated = normalizeGeneratedState({
|
|
1247
1526
|
openfga: {
|
|
1248
1527
|
store_id: initResult.openfgaStoreId,
|
|
@@ -1254,26 +1533,46 @@ async function cmdBootstrap() {
|
|
|
1254
1533
|
}
|
|
1255
1534
|
});
|
|
1256
1535
|
writeDeployArtifacts(config, bootstrappedGenerated);
|
|
1257
|
-
|
|
1536
|
+
logSuccess("Bootstrap completed");
|
|
1258
1537
|
}
|
|
1259
1538
|
async function cmdDeploy() {
|
|
1539
|
+
logSection("Deploy");
|
|
1260
1540
|
const { config, generated } = loadState();
|
|
1541
|
+
logInfo(`Release version: ${config.release.version}`);
|
|
1261
1542
|
assertSwarmManager();
|
|
1262
1543
|
assertBootstrapState(generated);
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1544
|
+
if (!currentOptions.dryRun) {
|
|
1545
|
+
const services = listStackServices(config);
|
|
1546
|
+
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") {
|
|
1547
|
+
throw new Error("Bootstrap has not completed successfully. Run 'envsync-deploy bootstrap' again.");
|
|
1548
|
+
}
|
|
1549
|
+
} else {
|
|
1550
|
+
logDryRun("Skipping runtime bootstrap service validation");
|
|
1266
1551
|
}
|
|
1267
1552
|
ensureRepoCheckout(config);
|
|
1268
1553
|
writeDeployArtifacts(config, generated);
|
|
1269
1554
|
buildKeycloakImage(config.images.keycloak);
|
|
1270
|
-
|
|
1271
|
-
|
|
1555
|
+
if (currentOptions.dryRun) {
|
|
1556
|
+
logDryRun(`Would ensure ${RELEASES_ROOT}/web/current exists`);
|
|
1557
|
+
logDryRun(`Would ensure ${RELEASES_ROOT}/landing/current exists`);
|
|
1558
|
+
} else {
|
|
1559
|
+
ensureDir(`${RELEASES_ROOT}/web/current`);
|
|
1560
|
+
ensureDir(`${RELEASES_ROOT}/landing/current`);
|
|
1561
|
+
}
|
|
1272
1562
|
extractStaticBundle(config.images.web, `${RELEASES_ROOT}/web/current`);
|
|
1273
1563
|
extractStaticBundle(config.images.landing, `${RELEASES_ROOT}/landing/current`);
|
|
1274
|
-
|
|
1275
|
-
|
|
1564
|
+
writeFileMaybe(`${RELEASES_ROOT}/web/current/runtime-config.js`, renderFrontendRuntimeConfig(config));
|
|
1565
|
+
writeFileMaybe(`${RELEASES_ROOT}/landing/current/runtime-config.js`, renderFrontendRuntimeConfig(config));
|
|
1566
|
+
if (currentOptions.dryRun) {
|
|
1567
|
+
logDryRun(`Would deploy full stack for ${config.services.stack_name}`);
|
|
1568
|
+
logCommand("docker", ["stack", "deploy", "-c", STACK_FILE, config.services.stack_name]);
|
|
1569
|
+
logSuccess("Deploy dry-run completed");
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
logStep("Deploying full stack");
|
|
1573
|
+
logCommand("docker", ["stack", "deploy", "-c", STACK_FILE, config.services.stack_name]);
|
|
1276
1574
|
run("docker", ["stack", "deploy", "-c", STACK_FILE, config.services.stack_name]);
|
|
1575
|
+
logSuccess("Deploy completed");
|
|
1277
1576
|
}
|
|
1278
1577
|
async function cmdHealth(asJson) {
|
|
1279
1578
|
const { config, generated } = loadState();
|
|
@@ -1314,20 +1613,28 @@ async function cmdHealth(asJson) {
|
|
|
1314
1613
|
console.log(JSON.stringify(checks, null, 2));
|
|
1315
1614
|
}
|
|
1316
1615
|
async function cmdUpgrade() {
|
|
1616
|
+
logSection("Upgrade");
|
|
1317
1617
|
const { config } = loadState();
|
|
1318
1618
|
config.images = {
|
|
1319
1619
|
...config.images,
|
|
1320
1620
|
...versionedImages(config.release.version)
|
|
1321
1621
|
};
|
|
1322
1622
|
saveDesiredConfig(config);
|
|
1623
|
+
if (currentOptions.dryRun) {
|
|
1624
|
+
logDryRun(`Would upgrade stack to release ${config.release.version}`);
|
|
1625
|
+
}
|
|
1323
1626
|
await cmdDeploy();
|
|
1324
1627
|
}
|
|
1325
1628
|
async function cmdUpgradeDeps() {
|
|
1629
|
+
logSection("Upgrade Dependencies");
|
|
1326
1630
|
const { config } = loadState();
|
|
1327
1631
|
config.images.traefik = "traefik:v3.1";
|
|
1328
1632
|
config.images.clickstack = "clickhouse/clickstack-all-in-one:latest";
|
|
1329
1633
|
config.images.otel_agent = "otel/opentelemetry-collector-contrib:0.111.0";
|
|
1330
1634
|
saveDesiredConfig(config);
|
|
1635
|
+
if (currentOptions.dryRun) {
|
|
1636
|
+
logDryRun("Would refresh dependency image tags and redeploy");
|
|
1637
|
+
}
|
|
1331
1638
|
await cmdDeploy();
|
|
1332
1639
|
}
|
|
1333
1640
|
function sha256File(filePath) {
|
|
@@ -1339,6 +1646,11 @@ function stackVolumeName(config, name) {
|
|
|
1339
1646
|
return `${config.services.stack_name}_${name}`;
|
|
1340
1647
|
}
|
|
1341
1648
|
function backupDockerVolume(volumeName, targetDir) {
|
|
1649
|
+
logStep(`Backing up Docker volume ${volumeName}`);
|
|
1650
|
+
if (currentOptions.dryRun) {
|
|
1651
|
+
logDryRun(`Would back up ${volumeName} into ${targetDir}`);
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1342
1654
|
ensureDir(targetDir);
|
|
1343
1655
|
run("docker", [
|
|
1344
1656
|
"run",
|
|
@@ -1352,8 +1664,14 @@ function backupDockerVolume(volumeName, targetDir) {
|
|
|
1352
1664
|
"-lc",
|
|
1353
1665
|
"cd /from && tar -czf /to/volume.tar.gz ."
|
|
1354
1666
|
]);
|
|
1667
|
+
logSuccess(`Backed up Docker volume ${volumeName}`);
|
|
1355
1668
|
}
|
|
1356
1669
|
function restoreDockerVolume(volumeName, sourceDir) {
|
|
1670
|
+
logStep(`Restoring Docker volume ${volumeName}`);
|
|
1671
|
+
if (currentOptions.dryRun) {
|
|
1672
|
+
logDryRun(`Would restore ${volumeName} from ${sourceDir}`);
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1357
1675
|
run("docker", ["volume", "create", volumeName], { quiet: true });
|
|
1358
1676
|
run("docker", [
|
|
1359
1677
|
"run",
|
|
@@ -1367,15 +1685,29 @@ function restoreDockerVolume(volumeName, sourceDir) {
|
|
|
1367
1685
|
"-lc",
|
|
1368
1686
|
"cd /to && tar -xzf /from/volume.tar.gz"
|
|
1369
1687
|
]);
|
|
1688
|
+
logSuccess(`Restored Docker volume ${volumeName}`);
|
|
1370
1689
|
}
|
|
1371
1690
|
async function cmdBackup() {
|
|
1691
|
+
logSection("Backup");
|
|
1372
1692
|
const { config } = loadState();
|
|
1373
|
-
ensureDir(config.backup.output_dir);
|
|
1374
1693
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:]/g, "-");
|
|
1375
1694
|
const archiveBase = path.join(config.backup.output_dir, `envsync-backup-${timestamp}`);
|
|
1376
1695
|
const manifestPath = `${archiveBase}.manifest.json`;
|
|
1377
1696
|
const tarPath = `${archiveBase}.tar.gz`;
|
|
1378
1697
|
const staged = path.join(BACKUPS_ROOT, `staging-${timestamp}`);
|
|
1698
|
+
logInfo(`Backup archive target: ${tarPath}`);
|
|
1699
|
+
if (currentOptions.dryRun) {
|
|
1700
|
+
logDryRun(`Would stage backup files in ${staged}`);
|
|
1701
|
+
for (const volume of STACK_VOLUMES) {
|
|
1702
|
+
backupDockerVolume(stackVolumeName(config, volume), path.join(staged, "volumes", volume));
|
|
1703
|
+
}
|
|
1704
|
+
logDryRun(`Would write manifest ${manifestPath}`);
|
|
1705
|
+
logDryRun(`Would create archive ${tarPath}`);
|
|
1706
|
+
logSuccess("Backup dry-run completed");
|
|
1707
|
+
console.log(tarPath);
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
ensureDir(config.backup.output_dir);
|
|
1379
1711
|
ensureDir(staged);
|
|
1380
1712
|
writeFile(path.join(staged, "deploy.env"), fs.readFileSync(DEPLOY_ENV, "utf8"));
|
|
1381
1713
|
writeFile(path.join(staged, "deploy.yaml"), fs.readFileSync(DEPLOY_YAML, "utf8"));
|
|
@@ -1400,11 +1732,21 @@ async function cmdBackup() {
|
|
|
1400
1732
|
volumes: STACK_VOLUMES.map((volume) => stackVolumeName(config, volume))
|
|
1401
1733
|
};
|
|
1402
1734
|
writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
|
|
1735
|
+
logSuccess("Backup completed");
|
|
1403
1736
|
console.log(tarPath);
|
|
1404
1737
|
}
|
|
1405
1738
|
async function cmdRestore(archivePath) {
|
|
1406
1739
|
if (!archivePath) throw new Error("restore requires a .tar.gz path");
|
|
1740
|
+
logSection("Restore");
|
|
1407
1741
|
const restoreRoot = path.join(BACKUPS_ROOT, `restore-${Date.now()}`);
|
|
1742
|
+
logInfo(`Restore archive: ${archivePath}`);
|
|
1743
|
+
if (currentOptions.dryRun) {
|
|
1744
|
+
logDryRun(`Would extract ${archivePath} into ${restoreRoot}`);
|
|
1745
|
+
logDryRun(`Would restore deploy files into ${DEPLOY_ROOT} and ${ETC_ROOT}`);
|
|
1746
|
+
logDryRun("Would restore all managed Docker volumes from the archive");
|
|
1747
|
+
logSuccess("Restore dry-run completed");
|
|
1748
|
+
return;
|
|
1749
|
+
}
|
|
1408
1750
|
ensureDir(restoreRoot);
|
|
1409
1751
|
run("bash", ["-lc", `tar -xzf ${JSON.stringify(archivePath)} -C ${JSON.stringify(restoreRoot)}`]);
|
|
1410
1752
|
writeFile(DEPLOY_ENV, fs.readFileSync(path.join(restoreRoot, "deploy.env"), "utf8"), 384);
|
|
@@ -1421,11 +1763,16 @@ async function cmdRestore(archivePath) {
|
|
|
1421
1763
|
for (const volume of STACK_VOLUMES) {
|
|
1422
1764
|
restoreDockerVolume(stackVolumeName(config, volume), path.join(restoreRoot, "volumes", volume));
|
|
1423
1765
|
}
|
|
1424
|
-
|
|
1766
|
+
logSuccess("Restore completed. Run 'envsync-deploy deploy' to start services.");
|
|
1425
1767
|
}
|
|
1426
1768
|
async function main() {
|
|
1427
|
-
const
|
|
1428
|
-
const
|
|
1769
|
+
const argv = process.argv.slice(2);
|
|
1770
|
+
const command = argv[0];
|
|
1771
|
+
const args = argv.slice(1);
|
|
1772
|
+
currentOptions = {
|
|
1773
|
+
dryRun: args.includes("--dry-run")
|
|
1774
|
+
};
|
|
1775
|
+
const positionals = args.filter((arg) => arg !== "--dry-run");
|
|
1429
1776
|
switch (command) {
|
|
1430
1777
|
case "preinstall":
|
|
1431
1778
|
await cmdPreinstall();
|
|
@@ -1440,7 +1787,7 @@ async function main() {
|
|
|
1440
1787
|
await cmdDeploy();
|
|
1441
1788
|
break;
|
|
1442
1789
|
case "health":
|
|
1443
|
-
await cmdHealth(
|
|
1790
|
+
await cmdHealth(positionals[0] === "--json");
|
|
1444
1791
|
break;
|
|
1445
1792
|
case "upgrade":
|
|
1446
1793
|
await cmdUpgrade();
|
|
@@ -1452,10 +1799,12 @@ async function main() {
|
|
|
1452
1799
|
await cmdBackup();
|
|
1453
1800
|
break;
|
|
1454
1801
|
case "restore":
|
|
1455
|
-
await cmdRestore(
|
|
1802
|
+
await cmdRestore(positionals[0] ?? "");
|
|
1456
1803
|
break;
|
|
1457
1804
|
default:
|
|
1458
|
-
console.log(
|
|
1805
|
+
console.log(
|
|
1806
|
+
"Usage: envsync-deploy <preinstall|setup|bootstrap|deploy|health|upgrade|upgrade-deps|backup|restore> [--dry-run]"
|
|
1807
|
+
);
|
|
1459
1808
|
process.exit(command ? 1 : 0);
|
|
1460
1809
|
}
|
|
1461
1810
|
}
|
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.5",
|
|
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"
|