Add fail-closed leoclean staging installer
Some checks are pending
CI / lint-and-test (push) Waiting to run
Some checks are pending
CI / lint-and-test (push) Waiting to run
This commit is contained in:
parent
70e8f1fe35
commit
a0fabc3072
7 changed files with 3018 additions and 36 deletions
|
|
@ -246,6 +246,65 @@ extra descriptor fields, wrong-architecture images, missing repository-digest
|
|||
bindings, mismatched image labels, duplicate JSON keys, and non-finite JSON
|
||||
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 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
|
||||
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
|
||||
digest-inspection package and this receipt producer receive exact-head human
|
||||
review and merge, separate live gates remain: staging repository/tag-policy
|
||||
preflight; authorized build/push receipt production; authorized independent
|
||||
digest finalization; installer/restart/rollback; and Cloud SQL/secret/service
|
||||
deployment proof. CI carries no Artifact Registry credentials and performs no
|
||||
push. IAM/secret changes, Cloud SQL access, proposal staging, production
|
||||
promotion, Telegram traffic, and VPS actions remain separate authority.
|
||||
digest finalization; an authorized installer/restart execution; and Cloud
|
||||
SQL/secret/service deployment proof. CI carries no Artifact Registry
|
||||
credentials and performs no push. IAM/secret changes, Cloud SQL access,
|
||||
proposal staging, production promotion, Telegram traffic, and VPS actions
|
||||
remain separate authority.
|
||||
|
||||
## Rollback and sunset
|
||||
|
||||
This slice's automatic rollback removes an unpublished bundle only. Receipt
|
||||
pathnames are never removed automatically; the private retained link and any
|
||||
ambiguous publication residue require explicit operator inspection. Tool-owned
|
||||
build and candidate tags, the verified
|
||||
Release finalization's automatic rollback removes an unpublished bundle only.
|
||||
Receipt pathnames are never removed automatically; the private retained link
|
||||
and any ambiguous publication residue require explicit operator inspection.
|
||||
Tool-owned build and candidate tags, the verified
|
||||
repository-digest reference, and content-addressable layers may remain because
|
||||
a Docker-config lock cannot establish exclusive ownership of daemon-global
|
||||
cache state. A remotely pushed candidate is intentionally not deleted during
|
||||
automatic rollback and cannot be consumed without a valid receipt plus
|
||||
independent finalization. Any later cache cleanup requires separately proven
|
||||
daemon isolation. This slice installs no unit and changes no running service.
|
||||
Restoring a prior descriptor, unit, and digest
|
||||
belongs to the later installer slice, which must never restart or edit the
|
||||
production Leo, Telegram, or VPS services.
|
||||
daemon isolation. The installer does not delete the bundle, local image, or any
|
||||
Artifact Registry object. Its bounded rollback restores the managed prior
|
||||
descriptor, helper, unit, receipt, and service state; it must never restart or
|
||||
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
|
||||
appropriate for isolated gateway/database and fresh-turn parity proof, but it
|
||||
|
|
|
|||
|
|
@ -112,8 +112,10 @@ cut over safely and retire the VPS after soak.
|
|||
staging repository/tag policy.
|
||||
4. Under explicit live authorization, build and push one candidate, publish
|
||||
its receipt, and independently finalize the exact digest.
|
||||
5. Add the separate fail-closed installer/restart/verification/rollback slice
|
||||
that consumes the exact inspection-bound release bundle.
|
||||
5. Review and merge the offline fail-closed
|
||||
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
|
||||
service.
|
||||
7. Prove effective identity, reads, function-only proposal staging, denied
|
||||
|
|
|
|||
|
|
@ -52,6 +52,13 @@ PLATFORM = "linux/amd64"
|
|||
IMAGE_REPOSITORY = "europe-west6-docker.pkg.dev/teleo-501523/teleo/leoclean-nosend-staging"
|
||||
CONTAINER_NAME = "livingip-leoclean-nosend"
|
||||
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"
|
||||
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")
|
||||
actual_script_paths = {
|
||||
path.relative_to(context).as_posix()
|
||||
for path in (context / "scripts").rglob("*")
|
||||
if path.is_file()
|
||||
path.relative_to(context).as_posix() for path in (context / "scripts").rglob("*") if path.is_file()
|
||||
}
|
||||
_require(
|
||||
actual_script_paths == set(IDENTITY_CONTRACT_SOURCES),
|
||||
|
|
@ -1496,9 +1501,7 @@ def validate_prepared_image_context(context: Path) -> tuple[dict[str, Any], dict
|
|||
_require(
|
||||
isinstance(artifact_files, list)
|
||||
and all(
|
||||
isinstance(entry, dict)
|
||||
and isinstance(entry.get("path"), str)
|
||||
and isinstance(entry.get("mode"), str)
|
||||
isinstance(entry, dict) and isinstance(entry.get("path"), str) and isinstance(entry.get("mode"), str)
|
||||
for entry in artifact_files
|
||||
),
|
||||
"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")
|
||||
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-sources/{relative}": 0o644 for relative in expected_identity_paths}
|
||||
)
|
||||
expected_file_modes.update({f"identity-sources/{relative}": 0o644 for relative in expected_identity_paths})
|
||||
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()}
|
||||
_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.update(
|
||||
f"artifact/{relative}"
|
||||
for relative in _parent_directory_paths({entry["path"] for entry in artifact_files})
|
||||
f"artifact/{relative}" for relative in _parent_directory_paths({entry["path"] for entry in artifact_files})
|
||||
)
|
||||
expected_directories.update(
|
||||
_parent_directory_paths({f"identity-sources/{relative}" for relative in expected_identity_paths})
|
||||
)
|
||||
expected_directories.update(_parent_directory_paths(set(IDENTITY_CONTRACT_SOURCES)))
|
||||
actual_directories = {
|
||||
path.relative_to(context).as_posix() for path in context.rglob("*") if path.is_dir()
|
||||
}
|
||||
actual_directories = {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(stat.S_IMODE(context.stat().st_mode) == 0o700, "prepared image context mode drifted")
|
||||
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:
|
||||
validate_release_descriptor(release)
|
||||
image = release["image"]["reference"]
|
||||
config_digest = release["image"]["config_digest"]
|
||||
release_sha = release["release_sha256"]
|
||||
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"
|
||||
postgres_data_tmpfs = "/var/lib/postgresql/data:ro,noexec,nosuid,nodev,size=64k,uid=0,gid=0,mode=0000"
|
||||
exec_start = " ".join(
|
||||
[
|
||||
"/usr/bin/docker run",
|
||||
DOCKER_BINARY,
|
||||
f"--host={DOCKER_HOST}",
|
||||
f"--config={SERVICE_DOCKER_CONFIG}",
|
||||
"run",
|
||||
"--rm",
|
||||
f"--name={CONTAINER_NAME}",
|
||||
f"--cidfile={SERVICE_CIDFILE}",
|
||||
"--pull=never",
|
||||
"--read-only",
|
||||
"--cap-drop=ALL",
|
||||
|
|
@ -2510,6 +2513,7 @@ def render_systemd_unit(release: dict[str, Any]) -> str:
|
|||
f"--tmpfs={temp_tmpfs}",
|
||||
f"--tmpfs={postgres_data_tmpfs}",
|
||||
f"--label=livingip.release.sha256={release_sha}",
|
||||
"--label=livingip.container.role=leoclean-gcp-nosend",
|
||||
image,
|
||||
"service",
|
||||
]
|
||||
|
|
@ -2523,9 +2527,15 @@ Wants=network-online.target
|
|||
|
||||
[Service]
|
||||
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}
|
||||
ExecStop=/usr/bin/docker stop --time=30 {CONTAINER_NAME}
|
||||
ExecStopPost=-/usr/bin/docker rm --force {CONTAINER_NAME}
|
||||
ExecStop={SERVICE_CONTROL_HELPER} stop {release_sha} {image} {config_digest}
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
TimeoutStartSec=120s
|
||||
|
|
@ -2552,7 +2562,11 @@ SystemCallArchitectures=native
|
|||
WantedBy=multi-user.target
|
||||
"""
|
||||
_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(
|
||||
"EnvironmentFile" not in unit and "LoadCredential" not in unit,
|
||||
"service unit adds an unreviewed credential source",
|
||||
|
|
|
|||
1363
ops/install_gcp_leoclean_nosend_release.py
Normal file
1363
ops/install_gcp_leoclean_nosend_release.py
Normal file
File diff suppressed because it is too large
Load diff
319
ops/leoclean_nosend_service_control.py
Normal file
319
ops/leoclean_nosend_service_control.py
Normal 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())
|
||||
1208
tests/test_gcp_leoclean_nosend_installer.py
Normal file
1208
tests/test_gcp_leoclean_nosend_installer.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1822,9 +1822,7 @@ def test_registry_inspection_absent_pull_dependent_retains_new_reference(
|
|||
calls: list[list[str]] = []
|
||||
environments: list[dict[str, str]] = []
|
||||
state = {"present": False, "dependent": False, "inspection_calls": 0}
|
||||
payload = json.dumps(
|
||||
[docker_inspection_fixture(image_input, reference, config_digest=config_digest)]
|
||||
).encode()
|
||||
payload = json.dumps([docker_inspection_fixture(image_input, reference, config_digest=config_digest)]).encode()
|
||||
monkeypatch.setenv("DOCKER_HOST", "tcp://unreviewed.example:2375")
|
||||
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 "EnvironmentFile" 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 "teleo-prod-1" not in unit
|
||||
assert "77.42.65.182" not in unit
|
||||
|
|
|
|||
Loading…
Reference in a new issue