@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.
- package/README.md +158 -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-Cfk-O4DJ.js} +406 -30
- package/dist/server-Cfk-O4DJ.js.map +1 -0
- package/dist/server.d.ts +26 -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-CiWIirVb.js} +827 -209
- package/dist/setup-CiWIirVb.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 +13 -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 +76 -1
- package/dist/utils/db.d.ts.map +1 -1
- package/dist/utils/ddev.d.ts +12 -5
- 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,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
|
-
*
|
|
381
|
-
* approot: /home/ddev/www/
|
|
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?.
|
|
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
|
|
506
|
-
|
|
507
|
-
|
|
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
|
|
720
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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: "
|
|
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 {
|
|
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-
|
|
1952
|
+
//# sourceMappingURL=server-Cfk-O4DJ.js.map
|