evOo-local/infra/updater/src/updater-logic.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

153 lines
6.3 KiB
TypeScript

import path from "node:path";
import {
DEFAULT_IMAGE_TAG,
IMAGE_TAG_ENV,
isValidImageName,
isValidImageTag,
OFFICIAL_SERVER_IMAGE,
PREVIOUS_IMAGE_TAG_ENV,
RECREATED_SERVICES,
resolveComposeProjectName,
resolveUpdaterToken,
} from "@rakazo/core";
export const DEFAULT_UPDATER_PORT = 7092;
export const DEFAULT_COMPOSE_FILE = "infra/compose/docker-compose.prod.yml";
/** Compose's own convention for a multi-file list, matching COMPOSE_FILE. */
export const DEFAULT_COMPOSE_PATH_SEPARATOR = ":";
const SERVICE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
/** The sidecar itself, which must never appear in the set of services an update recreates. */
const UPDATER_SERVICE = "updater";
export const MAX_STEP_OUTPUT = 8_000;
export interface UpdaterConfig {
/** Bind-mounted at the same absolute path it has on the host, so Compose resolves identically. */
deployDir: string;
/** Overlay order, absolute. Each becomes its own `--file`. */
composeFiles: readonly string[];
/** Services the update pulls, recreates and rolls back. */
updateServices: readonly string[];
envFile: string;
/** Passed as `docker compose -p`. Compose injects this into running services. */
projectName: string;
image: string;
token: string;
host: string;
port: number;
}
/**
* The deployment directory has to be an absolute path because Compose derives every relative bind
* mount from it. Mounting it anywhere other than its host path would make this container reconcile
* a *different* tree than the operator's. The project name is passed as `-p` separately so a custom
* `docker compose -p` stack is the one that is updated.
*/
export function resolveUpdaterConfig(env: NodeJS.ProcessEnv): UpdaterConfig {
const deployDir = env.RAKAZO_DEPLOY_DIR?.trim() ?? "";
if (deployDir === "" || !path.posix.isAbsolute(deployDir)) {
throw new Error("Set RAKAZO_DEPLOY_DIR to the absolute path of the deployment directory.");
}
const image = env.RAKAZO_IMAGE?.trim() || OFFICIAL_SERVER_IMAGE;
if (!isValidImageName(image))
throw new Error(`RAKAZO_IMAGE is not a usable image name: ${image}`);
// Compose itself takes a list here (COMPOSE_FILE with COMPOSE_PATH_SEPARATOR), and a deployment
// that layers an overlay must be reconciled with the same list the operator runs by hand. Every
// entry is validated separately: one bad path in a list is still a path escape.
const separator = env.COMPOSE_PATH_SEPARATOR?.trim() || DEFAULT_COMPOSE_PATH_SEPARATOR;
const composeFiles = (env.RAKAZO_COMPOSE_FILE?.trim() || DEFAULT_COMPOSE_FILE)
.split(separator)
.map((entry) => entry.trim())
.filter((entry) => entry !== "");
if (composeFiles.length === 0) {
throw new Error("RAKAZO_COMPOSE_FILE must name at least one Compose file.");
}
for (const composeFile of composeFiles) {
if (path.posix.isAbsolute(composeFile) || composeFile.split("/").includes("..")) {
throw new Error("RAKAZO_COMPOSE_FILE must list paths inside the deployment directory.");
}
}
// An overlay that adds a service built from the application image has to be recreated with the
// rest, or an update leaves it running the old code. These are appended to the built-in set
// rather than replacing it, so a typo cannot drop `api` from an update.
const extraServices = (env.RAKAZO_UPDATE_SERVICES ?? "")
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry !== "");
for (const service of extraServices) {
if (!SERVICE_NAME.test(service)) {
throw new Error(`RAKAZO_UPDATE_SERVICES contains an unusable service name: ${service}`);
}
// The sidecar is the process running the update. Recreating it would kill the request
// mid-flight, so there would be nothing left to finish the run, report it, or recover from a
// failed step. RECREATED_SERVICES already excludes it on purpose; this keeps that true.
if (service === UPDATER_SERVICE) {
throw new Error(
"RAKAZO_UPDATE_SERVICES must not contain the updater: it cannot recreate itself mid-update.",
);
}
}
const updateServices = [
...RECREATED_SERVICES,
...extraServices.filter((service) => !RECREATED_SERVICES.includes(service as never)),
];
const port = Number(env.RAKAZO_UPDATER_PORT ?? DEFAULT_UPDATER_PORT);
if (!Number.isInteger(port) || port <= 0 || port > 65_535) {
throw new Error("RAKAZO_UPDATER_PORT is not a port number.");
}
return {
deployDir,
composeFiles: composeFiles.map((composeFile) => path.posix.join(deployDir, composeFile)),
updateServices,
envFile: path.posix.join(deployDir, ".env"),
projectName: resolveComposeProjectName(env),
image,
token: resolveUpdaterToken(env),
host: env.RAKAZO_UPDATER_HOST?.trim() || "127.0.0.1",
port,
};
}
/**
* Reads one assignment out of a `.env` file the way Compose does: later assignments win, comments
* and blank lines are ignored, and one layer of quoting is removed.
*/
export function readEnvAssignment(contents: string, key: string): string | null {
let found: string | null = null;
for (const raw of contents.split(/\r?\n/)) {
const line = raw.trim();
if (line === "" || line.startsWith("#")) continue;
const separator = line.indexOf("=");
if (separator <= 0 || line.slice(0, separator).trim() !== key) continue;
const value = line.slice(separator + 1).trim();
found = value.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1");
}
return found;
}
export interface TagState {
currentTag: string;
previousTag: string | null;
}
/** What the deployment is pinned to now, ignoring anything in the file that is not a usable tag. */
export function readTagState(envContents: string): TagState {
const current = readEnvAssignment(envContents, IMAGE_TAG_ENV);
const previous = readEnvAssignment(envContents, PREVIOUS_IMAGE_TAG_ENV);
return {
currentTag: current !== null && isValidImageTag(current) ? current : DEFAULT_IMAGE_TAG,
previousTag: previous !== null && isValidImageTag(previous) ? previous : null,
};
}
export function truncateOutput(value: string): string {
const text = value.trimEnd();
if (text.length <= MAX_STEP_OUTPUT) return text;
return `…${text.slice(-MAX_STEP_OUTPUT)}`;
}
/**
* The sidecar answers with a plain reason when it will not act, so the API can surface it as a bad
* request instead of an opaque 500.
*/
export class UpdateRefused extends Error {}