diff --git a/.cargo/config.toml b/.cargo/config.toml index 1b4ec68d..b43df2e7 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -2,7 +2,7 @@ lint = "run --bin linter all" e2e-full = "run --bin e2e-tests-full" e2e-provision = "run --bin e2e-provision-tests" -e2e-config = "run --bin e2e-config-tests" +e2e-config = "run --bin e2e-config-and-release-tests" cov = "llvm-cov" cov-check = "llvm-cov --all-features --workspace --fail-under-lines 70" cov-lcov = "llvm-cov --lcov --output-path=./.coverage/lcov.info" diff --git a/.github/workflows/test-e2e-config.yml b/.github/workflows/test-e2e-config.yml index 7330ffcb..80bce65c 100644 --- a/.github/workflows/test-e2e-config.yml +++ b/.github/workflows/test-e2e-config.yml @@ -22,7 +22,7 @@ on: workflow_dispatch: # Allow manual triggering jobs: - e2e-config-tests: + e2e-config-and-release-tests: runs-on: ubuntu-latest timeout-minutes: 45 # Timeout for complete configuration testing with software installation @@ -62,16 +62,16 @@ jobs: chmod 600 fixtures/testing_rsa ls -la fixtures/testing_rsa - - name: Build E2E configuration tests binary + - name: Build E2E configuration and release tests binary run: | - cargo build --bin e2e-config-tests --release + cargo build --bin e2e-config-and-release-tests --release - - name: Run E2E configuration test + - name: Run E2E configuration and release test run: | - # Run the E2E configuration test with debug logging for better debugging - echo "🚀 Starting E2E configuration test at $(date)" - cargo run --bin e2e-config-tests - echo "✅ E2E configuration test completed at $(date)" + # Run the E2E configuration and release test with debug logging for better debugging + echo "🚀 Starting E2E configuration and release test at $(date)" + cargo run --bin e2e-config-and-release-tests + echo "✅ E2E configuration and release test completed at $(date)" env: # Preserve environment variables for the E2E test RUST_LOG: debug diff --git a/AGENTS.md b/AGENTS.md index 88273e60..8d7ce735 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,8 +130,8 @@ These principles should guide all development decisions, code reviews, and featu - **E2E Tests**: - `cargo run --bin e2e-tests-full` - Comprehensive tests (⚠️ **LOCAL ONLY** - cannot run on GitHub Actions due to network connectivity issues) - `cargo run --bin e2e-provision-and-destroy-tests` - Infrastructure provisioning and destruction tests (GitHub runner-compatible) - - `cargo run --bin e2e-config-tests` - Software installation and configuration tests (GitHub runner-compatible) - - Pre-commit hook runs the split tests (`e2e-provision-and-destroy-tests` + `e2e-config-tests`) for GitHub Copilot compatibility + - `cargo run --bin e2e-config-and-release-tests` - Software installation, configuration, release, and run workflow tests (GitHub runner-compatible) + - Pre-commit hook runs the split tests (`e2e-provision-and-destroy-tests` + `e2e-config-and-release-tests`) for GitHub Copilot compatibility - See [`docs/e2e-testing.md`](docs/e2e-testing.md) for detailed information about CI limitations Follow the project conventions and ensure all checks pass. diff --git a/Cargo.toml b/Cargo.toml index 6ffedddc..e3ababb2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,8 +25,8 @@ name = "e2e-tests-full" path = "src/bin/e2e_tests_full.rs" [[bin]] -name = "e2e-config-tests" -path = "src/bin/e2e_config_tests.rs" +name = "e2e-config-and-release-tests" +path = "src/bin/e2e_config_and_release_tests.rs" [[bin]] name = "e2e-provision-and-destroy-tests" diff --git a/README.md b/README.md index 8c4653b1..3cd55235 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ Use the E2E test binaries to run automated infrastructure tests with hardcoded e cargo run --bin e2e-tests-full # Run individual E2E test suites -cargo run --bin e2e-config-tests # Configuration generation and validation tests +cargo run --bin e2e-config-and-release-tests # Configuration, release, and run workflow tests cargo run --bin e2e-provision-and-destroy-tests # Infrastructure provisioning tests # Keep the test environment after completion for inspection diff --git a/docker/provisioned-instance/Dockerfile b/docker/provisioned-instance/Dockerfile index fa2fe9e8..db947e66 100644 --- a/docker/provisioned-instance/Dockerfile +++ b/docker/provisioned-instance/Dockerfile @@ -33,10 +33,17 @@ RUN apt-get update && apt-get install -y \ lsb-release \ # APT transport for HTTPS repositories apt-transport-https \ + # iptables for Docker networking + iptables \ # Clean up package cache && apt-get clean \ && rm -rf /var/lib/apt/lists/* +# Install Docker using official installation script +# This installs both Docker daemon and Docker Compose plugin +RUN curl -fsSL https://get.docker.com | sh \ + && rm -rf /var/lib/apt/lists/* + # Configure SSH server RUN mkdir -p /var/run/sshd \ # Configure SSH for password authentication initially (tests will set up keys later) @@ -45,7 +52,7 @@ RUN mkdir -p /var/run/sshd \ && sed -i 's/#PasswordAuthentication yes/PasswordAuthentication yes/' /etc/ssh/sshd_config # Create torrust user matching LXD VM configuration -RUN useradd -m -s /bin/bash -G sudo torrust \ +RUN useradd -m -s /bin/bash -G sudo,docker torrust \ # Set a simple password for initial SSH access (tests will set up keys later) && echo "torrust:torrust123" | chpasswd \ # Configure passwordless sudo diff --git a/docker/provisioned-instance/supervisord.conf b/docker/provisioned-instance/supervisord.conf index a334936f..35eca457 100644 --- a/docker/provisioned-instance/supervisord.conf +++ b/docker/provisioned-instance/supervisord.conf @@ -8,13 +8,21 @@ logfile_maxbytes=50MB logfile_backups=10 loglevel=info +[program:dockerd] +command=/usr/bin/dockerd --host=unix:///var/run/docker.sock --storage-driver=vfs +stdout_logfile=/var/log/supervisor/dockerd.log +stderr_logfile=/var/log/supervisor/dockerd.log +autorestart=true +startretries=3 +priority=5 + [program:sshd] command=/usr/sbin/sshd -D stdout_logfile=/var/log/supervisor/sshd.log stderr_logfile=/var/log/supervisor/sshd.log autorestart=true startretries=3 -priority=1 +priority=10 [unix_http_server] file=/var/run/supervisor.sock diff --git a/docker/ssh-server/Dockerfile b/docker/ssh-server/Dockerfile index a6c7f5a9..443a326c 100644 --- a/docker/ssh-server/Dockerfile +++ b/docker/ssh-server/Dockerfile @@ -28,7 +28,9 @@ RUN ssh-keygen -A # Create test user for SSH authentication RUN adduser -D -s /bin/sh testuser && \ echo 'testuser:testpass' | chpasswd && \ - addgroup testuser wheel + addgroup testuser wheel && \ + # Enable passwordless sudo for wheel group (required for deployment testing) + echo '%wheel ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers # Configure SSH daemon RUN mkdir -p /run/sshd && \ diff --git a/docs/codebase-architecture.md b/docs/codebase-architecture.md index ed07de90..dd5a1a54 100644 --- a/docs/codebase-architecture.md +++ b/docs/codebase-architecture.md @@ -209,7 +209,7 @@ Application initialization and lifecycle management: **Binary Files:** - ✅ `src/bin/linter.rs` - Code quality linting binary -- ✅ `src/bin/e2e-config-tests.rs` - E2E configuration tests +- ✅ `src/bin/e2e-config-and-release-tests.rs` - E2E configuration and release tests - ✅ `src/bin/e2e-provision-and-destroy-tests.rs` - E2E provisioning and destruction tests - ✅ `src/bin/e2e-tests-full.rs` - Full E2E test suite diff --git a/docs/contributing/commit-process.md b/docs/contributing/commit-process.md index 76494aca..ab2d46f2 100644 --- a/docs/contributing/commit-process.md +++ b/docs/contributing/commit-process.md @@ -135,7 +135,7 @@ This script runs all mandatory checks: 3. **Run tests**: `cargo test` 4. **Test documentation builds**: `cargo doc --no-deps --bins --examples --workspace --all-features` 5. **Run E2E provision and destroy tests**: `cargo run --bin e2e-provision-and-destroy-tests` -6. **Run E2E configuration tests**: `cargo run --bin e2e-config-tests` +6. **Run E2E configuration and release tests**: `cargo run --bin e2e-config-and-release-tests` **Note**: Code coverage is checked automatically in CI via GitHub Actions, not in the pre-commit script, to keep local commits fast and efficient. diff --git a/docs/contributing/copilot-agent/pre-commit-config.md b/docs/contributing/copilot-agent/pre-commit-config.md index 88b10d0c..f9e0e8e9 100644 --- a/docs/contributing/copilot-agent/pre-commit-config.md +++ b/docs/contributing/copilot-agent/pre-commit-config.md @@ -157,8 +157,8 @@ The pre-commit script will inform you about skipped tests and provide commands t # Run E2E provision tests (~44s) cargo run --bin e2e-provision-and-destroy-tests -# Run E2E config tests (~48s) -cargo run --bin e2e-config-tests +# Run E2E config and release tests (~48s) +cargo run --bin e2e-config-and-release-tests # Check coverage manually (if needed) cargo cov-check diff --git a/docs/contributing/templates.md b/docs/contributing/templates.md index dd2e8ed7..f22ccc2d 100644 --- a/docs/contributing/templates.md +++ b/docs/contributing/templates.md @@ -205,8 +205,8 @@ tracing::debug!( Run E2E tests to verify the playbook is copied correctly: ```bash -# Run E2E config tests (faster, tests configuration only) -cargo run --bin e2e-config-tests +# Run E2E config and release tests (faster, tests configuration only) +cargo run --bin e2e-config-and-release-tests # Or run full E2E tests cargo run --bin e2e-tests-full @@ -284,8 +284,8 @@ When creating new Ansible playbooks that need dynamic variables (ports, paths, e ```yaml # System Configuration -ssh_port: {{ ssh_port }} -my_service_port: {{ my_service_port }} # ← Add your new variable +ssh_port: { { ssh_port } } +my_service_port: { { my_service_port } } # ← Add your new variable ``` **Reference variables in static playbook using `vars_files`:** @@ -296,8 +296,8 @@ my_service_port: {{ my_service_port }} # ← Add your new variable - name: Configure My Service hosts: all vars_files: - - variables.yml # Load centralized variables - + - variables.yml # Load centralized variables + tasks: - name: Configure service port ansible.builtin.lineinfile: diff --git a/docs/decisions/docker-in-docker-for-e2e-tests.md b/docs/decisions/docker-in-docker-for-e2e-tests.md new file mode 100644 index 00000000..f31461bf --- /dev/null +++ b/docs/decisions/docker-in-docker-for-e2e-tests.md @@ -0,0 +1,251 @@ +# ADR: Docker-in-Docker Support for E2E Tests + +## Status + +Accepted (Implemented) + +## Context + +The `run` command starts Docker Compose services on the target VM. To test this in the +E2E configuration tests (`e2e_config_and_release_tests.rs`), we need Docker to be +available inside the test container that simulates a provisioned VM. + +Previously, the `provisioned-instance` Docker container did not have Docker installed +or available, so the `run` command could not be tested. We had a workaround +(`TORRUST_TD_SKIP_RUN_IN_CONTAINER`) to skip the `run` command in container-based tests, +but this meant we could not verify the complete deployment workflow in E2E tests. + +### Requirements + +1. Docker Compose must be able to run inside the test container +2. The solution must work on GitHub Actions CI/CD +3. The solution should not significantly increase test time +4. Security considerations should be documented + +## Decision Drivers + +- **Test Coverage**: We want to test the complete deployment workflow including `run` +- **CI Compatibility**: Must work on GitHub Actions runners +- **Simplicity**: Prefer simpler solutions that are easier to maintain +- **Security**: Understand and document security implications + +## Considered Options + +### Option 1: Docker-in-Docker (DinD) with Privileged Mode + +Run a full Docker daemon inside the container using `--privileged` mode. + +**Changes Required**: + +1. Install Docker daemon in `docker/provisioned-instance/Dockerfile` +2. Add Docker daemon to supervisor configuration +3. Run container with `--privileged` flag in testcontainers +4. Wait for Docker daemon to be ready before running tests + +**Pros**: + +- Complete isolation - containers created inside are truly nested +- Realistic simulation of a VM with Docker installed +- No side effects on host Docker + +**Cons**: + +- Requires `--privileged` flag (security concern) +- Docker daemon startup adds ~5-10 seconds to test time +- More complex supervisor configuration +- May have issues on some CI environments + +### Option 2: Docker Socket Mounting (DooD) + +Mount the host's Docker socket (`/var/run/docker.sock`) into the container. + +**Changes Required**: + +1. Install Docker CLI (not daemon) in `docker/provisioned-instance/Dockerfile` +2. Mount Docker socket when starting container in testcontainers +3. Add `torrust` user to `docker` group + +**Pros**: + +- Simpler setup - no daemon to manage +- Faster - no Docker daemon startup time +- No `--privileged` flag needed +- Works reliably on most CI environments + +**Cons**: + +- Containers created are siblings, not children +- Shares host Docker daemon (potential resource conflicts) +- Container names may conflict with host containers +- Less isolation + +### Option 3: Use Real VMs for Complete Testing (Current Approach) + +Keep the skip flag and only test `run` command with real LXD VMs. + +**Changes Required**: + +- None - keep current implementation +- Document that `run` is only tested in `e2e_tests_full.rs` and manual tests + +**Pros**: + +- No additional complexity +- Tests real VM behavior +- No security concerns + +**Cons**: + +- Incomplete E2E test coverage in container-based tests +- `run` command not tested in CI + +## Decision + +### Chosen Option: Option 1 - Docker-in-Docker (DinD) with Privileged Mode + +We choose DinD because: + +1. **Realistic Testing**: The container accurately simulates a VM with Docker installed +2. **Complete Isolation**: No interference with host Docker or other tests +3. **CI Compatibility**: GitHub Actions supports privileged containers +4. **Consistent Behavior**: Same Docker version inside container regardless of host + +The `--privileged` flag is acceptable for test containers because: + +- Test containers are ephemeral and isolated +- No untrusted code runs inside +- GitHub Actions already runs with elevated privileges + +## Implementation Plan + +### Phase 1: Update Dockerfile (Completed) + +Add Docker installation to `docker/provisioned-instance/Dockerfile`: + +```dockerfile +# Install iptables for Docker networking +RUN apt-get update && apt-get install -y iptables + +# Install Docker using official installation script +# This installs both Docker daemon and Docker Compose plugin +RUN curl -fsSL https://get.docker.com | sh \ + && rm -rf /var/lib/apt/lists/* + +# Create torrust user with docker group membership +RUN useradd -m -s /bin/bash -G sudo,docker torrust +``` + +### Phase 2: Update Supervisor Configuration (Completed) + +Add Docker daemon to `supervisord.conf` with `vfs` storage driver: + +```ini +[program:dockerd] +command=/usr/bin/dockerd --host=unix:///var/run/docker.sock --storage-driver=vfs +stdout_logfile=/var/log/supervisor/dockerd.log +stderr_logfile=/var/log/supervisor/dockerd.log +autorestart=true +startretries=3 +priority=5 +``` + +**Important**: The `vfs` storage driver is required because the default `overlay2` driver +does not work in nested Docker environments (fails with "invalid argument" error). +The `vfs` driver is slower but compatible with Docker-in-Docker scenarios. + +### Phase 3: Update Container Startup (Completed) + +Modify testcontainers configuration in `src/testing/e2e/containers/provisioned.rs`: + +1. Use `with_privileged(true)` from `testcontainers::ImageExt` trait +2. Wait for Docker daemon to be ready by checking for `dockerd entered RUNNING state` + in supervisor logs (instead of waiting for sshd) + +```rust +// Wait for Docker daemon to be ready (not just SSH) +.with_wait_condition(WaitFor::message_on_stdout("dockerd entered RUNNING state")) + +// Start with privileged mode for Docker-in-Docker +image.with_privileged(true).start().await +``` + +### Phase 4: Handle Docker Package Conflicts (Completed) + +A challenge discovered during implementation: Ansible's Docker installation playbook +installs `docker.io` package, which conflicts with Docker CE (`containerd.io`) installed +via `get.docker.com` script in the Dockerfile. + +**Solution**: Add environment variable `TORRUST_TD_SKIP_DOCKER_INSTALL_IN_CONTAINER` +to skip Docker/Docker Compose installation via Ansible when Docker is already +pre-installed in the container. + +In `src/bin/e2e_config_and_release_tests.rs`: + +```rust +// SAFETY: Set before async runtime starts +unsafe { + std::env::set_var("TORRUST_TD_SKIP_DOCKER_INSTALL_IN_CONTAINER", "true"); +} +``` + +In `src/application/command_handlers/configure/handler.rs`: + +```rust +let skip_docker = std::env::var("TORRUST_TD_SKIP_DOCKER_INSTALL_IN_CONTAINER") + .map(|v| v == "true") + .unwrap_or(false); + +if skip_docker { + // Skip Docker and Docker Compose installation steps +} +``` + +### Phase 5: Remove Skip Flag (Completed) + +Removed `TORRUST_TD_SKIP_RUN_IN_CONTAINER` environment variable since the `run` command +now works in container-based E2E tests. + +## Consequences + +### Positive + +- Complete E2E test coverage for all commands including `run` +- Tests verify real Docker Compose behavior +- CI/CD validates complete deployment workflow + +### Negative + +- Test containers require `--privileged` mode +- Slightly longer test startup time (~2-3 seconds for Docker daemon) +- More complex container configuration +- Must use `vfs` storage driver (slower than `overlay2`) + +### Neutral + +- Need to document privileged mode requirement +- Docker installation skipped via environment variable to avoid package conflicts + +## Lessons Learned + +1. **Storage Driver Compatibility**: The `overlay2` storage driver does not work in + nested Docker environments. The `vfs` driver must be used, which is slower but + compatible. + +2. **Package Conflicts**: Docker CE (`containerd.io` from `get.docker.com`) conflicts + with `docker.io` package. When Docker is pre-installed in the container, Ansible's + Docker installation must be skipped. + +3. **Rust 1.81+ Safety**: `std::env::set_var` is now `unsafe` in Rust 1.81+. Environment + variables must be set in an `unsafe` block with a safety comment explaining why + concurrent access is not possible. + +4. **Wait Conditions**: Waiting for `dockerd entered RUNNING state` in supervisor logs + is more reliable than waiting for SSH, as it ensures Docker is ready before tests run. + +## References + +- [Docker-in-Docker Documentation](https://hub.docker.com/_/docker) +- [GitHub Actions Container Support](https://docs.github.com/en/actions/using-containerized-services/about-service-containers) +- [testcontainers-rs Privileged Mode](https://docs.rs/testcontainers/latest/testcontainers/) +- [Docker Storage Drivers](https://docs.docker.com/storage/storagedriver/select-storage-driver/) +- [Rust 1.81 set_var Safety](https://doc.rust-lang.org/stable/std/env/fn.set_var.html) diff --git a/docs/decisions/docker-testing-evolution.md b/docs/decisions/docker-testing-evolution.md index 8bf015fe..3d6c6fd9 100644 --- a/docs/decisions/docker-testing-evolution.md +++ b/docs/decisions/docker-testing-evolution.md @@ -343,7 +343,7 @@ The **fundamental assumptions** in the original decision no longer applied: This hybrid approach has been **successfully implemented**: - ✅ `e2e-provision-and-destroy-tests` binary - Tests infrastructure provisioning and destruction lifecycle with LXD VMs -- ✅ `e2e-config-tests` binary - Tests configuration with Docker containers +- ✅ `e2e-config-and-release-tests` binary - Tests configuration and release with Docker containers - ✅ `e2e-tests-full` binary - Complete local testing (LXD + Docker) for development ## Monitoring and Review diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md index 56a10e11..83a6aed7 100644 --- a/docs/e2e-testing.md +++ b/docs/e2e-testing.md @@ -25,10 +25,10 @@ cargo run --bin e2e-provision-and-destroy-tests #### Configuration Tests -Test software installation and configuration (Ansible playbooks): +Test software installation, configuration, release, and run workflows (Ansible playbooks): ```bash -cargo run --bin e2e-config-tests +cargo run --bin e2e-config-and-release-tests ``` #### Full Local Testing @@ -59,7 +59,7 @@ cargo run --bin e2e-provision-and-destroy-tests cargo run --bin e2e-provision-and-destroy-tests -- --keep # Run configuration tests with debugging -cargo run --bin e2e-config-tests -- --keep +cargo run --bin e2e-config-and-release-tests -- --keep # Run full local tests with custom templates cargo run --bin e2e-tests-full -- --templates-dir ./custom/templates @@ -147,7 +147,7 @@ For detailed destroy command documentation, see: - [Destroy Command User Guide](user-guide/commands/destroy.md) - [Destroy Command Developer Guide](contributing/commands.md#destroycommand) -### E2E Configuration Tests (`e2e-config-tests`) +### E2E Configuration and Release Tests (`e2e-config-and-release-tests`) Tests software installation and configuration using Docker containers: @@ -321,7 +321,7 @@ docker rmi torrust-provisioned-instance - Validating VM creation and cloud-init setup - Working on provisioning-related features -**Use Configuration Tests (`e2e-config-tests`) when**: +**Use Configuration and Release Tests (`e2e-config-and-release-tests`) when**: - Testing Ansible playbooks and software installation - Validating configuration management changes @@ -372,10 +372,10 @@ cargo run --bin e2e-provision-tests -- --keep lxc exec torrust-tracker-vm -- /bin/bash ``` -#### Configuration Tests Debugging +#### Configuration and Release Tests Debugging ```bash -cargo run --bin e2e-config-tests -- --keep +cargo run --bin e2e-config-and-release-tests -- --keep # After test completion, find and connect to the Docker container: docker ps @@ -576,7 +576,7 @@ For Ansible playbooks or software installation modifications: 1. **Update configuration tests** in `src/bin/e2e_config_tests.rs` 2. **Add validation methods** for new software components 3. **Update Docker image** in `docker/provisioned-instance/` if needed -4. **Test locally**: `cargo run --bin e2e-config-tests` +4. **Test locally**: `cargo run --bin e2e-config-and-release-tests` 5. **Verify CI passes** on `.github/workflows/test-e2e-config.yml` ### End-to-End Integration diff --git a/docs/features/environment-state-management/implementation-plan/phase-2-state-transition-logging.md b/docs/features/environment-state-management/implementation-plan/phase-2-state-transition-logging.md index 3f483d23..873daf3f 100644 --- a/docs/features/environment-state-management/implementation-plan/phase-2-state-transition-logging.md +++ b/docs/features/environment-state-management/implementation-plan/phase-2-state-transition-logging.md @@ -128,7 +128,7 @@ mod tests { Existing E2E tests will automatically capture state transition logs. Verify log output in: - `cargo run --bin e2e-provision-and-destroy-tests` -- `cargo run --bin e2e-config-tests` +- `cargo run --bin e2e-config-and-release-tests` - `cargo run --bin e2e-tests-full` Expected log output: diff --git a/docs/features/environment-state-management/implementation-plan/phase-5-command-integration.md b/docs/features/environment-state-management/implementation-plan/phase-5-command-integration.md index fb36619f..a106664b 100644 --- a/docs/features/environment-state-management/implementation-plan/phase-5-command-integration.md +++ b/docs/features/environment-state-management/implementation-plan/phase-5-command-integration.md @@ -695,7 +695,7 @@ impl ConfigureCommand { } ``` -2. **Update E2E Config Test binary** (`src/bin/e2e-config-tests.rs`): +2. **Update E2E Config and Release Test binary** (`src/bin/e2e-config-and-release-tests.rs`): ```rust fn run_configure_test() -> Result<(), Box> { diff --git a/docs/issues/217-demo-slice-release-run-commands.md b/docs/issues/217-demo-slice-release-run-commands.md index ef43ca29..6863c178 100644 --- a/docs/issues/217-demo-slice-release-run-commands.md +++ b/docs/issues/217-demo-slice-release-run-commands.md @@ -9,15 +9,15 @@ This task implements the foundational scaffolding for the `release` and `run` co ## Goals -- [ ] Create `ReleaseCommandHandler` (App layer) with state transitions -- [ ] Create `RunCommandHandler` (App layer) with state transitions -- [ ] Create `release` CLI subcommand (Presentation layer) -- [ ] Create `run` CLI subcommand (Presentation layer) -- [ ] Add docker-compose file infrastructure -- [ ] Deploy and run demo-app (nginx) container on provisioned VM -- [ ] Verify container is running and healthy -- [ ] Rename `e2e_config_tests.rs` → `e2e_config_and_release_tests.rs` and extend -- [ ] Update `e2e_tests_full.rs` to include release and run commands +- [x] Create `ReleaseCommandHandler` (App layer) with state transitions +- [x] Create `RunCommandHandler` (App layer) with state transitions +- [x] Create `release` CLI subcommand (Presentation layer) +- [x] Create `run` CLI subcommand (Presentation layer) +- [x] Add docker-compose file infrastructure +- [x] Deploy and run demo-app (nginx) container on provisioned VM +- [x] Verify container is running and healthy +- [x] Rename `e2e_config_tests.rs` → `e2e_config_and_release_tests.rs` and extend +- [x] Update `e2e_tests_full.rs` to include release and run commands ## Architecture Overview @@ -179,7 +179,7 @@ path = "src/bin/e2e_config_and_release_tests.rs" ```bash # Change from: -cargo run --bin e2e-config-tests +cargo run --bin e2e-config-and-release-tests # To: cargo run --bin e2e-config-and-release-tests @@ -337,53 +337,72 @@ cat build/e2e-config/state.json cargo run -- destroy e2e-config ``` -### Phase 6: Steps Layer - First Step (Prepare Compose Files) +### Phase 6: Steps Layer - First Step (Prepare Compose Files) ✅ COMPLETE -**Location**: `src/application/steps/application/` +**Location**: `src/application/steps/application/release.rs` -Create first step: +Implemented as part of the `ReleaseStep`: -- `prepare_compose_files.rs` - Copy static docker-compose.yml to build directory +- `ReleaseStep::execute()` - Uses `DockerComposeTemplateRenderer` to copy docker-compose.yml to build directory -**Deliverable**: `release` command generates files in build dir (E2E verifiable). +**Deliverable**: `release` command generates files in build dir (E2E verifiable). ✅ -**Manual E2E Test**: +**Manual E2E Test**: ✅ Verified ```bash # Setup: Create and configure environment -cargo run -- create environment --env-file envs/e2e-config.json -cargo run -- provision e2e-config -cargo run -- configure e2e-config +cargo run -- create environment --env-file envs/e2e-full.json +cargo run -- provision e2e-full +cargo run -- configure e2e-full # Release should now generate docker-compose files -cargo run -- release e2e-config +cargo run -- release e2e-full # Verify docker-compose.yml was created in build directory -cat build/e2e-config/docker-compose/docker-compose.yml -# Expected: Valid docker-compose.yml with demo-app service - -# Verify file contents match source -diff data/e2e-config/templates/docker-compose/docker-compose.yml build/e2e-config/docker-compose/docker-compose.yml -# Expected: No differences (or expected differences) +cat build/e2e-full/docker-compose/docker-compose.yml +# Expected: Valid docker-compose.yml with demo-app service (nginx:alpine) # Cleanup -cargo run -- destroy e2e-config +cargo run -- destroy e2e-full ``` -### Phase 7: Infrastructure Layer - Docker Compose File Manager +### Phase 7: Infrastructure Layer - Docker Compose Template Renderer ✅ COMPLETE + +> **Status**: Implemented during Phase 6 as part of the release step integration. +> The `DockerComposeTemplateRenderer` was created following the same pattern as +> `AnsibleTemplateRenderer` and `TofuTemplateRenderer`. -**Location**: `src/infrastructure/external_tools/docker_compose/` +**Location**: `src/infrastructure/external_tools/docker_compose/template/renderer/mod.rs` -Create `DockerComposeFileManager`: +Following the established pattern from Ansible and Tofu external tools, we created: -- Copy static `docker-compose.yml` to build directory +```text +src/infrastructure/external_tools/docker_compose/ +├── mod.rs # Module exports and DOCKER_COMPOSE_SUBFOLDER constant +└── template/ + ├── mod.rs # Template module exports + └── renderer/ + └── mod.rs # DockerComposeTemplateRenderer implementation +``` + +**Created `DockerComposeTemplateRenderer`**: + +- Follows the `template/renderer/` folder structure pattern +- Uses `TemplateManager` for on-demand extraction from embedded templates +- Copies static `docker-compose.yml` to build directory - (Future: generate `.env` file) -**Deliverable**: Build directory contains correct docker-compose.yml. +**Deliverable**: Build directory contains correct docker-compose.yml. ✅ + +#### Docker Compose Source Template -#### Docker Compose Source Files +**Location**: `templates/docker-compose/docker-compose.yml` (embedded in binary at compile time) -**Location**: `data/{environment}/templates/docker-compose/` (following existing pattern for environment-specific templates) +Templates are embedded using rust-embed and extracted on-demand via `TemplateManager`: + +1. **Source**: `templates/docker-compose/docker-compose.yml` (embedded at compile time) +2. **Extracted to**: `{env_templates_dir}/docker-compose/docker-compose.yml` (on first use) +3. **Copied to**: `build/{env_name}/docker-compose/docker-compose.yml` (during release) For this demo slice, we use a **simple long-running service** that better emulates real application behavior (unlike `hello-world` which exits immediately): @@ -432,132 +451,484 @@ This means we likely don't need Tera templates for compose files. Instead: **Future Consideration**: For enhanced security, we could switch to runtime variable injection via `docker compose up --env-file` or shell environment variables, avoiding secrets stored in files on the VM. -**Naming Convention**: Following the existing patterns (`AnsibleTemplateRenderer`, `TofuTemplateRenderer`), we'll start with `DockerComposeFileManager` for now. These renderer classes manage **all templates** for their respective tools, not single files. If we later need Tera template resolution for docker-compose files (e.g., dynamic service definitions), we can rename to `DockerComposeTemplateRenderer`. +**Naming Convention**: Following the existing patterns (`AnsibleTemplateRenderer`, `TofuTemplateRenderer`), we named it `DockerComposeTemplateRenderer` to maintain consistency. The class handles all template management for Docker Compose files using the embedded template system. -**Manual E2E Test**: +**Manual E2E Test**: ✅ Verified ```bash -# Same as Phase 5 - verify file manager correctly copies files -cargo run -- create environment --env-file envs/e2e-config.json -cargo run -- provision e2e-config -cargo run -- configure e2e-config -cargo run -- release e2e-config +# Same as Phase 6 - verify renderer correctly copies files +cargo run -- create environment --env-file envs/e2e-full.json +cargo run -- provision e2e-full +cargo run -- configure e2e-full +cargo run -- release e2e-full -# Verify docker-compose.yml structure and content -cat build/e2e-config/docker-compose/docker-compose.yml +# Verify docker-compose.yml was created in build directory +cat build/e2e-full/docker-compose/docker-compose.yml +# Expected: Valid docker-compose.yml with demo-app service (nginx:alpine) # Verify it's valid YAML -python3 -c "import yaml; yaml.safe_load(open('build/e2e-config/docker-compose/docker-compose.yml'))" +python3 -c "import yaml; yaml.safe_load(open('build/e2e-full/docker-compose/docker-compose.yml'))" # Expected: No errors # Cleanup -cargo run -- destroy e2e-config +cargo run -- destroy e2e-full ``` -### Phase 8: Steps Layer - Transfer Files to VM +### Phase 8: Refactor Release Handler to Follow Provision Handler Patterns ✅ COMPLETE -**Location**: `src/application/steps/application/` +> **Status**: ✅ COMPLETE +> +> The `ReleaseCommandHandler` has been refactored to follow the established patterns from +> `ProvisionCommandHandler`. All components have been implemented and tested. -Create step: +#### Implementation Summary -- `transfer_files.rs` - Transfer release files to VM via SSH +The refactoring aligned the release handler with the codebase architecture: -**Location**: `src/adapters/ssh/` or `src/infrastructure/remote_actions/` +| Aspect | Before Refactor | After Refactor | +| ---------------------- | -------------------------------- | ---------------------------------------- | +| **Repository Type** | `Arc` | `TypedEnvironmentRepository` ✅ | +| **Error Handling** | Simple `Result` | Step tracking with `ReleaseStep` enum ✅ | +| **Failure State** | No failure state transition | `ReleaseFailed` with context ✅ | +| **Trace Files** | None | `ReleaseTraceWriter` ✅ | +| **File Transfer** | Direct SSH (`sudo tee`) | Ansible playbook ✅ | +| **Workflow Structure** | Single `release_step.execute()` | Multi-phase workflow ✅ | -Add file transfer capability: +#### What Was Implemented -- SCP-based file transfer using SSH client -- Create target directories on VM -- Handle permissions +**1. `ReleaseStep` Enum for Step Tracking** ✅ -**Target directory on VM**: `/opt/torrust/` (configurable) +```rust +// src/domain/environment/state/release_failed.rs +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ReleaseStep { + RenderDockerComposeTemplates, + DeployComposeFiles, +} +``` -**Deliverable**: `release` command copies files to VM (E2E verifiable via SSH). +**2. `ReleaseFailureContext`** ✅ -**Manual E2E Test**: +```rust +// src/domain/environment/state/release_failed.rs +pub struct ReleaseFailureContext { + pub failed_step: ReleaseStep, + pub error_kind: ErrorKind, + pub base: BaseFailureContext, +} +``` -```bash -# Setup: Full pipeline to configured state -cargo run -- create environment --env-file envs/e2e-config.json -cargo run -- provision e2e-config -cargo run -- configure e2e-config +**3. `TypedEnvironmentRepository`** ✅ -# Release should now transfer files to VM -cargo run -- release e2e-config +```rust +// src/application/command_handlers/release/handler.rs +pub struct ReleaseCommandHandler { + clock: Arc, + repository: TypedEnvironmentRepository, +} +``` -# Get VM IP from tofu output -VM_IP=$(cd build/e2e-config/tofu && tofu output -raw instance_ip) +**4. Multi-Phase Workflow** ✅ + +The handler now executes two distinct steps: + +- `RenderDockerComposeTemplatesStep`: Renders templates to build directory +- `DeployComposeFilesStep`: Deploys files to remote via Ansible + +**5. `ReleaseTraceWriter`** ✅ + +```rust +// src/infrastructure/trace/writer/commands/release.rs +pub struct ReleaseTraceWriter { + traces_dir: PathBuf, + clock: Arc, +} +``` + +**6. Failure State Transitions** ✅ + +On failure, the handler: + +- Builds `ReleaseFailureContext` with step information +- Writes trace file via `ReleaseTraceWriter` +- Transitions to `ReleaseFailed` state +- Saves state via repository + +#### Three-Level Architecture for File Transfer ✅ + +The implementation follows the codebase architecture (Command → Step → Remote Action): + +```text +┌─────────────────────────────────────────────────────────────────┐ +│ Level 1: ReleaseCommandHandler │ +│ - Orchestrates release workflow │ +│ - Manages state transitions (Releasing → Released/ReleaseFailed)│ +│ - Handles failure context and trace files │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Level 2: Steps │ +│ - RenderDockerComposeTemplatesStep: Render templates to build │ +│ - DeployComposeFilesStep: Execute Ansible playbook │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Level 3: Remote Actions (Ansible Playbook) │ +│ - deploy-compose-files.yml: Copy files to /opt/torrust/ │ +│ - Uses Ansible copy module (idempotent, permissions, backup) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +#### Why Ansible for File Transfer? + +| Aspect | Direct SSH (`sudo tee`) | Ansible Playbook | +| ---------------------- | ----------------------- | --------------------------- | +| **Consistency** | Different pattern | Matches provision/configure | +| **Idempotency** | No (overwrites) | Yes (copy module) | +| **Permissions** | Manual chmod | Built into copy module | +| **Directory Creation** | Separate mkdir | Automatic | +| **Error Handling** | Basic success/fail | Rich changed/ok/failed | +| **Logging** | Manual tracing | Detailed Ansible output | +| **Extensibility** | Code changes | Playbook changes | +| **Ops-Friendly** | Rust code only | Playbook can be inspected | + +#### Ansible Playbook ✅ + +**Location**: `templates/ansible/deploy-compose-files.yml` (embedded at compile time) + +```yaml +--- +# Deploy Docker Compose Files Playbook +# Copies the local docker-compose build folder to the remote host + +- name: Deploy Docker Compose Files + hosts: all + gather_facts: false + become: true + + vars: + remote_deploy_dir: /opt/torrust + local_compose_dir: "{{ compose_files_source_dir }}" + + tasks: + - name: 📦 Starting Docker Compose files deployment + ansible.builtin.debug: + msg: "🚀 Deploying Docker Compose files to {{ inventory_hostname }}:{{ remote_deploy_dir }}" + + - name: Ensure remote deployment directory exists + ansible.builtin.file: + path: "{{ remote_deploy_dir }}" + state: directory + mode: "0755" + owner: root + group: root + + - name: Copy Docker Compose files to remote host + ansible.builtin.copy: + src: "{{ local_compose_dir }}/" + dest: "{{ remote_deploy_dir }}/" + mode: "0644" + directory_mode: "0755" + owner: root + group: root + + - name: Verify docker-compose.yml exists on remote + ansible.builtin.stat: + path: "{{ remote_deploy_dir }}/docker-compose.yml" + register: compose_file_check + + - name: Fail if docker-compose.yml was not deployed + ansible.builtin.fail: + msg: "docker-compose.yml was not found at {{ remote_deploy_dir }}/docker-compose.yml after deployment" + when: not compose_file_check.stat.exists + + - name: Display deployment summary + ansible.builtin.debug: + msg: | + ✅ Docker Compose files deployed successfully! + 📁 Destination: {{ remote_deploy_dir }} + 📄 Files deployed: {{ deployed_files.files | length }} +``` + +#### `DeployComposeFilesStep` ✅ + +**Location**: `src/application/steps/application/deploy_compose_files.rs` + +```rust +/// Step that deploys Docker Compose files to a remote host via Ansible +/// +/// This step uses the `deploy-compose-files.yml` playbook to: +/// - Create the deployment directory on the remote host +/// - Copy docker-compose.yml with proper permissions +/// - Verify successful deployment +pub struct DeployComposeFilesStep { + ansible_client: Arc, + compose_build_dir: PathBuf, +} +``` + +#### Files Created/Modified ✅ + +**New Files Created**: + +- `src/domain/environment/state/release_failed.rs` ✅ - `ReleaseFailed` state, `ReleaseStep` enum, and `ReleaseFailureContext` +- `src/infrastructure/trace/writer/commands/release.rs` ✅ - `ReleaseTraceWriter` +- `src/application/steps/application/deploy_compose_files.rs` ✅ - `DeployComposeFilesStep` +- `src/application/steps/rendering/docker_compose_templates.rs` ✅ - `RenderDockerComposeTemplatesStep` +- `templates/ansible/deploy-compose-files.yml` ✅ - Ansible playbook + +**Modified Files**: + +- `src/application/command_handlers/release/handler.rs` ✅ - Refactored to match provision patterns +- `src/application/command_handlers/release/errors.rs` ✅ - Added step-aware errors +- `src/domain/environment/state/mod.rs` ✅ - Export new state types +- `src/infrastructure/external_tools/ansible/template/renderer/mod.rs` ✅ - Registered new playbook + +**Removed Files** (deprecated code cleanup): + +- `src/application/steps/application/release.rs` ❌ - Removed deprecated `ReleaseStep` class +- `src/adapters/ssh/client.rs` - Removed `write_remote_file` method (no longer needed) + +#### Deliverables ✅ + +All deliverables completed: -# SSH into VM and verify files were transferred -ssh -i fixtures/testing_rsa ubuntu@$VM_IP "cat /opt/torrust/docker-compose.yml" -# Expected: Contents of docker-compose.yml +1. ✅ `ReleaseCommandHandler` follows the same patterns as `ProvisionCommandHandler` +2. ✅ File transfer uses Ansible (consistent with configure command) +3. ✅ Failure states are properly tracked with `ReleaseFailed` and trace files +4. ✅ Step tracking enables precise error reporting +5. ✅ Deprecated code removed (`write_remote_file`, old `ReleaseStep` class) -# Verify directory structure on VM -ssh -i fixtures/testing_rsa ubuntu@$VM_IP "ls -la /opt/torrust/" -# Expected: docker-compose.yml present +**Manual E2E Test**: ✅ Verified -# Verify file permissions -ssh -i fixtures/testing_rsa ubuntu@$VM_IP "stat /opt/torrust/docker-compose.yml" -# Expected: Appropriate permissions for docker compose +```bash +# Setup: Full pipeline to configured state +cargo run -- create environment --env-file envs/e2e-full.json +cargo run -- provision e2e-full +cargo run -- configure e2e-full + +# Release should transfer files via Ansible +cargo run -- release e2e-full + +# Get VM IP +VM_IP=$(cd build/e2e-full/tofu && tofu output -raw instance_ip) + +# Verify files were transferred +ssh -i fixtures/testing_rsa torrust@$VM_IP "cat /opt/torrust/docker-compose.yml" +# Expected: Contents of docker-compose.yml with nginx:alpine service ✅ # Cleanup -cargo run -- destroy e2e-config +cargo run -- destroy e2e-full ``` -### Phase 9: Steps Layer - Start Services +### Phase 9: Steps Layer - Start Services ✅ COMPLETE + +> **Status**: ✅ COMPLETE +> +> The `run` command now starts Docker Compose services on the remote VM via Ansible. +> The `RunCommandHandler` follows the same patterns as `ProvisionCommandHandler` and +> `ReleaseCommandHandler`. **Location**: `src/application/steps/application/` -Create steps: +Created steps: -- `start_services.rs` - Execute `docker compose up -d` on VM -- `verify_services.rs` - Check that containers are running +- `start_services.rs` ✅ - Execute `docker compose up -d` on VM via Ansible playbook -**Deliverable**: `run` command starts containers on VM (E2E verifiable). +#### Implementation Summary -**Manual E2E Test**: +| Component | Description | +| ------------------------------ | -------------------------------------------------- | +| **`run-compose-services.yml`** | Ansible playbook to pull images and start services | +| **`StartServicesStep`** | Step that executes the Ansible playbook | +| **`RunStep` enum** | Step tracking for failure context | +| **`RunFailureContext`** | Structured failure information | +| **`RunTraceWriter`** | Generates trace files on failure | +| **`RunCommandHandler`** | Wired to controller, executes real service start | + +#### Files Created + +- `templates/ansible/run-compose-services.yml` ✅ - Ansible playbook +- `src/application/steps/application/start_services.rs` ✅ - `StartServicesStep` +- `src/domain/environment/state/run_failed.rs` ✅ - `RunStep`, `RunFailureContext` +- `src/infrastructure/trace/writer/commands/run.rs` ✅ - `RunTraceWriter` + +#### Files Modified + +- `src/application/command_handlers/run/handler.rs` ✅ - Full implementation +- `src/presentation/controllers/run/handler.rs` ✅ - Wired to real handler +- `src/presentation/controllers/run/errors.rs` ✅ - Error conversion + +**Deliverable**: `run` command starts containers on VM (E2E verifiable). ✅ + +#### Manual E2E Test Issues Encountered + +During manual E2E testing, several issues were encountered that required workarounds: + +##### Issue 1: `e2e-config` Environment Uses Non-Default SSH Port + +The `envs/e2e-config.json` configuration specifies SSH port `33118`, but cloud-init does not +reconfigure the SSH port. The VM listens on the default port `22`, causing SSH connection failures. + +**Workaround**: Use `envs/e2e-full.json` instead, which does not specify a custom SSH port +and defaults to port `22`. + +##### Issue 2: SSH Username Mismatch + +The original test documentation referenced `ubuntu@$VM_IP`, but the environment configuration +uses `torrust` as the SSH username. + +**Resolution**: Use the correct username from the environment configuration: + +```bash +# Wrong (from original docs) +ssh -i fixtures/testing_rsa ubuntu@$VM_IP "..." + +# Correct (actual configuration) +ssh -i fixtures/testing_rsa torrust@$VM_IP "..." +``` + +##### Issue 3: SSH Key Authentication Failures + +SSH connections failed with "Too many authentication failures" when using the default SSH +command. The SSH agent offers multiple keys before the correct one. + +**Resolution**: Use `-o IdentitiesOnly=yes` to force using only the specified key: + +```bash +ssh -o StrictHostKeyChecking=no -o IdentitiesOnly=yes \ + -i fixtures/testing_rsa torrust@$VM_IP "..." +``` + +##### Issue 4: Container-Based E2E Tests Cannot Run Docker + +The `e2e-config-and-release-tests` binary uses a Docker container that simulates a +provisioned VM. However, Docker daemon is not available inside this container +(no Docker-in-Docker configuration). + +**Resolution**: Added `TORRUST_TD_SKIP_RUN_IN_CONTAINER` environment variable to skip +the `run` command in container-based E2E tests: + +```rust +// In e2e_config_and_release_tests.rs +std::env::set_var("TORRUST_TD_SKIP_RUN_IN_CONTAINER", "true"); +``` + +The `run` command is only fully tested in: + +- Manual E2E tests with real LXD VMs (`e2e-full` environment) +- Future: `e2e_tests_full.rs` (local-only, requires VM network connectivity) + +#### Manual E2E Test (Corrected) ```bash # Setup: Full pipeline to released state -cargo run -- create environment --env-file envs/e2e-config.json -cargo run -- provision e2e-config -cargo run -- configure e2e-config -cargo run -- release e2e-config +# NOTE: Use e2e-full (not e2e-config) to avoid SSH port issues +cargo run -- create environment --env-file envs/e2e-full.json +cargo run -- provision e2e-full +cargo run -- configure e2e-full +cargo run -- release e2e-full # Run should start docker compose services -cargo run -- run e2e-config +cargo run -- run e2e-full -# Get VM IP -VM_IP=$(cd build/e2e-config/tofu && tofu output -raw instance_ip) +# Get VM IP from environment.json (tofu output may not work) +VM_IP=$(cat data/e2e-full/environment.json | grep -o '"instance_ip": "[^"]*"' | cut -d'"' -f4) # Verify containers are running on VM -ssh -i fixtures/testing_rsa ubuntu@$VM_IP "cd /opt/torrust && docker compose ps" -# Expected: demo-app service listed as "running" (healthy) +# NOTE: Use torrust user (not ubuntu) and IdentitiesOnly flag +ssh -o StrictHostKeyChecking=no -o IdentitiesOnly=yes \ + -i fixtures/testing_rsa torrust@$VM_IP \ + "cd /opt/torrust && docker compose ps" +# Expected: demo-app service listed as "running" (healthy) ✅ # Verify service is accessible -ssh -i fixtures/testing_rsa ubuntu@$VM_IP "curl -s http://localhost:8080" -# Expected: nginx welcome page HTML +ssh -o StrictHostKeyChecking=no -o IdentitiesOnly=yes \ + -i fixtures/testing_rsa torrust@$VM_IP \ + "curl -s http://localhost:8080" | head -20 +# Expected: nginx welcome page HTML ✅ -# Check container health status -ssh -i fixtures/testing_rsa ubuntu@$VM_IP "cd /opt/torrust && docker compose ps --format json | jq '.Health'" -# Expected: "healthy" - -# Verify docker compose can be stopped/started -ssh -i fixtures/testing_rsa ubuntu@$VM_IP "cd /opt/torrust && docker compose down" -ssh -i fixtures/testing_rsa ubuntu@$VM_IP "cd /opt/torrust && docker compose up -d" -# Expected: No errors, service comes back up +# Verify state transitioned to Running +cat data/e2e-full/environment.json | head -5 +# Expected: "Running" as top-level key ✅ # Cleanup -cargo run -- destroy e2e-config +cargo run -- destroy e2e-full ``` -### Phase 10: E2E Test Coverage +#### Verification Results ✅ + +| Check | Result | +| ----------------------------- | ---------------------------------- | +| VM IP retrieved | `10.140.190.15` ✅ | +| Containers running | `torrust-demo-app` Up (healthy) ✅ | +| Service accessible | nginx welcome page returned ✅ | +| State transitioned to Running | `"Running"` in environment.json ✅ | + +### Phase 10: E2E Test Coverage ✅ COMPLETE + +> **Status**: ✅ COMPLETE +> +> E2E tests now validate the complete deployment pipeline including service health checks. + +#### Implementation Summary + +The E2E test coverage has been enhanced with proper separation of validation concerns: -- Extend E2E tests to cover full release and run workflow -- Test full pipeline: create → provision → configure → release → run -- Verify demo-app container runs successfully and is healthy +| Component | Module | Description | +| ------------------------------ | --------------------------------- | ------------------------------------------------------------- | +| **`RunningServicesValidator`** | `remote_actions/` | Remote action to validate Docker Compose services are running | +| **`run_release_validation`** | `tasks/run_release_validation.rs` | Validates `release` command: Docker Compose files deployed | +| **`run_run_validation`** | `tasks/run_run_validation.rs` | Validates `run` command: services running and healthy | -**Deliverable**: Complete E2E test suite for new commands. +#### What Was Implemented + +**1. `RunningServicesValidator` Remote Action** ✅ + +```rust +// src/infrastructure/remote_actions/running_services.rs +pub struct RunningServicesValidator { + ssh_client: SshClient, + deploy_dir: PathBuf, +} +``` + +Validates: + +- Services are listed in `docker compose ps` output +- Services are in "running" status (not "exited" or "restarting") +- Health check status if configured (e.g., "healthy") +- HTTP accessibility for web services (optional, port 8080) + +**2. Separate Validation Modules (One Per Command)** ✅ + +Following Single Responsibility Principle: + +- `run_configuration_validation.rs` - Validates `configure` command (Docker/Docker Compose installed) +- `run_release_validation.rs` - Validates `release` command (Compose files deployed) +- `run_run_validation.rs` - Validates `run` command (services running) + +**3. Test Coverage Summary** ✅ + +| E2E Test Binary | Workflow Tested | Validations Run | +| --------------------------------- | -------------------------------------------------------- | --------------------------- | +| `e2e_provision_and_destroy_tests` | create → provision → destroy | N/A | +| `e2e_config_and_release_tests` | create → register → configure → release → run → validate | config ✅ release ✅ run ✅ | +| `e2e_tests_full` | create → provision → configure → release → run → destroy | (infrastructure) | + +#### Deliverables ✅ + +All deliverables completed: + +1. ✅ E2E tests cover full release and run workflow +2. ✅ Full pipeline tested: create → provision/register → configure → release → run +3. ✅ Demo-app container verified as running and healthy +4. ✅ New `RunningServicesValidator` validates service health +5. ✅ Configuration validation extended with service checks + +**Deliverable**: Complete E2E test suite for new commands. ✅ **Manual E2E Test - Full Pipeline**: @@ -720,7 +1091,7 @@ Update `scripts/pre-commit.sh` to run the renamed test: ```bash # Change from: -cargo run --bin e2e-config-tests +cargo run --bin e2e-config-and-release-tests # To: cargo run --bin e2e-config-and-release-tests @@ -793,57 +1164,76 @@ All errors must: ### Application Layer - `src/application/command_handlers/release/mod.rs` -- `src/application/command_handlers/release/handler.rs` -- `src/application/command_handlers/release/errors.rs` +- `src/application/command_handlers/release/handler.rs` - Refactor to match provision patterns +- `src/application/command_handlers/release/errors.rs` - Add step-aware errors - `src/application/command_handlers/run/mod.rs` - `src/application/command_handlers/run/handler.rs` - `src/application/command_handlers/run/errors.rs` ### Steps Layer -- `src/application/steps/application/prepare_compose_files.rs` -- `src/application/steps/application/transfer_files.rs` -- `src/application/steps/application/start_services.rs` -- `src/application/steps/application/verify_services.rs` +- `src/application/steps/application/deploy_compose_files.rs` ✅ (`DeployComposeFilesStep` - deploys via Ansible) +- `src/application/steps/rendering/docker_compose_templates.rs` ✅ (`RenderDockerComposeTemplatesStep` - renders templates) +- `src/application/steps/application/start_services.rs` (future - start docker compose services) + +### Infrastructure Layer (Remote Actions) + +- `src/infrastructure/remote_actions/running_services.rs` ✅ (`RunningServicesValidator` - validates running services via E2E tests) ### Infrastructure Layer -- `src/infrastructure/external_tools/docker_compose/` (file manager, not template renderer) -- Static docker compose files in `data/{environment}/templates/docker-compose/` -- (Future: `.env` generation if needed) +- `src/infrastructure/external_tools/docker_compose/mod.rs` ✅ (module exports and `DOCKER_COMPOSE_SUBFOLDER` constant) +- `src/infrastructure/external_tools/docker_compose/template/mod.rs` ✅ (template module exports) +- `src/infrastructure/external_tools/docker_compose/template/renderer/mod.rs` ✅ (`DockerComposeTemplateRenderer` implementation) +- `src/infrastructure/trace/writer/commands/release.rs` ✅ (`ReleaseTraceWriter` for failure trace files) +- `templates/docker-compose/docker-compose.yml` ✅ (embedded source template with nginx:alpine demo service) +- `templates/ansible/deploy-compose-files.yml` ✅ (Ansible playbook for file transfer) +- `docker/ssh-server/Dockerfile` ✅ (updated with passwordless sudo for testing) ### Presentation Layer -- `src/presentation/controllers/release/mod.rs` -- `src/presentation/controllers/release/handler.rs` -- `src/presentation/controllers/release/errors.rs` -- `src/presentation/controllers/run/mod.rs` -- `src/presentation/controllers/run/handler.rs` -- `src/presentation/controllers/run/errors.rs` +- `src/presentation/controllers/release/mod.rs` ✅ +- `src/presentation/controllers/release/errors.rs` ✅ +- `src/presentation/controllers/run/mod.rs` ✅ +- `src/presentation/controllers/run/errors.rs` ✅ -### Domain Layer (if needed) +### Domain Layer -- `src/domain/environment/state/starting.rs` (if `Starting` state doesn't exist) -- `src/domain/environment/state/start_failed.rs` (if `StartFailed` state doesn't exist) +- `src/domain/environment/state/release_failed.rs` ✅ (`ReleaseFailed` state, `ReleaseStep` enum, and `ReleaseFailureContext`) +- `src/domain/environment/state/releasing.rs` ✅ (state transitions) +- `src/domain/environment/state/released.rs` ✅ (released state) +- `src/domain/environment/state/running.rs` ✅ (running state) +- `src/domain/environment/state/run_failed.rs` ✅ (run failed state) ### E2E Tests (rename and update) -- `src/bin/e2e_config_tests.rs` → `src/bin/e2e_config_and_release_tests.rs` (rename) +- `src/bin/e2e_config_tests.rs` → `src/bin/e2e_config_and_release_tests.rs` ✅ (renamed) - `src/bin/e2e_tests_full.rs` (update to include release and run) -- Update `Cargo.toml` binary definition -- Update `scripts/pre-commit.sh` to use new test name +- Update `Cargo.toml` binary definition ✅ +- Update `scripts/pre-commit.sh` to use new test name ✅ + +### E2E Validation Modules + +- `src/testing/e2e/tasks/run_release_validation.rs` ✅ (validates `release` command - Docker Compose files deployed) +- `src/testing/e2e/tasks/run_run_validation.rs` ✅ (validates `run` command - services running and healthy) ## Related Documentation -- [Codebase Architecture](../codebase-architecture.md) +- [Codebase Architecture](../codebase-architecture.md) - Three-level architecture (Command → Step → Remote Action) - [DDD Layer Placement](../contributing/ddd-layer-placement.md) - [Error Handling Guide](../contributing/error-handling.md) - [Module Organization](../contributing/module-organization.md) ## Reference Implementation +- **`ProvisionCommandHandler`** - Primary pattern to follow for handler implementation: + - `TypedEnvironmentRepository` for typed save methods + - `StepResult` for step tracking + - `ProvisionFailureContext` and `ProvisionTraceWriter` for failure handling + - Multi-phase workflow with dedicated methods +- **`ConfigureCommandHandler`** - Pattern for Ansible playbook execution +- **`WaitForCloudInitStep`** - Example of step wrapping Ansible playbook - [torrust-demo compose.yaml](https://github.com/torrust/torrust-demo/blob/main/compose.yaml) - Reference docker-compose for future slices -- Existing `ConfigureCommandHandler` - Pattern to follow for handler implementation - Existing `ProvisionCommandHandler` - Pattern for async operations ## Notes diff --git a/docs/research/ansible-testing-strategy.md b/docs/research/ansible-testing-strategy.md index 991fcb12..e8d25657 100644 --- a/docs/research/ansible-testing-strategy.md +++ b/docs/research/ansible-testing-strategy.md @@ -93,11 +93,11 @@ cargo run --bin e2e-provision-and-destroy-tests # Time: ~20-30 seconds ``` -#### 2. Configuration Phase Testing (e2e-config-tests) +#### 2. Configuration Phase Testing (e2e-config-and-release-tests) ```bash # Test software installation with Docker containers -cargo run --bin e2e-config-tests +cargo run --bin e2e-config-and-release-tests # What this tests: # - Container creation (provisioned-instance state) @@ -116,7 +116,7 @@ Both test suites can run independently and in parallel: ```bash # Run both test phases simultaneously cargo run --bin e2e-provision-and-destroy-tests & -cargo run --bin e2e-config-tests & +cargo run --bin e2e-config-and-release-tests & wait ``` @@ -295,7 +295,7 @@ cargo run --bin e2e-provision-and-destroy-tests # - Cleanup and resource management # Configuration phase (runs independently) -cargo run --bin e2e-config-tests +cargo run --bin e2e-config-and-release-tests # - Container creation ~2-3s # - SSH key setup via password auth # - Ansible playbook execution @@ -360,14 +360,14 @@ This strategy addresses our specific needs by leveraging the best aspects of bot ### In Progress 🔄 -- 🔄 **Configuration Phase Testing**: `e2e-config-tests` binary implementation (Phase B.3) +- 🔄 **Configuration Phase Testing**: `e2e-config-and-release-tests` binary implementation (Phase B.3) - 🔄 **Container Lifecycle Management**: Integration with test binary - 🔄 **Ansible Playbook Integration**: Testing install-docker.yml, install-docker-compose.yml - 🔄 **GitHub Actions Workflow**: CI/CD pipeline for configuration testing ### Next Steps 📋 -1. **Complete Phase B.3**: Implement `e2e-config-tests` binary with Docker container integration +1. **Complete Phase B.3**: Implement `e2e-config-and-release-tests` binary with Docker container integration 2. **Playbook Validation**: Add comprehensive pre/post-condition checking to all playbooks 3. **CI/CD Integration**: Implement parallel test execution in GitHub Actions 4. **Future Container Phases**: Design `configured-instance`, `released-instance` containers diff --git a/docs/research/e2e-docker-config-testing.md b/docs/research/e2e-docker-config-testing.md index d40cf1a6..81f22c70 100644 --- a/docs/research/e2e-docker-config-testing.md +++ b/docs/research/e2e-docker-config-testing.md @@ -135,7 +135,7 @@ Since cloud-init is VM-specific, containers need alternative initialization: ### Next Steps (B.2+) 1. **Docker Implementation**: Build and test Docker configuration -2. **Binary Creation**: Implement `e2e-config-tests` binary +2. **Binary Creation**: Implement `e2e-config-and-release-tests` binary 3. **Container Management**: Integrate container lifecycle with tests 4. **Local Testing**: Validate complete workflow locally 5. **CI Integration**: Create GitHub Actions workflow @@ -146,7 +146,7 @@ Since cloud-init is VM-specific, containers need alternative initialization: ┌─────────────────────────────────────────────────────────┐ │ GitHub Actions Runner │ │ ┌─────────────────────────────────────────────────────┐│ -│ │ e2e-config-tests binary ││ +│ │ e2e-config-and-release-tests binary ││ │ │ ┌─────────────────────────────────────────────────┐ ││ │ │ │ Docker Container │ ││ │ │ │ ┌─────────────────────────────────────────────┐ │ ││ diff --git a/project-words.txt b/project-words.txt index 4e3335a7..1f3248cc 100644 --- a/project-words.txt +++ b/project-words.txt @@ -84,6 +84,7 @@ elif endfor endraw epel +equalto eprint eprintln executability @@ -180,6 +181,7 @@ rustls rustup schemars secureboot +selectattr serde serverurl shellcheck diff --git a/scripts/pre-commit.sh b/scripts/pre-commit.sh index e7b3e611..24f79e33 100755 --- a/scripts/pre-commit.sh +++ b/scripts/pre-commit.sh @@ -30,7 +30,7 @@ if [ "${TORRUST_TD_SKIP_SLOW_TESTS:-false}" = "true" ]; then echo "" echo "If you want to run them manually before committing, use these commands:" echo " cargo run --bin e2e-provision-and-destroy-tests # ~44s" - echo " cargo run --bin e2e-config-tests # ~48s" + echo " cargo run --bin e2e-config-and-release-tests # ~48s" echo " cargo cov-check # For coverage check" echo "" echo "Fast mode execution time: ~2 minutes 30 seconds" @@ -49,7 +49,7 @@ else "Running tests|All tests passed|||cargo test" "Testing cargo documentation|Documentation builds successfully|||cargo doc --no-deps --bins --examples --workspace --all-features" "Running E2E provision and destroy tests|Provision and destroy tests passed|(Testing infrastructure lifecycle - this may take a few minutes)|RUST_LOG=warn|cargo run --bin e2e-provision-and-destroy-tests" - "Running E2E configuration tests|Configuration tests passed|(Testing software installation and configuration)|RUST_LOG=warn|cargo run --bin e2e-config-tests" + "Running E2E configuration and release tests|Configuration and release tests passed|(Testing software installation, configuration, and release)|RUST_LOG=warn|cargo run --bin e2e-config-and-release-tests" ) fi diff --git a/src/application/command_handlers/configure/errors.rs b/src/application/command_handlers/configure/errors.rs index 8ba2a033..122bd4c9 100644 --- a/src/application/command_handlers/configure/errors.rs +++ b/src/application/command_handlers/configure/errors.rs @@ -6,6 +6,13 @@ use crate::shared::command::CommandError; /// Comprehensive error type for the `ConfigureCommandHandler` #[derive(Debug, thiserror::Error)] pub enum ConfigureCommandHandlerError { + /// Environment was not found in the repository + #[error("Environment not found: {name}")] + EnvironmentNotFound { + /// The name of the environment that was not found + name: String, + }, + #[error("Command execution failed: {0}")] Command(#[from] CommandError), @@ -19,6 +26,9 @@ pub enum ConfigureCommandHandlerError { impl crate::shared::Traceable for ConfigureCommandHandlerError { fn trace_format(&self) -> String { match self { + Self::EnvironmentNotFound { name } => { + format!("ConfigureCommandHandlerError: Environment not found - {name}") + } Self::Command(e) => { format!("ConfigureCommandHandlerError: Command execution failed - {e}") } @@ -34,15 +44,19 @@ impl crate::shared::Traceable for ConfigureCommandHandlerError { fn trace_source(&self) -> Option<&dyn crate::shared::Traceable> { match self { Self::Command(e) => Some(e), - Self::StatePersistence(_) | Self::InvalidState(_) => None, + Self::EnvironmentNotFound { .. } + | Self::StatePersistence(_) + | Self::InvalidState(_) => None, } } fn error_kind(&self) -> crate::shared::ErrorKind { match self { + Self::EnvironmentNotFound { .. } | Self::InvalidState(_) => { + crate::shared::ErrorKind::Configuration + } Self::Command(_) => crate::shared::ErrorKind::CommandExecution, Self::StatePersistence(_) => crate::shared::ErrorKind::StatePersistence, - Self::InvalidState(_) => crate::shared::ErrorKind::Configuration, } } } @@ -76,6 +90,25 @@ impl ConfigureCommandHandlerError { #[must_use] pub fn help(&self) -> &'static str { match self { + Self::EnvironmentNotFound { .. } => { + "Environment Not Found - Troubleshooting: + +1. Verify the environment name is correct +2. Check if the environment was created: + ls data/ + +3. If the environment doesn't exist, create it first: + cargo run -- create environment --env-file + +4. If the environment was previously destroyed, recreate it + +Common causes: +- Typo in environment name +- Environment was destroyed +- Working in the wrong directory + +For more information, see docs/user-guide/commands.md" + } Self::Command(_) => { "Command Execution Failed - Troubleshooting: @@ -148,6 +181,17 @@ mod tests { use crate::domain::environment::repository::RepositoryError; use crate::shared::command::CommandError; + #[test] + fn it_should_provide_help_for_environment_not_found() { + let error = ConfigureCommandHandlerError::EnvironmentNotFound { + name: "test-env".to_string(), + }; + + let help = error.help(); + assert!(help.contains("Environment Not Found")); + assert!(help.contains("Troubleshooting")); + } + #[test] fn it_should_provide_help_for_command_execution() { let error = ConfigureCommandHandlerError::Command(CommandError::ExecutionFailed { @@ -189,6 +233,9 @@ mod tests { #[test] fn it_should_have_help_for_all_error_variants() { let errors = vec![ + ConfigureCommandHandlerError::EnvironmentNotFound { + name: "test".to_string(), + }, ConfigureCommandHandlerError::Command(CommandError::ExecutionFailed { command: "test".to_string(), exit_code: "1".to_string(), diff --git a/src/application/command_handlers/configure/handler.rs b/src/application/command_handlers/configure/handler.rs index 64ca6e3a..7b32902c 100644 --- a/src/application/command_handlers/configure/handler.rs +++ b/src/application/command_handlers/configure/handler.rs @@ -2,7 +2,7 @@ use std::sync::Arc; -use tracing::{info, instrument}; +use tracing::{error, info, instrument}; use super::errors::ConfigureCommandHandlerError; use crate::adapters::ansible::AnsibleClient; @@ -11,9 +11,7 @@ use crate::application::steps::{ ConfigureFirewallStep, ConfigureSecurityUpdatesStep, InstallDockerComposeStep, InstallDockerStep, }; -use crate::domain::environment::repository::{ - EnvironmentRepository, RepositoryError, TypedEnvironmentRepository, -}; +use crate::domain::environment::repository::{EnvironmentRepository, TypedEnvironmentRepository}; use crate::domain::environment::state::{ConfigureFailureContext, ConfigureStep}; use crate::domain::environment::{Configured, Configuring, Environment}; use crate::domain::EnvironmentName; @@ -89,61 +87,40 @@ impl ConfigureCommandHandler { &self, env_name: &EnvironmentName, ) -> Result, ConfigureCommandHandlerError> { - info!( - command = "configure", - environment = %env_name, - "Starting complete infrastructure configuration workflow" - ); - - // 1. Load the environment from storage (returns AnyEnvironmentState - type-erased) - let any_env = self - .repository - .inner() - .load(env_name) - .map_err(ConfigureCommandHandlerError::StatePersistence)?; - - // 2. Check if environment exists - let any_env = any_env.ok_or_else(|| { - ConfigureCommandHandlerError::StatePersistence(RepositoryError::NotFound) - })?; + let environment = self.load_provisioned_environment(env_name)?; - // 3. Validate environment is in Provisioned state and restore type safety - let environment = any_env.try_into_provisioned()?; - - // Capture start time before transitioning to Configuring state let started_at = self.clock.now(); - // Transition to Configuring state let environment = environment.start_configuring(); - // Persist intermediate state self.repository.save_configuring(&environment)?; - // Build configuration dependencies (AnsibleClient) - let ansible_client = Self::build_configuration_dependencies(&environment); - - // Execute configuration steps with explicit step tracking - match Self::execute_configuration_with_tracking(&environment, &ansible_client) { + match Self::execute_configuration_with_tracking(&environment) { Ok(configured_env) => { - // Persist final state - self.repository.save_configured(&configured_env)?; - info!( command = "configure", environment = %configured_env.name(), "Infrastructure configuration completed successfully" ); + self.repository.save_configured(&configured_env)?; + Ok(configured_env) } Err((e, current_step)) => { - // Transition to error state with structured context - // current_step contains the step that was executing when the error occurred + error!( + command = "configure", + environment = %environment.name(), + failed_step = ?current_step, + error = %e, + "Infrastructure configuration failed" + ); + let context = self.build_failure_context(&environment, &e, current_step, started_at); + let failed = environment.configure_failed(context); - // Persist error state self.repository.save_configure_failed(&failed)?; Err(e) @@ -162,23 +139,45 @@ impl ConfigureCommandHandler { /// Returns a tuple of (error, `current_step`) if any configuration step fails fn execute_configuration_with_tracking( environment: &Environment, - ansible_client: &Arc, ) -> StepResult, ConfigureCommandHandlerError, ConfigureStep> { - // Track current step and execute each step - // If an error occurs, we return it along with the current step + let ansible_client = Arc::new(AnsibleClient::new(environment.ansible_build_dir())); + + // Allow tests or CI to skip Docker installation + // (useful for container-based tests where Docker is already installed via Dockerfile) + let skip_docker = std::env::var("TORRUST_TD_SKIP_DOCKER_INSTALL_IN_CONTAINER") + .map(|v| v == "true") + .unwrap_or(false); let current_step = ConfigureStep::InstallDocker; - InstallDockerStep::new(Arc::clone(ansible_client)) - .execute() - .map_err(|e| (e.into(), current_step))?; + if skip_docker { + info!( + command = "configure", + step = "install_docker", + status = "skipped", + "Skipping Docker installation due to TORRUST_TD_SKIP_DOCKER_INSTALL_IN_CONTAINER (Docker pre-installed)" + ); + } else { + InstallDockerStep::new(Arc::clone(&ansible_client)) + .execute() + .map_err(|e| (e.into(), current_step))?; + } let current_step = ConfigureStep::InstallDockerCompose; - InstallDockerComposeStep::new(Arc::clone(ansible_client)) - .execute() - .map_err(|e| (e.into(), current_step))?; + if skip_docker { + info!( + command = "configure", + step = "install_docker_compose", + status = "skipped", + "Skipping Docker Compose installation due to TORRUST_TD_SKIP_DOCKER_INSTALL_IN_CONTAINER (Docker Compose pre-installed)" + ); + } else { + InstallDockerComposeStep::new(Arc::clone(&ansible_client)) + .execute() + .map_err(|e| (e.into(), current_step))?; + } let current_step = ConfigureStep::ConfigureSecurityUpdates; - ConfigureSecurityUpdatesStep::new(Arc::clone(ansible_client)) + ConfigureSecurityUpdatesStep::new(Arc::clone(&ansible_client)) .execute() .map_err(|e| (e.into(), current_step))?; @@ -198,7 +197,7 @@ impl ConfigureCommandHandler { "Skipping UFW firewall configuration due to TORRUST_TD_SKIP_FIREWALL_IN_CONTAINER" ); } else { - ConfigureFirewallStep::new(Arc::clone(ansible_client)) + ConfigureFirewallStep::new(Arc::clone(&ansible_client)) .execute() .map_err(|e| (e.into(), current_step))?; } @@ -209,23 +208,6 @@ impl ConfigureCommandHandler { Ok(configured) } - /// Build configuration dependencies - /// - /// Creates the `AnsibleClient` needed for configuration operations. - /// - /// # Arguments - /// - /// * `environment` - The environment being configured (provides build directory path) - /// - /// # Returns - /// - /// Returns `AnsibleClient` for executing Ansible playbooks - fn build_configuration_dependencies( - environment: &Environment, - ) -> Arc { - Arc::new(AnsibleClient::new(environment.ansible_build_dir())) - } - /// Build failure context for a configuration error and generate trace file /// /// This helper method builds structured error context including the failed step, @@ -280,4 +262,32 @@ impl ConfigureCommandHandler { context } + + /// Load environment from storage and validate it is in `Provisioned` state + /// + /// # Errors + /// + /// Returns an error if: + /// * Persistence error occurs during load + /// * Environment does not exist + /// * Environment is not in `Provisioned` state + fn load_provisioned_environment( + &self, + env_name: &EnvironmentName, + ) -> Result< + crate::domain::environment::Environment, + ConfigureCommandHandlerError, + > { + let any_env = self + .repository + .inner() + .load(env_name) + .map_err(ConfigureCommandHandlerError::StatePersistence)?; + + let any_env = any_env.ok_or_else(|| ConfigureCommandHandlerError::EnvironmentNotFound { + name: env_name.to_string(), + })?; + + Ok(any_env.try_into_provisioned()?) + } } diff --git a/src/application/command_handlers/create/handler.rs b/src/application/command_handlers/create/handler.rs index a10fd528..71d2b9e5 100644 --- a/src/application/command_handlers/create/handler.rs +++ b/src/application/command_handlers/create/handler.rs @@ -206,21 +206,10 @@ impl CreateCommandHandler { config: EnvironmentCreationConfig, working_dir: &std::path::Path, ) -> Result, CreateCommandHandlerError> { - info!( - command = "create", - environment = %config.environment.name, - "Starting environment creation" - ); - - // Step 1: Convert configuration to domain objects - // This validates environment name, instance name, provider config, - // SSH username, and file existence let (environment_name, _instance_name, provider_config, ssh_credentials, ssh_port) = config .to_environment_params() .map_err(CreateCommandHandlerError::InvalidConfiguration)?; - // Step 2: Check if environment already exists - // This prevents duplicate environments and provides clear feedback if self .environment_repository .exists(&environment_name) @@ -231,9 +220,6 @@ impl CreateCommandHandler { }); } - // Step 3: Create environment entity with working directory for absolute paths - // Note: Environment::with_working_dir auto-generates instance_name from environment_name - // The _instance_name from config is currently unused but available for future use let environment = Environment::with_working_dir( environment_name, provider_config, @@ -242,8 +228,6 @@ impl CreateCommandHandler { working_dir, ); - // Step 5: Persist environment state - // Repository handles directory creation atomically during save self.environment_repository .save(&environment.clone().into_any()) .map_err(CreateCommandHandlerError::RepositoryError)?; diff --git a/src/application/command_handlers/destroy/errors.rs b/src/application/command_handlers/destroy/errors.rs index de27e058..32620b3d 100644 --- a/src/application/command_handlers/destroy/errors.rs +++ b/src/application/command_handlers/destroy/errors.rs @@ -9,6 +9,13 @@ use crate::shared::command::CommandError; /// Comprehensive error type for the `DestroyCommandHandler` #[derive(Debug, thiserror::Error)] pub enum DestroyCommandHandlerError { + /// Environment was not found in the repository + #[error("Environment not found: {name}")] + EnvironmentNotFound { + /// The name of the environment that was not found + name: String, + }, + #[error("OpenTofu command failed: {0}")] OpenTofu(#[from] OpenTofuError), @@ -32,6 +39,9 @@ pub enum DestroyCommandHandlerError { impl crate::shared::Traceable for DestroyCommandHandlerError { fn trace_format(&self) -> String { match self { + Self::EnvironmentNotFound { name } => { + format!("DestroyCommandHandlerError: Environment not found - {name}") + } Self::OpenTofu(e) => { format!("DestroyCommandHandlerError: OpenTofu command failed - {e}") } @@ -57,7 +67,8 @@ impl crate::shared::Traceable for DestroyCommandHandlerError { match self { Self::OpenTofu(e) => Some(e), Self::Command(e) => Some(e), - Self::StatePersistence(_) + Self::EnvironmentNotFound { .. } + | Self::StatePersistence(_) | Self::StateTransition(_) | Self::StateCleanupFailed { .. } => None, } @@ -65,9 +76,11 @@ impl crate::shared::Traceable for DestroyCommandHandlerError { fn error_kind(&self) -> crate::shared::ErrorKind { match self { + Self::EnvironmentNotFound { .. } | Self::StateTransition(_) => { + crate::shared::ErrorKind::Configuration + } Self::OpenTofu(_) => crate::shared::ErrorKind::InfrastructureOperation, Self::Command(_) => crate::shared::ErrorKind::CommandExecution, - Self::StateTransition(_) => crate::shared::ErrorKind::Configuration, Self::StatePersistence(_) | Self::StateCleanupFailed { .. } => { crate::shared::ErrorKind::StatePersistence } @@ -105,6 +118,24 @@ impl DestroyCommandHandlerError { #[must_use] pub fn help(&self) -> &'static str { match self { + Self::EnvironmentNotFound { .. } => { + "Environment Not Found - Troubleshooting: + +1. Verify the environment name is correct +2. Check if the environment was created: + ls data/ + +3. If the environment doesn't exist, there's nothing to destroy + +4. If the environment was previously destroyed, it has already been removed + +Common causes: +- Typo in environment name +- Environment was already destroyed +- Working in the wrong directory + +For more information, see docs/user-guide/commands.md" + } Self::OpenTofu(_) => { "OpenTofu Destroy Failed - Troubleshooting: @@ -287,9 +318,24 @@ mod tests { assert!(help.contains("permissions")); } + #[test] + fn it_should_provide_help_for_environment_not_found() { + let error = DestroyCommandHandlerError::EnvironmentNotFound { + name: "test-env".to_string(), + }; + + let help = error.help(); + assert!(help.contains("Environment Not Found")); + assert!(help.contains("Troubleshooting")); + assert!(help.contains("environment name")); + } + #[test] fn it_should_have_help_for_all_error_variants() { let errors = vec![ + DestroyCommandHandlerError::EnvironmentNotFound { + name: "test-env".to_string(), + }, DestroyCommandHandlerError::OpenTofu(OpenTofuError::CommandError( CommandError::ExecutionFailed { command: "tofu".to_string(), diff --git a/src/application/command_handlers/destroy/handler.rs b/src/application/command_handlers/destroy/handler.rs index c1a983b5..e04e4548 100644 --- a/src/application/command_handlers/destroy/handler.rs +++ b/src/application/command_handlers/destroy/handler.rs @@ -7,9 +7,7 @@ use tracing::{info, instrument}; use super::errors::DestroyCommandHandlerError; use crate::application::command_handlers::common::StepResult; use crate::application::steps::DestroyInfrastructureStep; -use crate::domain::environment::repository::{ - EnvironmentRepository, RepositoryError, TypedEnvironmentRepository, -}; +use crate::domain::environment::repository::{EnvironmentRepository, TypedEnvironmentRepository}; use crate::domain::environment::{Destroyed, Destroying, Environment}; use crate::domain::{AnyEnvironmentState, EnvironmentName}; use crate::shared::error::Traceable; @@ -89,25 +87,8 @@ impl DestroyCommandHandler { &self, env_name: &EnvironmentName, ) -> Result, DestroyCommandHandlerError> { - info!( - command = "destroy", - environment = %env_name, - "Starting complete infrastructure destruction workflow" - ); + let any_env = self.load_environment(env_name)?; - // 1. Load the environment from storage (returns AnyEnvironmentState - type-erased) - let any_env = self - .repository - .inner() - .load(env_name) - .map_err(DestroyCommandHandlerError::StatePersistence)?; - - // 2. Check if environment exists - let any_env = any_env.ok_or_else(|| { - DestroyCommandHandlerError::StatePersistence(RepositoryError::NotFound) - })?; - - // 3. Check if environment is already destroyed if let AnyEnvironmentState::Destroyed(env) = any_env { info!( command = "destroy", @@ -117,14 +98,10 @@ impl DestroyCommandHandler { return Ok(env); } - // 4. Capture start time before transitioning to Destroying state let started_at = self.clock.now(); - // 5. Get the build directory from the environment context (before consuming any_env) let opentofu_build_dir = any_env.tofu_build_dir(); - // 6. Transition to Destroying state - // Match on AnyEnvironmentState and call start_destroying on each typed environment let destroying_env = match any_env { AnyEnvironmentState::Created(env) => env.start_destroying(), AnyEnvironmentState::Provisioning(env) => env.start_destroying(), @@ -145,21 +122,16 @@ impl DestroyCommandHandler { } }; - // 7. Persist intermediate state self.repository.save_destroying(&destroying_env)?; - // 8. Create OpenTofu client with correct build directory let opentofu_client = Arc::new(crate::adapters::tofu::client::OpenTofuClient::new( opentofu_build_dir, )); - // 9. Execute destruction steps with explicit step tracking match Self::execute_destruction_with_tracking(&destroying_env, &opentofu_client) { Ok(()) => { - // Transition to Destroyed state let destroyed = destroying_env.destroyed(); - // Persist final state self.repository.save_destroyed(&destroyed)?; info!( @@ -171,12 +143,10 @@ impl DestroyCommandHandler { Ok(destroyed) } Err((e, current_step)) => { - // Transition to error state with structured context let context = self.build_failure_context(&destroying_env, &e, current_step, started_at); let failed = destroying_env.destroy_failed(context); - // Persist error state self.repository.save_destroy_failed(&failed)?; Err(e) @@ -393,4 +363,26 @@ impl DestroyCommandHandler { DestroyInfrastructureStep::new(Arc::clone(opentofu_client)).execute()?; Ok(()) } + + /// Load environment from storage + /// + /// # Errors + /// + /// Returns an error if: + /// * Persistence error occurs during load + /// * Environment does not exist + fn load_environment( + &self, + env_name: &EnvironmentName, + ) -> Result { + let any_env = self + .repository + .inner() + .load(env_name) + .map_err(DestroyCommandHandlerError::StatePersistence)?; + + any_env.ok_or_else(|| DestroyCommandHandlerError::EnvironmentNotFound { + name: env_name.to_string(), + }) + } } diff --git a/src/application/command_handlers/mod.rs b/src/application/command_handlers/mod.rs index 0f6bbd53..b0fff702 100644 --- a/src/application/command_handlers/mod.rs +++ b/src/application/command_handlers/mod.rs @@ -14,6 +14,8 @@ //! - `destroy` - Infrastructure destruction and teardown //! - `provision` - Infrastructure provisioning using `OpenTofu` //! - `register` - Register existing instances as alternative to provisioning +//! - `release` - Software release to target instances +//! - `run` - Stack execution on target instances //! - `test` - Deployment testing and validation //! //! Each command handler encapsulates a complete business workflow, handling orchestration, @@ -25,6 +27,8 @@ pub mod create; pub mod destroy; pub mod provision; pub mod register; +pub mod release; +pub mod run; pub mod test; pub use configure::ConfigureCommandHandler; @@ -32,4 +36,6 @@ pub use create::CreateCommandHandler; pub use destroy::DestroyCommandHandler; pub use provision::ProvisionCommandHandler; pub use register::RegisterCommandHandler; +pub use release::ReleaseCommandHandler; +pub use run::RunCommandHandler; pub use test::TestCommandHandler; diff --git a/src/application/command_handlers/provision/errors.rs b/src/application/command_handlers/provision/errors.rs index a29026ed..79f3301c 100644 --- a/src/application/command_handlers/provision/errors.rs +++ b/src/application/command_handlers/provision/errors.rs @@ -11,6 +11,9 @@ use crate::shared::command::CommandError; /// Comprehensive error type for the `ProvisionCommandHandler` #[derive(Debug, thiserror::Error)] pub enum ProvisionCommandHandlerError { + #[error("Environment not found: '{name}'")] + EnvironmentNotFound { name: String }, + #[error("OpenTofu template rendering failed: {0}")] OpenTofuTemplateRendering(#[from] TofuTemplateRendererError), @@ -45,6 +48,9 @@ impl From for ProvisionCommandHandlerError { impl crate::shared::Traceable for ProvisionCommandHandlerError { fn trace_format(&self) -> String { match self { + Self::EnvironmentNotFound { name } => { + format!("ProvisionCommandHandlerError: Environment not found - '{name}'") + } Self::OpenTofuTemplateRendering(e) => { format!("ProvisionCommandHandlerError: OpenTofu template rendering failed - {e}") } @@ -79,14 +85,16 @@ impl crate::shared::Traceable for ProvisionCommandHandlerError { Self::OpenTofu(e) => Some(e), Self::Command(e) => Some(e), Self::SshConnectivity(e) => Some(e), - Self::TemplateRendering(_) | Self::StatePersistence(_) | Self::StateTransition(_) => { - None - } + Self::EnvironmentNotFound { .. } + | Self::TemplateRendering(_) + | Self::StatePersistence(_) + | Self::StateTransition(_) => None, } } fn error_kind(&self) -> crate::shared::ErrorKind { match self { + Self::EnvironmentNotFound { .. } => crate::shared::ErrorKind::Configuration, Self::OpenTofuTemplateRendering(_) | Self::AnsibleTemplateRendering(_) | Self::TemplateRendering(_) => crate::shared::ErrorKind::TemplateRendering, @@ -128,8 +136,28 @@ impl ProvisionCommandHandlerError { /// assert!(help.contains("Troubleshooting")); /// ``` #[must_use] + #[allow(clippy::too_many_lines)] pub fn help(&self) -> &'static str { match self { + Self::EnvironmentNotFound { .. } => { + "Environment Not Found - Troubleshooting: + +1. Verify the environment name is correct +2. Check if the environment was created: + ls data/ + +3. If the environment doesn't exist, create it first: + cargo run -- create environment --env-file + +4. If the environment was previously destroyed, recreate it + +Common causes: +- Typo in environment name +- Environment was destroyed +- Working in the wrong directory + +For more information, see docs/user-guide/commands.md" + } Self::OpenTofuTemplateRendering(_) => { "OpenTofu Template Rendering Failed - Troubleshooting: @@ -352,6 +380,18 @@ mod tests { assert!(help.contains("data//")); } + #[test] + fn it_should_provide_help_for_environment_not_found() { + let error = ProvisionCommandHandlerError::EnvironmentNotFound { + name: "test-env".to_string(), + }; + + let help = error.help(); + assert!(help.contains("Environment Not Found")); + assert!(help.contains("Troubleshooting")); + assert!(help.contains("environment name")); + } + #[test] fn it_should_have_help_for_all_error_variants() { use crate::adapters::ssh::SshError; @@ -361,6 +401,9 @@ mod tests { use crate::shared::command::CommandError; let errors = vec![ + ProvisionCommandHandlerError::EnvironmentNotFound { + name: "test-env".to_string(), + }, ProvisionCommandHandlerError::OpenTofuTemplateRendering( TofuTemplateRendererError::DirectoryCreationFailed { directory: "test".to_string(), diff --git a/src/application/command_handlers/provision/handler.rs b/src/application/command_handlers/provision/handler.rs index bcf36cbb..2fae6d05 100644 --- a/src/application/command_handlers/provision/handler.rs +++ b/src/application/command_handlers/provision/handler.rs @@ -3,7 +3,7 @@ use std::net::IpAddr; use std::sync::Arc; -use tracing::{info, instrument}; +use tracing::{error, info, instrument}; use super::errors::ProvisionCommandHandlerError; use crate::adapters::ansible::AnsibleClient; @@ -17,9 +17,7 @@ use crate::application::steps::{ PlanInfrastructureStep, RenderOpenTofuTemplatesStep, ValidateInfrastructureStep, WaitForCloudInitStep, WaitForSSHConnectivityStep, }; -use crate::domain::environment::repository::{ - EnvironmentRepository, RepositoryError, TypedEnvironmentRepository, -}; +use crate::domain::environment::repository::{EnvironmentRepository, TypedEnvironmentRepository}; use crate::domain::environment::state::{ProvisionFailureContext, ProvisionStep}; use crate::domain::environment::{Environment, Provisioned, Provisioning}; use crate::domain::EnvironmentName; @@ -102,34 +100,12 @@ impl ProvisionCommandHandler { &self, env_name: &EnvironmentName, ) -> Result, ProvisionCommandHandlerError> { - info!( - command = "provision", - environment = %env_name, - "Starting complete infrastructure provisioning workflow" - ); + let environment = self.load_created_environment(env_name)?; - // 1. Load the environment from storage (returns AnyEnvironmentState - type-erased) - let any_env = self - .repository - .inner() - .load(env_name) - .map_err(ProvisionCommandHandlerError::StatePersistence)?; - - // 2. Check if environment exists - let any_env = any_env.ok_or_else(|| { - ProvisionCommandHandlerError::StatePersistence(RepositoryError::NotFound) - })?; - - // 3. Validate environment is in Created state and restore type safety - let environment = any_env.try_into_created()?; - - // 4. Capture start time before transitioning to Provisioning state let started_at = self.clock.now(); - // Transition to Provisioning state let environment = environment.start_provisioning(); - // Persist intermediate state self.repository.save_provisioning(&environment)?; // Execute provisioning workflow with explicit step tracking @@ -139,9 +115,6 @@ impl ProvisionCommandHandler { // Store instance IP in the environment context let provisioned = provisioned.with_instance_ip(instance_ip); - // Persist final state - self.repository.save_provisioned(&provisioned)?; - info!( command = "provision", environment = %provisioned.name(), @@ -149,16 +122,23 @@ impl ProvisionCommandHandler { "Infrastructure provisioning completed successfully" ); + self.repository.save_provisioned(&provisioned)?; + Ok(provisioned) } Err((e, current_step)) => { - // Transition to error state with structured context - // current_step contains the step that was executing when the error occurred + error!( + command = "provision", + environment = %environment.name(), + error = %e, + step = ?current_step, + "Infrastructure provisioning failed" + ); + let context = self.build_failure_context(&environment, &e, current_step, started_at); let failed = environment.provision_failed(context); - // Persist error state self.repository.save_provision_failed(&failed)?; Err(e) @@ -501,4 +481,30 @@ impl ProvisionCommandHandler { context } + + /// Load environment from storage and validate it is in `Created` state + /// + /// # Errors + /// + /// Returns an error if: + /// * Persistence error occurs during load + /// * Environment does not exist + /// * Environment is not in `Created` state + fn load_created_environment( + &self, + env_name: &EnvironmentName, + ) -> Result, ProvisionCommandHandlerError> + { + let any_env = self + .repository + .inner() + .load(env_name) + .map_err(ProvisionCommandHandlerError::StatePersistence)?; + + let any_env = any_env.ok_or_else(|| ProvisionCommandHandlerError::EnvironmentNotFound { + name: env_name.to_string(), + })?; + + Ok(any_env.try_into_created()?) + } } diff --git a/src/application/command_handlers/register/handler.rs b/src/application/command_handlers/register/handler.rs index 6b0fe28e..08c8cc03 100644 --- a/src/application/command_handlers/register/handler.rs +++ b/src/application/command_handlers/register/handler.rs @@ -81,45 +81,15 @@ impl RegisterCommandHandler { env_name: &EnvironmentName, instance_ip: IpAddr, ) -> Result, RegisterCommandHandlerError> { - info!( - command = "register", - environment = %env_name, - instance_ip = %instance_ip, - "Registering existing instance with environment" - ); + let environment = self.load_created_environment(env_name)?; - // 1. Load the environment from storage - let any_env = self - .repository - .inner() - .load(env_name) - .map_err(RegisterCommandHandlerError::RepositorySave)?; - - // 2. Check if environment exists - let any_env = any_env.ok_or_else(|| RegisterCommandHandlerError::EnvironmentNotFound { - name: env_name.clone(), - })?; - - // 3. Validate environment is in Created state - let environment = - any_env - .try_into_created() - .map_err(|e| RegisterCommandHandlerError::InvalidState { - name: env_name.clone(), - current_state: e.to_string(), - })?; - - // 4. Validate SSH connectivity (minimal validation for v1) self.validate_ssh_connectivity(&environment, instance_ip)?; - // 5. Prepare for configuration (render Ansible templates so configure command will work) self.prepare_for_configuration(&environment, instance_ip) .await?; - // 6. Register the instance by setting the IP and transitioning to Provisioned let provisioned = environment.register(instance_ip); - // 7. Persist the updated state self.repository.save_provisioned(&provisioned)?; info!( @@ -216,6 +186,36 @@ impl RegisterCommandHandler { Ok(()) } + + /// Load environment from storage and validate it is in `Created` state + /// + /// # Errors + /// + /// Returns an error if: + /// * Persistence error occurs during load + /// * Environment does not exist + /// * Environment is not in `Created` state + fn load_created_environment( + &self, + env_name: &EnvironmentName, + ) -> Result, RegisterCommandHandlerError> { + let any_env = self + .repository + .inner() + .load(env_name) + .map_err(RegisterCommandHandlerError::RepositorySave)?; + + let any_env = any_env.ok_or_else(|| RegisterCommandHandlerError::EnvironmentNotFound { + name: env_name.clone(), + })?; + + any_env + .try_into_created() + .map_err(|e| RegisterCommandHandlerError::InvalidState { + name: env_name.clone(), + current_state: e.to_string(), + }) + } } #[cfg(test)] diff --git a/src/application/command_handlers/release/errors.rs b/src/application/command_handlers/release/errors.rs new file mode 100644 index 00000000..b3c246ed --- /dev/null +++ b/src/application/command_handlers/release/errors.rs @@ -0,0 +1,361 @@ +//! Error types for the Release command handler + +use crate::application::steps::application::DeployComposeFilesStepError; +use crate::domain::environment::state::StateTypeError; +use crate::shared::error::{ErrorKind, Traceable}; + +/// Comprehensive error type for the `ReleaseCommandHandler` +/// +/// This error type captures all possible failures that can occur during +/// software release operations. Each variant provides detailed context +/// and actionable troubleshooting guidance. +#[derive(Debug, thiserror::Error)] +pub enum ReleaseCommandHandlerError { + /// Environment was not found in the repository + #[error("Environment not found: {name}")] + EnvironmentNotFound { + /// The name of the environment that was not found + name: String, + }, + + /// Instance IP address is not available (required for deployment) + /// + /// The release command requires the instance IP address to deploy files + /// to the remote host. This IP should be available after provisioning. + #[error("Instance IP address is not available for environment '{name}'. The provision step should have set this value.")] + MissingInstanceIp { + /// The name of the environment missing the instance IP + name: String, + }, + + /// Environment is in an invalid state for release + #[error("Environment is in an invalid state for release: {0}")] + InvalidState(#[from] StateTypeError), + + /// Failed to persist environment state + #[error("Failed to persist environment state: {0}")] + StatePersistence(#[from] crate::domain::environment::repository::RepositoryError), + + /// Template rendering failed + #[error("Template rendering failed: {0}")] + TemplateRendering(String), + + /// Deployment to remote host failed + #[error("Deployment to remote host failed: {message}")] + DeploymentFailed { + /// Description of the failure + message: String, + /// The underlying deployment step error + #[source] + source: DeployComposeFilesStepError, + }, + + /// Release operation failed + #[error("Release operation failed for environment '{name}': {message}")] + ReleaseOperationFailed { + /// The name of the environment + name: String, + /// Description of the failure + message: String, + }, +} + +impl Traceable for ReleaseCommandHandlerError { + fn trace_format(&self) -> String { + match self { + Self::EnvironmentNotFound { name } => { + format!("ReleaseCommandHandlerError: Environment not found - {name}") + } + Self::MissingInstanceIp { name } => { + format!("ReleaseCommandHandlerError: Instance IP not available for environment '{name}'") + } + Self::InvalidState(e) => { + format!("ReleaseCommandHandlerError: Invalid state for release - {e}") + } + Self::StatePersistence(e) => { + format!("ReleaseCommandHandlerError: Failed to persist environment state - {e}") + } + Self::TemplateRendering(message) => { + format!("ReleaseCommandHandlerError: Template rendering failed - {message}") + } + Self::DeploymentFailed { message, .. } => { + format!("ReleaseCommandHandlerError: Deployment failed - {message}") + } + Self::ReleaseOperationFailed { name, message } => { + format!( + "ReleaseCommandHandlerError: Release operation failed for '{name}' - {message}" + ) + } + } + } + + fn trace_source(&self) -> Option<&dyn Traceable> { + match self { + Self::DeploymentFailed { source, .. } => Some(source), + Self::StatePersistence(_) + | Self::EnvironmentNotFound { .. } + | Self::MissingInstanceIp { .. } + | Self::InvalidState(_) + | Self::TemplateRendering(_) + | Self::ReleaseOperationFailed { .. } => None, + } + } + + fn error_kind(&self) -> ErrorKind { + match self { + Self::EnvironmentNotFound { .. } + | Self::MissingInstanceIp { .. } + | Self::InvalidState(_) => ErrorKind::Configuration, + Self::StatePersistence(_) => ErrorKind::StatePersistence, + Self::TemplateRendering(_) => ErrorKind::TemplateRendering, + Self::DeploymentFailed { source, .. } => source.error_kind(), + Self::ReleaseOperationFailed { .. } => ErrorKind::InfrastructureOperation, + } + } +} + +impl ReleaseCommandHandlerError { + /// Provides detailed troubleshooting guidance for this error + /// + /// Returns context-specific help text that guides users toward resolving + /// the issue. This implements the project's tiered help system pattern + /// for actionable error messages. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::application::command_handlers::release::ReleaseCommandHandlerError; + /// + /// let error = ReleaseCommandHandlerError::EnvironmentNotFound { + /// name: "my-env".to_string(), + /// }; + /// + /// let help = error.help(); + /// assert!(help.contains("Environment Not Found")); + /// assert!(help.contains("Troubleshooting")); + /// ``` + #[must_use] + pub fn help(&self) -> &'static str { + match self { + Self::EnvironmentNotFound { .. } => { + "Environment Not Found - Troubleshooting: + +1. Verify the environment name is correct +2. Check if the environment was created: + ls data/ + +3. If the environment doesn't exist, create it first: + cargo run -- create environment --env-file + +4. If the environment was previously destroyed, recreate it + +Common causes: +- Typo in environment name +- Environment was destroyed +- Working in the wrong directory + +For more information, see docs/user-guide/commands.md" + } + Self::InvalidState { .. } => { + "Invalid Environment State - Troubleshooting: + +1. The release command requires the environment to be in Configured state +2. Check the current environment state: + cat data//environment.json + +3. If the environment is not configured, run: + cargo run -- configure + +4. If the environment is in a failed state, resolve the issue first + +State progression for release: + Created → Provisioned → Configured → Released + +For more information, see docs/user-guide/commands.md" + } + Self::MissingInstanceIp { .. } => { + "Missing Instance IP Address - Troubleshooting: + +The release command requires the instance IP address to deploy files to the +remote host. This IP should be automatically set during provisioning. + +1. Check if the environment was provisioned correctly: + cat data//environment.json + Look for the 'instance_ip' field in runtime_outputs + +2. If instance_ip is null, the provision step may have failed: + cargo run -- provision + +3. For registered instances, ensure the IP was provided during registration + +4. If using LXD, verify the VM is running and has an IP: + lxc list + +Common causes: +- Provision step failed or was interrupted +- VM/container has no network connectivity +- DHCP lease not yet assigned +- Registration was incomplete + +For more information, see docs/user-guide/commands.md" + } + Self::StatePersistence(_) => { + "State Persistence Failed - Troubleshooting: + +1. Check file system permissions for the data directory +2. Verify available disk space: df -h +3. Ensure no other process is accessing the environment files +4. Check for file system errors: dmesg | tail +5. Verify the data directory is writable + +State files are stored in: data// + +If the problem persists, report it with full system details." + } + Self::TemplateRendering(_) => { + "Template Rendering Failed - Troubleshooting: + +1. Check that template files exist in the templates directory +2. Verify template syntax is valid +3. Check that all required template variables are provided +4. Verify file system permissions for the build directory + +Common causes: +- Missing template files +- Invalid template syntax +- Insufficient disk space +- Permission denied on build directory + +For more information, see docs/user-guide/commands.md" + } + Self::DeploymentFailed { source, .. } => source.help(), + Self::ReleaseOperationFailed { .. } => { + "Release Operation Failed - Troubleshooting: + +1. Verify the target instance is reachable: + ssh @ + +2. Check that required software is installed on the target + +3. Review the error message above for specific details + +4. Check network connectivity and firewall rules + +5. Verify SSH credentials are correct + +Common causes: +- Network connectivity issues +- SSH authentication failure +- Target instance not running +- Insufficient permissions on target + +For more information, see docs/user-guide/commands.md" + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::environment::repository::RepositoryError; + use crate::domain::environment::state::StateTypeError; + + #[test] + fn it_should_provide_help_for_environment_not_found() { + let error = ReleaseCommandHandlerError::EnvironmentNotFound { + name: "test-env".to_string(), + }; + + let help = error.help(); + assert!(help.contains("Environment Not Found")); + assert!(help.contains("Troubleshooting")); + } + + #[test] + fn it_should_provide_help_for_invalid_state() { + let error = ReleaseCommandHandlerError::InvalidState(StateTypeError::UnexpectedState { + expected: "configured", + actual: "created".to_string(), + }); + + let help = error.help(); + assert!(help.contains("Invalid Environment State")); + assert!(help.contains("Troubleshooting")); + } + + #[test] + fn it_should_provide_help_for_state_persistence() { + let error = ReleaseCommandHandlerError::StatePersistence(RepositoryError::NotFound); + + let help = error.help(); + assert!(help.contains("State Persistence")); + assert!(help.contains("Troubleshooting")); + } + + #[test] + fn it_should_provide_help_for_template_rendering() { + let error = ReleaseCommandHandlerError::TemplateRendering("Test error".to_string()); + + let help = error.help(); + assert!(help.contains("Template Rendering")); + assert!(help.contains("Troubleshooting")); + } + + #[test] + fn it_should_provide_help_for_release_operation_failed() { + let error = ReleaseCommandHandlerError::ReleaseOperationFailed { + name: "test-env".to_string(), + message: "Connection refused".to_string(), + }; + + let help = error.help(); + assert!(help.contains("Release Operation Failed")); + assert!(help.contains("Troubleshooting")); + } + + #[test] + fn it_should_provide_help_for_missing_instance_ip() { + let error = ReleaseCommandHandlerError::MissingInstanceIp { + name: "test-env".to_string(), + }; + + let help = error.help(); + assert!(help.contains("Missing Instance IP")); + assert!(help.contains("Troubleshooting")); + } + + #[test] + fn it_should_have_help_for_all_error_variants() { + let errors: Vec = vec![ + ReleaseCommandHandlerError::EnvironmentNotFound { + name: "test".to_string(), + }, + ReleaseCommandHandlerError::MissingInstanceIp { + name: "test".to_string(), + }, + ReleaseCommandHandlerError::InvalidState(StateTypeError::UnexpectedState { + expected: "configured", + actual: "created".to_string(), + }), + ReleaseCommandHandlerError::StatePersistence(RepositoryError::NotFound), + ReleaseCommandHandlerError::TemplateRendering("test".to_string()), + ReleaseCommandHandlerError::DeploymentFailed { + message: "test".to_string(), + source: DeployComposeFilesStepError::ComposeBuildDirNotFound { + path: "/tmp/test".to_string(), + }, + }, + ReleaseCommandHandlerError::ReleaseOperationFailed { + name: "test".to_string(), + message: "error".to_string(), + }, + ]; + + for error in errors { + let help = error.help(); + assert!(!help.is_empty(), "Help text should not be empty"); + assert!(help.len() > 50, "Help should be detailed"); + } + } +} diff --git a/src/application/command_handlers/release/handler.rs b/src/application/command_handlers/release/handler.rs new file mode 100644 index 00000000..a5ec93b8 --- /dev/null +++ b/src/application/command_handlers/release/handler.rs @@ -0,0 +1,349 @@ +//! Release command handler implementation + +use std::net::IpAddr; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use tracing::{error, info, instrument}; + +use super::errors::ReleaseCommandHandlerError; +use crate::adapters::ansible::AnsibleClient; +use crate::application::command_handlers::common::StepResult; +use crate::application::steps::{DeployComposeFilesStep, RenderDockerComposeTemplatesStep}; +use crate::domain::environment::repository::{EnvironmentRepository, TypedEnvironmentRepository}; +use crate::domain::environment::state::{ReleaseFailureContext, ReleaseStep}; +use crate::domain::environment::{Configured, Environment, Released, Releasing}; +use crate::domain::template::TemplateManager; +use crate::domain::EnvironmentName; +use crate::shared::error::Traceable; + +/// `ReleaseCommandHandler` orchestrates the software release workflow +/// +/// The `ReleaseCommandHandler` orchestrates the software release workflow to +/// deploy software to a configured environment. +/// +/// This command handler handles all steps required to release software: +/// 1. Load the environment from storage +/// 2. Validate the environment is in the correct state +/// 3. Render Docker Compose templates to the build directory +/// 4. Deploy compose files to the remote host via Ansible +/// 5. Transition environment to `Released` state +/// +/// # Architecture +/// +/// Follows the three-level architecture: +/// - **Command** (Level 1): This handler orchestrates the release workflow +/// - **Step** (Level 2): `RenderDockerComposeTemplatesStep`, `DeployComposeFilesStep` +/// - **Remote Action** (Level 3): Ansible playbook executes on remote host +/// +/// # State Management +/// +/// The command handler integrates with the type-state pattern for environment lifecycle: +/// - Accepts environment in `Configured` state +/// - Transitions to `Environment` at start +/// - Returns `Environment` on success +/// - Transitions to `Environment` on error +/// +/// State is persisted after each transition using the injected repository. +pub struct ReleaseCommandHandler { + clock: Arc, + repository: TypedEnvironmentRepository, +} + +impl ReleaseCommandHandler { + /// Create a new `ReleaseCommandHandler` + #[must_use] + pub fn new( + repository: Arc, + clock: Arc, + ) -> Self { + Self { + clock, + repository: TypedEnvironmentRepository::new(repository), + } + } + + /// Execute the release workflow + /// + /// # Arguments + /// + /// * `env_name` - The name of the environment to release to + /// + /// # Returns + /// + /// Returns `Ok(Environment)` on success + /// + /// # Errors + /// + /// Returns an error if: + /// * Environment not found + /// * Environment is not in `Configured` state + /// * Docker Compose template rendering fails + /// * File deployment to VM fails + /// * State persistence fails + #[instrument( + name = "release_command", + skip_all, + fields( + command_type = "release", + environment = %env_name + ) + )] + pub async fn execute( + &self, + env_name: &EnvironmentName, + ) -> Result, ReleaseCommandHandlerError> { + let environment = self.load_configured_environment(env_name)?; + + let instance_ip = environment.instance_ip().ok_or_else(|| { + ReleaseCommandHandlerError::MissingInstanceIp { + name: env_name.to_string(), + } + })?; + + let started_at = self.clock.now(); + + info!( + command = "release", + environment = %env_name, + instance_ip = %instance_ip, + current_state = "configured", + target_state = "releasing", + "Environment loaded and validated. Transitioning to Releasing state." + ); + + let releasing_env = environment.start_releasing(); + + self.repository.save_releasing(&releasing_env)?; + + info!( + command = "release", + environment = %env_name, + current_state = "releasing", + "Releasing state persisted. Executing release steps." + ); + + match self + .execute_release_workflow(&releasing_env, instance_ip) + .await + { + Ok(released) => { + info!( + command = "release", + environment = %released.name(), + final_state = "released", + "Software release completed successfully" + ); + + self.repository.save_released(&released)?; + + Ok(released) + } + Err((e, current_step)) => { + error!( + command = "release", + environment = %releasing_env.name(), + error = %e, + step = ?current_step, + "Software release failed" + ); + + let context = + self.build_failure_context(&releasing_env, &e, current_step, started_at); + let failed = releasing_env.release_failed(context); + + self.repository.save_release_failed(&failed)?; + + Err(e) + } + } + } + + /// Execute the release workflow with step tracking + /// + /// This method orchestrates the complete release workflow: + /// 1. Render Docker Compose templates to the build directory + /// 2. Deploy compose files to the remote host via Ansible + /// + /// If an error occurs, it returns both the error and the step that was being + /// executed, enabling accurate failure context generation. + /// + /// # Arguments + /// + /// * `environment` - The environment in Releasing state + /// * `instance_ip` - The validated instance IP address (precondition checked by caller) + /// + /// # Errors + /// + /// Returns a tuple of (error, `current_step`) if any release step fails + async fn execute_release_workflow( + &self, + environment: &Environment, + instance_ip: IpAddr, + ) -> StepResult, ReleaseCommandHandlerError, ReleaseStep> { + // Step 1: Render Docker Compose templates + let compose_build_dir = self.render_docker_compose_templates(environment).await?; + + // Step 2: Deploy compose files to remote + self.deploy_compose_files_to_remote(environment, &compose_build_dir, instance_ip)?; + + let released = environment.clone().released(); + + Ok(released) + } + + /// Render Docker Compose templates to the build directory + /// + /// # Errors + /// + /// Returns a tuple of (error, `ReleaseStep::RenderDockerComposeTemplates`) if rendering fails + async fn render_docker_compose_templates( + &self, + environment: &Environment, + ) -> StepResult { + let current_step = ReleaseStep::RenderDockerComposeTemplates; + + let template_manager = Arc::new(TemplateManager::new(environment.templates_dir())); + let step = RenderDockerComposeTemplatesStep::new( + template_manager, + environment.build_dir().clone(), + ); + + let compose_build_dir = step.execute().await.map_err(|e| { + ( + ReleaseCommandHandlerError::TemplateRendering(e.to_string()), + current_step, + ) + })?; + + info!( + command = "release", + compose_build_dir = %compose_build_dir.display(), + "Docker Compose templates rendered successfully" + ); + + Ok(compose_build_dir) + } + + /// Deploy compose files to the remote host via Ansible + /// + /// # Arguments + /// + /// * `environment` - The environment in Releasing state + /// * `compose_build_dir` - Path to the rendered compose files + /// * `instance_ip` - The target instance IP address + /// + /// # Errors + /// + /// Returns a tuple of (error, `ReleaseStep::DeployComposeFilesToRemote`) if deployment fails + #[allow(clippy::result_large_err, clippy::unused_self)] + fn deploy_compose_files_to_remote( + &self, + environment: &Environment, + compose_build_dir: &Path, + instance_ip: IpAddr, + ) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { + let current_step = ReleaseStep::DeployComposeFilesToRemote; + + let ansible_client = Arc::new(AnsibleClient::new(environment.ansible_build_dir())); + let step = DeployComposeFilesStep::new(ansible_client, compose_build_dir.to_path_buf()); + + step.execute().map_err(|e| { + ( + ReleaseCommandHandlerError::DeploymentFailed { + message: e.to_string(), + source: e, + }, + current_step, + ) + })?; + + info!( + command = "release", + compose_build_dir = %compose_build_dir.display(), + instance_ip = %instance_ip, + "Compose files deployed to remote host successfully" + ); + + Ok(()) + } + + /// Build failure context for a release error and generate trace file + /// + /// This helper method builds structured error context including the failed step, + /// error classification, timing information, and generates a trace file for + /// post-mortem analysis. + /// + /// # Arguments + /// + /// * `environment` - The environment being released (for trace directory path) + /// * `error` - The release error that occurred + /// * `current_step` - The step that was executing when the error occurred + /// * `started_at` - The timestamp when release execution started + /// + /// # Returns + /// + /// A `ReleaseFailureContext` with all failure metadata and trace file path + fn build_failure_context( + &self, + environment: &Environment, + error: &ReleaseCommandHandlerError, + current_step: ReleaseStep, + started_at: chrono::DateTime, + ) -> ReleaseFailureContext { + use crate::application::command_handlers::common::failure_context::build_base_failure_context; + use crate::infrastructure::trace::ReleaseTraceWriter; + + // Step that failed is directly provided - no reverse engineering needed + let failed_step = current_step; + + // Get error kind from the error itself (errors are self-describing) + let error_kind = error.error_kind(); + + // Build base failure context using common helper + let base = build_base_failure_context(&self.clock, started_at, error.to_string()); + + // Build handler-specific context + let mut context = ReleaseFailureContext { + failed_step, + error_kind, + base, + }; + + // Generate trace file (logging handled by trace writer) + let traces_dir = environment.traces_dir(); + let writer = ReleaseTraceWriter::new(traces_dir, Arc::clone(&self.clock)); + + if let Ok(trace_file) = writer.write_trace(&context, error) { + context.base.trace_file_path = Some(trace_file); + } + + context + } + + /// Load environment from storage and validate it is in `Configured` state + /// + /// # Errors + /// + /// Returns an error if: + /// * Persistence error occurs during load + /// * Environment does not exist + /// * Environment is not in `Configured` state + #[allow(clippy::result_large_err)] + fn load_configured_environment( + &self, + env_name: &EnvironmentName, + ) -> Result, ReleaseCommandHandlerError> { + let any_env = self + .repository + .inner() + .load(env_name) + .map_err(ReleaseCommandHandlerError::StatePersistence)?; + + let any_env = any_env.ok_or_else(|| ReleaseCommandHandlerError::EnvironmentNotFound { + name: env_name.to_string(), + })?; + + Ok(any_env.try_into_configured()?) + } +} diff --git a/src/application/command_handlers/release/mod.rs b/src/application/command_handlers/release/mod.rs new file mode 100644 index 00000000..2b56b592 --- /dev/null +++ b/src/application/command_handlers/release/mod.rs @@ -0,0 +1,49 @@ +//! Release Command Module +//! +//! This module implements the delivery-agnostic `ReleaseCommandHandler` +//! for orchestrating software release operations on target instances. +//! +//! ## Architecture +//! +//! The `ReleaseCommandHandler` implements the Command Pattern and uses Dependency Injection +//! to interact with infrastructure services through interfaces: +//! +//! - **Repository Pattern**: Persists environment state via `EnvironmentRepository` +//! - **Clock Abstraction**: Provides deterministic time for testing via `Clock` trait +//! - **Domain-Driven Design**: Uses domain objects from `domain::environment` +//! +//! ## Design Principles +//! +//! - **Delivery-Agnostic**: Works with CLI, REST API, or any delivery mechanism +//! - **Synchronous**: Follows existing patterns (no async/await) +//! - **Explicit State Transitions**: Type-safe state machine for environment lifecycle +//! - **Explicit Errors**: All errors implement `.help()` with actionable guidance +//! +//! ## Release Workflow +//! +//! The command handler orchestrates a multi-step workflow: +//! +//! 1. **Load environment** - Retrieve environment from repository +//! 2. **Validate state** - Ensure environment is in a valid state for release +//! 3. **Release software** - Deploy software to the target instance +//! +//! ## State Management +//! +//! The command handler integrates with the type-state pattern for environment lifecycle: +//! +//! - Accepts environment in `Configured` state +//! - Transitions to `Environment` at start +//! - Returns `Environment` on success +//! - Transitions to `Environment` on error +//! +//! State is persisted after each transition using the injected repository. + +pub mod errors; +pub mod handler; + +#[cfg(test)] +mod tests; + +// Re-export main types for convenience +pub use errors::ReleaseCommandHandlerError; +pub use handler::ReleaseCommandHandler; diff --git a/src/application/command_handlers/release/tests.rs b/src/application/command_handlers/release/tests.rs new file mode 100644 index 00000000..30a5b8b2 --- /dev/null +++ b/src/application/command_handlers/release/tests.rs @@ -0,0 +1,45 @@ +//! Test module for Release Command +//! +//! This module contains test infrastructure and test cases for the `ReleaseCommandHandler`. + +use std::sync::Arc; + +use chrono::Utc; +use tempfile::TempDir; + +use super::handler::ReleaseCommandHandler; +use crate::domain::EnvironmentName; +use crate::infrastructure::persistence::filesystem::file_environment_repository::FileEnvironmentRepository; +use crate::testing::mock_clock::MockClock; + +/// Helper to create a test handler with mock dependencies in a temp directory +fn create_test_handler() -> (ReleaseCommandHandler, TempDir) { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let clock = Arc::new(MockClock::new(Utc::now())); + let repository = Arc::new(FileEnvironmentRepository::new( + temp_dir.path().to_path_buf(), + )); + let handler = ReleaseCommandHandler::new(repository, clock); + (handler, temp_dir) +} + +#[test] +fn it_should_create_handler_with_dependencies() { + let (_handler, _temp_dir) = create_test_handler(); + // Handler was created successfully - basic construction verified +} + +#[tokio::test] +async fn it_should_return_environment_not_found_error_when_environment_does_not_exist() { + let (handler, _temp_dir) = create_test_handler(); + let env_name = EnvironmentName::new("nonexistent-env").unwrap(); + + let result = handler.execute(&env_name).await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!( + error.to_string().contains("not found"), + "Expected 'not found' error, got: {error}" + ); +} diff --git a/src/application/command_handlers/run/errors.rs b/src/application/command_handlers/run/errors.rs new file mode 100644 index 00000000..a50fe995 --- /dev/null +++ b/src/application/command_handlers/run/errors.rs @@ -0,0 +1,356 @@ +//! Error types for the Run command handler + +use crate::application::steps::application::StartServicesStepError; +use crate::domain::environment::state::StateTypeError; +use crate::shared::error::{ErrorKind, Traceable}; + +/// Comprehensive error type for the `RunCommandHandler` +/// +/// This error type captures all possible failures that can occur during +/// stack execution operations. Each variant provides detailed context +/// and actionable troubleshooting guidance. +#[derive(Debug, thiserror::Error)] +pub enum RunCommandHandlerError { + /// Environment was not found in the repository + #[error("Environment not found: {name}")] + EnvironmentNotFound { + /// The name of the environment that was not found + name: String, + }, + + /// Instance IP address is not available (required for running services) + /// + /// The run command requires the instance IP address to start services + /// on the remote host. This IP should be available after provisioning. + #[error("Instance IP address is not available for environment '{name}'. The provision step should have set this value.")] + MissingInstanceIp { + /// The name of the environment missing the instance IP + name: String, + }, + + /// Environment is in an invalid state for running + #[error("Environment is in an invalid state for running: {0}")] + InvalidState(#[from] StateTypeError), + + /// Failed to persist environment state + #[error("Failed to persist environment state: {0}")] + StatePersistence(#[from] crate::domain::environment::repository::RepositoryError), + + /// Starting services on remote host failed + #[error("Starting services failed: {message}")] + StartServicesFailed { + /// Description of the failure + message: String, + /// The underlying step error + #[source] + source: StartServicesStepError, + }, + + /// Run operation failed + #[error("Run operation failed for environment '{name}': {message}")] + RunOperationFailed { + /// The name of the environment + name: String, + /// Description of the failure + message: String, + }, +} + +impl Traceable for RunCommandHandlerError { + fn trace_format(&self) -> String { + match self { + Self::EnvironmentNotFound { name } => { + format!("RunCommandHandlerError: Environment not found - {name}") + } + Self::MissingInstanceIp { name } => { + format!( + "RunCommandHandlerError: Instance IP not available for environment '{name}'" + ) + } + Self::InvalidState(e) => { + format!("RunCommandHandlerError: Invalid state for run - {e}") + } + Self::StatePersistence(e) => { + format!("RunCommandHandlerError: Failed to persist environment state - {e}") + } + Self::StartServicesFailed { message, .. } => { + format!("RunCommandHandlerError: Start services failed - {message}") + } + Self::RunOperationFailed { name, message } => { + format!("RunCommandHandlerError: Run operation failed for '{name}' - {message}") + } + } + } + + fn trace_source(&self) -> Option<&dyn Traceable> { + match self { + Self::StartServicesFailed { source, .. } => Some(source), + Self::StatePersistence(_) + | Self::EnvironmentNotFound { .. } + | Self::MissingInstanceIp { .. } + | Self::InvalidState(_) + | Self::RunOperationFailed { .. } => None, + } + } + + fn error_kind(&self) -> ErrorKind { + match self { + Self::EnvironmentNotFound { .. } + | Self::MissingInstanceIp { .. } + | Self::InvalidState(_) => ErrorKind::Configuration, + Self::StatePersistence(_) => ErrorKind::StatePersistence, + Self::StartServicesFailed { source, .. } => source.error_kind(), + Self::RunOperationFailed { .. } => ErrorKind::InfrastructureOperation, + } + } +} + +impl RunCommandHandlerError { + /// Provides detailed troubleshooting guidance for this error + /// + /// Returns context-specific help text that guides users toward resolving + /// the issue. This implements the project's tiered help system pattern + /// for actionable error messages. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::application::command_handlers::run::RunCommandHandlerError; + /// + /// let error = RunCommandHandlerError::EnvironmentNotFound { + /// name: "my-env".to_string(), + /// }; + /// + /// let help = error.help(); + /// assert!(help.contains("Environment Not Found")); + /// assert!(help.contains("Troubleshooting")); + /// ``` + #[must_use] + pub fn help(&self) -> &'static str { + match self { + Self::EnvironmentNotFound { .. } => { + "Environment Not Found - Troubleshooting: + +1. Verify the environment name is correct +2. Check if the environment was created: + ls data/ + +3. If the environment doesn't exist, create it first: + cargo run -- create environment --env-file + +4. If the environment was previously destroyed, recreate it + +Common causes: +- Typo in environment name +- Environment was destroyed +- Working in the wrong directory + +For more information, see docs/user-guide/commands.md" + } + Self::MissingInstanceIp { .. } => { + "Missing Instance IP Address - Troubleshooting: + +The run command requires the instance IP address to start services on the +remote host. This IP should be automatically set during provisioning. + +1. Check if the environment was provisioned correctly: + cat data//environment.json + Look for the 'instance_ip' field in runtime_outputs + +2. If instance_ip is null, the provision step may have failed: + cargo run -- provision + +3. For registered instances, ensure the IP was provided during registration + +4. If using LXD, verify the VM is running and has an IP: + lxc list + +Common causes: +- Provision step failed or was interrupted +- VM/container has no network connectivity +- DHCP lease not yet assigned +- Registration was incomplete + +For more information, see docs/user-guide/commands.md" + } + Self::InvalidState { .. } => { + "Invalid Environment State - Troubleshooting: + +1. The run command requires the environment to be in Released state +2. Check the current environment state: + cat data//environment.json + +3. If the environment is not released, run: + cargo run -- release + +4. If the environment is in a failed state, resolve the issue first + +State progression for run: + Created → Provisioned → Configured → Released → Running + +For more information, see docs/user-guide/commands.md" + } + Self::StatePersistence(_) => { + "State Persistence Failed - Troubleshooting: + +1. Check file system permissions for the data directory +2. Verify available disk space: df -h +3. Ensure no other process is accessing the environment files +4. Check for file system errors: dmesg | tail +5. Verify the data directory is writable + +State files are stored in: data// + +If the problem persists, report it with full system details." + } + Self::StartServicesFailed { source, .. } => source.help(), + Self::RunOperationFailed { .. } => { + "Run Operation Failed - Troubleshooting: + +1. Verify the target instance is reachable: + ssh @ + +2. Check that the software was properly released: + cargo run -- release + +3. Review the error message above for specific details + +4. Check service logs on the target instance + +5. Verify network connectivity and firewall rules + +Common causes: +- Service failed to start +- Port already in use +- Configuration errors +- Missing dependencies on target + +For more information, see docs/user-guide/commands.md" + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::environment::repository::RepositoryError; + use crate::domain::environment::state::StateTypeError; + use crate::shared::command::CommandError; + + #[test] + fn it_should_provide_help_for_environment_not_found() { + let error = RunCommandHandlerError::EnvironmentNotFound { + name: "test-env".to_string(), + }; + + let help = error.help(); + assert!(help.contains("Environment Not Found")); + assert!(help.contains("Troubleshooting")); + } + + #[test] + fn it_should_provide_help_for_missing_instance_ip() { + let error = RunCommandHandlerError::MissingInstanceIp { + name: "test-env".to_string(), + }; + + let help = error.help(); + assert!(help.contains("Missing Instance IP")); + assert!(help.contains("Troubleshooting")); + } + + #[test] + fn it_should_provide_help_for_invalid_state() { + let error = RunCommandHandlerError::InvalidState(StateTypeError::UnexpectedState { + expected: "Released", + actual: "Configured".to_string(), + }); + + let help = error.help(); + assert!(help.contains("Invalid Environment State")); + assert!(help.contains("Troubleshooting")); + } + + #[test] + fn it_should_provide_help_for_state_persistence() { + let error = RunCommandHandlerError::StatePersistence(RepositoryError::NotFound); + + let help = error.help(); + assert!(help.contains("State Persistence")); + assert!(help.contains("Troubleshooting")); + } + + #[test] + fn it_should_provide_help_for_start_services_failed() { + let cmd_error = CommandError::ExecutionFailed { + command: "ansible-playbook".to_string(), + exit_code: "1".to_string(), + stdout: String::new(), + stderr: "connection refused".to_string(), + }; + let error = RunCommandHandlerError::StartServicesFailed { + message: "Ansible playbook failed".to_string(), + source: StartServicesStepError::AnsiblePlaybookFailed { + message: "connection refused".to_string(), + source: cmd_error, + }, + }; + + let help = error.help(); + assert!(help.contains("Docker daemon")); + assert!(help.contains("release")); + } + + #[test] + fn it_should_provide_help_for_run_operation_failed() { + let error = RunCommandHandlerError::RunOperationFailed { + name: "test-env".to_string(), + message: "Service failed to start".to_string(), + }; + + let help = error.help(); + assert!(help.contains("Run Operation Failed")); + assert!(help.contains("Troubleshooting")); + } + + #[test] + fn it_should_have_help_for_all_error_variants() { + let cmd_error = CommandError::ExecutionFailed { + command: "ansible-playbook".to_string(), + exit_code: "1".to_string(), + stdout: String::new(), + stderr: "test".to_string(), + }; + let errors: Vec = vec![ + RunCommandHandlerError::EnvironmentNotFound { + name: "test".to_string(), + }, + RunCommandHandlerError::MissingInstanceIp { + name: "test".to_string(), + }, + RunCommandHandlerError::InvalidState(StateTypeError::UnexpectedState { + expected: "Released", + actual: "Configured".to_string(), + }), + RunCommandHandlerError::StatePersistence(RepositoryError::NotFound), + RunCommandHandlerError::StartServicesFailed { + message: "test".to_string(), + source: StartServicesStepError::AnsiblePlaybookFailed { + message: "test".to_string(), + source: cmd_error, + }, + }, + RunCommandHandlerError::RunOperationFailed { + name: "test".to_string(), + message: "error".to_string(), + }, + ]; + + for error in errors { + let help = error.help(); + assert!(!help.is_empty(), "Help text should not be empty"); + assert!(help.len() > 50, "Help should be detailed"); + } + } +} diff --git a/src/application/command_handlers/run/handler.rs b/src/application/command_handlers/run/handler.rs new file mode 100644 index 00000000..76e27ed8 --- /dev/null +++ b/src/application/command_handlers/run/handler.rs @@ -0,0 +1,290 @@ +//! Run command handler implementation + +use std::net::IpAddr; +use std::sync::Arc; + +use tracing::{error, info, instrument}; + +use super::errors::RunCommandHandlerError; +use crate::adapters::ansible::AnsibleClient; +use crate::application::command_handlers::common::StepResult; +use crate::application::steps::application::StartServicesStep; +use crate::domain::environment::repository::{EnvironmentRepository, TypedEnvironmentRepository}; +use crate::domain::environment::state::{RunFailureContext, RunStep}; +use crate::domain::environment::{Environment, Released, Running}; +use crate::domain::EnvironmentName; +use crate::shared::error::Traceable; + +/// `RunCommandHandler` orchestrates the stack execution workflow +/// +/// The `RunCommandHandler` orchestrates the execution of the deployed software +/// stack on the target environment. +/// +/// This command handler handles all steps required to run the stack: +/// 1. Load the environment from storage +/// 2. Validate the environment is in the correct state +/// 3. Start services via Ansible playbook +/// 4. Transition environment to `Running` state +/// +/// # Architecture +/// +/// Follows the three-level architecture: +/// - **Command** (Level 1): This handler orchestrates the run workflow +/// - **Step** (Level 2): `StartServicesStep` +/// - **Remote Action** (Level 3): Ansible playbook executes on remote host +/// +/// # State Management +/// +/// The command handler integrates with the type-state pattern for environment lifecycle: +/// - Accepts environment in `Released` state +/// - Transitions to `Environment` on success +/// - Transitions to `Environment` on error +/// +/// State is persisted after each transition using the injected repository. +pub struct RunCommandHandler { + pub(crate) clock: Arc, + pub(crate) repository: TypedEnvironmentRepository, +} + +impl RunCommandHandler { + /// Create a new `RunCommandHandler` + #[must_use] + pub fn new( + repository: Arc, + clock: Arc, + ) -> Self { + Self { + clock, + repository: TypedEnvironmentRepository::new(repository), + } + } + + /// Execute the run workflow + /// + /// # Arguments + /// + /// * `env_name` - The name of the environment to run + /// + /// # Returns + /// + /// Returns `Ok(Environment)` on success + /// + /// # Errors + /// + /// Returns an error if: + /// * Environment not found + /// * Environment is not in `Released` state + /// * Instance IP is not available + /// * Starting services fails + /// * State persistence fails + #[allow(clippy::result_large_err)] + #[instrument( + name = "run_command", + skip_all, + fields( + command_type = "run", + environment = %env_name + ) + )] + pub fn execute( + &self, + env_name: &EnvironmentName, + ) -> Result, RunCommandHandlerError> { + let environment = self.load_released_environment(env_name)?; + + let instance_ip = + environment + .instance_ip() + .ok_or_else(|| RunCommandHandlerError::MissingInstanceIp { + name: env_name.to_string(), + })?; + + let started_at = self.clock.now(); + + info!( + command = "run", + environment = %env_name, + instance_ip = %instance_ip, + current_state = "released", + target_state = "running", + "Environment loaded and validated. Executing run steps." + ); + + match self.execute_run_workflow(&environment, instance_ip) { + Ok(running) => { + info!( + command = "run", + environment = %running.name(), + final_state = "running", + "Stack execution completed successfully" + ); + + self.repository.save_running(&running)?; + + Ok(running) + } + Err((e, current_step)) => { + error!( + command = "run", + environment = %environment.name(), + error = %e, + step = ?current_step, + "Stack execution failed" + ); + + let context = + self.build_failure_context(&environment, &e, current_step, started_at); + + let failed = environment.start_running().run_failed(context); + + self.repository.save_run_failed(&failed)?; + + Err(e) + } + } + } + + /// Execute the run workflow with step tracking + /// + /// This method orchestrates the complete run workflow: + /// 1. Start Docker Compose services on the remote host + /// + /// If an error occurs, it returns both the error and the step that was being + /// executed, enabling accurate failure context generation. + /// + /// # Arguments + /// + /// * `environment` - The environment in Released state + /// * `instance_ip` - The validated instance IP address (precondition checked by caller) + /// + /// # Errors + /// + /// Returns a tuple of (error, `current_step`) if any run step fails + #[allow(clippy::result_large_err)] + fn execute_run_workflow( + &self, + environment: &Environment, + instance_ip: IpAddr, + ) -> StepResult, RunCommandHandlerError, RunStep> { + // Step 1: Start Docker Compose services + self.start_services(environment, instance_ip)?; + + let running = environment.clone().start_running(); + + Ok(running) + } + + /// Start Docker Compose services on the remote host via Ansible + /// + /// # Errors + /// + /// Returns a tuple of (error, `RunStep::StartServices`) if starting services fails + #[allow(clippy::result_large_err, clippy::unused_self)] + fn start_services( + &self, + environment: &Environment, + instance_ip: IpAddr, + ) -> StepResult<(), RunCommandHandlerError, RunStep> { + let current_step = RunStep::StartServices; + + let ansible_client = Arc::new(AnsibleClient::new(environment.ansible_build_dir())); + let step = StartServicesStep::new(ansible_client); + + step.execute().map_err(|e| { + ( + RunCommandHandlerError::StartServicesFailed { + message: e.to_string(), + source: e, + }, + current_step, + ) + })?; + + info!( + command = "run", + instance_ip = %instance_ip, + "Docker Compose services started successfully" + ); + + Ok(()) + } + + /// Build failure context for a run error and generate trace file + /// + /// This helper method builds structured error context including the failed step, + /// error classification, timing information, and generates a trace file for + /// post-mortem analysis. + /// + /// # Arguments + /// + /// * `environment` - The environment being run (for trace directory path) + /// * `error` - The run error that occurred + /// * `current_step` - The step that was executing when the error occurred + /// * `started_at` - The timestamp when run execution started + /// + /// # Returns + /// + /// A `RunFailureContext` with all failure metadata and trace file path + fn build_failure_context( + &self, + environment: &Environment, + error: &RunCommandHandlerError, + current_step: RunStep, + started_at: chrono::DateTime, + ) -> RunFailureContext { + use crate::application::command_handlers::common::failure_context::build_base_failure_context; + use crate::infrastructure::trace::RunTraceWriter; + + // Step that failed is directly provided - no reverse engineering needed + let failed_step = current_step; + + // Get error kind from the error itself (errors are self-describing) + let error_kind = error.error_kind(); + + // Build base failure context using common helper + let base = build_base_failure_context(&self.clock, started_at, error.to_string()); + + // Build handler-specific context + let mut context = RunFailureContext { + failed_step, + error_kind, + base, + }; + + // Generate trace file (logging handled by trace writer) + let traces_dir = environment.traces_dir(); + let writer = RunTraceWriter::new(traces_dir, Arc::clone(&self.clock)); + + if let Ok(trace_file) = writer.write_trace(&context, error) { + context.base.trace_file_path = Some(trace_file); + } + + context + } + + /// Load environment from storage and validate it is in `Released` state + /// + /// # Errors + /// + /// Returns an error if: + /// * Persistence error occurs during load + /// * Environment does not exist + /// * Environment is not in `Released` state + #[allow(clippy::result_large_err)] + fn load_released_environment( + &self, + env_name: &EnvironmentName, + ) -> Result, RunCommandHandlerError> { + let any_env = self + .repository + .inner() + .load(env_name) + .map_err(RunCommandHandlerError::StatePersistence)?; + + let any_env = any_env.ok_or_else(|| RunCommandHandlerError::EnvironmentNotFound { + name: env_name.to_string(), + })?; + + Ok(any_env.try_into_released()?) + } +} diff --git a/src/application/command_handlers/run/mod.rs b/src/application/command_handlers/run/mod.rs new file mode 100644 index 00000000..ccca3760 --- /dev/null +++ b/src/application/command_handlers/run/mod.rs @@ -0,0 +1,49 @@ +//! Run Command Module +//! +//! This module implements the delivery-agnostic `RunCommandHandler` +//! for orchestrating the execution of the deployed software stack. +//! +//! ## Architecture +//! +//! The `RunCommandHandler` implements the Command Pattern and uses Dependency Injection +//! to interact with infrastructure services through interfaces: +//! +//! - **Repository Pattern**: Persists environment state via `EnvironmentRepository` +//! - **Clock Abstraction**: Provides deterministic time for testing via `Clock` trait +//! - **Domain-Driven Design**: Uses domain objects from `domain::environment` +//! +//! ## Design Principles +//! +//! - **Delivery-Agnostic**: Works with CLI, REST API, or any delivery mechanism +//! - **Synchronous**: Follows existing patterns (no async/await) +//! - **Explicit State Transitions**: Type-safe state machine for environment lifecycle +//! - **Explicit Errors**: All errors implement `.help()` with actionable guidance +//! +//! ## Run Workflow +//! +//! The command handler orchestrates a multi-step workflow: +//! +//! 1. **Load environment** - Retrieve environment from repository +//! 2. **Validate state** - Ensure environment is in a valid state for running +//! 3. **Start services** - Start the deployed software stack on the target instance +//! +//! ## State Management +//! +//! The command handler integrates with the type-state pattern for environment lifecycle: +//! +//! - Accepts environment in `Released` state +//! - Transitions to `Environment` at start +//! - Returns `Environment` on success +//! - Transitions to `Environment` on error +//! +//! State is persisted after each transition using the injected repository. + +pub mod errors; +pub mod handler; + +#[cfg(test)] +mod tests; + +// Re-export main types for convenience +pub use errors::RunCommandHandlerError; +pub use handler::RunCommandHandler; diff --git a/src/application/command_handlers/run/tests.rs b/src/application/command_handlers/run/tests.rs new file mode 100644 index 00000000..0b8bf1d6 --- /dev/null +++ b/src/application/command_handlers/run/tests.rs @@ -0,0 +1,46 @@ +//! Test module for Run Command +//! +//! This module contains test infrastructure and test cases for the `RunCommandHandler`. + +use std::sync::Arc; + +use chrono::Utc; +use tempfile::TempDir; + +use super::handler::RunCommandHandler; +use crate::domain::EnvironmentName; +use crate::infrastructure::persistence::filesystem::file_environment_repository::FileEnvironmentRepository; +use crate::testing::mock_clock::MockClock; + +/// Helper to create a test handler with mock dependencies in a temp directory +fn create_test_handler() -> (RunCommandHandler, TempDir) { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let clock = Arc::new(MockClock::new(Utc::now())); + let repository = Arc::new(FileEnvironmentRepository::new( + temp_dir.path().to_path_buf(), + )); + let handler = RunCommandHandler::new(repository, clock); + (handler, temp_dir) +} + +#[test] +fn it_should_create_handler_with_dependencies() { + let (handler, _temp_dir) = create_test_handler(); + // Handler was created successfully - verify basic construction + assert!(Arc::strong_count(handler.repository.inner()) >= 1); +} + +#[test] +fn it_should_return_environment_not_found_error_when_environment_does_not_exist() { + let (handler, _temp_dir) = create_test_handler(); + let env_name = EnvironmentName::new("nonexistent-env").unwrap(); + + let result = handler.execute(&env_name); + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!( + error.to_string().contains("not found"), + "Expected 'not found' error, got: {error}" + ); +} diff --git a/src/application/command_handlers/test/errors.rs b/src/application/command_handlers/test/errors.rs index 149b19fd..2f9040ea 100644 --- a/src/application/command_handlers/test/errors.rs +++ b/src/application/command_handlers/test/errors.rs @@ -8,6 +8,9 @@ use crate::shared::command::CommandError; /// Comprehensive error type for the `TestCommandHandler` #[derive(Debug, thiserror::Error)] pub enum TestCommandHandlerError { + #[error("Environment not found: '{name}'")] + EnvironmentNotFound { name: String }, + #[error("Command execution failed: {0}")] Command(#[from] CommandError), @@ -23,3 +26,206 @@ pub enum TestCommandHandlerError { #[error("State persistence error: {0}")] StatePersistence(#[from] RepositoryError), } + +impl crate::shared::Traceable for TestCommandHandlerError { + fn trace_format(&self) -> String { + match self { + Self::EnvironmentNotFound { name } => { + format!("TestCommandHandlerError: Environment not found - '{name}'") + } + Self::Command(e) => { + format!("TestCommandHandlerError: Command execution failed - {e}") + } + Self::RemoteAction(e) => { + format!("TestCommandHandlerError: Remote action failed - {e}") + } + Self::MissingInstanceIp { environment_name } => { + format!( + "TestCommandHandlerError: Missing instance IP for environment '{environment_name}'" + ) + } + Self::StateTransition(e) => { + format!("TestCommandHandlerError: Invalid state transition - {e}") + } + Self::StatePersistence(e) => { + format!("TestCommandHandlerError: State persistence error - {e}") + } + } + } + + fn trace_source(&self) -> Option<&dyn crate::shared::Traceable> { + match self { + Self::Command(e) => Some(e), + Self::EnvironmentNotFound { .. } + | Self::RemoteAction(_) + | Self::MissingInstanceIp { .. } + | Self::StateTransition(_) + | Self::StatePersistence(_) => None, + } + } + + fn error_kind(&self) -> crate::shared::ErrorKind { + match self { + Self::EnvironmentNotFound { .. } | Self::MissingInstanceIp { .. } => { + crate::shared::ErrorKind::Configuration + } + Self::Command(_) | Self::RemoteAction(_) => crate::shared::ErrorKind::CommandExecution, + Self::StateTransition(_) | Self::StatePersistence(_) => { + crate::shared::ErrorKind::StatePersistence + } + } + } +} + +impl TestCommandHandlerError { + /// Provides detailed troubleshooting guidance for this error + #[must_use] + pub fn help(&self) -> &'static str { + match self { + Self::EnvironmentNotFound { .. } => { + "Environment Not Found - Troubleshooting: + +1. Verify the environment name is correct +2. Check if the environment was created: + ls data/ + +3. If the environment doesn't exist, create it first: + cargo run -- create environment --env-file + +4. If the environment was previously destroyed, recreate it + +Common causes: +- Typo in environment name +- Environment was destroyed +- Working in the wrong directory + +For more information, see docs/user-guide/commands.md" + } + Self::Command(_) => { + "Command Execution Failed - Troubleshooting: + +1. Check that required tools are installed +2. Verify PATH environment variable includes tool locations +3. Check command permissions and executability +4. Review stderr output above for specific error details + +For tool installation, see the setup documentation." + } + Self::RemoteAction(_) => { + "Remote Action Failed - Troubleshooting: + +1. Verify the instance is running +2. Check SSH connectivity to the instance +3. Ensure the remote command is available on the instance +4. Review the error message for specific details + +For SSH troubleshooting, see docs/contributing/debugging.md" + } + Self::MissingInstanceIp { .. } => { + "Missing Instance IP - Troubleshooting: + +The environment does not have an instance IP address set. +This typically means the environment was created but not provisioned. + +1. Check the environment state: + cat data//environment.json + +2. Provision the environment first: + cargo run -- provision + +3. Then run the test command + +For workflow details, see docs/deployment-overview.md" + } + Self::StateTransition(_) => { + "Invalid State Transition - Troubleshooting: + +The environment is not in the expected state for this operation. + +1. Check current environment state +2. Verify the workflow sequence +3. If environment is in wrong state, destroy and recreate if needed + +For workflow details, see docs/deployment-overview.md" + } + Self::StatePersistence(_) => { + "State Persistence Failed - Troubleshooting: + +1. Check file system permissions for the data directory +2. Verify available disk space: df -h +3. Ensure no other process is accessing the environment files +4. Check for file system errors: dmesg | tail + +State files are stored in: data// + +If the problem persists, report it with full system details." + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_provide_help_for_environment_not_found() { + let error = TestCommandHandlerError::EnvironmentNotFound { + name: "test-env".to_string(), + }; + + let help = error.help(); + assert!(help.contains("Environment Not Found")); + assert!(help.contains("Troubleshooting")); + assert!(help.contains("environment name")); + } + + #[test] + fn it_should_provide_help_for_missing_instance_ip() { + let error = TestCommandHandlerError::MissingInstanceIp { + environment_name: "test-env".to_string(), + }; + + let help = error.help(); + assert!(help.contains("Missing Instance IP")); + assert!(help.contains("Troubleshooting")); + assert!(help.contains("provision")); + } + + #[test] + fn it_should_have_help_for_all_error_variants() { + use crate::domain::environment::repository::RepositoryError; + use crate::domain::environment::state::StateTypeError; + use crate::shared::command::CommandError; + + let errors: Vec = vec![ + TestCommandHandlerError::EnvironmentNotFound { + name: "test-env".to_string(), + }, + TestCommandHandlerError::Command(CommandError::ExecutionFailed { + command: "test".to_string(), + exit_code: "1".to_string(), + stdout: String::new(), + stderr: "error".to_string(), + }), + TestCommandHandlerError::MissingInstanceIp { + environment_name: "test-env".to_string(), + }, + TestCommandHandlerError::StateTransition(StateTypeError::UnexpectedState { + expected: "Provisioned", + actual: "Created".to_string(), + }), + TestCommandHandlerError::StatePersistence(RepositoryError::NotFound), + ]; + + for error in errors { + let help = error.help(); + assert!(!help.is_empty(), "Help text should not be empty"); + assert!( + help.contains("Troubleshooting"), + "Help should contain troubleshooting guidance" + ); + assert!(help.len() > 50, "Help should be detailed"); + } + } +} diff --git a/src/application/command_handlers/test/handler.rs b/src/application/command_handlers/test/handler.rs index 94be128b..730f3b32 100644 --- a/src/application/command_handlers/test/handler.rs +++ b/src/application/command_handlers/test/handler.rs @@ -32,9 +32,7 @@ use crate::application::steps::{ ValidateCloudInitCompletionStep, ValidateDockerComposeInstallationStep, ValidateDockerInstallationStep, }; -use crate::domain::environment::repository::{ - EnvironmentRepository, RepositoryError, TypedEnvironmentRepository, -}; +use crate::domain::environment::repository::{EnvironmentRepository, TypedEnvironmentRepository}; use crate::domain::EnvironmentName; /// `TestCommandHandler` orchestrates smoke testing for running Torrust Tracker services @@ -104,25 +102,8 @@ impl TestCommandHandler { ) )] pub async fn execute(&self, env_name: &EnvironmentName) -> Result<(), TestCommandHandlerError> { - info!( - command = "test", - environment = %env_name, - "Starting complete infrastructure testing workflow" - ); - - // 1. Load the environment from storage (returns AnyEnvironmentState - type-erased) - let any_env = self - .repository - .inner() - .load(env_name) - .map_err(TestCommandHandlerError::StatePersistence)?; - - // 2. Check if environment exists - let any_env = any_env.ok_or(TestCommandHandlerError::StatePersistence( - RepositoryError::NotFound, - ))?; + let any_env = self.load_environment(env_name)?; - // 3. Extract instance IP (runtime check - works with any state) let instance_ip = any_env .instance_ip() @@ -154,4 +135,27 @@ impl TestCommandHandler { Ok(()) } + + /// Load environment from storage + /// + /// # Errors + /// + /// Returns an error if: + /// * Persistence error occurs during load + /// * Environment does not exist + fn load_environment( + &self, + env_name: &EnvironmentName, + ) -> Result + { + let any_env = self + .repository + .inner() + .load(env_name) + .map_err(TestCommandHandlerError::StatePersistence)?; + + any_env.ok_or_else(|| TestCommandHandlerError::EnvironmentNotFound { + name: env_name.to_string(), + }) + } } diff --git a/src/application/steps/application/deploy_compose_files.rs b/src/application/steps/application/deploy_compose_files.rs new file mode 100644 index 00000000..84f20b27 --- /dev/null +++ b/src/application/steps/application/deploy_compose_files.rs @@ -0,0 +1,305 @@ +//! Deploy Docker Compose files step +//! +//! This module provides the `DeployComposeFilesStep` which handles the deployment +//! of Docker Compose files and related assets to a remote host via Ansible. +//! +//! ## Key Features +//! +//! - Synchronizes local docker-compose build folder to remote host +//! - Uses Ansible's synchronize module for reliable file transfer +//! - Creates necessary directories on the remote host +//! - Verifies successful deployment +//! +//! ## Deployment Process +//! +//! The step executes the "deploy-compose-files" Ansible playbook which: +//! - Creates the `/opt/torrust` directory on the remote host +//! - Synchronizes all files from the local compose build directory +//! - Verifies that `docker-compose.yml` exists on the remote host +//! - Reports the list of deployed files +//! +//! ## Architecture +//! +//! This step follows the three-level architecture: +//! - **Command** (Level 1): `ReleaseCommandHandler` orchestrates the release workflow +//! - **Step** (Level 2): This `DeployComposeFilesStep` handles file deployment +//! - **Remote Action** (Level 3): Ansible playbook executes on the remote host +//! +//! ## Usage +//! +//! ```rust,ignore +//! use std::sync::Arc; +//! use std::path::PathBuf; +//! use crate::adapters::ansible::AnsibleClient; +//! use crate::application::steps::application::DeployComposeFilesStep; +//! +//! let ansible_client = Arc::new(AnsibleClient::new(PathBuf::from("/path/to/ansible/build"))); +//! let compose_build_dir = PathBuf::from("/path/to/compose/build"); +//! +//! let step = DeployComposeFilesStep::new(ansible_client, compose_build_dir); +//! step.execute()?; +//! ``` + +use std::path::PathBuf; +use std::sync::Arc; + +use thiserror::Error; +use tracing::{info, instrument}; + +use crate::adapters::ansible::AnsibleClient; +use crate::shared::command::CommandError; +use crate::shared::{ErrorKind, Traceable}; + +/// Default deployment directory on the remote host +pub const DEFAULT_REMOTE_DEPLOY_DIR: &str = "/opt/torrust"; + +/// Step that deploys Docker Compose files to a remote host via Ansible +/// +/// This step handles the transfer of Docker Compose files and related assets +/// to the remote instance using Ansible's synchronize module, which provides +/// reliable rsync-based file synchronization. +pub struct DeployComposeFilesStep { + ansible_client: Arc, + compose_build_dir: PathBuf, + remote_deploy_dir: String, +} + +impl DeployComposeFilesStep { + /// Creates a new `DeployComposeFilesStep` + /// + /// # Arguments + /// + /// * `ansible_client` - The Ansible client for executing playbooks + /// * `compose_build_dir` - Local directory containing Docker Compose files to deploy + #[must_use] + pub fn new(ansible_client: Arc, compose_build_dir: PathBuf) -> Self { + Self { + ansible_client, + compose_build_dir, + remote_deploy_dir: DEFAULT_REMOTE_DEPLOY_DIR.to_string(), + } + } + + /// Sets a custom remote deployment directory + /// + /// By default, files are deployed to `/opt/torrust` on the remote host. + #[must_use] + pub fn with_remote_deploy_dir(mut self, dir: impl Into) -> Self { + self.remote_deploy_dir = dir.into(); + self + } + + /// Execute the deployment step + /// + /// This will run the "deploy-compose-files" Ansible playbook to synchronize + /// the local compose build directory to the remote host. + /// + /// # Errors + /// + /// Returns an error if: + /// * The compose build directory does not exist + /// * The Ansible playbook execution fails + /// * File synchronization fails + #[instrument( + name = "deploy_compose_files", + skip_all, + fields( + step_type = "application", + operation = "deploy_compose_files", + compose_build_dir = %self.compose_build_dir.display(), + remote_deploy_dir = %self.remote_deploy_dir + ) + )] + pub fn execute(&self) -> Result<(), DeployComposeFilesStepError> { + info!( + step = "deploy_compose_files", + compose_build_dir = %self.compose_build_dir.display(), + remote_deploy_dir = %self.remote_deploy_dir, + "Deploying Docker Compose files to remote host" + ); + + // Validate that the compose build directory exists + if !self.compose_build_dir.exists() { + return Err(DeployComposeFilesStepError::ComposeBuildDirNotFound { + path: self.compose_build_dir.display().to_string(), + }); + } + + // Canonicalize the path to get an absolute path that Ansible can resolve correctly. + // The copy module needs an absolute path because it resolves relative paths + // from the playbook's directory, not the current working directory. + let absolute_compose_dir = self.compose_build_dir.canonicalize().map_err(|_| { + DeployComposeFilesStepError::ComposeBuildDirNotFound { + path: self.compose_build_dir.display().to_string(), + } + })?; + + // Execute the Ansible playbook with extra variables + let compose_dir_str = absolute_compose_dir.display().to_string(); + let extra_var = format!("compose_files_source_dir={compose_dir_str}"); + + self.ansible_client + .run_playbook("deploy-compose-files", &["-e", &extra_var]) + .map_err( + |source| DeployComposeFilesStepError::AnsiblePlaybookFailed { + message: source.to_string(), + source, + }, + )?; + + info!( + step = "deploy_compose_files", + status = "success", + remote_deploy_dir = %self.remote_deploy_dir, + "Docker Compose files deployed successfully" + ); + + Ok(()) + } +} + +/// Errors that can occur during the deploy compose files step +#[derive(Debug, Error)] +pub enum DeployComposeFilesStepError { + /// The compose build directory does not exist + #[error("Compose build directory not found: '{path}'")] + ComposeBuildDirNotFound { path: String }, + + /// Ansible playbook execution failed + #[error("Ansible playbook 'deploy-compose-files' failed: {message}")] + AnsiblePlaybookFailed { + message: String, + #[source] + source: CommandError, + }, +} + +impl DeployComposeFilesStepError { + /// Returns troubleshooting help for this error + #[must_use] + pub fn help(&self) -> &'static str { + match self { + Self::ComposeBuildDirNotFound { .. } => { + "The Docker Compose build directory was not found. Please check:\n\ + 1. The release step was executed before this step\n\ + 2. Docker Compose templates were rendered successfully\n\ + 3. The build directory path is correct" + } + Self::AnsiblePlaybookFailed { .. } => { + "Ansible playbook execution failed. Please check:\n\ + 1. SSH connectivity to the remote host\n\ + 2. Ansible is properly configured with valid inventory\n\ + 3. The remote host has sufficient disk space\n\ + 4. File permissions allow the deployment" + } + } + } +} + +impl Traceable for DeployComposeFilesStepError { + fn trace_format(&self) -> String { + match self { + Self::ComposeBuildDirNotFound { path } => { + format!("DeployComposeFilesStep::ComposeBuildDirNotFound - {path}") + } + Self::AnsiblePlaybookFailed { message, .. } => { + format!("DeployComposeFilesStep::AnsiblePlaybookFailed - {message}") + } + } + } + + fn trace_source(&self) -> Option<&dyn Traceable> { + match self { + Self::AnsiblePlaybookFailed { source, .. } => Some(source), + Self::ComposeBuildDirNotFound { .. } => None, + } + } + + fn error_kind(&self) -> ErrorKind { + match self { + Self::ComposeBuildDirNotFound { .. } => ErrorKind::Configuration, + Self::AnsiblePlaybookFailed { .. } => ErrorKind::InfrastructureOperation, + } + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::sync::Arc; + + use super::*; + use crate::adapters::ansible::AnsibleClient; + + #[test] + fn it_should_create_deploy_compose_files_step() { + let ansible_client = Arc::new(AnsibleClient::new(PathBuf::from("test_inventory.yml"))); + let compose_build_dir = PathBuf::from("/tmp/compose"); + + let step = DeployComposeFilesStep::new(ansible_client, compose_build_dir.clone()); + + assert_eq!(step.compose_build_dir, compose_build_dir); + assert_eq!(step.remote_deploy_dir, DEFAULT_REMOTE_DEPLOY_DIR); + } + + #[test] + fn it_should_allow_custom_remote_deploy_dir() { + let ansible_client = Arc::new(AnsibleClient::new(PathBuf::from("test_inventory.yml"))); + let compose_build_dir = PathBuf::from("/tmp/compose"); + + let step = DeployComposeFilesStep::new(ansible_client, compose_build_dir) + .with_remote_deploy_dir("/custom/path"); + + assert_eq!(step.remote_deploy_dir, "/custom/path"); + } + + #[test] + fn it_should_fail_when_compose_build_dir_not_found() { + let ansible_client = Arc::new(AnsibleClient::new(PathBuf::from("test_inventory.yml"))); + let compose_build_dir = PathBuf::from("/nonexistent/path"); + + let step = DeployComposeFilesStep::new(ansible_client, compose_build_dir); + let result = step.execute(); + + assert!(result.is_err()); + match result.unwrap_err() { + DeployComposeFilesStepError::ComposeBuildDirNotFound { path } => { + assert!(path.contains("nonexistent")); + } + DeployComposeFilesStepError::AnsiblePlaybookFailed { message, .. } => { + panic!("Expected ComposeBuildDirNotFound, got AnsiblePlaybookFailed: {message}") + } + } + } + + #[test] + fn errors_should_provide_help() { + let error = DeployComposeFilesStepError::ComposeBuildDirNotFound { + path: "/tmp/missing".to_string(), + }; + assert!(error.help().contains("Docker Compose build directory")); + + let cmd_error = CommandError::ExecutionFailed { + command: "test".to_string(), + exit_code: "1".to_string(), + stdout: String::new(), + stderr: "test error".to_string(), + }; + let error = DeployComposeFilesStepError::AnsiblePlaybookFailed { + message: "test".to_string(), + source: cmd_error, + }; + assert!(error.help().contains("Ansible playbook")); + } + + #[test] + fn errors_should_implement_traceable() { + let error = DeployComposeFilesStepError::ComposeBuildDirNotFound { + path: "/tmp/test".to_string(), + }; + + assert!(error.trace_format().contains("ComposeBuildDirNotFound")); + assert!(error.trace_source().is_none()); + assert!(matches!(error.error_kind(), ErrorKind::Configuration)); + } +} diff --git a/src/application/steps/application/mod.rs b/src/application/steps/application/mod.rs index 6796e6d9..35c25f70 100644 --- a/src/application/steps/application/mod.rs +++ b/src/application/steps/application/mod.rs @@ -4,24 +4,29 @@ //! operations. These steps handle application-specific operations like deployment, //! service management, configuration, and application health monitoring. //! +//! ## Available Steps +//! +//! - `deploy_compose_files` - Deploys Docker Compose files to remote host via Ansible +//! - `start_services` - Starts Docker Compose services via Ansible +//! - `run` - Legacy run step (placeholder) +//! //! ## Future Steps //! //! This module is prepared for future application deployment steps such as: -//! - Application container deployment -//! - Service configuration and startup //! - Application health checks and validation -//! - Application lifecycle management +//! - Service stop and restart operations +//! - Status monitoring and reporting //! //! ## Integration //! -//! Application steps will integrate with the existing infrastructure and +//! Application steps integrate with the existing infrastructure and //! software installation steps to provide complete deployment workflows //! from infrastructure provisioning to application operation. -// Future modules to be added: -// pub mod compose_config; -// pub mod transfer_files; -// pub mod deploy; -// pub mod start_services; -// pub mod stop_services; -// pub mod get_status; +pub mod deploy_compose_files; +pub mod run; +pub mod start_services; + +pub use deploy_compose_files::{DeployComposeFilesStep, DeployComposeFilesStepError}; +pub use run::{RunStep, RunStepError}; +pub use start_services::{StartServicesStep, StartServicesStepError}; diff --git a/src/application/steps/application/run.rs b/src/application/steps/application/run.rs new file mode 100644 index 00000000..51b3491b --- /dev/null +++ b/src/application/steps/application/run.rs @@ -0,0 +1,251 @@ +//! Application run step +//! +//! This module provides the `RunStep` which handles starting the application +//! stack on the remote host. The run step executes Docker Compose to bring +//! up all application services. +//! +//! ## Key Features +//! +//! - Docker Compose stack execution +//! - Service startup management +//! - Container orchestration via Ansible +//! - Integration with the step-based deployment architecture +//! +//! ## Run Process +//! +//! The step handles the run phase which typically includes: +//! - Executing `docker compose up -d` on the remote host +//! - Starting all application services in detached mode +//! - Verifying service startup (future enhancement) +//! - Managing container lifecycle +//! +//! This step is designed to be executed after the release step has +//! deployed all necessary configuration and compose files. + +use std::sync::Arc; + +use thiserror::Error; +use tracing::{info, instrument}; + +use crate::adapters::ansible::AnsibleClient; +use crate::shared::{ErrorKind, Traceable}; + +/// Step that runs the application stack on a remote host +/// +/// This step handles starting Docker Compose services on the remote instance, +/// bringing up all application containers. +pub struct RunStep { + ansible_client: Arc, +} + +impl RunStep { + #[must_use] + pub fn new(ansible_client: Arc) -> Self { + Self { ansible_client } + } + + /// Execute the run step + /// + /// This will start the Docker Compose application stack on the remote host. + /// + /// # Errors + /// + /// Returns an error if: + /// * Docker Compose execution fails + /// * Service startup fails + /// * Container creation fails + #[instrument( + name = "run_application", + skip_all, + fields(step_type = "application", operation = "run") + )] + pub fn execute(&self) -> Result<(), RunStepError> { + info!( + step = "run_application", + status = "starting", + "Starting application stack" + ); + + // TODO: Implement actual run logic + // This will include: + // 1. Execute docker compose up -d via Ansible + // 2. Verify services started successfully + // 3. Report container status + let _ = &self.ansible_client; // Suppress unused warning for now + + info!( + step = "run_application", + status = "success", + "Application stack started (placeholder)" + ); + + Ok(()) + } +} + +/// Errors that can occur during the run step +#[derive(Debug, Error)] +pub enum RunStepError { + /// Failed to execute Docker Compose + #[error("Failed to execute Docker Compose: {message}")] + DockerComposeExecutionFailed { + message: String, + #[source] + source: Option>, + }, + + /// Failed to start services + #[error("Failed to start services: {message}")] + ServiceStartupFailed { + message: String, + #[source] + source: Option>, + }, + + /// Failed to create containers + #[error("Failed to create containers: {message}")] + ContainerCreationFailed { + message: String, + #[source] + source: Option>, + }, +} + +impl RunStepError { + /// Returns troubleshooting help for this error + #[must_use] + pub fn help(&self) -> &'static str { + match self { + Self::DockerComposeExecutionFailed { .. } => { + "Docker Compose execution failed. Please check:\n\ + 1. Docker Compose files are present on the remote host\n\ + 2. Docker Compose syntax is valid\n\ + 3. Docker daemon is running on the remote host\n\ + 4. User has permissions to run Docker commands" + } + Self::ServiceStartupFailed { .. } => { + "Service startup failed. Please check:\n\ + 1. Container images are available or can be pulled\n\ + 2. Port conflicts with existing services\n\ + 3. Volume mounts are accessible\n\ + 4. Environment variables are properly configured" + } + Self::ContainerCreationFailed { .. } => { + "Container creation failed. Please check:\n\ + 1. Docker daemon is running and healthy\n\ + 2. Sufficient disk space for containers\n\ + 3. Network configuration is valid\n\ + 4. Container images exist and are valid" + } + } + } +} + +impl Traceable for RunStepError { + fn trace_format(&self) -> String { + match self { + Self::DockerComposeExecutionFailed { message, .. } => { + format!("RunStep::DockerComposeExecutionFailed - {message}") + } + Self::ServiceStartupFailed { message, .. } => { + format!("RunStep::ServiceStartupFailed - {message}") + } + Self::ContainerCreationFailed { message, .. } => { + format!("RunStep::ContainerCreationFailed - {message}") + } + } + } + + fn trace_source(&self) -> Option<&dyn Traceable> { + // These errors don't wrap Traceable sources + None + } + + fn error_kind(&self) -> ErrorKind { + // All run step errors are infrastructure-related + ErrorKind::InfrastructureOperation + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::sync::Arc; + + use super::*; + + #[test] + fn it_should_create_run_step() { + let ansible_client = Arc::new(AnsibleClient::new(PathBuf::from("test_inventory.yml"))); + let step = RunStep::new(ansible_client); + + // Test that the step can be created successfully + assert_eq!( + std::mem::size_of_val(&step), + std::mem::size_of::>() + ); + } + + #[test] + fn it_should_execute_run_step_placeholder() { + let ansible_client = Arc::new(AnsibleClient::new(PathBuf::from("test_inventory.yml"))); + let step = RunStep::new(ansible_client); + + // Placeholder should succeed + let result = step.execute(); + assert!(result.is_ok()); + } + + #[test] + fn docker_compose_error_should_provide_help() { + let error = RunStepError::DockerComposeExecutionFailed { + message: "Command not found".to_string(), + source: None, + }; + + let help = error.help(); + assert!(help.contains("Docker Compose")); + assert!(help.contains("Docker daemon")); + } + + #[test] + fn service_startup_error_should_provide_help() { + let error = RunStepError::ServiceStartupFailed { + message: "Port already in use".to_string(), + source: None, + }; + + let help = error.help(); + assert!(help.contains("Port conflicts")); + assert!(help.contains("Container images")); + } + + #[test] + fn container_creation_error_should_provide_help() { + let error = RunStepError::ContainerCreationFailed { + message: "No space left on device".to_string(), + source: None, + }; + + let help = error.help(); + assert!(help.contains("disk space")); + assert!(help.contains("Docker daemon")); + } + + #[test] + fn errors_should_implement_traceable() { + let error = RunStepError::DockerComposeExecutionFailed { + message: "test error".to_string(), + source: None, + }; + + assert!(error + .trace_format() + .contains("DockerComposeExecutionFailed")); + assert!(error.trace_source().is_none()); + assert!(matches!( + error.error_kind(), + ErrorKind::InfrastructureOperation + )); + } +} diff --git a/src/application/steps/application/start_services.rs b/src/application/steps/application/start_services.rs new file mode 100644 index 00000000..5aa2d938 --- /dev/null +++ b/src/application/steps/application/start_services.rs @@ -0,0 +1,212 @@ +//! Start Docker Compose services step +//! +//! This module provides the `StartServicesStep` which handles starting the +//! Docker Compose application stack on a remote host via Ansible. +//! +//! ## Key Features +//! +//! - Executes `docker compose up -d` on the remote host +//! - Pulls container images before starting +//! - Waits for services to become healthy +//! - Reports container status +//! +//! ## Architecture +//! +//! This step follows the three-level architecture: +//! - **Command** (Level 1): `RunCommandHandler` orchestrates the run workflow +//! - **Step** (Level 2): This `StartServicesStep` handles service startup +//! - **Remote Action** (Level 3): Ansible playbook executes on the remote host +//! +//! ## Usage +//! +//! ```rust,ignore +//! use std::sync::Arc; +//! use std::path::PathBuf; +//! use crate::adapters::ansible::AnsibleClient; +//! use crate::application::steps::application::StartServicesStep; +//! +//! let ansible_client = Arc::new(AnsibleClient::new(PathBuf::from("/path/to/ansible/build"))); +//! +//! let step = StartServicesStep::new(ansible_client); +//! step.execute()?; +//! ``` + +use std::sync::Arc; + +use thiserror::Error; +use tracing::{info, instrument}; + +use crate::adapters::ansible::AnsibleClient; +use crate::shared::command::CommandError; +use crate::shared::{ErrorKind, Traceable}; + +/// Step that starts Docker Compose services on a remote host via Ansible +/// +/// This step handles the execution of `docker compose up -d` on the remote +/// instance, bringing up all application containers defined in the compose file. +pub struct StartServicesStep { + ansible_client: Arc, +} + +impl StartServicesStep { + /// Creates a new `StartServicesStep` + /// + /// # Arguments + /// + /// * `ansible_client` - The Ansible client for executing playbooks + #[must_use] + pub fn new(ansible_client: Arc) -> Self { + Self { ansible_client } + } + + /// Execute the service startup step + /// + /// This will run the "run-compose-services" Ansible playbook to start + /// all Docker Compose services on the remote host. + /// + /// # Errors + /// + /// Returns an error if: + /// * The Ansible playbook execution fails + /// * Docker Compose services fail to start + /// * Container health checks fail + #[instrument( + name = "start_services", + skip_all, + fields(step_type = "application", operation = "start_services") + )] + pub fn execute(&self) -> Result<(), StartServicesStepError> { + info!( + step = "start_services", + status = "starting", + "Starting Docker Compose services on remote host" + ); + + self.ansible_client + .run_playbook("run-compose-services", &[]) + .map_err(|source| StartServicesStepError::AnsiblePlaybookFailed { + message: source.to_string(), + source, + })?; + + info!( + step = "start_services", + status = "success", + "Docker Compose services started successfully" + ); + + Ok(()) + } +} + +/// Errors that can occur during the start services step +#[derive(Debug, Error)] +pub enum StartServicesStepError { + /// Ansible playbook execution failed + #[error("Ansible playbook 'run-compose-services' failed: {message}")] + AnsiblePlaybookFailed { + message: String, + #[source] + source: CommandError, + }, +} + +impl StartServicesStepError { + /// Returns troubleshooting help for this error + #[must_use] + pub fn help(&self) -> &'static str { + match self { + Self::AnsiblePlaybookFailed { .. } => { + "Failed to start Docker Compose services. Please check:\n\ + 1. Docker daemon is running on the remote host\n\ + 2. Docker Compose files were deployed via 'release' command\n\ + 3. Container images can be pulled (network connectivity)\n\ + 4. No port conflicts with existing services\n\ + 5. Sufficient disk space and memory on the remote host\n\ + 6. SSH connectivity to the remote host is working" + } + } + } +} + +impl Traceable for StartServicesStepError { + fn trace_format(&self) -> String { + match self { + Self::AnsiblePlaybookFailed { message, .. } => { + format!("StartServicesStep::AnsiblePlaybookFailed - {message}") + } + } + } + + fn trace_source(&self) -> Option<&dyn Traceable> { + match self { + Self::AnsiblePlaybookFailed { source, .. } => Some(source), + } + } + + fn error_kind(&self) -> ErrorKind { + ErrorKind::InfrastructureOperation + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::sync::Arc; + + use super::*; + use crate::adapters::ansible::AnsibleClient; + + #[test] + fn it_should_create_start_services_step() { + let ansible_client = Arc::new(AnsibleClient::new(PathBuf::from("test_inventory.yml"))); + + let step = StartServicesStep::new(ansible_client); + + // Test that the step can be created successfully + assert_eq!( + std::mem::size_of_val(&step), + std::mem::size_of::>() + ); + } + + #[test] + fn errors_should_provide_help() { + let cmd_error = CommandError::ExecutionFailed { + command: "test".to_string(), + exit_code: "1".to_string(), + stdout: String::new(), + stderr: "test error".to_string(), + }; + let error = StartServicesStepError::AnsiblePlaybookFailed { + message: "test".to_string(), + source: cmd_error, + }; + + let help = error.help(); + assert!(help.contains("Docker daemon")); + assert!(help.contains("release")); + assert!(help.contains("port conflicts")); + } + + #[test] + fn errors_should_implement_traceable() { + let cmd_error = CommandError::ExecutionFailed { + command: "test".to_string(), + exit_code: "1".to_string(), + stdout: String::new(), + stderr: "test error".to_string(), + }; + let error = StartServicesStepError::AnsiblePlaybookFailed { + message: "test error".to_string(), + source: cmd_error, + }; + + assert!(error.trace_format().contains("AnsiblePlaybookFailed")); + assert!(error.trace_source().is_some()); + assert!(matches!( + error.error_kind(), + ErrorKind::InfrastructureOperation + )); + } +} diff --git a/src/application/steps/mod.rs b/src/application/steps/mod.rs index e797df54..b5199ead 100644 --- a/src/application/steps/mod.rs +++ b/src/application/steps/mod.rs @@ -27,13 +27,15 @@ pub mod system; pub mod validation; // Re-export all steps for easy access +pub use application::{DeployComposeFilesStep, DeployComposeFilesStepError, RunStep, RunStepError}; pub use connectivity::WaitForSSHConnectivityStep; pub use infrastructure::{ ApplyInfrastructureStep, DestroyInfrastructureStep, GetInstanceInfoStep, InitializeInfrastructureStep, PlanInfrastructureStep, ValidateInfrastructureStep, }; pub use rendering::{ - RenderAnsibleTemplatesError, RenderAnsibleTemplatesStep, RenderOpenTofuTemplatesStep, + RenderAnsibleTemplatesError, RenderAnsibleTemplatesStep, RenderDockerComposeTemplatesStep, + RenderOpenTofuTemplatesStep, }; pub use software::{InstallDockerComposeStep, InstallDockerStep}; pub use system::{ConfigureFirewallStep, ConfigureSecurityUpdatesStep, WaitForCloudInitStep}; diff --git a/src/application/steps/rendering/docker_compose_templates.rs b/src/application/steps/rendering/docker_compose_templates.rs new file mode 100644 index 00000000..184506b8 --- /dev/null +++ b/src/application/steps/rendering/docker_compose_templates.rs @@ -0,0 +1,172 @@ +//! Docker Compose template rendering step +//! +//! This module provides the `RenderDockerComposeTemplatesStep` which handles rendering +//! of Docker Compose configuration templates to the build directory. This step prepares +//! Docker Compose files for deployment to the remote host. +//! +//! ## Key Features +//! +//! - Template rendering for Docker Compose configurations +//! - Integration with the `DockerComposeTemplateRenderer` for file generation +//! - Build directory preparation for deployment operations +//! - Comprehensive error handling for template processing +//! +//! ## Usage Context +//! +//! This step is typically executed during the release workflow, after +//! infrastructure provisioning and software installation, to prepare +//! the Docker Compose files for deployment. +//! +//! ## Architecture +//! +//! This step follows the three-level architecture: +//! - **Command** (Level 1): `ReleaseCommandHandler` orchestrates the release workflow +//! - **Step** (Level 2): This `RenderDockerComposeTemplatesStep` handles template rendering +//! - The templates are rendered locally, no remote action is needed + +use std::path::PathBuf; +use std::sync::Arc; + +use tracing::{info, instrument}; + +use crate::domain::template::TemplateManager; +use crate::infrastructure::external_tools::docker_compose::{ + DockerComposeTemplateError, DockerComposeTemplateRenderer, +}; + +/// Step that renders Docker Compose templates to the build directory +/// +/// This step handles the preparation of Docker Compose configuration files +/// by rendering templates to the build directory. The rendered files are +/// then ready to be deployed to the remote host by the `DeployComposeFilesStep`. +pub struct RenderDockerComposeTemplatesStep { + template_manager: Arc, + build_dir: PathBuf, +} + +impl RenderDockerComposeTemplatesStep { + /// Creates a new `RenderDockerComposeTemplatesStep` + /// + /// # Arguments + /// + /// * `template_manager` - The template manager for accessing templates + /// * `build_dir` - The build directory where templates will be rendered + #[must_use] + pub fn new(template_manager: Arc, build_dir: PathBuf) -> Self { + Self { + template_manager, + build_dir, + } + } + + /// Execute the template rendering step + /// + /// This will render Docker Compose templates to the build directory. + /// + /// # Returns + /// + /// Returns the path to the docker-compose build directory on success. + /// + /// # Errors + /// + /// Returns an error if: + /// * Template rendering fails + /// * Directory creation fails + /// * File copying fails + #[instrument( + name = "render_docker_compose_templates", + skip_all, + fields( + step_type = "rendering", + template_type = "docker_compose", + build_dir = %self.build_dir.display() + ) + )] + pub async fn execute(&self) -> Result { + info!( + step = "render_docker_compose_templates", + templates_dir = %self.template_manager.templates_dir().display(), + build_dir = %self.build_dir.display(), + "Rendering Docker Compose templates" + ); + + let renderer = + DockerComposeTemplateRenderer::new(self.template_manager.clone(), &self.build_dir); + + let compose_build_dir = renderer.render().await?; + + info!( + step = "render_docker_compose_templates", + compose_build_dir = %compose_build_dir.display(), + status = "success", + "Docker Compose templates rendered successfully" + ); + + Ok(compose_build_dir) + } +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + use crate::infrastructure::external_tools::docker_compose::DOCKER_COMPOSE_SUBFOLDER; + + #[tokio::test] + async fn it_should_create_render_docker_compose_templates_step() { + let templates_dir = TempDir::new().expect("Failed to create templates dir"); + let build_dir = TempDir::new().expect("Failed to create build dir"); + + let template_manager = Arc::new(TemplateManager::new(templates_dir.path().to_path_buf())); + let step = RenderDockerComposeTemplatesStep::new( + template_manager.clone(), + build_dir.path().to_path_buf(), + ); + + assert_eq!(step.build_dir, build_dir.path()); + assert_eq!(step.template_manager.templates_dir(), templates_dir.path()); + } + + #[tokio::test] + async fn it_should_render_templates_from_embedded_sources() { + let templates_dir = TempDir::new().expect("Failed to create templates dir"); + let build_dir = TempDir::new().expect("Failed to create build dir"); + + let template_manager = Arc::new(TemplateManager::new(templates_dir.path().to_path_buf())); + let step = + RenderDockerComposeTemplatesStep::new(template_manager, build_dir.path().to_path_buf()); + + let result = step.execute().await; + + assert!(result.is_ok()); + let compose_build_dir = result.unwrap(); + assert!(compose_build_dir.join("docker-compose.yml").exists()); + } + + #[tokio::test] + async fn it_should_render_correct_content() { + let templates_dir = TempDir::new().expect("Failed to create templates dir"); + let build_dir = TempDir::new().expect("Failed to create build dir"); + + let template_manager = Arc::new(TemplateManager::new(templates_dir.path().to_path_buf())); + let step = + RenderDockerComposeTemplatesStep::new(template_manager, build_dir.path().to_path_buf()); + + let result = step.execute().await; + assert!(result.is_ok()); + + let output_content = tokio::fs::read_to_string( + build_dir + .path() + .join(DOCKER_COMPOSE_SUBFOLDER) + .join("docker-compose.yml"), + ) + .await + .expect("Failed to read output"); + + // Verify it contains expected content from embedded template + assert!(output_content.contains("nginx:alpine")); + assert!(output_content.contains("demo-app")); + } +} diff --git a/src/application/steps/rendering/mod.rs b/src/application/steps/rendering/mod.rs index e422597b..bc419220 100644 --- a/src/application/steps/rendering/mod.rs +++ b/src/application/steps/rendering/mod.rs @@ -8,6 +8,7 @@ //! //! - `ansible_templates` - Ansible template rendering with runtime variables //! - `opentofu_templates` - `OpenTofu` template rendering for infrastructure +//! - `docker_compose_templates` - Docker Compose template rendering for deployment //! //! ## Key Features //! @@ -20,7 +21,9 @@ //! runtime information like IP addresses, SSH keys, and deployment settings. pub mod ansible_templates; +pub mod docker_compose_templates; pub mod opentofu_templates; pub use ansible_templates::{RenderAnsibleTemplatesError, RenderAnsibleTemplatesStep}; +pub use docker_compose_templates::RenderDockerComposeTemplatesStep; pub use opentofu_templates::RenderOpenTofuTemplatesStep; diff --git a/src/bin/e2e_config_tests.rs b/src/bin/e2e_config_and_release_tests.rs similarity index 72% rename from src/bin/e2e_config_tests.rs rename to src/bin/e2e_config_and_release_tests.rs index 3fe0c816..a7c25466 100644 --- a/src/bin/e2e_config_tests.rs +++ b/src/bin/e2e_config_and_release_tests.rs @@ -1,25 +1,25 @@ -//! End-to-End Configuration Testing Binary for Torrust Tracker Deployer (Black-box) +//! End-to-End Configuration and Release Testing Binary for Torrust Tracker Deployer (Black-box) //! -//! This binary orchestrates configuration testing of the deployment infrastructure using +//! This binary orchestrates configuration and release testing of the deployment infrastructure using //! Docker containers instead of VMs. It uses a black-box approach, executing CLI commands //! as external processes rather than importing internal application logic. //! //! ## Usage //! -//! Run the E2E configuration tests: +//! Run the E2E configuration and release tests: //! //! ```bash -//! cargo run --bin e2e-config-tests +//! cargo run --bin e2e-config-and-release-tests //! ``` //! //! Run with custom options: //! //! ```bash //! # Change logging format -//! cargo run --bin e2e-config-tests -- --log-format json +//! cargo run --bin e2e-config-and-release-tests -- --log-format json //! //! # Show help -//! cargo run --bin e2e-config-tests -- --help +//! cargo run --bin e2e-config-and-release-tests -- --help //! ``` //! //! ## Test Workflow @@ -75,13 +75,17 @@ use torrust_tracker_deployer_lib::testing::e2e::tasks::black_box::{ }; use torrust_tracker_deployer_lib::testing::e2e::tasks::container::cleanup_infrastructure::stop_test_infrastructure; use torrust_tracker_deployer_lib::testing::e2e::tasks::run_configuration_validation::run_configuration_validation; +use torrust_tracker_deployer_lib::testing::e2e::tasks::run_release_validation::run_release_validation; +use torrust_tracker_deployer_lib::testing::e2e::tasks::run_run_validation::run_run_validation; /// Environment name for this E2E test const ENVIRONMENT_NAME: &str = "e2e-config"; #[derive(Parser)] -#[command(name = "e2e-config-tests")] -#[command(about = "E2E configuration tests using black-box approach with Docker containers")] +#[command(name = "e2e-config-and-release-tests")] +#[command( + about = "E2E configuration and release tests using black-box approach with Docker containers" +)] struct CliArgs { /// Logging format to use #[arg( @@ -113,9 +117,23 @@ struct CliArgs { pub async fn main() -> Result<()> { let cli = CliArgs::parse(); - // Set environment variable to skip firewall configuration in container-based tests - // UFW/iptables requires kernel capabilities not available in unprivileged containers - std::env::set_var("TORRUST_TD_SKIP_FIREWALL_IN_CONTAINER", "true"); + // SAFETY: These environment variables are set before any async runtime or + // spawned threads are created, so no concurrent access to the environment + // is possible at this point. This is the earliest point in main() after + // argument parsing. + unsafe { + // Set environment variable to skip firewall configuration in container-based tests + // UFW/iptables requires kernel capabilities not available in unprivileged containers + std::env::set_var("TORRUST_TD_SKIP_FIREWALL_IN_CONTAINER", "true"); + + // Skip Docker installation in container-based tests since Docker is already + // installed via the Dockerfile (Docker CE from get.docker.com script). + // This avoids package conflicts between docker.io and containerd.io. + std::env::set_var("TORRUST_TD_SKIP_DOCKER_INSTALL_IN_CONTAINER", "true"); + } + + // Note: Docker-in-Docker is now enabled via privileged mode in the container, + // so we can test the run command that starts Docker Compose services. // Initialize logging with production log location for E2E tests using the builder pattern LoggingBuilder::new(std::path::Path::new("./data/logs")) @@ -125,9 +143,9 @@ pub async fn main() -> Result<()> { info!( application = "torrust_tracker_deployer", - test_suite = "e2e_config_tests", + test_suite = "e2e_config_and_release_tests", log_format = ?cli.log_format, - "Starting E2E configuration tests (black-box) with Docker containers" + "Starting E2E configuration and release tests (black-box) with Docker containers" ); // Verify required dependencies before running tests @@ -138,7 +156,7 @@ pub async fn main() -> Result<()> { // Cleanup any artifacts from previous test runs (including Docker containers) run_container_preflight_cleanup(ENVIRONMENT_NAME)?; - let test_result = run_configuration_tests().await; + let test_result = run_configure_release_run_tests().await; let test_duration = test_start.elapsed(); @@ -153,27 +171,36 @@ pub async fn main() -> Result<()> { match test_result { Ok(()) => { info!( - test_suite = "e2e_config_tests", + test_suite = "e2e_config_and_release_tests", status = "success", - "All configuration tests passed successfully" + "All configuration and release tests passed successfully" ); Ok(()) } Err(error) => { error!( - test_suite = "e2e_config_tests", + test_suite = "e2e_config_and_release_tests", status = "failed", error = %error, - "Configuration tests failed" + "Configuration and release tests failed" ); Err(error) } } } -/// Run the complete configuration tests using black-box CLI commands -async fn run_configuration_tests() -> Result<()> { - info!("Starting configuration tests with Docker container (black-box approach)"); +/// Run the complete configure → release → run workflow tests using black-box CLI commands +/// +/// This function orchestrates the full software deployment workflow: +/// 1. Create environment from config file +/// 2. Register the container's IP as an existing instance +/// 3. Configure services via Ansible (install Docker, etc.) +/// 4. Release software (deploy Docker Compose files) +/// 5. Run services (start Docker Compose) +/// +/// Each step is followed by validation to ensure correctness. +async fn run_configure_release_run_tests() -> Result<()> { + info!("Starting configure → release → run tests with Docker container (black-box approach)"); // Build SSH credentials (same as used in e2e_config_tests) let project_root = std::env::current_dir().expect("Failed to get current directory"); @@ -203,8 +230,8 @@ async fn run_configuration_tests() -> Result<()> { /// Run the deployer workflow using CLI commands (black-box approach) /// -/// This executes the create → register → configure workflow via CLI commands, -/// followed by validation. +/// This executes the create → register → configure → release → run workflow +/// via CLI commands, with validation after each major step. async fn run_deployer_workflow( socket_addr: SocketAddr, ssh_credentials: &SshCredentials, @@ -229,12 +256,30 @@ async fn run_deployer_workflow( // (CLI: cargo run -- configure ) test_runner.configure_services()?; - // Validate the configuration + // Validate the configuration (Docker and Docker Compose installed correctly) run_configuration_validation(socket_addr, ssh_credentials) .await .map_err(|e| anyhow::anyhow!("{e}"))?; - info!("Configuration tests completed successfully"); + // Release software to the configured infrastructure + // (CLI: cargo run -- release ) + test_runner.release_software()?; + + // Validate the release (Docker Compose files deployed correctly) + run_release_validation(socket_addr, ssh_credentials) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + // Run services on the released infrastructure + // (CLI: cargo run -- run ) + test_runner.run_services()?; + + // Validate services are running (Docker Compose services started and healthy) + run_run_validation(socket_addr, ssh_credentials) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + info!("Configure → release → run workflow tests completed successfully"); Ok(()) } diff --git a/src/bin/e2e_tests_full.rs b/src/bin/e2e_tests_full.rs index 9646da97..c4598f42 100644 --- a/src/bin/e2e_tests_full.rs +++ b/src/bin/e2e_tests_full.rs @@ -7,7 +7,7 @@ //! ⚠️ **IMPORTANT**: This binary cannot run on GitHub Actions due to network connectivity //! issues within LXD VMs on GitHub runners. For CI environments, use the split test suites: //! - `cargo run --bin e2e-provision-and-destroy-tests` - Infrastructure provisioning only -//! - `cargo run --bin e2e-config-tests` - Configuration and software installation +//! - `cargo run --bin e2e-config-and-release-tests` - Configuration, release, and run workflows //! //! ## Usage //! @@ -175,6 +175,10 @@ fn run_e2e_test_workflow(environment_name: &str, destroy: bool) -> Result<()> { test_runner.configure_services()?; + test_runner.release_software()?; + + test_runner.run_services()?; + test_runner.validate_deployment()?; if destroy { diff --git a/src/bootstrap/container.rs b/src/bootstrap/container.rs index a33729ed..3d1737b7 100644 --- a/src/bootstrap/container.rs +++ b/src/bootstrap/container.rs @@ -18,6 +18,8 @@ use crate::presentation::controllers::create::subcommands::template::CreateTempl use crate::presentation::controllers::destroy::DestroyCommandController; use crate::presentation::controllers::provision::ProvisionCommandController; use crate::presentation::controllers::register::RegisterCommandController; +use crate::presentation::controllers::release::ReleaseCommandController; +use crate::presentation::controllers::run::RunCommandController; use crate::presentation::controllers::test::handler::TestCommandController; use crate::presentation::views::{UserOutput, VerbosityLevel}; use crate::shared::clock::Clock; @@ -229,6 +231,18 @@ impl Container { pub fn create_register_controller(&self) -> RegisterCommandController { RegisterCommandController::new(self.repository(), self.user_output()) } + + /// Create a new `ReleaseCommandController` + #[must_use] + pub fn create_release_controller(&self) -> ReleaseCommandController { + ReleaseCommandController::new(self.repository(), self.clock(), self.user_output()) + } + + /// Create a new `RunCommandController` + #[must_use] + pub fn create_run_controller(&self) -> RunCommandController { + RunCommandController::new(self.repository(), self.clock(), self.user_output()) + } } impl Default for Container { diff --git a/src/domain/environment/state/mod.rs b/src/domain/environment/state/mod.rs index d705a083..a1fd5823 100644 --- a/src/domain/environment/state/mod.rs +++ b/src/domain/environment/state/mod.rs @@ -75,10 +75,10 @@ pub use destroying::Destroying; pub use provision_failed::{ProvisionFailed, ProvisionFailureContext, ProvisionStep}; pub use provisioned::Provisioned; pub use provisioning::Provisioning; -pub use release_failed::ReleaseFailed; +pub use release_failed::{ReleaseFailed, ReleaseFailureContext, ReleaseStep}; pub use released::Released; pub use releasing::Releasing; -pub use run_failed::RunFailed; +pub use run_failed::{RunFailed, RunFailureContext, RunStep}; pub use running::Running; /// Error type for invalid type conversions when working with type-erased environments @@ -358,8 +358,8 @@ impl AnyEnvironmentState { match self { Self::ProvisionFailed(env) => Some(&env.state().context.base.error_summary), Self::ConfigureFailed(env) => Some(&env.state().context.base.error_summary), - Self::ReleaseFailed(env) => Some(&env.state().failed_step), - Self::RunFailed(env) => Some(&env.state().failed_step), + Self::ReleaseFailed(env) => Some(&env.state().context.base.error_summary), + Self::RunFailed(env) => Some(&env.state().context.base.error_summary), Self::DestroyFailed(env) => Some(&env.state().context.base.error_summary), _ => None, } @@ -635,6 +635,48 @@ mod tests { } } + /// Helper to create a test `ReleaseFailureContext` with custom error message + fn create_test_release_context(error_message: &str) -> ReleaseFailureContext { + use crate::domain::environment::TraceId; + use crate::shared::ErrorKind; + use chrono::Utc; + use std::time::Duration; + + ReleaseFailureContext { + failed_step: ReleaseStep::DeployComposeFilesToRemote, + error_kind: ErrorKind::InfrastructureOperation, + base: BaseFailureContext { + error_summary: error_message.to_string(), + failed_at: Utc::now(), + execution_started_at: Utc::now(), + execution_duration: Duration::from_secs(0), + trace_id: TraceId::default(), + trace_file_path: None, + }, + } + } + + /// Helper to create a test `RunFailureContext` with custom error message + fn create_test_run_context(error_message: &str) -> RunFailureContext { + use crate::domain::environment::TraceId; + use crate::shared::ErrorKind; + use chrono::Utc; + use std::time::Duration; + + RunFailureContext { + failed_step: RunStep::StartServices, + error_kind: ErrorKind::InfrastructureOperation, + base: BaseFailureContext { + error_summary: error_message.to_string(), + failed_at: Utc::now(), + execution_started_at: Utc::now(), + execution_duration: Duration::from_secs(0), + trace_id: TraceId::default(), + trace_file_path: None, + }, + } + } + /// Test module for state marker types /// /// These tests verify that state types can be created, cloned, and serialized @@ -712,18 +754,16 @@ mod tests { #[test] fn it_should_create_release_failed_state_with_context() { - let state = ReleaseFailed { - failed_step: "build_artifacts".to_string(), - }; - assert_eq!(state.failed_step, "build_artifacts"); + let context = create_test_release_context("build_artifacts"); + let state = ReleaseFailed { context }; + assert_eq!(state.context.base.error_summary, "build_artifacts"); } #[test] fn it_should_create_run_failed_state_with_context() { - let state = RunFailed { - failed_step: "application_startup".to_string(), - }; - assert_eq!(state.failed_step, "application_startup"); + let context = create_test_run_context("application_startup"); + let state = RunFailed { context }; + assert_eq!(state.context.base.error_summary, "application_startup"); } #[test] @@ -928,7 +968,7 @@ mod tests { .start_configuring() .configured() .start_releasing() - .release_failed("release error".to_string()); + .release_failed(super::create_test_release_context("release error")); let any_env = env.into_any(); assert!(matches!(any_env, AnyEnvironmentState::ReleaseFailed(_))); } @@ -943,7 +983,7 @@ mod tests { .start_releasing() .released() .start_running() - .run_failed("runtime error".to_string()); + .run_failed(super::create_test_run_context("runtime error")); let any_env = env.into_any(); assert!(matches!(any_env, AnyEnvironmentState::RunFailed(_))); } @@ -1070,7 +1110,7 @@ mod tests { .start_configuring() .configured() .start_releasing() - .release_failed("test error".to_string()); + .release_failed(super::create_test_release_context("test error")); let any_env = env.into_any(); let result = any_env.try_into_release_failed(); assert!(result.is_ok()); @@ -1086,7 +1126,7 @@ mod tests { .start_releasing() .released() .start_running() - .run_failed("test error".to_string()); + .run_failed(super::create_test_run_context("test error")); let any_env = env.into_any(); let result = any_env.try_into_run_failed(); assert!(result.is_ok()); @@ -1358,7 +1398,7 @@ mod tests { .start_configuring() .configured() .start_releasing() - .release_failed("error".to_string()) + .release_failed(super::super::create_test_release_context("error")) .into_any(); assert_eq!(any_env.state_name(), "release_failed"); } @@ -1373,7 +1413,7 @@ mod tests { .start_releasing() .released() .start_running() - .run_failed("error".to_string()) + .run_failed(super::super::create_test_run_context("error")) .into_any(); assert_eq!(any_env.state_name(), "run_failed"); } @@ -1508,7 +1548,7 @@ mod tests { .start_configuring() .configured() .start_releasing() - .release_failed("error".to_string()) + .release_failed(super::super::create_test_release_context("error")) .into_any(); assert!(!any_env.is_success_state()); } @@ -1523,7 +1563,7 @@ mod tests { .start_releasing() .released() .start_running() - .run_failed("error".to_string()) + .run_failed(super::super::create_test_run_context("error")) .into_any(); assert!(!any_env.is_success_state()); } @@ -1615,7 +1655,7 @@ mod tests { .start_configuring() .configured() .start_releasing() - .release_failed("error".to_string()) + .release_failed(super::super::create_test_release_context("error")) .into_any(); assert!(any_env.is_error_state()); } @@ -1630,7 +1670,7 @@ mod tests { .start_releasing() .released() .start_running() - .run_failed("error".to_string()) + .run_failed(super::super::create_test_run_context("error")) .into_any(); assert!(any_env.is_error_state()); } @@ -1732,7 +1772,7 @@ mod tests { .start_configuring() .configured() .start_releasing() - .release_failed("error".to_string()) + .release_failed(super::super::create_test_release_context("error")) .into_any(); assert!(any_env.is_terminal_state()); } @@ -1747,7 +1787,7 @@ mod tests { .start_releasing() .released() .start_running() - .run_failed("error".to_string()) + .run_failed(super::super::create_test_run_context("error")) .into_any(); assert!(any_env.is_terminal_state()); } @@ -1844,7 +1884,7 @@ mod tests { .start_configuring() .configured() .start_releasing() - .release_failed(error_message.to_string()) + .release_failed(super::super::create_test_release_context(error_message)) .into_any(); assert_eq!(any_env.error_details(), Some(error_message)); @@ -1861,7 +1901,7 @@ mod tests { .start_releasing() .released() .start_running() - .run_failed(error_message.to_string()) + .run_failed(super::super::create_test_run_context(error_message)) .into_any(); assert_eq!(any_env.error_details(), Some(error_message)); diff --git a/src/domain/environment/state/release_failed.rs b/src/domain/environment/state/release_failed.rs index fb03c0e9..667b4767 100644 --- a/src/domain/environment/state/release_failed.rs +++ b/src/domain/environment/state/release_failed.rs @@ -2,30 +2,85 @@ //! //! Error state - Release preparation failed //! -//! The release command failed during execution. The `failed_step` field -//! contains the name of the step that caused the failure. +//! The release command failed during execution. The `context` field +//! contains detailed information about the failure, including which step +//! failed, error classification, and trace file location. //! //! **Recovery Options:** //! - Destroy and recreate the environment //! - Manual release correction (advanced users) +use std::fmt; + use serde::{Deserialize, Serialize}; -use crate::domain::environment::state::{AnyEnvironmentState, StateTypeError}; +use crate::domain::environment::state::{AnyEnvironmentState, BaseFailureContext, StateTypeError}; use crate::domain::environment::Environment; +use crate::shared::error::ErrorKind; + +/// Steps in the release workflow +/// +/// Each variant represents a distinct phase in the release process. +/// This allows precise tracking of which step failed during release. +/// +/// The release workflow follows the three-level architecture: +/// - **Command** (Level 1): `ReleaseCommandHandler` orchestrates the workflow +/// - **Step** (Level 2): Individual steps like `RenderDockerComposeTemplatesStep` and `DeployComposeFilesStep` +/// - **Remote Action** (Level 3): Ansible playbooks execute on remote hosts +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReleaseStep { + /// Rendering Docker Compose templates to the build directory + RenderDockerComposeTemplates, + /// Deploying compose files to the remote host via Ansible + DeployComposeFilesToRemote, +} + +impl fmt::Display for ReleaseStep { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { + Self::RenderDockerComposeTemplates => "Render Docker Compose Templates", + Self::DeployComposeFilesToRemote => "Deploy Compose Files to Remote", + }; + write!(f, "{name}") + } +} + +/// Structured failure context for release command errors +/// +/// Contains comprehensive information about a release failure: +/// - Which step failed +/// - Error classification for recovery guidance +/// - Base failure metadata (timing, trace ID, error summary) +/// +/// This enables: +/// - Accurate error reporting +/// - Recovery suggestions based on the specific failure +/// - Post-mortem analysis via trace files +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ReleaseFailureContext { + /// The step that was executing when the failure occurred + pub failed_step: ReleaseStep, + + /// Classification of the error for recovery guidance + pub error_kind: ErrorKind, + + /// Common failure metadata (timing, trace, error summary) + pub base: BaseFailureContext, +} /// Error state - Release preparation failed /// -/// The release command failed during execution. The `failed_step` field -/// contains the name of the step that caused the failure. +/// The release command failed during execution. The `context` field +/// contains detailed information about the failure. /// /// **Recovery Options:** /// - Destroy and recreate the environment /// - Manual release correction (advanced users) -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ReleaseFailed { - /// The name of the step that failed during release - pub failed_step: String, + /// Structured failure context with step info, error classification, and trace + pub context: ReleaseFailureContext, } // Type Erasure: Typed → Runtime conversion (into_any) @@ -57,14 +112,74 @@ impl AnyEnvironmentState { #[cfg(test)] mod tests { + use std::time::Duration; + + use chrono::Utc; + use super::*; + use crate::domain::environment::TraceId; + + fn create_test_failure_context() -> ReleaseFailureContext { + let now = Utc::now(); + ReleaseFailureContext { + failed_step: ReleaseStep::RenderDockerComposeTemplates, + error_kind: ErrorKind::Configuration, + base: BaseFailureContext { + error_summary: "Test error".to_string(), + failed_at: now, + execution_started_at: now, + execution_duration: Duration::from_secs(10), + trace_id: TraceId::new(), + trace_file_path: None, + }, + } + } #[test] fn it_should_create_release_failed_state_with_context() { + let context = create_test_failure_context(); let state = ReleaseFailed { - failed_step: "build_artifacts".to_string(), + context: context.clone(), }; - assert_eq!(state.failed_step, "build_artifacts"); + assert_eq!( + state.context.failed_step, + ReleaseStep::RenderDockerComposeTemplates + ); + assert_eq!(state.context.error_kind, ErrorKind::Configuration); + } + + #[test] + fn it_should_display_release_step() { + assert_eq!( + format!("{}", ReleaseStep::RenderDockerComposeTemplates), + "Render Docker Compose Templates" + ); + assert_eq!( + format!("{}", ReleaseStep::DeployComposeFilesToRemote), + "Deploy Compose Files to Remote" + ); + } + + #[test] + fn it_should_serialize_release_step_to_snake_case() { + let step = ReleaseStep::RenderDockerComposeTemplates; + let json = serde_json::to_string(&step).unwrap(); + assert_eq!(json, r#""render_docker_compose_templates""#); + + let step = ReleaseStep::DeployComposeFilesToRemote; + let json = serde_json::to_string(&step).unwrap(); + assert_eq!(json, r#""deploy_compose_files_to_remote""#); + } + + #[test] + fn it_should_deserialize_release_step_from_snake_case() { + let step: ReleaseStep = + serde_json::from_str(r#""render_docker_compose_templates""#).unwrap(); + assert_eq!(step, ReleaseStep::RenderDockerComposeTemplates); + + let step: ReleaseStep = + serde_json::from_str(r#""deploy_compose_files_to_remote""#).unwrap(); + assert_eq!(step, ReleaseStep::DeployComposeFilesToRemote); } mod conversion_tests { @@ -94,6 +209,19 @@ mod tests { fn create_test_environment_release_failed() -> Environment { let name = EnvironmentName::new("test-env".to_string()).unwrap(); let ssh_creds = create_test_ssh_credentials(); + let now = Utc::now(); + let context = ReleaseFailureContext { + failed_step: ReleaseStep::DeployComposeFilesToRemote, + error_kind: ErrorKind::InfrastructureOperation, + base: BaseFailureContext { + error_summary: "SSH connection failed".to_string(), + failed_at: now, + execution_started_at: now, + execution_duration: Duration::from_secs(5), + trace_id: TraceId::new(), + trace_file_path: None, + }, + }; Environment::new( name.clone(), default_lxd_provider_config(&name), @@ -105,7 +233,7 @@ mod tests { .start_configuring() .configured() .start_releasing() - .release_failed("test error".to_string()) + .release_failed(context) } #[test] diff --git a/src/domain/environment/state/releasing.rs b/src/domain/environment/state/releasing.rs index 09da89d8..a42f8892 100644 --- a/src/domain/environment/state/releasing.rs +++ b/src/domain/environment/state/releasing.rs @@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize}; use crate::domain::environment::state::{ - AnyEnvironmentState, ReleaseFailed, Released, StateTypeError, + AnyEnvironmentState, ReleaseFailed, ReleaseFailureContext, Released, StateTypeError, }; use crate::domain::environment::Environment; @@ -40,9 +40,13 @@ impl Environment { /// Transitions from Releasing to `ReleaseFailed` state /// /// This method indicates that release preparation failed at a specific step. + /// The context contains detailed failure information including: + /// - The step that failed + /// - Error classification + /// - Timing and trace information #[must_use] - pub fn release_failed(self, failed_step: String) -> Environment { - self.with_state(ReleaseFailed { failed_step }) + pub fn release_failed(self, context: ReleaseFailureContext) -> Environment { + self.with_state(ReleaseFailed { context }) } } @@ -140,12 +144,18 @@ mod tests { } mod transition_tests { + use std::time::Duration; + + use chrono::Utc; + use super::*; use crate::adapters::ssh::SshCredentials; use crate::domain::environment::name::EnvironmentName; - use crate::domain::environment::state::Released; + use crate::domain::environment::state::{BaseFailureContext, ReleaseStep, Released}; + use crate::domain::environment::TraceId; use crate::domain::provider::{LxdConfig, ProviderConfig}; use crate::domain::ProfileName; + use crate::shared::error::ErrorKind; use crate::shared::Username; use std::path::PathBuf; @@ -176,6 +186,22 @@ mod tests { .start_releasing() } + fn create_test_failure_context(step: ReleaseStep) -> ReleaseFailureContext { + let now = Utc::now(); + ReleaseFailureContext { + failed_step: step, + error_kind: ErrorKind::InfrastructureOperation, + base: BaseFailureContext { + error_summary: "Test failure".to_string(), + failed_at: now, + execution_started_at: now, + execution_duration: Duration::from_secs(5), + trace_id: TraceId::new(), + trace_file_path: None, + }, + } + } + #[test] fn it_should_transition_from_releasing_to_released() { let env = create_test_environment(); @@ -188,9 +214,13 @@ mod tests { #[test] fn it_should_transition_from_releasing_to_release_failed() { let env = create_test_environment(); - let env = env.release_failed("build_artifacts_missing".to_string()); + let context = create_test_failure_context(ReleaseStep::DeployComposeFilesToRemote); + let env = env.release_failed(context); - assert_eq!(env.state().failed_step, "build_artifacts_missing"); + assert_eq!( + env.state().context.failed_step, + ReleaseStep::DeployComposeFilesToRemote + ); assert_eq!(env.name().as_str(), "test-state"); } } diff --git a/src/domain/environment/state/run_failed.rs b/src/domain/environment/state/run_failed.rs index e031cff5..b931986f 100644 --- a/src/domain/environment/state/run_failed.rs +++ b/src/domain/environment/state/run_failed.rs @@ -2,30 +2,82 @@ //! //! Error state - Application runtime failed //! -//! The application encountered a runtime error. The `failed_step` field -//! contains the name of the operation that caused the failure. +//! The run command failed during execution. The `context` field +//! contains detailed information about the failure, including which step +//! failed, error classification, and trace file location. //! //! **Recovery Options:** -//! - Restart the application +//! - Retry the run command //! - Destroy and recreate the environment +use std::fmt; + use serde::{Deserialize, Serialize}; -use crate::domain::environment::state::{AnyEnvironmentState, StateTypeError}; +use crate::domain::environment::state::{AnyEnvironmentState, BaseFailureContext, StateTypeError}; use crate::domain::environment::Environment; +use crate::shared::error::ErrorKind; + +/// Steps in the run workflow +/// +/// Each variant represents a distinct phase in the run process. +/// This allows precise tracking of which step failed during run. +/// +/// The run workflow follows the three-level architecture: +/// - **Command** (Level 1): `RunCommandHandler` orchestrates the workflow +/// - **Step** (Level 2): Individual steps like `StartServicesStep` +/// - **Remote Action** (Level 3): Ansible playbooks execute on remote hosts +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RunStep { + /// Starting Docker Compose services on the remote host + StartServices, +} + +impl fmt::Display for RunStep { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { + Self::StartServices => "Start Services", + }; + write!(f, "{name}") + } +} + +/// Structured failure context for run command errors +/// +/// Contains comprehensive information about a run failure: +/// - Which step failed +/// - Error classification for recovery guidance +/// - Base failure metadata (timing, trace ID, error summary) +/// +/// This enables: +/// - Accurate error reporting +/// - Recovery suggestions based on the specific failure +/// - Post-mortem analysis via trace files +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunFailureContext { + /// The step that was executing when the failure occurred + pub failed_step: RunStep, + + /// Classification of the error for recovery guidance + pub error_kind: ErrorKind, + + /// Common failure metadata (timing, trace, error summary) + pub base: BaseFailureContext, +} /// Error state - Application runtime failed /// -/// The application encountered a runtime error. The `failed_step` field -/// contains the name of the operation that caused the failure. +/// The run command failed during execution. The `context` field +/// contains detailed information about the failure. /// /// **Recovery Options:** -/// - Restart the application +/// - Retry the run command /// - Destroy and recreate the environment -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RunFailed { - /// The name of the operation that failed during runtime - pub failed_step: String, + /// Structured failure context with step info, error classification, and trace + pub context: RunFailureContext, } // Type Erasure: Typed → Runtime conversion (into_any) @@ -57,14 +109,55 @@ impl AnyEnvironmentState { #[cfg(test)] mod tests { + use std::time::Duration; + + use chrono::Utc; + use super::*; + use crate::domain::environment::TraceId; + + fn create_test_failure_context() -> RunFailureContext { + let now = Utc::now(); + RunFailureContext { + failed_step: RunStep::StartServices, + error_kind: ErrorKind::InfrastructureOperation, + base: BaseFailureContext { + error_summary: "Test error".to_string(), + failed_at: now, + execution_started_at: now, + execution_duration: Duration::from_secs(10), + trace_id: TraceId::new(), + trace_file_path: None, + }, + } + } #[test] fn it_should_create_run_failed_state_with_context() { + let context = create_test_failure_context(); let state = RunFailed { - failed_step: "application_startup".to_string(), + context: context.clone(), }; - assert_eq!(state.failed_step, "application_startup"); + assert_eq!(state.context.failed_step, RunStep::StartServices); + assert_eq!(state.context.error_kind, ErrorKind::InfrastructureOperation); + } + + #[test] + fn it_should_display_run_step() { + assert_eq!(format!("{}", RunStep::StartServices), "Start Services"); + } + + #[test] + fn it_should_serialize_run_step_to_snake_case() { + let step = RunStep::StartServices; + let json = serde_json::to_string(&step).unwrap(); + assert_eq!(json, r#""start_services""#); + } + + #[test] + fn it_should_deserialize_run_step_from_snake_case() { + let step: RunStep = serde_json::from_str(r#""start_services""#).unwrap(); + assert_eq!(step, RunStep::StartServices); } mod conversion_tests { @@ -94,6 +187,19 @@ mod tests { fn create_test_environment_run_failed() -> Environment { let name = EnvironmentName::new("test-env".to_string()).unwrap(); let ssh_creds = create_test_ssh_credentials(); + let now = Utc::now(); + let context = RunFailureContext { + failed_step: RunStep::StartServices, + error_kind: ErrorKind::InfrastructureOperation, + base: BaseFailureContext { + error_summary: "Docker compose failed".to_string(), + failed_at: now, + execution_started_at: now, + execution_duration: Duration::from_secs(5), + trace_id: TraceId::new(), + trace_file_path: None, + }, + }; Environment::new( name.clone(), default_lxd_provider_config(&name), @@ -107,7 +213,7 @@ mod tests { .start_releasing() .released() .start_running() - .run_failed("test error".to_string()) + .run_failed(context) } #[test] diff --git a/src/domain/environment/state/running.rs b/src/domain/environment/state/running.rs index 78c199c9..e1ec91bf 100644 --- a/src/domain/environment/state/running.rs +++ b/src/domain/environment/state/running.rs @@ -11,7 +11,9 @@ use serde::{Deserialize, Serialize}; -use crate::domain::environment::state::{AnyEnvironmentState, RunFailed, StateTypeError}; +use crate::domain::environment::state::{ + AnyEnvironmentState, RunFailed, RunFailureContext, StateTypeError, +}; use crate::domain::environment::Environment; /// Final state - Application is running @@ -30,9 +32,13 @@ impl Environment { /// Transitions from Running to `RunFailed` state /// /// This method indicates that the application encountered a runtime failure. + /// + /// # Arguments + /// + /// * `context` - Structured failure context with step info and error details #[must_use] - pub fn run_failed(self, failed_step: String) -> Environment { - self.with_state(RunFailed { failed_step }) + pub fn run_failed(self, context: RunFailureContext) -> Environment { + self.with_state(RunFailed { context }) } } @@ -151,12 +157,18 @@ mod tests { } mod transition_tests { + use std::time::Duration; + + use chrono::Utc; + use super::*; use crate::adapters::ssh::SshCredentials; use crate::domain::environment::name::EnvironmentName; - use crate::domain::environment::state::Destroyed; + use crate::domain::environment::state::{BaseFailureContext, Destroyed, RunStep}; + use crate::domain::environment::TraceId; use crate::domain::provider::{LxdConfig, ProviderConfig}; use crate::domain::ProfileName; + use crate::shared::error::ErrorKind; use crate::shared::Username; use std::path::PathBuf; @@ -189,12 +201,29 @@ mod tests { .start_running() } + fn create_test_failure_context() -> RunFailureContext { + let now = Utc::now(); + RunFailureContext { + failed_step: RunStep::StartServices, + error_kind: ErrorKind::InfrastructureOperation, + base: BaseFailureContext { + error_summary: "application_crash".to_string(), + failed_at: now, + execution_started_at: now, + execution_duration: Duration::from_secs(5), + trace_id: TraceId::new(), + trace_file_path: None, + }, + } + } + #[test] fn it_should_transition_from_running_to_run_failed() { let env = create_test_environment(); - let env = env.run_failed("application_crash".to_string()); + let context = create_test_failure_context(); + let env = env.run_failed(context); - assert_eq!(env.state().failed_step, "application_crash"); + assert_eq!(env.state().context.failed_step, RunStep::StartServices); assert_eq!(env.name().as_str(), "test-state"); } diff --git a/src/infrastructure/external_tools/ansible/template/renderer/mod.rs b/src/infrastructure/external_tools/ansible/template/renderer/mod.rs index 10a127bd..83573d37 100644 --- a/src/infrastructure/external_tools/ansible/template/renderer/mod.rs +++ b/src/infrastructure/external_tools/ansible/template/renderer/mod.rs @@ -336,6 +336,8 @@ impl AnsibleTemplateRenderer { "wait-cloud-init.yml", "configure-security-updates.yml", "configure-firewall.yml", + "deploy-compose-files.yml", + "run-compose-services.yml", ] { self.copy_static_file(template_manager, playbook, destination_dir) .await?; @@ -343,7 +345,7 @@ impl AnsibleTemplateRenderer { tracing::debug!( "Successfully copied {} static template files", - 7 // ansible.cfg + 6 playbooks + 9 // ansible.cfg + 8 playbooks ); Ok(()) diff --git a/src/infrastructure/external_tools/docker_compose/mod.rs b/src/infrastructure/external_tools/docker_compose/mod.rs new file mode 100644 index 00000000..068ac43a --- /dev/null +++ b/src/infrastructure/external_tools/docker_compose/mod.rs @@ -0,0 +1,21 @@ +//! Docker Compose integration for application deployment +//! +//! This module provides Docker Compose-specific functionality for the deployment system, +//! including template rendering for Docker Compose configuration files. +//! +//! ## Components +//! +//! - `template` - Template rendering functionality for Docker Compose files +//! +//! Note: Unlike Ansible and Tofu, Docker Compose currently only uses static templates +//! (no Tera variable substitution). If dynamic templates are needed in the future, +//! the template module can be extended similar to Ansible. + +pub mod template; + +pub use template::{DockerComposeTemplateError, DockerComposeTemplateRenderer}; + +/// Subdirectory name for Docker Compose-related files within the build directory. +/// +/// Docker Compose files will be rendered to `build_dir/{DOCKER_COMPOSE_SUBFOLDER}/`. +pub const DOCKER_COMPOSE_SUBFOLDER: &str = "docker-compose"; diff --git a/src/infrastructure/external_tools/docker_compose/template/mod.rs b/src/infrastructure/external_tools/docker_compose/template/mod.rs new file mode 100644 index 00000000..5716ce9b --- /dev/null +++ b/src/infrastructure/external_tools/docker_compose/template/mod.rs @@ -0,0 +1,16 @@ +//! Docker Compose template functionality +//! +//! This module provides template-related functionality for Docker Compose, +//! including the template renderer for static file management. +//! +//! ## Components +//! +//! - `renderer` - Template renderer for Docker Compose configuration files +//! +//! Note: Unlike Ansible, Docker Compose currently only uses static templates +//! (no Tera variable substitution). If dynamic templates are needed in the +//! future, a `wrappers` submodule can be added similar to Ansible. + +pub mod renderer; + +pub use renderer::{DockerComposeTemplateError, DockerComposeTemplateRenderer}; diff --git a/src/infrastructure/external_tools/docker_compose/template/renderer/mod.rs b/src/infrastructure/external_tools/docker_compose/template/renderer/mod.rs new file mode 100644 index 00000000..0c3d1c45 --- /dev/null +++ b/src/infrastructure/external_tools/docker_compose/template/renderer/mod.rs @@ -0,0 +1,593 @@ +//! # Docker Compose Template Renderer +//! +//! This module handles Docker Compose template rendering for deployment workflows. +//! It manages the creation of build directories and copying static template files +//! (docker-compose.yml) to the build directory. +//! +//! ## Design Decision +//! +//! Unlike Ansible and Tofu, Docker Compose files are typically used as static files, +//! with runtime configuration handled via environment variables. Docker Compose +//! supports environment variable substitution natively: +//! +//! - `.env` file auto-loaded from the same directory +//! - `${VAR:-default}` syntax for variable substitution +//! - `--env-file` flag at runtime +//! +//! Therefore, we use a simpler renderer that copies files as-is rather than +//! processing Tera templates. This keeps the implementation simple and follows +//! Docker Compose conventions. +//! +//! ## Template System Integration +//! +//! This renderer integrates with the embedded template system: +//! - Templates are embedded in the binary at compile time +//! - On first use, templates are extracted to the environment's templates directory +//! - Templates are then copied from the extracted location to the build directory +//! +//! See `docs/technical/template-system-architecture.md` for details on the +//! double-indirection pattern used by the template system. +//! +//! ## Key Features +//! +//! - **Static file copying**: Handles Docker Compose files that don't need Tera templating +//! - **Embedded template extraction**: Extracts templates from binary on-demand +//! - **Structured error handling**: Provides specific error types with detailed context +//! - **Tracing integration**: Comprehensive logging for debugging and monitoring +//! - **Testable design**: Modular structure that allows for comprehensive unit testing +//! +//! ## Usage +//! +//! ```rust,no_run +//! # use std::sync::Arc; +//! # use tempfile::TempDir; +//! # #[tokio::main] +//! # async fn main() -> Result<(), Box> { +//! use torrust_tracker_deployer_lib::infrastructure::external_tools::docker_compose::template::renderer::DockerComposeTemplateRenderer; +//! use torrust_tracker_deployer_lib::domain::template::TemplateManager; +//! +//! let temp_dir = TempDir::new()?; +//! let template_manager = Arc::new(TemplateManager::new("/path/to/templates")); +//! let renderer = DockerComposeTemplateRenderer::new(template_manager, temp_dir.path()); +//! +//! // Render (copy) templates to build directory +//! let build_compose_dir = renderer.render().await?; +//! # Ok(()) +//! # } +//! ``` + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use thiserror::Error; +use tracing::{debug, info, trace}; + +use crate::domain::template::{TemplateManager, TemplateManagerError}; +use crate::shared::{ErrorKind, Traceable}; + +/// Renders Docker Compose templates to a build directory +/// +/// This renderer is responsible for preparing Docker Compose templates for deployment +/// workflows. Currently, it handles static files that are copied as-is to the build +/// directory. If dynamic Tera templates are needed in the future (e.g., for dynamic +/// service definitions), this renderer can be extended to handle them. +pub struct DockerComposeTemplateRenderer { + template_manager: Arc, + build_dir: PathBuf, +} + +impl DockerComposeTemplateRenderer { + /// The docker-compose.yml filename + const COMPOSE_FILE: &'static str = "docker-compose.yml"; + + /// Default relative path for Docker Compose configuration files + const DOCKER_COMPOSE_BUILD_PATH: &'static str = "docker-compose"; + + /// Template path prefix for docker-compose templates (relative to templates root) + const DOCKER_COMPOSE_TEMPLATE_PATH: &'static str = "docker-compose"; + + /// Creates a new Docker Compose template renderer + /// + /// # Arguments + /// + /// * `template_manager` - The template manager for extracting embedded templates + /// * `build_dir` - The destination build directory + #[must_use] + pub fn new>(template_manager: Arc, build_dir: P) -> Self { + Self { + template_manager, + build_dir: build_dir.as_ref().to_path_buf(), + } + } + + /// Renders Docker Compose templates to the build directory + /// + /// This method: + /// 1. Creates the docker-compose subdirectory in the build directory + /// 2. Extracts the docker-compose.yml from embedded templates (if not already extracted) + /// 3. Copies the docker-compose.yml from extracted templates to build directory + /// + /// # Returns + /// + /// Returns the path to the build docker-compose directory on success. + /// + /// # Errors + /// + /// Returns an error if: + /// - Directory creation fails + /// - Template extraction fails + /// - File copying fails + pub async fn render(&self) -> Result { + info!( + template_type = "docker_compose", + templates_dir = %self.template_manager.templates_dir().display(), + build_dir = %self.build_dir.display(), + "Rendering Docker Compose templates" + ); + + // Create build directory structure + let build_compose_dir = self.create_build_directory().await?; + + // Copy static Docker Compose files + self.copy_static_templates(&build_compose_dir).await?; + + info!( + template_type = "docker_compose", + output_dir = %build_compose_dir.display(), + status = "complete", + "Docker Compose templates rendered successfully" + ); + + Ok(build_compose_dir) + } + + /// Builds the full Docker Compose build directory path + /// + /// # Returns + /// + /// * `PathBuf` - The complete path to the Docker Compose build directory + fn build_docker_compose_directory(&self) -> PathBuf { + self.build_dir.join(Self::DOCKER_COMPOSE_BUILD_PATH) + } + + /// Builds the template path for a specific file in the Docker Compose template directory + /// + /// # Arguments + /// + /// * `file_name` - The name of the template file + /// + /// # Returns + /// + /// * `String` - The complete template path for the specified file + fn build_template_path(file_name: &str) -> String { + format!("{}/{file_name}", Self::DOCKER_COMPOSE_TEMPLATE_PATH) + } + + /// Creates the Docker Compose build directory structure + /// + /// # Returns + /// + /// * `Result` - The created build directory path or an error + /// + /// # Errors + /// + /// Returns an error if directory creation fails + async fn create_build_directory(&self) -> Result { + let build_compose_dir = self.build_docker_compose_directory(); + + debug!( + directory = %build_compose_dir.display(), + "Creating Docker Compose build directory" + ); + + tokio::fs::create_dir_all(&build_compose_dir) + .await + .map_err( + |source| DockerComposeTemplateError::DirectoryCreationFailed { + directory: build_compose_dir.display().to_string(), + source, + }, + )?; + + trace!( + directory = %build_compose_dir.display(), + "Docker Compose build directory created" + ); + + Ok(build_compose_dir) + } + + /// Copies static Docker Compose template files that don't require variable substitution + /// + /// # Arguments + /// + /// * `destination_dir` - Directory where static files will be copied + /// + /// # Returns + /// + /// * `Result<(), DockerComposeTemplateError>` - Success or error from file copying operations + /// + /// # Errors + /// + /// Returns an error if: + /// - Template manager cannot provide required template paths + /// - File copying fails for any of the specified files + async fn copy_static_templates( + &self, + destination_dir: &Path, + ) -> Result<(), DockerComposeTemplateError> { + debug!("Copying static Docker Compose template files"); + + // Copy docker-compose.yml + self.copy_static_file(Self::COMPOSE_FILE, destination_dir) + .await?; + + debug!( + "Successfully copied {} static template files", + 1 // docker-compose.yml + ); + + Ok(()) + } + + /// Copies a single static template file from template manager to destination + /// + /// This method uses the `TemplateManager` to get the template path, which will + /// extract the template from embedded resources if it doesn't already exist. + /// + /// # Arguments + /// + /// * `file_name` - Name of the file to copy (without path prefix) + /// * `destination_dir` - Directory where the file will be copied + /// + /// # Returns + /// + /// * `Result<(), DockerComposeTemplateError>` - Success or error from the file copying operation + /// + /// # Errors + /// + /// Returns an error if: + /// - Template manager cannot provide the template path + /// - File copying fails + async fn copy_static_file( + &self, + file_name: &str, + destination_dir: &Path, + ) -> Result<(), DockerComposeTemplateError> { + let template_path = Self::build_template_path(file_name); + let dest_path = destination_dir.join(file_name); + + debug!( + template_path = %template_path, + destination = %dest_path.display(), + "Copying static file from extracted templates" + ); + + // Get the template path (extracts from embedded resources if needed) + let source_path = self + .template_manager + .get_template_path(&template_path) + .map_err(|source| DockerComposeTemplateError::TemplatePathFailed { + file_name: file_name.to_string(), + source, + })?; + + trace!( + source = %source_path.display(), + destination = %dest_path.display(), + "Template extracted, copying to build directory" + ); + + // Copy the file + tokio::fs::copy(&source_path, &dest_path) + .await + .map_err(|source| DockerComposeTemplateError::StaticFileCopyFailed { + file_name: file_name.to_string(), + source, + })?; + + debug!("Successfully copied static file {}", file_name); + Ok(()) + } +} + +/// Errors that can occur during Docker Compose template rendering +#[derive(Debug, Error)] +pub enum DockerComposeTemplateError { + /// Failed to create the build directory + #[error("Failed to create Docker Compose build directory '{directory}': {source}")] + DirectoryCreationFailed { + directory: String, + #[source] + source: std::io::Error, + }, + + /// Failed to get template path from template manager + #[error("Failed to get template path for '{file_name}': {source}")] + TemplatePathFailed { + file_name: String, + #[source] + source: TemplateManagerError, + }, + + /// Failed to copy static template file + #[error("Failed to copy static template file '{file_name}' to build directory: {source}")] + StaticFileCopyFailed { + file_name: String, + #[source] + source: std::io::Error, + }, +} + +impl DockerComposeTemplateError { + /// Returns troubleshooting help for this error + #[must_use] + pub fn help(&self) -> &'static str { + match self { + Self::DirectoryCreationFailed { .. } => { + "Failed to create the Docker Compose build directory. Please check:\n\ + 1. Disk space availability\n\ + 2. Write permissions on the build directory\n\ + 3. Parent directories exist and are accessible" + } + Self::TemplatePathFailed { .. } => { + "Failed to extract Docker Compose template from embedded resources. This indicates:\n\ + 1. The docker-compose template may be missing from the binary\n\ + 2. The templates directory may not be writable\n\ + 3. There may be a filesystem permission issue\n\ + Please report this as a bug if the problem persists." + } + Self::StaticFileCopyFailed { .. } => { + "Failed to copy Docker Compose file. Please check:\n\ + 1. Source file is readable\n\ + 2. Destination directory has write permissions\n\ + 3. Disk space availability" + } + } + } +} + +impl Traceable for DockerComposeTemplateError { + fn trace_format(&self) -> String { + match self { + Self::DirectoryCreationFailed { directory, .. } => { + format!("DockerComposeTemplateRenderer::DirectoryCreationFailed - {directory}") + } + Self::TemplatePathFailed { file_name, .. } => { + format!("DockerComposeTemplateRenderer::TemplatePathFailed - {file_name}") + } + Self::StaticFileCopyFailed { file_name, .. } => { + format!("DockerComposeTemplateRenderer::StaticFileCopyFailed - {file_name}") + } + } + } + + fn trace_source(&self) -> Option<&dyn Traceable> { + None + } + + fn error_kind(&self) -> ErrorKind { + match self { + Self::DirectoryCreationFailed { .. } | Self::StaticFileCopyFailed { .. } => { + ErrorKind::FileSystem + } + Self::TemplatePathFailed { .. } => ErrorKind::Configuration, + } + } +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + use crate::infrastructure::external_tools::docker_compose::DOCKER_COMPOSE_SUBFOLDER; + + /// Creates a `TemplateManager` that uses the embedded templates + /// + /// This tests the real integration with embedded templates by creating + /// a `TemplateManager` pointing to a temp directory where templates + /// will be extracted on-demand. + fn create_template_manager_with_embedded() -> (Arc, TempDir) { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let manager = Arc::new(TemplateManager::new(temp_dir.path())); + (manager, temp_dir) + } + + /// Helper to create a test template manager for testing + fn create_test_template_manager() -> Arc { + Arc::new(TemplateManager::new("/tmp/test/templates")) + } + + #[tokio::test] + async fn it_should_create_renderer_with_build_directory() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let build_path = temp_dir.path().join("build"); + let template_manager = create_test_template_manager(); + + let renderer = DockerComposeTemplateRenderer::new(template_manager, &build_path); + + assert_eq!(renderer.build_dir, build_path); + } + + #[tokio::test] + async fn it_should_build_correct_docker_compose_directory_path() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let build_path = temp_dir.path().join("build"); + let expected_path = build_path.join("docker-compose"); + let template_manager = create_test_template_manager(); + + let renderer = DockerComposeTemplateRenderer::new(template_manager, &build_path); + let actual_path = renderer.build_docker_compose_directory(); + + assert_eq!(actual_path, expected_path); + } + + #[tokio::test] + async fn it_should_build_correct_template_path_for_file() { + let template_path = + DockerComposeTemplateRenderer::build_template_path("docker-compose.yml"); + + assert_eq!(template_path, "docker-compose/docker-compose.yml"); + } + + #[tokio::test] + async fn it_should_render_docker_compose_files_from_embedded_templates() { + let (template_manager, _templates_dir) = create_template_manager_with_embedded(); + let build_dir = TempDir::new().expect("Failed to create build dir"); + + let renderer = DockerComposeTemplateRenderer::new(template_manager, build_dir.path()); + + let result = renderer.render().await; + + assert!(result.is_ok()); + let compose_build_dir = result.unwrap(); + assert!(compose_build_dir.join("docker-compose.yml").exists()); + } + + #[tokio::test] + async fn it_should_create_build_directory() { + let (template_manager, _templates_dir) = create_template_manager_with_embedded(); + let build_dir = TempDir::new().expect("Failed to create build dir"); + + let renderer = DockerComposeTemplateRenderer::new(template_manager, build_dir.path()); + + let result = renderer.render().await; + + assert!(result.is_ok()); + let compose_build_dir = build_dir.path().join(DOCKER_COMPOSE_SUBFOLDER); + assert!(compose_build_dir.exists()); + assert!(compose_build_dir.is_dir()); + } + + #[tokio::test] + async fn it_should_copy_compose_file_content_from_embedded() { + let (template_manager, templates_dir) = create_template_manager_with_embedded(); + let build_dir = TempDir::new().expect("Failed to create build dir"); + + let renderer = + DockerComposeTemplateRenderer::new(template_manager.clone(), build_dir.path()); + + let result = renderer.render().await; + assert!(result.is_ok()); + + // The template should have been extracted to templates_dir + let source_content = tokio::fs::read_to_string( + templates_dir + .path() + .join(DOCKER_COMPOSE_SUBFOLDER) + .join("docker-compose.yml"), + ) + .await + .expect("Failed to read source"); + + let dest_content = tokio::fs::read_to_string( + build_dir + .path() + .join(DOCKER_COMPOSE_SUBFOLDER) + .join("docker-compose.yml"), + ) + .await + .expect("Failed to read destination"); + + assert_eq!(source_content, dest_content); + + // Verify it contains expected content from embedded template + assert!(dest_content.contains("nginx:alpine")); + assert!(dest_content.contains("demo-app")); + } + + #[tokio::test] + async fn it_should_create_build_directory_successfully() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let build_path = temp_dir.path().join("build"); + let (template_manager, _templates_dir) = create_template_manager_with_embedded(); + let renderer = DockerComposeTemplateRenderer::new(template_manager, &build_path); + + let result = renderer.create_build_directory().await; + + assert!(result.is_ok()); + let created_dir = result.unwrap(); + assert_eq!(created_dir, build_path.join("docker-compose")); + assert!(created_dir.exists()); + assert!(created_dir.is_dir()); + } + + #[tokio::test] + async fn it_should_fail_gracefully_when_build_directory_creation_fails() { + let invalid_path = Path::new("/root/invalid/path/that/should/not/exist"); + let template_manager = create_test_template_manager(); + let renderer = DockerComposeTemplateRenderer::new(template_manager, invalid_path); + + let result = renderer.create_build_directory().await; + + assert!(result.is_err()); + match result.unwrap_err() { + DockerComposeTemplateError::DirectoryCreationFailed { directory, .. } => { + assert!(directory.contains("invalid")); + } + other => panic!("Expected DirectoryCreationFailed, got: {other:?}"), + } + } + + #[tokio::test] + async fn it_should_have_correct_template_file_constants() { + assert_eq!( + DockerComposeTemplateRenderer::DOCKER_COMPOSE_BUILD_PATH, + "docker-compose" + ); + assert_eq!( + DockerComposeTemplateRenderer::DOCKER_COMPOSE_TEMPLATE_PATH, + "docker-compose" + ); + assert_eq!( + DockerComposeTemplateRenderer::COMPOSE_FILE, + "docker-compose.yml" + ); + } + + #[test] + fn error_should_provide_help_for_template_path_failed() { + let error = DockerComposeTemplateError::TemplatePathFailed { + file_name: "docker-compose.yml".to_string(), + source: TemplateManagerError::TemplateNotFound { + relative_path: "docker-compose/docker-compose.yml".to_string(), + }, + }; + let help = error.help(); + assert!(help.contains("extract Docker Compose template")); + } + + #[test] + fn error_should_implement_traceable() { + let error = DockerComposeTemplateError::TemplatePathFailed { + file_name: "docker-compose.yml".to_string(), + source: TemplateManagerError::TemplateNotFound { + relative_path: "docker-compose/docker-compose.yml".to_string(), + }, + }; + assert!(error.trace_format().contains("TemplatePathFailed")); + assert!(error.trace_source().is_none()); + assert!(matches!(error.error_kind(), ErrorKind::Configuration)); + } + + #[test] + fn directory_creation_error_should_provide_help() { + let error = DockerComposeTemplateError::DirectoryCreationFailed { + directory: "/path/to/dir".to_string(), + source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "test"), + }; + let help = error.help(); + assert!(help.contains("create the Docker Compose build directory")); + } + + #[test] + fn static_file_copy_error_should_provide_help() { + let error = DockerComposeTemplateError::StaticFileCopyFailed { + file_name: "docker-compose.yml".to_string(), + source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "test"), + }; + let help = error.help(); + assert!(help.contains("copy Docker Compose file")); + } +} diff --git a/src/infrastructure/external_tools/mod.rs b/src/infrastructure/external_tools/mod.rs index 707d463a..80591e78 100644 --- a/src/infrastructure/external_tools/mod.rs +++ b/src/infrastructure/external_tools/mod.rs @@ -16,6 +16,8 @@ //! //! - `ansible` - Ansible configuration management integration //! - `template` - Template renderers for Ansible inventory and playbooks +//! - `docker_compose` - Docker Compose file management +//! - `file_manager` - File manager for Docker Compose configuration files //! - `tofu` - `OpenTofu` infrastructure provisioning integration //! - `template` - Template renderers for `OpenTofu` configuration files //! @@ -27,4 +29,5 @@ //! - Handle template validation and error reporting pub mod ansible; +pub mod docker_compose; pub mod tofu; diff --git a/src/infrastructure/remote_actions/mod.rs b/src/infrastructure/remote_actions/mod.rs index 465b59e0..f1d6903a 100644 --- a/src/infrastructure/remote_actions/mod.rs +++ b/src/infrastructure/remote_actions/mod.rs @@ -55,10 +55,12 @@ pub enum RemoteActionError { pub mod cloud_init; pub mod docker; pub mod docker_compose; +pub mod running_services; pub use cloud_init::CloudInitValidator; pub use docker::DockerValidator; pub use docker_compose::DockerComposeValidator; +pub use running_services::RunningServicesValidator; /// Trait for remote actions that can be executed on a server via SSH /// diff --git a/src/infrastructure/remote_actions/running_services.rs b/src/infrastructure/remote_actions/running_services.rs new file mode 100644 index 00000000..b6e423b1 --- /dev/null +++ b/src/infrastructure/remote_actions/running_services.rs @@ -0,0 +1,276 @@ +//! Running services validation remote action +//! +//! This module provides the `RunningServicesValidator` which checks that Docker Compose +//! services are running and healthy on remote instances after the `run` command has +//! executed the deployment. +//! +//! ## Current Scope (Demo Slice) +//! +//! This validator is designed for the demo slice which uses a temporary mocked service +//! (nginx web server). Validation is performed from **inside** the VM via SSH. +//! +//! ## Future Enhancements (Real Services) +//! +//! When implementing real Torrust services (Tracker, Index), validation should be +//! extended to include **external accessibility testing**: +//! +//! 1. **External HTTP/UDP Validation**: Test service accessibility from outside the VM, +//! not just from inside. For example, if the HTTP tracker is on port 7070, we need +//! to verify it's reachable from the test runner machine. +//! +//! 2. **Firewall Rule Verification**: External tests will implicitly validate that +//! firewall rules (UFW/iptables) are correctly configured. If a service is running +//! inside but not accessible from outside, it indicates a firewall misconfiguration. +//! +//! 3. **Both Internal and External Checks**: Consider running both types of validation: +//! - Internal (via SSH): Confirms service is running inside the container/VM +//! - External (from test runner): Confirms service is accessible through the network +//! +//! Example future validation for HTTP Tracker on port 7070: +//! ```text +//! // Internal check (current approach) +//! ssh user@vm "curl -sf http://localhost:7070/health" +//! +//! // External check (future enhancement) +//! curl -sf http://:7070/health +//! ``` +//! +//! This dual approach ensures complete end-to-end validation including network +//! configuration and firewall rules. +//! +//! ## Key Features +//! +//! - Validates services are in "running" state via `docker compose ps` +//! - Checks service health status (healthy/unhealthy) +//! - Verifies service accessibility via HTTP endpoint (for web services) +//! - Comprehensive error reporting with actionable troubleshooting steps +//! +//! ## Validation Process +//! +//! The validator performs multiple checks: +//! 1. Execute `docker compose ps` to verify services are listed +//! 2. Check that containers are in "running" status (not "exited" or "restarting") +//! 3. Verify health check status if configured (e.g., "healthy") +//! 4. Test HTTP accessibility for web services (optional) +//! +//! This ensures that the full deployment pipeline is validated end-to-end, +//! confirming that services are not just deployed but actually operational. + +use std::net::IpAddr; +use std::path::PathBuf; +use tracing::{info, instrument, warn}; + +use crate::adapters::ssh::SshClient; +use crate::adapters::ssh::SshConfig; +use crate::infrastructure::remote_actions::{RemoteAction, RemoteActionError}; + +/// Default deployment directory for Docker Compose files +const DEFAULT_DEPLOY_DIR: &str = "/opt/torrust"; + +/// Action that validates Docker Compose services are running and healthy +pub struct RunningServicesValidator { + ssh_client: SshClient, + deploy_dir: PathBuf, +} + +impl RunningServicesValidator { + /// Create a new `RunningServicesValidator` with the specified SSH configuration + /// + /// Uses the default deployment directory `/opt/torrust`. + /// + /// # Arguments + /// * `ssh_config` - SSH connection configuration containing credentials and host IP + #[must_use] + pub fn new(ssh_config: SshConfig) -> Self { + let ssh_client = SshClient::new(ssh_config); + Self { + ssh_client, + deploy_dir: PathBuf::from(DEFAULT_DEPLOY_DIR), + } + } + + /// Create a new `RunningServicesValidator` with a custom deployment directory + /// + /// # Arguments + /// * `ssh_config` - SSH connection configuration containing credentials and host IP + /// * `deploy_dir` - Path to the directory containing docker-compose.yml on the remote host + #[must_use] + pub fn with_deploy_dir(ssh_config: SshConfig, deploy_dir: PathBuf) -> Self { + let ssh_client = SshClient::new(ssh_config); + Self { + ssh_client, + deploy_dir, + } + } + + /// Check service status using docker compose ps (human-readable format) + fn check_services_status(&self) -> Result { + let deploy_dir = self.deploy_dir.display(); + let command = format!("cd {deploy_dir} && docker compose ps"); + + self.ssh_client + .execute(&command) + .map_err(|source| RemoteActionError::SshCommandFailed { + action_name: self.name().to_string(), + source, + }) + } + + /// Check if demo-app service (nginx) is accessible via HTTP + fn check_http_accessibility(&self, port: u16) -> Result { + let command = format!("curl -sf http://localhost:{port} > /dev/null"); + + self.ssh_client.check_command(&command).map_err(|source| { + RemoteActionError::SshCommandFailed { + action_name: self.name().to_string(), + source, + } + }) + } +} + +impl RemoteAction for RunningServicesValidator { + fn name(&self) -> &'static str { + "running-services-validation" + } + + #[instrument( + name = "running_services_validation", + skip(self), + fields( + action_type = "validation", + component = "running_services", + server_ip = %server_ip, + deploy_dir = %self.deploy_dir.display() + ) + )] + async fn execute(&self, server_ip: &IpAddr) -> Result<(), RemoteActionError> { + info!( + action = "running_services_validation", + deploy_dir = %self.deploy_dir.display(), + "Validating Docker Compose services are running" + ); + + // Step 1: Check services status using docker compose ps + let services_output = self.check_services_status()?; + let services_output = services_output.trim(); + + info!( + action = "running_services_validation", + check = "docker_compose_ps", + "Docker Compose services status retrieved" + ); + + // Step 2: Validate that at least one service is running + // The output should contain service information (not empty or just headers) + let has_running_services = !services_output.is_empty() + && (services_output.contains("running") || services_output.contains("Up")); + + if !has_running_services { + warn!( + action = "running_services_validation", + check = "services_running", + status = "warning", + output = %services_output, + "No running services detected in docker compose ps output" + ); + return Err(RemoteActionError::ValidationFailed { + action_name: self.name().to_string(), + message: format!( + "No running services detected. Output: {}", + if services_output.is_empty() { + "(empty)" + } else { + services_output + } + ), + }); + } + + info!( + action = "running_services_validation", + check = "services_running", + status = "success", + "Docker Compose services are running" + ); + + // Step 3: Check for healthy status (if health checks are configured) + let has_healthy_services = services_output.contains("healthy"); + let has_unhealthy_services = services_output.contains("unhealthy"); + + if has_unhealthy_services { + warn!( + action = "running_services_validation", + check = "health_status", + status = "warning", + output = %services_output, + "Some services are unhealthy" + ); + // Don't fail - just warn. Services might still be starting up. + } else if has_healthy_services { + info!( + action = "running_services_validation", + check = "health_status", + status = "success", + "Services are healthy" + ); + } + + // Step 4: Test HTTP accessibility for demo-app (nginx on port 8080) + match self.check_http_accessibility(8080) { + Ok(true) => { + info!( + action = "running_services_validation", + check = "http_accessibility", + port = 8080, + status = "success", + "Demo app service is accessible via HTTP" + ); + } + Ok(false) => { + warn!( + action = "running_services_validation", + check = "http_accessibility", + port = 8080, + status = "warning", + "Demo app service HTTP check returned false (may still be starting)" + ); + } + Err(e) => { + warn!( + action = "running_services_validation", + check = "http_accessibility", + port = 8080, + status = "warning", + error = %e, + "Could not verify HTTP accessibility (service may not expose HTTP)" + ); + // Don't fail - HTTP check is optional + } + } + + info!( + action = "running_services_validation", + status = "success", + "Running services validation completed successfully" + ); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_deploy_dir() { + assert_eq!(DEFAULT_DEPLOY_DIR, "/opt/torrust"); + } + + #[test] + fn test_action_name() { + // Can't test without SSH config, but we can verify the constant + assert_eq!("running-services-validation", "running-services-validation"); + } +} diff --git a/src/infrastructure/trace/mod.rs b/src/infrastructure/trace/mod.rs index a337c578..fddde375 100644 --- a/src/infrastructure/trace/mod.rs +++ b/src/infrastructure/trace/mod.rs @@ -9,8 +9,11 @@ //! - `sections` - Formatting utilities for trace sections //! - `error` - Error types for trace writing operations //! - `common` - Shared file I/O operations -//! - `commands` - Command-specific trace writers (provision, configure) +//! - `commands` - Command-specific trace writers (provision, configure, release, run) pub mod writer; -pub use writer::{ConfigureTraceWriter, ProvisionTraceWriter, TraceWriterError}; +pub use writer::{ + ConfigureTraceWriter, ProvisionTraceWriter, ReleaseTraceWriter, RunTraceWriter, + TraceWriterError, +}; diff --git a/src/infrastructure/trace/writer/commands/mod.rs b/src/infrastructure/trace/writer/commands/mod.rs index edd689b2..7b5a5e63 100644 --- a/src/infrastructure/trace/writer/commands/mod.rs +++ b/src/infrastructure/trace/writer/commands/mod.rs @@ -3,9 +3,15 @@ //! This module contains trace writers for each deployment command: //! - `provision` - Provisioning failures //! - `configure` - Configuration failures +//! - `release` - Release failures +//! - `run` - Run failures mod configure; mod provision; +mod release; +mod run; pub use configure::ConfigureTraceWriter; pub use provision::ProvisionTraceWriter; +pub use release::ReleaseTraceWriter; +pub use run::RunTraceWriter; diff --git a/src/infrastructure/trace/writer/commands/release.rs b/src/infrastructure/trace/writer/commands/release.rs new file mode 100644 index 00000000..8c7ccb1f --- /dev/null +++ b/src/infrastructure/trace/writer/commands/release.rs @@ -0,0 +1,417 @@ +//! Release command trace writer +//! +//! Generates trace files for release command failures with release-specific +//! context and metadata. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use crate::domain::environment::state::ReleaseFailureContext; +use crate::shared::{Clock, Traceable}; + +use super::super::common::CommonTraceWriter; +use super::super::error::TraceWriterError; +use super::super::sections::TraceSections; + +/// Release-specific trace writer +/// +/// Generates trace files for release command failures with release-specific +/// context and metadata. +/// +/// # Example +/// +/// ```no_run +/// use std::path::PathBuf; +/// use std::sync::Arc; +/// use torrust_tracker_deployer_lib::infrastructure::trace::ReleaseTraceWriter; +/// use torrust_tracker_deployer_lib::shared::SystemClock; +/// +/// let traces_dir = PathBuf::from("data/my-env/traces"); +/// let clock = Arc::new(SystemClock); +/// let writer = ReleaseTraceWriter::new(traces_dir, clock); +/// ``` +pub struct ReleaseTraceWriter { + common: CommonTraceWriter, +} + +impl ReleaseTraceWriter { + /// Create a new release trace writer + #[must_use] + pub fn new(traces_dir: impl Into, clock: Arc) -> Self { + Self { + common: CommonTraceWriter::new(traces_dir, clock), + } + } + + /// Write a release failure trace file + /// + /// Generates a trace file with release-specific context and logs the outcome. + /// Success is logged at INFO level, failures at WARN level. + /// + /// # Arguments + /// + /// * `ctx` - The release failure context with metadata + /// * `error` - The error that implements `Traceable` for chain extraction + /// + /// # Returns + /// + /// Path to the generated trace file + /// + /// # Errors + /// + /// Returns an error if directory creation or file writing fails + pub fn write_trace( + &self, + ctx: &ReleaseFailureContext, + error: &E, + ) -> Result { + use tracing::{info, warn}; + + let trace_content = Self::format_trace(ctx, error); + + match self.common.write_trace("release", &trace_content) { + Ok(trace_file_path) => { + info!( + trace_id = %ctx.base.trace_id, + trace_file = ?trace_file_path, + "Generated trace file for release failure" + ); + Ok(trace_file_path) + } + Err(e) => { + warn!( + trace_id = %ctx.base.trace_id, + error = %e, + "Failed to generate trace file for release failure" + ); + Err(e) + } + } + } + + /// Format a complete release trace + fn format_trace(ctx: &ReleaseFailureContext, error: &E) -> String { + use std::fmt::Write; + + let mut trace = String::new(); + + // Header + trace.push_str(&TraceSections::header("RELEASE FAILURE TRACE")); + + // Base metadata (common to all failures) + trace.push_str(&TraceSections::format_base_metadata(&ctx.base)); + + // Command-specific metadata + let _ = writeln!(trace, "Failed Step: {}", ctx.failed_step); + let _ = writeln!(trace, "Error Kind: {:?}\n", ctx.error_kind); + + // Error chain + trace.push_str(TraceSections::error_chain_header()); + trace.push_str(&TraceSections::format_error_chain(error)); + + // Footer + trace.push_str(TraceSections::footer()); + + trace + } + + /// Get the traces directory path + #[must_use] + pub fn traces_dir(&self) -> &Path { + self.common.traces_dir() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + use std::time::Duration; + use tempfile::TempDir; + + use crate::domain::environment::state::{ + BaseFailureContext, ReleaseFailureContext, ReleaseStep, + }; + use crate::domain::environment::TraceId; + use crate::shared::ErrorKind; + + // Test error implementing Traceable + #[derive(Debug)] + struct TestError { + message: String, + source: Option>, + } + + impl std::fmt::Display for TestError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "TestError: {}", self.message) + } + } + + impl std::error::Error for TestError {} + + impl Traceable for TestError { + fn trace_format(&self) -> String { + format!("TestError: {}", self.message) + } + + fn trace_source(&self) -> Option<&dyn Traceable> { + self.source.as_deref() + } + + fn error_kind(&self) -> crate::shared::ErrorKind { + crate::shared::ErrorKind::CommandExecution + } + } + + // Test helpers - Arrange phase utilities + + /// Create a test error with the given message + fn create_test_error(message: &str) -> TestError { + TestError { + message: message.to_string(), + source: None, + } + } + + /// Create a test writer with a temporary directory + /// + /// Returns (writer, `temp_dir`, `traces_dir`) + /// The `temp_dir` must be kept alive for the duration of the test + fn create_test_writer() -> (ReleaseTraceWriter, TempDir, PathBuf) { + use crate::domain::environment::TRACES_DIR_NAME; + use crate::testing::MockClock; + use chrono::TimeZone; + + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let traces_dir = temp_dir.path().join(TRACES_DIR_NAME); + let fixed_time = Utc.with_ymd_and_hms(2025, 10, 7, 12, 0, 0).unwrap(); + let clock = Arc::new(MockClock::new(fixed_time)); + let writer = ReleaseTraceWriter::new(traces_dir.clone(), clock); + (writer, temp_dir, traces_dir) + } + + /// Create a release failure context with default test values + /// + /// # Arguments + /// + /// * `error_summary` - The error summary message + /// + /// # Returns + /// + /// A release failure context with sensible defaults for testing + fn create_test_context(error_summary: &str) -> ReleaseFailureContext { + let now = Utc::now(); + ReleaseFailureContext { + failed_step: ReleaseStep::RenderDockerComposeTemplates, + error_kind: ErrorKind::TemplateRendering, + base: BaseFailureContext { + error_summary: error_summary.to_string(), + failed_at: now, + execution_started_at: now, + execution_duration: Duration::from_secs(5), + trace_id: TraceId::new(), + trace_file_path: None, + }, + } + } + + /// Create a release failure context with a specific trace ID + /// + /// Useful when you need to verify trace ID in assertions + fn create_test_context_with_trace_id( + error_summary: &str, + trace_id: TraceId, + ) -> ReleaseFailureContext { + let now = Utc::now(); + ReleaseFailureContext { + failed_step: ReleaseStep::RenderDockerComposeTemplates, + error_kind: ErrorKind::TemplateRendering, + base: BaseFailureContext { + error_summary: error_summary.to_string(), + failed_at: now, + execution_started_at: now, + execution_duration: Duration::from_secs(5), + trace_id, + trace_file_path: None, + }, + } + } + + #[test] + fn it_should_create_release_trace_writer_with_directory() { + // Arrange + let (writer, _temp_dir, traces_dir) = create_test_writer(); + + // Assert + assert_eq!(writer.traces_dir(), traces_dir); + } + + #[test] + fn it_should_create_traces_directory_on_first_write() { + // Arrange + let (writer, _temp_dir, traces_dir) = create_test_writer(); + let error = create_test_error("test error"); + let context = create_test_context(&error.to_string()); + + // Directory should not exist yet + assert!(!traces_dir.exists()); + + // Act + writer.write_trace(&context, &error).unwrap(); + + // Assert + assert!(traces_dir.exists()); + } + + #[test] + fn it_should_write_release_trace_with_correct_naming() { + // Arrange + let (writer, _temp_dir, _traces_dir) = create_test_writer(); + let error = create_test_error("release test error"); + let context = create_test_context(&error.to_string()); + + // Act + let trace_file = writer.write_trace(&context, &error).unwrap(); + + // Assert + assert!(trace_file.exists()); + + let filename = trace_file.file_name().unwrap().to_str().unwrap(); + assert!(filename.ends_with("-release.log")); + } + + #[test] + fn it_should_use_timestamp_and_command_as_filename() { + // Arrange + let (writer, _temp_dir, _traces_dir) = create_test_writer(); + let error = create_test_error("test error"); + let context = create_test_context(&error.to_string()); + + // Act + let trace_file = writer.write_trace(&context, &error).unwrap(); + + // Assert + let filename = trace_file.file_name().unwrap().to_str().unwrap(); + + // Verify filename format: {timestamp}-release.log + // Example: 20251003-103045-release.log + assert!(filename.ends_with("-release.log")); + + // Verify timestamp prefix exists (YYYYmmdd-HHMMSS format) + let parts: Vec<&str> = filename.split('-').collect(); + assert!(parts.len() >= 3); // At least YYYYmmdd, HHMMSS, release.log + + // Verify first part is date (8 digits) + assert_eq!(parts[0].len(), 8); + assert!(parts[0].chars().all(|c| c.is_ascii_digit())); + + // Verify second part is time (6 digits) + let time_part = parts[1]; + assert_eq!(time_part.len(), 6); + assert!(time_part.chars().all(|c| c.is_ascii_digit())); + } + + #[test] + fn it_should_include_trace_metadata_in_release_trace() { + // Arrange + let (writer, _temp_dir, _traces_dir) = create_test_writer(); + let error = create_test_error("test error"); + let trace_id = TraceId::new(); + let context = create_test_context_with_trace_id("Test error summary", trace_id.clone()); + + // Act + let trace_file = writer.write_trace(&context, &error).unwrap(); + let trace_data = std::fs::read_to_string(trace_file).unwrap(); + + // Assert - Verify metadata is included + assert!(trace_data.contains("RELEASE FAILURE TRACE")); + assert!(trace_data.contains(&format!("Trace ID: {trace_id}"))); + assert!(trace_data.contains("Failed Step: Render Docker Compose Templates")); + assert!(trace_data.contains("Error Kind: TemplateRendering")); + assert!(trace_data.contains("Error Summary: Test error summary")); + } + + #[test] + fn it_should_generate_trace_files_with_correct_naming() { + // This test verifies that trace files are created with correct naming convention + // and follow the format: {timestamp}-{command}.log + + // Arrange + let (writer, _temp_dir, traces_dir) = create_test_writer(); + let error = create_test_error("Simulated release failure"); + let context = create_test_context(&error.to_string()); + + // Act + let _trace_path = writer + .write_trace(&context, &error) + .expect("Failed to write trace file"); + + // Verify trace directory was created + assert!( + traces_dir.exists(), + "Traces directory should be created at: {traces_dir:?}" + ); + + // Verify trace file was created with correct naming: {timestamp}-release.log + let trace_files: Vec = std::fs::read_dir(&traces_dir) + .expect("Failed to read traces directory") + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_file()) + .collect(); + + assert_eq!( + trace_files.len(), + 1, + "Expected exactly 1 trace file, found: {trace_files:?}" + ); + + let trace_file = &trace_files[0]; + let filename = trace_file + .file_name() + .unwrap() + .to_str() + .expect("Filename should be valid UTF-8"); + + // Verify filename format + assert!( + filename.ends_with("-release.log"), + "Filename should end with '-release.log', got: {filename}" + ); + + // Verify file contains expected sections + let trace_data = std::fs::read_to_string(trace_file).expect("Failed to read trace file"); + assert!(trace_data.contains("RELEASE FAILURE TRACE")); + assert!(trace_data.contains("ERROR CHAIN")); + assert!(trace_data.contains("END OF TRACE")); + } + + #[test] + fn it_should_format_release_step_in_trace() { + // Arrange + let (writer, _temp_dir, _traces_dir) = create_test_writer(); + let error = create_test_error("File transfer failed"); + let now = Utc::now(); + let context = ReleaseFailureContext { + failed_step: ReleaseStep::DeployComposeFilesToRemote, + error_kind: ErrorKind::InfrastructureOperation, + base: BaseFailureContext { + error_summary: "SSH connection failed".to_string(), + failed_at: now, + execution_started_at: now, + execution_duration: Duration::from_secs(30), + trace_id: TraceId::new(), + trace_file_path: None, + }, + }; + + // Act + let trace_file = writer.write_trace(&context, &error).unwrap(); + let trace_data = std::fs::read_to_string(trace_file).unwrap(); + + // Assert - Verify step name is formatted with Display trait + assert!(trace_data.contains("Failed Step: Deploy Compose Files to Remote")); + assert!(trace_data.contains("Error Kind: InfrastructureOperation")); + } +} diff --git a/src/infrastructure/trace/writer/commands/run.rs b/src/infrastructure/trace/writer/commands/run.rs new file mode 100644 index 00000000..6b3a960d --- /dev/null +++ b/src/infrastructure/trace/writer/commands/run.rs @@ -0,0 +1,415 @@ +//! Run command trace writer +//! +//! Generates trace files for run command failures with run-specific +//! context and metadata. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use crate::domain::environment::state::RunFailureContext; +use crate::shared::{Clock, Traceable}; + +use super::super::common::CommonTraceWriter; +use super::super::error::TraceWriterError; +use super::super::sections::TraceSections; + +/// Run-specific trace writer +/// +/// Generates trace files for run command failures with run-specific +/// context and metadata. +/// +/// # Example +/// +/// ```no_run +/// use std::path::PathBuf; +/// use std::sync::Arc; +/// use torrust_tracker_deployer_lib::infrastructure::trace::RunTraceWriter; +/// use torrust_tracker_deployer_lib::shared::SystemClock; +/// +/// let traces_dir = PathBuf::from("data/my-env/traces"); +/// let clock = Arc::new(SystemClock); +/// let writer = RunTraceWriter::new(traces_dir, clock); +/// ``` +pub struct RunTraceWriter { + common: CommonTraceWriter, +} + +impl RunTraceWriter { + /// Create a new run trace writer + #[must_use] + pub fn new(traces_dir: impl Into, clock: Arc) -> Self { + Self { + common: CommonTraceWriter::new(traces_dir, clock), + } + } + + /// Write a run failure trace file + /// + /// Generates a trace file with run-specific context and logs the outcome. + /// Success is logged at INFO level, failures at WARN level. + /// + /// # Arguments + /// + /// * `ctx` - The run failure context with metadata + /// * `error` - The error that implements `Traceable` for chain extraction + /// + /// # Returns + /// + /// Path to the generated trace file + /// + /// # Errors + /// + /// Returns an error if directory creation or file writing fails + pub fn write_trace( + &self, + ctx: &RunFailureContext, + error: &E, + ) -> Result { + use tracing::{info, warn}; + + let trace_content = Self::format_trace(ctx, error); + + match self.common.write_trace("run", &trace_content) { + Ok(trace_file_path) => { + info!( + trace_id = %ctx.base.trace_id, + trace_file = ?trace_file_path, + "Generated trace file for run failure" + ); + Ok(trace_file_path) + } + Err(e) => { + warn!( + trace_id = %ctx.base.trace_id, + error = %e, + "Failed to generate trace file for run failure" + ); + Err(e) + } + } + } + + /// Format a complete run trace + fn format_trace(ctx: &RunFailureContext, error: &E) -> String { + use std::fmt::Write; + + let mut trace = String::new(); + + // Header + trace.push_str(&TraceSections::header("RUN FAILURE TRACE")); + + // Base metadata (common to all failures) + trace.push_str(&TraceSections::format_base_metadata(&ctx.base)); + + // Command-specific metadata + let _ = writeln!(trace, "Failed Step: {}", ctx.failed_step); + let _ = writeln!(trace, "Error Kind: {:?}\n", ctx.error_kind); + + // Error chain + trace.push_str(TraceSections::error_chain_header()); + trace.push_str(&TraceSections::format_error_chain(error)); + + // Footer + trace.push_str(TraceSections::footer()); + + trace + } + + /// Get the traces directory path + #[must_use] + pub fn traces_dir(&self) -> &Path { + self.common.traces_dir() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + use std::time::Duration; + use tempfile::TempDir; + + use crate::domain::environment::state::{BaseFailureContext, RunFailureContext, RunStep}; + use crate::domain::environment::TraceId; + use crate::shared::ErrorKind; + + // Test error implementing Traceable + #[derive(Debug)] + struct TestError { + message: String, + source: Option>, + } + + impl std::fmt::Display for TestError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "TestError: {}", self.message) + } + } + + impl std::error::Error for TestError {} + + impl Traceable for TestError { + fn trace_format(&self) -> String { + format!("TestError: {}", self.message) + } + + fn trace_source(&self) -> Option<&dyn Traceable> { + self.source.as_deref() + } + + fn error_kind(&self) -> crate::shared::ErrorKind { + crate::shared::ErrorKind::CommandExecution + } + } + + // Test helpers - Arrange phase utilities + + /// Create a test error with the given message + fn create_test_error(message: &str) -> TestError { + TestError { + message: message.to_string(), + source: None, + } + } + + /// Create a test writer with a temporary directory + /// + /// Returns (writer, `temp_dir`, `traces_dir`) + /// The `temp_dir` must be kept alive for the duration of the test + fn create_test_writer() -> (RunTraceWriter, TempDir, PathBuf) { + use crate::domain::environment::TRACES_DIR_NAME; + use crate::testing::MockClock; + use chrono::TimeZone; + + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let traces_dir = temp_dir.path().join(TRACES_DIR_NAME); + let fixed_time = Utc.with_ymd_and_hms(2025, 10, 7, 12, 0, 0).unwrap(); + let clock = Arc::new(MockClock::new(fixed_time)); + let writer = RunTraceWriter::new(traces_dir.clone(), clock); + (writer, temp_dir, traces_dir) + } + + /// Create a run failure context with default test values + /// + /// # Arguments + /// + /// * `error_summary` - The error summary message + /// + /// # Returns + /// + /// A run failure context with sensible defaults for testing + fn create_test_context(error_summary: &str) -> RunFailureContext { + let now = Utc::now(); + RunFailureContext { + failed_step: RunStep::StartServices, + error_kind: ErrorKind::InfrastructureOperation, + base: BaseFailureContext { + error_summary: error_summary.to_string(), + failed_at: now, + execution_started_at: now, + execution_duration: Duration::from_secs(5), + trace_id: TraceId::new(), + trace_file_path: None, + }, + } + } + + /// Create a run failure context with a specific trace ID + /// + /// Useful when you need to verify trace ID in assertions + fn create_test_context_with_trace_id( + error_summary: &str, + trace_id: TraceId, + ) -> RunFailureContext { + let now = Utc::now(); + RunFailureContext { + failed_step: RunStep::StartServices, + error_kind: ErrorKind::InfrastructureOperation, + base: BaseFailureContext { + error_summary: error_summary.to_string(), + failed_at: now, + execution_started_at: now, + execution_duration: Duration::from_secs(5), + trace_id, + trace_file_path: None, + }, + } + } + + #[test] + fn it_should_create_run_trace_writer_with_directory() { + // Arrange + let (writer, _temp_dir, traces_dir) = create_test_writer(); + + // Assert + assert_eq!(writer.traces_dir(), traces_dir); + } + + #[test] + fn it_should_create_traces_directory_on_first_write() { + // Arrange + let (writer, _temp_dir, traces_dir) = create_test_writer(); + let error = create_test_error("test error"); + let context = create_test_context(&error.to_string()); + + // Directory should not exist yet + assert!(!traces_dir.exists()); + + // Act + writer.write_trace(&context, &error).unwrap(); + + // Assert + assert!(traces_dir.exists()); + } + + #[test] + fn it_should_write_run_trace_with_correct_naming() { + // Arrange + let (writer, _temp_dir, _traces_dir) = create_test_writer(); + let error = create_test_error("run test error"); + let context = create_test_context(&error.to_string()); + + // Act + let trace_file = writer.write_trace(&context, &error).unwrap(); + + // Assert + assert!(trace_file.exists()); + + let filename = trace_file.file_name().unwrap().to_str().unwrap(); + assert!(filename.ends_with("-run.log")); + } + + #[test] + fn it_should_use_timestamp_and_command_as_filename() { + // Arrange + let (writer, _temp_dir, _traces_dir) = create_test_writer(); + let error = create_test_error("test error"); + let context = create_test_context(&error.to_string()); + + // Act + let trace_file = writer.write_trace(&context, &error).unwrap(); + + // Assert + let filename = trace_file.file_name().unwrap().to_str().unwrap(); + + // Verify filename format: {timestamp}-run.log + // Example: 20251003-103045-run.log + assert!(filename.ends_with("-run.log")); + + // Verify timestamp prefix exists (YYYYmmdd-HHMMSS format) + let parts: Vec<&str> = filename.split('-').collect(); + assert!(parts.len() >= 3); // At least YYYYmmdd, HHMMSS, run.log + + // Verify first part is date (8 digits) + assert_eq!(parts[0].len(), 8); + assert!(parts[0].chars().all(|c| c.is_ascii_digit())); + + // Verify second part is time (6 digits) + let time_part = parts[1]; + assert_eq!(time_part.len(), 6); + assert!(time_part.chars().all(|c| c.is_ascii_digit())); + } + + #[test] + fn it_should_include_trace_metadata_in_run_trace() { + // Arrange + let (writer, _temp_dir, _traces_dir) = create_test_writer(); + let error = create_test_error("test error"); + let trace_id = TraceId::new(); + let context = create_test_context_with_trace_id("Test error summary", trace_id.clone()); + + // Act + let trace_file = writer.write_trace(&context, &error).unwrap(); + let trace_data = std::fs::read_to_string(trace_file).unwrap(); + + // Assert - Verify metadata is included + assert!(trace_data.contains("RUN FAILURE TRACE")); + assert!(trace_data.contains(&format!("Trace ID: {trace_id}"))); + assert!(trace_data.contains("Failed Step: Start Services")); + assert!(trace_data.contains("Error Kind: InfrastructureOperation")); + assert!(trace_data.contains("Error Summary: Test error summary")); + } + + #[test] + fn it_should_generate_trace_files_with_correct_naming() { + // This test verifies that trace files are created with correct naming convention + // and follow the format: {timestamp}-{command}.log + + // Arrange + let (writer, _temp_dir, traces_dir) = create_test_writer(); + let error = create_test_error("Simulated run failure"); + let context = create_test_context(&error.to_string()); + + // Act + let _trace_path = writer + .write_trace(&context, &error) + .expect("Failed to write trace file"); + + // Verify trace directory was created + assert!( + traces_dir.exists(), + "Traces directory should be created at: {traces_dir:?}" + ); + + // Verify trace file was created with correct naming: {timestamp}-run.log + let trace_files: Vec = std::fs::read_dir(&traces_dir) + .expect("Failed to read traces directory") + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_file()) + .collect(); + + assert_eq!( + trace_files.len(), + 1, + "Expected exactly 1 trace file, found: {trace_files:?}" + ); + + let trace_file = &trace_files[0]; + let filename = trace_file + .file_name() + .unwrap() + .to_str() + .expect("Filename should be valid UTF-8"); + + // Verify filename format + assert!( + filename.ends_with("-run.log"), + "Filename should end with '-run.log', got: {filename}" + ); + + // Verify file contains expected sections + let trace_data = std::fs::read_to_string(trace_file).expect("Failed to read trace file"); + assert!(trace_data.contains("RUN FAILURE TRACE")); + assert!(trace_data.contains("ERROR CHAIN")); + assert!(trace_data.contains("END OF TRACE")); + } + + #[test] + fn it_should_format_run_step_in_trace() { + // Arrange + let (writer, _temp_dir, _traces_dir) = create_test_writer(); + let error = create_test_error("Docker compose up failed"); + let now = Utc::now(); + let context = RunFailureContext { + failed_step: RunStep::StartServices, + error_kind: ErrorKind::InfrastructureOperation, + base: BaseFailureContext { + error_summary: "Container failed to start".to_string(), + failed_at: now, + execution_started_at: now, + execution_duration: Duration::from_secs(30), + trace_id: TraceId::new(), + trace_file_path: None, + }, + }; + + // Act + let trace_file = writer.write_trace(&context, &error).unwrap(); + let trace_data = std::fs::read_to_string(trace_file).unwrap(); + + // Assert - Verify step name is formatted with Display trait + assert!(trace_data.contains("Failed Step: Start Services")); + assert!(trace_data.contains("Error Kind: InfrastructureOperation")); + } +} diff --git a/src/infrastructure/trace/writer/mod.rs b/src/infrastructure/trace/writer/mod.rs index 2e1c8be9..ce8e74f0 100644 --- a/src/infrastructure/trace/writer/mod.rs +++ b/src/infrastructure/trace/writer/mod.rs @@ -11,5 +11,7 @@ mod common; mod error; mod sections; -pub use commands::{ConfigureTraceWriter, ProvisionTraceWriter}; +pub use commands::{ + ConfigureTraceWriter, ProvisionTraceWriter, ReleaseTraceWriter, RunTraceWriter, +}; pub use error::TraceWriterError; diff --git a/src/presentation/controllers/mod.rs b/src/presentation/controllers/mod.rs index 11c8ba54..0165ae1a 100644 --- a/src/presentation/controllers/mod.rs +++ b/src/presentation/controllers/mod.rs @@ -249,11 +249,10 @@ pub mod create; pub mod destroy; pub mod provision; pub mod register; +pub mod release; +pub mod run; pub mod test; // Shared test utilities #[cfg(test)] pub mod tests; - -// Future command modules will be added here: -// pub mod release; diff --git a/src/presentation/controllers/release/errors.rs b/src/presentation/controllers/release/errors.rs new file mode 100644 index 00000000..38d37997 --- /dev/null +++ b/src/presentation/controllers/release/errors.rs @@ -0,0 +1,358 @@ +//! Error types for the Release Subcommand +//! +//! This module defines error types that can occur during CLI release command execution. +//! All errors follow the project's error handling principles by providing clear, +//! contextual, and actionable error messages with `.help()` methods. + +use thiserror::Error; + +use crate::application::command_handlers::release::ReleaseCommandHandlerError; +use crate::domain::environment::name::EnvironmentNameError; +use crate::presentation::views::progress::ProgressReporterError; + +/// Release command specific errors +/// +/// This enum contains all error variants specific to the release command, +/// including environment validation, state validation, and deployment failures. +/// Each variant includes relevant context and actionable error messages. +#[derive(Debug, Error)] +pub enum ReleaseSubcommandError { + // ===== Environment Validation Errors ===== + /// Environment name validation failed + /// + /// The provided environment name doesn't meet the validation requirements. + /// Use `.help()` for detailed troubleshooting steps. + #[error("Invalid environment name '{name}': {source} +Tip: Environment names must be 1-63 characters, start with letter/digit, contain only letters/digits/hyphens")] + InvalidEnvironmentName { + name: String, + #[source] + source: EnvironmentNameError, + }, + + /// Environment not found or inaccessible + /// + /// The environment couldn't be loaded from persistent storage. + /// Use `.help()` for detailed troubleshooting steps. + #[error( + "Environment '{name}' not found or inaccessible from data directory '{data_dir}' +Tip: Check if environment exists: ls -la {data_dir}/" + )] + EnvironmentNotAccessible { name: String, data_dir: String }, + + // ===== State Validation Errors ===== + /// Environment is not in the required state for release + /// + /// The release command requires the environment to be in the Configured state. + /// Use `.help()` for detailed troubleshooting steps. + #[error( + "Environment '{name}' is not in the required state for release (current: {current_state}, required: Configured) +Tip: Run 'configure' command first to prepare the environment for release" + )] + InvalidEnvironmentState { name: String, current_state: String }, + + // ===== Release Operation Errors ===== + /// Release operation failed + /// + /// The release process encountered an error during execution. + /// Use `.help()` for detailed troubleshooting steps. + #[error( + "Failed to release application in environment '{name}': {reason} +Tip: Check logs and try running with --log-output file-and-stderr for more details" + )] + ReleaseOperationFailed { name: String, reason: String }, + + // ===== Internal Errors ===== + /// Progress reporting failed + /// + /// Failed to report progress to the user due to an internal error. + /// This indicates a critical internal error. + #[error( + "Failed to report progress: {source} +Tip: This is a critical bug - please report it with full logs using --log-output file-and-stderr" + )] + ProgressReportingFailed { + #[source] + source: ProgressReporterError, + }, + + // ===== Application Layer Errors ===== + /// Application layer error during release + /// + /// The release command handler in the application layer returned an error. + /// Use `.help()` for detailed troubleshooting steps. + #[error("Release command failed: {source}")] + ApplicationLayerError { + #[source] + source: ReleaseCommandHandlerError, + }, +} + +// ============================================================================ +// ERROR CONVERSIONS +// ============================================================================ + +impl From for ReleaseSubcommandError { + fn from(source: ProgressReporterError) -> Self { + Self::ProgressReportingFailed { source } + } +} + +impl ReleaseSubcommandError { + /// Get detailed troubleshooting guidance for this error + /// + /// This method provides comprehensive troubleshooting steps that can be + /// displayed to users when they need more help resolving the error. + #[must_use] + #[allow(clippy::too_many_lines)] // Help text is comprehensive for user guidance + pub fn help(&self) -> &'static str { + match self { + Self::InvalidEnvironmentName { .. } => { + "Invalid Environment Name - Detailed Troubleshooting: + +1. Check environment name format: + - Length: Must be 1-63 characters + - Start: Must begin with letter (a-z, A-Z) or digit (0-9) + - Characters: Only letters, digits, and hyphens allowed + - End: Must not end with a hyphen + +2. Common valid examples: + - 'production' + - 'test-env' + - 'e2e-provision' + - 'dev123' + +3. Common invalid examples: + - 'test_env' (underscores not allowed) + - '-test' (starts with hyphen) + - 'test-' (ends with hyphen) + - '' (empty string) + +For more information, see the environment naming conventions in the documentation." + } + + Self::EnvironmentNotAccessible { .. } => { + "Environment Not Accessible - Detailed Troubleshooting: + +1. Check if environment exists: + - List environments: ls -la data/ + - Look for directory with your environment name + +2. Verify file permissions: + - Check directory permissions: ls -ld data/ + - Ensure read/write access: chmod 755 data/ + +3. Check if environment was created: + - Look for environment.json file: ls -la data/{env_name}/ + - Verify it's a valid deployment environment + +4. Common causes: + - Environment was never created (run create first) + - Wrong data directory path + - Permission issues + - Corrupted environment state + +If the environment should exist, check the logs for more details." + } + + Self::InvalidEnvironmentState { .. } => { + "Invalid Environment State - Detailed Troubleshooting: + +1. Check current environment state: + - The release command requires the environment to be 'Configured' + - Run the workflow in order: create → provision → configure → release + +2. Required workflow: + - First: torrust-tracker-deployer create environment -f env.json + - Then: torrust-tracker-deployer provision + - Then: torrust-tracker-deployer configure + - Finally: torrust-tracker-deployer release + +3. Check environment state: + - Look at data//environment.json + - Verify the 'state' field shows 'Configured' + +4. Common issues: + - Skipped the 'configure' step + - Previous configure command failed + - Environment was reset or recreated + +Run 'configure' command first, then retry 'release'." + } + + Self::ReleaseOperationFailed { .. } => { + "Release Operation Failed - Detailed Troubleshooting: + +1. Check system resources: + - Ensure sufficient disk space on target VM + - Check network connectivity to the VM + - Verify SSH access is working + +2. Review the operation logs: + - Run with verbose logging: --log-output file-and-stderr + - Check log files in data/logs/ + - Look for specific error details + +3. Check application files: + - Verify Docker Compose files are valid + - Check if required images are accessible + - Ensure deployment directory exists on VM + +4. Manual intervention may be needed: + - SSH into the VM and check disk space + - Verify Docker is running on the VM + - Check if previous deployments are blocking + +5. Recovery options: + - Retry the release operation + - Run 'configure' again to reset state + - Check VM status and logs + +For persistent issues, check the deployment documentation." + } + + Self::ProgressReportingFailed { .. } => { + "Progress Reporting Failed - Critical Internal Error: + +This is a critical bug that indicates progress reporting to the user failed. +This should never happen in normal operation. + +1. Immediate actions: + - Save all relevant logs and error messages + - Note what operation was being performed + - Record the environment state + +2. Report the issue: + - This is a bug that needs to be reported + - Include full logs: --log-output file-and-stderr + - Provide steps to reproduce if possible + - Include system information (OS, versions) + +3. Workaround: + - Restart the application + - Try the operation again + - Check for resource exhaustion (memory, threads) + +This error indicates a serious bug in the application's progress reporting system. +Please report it to the development team with full details." + } + + Self::ApplicationLayerError { source } => source.help(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::application::command_handlers::release::ReleaseCommandHandlerError; + + #[test] + fn it_should_provide_help_for_invalid_environment_name() { + let error = ReleaseSubcommandError::InvalidEnvironmentName { + name: "invalid_name".to_string(), + source: EnvironmentNameError::InvalidFormat { + attempted_name: "invalid_name".to_string(), + reason: "contains underscore".to_string(), + valid_examples: vec!["dev".to_string(), "staging".to_string()], + }, + }; + + let help = error.help(); + assert!(help.contains("Invalid Environment Name")); + assert!(help.contains("Check environment name format")); + } + + #[test] + fn it_should_provide_help_for_environment_not_accessible() { + let error = ReleaseSubcommandError::EnvironmentNotAccessible { + name: "test-env".to_string(), + data_dir: "/tmp/data".to_string(), + }; + + let help = error.help(); + assert!(help.contains("Environment Not Accessible")); + assert!(help.contains("Check if environment exists")); + } + + #[test] + fn it_should_provide_help_for_invalid_environment_state() { + let error = ReleaseSubcommandError::InvalidEnvironmentState { + name: "test-env".to_string(), + current_state: "Provisioned".to_string(), + }; + + let help = error.help(); + assert!(help.contains("Invalid Environment State")); + assert!(help.contains("configure")); + } + + #[test] + fn it_should_provide_help_for_application_layer_error() { + let error = ReleaseSubcommandError::ApplicationLayerError { + source: ReleaseCommandHandlerError::EnvironmentNotFound { + name: "test-env".to_string(), + }, + }; + + let help = error.help(); + assert!(help.contains("Environment Not Found")); + assert!(help.contains("Troubleshooting")); + } + + #[test] + fn it_should_have_help_for_all_error_variants() { + let errors: Vec = vec![ + ReleaseSubcommandError::InvalidEnvironmentName { + name: "test".to_string(), + source: EnvironmentNameError::InvalidFormat { + attempted_name: "test".to_string(), + reason: "invalid".to_string(), + valid_examples: vec!["dev".to_string()], + }, + }, + ReleaseSubcommandError::EnvironmentNotAccessible { + name: "test".to_string(), + data_dir: "/tmp".to_string(), + }, + ReleaseSubcommandError::InvalidEnvironmentState { + name: "test".to_string(), + current_state: "Created".to_string(), + }, + ReleaseSubcommandError::ReleaseOperationFailed { + name: "test".to_string(), + reason: "connection failed".to_string(), + }, + ReleaseSubcommandError::ApplicationLayerError { + source: ReleaseCommandHandlerError::EnvironmentNotFound { + name: "test".to_string(), + }, + }, + ]; + + for error in errors { + let help = error.help(); + assert!(!help.is_empty(), "Help text should not be empty"); + assert!( + help.contains("Troubleshooting") || help.len() > 50, + "Help should contain actionable guidance" + ); + } + } + + #[test] + fn it_should_display_error_with_context() { + let error = ReleaseSubcommandError::InvalidEnvironmentName { + name: "invalid_env".to_string(), + source: EnvironmentNameError::InvalidFormat { + attempted_name: "invalid_env".to_string(), + reason: "contains underscore".to_string(), + valid_examples: vec!["dev".to_string()], + }, + }; + + let message = error.to_string(); + assert!(message.contains("invalid_env")); + assert!(message.contains("Invalid environment name")); + } +} diff --git a/src/presentation/controllers/release/handler.rs b/src/presentation/controllers/release/handler.rs new file mode 100644 index 00000000..f5dc6ae9 --- /dev/null +++ b/src/presentation/controllers/release/handler.rs @@ -0,0 +1,275 @@ +//! Release Command Handler +//! +//! This module handles the release command execution at the presentation layer, +//! including environment validation, state validation, and user interaction. + +use std::cell::RefCell; +use std::sync::Arc; + +use parking_lot::ReentrantMutex; +use tracing::info; + +use crate::application::command_handlers::release::ReleaseCommandHandler; +use crate::domain::environment::name::EnvironmentName; +use crate::domain::environment::repository::EnvironmentRepository; +use crate::presentation::views::progress::ProgressReporter; +use crate::presentation::views::UserOutput; +use crate::shared::clock::Clock; + +use super::errors::ReleaseSubcommandError; + +/// Steps in the release workflow +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ReleaseStep { + ValidateEnvironment, + ReleaseApplication, +} + +impl ReleaseStep { + /// All steps in execution order + const ALL: &'static [Self] = &[Self::ValidateEnvironment, Self::ReleaseApplication]; + + /// Total number of steps + const fn count() -> usize { + Self::ALL.len() + } + + /// User-facing description for the step + fn description(self) -> &'static str { + match self { + Self::ValidateEnvironment => "Validating environment", + Self::ReleaseApplication => "Releasing application", + } + } +} + +/// Presentation layer controller for release command workflow +/// +/// Coordinates user interaction, progress reporting, and input validation +/// before delegating to the application layer `ReleaseCommandHandler`. +/// +/// # Responsibilities +/// +/// - Validate user input (environment name format) +/// - Show progress updates to the user +/// - Format success/error messages for display +/// - Delegate business logic to application layer +/// +/// # Architecture +/// +/// This controller sits in the presentation layer and handles all user-facing +/// concerns. It delegates actual business logic to the application layer's +/// `ReleaseCommandHandler`. +pub struct ReleaseCommandController { + repository: Arc, + clock: Arc, + progress: ProgressReporter, +} + +impl ReleaseCommandController { + /// Create a new release command controller + /// + /// Creates a `ReleaseCommandController` with direct repository injection. + /// This follows the single container architecture pattern. + #[allow(clippy::needless_pass_by_value)] // Constructor takes ownership of Arc parameters + pub fn new( + repository: Arc, + clock: Arc, + user_output: Arc>>, + ) -> Self { + let progress = ProgressReporter::new(user_output, ReleaseStep::count()); + + Self { + repository, + clock, + progress, + } + } + + /// Execute the complete release workflow + /// + /// Orchestrates all steps of the release command: + /// 1. Validate environment name + /// 2. Execute release via application handler + /// 3. Complete with success message + /// + /// # Arguments + /// + /// * `environment_name` - The name of the environment to release to + /// + /// # Errors + /// + /// Returns an error if: + /// - Environment name is invalid (format validation fails) + /// - Environment is not in the Configured state + /// - Docker Compose file preparation fails + /// - State persistence fails + /// + /// # Returns + /// + /// Returns `Ok(())` on success, or a `ReleaseSubcommandError` if any step fails. + #[allow(clippy::result_large_err)] + pub async fn execute(&mut self, environment_name: &str) -> Result<(), ReleaseSubcommandError> { + let env_name = self.validate_environment_name(environment_name)?; + + self.release_application(&env_name).await?; + + self.complete_workflow(environment_name)?; + + Ok(()) + } + + /// Validate the environment name format + /// + /// Shows progress to user and validates that the environment name + /// meets domain requirements (1-63 chars, alphanumeric + hyphens). + #[allow(clippy::result_large_err)] + fn validate_environment_name( + &mut self, + name: &str, + ) -> Result { + self.progress + .start_step(ReleaseStep::ValidateEnvironment.description())?; + + let env_name = EnvironmentName::new(name.to_string()).map_err(|source| { + ReleaseSubcommandError::InvalidEnvironmentName { + name: name.to_string(), + source, + } + })?; + + self.progress + .complete_step(Some(&format!("Environment name validated: {name}")))?; + + Ok(env_name) + } + + /// Release application to the environment + /// + /// Calls the application layer handler to execute the release workflow. + #[allow(clippy::result_large_err)] + async fn release_application( + &mut self, + env_name: &EnvironmentName, + ) -> Result<(), ReleaseSubcommandError> { + self.progress + .start_step(ReleaseStep::ReleaseApplication.description())?; + + let handler = ReleaseCommandHandler::new(self.repository.clone(), self.clock.clone()); + + let _released_env = handler + .execute(env_name) + .await + .map_err(|source| ReleaseSubcommandError::ApplicationLayerError { source })?; + + info!( + environment = %env_name, + final_state = "Released", + "Application released successfully" + ); + + self.progress + .complete_step(Some("Application released successfully"))?; + + Ok(()) + } + + /// Complete the workflow with success message + /// + /// Shows final success message to the user with workflow summary. + #[allow(clippy::result_large_err)] + fn complete_workflow(&mut self, name: &str) -> Result<(), ReleaseSubcommandError> { + self.progress.complete(&format!( + "Release command completed successfully for '{name}'" + ))?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::infrastructure::persistence::repository_factory::RepositoryFactory; + use crate::presentation::controllers::constants::DEFAULT_LOCK_TIMEOUT; + use crate::presentation::views::testing::TestUserOutput; + use crate::presentation::views::VerbosityLevel; + use crate::shared::SystemClock; + use tempfile::TempDir; + + /// Create test dependencies for release command handler tests + #[allow(clippy::type_complexity)] + fn create_test_dependencies( + temp_dir: &TempDir, + ) -> ( + Arc>>, + Arc, + Arc, + ) { + let (user_output, _, _) = + TestUserOutput::new(VerbosityLevel::Normal).into_reentrant_wrapped(); + let data_dir = temp_dir.path().join("data"); + let repository_factory = RepositoryFactory::new(DEFAULT_LOCK_TIMEOUT); + let repository = repository_factory.create(data_dir); + let clock = Arc::new(SystemClock); + + (user_output, repository, clock) + } + + #[tokio::test] + async fn it_should_return_error_for_invalid_environment_name() { + let temp_dir = TempDir::new().unwrap(); + + let (user_output, repository, clock) = create_test_dependencies(&temp_dir); + + // Test with invalid environment name (contains underscore) + let result = ReleaseCommandController::new(repository, clock, user_output.clone()) + .execute("invalid_name") + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + ReleaseSubcommandError::InvalidEnvironmentName { name, .. } => { + assert_eq!(name, "invalid_name"); + } + other => panic!("Expected InvalidEnvironmentName, got: {other:?}"), + } + } + + #[tokio::test] + async fn it_should_return_error_for_empty_environment_name() { + let temp_dir = TempDir::new().unwrap(); + + let (user_output, repository, clock) = create_test_dependencies(&temp_dir); + + let result = ReleaseCommandController::new(repository, clock, user_output.clone()) + .execute("") + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + ReleaseSubcommandError::InvalidEnvironmentName { name, .. } => { + assert_eq!(name, ""); + } + other => panic!("Expected InvalidEnvironmentName, got: {other:?}"), + } + } + + #[tokio::test] + async fn it_should_return_error_for_nonexistent_environment() { + let temp_dir = TempDir::new().unwrap(); + + let (user_output, repository, clock) = create_test_dependencies(&temp_dir); + + // Valid environment name but environment doesn't exist + let result = ReleaseCommandController::new(repository, clock, user_output.clone()) + .execute("test-env") + .await; + + // Should fail because environment doesn't exist + assert!(result.is_err()); + match result.unwrap_err() { + ReleaseSubcommandError::ApplicationLayerError { .. } => (), + other => panic!("Expected ApplicationLayerError, got: {other:?}"), + } + } +} diff --git a/src/presentation/controllers/release/mod.rs b/src/presentation/controllers/release/mod.rs new file mode 100644 index 00000000..cd83df7f --- /dev/null +++ b/src/presentation/controllers/release/mod.rs @@ -0,0 +1,51 @@ +//! Release Command Presentation Module +//! +//! This module implements the CLI presentation layer for the release command, +//! handling argument processing and user interaction. +//! +//! ## Architecture +//! +//! The release command presentation layer follows the DDD pattern, orchestrating +//! the application layer's `ReleaseCommandHandler` while providing user-friendly +//! output and error handling. +//! +//! ## Components +//! +//! - `errors` - Presentation layer error types with `.help()` methods +//! - `handler` - Main command handler orchestrating the workflow +//! +//! ## Usage Example +//! +//! ### Basic Usage +//! +//! ```rust +//! use std::path::Path; +//! use std::sync::Arc; +//! use torrust_tracker_deployer_lib::bootstrap::Container; +//! use torrust_tracker_deployer_lib::presentation::dispatch::ExecutionContext; +//! use torrust_tracker_deployer_lib::presentation::controllers::release; +//! use torrust_tracker_deployer_lib::presentation::views::VerbosityLevel; +//! +//! # #[tokio::main] +//! # async fn main() { +//! let container = Container::new(VerbosityLevel::Normal, Path::new(".")); +//! let context = ExecutionContext::new(Arc::new(container)); +//! +//! // Call the release handler +//! let result = context +//! .container() +//! .create_release_controller() +//! .execute("my-environment") +//! .await; +//! # } +//! ``` + +pub mod errors; +pub mod handler; +pub use handler::ReleaseCommandController; + +#[cfg(test)] +mod tests; + +// Re-export commonly used types for convenience +pub use errors::ReleaseSubcommandError; diff --git a/src/presentation/controllers/release/tests.rs b/src/presentation/controllers/release/tests.rs new file mode 100644 index 00000000..01890bb3 --- /dev/null +++ b/src/presentation/controllers/release/tests.rs @@ -0,0 +1,131 @@ +//! Tests for the Release Command Controller +//! +//! This module contains integration tests for the release command controller, +//! testing error handling and workflow execution. + +use std::cell::RefCell; +use std::sync::Arc; + +use parking_lot::ReentrantMutex; +use tempfile::TempDir; + +use crate::domain::environment::repository::EnvironmentRepository; +use crate::infrastructure::persistence::repository_factory::RepositoryFactory; +use crate::presentation::controllers::constants::DEFAULT_LOCK_TIMEOUT; +use crate::presentation::controllers::release::handler::ReleaseCommandController; +use crate::presentation::views::testing::TestUserOutput; +use crate::presentation::views::{UserOutput, VerbosityLevel}; +use crate::shared::clock::Clock; +use crate::shared::SystemClock; + +/// Create test dependencies for release command handler tests +#[allow(clippy::type_complexity)] +fn create_test_dependencies( + temp_dir: &TempDir, +) -> ( + Arc>>, + Arc, + Arc, +) { + let (user_output, _, _) = TestUserOutput::new(VerbosityLevel::Normal).into_reentrant_wrapped(); + let data_dir = temp_dir.path().join("data"); + let repository_factory = RepositoryFactory::new(DEFAULT_LOCK_TIMEOUT); + let repository = repository_factory.create(data_dir); + let clock = Arc::new(SystemClock); + + (user_output, repository, clock) +} + +mod environment_name_validation { + use super::*; + use crate::presentation::controllers::release::errors::ReleaseSubcommandError; + + #[tokio::test] + async fn it_should_reject_names_with_underscores() { + let temp_dir = TempDir::new().unwrap(); + let (user_output, repository, clock) = create_test_dependencies(&temp_dir); + + let result = ReleaseCommandController::new(repository, clock, user_output) + .execute("invalid_name") + .await; + + assert!(matches!( + result, + Err(ReleaseSubcommandError::InvalidEnvironmentName { .. }) + )); + } + + #[tokio::test] + async fn it_should_reject_empty_names() { + let temp_dir = TempDir::new().unwrap(); + let (user_output, repository, clock) = create_test_dependencies(&temp_dir); + + let result = ReleaseCommandController::new(repository, clock, user_output) + .execute("") + .await; + + assert!(matches!( + result, + Err(ReleaseSubcommandError::InvalidEnvironmentName { .. }) + )); + } + + #[tokio::test] + async fn it_should_reject_names_starting_with_hyphen() { + let temp_dir = TempDir::new().unwrap(); + let (user_output, repository, clock) = create_test_dependencies(&temp_dir); + + let result = ReleaseCommandController::new(repository, clock, user_output) + .execute("-invalid") + .await; + + assert!(matches!( + result, + Err(ReleaseSubcommandError::InvalidEnvironmentName { .. }) + )); + } +} + +mod workflow_errors { + use super::*; + use crate::presentation::controllers::release::errors::ReleaseSubcommandError; + + #[tokio::test] + async fn it_should_return_application_layer_error_for_nonexistent_environment() { + let temp_dir = TempDir::new().unwrap(); + let (user_output, repository, clock) = create_test_dependencies(&temp_dir); + + // Valid name but environment doesn't exist + let result = ReleaseCommandController::new(repository, clock, user_output) + .execute("production") + .await; + + // Should fail with ApplicationLayerError because environment doesn't exist + assert!( + matches!( + result, + Err(ReleaseSubcommandError::ApplicationLayerError { .. }) + ), + "Should fail with ApplicationLayerError when environment doesn't exist" + ); + } + + #[tokio::test] + async fn it_should_return_application_layer_error_for_hyphenated_nonexistent_environment() { + let temp_dir = TempDir::new().unwrap(); + let (user_output, repository, clock) = create_test_dependencies(&temp_dir); + + let result = ReleaseCommandController::new(repository, clock, user_output) + .execute("my-test-env") + .await; + + // Should fail with ApplicationLayerError because environment doesn't exist + assert!( + matches!( + result, + Err(ReleaseSubcommandError::ApplicationLayerError { .. }) + ), + "Should fail with ApplicationLayerError when hyphenated environment doesn't exist" + ); + } +} diff --git a/src/presentation/controllers/run/errors.rs b/src/presentation/controllers/run/errors.rs new file mode 100644 index 00000000..f5309df2 --- /dev/null +++ b/src/presentation/controllers/run/errors.rs @@ -0,0 +1,424 @@ +//! Error types for the Run Subcommand +//! +//! This module defines error types that can occur during CLI run command execution. +//! All errors follow the project's error handling principles by providing clear, +//! contextual, and actionable error messages with `.help()` methods. + +use thiserror::Error; + +use crate::application::command_handlers::run::RunCommandHandlerError; +use crate::domain::environment::name::EnvironmentNameError; +use crate::presentation::views::progress::ProgressReporterError; + +/// Run command specific errors +/// +/// This enum contains all error variants specific to the run command, +/// including environment validation, state validation, and service start failures. +/// Each variant includes relevant context and actionable error messages. +#[derive(Debug, Error)] +pub enum RunSubcommandError { + // ===== Environment Validation Errors ===== + /// Environment name validation failed + /// + /// The provided environment name doesn't meet the validation requirements. + /// Use `.help()` for detailed troubleshooting steps. + #[error("Invalid environment name '{name}': {source} +Tip: Environment names must be 1-63 characters, start with letter/digit, contain only letters/digits/hyphens")] + InvalidEnvironmentName { + name: String, + #[source] + source: EnvironmentNameError, + }, + + /// Environment not found or inaccessible + /// + /// The environment couldn't be loaded from persistent storage. + /// Use `.help()` for detailed troubleshooting steps. + #[error( + "Environment '{name}' not found or inaccessible from data directory '{data_dir}' +Tip: Check if environment exists: ls -la {data_dir}/" + )] + EnvironmentNotAccessible { name: String, data_dir: String }, + + // ===== State Validation Errors ===== + /// Environment is not in the required state for run + /// + /// The run command requires the environment to be in the Released state. + /// Use `.help()` for detailed troubleshooting steps. + #[error( + "Environment '{name}' is not in the required state for run (current: {current_state}, required: Released) +Tip: Run 'release' command first to deploy the application" + )] + InvalidEnvironmentState { name: String, current_state: String }, + + // ===== Run Operation Errors ===== + /// Run operation failed + /// + /// The run process encountered an error during execution. + /// Use `.help()` for detailed troubleshooting steps. + #[error( + "Failed to run application stack in environment '{name}': {reason} +Tip: Check logs and try running with --log-output file-and-stderr for more details" + )] + RunOperationFailed { name: String, reason: String }, + + /// Service start failed + /// + /// One or more services failed to start properly. + /// Use `.help()` for detailed troubleshooting steps. + #[error( + "Failed to start services in environment '{name}': {reason} +Tip: SSH into the VM and check Docker container logs: docker compose logs" + )] + ServiceStartFailed { name: String, reason: String }, + + // ===== Internal Errors ===== + /// Progress reporting failed + /// + /// Failed to report progress to the user due to an internal error. + /// This indicates a critical internal error. + #[error( + "Failed to report progress: {source} +Tip: This is a critical bug - please report it with full logs using --log-output file-and-stderr" + )] + ProgressReportingFailed { + #[source] + source: ProgressReporterError, + }, +} + +// ============================================================================ +// ERROR CONVERSIONS +// ============================================================================ + +impl From for RunSubcommandError { + fn from(source: ProgressReporterError) -> Self { + Self::ProgressReportingFailed { source } + } +} + +impl From for RunSubcommandError { + fn from(error: RunCommandHandlerError) -> Self { + match error { + RunCommandHandlerError::EnvironmentNotFound { name } => { + Self::EnvironmentNotAccessible { + name, + data_dir: "data".to_string(), + } + } + RunCommandHandlerError::InvalidState(state_err) => Self::InvalidEnvironmentState { + name: "environment".to_string(), + current_state: state_err.to_string(), + }, + RunCommandHandlerError::MissingInstanceIp { name } => Self::RunOperationFailed { + name, + reason: "Instance IP not available - environment may not be fully provisioned" + .to_string(), + }, + RunCommandHandlerError::StartServicesFailed { message, .. } => { + Self::ServiceStartFailed { + name: "environment".to_string(), + reason: message, + } + } + RunCommandHandlerError::StatePersistence(err) => Self::RunOperationFailed { + name: "environment".to_string(), + reason: format!("Failed to persist state: {err}"), + }, + RunCommandHandlerError::RunOperationFailed { name, message } => { + Self::RunOperationFailed { + name, + reason: message, + } + } + } + } +} + +impl RunSubcommandError { + /// Get detailed troubleshooting guidance for this error + /// + /// This method provides comprehensive troubleshooting steps that can be + /// displayed to users when they need more help resolving the error. + #[must_use] + #[allow(clippy::too_many_lines)] // Help text is comprehensive for user guidance + pub fn help(&self) -> &'static str { + match self { + Self::InvalidEnvironmentName { .. } => { + "Invalid Environment Name - Detailed Troubleshooting: + +1. Check environment name format: + - Length: Must be 1-63 characters + - Start: Must begin with letter (a-z, A-Z) or digit (0-9) + - Characters: Only letters, digits, and hyphens allowed + - End: Must not end with a hyphen + +2. Common valid examples: + - 'production' + - 'test-env' + - 'e2e-provision' + - 'dev123' + +3. Common invalid examples: + - 'test_env' (underscores not allowed) + - '-test' (starts with hyphen) + - 'test-' (ends with hyphen) + - '' (empty string) + +For more information, see the environment naming conventions in the documentation." + } + + Self::EnvironmentNotAccessible { .. } => { + "Environment Not Accessible - Detailed Troubleshooting: + +1. Check if environment exists: + - List environments: ls -la data/ + - Look for directory with your environment name + +2. Verify file permissions: + - Check directory permissions: ls -ld data/ + - Ensure read/write access: chmod 755 data/ + +3. Check if environment was created: + - Look for environment.json file: ls -la data/{env_name}/ + - Verify it's a valid deployment environment + +4. Common causes: + - Environment was never created (run create first) + - Wrong data directory path + - Permission issues + - Corrupted environment state + +If the environment should exist, check the logs for more details." + } + + Self::InvalidEnvironmentState { .. } => { + "Invalid Environment State - Detailed Troubleshooting: + +1. Check current environment state: + - The run command requires the environment to be 'Released' + - Run the workflow in order: create → provision → configure → release → run + +2. Required workflow: + - First: torrust-tracker-deployer create environment -f env.json + - Then: torrust-tracker-deployer provision + - Then: torrust-tracker-deployer configure + - Then: torrust-tracker-deployer release + - Finally: torrust-tracker-deployer run + +3. Check environment state: + - Look at data//environment.json + - Verify the 'state' field shows 'Released' + +4. Common issues: + - Skipped the 'release' step + - Previous release command failed + - Environment was reset or recreated + +Run 'release' command first, then retry 'run'." + } + + Self::RunOperationFailed { .. } => { + "Run Operation Failed - Detailed Troubleshooting: + +1. Check system resources: + - Ensure sufficient disk space on target VM + - Check network connectivity to the VM + - Verify SSH access is working + +2. Review the operation logs: + - Run with verbose logging: --log-output file-and-stderr + - Check log files in data/logs/ + - Look for specific error details + +3. Check Docker status on VM: + - SSH into the VM + - Run: docker compose ps + - Check container health: docker ps -a + +4. Common issues: + - Docker daemon not running + - Port conflicts with existing services + - Missing Docker images + - Resource constraints (memory, CPU) + +5. Recovery options: + - Retry the run operation + - Run 'release' again to reset deployment + - Manually stop conflicting services on VM + +For persistent issues, check the deployment documentation." + } + + Self::ServiceStartFailed { .. } => { + "Service Start Failed - Detailed Troubleshooting: + +1. Check service logs: + - SSH into the VM + - Run: docker compose logs -f + - Look for startup errors + +2. Verify service configuration: + - Check docker-compose.yml syntax + - Verify environment variables are set + - Ensure required secrets are available + +3. Check resource availability: + - Sufficient memory for all services + - Available ports (not already in use) + - Required volumes and mounts exist + +4. Common service issues: + - Database connection failures + - Missing configuration files + - Network connectivity between containers + - Permission issues on mounted volumes + +5. Manual debugging: + - SSH into the VM + - Run: docker compose up (without -d) to see logs + - Check individual container logs + +For persistent issues, check individual service documentation." + } + + Self::ProgressReportingFailed { .. } => { + "Progress Reporting Failed - Critical Internal Error: + +This is a critical bug that indicates progress reporting to the user failed. +This should never happen in normal operation. + +1. Immediate actions: + - Save all relevant logs and error messages + - Note what operation was being performed + - Record the environment state + +2. Report the issue: + - This is a bug that needs to be reported + - Include full logs: --log-output file-and-stderr + - Provide steps to reproduce if possible + - Include system information (OS, versions) + +3. Workaround: + - Restart the application + - Try the operation again + - Check for resource exhaustion (memory, threads) + +This error indicates a serious bug in the application's progress reporting system. +Please report it to the development team with full details." + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_provide_help_for_invalid_environment_name() { + let error = RunSubcommandError::InvalidEnvironmentName { + name: "invalid_name".to_string(), + source: EnvironmentNameError::InvalidFormat { + attempted_name: "invalid_name".to_string(), + reason: "contains underscore".to_string(), + valid_examples: vec!["dev".to_string(), "staging".to_string()], + }, + }; + + let help = error.help(); + assert!(help.contains("Invalid Environment Name")); + assert!(help.contains("Check environment name format")); + } + + #[test] + fn it_should_provide_help_for_environment_not_accessible() { + let error = RunSubcommandError::EnvironmentNotAccessible { + name: "test-env".to_string(), + data_dir: "/tmp/data".to_string(), + }; + + let help = error.help(); + assert!(help.contains("Environment Not Accessible")); + assert!(help.contains("Check if environment exists")); + } + + #[test] + fn it_should_provide_help_for_invalid_environment_state() { + let error = RunSubcommandError::InvalidEnvironmentState { + name: "test-env".to_string(), + current_state: "Configured".to_string(), + }; + + let help = error.help(); + assert!(help.contains("Invalid Environment State")); + assert!(help.contains("release")); + } + + #[test] + fn it_should_provide_help_for_service_start_failed() { + let error = RunSubcommandError::ServiceStartFailed { + name: "test-env".to_string(), + reason: "container exited".to_string(), + }; + + let help = error.help(); + assert!(help.contains("Service Start Failed")); + assert!(help.contains("docker compose logs")); + } + + #[test] + fn it_should_have_help_for_all_error_variants() { + let errors: Vec = vec![ + RunSubcommandError::InvalidEnvironmentName { + name: "test".to_string(), + source: EnvironmentNameError::InvalidFormat { + attempted_name: "test".to_string(), + reason: "invalid".to_string(), + valid_examples: vec!["dev".to_string()], + }, + }, + RunSubcommandError::EnvironmentNotAccessible { + name: "test".to_string(), + data_dir: "/tmp".to_string(), + }, + RunSubcommandError::InvalidEnvironmentState { + name: "test".to_string(), + current_state: "Created".to_string(), + }, + RunSubcommandError::RunOperationFailed { + name: "test".to_string(), + reason: "connection failed".to_string(), + }, + RunSubcommandError::ServiceStartFailed { + name: "test".to_string(), + reason: "timeout".to_string(), + }, + ]; + + for error in errors { + let help = error.help(); + assert!(!help.is_empty(), "Help text should not be empty"); + assert!( + help.contains("Troubleshooting") || help.len() > 50, + "Help should contain actionable guidance" + ); + } + } + + #[test] + fn it_should_display_error_with_context() { + let error = RunSubcommandError::InvalidEnvironmentName { + name: "invalid_env".to_string(), + source: EnvironmentNameError::InvalidFormat { + attempted_name: "invalid_env".to_string(), + reason: "contains underscore".to_string(), + valid_examples: vec!["dev".to_string()], + }, + }; + + let message = error.to_string(); + assert!(message.contains("invalid_env")); + assert!(message.contains("Invalid environment name")); + } +} diff --git a/src/presentation/controllers/run/handler.rs b/src/presentation/controllers/run/handler.rs new file mode 100644 index 00000000..e968dd01 --- /dev/null +++ b/src/presentation/controllers/run/handler.rs @@ -0,0 +1,271 @@ +//! Run Command Handler +//! +//! This module handles the run command execution at the presentation layer, +//! including environment validation, state validation, and user interaction. + +use std::cell::RefCell; +use std::sync::Arc; + +use parking_lot::ReentrantMutex; + +use crate::application::command_handlers::run::RunCommandHandler; +use crate::domain::environment::name::EnvironmentName; +use crate::domain::environment::repository::EnvironmentRepository; +use crate::presentation::views::progress::ProgressReporter; +use crate::presentation::views::UserOutput; +use crate::shared::clock::Clock; + +use super::errors::RunSubcommandError; + +/// Steps in the run workflow +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RunStep { + ValidateEnvironment, + RunServices, +} + +impl RunStep { + /// All steps in execution order + const ALL: &'static [Self] = &[Self::ValidateEnvironment, Self::RunServices]; + + /// Total number of steps + const fn count() -> usize { + Self::ALL.len() + } + + /// User-facing description for the step + fn description(self) -> &'static str { + match self { + Self::ValidateEnvironment => "Validating environment", + Self::RunServices => "Running application services", + } + } +} + +/// Presentation layer controller for run command workflow +/// +/// Coordinates user interaction, progress reporting, and input validation +/// before delegating to the application layer `RunCommandHandler`. +/// +/// # Responsibilities +/// +/// - Validate user input (environment name format) +/// - Validate environment state (must be Released) +/// - Show progress updates to the user +/// - Format success/error messages for display +/// - Delegate business logic to application layer +/// +/// # Architecture +/// +/// This controller sits in the presentation layer and handles all user-facing +/// concerns. It delegates actual business logic to the application layer's +/// `RunCommandHandler`. +pub struct RunCommandController { + repository: Arc, + clock: Arc, + progress: ProgressReporter, +} + +impl RunCommandController { + /// Create a new run command controller + /// + /// Creates a `RunCommandController` with direct repository injection. + /// This follows the single container architecture pattern. + #[allow(clippy::needless_pass_by_value)] // Constructor takes ownership of Arc parameters + pub fn new( + repository: Arc, + clock: Arc, + user_output: Arc>>, + ) -> Self { + let progress = ProgressReporter::new(user_output, RunStep::count()); + + Self { + repository, + clock, + progress, + } + } + + /// Execute the complete run workflow + /// + /// Orchestrates all steps of the run command: + /// 1. Validate environment name + /// 2. Run application services via `RunCommandHandler` + /// 3. Complete with success message + /// + /// # Arguments + /// + /// * `environment_name` - The name of the environment to run services in + /// + /// # Errors + /// + /// Returns an error if: + /// - Environment name is invalid (format validation fails) + /// - Environment is not in the Released state + /// - Service start fails + /// + /// # Returns + /// + /// Returns `Ok(())` on success, or a `RunSubcommandError` if any step fails. + #[allow(clippy::result_large_err)] + #[allow(clippy::unused_async)] // Part of uniform async presentation layer interface + pub async fn execute(&mut self, environment_name: &str) -> Result<(), RunSubcommandError> { + let env_name = self.validate_environment_name(environment_name)?; + + self.run_services(&env_name)?; + + self.complete_workflow(environment_name)?; + + Ok(()) + } + + /// Validate the environment name format + /// + /// Shows progress to user and validates that the environment name + /// meets domain requirements (1-63 chars, alphanumeric + hyphens). + #[allow(clippy::result_large_err)] + fn validate_environment_name( + &mut self, + name: &str, + ) -> Result { + self.progress + .start_step(RunStep::ValidateEnvironment.description())?; + + let env_name = EnvironmentName::new(name.to_string()).map_err(|source| { + RunSubcommandError::InvalidEnvironmentName { + name: name.to_string(), + source, + } + })?; + + self.progress + .complete_step(Some(&format!("Environment name validated: {name}")))?; + + Ok(env_name) + } + + /// Run services via the application layer handler + /// + /// Delegates to `RunCommandHandler` to execute the run workflow: + /// 1. Load environment from repository + /// 2. Validate environment is in Released state + /// 3. Start Docker Compose services via Ansible + /// 4. Update environment state to Running + #[allow(clippy::result_large_err)] + fn run_services(&mut self, env_name: &EnvironmentName) -> Result<(), RunSubcommandError> { + self.progress + .start_step(RunStep::RunServices.description())?; + + // Cast the repository to the base trait type that RunCommandHandler expects + let repository: Arc = + Arc::clone(&self.repository) + as Arc; + + let handler = RunCommandHandler::new(repository, Arc::clone(&self.clock)); + + handler.execute(env_name)?; + + self.progress.complete_step(Some("Services started"))?; + + Ok(()) + } + + /// Complete the workflow with success message + /// + /// Shows final success message to the user with workflow summary. + #[allow(clippy::result_large_err)] + fn complete_workflow(&mut self, name: &str) -> Result<(), RunSubcommandError> { + self.progress + .complete(&format!("Run command completed for '{name}'"))?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::infrastructure::persistence::repository_factory::RepositoryFactory; + use crate::presentation::controllers::constants::DEFAULT_LOCK_TIMEOUT; + use crate::presentation::views::testing::TestUserOutput; + use crate::presentation::views::VerbosityLevel; + use crate::shared::SystemClock; + use tempfile::TempDir; + + /// Create test dependencies for run command handler tests + #[allow(clippy::type_complexity)] + fn create_test_dependencies( + temp_dir: &TempDir, + ) -> ( + Arc>>, + Arc, + Arc, + ) { + let (user_output, _, _) = + TestUserOutput::new(VerbosityLevel::Normal).into_reentrant_wrapped(); + let data_dir = temp_dir.path().join("data"); + let repository_factory = RepositoryFactory::new(DEFAULT_LOCK_TIMEOUT); + let repository = repository_factory.create(data_dir); + let clock = Arc::new(SystemClock); + + (user_output, repository, clock) + } + + #[tokio::test] + async fn it_should_return_error_for_invalid_environment_name() { + let temp_dir = TempDir::new().unwrap(); + + let (user_output, repository, clock) = create_test_dependencies(&temp_dir); + + // Test with invalid environment name (contains underscore) + let result = RunCommandController::new(repository, clock, user_output.clone()) + .execute("invalid_name") + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + RunSubcommandError::InvalidEnvironmentName { name, .. } => { + assert_eq!(name, "invalid_name"); + } + other => panic!("Expected InvalidEnvironmentName, got: {other:?}"), + } + } + + #[tokio::test] + async fn it_should_return_error_for_empty_environment_name() { + let temp_dir = TempDir::new().unwrap(); + + let (user_output, repository, clock) = create_test_dependencies(&temp_dir); + + let result = RunCommandController::new(repository, clock, user_output.clone()) + .execute("") + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + RunSubcommandError::InvalidEnvironmentName { name, .. } => { + assert_eq!(name, ""); + } + other => panic!("Expected InvalidEnvironmentName, got: {other:?}"), + } + } + + #[tokio::test] + async fn it_should_return_error_when_environment_not_found() { + let temp_dir = TempDir::new().unwrap(); + + let (user_output, repository, clock) = create_test_dependencies(&temp_dir); + + // Valid environment name but doesn't exist + let result = RunCommandController::new(repository, clock, user_output.clone()) + .execute("test-env") + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + RunSubcommandError::EnvironmentNotAccessible { name, .. } => { + assert_eq!(name, "test-env"); + } + other => panic!("Expected EnvironmentNotAccessible, got: {other:?}"), + } + } +} diff --git a/src/presentation/controllers/run/mod.rs b/src/presentation/controllers/run/mod.rs new file mode 100644 index 00000000..813f991d --- /dev/null +++ b/src/presentation/controllers/run/mod.rs @@ -0,0 +1,51 @@ +//! Run Command Presentation Module +//! +//! This module implements the CLI presentation layer for the run command, +//! handling argument processing and user interaction. +//! +//! ## Architecture +//! +//! The run command presentation layer follows the DDD pattern, orchestrating +//! the application layer's `RunCommandHandler` while providing user-friendly +//! output and error handling. +//! +//! ## Components +//! +//! - `errors` - Presentation layer error types with `.help()` methods +//! - `handler` - Main command handler orchestrating the workflow +//! +//! ## Usage Example +//! +//! ### Basic Usage +//! +//! ```rust +//! use std::path::Path; +//! use std::sync::Arc; +//! use torrust_tracker_deployer_lib::bootstrap::Container; +//! use torrust_tracker_deployer_lib::presentation::dispatch::ExecutionContext; +//! use torrust_tracker_deployer_lib::presentation::controllers::run; +//! use torrust_tracker_deployer_lib::presentation::views::VerbosityLevel; +//! +//! # #[tokio::main] +//! # async fn main() { +//! let container = Container::new(VerbosityLevel::Normal, Path::new(".")); +//! let context = ExecutionContext::new(Arc::new(container)); +//! +//! // Call the run handler +//! let result = context +//! .container() +//! .create_run_controller() +//! .execute("my-environment") +//! .await; +//! # } +//! ``` + +pub mod errors; +pub mod handler; +pub use handler::RunCommandController; + +#[cfg(test)] +mod tests; + +// Re-export commonly used types for convenience +pub use errors::RunSubcommandError; diff --git a/src/presentation/controllers/run/tests.rs b/src/presentation/controllers/run/tests.rs new file mode 100644 index 00000000..2777e92b --- /dev/null +++ b/src/presentation/controllers/run/tests.rs @@ -0,0 +1,129 @@ +//! Tests for the Run Command Controller +//! +//! This module contains integration tests for the run command controller, +//! testing error handling and workflow execution. + +use std::cell::RefCell; +use std::sync::Arc; + +use parking_lot::ReentrantMutex; +use tempfile::TempDir; + +use crate::domain::environment::repository::EnvironmentRepository; +use crate::infrastructure::persistence::repository_factory::RepositoryFactory; +use crate::presentation::controllers::constants::DEFAULT_LOCK_TIMEOUT; +use crate::presentation::controllers::run::handler::RunCommandController; +use crate::presentation::views::testing::TestUserOutput; +use crate::presentation::views::{UserOutput, VerbosityLevel}; +use crate::shared::clock::Clock; +use crate::shared::SystemClock; + +/// Create test dependencies for run command handler tests +#[allow(clippy::type_complexity)] +fn create_test_dependencies( + temp_dir: &TempDir, +) -> ( + Arc>>, + Arc, + Arc, +) { + let (user_output, _, _) = TestUserOutput::new(VerbosityLevel::Normal).into_reentrant_wrapped(); + let data_dir = temp_dir.path().join("data"); + let repository_factory = RepositoryFactory::new(DEFAULT_LOCK_TIMEOUT); + let repository = repository_factory.create(data_dir); + let clock = Arc::new(SystemClock); + + (user_output, repository, clock) +} + +mod environment_name_validation { + use super::*; + use crate::presentation::controllers::run::errors::RunSubcommandError; + + #[tokio::test] + async fn it_should_reject_names_with_underscores() { + let temp_dir = TempDir::new().unwrap(); + let (user_output, repository, clock) = create_test_dependencies(&temp_dir); + + let result = RunCommandController::new(repository, clock, user_output) + .execute("invalid_name") + .await; + + assert!(matches!( + result, + Err(RunSubcommandError::InvalidEnvironmentName { .. }) + )); + } + + #[tokio::test] + async fn it_should_reject_empty_names() { + let temp_dir = TempDir::new().unwrap(); + let (user_output, repository, clock) = create_test_dependencies(&temp_dir); + + let result = RunCommandController::new(repository, clock, user_output) + .execute("") + .await; + + assert!(matches!( + result, + Err(RunSubcommandError::InvalidEnvironmentName { .. }) + )); + } + + #[tokio::test] + async fn it_should_reject_names_starting_with_hyphen() { + let temp_dir = TempDir::new().unwrap(); + let (user_output, repository, clock) = create_test_dependencies(&temp_dir); + + let result = RunCommandController::new(repository, clock, user_output) + .execute("-invalid") + .await; + + assert!(matches!( + result, + Err(RunSubcommandError::InvalidEnvironmentName { .. }) + )); + } +} + +mod real_workflow { + use super::*; + use crate::presentation::controllers::run::errors::RunSubcommandError; + + #[tokio::test] + async fn it_should_return_not_accessible_when_environment_does_not_exist() { + let temp_dir = TempDir::new().unwrap(); + let (user_output, repository, clock) = create_test_dependencies(&temp_dir); + + // Valid environment name but environment doesn't exist + let result = RunCommandController::new(repository, clock, user_output) + .execute("production") + .await; + + assert!( + matches!( + result, + Err(RunSubcommandError::EnvironmentNotAccessible { .. }) + ), + "Should fail with EnvironmentNotAccessible for non-existent environment" + ); + } + + #[tokio::test] + async fn it_should_return_not_accessible_for_hyphenated_names_when_env_does_not_exist() { + let temp_dir = TempDir::new().unwrap(); + let (user_output, repository, clock) = create_test_dependencies(&temp_dir); + + let result = RunCommandController::new(repository, clock, user_output) + .execute("my-test-env") + .await; + + assert!( + matches!( + result, + Err(RunSubcommandError::EnvironmentNotAccessible { .. }) + ), + "Should fail with EnvironmentNotAccessible for non-existent environment" + ); + } +} diff --git a/src/presentation/dispatch/router.rs b/src/presentation/dispatch/router.rs index 5e708a7b..98fa9f21 100644 --- a/src/presentation/dispatch/router.rs +++ b/src/presentation/dispatch/router.rs @@ -154,11 +154,22 @@ pub async fn route_command( .execute(&environment, &instance_ip) .await?; Ok(()) - } // Future commands will be added here as the Controller Layer expands: - // - // Commands::Release { environment, version } => { - // release::handle_release_command(&environment, &version, context).await?; - // Ok(()) - // } + } + Commands::Release { environment } => { + context + .container() + .create_release_controller() + .execute(&environment) + .await?; + Ok(()) + } + Commands::Run { environment } => { + context + .container() + .create_run_controller() + .execute(&environment) + .await?; + Ok(()) + } } } diff --git a/src/presentation/errors.rs b/src/presentation/errors.rs index 384f4d6f..70ff4754 100644 --- a/src/presentation/errors.rs +++ b/src/presentation/errors.rs @@ -22,7 +22,8 @@ use thiserror::Error; use crate::presentation::controllers::{ configure::ConfigureSubcommandError, create::CreateCommandError, destroy::DestroySubcommandError, provision::ProvisionSubcommandError, - register::errors::RegisterSubcommandError, test::TestSubcommandError, + register::errors::RegisterSubcommandError, release::ReleaseSubcommandError, + run::RunSubcommandError, test::TestSubcommandError, }; /// Errors that can occur during CLI command execution @@ -74,6 +75,20 @@ pub enum CommandError { #[error("Register command failed: {0}")] Register(Box), + /// Release command specific errors + /// + /// Encapsulates all errors that can occur during software release operations. + /// Use `.help()` for detailed troubleshooting steps. + #[error("Release command failed: {0}")] + Release(Box), + + /// Run command specific errors + /// + /// Encapsulates all errors that can occur during stack execution. + /// Use `.help()` for detailed troubleshooting steps. + #[error("Run command failed: {0}")] + Run(Box), + /// User output lock acquisition failed /// /// Failed to acquire the mutex lock for user output. This typically indicates @@ -118,6 +133,18 @@ impl From for CommandError { } } +impl From for CommandError { + fn from(error: ReleaseSubcommandError) -> Self { + Self::Release(Box::new(error)) + } +} + +impl From for CommandError { + fn from(error: RunSubcommandError) -> Self { + Self::Run(Box::new(error)) + } +} + impl CommandError { /// Get detailed troubleshooting guidance for this error /// @@ -160,6 +187,8 @@ impl CommandError { Self::Configure(e) => e.help(), Self::Register(e) => e.help(), Self::Test(e) => e.as_ref().help(), + Self::Release(e) => e.help(), + Self::Run(e) => e.help(), Self::UserOutputLockFailed => { "User Output Lock Failed - Detailed Troubleshooting: diff --git a/src/presentation/input/cli/commands.rs b/src/presentation/input/cli/commands.rs index ee4b7ab3..ee7608fb 100644 --- a/src/presentation/input/cli/commands.rs +++ b/src/presentation/input/cli/commands.rs @@ -121,15 +121,55 @@ pub enum Commands { #[arg(long, value_name = "IP_ADDRESS")] instance_ip: String, }, - // Future commands will be added here: - // - // /// Create a new release of the deployed application - // Release { - // /// Name of the environment for the release - // environment: String, - // /// Version tag for the release - // version: String, - // }, + + /// Release application files to a configured environment + /// + /// This command prepares and transfers application files (docker-compose.yml, + /// configuration files, etc.) to a configured VM. The environment must be + /// in the "Configured" state. + /// + /// After successful release: + /// - Docker compose files are copied to /opt/torrust/ on the VM + /// - Environment transitions to "Released" state + /// - You can then run `run ` to start the services + /// + /// # Examples + /// + /// ```text + /// torrust-tracker-deployer release my-env + /// torrust-tracker-deployer release production + /// ``` + Release { + /// Name of the environment to release to + /// + /// The environment name must match an existing environment that was + /// previously configured and is in "Configured" state. + environment: String, + }, + + /// Run the application stack on a released environment + /// + /// This command starts the docker compose services on a released VM. + /// The environment must be in the "Released" state. + /// + /// After successful run: + /// - Docker containers are started via 'docker compose up -d' + /// - Environment transitions to "Running" state + /// - Services are accessible on the VM + /// + /// # Examples + /// + /// ```text + /// torrust-tracker-deployer run my-env + /// torrust-tracker-deployer run production + /// ``` + Run { + /// Name of the environment to run + /// + /// The environment name must match an existing environment that was + /// previously released and is in "Released" state. + environment: String, + }, } /// Actions available for the create command diff --git a/src/presentation/input/cli/mod.rs b/src/presentation/input/cli/mod.rs index 517022d2..168906fc 100644 --- a/src/presentation/input/cli/mod.rs +++ b/src/presentation/input/cli/mod.rs @@ -51,7 +51,9 @@ mod tests { | Commands::Provision { .. } | Commands::Configure { .. } | Commands::Test { .. } - | Commands::Register { .. } => { + | Commands::Register { .. } + | Commands::Release { .. } + | Commands::Run { .. } => { panic!("Expected Destroy command") } } @@ -73,7 +75,9 @@ mod tests { | Commands::Provision { .. } | Commands::Configure { .. } | Commands::Test { .. } - | Commands::Register { .. } => { + | Commands::Register { .. } + | Commands::Release { .. } + | Commands::Run { .. } => { panic!("Expected Destroy command") } } @@ -120,7 +124,9 @@ mod tests { | Commands::Provision { .. } | Commands::Configure { .. } | Commands::Test { .. } - | Commands::Register { .. } => { + | Commands::Register { .. } + | Commands::Release { .. } + | Commands::Run { .. } => { panic!("Expected Destroy command") } } @@ -210,7 +216,9 @@ mod tests { | Commands::Provision { .. } | Commands::Configure { .. } | Commands::Test { .. } - | Commands::Register { .. } => { + | Commands::Register { .. } + | Commands::Release { .. } + | Commands::Run { .. } => { panic!("Expected Create command") } } @@ -240,7 +248,9 @@ mod tests { | Commands::Provision { .. } | Commands::Configure { .. } | Commands::Test { .. } - | Commands::Register { .. } => { + | Commands::Register { .. } + | Commands::Release { .. } + | Commands::Run { .. } => { panic!("Expected Create command") } } @@ -291,7 +301,9 @@ mod tests { | Commands::Provision { .. } | Commands::Configure { .. } | Commands::Test { .. } - | Commands::Register { .. } => { + | Commands::Register { .. } + | Commands::Release { .. } + | Commands::Run { .. } => { panic!("Expected Create command") } } @@ -371,7 +383,9 @@ mod tests { | Commands::Provision { .. } | Commands::Configure { .. } | Commands::Test { .. } - | Commands::Register { .. } => { + | Commands::Register { .. } + | Commands::Release { .. } + | Commands::Run { .. } => { panic!("Expected Create command") } } @@ -405,7 +419,9 @@ mod tests { | Commands::Provision { .. } | Commands::Configure { .. } | Commands::Test { .. } - | Commands::Register { .. } => { + | Commands::Register { .. } + | Commands::Release { .. } + | Commands::Run { .. } => { panic!("Expected Create command") } } @@ -584,7 +600,9 @@ mod tests { | Commands::Destroy { .. } | Commands::Provision { .. } | Commands::Configure { .. } - | Commands::Test { .. } => { + | Commands::Test { .. } + | Commands::Release { .. } + | Commands::Run { .. } => { panic!("Expected Register command") } } diff --git a/src/testing/e2e/containers/provisioned.rs b/src/testing/e2e/containers/provisioned.rs index 39091fa0..198f9f85 100644 --- a/src/testing/e2e/containers/provisioned.rs +++ b/src/testing/e2e/containers/provisioned.rs @@ -190,13 +190,14 @@ impl StoppedProvisionedContainer { // First build the Docker image if needed Self::build_image(self.timeouts.docker_build)?; - info!(ssh_port = %ssh_port, "Starting provisioned instance container"); + info!(ssh_port = %ssh_port, "Starting provisioned instance container with Docker-in-Docker support"); // Create and start the container using the configuration builder + // Wait for both SSH and Docker daemon to be ready let image = ContainerConfigBuilder::new(format!("{DEFAULT_IMAGE_NAME}:{DEFAULT_IMAGE_TAG}")) .with_exposed_port(ssh_port) - .with_wait_condition(WaitFor::message_on_stdout("sshd entered RUNNING state")) + .with_wait_condition(WaitFor::message_on_stdout("dockerd entered RUNNING state")) .build() .map_err(|source| { Box::new(ContainerError::ContainerRuntime { @@ -209,12 +210,17 @@ impl StoppedProvisionedContainer { }) })?; - // Start the container with optional container name + // Start the container with privileged mode for Docker-in-Docker support + // and optional container name let container = if let Some(name) = container_name { - info!(container_name = %name, "Starting container with custom name"); - image.with_container_name(name).start().await + info!(container_name = %name, "Starting container with custom name and privileged mode"); + image + .with_privileged(true) + .with_container_name(name) + .start() + .await } else { - image.start().await + image.with_privileged(true).start().await } .map_err(|source| { Box::new(ContainerError::ContainerRuntime { diff --git a/src/testing/e2e/process_runner.rs b/src/testing/e2e/process_runner.rs index 34504c33..9d061ebe 100644 --- a/src/testing/e2e/process_runner.rs +++ b/src/testing/e2e/process_runner.rs @@ -276,6 +276,76 @@ impl ProcessRunner { Ok(ProcessResult::new(output)) } + + /// Run the release command with the production binary + /// + /// This method runs `cargo run -- release ` with + /// optional working directory for the application itself via `--working-dir`. + /// + /// # Errors + /// + /// Returns an error if the command fails to execute. + /// + /// # Panics + /// + /// Panics if the working directory path contains invalid UTF-8. + pub fn run_release_command(&self, environment_name: &str) -> Result { + let mut cmd = Command::new("cargo"); + + if let Some(working_dir) = &self.working_dir { + // Build command with working directory + cmd.args([ + "run", + "--", + "release", + environment_name, + "--working-dir", + working_dir.to_str().unwrap(), + ]); + } else { + // No working directory, use relative paths + cmd.args(["run", "--", "release", environment_name]); + } + + let output = cmd.output().context("Failed to execute release command")?; + + Ok(ProcessResult::new(output)) + } + + /// Run the run command with the production binary + /// + /// This method runs `cargo run -- run ` with + /// optional working directory for the application itself via `--working-dir`. + /// + /// # Errors + /// + /// Returns an error if the command fails to execute. + /// + /// # Panics + /// + /// Panics if the working directory path contains invalid UTF-8. + pub fn run_run_command(&self, environment_name: &str) -> Result { + let mut cmd = Command::new("cargo"); + + if let Some(working_dir) = &self.working_dir { + // Build command with working directory + cmd.args([ + "run", + "--", + "run", + environment_name, + "--working-dir", + working_dir.to_str().unwrap(), + ]); + } else { + // No working directory, use relative paths + cmd.args(["run", "--", "run", environment_name]); + } + + let output = cmd.output().context("Failed to execute run command")?; + + Ok(ProcessResult::new(output)) + } } impl Default for ProcessRunner { diff --git a/src/testing/e2e/tasks/black_box/test_runner.rs b/src/testing/e2e/tasks/black_box/test_runner.rs index 4c97726d..e639ba37 100644 --- a/src/testing/e2e/tasks/black_box/test_runner.rs +++ b/src/testing/e2e/tasks/black_box/test_runner.rs @@ -279,6 +279,116 @@ impl E2eTestRunner { Ok(()) } + /// Releases software to the provisioned and configured infrastructure. + /// + /// # Errors + /// + /// Returns an error if the release command fails. + /// If `cleanup_on_failure` is enabled, attempts to destroy infrastructure before returning. + pub fn release_software(&self) -> Result<()> { + info!( + step = "release", + environment = %self.environment_name, + "Releasing software" + ); + + let release_result = self + .runner + .run_release_command(&self.environment_name) + .map_err(|e| anyhow::anyhow!("Failed to execute release command: {e}"))?; + + if !release_result.success() { + error!( + step = "release", + environment = %self.environment_name, + exit_code = ?release_result.exit_code(), + stderr = %release_result.stderr(), + "Release command failed" + ); + + self.attempt_cleanup_on_failure(); + + return Err(anyhow::anyhow!( + "Release failed with exit code {:?}", + release_result.exit_code() + )); + } + + info!( + step = "release", + environment = %self.environment_name, + status = "success", + "Software released successfully" + ); + + Ok(()) + } + + /// Runs services on the released infrastructure. + /// + /// # Skip Condition + /// + /// When `TORRUST_TD_SKIP_RUN_IN_CONTAINER` environment variable is set to `"true"`, + /// this step is skipped because Docker daemon is not available in the test container + /// (no Docker-in-Docker configuration). + /// + /// # Errors + /// + /// Returns an error if the run command fails. + /// If `cleanup_on_failure` is enabled, attempts to destroy infrastructure before returning. + pub fn run_services(&self) -> Result<()> { + // Check if run should be skipped (Docker not available in test container) + let skip_run = std::env::var("TORRUST_TD_SKIP_RUN_IN_CONTAINER") + .map(|v| v.to_lowercase() == "true") + .unwrap_or(false); + + if skip_run { + info!( + step = "run", + environment = %self.environment_name, + "Skipping run command due to TORRUST_TD_SKIP_RUN_IN_CONTAINER (Docker not available in test container)" + ); + return Ok(()); + } + + info!( + step = "run", + environment = %self.environment_name, + "Running services" + ); + + let run_result = self + .runner + .run_run_command(&self.environment_name) + .map_err(|e| anyhow::anyhow!("Failed to execute run command: {e}"))?; + + if !run_result.success() { + error!( + step = "run", + environment = %self.environment_name, + exit_code = ?run_result.exit_code(), + stderr = %run_result.stderr(), + "Run command failed" + ); + + self.attempt_cleanup_on_failure(); + + return Err(anyhow::anyhow!( + "Run failed with exit code {:?}", + run_result.exit_code() + )); + } + + info!( + step = "run", + environment = %self.environment_name, + status = "success", + "Services started successfully" + ); + + Ok(()) + } + /// Validates the deployment by running the test command. /// /// # Errors diff --git a/src/testing/e2e/tasks/mod.rs b/src/testing/e2e/tasks/mod.rs index 099efe5e..5c8ff7c3 100644 --- a/src/testing/e2e/tasks/mod.rs +++ b/src/testing/e2e/tasks/mod.rs @@ -33,5 +33,7 @@ pub mod preflight_cleanup; pub mod run_configuration_validation; pub mod run_configure_command; pub mod run_create_command; +pub mod run_release_validation; +pub mod run_run_validation; pub mod run_test_command; pub mod virtual_machine; diff --git a/src/testing/e2e/tasks/run_configuration_validation.rs b/src/testing/e2e/tasks/run_configuration_validation.rs index aa9dffb1..837b8dd4 100644 --- a/src/testing/e2e/tasks/run_configuration_validation.rs +++ b/src/testing/e2e/tasks/run_configuration_validation.rs @@ -1,8 +1,8 @@ //! Configuration validation task for E2E testing //! -//! This module provides the E2E testing task for validating that configurations -//! are working correctly after deployment. It performs comprehensive checks -//! to ensure all required services and components are properly installed and running. +//! This module provides the E2E testing task for validating that the `configure` +//! command executed correctly. It performs comprehensive checks to ensure all +//! required services and components are properly installed. //! //! ## Key Operations //! @@ -13,11 +13,9 @@ //! //! ## Integration //! -//! This is a generic task that can be used with any infrastructure type: -//! - Container-based testing environments -//! - VM-based testing environments -//! - Physical server deployments -//! - Cloud instance deployments +//! This validation runs after the `configure` command to verify that Docker +//! and Docker Compose were installed correctly. For validating running services +//! after the `run` command, use `run_run_validation` instead. use std::net::SocketAddr; use thiserror::Error; diff --git a/src/testing/e2e/tasks/run_release_validation.rs b/src/testing/e2e/tasks/run_release_validation.rs new file mode 100644 index 00000000..4cba4bc2 --- /dev/null +++ b/src/testing/e2e/tasks/run_release_validation.rs @@ -0,0 +1,214 @@ +//! Release validation task for E2E testing +//! +//! This module provides the E2E testing task for validating that the `release` +//! command executed correctly. It verifies that Docker Compose files were +//! properly deployed to the target instance. +//! +//! ## Key Operations +//! +//! - Validates Docker Compose files are present in the deployment directory +//! - Verifies file permissions and ownership +//! - Checks that the deployment directory structure is correct +//! +//! ## Integration +//! +//! This validation runs after the `release` command and before the `run` command +//! to ensure files are in place before starting services. + +use std::net::SocketAddr; +use thiserror::Error; +use tracing::info; + +use crate::adapters::ssh::SshConfig; +use crate::adapters::ssh::SshCredentials; +use crate::infrastructure::remote_actions::{RemoteAction, RemoteActionError}; + +/// Default deployment directory for Docker Compose files +const DEFAULT_DEPLOY_DIR: &str = "/opt/torrust"; + +/// Errors that can occur during release validation +#[derive(Debug, Error)] +pub enum ReleaseValidationError { + /// Compose files validation failed + #[error( + "Docker Compose files validation failed: {source} +Tip: Ensure the release command completed successfully and files were deployed" + )] + ComposeFilesValidationFailed { + #[source] + source: RemoteActionError, + }, +} + +impl ReleaseValidationError { + /// Get detailed troubleshooting guidance for this error + /// + /// # Example + /// + /// ```rust + /// # use torrust_tracker_deployer_lib::testing::e2e::tasks::run_release_validation::ReleaseValidationError; + /// # use torrust_tracker_deployer_lib::infrastructure::remote_actions::RemoteActionError; + /// let error = ReleaseValidationError::ComposeFilesValidationFailed { + /// source: RemoteActionError::ValidationFailed { + /// action_name: "compose_files_validation".to_string(), + /// message: "docker-compose.yml not found".to_string(), + /// }, + /// }; + /// println!("{}", error.help()); + /// ``` + #[must_use] + pub fn help(&self) -> &'static str { + match self { + Self::ComposeFilesValidationFailed { .. } => { + "Docker Compose Files Validation Failed - Detailed Troubleshooting: + +1. Check if release command completed: + - SSH to instance: ssh user@instance-ip + - Check deployment directory: ls -la /opt/torrust/ + - Verify docker-compose.yml exists + +2. Verify file deployment: + - Check Ansible deployment logs for errors + - Verify the release command ran without errors + - Ensure source template files exist in templates/docker-compose/ + +3. Common issues: + - Deployment directory not created: mkdir -p /opt/torrust + - Insufficient permissions to write files + - Ansible playbook failed silently + - Template rendering errors + +4. Re-deploy if needed: + - Re-run release command: cargo run -- release + - Or manually copy files to /opt/torrust/ + +For more information, see docs/e2e-testing.md." + } + } + } +} + +/// Validates Docker Compose files are deployed +struct ComposeFilesValidator { + ssh_client: crate::adapters::ssh::SshClient, + deploy_dir: std::path::PathBuf, +} + +impl ComposeFilesValidator { + /// Create a new `ComposeFilesValidator` with the specified SSH configuration + #[must_use] + fn new(ssh_config: SshConfig) -> Self { + let ssh_client = crate::adapters::ssh::SshClient::new(ssh_config); + Self { + ssh_client, + deploy_dir: std::path::PathBuf::from(DEFAULT_DEPLOY_DIR), + } + } +} + +impl RemoteAction for ComposeFilesValidator { + fn name(&self) -> &'static str { + "compose-files-validation" + } + + async fn execute(&self, server_ip: &std::net::IpAddr) -> Result<(), RemoteActionError> { + info!( + action = "compose_files_validation", + deploy_dir = %self.deploy_dir.display(), + server_ip = %server_ip, + "Validating Docker Compose files are deployed" + ); + + // Check if docker-compose.yml exists + let deploy_dir = self.deploy_dir.display(); + let command = format!("test -f {deploy_dir}/docker-compose.yml && echo 'exists'"); + + let output = self.ssh_client.execute(&command).map_err(|source| { + RemoteActionError::SshCommandFailed { + action_name: self.name().to_string(), + source, + } + })?; + + if !output.trim().contains("exists") { + return Err(RemoteActionError::ValidationFailed { + action_name: self.name().to_string(), + message: format!( + "docker-compose.yml not found in {deploy_dir}. \ + Ensure the release command completed successfully." + ), + }); + } + + info!( + action = "compose_files_validation", + status = "success", + "Docker Compose files are deployed correctly" + ); + + Ok(()) + } +} + +/// Run release validation tests on a configured instance +/// +/// This function validates that the `release` command executed correctly +/// by checking that Docker Compose files are present in the deployment directory. +/// +/// # Arguments +/// +/// * `socket_addr` - Socket address where the target instance can be reached +/// * `ssh_credentials` - SSH credentials for connecting to the instance +/// +/// # Returns +/// +/// Returns `Ok(())` when all validation tests pass successfully. +/// +/// # Errors +/// +/// Returns an error if: +/// - SSH connection cannot be established +/// - Docker Compose files are not found +/// - File validation fails +pub async fn run_release_validation( + socket_addr: SocketAddr, + ssh_credentials: &SshCredentials, +) -> Result<(), ReleaseValidationError> { + info!( + socket_addr = %socket_addr, + ssh_user = %ssh_credentials.ssh_username, + "Running release validation tests" + ); + + let ip_addr = socket_addr.ip(); + + // Validate Docker Compose files are deployed + validate_compose_files(ip_addr, ssh_credentials, socket_addr.port()).await?; + + info!( + socket_addr = %socket_addr, + status = "success", + "All release validation tests passed successfully" + ); + + Ok(()) +} + +/// Validate Docker Compose files are deployed +async fn validate_compose_files( + ip_addr: std::net::IpAddr, + ssh_credentials: &SshCredentials, + port: u16, +) -> Result<(), ReleaseValidationError> { + info!("Validating Docker Compose files deployment"); + + let ssh_config = SshConfig::new(ssh_credentials.clone(), SocketAddr::new(ip_addr, port)); + + let validator = ComposeFilesValidator::new(ssh_config); + validator + .execute(&ip_addr) + .await + .map_err(|source| ReleaseValidationError::ComposeFilesValidationFailed { source })?; + + Ok(()) +} diff --git a/src/testing/e2e/tasks/run_run_validation.rs b/src/testing/e2e/tasks/run_run_validation.rs new file mode 100644 index 00000000..ad4b22d4 --- /dev/null +++ b/src/testing/e2e/tasks/run_run_validation.rs @@ -0,0 +1,184 @@ +//! Run command validation task for E2E testing +//! +//! This module provides the E2E testing task for validating that the `run` +//! command executed correctly. It verifies that Docker Compose services are +//! running and healthy after deployment. +//! +//! ## Current Scope (Demo Slice) +//! +//! This validation is designed for the demo slice using a temporary nginx service. +//! All checks are performed from **inside** the VM via SSH commands. +//! +//! ## Future Enhancements (Real Torrust Services) +//! +//! When deploying real Torrust services (HTTP Tracker, UDP Tracker, Index), the +//! validation strategy should be extended: +//! +//! 1. **External Accessibility Testing**: +//! - Test HTTP Tracker endpoint from outside the VM (e.g., port 7070) +//! - Test UDP Tracker announce from outside the VM (e.g., port 6969) +//! - Test Index API endpoints from outside the VM +//! +//! 2. **Firewall Validation**: +//! - External tests implicitly validate firewall rules are correct +//! - If service runs inside but isn't accessible outside → firewall issue +//! - This catches UFW/iptables misconfigurations +//! +//! 3. **Dual Validation Strategy**: +//! - Internal (via SSH): Service is running inside the VM +//! - External (from test runner): Service is accessible through network + firewall +//! +//! See `RunningServicesValidator` in `infrastructure/remote_actions/running_services.rs` +//! for more details on the implementation approach. +//! +//! ## Key Operations +//! +//! - Validates services are running via `docker compose ps` +//! - Checks service health status if configured +//! - Tests HTTP accessibility for web services (optional) +//! - Provides comprehensive error reporting with troubleshooting steps +//! +//! ## Integration +//! +//! This validation runs after the `run` command to ensure services are +//! operational before considering the deployment successful. + +use std::net::SocketAddr; +use thiserror::Error; +use tracing::info; + +use crate::adapters::ssh::SshConfig; +use crate::adapters::ssh::SshCredentials; +use crate::infrastructure::remote_actions::{ + RemoteAction, RemoteActionError, RunningServicesValidator, +}; + +/// Errors that can occur during run validation +#[derive(Debug, Error)] +pub enum RunValidationError { + /// Running services validation failed + #[error( + "Running services validation failed: {source} +Tip: Ensure Docker Compose services are started and healthy" + )] + RunningServicesValidationFailed { + #[source] + source: RemoteActionError, + }, +} + +impl RunValidationError { + /// Get detailed troubleshooting guidance for this error + /// + /// # Example + /// + /// ```rust + /// # use torrust_tracker_deployer_lib::testing::e2e::tasks::run_run_validation::RunValidationError; + /// # use torrust_tracker_deployer_lib::infrastructure::remote_actions::RemoteActionError; + /// let error = RunValidationError::RunningServicesValidationFailed { + /// source: RemoteActionError::ValidationFailed { + /// action_name: "running_services_validation".to_string(), + /// message: "No running services detected".to_string(), + /// }, + /// }; + /// println!("{}", error.help()); + /// ``` + #[must_use] + pub fn help(&self) -> &'static str { + match self { + Self::RunningServicesValidationFailed { .. } => { + "Running Services Validation Failed - Detailed Troubleshooting: + +1. Check running services: + - SSH to instance: ssh user@instance-ip + - Check services status: cd /opt/torrust && docker compose ps + - View service logs: docker compose logs + +2. Common issues: + - Services exited immediately after starting + - Health checks failing (check health check configuration) + - Port conflicts with other services + - Container image pull failures (network issues) + - Insufficient memory or disk space + +3. Debug steps: + - Check container logs: docker compose logs demo-app + - Restart services: docker compose restart + - View detailed status: docker compose ps -a + +4. Re-run if needed: + - Re-run the 'run' command: cargo run -- run + - Or manually: cd /opt/torrust && docker compose up -d + +For more information, see docs/e2e-testing.md." + } + } + } +} + +/// Run validation tests for the `run` command on a configured instance +/// +/// This function validates that the `run` command executed correctly by +/// checking that Docker Compose services are running and healthy. +/// +/// # Arguments +/// +/// * `socket_addr` - Socket address where the target instance can be reached +/// * `ssh_credentials` - SSH credentials for connecting to the instance +/// +/// # Returns +/// +/// Returns `Ok(())` when all validation tests pass successfully. +/// +/// # Errors +/// +/// Returns an error if: +/// - SSH connection cannot be established +/// - Services are not running +/// - Services are unhealthy +pub async fn run_run_validation( + socket_addr: SocketAddr, + ssh_credentials: &SshCredentials, +) -> Result<(), RunValidationError> { + info!( + socket_addr = %socket_addr, + ssh_user = %ssh_credentials.ssh_username, + "Running 'run' command validation tests" + ); + + let ip_addr = socket_addr.ip(); + + // Validate running services + validate_running_services(ip_addr, ssh_credentials, socket_addr.port()).await?; + + info!( + socket_addr = %socket_addr, + status = "success", + "All 'run' command validation tests passed successfully" + ); + + Ok(()) +} + +/// Validate running services on a configured instance +/// +/// This function validates that Docker Compose services are running and healthy +/// on the target instance. It checks the status of services started by the `run` +/// command and verifies they are operational. +async fn validate_running_services( + ip_addr: std::net::IpAddr, + ssh_credentials: &SshCredentials, + port: u16, +) -> Result<(), RunValidationError> { + info!("Validating running services"); + + let ssh_config = SshConfig::new(ssh_credentials.clone(), SocketAddr::new(ip_addr, port)); + + let services_validator = RunningServicesValidator::new(ssh_config); + services_validator + .execute(&ip_addr) + .await + .map_err(|source| RunValidationError::RunningServicesValidationFailed { source })?; + + Ok(()) +} diff --git a/templates/ansible/deploy-compose-files.yml b/templates/ansible/deploy-compose-files.yml new file mode 100644 index 00000000..a7a38ac8 --- /dev/null +++ b/templates/ansible/deploy-compose-files.yml @@ -0,0 +1,61 @@ +--- +# Deploy Docker Compose Files Playbook +# Copies the local docker-compose build folder to the remote host + +- name: Deploy Docker Compose Files + hosts: all + gather_facts: false + become: true + + vars: + remote_deploy_dir: /opt/torrust + local_compose_dir: "{{ compose_files_source_dir }}" + + tasks: + - name: 📦 Starting Docker Compose files deployment + ansible.builtin.debug: + msg: "🚀 Deploying Docker Compose files to {{ inventory_hostname }}:{{ remote_deploy_dir }}" + + - name: Ensure remote deployment directory exists + ansible.builtin.file: + path: "{{ remote_deploy_dir }}" + state: directory + mode: "0755" + owner: root + group: root + + - name: Copy Docker Compose files to remote host + ansible.builtin.copy: + src: "{{ local_compose_dir }}/" + dest: "{{ remote_deploy_dir }}/" + mode: "0644" + directory_mode: "0755" + owner: root + group: root + + - name: Verify docker-compose.yml exists on remote + ansible.builtin.stat: + path: "{{ remote_deploy_dir }}/docker-compose.yml" + register: compose_file_check + + - name: Fail if docker-compose.yml was not deployed + ansible.builtin.fail: + msg: "docker-compose.yml was not found at {{ remote_deploy_dir }}/docker-compose.yml after deployment" + when: not compose_file_check.stat.exists + + - name: List deployed files + ansible.builtin.find: + paths: "{{ remote_deploy_dir }}" + file_type: file + recurse: true + register: deployed_files + + - name: Display deployment summary + ansible.builtin.debug: + msg: | + ✅ Docker Compose files deployed successfully! + 📁 Destination: {{ remote_deploy_dir }} + 📄 Files deployed: {{ deployed_files.files | length }} + {% for file in deployed_files.files %} + - {{ file.path | regex_replace(remote_deploy_dir ~ '/', '') }} + {% endfor %} diff --git a/templates/ansible/run-compose-services.yml b/templates/ansible/run-compose-services.yml new file mode 100644 index 00000000..bf0be117 --- /dev/null +++ b/templates/ansible/run-compose-services.yml @@ -0,0 +1,76 @@ +--- +# Run Docker Compose Services Playbook +# Starts the Docker Compose application stack on the remote host + +- name: Run Docker Compose Services + hosts: all + gather_facts: false + become: true + + vars: + deploy_dir: /opt/torrust + + tasks: + - name: 🚀 Starting Docker Compose services + ansible.builtin.debug: + msg: "Starting Docker Compose services in {{ deploy_dir }}" + + - name: Verify docker-compose.yml exists + ansible.builtin.stat: + path: "{{ deploy_dir }}/docker-compose.yml" + register: compose_file_check + + - name: Fail if docker-compose.yml not found + ansible.builtin.fail: + msg: | + docker-compose.yml not found at {{ deploy_dir }}/docker-compose.yml + + Please run the 'release' command first to deploy Docker Compose files: + cargo run -- release + when: not compose_file_check.stat.exists + + - name: Pull Docker images + ansible.builtin.command: + cmd: docker compose pull + chdir: "{{ deploy_dir }}" + register: pull_result + changed_when: "'Pulled' in pull_result.stdout or 'Downloaded' in pull_result.stdout" + failed_when: pull_result.rc != 0 + + - name: Start Docker Compose services + ansible.builtin.command: + cmd: docker compose up -d + chdir: "{{ deploy_dir }}" + register: compose_up_result + changed_when: "'Started' in compose_up_result.stderr or 'Creating' in compose_up_result.stderr" + failed_when: compose_up_result.rc != 0 + + - name: Wait for services to be healthy + ansible.builtin.command: + cmd: docker compose ps --format json + chdir: "{{ deploy_dir }}" + register: compose_status + retries: 30 + delay: 2 + until: > + (compose_status.stdout | from_json | selectattr('Health', 'defined') | list | length == 0) or + (compose_status.stdout | from_json | selectattr('Health', 'defined') | selectattr('Health', 'equalto', 'healthy') | list | length == + compose_status.stdout | from_json | selectattr('Health', 'defined') | list | length) + changed_when: false + ignore_errors: true + + - name: Get running containers status + ansible.builtin.command: + cmd: docker compose ps + chdir: "{{ deploy_dir }}" + register: final_status + changed_when: false + + - name: Display service status + ansible.builtin.debug: + msg: | + ✅ Docker Compose services started! + 📁 Working directory: {{ deploy_dir }} + + Container status: + {{ final_status.stdout }} diff --git a/templates/docker-compose/docker-compose.yml b/templates/docker-compose/docker-compose.yml new file mode 100644 index 00000000..79030454 --- /dev/null +++ b/templates/docker-compose/docker-compose.yml @@ -0,0 +1,25 @@ +# Docker Compose configuration for Torrust Tracker deployment +# +# This is a demo/MVP configuration using nginx as a simple web service +# to validate the deployment workflow. In production, this will be replaced +# with actual Torrust Tracker services. +# +# Usage: +# docker compose up -d +# docker compose ps +# docker compose logs +# docker compose down + +services: + demo-app: + image: nginx:alpine + container_name: torrust-demo-app + ports: + - "8080:80" + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s