feat: add Proxmox provisioning service with CI and deployment tooling
CI / javascript-check (push) Successful in 51s
CI / container-policy (push) Successful in 2s
CI / container-verify (push) Canceled after 0s
CI / container-publish (push) Canceled after 0s
CI / python-tests (push) Canceled after 6m59s

This commit is contained in:
BartelLuis
2026-09-14 20:09:12 +02:00
commit 06c3474636
52 changed files with 9779 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
"""Standalone container smoke: real TLS, login, persistence and restart.
Run inside the application image. All data lives in a temporary directory;
no installer, runner or provisioning module is executed.
"""
from http.cookiejar import CookieJar
import json
import os
from pathlib import Path
import secrets
import socket
import ssl
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from provisioner import cli
from provisioner.config import Settings
def main():
assert os.getuid() == 10001, "Container must use its unprivileged service account"
with tempfile.TemporaryDirectory(prefix="ais-smoke-") as directory:
root = Path(directory)
with socket.socket() as sock:
sock.bind(("127.0.0.1",0))
port = sock.getsockname()[1]
base = f"https://localhost:{port}"
settings = Settings(data_dir=root / "data",master_key_file=root / "keys" / "master.key",public_url=base)
password = secrets.token_urlsafe(24)
cli.getpass.getpass = lambda _:password
cli.initialize(settings,"smoke-admin")
certificate,private = root / "tls.crt",root / "tls.key"
subprocess.run(["openssl","req","-x509","-newkey","ed25519","-nodes","-keyout",str(private),"-out",str(certificate),"-days","1","-subj","/CN=localhost","-addext","subjectAltName=DNS:localhost"],check=True,capture_output=True)
environment = {**os.environ,"DATA_DIR":str(settings.data_dir),"MASTER_KEY_FILE":str(settings.master_key_file),"PUBLIC_URL":base,"SECURE_COOKIES":"true","TESTING":"false"}
context = ssl.create_default_context(cafile=str(certificate))
opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=context),urllib.request.HTTPCookieProcessor(CookieJar()))
process = None
def start():
child = subprocess.Popen([sys.executable,"-m","provisioner.cli","serve","--host","127.0.0.1","--port",str(port),"--tls-cert",str(certificate),"--tls-key",str(private)],env=environment,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
try:
for _ in range(60):
if child.poll() is not None:
raise AssertionError("Service exited during startup")
try:
with opener.open(base+"/health/ready",timeout=2) as response:
assert json.load(response)["status"] == "ready"
return child
except urllib.error.URLError:
time.sleep(0.1)
raise AssertionError("Service did not become ready")
except BaseException:
child.terminate()
child.wait(timeout=15)
raise
def request(path,payload=None,csrf=None):
headers = {}
body = None
if payload is not None:
body = json.dumps(payload).encode()
headers["Content-Type"] = "application/json"
if csrf:
headers["X-CSRF-Token"] = csrf
return opener.open(urllib.request.Request(base+path,data=body,headers=headers),timeout=5)
try:
process = start()
try:
urllib.request.urlopen(base+"/health/ready",timeout=3)
except urllib.error.URLError as error:
assert isinstance(error.reason,ssl.SSLCertVerificationError),repr(error)
else:
raise AssertionError("An untrusted TLS certificate was accepted")
form = urllib.parse.urlencode({"username":"smoke-admin","password":password}).encode()
with opener.open(urllib.request.Request(base+"/auth/login",data=form,headers={"Origin":base}),timeout=5) as response:
assert response.status == 200
assert b"/static/app.js" in response.read()
with request("/api/v1/me") as response:
csrf = json.load(response)["csrf_token"]
with request("/api/v1/hosts",{"fqdn":"smoke.example.net","site":"isolated-test","management_ip":"192.0.2.11/24","identities":[{"kind":"serial","value":"SMOKE-ONLY"}]},csrf) as response:
host_id = json.load(response)["id"]
with opener.open(base+"/static/app.js",timeout=5) as response:
assert response.status == 200 and len(response.read())>1000
process.terminate()
process.wait(timeout=15)
process = start()
with request("/api/v1/hosts") as response:
assert any(host["id"] == host_id for host in json.load(response))
print(json.dumps({"uid":os.getuid(),"checks":["TLS trusted certificate","untrusted certificate rejected","browser login and CSRF","static assets packaged","host persists across service restart","session persists across service restart"],"status":"passed"}))
finally:
if process and process.poll() is None:
process.terminate()
process.wait(timeout=15)
if __name__ == "__main__":
main()
+392
View File
@@ -0,0 +1,392 @@
"""Acceptance tests exercise authorization and installation state through HTTP."""
import base64
from concurrent.futures import ThreadPoolExecutor
from copy import deepcopy
from dataclasses import replace
import hashlib
import json
from pathlib import Path
import re
import time
import tomllib
from uuid import uuid4
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from fastapi.testclient import TestClient
import pytest
from provisioner.app import create_app
from provisioner.cli import ServiceLock, backup, initialize, restore
from provisioner.config import Settings
from provisioner.db import Database
from provisioner.security import Security
PASSWORD = "Ais-test-admin-only-452!"
ROOT_HASH = "$6$testsalt$" + "A" * 86
HOST_UUID = "d2e59b03-13cf-4ac9-a390-78c55f6a36d3"
HOST_MAC = "02:00:00:00:00:01"
SOURCE = '#!/bin/bash\nset -euo pipefail\ncase "$1" in\ncheck|apply|verify) exit 0;;\n*) exit 64;;\nesac\n'
def login(client, username="admin", password=PASSWORD):
response = client.post("/auth/login", data={"username": username, "password": password}, follow_redirects=False)
assert response.status_code == 303, response.text
response = client.get("/api/v1/me")
assert response.status_code == 200, response.text
return {"X-CSRF-Token": response.json()["csrf_token"]}
def post(client, path, data, csrf):
response = client.post(path, json=data, headers=csrf)
assert response.status_code in {200, 201}, f"{path}: {response.status_code} {response.text}"
return response.json()
@pytest.fixture
def environment(tmp_path):
settings = Settings(
data_dir=tmp_path / "data", master_key_file=tmp_path / "keys" / "master.key",
public_url="https://testserver", secure_cookies=False, bootstrap_username="admin",
bootstrap_password=PASSWORD, testing=True, four_eyes=False,
)
app = create_app(settings)
with TestClient(app, base_url="https://testserver") as client:
csrf = login(client)
yield app, client, csrf
@pytest.fixture
def prepared(environment):
app, client, csrf = environment
secret = post(client, "/api/v1/secrets", {"name": "test-root", "value": ROOT_HASH}, csrf)
group = post(client, "/api/v1/groups", {"name": "test-lab", "site": "lab", "valid_hours": 1}, csrf)
iso = post(client, "/api/v1/iso-records", {
"name": "Simulated ISO; not a real hardware certification", "build": "9.1-1", "sha256": "1" * 64,
"assistant_version": "test-only", "fingerprint": "2" * 64, "group_id": group["id"],
"native_token_support": True, "test_status": "passed", "test_evidence": "Synthetic HTTP acceptance fixture",
}, csrf)
module = post(client, "/api/v1/modules", {"name": "test-verification", "source": SOURCE,
"target_builds": ["9.1-1"], "retry_safe": True}, csrf)
publication = {"test_evidence": "Synthetic HTTP acceptance fixture", "reason": "Testing publication"}
post(client, f"/api/v1/modules/{module['id']}/publish", publication, csrf)
profile_data = json.loads((Path(__file__).parents[1] / "docs" / "sample-profile.json").read_text())
profile_data["values"]["root_secret_id"] = secret["id"]
installation = post(client, "/api/v1/profiles", profile_data, csrf)
post(client, f"/api/v1/profiles/{installation['id']}/publish", publication, csrf)
postinstall = post(client, "/api/v1/profiles", {"name": "test-postinstall", "kind": "postinstall",
"target_builds": ["9.1-1"], "steps": [{"id": "verify", "module_id": module["id"], "required": True}]}, csrf)
post(client, f"/api/v1/profiles/{postinstall['id']}/publish", publication, csrf)
identities = [{"kind": "uuid", "value": HOST_UUID}, {"kind": "serial", "value": "LAB-HOST-001"},
{"kind": "mac", "value": HOST_MAC}]
host = post(client, "/api/v1/hosts", {"fqdn": "pve01.lab.example.net", "site": "lab",
"management_ip": "192.0.2.10/24", "identities": identities,
"installation_profile_id": installation["id"], "postinstall_profile_id": postinstall["id"], "iso_id": iso["id"]}, csrf)
run = post(client, f"/api/v1/hosts/{host['id']}/approve-install", {
"expected_version": host["version"], "valid_minutes": 30, "confirmation": host["fqdn"],
"disks_confirmed": True, "reason": "Dedicated simulated test host"}, csrf)
payload = {"$schema": {"version": "1.0"}, "product": {"product": "pve"},
"iso": {"release": "9.1", "build": "1"}, "dmi": {"system": {"uuid": HOST_UUID, "serial": "LAB-HOST-001"}},
"network-interfaces": [{"mac": HOST_MAC}]}
return {"app": app, "client": client, "csrf": csrf, "group": group, "iso": iso,
"module": module, "installation": installation, "profile_data": profile_data,
"host": host, "run": run, "payload": payload, "identities": identities}
def answer(prepared, payload=None):
return prepared["client"].post("/installer/v1/answer", json=payload or prepared["payload"],
headers={"Authorization": f"Bearer {prepared['group']['token']}"})
def enroll(prepared):
response = answer(prepared)
assert response.status_code == 200, response.text
config = tomllib.loads(response.text)
bootstrap = prepared["client"].get(config["first-boot"]["url"])
assert bootstrap.status_code == 200
encoded = re.search(r"config = base64.b64decode\('([^']+)'\)", bootstrap.text).group(1)
runtime = json.loads(base64.b64decode(encoded))
key = Ed25519PrivateKey.generate()
public_key = base64.b64encode(key.public_key().public_bytes_raw()).decode()
registration = {"run_id": prepared["run"]["id"], "enrollment_secret": runtime["enrollment_secret"],
"public_key": public_key, "identities": prepared["identities"], "boot_id": "boot-test-1"}
response = prepared["client"].post("/agent/v1/enroll", json=registration)
assert response.status_code == 200, response.text
return key, public_key, registration
def signed(prepared, key, method, path, payload=None, headers=None):
body = b"" if payload is None else json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()
timestamp, nonce = str(int(time.time())), uuid4().hex
message = f"{method}\n{path}\n{timestamp}\n{nonce}\n{hashlib.sha256(body).hexdigest()}".encode()
request_headers = {"X-Run-ID": prepared["run"]["id"],
"X-Device-Key": base64.b64encode(key.public_key().public_bytes_raw()).decode(),
"X-Timestamp": timestamp, "X-Nonce": nonce, "X-Signature": base64.b64encode(key.sign(message)).decode(),
"Content-Type": "application/json"}
request_headers.update(headers or {})
return prepared["client"].request(method, path, content=body, headers=request_headers)
def test_authentication_csrf_and_reader_permissions(environment):
app, client, csrf = environment
anonymous = TestClient(app, base_url="https://testserver")
try:
assert anonymous.get("/api/v1/hosts").status_code == 401
finally:
anonymous.close()
assert client.post("/api/v1/groups", json={"name": "denied", "site": "lab"}).status_code == 403
post(client, "/api/v1/users", {"username": "reader", "password": PASSWORD, "role": "reader"}, csrf)
reader_csrf = login(client, "reader")
assert client.get("/api/v1/hosts").status_code == 200
assert client.post("/api/v1/groups", json={"name": "denied", "site": "lab"}, headers=reader_csrf).status_code == 403
assert client.get("/api/v1/secrets").status_code == 403
def test_unknown_conflicting_and_blocked_hosts_never_receive_answer(prepared):
unknown = deepcopy(prepared["payload"])
unknown["dmi"]["system"] = {"uuid": str(uuid4()), "serial": "UNKNOWN-HOST"}
unknown["network-interfaces"] = [{"mac": "02:00:00:00:ff:fe"}]
assert answer(prepared, unknown).status_code == 403
contradictory = deepcopy(prepared["payload"])
contradictory["dmi"]["system"]["uuid"] = str(uuid4())
assert answer(prepared, contradictory).status_code == 409
host = prepared["client"].get(f"/api/v1/hosts/{prepared['host']['id']}").json()
response = prepared["client"].patch(f"/api/v1/hosts/{host['id']}", json={"expected_version": host["version"], "blocked": True}, headers=prepared["csrf"])
assert response.status_code == 200, response.text
assert answer(prepared).status_code == 403
def test_expired_approval_and_wrong_group_cannot_install(prepared):
other = post(prepared["client"], "/api/v1/groups", {"name": "other-group", "site": "lab"}, prepared["csrf"])
denied = prepared["client"].post("/installer/v1/answer", json=prepared["payload"], headers={"Authorization": f"Bearer {other['token']}"})
assert denied.status_code == 403
with prepared["app"].state.db.connection(write=True) as connection:
connection.execute("UPDATE approvals SET expires_at=?", (time.time() - 1,))
assert answer(prepared).status_code == 410
def test_author_cannot_publish_own_version_when_four_eyes_enabled(environment):
app, client, csrf = environment
app.state.settings.four_eyes = True
draft = post(client, "/api/v1/profiles", {"name": "self-publish-denied", "kind": "installation"}, csrf)
response = client.post(f"/api/v1/profiles/{draft['id']}/publish", json={
"test_evidence": "A laboratory test report", "reason": "Self publication attempt"}, headers=csrf)
assert response.status_code == 403
def test_concurrent_installer_retries_reserve_one_immutable_answer(prepared):
with ThreadPoolExecutor(max_workers=10) as executor:
responses = list(executor.map(lambda _: answer(prepared), range(10)))
assert {response.status_code for response in responses} == {200}, [r.text for r in responses]
assert len({response.text for response in responses}) == 1
assert all(response.headers["cache-control"] == "no-store" for response in responses)
native = tomllib.loads(responses[0].text)
assert native["global"]["fqdn"] == prepared["host"]["fqdn"]
assert native["disk-setup"]["filter"] == {"ID_SERIAL_SHORT": "LAB_SYSTEM_DISK_001"}
assert "expected_count" not in native["disk-setup"]
with prepared["app"].state.db.connection() as connection:
assert connection.execute("SELECT count(*) FROM runs").fetchone()[0] == 1
assert connection.execute("SELECT status FROM approvals").fetchone()[0] == "consumed"
def test_new_profile_version_cannot_change_prepared_run(prepared):
original = prepared["run"]["snapshot"]
data = deepcopy(prepared["profile_data"])
data["values"]["network"]["dns"] = "192.0.2.54"
version2 = post(prepared["client"], "/api/v1/profiles", data, prepared["csrf"])
assert version2["version"] == prepared["installation"]["version"] + 1
assert version2["id"] != prepared["installation"]["id"]
current = prepared["client"].get(f"/api/v1/runs/{prepared['run']['id']}").json()
assert current["snapshot"] == original
response = answer(prepared)
assert response.status_code == 200, response.text
assert tomllib.loads(response.text)["network"]["dns"] == "192.0.2.53"
def test_enrollment_signature_sequence_and_verified_completion(prepared):
key, _, registration = enroll(prepared)
run_id = prepared["run"]["id"]
assert answer(prepared).status_code in {403, 410}
# Retrying a lost enrollment response must not generate a second device identity.
assert prepared["client"].post("/agent/v1/enroll", json=registration).status_code == 200
lease = signed(prepared, key, "POST", "/agent/v1/lease", {"run_id": run_id})
assert lease.status_code == 200 and lease.json()["action"] == "run"
manifest = signed(prepared, key, "GET", f"/agent/v1/runs/{run_id}/manifest")
assert manifest.status_code == 200, manifest.text
wrong_key = Ed25519PrivateKey.generate()
assert signed(prepared, wrong_key, "GET", f"/agent/v1/runs/{run_id}/manifest").status_code in {401, 403}
premature = signed(prepared, key, "POST", f"/agent/v1/runs/{run_id}/complete", {"verification": {"verify": {"passed": True}}})
assert premature.status_code == 409
def event(sequence, kind, **extra):
return {"sequence": sequence, "type": kind, "boot_id": "boot-test-1", "step_id": "verify",
"occurred_at": "2026-09-13T12:00:00Z", **extra}
out_of_order = {"events": [event(2, "step.started")]}
assert signed(prepared, key, "POST", f"/agent/v1/runs/{run_id}/events", out_of_order).status_code == 409
started = {"events": [event(1, "step.started")]}
assert signed(prepared, key, "POST", f"/agent/v1/runs/{run_id}/events", started).status_code == 200
assert signed(prepared, key, "POST", f"/agent/v1/runs/{run_id}/events", started).status_code == 200
false_success = {"events": [event(2, "step.succeeded", exit_code=0, verification={"passed": False})]}
assert signed(prepared, key, "POST", f"/agent/v1/runs/{run_id}/events", false_success).status_code == 409
succeeded = {"events": [event(2, "step.succeeded", exit_code=0, verification={"passed": True})]}
response = signed(prepared, key, "POST", f"/agent/v1/runs/{run_id}/events", succeeded)
assert response.status_code == 200, response.text
false_completion = signed(prepared, key, "POST", f"/agent/v1/runs/{run_id}/complete", {"verification": {"verify": {"passed": False}}})
assert false_completion.status_code == 409
completion = signed(prepared, key, "POST", f"/agent/v1/runs/{run_id}/complete", {"verification": {"verify": {"passed": True}}})
assert completion.status_code == 200, completion.text
status = prepared["client"].get(f"/api/v1/runs/{run_id}").json()["status"]
assert status == "succeeded"
assert answer(prepared).status_code in {403, 410}
assert signed(prepared, key, "GET", f"/agent/v1/runs/{run_id}/manifest").status_code in {403, 410}
def test_device_signature_replay_and_cross_run_access_are_denied(prepared):
key, _, _ = enroll(prepared)
run_id = prepared["run"]["id"]
assert signed(prepared, key, "POST", "/agent/v1/lease", {"run_id": run_id}).status_code == 200
original = signed(prepared, key, "GET", f"/agent/v1/runs/{run_id}/manifest")
assert original.status_code == 200
repeated = prepared["client"].request(original.request.method, original.request.url, content=original.request.content, headers=original.request.headers)
assert repeated.status_code == 409
foreign_path = "/agent/v1/runs/another-run/manifest"
tampered = prepared["client"].get(foreign_path, headers=original.request.headers)
assert tampered.status_code == 401
assert signed(prepared, key, "GET", foreign_path).status_code == 403
def test_tampered_module_is_not_delivered_to_runner(prepared):
key, _, _ = enroll(prepared)
run_id = prepared["run"]["id"]
assert signed(prepared, key, "POST", "/agent/v1/lease", {"run_id": run_id}).status_code == 200
checksum = prepared["module"]["digest"]
endpoint = f"/agent/v1/artifacts/{checksum}"
assert signed(prepared, key, "GET", endpoint).status_code == 200
artifact = prepared["app"].state.settings.data_dir / "artifacts" / checksum
artifact.write_bytes(b"#!/bin/bash\nexit 99\n")
response = signed(prepared, key, "GET", endpoint)
assert response.status_code == 503
assert "exit 99" not in response.text
def test_reconciliation_waits_for_issued_lease_and_requires_local_confirmation(prepared):
key, _, _ = enroll(prepared)
client, csrf = prepared["client"], prepared["csrf"]
run_id = prepared["run"]["id"]
lease = signed(prepared, key, "POST", "/agent/v1/lease", {"run_id": run_id})
assert lease.status_code == 200 and lease.json()["action"] == "run"
issued_until = lease.json()["expires_at"]
current = client.get(f"/api/v1/runs/{run_id}").json()
cancelled = post(client, f"/api/v1/runs/{run_id}/cancel", {
"expected_version": current["version"], "reason": "Stop before checking local host state"}, csrf)
stop = signed(prepared, key, "POST", "/agent/v1/lease", {"run_id": run_id})
assert stop.status_code == 200 and stop.json()["action"] == "stop"
with prepared["app"].state.db.connection(write=True) as connection:
assert connection.execute("SELECT lease_until FROM runs WHERE id=?", (run_id,)).fetchone()[0] == issued_until
connection.execute("UPDATE runs SET answer_until=? WHERE id=?", (time.time() - 1, run_id))
request = {"expected_version": cancelled["version"], "reason": "Installer and runner stopped locally and checked",
"confirmation": prepared["host"]["fqdn"], "execution_stopped": True}
endpoint = f"/api/v1/runs/{run_id}/reconcile"
assert client.post(endpoint, json=request, headers=csrf).status_code == 409
with prepared["app"].state.db.connection(write=True) as connection:
connection.execute("UPDATE runs SET lease_until=? WHERE id=?", (time.time() - 1, run_id))
assert client.post(endpoint, json={**request, "confirmation": "wrong.lab.example.net"}, headers=csrf).status_code == 422
assert client.post(endpoint, json={**request, "execution_stopped": False}, headers=csrf).status_code == 422
reconciled = post(client, endpoint, request, csrf)
assert reconciled["status"] == "cancelled"
with prepared["app"].state.db.connection() as connection:
row = connection.execute("SELECT * FROM runs WHERE id=?", (run_id,)).fetchone()
assert row["device_key"] is None and row["enrollment_hash"] is None
assert connection.execute("SELECT status FROM approvals WHERE id=?", (row["approval_id"],)).fetchone()[0] == "revoked"
assert connection.execute("SELECT 1 FROM audit WHERE action='run.reconciled' AND object_id=?", (run_id,)).fetchone()
assert signed(prepared, key, "POST", "/agent/v1/lease", {"run_id": run_id}).status_code == 401
host = client.get(f"/api/v1/hosts/{prepared['host']['id']}").json()
next_run = post(client, f"/api/v1/hosts/{host['id']}/approve-install", {
"expected_version": host["version"], "valid_minutes": 30, "confirmation": host["fqdn"],
"disks_confirmed": True, "reason": "Explicit new simulation after verified local stop"}, csrf)
assert next_run["id"] != run_id and next_run["status"] == "prepared"
def test_sensitive_values_are_encrypted_and_absent_from_management(prepared):
response = answer(prepared)
assert response.status_code == 200, response.text
bootstrap_url = tomllib.loads(response.text)["first-boot"]["url"]
for endpoint in ("/api/v1/hosts", "/api/v1/profiles", "/api/v1/runs", "/api/v1/audit", "/api/v1/groups"):
management = prepared["client"].get(endpoint)
assert management.status_code == 200, management.text
assert ROOT_HASH not in management.text
assert prepared["group"]["token"] not in management.text
assert bootstrap_url not in management.text
with prepared["app"].state.db.connection() as connection:
row = connection.execute("SELECT * FROM runs").fetchone()
assert ROOT_HASH not in row["answer_ciphertext"]
assert ROOT_HASH in prepared["app"].state.security.decrypt(row["answer_ciphertext"])
secret = connection.execute("SELECT ciphertext FROM secrets").fetchone()[0]
assert secret != ROOT_HASH
assert prepared["app"].state.security.decrypt(secret) == ROOT_HASH
def test_live_backup_offline_restore_revokes_all_active_credentials(prepared, tmp_path):
enroll(prepared)
settings = prepared["app"].state.settings
destination = tmp_path / "snapshot"
backup(settings, destination)
key_bytes = settings.master_key_file.read_bytes()
for path in destination.rglob("*"):
if path.is_file():
assert key_bytes not in path.read_bytes()
restored = replace(settings, data_dir=tmp_path / "restored")
restore(restored, destination)
db = Database(restored)
with db.connection() as connection:
assert connection.execute("SELECT count(*) FROM sessions").fetchone()[0] == 0
assert connection.execute("SELECT count(*) FROM groups WHERE revoked=0").fetchone()[0] == 0
row = connection.execute("SELECT * FROM runs").fetchone()
assert row["status"] == "needs_review"
assert all(row[name] is None for name in ("device_key", "bootstrap_hash", "enrollment_hash", "report_hash", "answer_ciphertext"))
assert Security(restored).decrypt(connection.execute("SELECT ciphertext FROM secrets").fetchone()[0]) == ROOT_HASH
assert list((restored.data_dir / "artifacts").iterdir())
with pytest.raises((RuntimeError, ValueError), match="in use|empty"):
restore(settings, destination)
restored_app = create_app(restored)
with TestClient(restored_app, base_url="https://testserver") as restored_client:
restored_csrf = login(restored_client)
current = restored_client.get(f"/api/v1/runs/{prepared['run']['id']}").json()
reconciled = post(restored_client, f"/api/v1/runs/{current['id']}/reconcile", {
"expected_version": current["version"], "confirmation": prepared["host"]["fqdn"],
"execution_stopped": True, "reason": "Physical host state checked after restore"}, restored_csrf)
assert reconciled["status"] == "cancelled"
def test_init_creates_only_password_hash_and_separate_key(tmp_path, monkeypatch):
settings = Settings(data_dir=tmp_path / "data", master_key_file=tmp_path / "keys" / "master.key")
monkeypatch.setattr("provisioner.cli.getpass.getpass", lambda _: PASSWORD)
initialize(settings, "admin")
with Database(settings).connection() as connection:
stored = connection.execute("SELECT password_hash FROM users").fetchone()[0]
assert PASSWORD not in stored
assert Security.verify_password(PASSWORD, stored)
assert settings.master_key_file.is_file()
with ServiceLock(settings.data_dir):
with pytest.raises(RuntimeError, match="in use"):
with ServiceLock(settings.data_dir):
pass
def test_restore_rejects_wrong_key_and_modified_backup(tmp_path, monkeypatch):
settings = Settings(data_dir=tmp_path / "original", master_key_file=tmp_path / "keys" / "master.key")
monkeypatch.setattr("provisioner.cli.getpass.getpass", lambda _: PASSWORD)
initialize(settings, "admin")
destination = tmp_path / "backup"
backup(settings, destination)
wrong_key = tmp_path / "keys" / "wrong.key"
wrong_key.write_bytes(Fernet.generate_key())
wrong_settings = replace(settings, data_dir=tmp_path / "wrong-restore", master_key_file=wrong_key)
with pytest.raises(ValueError, match="does not match"):
restore(wrong_settings, destination)
assert not wrong_settings.data_dir.exists()
config = destination / "settings.json"
config.write_text(config.read_text() + "\n", encoding="utf-8")
with pytest.raises(ValueError, match="checksum mismatch"):
restore(replace(settings, data_dir=tmp_path / "corrupt-restore"), destination)
+393
View File
@@ -0,0 +1,393 @@
"""Exercise publication gates with a fake Docker CLI, never a real daemon."""
import os
from pathlib import Path
import shutil
import subprocess
import tomllib
from types import SimpleNamespace
import pytest
ROOT = Path(__file__).resolve().parents[1]
VERSION = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]["version"]
SHA = "1234567890abcdef1234567890abcdef12345678"
REGISTRY_IMAGE = "ghcr.io/team/proxmox-ais"
DIGEST = f"{REGISTRY_IMAGE}@sha256:{'a' * 64}"
PASSWORD = "dummy-ci-token-$()-do-not-print"
def posix_shell():
if os.name == "nt":
shell = Path(os.environ.get("ProgramFiles", "C:/Program Files")) / "Git/bin/bash.exe"
if not shell.is_file():
pytest.skip("CI shell tests need Git Bash on Windows")
else:
shell = shutil.which("sh")
if not shell:
pytest.skip("CI shell tests need a POSIX shell")
return str(shell)
@pytest.fixture
def container_ci(tmp_path):
shell = posix_shell()
(tmp_path / "ci").mkdir()
(tmp_path / "tests").mkdir()
(tmp_path / "bin").mkdir()
(tmp_path / "tmp").mkdir()
shutil.copyfile(ROOT / "ci/container.sh", tmp_path / "ci/container.sh")
shutil.copyfile(ROOT / "pyproject.toml", tmp_path / "pyproject.toml")
smoke_source = (ROOT / "tests/container_smoke.py").read_bytes()
(tmp_path / "tests/container_smoke.py").write_bytes(smoke_source)
docker = tmp_path / "bin/docker"
docker.write_text(
"""#!/bin/sh
set -eu
{
printf '%s' "$1"
for argument in "$@"; do printf '\\t%s' "$argument"; done
printf '\\n'
} >> "$MOCK_DOCKER_LOG"
if [ "$1" = "${MOCK_FAIL_COMMAND:-}" ]; then exit 37; fi
case "$1" in
run)
cat > "$MOCK_SMOKE_STDIN"
exit "${MOCK_RUN_EXIT:-0}"
;;
login)
cat > "$MOCK_LOGIN_STDIN"
printf '%s' "$DOCKER_CONFIG" > "$MOCK_DOCKER_CONFIG"
printf '{"auths":{"test":"dummy-credentials"}}' > "$DOCKER_CONFIG/config.json"
;;
image)
printf '%s\\n' "$MOCK_REPO_DIGESTS"
;;
esac
""",
encoding="utf-8",
newline="\n",
)
docker.chmod(0o755)
environment = {
**os.environ,
"PATH": str(tmp_path / "bin") + os.pathsep + os.environ["PATH"],
"TMPDIR": (tmp_path / "tmp").as_posix(),
"GITHUB_SHA": SHA,
"GITHUB_RUN_ID": "210",
"GITHUB_RUN_ATTEMPT": "1",
"GITHUB_JOB": "container_publish",
"GITHUB_REPOSITORY": "Team/Proxmox-AIS",
"GITHUB_SERVER_URL": "https://github.com",
"GITHUB_EVENT_NAME": "push",
"GITHUB_REF_PROTECTED": "true",
"GITHUB_REF_TYPE": "branch",
"GITHUB_REF_NAME": "main",
"DEFAULT_BRANCH": "main",
"GITHUB_ACTOR": "ci-user",
"GHCR_TOKEN": PASSWORD,
"MOCK_DOCKER_LOG": (tmp_path / "docker.log").as_posix(),
"MOCK_SMOKE_STDIN": (tmp_path / "smoke.stdin").as_posix(),
"MOCK_LOGIN_STDIN": (tmp_path / "login.stdin").as_posix(),
"MOCK_DOCKER_CONFIG": (tmp_path / "docker-config.path").as_posix(),
"MOCK_REPO_DIGESTS": DIGEST,
}
def run(mode="publish", **overrides):
effective_environment = {**environment, **overrides}
for name in [name for name, value in effective_environment.items() if value is None]:
del effective_environment[name]
result = subprocess.run(
[str(shell), "ci/container.sh", mode],
cwd=tmp_path,
env=effective_environment,
capture_output=True,
text=True,
timeout=30,
)
log = tmp_path / "docker.log"
calls = [line.split("\t")[1:] for line in log.read_text().splitlines()] if log.exists() else []
return SimpleNamespace(result=result, calls=calls, root=tmp_path, smoke_source=smoke_source)
return run
def test_default_branch_publishes_only_after_hardened_image_smoke(container_ci):
outcome = container_ci()
assert outcome.result.returncode == 0, outcome.result.stderr
commands = [call[0] for call in outcome.calls]
assert commands == ["build", "run", "login", "tag", "push", "tag", "push", "image", "rm", "image"]
build_image = f"{REGISTRY_IMAGE}:ci-210-1-container_publish"
assert outcome.calls[-1] == ["image", "rm", build_image]
build = outcome.calls[0]
assert build[build.index("--platform") + 1] == "linux/amd64"
assert "--provenance=false" in build and "--sbom=false" in build
assert f"org.opencontainers.image.revision={SHA}" in build
assert "org.opencontainers.image.source=https://github.com/Team/Proxmox-AIS" in build
assert build[build.index("--tag") + 1] == build_image
smoke = outcome.calls[1]
for option in ["--read-only", "--cap-drop", "ALL", "no-new-privileges:true", "/tmp:rw,noexec,nosuid,size=128m"]:
assert option in smoke
assert smoke[smoke.index("--entrypoint") + 1] == "python"
assert smoke[smoke.index("--entrypoint") + 2] == build_image
assert all(call[1] == build_image for call in outcome.calls if call[0] == "tag")
assert "--volume" not in smoke and "-v" not in smoke
assert (outcome.root / "smoke.stdin").read_bytes() == outcome.smoke_source
assert [call[1] for call in outcome.calls if call[0] == "push"] == [
f"{REGISTRY_IMAGE}:sha-{SHA}", f"{REGISTRY_IMAGE}:edge"
]
assert (outcome.root / "deploy.env").read_text() == f"PROVISIONER_IMAGE={DIGEST}\n"
assert "ci-210-1-container_publish" in (outcome.root / "build.env").read_text()
assert outcome.calls[2] == ["login", "ghcr.io", "--username", "ci-user", "--password-stdin"]
assert (outcome.root / "login.stdin").read_text() == PASSWORD
assert PASSWORD not in outcome.result.stdout + outcome.result.stderr + (outcome.root / "docker.log").read_text()
config_path = Path((outcome.root / "docker-config.path").read_text())
assert not config_path.exists(), "Temporary registry credentials must be removed"
def test_release_tag_matches_project_version_and_does_not_move_edge(container_ci):
outcome = container_ci(GITHUB_REF_TYPE="tag", GITHUB_REF_NAME=f"v{VERSION}")
assert outcome.result.returncode == 0, outcome.result.stderr
assert [call[1] for call in outcome.calls if call[0] == "push"] == [
f"{REGISTRY_IMAGE}:sha-{SHA}", f"{REGISTRY_IMAGE}:{VERSION}"
]
def test_pull_request_verifies_without_registry_credentials(container_ci):
outcome = container_ci(
"verify", GITHUB_EVENT_NAME="pull_request", GITHUB_REF_NAME="1/merge",
GITHUB_REF_PROTECTED="false", GITHUB_ACTOR=None, GHCR_TOKEN=None,
)
assert outcome.result.returncode == 0, outcome.result.stderr
assert [call[0] for call in outcome.calls] == ["build", "run", "rm", "image"]
assert "--provenance=false" in outcome.calls[0] and "--sbom=false" in outcome.calls[0]
assert outcome.calls[-1] == ["image", "rm", f"{REGISTRY_IMAGE}:ci-210-1-container_publish"]
assert (outcome.root / "build.env").is_file()
assert not (outcome.root / "deploy.env").exists()
@pytest.mark.parametrize("overrides", [
{"GITHUB_REF_NAME": "feature/ci"},
{"GITHUB_REF_NAME": f"v{VERSION}"},
{"GITHUB_REF_NAME": None},
{"DEFAULT_BRANCH": ""},
{"GITHUB_REF_PROTECTED": "false"},
{"GITHUB_REF_PROTECTED": None},
{"GITHUB_REF_PROTECTED": "TRUE"},
{"GITHUB_EVENT_NAME": "pull_request"},
{"GITHUB_EVENT_NAME": "pull_request_target"},
{"GITHUB_EVENT_NAME": "workflow_run"},
{"GITHUB_EVENT_NAME": "schedule"},
{"GITHUB_EVENT_NAME": "web"},
{"GITHUB_EVENT_NAME": None},
{"GITHUB_REF_TYPE": None},
{"GITHUB_REF_TYPE": "pull_request"},
{"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "main"},
{"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v9999.9999.9999"},
{"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "nightly"},
{"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v0.1.0-rc1"},
{"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v00.1.0"},
{"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v0.01.0"},
{"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v0.1.00"},
{"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v0.1"},
{"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v0.1.0.0"},
{"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": f"v{VERSION}", "GITHUB_REF_PROTECTED": "false"},
{"GHCR_TOKEN": None},
{"GITHUB_ACTOR": None},
{"GITHUB_SHA": "1234"},
{"GITHUB_RUN_ID": "invalid"},
{"GITHUB_RUN_ATTEMPT": "../2"},
{"GITHUB_RUN_ATTEMPT": None},
{"GITHUB_JOB": "../invalid"},
{"GITHUB_JOB": "has space"},
{"GITHUB_JOB": "-invalid"},
{"GITHUB_JOB": ""},
{"GITHUB_REPOSITORY": "team"},
{"GITHUB_REPOSITORY": "team/repo/extra"},
{"GITHUB_REPOSITORY": "team/repo:tag"},
])
def test_unauthorized_or_invalid_publication_fails_before_build(container_ci, overrides):
outcome = container_ci(**overrides)
assert outcome.result.returncode != 0
assert not outcome.calls
assert not (outcome.root / "deploy.env").exists()
@pytest.mark.parametrize("ref_type, ref_name", [("branch", "main"), ("tag", f"v{VERSION}")])
def test_manual_run_can_publish_protected_default_branch_or_release(container_ci, ref_type, ref_name):
outcome = container_ci(
GITHUB_EVENT_NAME="workflow_dispatch", GITHUB_REF_TYPE=ref_type, GITHUB_REF_NAME=ref_name,
)
assert outcome.result.returncode == 0, outcome.result.stderr
assert (outcome.root / "deploy.env").is_file()
def test_manual_feature_branch_cannot_publish(container_ci):
outcome = container_ci(GITHUB_EVENT_NAME="workflow_dispatch", GITHUB_REF_NAME="feature/ci")
assert outcome.result.returncode != 0
assert not outcome.calls
@pytest.mark.parametrize("attempt, job", [("2", "container_publish"), ("1", "container-verify")])
def test_container_names_include_run_attempt_and_job(container_ci, attempt, job):
outcome = container_ci("verify", GITHUB_RUN_ATTEMPT=attempt, GITHUB_JOB=job)
assert outcome.result.returncode == 0, outcome.result.stderr
build_image = f"{REGISTRY_IMAGE}:ci-210-{attempt}-{job}"
build, smoke = outcome.calls[:2]
assert build[build.index("--tag") + 1] == build_image
assert smoke[smoke.index("--name") + 1] == f"ais-smoke-210-{attempt}-{job}"
assert outcome.calls[-1] == ["image", "rm", build_image]
@pytest.mark.parametrize("overrides", [
{"MOCK_FAIL_COMMAND": "build"},
{"MOCK_RUN_EXIT": "19"},
])
def test_failed_build_or_smoke_never_logs_in_or_pushes(container_ci, overrides):
outcome = container_ci(**overrides)
assert outcome.result.returncode != 0
assert not any(call[0] in {"login", "tag", "push"} for call in outcome.calls)
assert not (outcome.root / "build.env").exists()
assert not (outcome.root / "deploy.env").exists()
if "MOCK_RUN_EXIT" in overrides:
assert outcome.calls[-2] == ["rm", "--force", "ais-smoke-210-1-container_publish"]
assert outcome.calls[-1] == ["image", "rm", f"{REGISTRY_IMAGE}:ci-210-1-container_publish"]
def test_push_failure_does_not_create_deployment_artifact_and_cleans_credentials(container_ci):
outcome = container_ci(MOCK_FAIL_COMMAND="push")
assert outcome.result.returncode != 0
assert not (outcome.root / "deploy.env").exists()
assert not Path((outcome.root / "docker-config.path").read_text()).exists()
assert len([call for call in outcome.calls if call[0] == "push"]) == 1
@pytest.mark.parametrize("digests", ["", "other.example.test/image@sha256:" + "a" * 64, REGISTRY_IMAGE + "@sha256:invalid"])
def test_missing_or_invalid_registry_digest_fails_without_artifact(container_ci, digests):
outcome = container_ci(MOCK_REPO_DIGESTS=digests)
assert outcome.result.returncode != 0
assert not (outcome.root / "deploy.env").exists()
@pytest.fixture
def python_ci(tmp_path):
shell = posix_shell()
(tmp_path / "ci").mkdir()
(tmp_path / "bin").mkdir()
shutil.copyfile(ROOT / "ci/python-tests.sh", tmp_path / "ci/python-tests.sh")
shutil.copyfile(ROOT / "pyproject.toml", tmp_path / "pyproject.toml")
venv_python = tmp_path / "venv-python"
venv_python.write_text(
"""#!/bin/sh
set -eu
printf '%s\\n' "$*" >> "$MOCK_PYTHON_LOG"
case "$2" in
pip) exit "${MOCK_PIP_EXIT:-0}" ;;
pytest)
printf '<testsuite tests="1" />\\n' > reports/pytest.xml
exit "${MOCK_PYTEST_EXIT:-0}"
;;
*) exit 65 ;;
esac
""", encoding="utf-8", newline="\n",
)
python = tmp_path / "bin/python3"
python.write_text(
"""#!/bin/sh
set -eu
case "$1" in
-c) exit "${MOCK_PYTHON_VERSION_EXIT:-0}" ;;
-m)
[ "$2" = venv ]
printf '%s' "$3" > "$MOCK_VENV_PATH"
mkdir -p "$3/bin"
cp "$MOCK_VENV_PYTHON" "$3/bin/python"
chmod +x "$3/bin/python"
;;
*) exit 65 ;;
esac
""", encoding="utf-8", newline="\n",
)
python.chmod(0o755)
for name in ["bash", "openssl", "ssh-keygen"]:
executable = tmp_path / "bin" / name
executable.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8", newline="\n")
executable.chmod(0o755)
environment = {
**os.environ,
"PATH": str(tmp_path / "bin") + os.pathsep + os.environ["PATH"],
"GITHUB_WORKSPACE": tmp_path.as_posix(),
"GITHUB_RUN_ID": "543",
"GITHUB_RUN_ATTEMPT": "1",
"GITHUB_JOB": "python_tests",
"MOCK_PYTHON_LOG": (tmp_path / "python.log").as_posix(),
"MOCK_VENV_PATH": (tmp_path / "venv.path").as_posix(),
"MOCK_VENV_PYTHON": venv_python.as_posix(),
}
def run(missing_tool=None, **overrides):
effective_environment = {**environment, **overrides}
effective_environment = {name: value for name, value in effective_environment.items() if value is not None}
command = [shell, "ci/python-tests.sh"]
if missing_tool:
(tmp_path / "bin" / missing_tool).unlink()
# Git Bash adds its own tools to PATH on Windows startup. Limit
# PATH inside the shell to model a genuinely missing system tool.
command = [shell, "-c", 'PATH="$(pwd -P)/bin"\nexport PATH\n. ci/python-tests.sh']
result = subprocess.run(
command, cwd=tmp_path, env=effective_environment,
capture_output=True, text=True, timeout=30,
)
log = tmp_path / "python.log"
calls = log.read_text().splitlines() if log.exists() else []
return SimpleNamespace(result=result, calls=calls, root=tmp_path)
return run
@pytest.mark.parametrize("overrides, expected_code, expected_calls", [
({}, 0, ["-m pip install .[dev]", "-m pytest --junitxml=reports/pytest.xml"]),
({"MOCK_PYTEST_EXIT": "1"}, 1, ["-m pip install .[dev]", "-m pytest --junitxml=reports/pytest.xml"]),
({"MOCK_PIP_EXIT": "42"}, 42, ["-m pip install .[dev]"]),
])
def test_python_job_isolates_dependencies_and_cleans_venv(python_ci, overrides, expected_code, expected_calls):
outcome = python_ci(**overrides)
assert outcome.result.returncode == expected_code, outcome.result.stderr
assert outcome.calls == expected_calls
assert (outcome.root / "venv.path").is_file()
assert ".venv-ci-543-1-python_tests." in (outcome.root / "venv.path").read_text()
assert not list(outcome.root.glob(".venv-ci-*")), "The job must clean its virtual environment even when tests or installation fail"
assert (outcome.root / "reports/pytest.xml").exists() == ("MOCK_PIP_EXIT" not in overrides)
@pytest.mark.parametrize("required_tool", ["python3", "bash", "openssl", "ssh-keygen"])
def test_python_job_requires_system_tools_instead_of_skipping_tests(python_ci, required_tool):
outcome = python_ci(missing_tool=required_tool)
assert outcome.result.returncode != 0
assert f"Required runner tool is missing: {required_tool}" in outcome.result.stderr
assert not outcome.calls
assert not list(outcome.root.glob(".venv-ci-*"))
@pytest.mark.parametrize("overrides", [
{"GITHUB_RUN_ID": "../invalid"},
{"GITHUB_RUN_ATTEMPT": "invalid"},
{"GITHUB_RUN_ATTEMPT": None},
{"GITHUB_JOB": "../invalid"},
{"GITHUB_JOB": "has space"},
{"GITHUB_JOB": "-invalid"},
{"GITHUB_JOB": ""},
{"MOCK_PYTHON_VERSION_EXIT": "1"},
])
def test_python_job_rejects_invalid_job_or_python_version_before_installing(python_ci, overrides):
outcome = python_ci(**overrides)
assert outcome.result.returncode != 0
assert not outcome.calls
assert not list(outcome.root.glob(".venv-ci-*"))
def test_python_job_names_venv_for_the_run_attempt_and_job(python_ci):
outcome = python_ci(GITHUB_RUN_ATTEMPT="2", GITHUB_JOB="python-tests")
assert outcome.result.returncode == 0, outcome.result.stderr
assert ".venv-ci-543-2-python-tests." in (outcome.root / "venv.path").read_text()
assert not list(outcome.root.glob(".venv-ci-*"))
+165
View File
@@ -0,0 +1,165 @@
"""Actual Runner/API protocol with Ed25519; only target-side phases are mocked."""
import base64
from copy import deepcopy
import io
import json
import re
import urllib.error
import urllib.parse
from unittest.mock import Mock
import tomllib
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
import pytest
from provisioner.runner import API, Runner, TransportError
from test_acceptance import environment, prepared, answer, post, enroll, signed
class Key:
def __init__(self):
self.key = Ed25519PrivateKey.generate()
self.public_key = base64.b64encode(self.key.public_key().public_bytes_raw()).decode()
def sign(self, message):
return base64.b64encode(self.key.sign(message)).decode()
class TestOpener:
__test__ = False
def __init__(self, client):
self.client = client
self.drop_complete = False
self.drop_cancel = False
def open(self, request, timeout):
path = urllib.parse.urlsplit(request.full_url).path
result = self.client.request(request.method, path, content=request.data, headers=dict(request.header_items()))
if result.status_code >= 400:
raise urllib.error.HTTPError(request.full_url,result.status_code,result.text,result.headers,io.BytesIO(result.content))
if self.drop_complete and path.endswith("/complete"):
self.drop_complete = False
raise TransportError("Simulated lost completion acknowledgement")
if self.drop_cancel and path.endswith("/events") and b"run.cancelled" in (request.data or b""):
self.drop_cancel = False
raise TransportError("Simulated lost cancellation acknowledgement")
return io.BytesIO(result.content)
def make_runner(prepared,tmp_path,monkeypatch):
response = answer(prepared)
assert response.status_code == 200,response.text
bootstrap = prepared["client"].get(tomllib.loads(response.text)["first-boot"]["url"]).text
encoded = re.search(r"config = base64.b64decode\('([^']+)'\)",bootstrap).group(1)
config = json.loads(base64.b64decode(encoded))
key = Key()
api = API(config,key)
api.opener = TestOpener(prepared["client"])
runner = Runner(config,tmp_path / "target",api)
runner.key = key
monkeypatch.setattr("provisioner.runner.discover_identities",lambda *args:prepared["identities"])
return runner,api
@pytest.mark.parametrize("phases",[[0,0],[1,0,0]])
def test_actual_protocol_completes_verified_check_or_apply(prepared,tmp_path,monkeypatch,phases):
runner,api = make_runner(prepared,tmp_path,monkeypatch)
runner.execute = Mock(side_effect=phases)
result = runner.run()
assert result == 0,runner.state
assert runner.state["status"] == "succeeded"
detail = prepared["client"].get(f"/api/v1/runs/{runner.config['run_id']}").json()
assert detail["status"] == "succeeded"
assert detail["steps"][0]["status"] == "succeeded"
assert [item["type"] for item in detail["events"]] == ["step.started","step.succeeded"]
def test_runner_retries_lost_completion_without_running_scripts(prepared,tmp_path,monkeypatch):
runner,api = make_runner(prepared,tmp_path,monkeypatch)
runner.execute = Mock(return_value=0)
api.opener.drop_complete = True
assert runner.run() == 75,runner.state
assert runner.state["status"] == "completion_pending"
resumed = Runner(runner.config,runner.directory,api)
resumed.execute = Mock()
assert resumed.run() == 0,resumed.state
resumed.execute.assert_not_called()
def test_actual_reboot_resumes_same_verified_step(prepared,tmp_path,monkeypatch):
runner,api = make_runner(prepared,tmp_path,monkeypatch)
runner.execute = Mock(side_effect=[1,194])
assert runner.run() == 194,runner.state
detail = prepared["client"].get(f"/api/v1/runs/{runner.config['run_id']}").json()
assert detail["status"] == "reboot_pending"
resumed = Runner(runner.config,runner.directory,api)
resumed.boot_id = "new-boot-id"
resumed.execute = Mock(return_value=0)
assert resumed.run() == 0,resumed.state
assert [call.args[1] for call in resumed.execute.call_args_list] == ["check","verify"]
def test_failed_verification_requires_explicit_operator_resume(prepared,tmp_path,monkeypatch):
runner,api = make_runner(prepared,tmp_path,monkeypatch)
runner.execute = Mock(side_effect=[1,0,2])
assert runner.run() == 75,runner.state
detail = prepared["client"].get(f"/api/v1/runs/{runner.config['run_id']}").json()
assert detail["status"] == "needs_review"
waiting = Runner(runner.config,runner.directory,api)
waiting.execute = Mock()
assert waiting.run() == 75,waiting.state
waiting.execute.assert_not_called()
detail = prepared["client"].get(f"/api/v1/runs/{runner.config['run_id']}").json()
post(prepared["client"],f"/api/v1/runs/{runner.config['run_id']}/resume",{"expected_version":detail["version"],"reason":"Examined interrupted test step"},prepared["csrf"])
resumed = Runner(runner.config,runner.directory,api)
resumed.execute = Mock(return_value=0)
assert resumed.run() == 0,resumed.state
def test_lost_cancellation_acknowledgement_is_idempotent(prepared,tmp_path,monkeypatch):
runner,api = make_runner(prepared,tmp_path,monkeypatch)
# Enrollment before cancellation, without executing any module.
api.request("POST","/agent/v1/enroll",{"run_id":runner.config["run_id"],"enrollment_secret":runner.config["enrollment_secret"],"public_key":runner.key.public_key,"identities":prepared["identities"],"boot_id":runner.boot_id},signed=False)
runner.state["enrolled"] = True
runner.save()
detail = prepared["client"].get(f"/api/v1/runs/{runner.config['run_id']}").json()
post(prepared["client"],f"/api/v1/runs/{runner.config['run_id']}/cancel",{"expected_version":detail["version"],"reason":"Cancel test at safe boundary"},prepared["csrf"])
api.opener.drop_cancel = True
runner.execute = Mock()
assert runner.run() == 75,runner.state
resumed = Runner(runner.config,runner.directory,api)
resumed.execute = Mock()
assert resumed.run() == 0,resumed.state
resumed.execute.assert_not_called()
assert resumed.state["status"] == "cancelled"
def test_step_logs_redact_secrets_without_corrupting_json(prepared):
key,_,_ = enroll(prepared)
run_id = prepared["run"]["id"]
from test_acceptance import ROOT_HASH
result = signed(prepared,key,"POST",f"/agent/v1/runs/{run_id}/logs",{"chunks":[{"sequence":1,"step_id":"verify","text":f'password=abc\nquoted secret: "{ROOT_HASH}" token=xyz'}]})
assert result.status_code == 200,result.text
logs = prepared["client"].get(f"/api/v1/runs/{run_id}").json()["logs"]
assert ROOT_HASH not in json.dumps(logs)
assert "[REDACTED]" in logs[0]["text"]
def test_optional_failure_does_not_bypass_required_final_verification(prepared,tmp_path,monkeypatch):
client,csrf = prepared["client"],prepared["csrf"]
old = client.get(f"/api/v1/runs/{prepared['run']['id']}").json()
post(client,f"/api/v1/runs/{old['id']}/cancel",{"expected_version":old["version"],"reason":"Replace prepared test profile"},csrf)
profile = post(client,"/api/v1/profiles",{"name":"optional-then-required","kind":"postinstall","target_builds":["9.1-1"],"steps":[{"id":"optional","module_id":prepared["module"]["id"],"required":False},{"id":"final","module_id":prepared["module"]["id"],"required":True}]},csrf)
post(client,f"/api/v1/profiles/{profile['id']}/publish",{"test_evidence":"Synthetic optional failure protocol test","reason":"Test full runner protocol"},csrf)
host = client.get(f"/api/v1/hosts/{prepared['host']['id']}").json()
updated = client.patch(f"/api/v1/hosts/{host['id']}",json={"expected_version":host["version"],"postinstall_profile_id":profile["id"]},headers=csrf)
assert updated.status_code == 200,updated.text
host = updated.json()
prepared["run"] = post(client,f"/api/v1/hosts/{host['id']}/approve-install",{"expected_version":host["version"],"confirmation":host["fqdn"],"disks_confirmed":True,"reason":"Approve simulated optional failure run"},csrf)
runner,api = make_runner(prepared,tmp_path,monkeypatch)
runner.execute = Mock(side_effect=[2,0,0])
assert runner.run() == 0,runner.state
detail = client.get(f"/api/v1/runs/{runner.config['run_id']}").json()
assert [(s["step_id"],s["status"]) for s in detail["steps"]] == [("optional","failed"),("final","succeeded")]
assert detail["status"] == "succeeded"
+396
View File
@@ -0,0 +1,396 @@
"""Runner recovery tests use mocked phases: no provisioning code runs on this host."""
import base64
import ast
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import time
from unittest.mock import Mock
import pytest
from provisioner.bootstrap import render_bootstrap
from provisioner.builtin_modules import catalog
from provisioner.runner import API, DeviceKey, Halt, RebootRequested, Runner, TransportError, canonical_json, discover_identities
SOURCE = b"#!/bin/bash\nexit 0\n"
DIGEST = hashlib.sha256(SOURCE).hexdigest()
class FakeAPI:
def __init__(self, manifest):
self.manifest = manifest
self.artifact = SOURCE
self.events = []
self.logs = []
self.secrets = {"password": "secret-value-do-not-log"}
self.calls = []
self.action = "run"
self.version = 1
self.offline = False
self.drop_completion = False
def request(self, method, path, payload=None, **kwargs):
self.calls.append((method, path, payload))
if self.offline:
raise TransportError("offline")
if path.endswith("/lease"):
return {"action": self.action, "expires_at": time.time() + 900, "run_version": self.version}
if path.endswith("/manifest"):
return self.manifest
if "/artifacts/" in path:
return self.artifact
if "/secrets/" in path:
return self.secrets
if path.endswith("/events"):
self.events.extend(payload["events"])
return {"ack_sequence": payload["events"][-1]["sequence"]}
if path.endswith("/logs"):
self.logs.extend(payload["chunks"])
return {"ack_sequence": payload["chunks"][-1]["sequence"]}
if path.endswith("/complete"):
if self.drop_completion:
self.drop_completion = False
raise TransportError("completion response lost")
return {"status": "succeeded"}
raise AssertionError(path)
@pytest.fixture
def setup_runner(tmp_path):
step = {"id": "example", "name": "Example", "digest": DIGEST, "parameters": {},
"timeout_seconds": 60, "retry_safe": False, "dependencies": [], "required": True}
manifest = {"run_id": "run-example", "steps": [step], "reboot_budget": 1}
config = {"api_url": "https://provision.example.test", "run_id": manifest["run_id"],
"enrollment_secret": "enrollment-only", "identities": [],
"manifest_digest": hashlib.sha256(canonical_json(manifest)).hexdigest()}
api = FakeAPI(manifest)
runner = Runner(config, tmp_path, api)
runner.state["enrolled"] = True
runner.save()
return runner, api, step
def test_digest_mismatch_never_executes(setup_runner):
runner, api, step = setup_runner
api.artifact = b"tampered"
runner.execute = Mock()
assert runner.run() == 75
assert runner.state["status"] == "needs_review"
assert "digest mismatch" in runner.state["reason"]
runner.execute.assert_not_called()
def test_cached_artifact_is_revalidated_before_phase(setup_runner):
runner, api, step = setup_runner
path = runner.artifact(step)
path.write_bytes(b"corrupted cached content")
with pytest.raises(Halt, match="digest mismatch"):
runner.execute(step, "check", path, runner.directory / "unused-parameters.json")
def test_apply_checkpoint_is_durable_before_mutation(setup_runner):
runner, api, step = setup_runner
phases = []
def execute(step, phase, artifact, parameters):
phases.append(phase)
if phase == "apply":
state = json.loads(runner.state_path.read_text())
assert state["steps"][step["id"]]["status"] == "applying"
assert api.events[-1]["type"] == "step.started"
assert json.loads(parameters.read_text())["secrets"] == api.secrets
return 1 if phase == "check" else 0
runner.execute = execute
assert runner.run() == 0
assert phases == ["check", "apply", "verify"]
assert runner.state["status"] == "succeeded"
assert not (runner.directory / "step-parameters.json").exists()
assert "secret-value" not in runner.state_path.read_text()
def test_interrupted_non_repeatable_step_requires_review(setup_runner):
runner, api, step = setup_runner
runner.state["steps"]["example"] = {"status": "applying", "attempt": 1}
runner.execute = Mock(side_effect=[1, 1])
runner.run()
assert [call.args[1] for call in runner.execute.call_args_list] == ["check", "verify"]
assert runner.state["status"] == "needs_review"
assert "cannot be repeated safely" in runner.state["reason"]
def test_interrupted_converged_step_is_verified_without_apply(setup_runner):
runner, api, step = setup_runner
runner.state["steps"]["example"] = {"status": "applying", "attempt": 1}
runner.execute = Mock(return_value=0)
runner.run()
assert [call.args[1] for call in runner.execute.call_args_list] == ["check", "verify"]
assert runner.state["status"] == "succeeded"
def test_retry_safe_interrupted_step_can_reapply(setup_runner):
runner, api, step = setup_runner
step["retry_safe"] = True
runner.state["steps"]["example"] = {"status": "applying", "attempt": 1}
runner.execute = Mock(side_effect=[1, 1, 0, 0])
runner.run_step(step, 1)
assert [call.args[1] for call in runner.execute.call_args_list] == ["check", "verify", "apply", "verify"]
assert runner.state["steps"]["example"]["status"] == "succeeded"
assert runner.state["steps"]["example"]["attempt"] == 2
def test_success_exit_without_verification_is_not_success(setup_runner):
runner, api, step = setup_runner
runner.execute = Mock(side_effect=[1, 0, 2])
runner.run()
assert runner.state["status"] == "needs_review"
assert runner.state["steps"]["example"]["status"] == "failed"
assert not any(path.endswith("/complete") for _, path, _ in api.calls)
def test_terminal_response_loss_retries_completion_without_module_execution(setup_runner):
runner, api, step = setup_runner
runner.execute = Mock(return_value=0)
api.drop_completion = True
assert runner.run() == 75
assert runner.state["status"] == "completion_pending"
restarted = Runner(runner.config, runner.directory, api)
restarted.execute = Mock()
assert restarted.run() == 0
restarted.execute.assert_not_called()
assert restarted.state["status"] == "succeeded"
assert len([path for _, path, _ in api.calls if path.endswith("/complete")]) == 2
def test_event_queue_survives_network_loss_and_acknowledges(setup_runner):
runner, api, step = setup_runner
runner.event("step.started", "example")
api.offline = True
with pytest.raises(TransportError):
runner.flush()
restarted = Runner(runner.config, runner.directory, api)
assert restarted.state["events"][0]["sequence"] == 1
api.offline = False
restarted.flush()
assert restarted.state["events"] == []
assert api.events[0]["sequence"] == 1
def test_reboot_checkpoint_waits_for_changed_boot_id(setup_runner):
runner, api, step = setup_runner
runner.execute = Mock(side_effect=[1, 194])
assert runner.run() == 194
assert runner.state["status"] == "reboot_pending"
restarted = Runner(runner.config, runner.directory, api)
restarted.execute = Mock(return_value=0)
assert restarted.run() == 194
restarted.execute.assert_not_called()
restarted.boot_id = "next-boot"
assert restarted.run() == 0
assert [call.args[1] for call in restarted.execute.call_args_list] == ["check", "verify"]
assert any(event["type"] == "run.resumed" for event in api.events)
def test_reboot_budget_cannot_be_exceeded(setup_runner):
runner, api, step = setup_runner
runner.execute = Mock(side_effect=[1, 194])
runner.state["reboot_count"] = 1
with pytest.raises(Halt, match="budget exhausted"):
runner.run_step(step, 1)
def test_review_requires_explicit_server_resume_version(setup_runner):
runner, api, step = setup_runner
runner.state.update(status="needs_review", halted_version=1, review_started_at=time.time())
runner.execute = Mock(return_value=0)
assert runner.run() == 75
runner.execute.assert_not_called()
api.version = 2
assert runner.run() == 0
assert runner.state["status"] == "succeeded"
def test_secret_redaction_happens_before_durable_log_write(setup_runner):
runner, api, step = setup_runner
runner.secret_values = ["super-secret", "secret"]
runner.log("example", "output super-secret and secret")
state = runner.state_path.read_text()
assert "super-secret" not in state
assert runner.state["logs"][0]["text"] == "output [REDACTED] and [REDACTED]"
def test_expired_lease_prevents_any_module_execution(setup_runner):
runner, api, step = setup_runner
api.action = "wait"
runner.execute = Mock()
assert runner.run() == 75
runner.execute.assert_not_called()
def test_tls_cannot_be_disabled(setup_runner):
runner, api, step = setup_runner
with pytest.raises(Halt, match="HTTPS"):
API({**runner.config, "api_url": "http://example.test"}, Mock())
with pytest.raises(ValueError, match="HTTPS"):
render_bootstrap({**runner.config, "api_url": "http://example.test"})
def test_bootstrap_is_self_contained_persistent_and_bounded(setup_runner):
runner, api, step = setup_runner
result = render_bootstrap({**runner.config, "ca_pem": "TEST CA"})
assert len(result.encode()) < 1024 * 1024
assert result.index("persist(etc / 'config.json'") < result.index("systemctl enable --now")
embedded_python = result.split("<<'PVE_BOOTSTRAP_PY'\n", 1)[1].split("\nPVE_BOOTSTRAP_PY", 1)[0]
compile(embedded_python, "bootstrap-embedded", "exec")
assert "Restart=on-failure" in result
assert "trusted-ca.pem" in result
def test_module_drafts_have_compilable_embedded_python_and_no_release_claims():
modules = catalog()
assert len(modules) == 8
for module in modules:
assert module["status"] == "draft"
assert not module["test_evidence"] and not module["target_builds"]
python = module["source"].split("<<'PY'\n", 1)[1].rsplit("\nPY", 1)[0]
compile(python, module["id"], "exec")
assert 'case "${1:-}" in check|apply|verify)' in module["source"]
def test_identity_binding_uses_observed_target_data(tmp_path):
dmi = tmp_path / "class/dmi/id"
dmi.mkdir(parents=True)
(dmi / "product_serial").write_text("SERIAL-123\n")
observed = discover_identities([{"kind": "serial", "value": "Serial-123"}], tmp_path)
assert observed == [{"kind": "serial", "value": "serial-123"}]
with pytest.raises(Halt, match="do not match"):
discover_identities([{"kind": "serial", "value": "different-host"}], tmp_path)
def test_logs_remain_bounded_with_contiguous_sequence(setup_runner):
runner, api, step = setup_runner
for index in range(12):
runner.log("example", "x" * 131072)
chunks = runner.state["logs"]
assert sum(len(chunk["text"].encode()) for chunk in chunks) <= 1024 * 1024
assert all(len(chunk["text"]) <= 16384 for chunk in chunks)
assert [chunk["sequence"] for chunk in chunks] == list(range(1, len(chunks) + 1))
runner.flush()
assert not runner.state["logs"]
def test_openssl_device_key_signatures_and_key_reuse(tmp_path, monkeypatch):
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
git_bin = Path("C:/Program Files/Git/usr/bin")
if not shutil.which("openssl") and (git_bin / "openssl.exe").exists():
monkeypatch.setenv("PATH", str(git_bin) + os.pathsep + os.environ["PATH"])
if not shutil.which("openssl"):
pytest.skip("OpenSSL is unavailable on this test workstation")
key = DeviceKey(tmp_path)
key.ensure()
public_key = key.public_key
body = canonical_json({"run_id": "run-test"})
message = f"POST\n/agent/v1/lease\n1234567890\nonce-123456789\n{hashlib.sha256(body).hexdigest()}".encode()
signature = base64.b64decode(key.sign(message))
Ed25519PublicKey.from_public_bytes(base64.b64decode(public_key)).verify(signature, message)
key.ensure()
assert key.public_key == public_key
if os.name == "posix":
assert key.path.stat().st_mode & 0o777 == 0o600
def test_bash_syntax_without_executing_provisioning_modules(setup_runner):
runner, api, step = setup_runner
git_bash = Path("C:/Program Files/Git/usr/bin/bash.exe")
bash = str(git_bash) if git_bash.exists() else shutil.which("bash")
if not bash:
pytest.skip("Bash parser unavailable")
sources = [module["source"] for module in catalog()] + [render_bootstrap(runner.config)]
for source in sources:
result = subprocess.run([bash, "-n"], input=source, text=True, capture_output=True, timeout=15)
assert result.returncode == 0, result.stderr
def module_helper(module_id, function_name, namespace=None):
"""Load one pure/helper function without executing the module's host actions."""
source = next(module["source"] for module in catalog() if module["id"] == module_id)
python = source.split("<<'PY'\n", 1)[1].rsplit("\nPY", 1)[0]
function = next(node for node in ast.parse(python).body if isinstance(node, ast.FunctionDef) and node.name == function_name)
scope = {} if namespace is None else namespace
exec(compile(ast.Module(body=[function], type_ignores=[]), module_id, "exec"), scope)
return scope[function_name]
def test_ssh_rejects_truncated_public_key_before_writing_accounts(monkeypatch):
git_bin = Path("C:/Program Files/Git/usr/bin")
if not shutil.which("ssh-keygen") and (git_bin / "ssh-keygen.exe").exists():
monkeypatch.setenv("PATH", str(git_bin) + os.pathsep + os.environ["PATH"])
if not shutil.which("ssh-keygen"):
pytest.skip("OpenSSH public-key validator unavailable")
validate = module_helper("ssh", "validate_public_key", {"base64": base64, "os": os,
"subprocess": subprocess, "tempfile": tempfile})
malformed = base64.b64encode((11).to_bytes(4, "big") + b"ssh-ed25519" + b"x").decode()
with pytest.raises(SystemExit, match="OpenSSH rejected"):
validate("ssh-ed25519 " + malformed)
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
valid = Ed25519PrivateKey.generate().public_key().public_bytes(Encoding.OpenSSH, PublicFormat.OpenSSH).decode()
assert validate(valid) is None
def test_repository_verification_rejects_stale_other_suite_indexes():
verify = module_helper("repositories", "has_repository_indexes")
policy = " 500 https://repo.example.test/debian bookworm/main amd64 Packages\n"
assert verify(policy, "https://repo.example.test/debian", "bookworm", ["main"])
assert not verify(policy, "https://repo.example.test/debian", "trixie", ["main"])
assert not verify(policy, "https://repo.example.test/debian", "bookworm", ["main", "contrib"])
assert not verify(policy, "https://repo.example.test/deb", "bookworm", ["main"])
def test_pending_reboot_waits_for_permission(setup_runner):
runner, api, step = setup_runner
runner.state.update(status="reboot_pending", reboot_boot_id=runner.boot_id)
api.action = "wait"
assert runner.run() == 75
assert runner.state["status"] == "reboot_pending"
def test_review_deadline_stops_even_when_authorization_is_unavailable(setup_runner):
runner, api, step = setup_runner
runner.state.update(status="needs_review", review_started_at=time.time() - 86401)
api.offline = True
assert runner.run() == 0
assert api.calls == []
@pytest.mark.skipif(os.name != "posix", reason="Requires native Linux subprocess supervision")
def test_native_phase_obeys_shared_timeout(setup_runner):
runner, api, step = setup_runner
api.artifact = b"#!/bin/bash\nprintf 'timeout-probe\\n'\nsleep 30\n"
step["digest"] = hashlib.sha256(api.artifact).hexdigest()
runner.heartbeat = Mock()
artifact = runner.artifact(step)
runner.step_deadline = time.monotonic() + 1
before = time.monotonic()
result = runner.execute(step, "apply", artifact, runner.directory / "unused.json")
assert result == 124
assert time.monotonic() - before < 5
assert "timeout-probe" in runner.state["logs"][0]["text"]
@pytest.mark.skipif(os.name != "posix", reason="Requires native Linux subprocess supervision")
def test_native_output_redacts_secret_crossing_capture_boundary(setup_runner):
runner, api, step = setup_runner
secret = "sensitive-value-across-output-boundary"
payload = "x" * (131072 - 5) + secret
api.artifact = ("#!/bin/bash\nprintf '%s' '" + payload + "'\n").encode()
step["digest"] = hashlib.sha256(api.artifact).hexdigest()
runner.secret_values = [secret]
runner.heartbeat = Mock()
result = runner.execute(step, "check", runner.artifact(step), runner.directory / "unused.json")
assert result == 0
assert "sensi" not in "".join(chunk["text"] for chunk in runner.state["logs"])