@tridha643/hestia 1.0.1 → 1.2.0
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/dist/cli.js +2381 -604
- package/dist/cli.js.map +33 -24
- package/dist/daemon.js +983 -109
- package/dist/daemon.js.map +17 -14
- package/package.json +1 -1
- package/skills/hestia/SKILL.md +72 -10
package/dist/daemon.js
CHANGED
|
@@ -6945,9 +6945,9 @@ var require_public_api = __commonJS((exports) => {
|
|
|
6945
6945
|
});
|
|
6946
6946
|
|
|
6947
6947
|
// packages/engine/src/daemon/main.ts
|
|
6948
|
-
import { chmodSync as chmodSync6, rmSync as
|
|
6948
|
+
import { chmodSync as chmodSync6, rmSync as rmSync11 } from "fs";
|
|
6949
6949
|
import { randomBytes } from "crypto";
|
|
6950
|
-
import { join as
|
|
6950
|
+
import { join as join25 } from "path";
|
|
6951
6951
|
|
|
6952
6952
|
// packages/session-broker-core/src/limits.ts
|
|
6953
6953
|
var MAX_HTTP_BODY_BYTES = 4 * 1024 * 1024;
|
|
@@ -7807,6 +7807,19 @@ import {
|
|
|
7807
7807
|
} from "fs";
|
|
7808
7808
|
import { dirname } from "path";
|
|
7809
7809
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
7810
|
+
function fsyncDir(path) {
|
|
7811
|
+
let fd;
|
|
7812
|
+
try {
|
|
7813
|
+
fd = openSync(dirname(path), "r");
|
|
7814
|
+
} catch {
|
|
7815
|
+
return;
|
|
7816
|
+
}
|
|
7817
|
+
try {
|
|
7818
|
+
fsyncSync(fd);
|
|
7819
|
+
} catch {} finally {
|
|
7820
|
+
closeSync(fd);
|
|
7821
|
+
}
|
|
7822
|
+
}
|
|
7810
7823
|
function writeAtomicJsonFile(path, value, options = {}) {
|
|
7811
7824
|
mkdirSync(dirname(path), { recursive: true });
|
|
7812
7825
|
const temporaryPath = `${path}.${process.pid}.${randomUUID2()}.tmp`;
|
|
@@ -7828,6 +7841,7 @@ function writeAtomicJsonFile(path, value, options = {}) {
|
|
|
7828
7841
|
rmSync(temporaryPath, { force: true });
|
|
7829
7842
|
throw error;
|
|
7830
7843
|
}
|
|
7844
|
+
fsyncDir(path);
|
|
7831
7845
|
}
|
|
7832
7846
|
function writeAtomicTextFile(path, source, mode = 384) {
|
|
7833
7847
|
mkdirSync(dirname(path), { recursive: true });
|
|
@@ -7849,6 +7863,7 @@ function writeAtomicTextFile(path, source, mode = 384) {
|
|
|
7849
7863
|
rmSync(temporaryPath, { force: true });
|
|
7850
7864
|
throw error;
|
|
7851
7865
|
}
|
|
7866
|
+
fsyncDir(path);
|
|
7852
7867
|
}
|
|
7853
7868
|
|
|
7854
7869
|
// packages/engine/src/proc/pidfile.ts
|
|
@@ -8203,10 +8218,10 @@ function clearState(worktreeRoot, project) {
|
|
|
8203
8218
|
}
|
|
8204
8219
|
|
|
8205
8220
|
// packages/engine/src/index.ts
|
|
8206
|
-
import { existsSync as
|
|
8221
|
+
import { existsSync as existsSync21, rmSync as rmSync10 } from "fs";
|
|
8207
8222
|
import { execFile as execFile10 } from "child_process";
|
|
8208
8223
|
import { promisify as promisify9 } from "util";
|
|
8209
|
-
import { join as
|
|
8224
|
+
import { join as join24, resolve as resolve2 } from "path";
|
|
8210
8225
|
import { createHash as createHash7 } from "crypto";
|
|
8211
8226
|
import { resolve4, resolve6, resolveCname } from "dns/promises";
|
|
8212
8227
|
|
|
@@ -9402,10 +9417,10 @@ import { execFile as execFile7 } from "child_process";
|
|
|
9402
9417
|
import { Agent, createServer as createServer2, request as httpRequest } from "http";
|
|
9403
9418
|
import { createConnection, createServer as createNetServer } from "net";
|
|
9404
9419
|
import { promisify as promisify6 } from "util";
|
|
9405
|
-
import { existsSync as
|
|
9406
|
-
import { chmodSync as chmodSync5, lstatSync, mkdirSync as mkdirSync7, rmSync as
|
|
9420
|
+
import { existsSync as existsSync14, readFileSync as readFileSync14, readdirSync as readdirSync5 } from "fs";
|
|
9421
|
+
import { chmodSync as chmodSync5, lstatSync, mkdirSync as mkdirSync7, rmSync as rmSync9 } from "fs";
|
|
9407
9422
|
import { tmpdir } from "os";
|
|
9408
|
-
import { join as
|
|
9423
|
+
import { join as join17 } from "path";
|
|
9409
9424
|
import { createHash as createHash5 } from "crypto";
|
|
9410
9425
|
|
|
9411
9426
|
// packages/engine/src/router/router-config.ts
|
|
@@ -9876,6 +9891,39 @@ async function releaseSlot(port, project) {
|
|
|
9876
9891
|
});
|
|
9877
9892
|
} catch {}
|
|
9878
9893
|
}
|
|
9894
|
+
async function sharedVerb(port, verb, body) {
|
|
9895
|
+
const timeoutMs = (body.waitMs ?? 0) + 5000;
|
|
9896
|
+
let response;
|
|
9897
|
+
try {
|
|
9898
|
+
response = await fetch(`http://127.0.0.1:${port}/hestia/shared/${verb}`, {
|
|
9899
|
+
method: "POST",
|
|
9900
|
+
headers: { "content-type": "application/json", ...daemonAuthHeaders(port) },
|
|
9901
|
+
body: JSON.stringify(body),
|
|
9902
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
9903
|
+
});
|
|
9904
|
+
} catch (error) {
|
|
9905
|
+
throw new HestiaError("daemon-unreachable", `could not reach hestiad on 127.0.0.1:${port}: ${error.message}`);
|
|
9906
|
+
}
|
|
9907
|
+
const payload = await response.json().catch(() => ({}));
|
|
9908
|
+
if (!response.ok) {
|
|
9909
|
+
throw new HestiaError(payload.code ?? "daemon-unreachable", payload.error ?? `daemon rejected shared ${verb}: HTTP ${response.status}`);
|
|
9910
|
+
}
|
|
9911
|
+
return {
|
|
9912
|
+
granted: payload.granted === true,
|
|
9913
|
+
holder: payload.holder,
|
|
9914
|
+
queued: payload.queued ?? []
|
|
9915
|
+
};
|
|
9916
|
+
}
|
|
9917
|
+
async function releaseSharedForProject(port, project, service) {
|
|
9918
|
+
try {
|
|
9919
|
+
await fetch(`http://127.0.0.1:${port}/hestia/shared/release-project`, {
|
|
9920
|
+
method: "POST",
|
|
9921
|
+
headers: { "content-type": "application/json", ...daemonAuthHeaders(port) },
|
|
9922
|
+
body: JSON.stringify({ project, service }),
|
|
9923
|
+
signal: AbortSignal.timeout(2000)
|
|
9924
|
+
});
|
|
9925
|
+
} catch {}
|
|
9926
|
+
}
|
|
9879
9927
|
|
|
9880
9928
|
// packages/engine/src/daemon/launchd.ts
|
|
9881
9929
|
import { existsSync as existsSync11, mkdirSync as mkdirSync6, readFileSync as readFileSync11, rmSync as rmSync6 } from "fs";
|
|
@@ -10258,9 +10306,57 @@ function createRoutes(admission, startedAt, dependencies) {
|
|
|
10258
10306
|
await admission.release(body.project);
|
|
10259
10307
|
return json({ ok: true });
|
|
10260
10308
|
}
|
|
10309
|
+
if (url.pathname.startsWith("/hestia/shared/") && request.method === "POST") {
|
|
10310
|
+
let body;
|
|
10311
|
+
try {
|
|
10312
|
+
body = await parseJsonBody(request);
|
|
10313
|
+
} catch (error) {
|
|
10314
|
+
return json({ error: `invalid JSON body: ${error.message}` }, 400);
|
|
10315
|
+
}
|
|
10316
|
+
const verb = url.pathname.slice("/hestia/shared/".length);
|
|
10317
|
+
if (!isProject(body.project))
|
|
10318
|
+
return json({ error: "project is invalid" }, 400);
|
|
10319
|
+
const project = body.project;
|
|
10320
|
+
try {
|
|
10321
|
+
if (verb === "release-project") {
|
|
10322
|
+
const service = body.service === undefined ? undefined : isService(body.service) ? body.service : null;
|
|
10323
|
+
if (service === null)
|
|
10324
|
+
return json({ error: "service is invalid" }, 400);
|
|
10325
|
+
await dependencies.shared.releaseProject(project, service);
|
|
10326
|
+
return json({ ok: true });
|
|
10327
|
+
}
|
|
10328
|
+
if (!isSharedName(body.name))
|
|
10329
|
+
return json({ error: "name is invalid" }, 400);
|
|
10330
|
+
const name = body.name;
|
|
10331
|
+
if (verb === "claim") {
|
|
10332
|
+
if (typeof body.worktree !== "string" || !body.worktree.startsWith("/") || body.worktree.length > 4096) {
|
|
10333
|
+
return json({ error: "worktree is invalid" }, 400);
|
|
10334
|
+
}
|
|
10335
|
+
const waitMs = typeof body.waitMs === "number" && Number.isFinite(body.waitMs) && body.waitMs > 0 ? Math.min(body.waitMs, 24 * 60 * 60 * 1000) : 0;
|
|
10336
|
+
return json(await dependencies.shared.request(name, { project, worktree: body.worktree }, waitMs));
|
|
10337
|
+
}
|
|
10338
|
+
if (verb === "allow")
|
|
10339
|
+
return json(await dependencies.shared.allow(name, project));
|
|
10340
|
+
if (verb === "deny")
|
|
10341
|
+
return json(await dependencies.shared.deny(name, project));
|
|
10342
|
+
if (verb === "release")
|
|
10343
|
+
return json(await dependencies.shared.release(name, project));
|
|
10344
|
+
if (verb === "cancel")
|
|
10345
|
+
return json(await dependencies.shared.cancel(name, project));
|
|
10346
|
+
} catch (error) {
|
|
10347
|
+
if (error instanceof HestiaError) {
|
|
10348
|
+
return json({ error: error.message, code: error.code }, error.code === "shared-not-found" ? 404 : 409);
|
|
10349
|
+
}
|
|
10350
|
+
return json({ error: error.message }, 500);
|
|
10351
|
+
}
|
|
10352
|
+
return json({ error: `unknown route ${url.pathname}` }, 404);
|
|
10353
|
+
}
|
|
10261
10354
|
return json({ error: `unknown route ${url.pathname}` }, 404);
|
|
10262
10355
|
};
|
|
10263
10356
|
}
|
|
10357
|
+
function isSharedName(value) {
|
|
10358
|
+
return typeof value === "string" && /^[a-z0-9][a-z0-9-]{0,62}$/.test(value);
|
|
10359
|
+
}
|
|
10264
10360
|
|
|
10265
10361
|
// packages/engine/src/daemon/ensure.ts
|
|
10266
10362
|
var START_TIMEOUT_MS = 5000;
|
|
@@ -10477,6 +10573,149 @@ async function keychainHasFingerprint(keychain, fingerprint) {
|
|
|
10477
10573
|
}
|
|
10478
10574
|
}
|
|
10479
10575
|
|
|
10576
|
+
// packages/engine/src/tunnel/shared.ts
|
|
10577
|
+
import { existsSync as existsSync13, readFileSync as readFileSync13, readdirSync as readdirSync4, rmSync as rmSync8 } from "fs";
|
|
10578
|
+
import { join as join16 } from "path";
|
|
10579
|
+
var NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
10580
|
+
var LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
10581
|
+
var PATH_SEGMENT_RE = /^[A-Za-z0-9._~!$&'()*+,;=:@%-]+$/;
|
|
10582
|
+
function sharedRoot() {
|
|
10583
|
+
return join16(hestiaHome(), "shared");
|
|
10584
|
+
}
|
|
10585
|
+
function sharedPath(name) {
|
|
10586
|
+
return join16(sharedRoot(), `${name}.json`);
|
|
10587
|
+
}
|
|
10588
|
+
function assertSharedName(name) {
|
|
10589
|
+
if (!NAME_RE.test(name)) {
|
|
10590
|
+
throw new HestiaError("usage", `shared hostname handle ${JSON.stringify(name)} must be a DNS label: ` + `lowercase alphanumerics and hyphens, at most 63 characters`);
|
|
10591
|
+
}
|
|
10592
|
+
}
|
|
10593
|
+
function assertSharedHostnameFqdn(hostname) {
|
|
10594
|
+
const labels = hostname.split(".");
|
|
10595
|
+
const valid = hostname.length <= 253 && hostname === hostname.toLowerCase() && labels.length >= 2 && labels.every((label) => LABEL_RE.test(label));
|
|
10596
|
+
if (!valid) {
|
|
10597
|
+
throw new HestiaError("usage", `shared hostname ${JSON.stringify(hostname)} must be a fully-qualified ` + `lowercase domain (e.g. slack.acme.com)`);
|
|
10598
|
+
}
|
|
10599
|
+
}
|
|
10600
|
+
function normalizeSharedPath(input) {
|
|
10601
|
+
if (input === undefined)
|
|
10602
|
+
return;
|
|
10603
|
+
const trimmed = input.trim();
|
|
10604
|
+
const segments = trimmed.split("/").filter((segment) => segment !== "");
|
|
10605
|
+
for (const segment of segments) {
|
|
10606
|
+
if (segment === "." || segment === ".." || !PATH_SEGMENT_RE.test(segment)) {
|
|
10607
|
+
throw new HestiaError("usage", `shared hostname path ${JSON.stringify(input)} must be a URL path prefix ` + `like /webhooks/slack (no query, fragment, or ".." segments)`);
|
|
10608
|
+
}
|
|
10609
|
+
}
|
|
10610
|
+
if (segments.length === 0)
|
|
10611
|
+
return;
|
|
10612
|
+
return `/${segments.join("/")}`;
|
|
10613
|
+
}
|
|
10614
|
+
function sharedPathMatches(prefix, requestPath) {
|
|
10615
|
+
if (prefix === undefined)
|
|
10616
|
+
return true;
|
|
10617
|
+
return requestPath === prefix || requestPath.startsWith(`${prefix}/`);
|
|
10618
|
+
}
|
|
10619
|
+
function withSharedLock(fn) {
|
|
10620
|
+
return withLock(sharedRoot(), fn);
|
|
10621
|
+
}
|
|
10622
|
+
function parseShared(source) {
|
|
10623
|
+
let value;
|
|
10624
|
+
try {
|
|
10625
|
+
value = JSON.parse(source);
|
|
10626
|
+
} catch {
|
|
10627
|
+
return null;
|
|
10628
|
+
}
|
|
10629
|
+
if (value.schemaVersion !== STATE_SCHEMA_VERSION || typeof value.name !== "string" || !NAME_RE.test(value.name) || typeof value.hostname !== "string" || value.hostname.length === 0 || typeof value.tunnelUuid !== "string" || value.tunnelUuid.length === 0 || typeof value.zone !== "string" || value.zone.length === 0 || typeof value.service !== "string" || value.service.length === 0 || typeof value.createdAt !== "string")
|
|
10630
|
+
return null;
|
|
10631
|
+
if (value.path !== undefined && (typeof value.path !== "string" || !value.path.startsWith("/"))) {
|
|
10632
|
+
return null;
|
|
10633
|
+
}
|
|
10634
|
+
if (value.holder !== undefined) {
|
|
10635
|
+
const holder = value.holder;
|
|
10636
|
+
if (typeof holder.project !== "string" || typeof holder.worktree !== "string" || typeof holder.service !== "string" || typeof holder.at !== "string")
|
|
10637
|
+
return null;
|
|
10638
|
+
}
|
|
10639
|
+
if (value.queue !== undefined) {
|
|
10640
|
+
if (!Array.isArray(value.queue))
|
|
10641
|
+
return null;
|
|
10642
|
+
for (const entry of value.queue) {
|
|
10643
|
+
if (typeof entry !== "object" || entry === null || typeof entry.project !== "string" || typeof entry.worktree !== "string" || typeof entry.at !== "string")
|
|
10644
|
+
return null;
|
|
10645
|
+
}
|
|
10646
|
+
}
|
|
10647
|
+
return value;
|
|
10648
|
+
}
|
|
10649
|
+
function readSharedHostname(name) {
|
|
10650
|
+
const p = sharedPath(name);
|
|
10651
|
+
if (!existsSync13(p))
|
|
10652
|
+
return null;
|
|
10653
|
+
try {
|
|
10654
|
+
return parseShared(readFileSync13(p, "utf8"));
|
|
10655
|
+
} catch {
|
|
10656
|
+
return null;
|
|
10657
|
+
}
|
|
10658
|
+
}
|
|
10659
|
+
function listSharedHostnames() {
|
|
10660
|
+
const root = sharedRoot();
|
|
10661
|
+
if (!existsSync13(root))
|
|
10662
|
+
return [];
|
|
10663
|
+
const records = [];
|
|
10664
|
+
for (const entry of readdirSync4(root).sort()) {
|
|
10665
|
+
if (!entry.endsWith(".json"))
|
|
10666
|
+
continue;
|
|
10667
|
+
const record = readSharedHostname(entry.slice(0, -".json".length));
|
|
10668
|
+
if (record !== null && `${record.name}.json` === entry)
|
|
10669
|
+
records.push(record);
|
|
10670
|
+
}
|
|
10671
|
+
return records;
|
|
10672
|
+
}
|
|
10673
|
+
async function declareSharedHostname(record) {
|
|
10674
|
+
assertSharedName(record.name);
|
|
10675
|
+
assertSharedHostnameFqdn(record.hostname);
|
|
10676
|
+
const path = normalizeSharedPath(record.path);
|
|
10677
|
+
return withSharedLock(async () => {
|
|
10678
|
+
const collision = listSharedHostnames().find((candidate) => candidate.name !== record.name && candidate.hostname === record.hostname && (candidate.path ?? undefined) === (path ?? undefined));
|
|
10679
|
+
if (collision !== undefined) {
|
|
10680
|
+
throw new HestiaError("shared-conflict", `${record.hostname}${path ?? ""} is already declared as shared hostname ` + `"${collision.name}" \u2014 pick a distinct path or handle`);
|
|
10681
|
+
}
|
|
10682
|
+
const existing = readSharedHostname(record.name);
|
|
10683
|
+
if (existing !== null) {
|
|
10684
|
+
if (existing.hostname !== record.hostname || existing.tunnelUuid !== record.tunnelUuid || (existing.path ?? undefined) !== (path ?? undefined)) {
|
|
10685
|
+
throw new HestiaError("shared-conflict", `shared hostname "${record.name}" already declared as ${existing.hostname}` + `${existing.path ?? ""} on tunnel ${existing.tunnelUuid} \u2014 release and remove it before re-pointing`);
|
|
10686
|
+
}
|
|
10687
|
+
const merged = { ...existing, service: record.service, zone: record.zone };
|
|
10688
|
+
writeAtomicJsonFile(sharedPath(record.name), merged);
|
|
10689
|
+
return merged;
|
|
10690
|
+
}
|
|
10691
|
+
const fresh = {
|
|
10692
|
+
schemaVersion: STATE_SCHEMA_VERSION,
|
|
10693
|
+
...record,
|
|
10694
|
+
createdAt: new Date().toISOString()
|
|
10695
|
+
};
|
|
10696
|
+
if (path === undefined)
|
|
10697
|
+
delete fresh.path;
|
|
10698
|
+
else
|
|
10699
|
+
fresh.path = path;
|
|
10700
|
+
writeAtomicJsonFile(sharedPath(record.name), fresh);
|
|
10701
|
+
return fresh;
|
|
10702
|
+
});
|
|
10703
|
+
}
|
|
10704
|
+
async function updateSharedHostname(name, mutate) {
|
|
10705
|
+
return withSharedLock(async () => {
|
|
10706
|
+
const existing = readSharedHostname(name);
|
|
10707
|
+
if (existing === null)
|
|
10708
|
+
return null;
|
|
10709
|
+
const next = mutate(structuredClone(existing));
|
|
10710
|
+
if (next.queue !== undefined && next.queue.length === 0)
|
|
10711
|
+
delete next.queue;
|
|
10712
|
+
if (next.holder === undefined)
|
|
10713
|
+
delete next.holder;
|
|
10714
|
+
writeAtomicJsonFile(sharedPath(name), next);
|
|
10715
|
+
return next;
|
|
10716
|
+
});
|
|
10717
|
+
}
|
|
10718
|
+
|
|
10480
10719
|
// packages/engine/src/router/local-http-router.ts
|
|
10481
10720
|
var pexec6 = promisify6(execFile7);
|
|
10482
10721
|
var ROUTE_REFRESH_MS = 1000;
|
|
@@ -10492,18 +10731,25 @@ var HOP_BY_HOP_HEADERS = new Set([
|
|
|
10492
10731
|
"transfer-encoding",
|
|
10493
10732
|
"upgrade"
|
|
10494
10733
|
]);
|
|
10734
|
+
function pathnameOf(target) {
|
|
10735
|
+
if (target === undefined || target.length === 0)
|
|
10736
|
+
return "/";
|
|
10737
|
+
const cut = target.search(/[?#]/);
|
|
10738
|
+
const path = cut < 0 ? target : target.slice(0, cut);
|
|
10739
|
+
return path.startsWith("/") ? path : "/";
|
|
10740
|
+
}
|
|
10495
10741
|
|
|
10496
10742
|
class OriginOwnershipError extends Error {
|
|
10497
10743
|
}
|
|
10498
10744
|
function readRouterStackRecords() {
|
|
10499
|
-
const stacksDir =
|
|
10500
|
-
if (!
|
|
10745
|
+
const stacksDir = join17(hestiaHome(), "stacks");
|
|
10746
|
+
if (!existsSync14(stacksDir))
|
|
10501
10747
|
return [];
|
|
10502
10748
|
const records = [];
|
|
10503
|
-
for (const project of
|
|
10504
|
-
const path =
|
|
10749
|
+
for (const project of readdirSync5(stacksDir).sort()) {
|
|
10750
|
+
const path = join17(stacksDir, project, "stack.json");
|
|
10505
10751
|
try {
|
|
10506
|
-
const source =
|
|
10752
|
+
const source = readFileSync14(path);
|
|
10507
10753
|
if (source.byteLength > MAX_MIRROR_BYTES)
|
|
10508
10754
|
continue;
|
|
10509
10755
|
const record = parseStackRecord(source.toString("utf8"), path);
|
|
@@ -10655,11 +10901,11 @@ function internalEndpointAuthority(project, service) {
|
|
|
10655
10901
|
function publicGatewaySocketPath() {
|
|
10656
10902
|
const uid = process.getuid?.() ?? 501;
|
|
10657
10903
|
const homeHash = createHash5("sha256").update(hestiaHome()).digest("hex").slice(0, 12);
|
|
10658
|
-
return
|
|
10904
|
+
return join17(tmpdir(), `hestia-${uid}`, homeHash, "origin.sock");
|
|
10659
10905
|
}
|
|
10660
10906
|
function prepareGatewaySocket(path) {
|
|
10661
10907
|
const uid = process.getuid?.();
|
|
10662
|
-
const directories = [
|
|
10908
|
+
const directories = [join17(tmpdir(), `hestia-${uid ?? 501}`), join17(tmpdir(), `hestia-${uid ?? 501}`, createHash5("sha256").update(hestiaHome()).digest("hex").slice(0, 12))];
|
|
10663
10909
|
for (const directory of directories) {
|
|
10664
10910
|
mkdirSync7(directory, { recursive: true, mode: 448 });
|
|
10665
10911
|
const stat2 = lstatSync(directory);
|
|
@@ -10668,13 +10914,13 @@ function prepareGatewaySocket(path) {
|
|
|
10668
10914
|
}
|
|
10669
10915
|
chmodSync5(directory, 448);
|
|
10670
10916
|
}
|
|
10671
|
-
if (!
|
|
10917
|
+
if (!existsSync14(path))
|
|
10672
10918
|
return;
|
|
10673
10919
|
const stat = lstatSync(path);
|
|
10674
10920
|
if (stat.isSymbolicLink() || !stat.isSocket() || uid !== undefined && stat.uid !== uid) {
|
|
10675
10921
|
throw new Error(`refusing unsafe existing Hestia gateway socket ${path}`);
|
|
10676
10922
|
}
|
|
10677
|
-
|
|
10923
|
+
rmSync9(path);
|
|
10678
10924
|
}
|
|
10679
10925
|
function sendRouterResponse(response, status, message) {
|
|
10680
10926
|
response.writeHead(status, { "content-type": "text/plain; charset=utf-8", "x-hestia-router": "1" });
|
|
@@ -10687,6 +10933,7 @@ class HestiaLocalHttpRouter {
|
|
|
10687
10933
|
#frontServer = createNetServer((socket) => this.#acceptFrontSocket(socket));
|
|
10688
10934
|
#unixServer = createNetServer((socket) => this.#acceptFrontSocket(socket));
|
|
10689
10935
|
#targets = new Map;
|
|
10936
|
+
#sharedRoutes = new Map;
|
|
10690
10937
|
#timer;
|
|
10691
10938
|
async start() {
|
|
10692
10939
|
await new Promise((resolve2, reject) => {
|
|
@@ -10790,12 +11037,52 @@ class HestiaLocalHttpRouter {
|
|
|
10790
11037
|
}
|
|
10791
11038
|
}
|
|
10792
11039
|
}
|
|
11040
|
+
const sharedRoutes = new Map;
|
|
11041
|
+
for (const shared of listSharedHostnames()) {
|
|
11042
|
+
const hostname = shared.hostname.toLowerCase();
|
|
11043
|
+
targets.delete(hostname);
|
|
11044
|
+
const holder = shared.holder;
|
|
11045
|
+
let target;
|
|
11046
|
+
if (holder !== undefined) {
|
|
11047
|
+
const stack = records.find((record) => record.project === holder.project);
|
|
11048
|
+
const endpoint = stack?.endpoints.find((candidate2) => (candidate2.alias ?? candidate2.name) === holder.service);
|
|
11049
|
+
const service = stack?.services.find((candidate2) => candidate2.name === (endpoint?.workload ?? endpoint?.name));
|
|
11050
|
+
const binding = service?.bindings?.find((candidate2) => `${candidate2.target}/${candidate2.protocol}` === endpoint?.binding);
|
|
11051
|
+
const publishedPort = binding?.publishedPort ?? service?.publishedPort;
|
|
11052
|
+
const candidate = {
|
|
11053
|
+
hostname,
|
|
11054
|
+
project: holder.project,
|
|
11055
|
+
service: service === undefined || service.backend === "tunnel" || publishedPort === undefined ? undefined : { ...service, publishedPort }
|
|
11056
|
+
};
|
|
11057
|
+
const previous = this.#sharedRoutes.get(hostname)?.find((entry) => entry.name === shared.name)?.target;
|
|
11058
|
+
target = previous !== undefined && targetIdentity(previous) === targetIdentity(candidate) ? previous : candidate;
|
|
11059
|
+
}
|
|
11060
|
+
const list = sharedRoutes.get(hostname) ?? [];
|
|
11061
|
+
list.push({ path: shared.path, name: shared.name, claimed: holder !== undefined, target });
|
|
11062
|
+
sharedRoutes.set(hostname, list);
|
|
11063
|
+
}
|
|
11064
|
+
for (const list of sharedRoutes.values()) {
|
|
11065
|
+
list.sort((left, right) => (right.path?.length ?? -1) - (left.path?.length ?? -1));
|
|
11066
|
+
}
|
|
10793
11067
|
reconcilePortlessAliases([...targets.keys()].filter((hostname) => hostname.endsWith(".localhost")), this.port);
|
|
10794
|
-
|
|
10795
|
-
|
|
11068
|
+
const retained = new Set(targets.values());
|
|
11069
|
+
for (const list of sharedRoutes.values()) {
|
|
11070
|
+
for (const entry of list)
|
|
11071
|
+
if (entry.target !== undefined)
|
|
11072
|
+
retained.add(entry.target);
|
|
11073
|
+
}
|
|
11074
|
+
const priorTargets = [...this.#targets.values()];
|
|
11075
|
+
for (const list of this.#sharedRoutes.values()) {
|
|
11076
|
+
for (const entry of list)
|
|
11077
|
+
if (entry.target !== undefined)
|
|
11078
|
+
priorTargets.push(entry.target);
|
|
11079
|
+
}
|
|
11080
|
+
for (const target of priorTargets) {
|
|
11081
|
+
if (!retained.has(target))
|
|
10796
11082
|
target.agent?.destroy();
|
|
10797
11083
|
}
|
|
10798
11084
|
this.#targets = targets;
|
|
11085
|
+
this.#sharedRoutes = sharedRoutes;
|
|
10799
11086
|
}
|
|
10800
11087
|
stop() {
|
|
10801
11088
|
if (this.#timer)
|
|
@@ -10809,7 +11096,7 @@ class HestiaLocalHttpRouter {
|
|
|
10809
11096
|
target.agent?.destroy();
|
|
10810
11097
|
this.#frontServer.close();
|
|
10811
11098
|
this.#unixServer.close();
|
|
10812
|
-
|
|
11099
|
+
rmSync9(this.socketPath, { force: true });
|
|
10813
11100
|
this.#server.close();
|
|
10814
11101
|
}
|
|
10815
11102
|
#acceptFrontSocket(socket) {
|
|
@@ -10854,16 +11141,40 @@ Connection: close\r
|
|
|
10854
11141
|
`));
|
|
10855
11142
|
socket.once("error", () => internal.destroy());
|
|
10856
11143
|
}
|
|
11144
|
+
#matchTarget(hostname, requestPath) {
|
|
11145
|
+
const routes = this.#sharedRoutes.get(hostname);
|
|
11146
|
+
if (routes !== undefined) {
|
|
11147
|
+
const entry = routes.find((candidate) => sharedPathMatches(candidate.path, requestPath));
|
|
11148
|
+
if (entry === undefined)
|
|
11149
|
+
return { status: 404, message: "Hestia route not found" };
|
|
11150
|
+
if (!entry.claimed) {
|
|
11151
|
+
return {
|
|
11152
|
+
status: 503,
|
|
11153
|
+
message: `Shared hostname unclaimed \u2014 run \`hestia claim ${entry.name}\` in the worktree that should receive this traffic`
|
|
11154
|
+
};
|
|
11155
|
+
}
|
|
11156
|
+
if (entry.target?.service?.publishedPort === undefined) {
|
|
11157
|
+
return { status: 503, message: "Hestia route origin unavailable" };
|
|
11158
|
+
}
|
|
11159
|
+
return { target: entry.target };
|
|
11160
|
+
}
|
|
11161
|
+
const target = this.#targets.get(hostname);
|
|
11162
|
+
if (target === undefined)
|
|
11163
|
+
return { status: 404, message: "Hestia route not found" };
|
|
11164
|
+
if (target.service?.publishedPort === undefined) {
|
|
11165
|
+
return { status: 503, message: "Hestia route origin unavailable" };
|
|
11166
|
+
}
|
|
11167
|
+
return { target };
|
|
11168
|
+
}
|
|
10857
11169
|
async#proxyHttp(request, response) {
|
|
10858
11170
|
const hostname = parseRouteAuthority(request.headers.host);
|
|
10859
11171
|
if (hostname === null)
|
|
10860
11172
|
return sendRouterResponse(response, 400, "Malformed Host authority");
|
|
10861
|
-
const
|
|
10862
|
-
if (
|
|
10863
|
-
return sendRouterResponse(response,
|
|
10864
|
-
const
|
|
10865
|
-
|
|
10866
|
-
return sendRouterResponse(response, 503, "Hestia route origin unavailable");
|
|
11173
|
+
const match = this.#matchTarget(hostname, pathnameOf(request.url));
|
|
11174
|
+
if ("status" in match)
|
|
11175
|
+
return sendRouterResponse(response, match.status, match.message);
|
|
11176
|
+
const target = match.target;
|
|
11177
|
+
const port = target.service.publishedPort;
|
|
10867
11178
|
if (await verifyLocalRouterTarget(target) !== port) {
|
|
10868
11179
|
return sendRouterResponse(response, 503, "Hestia route origin unavailable");
|
|
10869
11180
|
}
|
|
@@ -10904,16 +11215,19 @@ Connection: close\r
|
|
|
10904
11215
|
`);
|
|
10905
11216
|
return;
|
|
10906
11217
|
}
|
|
10907
|
-
const
|
|
10908
|
-
|
|
10909
|
-
|
|
10910
|
-
|
|
11218
|
+
const rawHeaderLines = header.slice(0, -4).split(`\r
|
|
11219
|
+
`);
|
|
11220
|
+
const requestPath = pathnameOf(rawHeaderLines[0]?.split(" ")[1]);
|
|
11221
|
+
const match = this.#matchTarget(host, requestPath);
|
|
11222
|
+
if ("status" in match) {
|
|
11223
|
+
const reason = match.status === 404 ? "404 Not Found" : "503 Service Unavailable";
|
|
11224
|
+
socket.end(`HTTP/1.1 ${reason}\r
|
|
10911
11225
|
\r
|
|
10912
11226
|
`);
|
|
10913
11227
|
return;
|
|
10914
11228
|
}
|
|
10915
|
-
const
|
|
10916
|
-
|
|
11229
|
+
const target = match.target;
|
|
11230
|
+
const port = target.service.publishedPort;
|
|
10917
11231
|
const connectionValues = rawHeaderLines.slice(1).filter((line) => /^connection:/i.test(line)).map((line) => line.slice(line.indexOf(":") + 1));
|
|
10918
11232
|
const connectionNames = connectionHeaderTokens(connectionValues);
|
|
10919
11233
|
const retainedHeaders = rawHeaderLines.slice(1).filter((line) => {
|
|
@@ -10963,15 +11277,15 @@ Connection: close\r
|
|
|
10963
11277
|
}
|
|
10964
11278
|
|
|
10965
11279
|
// packages/engine/src/wrangler/adapter.ts
|
|
10966
|
-
import { existsSync as
|
|
11280
|
+
import { existsSync as existsSync15, readFileSync as readFileSync15, statSync as statSync2 } from "fs";
|
|
10967
11281
|
import { homedir as homedir3 } from "os";
|
|
10968
|
-
import { dirname as dirname5, join as
|
|
11282
|
+
import { dirname as dirname5, join as join18, relative as relative3 } from "path";
|
|
10969
11283
|
import { connect } from "net";
|
|
10970
11284
|
function privateRegistryDir(worktreeRoot) {
|
|
10971
|
-
return
|
|
11285
|
+
return join18(worktreeRoot, ".hestia", "wrangler-registry");
|
|
10972
11286
|
}
|
|
10973
11287
|
function globalRegistryDir() {
|
|
10974
|
-
return process.env.WRANGLER_REGISTRY_PATH ??
|
|
11288
|
+
return process.env.WRANGLER_REGISTRY_PATH ?? join18(homedir3(), ".wrangler", "config", "registry");
|
|
10975
11289
|
}
|
|
10976
11290
|
var HEARTBEAT_FRESH_MS = 60000;
|
|
10977
11291
|
function tcpConnectable(address, timeoutMs = 500) {
|
|
@@ -10995,19 +11309,19 @@ function tcpConnectable(address, timeoutMs = 500) {
|
|
|
10995
11309
|
}
|
|
10996
11310
|
async function preflightForeignSessions(workers) {
|
|
10997
11311
|
const dir = globalRegistryDir();
|
|
10998
|
-
if (!
|
|
11312
|
+
if (!existsSync15(dir))
|
|
10999
11313
|
return;
|
|
11000
11314
|
for (const w of workers) {
|
|
11001
11315
|
if (w.name === null)
|
|
11002
11316
|
continue;
|
|
11003
|
-
const entryPath =
|
|
11004
|
-
if (!
|
|
11317
|
+
const entryPath = join18(dir, w.name);
|
|
11318
|
+
if (!existsSync15(entryPath))
|
|
11005
11319
|
continue;
|
|
11006
11320
|
const freshMs = Date.now() - statSync2(entryPath).mtimeMs;
|
|
11007
11321
|
let live = freshMs < HEARTBEAT_FRESH_MS;
|
|
11008
11322
|
if (!live) {
|
|
11009
11323
|
try {
|
|
11010
|
-
const entry = JSON.parse(
|
|
11324
|
+
const entry = JSON.parse(readFileSync15(entryPath, "utf8"));
|
|
11011
11325
|
live = entry.debugPortAddress ? await tcpConnectable(entry.debugPortAddress) : false;
|
|
11012
11326
|
} catch {
|
|
11013
11327
|
live = false;
|
|
@@ -11030,8 +11344,8 @@ async function planWorkers(worktreeRoot, opts) {
|
|
|
11030
11344
|
const wranglerBinFor = (configPath) => {
|
|
11031
11345
|
let dir = dirname5(configPath);
|
|
11032
11346
|
for (;; ) {
|
|
11033
|
-
const candidate =
|
|
11034
|
-
if (
|
|
11347
|
+
const candidate = join18(dir, "node_modules", ".bin", "wrangler");
|
|
11348
|
+
if (existsSync15(candidate))
|
|
11035
11349
|
return candidate;
|
|
11036
11350
|
if (dir === worktreeRoot)
|
|
11037
11351
|
return null;
|
|
@@ -11096,11 +11410,11 @@ async function planWorkers(worktreeRoot, opts) {
|
|
|
11096
11410
|
}
|
|
11097
11411
|
|
|
11098
11412
|
// packages/engine/src/wrangler/verify.ts
|
|
11099
|
-
import { existsSync as
|
|
11413
|
+
import { existsSync as existsSync16, readdirSync as readdirSync6 } from "fs";
|
|
11100
11414
|
function namesIn(dir) {
|
|
11101
|
-
if (!
|
|
11415
|
+
if (!existsSync16(dir))
|
|
11102
11416
|
return new Set;
|
|
11103
|
-
return new Set(
|
|
11417
|
+
return new Set(readdirSync6(dir));
|
|
11104
11418
|
}
|
|
11105
11419
|
function snapshotGlobalRegistry() {
|
|
11106
11420
|
return namesIn(globalRegistryDir());
|
|
@@ -11125,16 +11439,16 @@ function globalGainWarnings(before, ourNames) {
|
|
|
11125
11439
|
// packages/engine/src/tunnel/cloudflared.ts
|
|
11126
11440
|
import { execFile as execFile8 } from "child_process";
|
|
11127
11441
|
import { promisify as promisify7 } from "util";
|
|
11128
|
-
import { existsSync as
|
|
11442
|
+
import { existsSync as existsSync17 } from "fs";
|
|
11129
11443
|
import { homedir as homedir4 } from "os";
|
|
11130
|
-
import { join as
|
|
11444
|
+
import { join as join19 } from "path";
|
|
11131
11445
|
var pexec7 = promisify7(execFile8);
|
|
11132
11446
|
var BIN = "cloudflared";
|
|
11133
11447
|
function cloudflaredHome() {
|
|
11134
|
-
return process.env.HESTIA_CLOUDFLARED_HOME ??
|
|
11448
|
+
return process.env.HESTIA_CLOUDFLARED_HOME ?? join19(homedir4(), ".cloudflared");
|
|
11135
11449
|
}
|
|
11136
11450
|
function credFilePath(uuid) {
|
|
11137
|
-
return process.env.TUNNEL_CRED_FILE ??
|
|
11451
|
+
return process.env.TUNNEL_CRED_FILE ?? join19(cloudflaredHome(), `${uuid}.json`);
|
|
11138
11452
|
}
|
|
11139
11453
|
async function run(args, timeoutMs = 30000) {
|
|
11140
11454
|
try {
|
|
@@ -11163,7 +11477,7 @@ async function adoptTunnel(name) {
|
|
|
11163
11477
|
throw new HestiaError("tunnel-not-found", `no tunnel named "${name}" on this account \u2014 create it once with ` + `\`cloudflared tunnel create ${name}\``);
|
|
11164
11478
|
}
|
|
11165
11479
|
const credFile = credFilePath(entry.id);
|
|
11166
|
-
if (!
|
|
11480
|
+
if (!existsSync17(credFile)) {
|
|
11167
11481
|
throw new HestiaError("tunnel-auth-missing", `credentials for tunnel "${name}" not found at ${credFile} \u2014 ` + `run \`cloudflared tunnel create\` on this machine or copy the JSON`);
|
|
11168
11482
|
}
|
|
11169
11483
|
return { uuid: entry.id, credFile, connections: entry.connections.length };
|
|
@@ -11171,8 +11485,8 @@ async function adoptTunnel(name) {
|
|
|
11171
11485
|
|
|
11172
11486
|
// packages/engine/src/tunnel/ingress.ts
|
|
11173
11487
|
import { createHash as createHash6 } from "crypto";
|
|
11174
|
-
import { existsSync as
|
|
11175
|
-
import { join as
|
|
11488
|
+
import { existsSync as existsSync18, readFileSync as readFileSync16 } from "fs";
|
|
11489
|
+
import { join as join20 } from "path";
|
|
11176
11490
|
var LABEL_MAX = 63;
|
|
11177
11491
|
var HASH_LEN = 6;
|
|
11178
11492
|
function hostnameFor(tunnelName, branch, service, zone) {
|
|
@@ -11192,12 +11506,12 @@ function inferZone(baseRules) {
|
|
|
11192
11506
|
return zones.size === 1 ? [...zones][0] : undefined;
|
|
11193
11507
|
}
|
|
11194
11508
|
function importBaseRules(uuid, name) {
|
|
11195
|
-
const p =
|
|
11196
|
-
if (!
|
|
11509
|
+
const p = join20(cloudflaredHome(), "config.yml");
|
|
11510
|
+
if (!existsSync18(p))
|
|
11197
11511
|
return [];
|
|
11198
11512
|
let parsed;
|
|
11199
11513
|
try {
|
|
11200
|
-
parsed = $parse(
|
|
11514
|
+
parsed = $parse(readFileSync16(p, "utf8"));
|
|
11201
11515
|
} catch {
|
|
11202
11516
|
return [];
|
|
11203
11517
|
}
|
|
@@ -11209,7 +11523,12 @@ function importBaseRules(uuid, name) {
|
|
|
11209
11523
|
return parsed.ingress.filter((r) => r.hostname !== undefined || r.path !== undefined);
|
|
11210
11524
|
}
|
|
11211
11525
|
function generateMergedConfig(opts) {
|
|
11212
|
-
assertDisjoint(opts.baseRules, opts.dynamicRules);
|
|
11526
|
+
assertDisjoint(opts.baseRules, opts.dynamicRules, opts.sharedRules ?? []);
|
|
11527
|
+
const sharedHostnames = [...new Set((opts.sharedRules ?? []).map((s) => s.hostname))];
|
|
11528
|
+
const shared = sharedHostnames.map((hostname) => ({
|
|
11529
|
+
hostname,
|
|
11530
|
+
service: `unix:${publicGatewaySocketPath()}`
|
|
11531
|
+
}));
|
|
11213
11532
|
const dynamic = opts.dynamicRules.map((d) => ({
|
|
11214
11533
|
hostname: d.hostname,
|
|
11215
11534
|
service: `unix:${publicGatewaySocketPath()}`,
|
|
@@ -11220,17 +11539,30 @@ function generateMergedConfig(opts) {
|
|
|
11220
11539
|
"credentials-file": opts.credFile,
|
|
11221
11540
|
ingress: [
|
|
11222
11541
|
...opts.baseRules,
|
|
11542
|
+
...shared,
|
|
11223
11543
|
...dynamic,
|
|
11224
11544
|
{ service: "http_status:404" }
|
|
11225
11545
|
]
|
|
11226
11546
|
});
|
|
11227
11547
|
}
|
|
11228
|
-
function assertDisjoint(base, dynamic) {
|
|
11548
|
+
function assertDisjoint(base, dynamic, shared) {
|
|
11229
11549
|
const seen = new Map;
|
|
11230
11550
|
for (const r of base) {
|
|
11231
11551
|
if (r.hostname !== undefined)
|
|
11232
11552
|
seen.set(r.hostname, "the user's static rules");
|
|
11233
11553
|
}
|
|
11554
|
+
const sharedByHost = new Map;
|
|
11555
|
+
for (const s of shared) {
|
|
11556
|
+
if (!sharedByHost.has(s.hostname))
|
|
11557
|
+
sharedByHost.set(s.hostname, s.name);
|
|
11558
|
+
}
|
|
11559
|
+
for (const [hostname, name] of sharedByHost) {
|
|
11560
|
+
const holder = seen.get(hostname);
|
|
11561
|
+
if (holder !== undefined) {
|
|
11562
|
+
throw new HestiaError("hostname-conflict", `shared hostname ${hostname} ("${name}") is already claimed by ${holder}`);
|
|
11563
|
+
}
|
|
11564
|
+
seen.set(hostname, `shared hostname "${name}"`);
|
|
11565
|
+
}
|
|
11234
11566
|
for (const d of dynamic) {
|
|
11235
11567
|
const holder = seen.get(d.hostname);
|
|
11236
11568
|
if (holder !== undefined) {
|
|
@@ -11242,12 +11574,91 @@ function assertDisjoint(base, dynamic) {
|
|
|
11242
11574
|
|
|
11243
11575
|
// packages/engine/src/tunnel/registry.ts
|
|
11244
11576
|
import {
|
|
11245
|
-
existsSync as
|
|
11577
|
+
existsSync as existsSync19,
|
|
11246
11578
|
mkdirSync as mkdirSync8,
|
|
11247
|
-
readFileSync as
|
|
11248
|
-
readdirSync as
|
|
11579
|
+
readFileSync as readFileSync17,
|
|
11580
|
+
readdirSync as readdirSync7
|
|
11249
11581
|
} from "fs";
|
|
11250
|
-
import { join as
|
|
11582
|
+
import { join as join22 } from "path";
|
|
11583
|
+
|
|
11584
|
+
// packages/engine/src/tunnel/orphans.ts
|
|
11585
|
+
import { execFileSync as execFileSync4 } from "child_process";
|
|
11586
|
+
import { join as join21 } from "path";
|
|
11587
|
+
function hestiaTunnelMarker(uuid) {
|
|
11588
|
+
return join21(hestiaHome(), "tunnel", uuid);
|
|
11589
|
+
}
|
|
11590
|
+
function parseLocalHestiaConnectors(psOutput, marker) {
|
|
11591
|
+
const rows = [];
|
|
11592
|
+
for (const line of psOutput.split(`
|
|
11593
|
+
`)) {
|
|
11594
|
+
const trimmed = line.trim();
|
|
11595
|
+
if (trimmed === "")
|
|
11596
|
+
continue;
|
|
11597
|
+
const m = trimmed.match(/^(\d+)\s+(\d+)\s+(.*)$/);
|
|
11598
|
+
if (m === null)
|
|
11599
|
+
continue;
|
|
11600
|
+
const command = m[3];
|
|
11601
|
+
if (!command.includes(marker))
|
|
11602
|
+
continue;
|
|
11603
|
+
if (!/(^|[\/\s])cloudflared(\s|$)/.test(command))
|
|
11604
|
+
continue;
|
|
11605
|
+
rows.push({ pid: Number(m[1]), pgid: Number(m[2]), command });
|
|
11606
|
+
}
|
|
11607
|
+
return rows;
|
|
11608
|
+
}
|
|
11609
|
+
function listLocalHestiaConnectors(uuid) {
|
|
11610
|
+
let out;
|
|
11611
|
+
try {
|
|
11612
|
+
out = execFileSync4("ps", ["-Ao", "pid=,pgid=,command="], {
|
|
11613
|
+
encoding: "utf8",
|
|
11614
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
11615
|
+
env: { ...process.env, LC_ALL: "C", LANG: "C" }
|
|
11616
|
+
});
|
|
11617
|
+
} catch {
|
|
11618
|
+
return [];
|
|
11619
|
+
}
|
|
11620
|
+
return parseLocalHestiaConnectors(out, hestiaTunnelMarker(uuid));
|
|
11621
|
+
}
|
|
11622
|
+
var REAP_GRACE_MS = 5000;
|
|
11623
|
+
var REAP_POLL_MS = 100;
|
|
11624
|
+
async function reapOrphanConnectors(uuid, keep) {
|
|
11625
|
+
const procs = listLocalHestiaConnectors(uuid).filter((p) => {
|
|
11626
|
+
if (keep === undefined)
|
|
11627
|
+
return true;
|
|
11628
|
+
return p.pgid !== keep.pgid && p.pid !== keep.pid;
|
|
11629
|
+
});
|
|
11630
|
+
if (procs.length === 0)
|
|
11631
|
+
return { reapedGroups: 0, pids: [] };
|
|
11632
|
+
const groups = new Set(procs.map((p) => p.pgid));
|
|
11633
|
+
const pids = procs.map((p) => p.pid);
|
|
11634
|
+
for (const pgid of groups) {
|
|
11635
|
+
try {
|
|
11636
|
+
process.kill(-pgid, "SIGTERM");
|
|
11637
|
+
} catch {}
|
|
11638
|
+
}
|
|
11639
|
+
const deadline = Date.now() + REAP_GRACE_MS;
|
|
11640
|
+
while (Date.now() < deadline) {
|
|
11641
|
+
const still = listLocalHestiaConnectors(uuid).filter((p) => {
|
|
11642
|
+
if (keep === undefined)
|
|
11643
|
+
return true;
|
|
11644
|
+
return p.pgid !== keep.pgid && p.pid !== keep.pid;
|
|
11645
|
+
});
|
|
11646
|
+
if (still.length === 0)
|
|
11647
|
+
return { reapedGroups: groups.size, pids };
|
|
11648
|
+
await new Promise((r) => setTimeout(r, REAP_POLL_MS));
|
|
11649
|
+
}
|
|
11650
|
+
for (const p of listLocalHestiaConnectors(uuid)) {
|
|
11651
|
+
if (keep !== undefined && (p.pgid === keep.pgid || p.pid === keep.pid))
|
|
11652
|
+
continue;
|
|
11653
|
+
try {
|
|
11654
|
+
process.kill(-p.pgid, "SIGKILL");
|
|
11655
|
+
} catch {}
|
|
11656
|
+
try {
|
|
11657
|
+
process.kill(p.pid, "SIGKILL");
|
|
11658
|
+
} catch {}
|
|
11659
|
+
}
|
|
11660
|
+
return { reapedGroups: groups.size, pids };
|
|
11661
|
+
}
|
|
11251
11662
|
|
|
11252
11663
|
// packages/engine/src/tunnel/verify.ts
|
|
11253
11664
|
var POLL_MS4 = 300;
|
|
@@ -11295,24 +11706,24 @@ async function quickTunnelUrl(metricsPort, timeoutMs) {
|
|
|
11295
11706
|
var CONNECTOR = "connector";
|
|
11296
11707
|
var READY_TIMEOUT_MS2 = 30000;
|
|
11297
11708
|
function tunnelDir(uuid) {
|
|
11298
|
-
return
|
|
11709
|
+
return join22(hestiaHome(), "tunnel", uuid);
|
|
11299
11710
|
}
|
|
11300
11711
|
function configPath(uuid) {
|
|
11301
|
-
return
|
|
11712
|
+
return join22(tunnelDir(uuid), "config.yml");
|
|
11302
11713
|
}
|
|
11303
11714
|
function adoptedMarker(uuid) {
|
|
11304
|
-
return
|
|
11715
|
+
return join22(tunnelDir(uuid), "adopted.json");
|
|
11305
11716
|
}
|
|
11306
11717
|
function isAdopted(uuid) {
|
|
11307
|
-
return
|
|
11718
|
+
return existsSync19(adoptedMarker(uuid));
|
|
11308
11719
|
}
|
|
11309
11720
|
function readAdopted(uuid) {
|
|
11310
11721
|
const p = adoptedMarker(uuid);
|
|
11311
|
-
if (!
|
|
11722
|
+
if (!existsSync19(p))
|
|
11312
11723
|
return null;
|
|
11313
11724
|
let marker;
|
|
11314
11725
|
try {
|
|
11315
|
-
marker = JSON.parse(
|
|
11726
|
+
marker = JSON.parse(readFileSync17(p, "utf8"));
|
|
11316
11727
|
} catch {
|
|
11317
11728
|
marker = {};
|
|
11318
11729
|
}
|
|
@@ -11320,14 +11731,14 @@ function readAdopted(uuid) {
|
|
|
11320
11731
|
return { uuid, name: marker.name, credFile: marker.credFile, reconstructed: false };
|
|
11321
11732
|
}
|
|
11322
11733
|
let name;
|
|
11323
|
-
const stacksDir =
|
|
11324
|
-
if (
|
|
11325
|
-
for (const project of
|
|
11326
|
-
const sp =
|
|
11327
|
-
if (!
|
|
11734
|
+
const stacksDir = join22(hestiaHome(), "stacks");
|
|
11735
|
+
if (existsSync19(stacksDir)) {
|
|
11736
|
+
for (const project of readdirSync7(stacksDir)) {
|
|
11737
|
+
const sp = join22(stacksDir, project, "stack.json");
|
|
11738
|
+
if (!existsSync19(sp))
|
|
11328
11739
|
continue;
|
|
11329
11740
|
try {
|
|
11330
|
-
const record = parseStackRecord(
|
|
11741
|
+
const record = parseStackRecord(readFileSync17(sp, "utf8"), sp);
|
|
11331
11742
|
if (record.tunnel?.uuid === uuid) {
|
|
11332
11743
|
name = record.tunnel.name;
|
|
11333
11744
|
break;
|
|
@@ -11343,24 +11754,24 @@ function readAdopted(uuid) {
|
|
|
11343
11754
|
};
|
|
11344
11755
|
}
|
|
11345
11756
|
function listAdopted() {
|
|
11346
|
-
const root =
|
|
11347
|
-
if (!
|
|
11757
|
+
const root = join22(hestiaHome(), "tunnel");
|
|
11758
|
+
if (!existsSync19(root))
|
|
11348
11759
|
return [];
|
|
11349
|
-
return
|
|
11760
|
+
return readdirSync7(root).filter((uuid) => isAdopted(uuid));
|
|
11350
11761
|
}
|
|
11351
11762
|
function collectDynamicRules(uuid) {
|
|
11352
|
-
const stacksDir =
|
|
11763
|
+
const stacksDir = join22(hestiaHome(), "stacks");
|
|
11353
11764
|
const rules = [];
|
|
11354
11765
|
const dropped = [];
|
|
11355
|
-
if (!
|
|
11766
|
+
if (!existsSync19(stacksDir))
|
|
11356
11767
|
return { rules, dropped };
|
|
11357
|
-
for (const project of
|
|
11358
|
-
const p =
|
|
11359
|
-
if (!
|
|
11768
|
+
for (const project of readdirSync7(stacksDir)) {
|
|
11769
|
+
const p = join22(stacksDir, project, "stack.json");
|
|
11770
|
+
if (!existsSync19(p))
|
|
11360
11771
|
continue;
|
|
11361
11772
|
let record;
|
|
11362
11773
|
try {
|
|
11363
|
-
record = parseStackRecord(
|
|
11774
|
+
record = parseStackRecord(readFileSync17(p, "utf8"), p);
|
|
11364
11775
|
} catch {
|
|
11365
11776
|
continue;
|
|
11366
11777
|
}
|
|
@@ -11389,20 +11800,26 @@ async function reconcileTunnel(ref, opts) {
|
|
|
11389
11800
|
for (const d of dropped) {
|
|
11390
11801
|
warnings.push(`exposure ${d.hostname} dropped \u2014 service "${d.service}" of ` + `${d.project} is not running (hostname now 404s)`);
|
|
11391
11802
|
}
|
|
11803
|
+
const sharedRules = listSharedHostnames().filter((record) => record.tunnelUuid === ref.uuid).map((record) => ({ name: record.name, hostname: record.hostname }));
|
|
11392
11804
|
const cfgPath = configPath(ref.uuid);
|
|
11393
11805
|
const nextConfig = generateMergedConfig({
|
|
11394
11806
|
uuid: ref.uuid,
|
|
11395
11807
|
credFile: ref.credFile,
|
|
11396
11808
|
baseRules,
|
|
11397
|
-
dynamicRules: rules
|
|
11809
|
+
dynamicRules: rules,
|
|
11810
|
+
sharedRules
|
|
11398
11811
|
});
|
|
11399
11812
|
const pf = readPidfile(dir, CONNECTOR);
|
|
11400
11813
|
const live = pf !== null && isLive(pf);
|
|
11401
|
-
const
|
|
11814
|
+
const { reapedGroups } = await reapOrphanConnectors(ref.uuid, live ? pf : undefined);
|
|
11815
|
+
if (reapedGroups > 0) {
|
|
11816
|
+
warnings.push(`reaped ${reapedGroups} orphan hestia connector process group(s) for this tunnel`);
|
|
11817
|
+
}
|
|
11818
|
+
const currentConfig = existsSync19(cfgPath) ? readFileSync17(cfgPath, "utf8") : null;
|
|
11402
11819
|
if (live && currentConfig === nextConfig) {
|
|
11403
11820
|
return { restarted: false, metricsPort: pf.port, ready: true, warnings };
|
|
11404
11821
|
}
|
|
11405
|
-
if (baseRules.length === 0 && rules.length === 0) {
|
|
11822
|
+
if (baseRules.length === 0 && rules.length === 0 && sharedRules.length === 0) {
|
|
11406
11823
|
if (pf !== null) {
|
|
11407
11824
|
await stopProcTree(pf);
|
|
11408
11825
|
removePidfile(dir, CONNECTOR);
|
|
@@ -11887,11 +12304,225 @@ async function* streamStackLogs(record, options = {}) {
|
|
|
11887
12304
|
await Promise.allSettled(pumps);
|
|
11888
12305
|
}
|
|
11889
12306
|
}
|
|
12307
|
+
// packages/engine/src/daemon/shared-arbiter.ts
|
|
12308
|
+
class SharedArbiter {
|
|
12309
|
+
onChange;
|
|
12310
|
+
#mutex = Promise.resolve();
|
|
12311
|
+
#polls = new Map;
|
|
12312
|
+
constructor(onChange) {
|
|
12313
|
+
this.onChange = onChange;
|
|
12314
|
+
}
|
|
12315
|
+
#locked(fn) {
|
|
12316
|
+
const next = this.#mutex.then(fn, fn);
|
|
12317
|
+
this.#mutex = next.catch(() => {});
|
|
12318
|
+
return next;
|
|
12319
|
+
}
|
|
12320
|
+
async#notify() {
|
|
12321
|
+
try {
|
|
12322
|
+
await this.onChange?.();
|
|
12323
|
+
} catch {}
|
|
12324
|
+
}
|
|
12325
|
+
#result(record, granted) {
|
|
12326
|
+
return { granted, holder: record.holder, queued: [...record.queue ?? []] };
|
|
12327
|
+
}
|
|
12328
|
+
#wake(name, project, result) {
|
|
12329
|
+
const key = `${name}\x00${project}`;
|
|
12330
|
+
for (const poll of this.#polls.get(key) ?? []) {
|
|
12331
|
+
clearTimeout(poll.timer);
|
|
12332
|
+
poll.resolve(result);
|
|
12333
|
+
}
|
|
12334
|
+
this.#polls.delete(key);
|
|
12335
|
+
}
|
|
12336
|
+
#grantNext(record) {
|
|
12337
|
+
const queue = [...record.queue ?? []];
|
|
12338
|
+
for (;; ) {
|
|
12339
|
+
const head = queue.shift();
|
|
12340
|
+
if (head === undefined) {
|
|
12341
|
+
const next = { ...record, queue: [] };
|
|
12342
|
+
delete next.holder;
|
|
12343
|
+
return next;
|
|
12344
|
+
}
|
|
12345
|
+
if (readMirrorState(head.project) === null)
|
|
12346
|
+
continue;
|
|
12347
|
+
return {
|
|
12348
|
+
...record,
|
|
12349
|
+
holder: {
|
|
12350
|
+
project: head.project,
|
|
12351
|
+
worktree: head.worktree,
|
|
12352
|
+
service: record.service,
|
|
12353
|
+
at: new Date().toISOString()
|
|
12354
|
+
},
|
|
12355
|
+
queue
|
|
12356
|
+
};
|
|
12357
|
+
}
|
|
12358
|
+
}
|
|
12359
|
+
async request(name, requester, waitMs) {
|
|
12360
|
+
const outcome = await this.#locked(async () => {
|
|
12361
|
+
let granted = false;
|
|
12362
|
+
const updated = await updateSharedHostname(name, (record) => {
|
|
12363
|
+
if (record.holder?.project === requester.project) {
|
|
12364
|
+
granted = true;
|
|
12365
|
+
return {
|
|
12366
|
+
...record,
|
|
12367
|
+
holder: { ...record.holder, worktree: requester.worktree },
|
|
12368
|
+
queue: (record.queue ?? []).filter((waiter) => waiter.project !== requester.project)
|
|
12369
|
+
};
|
|
12370
|
+
}
|
|
12371
|
+
if (record.holder === undefined) {
|
|
12372
|
+
granted = true;
|
|
12373
|
+
return {
|
|
12374
|
+
...record,
|
|
12375
|
+
holder: {
|
|
12376
|
+
project: requester.project,
|
|
12377
|
+
worktree: requester.worktree,
|
|
12378
|
+
service: record.service,
|
|
12379
|
+
at: new Date().toISOString()
|
|
12380
|
+
},
|
|
12381
|
+
queue: (record.queue ?? []).filter((waiter) => waiter.project !== requester.project)
|
|
12382
|
+
};
|
|
12383
|
+
}
|
|
12384
|
+
const queue = [...record.queue ?? []];
|
|
12385
|
+
const existing = queue.find((waiter) => waiter.project === requester.project);
|
|
12386
|
+
if (existing !== undefined)
|
|
12387
|
+
existing.worktree = requester.worktree;
|
|
12388
|
+
else
|
|
12389
|
+
queue.push({ project: requester.project, worktree: requester.worktree, at: new Date().toISOString() });
|
|
12390
|
+
return { ...record, queue };
|
|
12391
|
+
});
|
|
12392
|
+
if (updated === null) {
|
|
12393
|
+
throw new HestiaError("shared-not-found", `no shared hostname "${name}" is declared`);
|
|
12394
|
+
}
|
|
12395
|
+
return { granted, record: updated };
|
|
12396
|
+
});
|
|
12397
|
+
if (outcome.granted) {
|
|
12398
|
+
await this.#notify();
|
|
12399
|
+
return this.#result(outcome.record, true);
|
|
12400
|
+
}
|
|
12401
|
+
if (waitMs <= 0)
|
|
12402
|
+
return this.#result(outcome.record, false);
|
|
12403
|
+
return new Promise((resolve2) => {
|
|
12404
|
+
const key = `${name}\x00${requester.project}`;
|
|
12405
|
+
const polls = this.#polls.get(key) ?? [];
|
|
12406
|
+
const poll = {
|
|
12407
|
+
resolve: resolve2,
|
|
12408
|
+
timer: setTimeout(() => {
|
|
12409
|
+
const remaining = (this.#polls.get(key) ?? []).filter((candidate) => candidate !== poll);
|
|
12410
|
+
if (remaining.length === 0)
|
|
12411
|
+
this.#polls.delete(key);
|
|
12412
|
+
else
|
|
12413
|
+
this.#polls.set(key, remaining);
|
|
12414
|
+
const record = readSharedHostname(name);
|
|
12415
|
+
resolve2(record === null ? { granted: false, queued: [] } : this.#result(record, record.holder?.project === requester.project));
|
|
12416
|
+
}, waitMs)
|
|
12417
|
+
};
|
|
12418
|
+
polls.push(poll);
|
|
12419
|
+
this.#polls.set(key, polls);
|
|
12420
|
+
});
|
|
12421
|
+
}
|
|
12422
|
+
async cancel(name, project) {
|
|
12423
|
+
const record = await this.#locked(() => updateSharedHostname(name, (current) => ({
|
|
12424
|
+
...current,
|
|
12425
|
+
queue: (current.queue ?? []).filter((waiter) => waiter.project !== project)
|
|
12426
|
+
})));
|
|
12427
|
+
if (record === null)
|
|
12428
|
+
throw new HestiaError("shared-not-found", `no shared hostname "${name}" is declared`);
|
|
12429
|
+
this.#wake(name, project, this.#result(record, false));
|
|
12430
|
+
return this.#result(record, false);
|
|
12431
|
+
}
|
|
12432
|
+
#assertHolder(record, project, verb) {
|
|
12433
|
+
if (record.holder?.project !== project) {
|
|
12434
|
+
throw new HestiaError("shared-not-holder", record.holder === undefined ? `cannot ${verb} "${record.name}" \u2014 it is unclaimed` : `cannot ${verb} "${record.name}" \u2014 it is held by ${record.holder.project}, not ${project}`);
|
|
12435
|
+
}
|
|
12436
|
+
}
|
|
12437
|
+
async allow(name, callerProject) {
|
|
12438
|
+
const record = await this.#transfer(name, callerProject, "allow");
|
|
12439
|
+
return this.#result(record, false);
|
|
12440
|
+
}
|
|
12441
|
+
async deny(name, callerProject) {
|
|
12442
|
+
const record = await this.#locked(async () => {
|
|
12443
|
+
const current = readSharedHostname(name);
|
|
12444
|
+
if (current === null)
|
|
12445
|
+
throw new HestiaError("shared-not-found", `no shared hostname "${name}" is declared`);
|
|
12446
|
+
this.#assertHolder(current, callerProject, "deny");
|
|
12447
|
+
const updated = await updateSharedHostname(name, (candidate) => {
|
|
12448
|
+
const queue = [...candidate.queue ?? []];
|
|
12449
|
+
if (queue.length > 0)
|
|
12450
|
+
queue[0] = { ...queue[0], denied: true };
|
|
12451
|
+
return { ...candidate, queue };
|
|
12452
|
+
});
|
|
12453
|
+
return updated;
|
|
12454
|
+
});
|
|
12455
|
+
return this.#result(record, false);
|
|
12456
|
+
}
|
|
12457
|
+
async release(name, callerProject) {
|
|
12458
|
+
const record = await this.#transfer(name, callerProject, "release");
|
|
12459
|
+
return this.#result(record, false);
|
|
12460
|
+
}
|
|
12461
|
+
async#transfer(name, callerProject, verb) {
|
|
12462
|
+
const record = await this.#locked(async () => {
|
|
12463
|
+
const current = readSharedHostname(name);
|
|
12464
|
+
if (current === null)
|
|
12465
|
+
throw new HestiaError("shared-not-found", `no shared hostname "${name}" is declared`);
|
|
12466
|
+
this.#assertHolder(current, callerProject, verb);
|
|
12467
|
+
return await updateSharedHostname(name, (candidate) => this.#grantNext(candidate));
|
|
12468
|
+
});
|
|
12469
|
+
if (record.holder !== undefined) {
|
|
12470
|
+
this.#wake(record.name, record.holder.project, this.#result(record, true));
|
|
12471
|
+
}
|
|
12472
|
+
await this.#notify();
|
|
12473
|
+
return record;
|
|
12474
|
+
}
|
|
12475
|
+
async releaseProject(project, service) {
|
|
12476
|
+
const held = listSharedHostnames().filter((record) => record.holder?.project === project && (service === undefined || record.service === service));
|
|
12477
|
+
let changed = false;
|
|
12478
|
+
for (const record of held) {
|
|
12479
|
+
const updated = await this.#locked(async () => {
|
|
12480
|
+
const current = readSharedHostname(record.name);
|
|
12481
|
+
if (current?.holder?.project !== project)
|
|
12482
|
+
return null;
|
|
12483
|
+
return await updateSharedHostname(record.name, (candidate) => this.#grantNext(candidate));
|
|
12484
|
+
});
|
|
12485
|
+
if (updated === null || updated === undefined)
|
|
12486
|
+
continue;
|
|
12487
|
+
changed = true;
|
|
12488
|
+
if (updated.holder !== undefined) {
|
|
12489
|
+
this.#wake(updated.name, updated.holder.project, this.#result(updated, true));
|
|
12490
|
+
}
|
|
12491
|
+
}
|
|
12492
|
+
if (changed)
|
|
12493
|
+
await this.#notify();
|
|
12494
|
+
}
|
|
12495
|
+
async sweep(occupied) {
|
|
12496
|
+
let changed = false;
|
|
12497
|
+
for (const record of listSharedHostnames()) {
|
|
12498
|
+
const holderDead = record.holder !== undefined && !occupied.has(record.holder.project) && readMirrorState(record.holder.project) === null;
|
|
12499
|
+
const deadWaiters = (record.queue ?? []).filter((waiter) => readMirrorState(waiter.project) === null);
|
|
12500
|
+
if (!holderDead && deadWaiters.length === 0)
|
|
12501
|
+
continue;
|
|
12502
|
+
const updated = await this.#locked(() => updateSharedHostname(record.name, (candidate) => {
|
|
12503
|
+
const pruned = {
|
|
12504
|
+
...candidate,
|
|
12505
|
+
queue: (candidate.queue ?? []).filter((waiter) => readMirrorState(waiter.project) !== null)
|
|
12506
|
+
};
|
|
12507
|
+
const currentHolderDead = pruned.holder !== undefined && !occupied.has(pruned.holder.project) && readMirrorState(pruned.holder.project) === null;
|
|
12508
|
+
return currentHolderDead ? this.#grantNext(pruned) : pruned;
|
|
12509
|
+
}));
|
|
12510
|
+
if (updated === null)
|
|
12511
|
+
continue;
|
|
12512
|
+
changed = true;
|
|
12513
|
+
if (updated.holder !== undefined && updated.holder.project !== record.holder?.project) {
|
|
12514
|
+
this.#wake(updated.name, updated.holder.project, this.#result(updated, true));
|
|
12515
|
+
}
|
|
12516
|
+
}
|
|
12517
|
+
if (changed)
|
|
12518
|
+
await this.#notify();
|
|
12519
|
+
}
|
|
12520
|
+
}
|
|
11890
12521
|
// packages/engine/src/daemon/fleet-monitor.ts
|
|
11891
12522
|
import { execFile as execFile9 } from "child_process";
|
|
11892
12523
|
import { promisify as promisify8 } from "util";
|
|
11893
|
-
import { existsSync as
|
|
11894
|
-
import { join as
|
|
12524
|
+
import { existsSync as existsSync20, readFileSync as readFileSync18, readdirSync as readdirSync8 } from "fs";
|
|
12525
|
+
import { join as join23 } from "path";
|
|
11895
12526
|
var pexec8 = promisify8(execFile9);
|
|
11896
12527
|
var DEFAULT_REFRESH_MS = 1000;
|
|
11897
12528
|
var DEFAULT_HEARTBEAT_MS = 15000;
|
|
@@ -11938,13 +12569,13 @@ class LatestFleetChannel {
|
|
|
11938
12569
|
function readManagedMirrors() {
|
|
11939
12570
|
const records = [];
|
|
11940
12571
|
const warnings = [];
|
|
11941
|
-
const stacksDirectory =
|
|
11942
|
-
if (!
|
|
12572
|
+
const stacksDirectory = join23(hestiaHome(), "stacks");
|
|
12573
|
+
if (!existsSync20(stacksDirectory))
|
|
11943
12574
|
return { records, warnings };
|
|
11944
|
-
for (const project of
|
|
11945
|
-
const path =
|
|
12575
|
+
for (const project of readdirSync8(stacksDirectory).sort()) {
|
|
12576
|
+
const path = join23(stacksDirectory, project, "stack.json");
|
|
11946
12577
|
try {
|
|
11947
|
-
const source =
|
|
12578
|
+
const source = readFileSync18(path);
|
|
11948
12579
|
if (source.byteLength > MAX_MIRROR_BYTES2) {
|
|
11949
12580
|
warnings.push(`Fleet mirror too large for ${project}`);
|
|
11950
12581
|
continue;
|
|
@@ -11964,7 +12595,7 @@ function readManagedMirrors() {
|
|
|
11964
12595
|
async function attributeLegacyRepoId(record) {
|
|
11965
12596
|
if (record.repoId !== undefined)
|
|
11966
12597
|
return record.repoId;
|
|
11967
|
-
if (!
|
|
12598
|
+
if (!existsSync20(record.worktree))
|
|
11968
12599
|
return null;
|
|
11969
12600
|
const info = await getRepoInfo(record.worktree);
|
|
11970
12601
|
if (projectName(info.repoId, info.repo, info.branch, info.worktreeRoot) !== record.project)
|
|
@@ -12070,6 +12701,25 @@ function reservationIdentity(reservation, records) {
|
|
|
12070
12701
|
worktree: record.worktree
|
|
12071
12702
|
};
|
|
12072
12703
|
}
|
|
12704
|
+
function collectFleetShared(repoProjects) {
|
|
12705
|
+
return listSharedHostnames().map((record) => ({
|
|
12706
|
+
name: record.name,
|
|
12707
|
+
hostname: record.hostname,
|
|
12708
|
+
...record.path === undefined ? {} : { path: record.path },
|
|
12709
|
+
url: `https://${record.hostname}${record.path ?? ""}`,
|
|
12710
|
+
holder: record.holder === undefined ? undefined : {
|
|
12711
|
+
project: record.holder.project,
|
|
12712
|
+
worktree: record.holder.worktree,
|
|
12713
|
+
mine: repoProjects.has(record.holder.project)
|
|
12714
|
+
},
|
|
12715
|
+
queue: (record.queue ?? []).map((waiter) => ({
|
|
12716
|
+
project: waiter.project,
|
|
12717
|
+
worktree: waiter.worktree,
|
|
12718
|
+
...waiter.denied === true ? { denied: true } : {},
|
|
12719
|
+
mine: repoProjects.has(waiter.project)
|
|
12720
|
+
}))
|
|
12721
|
+
})).sort((left, right) => left.name.localeCompare(right.name));
|
|
12722
|
+
}
|
|
12073
12723
|
async function collectFleetSnapshot(repoId, admission) {
|
|
12074
12724
|
const mirrorResult = readManagedMirrors();
|
|
12075
12725
|
const machineConfig = readHestiaMachineConfig();
|
|
@@ -12165,6 +12815,7 @@ async function collectFleetSnapshot(repoId, admission) {
|
|
|
12165
12815
|
const allRecordedProjects = new Set(attributed.map((record) => record.project));
|
|
12166
12816
|
const unbackedReservations = reservations.filter((reservation) => !allRecordedProjects.has(reservation.project));
|
|
12167
12817
|
const { maxStacks, warnings: capWarnings } = resolveMaxStacks();
|
|
12818
|
+
const repoProjects = new Set(attributed.filter((record) => record.repoId === repoId).map((record) => record.project));
|
|
12168
12819
|
return {
|
|
12169
12820
|
repoId,
|
|
12170
12821
|
observedAt: new Date().toISOString(),
|
|
@@ -12175,6 +12826,7 @@ async function collectFleetSnapshot(repoId, admission) {
|
|
|
12175
12826
|
queued: queued.length
|
|
12176
12827
|
},
|
|
12177
12828
|
stacks: stackViews,
|
|
12829
|
+
shared: collectFleetShared(repoProjects),
|
|
12178
12830
|
warnings: [...capWarnings, ...warnings].sort()
|
|
12179
12831
|
};
|
|
12180
12832
|
}
|
|
@@ -12183,6 +12835,7 @@ function semanticFleetSnapshot(snapshot) {
|
|
|
12183
12835
|
repoId: snapshot.repoId,
|
|
12184
12836
|
capacity: snapshot.capacity,
|
|
12185
12837
|
stacks: snapshot.stacks,
|
|
12838
|
+
shared: snapshot.shared,
|
|
12186
12839
|
warnings: snapshot.warnings
|
|
12187
12840
|
});
|
|
12188
12841
|
}
|
|
@@ -12398,7 +13051,7 @@ async function assertHestiaStateIgnored(worktreeRoot) {
|
|
|
12398
13051
|
try {
|
|
12399
13052
|
await pexec9("git", ["-C", worktreeRoot, "check-ignore", "-q", ".hestia/"], { timeout: 5000 });
|
|
12400
13053
|
} catch {
|
|
12401
|
-
throw new HestiaError("state-not-ignored", `.hestia is not ignored in ${worktreeRoot}; add the line ".hestia/" to ${
|
|
13054
|
+
throw new HestiaError("state-not-ignored", `.hestia is not ignored in ${worktreeRoot}; add the line ".hestia/" to ${join24(worktreeRoot, ".gitignore")}`, { remedy: `.hestia/`, path: join24(worktreeRoot, ".gitignore") });
|
|
12402
13055
|
}
|
|
12403
13056
|
}
|
|
12404
13057
|
async function prepareCompose(worktreeRoot, project, repo, branch, opts, configuredBaseFile) {
|
|
@@ -12415,7 +13068,7 @@ async function prepareCompose(worktreeRoot, project, repo, branch, opts, configu
|
|
|
12415
13068
|
services
|
|
12416
13069
|
});
|
|
12417
13070
|
ensureDir(hestiaDir(worktreeRoot));
|
|
12418
|
-
const overridePath =
|
|
13071
|
+
const overridePath = join24(hestiaDir(worktreeRoot), OVERRIDE_FILE);
|
|
12419
13072
|
writeAtomicTextFile(overridePath, yaml);
|
|
12420
13073
|
return {
|
|
12421
13074
|
ctx: {
|
|
@@ -12448,7 +13101,7 @@ function writeDockerfileComposeModel(worktreeRoot, workloads, conventionalCompos
|
|
|
12448
13101
|
...conventionalComposeFile === undefined ? {} : { include: [{ path: conventionalComposeFile }] },
|
|
12449
13102
|
services
|
|
12450
13103
|
};
|
|
12451
|
-
const path =
|
|
13104
|
+
const path = join24(hestiaDir(worktreeRoot), DOCKERFILE_COMPOSE_FILE);
|
|
12452
13105
|
writeAtomicTextFile(path, $stringify(model));
|
|
12453
13106
|
return path;
|
|
12454
13107
|
}
|
|
@@ -12468,7 +13121,7 @@ function freshRecord(project, repoId, repo, branch, worktree) {
|
|
|
12468
13121
|
};
|
|
12469
13122
|
}
|
|
12470
13123
|
function assertCurrentStackIdentity(record, current) {
|
|
12471
|
-
const path =
|
|
13124
|
+
const path = join24(hestiaDir(current.worktreeRoot), "stack.json");
|
|
12472
13125
|
assertMutableStackRecord(record, path);
|
|
12473
13126
|
const matches = record.repoId === current.repoId && record.repo === current.repo && record.branch === current.branch && resolve2(record.worktree) === resolve2(current.worktreeRoot) && record.project === projectName(current.repoId, current.repo, current.branch, current.worktreeRoot);
|
|
12474
13127
|
if (!matches) {
|
|
@@ -12603,6 +13256,27 @@ function applyLocalRouteProjection(record) {
|
|
|
12603
13256
|
record.env[directUrlKey(endpoint.name)] = endpoint.url;
|
|
12604
13257
|
record.env[localUrlKey(endpoint.name)] = endpoint.localUrl;
|
|
12605
13258
|
}
|
|
13259
|
+
syncSharedProjection(record);
|
|
13260
|
+
}
|
|
13261
|
+
function syncSharedProjection(record) {
|
|
13262
|
+
for (const shared of listSharedHostnames()) {
|
|
13263
|
+
const url = `https://${shared.hostname}${shared.path ?? ""}`;
|
|
13264
|
+
if (shared.holder?.project === record.project) {
|
|
13265
|
+
const endpoint = record.endpoints.find((candidate) => (candidate.alias ?? candidate.name) === shared.service);
|
|
13266
|
+
if (endpoint !== undefined) {
|
|
13267
|
+
endpoint.publicUrl = url;
|
|
13268
|
+
record.env[urlKey(shared.service)] = url;
|
|
13269
|
+
}
|
|
13270
|
+
continue;
|
|
13271
|
+
}
|
|
13272
|
+
for (const endpoint of record.endpoints) {
|
|
13273
|
+
if (endpoint.publicUrl === url)
|
|
13274
|
+
delete endpoint.publicUrl;
|
|
13275
|
+
}
|
|
13276
|
+
if (record.env[urlKey(shared.service)] === url) {
|
|
13277
|
+
delete record.env[urlKey(shared.service)];
|
|
13278
|
+
}
|
|
13279
|
+
}
|
|
12606
13280
|
}
|
|
12607
13281
|
function syncExposures(record) {
|
|
12608
13282
|
const t = record.tunnel;
|
|
@@ -12627,7 +13301,7 @@ function matchesExpectedStack(record, expected) {
|
|
|
12627
13301
|
return record !== null && (record.repoId === undefined || record.repoId === expected.repoId) && record.worktree === expected.worktree && record.createdAt === expected.createdAt;
|
|
12628
13302
|
}
|
|
12629
13303
|
function projectMutationRoot(project) {
|
|
12630
|
-
return
|
|
13304
|
+
return join24(hestiaHome(), "project-locks", project);
|
|
12631
13305
|
}
|
|
12632
13306
|
async function assertNamedTunnelDns(hostname, tunnelUuid) {
|
|
12633
13307
|
const expected = `${tunnelUuid}.cfargotunnel.com`;
|
|
@@ -13033,6 +13707,7 @@ class ComposeEngine {
|
|
|
13033
13707
|
const { repo, repoId, branch, worktreeRoot } = currentIdentity;
|
|
13034
13708
|
let tunnel;
|
|
13035
13709
|
let tunnelDirty = false;
|
|
13710
|
+
const stoppedAliases = [];
|
|
13036
13711
|
const project = readState(worktreeRoot)?.project ?? projectName(repoId, repo, branch, worktreeRoot);
|
|
13037
13712
|
await withLock(worktreeRoot, () => withLock(projectMutationRoot(project), async () => {
|
|
13038
13713
|
const record = readState(worktreeRoot);
|
|
@@ -13041,6 +13716,11 @@ class ComposeEngine {
|
|
|
13041
13716
|
if (record.services.some((service) => service.name === name && service.backend === "docker")) {
|
|
13042
13717
|
throw new HestiaError("backend-not-stoppable", `Docker workload ${name} cannot be stopped individually; use hestia down`);
|
|
13043
13718
|
}
|
|
13719
|
+
for (const endpoint of record.endpoints) {
|
|
13720
|
+
if ((endpoint.workload ?? endpoint.name) === name) {
|
|
13721
|
+
stoppedAliases.push(endpoint.alias ?? endpoint.name);
|
|
13722
|
+
}
|
|
13723
|
+
}
|
|
13044
13724
|
}
|
|
13045
13725
|
const pf = readPidfile(worktreeRoot, name);
|
|
13046
13726
|
if (pf !== null) {
|
|
@@ -13086,6 +13766,9 @@ class ComposeEngine {
|
|
|
13086
13766
|
}
|
|
13087
13767
|
}
|
|
13088
13768
|
}));
|
|
13769
|
+
for (const alias of new Set(stoppedAliases)) {
|
|
13770
|
+
await this.#releaseSharedHoldings(project, alias);
|
|
13771
|
+
}
|
|
13089
13772
|
if (tunnelDirty)
|
|
13090
13773
|
await this.#reconcileAdopted(tunnel);
|
|
13091
13774
|
await this.#refreshLocalRoutes();
|
|
@@ -13221,12 +13904,12 @@ class ComposeEngine {
|
|
|
13221
13904
|
await stopProcTree(pf);
|
|
13222
13905
|
removePidfile(worktreeRoot, pf.name);
|
|
13223
13906
|
}
|
|
13224
|
-
|
|
13907
|
+
rmSync10(privateRegistryDir(worktreeRoot), { recursive: true, force: true });
|
|
13225
13908
|
const composeFile = record?.composeFile ?? tryLoadConfig(worktreeRoot)?.composeFile;
|
|
13226
13909
|
if (composeFile !== undefined) {
|
|
13227
13910
|
const project2 = record?.project ?? projectName(repoId, repo, branch, worktreeRoot);
|
|
13228
|
-
const overrideFile = record?.overrideFile ??
|
|
13229
|
-
if (
|
|
13911
|
+
const overrideFile = record?.overrideFile ?? join24(hestiaDir(worktreeRoot), OVERRIDE_FILE);
|
|
13912
|
+
if (existsSync21(overrideFile)) {
|
|
13230
13913
|
await composeDown({ project: project2, baseFile: composeFile, overrideFile, cwd: worktreeRoot }, opts?.destroy ?? false);
|
|
13231
13914
|
} else if (record?.composeFile !== undefined) {
|
|
13232
13915
|
const rest = ["compose", "-p", project2, "down", "--remove-orphans"];
|
|
@@ -13238,6 +13921,7 @@ class ComposeEngine {
|
|
|
13238
13921
|
const project = record?.project ?? projectName(repoId, repo, branch, worktreeRoot);
|
|
13239
13922
|
clearState(worktreeRoot, project);
|
|
13240
13923
|
await this.#releaseAdmission(project);
|
|
13924
|
+
await this.#releaseSharedHoldings(project);
|
|
13241
13925
|
}));
|
|
13242
13926
|
await this.#reconcileAdopted(tunnel);
|
|
13243
13927
|
await this.#refreshLocalRoutes();
|
|
@@ -13247,6 +13931,11 @@ class ComposeEngine {
|
|
|
13247
13931
|
if (j !== null)
|
|
13248
13932
|
await releaseSlot(j.port, project);
|
|
13249
13933
|
}
|
|
13934
|
+
async#releaseSharedHoldings(project, service) {
|
|
13935
|
+
const j = readDaemonJson();
|
|
13936
|
+
if (j !== null)
|
|
13937
|
+
await releaseSharedForProject(j.port, project, service);
|
|
13938
|
+
}
|
|
13250
13939
|
async downProject(project, opts) {
|
|
13251
13940
|
if (!/^[a-z0-9][a-z0-9-]{0,99}$/.test(project)) {
|
|
13252
13941
|
throw new HestiaError("usage", `invalid project name ${JSON.stringify(project)}`);
|
|
@@ -13289,9 +13978,9 @@ class ComposeEngine {
|
|
|
13289
13978
|
throw new HestiaError("compose-failed", `docker compose -p ${project} down failed: ${err.message}`);
|
|
13290
13979
|
}
|
|
13291
13980
|
}
|
|
13292
|
-
|
|
13981
|
+
rmSync10(mirrorDir(project), { recursive: true, force: true });
|
|
13293
13982
|
};
|
|
13294
|
-
if (record !== null &&
|
|
13983
|
+
if (record !== null && existsSync21(record.worktree)) {
|
|
13295
13984
|
const info = await getRepoInfo(record.worktree);
|
|
13296
13985
|
const identityMatches = resolve2(info.worktreeRoot) === resolve2(record.worktree) && (record.repoId === undefined ? record.repo === info.repo : record.repoId === info.repoId);
|
|
13297
13986
|
const localRecord = readState(record.worktree);
|
|
@@ -13312,6 +14001,7 @@ class ComposeEngine {
|
|
|
13312
14001
|
return;
|
|
13313
14002
|
}
|
|
13314
14003
|
await this.#releaseAdmission(project);
|
|
14004
|
+
await this.#releaseSharedHoldings(project);
|
|
13315
14005
|
await this.#reconcileAdopted(record.tunnel);
|
|
13316
14006
|
await this.#refreshLocalRoutes();
|
|
13317
14007
|
return;
|
|
@@ -13320,6 +14010,7 @@ class ComposeEngine {
|
|
|
13320
14010
|
const projectLockRoot = projectMutationRoot(project);
|
|
13321
14011
|
await withLock(projectLockRoot, teardownFromMirror);
|
|
13322
14012
|
await this.#releaseAdmission(project);
|
|
14013
|
+
await this.#releaseSharedHoldings(project);
|
|
13323
14014
|
await this.#reconcileAdopted(record?.tunnel);
|
|
13324
14015
|
await this.#refreshLocalRoutes();
|
|
13325
14016
|
}
|
|
@@ -13338,11 +14029,100 @@ class ComposeEngine {
|
|
|
13338
14029
|
}
|
|
13339
14030
|
await ensureDaemon();
|
|
13340
14031
|
await this.#refreshLocalRoutes(true);
|
|
14032
|
+
if (opts?.shared !== undefined) {
|
|
14033
|
+
if (tunnelName === undefined) {
|
|
14034
|
+
throw new HestiaError("shared-requires-named-tunnel", "shared hostnames need an adopted named tunnel (stable DNS is the point) \u2014 pass --tunnel <name>");
|
|
14035
|
+
}
|
|
14036
|
+
return this.#exposeShared(worktreeRoot, services, tunnelName, opts);
|
|
14037
|
+
}
|
|
13341
14038
|
if (tunnelName === undefined) {
|
|
13342
14039
|
return this.#exposeQuick(worktreeRoot, services, opts);
|
|
13343
14040
|
}
|
|
13344
14041
|
return this.#exposeNamed(worktreeRoot, services, tunnelName, opts);
|
|
13345
14042
|
}
|
|
14043
|
+
async#exposeShared(worktreeRoot, services, tunnelName, opts) {
|
|
14044
|
+
if (services.length !== 1) {
|
|
14045
|
+
throw new HestiaError("usage", "--shared declares exactly one service per shared hostname");
|
|
14046
|
+
}
|
|
14047
|
+
const name = opts.shared;
|
|
14048
|
+
assertSharedName(name);
|
|
14049
|
+
const project = readState(worktreeRoot)?.project;
|
|
14050
|
+
if (project === undefined) {
|
|
14051
|
+
throw new HestiaError("service-not-found", "no stack in this worktree \u2014 `hestia up`/`run` something first");
|
|
14052
|
+
}
|
|
14053
|
+
const adopted = await adoptTunnel(tunnelName);
|
|
14054
|
+
if (adopted.connections > 0 && !isAdopted(adopted.uuid) && !opts?.force) {
|
|
14055
|
+
throw new HestiaError("tunnel-busy", `tunnel "${tunnelName}" already has ${adopted.connections} live edge ` + `connection(s) from a foreign connector \u2014 stop the other cloudflared first (hestia's ` + `connector serves your static hostnames too), or pass --force to ` + `accept nondeterministic routing`);
|
|
14056
|
+
}
|
|
14057
|
+
const preflightRecord = readState(worktreeRoot);
|
|
14058
|
+
if (preflightRecord === null) {
|
|
14059
|
+
throw new HestiaError("service-not-found", "stack disappeared while preparing the shared hostname");
|
|
14060
|
+
}
|
|
14061
|
+
const baseRules = importBaseRules(adopted.uuid, tunnelName);
|
|
14062
|
+
const zoneHint = opts?.zone ?? preflightRecord.tunnel?.zone ?? inferZone(baseRules);
|
|
14063
|
+
let hostname;
|
|
14064
|
+
let zone;
|
|
14065
|
+
if (opts?.hostname !== undefined) {
|
|
14066
|
+
hostname = opts.hostname.toLowerCase();
|
|
14067
|
+
assertSharedHostnameFqdn(hostname);
|
|
14068
|
+
zone = zoneOf(hostname) ?? zoneHint ?? hostname;
|
|
14069
|
+
} else {
|
|
14070
|
+
if (zoneHint === undefined) {
|
|
14071
|
+
throw new HestiaError("usage", "cannot infer a zone from the tunnel's existing rules \u2014 pass --zone or an explicit --hostname");
|
|
14072
|
+
}
|
|
14073
|
+
zone = zoneHint;
|
|
14074
|
+
hostname = `${name}.${zone}`;
|
|
14075
|
+
}
|
|
14076
|
+
const path = normalizeSharedPath(opts?.path);
|
|
14077
|
+
const selection = resolveEndpointSelection(preflightRecord, services[0]);
|
|
14078
|
+
if (selection.endpoint.kind !== undefined && selection.endpoint.kind !== "http") {
|
|
14079
|
+
throw new HestiaError("usage", `public tunnels require an HTTP endpoint; ${services[0]} is ${selection.endpoint.kind}`);
|
|
14080
|
+
}
|
|
14081
|
+
const alias = selection.endpoint.alias ?? selection.endpoint.name;
|
|
14082
|
+
if (baseRules.some((rule) => rule.hostname === hostname)) {
|
|
14083
|
+
throw new HestiaError("hostname-conflict", `shared hostname ${hostname} is already claimed by the user's static rules`);
|
|
14084
|
+
}
|
|
14085
|
+
const dynamicConflict = collectDynamicRules(adopted.uuid).rules.find((rule) => rule.hostname === hostname);
|
|
14086
|
+
if (dynamicConflict !== undefined) {
|
|
14087
|
+
throw new HestiaError("hostname-conflict", `shared hostname ${hostname} is already claimed by project ${dynamicConflict.project}`);
|
|
14088
|
+
}
|
|
14089
|
+
await assertNamedTunnelDns(hostname, adopted.uuid);
|
|
14090
|
+
await declareSharedHostname({
|
|
14091
|
+
name,
|
|
14092
|
+
hostname,
|
|
14093
|
+
path,
|
|
14094
|
+
tunnelUuid: adopted.uuid,
|
|
14095
|
+
zone,
|
|
14096
|
+
service: alias
|
|
14097
|
+
});
|
|
14098
|
+
const handle = await ensureDaemon();
|
|
14099
|
+
const claim = await sharedVerb(handle.port, "claim", {
|
|
14100
|
+
name,
|
|
14101
|
+
project,
|
|
14102
|
+
worktree: worktreeRoot,
|
|
14103
|
+
waitMs: 0
|
|
14104
|
+
});
|
|
14105
|
+
if (!claim.granted) {
|
|
14106
|
+
throw new HestiaError("shared-held", `shared hostname "${name}" is held by ${claim.holder?.project ?? "another stack"} ` + `(${claim.queued.length} queued) \u2014 \`hestia claim ${name} --wait\` to queue for it`);
|
|
14107
|
+
}
|
|
14108
|
+
const outcome = await reconcileTunnel({ name: tunnelName, uuid: adopted.uuid, credFile: adopted.credFile }, { force: opts?.force, readyTimeoutMs: opts?.readyTimeoutMs });
|
|
14109
|
+
for (const w of outcome.warnings)
|
|
14110
|
+
process.stderr.write(`warning: ${w}
|
|
14111
|
+
`);
|
|
14112
|
+
const final = await withLock(worktreeRoot, () => withLock(projectMutationRoot(project), async () => {
|
|
14113
|
+
const record = readState(worktreeRoot);
|
|
14114
|
+
if (record === null) {
|
|
14115
|
+
throw new HestiaError("service-not-found", "stack disappeared while exposing (concurrent down?)");
|
|
14116
|
+
}
|
|
14117
|
+
applyLocalRouteProjection(record);
|
|
14118
|
+
writeState(worktreeRoot, record);
|
|
14119
|
+
return record;
|
|
14120
|
+
}));
|
|
14121
|
+
if (outcome.error !== undefined)
|
|
14122
|
+
throw outcome.error;
|
|
14123
|
+
await this.#refreshLocalRoutes(true);
|
|
14124
|
+
return final;
|
|
14125
|
+
}
|
|
13346
14126
|
async#exposeQuick(worktreeRoot, services, opts) {
|
|
13347
14127
|
const project = readState(worktreeRoot)?.project;
|
|
13348
14128
|
if (project === undefined) {
|
|
@@ -13361,7 +14141,7 @@ class ComposeEngine {
|
|
|
13361
14141
|
const alias = selection.endpoint.alias ?? selection.endpoint.name;
|
|
13362
14142
|
const authority = internalEndpointAuthority(record.project, alias);
|
|
13363
14143
|
const name = `aux-quick-${createHash7("sha256").update(alias).digest("hex").slice(0, 10)}`;
|
|
13364
|
-
const quickCfg =
|
|
14144
|
+
const quickCfg = join24(hestiaDir(worktreeRoot), `quick-${name}.yml`);
|
|
13365
14145
|
writeAtomicTextFile(quickCfg, `ingress:
|
|
13366
14146
|
- service: ${JSON.stringify(`unix:${publicGatewaySocketPath()}`)}
|
|
13367
14147
|
` + ` originRequest:
|
|
@@ -13553,6 +14333,80 @@ class ComposeEngine {
|
|
|
13553
14333
|
await this.#refreshLocalRoutes(true);
|
|
13554
14334
|
return final;
|
|
13555
14335
|
}
|
|
14336
|
+
async#sharedContext(cwd, name) {
|
|
14337
|
+
assertSharedName(name);
|
|
14338
|
+
const { worktreeRoot } = await getRepoInfo(cwd);
|
|
14339
|
+
const record = readState(worktreeRoot);
|
|
14340
|
+
if (record === null) {
|
|
14341
|
+
throw new HestiaError("service-not-found", "no stack in this worktree \u2014 `hestia up`/`run` something first");
|
|
14342
|
+
}
|
|
14343
|
+
const shared = readSharedHostname(name);
|
|
14344
|
+
if (shared === null) {
|
|
14345
|
+
throw new HestiaError("shared-not-found", `no shared hostname "${name}" is declared \u2014 \`hestia expose <svc> --shared ${name}\` creates one`);
|
|
14346
|
+
}
|
|
14347
|
+
return { worktreeRoot, record, shared };
|
|
14348
|
+
}
|
|
14349
|
+
async#applySharedProjection(worktreeRoot, project) {
|
|
14350
|
+
return withLock(worktreeRoot, () => withLock(projectMutationRoot(project), async () => {
|
|
14351
|
+
const record = readState(worktreeRoot);
|
|
14352
|
+
if (record === null) {
|
|
14353
|
+
throw new HestiaError("service-not-found", "stack disappeared during the shared-hostname transition");
|
|
14354
|
+
}
|
|
14355
|
+
applyLocalRouteProjection(record);
|
|
14356
|
+
writeState(worktreeRoot, record);
|
|
14357
|
+
return record;
|
|
14358
|
+
}));
|
|
14359
|
+
}
|
|
14360
|
+
async claimShared(cwd, name, opts) {
|
|
14361
|
+
const { worktreeRoot, record, shared } = await this.#sharedContext(cwd, name);
|
|
14362
|
+
const endpoint = record.endpoints.find((candidate) => (candidate.alias ?? candidate.name) === shared.service);
|
|
14363
|
+
if (endpoint === undefined) {
|
|
14364
|
+
throw new HestiaError("service-not-found", `shared hostname "${name}" routes to endpoint alias "${shared.service}", which this ` + `stack does not expose \u2014 \`hestia up\`/\`run\` it first`);
|
|
14365
|
+
}
|
|
14366
|
+
if (endpoint.kind !== undefined && endpoint.kind !== "http") {
|
|
14367
|
+
throw new HestiaError("usage", `shared hostnames require an HTTP endpoint; ${shared.service} is ${endpoint.kind}`);
|
|
14368
|
+
}
|
|
14369
|
+
const handle = await ensureDaemon();
|
|
14370
|
+
const result = await sharedVerb(handle.port, "claim", {
|
|
14371
|
+
name,
|
|
14372
|
+
project: record.project,
|
|
14373
|
+
worktree: worktreeRoot,
|
|
14374
|
+
waitMs: opts?.waitMs ?? 0
|
|
14375
|
+
});
|
|
14376
|
+
if (!result.granted) {
|
|
14377
|
+
const position = result.queued.findIndex((waiter) => waiter.project === record.project) + 1;
|
|
14378
|
+
throw new HestiaError("shared-held", `shared hostname "${name}" is held by ${result.holder?.project ?? "another stack"}` + (position > 0 ? ` \u2014 queued durably at position ${position}/${result.queued.length}; the holder can ` + `\`hestia share allow ${name}\`, or re-run \`hestia claim ${name} --wait\` to keep waiting` : ""), { holder: result.holder, queued: result.queued });
|
|
14379
|
+
}
|
|
14380
|
+
const updated = await this.#applySharedProjection(worktreeRoot, record.project);
|
|
14381
|
+
await this.#refreshLocalRoutes();
|
|
14382
|
+
return { record: updated, result };
|
|
14383
|
+
}
|
|
14384
|
+
async releaseShared(cwd, name) {
|
|
14385
|
+
const { worktreeRoot, record } = await this.#sharedContext(cwd, name);
|
|
14386
|
+
const handle = await ensureDaemon();
|
|
14387
|
+
await sharedVerb(handle.port, "release", { name, project: record.project });
|
|
14388
|
+
const updated = await this.#applySharedProjection(worktreeRoot, record.project);
|
|
14389
|
+
await this.#refreshLocalRoutes();
|
|
14390
|
+
return updated;
|
|
14391
|
+
}
|
|
14392
|
+
async cancelSharedClaim(cwd, name) {
|
|
14393
|
+
const { record } = await this.#sharedContext(cwd, name);
|
|
14394
|
+
const handle = await ensureDaemon();
|
|
14395
|
+
await sharedVerb(handle.port, "cancel", { name, project: record.project });
|
|
14396
|
+
}
|
|
14397
|
+
async allowShared(cwd, name) {
|
|
14398
|
+
const { worktreeRoot, record } = await this.#sharedContext(cwd, name);
|
|
14399
|
+
const handle = await ensureDaemon();
|
|
14400
|
+
await sharedVerb(handle.port, "allow", { name, project: record.project });
|
|
14401
|
+
const updated = await this.#applySharedProjection(worktreeRoot, record.project);
|
|
14402
|
+
await this.#refreshLocalRoutes();
|
|
14403
|
+
return updated;
|
|
14404
|
+
}
|
|
14405
|
+
async denyShared(cwd, name) {
|
|
14406
|
+
const { record } = await this.#sharedContext(cwd, name);
|
|
14407
|
+
const handle = await ensureDaemon();
|
|
14408
|
+
await sharedVerb(handle.port, "deny", { name, project: record.project });
|
|
14409
|
+
}
|
|
13556
14410
|
async status(cwd) {
|
|
13557
14411
|
const { worktreeRoot } = await getRepoInfo(cwd);
|
|
13558
14412
|
const record = readState(worktreeRoot);
|
|
@@ -13672,11 +14526,24 @@ function startDuties(admission, opts) {
|
|
|
13672
14526
|
} catch (err) {
|
|
13673
14527
|
log(`sweep: pump failed: ${err.message}`);
|
|
13674
14528
|
}
|
|
14529
|
+
if (opts?.shared !== undefined) {
|
|
14530
|
+
try {
|
|
14531
|
+
const state = admission.healthSnapshot();
|
|
14532
|
+
await opts.shared.sweep(new Set([...state.live, ...state.reserved]));
|
|
14533
|
+
} catch (err) {
|
|
14534
|
+
log(`sweep: shared-hostname sweep failed: ${err.message}`);
|
|
14535
|
+
}
|
|
14536
|
+
}
|
|
13675
14537
|
for (const uuid of listAdopted()) {
|
|
13676
14538
|
try {
|
|
13677
14539
|
const pf = connectorPidfile(uuid);
|
|
13678
|
-
if (pf !== null && isLive(pf))
|
|
14540
|
+
if (pf !== null && isLive(pf)) {
|
|
14541
|
+
const { reapedGroups } = await reapOrphanConnectors(uuid, pf);
|
|
14542
|
+
if (reapedGroups > 0) {
|
|
14543
|
+
log(`sweep: reaped ${reapedGroups} orphan connector group(s) for ${uuid}`);
|
|
14544
|
+
}
|
|
13679
14545
|
continue;
|
|
14546
|
+
}
|
|
13680
14547
|
const ref = readAdopted(uuid);
|
|
13681
14548
|
if (ref === null)
|
|
13682
14549
|
continue;
|
|
@@ -13686,6 +14553,11 @@ function startDuties(admission, opts) {
|
|
|
13686
14553
|
const outcome = await reconcileTunnel(ref);
|
|
13687
14554
|
if (outcome.restarted) {
|
|
13688
14555
|
log(`sweep: connector for ${ref.name} (${uuid}) revived (ready=${outcome.ready})`);
|
|
14556
|
+
} else {
|
|
14557
|
+
for (const w of outcome.warnings) {
|
|
14558
|
+
if (w.includes("reaped"))
|
|
14559
|
+
log(`sweep: ${w}`);
|
|
14560
|
+
}
|
|
13689
14561
|
}
|
|
13690
14562
|
} catch (err) {
|
|
13691
14563
|
log(`sweep: connector revival for ${uuid} failed: ${err.message}`);
|
|
@@ -13729,6 +14601,7 @@ async function main() {
|
|
|
13729
14601
|
const engine2 = new ComposeEngine;
|
|
13730
14602
|
const localRouter = new HestiaLocalHttpRouter;
|
|
13731
14603
|
const routerPort = await localRouter.start();
|
|
14604
|
+
const shared = new SharedArbiter(() => localRouter.refreshRoutes());
|
|
13732
14605
|
const token = randomBytes(32).toString("hex");
|
|
13733
14606
|
const server = serveSessionBrokerDaemon({
|
|
13734
14607
|
daemon,
|
|
@@ -13739,6 +14612,7 @@ async function main() {
|
|
|
13739
14612
|
fleet,
|
|
13740
14613
|
routerPort,
|
|
13741
14614
|
gatewaySocket: localRouter.socketPath,
|
|
14615
|
+
shared,
|
|
13742
14616
|
refreshLocalRoutes: () => localRouter.refreshRoutes(),
|
|
13743
14617
|
logsProject: (project, options) => engine2.logsProject(project, options)
|
|
13744
14618
|
})
|
|
@@ -13755,11 +14629,11 @@ async function main() {
|
|
|
13755
14629
|
port: "none",
|
|
13756
14630
|
backend: "proc"
|
|
13757
14631
|
}),
|
|
13758
|
-
logPath:
|
|
14632
|
+
logPath: join25(root, "daemon.log"),
|
|
13759
14633
|
signal: "term",
|
|
13760
14634
|
backend: "proc"
|
|
13761
14635
|
});
|
|
13762
|
-
writeAtomicJsonFile(
|
|
14636
|
+
writeAtomicJsonFile(join25(root, "daemon.json"), {
|
|
13763
14637
|
schemaVersion: STATE_SCHEMA_VERSION,
|
|
13764
14638
|
pid: process.pid,
|
|
13765
14639
|
port: server.port,
|
|
@@ -13769,13 +14643,13 @@ async function main() {
|
|
|
13769
14643
|
routerPort,
|
|
13770
14644
|
gatewaySocket: localRouter.socketPath
|
|
13771
14645
|
}, { mode: 384 });
|
|
13772
|
-
const stopDuties = startDuties(admission);
|
|
14646
|
+
const stopDuties = startDuties(admission, { shared });
|
|
13773
14647
|
const shutdown = () => {
|
|
13774
14648
|
stopDuties();
|
|
13775
14649
|
fleet.stop();
|
|
13776
14650
|
localRouter.stop();
|
|
13777
14651
|
removePidfile(root, PIDFILE_NAME2);
|
|
13778
|
-
|
|
14652
|
+
rmSync11(join25(root, "daemon.json"), { force: true });
|
|
13779
14653
|
server.stop(true);
|
|
13780
14654
|
process.exit(0);
|
|
13781
14655
|
};
|
|
@@ -13790,4 +14664,4 @@ async function main() {
|
|
|
13790
14664
|
}
|
|
13791
14665
|
main();
|
|
13792
14666
|
|
|
13793
|
-
//# debugId=
|
|
14667
|
+
//# debugId=E5997ABC0405B8AE64756E2164756E21
|