@astrofoundry/pi-astro 0.34.1 → 0.36.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 +3 -3
- package/agents/arcane.md +3 -1
- package/agents/inference.md +4 -5
- package/agents/release-reviewer.md +2 -2
- package/extensions/astro-discord/daily-report.test.ts +153 -77
- package/extensions/astro-discord/daily-report.ts +322 -53
- package/extensions/astro-discord/index.ts +180 -51
- package/extensions/astro-discord/reports.ts +0 -2
- package/extensions/astro-discord/tools-report.test.ts +78 -13
- package/extensions/astro-discord/tools-report.ts +123 -40
- package/extensions/specialist-gate/approvals.test.ts +4 -1
- package/extensions/specialist-gate/index.ts +1 -1
- package/package.json +1 -1
- package/skills/arcane/SKILL.md +16 -3
- package/skills/edge/SKILL.md +1 -1
- package/skills/inference/SKILL.md +3 -27
- package/skills/proxmox/SKILL.md +2 -2
- package/skills/security/SKILL.md +2 -2
- package/specialists/AGENTS.md +8 -7
- package/specialists/README.md +24 -18
- package/specialists/arcane/run.ts +223 -10
- package/specialists/edge/run.ts +3 -3
- package/specialists/entry/remote/spc-arcane-install.sh +2 -2
- package/specialists/entry/remote/spc-arcane-op.py +3 -0
- package/specialists/entry/specialist-install.sh +266 -8
- package/specialists/entry/specialist-install.test.ts +50 -1
- package/specialists/inference/run.ts +12 -94
- package/specialists/lib/arcane-host.test.ts +24 -1
- package/specialists/lib/arcane-host.ts +6 -2
- package/specialists/lib/deployment.ts +1 -1
- package/specialists/lib/stack-update.test.ts +101 -0
- package/specialists/lib/stack-update.ts +115 -0
- package/specialists/provision/provision.sh +2 -2
- package/specialists/wrappers.test.ts +7 -16
- package/specialists/entry/remote/spc-inference-hermes-entry.sh +0 -27
- package/specialists/entry/remote/spc-inference-pulsar-entry.sh +0 -14
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { UpdateRow } from "./image-updates.ts";
|
|
3
|
+
import { commitMessage, containersProblem, crossesMajor, nextImageRef, parseHolds, pendingRows, replaceImageRef, type StackContainer } from "./stack-update.ts";
|
|
4
|
+
|
|
5
|
+
const A = `sha256:${"a".repeat(64)}`;
|
|
6
|
+
const B = `sha256:${"b".repeat(64)}`;
|
|
7
|
+
|
|
8
|
+
function row(over: Partial<UpdateRow>): UpdateRow {
|
|
9
|
+
return { stack: "s", service: "svc", ref: "nginx:1.31.4-alpine", image: "nginx:1.31.4-alpine", kind: "fixed", running: "1.31.4-alpine", latest: "1.31.6-alpine", status: "release", action: "edit tag", note: "", ...over };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("update holds", () => {
|
|
13
|
+
it("reads stack names with a reason", () => {
|
|
14
|
+
expect(parseHolds('{"supabase": " coordinated release: review first "}')).toEqual({ supabase: "coordinated release: review first" });
|
|
15
|
+
expect(parseHolds("{}")).toEqual({});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("refuses a malformed file instead of holding nothing", () => {
|
|
19
|
+
expect(() => parseHolds("not json")).toThrow(/not JSON/);
|
|
20
|
+
expect(() => parseHolds('["supabase"]')).toThrow(/object of stack name/);
|
|
21
|
+
expect(() => parseHolds('{"supabase": ""}')).toThrow(/non-empty reason/);
|
|
22
|
+
expect(() => parseHolds('{"../x": "why"}')).toThrow(/stack name/);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("pending updates", () => {
|
|
27
|
+
it("keeps the rows an update changes and spots a major version", () => {
|
|
28
|
+
const rows = [row({ status: "update" }), row({ status: "release" }), row({ status: "rebuild" }), row({ status: "up-to-date" }), row({ status: "unknown" })];
|
|
29
|
+
expect(pendingRows(rows).map((r) => r.status)).toEqual(["update", "release", "rebuild"]);
|
|
30
|
+
expect(crossesMajor("2.14.3", "3.0.0")).toBe(true);
|
|
31
|
+
expect(crossesMajor("v2.16.1", "v2.17.0")).toBe(false);
|
|
32
|
+
expect(crossesMajor("older build", "3.0.0")).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("moves a pinned tag or digest and leaves pulls to the redeploy", () => {
|
|
36
|
+
expect(nextImageRef(row({}))).toBe("nginx:1.31.6-alpine");
|
|
37
|
+
expect(nextImageRef(row({ ref: "registry.local:5000/app:1.2", latest: "1.3" }))).toBe("registry.local:5000/app:1.3");
|
|
38
|
+
expect(nextImageRef(row({ ref: `ghcr.io/x/y@${A}`, action: "edit digest", latestDigest: B }))).toBe(`ghcr.io/x/y@${B}`);
|
|
39
|
+
expect(nextImageRef(row({ ref: `ghcr.io/x/y:v1@${A}`, action: "edit digest", latestDigest: B }))).toBe(`ghcr.io/x/y:v1@${B}`);
|
|
40
|
+
expect(nextImageRef(row({ action: "pull" }))).toBeNull();
|
|
41
|
+
expect(() => nextImageRef(row({ latest: "new build" }))).toThrow(/cannot move/);
|
|
42
|
+
expect(() => nextImageRef(row({ ref: `ghcr.io/x/y@${A}`, action: "edit digest", latestDigest: "sha256:short" }))).toThrow(/new digest/);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("Compose rewrite", () => {
|
|
47
|
+
const compose = [
|
|
48
|
+
"services:",
|
|
49
|
+
" db:",
|
|
50
|
+
" image: postgres:18",
|
|
51
|
+
" quoted:",
|
|
52
|
+
' image: "postgres:18" # pinned on purpose',
|
|
53
|
+
" single:",
|
|
54
|
+
" image: 'postgres:18'",
|
|
55
|
+
" longer:",
|
|
56
|
+
" image: postgres:18.1",
|
|
57
|
+
" other:",
|
|
58
|
+
" image: mypostgres:18",
|
|
59
|
+
" mixed:",
|
|
60
|
+
" image: \"postgres:18'",
|
|
61
|
+
" env:",
|
|
62
|
+
" environment:",
|
|
63
|
+
" IMAGE: postgres:18",
|
|
64
|
+
" image_note: image: postgres:18",
|
|
65
|
+
"",
|
|
66
|
+
].join("\n");
|
|
67
|
+
|
|
68
|
+
it("changes only image lines that name exactly the reference", () => {
|
|
69
|
+
const next = replaceImageRef(compose, "postgres:18", "postgres:19").split("\n");
|
|
70
|
+
expect(next[2]).toBe(" image: postgres:19");
|
|
71
|
+
expect(next[4]).toBe(' image: "postgres:19" # pinned on purpose');
|
|
72
|
+
expect(next[6]).toBe(" image: 'postgres:19'");
|
|
73
|
+
expect(next.slice(7)).toEqual(compose.split("\n").slice(7));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("treats the reference as text, not a pattern, and refuses a file without it", () => {
|
|
77
|
+
expect(replaceImageRef(" image: a.b:1+x\n image: aXb:1+x\n", "a.b:1+x", "a.b:2")).toBe(" image: a.b:2\n image: aXb:1+x\n");
|
|
78
|
+
expect(() => replaceImageRef(compose, "postgres:17", "postgres:18")).toThrow(/no "image: postgres:17" line/);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("stack health after a redeploy", () => {
|
|
83
|
+
const up = (name: string, over: Partial<StackContainer> = {}): StackContainer => ({ name, running: true, state: "running", exitCode: null, health: "healthy", ...over });
|
|
84
|
+
|
|
85
|
+
it("passes running containers and finished one-shot jobs", () => {
|
|
86
|
+
expect(containersProblem([up("db"), up("app", { health: "none" }), up("init", { running: false, state: "exited", exitCode: 0 })], 3)).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("names missing, stopped, unhealthy, and starting containers", () => {
|
|
90
|
+
expect(containersProblem([up("db")], 2)).toBe("1 of 2 services have a container");
|
|
91
|
+
expect(containersProblem([up("db"), up("init", { running: false, state: "exited", exitCode: 1 })], 2)).toBe("not running: init");
|
|
92
|
+
expect(containersProblem([up("db", { health: "unhealthy" }), up("app", { health: "starting" })], 2)).toBe("health: db (unhealthy), app (starting)");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("commit message", () => {
|
|
97
|
+
it("names the stack and every moved reference on one short line", () => {
|
|
98
|
+
expect(commitMessage("media", [{ service: "sonarr", from: "a:1", to: "a:2" }, { service: "radarr", from: "b:1", to: "b:2" }])).toBe("media: sonarr a:1 -> a:2, radarr b:1 -> b:2");
|
|
99
|
+
expect(commitMessage("s", Array.from({ length: 20 }, () => ({ service: "x", from: "y:1", to: "y:2" }))).length).toBe(200);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { UsageError } from "./errors.ts";
|
|
2
|
+
import { parseTag, type UpdateRow } from "./image-updates.ts";
|
|
3
|
+
import { parseImageRef } from "./registry.ts";
|
|
4
|
+
|
|
5
|
+
/** Stacks whose updates wait for a person, tracked in the root of the GitOps repository: `{ "<stack>": "<reason>" }`. */
|
|
6
|
+
export const HOLDS_FILE = "update-holds.json";
|
|
7
|
+
const STACK = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
|
|
8
|
+
|
|
9
|
+
/** The held stacks with their reasons; a malformed file is an error, never "nothing held". */
|
|
10
|
+
export function parseHolds(text: string): Record<string, string> {
|
|
11
|
+
let value: unknown;
|
|
12
|
+
try {
|
|
13
|
+
value = JSON.parse(text);
|
|
14
|
+
} catch {
|
|
15
|
+
throw new UsageError(`${HOLDS_FILE} is not JSON`);
|
|
16
|
+
}
|
|
17
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new UsageError(`${HOLDS_FILE} must be an object of stack name to reason`);
|
|
18
|
+
const holds: Record<string, string> = {};
|
|
19
|
+
for (const [stack, reason] of Object.entries(value)) {
|
|
20
|
+
if (!STACK.test(stack) || typeof reason !== "string" || reason.trim().length === 0) throw new UsageError(`${HOLDS_FILE}: "${stack.slice(0, 40)}" needs a stack name and a non-empty reason`);
|
|
21
|
+
holds[stack] = reason.trim().slice(0, 200);
|
|
22
|
+
}
|
|
23
|
+
return holds;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** True when the update moves to a newer major version, read from the first number of both release tags. */
|
|
27
|
+
export function crossesMajor(running: string, latest: string): boolean {
|
|
28
|
+
const a = parseTag(running);
|
|
29
|
+
const b = parseTag(latest);
|
|
30
|
+
return a !== null && b !== null && b.nums[0] > a.nums[0];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The rows of one stack that an update changes. */
|
|
34
|
+
export function pendingRows(rows: readonly UpdateRow[]): UpdateRow[] {
|
|
35
|
+
return rows.filter((r) => r.status === "update" || r.status === "release" || r.status === "rebuild");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The image reference a pinned row moves to: the newer tag for `edit tag`, the
|
|
40
|
+
* newer digest for `edit digest`; null for rows a redeploy with a pull applies.
|
|
41
|
+
*/
|
|
42
|
+
export function nextImageRef(row: UpdateRow): string | null {
|
|
43
|
+
if (row.action === "edit tag") {
|
|
44
|
+
const ref = parseImageRef(row.ref);
|
|
45
|
+
if (ref.digest || !ref.tag || !parseTag(row.latest)) throw new UsageError(`${row.service}: cannot move ${row.ref} to tag ${row.latest}`);
|
|
46
|
+
if (!row.ref.endsWith(`:${ref.tag}`)) throw new UsageError(`${row.service}: ${row.ref} does not end with its tag`);
|
|
47
|
+
return `${row.ref.slice(0, row.ref.length - ref.tag.length)}${row.latest}`;
|
|
48
|
+
}
|
|
49
|
+
if (row.action === "edit digest") {
|
|
50
|
+
const at = row.ref.indexOf("@");
|
|
51
|
+
if (at < 0 || !row.latestDigest || !/^sha256:[0-9a-f]{64}$/.test(row.latestDigest)) throw new UsageError(`${row.service}: cannot move ${row.ref} to a new digest`);
|
|
52
|
+
return `${row.ref.slice(0, at)}@${row.latestDigest}`;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function escapeRegExp(text: string): string {
|
|
58
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The Compose file with every `image:` line that names exactly `oldRef`
|
|
63
|
+
* (quoted or not, with an optional trailing comment) changed to `newRef`.
|
|
64
|
+
* Any other mention of the reference, or a longer reference that starts with
|
|
65
|
+
* it, stays as it is. Refuses a file without such a line.
|
|
66
|
+
*/
|
|
67
|
+
export function replaceImageRef(compose: string, oldRef: string, newRef: string): string {
|
|
68
|
+
const line = new RegExp(`^(\\s*image:\\s*)(["']?)${escapeRegExp(oldRef)}\\2(\\s*(?:#.*)?)$`, "gm");
|
|
69
|
+
let count = 0;
|
|
70
|
+
const next = compose.replace(line, (_all, head: string, quote: string, tail: string) => {
|
|
71
|
+
count++;
|
|
72
|
+
return `${head}${quote}${newRef}${quote}${tail}`;
|
|
73
|
+
});
|
|
74
|
+
if (count === 0) throw new UsageError(`the Compose file has no "image: ${oldRef}" line`);
|
|
75
|
+
return next;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type StackStatus = "updated" | "rolled back" | "failed" | "nothing to do";
|
|
79
|
+
|
|
80
|
+
export interface ServiceResult {
|
|
81
|
+
service: string;
|
|
82
|
+
ref: string;
|
|
83
|
+
from: string;
|
|
84
|
+
to: string;
|
|
85
|
+
/** `pull` (redeploy with a pull) or the reference written into the Compose file. */
|
|
86
|
+
applied: string;
|
|
87
|
+
/** After the update: current, still behind, or not checked. */
|
|
88
|
+
result: "current" | "behind" | "unchecked";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** One container of the stack after a redeploy, as `containers get` reports it. */
|
|
92
|
+
export interface StackContainer {
|
|
93
|
+
name: string;
|
|
94
|
+
running: boolean | null;
|
|
95
|
+
state: string | null;
|
|
96
|
+
exitCode: number | null;
|
|
97
|
+
health: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Why the stack is not healthy yet, or null when every container runs (or, as
|
|
102
|
+
* a one-shot job, exited with code 0) and none is unhealthy or still starting.
|
|
103
|
+
*/
|
|
104
|
+
export function containersProblem(containers: readonly StackContainer[], expected: number): string | null {
|
|
105
|
+
if (containers.length < expected) return `${containers.length} of ${expected} services have a container`;
|
|
106
|
+
const down = containers.filter((c) => c.running !== true && !(c.state === "exited" && c.exitCode === 0)).map((c) => c.name);
|
|
107
|
+
if (down.length > 0) return `not running: ${down.join(", ")}`;
|
|
108
|
+
const sick = containers.filter((c) => c.health === "unhealthy" || c.health === "starting").map((c) => `${c.name} (${c.health})`);
|
|
109
|
+
return sick.length > 0 ? `health: ${sick.join(", ")}` : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A commit subject that names the stack and every reference it changes. */
|
|
113
|
+
export function commitMessage(stack: string, edits: readonly { service: string; from: string; to: string }[]): string {
|
|
114
|
+
return `${stack}: ${edits.map((e) => `${e.service} ${e.from} -> ${e.to}`).join(", ")}`.slice(0, 200);
|
|
115
|
+
}
|
|
@@ -12,8 +12,8 @@ client_file="$config_dir/client.env"
|
|
|
12
12
|
password_file="$config_dir/master-password"
|
|
13
13
|
root="$home/.specialists"
|
|
14
14
|
collection_name="${SPECIALISTS_COLLECTION:-specialists}"
|
|
15
|
-
BW=/
|
|
16
|
-
JQ=/
|
|
15
|
+
BW=/usr/local/libexec/specialist-bw/current/bw
|
|
16
|
+
JQ=/usr/local/libexec/specialist-jq/current/jq
|
|
17
17
|
|
|
18
18
|
for f in "$client_file" "$password_file"; do
|
|
19
19
|
[ -f "$f" ] || { echo "missing $f" >&2; exit 2; }
|
|
@@ -7,7 +7,7 @@ import { buildBackupRemote, command as backupCommand } from "./backup/run.ts";
|
|
|
7
7
|
import { parseParams, parseRecordFilters, technitiumRequest, command as dnsCommand } from "./dns/run.ts";
|
|
8
8
|
import { buildVpsRemote, command as edgeCommand, isTransientSshFailure, PUBLIC_ROUTES, routeProbe } from "./edge/run.ts";
|
|
9
9
|
import { API_PREFIXES, buildDmzRemote, validateApiPath, command as identityCommand } from "./identity/run.ts";
|
|
10
|
-
import { buildInferenceRemote,
|
|
10
|
+
import { buildInferenceRemote, command as inferenceCommand } from "./inference/run.ts";
|
|
11
11
|
import { MAX_ADD_IDS, buildTrendarrCall, interpretRun, parseImdbIds, trendarrConfig, trendarrError, trendarrWarnings, command as mediaCommand } from "./media/run.ts";
|
|
12
12
|
import { ServiceError, UsageError } from "./lib/errors.ts";
|
|
13
13
|
import { safeRepoPath, safeWritePath } from "./lib/repo.ts";
|
|
@@ -44,6 +44,7 @@ describe("arcane wrapper", () => {
|
|
|
44
44
|
expect(await arcaneCommand(["--help"])).toBe(0);
|
|
45
45
|
expect(String(write.mock.calls[0][0])).toContain("git write");
|
|
46
46
|
expect(String(write.mock.calls[0][0])).toContain("git show");
|
|
47
|
+
expect(String(write.mock.calls[0][0])).toContain("stack-update <stack> [--confirm]");
|
|
47
48
|
expect(String(write.mock.calls[0][0])).not.toMatch(/^\s+gitops /m);
|
|
48
49
|
write.mockRestore();
|
|
49
50
|
});
|
|
@@ -189,7 +190,7 @@ describe("edge wrapper", () => {
|
|
|
189
190
|
const curl = async (args: string[]) => {
|
|
190
191
|
seen.push(args);
|
|
191
192
|
const url = args.at(-1) ?? "";
|
|
192
|
-
return { stdout: url.includes("/auth/v1/settings") ? "401 0.12" : url.includes("studio") || url.includes("mealie") ? "302 0.10" : "200 0.05", code: 0 };
|
|
193
|
+
return { stdout: url.includes("/auth/v1/settings") ? "401 0.12" : url.includes("oauth-authorization-server") ? "404 0.08" : url.includes("studio") || url.includes("mealie") ? "302 0.10" : "200 0.05", code: 0 };
|
|
193
194
|
};
|
|
194
195
|
const all = await routeProbe([], curl);
|
|
195
196
|
expect(all).toHaveLength(Object.keys(PUBLIC_ROUTES).length);
|
|
@@ -429,31 +430,21 @@ describe("proxmox wrapper", () => {
|
|
|
429
430
|
});
|
|
430
431
|
|
|
431
432
|
describe("inference wrapper", () => {
|
|
432
|
-
it("maps commands to
|
|
433
|
-
expect(buildInferenceRemote(["nexus-status"])).
|
|
433
|
+
it("maps commands to fixed remote strings and enforces confirmation", () => {
|
|
434
|
+
expect(buildInferenceRemote(["nexus-status"]).remote).toBe("nexus-status");
|
|
434
435
|
expect(buildInferenceRemote(["nexus-log", "embed", "100"]).remote).toBe("nexus-log embed 100");
|
|
435
436
|
expect(buildInferenceRemote(["nexus-stop", "rerank", "--confirm"]).remote).toBe("nexus-stop rerank");
|
|
436
|
-
expect(buildInferenceRemote(["hermes-upgrade", "--confirm"])).toMatchObject({ remote: "hermes-upgrade", spec: { target: "pulsar" } });
|
|
437
|
-
expect(buildInferenceRemote(["hermes-journal", "1 hour ago"])).toMatchObject({ spec: { target: "hermes" } });
|
|
438
437
|
expect(() => buildInferenceRemote(["nexus-stop", "rerank"])).toThrow(/--confirm/);
|
|
439
|
-
expect(() => buildInferenceRemote(["hermes-upgrade"])).toThrow(/--confirm/);
|
|
440
438
|
expect(() => buildInferenceRemote(["nexus-restart", "server"])).toThrow(/service/);
|
|
441
439
|
expect(() => buildInferenceRemote(["nexus-log", "embed", "5000"])).toThrow(/lines/);
|
|
442
|
-
expect(() => buildInferenceRemote(["hermes-restart", "discord"])).toThrow(/service/);
|
|
443
440
|
expect(() => buildInferenceRemote(["europa-start"])).toThrow(/unknown command/);
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
it("compacts github releases", () => {
|
|
447
|
-
const releases = compactReleases([{ tag_name: "v2026.9.1", name: "Sept", published_at: "2026-09-10T00:00:00Z", html_url: "https://x/y", prerelease: false, body: "x".repeat(7000) }, {}]);
|
|
448
|
-
expect(releases[0]).toMatchObject({ tag: "v2026.9.1", name: "Sept", prerelease: false, url: "https://x/y" });
|
|
449
|
-
expect(releases[0].notes.length).toBe(6000);
|
|
450
|
-
expect(releases[1]).toEqual({ tag: "?", name: "", published: "", prerelease: false, url: "", notes: "" });
|
|
441
|
+
expect(() => buildInferenceRemote(["hermes-status"])).toThrow(/unknown command/);
|
|
451
442
|
});
|
|
452
443
|
|
|
453
444
|
it("prints help without touching config", async () => {
|
|
454
445
|
const write = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
455
446
|
expect(await inferenceCommand(["help"])).toBe(0);
|
|
456
|
-
expect(String(write.mock.calls[0][0])).toContain("
|
|
447
|
+
expect(String(write.mock.calls[0][0])).toContain("europa-health");
|
|
457
448
|
write.mockRestore();
|
|
458
449
|
});
|
|
459
450
|
});
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
#!/bin/sh
|
|
2
|
-
# Forced command for the spc-inference key on LXC 101 (user hermes, no sudo).
|
|
3
|
-
# authorized_keys line in /home/hermes/.ssh/authorized_keys:
|
|
4
|
-
# command="/usr/local/sbin/spc-inference-hermes-entry",no-port-forwarding,no-agent-forwarding,no-pty,no-X11-forwarding ssh-ed25519 AAAA... spc-inference-hermes@cortex
|
|
5
|
-
set -eu
|
|
6
|
-
export PATH="$HOME/.local/bin:$HOME/.hermes/hermes-agent/venv/bin:/usr/local/bin:/usr/bin:/bin"
|
|
7
|
-
export XDG_RUNTIME_DIR="/run/user/$(id -u)"
|
|
8
|
-
cmd="${SSH_ORIGINAL_COMMAND:-}"
|
|
9
|
-
name="${cmd%% *}"
|
|
10
|
-
rest=""
|
|
11
|
-
[ "$cmd" != "$name" ] && rest="${cmd#* }"
|
|
12
|
-
since_ok() { printf '%s' "$1" | grep -Eq '^[A-Za-z0-9 :+-]{1,40}$'; }
|
|
13
|
-
units="hermes-gateway.service hermes-gateway-api.service"
|
|
14
|
-
|
|
15
|
-
case "$name" in
|
|
16
|
-
hermes-status) systemctl --user status $units --no-pager --lines=5 || true ;;
|
|
17
|
-
hermes-journal) since_ok "$rest" || { echo "bad since" >&2; exit 2; }; journalctl --user -u hermes-gateway.service -u hermes-gateway-api.service --since "$rest" --no-pager -n 200 ;;
|
|
18
|
-
hermes-errors) hermes logs errors ;;
|
|
19
|
-
hermes-restart)
|
|
20
|
-
case "$rest" in gateway) u=hermes-gateway.service ;; api) u=hermes-gateway-api.service ;; *) echo "bad service" >&2; exit 2 ;; esac
|
|
21
|
-
systemctl --user restart "$u"; sleep 3; systemctl --user status "$u" --no-pager --lines=5 || true ;;
|
|
22
|
-
hermes-api-health)
|
|
23
|
-
printf 'health=%s\n' "$(curl -s -o /dev/null -w '%{http_code}' -m 5 http://localhost:8650/health || echo 000)"
|
|
24
|
-
printf 'models=%s (401 expected)\n' "$(curl -s -o /dev/null -w '%{http_code}' -m 5 http://localhost:8650/v1/models || echo 000)" ;;
|
|
25
|
-
hermes-version) hermes --version | head -1 ;;
|
|
26
|
-
*) echo "refused: $cmd" >&2; exit 2 ;;
|
|
27
|
-
esac
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
#!/bin/sh
|
|
2
|
-
# Forced command for the spc-inference key on Pulsar (root): Hermes guest operations.
|
|
3
|
-
# authorized_keys line:
|
|
4
|
-
# command="/usr/local/sbin/spc-inference-pulsar-entry",no-port-forwarding,no-agent-forwarding,no-pty,no-X11-forwarding ssh-ed25519 AAAA... spc-inference-pulsar@cortex
|
|
5
|
-
set -eu
|
|
6
|
-
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
|
7
|
-
cmd="${SSH_ORIGINAL_COMMAND:-}"
|
|
8
|
-
|
|
9
|
-
case "$cmd" in
|
|
10
|
-
hermes-guest-status) pct status 101 ;;
|
|
11
|
-
hermes-snapshots) pct listsnapshot 101 ;;
|
|
12
|
-
hermes-upgrade) /usr/local/bin/hermes-upgrade ;;
|
|
13
|
-
*) echo "refused: $cmd" >&2; exit 2 ;;
|
|
14
|
-
esac
|