@studiometa/trafic-agent 0.1.46 → 0.1.48

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.
@@ -0,0 +1,36 @@
1
+ import { type SetupIo } from "../io.js";
2
+ import type { Migration } from "../types.js";
3
+ /** How long a container gets to stop after SIGTERM, in seconds. */
4
+ export declare const STOP_TIMEOUT = 600;
5
+ /**
6
+ * Migration 0019: stop the containers Docker lost track of in migration 0018.
7
+ *
8
+ * 0018 restarted Docker to apply the address pools. On a server set up before
9
+ * 0.1.47, Docker ran the containerd image store (`overlayfs`) although
10
+ * daemon.json asked for `overlay2`, and the restart switched the driver.
11
+ * dockerd skips containers created with another driver, so it forgot every
12
+ * running container — but `live-restore` had left their processes running.
13
+ * Those orphans keep the locks on the project volumes: each new database
14
+ * container exits on "Can't lock aria control file", each Elasticsearch on
15
+ * "failed to obtain node locks", and the project never starts again. Seen
16
+ * on a live server: 66 orphans, 27 of them databases.
17
+ *
18
+ * containerd still knows the orphans: they are the tasks in the `moby`
19
+ * namespace with no matching Docker container. Each one gets SIGTERM, which
20
+ * MariaDB, MySQL and Elasticsearch handle as a clean shutdown, and as long as
21
+ * it needs. No SIGKILL: a database killed mid-write is the one outcome worse
22
+ * than the outage, so a process that does not stop is reported and left
23
+ * alone. The stopped tasks are then deleted, which ends their shims.
24
+ *
25
+ * Nothing is restarted here: the volumes are free again, and the agent starts
26
+ * each project on its next request, on the new driver.
27
+ *
28
+ * Idempotent: does nothing when every containerd task belongs to a container
29
+ * Docker knows.
30
+ */
31
+ export declare const migration0019RecoverOrphanContainers: Migration;
32
+ /**
33
+ * The migration body, with its effects injected so tests can drive it.
34
+ */
35
+ export declare function recoverOrphanContainers(io?: SetupIo): void;
36
+ //# sourceMappingURL=0019__recover_orphan_containers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"0019__recover_orphan_containers.d.ts","sourceRoot":"","sources":["../../../src/setup/migrations/0019__recover_orphan_containers.ts"],"names":[],"mappings":"AACA,OAAO,EAAU,KAAK,OAAO,EAAE,MAAM,UAAU,CAAC;AAChD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAE7C,mEAAmE;AACnE,eAAO,MAAM,YAAY,MAAM,CAAC;AAEhC;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,eAAO,MAAM,oCAAoC,EAAE,SAOlD,CAAC;AASF;;GAEG;AACH,wBAAgB,uBAAuB,CAAC,EAAE,GAAE,OAAgB,GAAG,IAAI,CA8BlE"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/setup/migrations/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAkBjF,iDAAiD;AACjD,eAAO,MAAM,qBAAqB,iCAAiC,CAAC;AAEpE,+CAA+C;AAC/C,eAAO,MAAM,cAAc,EAAE,SAAS,EAiBrC,CAAC;AAMF;;;GAGG;AACH,wBAAgB,kBAAkB,IAAI,cAAc,CAqBnD;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,cAAc,GAAG,IAAI,CAG9D;AAMD;;GAEG;AACH,wBAAgB,cAAc,IAAI,kBAAkB,EAAE,CAMrD;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,UAAQ,GAAG,IAAI,CAyBzD;AAED;;;;;;GAMG;AACH,wBAAgB,wBAAwB,IAAI,IAAI,CAU/C"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/setup/migrations/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAqBjF,iDAAiD;AACjD,eAAO,MAAM,qBAAqB,iCAAiC,CAAC;AAEpE,+CAA+C;AAC/C,eAAO,MAAM,cAAc,EAAE,SAAS,EAoBrC,CAAC;AAMF;;;GAGG;AACH,wBAAgB,kBAAkB,IAAI,cAAc,CAqBnD;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,cAAc,GAAG,IAAI,CAG9D;AAMD;;GAEG;AACH,wBAAgB,cAAc,IAAI,kBAAkB,EAAE,CAMrD;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,UAAQ,GAAG,IAAI,CAyBzD;AAED;;;;;;GAMG;AACH,wBAAgB,wBAAwB,IAAI,IAAI,CAU/C"}
@@ -1,4 +1,4 @@
1
- import { B as resetSteps, C as shouldNeverStop, F as error, H as step, I as exec, L as execSilent, M as setProjectStatus, O as getIdleProjects, P as nodeIo, R as info, S as loadProjectConfig, U as success, V as setDryRun, W as warn, Y as loadProjectList, Z as stopProject, _ as installDdev, at as parseDuration, b as readToolPorts, d as TLS_STORE_CONFIG, f as addDdevAptRepo, g as getDockerGatewayIp, h as findRunningProject, it as loadConfig, l as ROUTER_COMPOSE_OVERRIDE, m as configureTraefik, p as configureDdev, q as getProjectInfo, u as STATIC_CONFIG, v as installSystemDeps, x as getIdleTimeoutMs, y as purgeTraefikAcmeStorage, z as isRoot } from "./server-EPU5dtPx.js";
1
+ import { $ as stopProject, A as getIdleProjects, B as info, C as getIdleTimeoutMs, G as success, H as resetSteps, I as nodeIo, K as warn, L as error, P as setProjectStatus, R as exec, S as readToolPorts, T as shouldNeverStop, U as setDryRun, V as isRoot, W as step, Y as getProjectInfo, Z as loadProjectList, _ as findRunningProjects, b as installSystemDeps, d as STATIC_CONFIG, f as TLS_STORE_CONFIG, g as findRunningProject, h as configureTraefik, m as configureDdev, ot as loadConfig, p as addDdevAptRepo, st as parseDuration, u as ROUTER_COMPOSE_OVERRIDE, v as getDockerGatewayIp, w as loadProjectConfig, x as purgeTraefikAcmeStorage, y as installDdev, z as execSilent } from "./server-DZAo8Clg.js";
2
2
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  //#region src/setup/docker-firewall.ts
4
4
  /** Where the rule script lives, so systemd and a human can both run it. */
@@ -226,6 +226,44 @@ function installDocker(io = nodeIo) {
226
226
  io.exec("usermod -aG docker ddev", { silent: true });
227
227
  success("Added ddev user to docker group");
228
228
  }
229
+ /** Docker daemon config file */
230
+ var DAEMON_CONFIG = "/etc/docker/daemon.json";
231
+ /**
232
+ * Address pools for the networks Docker creates.
233
+ *
234
+ * Every DDEV project gets its own network. Docker's built-in pools hold only
235
+ * about 31 of them (15 × /16 in 172.17–31.x, 16 × /20 in 192.168.x), so a
236
+ * server with ~30 projects fails with "all predefined address pools have
237
+ * been fully subnetted". /24 subnets in 172.16.0.0/12 give 4096 networks,
238
+ * and keep every network inside the range the firewall rules already allow
239
+ * (hardening.ts, docker-firewall.ts) — the built-in 192.168.x pools are not.
240
+ */
241
+ var DOCKER_ADDRESS_POOLS = [{
242
+ base: "172.16.0.0/12",
243
+ size: 24
244
+ }];
245
+ /**
246
+ * Add the address pools to an existing daemon config.
247
+ *
248
+ * Returns true when the config was changed. Leaves a config that already
249
+ * sets pools alone (an operator choice), and one it cannot parse.
250
+ */
251
+ function addDockerAddressPools(io = nodeIo) {
252
+ let config = {};
253
+ if (io.fileExists("/etc/docker/daemon.json")) try {
254
+ config = JSON.parse(io.readFile(DAEMON_CONFIG));
255
+ } catch {
256
+ warn("Could not parse existing Docker config, skipping");
257
+ return false;
258
+ }
259
+ if ("default-address-pools" in config) return false;
260
+ io.exec("mkdir -p /etc/docker");
261
+ io.writeFile(DAEMON_CONFIG, JSON.stringify({
262
+ ...config,
263
+ "default-address-pools": DOCKER_ADDRESS_POOLS
264
+ }, null, 2));
265
+ return true;
266
+ }
229
267
  /**
230
268
  * Configure Docker for production use
231
269
  */
@@ -238,10 +276,11 @@ function configureDocker(io = nodeIo) {
238
276
  "max-file": "3"
239
277
  },
240
278
  "storage-driver": "overlay2",
241
- "live-restore": true
279
+ "live-restore": true,
280
+ "default-address-pools": DOCKER_ADDRESS_POOLS
242
281
  };
243
282
  io.exec("mkdir -p /etc/docker");
244
- const configPath = "/etc/docker/daemon.json";
283
+ const configPath = DAEMON_CONFIG;
245
284
  if (io.fileExists(configPath)) try {
246
285
  const merged = {
247
286
  ...JSON.parse(io.readFile(configPath)),
@@ -254,10 +293,59 @@ function configureDocker(io = nodeIo) {
254
293
  return;
255
294
  }
256
295
  else io.writeFile(configPath, JSON.stringify(daemonConfig, null, 2));
257
- io.exec("systemctl reload docker || systemctl restart docker");
296
+ restartDocker(io);
258
297
  success("Docker configured with production settings");
259
298
  }
260
299
  /**
300
+ * Restart Docker to apply daemon.json, without orphaning running containers.
301
+ *
302
+ * `live-restore` keeps containers running across a restart, but only when
303
+ * the storage driver stays the same. Before 0.1.47, setup applied daemon.json
304
+ * with a reload, and a reload does not apply `storage-driver`. Docker 29
305
+ * defaults a fresh install to the containerd image store (`overlayfs`), so
306
+ * such a server runs `overlayfs` while daemon.json says `overlay2`. The first
307
+ * full restart switches the driver, and dockerd silently skips every
308
+ * container created with the other one. Their processes keep running and
309
+ * keep the locks on the database volumes. DDEV then creates new containers
310
+ * that exit at once on "Can't lock aria control file". Seen on a live server
311
+ * after migration 0018: 66 containers lost, 27 of them databases.
312
+ *
313
+ * So when the driver is about to change, the DDEV projects are stopped first,
314
+ * which shuts their databases down cleanly, and the projects that were running
315
+ * are started again on the new driver. Volumes do not depend on the storage
316
+ * driver, so no data moves. Images are pulled again for the new driver.
317
+ */
318
+ function restartDocker(io = nodeIo) {
319
+ if (!switchesStorageDriver(io)) {
320
+ io.exec("systemctl restart docker");
321
+ return;
322
+ }
323
+ const running = findRunningProjects(io);
324
+ warn("Docker changes its storage driver on restart, stopping DDEV projects first");
325
+ if (io.commandExists("ddev")) io.exec("su - ddev -c 'DDEV_NONINTERACTIVE=true ddev poweroff'", { silent: true });
326
+ io.exec("systemctl restart docker");
327
+ if (running.length > 0) {
328
+ info(`Starting ${running.length} project(s) again: ${running.join(", ")}`);
329
+ io.exec(`su - ddev -c 'DDEV_NONINTERACTIVE=true ddev start ${running.join(" ")}'`, { silent: true });
330
+ }
331
+ }
332
+ /**
333
+ * Whether a restart makes Docker use another storage driver.
334
+ *
335
+ * Compares the driver the daemon runs now with the one daemon.json asks for.
336
+ * A config without `storage-driver` keeps the current one.
337
+ */
338
+ function switchesStorageDriver(io) {
339
+ const active = io.execSilent("docker info --format '{{.Driver}}'");
340
+ if (!active || !io.fileExists("/etc/docker/daemon.json")) return false;
341
+ try {
342
+ const configured = JSON.parse(io.readFile(DAEMON_CONFIG))["storage-driver"];
343
+ return typeof configured === "string" && configured !== active;
344
+ } catch {
345
+ return false;
346
+ }
347
+ }
348
+ /**
261
349
  * Setup Docker system prune cron job
262
350
  */
263
351
  function setupDockerPrune(io = nodeIo) {
@@ -1391,9 +1479,9 @@ var migration0013SshKexDefaults = {
1391
1479
  * and its dist is the Ubuntu codename, so
1392
1480
  * `"origin=Docker,codename=${distro_codename}"` is the entry that matches it.
1393
1481
  *
1394
- * DDEV, installed from pkg.ddev.com, is deliberately not added: a DDEV major
1395
- * landing unattended can break running previews. DDEV is updated by
1396
- * `trafic-agent upgrade` instead, when an operator is there to see it.
1482
+ * DDEV, installed from its own apt repository, is deliberately not added: a
1483
+ * DDEV major landing unattended can break running previews. DDEV is updated
1484
+ * by `trafic-agent upgrade` instead, when an operator is there to see it.
1397
1485
  *
1398
1486
  * `configureUnattendedUpgrades` now writes the Docker entry, which covers
1399
1487
  * fresh installs. Servers set up by an earlier release still carry the old
@@ -1548,6 +1636,152 @@ function findRouterContainer(io = nodeIo) {
1548
1636
  return io.execSilent("docker ps -aq --filter label=com.docker.compose.service=ddev-router").trim().split("\n")[0] || void 0;
1549
1637
  }
1550
1638
  //#endregion
1639
+ //#region src/setup/migrations/0017__ddev_apt_cloudsmith.ts
1640
+ /** The Gemfury source file setup wrote before this release. */
1641
+ var OLD_SOURCES = "/etc/apt/sources.list.d/ddev.list";
1642
+ /** The dearmoured keyring that source was signed by. */
1643
+ var OLD_KEYRING = "/etc/apt/keyrings/ddev.gpg";
1644
+ /**
1645
+ * Migration 0017: move the DDEV apt repository from Gemfury to Cloudsmith.
1646
+ *
1647
+ * DDEV 1.25.4 moved its Debian and RPM packages to Cloudsmith
1648
+ * (ddev/ddev#8698) and its install docs now name
1649
+ * `https://packages.ddev.com/public/deb/ubuntu` with the key at
1650
+ * `https://packages.ddev.com/public/gpg.key`. The former Gemfury host,
1651
+ * `pkg.ddev.com`, still receives packages and DDEV has announced no
1652
+ * retirement date, so nothing is broken today — but a server left on the old
1653
+ * repository would stop seeing new DDEV releases the day it is switched off,
1654
+ * and `trafic-agent upgrade` runs `apt-get install --only-upgrade ddev`
1655
+ * against whatever repository is configured.
1656
+ *
1657
+ * The old files are removed rather than left alongside the new ones, as
1658
+ * DDEV's own instructions do: two sources offering the same package make apt
1659
+ * pick by version and would leave the Gemfury host in the picture for as
1660
+ * long as it answers. `setup` writes only the Cloudsmith source, so a fresh
1661
+ * server and an upgraded one end up identical.
1662
+ *
1663
+ * Idempotent: skipped once the Cloudsmith source file is in place, and each
1664
+ * removal is guarded by the file's existence.
1665
+ */
1666
+ var migration0017DdevAptCloudsmith = {
1667
+ id: "0017__ddev_apt_cloudsmith",
1668
+ description: "Move the DDEV apt repository from Gemfury to Cloudsmith",
1669
+ run() {
1670
+ runDdevAptCloudsmithMigration();
1671
+ }
1672
+ };
1673
+ /** The migration body, with its effects injected so tests can drive it. */
1674
+ function runDdevAptCloudsmithMigration(io = nodeIo) {
1675
+ if (io.fileExists("/etc/apt/sources.list.d/ddev.sources")) return;
1676
+ if (io.fileExists(OLD_SOURCES)) io.exec(`rm -f ${OLD_SOURCES}`, { silent: true });
1677
+ if (io.fileExists(OLD_KEYRING)) io.exec(`rm -f ${OLD_KEYRING}`, { silent: true });
1678
+ addDdevAptRepo(io);
1679
+ io.exec("apt-get update -qq", { silent: true });
1680
+ }
1681
+ //#endregion
1682
+ //#region src/setup/migrations/0018__docker_address_pools.ts
1683
+ /**
1684
+ * Migration 0018: give Docker enough address space for one network per
1685
+ * project.
1686
+ *
1687
+ * `setup` wrote a daemon.json without `default-address-pools`, so Docker
1688
+ * used its built-in pools: about 31 networks. Each DDEV project creates its
1689
+ * own `ddev-<name>_default` network, and a server with ~30 projects failed
1690
+ * to start the next one with "all predefined address pools have been fully
1691
+ * subnetted". The last 16 of those built-in networks are in 192.168.x,
1692
+ * outside the 172.16.0.0/12 range the firewall allows to reach the agent.
1693
+ *
1694
+ * `configureDocker` now writes /24 pools in 172.16.0.0/12, which covers
1695
+ * fresh installs. This migration adds them to existing servers.
1696
+ *
1697
+ * Docker reads the pools only at start, so the daemon is restarted.
1698
+ * `live-restore`, set by setup, keeps the running containers up. Existing
1699
+ * networks keep their subnets; only new networks get a /24, and Docker skips
1700
+ * subnets an existing network already uses.
1701
+ *
1702
+ * Changed after release: the first version ran `systemctl restart docker`
1703
+ * directly. On servers set up before 0.1.47 that restart also switched the
1704
+ * storage driver, which `live-restore` cannot survive, and orphaned every
1705
+ * running container (see restartDocker). The result on a server is the same
1706
+ * either way; only servers that have not run it yet get the safe restart.
1707
+ * Servers that already ran it are repaired by migration 0019.
1708
+ *
1709
+ * Idempotent: does nothing when the config already sets pools.
1710
+ */
1711
+ var migration0018DockerAddressPools = {
1712
+ id: "0018__docker_address_pools",
1713
+ description: "Give Docker address pools for one network per project",
1714
+ run() {
1715
+ runDockerAddressPoolsMigration();
1716
+ }
1717
+ };
1718
+ /**
1719
+ * The migration body, with its effects injected so tests can drive it.
1720
+ */
1721
+ function runDockerAddressPoolsMigration(io = nodeIo) {
1722
+ if (addDockerAddressPools(io)) restartDocker(io);
1723
+ }
1724
+ /**
1725
+ * Migration 0019: stop the containers Docker lost track of in migration 0018.
1726
+ *
1727
+ * 0018 restarted Docker to apply the address pools. On a server set up before
1728
+ * 0.1.47, Docker ran the containerd image store (`overlayfs`) although
1729
+ * daemon.json asked for `overlay2`, and the restart switched the driver.
1730
+ * dockerd skips containers created with another driver, so it forgot every
1731
+ * running container — but `live-restore` had left their processes running.
1732
+ * Those orphans keep the locks on the project volumes: each new database
1733
+ * container exits on "Can't lock aria control file", each Elasticsearch on
1734
+ * "failed to obtain node locks", and the project never starts again. Seen
1735
+ * on a live server: 66 orphans, 27 of them databases.
1736
+ *
1737
+ * containerd still knows the orphans: they are the tasks in the `moby`
1738
+ * namespace with no matching Docker container. Each one gets SIGTERM, which
1739
+ * MariaDB, MySQL and Elasticsearch handle as a clean shutdown, and as long as
1740
+ * it needs. No SIGKILL: a database killed mid-write is the one outcome worse
1741
+ * than the outage, so a process that does not stop is reported and left
1742
+ * alone. The stopped tasks are then deleted, which ends their shims.
1743
+ *
1744
+ * Nothing is restarted here: the volumes are free again, and the agent starts
1745
+ * each project on its next request, on the new driver.
1746
+ *
1747
+ * Idempotent: does nothing when every containerd task belongs to a container
1748
+ * Docker knows.
1749
+ */
1750
+ var migration0019RecoverOrphanContainers = {
1751
+ id: "0019__recover_orphan_containers",
1752
+ description: "Stop the containers Docker lost track of when it changed storage driver",
1753
+ run() {
1754
+ recoverOrphanContainers();
1755
+ }
1756
+ };
1757
+ /**
1758
+ * The migration body, with its effects injected so tests can drive it.
1759
+ */
1760
+ function recoverOrphanContainers(io = nodeIo) {
1761
+ const known = new Set(io.execSilent("docker ps -aq --no-trunc").split("\n").filter(Boolean));
1762
+ const orphans = parseTasks(io.execSilent("ctr -n moby tasks ls")).filter((task) => !known.has(task.id));
1763
+ if (orphans.length === 0) return;
1764
+ info(`Stopping ${orphans.length} container(s) Docker no longer tracks`);
1765
+ const running = orphans.filter((task) => task.status === "RUNNING");
1766
+ for (const task of running) io.execSilent(`kill -TERM ${task.pid}`);
1767
+ const stuck = running.filter((task) => {
1768
+ io.execSilent(`timeout 600 tail --pid=${task.pid} -f /dev/null`);
1769
+ return io.fileExists(`/proc/${task.pid}`);
1770
+ });
1771
+ for (const task of orphans.filter((task) => !stuck.includes(task))) io.execSilent(`ctr -n moby tasks delete ${task.id}`);
1772
+ for (const task of stuck) warn(`Container ${task.id.slice(0, 12)} (PID ${task.pid}) did not stop, left running`);
1773
+ }
1774
+ /**
1775
+ * Parse `ctr tasks ls`: a header, then one `TASK PID STATUS` row per task.
1776
+ */
1777
+ function parseTasks(output) {
1778
+ return output.split("\n").slice(1).map((line) => line.trim().split(/\s+/)).filter((columns) => columns.length >= 3).map(([id, pid, status]) => ({
1779
+ id,
1780
+ pid,
1781
+ status
1782
+ }));
1783
+ }
1784
+ //#endregion
1551
1785
  //#region src/setup/migrations/index.ts
1552
1786
  /** Path to the persisted migration state file */
1553
1787
  var MIGRATIONS_STATE_FILE = "/etc/trafic/.migrations.json";
@@ -1568,7 +1802,10 @@ var ALL_MIGRATIONS = [
1568
1802
  migration0013SshKexDefaults,
1569
1803
  migration0014UnattendedUpgradesDockerOrigin,
1570
1804
  migration0015CatchallPriority,
1571
- migration0016WildcardDnsChallenge
1805
+ migration0016WildcardDnsChallenge,
1806
+ migration0018DockerAddressPools,
1807
+ migration0017DdevAptCloudsmith,
1808
+ migration0019RecoverOrphanContainers
1572
1809
  ];
1573
1810
  /**
1574
1811
  * Load the current migration state from disk.
@@ -1726,4 +1963,4 @@ async function audit() {
1726
1963
  //#endregion
1727
1964
  export { printAuditResults as a, stopIdleProjects as c, buildDockerFirewallScript as d, buildDockerFirewallUnit as f, runPendingMigrations as i, DOCKER_FIREWALL_SCRIPT as l, setup as n, runAudit as o, configureDockerFirewall as p, listMigrations as r, startIdleScheduler as s, audit as t, DOCKER_FIREWALL_UNIT as u };
1728
1965
 
1729
- //# sourceMappingURL=setup-B02lDDZU.js.map
1966
+ //# sourceMappingURL=setup-DWgb6dAD.js.map