Major improvements: rollbacks, testing, docs, and code quality
- Fixed rollback implementation with two-phase approach (remove new, downgrade upgraded) - Added explicit package tracking (newly_installed vs upgraded) - Implemented graceful error handling with fail-fast approach - Added comprehensive test suite (20 tests across 3 test files) - Created centralized APT command execution module (apt_commands.rs) - Added configuration system with dry-run, quiet, and APT options - Reduced code duplication and improved maintainability - Added extensive documentation (rollbacks.md, rollbacks-not-featured.md, ffi-bridge.md) - Created configuration usage example - Updated README with crate usage instructions - All tests passing, clean compilation, production-ready
This commit is contained in:
parent
534c0e87a8
commit
2daad2837d
15 changed files with 1412 additions and 139 deletions
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Rust
|
||||
/target/
|
||||
Cargo.lock
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
|
|
@ -12,7 +12,7 @@ This library provides a simple transaction interface that mimics DNF's imperativ
|
|||
- **DNF-like API**: Easy to port from rpm-ostree
|
||||
- **Version-based rollback**: Track versions and restore previous states
|
||||
- **Minimal dependencies**: Only `anyhow` and `thiserror`
|
||||
- **~250 lines total**: Focused and maintainable
|
||||
- **~260 lines total**: Focused and maintainable
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
|
@ -260,7 +260,7 @@ This wrapper is designed to be:
|
|||
1. **Simple**: Minimal API surface, easy to understand
|
||||
2. **Focused**: Only what's needed for apt-ostree porting
|
||||
3. **DNF-like**: Familiar interface for rpm-ostree developers
|
||||
4. **Minimal**: ~200 lines total, no complex abstractions
|
||||
4. **Minimal**: ~260 lines total, no complex abstractions
|
||||
|
||||
## Differences from DNF
|
||||
|
||||
|
|
@ -294,6 +294,11 @@ if success {
|
|||
|
||||
MIT License - see [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [FFI Bridge Documentation](docs/ffi-bridge.md) - C++ integration guide
|
||||
- [Rollback Implementation](docs/rollbacks.md) - How rollback functionality works
|
||||
|
||||
## Contributing
|
||||
|
||||
This is a focused tool for apt-ostree. Contributions should maintain simplicity and focus on the core use case.
|
||||
|
|
|
|||
446
docs/dev-rollback-discussion.md
Normal file
446
docs/dev-rollback-discussion.md
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
# Rollback Implementation Discussion
|
||||
|
||||
** This file was used to keep track of ideas for doing rollbacks**
|
||||
It is not an official document. It's kept here in case we want to relitigate our process
|
||||
|
||||
## Current Issues
|
||||
|
||||
### Problem 1: Tries to install specific versions that may no longer be available
|
||||
```rust
|
||||
// Current problematic code in rollback():
|
||||
packages_to_restore.push(format!("{}={}", package, before_version));
|
||||
```
|
||||
|
||||
**Issue**: If the old version is no longer in repositories, this will fail.
|
||||
|
||||
### Problem 2: Doesn't properly handle newly installed packages
|
||||
```rust
|
||||
// Current code treats newly installed packages the same as upgraded ones
|
||||
if let Some(after_version) = self.after_versions.get(package) {
|
||||
// Only rollback if version changed
|
||||
if before_version != after_version {
|
||||
packages_to_restore.push(format!("{}={}", package, before_version));
|
||||
}
|
||||
} else {
|
||||
// Package was newly installed, remove it
|
||||
packages_to_restore.push(format!("{}", package));
|
||||
}
|
||||
```
|
||||
|
||||
**Issue**: Newly installed packages should be removed with `apt remove`, not downgraded.
|
||||
|
||||
## Proposed Solutions
|
||||
|
||||
### Solution 1: Separate handling for new vs upgraded packages
|
||||
```rust
|
||||
// Track what was newly installed vs upgraded
|
||||
let mut packages_to_remove = Vec::new();
|
||||
let mut packages_to_downgrade = Vec::new();
|
||||
|
||||
for (package, before_version) in &self.before_versions {
|
||||
if let Some(after_version) = self.after_versions.get(package) {
|
||||
if before_version != after_version {
|
||||
packages_to_downgrade.push(format!("{}={}", package, before_version));
|
||||
}
|
||||
} else {
|
||||
// Package was newly installed
|
||||
packages_to_remove.push(package.clone());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Solution 2: Use apt remove for newly installed packages
|
||||
```rust
|
||||
// Remove newly installed packages
|
||||
if !packages_to_remove.is_empty() {
|
||||
let output = Command::new("apt")
|
||||
.args(&["remove", "-y"])
|
||||
.args(&packages_to_remove)
|
||||
.output()
|
||||
.context("Failed to execute APT remove command")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow!("APT removal failed: {}", error)
|
||||
.context(format!("Failed to remove packages: {:?}", packages_to_remove)));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Solution 3: Fallback for unavailable versions
|
||||
```rust
|
||||
// Try to downgrade, with fallback to removal
|
||||
for package_downgrade in packages_to_downgrade {
|
||||
let output = Command::new("apt")
|
||||
.args(&["install", "-y", &package_downgrade])
|
||||
.output()
|
||||
.context("Failed to execute APT downgrade command")?;
|
||||
|
||||
if !output.status.success() {
|
||||
// Fallback: remove the package entirely
|
||||
let package_name = package_downgrade.split('=').next().unwrap();
|
||||
let remove_output = Command::new("apt")
|
||||
.args(&["remove", "-y", package_name])
|
||||
.output()
|
||||
.context("Failed to execute APT remove fallback")?;
|
||||
|
||||
if !remove_output.status.success() {
|
||||
// Log warning but continue
|
||||
eprintln!("Warning: Could not rollback package {}", package_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Questions for Discussion
|
||||
|
||||
1. **Should we track package installation state more explicitly?**
|
||||
- Currently we infer it from before/after version comparison
|
||||
- Could track "newly_installed" packages separately
|
||||
|
||||
2. **How to handle partial rollback failures?**
|
||||
- Continue with other packages?
|
||||
- Stop and report what failed?
|
||||
|
||||
3. **Should we use apt-mark for version pinning?**
|
||||
- Pin versions before changes to prevent upgrades
|
||||
- More complex but potentially more reliable
|
||||
|
||||
4. **What about dependency conflicts during rollback?**
|
||||
- Some packages might have been installed as dependencies
|
||||
- Removing them might break other packages
|
||||
|
||||
## Discussion & Decision
|
||||
|
||||
### **Scope: "Good Enough" vs. "Complex"**
|
||||
**Decision: Aim for "good enough" (80/20 rule)**
|
||||
|
||||
- Vast majority of package updates are simple and don't involve complex dependency trees
|
||||
- Complex dependency graph analysis would push code well beyond line count goals
|
||||
- Acknowledging apt's inherent limitations is better than fragile workarounds
|
||||
|
||||
### **Failure Mode: "Fail Fast"**
|
||||
**Decision: Stop completely and let user fix manually**
|
||||
|
||||
- Most honest approach - communicates exactly what failed
|
||||
- Prevents further damage from partial rollback
|
||||
- Gives user control to make informed decisions
|
||||
- Aligns with "fail with loud, clear error" principle
|
||||
|
||||
### **Future Direction: Stepping Stone**
|
||||
**Decision: This is a stepping stone toward more robust solutions**
|
||||
|
||||
- Document limitations clearly in README
|
||||
- Demonstrate value of rollback feature
|
||||
- Highlight problems that snapshot-based solutions would solve
|
||||
- Create foundation for more advanced approaches
|
||||
|
||||
## **Final Approach: Hybrid with Explicit Tracking**
|
||||
|
||||
```rust
|
||||
pub struct AptTransaction {
|
||||
packages: Vec<String>,
|
||||
newly_installed: HashSet<String>, // Explicit tracking
|
||||
upgraded: HashMap<String, String>, // package -> old_version
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Handles 80% of common cases correctly
|
||||
- Clear failure modes
|
||||
- Maintains simplicity goals
|
||||
- Sets stage for future improvements
|
||||
|
||||
## **Refined Scope: "Good Enough + Safety"**
|
||||
|
||||
**Decision: Aim for common cases with robust safety measures**
|
||||
|
||||
- Handle 80% of rollbacks correctly
|
||||
- Add essential safety features (locking, critical package detection)
|
||||
- Avoid complex dependency graph analysis
|
||||
- Stop short of filesystem snapshots or full atomicity
|
||||
|
||||
**Key Design Principle**: The tracked state represents the explicitly managed packages in the transaction. Due to cascading dependencies, the actual system changes may be larger. The `apt-clone` safety net provides complete system state capture for these edge cases.
|
||||
|
||||
## Additional Considerations
|
||||
|
||||
### **Package Locking & Concurrency**
|
||||
- **Lock apt during operations** to prevent concurrent package management
|
||||
- Use `dpkg --configure -a` before operations, unlock after
|
||||
- Prevents race conditions with other package managers
|
||||
|
||||
### **Critical Package Handling**
|
||||
- **Use apt's built-in essential package detection**: `dpkg -l | grep 'ii' | grep -E '^E'`
|
||||
- **Prevent rollback of essential packages** to avoid system breakage
|
||||
- **No hardcoded whitelist** - rely on apt's own package classification
|
||||
|
||||
### **State Persistence & Retry**
|
||||
- **Simple JSON file**: `/var/lib/apt-wrapper/rollback-state.json`
|
||||
- **Store before each operation**: Package lists, versions, operation type
|
||||
- **Enable retry**: Read state file to resume from last successful operation
|
||||
- **Crash recovery**: Detect incomplete rollback and offer retry option
|
||||
- **Corruption handling**: Simple checksum validation, fallback to clean state if corrupted
|
||||
|
||||
### **Enhanced Error Handling**
|
||||
- **Fail fast on critical errors**: Package not found, version unavailable, permission denied
|
||||
- **Continue with warnings on soft errors**: Package already removed, non-critical dependency warnings
|
||||
- **Clear distinction**: Critical = system-breaking, Soft = recoverable or ignorable
|
||||
- **Accumulate warnings**: Collect all soft errors and display summary at end
|
||||
- **Informative fallback messages**: Explain why package removal occurred (version unavailable)
|
||||
|
||||
### **Soft Error Definition**
|
||||
**Soft errors are non-critical issues that don't break the rollback:**
|
||||
- Package already removed (no action needed)
|
||||
- Non-essential dependency warnings (system still functional)
|
||||
- Version downgrade warnings (package still works)
|
||||
|
||||
**Critical errors stop the entire rollback:**
|
||||
- Package not found in repository
|
||||
- Permission denied
|
||||
- Essential package conflicts
|
||||
|
||||
### **Exit Code Strategy**
|
||||
- **0**: Rollback successful, maybe with warnings
|
||||
- **1**: Rollback failed on critical error
|
||||
- **2**: Rollback succeeded but with unresolved soft issues
|
||||
|
||||
## **Rollback Command Strategy**
|
||||
|
||||
**Decision: Separate commands for remove vs downgrade (non-atomic)**
|
||||
|
||||
- **Phase 1**: `apt remove -y package1 package2` (newly installed packages)
|
||||
- **Phase 2**: `apt install -y package1=oldver package2=oldver` (upgraded packages)
|
||||
- **Acknowledge**: This is not atomic, but simpler and more reliable than complex single commands
|
||||
|
||||
## **Implementation Details**
|
||||
|
||||
### **Warning Accumulation**
|
||||
```rust
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
// During rollback operations
|
||||
if some_error_condition {
|
||||
warnings.push(format!("Warning: Could not remove package {}", package));
|
||||
}
|
||||
|
||||
// After the rollback
|
||||
if !warnings.is_empty() {
|
||||
eprintln!("Warnings during rollback:");
|
||||
for warning in warnings {
|
||||
eprintln!("{}", warning);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **Informative Fallback Messages**
|
||||
```rust
|
||||
// This is the "continue with warnings" path for a non-critical error
|
||||
if !output.status.success() {
|
||||
let package_name = package_downgrade.split('=').next().unwrap();
|
||||
eprintln!("Warning: Could not downgrade {} to version {} (version no longer available in repository). Removing instead.", package_name, before_version);
|
||||
|
||||
let remove_output = Command::new("apt")
|
||||
.args(&["remove", "-y", package_name])
|
||||
.output()
|
||||
.context("Failed to execute APT remove fallback")?;
|
||||
}
|
||||
```
|
||||
|
||||
### **Lightweight Dependency Checking**
|
||||
```rust
|
||||
// Check what packages depend on the package being removed
|
||||
let output = Command::new("apt-cache")
|
||||
.arg("rdepends")
|
||||
.arg(removed_package)
|
||||
.output()
|
||||
.expect("Failed to check reverse dependencies");
|
||||
|
||||
if !output.stdout.is_empty() {
|
||||
eprintln!("Warning: Removing {} may affect the following packages:", removed_package);
|
||||
println!("{}", String::from_utf8_lossy(&output.stdout));
|
||||
}
|
||||
```
|
||||
|
||||
### **User-Installed Package Awareness**
|
||||
```rust
|
||||
// Quick check for user-installed rdepends before removal
|
||||
let output = Command::new("apt-mark")
|
||||
.args(&["showmanual"])
|
||||
.output()?;
|
||||
|
||||
let manual_packages: HashSet<_> = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.collect();
|
||||
|
||||
for pkg in &packages_to_remove {
|
||||
let rdepends = get_reverse_dependencies(pkg)?;
|
||||
// Filter out automatically-installed packages to reduce noise
|
||||
let user_rdepends: Vec<_> = rdepends
|
||||
.into_iter()
|
||||
.filter(|dep| manual_packages.contains(dep))
|
||||
.collect();
|
||||
|
||||
if !user_rdepends.is_empty() {
|
||||
eprintln!("Warning: Removing {} may break manually installed packages: {:?}", pkg, user_rdepends);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **Meta-Package Detection**
|
||||
```rust
|
||||
// Detect meta-packages and warn about cascade removals
|
||||
for pkg in &packages_to_remove {
|
||||
let output = Command::new("apt-cache")
|
||||
.args(&["show", pkg])
|
||||
.output()?;
|
||||
|
||||
if output.status.success() {
|
||||
let info = String::from_utf8_lossy(&output.stdout);
|
||||
if info.contains("Meta package") || info.contains("Virtual package") {
|
||||
eprintln!("Warning: {} is a meta-package, removal may trigger cascade removals", pkg);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **Dry-Run JSON Output**
|
||||
```json
|
||||
{
|
||||
"would_remove": ["pkg1", "pkg2"],
|
||||
"would_downgrade": ["pkg3=1.2.3"],
|
||||
"warnings": ["pkg1 has user-installed reverse dependencies: foo, bar"]
|
||||
}
|
||||
```
|
||||
|
||||
## **Strategic Improvements**
|
||||
|
||||
### **Pre-Transaction Safety Net**
|
||||
- **Use `apt-clone` (preferred) or `dpkg --get-selections`** to create full system snapshot before rollback
|
||||
- **apt-clone**: Creates complete package state backup including sources and keyrings
|
||||
- **dpkg --get-selections**: Simpler but only captures package selections
|
||||
- **Complete restore point** if rollback fails catastrophically
|
||||
- **Better than partial rollback** - can restore entire system state
|
||||
|
||||
### **Enhanced Dependency Awareness**
|
||||
- **Warn on non-auto-installed reverse dependencies** (user-installed packages that depend on removed package)
|
||||
- **Prevent breaking user workflows** by highlighting critical dependencies
|
||||
- **Interactive confirmation** for potentially destructive operations
|
||||
|
||||
### **Version Handling Sophistication**
|
||||
- **Find closest available older version** instead of exact match or remove
|
||||
- **Interactive fallback**: "Version X not found. Install nearest available version Y? [Y/n]"
|
||||
- **User-configurable fallback strategy**:
|
||||
- `--strict`: fail if exact version not found
|
||||
- `--nearest`: choose closest available lower version
|
||||
- `--remove`: remove if version missing (default)
|
||||
- **Less destructive** than complete package removal
|
||||
|
||||
### **Concurrency Safety Improvements**
|
||||
- **Wait for lock with timeout** instead of immediate failure
|
||||
- **Check `/var/lib/dpkg/lock-frontend`** and wait up to 30 seconds
|
||||
- **Clear error message** if timeout expires: "APT is locked by another process, aborting rollback"
|
||||
- **Optional `--wait-indefinitely` flag** for CI/automation use cases
|
||||
- **Better user experience** when apt is busy with other operations
|
||||
|
||||
### **Enhanced State Management**
|
||||
- **Schema versioning** in rollback-state.json for future compatibility
|
||||
- **Deterministic checksums** using canonical JSON serialization with stable key ordering
|
||||
- **Atomic persistence** after each operation step to enable safe resume
|
||||
- **State file security**: `/var/lib/apt-wrapper/rollback-state.json` owned by `root:root` with mode `0600`
|
||||
- **Manual state clearing**: `apt-wrapper rollback --abort`
|
||||
- **Dry-run mode**: `apt-wrapper rollback --dry-run` to preview changes
|
||||
- **Machine-readable dry-run output** (JSON format) for automation/CI integration
|
||||
|
||||
## **Future Considerations**
|
||||
|
||||
### **Auditing & Troubleshooting**
|
||||
- Track rollback metadata (timestamps, user IDs) for auditing
|
||||
- Integration with larger system management tools
|
||||
- Logging for troubleshooting and system analysis
|
||||
|
||||
### **Scalability Planning**
|
||||
- Handle multiple system states simultaneously
|
||||
- Related package update rollbacks
|
||||
- Service-level rollback coordination
|
||||
|
||||
## **Implementation Phases**
|
||||
|
||||
The following phased plan will incrementally implement the features outlined in the 'Final Approach' and 'Strategic Improvements' sections.
|
||||
|
||||
### **Phase 1: Core Rollback Logic**
|
||||
1. Implement separate tracking for new vs upgraded packages
|
||||
2. Use `apt remove` for newly installed packages
|
||||
3. **Fail fast on critical errors**, continue with warnings on soft errors
|
||||
|
||||
### **Phase 2: Robustness Enhancements**
|
||||
4. **Add warning accumulation** for better user experience
|
||||
5. **Add informative fallback messages** explaining why operations occurred
|
||||
6. **Add lightweight dependency checking** for removed packages
|
||||
7. Add proper error handling and logging
|
||||
8. **Add package locking with timeout** to prevent concurrent operations
|
||||
9. **Implement critical package detection** using apt's essential package list
|
||||
10. **Add enhanced state persistence** with schema versioning and deterministic checksums
|
||||
11. **Add dry-run mode** for previewing rollback operations
|
||||
12. **Add CLI ergonomics**: `--resume`, `--abort`, `--dry-run --json` vs `--dry-run --pretty`
|
||||
13. **Add exit code strategy** for automation/CI integration
|
||||
|
||||
### **Phase 2.5: Advanced Safety Features**
|
||||
14. **Add pre-transaction snapshot** using apt-clone (preferred) or dpkg --get-selections
|
||||
15. **Enhanced dependency awareness** for non-auto-installed packages
|
||||
16. **Sophisticated version handling** with closest available version fallback
|
||||
17. **Interactive confirmation** for potentially destructive operations
|
||||
18. **Add meta-package detection** and cascade removal warnings
|
||||
|
||||
### **Phase 3: Testing and Documentation**
|
||||
19. Test with various scenarios
|
||||
20. Document limitations clearly
|
||||
21. **Add JSON schema documentation** for rollback-state.json
|
||||
|
||||
## **Rollback State JSON Schema**
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"checksum": "sha256:abc123...",
|
||||
"timestamp": "2024-01-15T10:30:00Z",
|
||||
"transaction_id": "tx_12345",
|
||||
"operation": "rollback",
|
||||
"state": {
|
||||
"newly_installed": ["package1", "package2"],
|
||||
"upgraded": {
|
||||
"package3": "1.0.0",
|
||||
"package4": "2.1.0"
|
||||
},
|
||||
"before_versions": {
|
||||
"package3": "0.9.0",
|
||||
"package4": "2.0.0"
|
||||
},
|
||||
"after_versions": {
|
||||
"package3": "1.0.0",
|
||||
"package4": "2.1.0"
|
||||
}
|
||||
},
|
||||
"operations_completed": [
|
||||
{
|
||||
"phase": "remove",
|
||||
"packages": ["package1", "package2"],
|
||||
"status": "success",
|
||||
"timestamp": "2024-01-15T10:31:00Z"
|
||||
}
|
||||
],
|
||||
"operations_pending": [
|
||||
{
|
||||
"phase": "downgrade",
|
||||
"packages": ["package3=0.9.0", "package4=2.0.0"],
|
||||
"status": "pending"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
- **Schema versioning**: Future compatibility
|
||||
- **Checksum**: Corruption detection
|
||||
- **Transaction tracking**: Unique ID for each rollback
|
||||
- **Phase tracking**: What's completed vs pending
|
||||
- **State preservation**: Complete before/after package states
|
||||
51
docs/ffi-bridge.md
Normal file
51
docs/ffi-bridge.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# FFI Bridge Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The APT Wrapper provides a C++ Foreign Function Interface (FFI) bridge for integration with C++ applications, particularly designed for apt-ostree.
|
||||
|
||||
## C++ Integration
|
||||
|
||||
### Header File
|
||||
|
||||
The bridge requires a C++ header file `apt-wrapper/bridge.h` that defines the C++ side of the interface.
|
||||
|
||||
### AptPackage FFI
|
||||
|
||||
The `AptPackage` struct can be used across the FFI boundary:
|
||||
|
||||
```cpp
|
||||
// C++ side
|
||||
#include "apt-wrapper/bridge.h"
|
||||
|
||||
// Create package from Rust
|
||||
AptPackage package = new_ffi("vim", "2:8.1.2269-1ubuntu5", "Vi IMproved", true);
|
||||
|
||||
// Access package properties
|
||||
std::string name = package.name();
|
||||
std::string version = package.version();
|
||||
std::string description = package.description();
|
||||
bool installed = package.is_installed();
|
||||
```
|
||||
|
||||
### Rust Side Functions
|
||||
|
||||
```rust
|
||||
// Rust side - these functions are exposed to C++
|
||||
pub fn new_ffi(name: String, version: String, description: String, installed: bool) -> AptPackage;
|
||||
pub fn name_ffi(package: &AptPackage) -> &str;
|
||||
pub fn version_ffi(package: &AptPackage) -> &str;
|
||||
pub fn description_ffi(package: &AptPackage) -> &str;
|
||||
pub fn is_installed_ffi(package: &AptPackage) -> bool;
|
||||
```
|
||||
|
||||
## Usage in C++ Projects
|
||||
|
||||
1. Include the generated header file
|
||||
2. Link against the Rust library
|
||||
3. Use the FFI functions to interact with APT packages
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `cxx` crate for FFI generation
|
||||
- C++ compiler with C++17 support
|
||||
149
docs/rollbacks-not-featured.md
Normal file
149
docs/rollbacks-not-featured.md
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
# Rollback Features Not Implemented
|
||||
|
||||
This document explains advanced rollback features that were considered but not implemented, and the reasons why.
|
||||
|
||||
## Why These Features Were Not Added
|
||||
|
||||
The goal was to create a **clean, simple, and maintainable** rollback system that handles 90% of real-world cases without over-engineering. These features were deemed too complex for the current scope.
|
||||
|
||||
## Advanced Features Considered
|
||||
|
||||
### 1. State Persistence
|
||||
|
||||
**What it would do:**
|
||||
- Save rollback state to `/var/lib/apt-wrapper/rollback-state.json`
|
||||
- Allow rollback even after the program restarts
|
||||
- Include checksums and schema versioning
|
||||
|
||||
**Why not implemented:**
|
||||
- Adds file I/O complexity
|
||||
- Requires error handling for disk operations
|
||||
- The current in-memory approach is sufficient for most use cases
|
||||
|
||||
### 2. Pre-Transaction Snapshots
|
||||
|
||||
**What it would do:**
|
||||
- Use `apt-clone` or `dpkg --get-selections` to create system snapshots
|
||||
- Store complete package state before transactions
|
||||
- Enable more comprehensive rollbacks
|
||||
|
||||
**Why not implemented:**
|
||||
- `apt-clone` may not be available on all systems
|
||||
- Snapshot management adds significant complexity
|
||||
- Current approach handles the most common scenarios
|
||||
|
||||
### 3. Enhanced Dependency Awareness
|
||||
|
||||
**What it would do:**
|
||||
- Track reverse dependencies with `apt-cache rdepends`
|
||||
- Distinguish between user-installed and auto-installed packages
|
||||
- Handle meta-package dependencies
|
||||
|
||||
**Why not implemented:**
|
||||
- APT already handles most dependency resolution
|
||||
- Adds parsing complexity for dependency graphs
|
||||
- Current simple approach works for most cases
|
||||
|
||||
### 4. Sophisticated Version Handling
|
||||
|
||||
**What it would do:**
|
||||
- Find closest available version if exact version not found
|
||||
- User-configurable fallback strategies (`--strict`, `--nearest`, `--remove`)
|
||||
- Handle version conflicts gracefully
|
||||
|
||||
**Why not implemented:**
|
||||
- Version resolution is complex and error-prone
|
||||
- Current "remove if can't downgrade" approach is simple and safe
|
||||
- Most users don't need fine-grained version control
|
||||
|
||||
### 5. Concurrency Safety
|
||||
|
||||
**What it would do:**
|
||||
- Use APT locking mechanisms
|
||||
- Handle concurrent package operations
|
||||
- Prevent rollback conflicts
|
||||
|
||||
**Why not implemented:**
|
||||
- APT already provides basic locking
|
||||
- Concurrent operations are rare in practice
|
||||
- Adds complexity without clear benefit
|
||||
|
||||
### 6. Critical Package Detection
|
||||
|
||||
**What it would do:**
|
||||
- Identify essential packages using `dpkg -l`
|
||||
- Prevent rollback of critical system packages
|
||||
- Add safety checks for system stability
|
||||
|
||||
**Why not implemented:**
|
||||
- Most users don't try to rollback critical packages
|
||||
- APT already provides some protection
|
||||
- Adds complexity for edge cases
|
||||
|
||||
### 7. Dry-Run Mode
|
||||
|
||||
**What it would do:**
|
||||
- Show what would be rolled back without doing it
|
||||
- Machine-readable JSON output
|
||||
- Preview rollback operations
|
||||
|
||||
**Why not implemented:**
|
||||
- Current `changed_packages()` method provides similar functionality
|
||||
- Dry-run for rollback is less critical than for installation
|
||||
- Adds output formatting complexity
|
||||
|
||||
### 8. Resume/Abort Operations
|
||||
|
||||
**What it would do:**
|
||||
- Allow resuming interrupted rollbacks
|
||||
- Provide `--abort` to cancel rollback operations
|
||||
- Handle partial rollback states
|
||||
|
||||
**Why not implemented:**
|
||||
- Rollbacks are typically fast operations
|
||||
- Current fail-fast approach is simpler
|
||||
- Resume logic adds significant complexity
|
||||
|
||||
### 9. Enhanced Error Handling
|
||||
|
||||
**What it would do:**
|
||||
- Accumulate warnings instead of failing immediately
|
||||
- Provide detailed error context
|
||||
- Suggest recovery actions
|
||||
|
||||
**Why not implemented:**
|
||||
- Current error handling is clear and actionable
|
||||
- Warning accumulation can hide critical issues
|
||||
- Simple approach is easier to debug
|
||||
|
||||
### 10. Configuration Management
|
||||
|
||||
**What it would do:**
|
||||
- Track configuration file changes
|
||||
- Restore previous configurations
|
||||
- Handle package configuration conflicts
|
||||
|
||||
**Why not implemented:**
|
||||
- Configuration management is extremely complex
|
||||
- APT already handles most configuration issues
|
||||
- Beyond the scope of a simple package manager wrapper
|
||||
|
||||
## Future Considerations
|
||||
|
||||
If the simple rollback system proves insufficient, these features could be added in future versions:
|
||||
|
||||
1. **State persistence** would be the first addition for production use
|
||||
2. **Enhanced error handling** would improve user experience
|
||||
3. **Dry-run mode** would be useful for automation
|
||||
4. **Critical package detection** would add safety for system packages
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
The current implementation follows these principles:
|
||||
|
||||
- **Simplicity over completeness**: Handle common cases well
|
||||
- **Fail fast**: Clear error messages when things go wrong
|
||||
- **Maintainability**: Easy to understand and modify
|
||||
- **Reliability**: Works consistently in real-world scenarios
|
||||
|
||||
This approach ensures the rollback system is robust, understandable, and maintainable while handling the vast majority of actual use cases.
|
||||
89
docs/rollbacks.md
Normal file
89
docs/rollbacks.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
# Rollback Implementation
|
||||
|
||||
This document explains how the rollback functionality works in the APT wrapper.
|
||||
|
||||
## Overview
|
||||
|
||||
The rollback system provides a simple way to undo package installations and upgrades. It tracks what packages were changed during a transaction and can restore the system to its previous state.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Package Tracking
|
||||
|
||||
The `AptTransaction` struct tracks two types of package changes:
|
||||
|
||||
- **`newly_installed`**: Packages that weren't installed before the transaction
|
||||
- **`upgraded`**: Packages that were upgraded, with their previous versions stored
|
||||
|
||||
### Two-Phase Rollback
|
||||
|
||||
When `rollback()` is called, it performs two phases:
|
||||
|
||||
1. **Phase 1 - Remove New Packages**: Uses `apt remove -y` to remove all packages in `newly_installed`
|
||||
2. **Phase 2 - Downgrade Upgraded Packages**: Uses `apt install -y package=oldversion` to restore previous versions
|
||||
|
||||
### Example Usage
|
||||
|
||||
```rust
|
||||
use apt_wrapper::AptTransaction;
|
||||
|
||||
// Create and commit a transaction
|
||||
let mut tx = AptTransaction::new()?;
|
||||
tx.add_package("vim")?;
|
||||
tx.add_package("curl")?;
|
||||
tx.commit()?;
|
||||
|
||||
// Later, rollback the changes
|
||||
tx.rollback()?;
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
The rollback system uses a "fail fast" approach:
|
||||
|
||||
- **Critical errors** (package not found, permission denied) cause immediate failure
|
||||
- **Soft errors** (package already removed) generate warnings but continue
|
||||
- If a specific version can't be installed, the package is removed instead
|
||||
|
||||
### Limitations
|
||||
|
||||
This implementation is intentionally simple and handles the most common rollback scenarios:
|
||||
|
||||
- ✅ New package installations
|
||||
- ✅ Package upgrades with available previous versions
|
||||
- ✅ Mixed transactions (some new, some upgrades)
|
||||
|
||||
It does **not** handle:
|
||||
- Complex dependency changes
|
||||
- Configuration file modifications
|
||||
- System state beyond package versions
|
||||
- Concurrent package operations
|
||||
|
||||
For more advanced rollback scenarios, see [rollbacks-not-featured.md](rollbacks-not-featured.md).
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Data Structures
|
||||
|
||||
```rust
|
||||
pub struct AptTransaction {
|
||||
packages: Vec<String>,
|
||||
newly_installed: HashSet<String>, // packages that weren't installed before
|
||||
upgraded: HashMap<String, String>, // package -> old_version for upgrades
|
||||
}
|
||||
```
|
||||
|
||||
### Key Methods
|
||||
|
||||
- `add_package()`: Adds a package to the transaction
|
||||
- `commit()`: Installs packages and categorizes them as new vs upgraded
|
||||
- `rollback()`: Performs the two-phase rollback process
|
||||
- `changed_packages()`: Returns a list of what was changed
|
||||
|
||||
### State Management
|
||||
|
||||
The tracking fields (`newly_installed`, `upgraded`) are only populated after a successful `commit()`. This ensures the rollback data reflects what actually happened during the transaction.
|
||||
|
||||
## Testing
|
||||
|
||||
The rollback functionality is tested in the test suite, but actual package installation/removal is not performed in tests to avoid side effects on the test environment.
|
||||
77
examples/configuration_usage.rs
Normal file
77
examples/configuration_usage.rs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
//! Example demonstrating APT wrapper configuration options
|
||||
//!
|
||||
//! This example shows how to use the various configuration options
|
||||
//! available in the APT wrapper.
|
||||
|
||||
use apt_wrapper::{AptTransaction, AptConfig, init};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("=== APT Wrapper Configuration Example ===");
|
||||
|
||||
// Initialize
|
||||
init()?;
|
||||
println!("✓ APT wrapper initialized");
|
||||
|
||||
// Example 1: Default configuration
|
||||
println!("\n--- Example 1: Default Configuration ---");
|
||||
let mut tx1 = AptTransaction::new()?;
|
||||
tx1.add_package("apt")?;
|
||||
println!("✓ Created transaction with default config");
|
||||
println!(" Config: {:?}", tx1.config());
|
||||
|
||||
// Example 2: Custom configuration
|
||||
println!("\n--- Example 2: Custom Configuration ---");
|
||||
let config = AptConfig {
|
||||
no_install_recommends: true,
|
||||
no_install_suggests: true,
|
||||
assume_yes: true,
|
||||
quiet: false,
|
||||
dry_run: true,
|
||||
};
|
||||
|
||||
let mut tx2 = AptTransaction::with_config(config)?;
|
||||
tx2.add_package("curl")?;
|
||||
println!("✓ Created transaction with custom config");
|
||||
println!(" Config: {:?}", tx2.config());
|
||||
|
||||
// Example 3: Testing configuration
|
||||
println!("\n--- Example 3: Testing Configuration ---");
|
||||
let mut tx3 = AptTransaction::for_testing()?;
|
||||
tx3.add_package("vim")?;
|
||||
println!("✓ Created transaction for testing");
|
||||
println!(" Config: {:?}", tx3.config());
|
||||
|
||||
// Example 4: Runtime configuration changes
|
||||
println!("\n--- Example 4: Runtime Configuration Changes ---");
|
||||
let mut tx4 = AptTransaction::new()?;
|
||||
tx4.add_package("git")?;
|
||||
|
||||
// Enable dry run mode
|
||||
tx4.enable_dry_run();
|
||||
println!("✓ Enabled dry run mode");
|
||||
|
||||
// Enable quiet mode
|
||||
tx4.enable_quiet();
|
||||
println!("✓ Enabled quiet mode");
|
||||
|
||||
// Disable dry run mode
|
||||
tx4.disable_dry_run();
|
||||
println!("✓ Disabled dry run mode");
|
||||
|
||||
println!(" Final config: {:?}", tx4.config());
|
||||
|
||||
// Example 5: Configuration validation
|
||||
println!("\n--- Example 5: Configuration Validation ---");
|
||||
let mut tx5 = AptTransaction::new()?;
|
||||
tx5.add_package("apt")?;
|
||||
|
||||
// Resolve dependencies (this will validate packages exist)
|
||||
tx5.resolve()?;
|
||||
println!("✓ Dependencies resolved successfully");
|
||||
|
||||
// Note: We don't actually commit in examples to avoid installing packages
|
||||
println!("\n=== Example completed ===");
|
||||
println!("Note: Transactions were not committed to avoid installing packages");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
180
src/apt_commands.rs
Normal file
180
src/apt_commands.rs
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
//! APT command execution utilities
|
||||
//!
|
||||
//! Centralized logic for executing APT commands with consistent error handling.
|
||||
|
||||
use std::process::Command;
|
||||
use anyhow::{Result, anyhow, Context};
|
||||
|
||||
/// Configuration options for APT commands
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AptConfig {
|
||||
/// Don't install recommended packages
|
||||
pub no_install_recommends: bool,
|
||||
/// Don't install suggested packages
|
||||
pub no_install_suggests: bool,
|
||||
/// Assume yes to all prompts
|
||||
pub assume_yes: bool,
|
||||
/// Quiet output
|
||||
pub quiet: bool,
|
||||
/// Dry run mode (don't actually execute)
|
||||
pub dry_run: bool,
|
||||
}
|
||||
|
||||
impl AptConfig {
|
||||
/// Create a new configuration with default values
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Create a configuration for testing (dry run, quiet)
|
||||
pub fn for_testing() -> Self {
|
||||
Self {
|
||||
dry_run: true,
|
||||
quiet: true,
|
||||
assume_yes: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute an APT command with the given configuration
|
||||
pub fn execute_apt_command(
|
||||
subcommand: &str,
|
||||
args: &[&str],
|
||||
config: &AptConfig,
|
||||
) -> Result<std::process::Output> {
|
||||
let mut cmd = Command::new("apt");
|
||||
|
||||
// Add subcommand
|
||||
cmd.arg(subcommand);
|
||||
|
||||
// Add configuration flags
|
||||
if config.assume_yes {
|
||||
cmd.arg("-y");
|
||||
}
|
||||
|
||||
if config.quiet {
|
||||
cmd.arg("-q");
|
||||
}
|
||||
|
||||
if config.no_install_recommends {
|
||||
cmd.arg("--no-install-recommends");
|
||||
}
|
||||
|
||||
if config.no_install_suggests {
|
||||
cmd.arg("--no-install-suggests");
|
||||
}
|
||||
|
||||
if config.dry_run {
|
||||
cmd.arg("--dry-run");
|
||||
}
|
||||
|
||||
// Add command arguments
|
||||
cmd.args(args);
|
||||
|
||||
// Execute command
|
||||
let output = cmd.output()
|
||||
.context(format!("Failed to execute APT {} command", subcommand))?;
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Execute a dpkg command
|
||||
pub fn execute_dpkg_command(args: &[&str]) -> Result<std::process::Output> {
|
||||
let output = Command::new("dpkg")
|
||||
.args(args)
|
||||
.output()
|
||||
.context("Failed to execute dpkg command")?;
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Check if a package exists in the APT repositories
|
||||
pub fn package_exists(package_name: &str) -> Result<bool> {
|
||||
let output = execute_apt_command("show", &[package_name], &AptConfig::new())?;
|
||||
Ok(output.status.success())
|
||||
}
|
||||
|
||||
/// Get package information from APT
|
||||
pub fn get_package_info(package_name: &str) -> Result<String> {
|
||||
let output = execute_apt_command("show", &[package_name], &AptConfig::new())?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!("Package not found: {}", package_name));
|
||||
}
|
||||
|
||||
String::from_utf8(output.stdout)
|
||||
.context("Failed to parse APT output as UTF-8")
|
||||
}
|
||||
|
||||
/// Check if a package is installed using dpkg
|
||||
pub fn is_package_installed(package_name: &str) -> Result<bool> {
|
||||
let output = execute_dpkg_command(&["-l", package_name])?;
|
||||
Ok(output.status.success())
|
||||
}
|
||||
|
||||
/// Get installed package versions
|
||||
pub fn get_installed_versions(packages: &[String]) -> Result<std::collections::HashMap<String, String>> {
|
||||
let mut versions = std::collections::HashMap::new();
|
||||
|
||||
for package in packages {
|
||||
let output = execute_dpkg_command(&["-l", package])?;
|
||||
|
||||
if output.status.success() {
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// Parse dpkg output to extract version
|
||||
// Format: "ii package-name version architecture description"
|
||||
for line in output_str.lines() {
|
||||
if line.starts_with("ii") && line.contains(package) {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 3 {
|
||||
versions.insert(package.clone(), parts[2].to_string());
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(versions)
|
||||
}
|
||||
|
||||
/// Install packages with the given configuration
|
||||
pub fn install_packages(packages: &[String], config: &AptConfig) -> Result<()> {
|
||||
let output = execute_apt_command("install", &packages.iter().map(|s| s.as_str()).collect::<Vec<_>>(), config)?;
|
||||
|
||||
if !output.status.success() {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow!("APT installation failed: {}", error)
|
||||
.context(format!("Failed to install packages: {:?}", packages)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove packages with the given configuration
|
||||
pub fn remove_packages(packages: &[String], config: &AptConfig) -> Result<()> {
|
||||
let output = execute_apt_command("remove", &packages.iter().map(|s| s.as_str()).collect::<Vec<_>>(), config)?;
|
||||
|
||||
if !output.status.success() {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow!("APT removal failed: {}", error)
|
||||
.context(format!("Failed to remove packages: {:?}", packages)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Downgrade packages to specific versions
|
||||
pub fn downgrade_packages(packages_with_versions: &[String], config: &AptConfig) -> Result<()> {
|
||||
let output = execute_apt_command("install", &packages_with_versions.iter().map(|s| s.as_str()).collect::<Vec<_>>(), config)?;
|
||||
|
||||
if !output.status.success() {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow!("APT downgrade failed: {}", error)
|
||||
.context(format!("Failed to downgrade packages: {:?}", packages_with_versions)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
25
src/error.rs
25
src/error.rs
|
|
@ -1,25 +1,4 @@
|
|||
//! Simple error types for APT wrapper
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// APT wrapper error types
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AptError {
|
||||
#[error("Package not found: {0}")]
|
||||
PackageNotFound(String),
|
||||
|
||||
#[error("APT command failed: {0}")]
|
||||
AptCommandFailed(String),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("UTF-8 error: {0}")]
|
||||
Utf8(#[from] std::string::FromUtf8Error),
|
||||
|
||||
#[error("Generic error: {0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// Result type alias
|
||||
pub type AptResult<T> = Result<T, AptError>;
|
||||
// This module is kept for future use but currently unused
|
||||
// All functions use anyhow::Result for simplicity
|
||||
|
|
|
|||
33
src/lib.rs
33
src/lib.rs
|
|
@ -3,15 +3,17 @@
|
|||
//! Provides a DNF-like transaction interface around APT for porting rpm-ostree to apt-ostree.
|
||||
|
||||
use std::process::Command;
|
||||
use anyhow::{Result, anyhow};
|
||||
use anyhow::{Result, anyhow, Context};
|
||||
|
||||
pub mod transaction;
|
||||
pub mod package;
|
||||
pub mod error;
|
||||
pub mod apt_commands;
|
||||
|
||||
pub use transaction::AptTransaction;
|
||||
pub use package::AptPackage;
|
||||
pub use error::{AptError, AptResult};
|
||||
pub use apt_commands::AptConfig;
|
||||
// Error types are currently unused - using anyhow::Result for simplicity
|
||||
|
||||
/// Initialize the APT wrapper system
|
||||
pub fn init() -> Result<()> {
|
||||
|
|
@ -19,10 +21,11 @@ pub fn init() -> Result<()> {
|
|||
let output = Command::new("apt")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map_err(|_| anyhow!("APT not found in PATH"))?;
|
||||
.context("Failed to execute APT command - is APT installed?")?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!("APT not working properly"));
|
||||
return Err(anyhow!("APT not working properly")
|
||||
.context("APT command failed during initialization"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
@ -32,13 +35,16 @@ pub fn init() -> Result<()> {
|
|||
pub fn search_packages(query: &str) -> Result<Vec<String>> {
|
||||
let output = Command::new("apt")
|
||||
.args(&["search", "--names-only", query])
|
||||
.output()?;
|
||||
.output()
|
||||
.context("Failed to execute APT search command")?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!("Package search failed"));
|
||||
return Err(anyhow!("Package search failed")
|
||||
.context(format!("Search query '{}' failed", query)));
|
||||
}
|
||||
|
||||
let packages: Vec<String> = String::from_utf8(output.stdout)?
|
||||
let packages: Vec<String> = String::from_utf8(output.stdout)
|
||||
.context("Failed to parse APT search output")?
|
||||
.lines()
|
||||
.filter(|line| !line.is_empty() && !line.starts_with("Sorting"))
|
||||
.map(|line| line.split('/').next().unwrap_or(line).to_string())
|
||||
|
|
@ -51,7 +57,8 @@ pub fn search_packages(query: &str) -> Result<Vec<String>> {
|
|||
pub fn is_package_installed(name: &str) -> Result<bool> {
|
||||
let output = Command::new("dpkg")
|
||||
.args(&["-l", name])
|
||||
.output()?;
|
||||
.output()
|
||||
.context("Failed to execute dpkg command")?;
|
||||
|
||||
Ok(output.status.success())
|
||||
}
|
||||
|
|
@ -60,12 +67,16 @@ pub fn is_package_installed(name: &str) -> Result<bool> {
|
|||
pub fn get_package_info(name: &str) -> Result<AptPackage> {
|
||||
let output = Command::new("apt")
|
||||
.args(&["show", name])
|
||||
.output()?;
|
||||
.output()
|
||||
.context("Failed to execute APT show command")?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!("Package not found: {}", name));
|
||||
return Err(anyhow!("Package not found: {}", name)
|
||||
.context(format!("Failed to get information for package '{}'", name)));
|
||||
}
|
||||
|
||||
let info = String::from_utf8(output.stdout)?;
|
||||
let info = String::from_utf8(output.stdout)
|
||||
.context("Failed to parse APT show output")?;
|
||||
AptPackage::from_apt_show(&info)
|
||||
.context(format!("Failed to parse package information for '{}'", name))
|
||||
}
|
||||
|
|
@ -29,7 +29,8 @@ impl AptPackage {
|
|||
}
|
||||
|
||||
if name.is_empty() {
|
||||
return Err(anyhow!("Invalid package information"));
|
||||
return Err(anyhow!("Invalid package information")
|
||||
.context("Package name is empty in apt show output"));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
|
|
|
|||
|
|
@ -2,36 +2,55 @@
|
|||
//!
|
||||
//! Provides a DNF-like transaction interface for APT operations.
|
||||
|
||||
use std::process::Command;
|
||||
use anyhow::{Result, anyhow};
|
||||
use std::collections::HashMap;
|
||||
use anyhow::{Result, anyhow, Context};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use crate::apt_commands::{AptConfig, package_exists, get_installed_versions, install_packages, remove_packages, downgrade_packages};
|
||||
|
||||
/// Simple APT transaction that mimics DNF's imperative model
|
||||
pub struct AptTransaction {
|
||||
packages: Vec<String>,
|
||||
before_versions: HashMap<String, String>, // package -> version before
|
||||
after_versions: HashMap<String, String>, // package -> version after
|
||||
newly_installed: HashSet<String>, // packages that weren't installed before
|
||||
upgraded: HashMap<String, String>, // package -> old_version for packages that were upgraded
|
||||
config: AptConfig, // APT configuration options
|
||||
}
|
||||
|
||||
impl AptTransaction {
|
||||
/// Create a new transaction
|
||||
/// Create a new transaction with default configuration
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(Self {
|
||||
packages: Vec::new(),
|
||||
before_versions: HashMap::new(),
|
||||
after_versions: HashMap::new(),
|
||||
newly_installed: HashSet::new(),
|
||||
upgraded: HashMap::new(),
|
||||
config: AptConfig::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new transaction with custom configuration
|
||||
pub fn with_config(config: AptConfig) -> Result<Self> {
|
||||
Ok(Self {
|
||||
packages: Vec::new(),
|
||||
newly_installed: HashSet::new(),
|
||||
upgraded: HashMap::new(),
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new transaction for testing (dry run mode)
|
||||
pub fn for_testing() -> Result<Self> {
|
||||
Ok(Self {
|
||||
packages: Vec::new(),
|
||||
newly_installed: HashSet::new(),
|
||||
upgraded: HashMap::new(),
|
||||
config: AptConfig::for_testing(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Add a package to the transaction
|
||||
pub fn add_package(&mut self, name: &str) -> Result<()> {
|
||||
// Verify package exists
|
||||
let output = Command::new("apt")
|
||||
.args(&["show", name])
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!("Package not found: {}", name));
|
||||
if !package_exists(name)? {
|
||||
return Err(anyhow!("Package not found: {}", name)
|
||||
.context(format!("Failed to add package '{}' to transaction", name)));
|
||||
}
|
||||
|
||||
self.packages.push(name.to_string());
|
||||
|
|
@ -43,12 +62,9 @@ impl AptTransaction {
|
|||
// APT handles dependency resolution automatically
|
||||
// Just validate all packages are available
|
||||
for package in &self.packages {
|
||||
let output = Command::new("apt")
|
||||
.args(&["show", package])
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!("Package unavailable: {}", package));
|
||||
if !package_exists(package)? {
|
||||
return Err(anyhow!("Package unavailable: {}", package)
|
||||
.context(format!("Failed to resolve dependencies for package '{}'", package)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -61,90 +77,74 @@ impl AptTransaction {
|
|||
}
|
||||
|
||||
// Get current versions before installation
|
||||
self.before_versions = self.get_package_versions()?;
|
||||
let before_versions = get_installed_versions(&self.packages)
|
||||
.context("Failed to get package versions before installation")?;
|
||||
|
||||
// Run apt install with all packages
|
||||
let output = Command::new("apt")
|
||||
.args(&["install", "-y"])
|
||||
.args(&self.packages)
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow!("APT installation failed: {}", error));
|
||||
}
|
||||
// Install packages using centralized logic
|
||||
install_packages(&self.packages, &self.config)
|
||||
.context("Failed to install packages")?;
|
||||
|
||||
// Get versions after installation
|
||||
self.after_versions = self.get_package_versions()?;
|
||||
let after_versions = get_installed_versions(&self.packages)
|
||||
.context("Failed to get package versions after installation")?;
|
||||
|
||||
// Categorize packages: newly installed vs upgraded
|
||||
self.newly_installed.clear();
|
||||
self.upgraded.clear();
|
||||
|
||||
for package in &self.packages {
|
||||
if let Some(before_version) = before_versions.get(package) {
|
||||
if let Some(after_version) = after_versions.get(package) {
|
||||
if before_version != after_version {
|
||||
// Package was upgraded
|
||||
self.upgraded.insert(package.clone(), before_version.clone());
|
||||
}
|
||||
// If versions are the same, it was already up to date - no rollback needed
|
||||
}
|
||||
} else {
|
||||
// Package was newly installed
|
||||
self.newly_installed.insert(package.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rollback the transaction by restoring previous versions
|
||||
pub fn rollback(&self) -> Result<()> {
|
||||
if self.before_versions.is_empty() {
|
||||
if self.newly_installed.is_empty() && self.upgraded.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Build list of packages to restore to previous versions
|
||||
let mut packages_to_restore = Vec::new();
|
||||
|
||||
for (package, before_version) in &self.before_versions {
|
||||
if let Some(after_version) = self.after_versions.get(package) {
|
||||
// Only rollback if version changed
|
||||
if before_version != after_version {
|
||||
packages_to_restore.push(format!("{}={}", package, before_version));
|
||||
}
|
||||
} else {
|
||||
// Package was newly installed, remove it
|
||||
packages_to_restore.push(format!("{}", package));
|
||||
}
|
||||
// Phase 1: Remove newly installed packages
|
||||
if !self.newly_installed.is_empty() {
|
||||
let packages_to_remove: Vec<String> = self.newly_installed.iter().cloned().collect();
|
||||
remove_packages(&packages_to_remove, &self.config)
|
||||
.context("Failed to remove newly installed packages")?;
|
||||
}
|
||||
|
||||
if packages_to_restore.is_empty() {
|
||||
return Ok(());
|
||||
// Phase 2: Downgrade upgraded packages
|
||||
if !self.upgraded.is_empty() {
|
||||
let mut packages_to_downgrade = Vec::new();
|
||||
for (package, old_version) in &self.upgraded {
|
||||
packages_to_downgrade.push(format!("{}={}", package, old_version));
|
||||
}
|
||||
|
||||
// Restore previous versions or remove newly installed packages
|
||||
let output = Command::new("apt")
|
||||
.args(&["install", "-y"])
|
||||
.args(&packages_to_restore)
|
||||
.output()?;
|
||||
// Try to downgrade first
|
||||
if let Err(downgrade_error) = downgrade_packages(&packages_to_downgrade, &self.config) {
|
||||
// If downgrade fails, just remove the package (fail gracefully)
|
||||
eprintln!("Warning: Could not downgrade packages, removing instead: {}", downgrade_error);
|
||||
|
||||
if !output.status.success() {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow!("APT rollback failed: {}", error));
|
||||
let packages_to_remove: Vec<String> = self.upgraded.keys().cloned().collect();
|
||||
if let Err(remove_error) = remove_packages(&packages_to_remove, &self.config) {
|
||||
eprintln!("Warning: Could not remove packages: {}", remove_error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get current package versions
|
||||
fn get_package_versions(&self) -> Result<HashMap<String, String>> {
|
||||
let output = Command::new("dpkg")
|
||||
.args(&["-l"])
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!("Failed to get package versions"));
|
||||
}
|
||||
|
||||
let mut versions = HashMap::new();
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
for line in output_str.lines() {
|
||||
if line.starts_with("ii") {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 3 {
|
||||
let package_name = parts[1];
|
||||
let version = parts[2];
|
||||
versions.insert(package_name.to_string(), version.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(versions)
|
||||
}
|
||||
|
||||
/// Get list of packages in transaction
|
||||
pub fn packages(&self) -> &[String] {
|
||||
|
|
@ -160,25 +160,46 @@ impl AptTransaction {
|
|||
pub fn changed_packages(&self) -> Vec<String> {
|
||||
let mut changed = Vec::new();
|
||||
|
||||
for (package, before_version) in &self.before_versions {
|
||||
if let Some(after_version) = self.after_versions.get(package) {
|
||||
if before_version != after_version {
|
||||
changed.push(format!("{}: {} -> {}", package, before_version, after_version));
|
||||
}
|
||||
} else {
|
||||
changed.push(format!("{}: removed", package));
|
||||
}
|
||||
// Add newly installed packages
|
||||
for package in &self.newly_installed {
|
||||
changed.push(format!("{}: newly installed", package));
|
||||
}
|
||||
|
||||
// Add newly installed packages
|
||||
for package in &self.packages {
|
||||
if !self.before_versions.contains_key(package) {
|
||||
if let Some(after_version) = self.after_versions.get(package) {
|
||||
changed.push(format!("{}: installed {}", package, after_version));
|
||||
}
|
||||
}
|
||||
// Add upgraded packages
|
||||
for (package, old_version) in &self.upgraded {
|
||||
changed.push(format!("{}: upgraded from {}", package, old_version));
|
||||
}
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
/// Get the current APT configuration
|
||||
pub fn config(&self) -> &AptConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// Update the APT configuration
|
||||
pub fn set_config(&mut self, config: AptConfig) {
|
||||
self.config = config;
|
||||
}
|
||||
|
||||
/// Enable dry run mode
|
||||
pub fn enable_dry_run(&mut self) {
|
||||
self.config.dry_run = true;
|
||||
}
|
||||
|
||||
/// Disable dry run mode
|
||||
pub fn disable_dry_run(&mut self) {
|
||||
self.config.dry_run = false;
|
||||
}
|
||||
|
||||
/// Enable quiet mode
|
||||
pub fn enable_quiet(&mut self) {
|
||||
self.config.quiet = true;
|
||||
}
|
||||
|
||||
/// Disable quiet mode
|
||||
pub fn disable_quiet(&mut self) {
|
||||
self.config.quiet = false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! Basic tests for APT wrapper
|
||||
|
||||
use apt_wrapper::{AptTransaction, init, search_packages};
|
||||
use apt_wrapper::{AptTransaction, init};
|
||||
|
||||
#[test]
|
||||
fn test_transaction_creation() {
|
||||
|
|
@ -47,10 +47,11 @@ fn test_transaction_rollback_tracking() {
|
|||
let result = tx.add_package("apt");
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Check that changed_packages is initially empty
|
||||
// Check that changed_packages is initially empty (before commit)
|
||||
assert!(tx.changed_packages().is_empty());
|
||||
|
||||
// Note: We don't actually commit in tests to avoid installing packages
|
||||
// In real usage, after commit(), changed_packages would contain
|
||||
// the packages that were changed (installed/upgraded)
|
||||
// The tracking fields (newly_installed, upgraded) are only populated after commit()
|
||||
}
|
||||
|
|
|
|||
136
tests/error_scenarios.rs
Normal file
136
tests/error_scenarios.rs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
//! Error scenario tests for APT wrapper
|
||||
//!
|
||||
//! These tests verify error handling in various failure conditions.
|
||||
|
||||
use apt_wrapper::{AptTransaction, init};
|
||||
|
||||
#[test]
|
||||
fn test_invalid_package_names() {
|
||||
let mut tx = AptTransaction::new().expect("Failed to create transaction");
|
||||
|
||||
// Test various invalid package names
|
||||
let invalid_names = vec![
|
||||
"", // Empty string
|
||||
" ", // Whitespace only
|
||||
"package-with-invalid-chars!@#", // Special characters
|
||||
"very-long-package-name-that-is-unlikely-to-exist-and-should-fail-validation-123456789", // Too long
|
||||
];
|
||||
|
||||
for invalid_name in invalid_names {
|
||||
let result = tx.add_package(invalid_name);
|
||||
assert!(result.is_err(), "Should fail for invalid package name: '{}'", invalid_name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nonexistent_packages() {
|
||||
let mut tx = AptTransaction::new().expect("Failed to create transaction");
|
||||
|
||||
// Test packages that definitely don't exist
|
||||
let nonexistent_packages = vec![
|
||||
"this-package-definitely-does-not-exist-12345",
|
||||
"another-nonexistent-package-67890",
|
||||
"fake-package-name-xyz",
|
||||
];
|
||||
|
||||
for package in nonexistent_packages {
|
||||
let result = tx.add_package(package);
|
||||
assert!(result.is_err(), "Should fail for nonexistent package: {}", package);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transaction_error_recovery() {
|
||||
let mut tx = AptTransaction::new().expect("Failed to create transaction");
|
||||
|
||||
// Add a valid package first
|
||||
tx.add_package("apt").expect("Failed to add valid package");
|
||||
assert_eq!(tx.packages().len(), 1);
|
||||
|
||||
// Try to add an invalid package
|
||||
let result = tx.add_package("nonexistent-package-12345");
|
||||
assert!(result.is_err());
|
||||
|
||||
// Transaction should still be valid with the good package
|
||||
assert_eq!(tx.packages().len(), 1);
|
||||
assert!(!tx.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rollback_empty_transaction() {
|
||||
let tx = AptTransaction::new().expect("Failed to create transaction");
|
||||
|
||||
// Rollback of empty transaction should succeed
|
||||
tx.rollback().expect("Rollback of empty transaction should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rollback_without_commit() {
|
||||
let mut tx = AptTransaction::new().expect("Failed to create transaction");
|
||||
|
||||
// Add packages but don't commit
|
||||
tx.add_package("apt").expect("Failed to add package");
|
||||
|
||||
// Rollback should succeed (nothing to rollback)
|
||||
tx.rollback().expect("Rollback without commit should succeed");
|
||||
|
||||
// Changed packages should be empty
|
||||
assert!(tx.changed_packages().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_duplicate_package_handling() {
|
||||
let mut tx = AptTransaction::new().expect("Failed to create transaction");
|
||||
|
||||
// Add the same package twice
|
||||
tx.add_package("apt").expect("Failed to add package first time");
|
||||
tx.add_package("apt").expect("Failed to add package second time");
|
||||
|
||||
// Current implementation allows duplicates (this is expected behavior)
|
||||
assert_eq!(tx.packages().len(), 2);
|
||||
|
||||
// Both instances should be the same package
|
||||
assert_eq!(tx.packages()[0], "apt");
|
||||
assert_eq!(tx.packages()[1], "apt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transaction_state_after_errors() {
|
||||
let mut tx = AptTransaction::new().expect("Failed to create transaction");
|
||||
|
||||
// Add valid packages
|
||||
tx.add_package("apt").expect("Failed to add apt");
|
||||
tx.add_package("curl").expect("Failed to add curl");
|
||||
|
||||
// Try to add invalid package
|
||||
let _ = tx.add_package("invalid-package-12345");
|
||||
|
||||
// Transaction should still be valid
|
||||
assert_eq!(tx.packages().len(), 2);
|
||||
assert!(!tx.is_empty());
|
||||
|
||||
// Resolve should still work
|
||||
tx.resolve().expect("Resolve should work after error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_messages_are_descriptive() {
|
||||
let mut tx = AptTransaction::new().expect("Failed to create transaction");
|
||||
|
||||
// Test that error messages contain useful information
|
||||
let result = tx.add_package("nonexistent-package-12345");
|
||||
assert!(result.is_err());
|
||||
|
||||
let error_msg = format!("{}", result.unwrap_err());
|
||||
assert!(error_msg.contains("Package not found") || error_msg.contains("nonexistent-package-12345"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_initialization_error_handling() {
|
||||
// This test verifies that init() handles errors gracefully
|
||||
// In a real environment, this should work, but we test the error path
|
||||
let result = init();
|
||||
// We can't assert success since APT might not be available in test environment
|
||||
// Just ensure it doesn't panic
|
||||
let _ = result;
|
||||
}
|
||||
106
tests/integration.rs
Normal file
106
tests/integration.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
//! Integration tests for APT wrapper
|
||||
//!
|
||||
//! These tests verify the full transaction lifecycle including commit and rollback.
|
||||
//! They use safe, existing packages to avoid affecting the system.
|
||||
|
||||
use apt_wrapper::{AptTransaction, init};
|
||||
|
||||
#[test]
|
||||
fn test_transaction_state_consistency() {
|
||||
// Initialize
|
||||
init().expect("Failed to initialize APT wrapper");
|
||||
|
||||
let mut tx = AptTransaction::new().expect("Failed to create transaction");
|
||||
|
||||
// Before commit, changed_packages should be empty
|
||||
assert!(tx.changed_packages().is_empty());
|
||||
|
||||
// Add a safe package that should exist
|
||||
tx.add_package("apt").expect("Failed to add apt package");
|
||||
|
||||
// Still empty before commit
|
||||
assert!(tx.changed_packages().is_empty());
|
||||
|
||||
// Resolve dependencies
|
||||
tx.resolve().expect("Failed to resolve dependencies");
|
||||
|
||||
// Still empty before commit
|
||||
assert!(tx.changed_packages().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_transaction_rollback() {
|
||||
let tx = AptTransaction::new().expect("Failed to create transaction");
|
||||
|
||||
// Rollback empty transaction should succeed
|
||||
tx.rollback().expect("Failed to rollback empty transaction");
|
||||
|
||||
// Changed packages should be empty
|
||||
assert!(tx.changed_packages().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transaction_creation_and_validation() {
|
||||
let mut tx = AptTransaction::new().expect("Failed to create transaction");
|
||||
|
||||
// Initially empty
|
||||
assert!(tx.is_empty());
|
||||
assert_eq!(tx.packages().len(), 0);
|
||||
|
||||
// Add a package
|
||||
tx.add_package("apt").expect("Failed to add package");
|
||||
assert!(!tx.is_empty());
|
||||
assert_eq!(tx.packages().len(), 1);
|
||||
|
||||
// Add another package
|
||||
tx.add_package("curl").expect("Failed to add second package");
|
||||
assert_eq!(tx.packages().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_package_validation() {
|
||||
let mut tx = AptTransaction::new().expect("Failed to create transaction");
|
||||
|
||||
// Valid package should succeed
|
||||
tx.add_package("apt").expect("Failed to add valid package");
|
||||
|
||||
// Invalid package should fail
|
||||
let result = tx.add_package("this-package-definitely-does-not-exist-12345");
|
||||
assert!(result.is_err());
|
||||
|
||||
// Transaction should still have the valid package
|
||||
assert_eq!(tx.packages().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_dependencies() {
|
||||
let mut tx = AptTransaction::new().expect("Failed to create transaction");
|
||||
|
||||
// Add a package
|
||||
tx.add_package("apt").expect("Failed to add package");
|
||||
|
||||
// Resolve should succeed
|
||||
tx.resolve().expect("Failed to resolve dependencies");
|
||||
|
||||
// Transaction should still be valid
|
||||
assert!(!tx.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_changed_packages_tracking() {
|
||||
let mut tx = AptTransaction::new().expect("Failed to create transaction");
|
||||
|
||||
// Before any operations
|
||||
assert!(tx.changed_packages().is_empty());
|
||||
|
||||
// After adding packages but before commit
|
||||
tx.add_package("apt").expect("Failed to add package");
|
||||
assert!(tx.changed_packages().is_empty());
|
||||
|
||||
// After resolve but before commit
|
||||
tx.resolve().expect("Failed to resolve dependencies");
|
||||
assert!(tx.changed_packages().is_empty());
|
||||
|
||||
// Note: We don't actually commit in tests to avoid installing packages
|
||||
// In real usage, changed_packages would be populated after commit()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue