105 lines
6.7 KiB
TypeScript
105 lines
6.7 KiB
TypeScript
import { request } from "node:http";
|
|
import { z } from "zod";
|
|
|
|
const version = z.string().regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/);
|
|
export const requestId = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{7,79}$/);
|
|
export const checkBody = z.object({ requestId, version: version.optional() }).strict();
|
|
export const applyBody = z.object({ requestId, version, checkRequestId: requestId }).strict();
|
|
const errors = {
|
|
INVALID_REQUEST: "Ungültige Anfrage.", NOT_FOUND: "Auftrag nicht gefunden.",
|
|
BUSY: "Eine Prüfung oder ein Update läuft bereits.", SHUTTING_DOWN: "Der Update-Dienst wird beendet.",
|
|
REQUEST_CONFLICT: "Diese Auftrags-ID wurde bereits für eine andere Anfrage verwendet.",
|
|
CHECK_REQUIRED: "Bitte die Zielversion erneut prüfen.",
|
|
COMPOSE_REVIEW_REQUIRED: "Die Compose-Datei muss zuerst auf dem Host geprüft und übernommen werden.",
|
|
RECOVERY_REQUIRED: "Ein vorheriges Update benötigt Wiederherstellung auf dem Host.",
|
|
PLAN_CHANGED: "Release oder Konfiguration haben sich geändert. Bitte erneut prüfen.",
|
|
UPDATE_FAILED: "Auftrag fehlgeschlagen.",
|
|
LOGS_UNAVAILABLE: "Das Update-Protokoll kann gerade nicht gelesen werden.",
|
|
INTERRUPTED: "Der Auftrag wurde unterbrochen. Status auf dem Host prüfen.",
|
|
UPDATER_UNAVAILABLE: "Der Host-Update-Dienst ist nicht erreichbar. Dienst und Socket-Einbindung prüfen.",
|
|
UPDATER_PROTOCOL: "Der Host-Update-Dienst hat eine ungültige Antwort geliefert.",
|
|
};
|
|
type ErrorCode = keyof typeof errors;
|
|
const knownError = (code: unknown): code is ErrorCode => typeof code === "string" && Object.prototype.hasOwnProperty.call(errors, code);
|
|
export class UpdaterError extends Error {
|
|
constructor(public code: ErrorCode, public status = 503) { super(errors[code]); }
|
|
}
|
|
const state = z.object({
|
|
status: z.enum(["idle", "queued", "running", "checked", "up_to_date", "succeeded", "failed", "interrupted", "recovered"]),
|
|
phase: z.enum(["preparing", "validating", "checked", "pulling", "maintenance", "backed_up", "applying",
|
|
"retiring_matrix", "migrating", "starting", "verifying", "complete", "resolved"]).optional(),
|
|
command: z.enum(["check", "apply"]).optional(), requestId: requestId.optional(),
|
|
version: version.optional(), previousVersion: version.optional(), revision: z.string().regex(/^[a-f0-9]{40}$/).optional(),
|
|
recoveryRequired: z.boolean().optional(), maintenance: z.boolean().optional(), mutationStarted: z.boolean().optional(),
|
|
startedAt: z.string().regex(/^[0-9T:+.Z-]{10,40}$/).optional(), updatedAt: z.string().regex(/^[0-9T:+.Z-]{10,40}$/).optional(),
|
|
});
|
|
const plan = state.omit({ status: true }).extend({
|
|
updateAvailable: z.boolean(), requiresComposeAdoption: z.boolean(),
|
|
estimatedBackupBytes: z.number().nonnegative().finite().optional(),
|
|
storageProvider: z.enum(["minio", "seaweedfs"]).optional(), storageImageRetained: z.boolean().optional(),
|
|
retiredServices: z.array(z.enum(["matrix-db", "matrix-synapse", "matrix-redis", "matrix-element",
|
|
"matrix-livekit", "matrix-rtc-jwt", "matrix-turn", "matrix-well-known"])),
|
|
});
|
|
const job = state.extend({ plan: plan.optional(), progress: state.optional(),
|
|
error: z.object({ code: z.string() }).transform(({ code }) => ({
|
|
code: knownError(code) ? code : "UPDATE_FAILED",
|
|
message: knownError(code) ? errors[code] : errors.UPDATE_FAILED,
|
|
})).optional(),
|
|
});
|
|
const status = z.object({ apiVersion: z.literal(1), acceptingRequests: z.boolean(), engine: state,
|
|
installed: state.pick({ version: true, revision: true }), request: job.optional(),
|
|
capabilities: z.object({ requestLogs: z.boolean() }).optional(),
|
|
storage: z.object({ provider: z.literal("seaweedfs"), migrationPending: z.boolean() }).optional(),
|
|
});
|
|
const logs = z.object({ requestId, logs: z.array(z.object({
|
|
source: z.enum(["service", "events", "commands"]), content: z.string().max(6000), truncated: z.boolean(),
|
|
})).max(3),
|
|
});
|
|
|
|
// No caller-controlled host, URL, socket, headers or command can reach the privileged service.
|
|
export function createUpdaterClient(socketPath = "/run/fedeo-updater/control.sock", timeoutMs = 5000) {
|
|
return (method: "GET" | "POST", path: string, body?: unknown): Promise<{ status: number; body: unknown }> => {
|
|
if (!((method === "GET" && (path === "/v1/status" || /^\/v1\/requests\/[A-Za-z0-9][A-Za-z0-9_-]{7,79}(?:\/logs)?$/.test(path)))
|
|
|| (method === "POST" && ["/v1/check", "/v1/apply"].includes(path)))) {
|
|
return Promise.reject(new UpdaterError("INVALID_REQUEST", 400));
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
const req = request({ socketPath, path, method, agent: false, headers: payload ? {
|
|
"Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload),
|
|
} : {} }, res => {
|
|
const chunks: Buffer[] = [];
|
|
let bytes = 0;
|
|
res.on("data", chunk => {
|
|
bytes += chunk.length;
|
|
if (bytes > 65536) req.destroy(new UpdaterError("UPDATER_PROTOCOL", 502));
|
|
else chunks.push(chunk);
|
|
});
|
|
res.on("error", () => reject(new UpdaterError("UPDATER_UNAVAILABLE")));
|
|
res.on("end", () => {
|
|
try {
|
|
const data = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
const code = data?.error?.code;
|
|
if (![200, 202].includes(res.statusCode || 0)) {
|
|
throw knownError(code) && [400, 404, 409, 503].includes(res.statusCode!)
|
|
? new UpdaterError(code, res.statusCode) : new UpdaterError("UPDATER_PROTOCOL", 502);
|
|
}
|
|
const schema = path === "/v1/status" ? status : path.endsWith("/logs") ? logs : job;
|
|
const parsed = schema.parse(data);
|
|
if (path.endsWith("/logs") && data.requestId !== path.split("/")[3]) {
|
|
throw new UpdaterError("UPDATER_PROTOCOL", 502);
|
|
}
|
|
resolve({ status: res.statusCode!, body: parsed });
|
|
} catch (error) {
|
|
reject(error instanceof UpdaterError ? error : new UpdaterError("UPDATER_PROTOCOL", 502));
|
|
}
|
|
});
|
|
});
|
|
const timer = setTimeout(() => req.destroy(new UpdaterError("UPDATER_UNAVAILABLE")), timeoutMs);
|
|
req.on("close", () => clearTimeout(timer));
|
|
req.on("error", error => reject(error instanceof UpdaterError ? error : new UpdaterError("UPDATER_UNAVAILABLE")));
|
|
req.end(payload);
|
|
});
|
|
};
|
|
}
|