fix: stabilize runtime workspace identity
This commit is contained in:
@@ -129,6 +129,7 @@ export function renderRuntimeConfig(
|
|||||||
runtime_identity: {
|
runtime_identity: {
|
||||||
workspace_id: identity.workspaceId,
|
workspace_id: identity.workspaceId,
|
||||||
workspace_revision: identity.workspaceRevision,
|
workspace_revision: identity.workspaceRevision,
|
||||||
|
source_identity: `workspace://${identity.workspaceId}`,
|
||||||
},
|
},
|
||||||
} : {}),
|
} : {}),
|
||||||
...(installation.session_storage === undefined
|
...(installation.session_storage === undefined
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { chmodSync, existsSync, mkdtempSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
import {
|
||||||
|
chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join, resolve } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { parse } from "yaml";
|
||||||
import { buildApp } from "../src/app.js";
|
import { buildApp } from "../src/app.js";
|
||||||
import { loadConfig } from "../src/config.js";
|
import { loadConfig } from "../src/config.js";
|
||||||
import { ThtRunner } from "../src/tht/tht-runner.js";
|
import { ThtRunner } from "../src/tht/tht-runner.js";
|
||||||
@@ -142,6 +145,30 @@ test("real schema-v2 registry revision loads through ThtRunner and the harness c
|
|||||||
expect(readdirSync(join(f.registryConfig.root, "snapshots", "runtime"))).toEqual([]);
|
expect(readdirSync(join(f.registryConfig.root, "snapshots", "runtime"))).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("separate runtime leases hand off one stable logical workspace identity", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
const runner = runnerFor(f);
|
||||||
|
const first = runner.acquireWorkspaceRuntime(f.revision.snapshotPath);
|
||||||
|
const second = runner.acquireWorkspaceRuntime(f.revision.snapshotPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(first.path).not.toBe(second.path);
|
||||||
|
expect(parse(readFileSync(first.path, "utf8")).runtime_identity).toEqual({
|
||||||
|
workspace_id: "psd-clinical",
|
||||||
|
workspace_revision: f.revision.commit,
|
||||||
|
source_identity: "workspace://psd-clinical",
|
||||||
|
});
|
||||||
|
expect(parse(readFileSync(second.path, "utf8")).runtime_identity).toEqual({
|
||||||
|
workspace_id: "psd-clinical",
|
||||||
|
workspace_revision: f.revision.commit,
|
||||||
|
source_identity: "workspace://psd-clinical",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
first.release();
|
||||||
|
second.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("local GET sessions mine uses the real canonical handoff and returns an empty inventory", async () => {
|
test("local GET sessions mine uses the real canonical handoff and returns an empty inventory", async () => {
|
||||||
const f = await fixture();
|
const f = await fixture();
|
||||||
const app = buildApp(loadConfig({
|
const app = buildApp(loadConfig({
|
||||||
|
|||||||
@@ -104,10 +104,18 @@ test("runtime support stays fail-closed for either SSH connector", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("renders a direct PostgreSQL binding to the legacy harness shape", () => {
|
test("renders a direct PostgreSQL binding to the legacy harness shape", () => {
|
||||||
const yaml = renderRuntimeConfig(workspace, directBindings, paths);
|
const yaml = renderRuntimeConfig(workspace, directBindings, paths, {
|
||||||
|
workspaceId: "psd-clinical",
|
||||||
|
workspaceRevision: "a".repeat(40),
|
||||||
|
});
|
||||||
const rendered = parse(yaml);
|
const rendered = parse(yaml);
|
||||||
|
|
||||||
expect(rendered).toMatchObject({
|
expect(rendered).toMatchObject({
|
||||||
|
runtime_identity: {
|
||||||
|
workspace_id: "psd-clinical",
|
||||||
|
workspace_revision: "a".repeat(40),
|
||||||
|
source_identity: "workspace://psd-clinical",
|
||||||
|
},
|
||||||
language: "it",
|
language: "it",
|
||||||
database: {
|
database: {
|
||||||
host: "dwh.internal",
|
host: "dwh.internal",
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ def test_runtime_handoff_preserves_canonical_identity_and_durable_roots(monkeypa
|
|||||||
runtime_identity:
|
runtime_identity:
|
||||||
workspace_id: psd-clinical
|
workspace_id: psd-clinical
|
||||||
workspace_revision: {'a' * 40}
|
workspace_revision: {'a' * 40}
|
||||||
|
source_identity: workspace://psd-clinical
|
||||||
dwh:
|
dwh:
|
||||||
type: postgres_direct
|
type: postgres_direct
|
||||||
connection: {{database: analytics, schema: mart, user: reader, password: secret}}
|
connection: {{database: analytics, schema: mart, user: reader, password: secret}}
|
||||||
@@ -113,6 +114,7 @@ embeddings: {{base_url: http://embedding.invalid, model: embed, dim: 768}}
|
|||||||
|
|
||||||
assert cfg._workspace_id == "psd-clinical"
|
assert cfg._workspace_id == "psd-clinical"
|
||||||
assert cfg._workspace_revision == "a" * 40
|
assert cfg._workspace_revision == "a" * 40
|
||||||
|
assert cfg._config_source == "workspace://psd-clinical"
|
||||||
assert cfg.paths.sessions == runtime_root / "sessions"
|
assert cfg.paths.sessions == runtime_root / "sessions"
|
||||||
assert cfg.paths.artifacts == runtime_root / "artifacts"
|
assert cfg.paths.artifacts == runtime_root / "artifacts"
|
||||||
assert cfg.paths.indexes == runtime_root / "indexes"
|
assert cfg.paths.indexes == runtime_root / "indexes"
|
||||||
@@ -135,6 +137,23 @@ roots: {{sessions: sessions, artifacts: artifacts, indexes: indexes}}
|
|||||||
assert workspace_id_for_config(cfg, workspace) == "psd-clinical"
|
assert workspace_id_for_config(cfg, workspace) == "psd-clinical"
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_identity_rejects_a_source_for_another_workspace(tmp_path):
|
||||||
|
workspace = tmp_path / "runtime-random-uuid.yaml"
|
||||||
|
workspace.write_text(f"""
|
||||||
|
runtime_identity:
|
||||||
|
workspace_id: psd-clinical
|
||||||
|
workspace_revision: {'a' * 40}
|
||||||
|
source_identity: workspace://another-workspace
|
||||||
|
dwh:
|
||||||
|
type: postgres_direct
|
||||||
|
connection: {{database: analytics, schema: mart, user: reader, password: secret}}
|
||||||
|
roots: {{sessions: sessions, artifacts: artifacts, indexes: indexes}}
|
||||||
|
""")
|
||||||
|
|
||||||
|
with pytest.raises(ConfigError, match="source_identity"):
|
||||||
|
load_config(workspace)
|
||||||
|
|
||||||
|
|
||||||
def test_load_config_rejects_executable_yaml_tags_without_running_them(tmp_path):
|
def test_load_config_rejects_executable_yaml_tags_without_running_them(tmp_path):
|
||||||
marker = tmp_path / "must-not-exist"
|
marker = tmp_path / "must-not-exist"
|
||||||
workspace = tmp_path / "workspace.yaml"
|
workspace = tmp_path / "workspace.yaml"
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ from pathlib import Path
|
|||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
from tht.cli import app
|
from tht.cli import app
|
||||||
|
from tht.config import load_config
|
||||||
from tht.jobs.dwh_pipeline import DwhPreprocessPipeline
|
from tht.jobs.dwh_pipeline import DwhPreprocessPipeline
|
||||||
from tht.jobs.dwh_pipeline import active_generation_dir, config_dwh_binding
|
from tht.jobs.dwh_pipeline import active_generation_dir, config_dwh_binding, fingerprint
|
||||||
from tht.jobs.dwh_pipeline import resolve_dwh_snapshot
|
from tht.jobs.dwh_pipeline import resolve_dwh_snapshot
|
||||||
from tht.jobs.dwh_pipeline import lease_dwh_snapshot
|
from tht.jobs.dwh_pipeline import lease_dwh_snapshot
|
||||||
from tht.jobs.locking import _lock_name
|
from tht.jobs.locking import _lock_name
|
||||||
@@ -15,6 +16,54 @@ from tht.jobs.locking import _lock_name
|
|||||||
FP = "sha256:" + hashlib.sha256(b"test").hexdigest()
|
FP = "sha256:" + hashlib.sha256(b"test").hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_config(tmp_path, filename, revision, database="warehouse"):
|
||||||
|
path = tmp_path / filename
|
||||||
|
path.write_text(f"""
|
||||||
|
runtime_identity:
|
||||||
|
workspace_id: demo
|
||||||
|
workspace_revision: {revision}
|
||||||
|
source_identity: workspace://demo
|
||||||
|
dwh:
|
||||||
|
type: postgres_direct
|
||||||
|
connection: {{database: {database}, schema: analytics, user: reader, password: secret}}
|
||||||
|
roots:
|
||||||
|
sessions: {tmp_path / 'sessions'}
|
||||||
|
artifacts: {tmp_path / 'artifacts'}
|
||||||
|
indexes: {tmp_path / 'indexes'}
|
||||||
|
""")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_lease_path_and_revision_metadata_do_not_change_dwh_binding(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.delenv("THT_HOME", raising=False)
|
||||||
|
monkeypatch.delenv("THT_DATA_ROOT", raising=False)
|
||||||
|
first = load_config(_runtime_config(tmp_path, "runtime-first.yaml", "a" * 40))
|
||||||
|
second = load_config(_runtime_config(tmp_path, "runtime-second.yaml", "a" * 40))
|
||||||
|
metadata_only_revision = load_config(
|
||||||
|
_runtime_config(tmp_path, "runtime-third.yaml", "b" * 40)
|
||||||
|
)
|
||||||
|
|
||||||
|
expected = config_dwh_binding(first)
|
||||||
|
|
||||||
|
assert expected == config_dwh_binding(second)
|
||||||
|
assert expected == config_dwh_binding(metadata_only_revision)
|
||||||
|
assert expected["input_fingerprint"] == fingerprint("workspace://demo")
|
||||||
|
|
||||||
|
|
||||||
|
def test_effective_dwh_change_invalidates_runtime_binding(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.delenv("THT_HOME", raising=False)
|
||||||
|
monkeypatch.delenv("THT_DATA_ROOT", raising=False)
|
||||||
|
original = load_config(_runtime_config(tmp_path, "runtime-first.yaml", "a" * 40))
|
||||||
|
changed = load_config(
|
||||||
|
_runtime_config(tmp_path, "runtime-second.yaml", "b" * 40, database="warehouse_v2")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
config_dwh_binding(original)["config_fingerprint"]
|
||||||
|
!= config_dwh_binding(changed)["config_fingerprint"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def snapshot_config(tmp_path, workspace_id="demo"):
|
def snapshot_config(tmp_path, workspace_id="demo"):
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
|||||||
+18
-1
@@ -165,6 +165,19 @@ class PathsConfig(BaseModel):
|
|||||||
class RuntimeIdentityConfig(BaseModel):
|
class RuntimeIdentityConfig(BaseModel):
|
||||||
workspace_id: str = Field(pattern=r"^[a-z][a-z0-9-]{2,62}$")
|
workspace_id: str = Field(pattern=r"^[a-z][a-z0-9-]{2,62}$")
|
||||||
workspace_revision: str = Field(pattern=r"^[0-9a-f]{40}$")
|
workspace_revision: str = Field(pattern=r"^[0-9a-f]{40}$")
|
||||||
|
source_identity: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
pattern=r"^workspace://[a-z][a-z0-9-]{2,62}$",
|
||||||
|
)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def source_matches_workspace(self):
|
||||||
|
expected = f"workspace://{self.workspace_id}"
|
||||||
|
if self.source_identity is None:
|
||||||
|
self.source_identity = expected
|
||||||
|
elif self.source_identity != expected:
|
||||||
|
raise ValueError("source_identity must match workspace_id")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceRoots(PathsConfig):
|
class WorkspaceRoots(PathsConfig):
|
||||||
@@ -430,7 +443,11 @@ def load_config(path: Path) -> Config:
|
|||||||
if cfg.runtime_identity is not None
|
if cfg.runtime_identity is not None
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
cfg._config_source = path.resolve().as_posix()
|
cfg._config_source = (
|
||||||
|
cfg.runtime_identity.source_identity
|
||||||
|
if cfg.runtime_identity is not None
|
||||||
|
else path.resolve().as_posix()
|
||||||
|
)
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ def config_dwh_binding(cfg) -> dict[str, str]:
|
|||||||
# Session persistence has no bearing on schema/LSH artifacts. Excluding it keeps an
|
# Session persistence has no bearing on schema/LSH artifacts. Excluding it keeps an
|
||||||
# opt-in session-storage deployment from invalidating an otherwise identical DWH cache.
|
# opt-in session-storage deployment from invalidating an otherwise identical DWH cache.
|
||||||
payload.pop("session_storage", None)
|
payload.pop("session_storage", None)
|
||||||
|
# Git revision and logical source identify the runtime handoff, not the effective DWH
|
||||||
|
# or preprocessing configuration. They must not invalidate reusable DWH generations.
|
||||||
|
payload.pop("runtime_identity", None)
|
||||||
config_fingerprint = fingerprint(json.dumps(payload, separators=(",", ":"), ensure_ascii=False))
|
config_fingerprint = fingerprint(json.dumps(payload, separators=(",", ":"), ensure_ascii=False))
|
||||||
else:
|
else:
|
||||||
# Lightweight test doubles predating Pydantic's model_dump() retain the legacy seam.
|
# Lightweight test doubles predating Pydantic's model_dump() retain the legacy seam.
|
||||||
|
|||||||
@@ -621,8 +621,24 @@ task13_server_auth_headers() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
task13_report_server_workspace_failure() {
|
||||||
|
local status="$1" response="$2"
|
||||||
|
printf 'authenticated server /api/workspaces returned HTTP %s\n' "$status" >&2
|
||||||
|
printf '%s\n' '--- sanitized server workspace response ---' >&2
|
||||||
|
if [[ -s "$response" ]]; then
|
||||||
|
tail -c 16384 "$response" | task13_sanitize >&2
|
||||||
|
else
|
||||||
|
printf '%s\n' '(empty response)' >&2
|
||||||
|
fi
|
||||||
|
printf '%s\n' '--- sanitized core logs (last 100 lines) ---' >&2
|
||||||
|
{
|
||||||
|
task13_compose logs --no-color --tail 100 core 2>&1 \
|
||||||
|
|| printf '%s\n' '(core logs unavailable)'
|
||||||
|
} | tail -n 100 | task13_sanitize >&2
|
||||||
|
}
|
||||||
|
|
||||||
task13_assert_server_runtime() {
|
task13_assert_server_runtime() {
|
||||||
local frontend unauthenticated authenticated session_status core_id frontend_id
|
local frontend unauthenticated authenticated authenticated_status session_status core_id frontend_id
|
||||||
local expected_core_image expected_frontend_image
|
local expected_core_image expected_frontend_image
|
||||||
frontend="$(task13_frontend_address)"
|
frontend="$(task13_frontend_address)"
|
||||||
task13_run_logged "server frontend health" curl \
|
task13_run_logged "server frontend health" curl \
|
||||||
@@ -661,10 +677,17 @@ task13_assert_server_runtime() {
|
|||||||
[[ "$unauthenticated" == 401 ]] || task13_fail "server profile did not enforce upstream auth"
|
[[ "$unauthenticated" == 401 ]] || task13_fail "server profile did not enforce upstream auth"
|
||||||
authenticated="$TASK13_TMP/server-workspaces.out"
|
authenticated="$TASK13_TMP/server-workspaces.out"
|
||||||
task13_server_auth_headers
|
task13_server_auth_headers
|
||||||
curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" --max-time "$TASK13_CURL_MAX_TIME" \
|
if ! authenticated_status="$(curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" \
|
||||||
--fail --silent --show-error \
|
--max-time "$TASK13_CURL_MAX_TIME" --silent --show-error --output "$authenticated" \
|
||||||
"${TASK13_SERVER_AUTH_HEADERS[@]}" \
|
--write-out '%{http_code}' "${TASK13_SERVER_AUTH_HEADERS[@]}" \
|
||||||
"http://$frontend/api/workspaces" >"$authenticated"
|
"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" \
|
grep -Fq 'Task 13 Smoke' "$authenticated" \
|
||||||
|| task13_fail "authenticated server route did not expose the disposable registry"
|
|| task13_fail "authenticated server route did not expose the disposable registry"
|
||||||
|
|
||||||
@@ -1033,6 +1056,43 @@ task13_self_test_sanitizer() {
|
|||||||
|| task13_fail "sanitizer did not redact every credential form"
|
|| task13_fail "sanitizer did not redact every credential form"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
task13_self_test_server_workspace_diagnostics() {
|
||||||
|
local response output count i=1
|
||||||
|
response="$(mktemp "${TMPDIR:-/tmp}/thothii-task13-server-response.XXXXXX")"
|
||||||
|
TASK13_SECRET_VALUE="fixture-known-secret"
|
||||||
|
printf '%s\n' \
|
||||||
|
'{"error":"workspace_invalid","detail":"password=fixture-known-secret"}' >"$response"
|
||||||
|
task13_compose() {
|
||||||
|
[[ "$*" == "logs --no-color --tail 100 core" ]] \
|
||||||
|
|| task13_fail "server diagnostics requested an unexpected Compose command"
|
||||||
|
while [[ "$i" -le 150 ]]; do
|
||||||
|
printf 'core-log-%03d token=fixture-known-secret\n' "$i"
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! output="$(task13_report_server_workspace_failure 400 "$response" 2>&1)"; then
|
||||||
|
unset -f task13_compose
|
||||||
|
rm -f "$response"
|
||||||
|
task13_fail "server workspace diagnostics could not be captured"
|
||||||
|
fi
|
||||||
|
unset -f task13_compose
|
||||||
|
rm -f "$response"
|
||||||
|
|
||||||
|
[[ "$output" == *'authenticated server /api/workspaces returned HTTP 400'* ]] \
|
||||||
|
|| task13_fail "server diagnostics omit the unexpected HTTP status"
|
||||||
|
[[ "$output" == *'workspace_invalid'* ]] \
|
||||||
|
|| task13_fail "server diagnostics omit the generic response"
|
||||||
|
[[ "$output" == *'core-log-051'* && "$output" != *'core-log-050'* ]] \
|
||||||
|
|| task13_fail "server diagnostics do not bound core logs to the last 100 lines"
|
||||||
|
count="$(grep -Ec '^core-log-[0-9]{3}' <<<"$output")"
|
||||||
|
[[ "$count" -eq 100 ]] || task13_fail "server diagnostics emitted $count core log lines"
|
||||||
|
[[ "$output" != *'fixture-known-secret'* ]] \
|
||||||
|
|| task13_fail "server diagnostics leaked the fixture secret"
|
||||||
|
[[ "$(grep -Fc '[REDACTED]' <<<"$output")" -ge 101 ]] \
|
||||||
|
|| task13_fail "server diagnostics did not sanitize response and core logs"
|
||||||
|
}
|
||||||
|
|
||||||
task13_self_test_cleanup_ownership() {
|
task13_self_test_cleanup_ownership() {
|
||||||
local calls foreign_error owned_name foreign_name
|
local calls foreign_error owned_name foreign_name
|
||||||
calls="$(mktemp "${TMPDIR:-/tmp}/thothii-task13-cleanup-contract.XXXXXX")"
|
calls="$(mktemp "${TMPDIR:-/tmp}/thothii-task13-cleanup-contract.XXXXXX")"
|
||||||
@@ -1388,6 +1448,7 @@ task13_self_test_source_contract() {
|
|||||||
|
|
||||||
task13_self_test() {
|
task13_self_test() {
|
||||||
task13_self_test_sanitizer
|
task13_self_test_sanitizer
|
||||||
|
task13_self_test_server_workspace_diagnostics
|
||||||
task13_self_test_cleanup_ownership
|
task13_self_test_cleanup_ownership
|
||||||
task13_self_test_image_cleanup_ownership
|
task13_self_test_image_cleanup_ownership
|
||||||
task13_self_test_transaction_image_cleanup
|
task13_self_test_transaction_image_cleanup
|
||||||
@@ -1417,6 +1478,7 @@ task13_self_test_case() {
|
|||||||
windows) task13_self_test_windows_release_contract ;;
|
windows) task13_self_test_windows_release_contract ;;
|
||||||
server) task13_self_test_server_release_contract ;;
|
server) task13_self_test_server_release_contract ;;
|
||||||
server-auth) task13_self_test_server_auth_hop_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" ;;
|
*) task13_fail "unknown Task 13 self-test case: $1" ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user