@marcusrbrown/infra 0.26.2 → 0.27.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/README.md +5 -1
- package/dist/cli.js +1541 -423
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -6950,7 +6950,7 @@ import { goke } from "goke";
|
|
|
6950
6950
|
// package.json
|
|
6951
6951
|
var package_default = {
|
|
6952
6952
|
name: "@marcusrbrown/infra",
|
|
6953
|
-
version: "0.
|
|
6953
|
+
version: "0.27.0",
|
|
6954
6954
|
description: "Infrastructure management CLI \u2014 deploy automation, health checks, and MCP bridge",
|
|
6955
6955
|
keywords: [
|
|
6956
6956
|
"infra",
|
|
@@ -9763,49 +9763,207 @@ function registerBrokerLogs(cli) {
|
|
|
9763
9763
|
|
|
9764
9764
|
// src/commands/broker/status.ts
|
|
9765
9765
|
import { z as z9 } from "zod";
|
|
9766
|
-
|
|
9767
|
-
|
|
9768
|
-
|
|
9769
|
-
|
|
9770
|
-
|
|
9766
|
+
|
|
9767
|
+
// src/status/public-probes.ts
|
|
9768
|
+
var PUBLIC_PROBE_TIMEOUT_MS = 1e4;
|
|
9769
|
+
var PUBLIC_PROBE_RETRIES = 1;
|
|
9770
|
+
var MAX_BODY_CHARS = 64 * 1024;
|
|
9771
|
+
var KEEWEB_ORIGIN = "https://kw.igg.ms";
|
|
9772
|
+
var CLIPROXY_ORIGIN = "https://cliproxy.fro.bot";
|
|
9773
|
+
var BROKER_ORIGIN = "https://broker.fro.bot";
|
|
9774
|
+
var UMAMI_ORIGIN = "https://metrics.fro.bot";
|
|
9775
|
+
var DASHBOARD_ORIGIN = "https://dashboard.fro.bot";
|
|
9776
|
+
var PUBLIC_REACHABILITY_ID = "public-reachability";
|
|
9777
|
+
function isTimeoutError(error) {
|
|
9778
|
+
if (typeof error !== "object" || error === null || !("name" in error)) {
|
|
9779
|
+
return false;
|
|
9780
|
+
}
|
|
9781
|
+
return error.name === "TimeoutError" || error.name === "AbortError";
|
|
9782
|
+
}
|
|
9783
|
+
function discardBody(response) {
|
|
9771
9784
|
try {
|
|
9772
|
-
|
|
9773
|
-
|
|
9785
|
+
response.body?.cancel().catch(() => {
|
|
9786
|
+
return;
|
|
9774
9787
|
});
|
|
9775
|
-
|
|
9788
|
+
} catch {}
|
|
9789
|
+
}
|
|
9790
|
+
async function readBoundedBody(response, signal) {
|
|
9791
|
+
const reader = response.body?.getReader();
|
|
9792
|
+
if (reader === undefined) {
|
|
9793
|
+
return "";
|
|
9794
|
+
}
|
|
9795
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
9796
|
+
if (signal.aborted) {
|
|
9797
|
+
reject(signal.reason);
|
|
9798
|
+
}
|
|
9799
|
+
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
|
|
9800
|
+
});
|
|
9801
|
+
aborted.catch(() => {
|
|
9802
|
+
return;
|
|
9803
|
+
});
|
|
9804
|
+
const decoder = new TextDecoder;
|
|
9805
|
+
let text = "";
|
|
9806
|
+
try {
|
|
9807
|
+
while (text.length < MAX_BODY_CHARS) {
|
|
9808
|
+
const chunk = await Promise.race([reader.read(), aborted]);
|
|
9809
|
+
if (chunk.done) {
|
|
9810
|
+
text += decoder.decode();
|
|
9811
|
+
break;
|
|
9812
|
+
}
|
|
9813
|
+
text += decoder.decode(chunk.value, { stream: true });
|
|
9814
|
+
}
|
|
9815
|
+
return text.slice(0, MAX_BODY_CHARS);
|
|
9816
|
+
} catch {
|
|
9817
|
+
return;
|
|
9818
|
+
} finally {
|
|
9819
|
+
reader.cancel().catch(() => {
|
|
9820
|
+
return;
|
|
9821
|
+
});
|
|
9822
|
+
}
|
|
9823
|
+
}
|
|
9824
|
+
async function httpGet(url, options) {
|
|
9825
|
+
const retries = options.retries ?? 0;
|
|
9826
|
+
let lastError;
|
|
9827
|
+
for (let attempt = 0;attempt <= retries; attempt++) {
|
|
9828
|
+
const startedAt = performance.now();
|
|
9829
|
+
const signal = AbortSignal.timeout(options.timeoutMs);
|
|
9830
|
+
let response;
|
|
9831
|
+
try {
|
|
9832
|
+
response = await fetch(url, { signal });
|
|
9833
|
+
} catch (error) {
|
|
9834
|
+
lastError = {
|
|
9835
|
+
kind: "network-error",
|
|
9836
|
+
error,
|
|
9837
|
+
timedOut: isTimeoutError(error),
|
|
9838
|
+
durationMs: Math.max(0, performance.now() - startedAt)
|
|
9839
|
+
};
|
|
9840
|
+
continue;
|
|
9841
|
+
}
|
|
9842
|
+
let body;
|
|
9843
|
+
if (!response.ok) {
|
|
9844
|
+
discardBody(response);
|
|
9845
|
+
} else if (options.readBody === true) {
|
|
9846
|
+
body = await readBoundedBody(response, signal);
|
|
9847
|
+
}
|
|
9776
9848
|
return {
|
|
9849
|
+
kind: "response",
|
|
9850
|
+
response,
|
|
9851
|
+
status: response.status,
|
|
9777
9852
|
ok: response.ok,
|
|
9778
|
-
|
|
9779
|
-
|
|
9780
|
-
} catch (error) {
|
|
9781
|
-
const elapsedMs = Math.max(0, Math.round(performance.now() - startedAt));
|
|
9782
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
9783
|
-
return {
|
|
9784
|
-
ok: false,
|
|
9785
|
-
summary: `Request failed after ${elapsedMs}ms: ${message}`
|
|
9853
|
+
durationMs: Math.max(0, performance.now() - startedAt),
|
|
9854
|
+
body
|
|
9786
9855
|
};
|
|
9787
9856
|
}
|
|
9857
|
+
return lastError;
|
|
9788
9858
|
}
|
|
9789
|
-
|
|
9790
|
-
|
|
9859
|
+
function parseJsonObject(body) {
|
|
9860
|
+
let parsed;
|
|
9861
|
+
try {
|
|
9862
|
+
parsed = JSON.parse(body);
|
|
9863
|
+
} catch {
|
|
9864
|
+
return;
|
|
9865
|
+
}
|
|
9866
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
9867
|
+
return;
|
|
9868
|
+
}
|
|
9869
|
+
return parsed;
|
|
9870
|
+
}
|
|
9871
|
+
function checkUmamiHeartbeat(body) {
|
|
9872
|
+
return parseJsonObject(body)?.ok === true ? { ok: true } : { ok: false };
|
|
9873
|
+
}
|
|
9874
|
+
var CONTRACT_VERSION_PATTERN = /^[\w.+-]{1,64}$/;
|
|
9875
|
+
function checkOperatorHealth(body) {
|
|
9876
|
+
const version = parseJsonObject(body)?.contractVersion;
|
|
9877
|
+
if (typeof version !== "string" || !CONTRACT_VERSION_PATTERN.test(version)) {
|
|
9878
|
+
return { ok: false };
|
|
9879
|
+
}
|
|
9880
|
+
return { ok: true, detail: { contractVersion: version } };
|
|
9881
|
+
}
|
|
9882
|
+
function stripTrailingSlash2(value) {
|
|
9883
|
+
return value.endsWith("/") ? value.slice(0, -1) : value;
|
|
9884
|
+
}
|
|
9885
|
+
async function runProbe(spec, options = {}) {
|
|
9886
|
+
const url = `${stripTrailingSlash2(options.baseUrl ?? spec.origin)}${spec.path}`;
|
|
9887
|
+
const attempt = await httpGet(url, {
|
|
9888
|
+
timeoutMs: options.timeoutMs ?? PUBLIC_PROBE_TIMEOUT_MS,
|
|
9889
|
+
retries: PUBLIC_PROBE_RETRIES,
|
|
9890
|
+
readBody: spec.checkBody !== undefined
|
|
9891
|
+
});
|
|
9892
|
+
const observedAt = (options.now?.() ?? new Date).toISOString();
|
|
9893
|
+
const durationMs = Math.round(attempt.durationMs);
|
|
9894
|
+
const make = (result, detail) => ({
|
|
9895
|
+
id: PUBLIC_REACHABILITY_ID,
|
|
9896
|
+
kind: "reachability",
|
|
9897
|
+
result,
|
|
9898
|
+
observedAt,
|
|
9899
|
+
source: "http",
|
|
9900
|
+
detail
|
|
9901
|
+
});
|
|
9902
|
+
if (attempt.kind === "network-error") {
|
|
9903
|
+
let reason = attempt.timedOut ? "timeout" : "network-error";
|
|
9904
|
+
if (spec.unreachableReason === "dependency-unreachable") {
|
|
9905
|
+
reason = "dependency-unreachable";
|
|
9906
|
+
}
|
|
9907
|
+
return make("unknown", { durationMs, reason });
|
|
9908
|
+
}
|
|
9909
|
+
const detail = { statusCode: attempt.status, durationMs };
|
|
9910
|
+
if (!attempt.ok) {
|
|
9911
|
+
return make("fail", detail);
|
|
9912
|
+
}
|
|
9913
|
+
if (spec.checkBody === undefined) {
|
|
9914
|
+
return make("pass", detail);
|
|
9915
|
+
}
|
|
9916
|
+
const verdict = spec.checkBody(attempt.body ?? "");
|
|
9917
|
+
return verdict.ok ? make("pass", { ...detail, ...verdict.detail }) : make("fail", detail);
|
|
9918
|
+
}
|
|
9919
|
+
function probeKeeweb(options) {
|
|
9920
|
+
return runProbe({ origin: KEEWEB_ORIGIN, path: "/", unreachableReason: "network-error" }, options);
|
|
9921
|
+
}
|
|
9922
|
+
function probeCliproxy(options) {
|
|
9923
|
+
return runProbe({ origin: CLIPROXY_ORIGIN, path: "/healthz", unreachableReason: "network-error" }, options);
|
|
9924
|
+
}
|
|
9925
|
+
function probeBroker(options) {
|
|
9926
|
+
return runProbe({ origin: BROKER_ORIGIN, path: "/healthz", unreachableReason: "network-error" }, options);
|
|
9927
|
+
}
|
|
9928
|
+
function probeUmami(options) {
|
|
9929
|
+
return runProbe({ origin: UMAMI_ORIGIN, path: "/api/heartbeat", checkBody: checkUmamiHeartbeat, unreachableReason: "network-error" }, options);
|
|
9930
|
+
}
|
|
9931
|
+
function probeDashboard(options) {
|
|
9932
|
+
return runProbe({ origin: DASHBOARD_ORIGIN, path: "/api/healthz", unreachableReason: "network-error" }, options);
|
|
9933
|
+
}
|
|
9934
|
+
function probeGateway(options) {
|
|
9935
|
+
return runProbe({
|
|
9936
|
+
origin: DASHBOARD_ORIGIN,
|
|
9937
|
+
path: "/operator/health",
|
|
9938
|
+
checkBody: checkOperatorHealth,
|
|
9939
|
+
unreachableReason: "dependency-unreachable"
|
|
9940
|
+
}, options);
|
|
9941
|
+
}
|
|
9942
|
+
var PUBLIC_PROBES = {
|
|
9943
|
+
keeweb: probeKeeweb,
|
|
9944
|
+
cliproxy: probeCliproxy,
|
|
9945
|
+
gateway: probeGateway,
|
|
9946
|
+
umami: probeUmami,
|
|
9947
|
+
dashboard: probeDashboard,
|
|
9948
|
+
broker: probeBroker
|
|
9949
|
+
};
|
|
9950
|
+
|
|
9951
|
+
// src/commands/broker/status.ts
|
|
9952
|
+
var DEFAULT_BROKER_URL = "https://broker.fro.bot";
|
|
9953
|
+
async function checkBrokerHealth(baseUrl) {
|
|
9954
|
+
const url = `${baseUrl}/healthz`;
|
|
9955
|
+
const attempt = await httpGet(url, { timeoutMs: PUBLIC_PROBE_TIMEOUT_MS });
|
|
9956
|
+
const elapsedMs = Math.max(0, Math.round(attempt.durationMs));
|
|
9957
|
+
if (attempt.kind === "response") {
|
|
9791
9958
|
return {
|
|
9792
|
-
|
|
9793
|
-
|
|
9794
|
-
lastDeploy: "\u2014",
|
|
9795
|
-
version: "\u2014",
|
|
9796
|
-
contentHash: "\u2014",
|
|
9797
|
-
usageStats: "\u2014"
|
|
9959
|
+
ok: attempt.ok,
|
|
9960
|
+
summary: `GET ${url} \u2192 ${attempt.status} (${elapsedMs}ms)`
|
|
9798
9961
|
};
|
|
9799
9962
|
}
|
|
9800
|
-
const
|
|
9801
|
-
const health = await checkBrokerHealth(baseUrl);
|
|
9963
|
+
const message = attempt.error instanceof Error ? attempt.error.message : String(attempt.error);
|
|
9802
9964
|
return {
|
|
9803
|
-
|
|
9804
|
-
|
|
9805
|
-
lastDeploy: "\u2014",
|
|
9806
|
-
version: "\u2014",
|
|
9807
|
-
contentHash: "\u2014",
|
|
9808
|
-
usageStats: "\u2014"
|
|
9965
|
+
ok: false,
|
|
9966
|
+
summary: `Request failed after ${elapsedMs}ms: ${message}`
|
|
9809
9967
|
};
|
|
9810
9968
|
}
|
|
9811
9969
|
async function brokerStatusAction(options, ctx) {
|
|
@@ -9867,11 +10025,11 @@ function registerBrokerCommands(cli) {
|
|
|
9867
10025
|
import { chmodSync } from "fs";
|
|
9868
10026
|
import { z as z10 } from "zod";
|
|
9869
10027
|
var DEFAULT_CLIPROXY_URL3 = "https://cliproxy.fro.bot";
|
|
9870
|
-
function
|
|
10028
|
+
function stripTrailingSlash3(value) {
|
|
9871
10029
|
return value.endsWith("/") ? value.slice(0, -1) : value;
|
|
9872
10030
|
}
|
|
9873
10031
|
function resolveBaseUrl2(input) {
|
|
9874
|
-
return
|
|
10032
|
+
return stripTrailingSlash3(input ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL3);
|
|
9875
10033
|
}
|
|
9876
10034
|
var resolveManagementKey2 = resolveManagementKey;
|
|
9877
10035
|
function parseBoolean(value) {
|
|
@@ -10089,11 +10247,11 @@ function registerCliproxyDeploy(cli) {
|
|
|
10089
10247
|
// src/commands/cliproxy/keys.ts
|
|
10090
10248
|
import { z as z12 } from "zod";
|
|
10091
10249
|
var DEFAULT_CLIPROXY_URL4 = "https://cliproxy.fro.bot";
|
|
10092
|
-
function
|
|
10250
|
+
function stripTrailingSlash4(value) {
|
|
10093
10251
|
return value.endsWith("/") ? value.slice(0, -1) : value;
|
|
10094
10252
|
}
|
|
10095
10253
|
function resolveBaseUrl3(input) {
|
|
10096
|
-
return
|
|
10254
|
+
return stripTrailingSlash4(input ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL4);
|
|
10097
10255
|
}
|
|
10098
10256
|
function resolveManagementKey3(input) {
|
|
10099
10257
|
const key = input ?? process.env.CLIPROXY_MANAGEMENT_KEY;
|
|
@@ -10272,7 +10430,7 @@ function registerCliproxyLogin(cli) {
|
|
|
10272
10430
|
// src/commands/cliproxy/models.ts
|
|
10273
10431
|
import { z as z14 } from "zod";
|
|
10274
10432
|
var DEFAULT_CLIPROXY_URL5 = "https://cliproxy.fro.bot";
|
|
10275
|
-
function
|
|
10433
|
+
function stripTrailingSlash5(value) {
|
|
10276
10434
|
return value.endsWith("/") ? value.slice(0, -1) : value;
|
|
10277
10435
|
}
|
|
10278
10436
|
var modelEntrySchema2 = z14.object({
|
|
@@ -10344,8 +10502,8 @@ function formatVerboseEntry(entry) {
|
|
|
10344
10502
|
}
|
|
10345
10503
|
async function cliproxyModelsAction(options, ctx) {
|
|
10346
10504
|
try {
|
|
10347
|
-
const baseUrl =
|
|
10348
|
-
const trustedUrl =
|
|
10505
|
+
const baseUrl = stripTrailingSlash5(options.url ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL5);
|
|
10506
|
+
const trustedUrl = stripTrailingSlash5(process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL5);
|
|
10349
10507
|
const urlIsExplicitlyOverridden = options.url !== undefined && baseUrl !== trustedUrl;
|
|
10350
10508
|
const key = options.key ?? (urlIsExplicitlyOverridden ? undefined : process.env.CLIPROXY_API_KEY);
|
|
10351
10509
|
if (!key) {
|
|
@@ -10397,11 +10555,28 @@ import { z as z16 } from "zod";
|
|
|
10397
10555
|
import { z as z15 } from "zod";
|
|
10398
10556
|
|
|
10399
10557
|
// src/lib/redact.ts
|
|
10558
|
+
function lowerAscii(value) {
|
|
10559
|
+
let result = "";
|
|
10560
|
+
for (const char of value) {
|
|
10561
|
+
const code = char.codePointAt(0) ?? 0;
|
|
10562
|
+
result += code >= 65 && code <= 90 ? String.fromCodePoint(code + 32) : char;
|
|
10563
|
+
}
|
|
10564
|
+
return result;
|
|
10565
|
+
}
|
|
10400
10566
|
function redactHost(text, host) {
|
|
10401
10567
|
if (!host)
|
|
10402
10568
|
return text;
|
|
10403
|
-
const
|
|
10404
|
-
|
|
10569
|
+
const foldedText = lowerAscii(text);
|
|
10570
|
+
const foldedHost = lowerAscii(host);
|
|
10571
|
+
let result = "";
|
|
10572
|
+
let cursor = 0;
|
|
10573
|
+
let match = foldedText.indexOf(foldedHost, cursor);
|
|
10574
|
+
while (match !== -1) {
|
|
10575
|
+
result += `${text.slice(cursor, match)}<host>`;
|
|
10576
|
+
cursor = match + foldedHost.length;
|
|
10577
|
+
match = foldedText.indexOf(foldedHost, cursor);
|
|
10578
|
+
}
|
|
10579
|
+
return result + text.slice(cursor);
|
|
10405
10580
|
}
|
|
10406
10581
|
|
|
10407
10582
|
// ../shared/server/droplet-helpers.ts
|
|
@@ -10443,6 +10618,125 @@ function buildIdentityArgs(privateKey) {
|
|
|
10443
10618
|
return { args: ["-i", path, "-o", "IdentitiesOnly=yes"], cleanup };
|
|
10444
10619
|
}
|
|
10445
10620
|
|
|
10621
|
+
// src/status/collect.ts
|
|
10622
|
+
function observe(spec, result, options = {}) {
|
|
10623
|
+
return {
|
|
10624
|
+
id: spec.id,
|
|
10625
|
+
kind: spec.kind,
|
|
10626
|
+
source: spec.source,
|
|
10627
|
+
result,
|
|
10628
|
+
observedAt: (options.now ?? new Date).toISOString(),
|
|
10629
|
+
...options.detail === undefined ? {} : { detail: options.detail }
|
|
10630
|
+
};
|
|
10631
|
+
}
|
|
10632
|
+
function notMeasured(spec, reason) {
|
|
10633
|
+
return { id: spec.id, kind: spec.kind, source: "config", result: "not_measured", observedAt: null, detail: { reason } };
|
|
10634
|
+
}
|
|
10635
|
+
function composeObservation(spec, result) {
|
|
10636
|
+
if (result.services.length === 0) {
|
|
10637
|
+
return result.error === undefined ? observe(spec, "fail", { detail: { reason: "no-services" } }) : observe(spec, "unknown", { detail: { reason: "compose-failed" } });
|
|
10638
|
+
}
|
|
10639
|
+
return result.ok ? observe(spec, "pass") : observe(spec, "fail", { detail: { reason: "degraded" } });
|
|
10640
|
+
}
|
|
10641
|
+
function servicesFact(services) {
|
|
10642
|
+
return services.map(({ service, state, health }) => ({ service, state, health }));
|
|
10643
|
+
}
|
|
10644
|
+
|
|
10645
|
+
// src/status/declared.ts
|
|
10646
|
+
var publicReachability = {
|
|
10647
|
+
id: PUBLIC_REACHABILITY_ID,
|
|
10648
|
+
kind: "reachability",
|
|
10649
|
+
source: "http",
|
|
10650
|
+
owner: "probe"
|
|
10651
|
+
};
|
|
10652
|
+
var deployState = {
|
|
10653
|
+
id: "deploy-state",
|
|
10654
|
+
kind: "deploy-state",
|
|
10655
|
+
source: "github-api",
|
|
10656
|
+
owner: "github"
|
|
10657
|
+
};
|
|
10658
|
+
var composeServices = {
|
|
10659
|
+
id: "compose-services",
|
|
10660
|
+
kind: "container-health",
|
|
10661
|
+
source: "ssh",
|
|
10662
|
+
owner: "collector"
|
|
10663
|
+
};
|
|
10664
|
+
var DECLARED_OBSERVATIONS = {
|
|
10665
|
+
keeweb: [publicReachability, deployState],
|
|
10666
|
+
cliproxy: [
|
|
10667
|
+
publicReachability,
|
|
10668
|
+
{ id: "container-running", kind: "container-health", source: "ssh", owner: "collector" },
|
|
10669
|
+
deployState,
|
|
10670
|
+
{ id: "provider-route", kind: "end-to-end", source: "monitor", owner: "github" }
|
|
10671
|
+
],
|
|
10672
|
+
gateway: [
|
|
10673
|
+
publicReachability,
|
|
10674
|
+
composeServices,
|
|
10675
|
+
{ id: "workspace-memory", kind: "resource", source: "ssh", owner: "collector" },
|
|
10676
|
+
deployState
|
|
10677
|
+
],
|
|
10678
|
+
umami: [publicReachability, composeServices, deployState],
|
|
10679
|
+
dashboard: [publicReachability, composeServices, deployState],
|
|
10680
|
+
vpn: [
|
|
10681
|
+
{ id: PUBLIC_REACHABILITY_ID, kind: "reachability", source: "config", owner: "not-applicable" },
|
|
10682
|
+
{ id: "wireguard-interface", kind: "container-health", source: "ssh", owner: "collector" },
|
|
10683
|
+
deployState,
|
|
10684
|
+
{ id: "egress-route", kind: "end-to-end", source: "config", owner: "not-applicable" }
|
|
10685
|
+
],
|
|
10686
|
+
broker: [publicReachability, deployState]
|
|
10687
|
+
};
|
|
10688
|
+
function declaredObservation(app, id) {
|
|
10689
|
+
const declared = DECLARED_OBSERVATIONS[app].find((entry) => entry.id === id);
|
|
10690
|
+
if (declared === undefined) {
|
|
10691
|
+
throw new Error(`${app} does not declare observation "${id}"`);
|
|
10692
|
+
}
|
|
10693
|
+
return declared;
|
|
10694
|
+
}
|
|
10695
|
+
function unknown(declared, reason, now) {
|
|
10696
|
+
return observe(declared, "unknown", { detail: { reason }, now });
|
|
10697
|
+
}
|
|
10698
|
+
function resolveObservation(declared, probe, collector, github, now) {
|
|
10699
|
+
switch (declared.owner) {
|
|
10700
|
+
case "not-applicable":
|
|
10701
|
+
return notMeasured(declared, declared.owner);
|
|
10702
|
+
case "github":
|
|
10703
|
+
return github.find((observation) => observation.id === declared.id) ?? unknown(declared, "missing-result", now);
|
|
10704
|
+
case "probe":
|
|
10705
|
+
if (probe.kind === "ok")
|
|
10706
|
+
return probe.observation;
|
|
10707
|
+
return unknown(declared, probe.kind === "failed" ? "check-failed" : "missing-result", now);
|
|
10708
|
+
case "collector":
|
|
10709
|
+
if (collector.kind === "missing-config")
|
|
10710
|
+
return notMeasured(declared, "missing-config");
|
|
10711
|
+
if (collector.kind === "failed")
|
|
10712
|
+
return unknown(declared, "check-failed", now);
|
|
10713
|
+
if (collector.kind === "timeout")
|
|
10714
|
+
return unknown(declared, "timeout", now);
|
|
10715
|
+
if (collector.kind === "none")
|
|
10716
|
+
return unknown(declared, "missing-result", now);
|
|
10717
|
+
return collector.collection.observations.find((observation) => observation.id === declared.id) ?? unknown(declared, "missing-result", now);
|
|
10718
|
+
}
|
|
10719
|
+
}
|
|
10720
|
+
function assembleAppStatus(app, probe, collector, now, github = []) {
|
|
10721
|
+
return {
|
|
10722
|
+
app,
|
|
10723
|
+
observations: DECLARED_OBSERVATIONS[app].map((declared) => resolveObservation(declared, probe, collector, github, now)),
|
|
10724
|
+
facts: collector.kind === "ok" ? collector.collection.facts : {}
|
|
10725
|
+
};
|
|
10726
|
+
}
|
|
10727
|
+
var SEVERITY = { pass: 0, not_measured: 1, unknown: 2, fail: 3 };
|
|
10728
|
+
function worstResult(app, kind) {
|
|
10729
|
+
let worst;
|
|
10730
|
+
for (const observation of app.observations) {
|
|
10731
|
+
if (observation.kind !== kind)
|
|
10732
|
+
continue;
|
|
10733
|
+
if (worst === undefined || SEVERITY[observation.result] > SEVERITY[worst]) {
|
|
10734
|
+
worst = observation.result;
|
|
10735
|
+
}
|
|
10736
|
+
}
|
|
10737
|
+
return worst;
|
|
10738
|
+
}
|
|
10739
|
+
|
|
10446
10740
|
// src/commands/cliproxy/status.ts
|
|
10447
10741
|
var DEFAULT_CLIPROXY_URL6 = "https://cliproxy.fro.bot";
|
|
10448
10742
|
function defaultSpawn(cmd, opts) {
|
|
@@ -10460,7 +10754,7 @@ function levelLabel(level) {
|
|
|
10460
10754
|
function formatDurationMs(durationMs) {
|
|
10461
10755
|
return `${Math.max(0, Math.round(durationMs))}ms`;
|
|
10462
10756
|
}
|
|
10463
|
-
function
|
|
10757
|
+
function stripTrailingSlash6(value) {
|
|
10464
10758
|
return value.endsWith("/") ? value.slice(0, -1) : value;
|
|
10465
10759
|
}
|
|
10466
10760
|
async function parseJsonResponse(response) {
|
|
@@ -10474,12 +10768,9 @@ function toNumber(value) {
|
|
|
10474
10768
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
10475
10769
|
}
|
|
10476
10770
|
async function checkHttpReachability(url, verbose) {
|
|
10477
|
-
const
|
|
10478
|
-
|
|
10479
|
-
const response =
|
|
10480
|
-
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
|
|
10481
|
-
});
|
|
10482
|
-
const elapsedMs = performance.now() - startedAt;
|
|
10771
|
+
const attempt = await httpGet(url, { timeoutMs: HTTP_TIMEOUT_MS });
|
|
10772
|
+
if (attempt.kind === "response") {
|
|
10773
|
+
const { response } = attempt;
|
|
10483
10774
|
const details = [];
|
|
10484
10775
|
if (verbose) {
|
|
10485
10776
|
details.push(`URL: ${url}`);
|
|
@@ -10490,20 +10781,18 @@ async function checkHttpReachability(url, verbose) {
|
|
|
10490
10781
|
}
|
|
10491
10782
|
return {
|
|
10492
10783
|
title: "HTTP reachability",
|
|
10493
|
-
level:
|
|
10494
|
-
summary: `GET ${url} \u2192 ${
|
|
10784
|
+
level: attempt.ok ? "ok" : "error",
|
|
10785
|
+
summary: `GET ${url} \u2192 ${attempt.status} (${formatDurationMs(attempt.durationMs)})`,
|
|
10495
10786
|
details
|
|
10496
10787
|
};
|
|
10497
|
-
} catch (error) {
|
|
10498
|
-
const elapsedMs = performance.now() - startedAt;
|
|
10499
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
10500
|
-
return {
|
|
10501
|
-
title: "HTTP reachability",
|
|
10502
|
-
level: "error",
|
|
10503
|
-
summary: `Request failed after ${formatDurationMs(elapsedMs)}: ${message}`,
|
|
10504
|
-
details: verbose ? [`URL: ${url}`, `Timeout: ${HTTP_TIMEOUT_MS}ms`] : undefined
|
|
10505
|
-
};
|
|
10506
10788
|
}
|
|
10789
|
+
const message = attempt.error instanceof Error ? attempt.error.message : String(attempt.error);
|
|
10790
|
+
return {
|
|
10791
|
+
title: "HTTP reachability",
|
|
10792
|
+
level: "error",
|
|
10793
|
+
summary: `Request failed after ${formatDurationMs(attempt.durationMs)}: ${message}`,
|
|
10794
|
+
details: verbose ? [`URL: ${url}`, `Timeout: ${HTTP_TIMEOUT_MS}ms`] : undefined
|
|
10795
|
+
};
|
|
10507
10796
|
}
|
|
10508
10797
|
function countQueueErrors(records) {
|
|
10509
10798
|
let count = 0;
|
|
@@ -10613,7 +10902,7 @@ async function checkVersion(baseUrl, key) {
|
|
|
10613
10902
|
return { title: "Latest available version", level: "error", summary: `Unable to check latest version: ${message}` };
|
|
10614
10903
|
}
|
|
10615
10904
|
}
|
|
10616
|
-
async function
|
|
10905
|
+
async function inspectRunningContainer(host, spawn = defaultSpawn, signal) {
|
|
10617
10906
|
let cleanup = () => {
|
|
10618
10907
|
return;
|
|
10619
10908
|
};
|
|
@@ -10640,7 +10929,7 @@ async function checkRunningVersion(host, spawn = defaultSpawn) {
|
|
|
10640
10929
|
HOME: process.env.HOME ?? "/tmp",
|
|
10641
10930
|
...process.env.SSH_AUTH_SOCK ? { SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK } : {}
|
|
10642
10931
|
};
|
|
10643
|
-
const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe" });
|
|
10932
|
+
const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe", ...signal === undefined ? {} : { signal } });
|
|
10644
10933
|
const [stdoutText, stderrText, exitCode] = await Promise.all([
|
|
10645
10934
|
new Response(child.stdout).text(),
|
|
10646
10935
|
new Response(child.stderr).text(),
|
|
@@ -10648,28 +10937,56 @@ async function checkRunningVersion(host, spawn = defaultSpawn) {
|
|
|
10648
10937
|
]);
|
|
10649
10938
|
if (exitCode !== 0) {
|
|
10650
10939
|
return {
|
|
10651
|
-
|
|
10652
|
-
level: "warning",
|
|
10940
|
+
kind: "unavailable",
|
|
10653
10941
|
summary: `SSH command failed (exit ${exitCode}): ${redactHost(stderrText.trim(), host) || "unknown error"}`
|
|
10654
10942
|
};
|
|
10655
10943
|
}
|
|
10656
10944
|
const imageMatch = /(?:^|\s)eceasy\/cli-proxy-api:([^\s@]+)/m.exec(stdoutText);
|
|
10657
|
-
|
|
10658
|
-
return {
|
|
10659
|
-
title: "Running version",
|
|
10660
|
-
level: "warning",
|
|
10661
|
-
summary: "No running eceasy/cli-proxy-api container found."
|
|
10662
|
-
};
|
|
10663
|
-
}
|
|
10664
|
-
return { title: "Running version", level: "ok", summary: imageMatch[1] ?? "unknown" };
|
|
10945
|
+
return imageMatch ? { kind: "running", version: imageMatch[1] ?? "unknown" } : { kind: "none" };
|
|
10665
10946
|
} catch (error) {
|
|
10666
10947
|
const message = error instanceof Error ? error.message : String(error);
|
|
10667
|
-
return {
|
|
10948
|
+
return { kind: "unavailable", summary: `Unable to check running version via SSH: ${message}` };
|
|
10668
10949
|
} finally {
|
|
10669
10950
|
cleanup();
|
|
10670
10951
|
}
|
|
10671
10952
|
}
|
|
10953
|
+
async function checkRunningVersion(host, spawn = defaultSpawn) {
|
|
10954
|
+
const container = await inspectRunningContainer(host, spawn);
|
|
10955
|
+
if (container.kind === "running") {
|
|
10956
|
+
return { title: "Running version", level: "ok", summary: container.version };
|
|
10957
|
+
}
|
|
10958
|
+
if (container.kind === "none") {
|
|
10959
|
+
return { title: "Running version", level: "warning", summary: "No running eceasy/cli-proxy-api container found." };
|
|
10960
|
+
}
|
|
10961
|
+
return { title: "Running version", level: "warning", summary: container.summary };
|
|
10962
|
+
}
|
|
10672
10963
|
var DEFAULT_PROVIDER_MODEL = "claude-sonnet-4-6";
|
|
10964
|
+
async function readBodyText(response) {
|
|
10965
|
+
try {
|
|
10966
|
+
return await response.text();
|
|
10967
|
+
} catch {
|
|
10968
|
+
return "";
|
|
10969
|
+
}
|
|
10970
|
+
}
|
|
10971
|
+
var PROVIDER_MISSING_MESSAGE = /unknown provider for model/i;
|
|
10972
|
+
function isProviderMissingBody(bodyText) {
|
|
10973
|
+
let parsed;
|
|
10974
|
+
try {
|
|
10975
|
+
parsed = JSON.parse(bodyText);
|
|
10976
|
+
} catch {
|
|
10977
|
+
return PROVIDER_MISSING_MESSAGE.test(bodyText);
|
|
10978
|
+
}
|
|
10979
|
+
if (typeof parsed === "object" && parsed !== null && "error" in parsed) {
|
|
10980
|
+
const { error } = parsed;
|
|
10981
|
+
if (typeof error === "object" && error !== null) {
|
|
10982
|
+
if ("code" in error && error.code === "model_not_found")
|
|
10983
|
+
return true;
|
|
10984
|
+
if ("message" in error && typeof error.message === "string")
|
|
10985
|
+
return PROVIDER_MISSING_MESSAGE.test(error.message);
|
|
10986
|
+
}
|
|
10987
|
+
}
|
|
10988
|
+
return PROVIDER_MISSING_MESSAGE.test(bodyText);
|
|
10989
|
+
}
|
|
10673
10990
|
async function runProviderAuthProbe(baseUrl, apiKey, options) {
|
|
10674
10991
|
const model = options?.model ?? DEFAULT_PROVIDER_MODEL;
|
|
10675
10992
|
const verbose = options?.verbose === true;
|
|
@@ -10716,11 +11033,22 @@ async function runProviderAuthProbe(baseUrl, apiKey, options) {
|
|
|
10716
11033
|
}
|
|
10717
11034
|
};
|
|
10718
11035
|
}
|
|
11036
|
+
if (response.status === 400) {
|
|
11037
|
+
const bodyText = await readBodyText(response);
|
|
11038
|
+
if (isProviderMissingBody(bodyText)) {
|
|
11039
|
+
return {
|
|
11040
|
+
classification: { state: "dead", reason: "provider-missing" },
|
|
11041
|
+
human: {
|
|
11042
|
+
title: "Upstream provider auth (anthropic)",
|
|
11043
|
+
level: "error",
|
|
11044
|
+
summary: `Anthropic provider not registered for ${model} (HTTP ${response.status}) \u2014 check /v1/models; run: cliproxy login claude`,
|
|
11045
|
+
details: details.length > 0 ? details : undefined
|
|
11046
|
+
}
|
|
11047
|
+
};
|
|
11048
|
+
}
|
|
11049
|
+
}
|
|
10719
11050
|
if (response.status === 503) {
|
|
10720
|
-
|
|
10721
|
-
try {
|
|
10722
|
-
bodyText = await response.text();
|
|
10723
|
-
} catch {}
|
|
11051
|
+
const bodyText = await readBodyText(response);
|
|
10724
11052
|
const isAuthUnavailable = /auth_unavailable|no auth available|providers=claude/i.test(bodyText);
|
|
10725
11053
|
if (isAuthUnavailable) {
|
|
10726
11054
|
return {
|
|
@@ -10781,35 +11109,50 @@ async function probeManagementAuth(baseUrl, key) {
|
|
|
10781
11109
|
const isBanBody = payload !== null && typeof payload === "object" && Object.values(payload).some((v) => typeof v === "string" && /\b(?:ip[- ]?)?bann?ed\b/i.test(v));
|
|
10782
11110
|
if (isBanBody) {
|
|
10783
11111
|
return {
|
|
10784
|
-
|
|
10785
|
-
|
|
10786
|
-
|
|
11112
|
+
access: "denied",
|
|
11113
|
+
result: {
|
|
11114
|
+
title: "Management access",
|
|
11115
|
+
level: "error",
|
|
11116
|
+
summary: "IP banned \u2014 stop retrying for ~30 min. Management checks skipped."
|
|
11117
|
+
}
|
|
10787
11118
|
};
|
|
10788
11119
|
}
|
|
10789
11120
|
return {
|
|
10790
|
-
|
|
10791
|
-
|
|
10792
|
-
|
|
11121
|
+
access: "denied",
|
|
11122
|
+
result: {
|
|
11123
|
+
title: "Management access",
|
|
11124
|
+
level: "error",
|
|
11125
|
+
summary: "Management API returned HTTP 403. Management checks skipped."
|
|
11126
|
+
}
|
|
10793
11127
|
};
|
|
10794
11128
|
}
|
|
10795
11129
|
if (response.status === 401) {
|
|
10796
11130
|
return {
|
|
10797
|
-
|
|
10798
|
-
|
|
10799
|
-
|
|
11131
|
+
access: "denied",
|
|
11132
|
+
result: {
|
|
11133
|
+
title: "Management access",
|
|
11134
|
+
level: "error",
|
|
11135
|
+
summary: "Management API returned HTTP 401 (invalid key). Management checks skipped."
|
|
11136
|
+
}
|
|
10800
11137
|
};
|
|
10801
11138
|
}
|
|
10802
11139
|
return {
|
|
10803
|
-
|
|
10804
|
-
|
|
10805
|
-
|
|
11140
|
+
access: response.status === 429 ? "rate-limited" : "unavailable",
|
|
11141
|
+
result: {
|
|
11142
|
+
title: "Management access",
|
|
11143
|
+
level: "warning",
|
|
11144
|
+
summary: `Management auth probe returned HTTP ${response.status}. Management checks skipped.`
|
|
11145
|
+
}
|
|
10806
11146
|
};
|
|
10807
11147
|
} catch (error) {
|
|
10808
11148
|
const message = error instanceof Error ? error.message : String(error);
|
|
10809
11149
|
return {
|
|
10810
|
-
|
|
10811
|
-
|
|
10812
|
-
|
|
11150
|
+
access: "unavailable",
|
|
11151
|
+
result: {
|
|
11152
|
+
title: "Management access",
|
|
11153
|
+
level: "error",
|
|
11154
|
+
summary: `Management auth probe failed: ${message}`
|
|
11155
|
+
}
|
|
10813
11156
|
};
|
|
10814
11157
|
}
|
|
10815
11158
|
}
|
|
@@ -10819,7 +11162,7 @@ async function runManagementChecks(baseUrl, key) {
|
|
|
10819
11162
|
}
|
|
10820
11163
|
const authFailure = await probeManagementAuth(baseUrl, key);
|
|
10821
11164
|
if (authFailure !== null) {
|
|
10822
|
-
return { kind: "auth-failure", result: authFailure };
|
|
11165
|
+
return { kind: "auth-failure", result: authFailure.result, access: authFailure.access };
|
|
10823
11166
|
}
|
|
10824
11167
|
const [usage, version] = await Promise.all([checkUsageStats(baseUrl, key), checkVersion(baseUrl, key)]);
|
|
10825
11168
|
return { kind: "checks", usage, version };
|
|
@@ -10833,35 +11176,7 @@ function printCheckResult(result, ctx) {
|
|
|
10833
11176
|
}
|
|
10834
11177
|
}
|
|
10835
11178
|
}
|
|
10836
|
-
function formatCheckSummary(result) {
|
|
10837
|
-
return `${levelLabel(result.level)}: ${result.summary}`;
|
|
10838
|
-
}
|
|
10839
11179
|
var RUNNING_VERSION_NOT_CHECKED_SUMMARY = "Not checked for explicit URL override.";
|
|
10840
|
-
function formatVersionSummary(running, available) {
|
|
10841
|
-
const latestFailure = /429|rate limit/i.test(available.summary) ? "rate-limited" : /auth|\b40[13]\b|ip banned/i.test(available.summary) ? "auth" : "unknown";
|
|
10842
|
-
if (running.summary === RUNNING_VERSION_NOT_CHECKED_SUMMARY) {
|
|
10843
|
-
if (available.level === "ok") {
|
|
10844
|
-
return `not checked (latest ${available.summary})`;
|
|
10845
|
-
}
|
|
10846
|
-
if (available.summary === "Not checked (no management key).") {
|
|
10847
|
-
return "not checked (latest: no key)";
|
|
10848
|
-
}
|
|
10849
|
-
return `not checked (latest ${latestFailure})`;
|
|
10850
|
-
}
|
|
10851
|
-
if (running.level === "ok" && available.level === "ok") {
|
|
10852
|
-
return running.summary === available.summary ? `${running.summary} (latest)` : `${running.summary} (latest ${available.summary})`;
|
|
10853
|
-
}
|
|
10854
|
-
if (running.level === "ok") {
|
|
10855
|
-
return available.summary === "Not checked (no management key)." ? `${running.summary} (latest: no key)` : `${running.summary} (latest ${latestFailure})`;
|
|
10856
|
-
}
|
|
10857
|
-
if (available.level === "ok") {
|
|
10858
|
-
return `unknown (latest ${available.summary})`;
|
|
10859
|
-
}
|
|
10860
|
-
if (latestFailure === "auth") {
|
|
10861
|
-
return "unknown (auth)";
|
|
10862
|
-
}
|
|
10863
|
-
return latestFailure === "rate-limited" ? "unknown (latest rate-limited)" : "unknown";
|
|
10864
|
-
}
|
|
10865
11180
|
function getRunningVersionNotChecked(host) {
|
|
10866
11181
|
try {
|
|
10867
11182
|
validateCliproxyHost(host);
|
|
@@ -10871,51 +11186,64 @@ function getRunningVersionNotChecked(host) {
|
|
|
10871
11186
|
return { title: "Running version", level: "warning", summary: `Unable to validate running-version host: ${message}` };
|
|
10872
11187
|
}
|
|
10873
11188
|
}
|
|
10874
|
-
function
|
|
11189
|
+
function parseUsageCounts(result) {
|
|
10875
11190
|
const recentMatch = /recent:\s*(\d+)/.exec(result.summary);
|
|
10876
11191
|
if (!recentMatch) {
|
|
10877
11192
|
return null;
|
|
10878
11193
|
}
|
|
10879
|
-
const total = Number(recentMatch[1]);
|
|
10880
11194
|
const errorMatch = /errors:\s*(\d+)/.exec(result.summary);
|
|
10881
|
-
|
|
10882
|
-
|
|
11195
|
+
return { recent: Number(recentMatch[1]), errors: errorMatch ? Number(errorMatch[1]) : 0 };
|
|
11196
|
+
}
|
|
11197
|
+
function formatUsageSummaryLine(result) {
|
|
11198
|
+
const counts = parseUsageCounts(result);
|
|
11199
|
+
if (counts === null) {
|
|
11200
|
+
return null;
|
|
11201
|
+
}
|
|
11202
|
+
return `Recent requests: ${counts.recent}${counts.errors > 0 ? `, ${counts.errors} errors` : ""}`;
|
|
10883
11203
|
}
|
|
10884
|
-
async function getCliproxyStatusSummary(baseUrl, key,
|
|
10885
|
-
const normalizedBaseUrl =
|
|
11204
|
+
async function getCliproxyStatusSummary(baseUrl, key, spawn = defaultSpawn, signal) {
|
|
11205
|
+
const normalizedBaseUrl = stripTrailingSlash6(baseUrl);
|
|
10886
11206
|
const host = new URL(normalizedBaseUrl).hostname;
|
|
10887
|
-
const [
|
|
10888
|
-
checkHttpReachability(`${normalizedBaseUrl}/healthz`, verbose),
|
|
11207
|
+
const [mgmt, running] = await Promise.all([
|
|
10889
11208
|
runManagementChecks(normalizedBaseUrl, key || undefined),
|
|
10890
|
-
|
|
11209
|
+
inspectRunningContainer(host, spawn, signal)
|
|
10891
11210
|
]);
|
|
10892
|
-
|
|
10893
|
-
let
|
|
11211
|
+
const spec = declaredObservation("cliproxy", "container-running");
|
|
11212
|
+
let container;
|
|
11213
|
+
if (running.kind === "running") {
|
|
11214
|
+
container = observe(spec, "pass");
|
|
11215
|
+
} else if (running.kind === "none") {
|
|
11216
|
+
container = observe(spec, "fail", { detail: { reason: "not-running" } });
|
|
11217
|
+
} else {
|
|
11218
|
+
container = observe(spec, "unknown", { detail: { reason: "ssh-failed" } });
|
|
11219
|
+
}
|
|
11220
|
+
let managementAccess = "ok";
|
|
11221
|
+
let latestVersion = null;
|
|
11222
|
+
let usage = null;
|
|
10894
11223
|
if (mgmt.kind === "no-key") {
|
|
10895
|
-
|
|
10896
|
-
usageStats = "\u2014 (no key)";
|
|
11224
|
+
managementAccess = "no-key";
|
|
10897
11225
|
} else if (mgmt.kind === "auth-failure") {
|
|
10898
|
-
|
|
10899
|
-
usageStats = formatCheckSummary(mgmt.result);
|
|
11226
|
+
managementAccess = mgmt.access;
|
|
10900
11227
|
} else {
|
|
10901
|
-
|
|
10902
|
-
|
|
11228
|
+
latestVersion = mgmt.version.level === "ok" ? mgmt.version.summary : null;
|
|
11229
|
+
usage = parseUsageCounts(mgmt.usage);
|
|
10903
11230
|
}
|
|
10904
11231
|
return {
|
|
10905
|
-
|
|
10906
|
-
|
|
10907
|
-
|
|
10908
|
-
|
|
10909
|
-
|
|
10910
|
-
|
|
11232
|
+
observations: [container],
|
|
11233
|
+
facts: {
|
|
11234
|
+
runningVersion: running.kind === "running" ? running.version : null,
|
|
11235
|
+
latestVersion,
|
|
11236
|
+
usage,
|
|
11237
|
+
managementAccess
|
|
11238
|
+
}
|
|
10911
11239
|
};
|
|
10912
11240
|
}
|
|
10913
11241
|
async function cliproxyStatusAction(options, ctx, spawn = defaultSpawn) {
|
|
10914
11242
|
let errorCount = 0;
|
|
10915
11243
|
try {
|
|
10916
11244
|
const verbose = options.verbose === true;
|
|
10917
|
-
const baseUrl =
|
|
10918
|
-
const trustedUrl =
|
|
11245
|
+
const baseUrl = stripTrailingSlash6(options.url ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL6);
|
|
11246
|
+
const trustedUrl = stripTrailingSlash6(process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL6);
|
|
10919
11247
|
const urlIsExplicitlyOverridden = options.url !== undefined && baseUrl !== trustedUrl;
|
|
10920
11248
|
const managementKey = options.key ?? (urlIsExplicitlyOverridden ? undefined : process.env.CLIPROXY_MANAGEMENT_KEY);
|
|
10921
11249
|
const providerKey = options.apiKey ?? (urlIsExplicitlyOverridden ? undefined : process.env.CLIPROXY_API_KEY);
|
|
@@ -10993,6 +11321,9 @@ function formatMonitorRunSummary(summary) {
|
|
|
10993
11321
|
const issue = summary.issue.length > 0 ? summary.issue.join("+") : "none";
|
|
10994
11322
|
return `CLIProxy auth monitor summary: probe=${summary.probeState} reason=${summary.probeReason} transition=${summary.transition} issue=${issue} discord=${summary.discord} at ${summary.timestamp}`;
|
|
10995
11323
|
}
|
|
11324
|
+
function formatMonitorProbeLine(probe) {
|
|
11325
|
+
return `CLIProxy auth monitor probe: probe=${probe.probeState} reason=${probe.probeReason} at ${probe.timestamp}`;
|
|
11326
|
+
}
|
|
10996
11327
|
var githubIssueSchema = z16.object({
|
|
10997
11328
|
number: z16.number().int().nonnegative(),
|
|
10998
11329
|
state: z16.enum(["open", "closed"]),
|
|
@@ -11295,6 +11626,7 @@ async function cliproxyMonitorAction(options, ctx, runtime = defaultMonitorRunti
|
|
|
11295
11626
|
const timestamp = new Date().toISOString();
|
|
11296
11627
|
try {
|
|
11297
11628
|
const classification = validation === "live" ? await checkProviderAuthState(proxyUrl, inputs.apiKey) : providerState(validation);
|
|
11629
|
+
ctx.console.log(formatMonitorProbeLine({ probeState: classification.state, probeReason: classification.reason, timestamp }));
|
|
11298
11630
|
const finish = (transition, issue = [], discord = "none") => {
|
|
11299
11631
|
const summary = {
|
|
11300
11632
|
probeState: classification.state,
|
|
@@ -11430,11 +11762,11 @@ var AuthFilesResponseSchema = z18.object({
|
|
|
11430
11762
|
files: z18.array(AuthFileSchema)
|
|
11431
11763
|
});
|
|
11432
11764
|
var ManagementErrorSchema = z18.object({ error: z18.string() });
|
|
11433
|
-
function
|
|
11765
|
+
function stripTrailingSlash7(value) {
|
|
11434
11766
|
return value.endsWith("/") ? value.slice(0, -1) : value;
|
|
11435
11767
|
}
|
|
11436
11768
|
function resolveBaseUrl4(input) {
|
|
11437
|
-
return
|
|
11769
|
+
return stripTrailingSlash7(input ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL8);
|
|
11438
11770
|
}
|
|
11439
11771
|
function resolveManagementKey4(input) {
|
|
11440
11772
|
const key = input ?? process.env.CLIPROXY_MANAGEMENT_KEY;
|
|
@@ -11768,7 +12100,7 @@ function isAllRunning(rows) {
|
|
|
11768
12100
|
function defaultSpawn2(cmd, opts) {
|
|
11769
12101
|
return Bun.spawn(cmd, opts);
|
|
11770
12102
|
}
|
|
11771
|
-
async function getDashboardComposeStatus(host, spawn = defaultSpawn2) {
|
|
12103
|
+
async function getDashboardComposeStatus(host, spawn = defaultSpawn2, signal) {
|
|
11772
12104
|
validateDashboardHost(host);
|
|
11773
12105
|
const knownHostsArgs = buildKnownHostsArgs();
|
|
11774
12106
|
const { args: identityArgs, cleanup } = buildIdentityArgs(process.env.DASHBOARD_SSH_KEY);
|
|
@@ -11794,7 +12126,7 @@ async function getDashboardComposeStatus(host, spawn = defaultSpawn2) {
|
|
|
11794
12126
|
let stderrText;
|
|
11795
12127
|
let exitCode;
|
|
11796
12128
|
try {
|
|
11797
|
-
const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe" });
|
|
12129
|
+
const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe", ...signal === undefined ? {} : { signal } });
|
|
11798
12130
|
[stdoutText, stderrText, exitCode] = await Promise.all([
|
|
11799
12131
|
new Response(child.stdout).text(),
|
|
11800
12132
|
new Response(child.stderr).text(),
|
|
@@ -11824,50 +12156,11 @@ async function getDashboardComposeStatus(host, spawn = defaultSpawn2) {
|
|
|
11824
12156
|
const ok = isAllRunning(services);
|
|
11825
12157
|
return { ok, services };
|
|
11826
12158
|
}
|
|
11827
|
-
async function getDashboardStatusSummary(host, spawn) {
|
|
11828
|
-
|
|
11829
|
-
try {
|
|
11830
|
-
result = await getDashboardComposeStatus(host, spawn);
|
|
11831
|
-
} catch (error) {
|
|
11832
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
11833
|
-
return {
|
|
11834
|
-
app: "dashboard",
|
|
11835
|
-
http: `ERROR: ${errorMsg}`,
|
|
11836
|
-
lastDeploy: "\u2014",
|
|
11837
|
-
version: "\u2014",
|
|
11838
|
-
contentHash: "\u2014",
|
|
11839
|
-
usageStats: "\u2014"
|
|
11840
|
-
};
|
|
11841
|
-
}
|
|
11842
|
-
if (result.services.length === 0) {
|
|
11843
|
-
const errorMsg = result.error ?? "No services reported";
|
|
11844
|
-
return {
|
|
11845
|
-
app: "dashboard",
|
|
11846
|
-
http: `ERROR: ${errorMsg}`,
|
|
11847
|
-
lastDeploy: "\u2014",
|
|
11848
|
-
version: "\u2014",
|
|
11849
|
-
contentHash: "\u2014",
|
|
11850
|
-
usageStats: "\u2014"
|
|
11851
|
-
};
|
|
11852
|
-
}
|
|
11853
|
-
const rows = result.services.map((s) => `${s.service}:${s.state}/${s.health}`).join(", ");
|
|
11854
|
-
if (!result.ok) {
|
|
11855
|
-
return {
|
|
11856
|
-
app: "dashboard",
|
|
11857
|
-
http: `DEGRADED: ${rows}`,
|
|
11858
|
-
lastDeploy: "\u2014",
|
|
11859
|
-
version: "\u2014",
|
|
11860
|
-
contentHash: "\u2014",
|
|
11861
|
-
usageStats: "\u2014"
|
|
11862
|
-
};
|
|
11863
|
-
}
|
|
12159
|
+
async function getDashboardStatusSummary(host, spawn, signal) {
|
|
12160
|
+
const result = await getDashboardComposeStatus(host, spawn, signal);
|
|
11864
12161
|
return {
|
|
11865
|
-
|
|
11866
|
-
|
|
11867
|
-
lastDeploy: "\u2014",
|
|
11868
|
-
version: "\u2014",
|
|
11869
|
-
contentHash: "\u2014",
|
|
11870
|
-
usageStats: "\u2014"
|
|
12162
|
+
observations: [composeObservation(declaredObservation("dashboard", "compose-services"), result)],
|
|
12163
|
+
facts: { services: servicesFact(result.services) }
|
|
11871
12164
|
};
|
|
11872
12165
|
}
|
|
11873
12166
|
async function dashboardStatusAction(options, ctx, spawn) {
|
|
@@ -12610,12 +12903,6 @@ function formatWorkspaceMemory(status) {
|
|
|
12610
12903
|
` OpenCode: ${formatOpencode(status)}`
|
|
12611
12904
|
];
|
|
12612
12905
|
}
|
|
12613
|
-
function formatWorkspaceSummary(status) {
|
|
12614
|
-
if (status.status === "unknown")
|
|
12615
|
-
return "workspace=unknown";
|
|
12616
|
-
const mem = status.limitBytes === 0 ? `mem=${formatMiB(status.currentBytes)}MiB limit=none` : `mem=${formatMiB(status.currentBytes)}/${formatMiB(status.limitBytes)}MiB`;
|
|
12617
|
-
return `workspace ${mem} oom_kills=${status.oomKills} opencode=${status.opencode}`;
|
|
12618
|
-
}
|
|
12619
12906
|
|
|
12620
12907
|
// src/commands/gateway/status.ts
|
|
12621
12908
|
var WORKSPACE_PROBE_TIMEOUT_MS = 1e4;
|
|
@@ -12649,7 +12936,7 @@ function isAllRunning2(rows) {
|
|
|
12649
12936
|
function defaultSpawn5(cmd, opts) {
|
|
12650
12937
|
return Bun.spawn(cmd, opts);
|
|
12651
12938
|
}
|
|
12652
|
-
async function getGatewayComposeStatus(host, spawn = defaultSpawn5) {
|
|
12939
|
+
async function getGatewayComposeStatus(host, spawn = defaultSpawn5, signal) {
|
|
12653
12940
|
validateGatewayHost(host);
|
|
12654
12941
|
const knownHostsArgs = buildKnownHostsArgs();
|
|
12655
12942
|
const { args: identityArgs, cleanup } = buildIdentityArgs(process.env.GATEWAY_SSH_KEY);
|
|
@@ -12673,7 +12960,7 @@ async function getGatewayComposeStatus(host, spawn = defaultSpawn5) {
|
|
|
12673
12960
|
let stderrText;
|
|
12674
12961
|
let exitCode;
|
|
12675
12962
|
try {
|
|
12676
|
-
const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe" });
|
|
12963
|
+
const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe", ...signal === undefined ? {} : { signal } });
|
|
12677
12964
|
[stdoutText, stderrText, exitCode] = await Promise.all([
|
|
12678
12965
|
new Response(child.stdout).text(),
|
|
12679
12966
|
new Response(child.stderr).text(),
|
|
@@ -12704,7 +12991,7 @@ async function getGatewayComposeStatus(host, spawn = defaultSpawn5) {
|
|
|
12704
12991
|
const ok = isAllRunning2(services);
|
|
12705
12992
|
return { ok, services };
|
|
12706
12993
|
}
|
|
12707
|
-
async function getGatewayWorkspaceStatus(host, spawn = defaultSpawn5, timeoutMs = WORKSPACE_PROBE_TIMEOUT_MS) {
|
|
12994
|
+
async function getGatewayWorkspaceStatus(host, spawn = defaultSpawn5, timeoutMs = WORKSPACE_PROBE_TIMEOUT_MS, signal) {
|
|
12708
12995
|
validateGatewayHost(host);
|
|
12709
12996
|
const knownHostsArgs = buildKnownHostsArgs();
|
|
12710
12997
|
const { args: identityArgs, cleanup } = buildIdentityArgs(process.env.GATEWAY_SSH_KEY);
|
|
@@ -12729,7 +13016,7 @@ async function getGatewayWorkspaceStatus(host, spawn = defaultSpawn5, timeoutMs
|
|
|
12729
13016
|
timer = setTimeout(() => resolve("timeout"), timeoutMs);
|
|
12730
13017
|
});
|
|
12731
13018
|
try {
|
|
12732
|
-
const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe" });
|
|
13019
|
+
const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe", ...signal === undefined ? {} : { signal } });
|
|
12733
13020
|
const collected = Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
|
|
12734
13021
|
collected.catch(() => {
|
|
12735
13022
|
return;
|
|
@@ -12750,30 +13037,43 @@ async function getGatewayWorkspaceStatus(host, spawn = defaultSpawn5, timeoutMs
|
|
|
12750
13037
|
cleanup();
|
|
12751
13038
|
}
|
|
12752
13039
|
}
|
|
12753
|
-
|
|
12754
|
-
const
|
|
13040
|
+
function workspaceObservation(workspace) {
|
|
13041
|
+
const spec = declaredObservation("gateway", "workspace-memory");
|
|
13042
|
+
if (workspace.status === "unknown")
|
|
13043
|
+
return observe(spec, "unknown", { detail: { reason: "probe-failed" } });
|
|
13044
|
+
if (workspace.oomKilled || workspace.oomKills > 0)
|
|
13045
|
+
return observe(spec, "fail", { detail: { reason: "oom-kill" } });
|
|
13046
|
+
switch (workspace.opencode) {
|
|
13047
|
+
case "ready":
|
|
13048
|
+
return observe(spec, "pass");
|
|
13049
|
+
case "down":
|
|
13050
|
+
case "degraded":
|
|
13051
|
+
return observe(spec, "fail", { detail: { reason: `supervisor-${workspace.opencode}` } });
|
|
13052
|
+
case "skipped":
|
|
13053
|
+
return workspace.limitBytes > 0 ? observe(spec, "fail", { detail: { reason: "near-limit" } }) : observe(spec, "pass");
|
|
13054
|
+
case "starting":
|
|
13055
|
+
case "unknown":
|
|
13056
|
+
return observe(spec, "unknown", { detail: { reason: `supervisor-${workspace.opencode}` } });
|
|
13057
|
+
}
|
|
13058
|
+
}
|
|
13059
|
+
async function getGatewayStatusSummary(host, spawn, signal) {
|
|
13060
|
+
const result = await getGatewayComposeStatus(host, spawn, signal);
|
|
13061
|
+
const compose = composeObservation(declaredObservation("gateway", "compose-services"), result);
|
|
12755
13062
|
if (result.services.length === 0) {
|
|
12756
|
-
const errorMsg = result.error ?? "No services reported";
|
|
12757
13063
|
return {
|
|
12758
|
-
|
|
12759
|
-
|
|
12760
|
-
|
|
12761
|
-
|
|
12762
|
-
|
|
12763
|
-
|
|
13064
|
+
observations: [
|
|
13065
|
+
compose,
|
|
13066
|
+
observe(declaredObservation("gateway", "workspace-memory"), "unknown", {
|
|
13067
|
+
detail: { reason: "compose-unavailable" }
|
|
13068
|
+
})
|
|
13069
|
+
],
|
|
13070
|
+
facts: { services: [] }
|
|
12764
13071
|
};
|
|
12765
13072
|
}
|
|
12766
|
-
const
|
|
12767
|
-
const workspace = await getGatewayWorkspaceStatus(host, spawn);
|
|
12768
|
-
const prefix = result.ok ? `OK: ${rows}` : `DEGRADED: ${rows}`;
|
|
13073
|
+
const workspace = await getGatewayWorkspaceStatus(host, spawn, undefined, signal);
|
|
12769
13074
|
return {
|
|
12770
|
-
|
|
12771
|
-
|
|
12772
|
-
lastDeploy: "\u2014",
|
|
12773
|
-
version: "\u2014",
|
|
12774
|
-
contentHash: "\u2014",
|
|
12775
|
-
usageStats: "\u2014",
|
|
12776
|
-
workspace
|
|
13075
|
+
observations: [compose, workspaceObservation(workspace)],
|
|
13076
|
+
facts: { services: servicesFact(result.services), workspace: { ...workspace } }
|
|
12777
13077
|
};
|
|
12778
13078
|
}
|
|
12779
13079
|
async function gatewayStatusAction(options, ctx, spawn, workspaceTimeoutMs) {
|
|
@@ -12974,7 +13274,7 @@ import { join as join7 } from "path";
|
|
|
12974
13274
|
import { z as z28 } from "zod";
|
|
12975
13275
|
var SITE_URL = "https://kw.igg.ms/";
|
|
12976
13276
|
var GH_REPO = "marcusrbrown/infra";
|
|
12977
|
-
var
|
|
13277
|
+
var HTTP_TIMEOUT_MS3 = 1e4;
|
|
12978
13278
|
var ghRunSchema = z28.array(z28.object({
|
|
12979
13279
|
createdAt: z28.string().min(1),
|
|
12980
13280
|
url: z28.url()
|
|
@@ -13016,7 +13316,7 @@ async function checkHttpReachability2(verbose) {
|
|
|
13016
13316
|
const startedAt = performance.now();
|
|
13017
13317
|
try {
|
|
13018
13318
|
const response = await fetch(SITE_URL, {
|
|
13019
|
-
signal: AbortSignal.timeout(
|
|
13319
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS3)
|
|
13020
13320
|
});
|
|
13021
13321
|
const elapsedMs = performance.now() - startedAt;
|
|
13022
13322
|
const details = [];
|
|
@@ -13043,11 +13343,11 @@ async function checkHttpReachability2(verbose) {
|
|
|
13043
13343
|
title: "HTTP reachability",
|
|
13044
13344
|
level: "error",
|
|
13045
13345
|
summary: `Request failed after ${formatDurationMs2(elapsedMs)}: ${message}`,
|
|
13046
|
-
details: verbose ? [`URL: ${SITE_URL}`, `Timeout: ${
|
|
13346
|
+
details: verbose ? [`URL: ${SITE_URL}`, `Timeout: ${HTTP_TIMEOUT_MS3}ms`] : undefined
|
|
13047
13347
|
};
|
|
13048
13348
|
}
|
|
13049
13349
|
}
|
|
13050
|
-
async function checkLastDeploy(verbose) {
|
|
13350
|
+
async function checkLastDeploy(verbose, signal) {
|
|
13051
13351
|
try {
|
|
13052
13352
|
const proc = Bun.spawn([
|
|
13053
13353
|
"gh",
|
|
@@ -13062,7 +13362,8 @@ async function checkLastDeploy(verbose) {
|
|
|
13062
13362
|
GH_REPO
|
|
13063
13363
|
], {
|
|
13064
13364
|
stderr: "pipe",
|
|
13065
|
-
stdout: "pipe"
|
|
13365
|
+
stdout: "pipe",
|
|
13366
|
+
...signal === undefined ? {} : { signal }
|
|
13066
13367
|
});
|
|
13067
13368
|
const [exitCode, stdout, stderr] = await Promise.all([
|
|
13068
13369
|
proc.exited,
|
|
@@ -13104,7 +13405,8 @@ async function checkLastDeploy(verbose) {
|
|
|
13104
13405
|
title: "Last successful deploy",
|
|
13105
13406
|
level: "ok",
|
|
13106
13407
|
summary: `${formatDate(latestRun.createdAt)}`,
|
|
13107
|
-
details
|
|
13408
|
+
details,
|
|
13409
|
+
value: latestRun.createdAt
|
|
13108
13410
|
};
|
|
13109
13411
|
} catch (error) {
|
|
13110
13412
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -13158,7 +13460,7 @@ async function checkContentHash(verbose, repoRoot) {
|
|
|
13158
13460
|
}
|
|
13159
13461
|
try {
|
|
13160
13462
|
const response = await fetch(SITE_URL, {
|
|
13161
|
-
signal: AbortSignal.timeout(
|
|
13463
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS3)
|
|
13162
13464
|
});
|
|
13163
13465
|
if (!response.ok) {
|
|
13164
13466
|
return {
|
|
@@ -13177,14 +13479,16 @@ async function checkContentHash(verbose, repoRoot) {
|
|
|
13177
13479
|
title: "Content hash",
|
|
13178
13480
|
level: "ok",
|
|
13179
13481
|
summary: "Remote index.html matches local dist/index.html",
|
|
13180
|
-
details
|
|
13482
|
+
details,
|
|
13483
|
+
value: "match"
|
|
13181
13484
|
};
|
|
13182
13485
|
}
|
|
13183
13486
|
return {
|
|
13184
13487
|
title: "Content hash",
|
|
13185
13488
|
level: "warning",
|
|
13186
13489
|
summary: "Remote index.html differs from local dist/index.html",
|
|
13187
|
-
details
|
|
13490
|
+
details,
|
|
13491
|
+
value: "differs"
|
|
13188
13492
|
};
|
|
13189
13493
|
} catch (error) {
|
|
13190
13494
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -13205,22 +13509,17 @@ function printCheckResult2(result, ctx) {
|
|
|
13205
13509
|
}
|
|
13206
13510
|
}
|
|
13207
13511
|
}
|
|
13208
|
-
function
|
|
13209
|
-
|
|
13210
|
-
|
|
13211
|
-
|
|
13212
|
-
const [httpResult, lastDeployResult, contentHashResult] = await Promise.all([
|
|
13213
|
-
checkHttpReachability2(verbose),
|
|
13214
|
-
checkLastDeploy(verbose),
|
|
13215
|
-
checkContentHash(verbose)
|
|
13512
|
+
async function getKeewebStatusSummary(signal) {
|
|
13513
|
+
const [lastDeployResult, contentHashResult] = await Promise.all([
|
|
13514
|
+
checkLastDeploy(false, signal),
|
|
13515
|
+
checkContentHash(false)
|
|
13216
13516
|
]);
|
|
13217
13517
|
return {
|
|
13218
|
-
|
|
13219
|
-
|
|
13220
|
-
|
|
13221
|
-
|
|
13222
|
-
|
|
13223
|
-
usageStats: "\u2014"
|
|
13518
|
+
observations: [],
|
|
13519
|
+
facts: {
|
|
13520
|
+
lastDeploy: lastDeployResult.value ?? null,
|
|
13521
|
+
contentHash: contentHashResult.value ?? "unavailable"
|
|
13522
|
+
}
|
|
13224
13523
|
};
|
|
13225
13524
|
}
|
|
13226
13525
|
async function keewebStatusAction(options, ctx) {
|
|
@@ -13275,10 +13574,811 @@ function registerMcp(cli) {
|
|
|
13275
13574
|
}
|
|
13276
13575
|
|
|
13277
13576
|
// src/commands/status.ts
|
|
13278
|
-
import { z as
|
|
13577
|
+
import { z as z43 } from "zod";
|
|
13279
13578
|
|
|
13280
|
-
// src/
|
|
13579
|
+
// src/status/deploy-state.ts
|
|
13580
|
+
import { z as z30 } from "zod";
|
|
13581
|
+
|
|
13582
|
+
// src/status/github-api.ts
|
|
13583
|
+
var GITHUB_API_ORIGIN = "https://api.github.com";
|
|
13584
|
+
var GITHUB_REPOSITORY = "marcusrbrown/infra";
|
|
13585
|
+
var GITHUB_REQUEST_TIMEOUT_MS = 1e4;
|
|
13586
|
+
var MAX_LOG_BYTES = 4 * 1024 * 1024;
|
|
13587
|
+
function hasToken(token) {
|
|
13588
|
+
return token !== undefined && token.trim() !== "";
|
|
13589
|
+
}
|
|
13590
|
+
function resolveGithubToken(env) {
|
|
13591
|
+
return [env.GITHUB_TOKEN, env.GH_TOKEN].find(hasToken);
|
|
13592
|
+
}
|
|
13593
|
+
function apiHeaders(token) {
|
|
13594
|
+
return {
|
|
13595
|
+
accept: "application/vnd.github+json",
|
|
13596
|
+
authorization: `Bearer ${token}`,
|
|
13597
|
+
"x-github-api-version": "2022-11-28",
|
|
13598
|
+
"user-agent": "marcusrbrown-infra-status"
|
|
13599
|
+
};
|
|
13600
|
+
}
|
|
13601
|
+
async function githubGetJson(fetchFn, token, path, schema) {
|
|
13602
|
+
try {
|
|
13603
|
+
const response = await fetchFn(`${GITHUB_API_ORIGIN}${path}`, {
|
|
13604
|
+
headers: apiHeaders(token),
|
|
13605
|
+
signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS)
|
|
13606
|
+
});
|
|
13607
|
+
if (!response.ok) {
|
|
13608
|
+
await response.body?.cancel().catch(() => {
|
|
13609
|
+
return;
|
|
13610
|
+
});
|
|
13611
|
+
return { ok: false };
|
|
13612
|
+
}
|
|
13613
|
+
const parsed = schema.safeParse(await response.json());
|
|
13614
|
+
return parsed.success ? { ok: true, data: parsed.data } : { ok: false };
|
|
13615
|
+
} catch {
|
|
13616
|
+
return { ok: false };
|
|
13617
|
+
}
|
|
13618
|
+
}
|
|
13619
|
+
async function readBoundedText(response) {
|
|
13620
|
+
const declared = Number(response.headers.get("content-length"));
|
|
13621
|
+
if (Number.isFinite(declared) && declared > MAX_LOG_BYTES) {
|
|
13622
|
+
await response.body?.cancel().catch(() => {
|
|
13623
|
+
return;
|
|
13624
|
+
});
|
|
13625
|
+
return;
|
|
13626
|
+
}
|
|
13627
|
+
const text = await response.text();
|
|
13628
|
+
return text.length > MAX_LOG_BYTES ? undefined : text;
|
|
13629
|
+
}
|
|
13630
|
+
async function githubGetLogText(fetchFn, token, path) {
|
|
13631
|
+
try {
|
|
13632
|
+
const first = await fetchFn(`${GITHUB_API_ORIGIN}${path}`, {
|
|
13633
|
+
headers: apiHeaders(token),
|
|
13634
|
+
redirect: "manual",
|
|
13635
|
+
signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS)
|
|
13636
|
+
});
|
|
13637
|
+
if (first.status === 200) {
|
|
13638
|
+
const text = await readBoundedText(first);
|
|
13639
|
+
return text === undefined ? { ok: false } : { ok: true, data: text };
|
|
13640
|
+
}
|
|
13641
|
+
await first.body?.cancel().catch(() => {
|
|
13642
|
+
return;
|
|
13643
|
+
});
|
|
13644
|
+
const location = first.headers.get("location");
|
|
13645
|
+
if (first.status !== 302 || location === null || !location.startsWith("https://")) {
|
|
13646
|
+
return { ok: false };
|
|
13647
|
+
}
|
|
13648
|
+
const second = await fetchFn(location, { signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS) });
|
|
13649
|
+
if (!second.ok) {
|
|
13650
|
+
await second.body?.cancel().catch(() => {
|
|
13651
|
+
return;
|
|
13652
|
+
});
|
|
13653
|
+
return { ok: false };
|
|
13654
|
+
}
|
|
13655
|
+
const text = await readBoundedText(second);
|
|
13656
|
+
return text === undefined ? { ok: false } : { ok: true, data: text };
|
|
13657
|
+
} catch {
|
|
13658
|
+
return { ok: false };
|
|
13659
|
+
}
|
|
13660
|
+
}
|
|
13661
|
+
|
|
13662
|
+
// src/status/observation.ts
|
|
13281
13663
|
import { z as z29 } from "zod";
|
|
13664
|
+
var STATUS_SCHEMA_VERSION = 1;
|
|
13665
|
+
var OBSERVATION_KINDS = ["reachability", "container-health", "deploy-state", "end-to-end", "resource"];
|
|
13666
|
+
var OBSERVATION_SOURCES = ["http", "ssh", "github-api", "monitor", "config"];
|
|
13667
|
+
var APP_IDS = ["keeweb", "cliproxy", "gateway", "umami", "dashboard", "vpn", "broker"];
|
|
13668
|
+
var isoTimestampSchema = z29.iso.datetime();
|
|
13669
|
+
var observationDetailSchema = z29.strictObject({
|
|
13670
|
+
statusCode: z29.number().int().min(100).max(599).optional(),
|
|
13671
|
+
durationMs: z29.number().int().min(0).max(600000).optional(),
|
|
13672
|
+
contractVersion: z29.string().min(1).max(64).optional(),
|
|
13673
|
+
since: isoTimestampSchema.optional(),
|
|
13674
|
+
probeAt: isoTimestampSchema.optional(),
|
|
13675
|
+
reason: z29.string().max(32).regex(/^[a-z][a-z0-9_-]*$/).optional()
|
|
13676
|
+
});
|
|
13677
|
+
var observationBaseShape = {
|
|
13678
|
+
id: z29.string().max(64).regex(/^[a-z][a-z0-9-]*$/),
|
|
13679
|
+
kind: z29.enum(OBSERVATION_KINDS),
|
|
13680
|
+
source: z29.enum(OBSERVATION_SOURCES),
|
|
13681
|
+
detail: observationDetailSchema.optional()
|
|
13682
|
+
};
|
|
13683
|
+
var observationSchema = z29.discriminatedUnion("result", [
|
|
13684
|
+
z29.strictObject({
|
|
13685
|
+
...observationBaseShape,
|
|
13686
|
+
result: z29.enum(["pass", "fail", "unknown"]),
|
|
13687
|
+
observedAt: isoTimestampSchema
|
|
13688
|
+
}),
|
|
13689
|
+
z29.strictObject({
|
|
13690
|
+
...observationBaseShape,
|
|
13691
|
+
result: z29.literal("not_measured"),
|
|
13692
|
+
observedAt: z29.null()
|
|
13693
|
+
})
|
|
13694
|
+
]);
|
|
13695
|
+
var jsonValueSchema = z29.lazy(() => z29.union([
|
|
13696
|
+
z29.string(),
|
|
13697
|
+
z29.number(),
|
|
13698
|
+
z29.boolean(),
|
|
13699
|
+
z29.null(),
|
|
13700
|
+
z29.array(jsonValueSchema),
|
|
13701
|
+
z29.record(z29.string(), jsonValueSchema)
|
|
13702
|
+
]));
|
|
13703
|
+
var appStatusSchema = z29.strictObject({
|
|
13704
|
+
app: z29.enum(APP_IDS),
|
|
13705
|
+
observations: z29.array(observationSchema),
|
|
13706
|
+
facts: z29.record(z29.string().max(64), jsonValueSchema)
|
|
13707
|
+
}).check((ctx) => {
|
|
13708
|
+
const seen = new Set;
|
|
13709
|
+
for (const [index, observation] of ctx.value.observations.entries()) {
|
|
13710
|
+
if (seen.has(observation.id)) {
|
|
13711
|
+
ctx.issues.push({
|
|
13712
|
+
code: "custom",
|
|
13713
|
+
message: `Duplicate observation id "${observation.id}" for app "${ctx.value.app}"`,
|
|
13714
|
+
input: ctx.value,
|
|
13715
|
+
path: ["observations", index, "id"]
|
|
13716
|
+
});
|
|
13717
|
+
}
|
|
13718
|
+
seen.add(observation.id);
|
|
13719
|
+
}
|
|
13720
|
+
});
|
|
13721
|
+
var statusEnvelopeSchema = z29.strictObject({
|
|
13722
|
+
schemaVersion: z29.literal(STATUS_SCHEMA_VERSION),
|
|
13723
|
+
generatedAt: isoTimestampSchema,
|
|
13724
|
+
apps: z29.array(appStatusSchema)
|
|
13725
|
+
}).check((ctx) => {
|
|
13726
|
+
const seen = new Set;
|
|
13727
|
+
for (const [index, entry] of ctx.value.apps.entries()) {
|
|
13728
|
+
if (seen.has(entry.app)) {
|
|
13729
|
+
ctx.issues.push({
|
|
13730
|
+
code: "custom",
|
|
13731
|
+
message: `Duplicate app entry "${entry.app}"`,
|
|
13732
|
+
input: ctx.value,
|
|
13733
|
+
path: ["apps", index, "app"]
|
|
13734
|
+
});
|
|
13735
|
+
}
|
|
13736
|
+
seen.add(entry.app);
|
|
13737
|
+
}
|
|
13738
|
+
});
|
|
13739
|
+
|
|
13740
|
+
// src/status/deploy-state.ts
|
|
13741
|
+
var DEPLOY_STATE_ID = "deploy-state";
|
|
13742
|
+
var DEPLOY_STATE_REF = "main";
|
|
13743
|
+
var DEPLOY_STATE_CALLS_PER_APP = 2;
|
|
13744
|
+
var DEPLOY_STATE_CALL_BUDGET = APP_IDS.length * DEPLOY_STATE_CALLS_PER_APP;
|
|
13745
|
+
var SPEC = { id: DEPLOY_STATE_ID, kind: "deploy-state", source: "github-api" };
|
|
13746
|
+
var deploymentListSchema = z30.array(z30.object({ id: z30.number().int().positive(), ref: z30.string() }));
|
|
13747
|
+
var statusListSchema = z30.array(z30.object({ state: z30.string().min(1).max(32), created_at: z30.iso.datetime() }));
|
|
13748
|
+
function mapDeploymentState(state) {
|
|
13749
|
+
switch (state) {
|
|
13750
|
+
case "success":
|
|
13751
|
+
return { result: "pass" };
|
|
13752
|
+
case "failure":
|
|
13753
|
+
case "error":
|
|
13754
|
+
return { result: "fail" };
|
|
13755
|
+
case "waiting":
|
|
13756
|
+
return { result: "unknown", reason: "at_gate" };
|
|
13757
|
+
case "queued":
|
|
13758
|
+
case "in_progress":
|
|
13759
|
+
case "pending":
|
|
13760
|
+
return { result: "unknown", reason: "running" };
|
|
13761
|
+
case "inactive":
|
|
13762
|
+
return { result: "unknown", reason: "inactive" };
|
|
13763
|
+
default:
|
|
13764
|
+
return { result: "unknown", reason: "unknown-state" };
|
|
13765
|
+
}
|
|
13766
|
+
}
|
|
13767
|
+
function apiError(now) {
|
|
13768
|
+
return observe(SPEC, "unknown", { detail: { reason: "api-error" }, now });
|
|
13769
|
+
}
|
|
13770
|
+
async function readAppDeployState(app, fetchFn, token, now) {
|
|
13771
|
+
const deployments = await githubGetJson(fetchFn, token, `/repos/${GITHUB_REPOSITORY}/deployments?environment=${encodeURIComponent(app)}&ref=${DEPLOY_STATE_REF}&per_page=1`, deploymentListSchema);
|
|
13772
|
+
if (!deployments.ok)
|
|
13773
|
+
return apiError(now);
|
|
13774
|
+
const newest = deployments.data[0];
|
|
13775
|
+
if (newest === undefined)
|
|
13776
|
+
return notMeasured(SPEC, "none");
|
|
13777
|
+
if (newest.ref !== DEPLOY_STATE_REF)
|
|
13778
|
+
return apiError(now);
|
|
13779
|
+
const statuses = await githubGetJson(fetchFn, token, `/repos/${GITHUB_REPOSITORY}/deployments/${newest.id}/statuses?per_page=1`, statusListSchema);
|
|
13780
|
+
if (!statuses.ok)
|
|
13781
|
+
return apiError(now);
|
|
13782
|
+
const status = statuses.data[0];
|
|
13783
|
+
if (status === undefined)
|
|
13784
|
+
return observe(SPEC, "unknown", { detail: { reason: "no-status" }, now });
|
|
13785
|
+
const mapping = mapDeploymentState(status.state);
|
|
13786
|
+
const detail = {
|
|
13787
|
+
..."reason" in mapping ? { reason: mapping.reason } : {},
|
|
13788
|
+
since: status.created_at
|
|
13789
|
+
};
|
|
13790
|
+
return observe(SPEC, mapping.result, { detail, now });
|
|
13791
|
+
}
|
|
13792
|
+
async function readDeployStates(options) {
|
|
13793
|
+
const now = options.now ?? new Date;
|
|
13794
|
+
const { token } = options;
|
|
13795
|
+
if (!hasToken(token)) {
|
|
13796
|
+
return Object.fromEntries(APP_IDS.map((app) => [app, notMeasured(SPEC, "missing-config")]));
|
|
13797
|
+
}
|
|
13798
|
+
const fetchFn = options.fetch ?? fetch;
|
|
13799
|
+
const entries = await Promise.all(APP_IDS.map(async (app) => [app, await readAppDeployState(app, fetchFn, token, now)]));
|
|
13800
|
+
return Object.fromEntries(entries);
|
|
13801
|
+
}
|
|
13802
|
+
|
|
13803
|
+
// src/status/monitor-state.ts
|
|
13804
|
+
import { z as z31 } from "zod";
|
|
13805
|
+
var MONITOR_OBSERVATION_ID = "provider-route";
|
|
13806
|
+
var MONITOR_WORKFLOW_FILE = "cliproxy-auth-monitor.yaml";
|
|
13807
|
+
var PROBE_LINE_PREFIX = "CLIProxy auth monitor probe:";
|
|
13808
|
+
var MONITOR_LOG_RETRY_BUDGET = 1;
|
|
13809
|
+
var MONITOR_STALE_AFTER_MS = 45 * 60000;
|
|
13810
|
+
var MAX_FUTURE_SKEW_MS = 5 * 60000;
|
|
13811
|
+
var SPEC2 = { id: MONITOR_OBSERVATION_ID, kind: "end-to-end", source: "monitor" };
|
|
13812
|
+
var PROBE_REASON_STATE = {
|
|
13813
|
+
ok: "healthy",
|
|
13814
|
+
"auth-401": "dead",
|
|
13815
|
+
"auth-unavailable-503": "dead",
|
|
13816
|
+
"provider-missing": "dead",
|
|
13817
|
+
"unrelated-http": "unknown",
|
|
13818
|
+
timeout: "unknown",
|
|
13819
|
+
network: "unknown"
|
|
13820
|
+
};
|
|
13821
|
+
var runsSchema = z31.object({
|
|
13822
|
+
workflow_runs: z31.array(z31.object({
|
|
13823
|
+
id: z31.number().int().positive(),
|
|
13824
|
+
event: z31.string(),
|
|
13825
|
+
head_branch: z31.string().nullable(),
|
|
13826
|
+
status: z31.string()
|
|
13827
|
+
}))
|
|
13828
|
+
});
|
|
13829
|
+
var jobsSchema = z31.object({ jobs: z31.array(z31.object({ id: z31.number().int().positive() })) });
|
|
13830
|
+
var isoSchema = z31.iso.datetime();
|
|
13831
|
+
var RUNNER_PREFIX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z /;
|
|
13832
|
+
var PROBE_LINE = /^CLIProxy auth monitor probe: probe=([a-z]+) reason=([a-z0-9-]+) at (\S+)$/;
|
|
13833
|
+
function isReason(value) {
|
|
13834
|
+
return Object.keys(PROBE_REASON_STATE).includes(value);
|
|
13835
|
+
}
|
|
13836
|
+
function parseProbeLine(log) {
|
|
13837
|
+
const lines = log.replace(/^\uFEFF/, "").split(`
|
|
13838
|
+
`).map((line) => line.replace(/\r$/, "").replace(RUNNER_PREFIX, "")).filter((line) => line.startsWith(PROBE_LINE_PREFIX));
|
|
13839
|
+
if (lines.length === 0)
|
|
13840
|
+
return { kind: "none" };
|
|
13841
|
+
if (lines.length > 1)
|
|
13842
|
+
return { kind: "multiple" };
|
|
13843
|
+
const match = PROBE_LINE.exec(lines[0] ?? "");
|
|
13844
|
+
if (match === null)
|
|
13845
|
+
return { kind: "malformed" };
|
|
13846
|
+
const [, state, reason, at] = match;
|
|
13847
|
+
if (state === undefined || reason === undefined || at === undefined)
|
|
13848
|
+
return { kind: "malformed" };
|
|
13849
|
+
if (!isReason(reason) || PROBE_REASON_STATE[reason] !== state || !isoSchema.safeParse(at).success) {
|
|
13850
|
+
return { kind: "malformed" };
|
|
13851
|
+
}
|
|
13852
|
+
return { kind: "probe", state: PROBE_REASON_STATE[reason], reason, at };
|
|
13853
|
+
}
|
|
13854
|
+
function unknown2(reason, now) {
|
|
13855
|
+
return observe(SPEC2, "unknown", { detail: { reason }, now });
|
|
13856
|
+
}
|
|
13857
|
+
async function readLog(fetchFn, token, jobId) {
|
|
13858
|
+
for (let attempt = 0;attempt <= MONITOR_LOG_RETRY_BUDGET; attempt += 1) {
|
|
13859
|
+
const log = await githubGetLogText(fetchFn, token, `/repos/${GITHUB_REPOSITORY}/actions/jobs/${jobId}/logs`);
|
|
13860
|
+
if (!log.ok)
|
|
13861
|
+
return { kind: "error" };
|
|
13862
|
+
if (log.data.trim() !== "")
|
|
13863
|
+
return { kind: "log", text: log.data };
|
|
13864
|
+
}
|
|
13865
|
+
return { kind: "empty" };
|
|
13866
|
+
}
|
|
13867
|
+
async function readMonitorState(options) {
|
|
13868
|
+
const now = options.now ?? new Date;
|
|
13869
|
+
const { token } = options;
|
|
13870
|
+
if (!hasToken(token))
|
|
13871
|
+
return notMeasured(SPEC2, "missing-config");
|
|
13872
|
+
const fetchFn = options.fetch ?? fetch;
|
|
13873
|
+
const runs = await githubGetJson(fetchFn, token, `/repos/${GITHUB_REPOSITORY}/actions/workflows/${MONITOR_WORKFLOW_FILE}/runs?event=schedule&branch=main&per_page=1`, runsSchema);
|
|
13874
|
+
if (!runs.ok)
|
|
13875
|
+
return unknown2("api-error", now);
|
|
13876
|
+
const run = runs.data.workflow_runs[0];
|
|
13877
|
+
if (run === undefined || run.event !== "schedule" || run.head_branch !== "main")
|
|
13878
|
+
return unknown2("no-run", now);
|
|
13879
|
+
if (run.status !== "completed")
|
|
13880
|
+
return unknown2("running", now);
|
|
13881
|
+
const jobs = await githubGetJson(fetchFn, token, `/repos/${GITHUB_REPOSITORY}/actions/runs/${run.id}/jobs?per_page=2`, jobsSchema);
|
|
13882
|
+
if (!jobs.ok)
|
|
13883
|
+
return unknown2("api-error", now);
|
|
13884
|
+
const [job, ...extra] = jobs.data.jobs;
|
|
13885
|
+
if (job === undefined || extra.length > 0)
|
|
13886
|
+
return unknown2("job-missing", now);
|
|
13887
|
+
const log = await readLog(fetchFn, token, job.id);
|
|
13888
|
+
if (log.kind === "error")
|
|
13889
|
+
return unknown2("api-error", now);
|
|
13890
|
+
if (log.kind === "empty")
|
|
13891
|
+
return unknown2("log-empty", now);
|
|
13892
|
+
const parsed = parseProbeLine(log.text);
|
|
13893
|
+
switch (parsed.kind) {
|
|
13894
|
+
case "none":
|
|
13895
|
+
return unknown2("no-probe-line", now);
|
|
13896
|
+
case "multiple":
|
|
13897
|
+
return unknown2("multiple-probe-lines", now);
|
|
13898
|
+
case "malformed":
|
|
13899
|
+
return unknown2("malformed-probe-line", now);
|
|
13900
|
+
case "probe":
|
|
13901
|
+
break;
|
|
13902
|
+
}
|
|
13903
|
+
const age = now.getTime() - Date.parse(parsed.at);
|
|
13904
|
+
if (age < -MAX_FUTURE_SKEW_MS)
|
|
13905
|
+
return unknown2("malformed-probe-line", now);
|
|
13906
|
+
if (age > MONITOR_STALE_AFTER_MS) {
|
|
13907
|
+
return observe(SPEC2, "unknown", { detail: { reason: "stale", probeAt: parsed.at }, now });
|
|
13908
|
+
}
|
|
13909
|
+
const result = parsed.state === "healthy" ? "pass" : parsed.state === "dead" ? "fail" : "unknown";
|
|
13910
|
+
return observe(SPEC2, result, { detail: { reason: parsed.reason, probeAt: parsed.at }, now });
|
|
13911
|
+
}
|
|
13912
|
+
|
|
13913
|
+
// src/status/github-reads.ts
|
|
13914
|
+
async function runGithubReads(readers, options) {
|
|
13915
|
+
const [deploy, monitor] = await Promise.allSettled([
|
|
13916
|
+
readers.readDeployStates(options),
|
|
13917
|
+
readers.readMonitorState(options)
|
|
13918
|
+
]);
|
|
13919
|
+
return { deploy, monitor };
|
|
13920
|
+
}
|
|
13921
|
+
function checkFailed(app, id, now) {
|
|
13922
|
+
return observe(declaredObservation(app, id), "unknown", { detail: { reason: "check-failed" }, now });
|
|
13923
|
+
}
|
|
13924
|
+
function githubObservationsFor(app, reads, now) {
|
|
13925
|
+
const observations = [];
|
|
13926
|
+
if (reads.deploy.status === "rejected") {
|
|
13927
|
+
observations.push(checkFailed(app, "deploy-state", now));
|
|
13928
|
+
} else {
|
|
13929
|
+
const deployState = reads.deploy.value[app];
|
|
13930
|
+
if (deployState !== undefined)
|
|
13931
|
+
observations.push(deployState);
|
|
13932
|
+
}
|
|
13933
|
+
if (app === "cliproxy") {
|
|
13934
|
+
observations.push(reads.monitor.status === "rejected" ? checkFailed(app, MONITOR_OBSERVATION_ID, now) : reads.monitor.value);
|
|
13935
|
+
}
|
|
13936
|
+
return observations;
|
|
13937
|
+
}
|
|
13938
|
+
|
|
13939
|
+
// src/commands/status-history-locate.ts
|
|
13940
|
+
import { appendFile } from "fs/promises";
|
|
13941
|
+
import { z as z32 } from "zod";
|
|
13942
|
+
var HISTORY_ARTIFACT_NAME = "status-history";
|
|
13943
|
+
var SNAPSHOT_WORKFLOW_PATH = ".github/workflows/status-snapshot.yaml";
|
|
13944
|
+
var HISTORY_LOCATE_RUN_CHECK_BUDGET = 5;
|
|
13945
|
+
var LIST_PAGE_SIZE = 100;
|
|
13946
|
+
var WRITER_EVENTS = new Set(["schedule", "workflow_dispatch"]);
|
|
13947
|
+
var REPOSITORY_PATTERN = /^[\w.-]+\/[\w.-]+$/;
|
|
13948
|
+
var artifactsSchema = z32.object({
|
|
13949
|
+
total_count: z32.number().int().nonnegative(),
|
|
13950
|
+
artifacts: z32.array(z32.object({
|
|
13951
|
+
name: z32.string(),
|
|
13952
|
+
expired: z32.boolean(),
|
|
13953
|
+
created_at: z32.iso.datetime({ offset: true }),
|
|
13954
|
+
workflow_run: z32.object({ id: z32.number().int().positive(), head_branch: z32.string().nullable() }).nullable()
|
|
13955
|
+
}))
|
|
13956
|
+
});
|
|
13957
|
+
var runSchema = z32.object({
|
|
13958
|
+
id: z32.number().int().positive(),
|
|
13959
|
+
event: z32.string(),
|
|
13960
|
+
head_branch: z32.string().nullable(),
|
|
13961
|
+
path: z32.string()
|
|
13962
|
+
});
|
|
13963
|
+
async function locateHistory(fetchFn, token, repository) {
|
|
13964
|
+
const listed = await githubGetJson(fetchFn, token, `/repos/${repository}/actions/artifacts?name=${HISTORY_ARTIFACT_NAME}&per_page=${LIST_PAGE_SIZE}`, artifactsSchema);
|
|
13965
|
+
if (!listed.ok)
|
|
13966
|
+
return { state: "error", reason: "api-error" };
|
|
13967
|
+
const candidates = listed.data.artifacts.filter((artifact) => artifact.name === HISTORY_ARTIFACT_NAME && !artifact.expired).flatMap((artifact) => artifact.workflow_run?.head_branch === "main" ? [{ createdAt: Date.parse(artifact.created_at), runId: artifact.workflow_run.id }] : []).toSorted((left, right) => right.createdAt - left.createdAt);
|
|
13968
|
+
let checked = 0;
|
|
13969
|
+
for (const candidate of candidates) {
|
|
13970
|
+
if (checked >= HISTORY_LOCATE_RUN_CHECK_BUDGET)
|
|
13971
|
+
return { state: "error", reason: "run-check-budget-exceeded" };
|
|
13972
|
+
checked += 1;
|
|
13973
|
+
const run = await githubGetJson(fetchFn, token, `/repos/${repository}/actions/runs/${candidate.runId}`, runSchema);
|
|
13974
|
+
if (!run.ok)
|
|
13975
|
+
return { state: "error", reason: "api-error" };
|
|
13976
|
+
const { data } = run;
|
|
13977
|
+
if (data.id === candidate.runId && data.head_branch === "main" && WRITER_EVENTS.has(data.event) && data.path === SNAPSHOT_WORKFLOW_PATH) {
|
|
13978
|
+
return { state: "found", runId: candidate.runId };
|
|
13979
|
+
}
|
|
13980
|
+
}
|
|
13981
|
+
if (listed.data.total_count > listed.data.artifacts.length)
|
|
13982
|
+
return { state: "error", reason: "incomplete-listing" };
|
|
13983
|
+
return { state: "empty" };
|
|
13984
|
+
}
|
|
13985
|
+
function outputLines(result) {
|
|
13986
|
+
return result.state === "found" ? `state=found
|
|
13987
|
+
run-id=${result.runId}
|
|
13988
|
+
` : `state=empty
|
|
13989
|
+
`;
|
|
13990
|
+
}
|
|
13991
|
+
async function statusHistoryLocateAction(ctx, deps = {}, env = process.env) {
|
|
13992
|
+
const fail = (reason) => {
|
|
13993
|
+
ctx.console.error(`status history-locate failed: ${reason}`);
|
|
13994
|
+
ctx.process.exit(1);
|
|
13995
|
+
};
|
|
13996
|
+
const token = resolveGithubToken(env);
|
|
13997
|
+
if (!hasToken(token))
|
|
13998
|
+
return fail("missing-token");
|
|
13999
|
+
const repository = env.GITHUB_REPOSITORY?.trim() || GITHUB_REPOSITORY;
|
|
14000
|
+
if (!REPOSITORY_PATTERN.test(repository))
|
|
14001
|
+
return fail("invalid-repository");
|
|
14002
|
+
const result = await locateHistory(deps.fetch ?? fetch, token, repository);
|
|
14003
|
+
if (result.state === "error")
|
|
14004
|
+
return fail(result.reason);
|
|
14005
|
+
const outputPath = env.GITHUB_OUTPUT?.trim() ?? "";
|
|
14006
|
+
if (outputPath !== "") {
|
|
14007
|
+
try {
|
|
14008
|
+
await appendFile(outputPath, outputLines(result));
|
|
14009
|
+
} catch {
|
|
14010
|
+
return fail("output-file-error");
|
|
14011
|
+
}
|
|
14012
|
+
}
|
|
14013
|
+
ctx.console.log(result.state === "found" ? `found run-id=${result.runId}` : "empty");
|
|
14014
|
+
}
|
|
14015
|
+
function registerStatusHistoryLocate(cli, deps) {
|
|
14016
|
+
cli.command("status history-locate", 'Find the newest usable status-history artifact for the snapshot workflow. Needs GITHUB_TOKEN or GH_TOKEN (read-only, actions: read) and reads GITHUB_REPOSITORY. Prints exactly "found run-id=<id>" or "empty" and exits 0; any API error, truncated listing or exhausted call budget exits non-zero, so an outage is never mistaken for an empty history. When GITHUB_OUTPUT is set it also appends state= and run-id= lines. CLI-only: not exposed over MCP.').example("# Locate the history artifact in CI").example("infra status history-locate").action((_options, ctx) => statusHistoryLocateAction(ctx, deps));
|
|
14017
|
+
}
|
|
14018
|
+
|
|
14019
|
+
// src/commands/status-snapshot.ts
|
|
14020
|
+
import { z as z34 } from "zod";
|
|
14021
|
+
|
|
14022
|
+
// src/status/history.ts
|
|
14023
|
+
import { randomUUID } from "crypto";
|
|
14024
|
+
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "fs/promises";
|
|
14025
|
+
import { join as join8 } from "path";
|
|
14026
|
+
import { z as z33 } from "zod";
|
|
14027
|
+
var HISTORY_SCHEMA_VERSION = 1;
|
|
14028
|
+
var HISTORY_WINDOW_DAYS = 90;
|
|
14029
|
+
var HISTORY_WINDOW_MS = HISTORY_WINDOW_DAYS * 24 * 60 * 60000;
|
|
14030
|
+
var HISTORY_MAX_BYTES = 10 * 1024 * 1024;
|
|
14031
|
+
var MAX_FUTURE_SKEW_MS2 = 5 * 60000;
|
|
14032
|
+
var MAX_OBSERVATIONS_PER_SAMPLE = 32;
|
|
14033
|
+
var MESSAGES = {
|
|
14034
|
+
"missing-history": "history not found; pass --bootstrap to create it",
|
|
14035
|
+
"history-exists": "history already exists; refusing to bootstrap over it",
|
|
14036
|
+
"unsupported-schema": "unsupported history schemaVersion",
|
|
14037
|
+
malformed: "history is malformed",
|
|
14038
|
+
"unknown-app": "history holds an unknown app",
|
|
14039
|
+
"duplicate-sample": "history holds a duplicate run and attempt",
|
|
14040
|
+
"future-sample": "a sample is dated more than 5 minutes in the future",
|
|
14041
|
+
"sample-dropped": "merge would drop a sample inside the 90-day window",
|
|
14042
|
+
"newest-regressed": "newest sample time would move backwards",
|
|
14043
|
+
"too-large": "history exceeds the 10 MB cap",
|
|
14044
|
+
"invalid-sample": "snapshot sample is invalid",
|
|
14045
|
+
"invalid-run": "GITHUB_RUN_ID or GITHUB_RUN_ATTEMPT is invalid",
|
|
14046
|
+
"invalid-now": "--now must be a UTC ISO timestamp"
|
|
14047
|
+
};
|
|
14048
|
+
|
|
14049
|
+
class HistoryError extends Error {
|
|
14050
|
+
code;
|
|
14051
|
+
app;
|
|
14052
|
+
constructor(code, app) {
|
|
14053
|
+
super(app === undefined ? MESSAGES[code] : `${MESSAGES[code]} (${app})`);
|
|
14054
|
+
this.name = "HistoryError";
|
|
14055
|
+
this.code = code;
|
|
14056
|
+
this.app = app;
|
|
14057
|
+
}
|
|
14058
|
+
}
|
|
14059
|
+
var RESULT_CODES = { pass: "p", fail: "f", unknown: "u", not_measured: "n" };
|
|
14060
|
+
var resultCodeSchema = z33.enum(["p", "f", "u", "n"]);
|
|
14061
|
+
var observationIdSchema = z33.string().max(64).regex(/^[a-z][a-z0-9-]*$/);
|
|
14062
|
+
var SAMPLE_DETAIL_KEYS = ["statusCode", "reason", "contractVersion", "since", "probeAt"];
|
|
14063
|
+
var sampleDetailSchema = observationDetailSchema.pick({
|
|
14064
|
+
statusCode: true,
|
|
14065
|
+
reason: true,
|
|
14066
|
+
contractVersion: true,
|
|
14067
|
+
since: true,
|
|
14068
|
+
probeAt: true
|
|
14069
|
+
});
|
|
14070
|
+
var historySampleSchema = z33.strictObject({
|
|
14071
|
+
t: z33.iso.datetime(),
|
|
14072
|
+
run: z33.string().regex(/^(?:\d{1,20}|local(?:-\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z)?)$/),
|
|
14073
|
+
att: z33.number().int().min(1).max(1e4),
|
|
14074
|
+
r: z33.record(observationIdSchema, resultCodeSchema),
|
|
14075
|
+
d: z33.record(observationIdSchema, sampleDetailSchema).optional()
|
|
14076
|
+
}).refine((sample) => {
|
|
14077
|
+
const count = Object.keys(sample.r).length;
|
|
14078
|
+
return count >= 1 && count <= MAX_OBSERVATIONS_PER_SAMPLE;
|
|
14079
|
+
}, "r must hold 1 to 32 results").refine((sample) => Object.keys(sample.d ?? {}).every((id) => (id in sample.r)), "d may only describe ids present in r");
|
|
14080
|
+
var historyMetaSchema = z33.strictObject({
|
|
14081
|
+
schemaVersion: z33.literal(HISTORY_SCHEMA_VERSION),
|
|
14082
|
+
lastSampleAt: z33.iso.datetime()
|
|
14083
|
+
});
|
|
14084
|
+
var historyAppFileSchema = z33.array(historySampleSchema);
|
|
14085
|
+
function recordByApp(build) {
|
|
14086
|
+
return {
|
|
14087
|
+
keeweb: build("keeweb"),
|
|
14088
|
+
cliproxy: build("cliproxy"),
|
|
14089
|
+
gateway: build("gateway"),
|
|
14090
|
+
umami: build("umami"),
|
|
14091
|
+
dashboard: build("dashboard"),
|
|
14092
|
+
vpn: build("vpn"),
|
|
14093
|
+
broker: build("broker")
|
|
14094
|
+
};
|
|
14095
|
+
}
|
|
14096
|
+
function pickDetail(detail) {
|
|
14097
|
+
if (detail === undefined)
|
|
14098
|
+
return;
|
|
14099
|
+
const picked = Object.fromEntries(SAMPLE_DETAIL_KEYS.flatMap((key) => detail[key] === undefined ? [] : [[key, detail[key]]]));
|
|
14100
|
+
if (Object.keys(picked).length === 0)
|
|
14101
|
+
return;
|
|
14102
|
+
const parsed = sampleDetailSchema.safeParse(picked);
|
|
14103
|
+
if (!parsed.success)
|
|
14104
|
+
throw new HistoryError("invalid-sample");
|
|
14105
|
+
return parsed.data;
|
|
14106
|
+
}
|
|
14107
|
+
function compactSample(observations, identity, now) {
|
|
14108
|
+
const r = {};
|
|
14109
|
+
const d = {};
|
|
14110
|
+
for (const observation of observations) {
|
|
14111
|
+
r[observation.id] = RESULT_CODES[observation.result];
|
|
14112
|
+
const detail = pickDetail(observation.detail);
|
|
14113
|
+
if (detail !== undefined)
|
|
14114
|
+
d[observation.id] = detail;
|
|
14115
|
+
}
|
|
14116
|
+
const parsed = historySampleSchema.safeParse({
|
|
14117
|
+
t: now.toISOString(),
|
|
14118
|
+
run: identity.run,
|
|
14119
|
+
att: identity.att,
|
|
14120
|
+
r,
|
|
14121
|
+
...Object.keys(d).length === 0 ? {} : { d }
|
|
14122
|
+
});
|
|
14123
|
+
if (!parsed.success)
|
|
14124
|
+
throw new HistoryError("invalid-sample");
|
|
14125
|
+
return parsed.data;
|
|
14126
|
+
}
|
|
14127
|
+
function serializeHistory(history) {
|
|
14128
|
+
const files = [
|
|
14129
|
+
...APP_IDS.map((app) => ({ path: `apps/${app}.json`, content: JSON.stringify(history.apps[app]) })),
|
|
14130
|
+
{ path: "meta.json", content: JSON.stringify(history.meta) }
|
|
14131
|
+
];
|
|
14132
|
+
const encoder = new TextEncoder;
|
|
14133
|
+
return { files, bytes: files.reduce((total, file) => total + encoder.encode(file.content).length, 0) };
|
|
14134
|
+
}
|
|
14135
|
+
var sampleKey = (sample) => `${sample.run}:${sample.att}`;
|
|
14136
|
+
var sampleTime = (sample) => Date.parse(sample.t);
|
|
14137
|
+
var KNOWN_APPS = APP_IDS;
|
|
14138
|
+
function isAppId(value) {
|
|
14139
|
+
return KNOWN_APPS.includes(value);
|
|
14140
|
+
}
|
|
14141
|
+
function assertHistoryInvariants({ previous, next, now, maxBytes = HISTORY_MAX_BYTES }) {
|
|
14142
|
+
const nowMs = now.getTime();
|
|
14143
|
+
const cutoff = nowMs - HISTORY_WINDOW_MS;
|
|
14144
|
+
const futureLimit = nowMs + MAX_FUTURE_SKEW_MS2;
|
|
14145
|
+
for (const app of Object.keys(next.apps)) {
|
|
14146
|
+
if (!isAppId(app))
|
|
14147
|
+
throw new HistoryError("unknown-app");
|
|
14148
|
+
}
|
|
14149
|
+
if (Date.parse(next.meta.lastSampleAt) > futureLimit)
|
|
14150
|
+
throw new HistoryError("future-sample");
|
|
14151
|
+
let newest = Number.NEGATIVE_INFINITY;
|
|
14152
|
+
for (const app of APP_IDS) {
|
|
14153
|
+
const keys = new Set;
|
|
14154
|
+
for (const sample of next.apps[app]) {
|
|
14155
|
+
if (sampleTime(sample) > futureLimit)
|
|
14156
|
+
throw new HistoryError("future-sample", app);
|
|
14157
|
+
const key = sampleKey(sample);
|
|
14158
|
+
if (keys.has(key))
|
|
14159
|
+
throw new HistoryError("duplicate-sample", app);
|
|
14160
|
+
keys.add(key);
|
|
14161
|
+
newest = Math.max(newest, sampleTime(sample));
|
|
14162
|
+
}
|
|
14163
|
+
const before = previous?.apps[app] ?? [];
|
|
14164
|
+
const insideWindow = before.filter((sample) => sampleTime(sample) >= cutoff);
|
|
14165
|
+
const outsideWindow = before.length - insideWindow.length;
|
|
14166
|
+
if (next.apps[app].length < before.length - outsideWindow || insideWindow.some((sample) => !keys.has(sampleKey(sample)))) {
|
|
14167
|
+
throw new HistoryError("sample-dropped", app);
|
|
14168
|
+
}
|
|
14169
|
+
}
|
|
14170
|
+
if (previous !== undefined) {
|
|
14171
|
+
const floor = Date.parse(previous.meta.lastSampleAt);
|
|
14172
|
+
if (floor >= cutoff && (newest < floor || Date.parse(next.meta.lastSampleAt) < floor)) {
|
|
14173
|
+
throw new HistoryError("newest-regressed");
|
|
14174
|
+
}
|
|
14175
|
+
}
|
|
14176
|
+
if (serializeHistory(next).bytes > maxBytes)
|
|
14177
|
+
throw new HistoryError("too-large");
|
|
14178
|
+
}
|
|
14179
|
+
function mergeHistory(input) {
|
|
14180
|
+
const { previous, incoming, now } = input;
|
|
14181
|
+
const cutoff = now.getTime() - HISTORY_WINDOW_MS;
|
|
14182
|
+
let added = 0;
|
|
14183
|
+
let trimmed = 0;
|
|
14184
|
+
let newest;
|
|
14185
|
+
const apps = {};
|
|
14186
|
+
for (const app of APP_IDS) {
|
|
14187
|
+
const existing = previous?.apps[app] ?? [];
|
|
14188
|
+
const known = new Set(existing.map(sampleKey));
|
|
14189
|
+
const fresh = incoming[app];
|
|
14190
|
+
const appended = known.has(sampleKey(fresh)) ? [] : [fresh];
|
|
14191
|
+
const candidates = [...existing, ...appended];
|
|
14192
|
+
const kept = candidates.filter((sample) => sampleTime(sample) >= cutoff).sort((a, b) => sampleTime(a) - sampleTime(b));
|
|
14193
|
+
added += appended.length;
|
|
14194
|
+
trimmed += candidates.length - kept.length;
|
|
14195
|
+
apps[app] = kept;
|
|
14196
|
+
for (const sample of kept) {
|
|
14197
|
+
if (newest === undefined || sampleTime(sample) > sampleTime(newest))
|
|
14198
|
+
newest = sample;
|
|
14199
|
+
}
|
|
14200
|
+
}
|
|
14201
|
+
const history = {
|
|
14202
|
+
meta: { schemaVersion: HISTORY_SCHEMA_VERSION, lastSampleAt: newest?.t ?? now.toISOString() },
|
|
14203
|
+
apps
|
|
14204
|
+
};
|
|
14205
|
+
assertHistoryInvariants({
|
|
14206
|
+
previous,
|
|
14207
|
+
next: history,
|
|
14208
|
+
now,
|
|
14209
|
+
...input.maxBytes === undefined ? {} : { maxBytes: input.maxBytes }
|
|
14210
|
+
});
|
|
14211
|
+
return { history, added, trimmed };
|
|
14212
|
+
}
|
|
14213
|
+
function isNotFound(error) {
|
|
14214
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
14215
|
+
}
|
|
14216
|
+
async function pathExists(path) {
|
|
14217
|
+
try {
|
|
14218
|
+
await stat(path);
|
|
14219
|
+
return true;
|
|
14220
|
+
} catch (error) {
|
|
14221
|
+
if (isNotFound(error))
|
|
14222
|
+
return false;
|
|
14223
|
+
throw error;
|
|
14224
|
+
}
|
|
14225
|
+
}
|
|
14226
|
+
async function readBounded(path, budget, app) {
|
|
14227
|
+
let size;
|
|
14228
|
+
try {
|
|
14229
|
+
size = (await stat(path)).size;
|
|
14230
|
+
} catch (error) {
|
|
14231
|
+
if (isNotFound(error))
|
|
14232
|
+
throw new HistoryError("malformed", app);
|
|
14233
|
+
throw error;
|
|
14234
|
+
}
|
|
14235
|
+
budget.remaining -= size;
|
|
14236
|
+
if (budget.remaining < 0)
|
|
14237
|
+
throw new HistoryError("too-large");
|
|
14238
|
+
return readFile(path, "utf8");
|
|
14239
|
+
}
|
|
14240
|
+
function parseJson(text, app) {
|
|
14241
|
+
try {
|
|
14242
|
+
return JSON.parse(text);
|
|
14243
|
+
} catch {
|
|
14244
|
+
throw new HistoryError("malformed", app);
|
|
14245
|
+
}
|
|
14246
|
+
}
|
|
14247
|
+
async function loadHistory(dir, mode, options = {}) {
|
|
14248
|
+
const metaPath = join8(dir, "meta.json");
|
|
14249
|
+
const appsDir = join8(dir, "apps");
|
|
14250
|
+
const hasMeta = await pathExists(metaPath);
|
|
14251
|
+
if (mode === "bootstrap") {
|
|
14252
|
+
const hasApps = await pathExists(appsDir) && (await readdir(appsDir)).length > 0;
|
|
14253
|
+
if (hasMeta || hasApps)
|
|
14254
|
+
throw new HistoryError("history-exists");
|
|
14255
|
+
return;
|
|
14256
|
+
}
|
|
14257
|
+
if (!hasMeta)
|
|
14258
|
+
throw new HistoryError("missing-history");
|
|
14259
|
+
const budget = { remaining: options.maxBytes ?? HISTORY_MAX_BYTES };
|
|
14260
|
+
const rawMeta = parseJson(await readBounded(metaPath, budget));
|
|
14261
|
+
if (typeof rawMeta === "object" && rawMeta !== null && "schemaVersion" in rawMeta && rawMeta.schemaVersion !== 1) {
|
|
14262
|
+
throw new HistoryError("unsupported-schema");
|
|
14263
|
+
}
|
|
14264
|
+
const meta = historyMetaSchema.safeParse(rawMeta);
|
|
14265
|
+
if (!meta.success)
|
|
14266
|
+
throw new HistoryError("malformed");
|
|
14267
|
+
let entries;
|
|
14268
|
+
try {
|
|
14269
|
+
entries = await readdir(appsDir);
|
|
14270
|
+
} catch (error) {
|
|
14271
|
+
if (isNotFound(error))
|
|
14272
|
+
throw new HistoryError("malformed");
|
|
14273
|
+
throw error;
|
|
14274
|
+
}
|
|
14275
|
+
for (const entry of entries) {
|
|
14276
|
+
if (!entry.endsWith(".json") || !isAppId(entry.slice(0, -".json".length)))
|
|
14277
|
+
throw new HistoryError("unknown-app");
|
|
14278
|
+
}
|
|
14279
|
+
const apps = {};
|
|
14280
|
+
for (const app of APP_IDS) {
|
|
14281
|
+
const parsed = historyAppFileSchema.safeParse(parseJson(await readBounded(join8(appsDir, `${app}.json`), budget, app), app));
|
|
14282
|
+
if (!parsed.success)
|
|
14283
|
+
throw new HistoryError("malformed", app);
|
|
14284
|
+
const keys = new Set(parsed.data.map(sampleKey));
|
|
14285
|
+
if (keys.size !== parsed.data.length)
|
|
14286
|
+
throw new HistoryError("duplicate-sample", app);
|
|
14287
|
+
apps[app] = parsed.data;
|
|
14288
|
+
}
|
|
14289
|
+
return { meta: meta.data, apps };
|
|
14290
|
+
}
|
|
14291
|
+
async function writeHistory(dir, history) {
|
|
14292
|
+
const { files } = serializeHistory(history);
|
|
14293
|
+
const staged = [];
|
|
14294
|
+
try {
|
|
14295
|
+
await mkdir(join8(dir, "apps"), { recursive: true });
|
|
14296
|
+
for (const file of files) {
|
|
14297
|
+
const final = join8(dir, file.path);
|
|
14298
|
+
const temp = `${final}.${randomUUID()}.tmp`;
|
|
14299
|
+
staged.push({ temp, final });
|
|
14300
|
+
await writeFile(temp, file.content, { flag: "wx" });
|
|
14301
|
+
}
|
|
14302
|
+
for (const { temp, final } of staged)
|
|
14303
|
+
await rename(temp, final);
|
|
14304
|
+
} catch (error) {
|
|
14305
|
+
await Promise.all(staged.map(({ temp }) => rm(temp, { force: true })));
|
|
14306
|
+
throw error;
|
|
14307
|
+
}
|
|
14308
|
+
}
|
|
14309
|
+
|
|
14310
|
+
// src/commands/status-snapshot.ts
|
|
14311
|
+
function resolveRunIdentity(env, now) {
|
|
14312
|
+
const run = env.GITHUB_RUN_ID?.trim() ?? "";
|
|
14313
|
+
if (run === "")
|
|
14314
|
+
return { run: `local-${now.toISOString()}`, att: 1 };
|
|
14315
|
+
const attempt = env.GITHUB_RUN_ATTEMPT?.trim() ?? "";
|
|
14316
|
+
if (!/^\d{1,20}$/.test(run) || attempt !== "" && !/^\d{1,5}$/.test(attempt)) {
|
|
14317
|
+
throw new HistoryError("invalid-run");
|
|
14318
|
+
}
|
|
14319
|
+
return { run, att: attempt === "" ? 1 : Number(attempt) };
|
|
14320
|
+
}
|
|
14321
|
+
function resolveNow(value, clock) {
|
|
14322
|
+
if (value === undefined)
|
|
14323
|
+
return clock();
|
|
14324
|
+
if (!z34.iso.datetime().safeParse(value).success)
|
|
14325
|
+
throw new HistoryError("invalid-now");
|
|
14326
|
+
return new Date(value);
|
|
14327
|
+
}
|
|
14328
|
+
var SNAPSHOT_IDS = recordByApp((app) => new Set(DECLARED_OBSERVATIONS[app].filter((declared) => declared.owner === "probe" || declared.owner === "github").map((declared) => declared.id)));
|
|
14329
|
+
async function runProbe2(probe) {
|
|
14330
|
+
if (probe === undefined)
|
|
14331
|
+
return { kind: "none" };
|
|
14332
|
+
try {
|
|
14333
|
+
return { kind: "ok", observation: await probe() };
|
|
14334
|
+
} catch {
|
|
14335
|
+
return { kind: "failed" };
|
|
14336
|
+
}
|
|
14337
|
+
}
|
|
14338
|
+
async function collectObservations(deps, token, now) {
|
|
14339
|
+
const reads = runGithubReads(deps, { token, now, ...deps.fetch === undefined ? {} : { fetch: deps.fetch } });
|
|
14340
|
+
const probes = await Promise.all(APP_IDS.map(async (app) => [app, await runProbe2(deps.publicProbes[app])]));
|
|
14341
|
+
const settled = await reads;
|
|
14342
|
+
return recordByApp((app) => {
|
|
14343
|
+
const probe = probes.find(([candidate]) => candidate === app)?.[1] ?? { kind: "none" };
|
|
14344
|
+
const status = assembleAppStatus(app, probe, { kind: "none" }, now, githubObservationsFor(app, settled, now));
|
|
14345
|
+
return status.observations.filter((observation) => SNAPSHOT_IDS[app].has(observation.id));
|
|
14346
|
+
});
|
|
14347
|
+
}
|
|
14348
|
+
function describeFailure(error) {
|
|
14349
|
+
if (error instanceof HistoryError)
|
|
14350
|
+
return error.message;
|
|
14351
|
+
if (error instanceof Error && "code" in error && typeof error.code === "string" && /^E[A-Z\d]+$/.test(error.code)) {
|
|
14352
|
+
return `filesystem error (${error.code})`;
|
|
14353
|
+
}
|
|
14354
|
+
return "unexpected error";
|
|
14355
|
+
}
|
|
14356
|
+
async function statusSnapshotAction(options, ctx, deps, env = process.env) {
|
|
14357
|
+
try {
|
|
14358
|
+
const now = resolveNow(options.now, deps.now);
|
|
14359
|
+
const identity = resolveRunIdentity(env, now);
|
|
14360
|
+
const previous = await loadHistory(options.history, options.bootstrap === true ? "bootstrap" : "existing");
|
|
14361
|
+
const observations = await collectObservations(deps, resolveGithubToken(env), now);
|
|
14362
|
+
const incoming = recordByApp((app) => compactSample(observations[app], identity, now));
|
|
14363
|
+
const merged = mergeHistory({ previous, incoming, now });
|
|
14364
|
+
if (previous === undefined || merged.added > 0 || merged.trimmed > 0) {
|
|
14365
|
+
await writeHistory(options.history, merged.history);
|
|
14366
|
+
}
|
|
14367
|
+
ctx.console.log(`samples: ${APP_IDS.map((app) => `${app}=${merged.history.apps[app].length}`).join(" ")}`);
|
|
14368
|
+
ctx.console.log(`added: ${merged.added}`);
|
|
14369
|
+
ctx.console.log(`trimmed: ${merged.trimmed}`);
|
|
14370
|
+
ctx.console.log(`bytes: ${serializeHistory(merged.history).bytes}`);
|
|
14371
|
+
} catch (error) {
|
|
14372
|
+
ctx.console.error(`status snapshot failed: ${describeFailure(error)}`);
|
|
14373
|
+
ctx.process.exit(1);
|
|
14374
|
+
}
|
|
14375
|
+
}
|
|
14376
|
+
function registerStatusSnapshot(cli, deps) {
|
|
14377
|
+
cli.command("status snapshot", "Take one public-safe status sample and merge it into the 90-day history directory. Runs only the public HTTPS probes, the GitHub Deployments reader and the auth monitor reader (GITHUB_TOKEN or GH_TOKEN, read-only); it uses no SSH and no deploy secrets. Stages every file as a temp file before renaming any into place; any invariant violation exits non-zero and writes nothing. Prints counts only. Exits 0 once the snapshot is written, even when observations are fail or unknown, because those are data. CLI-only: not exposed over MCP.").option("--history <dir>", z34.string().min(1).describe("History directory holding meta.json and apps/<app>.json. It must already contain a valid history unless --bootstrap is given.")).option("--bootstrap", z34.boolean().describe("Create the history and its first sample. Fails if meta.json or any app file already exists; without this flag a missing history fails.")).option("--now [iso]", z34.string().describe("UTC ISO timestamp (for example 2026-11-01T12:00:00Z) to use as the sample time and the 90-day trim reference instead of the clock. Intended for tests and replays.")).example("# Create the first history sample in an empty directory").example("infra status snapshot --history ./status-history --bootstrap").example("# Append a sample to an existing history").example("infra status snapshot --history ./status-history").action((options, ctx) => statusSnapshotAction(options, ctx, deps));
|
|
14378
|
+
}
|
|
14379
|
+
|
|
14380
|
+
// src/commands/umami/deploy.ts
|
|
14381
|
+
import { z as z35 } from "zod";
|
|
13282
14382
|
var REPO6 = "marcusrbrown/infra";
|
|
13283
14383
|
var WORKFLOW_NAME6 = "Deploy Umami";
|
|
13284
14384
|
var WORKFLOW_URL6 = "https://github.com/marcusrbrown/infra/actions/workflows/deploy-umami.yaml";
|
|
@@ -13313,7 +14413,7 @@ function validateUmamiRemotePreconditions() {
|
|
|
13313
14413
|
}
|
|
13314
14414
|
}
|
|
13315
14415
|
function registerUmamiDeploy(cli) {
|
|
13316
|
-
cli.command("umami deploy", "Deploy Umami analytics. Default mode triggers the GitHub Deploy Umami workflow, while --local runs apps/umami deploy directly with Bun.").option("--local",
|
|
14416
|
+
cli.command("umami deploy", "Deploy Umami analytics. Default mode triggers the GitHub Deploy Umami workflow, while --local runs apps/umami deploy directly with Bun.").option("--local", z35.boolean().default(false).describe("Run local deployment with Bun using apps/umami instead of triggering GitHub Actions.")).option("--dry-run", z35.boolean().default(false).describe("Validate deploy prerequisites and print planned actions without executing local deploy or dispatching workflow.")).example("# Trigger remote GitHub Actions deploy (default mode)").example("infra umami deploy").example("# Validate local deploy preconditions and planned command").example("infra umami deploy --local --dry-run").example("# Run local deploy with explicit SSH agent context").example("infra umami deploy --local").action(async (options) => {
|
|
13317
14417
|
if (options.local) {
|
|
13318
14418
|
const command = ["bun", "run", "--cwd", "apps/umami", "deploy"];
|
|
13319
14419
|
if (options.dryRun) {
|
|
@@ -13354,7 +14454,7 @@ function registerUmamiDeploy(cli) {
|
|
|
13354
14454
|
}
|
|
13355
14455
|
|
|
13356
14456
|
// src/commands/umami/logs.ts
|
|
13357
|
-
import { z as
|
|
14457
|
+
import { z as z36 } from "zod";
|
|
13358
14458
|
|
|
13359
14459
|
// src/commands/umami/host.ts
|
|
13360
14460
|
var VALID_HOST_RE5 = /^[a-z\d][a-z\d.\-]*$/i;
|
|
@@ -13418,7 +14518,7 @@ async function streamUmamiLogs(opts, spawn = defaultLogsSpawn4, printOut, printE
|
|
|
13418
14518
|
return { refused: false, exitCode };
|
|
13419
14519
|
}
|
|
13420
14520
|
function registerUmamiLogs(cli) {
|
|
13421
|
-
cli.command("umami logs [service]", "Stream logs from an Umami service via SSH and docker compose.").option("--tail [n]",
|
|
14521
|
+
cli.command("umami logs [service]", "Stream logs from an Umami service via SSH and docker compose.").option("--tail [n]", z36.number().default(100).describe("Number of log lines to tail from each service.")).option("--allow-ci", z36.boolean().default(false).describe("Allow log streaming in CI environments. Logs may contain sensitive credentials.")).option("--key [key]", z36.string().describe("Environment variable name holding the SSH host. Falls back to UMAMI_DOMAIN when omitted.")).action(async (service, options) => {
|
|
13422
14522
|
const targetService = service ?? "umami";
|
|
13423
14523
|
try {
|
|
13424
14524
|
validateService3(targetService);
|
|
@@ -13448,7 +14548,7 @@ function registerUmamiLogs(cli) {
|
|
|
13448
14548
|
}
|
|
13449
14549
|
|
|
13450
14550
|
// src/commands/umami/status.ts
|
|
13451
|
-
import { z as
|
|
14551
|
+
import { z as z37 } from "zod";
|
|
13452
14552
|
var COMPOSE_PROJECT_DIR7 = "/opt/umami";
|
|
13453
14553
|
function parseComposePsOutput3(stdoutText) {
|
|
13454
14554
|
const trimmed = stdoutText.trim();
|
|
@@ -13480,7 +14580,7 @@ function isAllRunning3(rows) {
|
|
|
13480
14580
|
function defaultSpawn6(cmd, opts) {
|
|
13481
14581
|
return Bun.spawn(cmd, opts);
|
|
13482
14582
|
}
|
|
13483
|
-
async function getUmamiComposeStatus(host, spawn = defaultSpawn6) {
|
|
14583
|
+
async function getUmamiComposeStatus(host, spawn = defaultSpawn6, signal) {
|
|
13484
14584
|
validateUmamiHost(host);
|
|
13485
14585
|
const knownHostsArgs = buildKnownHostsArgs();
|
|
13486
14586
|
const { args: identityArgs, cleanup } = buildIdentityArgs(process.env.UMAMI_SSH_KEY);
|
|
@@ -13504,7 +14604,7 @@ async function getUmamiComposeStatus(host, spawn = defaultSpawn6) {
|
|
|
13504
14604
|
let stderrText;
|
|
13505
14605
|
let exitCode;
|
|
13506
14606
|
try {
|
|
13507
|
-
const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe" });
|
|
14607
|
+
const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe", ...signal === undefined ? {} : { signal } });
|
|
13508
14608
|
[stdoutText, stderrText, exitCode] = await Promise.all([
|
|
13509
14609
|
new Response(child.stdout).text(),
|
|
13510
14610
|
new Response(child.stderr).text(),
|
|
@@ -13534,50 +14634,11 @@ async function getUmamiComposeStatus(host, spawn = defaultSpawn6) {
|
|
|
13534
14634
|
const ok = isAllRunning3(services);
|
|
13535
14635
|
return { ok, services };
|
|
13536
14636
|
}
|
|
13537
|
-
async function getUmamiStatusSummary(host, spawn) {
|
|
13538
|
-
|
|
13539
|
-
try {
|
|
13540
|
-
result = await getUmamiComposeStatus(host, spawn);
|
|
13541
|
-
} catch (error) {
|
|
13542
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
13543
|
-
return {
|
|
13544
|
-
app: "umami",
|
|
13545
|
-
http: `ERROR: ${errorMsg}`,
|
|
13546
|
-
lastDeploy: "\u2014",
|
|
13547
|
-
version: "\u2014",
|
|
13548
|
-
contentHash: "\u2014",
|
|
13549
|
-
usageStats: "\u2014"
|
|
13550
|
-
};
|
|
13551
|
-
}
|
|
13552
|
-
if (result.services.length === 0) {
|
|
13553
|
-
const errorMsg = result.error ?? "No services reported";
|
|
13554
|
-
return {
|
|
13555
|
-
app: "umami",
|
|
13556
|
-
http: `ERROR: ${errorMsg}`,
|
|
13557
|
-
lastDeploy: "\u2014",
|
|
13558
|
-
version: "\u2014",
|
|
13559
|
-
contentHash: "\u2014",
|
|
13560
|
-
usageStats: "\u2014"
|
|
13561
|
-
};
|
|
13562
|
-
}
|
|
13563
|
-
const rows = result.services.map((s) => `${s.service}:${s.state}/${s.health}`).join(", ");
|
|
13564
|
-
if (!result.ok) {
|
|
13565
|
-
return {
|
|
13566
|
-
app: "umami",
|
|
13567
|
-
http: `DEGRADED: ${rows}`,
|
|
13568
|
-
lastDeploy: "\u2014",
|
|
13569
|
-
version: "\u2014",
|
|
13570
|
-
contentHash: "\u2014",
|
|
13571
|
-
usageStats: "\u2014"
|
|
13572
|
-
};
|
|
13573
|
-
}
|
|
14637
|
+
async function getUmamiStatusSummary(host, spawn, signal) {
|
|
14638
|
+
const result = await getUmamiComposeStatus(host, spawn, signal);
|
|
13574
14639
|
return {
|
|
13575
|
-
|
|
13576
|
-
|
|
13577
|
-
lastDeploy: "\u2014",
|
|
13578
|
-
version: "\u2014",
|
|
13579
|
-
contentHash: "\u2014",
|
|
13580
|
-
usageStats: "\u2014"
|
|
14640
|
+
observations: [composeObservation(declaredObservation("umami", "compose-services"), result)],
|
|
14641
|
+
facts: { services: servicesFact(result.services) }
|
|
13581
14642
|
};
|
|
13582
14643
|
}
|
|
13583
14644
|
async function umamiStatusAction(options, ctx, spawn) {
|
|
@@ -13619,7 +14680,7 @@ async function umamiStatusAction(options, ctx, spawn) {
|
|
|
13619
14680
|
}
|
|
13620
14681
|
}
|
|
13621
14682
|
function registerUmamiStatus(cli) {
|
|
13622
|
-
cli.command("umami status", "Show operational health of the Umami analytics deployment via docker compose ps.").option("--key [key]",
|
|
14683
|
+
cli.command("umami status", "Show operational health of the Umami analytics deployment via docker compose ps.").option("--key [key]", z37.string().optional().describe("Environment variable name holding the SSH host. Falls back to UMAMI_DOMAIN when omitted.")).action((options, ctx) => umamiStatusAction(options, ctx));
|
|
13623
14684
|
}
|
|
13624
14685
|
|
|
13625
14686
|
// src/commands/umami/index.ts
|
|
@@ -13630,26 +14691,26 @@ function registerUmamiCommands(cli) {
|
|
|
13630
14691
|
}
|
|
13631
14692
|
|
|
13632
14693
|
// src/commands/vpn/client.ts
|
|
13633
|
-
import { mkdir, writeFile as
|
|
13634
|
-
import { join as
|
|
13635
|
-
import { z as
|
|
14694
|
+
import { mkdir as mkdir2, writeFile as writeFile3 } from "fs/promises";
|
|
14695
|
+
import { join as join9, resolve as resolve2 } from "path";
|
|
14696
|
+
import { z as z39 } from "zod";
|
|
13636
14697
|
|
|
13637
14698
|
// src/commands/vpn/peers.ts
|
|
13638
|
-
import { readFile, writeFile } from "fs/promises";
|
|
13639
|
-
import { z as
|
|
13640
|
-
var peerSchema =
|
|
13641
|
-
name:
|
|
13642
|
-
publicKey:
|
|
13643
|
-
tunnelIp:
|
|
14699
|
+
import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
14700
|
+
import { z as z38 } from "zod";
|
|
14701
|
+
var peerSchema = z38.object({
|
|
14702
|
+
name: z38.string(),
|
|
14703
|
+
publicKey: z38.string(),
|
|
14704
|
+
tunnelIp: z38.string()
|
|
13644
14705
|
});
|
|
13645
|
-
var peersFileSchema =
|
|
13646
|
-
peers:
|
|
14706
|
+
var peersFileSchema = z38.object({
|
|
14707
|
+
peers: z38.array(peerSchema)
|
|
13647
14708
|
}).superRefine((data, ctx) => {
|
|
13648
14709
|
const seenIps = new Set;
|
|
13649
14710
|
for (const peer of data.peers) {
|
|
13650
14711
|
if (seenIps.has(peer.tunnelIp)) {
|
|
13651
14712
|
ctx.addIssue({
|
|
13652
|
-
code:
|
|
14713
|
+
code: z38.ZodIssueCode.custom,
|
|
13653
14714
|
message: `Duplicate tunnel IP "${peer.tunnelIp}" \u2014 each peer must have a unique tunnel IP.`,
|
|
13654
14715
|
path: ["peers"]
|
|
13655
14716
|
});
|
|
@@ -13661,7 +14722,7 @@ var peersFileSchema = z32.object({
|
|
|
13661
14722
|
for (const peer of data.peers) {
|
|
13662
14723
|
if (seenNames.has(peer.name)) {
|
|
13663
14724
|
ctx.addIssue({
|
|
13664
|
-
code:
|
|
14725
|
+
code: z38.ZodIssueCode.custom,
|
|
13665
14726
|
message: `Duplicate name "${peer.name}" \u2014 each peer must have a unique name.`,
|
|
13666
14727
|
path: ["peers"]
|
|
13667
14728
|
});
|
|
@@ -13674,7 +14735,7 @@ function parsePeersJson(raw) {
|
|
|
13674
14735
|
return peersFileSchema.parse(raw);
|
|
13675
14736
|
}
|
|
13676
14737
|
async function readPeers(filePath) {
|
|
13677
|
-
const content = await
|
|
14738
|
+
const content = await readFile2(filePath, "utf-8");
|
|
13678
14739
|
const raw = JSON.parse(content);
|
|
13679
14740
|
return parsePeersJson(raw);
|
|
13680
14741
|
}
|
|
@@ -13691,7 +14752,7 @@ async function readPeersOrEmpty(filePath) {
|
|
|
13691
14752
|
async function writePeers(filePath, peersFile) {
|
|
13692
14753
|
const content = `${JSON.stringify(peersFile, null, 2)}
|
|
13693
14754
|
`;
|
|
13694
|
-
await
|
|
14755
|
+
await writeFile2(filePath, content, { mode: 384 });
|
|
13695
14756
|
}
|
|
13696
14757
|
var TUNNEL_SUBNET_PREFIX = "10.8.0.";
|
|
13697
14758
|
var SERVER_OCTET = 1;
|
|
@@ -13874,8 +14935,8 @@ async function vpnClientAdd(name, opts) {
|
|
|
13874
14935
|
allowedIps,
|
|
13875
14936
|
dns
|
|
13876
14937
|
});
|
|
13877
|
-
await
|
|
13878
|
-
await
|
|
14938
|
+
await mkdir2(clientsDir, { recursive: true });
|
|
14939
|
+
await writeFile3(confPath, confContent, { mode: 384 });
|
|
13879
14940
|
const peersJson = `${JSON.stringify(updatedPeersFile, null, 2)}
|
|
13880
14941
|
`;
|
|
13881
14942
|
await syncPeersSecret(peersJson, spawnFn, syncTimeoutMs);
|
|
@@ -13897,13 +14958,13 @@ async function vpnClientRemove(name, peersJsonPath, opts = {}) {
|
|
|
13897
14958
|
await syncPeersSecret(peersJson, spawnFn, syncTimeoutMs);
|
|
13898
14959
|
}
|
|
13899
14960
|
function resolveDefaultPeersJsonPath() {
|
|
13900
|
-
return
|
|
14961
|
+
return join9(findRepoRoot(), "apps", "vpn", "config", "peers.json");
|
|
13901
14962
|
}
|
|
13902
14963
|
function resolveDefaultClientsDir() {
|
|
13903
|
-
return
|
|
14964
|
+
return join9(findRepoRoot(), "apps", "vpn", "clients");
|
|
13904
14965
|
}
|
|
13905
14966
|
function registerVpnClient(cli) {
|
|
13906
|
-
cli.command("vpn client add <name>", "Add a new VPN client peer. Generates a keypair, assigns a tunnel IP, writes the client .conf to apps/vpn/clients/, and syncs the roster to the VPN_PEERS GitHub secret (best-effort).").option("--split-tunnel [cidrs]",
|
|
14967
|
+
cli.command("vpn client add <name>", "Add a new VPN client peer. Generates a keypair, assigns a tunnel IP, writes the client .conf to apps/vpn/clients/, and syncs the roster to the VPN_PEERS GitHub secret (best-effort).").option("--split-tunnel [cidrs]", z39.string().describe("Comma-separated CIDRs for split-tunnel routing. Defaults to 0.0.0.0/0 (full tunnel).")).option("--dns [dns]", z39.string().describe("DNS server for the client config.")).option("--server-pubkey [key]", z39.string().describe("Server public key. Falls back to VPN_SERVER_PUBKEY env var.")).option("--endpoint [host]", z39.string().describe("VPN server endpoint (IP or hostname). Falls back to VPN_HOST env var.")).example('# Add a new client named "laptop"').example("infra vpn client add laptop").example("# Add a split-tunnel client").example("infra vpn client add laptop --split-tunnel 10.0.0.0/8,192.168.0.0/16").action(async (name, options) => {
|
|
13907
14968
|
const serverPublicKey = options.serverPubkey ?? process.env.VPN_SERVER_PUBKEY;
|
|
13908
14969
|
const endpoint = options.endpoint ?? process.env.VPN_HOST;
|
|
13909
14970
|
if (!serverPublicKey) {
|
|
@@ -13967,7 +15028,7 @@ function registerVpnClient(cli) {
|
|
|
13967
15028
|
}
|
|
13968
15029
|
|
|
13969
15030
|
// src/commands/vpn/deploy.ts
|
|
13970
|
-
import { z as
|
|
15031
|
+
import { z as z40 } from "zod";
|
|
13971
15032
|
var REPO7 = "marcusrbrown/infra";
|
|
13972
15033
|
var WORKFLOW_NAME7 = "Deploy VPN";
|
|
13973
15034
|
var WORKFLOW_URL7 = "https://github.com/marcusrbrown/infra/actions/workflows/deploy-vpn.yaml";
|
|
@@ -13997,7 +15058,7 @@ function validateVpnRemotePreconditions() {
|
|
|
13997
15058
|
}
|
|
13998
15059
|
}
|
|
13999
15060
|
function registerVpnDeploy(cli) {
|
|
14000
|
-
cli.command("vpn deploy", "Deploy the VPN. Default mode triggers the GitHub Deploy VPN workflow, while --local runs apps/vpn deploy directly with Bun.").option("--local",
|
|
15061
|
+
cli.command("vpn deploy", "Deploy the VPN. Default mode triggers the GitHub Deploy VPN workflow, while --local runs apps/vpn deploy directly with Bun.").option("--local", z40.boolean().default(false).describe("Run local deployment with Bun using apps/vpn instead of triggering GitHub Actions.")).option("--dry-run", z40.boolean().default(false).describe("Validate deploy prerequisites and print planned actions without executing local deploy or dispatching workflow.")).option("--force-server-key", z40.boolean().default(false).describe("Force regeneration of the server WireGuard keypair. WARNING: invalidates all existing client configs.")).example("# Trigger remote GitHub Actions deploy (default mode)").example("infra vpn deploy").example("# Validate local deploy preconditions and planned command").example("infra vpn deploy --local --dry-run").example("# Run local deploy with explicit SSH agent context").example("infra vpn deploy --local").action(async (options) => {
|
|
14001
15062
|
if (options.local) {
|
|
14002
15063
|
const command = [
|
|
14003
15064
|
"bun",
|
|
@@ -14045,7 +15106,7 @@ function registerVpnDeploy(cli) {
|
|
|
14045
15106
|
}
|
|
14046
15107
|
|
|
14047
15108
|
// src/commands/vpn/logs.ts
|
|
14048
|
-
import { z as
|
|
15109
|
+
import { z as z41 } from "zod";
|
|
14049
15110
|
|
|
14050
15111
|
// src/commands/vpn/host.ts
|
|
14051
15112
|
var VALID_HOST_RE6 = /^[a-z\d][a-z\d.\-]*$/i;
|
|
@@ -14101,7 +15162,7 @@ async function streamVpnLogs(opts, spawn = defaultLogsSpawn5, printOut, printErr
|
|
|
14101
15162
|
return { refused: false, exitCode };
|
|
14102
15163
|
}
|
|
14103
15164
|
function registerVpnLogs(cli) {
|
|
14104
|
-
cli.command("vpn logs", "Stream WireGuard VPN logs via SSH and journalctl.").option("--tail [n]",
|
|
15165
|
+
cli.command("vpn logs", "Stream WireGuard VPN logs via SSH and journalctl.").option("--tail [n]", z41.number().default(100).describe("Number of log lines to tail.")).option("--allow-ci", z41.boolean().default(false).describe("Allow log streaming in CI environments. Logs may contain sensitive VPN data.")).option("--key [key]", z41.string().describe("Environment variable name holding the SSH host. Falls back to VPN_HOST when omitted.")).action(async (options) => {
|
|
14105
15166
|
const hostEnvKey = options.key ?? "VPN_HOST";
|
|
14106
15167
|
const host = process.env[hostEnvKey];
|
|
14107
15168
|
if (!host) {
|
|
@@ -14123,7 +15184,7 @@ function registerVpnLogs(cli) {
|
|
|
14123
15184
|
}
|
|
14124
15185
|
|
|
14125
15186
|
// src/commands/vpn/status.ts
|
|
14126
|
-
import { z as
|
|
15187
|
+
import { z as z42 } from "zod";
|
|
14127
15188
|
|
|
14128
15189
|
// src/commands/vpn/shared.ts
|
|
14129
15190
|
function parseWgShowOutput(output) {
|
|
@@ -14143,7 +15204,7 @@ function parseWgShowOutput(output) {
|
|
|
14143
15204
|
function defaultSpawn7(cmd, opts) {
|
|
14144
15205
|
return Bun.spawn(cmd, opts);
|
|
14145
15206
|
}
|
|
14146
|
-
async function getVpnWgStatus(host, spawn = defaultSpawn7) {
|
|
15207
|
+
async function getVpnWgStatus(host, spawn = defaultSpawn7, signal) {
|
|
14147
15208
|
validateVpnHost(host);
|
|
14148
15209
|
const knownHostsArgs = buildKnownHostsArgs();
|
|
14149
15210
|
const { args: identityArgs, cleanup } = buildIdentityArgs(process.env.VPN_SSH_KEY);
|
|
@@ -14167,7 +15228,7 @@ async function getVpnWgStatus(host, spawn = defaultSpawn7) {
|
|
|
14167
15228
|
let stderrText;
|
|
14168
15229
|
let exitCode;
|
|
14169
15230
|
try {
|
|
14170
|
-
const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe" });
|
|
15231
|
+
const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe", ...signal === undefined ? {} : { signal } });
|
|
14171
15232
|
[stdoutText, stderrText, exitCode] = await Promise.all([
|
|
14172
15233
|
new Response(child.stdout).text(),
|
|
14173
15234
|
new Response(child.stderr).text(),
|
|
@@ -14180,35 +15241,26 @@ async function getVpnWgStatus(host, spawn = defaultSpawn7) {
|
|
|
14180
15241
|
const redacted = redactHost(stderrText.trim(), host) || "unknown error";
|
|
14181
15242
|
return {
|
|
14182
15243
|
ok: false,
|
|
14183
|
-
error: `SSH command failed (exit ${exitCode}): ${redacted}
|
|
15244
|
+
error: `SSH command failed (exit ${exitCode}): ${redacted}`,
|
|
15245
|
+
exitCode
|
|
14184
15246
|
};
|
|
14185
15247
|
}
|
|
14186
15248
|
const wgShow = parseWgShowOutput(stdoutText);
|
|
14187
15249
|
return { ok: wgShow.interfaceUp, wgShow };
|
|
14188
15250
|
}
|
|
14189
|
-
|
|
14190
|
-
|
|
15251
|
+
var SSH_FAILURE_EXIT_CODE = 255;
|
|
15252
|
+
async function getVpnStatusSummary(host, spawn, signal) {
|
|
15253
|
+
const result = await getVpnWgStatus(host, spawn, signal);
|
|
15254
|
+
const spec = declaredObservation("vpn", "wireguard-interface");
|
|
15255
|
+
if (result.exitCode === SSH_FAILURE_EXIT_CODE) {
|
|
15256
|
+
return { observations: [observe(spec, "unknown", { detail: { reason: "ssh-failed" } })], facts: {} };
|
|
15257
|
+
}
|
|
14191
15258
|
if (!result.ok || !result.wgShow) {
|
|
14192
|
-
|
|
14193
|
-
return {
|
|
14194
|
-
app: "vpn",
|
|
14195
|
-
http: `ERROR: ${errorMsg}`,
|
|
14196
|
-
lastDeploy: "\u2014",
|
|
14197
|
-
version: "\u2014",
|
|
14198
|
-
contentHash: "\u2014",
|
|
14199
|
-
usageStats: "\u2014"
|
|
14200
|
-
};
|
|
15259
|
+
return { observations: [observe(spec, "fail", { detail: { reason: "interface-down" } })], facts: {} };
|
|
14201
15260
|
}
|
|
14202
|
-
const { wgShow } = result;
|
|
14203
|
-
const pubKeyInfo = wgShow.serverPublicKey ? ` pubkey:${wgShow.serverPublicKey}` : "";
|
|
14204
|
-
const peerInfo = `${wgShow.peerCount} peer(s)`;
|
|
14205
15261
|
return {
|
|
14206
|
-
|
|
14207
|
-
|
|
14208
|
-
lastDeploy: "\u2014",
|
|
14209
|
-
version: "\u2014",
|
|
14210
|
-
contentHash: "\u2014",
|
|
14211
|
-
usageStats: "\u2014"
|
|
15262
|
+
observations: [observe(spec, "pass")],
|
|
15263
|
+
facts: { peerCount: result.wgShow.peerCount, serverPublicKey: result.wgShow.serverPublicKey ?? null }
|
|
14212
15264
|
};
|
|
14213
15265
|
}
|
|
14214
15266
|
|
|
@@ -14250,7 +15302,7 @@ async function vpnStatusAction(options, ctx, spawn) {
|
|
|
14250
15302
|
}
|
|
14251
15303
|
}
|
|
14252
15304
|
function registerVpnStatus(cli) {
|
|
14253
|
-
cli.command("vpn status", "Show operational health of the VPN box via SSH and wg show.").option("--key [key]",
|
|
15305
|
+
cli.command("vpn status", "Show operational health of the VPN box via SSH and wg show.").option("--key [key]", z42.string().describe("Environment variable name holding the SSH host. Falls back to VPN_HOST when omitted.")).action((options, ctx) => vpnStatusAction(options, ctx));
|
|
14254
15306
|
}
|
|
14255
15307
|
|
|
14256
15308
|
// src/commands/vpn/index.ts
|
|
@@ -14263,101 +15315,167 @@ function registerVpnCommands(cli) {
|
|
|
14263
15315
|
|
|
14264
15316
|
// src/commands/status.ts
|
|
14265
15317
|
var DEFAULT_CLIPROXY_URL9 = "https://cliproxy.fro.bot";
|
|
14266
|
-
var
|
|
15318
|
+
var COLLECTOR_DEADLINE_MS = 30000;
|
|
15319
|
+
var defaultDependencies = {
|
|
15320
|
+
getKeewebStatusSummary,
|
|
15321
|
+
getCliproxyStatusSummary: (baseUrl, key, signal) => getCliproxyStatusSummary(baseUrl, key, undefined, signal),
|
|
15322
|
+
getGatewayStatusSummary: (host, signal) => getGatewayStatusSummary(host, undefined, signal),
|
|
15323
|
+
getUmamiStatusSummary: (host, signal) => getUmamiStatusSummary(host, undefined, signal),
|
|
15324
|
+
getDashboardStatusSummary: (host, signal) => getDashboardStatusSummary(host, undefined, signal),
|
|
15325
|
+
getVpnStatusSummary: (host, signal) => getVpnStatusSummary(host, undefined, signal),
|
|
15326
|
+
publicProbes: PUBLIC_PROBES,
|
|
15327
|
+
readDeployStates,
|
|
15328
|
+
readMonitorState,
|
|
15329
|
+
now: () => new Date,
|
|
15330
|
+
collectorDeadlineMs: COLLECTOR_DEADLINE_MS
|
|
15331
|
+
};
|
|
14267
15332
|
var TABLE_COLUMNS = [
|
|
14268
|
-
{
|
|
14269
|
-
{
|
|
14270
|
-
{
|
|
14271
|
-
{
|
|
14272
|
-
{
|
|
14273
|
-
{ key: "usageStats", label: "Usage Stats" }
|
|
15333
|
+
{ kind: "reachability", label: "Reachability" },
|
|
15334
|
+
{ kind: "container-health", label: "Container" },
|
|
15335
|
+
{ kind: "deploy-state", label: "Deploy" },
|
|
15336
|
+
{ kind: "end-to-end", label: "End-to-end" },
|
|
15337
|
+
{ kind: "resource", label: "Resource" }
|
|
14274
15338
|
];
|
|
14275
|
-
|
|
15339
|
+
var RESULT_CELLS = {
|
|
15340
|
+
pass: "\u2705 pass",
|
|
15341
|
+
fail: "\u274C fail",
|
|
15342
|
+
unknown: "\u2753 unknown",
|
|
15343
|
+
not_measured: "\u26AA not measured"
|
|
15344
|
+
};
|
|
15345
|
+
var UNDECLARED_CELL = "\u2014";
|
|
15346
|
+
function stripTrailingSlash8(value) {
|
|
14276
15347
|
return value.endsWith("/") ? value.slice(0, -1) : value;
|
|
14277
15348
|
}
|
|
14278
|
-
function
|
|
14279
|
-
const
|
|
14280
|
-
|
|
14281
|
-
|
|
14282
|
-
|
|
14283
|
-
http: errorCell,
|
|
14284
|
-
lastDeploy: errorCell,
|
|
14285
|
-
version: errorCell,
|
|
14286
|
-
contentHash: errorCell,
|
|
14287
|
-
usageStats: errorCell
|
|
14288
|
-
};
|
|
15349
|
+
function brokerBaseUrl(domain) {
|
|
15350
|
+
const trimmed = domain?.trim() ?? "";
|
|
15351
|
+
if (trimmed === "")
|
|
15352
|
+
return;
|
|
15353
|
+
return /^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
14289
15354
|
}
|
|
14290
|
-
function
|
|
14291
|
-
return
|
|
14292
|
-
|
|
14293
|
-
|
|
14294
|
-
|
|
14295
|
-
|
|
14296
|
-
|
|
14297
|
-
|
|
14298
|
-
|
|
15355
|
+
function describeReason(reason) {
|
|
15356
|
+
return reason instanceof Error ? reason.message : String(reason);
|
|
15357
|
+
}
|
|
15358
|
+
var TIMED_OUT = Symbol("collector-timed-out");
|
|
15359
|
+
function hostCollector(host, run) {
|
|
15360
|
+
return host === "" ? { kind: "missing-config" } : { kind: "run", run: (signal) => run(host, signal) };
|
|
15361
|
+
}
|
|
15362
|
+
async function withDeadline(run, deadlineMs) {
|
|
15363
|
+
const controller = new AbortController;
|
|
15364
|
+
let timer;
|
|
15365
|
+
const deadline = new Promise((resolve) => {
|
|
15366
|
+
timer = setTimeout(() => {
|
|
15367
|
+
controller.abort();
|
|
15368
|
+
resolve(TIMED_OUT);
|
|
15369
|
+
}, deadlineMs);
|
|
15370
|
+
});
|
|
15371
|
+
try {
|
|
15372
|
+
const work = run.run(controller.signal);
|
|
15373
|
+
work.catch(() => {
|
|
15374
|
+
return;
|
|
15375
|
+
});
|
|
15376
|
+
return await Promise.race([work, deadline]);
|
|
15377
|
+
} finally {
|
|
15378
|
+
clearTimeout(timer);
|
|
15379
|
+
}
|
|
15380
|
+
}
|
|
15381
|
+
async function collectApp(app, probe, collector, options) {
|
|
15382
|
+
const { ctx, now, deadlineMs, json, githubReads } = options;
|
|
15383
|
+
const [probeResult, collectorResult] = await Promise.allSettled([
|
|
15384
|
+
probe === undefined ? Promise.resolve(undefined) : probe(),
|
|
15385
|
+
collector.kind === "run" ? withDeadline(collector, deadlineMs) : Promise.resolve(undefined)
|
|
15386
|
+
]);
|
|
15387
|
+
const logFailure = (message) => {
|
|
15388
|
+
if (!json)
|
|
15389
|
+
ctx.console.error(`${app} ${message}`);
|
|
14299
15390
|
};
|
|
15391
|
+
let probeOutcome = { kind: "none" };
|
|
15392
|
+
if (probeResult.status === "rejected") {
|
|
15393
|
+
logFailure(`status check failed: ${describeReason(probeResult.reason)}`);
|
|
15394
|
+
probeOutcome = { kind: "failed" };
|
|
15395
|
+
} else if (probeResult.value !== undefined) {
|
|
15396
|
+
probeOutcome = { kind: "ok", observation: probeResult.value };
|
|
15397
|
+
}
|
|
15398
|
+
let collectorOutcome = { kind: collector.kind === "run" ? "none" : collector.kind };
|
|
15399
|
+
if (collectorResult.status === "rejected") {
|
|
15400
|
+
logFailure(`status check failed: ${describeReason(collectorResult.reason)}`);
|
|
15401
|
+
collectorOutcome = { kind: "failed" };
|
|
15402
|
+
} else if (collectorResult.value === TIMED_OUT) {
|
|
15403
|
+
logFailure(`status check timed out after ${deadlineMs}ms`);
|
|
15404
|
+
collectorOutcome = { kind: "timeout" };
|
|
15405
|
+
} else if (collectorResult.value !== undefined) {
|
|
15406
|
+
collectorOutcome = { kind: "ok", collection: collectorResult.value };
|
|
15407
|
+
}
|
|
15408
|
+
return assembleAppStatus(app, probeOutcome, collectorOutcome, now(), githubObservationsFor(app, await githubReads, now()));
|
|
15409
|
+
}
|
|
15410
|
+
function formatRow(app) {
|
|
15411
|
+
const cells = TABLE_COLUMNS.map(({ kind }) => {
|
|
15412
|
+
const worst = worstResult(app, kind);
|
|
15413
|
+
return worst === undefined ? UNDECLARED_CELL : RESULT_CELLS[worst];
|
|
15414
|
+
});
|
|
15415
|
+
return `| ${[app.app, ...cells].join(" | ")} |`;
|
|
14300
15416
|
}
|
|
14301
|
-
function
|
|
14302
|
-
|
|
14303
|
-
return `| ${values.join(" | ")} |`;
|
|
15417
|
+
function formatHeader() {
|
|
15418
|
+
return `| ${["App", ...TABLE_COLUMNS.map(({ label }) => label)].join(" | ")} |`;
|
|
14304
15419
|
}
|
|
14305
|
-
async function unifiedStatusAction(options, ctx, dependencies = {
|
|
14306
|
-
|
|
14307
|
-
getCliproxyStatusSummary,
|
|
14308
|
-
getGatewayStatusSummary,
|
|
14309
|
-
getUmamiStatusSummary,
|
|
14310
|
-
getDashboardStatusSummary,
|
|
14311
|
-
getVpnStatusSummary,
|
|
14312
|
-
getBrokerStatusSummary
|
|
14313
|
-
}) {
|
|
14314
|
-
const verbose = options.verbose === true;
|
|
14315
|
-
const cliproxyBaseUrl = stripTrailingSlash7(process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL9);
|
|
15420
|
+
async function unifiedStatusAction(options, ctx, dependencies = defaultDependencies) {
|
|
15421
|
+
const cliproxyBaseUrl = stripTrailingSlash8(process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL9);
|
|
14316
15422
|
const cliproxyKey = process.env.CLIPROXY_MANAGEMENT_KEY ?? "";
|
|
14317
15423
|
const gatewayHost = process.env.GATEWAY_HOST ?? "";
|
|
14318
15424
|
const umamiHost = process.env.UMAMI_DOMAIN ?? "";
|
|
14319
15425
|
const dashboardHost = process.env.DASHBOARD_DOMAIN ?? "";
|
|
14320
15426
|
const vpnHost = process.env.VPN_HOST ?? "";
|
|
14321
|
-
const
|
|
14322
|
-
|
|
14323
|
-
|
|
14324
|
-
|
|
14325
|
-
|
|
14326
|
-
|
|
14327
|
-
dependencies.
|
|
14328
|
-
dependencies.
|
|
14329
|
-
dependencies.
|
|
14330
|
-
|
|
14331
|
-
|
|
14332
|
-
|
|
14333
|
-
|
|
14334
|
-
|
|
14335
|
-
|
|
14336
|
-
|
|
14337
|
-
|
|
14338
|
-
|
|
14339
|
-
|
|
14340
|
-
|
|
15427
|
+
const collectors = {
|
|
15428
|
+
keeweb: { kind: "run", run: (signal) => dependencies.getKeewebStatusSummary(signal) },
|
|
15429
|
+
cliproxy: {
|
|
15430
|
+
kind: "run",
|
|
15431
|
+
run: (signal) => dependencies.getCliproxyStatusSummary(cliproxyBaseUrl, cliproxyKey, signal)
|
|
15432
|
+
},
|
|
15433
|
+
gateway: hostCollector(gatewayHost, dependencies.getGatewayStatusSummary),
|
|
15434
|
+
umami: hostCollector(umamiHost, dependencies.getUmamiStatusSummary),
|
|
15435
|
+
dashboard: hostCollector(dashboardHost, dependencies.getDashboardStatusSummary),
|
|
15436
|
+
vpn: hostCollector(vpnHost, dependencies.getVpnStatusSummary),
|
|
15437
|
+
broker: { kind: "none" }
|
|
15438
|
+
};
|
|
15439
|
+
const probeBaseUrls = {
|
|
15440
|
+
cliproxy: cliproxyBaseUrl,
|
|
15441
|
+
broker: brokerBaseUrl(process.env.BROKER_DOMAIN)
|
|
15442
|
+
};
|
|
15443
|
+
const json = options.json === true;
|
|
15444
|
+
const githubReads = runGithubReads(dependencies, {
|
|
15445
|
+
token: resolveGithubToken(process.env),
|
|
15446
|
+
now: dependencies.now(),
|
|
15447
|
+
...dependencies.fetch === undefined ? {} : { fetch: dependencies.fetch }
|
|
14341
15448
|
});
|
|
14342
|
-
|
|
14343
|
-
|
|
15449
|
+
const apps = await Promise.all(APP_IDS.map((app) => {
|
|
15450
|
+
const probe = dependencies.publicProbes[app];
|
|
15451
|
+
const baseUrl = probeBaseUrls[app];
|
|
15452
|
+
const probeOptions = baseUrl === undefined ? undefined : { baseUrl };
|
|
15453
|
+
return collectApp(app, probe && (() => probe(probeOptions)), collectors[app], {
|
|
15454
|
+
ctx,
|
|
15455
|
+
now: dependencies.now,
|
|
15456
|
+
deadlineMs: dependencies.collectorDeadlineMs,
|
|
15457
|
+
json,
|
|
15458
|
+
githubReads
|
|
15459
|
+
});
|
|
15460
|
+
}));
|
|
15461
|
+
if (json) {
|
|
15462
|
+
const envelope = {
|
|
15463
|
+
schemaVersion: STATUS_SCHEMA_VERSION,
|
|
15464
|
+
generatedAt: dependencies.now().toISOString(),
|
|
15465
|
+
apps
|
|
15466
|
+
};
|
|
15467
|
+
ctx.console.log(JSON.stringify(envelope));
|
|
14344
15468
|
return;
|
|
14345
15469
|
}
|
|
14346
|
-
ctx.console.log(
|
|
14347
|
-
for (const
|
|
14348
|
-
ctx.console.log(formatRow(
|
|
15470
|
+
ctx.console.log(formatHeader());
|
|
15471
|
+
for (const app of apps) {
|
|
15472
|
+
ctx.console.log(formatRow(app));
|
|
14349
15473
|
}
|
|
14350
15474
|
}
|
|
14351
|
-
function registerStatus(cli, dependencies = {
|
|
14352
|
-
|
|
14353
|
-
|
|
14354
|
-
|
|
14355
|
-
getUmamiStatusSummary,
|
|
14356
|
-
getDashboardStatusSummary,
|
|
14357
|
-
getVpnStatusSummary,
|
|
14358
|
-
getBrokerStatusSummary
|
|
14359
|
-
}) {
|
|
14360
|
-
cli.command("status", "Show status of all deployments").option("--json", z37.boolean().describe("Output machine-readable JSON with keeweb, cliproxy, gateway, umami, dashboard, vpn, and broker summary objects. The gateway object also carries a structured workspace field.")).option("--verbose", z37.boolean().describe("Include verbose per-app health check details when building the summary rows.")).action((options, ctx) => unifiedStatusAction(options, ctx, dependencies));
|
|
15475
|
+
function registerStatus(cli, dependencies = defaultDependencies) {
|
|
15476
|
+
cli.command("status", "Show status of all deployments").option("--json", z43.boolean().describe("Output a schema-versioned JSON envelope: schemaVersion, generatedAt, and one entry per app with typed observations (id, kind, result, observedAt, source) and facts. The gateway workspace memory status is in facts.workspace.")).action((options, ctx) => unifiedStatusAction(options, ctx, dependencies));
|
|
15477
|
+
registerStatusSnapshot(cli, dependencies);
|
|
15478
|
+
registerStatusHistoryLocate(cli, dependencies);
|
|
14361
15479
|
}
|
|
14362
15480
|
|
|
14363
15481
|
// src/cli.ts
|