807 lines
27 KiB
Python
807 lines
27 KiB
Python
#!/usr/bin/env python3
|
|
"""Run a networkless PostgreSQL least-privilege lifecycle for Leo.
|
|
|
|
The canary provisions the real guarded proposal/apply prerequisites and the
|
|
real Observatory read role in a disposable PostgreSQL container. It then
|
|
connects as the read principal, proves required retrieval, attempts forbidden
|
|
database and role operations, compares catalog/data fingerprints, and verifies
|
|
that the container and temporary work directory were removed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import secrets
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
APPLY_PREREQS_SQL = ROOT / "scripts" / "kb_apply_prereqs.sql"
|
|
READ_ROLE_SQL = ROOT / "ops" / "observatory_read_role.sql"
|
|
DATABASE = "teleo_canonical"
|
|
IAM_USER = "sa-observatory-read-adapter@teleo-501523.iam"
|
|
PARTICIPANT_HANDLE = "m3taversal"
|
|
DEFAULT_IMAGE = "postgres:16-alpine"
|
|
CANARY_LABEL = "leo-negative-permissions"
|
|
|
|
BOOTSTRAP_SQL = r"""
|
|
create role kb_gate_owner nologin inherit;
|
|
create role kb_review login inherit;
|
|
create role kb_apply login inherit;
|
|
|
|
create schema kb_stage;
|
|
|
|
create table public.agents (
|
|
id uuid primary key,
|
|
handle text not null unique,
|
|
kind text not null
|
|
);
|
|
create table public.strategies (
|
|
id uuid primary key,
|
|
agent_id uuid not null,
|
|
active boolean not null default true
|
|
);
|
|
create table public.strategy_nodes (id uuid primary key);
|
|
create table public.claim_evidence (
|
|
id uuid primary key,
|
|
claim_id uuid not null,
|
|
source_id uuid not null,
|
|
role text not null
|
|
);
|
|
create table public.claim_edges (id uuid primary key);
|
|
create table public.claims (
|
|
id uuid primary key,
|
|
text text not null
|
|
);
|
|
create table public.sources (
|
|
id uuid primary key,
|
|
hash text not null
|
|
);
|
|
create table public.reasoning_tools (id uuid primary key);
|
|
create table public.private_notes (
|
|
id uuid primary key,
|
|
note text not null
|
|
);
|
|
|
|
insert into public.agents (id, handle, kind)
|
|
values ('11111111-1111-1111-1111-111111111111', '__PARTICIPANT_HANDLE__', 'human');
|
|
|
|
create table kb_stage.kb_proposals (
|
|
id uuid primary key,
|
|
proposal_type text not null,
|
|
status text not null,
|
|
payload jsonb not null,
|
|
reviewed_by_handle text,
|
|
reviewed_by_agent_id uuid,
|
|
reviewed_at timestamptz,
|
|
review_note text,
|
|
applied_by_handle text,
|
|
applied_by_agent_id uuid,
|
|
applied_at timestamptz,
|
|
updated_at timestamptz not null default now()
|
|
);
|
|
create table kb_stage.kb_review_principals (
|
|
db_role name primary key,
|
|
reviewed_by_handle text not null,
|
|
reviewed_by_agent_id uuid not null references public.agents(id),
|
|
active boolean not null default true,
|
|
created_at timestamptz not null default now()
|
|
);
|
|
create table kb_stage.kb_proposal_approvals (
|
|
proposal_id uuid primary key references kb_stage.kb_proposals(id),
|
|
proposal_type text not null,
|
|
payload jsonb not null,
|
|
reviewed_by_handle text not null,
|
|
reviewed_by_agent_id uuid not null references public.agents(id),
|
|
reviewed_by_db_role name not null,
|
|
reviewed_at timestamptz not null,
|
|
review_note text not null
|
|
);
|
|
|
|
insert into public.claims (id, text)
|
|
values (
|
|
'22222222-2222-2222-2222-222222222222',
|
|
'Canonical Leo knowledge remains review-gated.'
|
|
);
|
|
insert into public.sources (id, hash)
|
|
values ('33333333-3333-3333-3333-333333333333', 'fixture-source-hash');
|
|
insert into public.private_notes (id, note)
|
|
values ('44444444-4444-4444-4444-444444444444', 'not allowlisted');
|
|
insert into kb_stage.kb_proposals (
|
|
id, proposal_type, status, payload
|
|
) values (
|
|
'55555555-5555-5555-5555-555555555555',
|
|
'revise_strategy',
|
|
'pending_review',
|
|
'{"apply_payload":{"agent_id":"11111111-1111-1111-1111-111111111111"}}'
|
|
);
|
|
"""
|
|
|
|
|
|
class CanaryError(RuntimeError):
|
|
"""A setup or verification step failed."""
|
|
|
|
|
|
def utc_now() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def canonical_sha256(value: Any) -> str:
|
|
encoded = json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
|
|
return hashlib.sha256(encoded).hexdigest()
|
|
|
|
|
|
def sql_literal(value: str) -> str:
|
|
return "'" + value.replace("'", "''") + "'"
|
|
|
|
|
|
def sql_identifier(value: str) -> str:
|
|
return '"' + value.replace('"', '""') + '"'
|
|
|
|
|
|
def run(
|
|
command: list[str],
|
|
*,
|
|
input_text: str | None = None,
|
|
check: bool = True,
|
|
timeout: int = 60,
|
|
) -> subprocess.CompletedProcess[str]:
|
|
completed = subprocess.run(
|
|
command,
|
|
input=input_text,
|
|
text=True,
|
|
capture_output=True,
|
|
check=False,
|
|
timeout=timeout,
|
|
)
|
|
if check and completed.returncode != 0:
|
|
stderr = completed.stderr.strip()[-2000:]
|
|
raise CanaryError(f"command failed with exit {completed.returncode}: {stderr}")
|
|
return completed
|
|
|
|
|
|
def admin_psql(container: str, sql: str, *, exec_env: dict[str, str] | None = None) -> str:
|
|
command = ["docker", "exec"]
|
|
for name, value in sorted((exec_env or {}).items()):
|
|
command.extend(["-e", f"{name}={value}"])
|
|
command.extend(
|
|
[
|
|
"-i",
|
|
container,
|
|
"psql",
|
|
"-X",
|
|
"--set",
|
|
"ON_ERROR_STOP=1",
|
|
"-U",
|
|
"postgres",
|
|
"-d",
|
|
DATABASE,
|
|
"-At",
|
|
]
|
|
)
|
|
return run(command, input_text=sql).stdout
|
|
|
|
|
|
def user_psql(
|
|
container: str,
|
|
password: str,
|
|
sql_commands: list[str],
|
|
) -> subprocess.CompletedProcess[str]:
|
|
command = [
|
|
"docker",
|
|
"exec",
|
|
"-e",
|
|
f"PGPASSWORD={password}",
|
|
container,
|
|
"psql",
|
|
"-X",
|
|
"--set",
|
|
"ON_ERROR_STOP=1",
|
|
"--set",
|
|
"VERBOSITY=verbose",
|
|
"-h",
|
|
"127.0.0.1",
|
|
"-U",
|
|
IAM_USER,
|
|
"-d",
|
|
DATABASE,
|
|
"-At",
|
|
]
|
|
for sql in sql_commands:
|
|
command.extend(["-c", sql])
|
|
return run(command, check=False)
|
|
|
|
|
|
def parse_last_json(output: str) -> dict[str, Any]:
|
|
for line in reversed(output.splitlines()):
|
|
line = line.strip()
|
|
if not line.startswith("{"):
|
|
continue
|
|
parsed = json.loads(line)
|
|
if isinstance(parsed, dict):
|
|
return parsed
|
|
raise CanaryError("expected a JSON object in psql output")
|
|
|
|
|
|
def sanitized_denial(completed: subprocess.CompletedProcess[str]) -> str:
|
|
lines = [line.strip() for line in completed.stderr.splitlines() if line.strip()]
|
|
for line in lines:
|
|
if "ERROR:" in line or "FATAL:" in line:
|
|
return line[:500]
|
|
return (lines[-1] if lines else "no database denial was returned")[:500]
|
|
|
|
|
|
def allowed_operation(
|
|
operation_id: str,
|
|
category: str,
|
|
completed: subprocess.CompletedProcess[str],
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"id": operation_id,
|
|
"category": category,
|
|
"expected": "allowed",
|
|
"passed": completed.returncode == 0,
|
|
"exit_code": completed.returncode,
|
|
"readback": [line for line in completed.stdout.splitlines() if line],
|
|
"error": sanitized_denial(completed) if completed.returncode != 0 else None,
|
|
}
|
|
|
|
|
|
def denied_operation(
|
|
operation_id: str,
|
|
category: str,
|
|
completed: subprocess.CompletedProcess[str],
|
|
expected_fragments: tuple[str, ...],
|
|
) -> dict[str, Any]:
|
|
denial = sanitized_denial(completed)
|
|
lowered = denial.lower()
|
|
matched = next((fragment for fragment in expected_fragments if fragment.lower() in lowered), None)
|
|
return {
|
|
"id": operation_id,
|
|
"category": category,
|
|
"expected": "denied",
|
|
"passed": completed.returncode != 0 and matched is not None,
|
|
"exit_code": completed.returncode,
|
|
"matched_denial": matched,
|
|
"denial": denial,
|
|
}
|
|
|
|
|
|
def fingerprint(container: str) -> dict[str, Any]:
|
|
principal = sql_literal(IAM_USER)
|
|
query = f"""
|
|
select jsonb_build_object(
|
|
'database', current_database(),
|
|
'participant_handle', (
|
|
select handle
|
|
from public.agents
|
|
where id = '11111111-1111-1111-1111-111111111111'
|
|
),
|
|
'principal', (
|
|
select jsonb_build_object(
|
|
'rolname', rolname,
|
|
'rolinherit', rolinherit,
|
|
'rolsuper', rolsuper,
|
|
'rolcreatedb', rolcreatedb,
|
|
'rolcreaterole', rolcreaterole,
|
|
'rolreplication', rolreplication,
|
|
'rolbypassrls', rolbypassrls
|
|
)
|
|
from pg_catalog.pg_roles
|
|
where rolname = {principal}
|
|
),
|
|
'memberships', coalesce((
|
|
select jsonb_agg(
|
|
jsonb_build_object(
|
|
'role', granted.rolname,
|
|
'admin_option', membership.admin_option
|
|
) order by granted.rolname
|
|
)
|
|
from pg_catalog.pg_auth_members membership
|
|
join pg_catalog.pg_roles granted on granted.oid = membership.roleid
|
|
join pg_catalog.pg_roles member on member.oid = membership.member
|
|
where member.rolname = {principal}
|
|
), '[]'::jsonb),
|
|
'role_database_settings', coalesce((
|
|
select jsonb_agg(setting order by setting)
|
|
from pg_catalog.pg_db_role_setting role_setting
|
|
cross join lateral unnest(role_setting.setconfig) setting
|
|
where role_setting.setrole = (select oid from pg_catalog.pg_roles where rolname = {principal})
|
|
and role_setting.setdatabase = (select oid from pg_catalog.pg_database where datname = current_database())
|
|
), '[]'::jsonb),
|
|
'effective_privileges', jsonb_build_object(
|
|
'claims_select', pg_catalog.has_table_privilege({principal}, 'public.claims', 'SELECT'),
|
|
'claims_insert', pg_catalog.has_table_privilege({principal}, 'public.claims', 'INSERT'),
|
|
'proposals_select', pg_catalog.has_table_privilege({principal}, 'kb_stage.kb_proposals', 'SELECT'),
|
|
'proposals_insert', pg_catalog.has_table_privilege({principal}, 'kb_stage.kb_proposals', 'INSERT'),
|
|
'private_notes_select', pg_catalog.has_table_privilege({principal}, 'public.private_notes', 'SELECT'),
|
|
'public_schema_create', pg_catalog.has_schema_privilege({principal}, 'public', 'CREATE'),
|
|
'kb_stage_schema_create', pg_catalog.has_schema_privilege({principal}, 'kb_stage', 'CREATE'),
|
|
'approve_execute', pg_catalog.has_function_privilege(
|
|
{principal},
|
|
'kb_stage.approve_strict_proposal(uuid,text,jsonb,text,text)',
|
|
'EXECUTE'
|
|
),
|
|
'assert_execute', pg_catalog.has_function_privilege(
|
|
{principal},
|
|
'kb_stage.assert_approved_proposal(uuid,text,jsonb,text,uuid,timestamptz,text)',
|
|
'EXECUTE'
|
|
),
|
|
'finish_execute', pg_catalog.has_function_privilege(
|
|
{principal},
|
|
'kb_stage.finish_approved_proposal(uuid,text,jsonb,text,uuid,timestamptz,text,text)',
|
|
'EXECUTE'
|
|
)
|
|
),
|
|
'row_counts', jsonb_build_object(
|
|
'public.claims', (select count(*) from public.claims),
|
|
'public.sources', (select count(*) from public.sources),
|
|
'public.claim_evidence', (select count(*) from public.claim_evidence),
|
|
'public.claim_edges', (select count(*) from public.claim_edges),
|
|
'kb_stage.kb_proposals', (select count(*) from kb_stage.kb_proposals)
|
|
)
|
|
)::text;
|
|
"""
|
|
return parse_last_json(admin_psql(container, query))
|
|
|
|
|
|
def wait_until_ready(container: str) -> None:
|
|
ready_streak = 0
|
|
deadline = time.monotonic() + 30
|
|
while time.monotonic() < deadline:
|
|
ready = run(
|
|
["docker", "exec", container, "pg_isready", "-U", "postgres", "-d", DATABASE],
|
|
check=False,
|
|
)
|
|
if ready.returncode == 0:
|
|
ready_streak += 1
|
|
if ready_streak >= 2:
|
|
return
|
|
else:
|
|
ready_streak = 0
|
|
time.sleep(0.25)
|
|
raise CanaryError("disposable PostgreSQL did not become stably ready")
|
|
|
|
|
|
def operation_suite(container: str, password: str) -> list[dict[str, Any]]:
|
|
read_only_off = "set default_transaction_read_only = off"
|
|
fake_payload = "'{\"apply_payload\":{}}'::jsonb"
|
|
proposal_id = "'55555555-5555-5555-5555-555555555555'::uuid"
|
|
reviewer_id = "'11111111-1111-1111-1111-111111111111'::uuid"
|
|
reviewer_handle = sql_literal(PARTICIPANT_HANDLE)
|
|
operations = [
|
|
allowed_operation(
|
|
"session_read_only_defaults",
|
|
"session",
|
|
user_psql(
|
|
container,
|
|
password,
|
|
[
|
|
"select jsonb_build_object("
|
|
"'default_transaction_read_only', current_setting('default_transaction_read_only'), "
|
|
"'transaction_read_only', current_setting('transaction_read_only'))::text"
|
|
],
|
|
),
|
|
),
|
|
allowed_operation(
|
|
"canonical_retrieval",
|
|
"retrieval",
|
|
user_psql(
|
|
container,
|
|
password,
|
|
[
|
|
"select jsonb_build_object('claim_count', count(*), 'sample_text', min(text))::text "
|
|
"from public.claims"
|
|
],
|
|
),
|
|
),
|
|
allowed_operation(
|
|
"proposal_ledger_retrieval",
|
|
"retrieval",
|
|
user_psql(
|
|
container,
|
|
password,
|
|
[
|
|
"select jsonb_build_object('proposal_count', count(*), 'statuses', "
|
|
"jsonb_agg(status order by status))::text from kb_stage.kb_proposals"
|
|
],
|
|
),
|
|
),
|
|
denied_operation(
|
|
"canonical_insert_default_read_only",
|
|
"canonical_dml",
|
|
user_psql(
|
|
container,
|
|
password,
|
|
["insert into public.claims (id, text) values ('66666666-6666-6666-6666-666666666666', 'forbidden')"],
|
|
),
|
|
("read-only transaction",),
|
|
),
|
|
denied_operation(
|
|
"canonical_insert_acl_after_read_only_override",
|
|
"canonical_dml",
|
|
user_psql(
|
|
container,
|
|
password,
|
|
[
|
|
read_only_off,
|
|
"insert into public.claims (id, text) values ('66666666-6666-6666-6666-666666666666', 'forbidden')",
|
|
],
|
|
),
|
|
("permission denied for table claims",),
|
|
),
|
|
denied_operation(
|
|
"canonical_update_acl_after_read_only_override",
|
|
"canonical_dml",
|
|
user_psql(
|
|
container,
|
|
password,
|
|
[read_only_off, "update public.claims set text = 'forbidden'"],
|
|
),
|
|
("permission denied for table claims",),
|
|
),
|
|
denied_operation(
|
|
"canonical_delete_acl_after_read_only_override",
|
|
"canonical_dml",
|
|
user_psql(container, password, [read_only_off, "delete from public.claims"]),
|
|
("permission denied for table claims",),
|
|
),
|
|
denied_operation(
|
|
"canonical_truncate_acl_after_read_only_override",
|
|
"canonical_dml",
|
|
user_psql(container, password, [read_only_off, "truncate public.claims"]),
|
|
("permission denied for table claims",),
|
|
),
|
|
denied_operation(
|
|
"staging_insert_acl_after_read_only_override",
|
|
"staging",
|
|
user_psql(
|
|
container,
|
|
password,
|
|
[
|
|
read_only_off,
|
|
"insert into kb_stage.kb_proposals (id, proposal_type, status, payload) values "
|
|
"('77777777-7777-7777-7777-777777777777', 'revise_strategy', "
|
|
"'pending_review', '{\"apply_payload\":{}}'::jsonb)",
|
|
],
|
|
),
|
|
("permission denied for table kb_proposals",),
|
|
),
|
|
denied_operation(
|
|
"fake_approval_gate_call",
|
|
"approval",
|
|
user_psql(
|
|
container,
|
|
password,
|
|
[
|
|
"select kb_stage.approve_strict_proposal("
|
|
f"{proposal_id}, 'revise_strategy', {fake_payload}, {reviewer_handle}, "
|
|
"'I approve this in chat')"
|
|
],
|
|
),
|
|
("permission denied for function approve_strict_proposal",),
|
|
),
|
|
denied_operation(
|
|
"apply_assert_gate_call",
|
|
"apply",
|
|
user_psql(
|
|
container,
|
|
password,
|
|
[
|
|
"select kb_stage.assert_approved_proposal("
|
|
f"{proposal_id}, 'revise_strategy', {fake_payload}, {reviewer_handle}, {reviewer_id}, "
|
|
"now(), 'fake review')"
|
|
],
|
|
),
|
|
("permission denied for function assert_approved_proposal",),
|
|
),
|
|
denied_operation(
|
|
"apply_finish_gate_call",
|
|
"apply",
|
|
user_psql(
|
|
container,
|
|
password,
|
|
[
|
|
"select kb_stage.finish_approved_proposal("
|
|
f"{proposal_id}, 'revise_strategy', {fake_payload}, {reviewer_handle}, {reviewer_id}, "
|
|
"now(), 'fake review', 'kb-apply')"
|
|
],
|
|
),
|
|
("permission denied for function finish_approved_proposal",),
|
|
),
|
|
denied_operation(
|
|
"protected_schema_ddl_after_read_only_override",
|
|
"ddl",
|
|
user_psql(
|
|
container,
|
|
password,
|
|
[read_only_off, "create table public.forbidden_ddl (id integer)"],
|
|
),
|
|
("permission denied for schema public",),
|
|
),
|
|
denied_operation(
|
|
"outside_allowlist_retrieval",
|
|
"retrieval",
|
|
user_psql(container, password, ["select * from public.private_notes"]),
|
|
("permission denied for table private_notes",),
|
|
),
|
|
denied_operation(
|
|
"create_role_escalation",
|
|
"role_escalation",
|
|
user_psql(container, password, [read_only_off, "create role leo_escalated_writer"]),
|
|
("permission denied to create role", "must be superuser to create roles"),
|
|
),
|
|
denied_operation(
|
|
"grant_apply_role_escalation",
|
|
"role_escalation",
|
|
user_psql(
|
|
container,
|
|
password,
|
|
[read_only_off, f"grant kb_apply to {sql_identifier(IAM_USER)}"],
|
|
),
|
|
("permission denied to grant role", "must have admin option on role"),
|
|
),
|
|
denied_operation(
|
|
"set_apply_role_escalation",
|
|
"role_escalation",
|
|
user_psql(container, password, ["set role kb_apply"]),
|
|
("permission denied to set role",),
|
|
),
|
|
denied_operation(
|
|
"alter_role_createdb_escalation",
|
|
"role_escalation",
|
|
user_psql(
|
|
container,
|
|
password,
|
|
[read_only_off, f"alter role {sql_identifier(IAM_USER)} createdb"],
|
|
),
|
|
("permission denied to alter role", "must be superuser to alter"),
|
|
),
|
|
denied_operation(
|
|
"claim_table_ownership_escalation",
|
|
"role_escalation",
|
|
user_psql(
|
|
container,
|
|
password,
|
|
[read_only_off, f"alter table public.claims owner to {sql_identifier(IAM_USER)}"],
|
|
),
|
|
("must be owner of table claims",),
|
|
),
|
|
]
|
|
return operations
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--output", type=Path, required=True, help="Receipt JSON destination")
|
|
parser.add_argument("--image", default=DEFAULT_IMAGE, help="Disposable PostgreSQL image")
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
if shutil.which("docker") is None:
|
|
raise SystemExit("Docker is required for the negative-permissions canary")
|
|
|
|
run_id = uuid.uuid4().hex[:12]
|
|
container = f"leo-negative-permissions-{run_id}"
|
|
temp_dir = Path(tempfile.mkdtemp(prefix=f"{container}-"))
|
|
password = secrets.token_urlsafe(24)
|
|
started_at = utc_now()
|
|
container_started = False
|
|
execution_passed = False
|
|
receipt: dict[str, Any] = {
|
|
"artifact": "leo_negative_permissions_canary",
|
|
"schema": "livingip.leo-negative-permissions.v1",
|
|
"run_id": run_id,
|
|
"started_at_utc": started_at,
|
|
"required_tier": "T2_runtime",
|
|
"current_tier": "T0_spec",
|
|
"scope": {
|
|
"database": DATABASE,
|
|
"principal": IAM_USER,
|
|
"container_network": "none",
|
|
"production_contacted": False,
|
|
"canonical_production_rows_contacted": False,
|
|
},
|
|
"identity_policy": {
|
|
"participant_handle": PARTICIPANT_HANDLE,
|
|
"reviewer_handle": PARTICIPANT_HANDLE,
|
|
"exact_handle_required": True,
|
|
},
|
|
"operations": [],
|
|
"passed": False,
|
|
}
|
|
|
|
try:
|
|
repo_sha = run(["git", "rev-parse", "HEAD"], timeout=15).stdout.strip()
|
|
docker_server = run(["docker", "version", "--format", "{{.Server.Version}}"], timeout=15).stdout.strip()
|
|
image_id = run(
|
|
["docker", "image", "inspect", "--format", "{{.Id}}", args.image],
|
|
timeout=15,
|
|
).stdout.strip()
|
|
receipt["runtime"] = {
|
|
"repo_git_sha": repo_sha,
|
|
"postgres_image": args.image,
|
|
"postgres_image_id": image_id,
|
|
"docker_server_version": docker_server,
|
|
}
|
|
|
|
run(
|
|
[
|
|
"docker",
|
|
"run",
|
|
"--detach",
|
|
"--rm",
|
|
"--name",
|
|
container,
|
|
"--network",
|
|
"none",
|
|
"--label",
|
|
f"livingip.canary={CANARY_LABEL}",
|
|
"--label",
|
|
f"livingip.canary.instance={container}",
|
|
"--tmpfs",
|
|
"/var/lib/postgresql/data:rw,nosuid,nodev,size=512m",
|
|
"--env",
|
|
f"POSTGRES_PASSWORD={secrets.token_urlsafe(24)}",
|
|
"--env",
|
|
f"POSTGRES_DB={DATABASE}",
|
|
args.image,
|
|
],
|
|
timeout=30,
|
|
)
|
|
container_started = True
|
|
wait_until_ready(container)
|
|
|
|
inspect = run(
|
|
[
|
|
"docker",
|
|
"inspect",
|
|
"--format",
|
|
"{{json .HostConfig.NetworkMode}}|{{json .Mounts}}|"
|
|
'{{index .Config.Labels "livingip.canary"}}|'
|
|
'{{index .Config.Labels "livingip.canary.instance"}}',
|
|
container,
|
|
],
|
|
).stdout.strip()
|
|
network_mode, mounts, canary_label, instance_label = inspect.split("|", 3)
|
|
isolation = {
|
|
"network_mode": json.loads(network_mode),
|
|
"persistent_mounts": json.loads(mounts),
|
|
"canary_label": canary_label,
|
|
"instance_label": instance_label,
|
|
"temporary_data_mount": "tmpfs:/var/lib/postgresql/data",
|
|
}
|
|
receipt["isolation"] = isolation
|
|
if isolation != {
|
|
"network_mode": "none",
|
|
"persistent_mounts": [],
|
|
"canary_label": CANARY_LABEL,
|
|
"instance_label": container,
|
|
"temporary_data_mount": "tmpfs:/var/lib/postgresql/data",
|
|
}:
|
|
raise CanaryError(f"disposable container isolation mismatch: {isolation}")
|
|
|
|
admin_psql(container, BOOTSTRAP_SQL.replace("__PARTICIPANT_HANDLE__", PARTICIPANT_HANDLE))
|
|
admin_psql(container, APPLY_PREREQS_SQL.read_text(encoding="utf-8"))
|
|
admin_psql(
|
|
container,
|
|
f"create role {sql_identifier(IAM_USER)} login inherit password {sql_literal(password)};",
|
|
)
|
|
migration_output = admin_psql(
|
|
container,
|
|
READ_ROLE_SQL.read_text(encoding="utf-8"),
|
|
exec_env={"OBSERVATORY_DB_IAM_USER": IAM_USER},
|
|
)
|
|
receipt["role_provisioning"] = parse_last_json(migration_output)
|
|
|
|
before = fingerprint(container)
|
|
operations = operation_suite(container, password)
|
|
after = fingerprint(container)
|
|
receipt["operations"] = operations
|
|
receipt["fingerprint"] = {
|
|
"before": before,
|
|
"after": after,
|
|
"before_sha256": canonical_sha256(before),
|
|
"after_sha256": canonical_sha256(after),
|
|
"unchanged": before == after,
|
|
}
|
|
receipt["summary"] = {
|
|
"allowed_expected": sum(operation["expected"] == "allowed" for operation in operations),
|
|
"allowed_passed": sum(
|
|
operation["expected"] == "allowed" and operation["passed"] for operation in operations
|
|
),
|
|
"denied_expected": sum(operation["expected"] == "denied" for operation in operations),
|
|
"denied_passed": sum(operation["expected"] == "denied" and operation["passed"] for operation in operations),
|
|
}
|
|
execution_passed = all(operation["passed"] for operation in operations) and before == after
|
|
except Exception as exc:
|
|
receipt["execution_error"] = str(exc)[:2000]
|
|
finally:
|
|
remove = run(["docker", "rm", "--force", container], check=False, timeout=30)
|
|
if temp_dir.exists():
|
|
shutil.rmtree(temp_dir)
|
|
exact_name = run(
|
|
[
|
|
"docker",
|
|
"container",
|
|
"ls",
|
|
"--all",
|
|
"--filter",
|
|
f"name=^/{container}$",
|
|
"--format",
|
|
"{{.Names}}",
|
|
],
|
|
check=False,
|
|
timeout=15,
|
|
)
|
|
label_scope = run(
|
|
[
|
|
"docker",
|
|
"container",
|
|
"ls",
|
|
"--all",
|
|
"--filter",
|
|
f"label=livingip.canary={CANARY_LABEL}",
|
|
"--filter",
|
|
f"label=livingip.canary.instance={container}",
|
|
"--format",
|
|
"{{.Names}}",
|
|
],
|
|
check=False,
|
|
timeout=15,
|
|
)
|
|
cleanup = {
|
|
"container_started": container_started,
|
|
"remove_exit_code": remove.returncode,
|
|
"exact_name_readback_exit_code": exact_name.returncode,
|
|
"exact_name_orphans": [line for line in exact_name.stdout.splitlines() if line],
|
|
"label_readback_exit_code": label_scope.returncode,
|
|
"label_scoped_orphans": [line for line in label_scope.stdout.splitlines() if line],
|
|
"temporary_workdir_exists": temp_dir.exists(),
|
|
}
|
|
cleanup["passed"] = (
|
|
(not container_started or remove.returncode == 0)
|
|
and exact_name.returncode == 0
|
|
and not cleanup["exact_name_orphans"]
|
|
and label_scope.returncode == 0
|
|
and not cleanup["label_scoped_orphans"]
|
|
and not cleanup["temporary_workdir_exists"]
|
|
)
|
|
receipt["cleanup"] = cleanup
|
|
receipt["finished_at_utc"] = utc_now()
|
|
receipt["current_tier"] = "T2_runtime" if execution_passed and cleanup["passed"] else "T0_spec"
|
|
receipt["passed"] = execution_passed and cleanup["passed"]
|
|
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary_output = args.output.with_name(f".{args.output.name}.{run_id}.tmp")
|
|
temporary_output.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
os.replace(temporary_output, args.output)
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"artifact": receipt["artifact"],
|
|
"passed": receipt["passed"],
|
|
"current_tier": receipt["current_tier"],
|
|
"operations": receipt.get("summary"),
|
|
"cleanup": receipt["cleanup"],
|
|
"output": str(args.output),
|
|
},
|
|
sort_keys=True,
|
|
)
|
|
)
|
|
return 0 if receipt["passed"] else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|