@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.
Files changed (43) hide show
  1. package/README.md +156 -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-B_iFvS6v.js} +366 -28
  8. package/dist/server-B_iFvS6v.js.map +1 -0
  9. package/dist/server.d.ts +21 -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-BbXL1ZFY.js} +826 -209
  18. package/dist/setup-BbXL1ZFY.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 +12 -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 +59 -1
  36. package/dist/utils/db.d.ts.map +1 -1
  37. package/dist/utils/ddev.d.ts +10 -3
  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
@@ -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,88 @@ 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.
868
+ */
869
+ function deleteProject(name) {
870
+ const database = getDb();
871
+ database.prepare("DELETE FROM projects WHERE name = ?").run(name);
872
+ database.prepare("DELETE FROM access_logs WHERE project = ?").run(name);
873
+ database.prepare("DELETE FROM scheduled_destroys WHERE name = ?").run(name);
874
+ }
875
+ /**
876
+ * Schedule the removal of a project, or replace a removal already
877
+ * scheduled. `timeoutMs` is the time limit of the backup.
878
+ */
879
+ function scheduleDestroy(name, destroyAt, backup, timeoutMs) {
880
+ getDb().prepare(`
881
+ INSERT INTO scheduled_destroys (name, destroy_at, backup, timeout_ms)
882
+ VALUES (?, ?, ?, ?)
883
+ ON CONFLICT(name) DO UPDATE SET
884
+ destroy_at = excluded.destroy_at,
885
+ backup = excluded.backup,
886
+ timeout_ms = excluded.timeout_ms
887
+ `).run(name, destroyAt, backup ? 1 : 0, timeoutMs ?? null);
888
+ }
889
+ function toScheduledDestroy({ name, destroyAt, backup, timeoutMs, removing }) {
890
+ return {
891
+ name,
892
+ destroyAt,
893
+ backup: backup === 1,
894
+ ...timeoutMs === null ? {} : { timeoutMs },
895
+ removing: removing === 1
896
+ };
897
+ }
898
+ /** The columns of a ScheduledDestroy. */
899
+ var SCHEDULED_DESTROY_COLUMNS = "name, destroy_at as destroyAt, backup, timeout_ms as timeoutMs, removing";
900
+ /**
901
+ * Cancel the scheduled removal of a project:
902
+ * - "cancelled" when it was scheduled and is now cancelled
903
+ * - "none" when there was none
904
+ * - "removing" when the sweep already removes the project (see
905
+ * startScheduledDestroy): it is too late, and the removal stays
906
+ */
907
+ function cancelScheduledDestroy(name) {
908
+ const result = getDb().prepare("DELETE FROM scheduled_destroys WHERE name = ? AND removing = 0").run(name);
909
+ if (Number(result.changes) > 0) return "cancelled";
910
+ return getScheduledDestroy(name) ? "removing" : "none";
911
+ }
912
+ /**
913
+ * Mark a scheduled removal as started, when it is still due at `dueBy`.
914
+ * From then on, a cancel is refused, and a retry after a failure does not
915
+ * back the project up again. Returns false when the removal was cancelled
916
+ * or moved to a later date meanwhile. One statement, so a cancel from
917
+ * another process comes either before (false) or after (refused).
918
+ */
919
+ function startScheduledDestroy(name, dueBy) {
920
+ const result = getDb().prepare("UPDATE scheduled_destroys SET removing = 1 WHERE name = ? AND destroy_at <= ?").run(name, dueBy);
921
+ return Number(result.changes) > 0;
922
+ }
923
+ /**
924
+ * Get the scheduled removals, soonest first. With `dueBy`, only the removals
925
+ * due at or before that time.
926
+ */
927
+ function getScheduledDestroys(dueBy = Number.MAX_SAFE_INTEGER) {
928
+ return getDb().prepare(`
929
+ SELECT ${SCHEDULED_DESTROY_COLUMNS}
930
+ FROM scheduled_destroys
931
+ WHERE destroy_at <= ?
932
+ ORDER BY destroy_at, name
933
+ `).all(dueBy).map(toScheduledDestroy);
934
+ }
935
+ /**
936
+ * Get the scheduled removal of a project when it is due at or before
937
+ * `dueBy`, or undefined when there is none.
938
+ */
939
+ function getScheduledDestroy(name, dueBy = Number.MAX_SAFE_INTEGER) {
940
+ const row = getDb().prepare(`
941
+ SELECT ${SCHEDULED_DESTROY_COLUMNS}
942
+ FROM scheduled_destroys
943
+ WHERE name = ? AND destroy_at <= ?
944
+ `).get(name, dueBy);
945
+ return row && toScheduledDestroy(row);
946
+ }
947
+ /**
735
948
  * Log an access
736
949
  */
737
950
  function logAccess(log) {
@@ -828,6 +1041,86 @@ function getIdleTimeoutMs(config) {
828
1041
  return ms > 0 ? ms : void 0;
829
1042
  }
830
1043
  //#endregion
1044
+ //#region src/tasks/running-limit.ts
1045
+ /**
1046
+ * The real dependencies: the agent database, and DDEV through `run`. The
1047
+ * default runner is the real one.
1048
+ */
1049
+ function ddevRunningLimitDeps(run = nodeDdevRunner) {
1050
+ return {
1051
+ listProjects: () => listProjects(run),
1052
+ stopProject: (name) => stopProject(name, run),
1053
+ getProject,
1054
+ setProjectStatus: (name, status) => setProjectStatus(name, status),
1055
+ updateProjectAccess: (name) => updateProjectAccess(name)
1056
+ };
1057
+ }
1058
+ /** Whether the agent works on a project, so it must not be stopped. */
1059
+ function isBusy(record) {
1060
+ return record.status === "starting" || record.status === "destroying";
1061
+ }
1062
+ /** DDEV states where the project's containers are up and use memory. */
1063
+ function isUp(project) {
1064
+ return project.status === "running" || project.status === "starting" || project.status === "unhealthy";
1065
+ }
1066
+ /**
1067
+ * Stop the least recently used projects until `max` or fewer run.
1068
+ *
1069
+ * A project runs when `ddev list` shows it up, or when the agent is starting
1070
+ * it (status "starting" in the database). `ddev list` is the source because
1071
+ * `trafic deploy` runs `ddev start` itself, so the database does not know
1072
+ * about every running project. The projects the agent is starting count too,
1073
+ * because DDEV shows them stopped until their containers exist: the waiting
1074
+ * page marks its project "starting" before it calls this, so that project
1075
+ * counts once whether DDEV shows it up or not, and calls made for starts at
1076
+ * the same time each see all of them.
1077
+ *
1078
+ * Because of deploys, the database is first brought in line with DDEV. A
1079
+ * project that runs while the database says it is stopped (or has no record
1080
+ * of it) was started outside the agent, most likely by a deploy. Its last
1081
+ * access is then old or missing, which would make it the first one stopped,
1082
+ * right after the deploy. It is recorded as accessed now instead. A project
1083
+ * the database says runs while DDEV says it does not was stopped outside the
1084
+ * agent, and is recorded as stopped, so its next start is seen too.
1085
+ *
1086
+ * Projects with `idle_timeout: never`, projects the agent is starting and
1087
+ * projects `trafic-agent destroy` is removing (status "destroying": its
1088
+ * backup starts a stopped project) are never stopped. When no other project
1089
+ * can be stopped, a warning is logged and the server stays over the limit.
1090
+ *
1091
+ * Returns the names of the projects it stopped.
1092
+ */
1093
+ async function enforceRunningLimit(options, deps = ddevRunningLimitDeps()) {
1094
+ const { max, neverStops } = options;
1095
+ if (max <= 0) return [];
1096
+ const projects = await deps.listProjects();
1097
+ for (const project of projects) {
1098
+ const status = deps.getProject(project.name)?.status;
1099
+ if (isUp(project) && (status === void 0 || status === "stopped")) {
1100
+ deps.updateProjectAccess(project.name);
1101
+ deps.setProjectStatus(project.name, "running");
1102
+ } else if (!isUp(project) && status === "running") deps.setProjectStatus(project.name, "stopped");
1103
+ }
1104
+ const running = [];
1105
+ for (const project of projects) {
1106
+ const record = deps.getProject(project.name);
1107
+ if (record && (isUp(project) || record.status === "starting")) running.push(record);
1108
+ }
1109
+ const excess = running.length - max;
1110
+ if (excess <= 0) return [];
1111
+ const candidates = running.filter((record) => !isBusy(record) && !neverStops(record.name)).sort((a, b) => a.lastAccess - b.lastAccess).slice(0, excess);
1112
+ const stopped = [];
1113
+ for (const record of candidates) {
1114
+ console.log(`Stopping ${record.name}: ${running.length} projects running, max_running_projects is ${max}`);
1115
+ if (await deps.stopProject(record.name)) {
1116
+ deps.setProjectStatus(record.name, "stopped");
1117
+ stopped.push(record.name);
1118
+ } else console.error(`Failed to stop: ${record.name}`);
1119
+ }
1120
+ if (stopped.length < excess) console.warn(`${running.length - stopped.length} projects running, max_running_projects is ${max}: no other project can be stopped`);
1121
+ return stopped;
1122
+ }
1123
+ //#endregion
831
1124
  //#region src/setup/ddev.ts
832
1125
  /**
833
1126
  * Install system dependencies required by Trafic
@@ -1435,7 +1728,15 @@ async function handleErrors(req, res, deps) {
1435
1728
  res.end(template.replace("{{message}}", "Project not found"));
1436
1729
  return;
1437
1730
  }
1438
- if (deps.getProject(projectName)?.status === "starting") {
1731
+ deps.releaseStaleStatuses();
1732
+ const record = deps.getProject(projectName);
1733
+ if (record?.status === "destroying") {
1734
+ const template = deps.loadTemplate("error");
1735
+ res.writeHead(503, { "Content-Type": "text/html" });
1736
+ res.end(template.replace("{{message}}", "This project is being removed"));
1737
+ return;
1738
+ }
1739
+ if (record?.status === "starting") {
1439
1740
  const template = deps.loadTemplate("wait");
1440
1741
  res.writeHead(503, {
1441
1742
  "Content-Type": "text/html",
@@ -1451,12 +1752,37 @@ async function handleErrors(req, res, deps) {
1451
1752
  "Retry-After": "5"
1452
1753
  });
1453
1754
  res.end(template.replace(/\{\{project\}\}/g, projectName).replace(/\{\{hostname\}\}/g, hostname));
1454
- deps.startProject(projectName).then((success) => {
1755
+ startWithinLimit(projectName, deps);
1756
+ }
1757
+ /**
1758
+ * Stop projects until the running ones fit under `max`, before the waiting
1759
+ * page starts a project. A project's own config says whether it never stops.
1760
+ */
1761
+ function makeRoomUnderLimit(max, projectConfigs, run = nodeDdevRunner) {
1762
+ return enforceRunningLimit({
1763
+ max,
1764
+ neverStops: (name) => shouldNeverStop(projectConfigs.get(name) ?? {})
1765
+ }, ddevRunningLimitDeps(run));
1766
+ }
1767
+ /**
1768
+ * Make room under max_running_projects, then start the project.
1769
+ *
1770
+ * A failure to make room does not block the start: a page that never comes
1771
+ * up is worse than a server one project over its limit.
1772
+ */
1773
+ async function startWithinLimit(projectName, deps) {
1774
+ try {
1775
+ await deps.makeRoom();
1776
+ } catch (error) {
1777
+ console.error(`Could not make room for ${projectName}:`, error);
1778
+ }
1779
+ try {
1780
+ const success = await deps.startProject(projectName);
1455
1781
  deps.setProjectStatus(projectName, success ? "running" : "stopped");
1456
- }).catch((error) => {
1782
+ } catch (error) {
1457
1783
  deps.setProjectStatus(projectName, "stopped");
1458
1784
  console.error(`Could not start ${projectName}:`, error);
1459
- });
1785
+ }
1460
1786
  }
1461
1787
  /**
1462
1788
  * Handle status polling requests
@@ -1506,7 +1832,7 @@ async function handleRequest(req, res, deps) {
1506
1832
  res.writeHead(200, { "Content-Type": "application/json" });
1507
1833
  res.end(JSON.stringify({
1508
1834
  status: "ok",
1509
- version: "__VERSION__"
1835
+ version: "0.1.50"
1510
1836
  }));
1511
1837
  } else await handleErrors(req, res, deps);
1512
1838
  } catch (error) {
@@ -1532,11 +1858,21 @@ function reloadProjects() {
1532
1858
  console.log(`Loaded ${projectList.size} projects (${projectConfigs.size} with custom config)`);
1533
1859
  }
1534
1860
  /**
1861
+ * Set back the busy statuses that a stopped service or a killed removal left
1862
+ * behind, when the service starts. No start or removal of this new process
1863
+ * is in flight yet, so its own id counts as gone too: after a reboot, the
1864
+ * new service can have the id of the old one.
1865
+ */
1866
+ function releaseStatusesAtStart(isAlive = isProcessAlive) {
1867
+ return releaseStaleStatuses((pid) => pid !== process.pid && isAlive(pid));
1868
+ }
1869
+ /**
1535
1870
  * Start the agent server
1536
1871
  */
1537
1872
  function startServer(agentConfig) {
1538
1873
  config = agentConfig;
1539
1874
  initDb(config.dbPath);
1875
+ for (const name of releaseStatusesAtStart()) console.log(`Set ${name} back to running: the process that started or removed it is gone`);
1540
1876
  syncForwardAuthAddress(config.projectListPath);
1541
1877
  warnIfWildcardNotApplied(config);
1542
1878
  reloadProjects();
@@ -1553,7 +1889,9 @@ function startServer(agentConfig) {
1553
1889
  logAccess,
1554
1890
  getProject,
1555
1891
  setProjectStatus,
1892
+ releaseStaleStatuses,
1556
1893
  startProject,
1894
+ makeRoom: () => makeRoomUnderLimit(config.maxRunningProjects, projectConfigs),
1557
1895
  getProjectInfo
1558
1896
  });
1559
1897
  createServer((req, res) => {
@@ -1571,6 +1909,6 @@ function startServer(agentConfig) {
1571
1909
  });
1572
1910
  }
1573
1911
  //#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 };
1912
+ export { info as $, cancelScheduledDestroy as A, initDb as B, purgeTraefikAcmeStorage as C, validateConfig as Ct, getIdleTimeoutMs as D, enforceRunningLimit as E, getDb as F, setProjectStatus as G, logAccess as H, getIdleProjects as I, updateProjectAccess as J, startScheduledDestroy as K, getProject as L, closeDb as M, deleteProject as N, loadProjectConfig as O, getAccessLogs as P, execSilent as Q, getScheduledDestroy as R, installSystemDeps as S, parseTimeout as St, ddevRunningLimitDeps as T, releaseStaleStatuses as U, isProcessAlive as V, scheduleDestroy as W, error as X, nodeIo as Y, exec as Z, configureTraefik as _, matchIp as _t, handleStatus as a, warn as at, getDockerGatewayIp as b, loadConfig as bt, routePath as c, getProjectInfo as ct, DNS_RESOLVER as d, nodeDdevRunner as dt, isRoot as et, ROUTER_COMPOSE_OVERRIDE as f, startProject as ft, configureDdev as g, matchHostname as gt, addDdevAptRepo as h, checkAuth as ht, handleRequest as i, success as it, cleanOldLogs as j, shouldNeverStop as k, startServer as l, listProjects as lt, TLS_STORE_CONFIG as m, watchProjectList as mt, handleAuth as n, setDryRun as nt, makeRoomUnderLimit as o, buildHostnameIndex as ot, STATIC_CONFIG as p, stopProject as pt, systemClock as q, handleErrors as r, step as rt, releaseStatusesAtStart as s, getProjectFromHostname as st, getEffectiveAuthConfig as t, resetSteps as tt, DDEV_SOURCES as u, loadProjectList as ut, findRunningProject as v, parseBasicAuth as vt, readToolPorts as w, isInside as wt, installDdev as x, parseDuration as xt, findRunningProjects as y, parseBearerToken as yt, getScheduledDestroys as z };
1575
1913
 
1576
- //# sourceMappingURL=server-DZAo8Clg.js.map
1914
+ //# sourceMappingURL=server-B_iFvS6v.js.map