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.
153 lines
6.3 KiB
TypeScript
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 {}
|