📋 Detailed Plan
Breaking down Phase 5 into manageable, testable subtasks for integrating type-safe state management into commands.
Status: ✅ COMPLETED - All 4 subtasks complete (October 6, 2025)
Goal: Update commands to use type-safe state transitions, persist state during execution, and enable orchestration with compile-time state guarantees.
Why We Need This: Commands currently operate on stateless Environment objects. By integrating the type-state pattern and persistence layer, we enable:
- State Visibility: Track command progress and detect interrupted operations
- Error Recovery Guidance: Know exactly which step failed and provide actionable advice
- Orchestration Safety: Chain commands with compile-time guarantees of valid state flows
- Observability: Automatic state persistence creates audit trail of every transition
Final Test Count: 704 tests passing ✅
- ✅
ProvisionCommandacceptsEnvironment<Created>, returnsEnvironment<Provisioned> - ✅
ConfigureCommandacceptsEnvironment<Provisioned>, returnsEnvironment<Configured> - ✅ Commands transition through intermediate states (
Provisioning,Configuring) - ✅ Commands transition to error states on failure with step context
- ✅ State persisted after each transition via repository
- ✅ Persistence failures handled gracefully (don't break commands)
- ✅ Compile-time prevention of invalid state transitions
- ✅ Type-safe state transitions demonstrated in E2E tests
- ✅ State transitions visible in logs during E2E tests and persistent audit trail
Approach: Incrementally update commands to accept and return typed states, integrate persistence at command boundaries, and build an orchestration layer that leverages compile-time state validation.
Status: ✅ Completed (Commit: ff80d7d)
Purpose: Clean up and restructure commands to make Phase 5 implementation easier and safer.
Why This First: Before integrating state management (which requires significant changes to command signatures), we should:
- Extract logical phases from monolithic
execute()methods - Remove redundant dependencies that will be replaced by
Environment<S> - Add helper methods for error context extraction
- Create clear structure with obvious injection points
See: Refactoring Plan
Tasks:
- Refactor ProvisionCommand Structure - Extract 5 logical phases from execute() method
- Remove Redundant SshCredentials Field - Pass as parameter instead of storing in struct
- Add Error Context Extraction Helpers - Prepare for error state tracking
- Add Persistence Injection Point Markers - Document where state transitions will go
- Setup Repository Infrastructure in Container - Instantiate repository, prepare for injection (non-breaking since commands keep old signatures for now)
Benefits:
- Smaller, focused commits before major changes
- Reduced complexity of Phase 5 implementation
- Clear separation between refactoring and feature work
- Lower risk of bugs during state integration
Tests:
- All existing tests continue to pass (no behavior changes)
- 2 new tests for error context extraction helpers
- 5 new integration tests for repository infrastructure:
- Repository Instantiation: Verify repository is correctly instantiated with 30-second timeout
- State File Paths: Verify state files follow
build/{env_name}/state.jsonconvention - File Locking: Verify locks prevent concurrent modifications to same environment
- Concurrent Execution: Verify different environments can be modified concurrently
- Lock Cleanup: Verify orphaned locks (dead process) are automatically cleaned up
Estimated Impact:
- ProvisionCommand: ~180 LOC → ~220 LOC (+40 for extracted methods)
- ConfigureCommand: ~100 LOC → ~110 LOC (+10 for helpers)
- Test count: 663 → 670 tests (+2 unit + 5 integration)
Commit Messages:
refactor: extract logical phases in ProvisionCommand for better clarity
refactor: pass SshCredentials as parameter instead of storing in ProvisionCommand
refactor: add error context extraction helpers for future state tracking
docs: add Phase 5 injection point markers in command handlers
refactor: setup repository infrastructure in Container
test: add integration tests for repository infrastructure and file locking
Container Setup Details (Task 5):
Add repository to container WITHOUT changing command signatures yet:
pub struct Container {
// ... existing fields ...
environment_repository: Arc<dyn EnvironmentRepository>,
}
impl Container {
pub fn new() -> Result<Self, ContainerError> {
// ... existing setup ...
// Create repository with 30-second lock timeout
let repository = Arc::new(JsonFileRepository::new(
Duration::from_secs(30),
)) as Arc<dyn EnvironmentRepository>;
Ok(Self {
// ... existing fields ...
environment_repository: repository,
})
}
// Keep existing factory methods unchanged for now
// They will be updated in Subtasks 3-4 when command signatures change
}Success Criteria:
- ✅ All refactoring tasks completed
- ✅ Container instantiates repository (not yet injected into commands)
- ✅ Repository integration tests pass (5 tests covering locking, paths, cleanup)
- ✅ All linters pass (no new warnings)
- ✅ All tests pass (no behavior changes)
- ✅ Code complexity reduced (main execute() methods < 30 lines)
- ✅ Clear structure for Phase 5 integration
Status: ✅ Completed (Commit: 7f02019)
Purpose: Document the architectural decision to use typed state returns in commands instead of the pure command handler pattern.
Changes:
Create an Architectural Decision Record (ADR) at docs/decisions/command-state-return-pattern.md documenting:
-
Context: Why this decision is needed
- Commands need to integrate with type-state pattern
- Two main approaches: pure command handler vs typed returns
- Need to decide which pattern to use
-
Decision: Use typed state returns (commands accept and return
Environment<S>) -
Rationale: Why typed returns are better for this use case
- Leverages compile-time type safety from type-state pattern (4 phases of work)
- Avoids repeated loading/pattern matching on
AnyEnvironmentState - Enables fluent command chaining in the future
- Commands are state transformations, not just side effects
- Repository is for persistence, not primary data flow
- Type safety benefits outweigh CQS purism
-
Alternatives Considered:
- Pure Command Handler: Commands load/save via repository, no returns
- Pros: Follows traditional Command/Query Separation (CQS), well-known CQRS pattern
- Cons: Pattern matching overhead, loses compile-time safety, repository-centric
- Hybrid Approach: Store environment in command struct with interior mutability
- Cons: Borrowing complexity, unclear ownership
- Pure Command Handler: Commands load/save via repository, no returns
-
Consequences:
- Positive:
- Compile-time prevention of invalid state transitions
- Clear data flow: input → transform → output
- No repeated parsing of
AnyEnvironmentState - Future orchestration layer possible
- Type-state pattern reaches its full potential
- Negative:
- Deviates from pure command handler pattern
- Commands return values (not traditional CQS)
- Neutral:
- Repository is secondary concern (persistence only)
- Positive:
-
References:
- Type-State Pattern: https://cliffle.com/blog/rust-typestate/
- CQRS flexibility: Commands can return acknowledgments
- Phase 1-4 implementation of type-state pattern
Success Criteria:
- ✅ ADR document created at
docs/decisions/command-state-return-pattern.md - ✅ ADR follows project template structure
- ✅ Decision clearly explained with concrete examples
- ✅ Alternatives documented with fair assessment
- ✅ Consequences (positive, negative, neutral) listed
- ✅ ADR added to decisions index in
docs/decisions/README.md
Commit: docs: add ADR for command state return pattern decision
Estimated Test Count: 663 tests (no new tests, documentation only)
Purpose: Modify ProvisionCommand to accept Environment<Created> and return Environment<Provisioned>, with intermediate state transitions and persistence. Immediately update all code that uses this command (Container factory, E2E binaries) since this is a breaking change.
Changes:
-
Update Command Signature:
- Change
execute()to acceptEnvironment<Created>instead of raw parameters - Return
Environment<Provisioned>instead of justIpAddr - Store
Environmentas a field in the command struct
- Change
-
Integrate State Transitions:
- Transition to
Provisioningstate at command start - Persist state after each major step (OpenTofu init, apply, etc.)
- Transition to
Provisionedstate on success - Transition to
ProvisionFailedstate on error with failed step name
- Transition to
-
Add Persistence Integration:
- Inject
Arc<dyn EnvironmentRepository>dependency - Save state after transitioning to
Provisioning - Save state after transitioning to
ProvisionedorProvisionFailed - Handle persistence errors gracefully (log but don't fail command)
- Inject
-
Preserve Backward Compatibility:
- Keep existing E2E tests passing
- Maintain current logging and error handling behavior
- Ensure IP address is still returned (via
Environment<Provisioned>getter)
Implementation Details:
use crate::domain::environment::{Environment, Created, Provisioning, Provisioned, ProvisionFailed};
use crate::domain::environment::repository::EnvironmentRepository;
pub struct ProvisionCommand {
tofu_template_renderer: Arc<TofuTemplateRenderer>,
ansible_template_renderer: Arc<AnsibleTemplateRenderer>,
ansible_client: Arc<AnsibleClient>,
opentofu_client: Arc<OpenTofuClient>,
repository: Arc<dyn EnvironmentRepository>,
}
impl ProvisionCommand {
pub fn new(
tofu_template_renderer: Arc<TofuTemplateRenderer>,
ansible_template_renderer: Arc<AnsibleTemplateRenderer>,
ansible_client: Arc<AnsibleClient>,
opentofu_client: Arc<OpenTofuClient>,
repository: Arc<dyn EnvironmentRepository>,
) -> Self {
Self {
tofu_template_renderer,
ansible_template_renderer,
ansible_client,
opentofu_client,
repository,
}
}
#[instrument(
name = "provision_command",
skip_all,
fields(
command_type = "provision",
environment = %environment.name()
)
)]
pub async fn execute(
&self,
environment: Environment<Created>,
) -> Result<Environment<Provisioned>, ProvisionCommandError> {
info!(
command = "provision",
environment = %environment.name(),
"Starting infrastructure provisioning workflow"
);
// Transition to Provisioning state
let environment = environment.start_provisioning();
// Persist intermediate state
if let Err(e) = self.persist_state(&environment) {
warn!(
"Failed to persist provisioning state: {}. Continuing with command execution.",
e
);
}
// Execute provisioning steps...
match self.execute_provisioning_steps(&environment).await {
Ok(provisioned_env) => {
// Persist final state
if let Err(e) = self.persist_state(&provisioned_env) {
warn!(
"Failed to persist provisioned state: {}. Instance is provisioned but state not saved.",
e
);
}
Ok(provisioned_env)
}
Err(e) => {
// Transition to error state with step context
let failed_env = environment.fail_provisioning(
self.extract_failed_step(&e)
);
// Persist error state
if let Err(persist_err) = self.persist_state(&failed_env) {
warn!(
"Failed to persist provision failure state: {}",
persist_err
);
}
Err(e)
}
}
}
async fn execute_provisioning_steps(
&self,
environment: &Environment<Provisioning>,
) -> Result<Environment<Provisioned>, ProvisionCommandError> {
// Render OpenTofu templates
RenderOpenTofuTemplatesStep::new(Arc::clone(&self.tofu_template_renderer))
.execute()
.await?;
// Create instance via OpenTofu
self.create_instance()?;
// Get instance info
let instance_info = GetInstanceInfoStep::new(Arc::clone(&self.opentofu_client))
.execute()?;
let instance_ip = instance_info.ip_address;
// Render Ansible templates with runtime IP
RenderAnsibleTemplatesStep::new(
Arc::clone(&self.ansible_template_renderer),
instance_ip,
)
.execute()?;
// Wait for connectivity
WaitForSSHConnectivityStep::new(
instance_ip,
&environment.ssh_credentials(),
)
.execute()
.await?;
WaitForCloudInitStep::new(
Arc::clone(&self.ansible_client),
)
.execute()?;
info!(
command = "provision",
environment = %environment.name(),
ip = %instance_ip,
"Infrastructure provisioning completed successfully"
);
// Transition to Provisioned state
Ok(environment.clone().complete_provisioning(instance_ip))
}
fn persist_state<S: Serialize>(
&self,
environment: &Environment<S>,
) -> Result<(), RepositoryError> {
let any_state = environment.clone().into_any();
self.repository.save(&any_state)
}
fn extract_failed_step(&self, error: &ProvisionCommandError) -> String {
// Extract step information from error for context
match error {
ProvisionCommandError::OpenTofuTemplateRendering(_) => {
"render_opentofu_templates".to_string()
}
ProvisionCommandError::OpenTofu(e) => {
format!("opentofu_{}", self.extract_opentofu_step(e))
}
ProvisionCommandError::AnsibleTemplateRendering(_) => {
"render_ansible_templates".to_string()
}
ProvisionCommandError::SshConnectivity(_) => {
"wait_ssh_connectivity".to_string()
}
ProvisionCommandError::Command(_) => {
"cloud_init_wait".to_string()
}
}
}
fn extract_opentofu_step(&self, _error: &OpenTofuError) -> String {
// Could be more sophisticated based on error variant
"operation".to_string()
}
}Tests to Add:
- Test command accepts
Environment<Created>and returnsEnvironment<Provisioned> - Test state transition to
Provisioningat command start - Test state transition to
Provisionedon success - Test state transition to
ProvisionFailedon error with step name - Test persistence is called after each state transition
- Test command continues execution if persistence fails (with warning log)
- Test IP address is accessible from returned
Environment<Provisioned> - Mock repository to verify state persistence calls
Integration Updates (Must be done together with command changes):
-
Update Container factory method:
pub fn provision_command(&self) -> ProvisionCommand { ProvisionCommand::new( Arc::clone(&self.tofu_template_renderer), Arc::clone(&self.ansible_template_renderer), Arc::clone(&self.ansible_client), Arc::clone(&self.opentofu_client), Arc::clone(&self.environment_repository), // Inject repository ) }
-
Update E2E Provision and Destroy Test binary (
src/bin/e2e-provision-and-destroy-tests.rs):async fn run_provision_test() -> Result<(), Box<dyn std::error::Error>> { let container = Container::new()?; // Create environment in Created state let env_name = EnvironmentName::new("e2e-provision".to_string())?; let ssh_credentials = load_test_ssh_credentials()?; let environment = Environment::new(env_name, ssh_credentials); // Execute with typed state let provision_cmd = container.provision_command(); let provisioned_env = provision_cmd.execute(environment).await?; // Extract IP from typed environment let ip_address = provisioned_env.ip_address() .expect("Provisioned environment must have IP"); info!(ip = %ip_address, "Provisioning completed"); Ok(()) }
-
Update E2E Full Test binary (
src/bin/e2e-tests-full.rs) - Provision part:async fn run_full_deployment() -> Result<(), Box<dyn std::error::Error>> { let container = Container::new()?; // Create initial environment let env_name = EnvironmentName::new("e2e-full".to_string())?; let ssh_credentials = load_test_ssh_credentials()?; let environment = Environment::new(env_name, ssh_credentials); // Provision with typed state let provision_cmd = container.provision_command(); let provisioned_env = provision_cmd.execute(environment).await?; // TODO: Configure step will be updated in Subtask 4 // For now, just verify provisioning worked info!(ip = %provisioned_env.ip_address().unwrap(), "Provisioned"); Ok(()) }
Tests to Update:
- Update E2E provision test binary to create
Environment<Created> - Update E2E full test binary (provision part) to use typed states
- Verify existing tests still pass with new signature
Success Criteria:
- ✅
ProvisionCommandsignature uses typed states - ✅ State transitions at command boundaries
- ✅ State persisted after transitions
- ✅ Error states include failed step information
- ✅ Persistence failures don't break command execution
- ✅ All existing E2E tests pass
- ✅ All unit tests pass
- ✅ All linters pass
Commit Messages:
feat: integrate type-safe state management into ProvisionCommand
refactor: update Container factory method to inject repository into ProvisionCommand
refactor: update E2E provision test to use typed Environment states
refactor: update E2E full test (provision part) to use typed states
Estimated Test Count: 670+ tests (7 new unit tests)
Status: ✅ Completed (Commit: 8997275)
Purpose: Modify ConfigureCommand to accept Environment<Provisioned> and return Environment<Configured>, with state transitions and persistence. Immediately update all code that uses this command (Container factory, E2E binaries) since this is a breaking change.
Changes:
-
Update Command Signature:
- Change
execute()to acceptEnvironment<Provisioned>instead of depending on external state - Return
Environment<Configured>on success - Store
Environmentas a field in the command struct
- Change
-
Integrate State Transitions:
- Transition to
Configuringstate at command start - Persist state after transition
- Transition to
Configuredstate on success - Transition to
ConfigureFailedstate on error with failed step name
- Transition to
-
Add Persistence Integration:
- Inject
Arc<dyn EnvironmentRepository>dependency - Save state after each transition
- Handle persistence errors gracefully
- Inject
-
Extract IP Address:
- Access IP address from
Environment<Provisioned>state - Use IP for Ansible operations
- Access IP address from
Implementation Details:
use crate::domain::environment::{Environment, Provisioned, Configuring, Configured, ConfigureFailed};
use crate::domain::environment::repository::EnvironmentRepository;
pub struct ConfigureCommand {
ansible_client: Arc<AnsibleClient>,
repository: Arc<dyn EnvironmentRepository>,
}
impl ConfigureCommand {
pub fn new(
ansible_client: Arc<AnsibleClient>,
repository: Arc<dyn EnvironmentRepository>,
) -> Self {
Self {
ansible_client,
repository,
}
}
#[instrument(
name = "configure_command",
skip_all,
fields(
command_type = "configure",
environment = %environment.name()
)
)]
pub fn execute(
&self,
environment: Environment<Provisioned>,
) -> Result<Environment<Configured>, ConfigureCommandError> {
info!(
command = "configure",
environment = %environment.name(),
"Starting infrastructure configuration workflow"
);
// Transition to Configuring state
let environment = environment.start_configuring();
// Persist intermediate state
if let Err(e) = self.persist_state(&environment) {
warn!(
"Failed to persist configuring state: {}. Continuing with command execution.",
e
);
}
// Execute configuration steps
match self.execute_configuration_steps(&environment) {
Ok(configured_env) => {
// Persist final state
if let Err(e) = self.persist_state(&configured_env) {
warn!(
"Failed to persist configured state: {}. Instance is configured but state not saved.",
e
);
}
Ok(configured_env)
}
Err(e) => {
// Transition to error state
let failed_env = environment.fail_configuring(
self.extract_failed_step(&e)
);
// Persist error state
if let Err(persist_err) = self.persist_state(&failed_env) {
warn!(
"Failed to persist configure failure state: {}",
persist_err
);
}
Err(e)
}
}
}
fn execute_configuration_steps(
&self,
environment: &Environment<Configuring>,
) -> Result<Environment<Configured>, ConfigureCommandError> {
// Install Docker
InstallDockerStep::new(Arc::clone(&self.ansible_client))
.execute()?;
// Install Docker Compose
InstallDockerComposeStep::new(Arc::clone(&self.ansible_client))
.execute()?;
info!(
command = "configure",
environment = %environment.name(),
"Infrastructure configuration completed successfully"
);
// Transition to Configured state
Ok(environment.clone().complete_configuring())
}
fn persist_state<S: Serialize>(
&self,
environment: &Environment<S>,
) -> Result<(), RepositoryError> {
let any_state = environment.clone().into_any();
self.repository.save(&any_state)
}
fn extract_failed_step(&self, error: &ConfigureCommandError) -> String {
match error {
ConfigureCommandError::Command(e) => {
// Try to extract step from command error
format!("configuration_step: {}", e)
}
}
}
}Tests to Add:
- Test command accepts
Environment<Provisioned>and returnsEnvironment<Configured> - Test compile-time error when passing wrong state (documentation test)
- Test state transition to
Configuringat command start - Test state transition to
Configuredon success - Test state transition to
ConfigureFailedon error with step name - Test persistence is called after each state transition
- Test command continues if persistence fails
- Mock repository to verify persistence calls
Integration Updates (Must be done together with command changes):
-
Update Container factory method:
pub fn configure_command(&self) -> ConfigureCommand { ConfigureCommand::new( Arc::clone(&self.ansible_client), Arc::clone(&self.environment_repository), // Inject repository ) }
-
Update E2E Config Test binary (
src/bin/e2e-config-tests.rs):fn run_configure_test() -> Result<(), Box<dyn std::error::Error>> { let container = Container::new()?; // Load provisioned environment (from previous provision) // Or mock a provisioned environment for testing let provisioned_env = load_provisioned_environment()?; // Execute with typed state let configure_cmd = container.configure_command(); let configured_env = configure_cmd.execute(provisioned_env)?; info!(environment = %configured_env.name(), "Configuration completed"); Ok(()) }
-
Update E2E Full Test binary (
src/bin/e2e-tests-full.rs) - Complete the chain:async fn run_full_deployment() -> Result<(), Box<dyn std::error::Error>> { let container = Container::new()?; // Create initial environment let env_name = EnvironmentName::new("e2e-full".to_string())?; let ssh_credentials = load_test_ssh_credentials()?; let environment = Environment::new(env_name, ssh_credentials); // Provision let provision_cmd = container.provision_command(); let provisioned_env = provision_cmd.execute(environment).await?; // Configure (compile-time enforced: requires Provisioned state!) let configure_cmd = container.configure_command(); let configured_env = configure_cmd.execute(provisioned_env)?; info!( environment = %configured_env.name(), "Full deployment completed successfully" ); Ok(()) }
Tests to Update:
- Update E2E config test binary to use typed
Environment<Provisioned> - Update E2E full test binary (configure part) to chain from provision
- Verify compile-time type safety: cannot pass
Environment<Created>to configure - Verify tests pass with new typed interface
Success Criteria: ✅ All achieved
- ✅
ConfigureCommandsignature uses typed states - ✅ Compile-time enforcement: only accepts
Environment<Provisioned> - ✅ State transitions at command boundaries
- ✅ State persisted after transitions
- ✅ Error states include failed step information
- ✅ All existing E2E tests pass
- ✅ All unit tests pass (704 total)
- ✅ All linters pass
Commits:
8997275- feat: integrate state management in ConfigureCommand and fix environment persistencec0aff04- fix: use temp directory in repository_factory test to avoid data/ artifacts
Final Test Count: 704 tests passing ✅
- ✅ ADR documenting command state return pattern vs pure command handler approach (Commit: 7f02019)
- ✅
ProvisionCommandacceptsEnvironment<Created>, returnsEnvironment<Provisioned>(Commit: 698d85a) - ✅
ConfigureCommandacceptsEnvironment<Provisioned>, returnsEnvironment<Configured>(Commit: 8997275) - ✅ Commands transition through intermediate states (
Provisioning,Configuring) - ✅ Commands transition to error states on failure with step context
- ✅ State persisted after each transition via repository
- ✅ Persistence failures handled gracefully (don't break commands)
- ✅ Compile-time prevention of invalid state transitions
- ✅ Type-safe state transitions demonstrated in E2E tests
- ✅ State transitions visible in logs during E2E tests
- ✅ Environment files correctly persisted at
data/{env}/environment.json
Location and Naming Convention:
- State files:
build/{environment_name}/state.json - Lock files:
build/{environment_name}/state.json.lock - Created by repository during first save operation
- Persisted across command executions for same environment
Handling Missing or Corrupted State:
- If state file missing: Command proceeds (environment is source of truth)
- If state file corrupted: Log error, proceed with command (or fail-fast, to be decided)
- If lock file orphaned (process died): File locking library handles with PID validation
- Manual state manipulation: Commands can detect mismatch and warn user
Cleanup Strategy:
- E2E tests can optionally clean up state files after test completion
- Or leave them for debugging (current approach in E2E tests)
- Future: Add
destroycommand to clean up both infrastructure and state
Locking Behavior:
- File locks are per-environment (each environment has its own state file)
- Multiple commands on different environments can run concurrently ✅
- Multiple commands on same environment are serialized by file lock ✅
- Lock timeout: 30 seconds (configurable)
User Experience with Locks:
- If lock acquisition fails: Error message includes PID of lock holder
- User can check if process is running:
ps -p <pid>(Unix) ortasklist /FI "PID eq <pid>"(Windows) - If process is dead: Lock file is automatically cleaned up on next attempt
- Clear actionable error messages (following error handling guide)
Current Behavior (Phase 5):
- Commands do not validate input state against persisted state
- Type system ensures correct state at compile time
- If provision fails →
Environment<ProvisionFailed>persisted - User must handle error state (future: add
resumeorretrycommand)
Invalid State Scenarios:
❌ Cannot call configure on Environment<Created> → Compile error (type safety)
❌ Cannot call provision on Environment<Provisioned> → Would create new typed env
✅ Can call provision on Environment<ProvisionFailed> → Would replace failed state
Environment<Created> → Mismatch warning (optional validation)
Future Enhancement (Phase 6+):
- Add state validation: Check stored state matches input state type
- Add
resumecommand: Load state, determine next action, continue workflow - Add
retrycommand: Reset error state, retry failed operation
Constructor Parameters:
- Lock timeout:
Duration(30 seconds default) - State file base path: Derived from environment name (
build/{env_name}/) - Lock file strategy: Same directory as state file,
.lockextension
Test vs Production:
- Unit tests: Mock repository (
MockEnvironmentRepository) - E2E tests: Real file repository (
JsonFileRepository) with test environment names - Production (future): Same file repository, configured paths
Example Configuration:
// In container or main setup
let repository = Arc::new(JsonFileRepository::new(
Duration::from_secs(30), // Lock timeout
)) as Arc<dyn EnvironmentRepository>;State Transition Logging (from Phase 2):
- Every state transition automatically logged with structured fields
#[instrument]spans track command execution- State name, environment name, timestamp included
Example Log Output:
2025-10-03T10:30:15.123Z INFO provision_command{environment="e2e-full"}:
Starting infrastructure provisioning workflow
2025-10-03T10:30:15.124Z INFO state_transition{environment="e2e-full"}:
Transitioning from Created to Provisioning
2025-10-03T10:30:15.125Z DEBUG persistence{environment="e2e-full"}:
Saving state to build/e2e-full/state.json
... provision steps ...
2025-10-03T10:32:45.678Z INFO state_transition{environment="e2e-full"}:
Transitioning from Provisioning to Provisioned with IP 10.140.190.14
2025-10-03T10:32:45.679Z DEBUG persistence{environment="e2e-full"}:
Saving state to build/e2e-full/state.json
2025-10-03T10:32:45.680Z INFO provision_command{environment="e2e-full"}:
Provisioning completed successfully
Documentation Tests (to be added in implementation):
/// # Compile-Time Type Safety
///
/// The type-state pattern prevents invalid state transitions at compile time:
///
/// ```compile_fail
/// # use torrust_tracker_deploy::domain::environment::{Environment, Created, Provisioned};
/// # use torrust_tracker_deploy::application::commands::ConfigureCommand;
/// let environment: Environment<Created> = Environment::new(/* ... */);
/// let configure_cmd = ConfigureCommand::new(/* ... */);
///
/// // This will NOT compile: ConfigureCommand requires Environment<Provisioned>
/// configure_cmd.execute(environment)?;
/// // ^^^^^^^^^^^ expected Environment<Provisioned>, found Environment<Created>
/// ```
///
/// Correct usage requires provisioning first:
///
/// ```rust
/// # use torrust_tracker_deploy::domain::environment::{Environment, Created};
/// let environment: Environment<Created> = Environment::new(/* ... */);
/// let provision_cmd = ProvisionCommand::new(/* ... */);
/// let configure_cmd = ConfigureCommand::new(/* ... */);
///
/// // Type-safe command chaining
/// let provisioned = provision_cmd.execute(environment).await?;
/// let configured = configure_cmd.execute(provisioned)?; // ✅ Compiles!
/// ```
pub struct ConfigureCommand { /* ... */ }-
Unit Tests (Subtasks 1-2):
- Test individual command state transitions
- Test error state handling with failed steps
- Test persistence integration (with mocked repository)
- Verify compile-time type safety (documentation tests)
- Test graceful handling of persistence failures
-
Integration Tests:
- Test commands with mocked repository and infrastructure
- Verify state persistence across command executions
- Test error propagation and state tracking
-
E2E Tests:
- Update existing E2E tests to use new command signatures
- Verify full workflow with real infrastructure
- Verify state transitions are logged
- Verify persisted state (for file-based E2E tests)
- New Tests: ~21 tests (7 Subtask 1 + 7 Subtask 3 + 7 Subtask 4)
- Subtask 1: 2 unit tests (error helpers) + 5 integration tests (repository/locking)
- Subtask 3: 7 unit tests (ProvisionCommand state management)
- Subtask 4: 7 unit tests (ConfigureCommand state management)
- Total Tests: ~684 (starting from 663)
- Test Execution Time: Similar to current (mocked repository for unit tests)
- Documentation:
- New ADR documenting command state return pattern decision
- Updated command documentation with state management examples
- Comprehensive integration considerations documented
- Code Changes: ~600 lines (refactoring + commands + integration + tests)
- Breaking Changes: Command signatures, container, E2E test binaries (internal only, no public API yet)
- Subtasks: 4 (1 refactoring + 2 ADR + 3-4 commands with integration)
- Phase 1: State marker types ✅
- Phase 2: State transition logging ✅
- Phase 3: Type erasure and serialization ✅
- Phase 4: Persistence layer with file locking ✅
With Phase 5 complete, the environment state management feature will be fully functional:
- ✅ Type-safe state machine (Phase 1)
- ✅ Observable transitions (Phase 2)
- ✅ Serialization support (Phase 3)
- ✅ Persistent storage (Phase 4)
- ✅ Command integration (Phase 5)
Future Enhancements (Phase 6+):
- Command Orchestration Layer: Build a
DeploymentOrchestratorthat chains commands with compile-time state validation, enabling fluent API likeorchestrator.provision(env).await?.configure()? - Add
statuscommand to query environment state - Add
destroycommand with state cleanup - Add
resumecommand for recovering from interrupted operations - Implement validation of infrastructure state vs stored state
- Add event sourcing for full transition history
- Create CLI for user-facing operations
| Risk | Impact | Mitigation |
|---|---|---|
| Breaking E2E tests during refactoring | High | Incremental changes, maintain backward compatibility temporarily |
| Persistence errors breaking commands | Medium | Graceful degradation: log warnings, continue execution |
| Type-state pattern too complex | Low | Excellent documentation, examples, clear error messages |
- Feature Description - Overall feature goals and requirements
- Requirements Analysis - Q&A that shaped the feature
- Phase 1: Foundation - Type-state pattern implementation
- Phase 4: Persistence - Repository and file locking
- Error Handling Guide - Error handling principles
- Testing Conventions - Testing best practices
To begin implementing Phase 5:
- Review this plan thoroughly, especially the Critical Integration Considerations section
- Read related documentation to understand context
- Start with Subtask 1 (Preparatory Refactoring) - Clean up commands AND setup repository infrastructure first
- Continue with Subtask 2 (Create ADR) - Document the architectural decision
- Implement Subtask 3 (ProvisionCommand) - Immediately update Container and E2E binaries (breaking change!)
- Implement Subtask 4 (ConfigureCommand) - Immediately update Container and E2E binaries (breaking change!)
- Follow the test-driven development approach for each subtask
- Commit after each major change within a subtask
- Update this document with actual test counts and any deviations
Remember: The goal is incremental, testable progress with immediate integration:
- Refactoring + Setup (Subtask 1) reduces complexity AND prepares infrastructure (non-breaking)
- Document decision (Subtask 2) explains the "why" before the "how"
- Command + Integration (Subtasks 3-4) implement functionality AND update all callers atomically
Critical: Each command update MUST include updating its callers (Container factory + E2E binaries) in the same subtask. Otherwise code won't compile!