import { DEFAULT_UPDATE_REMOTE, isOfficialRepoUrl, normalizeRepoUrl, normalizeUpdateBranch, } from "./self-update.js"; /** * The GitHub repository whose CI fills the image namespace. `publish-server-image.yml` pushes to * `ghcr.io/${{ github.repository }}`, so the namespace always belongs to whichever repository ran * the workflow. Naming a different owner here points the deployment at packages nobody publishes. * * `OFFICIAL_REPO_URL` names the same repository, so the source commit selected from a release is * guaranteed to have been eligible for this repository's publishing workflow. */ export const PUBLISHED_IMAGE_REPO = "cynarai/evoo-local"; /** The published server image. One image runs api, worker, and web. */ export const OFFICIAL_SERVER_IMAGE = `ghcr.io/${PUBLISHED_IMAGE_REPO}/app`; /** The updater sidecar's image, published alongside the server image but tagged independently. */ export const OFFICIAL_UPDATER_IMAGE = `ghcr.io/${PUBLISHED_IMAGE_REPO}/updater`; /** * The tag a deployment that has never run an update is on. It is deliberately *not* `latest`: no * registry serves this tag, so `docker compose up --build` builds it from the checkout and a fresh * install works with an empty registry. `latest` only exists once a release has been published. */ export const LOCAL_IMAGE_TAG = "local"; export const DEFAULT_IMAGE_TAG = LOCAL_IMAGE_TAG; export const IMAGE_TAG_ENV = "RAKAZO_IMAGE_TAG"; export const PREVIOUS_IMAGE_TAG_ENV = "RAKAZO_IMAGE_TAG_PREVIOUS"; /** * Matches the `name:` pinned in `infra/compose/docker-compose.prod.yml`. Used when Compose has not * injected `COMPOSE_PROJECT_NAME` (it does, into running services) and no dedicated override is set. */ export const DEFAULT_COMPOSE_PROJECT_NAME = "rakazo-prod"; export const COMPOSE_PROJECT_NAME_ENV = "COMPOSE_PROJECT_NAME"; export const COMPOSE_PROJECT_NAME_OVERRIDE_ENV = "RAKAZO_COMPOSE_PROJECT_NAME"; /** * The services a recreate replaces. `updater` is deliberately absent: it is the process running * the update, and recreating it would kill the run half way through. `postgres` and `caddy` are * absent because neither uses the Rakazo image. */ export const RECREATED_SERVICES = ["api", "worker", "web"] as const; const IMAGE_TAG = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/; const IMAGE_NAME_SEGMENT = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/; const IMAGE_HOST = /^[a-z0-9]+(?:[.-][a-z0-9]+)*(?::[0-9]{1,5})?$/; const COMMIT = /^[0-9a-f]{40,64}$/; const ENV_KEY = /^[A-Z][A-Z0-9_]*$/; const ENV_VALUE = /^[A-Za-z0-9._:/@+-]{0,256}$/; const RELEASE_TAG = /^v(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/; /** Compose project names must not start with `-`, or `-p` would treat them as a second flag. */ const COMPOSE_PROJECT_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/; export function isValidImageTag(tag: string): boolean { return IMAGE_TAG.test(tag); } /** A registry reference the updater is willing to hand to `docker compose` as one env value. */ export function isValidImageName(name: string): boolean { if (name.length === 0 || name.length > 200) return false; const segments = name.split("/"); const first = segments[0] ?? ""; const hasHost = segments.length > 1 && (first.includes(".") || first.includes(":")); if (hasHost && !IMAGE_HOST.test(first)) return false; return segments.slice(hasHost ? 1 : 0).every((segment) => IMAGE_NAME_SEGMENT.test(segment)); } export function isValidComposeProjectName(name: string): boolean { return COMPOSE_PROJECT_NAME.test(name); } /** * The project `-p` must name. Compose injects `COMPOSE_PROJECT_NAME` into running services, which * is the stack the operator actually started (including `docker compose -p something-else`). A * dedicated override exists for running the sidecar outside Compose. Unset falls back to the name * pinned in the compose file, never to guessing from the working directory. */ export function resolveComposeProjectName( env: Readonly>, ): string { const injected = env[COMPOSE_PROJECT_NAME_ENV]?.trim() ?? ""; const override = env[COMPOSE_PROJECT_NAME_OVERRIDE_ENV]?.trim() ?? ""; const candidate = injected || override; if (candidate === "") return DEFAULT_COMPOSE_PROJECT_NAME; if (!isValidComposeProjectName(candidate)) { throw new Error(`Refusing an unusable Compose project name: ${candidate}`); } return candidate; } export function imageRef(name: string, tag: string): string { if (!isValidImageName(name)) throw new Error(`Refusing an unusable image name: ${name}`); if (!isValidImageTag(tag)) throw new Error(`Refusing an unusable image tag: ${tag}`); return `${name}:${tag}`; } /** * The tag a fork's build is stored under. Forks have no published images, so the tag has to be * local and distinguishable from anything the registry could serve, or a later `pull` of the same * name would silently replace the operator's own build. */ export function forkImageTag(commit: string): string { if (!COMMIT.test(commit)) throw new Error("A fork build needs a resolved commit to tag."); return `local-${commit}`; } /** * A tag that only exists on this host, so trying to pull it would fail rather than find nothing. * Covers both the per-commit fork builds (`local-`) and the bare `local` a fresh install * builds, because a rollback onto either has to skip `docker compose pull`. */ export function isLocalImageTag(tag: string): boolean { return tag === LOCAL_IMAGE_TAG || tag.startsWith(`${LOCAL_IMAGE_TAG}-`); } /** * The source-addressed per-commit tag the publish workflow pushes. This has to agree character for * character with `docker/metadata-action`'s `type=sha`. The workflow deliberately raises * `DOCKER_METADATA_SHORT_SHA_LENGTH` to 40 so source-addressed image tags cannot collide at the * action's unsafe seven-character default. */ export function commitImageTag(commit: string): string { if (!COMMIT.test(commit)) throw new Error("A commit tag needs a resolved commit."); return `sha-${commit}`; } export interface ReleaseTag { tag: string; major: number; minor: number; patch: number; prerelease: string | null; } export function parseReleaseTag(tag: string): ReleaseTag | null { const match = RELEASE_TAG.exec(tag); if (!match) return null; return { tag, major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), prerelease: match[4] ?? null, }; } export function compareReleaseTags(a: ReleaseTag, b: ReleaseTag): number { if (a.major !== b.major) return a.major - b.major; if (a.minor !== b.minor) return a.minor - b.minor; if (a.patch !== b.patch) return a.patch - b.patch; if (a.prerelease === b.prerelease) return 0; if (a.prerelease === null) return 1; if (b.prerelease === null) return -1; return a.prerelease < b.prerelease ? -1 : 1; } /** Reads `git ls-remote --tags `, which is the tag list without cloning anything. */ export function parseLsRemoteTags(stdout: string): string[] { const tags = new Set(); for (const line of stdout.split("\n")) { const ref = line.split("\t")[1]?.trim(); if (!ref?.startsWith("refs/tags/")) continue; const tag = ref.slice("refs/tags/".length).replace(/\^\{\}$/, ""); if (tag !== "" && isValidImageTag(tag)) tags.add(tag); } return [...tags]; } export interface RemoteRelease { tag: string; /** The commit behind the tag, never the object id of an annotated tag. */ commit: string; } /** * Reads the source commit for each release. Annotated tags have two `ls-remote` rows; the peeled * `^{}` row wins so the image tag agrees with `github.sha` in the publishing workflow. */ export function parseLsRemoteReleases(stdout: string): RemoteRelease[] { const releases = new Map(); for (const line of stdout.split("\n")) { const [commit = "", rawRef = ""] = line.trim().split(/\s+/, 2); if (!COMMIT.test(commit) || !rawRef.startsWith("refs/tags/")) continue; const peeled = rawRef.endsWith("^{}"); const tag = rawRef.slice("refs/tags/".length).replace(/\^\{\}$/, ""); if (tag === "" || !isValidImageTag(tag)) continue; const entry = releases.get(tag) ?? {}; if (peeled) entry.peeled = commit; else entry.direct = commit; releases.set(tag, entry); } return [...releases].flatMap(([tag, commits]) => { const commit = commits.peeled ?? commits.direct; return commit === undefined ? [] : [{ tag, commit }]; }); } /** * The newest stable release. Pre-releases are skipped because "update to latest" should not move a * deployment onto a release candidate, and there is no way to opt back out once it has recreated. */ export function selectLatestReleaseTag(tags: readonly string[]): string | null { let best: ReleaseTag | null = null; for (const tag of tags) { const parsed = parseReleaseTag(tag); if (!parsed || parsed.prerelease !== null) continue; if (best === null || compareReleaseTags(parsed, best) > 0) best = parsed; } return best?.tag ?? null; } /** Selects the newest stable release together with the commit-tagged image CI published for it. */ export function selectLatestRelease(releases: readonly RemoteRelease[]): RemoteRelease | null { let best: { release: ReleaseTag; commit: string } | null = null; for (const candidate of releases) { const parsed = parseReleaseTag(candidate.tag); if (!parsed || parsed.prerelease !== null) continue; if (best === null || compareReleaseTags(parsed, best.release) > 0) { best = { release: parsed, commit: candidate.commit }; } } return best === null ? null : { tag: best.release.tag, commit: best.commit }; } export type UpdateStrategy = "pull" | "build"; export interface StrategyDecision { strategy: UpdateStrategy; reason: string; } /** * Only the official repository has published images. A fork has to be built where it is deployed, * which is minutes rather than seconds, so the choice is stated rather than guessed at call sites. */ export function chooseUpdateStrategy(input: { official: boolean }): StrategyDecision { if (input.official) { return { strategy: "pull", reason: "Official releases are published as images, so the update is a download and restart.", }; } return { strategy: "build", reason: "A fork has no published images, so the server builds this update itself. Expect minutes, not seconds.", }; } export type ExecutionMode = "sidecar" | "checkout" | "unavailable"; export interface ExecutionModeDecision { mode: ExecutionMode; reason: string | null; } /** * Which of the two engines can actually apply an update here. The sidecar wins whenever it is * configured: a Compose deployment's own container has no `.git` and nothing to restart it, so its * only route is the container that outlives it. */ export function resolveExecutionMode(input: { hasUpdater?: boolean; hasCheckout?: boolean; disabled?: boolean; }): ExecutionModeDecision { if (input.disabled === true) { return { mode: "unavailable", reason: "Self-update is switched off for this deployment." }; } if (input.hasUpdater === true) return { mode: "sidecar", reason: null }; if (input.hasCheckout === true) return { mode: "checkout", reason: null }; return { mode: "unavailable", reason: "This deployment has neither an updater sidecar nor a git checkout, so it cannot update itself. Add the `updater` service from infra/compose/docker-compose.prod.yml.", }; } /** * What the deployment-owner Settings UI should show. Distinct from {@link resolveExecutionMode}: * Compose without the opt-in sidecar is still a Compose install, and source checkouts must never * look like they can one-click apply from the API process (no `.git` in the app image; no Docker * socket either). */ export type InstallKind = "sidecar" | "compose" | "source"; export interface InstallKindDecision { kind: InstallKind; /** Maps onto ServerUpdateMode for contracts that already use that enum. */ mode: ExecutionMode; reason: string | null; } /** * `updaterUrlConfigured` means the API was given `RAKAZO_UPDATER_URL` (Compose prod always sets * this). Reachability requires both URL and token and a live sidecar. A source checkout is only * claimed when there is no Compose updater wiring and `.git` is present on disk. */ export function resolveInstallKind(input: { updaterUrlConfigured?: boolean; updaterReachable?: boolean; hasCheckout?: boolean; disabled?: boolean; }): InstallKindDecision { if (input.disabled === true) { return { kind: input.updaterUrlConfigured === true ? "compose" : input.hasCheckout === true ? "source" : "compose", mode: "unavailable", reason: "Self-update is switched off for this deployment.", }; } if (input.updaterReachable === true) { return { kind: "sidecar", mode: "sidecar", reason: null }; } if (input.updaterUrlConfigured === true) { return { kind: "compose", mode: "unavailable", reason: "The updater sidecar is not reachable. Start the opt-in `updater` profile, or upgrade from the host with the Compose commands below.", }; } if (input.hasCheckout === true) { return { kind: "source", mode: "checkout", reason: "This is a source checkout. Upgrade from a terminal; Settings cannot apply it.", }; } return { kind: "compose", mode: "unavailable", reason: "This deployment has no updater sidecar. Upgrade from the host with the Compose commands below, or enable the `updater` profile.", }; } /** Exact host commands from docs/self-host.md for Compose on a published release tag. */ export const COMPOSE_PULL_UPGRADE_COMMANDS = [ "docker compose --env-file .env -f infra/compose/docker-compose.prod.yml pull api worker web", "docker compose --env-file .env -f infra/compose/docker-compose.prod.yml up -d --wait --pull never api worker web", ] as const; /** Exact host commands from docs/self-host.md for Compose on the default `local` tag. */ export const COMPOSE_LOCAL_BUILD_UPGRADE_COMMANDS = [ "git pull", "GIT_SHA=$(git rev-parse HEAD) docker compose --env-file .env -f infra/compose/docker-compose.prod.yml up -d --wait --pull never --build api worker web", ] as const; /** @deprecated Prefer {@link COMPOSE_PULL_UPGRADE_COMMANDS}; kept for call-site clarity in tests. */ export const COMPOSE_MANUAL_UPGRADE_COMMANDS = COMPOSE_PULL_UPGRADE_COMMANDS; /** Exact host commands from docs/self-host.md for source / `pnpm dev` installs. */ export const SOURCE_MANUAL_UPGRADE_COMMANDS = [ "git pull", "pnpm --filter @rakazo/db migrate", "# Restart the API and worker processes", ] as const; /** * Host commands Settings should show when the sidecar cannot apply. * Compose picks pull vs rebuild from the current image tag when known; otherwise both documented * paths from self-host.md so a `local` install is not told to `pull` a tag the registry never serves. */ export function manualUpgradeCommands( kind: InstallKind, options: { imageTag?: string | null } = {}, ): readonly string[] { if (kind === "source") return SOURCE_MANUAL_UPGRADE_COMMANDS; if (kind !== "compose") return []; const tag = options.imageTag?.trim() ?? ""; if (tag !== "" && isLocalImageTag(tag)) return COMPOSE_LOCAL_BUILD_UPGRADE_COMMANDS; if (tag !== "" && !isLocalImageTag(tag)) return COMPOSE_PULL_UPGRADE_COMMANDS; return [ "# Published release tag", ...COMPOSE_PULL_UPGRADE_COMMANDS, "# Local tag (rebuild from checkout)", ...COMPOSE_LOCAL_BUILD_UPGRADE_COMMANDS, ]; } export interface ComposeInvocation { command: string; args: string[]; } export interface ComposeTarget { /** * Compose files in overlay order, each passed as its own `--file`. A deployment that layers an * overlay on the base file has to be reconciled with the same file list the operator uses, or an * update recreates services without the overlay's wiring. */ composeFiles: readonly string[]; envFiles?: readonly string[]; /** Passed as `docker compose -p `. Defaults to {@link DEFAULT_COMPOSE_PROJECT_NAME}. */ projectName?: string; /** * Services to pull, recreate and roll back. Defaults to {@link RECREATED_SERVICES}. A deployment * whose overlay adds an app-image service has to recreate that one too, or the update leaves it * running the old code. */ services?: readonly string[]; } function composeBase(target: ComposeTarget): string[] { const projectName = target.projectName ?? DEFAULT_COMPOSE_PROJECT_NAME; if (!isValidComposeProjectName(projectName)) { throw new Error(`Refusing an unusable Compose project name: ${projectName}`); } if (target.composeFiles.length === 0) { throw new Error("Refusing a Compose target with no Compose file."); } const args = ["compose", "-p", projectName]; for (const envFile of target.envFiles ?? []) args.push("--env-file", envFile); for (const composeFile of target.composeFiles) args.push("--file", composeFile); return args; } function targetServices(target: ComposeTarget): readonly string[] { const services = target.services ?? []; return services.length > 0 ? services : RECREATED_SERVICES; } export function composePullArgv( target: ComposeTarget, services: readonly string[] = targetServices(target), ): ComposeInvocation { return { command: "docker", args: [...composeBase(target), "pull", ...services] }; } export function composeUpArgv( target: ComposeTarget, options: { build?: boolean; services?: readonly string[] } = {}, ): ComposeInvocation { const args = [ ...composeBase(target), "up", "--detach", "--wait", "--wait-timeout", "300", "--pull", "never", ]; args.push(options.build === true ? "--build" : "--no-build"); args.push(...(options.services ?? targetServices(target))); return { command: "docker", args }; } export function composePsArgv(target: ComposeTarget): ComposeInvocation { return { command: "docker", args: [...composeBase(target), "ps", "--format", "json"] }; } /** * `git status` as seen inside the Linux sidecar against a Windows checkout. `core.autocrlf=true` * makes CR-only worktree noise match what the host already considers clean. The `-c` is argv, not a * shell fragment. */ export function gitStatusArgv(): string[] { return [ "--no-optional-locks", "-c", "core.autocrlf=true", "status", "--porcelain", "--untracked-files=no", ]; } /** Worktree vs index, ignoring CR at EOL so a CRLF-only file does not count as dirty. */ export function gitWorktreeContentDiffArgv(): string[] { return [ "--no-optional-locks", "-c", "core.autocrlf=true", "diff", "--name-only", "--ignore-cr-at-eol", ]; } /** Index vs HEAD, same CR rule, so a staged content change cannot hide behind the worktree check. */ export function gitIndexContentDiffArgv(): string[] { return [ "--no-optional-locks", "-c", "core.autocrlf=true", "diff", "--cached", "--name-only", "--ignore-cr-at-eol", ]; } /** Untracked source can enter `COPY . .`, so a build must not silently include it. */ export function gitUntrackedFilesArgv(): string[] { return ["--no-optional-locks", "ls-files", "--others", "--exclude-standard"]; } export function parseGitNameOnly(stdout: string): string[] { const paths: string[] = []; for (const line of stdout.split("\n")) { const path = line.trim(); if (path !== "") paths.push(path); } return paths; } export interface TrackedDirtyDecision { dirty: boolean; dirtyPaths: string[]; } /** * Porcelain lists every tracked path git considers dirty, including CRLF-only noise when a Windows * `core.autocrlf=true` checkout is bind-mounted into Linux. `contentChanged` is the same comparison * after `--ignore-cr-at-eol`. Porcelain-only paths are dropped; a path that still has a content * diff stays dirty. If the content diffs could not be read, porcelain is the last word — the guard * is not weakened for a failed filter. */ export function resolveTrackedDirtyPaths(input: { porcelainChanged: readonly string[]; contentChanged: readonly string[]; contentDiffOk: boolean; untrackedPaths?: readonly string[]; }): TrackedDirtyDecision { const untrackedPaths = input.untrackedPaths ?? []; if (!input.contentDiffOk) { const dirtyPaths = uniquePaths([...input.porcelainChanged, ...untrackedPaths]); return { dirty: dirtyPaths.length > 0, dirtyPaths }; } const dirtyPaths = uniquePaths([...input.contentChanged, ...untrackedPaths]); return { dirty: dirtyPaths.length > 0, dirtyPaths }; } function uniquePaths(paths: readonly string[]): string[] { const seen = new Set(); const unique: string[] = []; for (const path of paths) { if (path === "" || seen.has(path)) continue; seen.add(path); unique.push(path); } return unique; } export interface ComposeUpdateStep { id: string; label: string; command: string; args: string[]; } export interface ComposeUpdatePlanInput { strategy: UpdateStrategy; target: ComposeTarget; /** Only the build path touches the checkout, and only when the remote has to be re-pointed. */ repoUrl?: string; branch?: string; repointRemote?: boolean; remote?: string; } /** * Ordered work for one Compose update. * * Migrations are absent on purpose. The `api` service runs `prisma migrate deploy` as the first * thing in its own start command, so a recreate already orders them correctly: Compose stops the * old container, the new one migrates with the new code, then serves. Running migrations from here * would instead apply the new schema while the old process is still answering requests, which is * the window this ordering exists to close. */ export function composeUpdatePlan(input: ComposeUpdatePlanInput): ComposeUpdateStep[] { const steps: ComposeUpdateStep[] = []; if (input.strategy === "build") { const remote = input.remote ?? DEFAULT_UPDATE_REMOTE; const branch = input.branch ?? "main"; if (input.repointRemote === true && input.repoUrl) { steps.push({ id: "remote", label: "Point the checkout at the chosen repository", command: "git", args: ["remote", "set-url", remote, input.repoUrl], }); } steps.push( { id: "fetch", label: "Fetch the latest commits", command: "git", args: ["fetch", remote, branch], }, { id: "checkout", label: `Check out ${branch}`, command: "git", args: ["checkout", branch], }, { id: "merge", label: "Fast-forward to the fetched commit", command: "git", args: ["merge", "--ff-only", `${remote}/${branch}`], }, ); const up = composeUpArgv(input.target, { build: true }); steps.push({ id: "recreate", label: "Build the new images and recreate the services", command: up.command, args: up.args, }); return steps; } const pull = composePullArgv(input.target); const up = composeUpArgv(input.target); steps.push( { id: "pull", label: "Download the new images", command: pull.command, args: pull.args }, { id: "recreate", label: "Recreate the services", command: up.command, args: up.args }, ); return steps; } export type RollbackDecision = { tag: string } | { error: string }; /** * Rollback is "deploy the tag that was running before the last update". The updater reuses the * image already cached on this host instead of trusting that a mutable registry tag still points * at the same content. */ export function rollbackTarget(input: { currentTag: string | null; previousTag: string | null; }): RollbackDecision { if (!input.previousTag) { return { error: "No previous image tag was recorded, so there is nothing to roll back to." }; } if (!isValidImageTag(input.previousTag)) { return { error: "The recorded previous image tag is not a usable tag." }; } if (input.previousTag === input.currentTag) { return { error: "The previous image tag is the one already running." }; } return { tag: input.previousTag }; } /** * Rewrites managed assignments in the deployment's `.env` without disturbing anything else in it, * so the operator's own `docker compose --env-file .env … up -d` keeps using the pinned tag. * Values are checked against a conservative grammar first: this file is read by Compose as * interpolation input, and it is the last place a repository URL or ref could leak into a build * argument or a service definition. */ export function upsertEnvAssignments( contents: string, assignments: Readonly>, ): string { for (const [key, value] of Object.entries(assignments)) { if (!ENV_KEY.test(key)) throw new Error(`Refusing to write the env key ${key}.`); if (!ENV_VALUE.test(value)) throw new Error(`Refusing to write the value of ${key}.`); } const newline = contents.includes("\r\n") ? "\r\n" : "\n"; const lines = contents.split(/\r?\n/); const pending = new Map(Object.entries(assignments)); const next = lines.map((line) => { const key = line.match(/^([A-Z][A-Z0-9_]*)=/)?.[1]; if (key === undefined || !Object.hasOwn(assignments, key)) return line; const value = assignments[key] as string; pending.delete(key); return `${key}=${value}`; }); if (pending.size > 0) { while (next.length > 0 && next[next.length - 1] === "") next.pop(); if (next.length > 0) next.push(""); for (const [key, value] of pending) next.push(`${key}=${value}`); } const rendered = next.join(newline); return rendered.endsWith(newline) ? rendered : `${rendered}${newline}`; } export interface UpdateRequest { repoUrl: string; branch: string; official: boolean; } export type UpdateRequestResult = { request: UpdateRequest } | { error: string }; /** * The sidecar's own front door. It holds the Docker socket, so it is a separate trust boundary and * re-runs every check the API already ran rather than trusting that the caller sanitized anything. */ export function validateUpdateRequest(input: { repoUrl?: unknown; branch?: unknown; }): UpdateRequestResult { if (typeof input.repoUrl !== "string") return { error: "A repository URL is required." }; if (typeof input.branch !== "string") return { error: "A branch is required." }; const url = normalizeRepoUrl(input.repoUrl); if ("error" in url) return { error: url.error }; const branch = normalizeUpdateBranch(input.branch); if ("error" in branch) return { error: branch.error }; return { request: { repoUrl: url.url, branch: branch.branch, official: isOfficialRepoUrl(url.url) }, }; }