feat(auth): integrate authentication with installation lifecycle
This commit is contained in:
@@ -23,6 +23,8 @@ services:
|
||||
THT_WORKSPACE_SECRET_ROOTS: /run/secrets
|
||||
THT_SECRETS_FILE: /run/secrets/thothii.secrets
|
||||
THT_PI_AUTH_FILE: /home/thoth/.pi/agent/auth.json
|
||||
THT_AUTH_CONFIG_FILE: /run/thothii-auth/auth.yaml
|
||||
THT_AUTH_STATE_ROOT: /data/auth
|
||||
THT_DB_NAME: ${THT_DB_NAME:-}
|
||||
THT_DWH_REST_URL: ${THT_DWH_REST_URL:-}
|
||||
THT_LLM_URL: ${THT_LLM_URL:-}
|
||||
@@ -40,6 +42,8 @@ services:
|
||||
- workspace-registry:/data/workspace-registry
|
||||
- workspace-secrets:/data/workspace-secrets
|
||||
- sessions:/data/sessions
|
||||
- ${THT_AUTH_CONFIG_ROOT:?set THT_AUTH_CONFIG_ROOT}:/run/thothii-auth:ro
|
||||
- auth-state:/data/auth
|
||||
secrets:
|
||||
- source: thothii_secrets
|
||||
target: thothii.secrets
|
||||
@@ -204,6 +208,7 @@ volumes:
|
||||
sessions:
|
||||
qdrant-data:
|
||||
embedding-models:
|
||||
auth-state:
|
||||
|
||||
secrets:
|
||||
thothii_secrets:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
services:
|
||||
core:
|
||||
environment:
|
||||
AUTH_MODE: none
|
||||
NODE_ENV: development
|
||||
THT_WORKSPACE_INSTALLATION_ID: local
|
||||
ports:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
services:
|
||||
core:
|
||||
environment:
|
||||
AUTH_MODE: upstream
|
||||
THOTH_PUBLIC_EXPOSURE: "true"
|
||||
THT_DATA_ROOT: /data
|
||||
THT_WORKSPACE_INSTALLATION_ID: server
|
||||
@@ -11,6 +10,10 @@ services:
|
||||
- type: bind
|
||||
source: ${THT_DATA_ROOT:?set THT_DATA_ROOT}
|
||||
target: /data
|
||||
- type: bind
|
||||
source: ${THT_AUTH_CONFIG_ROOT:?set THT_AUTH_CONFIG_ROOT}
|
||||
target: /run/thothii-auth
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ${THT_PI_STATE_ROOT:?set THT_PI_STATE_ROOT}
|
||||
target: /home/thoth/.pi
|
||||
@@ -30,6 +33,8 @@ services:
|
||||
source: ${THT_WORKSPACE_REGISTRY_ROOT:?set THT_WORKSPACE_REGISTRY_ROOT}
|
||||
target: /data/workspace-registry
|
||||
restart: unless-stopped
|
||||
# Deprecated migration adapter: only use this when no auth.yaml is mounted yet.
|
||||
# AUTH_MODE: upstream
|
||||
|
||||
frontend:
|
||||
ports:
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
services:
|
||||
core:
|
||||
environment:
|
||||
AUTH_MODE: upstream
|
||||
THOTH_PUBLIC_EXPOSURE: "true"
|
||||
THT_SESSION_STORAGE: postgres
|
||||
THT_CONFIG: /app/harness/workspaces/server-sessions.yaml
|
||||
|
||||
Vendored
+1
@@ -6,6 +6,7 @@ THOTH_CORE_HTTP_PORT=8787
|
||||
MAX_PI_PROCESSES=4
|
||||
PI_AUTH_FILE=/absolute/path/to/pi-auth.json
|
||||
THT_SECRETS_FILE=/absolute/path/to/thothii.secrets
|
||||
THT_AUTH_CONFIG_ROOT=/absolute/path/to/thothii-auth
|
||||
|
||||
THT_WORKSPACE_GIT_REMOTE=https://git.example.invalid/platform/thoth-workspaces.git
|
||||
THT_WORKSPACE_GIT_BRANCH=main
|
||||
|
||||
Vendored
+1
@@ -5,6 +5,7 @@ THOTH_HTTP_PORT=8080
|
||||
MAX_PI_PROCESSES=4
|
||||
PI_AUTH_FILE=/absolute/path/to/pi-auth.json
|
||||
THT_SECRETS_FILE=/absolute/path/to/thothii.secrets
|
||||
THT_AUTH_CONFIG_ROOT=/absolute/path/to/thothii-auth
|
||||
|
||||
THT_DATA_ROOT=/srv/thothii/data
|
||||
THT_PI_STATE_ROOT=/srv/thothii/pi-state
|
||||
|
||||
@@ -9,6 +9,7 @@ THT_WORKSPACE_GIT_KNOWN_HOSTS_FILE=<abs>/deploy/psd/secrets/git-known-hosts
|
||||
# App
|
||||
THT_SECRETS_FILE=<abs>/deploy/psd/secrets/thothii.secrets
|
||||
PI_AUTH_FILE=<abs>/deploy/psd/secrets/pi-auth.json
|
||||
THT_AUTH_CONFIG_ROOT=<abs>/deploy/psd/auth
|
||||
# DWH and Evidence credentials are entered later in Workspace management and stored encrypted
|
||||
# by the backend. They do not depend on host filesystem paths.
|
||||
|
||||
@@ -19,7 +20,6 @@ PI_THINKING=medium
|
||||
|
||||
# App defaults
|
||||
THT_DWH_PRECHECK=true
|
||||
AUTH_MODE=none
|
||||
THOTH_PUBLIC_EXPOSURE=false
|
||||
MAX_PI_PROCESSES=4
|
||||
THOTH_HTTP_PORT=8080
|
||||
|
||||
@@ -8,5 +8,7 @@ workspaceRepository:
|
||||
remote: git@github.com:mptyl/tht-workspace-psd.git
|
||||
branch: main
|
||||
access: ssh
|
||||
authentication:
|
||||
configDirectory: "<abs>/projects/ThothII/deploy/psd/auth"
|
||||
overrides:
|
||||
- "<abs>/projects/ThothII/deploy/compose.git-ssh.yaml"
|
||||
|
||||
@@ -9,8 +9,10 @@ chmod 600 deploy/secrets/thothii.secrets
|
||||
```
|
||||
|
||||
The file uses strict `KEY=VALUE` lines (comments and blank lines are allowed). The supported
|
||||
keys are `THT_MODEL_API_KEY`, `THT_DWH_API_KEY`, `THT_CA`, and `THT_SSL_CA`. Values must be
|
||||
non-empty and contain no whitespace. Do not put secrets
|
||||
keys are `THT_MODEL_API_KEY`, `THT_DWH_API_KEY`, `THT_CA`, `THT_SSL_CA`,
|
||||
`THT_OIDC_CLIENT_SECRET`, and `THT_AUTHENTIK_API_TOKEN`. The two authentication keys are fixed
|
||||
empty entries for local authentication and must be populated only in a protected OIDC installation.
|
||||
Other configured values must be non-empty and contain no whitespace. Do not put secrets
|
||||
in the root `.env`, workspace YAML, URLs, logs, or rendered Compose output.
|
||||
|
||||
Do not add vector or embedding endpoint credentials to the bundle. Active operator manuals use
|
||||
|
||||
@@ -10,3 +10,7 @@
|
||||
|
||||
# Optional CA material/path understood by the configured adapter.
|
||||
# THT_CA=/run/secrets/ca-chain.pem
|
||||
|
||||
# OIDC/Authentik references. Keep these fixed keys empty until OIDC is configured.
|
||||
THT_OIDC_CLIENT_SECRET=
|
||||
THT_AUTHENTIK_API_TOKEN=
|
||||
|
||||
@@ -7,5 +7,7 @@ workspaceRepository:
|
||||
remote: git@git.example.com:organization/workspaces.git
|
||||
branch: main
|
||||
access: ssh
|
||||
authentication:
|
||||
configDirectory: "/absolute/path/to/thothii-auth"
|
||||
overrides:
|
||||
- "/absolute/path/to/ThothII/deploy/compose.git-ssh.yaml"
|
||||
|
||||
@@ -7,6 +7,8 @@ workspaceRepository:
|
||||
remote: git@git.example.com:organization/workspaces.git
|
||||
branch: main
|
||||
access: ssh
|
||||
authentication:
|
||||
configDirectory: "/absolute/path/to/thothii-auth"
|
||||
overrides:
|
||||
- "/absolute/path/to/ThothII/deploy/compose.session-server.yaml.example"
|
||||
- "/absolute/path/to/ThothII/deploy/compose.git-ssh.yaml"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { constants, accessSync, readFileSync, statSync } from "node:fs";
|
||||
import { constants, accessSync, readFileSync, realpathSync, statSync } from "node:fs";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { resolveRuntimeBindings } from "../backend/src/workspaces/bindings.js";
|
||||
@@ -28,7 +28,12 @@ for (const [name, service, expectedExpose] of [
|
||||
["qdrant", qdrant, "6333"],
|
||||
["embedding", embedding, "11434"],
|
||||
] as const) {
|
||||
if ((service.ports || []).length !== 0) throw new Error(`${name} must not publish host ports`);
|
||||
const localQdrantDashboard = name === "qdrant" && profile === "local"
|
||||
&& (service.ports || []).length === 1
|
||||
&& service.ports[0].host_ip === "127.0.0.1" && Number(service.ports[0].target) === 6333;
|
||||
if ((service.ports || []).length !== 0 && !localQdrantDashboard) {
|
||||
throw new Error(`${name} must not publish host ports outside the local Qdrant dashboard`);
|
||||
}
|
||||
if ((service.expose || []).join(",") !== expectedExpose) {
|
||||
throw new Error(`${name} must expose only ${expectedExpose}`);
|
||||
}
|
||||
@@ -139,8 +144,9 @@ for (const target of [
|
||||
}
|
||||
|
||||
const resolverEnvironment = { ...core.environment };
|
||||
resolverEnvironment.THT_WS_TASK13_SMOKE_DWH_PASSWORD_FILE = runtimePasswordMounts[0].source;
|
||||
const bindings = resolveRuntimeBindings(workspace, resolverEnvironment, [dirname(runtimePasswordMounts[0].source)]);
|
||||
const runtimePasswordSource = realpathSync(runtimePasswordMounts[0].source);
|
||||
resolverEnvironment.THT_WS_TASK13_SMOKE_DWH_PASSWORD_FILE = runtimePasswordSource;
|
||||
const bindings = resolveRuntimeBindings(workspace, resolverEnvironment, [dirname(runtimePasswordSource)]);
|
||||
for (const [role, binding] of Object.entries(bindings)) {
|
||||
if ((binding as any).missing.length !== 0) {
|
||||
throw new Error(`workspace resolver reports missing ${role} bindings: ${(binding as any).missing.join(",")}`);
|
||||
@@ -150,10 +156,14 @@ const runtime = parse(renderRuntimeConfig(workspace, bindings, {
|
||||
sessions: "/data/sessions",
|
||||
artifacts: "/data/artifacts",
|
||||
indexes: "/data/indexes",
|
||||
}, {
|
||||
workspaceId: "task13-smoke",
|
||||
workspaceRevision: "task13-fixture",
|
||||
revisionContentRoot: join(dirname(workspacePath), "task13-fixture"),
|
||||
}));
|
||||
if (runtime.database.host !== expected.THT_WS_TASK13_SMOKE_DWH_HOST
|
||||
|| runtime.database.user !== expected.THT_WS_TASK13_SMOKE_DWH_USER
|
||||
|| runtime.database.password_file !== runtimePasswordMounts[0].source) {
|
||||
|| runtime.database.password_file !== runtimePasswordSource) {
|
||||
throw new Error("workspace resolver produced the wrong DWH runtime");
|
||||
}
|
||||
if (runtime.resources?.vector?.base_url !== "http://qdrant:6333"
|
||||
|
||||
@@ -20,7 +20,8 @@ done
|
||||
printf '%s\n' '{}' >"$tmp/pi-auth.json"
|
||||
printf '%s\n' 'THT_MODEL_API_KEY=fixture-model-api-key' >"$tmp/thothii.secrets"
|
||||
chmod 0600 "$tmp/pi-auth.json" "$tmp/thothii.secrets"
|
||||
mkdir -p "$tmp/data" "$tmp/pi-state" "$tmp/workspace-registry"
|
||||
mkdir -p "$tmp/data" "$tmp/pi-state" "$tmp/workspace-registry" "$tmp/auth"
|
||||
chmod 0700 "$tmp/auth"
|
||||
"$root/scripts/prepare-server-pi-state.sh" "$tmp/pi-state" "$(id -u)" "$(id -g)" >/dev/null
|
||||
printf '%s\n' 'fixture-session-password' >"$tmp/session-runtime-password"
|
||||
printf '%s\n' 'fixture-session-migrator-password' >"$tmp/session-migrator-password"
|
||||
@@ -34,7 +35,8 @@ for profile in local server; do
|
||||
printf '%s\n' \
|
||||
'THT_WORKSPACE_GIT_REMOTE=https://git.example.invalid/platform/thoth-workspaces.git' \
|
||||
"PI_AUTH_FILE=$tmp/pi-auth.json" \
|
||||
"THT_SECRETS_FILE=$tmp/thothii.secrets"
|
||||
"THT_SECRETS_FILE=$tmp/thothii.secrets" \
|
||||
"THT_AUTH_CONFIG_ROOT=$tmp/auth"
|
||||
if [[ "$profile" == server ]]; then
|
||||
printf '%s\n' \
|
||||
"THT_DATA_ROOT=$tmp/data" \
|
||||
@@ -73,6 +75,23 @@ if (!config.services.core.volumes?.some(
|
||||
)) {
|
||||
throw new Error(profile + ": install stack lacks the read-only Pi auth file");
|
||||
}
|
||||
const authConfig = config.services.core.volumes?.filter((mount) => mount.target === "/run/thothii-auth") || [];
|
||||
if (authConfig.length !== 1 || authConfig[0].type !== "bind" || !authConfig[0].read_only) {
|
||||
throw new Error(profile + ": core must receive one read-only authentication config bind");
|
||||
}
|
||||
if (profile === "local") {
|
||||
const authState = config.services.core.volumes?.filter((mount) => mount.target === "/data/auth") || [];
|
||||
if (authState.length !== 1 || authState[0].type !== "volume" || authState[0].source !== "auth-state") {
|
||||
throw new Error("local: core must receive the auth-state volume");
|
||||
}
|
||||
} else if (!config.services.core.volumes?.some((mount) => mount.target === "/data" && mount.type === "bind")) {
|
||||
throw new Error("server: core must preserve the whole /data bind that contains auth state");
|
||||
}
|
||||
if ((config.services["workspace-maintenance"]?.volumes || []).some(
|
||||
(mount) => mount.target === "/run/thothii-auth" || mount.target === "/data/auth",
|
||||
)) {
|
||||
throw new Error(profile + ": workspace-maintenance received authentication data");
|
||||
}
|
||||
if ((config.services.frontend.secrets || []).length !== 0) {
|
||||
throw new Error(profile + ": frontend received runtime secrets");
|
||||
}
|
||||
|
||||
@@ -56,6 +56,19 @@ if (core.environment?.THT_SECRETS_FILE !== "/run/secrets/thothii.secrets") {
|
||||
if (core.environment?.THT_PI_AUTH_FILE !== "/home/thoth/.pi/agent/auth.json") {
|
||||
throw new Error(`${name}: core does not declare the mounted Pi authentication source`);
|
||||
}
|
||||
if (core.environment?.THT_AUTH_CONFIG_FILE !== "/run/thothii-auth/auth.yaml"
|
||||
|| core.environment?.THT_AUTH_STATE_ROOT !== "/data/auth") {
|
||||
throw new Error(`${name}: core authentication paths do not use the canonical locations`);
|
||||
}
|
||||
const authConfig = (core.volumes || []).filter((mount) => mount.target === "/run/thothii-auth");
|
||||
if (authConfig.length !== 1 || authConfig[0].type !== "bind" || !authConfig[0].read_only) {
|
||||
throw new Error(`${name}: core must receive exactly one read-only authentication config bind`);
|
||||
}
|
||||
if ((config.services["workspace-maintenance"]?.volumes || []).some(
|
||||
(mount) => mount.target === "/run/thothii-auth" || mount.target === "/data/auth",
|
||||
)) {
|
||||
throw new Error(`${name}: workspace-maintenance received authentication data`);
|
||||
}
|
||||
if ((config.services.frontend?.secrets || []).length !== 0) {
|
||||
throw new Error(`${name}: frontend must not receive runtime secrets`);
|
||||
}
|
||||
@@ -113,10 +126,13 @@ write_secret "$fixture_root/https-credentials" 'fixture-https-credentials'
|
||||
write_secret "$fixture_root/https-ca.pem" 'fixture-https-ca'
|
||||
write_secret "$fixture_root/dwh-password" 'fixture-dwh-password'
|
||||
write_secret "$fixture_root/vector-api-key" 'fixture-vector-api-key'
|
||||
mkdir -p "$fixture_root/auth"
|
||||
chmod 0700 "$fixture_root/auth"
|
||||
printf '%s\n' \
|
||||
'THT_WORKSPACE_GIT_REMOTE=https://git.example.invalid/platform/thoth-workspaces.git' \
|
||||
"PI_AUTH_FILE=$fixture_root/pi-auth.json" \
|
||||
"THT_SECRETS_FILE=$fixture_root/thothii.secrets" \
|
||||
"THT_AUTH_CONFIG_ROOT=$fixture_root/auth" \
|
||||
"THT_WORKSPACE_BINDINGS_ENV_FILE=$fixture_root/workspace-bindings.env" \
|
||||
"THT_WORKSPACE_GIT_SSH_KEY_FILE=$fixture_root/ssh-private-key" \
|
||||
"THT_WORKSPACE_GIT_KNOWN_HOSTS_FILE=$fixture_root/ssh-known-hosts" \
|
||||
@@ -188,6 +204,7 @@ printf '%s\n' \
|
||||
'THT_WORKSPACE_GIT_REMOTE=https://git.example.invalid/platform/thoth-workspaces.git' \
|
||||
"PI_AUTH_FILE=$fixture_root/pi-auth.json" \
|
||||
"THT_SECRETS_FILE=$fixture_root/thothii.secrets" \
|
||||
"THT_AUTH_CONFIG_ROOT=$fixture_root/auth" \
|
||||
"THT_WS_NORTH_STAR_RESEARCH_DWH_PASSWORD_SOURCE=$fixture_root/dwh-password" \
|
||||
"THT_WS_NORTH_STAR_RESEARCH_VECTOR_API_KEY_SOURCE=$fixture_root/vector-api-key" \
|
||||
>"$fixture_root/operator-with-vector.env"
|
||||
|
||||
@@ -24,7 +24,7 @@ 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", "qdrant-data", "embedding-models"]) {
|
||||
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 core = config.services.core;
|
||||
@@ -35,9 +35,12 @@ const modelInit = config.services["embedding-model-init"];
|
||||
if (!frontend.ports?.some((port) => port.host_ip === "127.0.0.1")) {
|
||||
throw new Error("local frontend must publish a loopback port");
|
||||
}
|
||||
for (const service of [qdrant, embedding, modelInit]) {
|
||||
for (const service of [embedding, modelInit]) {
|
||||
if ((service.ports || []).length !== 0) throw new Error("private semantic services must not publish host ports");
|
||||
}
|
||||
if (!qdrant.ports?.some((port) => port.host_ip === "127.0.0.1" && Number(port.target) === 6333)) {
|
||||
throw new Error("local Qdrant dashboard must publish only its loopback port");
|
||||
}
|
||||
if ((qdrant.expose || []).join(",") !== "6333") throw new Error("qdrant must expose only 6333");
|
||||
if ((embedding.expose || []).join(",") !== "11434") throw new Error("embedding must expose only 11434");
|
||||
if (!qdrant.healthcheck) throw new Error("qdrant must define a healthcheck");
|
||||
@@ -53,8 +56,9 @@ if (modelInit.image !== "ollama/ollama:0.32.0@sha256:57f573b47f1f71ebb445789f279
|
||||
}
|
||||
const env = core.environment || {};
|
||||
for (const [key, value] of Object.entries({
|
||||
AUTH_MODE: "none",
|
||||
THT_WORKSPACE_INSTALLATION_ID: "local",
|
||||
THT_AUTH_CONFIG_FILE: "/run/thothii-auth/auth.yaml",
|
||||
THT_AUTH_STATE_ROOT: "/data/auth",
|
||||
THT_INTERNAL_QDRANT_URL: "http://qdrant:6333",
|
||||
THT_INTERNAL_EMBEDDING_URL: "http://embedding:11434",
|
||||
THT_INTERNAL_EMBEDDING_MODEL: "qwen3-embedding:0.6b",
|
||||
@@ -62,6 +66,18 @@ for (const [key, value] of Object.entries({
|
||||
})) {
|
||||
if (env[key] !== value) throw new Error(`unexpected core ${key}: ${env[key]}`);
|
||||
}
|
||||
const authConfigMounts = (core.volumes || []).filter((mount) => mount.target === "/run/thothii-auth");
|
||||
if (authConfigMounts.length !== 1 || authConfigMounts[0].type !== "bind" || !authConfigMounts[0].read_only) {
|
||||
throw new Error("core must receive exactly one read-only authentication configuration bind");
|
||||
}
|
||||
const authStateMounts = (core.volumes || []).filter((mount) => mount.target === "/data/auth");
|
||||
if (authStateMounts.length !== 1 || authStateMounts[0].type !== "volume" || authStateMounts[0].source !== "auth-state") {
|
||||
throw new Error("core must receive exactly one auth-state volume");
|
||||
}
|
||||
const maintenanceMounts = config.services["workspace-maintenance"]?.volumes || [];
|
||||
if (maintenanceMounts.some((mount) => mount.target === "/run/thothii-auth" || mount.target === "/data/auth")) {
|
||||
throw new Error("workspace-maintenance must not receive authentication configuration or state");
|
||||
}
|
||||
for (const forbidden of ["THT_VEC_REST_URL", "THT_VEC_WRITE_REST_URL", "THT_OLLAMA_URL"]) {
|
||||
if (Object.hasOwn(env, forbidden) && env[forbidden] !== "") {
|
||||
throw new Error(`core must not require external semantic binding ${forbidden}`);
|
||||
|
||||
@@ -31,6 +31,13 @@ TASK13_LOG="$fixture/task13.log"
|
||||
TASK13_INSTALLATION="$fixture/thothii-installation.yaml"
|
||||
TASK13_PI_AUTH="$fixture/pi-auth.json"
|
||||
TASK13_SECRETS="$fixture/thothii.secrets"
|
||||
TASK13_AUTH_ROOT="$fixture/auth"
|
||||
TASK13_AUTH_PASSWORD_FILE="$fixture/local-auth-password"
|
||||
TASK13_AUTH_ADMIN=task13-admin
|
||||
TASK13_AUTH_PASSWORD="fixture-auth-password-$profile"
|
||||
TASK13_OIDC_CLIENT_SECRET="fixture-oidc-client-$profile"
|
||||
TASK13_AUTHENTIK_API_TOKEN="fixture-authentik-token-$profile"
|
||||
TASK13_FRONTEND_PORT=18080
|
||||
TASK13_SESSION_RUNTIME_PASSWORD="$fixture/runtime-password"
|
||||
TASK13_PI_MODELS="$fixture/models.json"
|
||||
TASK13_PI_SETTINGS="$fixture/settings.json"
|
||||
|
||||
@@ -169,11 +169,12 @@ task13_compose_logged() {
|
||||
task13_write_environment() {
|
||||
local remote="$1"
|
||||
{
|
||||
printf 'THOTH_HTTP_PORT=0\n'
|
||||
printf 'THOTH_HTTP_PORT=%s\n' "$TASK13_FRONTEND_PORT"
|
||||
printf 'THOTH_CORE_HTTP_PORT=0\n'
|
||||
printf 'MAX_PI_PROCESSES=2\n'
|
||||
printf 'PI_AUTH_FILE=%s\n' "$TASK13_PI_AUTH"
|
||||
printf 'THT_SECRETS_FILE=%s\n' "$TASK13_SECRETS"
|
||||
printf 'THT_AUTH_CONFIG_ROOT=%s\n' "$TASK13_AUTH_ROOT"
|
||||
printf 'THT_WORKSPACE_GIT_REMOTE=%s\n' "$remote"
|
||||
printf 'THT_WORKSPACE_GIT_BRANCH=%s\n' "$TASK13_BRANCH"
|
||||
printf 'THT_LLM_URL=http://%s:9000/v1\n' "$TASK13_LLM_CONTAINER"
|
||||
@@ -185,8 +186,11 @@ task13_write_fixture_files() {
|
||||
printf '{}\n' >"$TASK13_PI_AUTH"
|
||||
printf 'THT_MODEL_API_KEY=%s\n' "$TASK13_SECRET_VALUE" >"$TASK13_SECRETS"
|
||||
printf '%s' "task13-runtime-password-$TASK13_RUN_ID" >"$TASK13_SESSION_RUNTIME_PASSWORD"
|
||||
printf '%s' "$TASK13_AUTH_PASSWORD" >"$TASK13_AUTH_PASSWORD_FILE"
|
||||
mkdir -p "$TASK13_AUTH_ROOT"
|
||||
chmod 0644 "$TASK13_PI_AUTH"
|
||||
chmod 0600 "$TASK13_SECRETS" "$TASK13_SESSION_RUNTIME_PASSWORD"
|
||||
chmod 0700 "$TASK13_AUTH_ROOT"
|
||||
chmod 0600 "$TASK13_SECRETS" "$TASK13_SESSION_RUNTIME_PASSWORD" "$TASK13_AUTH_PASSWORD_FILE"
|
||||
|
||||
cat >"$TASK13_PI_MODELS" <<EOF
|
||||
{
|
||||
@@ -293,6 +297,8 @@ services:
|
||||
- $TASK13_PI_SETTINGS:/home/thoth/.pi/agent/settings.json:ro
|
||||
- workspace-registry:/data/workspace-registry
|
||||
- sessions:/data/sessions
|
||||
- $TASK13_AUTH_ROOT:/run/thothii-auth:ro
|
||||
- auth-state:/data/auth
|
||||
- $TASK13_SESSION_RUNTIME_PASSWORD:/run/secrets/task13-runtime-password:ro
|
||||
- $TASK13_REMOTE:/fixtures/remote.git:ro
|
||||
frontend:
|
||||
@@ -303,6 +309,8 @@ services:
|
||||
labels:
|
||||
io.thothii.task13.run: "$TASK13_RUN_ID"
|
||||
qdrant:
|
||||
# The normal local profile exposes a developer dashboard; the isolated smoke needs no host port.
|
||||
ports: !reset []
|
||||
labels:
|
||||
io.thothii.task13.run: "$TASK13_RUN_ID"
|
||||
embedding:
|
||||
@@ -328,6 +336,9 @@ volumes:
|
||||
sessions:
|
||||
labels:
|
||||
io.thothii.task13.run: "$TASK13_RUN_ID"
|
||||
auth-state:
|
||||
labels:
|
||||
io.thothii.task13.run: "$TASK13_RUN_ID"
|
||||
qdrant-data:
|
||||
labels:
|
||||
io.thothii.task13.run: "$TASK13_RUN_ID"
|
||||
@@ -341,6 +352,8 @@ EOF
|
||||
profile: local
|
||||
projectDirectory: "$TASK13_ROOT"
|
||||
envFile: "$TASK13_ENV_FILE"
|
||||
authentication:
|
||||
configDirectory: "$TASK13_AUTH_ROOT"
|
||||
overrides:
|
||||
- "$TASK13_OVERRIDE"
|
||||
EOF
|
||||
@@ -350,7 +363,8 @@ EOF
|
||||
task13_write_server_fixture_files() {
|
||||
local data_root pi_root registry_root remote_path workspace_path
|
||||
printf '{}\n' >"$TASK13_PI_AUTH"
|
||||
printf 'THT_MODEL_API_KEY=%s\n' "$TASK13_SECRET_VALUE" >"$TASK13_SECRETS"
|
||||
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"
|
||||
printf '%s' "$TASK13_SESSION_PASSWORD" >"$TASK13_SESSION_RUNTIME_PASSWORD"
|
||||
printf '%s' "$TASK13_SESSION_MIGRATOR_PASSWORD" >"$TASK13_SESSION_MIGRATOR_PASSWORD_FILE"
|
||||
cat >"$TASK13_SESSION_CA" <<'EOF'
|
||||
@@ -383,10 +397,12 @@ EOF
|
||||
chmod 0600 "$TASK13_SERVER_WORKSPACE_CONFIG"
|
||||
|
||||
mkdir -p "$TASK13_SERVER_DATA" "$TASK13_SERVER_PI_STATE" "$TASK13_SERVER_REGISTRY"
|
||||
mkdir -p "$TASK13_AUTH_ROOT"
|
||||
"$TASK13_ROOT/scripts/prepare-server-pi-state.sh" \
|
||||
"$TASK13_SERVER_PI_STATE" "$(id -u)" "$(id -g)" >>"$TASK13_LOG"
|
||||
chmod 0777 "$TASK13_SERVER_DATA" "$TASK13_SERVER_PI_STATE" \
|
||||
"$TASK13_SERVER_PI_STATE/agent" "$TASK13_SERVER_REGISTRY"
|
||||
chmod 0700 "$TASK13_AUTH_ROOT"
|
||||
data_root="$TASK13_SERVER_DATA"
|
||||
pi_root="$TASK13_SERVER_PI_STATE"
|
||||
registry_root="$TASK13_SERVER_REGISTRY"
|
||||
@@ -449,6 +465,7 @@ EOF
|
||||
printf 'MAX_PI_PROCESSES=2\n'
|
||||
printf 'PI_AUTH_FILE=%s\n' "$TASK13_PI_AUTH"
|
||||
printf 'THT_SECRETS_FILE=%s\n' "$TASK13_SECRETS"
|
||||
printf 'THT_AUTH_CONFIG_ROOT=%s\n' "$TASK13_AUTH_ROOT"
|
||||
printf 'THT_WORKSPACE_GIT_REMOTE=/fixtures/remote.git\n'
|
||||
printf 'THT_WORKSPACE_GIT_BRANCH=%s\n' "$TASK13_BRANCH"
|
||||
printf 'THT_DATA_ROOT=%s\n' "$data_root"
|
||||
@@ -467,6 +484,18 @@ EOF
|
||||
printf 'THT_LLM_URL=https://llm.task13.invalid/v1\n'
|
||||
} >"$TASK13_ENV_FILE"
|
||||
chmod 0600 "$TASK13_ENV_FILE"
|
||||
|
||||
cat >"$TASK13_INSTALLATION" <<EOF
|
||||
profile: server
|
||||
projectDirectory: "$TASK13_ROOT"
|
||||
envFile: "$TASK13_ENV_FILE"
|
||||
authentication:
|
||||
configDirectory: "$TASK13_AUTH_ROOT"
|
||||
overrides:
|
||||
- "$TASK13_ROOT/deploy/compose.session-server.yaml.example"
|
||||
- "$TASK13_OVERRIDE"
|
||||
EOF
|
||||
chmod 0600 "$TASK13_INSTALLATION"
|
||||
}
|
||||
|
||||
task13_workspace_metadata() {
|
||||
@@ -596,6 +625,22 @@ task13_assert_rendered_contract() {
|
||||
if grep -Eq 'THT_VEC_REST_URL|THT_VEC_WRITE_REST_URL|THT_OLLAMA_URL' "$rendered"; then
|
||||
task13_fail "rendered Compose still exposes retired external semantic bindings"
|
||||
fi
|
||||
grep -Fq '/run/thothii-auth' "$rendered" || task13_fail "rendered Compose lacks the read-only auth configuration mount"
|
||||
grep -Fq '/data/auth' "$rendered" || task13_fail "rendered Compose lacks authentication state storage"
|
||||
}
|
||||
|
||||
task13_configure_local_authentication() {
|
||||
task13_run_logged "configure local authentication" "$TASK13_THT" --installation "$TASK13_INSTALLATION" auth configure \
|
||||
--mode local --public-url "http://127.0.0.1:$TASK13_FRONTEND_PORT" \
|
||||
--admin-user "$TASK13_AUTH_ADMIN" --admin-display-name "Task 13 Administrator" \
|
||||
--password-file "$TASK13_AUTH_PASSWORD_FILE"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
task13_start_stack() {
|
||||
@@ -641,6 +686,49 @@ task13_core_id() {
|
||||
task13_compose ps -q core
|
||||
}
|
||||
|
||||
task13_assert_maintenance_auth_isolation() {
|
||||
task13_compose_logged "workspace maintenance auth isolation" \
|
||||
--profile workspace-maintenance run --rm --no-deps --entrypoint sh workspace-maintenance -ceu \
|
||||
'test ! -e /run/thothii-auth && test ! -e /data/auth'
|
||||
}
|
||||
|
||||
task13_assert_local_auth_lifecycle() {
|
||||
local frontend cookie_jar login_body me csrf unauthenticated
|
||||
frontend="$(task13_frontend_address)"
|
||||
cookie_jar="$TASK13_TMP/local-auth.cookies"
|
||||
login_body="$TASK13_TMP/local-auth-login.json"
|
||||
printf '{"username":"%s","password":"%s","remember":true}' \
|
||||
"$TASK13_AUTH_ADMIN" "$TASK13_AUTH_PASSWORD" >"$login_body"
|
||||
chmod 0600 "$cookie_jar" "$login_body" 2>/dev/null || chmod 0600 "$login_body"
|
||||
|
||||
task13_run_logged "local auth configuration" curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" \
|
||||
--max-time "$TASK13_CURL_MAX_TIME" --fail --silent --show-error "http://$frontend/api/auth/config"
|
||||
task13_run_logged "local auth login" curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" \
|
||||
--max-time "$TASK13_CURL_MAX_TIME" --fail --silent --show-error --cookie-jar "$cookie_jar" \
|
||||
-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_jar" "http://$frontend/api/me")"
|
||||
node -e 'const value=JSON.parse(process.argv[1]); if(value.issuer!=="local"||value.session?.remembered!==true||typeof value.csrfToken!=="string") process.exit(1)' "$me" \
|
||||
|| task13_fail "local login did not create a remembered authenticated session"
|
||||
csrf="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).csrfToken)' "$me")"
|
||||
task13_compose_logged "remembered local auth core restart" up --detach --force-recreate --wait --wait-timeout 120 core
|
||||
me="$(curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" --max-time "$TASK13_CURL_MAX_TIME" \
|
||||
--fail --silent --show-error --cookie "$cookie_jar" "http://$frontend/api/me")"
|
||||
node -e 'const value=JSON.parse(process.argv[1]); if(value.session?.remembered!==true) process.exit(1)' "$me" \
|
||||
|| task13_fail "remembered local session did not survive a core restart"
|
||||
csrf="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).csrfToken)' "$me")"
|
||||
task13_run_logged "local auth Pi management" curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" \
|
||||
--max-time 45 --fail --silent --show-error --cookie "$cookie_jar" \
|
||||
-H "Origin: http://$frontend" -H "x-thothii-csrf: $csrf" -X POST "http://$frontend/api/pi-management/test"
|
||||
task13_run_logged "local auth logout" curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" \
|
||||
--max-time "$TASK13_CURL_MAX_TIME" --fail --silent --show-error --cookie "$cookie_jar" \
|
||||
-H "Origin: http://$frontend" -H "x-thothii-csrf: $csrf" -X POST "http://$frontend/api/auth/logout"
|
||||
unauthenticated="$(curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" --max-time "$TASK13_CURL_MAX_TIME" \
|
||||
--silent --output /dev/null --write-out '%{http_code}' --cookie "$cookie_jar" "http://$frontend/api/me")"
|
||||
[[ "$unauthenticated" == 401 ]] || task13_fail "local logout did not revoke the remembered session"
|
||||
}
|
||||
|
||||
task13_assert_runtime() {
|
||||
local frontend expected_pi actual_pi core_id
|
||||
frontend="$(task13_frontend_address)"
|
||||
@@ -668,25 +756,11 @@ task13_assert_runtime() {
|
||||
core_id="$(task13_core_id)"
|
||||
[[ "$(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_compose_logged "internal Pi provider smoke" exec -T core \
|
||||
curl --connect-timeout 3 --max-time 45 -fsS -X POST \
|
||||
-H 'x-thoth-principal-issuer: tht' \
|
||||
-H 'x-thoth-principal-subject: tht-maintenance' \
|
||||
-H 'x-thoth-principal-display-name: Tht maintenance' \
|
||||
-H 'x-thoth-is-admin: 1' \
|
||||
http://127.0.0.1:8787/pi-management/test
|
||||
task13_assert_maintenance_auth_isolation
|
||||
task13_assert_local_auth_lifecycle
|
||||
task13_run_logged "tht Pi doctor" "$TASK13_THT" --installation "$TASK13_INSTALLATION" pi doctor
|
||||
}
|
||||
|
||||
task13_server_auth_headers() {
|
||||
TASK13_SERVER_AUTH_HEADERS=(
|
||||
-H 'x-thoth-trusted-principal-issuer: task13-proxy'
|
||||
-H 'x-thoth-trusted-principal-subject: task13-user'
|
||||
-H 'x-thoth-trusted-principal-display-name: Task 13 User'
|
||||
-H 'x-thoth-trusted-is-admin: 0'
|
||||
)
|
||||
}
|
||||
|
||||
task13_report_server_workspace_failure() {
|
||||
local status="$1" response="$2"
|
||||
printf 'authenticated server /api/workspaces returned HTTP %s\n' "$status" >&2
|
||||
@@ -725,7 +799,7 @@ task13_report_server_workspace_failure() {
|
||||
}
|
||||
|
||||
task13_assert_server_runtime() {
|
||||
local frontend unauthenticated authenticated authenticated_status session_status core_id frontend_id
|
||||
local frontend unauthenticated trusted_header_status session_status diagnostics diagnostic_status core_id frontend_id
|
||||
local expected_core_image expected_frontend_image
|
||||
frontend="$(task13_frontend_address)"
|
||||
task13_run_logged "server frontend health" curl \
|
||||
@@ -744,7 +818,9 @@ task13_assert_server_runtime() {
|
||||
[[ "$(docker inspect --format '{{.Image}}' "$frontend_id")" == "$expected_frontend_image" ]] \
|
||||
|| task13_fail "server frontend did not use the smoke-built frontend image"
|
||||
task13_compose exec -T core sh -ceu '
|
||||
test "$AUTH_MODE" = upstream
|
||||
test -r /run/thothii-auth/auth.yaml
|
||||
test -d /data/auth
|
||||
test -z "${AUTH_MODE+x}"
|
||||
test "$THT_SESSION_STORAGE" = postgres
|
||||
test -r /run/secrets/thothii.secrets
|
||||
test -r /run/secrets/session_runtime_password
|
||||
@@ -757,37 +833,41 @@ task13_assert_server_runtime() {
|
||||
|| task13_fail "server profile did not bind the disposable Pi state root"
|
||||
task13_mount_fingerprint | grep -Fq '/data/workspace-registry = bind :' \
|
||||
|| task13_fail "server profile did not bind the disposable registry root"
|
||||
task13_assert_maintenance_auth_isolation
|
||||
|
||||
unauthenticated="$(curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" \
|
||||
--max-time "$TASK13_CURL_MAX_TIME" --silent --output /dev/null --write-out '%{http_code}' \
|
||||
"http://$frontend/api/workspaces")"
|
||||
[[ "$unauthenticated" == 401 ]] || task13_fail "server profile did not enforce upstream auth"
|
||||
authenticated="$TASK13_TMP/server-workspaces.out"
|
||||
task13_server_auth_headers
|
||||
if ! authenticated_status="$(curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" \
|
||||
--max-time "$TASK13_CURL_MAX_TIME" --silent --show-error --output "$authenticated" \
|
||||
--write-out '%{http_code}' "${TASK13_SERVER_AUTH_HEADERS[@]}" \
|
||||
"http://$frontend/api/workspaces")"; then
|
||||
task13_report_server_workspace_failure "${authenticated_status:-transport-error}" "$authenticated"
|
||||
task13_fail "authenticated server workspace request failed"
|
||||
fi
|
||||
if [[ "$authenticated_status" != 200 ]]; then
|
||||
task13_report_server_workspace_failure "$authenticated_status" "$authenticated"
|
||||
task13_fail "authenticated server workspace route returned an unexpected status"
|
||||
fi
|
||||
grep -Fq 'Task 13 Smoke' "$authenticated" \
|
||||
|| task13_fail "authenticated server route did not expose the disposable registry"
|
||||
[[ "$unauthenticated" == 401 ]] || task13_fail "server profile did not enforce OIDC authentication"
|
||||
trusted_header_status="$(curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" \
|
||||
--max-time "$TASK13_CURL_MAX_TIME" --silent --output /dev/null --write-out '%{http_code}' \
|
||||
-H 'x-thoth-trusted-principal-issuer: task13-proxy' \
|
||||
-H 'x-thoth-trusted-principal-subject: task13-user' \
|
||||
-H 'x-thoth-trusted-principal-display-name: Task 13 User' \
|
||||
-H 'x-thoth-trusted-is-admin: 0' \
|
||||
"http://$frontend/api/workspaces")"
|
||||
[[ "$trusted_header_status" == 401 ]] || task13_fail "server accepted retired trusted identity headers"
|
||||
|
||||
session_status="$(curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" \
|
||||
--max-time "$TASK13_CURL_MAX_TIME" --silent --output "$TASK13_TMP/server-sessions.out" \
|
||||
--write-out '%{http_code}' \
|
||||
"${TASK13_SERVER_AUTH_HEADERS[@]}" \
|
||||
"http://$frontend/api/sessions")"
|
||||
[[ "$session_status" == 503 ]] \
|
||||
|| task13_fail "disposable unavailable session dependency did not fail closed with 503"
|
||||
[[ "$session_status" == 401 ]] \
|
||||
|| task13_fail "server session route did not fail closed before OIDC authentication"
|
||||
if grep -Fq "$TASK13_SECRET_VALUE" "$TASK13_TMP/server-sessions.out"; then
|
||||
task13_fail "server session failure exposed the fixture secret"
|
||||
fi
|
||||
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"
|
||||
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() {
|
||||
@@ -1535,24 +1615,6 @@ task13_self_test_server_release_contract() {
|
||||
|| task13_fail "workflow lacks an outer timeout for the Linux server smoke"
|
||||
}
|
||||
|
||||
task13_self_test_server_auth_hop_contract() {
|
||||
local joined
|
||||
task13_server_auth_headers
|
||||
joined="${TASK13_SERVER_AUTH_HEADERS[*]}"
|
||||
for header in \
|
||||
x-thoth-trusted-principal-issuer \
|
||||
x-thoth-trusted-principal-subject \
|
||||
x-thoth-trusted-principal-display-name \
|
||||
x-thoth-trusted-is-admin; do
|
||||
[[ "$joined" == *"$header:"* ]] \
|
||||
|| task13_fail "server smoke omits trusted frontend hop header: $header"
|
||||
done
|
||||
[[ "$joined" == *'x-thoth-trusted-is-admin: 0'* ]] \
|
||||
|| task13_fail "server smoke admin claim is not the exact non-admin value"
|
||||
[[ "$joined" != *'x-thoth-principal-issuer:'* ]] \
|
||||
|| task13_fail "server smoke sends public identity headers to the frontend hop"
|
||||
}
|
||||
|
||||
task13_self_test_source_contract() {
|
||||
local root host_network push_command registry_function workflow uses_count pinned_uses_count
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
|
||||
@@ -1616,7 +1678,6 @@ task13_self_test() {
|
||||
task13_self_test_internal_semantic_offline_contract
|
||||
task13_self_test_windows_release_contract
|
||||
task13_self_test_server_release_contract
|
||||
task13_self_test_server_auth_hop_contract
|
||||
task13_self_test_source_contract
|
||||
printf 'Task 13 smoke safety contracts passed.\n'
|
||||
}
|
||||
@@ -1633,7 +1694,6 @@ task13_self_test_case() {
|
||||
semantic-offline) task13_self_test_internal_semantic_offline_contract ;;
|
||||
windows) task13_self_test_windows_release_contract ;;
|
||||
server) task13_self_test_server_release_contract ;;
|
||||
server-auth) task13_self_test_server_auth_hop_contract ;;
|
||||
server-diagnostics) task13_self_test_server_workspace_diagnostics ;;
|
||||
*) task13_fail "unknown Task 13 self-test case: $1" ;;
|
||||
esac
|
||||
@@ -1708,6 +1768,12 @@ task13_initialize() {
|
||||
TASK13_OVERRIDE="$TASK13_TMP/compose.task13.yaml"
|
||||
TASK13_PI_AUTH="$TASK13_TMP/pi-auth.json"
|
||||
TASK13_SECRETS="$TASK13_TMP/thothii.secrets"
|
||||
TASK13_AUTH_ROOT="$TASK13_TMP/auth"
|
||||
TASK13_AUTH_PASSWORD_FILE="$TASK13_TMP/local-auth-password"
|
||||
TASK13_AUTH_ADMIN=task13-admin
|
||||
TASK13_AUTH_PASSWORD="task13-auth-$TASK13_RUN_ID"
|
||||
TASK13_OIDC_CLIENT_SECRET="task13-oidc-client-$TASK13_RUN_ID"
|
||||
TASK13_AUTHENTIK_API_TOKEN="task13-authentik-token-$TASK13_RUN_ID"
|
||||
TASK13_PI_MODELS="$TASK13_TMP/models.json"
|
||||
TASK13_PI_SETTINGS="$TASK13_TMP/pi-settings.json"
|
||||
TASK13_LLM_SERVER="$TASK13_TMP/fake-llm.mjs"
|
||||
@@ -1729,10 +1795,11 @@ task13_initialize() {
|
||||
TASK13_SESSION_CA="$TASK13_TMP/session-ca.pem"
|
||||
TASK13_SESSION_PASSWORD="task13-runtime-$TASK13_RUN_ID"
|
||||
TASK13_SESSION_MIGRATOR_PASSWORD="task13-migrator-$TASK13_RUN_ID"
|
||||
TASK13_FRONTEND_PORT=""
|
||||
}
|
||||
|
||||
task13_require_tools() {
|
||||
for command in bash git docker curl sed awk grep rg sort; do
|
||||
for command in bash git docker curl node 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
|
||||
@@ -1745,10 +1812,13 @@ task13_smoke_main() {
|
||||
[[ "$mode" == full || "$mode" == update ]] || task13_fail "unknown Task 13 smoke mode: $mode"
|
||||
task13_initialize
|
||||
task13_require_tools
|
||||
TASK13_FRONTEND_PORT="$(node -e 'const net=require("node:net"); const server=net.createServer(); server.listen(0,"127.0.0.1",()=>{process.stdout.write(String(server.address().port)); server.close()})')"
|
||||
[[ "$TASK13_FRONTEND_PORT" =~ ^[1-9][0-9]*$ ]] || task13_fail "could not reserve a loopback frontend port"
|
||||
task13_write_fixture_files
|
||||
task13_write_environment /fixtures/remote.git
|
||||
task13_seed_registry
|
||||
task13_build_tht
|
||||
task13_configure_local_authentication
|
||||
task13_start_stack
|
||||
task13_assert_project_ownership
|
||||
task13_assert_built_image_ownership
|
||||
@@ -1767,6 +1837,8 @@ task13_server_smoke_main() {
|
||||
task13_require_tools
|
||||
task13_write_server_fixture_files
|
||||
task13_seed_registry
|
||||
task13_build_tht
|
||||
task13_configure_server_oidc_authentication
|
||||
task13_start_server_stack
|
||||
task13_assert_project_ownership
|
||||
task13_assert_built_image_ownership
|
||||
|
||||
@@ -364,6 +364,26 @@ func parseSetupArgs(args []string) (setup.Request, error) {
|
||||
target = &request.Answers.GitSSHKeyFile
|
||||
case "--git-known-hosts-file":
|
||||
target = &request.Answers.GitKnownHostsFile
|
||||
case "--auth-mode":
|
||||
target = &request.Answers.AuthMode
|
||||
case "--auth-public-url":
|
||||
target = &request.Answers.AuthPublicURL
|
||||
case "--auth-admin-user":
|
||||
target = &request.Answers.AuthAdminUser
|
||||
case "--auth-admin-display-name":
|
||||
target = &request.Answers.AuthAdminDisplayName
|
||||
case "--auth-password-file":
|
||||
target = &request.Answers.AuthPasswordFile
|
||||
case "--auth-issuer":
|
||||
target = &request.Answers.AuthIssuer
|
||||
case "--auth-client-id":
|
||||
target = &request.Answers.AuthClientID
|
||||
case "--auth-authentik-base-url":
|
||||
target = &request.Answers.AuthAuthentikBaseURL
|
||||
case "--auth-user-group":
|
||||
target = &request.Answers.AuthUserGroup
|
||||
case "--auth-admin-group":
|
||||
target = &request.Answers.AuthAdminGroup
|
||||
default:
|
||||
return setup.Request{}, fmt.Errorf("unknown setup option %q", flag)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,22 @@ func TestBackupCommandParsesSafeTransactionalOptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupArgumentsRequireCompleteNonInteractiveAuthenticationInputs(t *testing.T) {
|
||||
request, err := parseSetupArgs([]string{
|
||||
"--non-interactive", "--auth-mode", "local", "--auth-public-url", "http://127.0.0.1:8080",
|
||||
"--auth-admin-user", "admin", "--auth-admin-display-name", "Initial Admin", "--auth-password-file", "/protected/password",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if request.Answers.AuthMode != "local" || request.Answers.AuthPasswordFile != "/protected/password" {
|
||||
t.Fatalf("setup request = %#v", request)
|
||||
}
|
||||
if _, err := parseSetupArgs([]string{"--auth-mode", "local", "--auth-mode", "oidc"}); err == nil {
|
||||
t.Fatal("duplicate --auth-mode was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupCommandDispatchesWithoutDockerAndPrintsCustodyWarning(t *testing.T) {
|
||||
installation := config.Installation{Path: "/tmp/thothii-installation.yaml"}
|
||||
var received backup.CreateRequest
|
||||
@@ -1469,7 +1485,7 @@ case " $* " in
|
||||
if [ -n "${THT_FAKE_CONFIG:-}" ]; then
|
||||
printf '%s\n' "$THT_FAKE_CONFIG"
|
||||
else
|
||||
printf '%s\n' '{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}'
|
||||
printf '%s\n' '{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{},"auth-state":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}'
|
||||
fi ;;
|
||||
*" run --rm --no-deps --no-TTY session-migrate "*)
|
||||
if [ "${THT_FAKE_MIGRATION_EXIT:-0}" -ne 0 ]; then
|
||||
|
||||
@@ -21,7 +21,9 @@ import (
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/compose"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/lifecycle"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/service"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -130,6 +132,10 @@ func createWithDependencies(ctx context.Context, installation config.Installatio
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("installation external secret references could not be read: %w", err)
|
||||
}
|
||||
authenticationPaths, err := authenticationConfigFiles(installation)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
revision, err := dependencies.revision(ctx, installation.ProjectDirectory)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
@@ -208,12 +214,12 @@ func createWithDependencies(ctx context.Context, installation config.Installatio
|
||||
InstallationID: installationID,
|
||||
CreatedAt: dependencies.now().UTC(),
|
||||
SourceRevision: revision,
|
||||
IncludesSecrets: request.IncludeSecrets && len(secretPaths) > 0,
|
||||
IncludesSecrets: request.IncludeSecrets && len(secretPaths)+len(authenticationPaths) > 0,
|
||||
ComposeProject: installation.ProjectName(),
|
||||
Images: images,
|
||||
Volumes: volumes,
|
||||
}
|
||||
if err := writeArchive(ctx, output, reservation, installation, request, secretPaths, manifest, volumes, dependencies); err != nil {
|
||||
if err := writeArchive(ctx, output, reservation, installation, request, secretPaths, authenticationPaths, manifest, volumes, dependencies); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
published = true
|
||||
@@ -228,8 +234,8 @@ func createWithDependencies(ctx context.Context, installation config.Installatio
|
||||
maintenanceActive = false
|
||||
}
|
||||
result = Result{Path: output}
|
||||
if request.IncludeSecrets {
|
||||
result.Warning = "The archive contains external secret files. Protect its custody and access."
|
||||
if manifest.IncludesSecrets {
|
||||
result.Warning = "The archive contains external secret files, including authentication configuration. Protect its custody and access."
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -631,7 +637,7 @@ func runCompose(ctx context.Context, installation config.Installation, runner ar
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeArchive(ctx context.Context, output string, reservation *archiveReservation, installation config.Installation, request CreateRequest, secretPaths []string, manifest Manifest, volumes []VolumeMetadata, dependencies dependencies) (resultErr error) {
|
||||
func writeArchive(ctx context.Context, output string, reservation *archiveReservation, installation config.Installation, request CreateRequest, secretPaths, authenticationPaths []string, manifest Manifest, volumes []VolumeMetadata, dependencies dependencies) (resultErr error) {
|
||||
directory := filepath.Dir(output)
|
||||
temporary, err := os.CreateTemp(directory, ".tht-backup-*.tmp")
|
||||
if err != nil {
|
||||
@@ -732,6 +738,23 @@ func writeArchive(ctx context.Context, output string, reservation *archiveReserv
|
||||
SourcePath: source, SHA256: "sha256:" + hex.EncodeToString(hash.Sum(nil)), Size: size, Sensitive: true,
|
||||
})
|
||||
}
|
||||
for index, source := range authenticationPaths {
|
||||
name := fmt.Sprintf("authentication-secrets/%03d-%s", index, filepath.Base(source))
|
||||
if request.IncludeSecrets {
|
||||
if err := addFile(name, "authentication-configuration", true, source); err != nil {
|
||||
return err
|
||||
}
|
||||
entry := &manifest.Entries[len(manifest.Entries)-1]
|
||||
entry.Kind, entry.SourcePath, entry.Sensitive = EntryExternalSecret, source, true
|
||||
continue
|
||||
}
|
||||
if filepath.Base(source) != "auth.yaml" {
|
||||
continue
|
||||
}
|
||||
if err := addAuthenticationReference(&manifest, name, source); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, volume := range volumes {
|
||||
headerName := "volumes/" + volume.LogicalName + ".tar"
|
||||
header := &zip.FileHeader{Name: headerName, Method: zip.Deflate}
|
||||
@@ -804,6 +827,51 @@ func configurationInputs(installation config.Installation) []configurationInput
|
||||
return inputs
|
||||
}
|
||||
|
||||
const maxAuthenticationConfigurationBytes = 1 << 20
|
||||
|
||||
func authenticationConfigFiles(installation config.Installation) ([]string, error) {
|
||||
directory := installation.AuthenticationDirectory()
|
||||
if directory == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if err := safeio.ValidatePrivateDirectory(directory); err != nil {
|
||||
return nil, errors.New("authentication configuration directory is unavailable or unsafe")
|
||||
}
|
||||
authPath := filepath.Join(directory, "auth.yaml")
|
||||
contents, err := safeio.ReadCanonicalRegular(authPath, maxAuthenticationConfigurationBytes)
|
||||
if err != nil {
|
||||
return nil, errors.New("authentication configuration is unavailable or unsafe")
|
||||
}
|
||||
var configuration struct {
|
||||
Mode string `yaml:"mode"`
|
||||
}
|
||||
if err := yaml.Unmarshal(contents, &configuration); err != nil || (configuration.Mode != "local" && configuration.Mode != "oidc") {
|
||||
return nil, errors.New("authentication configuration is unavailable or invalid")
|
||||
}
|
||||
paths := []string{authPath}
|
||||
if configuration.Mode == "local" {
|
||||
usersPath := filepath.Join(directory, "users.yaml")
|
||||
if _, err := safeio.ReadCanonicalRegular(usersPath, maxAuthenticationConfigurationBytes); err != nil {
|
||||
return nil, errors.New("authentication user registry is unavailable or unsafe")
|
||||
}
|
||||
paths = append(paths, usersPath)
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func addAuthenticationReference(manifest *Manifest, name, source string) error {
|
||||
contents, err := safeio.ReadCanonicalRegular(source, maxAuthenticationConfigurationBytes)
|
||||
if err != nil {
|
||||
return errors.New("authentication configuration is unavailable or unsafe")
|
||||
}
|
||||
digest := sha256.Sum256(contents)
|
||||
manifest.Entries = append(manifest.Entries, Entry{
|
||||
Path: name, Kind: EntrySecretReference, Owner: "authentication-configuration", SourcePath: source,
|
||||
SHA256: "sha256:" + hex.EncodeToString(digest[:]), Size: int64(len(contents)), Sensitive: true,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func volumeArchiveCommand(volume string) []string {
|
||||
return []string{"run", "--rm", "--network", "none", "--mount", "type=volume,src=" + volume + ",dst=/source,readonly", helperImage, "tar", "--numeric-owner", "-C", "/source", "-cf", "-", "."}
|
||||
}
|
||||
@@ -837,6 +905,10 @@ func archivePreservationRoots(installation config.Installation, secretPaths []st
|
||||
if err != nil || !info.IsDir() {
|
||||
return errors.New("server preservation root is unavailable")
|
||||
}
|
||||
authStateRoot := ""
|
||||
if variable == "THT_DATA_ROOT" {
|
||||
authStateRoot = filepath.Join(root, "auth")
|
||||
}
|
||||
err = filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
@@ -847,6 +919,12 @@ func archivePreservationRoots(installation config.Installation, secretPaths []st
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if authStateRoot != "" && path == authStateRoot {
|
||||
if entry.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
return errors.New("server preservation root contains a symlink")
|
||||
}
|
||||
|
||||
@@ -70,6 +70,75 @@ func TestCreateWritesManifestLastWithConfigurationMetadataAndSevenVolumes(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateReferencesAuthFilesByDefaultAndArchivesThemOnlyWithSecretCustody(t *testing.T) {
|
||||
fixture := newBackupFixture(t, "local")
|
||||
authDirectory := filepath.Join(filepath.Dir(fixture.installation.Path), "auth")
|
||||
if err := os.Mkdir(authDirectory, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authPath := filepath.Join(authDirectory, "auth.yaml")
|
||||
usersPath := filepath.Join(authDirectory, "users.yaml")
|
||||
if err := os.WriteFile(authPath, []byte("version: 1\nmode: local\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(usersPath, []byte("users:\n - passwordHash: must-not-be-archived-by-default\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixture.installation.Authentication.ConfigDirectory = authDirectory
|
||||
|
||||
defaultOutput := filepath.Join(t.TempDir(), "default.zip")
|
||||
defaultResult, err := createWithDependencies(context.Background(), fixture.installation, CreateRequest{Output: defaultOutput}, testDependencies(t, newBackupRunner(fixture.installation, false)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if defaultResult.Warning != "" {
|
||||
t.Fatalf("default backup warning = %q, want no custody warning", defaultResult.Warning)
|
||||
}
|
||||
defaultArchive := readFixtureArchive(t, defaultOutput)
|
||||
defaultBytes := bytes.Join(mapValues(defaultArchive.files), nil)
|
||||
for _, value := range []string{"mode: local", "must-not-be-archived-by-default"} {
|
||||
if bytes.Contains(defaultBytes, []byte(value)) {
|
||||
t.Fatalf("default backup contains authentication content %q", value)
|
||||
}
|
||||
}
|
||||
if !manifestHasReference(defaultArchive.manifest, authPath) || manifestHasReference(defaultArchive.manifest, usersPath) {
|
||||
t.Fatalf("default backup did not record only the auth.yaml configuration path: %#v", defaultArchive.manifest.Entries)
|
||||
}
|
||||
|
||||
secretOutput := filepath.Join(t.TempDir(), "with-auth-secrets.zip")
|
||||
secretResult, err := createWithDependencies(context.Background(), fixture.installation, CreateRequest{Output: secretOutput, IncludeSecrets: true, Confirm: true}, testDependencies(t, newBackupRunner(fixture.installation, false)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(secretResult.Warning, "custody") {
|
||||
t.Fatalf("secret backup warning = %q, want custody guidance", secretResult.Warning)
|
||||
}
|
||||
secretArchive := readFixtureArchive(t, secretOutput)
|
||||
for _, path := range []string{authPath, usersPath} {
|
||||
if !manifestHasArchivedSecret(secretArchive.manifest, path) {
|
||||
t.Fatalf("secret backup did not archive authentication file %q", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func manifestHasReference(manifest Manifest, sourcePath string) bool {
|
||||
for _, entry := range manifest.Entries {
|
||||
if entry.Kind == EntrySecretReference && entry.SourcePath == sourcePath && !entry.Archived {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func manifestHasArchivedSecret(manifest Manifest, sourcePath string) bool {
|
||||
for _, entry := range manifest.Entries {
|
||||
if entry.Kind == EntryExternalSecret && entry.SourcePath == sourcePath && entry.Archived && entry.Sensitive {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestCreateRestartsAndVerifiesAnInstallationThatWasRunning(t *testing.T) {
|
||||
fixture := newBackupFixture(t, "local")
|
||||
runner := newBackupRunner(fixture.installation, true)
|
||||
|
||||
@@ -27,18 +27,19 @@ type RestoreResult struct {
|
||||
Verified bool
|
||||
}
|
||||
|
||||
type restoreLock interface { Release() error }
|
||||
type restoreLock interface{ Release() error }
|
||||
type restoreVerify func(context.Context, config.Installation, archiveRunner) error
|
||||
|
||||
type restoreDependencies struct {
|
||||
preflight func(context.Context, config.Installation, PreflightRequest) (PreflightResult, error)
|
||||
checkpoint func(context.Context, config.Installation, CreateRequest) (Result, error)
|
||||
acquireLock func(config.Installation) (restoreLock, error)
|
||||
runner archiveRunner
|
||||
sleep func(duration time.Duration)
|
||||
restoreFile func(context.Context, config.Installation, ArchiveEntryMetadata, io.Reader) error
|
||||
restoreVolume func(context.Context, config.Installation, VolumeMetadata, io.Reader) error
|
||||
verify map[string]restoreVerify
|
||||
preflight func(context.Context, config.Installation, PreflightRequest) (PreflightResult, error)
|
||||
checkpoint func(context.Context, config.Installation, CreateRequest) (Result, error)
|
||||
acquireLock func(config.Installation) (restoreLock, error)
|
||||
runner archiveRunner
|
||||
sleep func(duration time.Duration)
|
||||
restoreFile func(context.Context, config.Installation, ArchiveEntryMetadata, io.Reader) error
|
||||
restoreVolume func(context.Context, config.Installation, VolumeMetadata, io.Reader) error
|
||||
resetAuthenticationState func(context.Context, config.Installation, archiveRunner) error
|
||||
verify map[string]restoreVerify
|
||||
}
|
||||
|
||||
// Restore runs the host transaction. Concrete host dependencies are intentionally kept outside
|
||||
@@ -48,60 +49,126 @@ func Restore(ctx context.Context, installation config.Installation, request Rest
|
||||
}
|
||||
|
||||
func restoreWithDependencies(ctx context.Context, installation config.Installation, request RestoreRequest, deps restoreDependencies) (result RestoreResult, resultErr error) {
|
||||
if !request.Confirm { return RestoreResult{}, ErrRestoreConfirmationRequired }
|
||||
if request.Archive == "" { return RestoreResult{}, errors.New("restore archive is required") }
|
||||
if deps.preflight == nil || deps.checkpoint == nil || deps.acquireLock == nil || deps.runner == nil || deps.restoreFile == nil || deps.restoreVolume == nil || deps.verify == nil {
|
||||
if !request.Confirm {
|
||||
return RestoreResult{}, ErrRestoreConfirmationRequired
|
||||
}
|
||||
if request.Archive == "" {
|
||||
return RestoreResult{}, errors.New("restore archive is required")
|
||||
}
|
||||
if deps.preflight == nil || deps.checkpoint == nil || deps.acquireLock == nil || deps.runner == nil || deps.restoreFile == nil || deps.restoreVolume == nil || deps.resetAuthenticationState == nil || deps.verify == nil {
|
||||
return RestoreResult{}, errors.New("restore dependencies are incomplete")
|
||||
}
|
||||
preflight, err := deps.preflight(ctx, installation, PreflightRequest{Archive: request.Archive, Confirm: true, AllowExternalSecrets: true})
|
||||
if err != nil { return RestoreResult{}, err }
|
||||
if err != nil {
|
||||
return RestoreResult{}, err
|
||||
}
|
||||
defer preflight.CloseArchive()
|
||||
archive, err := preflight.RevalidateArchive()
|
||||
if err != nil { return RestoreResult{}, err }
|
||||
if err != nil {
|
||||
return RestoreResult{}, err
|
||||
}
|
||||
|
||||
checkpoint, err := deps.checkpoint(ctx, installation, CreateRequest{})
|
||||
if err != nil { return RestoreResult{}, fmt.Errorf("create recovery checkpoint: %w", err) }
|
||||
if err != nil {
|
||||
return RestoreResult{}, fmt.Errorf("create recovery checkpoint: %w", err)
|
||||
}
|
||||
result.Checkpoint = checkpoint.Path
|
||||
lock, err := deps.acquireLock(installation)
|
||||
if err != nil { return result, err }
|
||||
defer func() { if releaseErr := lock.Release(); releaseErr != nil && resultErr == nil { resultErr = releaseErr } }()
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer func() {
|
||||
if releaseErr := lock.Release(); releaseErr != nil && resultErr == nil {
|
||||
resultErr = releaseErr
|
||||
}
|
||||
}()
|
||||
|
||||
wasRunning, err := installationRunning(ctx, installation, deps.runner)
|
||||
if err != nil { return result, err }
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
mutated := false
|
||||
defer func() {
|
||||
if resultErr != nil && mutated { _ = runCompose(context.Background(), installation, deps.runner, "stop") }
|
||||
if resultErr != nil && mutated {
|
||||
_ = runCompose(context.Background(), installation, deps.runner, "stop")
|
||||
}
|
||||
}()
|
||||
if wasRunning {
|
||||
if err := maintenance(ctx, installation, deps.runner, true); err != nil { return result, err }
|
||||
if err := waitForNoActiveSessions(ctx, installation, deps.runner, request.Drain, deps.sleep); err != nil { return result, err }
|
||||
if err := runCompose(ctx, installation, deps.runner, "stop"); err != nil { return result, err }
|
||||
if err := maintenance(ctx, installation, deps.runner, true); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if err := waitForNoActiveSessions(ctx, installation, deps.runner, request.Drain, deps.sleep); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if err := runCompose(ctx, installation, deps.runner, "stop"); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
reader, err := zip.NewReader(archive, preflight.ArchiveSize)
|
||||
if err != nil { return result, fmt.Errorf("read verified restore archive: %w", err) }
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("read verified restore archive: %w", err)
|
||||
}
|
||||
members := make(map[string]*zip.File, len(reader.File))
|
||||
for _, member := range reader.File { members[member.Name] = member }
|
||||
for _, member := range reader.File {
|
||||
members[member.Name] = member
|
||||
}
|
||||
for _, entry := range preflight.Entries {
|
||||
if entry.Kind == EntryVolume { continue }
|
||||
if entry.Kind == EntryVolume {
|
||||
continue
|
||||
}
|
||||
member := members[entry.Path]
|
||||
if member == nil { return result, fmt.Errorf("verified archive is missing %q", entry.Path) }
|
||||
if member == nil {
|
||||
return result, fmt.Errorf("verified archive is missing %q", entry.Path)
|
||||
}
|
||||
stream, openErr := member.Open()
|
||||
if openErr != nil { return result, fmt.Errorf("open verified archive member %q: %w", entry.Path, openErr) }
|
||||
if openErr != nil {
|
||||
return result, fmt.Errorf("open verified archive member %q: %w", entry.Path, openErr)
|
||||
}
|
||||
mutated = true
|
||||
restoreErr := deps.restoreFile(ctx, installation, entry, stream)
|
||||
closeErr := stream.Close()
|
||||
if restoreErr != nil { return result, restoreErr }
|
||||
if closeErr != nil { return result, closeErr }
|
||||
if restoreErr != nil {
|
||||
return result, restoreErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return result, closeErr
|
||||
}
|
||||
}
|
||||
if err := deps.resetAuthenticationState(ctx, installation, deps.runner); err != nil {
|
||||
return result, fmt.Errorf("reset authentication state: %w", err)
|
||||
}
|
||||
if wasRunning {
|
||||
if err := composeStartAndVerify(ctx, installation, deps.runner); err != nil { return result, err }
|
||||
if err := composeStartAndVerify(ctx, installation, deps.runner); err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Restarted = true
|
||||
}
|
||||
for _, name := range []string{"health", "doctor", "pi", "workspace"} {
|
||||
check := deps.verify[name]
|
||||
if check == nil { return result, fmt.Errorf("restore verification %q is unavailable", name) }
|
||||
if err := check(ctx, installation, deps.runner); err != nil { return result, fmt.Errorf("restore verification %s: %w", name, err) }
|
||||
if check == nil {
|
||||
return result, fmt.Errorf("restore verification %q is unavailable", name)
|
||||
}
|
||||
if err := check(ctx, installation, deps.runner); err != nil {
|
||||
return result, fmt.Errorf("restore verification %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
result.Verified = true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 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",
|
||||
), nil)
|
||||
if err != nil {
|
||||
return dockerError("reset authentication state", result, err)
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return dockerError("reset authentication state", result, errors.New("Compose returned a nonzero exit status"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -59,6 +59,58 @@ func TestRestoreStoppedInstallationRunsCheckpointRestoreAndVerification(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreResetsAuthenticationStateBeforeRestart(t *testing.T) {
|
||||
installation := preflightTestInstallation(t)
|
||||
archive := restoreArchive(t)
|
||||
runner := newBackupRunner(installation, true)
|
||||
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.resetAuthenticationState = func(context.Context, config.Installation, archiveRunner) error {
|
||||
events = append(events, "reset-auth-state")
|
||||
return nil
|
||||
}
|
||||
for _, name := range []string{"health", "doctor", "pi", "workspace"} {
|
||||
name := name
|
||||
deps.verify[name] = func(context.Context, config.Installation, archiveRunner) error {
|
||||
events = append(events, name)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
result, err := restoreWithDependencies(context.Background(), installation, RestoreRequest{Archive: archive, Confirm: true}, deps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Restarted || !result.Verified {
|
||||
t.Fatalf("restore result = %#v", result)
|
||||
}
|
||||
if got, want := events, []string{"file:configuration/operator.env", "reset-auth-state", "health", "doctor", "pi", "workspace"}; !equalStrings(got, want) {
|
||||
t.Fatalf("restore events = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetAuthenticationStateCreatesOnlyPrivateEmptyStateDirectories(t *testing.T) {
|
||||
installation := preflightTestInstallation(t)
|
||||
runner := &authenticationStateResetRunner{}
|
||||
|
||||
if err := resetAuthenticationState(context.Background(), installation, runner); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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",
|
||||
} {
|
||||
if !strings.Contains(joined, required) {
|
||||
t.Fatalf("authentication state reset command omits %q: %#v", required, runner.args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestorePreflightFailureDoesNotMutateTarget(t *testing.T) {
|
||||
installation := preflightTestInstallation(t)
|
||||
runner := newBackupRunner(installation, true)
|
||||
@@ -213,6 +265,19 @@ type fakeRestoreLock struct {
|
||||
release func()
|
||||
}
|
||||
|
||||
type authenticationStateResetRunner struct{ args []string }
|
||||
|
||||
func (runner *authenticationStateResetRunner) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) {
|
||||
runner.args = append([]string(nil), args...)
|
||||
return compose.Result{}, nil
|
||||
}
|
||||
|
||||
func (runner *authenticationStateResetRunner) Stream(context.Context, []string, io.Reader, io.Writer) (compose.Result, error) {
|
||||
return compose.Result{}, errors.New("authentication state reset must not stream a volume archive")
|
||||
}
|
||||
|
||||
func (*authenticationStateResetRunner) SessionInventoryScope() string { return "mine" }
|
||||
|
||||
func (lock fakeRestoreLock) Release() error {
|
||||
if lock.release != nil {
|
||||
lock.release()
|
||||
@@ -248,6 +313,9 @@ func restoreTestDependencies(t *testing.T, runner archiveRunner) restoreDependen
|
||||
restoreVolume: func(context.Context, config.Installation, VolumeMetadata, io.Reader) error {
|
||||
return nil
|
||||
},
|
||||
resetAuthenticationState: func(context.Context, config.Installation, archiveRunner) error {
|
||||
return nil
|
||||
},
|
||||
verify: map[string]restoreVerify{
|
||||
"health": func(context.Context, config.Installation, archiveRunner) error { return nil },
|
||||
"doctor": func(context.Context, config.Installation, archiveRunner) error { return nil },
|
||||
|
||||
@@ -353,7 +353,7 @@ func filePermissions(installation config.Installation) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateVolumes checks the seven persistent volumes required by a ThothII installation.
|
||||
// ValidateVolumes checks the eight persistent volumes required by a local ThothII installation.
|
||||
func ValidateVolumes(rendered string) error {
|
||||
var document struct {
|
||||
Volumes map[string]json.RawMessage `json:"volumes"`
|
||||
@@ -361,7 +361,7 @@ func ValidateVolumes(rendered string) error {
|
||||
if err := json.Unmarshal([]byte(rendered), &document); err != nil {
|
||||
return errors.New("Compose returned invalid rendered configuration")
|
||||
}
|
||||
for _, name := range []string{"settings", "pi-state", "workspace-registry", "workspace-secrets", "sessions", "qdrant-data", "embedding-models"} {
|
||||
for _, name := range []string{"settings", "pi-state", "workspace-registry", "workspace-secrets", "sessions", "qdrant-data", "embedding-models", "auth-state"} {
|
||||
if _, exists := document.Volumes[name]; !exists {
|
||||
return fmt.Errorf("rendered Compose configuration is missing required volume %s", name)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,17 @@ func TestRunReportsUnavailableDockerWithoutReturningAnExecutionError(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateVolumesRequiresAuthState(t *testing.T) {
|
||||
legacy := `{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{}}}`
|
||||
if err := ValidateVolumes(legacy); err == nil || !strings.Contains(err.Error(), "auth-state") {
|
||||
t.Fatalf("ValidateVolumes() error = %v, want missing auth-state", err)
|
||||
}
|
||||
withAuthState := `{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{},"auth-state":{}}}`
|
||||
if err := ValidateVolumes(withAuthState); err != nil {
|
||||
t.Fatalf("ValidateVolumes() error = %v, want complete volume set", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Catches Docker availability short-circuiting a host file-permission failure.
|
||||
func TestRunChecksUnsafeFilesEvenWhenDockerIsUnavailable(t *testing.T) {
|
||||
installation := doctorInstallation(t, "")
|
||||
@@ -327,7 +338,7 @@ func assertChecklist(t *testing.T, report Report, want []string) {
|
||||
}
|
||||
}
|
||||
|
||||
const renderedConfig = `{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}`
|
||||
const renderedConfig = `{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{},"auth-state":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}`
|
||||
|
||||
const healthyServices = `[
|
||||
{"Service":"core","State":"running","Health":"healthy"},
|
||||
|
||||
@@ -65,6 +65,9 @@ func TestEnsureFilesCreatesDiscoverableConfigurationInProjectWithSpaces(t *testi
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(environment), "THT_AUTH_CONFIG_ROOT=") {
|
||||
t.Fatalf("generated environment does not declare the authentication config root: %s", environment)
|
||||
}
|
||||
for _, secretValue := range []string{"super-secret-value", "pi-secret-value", "private-key-value"} {
|
||||
if bytes.Contains(descriptor, []byte(secretValue)) || bytes.Contains(environment, []byte(secretValue)) {
|
||||
t.Fatalf("generated configuration contains a secret value %q", secretValue)
|
||||
|
||||
@@ -26,6 +26,16 @@ type Answers struct {
|
||||
GitCAFile string
|
||||
GitSSHKeyFile string
|
||||
GitKnownHostsFile string
|
||||
AuthMode string
|
||||
AuthPublicURL string
|
||||
AuthAdminUser string
|
||||
AuthAdminDisplayName string
|
||||
AuthPasswordFile string
|
||||
AuthIssuer string
|
||||
AuthClientID string
|
||||
AuthAuthentikBaseURL string
|
||||
AuthUserGroup string
|
||||
AuthAdminGroup string
|
||||
CreateSecretTemplates bool
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/authconfig"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/compose"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/doctor"
|
||||
@@ -53,6 +54,9 @@ func Run(ctx context.Context, runner compose.Runner, request Request, input io.R
|
||||
return Result{}, fmt.Errorf("setup generated configuration is invalid: %w", err)
|
||||
}
|
||||
result := Result{DescriptorPath: files.DescriptorPath, ProjectName: installation.ProjectName(), Configured: true}
|
||||
if err := configureAuthentication(ctx, installation, request, input, output); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if err := runCompose(ctx, runner, installation, "config", "--quiet"); err != nil {
|
||||
return Result{}, fmt.Errorf("setup Compose configuration: %w", err)
|
||||
}
|
||||
@@ -78,6 +82,76 @@ func Run(ctx context.Context, runner compose.Runner, request Request, input io.R
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func configureAuthentication(ctx context.Context, installation config.Installation, request Request, input io.Reader, output io.Writer) error {
|
||||
directory := installation.AuthenticationDirectory()
|
||||
if _, _, err := authconfig.Load(directory); err == nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(directory, "auth.yaml")); !errors.Is(err, os.ErrNotExist) {
|
||||
return errors.New("setup authentication configuration is invalid")
|
||||
}
|
||||
args, err := authenticationConfigureArgs(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exitCode := authconfig.Run(ctx, installation, args, input, io.Discard, output); exitCode != 0 {
|
||||
return errors.New("setup authentication configuration failed")
|
||||
}
|
||||
if _, _, err := authconfig.Load(directory); err != nil {
|
||||
return errors.New("setup authentication configuration is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func authenticationConfigureArgs(request Request) ([]string, error) {
|
||||
answers := request.Answers
|
||||
mode := answers.AuthMode
|
||||
if mode == "" && !request.NonInteractive {
|
||||
mode = "local"
|
||||
}
|
||||
if mode != "local" && mode != "oidc" {
|
||||
return nil, errors.New("setup requires --auth-mode local or oidc")
|
||||
}
|
||||
if mode == "local" && request.NonInteractive && (answers.AuthAdminUser == "" || answers.AuthAdminDisplayName == "" || answers.AuthPasswordFile == "") {
|
||||
return nil, errors.New("non-interactive local authentication requires --auth-admin-user, --auth-admin-display-name, and --auth-password-file")
|
||||
}
|
||||
publicURL := answers.AuthPublicURL
|
||||
if publicURL == "" && !request.NonInteractive && mode == "local" {
|
||||
publicURL = "http://127.0.0.1:8080"
|
||||
}
|
||||
if publicURL == "" {
|
||||
return nil, errors.New("setup requires --auth-public-url")
|
||||
}
|
||||
args := []string{"configure", "--mode", mode, "--public-url", publicURL}
|
||||
if mode == "local" {
|
||||
if answers.AuthAdminUser != "" {
|
||||
args = append(args, "--admin-user", answers.AuthAdminUser)
|
||||
}
|
||||
if answers.AuthAdminDisplayName != "" {
|
||||
args = append(args, "--admin-display-name", answers.AuthAdminDisplayName)
|
||||
}
|
||||
if answers.AuthPasswordFile != "" {
|
||||
args = append(args, "--password-file", answers.AuthPasswordFile)
|
||||
}
|
||||
return args, nil
|
||||
}
|
||||
for _, option := range []struct {
|
||||
name, value string
|
||||
}{
|
||||
{"--issuer", answers.AuthIssuer},
|
||||
{"--client-id", answers.AuthClientID},
|
||||
{"--authentik-base-url", answers.AuthAuthentikBaseURL},
|
||||
{"--user-group", answers.AuthUserGroup},
|
||||
{"--admin-group", answers.AuthAdminGroup},
|
||||
} {
|
||||
if option.value == "" {
|
||||
return nil, errors.New("OIDC authentication requires complete provider and group options")
|
||||
}
|
||||
args = append(args, option.name, option.value)
|
||||
}
|
||||
return args, nil
|
||||
}
|
||||
|
||||
func checkHost(ctx context.Context, runner compose.Runner, root string) error {
|
||||
checks := []struct {
|
||||
name string
|
||||
|
||||
@@ -11,7 +11,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/authconfig"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/compose"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/doctor"
|
||||
)
|
||||
|
||||
@@ -65,6 +67,55 @@ func TestRunConfigureOnlyStopsAfterRenderedConfiguration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConfiguresAndStaticallyValidatesLocalAuthBeforeComposeRender(t *testing.T) {
|
||||
projectRoot, request := setupRunFixture(t, true)
|
||||
passwordFile := filepath.Join(projectRoot, "initial-admin-password")
|
||||
if err := os.WriteFile(passwordFile, []byte("correct horse battery staple"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.NonInteractive = true
|
||||
request.Answers.AuthMode = "local"
|
||||
request.Answers.AuthPublicURL = "http://127.0.0.1:8080"
|
||||
request.Answers.AuthAdminUser = "admin"
|
||||
request.Answers.AuthAdminDisplayName = "Initial Admin"
|
||||
request.Answers.AuthPasswordFile = passwordFile
|
||||
runner := &setupRunner{}
|
||||
|
||||
if _, err := Run(context.Background(), runner, request, strings.NewReader(""), io.Discard); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
installationPath := filepath.Join(projectRoot, "deploy", "ci", "thothii-installation.yaml")
|
||||
installation, err := config.Load(installationPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
configuration, registry, err := authconfig.Load(installation.AuthenticationDirectory())
|
||||
if err != nil {
|
||||
t.Fatalf("setup did not create a statically valid authentication configuration: %v", err)
|
||||
}
|
||||
if configuration.Mode != "local" || len(registry.Users) != 1 || registry.Users[0].Username != "admin" {
|
||||
t.Fatalf("authentication configuration = %#v registry = %#v", configuration, registry)
|
||||
}
|
||||
assertSetupStages(t, runner, "docker engine", "docker compose", "architecture", "compose config")
|
||||
}
|
||||
|
||||
func TestRunRejectsIncompleteNonInteractiveLocalAuthenticationBeforeComposeRender(t *testing.T) {
|
||||
_, request := setupRunFixture(t, true)
|
||||
request.NonInteractive = true
|
||||
request.Answers.AuthMode = "local"
|
||||
request.Answers.AuthPublicURL = ""
|
||||
request.Answers.AuthAdminUser = ""
|
||||
request.Answers.AuthAdminDisplayName = ""
|
||||
request.Answers.AuthPasswordFile = ""
|
||||
runner := &setupRunner{}
|
||||
|
||||
_, err := Run(context.Background(), runner, request, strings.NewReader(""), io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "non-interactive local authentication") {
|
||||
t.Fatalf("Run() error = %v, want non-interactive local authentication guidance", err)
|
||||
}
|
||||
assertSetupStages(t, runner, "docker engine", "docker compose", "architecture")
|
||||
}
|
||||
|
||||
func TestRunPropagatesPreflightFailureBeforeWritingConfiguration(t *testing.T) {
|
||||
projectRoot, request := setupRunFixture(t, false)
|
||||
runner := &setupRunner{failureAt: "docker engine"}
|
||||
@@ -137,7 +188,7 @@ func TestRunPiDoctorFailurePreservesCauseAndOffersRecovery(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequireVolumesRequiresEveryInstallationVolume(t *testing.T) {
|
||||
all := []string{"settings", "pi-state", "workspace-registry", "workspace-secrets", "sessions", "qdrant-data", "embedding-models"}
|
||||
all := []string{"settings", "pi-state", "workspace-registry", "workspace-secrets", "sessions", "qdrant-data", "embedding-models", "auth-state"}
|
||||
for _, missing := range all {
|
||||
t.Run("missing "+missing, func(t *testing.T) {
|
||||
volumes := make([]string, 0, len(all)-1)
|
||||
@@ -301,7 +352,7 @@ func setupStage(args []string) (string, compose.Result) {
|
||||
}
|
||||
}
|
||||
|
||||
const renderedSetupConfig = `{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}`
|
||||
const renderedSetupConfig = `{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{},"auth-state":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}`
|
||||
|
||||
func renderedConfigForVolumes(volumes ...string) string {
|
||||
entries := make([]string, 0, len(volumes))
|
||||
@@ -338,12 +389,18 @@ func setupRunFixture(t *testing.T, configureOnly bool) (string, Request) {
|
||||
if err := os.MkdirAll(secrets, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
passwordFile := filepath.Join(secrets, "initial-admin-password")
|
||||
if err := os.WriteFile(passwordFile, []byte("fixture authentication password"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return root, Request{
|
||||
ProjectRoot: root, InstallationID: "ci", Profile: "local", ConfigureOnly: configureOnly, NonInteractive: true,
|
||||
Answers: Answers{
|
||||
WorkspaceRemote: "https://git.example.invalid/thothii-workspaces.git", WorkspaceBranch: "main", WorkspaceAccess: "https",
|
||||
SecretsFile: filepath.Join(secrets, "thothii.secrets"), PiAuthFile: filepath.Join(secrets, "pi-auth.json"),
|
||||
GitCredentialsFile: filepath.Join(secrets, "git-credentials"), GitCAFile: filepath.Join(secrets, "git-ca.pem"),
|
||||
AuthMode: "local", AuthPublicURL: "http://127.0.0.1:8080", AuthAdminUser: "admin",
|
||||
AuthAdminDisplayName: "Initial Admin", AuthPasswordFile: passwordFile,
|
||||
CreateSecretTemplates: true,
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user