@studiometa/trafic-agent 0.1.49 → 0.1.50
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 +156 -13
- package/dist/cli.js +134 -14
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/{server-DZAo8Clg.js → server-B_iFvS6v.js} +366 -28
- package/dist/server-B_iFvS6v.js.map +1 -0
- package/dist/server.d.ts +21 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -2
- package/dist/setup/agent.d.ts +11 -1
- package/dist/setup/agent.d.ts.map +1 -1
- package/dist/setup/migrations/0020__backup_dir.d.ts +23 -0
- package/dist/setup/migrations/0020__backup_dir.d.ts.map +1 -0
- package/dist/setup/migrations/index.d.ts.map +1 -1
- package/dist/{setup-DWgb6dAD.js → setup-BbXL1ZFY.js} +826 -209
- package/dist/setup-BbXL1ZFY.js.map +1 -0
- package/dist/tasks/access-logs.d.ts +8 -0
- package/dist/tasks/access-logs.d.ts.map +1 -0
- package/dist/tasks/backup.d.ts +48 -0
- package/dist/tasks/backup.d.ts.map +1 -0
- package/dist/tasks/destroy-project.d.ts +71 -0
- package/dist/tasks/destroy-project.d.ts.map +1 -0
- package/dist/tasks/destroy-scheduled.d.ts +41 -0
- package/dist/tasks/destroy-scheduled.d.ts.map +1 -0
- package/dist/tasks/running-limit.d.ts +54 -0
- package/dist/tasks/running-limit.d.ts.map +1 -0
- package/dist/tasks/stop-idle.d.ts +12 -2
- package/dist/tasks/stop-idle.d.ts.map +1 -1
- package/dist/types.d.ts +41 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/config.d.ts +6 -0
- package/dist/utils/config.d.ts.map +1 -1
- package/dist/utils/db.d.ts +59 -1
- package/dist/utils/db.d.ts.map +1 -1
- package/dist/utils/ddev.d.ts +10 -3
- package/dist/utils/ddev.d.ts.map +1 -1
- package/dist/utils/paths.d.ts +3 -0
- package/dist/utils/paths.d.ts.map +1 -0
- package/package.json +1 -1
- package/dist/server-DZAo8Clg.js.map +0 -1
- package/dist/setup-DWgb6dAD.js.map +0 -1
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import { $ as
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { $ as info, B as initDb, C as purgeTraefikAcmeStorage, D as getIdleTimeoutMs, E as enforceRunningLimit, G as setProjectStatus, I as getIdleProjects, K as startScheduledDestroy, L as getProject, N as deleteProject, O as loadProjectConfig, Q as execSilent, R as getScheduledDestroy, S as installSystemDeps, T as ddevRunningLimitDeps, U as releaseStaleStatuses, W as scheduleDestroy, X as error, Y as nodeIo, Z as exec, _ as configureTraefik, at as warn, b as getDockerGatewayIp, bt as loadConfig, ct as getProjectInfo, dt as nodeDdevRunner, et as isRoot, f as ROUTER_COMPOSE_OVERRIDE, ft as startProject, g as configureDdev, h as addDdevAptRepo, it as success, j as cleanOldLogs, k as shouldNeverStop, m as TLS_STORE_CONFIG, nt as setDryRun, p as STATIC_CONFIG, pt as stopProject, q as systemClock, rt as step, tt as resetSteps, ut as loadProjectList, v as findRunningProject, w as readToolPorts, wt as isInside, x as installDdev, xt as parseDuration, y as findRunningProjects, z as getScheduledDestroys } from "./server-B_iFvS6v.js";
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, rmdirSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join, posix, resolve } from "node:path";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
3
6
|
//#region src/setup/docker-firewall.ts
|
|
4
7
|
/** Where the rule script lives, so systemd and a human can both run it. */
|
|
5
8
|
var DOCKER_FIREWALL_SCRIPT = "/usr/local/sbin/trafic-docker-firewall";
|
|
@@ -127,49 +130,815 @@ function configureDockerFirewall(toolPorts, io = nodeIo) {
|
|
|
127
130
|
//#region src/tasks/stop-idle.ts
|
|
128
131
|
/**
|
|
129
132
|
* Stop idle projects that haven't been accessed recently
|
|
130
|
-
*
|
|
133
|
+
* A project's idle_timeout in .ddev/config.trafic.yaml replaces the global
|
|
134
|
+
* one, shorter or longer
|
|
135
|
+
*
|
|
136
|
+
* Only projects with the status "running" are idle: a project the agent is
|
|
137
|
+
* starting or removing is never stopped here. A start or a removal whose
|
|
138
|
+
* process was killed is set back to "running" first, so its project is not
|
|
139
|
+
* left up for good.
|
|
140
|
+
*
|
|
141
|
+
* Then brings the server back under max_running_projects: a deploy runs
|
|
142
|
+
* `ddev start` without asking the agent, so only the sweep sees it.
|
|
131
143
|
*/
|
|
132
|
-
async function stopIdleProjects(config) {
|
|
144
|
+
async function stopIdleProjects(config, run = nodeDdevRunner) {
|
|
133
145
|
const globalThresholdMs = parseDuration(config.idleTimeout);
|
|
134
146
|
const projects = loadProjectList(config.projectListPath);
|
|
135
|
-
const
|
|
136
|
-
for (const project of
|
|
147
|
+
for (const name of releaseStaleStatuses()) console.log(`Set ${name} back to running: the process that started or removed it is gone`);
|
|
148
|
+
for (const project of getIdleProjects(0)) {
|
|
137
149
|
const projectDir = projects.get(project.name);
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if (Date.now() - project.lastAccess < projectThresholdMs) continue;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
if ((await getProjectInfo(project.name))?.status !== "running") {
|
|
150
|
+
const projectConfig = projectDir ? loadProjectConfig(projectDir) : {};
|
|
151
|
+
if (shouldNeverStop(projectConfig)) continue;
|
|
152
|
+
const thresholdMs = getIdleTimeoutMs(projectConfig) ?? globalThresholdMs;
|
|
153
|
+
if (Date.now() - project.lastAccess < thresholdMs) continue;
|
|
154
|
+
if ((await getProjectInfo(project.name, run))?.status !== "running") {
|
|
147
155
|
setProjectStatus(project.name, "stopped");
|
|
148
156
|
continue;
|
|
149
157
|
}
|
|
150
158
|
console.log(`Stopping idle project: ${project.name}`);
|
|
151
|
-
if (await stopProject(project.name)) {
|
|
159
|
+
if (await stopProject(project.name, run)) {
|
|
152
160
|
setProjectStatus(project.name, "stopped");
|
|
153
161
|
console.log(`Stopped: ${project.name}`);
|
|
154
162
|
} else console.error(`Failed to stop: ${project.name}`);
|
|
155
163
|
}
|
|
164
|
+
await enforceRunningLimit({
|
|
165
|
+
max: config.maxRunningProjects,
|
|
166
|
+
neverStops: (name) => {
|
|
167
|
+
const projectDir = projects.get(name);
|
|
168
|
+
return projectDir ? shouldNeverStop(loadProjectConfig(projectDir)) : false;
|
|
169
|
+
}
|
|
170
|
+
}, ddevRunningLimitDeps(run));
|
|
156
171
|
}
|
|
157
172
|
/**
|
|
158
173
|
* Start the idle check scheduler
|
|
159
174
|
*/
|
|
160
175
|
/** An idle sweep that throws must not take the scheduler down with it. */
|
|
161
|
-
function reportSweepError(error) {
|
|
176
|
+
function reportSweepError$1(error) {
|
|
162
177
|
console.error("Idle sweep failed:", error);
|
|
163
178
|
}
|
|
164
179
|
function startIdleScheduler(config) {
|
|
165
180
|
const intervalMs = parseDuration(config.idleCheckInterval);
|
|
166
181
|
console.log(`Idle scheduler started: checking every ${config.idleCheckInterval}, timeout ${config.idleTimeout}`);
|
|
167
|
-
stopIdleProjects(config).catch(reportSweepError);
|
|
182
|
+
stopIdleProjects(config).catch(reportSweepError$1);
|
|
168
183
|
return setInterval(() => {
|
|
169
|
-
stopIdleProjects(config).catch(reportSweepError);
|
|
184
|
+
stopIdleProjects(config).catch(reportSweepError$1);
|
|
170
185
|
}, intervalMs);
|
|
171
186
|
}
|
|
172
187
|
//#endregion
|
|
188
|
+
//#region src/setup/agent.ts
|
|
189
|
+
/** Node.js major version required by the agent */
|
|
190
|
+
var NODE_MAJOR = 24;
|
|
191
|
+
/** NodeSource keyring path */
|
|
192
|
+
var NODESOURCE_KEYRING = "/etc/apt/keyrings/nodesource.gpg";
|
|
193
|
+
/**
|
|
194
|
+
* Add the NodeSource apt repository and GPG key.
|
|
195
|
+
* Idempotent: safe to call even if the repository is already configured.
|
|
196
|
+
*
|
|
197
|
+
* nodejs.org points at NodeSource for apt installs. The Ubuntu 24.04
|
|
198
|
+
* `nodejs` package is 18.x, far below what the agent needs.
|
|
199
|
+
*/
|
|
200
|
+
function addNodeSourceAptRepo(io = nodeIo) {
|
|
201
|
+
io.exec("install -m 0755 -d /etc/apt/keyrings", { silent: true });
|
|
202
|
+
io.exec("curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key -o /tmp/trafic-nodesource.key", { silent: true });
|
|
203
|
+
io.exec("gpg --batch --yes --dearmor -o /tmp/trafic-nodesource.gpg /tmp/trafic-nodesource.key", { silent: true });
|
|
204
|
+
io.exec(`install -m 0644 /tmp/trafic-nodesource.gpg ${NODESOURCE_KEYRING}`, { silent: true });
|
|
205
|
+
io.exec("rm -f /tmp/trafic-nodesource.key /tmp/trafic-nodesource.gpg", { silent: true });
|
|
206
|
+
io.exec(`echo "deb [signed-by=${NODESOURCE_KEYRING}] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list > /dev/null`, { silent: true });
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Install Node.js from the NodeSource apt repository.
|
|
210
|
+
*
|
|
211
|
+
* apt keeps node and npm in /usr/bin — on root's PATH, so `which
|
|
212
|
+
* trafic-agent` resolves for the systemd unit — and unattended-upgrades
|
|
213
|
+
* patches them, which a version manager install never gets.
|
|
214
|
+
*/
|
|
215
|
+
function installNode(io = nodeIo) {
|
|
216
|
+
step("Install Node.js");
|
|
217
|
+
if (io.commandExists("node")) {
|
|
218
|
+
const version = io.exec("node --version", { silent: true });
|
|
219
|
+
info(`Node.js already installed: ${version?.trim()}`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
info("Adding NodeSource apt repository...");
|
|
223
|
+
addNodeSourceAptRepo(io);
|
|
224
|
+
info("Installing Node.js...");
|
|
225
|
+
io.exec("apt-get update -qq", { silent: true });
|
|
226
|
+
io.exec("DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y nodejs", { silent: true });
|
|
227
|
+
success(`Node.js ${NODE_MAJOR} installed from apt`);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Install the Trafic agent globally, at the version that runs this setup.
|
|
231
|
+
*
|
|
232
|
+
* `trafic setup --agent-version` installs that version, then runs this
|
|
233
|
+
* setup: an install with no version would replace it by the latest one.
|
|
234
|
+
*/
|
|
235
|
+
function installAgent(io = nodeIo) {
|
|
236
|
+
step("Install Trafic agent");
|
|
237
|
+
io.exec(`npm install -g @studiometa/trafic-agent@0.1.50`);
|
|
238
|
+
success(`Trafic agent 0.1.50 installed`);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Create the agent configuration
|
|
242
|
+
*/
|
|
243
|
+
function createAgentConfig(options, io = nodeIo) {
|
|
244
|
+
const { tld, trustedProxyHops = 1, dnsProvider, dnsEnv = {} } = options;
|
|
245
|
+
step("Create agent configuration");
|
|
246
|
+
io.exec("mkdir -p /etc/trafic");
|
|
247
|
+
io.exec("mkdir -p /var/lib/trafic");
|
|
248
|
+
io.exec("chown ddev:ddev /var/lib/trafic");
|
|
249
|
+
createBackupDir$1(io);
|
|
250
|
+
const configPath = "/etc/trafic/config.toml";
|
|
251
|
+
const tlsSection = buildTlsSection(dnsProvider, dnsEnv);
|
|
252
|
+
if (io.fileExists(configPath)) {
|
|
253
|
+
info("Configuration already exists at /etc/trafic/config.toml — skipping");
|
|
254
|
+
info("Edit this file to configure authentication");
|
|
255
|
+
if (tlsSection) {
|
|
256
|
+
info("Add this to /etc/trafic/config.toml, then run `trafic-agent upgrade`:");
|
|
257
|
+
for (const line of buildTlsSection(dnsProvider, dnsEnv, true).trim().split("\n")) info(` ${line}`);
|
|
258
|
+
info("Replace each REPLACE_ME with the value you passed to --dns-env.");
|
|
259
|
+
}
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const config = `# Trafic agent configuration
|
|
263
|
+
# Generated by trafic setup
|
|
264
|
+
|
|
265
|
+
# Required: TLD for DDEV projects
|
|
266
|
+
tld = "${tld}"
|
|
267
|
+
|
|
268
|
+
# Agent HTTP server port
|
|
269
|
+
port = 9876
|
|
270
|
+
|
|
271
|
+
# Database and paths
|
|
272
|
+
db_path = "/var/lib/trafic/db.sqlite"
|
|
273
|
+
project_list_path = "/home/ddev/.ddev/project_list.yaml"
|
|
274
|
+
projects_dir = "/home/ddev/www"
|
|
275
|
+
|
|
276
|
+
# Scale-to-zero settings
|
|
277
|
+
idle_timeout = "4h"
|
|
278
|
+
idle_check_interval = "5m"
|
|
279
|
+
|
|
280
|
+
# Authentication
|
|
281
|
+
[auth]
|
|
282
|
+
default_policy = "basic"
|
|
283
|
+
allowed_ips = []
|
|
284
|
+
tokens = []
|
|
285
|
+
basic_auth = []
|
|
286
|
+
rules = []
|
|
287
|
+
|
|
288
|
+
# Proxies in front of the agent, counted so the client address can be read
|
|
289
|
+
# from X-Forwarded-For. 1 is ddev-router/Traefik alone. Raise it by one for
|
|
290
|
+
# every extra proxy — a CDN or load balancer ahead of Traefik makes it 2.
|
|
291
|
+
# Too low and allowed_ips matches a proxy instead of the client; too high and
|
|
292
|
+
# it can be spoofed by a client-supplied header.
|
|
293
|
+
trusted_proxy_hops = ${trustedProxyHops}
|
|
294
|
+
${tlsSection}`;
|
|
295
|
+
io.writeFile(configPath, config);
|
|
296
|
+
io.exec("chmod 640 /etc/trafic/config.toml");
|
|
297
|
+
io.exec("chown root:ddev /etc/trafic/config.toml");
|
|
298
|
+
success("Configuration created at /etc/trafic/config.toml");
|
|
299
|
+
info("Edit this file to configure authentication");
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Build the [tls] section of the generated config.
|
|
303
|
+
*
|
|
304
|
+
* Empty without a DNS provider: per-host certificates stay the default, and
|
|
305
|
+
* an empty section would only invite a half-filled one.
|
|
306
|
+
*/
|
|
307
|
+
function buildTlsSection(dnsProvider, dnsEnv, redact = false) {
|
|
308
|
+
if (!dnsProvider) return "";
|
|
309
|
+
const env = Object.entries(dnsEnv).map(([key, value]) => `${key} = "${redact ? "REPLACE_ME" : escapeTomlString(value)}"`).join("\n");
|
|
310
|
+
return `
|
|
311
|
+
# Wildcard certificate (DNS-01). One *.<tld> certificate replaces the
|
|
312
|
+
# per-hostname ones, so previews stop consuming the Let's Encrypt quota.
|
|
313
|
+
[tls]
|
|
314
|
+
dns_provider = "${escapeTomlString(dnsProvider)}"
|
|
315
|
+
# ca_server = "https://acme-staging-v02.api.letsencrypt.org/directory"
|
|
316
|
+
|
|
317
|
+
# Credentials the lego provider reads. Passed to the ddev-router container.
|
|
318
|
+
[tls.dns_env]
|
|
319
|
+
${env}
|
|
320
|
+
`;
|
|
321
|
+
}
|
|
322
|
+
/** Escape a value for a basic (double-quoted) TOML string. */
|
|
323
|
+
function escapeTomlString(value) {
|
|
324
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
|
|
325
|
+
}
|
|
326
|
+
/** Default backup directory, `[backup] dir` in the agent config. */
|
|
327
|
+
var BACKUP_DIR = "/var/backups/trafic";
|
|
328
|
+
/**
|
|
329
|
+
* Create the backup directory, writable by the ddev user only: it holds
|
|
330
|
+
* database dumps.
|
|
331
|
+
*/
|
|
332
|
+
function createBackupDir$1(io = nodeIo) {
|
|
333
|
+
io.exec(`mkdir -p ${BACKUP_DIR}`);
|
|
334
|
+
io.exec(`chown ddev:ddev ${BACKUP_DIR}`);
|
|
335
|
+
io.exec(`chmod 700 ${BACKUP_DIR}`);
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Create systemd service for the agent
|
|
339
|
+
*/
|
|
340
|
+
function createSystemdService(io = nodeIo) {
|
|
341
|
+
step("Create systemd service");
|
|
342
|
+
const serviceContent = `[Unit]
|
|
343
|
+
Description=Trafic Agent - DDEV preview environment manager
|
|
344
|
+
After=network.target docker.service
|
|
345
|
+
Requires=docker.service
|
|
346
|
+
|
|
347
|
+
[Service]
|
|
348
|
+
Type=simple
|
|
349
|
+
User=ddev
|
|
350
|
+
Group=ddev
|
|
351
|
+
ExecStart=${io.exec("which trafic-agent", { silent: true })?.trim() || "/usr/bin/trafic-agent"} start --config /etc/trafic/config.toml
|
|
352
|
+
Restart=always
|
|
353
|
+
RestartSec=5
|
|
354
|
+
StandardOutput=journal
|
|
355
|
+
StandardError=journal
|
|
356
|
+
SyslogIdentifier=trafic-agent
|
|
357
|
+
|
|
358
|
+
# Environment
|
|
359
|
+
Environment=NODE_ENV=production
|
|
360
|
+
# Skip ddev-hostname /etc/hosts management — on a server, hostnames resolve
|
|
361
|
+
# to the public IP (not localhost), which would trigger ddev-hostname on every
|
|
362
|
+
# ddev start. DDEV_NONINTERACTIVE=true skips that entirely.
|
|
363
|
+
Environment=DDEV_NONINTERACTIVE=true
|
|
364
|
+
|
|
365
|
+
# Security hardening
|
|
366
|
+
NoNewPrivileges=true
|
|
367
|
+
ProtectSystem=strict
|
|
368
|
+
ProtectHome=read-only
|
|
369
|
+
# ddev start writes across its own home: the project directory
|
|
370
|
+
# (.ddev/.webimageBuild), buildx state in .docker, mutagen data. Listing
|
|
371
|
+
# individual paths is whack-a-mole across DDEV versions, so grant the home
|
|
372
|
+
# and leave the rest of /home read-only. The backup sweep removes old
|
|
373
|
+
# backups from ${BACKUP_DIR}; with "-", a missing backup directory does not
|
|
374
|
+
# stop the service from starting.
|
|
375
|
+
ReadWritePaths=/var/lib/trafic /home/ddev -${BACKUP_DIR}
|
|
376
|
+
PrivateTmp=true
|
|
377
|
+
|
|
378
|
+
[Install]
|
|
379
|
+
WantedBy=multi-user.target
|
|
380
|
+
`;
|
|
381
|
+
io.writeFile("/etc/systemd/system/trafic-agent.service", serviceContent);
|
|
382
|
+
io.exec("chmod 644 /etc/systemd/system/trafic-agent.service");
|
|
383
|
+
io.exec("systemctl daemon-reload");
|
|
384
|
+
io.exec("systemctl enable trafic-agent");
|
|
385
|
+
io.exec("systemctl start trafic-agent");
|
|
386
|
+
success("Trafic agent service started");
|
|
387
|
+
info("View logs: journalctl -u trafic-agent -f");
|
|
388
|
+
}
|
|
389
|
+
//#endregion
|
|
390
|
+
//#region src/tasks/backup.ts
|
|
391
|
+
var execFileAsync = promisify(execFile);
|
|
392
|
+
/** Where DDEV mounts the project in the web container. */
|
|
393
|
+
var CONTAINER_APPROOT = "/var/www/html";
|
|
394
|
+
/** Reading the upload directories is quick. */
|
|
395
|
+
var UPLOAD_DIRS_TIMEOUT_MS = 6e4;
|
|
396
|
+
/** Retention is counted in days, so an hourly sweep is enough. */
|
|
397
|
+
var SWEEP_INTERVAL_MS$1 = 36e5;
|
|
398
|
+
var DAY_MS = 864e5;
|
|
399
|
+
/** A backup directory name: an ISO date in UTC, with `-` for `:`. */
|
|
400
|
+
var TIMESTAMP = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})Z$/;
|
|
401
|
+
/** The name of a backup directory, e.g. `2026-10-14T07-40-24Z`. */
|
|
402
|
+
function backupTimestamp(date) {
|
|
403
|
+
return `${date.toISOString().slice(0, 19).replace(/:/g, "-")}Z`;
|
|
404
|
+
}
|
|
405
|
+
/** The time of a backup directory name, or undefined for any other name. */
|
|
406
|
+
function parseBackupTimestamp(name) {
|
|
407
|
+
const match = TIMESTAMP.exec(name);
|
|
408
|
+
if (!match) return;
|
|
409
|
+
const [, day, hours, minutes, seconds] = match;
|
|
410
|
+
const time = Date.parse(`${day}T${hours}:${minutes}:${seconds}Z`);
|
|
411
|
+
return Number.isNaN(time) ? void 0 : time;
|
|
412
|
+
}
|
|
413
|
+
/** Whether a path is an existing directory. */
|
|
414
|
+
function isDirectory$1(path) {
|
|
415
|
+
try {
|
|
416
|
+
return statSync(path).isDirectory();
|
|
417
|
+
} catch {
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* The upload directories of a project, relative to its directory.
|
|
423
|
+
*
|
|
424
|
+
* DDEV resolves them: `upload_dirs` from the project config, or the default
|
|
425
|
+
* of the project type (`wp-content/uploads` for WordPress, for instance).
|
|
426
|
+
* Neither `ddev describe` nor `ddev utility configyaml` shows the type
|
|
427
|
+
* default, but DDEV gives the result to the web container as
|
|
428
|
+
* `DDEV_FILES_DIRS`, comma-separated container paths. The project must be
|
|
429
|
+
* running.
|
|
430
|
+
*
|
|
431
|
+
* A directory that does not exist on the host is left out.
|
|
432
|
+
*/
|
|
433
|
+
async function findUploadDirs(dir, timeoutMs, run) {
|
|
434
|
+
const { ok, stdout } = await run([
|
|
435
|
+
"exec",
|
|
436
|
+
"printenv",
|
|
437
|
+
"DDEV_FILES_DIRS"
|
|
438
|
+
], timeoutMs, dir);
|
|
439
|
+
if (!ok) throw new Error("Could not read the upload directories (DDEV_FILES_DIRS)");
|
|
440
|
+
return stdout.trim().split(",").filter(Boolean).map((path) => posix.relative(CONTAINER_APPROOT, path)).filter((path) => path && !path.startsWith("..") && !posix.isAbsolute(path)).filter((path) => isDirectory$1(join(dir, path)));
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Create the backup directory of one backup (see createBackupDir).
|
|
444
|
+
*
|
|
445
|
+
* The agent service runs in a systemd sandbox (`ProtectSystem=strict`) that
|
|
446
|
+
* can write to a few paths only. A `[backup] dir` outside of them is
|
|
447
|
+
* read-only for the service, so every scheduled backup fails and the
|
|
448
|
+
* project is never removed: say so in the error, which the sweep logs.
|
|
449
|
+
*/
|
|
450
|
+
function createBackupTarget(target, backupDir, name) {
|
|
451
|
+
try {
|
|
452
|
+
mkdirSync(join(backupDir, name), { recursive: true });
|
|
453
|
+
createBackupDir(target, name);
|
|
454
|
+
} catch (error) {
|
|
455
|
+
const { code } = error;
|
|
456
|
+
if (code !== "EROFS" && code !== "EACCES") throw error;
|
|
457
|
+
throw new Error(`Cannot write to the backup directory ${backupDir} (${code}). The agent service runs in a systemd sandbox (ProtectSystem=strict) and can only write to /var/lib/trafic, /home/ddev and ${BACKUP_DIR}: set [backup] dir inside one of them, writable by the ddev user`, { cause: error });
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
/** The time left before the deadline of the backup. */
|
|
461
|
+
function timeLeft(deadline) {
|
|
462
|
+
const left = deadline - Date.now();
|
|
463
|
+
if (left <= 0) throw new Error("No time is left for the next step");
|
|
464
|
+
return left;
|
|
465
|
+
}
|
|
466
|
+
/** A duration in the largest whole unit: 1h, 40m or 90s. */
|
|
467
|
+
function formatDuration(ms) {
|
|
468
|
+
if (ms % 36e5 === 0) return `${ms / 36e5}h`;
|
|
469
|
+
if (ms % 6e4 === 0) return `${ms / 6e4}m`;
|
|
470
|
+
return `${Math.ceil(ms / 1e3)}s`;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* The error of a backup that went over its time limit. The step that ran
|
|
474
|
+
* out of time only fails (ddev reports a failed command, tar is killed), so
|
|
475
|
+
* this says it was the time limit, and how to raise it.
|
|
476
|
+
*/
|
|
477
|
+
function timeoutError(timeoutMs, cause) {
|
|
478
|
+
return new Error(`The backup timed out: it took longer than its time limit of ${formatDuration(timeoutMs)}. Raise the limit with --timeout`, { cause });
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Write the database dump and the upload archive of a running project.
|
|
482
|
+
* Each step gets the time left before `deadline`.
|
|
483
|
+
*/
|
|
484
|
+
async function writeBackup(hasDatabase, dir, target, deadline, run) {
|
|
485
|
+
if (hasDatabase) {
|
|
486
|
+
const file = join(target, "db.sql.gz");
|
|
487
|
+
console.log(`Exporting the database to ${file}`);
|
|
488
|
+
const { ok } = await run(["export-db", `--file=${file}`], timeLeft(deadline), dir);
|
|
489
|
+
if (!ok) throw new Error("Could not export the database");
|
|
490
|
+
} else console.log("No database container — skipping the database dump");
|
|
491
|
+
const uploadDirs = await findUploadDirs(dir, Math.min(UPLOAD_DIRS_TIMEOUT_MS, timeLeft(deadline)), run);
|
|
492
|
+
if (uploadDirs.length === 0) {
|
|
493
|
+
console.log("No upload directory — skipping the files archive");
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const file = join(target, "files.tar.gz");
|
|
497
|
+
console.log(`Archiving ${uploadDirs.join(", ")} to ${file}`);
|
|
498
|
+
await archive(file, dir, uploadDirs, timeLeft(deadline));
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Archive paths of a directory to a tar.gz file.
|
|
502
|
+
*
|
|
503
|
+
* The project runs during the archive, and can write to its upload
|
|
504
|
+
* directories at any time. GNU tar then exits with code 1 ("some files
|
|
505
|
+
* differ"): the archive is written, with the changed file as tar read it.
|
|
506
|
+
* That is accepted with a warning. Code 2 or more (a fatal error, such as an
|
|
507
|
+
* unreadable file) and a timeout fail the archive.
|
|
508
|
+
*/
|
|
509
|
+
async function archive(file, dir, paths, timeoutMs) {
|
|
510
|
+
try {
|
|
511
|
+
await execFileAsync("tar", [
|
|
512
|
+
"-czf",
|
|
513
|
+
file,
|
|
514
|
+
"-C",
|
|
515
|
+
dir,
|
|
516
|
+
"--",
|
|
517
|
+
...paths
|
|
518
|
+
], { timeout: timeoutMs });
|
|
519
|
+
} catch (error) {
|
|
520
|
+
const { code, killed } = error;
|
|
521
|
+
if (code !== 1 || killed) throw error;
|
|
522
|
+
console.warn("Some files changed while tar read them: the archive has them as tar read them");
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Create the directory of one backup. It must not exist: two backups of a
|
|
527
|
+
* project in the same second would share it, and the one that fails would
|
|
528
|
+
* remove the files of the other. The second one fails here, before it
|
|
529
|
+
* changes anything.
|
|
530
|
+
*/
|
|
531
|
+
function createBackupDir(target, name) {
|
|
532
|
+
try {
|
|
533
|
+
mkdirSync(target);
|
|
534
|
+
} catch (error) {
|
|
535
|
+
if (error.code === "EEXIST") throw new Error(`${target} already exists: another backup of ${name} runs or ran in the same second`);
|
|
536
|
+
throw error;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Back up a DDEV project to `<backupDir>/<name>/<timestamp>`.
|
|
541
|
+
*
|
|
542
|
+
* The backup holds:
|
|
543
|
+
* - `db.sql.gz`: the `ddev export-db` dump, unless the project omits the db
|
|
544
|
+
* container
|
|
545
|
+
* - `files.tar.gz`: the upload directories, with paths relative to the
|
|
546
|
+
* project directory, unless the project has none
|
|
547
|
+
*
|
|
548
|
+
* Both need the project running, so a stopped project is started, then
|
|
549
|
+
* stopped again. A failed backup, or a backup that takes longer than its
|
|
550
|
+
* time limit (`timeoutMs`), throws and leaves no backup directory. Returns
|
|
551
|
+
* the backup directory.
|
|
552
|
+
*/
|
|
553
|
+
async function backupProject(name, dir, backupDir, options = {}, run = nodeDdevRunner) {
|
|
554
|
+
const info = await getProjectInfo(name, run);
|
|
555
|
+
if (!info) throw new Error(`Could not describe the DDEV project ${name}`);
|
|
556
|
+
const target = join(backupDir, name, backupTimestamp(/* @__PURE__ */ new Date()));
|
|
557
|
+
const wasRunning = info.status === "running";
|
|
558
|
+
createBackupTarget(target, backupDir, name);
|
|
559
|
+
try {
|
|
560
|
+
if (!wasRunning) {
|
|
561
|
+
console.log(`Starting ${name} for the backup`);
|
|
562
|
+
if (!await startProject(name, run)) throw new Error(`Could not start ${name} for the backup`);
|
|
563
|
+
}
|
|
564
|
+
const timeoutMs = options.timeoutMs ?? 24e5;
|
|
565
|
+
const deadline = Date.now() + timeoutMs;
|
|
566
|
+
try {
|
|
567
|
+
await writeBackup(info.dbType !== void 0, dir, target, deadline, run);
|
|
568
|
+
} catch (error) {
|
|
569
|
+
throw Date.now() >= deadline ? timeoutError(timeoutMs, error) : error;
|
|
570
|
+
} finally {
|
|
571
|
+
if (!wasRunning && !await stopProject(name, run)) console.warn(`Could not stop ${name} after the backup`);
|
|
572
|
+
}
|
|
573
|
+
} catch (error) {
|
|
574
|
+
rmSync(target, {
|
|
575
|
+
recursive: true,
|
|
576
|
+
force: true
|
|
577
|
+
});
|
|
578
|
+
throw error;
|
|
579
|
+
}
|
|
580
|
+
console.log(`Backup saved to ${target}`);
|
|
581
|
+
return target;
|
|
582
|
+
}
|
|
583
|
+
/** The subdirectories of a directory, or none when it does not exist. */
|
|
584
|
+
function listDirs(path) {
|
|
585
|
+
if (!existsSync(path)) return [];
|
|
586
|
+
return readdirSync(path, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Remove the backups older than `retainDays`, and the project directories
|
|
590
|
+
* that this removal leaves empty. Only directories with a backup timestamp
|
|
591
|
+
* name are removed, and a project directory only when this sweep removed a
|
|
592
|
+
* backup from it: `dir` can be a shared directory, with empty directories
|
|
593
|
+
* that are not backups. Returns the number of backups removed.
|
|
594
|
+
*/
|
|
595
|
+
function removeOldBackups(config, now = Date.now()) {
|
|
596
|
+
const cutoff = now - config.retainDays * DAY_MS;
|
|
597
|
+
let removed = 0;
|
|
598
|
+
for (const project of listDirs(config.dir)) {
|
|
599
|
+
const projectDir = join(config.dir, project);
|
|
600
|
+
let removedFromProject = 0;
|
|
601
|
+
for (const name of listDirs(projectDir)) {
|
|
602
|
+
const time = parseBackupTimestamp(name);
|
|
603
|
+
if (time !== void 0 && time < cutoff) {
|
|
604
|
+
rmSync(join(projectDir, name), {
|
|
605
|
+
recursive: true,
|
|
606
|
+
force: true
|
|
607
|
+
});
|
|
608
|
+
console.log(`Removed old backup: ${project}/${name}`);
|
|
609
|
+
removedFromProject++;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
removed += removedFromProject;
|
|
613
|
+
if (removedFromProject === 0) continue;
|
|
614
|
+
try {
|
|
615
|
+
if (readdirSync(projectDir).length === 0) rmdirSync(projectDir);
|
|
616
|
+
} catch {}
|
|
617
|
+
}
|
|
618
|
+
return removed;
|
|
619
|
+
}
|
|
620
|
+
/** A sweep that throws must not take the scheduler down with it. */
|
|
621
|
+
function sweepBackups(config) {
|
|
622
|
+
try {
|
|
623
|
+
removeOldBackups(config);
|
|
624
|
+
} catch (error) {
|
|
625
|
+
console.error("Backup sweep failed:", error);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Start the backup retention sweep
|
|
630
|
+
*/
|
|
631
|
+
function startBackupSweepScheduler(config) {
|
|
632
|
+
console.log(`Backup sweep started: removing backups older than ${config.backup.retainDays} days from ${config.backup.dir}`);
|
|
633
|
+
sweepBackups(config.backup);
|
|
634
|
+
return setInterval(() => sweepBackups(config.backup), SWEEP_INTERVAL_MS$1);
|
|
635
|
+
}
|
|
636
|
+
//#endregion
|
|
637
|
+
//#region src/tasks/destroy-project.ts
|
|
638
|
+
/** Removing containers and volumes is slower than a stop. */
|
|
639
|
+
var DELETE_TIMEOUT_MS = 3e5;
|
|
640
|
+
/**
|
|
641
|
+
* What to do when `trafic-agent destroy` cannot read the config or open the
|
|
642
|
+
* database. Only the ddev user can do both: the config is readable by root
|
|
643
|
+
* and the ddev group only (640 root:ddev), and the database directory
|
|
644
|
+
* belongs to ddev. `trafic destroy` connects as ddev by default.
|
|
645
|
+
*/
|
|
646
|
+
var DDEV_USER_HINT = "connect as the ddev user";
|
|
647
|
+
/** SQLite result codes of a database that the user cannot open or write. */
|
|
648
|
+
var SQLITE_ACCESS_ERRORS = /* @__PURE__ */ new Set([
|
|
649
|
+
3,
|
|
650
|
+
8,
|
|
651
|
+
14
|
|
652
|
+
]);
|
|
653
|
+
/**
|
|
654
|
+
* Read the agent config for `trafic-agent destroy`. A permission error says
|
|
655
|
+
* which user to connect as.
|
|
656
|
+
*/
|
|
657
|
+
function loadDestroyConfig(configPath) {
|
|
658
|
+
try {
|
|
659
|
+
return loadConfig(configPath);
|
|
660
|
+
} catch (error) {
|
|
661
|
+
const { code, path } = error;
|
|
662
|
+
if (code !== "EACCES" && code !== "EPERM") throw error;
|
|
663
|
+
throw new Error(`cannot read ${path ?? configPath} (${code}): ${DDEV_USER_HINT}`, { cause: error });
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Open the agent database for `trafic-agent destroy`. A permission error
|
|
668
|
+
* says which user to connect as.
|
|
669
|
+
*/
|
|
670
|
+
function openDestroyDb(dbPath) {
|
|
671
|
+
try {
|
|
672
|
+
initDb(dbPath);
|
|
673
|
+
} catch (error) {
|
|
674
|
+
const { errcode } = error;
|
|
675
|
+
if (errcode === void 0 || !SQLITE_ACCESS_ERRORS.has(errcode & 255)) throw error;
|
|
676
|
+
throw new Error(`cannot open the database ${dbPath} (${error.message}): ${DDEV_USER_HINT}`, { cause: error });
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Find the directory of a project, and refuse one outside the projects
|
|
681
|
+
* directory.
|
|
682
|
+
*
|
|
683
|
+
* DDEV's project list gives the real app root. A project DDEV has already
|
|
684
|
+
* forgotten is looked for at `<projects_dir>/<name>`, where `trafic deploy`
|
|
685
|
+
* puts it, so its directory can still be cleaned up.
|
|
686
|
+
*
|
|
687
|
+
* The check stands between a name typed on a command line and an
|
|
688
|
+
* `rm -rf`: an empty name, `..` or an app root of `/` must never reach it.
|
|
689
|
+
* The path is checked as written, then, when it exists, with its symbolic
|
|
690
|
+
* links followed: a symlinked project directory or parent directory could
|
|
691
|
+
* otherwise point `ddev delete` and the removal at a directory outside the
|
|
692
|
+
* projects directory.
|
|
693
|
+
*/
|
|
694
|
+
function resolveProjectDir(name, config) {
|
|
695
|
+
if (!name.trim() || name === "." || name === ".." || name.includes("/")) throw new Error(`Invalid project name: "${name}"`);
|
|
696
|
+
const projectsDir = resolve(config.projectsDir);
|
|
697
|
+
const appRoot = loadProjectList(config.projectListPath).get(name);
|
|
698
|
+
const dir = resolve(appRoot || join(projectsDir, name));
|
|
699
|
+
if (!isInside(projectsDir, dir)) throw new Error(`Refusing to remove ${dir}: it is not inside the projects directory ${projectsDir}. Set projects_dir in the agent config to the directory that \`trafic deploy\` uses`);
|
|
700
|
+
checkRealPath(dir, projectsDir);
|
|
701
|
+
return dir;
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Refuse an existing project directory that is a symbolic link, or that a
|
|
705
|
+
* symbolic link in its path takes outside the projects directory. A path
|
|
706
|
+
* that does not exist is left to the caller: there is nothing to remove.
|
|
707
|
+
*/
|
|
708
|
+
function checkRealPath(dir, projectsDir) {
|
|
709
|
+
let stats;
|
|
710
|
+
try {
|
|
711
|
+
stats = lstatSync(dir);
|
|
712
|
+
} catch (error) {
|
|
713
|
+
const { code } = error;
|
|
714
|
+
if (code === "ENOENT" || code === "ENOTDIR") return;
|
|
715
|
+
throw error;
|
|
716
|
+
}
|
|
717
|
+
if (stats.isSymbolicLink()) throw new Error(`Refusing to remove ${dir}: it is a symbolic link`);
|
|
718
|
+
const realDir = realpathSync(dir);
|
|
719
|
+
const realProjectsDir = realpathSync(projectsDir);
|
|
720
|
+
if (!isInside(realProjectsDir, realDir)) throw new Error(`Refusing to remove ${dir}: it resolves to ${realDir}, which is not inside the projects directory ${realProjectsDir}`);
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Refuse a backup directory that is the project directory or inside it: the
|
|
724
|
+
* removal would delete the backup right after it is written. validateConfig
|
|
725
|
+
* already refuses a backup.dir in projects_dir; this also follows symbolic
|
|
726
|
+
* links. It checks `<backup.dir>/<name>`, where the backup goes, so a
|
|
727
|
+
* symbolic link at that level is followed too.
|
|
728
|
+
*/
|
|
729
|
+
function checkBackupDir(backupDir, name, dir) {
|
|
730
|
+
const target = resolve(backupDir, name);
|
|
731
|
+
const realTarget = realpathOfNearest(target);
|
|
732
|
+
const realDir = realpathSync(dir);
|
|
733
|
+
if (target === dir || isInside(dir, target) || realTarget === realDir || isInside(realDir, realTarget)) throw new Error(`Refusing to back up to ${backupDir}: it is inside the project directory ${dir}, which the removal deletes`);
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* The real path of a path that may not exist yet: the real path of its
|
|
737
|
+
* nearest existing ancestor, plus the rest of the path. The backup creates
|
|
738
|
+
* the missing directories through the symbolic links of that ancestor.
|
|
739
|
+
*/
|
|
740
|
+
function realpathOfNearest(path) {
|
|
741
|
+
const rest = [];
|
|
742
|
+
let current = path;
|
|
743
|
+
while (!existsSync(current)) {
|
|
744
|
+
const parent = dirname(current);
|
|
745
|
+
if (parent === current) break;
|
|
746
|
+
rest.unshift(basename(current));
|
|
747
|
+
current = parent;
|
|
748
|
+
}
|
|
749
|
+
return join(realpathSync(current), ...rest);
|
|
750
|
+
}
|
|
751
|
+
/** Whether a path is an existing directory. */
|
|
752
|
+
function isDirectory(path) {
|
|
753
|
+
try {
|
|
754
|
+
return statSync(path).isDirectory();
|
|
755
|
+
} catch {
|
|
756
|
+
return false;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Remove a project from the server.
|
|
761
|
+
*
|
|
762
|
+
* Steps:
|
|
763
|
+
* 1. With `backup`, back up the database and the upload directories, all
|
|
764
|
+
* within `timeoutMs` when it is given
|
|
765
|
+
* 2. Delete the DDEV project (containers, volumes, no snapshot)
|
|
766
|
+
* 3. Remove the project directory
|
|
767
|
+
* 4. Delete the project's records from the agent database
|
|
768
|
+
*
|
|
769
|
+
* A failed backup throws before anything is removed.
|
|
770
|
+
*
|
|
771
|
+
* A backup can take many minutes. With `beforeDelete`, the removal calls it
|
|
772
|
+
* after the backup, just before step 2, and stops without removing anything
|
|
773
|
+
* when it returns false: a scheduled removal that a deploy cancelled
|
|
774
|
+
* meanwhile must not delete the new deploy.
|
|
775
|
+
*
|
|
776
|
+
* A failed `ddev delete` does not stop the removal: a project DDEV has
|
|
777
|
+
* already forgotten could otherwise never be cleaned up, and the next deploy
|
|
778
|
+
* would find a stale directory.
|
|
779
|
+
*
|
|
780
|
+
* While the removal runs, the project has the status "destroying", which the
|
|
781
|
+
* idle sweep and max_running_projects never stop, and the waiting page never
|
|
782
|
+
* starts: the backup can take many minutes, and a stop in the middle of it
|
|
783
|
+
* fails the backup. The status is in the database, so the service sees it
|
|
784
|
+
* when `trafic-agent destroy` runs from the command line. A removal that
|
|
785
|
+
* fails sets the status back: "running" when it was, else "stopped". A
|
|
786
|
+
* removal that is killed cannot: the status records the id of its process,
|
|
787
|
+
* and the service sets it back to "running" once that process is gone (see
|
|
788
|
+
* releaseStaleStatuses).
|
|
789
|
+
*
|
|
790
|
+
* The database must be open (`initDb`). Returns false when there was no
|
|
791
|
+
* project directory or the removal was cancelled. Without a project
|
|
792
|
+
* directory, the project's records are still deleted, so a retry after a run
|
|
793
|
+
* that failed between steps 2 and 3 cleans them up. A cancelled removal
|
|
794
|
+
* removes nothing and sets the status back.
|
|
795
|
+
*/
|
|
796
|
+
async function destroyProject(name, config, options = {}, run = nodeDdevRunner) {
|
|
797
|
+
const dir = resolveProjectDir(name, config);
|
|
798
|
+
if (!isDirectory(dir)) {
|
|
799
|
+
console.warn(`Project directory ${dir} does not exist — nothing to destroy`);
|
|
800
|
+
deleteProject(name);
|
|
801
|
+
return false;
|
|
802
|
+
}
|
|
803
|
+
if (options.backup) checkBackupDir(config.backup.dir, name, dir);
|
|
804
|
+
const previous = getProject(name)?.status;
|
|
805
|
+
setProjectStatus(name, "destroying");
|
|
806
|
+
let removed;
|
|
807
|
+
try {
|
|
808
|
+
removed = await removeProject(name, dir, config, options, run);
|
|
809
|
+
} catch (error) {
|
|
810
|
+
setProjectStatus(name, previous === "running" ? "running" : "stopped");
|
|
811
|
+
throw error;
|
|
812
|
+
}
|
|
813
|
+
if (!removed) setProjectStatus(name, previous === "running" ? "running" : "stopped");
|
|
814
|
+
return removed;
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* The steps of destroyProject, once the project is marked "destroying".
|
|
818
|
+
* Returns false when the removal was cancelled after the backup.
|
|
819
|
+
*/
|
|
820
|
+
async function removeProject(name, dir, config, options, run) {
|
|
821
|
+
if (options.backup) {
|
|
822
|
+
console.log(`Backing up ${name}`);
|
|
823
|
+
await backupProject(name, dir, config.backup.dir, { timeoutMs: options.timeoutMs }, run);
|
|
824
|
+
}
|
|
825
|
+
if (options.beforeDelete && !options.beforeDelete()) {
|
|
826
|
+
console.log(`Removal of ${name} cancelled — nothing removed`);
|
|
827
|
+
return false;
|
|
828
|
+
}
|
|
829
|
+
console.log(`Deleting DDEV project: ${name}`);
|
|
830
|
+
const { ok } = await run(["delete", "-Oy"], DELETE_TIMEOUT_MS, dir);
|
|
831
|
+
if (!ok) console.warn("DDEV delete failed — project may not be a valid DDEV project");
|
|
832
|
+
console.log(`Removing ${dir}`);
|
|
833
|
+
rmSync(dir, {
|
|
834
|
+
recursive: true,
|
|
835
|
+
force: true
|
|
836
|
+
});
|
|
837
|
+
deleteProject(name);
|
|
838
|
+
console.log(`Destroyed: ${name}`);
|
|
839
|
+
return true;
|
|
840
|
+
}
|
|
841
|
+
//#endregion
|
|
842
|
+
//#region src/tasks/destroy-scheduled.ts
|
|
843
|
+
/** Hours, minutes and seconds, in this order: "48h", "1h30m", "90s". */
|
|
844
|
+
var DELAY_REGEX = /^(?:\d+h)?(?:\d+m)?(?:\d+s)?$/;
|
|
845
|
+
/**
|
|
846
|
+
* Schedule the removal of a project, `delay` from now ("48h", "1h30m").
|
|
847
|
+
*
|
|
848
|
+
* The date goes to the agent database, not to the project: a redeploy does
|
|
849
|
+
* not reliably rewrite the project files, and the sweep must find the date
|
|
850
|
+
* even for a project DDEV has forgotten. The name is checked now, so a bad
|
|
851
|
+
* name or a wrong `projects_dir` fails the CI job, not a sweep two days
|
|
852
|
+
* later. With `backup`, the removal backs the project up first, within
|
|
853
|
+
* `timeoutMs` when it is given. A removal that the sweep already started
|
|
854
|
+
* cannot be scheduled again. Returns the date of the removal.
|
|
855
|
+
*/
|
|
856
|
+
function scheduleProjectDestroy(name, delay, config, options = {}, now = systemClock) {
|
|
857
|
+
const delayMs = DELAY_REGEX.test(delay) ? parseDuration(delay) : 0;
|
|
858
|
+
if (delayMs <= 0) throw new Error(`Invalid delay: "${delay}". Use hours, minutes or seconds, e.g. 48h or 1h30m`);
|
|
859
|
+
resolveProjectDir(name, config);
|
|
860
|
+
if (getScheduledDestroy(name)?.removing) throw new Error(`The removal of ${name} is in progress`);
|
|
861
|
+
const destroyAt = now() + delayMs;
|
|
862
|
+
scheduleDestroy(name, destroyAt, options.backup ?? false, options.timeoutMs);
|
|
863
|
+
return new Date(destroyAt);
|
|
864
|
+
}
|
|
865
|
+
/** A removal can take longer than the sweep interval; one sweep at a time. */
|
|
866
|
+
var sweeping = false;
|
|
867
|
+
/**
|
|
868
|
+
* Remove each project whose scheduled removal is due, through the same
|
|
869
|
+
* removal as `trafic-agent destroy`, with a backup when one was asked for.
|
|
870
|
+
*
|
|
871
|
+
* A failure on one project does not stop the sweep: it is logged, and the
|
|
872
|
+
* removal stays scheduled so the next sweep tries again. A project already
|
|
873
|
+
* gone has its schedule cleared.
|
|
874
|
+
*
|
|
875
|
+
* A removal can take minutes, and a deploy or a `--cancel` can cancel the
|
|
876
|
+
* removal of a project meanwhile. So the sweep reads each removal again just
|
|
877
|
+
* before it starts, and once more after the backup. There, it marks the
|
|
878
|
+
* removal as started (startScheduledDestroy) before `ddev delete`: a later
|
|
879
|
+
* cancel is refused, so the deploy fails instead of deploying a project
|
|
880
|
+
* that is about to go. A retry of a started removal does not back the
|
|
881
|
+
* project up again: the backup exists, and DDEV may have forgotten the
|
|
882
|
+
* project already.
|
|
883
|
+
*/
|
|
884
|
+
async function destroyScheduledProjects(config, now = systemClock, run = nodeDdevRunner) {
|
|
885
|
+
if (sweeping) return;
|
|
886
|
+
sweeping = true;
|
|
887
|
+
try {
|
|
888
|
+
for (const { name } of getScheduledDestroys(now())) {
|
|
889
|
+
const scheduled = getScheduledDestroy(name, now());
|
|
890
|
+
if (!scheduled) continue;
|
|
891
|
+
console.log(`Destroying ${name}: its scheduled removal is ${scheduled.removing ? "in progress" : "due"}`);
|
|
892
|
+
try {
|
|
893
|
+
await destroyProject(name, config, {
|
|
894
|
+
backup: scheduled.backup && !scheduled.removing,
|
|
895
|
+
timeoutMs: scheduled.timeoutMs,
|
|
896
|
+
beforeDelete: () => startScheduledDestroy(name, now())
|
|
897
|
+
}, run);
|
|
898
|
+
} catch (error) {
|
|
899
|
+
console.error(`Scheduled removal of ${name} failed, retrying on the next sweep: ${error.message}`);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
} finally {
|
|
903
|
+
sweeping = false;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
/** A sweep that throws must not take the scheduler down with it. */
|
|
907
|
+
function reportSweepError(error) {
|
|
908
|
+
console.error("Scheduled removal sweep failed:", error);
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Start the scheduled removal sweep, on the idle check interval
|
|
912
|
+
*/
|
|
913
|
+
function startScheduledDestroyScheduler(config) {
|
|
914
|
+
const intervalMs = parseDuration(config.idleCheckInterval);
|
|
915
|
+
console.log(`Scheduled removal sweep started: checking every ${config.idleCheckInterval}`);
|
|
916
|
+
destroyScheduledProjects(config).catch(reportSweepError);
|
|
917
|
+
return setInterval(() => {
|
|
918
|
+
destroyScheduledProjects(config).catch(reportSweepError);
|
|
919
|
+
}, intervalMs);
|
|
920
|
+
}
|
|
921
|
+
/** Retention is counted in days, so an hourly sweep is enough. */
|
|
922
|
+
var SWEEP_INTERVAL_MS = 36e5;
|
|
923
|
+
/** A sweep that throws must not take the scheduler down with it. */
|
|
924
|
+
function sweepAccessLogs() {
|
|
925
|
+
try {
|
|
926
|
+
const removed = cleanOldLogs(30);
|
|
927
|
+
if (removed > 0) console.log(`Removed ${removed} access logs older than 30 days`);
|
|
928
|
+
} catch (error) {
|
|
929
|
+
console.error("Access log sweep failed:", error);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Start the access log retention sweep. Each request logs a row, so without
|
|
934
|
+
* it the database grows for good. The database must be open.
|
|
935
|
+
*/
|
|
936
|
+
function startAccessLogSweepScheduler() {
|
|
937
|
+
console.log(`Access log sweep started: removing access logs older than 30 days`);
|
|
938
|
+
sweepAccessLogs();
|
|
939
|
+
return setInterval(sweepAccessLogs, SWEEP_INTERVAL_MS);
|
|
940
|
+
}
|
|
941
|
+
//#endregion
|
|
173
942
|
//#region src/setup/user.ts
|
|
174
943
|
/**
|
|
175
944
|
* Write /etc/sudoers.d/trafic-ddev allowing the ddev user to run
|
|
@@ -357,191 +1126,6 @@ function setupDockerPrune(io = nodeIo) {
|
|
|
357
1126
|
success("Docker prune scheduled weekly at 3am on Sundays");
|
|
358
1127
|
}
|
|
359
1128
|
//#endregion
|
|
360
|
-
//#region src/setup/agent.ts
|
|
361
|
-
/** Node.js major version required by the agent */
|
|
362
|
-
var NODE_MAJOR = 24;
|
|
363
|
-
/** NodeSource keyring path */
|
|
364
|
-
var NODESOURCE_KEYRING = "/etc/apt/keyrings/nodesource.gpg";
|
|
365
|
-
/**
|
|
366
|
-
* Add the NodeSource apt repository and GPG key.
|
|
367
|
-
* Idempotent: safe to call even if the repository is already configured.
|
|
368
|
-
*
|
|
369
|
-
* nodejs.org points at NodeSource for apt installs. The Ubuntu 24.04
|
|
370
|
-
* `nodejs` package is 18.x, far below what the agent needs.
|
|
371
|
-
*/
|
|
372
|
-
function addNodeSourceAptRepo(io = nodeIo) {
|
|
373
|
-
io.exec("install -m 0755 -d /etc/apt/keyrings", { silent: true });
|
|
374
|
-
io.exec("curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key -o /tmp/trafic-nodesource.key", { silent: true });
|
|
375
|
-
io.exec("gpg --batch --yes --dearmor -o /tmp/trafic-nodesource.gpg /tmp/trafic-nodesource.key", { silent: true });
|
|
376
|
-
io.exec(`install -m 0644 /tmp/trafic-nodesource.gpg ${NODESOURCE_KEYRING}`, { silent: true });
|
|
377
|
-
io.exec("rm -f /tmp/trafic-nodesource.key /tmp/trafic-nodesource.gpg", { silent: true });
|
|
378
|
-
io.exec(`echo "deb [signed-by=${NODESOURCE_KEYRING}] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list > /dev/null`, { silent: true });
|
|
379
|
-
}
|
|
380
|
-
/**
|
|
381
|
-
* Install Node.js from the NodeSource apt repository.
|
|
382
|
-
*
|
|
383
|
-
* apt keeps node and npm in /usr/bin — on root's PATH, so `which
|
|
384
|
-
* trafic-agent` resolves for the systemd unit — and unattended-upgrades
|
|
385
|
-
* patches them, which a version manager install never gets.
|
|
386
|
-
*/
|
|
387
|
-
function installNode(io = nodeIo) {
|
|
388
|
-
step("Install Node.js");
|
|
389
|
-
if (io.commandExists("node")) {
|
|
390
|
-
const version = io.exec("node --version", { silent: true });
|
|
391
|
-
info(`Node.js already installed: ${version?.trim()}`);
|
|
392
|
-
return;
|
|
393
|
-
}
|
|
394
|
-
info("Adding NodeSource apt repository...");
|
|
395
|
-
addNodeSourceAptRepo(io);
|
|
396
|
-
info("Installing Node.js...");
|
|
397
|
-
io.exec("apt-get update -qq", { silent: true });
|
|
398
|
-
io.exec("DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y nodejs", { silent: true });
|
|
399
|
-
success(`Node.js ${NODE_MAJOR} installed from apt`);
|
|
400
|
-
}
|
|
401
|
-
/**
|
|
402
|
-
* Install the Trafic agent
|
|
403
|
-
*/
|
|
404
|
-
function installAgent(io = nodeIo) {
|
|
405
|
-
step("Install Trafic agent");
|
|
406
|
-
io.exec("npm install -g @studiometa/trafic-agent");
|
|
407
|
-
success("Trafic agent installed");
|
|
408
|
-
}
|
|
409
|
-
/**
|
|
410
|
-
* Create the agent configuration
|
|
411
|
-
*/
|
|
412
|
-
function createAgentConfig(options, io = nodeIo) {
|
|
413
|
-
const { tld, trustedProxyHops = 1, dnsProvider, dnsEnv = {} } = options;
|
|
414
|
-
step("Create agent configuration");
|
|
415
|
-
io.exec("mkdir -p /etc/trafic");
|
|
416
|
-
io.exec("mkdir -p /var/lib/trafic");
|
|
417
|
-
io.exec("chown ddev:ddev /var/lib/trafic");
|
|
418
|
-
const configPath = "/etc/trafic/config.toml";
|
|
419
|
-
const tlsSection = buildTlsSection(dnsProvider, dnsEnv);
|
|
420
|
-
if (io.fileExists(configPath)) {
|
|
421
|
-
info("Configuration already exists at /etc/trafic/config.toml — skipping");
|
|
422
|
-
info("Edit this file to configure authentication");
|
|
423
|
-
if (tlsSection) {
|
|
424
|
-
info("Add this to /etc/trafic/config.toml, then run `trafic-agent upgrade`:");
|
|
425
|
-
for (const line of buildTlsSection(dnsProvider, dnsEnv, true).trim().split("\n")) info(` ${line}`);
|
|
426
|
-
info("Replace each REPLACE_ME with the value you passed to --dns-env.");
|
|
427
|
-
}
|
|
428
|
-
return;
|
|
429
|
-
}
|
|
430
|
-
const config = `# Trafic agent configuration
|
|
431
|
-
# Generated by trafic setup
|
|
432
|
-
|
|
433
|
-
# Required: TLD for DDEV projects
|
|
434
|
-
tld = "${tld}"
|
|
435
|
-
|
|
436
|
-
# Agent HTTP server port
|
|
437
|
-
port = 9876
|
|
438
|
-
|
|
439
|
-
# Database and paths
|
|
440
|
-
db_path = "/var/lib/trafic/db.sqlite"
|
|
441
|
-
project_list_path = "/home/ddev/.ddev/project_list.yaml"
|
|
442
|
-
projects_dir = "/home/ddev/www"
|
|
443
|
-
|
|
444
|
-
# Scale-to-zero settings
|
|
445
|
-
idle_timeout = "4h"
|
|
446
|
-
idle_check_interval = "5m"
|
|
447
|
-
|
|
448
|
-
# Authentication
|
|
449
|
-
[auth]
|
|
450
|
-
default_policy = "basic"
|
|
451
|
-
allowed_ips = []
|
|
452
|
-
tokens = []
|
|
453
|
-
basic_auth = []
|
|
454
|
-
rules = []
|
|
455
|
-
|
|
456
|
-
# Proxies in front of the agent, counted so the client address can be read
|
|
457
|
-
# from X-Forwarded-For. 1 is ddev-router/Traefik alone. Raise it by one for
|
|
458
|
-
# every extra proxy — a CDN or load balancer ahead of Traefik makes it 2.
|
|
459
|
-
# Too low and allowed_ips matches a proxy instead of the client; too high and
|
|
460
|
-
# it can be spoofed by a client-supplied header.
|
|
461
|
-
trusted_proxy_hops = ${trustedProxyHops}
|
|
462
|
-
${tlsSection}`;
|
|
463
|
-
io.writeFile(configPath, config);
|
|
464
|
-
io.exec("chmod 640 /etc/trafic/config.toml");
|
|
465
|
-
io.exec("chown root:ddev /etc/trafic/config.toml");
|
|
466
|
-
success("Configuration created at /etc/trafic/config.toml");
|
|
467
|
-
info("Edit this file to configure authentication");
|
|
468
|
-
}
|
|
469
|
-
/**
|
|
470
|
-
* Build the [tls] section of the generated config.
|
|
471
|
-
*
|
|
472
|
-
* Empty without a DNS provider: per-host certificates stay the default, and
|
|
473
|
-
* an empty section would only invite a half-filled one.
|
|
474
|
-
*/
|
|
475
|
-
function buildTlsSection(dnsProvider, dnsEnv, redact = false) {
|
|
476
|
-
if (!dnsProvider) return "";
|
|
477
|
-
const env = Object.entries(dnsEnv).map(([key, value]) => `${key} = "${redact ? "REPLACE_ME" : escapeTomlString(value)}"`).join("\n");
|
|
478
|
-
return `
|
|
479
|
-
# Wildcard certificate (DNS-01). One *.<tld> certificate replaces the
|
|
480
|
-
# per-hostname ones, so previews stop consuming the Let's Encrypt quota.
|
|
481
|
-
[tls]
|
|
482
|
-
dns_provider = "${escapeTomlString(dnsProvider)}"
|
|
483
|
-
# ca_server = "https://acme-staging-v02.api.letsencrypt.org/directory"
|
|
484
|
-
|
|
485
|
-
# Credentials the lego provider reads. Passed to the ddev-router container.
|
|
486
|
-
[tls.dns_env]
|
|
487
|
-
${env}
|
|
488
|
-
`;
|
|
489
|
-
}
|
|
490
|
-
/** Escape a value for a basic (double-quoted) TOML string. */
|
|
491
|
-
function escapeTomlString(value) {
|
|
492
|
-
return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
|
|
493
|
-
}
|
|
494
|
-
/**
|
|
495
|
-
* Create systemd service for the agent
|
|
496
|
-
*/
|
|
497
|
-
function createSystemdService(io = nodeIo) {
|
|
498
|
-
step("Create systemd service");
|
|
499
|
-
const serviceContent = `[Unit]
|
|
500
|
-
Description=Trafic Agent - DDEV preview environment manager
|
|
501
|
-
After=network.target docker.service
|
|
502
|
-
Requires=docker.service
|
|
503
|
-
|
|
504
|
-
[Service]
|
|
505
|
-
Type=simple
|
|
506
|
-
User=ddev
|
|
507
|
-
Group=ddev
|
|
508
|
-
ExecStart=${io.exec("which trafic-agent", { silent: true })?.trim() || "/usr/bin/trafic-agent"} start --config /etc/trafic/config.toml
|
|
509
|
-
Restart=always
|
|
510
|
-
RestartSec=5
|
|
511
|
-
StandardOutput=journal
|
|
512
|
-
StandardError=journal
|
|
513
|
-
SyslogIdentifier=trafic-agent
|
|
514
|
-
|
|
515
|
-
# Environment
|
|
516
|
-
Environment=NODE_ENV=production
|
|
517
|
-
# Skip ddev-hostname /etc/hosts management — on a server, hostnames resolve
|
|
518
|
-
# to the public IP (not localhost), which would trigger ddev-hostname on every
|
|
519
|
-
# ddev start. DDEV_NONINTERACTIVE=true skips that entirely.
|
|
520
|
-
Environment=DDEV_NONINTERACTIVE=true
|
|
521
|
-
|
|
522
|
-
# Security hardening
|
|
523
|
-
NoNewPrivileges=true
|
|
524
|
-
ProtectSystem=strict
|
|
525
|
-
ProtectHome=read-only
|
|
526
|
-
# ddev start writes across its own home: the project directory
|
|
527
|
-
# (.ddev/.webimageBuild), buildx state in .docker, mutagen data. Listing
|
|
528
|
-
# individual paths is whack-a-mole across DDEV versions, so grant the home
|
|
529
|
-
# and leave the rest of /home read-only.
|
|
530
|
-
ReadWritePaths=/var/lib/trafic /home/ddev
|
|
531
|
-
PrivateTmp=true
|
|
532
|
-
|
|
533
|
-
[Install]
|
|
534
|
-
WantedBy=multi-user.target
|
|
535
|
-
`;
|
|
536
|
-
io.writeFile("/etc/systemd/system/trafic-agent.service", serviceContent);
|
|
537
|
-
io.exec("chmod 644 /etc/systemd/system/trafic-agent.service");
|
|
538
|
-
io.exec("systemctl daemon-reload");
|
|
539
|
-
io.exec("systemctl enable trafic-agent");
|
|
540
|
-
io.exec("systemctl start trafic-agent");
|
|
541
|
-
success("Trafic agent service started");
|
|
542
|
-
info("View logs: journalctl -u trafic-agent -f");
|
|
543
|
-
}
|
|
544
|
-
//#endregion
|
|
545
1129
|
//#region src/setup/hardening.ts
|
|
546
1130
|
/** The sshd drop-in written by `setup`, and the only file hardening owns. */
|
|
547
1131
|
var SSHD_DROPIN = "/etc/ssh/sshd_config.d/trafic.conf";
|
|
@@ -1297,7 +1881,7 @@ http:
|
|
|
1297
1881
|
};
|
|
1298
1882
|
//#endregion
|
|
1299
1883
|
//#region src/setup/migrations/0009__scale_to_zero_fixes.ts
|
|
1300
|
-
var UNIT_PATH = "/etc/systemd/system/trafic-agent.service";
|
|
1884
|
+
var UNIT_PATH$1 = "/etc/systemd/system/trafic-agent.service";
|
|
1301
1885
|
var DYNAMIC_CONFIG$2 = "/home/ddev/.ddev/traefik/trafic.yaml";
|
|
1302
1886
|
/**
|
|
1303
1887
|
* Migration 0009: Make scale-to-zero actually work.
|
|
@@ -1324,7 +1908,7 @@ var migration0009ScaleToZeroFixes = {
|
|
|
1324
1908
|
id: "0009__scale_to_zero_fixes",
|
|
1325
1909
|
description: "Fix scale-to-zero: waiting page router and agent write access",
|
|
1326
1910
|
run() {
|
|
1327
|
-
const unitNeedsWrite = !existsSync(UNIT_PATH) || !/^ReadWritePaths=.*\/home\/ddev(\s|$)/m.test(readFileSync(UNIT_PATH, "utf-8"));
|
|
1911
|
+
const unitNeedsWrite = !existsSync(UNIT_PATH$1) || !/^ReadWritePaths=.*\/home\/ddev(\s|$)/m.test(readFileSync(UNIT_PATH$1, "utf-8"));
|
|
1328
1912
|
if (!existsSync(DYNAMIC_CONFIG$2) || !readFileSync(DYNAMIC_CONFIG$2, "utf-8").includes("trafic-catchall")) {
|
|
1329
1913
|
configureTraefik();
|
|
1330
1914
|
exec("su - ddev -c 'DDEV_NONINTERACTIVE=true ddev poweroff && ddev start --all'", { silent: true });
|
|
@@ -1782,6 +2366,38 @@ function parseTasks(output) {
|
|
|
1782
2366
|
}));
|
|
1783
2367
|
}
|
|
1784
2368
|
//#endregion
|
|
2369
|
+
//#region src/setup/migrations/0020__backup_dir.ts
|
|
2370
|
+
var UNIT_PATH = "/etc/systemd/system/trafic-agent.service";
|
|
2371
|
+
/**
|
|
2372
|
+
* Migration 0020: let the agent service write to the backup directory.
|
|
2373
|
+
*
|
|
2374
|
+
* The agent now removes old backups from /var/backups/trafic in a periodic
|
|
2375
|
+
* sweep, but the unit runs with `ProtectSystem=strict` and only lists
|
|
2376
|
+
* /var/lib/trafic and /home/ddev in `ReadWritePaths`, so every removal would
|
|
2377
|
+
* fail with a read-only file system error.
|
|
2378
|
+
*
|
|
2379
|
+
* Creates the directory first: hardening created it, but a server set up with
|
|
2380
|
+
* `--no-hardening` has none, and the ddev user cannot create it in
|
|
2381
|
+
* /var/backups. Then rewrites the unit, which lists the directory with the
|
|
2382
|
+
* `-` prefix: systemd refuses to start a unit whose `ReadWritePaths` names a
|
|
2383
|
+
* missing path, unless the path has that prefix.
|
|
2384
|
+
*
|
|
2385
|
+
* Idempotent: the unit is rewritten only while it does not list the backup
|
|
2386
|
+
* directory with the `-` prefix.
|
|
2387
|
+
*/
|
|
2388
|
+
var migration0020BackupDir = {
|
|
2389
|
+
id: "0020__backup_dir",
|
|
2390
|
+
description: "Let the agent service write to the backup directory",
|
|
2391
|
+
run() {
|
|
2392
|
+
runBackupDirMigration();
|
|
2393
|
+
}
|
|
2394
|
+
};
|
|
2395
|
+
/** The migration body, with its effects injected so tests can drive it. */
|
|
2396
|
+
function runBackupDirMigration(io = nodeIo) {
|
|
2397
|
+
createBackupDir$1(io);
|
|
2398
|
+
if (!(io.fileExists(UNIT_PATH) && new RegExp(`^ReadWritePaths=.*\\s-/var/backups/trafic(\\s|$)`, "m").test(io.readFile(UNIT_PATH)))) createSystemdService(io);
|
|
2399
|
+
}
|
|
2400
|
+
//#endregion
|
|
1785
2401
|
//#region src/setup/migrations/index.ts
|
|
1786
2402
|
/** Path to the persisted migration state file */
|
|
1787
2403
|
var MIGRATIONS_STATE_FILE = "/etc/trafic/.migrations.json";
|
|
@@ -1805,7 +2421,8 @@ var ALL_MIGRATIONS = [
|
|
|
1805
2421
|
migration0016WildcardDnsChallenge,
|
|
1806
2422
|
migration0018DockerAddressPools,
|
|
1807
2423
|
migration0017DdevAptCloudsmith,
|
|
1808
|
-
migration0019RecoverOrphanContainers
|
|
2424
|
+
migration0019RecoverOrphanContainers,
|
|
2425
|
+
migration0020BackupDir
|
|
1809
2426
|
];
|
|
1810
2427
|
/**
|
|
1811
2428
|
* Load the current migration state from disk.
|
|
@@ -1961,6 +2578,6 @@ async function audit() {
|
|
|
1961
2578
|
if (checks.filter((c) => c.status === "fail").length > 0) process.exit(1);
|
|
1962
2579
|
}
|
|
1963
2580
|
//#endregion
|
|
1964
|
-
export { printAuditResults as a,
|
|
2581
|
+
export { configureDockerFirewall as C, buildDockerFirewallUnit as S, startIdleScheduler as _, printAuditResults as a, DOCKER_FIREWALL_UNIT as b, destroyScheduledProjects as c, destroyProject as d, loadDestroyConfig as f, startBackupSweepScheduler as g, removeOldBackups as h, runPendingMigrations as i, scheduleProjectDestroy as l, backupProject as m, setup as n, runAudit as o, openDestroyDb as p, listMigrations as r, startAccessLogSweepScheduler as s, audit as t, startScheduledDestroyScheduler as u, stopIdleProjects as v, buildDockerFirewallScript as x, DOCKER_FIREWALL_SCRIPT as y };
|
|
1965
2582
|
|
|
1966
|
-
//# sourceMappingURL=setup-
|
|
2583
|
+
//# sourceMappingURL=setup-BbXL1ZFY.js.map
|