@envsync-cloud/deploy-cli 0.6.0
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/LICENSE +21 -0
- package/README.md +97 -0
- package/dist/index.js +902 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 EnvSync Cloud
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# `@envsync-cloud/deploy-cli`
|
|
2
|
+
|
|
3
|
+
CLI for self-hosted EnvSync deployment on Docker Swarm.
|
|
4
|
+
|
|
5
|
+
This package provisions and manages the current EnvSync self-hosted stack for a single-host Docker Swarm installation. It is intended for operators deploying EnvSync on Ubuntu or Debian rather than for local app development.
|
|
6
|
+
|
|
7
|
+
## Supported Target Environment
|
|
8
|
+
|
|
9
|
+
- Single-host Ubuntu or Debian machine
|
|
10
|
+
- Docker Swarm manager node
|
|
11
|
+
- Public DNS ready for the root domain and subdomains
|
|
12
|
+
- Root or sudo access on the target host
|
|
13
|
+
|
|
14
|
+
The current self-hosted direction is documented in the main self-hosting guide:
|
|
15
|
+
- https://github.com/EnvSync-Cloud/envsync/blob/main/SELFHOSTING.md
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
Run without a global install:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx @envsync-cloud/deploy-cli <command>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Install globally:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install -g @envsync-cloud/deploy-cli
|
|
29
|
+
envsync-deploy <command>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Optional Bun invocation:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
bunx @envsync-cloud/deploy-cli <command>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Commands
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
envsync-deploy preinstall
|
|
42
|
+
envsync-deploy setup
|
|
43
|
+
envsync-deploy deploy
|
|
44
|
+
envsync-deploy health [--json]
|
|
45
|
+
envsync-deploy upgrade
|
|
46
|
+
envsync-deploy upgrade-deps
|
|
47
|
+
envsync-deploy backup
|
|
48
|
+
envsync-deploy restore <archive>
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Quick Start
|
|
52
|
+
|
|
53
|
+
Prepare the host:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npx @envsync-cloud/deploy-cli preinstall
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Generate deployment state and prompts:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npx @envsync-cloud/deploy-cli setup
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Apply the Docker Swarm stack:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npx @envsync-cloud/deploy-cli deploy
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Check service health:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
npx @envsync-cloud/deploy-cli health --json
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Create a backup archive:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
npx @envsync-cloud/deploy-cli backup
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Restore from an existing backup archive:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
npx @envsync-cloud/deploy-cli restore /path/to/envsync-backup.tar.gz
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Links
|
|
90
|
+
|
|
91
|
+
- Repository: https://github.com/EnvSync-Cloud/envsync
|
|
92
|
+
- Issues: https://github.com/EnvSync-Cloud/envsync/issues
|
|
93
|
+
- Self-hosting guide: https://github.com/EnvSync-Cloud/envsync/blob/main/SELFHOSTING.md
|
|
94
|
+
|
|
95
|
+
## Versioning
|
|
96
|
+
|
|
97
|
+
This package releases from the shared monorepo tag flow. Published npm versions are tied to repo tags in the form `vX.Y.Z`.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { createHash, randomBytes } from "crypto";
|
|
5
|
+
import { spawnSync } from "child_process";
|
|
6
|
+
import fs from "fs";
|
|
7
|
+
import path from "path";
|
|
8
|
+
import readline from "readline";
|
|
9
|
+
var HOST_ROOT = "/opt/envsync";
|
|
10
|
+
var DEPLOY_ROOT = "/opt/envsync/deploy";
|
|
11
|
+
var RELEASES_ROOT = "/opt/envsync/releases";
|
|
12
|
+
var BACKUPS_ROOT = "/opt/envsync/backups";
|
|
13
|
+
var ETC_ROOT = "/etc/envsync";
|
|
14
|
+
var TRAEFIK_STATE_ROOT = "/var/lib/envsync/traefik";
|
|
15
|
+
var REPO_ROOT = "/opt/envsync/repo";
|
|
16
|
+
var DEPLOY_ENV = "/etc/envsync/deploy.env";
|
|
17
|
+
var DEPLOY_YAML = "/etc/envsync/deploy.yaml";
|
|
18
|
+
var VERSIONS_LOCK = "/opt/envsync/deploy/versions.lock.json";
|
|
19
|
+
var STACK_FILE = "/opt/envsync/deploy/docker-stack.yaml";
|
|
20
|
+
var TRAEFIK_DYNAMIC_FILE = "/opt/envsync/deploy/traefik-dynamic.yaml";
|
|
21
|
+
var KEYCLOAK_REALM_FILE = "/opt/envsync/deploy/keycloak-realm.envsync.json";
|
|
22
|
+
var NGINX_WEB_CONF = "/opt/envsync/deploy/nginx-web.conf";
|
|
23
|
+
var NGINX_LANDING_CONF = "/opt/envsync/deploy/nginx-landing.conf";
|
|
24
|
+
var OTEL_AGENT_CONF = "/opt/envsync/deploy/otel-agent.yaml";
|
|
25
|
+
var INTERNAL_CONFIG_JSON = "/opt/envsync/deploy/config.json";
|
|
26
|
+
var STACK_VOLUMES = [
|
|
27
|
+
"postgres_data",
|
|
28
|
+
"redis_data",
|
|
29
|
+
"rustfs_data",
|
|
30
|
+
"keycloak_db_data",
|
|
31
|
+
"openfga_db_data",
|
|
32
|
+
"minikms_db_data",
|
|
33
|
+
"clickstack_data",
|
|
34
|
+
"clickstack_ch_data",
|
|
35
|
+
"clickstack_ch_logs"
|
|
36
|
+
];
|
|
37
|
+
function run(cmd, args, opts = {}) {
|
|
38
|
+
const result = spawnSync(cmd, args, {
|
|
39
|
+
cwd: opts.cwd,
|
|
40
|
+
env: { ...process.env, ...opts.env },
|
|
41
|
+
stdio: opts.quiet ? "pipe" : "inherit",
|
|
42
|
+
encoding: "utf8"
|
|
43
|
+
});
|
|
44
|
+
if (result.status !== 0) {
|
|
45
|
+
const stderr = typeof result.stderr === "string" ? result.stderr : "";
|
|
46
|
+
throw new Error(`Command failed: ${cmd} ${args.join(" ")}${stderr ? `
|
|
47
|
+
${stderr}` : ""}`);
|
|
48
|
+
}
|
|
49
|
+
return result.stdout?.toString() ?? "";
|
|
50
|
+
}
|
|
51
|
+
function ensureDir(dir) {
|
|
52
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
53
|
+
}
|
|
54
|
+
function writeFile(target, content, mode) {
|
|
55
|
+
ensureDir(path.dirname(target));
|
|
56
|
+
fs.writeFileSync(target, content, "utf8");
|
|
57
|
+
if (mode != null) fs.chmodSync(target, mode);
|
|
58
|
+
}
|
|
59
|
+
function exists(target) {
|
|
60
|
+
return fs.existsSync(target);
|
|
61
|
+
}
|
|
62
|
+
function randomSecret(bytes = 24) {
|
|
63
|
+
return randomBytes(bytes).toString("hex");
|
|
64
|
+
}
|
|
65
|
+
function yamlScalar(value) {
|
|
66
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
67
|
+
if (typeof value === "number") return `${value}`;
|
|
68
|
+
if (/^[A-Za-z0-9._/@:-]+$/.test(value)) return value;
|
|
69
|
+
return JSON.stringify(value);
|
|
70
|
+
}
|
|
71
|
+
function toYaml(value, indent = 0) {
|
|
72
|
+
const pad = " ".repeat(indent);
|
|
73
|
+
if (Array.isArray(value)) {
|
|
74
|
+
return value.map((item) => {
|
|
75
|
+
if (typeof item === "object" && item !== null) {
|
|
76
|
+
const child = toYaml(item, indent + 2);
|
|
77
|
+
return `${pad}- ${child.trimStart()}`.includes("\n") ? `${pad}-
|
|
78
|
+
${child}` : `${pad}- ${child.trimStart()}`;
|
|
79
|
+
}
|
|
80
|
+
return `${pad}- ${yamlScalar(item)}`;
|
|
81
|
+
}).join("\n");
|
|
82
|
+
}
|
|
83
|
+
if (typeof value === "object" && value !== null) {
|
|
84
|
+
return Object.entries(value).map(([key, item]) => {
|
|
85
|
+
if (Array.isArray(item) || typeof item === "object" && item !== null) {
|
|
86
|
+
return `${pad}${key}:
|
|
87
|
+
${toYaml(item, indent + 2)}`;
|
|
88
|
+
}
|
|
89
|
+
return `${pad}${key}: ${yamlScalar(item)}`;
|
|
90
|
+
}).join("\n");
|
|
91
|
+
}
|
|
92
|
+
return `${pad}${yamlScalar(value)}`;
|
|
93
|
+
}
|
|
94
|
+
function parseYamlScalar(value) {
|
|
95
|
+
const trimmed = value.trim();
|
|
96
|
+
if (trimmed === "true") return true;
|
|
97
|
+
if (trimmed === "false") return false;
|
|
98
|
+
if (/^-?\d+$/.test(trimmed)) return Number(trimmed);
|
|
99
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'")) {
|
|
100
|
+
return trimmed.slice(1, -1);
|
|
101
|
+
}
|
|
102
|
+
return trimmed;
|
|
103
|
+
}
|
|
104
|
+
function parseSimpleYamlObject(input) {
|
|
105
|
+
const root = {};
|
|
106
|
+
const stack = [{ indent: -1, value: root }];
|
|
107
|
+
for (const rawLine of input.split(/\r?\n/)) {
|
|
108
|
+
const trimmed = rawLine.trim();
|
|
109
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
110
|
+
const indent = rawLine.length - rawLine.trimStart().length;
|
|
111
|
+
const separator = trimmed.indexOf(":");
|
|
112
|
+
if (separator === -1) continue;
|
|
113
|
+
const key = trimmed.slice(0, separator).trim();
|
|
114
|
+
const rest = trimmed.slice(separator + 1).trim();
|
|
115
|
+
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
|
|
116
|
+
stack.pop();
|
|
117
|
+
}
|
|
118
|
+
const parent = stack[stack.length - 1].value;
|
|
119
|
+
if (!rest) {
|
|
120
|
+
const child = {};
|
|
121
|
+
parent[key] = child;
|
|
122
|
+
stack.push({ indent, value: child });
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
parent[key] = parseYamlScalar(rest);
|
|
126
|
+
}
|
|
127
|
+
return root;
|
|
128
|
+
}
|
|
129
|
+
function indentBlock(content, spaces) {
|
|
130
|
+
const prefix = " ".repeat(spaces);
|
|
131
|
+
return content.split("\n").map((line) => line ? `${prefix}${line}` : line).join("\n");
|
|
132
|
+
}
|
|
133
|
+
async function ask(question, fallback = "") {
|
|
134
|
+
if (!process.stdin.isTTY) return fallback;
|
|
135
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
136
|
+
return await new Promise((resolve) => {
|
|
137
|
+
rl.question(fallback ? `${question} [${fallback}]: ` : `${question}: `, (answer) => {
|
|
138
|
+
rl.close();
|
|
139
|
+
resolve(answer.trim() || fallback);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
function domainMap(rootDomain) {
|
|
144
|
+
return {
|
|
145
|
+
landing: rootDomain,
|
|
146
|
+
app: `app.${rootDomain}`,
|
|
147
|
+
api: `api.${rootDomain}`,
|
|
148
|
+
auth: `auth.${rootDomain}`,
|
|
149
|
+
obs: `obs.${rootDomain}`,
|
|
150
|
+
mail: `mail.${rootDomain}`,
|
|
151
|
+
s3: `s3.${rootDomain}`,
|
|
152
|
+
s3Console: `console.s3.${rootDomain}`
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function loadConfig() {
|
|
156
|
+
if (!exists(DEPLOY_YAML)) {
|
|
157
|
+
throw new Error(`Missing deploy config at ${DEPLOY_YAML}. Run setup first.`);
|
|
158
|
+
}
|
|
159
|
+
const raw = fs.readFileSync(DEPLOY_YAML, "utf8");
|
|
160
|
+
if (raw.trimStart().startsWith("{")) {
|
|
161
|
+
return JSON.parse(raw);
|
|
162
|
+
}
|
|
163
|
+
return parseSimpleYamlObject(raw);
|
|
164
|
+
}
|
|
165
|
+
function saveConfig(config) {
|
|
166
|
+
writeFile(DEPLOY_YAML, toYaml(config) + "\n");
|
|
167
|
+
writeFile(INTERNAL_CONFIG_JSON, JSON.stringify(config, null, 2) + "\n");
|
|
168
|
+
writeFile(DEPLOY_ENV, renderEnv(config), 384);
|
|
169
|
+
writeFile(VERSIONS_LOCK, JSON.stringify(config.images, null, 2) + "\n");
|
|
170
|
+
writeFile(KEYCLOAK_REALM_FILE, renderKeycloakRealm(config));
|
|
171
|
+
writeFile(TRAEFIK_DYNAMIC_FILE, renderTraefikDynamicConfig(config));
|
|
172
|
+
writeFile(STACK_FILE, renderStack(config));
|
|
173
|
+
writeFile(NGINX_WEB_CONF, renderNginxConf("web"));
|
|
174
|
+
writeFile(NGINX_LANDING_CONF, renderNginxConf("landing"));
|
|
175
|
+
writeFile(OTEL_AGENT_CONF, renderOtelAgentConfig(config));
|
|
176
|
+
}
|
|
177
|
+
function buildEnvMap(config) {
|
|
178
|
+
const hosts = domainMap(config.domain.root_domain);
|
|
179
|
+
return {
|
|
180
|
+
NODE_ENV: "production",
|
|
181
|
+
PORT: `${config.services.api_port}`,
|
|
182
|
+
DATABASE_HOST: "postgres",
|
|
183
|
+
DATABASE_PORT: "5432",
|
|
184
|
+
DATABASE_USER: "postgres",
|
|
185
|
+
DATABASE_PASSWORD: "envsync-postgres",
|
|
186
|
+
DATABASE_NAME: "envsync",
|
|
187
|
+
POSTGRES_USER: "postgres",
|
|
188
|
+
POSTGRES_PASSWORD: "envsync-postgres",
|
|
189
|
+
POSTGRES_DB: "envsync",
|
|
190
|
+
S3_BUCKET: "envsync-bucket",
|
|
191
|
+
S3_REGION: "us-east-1",
|
|
192
|
+
S3_ACCESS_KEY: "envsync-rustfs",
|
|
193
|
+
S3_SECRET_KEY: randomSecret(16),
|
|
194
|
+
S3_BUCKET_URL: `https://${hosts.s3}`,
|
|
195
|
+
S3_ENDPOINT: "http://rustfs:9000",
|
|
196
|
+
REDIS_URL: "redis://redis:6379",
|
|
197
|
+
SMTP_HOST: config.smtp.host,
|
|
198
|
+
SMTP_PORT: `${config.smtp.port}`,
|
|
199
|
+
SMTP_SECURE: `${config.smtp.secure}`,
|
|
200
|
+
SMTP_USER: config.smtp.user,
|
|
201
|
+
SMTP_PASS: config.smtp.pass,
|
|
202
|
+
SMTP_FROM: config.smtp.from,
|
|
203
|
+
KEYCLOAK_URL: "http://keycloak:8080",
|
|
204
|
+
KEYCLOAK_REALM: config.auth.keycloak_realm,
|
|
205
|
+
KEYCLOAK_ADMIN_USER: config.auth.admin_user,
|
|
206
|
+
KEYCLOAK_ADMIN_PASSWORD: config.auth.admin_password,
|
|
207
|
+
KEYCLOAK_WEB_CLIENT_ID: config.auth.web_client_id,
|
|
208
|
+
KEYCLOAK_WEB_CLIENT_SECRET: randomSecret(),
|
|
209
|
+
KEYCLOAK_CLI_CLIENT_ID: config.auth.cli_client_id,
|
|
210
|
+
KEYCLOAK_API_CLIENT_ID: config.auth.api_client_id,
|
|
211
|
+
KEYCLOAK_API_CLIENT_SECRET: randomSecret(),
|
|
212
|
+
KEYCLOAK_WEB_REDIRECT_URI: `https://${hosts.api}/api/access/web/callback`,
|
|
213
|
+
KEYCLOAK_WEB_CALLBACK_URL: `https://${hosts.app}/auth/callback`,
|
|
214
|
+
KEYCLOAK_API_REDIRECT_URI: `https://${hosts.api}/api/access/api/callback`,
|
|
215
|
+
LANDING_PAGE_URL: `https://${hosts.landing}`,
|
|
216
|
+
DASHBOARD_URL: `https://${hosts.app}`,
|
|
217
|
+
OPENFGA_API_URL: "http://openfga:8090",
|
|
218
|
+
OPENFGA_STORE_ID: "",
|
|
219
|
+
OPENFGA_MODEL_ID: "",
|
|
220
|
+
OPENFGA_DB_PASSWORD: randomSecret(),
|
|
221
|
+
MINIKMS_GRPC_ADDR: "minikms:50051",
|
|
222
|
+
MINIKMS_TLS_ENABLED: "false",
|
|
223
|
+
MINIKMS_ROOT_KEY: randomBytes(32).toString("hex"),
|
|
224
|
+
MINIKMS_DB_USER: "postgres",
|
|
225
|
+
MINIKMS_DB_PASSWORD: randomSecret(),
|
|
226
|
+
OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-agent:4318",
|
|
227
|
+
OTEL_SERVICE_NAME: "envsync-api",
|
|
228
|
+
OTEL_SDK_DISABLED: "false",
|
|
229
|
+
CLICKSTACK_URL: `https://${hosts.obs}`
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function renderEnv(config) {
|
|
233
|
+
return Object.entries({
|
|
234
|
+
...buildEnvMap(config),
|
|
235
|
+
KEYCLOAK_IMAGE_TAG: config.images.keycloak.split(":").slice(1).join(":") || "local"
|
|
236
|
+
}).map(([k, v]) => `${k}=${v}`).join("\n") + "\n";
|
|
237
|
+
}
|
|
238
|
+
function renderServiceEnvironment(config, overrides = {}) {
|
|
239
|
+
return toYaml({ ...buildEnvMap(config), ...overrides }, 0);
|
|
240
|
+
}
|
|
241
|
+
function renderKeycloakRealm(config) {
|
|
242
|
+
const hosts = domainMap(config.domain.root_domain);
|
|
243
|
+
const webSecret = extractEnvValue("KEYCLOAK_WEB_CLIENT_SECRET");
|
|
244
|
+
const apiSecret = extractEnvValue("KEYCLOAK_API_CLIENT_SECRET");
|
|
245
|
+
return JSON.stringify(
|
|
246
|
+
{
|
|
247
|
+
realm: config.auth.keycloak_realm,
|
|
248
|
+
enabled: true,
|
|
249
|
+
loginTheme: "envsync",
|
|
250
|
+
emailTheme: "envsync",
|
|
251
|
+
clients: [
|
|
252
|
+
{
|
|
253
|
+
clientId: config.auth.web_client_id,
|
|
254
|
+
name: "EnvSync Web",
|
|
255
|
+
protocol: "openid-connect",
|
|
256
|
+
publicClient: false,
|
|
257
|
+
secret: webSecret,
|
|
258
|
+
standardFlowEnabled: true,
|
|
259
|
+
directAccessGrantsEnabled: false,
|
|
260
|
+
redirectUris: [`https://${hosts.api}/api/access/web/callback`],
|
|
261
|
+
webOrigins: [`https://${hosts.app}`],
|
|
262
|
+
defaultClientScopes: ["basic", "web-origins", "profile", "email", "roles"]
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
clientId: config.auth.api_client_id,
|
|
266
|
+
name: "EnvSync API",
|
|
267
|
+
protocol: "openid-connect",
|
|
268
|
+
publicClient: false,
|
|
269
|
+
secret: apiSecret,
|
|
270
|
+
standardFlowEnabled: true,
|
|
271
|
+
redirectUris: [`https://${hosts.api}/api/access/api/callback`],
|
|
272
|
+
webOrigins: [`https://${hosts.api}`],
|
|
273
|
+
defaultClientScopes: ["basic", "profile", "email", "roles"]
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
clientId: config.auth.cli_client_id,
|
|
277
|
+
name: "EnvSync CLI",
|
|
278
|
+
protocol: "openid-connect",
|
|
279
|
+
publicClient: true,
|
|
280
|
+
standardFlowEnabled: false,
|
|
281
|
+
directAccessGrantsEnabled: false,
|
|
282
|
+
attributes: {
|
|
283
|
+
"oauth2.device.authorization.grant.enabled": "true"
|
|
284
|
+
},
|
|
285
|
+
defaultClientScopes: ["basic", "profile", "email", "roles"]
|
|
286
|
+
}
|
|
287
|
+
]
|
|
288
|
+
},
|
|
289
|
+
null,
|
|
290
|
+
2
|
|
291
|
+
) + "\n";
|
|
292
|
+
}
|
|
293
|
+
function extractEnvValue(key) {
|
|
294
|
+
const env = exists(DEPLOY_ENV) ? fs.readFileSync(DEPLOY_ENV, "utf8") : "";
|
|
295
|
+
const line = env.split(/\r?\n/).find((entry) => entry.startsWith(`${key}=`));
|
|
296
|
+
return line?.slice(key.length + 1) ?? "";
|
|
297
|
+
}
|
|
298
|
+
function renderTraefikDynamicConfig(config) {
|
|
299
|
+
const hosts = domainMap(config.domain.root_domain);
|
|
300
|
+
return [
|
|
301
|
+
"http:",
|
|
302
|
+
" middlewares:",
|
|
303
|
+
" secure-headers:",
|
|
304
|
+
" headers:",
|
|
305
|
+
" browserXssFilter: true",
|
|
306
|
+
" contentTypeNosniff: true",
|
|
307
|
+
" forceSTSHeader: true",
|
|
308
|
+
" stsSeconds: 31536000",
|
|
309
|
+
" gzip:",
|
|
310
|
+
" compress: {}",
|
|
311
|
+
" services:",
|
|
312
|
+
" envsync-api:",
|
|
313
|
+
" weighted:",
|
|
314
|
+
" services:",
|
|
315
|
+
" - name: envsync-api-blue",
|
|
316
|
+
" weight: 100",
|
|
317
|
+
" - name: envsync-api-green",
|
|
318
|
+
" weight: 0",
|
|
319
|
+
" envsync-api-blue:",
|
|
320
|
+
" loadBalancer:",
|
|
321
|
+
" servers:",
|
|
322
|
+
" - url: http://envsync_api_blue:4000",
|
|
323
|
+
" envsync-api-green:",
|
|
324
|
+
" loadBalancer:",
|
|
325
|
+
" servers:",
|
|
326
|
+
" - url: http://envsync_api_green:4000",
|
|
327
|
+
" landing:",
|
|
328
|
+
" loadBalancer:",
|
|
329
|
+
" servers:",
|
|
330
|
+
" - url: http://landing_nginx:8080",
|
|
331
|
+
" web:",
|
|
332
|
+
" loadBalancer:",
|
|
333
|
+
" servers:",
|
|
334
|
+
" - url: http://web_nginx:8080",
|
|
335
|
+
" routers:",
|
|
336
|
+
` landing-router:`,
|
|
337
|
+
` rule: Host(\`${hosts.landing}\`)`,
|
|
338
|
+
" service: landing",
|
|
339
|
+
" entryPoints: [websecure]",
|
|
340
|
+
" tls: {}",
|
|
341
|
+
` web-router:`,
|
|
342
|
+
` rule: Host(\`${hosts.app}\`)`,
|
|
343
|
+
" service: web",
|
|
344
|
+
" entryPoints: [websecure]",
|
|
345
|
+
" tls: {}",
|
|
346
|
+
` api-router:`,
|
|
347
|
+
` rule: Host(\`${hosts.api}\`)`,
|
|
348
|
+
" service: envsync-api",
|
|
349
|
+
" entryPoints: [websecure]",
|
|
350
|
+
" tls: {}"
|
|
351
|
+
].join("\n") + "\n";
|
|
352
|
+
}
|
|
353
|
+
function renderNginxConf(kind) {
|
|
354
|
+
return [
|
|
355
|
+
"server {",
|
|
356
|
+
" listen 8080;",
|
|
357
|
+
" server_name _;",
|
|
358
|
+
` root /srv/${kind};`,
|
|
359
|
+
" index index.html;",
|
|
360
|
+
" location / {",
|
|
361
|
+
" try_files $uri $uri/ /index.html;",
|
|
362
|
+
" }",
|
|
363
|
+
"}"
|
|
364
|
+
].join("\n") + "\n";
|
|
365
|
+
}
|
|
366
|
+
function renderOtelAgentConfig(config) {
|
|
367
|
+
return [
|
|
368
|
+
"receivers:",
|
|
369
|
+
" otlp:",
|
|
370
|
+
" protocols:",
|
|
371
|
+
" grpc:",
|
|
372
|
+
" http:",
|
|
373
|
+
"processors:",
|
|
374
|
+
" batch: {}",
|
|
375
|
+
" resource:",
|
|
376
|
+
" attributes:",
|
|
377
|
+
" - key: deployment.environment",
|
|
378
|
+
" value: production",
|
|
379
|
+
" action: upsert",
|
|
380
|
+
"exporters:",
|
|
381
|
+
" otlphttp/clickstack:",
|
|
382
|
+
` endpoint: http://clickstack:${config.services.clickstack_otlp_http_port}`,
|
|
383
|
+
"service:",
|
|
384
|
+
" pipelines:",
|
|
385
|
+
" traces:",
|
|
386
|
+
" receivers: [otlp]",
|
|
387
|
+
" processors: [resource, batch]",
|
|
388
|
+
" exporters: [otlphttp/clickstack]",
|
|
389
|
+
" logs:",
|
|
390
|
+
" receivers: [otlp]",
|
|
391
|
+
" processors: [resource, batch]",
|
|
392
|
+
" exporters: [otlphttp/clickstack]",
|
|
393
|
+
" metrics:",
|
|
394
|
+
" receivers: [otlp]",
|
|
395
|
+
" processors: [resource, batch]",
|
|
396
|
+
" exporters: [otlphttp/clickstack]"
|
|
397
|
+
].join("\n") + "\n";
|
|
398
|
+
}
|
|
399
|
+
function renderStack(config) {
|
|
400
|
+
const hosts = domainMap(config.domain.root_domain);
|
|
401
|
+
const apiEnvironment = renderServiceEnvironment(config, {
|
|
402
|
+
OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-agent:4318",
|
|
403
|
+
KEYCLOAK_URL: "http://keycloak:8080",
|
|
404
|
+
OPENFGA_API_URL: "http://openfga:8090",
|
|
405
|
+
MINIKMS_GRPC_ADDR: "minikms:50051",
|
|
406
|
+
S3_ENDPOINT: "http://rustfs:9000",
|
|
407
|
+
S3_BUCKET_URL: `https://${hosts.s3}`
|
|
408
|
+
});
|
|
409
|
+
return `
|
|
410
|
+
version: "3.9"
|
|
411
|
+
services:
|
|
412
|
+
traefik:
|
|
413
|
+
image: ${config.images.traefik}
|
|
414
|
+
command:
|
|
415
|
+
- --providers.docker.swarmMode=true
|
|
416
|
+
- --providers.docker.exposedByDefault=false
|
|
417
|
+
- --providers.file.filename=/etc/traefik/dynamic/traefik-dynamic.yaml
|
|
418
|
+
- --entrypoints.web.address=:80
|
|
419
|
+
- --entrypoints.websecure.address=:443
|
|
420
|
+
- --certificatesresolvers.letsencrypt.acme.email=${config.domain.acme_email}
|
|
421
|
+
- --certificatesresolvers.letsencrypt.acme.storage=/var/lib/traefik/acme.json
|
|
422
|
+
- --certificatesresolvers.letsencrypt.acme.httpchallenge=true
|
|
423
|
+
- --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
|
|
424
|
+
ports:
|
|
425
|
+
- target: 80
|
|
426
|
+
published: 80
|
|
427
|
+
protocol: tcp
|
|
428
|
+
mode: host
|
|
429
|
+
- target: 443
|
|
430
|
+
published: 443
|
|
431
|
+
protocol: tcp
|
|
432
|
+
mode: host
|
|
433
|
+
volumes:
|
|
434
|
+
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
435
|
+
- ${TRAEFIK_STATE_ROOT}:/var/lib/traefik
|
|
436
|
+
- ${DEPLOY_ROOT}:/etc/traefik/dynamic:ro
|
|
437
|
+
networks: [envsync]
|
|
438
|
+
|
|
439
|
+
postgres:
|
|
440
|
+
image: postgres:17
|
|
441
|
+
environment:
|
|
442
|
+
POSTGRES_USER: postgres
|
|
443
|
+
POSTGRES_PASSWORD: envsync-postgres
|
|
444
|
+
POSTGRES_DB: envsync
|
|
445
|
+
volumes:
|
|
446
|
+
- postgres_data:/var/lib/postgresql/data
|
|
447
|
+
networks: [envsync]
|
|
448
|
+
|
|
449
|
+
redis:
|
|
450
|
+
image: redis:7
|
|
451
|
+
volumes:
|
|
452
|
+
- redis_data:/data
|
|
453
|
+
networks: [envsync]
|
|
454
|
+
|
|
455
|
+
rustfs:
|
|
456
|
+
image: rustfs/rustfs:latest
|
|
457
|
+
environment:
|
|
458
|
+
RUSTFS_DATA_DIR: /data
|
|
459
|
+
RUSTFS_ACCESS_KEY: envsync-rustfs
|
|
460
|
+
RUSTFS_SECRET_KEY: ${extractEnvValue("S3_SECRET_KEY")}
|
|
461
|
+
RUSTFS_CONSOLE_ENABLE: "true"
|
|
462
|
+
volumes:
|
|
463
|
+
- rustfs_data:/data
|
|
464
|
+
networks: [envsync]
|
|
465
|
+
deploy:
|
|
466
|
+
labels:
|
|
467
|
+
- traefik.enable=true
|
|
468
|
+
- traefik.http.routers.s3.rule=Host(\`${hosts.s3}\`)
|
|
469
|
+
- traefik.http.routers.s3.entrypoints=websecure
|
|
470
|
+
- traefik.http.routers.s3.tls.certresolver=letsencrypt
|
|
471
|
+
- traefik.http.services.s3.loadbalancer.server.port=9000
|
|
472
|
+
- traefik.http.routers.s3-console.rule=Host(\`${hosts.s3Console}\`)
|
|
473
|
+
- traefik.http.routers.s3-console.entrypoints=websecure
|
|
474
|
+
- traefik.http.routers.s3-console.tls.certresolver=letsencrypt
|
|
475
|
+
- traefik.http.services.s3-console.loadbalancer.server.port=9001
|
|
476
|
+
|
|
477
|
+
keycloak_db:
|
|
478
|
+
image: postgres:17
|
|
479
|
+
environment:
|
|
480
|
+
POSTGRES_USER: keycloak
|
|
481
|
+
POSTGRES_PASSWORD: ${extractEnvValue("KEYCLOAK_ADMIN_PASSWORD")}
|
|
482
|
+
POSTGRES_DB: keycloak
|
|
483
|
+
volumes:
|
|
484
|
+
- keycloak_db_data:/var/lib/postgresql/data
|
|
485
|
+
networks: [envsync]
|
|
486
|
+
|
|
487
|
+
keycloak:
|
|
488
|
+
image: ${config.images.keycloak}
|
|
489
|
+
entrypoint: ["/bin/sh", "-lc"]
|
|
490
|
+
command:
|
|
491
|
+
- /opt/keycloak/bin/kc.sh import --dir /opt/keycloak/data/import --override true && exec /opt/keycloak/bin/kc.sh start-dev
|
|
492
|
+
environment:
|
|
493
|
+
KC_DB: postgres
|
|
494
|
+
KC_DB_URL: jdbc:postgresql://keycloak_db:5432/keycloak
|
|
495
|
+
KC_DB_USERNAME: keycloak
|
|
496
|
+
KC_DB_PASSWORD: ${extractEnvValue("KEYCLOAK_ADMIN_PASSWORD")}
|
|
497
|
+
KC_BOOTSTRAP_ADMIN_USERNAME: ${config.auth.admin_user}
|
|
498
|
+
KC_BOOTSTRAP_ADMIN_PASSWORD: ${config.auth.admin_password}
|
|
499
|
+
KC_HTTP_ENABLED: "true"
|
|
500
|
+
KC_PROXY_HEADERS: xforwarded
|
|
501
|
+
KC_HOSTNAME: ${hosts.auth}
|
|
502
|
+
volumes:
|
|
503
|
+
- ${DEPLOY_ROOT}/keycloak-realm.envsync.json:/opt/keycloak/data/import/realm.json:ro
|
|
504
|
+
networks: [envsync]
|
|
505
|
+
deploy:
|
|
506
|
+
labels:
|
|
507
|
+
- traefik.enable=true
|
|
508
|
+
- traefik.http.routers.keycloak.rule=Host(\`${hosts.auth}\`)
|
|
509
|
+
- traefik.http.routers.keycloak.entrypoints=websecure
|
|
510
|
+
- traefik.http.routers.keycloak.tls.certresolver=letsencrypt
|
|
511
|
+
- traefik.http.services.keycloak.loadbalancer.server.port=8080
|
|
512
|
+
|
|
513
|
+
openfga_db:
|
|
514
|
+
image: postgres:17
|
|
515
|
+
environment:
|
|
516
|
+
POSTGRES_USER: openfga
|
|
517
|
+
POSTGRES_PASSWORD: ${extractEnvValue("OPENFGA_DB_PASSWORD")}
|
|
518
|
+
POSTGRES_DB: openfga
|
|
519
|
+
volumes:
|
|
520
|
+
- openfga_db_data:/var/lib/postgresql/data
|
|
521
|
+
networks: [envsync]
|
|
522
|
+
|
|
523
|
+
openfga:
|
|
524
|
+
image: openfga/openfga:v1.12.0
|
|
525
|
+
command: run
|
|
526
|
+
environment:
|
|
527
|
+
OPENFGA_DATASTORE_ENGINE: postgres
|
|
528
|
+
OPENFGA_DATASTORE_URI: postgres://openfga:${extractEnvValue("OPENFGA_DB_PASSWORD")}@openfga_db:5432/openfga?sslmode=disable
|
|
529
|
+
OPENFGA_HTTP_ADDR: 0.0.0.0:8090
|
|
530
|
+
OPENFGA_GRPC_ADDR: 0.0.0.0:8091
|
|
531
|
+
networks: [envsync]
|
|
532
|
+
|
|
533
|
+
minikms_db:
|
|
534
|
+
image: postgres:17
|
|
535
|
+
environment:
|
|
536
|
+
POSTGRES_USER: postgres
|
|
537
|
+
POSTGRES_PASSWORD: ${extractEnvValue("MINIKMS_DB_PASSWORD")}
|
|
538
|
+
POSTGRES_DB: minikms
|
|
539
|
+
volumes:
|
|
540
|
+
- minikms_db_data:/var/lib/postgresql/data
|
|
541
|
+
networks: [envsync]
|
|
542
|
+
|
|
543
|
+
minikms:
|
|
544
|
+
image: ghcr.io/envsync-cloud/minikms:sha-735dfe8
|
|
545
|
+
environment:
|
|
546
|
+
MINIKMS_ROOT_KEY: ${extractEnvValue("MINIKMS_ROOT_KEY")}
|
|
547
|
+
MINIKMS_DB_URL: postgres://postgres:${extractEnvValue("MINIKMS_DB_PASSWORD")}@minikms_db:5432/minikms?sslmode=disable
|
|
548
|
+
MINIKMS_REDIS_URL: redis://redis:6379
|
|
549
|
+
MINIKMS_TLS_ENABLED: "false"
|
|
550
|
+
networks: [envsync]
|
|
551
|
+
|
|
552
|
+
clickstack:
|
|
553
|
+
image: ${config.images.clickstack}
|
|
554
|
+
volumes:
|
|
555
|
+
- clickstack_data:/data/db
|
|
556
|
+
- clickstack_ch_data:/var/lib/clickhouse
|
|
557
|
+
- clickstack_ch_logs:/var/log/clickhouse-server
|
|
558
|
+
networks: [envsync]
|
|
559
|
+
deploy:
|
|
560
|
+
labels:
|
|
561
|
+
- traefik.enable=true
|
|
562
|
+
- traefik.http.routers.obs.rule=Host(\`${hosts.obs}\`)
|
|
563
|
+
- traefik.http.routers.obs.entrypoints=websecure
|
|
564
|
+
- traefik.http.routers.obs.tls.certresolver=letsencrypt
|
|
565
|
+
- traefik.http.services.obs.loadbalancer.server.port=8080
|
|
566
|
+
|
|
567
|
+
otel-agent:
|
|
568
|
+
image: ${config.images.otel_agent}
|
|
569
|
+
command: ["--config=/etc/otel-agent.yaml"]
|
|
570
|
+
volumes:
|
|
571
|
+
- ${OTEL_AGENT_CONF}:/etc/otel-agent.yaml:ro
|
|
572
|
+
networks: [envsync]
|
|
573
|
+
|
|
574
|
+
landing_nginx:
|
|
575
|
+
image: nginx:1.27-alpine
|
|
576
|
+
volumes:
|
|
577
|
+
- ${NGINX_LANDING_CONF}:/etc/nginx/conf.d/default.conf:ro
|
|
578
|
+
- ${RELEASES_ROOT}/landing/current:/srv/landing:ro
|
|
579
|
+
networks: [envsync]
|
|
580
|
+
|
|
581
|
+
web_nginx:
|
|
582
|
+
image: nginx:1.27-alpine
|
|
583
|
+
volumes:
|
|
584
|
+
- ${NGINX_WEB_CONF}:/etc/nginx/conf.d/default.conf:ro
|
|
585
|
+
- ${RELEASES_ROOT}/web/current:/srv/web:ro
|
|
586
|
+
networks: [envsync]
|
|
587
|
+
|
|
588
|
+
envsync_api_blue:
|
|
589
|
+
image: ${config.images.api}
|
|
590
|
+
environment:
|
|
591
|
+
${indentBlock(apiEnvironment, 6)}
|
|
592
|
+
networks: [envsync]
|
|
593
|
+
|
|
594
|
+
envsync_api_green:
|
|
595
|
+
image: ${config.images.api}
|
|
596
|
+
environment:
|
|
597
|
+
${indentBlock(apiEnvironment, 6)}
|
|
598
|
+
networks: [envsync]
|
|
599
|
+
|
|
600
|
+
networks:
|
|
601
|
+
envsync:
|
|
602
|
+
driver: overlay
|
|
603
|
+
attachable: true
|
|
604
|
+
|
|
605
|
+
volumes:
|
|
606
|
+
postgres_data:
|
|
607
|
+
redis_data:
|
|
608
|
+
rustfs_data:
|
|
609
|
+
keycloak_db_data:
|
|
610
|
+
openfga_db_data:
|
|
611
|
+
minikms_db_data:
|
|
612
|
+
clickstack_data:
|
|
613
|
+
clickstack_ch_data:
|
|
614
|
+
clickstack_ch_logs:
|
|
615
|
+
`.trimStart();
|
|
616
|
+
}
|
|
617
|
+
async function cmdPreinstall() {
|
|
618
|
+
ensureDir(HOST_ROOT);
|
|
619
|
+
ensureDir(DEPLOY_ROOT);
|
|
620
|
+
ensureDir(RELEASES_ROOT);
|
|
621
|
+
ensureDir(BACKUPS_ROOT);
|
|
622
|
+
ensureDir(ETC_ROOT);
|
|
623
|
+
ensureDir(TRAEFIK_STATE_ROOT);
|
|
624
|
+
run("bash", ["-lc", "command -v apt-get >/dev/null"]);
|
|
625
|
+
run("sudo", ["apt-get", "update"]);
|
|
626
|
+
run("sudo", ["apt-get", "install", "-y", "docker.io", "docker-compose-v2", "git", "curl", "jq", "openssl", "tar"]);
|
|
627
|
+
run("sudo", ["systemctl", "enable", "--now", "docker"]);
|
|
628
|
+
try {
|
|
629
|
+
run("docker", ["swarm", "init"]);
|
|
630
|
+
} catch {
|
|
631
|
+
}
|
|
632
|
+
run("docker", ["buildx", "version"]);
|
|
633
|
+
run("bash", ["-lc", "curl -fsSL https://ghcr.io >/dev/null"]);
|
|
634
|
+
run("bash", ["-lc", "curl -fsSL https://acme-v02.api.letsencrypt.org/directory >/dev/null"]);
|
|
635
|
+
}
|
|
636
|
+
async function cmdSetup() {
|
|
637
|
+
const rootDomain = await ask("Root domain", "example.com");
|
|
638
|
+
const acmeEmail = await ask("ACME email", `admin@${rootDomain}`);
|
|
639
|
+
const channel = await ask("Release channel", "stable");
|
|
640
|
+
const adminUser = await ask("Keycloak admin user", "admin");
|
|
641
|
+
const adminPassword = await ask("Keycloak admin password", randomSecret(12));
|
|
642
|
+
const smtpHost = await ask("SMTP host", "smtp.example.com");
|
|
643
|
+
const smtpPort = Number(await ask("SMTP port", "587"));
|
|
644
|
+
const smtpSecure = await ask("SMTP secure (true/false)", "true") === "true";
|
|
645
|
+
const smtpUser = await ask("SMTP user", "");
|
|
646
|
+
const smtpPass = await ask("SMTP pass", "");
|
|
647
|
+
const smtpFrom = await ask("SMTP from", `noreply@${rootDomain}`);
|
|
648
|
+
const retentionDays = Number(await ask("ClickStack retention days", "30"));
|
|
649
|
+
const publicAuth = await ask("Expose auth.<domain> publicly (true/false)", "true") === "true";
|
|
650
|
+
const publicObs = await ask("Expose obs.<domain> publicly (true/false)", "true") === "true";
|
|
651
|
+
const mailpitEnabled = await ask("Enable mailpit (true/false)", "false") === "true";
|
|
652
|
+
const config = {
|
|
653
|
+
domain: { root_domain: rootDomain, acme_email: acmeEmail },
|
|
654
|
+
images: {
|
|
655
|
+
api: `ghcr.io/envsync-cloud/envsync-api:${channel}`,
|
|
656
|
+
keycloak: `envsync-keycloak:${channel}`,
|
|
657
|
+
web: `ghcr.io/envsync-cloud/envsync-web-static:${channel}`,
|
|
658
|
+
landing: `ghcr.io/envsync-cloud/envsync-landing-static:${channel}`,
|
|
659
|
+
clickstack: "clickhouse/clickstack-all-in-one:latest",
|
|
660
|
+
traefik: "traefik:v3.1",
|
|
661
|
+
otel_agent: "otel/opentelemetry-collector-contrib:0.111.0"
|
|
662
|
+
},
|
|
663
|
+
services: {
|
|
664
|
+
stack_name: "envsync",
|
|
665
|
+
api_port: 4e3,
|
|
666
|
+
clickstack_ui_port: 8080,
|
|
667
|
+
clickstack_otlp_http_port: 4318,
|
|
668
|
+
clickstack_otlp_grpc_port: 4317,
|
|
669
|
+
keycloak_port: 8080,
|
|
670
|
+
rustfs_port: 9e3,
|
|
671
|
+
rustfs_console_port: 9001
|
|
672
|
+
},
|
|
673
|
+
auth: {
|
|
674
|
+
keycloak_realm: "envsync",
|
|
675
|
+
admin_user: adminUser,
|
|
676
|
+
admin_password: adminPassword,
|
|
677
|
+
web_client_id: "envsync-web",
|
|
678
|
+
api_client_id: "envsync-api",
|
|
679
|
+
cli_client_id: "envsync-cli"
|
|
680
|
+
},
|
|
681
|
+
observability: {
|
|
682
|
+
retention_days: retentionDays,
|
|
683
|
+
public_obs: publicObs
|
|
684
|
+
},
|
|
685
|
+
backup: {
|
|
686
|
+
output_dir: BACKUPS_ROOT,
|
|
687
|
+
encrypted: true
|
|
688
|
+
},
|
|
689
|
+
smtp: {
|
|
690
|
+
host: smtpHost,
|
|
691
|
+
port: smtpPort,
|
|
692
|
+
secure: smtpSecure,
|
|
693
|
+
user: smtpUser,
|
|
694
|
+
pass: smtpPass,
|
|
695
|
+
from: smtpFrom
|
|
696
|
+
},
|
|
697
|
+
exposure: {
|
|
698
|
+
public_auth: publicAuth,
|
|
699
|
+
public_obs: publicObs,
|
|
700
|
+
mailpit_enabled: mailpitEnabled,
|
|
701
|
+
s3_public: true,
|
|
702
|
+
s3_console_public: true
|
|
703
|
+
},
|
|
704
|
+
release_channel: channel
|
|
705
|
+
};
|
|
706
|
+
ensureDir(REPO_ROOT);
|
|
707
|
+
if (!exists(path.join(REPO_ROOT, ".git"))) {
|
|
708
|
+
run("git", ["clone", "https://github.com/EnvSync-Cloud/envsync.git", REPO_ROOT]);
|
|
709
|
+
}
|
|
710
|
+
saveConfig(config);
|
|
711
|
+
console.log(`Config written to ${DEPLOY_YAML}`);
|
|
712
|
+
console.log("Create these DNS records:");
|
|
713
|
+
console.log(JSON.stringify(domainMap(rootDomain), null, 2));
|
|
714
|
+
}
|
|
715
|
+
function extractStaticBundle(image, targetDir) {
|
|
716
|
+
ensureDir(targetDir);
|
|
717
|
+
const containerId = run("docker", ["create", image], { quiet: true }).trim();
|
|
718
|
+
try {
|
|
719
|
+
run("docker", ["cp", `${containerId}:/app/dist/.`, targetDir]);
|
|
720
|
+
} finally {
|
|
721
|
+
run("docker", ["rm", "-f", containerId], { quiet: true });
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
function buildKeycloakImage(imageTag, repoRoot = REPO_ROOT) {
|
|
725
|
+
const buildContext = path.join(repoRoot, "packages/envsync-keycloak-theme");
|
|
726
|
+
if (!exists(path.join(buildContext, "Dockerfile"))) {
|
|
727
|
+
throw new Error(`Missing Keycloak Docker build context at ${buildContext}`);
|
|
728
|
+
}
|
|
729
|
+
run("docker", ["build", "-t", imageTag, buildContext]);
|
|
730
|
+
}
|
|
731
|
+
async function cmdDeploy() {
|
|
732
|
+
const config = loadConfig();
|
|
733
|
+
buildKeycloakImage(config.images.keycloak);
|
|
734
|
+
ensureDir(`${RELEASES_ROOT}/web/current`);
|
|
735
|
+
ensureDir(`${RELEASES_ROOT}/landing/current`);
|
|
736
|
+
extractStaticBundle(config.images.web, `${RELEASES_ROOT}/web/current`);
|
|
737
|
+
extractStaticBundle(config.images.landing, `${RELEASES_ROOT}/landing/current`);
|
|
738
|
+
run("docker", ["stack", "deploy", "-c", STACK_FILE, config.services.stack_name]);
|
|
739
|
+
}
|
|
740
|
+
async function cmdHealth(asJson) {
|
|
741
|
+
const config = loadConfig();
|
|
742
|
+
const hosts = domainMap(config.domain.root_domain);
|
|
743
|
+
const services = run("docker", ["stack", "services", config.services.stack_name], { quiet: true });
|
|
744
|
+
const checks = {
|
|
745
|
+
keycloak_image: config.images.keycloak,
|
|
746
|
+
services,
|
|
747
|
+
public: {
|
|
748
|
+
landing: `https://${hosts.landing}`,
|
|
749
|
+
app: `https://${hosts.app}`,
|
|
750
|
+
api: `https://${hosts.api}/health`,
|
|
751
|
+
auth: `https://${hosts.auth}/realms/${config.auth.keycloak_realm}/.well-known/openid-configuration`,
|
|
752
|
+
obs: `https://${hosts.obs}`
|
|
753
|
+
}
|
|
754
|
+
};
|
|
755
|
+
if (asJson) console.log(JSON.stringify(checks, null, 2));
|
|
756
|
+
else {
|
|
757
|
+
console.log(services);
|
|
758
|
+
console.log(JSON.stringify(checks.public, null, 2));
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
async function cmdUpgrade() {
|
|
762
|
+
const config = loadConfig();
|
|
763
|
+
const nextImage = `ghcr.io/envsync-cloud/envsync-api:${config.release_channel}`;
|
|
764
|
+
config.images.api = nextImage;
|
|
765
|
+
saveConfig(config);
|
|
766
|
+
await cmdDeploy();
|
|
767
|
+
}
|
|
768
|
+
async function cmdUpgradeDeps() {
|
|
769
|
+
const config = loadConfig();
|
|
770
|
+
config.images.traefik = "traefik:v3.1";
|
|
771
|
+
config.images.clickstack = "clickhouse/clickstack-all-in-one:latest";
|
|
772
|
+
config.images.otel_agent = "otel/opentelemetry-collector-contrib:0.111.0";
|
|
773
|
+
saveConfig(config);
|
|
774
|
+
await cmdDeploy();
|
|
775
|
+
}
|
|
776
|
+
function sha256File(filePath) {
|
|
777
|
+
const hash = createHash("sha256");
|
|
778
|
+
hash.update(fs.readFileSync(filePath));
|
|
779
|
+
return hash.digest("hex");
|
|
780
|
+
}
|
|
781
|
+
function stackVolumeName(config, name) {
|
|
782
|
+
return `${config.services.stack_name}_${name}`;
|
|
783
|
+
}
|
|
784
|
+
function backupDockerVolume(volumeName, targetDir) {
|
|
785
|
+
ensureDir(targetDir);
|
|
786
|
+
run("docker", [
|
|
787
|
+
"run",
|
|
788
|
+
"--rm",
|
|
789
|
+
"-v",
|
|
790
|
+
`${volumeName}:/from:ro`,
|
|
791
|
+
"-v",
|
|
792
|
+
`${targetDir}:/to`,
|
|
793
|
+
"alpine:3.20",
|
|
794
|
+
"sh",
|
|
795
|
+
"-lc",
|
|
796
|
+
"cd /from && tar -czf /to/volume.tar.gz ."
|
|
797
|
+
]);
|
|
798
|
+
}
|
|
799
|
+
function restoreDockerVolume(volumeName, sourceDir) {
|
|
800
|
+
run("docker", ["volume", "create", volumeName], { quiet: true });
|
|
801
|
+
run("docker", [
|
|
802
|
+
"run",
|
|
803
|
+
"--rm",
|
|
804
|
+
"-v",
|
|
805
|
+
`${volumeName}:/to`,
|
|
806
|
+
"-v",
|
|
807
|
+
`${sourceDir}:/from:ro`,
|
|
808
|
+
"alpine:3.20",
|
|
809
|
+
"sh",
|
|
810
|
+
"-lc",
|
|
811
|
+
"cd /to && tar -xzf /from/volume.tar.gz"
|
|
812
|
+
]);
|
|
813
|
+
}
|
|
814
|
+
async function cmdBackup() {
|
|
815
|
+
const config = loadConfig();
|
|
816
|
+
ensureDir(config.backup.output_dir);
|
|
817
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:]/g, "-");
|
|
818
|
+
const archiveBase = path.join(config.backup.output_dir, `envsync-backup-${timestamp}`);
|
|
819
|
+
const manifestPath = `${archiveBase}.manifest.json`;
|
|
820
|
+
const tarPath = `${archiveBase}.tar.gz`;
|
|
821
|
+
const staged = path.join(BACKUPS_ROOT, `staging-${timestamp}`);
|
|
822
|
+
ensureDir(staged);
|
|
823
|
+
writeFile(path.join(staged, "deploy.env"), fs.readFileSync(DEPLOY_ENV, "utf8"));
|
|
824
|
+
writeFile(path.join(staged, "deploy.yaml"), fs.readFileSync(DEPLOY_YAML, "utf8"));
|
|
825
|
+
writeFile(path.join(staged, "config.json"), fs.readFileSync(INTERNAL_CONFIG_JSON, "utf8"));
|
|
826
|
+
writeFile(path.join(staged, "versions.lock.json"), fs.readFileSync(VERSIONS_LOCK, "utf8"));
|
|
827
|
+
writeFile(path.join(staged, "docker-stack.yaml"), fs.readFileSync(STACK_FILE, "utf8"));
|
|
828
|
+
writeFile(path.join(staged, "traefik-dynamic.yaml"), fs.readFileSync(TRAEFIK_DYNAMIC_FILE, "utf8"));
|
|
829
|
+
writeFile(path.join(staged, "keycloak-realm.envsync.json"), fs.readFileSync(KEYCLOAK_REALM_FILE, "utf8"));
|
|
830
|
+
writeFile(path.join(staged, "otel-agent.yaml"), fs.readFileSync(OTEL_AGENT_CONF, "utf8"));
|
|
831
|
+
const volumesDir = path.join(staged, "volumes");
|
|
832
|
+
for (const volume of STACK_VOLUMES) {
|
|
833
|
+
const target = path.join(volumesDir, volume);
|
|
834
|
+
backupDockerVolume(stackVolumeName(config, volume), target);
|
|
835
|
+
}
|
|
836
|
+
run("bash", ["-lc", `tar -czf ${JSON.stringify(tarPath)} -C ${JSON.stringify(staged)} .`]);
|
|
837
|
+
const manifest = {
|
|
838
|
+
archive: path.basename(tarPath),
|
|
839
|
+
sha256: sha256File(tarPath),
|
|
840
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
841
|
+
stack_name: config.services.stack_name,
|
|
842
|
+
volumes: STACK_VOLUMES.map((volume) => stackVolumeName(config, volume))
|
|
843
|
+
};
|
|
844
|
+
writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
|
|
845
|
+
console.log(tarPath);
|
|
846
|
+
}
|
|
847
|
+
async function cmdRestore(archivePath) {
|
|
848
|
+
if (!archivePath) throw new Error("restore requires a .tar.gz path");
|
|
849
|
+
const config = loadConfig();
|
|
850
|
+
const restoreRoot = path.join(BACKUPS_ROOT, `restore-${Date.now()}`);
|
|
851
|
+
ensureDir(restoreRoot);
|
|
852
|
+
run("bash", ["-lc", `tar -xzf ${JSON.stringify(archivePath)} -C ${JSON.stringify(restoreRoot)}`]);
|
|
853
|
+
writeFile(DEPLOY_ENV, fs.readFileSync(path.join(restoreRoot, "deploy.env"), "utf8"), 384);
|
|
854
|
+
writeFile(DEPLOY_YAML, fs.readFileSync(path.join(restoreRoot, "deploy.yaml"), "utf8"));
|
|
855
|
+
writeFile(INTERNAL_CONFIG_JSON, fs.readFileSync(path.join(restoreRoot, "config.json"), "utf8"));
|
|
856
|
+
writeFile(VERSIONS_LOCK, fs.readFileSync(path.join(restoreRoot, "versions.lock.json"), "utf8"));
|
|
857
|
+
writeFile(STACK_FILE, fs.readFileSync(path.join(restoreRoot, "docker-stack.yaml"), "utf8"));
|
|
858
|
+
writeFile(TRAEFIK_DYNAMIC_FILE, fs.readFileSync(path.join(restoreRoot, "traefik-dynamic.yaml"), "utf8"));
|
|
859
|
+
writeFile(KEYCLOAK_REALM_FILE, fs.readFileSync(path.join(restoreRoot, "keycloak-realm.envsync.json"), "utf8"));
|
|
860
|
+
writeFile(OTEL_AGENT_CONF, fs.readFileSync(path.join(restoreRoot, "otel-agent.yaml"), "utf8"));
|
|
861
|
+
for (const volume of STACK_VOLUMES) {
|
|
862
|
+
restoreDockerVolume(stackVolumeName(config, volume), path.join(restoreRoot, "volumes", volume));
|
|
863
|
+
}
|
|
864
|
+
await cmdDeploy();
|
|
865
|
+
}
|
|
866
|
+
async function main() {
|
|
867
|
+
const command = process.argv[2];
|
|
868
|
+
const flag = process.argv[3];
|
|
869
|
+
switch (command) {
|
|
870
|
+
case "preinstall":
|
|
871
|
+
await cmdPreinstall();
|
|
872
|
+
break;
|
|
873
|
+
case "setup":
|
|
874
|
+
await cmdSetup();
|
|
875
|
+
break;
|
|
876
|
+
case "deploy":
|
|
877
|
+
await cmdDeploy();
|
|
878
|
+
break;
|
|
879
|
+
case "health":
|
|
880
|
+
await cmdHealth(flag === "--json");
|
|
881
|
+
break;
|
|
882
|
+
case "upgrade":
|
|
883
|
+
await cmdUpgrade();
|
|
884
|
+
break;
|
|
885
|
+
case "upgrade-deps":
|
|
886
|
+
await cmdUpgradeDeps();
|
|
887
|
+
break;
|
|
888
|
+
case "backup":
|
|
889
|
+
await cmdBackup();
|
|
890
|
+
break;
|
|
891
|
+
case "restore":
|
|
892
|
+
await cmdRestore(flag ?? "");
|
|
893
|
+
break;
|
|
894
|
+
default:
|
|
895
|
+
console.log("Usage: envsync-deploy <preinstall|setup|deploy|health|upgrade|upgrade-deps|backup|restore>");
|
|
896
|
+
process.exit(command ? 1 : 0);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
main().catch((err) => {
|
|
900
|
+
console.error(err instanceof Error ? err.message : err);
|
|
901
|
+
process.exit(1);
|
|
902
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@envsync-cloud/deploy-cli",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "CLI for self-hosted EnvSync deployment on Docker Swarm",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"envsync-deploy": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsup --config tsup.config.ts",
|
|
19
|
+
"prepack": "bun run build",
|
|
20
|
+
"check:pack": "npm pack --dry-run",
|
|
21
|
+
"start": "bun run src/index.ts"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/EnvSync-Cloud/envsync.git",
|
|
26
|
+
"directory": "packages/deploy-cli"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/EnvSync-Cloud/envsync/tree/main/packages/deploy-cli#readme",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/EnvSync-Cloud/envsync/issues"
|
|
31
|
+
},
|
|
32
|
+
"author": "EnvSync Cloud",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"keywords": [
|
|
35
|
+
"envsync",
|
|
36
|
+
"deploy",
|
|
37
|
+
"self-hosted",
|
|
38
|
+
"docker-swarm",
|
|
39
|
+
"cli",
|
|
40
|
+
"secrets"
|
|
41
|
+
],
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"tsup": "^8.5.0",
|
|
44
|
+
"typescript": "^5.9.2"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
}
|
|
49
|
+
}
|