@studiometa/trafic-agent 0.1.49 → 0.1.51

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.
Files changed (43) hide show
  1. package/README.md +158 -13
  2. package/dist/cli.js +134 -14
  3. package/dist/cli.js.map +1 -1
  4. package/dist/index.d.ts +7 -2
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +3 -3
  7. package/dist/{server-DZAo8Clg.js → server-Cfk-O4DJ.js} +406 -30
  8. package/dist/server-Cfk-O4DJ.js.map +1 -0
  9. package/dist/server.d.ts +26 -2
  10. package/dist/server.d.ts.map +1 -1
  11. package/dist/server.js +2 -2
  12. package/dist/setup/agent.d.ts +11 -1
  13. package/dist/setup/agent.d.ts.map +1 -1
  14. package/dist/setup/migrations/0020__backup_dir.d.ts +23 -0
  15. package/dist/setup/migrations/0020__backup_dir.d.ts.map +1 -0
  16. package/dist/setup/migrations/index.d.ts.map +1 -1
  17. package/dist/{setup-DWgb6dAD.js → setup-CiWIirVb.js} +827 -209
  18. package/dist/setup-CiWIirVb.js.map +1 -0
  19. package/dist/tasks/access-logs.d.ts +8 -0
  20. package/dist/tasks/access-logs.d.ts.map +1 -0
  21. package/dist/tasks/backup.d.ts +48 -0
  22. package/dist/tasks/backup.d.ts.map +1 -0
  23. package/dist/tasks/destroy-project.d.ts +71 -0
  24. package/dist/tasks/destroy-project.d.ts.map +1 -0
  25. package/dist/tasks/destroy-scheduled.d.ts +41 -0
  26. package/dist/tasks/destroy-scheduled.d.ts.map +1 -0
  27. package/dist/tasks/running-limit.d.ts +54 -0
  28. package/dist/tasks/running-limit.d.ts.map +1 -0
  29. package/dist/tasks/stop-idle.d.ts +13 -2
  30. package/dist/tasks/stop-idle.d.ts.map +1 -1
  31. package/dist/types.d.ts +41 -2
  32. package/dist/types.d.ts.map +1 -1
  33. package/dist/utils/config.d.ts +6 -0
  34. package/dist/utils/config.d.ts.map +1 -1
  35. package/dist/utils/db.d.ts +76 -1
  36. package/dist/utils/db.d.ts.map +1 -1
  37. package/dist/utils/ddev.d.ts +12 -5
  38. package/dist/utils/ddev.d.ts.map +1 -1
  39. package/dist/utils/paths.d.ts +3 -0
  40. package/dist/utils/paths.d.ts.map +1 -0
  41. package/package.json +1 -1
  42. package/dist/server-DZAo8Clg.js.map +0 -1
  43. package/dist/setup-DWgb6dAD.js.map +0 -1
@@ -1,10 +1,17 @@
1
1
  import { existsSync, readFileSync, watch, writeFileSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
2
3
  import { parse } from "smol-toml";
3
4
  import { execFile, execSync } from "node:child_process";
4
5
  import { DatabaseSync } from "node:sqlite";
5
- import { dirname, join, resolve } from "node:path";
6
6
  import { createServer } from "node:http";
7
7
  import { fileURLToPath } from "node:url";
8
+ //#region src/utils/paths.ts
9
+ /** Whether `child` is strictly inside `parent`. Both are resolved paths. */
10
+ function isInside(parent, child) {
11
+ const path = relative(parent, child);
12
+ return Boolean(path) && !isAbsolute(path) && path.split(sep)[0] !== "..";
13
+ }
14
+ //#endregion
8
15
  //#region src/utils/config.ts
9
16
  var DEFAULT_CONFIG_PATHS = [
10
17
  "/etc/trafic/config.toml",
@@ -19,6 +26,7 @@ var DEFAULT_CONFIG = {
19
26
  projectsDir: "/home/ddev/www",
20
27
  idleTimeout: "30m",
21
28
  idleCheckInterval: "5m",
29
+ maxRunningProjects: 0,
22
30
  auth: {
23
31
  defaultPolicy: "basic",
24
32
  allowedIps: [],
@@ -27,7 +35,11 @@ var DEFAULT_CONFIG = {
27
35
  rules: [],
28
36
  trustedProxyHops: 1
29
37
  },
30
- tls: { dnsEnv: {} }
38
+ tls: { dnsEnv: {} },
39
+ backup: {
40
+ dir: "/var/backups/trafic",
41
+ retainDays: 14
42
+ }
31
43
  };
32
44
  /**
33
45
  * Parse a duration string (e.g., "30m", "1h", "2h30m") to milliseconds
@@ -40,6 +52,22 @@ function parseDuration(duration) {
40
52
  const seconds = parseInt(match[3] ?? "0", 10);
41
53
  return (hours * 3600 + minutes * 60 + seconds) * 1e3;
42
54
  }
55
+ /** Duration suffixes accepted in a timeout, in milliseconds. */
56
+ var TIMEOUT_UNITS = {
57
+ s: 1e3,
58
+ m: 6e4,
59
+ h: 36e5
60
+ };
61
+ /**
62
+ * Parse a command-line timeout such as "10m", "90s" or "1h"; a bare number
63
+ * is minutes. The grammar is the one of `trafic --timeout`, which the CLI
64
+ * passes on as is. Returns undefined for anything else, or for zero.
65
+ */
66
+ function parseTimeout(value) {
67
+ const match = /^(\d+)([smh])?$/.exec(value.trim());
68
+ if (!match || Number(match[1]) <= 0) return;
69
+ return Number(match[1]) * TIMEOUT_UNITS[match[2] ?? "m"];
70
+ }
43
71
  /**
44
72
  * Load configuration from TOML file
45
73
  */
@@ -54,6 +82,7 @@ function loadConfig(configPath) {
54
82
  const parsed = parse(content);
55
83
  const auth = mergeAuthConfig(parsed.auth);
56
84
  const tls = mergeTlsConfig(parsed.tls);
85
+ const backup = mergeBackupConfig(parsed.backup);
57
86
  return {
58
87
  tld: parsed.tld ?? DEFAULT_CONFIG.tld,
59
88
  port: parsed.port ?? DEFAULT_CONFIG.port,
@@ -62,8 +91,20 @@ function loadConfig(configPath) {
62
91
  projectsDir: parsed.projects_dir ?? DEFAULT_CONFIG.projectsDir,
63
92
  idleTimeout: parsed.idle_timeout ?? DEFAULT_CONFIG.idleTimeout,
64
93
  idleCheckInterval: parsed.idle_check_interval ?? DEFAULT_CONFIG.idleCheckInterval,
94
+ maxRunningProjects: parsed.max_running_projects ?? DEFAULT_CONFIG.maxRunningProjects,
65
95
  auth,
66
- tls
96
+ tls,
97
+ backup
98
+ };
99
+ }
100
+ /**
101
+ * Read the [backup] section. validateConfig reports a bad value.
102
+ */
103
+ function mergeBackupConfig(raw) {
104
+ if (!raw) return DEFAULT_CONFIG.backup;
105
+ return {
106
+ dir: raw.dir ?? DEFAULT_CONFIG.backup.dir,
107
+ retainDays: raw.retain_days ?? DEFAULT_CONFIG.backup.retainDays
67
108
  };
68
109
  }
69
110
  /**
@@ -134,11 +175,19 @@ function validateConfig(config) {
134
175
  const errors = [];
135
176
  if (!config.tld) errors.push("tld is required");
136
177
  if (config.port < 1 || config.port > 65535) errors.push("port must be between 1 and 65535");
178
+ if (!Number.isInteger(config.maxRunningProjects) || config.maxRunningProjects < 0) errors.push("max_running_projects must be a whole number, 0 or more");
137
179
  if (config.auth.defaultPolicy !== "allow" && config.auth.defaultPolicy !== "deny" && config.auth.defaultPolicy !== "basic" && config.auth.defaultPolicy !== "token") errors.push("auth.default_policy must be \"allow\", \"deny\", \"basic\", or \"token\"");
138
180
  for (const [key, value] of Object.entries(config.tls.dnsEnv)) {
139
181
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) errors.push(`tls.dns_env key "${key}" is not a valid environment variable name`);
140
182
  if (typeof value !== "string") errors.push(`tls.dns_env.${key} must be a string`);
141
183
  }
184
+ if (typeof config.backup.dir !== "string" || !isAbsolute(config.backup.dir)) errors.push("backup.dir must be an absolute path");
185
+ else {
186
+ const backupDir = resolve(config.backup.dir);
187
+ const projectsDir = resolve(config.projectsDir);
188
+ if (backupDir === projectsDir || isInside(projectsDir, backupDir) || isInside(backupDir, projectsDir)) errors.push("backup.dir must not be projects_dir, be inside it or contain it");
189
+ }
190
+ if (!Number.isInteger(config.backup.retainDays) || config.backup.retainDays < 1) errors.push("backup.retain_days must be a whole number of 1 or more");
142
191
  return errors;
143
192
  }
144
193
  //#endregion
@@ -377,8 +426,8 @@ var APPROOT_LINE = /^\s+approot:\s*(.+?)\s*$/;
377
426
  *
378
427
  * The file nests the path under the project name:
379
428
  *
380
- * scalian:
381
- * approot: /home/ddev/www/scalian
429
+ * my-app:
430
+ * approot: /home/ddev/www/my-app
382
431
  *
383
432
  * Indentation therefore has to be read. A flat key/value pass produces one
384
433
  * entry per line instead: the project mapped to an empty string, plus a
@@ -433,10 +482,11 @@ function watchProjectList(projectListPath, onChange) {
433
482
  });
434
483
  }
435
484
  /** The real runner, used unless a caller passes its own. */
436
- var nodeDdevRunner = (args, timeoutMs) => new Promise((resolve) => {
485
+ var nodeDdevRunner = (args, timeoutMs, cwd) => new Promise((resolve) => {
437
486
  execFile("ddev", args, {
438
487
  encoding: "utf-8",
439
- timeout: timeoutMs
488
+ timeout: timeoutMs,
489
+ cwd
440
490
  }, (error, stdout) => {
441
491
  if (error) {
442
492
  console.error(`ddev ${args.join(" ")} failed:`, error.message);
@@ -474,7 +524,7 @@ async function getProjectInfo(name, run = nodeDdevRunner) {
474
524
  httpsURLs: raw.httpsURLs ?? [],
475
525
  type: raw.type,
476
526
  phpVersion: raw.php_version,
477
- dbType: raw.dbinfo?.dbType
527
+ dbType: raw.dbinfo?.database_type
478
528
  };
479
529
  } catch {
480
530
  return;
@@ -498,17 +548,19 @@ async function stopProject(name, run = nodeDdevRunner) {
498
548
  return ok;
499
549
  }
500
550
  /**
501
- * List all DDEV projects with their status
551
+ * List all DDEV projects with their live status.
552
+ *
553
+ * `ddev list -j` asks Docker, so a project started or stopped outside the
554
+ * agent (by `trafic deploy`, say) shows its real state here. It wraps the
555
+ * entries in a `raw` array.
502
556
  */
503
- function listProjects() {
557
+ async function listProjects(run = nodeDdevRunner) {
558
+ const { ok, stdout } = await run(["list", "-j"], 3e4);
559
+ if (!ok) return [];
504
560
  try {
505
- const output = execSync("ddev list -j", {
506
- encoding: "utf-8",
507
- timeout: 3e4
508
- });
509
- const data = JSON.parse(output);
510
- if (!Array.isArray(data)) return [];
511
- return data.map((p) => ({
561
+ const data = JSON.parse(stdout);
562
+ if (!Array.isArray(data.raw)) return [];
563
+ return data.raw.map((p) => ({
512
564
  name: p.name,
513
565
  status: p.status,
514
566
  appRoot: p.approot,
@@ -651,16 +703,25 @@ var nodeIo = {
651
703
  var systemClock = () => Date.now();
652
704
  var db = null;
653
705
  /**
706
+ * How long a write waits for another connection to release the lock.
707
+ *
708
+ * The service and a `trafic-agent destroy` run over SSH open the same file.
709
+ * WAL lets readers and one writer work together, but a second writer fails
710
+ * at once with "database is locked" unless it is told to wait.
711
+ */
712
+ var BUSY_TIMEOUT_MS = 5e3;
713
+ /**
654
714
  * Initialize the database connection and schema
655
715
  */
656
716
  function initDb(dbPath) {
657
- db = new DatabaseSync(dbPath);
717
+ db = new DatabaseSync(dbPath, { timeout: BUSY_TIMEOUT_MS });
658
718
  db.exec("PRAGMA journal_mode=WAL");
659
719
  db.exec(`
660
720
  CREATE TABLE IF NOT EXISTS projects (
661
721
  name TEXT PRIMARY KEY,
662
722
  last_access INTEGER NOT NULL,
663
- status TEXT NOT NULL DEFAULT 'stopped'
723
+ status TEXT NOT NULL DEFAULT 'stopped',
724
+ status_pid INTEGER
664
725
  );
665
726
 
666
727
  CREATE TABLE IF NOT EXISTS access_logs (
@@ -674,10 +735,31 @@ function initDb(dbPath) {
674
735
 
675
736
  CREATE INDEX IF NOT EXISTS idx_access_logs_project ON access_logs(project);
676
737
  CREATE INDEX IF NOT EXISTS idx_access_logs_timestamp ON access_logs(timestamp);
738
+
739
+ CREATE TABLE IF NOT EXISTS scheduled_destroys (
740
+ name TEXT PRIMARY KEY,
741
+ destroy_at INTEGER NOT NULL,
742
+ backup INTEGER NOT NULL DEFAULT 0,
743
+ timeout_ms INTEGER,
744
+ removing INTEGER NOT NULL DEFAULT 0
745
+ );
677
746
  `);
747
+ addColumn(db, "projects", "status_pid INTEGER");
678
748
  return db;
679
749
  }
680
750
  /**
751
+ * Add a column to a table that does not have it yet. The service and a
752
+ * `trafic-agent destroy` run can both get here first, so a column that
753
+ * exists already is not an error.
754
+ */
755
+ function addColumn(database, table, column) {
756
+ try {
757
+ database.exec(`ALTER TABLE ${table} ADD COLUMN ${column}`);
758
+ } catch (error) {
759
+ if (!/duplicate column name/i.test(error.message)) throw error;
760
+ }
761
+ }
762
+ /**
681
763
  * Get the database instance
682
764
  */
683
765
  function getDb() {
@@ -710,14 +792,63 @@ function getProject(name) {
710
792
  return getDb().prepare("SELECT name, last_access as lastAccess, status FROM projects WHERE name = ?").get(name);
711
793
  }
712
794
  /**
795
+ * The statuses a process sets while it works on a project: "starting" (the
796
+ * waiting page, in the service) and "destroying" (`trafic-agent destroy`).
797
+ */
798
+ var BUSY_STATUSES = ["starting", "destroying"];
799
+ /**
713
800
  * Set project status
801
+ *
802
+ * A busy status ("starting", "destroying") records the id of this process,
803
+ * so releaseStaleStatuses can tell when the process that set it is gone.
714
804
  */
715
805
  function setProjectStatus(name, status, now = systemClock) {
716
806
  getDb().prepare(`
717
- INSERT INTO projects (name, last_access, status)
718
- VALUES (?, ?, ?)
719
- ON CONFLICT(name) DO UPDATE SET status = excluded.status
720
- `).run(name, now(), status);
807
+ INSERT INTO projects (name, last_access, status, status_pid)
808
+ VALUES (?, ?, ?, ?)
809
+ ON CONFLICT(name) DO UPDATE SET
810
+ status = excluded.status,
811
+ status_pid = excluded.status_pid
812
+ `).run(name, now(), status, BUSY_STATUSES.includes(status) ? process.pid : null);
813
+ }
814
+ /**
815
+ * Whether a process is alive. EPERM means it is, but belongs to another user.
816
+ */
817
+ function isProcessAlive(pid) {
818
+ try {
819
+ process.kill(pid, 0);
820
+ return true;
821
+ } catch (error) {
822
+ return error.code === "EPERM";
823
+ }
824
+ }
825
+ /**
826
+ * Set back to "running" the busy statuses ("starting", "destroying") whose
827
+ * process is gone, and return the names of those projects.
828
+ *
829
+ * The process that sets a busy status sets it back when it ends. A process
830
+ * that is killed (a cancelled CI job, a dropped SSH connection, a restart of
831
+ * the service) cannot, and the status would stay: the sweeps never stop a
832
+ * busy project, max_running_projects counts it as running, and the waiting
833
+ * page never starts it. "running" lets the idle sweep check the project
834
+ * against DDEV and stop it, also when it is stopped already.
835
+ *
836
+ * `isAlive` says whether a process still works on its project: the service
837
+ * passes its own check at start, where its own id is not alive yet.
838
+ */
839
+ function releaseStaleStatuses(isAlive = isProcessAlive) {
840
+ const database = getDb();
841
+ const rows = database.prepare("SELECT name, status, status_pid AS pid FROM projects WHERE status IN ('starting', 'destroying')").all();
842
+ const release = database.prepare(`
843
+ UPDATE projects SET status = 'running', status_pid = NULL
844
+ WHERE name = ? AND status = ? AND status_pid IS ?
845
+ `);
846
+ const released = [];
847
+ for (const { name, status, pid } of rows) {
848
+ if (pid !== null && isAlive(pid)) continue;
849
+ if (Number(release.run(name, status, pid).changes) > 0) released.push(name);
850
+ }
851
+ return released;
721
852
  }
722
853
  /**
723
854
  * Get idle projects (last access older than threshold)
@@ -732,6 +863,118 @@ function getIdleProjects(thresholdMs, now = systemClock) {
732
863
  `).all(cutoff);
733
864
  }
734
865
  /**
866
+ * Delete every record of a project: its status row, its access logs and its
867
+ * scheduled removal. Returns whether there was any.
868
+ */
869
+ function deleteProject(name) {
870
+ const database = getDb();
871
+ return [
872
+ database.prepare("DELETE FROM projects WHERE name = ?").run(name),
873
+ database.prepare("DELETE FROM access_logs WHERE project = ?").run(name),
874
+ database.prepare("DELETE FROM scheduled_destroys WHERE name = ?").run(name)
875
+ ].reduce((total, result) => total + Number(result.changes), 0) > 0;
876
+ }
877
+ /**
878
+ * Delete the records of the projects that DDEV no longer lists, and return
879
+ * their names.
880
+ *
881
+ * A project removed outside the agent (`ddev delete` by hand, or a
882
+ * `trafic destroy` older than the agent's own removal) leaves its row behind.
883
+ * releaseStaleStatuses would set that row back to "running", and every sweep
884
+ * would then ask DDEV about a project it does not know.
885
+ *
886
+ * A project with a busy status whose process lives is kept: `ddev delete`
887
+ * takes it out of the list before its removal deletes the records. A project
888
+ * with a scheduled removal is kept too: `ddev delete` by hand leaves its
889
+ * directory, and the scheduled removal still has to remove it. An empty
890
+ * list is kept as is too: a missing or unreadable project list is more likely
891
+ * than a server with no project, and keeping the rows is harmless.
892
+ */
893
+ function deleteUnlistedProjects(listed, isAlive = isProcessAlive) {
894
+ if (listed.size === 0) return [];
895
+ const rows = getDb().prepare("SELECT name, status, status_pid AS pid FROM projects WHERE name NOT IN (SELECT name FROM scheduled_destroys)").all();
896
+ const deleted = [];
897
+ for (const { name, status, pid } of rows) {
898
+ if (listed.has(name)) continue;
899
+ if (BUSY_STATUSES.includes(status) && pid !== null && isAlive(pid)) continue;
900
+ deleteProject(name);
901
+ deleted.push(name);
902
+ }
903
+ return deleted;
904
+ }
905
+ /**
906
+ * Schedule the removal of a project, or replace a removal already
907
+ * scheduled. `timeoutMs` is the time limit of the backup.
908
+ */
909
+ function scheduleDestroy(name, destroyAt, backup, timeoutMs) {
910
+ getDb().prepare(`
911
+ INSERT INTO scheduled_destroys (name, destroy_at, backup, timeout_ms)
912
+ VALUES (?, ?, ?, ?)
913
+ ON CONFLICT(name) DO UPDATE SET
914
+ destroy_at = excluded.destroy_at,
915
+ backup = excluded.backup,
916
+ timeout_ms = excluded.timeout_ms
917
+ `).run(name, destroyAt, backup ? 1 : 0, timeoutMs ?? null);
918
+ }
919
+ function toScheduledDestroy({ name, destroyAt, backup, timeoutMs, removing }) {
920
+ return {
921
+ name,
922
+ destroyAt,
923
+ backup: backup === 1,
924
+ ...timeoutMs === null ? {} : { timeoutMs },
925
+ removing: removing === 1
926
+ };
927
+ }
928
+ /** The columns of a ScheduledDestroy. */
929
+ var SCHEDULED_DESTROY_COLUMNS = "name, destroy_at as destroyAt, backup, timeout_ms as timeoutMs, removing";
930
+ /**
931
+ * Cancel the scheduled removal of a project:
932
+ * - "cancelled" when it was scheduled and is now cancelled
933
+ * - "none" when there was none
934
+ * - "removing" when the sweep already removes the project (see
935
+ * startScheduledDestroy): it is too late, and the removal stays
936
+ */
937
+ function cancelScheduledDestroy(name) {
938
+ const result = getDb().prepare("DELETE FROM scheduled_destroys WHERE name = ? AND removing = 0").run(name);
939
+ if (Number(result.changes) > 0) return "cancelled";
940
+ return getScheduledDestroy(name) ? "removing" : "none";
941
+ }
942
+ /**
943
+ * Mark a scheduled removal as started, when it is still due at `dueBy`.
944
+ * From then on, a cancel is refused, and a retry after a failure does not
945
+ * back the project up again. Returns false when the removal was cancelled
946
+ * or moved to a later date meanwhile. One statement, so a cancel from
947
+ * another process comes either before (false) or after (refused).
948
+ */
949
+ function startScheduledDestroy(name, dueBy) {
950
+ const result = getDb().prepare("UPDATE scheduled_destroys SET removing = 1 WHERE name = ? AND destroy_at <= ?").run(name, dueBy);
951
+ return Number(result.changes) > 0;
952
+ }
953
+ /**
954
+ * Get the scheduled removals, soonest first. With `dueBy`, only the removals
955
+ * due at or before that time.
956
+ */
957
+ function getScheduledDestroys(dueBy = Number.MAX_SAFE_INTEGER) {
958
+ return getDb().prepare(`
959
+ SELECT ${SCHEDULED_DESTROY_COLUMNS}
960
+ FROM scheduled_destroys
961
+ WHERE destroy_at <= ?
962
+ ORDER BY destroy_at, name
963
+ `).all(dueBy).map(toScheduledDestroy);
964
+ }
965
+ /**
966
+ * Get the scheduled removal of a project when it is due at or before
967
+ * `dueBy`, or undefined when there is none.
968
+ */
969
+ function getScheduledDestroy(name, dueBy = Number.MAX_SAFE_INTEGER) {
970
+ const row = getDb().prepare(`
971
+ SELECT ${SCHEDULED_DESTROY_COLUMNS}
972
+ FROM scheduled_destroys
973
+ WHERE name = ? AND destroy_at <= ?
974
+ `).get(name, dueBy);
975
+ return row && toScheduledDestroy(row);
976
+ }
977
+ /**
735
978
  * Log an access
736
979
  */
737
980
  function logAccess(log) {
@@ -828,6 +1071,86 @@ function getIdleTimeoutMs(config) {
828
1071
  return ms > 0 ? ms : void 0;
829
1072
  }
830
1073
  //#endregion
1074
+ //#region src/tasks/running-limit.ts
1075
+ /**
1076
+ * The real dependencies: the agent database, and DDEV through `run`. The
1077
+ * default runner is the real one.
1078
+ */
1079
+ function ddevRunningLimitDeps(run = nodeDdevRunner) {
1080
+ return {
1081
+ listProjects: () => listProjects(run),
1082
+ stopProject: (name) => stopProject(name, run),
1083
+ getProject,
1084
+ setProjectStatus: (name, status) => setProjectStatus(name, status),
1085
+ updateProjectAccess: (name) => updateProjectAccess(name)
1086
+ };
1087
+ }
1088
+ /** Whether the agent works on a project, so it must not be stopped. */
1089
+ function isBusy(record) {
1090
+ return record.status === "starting" || record.status === "destroying";
1091
+ }
1092
+ /** DDEV states where the project's containers are up and use memory. */
1093
+ function isUp(project) {
1094
+ return project.status === "running" || project.status === "starting" || project.status === "unhealthy";
1095
+ }
1096
+ /**
1097
+ * Stop the least recently used projects until `max` or fewer run.
1098
+ *
1099
+ * A project runs when `ddev list` shows it up, or when the agent is starting
1100
+ * it (status "starting" in the database). `ddev list` is the source because
1101
+ * `trafic deploy` runs `ddev start` itself, so the database does not know
1102
+ * about every running project. The projects the agent is starting count too,
1103
+ * because DDEV shows them stopped until their containers exist: the waiting
1104
+ * page marks its project "starting" before it calls this, so that project
1105
+ * counts once whether DDEV shows it up or not, and calls made for starts at
1106
+ * the same time each see all of them.
1107
+ *
1108
+ * Because of deploys, the database is first brought in line with DDEV. A
1109
+ * project that runs while the database says it is stopped (or has no record
1110
+ * of it) was started outside the agent, most likely by a deploy. Its last
1111
+ * access is then old or missing, which would make it the first one stopped,
1112
+ * right after the deploy. It is recorded as accessed now instead. A project
1113
+ * the database says runs while DDEV says it does not was stopped outside the
1114
+ * agent, and is recorded as stopped, so its next start is seen too.
1115
+ *
1116
+ * Projects with `idle_timeout: never`, projects the agent is starting and
1117
+ * projects `trafic-agent destroy` is removing (status "destroying": its
1118
+ * backup starts a stopped project) are never stopped. When no other project
1119
+ * can be stopped, a warning is logged and the server stays over the limit.
1120
+ *
1121
+ * Returns the names of the projects it stopped.
1122
+ */
1123
+ async function enforceRunningLimit(options, deps = ddevRunningLimitDeps()) {
1124
+ const { max, neverStops } = options;
1125
+ if (max <= 0) return [];
1126
+ const projects = await deps.listProjects();
1127
+ for (const project of projects) {
1128
+ const status = deps.getProject(project.name)?.status;
1129
+ if (isUp(project) && (status === void 0 || status === "stopped")) {
1130
+ deps.updateProjectAccess(project.name);
1131
+ deps.setProjectStatus(project.name, "running");
1132
+ } else if (!isUp(project) && status === "running") deps.setProjectStatus(project.name, "stopped");
1133
+ }
1134
+ const running = [];
1135
+ for (const project of projects) {
1136
+ const record = deps.getProject(project.name);
1137
+ if (record && (isUp(project) || record.status === "starting")) running.push(record);
1138
+ }
1139
+ const excess = running.length - max;
1140
+ if (excess <= 0) return [];
1141
+ const candidates = running.filter((record) => !isBusy(record) && !neverStops(record.name)).sort((a, b) => a.lastAccess - b.lastAccess).slice(0, excess);
1142
+ const stopped = [];
1143
+ for (const record of candidates) {
1144
+ console.log(`Stopping ${record.name}: ${running.length} projects running, max_running_projects is ${max}`);
1145
+ if (await deps.stopProject(record.name)) {
1146
+ deps.setProjectStatus(record.name, "stopped");
1147
+ stopped.push(record.name);
1148
+ } else console.error(`Failed to stop: ${record.name}`);
1149
+ }
1150
+ if (stopped.length < excess) console.warn(`${running.length - stopped.length} projects running, max_running_projects is ${max}: no other project can be stopped`);
1151
+ return stopped;
1152
+ }
1153
+ //#endregion
831
1154
  //#region src/setup/ddev.ts
832
1155
  /**
833
1156
  * Install system dependencies required by Trafic
@@ -1435,7 +1758,15 @@ async function handleErrors(req, res, deps) {
1435
1758
  res.end(template.replace("{{message}}", "Project not found"));
1436
1759
  return;
1437
1760
  }
1438
- if (deps.getProject(projectName)?.status === "starting") {
1761
+ deps.releaseStaleStatuses();
1762
+ const record = deps.getProject(projectName);
1763
+ if (record?.status === "destroying") {
1764
+ const template = deps.loadTemplate("error");
1765
+ res.writeHead(503, { "Content-Type": "text/html" });
1766
+ res.end(template.replace("{{message}}", "This project is being removed"));
1767
+ return;
1768
+ }
1769
+ if (record?.status === "starting") {
1439
1770
  const template = deps.loadTemplate("wait");
1440
1771
  res.writeHead(503, {
1441
1772
  "Content-Type": "text/html",
@@ -1451,12 +1782,37 @@ async function handleErrors(req, res, deps) {
1451
1782
  "Retry-After": "5"
1452
1783
  });
1453
1784
  res.end(template.replace(/\{\{project\}\}/g, projectName).replace(/\{\{hostname\}\}/g, hostname));
1454
- deps.startProject(projectName).then((success) => {
1785
+ startWithinLimit(projectName, deps);
1786
+ }
1787
+ /**
1788
+ * Stop projects until the running ones fit under `max`, before the waiting
1789
+ * page starts a project. A project's own config says whether it never stops.
1790
+ */
1791
+ function makeRoomUnderLimit(max, projectConfigs, run = nodeDdevRunner) {
1792
+ return enforceRunningLimit({
1793
+ max,
1794
+ neverStops: (name) => shouldNeverStop(projectConfigs.get(name) ?? {})
1795
+ }, ddevRunningLimitDeps(run));
1796
+ }
1797
+ /**
1798
+ * Make room under max_running_projects, then start the project.
1799
+ *
1800
+ * A failure to make room does not block the start: a page that never comes
1801
+ * up is worse than a server one project over its limit.
1802
+ */
1803
+ async function startWithinLimit(projectName, deps) {
1804
+ try {
1805
+ await deps.makeRoom();
1806
+ } catch (error) {
1807
+ console.error(`Could not make room for ${projectName}:`, error);
1808
+ }
1809
+ try {
1810
+ const success = await deps.startProject(projectName);
1455
1811
  deps.setProjectStatus(projectName, success ? "running" : "stopped");
1456
- }).catch((error) => {
1812
+ } catch (error) {
1457
1813
  deps.setProjectStatus(projectName, "stopped");
1458
1814
  console.error(`Could not start ${projectName}:`, error);
1459
- });
1815
+ }
1460
1816
  }
1461
1817
  /**
1462
1818
  * Handle status polling requests
@@ -1506,7 +1862,7 @@ async function handleRequest(req, res, deps) {
1506
1862
  res.writeHead(200, { "Content-Type": "application/json" });
1507
1863
  res.end(JSON.stringify({
1508
1864
  status: "ok",
1509
- version: "__VERSION__"
1865
+ version: "0.1.51"
1510
1866
  }));
1511
1867
  } else await handleErrors(req, res, deps);
1512
1868
  } catch (error) {
@@ -1532,11 +1888,29 @@ function reloadProjects() {
1532
1888
  console.log(`Loaded ${projectList.size} projects (${projectConfigs.size} with custom config)`);
1533
1889
  }
1534
1890
  /**
1891
+ * Set back the busy statuses that a stopped service or a killed removal left
1892
+ * behind, when the service starts. No start or removal of this new process
1893
+ * is in flight yet, so its own id counts as gone too: after a reboot, the
1894
+ * new service can have the id of the old one.
1895
+ */
1896
+ function releaseStatusesAtStart(isAlive = isProcessAlive) {
1897
+ return releaseStaleStatuses((pid) => pid !== process.pid && isAlive(pid));
1898
+ }
1899
+ /**
1900
+ * Delete the records of the projects that DDEV no longer lists, when the
1901
+ * service starts. Its own id counts as gone, as in releaseStatusesAtStart.
1902
+ */
1903
+ function deleteUnlistedAtStart(projectListPath, isAlive = isProcessAlive) {
1904
+ return deleteUnlistedProjects(new Set(loadProjectList(projectListPath).keys()), (pid) => pid !== process.pid && isAlive(pid));
1905
+ }
1906
+ /**
1535
1907
  * Start the agent server
1536
1908
  */
1537
1909
  function startServer(agentConfig) {
1538
1910
  config = agentConfig;
1539
1911
  initDb(config.dbPath);
1912
+ for (const name of deleteUnlistedAtStart(config.projectListPath)) console.log(`Removed ${name} from the agent database: DDEV no longer lists it`);
1913
+ for (const name of releaseStatusesAtStart()) console.log(`Set ${name} back to running: the process that started or removed it is gone`);
1540
1914
  syncForwardAuthAddress(config.projectListPath);
1541
1915
  warnIfWildcardNotApplied(config);
1542
1916
  reloadProjects();
@@ -1553,7 +1927,9 @@ function startServer(agentConfig) {
1553
1927
  logAccess,
1554
1928
  getProject,
1555
1929
  setProjectStatus,
1930
+ releaseStaleStatuses,
1556
1931
  startProject,
1932
+ makeRoom: () => makeRoomUnderLimit(config.maxRunningProjects, projectConfigs),
1557
1933
  getProjectInfo
1558
1934
  });
1559
1935
  createServer((req, res) => {
@@ -1571,6 +1947,6 @@ function startServer(agentConfig) {
1571
1947
  });
1572
1948
  }
1573
1949
  //#endregion
1574
- export { stopProject as $, getIdleProjects as A, info as B, getIdleTimeoutMs as C, closeDb as D, cleanOldLogs as E, updateProjectAccess as F, success as G, resetSteps as H, nodeIo as I, getProjectFromHostname as J, warn as K, error as L, initDb as M, logAccess as N, getAccessLogs as O, setProjectStatus as P, startProject as Q, exec as R, readToolPorts as S, shouldNeverStop as T, setDryRun as U, isRoot as V, step as W, listProjects as X, getProjectInfo as Y, loadProjectList as Z, findRunningProjects as _, handleStatus as a, parseBearerToken as at, installSystemDeps as b, DDEV_SOURCES as c, validateConfig as ct, STATIC_CONFIG as d, watchProjectList as et, TLS_STORE_CONFIG as f, findRunningProject as g, configureTraefik as h, handleRequest as i, parseBasicAuth as it, getProject as j, getDb as k, DNS_RESOLVER as l, configureDdev as m, handleAuth as n, matchHostname as nt, routePath as o, loadConfig as ot, addDdevAptRepo as p, buildHostnameIndex as q, handleErrors as r, matchIp as rt, startServer as s, parseDuration as st, getEffectiveAuthConfig as t, checkAuth as tt, ROUTER_COMPOSE_OVERRIDE as u, getDockerGatewayIp as v, loadProjectConfig as w, purgeTraefikAcmeStorage as x, installDdev as y, execSilent as z };
1950
+ export { exec as $, shouldNeverStop as A, getScheduledDestroy as B, installSystemDeps as C, parseDuration as Ct, enforceRunningLimit as D, ddevRunningLimitDeps as E, isInside as Et, deleteUnlistedProjects as F, releaseStaleStatuses as G, initDb as H, getAccessLogs as I, startScheduledDestroy as J, scheduleDestroy as K, getDb as L, cleanOldLogs as M, closeDb as N, getIdleTimeoutMs as O, deleteProject as P, error as Q, getIdleProjects as R, installDdev as S, loadConfig as St, readToolPorts as T, validateConfig as Tt, isProcessAlive as U, getScheduledDestroys as V, logAccess as W, updateProjectAccess as X, systemClock as Y, nodeIo as Z, configureDdev as _, checkAuth as _t, handleRequest as a, step as at, findRunningProjects as b, parseBasicAuth as bt, releaseStatusesAtStart as c, buildHostnameIndex as ct, DDEV_SOURCES as d, listProjects as dt, execSilent as et, DNS_RESOLVER as f, loadProjectList as ft, addDdevAptRepo as g, watchProjectList as gt, TLS_STORE_CONFIG as h, stopProject as ht, handleErrors as i, setDryRun as it, cancelScheduledDestroy as j, loadProjectConfig as k, routePath as l, getProjectFromHostname as lt, STATIC_CONFIG as m, startProject as mt, getEffectiveAuthConfig as n, isRoot as nt, handleStatus as o, success as ot, ROUTER_COMPOSE_OVERRIDE as p, nodeDdevRunner as pt, setProjectStatus as q, handleAuth as r, resetSteps as rt, makeRoomUnderLimit as s, warn as st, deleteUnlistedAtStart as t, info as tt, startServer as u, getProjectInfo as ut, configureTraefik as v, matchHostname as vt, purgeTraefikAcmeStorage as w, parseTimeout as wt, getDockerGatewayIp as x, parseBearerToken as xt, findRunningProject as y, matchIp as yt, getProject as z };
1575
1951
 
1576
- //# sourceMappingURL=server-DZAo8Clg.js.map
1952
+ //# sourceMappingURL=server-Cfk-O4DJ.js.map