docs(apex): keep public guidance and examples deployment-neutral

This commit is contained in:
deytenit committed 2026-10-26 12:53:58 +03:00
1 parent 995ea9bdf9
commit 0881013357
13 files changed
+56 -74

No files matched your search

+5 -6
View File
@@ -22,7 +22,7 @@ cd selfhost.example.com && ./init.sh && source ~/.bashrc
cat > node.env <<'EOF'
APEX_NODE_FQDN=selfhost.example.com
APEX_NODE_HOST=selfhost.example.com
APEX_SUBNET=198.18.16.0/24
APEX_SUBNET=198.18.0.0/24
EOF
# Configure host baseline and bring up compositions
@@ -63,13 +63,12 @@ Running and maintaining services on an existing host.
- Update services: `apex compose pull && apex compose up`
- Run backups: `apex backup/run`
- Sync repository state: `apex sync/repository`
- **Replication**: For multi-node high availability patterns and contracts, consult the [Composition Replication Documentation](https://docs.ermnvldmr.com/en/apex/concepts/replication/).
Target a composition explicitly when operating on one application:
```sh
apex compose -p apex restart traefik
apex compose -p sso up -d
apex compose -p app up -d
apex compose --dry-run -p apex logs --tail 20 xray
```
@@ -79,9 +78,8 @@ remain supported. Use `-p` when service names or option values could be mistaken
for composition names. Without a target, APEX visits core first, then other
compositions alphabetically; `down` reverses that order.
Compose operations do not remove Xray sockets. Keep any node-owned socket cleanup
in Xray's startup entrypoint, when its previous process has stopped. A targeted
restart or an unchanged `up` must not remove another running service's listeners.
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:
@@ -124,6 +122,7 @@ Bootstrapping clean Debian 13 hosts on bare metal or cloud providers without clo
Adding shared actions or improving the engine.
- **Conventions**: Maintain strict Python standard library compliance. No external runtime packages.
- **Public documentation**: Describe supported interfaces using synthetic examples. Keep deployment inventories, internal plans, operational runbooks, and host-specific configuration in their owning private repositories.
- **Setup**:
```sh
python3 -m venv .venv
+1 -1
View File
@@ -1,5 +1,5 @@
# 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
from engine.descriptor import Meta, Flag, build_parser
from engine import overlay
+2 -2
View File
@@ -1,7 +1,7 @@
# 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
node's /24 (SP3 §6.2). No new deps — ipaddress + re are stdlib."""
node's /24."""
import ipaddress, os, re, shutil
from engine.descriptor import Meta, Flag
@@ -7,7 +7,7 @@ from provisioning.contract import ContractError, normalize_user_data, parse_boot
def example():
return {
'preserve_hostname': False,
'hostname': 'metis.a1.example.com',
'hostname': 'node1.a1.example.com',
'users': [{
'name': 'adam',
'sudo': 'ALL=(ALL) NOPASSWD:ALL',
@@ -19,8 +19,8 @@ def example():
'path': '/etc/apex/bootstrap.env',
'owner': 'root:root',
'permissions': '0600',
'content': 'APEX_REPO_NAME="metis.example.com"\n'
'APEX_REPO_URL="https://example.com/team/metis.git"\n',
'content': 'APEX_REPO_NAME="node1.example.com"\n'
'APEX_REPO_URL="https://example.com/team/node1.git"\n',
}],
'runcmd': ['/usr/local/bin/apex-bootstrap'],
}
@@ -34,7 +34,7 @@ def add_env(doc, text):
class TestBootstrapAssignments(unittest.TestCase):
def test_literal_quotes_comments_and_empty_values(self):
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")
self.assertEqual(values['APEX_REPO_REF'], 'release-1')
self.assertEqual(values['APEX_TIER1_DEVICE'], '')
@@ -76,8 +76,8 @@ class TestBootstrapAssignments(unittest.TestCase):
class TestUserDataContract(unittest.TestCase):
def test_public_repository_and_directory_defaults(self):
spec = normalize_user_data(example())
self.assertEqual(spec.hostname, 'metis.a1.example.com')
self.assertEqual(spec.repository.name, 'metis.example.com')
self.assertEqual(spec.hostname, 'node1.a1.example.com')
self.assertEqual(spec.repository.name, 'node1.example.com')
self.assertIsNone(spec.repository.ref)
self.assertEqual([tier.mode for tier in spec.tiers], ['directory'] * 3)
self.assertEqual(spec.tiers[0].number, 1)
@@ -173,9 +173,9 @@ class TestUserDataContract(unittest.TestCase):
def test_repository_paths_and_embedded_passwords(self):
for name, url in [
('../escape', 'https://example.com/repo.git'),
('metis', 'https://user:SECRET_SENTINEL@example.com/repo.git'),
('metis', 'file:///etc/shadow'),
('metis', '-uSECRET_SENTINEL'),
('node1', 'https://user:SECRET_SENTINEL@example.com/repo.git'),
('node1', 'file:///etc/shadow'),
('node1', '-uSECRET_SENTINEL'),
]:
doc = example()
doc['write_files'][0]['content'] = (
@@ -32,7 +32,7 @@ class RepositoryTests(unittest.TestCase):
(self.commons / 'etc/.vimrc').write_text('set number\n')
self.git(['add', '.'], 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(
'#!/bin/sh\nset -eu\ngit config core.hooksPath commons/githooks\n'
'printf \'export PATH="%s/commons:$PATH"\\n\' "$PWD" > "$HOME/.bashrc"\n'
@@ -40,7 +40,7 @@ class RepositoryTests(unittest.TestCase):
self.git(['-c', 'protocol.file.allow=always', 'submodule', 'add', str(self.commons), 'commons'], self.upstream)
self.git(['add', '.'], 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.saved = []
self.account = patch('provisioning.repository.pwd.getpwnam',
@@ -71,19 +71,19 @@ class RepositoryTests(unittest.TestCase):
self.git(['commit', '-qm', 'advance'], self.upstream)
second = self.enroll()
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'])
def test_local_changes_are_preserved_and_stop_initialization(self):
self.enroll()
path = self.srv / 'metis' / 'node.env'
path = self.srv / 'node1' / 'node.env'
path.write_text('local changes')
with self.assertRaisesRegex(ProvisionError, 'local changes'):
self.enroll()
self.assertEqual(path.read_text(), 'local changes')
def test_unrecorded_checkout_is_not_adopted(self):
(self.srv / 'metis').mkdir()
(self.srv / 'node1').mkdir()
with self.assertRaisesRegex(ProvisionError, 'adoption'):
self.enroll()
@@ -98,10 +98,10 @@ class RepositoryTests(unittest.TestCase):
def test_missing_or_unsupported_interface_stops_before_hook(self):
for version in ('', 'APEX_ENROLLMENT_INTERFACE=999\n'):
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(['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 = {}
with self.assertRaisesRegex(ProvisionError, 'enrollment interface'):
self.enroll()
@@ -110,7 +110,7 @@ class RepositoryTests(unittest.TestCase):
def test_retry_checks_hook_state_instead_of_completion_marker(self):
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'):
self.enroll()
@@ -37,12 +37,12 @@ class StateTests(unittest.TestCase):
def test_failed_stage_is_retryable_and_success_is_reverified(self):
path = self.root / 'journal'
record = Journal(path, {'hostname': 'metis'}, 'rev', 'digest')
record = Journal(path, {'hostname': 'node1'}, 'rev', 'digest')
with self.assertRaises(RuntimeError):
record.stage('package', lambda: (_ for _ in ()).throw(RuntimeError('SECRET')))
self.assertNotIn('SECRET', path.read_text())
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'}))
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):
path = self.root / 'journal'
Journal(path, {'hostname': 'metis'}, 'rev', 'digest').save()
for identity, digest in (({'hostname': 'other'}, 'digest'), ({'hostname': 'metis'}, 'other')):
Journal(path, {'hostname': 'node1'}, 'rev', 'digest').save()
for identity, digest in (({'hostname': 'other'}, 'digest'), ({'hostname': 'node1'}, 'other')):
with self.assertRaises(ProvisionError):
Journal(path, identity, 'rev', digest)
def test_unsupported_interface_cannot_resume_and_revisions_survive_retry(self):
path = self.root / 'journal'
Journal(path, {'hostname': 'metis'}, 'source-rev', 'digest').save()
resumed = Journal(path, {'hostname': 'metis'}, 'source-rev', 'digest')
Journal(path, {'hostname': 'node1'}, 'source-rev', 'digest').save()
resumed = Journal(path, {'hostname': 'node1'}, 'source-rev', 'digest')
self.assertEqual(resumed.data['baseline_revision'], 'source-rev')
for version in (None, 999):
resumed.data['enrollment_interface'] = version
resumed.save()
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):
beat = self.root / 'heartbeat'
@@ -23,7 +23,7 @@ class UserDataTests(unittest.TestCase):
import yaml
from test_provision_contract import example
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):
with self.assertRaisesRegex(ContractError, 'unique strings'):
+9 -15
View File
@@ -1,5 +1,5 @@
# 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
# 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.
@@ -18,7 +18,7 @@ services:
network_mode: "service:traefik"
restart: unless-stopped
labels:
# backup (resticontainer, core/all-nodes): adguard config only (work data regenerable)
# backup (resticontainer): adguard config only (work data regenerable)
- "restic.enable=true"
- "restic.backup.paths=/opt/adguardhome/conf"
cap_add:
@@ -30,12 +30,8 @@ services:
- no-new-privileges:true
# --- optional: observability agent (profile-gated) ---
# Grafana Alloy: one agent per node. Collects host metrics (node_exporter-
# equivalent) + container metrics (cadvisor) and PUSHES them via remote_write;
# 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.
# Collect host/container metrics and logs for configured remote endpoints.
# Alloy requires outbound access to those endpoints through the direct network.
alloy:
image: grafana/alloy:v1.20.0
container_name: apex-alloy
@@ -86,8 +82,7 @@ services:
# 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
# (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)
# — a privileged manual one-shot, same trust surface as the old restic core.
# socket (list/inspect/exec/stop/start) and read-only access to /srv.
resticontainer:
image: ghcr.io/deytenit/resticontainer:0.19.1@sha256:85457ee665b1c6c8438ac3494c286d267169ee023857da3a605a6fe16dab3d82
container_name: apex-resticontainer
@@ -112,9 +107,8 @@ services:
# for any other S3-compatible store (Yandex, Backblaze B2, MinIO, …).
AWS_REGION: "${APEX_RESTIC_AWS_REGION:-us-east-1}"
AWS_ENDPOINT_URL: "${APEX_RESTIC_AWS_ENDPOINT:-}"
# max compression is fleet policy (backup/run also forces --compression max on
# the CLI; needs repo format v2, which a fresh restic init creates). Per-node
# override via APEX_RESTIC_COMPRESSION only if ever needed.
# Compression requires repository format v2. The default is max;
# override it with APEX_RESTIC_COMPRESSION for direct container use.
RESTIC_COMPRESSION: "${APEX_RESTIC_COMPRESSION:-max}"
RESTIC_CACHE_DIR: /var/restic-cache
network_mode: "host"
@@ -195,7 +189,7 @@ services:
- "traefik.http.middlewares.apex-adguard-strip-prefix.stripprefix.prefixes=/adguard"
- "traefik.http.routers.apex-adguard.service=apex-adguard"
- "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)
- "restic.enable=true"
- "restic.backup.paths=/etc/traefik/dynamic"
@@ -218,7 +212,7 @@ services:
command: [ "-c", "/etc/xray/config.jsonc" ]
restart: unless-stopped
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.backup.paths=/etc/xray/config.jsonc"
user: "0:0"
+1 -1
View File
@@ -1,5 +1,5 @@
# 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
import os
+3 -4
View File
@@ -1,9 +1,8 @@
# 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
delta: envsubst emptied them — literal survivors surface typos instead of silently
blanking). Shell $(...) command-substitution survives untouched.
safe_substitute replaces known $VAR/${VAR} and leaves unknown ones literal.
Shell $(...) command-substitution survives untouched.
Loops/conditionals live in the python action, never in the template file.
"""
from __future__ import annotations
+3 -4
View File
@@ -1,9 +1,8 @@
# 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
and the new APEX blocks first (so re-running after cutover migrates the marker in
place — SP2 §6). Docker rules go through ufw-docker; host rules through plain ufw.
Replace existing supported marker blocks before inserting current rules.
Docker rules go through ufw-docker; host rules through plain ufw.
"""
from __future__ import annotations
+6 -15
View File
@@ -1,13 +1,6 @@
// ============================================================================
// APEX fleet observability agent — per-node Grafana Alloy config (shared).
// Committed, env-parameterized: every node runs this SAME file; per-node values
// 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).
// ============================================================================
// Optional shared Grafana Alloy configuration.
// Metrics and logs use the endpoints and credentials supplied through
// APEX_PROM_PUSH_URL, APEX_LOKI_PUSH_URL, and APEX_OBS_BASIC_AUTH_USER/PASS.
// ----------------------------------------------------------------------------
// METRICS
@@ -81,10 +74,8 @@ prometheus.scrape "containers" {
scrape_interval = "30s"
}
// CrowdSec agent metrics. CrowdSec runs on the host (not a container) and exposes Prometheus
// metrics on :6060; nodes that run it bind that endpoint on 0.0.0.0 (the host firewall keeps it
// off the public interface) so Alloy can reach it via the docker gateway. Nodes that don't run
// CrowdSec simply report this target down.
// The CrowdSec scrape requires a reachable host metrics endpoint on port 6060.
// If no endpoint is configured, this target reports down.
prometheus.scrape "crowdsec" {
targets = [{
__address__ = "host.docker.internal:6060",
@@ -96,7 +87,7 @@ prometheus.scrape "crowdsec" {
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" {
endpoint {
url = sys.env("APEX_PROM_PUSH_URL")
+1 -1
View File
@@ -24,7 +24,7 @@ class TestEnv(unittest.TestCase):
self.ctx = MagicMock()
self.ctx.paths.compositions = str(self.core.parent)
self.ctx.vars.return_value = {
'APEX_SUBNET': '198.18.16.0/24',
'APEX_SUBNET': '198.18.0.0/24',
'APEX_NODE_HOST': 'test-host',
'OTHER_VAR': 'ignore_me',
}