fix(auth): address Task 13 deployment review findings

This commit is contained in:
2026-08-17 21:31:25 +02:00
parent 9558eaa508
commit 7e52df2702
22 changed files with 1279 additions and 82 deletions
+15
View File
@@ -82,6 +82,12 @@ export function authenticateSession(deps: AuthDependencies): preHandlerHookHandl
const snapshot = captureAuthConfigSnapshot(request, deps.authentication);
if (isPublicRoute(request)) return;
const operator = loopbackMaintenancePrincipal(request);
if (operator) {
request.principal = operator;
return;
}
if (legacy) {
await legacy(request, reply);
if (reply.sent || !STATE_CHANGING_METHODS.has(request.method)) return;
@@ -138,6 +144,15 @@ export function authenticateSession(deps: AuthDependencies): preHandlerHookHandl
};
}
function loopbackMaintenancePrincipal(request: FastifyRequest): PrincipalContext | undefined {
if (request.ip !== "127.0.0.1" && request.ip !== "::1" && request.ip !== "::ffff:127.0.0.1") return undefined;
if (singleHeader(request.headers["x-thoth-principal-issuer"]) !== "tht"
|| singleHeader(request.headers["x-thoth-principal-subject"]) !== "tht-maintenance"
|| singleHeader(request.headers["x-thoth-principal-display-name"]) !== "Tht maintenance"
|| singleHeader(request.headers["x-thoth-is-admin"]) !== "1") return undefined;
return upstreamPrincipal(request.headers);
}
export function requireCsrf(request: FastifyRequest, reply: FastifyReply): true | FastifyReply {
const expectedOrigin = request.authPublicOrigin;
const token = request.authSessionToken;
+1 -1
View File
@@ -173,7 +173,7 @@ function validateFilename(filename: string, allowClaim = false, allowOidcSlot =
}
function safeThtExecutable(value: string | undefined, pathStyle: AuthStoragePathStyle): string {
const executable = value ?? process.env.THT_BIN ?? "tht";
const executable = value ?? process.env.THT_AUTH_STORAGE_BIN ?? process.env.THT_BIN ?? "tht";
if (typeof executable !== "string" || executable.length === 0 || /[\u0000-\u001f\u007f]/.test(executable)) throw invalid();
if (executable === "tht" || (pathStyle === "windows" && executable === "tht.exe")) return executable;
const paths = pathStyle === "windows" ? win32 : posix;
+3 -2
View File
@@ -1,5 +1,6 @@
import { buildApp, type AppWithAuthSessionStore } from "./app.js";
import { loadConfig } from "./config.js";
import { formatStartupFailure } from "./startup-error.js";
const config = loadConfig(process.env);
const app = buildApp(config) as AppWithAuthSessionStore;
@@ -17,7 +18,7 @@ async function start(): Promise<void> {
console.log(`backend listening on ${address}`);
}
void start().catch(() => {
console.error("backend startup failed");
void start().catch((error: unknown) => {
console.error(formatStartupFailure(error));
process.exitCode = 1;
});
+12
View File
@@ -0,0 +1,12 @@
const STARTUP_CAUSES = new Set([
"auth_config_invalid",
"auth_session_store_invalid",
"workspace_registry_invalid",
]);
/** Return one bounded machine cause; never include the original error text or stack. */
export function formatStartupFailure(error: unknown): string {
const message = error instanceof Error ? error.message : "";
const cause = STARTUP_CAUSES.has(message) ? message : "startup_unknown";
return `backend startup failed: ${cause}`;
}
+37
View File
@@ -184,6 +184,43 @@ test("the session boundary exposes only exact health and authentication protocol
expect((await app.inject({ method: "GET", url: "/auth/configured" })).statusCode).toBe(401);
});
test("the session boundary retains the exact loopback tht maintenance identity in configured auth modes", async () => {
const app = Fastify();
app.addHook("preHandler", authenticateSession({ mode: "local" }));
app.get("/private", async (request) => getPrincipal(request));
app.post("/private", async (request) => getPrincipal(request));
const headers = {
"x-thoth-principal-issuer": "tht",
"x-thoth-principal-subject": "tht-maintenance",
"x-thoth-principal-display-name": "Tht maintenance",
"x-thoth-is-admin": "1",
};
for (const method of ["GET", "POST"] as const) {
const response = await app.inject({ method, url: "/private", headers, remoteAddress: "127.0.0.1" });
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({ issuer: "tht", subject: "tht-maintenance", isAdmin: true });
}
});
test("the session boundary rejects tht maintenance headers outside exact loopback provenance", async () => {
const app = Fastify();
app.addHook("preHandler", authenticateSession({ mode: "local" }));
app.get("/private", async (request) => getPrincipal(request));
const exact = {
"x-thoth-principal-issuer": "tht",
"x-thoth-principal-subject": "tht-maintenance",
"x-thoth-principal-display-name": "Tht maintenance",
"x-thoth-is-admin": "1",
};
expect((await app.inject({ method: "GET", url: "/private", headers: exact, remoteAddress: "172.30.0.9" })).statusCode).toBe(503);
expect((await app.inject({
method: "GET", url: "/private", remoteAddress: "127.0.0.1",
headers: { ...exact, "x-thoth-principal-subject": "not-maintenance" },
})).statusCode).toBe(503);
});
test("the session boundary touches a valid cookie session through the bounded Task 7 store operation", async () => {
const sessions = {
resolve: vi.fn(async () => ({
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { formatStartupFailure } from "../src/startup-error.js";
describe("formatStartupFailure", () => {
it.each([
[new Error("auth_session_store_invalid"), "backend startup failed: auth_session_store_invalid"],
[new Error("auth_config_invalid"), "backend startup failed: auth_config_invalid"],
[new Error("workspace_registry_invalid"), "backend startup failed: workspace_registry_invalid"],
])("emits only an allowlisted startup cause", (error, expected) => {
expect(formatStartupFailure(error)).toBe(expected);
});
it("collapses unknown errors without exposing their message, stack, token, or path", () => {
const error = new Error(
"EACCES password=plain-secret token=token-secret at /run/secrets/private-token",
);
error.stack = "Error: raw failure\n at /app/backend/dist/server.js:42:1";
const formatted = formatStartupFailure(error);
expect(formatted).toBe("backend startup failed: startup_unknown");
for (const leaked of [
"EACCES", "plain-secret", "token-secret", "/run/secrets", "raw failure", "server.js",
]) {
expect(formatted).not.toContain(leaked);
}
});
});
+23
View File
@@ -93,6 +93,29 @@ function bridgeForChild(child: FakeBridgeChild, pathStyle: "windows" | "posix" =
}
describe("Windows auth-storage bridge", () => {
test("uses a dedicated native storage executable without replacing the harness tht", async () => {
vi.stubEnv("THT_BIN", "/opt/venv/bin/tht");
vi.stubEnv("THT_AUTH_STORAGE_BIN", "/usr/local/bin/tht-auth-storage");
const calls: Array<{ executable: string }> = [];
const bridge = createPosixAuthStorageBridge({
invoke: async (call) => {
calls.push(call);
return {
code: 0,
stdout: Buffer.from('{"version":1,"ok":true,"prepared":true}\n'),
stderr: Buffer.alloc(0),
};
},
});
await bridge.ensureLayout("/data/auth");
expect(calls).toHaveLength(1);
expect(calls[0]!.executable).toBe("/usr/local/bin/tht-auth-storage");
expect(process.env.THT_BIN).toBe("/opt/venv/bin/tht");
vi.unstubAllEnvs();
});
test("uses the same bounded hidden bridge to ensure a POSIX session layout", async () => {
const calls: Array<{ executable: string; args: readonly string[]; input: Buffer; timeoutMs: number }> = [];
const bridge = createPosixAuthStorageBridge({
+4
View File
@@ -13,6 +13,10 @@ node /app/docker/ensure-pi-trust.mjs "${THT_HARNESS_DIR:-/app/harness}"
cmd="${1:-server}"
case "$cmd" in
server)
[[ "${THT_AUTH_STATE_ROOT:-/data/auth}" == /data/auth ]] \
|| { printf '%s\n' 'authentication state root is invalid' >&2; exit 1; }
printf '%s\n' '{"version":1,"operation":"ensure-layout","root":"/data/auth"}' \
| "${THT_AUTH_STORAGE_BIN:-/usr/local/bin/tht-auth-storage}" _auth-storage >/dev/null
exec node /app/backend/dist/server.js
;;
check)
+15 -2
View File
@@ -7,6 +7,14 @@ ARG IMAGE_VERSION=local
# ---- Pinned Node source for the runtime binary and npm ----
FROM node:24.16.0-bookworm@sha256:40ad9f3064e67d6860b4bc3fe1880b2953934fd6320ada990e45fe0efa6badd7 AS node-runtime
# ---- Pinned native storage helper used by the authenticated backend ----
FROM golang:1.26.5-bookworm@sha256:1ecb7edf62a0408027bd5729dfd6b1b8766e578e8df93995b225dfd0944eb651 AS tht-auth-storage-build
WORKDIR /src/tools/tht
COPY tools/tht/go.mod tools/tht/go.sum ./
RUN go mod download
COPY tools/tht ./
RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/tht-auth-storage ./cmd/tht
# ---- Stage 0: locked Pi runtime ----
FROM node:24.16.0-bookworm@sha256:40ad9f3064e67d6860b4bc3fe1880b2953934fd6320ada990e45fe0efa6badd7 AS pi-runtime-build
ARG PI_VERSION
@@ -61,7 +69,9 @@ RUN useradd --create-home --uid 10001 --shell /bin/bash thoth
# Docker copies these owned directories into newly-created named volumes, allowing the non-root
# runtime user to create application settings, sessions, registry snapshots, state, and locks.
RUN mkdir -p /home/thoth/.pi/agent /data/settings /data/sessions /data/workspace-registry /data/workspace-secrets \
&& chown -R thoth:thoth /home/thoth/.pi /data
/data/auth/sessions /data/auth/oidc \
&& chown -R thoth:thoth /home/thoth/.pi /data \
&& chmod 0700 /data/auth /data/auth/sessions /data/auth/oidc
COPY harness/ /app/harness/
# Pi scrive lock/settings in .pi: ownership thoth per sopravvivere al rebuild
@@ -91,14 +101,17 @@ COPY backend/package*.json /app/backend/
# Runtime Pi is installed only from the committed lockfile. The image exposes its immutable
# executable directly, so no host Pi installation or writable global npm directory is needed.
COPY --from=pi-runtime-build /opt/pi-runtime/node_modules /opt/pi-runtime/node_modules
COPY --from=tht-auth-storage-build /out/tht-auth-storage /usr/local/bin/tht-auth-storage
RUN ln -s /opt/pi-runtime/node_modules/.bin/pi /usr/local/bin/pi \
&& test "$(pi --version)" = "$PI_VERSION"
&& test "$(pi --version)" = "$PI_VERSION" \
&& test -x /usr/local/bin/tht-auth-storage
ENV PATH="/opt/venv/bin:/usr/local/bin:$PATH" \
PI_VERSION="${PI_VERSION}" \
HOST=0.0.0.0 PORT=8787 \
THT_HARNESS_DIR=/app/harness \
THT_BIN=/opt/venv/bin/tht \
THT_AUTH_STORAGE_BIN=/usr/local/bin/tht-auth-storage \
PI_BIN=pi \
HOME=/home/thoth
+13 -2
View File
@@ -24,8 +24,19 @@ if (services.join(",") !== "core,embedding,embedding-model-init,frontend,qdrant"
if (/omics_portal|chirone|localllm_default|\/home\/chirone/i.test(JSON.stringify(config))) {
throw new Error("default Compose contains application-specific coupling");
}
for (const volume of ["settings", "pi-state", "workspace-registry", "sessions", "auth-state", "qdrant-data", "embedding-models"]) {
if (!config.volumes || !config.volumes[volume]) throw new Error(`missing required volume: ${volume}`);
const expectedVolumes = [
"auth-state",
"embedding-models",
"pi-state",
"qdrant-data",
"sessions",
"settings",
"workspace-registry",
"workspace-secrets",
];
const actualVolumes = Object.keys(config.volumes || {});
if (actualVolumes.join(",") !== expectedVolumes.join(",")) {
throw new Error(`unexpected ordered volume set: ${actualVolumes.join(",")}`);
}
const core = config.services.core;
const frontend = config.services.frontend;
+327 -33
View File
@@ -166,6 +166,44 @@ task13_compose_logged() {
task13_run_logged "$label" "${TASK13_COMPOSE[@]}" "$@"
}
task13_report_core_startup_failure() {
local container_id state exit_code logs cause="startup failure is unclassified"
container_id="$(task13_compose ps --all -q core 2>/dev/null | head -n 1 || true)"
state=""
if [[ -n "$container_id" ]]; then
state="$(docker inspect --format '{{.State.Status}}:{{.State.ExitCode}}' "$container_id" 2>/dev/null || true)"
fi
exit_code="${state##*:}"
[[ "$exit_code" =~ ^[0-9]{1,3}$ ]] || exit_code="unknown"
logs="$(task13_compose logs --no-color --tail 100 core 2>/dev/null || true)"
case "$logs" in
*auth_session_store_invalid*|*auth*storage*request*failed*|*EACCES*auth*|*permission*auth*)
cause="authentication state storage is unavailable"
;;
*auth_config_invalid*|*authentication*configuration*)
cause="authentication configuration is invalid"
;;
*workspace_registry_invalid*|*workspace*registry*)
cause="workspace registry startup validation failed"
;;
esac
printf 'Core startup cause: %s (exit code %s).\n' "$cause" "$exit_code" >&2
}
task13_compose_start_logged() {
local label="$1"
shift
task13_compose_files
if task13_bounded "$TASK13_COMMAND_TIMEOUT" "$label" \
"${TASK13_COMPOSE[@]}" "$@" >>"$TASK13_LOG" 2>&1; then
return 0
fi
TASK13_FAILURE_LOGGED=1
printf 'Task 13 command failed: %s\n' "$label" >&2
task13_report_core_startup_failure
return 1
}
task13_write_environment() {
local remote="$1"
{
@@ -296,6 +334,7 @@ services:
- $TASK13_PI_MODELS:/home/thoth/.pi/agent/models.json:ro
- $TASK13_PI_SETTINGS:/home/thoth/.pi/agent/settings.json:ro
- workspace-registry:/data/workspace-registry
- workspace-secrets:/data/workspace-secrets
- sessions:/data/sessions
- $TASK13_AUTH_ROOT:/run/thothii-auth:ro
- auth-state:/data/auth
@@ -333,6 +372,9 @@ volumes:
workspace-registry:
labels:
io.thothii.task13.run: "$TASK13_RUN_ID"
workspace-secrets:
labels:
io.thothii.task13.run: "$TASK13_RUN_ID"
sessions:
labels:
io.thothii.task13.run: "$TASK13_RUN_ID"
@@ -362,6 +404,9 @@ EOF
task13_write_server_fixture_files() {
local data_root pi_root registry_root remote_path workspace_path
TASK13_OIDC_SERVER="${TASK13_OIDC_SERVER:-$TASK13_TMP/fake-oidc.mjs}"
TASK13_OIDC_CERT="${TASK13_OIDC_CERT:-$TASK13_TMP/fake-oidc-cert.pem}"
TASK13_OIDC_KEY="${TASK13_OIDC_KEY:-$TASK13_TMP/fake-oidc-key.pem}"
printf '{}\n' >"$TASK13_PI_AUTH"
printf 'THT_MODEL_API_KEY=%s\nTHT_OIDC_CLIENT_SECRET=%s\nTHT_AUTHENTIK_API_TOKEN=%s\n' \
"$TASK13_SECRET_VALUE" "$TASK13_OIDC_CLIENT_SECRET" "$TASK13_AUTHENTIK_API_TOKEN" >"$TASK13_SECRETS"
@@ -376,6 +421,63 @@ EOF
chmod 0600 "$TASK13_SECRETS" "$TASK13_SESSION_RUNTIME_PASSWORD" \
"$TASK13_SESSION_MIGRATOR_PASSWORD_FILE"
task13_run_logged "create scoped fake OIDC certificate" openssl req -x509 -newkey rsa:2048 \
-sha256 -nodes -days 1 -subj '/CN=task13-fake-oidc' \
-addext 'subjectAltName=DNS:task13-fake-oidc' \
-keyout "$TASK13_OIDC_KEY" -out "$TASK13_OIDC_CERT"
chmod 0600 "$TASK13_OIDC_KEY"
chmod 0644 "$TASK13_OIDC_CERT"
cat >"$TASK13_OIDC_SERVER" <<'EOF'
import { createPublicKey, generateKeyPairSync } from "node:crypto";
import { readFileSync } from "node:fs";
import https from "node:https";
const origin = "https://task13-fake-oidc:9443";
const issuer = `${origin}/application/o/task13/`;
const expectedToken = process.env.TASK13_AUTHENTIK_API_TOKEN;
const { publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const jwk = { ...createPublicKey(publicKey).export({ format: "jwk" }), kid: "task13", use: "sig", alg: "RS256" };
const send = (response, status, body) => {
const payload = JSON.stringify(body);
response.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(payload) });
response.end(payload);
};
const server = https.createServer({
cert: readFileSync("/fixtures/oidc-cert.pem"),
key: readFileSync("/fixtures/oidc-key.pem"),
}, (request, response) => {
const target = new URL(request.url ?? "/", origin);
if (target.pathname === "/health") return send(response, 200, { status: "ok" });
if (target.pathname.includes(".well-known/openid-configuration")) {
return send(response, 200, {
issuer,
authorization_endpoint: `${origin}/authorize`,
token_endpoint: `${origin}/token`,
jwks_uri: `${origin}/jwks`,
response_types_supported: ["code"],
subject_types_supported: ["public"],
id_token_signing_alg_values_supported: ["RS256"],
});
}
if (target.pathname === "/jwks") return send(response, 200, { keys: [jwk] });
if (target.pathname === "/api/v3/core/groups/") {
if (request.headers.authorization !== `Bearer ${expectedToken}`) return send(response, 401, { detail: "unauthorized" });
const name = target.searchParams.get("name") ?? "";
console.log(`group:${name}`);
const configured = name === "task13-users" || name === "task13-admins";
return send(response, 200, {
pagination: { next: null },
results: configured ? [{ name }, { name: "task13-unrelated" }] : [{ name: "task13-unrelated" }],
});
}
return send(response, 404, { error: "not_found" });
});
server.listen(9443, "0.0.0.0");
EOF
chmod 0644 "$TASK13_OIDC_SERVER"
cat >"$TASK13_SERVER_WORKSPACE_CONFIG" <<'EOF'
language: en
session_storage:
@@ -417,6 +519,7 @@ services:
labels:
io.thothii.task13.run: "$TASK13_RUN_ID"
environment:
NODE_EXTRA_CA_CERTS: /fixtures/task13-oidc-ca.pem
THT_WS_TASK13_SMOKE_DWH_TRANSPORT: postgres_direct
THT_WS_TASK13_SMOKE_DWH_HOST: dwh.task13.invalid
THT_WS_TASK13_SMOKE_DWH_PORT: "5432"
@@ -427,6 +530,7 @@ services:
volumes:
- $remote_path:/fixtures/remote.git:ro
- $TASK13_SESSION_RUNTIME_PASSWORD:/run/secrets/task13-runtime-password:ro
- $TASK13_OIDC_CERT:/fixtures/task13-oidc-ca.pem:ro
frontend:
image: $TASK13_FRONTEND_IMAGE
build:
@@ -639,15 +743,15 @@ task13_configure_local_authentication() {
task13_configure_server_oidc_authentication() {
task13_run_logged "configure fake server OIDC authentication" "$TASK13_THT" --installation "$TASK13_INSTALLATION" auth configure \
--mode oidc --public-url "https://task13.example.invalid" \
--issuer "https://task13-fake-oidc.invalid/application/o/task13/" --client-id task13-smoke-client \
--authentik-base-url "https://task13-fake-authentik.invalid" --user-group task13-users --admin-group task13-admins
--issuer "https://task13-fake-oidc:9443/application/o/task13/" --client-id task13-smoke-client \
--authentik-base-url "https://task13-fake-oidc:9443" --user-group task13-users --admin-group task13-admins
}
task13_start_stack() {
printf '== Build and start isolated local Compose distribution ==\n'
task13_assert_rendered_contract
task13_compose_logged "build local Compose images" build --pull
task13_compose_logged "start local Compose distribution" up --detach --wait --wait-timeout 120
task13_compose_start_logged "start local Compose distribution" up --detach --wait --wait-timeout 120
TASK13_NETWORK="$(docker network ls \
--filter "label=com.docker.compose.project=$TASK13_PROJECT" \
--filter 'label=com.docker.compose.network=thothii' --format '{{.Name}}')"
@@ -674,7 +778,36 @@ task13_start_server_stack() {
printf '== Build and start isolated Linux server profile ==\n'
task13_assert_rendered_contract
task13_compose_logged "build server Compose images" build --pull core frontend
task13_compose_logged "start server Compose distribution" \
task13_compose_logged "create server Compose resources" create core frontend
TASK13_NETWORK="$(docker network ls \
--filter "label=com.docker.compose.project=$TASK13_PROJECT" \
--filter 'label=com.docker.compose.network=thothii' --format '{{.Name}}')"
[[ -n "$TASK13_NETWORK" && "$TASK13_NETWORK" != *$'\n'* ]] \
|| task13_fail "isolated server Compose network was not resolved"
task13_run_logged "start scoped fake OIDC provider" docker run --detach \
--name "$TASK13_OIDC_CONTAINER" \
--label "io.thothii.task13.run=$TASK13_RUN_ID" \
--network "$TASK13_NETWORK" --network-alias task13-fake-oidc \
--user "$(id -u):$(id -g)" \
--env "TASK13_AUTHENTIK_API_TOKEN=$TASK13_AUTHENTIK_API_TOKEN" \
--env NODE_EXTRA_CA_CERTS=/fixtures/oidc-cert.pem \
--entrypoint node \
--volume "$TASK13_OIDC_SERVER:/fixtures/fake-oidc.mjs:ro" \
--volume "$TASK13_OIDC_CERT:/fixtures/oidc-cert.pem:ro" \
--volume "$TASK13_OIDC_KEY:/fixtures/oidc-key.pem:ro" \
"$TASK13_CORE_IMAGE" /fixtures/fake-oidc.mjs
for _attempt in $(seq 1 30); do
if docker exec "$TASK13_OIDC_CONTAINER" node -e \
"fetch('https://task13-fake-oidc:9443/health',{signal:AbortSignal.timeout(3000)}).then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" \
>>"$TASK13_LOG" 2>&1; then
break
fi
sleep 1
done
docker exec "$TASK13_OIDC_CONTAINER" node -e \
"fetch('https://task13-fake-oidc:9443/health',{signal:AbortSignal.timeout(3000)}).then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" \
>>"$TASK13_LOG" 2>&1 || task13_log_failure "scoped fake OIDC provider readiness"
task13_compose_start_logged "start server Compose distribution" \
up --detach --wait --wait-timeout 120 core frontend
}
@@ -729,6 +862,116 @@ task13_assert_local_auth_lifecycle() {
[[ "$unauthenticated" == 401 ]] || task13_fail "local logout did not revoke the remembered session"
}
task13_create_admin_session() {
local frontend login_body me
frontend="$(task13_frontend_address)"
TASK13_ADMIN_COOKIE="$TASK13_TMP/operations-admin.cookies"
login_body="$TASK13_TMP/operations-admin-login.json"
printf '{"username":"%s","password":"%s","remember":true}' \
"$TASK13_AUTH_ADMIN" "$TASK13_AUTH_PASSWORD" >"$login_body"
chmod 0600 "$login_body"
task13_run_logged "create authenticated smoke administration session" curl \
--connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" --max-time "$TASK13_CURL_MAX_TIME" \
--fail --silent --show-error --cookie-jar "$TASK13_ADMIN_COOKIE" \
-H "Origin: http://$frontend" -H 'content-type: application/json' --data-binary "@$login_body" \
"http://$frontend/api/auth/local/login"
me="$(curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" --max-time "$TASK13_CURL_MAX_TIME" \
--fail --silent --show-error --cookie "$TASK13_ADMIN_COOKIE" "http://$frontend/api/me")"
TASK13_ADMIN_CSRF="$(node -e 'const value=JSON.parse(process.argv[1]); if(typeof value.csrfToken!=="string") process.exit(1); process.stdout.write(value.csrfToken)' "$me")" \
|| task13_fail "authenticated smoke administration session lacks CSRF state"
}
task13_authenticated_get() {
local path="$1" frontend
frontend="$(task13_frontend_address)"
curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" --max-time "$TASK13_CURL_MAX_TIME" \
--fail --silent --show-error --cookie "$TASK13_ADMIN_COOKIE" "http://$frontend/api/$path"
}
task13_authenticated_post() {
local path="$1" frontend
frontend="$(task13_frontend_address)"
curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" --max-time 15 \
--fail --silent --show-error --cookie "$TASK13_ADMIN_COOKIE" \
-H "Origin: http://$frontend" -H "x-thothii-csrf: $TASK13_ADMIN_CSRF" \
-X POST "http://$frontend/api/$path"
}
task13_assert_local_restore_reauthentication() {
local frontend archive login_body cookie_before cookie_after me status_before status_after
frontend="$(task13_frontend_address)"
archive="$TASK13_TMP/local-restore-source.zip"
login_body="$TASK13_TMP/local-restore-login.json"
cookie_before="$TASK13_TMP/local-restore-before.cookies"
cookie_after="$TASK13_TMP/local-restore-after.cookies"
printf '{"username":"%s","password":"%s","remember":true}' \
"$TASK13_AUTH_ADMIN" "$TASK13_AUTH_PASSWORD" >"$login_body"
chmod 0600 "$login_body"
task13_run_logged "create pre-backup browser session" curl \
--connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" --max-time "$TASK13_CURL_MAX_TIME" \
--fail --silent --show-error --cookie-jar "$cookie_before" \
-H "Origin: http://$frontend" -H 'content-type: application/json' --data-binary "@$login_body" \
"http://$frontend/api/auth/local/login"
task13_compose_logged "stop local stack for backup" stop
task13_run_logged "create real default-custody backup" "$TASK13_THT" \
--installation "$TASK13_INSTALLATION" backup --output "$archive"
python3 - "$archive" <<'PY'
import json
import sys
import zipfile
with zipfile.ZipFile(sys.argv[1]) as archive:
manifest = json.loads(archive.read("manifest.json"))
volumes = [item["logical_name"] for item in manifest["volumes"]]
if volumes != ["embedding-models", "pi-state", "qdrant-data", "sessions", "settings", "workspace-registry", "workspace-secrets"]:
raise SystemExit(f"unexpected backup volume custody: {volumes}")
entries = manifest["entries"]
if any(item.get("logical_name") == "auth-state" or "/data/auth" in item.get("source_path", "") for item in entries):
raise SystemExit("default backup contains authentication runtime state")
auth = [item for item in entries if item["path"].startswith("authentication-secrets/")]
if len(auth) != 1 or not auth[0]["path"].endswith("-auth.yaml") or auth[0]["archived"]:
raise SystemExit("default backup auth custody is not an auth.yaml reference only")
if any(item["path"].endswith("users.yaml") for item in entries):
raise SystemExit("default backup contains users.yaml")
PY
task13_compose_start_logged "restart local stack before restore" up --detach --wait --wait-timeout 120
status_before="$(curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" --max-time "$TASK13_CURL_MAX_TIME" \
--silent --output /dev/null --write-out '%{http_code}' --cookie "$cookie_before" "http://$frontend/api/me")"
[[ "$status_before" == 200 ]] || task13_fail "pre-backup browser session did not survive an ordinary stop/start"
task13_run_logged "create post-backup browser session" curl \
--connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" --max-time "$TASK13_CURL_MAX_TIME" \
--fail --silent --show-error --cookie-jar "$cookie_after" \
-H "Origin: http://$frontend" -H 'content-type: application/json' --data-binary "@$login_body" \
"http://$frontend/api/auth/local/login"
me="$(curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" --max-time "$TASK13_CURL_MAX_TIME" \
--fail --silent --show-error --cookie "$cookie_after" "http://$frontend/api/me")"
node -e 'const value=JSON.parse(process.argv[1]); if(value.issuer!=="local"||value.session?.remembered!==true) process.exit(1)' "$me" \
|| task13_fail "post-backup browser session was not authenticated"
task13_compose_logged "seed pending OIDC state excluded from restore" exec -T core sh -ceu \
'printf %s "{}" > /data/auth/oidc/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.json && chmod 0600 /data/auth/oidc/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.json && test "$(find /data/auth/sessions -type f | wc -l | tr -d " ")" -ge 2'
task13_compose_logged "stop local stack for restore" stop
task13_run_logged "perform real production restore" "$TASK13_THT" \
--installation "$TASK13_INSTALLATION" restore "$archive" --yes
task13_compose_start_logged "start restored local stack" up --detach --wait --wait-timeout 120
status_before="$(curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" --max-time "$TASK13_CURL_MAX_TIME" \
--silent --output /dev/null --write-out '%{http_code}' --cookie "$cookie_before" "http://$frontend/api/me")"
status_after="$(curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" --max-time "$TASK13_CURL_MAX_TIME" \
--silent --output /dev/null --write-out '%{http_code}' --cookie "$cookie_after" "http://$frontend/api/me")"
[[ "$status_before" == 401 && "$status_after" == 401 ]] \
|| task13_fail "restore did not force every independent browser session to reauthenticate"
task13_compose_logged "verify private empty restored auth state" exec -T core sh -ceu '
test "$(stat -c %a /data/auth)" = 700
test "$(stat -c %a /data/auth/sessions)" = 700
test "$(stat -c %a /data/auth/oidc)" = 700
test "$(stat -c %u /data/auth)" = "$(id -u)"
test -z "$(find /data/auth/sessions /data/auth/oidc -mindepth 1 -print -quit)"
'
task13_create_admin_session
}
task13_assert_runtime() {
local frontend expected_pi actual_pi core_id
frontend="$(task13_frontend_address)"
@@ -745,8 +988,10 @@ task13_assert_runtime() {
'command -v pi >/dev/null'
task13_compose_logged "core Docker socket isolation" exec -T core sh -ceu \
'test ! -e /var/run/docker.sock'
task13_compose_logged "workspace registry bootstrap" exec -T core \
curl --connect-timeout 3 --max-time 10 -fsS http://127.0.0.1:8787/workspace-registry/status
task13_assert_local_auth_lifecycle
task13_create_admin_session
task13_authenticated_get workspace-registry/status >/dev/null \
|| task13_fail "authenticated workspace registry bootstrap failed"
task13_compose_logged "active workspace registry state" exec -T core sh -ceu \
'test -f /data/workspace-registry/state/active.json'
task13_compose_logged "mounted Pi auth readability" exec -T core sh -ceu \
@@ -757,7 +1002,6 @@ task13_assert_runtime() {
[[ "$(docker inspect --format '{{ index .Config.Labels "io.thothii.task13.run" }}' "$core_id")" == "$TASK13_RUN_ID" ]] \
|| task13_fail "core lacks the explicit Task 13 resource label"
task13_assert_maintenance_auth_isolation
task13_assert_local_auth_lifecycle
task13_run_logged "tht Pi doctor" "$TASK13_THT" --installation "$TASK13_INSTALLATION" pi doctor
}
@@ -799,7 +1043,7 @@ task13_report_server_workspace_failure() {
}
task13_assert_server_runtime() {
local frontend unauthenticated trusted_header_status session_status diagnostics diagnostic_status core_id frontend_id
local frontend unauthenticated trusted_header_status session_status diagnostics status provider_requests core_id frontend_id
local expected_core_image expected_frontend_image
frontend="$(task13_frontend_address)"
task13_run_logged "server frontend health" curl \
@@ -857,22 +1101,32 @@ task13_assert_server_runtime() {
if grep -Fq "$TASK13_SECRET_VALUE" "$TASK13_TMP/server-sessions.out"; then
task13_fail "server session failure exposed the fixture secret"
fi
status="$TASK13_TMP/server-auth-status.json"
task13_run_logged "server static OIDC status" "$TASK13_THT" --installation "$TASK13_INSTALLATION" auth status --json
"$TASK13_THT" --installation "$TASK13_INSTALLATION" auth status --json >"$status"
node -e 'const value=JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); if(value.mode!=="oidc"||typeof value.configRevision!=="string"||value.configRevision.length!==64) process.exit(1)' "$status" \
|| task13_fail "server static OIDC status was not valid"
diagnostics="$TASK13_TMP/server-auth-diagnostics.json"
set +e
"$TASK13_THT" --installation "$TASK13_INSTALLATION" auth check --json >"$diagnostics" 2>>"$TASK13_LOG"
diagnostic_status=$?
set -e
[[ "$diagnostic_status" == 1 ]] || task13_fail "fake OIDC diagnostics did not fail closed"
node -e 'const value=JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); if(value.mode!=="oidc"||value.ready!==false||!value.checks.some((item)=>item.code==="oidc_discovery_unreachable")) process.exit(1)' "$diagnostics" \
|| task13_fail "fake OIDC fixture did not produce the expected static diagnostic"
task13_run_logged "server live OIDC diagnostics" "$TASK13_THT" --installation "$TASK13_INSTALLATION" auth check --json
"$TASK13_THT" --installation "$TASK13_INSTALLATION" auth check --json >"$diagnostics"
node -e 'const value=JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); if(value.mode!=="oidc"||value.ready!==true||value.checks.length!==1||value.checks[0].code!=="auth_ready") process.exit(1)' "$diagnostics" \
|| task13_fail "scoped fake OIDC provider did not pass live production diagnostics"
provider_requests="$(docker logs "$TASK13_OIDC_CONTAINER" 2>>"$TASK13_LOG")"
[[ "$(grep -Fc 'group:task13-users' <<<"$provider_requests")" -ge 1 ]] \
|| task13_fail "live OIDC diagnostics did not verify the mandatory user group"
[[ "$(grep -Fc 'group:task13-admins' <<<"$provider_requests")" -ge 1 ]] \
|| task13_fail "live OIDC diagnostics did not verify the mandatory administrator group"
if grep -Fq 'group:task13-unrelated' <<<"$provider_requests" \
|| grep -Fq 'task13-unrelated' "$diagnostics"; then
task13_fail "live OIDC diagnostics queried or warned about an unrelated group"
fi
if grep -Fq "$TASK13_OIDC_CLIENT_SECRET" "$diagnostics" || grep -Fq "$TASK13_AUTHENTIK_API_TOKEN" "$diagnostics"; then
task13_fail "OIDC diagnostics exposed a fixture secret"
fi
}
task13_registry_status() {
task13_compose exec -T core curl --connect-timeout 3 --max-time 10 -fsS \
http://127.0.0.1:8787/workspace-registry/status
task13_authenticated_get workspace-registry/status
}
task13_registry_head() {
@@ -914,8 +1168,7 @@ task13_prepare_persistence() {
TASK13_INITIAL_MOUNTS="$(task13_mount_fingerprint)"
TASK13_INITIAL_HEAD="$(task13_active_registry_head)"
[[ "$TASK13_INITIAL_HEAD" =~ ^[0-9a-f]{40}$ ]] || task13_fail "initial registry head is invalid"
task13_compose exec -T core curl --connect-timeout 3 --max-time 10 -fsS \
http://127.0.0.1:8787/workspaces \
task13_authenticated_get workspaces \
| grep -Fq 'Task 13 Smoke' || task13_fail "initial workspace is unavailable"
}
@@ -939,8 +1192,8 @@ task13_registry_lifecycle() {
task13_commit_registry_change 'Update Task 13 workspace metadata'
task13_write_environment /fixtures/remote.git
task13_compose_logged "online Compose recreation" up --detach --force-recreate --wait --wait-timeout 120
task13_compose exec -T core curl --connect-timeout 3 --max-time 15 -fsS -X POST http://127.0.0.1:8787/workspace-registry/pull >/dev/null
task13_compose exec -T core curl --connect-timeout 3 --max-time 10 -fsS http://127.0.0.1:8787/workspaces | grep -Fq 'Task 13 Smoke Updated' || task13_fail "valid Git update was not activated"
task13_authenticated_post workspace-registry/pull >/dev/null
task13_authenticated_get workspaces | grep -Fq 'Task 13 Smoke Updated' || task13_fail "valid Git update was not activated"
valid_head="$(task13_active_registry_head)"
[[ "$valid_head" =~ ^[0-9a-f]{40}$ && "$valid_head" != "$TASK13_INITIAL_HEAD" ]] || task13_fail "valid Git update did not advance the registry head"
TASK13_INITIAL_HEAD="$valid_head"
@@ -950,7 +1203,7 @@ task13_registry_lifecycle() {
'
printf 'guide v2\n' >"$TASK13_SEED/$TASK13_WORKSPACE_ID/evidence/guide.md"
task13_commit_registry_change 'Update Task 13 workspace evidence only'
task13_compose exec -T core curl --connect-timeout 3 --max-time 15 -fsS -X POST http://127.0.0.1:8787/workspace-registry/pull >/dev/null
task13_authenticated_post workspace-registry/pull >/dev/null
evidence_head="$(task13_active_registry_head)"
[[ "$evidence_head" =~ ^[0-9a-f]{40}$ && "$evidence_head" != "$valid_head" ]] || task13_fail "content-only Git Evidence update did not advance the registry head"
TASK13_INITIAL_HEAD="$evidence_head"
@@ -960,14 +1213,14 @@ task13_registry_lifecycle() {
'
task13_replace_once "$TASK13_SEED/thoth-workspaces.yaml" 'name: Task 13 Smoke Updated' 'name: Task 13 Smoke Drift'
task13_commit_registry_change 'Break Task 13 workspace metadata parity'
if task13_compose exec -T core curl --connect-timeout 3 --max-time 15 -fsS -X POST http://127.0.0.1:8787/workspace-registry/pull >>"$TASK13_LOG" 2>&1; then
if task13_authenticated_post workspace-registry/pull >>"$TASK13_LOG" 2>&1; then
task13_fail "registry accepted catalog/descriptor metadata mismatch"
fi
[[ "$(task13_active_registry_head)" == "$TASK13_INITIAL_HEAD" ]] || task13_fail "metadata mismatch replaced the valid registry head"
task13_compose exec -T core curl --connect-timeout 3 --max-time 10 -fsS http://127.0.0.1:8787/workspaces | grep -Fq 'Task 13 Smoke Updated' || task13_fail "metadata mismatch displaced the valid workspace"
task13_authenticated_get workspaces | grep -Fq 'Task 13 Smoke Updated' || task13_fail "metadata mismatch displaced the valid workspace"
task13_replace_once "$TASK13_SEED/thoth-workspaces.yaml" 'name: Task 13 Smoke Drift' 'name: Task 13 Smoke Updated'
task13_commit_registry_change 'Restore Task 13 workspace metadata parity'
task13_compose exec -T core curl --connect-timeout 3 --max-time 15 -fsS -X POST http://127.0.0.1:8787/workspace-registry/pull >/dev/null
task13_authenticated_post workspace-registry/pull >/dev/null
repaired_head="$(task13_active_registry_head)"
[[ "$repaired_head" =~ ^[0-9a-f]{40}$ && "$repaired_head" != "$TASK13_INITIAL_HEAD" ]] || task13_fail "metadata repair did not restore a fresh valid registry head"
TASK13_INITIAL_HEAD="$repaired_head"
@@ -983,14 +1236,14 @@ task13_registry_lifecycle() {
mkdir -p "$TASK13_SEED/orphan/evidence"
printf 'orphan guide\n' >"$TASK13_SEED/orphan/evidence/guide.md"
task13_commit_registry_change 'Add orphan Task 13 workspace directory'
if task13_compose exec -T core curl --connect-timeout 3 --max-time 15 -fsS -X POST http://127.0.0.1:8787/workspace-registry/pull >>"$TASK13_LOG" 2>&1; then
if task13_authenticated_post workspace-registry/pull >>"$TASK13_LOG" 2>&1; then
task13_fail "registry accepted orphan Task 13 descriptor directory"
fi
[[ "$(task13_active_registry_head)" == "$TASK13_INITIAL_HEAD" ]] || task13_fail "orphan descriptor directory replaced the valid registry head"
task13_compose exec -T core curl --connect-timeout 3 --max-time 10 -fsS http://127.0.0.1:8787/workspaces | grep -Fq 'Task 13 Smoke Updated' || task13_fail "orphan descriptor displaced the valid workspace"
task13_authenticated_get workspaces | grep -Fq 'Task 13 Smoke Updated' || task13_fail "orphan descriptor displaced the valid workspace"
rm -rf "$TASK13_SEED/orphan"
task13_commit_registry_change 'Remove orphan Task 13 workspace directory'
task13_compose exec -T core curl --connect-timeout 3 --max-time 15 -fsS -X POST http://127.0.0.1:8787/workspace-registry/pull >/dev/null
task13_authenticated_post workspace-registry/pull >/dev/null
TASK13_INITIAL_HEAD="$(task13_active_registry_head)"
printf '== Reject the retired flat workspace layout and retain the valid snapshot ==
@@ -999,11 +1252,11 @@ task13_registry_lifecycle() {
cp "$TASK13_ROOT/scripts/fixtures/workspace-registry-task13.yaml" "$TASK13_SEED/workspaces/$TASK13_WORKSPACE_ID.yaml"
printf 'legacy guide\n' >"$TASK13_SEED/workspace-content/$TASK13_WORKSPACE_ID/evidence/guide.md"
task13_commit_registry_change 'Reintroduce retired flat Task 13 workspace layout'
if task13_compose exec -T core curl --connect-timeout 3 --max-time 15 -fsS -X POST http://127.0.0.1:8787/workspace-registry/pull >>"$TASK13_LOG" 2>&1; then
if task13_authenticated_post workspace-registry/pull >>"$TASK13_LOG" 2>&1; then
task13_fail "registry accepted the retired flat Task 13 workspace layout"
fi
[[ "$(task13_active_registry_head)" == "$TASK13_INITIAL_HEAD" ]] || task13_fail "retired flat workspace layout replaced the valid registry head"
task13_compose exec -T core curl --connect-timeout 3 --max-time 10 -fsS http://127.0.0.1:8787/workspaces | grep -Fq 'Task 13 Smoke Updated' || task13_fail "retired flat workspace layout displaced the valid workspace"
task13_authenticated_get workspaces | grep -Fq 'Task 13 Smoke Updated' || task13_fail "retired flat workspace layout displaced the valid workspace"
task13_assert_sentinels
}
@@ -1059,8 +1312,7 @@ task13_update_rollback() {
task13_assert_sentinels
task13_run_logged "post-rollback tht doctor" \
"$TASK13_THT" --installation "$TASK13_INSTALLATION" pi doctor
task13_compose exec -T core curl --connect-timeout 3 --max-time 10 -fsS \
http://127.0.0.1:8787/workspaces \
task13_authenticated_get workspaces \
| grep -Fq 'Task 13 Smoke' || task13_fail "rollback lost the active workspace"
}
@@ -1174,6 +1426,7 @@ task13_cleanup() {
fi
task13_remove_labeled_container "${TASK13_BAD_CANDIDATE_CONTAINER:-}" || cleanup_rc=1
task13_remove_labeled_container "${TASK13_LLM_CONTAINER:-}" || cleanup_rc=1
task13_remove_labeled_container "${TASK13_OIDC_CONTAINER:-}" || cleanup_rc=1
if [[ -n "${TASK13_PROJECT:-}" && -n "${TASK13_ROOT:-}" && -f "${TASK13_OVERRIDE:-}" ]]; then
if task13_assert_project_ownership >>"${TASK13_LOG:-/dev/null}" 2>&1; then
task13_compose_files
@@ -1304,6 +1557,40 @@ task13_self_test_server_workspace_diagnostics() {
|| task13_fail "server diagnostics did not sanitize response and core logs"
}
task13_self_test_core_startup_diagnostics() {
local output
TASK13_SECRET_VALUE="fixture-known-secret"
task13_compose() {
case "$*" in
"ps --all -q core") printf '%s\n' 'task13-core-id' ;;
"logs --no-color --tail 100 core")
printf '%s\n' \
'Error: EACCES: permission denied, mkdir /data/auth/sessions' \
'password=plain-secret token=fixture-known-secret' \
'secret path: /run/secrets/private-token' \
' at createFileAuthSessionStore (/app/backend/dist/auth/session-store.js:101:9)'
;;
*) task13_fail "startup diagnostics requested an unexpected Compose command: $*" ;;
esac
}
docker() {
[[ "$*" == "inspect --format {{.State.Status}}:{{.State.ExitCode}} task13-core-id" ]] \
|| task13_fail "startup diagnostics requested an unexpected Docker command: $*"
printf '%s\n' 'exited:1'
}
output="$(task13_report_core_startup_failure 2>&1)"
unset -f task13_compose docker
[[ "$output" == 'Core startup cause: authentication state storage is unavailable (exit code 1).' ]] \
|| task13_fail "startup diagnostics emitted a non-allowlisted cause: $output"
for leaked in EACCES permission /data/auth /run/secrets plain-secret fixture-known-secret \
createFileAuthSessionStore session-store.js; do
[[ "$output" != *"$leaked"* ]] || task13_fail "startup diagnostics leaked $leaked"
done
}
task13_self_test_cleanup_ownership() {
local calls foreign_error owned_name foreign_name
calls="$(mktemp "${TMPDIR:-/tmp}/thothii-task13-cleanup-contract.XXXXXX")"
@@ -1664,6 +1951,7 @@ task13_self_test_source_contract() {
task13_self_test() {
task13_self_test_sanitizer
task13_self_test_core_startup_diagnostics
task13_self_test_server_workspace_diagnostics
task13_self_test_cleanup_ownership
task13_self_test_image_cleanup_ownership
@@ -1695,6 +1983,7 @@ task13_self_test_case() {
windows) task13_self_test_windows_release_contract ;;
server) task13_self_test_server_release_contract ;;
server-diagnostics) task13_self_test_server_workspace_diagnostics ;;
startup-diagnostics) task13_self_test_core_startup_diagnostics ;;
*) task13_fail "unknown Task 13 self-test case: $1" ;;
esac
}
@@ -1777,8 +2066,12 @@ task13_initialize() {
TASK13_PI_MODELS="$TASK13_TMP/models.json"
TASK13_PI_SETTINGS="$TASK13_TMP/pi-settings.json"
TASK13_LLM_SERVER="$TASK13_TMP/fake-llm.mjs"
TASK13_OIDC_SERVER="$TASK13_TMP/fake-oidc.mjs"
TASK13_OIDC_CERT="$TASK13_TMP/fake-oidc-cert.pem"
TASK13_OIDC_KEY="$TASK13_TMP/fake-oidc-key.pem"
TASK13_THT_DIR="$TASK13_TMP/tht"
TASK13_LLM_CONTAINER="$TASK13_PROJECT-llm"
TASK13_OIDC_CONTAINER="$TASK13_PROJECT-oidc"
TASK13_BAD_CANDIDATE_CONTAINER="$TASK13_PROJECT-bad-candidate"
TASK13_CORE_IMAGE="task13-core-$TASK13_RUN_ID:local"
TASK13_FRONTEND_IMAGE="task13-frontend-$TASK13_RUN_ID:local"
@@ -1799,7 +2092,7 @@ task13_initialize() {
}
task13_require_tools() {
for command in bash git docker curl node sed awk grep rg sort; do
for command in bash git docker curl node openssl python3 sed awk grep rg sort; do
command -v "$command" >/dev/null 2>&1 || task13_fail "$command is required"
done
task13_run_logged "Docker daemon readiness" docker info
@@ -1823,6 +2116,7 @@ task13_smoke_main() {
task13_assert_project_ownership
task13_assert_built_image_ownership
task13_assert_runtime
task13_assert_local_restore_reauthentication
task13_prepare_persistence
if [[ "$mode" == full ]]; then
task13_registry_lifecycle
+60 -21
View File
@@ -457,26 +457,6 @@ func inspectRequiredVolumes(ctx context.Context, runner archiveRunner, rendered
}
func imageIdentities(ctx context.Context, installation config.Installation, runner archiveRunner, rendered renderedCompose) ([]ImageIdentity, error) {
result, err := runner.Run(ctx, installation.ComposeArgs("images", "--format", "json"), nil)
if err != nil {
return nil, dockerError("inspect image identities", result, err)
}
ids := map[string]string{}
decoder := json.NewDecoder(strings.NewReader(result.Stdout))
for {
var item struct {
Service string `json:"Service"`
ID string `json:"ID"`
}
err := decoder.Decode(&item)
if errors.Is(err, io.EOF) {
break
}
if err != nil || item.Service == "" {
return nil, errors.New("Docker Compose returned invalid image identities")
}
ids[item.Service] = item.ID
}
services := make([]string, 0, len(rendered.Services))
for name, definition := range rendered.Services {
if definition.Image != "" {
@@ -486,11 +466,70 @@ func imageIdentities(ctx context.Context, installation config.Installation, runn
sort.Strings(services)
images := make([]ImageIdentity, 0, len(services))
for _, name := range services {
images = append(images, ImageIdentity{Service: name, Reference: rendered.Services[name].Image, ID: ids[name]})
result, err := runner.Run(ctx, installation.ComposeArgs("images", "--format", "json", name), nil)
if err != nil {
return nil, dockerError("inspect image identities", result, err)
}
inspected, err := decodeComposeImageIdentities(result.Stdout)
if err != nil {
return nil, err
}
matching := make([]composeImageIdentity, 0, len(inspected))
for _, item := range inspected {
if item.Service == "" || item.Service == name {
matching = append(matching, item)
}
}
if len(matching) > 1 {
return nil, errors.New("Docker Compose returned invalid image identities")
}
id := ""
if len(matching) == 1 {
id = matching[0].ID
}
images = append(images, ImageIdentity{Service: name, Reference: rendered.Services[name].Image, ID: id})
}
return images, nil
}
type composeImageIdentity struct {
Service string `json:"Service"`
ContainerName string `json:"ContainerName"`
ID string `json:"ID"`
}
func decodeComposeImageIdentities(value string) ([]composeImageIdentity, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil, nil
}
var identities []composeImageIdentity
if strings.HasPrefix(trimmed, "[") {
if json.Unmarshal([]byte(trimmed), &identities) != nil {
return nil, errors.New("Docker Compose returned invalid image identities")
}
} else {
decoder := json.NewDecoder(strings.NewReader(trimmed))
for {
var item composeImageIdentity
err := decoder.Decode(&item)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, errors.New("Docker Compose returned invalid image identities")
}
identities = append(identities, item)
}
}
for _, item := range identities {
if item.ID == "" || item.Service == "" && item.ContainerName == "" {
return nil, errors.New("Docker Compose returned invalid image identities")
}
}
return identities, nil
}
func installationRunning(ctx context.Context, installation config.Installation, runner archiveRunner) (bool, error) {
result, err := runner.Run(ctx, installation.ComposeArgs("ps", "--all", "--format", "json"), nil)
if err != nil {
+26
View File
@@ -23,6 +23,32 @@ import (
var requiredTestVolumes = []string{"settings", "pi-state", "workspace-registry", "workspace-secrets", "sessions", "qdrant-data", "embedding-models"}
func TestDecodeComposeImageIdentitiesAcceptsArrayAndStreamingJSON(t *testing.T) {
for name, input := range map[string]string{
"array": `[{"ContainerName":"project-core-1","ID":"sha256:core"},{"ContainerName":"project-frontend-1","ID":"sha256:frontend"}]`,
"streaming": "{\"Service\":\"core\",\"ID\":\"sha256:core\"}\n{\"Service\":\"frontend\",\"ID\":\"sha256:frontend\"}\n",
} {
t.Run(name, func(t *testing.T) {
identities, err := decodeComposeImageIdentities(input)
if err != nil {
t.Fatal(err)
}
if len(identities) != 2 || identities[0].ID != "sha256:core" || identities[1].ID != "sha256:frontend" {
t.Fatalf("image identities = %#v", identities)
}
})
}
}
func TestDecodeComposeImageIdentitiesAcceptsNoContainerForProfiledService(t *testing.T) {
for _, input := range []string{"", "[]"} {
identities, err := decodeComposeImageIdentities(input)
if err != nil || len(identities) != 0 {
t.Fatalf("decodeComposeImageIdentities(%q) = %#v, %v", input, identities, err)
}
}
}
func TestCreateWritesManifestLastWithConfigurationMetadataAndSevenVolumes(t *testing.T) {
fixture := newBackupFixture(t, "local")
output := filepath.Join(t.TempDir(), "custom.zip")
+11 -7
View File
@@ -61,13 +61,15 @@ type PreflightDependencies struct {
// ArchiveEntryMetadata is safe restore metadata. It intentionally contains no archive payload.
type ArchiveEntryMetadata struct {
Path string
Kind string
Owner string
Size int64
SHA256 string
Mode uint32
Sensitive bool
Path string
Kind string
Owner string
LogicalName string
SourcePath string
Size int64
SHA256 string
Mode uint32
Sensitive bool
}
// PreflightResult is the validated, non-mutating input for a future restore transaction.
@@ -534,6 +536,8 @@ func reconcileArchiveEntries(ctx context.Context, manifest Manifest, entries map
requiredBytes += uint64(actual.metadata.Size)
actual.metadata.Kind = entry.Kind
actual.metadata.Owner = entry.Owner
actual.metadata.LogicalName = entry.LogicalName
actual.metadata.SourcePath = entry.SourcePath
actual.metadata.Sensitive = entry.Sensitive
metadata = append(metadata, actual.metadata)
delete(entries, entry.Path)
+17 -5
View File
@@ -325,6 +325,7 @@ type preflightArchiveSpec struct {
entries []preflightArchiveEntry
rawEntries []preflightRawArchiveEntry
rawManifest []byte
volumes []VolumeMetadata
}
type preflightArchiveEntry struct {
@@ -337,6 +338,9 @@ type preflightArchiveEntry struct {
mode os.FileMode
manifestMode *uint32
method uint16
owner string
logicalName string
sourcePath string
}
type preflightRawArchiveEntry struct {
@@ -374,6 +378,7 @@ func writePreflightArchive(t *testing.T, archivePath string, spec preflightArchi
SchemaVersion: spec.schemaVersion, InstallationID: spec.installationID,
CreatedAt: time.Date(2026, 8, 16, 10, 0, 0, 0, time.UTC), SourceRevision: testRevision,
IncludesSecrets: spec.includeSecrets, ComposeProject: "thothii-test",
Volumes: append([]VolumeMetadata(nil), spec.volumes...),
}
for _, entry := range spec.entries {
checksum := entry.checksum
@@ -384,11 +389,18 @@ func writePreflightArchive(t *testing.T, archivePath string, spec preflightArchi
if kind == "" {
kind = EntryFile
}
sourcePath := ""
owner := "installation"
sourcePath := entry.sourcePath
owner := entry.owner
if owner == "" {
owner = "installation"
}
if kind == EntryExternalSecret {
sourcePath = "/protected/secret"
owner = "external-secret"
if sourcePath == "" {
sourcePath = "/protected/secret"
}
if entry.owner == "" {
owner = "external-secret"
}
}
mode := uint32(entry.mode.Perm())
if mode == 0 {
@@ -397,7 +409,7 @@ func writePreflightArchive(t *testing.T, archivePath string, spec preflightArchi
if entry.manifestMode != nil {
mode = *entry.manifestMode
}
manifest.Entries = append(manifest.Entries, Entry{Path: entry.path, Kind: kind, Owner: owner, SourcePath: sourcePath, SHA256: checksum, Size: int64(len(entry.body)), Mode: mode, Archived: true, Sensitive: entry.sensitive})
manifest.Entries = append(manifest.Entries, Entry{Path: entry.path, Kind: kind, Owner: owner, LogicalName: entry.logicalName, SourcePath: sourcePath, SHA256: checksum, Size: int64(len(entry.body)), Mode: mode, Archived: true, Sensitive: entry.sensitive})
}
manifestBytes, err := manifest.JSON()
if err != nil {
+28 -8
View File
@@ -42,10 +42,11 @@ type restoreDependencies struct {
verify map[string]restoreVerify
}
// Restore runs the host transaction. Concrete host dependencies are intentionally kept outside
// the deterministic core so callers cannot bypass its preflight and checkpoint boundaries.
// Restore runs the host transaction through the same concrete Docker/filesystem boundaries used
// by backup creation. The injectable core below exists only to make every failure boundary
// deterministic in tests.
func Restore(ctx context.Context, installation config.Installation, request RestoreRequest) (RestoreResult, error) {
return RestoreResult{}, errors.New("restore host dependencies are unavailable")
return restoreWithDependencies(ctx, installation, request, productionRestoreDependencies(installation))
}
func restoreWithDependencies(ctx context.Context, installation config.Installation, request RestoreRequest, deps restoreDependencies) (result RestoreResult, resultErr error) {
@@ -113,9 +114,6 @@ func restoreWithDependencies(ctx context.Context, installation config.Installati
members[member.Name] = member
}
for _, entry := range preflight.Entries {
if entry.Kind == EntryVolume {
continue
}
member := members[entry.Path]
if member == nil {
return result, fmt.Errorf("verified archive is missing %q", entry.Path)
@@ -125,7 +123,17 @@ func restoreWithDependencies(ctx context.Context, installation config.Installati
return result, fmt.Errorf("open verified archive member %q: %w", entry.Path, openErr)
}
mutated = true
restoreErr := deps.restoreFile(ctx, installation, entry, stream)
var restoreErr error
if entry.Kind == EntryVolume {
volume, found := restoreVolumeMetadata(preflight.Manifest, entry.LogicalName)
if !found {
_ = stream.Close()
return result, errors.New("verified volume metadata is incomplete")
}
restoreErr = deps.restoreVolume(ctx, installation, volume, stream)
} else {
restoreErr = deps.restoreFile(ctx, installation, entry, stream)
}
closeErr := stream.Close()
if restoreErr != nil {
return result, restoreErr
@@ -156,13 +164,25 @@ func restoreWithDependencies(ctx context.Context, installation config.Installati
return result, nil
}
func restoreVolumeMetadata(manifest Manifest, logicalName string) (VolumeMetadata, bool) {
if logicalName == "" {
return VolumeMetadata{}, false
}
for _, volume := range manifest.Volumes {
if volume.LogicalName == logicalName {
return volume, true
}
}
return VolumeMetadata{}, false
}
// resetAuthenticationState clears browser sessions and pending OIDC transactions without touching
// installation-global auth.yaml or users.yaml. The command runs as the unprivileged core user so
// the recreated state root is private to the service on both the local volume and server /data bind.
func resetAuthenticationState(ctx context.Context, installation config.Installation, runner archiveRunner) error {
result, err := runner.Run(ctx, installation.ComposeArgs(
"run", "--rm", "--no-deps", "--no-TTY", "--entrypoint", "sh", "core", "-ceu",
"rm -rf /data/auth && install -d -m 0700 /data/auth /data/auth/sessions /data/auth/oidc",
"find /data/auth -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + && install -d -m 0700 /data/auth /data/auth/sessions /data/auth/oidc && test -z \"$(find /data/auth/sessions /data/auth/oidc -mindepth 1 -print -quit)\"",
), nil)
if err != nil {
return dockerError("reset authentication state", result, err)
@@ -0,0 +1,22 @@
//go:build !windows
package backup
import (
"errors"
"golang.org/x/sys/unix"
)
func restoreFreeBytes(target string) (uint64, error) {
var statistics unix.Statfs_t
if err := unix.Statfs(target, &statistics); err != nil {
return 0, errors.New("restore filesystem capacity is unavailable")
}
blockSize := uint64(statistics.Bsize)
available := uint64(statistics.Bavail)
if blockSize != 0 && available > ^uint64(0)/blockSize {
return 0, errors.New("restore filesystem capacity is invalid")
}
return blockSize * available, nil
}
@@ -0,0 +1,21 @@
//go:build windows
package backup
import (
"errors"
"golang.org/x/sys/windows"
)
func restoreFreeBytes(target string) (uint64, error) {
path, err := windows.UTF16PtrFromString(target)
if err != nil {
return 0, errors.New("restore filesystem capacity is unavailable")
}
var available uint64
if err := windows.GetDiskFreeSpaceEx(path, &available, nil, nil); err != nil {
return 0, errors.New("restore filesystem capacity is unavailable")
}
return available, nil
}
@@ -0,0 +1,121 @@
//go:build !windows
package backup
import (
"crypto/rand"
"encoding/hex"
"errors"
"io"
"os"
"path/filepath"
"strings"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
"golang.org/x/sys/unix"
)
type restoreTargetIdentity struct {
exists bool
device uint64
inode uint64
}
func replaceRestoreFile(target string, contents []byte, mode os.FileMode) error {
if safeio.ValidateCanonicalPath(target) != nil || mode&os.ModeType != 0 || mode.Perm() == 0 {
return safeio.ErrUnsafeFile
}
components := strings.Split(strings.TrimPrefix(target, string(os.PathSeparator)), string(os.PathSeparator))
if len(components) < 2 || components[0] == "" || components[len(components)-1] == "" {
return safeio.ErrUnsafeFile
}
directory, err := unix.Open(string(os.PathSeparator), unix.O_RDONLY|unix.O_CLOEXEC|unix.O_DIRECTORY, 0)
if err != nil {
return safeio.ErrUnsafeFile
}
defer unix.Close(directory)
for _, component := range components[:len(components)-1] {
next, openErr := unix.Openat(directory, component, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_DIRECTORY|unix.O_NOFOLLOW, 0)
if openErr != nil {
return safeio.ErrUnsafeFile
}
unix.Close(directory)
directory = next
}
name := components[len(components)-1]
identity, err := inspectRestoreTargetAt(directory, name)
if err != nil {
return safeio.ErrUnsafeFile
}
temporary, err := writeRestoreTemporaryAt(directory, contents, mode.Perm())
if err != nil {
return safeio.ErrUnsafeFile
}
defer func() { _ = unix.Unlinkat(directory, temporary, 0) }()
current, err := inspectRestoreTargetAt(directory, name)
if err != nil || current != identity {
return safeio.ErrUnsafeFile
}
if err := unix.Renameat(directory, temporary, directory, name); err != nil {
return safeio.ErrUnsafeFile
}
temporary = ""
if err := unix.Fsync(directory); err != nil {
return safeio.ErrUnsafeFile
}
return nil
}
func inspectRestoreTargetAt(directory int, name string) (restoreTargetIdentity, error) {
var status unix.Stat_t
err := unix.Fstatat(directory, name, &status, unix.AT_SYMLINK_NOFOLLOW)
if errors.Is(err, unix.ENOENT) {
return restoreTargetIdentity{}, nil
}
if err != nil || status.Mode&unix.S_IFMT != unix.S_IFREG || status.Nlink != 1 {
return restoreTargetIdentity{}, safeio.ErrUnsafeFile
}
return restoreTargetIdentity{exists: true, device: uint64(status.Dev), inode: status.Ino}, nil
}
func writeRestoreTemporaryAt(directory int, contents []byte, mode os.FileMode) (string, error) {
for attempt := 0; attempt < 16; attempt++ {
random := make([]byte, 8)
if _, err := rand.Read(random); err != nil {
return "", err
}
name := ".tht-restore-" + hex.EncodeToString(random) + ".tmp"
descriptor, err := unix.Openat(directory, name, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_CLOEXEC|unix.O_NOFOLLOW, uint32(mode))
if errors.Is(err, unix.EEXIST) {
continue
}
if err != nil {
return "", err
}
file := os.NewFile(uintptr(descriptor), filepath.Base(name))
if file == nil {
unix.Close(descriptor)
return "", safeio.ErrUnsafeFile
}
if err := file.Chmod(mode); err == nil {
var written int
written, err = file.Write(contents)
if err == nil && written != len(contents) {
err = io.ErrShortWrite
}
}
if err == nil {
err = file.Sync()
}
closeErr := file.Close()
if err == nil {
err = closeErr
}
if err != nil {
_ = unix.Unlinkat(directory, name, 0)
return "", err
}
return name, nil
}
return "", safeio.ErrUnsafeFile
}
@@ -0,0 +1,59 @@
//go:build windows
package backup
import (
"errors"
"os"
"path/filepath"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
"golang.org/x/sys/windows"
)
func replaceRestoreFile(target string, contents []byte, mode os.FileMode) error {
if safeio.ValidateCanonicalPath(target) != nil || mode&os.ModeType != 0 || mode.Perm() == 0 {
return safeio.ErrUnsafeFile
}
parent := filepath.Dir(target)
resolved, err := filepath.EvalSymlinks(parent)
if err != nil || resolved != parent || !safeWindowsRestoreTarget(target) {
return safeio.ErrUnsafeFile
}
temporary, err := os.CreateTemp(parent, ".tht-restore-*.tmp")
if err != nil {
return safeio.ErrUnsafeFile
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if err := temporary.Chmod(mode.Perm()); err == nil {
_, err = temporary.Write(contents)
}
if err == nil {
err = temporary.Sync()
}
closeErr := temporary.Close()
if err == nil {
err = closeErr
}
if err != nil || !safeWindowsRestoreTarget(target) {
return safeio.ErrUnsafeFile
}
from, fromErr := windows.UTF16PtrFromString(temporaryPath)
to, toErr := windows.UTF16PtrFromString(target)
if fromErr != nil || toErr != nil {
return safeio.ErrUnsafeFile
}
if err := windows.MoveFileEx(from, to, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH); err != nil {
return safeio.ErrUnsafeFile
}
return nil
}
func safeWindowsRestoreTarget(target string) bool {
info, err := os.Lstat(target)
if errors.Is(err, os.ErrNotExist) {
return true
}
return err == nil && info.Mode().IsRegular() && info.Mode()&os.ModeSymlink == 0
}
+352
View File
@@ -0,0 +1,352 @@
package backup
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/aritmolab/thothii/tools/tht/internal/compose"
"github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/doctor"
"github.com/aritmolab/thothii/tools/tht/internal/lifecycle"
"github.com/aritmolab/thothii/tools/tht/internal/pi"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
"github.com/aritmolab/thothii/tools/tht/internal/service"
)
func productionRestoreDependencies(installation config.Installation) restoreDependencies {
runner := hostRunner{runner: compose.NewRunner(""), binary: "docker", profile: installation.Profile}
return restoreDependencies{
preflight: func(ctx context.Context, target config.Installation, request PreflightRequest) (PreflightResult, error) {
return Preflight(ctx, target, request, PreflightDependencies{
FreeBytes: restoreFreeBytes,
CheckOwnershipPermissions: validateRestoreTargets,
CheckVolumeMapping: func(ctx context.Context, target config.Installation, manifest Manifest) error {
return validateRestoreVolumes(ctx, target, manifest, runner)
},
CheckImageConfigCompatibility: func(ctx context.Context, target config.Installation, manifest Manifest) error {
return validateRestoreImages(ctx, target, manifest, runner)
},
})
},
checkpoint: func(ctx context.Context, target config.Installation, request CreateRequest) (Result, error) {
path, err := restoreCheckpointPath(target, time.Now().UTC())
if err != nil {
return Result{}, err
}
request.Output = path
return Create(ctx, target, request)
},
acquireLock: func(target config.Installation) (restoreLock, error) {
return lifecycle.Acquire(target)
},
runner: runner,
sleep: time.Sleep,
restoreFile: restoreFilePayload,
restoreVolume: func(ctx context.Context, _ config.Installation, volume VolumeMetadata, input io.Reader) error {
result, err := runner.Stream(ctx, volumeRestoreCommand(volume.Name), input, io.Discard)
if err != nil || result.ExitCode != 0 {
if err == nil {
err = errors.New("Docker volume helper returned a nonzero exit status")
}
return fmt.Errorf("restore volume %s: %w", volume.LogicalName, dockerError("stream volume", result, err))
}
return nil
},
resetAuthenticationState: resetAuthenticationState,
verify: map[string]restoreVerify{
"health": verifyRestoreHealth,
"doctor": verifyRestoreDoctor,
"pi": verifyRestorePi,
"workspace": verifyRestoreWorkspace,
},
}
}
func restoreCheckpointPath(installation config.Installation, now time.Time) (string, error) {
suffix := make([]byte, 8)
if _, err := rand.Read(suffix); err != nil {
return "", errors.New("recovery checkpoint name is unavailable")
}
name := fmt.Sprintf("restore-checkpoint-%s-%s.zip", now.Format("20060102T150405.000000000Z"), hex.EncodeToString(suffix))
return filepath.Join(installation.ControlDirectory(), name), nil
}
func validateRestoreTargets(_ context.Context, installation config.Installation, manifest Manifest) error {
for _, entry := range manifest.Entries {
if entry.Kind == EntryVolume {
continue
}
if !entry.Archived {
if entry.Kind == EntrySecretReference || entry.Kind == EntryPreservationReference {
if err := validateRestoreReference(installation, entry); err != nil {
return err
}
}
continue
}
metadata := ArchiveEntryMetadata{
Path: entry.Path, Kind: entry.Kind, Owner: entry.Owner, LogicalName: entry.LogicalName,
SourcePath: entry.SourcePath, Size: entry.Size, SHA256: entry.SHA256, Mode: entry.Mode,
Sensitive: entry.Sensitive,
}
target, err := restoreFileTarget(installation, metadata)
if err != nil || !safeRestoreParent(target) {
return errors.New("restore target ownership or permissions are invalid")
}
}
return nil
}
func validateRestoreReference(installation config.Installation, entry Entry) error {
metadata := ArchiveEntryMetadata{
Path: entry.Path, Kind: entry.Kind, Owner: entry.Owner, SourcePath: entry.SourcePath,
Size: entry.Size, SHA256: entry.SHA256, Mode: entry.Mode, Sensitive: entry.Sensitive,
}
if entry.Kind == EntryPreservationReference {
return nil
}
if _, err := restoreExternalTarget(installation, metadata); err != nil {
return errors.New("restore external prerequisite is invalid")
}
contents, err := safeio.ReadCanonicalRegular(entry.SourcePath, entry.Size)
if err != nil || int64(len(contents)) != entry.Size {
return errors.New("restore external prerequisite is unavailable or unsafe")
}
digest := sha256.Sum256(contents)
if "sha256:"+hex.EncodeToString(digest[:]) != entry.SHA256 {
return errors.New("restore external prerequisite has changed")
}
return nil
}
func validateRestoreVolumes(ctx context.Context, installation config.Installation, manifest Manifest, runner archiveRunner) error {
if len(manifest.Volumes) != len(requiredVolumes) {
return errors.New("backup volume set is incomplete")
}
rendered, err := renderedConfiguration(ctx, installation, runner)
if err != nil {
return err
}
current, err := inspectRequiredVolumes(ctx, runner, rendered)
if err != nil {
return err
}
archived := make(map[string]VolumeMetadata, len(manifest.Volumes))
for _, volume := range manifest.Volumes {
archived[volume.LogicalName] = volume
}
for _, volume := range current {
previous, found := archived[volume.LogicalName]
if !found || previous.Name != volume.Name || previous.Driver != volume.Driver {
return errors.New("backup volume ownership does not match the installation")
}
if volume.Labels["com.docker.compose.project"] != installation.ProjectName() {
return errors.New("current volume is not owned by the installation")
}
}
return nil
}
func validateRestoreImages(ctx context.Context, installation config.Installation, manifest Manifest, runner archiveRunner) error {
rendered, err := renderedConfiguration(ctx, installation, runner)
if err != nil {
return err
}
for _, image := range manifest.Images {
serviceDefinition, found := rendered.Services[image.Service]
if !found || serviceDefinition.Image == "" || serviceDefinition.Image != image.Reference {
return errors.New("backup image configuration does not match the installation")
}
}
return nil
}
func restoreFilePayload(_ context.Context, installation config.Installation, entry ArchiveEntryMetadata, input io.Reader) error {
if entry.Size < 0 || uint64(entry.Size) > defaultPreflightMaxUncompressedBytes {
return errors.New("restore file size is invalid")
}
contents, err := io.ReadAll(io.LimitReader(input, entry.Size+1))
if err != nil || int64(len(contents)) != entry.Size {
return errors.New("restore file payload is invalid")
}
target, err := restoreFileTarget(installation, entry)
if err != nil {
return err
}
if err := replaceRestoreFile(target, contents, os.FileMode(entry.Mode)); err != nil {
return errors.New("restore file could not be replaced safely")
}
return nil
}
func restoreFileTarget(installation config.Installation, entry ArchiveEntryMetadata) (string, error) {
if entry.Kind == EntryExternalSecret {
return restoreExternalTarget(installation, entry)
}
if entry.Kind != EntryFile {
return "", errors.New("restore file kind is unsupported")
}
switch entry.Path {
case "configuration/installation/thothii-installation.yaml":
return installation.Path, nil
case "configuration/environment/operator.env":
return installation.EnvFile, nil
case "configuration/pi/models.json":
return filepath.Join(installation.ProjectDirectory, "deploy", "pi", "models.json"), nil
case "configuration/pi/settings.json":
return filepath.Join(installation.ProjectDirectory, "deploy", "pi", "settings.json"), nil
case "configuration/generated/current-image.yaml":
return installation.CurrentImageOverridePath(), nil
}
if strings.HasPrefix(entry.Path, "configuration/overrides/") {
name := strings.TrimPrefix(entry.Path, "configuration/overrides/")
indexText, base, found := strings.Cut(name, "-")
index, parseErr := strconv.Atoi(indexText)
if !found || parseErr != nil || len(indexText) != 2 || index < 0 || index >= len(installation.Overrides) || filepath.Base(installation.Overrides[index]) != base {
return "", errors.New("restore override target is invalid")
}
return installation.Overrides[index], nil
}
if strings.HasPrefix(entry.Owner, "preservation-root:") && strings.HasPrefix(entry.Path, "preservation/") {
variable := strings.TrimPrefix(entry.Owner, "preservation-root:")
allowed := variable == "THT_DATA_ROOT" || variable == "THT_PI_STATE_ROOT" || variable == "THT_WORKSPACE_REGISTRY_ROOT"
parts := strings.SplitN(entry.Path, "/", 3)
root, rootErr := installation.EnvironmentValue(variable)
if !allowed || len(parts) != 3 || rootErr != nil || root == "" {
return "", errors.New("restore preservation target is invalid")
}
target := filepath.Join(root, filepath.FromSlash(parts[2]))
relative, relErr := filepath.Rel(root, target)
if relErr != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return "", errors.New("restore preservation target escapes its root")
}
return target, nil
}
return "", errors.New("restore file target is not declared")
}
func restoreExternalTarget(installation config.Installation, entry ArchiveEntryMetadata) (string, error) {
if entry.SourcePath == "" || filepath.Clean(entry.SourcePath) != entry.SourcePath || !filepath.IsAbs(entry.SourcePath) {
return "", errors.New("restore external target is invalid")
}
if entry.Owner == "external-secret" {
paths, err := installation.SecretFiles()
if err != nil {
return "", errors.New("restore external secret declarations are unavailable")
}
for _, path := range paths {
if path == entry.SourcePath {
return path, nil
}
}
return "", errors.New("restore external secret target is not declared")
}
if entry.Owner == "authentication-configuration" {
for _, name := range []string{"auth.yaml", "users.yaml"} {
path := filepath.Join(installation.AuthenticationDirectory(), name)
if entry.SourcePath == path {
return path, nil
}
}
}
return "", errors.New("restore external target owner is invalid")
}
func safeRestoreParent(target string) bool {
if target == "" || !filepath.IsAbs(target) || filepath.Clean(target) != target {
return false
}
parent := filepath.Dir(target)
resolved, err := filepath.EvalSymlinks(parent)
if err != nil || resolved != parent {
return false
}
info, err := os.Stat(parent)
if err != nil || !info.IsDir() {
return false
}
if targetInfo, err := os.Lstat(target); err == nil {
return targetInfo.Mode().IsRegular() && targetInfo.Mode()&os.ModeSymlink == 0
} else {
return errors.Is(err, os.ErrNotExist)
}
}
func volumeRestoreCommand(volume string) []string {
return []string{
"run", "--rm", "--network", "none", "--mount", "type=volume,src=" + volume + ",dst=/target",
helperImage, "sh", "-ceu",
"rm -rf -- /target/* /target/.[!.]* /target/..?*; tar --numeric-owner -C /target -xf -",
}
}
func restoreVerificationRunning(ctx context.Context, installation config.Installation, runner archiveRunner) (bool, error) {
return installationRunning(ctx, installation, runner)
}
func verifyRestoreHealth(ctx context.Context, installation config.Installation, runner archiveRunner) error {
running, err := restoreVerificationRunning(ctx, installation, runner)
if err != nil || !running {
return err
}
return service.WaitForHealthy(ctx, installation, runner)
}
func verifyRestoreDoctor(ctx context.Context, installation config.Installation, runner archiveRunner) error {
running, err := restoreVerificationRunning(ctx, installation, runner)
if err != nil {
return err
}
if !running {
result, configErr := runner.Run(ctx, installation.ComposeArgs("config", "--quiet"), nil)
if configErr != nil {
return dockerError("verify restored Compose configuration", result, configErr)
}
return nil
}
report, err := doctor.Run(ctx, installation, runner)
if err != nil {
return err
}
if !report.OK {
return errors.New("aggregate doctor did not pass after restore")
}
return nil
}
func verifyRestorePi(ctx context.Context, installation config.Installation, runner archiveRunner) error {
running, err := restoreVerificationRunning(ctx, installation, runner)
if err != nil || !running {
return err
}
return pi.Doctor(ctx, compose.InstallationRunner{Installation: installation, Runner: runner})
}
func verifyRestoreWorkspace(ctx context.Context, installation config.Installation, runner archiveRunner) error {
running, err := restoreVerificationRunning(ctx, installation, runner)
if err != nil || !running {
return err
}
result, err := runner.Run(ctx, installation.ComposeArgs(
"exec", "-T", "core", "curl", "-fsS", "--max-time", "5", "http://127.0.0.1:8787/workspaces",
), nil)
if err != nil {
return dockerError("inspect restored workspaces", result, err)
}
var workspaces []json.RawMessage
if json.Unmarshal([]byte(result.Stdout), &workspaces) != nil {
return errors.New("restored workspace inspection returned invalid JSON")
}
return nil
}
+84 -1
View File
@@ -1,6 +1,8 @@
package backup
import (
"archive/tar"
"bytes"
"context"
"errors"
"io"
@@ -13,6 +15,85 @@ import (
"github.com/aritmolab/thothii/tools/tht/internal/config"
)
func TestRestorePublicPathUsesConcreteProductionPreflight(t *testing.T) {
root := t.TempDir()
installation := config.Installation{
Path: filepath.Join(root, "deploy", "local-dev", "thothii-installation.yaml"),
ProjectDirectory: root,
}
missing := filepath.Join(root, "missing.zip")
_, err := Restore(context.Background(), installation, RestoreRequest{Archive: missing, Confirm: true})
if err == nil || strings.Contains(err.Error(), "dependencies are unavailable") || !strings.Contains(err.Error(), "backup archive") {
t.Fatalf("Restore() error = %v, want production archive preflight", err)
}
}
func TestRestoreRestoresVerifiedVolumesInManifestOrderBeforeAuthenticationReset(t *testing.T) {
installation := preflightTestInstallation(t)
archive := filepath.Join(t.TempDir(), "restore-volumes.zip")
sessionsTar := safeRestoreTar(t, "session.txt", "session")
settingsTar := safeRestoreTar(t, "settings.json", "settings")
writePreflightArchive(t, archive, preflightArchiveSpec{
volumes: []VolumeMetadata{
{LogicalName: "sessions", Name: "project_sessions", Driver: "local"},
{LogicalName: "settings", Name: "project_settings", Driver: "local"},
},
entries: []preflightArchiveEntry{
{path: "configuration/operator.env", body: []byte("safe")},
{path: "volumes/settings.tar", body: settingsTar, kind: EntryVolume, owner: "volume:settings", logicalName: "settings"},
{path: "volumes/sessions.tar", body: sessionsTar, kind: EntryVolume, owner: "volume:sessions", logicalName: "sessions"},
},
})
runner := newBackupRunner(installation, false)
deps := restoreTestDependencies(t, runner)
var events []string
deps.restoreFile = func(_ context.Context, _ config.Installation, entry ArchiveEntryMetadata, _ io.Reader) error {
events = append(events, "file:"+entry.Path)
return nil
}
deps.restoreVolume = func(_ context.Context, _ config.Installation, volume VolumeMetadata, stream io.Reader) error {
if _, err := io.ReadAll(stream); err != nil {
return err
}
events = append(events, "volume:"+volume.LogicalName)
return nil
}
deps.resetAuthenticationState = func(context.Context, config.Installation, archiveRunner) error {
events = append(events, "reset-auth-state")
return nil
}
if _, err := restoreWithDependencies(context.Background(), installation, RestoreRequest{Archive: archive, Confirm: true}, deps); err != nil {
t.Fatal(err)
}
if got, want := events, []string{
"file:configuration/operator.env",
"volume:sessions",
"volume:settings",
"reset-auth-state",
}; !equalStrings(got, want) {
t.Fatalf("restore events = %v, want %v", got, want)
}
}
func safeRestoreTar(t *testing.T, name, contents string) []byte {
t.Helper()
var output bytes.Buffer
writer := tar.NewWriter(&output)
if err := writer.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: int64(len(contents)), Typeflag: tar.TypeReg}); err != nil {
t.Fatal(err)
}
if _, err := writer.Write([]byte(contents)); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
return output.Bytes()
}
func TestRestoreStoppedInstallationRunsCheckpointRestoreAndVerification(t *testing.T) {
installation := preflightTestInstallation(t)
archive := filepath.Join(t.TempDir(), "restore.zip")
@@ -103,7 +184,9 @@ func TestResetAuthenticationStateCreatesOnlyPrivateEmptyStateDirectories(t *test
joined := strings.Join(runner.args, "\x00")
for _, required := range []string{
"run", "--rm", "--no-deps", "--no-TTY", "--entrypoint", "sh", "core", "-ceu",
"rm -rf /data/auth && install -d -m 0700 /data/auth /data/auth/sessions /data/auth/oidc",
"find /data/auth -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +",
"install -d -m 0700 /data/auth /data/auth/sessions /data/auth/oidc",
"find /data/auth/sessions /data/auth/oidc -mindepth 1 -print -quit",
} {
if !strings.Contains(joined, required) {
t.Fatalf("authentication state reset command omits %q: %#v", required, runner.args)