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