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.
199 lines
7.4 KiB
TypeScript
199 lines
7.4 KiB
TypeScript
import { DEFAULT_COMPOSE_PROJECT_NAME, isLocalImageTag } from "@rakazo/core";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
DEFAULT_COMPOSE_FILE,
|
|
DEFAULT_UPDATER_PORT,
|
|
readEnvAssignment,
|
|
readTagState,
|
|
resolveUpdaterConfig,
|
|
truncateOutput,
|
|
} from "./updater-logic.js";
|
|
|
|
const base = {
|
|
RAKAZO_DEPLOY_DIR: "/srv/rakazo",
|
|
RAKAZO_UPDATER_TOKEN: "fake-review-updater-token-000000000000",
|
|
} as const;
|
|
|
|
describe("resolveUpdaterConfig", () => {
|
|
it("derives the compose file, env file, and defaults from the deployment directory", () => {
|
|
const config = resolveUpdaterConfig({ ...base });
|
|
expect(config).toMatchObject({
|
|
deployDir: "/srv/rakazo",
|
|
composeFiles: [`/srv/rakazo/${DEFAULT_COMPOSE_FILE}`],
|
|
updateServices: ["api", "worker", "web"],
|
|
envFile: "/srv/rakazo/.env",
|
|
projectName: DEFAULT_COMPOSE_PROJECT_NAME,
|
|
token: base.RAKAZO_UPDATER_TOKEN,
|
|
port: DEFAULT_UPDATER_PORT,
|
|
});
|
|
});
|
|
|
|
it("uses the Compose-injected project name so -p matches the running stack", () => {
|
|
expect(
|
|
resolveUpdaterConfig({ ...base, COMPOSE_PROJECT_NAME: "operator-stack" }).projectName,
|
|
).toBe("operator-stack");
|
|
expect(
|
|
resolveUpdaterConfig({ ...base, RAKAZO_COMPOSE_PROJECT_NAME: "manual-stack" }).projectName,
|
|
).toBe("manual-stack");
|
|
});
|
|
|
|
it("refuses a project name it would not pass as a single -p argument", () => {
|
|
expect(() => resolveUpdaterConfig({ ...base, COMPOSE_PROJECT_NAME: "-f" })).toThrow(
|
|
/project name/,
|
|
);
|
|
});
|
|
|
|
it("binds to loopback unless the deployment says otherwise, so a stray port is not a door", () => {
|
|
expect(resolveUpdaterConfig({ ...base }).host).toBe("127.0.0.1");
|
|
expect(resolveUpdaterConfig({ ...base, RAKAZO_UPDATER_HOST: "0.0.0.0" }).host).toBe("0.0.0.0");
|
|
});
|
|
|
|
it("refuses a deployment directory that is missing or relative", () => {
|
|
expect(() => resolveUpdaterConfig({ RAKAZO_UPDATER_TOKEN: "t" })).toThrow(/RAKAZO_DEPLOY_DIR/);
|
|
expect(() => resolveUpdaterConfig({ ...base, RAKAZO_DEPLOY_DIR: "srv/rakazo" })).toThrow(
|
|
/RAKAZO_DEPLOY_DIR/,
|
|
);
|
|
});
|
|
|
|
it("refuses a compose path that escapes the deployment directory", () => {
|
|
for (const composeFile of ["/etc/compose.yml", "../../etc/compose.yml", "a/../../b.yml"]) {
|
|
expect(() => resolveUpdaterConfig({ ...base, RAKAZO_COMPOSE_FILE: composeFile })).toThrow(
|
|
/RAKAZO_COMPOSE_FILE/,
|
|
);
|
|
}
|
|
});
|
|
|
|
it("resolves a Compose file list in order, against the deployment directory", () => {
|
|
expect(
|
|
resolveUpdaterConfig({
|
|
...base,
|
|
RAKAZO_COMPOSE_FILE: "infra/compose/docker-compose.prod.yml:ops/overlay.yml",
|
|
}).composeFiles,
|
|
).toEqual(["/srv/rakazo/infra/compose/docker-compose.prod.yml", "/srv/rakazo/ops/overlay.yml"]);
|
|
});
|
|
|
|
it("honours COMPOSE_PATH_SEPARATOR the way Compose does", () => {
|
|
expect(
|
|
resolveUpdaterConfig({
|
|
...base,
|
|
COMPOSE_PATH_SEPARATOR: ",",
|
|
RAKAZO_COMPOSE_FILE: "a.yml,b.yml",
|
|
}).composeFiles,
|
|
).toEqual(["/srv/rakazo/a.yml", "/srv/rakazo/b.yml"]);
|
|
});
|
|
|
|
it("checks every entry in a list, not just the first", () => {
|
|
expect(() =>
|
|
resolveUpdaterConfig({
|
|
...base,
|
|
RAKAZO_COMPOSE_FILE: "infra/compose/docker-compose.prod.yml:../../etc/compose.yml",
|
|
}),
|
|
).toThrow(/RAKAZO_COMPOSE_FILE/);
|
|
expect(() =>
|
|
resolveUpdaterConfig({
|
|
...base,
|
|
RAKAZO_COMPOSE_FILE: "infra/compose/docker-compose.prod.yml:/etc/compose.yml",
|
|
}),
|
|
).toThrow(/RAKAZO_COMPOSE_FILE/);
|
|
});
|
|
|
|
it("refuses a Compose file list that names nothing", () => {
|
|
expect(() => resolveUpdaterConfig({ ...base, RAKAZO_COMPOSE_FILE: ": :" })).toThrow(
|
|
/RAKAZO_COMPOSE_FILE/,
|
|
);
|
|
});
|
|
|
|
it("appends the deployment's extra services to the built-in set", () => {
|
|
expect(
|
|
resolveUpdaterConfig({ ...base, RAKAZO_UPDATE_SERVICES: "supervisor, caddy" }).updateServices,
|
|
).toEqual(["api", "worker", "web", "supervisor", "caddy"]);
|
|
});
|
|
|
|
it("cannot drop a built-in service, however RAKAZO_UPDATE_SERVICES is written", () => {
|
|
const services = resolveUpdaterConfig({
|
|
...base,
|
|
RAKAZO_UPDATE_SERVICES: "web,supervisor",
|
|
}).updateServices;
|
|
expect(services).toEqual(["api", "worker", "web", "supervisor"]);
|
|
});
|
|
|
|
it("refuses to recreate the updater, which would kill the run mid-flight", () => {
|
|
expect(() =>
|
|
resolveUpdaterConfig({ ...base, RAKAZO_UPDATE_SERVICES: "supervisor,updater" }),
|
|
).toThrow(/updater/);
|
|
});
|
|
|
|
it("refuses a service name it would not hand to compose as one argument", () => {
|
|
for (const service of ["--build", "a b", "-f", "api;rm"]) {
|
|
expect(() =>
|
|
resolveUpdaterConfig({ ...base, RAKAZO_UPDATE_SERVICES: `supervisor,${service}` }),
|
|
).toThrow(/RAKAZO_UPDATE_SERVICES/);
|
|
}
|
|
});
|
|
|
|
it("refuses an image name it would not be willing to hand to compose", () => {
|
|
expect(() => resolveUpdaterConfig({ ...base, RAKAZO_IMAGE: "Bad Name" })).toThrow(
|
|
/RAKAZO_IMAGE/,
|
|
);
|
|
expect(resolveUpdaterConfig({ ...base, RAKAZO_IMAGE: "ghcr.io/me/app" }).image).toBe(
|
|
"ghcr.io/me/app",
|
|
);
|
|
});
|
|
|
|
it("refuses a port that is not a port", () => {
|
|
expect(() => resolveUpdaterConfig({ ...base, RAKAZO_UPDATER_PORT: "0" })).toThrow(/port/);
|
|
expect(() => resolveUpdaterConfig({ ...base, RAKAZO_UPDATER_PORT: "seven" })).toThrow(/port/);
|
|
});
|
|
});
|
|
|
|
describe("readEnvAssignment", () => {
|
|
it("reads the last assignment, ignoring comments and blank lines", () => {
|
|
const contents = ["# RAKAZO_IMAGE_TAG=commented", "", "A=1", "A=2"].join("\n");
|
|
expect(readEnvAssignment(contents, "A")).toBe("2");
|
|
expect(readEnvAssignment(contents, "RAKAZO_IMAGE_TAG")).toBeNull();
|
|
});
|
|
|
|
it("removes one layer of quoting", () => {
|
|
expect(readEnvAssignment('A="v1.0.0"', "A")).toBe("v1.0.0");
|
|
expect(readEnvAssignment("A='v1.0.0'", "A")).toBe("v1.0.0");
|
|
});
|
|
|
|
it("does not match a key that merely shares a prefix", () => {
|
|
expect(readEnvAssignment("RAKAZO_IMAGE_TAG_PREVIOUS=v1", "RAKAZO_IMAGE_TAG")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("readTagState", () => {
|
|
it("reads the pinned tag and the rollback tag", () => {
|
|
const contents = "RAKAZO_IMAGE_TAG=v1.1.0\nRAKAZO_IMAGE_TAG_PREVIOUS=v1.0.0\n";
|
|
expect(readTagState(contents)).toEqual({ currentTag: "v1.1.0", previousTag: "v1.0.0" });
|
|
});
|
|
|
|
it("falls back to the locally built tag when nothing is pinned yet", () => {
|
|
expect(readTagState("")).toEqual({ currentTag: "local", previousTag: null });
|
|
});
|
|
|
|
it("ignores values in the file that are not usable tags", () => {
|
|
const contents = "RAKAZO_IMAGE_TAG=-rm\nRAKAZO_IMAGE_TAG_PREVIOUS=$(id)\n";
|
|
expect(readTagState(contents)).toEqual({ currentTag: "local", previousTag: null });
|
|
});
|
|
|
|
/**
|
|
* The fallback has to be a tag no registry serves. A deployment that has never been pinned has
|
|
* only ever built its images locally, so a fallback of `latest` would send both a first
|
|
* `docker compose up` and a rollback to a registry that may have nothing under that name.
|
|
*/
|
|
it("falls back to a tag that is local-only, so nothing tries to pull it", () => {
|
|
expect(isLocalImageTag(readTagState("").currentTag)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("truncateOutput", () => {
|
|
it("keeps short output as-is and keeps the tail of long output", () => {
|
|
expect(truncateOutput("done\n")).toBe("done");
|
|
const long = truncateOutput("x".repeat(20_000));
|
|
expect(long.startsWith("…")).toBe(true);
|
|
expect(long.length).toBeLessThan(9_000);
|
|
});
|
|
});
|