Add fail-closed leoclean staging installer
Some checks are pending
CI / lint-and-test (push) Waiting to run

This commit is contained in:
fwazb 2026-07-21 10:58:36 -07:00
parent 70e8f1fe35
commit a0fabc3072
7 changed files with 3018 additions and 36 deletions

View file

@ -246,6 +246,65 @@ extra descriptor fields, wrong-architecture images, missing repository-digest
bindings, mismatched image labels, duplicate JSON keys, and non-finite JSON bindings, mismatched image labels, duplicate JSON keys, and non-finite JSON
values are rejected. values are rejected.
## Install, restart, verify, and roll back
The installer consumes one finalized release-v3 bundle. Its default invocation
is a dry run: it validates the exact two-file bundle, re-renders the unit from
the descriptor, binds the installer/package/control-helper source closure to
the clean Git revision and this checkout's fixed contract root, captures those
stable bytes once, and prints the proposed target without changing the host:
```bash
python3 ops/install_gcp_leoclean_nosend_release.py \
--bundle /tmp/leoclean-nosend-release
```
After separate host-restart authorization, the same reviewed revision can be
executed as root:
```bash
sudo python3 ops/install_gcp_leoclean_nosend_release.py \
--bundle /tmp/leoclean-nosend-release \
--execute-restart
```
The executable path never pulls an image. Before touching the service it
requires the exact digest reference and normalized config digest already to be
present on the local `unix:///var/run/docker.sock` daemon through an empty,
service-private Docker config directory. The complete executable transaction,
including rollback, holds a nonblocking root-owned mode-`0600` lock at
`/run/leoclean-gcp-nosend-installer.lock`. It installs only the deterministic
unit, a standalone release-bound container-control helper, the exact release
descriptor, and a mode-`0600` install receipt. Atomic publications retain the
published inode identity across durability/readback failure; rollback refuses
to overwrite a pathname that no longer names that inode. The helper controls a
container only through a mode-`0600` CID file in the service's mode-`0700`
runtime directory, then rechecks the full ID, name, immutable image reference,
config digest, release hash, role label, and running state. After the trusted ID
disappears it rechecks that the canonical name is still unbound before removing
the CID file. It never removes a container, uses a mutable name as stop
authority, or invokes `--force`.
An already installed release is verified without a restart. During an upgrade,
the installer accepts only a stable two-readback active/running or inactive/dead
prior posture. Before trusting the old `ExecStop`, it exact-binds effective
`ExecStartPre`, `ExecStart`, `ExecStop`, runtime-directory and systemd security
properties to the release and verifies the supervising PID's executable,
command line and cgroup through `/proc`. It then stops the prior service through
that currently loaded release-bound unit and only afterward replaces and
reloads the unit. Candidate verification also rejects filesystem or effective
systemd drop-ins, environment files, forbidden inherited process environment,
an unexpected fragment, image/config/runtime drift, a non-healthy container,
or any PID, container-ID, invocation-ID, or restart-counter change across the
two readbacks. On failure it stops the candidate, restores the exact prior
files, modes and active/inactive/absent posture, and reloads systemd. An
incomplete rollback fails closed and is reported as such.
This installer does not enable the service, provision IAM, roles or secrets,
contact GCP or Cloud SQL, change Telegram traffic, touch the VPS, approve a
proposal, or promote production. Those remain later, separately authorized
boundaries.
## Offline evidence and live gate ## Offline evidence and live gate
Offline tests prove strict receipt/descriptor/inspection schemas, exact Offline tests prove strict receipt/descriptor/inspection schemas, exact
@ -281,30 +340,43 @@ is absent, installation is separate infrastructure work; the deploy must stop
instead of falling back to the production sync script or a mutable host virtual instead of falling back to the production sync script or a mutable host virtual
environment. environment.
The installer tests use a disposable filesystem root and exact fake
systemd/Docker readbacks. They prove dry-run non-mutation, local digest/config
binding, first install, no-restart idempotence, old-unit-first upgrade ordering,
effective-environment and later-drop-in rejection, PID/restart-race rejection,
automatic rollback to a running prior release, first-install cleanup, managed
file posture, contract-root source binding, stale-loaded-unit rejection,
supervising-process binding, lock contention, post-rename pathname replacement,
exact inactive-posture rollback, and full-ID/config-digest-only container
control including name-rebind refusal. They do not constitute a live staging
restart or a Cloud SQL identity proof.
Publishing the branch and stacked PR is only a review handoff. After both the Publishing the branch and stacked PR is only a review handoff. After both the
digest-inspection package and this receipt producer receive exact-head human digest-inspection package and this receipt producer receive exact-head human
review and merge, separate live gates remain: staging repository/tag-policy review and merge, separate live gates remain: staging repository/tag-policy
preflight; authorized build/push receipt production; authorized independent preflight; authorized build/push receipt production; authorized independent
digest finalization; installer/restart/rollback; and Cloud SQL/secret/service digest finalization; an authorized installer/restart execution; and Cloud
deployment proof. CI carries no Artifact Registry credentials and performs no SQL/secret/service deployment proof. CI carries no Artifact Registry
push. IAM/secret changes, Cloud SQL access, proposal staging, production credentials and performs no push. IAM/secret changes, Cloud SQL access,
promotion, Telegram traffic, and VPS actions remain separate authority. proposal staging, production promotion, Telegram traffic, and VPS actions
remain separate authority.
## Rollback and sunset ## Rollback and sunset
This slice's automatic rollback removes an unpublished bundle only. Receipt Release finalization's automatic rollback removes an unpublished bundle only.
pathnames are never removed automatically; the private retained link and any Receipt pathnames are never removed automatically; the private retained link
ambiguous publication residue require explicit operator inspection. Tool-owned and any ambiguous publication residue require explicit operator inspection.
build and candidate tags, the verified Tool-owned build and candidate tags, the verified
repository-digest reference, and content-addressable layers may remain because repository-digest reference, and content-addressable layers may remain because
a Docker-config lock cannot establish exclusive ownership of daemon-global a Docker-config lock cannot establish exclusive ownership of daemon-global
cache state. A remotely pushed candidate is intentionally not deleted during cache state. A remotely pushed candidate is intentionally not deleted during
automatic rollback and cannot be consumed without a valid receipt plus automatic rollback and cannot be consumed without a valid receipt plus
independent finalization. Any later cache cleanup requires separately proven independent finalization. Any later cache cleanup requires separately proven
daemon isolation. This slice installs no unit and changes no running service. daemon isolation. The installer does not delete the bundle, local image, or any
Restoring a prior descriptor, unit, and digest Artifact Registry object. Its bounded rollback restores the managed prior
belongs to the later installer slice, which must never restart or edit the descriptor, helper, unit, receipt, and service state; it must never restart or
production Leo, Telegram, or VPS services. edit the production Leo, Telegram, or VPS services. A live staging execution
and rollback proof remain separately authorized.
The fresh tmpfs profile intentionally has no session continuity. That is The fresh tmpfs profile intentionally has no session continuity. That is
appropriate for isolated gateway/database and fresh-turn parity proof, but it appropriate for isolated gateway/database and fresh-turn parity proof, but it

View file

@ -112,8 +112,10 @@ cut over safely and retire the VPS after soak.
staging repository/tag policy. staging repository/tag policy.
4. Under explicit live authorization, build and push one candidate, publish 4. Under explicit live authorization, build and push one candidate, publish
its receipt, and independently finalize the exact digest. its receipt, and independently finalize the exact digest.
5. Add the separate fail-closed installer/restart/verification/rollback slice 5. Review and merge the offline fail-closed
that consumes the exact inspection-bound release bundle. installer/restart/verification/rollback slice that consumes the exact
inspection-bound release bundle; authorize its first staging execution
separately.
6. Provision the scoped Cloud SQL role/secret and deploy the separate staging 6. Provision the scoped Cloud SQL role/secret and deploy the separate staging
service. service.
7. Prove effective identity, reads, function-only proposal staging, denied 7. Prove effective identity, reads, function-only proposal staging, denied

View file

@ -52,6 +52,13 @@ PLATFORM = "linux/amd64"
IMAGE_REPOSITORY = "europe-west6-docker.pkg.dev/teleo-501523/teleo/leoclean-nosend-staging" IMAGE_REPOSITORY = "europe-west6-docker.pkg.dev/teleo-501523/teleo/leoclean-nosend-staging"
CONTAINER_NAME = "livingip-leoclean-nosend" CONTAINER_NAME = "livingip-leoclean-nosend"
LOCAL_BUILD_REPOSITORY = "livingip-leoclean-nosend-build" LOCAL_BUILD_REPOSITORY = "livingip-leoclean-nosend-build"
DOCKER_BINARY = "/usr/bin/docker"
DOCKER_HOST = "unix:///var/run/docker.sock"
SERVICE_DOCKER_CONFIG = "/var/lib/livingip/leoclean-nosend/docker-config"
SERVICE_CONTROL_HELPER = "/usr/local/libexec/livingip/leoclean-nosend-service-control"
SERVICE_RUNTIME_DIRECTORY = "leoclean-gcp-nosend"
SERVICE_CIDFILE = f"/run/{SERVICE_RUNTIME_DIRECTORY}/container.cid"
SERVICE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
HERMES_COMMIT = "b2f477a30b3c05d0f383c543af98496ae8a96070" HERMES_COMMIT = "b2f477a30b3c05d0f383c543af98496ae8a96070"
PYTHON_VERSION = "3.11.9" PYTHON_VERSION = "3.11.9"
@ -1477,9 +1484,7 @@ def validate_prepared_image_context(context: Path) -> tuple[dict[str, Any], dict
} }
_require(actual_identity_paths == expected_identity_paths, "prepared identity source entry set drifted") _require(actual_identity_paths == expected_identity_paths, "prepared identity source entry set drifted")
actual_script_paths = { actual_script_paths = {
path.relative_to(context).as_posix() path.relative_to(context).as_posix() for path in (context / "scripts").rglob("*") if path.is_file()
for path in (context / "scripts").rglob("*")
if path.is_file()
} }
_require( _require(
actual_script_paths == set(IDENTITY_CONTRACT_SOURCES), actual_script_paths == set(IDENTITY_CONTRACT_SOURCES),
@ -1496,9 +1501,7 @@ def validate_prepared_image_context(context: Path) -> tuple[dict[str, Any], dict
_require( _require(
isinstance(artifact_files, list) isinstance(artifact_files, list)
and all( and all(
isinstance(entry, dict) isinstance(entry, dict) and isinstance(entry.get("path"), str) and isinstance(entry.get("mode"), str)
and isinstance(entry.get("path"), str)
and isinstance(entry.get("mode"), str)
for entry in artifact_files for entry in artifact_files
), ),
"prepared artifact file manifest is invalid", "prepared artifact file manifest is invalid",
@ -1518,9 +1521,7 @@ def validate_prepared_image_context(context: Path) -> tuple[dict[str, Any], dict
_require(bool(re.fullmatch(r"0o[0-7]{3,4}", raw_mode)), "prepared artifact file mode is invalid") _require(bool(re.fullmatch(r"0o[0-7]{3,4}", raw_mode)), "prepared artifact file mode is invalid")
expected_file_modes[f"artifact/{entry['path']}"] = int(raw_mode.removeprefix("0o"), 8) expected_file_modes[f"artifact/{entry['path']}"] = int(raw_mode.removeprefix("0o"), 8)
expected_file_modes.update({f"identity/{name}": 0o600 for name in IDENTITY_FILES}) expected_file_modes.update({f"identity/{name}": 0o600 for name in IDENTITY_FILES})
expected_file_modes.update( expected_file_modes.update({f"identity-sources/{relative}": 0o644 for relative in expected_identity_paths})
{f"identity-sources/{relative}": 0o644 for relative in expected_identity_paths}
)
expected_file_modes.update({relative: 0o644 for relative in IDENTITY_CONTRACT_SOURCES}) expected_file_modes.update({relative: 0o644 for relative in IDENTITY_CONTRACT_SOURCES})
actual_files = {path.relative_to(context).as_posix() for path in context.rglob("*") if path.is_file()} actual_files = {path.relative_to(context).as_posix() for path in context.rglob("*") if path.is_file()}
_require(actual_files == set(expected_file_modes), "prepared image file entry set drifted") _require(actual_files == set(expected_file_modes), "prepared image file entry set drifted")
@ -1531,16 +1532,13 @@ def validate_prepared_image_context(context: Path) -> tuple[dict[str, Any], dict
) )
expected_directories = {"artifact", "identity", "identity-sources", "scripts"} expected_directories = {"artifact", "identity", "identity-sources", "scripts"}
expected_directories.update( expected_directories.update(
f"artifact/{relative}" f"artifact/{relative}" for relative in _parent_directory_paths({entry["path"] for entry in artifact_files})
for relative in _parent_directory_paths({entry["path"] for entry in artifact_files})
) )
expected_directories.update( expected_directories.update(
_parent_directory_paths({f"identity-sources/{relative}" for relative in expected_identity_paths}) _parent_directory_paths({f"identity-sources/{relative}" for relative in expected_identity_paths})
) )
expected_directories.update(_parent_directory_paths(set(IDENTITY_CONTRACT_SOURCES))) expected_directories.update(_parent_directory_paths(set(IDENTITY_CONTRACT_SOURCES)))
actual_directories = { actual_directories = {path.relative_to(context).as_posix() for path in context.rglob("*") if path.is_dir()}
path.relative_to(context).as_posix() for path in context.rglob("*") if path.is_dir()
}
_require(actual_directories == expected_directories, "prepared image directory entry set drifted") _require(actual_directories == expected_directories, "prepared image directory entry set drifted")
_require(stat.S_IMODE(context.stat().st_mode) == 0o700, "prepared image context mode drifted") _require(stat.S_IMODE(context.stat().st_mode) == 0o700, "prepared image context mode drifted")
for relative in actual_directories: for relative in actual_directories:
@ -2486,15 +2484,20 @@ def validate_release_descriptor(value: dict[str, Any]) -> None:
def render_systemd_unit(release: dict[str, Any]) -> str: def render_systemd_unit(release: dict[str, Any]) -> str:
validate_release_descriptor(release) validate_release_descriptor(release)
image = release["image"]["reference"] image = release["image"]["reference"]
config_digest = release["image"]["config_digest"]
release_sha = release["release_sha256"] release_sha = release["release_sha256"]
profile_tmpfs = f"{PROFILE_ROOT}:rw,noexec,nosuid,nodev,size=64m,uid=0,gid=0,mode=0700" profile_tmpfs = f"{PROFILE_ROOT}:rw,noexec,nosuid,nodev,size=64m,uid=0,gid=0,mode=0700"
temp_tmpfs = f"/tmp:rw,noexec,nosuid,nodev,size=64m,uid={RUNTIME_UID},gid={RUNTIME_GID},mode=0700" temp_tmpfs = f"/tmp:rw,noexec,nosuid,nodev,size=64m,uid={RUNTIME_UID},gid={RUNTIME_GID},mode=0700"
postgres_data_tmpfs = "/var/lib/postgresql/data:ro,noexec,nosuid,nodev,size=64k,uid=0,gid=0,mode=0000" postgres_data_tmpfs = "/var/lib/postgresql/data:ro,noexec,nosuid,nodev,size=64k,uid=0,gid=0,mode=0000"
exec_start = " ".join( exec_start = " ".join(
[ [
"/usr/bin/docker run", DOCKER_BINARY,
f"--host={DOCKER_HOST}",
f"--config={SERVICE_DOCKER_CONFIG}",
"run",
"--rm", "--rm",
f"--name={CONTAINER_NAME}", f"--name={CONTAINER_NAME}",
f"--cidfile={SERVICE_CIDFILE}",
"--pull=never", "--pull=never",
"--read-only", "--read-only",
"--cap-drop=ALL", "--cap-drop=ALL",
@ -2510,6 +2513,7 @@ def render_systemd_unit(release: dict[str, Any]) -> str:
f"--tmpfs={temp_tmpfs}", f"--tmpfs={temp_tmpfs}",
f"--tmpfs={postgres_data_tmpfs}", f"--tmpfs={postgres_data_tmpfs}",
f"--label=livingip.release.sha256={release_sha}", f"--label=livingip.release.sha256={release_sha}",
"--label=livingip.container.role=leoclean-gcp-nosend",
image, image,
"service", "service",
] ]
@ -2523,9 +2527,15 @@ Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
Environment=PATH={SERVICE_PATH}
UnsetEnvironment=DOCKER_HOST DOCKER_CONFIG DOCKER_CONTEXT DOCKER_TLS_VERIFY DOCKER_CERT_PATH DOCKER_API_VERSION DOCKER_CUSTOM_HEADERS BUILDKIT_HOST BUILDX_BUILDER
UnsetEnvironment=HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY http_proxy https_proxy all_proxy no_proxy
RuntimeDirectory={SERVICE_RUNTIME_DIRECTORY}
RuntimeDirectoryMode=0700
RuntimeDirectoryPreserve=no
ExecStartPre={SERVICE_CONTROL_HELPER} prestart {release_sha} {image} {config_digest}
ExecStart={exec_start} ExecStart={exec_start}
ExecStop=/usr/bin/docker stop --time=30 {CONTAINER_NAME} ExecStop={SERVICE_CONTROL_HELPER} stop {release_sha} {image} {config_digest}
ExecStopPost=-/usr/bin/docker rm --force {CONTAINER_NAME}
Restart=on-failure Restart=on-failure
RestartSec=5s RestartSec=5s
TimeoutStartSec=120s TimeoutStartSec=120s
@ -2552,7 +2562,11 @@ SystemCallArchitectures=native
WantedBy=multi-user.target WantedBy=multi-user.target
""" """
_require("--publish" not in unit and " -p " not in unit, "service unit publishes a port") _require("--publish" not in unit and " -p " not in unit, "service unit publishes a port")
_require("docker.sock" not in unit, "service unit mounts the Docker socket") _require(
"--volume=/var/run/docker.sock" not in unit and "--mount=type=bind,source=/var/run/docker.sock" not in unit,
"service unit mounts the Docker socket",
)
_require("docker rm" not in unit and "--force" not in unit, "service unit contains destructive cleanup")
_require( _require(
"EnvironmentFile" not in unit and "LoadCredential" not in unit, "EnvironmentFile" not in unit and "LoadCredential" not in unit,
"service unit adds an unreviewed credential source", "service unit adds an unreviewed credential source",

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,319 @@
#!/usr/bin/python3 -I
"""Control one digest-bound leoclean no-send container by verified full ID."""
from __future__ import annotations
import argparse
import json
import os
import re
import stat
import subprocess
import sys
import time
from collections.abc import Callable
from pathlib import Path
from typing import Any
DOCKER_BINARY = "/usr/bin/docker"
DOCKER_HOST = "unix:///var/run/docker.sock"
DOCKER_CONFIG = "/var/lib/livingip/leoclean-nosend/docker-config"
CONTAINER_NAME = "livingip-leoclean-nosend"
CIDFILE = Path("/run/leoclean-gcp-nosend/container.cid")
IMAGE_REPOSITORY = "europe-west6-docker.pkg.dev/teleo-501523/teleo/leoclean-nosend-staging"
SAFE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
MAX_DOCKER_OUTPUT_BYTES = 1024 * 1024
HEX_64 = re.compile(r"^[0-9a-f]{64}$")
IMAGE_REFERENCE = re.compile(rf"^{re.escape(IMAGE_REPOSITORY)}@sha256:[0-9a-f]{{64}}$")
IMAGE_CONFIG_DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$")
Runner = Callable[[list[str]], subprocess.CompletedProcess[bytes]]
class ControlError(RuntimeError):
"""A bounded service-control failure."""
def _require(condition: bool, message: str) -> None:
if not condition:
raise ControlError(message)
def _strict_json(raw: bytes) -> Any:
_require(len(raw) <= MAX_DOCKER_OUTPUT_BYTES, "docker output exceeded the safety limit")
def reject_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
value: dict[str, Any] = {}
for key, item in pairs:
if key in value:
raise ValueError("duplicate key")
value[key] = item
return value
try:
return json.loads(
raw.decode("utf-8", "strict"),
object_pairs_hook=reject_duplicates,
parse_constant=lambda _value: (_ for _ in ()).throw(ValueError("constant")),
)
except (UnicodeError, ValueError, json.JSONDecodeError) as exc:
raise ControlError("docker inspection was invalid") from exc
def _default_runner(argv: list[str]) -> subprocess.CompletedProcess[bytes]:
environment = {"PATH": SAFE_PATH}
return subprocess.run(
argv,
check=False,
stdin=subprocess.DEVNULL,
capture_output=True,
timeout=45,
env=environment,
)
def _docker(arguments: list[str], runner: Runner) -> subprocess.CompletedProcess[bytes]:
argv = [
DOCKER_BINARY,
f"--host={DOCKER_HOST}",
f"--config={DOCKER_CONFIG}",
*arguments,
]
try:
completed = runner(argv)
except (OSError, subprocess.SubprocessError) as exc:
raise ControlError("docker command failed") from exc
_require(
len(completed.stdout) <= MAX_DOCKER_OUTPUT_BYTES and len(completed.stderr) <= MAX_DOCKER_OUTPUT_BYTES,
"docker output exceeded the safety limit",
)
return completed
def _inspect(reference: str, runner: Runner) -> dict[str, Any] | None:
completed = _docker(["container", "inspect", reference], runner)
if completed.returncode != 0:
try:
error = completed.stderr.decode("utf-8", "strict").strip()
except UnicodeError as exc:
raise ControlError("container absence could not be proven") from exc
absence = re.compile(
rf"^(?:Error response from daemon: |Error: )?No such (?:container|object): {re.escape(reference)}$"
)
_require(absence.fullmatch(error) is not None, "container absence could not be proven")
return None
value = _strict_json(completed.stdout)
_require(
isinstance(value, list) and len(value) == 1 and isinstance(value[0], dict),
"container inspection was not exact",
)
return value[0]
def _validate_container(
value: dict[str, Any],
*,
container_id: str,
release_sha256: str,
image_reference: str,
image_config_digest: str,
) -> bool:
config = value.get("Config")
labels = config.get("Labels") if isinstance(config, dict) else None
state = value.get("State")
_require(
value.get("Id") == container_id
and value.get("Name") == f"/{CONTAINER_NAME}"
and value.get("Image") == image_config_digest
and isinstance(config, dict)
and config.get("Image") == image_reference
and isinstance(labels, dict)
and labels.get("livingip.release.sha256") == release_sha256
and labels.get("livingip.container.role") == "leoclean-gcp-nosend"
and isinstance(state, dict)
and isinstance(state.get("Running"), bool),
"container identity did not match the installed release",
)
return bool(state["Running"])
def _cidfile_parent_descriptor(cidfile: Path) -> int:
flags = os.O_RDONLY
if hasattr(os, "O_DIRECTORY"):
flags |= os.O_DIRECTORY
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
try:
descriptor = os.open(cidfile.parent, flags)
observed = os.fstat(descriptor)
named = os.stat(cidfile.parent, follow_symlinks=False)
except OSError as exc:
raise ControlError("container ID directory was unsafe") from exc
if not (
stat.S_ISDIR(observed.st_mode)
and (observed.st_dev, observed.st_ino) == (named.st_dev, named.st_ino)
and observed.st_uid == os.geteuid()
and observed.st_mode & 0o077 == 0
):
os.close(descriptor)
raise ControlError("container ID directory was unsafe")
return descriptor
def _read_cidfile(cidfile: Path) -> tuple[str, tuple[int, int]] | None:
parent_descriptor = _cidfile_parent_descriptor(cidfile)
descriptor: int | None = None
try:
flags = os.O_RDONLY
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
try:
descriptor = os.open(cidfile.name, flags, dir_fd=parent_descriptor)
except FileNotFoundError:
return None
observed = os.fstat(descriptor)
named = os.stat(cidfile.name, dir_fd=parent_descriptor, follow_symlinks=False)
_require(
stat.S_ISREG(observed.st_mode)
and (observed.st_dev, observed.st_ino) == (named.st_dev, named.st_ino)
and observed.st_uid == os.geteuid()
and stat.S_IMODE(observed.st_mode) == 0o600
and observed.st_nlink == 1
and observed.st_size <= 65,
"container ID file was unsafe",
)
raw = os.read(descriptor, 66)
try:
container_id = raw.decode("ascii", "strict").strip()
except UnicodeError as exc:
raise ControlError("container ID file was invalid") from exc
_require(HEX_64.fullmatch(container_id) is not None, "container ID file was invalid")
return container_id, (observed.st_dev, observed.st_ino)
except OSError as exc:
raise ControlError("container ID file could not be read") from exc
finally:
if descriptor is not None:
os.close(descriptor)
os.close(parent_descriptor)
def _unlink_cidfile(cidfile: Path, identity: tuple[int, int]) -> None:
parent_descriptor = _cidfile_parent_descriptor(cidfile)
try:
observed = os.stat(cidfile.name, dir_fd=parent_descriptor, follow_symlinks=False)
_require(
stat.S_ISREG(observed.st_mode) and (observed.st_dev, observed.st_ino) == identity,
"container ID file changed during cleanup",
)
os.unlink(cidfile.name, dir_fd=parent_descriptor)
os.fsync(parent_descriptor)
except OSError as exc:
raise ControlError("container ID file cleanup failed") from exc
finally:
os.close(parent_descriptor)
def _wait_absent(container_id: str, runner: Runner, sleeper: Callable[[float], None]) -> None:
for _attempt in range(60):
if _inspect(container_id, runner) is None:
return
sleeper(0.25)
raise ControlError("container did not stop within the bounded wait")
def _require_name_unbound(runner: Runner) -> None:
_require(_inspect(CONTAINER_NAME, runner) is None, "container name was rebound after the trusted container exited")
def _stop_exact(container_id: str, runner: Runner, sleeper: Callable[[float], None]) -> None:
completed = _docker(["container", "stop", "--time=30", container_id], runner)
_require(completed.returncode == 0, "container stop failed")
_wait_absent(container_id, runner, sleeper)
def control_container(
action: str,
release_sha256: str,
image_reference: str,
image_config_digest: str,
*,
runner: Runner = _default_runner,
sleeper: Callable[[float], None] = time.sleep,
cidfile: Path = CIDFILE,
) -> dict[str, object]:
_require(action in {"prestart", "stop"}, "service-control action was invalid")
_require(HEX_64.fullmatch(release_sha256) is not None, "release hash was invalid")
_require(IMAGE_REFERENCE.fullmatch(image_reference) is not None, "image reference was invalid")
_require(IMAGE_CONFIG_DIGEST.fullmatch(image_config_digest) is not None, "image config digest was invalid")
cid = _read_cidfile(cidfile)
by_name = _inspect(CONTAINER_NAME, runner)
if cid is None:
_require(by_name is None, "container name is occupied without a trusted ID file")
return {"action": action, "status": "pass", "container": "absent"}
container_id, cid_identity = cid
by_id = _inspect(container_id, runner)
if by_id is None:
_require(by_name is None, "container name was rebound after the trusted container exited")
_require_name_unbound(runner)
_unlink_cidfile(cidfile, cid_identity)
return {"action": action, "status": "pass", "container": "absent"}
running = _validate_container(
by_id,
container_id=container_id,
release_sha256=release_sha256,
image_reference=image_reference,
image_config_digest=image_config_digest,
)
_require(
by_name is not None and by_name.get("Id") == container_id,
"container name and trusted ID did not agree",
)
if running:
_stop_exact(container_id, runner, sleeper)
else:
_wait_absent(container_id, runner, sleeper)
_require_name_unbound(runner)
_unlink_cidfile(cidfile, cid_identity)
return {"action": action, "status": "pass", "container": "stopped"}
class _Parser(argparse.ArgumentParser):
def error(self, _message: str) -> None:
raise ControlError("invalid arguments")
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = _Parser(description=__doc__, add_help=False)
parser.add_argument("action", choices=("prestart", "stop"))
parser.add_argument("release_sha256")
parser.add_argument("image_reference")
parser.add_argument("image_config_digest")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
try:
args = parse_args(argv)
result = control_container(
args.action,
args.release_sha256,
args.image_reference,
args.image_config_digest,
)
sys.stdout.write(json.dumps(result, separators=(",", ":"), sort_keys=True) + "\n")
return 0
except ControlError as exc:
sys.stderr.write(
json.dumps({"error": str(exc), "status": "fail"}, separators=(",", ":"), sort_keys=True) + "\n"
)
return 65
except Exception:
sys.stderr.write('{"error":"service control failed","status":"fail"}\n')
return 65
if __name__ == "__main__":
raise SystemExit(main())

File diff suppressed because it is too large Load diff

View file

@ -1822,9 +1822,7 @@ def test_registry_inspection_absent_pull_dependent_retains_new_reference(
calls: list[list[str]] = [] calls: list[list[str]] = []
environments: list[dict[str, str]] = [] environments: list[dict[str, str]] = []
state = {"present": False, "dependent": False, "inspection_calls": 0} state = {"present": False, "dependent": False, "inspection_calls": 0}
payload = json.dumps( payload = json.dumps([docker_inspection_fixture(image_input, reference, config_digest=config_digest)]).encode()
[docker_inspection_fixture(image_input, reference, config_digest=config_digest)]
).encode()
monkeypatch.setenv("DOCKER_HOST", "tcp://unreviewed.example:2375") monkeypatch.setenv("DOCKER_HOST", "tcp://unreviewed.example:2375")
monkeypatch.setenv("DOCKER_CONTEXT", "unreviewed") monkeypatch.setenv("DOCKER_CONTEXT", "unreviewed")
@ -2703,7 +2701,13 @@ def test_rendered_unit_is_isolated_digest_pinned_and_has_no_credential_or_transp
assert "NoNewPrivileges=yes" in unit assert "NoNewPrivileges=yes" in unit
assert "EnvironmentFile" not in unit assert "EnvironmentFile" not in unit
assert "LoadCredential" not in unit assert "LoadCredential" not in unit
assert "docker.sock" not in unit assert f"--host={package.DOCKER_HOST}" in unit
assert f"--config={package.SERVICE_DOCKER_CONFIG}" in unit
assert f"--cidfile={package.SERVICE_CIDFILE}" in unit
assert package.SERVICE_CONTROL_HELPER in unit
assert "docker rm" not in unit
assert "--force" not in unit
assert "--volume=/var/run/docker.sock" not in unit
assert "--publish" not in unit assert "--publish" not in unit
assert "teleo-prod-1" not in unit assert "teleo-prod-1" not in unit
assert "77.42.65.182" not in unit assert "77.42.65.182" not in unit