feat: add Proxmox provisioning service with CI and deployment tooling
This commit is contained in:
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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-*"))
|
||||
@@ -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"
|
||||
@@ -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"])
|
||||
Reference in New Issue
Block a user