fix(apex): preserve environments and restore secure standalone operations

This commit is contained in:
deytenit committed 2026-10-26 12:26:09 +03:00
1 parent 6463bad5ee
commit 995ea9bdf9
44 files changed
+1399 -429

No files matched your search

@@ -23,12 +23,20 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Install Developer Dependencies
run: python3 -m pip install ruff==0.17.2 "PyYAML>=6.0,<7"
- name: Validate Shell Script Syntax
run: |
for script in ci/packer/scripts/*.sh engine/provisioning/seed.sh; do
for script in ci/packer/scripts/*.sh ci/scripts/init-node/provisioning/seed.sh; do
bash -n "$script"
done
bash tests/run.sh
make check
- name: Setup Packer
uses: hashicorp/setup-packer@main
+1
View File
@@ -9,6 +9,7 @@
__pycache__/
*.pyc
.pytest_cache/
.venv/
# Runtime / secrets (never in commons)
*.log
+2 -2
View File
@@ -1,13 +1,13 @@
.PHONY: test lint fmt check
test:
python3 -m unittest discover -s tests
bash tests/run.sh
lint:
@if command -v ruff >/dev/null 2>&1; then \
ruff check .; \
else \
python3 -m py_compile engine/**/*.py actions/**/*.py tests/*.py; \
python3 -m compileall -q engine actions tests ci/scripts && \
echo "ruff not installed in PATH; verified python bytecode compilation."; \
fi
+55 -7
View File
@@ -4,7 +4,7 @@
# [apex.ermnvldmr.com](https://apex.ermnvldmr.com)
The boring, standard-library fleet operations framework powering self-hosted infrastructure across independent nodes.
Local infrastructure operations for independently owned nodes, with a Python standard-library core.
## Quick Start
@@ -37,7 +37,7 @@ You should understand the following invariants before exploring the codebase:
### Core Principles
- **No Control Plane**: No central orchestrator, agent daemon, or cluster controller. Commands act locally on the node you run them on.
- **Zero Build Step & Stdlib-Only**: Pure Python 3 standard library and Git. No `pip` dependencies, Node, Go, or code-generation steps.
- **Core Runtime**: Python 3.10 or newer and Git, with no `pip` dependencies or build step. Standalone host provisioning and workstation checks have separate requirements below.
- **Node-Owned Repositories**: Each machine is an independent Git repository pinning `apex` as a `commons/` submodule. It declares its own identity, compositions, and local overrides.
- **Overlay Architecture**: Node repositories can extend or override any shared action by placing an executable in `proprietaries/actions/`.
@@ -46,8 +46,8 @@ You should understand the following invariants before exploring the codebase:
- **[`actions/`](./actions)**: Built-in engine actions (`compose.py`, `configure/`, `sync/`, `tiers/`, `backup/`, `utils/`).
- **[`compositions/`](./compositions)**: The shared core Compose stack (`traefik`, `socket-proxy`, `alloy`, `adguard`, `xray`, `restic`).
- **[`engine/`](./engine)**: Framework engine — action discovery, overlay resolution, context injection, and logging.
- **[`etc/`](./etc)**: Static service configurations, daemon templates, and helper scripts (Alloy configs, Happ generator).
- **[`ci/`](./ci)**: Host image build recipes (Packer for Debian 13) and cloud-init bootstrap templates.
- **[`etc/`](./etc)**: Shared static configuration, including Alloy.
- **[`ci/`](./ci)**: Host image build recipes, cloud-init templates, and standalone Debian enrollment tooling.
- **[`assets/`](./assets)**: Repository branding assets (`logo.png`).
---
@@ -65,6 +65,45 @@ Running and maintaining services on an existing host.
- Sync repository state: `apex sync/repository`
- **Replication**: For multi-node high availability patterns and contracts, consult the [Composition Replication Documentation](https://docs.ermnvldmr.com/en/apex/concepts/replication/).
Target a composition explicitly when operating on one application:
```sh
apex compose -p apex restart traefik
apex compose -p sso up -d
apex compose --dry-run -p apex logs --tail 20 xray
```
Wrapper options precede the action; remaining arguments go to Docker Compose.
The positional forms `apex compose restart apex traefik` and `apex compose up -d apex`
remain supported. Use `-p` when service names or option values could be mistaken
for composition names. Without a target, APEX visits core first, then other
compositions alphabetically; `down` reverses that order.
Compose operations do not remove Xray sockets. Keep any node-owned socket cleanup
in Xray's startup entrypoint, when its previous process has stopped. A targeted
restart or an unchanged `up` must not remove another running service's listeners.
For direct Docker Compose use in the core composition:
```sh
apex env --sync
cd compositions/apex
docker compose config --quiet
docker compose restart traefik
```
Sync preserves existing `.env` credentials and UID/GID entries, including tier
symlinks. It updates a managed block containing `apex.env` settings (including
profiles) followed by synthesized node/path values, and writes with mode `0600`.
Resync after changing `apex.env`, node identity, or tier paths. Shell environment
values still take precedence in Docker Compose. Do not edit the generated block.
`eval "$(apex env)"` exports synthesized APEX values to the current shell;
`apex env --dotenv` prints dotenv syntax, and `apex env --write generated.env`
writes those values to a separate file. These three modes do not merge the
composition's secrets or `apex.env`; use `--sync` for the complete core bridge.
Choose one output mode per invocation.
### Infrastructure Administrator
Bootstrapping clean Debian 13 hosts on bare metal or cloud providers without cloud-init support.
@@ -75,16 +114,25 @@ Bootstrapping clean Debian 13 hosts on bare metal or cloud providers without clo
```sh
python3 ci/scripts/init-node.py root@server --user-data /private/path/location.user-data.yaml
```
- **Prerequisites**: Run from this commons checkout with Python 3.10+ and OpenSSH. The target must be Debian 13 amd64, reachable using a trusted SSH host key as root or an administrator with passwordless `sudo`. The node checkout must declare `APEX_ENROLLMENT_INTERFACE=1` and have a matching enrollment-aware `init.sh`. Use the supported template subset, not arbitrary cloud-config directives. Keep filled user-data and repository credentials outside Git; supply trusted repository host keys for SSH clones.
- **Bootstrap Dependencies**: The target seed installs distro `python3-yaml` for user-data validation; this is separate from the standard-library APEX core. ZFS setup needs matching running-kernel headers/modules. A mismatch stops with a diagnosis; enrollment does not reboot automatically.
- **Security Handover**: Safely switches SSH to key-only authentication on port 2222 (`adam@server`) protected by an automated rollback watchdog, activates UFW firewall rules, Docker iptables integration, and CrowdSec.
- **Resuming**: Post-enrollment commands connect via `adam@server --port 2222`.
- **Resuming**: Follow the emitted standalone resume command. After committed access handover, use `python3 ci/scripts/init-node.py adam@server --port 2222 --user-data /private/path/location.user-data.yaml`; before handover, use the bootstrap endpoint after pending rollback. An uncertain handover requires checking which endpoint is active. Preserve any `--identity-file` option. Core `apex` has no remote `init` action.
### Developer & Contributor
Adding shared actions or improving the engine.
- **Conventions**: Maintain strict Python standard library compliance. No external runtime packages.
- **Testing**: Run test suite via `python3 -m unittest discover -s tests`.
- **Linting**: Keep code formatted and clean according to PEP 8 standards.
- **Setup**:
```sh
python3 -m venv .venv
. .venv/bin/activate
python3 -m pip install ruff==0.17.2 'PyYAML>=6.0,<7'
```
- **Verification**: `make check` runs lint and both the core and standalone provisioning suites. `make test` (or `bash tests/run.sh`) runs both suites; discovery limited to `tests/` omits provisioning. PyYAML is needed for the provisioning tests, not core execution. Environment integration tests use `docker compose` or standalone `docker-compose` with `config`; no daemon is needed.
- **Linting**: Ruff checks correctness and security rules. Existing import-order, line-length, compact-statement, and annotation-style debt is explicitly deferred in `pyproject.toml` to avoid unrelated formatting changes. Without Ruff, `make lint` recursively checks Python compilation and fails on syntax errors; that fallback is not equivalent to Ruff. `make fmt` formats files and is a separate, deliberate operation.
- **Packer**: Run `packer init ci/packer/apex-debian-13.pkr.hcl` and `packer validate ci/packer/apex-debian-13.pkr.hcl` for template validation. A VM build or live enrollment is a separate operation; unit tests and template validation do not demonstrate a deployed host.
---
+1 -1
View File
@@ -75,4 +75,4 @@ def run(ctx, args):
except Exception as e:
log.error(f"{type(e).__name__}: {e}")
ctx.notify.error(TITLE, url, f"backup failed: {e}")
raise SystemExit(1)
raise SystemExit(1) from e
+35 -42
View File
@@ -2,40 +2,39 @@
"""Orchestrate docker compose across the node.
Supports targeting all compositions in sequence (default: apex core first, services next;
reverse on down) or targeting a specific composition (e.g. `apex compose restart apex`
or `apex compose up -d sso`). Exports derived APEX_* vars and layers --env-file (.env secrets + apex.env).
reverse on down) or targeting one with `apex compose -p apex restart traefik`.
Wrapper options precede the action. Exports derived APEX_* vars and layers env files.
"""
import os
from engine.descriptor import Arg, Flag, Meta, Opt, Rest
import shlex
from engine.descriptor import Flag, Meta, Opt, Rest
METADATA = Meta(
summary="Orchestrate docker compose across the node or on a specific composition.",
args=[
Opt("-p", "Target composition name or 'all'.", default=None),
Flag("--dry-run", "Print commands only."),
Arg("action", "up | down | restart | ps | logs | ..."),
Rest("extra", "Extra args (target composition name, service names, or docker compose flags)."),
Rest("command", "ACTION [COMPOSE_ARGS...]; wrapper options must precede ACTION."),
],
)
def _clean_xray_sockets(ctx):
"""Unlink stale host-side Xray sockets in /dev/shm to prevent REALITY panic crash loops."""
shm = os.path.join(ctx.paths.stier3, "apex", "xray", "shm")
if os.path.isdir(shm):
for f in os.listdir(shm):
if f.endswith(".sock"):
try:
os.unlink(os.path.join(shm, f))
ctx.log.info(f"Cleaned stale Xray socket: {f}")
except OSError as e:
ctx.log.warn(f"Failed to unlink {f}: {e}")
def run(ctx, args):
log, comp, action = ctx.log, ctx.paths.compositions, args.action
extra = [a for a in (args.extra or []) if a != "--dry-run"]
dry_run = args.dry_run or len(extra) != len(args.extra or [])
log, comp = ctx.log, ctx.paths.compositions
command = list(args.command)
if command and command[0] == "--":
command.pop(0)
if not command:
log.error("ACTION is required (up, down, restart, logs, ...).")
raise SystemExit(64)
action, extra = command[0], command[1:]
dry_run = args.dry_run
before_separator = extra[:extra.index("--")] if "--" in extra else extra
if any(arg in ("-p", "--dry-run") or arg.startswith("-p=")
or arg.startswith("--dry-run=") for arg in before_separator):
log.error("Wrapper options -p and --dry-run must precede ACTION; use -- for passthrough.")
raise SystemExit(64)
env = dict(os.environ)
env.update({k: v for k, v in ctx.vars().items() if k.startswith("APEX_")})
@@ -49,31 +48,25 @@ def run(ctx, args):
]
target = getattr(args, "p", None)
if not target and extra:
first = extra[0]
if first == "all":
target = "all"
extra = extra[1:]
elif first in available:
target = first
extra = extra[1:]
elif not first.startswith("-"):
target = first
extra = extra[1:]
elif extra[-1] in available and len(extra) > 1:
target = extra[-1]
extra = extra[:-1]
if target is None and extra:
if extra[0] in ["all", *available]:
target, extra = extra[0], extra[1:]
elif action == "up" and len(extra) >= 2 and extra[0] == "-d":
# Preserve the documented `up -d apex` spelling without scanning flag values.
if extra[1] in ["all", *available]:
if len(extra) != 2:
log.error("Use -p COMPOSITION before up when adding service arguments.")
raise SystemExit(64)
target, extra = extra[1], ["-d"]
if not target:
if target is None:
target = "all"
if target != "all" and target not in available:
log.error(f"Unknown composition '{target}'. Available: {', '.join(sorted(available)) if available else 'none'}")
names = ", ".join(sorted(available)) if available else "none"
log.error(f"Unknown composition '{target}'. Available: {names}")
raise SystemExit(1)
if action in ("up", "restart", "start") and target in ("all", "apex") and not dry_run:
_clean_xray_sockets(ctx)
def sequence():
if target != "all":
return [os.path.join(comp, target)]
@@ -89,13 +82,13 @@ def run(ctx, args):
if os.path.isfile(os.path.join(dirpath, ef)):
base += ["--env-file", ef]
if action == "up":
up_flags = [] if "-d" in extra else ["-d"]
up_flags = [] if any(flag in extra for flag in ("-d", "--detach")) else ["-d"]
cmd = base + ["up"] + up_flags + extra
else:
cmd = base + [action] + extra
if dry_run:
log.info(f"[DRY-RUN] (cd {dirpath} && {' '.join(cmd)})")
log.info(f"[DRY-RUN] (cd {shlex.quote(dirpath)} && {shlex.join(cmd)})")
else:
log.info(f"{action} {name}...")
ctx.sys.run(cmd, cwd=dirpath, env=env, check=True)
+1 -1
View File
@@ -21,7 +21,7 @@ def run(ctx, args):
mod = overlay.load_module(r.path, "apex_umbrella_" + name.replace("/", "_"))
if getattr(mod, "DISABLED", None):
ctx.log.warn(f"Skipping disabled {name}"); continue
meta = getattr(mod, "METADATA")
meta = mod.METADATA
has_dry = any(getattr(a, "name", None) == "--dry-run" for a in meta.args)
argv = ["--dry-run"] if (args.dry_run and has_dry) else []
sub_args = build_parser(meta, f"apex {name}").parse_args(argv)
+78 -36
View File
@@ -1,13 +1,85 @@
# actions/configure/base.py
"""Port of configure/base — base packages + ufw + ufw-docker helper."""
import hashlib
import os
import shutil
import tempfile
from pathlib import Path
import re
import stat
import urllib.request
from engine.descriptor import Meta
METADATA = Meta(summary="Configure base system packages (curl, wget, git, rsyslog, ufw, ufw-docker).")
UFW_DOCKER_URL = "https://github.com/chaifeng/ufw-docker/raw/master/ufw-docker"
UFW_DOCKER_REVISION = "020a8699f95592561f254d8d4ad1bb40d401dfc7"
UFW_DOCKER_SHA256 = "643e56b080567c567b4aa28650196849b2a2da5dd0473fd3e5216b0886035ab0"
UFW_DOCKER_URL = (
"https://raw.githubusercontent.com/chaifeng/ufw-docker/"
+ UFW_DOCKER_REVISION + "/ufw-docker"
)
UFW_DOCKER_PATH = Path("/usr/local/bin/ufw-docker")
BASE_PACKAGES = ("curl", "wget", "git", "rsyslog", "ufw")
def _install_missing(s, packages):
missing = []
for package in packages:
result = s.run(["dpkg-query", "-W", "-f=${Status}", package],
check=False, capture=True)
if result.returncode or result.stdout.split()[1:] != ["ok", "installed"]:
missing.append(package)
if not missing:
return
apt = ["env", "LC_ALL=C", "DEBIAN_FRONTEND=noninteractive", "apt-get",
"-o", "DPkg::Lock::Timeout=60", "-o", "Acquire::Retries=2",
"-o", "Acquire::http::Timeout=30", "-o", "Acquire::https::Timeout=30"]
s.sudo([*apt, "update", "--error-on=any"])
command = [*apt, "install", "-y", "--no-install-recommends", "--no-upgrade",
"--no-remove", *missing]
plan = s.sudo([*command, "--simulate"], capture=True)
if any(line.startswith("Remv ") or re.match(r"^Inst \S+ \[", line)
for line in plan.stdout.splitlines()):
raise RuntimeError("Missing packages require upgrades/removals; reconcile packages first.")
s.sudo(command)
def _check_helper_path(path):
for parent in reversed(path.parents):
info = parent.lstat()
if (not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or
info.st_mode & 0o022):
raise RuntimeError(f"Unsafe ufw-docker ancestor: {parent}")
try:
info = path.lstat()
except FileNotFoundError:
return False
if (not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_gid != 0 or
info.st_mode & 0o022 or info.st_nlink != 1):
raise RuntimeError(f"Unsafe ufw-docker target: {path}")
return True
def _install_helper(ctx):
path = UFW_DOCKER_PATH
exists = _check_helper_path(path)
payload = path.read_bytes() if exists else None
if payload is None or hashlib.sha256(payload).hexdigest() != UFW_DOCKER_SHA256:
ctx.log.info(f"Downloading pinned ufw-docker helper to {path}...")
# The immutable HTTPS URL is authenticated by the digest below.
with urllib.request.urlopen(UFW_DOCKER_URL, timeout=30) as response: # noqa: S310
payload = response.read(1024 * 1024 + 1)
if len(payload) > 1024 * 1024 or hashlib.sha256(payload).hexdigest() != UFW_DOCKER_SHA256:
raise RuntimeError("ufw-docker checksum mismatch; refusing installation.")
_check_helper_path(path)
ctx.host.write_file(str(path), payload.decode("utf-8"), mode="0755",
backup=False, sudo=True)
elif stat.S_IMODE(path.stat().st_mode) != 0o755:
ctx.host.write_file(str(path), payload.decode("utf-8"), mode="0755",
backup=False, sudo=True)
if (not _check_helper_path(path) or
hashlib.sha256(path.read_bytes()).hexdigest() != UFW_DOCKER_SHA256 or
stat.S_IMODE(path.stat().st_mode) != 0o755):
raise RuntimeError("ufw-docker verification failed; refusing execution.")
ctx.log.info("Ensuring ufw-docker routing rules are installed in UFW...")
ctx.sys.sudo([str(path), "install"])
def run(ctx, args):
@@ -16,14 +88,8 @@ def run(ctx, args):
if not os.path.isfile("/etc/debian_version"):
log.error("Requires a Debian-based system (Debian/Ubuntu).")
raise SystemExit(1)
needed = [p for p in ["curl", "wget", "git", "rsyslog"] if not shutil.which(p)]
if needed:
log.info("Updating package lists...")
s.sudo(["apt-get", "update", "-y"])
log.info(f"Installing missing essential packages: {', '.join(needed)}...")
s.sudo(["apt-get", "install", "-y", "-o", "DPkg::Lock::Timeout=60", *needed])
else:
log.info("Essential packages already installed.")
_check_helper_path(UFW_DOCKER_PATH)
_install_missing(s, BASE_PACKAGES)
s.ensure_running("rsyslog")
# Configure APT to discard downloaded archives
@@ -61,29 +127,5 @@ def run(ctx, args):
s.sudo(["tee", journal_conf], input=journal_content)
s.sudo(["systemctl", "restart", "systemd-journald"])
if not shutil.which("ufw"):
log.info("Installing UFW...")
s.sudo(["apt-get", "install", "-y", "-o", "DPkg::Lock::Timeout=60", "ufw"])
else:
log.info("UFW already installed.")
target_bin = "/usr/local/bin/ufw-docker"
if not (os.path.isfile(target_bin) and os.access(target_bin, os.X_OK)):
log.info("Downloading ufw-docker helper to /usr/local/bin/ufw-docker...")
with tempfile.NamedTemporaryFile(delete=False) as tf:
tmp_name = tf.name
try:
urllib.request.urlretrieve(UFW_DOCKER_URL, tmp_name)
s.sudo(["cp", tmp_name, target_bin])
s.sudo(["chown", "root:root", target_bin])
s.sudo(["chmod", "0755", target_bin])
log.success(f"ufw-docker installed to {target_bin}.")
finally:
if os.path.exists(tmp_name):
os.unlink(tmp_name)
else:
log.info(f"ufw-docker already exists at {target_bin}.")
log.info("Ensuring ufw-docker routing rules are installed in UFW...")
s.sudo([target_bin, "install"])
_install_helper(ctx)
log.success("Base system initialization completed.")
+1 -1
View File
@@ -18,7 +18,7 @@ def run(ctx, args):
ZoneInfo(zone)
except (ValueError, ZoneInfoNotFoundError):
ctx.log.error("configs/timezone must name an installed IANA timezone.")
raise SystemExit(1)
raise SystemExit(1) from None
current = ctx.sys.run(["timedatectl", "show", "--property=Timezone", "--value"], capture=True).stdout.strip()
if current == zone:
ctx.log.info(f"Timezone already set to {zone}.")
+66 -38
View File
@@ -1,54 +1,82 @@
# actions/env.py
"""Export or synchronize synthesized APEX_* environment variables.
Outputs shell export statements (for `eval $(apex env)`) or writes a .env file
for direct compatibility with bare `docker compose` commands.
"""
"""Export synthesized APEX values or bridge a composition's environment for Compose."""
import os
import shlex
from engine.descriptor import Flag, Meta, Opt
METADATA = Meta(
summary="Export or sync synthesized APEX_* variables for shell or docker compose.",
args=[
Flag("--export", "Format as shell export statements (default)."),
Flag("--dotenv", "Format as raw KEY=VAL lines without 'export'."),
Opt("--write", "Write variables to the specified file path.", default=None),
Flag("--sync", "Sync variables into compositions/apex/.env for bare docker compose."),
Flag("--dotenv", "Print quoted generated APEX_* dotenv values only."),
Opt("--write", "Write a standalone generated dotenv file (0600).", default=None),
Flag("--sync", "Preserve user .env text and refresh the core Compose managed block."),
],
)
_START = "# BEGIN APEX MANAGED ENV"
_END = "# END APEX MANAGED ENV"
def _dotenv_value(value):
"""Quote a literal using Compose dotenv escapes, including interpolation dollars."""
value = str(value)
for old, new in (("\\", "\\\\"), ('"', '\\"'), ("$", "$$"),
("\r", "\\r"), ("\n", "\\n"), ("\t", "\\t")):
value = value.replace(old, new)
return f'"{value}"'
def _read_optional(path):
try:
with open(path, encoding="utf-8", newline="") as source:
return source.read()
except FileNotFoundError:
return ""
def _sync_body(existing, public, generated):
if any(marker in public or marker in generated for marker in (_START, _END)):
raise ValueError("Managed environment marker collides with source content.")
lines = existing.splitlines(keepends=True)
markers = [(index, line.rstrip("\r\n")) for index, line in enumerate(lines)
if _START in line or _END in line]
if markers:
if (len(markers) != 2 or markers[0][1] != _START or markers[1][1] != _END):
raise ValueError("Malformed or duplicate managed environment markers; .env unchanged.")
start, end = markers[0][0], markers[1][0]
existing = "".join(lines[:start] + lines[end + 1:])
# Keep user bytes in order, but place managed values last to preserve precedence.
separator = "" if not existing or existing.endswith("\n") else "\n"
public_separator = "" if not public or public.endswith("\n") else "\n"
return (existing + separator + _START + "\n" + public + public_separator
+ generated + _END + "\n")
def run(ctx, args):
log = ctx.log
vars_dict = {k: v for k, v in ctx.vars().items() if k.startswith("APEX_")}
target_file = None
modes = (args.export, args.dotenv, args.write is not None, args.sync)
if sum(bool(mode) for mode in modes) > 1:
ctx.log.error("Choose only one of --export, --dotenv, --write, or --sync.")
raise SystemExit(64)
variables = {k: v for k, v in ctx.vars().items() if k.startswith("APEX_")}
generated = "".join(f"{key}={_dotenv_value(value)}\n"
for key, value in sorted(variables.items()))
target = args.write
if args.sync:
target_file = os.path.join(ctx.paths.compositions, "apex", ".env")
elif args.write:
target_file = args.write
if target_file:
content = [
"# Generated by apex env. Do not edit directly.",
"# Contains synthesized node and path scalars for docker compose interpolation.",
]
for k in sorted(vars_dict.keys()):
content.append(f"{k}={vars_dict[k]}")
content.append("")
body = "\n".join(content)
os.makedirs(os.path.dirname(os.path.abspath(target_file)), exist_ok=True)
ctx.host.write_file(target_file, body, mode="0600")
log.success(f"Synced {len(vars_dict)} variables to {target_file}.")
return 0
if args.dotenv:
for k in sorted(vars_dict.keys()):
print(f"{k}={vars_dict[k]}")
core = os.path.join(ctx.paths.compositions, "apex")
target = os.path.join(core, ".env")
try:
generated = _sync_body(_read_optional(target),
_read_optional(os.path.join(core, "apex.env")), generated)
except ValueError as error:
ctx.log.error(str(error))
raise SystemExit(65) from error
if target is not None:
ctx.host.write_file(target, generated, mode="0600")
ctx.log.success(f"Wrote {len(variables)} synthesized variables to {target}.")
elif args.dotenv:
print(generated, end="")
else:
for k in sorted(vars_dict.keys()):
print(f"export {k}={shlex.quote(str(vars_dict[k]))}")
for key, value in sorted(variables.items()):
print(f"export {key}={shlex.quote(str(value))}")
return 0
+1 -1
View File
@@ -220,7 +220,7 @@ def run(ctx, args):
except Exception as e: # bash ERR trap parity: unhandled failures page via Telegram
log.error(f"{type(e).__name__}: {e}")
ctx.notify.error(TITLE, url, f"Critical error: {e}")
raise SystemExit(1)
raise SystemExit(1) from e
def _check(ctx, args, log, node, url, s):
if not os.path.isfile("/etc/debian_version"):
+1 -1
View File
@@ -61,4 +61,4 @@ def run(ctx, args):
except Exception as e:
log.error(f"{type(e).__name__}: {e}")
ctx.notify.error(TITLE, url, f"Sync failed: {e}")
raise SystemExit(1)
raise SystemExit(1) from e
+1 -1
View File
@@ -33,4 +33,4 @@ def run(ctx, args):
ctx.notify.success(TITLE, url, f"Certificates extracted for {node}")
except Exception as e:
log.error(str(e)); ctx.notify.error(TITLE, url, f"cert extraction failed: {e}")
raise SystemExit(1)
raise SystemExit(1) from e
+3 -3
View File
@@ -61,7 +61,7 @@ build {
sources = ["source.qemu.apex_debian"]
provisioner "file" {
source = "engine"
source = "ci/scripts/init-node/provisioning"
destination = "/tmp/"
}
@@ -69,8 +69,8 @@ build {
execute_command = "sudo -n sh -c '{{ .Vars }} {{ .Path }}'"
environment_vars = ["APEX_PROVISION_REVISION=${var.provision_revision}"]
inline = [
"bash /tmp/engine/provisioning/seed.sh",
"cd /tmp && python3 -m engine.provisioning.image install"
"bash /tmp/provisioning/seed.sh",
"cd /tmp && python3 -m provisioning.image install"
]
}
+2 -1
View File
@@ -13,7 +13,8 @@ _repo_root = os.path.dirname(os.path.dirname(_here))
_prov_dir = os.path.join(_here, "init-node")
sys.path.insert(0, _prov_dir)
from provisioning.transport import initialize
# The standalone package lives beside this launcher, outside the engine.
from provisioning.transport import initialize # noqa: E402
def main(argv=None):
+2 -2
View File
@@ -319,12 +319,12 @@ class Host:
self.command(['ufw', '--force', 'delete', 'allow', '2222/tcp', 'comment', 'apex-init-access'])
def arm(self):
bundle = Path(__file__).resolve().parents[2]
bundle = Path(__file__).resolve().parents[1]
if not re.fullmatch(r'/usr/local/lib/apex-provisioner/[a-f0-9]{64}', str(bundle)):
raise AccessError('Access runner must execute from the installed root-owned bundle')
service = ('[Unit]\nDescription=APEX pending access rollback\nAfter=local-fs.target\n'
'[Service]\nType=oneshot\nWorkingDirectory=' + str(bundle) + '\n'
'ExecStart=/usr/bin/python3 -m engine.provisioning.access tick\n')
'ExecStart=/usr/bin/python3 -m provisioning.access tick\n')
timer = ('[Unit]\nDescription=APEX access rollback deadline and boot recovery\n'
'[Timer]\nOnBootSec=5s\nOnUnitActiveSec=10s\nAccuracySec=1s\n'
'[Install]\nWantedBy=timers.target\n')
@@ -325,7 +325,8 @@ def configure_sources():
key = Path(f"/etc/apt/keyrings/apex-{name}.asc")
if not key.exists():
try:
with urllib.request.urlopen(key_url, timeout=30) as response:
# Both repository key URLs above are fixed HTTPS endpoints.
with urllib.request.urlopen(key_url, timeout=30) as response: # noqa: S310
data = response.read()
except OSError as exc:
raise BaselineError(f"Cannot download {name} repository signing key.") from exc
+5 -5
View File
@@ -16,9 +16,9 @@ from .target import preflight, run
def install():
from .transport import build_bundle
source = Path(__file__).resolve().parents[2]
data, digest, _ = build_bundle(source)
from .transport import _bundle_from_source
source = Path(__file__).resolve().parents[1]
data, digest = _bundle_from_source(source)
destination = safe_path(Path('/usr/local/lib/apex-provisioner') / digest)
destination.mkdir(mode=0o755, parents=True, exist_ok=False)
with tarfile.open(fileobj=io.BytesIO(data)) as archive:
@@ -29,7 +29,7 @@ def install():
atomic_write('/etc/apex/provisioner.json', json.dumps(metadata) + '\n')
for executable, operation in (('apex-provision-image', ''), ('apex-bootstrap', 'cloud-enroll ')):
content = ('#!/bin/sh\nset -eu\ncd ' + str(destination) + '\n'
'exec /usr/bin/python3 -m engine.provisioning.image ' + operation + '"$@"\n')
'exec /usr/bin/python3 -m provisioning.image ' + operation + '"$@"\n')
atomic_write('/usr/local/bin/' + executable, content, 0o755)
return metadata
@@ -84,7 +84,7 @@ def sanitize_files(root=Path('/')):
dbus.unlink()
dbus.parent.mkdir(parents=True, exist_ok=True)
dbus.symlink_to('/etc/machine-id')
for name in ('var/lib/apex-init', 'tmp/engine', 'var/lib/cloud'):
for name in ('var/lib/apex-init', 'tmp/provisioning', 'var/lib/cloud'):
path = root / name
if path.is_symlink():
path.unlink()
@@ -45,7 +45,7 @@ def _runtime_rule(line):
if len(tokens) % 2:
raise SecurityError('Unsupported runtime firewall rule')
pairs = []
for key, value in zip(tokens[::2], tokens[1::2]):
for key, value in zip(tokens[::2], tokens[1::2], strict=True):
if key in ('-s', '-d'):
value = str(ipaddress.ip_network(value, strict=False))
elif key == '--ctstate':
@@ -77,7 +77,7 @@ class Security:
if not active:
output = self.host.command(['dpkg-query', '-W', '-f=${Conffiles}', 'ufw']).stdout
conffiles = dict(re.findall(r'^\s*(/\S+)\s+([a-f0-9]{32})(?:\s|$)', output, re.M))
pristine = (hashlib.md5(base64.b64decode(files['/etc/default/ufw']['data'])).hexdigest()
pristine = (hashlib.md5(base64.b64decode(files['/etc/default/ufw']['data']), usedforsecurity=False).hexdigest()
== conffiles.get('/etc/default/ufw'))
manifest = dict((name, digest) for digest, name in re.findall(
r'^([a-f0-9]{32})\s+(\S+)\s*$', self._text('/var/lib/dpkg/info/ufw.md5sums'), re.M))
@@ -86,7 +86,7 @@ class Security:
filename = name.rsplit('/', 1)[1]
template = '/usr/share/ufw/' + ('' if filename == 'ufw.conf' else 'iptables/') + filename
data = self._path(template).read_bytes()
pristine = pristine and (hashlib.md5(data).hexdigest() == manifest.get(template.lstrip('/'))
pristine = pristine and (hashlib.md5(data, usedforsecurity=False).hexdigest() == manifest.get(template.lstrip('/'))
and data == base64.b64decode(files[name]['data']))
if not pristine:
raise SecurityError('Inactive UFW is not provably pristine; reconcile its existing policy first')
@@ -111,7 +111,7 @@ class Security:
output = self.host.command(['dpkg-query', '-W', '-f=${Conffiles}',
'crowdsec-firewall-bouncer-iptables']).stdout
conffiles = dict(re.findall(r'^\s*(/\S+)\s+([a-f0-9]{32})(?:\s|$)', output, re.M))
return hashlib.md5(self._path(path).read_bytes()).hexdigest() == conffiles.get(path)
return hashlib.md5(self._path(path).read_bytes(), usedforsecurity=False).hexdigest() == conffiles.get(path)
def _path(self, name):
path = self.host.path(name)
+28 -19
View File
@@ -129,17 +129,19 @@ def destination(value, port=None):
return match[1], hostname, int(port) if port is not None else None
def build_bundle(commons):
root = Path(commons)
files = sorted((root / 'engine').rglob('*.py'))
seed = root / 'engine/provisioning/seed.sh'
if not seed.is_file():
raise TransportError('Provisioning seed is missing')
def _bundle_from_source(root):
root = Path(root).resolve()
package = root / 'provisioning'
files = sorted(package.rglob('*.py'))
seed = package / 'seed.sh'
if not seed.is_file() or not (package / '__init__.py').is_file():
raise TransportError('Provisioning package or seed is missing')
files.append(seed)
output = io.BytesIO()
with tarfile.open(fileobj=output, mode='w') as archive:
for path in files:
if path.is_symlink() or '__pycache__' in path.parts:
if (not path.is_file() or '__pycache__' in path.parts
or any(part.is_symlink() for part in (path, *path.parents))):
raise TransportError('Provisioning bundle contains an unsafe source path')
data = path.read_bytes()
info = tarfile.TarInfo(path.relative_to(root).as_posix())
@@ -147,15 +149,21 @@ def build_bundle(commons):
info.uid = info.gid = 0
archive.addfile(info, io.BytesIO(data))
data = output.getvalue()
return data, hashlib.sha256(data).hexdigest()
def build_bundle(commons):
root = Path(commons).resolve()
data, digest = _bundle_from_source(root / 'ci/scripts/init-node')
revision = 'unversioned-modified'
if shutil.which('git'):
result = subprocess.run(['git', '-C', str(root), 'rev-parse', 'HEAD'], capture_output=True, text=True, timeout=30)
if result.returncode == 0 and re.fullmatch(r'[a-f0-9]{40,64}\n?', result.stdout):
revision = result.stdout.strip()
dirty = subprocess.run(['git', '-C', str(root), 'status', '--porcelain', '--', 'engine'], capture_output=True, text=True, timeout=30)
dirty = subprocess.run(['git', '-C', str(root), 'status', '--porcelain', '--', 'ci/scripts/init-node', 'ci/scripts/init-node.py'], capture_output=True, text=True, timeout=30)
if dirty.returncode or dirty.stdout.strip():
revision += '-modified'
return data, hashlib.sha256(data).hexdigest(), revision
return data, digest, revision
class SSH:
@@ -240,7 +248,7 @@ def protected_handover(ssh, invoke, token, port, client_address):
except Exception:
try:
invoke('rollback', token, None, None, False)
except Exception:
except Exception: # noqa: S110 — diagnostics may contain secrets.
pass # Persistent timer is authoritative if both connections are gone.
raise
@@ -297,7 +305,7 @@ def initialize(commons, target, user_data, port=None, identity_file=None, progre
if active.returncode == 0:
raise TransportError('An enrollment unit is still running: ' + previous_unit + '; wait for it to settle before resuming')
stage = ssh.run(REMOTE_PATH_GUARD + '\napex_guard_dir /var/tmp; mktemp -d /var/tmp/apex-init.XXXXXXXXXX').stdout.decode().strip()
if not re.fullmatch(r'/var/tmp/apex-init\.[A-Za-z0-9]{10}', stage):
if not re.fullmatch(r'/var/tmp/apex-init\.[A-Za-z0-9]{10}', stage): # noqa: S108 — validates remote mktemp output.
raise TransportError('Target returned an invalid staging path')
# Hard reboot can bypass the shell trap; boot-time tmpfiles removes opaque inputs.
cleanup = ('r ' + stage + '/user-data - - - -\n' +
@@ -308,13 +316,13 @@ def initialize(commons, target, user_data, port=None, identity_file=None, progre
install = remote_bundle_install(stage, installed, digest)
progress('Installing the versioned target runtime and validating enrollment input.')
ssh.run(install)
base = ['python3', '-m', 'engine.provisioning.target']
base = ['python3', '-m', 'provisioning.target']
options = ['--user-data', stage + '/user-data', '--revision', revision, '--bundle-digest', digest]
# State remains inspectable after disconnect; user-data is removed by the durable unit.
enrollment = shlex.join(base + ['enroll'] + options + ['--heartbeat', stage + '/heartbeat'])
validation = shlex.join(base + ['validate'] + options)
access_preflight = shlex.join(['python3', '-m', 'engine.provisioning.access', 'preflight'])
seed = shlex.join(['bash', installed + '/engine/provisioning/seed.sh'])
access_preflight = shlex.join(['python3', '-m', 'provisioning.access', 'preflight'])
seed = shlex.join(['bash', installed + '/provisioning/seed.sh'])
wrapper = ('umask 077; cd ' + installed + '; '
'trap ' + shlex.quote('rm -f ' + stage + '/user-data ' + stage + '/heartbeat ' + cleanup_policy) + ' EXIT; '
'{ ' + seed + ' && ' + validation + ' && ' + access_preflight + ' && ' + enrollment + '; } >' + stage + '/runner.log 2>&1; rc=$?; '
@@ -344,7 +352,7 @@ def initialize(commons, target, user_data, port=None, identity_file=None, progre
result = ssh.run(REMOTE_PATH_GUARD + '\napex_guard_file /var/lib/apex-init/access/state.json; '
'cat /var/lib/apex-init/access/state.json', fresh=True)
return json.loads(result.stdout)
args = ['python3', '-m', 'engine.provisioning.access', operation, '--token', token]
args = ['python3', '-m', 'provisioning.access', operation, '--token', token]
if original_port:
args += ['--original-port', str(original_port), '--client-address', address]
result = ssh.run('cd ' + installed + ' && ' + shlex.join(args), fresh=fresh)
@@ -368,7 +376,7 @@ def initialize(commons, target, user_data, port=None, identity_file=None, progre
try:
ssh.run('rm -f ' + stage + '/user-data ' + stage + '/heartbeat ' + stage + '/bundle.tar ' + cleanup_policy, fresh=fresh)
break
except Exception:
except Exception: # noqa: S110 — cleanup tries both verified endpoints.
pass
if launched and not finished:
progress('Coordinator stopped. The current target stage may finish; later stages stop after heartbeat expiry.')
@@ -379,7 +387,8 @@ def initialize(commons, target, user_data, port=None, identity_file=None, progre
label = 'Safe resume' + ('' if access_committed else ' after any pending rollback')
endpoints = [(label, 'adam' if access_committed else ssh.user, 2222 if access_committed else ssh.resolved_port)]
for label, resume_user, resume_port in endpoints:
resume = './apex init ' + resume_user + '@' + ssh.host + ' --port ' + str(resume_port) + ' --user-data ' + shlex.quote(str(source))
resume = ['python3', 'ci/scripts/init-node.py', resume_user + '@' + ssh.host,
'--port', str(resume_port), '--user-data', str(source)]
if ssh.identity:
resume += ' --identity-file ' + shlex.quote(str(ssh.identity))
progress(label + ': ' + resume)
resume += ['--identity-file', str(ssh.identity)]
progress(label + ': ' + shlex.join(resume))
@@ -23,7 +23,8 @@ def decode_user_data(content):
if len(content) > 1024 * 1024:
raise ContractError('user-data exceeds the supported size')
try:
document = yaml.load(content, Loader=Loader)
# Loader extends SafeLoader only to reject aliases and duplicate keys.
document = yaml.load(content, Loader=Loader) # noqa: S506
except (yaml.YAMLError, UnicodeError, RecursionError):
raise ContractError('invalid or unsupported YAML user-data') from None
return normalize_user_data(document)
@@ -1,4 +1,4 @@
from engine.provisioning.security import SecurityError
from provisioning.security import SecurityError
class SecurityBoundary:
@@ -7,7 +7,7 @@ import tempfile
import unittest
from unittest.mock import patch
from engine.provisioning.access import AccessError, Host, Transition, atomic, main
from provisioning.access import AccessError, Host, Transition, atomic, main
from provision_fakes import SecurityBoundary
@@ -122,7 +122,7 @@ class AccessTests(unittest.TestCase):
self.assertEqual(self.host.restored, 1)
def test_watchdog_restores_access_while_enrollment_lock_is_held(self):
from engine.provisioning.state import exclusive_lock
from provisioning.state import exclusive_lock
self.begin()
self.clock += 301
with exclusive_lock(self.host.path('/var/lib/apex-init/init.lock')):
@@ -297,8 +297,8 @@ class LivePolicyTests(unittest.TestCase):
return self.runner(args, **kwargs)
self.host.runner = unseeded
before = sorted(self.root.rglob('*'))
with patch('engine.provisioning.access.Host', return_value=self.host), \
patch('engine.provisioning.access.os.geteuid', return_value=0):
with patch('provisioning.access.Host', return_value=self.host), \
patch('provisioning.access.os.geteuid', return_value=0):
self.assertEqual(main(['preflight']), 0)
self.assertEqual(sorted(self.root.rglob('*')), before)
self.assertIn(['/usr/sbin/sshd', '-t'], self.calls)
@@ -309,7 +309,7 @@ class LivePolicyTests(unittest.TestCase):
self.cmdline()
for failure in ('socket', 'invocation'):
with self.subTest(failure=failure):
def incompatible(args, **kwargs):
def incompatible(args, failure=failure, **kwargs):
if failure == 'socket' and args == ['systemctl', 'is-enabled', '--quiet', 'ssh.socket']:
return subprocess.CompletedProcess(args, 0, '', '')
return self.runner(args, **kwargs)
@@ -2,14 +2,13 @@
import hashlib
import importlib.util
import io
import json
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
from engine.provisioning import baseline
from provisioning import baseline
class Host:
@@ -1,7 +1,7 @@
import copy
import unittest
from engine.provisioning.contract import ContractError, normalize_user_data, parse_bootstrap_env
from provisioning.contract import ContractError, normalize_user_data, parse_bootstrap_env
def example():
@@ -1,17 +1,90 @@
import io
import os
from pathlib import Path
import shlex
import subprocess
import sys
import tarfile
import tempfile
import unittest
from unittest.mock import patch
from engine.provisioning.image import cloud_enroll, sanitize_files
from engine.provisioning.state import ProvisionError
from provisioning import access, image
from provisioning.image import cloud_enroll, sanitize_files
from provisioning.transport import build_bundle
from provisioning.state import atomic_write, safe_path
from provisioning.state import ProvisionError
class ImageTests(unittest.TestCase):
def test_uploaded_package_installs_runnable_wrappers_and_watchdog(self):
commons = Path(__file__).resolve().parents[4]
data, digest, _ = build_bundle(commons)
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary).resolve()
upload = root / 'tmp'
upload.mkdir()
with tarfile.open(fileobj=io.BytesIO(data)) as archive:
archive.extractall(upload, filter='data')
def mapped(path):
return safe_path(root / str(path).lstrip('/'))
def write(path, content, mode=0o600):
return atomic_write(mapped(path), content, mode)
with patch.object(image, '__file__', str(upload / 'provisioning/image.py')), \
patch.object(image, 'safe_path', side_effect=mapped), \
patch.object(image, 'atomic_write', side_effect=write):
metadata = image.install()
installed = root / 'usr/local/lib/apex-provisioner' / digest
self.assertEqual(metadata['path'], str(installed))
self.assertTrue((installed / 'provisioning/seed.sh').is_file())
environment = dict(os.environ)
environment.pop('PYTHONPATH', None)
for executable in ('apex-provision-image', 'apex-bootstrap'):
wrapper = root / 'usr/local/bin' / executable
self.assertEqual(wrapper.stat().st_mode & 0o777, 0o755)
script = wrapper.read_text().replace('/usr/bin/python3', shlex.quote(sys.executable) + ' -S')
result = subprocess.run(['/bin/sh', '-c', script, executable, '--help'],
cwd=root, env=environment, capture_output=True,
text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn('usage:', result.stdout)
commands = []
def runner(args, **kwargs):
commands.append(args)
return subprocess.CompletedProcess(args, 0, '', '')
canonical = '/usr/local/lib/apex-provisioner/' + digest
with patch.object(access, '__file__', canonical + '/provisioning/access.py'):
access.Host(root, runner=runner).arm()
service = (root / 'etc/systemd/system/apex-access-rollback.service').read_text()
fields = dict(line.split('=', 1) for line in service.splitlines() if '=' in line)
self.assertEqual(fields['WorkingDirectory'], canonical)
argv = shlex.split(fields['ExecStart'])
self.assertEqual(argv[-1], 'tick')
result = subprocess.run([sys.executable, '-S', *argv[1:-1], '--help'],
cwd=installed, env=environment, capture_output=True,
text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn(['systemctl', 'enable', '--now', 'apex-access-rollback.timer'], commands)
sanitize_files(root)
self.assertFalse((upload / 'provisioning').exists())
self.assertTrue((installed / 'provisioning/image.py').is_file())
def test_watchdog_rejects_uninstalled_package_before_writing_units(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary).resolve()
with self.assertRaisesRegex(access.AccessError, 'installed root-owned bundle'):
access.Host(root).arm()
self.assertEqual(list(root.iterdir()), [])
def test_incompatible_image_stops_before_any_enrollment(self):
for metadata in ('{}', '{"enrollment_interface":999}'):
with self.subTest(metadata=metadata), patch.object(Path, 'read_text', return_value=metadata), \
patch('engine.provisioning.target.enroll') as enroll:
patch('provisioning.target.enroll') as enroll:
with self.assertRaisesRegex(ProvisionError, 'image enrollment interface'):
cloud_enroll()
enroll.assert_not_called()
@@ -23,7 +96,8 @@ class ImageTests(unittest.TestCase):
'root/.ssh/authorized_keys', 'home/debian/.ssh/authorized_keys',
'etc/machine-id', 'var/lib/dbus/machine-id',
'var/lib/apex-init/enrollment.json', 'var/lib/cloud/instance/user-data.txt',
'usr/local/lib/apex-provisioner/digest/engine/provisioning/target.py'):
'usr/local/lib/apex-provisioner/digest/provisioning/target.py',
'tmp/provisioning/image.py'):
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text('build-specific')
@@ -33,5 +107,6 @@ class ImageTests(unittest.TestCase):
self.assertFalse((root / 'root/.ssh/authorized_keys').exists())
self.assertFalse((root / 'var/lib/apex-init').exists())
self.assertFalse((root / 'var/lib/cloud').exists())
self.assertTrue((root / 'usr/local/lib/apex-provisioner/digest/engine/provisioning/target.py').is_file())
self.assertFalse((root / 'tmp/provisioning').exists())
self.assertTrue((root / 'usr/local/lib/apex-provisioner/digest/provisioning/target.py').is_file())
self.assertTrue((root / 'etc/systemd/system/ssh.service.d/10-generate-host-keys.conf').is_file())
@@ -6,9 +6,9 @@ import tempfile
import unittest
from unittest.mock import patch
from engine.provisioning.contract import Enrollment, Repository
from engine.provisioning.repository import reconcile_repository
from engine.provisioning.state import ProvisionError
from provisioning.contract import Enrollment, Repository
from provisioning.repository import reconcile_repository
from provisioning.state import ProvisionError
class RepositoryTests(unittest.TestCase):
@@ -43,11 +43,11 @@ class RepositoryTests(unittest.TestCase):
self.spec = Enrollment('metis.example.com', Repository('metis', str(self.upstream), None), (), (), ())
self.state = {}
self.saved = []
self.account = patch('engine.provisioning.repository.pwd.getpwnam',
self.account = patch('provisioning.repository.pwd.getpwnam',
return_value=SimpleNamespace(pw_uid=os.getuid(), pw_gid=os.getgid(), pw_dir=str(self.home)))
self.account.start()
self.addCleanup(self.account.stop)
self.runner = patch('engine.provisioning.repository.as_adam', side_effect=self.local_run)
self.runner = patch('provisioning.repository.as_adam', side_effect=self.local_run)
self.runner.start()
self.addCleanup(self.runner.stop)
@@ -10,8 +10,8 @@ import tempfile
import unittest
from unittest.mock import patch
from engine.provisioning.access import Host
from engine.provisioning import security
from provisioning.access import Host
from provisioning import security
# Pinned helper default output, kept independent of the adapter's validation code.
@@ -97,7 +97,7 @@ class SecurityHost(Host):
path = self.path(name)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
self.conffiles = {'/etc/default/ufw': hashlib.md5(contents['/etc/default/ufw'].encode()).hexdigest()}
self.conffiles = {'/etc/default/ufw': hashlib.md5(contents['/etc/default/ufw'].encode(), usedforsecurity=False).hexdigest()}
self.bouncer_conffiles = {}
checksums = []
for name, content in contents.items():
@@ -108,7 +108,7 @@ class SecurityHost(Host):
path = self.path(template)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
checksums.append(hashlib.md5(content.encode()).hexdigest() + ' ' + template.lstrip('/'))
checksums.append(hashlib.md5(content.encode(), usedforsecurity=False).hexdigest() + ' ' + template.lstrip('/'))
manifest = self.path('/var/lib/dpkg/info/ufw.md5sums')
manifest.parent.mkdir(parents=True, exist_ok=True)
manifest.write_text('\n'.join(checksums) + '\n')
@@ -275,7 +275,7 @@ class SecurityTests(unittest.TestCase):
path = self.host.path(name)
path.parent.mkdir(parents=True)
path.write_text('[Service]\nExecStart=/usr/bin/crowdsec-firewall-bouncer\n')
self.host.bouncer_conffiles[name] = hashlib.md5(path.read_bytes()).hexdigest()
self.host.bouncer_conffiles[name] = hashlib.md5(path.read_bytes(), usedforsecurity=False).hexdigest()
self.host.services['crowdsec-firewall-bouncer']['FragmentPath'] = name
state = self.prepared()
self.adapter.activate(state, self.persist)
@@ -535,7 +535,7 @@ class SecurityTests(unittest.TestCase):
def test_disabled_ipv6_runtime_is_preserved_during_helper_cleanup(self):
path = self.host.path('/etc/default/ufw')
path.write_text(path.read_text().replace('IPV6=yes', 'IPV6=no'))
self.host.conffiles['/etc/default/ufw'] = hashlib.md5(path.read_bytes()).hexdigest()
self.host.conffiles['/etc/default/ufw'] = hashlib.md5(path.read_bytes(), usedforsecurity=False).hexdigest()
before = copy.deepcopy(self.host.runtime_chains)
state = self.prepared()
self.adapter.activate(state, self.persist)
@@ -828,7 +828,7 @@ class SecurityTests(unittest.TestCase):
def test_ipv6_peer_requires_ipv6_firewall_support(self):
path = self.host.path('/etc/default/ufw')
path.write_text(path.read_text().replace('IPV6=yes', 'IPV6=no'))
self.host.conffiles['/etc/default/ufw'] = hashlib.md5(path.read_bytes()).hexdigest()
self.host.conffiles['/etc/default/ufw'] = hashlib.md5(path.read_bytes(), usedforsecurity=False).hexdigest()
with self.assertRaises(security.SecurityError):
self.adapter.snapshot(self.adapter.preflight(), 22, '2001:db8::1')
@@ -2,8 +2,8 @@ import tempfile
import unittest
from pathlib import Path
from engine.provisioning.access import AccessError, Transition
from engine.provisioning.security import SecurityError
from provisioning.access import AccessError, Transition
from provisioning.security import SecurityError
from test_provision_access import FakeHost, TOKEN
@@ -4,7 +4,7 @@ from pathlib import Path
import tempfile
import unittest
from engine.provisioning.state import (Journal, ProvisionError, atomic_write,
from provisioning.state import (Journal, ProvisionError, atomic_write,
check_heartbeat, exclusive_lock)
@@ -8,9 +8,9 @@ from types import SimpleNamespace
import unittest
from unittest.mock import patch
from engine.provisioning.contract import Enrollment, Repository, Tier
from engine.provisioning import storage
from engine.provisioning.storage import StorageError, _persist_swap, reconcile_storage
from provisioning.contract import Enrollment, Repository, Tier
from provisioning import storage
from provisioning.storage import StorageError, _persist_swap, reconcile_storage
def enrollment(*tiers):
@@ -49,13 +49,13 @@ class TestStorage(unittest.TestCase):
self.create_hook = None
for name, value in [('SRV', self.srv), ('SWAPFILE', self.swap),
('FSTAB', self.fstab), ('MEMINFO', self.meminfo), ('SWAPS', self.swaps)]:
patcher = patch('engine.provisioning.storage.' + name, value, create=True)
patcher = patch('provisioning.storage.' + name, value, create=True)
patcher.start()
self.addCleanup(patcher.stop)
patcher = patch('engine.provisioning.storage.subprocess.run', side_effect=self.run_command)
patcher = patch('provisioning.storage.subprocess.run', side_effect=self.run_command)
patcher.start()
self.addCleanup(patcher.stop)
patcher = patch('engine.provisioning.storage._device_number', side_effect=self.device_number)
patcher = patch('provisioning.storage._device_number', side_effect=self.device_number)
patcher.start()
self.addCleanup(patcher.stop)
@@ -365,7 +365,7 @@ class TestStorage(unittest.TestCase):
def test_swap_skip_reports_low_space_and_unsupported_filesystems(self):
self.set_active_swaps([])
with patch('engine.provisioning.storage.os.statvfs', return_value=SimpleNamespace(f_bavail=1, f_frsize=4096)):
with patch('provisioning.storage.os.statvfs', return_value=SimpleNamespace(f_bavail=1, f_frsize=4096)):
result = reconcile_storage(enrollment(), self.state, self.save)
self.assertIn('insufficient', result['swap']['reason'])
self.mounts[0]['fstype'] = 'btrfs'
@@ -375,9 +375,9 @@ class TestStorage(unittest.TestCase):
def test_swap_sizes_allocation_and_records_verified_result(self):
self.set_active_swaps([])
with patch('engine.provisioning.storage.os.statvfs', return_value=SimpleNamespace(f_bavail=16, f_frsize=1024 ** 3)), \
patch('engine.provisioning.storage.os.posix_fallocate', create=True) as allocate, \
patch('engine.provisioning.storage._persist_swap') as persist:
with patch('provisioning.storage.os.statvfs', return_value=SimpleNamespace(f_bavail=16, f_frsize=1024 ** 3)), \
patch('provisioning.storage.os.posix_fallocate', create=True) as allocate, \
patch('provisioning.storage._persist_swap') as persist:
result = reconcile_storage(enrollment(), self.state, self.save)
self.assertEqual(allocate.call_args.args[1:], (0, 4 * 1024 ** 3))
self.assertEqual(result['swap'], {'status': 'complete', 'size': 4 * 1024 ** 3})
@@ -401,9 +401,9 @@ class TestStorage(unittest.TestCase):
def test_unobserved_swap_activation_is_not_persisted_or_reported_complete(self):
self.set_active_swaps([])
self.activate_swap = False
with patch('engine.provisioning.storage.os.statvfs', return_value=SimpleNamespace(f_bavail=16, f_frsize=1024 ** 3)), \
patch('engine.provisioning.storage.os.posix_fallocate', create=True), \
patch('engine.provisioning.storage._persist_swap') as persist:
with patch('provisioning.storage.os.statvfs', return_value=SimpleNamespace(f_bavail=16, f_frsize=1024 ** 3)), \
patch('provisioning.storage.os.posix_fallocate', create=True), \
patch('provisioning.storage._persist_swap') as persist:
result = reconcile_storage(enrollment(), self.state, self.save)
self.assertEqual(result['swap']['status'], 'skipped')
self.assertIn('activation did not become observable', result['swap']['reason'])
@@ -421,9 +421,9 @@ class TestStorage(unittest.TestCase):
def test_small_memory_swap_is_two_gib(self):
self.set_active_swaps([])
self.meminfo.write_text('MemTotal: 4194304 kB\n')
with patch('engine.provisioning.storage.os.statvfs', return_value=SimpleNamespace(f_bavail=16, f_frsize=1024 ** 3)), \
patch('engine.provisioning.storage.os.posix_fallocate', create=True) as allocate, \
patch('engine.provisioning.storage._persist_swap'):
with patch('provisioning.storage.os.statvfs', return_value=SimpleNamespace(f_bavail=16, f_frsize=1024 ** 3)), \
patch('provisioning.storage.os.posix_fallocate', create=True) as allocate, \
patch('provisioning.storage._persist_swap'):
result = reconcile_storage(enrollment(), self.state, self.save)
self.assertEqual(allocate.call_args.args[1:], (0, 2 * 1024 ** 3))
self.assertEqual(result['swap']['size'], 2 * 1024 ** 3)
@@ -439,8 +439,8 @@ class TestStorage(unittest.TestCase):
return os.stat_result(fields)
return info
with patch('engine.provisioning.storage.Path.lstat', root_owned), \
patch('engine.provisioning.storage.os.fchown'):
with patch('provisioning.storage.Path.lstat', root_owned), \
patch('provisioning.storage.os.fchown'):
_persist_swap()
_persist_swap()
self.assertEqual(self.fstab.read_text(), '# existing entries\n' +
@@ -8,10 +8,10 @@ import tempfile
import unittest
from unittest.mock import patch
from engine.provisioning import target
from engine.provisioning.contract import normalize_user_data
from engine.provisioning.state import ProvisionError, atomic_write, safe_path
from tests.test_provision_contract import example
from provisioning import target
from provisioning.contract import normalize_user_data
from provisioning.state import ProvisionError, atomic_write, safe_path
from test_provision_contract import example
class TargetTests(unittest.TestCase):
@@ -10,10 +10,10 @@ import tempfile
import unittest
from unittest.mock import patch
from engine.provisioning.transport import (
from provisioning.transport import (
SSH, TransportError, HandoverUncertain, build_bundle, destination, local_file, protected_handover,
initialize,
REMOTE_PATH_GUARD, remote_atomic_input,
remote_atomic_input,
)
@@ -27,7 +27,7 @@ class TransportTests(unittest.TestCase):
stage = root / 'stage'
stage.mkdir()
installed = root / 'bundle'
seed = installed / 'engine/provisioning/seed.sh'
seed = installed / 'provisioning/seed.sh'
seed.parent.mkdir(parents=True)
seed.write_text('printf "seed\\n" >> "$APEX_TEST_EVENTS"\n')
binaries = root / 'bin'
@@ -55,7 +55,8 @@ if event == 'enroll':
pass
def resolve(self):
pass
def run(self, script, data=None, **kwargs):
def run(self, script, data=None, installed=installed, stage=stage,
root=root, environment=environment, **kwargs):
value = b''
if 'ssh_host_' in script:
value = b'192.0.2.1 50000 192.0.2.2 22\nssh-ed25519 AAAA fixture\n'
@@ -71,9 +72,9 @@ if event == 'enroll':
elif script.startswith('if test -f /var/tmp'):
value = (stage / 'result').read_bytes()
return subprocess.CompletedProcess([], 0, value, b'')
with patch('engine.provisioning.transport.SSH', Remote), \
patch('engine.provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \
patch('engine.provisioning.transport.shutil.which', return_value='/usr/bin/ssh'):
with patch('provisioning.transport.SSH', Remote), \
patch('provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \
patch('provisioning.transport.shutil.which', return_value='/usr/bin/ssh'):
with self.assertRaises(TransportError):
initialize(root, 'root@example.com', source, progress=lambda message: None)
expected = ['seed', 'validate', 'access-preflight']
@@ -151,9 +152,9 @@ if event == 'enroll':
self.assertIn('ssh-ed25519 AAAA', transport.trust_file.read_text())
raise PreflightComplete
transport = SSH('adam@example.com', runner=runner)
with patch('engine.provisioning.transport.SSH', return_value=transport), \
patch('engine.provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \
patch('engine.provisioning.transport.shutil.which', return_value='/usr/bin/ssh'):
with patch('provisioning.transport.SSH', return_value=transport), \
patch('provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \
patch('provisioning.transport.shutil.which', return_value='/usr/bin/ssh'):
with self.assertRaises(PreflightComplete):
initialize(root, 'adam@example.com', source, progress=lambda message: None)
self.assertEqual(len(fallbacks), 1)
@@ -237,8 +238,8 @@ if event == 'enroll':
self.assertEqual(calls, ['begin', 'fresh', 'finalize', 'fresh', 'commit'])
def test_lost_commit_reply_is_reconciled_without_rolling_back_committed_access(self):
from engine.provisioning.access import Transition
from tests.test_provision_access import FakeHost
from provisioning.access import Transition
from test_provision_access import FakeHost
with tempfile.TemporaryDirectory() as directory:
host = FakeHost(Path(directory))
transition = Transition(host, now=lambda: 1000, security=host.security)
@@ -277,17 +278,20 @@ if event == 'enroll':
self.assertEqual(calls, ['begin', 'finalize', 'commit', 'status', 'rollback'])
def test_lost_commit_reply_reports_verified_or_uncertain_resume_endpoints(self):
from engine.provisioning.access import Transition
from tests.test_provision_access import FakeHost
from provisioning.access import Transition
from test_provision_access import FakeHost
for unreachable in (False, True):
with self.subTest(unreachable=unreachable), tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = root / 'user-data'
source = root / 'user data $literal'
source.write_bytes(b'opaque input')
identity = root / 'identity file'
identity.write_bytes(b'synthetic private identity')
host = FakeHost(root)
transition = Transition(host, now=lambda: 1000, security=host.security)
messages, commands = [], []
def runner(args, **kwargs):
def runner(args, commands=commands, transition=transition,
unreachable=unreachable, **kwargs):
if '-G' in args:
return subprocess.CompletedProcess(args, 0, 'hostname 192.0.2.2\nport 22\n', '')
command = shlex.split(args[-1])
@@ -300,7 +304,7 @@ if event == 'enroll':
value = b'/var/tmp/apex-init.0123456789\n'
elif script.startswith('if test -f /var/tmp'):
value = b'0'
elif script.startswith('cd ') and 'engine.provisioning.access' in script:
elif script.startswith('cd ') and 'provisioning.access' in script:
access_args = shlex.split(script)[3:]
operation = access_args[3]
token = access_args[access_args.index('--token') + 1]
@@ -316,24 +320,28 @@ if event == 'enroll':
if unreachable:
return subprocess.CompletedProcess(args, 255, b'', b'connection lost')
value = json.dumps(transition.load()).encode()
elif 'engine.provisioning.target complete' in script:
elif 'provisioning.target complete' in script:
value = b'{"status": "complete"}'
return subprocess.CompletedProcess(args, 0, value, b'')
def transport(*args):
return SSH(*args, runner=runner)
with patch('engine.provisioning.transport.SSH', side_effect=transport), \
patch('engine.provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \
patch('engine.provisioning.transport.shutil.which', return_value='/usr/bin/ssh'):
with patch('provisioning.transport.SSH', side_effect=transport), \
patch('provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \
patch('provisioning.transport.shutil.which', return_value='/usr/bin/ssh'):
if unreachable:
with self.assertRaises(HandoverUncertain):
initialize(root, 'debian@example.com', source, progress=messages.append)
initialize(root, 'debian@example.com', source, identity_file=identity, progress=messages.append)
self.assertTrue(any('outcome is unknown' in message for message in messages))
self.assertIn('adam@example.com --port 2222', messages[-2])
self.assertIn('debian@example.com --port 22', messages[-1])
self.assertFalse(any('Safe resume' in message or 'pending rollback' in message for message in messages))
else:
self.assertEqual(initialize(root, 'debian@example.com', source, progress=messages.append), {'status': 'complete'})
self.assertIn('Safe resume: ./apex init adam@example.com --port 2222', messages[-1])
self.assertEqual(initialize(root, 'debian@example.com', source, identity_file=identity, progress=messages.append), {'status': 'complete'})
self.assertIn('Safe resume: python3 ci/scripts/init-node.py adam@example.com --port 2222', messages[-1])
resume = shlex.split(messages[-1].split(': ', 1)[1])
self.assertEqual(resume[:2], ['python3', 'ci/scripts/init-node.py'])
self.assertEqual(resume[resume.index('--user-data') + 1], str(source.resolve()))
self.assertEqual(resume[resume.index('--identity-file') + 1], str(identity.resolve()))
self.assertEqual(transition.load()['status'], 'committed')
self.assertFalse(host.armed)
self.assertFalse(any('access rollback' in script for script in commands))
@@ -341,29 +349,64 @@ if event == 'enroll':
def test_bundle_excludes_repository_and_environment_secrets(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / 'engine/provisioning').mkdir(parents=True)
(root / 'engine/__init__.py').write_text('')
(root / 'engine/provisioning/seed.sh').write_text('#!/bin/sh\n')
(root / 'ci/scripts/init-node/provisioning').mkdir(parents=True)
(root / 'ci/scripts/init-node/provisioning/__init__.py').write_text('')
(root / 'ci/scripts/init-node/provisioning/seed.sh').write_text('#!/bin/sh\n')
(root / '.env').write_text('private secret')
(root / 'engine/private.env').write_text('private secret')
(root / 'ci/scripts/init-node/provisioning/private.env').write_text('private secret')
data, digest, revision = build_bundle(root)
with tarfile.open(fileobj=io.BytesIO(data)) as archive:
self.assertEqual(set(archive.getnames()), {'engine/__init__.py', 'engine/provisioning/seed.sh'})
self.assertEqual(set(archive.getnames()), {'provisioning/__init__.py', 'provisioning/seed.sh'})
self.assertNotIn(b'private secret', data)
self.assertEqual(build_bundle(root)[1], digest)
def test_real_archive_runs_without_engine_or_site_packages(self):
commons = Path(__file__).resolve().parents[4]
data, digest, revision = build_bundle(commons)
self.assertEqual(build_bundle(commons)[1], digest)
with tempfile.TemporaryDirectory() as directory:
with tarfile.open(fileobj=io.BytesIO(data)) as archive:
names = archive.getnames()
self.assertTrue(names)
self.assertTrue(all(name.startswith('provisioning/') for name in names))
self.assertIn('provisioning/__init__.py', names)
self.assertIn('provisioning/seed.sh', names)
archive.extractall(directory, filter='data')
environment = dict(os.environ)
environment.pop('PYTHONPATH', None)
for module in ('provisioning.target', 'provisioning.access', 'provisioning.image'):
with self.subTest(module=module):
result = subprocess.run([sys.executable, '-S', '-m', module, '--help'],
cwd=directory, env=environment, capture_output=True,
text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn('usage:', result.stdout)
def test_bundle_rejects_symlinked_source(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
package = root / 'ci/scripts/init-node/provisioning'
package.mkdir(parents=True)
(package / '__init__.py').write_text('')
(package / 'seed.sh').write_text('#!/bin/sh\n')
outside = root / 'outside.py'
outside.write_text('private = True\n')
(package / 'target.py').symlink_to(outside)
with self.assertRaisesRegex(TransportError, 'unsafe source path'):
build_bundle(root)
def test_missing_input_is_a_safe_validation_error(self):
with self.assertRaises(TransportError):
local_file('/nonexistent/apex-user-data', 'User-data')
def test_init_from_node_checkout_does_not_resolve_local_identity(self):
from engine import cli
root = Path(__file__).resolve().parents[1]
with patch.object(cli, '_resolve_layout', return_value=(str(root), str(root), None, True)), \
patch.object(cli, 'resolve', side_effect=AssertionError('local identity used')):
with self.assertRaises(SystemExit) as caught:
cli.main(['init', '--help'])
self.assertEqual(caught.exception.code, 0)
def test_standalone_help_runs_without_engine_or_yaml_imports(self):
launcher = Path(__file__).resolve().parents[2] / 'init-node.py'
with tempfile.TemporaryDirectory() as directory:
result = subprocess.run([sys.executable, '-S', str(launcher), '--help'],
cwd=directory, capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn('--user-data', result.stdout)
self.assertIn('--identity-file', result.stdout)
def test_dead_enrollment_unit_stops_polling_and_removes_input(self):
with tempfile.TemporaryDirectory() as directory:
@@ -389,9 +432,9 @@ if event == 'enroll':
value = b''
return subprocess.CompletedProcess([], 0, value, b'')
messages = []
with patch('engine.provisioning.transport.SSH', Remote), \
patch('engine.provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \
patch('engine.provisioning.transport.shutil.which', return_value='/usr/bin/ssh'):
with patch('provisioning.transport.SSH', Remote), \
patch('provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \
patch('provisioning.transport.shutil.which', return_value='/usr/bin/ssh'):
with self.assertRaises(TransportError) as caught:
initialize(root, 'root@example.com', source, progress=messages.append)
self.assertIn('/runner.log', str(caught.exception))
@@ -416,16 +459,16 @@ if event == 'enroll':
value = b'/var/tmp/apex-init.0123456789\n'
elif script.startswith('if test -f /var/tmp'):
value = b'0'
elif 'engine.provisioning.target complete' in script:
elif 'provisioning.target complete' in script:
raise TransportError('record failed')
else:
value = b''
return subprocess.CompletedProcess([], 0, value, b'')
messages = []
with patch('engine.provisioning.transport.SSH', Remote), \
patch('engine.provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \
patch('engine.provisioning.transport.protected_handover'), \
patch('engine.provisioning.transport.shutil.which', return_value='/usr/bin/ssh'):
with patch('provisioning.transport.SSH', Remote), \
patch('provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \
patch('provisioning.transport.protected_handover'), \
patch('provisioning.transport.shutil.which', return_value='/usr/bin/ssh'):
with self.assertRaises(TransportError):
initialize(root, 'root@example.com', source, progress=messages.append)
self.assertIn('adam@example.com --port 2222', messages[-1])
@@ -1,8 +1,8 @@
import importlib.util
import unittest
from engine.provisioning.contract import ContractError
from engine.provisioning.userdata import decode_user_data
from provisioning.contract import ContractError
from provisioning.userdata import decode_user_data
@unittest.skipUnless(importlib.util.find_spec('yaml'), 'target-only python3-yaml is unavailable')
@@ -21,7 +21,7 @@ class UserDataTests(unittest.TestCase):
def test_normal_supported_document(self):
import yaml
from tests.test_provision_contract import example
from test_provision_contract import example
spec = decode_user_data(yaml.safe_dump(example()).encode())
self.assertEqual(spec.hostname, 'metis.a1.example.com')
+66 -22
View File
@@ -6,10 +6,10 @@ Ports root_template_crontab (install + rollback) and the ufw block-injection log
"""
from __future__ import annotations
import inspect
import json
import os
import shutil
import subprocess
import tempfile
import time
from typing import List, Optional, Sequence, Tuple
@@ -55,11 +55,73 @@ def apply_block(text: str, content: str, start: str, end: str,
return "\n".join(result) + "\n", inserted
def _atomic_write(path, content, mode):
"""Self-contained so sudo executes the same operation without importing user code."""
import os
import stat
import tempfile
target = os.path.realpath(path)
parent = os.path.dirname(target)
try:
previous = os.stat(target)
except FileNotFoundError:
previous = None
if previous is not None and not stat.S_ISREG(previous.st_mode):
raise ValueError(f"Not a regular file: {path}")
target_mode = (int(mode, 8) if mode is not None else
stat.S_IMODE(previous.st_mode) if previous is not None else 0o644)
os.makedirs(parent, exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix=".apex-", dir=parent)
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="") as stream:
stream.write(content)
stream.flush()
if previous is not None:
current = os.fstat(stream.fileno())
if (current.st_uid, current.st_gid) != (previous.st_uid, previous.st_gid):
os.fchown(stream.fileno(), previous.st_uid, previous.st_gid)
os.fchmod(stream.fileno(), target_mode)
os.fsync(stream.fileno())
os.replace(temporary, target)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
def _atomic_backup(path, destination):
import os
import shutil
import tempfile
destination = os.path.abspath(destination)
fd, temporary = tempfile.mkstemp(prefix=".apex-", dir=os.path.dirname(destination))
os.close(fd)
try:
shutil.copyfile(path, temporary)
previous, current = os.stat(path), os.stat(temporary)
if (current.st_uid, current.st_gid) != (previous.st_uid, previous.st_gid):
os.chown(temporary, previous.st_uid, previous.st_gid)
shutil.copystat(path, temporary)
os.replace(temporary, destination)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
class Host:
def __init__(self, log, sys_):
self.log = log
self.sys = sys_
def _file_operation(self, operation, args, sudo):
if sudo:
script = (inspect.getsource(operation) + "\nimport json, sys\n" +
f"{operation.__name__}(*json.load(sys.stdin))\n")
self.sys.sudo(["python3", "-I", "-c", script], input=json.dumps(args))
else:
operation(*args)
# --- read/write with sudo awareness ---
def _read(self, path: str, sudo: bool = False) -> str:
if sudo:
@@ -74,10 +136,7 @@ class Host:
if not exists:
return None
bkp = f"{path}.{int(time.time())}.bak"
if sudo:
self.sys.sudo(["cp", "-a", path, bkp])
else:
shutil.copy2(path, bkp)
self._file_operation(_atomic_backup, [path, bkp], sudo)
self.log.info(f"Backed up {path} -> {bkp}")
return bkp
@@ -88,22 +147,7 @@ class Host:
return
if backup:
self.backup(path, sudo=sudo)
fd, tmp_name = tempfile.mkstemp(prefix="apex-")
try:
with os.fdopen(fd, "w", encoding="utf-8") as tmp:
tmp.write(content)
target_mode = mode or "0644"
if sudo:
self.sys.sudo(["mkdir", "-p", os.path.dirname(path)])
self.sys.sudo(["cp", tmp_name, path])
self.sys.sudo(["chmod", target_mode, path])
else:
os.makedirs(os.path.dirname(path), exist_ok=True)
shutil.copyfile(tmp_name, path)
os.chmod(path, int(target_mode, 8))
finally:
if os.path.exists(tmp_name):
os.unlink(tmp_name)
self._file_operation(_atomic_write, [path, content, mode], sudo)
self.log.success(f"Wrote {path}")
def inject_block(self, target: str, content: str, start: str, end: str,
+1 -1
View File
@@ -73,7 +73,7 @@ class Notify:
data = urllib.parse.urlencode({"parse_mode": "HTML", "text": text}).encode()
self.log.info(f"Sending Telegram notification ({level}): {title}")
try:
with urllib.request.urlopen(urllib.request.Request(bot_url, data=data),
with urllib.request.urlopen(urllib.request.Request(bot_url, data=data), # noqa: S310 -- HTTPS validated above.
timeout=NOTIFY_TIMEOUT) as r:
r.read()
return True
+10 -1
View File
@@ -6,13 +6,22 @@ readme = "README.md"
license = { text = "BSD-2-Clause" }
requires-python = ">=3.10"
[project.optional-dependencies]
dev = ["ruff==0.17.2", "PyYAML>=6.0,<7"]
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "W", "I", "B", "UP", "S"]
ignore = ["S603", "S607"] # Subprocess invocations are mediated via ctx.sys
# Existing formatting and typing modernization are outside correctness checks.
# Subprocess arguments are constructed explicitly; no implicit shell execution.
ignore = ["E401", "E501", "E702", "E741", "I001", "UP006", "UP035", "UP045", "S603", "S607"]
[tool.ruff.lint.per-file-ignores]
# These tests use synthetic paths; no fixed temporary path is written.
"ci/scripts/init-node/tests/test_provision_transport.py" = ["S108"]
[tool.mypy]
python_version = "3.10"
+3 -2
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bash
# Run the full engine test suite (stdlib unittest — no pytest).
# Run the core and standalone provisioning test suites (stdlib unittest — no pytest).
set -euo pipefail
cd "$(dirname "$0")/.."
exec python3 -m unittest discover -s tests -p 'test_*.py' -v
python3 -m unittest discover -s tests -p 'test_*.py' -v
PYTHONPATH=ci/scripts/init-node${PYTHONPATH:+:$PYTHONPATH} python3 -m unittest discover -s ci/scripts/init-node/tests -p 'test_*.py' -v
+95 -91
View File
@@ -1,119 +1,123 @@
# tests/test_compose.py
import contextlib
import io
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock
from actions import compose
from engine.descriptor import build_parser
class TestCompose(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.TemporaryDirectory()
self.addCleanup(self.tmpdir.cleanup)
self.comp_dir = os.path.join(self.tmpdir.name, "compositions")
os.makedirs(self.comp_dir)
# Create compositions: apex, sso, arr
for name in ("apex", "sso", "arr"):
d = os.path.join(self.comp_dir, name)
os.makedirs(d)
with open(os.path.join(d, "docker-compose.yml"), "w") as f:
f.write(f"name: {name}\n")
self.stier3 = os.path.join(self.tmpdir.name, "tier3_shared")
os.makedirs(os.path.join(self.stier3, "apex", "xray", "shm"))
self.comp_dir = Path(self.tmpdir.name) / 'compositions'
for name in ('apex', 'sso', 'arr'):
directory = self.comp_dir / name
directory.mkdir(parents=True)
(directory / 'docker-compose.yml').write_text(f'name: {name}\n')
self.ctx = MagicMock()
self.ctx.paths.compositions = self.comp_dir
self.ctx.paths.stier3 = self.stier3
self.ctx.vars.return_value = {
"APEX_SUBNET": "198.18.16.0/24",
"APEX_NODE_HOST": "test-host",
}
self.ctx.sys.run = MagicMock()
self.ctx.paths.compositions = str(self.comp_dir)
self.ctx.paths.stier3 = self.tmpdir.name
self.ctx.vars.return_value = {'APEX_NODE_HOST': 'test-host', 'OTHER': 'ignored'}
def test_sequence_all_by_default(self):
args = MagicMock(action="restart", p=None, dry_run=False, extra=[])
def run_compose(self, *argv):
args = build_parser(compose.METADATA, 'apex compose').parse_args(argv)
compose.run(self.ctx, args)
# Should execute on apex, arr, sso (apex core first, others sorted)
calls = self.ctx.sys.run.call_args_list
self.assertEqual(len(calls), 3)
visited = [os.path.basename(c.kwargs["cwd"]) for c in calls]
self.assertEqual(visited, ["apex", "arr", "sso"])
def visited(self):
return [os.path.basename(call.kwargs['cwd'])
for call in self.ctx.sys.run.call_args_list]
def test_down_reverse_sequence(self):
args = MagicMock(action="down", p=None, dry_run=False, extra=[])
compose.run(self.ctx, args)
def test_sequence_all_and_down_reverse(self):
for argv, expected in [(('restart',), ['apex', 'arr', 'sso']),
(('-p', 'all', 'up'), ['apex', 'arr', 'sso']),
(('down', 'all'), ['sso', 'arr', 'apex'])]:
with self.subTest(argv=argv):
self.ctx.sys.run.reset_mock()
self.run_compose(*argv)
self.assertEqual(self.visited(), expected)
calls = self.ctx.sys.run.call_args_list
self.assertEqual(len(calls), 3)
visited = [os.path.basename(c.kwargs["cwd"]) for c in calls]
# Reverse on down: sso, arr, apex
self.assertEqual(visited, ["sso", "arr", "apex"])
def test_explicit_target_preserves_service_named_like_composition(self):
self.run_compose('-p', 'apex', 'restart', 'sso')
self.assertEqual(self.visited(), ['apex'])
call = self.ctx.sys.run.call_args
self.assertEqual(call.args[0], ['docker', 'compose', 'restart', 'sso'])
self.assertEqual(call.kwargs['env']['APEX_NODE_HOST'], 'test-host')
self.assertTrue(call.kwargs['check'])
def test_target_single_composition_positional(self):
args = MagicMock(action="restart", p=None, dry_run=False, extra=["apex"])
compose.run(self.ctx, args)
def test_immediate_positional_composition_compatibility(self):
self.run_compose('restart', 'apex', 'traefik')
self.assertEqual(self.visited(), ['apex'])
self.assertEqual(self.ctx.sys.run.call_args.args[0],
['docker', 'compose', 'restart', 'traefik'])
calls = self.ctx.sys.run.call_args_list
self.assertEqual(len(calls), 1)
self.assertEqual(os.path.basename(calls[0].kwargs["cwd"]), "apex")
self.assertEqual(calls[0].args[0], ["docker", "compose", "restart"])
def test_up_detached_compatibility_and_default_detached(self):
for argv in [('up', '-d', 'apex'), ('-p', 'apex', 'up'),
('up', 'apex', '-d')]:
with self.subTest(argv=argv):
self.ctx.sys.run.reset_mock()
self.run_compose(*argv)
self.assertEqual(self.visited(), ['apex'])
self.assertEqual(self.ctx.sys.run.call_args.args[0],
['docker', 'compose', 'up', '-d'])
def test_target_single_composition_flag(self):
args = MagicMock(action="restart", p="sso", dry_run=False, extra=[])
compose.run(self.ctx, args)
def test_flag_values_and_unknown_services_do_not_retarget(self):
for argv in [('logs', '--tail', 'sso'), ('restart', 'traefik'),
('up', '--scale', 'sso'), ('logs', '--', 'apex')]:
with self.subTest(argv=argv):
self.ctx.sys.run.reset_mock()
self.run_compose(*argv)
self.assertEqual(self.visited(), ['apex', 'arr', 'sso'])
prefix = ['docker', 'compose', argv[0]]
if argv[0] == 'up':
prefix += ['-d']
self.assertEqual(self.ctx.sys.run.call_args.args[0], prefix + list(argv[1:]))
calls = self.ctx.sys.run.call_args_list
self.assertEqual(len(calls), 1)
self.assertEqual(os.path.basename(calls[0].kwargs["cwd"]), "sso")
def test_invalid_wrapper_options_fail_before_execution(self):
for argv, code in [(('-p', 'unknown', 'up'), 1), (('-p', '', 'up'), 1),
(('-p',), 64),
(('up', '-p', 'apex'), 64),
(('up', '--dry-run'), 64), ((), 64),
(('up', '-d', 'apex', 'traefik'), 64)]:
with self.subTest(argv=argv), contextlib.redirect_stdout(io.StringIO()), \
contextlib.redirect_stderr(io.StringIO()):
with self.assertRaises(SystemExit) as error:
self.run_compose(*argv)
self.assertEqual(error.exception.code, code)
self.ctx.sys.run.assert_not_called()
def test_target_with_service_passthrough(self):
args = MagicMock(action="restart", p=None, dry_run=False, extra=["apex", "traefik"])
compose.run(self.ctx, args)
def test_separator_preserves_wrapper_looking_passthrough_tokens(self):
self.run_compose('-p', 'apex', 'exec', '--', 'probe', '--dry-run', '-p', 'sso')
self.assertEqual(self.ctx.sys.run.call_args.args[0],
['docker', 'compose', 'exec', '--', 'probe', '--dry-run', '-p', 'sso'])
calls = self.ctx.sys.run.call_args_list
self.assertEqual(len(calls), 1)
self.assertEqual(os.path.basename(calls[0].kwargs["cwd"]), "apex")
self.assertEqual(calls[0].args[0], ["docker", "compose", "restart", "traefik"])
def test_env_files_layer_before_action(self):
for filename in ('.env', 'apex.env'):
(self.comp_dir / 'apex' / filename).write_text('PORT=1234\n')
self.run_compose('-p', 'apex', 'config')
self.assertEqual(self.ctx.sys.run.call_args.args[0],
['docker', 'compose', '--env-file', '.env', '--env-file',
'apex.env', 'config'])
def test_target_up_detached_order(self):
args = MagicMock(action="up", p=None, dry_run=False, extra=["-d", "sso"])
compose.run(self.ctx, args)
calls = self.ctx.sys.run.call_args_list
self.assertEqual(len(calls), 1)
self.assertEqual(os.path.basename(calls[0].kwargs["cwd"]), "sso")
self.assertEqual(calls[0].args[0], ["docker", "compose", "up", "-d"])
def test_dry_run_skips_execution(self):
args = MagicMock(action="restart", p=None, dry_run=True, extra=["apex"])
compose.run(self.ctx, args)
self.ctx.sys.run.assert_not_called()
self.ctx.log.info.assert_any_call(
f"[DRY-RUN] (cd {os.path.join(self.comp_dir, 'apex')} && docker compose restart)"
)
def test_unknown_target_raises_system_exit(self):
args = MagicMock(action="restart", p=None, dry_run=False, extra=["nonexistent"])
with self.assertRaises(SystemExit) as cm:
compose.run(self.ctx, args)
self.assertEqual(cm.exception.code, 1)
def test_xray_socket_cleanup_on_apex_restart(self):
shm = os.path.join(self.stier3, "apex", "xray", "shm")
sock_file = os.path.join(shm, "stale.sock")
with open(sock_file, "w") as f:
f.write("")
args = MagicMock(action="restart", p=None, dry_run=False, extra=["apex"])
compose.run(self.ctx, args)
self.assertFalse(os.path.exists(sock_file))
def test_socket_survives_restart_up_and_dry_run(self):
shm = Path(self.tmpdir.name) / 'apex' / 'xray' / 'shm'
shm.mkdir(parents=True)
path = shm / 'live.sock'
path.write_bytes(b"socket path must survive")
for argv in [('restart', 'apex'), ('-p', 'apex', 'restart', 'traefik'),
('up',), ('--dry-run', '-p', 'apex', 'logs', '--tail', '20', 'xray')]:
with self.subTest(argv=argv):
self.ctx.sys.run.reset_mock()
self.run_compose(*argv)
self.assertTrue(path.exists())
if argv[0] == '--dry-run':
self.ctx.sys.run.assert_not_called()
self.assertIn('logs --tail 20 xray', self.ctx.log.info.call_args.args[0])
if __name__ == "__main__":
if __name__ == '__main__':
unittest.main()
+281
View File
@@ -0,0 +1,281 @@
"""Local baseline policy: fake only package, network, and root privilege boundaries."""
import hashlib
import io
import json
import os
from pathlib import Path
import stat
import subprocess
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
from engine import overlay
from engine.lib.host import Host
from engine.lib.sys import Sys
base = overlay.load_module(
str(Path(__file__).resolve().parents[1] / "actions/configure/base.py"),
"apex_test_configure_base",
)
class TestBasePackages(unittest.TestCase):
def setUp(self):
self.system = Mock()
self.installed = set(base.BASE_PACKAGES)
self.system.run.side_effect = self.query
self.system.sudo.return_value = subprocess.CompletedProcess([], 0, stdout="")
def query(self, command, **kwargs):
self.assertEqual(command[:3], ["dpkg-query", "-W", "-f=${Status}"])
status = "install ok installed" if command[-1] in self.installed else "unknown ok not-installed"
return subprocess.CompletedProcess(command, 0, stdout=status)
def test_installed_rsyslog_and_ufw_do_not_require_executable_or_apt_update(self):
base._install_missing(self.system, base.BASE_PACKAGES)
self.assertEqual({call.args[0][-1] for call in self.system.run.call_args_list},
{"curl", "wget", "git", "rsyslog", "ufw"})
self.system.sudo.assert_not_called()
def test_only_missing_packages_are_installed_after_safe_simulation(self):
self.installed.remove("ufw")
self.system.sudo.return_value = subprocess.CompletedProcess(
[], 0, stdout="Inst ufw (0.36.2 Debian:13 [all])\nConf ufw (0.36.2 Debian:13 [all])\n",
)
base._install_missing(self.system, base.BASE_PACKAGES)
commands = [call.args[0] for call in self.system.sudo.call_args_list]
self.assertEqual(len(commands), 3)
self.assertIn("update", commands[0])
self.assertEqual(commands[1], [*commands[2], "--simulate"])
install = commands[2]
self.assertEqual(install[install.index("install"):], [
"install", "-y", "--no-install-recommends", "--no-upgrade", "--no-remove", "ufw",
])
def test_privileged_apt_environment_survives_sudo_filtering(self):
execute = subprocess.run
operations = []
def filtered_sudo(command, **kwargs):
if command[0] == "dpkg-query":
return subprocess.CompletedProcess(command, 1, stdout="")
self.assertEqual(command[0], "sudo")
incoming = kwargs.get("env") or os.environ
filtered = {"PATH": os.defpath}
if "LC_ALL" in incoming:
filtered["LC_ALL"] = incoming["LC_ALL"]
apt_index = command.index("apt-get")
probe = execute([
*command[1:apt_index], sys.executable, "-I", "-c",
"import json, os; print(json.dumps([os.getenv('LC_ALL'), "
"os.getenv('DEBIAN_FRONTEND')]))",
], env=filtered, check=True, text=True, capture_output=True)
self.assertEqual(json.loads(probe.stdout), ["C", "noninteractive"])
operations.append(command[apt_index:])
plan = "Inst ufw (0.36.2 Debian:13 [all])\n" if "--simulate" in command else ""
return subprocess.CompletedProcess(command, 0, stdout=plan)
with patch("engine.lib.sys.subprocess.run", side_effect=filtered_sudo):
base._install_missing(Sys(Mock()), ["ufw"])
self.assertEqual(len(operations), 3)
self.assertIn("update", operations[0])
self.assertIn("--simulate", operations[1])
self.assertIn("install", operations[2])
self.assertNotIn("--simulate", operations[2])
def test_dependency_upgrades_and_removals_abort_before_installation(self):
self.installed.remove("curl")
for plan in ("Inst libcurl4 [7.0] (8.0 Debian:13 [amd64])\n",
"Remv libcurl4 [7.0]\n"):
with self.subTest(plan=plan):
self.system.sudo.reset_mock()
self.system.sudo.return_value = subprocess.CompletedProcess([], 0, stdout=plan)
with self.assertRaisesRegex(RuntimeError, "upgrades/removals"):
base._install_missing(self.system, base.BASE_PACKAGES)
self.assertEqual(self.system.sudo.call_count, 2)
def test_failed_package_query_is_not_treated_as_installed(self):
self.system.run.return_value = subprocess.CompletedProcess([], 1, stdout="")
self.system.run.side_effect = None
base._install_missing(self.system, ["rsyslog"])
self.assertEqual(self.system.sudo.call_args.args[0][-1], "rsyslog")
class TestPinnedHelper(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name).resolve()
self.path = self.root / "bin" / "ufw-docker"
self.path.parent.mkdir()
self.payload = b"#!/bin/sh\nprintf 'verified fixture\\n'\n"
self.wrong_owner = None
self.wrong_group = None
self.system = Mock()
self.system.sudo.side_effect = self.sudo
self.ctx = SimpleNamespace(log=Mock(), sys=self.system)
self.ctx.host = Host(self.ctx.log, self.system)
real_lstat = Path.lstat
def root_lstat(path, *args, **kwargs):
info = list(real_lstat(path, *args, **kwargs))
info[4] = 1001 if path == self.wrong_owner else 0
info[5] = 1001 if path == self.wrong_group else 0
if path not in (self.path, self.path.parent, self.root):
info[0] &= ~0o022
return os.stat_result(info)
# Model root-owned ancestry without sudo/chown; content, modes and links stay real.
self.addCleanup(patch.stopall)
patch.object(Path, "lstat", autospec=True, side_effect=root_lstat).start()
patch.object(base, "UFW_DOCKER_PATH", self.path).start()
patch.object(base, "UFW_DOCKER_SHA256", hashlib.sha256(self.payload).hexdigest()).start()
self.download = patch.object(base.urllib.request, "urlopen").start()
self.download.return_value = io.BytesIO(self.payload)
def sudo(self, command, **kwargs):
if command[:3] == ["python3", "-I", "-c"]:
return subprocess.run([sys.executable, *command[1:]], check=True, text=True, **kwargs)
self.assertEqual(command, [str(self.path), "install"])
self.assertEqual(self.path.read_bytes(), self.payload)
self.assertEqual(stat.S_IMODE(self.path.stat().st_mode), 0o755)
return subprocess.CompletedProcess(command, 0)
def write_helper(self, payload=None, mode=0o755):
self.path.write_bytes(self.payload if payload is None else payload)
self.path.chmod(mode)
def test_verified_existing_helper_executes_without_download(self):
self.write_helper()
base._install_helper(self.ctx)
self.download.assert_not_called()
self.assertEqual(self.system.sudo.call_args.args[0], [str(self.path), "install"])
self.assertEqual(self.system.sudo.call_count, 1)
def test_wrong_executable_content_is_replaced_only_with_verified_download(self):
self.write_helper(b"#!/bin/sh\necho wrong\n")
base._install_helper(self.ctx)
self.assertEqual(self.path.read_bytes(), self.payload)
self.assertEqual(self.system.sudo.call_count, 2)
self.download.assert_called_once_with(base.UFW_DOCKER_URL, timeout=30)
def test_missing_helper_is_published_with_executable_mode(self):
base._install_helper(self.ctx)
self.assertEqual(self.path.read_bytes(), self.payload)
self.assertEqual(stat.S_IMODE(self.path.stat().st_mode), 0o755)
self.assertEqual(list(self.path.parent.iterdir()), [self.path])
def test_verified_nonexecutable_content_is_reused_and_mode_repaired(self):
self.write_helper(mode=0o600)
base._install_helper(self.ctx)
self.download.assert_not_called()
self.assertEqual(stat.S_IMODE(self.path.stat().st_mode), 0o755)
self.assertEqual(self.system.sudo.call_count, 2)
def test_bad_checksum_never_publishes_or_executes(self):
self.download.return_value = io.BytesIO(b"not the pinned helper")
with self.assertRaisesRegex(RuntimeError, "checksum mismatch"):
base._install_helper(self.ctx)
self.system.sudo.assert_not_called()
self.assertFalse(self.path.exists())
def test_bad_download_preserves_existing_wrong_helper_without_executing_it(self):
self.write_helper(b"old unknown executable")
self.download.return_value = io.BytesIO(b"bad download")
with self.assertRaisesRegex(RuntimeError, "checksum mismatch"):
base._install_helper(self.ctx)
self.system.sudo.assert_not_called()
self.assertEqual(self.path.read_bytes(), b"old unknown executable")
def assert_rejected_before_network_or_sudo(self):
with self.assertRaisesRegex(RuntimeError, "Unsafe ufw-docker"):
base._install_helper(self.ctx)
self.download.assert_not_called()
self.system.sudo.assert_not_called()
def test_symlink_target_is_rejected(self):
target = self.root / "untrusted"
target.write_bytes(self.payload)
self.path.symlink_to(target)
self.assert_rejected_before_network_or_sudo()
def test_dangling_symlink_target_is_rejected(self):
self.path.symlink_to(self.root / "missing")
self.assert_rejected_before_network_or_sudo()
def test_symlink_ancestor_is_rejected(self):
self.path.parent.rmdir()
target = self.root / "real-bin"
target.mkdir()
self.path.parent.symlink_to(target)
self.assert_rejected_before_network_or_sudo()
def test_writable_ancestor_is_rejected(self):
self.path.parent.chmod(0o777)
self.assert_rejected_before_network_or_sudo()
def test_unsafe_helper_preflight_precedes_package_network_or_mutation(self):
self.path.parent.chmod(0o777)
with patch.object(base.os.path, "isfile", return_value=True):
with self.assertRaisesRegex(RuntimeError, "Unsafe ufw-docker"):
base.run(self.ctx, None)
self.download.assert_not_called()
self.system.run.assert_not_called()
self.system.sudo.assert_not_called()
def test_untrusted_owner_ancestor_is_rejected(self):
self.wrong_owner = self.path.parent
self.assert_rejected_before_network_or_sudo()
def test_untrusted_file_owner_is_rejected(self):
self.write_helper()
self.wrong_owner = self.path
self.assert_rejected_before_network_or_sudo()
def test_untrusted_file_group_is_rejected(self):
self.write_helper()
self.wrong_group = self.path
self.assert_rejected_before_network_or_sudo()
def test_writable_helper_is_rejected(self):
self.write_helper(mode=0o775)
self.assert_rejected_before_network_or_sudo()
def test_hardlinked_helper_is_rejected(self):
self.write_helper()
os.link(self.path, self.root / "other-link")
self.assert_rejected_before_network_or_sudo()
def test_nonregular_helper_is_rejected(self):
self.path.mkdir()
self.assert_rejected_before_network_or_sudo()
def test_path_changed_during_download_is_rejected_before_publication(self):
def download(*args, **kwargs):
self.path.symlink_to(self.root / "untrusted")
return io.BytesIO(self.payload)
self.download.side_effect = download
with self.assertRaisesRegex(RuntimeError, "Unsafe ufw-docker"):
base._install_helper(self.ctx)
self.system.sudo.assert_not_called()
def test_changed_content_after_publication_is_not_executed(self):
real_sudo = self.sudo
def tamper(command, **kwargs):
result = real_sudo(command, **kwargs)
self.path.write_bytes(b"changed after publication")
return result
self.system.sudo.side_effect = tamper
with self.assertRaisesRegex(RuntimeError, "verification failed"):
base._install_helper(self.ctx)
self.assertEqual(self.system.sudo.call_count, 1)
if __name__ == "__main__":
unittest.main()
+156 -42
View File
@@ -1,68 +1,182 @@
# tests/test_env.py
import contextlib
import io
import json
import os
import sys
import shutil
import stat
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock
from actions import env
from engine.descriptor import build_parser
from engine.lib.host import Host
class TestEnv(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.TemporaryDirectory()
self.addCleanup(self.tmpdir.cleanup)
self.comp_dir = os.path.join(self.tmpdir.name, "compositions")
os.makedirs(os.path.join(self.comp_dir, "apex"))
self.core = Path(self.tmpdir.name) / 'compositions' / 'apex'
self.core.mkdir(parents=True)
self.ctx = MagicMock()
self.ctx.paths.compositions = self.comp_dir
self.ctx.paths.compositions = str(self.core.parent)
self.ctx.vars.return_value = {
"APEX_SUBNET": "198.18.16.0/24",
"APEX_NODE_HOST": "test-host",
"OTHER_VAR": "ignore_me",
'APEX_SUBNET': '198.18.16.0/24',
'APEX_NODE_HOST': 'test-host',
'OTHER_VAR': 'ignore_me',
}
self.ctx.host = Host(self.ctx.log, self.ctx.sys)
def test_export_format_stdout(self):
args = MagicMock(sync=False, write=None, dotenv=False)
captured = io.StringIO()
old_stdout = sys.stdout
try:
sys.stdout = captured
def run_env(self, *argv):
args = build_parser(env.METADATA, 'apex env').parse_args(argv)
with contextlib.redirect_stdout(io.StringIO()) as out:
env.run(self.ctx, args)
finally:
sys.stdout = old_stdout
return out.getvalue()
out = captured.getvalue()
self.assertIn("export APEX_NODE_HOST=test-host", out)
self.assertIn("export APEX_SUBNET=198.18.16.0/24", out)
self.assertNotIn("OTHER_VAR", out)
def test_shell_exports_round_trip_literal_values(self):
value = "a$b # 'quote' \"double\" \\backslash\r\nnext"
self.ctx.vars.return_value['APEX_NODE_HOST'] = value
exports = self.run_env('--export')
self.assertEqual(self.run_env(), exports)
script = exports + '\nprintf %s "$APEX_NODE_HOST"'
result = subprocess.run(['/bin/sh', '-c', script], capture_output=True, check=True,
timeout=30, env={})
self.assertEqual(result.stdout.decode(), value)
self.assertNotIn('OTHER_VAR', script)
def test_dotenv_format_stdout(self):
args = MagicMock(sync=False, write=None, dotenv=True)
captured = io.StringIO()
old_stdout = sys.stdout
try:
sys.stdout = captured
env.run(self.ctx, args)
finally:
sys.stdout = old_stdout
def test_dotenv_does_not_disclose_other_values_or_existing_secrets(self):
(self.core / '.env').write_text('SECRET=synthetic-private\n')
output = self.run_env('--dotenv')
self.assertIn('APEX_NODE_HOST=', output)
self.assertNotIn('export ', output)
self.assertNotIn('SECRET', output)
self.assertNotIn('OTHER_VAR', output)
out = captured.getvalue()
self.assertIn("APEX_NODE_HOST=test-host", out)
self.assertNotIn("export", out)
def test_repeat_sync_preserves_credentials_bytes_and_updates_managed_values(self):
original = b'# user credentials\r\nTOKEN=\'a$#"\\literal\'\r\nUID=1000\r\nGID=1000'
target = self.core / '.env'
target.write_bytes(original)
target.chmod(0o600)
(self.core / 'apex.env').write_bytes(b'COMPOSE_PROFILES=core\r\nPORT=1234\r\n')
self.run_env('--sync')
first = target.read_bytes()
self.assertTrue(first.startswith(original + b'\n'))
self.assertIn(b'COMPOSE_PROFILES=core\r\nPORT=1234\r\n', first)
self.run_env('--sync')
self.assertEqual(first, target.read_bytes())
self.ctx.vars.return_value['APEX_NODE_HOST'] = 'new-host'
self.run_env('--sync')
self.assertTrue(target.read_bytes().startswith(original + b'\n'))
self.assertNotIn(b'test-host', target.read_bytes())
self.assertEqual(stat.S_IMODE(target.stat().st_mode), 0o600)
def test_sync_to_file(self):
target = os.path.join(self.comp_dir, "apex", ".env")
args = MagicMock(sync=True, write=None, dotenv=False)
env.run(self.ctx, args)
def test_sync_preserves_symlink_and_private_target(self):
target = Path(self.tmpdir.name) / 'private.env'
target.write_text('TOKEN=synthetic\n')
link = self.core / '.env'
link.symlink_to(target)
self.run_env('--sync')
self.assertTrue(link.is_symlink())
self.assertIn('TOKEN=synthetic', target.read_text())
self.assertIn('APEX_NODE_HOST=', target.read_text())
self.assertEqual(stat.S_IMODE(target.stat().st_mode), 0o600)
self.ctx.host.write_file.assert_called_once()
call_args = self.ctx.host.write_file.call_args
self.assertEqual(call_args[0][0], target)
self.assertIn("APEX_SUBNET=198.18.16.0/24", call_args[0][1])
def test_write_basename_is_standalone_and_private(self):
previous = os.getcwd()
self.addCleanup(os.chdir, previous)
os.chdir(self.tmpdir.name)
Path('generated.env').write_text('OLD=removed\n')
self.run_env('--write', 'generated.env')
content = Path('generated.env').read_text()
self.assertIn('APEX_NODE_HOST=', content)
self.assertNotIn('OLD=', content)
self.assertNotIn('BEGIN', content)
self.assertEqual(stat.S_IMODE(Path('generated.env').stat().st_mode), 0o600)
def test_incompatible_modes_fail_without_writing(self):
for argv in [('--sync', '--dotenv'), ('--sync', '--write', 'file'),
('--export', '--dotenv'), ('--export', '--write', 'file')]:
with self.subTest(argv=argv):
with self.assertRaises(SystemExit) as error:
self.run_env(*argv)
self.assertEqual(error.exception.code, 64)
self.assertFalse((self.core / '.env').exists())
def test_uncertain_managed_markers_fail_without_changing_file(self):
start = '# BEGIN APEX MANAGED ENV'
end = '# END APEX MANAGED ENV'
for content in [start+'\n', end+'\n', end+'\n'+start+'\n',
start+'\n'+start+'\n'+end+'\n',
start+'\n'+end+'\n'+start+'\n'+end+'\n',
'TOKEN="'+start+'"\n']:
target = self.core / '.env'
target.write_text(content)
with self.subTest(content=content):
with self.assertRaises(SystemExit) as error:
self.run_env('--sync')
self.assertEqual(error.exception.code, 65)
self.assertEqual(target.read_text(), content)
def test_source_marker_collision_fails_without_writing(self):
(self.core / 'apex.env').write_text('# BEGIN APEX MANAGED ENV\n')
with self.assertRaises(SystemExit) as error:
self.run_env('--sync')
self.assertEqual(error.exception.code, 65)
self.assertFalse((self.core / '.env').exists())
(self.core / 'apex.env').unlink()
self.ctx.vars.return_value['APEX_NODE_HOST'] = '# END APEX MANAGED ENV'
with self.assertRaises(SystemExit) as error:
self.run_env('--sync')
self.assertEqual(error.exception.code, 65)
self.assertFalse((self.core / '.env').exists())
def test_bare_compose_interprets_literals_precedence_and_profiles(self):
binary = shutil.which('docker-compose')
if binary is None:
self.skipTest('Standalone docker-compose required for daemon-free config check')
values = {
'APEX_LITERAL': "a$b ${UNSET} $$ # 'quote' \"double\" \\backslash",
'APEX_TRAILING': 'last\\',
'APEX_LINES': 'one\r\ntwo\nthree\tfour',
'APEX_EMPTY': '',
'APEX_ESCAPES': "\\n \\\\ \\' \\\"",
'APEX_NODE_HOST': 'synthesized',
}
self.ctx.vars.return_value = values
(self.core / '.env').write_text('UID=1000\nGID=1001\nPORT=1\nAPEX_NODE_HOST=old\n'
"TOKEN='literal$token#hash'\n")
(self.core / 'apex.env').write_text('PORT=2\nAPEX_NODE_HOST=public\n'
'COMPOSE_PROFILES=chosen\n')
keys = [*values, 'UID', 'GID', 'PORT', 'TOKEN']
(self.core / 'docker-compose.yml').write_text(
'services:\n probe:\n image: busybox\n profiles: [chosen]\n'
' environment:\n' + ''.join(f' {key}: ${{{key}}}\n' for key in keys))
self.run_env('--sync')
result = subprocess.run([binary, 'config', '--format', 'json'], cwd=self.core,
capture_output=True, check=True, timeout=30, env={})
actual = json.loads(result.stdout)['services']['probe']['environment']
expected = {**values, 'UID': '1000', 'GID': '1001', 'PORT': '2',
'TOKEN': 'literal$token#hash'}
# Compose re-escapes literal dollars when serializing a reusable configuration.
self.assertEqual(actual, {k: v.replace('$', '$$') for k, v in expected.items()})
result = subprocess.run([binary, 'config', '--environment'], cwd=self.core,
capture_output=True, check=True, timeout=30, env={})
for key, value in {**expected, 'COMPOSE_PROFILES': 'chosen'}.items():
self.assertIn(f'{key}={value}\n'.encode(), result.stdout)
with (self.core / '.env').open('a') as target:
target.write('# later user addition\nPORT=999\n')
self.run_env('--sync')
self.assertIn('# later user addition\nPORT=999\n', (self.core / '.env').read_text())
result = subprocess.run([binary, 'config', '--format', 'json'], cwd=self.core,
capture_output=True, check=True, timeout=30, env={})
self.assertEqual(json.loads(result.stdout)['services']['probe']['environment']['PORT'], '2')
result = subprocess.run([binary, 'config', '--services'], cwd=self.core,
capture_output=True, check=True, timeout=30, env={})
self.assertEqual(result.stdout.strip(), b'probe')
if __name__ == "__main__":
if __name__ == '__main__':
unittest.main()
+195 -1
View File
@@ -1,6 +1,200 @@
# tests/test_host.py
import inspect
import os
from pathlib import Path
import stat
import subprocess
import sys
import tempfile
import unittest
from engine.lib.host import apply_block
from unittest.mock import Mock, patch
from engine.lib.host import Host, apply_block
def _check_backup_chmod(chmod, expected_group):
import os
import stat
def checked(path, mode, *args, **kwargs):
result = chmod(path, mode, *args, **kwargs)
info = os.stat(path)
with open(path, "rb") as stream:
content = stream.read()
if content and stat.S_IMODE(info.st_mode) & 0o077 and info.st_gid != expected_group:
raise AssertionError("Backup content exposed to the inherited staging group")
return result
return checked
class TestHostWrites(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name).resolve()
self.log = Mock()
self.host = Host(self.log, Mock())
def test_existing_private_modes_and_owner_survive_replacement(self):
for mode in (0o600, 0o640):
with self.subTest(mode=mode):
path = self.root / "secret"
path.write_text("old")
path.chmod(mode)
previous = path.stat()
self.host.write_file(str(path), "new", backup=False)
self.assertEqual(path.read_text(), "new")
self.assertEqual(stat.S_IMODE(path.stat().st_mode), mode)
self.assertEqual((path.stat().st_uid, path.stat().st_gid),
(previous.st_uid, previous.st_gid))
def test_new_basename_defaults_to_0644(self):
previous = Path.cwd()
try:
os.chdir(self.root)
self.host.write_file("settings", "value", backup=False)
finally:
os.chdir(previous)
path = self.root / "settings"
self.assertEqual(path.read_text(), "value")
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o644)
def test_existing_supplementary_group_survives_replacement(self):
path = self.root / "group-owned"
path.write_text("old")
groups = set(os.getgroups()) - {path.stat().st_gid}
if not groups:
self.skipTest("No alternate supplementary group available")
group = next(iter(groups))
try:
os.chown(path, -1, group)
except PermissionError:
self.skipTest("Filesystem does not permit changing to a supplementary group")
self.host.write_file(str(path), "new", backup=False)
self.assertEqual(path.read_text(), "new")
self.assertEqual(path.stat().st_gid, group)
def test_explicit_mode_is_applied_before_atomic_publication(self):
path = self.root / "secret"
path.write_text("old")
replace = os.replace
published = []
def observe(source, destination):
self.assertEqual(path.read_text(), "old")
self.assertEqual(Path(source).parent, self.root)
self.assertEqual(stat.S_IMODE(os.stat(source).st_mode), 0o600)
self.assertEqual(Path(source).read_text(), "new secret")
published.append(destination)
replace(source, destination)
with patch("os.replace", side_effect=observe):
self.host.write_file(str(path), "new secret", mode="0600", backup=False)
self.assertEqual(len(published), 1)
self.assertEqual(path.read_text(), "new secret")
def test_symlink_remains_and_target_receives_new_content(self):
target = self.root / "tier" / "secret"
target.parent.mkdir()
target.write_text("old")
target.chmod(0o600)
link = self.root / ".env"
link.symlink_to("tier/secret")
self.host.write_file(str(link), "new", backup=False)
self.assertTrue(link.is_symlink())
self.assertEqual(os.readlink(link), "tier/secret")
self.assertEqual(target.read_text(), "new")
self.assertEqual(stat.S_IMODE(target.stat().st_mode), 0o600)
def test_dry_run_does_not_create_parents_or_backups(self):
self.host.write_file(str(self.root / "missing" / "secret"), "new", dry_run=True)
self.assertEqual(list(self.root.iterdir()), [])
def test_backup_preserves_old_private_content(self):
path = self.root / "secret"
path.write_text("old")
path.chmod(0o600)
self.host.write_file(str(path), "new")
backups = list(self.root.glob("secret.*.bak"))
self.assertEqual(len(backups), 1)
self.assertEqual(backups[0].read_text(), "old")
self.assertEqual(stat.S_IMODE(backups[0].stat().st_mode), 0o600)
self.assertEqual(path.read_text(), "new")
def test_backup_sets_owner_before_exposing_group_readable_content(self):
path = self.root / "group-secret"
path.write_text("synthetic group-only secret")
groups = set(os.getgroups()) - {path.stat().st_gid}
if not groups:
self.skipTest("No alternate supplementary group available")
group = next(iter(groups))
try:
os.chown(path, -1, group)
except PermissionError:
self.skipTest("Filesystem does not permit changing to a supplementary group")
path.chmod(0o640)
os.utime(path, (1600000000, 1600000000))
def local_sudo(command, **kwargs):
self.assertEqual(command[:3], ["python3", "-I", "-c"])
probe = ("import os\n" + inspect.getsource(_check_backup_chmod) +
f"\nos.chmod = _check_backup_chmod(os.chmod, {group})\n")
result = subprocess.run([sys.executable, "-I", "-c", probe + command[3]],
check=False, text=True, capture_output=True, **kwargs)
self.assertEqual(result.returncode, 0, result.stderr)
return result
self.host.sys.ok.return_value = True
self.host.sys.sudo.side_effect = local_sudo
for sudo in (False, True):
with self.subTest(sudo=sudo):
with patch("os.chmod", side_effect=_check_backup_chmod(os.chmod, group)):
backup = Path(self.host.backup(str(path), sudo=sudo))
self.assertEqual(backup.read_bytes(), path.read_bytes())
self.assertEqual(backup.stat().st_gid, group)
self.assertEqual(stat.S_IMODE(backup.stat().st_mode), 0o640)
self.assertEqual(backup.stat().st_mtime_ns, path.stat().st_mtime_ns)
backup.unlink()
def test_publication_failure_keeps_original_and_cleans_staging(self):
path = self.root / "secret"
path.write_text("old")
with patch("os.replace", side_effect=OSError("publication failed")):
with self.assertRaisesRegex(OSError, "publication failed"):
self.host.write_file(str(path), "new", backup=False)
self.assertEqual(path.read_text(), "old")
self.assertEqual(list(self.root.iterdir()), [path])
self.log.success.assert_not_called()
def test_write_failure_keeps_original_and_cleans_staging(self):
path = self.root / "secret"
path.write_text("old")
with patch("os.fsync", side_effect=OSError("disk full")):
with self.assertRaisesRegex(OSError, "disk full"):
self.host.write_file(str(path), "new", backup=False)
self.assertEqual(path.read_text(), "old")
self.assertEqual(list(self.root.iterdir()), [path])
self.log.success.assert_not_called()
def test_sudo_runs_same_writer_without_copying_over_live_target(self):
target = self.root / "secret"
target.write_text("old")
target.chmod(0o640)
link = self.root / ".env"
link.symlink_to("secret")
def local_sudo(command, **kwargs):
self.assertEqual(command[:3], ["python3", "-I", "-c"])
return subprocess.run([sys.executable, *command[1:]], check=True,
text=True, **kwargs)
self.host.sys.sudo.side_effect = local_sudo
self.host.write_file(str(link), "new", sudo=True, backup=False)
self.assertTrue(link.is_symlink())
self.assertEqual(target.read_text(), "new")
self.assertEqual(stat.S_IMODE(target.stat().st_mode), 0o640)
self.assertEqual(set(self.root.iterdir()), {link, target})
class TestApplyBlock(unittest.TestCase):
def test_insert_after_anchor(self):
+1 -1
View File
@@ -1,6 +1,6 @@
# tests/test_identity.py
import os, tempfile, unittest
from engine.identity import resolve, Node
from engine.identity import resolve
class TestIdentity(unittest.TestCase):
def _envfile(self, text):
+74
View File
@@ -0,0 +1,74 @@
"""Exercise verification entrypoints in disposable source trees."""
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
class ToolingTests(unittest.TestCase):
def test_lint_fallback_recurses_and_propagates_each_root_failure(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
shutil.copyfile(ROOT / 'Makefile', root / 'Makefile')
binaries = root / 'bin'
binaries.mkdir()
make = shutil.which('make')
self.assertIsNotNone(make, 'make is required for the declared verification workflow')
(binaries / 'make').symlink_to(make)
(binaries / 'python3').symlink_to(sys.executable)
environment = dict(os.environ, PATH=str(binaries))
sources = []
for name in ('engine', 'actions', 'tests', 'ci/scripts'):
source = root / name / 'nested' / 'case.py'
source.parent.mkdir(parents=True)
source.write_text('value = 1\n')
sources.append(source)
for source in sources:
with self.subTest(root=source.parent.parent):
invalid = source.with_name('invalid.py')
invalid.write_text('def broken(:\n')
result = subprocess.run([str(binaries / 'make'), 'lint'], cwd=root,
env=environment, capture_output=True, text=True,
timeout=30)
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertNotIn('verified python bytecode compilation', result.stdout)
invalid.unlink()
result = subprocess.run([str(binaries / 'make'), 'lint'], cwd=root,
env=environment, capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn('verified python bytecode compilation', result.stdout)
def test_suite_runner_discovers_both_suites_and_propagates_failures(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
suites = (root / 'tests', root / 'ci/scripts/init-node/tests')
for suite in suites:
suite.mkdir(parents=True)
shutil.copyfile(ROOT / 'tests/run.sh', suites[0] / 'run.sh')
binaries = root / 'bin'
binaries.mkdir()
(binaries / 'python3').symlink_to(sys.executable)
environment = dict(os.environ, PATH=str(binaries) + os.pathsep + os.environ['PATH'])
for failure in (None, 0, 1):
with self.subTest(failing_suite=failure):
for index, suite in enumerate(suites):
(suite / 'test_case.py').write_text(
'import unittest\nclass Case(unittest.TestCase):\n'
' def test_suite_' + str(index) + '(self):\n'
' self.assertTrue(' + repr(index != failure) + ')\n')
result = subprocess.run(['/bin/bash', 'tests/run.sh'], cwd=root,
env=environment, capture_output=True, text=True,
timeout=30)
if failure is None:
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn('test_suite_0', result.stderr)
self.assertIn('test_suite_1', result.stderr)
else:
self.assertNotEqual(result.returncode, 0, result.stderr)
self.assertIn('FAIL: test_suite_' + str(failure), result.stderr)