ADR-007: Python CLI as Primary Control Plane¶
Historical note: references to
aptl scenarioin this ADR describe the retired pre-SDL runtime, not the current SDL-only branch.
Status¶
accepted
Date¶
2026-02-07
Context¶
From v2.0 through v3.x, the lab lifecycle was managed by start-lab.sh—a 273-line bash script that handled SSH key generation, environment setup, certificate management, Docker Compose orchestration, credential synchronization, and health checking. While functional, it had accumulated serious limitations:
Problems with start-lab.sh¶
-
No error handling: Commands that failed silently left the lab in an inconsistent state. A failed certificate generation didn't stop the startup sequence—containers would start without valid certs and fail with cryptic TLS errors.
-
No parallelism: Every operation ran sequentially. Docker image pulling, health checking, and SSH testing all blocked the main thread. Startup took longer than necessary.
-
No state management: The script had no concept of lab state. Running it twice could produce duplicate certificate generation, conflicting Docker Compose processes, or orphaned containers. There was no
stoporstatuscommand. -
Only 4 of 9 profiles: The script hardcoded
--profileflags for only 4 Docker Compose profiles (wazuh, victim, kali, reverse). As the SOC stack and enterprise infrastructure were added, the script couldn't deploy them. Users had to manually add--profile soc --profile enterpriseflags. -
No configuration validation: The script read
aptl.jsonfor container enable/disable state but didn't validate the configuration. Invalid JSON, missing fields, or impossible combinations (SOC without Wazuh) passed silently. -
Bash limitations: Complex operations like NDJSON parsing (
docker compose ps --format jsonoutputs one JSON object per line, not a JSON array), credential file manipulation with regex, and structured logging were awkward or fragile in bash.
Requirements¶
- Start, stop, and status commands with proper lifecycle management
- Configuration validation before deployment
- Structured error handling—fail early, report clearly
- All Docker Compose profiles supported via
aptl.json - Health check orchestration that waits for dependencies
- SSH connectivity verification
- Credential synchronization across Wazuh components
- SSL certificate generation/management
- Extensible for future commands (
aptl scenario,aptl config)
Decision¶
Implement a Python CLI (aptl) as the primary control plane, replacing start-lab.sh.
Technology Choices¶
| Component | Choice | Rationale |
|---|---|---|
| CLI framework | Typer | Declarative command definitions, auto-generated help, type annotations for argument validation |
| Configuration | Pydantic | Schema validation, type coercion, clear error messages for invalid configs |
| Output | Rich | Colored terminal output, progress indicators, tables—without manual ANSI escape codes |
| Structure | src layout | src/aptl/ package with pyproject.toml. Standard Python packaging. |
Architecture¶
src/aptl/
├── cli/ # Thin CLI layer (typer commands)
│ ├── main.py # aptl entry point
│ ├── lab.py # aptl lab start|stop|status
│ ├── scenario.py # aptl scenario start|stop|list
│ ├── container.py # aptl container list|shell|logs
│ └── config.py # aptl config show|validate
├── core/ # Domain logic (no CLI dependencies)
│ ├── lab.py # Lab lifecycle: start, stop, status, orchestration
│ ├── config.py # Pydantic models: AptlConfig, ContainerConfig
│ ├── env.py # .env file management, EnvVars
│ ├── ssh.py # SSH key generation (Ed25519)
│ ├── certs.py # SSL certificate generation via Docker
│ ├── credentials.py # Wazuh credential synchronization
│ ├── services.py # Health checks, readiness probes
│ ├── sysreqs.py # Platform-aware system requirements
│ ├── snapshot.py # Range snapshot capture
│ └── scenario.py # Scenario engine
└── utils/
└── logging.py # Structured logging
The separation between cli/ and core/ is intentional—the core domain logic has no dependency on Typer, Rich, or terminal I/O. This enables:
- Testing: Core functions are testable without mocking CLI frameworks
- Reuse: The future web UI backend (ADR-011) shares the same
core/modules - Scripting: Core functions can be imported directly for custom automation
12-Step Lab Orchestration¶
Superseded in part by ADR-030 (issue #1018). The fixed 12-step list and the rule that every step fails the sequence no longer describe
aptl lab start. The step order is now_LAB_START_STEPSinsrc/aptl/core/lab.py, containers start through the RAES realization handoff rather than a directdocker compose up, and late-startup failures are classified as fatal,degraded_unusable, ordegraded_usableper ADR-030. Requirement CLI-003 states the current ordering invariants and failure policy. The list below is the original decision.
aptl lab start executes a deterministic sequence:
- Load and validate configuration (
aptl.json) - Load environment variables (
.env) - Check system requirements (including
vm.max_map_count >= 262144on native Linux Docker Engine) - Generate SSH keys (Ed25519, if not present)
- Generate SSL certificates (via Docker container, if not present)
- Synchronize credentials to Wazuh config files
- Pre-pull Docker images (with progress visibility)
- Start containers via
docker compose up --build -dwith profile flags - Wait for Wazuh Indexer to be healthy
- Wait for Wazuh Manager API to be ready
- Test SSH connectivity to all SSH-enabled containers (with retry: 60s timeout, 5s interval)
- Capture range snapshot
Each step reports its status and fails the entire sequence on error, with a clear message about what went wrong and how to fix it.
Test Coverage¶
587+ tests across 12 test files covering all core modules. Tests use mocking for Docker and subprocess calls to run without a live lab environment.
Security Guardrail: Project-Rooted Credential Writes¶
Superseded in part by ADR-028 (issue #200). Credential synchronization no longer writes back over the checked-in
config/files—those are source-owned templates. The credentialized result is now rendered into the ignored.aptl/config/state tree (0700dirs /0600files, atomic write), anddocker-compose.ymlmounts that rendered copy. The project-rooting boundary below still applies, now to both the source template path and the rendered output path.
Wazuh credential synchronization is part of lab startup. The synchronization API
owns construction of the canonical project-relative source path (under
config/wazuh_dashboard/ and config/wazuh_cluster/) and the canonical
project-relative rendered output path (under .aptl/config/) from the caller's
project_dir, resolves each target, and rejects any path that is not contained
by the resolved project root before reading or writing.
Do not expose arbitrary caller-provided output paths for these credential writers. If future startup steps need to write project-owned files, keep the same boundary: accept a project root plus a hardcoded project-relative target, validate containment at the core-module boundary, then perform I/O.
Consequences¶
Positive¶
- Reliable startup: Every step validates its preconditions and reports failures clearly
- All profiles supported:
aptl.jsonconfiguration drives which profiles are activated—all 6 profile groups work - Extensible: Adding
aptl scenario start/stopwas straightforward because the core domain logic was already separated - Testable: 587+ tests provide confidence in orchestration logic, credential handling, certificate management
- Shared core: Web UI backend can import
src/aptl/core/directly instead of shelling out to the CLI
Negative¶
- Python dependency: Users must have Python 3.11+ and install the package (
pip install -e .). The bash script had no dependencies beyond Docker. - Two systems:
start-lab.shwas retained as an alternative through v4.2.1, creating confusion about which was authoritative. It was removed in v4.2.2. - Startup overhead: Python interpreter startup adds ~1 second vs. bash. Negligible compared to Docker operations but noticeable for
aptl --help.
Risks¶
- The
subprocess.run()calls todocker composeare a fragile interface—changes to Docker Compose's CLI, output format, or exit codes can break the orchestration. The NDJSON parsing bug (v4.5.0) was an example. - Credential synchronization uses regex to modify Wazuh configuration XML files. A polynomial backtracking (ReDoS) vulnerability was found and fixed in v4.6.5. XML manipulation via regex remains fragile.
- The startup sequence is serial. Its later steps could potentially be parallelized for faster startup, but the dependency ordering makes this non-trivial.