feat: expose safe pi management api
This commit is contained in:
@@ -13,12 +13,14 @@ import { sqlRoutes } from "./routes/sql.js";
|
||||
import { metaRoutes, type ListModelsFn } from "./routes/meta.js";
|
||||
import { settingsRoutes, effectiveSettings } from "./routes/settings.js";
|
||||
import { createPiModelLister } from "./pi/list-models.js";
|
||||
import { createPiManagement, type PiManagementService } from "./pi/management.js";
|
||||
import { loadSettings, type Settings } from "./settings/settings-store.js";
|
||||
import { ReadinessManager } from "./runtime/readiness-manager.js";
|
||||
import { MaintenanceBarrier } from "./runtime/maintenance-gate.js";
|
||||
import { WorkspaceRegistry } from "./workspaces/registry.js";
|
||||
import { createProductionWorkspaceDiagnoser } from "./workspaces/diagnostics.js";
|
||||
import { workspaceRoutes, type WorkspaceDiagnoser } from "./routes/workspaces.js";
|
||||
import { piManagementRoutes } from "./routes/pi-management.js";
|
||||
import { resolveRuntimeBindings, supportsSessionRuntime } from "./workspaces/bindings.js";
|
||||
import type { WorkspaceDescriptor } from "./workspaces/schema.js";
|
||||
|
||||
@@ -34,6 +36,7 @@ export interface BuildAppDeps {
|
||||
workspaceDiagnoser?: WorkspaceDiagnoser;
|
||||
workspaceRuntimeSupport?: (workspace: WorkspaceDescriptor) => boolean;
|
||||
maintenanceBarrier?: MaintenanceBarrier;
|
||||
piManagement?: PiManagementService;
|
||||
}
|
||||
|
||||
export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstance {
|
||||
@@ -86,6 +89,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
if (deps?.getSettings) return await deps.getSettings(principal);
|
||||
return effectiveSettings(config, loadSettings(config));
|
||||
};
|
||||
const piManagement = deps?.piManagement ?? createPiManagement(config, { listModels });
|
||||
|
||||
const maintenanceBarrier = deps?.maintenanceBarrier ?? new MaintenanceBarrier(config.maintenanceFile);
|
||||
const authenticate = authPreHandler(config.authMode);
|
||||
@@ -141,6 +145,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
metaRoutes(app, { harnessDir: config.harnessDir, listModels });
|
||||
workspaceRoutes(app, { registry: workspaceRegistry, config: config.workspaceRegistry, diagnose: workspaceDiagnoser });
|
||||
settingsRoutes(app, { cfg: config, listModels, getSettings });
|
||||
piManagementRoutes(app, { config, service: piManagement });
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
+15
-2
@@ -4,6 +4,7 @@ import type { WorkspaceRegistryConfig } from "./workspaces/types.js";
|
||||
export interface AppConfig {
|
||||
host: string; port: number; harnessDir: string; thtBin: string; piBin: string;
|
||||
authMode: "none" | "mock" | "upstream";
|
||||
publicExposure: boolean;
|
||||
sessionStorage: {
|
||||
mode: "local" | "postgres";
|
||||
host?: string; port?: number; database?: string; runtimeUser?: string;
|
||||
@@ -15,6 +16,7 @@ export interface AppConfig {
|
||||
maintenanceFile: string;
|
||||
dataRoot?: string;
|
||||
ollamaEnsureTimeoutMs: number;
|
||||
piManagementTimeoutMs: number;
|
||||
secretsFile?: string;
|
||||
secretFiles: Readonly<Record<string, string | undefined>>;
|
||||
modelApiKeyFile?: string;
|
||||
@@ -91,19 +93,28 @@ function diagnosticTimeout(value: string | undefined): number {
|
||||
return timeout;
|
||||
}
|
||||
|
||||
function piManagementTimeout(value: string | undefined): number {
|
||||
const timeout = Number(value ?? 8_000);
|
||||
if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 30_000) {
|
||||
throw new Error("Pi management timeout configuration is invalid");
|
||||
}
|
||||
return timeout;
|
||||
}
|
||||
|
||||
export function loadConfig(env: Record<string, string | undefined>): AppConfig {
|
||||
const authMode = env.AUTH_MODE ?? "none";
|
||||
if (!(["none", "mock", "upstream"] as const).includes(authMode as AppConfig["authMode"])) {
|
||||
throw new Error(`unsupported AUTH_MODE=${authMode}; use none, mock, or upstream`);
|
||||
}
|
||||
if (env.THOTH_PUBLIC_EXPOSURE === "true" && authMode !== "upstream") {
|
||||
const publicExposure = env.THOTH_PUBLIC_EXPOSURE === "true";
|
||||
if (publicExposure && authMode !== "upstream") {
|
||||
throw new Error("public exposure requires AUTH_MODE=upstream behind a trusted proxy");
|
||||
}
|
||||
const sessionStorageMode = env.THT_SESSION_STORAGE ?? "local";
|
||||
if (sessionStorageMode !== "local" && sessionStorageMode !== "postgres") {
|
||||
throw new Error("session storage configuration is invalid");
|
||||
}
|
||||
if (sessionStorageMode === "local" && env.THOTH_PUBLIC_EXPOSURE === "true") {
|
||||
if (sessionStorageMode === "local" && publicExposure) {
|
||||
throw new Error("local session storage requires loopback-only deployment");
|
||||
}
|
||||
const legacyWorkspaceMode = env.THT_LEGACY_WORKSPACE_MODE;
|
||||
@@ -204,6 +215,7 @@ export function loadConfig(env: Record<string, string | undefined>): AppConfig {
|
||||
thtBin: env.THT_BIN ?? "tht",
|
||||
piBin: env.PI_BIN ?? "pi",
|
||||
authMode: authMode as AppConfig["authMode"],
|
||||
publicExposure,
|
||||
sessionStorage,
|
||||
defaults: { provider: env.PI_PROVIDER, model: env.PI_MODEL, thinking: env.PI_THINKING },
|
||||
maxPiProcesses: Number(env.MAX_PI_PROCESSES ?? 4),
|
||||
@@ -211,6 +223,7 @@ export function loadConfig(env: Record<string, string | undefined>): AppConfig {
|
||||
maintenanceFile: env.THT_MAINTENANCE_FILE ?? path.join(path.dirname(settingsFile), "maintenance.json"),
|
||||
dataRoot: env.THT_DATA_ROOT,
|
||||
ollamaEnsureTimeoutMs: Number(env.OLLAMA_ENSURE_TIMEOUT_MS ?? 60000),
|
||||
piManagementTimeoutMs: piManagementTimeout(env.PI_MANAGEMENT_TIMEOUT_MS),
|
||||
secretsFile,
|
||||
secretFiles,
|
||||
modelApiKeyFile,
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { execFile as nodeExecFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type { AppConfig } from "../config.js";
|
||||
import {
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
type Settings,
|
||||
} from "../settings/settings-store.js";
|
||||
import type { PiModel } from "./list-models.js";
|
||||
|
||||
const execFile = promisify(nodeExecFile);
|
||||
const REASONING_CHOICES = ["low", "medium", "high"] as const;
|
||||
const VERSION_PATTERN = /^(?:pi(?:\s+version)?\s+)?v?(\d+(?:\.\d+){1,3}(?:[-+][0-9A-Za-z.-]+)?)$/;
|
||||
const MAX_LOG_LINES = 200;
|
||||
const MAX_LOG_LINE_LENGTH = 4_096;
|
||||
const MAX_EXEC_OUTPUT_BYTES = 64 * 1024;
|
||||
|
||||
export type PiReasoning = typeof REASONING_CHOICES[number];
|
||||
|
||||
export interface PiInstallationConfig {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
reasoning?: PiReasoning;
|
||||
}
|
||||
|
||||
export interface PiStatus {
|
||||
version?: string;
|
||||
ready: boolean;
|
||||
config: PiInstallationConfig;
|
||||
checkedAt: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface PiOptions {
|
||||
providers: string[];
|
||||
models: Array<{ provider: string; id: string }>;
|
||||
reasoning: PiReasoning[];
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export interface PiTestResult {
|
||||
ready: boolean;
|
||||
checkedAt: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface PiLogs {
|
||||
lines: string[];
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export interface PiExecFileOptions {
|
||||
timeout: number;
|
||||
maxBuffer: number;
|
||||
}
|
||||
|
||||
export type PiExecFile = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: PiExecFileOptions,
|
||||
) => Promise<{ stdout: string; stderr: string }>;
|
||||
|
||||
export interface PiManagementService {
|
||||
status(): Promise<PiStatus>;
|
||||
options(): Promise<PiOptions>;
|
||||
configure(value: PiInstallationConfig): Promise<PiInstallationConfig & { updatedAt: string }>;
|
||||
test(): Promise<PiTestResult>;
|
||||
logs(): Promise<PiLogs>;
|
||||
}
|
||||
|
||||
export class PiManagementError extends Error {
|
||||
constructor(
|
||||
public readonly code: "pi_management_invalid_config" | "pi_management_unavailable" | "pi_management_write_failed",
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
interface PiManagementDeps {
|
||||
execute?: PiExecFile;
|
||||
listModels: () => Promise<PiModel[]>;
|
||||
readSettings?: () => Settings;
|
||||
saveSettings?: (settings: Settings) => Settings;
|
||||
readLogs?: () => string | Promise<string>;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
export function createPiManagement(config: AppConfig, deps: PiManagementDeps): PiManagementService {
|
||||
const now = deps.now ?? (() => new Date());
|
||||
const diagnostics: string[] = [];
|
||||
const addDiagnostic = (message: string): void => {
|
||||
diagnostics.push(`${now().toISOString()} ${redact(message)}`);
|
||||
if (diagnostics.length > MAX_LOG_LINES) diagnostics.splice(0, diagnostics.length - MAX_LOG_LINES);
|
||||
};
|
||||
const execute = deps.execute ?? defaultExecFile;
|
||||
const readSettings = deps.readSettings ?? (() => loadSettings(config));
|
||||
const persistSettings = deps.saveSettings ?? ((settings) => saveSettings(config, settings));
|
||||
const readLogs = deps.readLogs ?? (() => diagnostics.join("\n"));
|
||||
|
||||
const closedOptions = async (): Promise<Omit<PiOptions, "checkedAt">> => {
|
||||
let listed: PiModel[];
|
||||
try {
|
||||
listed = await deps.listModels();
|
||||
} catch {
|
||||
throw new PiManagementError("pi_management_unavailable", "Pi model choices are unavailable");
|
||||
}
|
||||
const models: Array<{ provider: string; id: string }> = [];
|
||||
const providers: string[] = [];
|
||||
const seenModels = new Set<string>();
|
||||
const seenProviders = new Set<string>();
|
||||
for (const model of listed) {
|
||||
if (!isChoice(model?.provider) || !isChoice(model?.id)) continue;
|
||||
const key = `${model.provider}\u0000${model.id}`;
|
||||
if (seenModels.has(key)) continue;
|
||||
seenModels.add(key);
|
||||
models.push({ provider: model.provider, id: model.id });
|
||||
if (!seenProviders.has(model.provider)) {
|
||||
seenProviders.add(model.provider);
|
||||
providers.push(model.provider);
|
||||
}
|
||||
}
|
||||
return { providers, models, reasoning: [...REASONING_CHOICES] };
|
||||
};
|
||||
|
||||
const version = async (): Promise<string> => {
|
||||
let output: { stdout: string; stderr: string };
|
||||
try {
|
||||
// The Pi executable and every argument are installation-owned constants. Do not add a shell.
|
||||
output = await execute(config.piBin, ["--version"], {
|
||||
timeout: config.piManagementTimeoutMs,
|
||||
maxBuffer: MAX_EXEC_OUTPUT_BYTES,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isTimeout(error)) {
|
||||
throw new PiManagementError("pi_management_unavailable", "Pi smoke check timed out");
|
||||
}
|
||||
throw new PiManagementError("pi_management_unavailable", "Pi runtime is unavailable");
|
||||
}
|
||||
const matched = VERSION_PATTERN.exec(output.stdout.trim());
|
||||
if (!matched) throw new PiManagementError("pi_management_unavailable", "Pi runtime returned an invalid version");
|
||||
return matched[1];
|
||||
};
|
||||
|
||||
const installationConfig = (): PiInstallationConfig => {
|
||||
const settings = readSettings();
|
||||
const provider = config.defaults.provider ?? settings.provider;
|
||||
const model = config.defaults.model ?? settings.model;
|
||||
const reasoning = config.defaults.thinking ?? settings.thinking;
|
||||
return {
|
||||
...(isChoice(provider) ? { provider } : {}),
|
||||
...(isChoice(model) ? { model } : {}),
|
||||
...(isReasoning(reasoning) ? { reasoning } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
async status(): Promise<PiStatus> {
|
||||
const checkedAt = now().toISOString();
|
||||
const current = installationConfig();
|
||||
try {
|
||||
const currentVersion = await version();
|
||||
addDiagnostic("Pi version probe succeeded");
|
||||
return { version: currentVersion, ready: true, config: current, checkedAt };
|
||||
} catch (error) {
|
||||
const message = stableMessage(error, "Pi runtime is unavailable");
|
||||
addDiagnostic(message);
|
||||
return { ready: false, config: current, checkedAt, message };
|
||||
}
|
||||
},
|
||||
|
||||
async options(): Promise<PiOptions> {
|
||||
const choices = await closedOptions();
|
||||
return { ...choices, checkedAt: now().toISOString() };
|
||||
},
|
||||
|
||||
async configure(value: PiInstallationConfig): Promise<PiInstallationConfig & { updatedAt: string }> {
|
||||
if (!isInstallationConfig(value)) {
|
||||
throw new PiManagementError("pi_management_invalid_config", "Pi installation configuration is invalid");
|
||||
}
|
||||
const choices = await closedOptions();
|
||||
if (!choices.models.some((model) => model.provider === value.provider && model.id === value.model)) {
|
||||
throw new PiManagementError("pi_management_invalid_config", "Pi provider and model must be selected from available choices");
|
||||
}
|
||||
try {
|
||||
persistSettings({ ...readSettings(), provider: value.provider, model: value.model, thinking: value.reasoning });
|
||||
} catch {
|
||||
throw new PiManagementError("pi_management_write_failed", "Pi installation configuration could not be saved");
|
||||
}
|
||||
addDiagnostic("Pi installation defaults updated");
|
||||
return { ...value, updatedAt: now().toISOString() };
|
||||
},
|
||||
|
||||
async test(): Promise<PiTestResult> {
|
||||
const checkedAt = now().toISOString();
|
||||
try {
|
||||
await version();
|
||||
const current = installationConfig();
|
||||
if (!current.provider || !current.model || !current.reasoning) {
|
||||
return smokeFailure("Pi installation configuration is incomplete", checkedAt, addDiagnostic);
|
||||
}
|
||||
const choices = await closedOptions();
|
||||
if (!choices.models.some((model) => model.provider === current.provider && model.id === current.model)) {
|
||||
return smokeFailure("Configured Pi provider and model are unavailable", checkedAt, addDiagnostic);
|
||||
}
|
||||
addDiagnostic("Pi smoke check succeeded");
|
||||
return { ready: true, checkedAt };
|
||||
} catch (error) {
|
||||
return smokeFailure(stableMessage(error, "Pi smoke check failed"), checkedAt, addDiagnostic);
|
||||
}
|
||||
},
|
||||
|
||||
async logs(): Promise<PiLogs> {
|
||||
let source = "";
|
||||
try {
|
||||
source = await readLogs();
|
||||
} catch {
|
||||
source = "Pi diagnostics are unavailable";
|
||||
}
|
||||
const lines = source
|
||||
.split(/\r?\n/u)
|
||||
.filter((line) => line.length > 0)
|
||||
.slice(-MAX_LOG_LINES)
|
||||
.map((line) => redact(line.slice(0, MAX_LOG_LINE_LENGTH)));
|
||||
return { lines, checkedAt: now().toISOString() };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function defaultExecFile(command: string, args: string[], options: PiExecFileOptions) {
|
||||
const result = await execFile(command, args, {
|
||||
timeout: options.timeout,
|
||||
maxBuffer: options.maxBuffer,
|
||||
windowsHide: true,
|
||||
});
|
||||
return { stdout: String(result.stdout), stderr: String(result.stderr) };
|
||||
}
|
||||
|
||||
function isChoice(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0 && value.length <= 128 && value.trim() === value
|
||||
&& /^[A-Za-z0-9][A-Za-z0-9._/-]*$/u.test(value);
|
||||
}
|
||||
|
||||
function isReasoning(value: unknown): value is PiReasoning {
|
||||
return typeof value === "string" && (REASONING_CHOICES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function isInstallationConfig(value: unknown): value is Required<PiInstallationConfig> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (Object.keys(candidate).length !== 3 || Object.keys(candidate).some((key) => !["provider", "model", "reasoning"].includes(key))) {
|
||||
return false;
|
||||
}
|
||||
return isChoice(candidate.provider) && isChoice(candidate.model) && isReasoning(candidate.reasoning);
|
||||
}
|
||||
|
||||
function isTimeout(error: unknown): boolean {
|
||||
return Boolean(
|
||||
error && typeof error === "object" && (
|
||||
(error as { code?: unknown }).code === "ETIMEDOUT"
|
||||
|| (error as { killed?: unknown }).killed === true
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function stableMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof PiManagementError ? error.message : fallback;
|
||||
}
|
||||
|
||||
function smokeFailure(
|
||||
message: string,
|
||||
checkedAt: string,
|
||||
addDiagnostic: (message: string) => void,
|
||||
): PiTestResult {
|
||||
addDiagnostic(message);
|
||||
return { ready: false, message, checkedAt };
|
||||
}
|
||||
|
||||
export function redact(value: string): string {
|
||||
return value
|
||||
.replace(/(\bauthorization\b\s*:\s*Bearer\s+)[^\s,;]+/giu, "$1[REDACTED]")
|
||||
.replace(/(\b(?:api[_-]?key|token|password|secret|authorization)\b\s*(?:=|:)\s*)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s,;]+)/giu, "$1[REDACTED]")
|
||||
.replace(/(\bBearer\s+)[^\s,;]+/giu, "$1[REDACTED]")
|
||||
.replace(/(\w+:\/\/[^:/\s]+:)[^@/\s]+@/gu, "$1[REDACTED]@");
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { getPrincipal } from "../auth/auth.js";
|
||||
import type { AppConfig } from "../config.js";
|
||||
import { PiManagementError, type PiManagementService } from "../pi/management.js";
|
||||
|
||||
export function piManagementRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: { config: AppConfig; service: PiManagementService },
|
||||
): void {
|
||||
app.get("/pi-management/status", async (request, reply) => run(request, reply, deps, () => deps.service.status()));
|
||||
app.get("/pi-management/options", async (request, reply) => run(request, reply, deps, () => deps.service.options()));
|
||||
app.put("/pi-management/config", async (request, reply) => run(
|
||||
request,
|
||||
reply,
|
||||
deps,
|
||||
() => deps.service.configure((request.body ?? {}) as Record<string, unknown>),
|
||||
));
|
||||
app.post("/pi-management/test", async (request, reply) => run(request, reply, deps, () => deps.service.test()));
|
||||
app.get("/pi-management/logs", async (request, reply) => run(request, reply, deps, () => deps.service.logs()));
|
||||
}
|
||||
|
||||
async function run<T>(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
deps: { config: AppConfig; service: PiManagementService },
|
||||
action: () => Promise<T>,
|
||||
): Promise<T | FastifyReply> {
|
||||
const principal = getPrincipal(request);
|
||||
if (!managementAllowed(deps.config, principal.isAdmin)) {
|
||||
return reply.code(403).send({ code: "pi_management_forbidden", error: "Pi management is not permitted" });
|
||||
}
|
||||
try {
|
||||
return await action();
|
||||
} catch (error) {
|
||||
if (error instanceof PiManagementError) {
|
||||
const statusCode = error.code === "pi_management_invalid_config" ? 400 : 503;
|
||||
return reply.code(statusCode).send({ code: error.code, error: error.message });
|
||||
}
|
||||
return reply.code(503).send({ code: "pi_management_unavailable", error: "Pi management is unavailable" });
|
||||
}
|
||||
}
|
||||
|
||||
function managementAllowed(config: AppConfig, isAdmin: boolean): boolean {
|
||||
return (config.authMode === "none" && !config.publicExposure)
|
||||
|| (config.authMode === "upstream" && isAdmin);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import {
|
||||
PiManagementError,
|
||||
createPiManagement,
|
||||
type PiExecFile,
|
||||
} from "../src/pi/management.js";
|
||||
|
||||
function configFor(settingsFile = join(mkdtempSync(join(tmpdir(), "tht-pi-management-")), "settings.json")) {
|
||||
return loadConfig({
|
||||
THT_HARNESS_DIR: "../harness",
|
||||
SETTINGS_FILE: settingsFile,
|
||||
PI_BIN: "/usr/local/bin/pi",
|
||||
PI_MANAGEMENT_TIMEOUT_MS: "750",
|
||||
});
|
||||
}
|
||||
|
||||
const supportedModels = [
|
||||
{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true },
|
||||
{ provider: "deepseek", id: "deepseek-v4", name: "DeepSeek V4", reasoning: true },
|
||||
];
|
||||
|
||||
function successfulExec(calls: Array<{ command: string; args: string[]; timeout: number }>): PiExecFile {
|
||||
return async (command, args, options) => {
|
||||
calls.push({ command, args, timeout: options.timeout });
|
||||
return { stdout: "pi 0.80.3\n", stderr: "" };
|
||||
};
|
||||
}
|
||||
|
||||
// Catches a Pi executable that emits unexpected text or is invoked through a shell, which could
|
||||
// turn a version display into a command-injection or information-disclosure surface.
|
||||
test("status parses only a Pi version from a fixed execFile argument array", async () => {
|
||||
const calls: Array<{ command: string; args: string[]; timeout: number }> = [];
|
||||
const service = createPiManagement(configFor(), {
|
||||
execute: successfulExec(calls),
|
||||
listModels: async () => supportedModels,
|
||||
readSettings: () => ({ provider: "zai", model: "glm-5.2", thinking: "medium" }),
|
||||
now: () => new Date("2026-08-05T10:00:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(service.status()).resolves.toEqual({
|
||||
version: "0.80.3",
|
||||
ready: true,
|
||||
config: { provider: "zai", model: "glm-5.2", reasoning: "medium" },
|
||||
checkedAt: "2026-08-05T10:00:00.000Z",
|
||||
});
|
||||
expect(calls).toEqual([{ command: "/usr/local/bin/pi", args: ["--version"], timeout: 750 }]);
|
||||
});
|
||||
|
||||
// Catches an options response that leaks provider metadata or lets callers choose model IDs that
|
||||
// Pi did not explicitly enable for this installation.
|
||||
test("options expose only closed provider, model, and reasoning choices", async () => {
|
||||
const service = createPiManagement(configFor(), {
|
||||
execute: successfulExec([]),
|
||||
listModels: async () => supportedModels,
|
||||
now: () => new Date("2026-08-05T10:00:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(service.options()).resolves.toEqual({
|
||||
providers: ["zai", "deepseek"],
|
||||
models: [
|
||||
{ provider: "zai", id: "glm-5.2" },
|
||||
{ provider: "deepseek", id: "deepseek-v4" },
|
||||
],
|
||||
reasoning: ["low", "medium", "high"],
|
||||
checkedAt: "2026-08-05T10:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
// Catches configuration writes that accept whitespace, unknown choices, or extra free-form fields
|
||||
// before reaching the durable installation settings file.
|
||||
test("config rejects invalid free-form values before writing settings", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "tht-pi-management-invalid-"));
|
||||
try {
|
||||
let writes = 0;
|
||||
const service = createPiManagement(configFor(join(directory, "settings.json")), {
|
||||
execute: successfulExec([]),
|
||||
listModels: async () => supportedModels,
|
||||
readSettings: () => ({}),
|
||||
saveSettings: () => { writes += 1; return {}; },
|
||||
});
|
||||
|
||||
await expect(service.configure({
|
||||
provider: "zai ", model: "glm-5.2", reasoning: "medium", unexpected: "value",
|
||||
} as any)).rejects.toMatchObject<PiManagementError>({ code: "pi_management_invalid_config" });
|
||||
expect(writes).toBe(0);
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Catches a non-atomic implementation that can leave partial settings or temporary files after a
|
||||
// normal installation-default update.
|
||||
test("config validates closed choices and atomically persists non-secret defaults", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "tht-pi-management-write-"));
|
||||
const settingsFile = join(directory, "settings.json");
|
||||
try {
|
||||
const service = createPiManagement(configFor(settingsFile), {
|
||||
execute: successfulExec([]),
|
||||
listModels: async () => supportedModels,
|
||||
now: () => new Date("2026-08-05T10:00:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(service.configure({
|
||||
provider: "zai", model: "glm-5.2", reasoning: "high",
|
||||
})).resolves.toEqual({
|
||||
provider: "zai", model: "glm-5.2", reasoning: "high", updatedAt: "2026-08-05T10:00:00.000Z",
|
||||
});
|
||||
expect(JSON.parse(readFileSync(settingsFile, "utf8"))).toEqual({
|
||||
provider: "zai", model: "glm-5.2", thinking: "high",
|
||||
});
|
||||
expect(readdirSync(directory)).toEqual(["settings.json"]);
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Catches a hung Pi smoke check that leaves an operator waiting indefinitely or returns raw child
|
||||
// diagnostics containing provider credentials.
|
||||
test("smoke uses the configured timeout and reports a sanitized timeout", async () => {
|
||||
const calls: Array<{ command: string; args: string[]; timeout: number }> = [];
|
||||
const service = createPiManagement(configFor(), {
|
||||
execute: async (command, args, options) => {
|
||||
calls.push({ command, args, timeout: options.timeout });
|
||||
throw Object.assign(new Error("provider token=raw-provider-token"), { code: "ETIMEDOUT" });
|
||||
},
|
||||
listModels: async () => supportedModels,
|
||||
readSettings: () => ({ provider: "zai", model: "glm-5.2", thinking: "medium" }),
|
||||
now: () => new Date("2026-08-05T10:00:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(service.test()).resolves.toEqual({
|
||||
ready: false,
|
||||
message: "Pi smoke check timed out",
|
||||
checkedAt: "2026-08-05T10:00:00.000Z",
|
||||
});
|
||||
expect(calls).toEqual([{ command: "/usr/local/bin/pi", args: ["--version"], timeout: 750 }]);
|
||||
});
|
||||
|
||||
// Catches an unbounded diagnostics endpoint or one that returns bearer tokens and connection
|
||||
// passwords captured in Pi output.
|
||||
test("logs keep only the latest 200 redacted lines", async () => {
|
||||
const source = Array.from({ length: 205 }, (_, index) => `line-${index + 1}`);
|
||||
source[203] = "Authorization: Bearer raw-bearer-token";
|
||||
source[204] = "database_url=postgres://thoth:raw-db-password@example.invalid/db";
|
||||
const service = createPiManagement(configFor(), {
|
||||
execute: successfulExec([]),
|
||||
listModels: async () => supportedModels,
|
||||
readLogs: () => source.join("\n"),
|
||||
now: () => new Date("2026-08-05T10:00:00.000Z"),
|
||||
});
|
||||
|
||||
const logs = await service.logs();
|
||||
expect(logs.checkedAt).toBe("2026-08-05T10:00:00.000Z");
|
||||
expect(logs.lines).toHaveLength(200);
|
||||
expect(logs.lines[0]).toBe("line-6");
|
||||
expect(logs.lines.join("\n")).not.toContain("raw-bearer-token");
|
||||
expect(logs.lines.join("\n")).not.toContain("raw-db-password");
|
||||
expect(logs.lines.join("\n")).toContain("[REDACTED]");
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import type { PiManagementService } from "../src/pi/management.js";
|
||||
import { piManagementRoutes } from "../src/routes/pi-management.js";
|
||||
|
||||
void piManagementRoutes;
|
||||
|
||||
function fakeService(): PiManagementService {
|
||||
return {
|
||||
status: vi.fn(async () => ({
|
||||
version: "0.80.3", ready: true,
|
||||
config: { provider: "zai", model: "glm-5.2", reasoning: "medium" },
|
||||
checkedAt: "2026-08-05T10:00:00.000Z",
|
||||
})),
|
||||
options: vi.fn(async () => ({
|
||||
providers: ["zai"], models: [{ provider: "zai", id: "glm-5.2" }],
|
||||
reasoning: ["low", "medium", "high"], checkedAt: "2026-08-05T10:00:00.000Z",
|
||||
})),
|
||||
configure: vi.fn(async (value) => ({ ...value, updatedAt: "2026-08-05T10:00:00.000Z" })),
|
||||
test: vi.fn(async () => ({ ready: true, checkedAt: "2026-08-05T10:00:00.000Z" })),
|
||||
logs: vi.fn(async () => ({ lines: ["Pi smoke check succeeded"], checkedAt: "2026-08-05T10:00:00.000Z" })),
|
||||
};
|
||||
}
|
||||
|
||||
function appWith(service: PiManagementService, env: Record<string, string> = {}) {
|
||||
return buildApp(loadConfig({ THT_HARNESS_DIR: "../harness", ...env }), {
|
||||
thtRunner: {} as any,
|
||||
piManagement: service,
|
||||
});
|
||||
}
|
||||
|
||||
const adminHeaders = {
|
||||
"x-thoth-principal-issuer": "portal",
|
||||
"x-thoth-principal-subject": "operator",
|
||||
"x-thoth-is-admin": "1",
|
||||
};
|
||||
|
||||
const exposedServerEnv = {
|
||||
AUTH_MODE: "upstream",
|
||||
THOTH_PUBLIC_EXPOSURE: "true",
|
||||
THT_SESSION_STORAGE: "postgres",
|
||||
THT_SESSION_DB_HOST: "db.example.invalid",
|
||||
THT_SESSION_DB_NAME: "thoth_sessions",
|
||||
THT_SESSION_RUNTIME_USER: "thoth_runtime",
|
||||
THT_SESSION_RUNTIME_PASSWORD_FILE: "/run/secrets/session-password",
|
||||
THT_SESSION_DB_SSLMODE: "verify-full",
|
||||
THT_SESSION_DB_SSLROOTCERT: "/run/secrets/session-ca.pem",
|
||||
};
|
||||
|
||||
// Catches a server deployment that lets an ordinary authenticated user inspect or mutate
|
||||
// installation-wide Pi configuration without the trusted upstream admin claim.
|
||||
test("exposed upstream deployments reject Pi Management without a trusted admin identity", async () => {
|
||||
const service = fakeService();
|
||||
const app = appWith(service, exposedServerEnv);
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "GET", url: "/pi-management/status",
|
||||
headers: { ...adminHeaders, "x-thoth-is-admin": "0" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.json()).toEqual({ code: "pi_management_forbidden", error: "Pi management is not permitted" });
|
||||
expect(service.status).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Catches an accidental privilege regression that blocks safe loopback-only installations or
|
||||
// returns fields beyond the sanctioned Pi Management status contract.
|
||||
test("loopback-only AUTH_MODE=none may read the sanitized Pi status", async () => {
|
||||
const app = appWith(fakeService());
|
||||
try {
|
||||
const response = await app.inject({ method: "GET", url: "/pi-management/status" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({
|
||||
version: "0.80.3", ready: true,
|
||||
config: { provider: "zai", model: "glm-5.2", reasoning: "medium" },
|
||||
checkedAt: "2026-08-05T10:00:00.000Z",
|
||||
});
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Catches route wiring that bypasses closed service validation or gives the browser a Docker/image
|
||||
// lifecycle endpoint rather than only installation-default configuration and diagnostics.
|
||||
test("trusted admins receive only configuration, smoke, options, and log endpoints", async () => {
|
||||
const service = fakeService();
|
||||
const app = appWith(service, exposedServerEnv);
|
||||
try {
|
||||
const options = await app.inject({ method: "GET", url: "/pi-management/options", headers: adminHeaders });
|
||||
const configured = await app.inject({
|
||||
method: "PUT", url: "/pi-management/config", headers: adminHeaders,
|
||||
payload: { provider: "zai", model: "glm-5.2", reasoning: "high" },
|
||||
});
|
||||
const smoke = await app.inject({ method: "POST", url: "/pi-management/test", headers: adminHeaders });
|
||||
const logs = await app.inject({ method: "GET", url: "/pi-management/logs", headers: adminHeaders });
|
||||
|
||||
expect(options.statusCode).toBe(200);
|
||||
expect(configured.statusCode).toBe(200);
|
||||
expect(configured.json()).toMatchObject({ provider: "zai", model: "glm-5.2", reasoning: "high" });
|
||||
expect(smoke.statusCode).toBe(200);
|
||||
expect(logs.statusCode).toBe(200);
|
||||
expect(app.printRoutes()).not.toContain("update");
|
||||
expect(app.printRoutes()).not.toContain("rollback");
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"github.com/aritmolab/thothii/tools/thothctl/internal/testsupport"
|
||||
)
|
||||
|
||||
// Catches interactive configuration prompts that use retired model-only data instead of the
|
||||
// provider, model, and reasoning choices supplied by the dedicated Pi Management API.
|
||||
func TestResolvePiConfigureUsesNumberedClosedChoicesOnlyForTTY(t *testing.T) {
|
||||
runner := &wizardRunner{}
|
||||
var prompt bytes.Buffer
|
||||
@@ -88,7 +90,7 @@ type wizardRunner struct{ calls []string }
|
||||
|
||||
func (r *wizardRunner) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) {
|
||||
r.calls = append(r.calls, strings.Join(args, " "))
|
||||
return compose.Result{Stdout: `{"models":[{"provider":"deepseek","id":"deepseek-v4"},{"provider":"zai","id":"glm-5.2"}]}`}, nil
|
||||
return compose.Result{Stdout: `{"providers":["deepseek","zai"],"models":[{"provider":"deepseek","id":"deepseek-v4"},{"provider":"zai","id":"glm-5.2"}],"reasoning":["low","medium","high"]}`}, nil
|
||||
}
|
||||
|
||||
func TestRunLogsRedactsAnUnlabelledDeclaredSecret(t *testing.T) {
|
||||
@@ -618,7 +620,7 @@ case " $* " in
|
||||
*"io.thothii.pi.version"*) printf '%s\n' '0.80.3' ;;
|
||||
*"PI_VERSION"*) printf '%s\n' '0.80.3' ;;
|
||||
*" pi --version "*) printf '%s\n' '0.80.3' ;;
|
||||
*"/models "*) printf '%s\n' '{"models":[{"provider":"provider","id":"model"}]}' ;;
|
||||
*"/pi-management/options "*) printf '%s\n' '{"providers":["provider"],"models":[{"provider":"provider","id":"model"}],"reasoning":["low","medium","high"]}' ;;
|
||||
*"settings-cli.js --snapshot"*) printf '%s\n' '{"exists":false,"rawBase64":""}' ;;
|
||||
*"/settings "*) printf '%s\n' '{"provider":"provider","model":"model","thinking":"medium"}' ;;
|
||||
*"/internal/maintenance/status "*) printf '%s\n' '{"active":true,"admissions":0}' ;;
|
||||
|
||||
@@ -28,6 +28,12 @@ type ModelOption struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type piOptions struct {
|
||||
Providers []string `json:"providers"`
|
||||
Models []ModelOption `json:"models"`
|
||||
Reasoning []string `json:"reasoning"`
|
||||
}
|
||||
|
||||
type settingsFileSnapshot struct {
|
||||
Exists bool `json:"exists"`
|
||||
RawBase64 string `json:"rawBase64"`
|
||||
@@ -46,19 +52,16 @@ func Configure(ctx context.Context, runner Runner, value Defaults) error {
|
||||
if !choicePattern.MatchString(value.Provider) || !choicePattern.MatchString(value.Model) {
|
||||
return errors.New("provider and model must be supported identifiers")
|
||||
}
|
||||
if value.Thinking != "low" && value.Thinking != "medium" && value.Thinking != "high" {
|
||||
return errors.New("thinking must be low, medium, or high")
|
||||
}
|
||||
before, err := renderedCore(ctx, runner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
options, err := ConfigurationOptions(ctx, runner)
|
||||
options, err := configurationOptions(ctx, runner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
found := false
|
||||
for _, model := range options {
|
||||
for _, model := range options.Models {
|
||||
if model.Provider == value.Provider && model.ID == value.Model {
|
||||
found = true
|
||||
}
|
||||
@@ -66,6 +69,15 @@ func Configure(ctx context.Context, runner Runner, value Defaults) error {
|
||||
if !found {
|
||||
return errors.New("provider/model is not in Pi options")
|
||||
}
|
||||
thinkingFound := false
|
||||
for _, reasoning := range options.Reasoning {
|
||||
if reasoning == value.Thinking {
|
||||
thinkingFound = true
|
||||
}
|
||||
}
|
||||
if !thinkingFound {
|
||||
return errors.New("thinking is not in Pi options")
|
||||
}
|
||||
old, err := captureSettingsFile(ctx, runner)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -107,24 +119,47 @@ func Configure(ctx context.Context, runner Runner, value Defaults) error {
|
||||
}
|
||||
|
||||
func ConfigurationOptions(ctx context.Context, runner Runner) ([]ModelOption, error) {
|
||||
args := append([]string{"exec", "-T", "core", "curl", "-fsS"}, internalIdentityHeaders...)
|
||||
args = append(args, "http://127.0.0.1:8787/models")
|
||||
models, err := runCompose(ctx, runner, args...)
|
||||
options, err := configurationOptions(ctx, runner)
|
||||
if err != nil {
|
||||
return nil, commandError("Pi options check", models, err)
|
||||
return nil, err
|
||||
}
|
||||
var payload struct {
|
||||
Models []ModelOption `json:"models"`
|
||||
return options.Models, nil
|
||||
}
|
||||
|
||||
func configurationOptions(ctx context.Context, runner Runner) (piOptions, error) {
|
||||
args := append([]string{"exec", "-T", "core", "curl", "-fsS"}, internalIdentityHeaders...)
|
||||
args = append(args, "http://127.0.0.1:8787/pi-management/options")
|
||||
result, err := runCompose(ctx, runner, args...)
|
||||
if err != nil {
|
||||
return piOptions{}, commandError("Pi options check", result, err)
|
||||
}
|
||||
if json.Unmarshal([]byte(models.Stdout), &payload) != nil || len(payload.Models) == 0 {
|
||||
return nil, errors.New("Pi options response is invalid or empty")
|
||||
var payload piOptions
|
||||
if json.Unmarshal([]byte(result.Stdout), &payload) != nil || len(payload.Providers) == 0 || len(payload.Models) == 0 || len(payload.Reasoning) == 0 {
|
||||
return piOptions{}, errors.New("Pi options response is invalid or empty")
|
||||
}
|
||||
providers := make(map[string]bool, len(payload.Providers))
|
||||
for _, provider := range payload.Providers {
|
||||
if !choicePattern.MatchString(provider) || providers[provider] {
|
||||
return piOptions{}, errors.New("Pi options response contains an invalid provider")
|
||||
}
|
||||
providers[provider] = true
|
||||
}
|
||||
models := make(map[string]bool, len(payload.Models))
|
||||
for _, option := range payload.Models {
|
||||
if !choicePattern.MatchString(option.Provider) || !choicePattern.MatchString(option.ID) {
|
||||
return nil, errors.New("Pi options response contains an invalid provider/model")
|
||||
key := option.Provider + "\x00" + option.ID
|
||||
if !providers[option.Provider] || !choicePattern.MatchString(option.ID) || models[key] {
|
||||
return piOptions{}, errors.New("Pi options response contains an invalid provider/model")
|
||||
}
|
||||
models[key] = true
|
||||
}
|
||||
return payload.Models, nil
|
||||
reasoning := make(map[string]bool, len(payload.Reasoning))
|
||||
for _, value := range payload.Reasoning {
|
||||
if (value != "low" && value != "medium" && value != "high") || reasoning[value] {
|
||||
return piOptions{}, errors.New("Pi options response contains an invalid reasoning choice")
|
||||
}
|
||||
reasoning[value] = true
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func writeDefaults(ctx context.Context, runner Runner, value Defaults) (compose.Result, error) {
|
||||
@@ -253,41 +288,25 @@ func expectedVersions(ctx context.Context, runner Runner) (string, string, error
|
||||
return expectedValue, labelValue, nil
|
||||
}
|
||||
|
||||
// Test performs the pre-Task-8 composite smoke through core's private loopback endpoint.
|
||||
// Test retains the direct image-version signal, then delegates all Pi configuration/provider smoke
|
||||
// validation to core's dedicated, admin-only Pi Management endpoint.
|
||||
func Test(ctx context.Context, runner Runner) error {
|
||||
if _, err := Status(ctx, runner); err != nil {
|
||||
return err
|
||||
}
|
||||
health, err := runCompose(ctx, runner, "exec", "-T", "core", "curl", "-fsS", "http://127.0.0.1:8787/health")
|
||||
args := append([]string{"exec", "-T", "core", "curl", "-fsS", "-X", "POST"}, internalIdentityHeaders...)
|
||||
args = append(args, "http://127.0.0.1:8787/pi-management/test")
|
||||
smoke, err := runCompose(ctx, runner, args...)
|
||||
if err != nil {
|
||||
return commandError("Pi smoke check", health, err)
|
||||
return commandError("Pi smoke check", smoke, err)
|
||||
}
|
||||
var healthPayload struct {
|
||||
Status string `json:"status"`
|
||||
var smokePayload struct {
|
||||
Ready bool `json:"ready"`
|
||||
}
|
||||
if json.Unmarshal([]byte(health.Stdout), &healthPayload) != nil || healthPayload.Status != "ok" {
|
||||
return errors.New("Pi smoke health response is not ready")
|
||||
if json.Unmarshal([]byte(smoke.Stdout), &smokePayload) != nil || !smokePayload.Ready {
|
||||
return errors.New("Pi smoke response is not ready")
|
||||
}
|
||||
models, err := ConfigurationOptions(ctx, runner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settingsArgs := append([]string{"exec", "-T", "core", "curl", "-fsS"}, internalIdentityHeaders...)
|
||||
settingsArgs = append(settingsArgs, "http://127.0.0.1:8787/settings")
|
||||
settings, err := runCompose(ctx, runner, settingsArgs...)
|
||||
if err != nil {
|
||||
return commandError("Pi smoke settings check", settings, err)
|
||||
}
|
||||
var selected Defaults
|
||||
if json.Unmarshal([]byte(settings.Stdout), &selected) != nil || !choicePattern.MatchString(selected.Provider) || !choicePattern.MatchString(selected.Model) || (selected.Thinking != "low" && selected.Thinking != "medium" && selected.Thinking != "high") {
|
||||
return errors.New("Pi smoke settings response is incomplete")
|
||||
}
|
||||
for _, model := range models {
|
||||
if model.Provider == selected.Provider && model.ID == selected.Model {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("configured provider/model does not match an available Pi model entry")
|
||||
}
|
||||
|
||||
func renderedCore(ctx context.Context, runner Runner) (Image, error) {
|
||||
|
||||
@@ -123,8 +123,8 @@ func (f *configureRunner) Run(_ context.Context, args []string, stdin io.Reader)
|
||||
endpoint = "https://drift.example.invalid"
|
||||
}
|
||||
return compose.Result{Stdout: `{"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"` + endpoint + `"}}}}`}, nil
|
||||
case strings.Contains(call, "/models"):
|
||||
return compose.Result{Stdout: `{"models":[{"provider":"old","id":"old-model"},{"provider":"new","id":"new-model"}]}`}, nil
|
||||
case strings.Contains(call, "/pi-management/options"):
|
||||
return compose.Result{Stdout: `{"providers":["old","new"],"models":[{"provider":"old","id":"old-model"},{"provider":"new","id":"new-model"}],"reasoning":["low","medium","high"]}`}, nil
|
||||
case strings.Contains(call, "settings-cli.js --snapshot"):
|
||||
raw := f.settingsRaw
|
||||
payload := map[string]any{"exists": f.settingsExist, "rawBase64": base64.StdEncoding.EncodeToString(raw)}
|
||||
@@ -218,11 +218,14 @@ func TestConfigureCompensationRestoresAbsentAndExactEmptyPriorFiles(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureValidatesBackendModelOptionsWritesRealCoreSettingsAndUsesUpstreamIdentity(t *testing.T) {
|
||||
// Catches thothctl reading the legacy public model route instead of the admin-only closed Pi
|
||||
// Management choices before it writes shared installation defaults.
|
||||
func TestConfigureLoadsDedicatedClosedOptionsWritesRealCoreSettingsAndUsesUpstreamIdentity(t *testing.T) {
|
||||
fake := newFakeRunner()
|
||||
if err := Configure(context.Background(), fake, Defaults{Provider: "provider", Model: "model", Thinking: "medium"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertCalled(t, fake.calls, "/pi-management/options")
|
||||
assertCalled(t, fake.calls, "node /app/backend/dist/settings/settings-cli.js --provider provider --model model --thinking medium")
|
||||
assertCalled(t, fake.calls, "x-thoth-principal-subject: thothctl-maintenance")
|
||||
if got := strings.Join(fake.calls, "\n"); strings.Contains(got, "pi-defaults.json") || strings.Contains(got, "secret") {
|
||||
@@ -233,28 +236,47 @@ func TestConfigureValidatesBackendModelOptionsWritesRealCoreSettingsAndUsesUpstr
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestUsesOnlySanitizedPiAndCoreProbes(t *testing.T) {
|
||||
// Catches a smoke check that composes health/models/settings itself and drifts from the dedicated
|
||||
// backend contract, rather than retaining only the independent in-container version signal.
|
||||
func TestTestUsesDedicatedSmokeEndpointAndIndependentImageVersionProbe(t *testing.T) {
|
||||
fake := newFakeRunner()
|
||||
if err := Test(context.Background(), fake); err != nil {
|
||||
t.Fatalf("Test() error = %v", err)
|
||||
}
|
||||
for _, command := range []string{"pi --version", "/health", "/models", "/settings"} {
|
||||
for _, command := range []string{"pi --version", "/pi-management/test", "x-thoth-principal-subject: thothctl-maintenance"} {
|
||||
assertCalled(t, fake.calls, command)
|
||||
}
|
||||
for _, legacy := range []string{"/health", "/models", "/settings"} {
|
||||
if strings.Contains(strings.Join(fake.calls, "\n"), legacy) {
|
||||
t.Fatalf("Pi smoke invoked legacy endpoint %q: %s", legacy, strings.Join(fake.calls, "\n"))
|
||||
}
|
||||
}
|
||||
if got := strings.Join(fake.calls, "\n"); strings.Contains(got, "secret") {
|
||||
t.Fatalf("probe commands expose secret: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestRequiresConfiguredProviderAndModelToMatchOneAvailableEntry(t *testing.T) {
|
||||
// Catches an ignored negative ready result from the backend smoke endpoint, which would report a
|
||||
// successfully verified candidate image while its configured Pi runtime is unusable.
|
||||
func TestTestRequiresDedicatedSmokeEndpointToReportReady(t *testing.T) {
|
||||
fake := newFakeRunner()
|
||||
fake.modelsWire = `{"models":[{"id":"different-model","provider":"provider"}]}`
|
||||
if err := Test(context.Background(), fake); err == nil || !strings.Contains(err.Error(), "configured provider/model") {
|
||||
t.Fatalf("Test() error = %v, want exact settings/model mismatch", err)
|
||||
fake.piManagementTestWire = `{"ready":false,"message":"provider unavailable"}`
|
||||
if err := Test(context.Background(), fake); err == nil || !strings.Contains(err.Error(), "Pi smoke response is not ready") {
|
||||
t.Fatalf("Test() error = %v, want negative dedicated smoke result", err)
|
||||
}
|
||||
fake.modelsWire = `{"models":[{"id":"model","provider":"provider"}]}`
|
||||
fake.piManagementTestWire = `{"ready":true}`
|
||||
if err := Test(context.Background(), fake); err != nil {
|
||||
t.Fatalf("Test() exact match error = %v", err)
|
||||
}
|
||||
assertCalled(t, fake.calls, "pi --version")
|
||||
}
|
||||
|
||||
// Catches thothctl accepting a reasoning level that the backend did not publish as a closed
|
||||
// installation option, which would bypass the Pi Management validation surface.
|
||||
func TestConfigureRejectsReasoningOutsideDedicatedClosedOptions(t *testing.T) {
|
||||
fake := newFakeRunner()
|
||||
fake.piManagementOptionsWire = `{"providers":["provider"],"models":[{"provider":"provider","id":"model"}],"reasoning":["low"]}`
|
||||
if err := Configure(context.Background(), fake, Defaults{Provider: "provider", Model: "model", Thinking: "high"}); err == nil || !strings.Contains(err.Error(), "Pi options") {
|
||||
t.Fatalf("Configure() error = %v, want closed reasoning rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,6 +517,8 @@ func TestMaintenanceClearAndCompensationFailuresRemainGated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Catches maintenance recovery clearing admission after the obsolete composite smoke rather than
|
||||
// the same dedicated Pi Management smoke contract used for ordinary image verification.
|
||||
func TestRecoverMaintenanceClearsOnlyAfterTerminalStateAndVerifiedSmoke(t *testing.T) {
|
||||
fake := newFakeRunner()
|
||||
fake.maintenance = true
|
||||
@@ -544,8 +546,7 @@ func TestRecoverMaintenanceClearsOnlyAfterTerminalStateAndVerifiedSmoke(t *testi
|
||||
if selected := readSelectorReference(t, currentImageOverridePath(statePath)); selected != previous.Reference {
|
||||
t.Fatalf("maintenance cleanup changed durable selector to %q", selected)
|
||||
}
|
||||
assertCalled(t, fake.calls, "/models")
|
||||
assertCalled(t, fake.calls, "/settings")
|
||||
assertCalled(t, fake.calls, "/pi-management/test")
|
||||
}
|
||||
|
||||
func TestRecoverMaintenanceRefusesPendingTransaction(t *testing.T) {
|
||||
@@ -811,6 +812,8 @@ type fakeRunner struct {
|
||||
backendRestarts int
|
||||
dropMaintenanceAfterCandidate bool
|
||||
modelsWire string
|
||||
piManagementOptionsWire string
|
||||
piManagementTestWire string
|
||||
rollbackPrepared bool
|
||||
coreRunning bool
|
||||
execFailuresWhileStopped int
|
||||
@@ -867,7 +870,7 @@ func (f *fakeRunner) Run(_ context.Context, args []string, _ io.Reader) (compose
|
||||
if f.fail == "version" && f.built && strings.Contains(call, "pi --version") && strings.Contains(call, "exec") {
|
||||
return compose.Result{ExitCode: 1}, errors.New("version token=secret")
|
||||
}
|
||||
if f.fail == "smoke" && f.built && strings.Contains(call, "127.0.0.1:8787/models") {
|
||||
if f.fail == "smoke" && f.built && strings.Contains(call, "127.0.0.1:8787/pi-management/test") {
|
||||
return compose.Result{ExitCode: 1}, errors.New("smoke token=secret")
|
||||
}
|
||||
switch {
|
||||
@@ -1016,6 +1019,19 @@ func (f *fakeRunner) Run(_ context.Context, args []string, _ io.Reader) (compose
|
||||
return compose.Result{Stdout: f.version + "\n"}, nil
|
||||
case strings.Contains(call, "PI_VERSION"):
|
||||
return compose.Result{Stdout: f.expectedVersion + "\n"}, nil
|
||||
case strings.Contains(call, "/pi-management/options"):
|
||||
if f.piManagementOptionsWire != "" {
|
||||
return compose.Result{Stdout: f.piManagementOptionsWire}, nil
|
||||
}
|
||||
return compose.Result{Stdout: `{"providers":["provider"],"models":[{"id":"model","provider":"provider"}],"reasoning":["low","medium","high"]}`}, nil
|
||||
case strings.Contains(call, "/pi-management/test"):
|
||||
if f.currentImage == "sha256:old" {
|
||||
f.restoredProofComplete = true
|
||||
}
|
||||
if f.piManagementTestWire != "" {
|
||||
return compose.Result{Stdout: f.piManagementTestWire}, nil
|
||||
}
|
||||
return compose.Result{Stdout: `{"ready":true}`}, nil
|
||||
case strings.Contains(call, "/models"):
|
||||
if f.modelsWire != "" {
|
||||
return compose.Result{Stdout: f.modelsWire}, nil
|
||||
|
||||
Reference in New Issue
Block a user