2 Commits
50 changed files with 1450 additions and 498 deletions

No files matched your search

@@ -23,12 +23,20 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 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 - name: Validate Shell Script Syntax
run: | 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" bash -n "$script"
done done
bash tests/run.sh make check
- name: Setup Packer - name: Setup Packer
uses: hashicorp/setup-packer@main uses: hashicorp/setup-packer@main
+1
View File
@@ -9,6 +9,7 @@
__pycache__/ __pycache__/
*.pyc *.pyc
.pytest_cache/ .pytest_cache/
.venv/
# Runtime / secrets (never in commons) # Runtime / secrets (never in commons)
*.log *.log
+2 -2
View File
@@ -1,13 +1,13 @@
.PHONY: test lint fmt check .PHONY: test lint fmt check
test: test:
python3 -m unittest discover -s tests bash tests/run.sh
lint: lint:
@if command -v ruff >/dev/null 2>&1; then \ @if command -v ruff >/dev/null 2>&1; then \
ruff check .; \ ruff check .; \
else \ 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."; \ echo "ruff not installed in PATH; verified python bytecode compilation."; \
fi fi
+56 -9
View File
@@ -4,7 +4,7 @@
# [apex.ermnvldmr.com](https://apex.ermnvldmr.com) # [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 ## Quick Start
@@ -22,7 +22,7 @@ cd selfhost.example.com && ./init.sh && source ~/.bashrc
cat > node.env <<'EOF' cat > node.env <<'EOF'
APEX_NODE_FQDN=selfhost.example.com APEX_NODE_FQDN=selfhost.example.com
APEX_NODE_HOST=selfhost.example.com APEX_NODE_HOST=selfhost.example.com
APEX_SUBNET=198.18.16.0/24 APEX_SUBNET=198.18.0.0/24
EOF EOF
# Configure host baseline and bring up compositions # Configure host baseline and bring up compositions
@@ -37,7 +37,7 @@ You should understand the following invariants before exploring the codebase:
### Core Principles ### Core Principles
- **No Control Plane**: No central orchestrator, agent daemon, or cluster controller. Commands act locally on the node you run them on. - **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. - **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/`. - **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/`). - **[`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`). - **[`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. - **[`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). - **[`etc/`](./etc)**: Shared static configuration, including Alloy.
- **[`ci/`](./ci)**: Host image build recipes (Packer for Debian 13) and cloud-init bootstrap templates. - **[`ci/`](./ci)**: Host image build recipes, cloud-init templates, and standalone Debian enrollment tooling.
- **[`assets/`](./assets)**: Repository branding assets (`logo.png`). - **[`assets/`](./assets)**: Repository branding assets (`logo.png`).
--- ---
@@ -63,7 +63,44 @@ Running and maintaining services on an existing host.
- Update services: `apex compose pull && apex compose up` - Update services: `apex compose pull && apex compose up`
- Run backups: `apex backup/run` - Run backups: `apex backup/run`
- Sync repository state: `apex sync/repository` - 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 app 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.
Replace `app` with a composition defined in your repository. Use targeted `up -d`
after changing a container definition; `restart` retains the existing definition.
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 ### Infrastructure Administrator
@@ -75,16 +112,26 @@ Bootstrapping clean Debian 13 hosts on bare metal or cloud providers without clo
```sh ```sh
python3 ci/scripts/init-node.py root@server --user-data /private/path/location.user-data.yaml 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. - **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 ### Developer & Contributor
Adding shared actions or improving the engine. Adding shared actions or improving the engine.
- **Conventions**: Maintain strict Python standard library compliance. No external runtime packages. - **Conventions**: Maintain strict Python standard library compliance. No external runtime packages.
- **Testing**: Run test suite via `python3 -m unittest discover -s tests`. - **Public documentation**: Describe supported interfaces using synthetic examples. Keep deployment inventories, internal plans, operational runbooks, and host-specific configuration in their owning private repositories.
- **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: except Exception as e:
log.error(f"{type(e).__name__}: {e}") log.error(f"{type(e).__name__}: {e}")
ctx.notify.error(TITLE, url, f"backup failed: {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. """Orchestrate docker compose across the node.
Supports targeting all compositions in sequence (default: apex core first, services next; 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` reverse on down) or targeting one with `apex compose -p apex restart traefik`.
or `apex compose up -d sso`). Exports derived APEX_* vars and layers --env-file (.env secrets + apex.env). Wrapper options precede the action. Exports derived APEX_* vars and layers env files.
""" """
import os import os
from engine.descriptor import Arg, Flag, Meta, Opt, Rest import shlex
from engine.descriptor import Flag, Meta, Opt, Rest
METADATA = Meta( METADATA = Meta(
summary="Orchestrate docker compose across the node or on a specific composition.", summary="Orchestrate docker compose across the node or on a specific composition.",
args=[ args=[
Opt("-p", "Target composition name or 'all'.", default=None), Opt("-p", "Target composition name or 'all'.", default=None),
Flag("--dry-run", "Print commands only."), Flag("--dry-run", "Print commands only."),
Arg("action", "up | down | restart | ps | logs | ..."), Rest("command", "ACTION [COMPOSE_ARGS...]; wrapper options must precede ACTION."),
Rest("extra", "Extra args (target composition name, service names, or docker compose flags)."),
], ],
) )
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): def run(ctx, args):
log, comp, action = ctx.log, ctx.paths.compositions, args.action log, comp = ctx.log, ctx.paths.compositions
extra = [a for a in (args.extra or []) if a != "--dry-run"] command = list(args.command)
dry_run = args.dry_run or len(extra) != len(args.extra or []) 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 = dict(os.environ)
env.update({k: v for k, v in ctx.vars().items() if k.startswith("APEX_")}) 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) target = getattr(args, "p", None)
if not target and extra: if target is None and extra:
first = extra[0] if extra[0] in ["all", *available]:
if first == "all": target, extra = extra[0], extra[1:]
target = "all" elif action == "up" and len(extra) >= 2 and extra[0] == "-d":
extra = extra[1:] # Preserve the documented `up -d apex` spelling without scanning flag values.
elif first in available: if extra[1] in ["all", *available]:
target = first if len(extra) != 2:
extra = extra[1:] log.error("Use -p COMPOSITION before up when adding service arguments.")
elif not first.startswith("-"): raise SystemExit(64)
target = first target, extra = extra[1], ["-d"]
extra = extra[1:]
elif extra[-1] in available and len(extra) > 1:
target = extra[-1]
extra = extra[:-1]
if not target: if target is None:
target = "all" target = "all"
if target != "all" and target not in available: 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) raise SystemExit(1)
if action in ("up", "restart", "start") and target in ("all", "apex") and not dry_run:
_clean_xray_sockets(ctx)
def sequence(): def sequence():
if target != "all": if target != "all":
return [os.path.join(comp, target)] return [os.path.join(comp, target)]
@@ -89,13 +82,13 @@ def run(ctx, args):
if os.path.isfile(os.path.join(dirpath, ef)): if os.path.isfile(os.path.join(dirpath, ef)):
base += ["--env-file", ef] base += ["--env-file", ef]
if action == "up": 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 cmd = base + ["up"] + up_flags + extra
else: else:
cmd = base + [action] + extra cmd = base + [action] + extra
if dry_run: 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: else:
log.info(f"{action} {name}...") log.info(f"{action} {name}...")
ctx.sys.run(cmd, cwd=dirpath, env=env, check=True) ctx.sys.run(cmd, cwd=dirpath, env=env, check=True)
+2 -2
View File
@@ -1,5 +1,5 @@
# actions/configure.py # actions/configure.py
"""Umbrella: run every configure/* action in a stable order (SP2 §3.1).""" """Run every configure/* action in a stable order."""
import os import os
from engine.descriptor import Meta, Flag, build_parser from engine.descriptor import Meta, Flag, build_parser
from engine import overlay from engine import overlay
@@ -21,7 +21,7 @@ def run(ctx, args):
mod = overlay.load_module(r.path, "apex_umbrella_" + name.replace("/", "_")) mod = overlay.load_module(r.path, "apex_umbrella_" + name.replace("/", "_"))
if getattr(mod, "DISABLED", None): if getattr(mod, "DISABLED", None):
ctx.log.warn(f"Skipping disabled {name}"); continue 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) 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 [] argv = ["--dry-run"] if (args.dry_run and has_dry) else []
sub_args = build_parser(meta, f"apex {name}").parse_args(argv) sub_args = build_parser(meta, f"apex {name}").parse_args(argv)
+78 -36
View File
@@ -1,13 +1,85 @@
# actions/configure/base.py # actions/configure/base.py
"""Port of configure/base — base packages + ufw + ufw-docker helper.""" """Port of configure/base — base packages + ufw + ufw-docker helper."""
import hashlib
import os import os
import shutil from pathlib import Path
import tempfile import re
import stat
import urllib.request import urllib.request
from engine.descriptor import Meta from engine.descriptor import Meta
METADATA = Meta(summary="Configure base system packages (curl, wget, git, rsyslog, ufw, ufw-docker).") 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): def run(ctx, args):
@@ -16,14 +88,8 @@ def run(ctx, args):
if not os.path.isfile("/etc/debian_version"): if not os.path.isfile("/etc/debian_version"):
log.error("Requires a Debian-based system (Debian/Ubuntu).") log.error("Requires a Debian-based system (Debian/Ubuntu).")
raise SystemExit(1) raise SystemExit(1)
needed = [p for p in ["curl", "wget", "git", "rsyslog"] if not shutil.which(p)] _check_helper_path(UFW_DOCKER_PATH)
if needed: _install_missing(s, BASE_PACKAGES)
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.")
s.ensure_running("rsyslog") s.ensure_running("rsyslog")
# Configure APT to discard downloaded archives # Configure APT to discard downloaded archives
@@ -61,29 +127,5 @@ def run(ctx, args):
s.sudo(["tee", journal_conf], input=journal_content) s.sudo(["tee", journal_conf], input=journal_content)
s.sudo(["systemctl", "restart", "systemd-journald"]) s.sudo(["systemctl", "restart", "systemd-journald"])
if not shutil.which("ufw"): _install_helper(ctx)
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"])
log.success("Base system initialization completed.") log.success("Base system initialization completed.")
+1 -1
View File
@@ -18,7 +18,7 @@ def run(ctx, args):
ZoneInfo(zone) ZoneInfo(zone)
except (ValueError, ZoneInfoNotFoundError): except (ValueError, ZoneInfoNotFoundError):
ctx.log.error("configs/timezone must name an installed IANA timezone.") 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() current = ctx.sys.run(["timedatectl", "show", "--property=Timezone", "--value"], capture=True).stdout.strip()
if current == zone: if current == zone:
ctx.log.info(f"Timezone already set to {zone}.") ctx.log.info(f"Timezone already set to {zone}.")
+66 -38
View File
@@ -1,54 +1,82 @@
# actions/env.py """Export synthesized APEX values or bridge a composition's environment for Compose."""
"""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.
"""
import os import os
import shlex import shlex
from engine.descriptor import Flag, Meta, Opt from engine.descriptor import Flag, Meta, Opt
METADATA = Meta( METADATA = Meta(
summary="Export or sync synthesized APEX_* variables for shell or docker compose.", summary="Export or sync synthesized APEX_* variables for shell or docker compose.",
args=[ args=[
Flag("--export", "Format as shell export statements (default)."), Flag("--export", "Format as shell export statements (default)."),
Flag("--dotenv", "Format as raw KEY=VAL lines without 'export'."), Flag("--dotenv", "Print quoted generated APEX_* dotenv values only."),
Opt("--write", "Write variables to the specified file path.", default=None), Opt("--write", "Write a standalone generated dotenv file (0600).", default=None),
Flag("--sync", "Sync variables into compositions/apex/.env for bare docker compose."), 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): def run(ctx, args):
log = ctx.log modes = (args.export, args.dotenv, args.write is not None, args.sync)
vars_dict = {k: v for k, v in ctx.vars().items() if k.startswith("APEX_")} if sum(bool(mode) for mode in modes) > 1:
ctx.log.error("Choose only one of --export, --dotenv, --write, or --sync.")
target_file = None 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: if args.sync:
target_file = os.path.join(ctx.paths.compositions, "apex", ".env") core = os.path.join(ctx.paths.compositions, "apex")
elif args.write: target = os.path.join(core, ".env")
target_file = args.write try:
generated = _sync_body(_read_optional(target),
if target_file: _read_optional(os.path.join(core, "apex.env")), generated)
content = [ except ValueError as error:
"# Generated by apex env. Do not edit directly.", ctx.log.error(str(error))
"# Contains synthesized node and path scalars for docker compose interpolation.", raise SystemExit(65) from error
] if target is not None:
for k in sorted(vars_dict.keys()): ctx.host.write_file(target, generated, mode="0600")
content.append(f"{k}={vars_dict[k]}") ctx.log.success(f"Wrote {len(variables)} synthesized variables to {target}.")
content.append("") elif args.dotenv:
body = "\n".join(content) print(generated, end="")
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]}")
else: else:
for k in sorted(vars_dict.keys()): for key, value in sorted(variables.items()):
print(f"export {k}={shlex.quote(str(vars_dict[k]))}") print(f"export {key}={shlex.quote(str(value))}")
return 0 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 except Exception as e: # bash ERR trap parity: unhandled failures page via Telegram
log.error(f"{type(e).__name__}: {e}") log.error(f"{type(e).__name__}: {e}")
ctx.notify.error(TITLE, url, f"Critical error: {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): def _check(ctx, args, log, node, url, s):
if not os.path.isfile("/etc/debian_version"): if not os.path.isfile("/etc/debian_version"):
+1 -1
View File
@@ -61,4 +61,4 @@ def run(ctx, args):
except Exception as e: except Exception as e:
log.error(f"{type(e).__name__}: {e}") log.error(f"{type(e).__name__}: {e}")
ctx.notify.error(TITLE, url, f"Sync failed: {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}") ctx.notify.success(TITLE, url, f"Certificates extracted for {node}")
except Exception as e: except Exception as e:
log.error(str(e)); ctx.notify.error(TITLE, url, f"cert extraction failed: {e}") log.error(str(e)); ctx.notify.error(TITLE, url, f"cert extraction failed: {e}")
raise SystemExit(1) raise SystemExit(1) from e
+2 -2
View File
@@ -1,7 +1,7 @@
# actions/utils/lint-docker-compose.py # actions/utils/lint-docker-compose.py
"""Port of utils/lint-docker-compose (dclint via docker) + stdlib anchor-IP validation: """Run dclint through Docker and validate anchor IPs:
every ipv4_address in compositions/*/docker-compose.yml must be unique and inside the every ipv4_address in compositions/*/docker-compose.yml must be unique and inside the
node's /24 (SP3 §6.2). No new deps — ipaddress + re are stdlib.""" node's /24."""
import ipaddress, os, re, shutil import ipaddress, os, re, shutil
from engine.descriptor import Meta, Flag from engine.descriptor import Meta, Flag
+3 -3
View File
@@ -61,7 +61,7 @@ build {
sources = ["source.qemu.apex_debian"] sources = ["source.qemu.apex_debian"]
provisioner "file" { provisioner "file" {
source = "engine" source = "ci/scripts/init-node/provisioning"
destination = "/tmp/" destination = "/tmp/"
} }
@@ -69,8 +69,8 @@ build {
execute_command = "sudo -n sh -c '{{ .Vars }} {{ .Path }}'" execute_command = "sudo -n sh -c '{{ .Vars }} {{ .Path }}'"
environment_vars = ["APEX_PROVISION_REVISION=${var.provision_revision}"] environment_vars = ["APEX_PROVISION_REVISION=${var.provision_revision}"]
inline = [ inline = [
"bash /tmp/engine/provisioning/seed.sh", "bash /tmp/provisioning/seed.sh",
"cd /tmp && python3 -m engine.provisioning.image install" "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") _prov_dir = os.path.join(_here, "init-node")
sys.path.insert(0, _prov_dir) 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): 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']) self.command(['ufw', '--force', 'delete', 'allow', '2222/tcp', 'comment', 'apex-init-access'])
def arm(self): 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)): 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') 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 = ('[Unit]\nDescription=APEX pending access rollback\nAfter=local-fs.target\n'
'[Service]\nType=oneshot\nWorkingDirectory=' + str(bundle) + '\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 = ('[Unit]\nDescription=APEX access rollback deadline and boot recovery\n'
'[Timer]\nOnBootSec=5s\nOnUnitActiveSec=10s\nAccuracySec=1s\n' '[Timer]\nOnBootSec=5s\nOnUnitActiveSec=10s\nAccuracySec=1s\n'
'[Install]\nWantedBy=timers.target\n') '[Install]\nWantedBy=timers.target\n')
@@ -325,7 +325,8 @@ def configure_sources():
key = Path(f"/etc/apt/keyrings/apex-{name}.asc") key = Path(f"/etc/apt/keyrings/apex-{name}.asc")
if not key.exists(): if not key.exists():
try: 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() data = response.read()
except OSError as exc: except OSError as exc:
raise BaselineError(f"Cannot download {name} repository signing key.") from 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(): def install():
from .transport import build_bundle from .transport import _bundle_from_source
source = Path(__file__).resolve().parents[2] source = Path(__file__).resolve().parents[1]
data, digest, _ = build_bundle(source) data, digest = _bundle_from_source(source)
destination = safe_path(Path('/usr/local/lib/apex-provisioner') / digest) destination = safe_path(Path('/usr/local/lib/apex-provisioner') / digest)
destination.mkdir(mode=0o755, parents=True, exist_ok=False) destination.mkdir(mode=0o755, parents=True, exist_ok=False)
with tarfile.open(fileobj=io.BytesIO(data)) as archive: 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') atomic_write('/etc/apex/provisioner.json', json.dumps(metadata) + '\n')
for executable, operation in (('apex-provision-image', ''), ('apex-bootstrap', 'cloud-enroll ')): for executable, operation in (('apex-provision-image', ''), ('apex-bootstrap', 'cloud-enroll ')):
content = ('#!/bin/sh\nset -eu\ncd ' + str(destination) + '\n' 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) atomic_write('/usr/local/bin/' + executable, content, 0o755)
return metadata return metadata
@@ -84,7 +84,7 @@ def sanitize_files(root=Path('/')):
dbus.unlink() dbus.unlink()
dbus.parent.mkdir(parents=True, exist_ok=True) dbus.parent.mkdir(parents=True, exist_ok=True)
dbus.symlink_to('/etc/machine-id') 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 path = root / name
if path.is_symlink(): if path.is_symlink():
path.unlink() path.unlink()
@@ -45,7 +45,7 @@ def _runtime_rule(line):
if len(tokens) % 2: if len(tokens) % 2:
raise SecurityError('Unsupported runtime firewall rule') raise SecurityError('Unsupported runtime firewall rule')
pairs = [] 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'): if key in ('-s', '-d'):
value = str(ipaddress.ip_network(value, strict=False)) value = str(ipaddress.ip_network(value, strict=False))
elif key == '--ctstate': elif key == '--ctstate':
@@ -77,7 +77,7 @@ class Security:
if not active: if not active:
output = self.host.command(['dpkg-query', '-W', '-f=${Conffiles}', 'ufw']).stdout 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)) 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')) == conffiles.get('/etc/default/ufw'))
manifest = dict((name, digest) for digest, name in re.findall( 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)) 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] filename = name.rsplit('/', 1)[1]
template = '/usr/share/ufw/' + ('' if filename == 'ufw.conf' else 'iptables/') + filename template = '/usr/share/ufw/' + ('' if filename == 'ufw.conf' else 'iptables/') + filename
data = self._path(template).read_bytes() 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'])) and data == base64.b64decode(files[name]['data']))
if not pristine: if not pristine:
raise SecurityError('Inactive UFW is not provably pristine; reconcile its existing policy first') 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}', output = self.host.command(['dpkg-query', '-W', '-f=${Conffiles}',
'crowdsec-firewall-bouncer-iptables']).stdout 'crowdsec-firewall-bouncer-iptables']).stdout
conffiles = dict(re.findall(r'^\s*(/\S+)\s+([a-f0-9]{32})(?:\s|$)', output, re.M)) 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): def _path(self, name):
path = self.host.path(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 return match[1], hostname, int(port) if port is not None else None
def build_bundle(commons): def _bundle_from_source(root):
root = Path(commons) root = Path(root).resolve()
files = sorted((root / 'engine').rglob('*.py')) package = root / 'provisioning'
seed = root / 'engine/provisioning/seed.sh' files = sorted(package.rglob('*.py'))
if not seed.is_file(): seed = package / 'seed.sh'
raise TransportError('Provisioning seed is missing') if not seed.is_file() or not (package / '__init__.py').is_file():
raise TransportError('Provisioning package or seed is missing')
files.append(seed) files.append(seed)
output = io.BytesIO() output = io.BytesIO()
with tarfile.open(fileobj=output, mode='w') as archive: with tarfile.open(fileobj=output, mode='w') as archive:
for path in files: 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') raise TransportError('Provisioning bundle contains an unsafe source path')
data = path.read_bytes() data = path.read_bytes()
info = tarfile.TarInfo(path.relative_to(root).as_posix()) info = tarfile.TarInfo(path.relative_to(root).as_posix())
@@ -147,15 +149,21 @@ def build_bundle(commons):
info.uid = info.gid = 0 info.uid = info.gid = 0
archive.addfile(info, io.BytesIO(data)) archive.addfile(info, io.BytesIO(data))
data = output.getvalue() 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' revision = 'unversioned-modified'
if shutil.which('git'): if shutil.which('git'):
result = subprocess.run(['git', '-C', str(root), 'rev-parse', 'HEAD'], capture_output=True, text=True, timeout=30) 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): if result.returncode == 0 and re.fullmatch(r'[a-f0-9]{40,64}\n?', result.stdout):
revision = result.stdout.strip() 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(): if dirty.returncode or dirty.stdout.strip():
revision += '-modified' revision += '-modified'
return data, hashlib.sha256(data).hexdigest(), revision return data, digest, revision
class SSH: class SSH:
@@ -240,7 +248,7 @@ def protected_handover(ssh, invoke, token, port, client_address):
except Exception: except Exception:
try: try:
invoke('rollback', token, None, None, False) 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. pass # Persistent timer is authoritative if both connections are gone.
raise raise
@@ -297,7 +305,7 @@ def initialize(commons, target, user_data, port=None, identity_file=None, progre
if active.returncode == 0: if active.returncode == 0:
raise TransportError('An enrollment unit is still running: ' + previous_unit + '; wait for it to settle before resuming') 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() 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') raise TransportError('Target returned an invalid staging path')
# Hard reboot can bypass the shell trap; boot-time tmpfiles removes opaque inputs. # Hard reboot can bypass the shell trap; boot-time tmpfiles removes opaque inputs.
cleanup = ('r ' + stage + '/user-data - - - -\n' + 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) install = remote_bundle_install(stage, installed, digest)
progress('Installing the versioned target runtime and validating enrollment input.') progress('Installing the versioned target runtime and validating enrollment input.')
ssh.run(install) 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] options = ['--user-data', stage + '/user-data', '--revision', revision, '--bundle-digest', digest]
# State remains inspectable after disconnect; user-data is removed by the durable unit. # State remains inspectable after disconnect; user-data is removed by the durable unit.
enrollment = shlex.join(base + ['enroll'] + options + ['--heartbeat', stage + '/heartbeat']) enrollment = shlex.join(base + ['enroll'] + options + ['--heartbeat', stage + '/heartbeat'])
validation = shlex.join(base + ['validate'] + options) validation = shlex.join(base + ['validate'] + options)
access_preflight = shlex.join(['python3', '-m', 'engine.provisioning.access', 'preflight']) access_preflight = shlex.join(['python3', '-m', 'provisioning.access', 'preflight'])
seed = shlex.join(['bash', installed + '/engine/provisioning/seed.sh']) seed = shlex.join(['bash', installed + '/provisioning/seed.sh'])
wrapper = ('umask 077; cd ' + installed + '; ' wrapper = ('umask 077; cd ' + installed + '; '
'trap ' + shlex.quote('rm -f ' + stage + '/user-data ' + stage + '/heartbeat ' + cleanup_policy) + ' EXIT; ' 'trap ' + shlex.quote('rm -f ' + stage + '/user-data ' + stage + '/heartbeat ' + cleanup_policy) + ' EXIT; '
'{ ' + seed + ' && ' + validation + ' && ' + access_preflight + ' && ' + enrollment + '; } >' + stage + '/runner.log 2>&1; rc=$?; ' '{ ' + 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; ' 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) 'cat /var/lib/apex-init/access/state.json', fresh=True)
return json.loads(result.stdout) 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: if original_port:
args += ['--original-port', str(original_port), '--client-address', address] args += ['--original-port', str(original_port), '--client-address', address]
result = ssh.run('cd ' + installed + ' && ' + shlex.join(args), fresh=fresh) 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: try:
ssh.run('rm -f ' + stage + '/user-data ' + stage + '/heartbeat ' + stage + '/bundle.tar ' + cleanup_policy, fresh=fresh) ssh.run('rm -f ' + stage + '/user-data ' + stage + '/heartbeat ' + stage + '/bundle.tar ' + cleanup_policy, fresh=fresh)
break break
except Exception: except Exception: # noqa: S110 — cleanup tries both verified endpoints.
pass pass
if launched and not finished: if launched and not finished:
progress('Coordinator stopped. The current target stage may finish; later stages stop after heartbeat expiry.') 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') 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)] 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: 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: if ssh.identity:
resume += ' --identity-file ' + shlex.quote(str(ssh.identity)) resume += ['--identity-file', str(ssh.identity)]
progress(label + ': ' + resume) progress(label + ': ' + shlex.join(resume))
@@ -23,7 +23,8 @@ def decode_user_data(content):
if len(content) > 1024 * 1024: if len(content) > 1024 * 1024:
raise ContractError('user-data exceeds the supported size') raise ContractError('user-data exceeds the supported size')
try: 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): except (yaml.YAMLError, UnicodeError, RecursionError):
raise ContractError('invalid or unsupported YAML user-data') from None raise ContractError('invalid or unsupported YAML user-data') from None
return normalize_user_data(document) return normalize_user_data(document)
@@ -1,4 +1,4 @@
from engine.provisioning.security import SecurityError from provisioning.security import SecurityError
class SecurityBoundary: class SecurityBoundary:
@@ -7,7 +7,7 @@ import tempfile
import unittest import unittest
from unittest.mock import patch 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 from provision_fakes import SecurityBoundary
@@ -122,7 +122,7 @@ class AccessTests(unittest.TestCase):
self.assertEqual(self.host.restored, 1) self.assertEqual(self.host.restored, 1)
def test_watchdog_restores_access_while_enrollment_lock_is_held(self): 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.begin()
self.clock += 301 self.clock += 301
with exclusive_lock(self.host.path('/var/lib/apex-init/init.lock')): 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) return self.runner(args, **kwargs)
self.host.runner = unseeded self.host.runner = unseeded
before = sorted(self.root.rglob('*')) before = sorted(self.root.rglob('*'))
with patch('engine.provisioning.access.Host', return_value=self.host), \ with patch('provisioning.access.Host', return_value=self.host), \
patch('engine.provisioning.access.os.geteuid', return_value=0): patch('provisioning.access.os.geteuid', return_value=0):
self.assertEqual(main(['preflight']), 0) self.assertEqual(main(['preflight']), 0)
self.assertEqual(sorted(self.root.rglob('*')), before) self.assertEqual(sorted(self.root.rglob('*')), before)
self.assertIn(['/usr/sbin/sshd', '-t'], self.calls) self.assertIn(['/usr/sbin/sshd', '-t'], self.calls)
@@ -309,7 +309,7 @@ class LivePolicyTests(unittest.TestCase):
self.cmdline() self.cmdline()
for failure in ('socket', 'invocation'): for failure in ('socket', 'invocation'):
with self.subTest(failure=failure): 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']: if failure == 'socket' and args == ['systemctl', 'is-enabled', '--quiet', 'ssh.socket']:
return subprocess.CompletedProcess(args, 0, '', '') return subprocess.CompletedProcess(args, 0, '', '')
return self.runner(args, **kwargs) return self.runner(args, **kwargs)
@@ -2,14 +2,13 @@
import hashlib import hashlib
import importlib.util import importlib.util
import io import io
import json
from pathlib import Path from pathlib import Path
import subprocess import subprocess
import tempfile import tempfile
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from engine.provisioning import baseline from provisioning import baseline
class Host: class Host:
@@ -1,13 +1,13 @@
import copy import copy
import unittest 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(): def example():
return { return {
'preserve_hostname': False, 'preserve_hostname': False,
'hostname': 'metis.a1.example.com', 'hostname': 'node1.a1.example.com',
'users': [{ 'users': [{
'name': 'adam', 'name': 'adam',
'sudo': 'ALL=(ALL) NOPASSWD:ALL', 'sudo': 'ALL=(ALL) NOPASSWD:ALL',
@@ -19,8 +19,8 @@ def example():
'path': '/etc/apex/bootstrap.env', 'path': '/etc/apex/bootstrap.env',
'owner': 'root:root', 'owner': 'root:root',
'permissions': '0600', 'permissions': '0600',
'content': 'APEX_REPO_NAME="metis.example.com"\n' 'content': 'APEX_REPO_NAME="node1.example.com"\n'
'APEX_REPO_URL="https://example.com/team/metis.git"\n', 'APEX_REPO_URL="https://example.com/team/node1.git"\n',
}], }],
'runcmd': ['/usr/local/bin/apex-bootstrap'], 'runcmd': ['/usr/local/bin/apex-bootstrap'],
} }
@@ -34,7 +34,7 @@ def add_env(doc, text):
class TestBootstrapAssignments(unittest.TestCase): class TestBootstrapAssignments(unittest.TestCase):
def test_literal_quotes_comments_and_empty_values(self): def test_literal_quotes_comments_and_empty_values(self):
values = parse_bootstrap_env( values = parse_bootstrap_env(
'# enrollment\nAPEX_REPO_NAME=metis.example.com\n' '# enrollment\nAPEX_REPO_NAME=node1.example.com\n'
"APEX_REPO_REF='release-1'\nAPEX_TIER1_DEVICE=\n") "APEX_REPO_REF='release-1'\nAPEX_TIER1_DEVICE=\n")
self.assertEqual(values['APEX_REPO_REF'], 'release-1') self.assertEqual(values['APEX_REPO_REF'], 'release-1')
self.assertEqual(values['APEX_TIER1_DEVICE'], '') self.assertEqual(values['APEX_TIER1_DEVICE'], '')
@@ -76,8 +76,8 @@ class TestBootstrapAssignments(unittest.TestCase):
class TestUserDataContract(unittest.TestCase): class TestUserDataContract(unittest.TestCase):
def test_public_repository_and_directory_defaults(self): def test_public_repository_and_directory_defaults(self):
spec = normalize_user_data(example()) spec = normalize_user_data(example())
self.assertEqual(spec.hostname, 'metis.a1.example.com') self.assertEqual(spec.hostname, 'node1.a1.example.com')
self.assertEqual(spec.repository.name, 'metis.example.com') self.assertEqual(spec.repository.name, 'node1.example.com')
self.assertIsNone(spec.repository.ref) self.assertIsNone(spec.repository.ref)
self.assertEqual([tier.mode for tier in spec.tiers], ['directory'] * 3) self.assertEqual([tier.mode for tier in spec.tiers], ['directory'] * 3)
self.assertEqual(spec.tiers[0].number, 1) self.assertEqual(spec.tiers[0].number, 1)
@@ -173,9 +173,9 @@ class TestUserDataContract(unittest.TestCase):
def test_repository_paths_and_embedded_passwords(self): def test_repository_paths_and_embedded_passwords(self):
for name, url in [ for name, url in [
('../escape', 'https://example.com/repo.git'), ('../escape', 'https://example.com/repo.git'),
('metis', 'https://user:SECRET_SENTINEL@example.com/repo.git'), ('node1', 'https://user:SECRET_SENTINEL@example.com/repo.git'),
('metis', 'file:///etc/shadow'), ('node1', 'file:///etc/shadow'),
('metis', '-uSECRET_SENTINEL'), ('node1', '-uSECRET_SENTINEL'),
]: ]:
doc = example() doc = example()
doc['write_files'][0]['content'] = ( doc['write_files'][0]['content'] = (
@@ -1,17 +1,90 @@
import io
import os
from pathlib import Path from pathlib import Path
import shlex
import subprocess
import sys
import tarfile
import tempfile import tempfile
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from engine.provisioning.image import cloud_enroll, sanitize_files from provisioning import access, image
from engine.provisioning.state import ProvisionError 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): 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): def test_incompatible_image_stops_before_any_enrollment(self):
for metadata in ('{}', '{"enrollment_interface":999}'): for metadata in ('{}', '{"enrollment_interface":999}'):
with self.subTest(metadata=metadata), patch.object(Path, 'read_text', return_value=metadata), \ 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'): with self.assertRaisesRegex(ProvisionError, 'image enrollment interface'):
cloud_enroll() cloud_enroll()
enroll.assert_not_called() enroll.assert_not_called()
@@ -23,7 +96,8 @@ class ImageTests(unittest.TestCase):
'root/.ssh/authorized_keys', 'home/debian/.ssh/authorized_keys', 'root/.ssh/authorized_keys', 'home/debian/.ssh/authorized_keys',
'etc/machine-id', 'var/lib/dbus/machine-id', 'etc/machine-id', 'var/lib/dbus/machine-id',
'var/lib/apex-init/enrollment.json', 'var/lib/cloud/instance/user-data.txt', '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 = root / relative
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_text('build-specific') path.write_text('build-specific')
@@ -33,5 +107,6 @@ class ImageTests(unittest.TestCase):
self.assertFalse((root / 'root/.ssh/authorized_keys').exists()) self.assertFalse((root / 'root/.ssh/authorized_keys').exists())
self.assertFalse((root / 'var/lib/apex-init').exists()) self.assertFalse((root / 'var/lib/apex-init').exists())
self.assertFalse((root / 'var/lib/cloud').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()) self.assertTrue((root / 'etc/systemd/system/ssh.service.d/10-generate-host-keys.conf').is_file())
@@ -6,9 +6,9 @@ import tempfile
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from engine.provisioning.contract import Enrollment, Repository from provisioning.contract import Enrollment, Repository
from engine.provisioning.repository import reconcile_repository from provisioning.repository import reconcile_repository
from engine.provisioning.state import ProvisionError from provisioning.state import ProvisionError
class RepositoryTests(unittest.TestCase): class RepositoryTests(unittest.TestCase):
@@ -32,7 +32,7 @@ class RepositoryTests(unittest.TestCase):
(self.commons / 'etc/.vimrc').write_text('set number\n') (self.commons / 'etc/.vimrc').write_text('set number\n')
self.git(['add', '.'], self.commons) self.git(['add', '.'], self.commons)
self.git(['commit', '-qm', 'initial'], self.commons) self.git(['commit', '-qm', 'initial'], self.commons)
(self.upstream / 'node.env').write_text('APEX_NODE_FQDN=metis.example.com\nAPEX_ENROLLMENT_INTERFACE=1\n') (self.upstream / 'node.env').write_text('APEX_NODE_FQDN=node1.example.com\nAPEX_ENROLLMENT_INTERFACE=1\n')
(self.upstream / 'init.sh').write_text( (self.upstream / 'init.sh').write_text(
'#!/bin/sh\nset -eu\ngit config core.hooksPath commons/githooks\n' '#!/bin/sh\nset -eu\ngit config core.hooksPath commons/githooks\n'
'printf \'export PATH="%s/commons:$PATH"\\n\' "$PWD" > "$HOME/.bashrc"\n' 'printf \'export PATH="%s/commons:$PATH"\\n\' "$PWD" > "$HOME/.bashrc"\n'
@@ -40,14 +40,14 @@ class RepositoryTests(unittest.TestCase):
self.git(['-c', 'protocol.file.allow=always', 'submodule', 'add', str(self.commons), 'commons'], self.upstream) self.git(['-c', 'protocol.file.allow=always', 'submodule', 'add', str(self.commons), 'commons'], self.upstream)
self.git(['add', '.'], self.upstream) self.git(['add', '.'], self.upstream)
self.git(['commit', '-qm', 'initial'], self.upstream) self.git(['commit', '-qm', 'initial'], self.upstream)
self.spec = Enrollment('metis.example.com', Repository('metis', str(self.upstream), None), (), (), ()) self.spec = Enrollment('node1.example.com', Repository('node1', str(self.upstream), None), (), (), ())
self.state = {} self.state = {}
self.saved = [] 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))) return_value=SimpleNamespace(pw_uid=os.getuid(), pw_gid=os.getgid(), pw_dir=str(self.home)))
self.account.start() self.account.start()
self.addCleanup(self.account.stop) 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.runner.start()
self.addCleanup(self.runner.stop) self.addCleanup(self.runner.stop)
@@ -71,19 +71,19 @@ class RepositoryTests(unittest.TestCase):
self.git(['commit', '-qm', 'advance'], self.upstream) self.git(['commit', '-qm', 'advance'], self.upstream)
second = self.enroll() second = self.enroll()
self.assertEqual(first['commit'], second['commit']) self.assertEqual(first['commit'], second['commit'])
self.assertFalse((self.srv / 'metis' / 'new').exists()) self.assertFalse((self.srv / 'node1' / 'new').exists())
self.assertTrue(self.state['repository']['hook_complete']) self.assertTrue(self.state['repository']['hook_complete'])
def test_local_changes_are_preserved_and_stop_initialization(self): def test_local_changes_are_preserved_and_stop_initialization(self):
self.enroll() self.enroll()
path = self.srv / 'metis' / 'node.env' path = self.srv / 'node1' / 'node.env'
path.write_text('local changes') path.write_text('local changes')
with self.assertRaisesRegex(ProvisionError, 'local changes'): with self.assertRaisesRegex(ProvisionError, 'local changes'):
self.enroll() self.enroll()
self.assertEqual(path.read_text(), 'local changes') self.assertEqual(path.read_text(), 'local changes')
def test_unrecorded_checkout_is_not_adopted(self): def test_unrecorded_checkout_is_not_adopted(self):
(self.srv / 'metis').mkdir() (self.srv / 'node1').mkdir()
with self.assertRaisesRegex(ProvisionError, 'adoption'): with self.assertRaisesRegex(ProvisionError, 'adoption'):
self.enroll() self.enroll()
@@ -98,10 +98,10 @@ class RepositoryTests(unittest.TestCase):
def test_missing_or_unsupported_interface_stops_before_hook(self): def test_missing_or_unsupported_interface_stops_before_hook(self):
for version in ('', 'APEX_ENROLLMENT_INTERFACE=999\n'): for version in ('', 'APEX_ENROLLMENT_INTERFACE=999\n'):
with self.subTest(version=version): with self.subTest(version=version):
(self.upstream / 'node.env').write_text('APEX_NODE_FQDN=metis.example.com\n' + version) (self.upstream / 'node.env').write_text('APEX_NODE_FQDN=node1.example.com\n' + version)
self.git(['add', '.'], self.upstream) self.git(['add', '.'], self.upstream)
self.git(['commit', '-qm', 'unsupported interface'], self.upstream) self.git(['commit', '-qm', 'unsupported interface'], self.upstream)
self.spec = Enrollment('metis.example.com', Repository('missing-' + str(len(version)), str(self.upstream), None), (), (), ()) self.spec = Enrollment('node1.example.com', Repository('missing-' + str(len(version)), str(self.upstream), None), (), (), ())
self.state = {} self.state = {}
with self.assertRaisesRegex(ProvisionError, 'enrollment interface'): with self.assertRaisesRegex(ProvisionError, 'enrollment interface'):
self.enroll() self.enroll()
@@ -110,7 +110,7 @@ class RepositoryTests(unittest.TestCase):
def test_retry_checks_hook_state_instead_of_completion_marker(self): def test_retry_checks_hook_state_instead_of_completion_marker(self):
self.enroll() self.enroll()
self.git(['config', 'core.hooksPath', 'wrong-path'], self.srv / 'metis') self.git(['config', 'core.hooksPath', 'wrong-path'], self.srv / 'node1')
with self.assertRaisesRegex(ProvisionError, 'hook configuration'): with self.assertRaisesRegex(ProvisionError, 'hook configuration'):
self.enroll() self.enroll()
@@ -10,8 +10,8 @@ import tempfile
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from engine.provisioning.access import Host from provisioning.access import Host
from engine.provisioning import security from provisioning import security
# Pinned helper default output, kept independent of the adapter's validation code. # Pinned helper default output, kept independent of the adapter's validation code.
@@ -97,7 +97,7 @@ class SecurityHost(Host):
path = self.path(name) path = self.path(name)
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content) 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 = {} self.bouncer_conffiles = {}
checksums = [] checksums = []
for name, content in contents.items(): for name, content in contents.items():
@@ -108,7 +108,7 @@ class SecurityHost(Host):
path = self.path(template) path = self.path(template)
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content) 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 = self.path('/var/lib/dpkg/info/ufw.md5sums')
manifest.parent.mkdir(parents=True, exist_ok=True) manifest.parent.mkdir(parents=True, exist_ok=True)
manifest.write_text('\n'.join(checksums) + '\n') manifest.write_text('\n'.join(checksums) + '\n')
@@ -275,7 +275,7 @@ class SecurityTests(unittest.TestCase):
path = self.host.path(name) path = self.host.path(name)
path.parent.mkdir(parents=True) path.parent.mkdir(parents=True)
path.write_text('[Service]\nExecStart=/usr/bin/crowdsec-firewall-bouncer\n') 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 self.host.services['crowdsec-firewall-bouncer']['FragmentPath'] = name
state = self.prepared() state = self.prepared()
self.adapter.activate(state, self.persist) 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): def test_disabled_ipv6_runtime_is_preserved_during_helper_cleanup(self):
path = self.host.path('/etc/default/ufw') path = self.host.path('/etc/default/ufw')
path.write_text(path.read_text().replace('IPV6=yes', 'IPV6=no')) 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) before = copy.deepcopy(self.host.runtime_chains)
state = self.prepared() state = self.prepared()
self.adapter.activate(state, self.persist) self.adapter.activate(state, self.persist)
@@ -828,7 +828,7 @@ class SecurityTests(unittest.TestCase):
def test_ipv6_peer_requires_ipv6_firewall_support(self): def test_ipv6_peer_requires_ipv6_firewall_support(self):
path = self.host.path('/etc/default/ufw') path = self.host.path('/etc/default/ufw')
path.write_text(path.read_text().replace('IPV6=yes', 'IPV6=no')) 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): with self.assertRaises(security.SecurityError):
self.adapter.snapshot(self.adapter.preflight(), 22, '2001:db8::1') self.adapter.snapshot(self.adapter.preflight(), 22, '2001:db8::1')
@@ -2,8 +2,8 @@ import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from engine.provisioning.access import AccessError, Transition from provisioning.access import AccessError, Transition
from engine.provisioning.security import SecurityError from provisioning.security import SecurityError
from test_provision_access import FakeHost, TOKEN from test_provision_access import FakeHost, TOKEN
@@ -4,7 +4,7 @@ from pathlib import Path
import tempfile import tempfile
import unittest import unittest
from engine.provisioning.state import (Journal, ProvisionError, atomic_write, from provisioning.state import (Journal, ProvisionError, atomic_write,
check_heartbeat, exclusive_lock) check_heartbeat, exclusive_lock)
@@ -37,12 +37,12 @@ class StateTests(unittest.TestCase):
def test_failed_stage_is_retryable_and_success_is_reverified(self): def test_failed_stage_is_retryable_and_success_is_reverified(self):
path = self.root / 'journal' path = self.root / 'journal'
record = Journal(path, {'hostname': 'metis'}, 'rev', 'digest') record = Journal(path, {'hostname': 'node1'}, 'rev', 'digest')
with self.assertRaises(RuntimeError): with self.assertRaises(RuntimeError):
record.stage('package', lambda: (_ for _ in ()).throw(RuntimeError('SECRET'))) record.stage('package', lambda: (_ for _ in ()).throw(RuntimeError('SECRET')))
self.assertNotIn('SECRET', path.read_text()) self.assertNotIn('SECRET', path.read_text())
self.assertEqual(json.loads(path.read_text())['failed_stage'], 'package') self.assertEqual(json.loads(path.read_text())['failed_stage'], 'package')
record = Journal(path, {'hostname': 'metis'}, 'rev', 'digest') record = Journal(path, {'hostname': 'node1'}, 'rev', 'digest')
results = iter(({'version': 'one'}, {'version': 'two'})) results = iter(({'version': 'one'}, {'version': 'two'}))
record.stage('package', lambda: next(results)) record.stage('package', lambda: next(results))
record.stage('package', lambda: next(results)) record.stage('package', lambda: next(results))
@@ -50,21 +50,21 @@ class StateTests(unittest.TestCase):
def test_identity_or_bundle_change_cannot_retarget_partial_run(self): def test_identity_or_bundle_change_cannot_retarget_partial_run(self):
path = self.root / 'journal' path = self.root / 'journal'
Journal(path, {'hostname': 'metis'}, 'rev', 'digest').save() Journal(path, {'hostname': 'node1'}, 'rev', 'digest').save()
for identity, digest in (({'hostname': 'other'}, 'digest'), ({'hostname': 'metis'}, 'other')): for identity, digest in (({'hostname': 'other'}, 'digest'), ({'hostname': 'node1'}, 'other')):
with self.assertRaises(ProvisionError): with self.assertRaises(ProvisionError):
Journal(path, identity, 'rev', digest) Journal(path, identity, 'rev', digest)
def test_unsupported_interface_cannot_resume_and_revisions_survive_retry(self): def test_unsupported_interface_cannot_resume_and_revisions_survive_retry(self):
path = self.root / 'journal' path = self.root / 'journal'
Journal(path, {'hostname': 'metis'}, 'source-rev', 'digest').save() Journal(path, {'hostname': 'node1'}, 'source-rev', 'digest').save()
resumed = Journal(path, {'hostname': 'metis'}, 'source-rev', 'digest') resumed = Journal(path, {'hostname': 'node1'}, 'source-rev', 'digest')
self.assertEqual(resumed.data['baseline_revision'], 'source-rev') self.assertEqual(resumed.data['baseline_revision'], 'source-rev')
for version in (None, 999): for version in (None, 999):
resumed.data['enrollment_interface'] = version resumed.data['enrollment_interface'] = version
resumed.save() resumed.save()
with self.assertRaisesRegex(ProvisionError, 'enrollment interface'): with self.assertRaisesRegex(ProvisionError, 'enrollment interface'):
Journal(path, {'hostname': 'metis'}, 'source-rev', 'digest') Journal(path, {'hostname': 'node1'}, 'source-rev', 'digest')
def test_expired_heartbeat_prevents_next_operation(self): def test_expired_heartbeat_prevents_next_operation(self):
beat = self.root / 'heartbeat' beat = self.root / 'heartbeat'
@@ -8,9 +8,9 @@ from types import SimpleNamespace
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from engine.provisioning.contract import Enrollment, Repository, Tier from provisioning.contract import Enrollment, Repository, Tier
from engine.provisioning import storage from provisioning import storage
from engine.provisioning.storage import StorageError, _persist_swap, reconcile_storage from provisioning.storage import StorageError, _persist_swap, reconcile_storage
def enrollment(*tiers): def enrollment(*tiers):
@@ -49,13 +49,13 @@ class TestStorage(unittest.TestCase):
self.create_hook = None self.create_hook = None
for name, value in [('SRV', self.srv), ('SWAPFILE', self.swap), for name, value in [('SRV', self.srv), ('SWAPFILE', self.swap),
('FSTAB', self.fstab), ('MEMINFO', self.meminfo), ('SWAPS', self.swaps)]: ('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() patcher.start()
self.addCleanup(patcher.stop) 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() patcher.start()
self.addCleanup(patcher.stop) 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() patcher.start()
self.addCleanup(patcher.stop) self.addCleanup(patcher.stop)
@@ -365,7 +365,7 @@ class TestStorage(unittest.TestCase):
def test_swap_skip_reports_low_space_and_unsupported_filesystems(self): def test_swap_skip_reports_low_space_and_unsupported_filesystems(self):
self.set_active_swaps([]) 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) result = reconcile_storage(enrollment(), self.state, self.save)
self.assertIn('insufficient', result['swap']['reason']) self.assertIn('insufficient', result['swap']['reason'])
self.mounts[0]['fstype'] = 'btrfs' self.mounts[0]['fstype'] = 'btrfs'
@@ -375,9 +375,9 @@ class TestStorage(unittest.TestCase):
def test_swap_sizes_allocation_and_records_verified_result(self): def test_swap_sizes_allocation_and_records_verified_result(self):
self.set_active_swaps([]) self.set_active_swaps([])
with patch('engine.provisioning.storage.os.statvfs', return_value=SimpleNamespace(f_bavail=16, f_frsize=1024 ** 3)), \ with patch('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('provisioning.storage.os.posix_fallocate', create=True) as allocate, \
patch('engine.provisioning.storage._persist_swap') as persist: patch('provisioning.storage._persist_swap') as persist:
result = reconcile_storage(enrollment(), self.state, self.save) result = reconcile_storage(enrollment(), self.state, self.save)
self.assertEqual(allocate.call_args.args[1:], (0, 4 * 1024 ** 3)) self.assertEqual(allocate.call_args.args[1:], (0, 4 * 1024 ** 3))
self.assertEqual(result['swap'], {'status': 'complete', 'size': 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): def test_unobserved_swap_activation_is_not_persisted_or_reported_complete(self):
self.set_active_swaps([]) self.set_active_swaps([])
self.activate_swap = False self.activate_swap = False
with patch('engine.provisioning.storage.os.statvfs', return_value=SimpleNamespace(f_bavail=16, f_frsize=1024 ** 3)), \ with patch('provisioning.storage.os.statvfs', return_value=SimpleNamespace(f_bavail=16, f_frsize=1024 ** 3)), \
patch('engine.provisioning.storage.os.posix_fallocate', create=True), \ patch('provisioning.storage.os.posix_fallocate', create=True), \
patch('engine.provisioning.storage._persist_swap') as persist: patch('provisioning.storage._persist_swap') as persist:
result = reconcile_storage(enrollment(), self.state, self.save) result = reconcile_storage(enrollment(), self.state, self.save)
self.assertEqual(result['swap']['status'], 'skipped') self.assertEqual(result['swap']['status'], 'skipped')
self.assertIn('activation did not become observable', result['swap']['reason']) 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): def test_small_memory_swap_is_two_gib(self):
self.set_active_swaps([]) self.set_active_swaps([])
self.meminfo.write_text('MemTotal: 4194304 kB\n') 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)), \ with patch('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('provisioning.storage.os.posix_fallocate', create=True) as allocate, \
patch('engine.provisioning.storage._persist_swap'): patch('provisioning.storage._persist_swap'):
result = reconcile_storage(enrollment(), self.state, self.save) result = reconcile_storage(enrollment(), self.state, self.save)
self.assertEqual(allocate.call_args.args[1:], (0, 2 * 1024 ** 3)) self.assertEqual(allocate.call_args.args[1:], (0, 2 * 1024 ** 3))
self.assertEqual(result['swap']['size'], 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 os.stat_result(fields)
return info return info
with patch('engine.provisioning.storage.Path.lstat', root_owned), \ with patch('provisioning.storage.Path.lstat', root_owned), \
patch('engine.provisioning.storage.os.fchown'): patch('provisioning.storage.os.fchown'):
_persist_swap() _persist_swap()
_persist_swap() _persist_swap()
self.assertEqual(self.fstab.read_text(), '# existing entries\n' + self.assertEqual(self.fstab.read_text(), '# existing entries\n' +
@@ -8,10 +8,10 @@ import tempfile
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from engine.provisioning import target from provisioning import target
from engine.provisioning.contract import normalize_user_data from provisioning.contract import normalize_user_data
from engine.provisioning.state import ProvisionError, atomic_write, safe_path from provisioning.state import ProvisionError, atomic_write, safe_path
from tests.test_provision_contract import example from test_provision_contract import example
class TargetTests(unittest.TestCase): class TargetTests(unittest.TestCase):
@@ -10,10 +10,10 @@ import tempfile
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from engine.provisioning.transport import ( from provisioning.transport import (
SSH, TransportError, HandoverUncertain, build_bundle, destination, local_file, protected_handover, SSH, TransportError, HandoverUncertain, build_bundle, destination, local_file, protected_handover,
initialize, initialize,
REMOTE_PATH_GUARD, remote_atomic_input, remote_atomic_input,
) )
@@ -27,7 +27,7 @@ class TransportTests(unittest.TestCase):
stage = root / 'stage' stage = root / 'stage'
stage.mkdir() stage.mkdir()
installed = root / 'bundle' installed = root / 'bundle'
seed = installed / 'engine/provisioning/seed.sh' seed = installed / 'provisioning/seed.sh'
seed.parent.mkdir(parents=True) seed.parent.mkdir(parents=True)
seed.write_text('printf "seed\\n" >> "$APEX_TEST_EVENTS"\n') seed.write_text('printf "seed\\n" >> "$APEX_TEST_EVENTS"\n')
binaries = root / 'bin' binaries = root / 'bin'
@@ -55,7 +55,8 @@ if event == 'enroll':
pass pass
def resolve(self): def resolve(self):
pass 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'' value = b''
if 'ssh_host_' in script: if 'ssh_host_' in script:
value = b'192.0.2.1 50000 192.0.2.2 22\nssh-ed25519 AAAA fixture\n' 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'): elif script.startswith('if test -f /var/tmp'):
value = (stage / 'result').read_bytes() value = (stage / 'result').read_bytes()
return subprocess.CompletedProcess([], 0, value, b'') return subprocess.CompletedProcess([], 0, value, b'')
with patch('engine.provisioning.transport.SSH', Remote), \ with patch('provisioning.transport.SSH', Remote), \
patch('engine.provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \ patch('provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \
patch('engine.provisioning.transport.shutil.which', return_value='/usr/bin/ssh'): patch('provisioning.transport.shutil.which', return_value='/usr/bin/ssh'):
with self.assertRaises(TransportError): with self.assertRaises(TransportError):
initialize(root, 'root@example.com', source, progress=lambda message: None) initialize(root, 'root@example.com', source, progress=lambda message: None)
expected = ['seed', 'validate', 'access-preflight'] expected = ['seed', 'validate', 'access-preflight']
@@ -151,9 +152,9 @@ if event == 'enroll':
self.assertIn('ssh-ed25519 AAAA', transport.trust_file.read_text()) self.assertIn('ssh-ed25519 AAAA', transport.trust_file.read_text())
raise PreflightComplete raise PreflightComplete
transport = SSH('adam@example.com', runner=runner) transport = SSH('adam@example.com', runner=runner)
with patch('engine.provisioning.transport.SSH', return_value=transport), \ with patch('provisioning.transport.SSH', return_value=transport), \
patch('engine.provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \ patch('provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \
patch('engine.provisioning.transport.shutil.which', return_value='/usr/bin/ssh'): patch('provisioning.transport.shutil.which', return_value='/usr/bin/ssh'):
with self.assertRaises(PreflightComplete): with self.assertRaises(PreflightComplete):
initialize(root, 'adam@example.com', source, progress=lambda message: None) initialize(root, 'adam@example.com', source, progress=lambda message: None)
self.assertEqual(len(fallbacks), 1) self.assertEqual(len(fallbacks), 1)
@@ -237,8 +238,8 @@ if event == 'enroll':
self.assertEqual(calls, ['begin', 'fresh', 'finalize', 'fresh', 'commit']) self.assertEqual(calls, ['begin', 'fresh', 'finalize', 'fresh', 'commit'])
def test_lost_commit_reply_is_reconciled_without_rolling_back_committed_access(self): def test_lost_commit_reply_is_reconciled_without_rolling_back_committed_access(self):
from engine.provisioning.access import Transition from provisioning.access import Transition
from tests.test_provision_access import FakeHost from test_provision_access import FakeHost
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
host = FakeHost(Path(directory)) host = FakeHost(Path(directory))
transition = Transition(host, now=lambda: 1000, security=host.security) transition = Transition(host, now=lambda: 1000, security=host.security)
@@ -277,17 +278,20 @@ if event == 'enroll':
self.assertEqual(calls, ['begin', 'finalize', 'commit', 'status', 'rollback']) self.assertEqual(calls, ['begin', 'finalize', 'commit', 'status', 'rollback'])
def test_lost_commit_reply_reports_verified_or_uncertain_resume_endpoints(self): def test_lost_commit_reply_reports_verified_or_uncertain_resume_endpoints(self):
from engine.provisioning.access import Transition from provisioning.access import Transition
from tests.test_provision_access import FakeHost from test_provision_access import FakeHost
for unreachable in (False, True): for unreachable in (False, True):
with self.subTest(unreachable=unreachable), tempfile.TemporaryDirectory() as directory: with self.subTest(unreachable=unreachable), tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)
source = root / 'user-data' source = root / 'user data $literal'
source.write_bytes(b'opaque input') source.write_bytes(b'opaque input')
identity = root / 'identity file'
identity.write_bytes(b'synthetic private identity')
host = FakeHost(root) host = FakeHost(root)
transition = Transition(host, now=lambda: 1000, security=host.security) transition = Transition(host, now=lambda: 1000, security=host.security)
messages, commands = [], [] messages, commands = [], []
def runner(args, **kwargs): def runner(args, commands=commands, transition=transition,
unreachable=unreachable, **kwargs):
if '-G' in args: if '-G' in args:
return subprocess.CompletedProcess(args, 0, 'hostname 192.0.2.2\nport 22\n', '') return subprocess.CompletedProcess(args, 0, 'hostname 192.0.2.2\nport 22\n', '')
command = shlex.split(args[-1]) command = shlex.split(args[-1])
@@ -300,7 +304,7 @@ if event == 'enroll':
value = b'/var/tmp/apex-init.0123456789\n' value = b'/var/tmp/apex-init.0123456789\n'
elif script.startswith('if test -f /var/tmp'): elif script.startswith('if test -f /var/tmp'):
value = b'0' 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:] access_args = shlex.split(script)[3:]
operation = access_args[3] operation = access_args[3]
token = access_args[access_args.index('--token') + 1] token = access_args[access_args.index('--token') + 1]
@@ -316,24 +320,28 @@ if event == 'enroll':
if unreachable: if unreachable:
return subprocess.CompletedProcess(args, 255, b'', b'connection lost') return subprocess.CompletedProcess(args, 255, b'', b'connection lost')
value = json.dumps(transition.load()).encode() value = json.dumps(transition.load()).encode()
elif 'engine.provisioning.target complete' in script: elif 'provisioning.target complete' in script:
value = b'{"status": "complete"}' value = b'{"status": "complete"}'
return subprocess.CompletedProcess(args, 0, value, b'') return subprocess.CompletedProcess(args, 0, value, b'')
def transport(*args): def transport(*args):
return SSH(*args, runner=runner) return SSH(*args, runner=runner)
with patch('engine.provisioning.transport.SSH', side_effect=transport), \ with patch('provisioning.transport.SSH', side_effect=transport), \
patch('engine.provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \ patch('provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \
patch('engine.provisioning.transport.shutil.which', return_value='/usr/bin/ssh'): patch('provisioning.transport.shutil.which', return_value='/usr/bin/ssh'):
if unreachable: if unreachable:
with self.assertRaises(HandoverUncertain): 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.assertTrue(any('outcome is unknown' in message for message in messages))
self.assertIn('adam@example.com --port 2222', messages[-2]) self.assertIn('adam@example.com --port 2222', messages[-2])
self.assertIn('debian@example.com --port 22', messages[-1]) 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)) self.assertFalse(any('Safe resume' in message or 'pending rollback' in message for message in messages))
else: else:
self.assertEqual(initialize(root, 'debian@example.com', source, progress=messages.append), {'status': 'complete'}) self.assertEqual(initialize(root, 'debian@example.com', source, identity_file=identity, progress=messages.append), {'status': 'complete'})
self.assertIn('Safe resume: ./apex init adam@example.com --port 2222', messages[-1]) 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.assertEqual(transition.load()['status'], 'committed')
self.assertFalse(host.armed) self.assertFalse(host.armed)
self.assertFalse(any('access rollback' in script for script in commands)) 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): def test_bundle_excludes_repository_and_environment_secrets(self):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)
(root / 'engine/provisioning').mkdir(parents=True) (root / 'ci/scripts/init-node/provisioning').mkdir(parents=True)
(root / 'engine/__init__.py').write_text('') (root / 'ci/scripts/init-node/provisioning/__init__.py').write_text('')
(root / 'engine/provisioning/seed.sh').write_text('#!/bin/sh\n') (root / 'ci/scripts/init-node/provisioning/seed.sh').write_text('#!/bin/sh\n')
(root / '.env').write_text('private secret') (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) data, digest, revision = build_bundle(root)
with tarfile.open(fileobj=io.BytesIO(data)) as archive: 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.assertNotIn(b'private secret', data)
self.assertEqual(build_bundle(root)[1], digest) 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): def test_missing_input_is_a_safe_validation_error(self):
with self.assertRaises(TransportError): with self.assertRaises(TransportError):
local_file('/nonexistent/apex-user-data', 'User-data') local_file('/nonexistent/apex-user-data', 'User-data')
def test_init_from_node_checkout_does_not_resolve_local_identity(self): def test_standalone_help_runs_without_engine_or_yaml_imports(self):
from engine import cli launcher = Path(__file__).resolve().parents[2] / 'init-node.py'
root = Path(__file__).resolve().parents[1] with tempfile.TemporaryDirectory() as directory:
with patch.object(cli, '_resolve_layout', return_value=(str(root), str(root), None, True)), \ result = subprocess.run([sys.executable, '-S', str(launcher), '--help'],
patch.object(cli, 'resolve', side_effect=AssertionError('local identity used')): cwd=directory, capture_output=True, text=True, timeout=30)
with self.assertRaises(SystemExit) as caught: self.assertEqual(result.returncode, 0, result.stderr)
cli.main(['init', '--help']) self.assertIn('--user-data', result.stdout)
self.assertEqual(caught.exception.code, 0) self.assertIn('--identity-file', result.stdout)
def test_dead_enrollment_unit_stops_polling_and_removes_input(self): def test_dead_enrollment_unit_stops_polling_and_removes_input(self):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
@@ -389,9 +432,9 @@ if event == 'enroll':
value = b'' value = b''
return subprocess.CompletedProcess([], 0, value, b'') return subprocess.CompletedProcess([], 0, value, b'')
messages = [] messages = []
with patch('engine.provisioning.transport.SSH', Remote), \ with patch('provisioning.transport.SSH', Remote), \
patch('engine.provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \ patch('provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \
patch('engine.provisioning.transport.shutil.which', return_value='/usr/bin/ssh'): patch('provisioning.transport.shutil.which', return_value='/usr/bin/ssh'):
with self.assertRaises(TransportError) as caught: with self.assertRaises(TransportError) as caught:
initialize(root, 'root@example.com', source, progress=messages.append) initialize(root, 'root@example.com', source, progress=messages.append)
self.assertIn('/runner.log', str(caught.exception)) self.assertIn('/runner.log', str(caught.exception))
@@ -416,16 +459,16 @@ if event == 'enroll':
value = b'/var/tmp/apex-init.0123456789\n' value = b'/var/tmp/apex-init.0123456789\n'
elif script.startswith('if test -f /var/tmp'): elif script.startswith('if test -f /var/tmp'):
value = b'0' value = b'0'
elif 'engine.provisioning.target complete' in script: elif 'provisioning.target complete' in script:
raise TransportError('record failed') raise TransportError('record failed')
else: else:
value = b'' value = b''
return subprocess.CompletedProcess([], 0, value, b'') return subprocess.CompletedProcess([], 0, value, b'')
messages = [] messages = []
with patch('engine.provisioning.transport.SSH', Remote), \ with patch('provisioning.transport.SSH', Remote), \
patch('engine.provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \ patch('provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \
patch('engine.provisioning.transport.protected_handover'), \ patch('provisioning.transport.protected_handover'), \
patch('engine.provisioning.transport.shutil.which', return_value='/usr/bin/ssh'): patch('provisioning.transport.shutil.which', return_value='/usr/bin/ssh'):
with self.assertRaises(TransportError): with self.assertRaises(TransportError):
initialize(root, 'root@example.com', source, progress=messages.append) initialize(root, 'root@example.com', source, progress=messages.append)
self.assertIn('adam@example.com --port 2222', messages[-1]) self.assertIn('adam@example.com --port 2222', messages[-1])
@@ -1,8 +1,8 @@
import importlib.util import importlib.util
import unittest import unittest
from engine.provisioning.contract import ContractError from provisioning.contract import ContractError
from engine.provisioning.userdata import decode_user_data from provisioning.userdata import decode_user_data
@unittest.skipUnless(importlib.util.find_spec('yaml'), 'target-only python3-yaml is unavailable') @unittest.skipUnless(importlib.util.find_spec('yaml'), 'target-only python3-yaml is unavailable')
@@ -21,9 +21,9 @@ class UserDataTests(unittest.TestCase):
def test_normal_supported_document(self): def test_normal_supported_document(self):
import yaml import yaml
from tests.test_provision_contract import example from test_provision_contract import example
spec = decode_user_data(yaml.safe_dump(example()).encode()) spec = decode_user_data(yaml.safe_dump(example()).encode())
self.assertEqual(spec.hostname, 'metis.a1.example.com') self.assertEqual(spec.hostname, 'node1.a1.example.com')
def test_yaml_boolean_mapping_key_rejected(self): def test_yaml_boolean_mapping_key_rejected(self):
with self.assertRaisesRegex(ContractError, 'unique strings'): with self.assertRaisesRegex(ContractError, 'unique strings'):
+9 -15
View File
@@ -1,5 +1,5 @@
# commons/compositions/apex/docker-compose.yml # commons/compositions/apex/docker-compose.yml
# The shared apex CORE composition. Consumed by each node via include: (SP3). # The shared apex CORE composition. Available to node compositions through include:.
# FULLY ${env}-parameterized; NO './' host paths (relative paths in an included file # FULLY ${env}-parameterized; NO './' host paths (relative paths in an included file
# resolve against THIS dir — always use absolute ${APEX_*} vars). The engine exports # resolve against THIS dir — always use absolute ${APEX_*} vars). The engine exports
# APEX_TIER*/APEX_TIER*_SHARED/APEX_TIER_ROOT*/APEX_SUBNET/APEX_NODE_FQDN at invoke. # APEX_TIER*/APEX_TIER*_SHARED/APEX_TIER_ROOT*/APEX_SUBNET/APEX_NODE_FQDN at invoke.
@@ -18,7 +18,7 @@ services:
network_mode: "service:traefik" network_mode: "service:traefik"
restart: unless-stopped restart: unless-stopped
labels: labels:
# backup (resticontainer, core/all-nodes): adguard config only (work data regenerable) # backup (resticontainer): adguard config only (work data regenerable)
- "restic.enable=true" - "restic.enable=true"
- "restic.backup.paths=/opt/adguardhome/conf" - "restic.backup.paths=/opt/adguardhome/conf"
cap_add: cap_add:
@@ -30,12 +30,8 @@ services:
- no-new-privileges:true - no-new-privileges:true
# --- optional: observability agent (profile-gated) --- # --- optional: observability agent (profile-gated) ---
# Grafana Alloy: one agent per node. Collects host metrics (node_exporter- # Collect host/container metrics and logs for configured remote endpoints.
# equivalent) + container metrics (cadvisor) and PUSHES them via remote_write; # Alloy requires outbound access to those endpoints through the direct network.
# collects systemd-journal + docker logs and PUSHES them to Loki. All targets
# are OUTBOUND to a central Prometheus + Loki (hosted on whichever node runs them), so
# Alloy needs egress (direct, not enclave). Replaces the old store-only `loki`
# service (which had no feeder). Config is a committed, env-parameterized asset.
alloy: alloy:
image: grafana/alloy:v1.20.0 image: grafana/alloy:v1.20.0
container_name: apex-alloy container_name: apex-alloy
@@ -86,8 +82,7 @@ services:
# container mounts to host paths (via the /srv mount), snapshots the union # container mounts to host paths (via the /srv mount), snapshots the union
# in a single restic run, then restarts + runs post-hooks. Every other subcommand # in a single restic run, then restarts + runs post-hooks. Every other subcommand
# (init/snapshots/forget/…) passes straight through to restic. Needs the docker # (init/snapshots/forget/…) passes straight through to restic. Needs the docker
# socket (list/inspect/exec/stop/start) and /srv read-only (the fleet's data root) # socket (list/inspect/exec/stop/start) and read-only access to /srv.
# — a privileged manual one-shot, same trust surface as the old restic core.
resticontainer: resticontainer:
image: ghcr.io/deytenit/resticontainer:0.19.1@sha256:85457ee665b1c6c8438ac3494c286d267169ee023857da3a605a6fe16dab3d82 image: ghcr.io/deytenit/resticontainer:0.19.1@sha256:85457ee665b1c6c8438ac3494c286d267169ee023857da3a605a6fe16dab3d82
container_name: apex-resticontainer container_name: apex-resticontainer
@@ -112,9 +107,8 @@ services:
# for any other S3-compatible store (Yandex, Backblaze B2, MinIO, …). # for any other S3-compatible store (Yandex, Backblaze B2, MinIO, …).
AWS_REGION: "${APEX_RESTIC_AWS_REGION:-us-east-1}" AWS_REGION: "${APEX_RESTIC_AWS_REGION:-us-east-1}"
AWS_ENDPOINT_URL: "${APEX_RESTIC_AWS_ENDPOINT:-}" AWS_ENDPOINT_URL: "${APEX_RESTIC_AWS_ENDPOINT:-}"
# max compression is fleet policy (backup/run also forces --compression max on # Compression requires repository format v2. The default is max;
# the CLI; needs repo format v2, which a fresh restic init creates). Per-node # override it with APEX_RESTIC_COMPRESSION for direct container use.
# override via APEX_RESTIC_COMPRESSION only if ever needed.
RESTIC_COMPRESSION: "${APEX_RESTIC_COMPRESSION:-max}" RESTIC_COMPRESSION: "${APEX_RESTIC_COMPRESSION:-max}"
RESTIC_CACHE_DIR: /var/restic-cache RESTIC_CACHE_DIR: /var/restic-cache
network_mode: "host" network_mode: "host"
@@ -195,7 +189,7 @@ services:
- "traefik.http.middlewares.apex-adguard-strip-prefix.stripprefix.prefixes=/adguard" - "traefik.http.middlewares.apex-adguard-strip-prefix.stripprefix.prefixes=/adguard"
- "traefik.http.routers.apex-adguard.service=apex-adguard" - "traefik.http.routers.apex-adguard.service=apex-adguard"
- "traefik.http.services.apex-adguard.loadbalancer.server.port=3000" - "traefik.http.services.apex-adguard.loadbalancer.server.port=3000"
# backup (resticontainer, core/all-nodes): dynamic routing rules only # backup (resticontainer): dynamic routing rules only
# (letsencrypt certs are regenerable via ACME/Cloudflare DNS-01) # (letsencrypt certs are regenerable via ACME/Cloudflare DNS-01)
- "restic.enable=true" - "restic.enable=true"
- "restic.backup.paths=/etc/traefik/dynamic" - "restic.backup.paths=/etc/traefik/dynamic"
@@ -218,7 +212,7 @@ services:
command: [ "-c", "/etc/xray/config.jsonc" ] command: [ "-c", "/etc/xray/config.jsonc" ]
restart: unless-stopped restart: unless-stopped
labels: labels:
# backup (resticontainer, core/all-nodes): xray config only (logs + shm are ephemeral) # backup (resticontainer): xray config only (logs + shm are ephemeral)
- "restic.enable=true" - "restic.enable=true"
- "restic.backup.paths=/etc/xray/config.jsonc" - "restic.backup.paths=/etc/xray/config.jsonc"
user: "0:0" user: "0:0"
+1 -1
View File
@@ -1,5 +1,5 @@
# engine/context.py # engine/context.py
"""Context (ctx) — the single seam between an action and the world (SP2 §2.1).""" """Context and filesystem paths available to actions."""
from __future__ import annotations from __future__ import annotations
import os import os
+66 -22
View File
@@ -6,10 +6,10 @@ Ports root_template_crontab (install + rollback) and the ufw block-injection log
""" """
from __future__ import annotations from __future__ import annotations
import inspect
import json
import os import os
import shutil
import subprocess import subprocess
import tempfile
import time import time
from typing import List, Optional, Sequence, Tuple 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 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: class Host:
def __init__(self, log, sys_): def __init__(self, log, sys_):
self.log = log self.log = log
self.sys = sys_ 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 --- # --- read/write with sudo awareness ---
def _read(self, path: str, sudo: bool = False) -> str: def _read(self, path: str, sudo: bool = False) -> str:
if sudo: if sudo:
@@ -74,10 +136,7 @@ class Host:
if not exists: if not exists:
return None return None
bkp = f"{path}.{int(time.time())}.bak" bkp = f"{path}.{int(time.time())}.bak"
if sudo: self._file_operation(_atomic_backup, [path, bkp], sudo)
self.sys.sudo(["cp", "-a", path, bkp])
else:
shutil.copy2(path, bkp)
self.log.info(f"Backed up {path} -> {bkp}") self.log.info(f"Backed up {path} -> {bkp}")
return bkp return bkp
@@ -88,22 +147,7 @@ class Host:
return return
if backup: if backup:
self.backup(path, sudo=sudo) self.backup(path, sudo=sudo)
fd, tmp_name = tempfile.mkstemp(prefix="apex-") self._file_operation(_atomic_write, [path, content, mode], sudo)
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.log.success(f"Wrote {path}") self.log.success(f"Wrote {path}")
def inject_block(self, target: str, content: str, start: str, end: str, 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() data = urllib.parse.urlencode({"parse_mode": "HTML", "text": text}).encode()
self.log.info(f"Sending Telegram notification ({level}): {title}") self.log.info(f"Sending Telegram notification ({level}): {title}")
try: 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: timeout=NOTIFY_TIMEOUT) as r:
r.read() r.read()
return True return True
+3 -4
View File
@@ -1,9 +1,8 @@
# engine/lib/tpl.py # engine/lib/tpl.py
"""ctx.tpl — stdlib string.Template rendering (envsubst successor, SP2 decision #3). """ctx.tpl — stdlib string.Template rendering.
safe_substitute replaces known $VAR/${VAR} and leaves unknown ones literal (deliberate safe_substitute replaces known $VAR/${VAR} and leaves unknown ones literal.
delta: envsubst emptied them — literal survivors surface typos instead of silently Shell $(...) command-substitution survives untouched.
blanking). Shell $(...) command-substitution survives untouched.
Loops/conditionals live in the python action, never in the template file. Loops/conditionals live in the python action, never in the template file.
""" """
from __future__ import annotations from __future__ import annotations
+3 -4
View File
@@ -1,9 +1,8 @@
# engine/lib/ufw.py # engine/lib/ufw.py
"""ctx.ufw — ufw + ufw-docker rule application with self-healing marker migration. """Apply ufw and ufw-docker rules.
Injects the APEX marker block into before/after.rules, deleting BOTH the old ROOT Replace existing supported marker blocks before inserting current rules.
and the new APEX blocks first (so re-running after cutover migrates the marker in Docker rules go through ufw-docker; host rules through plain ufw.
place — SP2 §6). Docker rules go through ufw-docker; host rules through plain ufw.
""" """
from __future__ import annotations from __future__ import annotations
+6 -15
View File
@@ -1,13 +1,6 @@
// ============================================================================ // Optional shared Grafana Alloy configuration.
// APEX fleet observability agent — per-node Grafana Alloy config (shared). // Metrics and logs use the endpoints and credentials supplied through
// Committed, env-parameterized: every node runs this SAME file; per-node values // APEX_PROM_PUSH_URL, APEX_LOKI_PUSH_URL, and APEX_OBS_BASIC_AUTH_USER/PASS.
// come from sys.env(...) (APEX_NODE_HOST, push URLs, obs basic-auth creds).
// Verified against grafana.com/docs/alloy/latest (Alloy v1.17.1).
// ============================================================================
// Model: PUSH. Metrics -> remote_write to a central Prometheus; logs -> loki.write
// to a central Loki. Push endpoints + basic-auth are per-node env
// (APEX_PROM_PUSH_URL, APEX_LOKI_PUSH_URL, APEX_OBS_BASIC_AUTH_USER/PASS).
// ============================================================================
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// METRICS // METRICS
@@ -81,10 +74,8 @@ prometheus.scrape "containers" {
scrape_interval = "30s" scrape_interval = "30s"
} }
// CrowdSec agent metrics. CrowdSec runs on the host (not a container) and exposes Prometheus // The CrowdSec scrape requires a reachable host metrics endpoint on port 6060.
// metrics on :6060; nodes that run it bind that endpoint on 0.0.0.0 (the host firewall keeps it // If no endpoint is configured, this target reports down.
// off the public interface) so Alloy can reach it via the docker gateway. Nodes that don't run
// CrowdSec simply report this target down.
prometheus.scrape "crowdsec" { prometheus.scrape "crowdsec" {
targets = [{ targets = [{
__address__ = "host.docker.internal:6060", __address__ = "host.docker.internal:6060",
@@ -96,7 +87,7 @@ prometheus.scrape "crowdsec" {
scrape_interval = "30s" scrape_interval = "30s"
} }
// Push metrics to the central Prometheus (needs --web.enable-remote-write-receiver). // The configured Prometheus-compatible endpoint must accept remote writes.
prometheus.remote_write "default" { prometheus.remote_write "default" {
endpoint { endpoint {
url = sys.env("APEX_PROM_PUSH_URL") url = sys.env("APEX_PROM_PUSH_URL")
+10 -1
View File
@@ -6,13 +6,22 @@ readme = "README.md"
license = { text = "BSD-2-Clause" } license = { text = "BSD-2-Clause" }
requires-python = ">=3.10" requires-python = ">=3.10"
[project.optional-dependencies]
dev = ["ruff==0.17.2", "PyYAML>=6.0,<7"]
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
target-version = "py310" target-version = "py310"
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "W", "I", "B", "UP", "S"] 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] [tool.mypy]
python_version = "3.10" python_version = "3.10"
+3 -2
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bash #!/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 set -euo pipefail
cd "$(dirname "$0")/.." 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 os
import tempfile import tempfile
import unittest import unittest
from pathlib import Path
from unittest.mock import MagicMock from unittest.mock import MagicMock
from actions import compose from actions import compose
from engine.descriptor import build_parser
class TestCompose(unittest.TestCase): class TestCompose(unittest.TestCase):
def setUp(self): def setUp(self):
self.tmpdir = tempfile.TemporaryDirectory() self.tmpdir = tempfile.TemporaryDirectory()
self.addCleanup(self.tmpdir.cleanup) self.addCleanup(self.tmpdir.cleanup)
self.comp_dir = os.path.join(self.tmpdir.name, "compositions") self.comp_dir = Path(self.tmpdir.name) / 'compositions'
os.makedirs(self.comp_dir) for name in ('apex', 'sso', 'arr'):
directory = self.comp_dir / name
# Create compositions: apex, sso, arr directory.mkdir(parents=True)
for name in ("apex", "sso", "arr"): (directory / 'docker-compose.yml').write_text(f'name: {name}\n')
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.ctx = MagicMock() self.ctx = MagicMock()
self.ctx.paths.compositions = self.comp_dir self.ctx.paths.compositions = str(self.comp_dir)
self.ctx.paths.stier3 = self.stier3 self.ctx.paths.stier3 = self.tmpdir.name
self.ctx.vars.return_value = { self.ctx.vars.return_value = {'APEX_NODE_HOST': 'test-host', 'OTHER': 'ignored'}
"APEX_SUBNET": "198.18.16.0/24",
"APEX_NODE_HOST": "test-host",
}
self.ctx.sys.run = MagicMock()
def test_sequence_all_by_default(self): def run_compose(self, *argv):
args = MagicMock(action="restart", p=None, dry_run=False, extra=[]) args = build_parser(compose.METADATA, 'apex compose').parse_args(argv)
compose.run(self.ctx, args) compose.run(self.ctx, args)
# Should execute on apex, arr, sso (apex core first, others sorted) def visited(self):
calls = self.ctx.sys.run.call_args_list return [os.path.basename(call.kwargs['cwd'])
self.assertEqual(len(calls), 3) for call in self.ctx.sys.run.call_args_list]
visited = [os.path.basename(c.kwargs["cwd"]) for c in calls]
self.assertEqual(visited, ["apex", "arr", "sso"])
def test_down_reverse_sequence(self): def test_sequence_all_and_down_reverse(self):
args = MagicMock(action="down", p=None, dry_run=False, extra=[]) for argv, expected in [(('restart',), ['apex', 'arr', 'sso']),
compose.run(self.ctx, args) (('-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 def test_explicit_target_preserves_service_named_like_composition(self):
self.assertEqual(len(calls), 3) self.run_compose('-p', 'apex', 'restart', 'sso')
visited = [os.path.basename(c.kwargs["cwd"]) for c in calls] self.assertEqual(self.visited(), ['apex'])
# Reverse on down: sso, arr, apex call = self.ctx.sys.run.call_args
self.assertEqual(visited, ["sso", "arr", "apex"]) 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): def test_immediate_positional_composition_compatibility(self):
args = MagicMock(action="restart", p=None, dry_run=False, extra=["apex"]) self.run_compose('restart', 'apex', 'traefik')
compose.run(self.ctx, args) 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 def test_up_detached_compatibility_and_default_detached(self):
self.assertEqual(len(calls), 1) for argv in [('up', '-d', 'apex'), ('-p', 'apex', 'up'),
self.assertEqual(os.path.basename(calls[0].kwargs["cwd"]), "apex") ('up', 'apex', '-d')]:
self.assertEqual(calls[0].args[0], ["docker", "compose", "restart"]) 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): def test_flag_values_and_unknown_services_do_not_retarget(self):
args = MagicMock(action="restart", p="sso", dry_run=False, extra=[]) for argv in [('logs', '--tail', 'sso'), ('restart', 'traefik'),
compose.run(self.ctx, args) ('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 def test_invalid_wrapper_options_fail_before_execution(self):
self.assertEqual(len(calls), 1) for argv, code in [(('-p', 'unknown', 'up'), 1), (('-p', '', 'up'), 1),
self.assertEqual(os.path.basename(calls[0].kwargs["cwd"]), "sso") (('-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): def test_separator_preserves_wrapper_looking_passthrough_tokens(self):
args = MagicMock(action="restart", p=None, dry_run=False, extra=["apex", "traefik"]) self.run_compose('-p', 'apex', 'exec', '--', 'probe', '--dry-run', '-p', 'sso')
compose.run(self.ctx, args) 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 def test_env_files_layer_before_action(self):
self.assertEqual(len(calls), 1) for filename in ('.env', 'apex.env'):
self.assertEqual(os.path.basename(calls[0].kwargs["cwd"]), "apex") (self.comp_dir / 'apex' / filename).write_text('PORT=1234\n')
self.assertEqual(calls[0].args[0], ["docker", "compose", "restart", "traefik"]) 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): def test_socket_survives_restart_up_and_dry_run(self):
args = MagicMock(action="up", p=None, dry_run=False, extra=["-d", "sso"]) shm = Path(self.tmpdir.name) / 'apex' / 'xray' / 'shm'
compose.run(self.ctx, args) shm.mkdir(parents=True)
path = shm / 'live.sock'
calls = self.ctx.sys.run.call_args_list path.write_bytes(b"socket path must survive")
self.assertEqual(len(calls), 1) for argv in [('restart', 'apex'), ('-p', 'apex', 'restart', 'traefik'),
self.assertEqual(os.path.basename(calls[0].kwargs["cwd"]), "sso") ('up',), ('--dry-run', '-p', 'apex', 'logs', '--tail', '20', 'xray')]:
self.assertEqual(calls[0].args[0], ["docker", "compose", "up", "-d"]) with self.subTest(argv=argv):
self.ctx.sys.run.reset_mock()
def test_dry_run_skips_execution(self): self.run_compose(*argv)
args = MagicMock(action="restart", p=None, dry_run=True, extra=["apex"]) self.assertTrue(path.exists())
compose.run(self.ctx, args) if argv[0] == '--dry-run':
self.ctx.sys.run.assert_not_called()
self.ctx.sys.run.assert_not_called() self.assertIn('logs --tail 20 xray', self.ctx.log.info.call_args.args[0])
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))
if __name__ == "__main__": if __name__ == '__main__':
unittest.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 io
import json
import os import os
import sys import shutil
import stat
import subprocess
import tempfile import tempfile
import unittest import unittest
from pathlib import Path
from unittest.mock import MagicMock from unittest.mock import MagicMock
from actions import env from actions import env
from engine.descriptor import build_parser
from engine.lib.host import Host
class TestEnv(unittest.TestCase): class TestEnv(unittest.TestCase):
def setUp(self): def setUp(self):
self.tmpdir = tempfile.TemporaryDirectory() self.tmpdir = tempfile.TemporaryDirectory()
self.addCleanup(self.tmpdir.cleanup) self.addCleanup(self.tmpdir.cleanup)
self.comp_dir = os.path.join(self.tmpdir.name, "compositions") self.core = Path(self.tmpdir.name) / 'compositions' / 'apex'
os.makedirs(os.path.join(self.comp_dir, "apex")) self.core.mkdir(parents=True)
self.ctx = MagicMock() self.ctx = MagicMock()
self.ctx.paths.compositions = self.comp_dir self.ctx.paths.compositions = str(self.core.parent)
self.ctx.vars.return_value = { self.ctx.vars.return_value = {
"APEX_SUBNET": "198.18.16.0/24", 'APEX_SUBNET': '198.18.0.0/24',
"APEX_NODE_HOST": "test-host", 'APEX_NODE_HOST': 'test-host',
"OTHER_VAR": "ignore_me", 'OTHER_VAR': 'ignore_me',
} }
self.ctx.host = Host(self.ctx.log, self.ctx.sys)
def test_export_format_stdout(self): def run_env(self, *argv):
args = MagicMock(sync=False, write=None, dotenv=False) args = build_parser(env.METADATA, 'apex env').parse_args(argv)
captured = io.StringIO() with contextlib.redirect_stdout(io.StringIO()) as out:
old_stdout = sys.stdout
try:
sys.stdout = captured
env.run(self.ctx, args) env.run(self.ctx, args)
finally: return out.getvalue()
sys.stdout = old_stdout
out = captured.getvalue() def test_shell_exports_round_trip_literal_values(self):
self.assertIn("export APEX_NODE_HOST=test-host", out) value = "a$b # 'quote' \"double\" \\backslash\r\nnext"
self.assertIn("export APEX_SUBNET=198.18.16.0/24", out) self.ctx.vars.return_value['APEX_NODE_HOST'] = value
self.assertNotIn("OTHER_VAR", out) 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): def test_dotenv_does_not_disclose_other_values_or_existing_secrets(self):
args = MagicMock(sync=False, write=None, dotenv=True) (self.core / '.env').write_text('SECRET=synthetic-private\n')
captured = io.StringIO() output = self.run_env('--dotenv')
old_stdout = sys.stdout self.assertIn('APEX_NODE_HOST=', output)
try: self.assertNotIn('export ', output)
sys.stdout = captured self.assertNotIn('SECRET', output)
env.run(self.ctx, args) self.assertNotIn('OTHER_VAR', output)
finally:
sys.stdout = old_stdout
out = captured.getvalue() def test_repeat_sync_preserves_credentials_bytes_and_updates_managed_values(self):
self.assertIn("APEX_NODE_HOST=test-host", out) original = b'# user credentials\r\nTOKEN=\'a$#"\\literal\'\r\nUID=1000\r\nGID=1000'
self.assertNotIn("export", out) 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): def test_sync_preserves_symlink_and_private_target(self):
target = os.path.join(self.comp_dir, "apex", ".env") target = Path(self.tmpdir.name) / 'private.env'
args = MagicMock(sync=True, write=None, dotenv=False) target.write_text('TOKEN=synthetic\n')
env.run(self.ctx, args) 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() def test_write_basename_is_standalone_and_private(self):
call_args = self.ctx.host.write_file.call_args previous = os.getcwd()
self.assertEqual(call_args[0][0], target) self.addCleanup(os.chdir, previous)
self.assertIn("APEX_SUBNET=198.18.16.0/24", call_args[0][1]) 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() unittest.main()
+195 -1
View File
@@ -1,6 +1,200 @@
# tests/test_host.py # tests/test_host.py
import inspect
import os
from pathlib import Path
import stat
import subprocess
import sys
import tempfile
import unittest 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): class TestApplyBlock(unittest.TestCase):
def test_insert_after_anchor(self): def test_insert_after_anchor(self):
+1 -1
View File
@@ -1,6 +1,6 @@
# tests/test_identity.py # tests/test_identity.py
import os, tempfile, unittest import os, tempfile, unittest
from engine.identity import resolve, Node from engine.identity import resolve
class TestIdentity(unittest.TestCase): class TestIdentity(unittest.TestCase):
def _envfile(self, text): 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)