Add fail-closed GCP no-send IAP runner
This commit is contained in:
parent
3d35a7fafb
commit
b9081a537c
6 changed files with 4313 additions and 93 deletions
|
|
@ -24,38 +24,46 @@ until the GCP service has passed soak and restore testing.
|
||||||
|
|
||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
- Current main/base: `3f09bb2b6c8ac3113fc1743529a03c0e9b6ea587`.
|
- Current main/base: `3d35a7fafb41b61a94cb75de9c231afdc08b7f2b`.
|
||||||
- PRs #220, #221, #226, #227, #228, #229, and #230 are merged. Main therefore contains the
|
- PRs #220, #221, #226, #227, #228, #229, #230, and #231 are merged. Main
|
||||||
source-first V3 rebuild foundation, the PostgreSQL large-object boundary,
|
therefore contains the source-first V3 rebuild foundation, the PostgreSQL
|
||||||
bounded Observatory prerequisite preservation, Cloud SQL provider-database
|
least-privilege boundary, the exact-revision publication workflow, the
|
||||||
residual handling, and the manual exact-revision no-send publication
|
hardened no-send installer with automatic transaction rollback, and the
|
||||||
workflow plus the composable prerequisite/runtime ACL boundary and normalized
|
container-bound `verify-database` command.
|
||||||
copied-artifact directory contract.
|
- The exact-main GCP preflight passed for `3d35a7f`: `teleo-staging-1` and the
|
||||||
- The exact-main GCP preflight passed for `3f09bb2`: the staging VM and private
|
private PostgreSQL 16 Cloud SQL endpoint were reachable, Artifact Registry was
|
||||||
PostgreSQL 16 Cloud SQL endpoint were reachable, the Artifact Registry
|
immutable, and neither the VM nor Cloud SQL had a public attachment.
|
||||||
repository was immutable, and no public database attachment was present.
|
- Manual publication run #4 passed for that exact revision. The retained GitHub
|
||||||
- Manual publication run #3 passed for that exact revision. Artifact Registry
|
artifact archive hashes to
|
||||||
independently reports immutable manifest digest
|
`sha256:dbe7a34f2c03e2240de54c40afe0aefae00013dff51e17913140fa38f6e119ea`.
|
||||||
`sha256:c326154acd0b9ae468449abe1c5fd398b6757f375a1a86fdb40e982c515a19a4`
|
Its self-hashed publication journal records `fresh_push`, not reuse or
|
||||||
|
reconciliation, and binds release hash
|
||||||
|
`3e7b00d5ac12308cfc35e3b507258b659e6d95926f3c6241f67240d44105b72f`.
|
||||||
|
- Artifact Registry independently reports immutable manifest digest
|
||||||
|
`sha256:c69781400eecd5d2a932b9c7c988ed460365b8bc3f592fe95e51e4c1edf7b230`
|
||||||
and config digest
|
and config digest
|
||||||
`sha256:5c4d5e1bcfc8cd112e5c999217fce064b5be718409708a154e9d58f007a521ad`,
|
`sha256:c5fc9e715bcbee7d4fcc512b0c38aa4acee589836fbc11dbe9c6c34194959438`,
|
||||||
with the expected revision and package labels. This is a valid intermediate
|
with the expected revision, input, artifact, identity, runtime, and target
|
||||||
candidate, not an approved deployment candidate.
|
labels. The release descriptor and build/push receipt validate against current
|
||||||
- The original run artifact still needs to be retained and checked for its
|
package code.
|
||||||
non-reconstructible publication-outcome journal. Final registry state alone
|
- A read-only IAM check confirms the scoped runtime secret exists with one
|
||||||
cannot prove whether the run freshly pushed, reused, or reconciled a candidate.
|
enabled version, grants `roles/secretmanager.secretAccessor` only to
|
||||||
- The published image cannot yet perform a container-bound database proof. A
|
`sa-teleo-staging-vm`, and the administrator secret does not grant that VM
|
||||||
narrow follow-up is adding a hash-bound `verify-database` command that uses the
|
access. The VM also has repository-level Artifact Registry reader and no
|
||||||
attached staging service-account metadata identity, the scoped secret, and the
|
detected project-level role.
|
||||||
existing least-privilege SQL verifier from inside the running container. The
|
- The native PostgreSQL `leoclean_kb_runtime` role and password match remain
|
||||||
next deployable image must be published from the merge revision containing
|
unproven. Cloud SQL control-plane user listing is not authoritative for native
|
||||||
that verifier.
|
PostgreSQL roles; the container-bound permission verifier is the intended
|
||||||
- The GCP VM, private Cloud SQL instance, and scoped Secret Manager secret are
|
proof.
|
||||||
known to exist, but the scoped VM secret binding and runtime role have not been
|
- Branch `feat/leoclean-iap-installer-runner` now contains the local review
|
||||||
activated for this service.
|
candidate for the fixed-target IAP transfer/pull/install/restart/verify
|
||||||
- Live Cloud SQL provisioning of `leoclean_kb_runtime`, VM access to the scoped
|
runner and receipt-bound post-success rollback. It remains unmerged and has
|
||||||
secret, installation of the no-send service, effective running-process
|
not been used against GCP. The proportional suite passes 308 tests; the full
|
||||||
identity, restart behavior, and rollback remain unproven.
|
suite passes 3,013 tests with 3 skips when the repository's Python 3.11
|
||||||
|
environment is first on `PATH`.
|
||||||
|
- Installation of the no-send service, effective running-process identity,
|
||||||
|
database permissions, restart behavior, and post-success rollback remain
|
||||||
|
unproven.
|
||||||
- The source-first V3 reconstruction code is on main, but no canonical GCP
|
- The source-first V3 reconstruction code is on main, but no canonical GCP
|
||||||
promotion is implied by that merge.
|
promotion is implied by that merge.
|
||||||
- Telegram still does not route to GCP. The VPS, production traffic, and
|
- Telegram still does not route to GCP. The VPS, production traffic, and
|
||||||
|
|
@ -63,18 +71,22 @@ until the GCP service has passed soak and restore testing.
|
||||||
|
|
||||||
## Execution order
|
## Execution order
|
||||||
|
|
||||||
1. Review and merge the container-bound database verifier. Run its disposable
|
1. Completed: merge the container-bound database verifier, run its disposable
|
||||||
PostgreSQL and OCI fail-closed tests, then publish one exact-main replacement
|
PostgreSQL and OCI fail-closed tests, publish the exact-main candidate, retain
|
||||||
candidate and retain the complete publication artifact.
|
the complete artifact, and cross-check its receipts and registry digests.
|
||||||
2. Add the narrow IAP transfer/install/restart/rollback runner that consumes the
|
2. Local implementation complete; review and merge the narrow fixed-target IAP
|
||||||
finalized release bundle, pulls only its immutable digest, invokes the existing
|
transfer/install/restart/verify runner plus receipt-bound post-success
|
||||||
installer, and binds the live container proof to systemd/container identity.
|
rollback. It consumes only the finalized release artifact and expected
|
||||||
3. Provision `leoclean_kb_runtime`, grant only the staging VM access to the
|
hashes, pulls only the immutable digest, preserves ephemeral credential
|
||||||
scoped secret, and prove the Cloud SQL role's allowed reads and
|
cleanup, and emits sanitized self-hashed receipts.
|
||||||
function-only proposal staging plus denied writes and escalation.
|
3. Prove or provision `leoclean_kb_runtime`. The VM already has access only to
|
||||||
4. Install the exact immutable image as a separate no-send GCP service. Prove
|
the scoped secret; verify that secret matches the native role, allowed reads,
|
||||||
the running process uses the scoped identity without administrator fallback,
|
transaction-rolled-back function-only proposal staging, and denied writes or
|
||||||
then prove restart and rollback.
|
escalation.
|
||||||
|
4. Install the exact immutable image as a separate no-send GCP service. Bind
|
||||||
|
systemd, PID/invocation, full container ID, image/config digests, and the
|
||||||
|
container database receipt; restart once and then execute the reviewed
|
||||||
|
post-success rollback proof.
|
||||||
5. Reconstruct or reconcile the V3 knowledge database into a separately
|
5. Reconstruct or reconcile the V3 knowledge database into a separately
|
||||||
identified canonical candidate and prove row/hash/source receipts before
|
identified canonical candidate and prove row/hash/source receipts before
|
||||||
any promotion.
|
any promotion.
|
||||||
|
|
@ -88,7 +100,7 @@ until the GCP service has passed soak and restore testing.
|
||||||
|
|
||||||
## Authority and rollback boundaries
|
## Authority and rollback boundaries
|
||||||
|
|
||||||
- Database role provisioning can be rolled back independently by refencing
|
- Database role provisioning can be rolled back independently by revoking
|
||||||
the runtime role and removing its scoped secret access.
|
the runtime role and removing its scoped secret access.
|
||||||
- Service deployment can be rolled back independently to the prior immutable
|
- Service deployment can be rolled back independently to the prior immutable
|
||||||
image or stopped without changing canonical data.
|
image or stopped without changing canonical data.
|
||||||
|
|
|
||||||
1065
ops/gcp_leoclean_nosend_iap_remote.py
Normal file
1065
ops/gcp_leoclean_nosend_iap_remote.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -4,6 +4,8 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
import contextlib
|
import contextlib
|
||||||
import fcntl
|
import fcntl
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
@ -27,12 +29,15 @@ if str(CONTRACT_ROOT) not in sys.path:
|
||||||
|
|
||||||
from ops import gcp_leoclean_nosend_package as package # noqa: E402
|
from ops import gcp_leoclean_nosend_package as package # noqa: E402
|
||||||
|
|
||||||
INSTALL_RECEIPT_SCHEMA = "livingip.leocleanNoSendInstallReceipt.v1"
|
INSTALL_RECEIPT_SCHEMA = "livingip.leocleanNoSendInstallReceipt.v2"
|
||||||
|
ROLLBACK_STATE_SCHEMA = "livingip.leocleanNoSendRollbackState.v1"
|
||||||
|
ROLLBACK_RESULT_SCHEMA = "livingip.leocleanNoSendRollbackResult.v1"
|
||||||
SERVICE = package.SERVICE
|
SERVICE = package.SERVICE
|
||||||
SYSTEMCTL = "/usr/bin/systemctl"
|
SYSTEMCTL = "/usr/bin/systemctl"
|
||||||
MAX_COMMAND_OUTPUT_BYTES = 1024 * 1024
|
MAX_COMMAND_OUTPUT_BYTES = 1024 * 1024
|
||||||
MAX_RELEASE_BYTES = 8 * 1024 * 1024
|
MAX_RELEASE_BYTES = 8 * 1024 * 1024
|
||||||
MAX_SOURCE_BYTES = 8 * 1024 * 1024
|
MAX_SOURCE_BYTES = 8 * 1024 * 1024
|
||||||
|
MAX_ROLLBACK_STATE_BYTES = 32 * 1024 * 1024
|
||||||
HEX_64 = re.compile(r"^[0-9a-f]{64}$")
|
HEX_64 = re.compile(r"^[0-9a-f]{64}$")
|
||||||
HEX_40 = re.compile(r"^[0-9a-f]{40}$")
|
HEX_40 = re.compile(r"^[0-9a-f]{40}$")
|
||||||
HEX_32 = re.compile(r"^[0-9a-f]{32}$")
|
HEX_32 = re.compile(r"^[0-9a-f]{32}$")
|
||||||
|
|
@ -41,6 +46,7 @@ SOURCE_PATHS = (
|
||||||
"ops/install_gcp_leoclean_nosend_release.py",
|
"ops/install_gcp_leoclean_nosend_release.py",
|
||||||
"ops/leoclean_nosend_service_control.py",
|
"ops/leoclean_nosend_service_control.py",
|
||||||
)
|
)
|
||||||
|
MANAGED_FILE_ROLES = frozenset({"unit", "helper", "release", "receipt", "rollback_state"})
|
||||||
SERVICE_EXEC_PROPERTIES = ("ExecStartPre", "ExecStart", "ExecStop")
|
SERVICE_EXEC_PROPERTIES = ("ExecStartPre", "ExecStart", "ExecStop")
|
||||||
SERVICE_SECURITY_PROPERTIES = (
|
SERVICE_SECURITY_PROPERTIES = (
|
||||||
"RuntimeDirectory",
|
"RuntimeDirectory",
|
||||||
|
|
@ -134,6 +140,7 @@ class InstallPaths:
|
||||||
docker_config: Path
|
docker_config: Path
|
||||||
release: Path
|
release: Path
|
||||||
receipt: Path
|
receipt: Path
|
||||||
|
rollback_state: Path
|
||||||
lock: Path
|
lock: Path
|
||||||
proc_root: Path
|
proc_root: Path
|
||||||
dropin_directories: tuple[Path, ...]
|
dropin_directories: tuple[Path, ...]
|
||||||
|
|
@ -154,6 +161,7 @@ class InstallPaths:
|
||||||
docker_config=state / "docker-config",
|
docker_config=state / "docker-config",
|
||||||
release=state / "release.json",
|
release=state / "release.json",
|
||||||
receipt=state / "install-receipt.json",
|
receipt=state / "install-receipt.json",
|
||||||
|
rollback_state=state / "rollback-state.json",
|
||||||
lock=rooted("/run/leoclean-gcp-nosend-installer.lock"),
|
lock=rooted("/run/leoclean-gcp-nosend-installer.lock"),
|
||||||
proc_root=proc_root or rooted("/proc"),
|
proc_root=proc_root or rooted("/proc"),
|
||||||
dropin_directories=tuple(
|
dropin_directories=tuple(
|
||||||
|
|
@ -173,6 +181,7 @@ class FileSnapshot:
|
||||||
existed: bool
|
existed: bool
|
||||||
content: bytes = b""
|
content: bytes = b""
|
||||||
mode: int = 0
|
mode: int = 0
|
||||||
|
identity: tuple[int, ...] | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -465,12 +474,74 @@ def _snapshot(path: Path) -> FileSnapshot:
|
||||||
return FileSnapshot(False)
|
return FileSnapshot(False)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise InstallError("installed file could not be identified") from exc
|
raise InstallError("installed file could not be identified") from exc
|
||||||
_require(stat.S_ISREG(observed.st_mode), "installed path was not a regular file")
|
_require(
|
||||||
|
stat.S_ISREG(observed.st_mode) and observed.st_nlink == 1,
|
||||||
|
"installed path was not a regular file",
|
||||||
|
)
|
||||||
|
content = _read_stable_file(
|
||||||
|
path,
|
||||||
|
label="installed file",
|
||||||
|
max_bytes=MAX_ROLLBACK_STATE_BYTES,
|
||||||
|
expected_mode=stat.S_IMODE(observed.st_mode),
|
||||||
|
)
|
||||||
|
named = path.stat(follow_symlinks=False)
|
||||||
|
_require(
|
||||||
|
(named.st_dev, named.st_ino, named.st_mode, named.st_nlink)
|
||||||
|
== (observed.st_dev, observed.st_ino, observed.st_mode, observed.st_nlink),
|
||||||
|
"installed file changed while it was snapshotted",
|
||||||
|
)
|
||||||
|
return FileSnapshot(
|
||||||
|
True,
|
||||||
|
content,
|
||||||
|
stat.S_IMODE(observed.st_mode),
|
||||||
|
(
|
||||||
|
observed.st_dev,
|
||||||
|
observed.st_ino,
|
||||||
|
observed.st_mode,
|
||||||
|
observed.st_nlink,
|
||||||
|
observed.st_size,
|
||||||
|
observed.st_mtime_ns,
|
||||||
|
observed.st_ctime_ns,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_to_json(snapshot: FileSnapshot) -> dict[str, Any]:
|
||||||
|
if not snapshot.existed:
|
||||||
|
return {"existed": False}
|
||||||
|
_require(snapshot.identity is not None, "rollback snapshot identity was missing")
|
||||||
|
return {
|
||||||
|
"existed": True,
|
||||||
|
"mode": snapshot.mode,
|
||||||
|
"sha256": _sha256_bytes(snapshot.content),
|
||||||
|
"content_base64": base64.b64encode(snapshot.content).decode("ascii"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_from_json(value: Any, *, label: str) -> FileSnapshot:
|
||||||
|
_require(isinstance(value, dict), f"{label} was invalid")
|
||||||
|
if value == {"existed": False}:
|
||||||
|
return FileSnapshot(False)
|
||||||
|
_require(
|
||||||
|
set(value) == {"existed", "mode", "sha256", "content_base64"}
|
||||||
|
and value.get("existed") is True
|
||||||
|
and isinstance(value.get("mode"), int)
|
||||||
|
and not isinstance(value.get("mode"), bool)
|
||||||
|
and 0 <= value["mode"] <= 0o777
|
||||||
|
and isinstance(value.get("sha256"), str)
|
||||||
|
and HEX_64.fullmatch(value["sha256"]) is not None
|
||||||
|
and isinstance(value.get("content_base64"), str),
|
||||||
|
f"{label} was invalid",
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
content = path.read_bytes()
|
content = base64.b64decode(value["content_base64"], validate=True)
|
||||||
except OSError as exc:
|
except (ValueError, binascii.Error) as exc:
|
||||||
raise InstallError("installed file could not be read") from exc
|
raise InstallError(f"{label} was invalid") from exc
|
||||||
return FileSnapshot(True, content, stat.S_IMODE(observed.st_mode))
|
_require(
|
||||||
|
len(content) <= MAX_ROLLBACK_STATE_BYTES and _sha256_bytes(content) == value["sha256"],
|
||||||
|
f"{label} was invalid",
|
||||||
|
)
|
||||||
|
return FileSnapshot(True, content, value["mode"])
|
||||||
|
|
||||||
|
|
||||||
def _ensure_directory(path: Path, mode: int, created: list[Path]) -> None:
|
def _ensure_directory(path: Path, mode: int, created: list[Path]) -> None:
|
||||||
|
|
@ -573,19 +644,34 @@ def _publish_managed_file(
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def _restore_snapshot(path: Path, snapshot: FileSnapshot, installed_identity: tuple[int, int] | None) -> None:
|
def _restore_snapshot(
|
||||||
|
path: Path,
|
||||||
|
snapshot: FileSnapshot,
|
||||||
|
installed_identity: tuple[int, ...] | None,
|
||||||
|
) -> tuple[int, int] | None:
|
||||||
if installed_identity is not None:
|
if installed_identity is not None:
|
||||||
observed = path.stat(follow_symlinks=False)
|
observed = path.stat(follow_symlinks=False)
|
||||||
|
observed_identity = (
|
||||||
|
observed.st_dev,
|
||||||
|
observed.st_ino,
|
||||||
|
observed.st_mode,
|
||||||
|
observed.st_nlink,
|
||||||
|
observed.st_size,
|
||||||
|
observed.st_mtime_ns,
|
||||||
|
observed.st_ctime_ns,
|
||||||
|
)
|
||||||
_require(
|
_require(
|
||||||
stat.S_ISREG(observed.st_mode) and (observed.st_dev, observed.st_ino) == installed_identity,
|
stat.S_ISREG(observed.st_mode)
|
||||||
|
and (observed.st_dev, observed.st_ino) == installed_identity[:2]
|
||||||
|
and (len(installed_identity) == 2 or observed_identity == installed_identity),
|
||||||
"rollback refused a replaced installed file",
|
"rollback refused a replaced installed file",
|
||||||
)
|
)
|
||||||
if snapshot.existed:
|
if snapshot.existed:
|
||||||
_atomic_write(path, snapshot.content, snapshot.mode)
|
return _atomic_write(path, snapshot.content, snapshot.mode)
|
||||||
return
|
|
||||||
if path.exists() or path.is_symlink():
|
if path.exists() or path.is_symlink():
|
||||||
path.unlink()
|
path.unlink()
|
||||||
_fsync_directory(path.parent)
|
_fsync_directory(path.parent)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _dropins_absent(paths: InstallPaths) -> None:
|
def _dropins_absent(paths: InstallPaths) -> None:
|
||||||
|
|
@ -979,27 +1065,174 @@ def _read_managed_release(paths: InstallPaths) -> dict[str, Any] | None:
|
||||||
if not paths.release.exists() and not paths.release.is_symlink():
|
if not paths.release.exists() and not paths.release.is_symlink():
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
value = package.load_json(paths.release, "installed release descriptor")
|
raw = _read_stable_file(
|
||||||
|
paths.release,
|
||||||
|
label="installed release descriptor",
|
||||||
|
max_bytes=MAX_RELEASE_BYTES,
|
||||||
|
expected_mode=0o600,
|
||||||
|
)
|
||||||
|
value = _strict_json_bytes(raw, "installed release descriptor")
|
||||||
|
_require(isinstance(value, dict), "installed release descriptor was invalid")
|
||||||
package.validate_release_descriptor(value)
|
package.validate_release_descriptor(value)
|
||||||
except (OSError, package.PackageError) as exc:
|
except (OSError, InstallError, package.PackageError) as exc:
|
||||||
raise InstallError("installed release descriptor was invalid") from exc
|
raise InstallError("installed release descriptor was invalid") from exc
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _managed_files(paths: InstallPaths) -> dict[str, tuple[Path, int]]:
|
||||||
|
return {
|
||||||
|
"unit": (paths.unit, 0o644),
|
||||||
|
"helper": (paths.helper, 0o755),
|
||||||
|
"release": (paths.release, 0o600),
|
||||||
|
"receipt": (paths.receipt, 0o600),
|
||||||
|
"rollback_state": (paths.rollback_state, 0o600),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_strict_json_file(path: Path, *, label: str, max_bytes: int, mode: int) -> tuple[dict[str, Any], bytes]:
|
||||||
|
raw = _read_stable_file(path, label=label, max_bytes=max_bytes, expected_mode=mode)
|
||||||
|
value = _strict_json_bytes(raw, label)
|
||||||
|
_require(isinstance(value, dict), f"{label} was invalid")
|
||||||
|
return value, raw
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_rollback_state(value: Mapping[str, Any], *, current_release_sha256: str) -> None:
|
||||||
|
_require(
|
||||||
|
set(value)
|
||||||
|
== {
|
||||||
|
"schema",
|
||||||
|
"current_release_sha256",
|
||||||
|
"previous_files",
|
||||||
|
"previous_directories",
|
||||||
|
"previous_service",
|
||||||
|
"sha256",
|
||||||
|
}
|
||||||
|
and value.get("schema") == ROLLBACK_STATE_SCHEMA
|
||||||
|
and value.get("current_release_sha256") == current_release_sha256,
|
||||||
|
"rollback state was invalid",
|
||||||
|
)
|
||||||
|
previous_files = value.get("previous_files")
|
||||||
|
_require(
|
||||||
|
isinstance(previous_files, dict) and set(previous_files) == MANAGED_FILE_ROLES,
|
||||||
|
"rollback state was invalid",
|
||||||
|
)
|
||||||
|
decoded = {
|
||||||
|
role: _snapshot_from_json(previous_files[role], label=f"rollback {role} snapshot")
|
||||||
|
for role in sorted(previous_files)
|
||||||
|
}
|
||||||
|
prior_presence = {role: snapshot.existed for role, snapshot in decoded.items()}
|
||||||
|
_require(
|
||||||
|
not any(prior_presence.values()) or all(prior_presence.values()),
|
||||||
|
"rollback prior managed file set was partial",
|
||||||
|
)
|
||||||
|
previous_directories = value.get("previous_directories")
|
||||||
|
_require(
|
||||||
|
isinstance(previous_directories, dict)
|
||||||
|
and set(previous_directories) == {"state_dir_existed", "docker_config_existed"}
|
||||||
|
and all(isinstance(item, bool) for item in previous_directories.values()),
|
||||||
|
"rollback state was invalid",
|
||||||
|
)
|
||||||
|
previous_service = value.get("previous_service")
|
||||||
|
_require(
|
||||||
|
isinstance(previous_service, dict)
|
||||||
|
and set(previous_service) == {"posture", "active_state", "sub_state", "n_restarts"}
|
||||||
|
and previous_service.get("posture") in {"absent", "inactive", "active"}
|
||||||
|
and previous_service.get("active_state") in {"inactive", "active"}
|
||||||
|
and previous_service.get("sub_state") in {"dead", "running"}
|
||||||
|
and isinstance(previous_service.get("n_restarts"), int)
|
||||||
|
and not isinstance(previous_service.get("n_restarts"), bool)
|
||||||
|
and previous_service["n_restarts"] >= 0,
|
||||||
|
"rollback state was invalid",
|
||||||
|
)
|
||||||
|
expected_state = {
|
||||||
|
"absent": ("inactive", "dead"),
|
||||||
|
"inactive": ("inactive", "dead"),
|
||||||
|
"active": ("active", "running"),
|
||||||
|
}[previous_service["posture"]]
|
||||||
|
_require(
|
||||||
|
(previous_service["active_state"], previous_service["sub_state"]) == expected_state
|
||||||
|
and ((previous_service["posture"] == "absent") == (not any(prior_presence.values()))),
|
||||||
|
"rollback state was invalid",
|
||||||
|
)
|
||||||
|
stable = {key: item for key, item in value.items() if key != "sha256"}
|
||||||
|
_require(value.get("sha256") == package.canonical_sha256(stable), "rollback state self-hash drifted")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_rollback_state(paths: InstallPaths, release_sha256: str) -> tuple[dict[str, Any], bytes]:
|
||||||
|
value, raw = _read_strict_json_file(
|
||||||
|
paths.rollback_state,
|
||||||
|
label="rollback state",
|
||||||
|
max_bytes=MAX_ROLLBACK_STATE_BYTES,
|
||||||
|
mode=0o600,
|
||||||
|
)
|
||||||
|
_validate_rollback_state(value, current_release_sha256=release_sha256)
|
||||||
|
return value, raw
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_install_receipt(
|
||||||
|
receipt: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
release: Mapping[str, Any],
|
||||||
|
paths: InstallPaths,
|
||||||
|
rollback_state_raw: bytes,
|
||||||
|
) -> None:
|
||||||
|
installed_files = receipt.get("installed_files")
|
||||||
|
rollback = receipt.get("rollback")
|
||||||
|
_require(
|
||||||
|
receipt.get("schema") == INSTALL_RECEIPT_SCHEMA
|
||||||
|
and receipt.get("status") == "pass"
|
||||||
|
and receipt.get("release_sha256") == release["release_sha256"]
|
||||||
|
and isinstance(installed_files, dict)
|
||||||
|
and set(installed_files)
|
||||||
|
== {"unit_sha256", "helper_sha256", "release_descriptor_sha256", "rollback_state_sha256"}
|
||||||
|
and installed_files.get("unit_sha256")
|
||||||
|
== _sha256_bytes(
|
||||||
|
_read_stable_file(
|
||||||
|
paths.unit, label="installed service unit", max_bytes=MAX_RELEASE_BYTES, expected_mode=0o644
|
||||||
|
)
|
||||||
|
)
|
||||||
|
and installed_files.get("helper_sha256")
|
||||||
|
== _sha256_bytes(
|
||||||
|
_read_stable_file(
|
||||||
|
paths.helper, label="installed service helper", max_bytes=MAX_SOURCE_BYTES, expected_mode=0o755
|
||||||
|
)
|
||||||
|
)
|
||||||
|
and installed_files.get("release_descriptor_sha256")
|
||||||
|
== _sha256_bytes(
|
||||||
|
_read_stable_file(
|
||||||
|
paths.release,
|
||||||
|
label="installed release descriptor",
|
||||||
|
max_bytes=MAX_RELEASE_BYTES,
|
||||||
|
expected_mode=0o600,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
and installed_files.get("rollback_state_sha256") == _sha256_bytes(rollback_state_raw)
|
||||||
|
and isinstance(rollback, dict)
|
||||||
|
and rollback.get("state_sha256") == _sha256_bytes(rollback_state_raw)
|
||||||
|
and rollback.get("state_path") == str(paths.rollback_state),
|
||||||
|
"installed receipt was invalid",
|
||||||
|
)
|
||||||
|
_require(isinstance(receipt.get("installer_source"), dict), "installed receipt source binding was invalid")
|
||||||
|
_validate_source_binding(receipt["installer_source"])
|
||||||
|
_require(
|
||||||
|
receipt.get("image")
|
||||||
|
== {
|
||||||
|
"reference": release["image"]["reference"],
|
||||||
|
"digest": release["image"]["digest"],
|
||||||
|
"config_digest": release["image"]["config_digest"],
|
||||||
|
"input_sha256": release["image"]["input_sha256"],
|
||||||
|
},
|
||||||
|
"installed receipt image binding was invalid",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_managed_prior_state(paths: InstallPaths, helper_source: bytes) -> dict[str, Any] | None:
|
def _validate_managed_prior_state(paths: InstallPaths, helper_source: bytes) -> dict[str, Any] | None:
|
||||||
unit_exists = paths.unit.exists() or paths.unit.is_symlink()
|
managed = _managed_files(paths)
|
||||||
helper_exists = paths.helper.exists() or paths.helper.is_symlink()
|
presence = {role: path.exists() or path.is_symlink() for role, (path, _mode) in managed.items()}
|
||||||
release_exists = paths.release.exists() or paths.release.is_symlink()
|
if not any(presence.values()):
|
||||||
receipt_exists = paths.receipt.exists() or paths.receipt.is_symlink()
|
|
||||||
if not any((unit_exists, helper_exists, release_exists, receipt_exists)):
|
|
||||||
return None
|
return None
|
||||||
_require(all((unit_exists, helper_exists, release_exists, receipt_exists)), "managed install state was partial")
|
_require(all(presence.values()), "managed install state was partial")
|
||||||
for path, mode in (
|
for path, mode in managed.values():
|
||||||
(paths.unit, 0o644),
|
|
||||||
(paths.helper, 0o755),
|
|
||||||
(paths.release, 0o600),
|
|
||||||
(paths.receipt, 0o600),
|
|
||||||
):
|
|
||||||
observed = path.stat(follow_symlinks=False)
|
observed = path.stat(follow_symlinks=False)
|
||||||
_require(
|
_require(
|
||||||
stat.S_ISREG(observed.st_mode)
|
stat.S_ISREG(observed.st_mode)
|
||||||
|
|
@ -1021,21 +1254,19 @@ def _validate_managed_prior_state(paths: InstallPaths, helper_source: bytes) ->
|
||||||
_require(release is not None, "managed release state was absent")
|
_require(release is not None, "managed release state was absent")
|
||||||
_require(paths.unit.read_bytes() == package.render_systemd_unit(release).encode(), "installed unit drifted")
|
_require(paths.unit.read_bytes() == package.render_systemd_unit(release).encode(), "installed unit drifted")
|
||||||
_require(paths.helper.is_file() and not paths.helper.is_symlink(), "installed helper was unsafe")
|
_require(paths.helper.is_file() and not paths.helper.is_symlink(), "installed helper was unsafe")
|
||||||
try:
|
rollback_state, rollback_state_raw = _read_rollback_state(paths, release["release_sha256"])
|
||||||
receipt = package.load_json(paths.receipt, "install receipt")
|
del rollback_state
|
||||||
except (OSError, package.PackageError) as exc:
|
receipt, _receipt_raw = _read_strict_json_file(
|
||||||
raise InstallError("installed receipt was invalid") from exc
|
paths.receipt,
|
||||||
_require(
|
label="install receipt",
|
||||||
receipt.get("schema") == INSTALL_RECEIPT_SCHEMA
|
max_bytes=MAX_RELEASE_BYTES,
|
||||||
and receipt.get("status") == "pass"
|
mode=0o600,
|
||||||
and receipt.get("release_sha256") == release["release_sha256"],
|
|
||||||
"installed receipt was invalid",
|
|
||||||
)
|
)
|
||||||
_require(
|
_validate_install_receipt(
|
||||||
isinstance(receipt.get("installed_files"), dict)
|
receipt,
|
||||||
and receipt["installed_files"].get("unit_sha256") == _sha256_bytes(paths.unit.read_bytes())
|
release=release,
|
||||||
and receipt["installed_files"].get("helper_sha256") == _sha256_bytes(paths.helper.read_bytes()),
|
paths=paths,
|
||||||
"installed file receipt drifted",
|
rollback_state_raw=rollback_state_raw,
|
||||||
)
|
)
|
||||||
_require(bool(helper_source), "service-control helper source was empty")
|
_require(bool(helper_source), "service-control helper source was empty")
|
||||||
return release
|
return release
|
||||||
|
|
@ -1062,6 +1293,65 @@ def _cleanup_created_directories(created: list[Path]) -> bool:
|
||||||
return complete
|
return complete
|
||||||
|
|
||||||
|
|
||||||
|
def _directory_existed_safely(path: Path, mode: int) -> bool:
|
||||||
|
if not path.exists() and not path.is_symlink():
|
||||||
|
return False
|
||||||
|
observed = path.stat(follow_symlinks=False)
|
||||||
|
_require(
|
||||||
|
stat.S_ISDIR(observed.st_mode) and observed.st_uid == os.geteuid() and stat.S_IMODE(observed.st_mode) == mode,
|
||||||
|
"prior install directory posture was unsafe",
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _prior_directory_state(paths: InstallPaths) -> dict[str, bool]:
|
||||||
|
state_existed = _directory_existed_safely(paths.state_dir, 0o700)
|
||||||
|
docker_existed = _directory_existed_safely(paths.docker_config, 0o700)
|
||||||
|
if docker_existed:
|
||||||
|
_require(state_existed and not any(paths.docker_config.iterdir()), "prior Docker config posture was unsafe")
|
||||||
|
if state_existed:
|
||||||
|
allowed = {paths.docker_config.name} if docker_existed else set()
|
||||||
|
observed = {path.name for path in paths.state_dir.iterdir()}
|
||||||
|
managed_names = {paths.release.name, paths.receipt.name, paths.rollback_state.name}
|
||||||
|
_require(
|
||||||
|
observed <= allowed | managed_names,
|
||||||
|
"prior install state directory contained an unmanaged entry",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"state_dir_existed": state_existed,
|
||||||
|
"docker_config_existed": docker_existed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_rollback_state(
|
||||||
|
release_sha256: str,
|
||||||
|
snapshots: Mapping[Path, FileSnapshot],
|
||||||
|
paths: InstallPaths,
|
||||||
|
previous_directories: Mapping[str, bool],
|
||||||
|
prior_posture: ServicePosture,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
path_to_role = {path: role for role, (path, _mode) in _managed_files(paths).items()}
|
||||||
|
_require(set(snapshots) == set(path_to_role), "rollback snapshot set was not exact")
|
||||||
|
stable = {
|
||||||
|
"schema": ROLLBACK_STATE_SCHEMA,
|
||||||
|
"current_release_sha256": release_sha256,
|
||||||
|
"previous_files": {
|
||||||
|
path_to_role[path]: _snapshot_to_json(snapshots[path])
|
||||||
|
for path in sorted(snapshots, key=lambda item: path_to_role[item])
|
||||||
|
},
|
||||||
|
"previous_directories": dict(previous_directories),
|
||||||
|
"previous_service": {
|
||||||
|
"posture": prior_posture.kind,
|
||||||
|
"active_state": prior_posture.state["ActiveState"],
|
||||||
|
"sub_state": prior_posture.state["SubState"],
|
||||||
|
"n_restarts": prior_posture.state["NRestarts"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
value = {**stable, "sha256": package.canonical_sha256(stable)}
|
||||||
|
_validate_rollback_state(value, current_release_sha256=release_sha256)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def _install_lock(paths: InstallPaths):
|
def _install_lock(paths: InstallPaths):
|
||||||
if paths.root == Path("/"):
|
if paths.root == Path("/"):
|
||||||
|
|
@ -1129,6 +1419,7 @@ def _install_release_transaction(
|
||||||
)
|
)
|
||||||
before = prior_posture.state
|
before = prior_posture.state
|
||||||
prior_active = prior_posture.kind == "active"
|
prior_active = prior_posture.kind == "active"
|
||||||
|
previous_directories = _prior_directory_state(paths)
|
||||||
|
|
||||||
created: list[Path] = []
|
created: list[Path] = []
|
||||||
try:
|
try:
|
||||||
|
|
@ -1149,6 +1440,7 @@ def _install_release_transaction(
|
||||||
paths.helper: _snapshot(paths.helper),
|
paths.helper: _snapshot(paths.helper),
|
||||||
paths.release: _snapshot(paths.release),
|
paths.release: _snapshot(paths.release),
|
||||||
paths.receipt: _snapshot(paths.receipt),
|
paths.receipt: _snapshot(paths.receipt),
|
||||||
|
paths.rollback_state: _snapshot(paths.rollback_state),
|
||||||
}
|
}
|
||||||
installed_identities: dict[Path, tuple[int, int]] = {}
|
installed_identities: dict[Path, tuple[int, int]] = {}
|
||||||
attempted_paths: set[Path] = set()
|
attempted_paths: set[Path] = set()
|
||||||
|
|
@ -1198,6 +1490,20 @@ def _install_release_transaction(
|
||||||
release_bytes = _canonical_json(release)
|
release_bytes = _canonical_json(release)
|
||||||
attempted_paths.add(paths.release)
|
attempted_paths.add(paths.release)
|
||||||
_publish_managed_file(paths.release, release_bytes, 0o600, installed_identities)
|
_publish_managed_file(paths.release, release_bytes, 0o600, installed_identities)
|
||||||
|
rollback_state = _build_rollback_state(
|
||||||
|
release["release_sha256"],
|
||||||
|
snapshots,
|
||||||
|
paths,
|
||||||
|
previous_directories,
|
||||||
|
prior_posture,
|
||||||
|
)
|
||||||
|
rollback_state_bytes = _canonical_json(rollback_state)
|
||||||
|
_require(
|
||||||
|
len(rollback_state_bytes) <= MAX_ROLLBACK_STATE_BYTES,
|
||||||
|
"rollback state exceeded the safety limit",
|
||||||
|
)
|
||||||
|
attempted_paths.add(paths.rollback_state)
|
||||||
|
_publish_managed_file(paths.rollback_state, rollback_state_bytes, 0o600, installed_identities)
|
||||||
receipt = {
|
receipt = {
|
||||||
"schema": INSTALL_RECEIPT_SCHEMA,
|
"schema": INSTALL_RECEIPT_SCHEMA,
|
||||||
"status": "pass",
|
"status": "pass",
|
||||||
|
|
@ -1214,6 +1520,7 @@ def _install_release_transaction(
|
||||||
"unit_sha256": _sha256_bytes(unit_content),
|
"unit_sha256": _sha256_bytes(unit_content),
|
||||||
"helper_sha256": _sha256_bytes(helper_content),
|
"helper_sha256": _sha256_bytes(helper_content),
|
||||||
"release_descriptor_sha256": _sha256_bytes(release_bytes),
|
"release_descriptor_sha256": _sha256_bytes(release_bytes),
|
||||||
|
"rollback_state_sha256": _sha256_bytes(rollback_state_bytes),
|
||||||
},
|
},
|
||||||
"service_before": {
|
"service_before": {
|
||||||
"load_state": before["LoadState"],
|
"load_state": before["LoadState"],
|
||||||
|
|
@ -1228,6 +1535,8 @@ def _install_release_transaction(
|
||||||
"previous_service_active": prior_active,
|
"previous_service_active": prior_active,
|
||||||
"previous_service_posture": prior_posture.kind,
|
"previous_service_posture": prior_posture.kind,
|
||||||
"automatic_on_failure": True,
|
"automatic_on_failure": True,
|
||||||
|
"state_path": str(paths.rollback_state),
|
||||||
|
"state_sha256": _sha256_bytes(rollback_state_bytes),
|
||||||
},
|
},
|
||||||
"authority": {
|
"authority": {
|
||||||
"image_pulled": False,
|
"image_pulled": False,
|
||||||
|
|
@ -1246,7 +1555,7 @@ def _install_release_transaction(
|
||||||
_systemctl("stop", runner)
|
_systemctl("stop", runner)
|
||||||
except InstallError:
|
except InstallError:
|
||||||
rollback_errors.append("candidate_stop")
|
rollback_errors.append("candidate_stop")
|
||||||
for path in (paths.receipt, paths.release, paths.unit, paths.helper):
|
for path in (paths.receipt, paths.rollback_state, paths.release, paths.unit, paths.helper):
|
||||||
if path not in attempted_paths:
|
if path not in attempted_paths:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
|
|
@ -1294,6 +1603,415 @@ def _install_release_transaction(
|
||||||
raise InstallError("installation failed and was rolled back") from exc
|
raise InstallError("installation failed and was rolled back") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _json_from_snapshot(snapshot: FileSnapshot, *, label: str) -> dict[str, Any]:
|
||||||
|
_require(snapshot.existed, f"{label} was absent")
|
||||||
|
value = _strict_json_bytes(snapshot.content, label)
|
||||||
|
_require(isinstance(value, dict), f"{label} was invalid")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_managed_snapshots(
|
||||||
|
snapshots: Mapping[str, FileSnapshot],
|
||||||
|
*,
|
||||||
|
paths: InstallPaths,
|
||||||
|
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
|
||||||
|
expected_modes = {role: mode for role, (_path, mode) in _managed_files(paths).items()}
|
||||||
|
_require(set(snapshots) == set(expected_modes), "managed snapshot set was not exact")
|
||||||
|
_require(all(snapshot.existed for snapshot in snapshots.values()), "managed snapshot set was partial")
|
||||||
|
_require(
|
||||||
|
all(snapshots[role].mode == expected_modes[role] for role in expected_modes),
|
||||||
|
"managed snapshot mode drifted",
|
||||||
|
)
|
||||||
|
release = _json_from_snapshot(snapshots["release"], label="snapshot release descriptor")
|
||||||
|
try:
|
||||||
|
package.validate_release_descriptor(release)
|
||||||
|
except package.PackageError as exc:
|
||||||
|
raise InstallError("snapshot release descriptor was invalid") from exc
|
||||||
|
release_sha256 = release["release_sha256"]
|
||||||
|
_require(
|
||||||
|
snapshots["unit"].content == package.render_systemd_unit(release).encode("utf-8")
|
||||||
|
and snapshots["helper"].content.startswith(b"#!/usr/bin/python3 -I\n"),
|
||||||
|
"snapshot installed files differed from the release",
|
||||||
|
)
|
||||||
|
rollback_state = _json_from_snapshot(snapshots["rollback_state"], label="snapshot rollback state")
|
||||||
|
_validate_rollback_state(rollback_state, current_release_sha256=release_sha256)
|
||||||
|
receipt = _json_from_snapshot(snapshots["receipt"], label="snapshot install receipt")
|
||||||
|
installed_files = receipt.get("installed_files")
|
||||||
|
rollback = receipt.get("rollback")
|
||||||
|
_require(
|
||||||
|
receipt.get("schema") == INSTALL_RECEIPT_SCHEMA
|
||||||
|
and receipt.get("status") == "pass"
|
||||||
|
and receipt.get("release_sha256") == release_sha256
|
||||||
|
and isinstance(receipt.get("installer_source"), dict)
|
||||||
|
and isinstance(installed_files, dict)
|
||||||
|
and installed_files.get("unit_sha256") == _sha256_bytes(snapshots["unit"].content)
|
||||||
|
and installed_files.get("helper_sha256") == _sha256_bytes(snapshots["helper"].content)
|
||||||
|
and installed_files.get("release_descriptor_sha256") == _sha256_bytes(snapshots["release"].content)
|
||||||
|
and installed_files.get("rollback_state_sha256") == _sha256_bytes(snapshots["rollback_state"].content)
|
||||||
|
and isinstance(rollback, dict)
|
||||||
|
and rollback.get("state_path") == str(paths.rollback_state)
|
||||||
|
and rollback.get("state_sha256") == _sha256_bytes(snapshots["rollback_state"].content),
|
||||||
|
"snapshot install receipt was invalid",
|
||||||
|
)
|
||||||
|
_validate_source_binding(receipt["installer_source"])
|
||||||
|
return release, receipt, rollback_state
|
||||||
|
|
||||||
|
|
||||||
|
def _current_managed_snapshots(paths: InstallPaths) -> dict[str, FileSnapshot]:
|
||||||
|
return {role: _snapshot(path) for role, (path, _mode) in _managed_files(paths).items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _previous_snapshots(rollback_state: Mapping[str, Any]) -> dict[str, FileSnapshot]:
|
||||||
|
previous_files = rollback_state["previous_files"]
|
||||||
|
_require(isinstance(previous_files, dict), "rollback prior file set was invalid")
|
||||||
|
return {
|
||||||
|
role: _snapshot_from_json(previous_files[role], label=f"rollback {role} snapshot")
|
||||||
|
for role in sorted(previous_files)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_explicit_rollback(
|
||||||
|
expected_release_sha256: str,
|
||||||
|
*,
|
||||||
|
paths: InstallPaths,
|
||||||
|
source_binding: Mapping[str, Any],
|
||||||
|
runner: Runner,
|
||||||
|
sleeper: Sleeper,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
_require(HEX_64.fullmatch(expected_release_sha256) is not None, "expected release hash was invalid")
|
||||||
|
_bound_source_files(source_binding)
|
||||||
|
managed_release = _validate_managed_prior_state(paths, b"explicit rollback")
|
||||||
|
_require(managed_release is not None, "explicit rollback found no managed release")
|
||||||
|
current_snapshots = _current_managed_snapshots(paths)
|
||||||
|
current_release, current_receipt, rollback_state = _validate_managed_snapshots(
|
||||||
|
current_snapshots,
|
||||||
|
paths=paths,
|
||||||
|
)
|
||||||
|
_require(
|
||||||
|
current_release == managed_release and current_release["release_sha256"] == expected_release_sha256,
|
||||||
|
"installed release did not match the expected release hash",
|
||||||
|
)
|
||||||
|
previous_snapshots = _previous_snapshots(rollback_state)
|
||||||
|
previous_presence = [snapshot.existed for snapshot in previous_snapshots.values()]
|
||||||
|
previous_release: dict[str, Any] | None
|
||||||
|
if any(previous_presence):
|
||||||
|
_require(all(previous_presence), "rollback prior managed file set was partial")
|
||||||
|
previous_release, _previous_receipt, _previous_rollback = _validate_managed_snapshots(
|
||||||
|
previous_snapshots,
|
||||||
|
paths=paths,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
previous_release = None
|
||||||
|
current_posture = _stable_service_posture(
|
||||||
|
current_release,
|
||||||
|
runner=runner,
|
||||||
|
paths=paths,
|
||||||
|
sleeper=sleeper,
|
||||||
|
)
|
||||||
|
current_verification: dict[str, Any] | None = None
|
||||||
|
if current_posture.kind == "active":
|
||||||
|
current_verification = verify_running_release(
|
||||||
|
current_release,
|
||||||
|
runner=runner,
|
||||||
|
paths=paths,
|
||||||
|
sleeper=sleeper,
|
||||||
|
)
|
||||||
|
_require(
|
||||||
|
current_verification["main_pid"] == current_posture.state["MainPID"]
|
||||||
|
and current_verification["n_restarts"] == current_posture.state["NRestarts"]
|
||||||
|
and current_verification["invocation_id"] == current_posture.state["InvocationID"],
|
||||||
|
"installed service changed during rollback preparation",
|
||||||
|
)
|
||||||
|
previous_service = rollback_state["previous_service"]
|
||||||
|
_require(
|
||||||
|
(previous_release is None) == (previous_service["posture"] == "absent"),
|
||||||
|
"rollback prior release differed from its recorded posture",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"current_release": current_release,
|
||||||
|
"current_receipt": current_receipt,
|
||||||
|
"current_snapshots": current_snapshots,
|
||||||
|
"current_posture": current_posture,
|
||||||
|
"current_verification": current_verification,
|
||||||
|
"previous_snapshots": previous_snapshots,
|
||||||
|
"previous_release": previous_release,
|
||||||
|
"previous_directories": rollback_state["previous_directories"],
|
||||||
|
"previous_service": previous_service,
|
||||||
|
"rollback_state_sha256": _sha256_bytes(current_snapshots["rollback_state"].content),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_rollback_plan(
|
||||||
|
expected_release_sha256: str,
|
||||||
|
prepared: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
paths: InstallPaths,
|
||||||
|
source_binding: Mapping[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
previous_release = prepared["previous_release"]
|
||||||
|
return {
|
||||||
|
"schema": ROLLBACK_RESULT_SCHEMA,
|
||||||
|
"status": "pass",
|
||||||
|
"mode": "dry_run",
|
||||||
|
"installer_source": dict(source_binding),
|
||||||
|
"expected_release_sha256": expected_release_sha256,
|
||||||
|
"installed_release_sha256": prepared["current_release"]["release_sha256"],
|
||||||
|
"installed_receipt_release_sha256": prepared["current_receipt"]["release_sha256"],
|
||||||
|
"rollback_state_sha256": prepared["rollback_state_sha256"],
|
||||||
|
"current_service_posture": prepared["current_posture"].kind,
|
||||||
|
"target_service_posture": prepared["previous_service"]["posture"],
|
||||||
|
"target_release_sha256": previous_release["release_sha256"] if previous_release is not None else None,
|
||||||
|
"target": {
|
||||||
|
"service": SERVICE,
|
||||||
|
"unit": str(paths.unit),
|
||||||
|
"helper": str(paths.helper),
|
||||||
|
"state_directory": str(paths.state_dir),
|
||||||
|
},
|
||||||
|
"authority": {
|
||||||
|
"execute_requires_root_and_execute_rollback": True,
|
||||||
|
"image_pulled": False,
|
||||||
|
"cloud_or_database_authority_changed": False,
|
||||||
|
"transport_authority_changed": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_rollback_owned_directories(paths: InstallPaths, previous_directories: Mapping[str, bool]) -> None:
|
||||||
|
_require(not any(paths.docker_config.iterdir()), "rollback Docker config directory was not empty")
|
||||||
|
if not previous_directories["docker_config_existed"]:
|
||||||
|
paths.docker_config.rmdir()
|
||||||
|
_fsync_directory(paths.state_dir)
|
||||||
|
if not previous_directories["state_dir_existed"]:
|
||||||
|
_require(not any(paths.state_dir.iterdir()), "rollback state directory was not empty")
|
||||||
|
paths.state_dir.rmdir()
|
||||||
|
_fsync_directory(paths.state_dir.parent)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_snapshot_identities(
|
||||||
|
snapshots: Mapping[str, FileSnapshot],
|
||||||
|
*,
|
||||||
|
paths: InstallPaths,
|
||||||
|
) -> None:
|
||||||
|
for role, (path, expected_mode) in _managed_files(paths).items():
|
||||||
|
snapshot = snapshots[role]
|
||||||
|
_require(snapshot.existed and snapshot.identity is not None, "installed rollback file identity was missing")
|
||||||
|
try:
|
||||||
|
observed = path.stat(follow_symlinks=False)
|
||||||
|
except OSError as exc:
|
||||||
|
raise InstallError("installed rollback file identity drifted") from exc
|
||||||
|
observed_identity = (
|
||||||
|
observed.st_dev,
|
||||||
|
observed.st_ino,
|
||||||
|
observed.st_mode,
|
||||||
|
observed.st_nlink,
|
||||||
|
observed.st_size,
|
||||||
|
observed.st_mtime_ns,
|
||||||
|
observed.st_ctime_ns,
|
||||||
|
)
|
||||||
|
_require(
|
||||||
|
stat.S_ISREG(observed.st_mode)
|
||||||
|
and observed.st_nlink == 1
|
||||||
|
and stat.S_IMODE(observed.st_mode) == expected_mode
|
||||||
|
and observed_identity == snapshot.identity,
|
||||||
|
"installed rollback file identity drifted",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_explicit_rollback(
|
||||||
|
expected_release_sha256: str,
|
||||||
|
prepared: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
paths: InstallPaths,
|
||||||
|
runner: Runner,
|
||||||
|
sleeper: Sleeper,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
current_release = prepared["current_release"]
|
||||||
|
current_snapshots = prepared["current_snapshots"]
|
||||||
|
current_posture: ServicePosture = prepared["current_posture"]
|
||||||
|
previous_snapshots = prepared["previous_snapshots"]
|
||||||
|
previous_release = prepared["previous_release"]
|
||||||
|
previous_service = prepared["previous_service"]
|
||||||
|
role_paths = {role: path for role, (path, _mode) in _managed_files(paths).items()}
|
||||||
|
restored_identities: dict[Path, tuple[int, int] | None] = {}
|
||||||
|
mutated_paths: set[Path] = set()
|
||||||
|
files_mutated = False
|
||||||
|
service_stopped = False
|
||||||
|
target_may_be_active = False
|
||||||
|
try:
|
||||||
|
_assert_snapshot_identities(current_snapshots, paths=paths)
|
||||||
|
if current_posture.kind == "active":
|
||||||
|
fresh_verification = verify_running_release(
|
||||||
|
current_release,
|
||||||
|
runner=runner,
|
||||||
|
paths=paths,
|
||||||
|
sleeper=sleeper,
|
||||||
|
)
|
||||||
|
_require(
|
||||||
|
fresh_verification == prepared["current_verification"],
|
||||||
|
"service or container drifted before rollback stop",
|
||||||
|
)
|
||||||
|
before_stop = _service_state(runner)
|
||||||
|
_require(
|
||||||
|
before_stop["MainPID"] == current_posture.state["MainPID"]
|
||||||
|
and before_stop["NRestarts"] == current_posture.state["NRestarts"]
|
||||||
|
and before_stop["InvocationID"] == current_posture.state["InvocationID"]
|
||||||
|
and before_stop["ActiveState"] == "active"
|
||||||
|
and before_stop["SubState"] == "running",
|
||||||
|
"service PID or invocation drifted before rollback stop",
|
||||||
|
)
|
||||||
|
_systemctl("stop", runner)
|
||||||
|
service_stopped = True
|
||||||
|
stopped = _stable_service_posture(
|
||||||
|
current_release,
|
||||||
|
runner=runner,
|
||||||
|
paths=paths,
|
||||||
|
sleeper=sleeper,
|
||||||
|
)
|
||||||
|
_require(stopped.kind == "inactive", "installed service did not stop into a trusted posture")
|
||||||
|
for role in ("receipt", "rollback_state", "release", "unit", "helper"):
|
||||||
|
path = role_paths[role]
|
||||||
|
files_mutated = True
|
||||||
|
mutated_paths.add(path)
|
||||||
|
restored_identities[path] = _restore_snapshot(
|
||||||
|
path,
|
||||||
|
previous_snapshots[role],
|
||||||
|
current_snapshots[role].identity,
|
||||||
|
)
|
||||||
|
_systemctl("daemon-reload", runner)
|
||||||
|
if previous_service["posture"] == "active":
|
||||||
|
_require(previous_release is not None, "rollback active target release was missing")
|
||||||
|
target_may_be_active = True
|
||||||
|
_systemctl("restart", runner)
|
||||||
|
final_verification = verify_running_release(
|
||||||
|
previous_release,
|
||||||
|
runner=runner,
|
||||||
|
paths=paths,
|
||||||
|
sleeper=sleeper,
|
||||||
|
)
|
||||||
|
_require(
|
||||||
|
final_verification["n_restarts"] == previous_service["n_restarts"],
|
||||||
|
"rollback did not restore the recorded active posture",
|
||||||
|
)
|
||||||
|
final_posture = "active"
|
||||||
|
elif previous_service["posture"] == "inactive":
|
||||||
|
_require(previous_release is not None, "rollback inactive target release was missing")
|
||||||
|
final = _stable_service_posture(
|
||||||
|
previous_release,
|
||||||
|
runner=runner,
|
||||||
|
paths=paths,
|
||||||
|
sleeper=sleeper,
|
||||||
|
)
|
||||||
|
_require(
|
||||||
|
final.kind == "inactive" and final.state["NRestarts"] == previous_service["n_restarts"],
|
||||||
|
"rollback did not restore the recorded inactive posture",
|
||||||
|
)
|
||||||
|
final_verification = None
|
||||||
|
final_posture = "inactive"
|
||||||
|
else:
|
||||||
|
_require(previous_release is None, "rollback absent target unexpectedly had a release")
|
||||||
|
_systemctl("reset-failed", runner)
|
||||||
|
final = _stable_service_posture(
|
||||||
|
None,
|
||||||
|
runner=runner,
|
||||||
|
paths=paths,
|
||||||
|
sleeper=sleeper,
|
||||||
|
)
|
||||||
|
_require(final.kind == "absent", "rollback did not restore the absent posture")
|
||||||
|
_remove_rollback_owned_directories(paths, prepared["previous_directories"])
|
||||||
|
final_verification = None
|
||||||
|
final_posture = "absent"
|
||||||
|
return {
|
||||||
|
"schema": ROLLBACK_RESULT_SCHEMA,
|
||||||
|
"status": "pass",
|
||||||
|
"mode": "executed",
|
||||||
|
"rolled_back_release_sha256": expected_release_sha256,
|
||||||
|
"restored_release_sha256": previous_release["release_sha256"] if previous_release is not None else None,
|
||||||
|
"restored_service_posture": final_posture,
|
||||||
|
"verification": final_verification,
|
||||||
|
"authority": {
|
||||||
|
"image_pulled": False,
|
||||||
|
"cloud_or_database_authority_changed": False,
|
||||||
|
"transport_authority_changed": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
recovery_errors: list[str] = []
|
||||||
|
if target_may_be_active:
|
||||||
|
try:
|
||||||
|
_systemctl("stop", runner)
|
||||||
|
except InstallError:
|
||||||
|
recovery_errors.append("target_stop")
|
||||||
|
if files_mutated:
|
||||||
|
for role in ("receipt", "rollback_state", "release", "unit", "helper"):
|
||||||
|
path = role_paths[role]
|
||||||
|
if path not in mutated_paths:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if restored_identities.get(path) is None:
|
||||||
|
_require(
|
||||||
|
not path.exists() and not path.is_symlink(),
|
||||||
|
"rollback recovery refused a replaced pathname",
|
||||||
|
)
|
||||||
|
_restore_snapshot(path, current_snapshots[role], restored_identities.get(path))
|
||||||
|
except (OSError, InstallError):
|
||||||
|
recovery_errors.append("file_restore")
|
||||||
|
try:
|
||||||
|
_systemctl("daemon-reload", runner)
|
||||||
|
except InstallError:
|
||||||
|
recovery_errors.append("daemon_reload")
|
||||||
|
if service_stopped and current_posture.kind == "active":
|
||||||
|
try:
|
||||||
|
_systemctl("restart", runner)
|
||||||
|
verify_running_release(current_release, runner=runner, paths=paths, sleeper=sleeper)
|
||||||
|
except InstallError:
|
||||||
|
recovery_errors.append("current_restart")
|
||||||
|
if recovery_errors:
|
||||||
|
raise InstallError("explicit rollback failed and recovery was incomplete") from exc
|
||||||
|
if isinstance(exc, InstallError):
|
||||||
|
raise
|
||||||
|
raise InstallError("explicit rollback failed without changing the installed release") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def rollback_release(
|
||||||
|
expected_release_sha256: str,
|
||||||
|
*,
|
||||||
|
paths: InstallPaths,
|
||||||
|
source_binding: Mapping[str, Any],
|
||||||
|
runner: Runner = _default_runner,
|
||||||
|
sleeper: Sleeper = time.sleep,
|
||||||
|
execute_rollback: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not execute_rollback:
|
||||||
|
prepared = _prepare_explicit_rollback(
|
||||||
|
expected_release_sha256,
|
||||||
|
paths=paths,
|
||||||
|
source_binding=source_binding,
|
||||||
|
runner=runner,
|
||||||
|
sleeper=sleeper,
|
||||||
|
)
|
||||||
|
return _build_rollback_plan(
|
||||||
|
expected_release_sha256,
|
||||||
|
prepared,
|
||||||
|
paths=paths,
|
||||||
|
source_binding=source_binding,
|
||||||
|
)
|
||||||
|
with _install_lock(paths):
|
||||||
|
prepared = _prepare_explicit_rollback(
|
||||||
|
expected_release_sha256,
|
||||||
|
paths=paths,
|
||||||
|
source_binding=source_binding,
|
||||||
|
runner=runner,
|
||||||
|
sleeper=sleeper,
|
||||||
|
)
|
||||||
|
return _execute_explicit_rollback(
|
||||||
|
expected_release_sha256,
|
||||||
|
prepared,
|
||||||
|
paths=paths,
|
||||||
|
runner=runner,
|
||||||
|
sleeper=sleeper,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def install_release(
|
def install_release(
|
||||||
bundle: Path,
|
bundle: Path,
|
||||||
*,
|
*,
|
||||||
|
|
@ -1330,23 +2048,45 @@ class _Parser(argparse.ArgumentParser):
|
||||||
|
|
||||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
parser = _Parser(description=__doc__, add_help=False)
|
parser = _Parser(description=__doc__, add_help=False)
|
||||||
parser.add_argument("--bundle", required=True, type=Path)
|
parser.add_argument("--bundle", type=Path)
|
||||||
parser.add_argument("--execute-restart", action="store_true")
|
parser.add_argument("--execute-restart", action="store_true")
|
||||||
return parser.parse_args(argv)
|
parser.add_argument("--rollback-release")
|
||||||
|
parser.add_argument("--execute-rollback", action="store_true")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
if args.rollback_release is None:
|
||||||
|
_require(args.bundle is not None and not args.execute_rollback, "invalid arguments")
|
||||||
|
else:
|
||||||
|
_require(
|
||||||
|
args.bundle is None and not args.execute_restart and HEX_64.fullmatch(args.rollback_release) is not None,
|
||||||
|
"invalid arguments",
|
||||||
|
)
|
||||||
|
_require(not (args.execute_restart and args.execute_rollback), "invalid arguments")
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
try:
|
try:
|
||||||
args = parse_args(argv)
|
args = parse_args(argv)
|
||||||
root = Path("/")
|
root = Path("/")
|
||||||
if args.execute_restart:
|
if args.execute_restart or args.execute_rollback:
|
||||||
_require(os.geteuid() == 0, "live installation requires root")
|
_require(os.geteuid() == 0, "live installation or rollback requires root")
|
||||||
result = install_release(
|
paths = InstallPaths.under(root)
|
||||||
args.bundle.absolute(),
|
binding = reviewed_source_binding()
|
||||||
paths=InstallPaths.under(root),
|
if args.rollback_release is not None:
|
||||||
source_binding=reviewed_source_binding(),
|
result = rollback_release(
|
||||||
execute_restart=args.execute_restart,
|
args.rollback_release,
|
||||||
)
|
paths=paths,
|
||||||
|
source_binding=binding,
|
||||||
|
execute_rollback=args.execute_rollback,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_require(args.bundle is not None, "invalid arguments")
|
||||||
|
result = install_release(
|
||||||
|
args.bundle.absolute(),
|
||||||
|
paths=paths,
|
||||||
|
source_binding=binding,
|
||||||
|
execute_restart=args.execute_restart,
|
||||||
|
)
|
||||||
sys.stdout.write(json.dumps(result, allow_nan=False, separators=(",", ":"), sort_keys=True) + "\n")
|
sys.stdout.write(json.dumps(result, allow_nan=False, separators=(",", ":"), sort_keys=True) + "\n")
|
||||||
return 0
|
return 0
|
||||||
except (InstallError, package.PackageError) as exc:
|
except (InstallError, package.PackageError) as exc:
|
||||||
|
|
|
||||||
1279
ops/run_gcp_leoclean_nosend_iap.py
Normal file
1279
ops/run_gcp_leoclean_nosend_iap.py
Normal file
File diff suppressed because it is too large
Load diff
779
tests/test_gcp_leoclean_nosend_iap_runner.py
Normal file
779
tests/test_gcp_leoclean_nosend_iap_runner.py
Normal file
|
|
@ -0,0 +1,779 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ops import gcp_leoclean_nosend_iap_remote as remote
|
||||||
|
from ops import run_gcp_leoclean_nosend_iap as local
|
||||||
|
|
||||||
|
|
||||||
|
def completed(
|
||||||
|
argv: list[str],
|
||||||
|
*,
|
||||||
|
returncode: int = 0,
|
||||||
|
stdout: bytes = b"",
|
||||||
|
stderr: bytes = b"",
|
||||||
|
) -> subprocess.CompletedProcess[bytes]:
|
||||||
|
return subprocess.CompletedProcess(argv, returncode, stdout, stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def private_directory(path: Path) -> Path:
|
||||||
|
path.mkdir(mode=0o700)
|
||||||
|
path.chmod(0o700)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def fixed_commands(tmp_path: Path, operation: str = "install") -> tuple[list[str], list[str], list[str]]:
|
||||||
|
request_id = "iap-abcdefghijkl"
|
||||||
|
archive_name, key_name = local.remote_names(request_id)
|
||||||
|
archive = tmp_path / archive_name
|
||||||
|
ssh_key = tmp_path / key_name
|
||||||
|
archive.write_bytes(b"archive")
|
||||||
|
archive.chmod(0o600)
|
||||||
|
return local.build_gcloud_argv(
|
||||||
|
operation=operation,
|
||||||
|
request_id=request_id,
|
||||||
|
runner_revision="a" * 40,
|
||||||
|
release_revision="b" * 40,
|
||||||
|
archive=archive,
|
||||||
|
archive_sha256="c" * 64,
|
||||||
|
ssh_key=ssh_key,
|
||||||
|
helper_sha256="d" * 64,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def executable_plan(tmp_path: Path, operation: str) -> dict[str, Any]:
|
||||||
|
request_id = "iap-abcdefghijkl"
|
||||||
|
archive_name, key_name = local.remote_names(request_id)
|
||||||
|
archive = tmp_path / archive_name
|
||||||
|
ssh_key = tmp_path / key_name
|
||||||
|
archive.write_bytes(b"archive")
|
||||||
|
archive.chmod(0o600)
|
||||||
|
archive_sha256 = local._sha256_bytes(archive.read_bytes())
|
||||||
|
helper_sha256 = "c" * 64
|
||||||
|
commands = local.build_gcloud_argv(
|
||||||
|
operation=operation,
|
||||||
|
request_id=request_id,
|
||||||
|
runner_revision="a" * 40,
|
||||||
|
release_revision="b" * 40,
|
||||||
|
archive=archive,
|
||||||
|
archive_sha256=archive_sha256,
|
||||||
|
ssh_key=ssh_key,
|
||||||
|
helper_sha256=helper_sha256,
|
||||||
|
)
|
||||||
|
expected = {
|
||||||
|
"release_artifact_sha256": "d" * 64,
|
||||||
|
"release_sha256": "e" * 64,
|
||||||
|
"image_digest": f"sha256:{'f' * 64}",
|
||||||
|
"image_config_digest": f"sha256:{'1' * 64}",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"schema": local.PLAN_SCHEMA,
|
||||||
|
"status": "pass",
|
||||||
|
"mode": "dry_run",
|
||||||
|
"operation": operation,
|
||||||
|
"request_id": "iap-abcdefghijkl",
|
||||||
|
"runner_revision": "a" * 40,
|
||||||
|
"release_revision": "b" * 40,
|
||||||
|
"target": {
|
||||||
|
"project": local.PROJECT,
|
||||||
|
"zone": local.ZONE,
|
||||||
|
"instance": local.INSTANCE,
|
||||||
|
"service": local.SERVICE,
|
||||||
|
"service_account": local.SERVICE_ACCOUNT,
|
||||||
|
},
|
||||||
|
"release": {
|
||||||
|
"release_sha256": expected["release_sha256"],
|
||||||
|
"image_reference": f"{local.package.IMAGE_REPOSITORY}@{expected['image_digest']}",
|
||||||
|
"image_digest": expected["image_digest"],
|
||||||
|
"image_config_digest": expected["image_config_digest"],
|
||||||
|
},
|
||||||
|
"release_artifact": {
|
||||||
|
"archive_sha256": expected["release_artifact_sha256"],
|
||||||
|
"archive_size": 1,
|
||||||
|
"members": [],
|
||||||
|
},
|
||||||
|
"expected": expected,
|
||||||
|
"request": {
|
||||||
|
"archive": str(archive),
|
||||||
|
"archive_sha256": archive_sha256,
|
||||||
|
"helper_sha256": helper_sha256,
|
||||||
|
"manifest_sha256": "d" * 64,
|
||||||
|
"ssh_key": str(ssh_key),
|
||||||
|
},
|
||||||
|
"actions": local._action_receipts(commands),
|
||||||
|
"authority": {
|
||||||
|
"gcp_mutation": operation in {"install", "rollback"},
|
||||||
|
"service_restart": operation in {"install", "rollback"},
|
||||||
|
"database_or_secret_provisioning": False,
|
||||||
|
"production_or_transport": False,
|
||||||
|
"vps": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_gcloud_commands_are_fixed_iap_only_and_expire_keys(tmp_path: Path) -> None:
|
||||||
|
scp, ssh, cleanup = fixed_commands(tmp_path)
|
||||||
|
|
||||||
|
assert scp[:3] == [local.GCLOUD, "compute", "scp"]
|
||||||
|
assert ssh[:4] == [local.GCLOUD, "compute", "ssh", local.INSTANCE]
|
||||||
|
for command in (scp, ssh, cleanup):
|
||||||
|
assert f"--project={local.PROJECT}" in command
|
||||||
|
assert f"--zone={local.ZONE}" in command
|
||||||
|
assert "--tunnel-through-iap" in command
|
||||||
|
assert "--ssh-key-expire-after=5m" in command
|
||||||
|
encoded = json.dumps({"scp": scp, "ssh": ssh, "cleanup": cleanup})
|
||||||
|
assert local.INSTANCE in encoded
|
||||||
|
assert local.SERVICE not in encoded
|
||||||
|
assert "teleo-prod-1" not in encoded
|
||||||
|
assert "77.42.65.182" not in encoded
|
||||||
|
assert "telegram" not in encoded.casefold()
|
||||||
|
assert "sudo -- /usr/bin/python3 -I -c" in ssh[-1]
|
||||||
|
assert "/tmp/livingip-leoclean-nosend-remote" not in ssh[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_embedded_root_bootstrap_compiles() -> None:
|
||||||
|
compile(local.BOOTSTRAP_CODE, "<leoclean-iap-bootstrap>", "exec")
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_origin_main_readback_is_exact() -> None:
|
||||||
|
revision = "a" * 40
|
||||||
|
responses = iter(
|
||||||
|
[
|
||||||
|
f"{local.ORIGIN_URL}\n".encode(),
|
||||||
|
f"{revision}\trefs/heads/main\n".encode(),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def runner(argv: list[str], **_kwargs: Any) -> subprocess.CompletedProcess[bytes]:
|
||||||
|
return completed(argv, stdout=next(responses))
|
||||||
|
|
||||||
|
local.validate_remote_main(revision, runner=runner)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"responses",
|
||||||
|
[
|
||||||
|
[b"git@github.com:someone-else/teleo-infrastructure.git\n"],
|
||||||
|
[
|
||||||
|
f"{local.ORIGIN_URL}\n".encode(),
|
||||||
|
f"{'b' * 40}\trefs/heads/main\n".encode(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_live_origin_main_readback_rejects_different_authority(responses: list[bytes]) -> None:
|
||||||
|
output = iter(responses)
|
||||||
|
|
||||||
|
def runner(argv: list[str], **_kwargs: Any) -> subprocess.CompletedProcess[bytes]:
|
||||||
|
return completed(argv, stdout=next(output))
|
||||||
|
|
||||||
|
with pytest.raises(local.IapRequestError):
|
||||||
|
local.validate_remote_main("a" * 40, runner=runner)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["main", "a" * 39, "A" * 40, "a" * 41, "a" * 39 + ";"])
|
||||||
|
def test_revision_input_is_exact(value: str, tmp_path: Path) -> None:
|
||||||
|
archive_name, key_name = local.remote_names("iap-abcdefghijkl")
|
||||||
|
with pytest.raises(local.IapRequestError, match="revision"):
|
||||||
|
local.build_gcloud_argv(
|
||||||
|
operation="install",
|
||||||
|
request_id="iap-abcdefghijkl",
|
||||||
|
runner_revision=value,
|
||||||
|
release_revision="b" * 40,
|
||||||
|
archive=tmp_path / archive_name,
|
||||||
|
archive_sha256="b" * 64,
|
||||||
|
ssh_key=tmp_path / key_name,
|
||||||
|
helper_sha256="c" * 64,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["iap-short", "iap-abcdefghijkl;id", "../iap-abcdefghijkl", "iap-ABCDEFGHIJKL"])
|
||||||
|
def test_request_id_rejects_shell_and_path_fragments(value: str) -> None:
|
||||||
|
with pytest.raises(local.IapRequestError, match="request ID"):
|
||||||
|
local.remote_names(value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mutable_release_reference_is_rejected(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
release = {
|
||||||
|
"image": {"reference": f"{local.package.IMAGE_REPOSITORY}:latest"},
|
||||||
|
"image_input": {"runtime": {"teleo_git_head": "a" * 40}},
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(local.installer, "validate_release_bundle", lambda _bundle: (release, b"unit"))
|
||||||
|
|
||||||
|
with pytest.raises(local.IapRequestError, match="immutable"):
|
||||||
|
local.validate_release(
|
||||||
|
tmp_path,
|
||||||
|
"a" * 40,
|
||||||
|
expected_release_sha256="b" * 64,
|
||||||
|
expected_image_digest=f"sha256:{'c' * 64}",
|
||||||
|
expected_image_config_digest=f"sha256:{'d' * 64}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stable_reader_rejects_symlink(tmp_path: Path) -> None:
|
||||||
|
root = private_directory(tmp_path / "root")
|
||||||
|
target = root / "target"
|
||||||
|
target.write_text("safe", encoding="utf-8")
|
||||||
|
alias = root / "alias"
|
||||||
|
alias.symlink_to(target)
|
||||||
|
|
||||||
|
with pytest.raises(local.IapRequestError, match="unsafe"):
|
||||||
|
local._read_stable_file(alias.absolute(), root=root.absolute())
|
||||||
|
|
||||||
|
|
||||||
|
def archive_manifest(payloads: dict[str, tuple[bytes, int]]) -> dict[str, Any]:
|
||||||
|
files = [
|
||||||
|
{"path": path, "sha256": local._sha256_bytes(content), "size": len(content), "mode": mode}
|
||||||
|
for path, (content, mode) in sorted(payloads.items())
|
||||||
|
]
|
||||||
|
stable = {
|
||||||
|
"schema": remote.SCHEMA,
|
||||||
|
"operation": "verify",
|
||||||
|
"request_id": "iap-abcdefghijkl",
|
||||||
|
"runner_revision": "a" * 40,
|
||||||
|
"release_revision": "b" * 40,
|
||||||
|
"target": {
|
||||||
|
"project": remote.PROJECT,
|
||||||
|
"zone": remote.ZONE,
|
||||||
|
"instance": remote.INSTANCE,
|
||||||
|
"service": remote.SERVICE,
|
||||||
|
"service_account": remote.SERVICE_ACCOUNT,
|
||||||
|
},
|
||||||
|
"release": {
|
||||||
|
"release_sha256": "b" * 64,
|
||||||
|
"image_reference": f"{remote.IMAGE_REPOSITORY}@sha256:{'c' * 64}",
|
||||||
|
"image_digest": f"sha256:{'c' * 64}",
|
||||||
|
"image_config_digest": f"sha256:{'d' * 64}",
|
||||||
|
},
|
||||||
|
"installer_source": {"revision": "a" * 40, "files": [], "sha256": "e" * 64},
|
||||||
|
"release_artifact": {"archive_sha256": "f" * 64, "archive_size": 1, "members": []},
|
||||||
|
"files": files,
|
||||||
|
}
|
||||||
|
return {**stable, "manifest_sha256": remote._canonical_sha256(stable)}
|
||||||
|
|
||||||
|
|
||||||
|
def exact_payloads() -> dict[str, tuple[bytes, int]]:
|
||||||
|
return {name: (f"payload:{name}\n".encode(), 0o644) for name in remote.EXPECTED_MEMBERS - {"request-manifest.json"}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_archive_is_deterministic_and_extracts_exact_members(tmp_path: Path) -> None:
|
||||||
|
first_root = private_directory(tmp_path / "first")
|
||||||
|
second_root = private_directory(tmp_path / "second")
|
||||||
|
extracted = private_directory(tmp_path / "extracted")
|
||||||
|
payloads = exact_payloads()
|
||||||
|
manifest = archive_manifest(payloads)
|
||||||
|
|
||||||
|
first_hash = local.build_archive(first_root / "request.tar.gz", manifest=manifest, payloads=payloads)
|
||||||
|
second_hash = local.build_archive(second_root / "request.tar.gz", manifest=manifest, payloads=payloads)
|
||||||
|
observed = remote.extract_archive(first_root / "request.tar.gz", extracted)
|
||||||
|
|
||||||
|
assert first_hash == second_hash
|
||||||
|
assert (first_root / "request.tar.gz").read_bytes() == (second_root / "request.tar.gz").read_bytes()
|
||||||
|
assert observed == manifest
|
||||||
|
assert {str(path.relative_to(extracted)) for path in extracted.rglob("*") if path.is_file()} == (
|
||||||
|
remote.EXPECTED_MEMBERS - {"request-manifest.json"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def write_unsafe_archive(path: Path, *, kind: str) -> None:
|
||||||
|
with tarfile.open(path, "w:gz") as archive:
|
||||||
|
for index, name in enumerate(sorted(remote.EXPECTED_MEMBERS)):
|
||||||
|
info = tarfile.TarInfo(name)
|
||||||
|
info.uid = 0
|
||||||
|
info.gid = 0
|
||||||
|
info.uname = "root"
|
||||||
|
info.gname = "root"
|
||||||
|
info.mtime = 0
|
||||||
|
info.mode = 0o600
|
||||||
|
content = b"{}"
|
||||||
|
if kind == "link" and index == 0:
|
||||||
|
info.type = tarfile.SYMTYPE
|
||||||
|
info.linkname = "/etc/passwd"
|
||||||
|
info.size = 0
|
||||||
|
archive.addfile(info)
|
||||||
|
continue
|
||||||
|
if kind == "oversize" and index == 0:
|
||||||
|
content = b"x" * (remote.MAX_MEMBER_BYTES + 1)
|
||||||
|
info.size = len(content)
|
||||||
|
archive.addfile(info, io.BytesIO(content))
|
||||||
|
if kind == "duplicate":
|
||||||
|
info = tarfile.TarInfo("request-manifest.json")
|
||||||
|
info.size = 2
|
||||||
|
info.mode = 0o600
|
||||||
|
info.uid = info.gid = info.mtime = 0
|
||||||
|
info.uname = info.gname = "root"
|
||||||
|
archive.addfile(info, io.BytesIO(b"{}"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kind", ["link", "oversize", "duplicate"])
|
||||||
|
def test_archive_rejects_links_oversize_and_duplicate_members(tmp_path: Path, kind: str) -> None:
|
||||||
|
path = tmp_path / f"{kind}.tar.gz"
|
||||||
|
destination = private_directory(tmp_path / kind)
|
||||||
|
write_unsafe_archive(path, kind=kind)
|
||||||
|
|
||||||
|
with pytest.raises(remote.RemoteError):
|
||||||
|
remote.extract_archive(path, destination)
|
||||||
|
|
||||||
|
|
||||||
|
def test_archive_output_requires_private_directory(tmp_path: Path) -> None:
|
||||||
|
output = tmp_path / "public"
|
||||||
|
output.mkdir(mode=0o755)
|
||||||
|
output.chmod(0o755)
|
||||||
|
with pytest.raises(local.IapRequestError, match="private"):
|
||||||
|
local.build_archive(output / "request.tar.gz", manifest={}, payloads={})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("field", "value"),
|
||||||
|
[
|
||||||
|
("expected_artifact_sha256", "not-a-hash"),
|
||||||
|
("expected_release_sha256", "a" * 63),
|
||||||
|
("expected_image_digest", "latest"),
|
||||||
|
("expected_image_config_digest", "sha256:" + "A" * 64),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_expected_release_inputs_are_exact(field: str, value: str) -> None:
|
||||||
|
arguments = {
|
||||||
|
"expected_artifact_sha256": "a" * 64,
|
||||||
|
"expected_release_sha256": "b" * 64,
|
||||||
|
"expected_image_digest": f"sha256:{'c' * 64}",
|
||||||
|
"expected_image_config_digest": f"sha256:{'d' * 64}",
|
||||||
|
}
|
||||||
|
arguments[field] = value
|
||||||
|
with pytest.raises(local.IapRequestError, match="expected"):
|
||||||
|
local._validate_expected_bindings(**arguments)
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_and_original_artifact_require_independent_expected_values(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
release = {
|
||||||
|
"release_sha256": "a" * 64,
|
||||||
|
"image": {
|
||||||
|
"reference": f"{local.package.IMAGE_REPOSITORY}@sha256:{'b' * 64}",
|
||||||
|
"digest": f"sha256:{'b' * 64}",
|
||||||
|
"config_digest": f"sha256:{'c' * 64}",
|
||||||
|
},
|
||||||
|
"image_input": {"runtime": {"teleo_git_head": "d" * 40}},
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(local.installer, "validate_release_bundle", lambda _bundle: (release, b"unit"))
|
||||||
|
with pytest.raises(local.IapRequestError, match="independently supplied"):
|
||||||
|
local.validate_release(
|
||||||
|
tmp_path,
|
||||||
|
"d" * 40,
|
||||||
|
expected_release_sha256="e" * 64,
|
||||||
|
expected_image_digest=f"sha256:{'b' * 64}",
|
||||||
|
expected_image_config_digest=f"sha256:{'c' * 64}",
|
||||||
|
)
|
||||||
|
|
||||||
|
artifact = tmp_path / "artifact.zip"
|
||||||
|
artifact.write_bytes(b"not-the-approved-artifact")
|
||||||
|
artifact.chmod(0o600)
|
||||||
|
with pytest.raises(local.IapRequestError, match="independently supplied"):
|
||||||
|
local.validate_release_artifact(
|
||||||
|
artifact,
|
||||||
|
bundle=tmp_path,
|
||||||
|
release=release,
|
||||||
|
expected_artifact_sha256="f" * 64,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dry_run_plan_records_only_bounded_action_hashes(tmp_path: Path) -> None:
|
||||||
|
plan = executable_plan(tmp_path, "install")
|
||||||
|
encoded = json.dumps(plan, sort_keys=True)
|
||||||
|
|
||||||
|
assert [item["label"] for item in plan["actions"]] == [
|
||||||
|
"upload_request",
|
||||||
|
"execute_remote_request",
|
||||||
|
"cleanup_failed_transfer",
|
||||||
|
]
|
||||||
|
assert all(set(item) == {"label", "argv_sha256"} for item in plan["actions"])
|
||||||
|
assert local.BOOTSTRAP_CODE not in encoded
|
||||||
|
assert "--command=" not in encoded
|
||||||
|
assert "sudo --" not in encoded
|
||||||
|
|
||||||
|
|
||||||
|
def test_gcloud_environment_selects_absolute_compatible_python(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
home = private_directory(tmp_path / "home")
|
||||||
|
config = private_directory(home / "gcloud")
|
||||||
|
python = tmp_path / "python311"
|
||||||
|
python.write_text("placeholder", encoding="utf-8")
|
||||||
|
python.chmod(0o700)
|
||||||
|
monkeypatch.setenv("HOME", str(home))
|
||||||
|
monkeypatch.setenv("CLOUDSDK_CONFIG", str(config))
|
||||||
|
monkeypatch.setenv("CLOUDSDK_PYTHON", str(python))
|
||||||
|
|
||||||
|
environment = local.gcloud_environment(
|
||||||
|
probe_runner=lambda argv, **_kwargs: completed(argv, stdout=b"Python 3.11.9\n")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert environment["CLOUDSDK_PYTHON"] == str(python.resolve())
|
||||||
|
assert environment["CLOUDSDK_CONFIG"] == str(config.resolve())
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_result_receipt_is_private_and_self_hashed(tmp_path: Path) -> None:
|
||||||
|
plan = executable_plan(tmp_path, "verify")
|
||||||
|
result = {"bounded": "remote-result"}
|
||||||
|
|
||||||
|
path, receipt = local._write_result_receipt(plan, result)
|
||||||
|
stable = {key: value for key, value in receipt.items() if key != "receipt_sha256"}
|
||||||
|
|
||||||
|
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
||||||
|
assert receipt["receipt_sha256"] == local.package.canonical_sha256(stable)
|
||||||
|
assert json.loads(path.read_text(encoding="utf-8")) == receipt
|
||||||
|
|
||||||
|
|
||||||
|
def test_execute_failure_does_not_forward_command_output(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
plan = executable_plan(tmp_path, "install")
|
||||||
|
monkeypatch.setattr(local, "validate_remote_main", lambda *_args, **_kwargs: None)
|
||||||
|
|
||||||
|
def runner(argv: list[str], **_kwargs: Any) -> subprocess.CompletedProcess[bytes]:
|
||||||
|
return completed(argv, returncode=1, stderr=b"password=extremely-secret")
|
||||||
|
|
||||||
|
with pytest.raises(local.IapRequestError) as caught:
|
||||||
|
local.execute_plan(plan, runner=runner, gcloud_env={"PATH": "/usr/bin"})
|
||||||
|
assert "extremely-secret" not in str(caught.value)
|
||||||
|
assert "password" not in str(caught.value).casefold()
|
||||||
|
|
||||||
|
|
||||||
|
def test_execute_rejects_unsanitized_remote_result(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
plan = executable_plan(tmp_path, "verify")
|
||||||
|
monkeypatch.setattr(local, "validate_remote_main", lambda *_args, **_kwargs: None)
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def runner(argv: list[str], **_kwargs: Any) -> subprocess.CompletedProcess[bytes]:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
if calls == 1:
|
||||||
|
return completed(argv)
|
||||||
|
payload = {
|
||||||
|
"schema": local.REMOTE_RESULT_SCHEMA,
|
||||||
|
"status": "pass",
|
||||||
|
"operation": "verify",
|
||||||
|
"request_id": "iap-abcdefghijkl",
|
||||||
|
"access_token": "not-allowed",
|
||||||
|
}
|
||||||
|
return completed(argv, stdout=json.dumps(payload).encode())
|
||||||
|
|
||||||
|
with pytest.raises(local.IapRequestError) as caught:
|
||||||
|
local.execute_plan(plan, runner=runner, gcloud_env={"PATH": "/usr/bin"})
|
||||||
|
assert "not-allowed" not in str(caught.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_execute_rederives_commands_and_rejects_plan_tampering(tmp_path: Path) -> None:
|
||||||
|
plan = executable_plan(tmp_path, "verify")
|
||||||
|
plan["actions"][1]["argv_sha256"] = "0" * 64
|
||||||
|
|
||||||
|
with pytest.raises(local.IapRequestError, match="fixed IAP plan"):
|
||||||
|
local.execute_plan(plan, runner=lambda argv, **_kwargs: completed(argv))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("fail_pull", [False, True])
|
||||||
|
def test_ephemeral_registry_credentials_are_always_removed(tmp_path: Path, fail_pull: bool) -> None:
|
||||||
|
calls: list[tuple[list[str], bytes | None]] = []
|
||||||
|
|
||||||
|
def runner(argv: list[str], **kwargs: Any) -> subprocess.CompletedProcess[bytes]:
|
||||||
|
input_bytes = kwargs.get("input")
|
||||||
|
calls.append((argv, input_bytes))
|
||||||
|
if argv[0] == "/usr/bin/curl":
|
||||||
|
return completed(argv, stdout=b'{"access_token":"abcdefghijklmnop","token_type":"Bearer"}')
|
||||||
|
if "pull" in argv and fail_pull:
|
||||||
|
return completed(argv, returncode=1, stderr=b"registry said secret")
|
||||||
|
return completed(argv)
|
||||||
|
|
||||||
|
reference = f"{remote.IMAGE_REPOSITORY}@sha256:{'a' * 64}"
|
||||||
|
if fail_pull:
|
||||||
|
with pytest.raises(remote.RemoteError, match="immutable image pull"):
|
||||||
|
remote.pull_immutable_image(reference, tmp_path, runner=runner)
|
||||||
|
else:
|
||||||
|
remote.pull_immutable_image(reference, tmp_path, runner=runner)
|
||||||
|
assert not any(path.name.startswith("docker-auth-") for path in tmp_path.iterdir())
|
||||||
|
assert all("abcdefghijklmnop" not in " ".join(argv) for argv, _input in calls)
|
||||||
|
assert any(input_bytes == b"abcdefghijklmnop\n" for _argv, input_bytes in calls)
|
||||||
|
assert any("logout" in argv for argv, _input in calls)
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_target_binds_project_zone_instance_and_service_account() -> None:
|
||||||
|
values = {
|
||||||
|
"instance/service-accounts/default/email": remote.SERVICE_ACCOUNT,
|
||||||
|
"project/project-id": remote.PROJECT,
|
||||||
|
"instance/name": remote.INSTANCE,
|
||||||
|
"instance/zone": f"projects/123/zones/{remote.ZONE}",
|
||||||
|
}
|
||||||
|
|
||||||
|
def runner(argv: list[str], **_kwargs: Any) -> subprocess.CompletedProcess[bytes]:
|
||||||
|
path = argv[-1].removeprefix(f"{remote.METADATA}/")
|
||||||
|
return completed(argv, stdout=values[path].encode())
|
||||||
|
|
||||||
|
remote.verify_service_account(runner=runner)
|
||||||
|
|
||||||
|
values["instance/name"] = "wrong-instance"
|
||||||
|
with pytest.raises(remote.RemoteError, match="target or service account"):
|
||||||
|
remote.verify_service_account(runner=runner)
|
||||||
|
|
||||||
|
|
||||||
|
def test_outer_lock_and_reused_request_id_fail_closed(tmp_path: Path) -> None:
|
||||||
|
lock = tmp_path / "runner.lock"
|
||||||
|
first = remote._acquire_lock(lock, expected_uid=os.geteuid())
|
||||||
|
try:
|
||||||
|
with pytest.raises(remote.RemoteError, match="another request"):
|
||||||
|
remote._acquire_lock(lock, expected_uid=os.geteuid())
|
||||||
|
finally:
|
||||||
|
os.close(first)
|
||||||
|
|
||||||
|
root = tmp_path / "requests"
|
||||||
|
claimed = remote._claim_request_directory(
|
||||||
|
root,
|
||||||
|
"iap-abcdefghijkl",
|
||||||
|
expected_uid=os.geteuid(),
|
||||||
|
trusted_parent=tmp_path,
|
||||||
|
)
|
||||||
|
assert stat.S_IMODE(claimed.stat().st_mode) == 0o700
|
||||||
|
with pytest.raises(remote.RemoteError, match="already used"):
|
||||||
|
remote._claim_request_directory(
|
||||||
|
root,
|
||||||
|
"iap-abcdefghijkl",
|
||||||
|
expected_uid=os.geteuid(),
|
||||||
|
trusted_parent=tmp_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rollback_uses_only_explicit_receipt_bound_interface(tmp_path: Path) -> None:
|
||||||
|
observed: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def rollback(release_sha256: str, **kwargs: Any) -> dict[str, Any]:
|
||||||
|
observed.update({"release_sha256": release_sha256, **kwargs})
|
||||||
|
return {"schema": "rollback", "status": "pass"}
|
||||||
|
|
||||||
|
installer = SimpleNamespace(rollback_release=rollback)
|
||||||
|
result = remote._execute_explicit_rollback(
|
||||||
|
installer_module=installer,
|
||||||
|
release_sha256="a" * 64,
|
||||||
|
paths=object(),
|
||||||
|
source_binding={"revision": "b" * 40},
|
||||||
|
runner=lambda argv, **_kwargs: completed(argv),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "pass"
|
||||||
|
assert observed["release_sha256"] == "a" * 64
|
||||||
|
assert observed["execute_rollback"] is True
|
||||||
|
assert "install_release" not in observed
|
||||||
|
|
||||||
|
|
||||||
|
def test_rollback_fails_when_explicit_interface_is_absent(tmp_path: Path) -> None:
|
||||||
|
with pytest.raises(remote.RemoteError, match="lacked the explicit rollback"):
|
||||||
|
remote._execute_explicit_rollback(
|
||||||
|
installer_module=SimpleNamespace(),
|
||||||
|
release_sha256="a" * 64,
|
||||||
|
paths=object(),
|
||||||
|
source_binding={},
|
||||||
|
runner=lambda argv, **_kwargs: completed(argv),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("failure_point", ["first_database", "restart", "second_database"])
|
||||||
|
def test_post_install_proof_failure_executes_explicit_rollback(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
failure_point: str,
|
||||||
|
) -> None:
|
||||||
|
release = {
|
||||||
|
"release_sha256": "a" * 64,
|
||||||
|
"image": {
|
||||||
|
"reference": f"{remote.IMAGE_REPOSITORY}@sha256:{'b' * 64}",
|
||||||
|
"digest": f"sha256:{'b' * 64}",
|
||||||
|
"config_digest": f"sha256:{'c' * 64}",
|
||||||
|
},
|
||||||
|
"image_input": {"runtime": {"teleo_git_head": "d" * 40}},
|
||||||
|
}
|
||||||
|
manifest = {
|
||||||
|
"operation": "install",
|
||||||
|
"request_id": "iap-abcdefghijkl",
|
||||||
|
"runner_revision": "e" * 40,
|
||||||
|
"release_revision": "d" * 40,
|
||||||
|
"manifest_sha256": "f" * 64,
|
||||||
|
"release": {
|
||||||
|
"release_sha256": release["release_sha256"],
|
||||||
|
"image_reference": release["image"]["reference"],
|
||||||
|
"image_digest": release["image"]["digest"],
|
||||||
|
"image_config_digest": release["image"]["config_digest"],
|
||||||
|
},
|
||||||
|
"release_artifact": {"archive_sha256": "1" * 64},
|
||||||
|
"installer_source": {"revision": "e" * 40},
|
||||||
|
}
|
||||||
|
verifications = iter(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"main_pid": 10,
|
||||||
|
"invocation_id": "2" * 32,
|
||||||
|
"container_id": "3" * 64,
|
||||||
|
"n_restarts": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"main_pid": 11,
|
||||||
|
"invocation_id": "4" * 32,
|
||||||
|
"container_id": "5" * 64,
|
||||||
|
"n_restarts": 0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
rollbacks: list[str] = []
|
||||||
|
|
||||||
|
class Paths:
|
||||||
|
@staticmethod
|
||||||
|
def under(_root: Path) -> object:
|
||||||
|
return object()
|
||||||
|
|
||||||
|
installer_module = SimpleNamespace(
|
||||||
|
SYSTEMCTL="/usr/bin/systemctl",
|
||||||
|
InstallPaths=Paths,
|
||||||
|
validate_release_bundle=lambda _bundle: (release, b"unit"),
|
||||||
|
_validate_source_binding=lambda _binding: None,
|
||||||
|
install_release=lambda *_args, **_kwargs: {"status": "pass"},
|
||||||
|
verify_running_release=lambda *_args, **_kwargs: next(verifications),
|
||||||
|
rollback_release=lambda release_sha256, **_kwargs: (
|
||||||
|
rollbacks.append(release_sha256)
|
||||||
|
or {
|
||||||
|
"schema": "livingip.leocleanNoSendRollbackResult.v1",
|
||||||
|
"status": "pass",
|
||||||
|
"mode": "executed",
|
||||||
|
"rolled_back_release_sha256": release_sha256,
|
||||||
|
"restored_release_sha256": None,
|
||||||
|
"restored_service_posture": "absent",
|
||||||
|
"verification": None,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
package_module = SimpleNamespace(
|
||||||
|
render_systemd_unit=lambda _release: "unit",
|
||||||
|
canonical_sha256=local.package.canonical_sha256,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(remote, "validate_original_release_artifact", lambda *_args, **_kwargs: None)
|
||||||
|
monkeypatch.setattr(remote, "_load_contract_modules", lambda _root: (package_module, installer_module))
|
||||||
|
monkeypatch.setattr(remote, "pull_immutable_image", lambda *_args, **_kwargs: None)
|
||||||
|
database_calls = 0
|
||||||
|
|
||||||
|
def verify_database(**_kwargs: Any) -> dict[str, Any]:
|
||||||
|
nonlocal database_calls
|
||||||
|
database_calls += 1
|
||||||
|
if failure_point == "first_database" and database_calls == 1:
|
||||||
|
raise remote.RemoteError("database failed")
|
||||||
|
if failure_point == "second_database" and database_calls == 2:
|
||||||
|
raise remote.RemoteError("database failed")
|
||||||
|
return {"status": "pass"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(remote, "_verify_database", verify_database)
|
||||||
|
|
||||||
|
def runner(argv: list[str], **_kwargs: Any) -> subprocess.CompletedProcess[bytes]:
|
||||||
|
if failure_point == "restart" and argv[:2] == ["/usr/bin/systemctl", "restart"]:
|
||||||
|
return completed(argv, returncode=1)
|
||||||
|
return completed(argv)
|
||||||
|
|
||||||
|
with pytest.raises(remote.RemoteError, match="install_failed_rolled_back"):
|
||||||
|
remote.execute_request(
|
||||||
|
manifest,
|
||||||
|
tmp_path,
|
||||||
|
tmp_path,
|
||||||
|
"6" * 64,
|
||||||
|
"7" * 64,
|
||||||
|
runner=runner,
|
||||||
|
)
|
||||||
|
assert rollbacks == [release["release_sha256"]]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("posture", "restored_release", "verification"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"active",
|
||||||
|
"e" * 64,
|
||||||
|
{
|
||||||
|
"active_state": "active",
|
||||||
|
"sub_state": "running",
|
||||||
|
"container_id": "f" * 64,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
("inactive", "e" * 64, None),
|
||||||
|
("absent", None, None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_rollback_result_accepts_exact_recorded_postures(
|
||||||
|
posture: str,
|
||||||
|
restored_release: str | None,
|
||||||
|
verification: dict[str, Any] | None,
|
||||||
|
) -> None:
|
||||||
|
manifest = {
|
||||||
|
"request_id": "iap-abcdefghijkl",
|
||||||
|
"runner_revision": "a" * 40,
|
||||||
|
"release_revision": "d" * 40,
|
||||||
|
"manifest_sha256": "1" * 64,
|
||||||
|
"release_artifact": {"archive_sha256": "2" * 64},
|
||||||
|
}
|
||||||
|
release = {
|
||||||
|
"release_sha256": "b" * 64,
|
||||||
|
"image": {"digest": f"sha256:{'c' * 64}", "config_digest": f"sha256:{'d' * 64}"},
|
||||||
|
}
|
||||||
|
rollback = {
|
||||||
|
"schema": "livingip.leocleanNoSendRollbackResult.v1",
|
||||||
|
"status": "pass",
|
||||||
|
"mode": "executed",
|
||||||
|
"rolled_back_release_sha256": "b" * 64,
|
||||||
|
"restored_release_sha256": restored_release,
|
||||||
|
"restored_service_posture": posture,
|
||||||
|
"verification": verification,
|
||||||
|
}
|
||||||
|
|
||||||
|
result = remote._rollback_result(
|
||||||
|
manifest=manifest,
|
||||||
|
release=release,
|
||||||
|
rollback=rollback,
|
||||||
|
archive_sha256="3" * 64,
|
||||||
|
helper_sha256="4" * 64,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["service"]["restored_posture"] == posture
|
||||||
|
assert result["database"] == {"status": "not_run_after_explicit_rollback"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_absent_rollback_rejects_invented_database_or_runtime_verification() -> None:
|
||||||
|
manifest = {
|
||||||
|
"request_id": "iap-abcdefghijkl",
|
||||||
|
"runner_revision": "a" * 40,
|
||||||
|
"release_revision": "d" * 40,
|
||||||
|
"manifest_sha256": "1" * 64,
|
||||||
|
"release_artifact": {"archive_sha256": "2" * 64},
|
||||||
|
}
|
||||||
|
release = {
|
||||||
|
"release_sha256": "b" * 64,
|
||||||
|
"image": {"digest": f"sha256:{'c' * 64}", "config_digest": f"sha256:{'d' * 64}"},
|
||||||
|
}
|
||||||
|
rollback = {
|
||||||
|
"schema": "livingip.leocleanNoSendRollbackResult.v1",
|
||||||
|
"status": "pass",
|
||||||
|
"mode": "executed",
|
||||||
|
"rolled_back_release_sha256": "b" * 64,
|
||||||
|
"restored_release_sha256": None,
|
||||||
|
"restored_service_posture": "absent",
|
||||||
|
"verification": {"active_state": "active"},
|
||||||
|
}
|
||||||
|
with pytest.raises(remote.RemoteError, match="absent rollback"):
|
||||||
|
remote._rollback_result(
|
||||||
|
manifest=manifest,
|
||||||
|
release=release,
|
||||||
|
rollback=rollback,
|
||||||
|
archive_sha256="3" * 64,
|
||||||
|
helper_sha256="4" * 64,
|
||||||
|
)
|
||||||
|
|
@ -337,6 +337,20 @@ def test_alternate_repo_root_cannot_bind_the_executing_installer(tmp_path: Path)
|
||||||
installer.parse_args(["--bundle", str(tmp_path), "--repo-root", str(tmp_path)])
|
installer.parse_args(["--bundle", str(tmp_path), "--repo-root", str(tmp_path)])
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_rollback_cli_is_dry_run_unless_execute_flag_is_separate() -> None:
|
||||||
|
release_sha256 = "a" * 64
|
||||||
|
|
||||||
|
dry_run = installer.parse_args(["--rollback-release", release_sha256])
|
||||||
|
execute = installer.parse_args(["--rollback-release", release_sha256, "--execute-rollback"])
|
||||||
|
|
||||||
|
assert dry_run.execute_rollback is False
|
||||||
|
assert execute.execute_rollback is True
|
||||||
|
with pytest.raises(installer.InstallError, match="invalid arguments"):
|
||||||
|
installer.parse_args(["--execute-rollback"])
|
||||||
|
with pytest.raises(installer.InstallError, match="invalid arguments"):
|
||||||
|
installer.parse_args(["--bundle", "/tmp/release", "--rollback-release", release_sha256])
|
||||||
|
|
||||||
|
|
||||||
def test_rehashed_alternate_source_bytes_cannot_bypass_actual_source_binding(tmp_path: Path) -> None:
|
def test_rehashed_alternate_source_bytes_cannot_bypass_actual_source_binding(tmp_path: Path) -> None:
|
||||||
bundle, _release, _image = release_bundle(tmp_path, "2")
|
bundle, _release, _image = release_bundle(tmp_path, "2")
|
||||||
paths = install_paths(tmp_path)
|
paths = install_paths(tmp_path)
|
||||||
|
|
@ -372,6 +386,7 @@ def test_disposable_root_install_is_digest_bound_idempotent_and_exact(tmp_path:
|
||||||
)
|
)
|
||||||
|
|
||||||
assert receipt["status"] == "pass"
|
assert receipt["status"] == "pass"
|
||||||
|
assert receipt["schema"] == "livingip.leocleanNoSendInstallReceipt.v2"
|
||||||
assert receipt["release_sha256"] == release["release_sha256"]
|
assert receipt["release_sha256"] == release["release_sha256"]
|
||||||
assert host.restart_calls == 1
|
assert host.restart_calls == 1
|
||||||
assert paths.unit.read_text(encoding="utf-8") == package.render_systemd_unit(release)
|
assert paths.unit.read_text(encoding="utf-8") == package.render_systemd_unit(release)
|
||||||
|
|
@ -379,6 +394,11 @@ def test_disposable_root_install_is_digest_bound_idempotent_and_exact(tmp_path:
|
||||||
assert paths.helper.stat().st_mode & 0o777 == 0o755
|
assert paths.helper.stat().st_mode & 0o777 == 0o755
|
||||||
assert paths.release.stat().st_mode & 0o777 == 0o600
|
assert paths.release.stat().st_mode & 0o777 == 0o600
|
||||||
assert paths.receipt.stat().st_mode & 0o777 == 0o600
|
assert paths.receipt.stat().st_mode & 0o777 == 0o600
|
||||||
|
assert paths.rollback_state.stat().st_mode & 0o777 == 0o600
|
||||||
|
rollback_state = json.loads(paths.rollback_state.read_text(encoding="utf-8"))
|
||||||
|
assert rollback_state["schema"] == installer.ROLLBACK_STATE_SCHEMA
|
||||||
|
assert rollback_state["current_release_sha256"] == release["release_sha256"]
|
||||||
|
assert receipt["rollback"]["state_sha256"] == package.sha256_file(paths.rollback_state)
|
||||||
assert not list(paths.docker_config.iterdir())
|
assert not list(paths.docker_config.iterdir())
|
||||||
assert not list(paths.unit.parent.glob(f".{paths.unit.name}.*.tmp"))
|
assert not list(paths.unit.parent.glob(f".{paths.unit.name}.*.tmp"))
|
||||||
|
|
||||||
|
|
@ -962,6 +982,331 @@ def test_effective_process_environment_override_is_rejected_and_rolled_back(tmp_
|
||||||
assert not paths.state_dir.exists()
|
assert not paths.state_dir.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_rollback_is_dry_run_by_default_and_removes_first_install(tmp_path: Path) -> None:
|
||||||
|
bundle, release, image = release_bundle(tmp_path, "2")
|
||||||
|
paths = install_paths(tmp_path)
|
||||||
|
host = FakeHost(paths)
|
||||||
|
host.add_release(release, image)
|
||||||
|
installer.install_release(
|
||||||
|
bundle,
|
||||||
|
paths=paths,
|
||||||
|
source_binding=source_binding(),
|
||||||
|
runner=host,
|
||||||
|
sleeper=lambda _seconds: None,
|
||||||
|
execute_restart=True,
|
||||||
|
)
|
||||||
|
before_calls = list(host.calls)
|
||||||
|
before_stops = host.stop_calls
|
||||||
|
before_restarts = host.restart_calls
|
||||||
|
|
||||||
|
plan = installer.rollback_release(
|
||||||
|
str(release["release_sha256"]),
|
||||||
|
paths=paths,
|
||||||
|
source_binding=source_binding(),
|
||||||
|
runner=host,
|
||||||
|
sleeper=lambda _seconds: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert plan["mode"] == "dry_run"
|
||||||
|
assert plan["installed_release_sha256"] == release["release_sha256"]
|
||||||
|
assert plan["target_release_sha256"] is None
|
||||||
|
assert plan["target_service_posture"] == "absent"
|
||||||
|
assert host.stop_calls == before_stops
|
||||||
|
assert host.restart_calls == before_restarts
|
||||||
|
assert host.calls != before_calls
|
||||||
|
assert host.active
|
||||||
|
|
||||||
|
with pytest.raises(installer.InstallError, match="did not match the expected release hash"):
|
||||||
|
installer.rollback_release(
|
||||||
|
"f" * 64,
|
||||||
|
paths=paths,
|
||||||
|
source_binding=source_binding(),
|
||||||
|
runner=host,
|
||||||
|
sleeper=lambda _seconds: None,
|
||||||
|
execute_rollback=True,
|
||||||
|
)
|
||||||
|
assert host.stop_calls == before_stops
|
||||||
|
|
||||||
|
result = installer.rollback_release(
|
||||||
|
str(release["release_sha256"]),
|
||||||
|
paths=paths,
|
||||||
|
source_binding=source_binding(),
|
||||||
|
runner=host,
|
||||||
|
sleeper=lambda _seconds: None,
|
||||||
|
execute_rollback=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"schema": installer.ROLLBACK_RESULT_SCHEMA,
|
||||||
|
"status": "pass",
|
||||||
|
"mode": "executed",
|
||||||
|
"rolled_back_release_sha256": release["release_sha256"],
|
||||||
|
"restored_release_sha256": None,
|
||||||
|
"restored_service_posture": "absent",
|
||||||
|
"verification": None,
|
||||||
|
"authority": {
|
||||||
|
"image_pulled": False,
|
||||||
|
"cloud_or_database_authority_changed": False,
|
||||||
|
"transport_authority_changed": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert not host.active
|
||||||
|
assert host.loaded_release is None
|
||||||
|
assert not paths.unit.exists()
|
||||||
|
assert not paths.helper.exists()
|
||||||
|
assert not paths.state_dir.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_rollback_restores_exact_previous_active_release_and_history(tmp_path: Path) -> None:
|
||||||
|
first_bundle, first_release, first_image = release_bundle(tmp_path, "2")
|
||||||
|
second_bundle, second_release, second_image = release_bundle(tmp_path, "4")
|
||||||
|
paths = install_paths(tmp_path)
|
||||||
|
host = FakeHost(paths)
|
||||||
|
host.add_release(first_release, first_image)
|
||||||
|
host.add_release(second_release, second_image)
|
||||||
|
installer.install_release(
|
||||||
|
first_bundle,
|
||||||
|
paths=paths,
|
||||||
|
source_binding=source_binding(),
|
||||||
|
runner=host,
|
||||||
|
sleeper=lambda _seconds: None,
|
||||||
|
execute_restart=True,
|
||||||
|
)
|
||||||
|
first_files = {role: path.read_bytes() for role, (path, _mode) in installer._managed_files(paths).items()}
|
||||||
|
installer.install_release(
|
||||||
|
second_bundle,
|
||||||
|
paths=paths,
|
||||||
|
source_binding=source_binding(),
|
||||||
|
runner=host,
|
||||||
|
sleeper=lambda _seconds: None,
|
||||||
|
execute_restart=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = installer.rollback_release(
|
||||||
|
str(second_release["release_sha256"]),
|
||||||
|
paths=paths,
|
||||||
|
source_binding=source_binding(),
|
||||||
|
runner=host,
|
||||||
|
sleeper=lambda _seconds: None,
|
||||||
|
execute_rollback=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["restored_release_sha256"] == first_release["release_sha256"]
|
||||||
|
assert result["restored_service_posture"] == "active"
|
||||||
|
assert host.current_release == first_release
|
||||||
|
assert {role: path.read_bytes() for role, (path, _mode) in installer._managed_files(paths).items()} == first_files
|
||||||
|
|
||||||
|
nested = installer.rollback_release(
|
||||||
|
str(first_release["release_sha256"]),
|
||||||
|
paths=paths,
|
||||||
|
source_binding=source_binding(),
|
||||||
|
runner=host,
|
||||||
|
sleeper=lambda _seconds: None,
|
||||||
|
execute_rollback=True,
|
||||||
|
)
|
||||||
|
assert nested["restored_service_posture"] == "absent"
|
||||||
|
assert not paths.state_dir.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_rollback_restores_previous_inactive_posture(tmp_path: Path) -> None:
|
||||||
|
first_bundle, first_release, first_image = release_bundle(tmp_path, "2")
|
||||||
|
second_bundle, second_release, second_image = release_bundle(tmp_path, "4")
|
||||||
|
paths = install_paths(tmp_path)
|
||||||
|
host = FakeHost(paths)
|
||||||
|
host.add_release(first_release, first_image)
|
||||||
|
host.add_release(second_release, second_image)
|
||||||
|
installer.install_release(
|
||||||
|
first_bundle,
|
||||||
|
paths=paths,
|
||||||
|
source_binding=source_binding(),
|
||||||
|
runner=host,
|
||||||
|
sleeper=lambda _seconds: None,
|
||||||
|
execute_restart=True,
|
||||||
|
)
|
||||||
|
host._stop()
|
||||||
|
installer.install_release(
|
||||||
|
second_bundle,
|
||||||
|
paths=paths,
|
||||||
|
source_binding=source_binding(),
|
||||||
|
runner=host,
|
||||||
|
sleeper=lambda _seconds: None,
|
||||||
|
execute_restart=True,
|
||||||
|
)
|
||||||
|
restarts = host.restart_calls
|
||||||
|
|
||||||
|
result = installer.rollback_release(
|
||||||
|
str(second_release["release_sha256"]),
|
||||||
|
paths=paths,
|
||||||
|
source_binding=source_binding(),
|
||||||
|
runner=host,
|
||||||
|
sleeper=lambda _seconds: None,
|
||||||
|
execute_rollback=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["restored_release_sha256"] == first_release["release_sha256"]
|
||||||
|
assert result["restored_service_posture"] == "inactive"
|
||||||
|
assert not host.active
|
||||||
|
assert host.loaded_release == first_release
|
||||||
|
assert host.restart_calls == restarts
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_explicit_rollback_recovers_current_release(tmp_path: Path) -> None:
|
||||||
|
first_bundle, first_release, first_image = release_bundle(tmp_path, "2")
|
||||||
|
second_bundle, second_release, second_image = release_bundle(tmp_path, "4")
|
||||||
|
paths = install_paths(tmp_path)
|
||||||
|
host = FakeHost(paths)
|
||||||
|
host.add_release(first_release, first_image)
|
||||||
|
host.add_release(second_release, second_image)
|
||||||
|
installer.install_release(
|
||||||
|
first_bundle,
|
||||||
|
paths=paths,
|
||||||
|
source_binding=source_binding(),
|
||||||
|
runner=host,
|
||||||
|
sleeper=lambda _seconds: None,
|
||||||
|
execute_restart=True,
|
||||||
|
)
|
||||||
|
installer.install_release(
|
||||||
|
second_bundle,
|
||||||
|
paths=paths,
|
||||||
|
source_binding=source_binding(),
|
||||||
|
runner=host,
|
||||||
|
sleeper=lambda _seconds: None,
|
||||||
|
execute_restart=True,
|
||||||
|
)
|
||||||
|
second_files = {role: path.read_bytes() for role, (path, _mode) in installer._managed_files(paths).items()}
|
||||||
|
fail_next_reload = True
|
||||||
|
|
||||||
|
def runner(arguments: list[str]) -> subprocess.CompletedProcess[bytes]:
|
||||||
|
nonlocal fail_next_reload
|
||||||
|
if arguments[:2] == [installer.SYSTEMCTL, "daemon-reload"] and fail_next_reload:
|
||||||
|
fail_next_reload = False
|
||||||
|
return subprocess.CompletedProcess(arguments, 1, stdout=b"", stderr=b"injected")
|
||||||
|
return host(arguments)
|
||||||
|
|
||||||
|
with pytest.raises(installer.InstallError, match="systemd daemon-reload failed"):
|
||||||
|
installer.rollback_release(
|
||||||
|
str(second_release["release_sha256"]),
|
||||||
|
paths=paths,
|
||||||
|
source_binding=source_binding(),
|
||||||
|
runner=runner,
|
||||||
|
sleeper=lambda _seconds: None,
|
||||||
|
execute_rollback=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert host.active
|
||||||
|
assert host.current_release == second_release
|
||||||
|
assert {role: path.read_bytes() for role, (path, _mode) in installer._managed_files(paths).items()} == second_files
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_rollback_rejects_receipt_drift_before_service_stop(tmp_path: Path) -> None:
|
||||||
|
bundle, release, image = release_bundle(tmp_path, "2")
|
||||||
|
paths = install_paths(tmp_path)
|
||||||
|
host = FakeHost(paths)
|
||||||
|
host.add_release(release, image)
|
||||||
|
installer.install_release(
|
||||||
|
bundle,
|
||||||
|
paths=paths,
|
||||||
|
source_binding=source_binding(),
|
||||||
|
runner=host,
|
||||||
|
sleeper=lambda _seconds: None,
|
||||||
|
execute_restart=True,
|
||||||
|
)
|
||||||
|
receipt = json.loads(paths.receipt.read_text(encoding="utf-8"))
|
||||||
|
receipt["release_sha256"] = "f" * 64
|
||||||
|
paths.receipt.write_text(json.dumps(receipt), encoding="utf-8")
|
||||||
|
paths.receipt.chmod(0o600)
|
||||||
|
stop_calls = host.stop_calls
|
||||||
|
|
||||||
|
with pytest.raises(installer.InstallError, match=r"(installed|snapshot install) receipt was invalid"):
|
||||||
|
installer.rollback_release(
|
||||||
|
str(release["release_sha256"]),
|
||||||
|
paths=paths,
|
||||||
|
source_binding=source_binding(),
|
||||||
|
runner=host,
|
||||||
|
sleeper=lambda _seconds: None,
|
||||||
|
execute_rollback=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert host.stop_calls == stop_calls
|
||||||
|
assert host.active
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_rollback_rejects_path_inode_drift_before_service_stop(tmp_path: Path) -> None:
|
||||||
|
bundle, release, image = release_bundle(tmp_path, "2")
|
||||||
|
paths = install_paths(tmp_path)
|
||||||
|
host = FakeHost(paths)
|
||||||
|
host.add_release(release, image)
|
||||||
|
installer.install_release(
|
||||||
|
bundle,
|
||||||
|
paths=paths,
|
||||||
|
source_binding=source_binding(),
|
||||||
|
runner=host,
|
||||||
|
sleeper=lambda _seconds: None,
|
||||||
|
execute_restart=True,
|
||||||
|
)
|
||||||
|
prepared = installer._prepare_explicit_rollback(
|
||||||
|
str(release["release_sha256"]),
|
||||||
|
paths=paths,
|
||||||
|
source_binding=source_binding(),
|
||||||
|
runner=host,
|
||||||
|
sleeper=lambda _seconds: None,
|
||||||
|
)
|
||||||
|
unit = paths.unit.read_bytes()
|
||||||
|
paths.unit.unlink()
|
||||||
|
paths.unit.write_bytes(unit)
|
||||||
|
paths.unit.chmod(0o644)
|
||||||
|
stop_calls = host.stop_calls
|
||||||
|
|
||||||
|
with pytest.raises(installer.InstallError, match="file identity drifted"):
|
||||||
|
installer._execute_explicit_rollback(
|
||||||
|
str(release["release_sha256"]),
|
||||||
|
prepared,
|
||||||
|
paths=paths,
|
||||||
|
runner=host,
|
||||||
|
sleeper=lambda _seconds: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert host.stop_calls == stop_calls
|
||||||
|
assert host.active
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_rollback_rejects_pid_drift_before_service_stop(tmp_path: Path) -> None:
|
||||||
|
bundle, release, image = release_bundle(tmp_path, "2")
|
||||||
|
paths = install_paths(tmp_path)
|
||||||
|
host = FakeHost(paths)
|
||||||
|
host.add_release(release, image)
|
||||||
|
installer.install_release(
|
||||||
|
bundle,
|
||||||
|
paths=paths,
|
||||||
|
source_binding=source_binding(),
|
||||||
|
runner=host,
|
||||||
|
sleeper=lambda _seconds: None,
|
||||||
|
execute_restart=True,
|
||||||
|
)
|
||||||
|
prepared = installer._prepare_explicit_rollback(
|
||||||
|
str(release["release_sha256"]),
|
||||||
|
paths=paths,
|
||||||
|
source_binding=source_binding(),
|
||||||
|
runner=host,
|
||||||
|
sleeper=lambda _seconds: None,
|
||||||
|
)
|
||||||
|
host.main_pid += 1
|
||||||
|
host._write_process_environment()
|
||||||
|
stop_calls = host.stop_calls
|
||||||
|
|
||||||
|
with pytest.raises(installer.InstallError, match=r"changed during verification|drifted before rollback stop"):
|
||||||
|
installer._execute_explicit_rollback(
|
||||||
|
str(release["release_sha256"]),
|
||||||
|
prepared,
|
||||||
|
paths=paths,
|
||||||
|
runner=host,
|
||||||
|
sleeper=lambda _seconds: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert host.stop_calls == stop_calls
|
||||||
|
assert host.active
|
||||||
|
|
||||||
|
|
||||||
def test_control_helper_stops_only_trusted_full_container_id(tmp_path: Path) -> None:
|
def test_control_helper_stops_only_trusted_full_container_id(tmp_path: Path) -> None:
|
||||||
cid_parent = tmp_path / "runtime"
|
cid_parent = tmp_path / "runtime"
|
||||||
cid_parent.mkdir(mode=0o700)
|
cid_parent.mkdir(mode=0o700)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue