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.
717 lines
26 KiB
TypeScript
717 lines
26 KiB
TypeScript
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 = "elie222/rakazo";
|
|
|
|
/** 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<Record<string, string | undefined>>,
|
|
): 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-<commit>`) 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 <url>`, which is the tag list without cloning anything. */
|
|
export function parseLsRemoteTags(stdout: string): string[] {
|
|
const tags = new Set<string>();
|
|
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<string, { direct?: string; peeled?: string }>();
|
|
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 <name>`. 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<string>();
|
|
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<Record<string, string>>,
|
|
): 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) },
|
|
};
|
|
}
|