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