easy-vps 0.1.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/bin/cli.js +180 -0
- package/client/assets/index-C4GZ_uMC.js +104 -0
- package/client/assets/index-Dq-W19hL.css +2 -0
- package/client/assets/poppins-devanagari-400-normal-CJDn6rn8.woff2 +0 -0
- package/client/assets/poppins-devanagari-400-normal-CqVvlrh5.woff +0 -0
- package/client/assets/poppins-latin-400-normal-BOb3E3N0.woff +0 -0
- package/client/assets/poppins-latin-400-normal-cpxAROuN.woff2 +0 -0
- package/client/assets/poppins-latin-ext-400-normal-DaBSavcJ.woff +0 -0
- package/client/assets/poppins-latin-ext-400-normal-by3JarPu.woff2 +0 -0
- package/client/favicon.svg +1 -0
- package/client/icons.svg +24 -0
- package/client/index.html +14 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +143 -0
- package/dist/routes/async-route.d.ts +3 -0
- package/dist/routes/async-route.js +9 -0
- package/dist/routes/auth.d.ts +5 -0
- package/dist/routes/auth.js +101 -0
- package/dist/routes/database.d.ts +2 -0
- package/dist/routes/database.js +192 -0
- package/dist/routes/deploy.d.ts +2 -0
- package/dist/routes/deploy.js +161 -0
- package/dist/routes/domain.d.ts +2 -0
- package/dist/routes/domain.js +54 -0
- package/dist/routes/firewall.d.ts +2 -0
- package/dist/routes/firewall.js +63 -0
- package/dist/routes/instances.d.ts +2 -0
- package/dist/routes/instances.js +77 -0
- package/dist/routes/logs.d.ts +2 -0
- package/dist/routes/logs.js +67 -0
- package/dist/routes/system.d.ts +2 -0
- package/dist/routes/system.js +83 -0
- package/dist/services/auth.d.ts +25 -0
- package/dist/services/auth.js +128 -0
- package/dist/services/backup-config.d.ts +16 -0
- package/dist/services/backup-config.js +51 -0
- package/dist/services/backup-scheduler.d.ts +2 -0
- package/dist/services/backup-scheduler.js +104 -0
- package/dist/services/daemon.d.ts +28 -0
- package/dist/services/daemon.js +180 -0
- package/dist/services/database.d.ts +60 -0
- package/dist/services/database.js +540 -0
- package/dist/services/deploy.d.ts +102 -0
- package/dist/services/deploy.js +540 -0
- package/dist/services/domain.d.ts +26 -0
- package/dist/services/domain.js +170 -0
- package/dist/services/firewall.d.ts +24 -0
- package/dist/services/firewall.js +64 -0
- package/dist/services/instances.d.ts +17 -0
- package/dist/services/instances.js +67 -0
- package/dist/services/logs.d.ts +8 -0
- package/dist/services/logs.js +61 -0
- package/dist/services/metrics-history.d.ts +13 -0
- package/dist/services/metrics-history.js +36 -0
- package/dist/services/packages.d.ts +22 -0
- package/dist/services/packages.js +124 -0
- package/dist/services/s3.d.ts +17 -0
- package/dist/services/s3.js +93 -0
- package/dist/services/ssh.d.ts +78 -0
- package/dist/services/ssh.js +309 -0
- package/dist/services/system.d.ts +74 -0
- package/dist/services/system.js +286 -0
- package/package.json +55 -0
- package/scripts/dev.js +88 -0
- package/scripts/postinstall.js +123 -0
- package/scripts/preuninstall.js +21 -0
- package/scripts/try-install.sh +65 -0
- package/scripts/ui.js +54 -0
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.startPooler = startPooler;
|
|
40
|
+
exports.list = list;
|
|
41
|
+
exports.createDatabase = createDatabase;
|
|
42
|
+
exports.removeDatabase = removeDatabase;
|
|
43
|
+
exports.backup = backup;
|
|
44
|
+
exports.restore = restore;
|
|
45
|
+
exports.downloadDump = downloadDump;
|
|
46
|
+
exports.backupToS3 = backupToS3;
|
|
47
|
+
exports.backupStream = backupStream;
|
|
48
|
+
exports.readBackupFromS3 = readBackupFromS3;
|
|
49
|
+
exports.listBackupsFromS3 = listBackupsFromS3;
|
|
50
|
+
exports.restoreFromS3 = restoreFromS3;
|
|
51
|
+
// Database management via Docker containers on the managed VPS.
|
|
52
|
+
const node_fs_1 = require("node:fs");
|
|
53
|
+
const node_os_1 = require("node:os");
|
|
54
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
55
|
+
const ssh_1 = require("./ssh");
|
|
56
|
+
const backup_config_1 = require("./backup-config");
|
|
57
|
+
const s3_1 = require("./s3");
|
|
58
|
+
const ENGINE_IMAGE = {
|
|
59
|
+
postgres: 'postgres:16-alpine',
|
|
60
|
+
mysql: 'mysql:8',
|
|
61
|
+
mongodb: 'mongo:7',
|
|
62
|
+
};
|
|
63
|
+
const ENGINE_ENV = {
|
|
64
|
+
postgres: (c) => [
|
|
65
|
+
`-e POSTGRES_USER=${(0, ssh_1.quote)(c.user)}`,
|
|
66
|
+
`-e POSTGRES_PASSWORD=${(0, ssh_1.quote)(c.password)}`,
|
|
67
|
+
],
|
|
68
|
+
mysql: (c) => [
|
|
69
|
+
`-e MYSQL_ROOT_PASSWORD=${(0, ssh_1.quote)(c.password)}`,
|
|
70
|
+
`-e MYSQL_USER=${(0, ssh_1.quote)(c.user)}`,
|
|
71
|
+
`-e MYSQL_PASSWORD=${(0, ssh_1.quote)(c.password)}`,
|
|
72
|
+
],
|
|
73
|
+
mongodb: (c) => [
|
|
74
|
+
`-e MONGO_INITDB_ROOT_USERNAME=${(0, ssh_1.quote)(c.user)}`,
|
|
75
|
+
`-e MONGO_INITDB_ROOT_PASSWORD=${(0, ssh_1.quote)(c.password)}`,
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
const LABEL = 'easy-vps=database';
|
|
79
|
+
/** Default internal port inside the container for each engine. */
|
|
80
|
+
const INTERNAL_PORT = {
|
|
81
|
+
postgres: 5432,
|
|
82
|
+
mysql: 3306,
|
|
83
|
+
mongodb: 27017,
|
|
84
|
+
};
|
|
85
|
+
/** Internal listen port for the connection pooler of each engine (0 = none). */
|
|
86
|
+
const POOLED_INTERNAL_PORT = {
|
|
87
|
+
postgres: 6432,
|
|
88
|
+
mysql: 6033,
|
|
89
|
+
mongodb: 0,
|
|
90
|
+
};
|
|
91
|
+
/** Docker image used for the connection pooler of each engine. */
|
|
92
|
+
const POOLER_IMAGE = {
|
|
93
|
+
postgres: 'pgbouncer/pgbouncer:latest',
|
|
94
|
+
mysql: 'proxysql/proxysql:latest',
|
|
95
|
+
};
|
|
96
|
+
const POOLER_LABEL = 'easy-vps=pooler';
|
|
97
|
+
/** Config files (written to the VPS) for a database's pooler container. */
|
|
98
|
+
function poolerConfigFiles(engine, config) {
|
|
99
|
+
if (engine === 'postgres') {
|
|
100
|
+
const userlist = `"${config.user}" "${config.password}"\n`;
|
|
101
|
+
const clientCap = Math.max(100, config.poolSize * 10);
|
|
102
|
+
const ini = [
|
|
103
|
+
'[databases]',
|
|
104
|
+
`${config.name} = host=${config.name} port=5432 dbname=${config.name}`,
|
|
105
|
+
'',
|
|
106
|
+
'[pgbouncer]',
|
|
107
|
+
'listen_addr = 0.0.0.0',
|
|
108
|
+
'listen_port = 6432',
|
|
109
|
+
'auth_type = md5',
|
|
110
|
+
'auth_file = /etc/pgbouncer/userlist.txt',
|
|
111
|
+
'pool_mode = transaction',
|
|
112
|
+
`max_client_conn = ${clientCap}`,
|
|
113
|
+
`default_pool_size = ${config.poolSize}`,
|
|
114
|
+
`max_user_connections = ${config.poolSize}`,
|
|
115
|
+
'',
|
|
116
|
+
].join('\n');
|
|
117
|
+
return [
|
|
118
|
+
{ remote: `/tmp/${config.name}-pgbouncer.ini`, content: ini, mount: '/etc/pgbouncer/pgbouncer.ini' },
|
|
119
|
+
{ remote: `/tmp/${config.name}-userlist.txt`, content: userlist, mount: '/etc/pgbouncer/userlist.txt' },
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
if (engine === 'mysql') {
|
|
123
|
+
const clientCap = Math.max(100, config.poolSize * 10);
|
|
124
|
+
const cnf = [
|
|
125
|
+
'datadir="/var/lib/proxysql"',
|
|
126
|
+
'admin_variables=',
|
|
127
|
+
'{',
|
|
128
|
+
' admin_credentials="admin:admin"',
|
|
129
|
+
' mysql_ifaces="0.0.0.0:6032"',
|
|
130
|
+
'}',
|
|
131
|
+
'mysql_variables=',
|
|
132
|
+
'{',
|
|
133
|
+
' interfaces="0.0.0.0:6033"',
|
|
134
|
+
' default_schema="mysql"',
|
|
135
|
+
` max_connections = ${clientCap}`,
|
|
136
|
+
'}',
|
|
137
|
+
'mysql_servers=',
|
|
138
|
+
'(',
|
|
139
|
+
` { address="${config.name}", port=3306, hostgroup=0 }`,
|
|
140
|
+
')',
|
|
141
|
+
'mysql_users=',
|
|
142
|
+
'(',
|
|
143
|
+
` { username="${config.user}", password="${config.password}", default_hostgroup=0, max_connections=${config.poolSize} }`,
|
|
144
|
+
')',
|
|
145
|
+
'',
|
|
146
|
+
].join('\n');
|
|
147
|
+
return [{ remote: `/tmp/${config.name}-proxysql.cnf`, content: cnf, mount: '/etc/proxysql.cnf' }];
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
/** Start the pooler sidecar for an engine that has one. Returns the host port, or 0. */
|
|
152
|
+
async function startPooler(remote, config) {
|
|
153
|
+
const internal = POOLED_INTERNAL_PORT[config.engine];
|
|
154
|
+
const image = POOLER_IMAGE[config.engine];
|
|
155
|
+
if (!internal || !image)
|
|
156
|
+
return 0;
|
|
157
|
+
await remote.exec((0, ssh_1.loginShell)(`docker rm -f ${(0, ssh_1.quote)(config.name)}-pooler >/dev/null 2>&1 || true`), 10_000);
|
|
158
|
+
const files = poolerConfigFiles(config.engine, config);
|
|
159
|
+
if (!files)
|
|
160
|
+
return 0;
|
|
161
|
+
for (const file of files)
|
|
162
|
+
await remote.writeFile(file.remote, file.content);
|
|
163
|
+
const pooledHostPort = await findPort(remote, internal);
|
|
164
|
+
const mounts = files.map((f) => `-v ${(0, ssh_1.quote)(f.remote)}:${(0, ssh_1.quote)(f.mount)}`).join(' ');
|
|
165
|
+
const poolerCmd = [
|
|
166
|
+
'docker run -d',
|
|
167
|
+
`--name ${(0, ssh_1.quote)(config.name)}-pooler`,
|
|
168
|
+
`--restart unless-stopped`,
|
|
169
|
+
`-l ${POOLER_LABEL} -l db=${(0, ssh_1.quote)(config.name)} -l engine=${config.engine}`,
|
|
170
|
+
`-p ${pooledHostPort}:${internal}`,
|
|
171
|
+
mounts,
|
|
172
|
+
image,
|
|
173
|
+
]
|
|
174
|
+
.filter(Boolean)
|
|
175
|
+
.join(' ');
|
|
176
|
+
await remote.exec((0, ssh_1.loginShell)(await remote.privileged(poolerCmd)), 120_000);
|
|
177
|
+
return pooledHostPort;
|
|
178
|
+
}
|
|
179
|
+
/** Tear down the pooler sidecar for a database (container + config files). */
|
|
180
|
+
async function removePooler(remote, name) {
|
|
181
|
+
await remote.exec((0, ssh_1.loginShell)(`docker rm -f ${(0, ssh_1.quote)(name)}-pooler >/dev/null 2>&1 || true`), 15_000);
|
|
182
|
+
await remote.exec((0, ssh_1.loginShell)(`rm -f ${(0, ssh_1.quote)(`/tmp/${name}-pgbouncer.ini`)} ${(0, ssh_1.quote)(`/tmp/${name}-userlist.txt`)} ${(0, ssh_1.quote)(`/tmp/${name}-proxysql.cnf`)} >/dev/null 2>&1 || true`), 10_000);
|
|
183
|
+
}
|
|
184
|
+
/** Extract a port number from a `local-address:port` token (IPv4 or IPv6). */
|
|
185
|
+
function parseListeningPort(token) {
|
|
186
|
+
// IPv6 like [::]:27017 or ::1:27017 — the trailing :<digits> is the port.
|
|
187
|
+
// IPv4 like 0.0.0.0:27017 or 127.0.0.1:27017.
|
|
188
|
+
const match = token.match(/:(\d+)$/);
|
|
189
|
+
return match ? Number(match[1]) : null;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Get every host TCP port currently in use, from the OS listen table. This
|
|
193
|
+
* catches ports held by Docker containers AND by any other process (e.g. a
|
|
194
|
+
* natively installed mongod), and does not depend on `docker ps` output or on
|
|
195
|
+
* sudo for `-p`. Combines `ss`/`netstat` with a best-effort Docker scan.
|
|
196
|
+
*/
|
|
197
|
+
async function usedPorts(remote) {
|
|
198
|
+
const ports = new Set();
|
|
199
|
+
const scan = async (command) => {
|
|
200
|
+
const { stdout, code } = await remote.exec((0, ssh_1.loginShell)(await remote.privileged(command)), 10_000);
|
|
201
|
+
return code === 0 ? stdout : null;
|
|
202
|
+
};
|
|
203
|
+
// Primary: host listening sockets (no sudo needed for -tln listen table).
|
|
204
|
+
const socketOut = (await scan('ss -tlnp')) ?? (await scan('ss -tln')) ?? (await scan('netstat -tlnp')) ?? null;
|
|
205
|
+
if (socketOut) {
|
|
206
|
+
for (const line of socketOut.split('\n')) {
|
|
207
|
+
for (const token of line.trim().split(/\s+/)) {
|
|
208
|
+
const port = parseListeningPort(token);
|
|
209
|
+
if (port) {
|
|
210
|
+
ports.add(port);
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// Secondary best-effort: Docker-published host ports. Some setups expose
|
|
217
|
+
// containers that `ss` may omit (e.g. host-network mode); this backs that up.
|
|
218
|
+
const dockerOut = await scan("docker ps -a --format '{{.Ports}}'");
|
|
219
|
+
if (dockerOut) {
|
|
220
|
+
for (const entry of dockerOut.split(',')) {
|
|
221
|
+
const match = entry.match(/:(\d+)->/);
|
|
222
|
+
if (match)
|
|
223
|
+
ports.add(Number(match[1]));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return ports;
|
|
227
|
+
}
|
|
228
|
+
/** Find the next available host port starting from `preferred`. */
|
|
229
|
+
async function findPort(remote, preferred) {
|
|
230
|
+
if (preferred === 0)
|
|
231
|
+
return 0;
|
|
232
|
+
const taken = await usedPorts(remote);
|
|
233
|
+
let port = preferred;
|
|
234
|
+
while (taken.has(port))
|
|
235
|
+
port++;
|
|
236
|
+
return port;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Ensure Docker is installed and the daemon is running.
|
|
240
|
+
* Installs docker.io via apt if missing, then starts the service.
|
|
241
|
+
*/
|
|
242
|
+
async function ensureDocker(remote) {
|
|
243
|
+
// Check if Docker binary exists
|
|
244
|
+
const { code: binExists } = await remote.exec((0, ssh_1.loginShell)('command -v docker'), 10_000);
|
|
245
|
+
if (binExists !== 0) {
|
|
246
|
+
// Install Docker
|
|
247
|
+
await remote.execOrThrow((0, ssh_1.loginShell)(await remote.privileged('export DEBIAN_FRONTEND=noninteractive; apt-get update && apt-get install -y docker.io')), 180_000);
|
|
248
|
+
}
|
|
249
|
+
// Check if Docker daemon is responsive
|
|
250
|
+
const { code: daemonUp } = await remote.exec((0, ssh_1.loginShell)('docker info >/dev/null 2>&1'), 10_000);
|
|
251
|
+
if (daemonUp === 0)
|
|
252
|
+
return;
|
|
253
|
+
// Try starting Docker with service command (works without systemd)
|
|
254
|
+
await remote.exec((0, ssh_1.loginShell)(await remote.privileged('service docker start')), 15_000);
|
|
255
|
+
// Verify it came up
|
|
256
|
+
const { code: check } = await remote.exec((0, ssh_1.loginShell)('docker info >/dev/null 2>&1'), 10_000);
|
|
257
|
+
if (check !== 0) {
|
|
258
|
+
throw new Error('Docker is installed but the daemon could not be started. ' +
|
|
259
|
+
'Check that Docker is properly installed on this server.');
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/** List all databases managed by easy-vps. */
|
|
263
|
+
async function list(remote) {
|
|
264
|
+
const format = '{{.Names}}\\t{{.Labels}}\\t{{.Ports}}\\t{{.State}}';
|
|
265
|
+
const { stdout, code } = await remote.exec((0, ssh_1.loginShell)(await remote.privileged(`docker ps -a --filter label=easy-vps --format "${format}"`)), 10_000);
|
|
266
|
+
if (code !== 0)
|
|
267
|
+
return [];
|
|
268
|
+
const pooledPorts = new Map();
|
|
269
|
+
const instances = [];
|
|
270
|
+
for (const line of stdout.split('\n')) {
|
|
271
|
+
const trimmed = line.trim();
|
|
272
|
+
if (!trimmed)
|
|
273
|
+
continue;
|
|
274
|
+
const [name, labels, ports, state] = trimmed.split('\t');
|
|
275
|
+
// Parse labels: easy-vps=database,engine=postgres,user=admin
|
|
276
|
+
const labelMap = new Map();
|
|
277
|
+
for (const part of (labels || '').split(',')) {
|
|
278
|
+
const [k, v] = part.split('=');
|
|
279
|
+
if (k)
|
|
280
|
+
labelMap.set(k.trim(), v?.trim() ?? '');
|
|
281
|
+
}
|
|
282
|
+
// A pooler sidecar (easy-vps=pooler, db=<name>) only contributes its port.
|
|
283
|
+
if (labelMap.get('easy-vps') === 'pooler') {
|
|
284
|
+
const dbName = labelMap.get('db') || '';
|
|
285
|
+
const portMatch = ports?.match(/:(\d+)->/);
|
|
286
|
+
if (dbName && portMatch)
|
|
287
|
+
pooledPorts.set(dbName, Number(portMatch[1]));
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (labelMap.get('easy-vps') !== 'database')
|
|
291
|
+
continue;
|
|
292
|
+
const engine = (labelMap.get('engine') || 'postgres');
|
|
293
|
+
const user = labelMap.get('user') || '';
|
|
294
|
+
const password = labelMap.get('password') || '';
|
|
295
|
+
const poolSize = Number(labelMap.get('pool_size')) || 10;
|
|
296
|
+
// Extract host port from "0.0.0.0:5432->5432/tcp" format
|
|
297
|
+
let port = 0;
|
|
298
|
+
const portMatch = ports?.match(/:(\d+)->/);
|
|
299
|
+
if (portMatch)
|
|
300
|
+
port = Number(portMatch[1]);
|
|
301
|
+
let status = 'unknown';
|
|
302
|
+
if (state === 'running')
|
|
303
|
+
status = 'running';
|
|
304
|
+
else if (state === 'exited' || state === 'stopped')
|
|
305
|
+
status = 'stopped';
|
|
306
|
+
instances.push({
|
|
307
|
+
name,
|
|
308
|
+
engine,
|
|
309
|
+
user,
|
|
310
|
+
password,
|
|
311
|
+
port,
|
|
312
|
+
host: remote.creds.host,
|
|
313
|
+
status,
|
|
314
|
+
pooledPort: 0,
|
|
315
|
+
poolSize,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
for (const instance of instances) {
|
|
319
|
+
instance.pooledPort = pooledPorts.get(instance.name) ?? 0;
|
|
320
|
+
}
|
|
321
|
+
return instances;
|
|
322
|
+
}
|
|
323
|
+
/** Create a new database container. */
|
|
324
|
+
async function createDatabase(remote, config) {
|
|
325
|
+
await ensureDocker(remote);
|
|
326
|
+
const image = ENGINE_IMAGE[config.engine];
|
|
327
|
+
if (!image)
|
|
328
|
+
throw new Error(`Unsupported engine: ${config.engine}`);
|
|
329
|
+
// Stop and remove any existing container with the same name
|
|
330
|
+
await remote.exec((0, ssh_1.loginShell)(`docker rm -f ${(0, ssh_1.quote)(config.name)} >/dev/null 2>&1 || true`), 10_000);
|
|
331
|
+
const envArgs = ENGINE_ENV[config.engine](config).join(' ');
|
|
332
|
+
const internalPort = INTERNAL_PORT[config.engine];
|
|
333
|
+
const hostPort = await findPort(remote, config.port);
|
|
334
|
+
const portArg = hostPort ? `-p ${hostPort}:${internalPort}` : '';
|
|
335
|
+
const labels = `-l ${LABEL} -l engine=${config.engine} -l user=${(0, ssh_1.quote)(config.user)} -l password=${(0, ssh_1.quote)(config.password)} -l pool_size=${config.poolSize}`;
|
|
336
|
+
const command = [
|
|
337
|
+
'docker run -d',
|
|
338
|
+
`--name ${(0, ssh_1.quote)(config.name)}`,
|
|
339
|
+
'--restart unless-stopped',
|
|
340
|
+
labels,
|
|
341
|
+
envArgs,
|
|
342
|
+
portArg,
|
|
343
|
+
image,
|
|
344
|
+
]
|
|
345
|
+
.filter(Boolean)
|
|
346
|
+
.join(' ');
|
|
347
|
+
const result = await remote.exec((0, ssh_1.loginShell)(await remote.privileged(command)), 120_000);
|
|
348
|
+
if (result.code !== 0) {
|
|
349
|
+
const detail = result.stderr.trim() || 'Container start failed';
|
|
350
|
+
throw new Error(detail);
|
|
351
|
+
}
|
|
352
|
+
const pooledPort = await startPooler(remote, config);
|
|
353
|
+
return { port: hostPort, host: remote.creds.host, pooledPort };
|
|
354
|
+
}
|
|
355
|
+
/** Remove a database container. */
|
|
356
|
+
async function removeDatabase(remote, name) {
|
|
357
|
+
await ensureDocker(remote);
|
|
358
|
+
// Stop first, then remove — rm -f handles both but stop first is cleaner
|
|
359
|
+
await remote.exec((0, ssh_1.loginShell)(await remote.privileged(`docker stop ${(0, ssh_1.quote)(name)} 2>/dev/null || true`)), 15_000);
|
|
360
|
+
await remote.exec((0, ssh_1.loginShell)(await remote.privileged(`docker rm ${(0, ssh_1.quote)(name)} 2>/dev/null || true`)), 10_000);
|
|
361
|
+
await removePooler(remote, name);
|
|
362
|
+
// Verify removal
|
|
363
|
+
const check = await remote.exec((0, ssh_1.loginShell)(`docker inspect ${(0, ssh_1.quote)(name)} 2>/dev/null`), 10_000);
|
|
364
|
+
if (check.code === 0) {
|
|
365
|
+
throw new Error(`Container ${name} could not be removed`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/** Backup a database using pg_dump. */
|
|
369
|
+
async function backup(remote, name) {
|
|
370
|
+
const dumpPath = `/tmp/${name}-$(date +%Y%m%d-%H%M%S).sql.gz`;
|
|
371
|
+
const command = `docker exec ${(0, ssh_1.quote)(name)} pg_dumpall -U postgres | gzip > ${(0, ssh_1.quote)(dumpPath)}`;
|
|
372
|
+
const result = await remote.exec((0, ssh_1.loginShell)(await remote.privileged(command)), 60_000);
|
|
373
|
+
if (result.code !== 0) {
|
|
374
|
+
throw new Error(result.stderr.trim() || 'Backup failed');
|
|
375
|
+
}
|
|
376
|
+
return { path: dumpPath };
|
|
377
|
+
}
|
|
378
|
+
/** Restore a database from a dump file. */
|
|
379
|
+
async function restore(remote, name, dumpPath) {
|
|
380
|
+
const command = `gunzip -c ${(0, ssh_1.quote)(dumpPath)} | docker exec -i ${(0, ssh_1.quote)(name)} psql -U postgres`;
|
|
381
|
+
const result = await remote.exec((0, ssh_1.loginShell)(await remote.privileged(command)), 120_000);
|
|
382
|
+
if (result.code !== 0) {
|
|
383
|
+
throw new Error(result.stderr.trim() || 'Restore failed');
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/** Download a file from the VPS to the panel server using base64 encoding. */
|
|
387
|
+
async function downloadDump(remote, remotePath, localPath) {
|
|
388
|
+
const { stdout, code } = await remote.exec((0, ssh_1.loginShell)(`base64 < ${(0, ssh_1.quote)(remotePath)}`), 120_000);
|
|
389
|
+
if (code !== 0) {
|
|
390
|
+
throw new Error('Failed to download dump from VPS');
|
|
391
|
+
}
|
|
392
|
+
const buffer = Buffer.from(stdout.replace(/\n/g, ''), 'base64');
|
|
393
|
+
(0, node_fs_1.writeFileSync)(localPath, buffer);
|
|
394
|
+
}
|
|
395
|
+
/** Upload a database backup to S3. */
|
|
396
|
+
async function backupToS3(remote, name, engine) {
|
|
397
|
+
const config = (0, backup_config_1.getBackupConfig)(name);
|
|
398
|
+
if (!config.s3)
|
|
399
|
+
throw new Error('S3 not configured for this database');
|
|
400
|
+
// Create dump on VPS
|
|
401
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
402
|
+
const remoteDumpPath = `/tmp/${name}-${timestamp}.sql.gz`;
|
|
403
|
+
const command = `docker exec ${(0, ssh_1.quote)(name)} pg_dumpall -U postgres | gzip > ${(0, ssh_1.quote)(remoteDumpPath)}`;
|
|
404
|
+
const result = await remote.exec((0, ssh_1.loginShell)(await remote.privileged(command)), 60_000);
|
|
405
|
+
if (result.code !== 0) {
|
|
406
|
+
throw new Error(result.stderr.trim() || 'Backup failed');
|
|
407
|
+
}
|
|
408
|
+
// Download to panel server temp file
|
|
409
|
+
const localDumpPath = node_path_1.default.join((0, node_os_1.tmpdir)(), `${name}-${timestamp}.sql.gz`);
|
|
410
|
+
await downloadDump(remote, remoteDumpPath, localDumpPath);
|
|
411
|
+
// Upload to S3
|
|
412
|
+
const s3Key = (0, s3_1.backupS3Key)(engine, name, timestamp);
|
|
413
|
+
await (0, s3_1.uploadToS3)(config.s3, localDumpPath, s3Key);
|
|
414
|
+
// Cleanup
|
|
415
|
+
try {
|
|
416
|
+
(0, node_fs_1.unlinkSync)(localDumpPath);
|
|
417
|
+
}
|
|
418
|
+
catch { /* ignore */ }
|
|
419
|
+
await remote.exec((0, ssh_1.loginShell)(`rm -f ${(0, ssh_1.quote)(remoteDumpPath)}`), 5_000);
|
|
420
|
+
return { key: s3Key };
|
|
421
|
+
}
|
|
422
|
+
/** Stream a backup with progress logging via SSH execStream. */
|
|
423
|
+
function backupStream(remote, name, engine, handlers) {
|
|
424
|
+
let cancelled = false;
|
|
425
|
+
const run = async () => {
|
|
426
|
+
try {
|
|
427
|
+
// Step 1: Create dump on VPS (streamed)
|
|
428
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
429
|
+
const remoteDumpPath = `/tmp/${name}-${timestamp}.sql.gz`;
|
|
430
|
+
handlers.onOutput(`Starting backup of ${name} (${engine})…`);
|
|
431
|
+
const dumpCommand = await remote.privileged(`docker exec ${(0, ssh_1.quote)(name)} pg_dumpall -U postgres | gzip > ${(0, ssh_1.quote)(remoteDumpPath)}`);
|
|
432
|
+
await new Promise((resolve, reject) => {
|
|
433
|
+
const cancel = remote.execStream(dumpCommand, {
|
|
434
|
+
onOutput: (line) => handlers.onOutput(line),
|
|
435
|
+
onDone: () => resolve(),
|
|
436
|
+
onError: (msg) => reject(new Error(msg)),
|
|
437
|
+
});
|
|
438
|
+
// Store cancel for cleanup
|
|
439
|
+
if (cancelled)
|
|
440
|
+
cancel();
|
|
441
|
+
});
|
|
442
|
+
if (cancelled)
|
|
443
|
+
return;
|
|
444
|
+
handlers.onOutput('Dump created successfully');
|
|
445
|
+
// Step 2: Download to panel server
|
|
446
|
+
handlers.onOutput('Downloading dump from VPS…');
|
|
447
|
+
const localDumpPath = node_path_1.default.join((0, node_os_1.tmpdir)(), `${name}-${timestamp}.sql.gz`);
|
|
448
|
+
await downloadDump(remote, remoteDumpPath, localDumpPath);
|
|
449
|
+
if (cancelled)
|
|
450
|
+
return;
|
|
451
|
+
const stats = (0, node_fs_1.readFileSync)(localDumpPath);
|
|
452
|
+
handlers.onOutput(`Downloaded ${(stats.length / 1024 / 1024).toFixed(1)} MB`);
|
|
453
|
+
// Step 3: Upload to S3
|
|
454
|
+
const config = (0, backup_config_1.getBackupConfig)(name);
|
|
455
|
+
if (config.s3) {
|
|
456
|
+
handlers.onOutput('Uploading to S3…');
|
|
457
|
+
const s3Key = (0, s3_1.backupS3Key)(engine, name, timestamp);
|
|
458
|
+
await (0, s3_1.uploadToS3)(config.s3, localDumpPath, s3Key);
|
|
459
|
+
handlers.onOutput(`Uploaded to s3://${config.s3.bucket}/${s3Key}`);
|
|
460
|
+
// Enforce retention
|
|
461
|
+
handlers.onOutput('Enforcing retention policy…');
|
|
462
|
+
await (0, s3_1.enforceRetention)(config.s3, engine, name, config.retention);
|
|
463
|
+
handlers.onOutput('Retention enforced');
|
|
464
|
+
}
|
|
465
|
+
// Step 4: Cleanup
|
|
466
|
+
try {
|
|
467
|
+
(0, node_fs_1.unlinkSync)(localDumpPath);
|
|
468
|
+
}
|
|
469
|
+
catch { /* ignore */ }
|
|
470
|
+
await remote.exec((0, ssh_1.loginShell)(`rm -f ${(0, ssh_1.quote)(remoteDumpPath)}`), 5_000);
|
|
471
|
+
if (!cancelled) {
|
|
472
|
+
handlers.onOutput('Backup complete');
|
|
473
|
+
handlers.onDone();
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
catch (err) {
|
|
477
|
+
if (!cancelled) {
|
|
478
|
+
handlers.onError(err.message);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
run();
|
|
483
|
+
return () => {
|
|
484
|
+
cancelled = true;
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
/** Read a backup file from S3 and return its buffer. */
|
|
488
|
+
async function readBackupFromS3(name, engine, s3Key) {
|
|
489
|
+
const config = (0, backup_config_1.getBackupConfig)(name);
|
|
490
|
+
if (!config.s3)
|
|
491
|
+
throw new Error('S3 not configured');
|
|
492
|
+
const localPath = node_path_1.default.join((0, node_os_1.tmpdir)(), `download-${Date.now()}.dmp`);
|
|
493
|
+
await (0, s3_1.downloadFromS3)(config.s3, s3Key, localPath);
|
|
494
|
+
const buffer = (0, node_fs_1.readFileSync)(localPath);
|
|
495
|
+
try {
|
|
496
|
+
(0, node_fs_1.unlinkSync)(localPath);
|
|
497
|
+
}
|
|
498
|
+
catch { /* ignore */ }
|
|
499
|
+
const parts = s3Key.split('/');
|
|
500
|
+
const fileName = `${name}-${parts[parts.length - 2]}.dmp`;
|
|
501
|
+
return { buffer, fileName };
|
|
502
|
+
}
|
|
503
|
+
/** List backups for a database from S3. */
|
|
504
|
+
async function listBackupsFromS3(name, engine) {
|
|
505
|
+
const config = (0, backup_config_1.getBackupConfig)(name);
|
|
506
|
+
if (!config.s3)
|
|
507
|
+
return [];
|
|
508
|
+
return (0, s3_1.listS3Backups)(config.s3, engine, name);
|
|
509
|
+
}
|
|
510
|
+
/** Restore a database from an S3 backup. */
|
|
511
|
+
async function restoreFromS3(remote, name, engine, s3Key) {
|
|
512
|
+
const config = (0, backup_config_1.getBackupConfig)(name);
|
|
513
|
+
if (!config.s3)
|
|
514
|
+
throw new Error('S3 not configured for this database');
|
|
515
|
+
// Download from S3 to panel server
|
|
516
|
+
const localDumpPath = node_path_1.default.join((0, node_os_1.tmpdir)(), `${name}-restore-${Date.now()}.sql.gz`);
|
|
517
|
+
await (0, s3_1.downloadFromS3)(config.s3, s3Key, localDumpPath);
|
|
518
|
+
// Upload to VPS
|
|
519
|
+
const remoteDumpPath = `/tmp/${name}-restore.sql.gz`;
|
|
520
|
+
const { readFileSync } = await Promise.resolve().then(() => __importStar(require('node:fs')));
|
|
521
|
+
const dumpData = readFileSync(localDumpPath);
|
|
522
|
+
const base64Data = dumpData.toString('base64');
|
|
523
|
+
// Write to VPS via base64 decode
|
|
524
|
+
const writeResult = await remote.exec((0, ssh_1.loginShell)(`echo ${(0, ssh_1.quote)(base64Data)} | base64 -d > ${(0, ssh_1.quote)(remoteDumpPath)}`), 30_000);
|
|
525
|
+
if (writeResult.code !== 0) {
|
|
526
|
+
throw new Error('Failed to upload dump to VPS');
|
|
527
|
+
}
|
|
528
|
+
// Restore
|
|
529
|
+
const command = `gunzip -c ${(0, ssh_1.quote)(remoteDumpPath)} | docker exec -i ${(0, ssh_1.quote)(name)} psql -U postgres`;
|
|
530
|
+
const restoreResult = await remote.exec((0, ssh_1.loginShell)(await remote.privileged(command)), 120_000);
|
|
531
|
+
if (restoreResult.code !== 0) {
|
|
532
|
+
throw new Error(restoreResult.stderr.trim() || 'Restore failed');
|
|
533
|
+
}
|
|
534
|
+
// Cleanup
|
|
535
|
+
try {
|
|
536
|
+
(0, node_fs_1.unlinkSync)(localDumpPath);
|
|
537
|
+
}
|
|
538
|
+
catch { /* ignore */ }
|
|
539
|
+
await remote.exec((0, ssh_1.loginShell)(`rm -f ${(0, ssh_1.quote)(remoteDumpPath)}`), 5_000);
|
|
540
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { type Remote, type StreamHandlers } from './ssh';
|
|
2
|
+
export type Runtime = 'pm2' | 'docker';
|
|
3
|
+
export interface DeploymentConfig {
|
|
4
|
+
name: string;
|
|
5
|
+
repository: string;
|
|
6
|
+
branch?: string;
|
|
7
|
+
/** Domain the site is served on; recorded in the log, proxied by Nginx already. */
|
|
8
|
+
domain?: string;
|
|
9
|
+
installCommand: string;
|
|
10
|
+
buildCommand: string;
|
|
11
|
+
runCommand: string;
|
|
12
|
+
runtime: Runtime;
|
|
13
|
+
port: number;
|
|
14
|
+
}
|
|
15
|
+
/** A deployed site as list() can see it — the commands that created it are not
|
|
16
|
+
* recoverable from the server, so this is not a DeploymentConfig. */
|
|
17
|
+
export interface Deployment {
|
|
18
|
+
name: string;
|
|
19
|
+
repository: string;
|
|
20
|
+
branch?: string;
|
|
21
|
+
domain?: string;
|
|
22
|
+
runtime: Runtime;
|
|
23
|
+
port: number;
|
|
24
|
+
status: 'running' | 'stopped' | 'unknown';
|
|
25
|
+
}
|
|
26
|
+
/** What a queued job will run once its stream opens. */
|
|
27
|
+
export type JobKind =
|
|
28
|
+
/** Fetch latest, reinstall, build, and recreate the process — a full deploy. */
|
|
29
|
+
'deploy'
|
|
30
|
+
/** Build against the existing clone and restart in place — no pull, no install. */
|
|
31
|
+
| 'rebuild';
|
|
32
|
+
/** One past run of a site's deploy script, for the Deployments tab's history list. */
|
|
33
|
+
export interface DeploymentRecord {
|
|
34
|
+
id: string;
|
|
35
|
+
kind: JobKind;
|
|
36
|
+
branch: string;
|
|
37
|
+
/** Short commit hash, once the clone step reports it — rebuilds don't fetch,
|
|
38
|
+
* so this stays whatever it already was for that site. */
|
|
39
|
+
commit?: string;
|
|
40
|
+
startedAt: number;
|
|
41
|
+
finishedAt?: number;
|
|
42
|
+
status: 'running' | 'success' | 'failed';
|
|
43
|
+
error?: string;
|
|
44
|
+
}
|
|
45
|
+
export declare function deploymentHistory(name: string): DeploymentRecord[];
|
|
46
|
+
/** Validates the form's payload and queues it; the id is what the UI streams. */
|
|
47
|
+
export declare function createJob(sessionId: string, config: DeploymentConfig, kind?: JobKind): string;
|
|
48
|
+
export declare function takeJob(id: string, sessionId: string): {
|
|
49
|
+
config: DeploymentConfig;
|
|
50
|
+
kind: JobKind;
|
|
51
|
+
} | null;
|
|
52
|
+
/**
|
|
53
|
+
* Re-queues the config behind `name`'s last deploy — full pull, or build+restart
|
|
54
|
+
* only, depending on `kind`. Returns null when no config is known at all: the
|
|
55
|
+
* site was never deployed through here, or its only deploy never succeeded.
|
|
56
|
+
*/
|
|
57
|
+
export declare function requeueJob(remote: Remote, sessionId: string, name: string, kind: JobKind): Promise<string | null>;
|
|
58
|
+
/**
|
|
59
|
+
* The whole deploy as one script. `set -e` stops at the first failing step, so a
|
|
60
|
+
* broken build never reaches the run stage and the exit code reaches the UI.
|
|
61
|
+
*/
|
|
62
|
+
export declare function buildScript(config: DeploymentConfig): string;
|
|
63
|
+
/**
|
|
64
|
+
* Rebuilds and restarts a site without touching its clone — no fetch, no
|
|
65
|
+
* `npm install`. For when only the running process needs to pick up a change
|
|
66
|
+
* (a rebuild of already-pulled code, or a restart after an env upload) and a
|
|
67
|
+
* full pull isn't wanted.
|
|
68
|
+
*/
|
|
69
|
+
export declare function rebuildScript(config: DeploymentConfig): string;
|
|
70
|
+
/** Runs a queued full deploy: fetch, install, build, recreate the process. */
|
|
71
|
+
export declare function deployStream(remote: Remote, config: DeploymentConfig, handlers: StreamHandlers): () => void;
|
|
72
|
+
/** Runs a queued rebuild: build against the existing clone, restart in place. */
|
|
73
|
+
export declare function rebuildStream(remote: Remote, config: DeploymentConfig, handlers: StreamHandlers): () => void;
|
|
74
|
+
/**
|
|
75
|
+
* Websites this panel deployed: the clones on disk, joined with whatever PM2 or
|
|
76
|
+
* Docker currently reports about them, and the domain pointing at their port.
|
|
77
|
+
*/
|
|
78
|
+
export declare function list(remote: Remote): Promise<Deployment[]>;
|
|
79
|
+
export declare function stop(_name: string): Promise<never>;
|
|
80
|
+
/**
|
|
81
|
+
* Live tail of a deployed site's own output — pm2's log for that process, or
|
|
82
|
+
* the container's stdout/stderr — starting from recent history and following
|
|
83
|
+
* as new lines arrive. Runtime is looked up fresh rather than trusted from the
|
|
84
|
+
* caller, since it's what's actually running that matters here.
|
|
85
|
+
*/
|
|
86
|
+
export declare function logStream(remote: Remote, name: string, handlers: StreamHandlers): () => void;
|
|
87
|
+
/**
|
|
88
|
+
* Tears down a deployed site: stops it under whichever runtime is running it —
|
|
89
|
+
* a name only ever exists in one of pm2 or docker, but both are tried since
|
|
90
|
+
* that runtime isn't known here — then deletes its clone from disk.
|
|
91
|
+
*/
|
|
92
|
+
export declare function remove(remote: Remote, name: string): Promise<void>;
|
|
93
|
+
/** Raw .env contents at the project's root, or null when none has been uploaded. */
|
|
94
|
+
export declare function readEnv(remote: Remote, name: string): Promise<string | null>;
|
|
95
|
+
/**
|
|
96
|
+
* Replaces the project's .env with the uploaded contents and restarts the site
|
|
97
|
+
* so it picks up the new values. Docker containers only see this if their
|
|
98
|
+
* Dockerfile reads .env from the project directory at container start — a
|
|
99
|
+
* container built to bake env vars in at image build time will need a
|
|
100
|
+
* re-deploy instead, not just a restart.
|
|
101
|
+
*/
|
|
102
|
+
export declare function writeEnv(remote: Remote, name: string, content: string): Promise<void>;
|