@studiometa/trafic-agent 0.1.49 → 0.1.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +156 -13
- package/dist/cli.js +134 -14
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/{server-DZAo8Clg.js → server-B_iFvS6v.js} +366 -28
- package/dist/server-B_iFvS6v.js.map +1 -0
- package/dist/server.d.ts +21 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -2
- package/dist/setup/agent.d.ts +11 -1
- package/dist/setup/agent.d.ts.map +1 -1
- package/dist/setup/migrations/0020__backup_dir.d.ts +23 -0
- package/dist/setup/migrations/0020__backup_dir.d.ts.map +1 -0
- package/dist/setup/migrations/index.d.ts.map +1 -1
- package/dist/{setup-DWgb6dAD.js → setup-BbXL1ZFY.js} +826 -209
- package/dist/setup-BbXL1ZFY.js.map +1 -0
- package/dist/tasks/access-logs.d.ts +8 -0
- package/dist/tasks/access-logs.d.ts.map +1 -0
- package/dist/tasks/backup.d.ts +48 -0
- package/dist/tasks/backup.d.ts.map +1 -0
- package/dist/tasks/destroy-project.d.ts +71 -0
- package/dist/tasks/destroy-project.d.ts.map +1 -0
- package/dist/tasks/destroy-scheduled.d.ts +41 -0
- package/dist/tasks/destroy-scheduled.d.ts.map +1 -0
- package/dist/tasks/running-limit.d.ts +54 -0
- package/dist/tasks/running-limit.d.ts.map +1 -0
- package/dist/tasks/stop-idle.d.ts +12 -2
- package/dist/tasks/stop-idle.d.ts.map +1 -1
- package/dist/types.d.ts +41 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/config.d.ts +6 -0
- package/dist/utils/config.d.ts.map +1 -1
- package/dist/utils/db.d.ts +59 -1
- package/dist/utils/db.d.ts.map +1 -1
- package/dist/utils/ddev.d.ts +10 -3
- package/dist/utils/ddev.d.ts.map +1 -1
- package/dist/utils/paths.d.ts +3 -0
- package/dist/utils/paths.d.ts.map +1 -0
- package/package.json +1 -1
- package/dist/server-DZAo8Clg.js.map +0 -1
- package/dist/setup-DWgb6dAD.js.map +0 -1
|
@@ -1,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?.
|
|
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,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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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: "
|
|
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 {
|
|
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-
|
|
1914
|
+
//# sourceMappingURL=server-B_iFvS6v.js.map
|