chore: initial commit of docker-tools
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
node_modules/
|
||||
.venv/
|
||||
201
bash/DEBUGGING.md
Executable file
201
bash/DEBUGGING.md
Executable file
@@ -0,0 +1,201 @@
|
||||
# Debugging and Optimization Guide
|
||||
|
||||
This document describes the debugging and optimization features added to the docker-tools bash scripts.
|
||||
|
||||
## Debug Mode
|
||||
|
||||
Enable comprehensive debugging with the `--debug` or `-d` flag:
|
||||
|
||||
```bash
|
||||
./docker-backup.sh --debug --all
|
||||
./docker-migrate.sh --debug --service myapp --dest user@host
|
||||
```
|
||||
|
||||
### What Debug Mode Does
|
||||
|
||||
1. **Enables `set -x` (xtrace)**: Shows every command executed with line numbers
|
||||
2. **Function tracing**: Shows function entry points with file:line information
|
||||
3. **Stack traces**: On errors, shows call stack
|
||||
4. **Detailed logging**: All operations log debug information
|
||||
5. **Verbose output**: Shows internal state and decisions
|
||||
|
||||
### Debug Output Format
|
||||
|
||||
```
|
||||
+ [script.sh:123] function_name(): Command being executed
|
||||
[DEBUG] script.sh:123 function_name()
|
||||
[DEBUG] Validating stack name: 'myapp'
|
||||
[DEBUG] Stack name validation passed
|
||||
```
|
||||
|
||||
## Verbose Mode
|
||||
|
||||
Enable verbose logging without full xtrace:
|
||||
|
||||
```bash
|
||||
./docker-backup.sh --verbose --all
|
||||
```
|
||||
|
||||
Verbose mode provides:
|
||||
- Function entry/exit logging
|
||||
- Operation details
|
||||
- State information
|
||||
- Without the command-level tracing of debug mode
|
||||
|
||||
## Debug Logging Functions
|
||||
|
||||
### `log_debug()`
|
||||
Logs debug messages (only shown in DEBUG or VERBOSE mode):
|
||||
```bash
|
||||
log_debug "Processing stack: $stack"
|
||||
```
|
||||
|
||||
### `log_verbose()`
|
||||
Logs verbose messages (only shown in VERBOSE mode):
|
||||
```bash
|
||||
log_verbose "Found ${#stacks[@]} stacks"
|
||||
```
|
||||
|
||||
### `debug_trace()`
|
||||
Automatically called in functions to show entry points:
|
||||
- Shows file, line number, and function name
|
||||
- Only active when DEBUG or VERBOSE is enabled
|
||||
|
||||
## Error Handling Improvements
|
||||
|
||||
### Better Error Messages
|
||||
- More descriptive error messages with context
|
||||
- Shows what was attempted and why it failed
|
||||
- Includes relevant variable values
|
||||
|
||||
### Exit Codes
|
||||
- Consistent exit codes across scripts
|
||||
- `0`: Success
|
||||
- `1`: General error
|
||||
- `2`: Invalid arguments
|
||||
- `3`: Dependency missing
|
||||
|
||||
### Error Stack Traces
|
||||
In DEBUG mode, errors show call stack:
|
||||
```
|
||||
[ERROR] Stack directory not found: /opt/stacks/myapp
|
||||
-> docker-backup.sh:248 backup_stack()
|
||||
-> docker-backup.sh:510 main()
|
||||
```
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
### Reduced Redundant Operations
|
||||
- Docker commands cached where appropriate
|
||||
- Stack discovery optimized
|
||||
- Parallel operations properly throttled
|
||||
|
||||
### Improved Logging
|
||||
- Log file writes are non-blocking
|
||||
- Fallback to /tmp if primary log location fails
|
||||
- Automatic log directory creation
|
||||
|
||||
### Spinner Optimization
|
||||
- Spinner disabled in DEBUG mode (would interfere with output)
|
||||
- Proper cleanup on exit
|
||||
|
||||
## Common Debugging Scenarios
|
||||
|
||||
### Stack Not Found
|
||||
```bash
|
||||
DEBUG=true ./docker-backup.sh --stack myapp
|
||||
# Shows:
|
||||
# [DEBUG] Discovering stacks in: /opt/stacks
|
||||
# [DEBUG] Found directory: myapp
|
||||
# [DEBUG] Validating stack name: 'myapp'
|
||||
# [ERROR] Stack directory not found: /opt/stacks/myapp
|
||||
```
|
||||
|
||||
### Docker Connection Issues
|
||||
```bash
|
||||
DEBUG=true ./docker-backup.sh --all
|
||||
# Shows:
|
||||
# [DEBUG] Checking Docker installation and daemon status
|
||||
# [DEBUG] Docker command found: /usr/bin/docker
|
||||
# [DEBUG] Docker version: Docker version 24.0.5
|
||||
# [ERROR] Docker daemon not running or not accessible.
|
||||
# [DEBUG] Attempting to get more details...
|
||||
```
|
||||
|
||||
### Transfer Failures
|
||||
```bash
|
||||
DEBUG=true ./docker-migrate.sh --service myapp --dest user@host
|
||||
# Shows:
|
||||
# [DEBUG] Executing SSH command: host=host, port=22, user=user
|
||||
# [DEBUG] SSH connection: user@host:22
|
||||
# [DEBUG] SSH command exit code: 255
|
||||
# [ERROR] SSH connection failed
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
You can also set debug mode via environment variables:
|
||||
|
||||
```bash
|
||||
export DEBUG=true
|
||||
./docker-backup.sh --all
|
||||
|
||||
export VERBOSE=true
|
||||
./docker-migrate.sh --service myapp --dest user@host
|
||||
```
|
||||
|
||||
## Log Files
|
||||
|
||||
Debug information is written to log files:
|
||||
- Default: `/tmp/docwell/docwell.log`
|
||||
- Script-specific: `/tmp/docwell/docker-{script}.log`
|
||||
- Fallback: `/tmp/docwell/fallback.log` (if primary location fails)
|
||||
|
||||
Log format:
|
||||
```
|
||||
2025-02-18 10:30:45 [DEBUG] Validating stack name: 'myapp'
|
||||
2025-02-18 10:30:45 [INFO] Starting backup for myapp
|
||||
2025-02-18 10:30:46 [OK] Backup complete: myapp
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use DEBUG for troubleshooting**: When something fails unexpectedly
|
||||
2. **Use VERBOSE for monitoring**: When you want to see what's happening without full trace
|
||||
3. **Check log files**: Even without DEBUG, errors are logged
|
||||
4. **Combine with dry-run**: `--debug --dry-run` to see what would happen
|
||||
5. **Redirect output**: `--debug 2>debug.log` to save debug output
|
||||
|
||||
## Troubleshooting Tips
|
||||
|
||||
### Script Hangs
|
||||
- Enable DEBUG to see where it's stuck
|
||||
- Check for SSH timeouts or network issues
|
||||
- Look for spinner processes that didn't clean up
|
||||
|
||||
### Permission Errors
|
||||
- DEBUG shows exact commands being run
|
||||
- Check if sudo is needed (shown in debug output)
|
||||
- Verify file/directory permissions
|
||||
|
||||
### Unexpected Behavior
|
||||
- Compare DEBUG output with expected behavior
|
||||
- Check environment variables (shown at script start)
|
||||
- Verify configuration file values
|
||||
|
||||
## Performance Monitoring
|
||||
|
||||
With VERBOSE mode, you can see:
|
||||
- How many stacks were discovered
|
||||
- Time spent on each operation
|
||||
- Parallel job status
|
||||
- Resource usage
|
||||
|
||||
Example:
|
||||
```bash
|
||||
VERBOSE=true ./docker-backup.sh --all
|
||||
# Shows:
|
||||
# [VERBOSE] Found 5 directories, 5 valid stacks
|
||||
# [VERBOSE] Total stacks discovered: 5 (0 from docker ps)
|
||||
# [VERBOSE] Processing stack 1/5: myapp
|
||||
```
|
||||
165
bash/MIGRATION_NOTES.md
Executable file
165
bash/MIGRATION_NOTES.md
Executable file
@@ -0,0 +1,165 @@
|
||||
# Migration Notes: docker-stack.sh + docker-update.sh → docker-manager.sh
|
||||
|
||||
## Overview
|
||||
|
||||
The `docker-stack.sh` and `docker-update.sh` scripts have been combined into a single `docker-manager.sh` script that provides both stack lifecycle management and update functionality.
|
||||
|
||||
## Why Combine?
|
||||
|
||||
- **Logical grouping**: Stack management and updates are closely related operations
|
||||
- **Better UX**: Single entry point for all stack operations
|
||||
- **Reduced duplication**: Shared functions (get_stacks, is_stack_running, etc.)
|
||||
- **Consistent interface**: All stack operations in one place
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### Old Commands → New Commands
|
||||
|
||||
#### Stack Management
|
||||
```bash
|
||||
# Old
|
||||
docker-stack.sh --list
|
||||
docker-stack.sh --start myapp
|
||||
docker-stack.sh --stop myapp
|
||||
docker-stack.sh --restart myapp
|
||||
docker-stack.sh --status myapp
|
||||
docker-stack.sh --logs myapp
|
||||
|
||||
# New (same commands work!)
|
||||
docker-manager.sh --list
|
||||
docker-manager.sh --start myapp
|
||||
docker-manager.sh --stop myapp
|
||||
docker-manager.sh --restart myapp
|
||||
docker-manager.sh --status myapp
|
||||
docker-manager.sh --logs myapp
|
||||
```
|
||||
|
||||
#### Update Operations
|
||||
```bash
|
||||
# Old
|
||||
docker-update.sh --check
|
||||
docker-update.sh --all
|
||||
docker-update.sh --stack myapp
|
||||
docker-update.sh --auto
|
||||
|
||||
# New (slightly different flags)
|
||||
docker-manager.sh --check
|
||||
docker-manager.sh --update-all # was --all
|
||||
docker-manager.sh --update myapp # was --stack
|
||||
docker-manager.sh --auto
|
||||
```
|
||||
|
||||
### Flag Changes
|
||||
|
||||
| Old (docker-update.sh) | New (docker-manager.sh) | Notes |
|
||||
|------------------------|-------------------------|-------|
|
||||
| `--all` | `--update-all` or `-a` | More explicit |
|
||||
| `--stack STACK` | `--update STACK` or `-u` | More consistent |
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
The old scripts (`docker-stack.sh` and `docker-update.sh`) are still available and functional. They can be:
|
||||
- **Kept**: For backward compatibility with existing scripts
|
||||
- **Removed**: If you prefer a single unified script
|
||||
- **Aliased**: Create symlinks or aliases pointing to docker-manager.sh
|
||||
|
||||
### Creating Aliases (Optional)
|
||||
|
||||
If you want to keep using the old command names:
|
||||
|
||||
```bash
|
||||
# Create symlinks
|
||||
ln -s docker-manager.sh docker-stack.sh
|
||||
ln -s docker-manager.sh docker-update.sh
|
||||
|
||||
# Or create aliases in your shell config
|
||||
alias docker-stack='docker-manager.sh'
|
||||
alias docker-update='docker-manager.sh'
|
||||
```
|
||||
|
||||
### Interactive Mode
|
||||
|
||||
The combined script provides a unified interactive menu:
|
||||
|
||||
```
|
||||
Docker Stack Manager
|
||||
====================
|
||||
Available stacks:
|
||||
1) myapp [running]
|
||||
2) test-stack [stopped]
|
||||
|
||||
r) Restart all
|
||||
s) Stop all
|
||||
u) Update all # NEW: Update all stacks
|
||||
c) Check for updates # NEW: Check for updates
|
||||
0) Exit
|
||||
```
|
||||
|
||||
When selecting a stack, you get both management and update options:
|
||||
|
||||
```
|
||||
Manage: myapp
|
||||
=============
|
||||
Status: ● Running
|
||||
|
||||
Stack Management:
|
||||
1) Start
|
||||
2) Stop
|
||||
3) Restart
|
||||
|
||||
Updates:
|
||||
4) Update images # NEW: Update this stack
|
||||
5) Check for updates # NEW: Check this stack
|
||||
|
||||
Other:
|
||||
6) View logs
|
||||
7) View compose file
|
||||
0) Back
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Single Script**: One script to manage instead of two
|
||||
2. **Unified Interface**: Consistent CLI flags and behavior
|
||||
3. **Better Organization**: Related operations grouped together
|
||||
4. **Less Code Duplication**: Shared helper functions
|
||||
5. **Easier Maintenance**: One script to update instead of two
|
||||
|
||||
## Recommendations
|
||||
|
||||
- **New scripts**: Use `docker-manager.sh`
|
||||
- **Existing scripts**: Update to use `docker-manager.sh` when convenient
|
||||
- **Old scripts**: Can be removed once migration is complete
|
||||
|
||||
## Examples
|
||||
|
||||
### Combined Operations
|
||||
```bash
|
||||
# Check for updates, then update all if available
|
||||
docker-manager.sh --check
|
||||
docker-manager.sh --update-all
|
||||
|
||||
# Start a stack and update it
|
||||
docker-manager.sh --start myapp
|
||||
docker-manager.sh --update myapp
|
||||
|
||||
# Check status and updates in one go
|
||||
docker-manager.sh --list
|
||||
docker-manager.sh --check
|
||||
```
|
||||
|
||||
### Automation Scripts
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Combined stack management script
|
||||
|
||||
# List all stacks
|
||||
docker-manager.sh --list --quiet
|
||||
|
||||
# Check for updates
|
||||
if docker-manager.sh --check --quiet | grep -q "update available"; then
|
||||
# Update all stacks
|
||||
docker-manager.sh --update-all --yes --quiet
|
||||
fi
|
||||
```
|
||||
|
||||
161
bash/README.md
Executable file
161
bash/README.md
Executable file
@@ -0,0 +1,161 @@
|
||||
# Docker Tools - Bash Scripts
|
||||
|
||||
This directory contains enhanced bash scripts that match the functionality of the Go version (`docwell.go`). All scripts are version 2.6.2 and share a common library.
|
||||
|
||||
## Architecture
|
||||
|
||||
All scripts source `lib/common.sh`, which provides:
|
||||
- Color output with `NO_COLOR` support
|
||||
- Spinner/progress animations (braille frames)
|
||||
- Unified logging (`log_info`, `log_warn`, `log_error`, `log_success`, `log_header`)
|
||||
- Config file loading from `~/.config/docwell/config`
|
||||
- Compose file detection, stack discovery, validation
|
||||
- Cleanup traps (signal handling for Ctrl+C)
|
||||
- Dry-run wrapper (`run_or_dry`)
|
||||
- Bounded parallel execution
|
||||
- SSH helpers
|
||||
|
||||
> **Note**: `docker-stack.sh` and `docker-update.sh` have been combined into `docker-manager.sh`. The old scripts are kept for backward compatibility but `docker-manager.sh` is recommended for new usage.
|
||||
|
||||
## Scripts Overview
|
||||
|
||||
### docker-backup.sh
|
||||
Backup Docker stacks with compression.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
./docker-backup.sh --list # List stacks
|
||||
./docker-backup.sh --all # Backup all stacks
|
||||
./docker-backup.sh --stack myapp # Backup specific stack
|
||||
./docker-backup.sh --all --quiet --yes # Non-interactive backup
|
||||
./docker-backup.sh --all --dry-run # Preview without executing
|
||||
```
|
||||
|
||||
### docker-cleanup.sh
|
||||
Clean up Docker resources (containers, images, volumes, networks, build cache).
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
./docker-cleanup.sh --containers # Remove stopped containers
|
||||
./docker-cleanup.sh --all --yes # Run all cleanups
|
||||
./docker-cleanup.sh --images --volumes # Remove images and volumes
|
||||
./docker-cleanup.sh --all --dry-run # Preview cleanup
|
||||
```
|
||||
|
||||
### docker-manager.sh
|
||||
Combined stack management and update functionality.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
./docker-manager.sh --list # List all stacks
|
||||
./docker-manager.sh --start myapp # Start a stack
|
||||
./docker-manager.sh --stop myapp # Stop a stack
|
||||
./docker-manager.sh --check # Check for updates
|
||||
./docker-manager.sh --update-all # Update all stacks
|
||||
./docker-manager.sh --update myapp # Update specific stack
|
||||
./docker-manager.sh --auto --yes # Auto-update all stacks
|
||||
```
|
||||
|
||||
### docker-migrate.sh
|
||||
Migrate Docker services between servers.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
./docker-migrate.sh myapp --dest user@remote-host
|
||||
./docker-migrate.sh --service myapp --dest user@remote --method rsync
|
||||
./docker-migrate.sh --service myapp --dest remote --bwlimit 50 --retries 5
|
||||
./docker-migrate.sh --service myapp --dest remote --dry-run
|
||||
```
|
||||
|
||||
### docker-auto-migrate.sh
|
||||
Migrate ALL stacks to a destination host.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
./docker-auto-migrate.sh --dest 10.0.0.2 --dest-user admin
|
||||
./docker-auto-migrate.sh --dest remote-host --method rsync --dry-run
|
||||
```
|
||||
|
||||
## Common Options
|
||||
|
||||
All scripts support:
|
||||
- `--version` — Show version information
|
||||
- `--help, -h` — Show help message
|
||||
- `--quiet, -q` — Suppress non-error output
|
||||
- `--yes, -y` — Auto-confirm all prompts
|
||||
- `--dry-run` — Preview without executing
|
||||
- `--install-deps` — Auto-install missing dependencies (requires root)
|
||||
- `--log FILE` — Custom log file path
|
||||
|
||||
## Configuration
|
||||
|
||||
### Config File
|
||||
Scripts load settings from `~/.config/docwell/config` (same as Go version):
|
||||
```ini
|
||||
StacksDir="/opt/stacks"
|
||||
BackupBase="/storage/backups/docker-myhost"
|
||||
LogFile="/tmp/docwell/docwell.log"
|
||||
OldHost="10.0.0.10"
|
||||
OldPort="22"
|
||||
OldUser="admin"
|
||||
BandwidthMB="50"
|
||||
TransferRetries="3"
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
Config file values can be overridden by environment variables:
|
||||
- `STACKS_DIR` — Stacks directory (default: `/opt/stacks`)
|
||||
- `BACKUP_BASE` — Backup base directory (default: `/storage/backups/docker-$HOSTNAME`)
|
||||
- `LOG_FILE` — Log file path (default: `/tmp/docwell/docwell.log`)
|
||||
- `NO_COLOR` — Disable color output when set
|
||||
|
||||
Migration-specific:
|
||||
- `OLD_HOST`, `OLD_PORT`, `OLD_USER` — Source server defaults
|
||||
- `NEW_HOST`, `NEW_PORT`, `NEW_USER` — Destination server defaults
|
||||
|
||||
## Comparison with Go Version
|
||||
|
||||
| Feature | Bash | Go |
|
||||
|---|---|---|
|
||||
| CLI Flags | ✅ | ✅ |
|
||||
| Interactive Mode | ✅ | ✅ |
|
||||
| Spinner/Progress | ✅ | ✅ |
|
||||
| Config File | ✅ | ✅ |
|
||||
| Dry-Run | ✅ | ✅ |
|
||||
| Signal Handling | ✅ | ✅ |
|
||||
| Transfer Methods | ✅ rsync, tar, rclone | ✅ rsync, tar, rclone |
|
||||
| Parallel Operations | ✅ Bounded | ✅ Full |
|
||||
| Post-Migration Verify | ✅ | ✅ |
|
||||
| Bandwidth Limiting | ✅ | ✅ |
|
||||
| NO_COLOR Support | ✅ | ❌ |
|
||||
|
||||
## Examples
|
||||
|
||||
### Automated Backup (cron)
|
||||
```bash
|
||||
0 2 * * * /usr/local/bin/docker-backup.sh --all --quiet --yes
|
||||
```
|
||||
|
||||
### Weekly Cleanup (cron)
|
||||
```bash
|
||||
0 2 * * 0 /usr/local/bin/docker-cleanup.sh --all --yes --quiet
|
||||
```
|
||||
|
||||
### Update Check Script
|
||||
```bash
|
||||
#!/bin/bash
|
||||
/usr/local/bin/docker-manager.sh --check --quiet
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- All scripts require Docker and Docker Compose
|
||||
- Some operations may require root or sudo
|
||||
- Scripts use `set -euo pipefail` for strict error handling
|
||||
- Color output disabled when `NO_COLOR=1` is set or stdout is not a terminal
|
||||
|
||||
## See Also
|
||||
|
||||
- Go version: `../go/docwell.go`
|
||||
- Shared library: `lib/common.sh`
|
||||
- Test stack: `../test-stack/`
|
||||
366
bash/docker-auto-migrate.sh
Executable file
366
bash/docker-auto-migrate.sh
Executable file
@@ -0,0 +1,366 @@
|
||||
#!/bin/bash
|
||||
# Docker Auto-Migration Script
|
||||
# Migrates ALL stacks to a specified destination host
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Version
|
||||
VERSION="2.6.2"
|
||||
|
||||
# Resolve script directory and source shared library
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=lib/common.sh
|
||||
source "$SCRIPT_DIR/lib/common.sh"
|
||||
|
||||
# Enable debug trace after sourcing common.sh (so DEBUG variable is available)
|
||||
enable_debug_trace
|
||||
|
||||
# Default configuration
|
||||
LOG_FILE="${LOG_FILE:-/tmp/docwell/docker-auto-migrate.log}"
|
||||
DEST_HOST="${DEST_HOST:-}"
|
||||
DEST_USER="${DEST_USER:-$(whoami)}"
|
||||
DEST_PORT="${DEST_PORT:-22}"
|
||||
REMOTE_STACKS_DIR="${REMOTE_STACKS_DIR:-/opt/stacks}"
|
||||
TRANSFER_METHOD="rclone"
|
||||
COMPRESSION="${COMPRESSION:-zstd}"
|
||||
|
||||
# Load config file overrides
|
||||
load_config
|
||||
|
||||
parse_args() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--version)
|
||||
echo "docker-auto-migrate.sh v$VERSION"
|
||||
exit 0
|
||||
;;
|
||||
--quiet|-q)
|
||||
QUIET=true
|
||||
shift
|
||||
;;
|
||||
--yes|-y)
|
||||
YES=true
|
||||
shift
|
||||
;;
|
||||
--dest)
|
||||
DEST_HOST="$2"
|
||||
shift 2
|
||||
;;
|
||||
--dest-user)
|
||||
DEST_USER="$2"
|
||||
shift 2
|
||||
;;
|
||||
--dest-port)
|
||||
DEST_PORT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--stacks-dir)
|
||||
STACKS_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--remote-stacks-dir)
|
||||
REMOTE_STACKS_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--method)
|
||||
TRANSFER_METHOD="$2"
|
||||
shift 2
|
||||
;;
|
||||
--install-deps)
|
||||
AUTO_INSTALL=true
|
||||
shift
|
||||
;;
|
||||
--log)
|
||||
LOG_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
shift
|
||||
;;
|
||||
--debug|-d)
|
||||
DEBUG=true
|
||||
shift
|
||||
;;
|
||||
--verbose|-v)
|
||||
VERBOSE=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Unknown option: $1${NC}" >&2
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
show_help() {
|
||||
cat << EOF
|
||||
Docker Auto-Migration Script v$VERSION
|
||||
|
||||
Migrates ALL Docker stacks to a specified destination host.
|
||||
Requires root privileges and SSH key-based authentication.
|
||||
|
||||
Usage: $0 [OPTIONS]
|
||||
|
||||
Options:
|
||||
--dest HOST Destination host (required)
|
||||
--dest-user USER Destination user (default: current user)
|
||||
--dest-port PORT Destination SSH port (default: 22)
|
||||
--stacks-dir DIR Local stacks directory (default: /opt/stacks)
|
||||
--remote-stacks-dir Remote stacks directory (default: /opt/stacks)
|
||||
--method METHOD Transfer method: rclone, rsync (default: rclone)
|
||||
--quiet, -q Suppress non-error output
|
||||
--yes, -y Auto-confirm all prompts
|
||||
--dry-run Show what would be done without actually doing it
|
||||
--debug, -d Enable debug mode (verbose output + xtrace)
|
||||
--verbose, -v Enable verbose logging
|
||||
--install-deps Auto-install missing dependencies (requires root)
|
||||
--log FILE Log file path
|
||||
--version Show version information
|
||||
--help, -h Show this help message
|
||||
|
||||
Environment Variables:
|
||||
DEST_HOST Destination host
|
||||
DEST_USER Destination user
|
||||
DEST_PORT Destination SSH port
|
||||
STACKS_DIR Local stacks directory
|
||||
REMOTE_STACKS_DIR Remote stacks directory
|
||||
|
||||
Examples:
|
||||
$0 --dest 10.0.0.2 --dest-user admin
|
||||
$0 --dest 10.0.0.2 --method rsync --dry-run
|
||||
$0 --dest remote-host --yes
|
||||
EOF
|
||||
}
|
||||
|
||||
check_dependencies() {
|
||||
local required=("docker" "ssh")
|
||||
|
||||
case "$TRANSFER_METHOD" in
|
||||
rclone) required+=("rclone") ;;
|
||||
rsync) required+=("rsync") ;;
|
||||
esac
|
||||
|
||||
local missing=()
|
||||
for cmd in "${required[@]}"; do
|
||||
command -v "$cmd" &>/dev/null || missing+=("$cmd")
|
||||
done
|
||||
|
||||
if [[ ${#missing[@]} -gt 0 ]]; then
|
||||
log_error "Missing dependencies: ${missing[*]}"
|
||||
if [[ "$AUTO_INSTALL" == "true" ]]; then
|
||||
install_dependencies "${missing[@]}"
|
||||
return $?
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
stop_local_stack() {
|
||||
local stack="$1"
|
||||
local stack_path="$STACKS_DIR/$stack"
|
||||
local compose_file
|
||||
compose_file=$(get_compose_file "$stack_path") || return 1
|
||||
run_or_dry "stop $stack" docker compose -f "$stack_path/$compose_file" down >/dev/null 2>&1
|
||||
}
|
||||
|
||||
start_remote_stack() {
|
||||
local stack="$1"
|
||||
local remote_path="$REMOTE_STACKS_DIR/$stack"
|
||||
|
||||
for f in compose.yaml compose.yml docker-compose.yaml docker-compose.yml; do
|
||||
if ssh -p "$DEST_PORT" -o StrictHostKeyChecking=no "$DEST_USER@$DEST_HOST" \
|
||||
"test -f '$remote_path/$f'" 2>/dev/null; then
|
||||
run_or_dry "start $stack on remote" \
|
||||
ssh -p "$DEST_PORT" -o StrictHostKeyChecking=no "$DEST_USER@$DEST_HOST" \
|
||||
"cd '$remote_path' && docker compose -f '$f' up -d" >/dev/null 2>&1
|
||||
return $?
|
||||
fi
|
||||
done
|
||||
|
||||
log_error "No compose file found on destination for $stack"
|
||||
return 1
|
||||
}
|
||||
|
||||
transfer_files() {
|
||||
local src="$1" dst="$2"
|
||||
|
||||
case "$TRANSFER_METHOD" in
|
||||
rclone)
|
||||
run_or_dry "rclone sync $src to $dst" \
|
||||
rclone sync "$src" ":sftp,host=$DEST_HOST,user=$DEST_USER,port=$DEST_PORT:$dst" \
|
||||
--transfers 16 --progress --sftp-ask-password 2>&1
|
||||
;;
|
||||
rsync)
|
||||
run_or_dry "rsync $src to $dst" \
|
||||
rsync -avz --progress --delete \
|
||||
-e "ssh -p $DEST_PORT -o StrictHostKeyChecking=no" \
|
||||
"$src/" "$DEST_USER@$DEST_HOST:$dst/"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
migrate_volume() {
|
||||
local vol="$1"
|
||||
local vol_backup_dir="$2"
|
||||
|
||||
local comp_info
|
||||
comp_info=$(get_compressor "$COMPRESSION")
|
||||
local compressor="${comp_info%%:*}"
|
||||
local ext="${comp_info#*:}"
|
||||
|
||||
log_info "Backing up volume: $vol"
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log_info "[DRY-RUN] Would migrate volume $vol"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Backup locally
|
||||
docker run --rm \
|
||||
-v "${vol}:/data:ro" \
|
||||
-v "$vol_backup_dir:/backup" \
|
||||
alpine \
|
||||
sh -c "tar -cf - -C /data . | $compressor > /backup/${vol}${ext}" 2>/dev/null || {
|
||||
log_error "Failed to backup volume: $vol"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Transfer to remote
|
||||
transfer_files "$vol_backup_dir/${vol}${ext}" "/tmp/docwell_vol_migrate/"
|
||||
|
||||
# Restore on remote
|
||||
local decompressor
|
||||
case "$compressor" in
|
||||
zstd) decompressor="zstd -d" ;;
|
||||
gzip) decompressor="gzip -d" ;;
|
||||
*) decompressor="cat" ;;
|
||||
esac
|
||||
|
||||
ssh -p "$DEST_PORT" -o StrictHostKeyChecking=no "$DEST_USER@$DEST_HOST" \
|
||||
"docker volume create '$vol' 2>/dev/null; \
|
||||
docker run --rm -v '${vol}:/data' -v '/tmp/docwell_vol_migrate:/backup:ro' alpine \
|
||||
sh -c 'cd /data && cat /backup/${vol}${ext} | ${decompressor} | tar -xf -'" 2>/dev/null || {
|
||||
log_warn "Failed to restore volume on remote: $vol"
|
||||
return 1
|
||||
}
|
||||
|
||||
log_success "Volume $vol migrated"
|
||||
}
|
||||
|
||||
main() {
|
||||
parse_args "$@"
|
||||
|
||||
# Must be root
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
log_error "This script requires root privileges"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$DEST_HOST" ]]; then
|
||||
log_error "Destination host required (--dest HOST)"
|
||||
show_help
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! check_dependencies; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test SSH
|
||||
if ! test_ssh "$DEST_HOST" "$DEST_PORT" "$DEST_USER"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local stacks
|
||||
mapfile -t stacks < <(get_stacks)
|
||||
|
||||
if [[ ${#stacks[@]} -eq 0 ]]; then
|
||||
log_error "No stacks found in $STACKS_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_header "Auto-Migrate All Stacks"
|
||||
echo -e "${GRAY}DocWell Auto-Migration v$VERSION${NC}"
|
||||
echo
|
||||
echo -e " Source: local ($STACKS_DIR)"
|
||||
echo -e " Destination: $DEST_USER@$DEST_HOST:$DEST_PORT ($REMOTE_STACKS_DIR)"
|
||||
echo -e " Method: $TRANSFER_METHOD"
|
||||
echo -e " Stacks: ${#stacks[@]}"
|
||||
echo
|
||||
|
||||
if ! confirm "Proceed with migrating ALL stacks?"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
local start_time=$SECONDS
|
||||
local total=${#stacks[@]}
|
||||
local failed=()
|
||||
local vol_backup_dir
|
||||
vol_backup_dir=$(mktemp -d /tmp/docwell_auto_migrate.XXXXXX)
|
||||
register_cleanup "rm -rf '$vol_backup_dir'"
|
||||
|
||||
local idx=0
|
||||
for stack in "${stacks[@]}"; do
|
||||
((idx++))
|
||||
echo
|
||||
log_separator
|
||||
echo -e "${CYAN}[$idx/$total]${NC} ${BOLD}$stack${NC}"
|
||||
|
||||
# Step 1: Stop local stack
|
||||
log_info "Stopping local stack..."
|
||||
if is_stack_running "$stack"; then
|
||||
stop_local_stack "$stack" || {
|
||||
log_warn "Failed to stop $stack, continuing..."
|
||||
}
|
||||
fi
|
||||
|
||||
# Step 2: Transfer files
|
||||
log_info "Transferring files..."
|
||||
if ! transfer_files "$STACKS_DIR/$stack" "$REMOTE_STACKS_DIR/$stack"; then
|
||||
log_error "Failed to transfer files for $stack"
|
||||
failed+=("$stack")
|
||||
continue
|
||||
fi
|
||||
|
||||
# Step 3: Migrate volumes
|
||||
local volumes
|
||||
mapfile -t volumes < <(get_service_volumes "$stack" 2>/dev/null || true)
|
||||
for vol in "${volumes[@]}"; do
|
||||
[[ -z "$vol" ]] && continue
|
||||
migrate_volume "$vol" "$vol_backup_dir" || {
|
||||
log_warn "Volume migration failed for $vol"
|
||||
}
|
||||
done
|
||||
|
||||
# Step 4: Start on remote
|
||||
log_info "Starting on destination..."
|
||||
if ! start_remote_stack "$stack"; then
|
||||
log_warn "Failed to start $stack on destination"
|
||||
failed+=("$stack")
|
||||
else
|
||||
log_success "$stack migrated"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
log_separator
|
||||
local elapsed=$(( SECONDS - start_time ))
|
||||
|
||||
if [[ ${#failed[@]} -gt 0 ]]; then
|
||||
log_warn "Failed stacks (${#failed[@]}): ${failed[*]}"
|
||||
else
|
||||
log_success "All stacks migrated successfully"
|
||||
fi
|
||||
|
||||
log_info "Auto-migration completed in $(format_elapsed "$elapsed")"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
602
bash/docker-backup.sh
Executable file
602
bash/docker-backup.sh
Executable file
@@ -0,0 +1,602 @@
|
||||
#!/bin/bash
|
||||
# Docker Stack Backup Script
|
||||
# Enhanced version matching Go docwell functionality
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Version
|
||||
VERSION="2.6.2"
|
||||
|
||||
# Resolve script directory and source shared library
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=lib/common.sh
|
||||
source "$SCRIPT_DIR/lib/common.sh"
|
||||
|
||||
# Enable debug trace after sourcing common.sh (so DEBUG variable is available)
|
||||
enable_debug_trace
|
||||
|
||||
# Default configuration
|
||||
HOSTNAME=$(hostname 2>/dev/null | tr -cd 'a-zA-Z0-9._-' || echo "unknown")
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
BACKUP_BASE="${BACKUP_BASE:-/storage/backups/docker-$HOSTNAME}"
|
||||
BACKUP_DIR="$BACKUP_BASE/$DATE"
|
||||
LOG_FILE="${LOG_FILE:-/tmp/docwell/docker-backup.log}"
|
||||
|
||||
# CLI flags
|
||||
BACKUP_ALL=false
|
||||
BACKUP_STACK=""
|
||||
LIST_ONLY=false
|
||||
AUTO_MODE=false
|
||||
COMPRESSION="zstd"
|
||||
|
||||
# Parser configuration
|
||||
MAX_STACK_NAME_LEN=30
|
||||
|
||||
# Load config file overrides
|
||||
load_config
|
||||
|
||||
# Parse command line arguments
|
||||
parse_args() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--version)
|
||||
echo "docker-backup.sh v$VERSION"
|
||||
exit 0
|
||||
;;
|
||||
--quiet|-q)
|
||||
QUIET=true
|
||||
shift
|
||||
;;
|
||||
--yes|-y)
|
||||
YES=true
|
||||
shift
|
||||
;;
|
||||
--all|-a)
|
||||
BACKUP_ALL=true
|
||||
shift
|
||||
;;
|
||||
--stack|-s)
|
||||
require_arg "$1" "${2:-}" || exit 1
|
||||
BACKUP_STACK="$2"
|
||||
shift 2
|
||||
;;
|
||||
--list|-l)
|
||||
LIST_ONLY=true
|
||||
shift
|
||||
;;
|
||||
--stacks-dir)
|
||||
STACKS_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--backup-base)
|
||||
BACKUP_BASE="$2"
|
||||
BACKUP_DIR="$BACKUP_BASE/$DATE"
|
||||
shift 2
|
||||
;;
|
||||
--auto)
|
||||
AUTO_MODE=true
|
||||
QUIET=true
|
||||
YES=true
|
||||
shift
|
||||
;;
|
||||
--install-deps)
|
||||
AUTO_INSTALL=true
|
||||
shift
|
||||
;;
|
||||
--log)
|
||||
LOG_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--compression|-c)
|
||||
COMPRESSION="$2"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
shift
|
||||
;;
|
||||
--debug|-d)
|
||||
DEBUG=true
|
||||
shift
|
||||
;;
|
||||
--verbose|-v)
|
||||
VERBOSE=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Unknown option: $1${NC}" >&2
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
show_help() {
|
||||
cat << EOF
|
||||
Docker Stack Backup Script v$VERSION
|
||||
|
||||
Usage: $0 [OPTIONS]
|
||||
|
||||
Options:
|
||||
--all, -a Backup all stacks
|
||||
--stack STACK, -s Backup specific stack by name
|
||||
--list, -l List available stacks for backup
|
||||
--stacks-dir DIR Stacks directory (default: /opt/stacks)
|
||||
--backup-base DIR Backup base directory (default: /storage/backups/docker-\$HOSTNAME)
|
||||
--log FILE Log file path (default: /tmp/docwell/docker-backup.log)
|
||||
--quiet, -q Suppress non-error output
|
||||
--yes, -y Auto-confirm all prompts
|
||||
--auto Auto mode: backup all stacks & clean resources (for cron)
|
||||
--compression METHOD Compression method: zstd or gzip (default: zstd)
|
||||
--dry-run Show what would be done without actually doing it
|
||||
--debug, -d Enable debug mode (verbose output + xtrace)
|
||||
--verbose, -v Enable verbose logging
|
||||
--install-deps Auto-install missing dependencies (requires root)
|
||||
--version Show version information
|
||||
--help, -h Show this help message
|
||||
|
||||
Examples:
|
||||
$0 --list # List all stacks
|
||||
$0 --all # Backup all stacks
|
||||
$0 --stack myapp # Backup specific stack
|
||||
$0 --all --quiet --yes # Backup all stacks non-interactively
|
||||
$0 --all --dry-run # Preview backup without executing
|
||||
EOF
|
||||
}
|
||||
|
||||
# Check for required dependencies
|
||||
check_dependencies() {
|
||||
local required_cmds=("docker")
|
||||
local optional_cmds=("zstd")
|
||||
local missing=()
|
||||
local missing_optional=()
|
||||
|
||||
for cmd in "${required_cmds[@]}"; do
|
||||
if ! command -v "$cmd" &>/dev/null; then
|
||||
missing+=("$cmd")
|
||||
fi
|
||||
done
|
||||
|
||||
for cmd in "${optional_cmds[@]}"; do
|
||||
if ! command -v "$cmd" &>/dev/null; then
|
||||
missing_optional+=("$cmd")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#missing_optional[@]} -gt 0 ]]; then
|
||||
log_warn "Optional dependencies missing: ${missing_optional[*]} (will use fallback)"
|
||||
fi
|
||||
|
||||
if [[ ${#missing[@]} -gt 0 ]]; then
|
||||
log_error "Missing required dependencies: ${missing[*]}"
|
||||
echo -e "${BLUE}Install commands:${NC}"
|
||||
command -v apt &>/dev/null && echo -e " ${GREEN}Debian/Ubuntu:${NC} sudo apt install docker.io ${missing_optional[*]}"
|
||||
command -v pacman &>/dev/null && echo -e " ${GREEN}Arch Linux:${NC} sudo pacman -S docker ${missing_optional[*]}"
|
||||
|
||||
if [[ "$AUTO_INSTALL" == "true" ]]; then
|
||||
log_info "Auto-installing dependencies..."
|
||||
install_dependencies "${missing[@]}" "${missing_optional[@]}"
|
||||
return $?
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
find_compose_file() {
|
||||
local stack="$1"
|
||||
get_compose_file "$STACKS_DIR/$stack"
|
||||
}
|
||||
|
||||
stop_stack() {
|
||||
local stack="$1"
|
||||
local stack_path="$STACKS_DIR/$stack"
|
||||
local compose_file
|
||||
|
||||
log_debug "Attempting to stop stack: $stack"
|
||||
compose_file=$(find_compose_file "$stack") || {
|
||||
log_debug "No compose file found for stack: $stack"
|
||||
return 1
|
||||
}
|
||||
|
||||
if is_stack_running "$stack"; then
|
||||
log_info "Stopping $stack..."
|
||||
if run_or_dry "stop $stack" docker compose -f "$stack_path/$compose_file" down >/dev/null 2>&1; then
|
||||
log_debug "Stack stopped successfully: $stack"
|
||||
return 0
|
||||
else
|
||||
log_error "Failed to stop stack: $stack"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
log_debug "Stack was not running: $stack"
|
||||
return 1
|
||||
}
|
||||
|
||||
start_stack() {
|
||||
local stack="$1"
|
||||
local stack_path="$STACKS_DIR/$stack"
|
||||
local compose_file
|
||||
|
||||
log_debug "Attempting to start stack: $stack"
|
||||
compose_file=$(find_compose_file "$stack") || {
|
||||
log_error "No compose file found for stack: $stack"
|
||||
return 1
|
||||
}
|
||||
|
||||
log_info "Starting $stack..."
|
||||
if run_or_dry "start $stack" docker compose -f "$stack_path/$compose_file" up -d >/dev/null 2>&1; then
|
||||
log_debug "Stack started successfully: $stack"
|
||||
return 0
|
||||
else
|
||||
log_error "Failed to start stack: $stack"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
create_backup_archive() {
|
||||
local stack="$1"
|
||||
log_debug "Creating backup archive for: $stack"
|
||||
|
||||
# Temp files in BACKUP_DIR, cleaned up on failure
|
||||
local temp_tar
|
||||
temp_tar=$(mktemp "${BACKUP_DIR}/.${stack}.tar.XXXXXX" 2>/dev/null) || {
|
||||
log_error "Failed to create temporary file in $BACKUP_DIR"
|
||||
return 1
|
||||
}
|
||||
register_cleanup "rm -f '$temp_tar'"
|
||||
log_debug "Temporary tar file: $temp_tar"
|
||||
|
||||
log_info "Archiving $stack..."
|
||||
|
||||
local comp_info
|
||||
comp_info=$(get_compressor "$COMPRESSION")
|
||||
local compressor="${comp_info%%:*}"
|
||||
local ext="${comp_info#*:}"
|
||||
log_debug "Using compressor: $compressor, extension: $ext"
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log_info "[DRY-RUN] Would create: $BACKUP_DIR/docker-$stack$ext"
|
||||
rm -f "$temp_tar"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_debug "Creating tar archive..."
|
||||
if ! tar -cf "$temp_tar" -C "$STACKS_DIR" "$stack" 2>/dev/null; then
|
||||
log_error "Failed to create tar archive for $stack"
|
||||
rm -f "$temp_tar"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local tar_size
|
||||
tar_size=$(stat -f%z "$temp_tar" 2>/dev/null || stat -c%s "$temp_tar" 2>/dev/null || echo "unknown")
|
||||
log_debug "Tar archive size: $tar_size bytes"
|
||||
|
||||
log_debug "Compressing archive with $compressor..."
|
||||
if ! $compressor < "$temp_tar" > "$BACKUP_DIR/docker-$stack$ext" 2>/dev/null; then
|
||||
log_error "Failed to compress archive for $stack"
|
||||
rm -f "$temp_tar" "$BACKUP_DIR/docker-$stack$ext" 2>/dev/null
|
||||
return 1
|
||||
fi
|
||||
|
||||
local final_size
|
||||
final_size=$(stat -f%z "$BACKUP_DIR/docker-$stack$ext" 2>/dev/null || stat -c%s "$BACKUP_DIR/docker-$stack$ext" 2>/dev/null || echo "unknown")
|
||||
log_debug "Final backup size: $final_size bytes"
|
||||
log_debug "Backup archive created successfully: $BACKUP_DIR/docker-$stack$ext"
|
||||
|
||||
rm -f "$temp_tar"
|
||||
return 0
|
||||
}
|
||||
|
||||
backup_stack() {
|
||||
local stack="$1"
|
||||
log_debug "Starting backup for stack: $stack"
|
||||
|
||||
if ! validate_stack_name "$stack"; then
|
||||
log_error "Invalid stack name: $stack"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local stack_path="$STACKS_DIR/$stack"
|
||||
if [[ ! -d "$stack_path" ]]; then
|
||||
log_error "Stack directory not found: $stack_path"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_debug "Stack path verified: $stack_path"
|
||||
|
||||
# Create backup directory
|
||||
if ! mkdir -p "$BACKUP_DIR"; then
|
||||
log_error "Failed to create backup directory: $BACKUP_DIR"
|
||||
return 1
|
||||
fi
|
||||
log_debug "Backup directory ready: $BACKUP_DIR"
|
||||
|
||||
local was_running=false
|
||||
|
||||
if stop_stack "$stack"; then
|
||||
was_running=true
|
||||
log_debug "Stack was running, stopped for backup"
|
||||
else
|
||||
log_debug "Stack was not running"
|
||||
fi
|
||||
|
||||
if create_backup_archive "$stack"; then
|
||||
log_success "Backup complete: $stack"
|
||||
if [[ "$was_running" == "true" ]]; then
|
||||
log_debug "Restarting stack: $stack"
|
||||
start_stack "$stack" || log_warn "Failed to restart $stack"
|
||||
fi
|
||||
return 0
|
||||
else
|
||||
log_error "Backup failed: $stack"
|
||||
if [[ "$was_running" == "true" ]]; then
|
||||
log_debug "Attempting to restart stack after failed backup: $stack"
|
||||
start_stack "$stack" || log_warn "Failed to restart $stack"
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
list_stacks() {
|
||||
show_spinner "Loading stacks..."
|
||||
local stacks
|
||||
mapfile -t stacks < <(get_stacks)
|
||||
hide_spinner
|
||||
|
||||
if [[ ${#stacks[@]} -eq 0 ]]; then
|
||||
log_info "No stacks found in $STACKS_DIR"
|
||||
return 1
|
||||
fi
|
||||
|
||||
for stack in "${stacks[@]}"; do
|
||||
local status="stopped"
|
||||
local size
|
||||
size=$(get_stack_size "$stack")
|
||||
if is_stack_running "$stack"; then
|
||||
status="running"
|
||||
fi
|
||||
echo -e "$stack\t$status\t$size"
|
||||
done
|
||||
}
|
||||
|
||||
interactive_backup() {
|
||||
local stacks
|
||||
show_spinner "Loading stacks..."
|
||||
mapfile -t stacks < <(get_stacks)
|
||||
hide_spinner
|
||||
|
||||
if [[ ${#stacks[@]} -eq 0 ]]; then
|
||||
log_error "No stacks found in $STACKS_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_header "Backup Docker Stacks"
|
||||
echo -e "${GRAY}DocWell Backup v$VERSION${NC}"
|
||||
echo
|
||||
|
||||
echo "Available stacks:"
|
||||
echo -e " ${BOLD}0${NC}) All stacks ${GRAY}[Backup everything]${NC}"
|
||||
for i in "${!stacks[@]}"; do
|
||||
local stack="${stacks[$i]}"
|
||||
local status="●"
|
||||
local color="$GRAY"
|
||||
if is_stack_running "$stack"; then
|
||||
color="$GREEN"
|
||||
fi
|
||||
local size
|
||||
size=$(get_stack_size "$stack")
|
||||
echo -e " ${BOLD}$((i+1))${NC}) ${color}${status}${NC} ${stack:0:$MAX_STACK_NAME_LEN} ${GRAY}[${size}]${NC}"
|
||||
done
|
||||
echo
|
||||
|
||||
read -rp "Enter selection (0 for all, comma-separated numbers): " selection
|
||||
|
||||
log_info "Starting backup on $HOSTNAME"
|
||||
local start_time=$SECONDS
|
||||
|
||||
if [[ "$selection" == "0" ]]; then
|
||||
_backup_stacks_parallel "${stacks[@]}"
|
||||
else
|
||||
IFS=',' read -ra SELECTIONS <<< "$selection"
|
||||
for sel in "${SELECTIONS[@]}"; do
|
||||
sel=$(echo "$sel" | xargs)
|
||||
|
||||
if [[ ! "$sel" =~ ^[0-9]+$ ]]; then
|
||||
log_warn "Invalid selection: $sel"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$sel" -ge 1 ]] && [[ "$sel" -le "${#stacks[@]}" ]]; then
|
||||
local idx=$((sel-1))
|
||||
backup_stack "${stacks[$idx]}" || true
|
||||
else
|
||||
log_warn "Selection out of range: $sel"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
local elapsed=$(( SECONDS - start_time ))
|
||||
log_info "Backup completed in $(format_elapsed "$elapsed")"
|
||||
}
|
||||
|
||||
_backup_stacks_parallel() {
|
||||
local stacks=("$@")
|
||||
local total=${#stacks[@]}
|
||||
local pids=()
|
||||
local fail_dir
|
||||
fail_dir=$(mktemp -d /tmp/backup_fail.XXXXXX)
|
||||
register_cleanup "rm -rf '$fail_dir'"
|
||||
|
||||
log_info "Starting backups for $total stack(s)..."
|
||||
|
||||
local idx=0
|
||||
for stack in "${stacks[@]}"; do
|
||||
((idx++))
|
||||
echo -e "${CYAN}[$idx/$total]${NC} Backing up $stack..."
|
||||
parallel_throttle "$MAX_PARALLEL"
|
||||
(
|
||||
if ! backup_stack "$stack"; then
|
||||
touch "$fail_dir/$stack"
|
||||
fi
|
||||
) &
|
||||
_parallel_pids+=($!)
|
||||
done
|
||||
|
||||
parallel_wait
|
||||
|
||||
# Collect failures
|
||||
local failed_stacks=()
|
||||
for stack in "${stacks[@]}"; do
|
||||
if [[ -f "$fail_dir/$stack" ]]; then
|
||||
failed_stacks+=("$stack")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#failed_stacks[@]} -gt 0 ]]; then
|
||||
log_warn "Failed to backup ${#failed_stacks[@]} stack(s): ${failed_stacks[*]}"
|
||||
else
|
||||
log_success "All stacks backed up successfully"
|
||||
fi
|
||||
}
|
||||
|
||||
auto_mode() {
|
||||
log_info "Starting auto mode on $HOSTNAME"
|
||||
local start_time=$SECONDS
|
||||
|
||||
# Backup all stacks
|
||||
local stacks
|
||||
mapfile -t stacks < <(get_stacks)
|
||||
|
||||
if [[ ${#stacks[@]} -eq 0 ]]; then
|
||||
log_warn "No stacks found for backup"
|
||||
else
|
||||
log_info "Backing up ${#stacks[@]} stack(s)..."
|
||||
local failed_count=0
|
||||
local idx=0
|
||||
for stack in "${stacks[@]}"; do
|
||||
((idx++))
|
||||
[[ "$QUIET" == "false" ]] && echo -e "${CYAN}[$idx/${#stacks[@]}]${NC} $stack"
|
||||
if ! backup_stack "$stack"; then
|
||||
((failed_count++))
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $failed_count -gt 0 ]]; then
|
||||
log_warn "Failed to backup $failed_count stack(s)"
|
||||
else
|
||||
log_success "All stacks backed up successfully"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Cleanup: stopped containers
|
||||
log_info "Cleaning up stopped containers..."
|
||||
if run_or_dry "prune containers" docker container prune -f > /dev/null 2>&1; then
|
||||
log_success "Stopped containers cleaned"
|
||||
else
|
||||
log_warn "Failed to clean stopped containers"
|
||||
fi
|
||||
|
||||
# Cleanup: dangling images
|
||||
log_info "Cleaning up dangling images..."
|
||||
if run_or_dry "prune images" docker image prune -f > /dev/null 2>&1; then
|
||||
log_success "Dangling images cleaned"
|
||||
else
|
||||
log_warn "Failed to clean dangling images"
|
||||
fi
|
||||
|
||||
local elapsed=$(( SECONDS - start_time ))
|
||||
log_info "Auto mode completed in $(format_elapsed "$elapsed")"
|
||||
}
|
||||
|
||||
main() {
|
||||
parse_args "$@"
|
||||
|
||||
log_debug "Starting docker-backup.sh v$VERSION"
|
||||
log_debug "Configuration: STACKS_DIR=$STACKS_DIR, BACKUP_BASE=$BACKUP_BASE"
|
||||
log_debug "Flags: QUIET=$QUIET, YES=$YES, DRY_RUN=$DRY_RUN, DEBUG=$DEBUG, VERBOSE=$VERBOSE"
|
||||
|
||||
# Check dependencies first
|
||||
if ! check_dependencies; then
|
||||
log_error "Dependency check failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validation of critical dirs
|
||||
if [[ ! -d "$STACKS_DIR" ]]; then
|
||||
log_error "Stacks directory does not exist: $STACKS_DIR"
|
||||
log_debug "Attempting to create stacks directory..."
|
||||
if mkdir -p "$STACKS_DIR" 2>/dev/null; then
|
||||
log_info "Created stacks directory: $STACKS_DIR"
|
||||
else
|
||||
log_error "Cannot create stacks directory: $STACKS_DIR"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check docker connectivity
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
log_debug "Docker info check failed, checking permissions..."
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
if ! confirm "Docker socket not accessible. Run with sudo?"; then
|
||||
exit 1
|
||||
fi
|
||||
log_debug "Re-executing with sudo..."
|
||||
exec sudo "$SCRIPT_DIR/$(basename "${BASH_SOURCE[0]}")" "$@"
|
||||
else
|
||||
log_error "Cannot connect to Docker daemon even as root."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Handle auto mode
|
||||
if [[ "$AUTO_MODE" == "true" ]]; then
|
||||
auto_mode
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Handle list only
|
||||
if [[ "$LIST_ONLY" == "true" ]]; then
|
||||
list_stacks
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Handle specific stack backup
|
||||
if [[ -n "$BACKUP_STACK" ]]; then
|
||||
if ! validate_stack_name "$BACKUP_STACK"; then
|
||||
exit 1
|
||||
fi
|
||||
local start_time=$SECONDS
|
||||
backup_stack "$BACKUP_STACK"
|
||||
local rc=$?
|
||||
local elapsed=$(( SECONDS - start_time ))
|
||||
log_info "Completed in $(format_elapsed "$elapsed")"
|
||||
exit $rc
|
||||
fi
|
||||
|
||||
# Handle backup all
|
||||
if [[ "$BACKUP_ALL" == "true" ]]; then
|
||||
local stacks
|
||||
mapfile -t stacks < <(get_stacks)
|
||||
if [[ ${#stacks[@]} -eq 0 ]]; then
|
||||
log_error "No stacks found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local start_time=$SECONDS
|
||||
_backup_stacks_parallel "${stacks[@]}"
|
||||
local elapsed=$(( SECONDS - start_time ))
|
||||
log_info "Completed in $(format_elapsed "$elapsed")"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Interactive mode
|
||||
interactive_backup
|
||||
}
|
||||
|
||||
main "$@"
|
||||
536
bash/docker-cleanup.sh
Executable file
536
bash/docker-cleanup.sh
Executable file
@@ -0,0 +1,536 @@
|
||||
#!/bin/bash
|
||||
# Docker Cleanup Script
|
||||
# Enhanced version matching Go docwell functionality
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="2.6.2"
|
||||
|
||||
# Resolve script directory and source shared library
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=lib/common.sh
|
||||
source "$SCRIPT_DIR/lib/common.sh"
|
||||
|
||||
# Enable debug trace after sourcing common.sh (so DEBUG variable is available)
|
||||
enable_debug_trace
|
||||
|
||||
# CLI flags
|
||||
CLEANUP_CONTAINERS=false
|
||||
CLEANUP_IMAGES=false
|
||||
CLEANUP_VOLUMES=false
|
||||
CLEANUP_NETWORKS=false
|
||||
CLEANUP_BUILDCACHE=false
|
||||
CLEANUP_SYSTEM=false
|
||||
CLEANUP_PACKAGES=false
|
||||
CLEANUP_ALL=false
|
||||
LOG_FILE="${LOG_FILE:-/tmp/docwell/docker-cleanup.log}"
|
||||
|
||||
# Load config file overrides
|
||||
load_config
|
||||
|
||||
parse_args() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--version)
|
||||
echo "docker-cleanup.sh v$VERSION"
|
||||
exit 0
|
||||
;;
|
||||
--quiet|-q)
|
||||
QUIET=true
|
||||
shift
|
||||
;;
|
||||
--yes|-y)
|
||||
YES=true
|
||||
shift
|
||||
;;
|
||||
--containers)
|
||||
CLEANUP_CONTAINERS=true
|
||||
shift
|
||||
;;
|
||||
--images)
|
||||
CLEANUP_IMAGES=true
|
||||
shift
|
||||
;;
|
||||
--volumes)
|
||||
CLEANUP_VOLUMES=true
|
||||
shift
|
||||
;;
|
||||
--networks)
|
||||
CLEANUP_NETWORKS=true
|
||||
shift
|
||||
;;
|
||||
--buildcache)
|
||||
CLEANUP_BUILDCACHE=true
|
||||
shift
|
||||
;;
|
||||
--system)
|
||||
CLEANUP_SYSTEM=true
|
||||
shift
|
||||
;;
|
||||
--packages)
|
||||
CLEANUP_PACKAGES=true
|
||||
shift
|
||||
;;
|
||||
--all)
|
||||
CLEANUP_ALL=true
|
||||
shift
|
||||
;;
|
||||
--install-deps)
|
||||
AUTO_INSTALL=true
|
||||
shift
|
||||
;;
|
||||
--log)
|
||||
LOG_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
shift
|
||||
;;
|
||||
--debug|-d)
|
||||
DEBUG=true
|
||||
shift
|
||||
;;
|
||||
--verbose|-v)
|
||||
VERBOSE=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Unknown option: $1${NC}" >&2
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
show_help() {
|
||||
cat << EOF
|
||||
Docker Cleanup Script v$VERSION
|
||||
|
||||
Usage: $0 [OPTIONS]
|
||||
|
||||
Options:
|
||||
--containers Remove stopped containers
|
||||
--images Remove unused images
|
||||
--volumes Remove unused volumes (DATA LOSS WARNING)
|
||||
--networks Remove unused networks
|
||||
--buildcache Clear build cache
|
||||
--system System-wide cleanup
|
||||
--packages Clean OS package cache
|
||||
--all Run all cleanup operations
|
||||
--install-deps Auto-install missing dependencies (requires root)
|
||||
--log FILE Log file path (default: /tmp/docwell/docker-cleanup.log)
|
||||
--dry-run Show what would be done without actually doing it
|
||||
--debug, -d Enable debug mode (verbose output + xtrace)
|
||||
--verbose, -v Enable verbose logging
|
||||
--quiet, -q Suppress non-error output
|
||||
--yes, -y Auto-confirm all prompts
|
||||
--version Show version information
|
||||
--help, -h Show this help message
|
||||
|
||||
Examples:
|
||||
$0 --containers # Remove stopped containers
|
||||
$0 --all --yes # Run all cleanups non-interactively
|
||||
$0 --images --volumes # Remove images and volumes
|
||||
$0 --all --dry-run # Preview cleanup without executing
|
||||
EOF
|
||||
}
|
||||
|
||||
show_docker_usage() {
|
||||
log_header "Docker Disk Usage"
|
||||
docker system df -v 2>/dev/null || {
|
||||
log_error "Docker not running or not accessible"
|
||||
exit 1
|
||||
}
|
||||
echo
|
||||
}
|
||||
|
||||
cleanup_containers() {
|
||||
log_header "Container Cleanup"
|
||||
|
||||
local stopped_count
|
||||
stopped_count=$(docker ps -aq --filter status=exited 2>/dev/null | wc -l || echo "0")
|
||||
|
||||
if [[ "$stopped_count" == "0" ]] || [[ -z "$stopped_count" ]]; then
|
||||
log_info "No stopped containers found"
|
||||
echo
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$QUIET" == "false" ]]; then
|
||||
echo -e "${YELLOW}Stopped containers to remove ($stopped_count):${NC}"
|
||||
docker ps -a --filter status=exited --format "table {{.Names}}\t{{.Image}}\t{{.Status}}" 2>/dev/null || true
|
||||
echo
|
||||
fi
|
||||
|
||||
if ! confirm "Remove stopped containers?"; then
|
||||
log_info "Skipping container cleanup"
|
||||
echo
|
||||
return 0
|
||||
fi
|
||||
|
||||
if run_or_dry "prune stopped containers" docker container prune -f &>/dev/null; then
|
||||
log_success "Removed stopped containers"
|
||||
else
|
||||
log_error "Failed to remove containers"
|
||||
return 1
|
||||
fi
|
||||
echo
|
||||
}
|
||||
|
||||
cleanup_images() {
|
||||
local remove_all="${1:-}"
|
||||
log_header "Image Cleanup"
|
||||
|
||||
local dangling_count
|
||||
dangling_count=$(docker images -f "dangling=true" -q 2>/dev/null | wc -l || echo "0")
|
||||
|
||||
if [[ "$dangling_count" != "0" ]] && [[ -n "$dangling_count" ]]; then
|
||||
if [[ "$QUIET" == "false" ]]; then
|
||||
echo -e "${YELLOW}Dangling images to remove ($dangling_count):${NC}"
|
||||
docker images -f "dangling=true" --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}" 2>/dev/null || true
|
||||
echo
|
||||
fi
|
||||
|
||||
if ! confirm "Remove dangling images?"; then
|
||||
log_info "Skipping dangling image cleanup"
|
||||
echo
|
||||
return 0
|
||||
fi
|
||||
|
||||
if run_or_dry "prune dangling images" docker image prune -f &>/dev/null; then
|
||||
log_success "Removed dangling images"
|
||||
else
|
||||
log_error "Failed to remove dangling images"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
log_info "No dangling images found"
|
||||
fi
|
||||
|
||||
if [[ "$QUIET" == "false" ]] && [[ "$remove_all" != "--all" ]]; then
|
||||
local unused_count
|
||||
unused_count=$(docker images -q 2>/dev/null | wc -l || echo "0")
|
||||
if [[ "$unused_count" != "0" ]]; then
|
||||
echo -e "${YELLOW}Unused images (showing first 20):${NC}"
|
||||
docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}" 2>/dev/null | head -20 || true
|
||||
echo
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$remove_all" != "--all" ]]; then
|
||||
if ! confirm "Remove ALL unused images?"; then
|
||||
log_info "Skipping unused image cleanup"
|
||||
echo
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if run_or_dry "prune all unused images" docker image prune -a -f &>/dev/null; then
|
||||
log_success "Removed all unused images"
|
||||
else
|
||||
log_error "Failed to remove unused images"
|
||||
return 1
|
||||
fi
|
||||
echo
|
||||
}
|
||||
|
||||
cleanup_volumes() {
|
||||
log_header "Volume Cleanup"
|
||||
|
||||
local unused_count
|
||||
unused_count=$(docker volume ls -q --filter dangling=true 2>/dev/null | wc -l || echo "0")
|
||||
|
||||
if [[ "$unused_count" == "0" ]] || [[ -z "$unused_count" ]]; then
|
||||
log_info "No unused volumes found"
|
||||
echo
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$QUIET" == "false" ]]; then
|
||||
echo -e "${YELLOW}Unused volumes to remove ($unused_count):${NC}"
|
||||
docker volume ls --filter dangling=true --format "table {{.Name}}\t{{.Driver}}" 2>/dev/null || true
|
||||
echo
|
||||
fi
|
||||
|
||||
log_warn "This will permanently delete volume data!"
|
||||
if ! confirm "Remove unused volumes?"; then
|
||||
log_info "Skipping volume cleanup"
|
||||
echo
|
||||
return 0
|
||||
fi
|
||||
|
||||
if run_or_dry "prune unused volumes" docker volume prune -f &>/dev/null; then
|
||||
log_success "Removed unused volumes"
|
||||
else
|
||||
log_error "Failed to remove volumes"
|
||||
return 1
|
||||
fi
|
||||
echo
|
||||
}
|
||||
|
||||
cleanup_networks() {
|
||||
log_header "Network Cleanup"
|
||||
|
||||
if [[ "$QUIET" == "false" ]]; then
|
||||
echo -e "${YELLOW}Unused networks:${NC}"
|
||||
docker network ls --format "table {{.Name}}\t{{.Driver}}\t{{.Scope}}"
|
||||
echo
|
||||
fi
|
||||
|
||||
if ! confirm "Remove unused networks?"; then
|
||||
log_info "Skipping network cleanup"
|
||||
echo
|
||||
return 0
|
||||
fi
|
||||
|
||||
if run_or_dry "prune unused networks" docker network prune -f &>/dev/null; then
|
||||
log_success "Removed unused networks"
|
||||
else
|
||||
log_error "Failed to remove networks"
|
||||
return 1
|
||||
fi
|
||||
echo
|
||||
}
|
||||
|
||||
cleanup_build_cache() {
|
||||
log_header "Build Cache Cleanup"
|
||||
|
||||
if [[ "$QUIET" == "false" ]]; then
|
||||
echo -e "${YELLOW}Build cache usage:${NC}"
|
||||
docker system df 2>/dev/null | grep "Build Cache" || echo "No build cache info available"
|
||||
echo
|
||||
fi
|
||||
|
||||
if ! confirm "Clear build cache?"; then
|
||||
log_info "Skipping build cache cleanup"
|
||||
echo
|
||||
return 0
|
||||
fi
|
||||
|
||||
if run_or_dry "prune build cache" docker builder prune -f &>/dev/null; then
|
||||
log_success "Cleared build cache"
|
||||
else
|
||||
log_error "Failed to clear build cache"
|
||||
return 1
|
||||
fi
|
||||
echo
|
||||
}
|
||||
|
||||
cleanup_system() {
|
||||
log_header "System-wide Cleanup"
|
||||
|
||||
log_warn "This will remove:"
|
||||
echo "- All stopped containers"
|
||||
echo "- All networks not used by at least one container"
|
||||
echo "- All dangling images"
|
||||
echo "- All dangling build cache"
|
||||
echo
|
||||
|
||||
if ! confirm "Perform system cleanup?"; then
|
||||
log_info "Skipping system cleanup"
|
||||
echo
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! run_or_dry "system prune" docker system prune -f &>/dev/null; then
|
||||
log_error "Failed to prune system"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_success "System cleanup completed"
|
||||
|
||||
if confirm "Also remove unused images?"; then
|
||||
if run_or_dry "aggressive system prune" docker system prune -a -f &>/dev/null; then
|
||||
log_success "Aggressive cleanup completed"
|
||||
else
|
||||
log_error "Failed to prune system with images"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
echo
|
||||
}
|
||||
|
||||
cleanup_packages() {
|
||||
log_header "Package Manager Cleanup"
|
||||
|
||||
if ! check_root; then
|
||||
log_error "sudo privileges required for package cleanup"
|
||||
echo
|
||||
return 1
|
||||
fi
|
||||
|
||||
if command -v pacman &> /dev/null; then
|
||||
log_info "Detected Arch Linux"
|
||||
|
||||
local orphaned
|
||||
orphaned=$(pacman -Qtdq 2>/dev/null || true)
|
||||
if [[ -n "$orphaned" ]]; then
|
||||
if [[ "$QUIET" == "false" ]]; then
|
||||
echo -e "${YELLOW}Orphaned packages:${NC}"
|
||||
echo "$orphaned"
|
||||
echo
|
||||
fi
|
||||
|
||||
if confirm "Remove orphaned packages?"; then
|
||||
local orphaned_packages=()
|
||||
while IFS= read -r pkg; do
|
||||
[[ -n "$pkg" ]] && orphaned_packages+=("$pkg")
|
||||
done < <(echo "$orphaned")
|
||||
|
||||
if (( ${#orphaned_packages[@]} > 0 )); then
|
||||
if run_or_dry "remove orphaned packages" sudo pacman -Rns "${orphaned_packages[@]}" &>/dev/null; then
|
||||
log_success "Orphaned packages removed"
|
||||
else
|
||||
log_warn "Failed to remove orphaned packages"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
else
|
||||
log_info "No orphaned packages found"
|
||||
fi
|
||||
|
||||
if confirm "Clear package cache?"; then
|
||||
if run_or_dry "clear package cache" sudo pacman -Scc --noconfirm &>/dev/null; then
|
||||
log_success "Package cache cleared"
|
||||
else
|
||||
log_error "Failed to clear package cache"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
elif command -v apt &> /dev/null; then
|
||||
log_info "Detected Debian/Ubuntu"
|
||||
|
||||
if confirm "Remove unused packages?"; then
|
||||
if run_or_dry "autoremove packages" sudo apt autoremove -y &>/dev/null; then
|
||||
log_success "Removed unused packages"
|
||||
else
|
||||
log_error "Failed to remove unused packages"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if confirm "Clean package cache?"; then
|
||||
if run_or_dry "clean package cache" sudo apt autoclean &>/dev/null; then
|
||||
log_success "Cache cleaned"
|
||||
else
|
||||
log_error "Failed to clean cache"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
log_info "No supported package manager found"
|
||||
fi
|
||||
echo
|
||||
}
|
||||
|
||||
cleanup_all() {
|
||||
show_docker_usage
|
||||
cleanup_containers
|
||||
cleanup_images
|
||||
cleanup_networks
|
||||
cleanup_build_cache
|
||||
|
||||
if confirm "Also remove unused volumes? (DATA LOSS WARNING)"; then
|
||||
cleanup_volumes
|
||||
fi
|
||||
|
||||
if confirm "Also remove ALL unused images?"; then
|
||||
cleanup_images --all
|
||||
fi
|
||||
|
||||
if confirm "Also clean package cache?"; then
|
||||
cleanup_packages
|
||||
fi
|
||||
}
|
||||
|
||||
main_menu() {
|
||||
while true; do
|
||||
log_header "Docker Cleanup Menu"
|
||||
echo -e "${GRAY}DocWell Cleanup v$VERSION${NC}"
|
||||
echo
|
||||
echo "1) Show Docker disk usage"
|
||||
echo "2) Clean containers (stopped)"
|
||||
echo "3) Clean images (dangling/unused)"
|
||||
echo "4) Clean volumes (unused)"
|
||||
echo "5) Clean networks (unused)"
|
||||
echo "6) Clean build cache"
|
||||
echo "7) System-wide cleanup"
|
||||
echo "8) Package manager cleanup"
|
||||
echo "9) Run all cleanups"
|
||||
echo "0) Exit"
|
||||
echo
|
||||
|
||||
read -rp "Select option [0-9]: " choice
|
||||
|
||||
if [[ ! "$choice" =~ ^[0-9]$ ]]; then
|
||||
log_error "Invalid option. Please enter a number between 0-9."
|
||||
echo
|
||||
read -rp "Press Enter to continue..."
|
||||
clear_screen
|
||||
continue
|
||||
fi
|
||||
|
||||
case $choice in
|
||||
1) show_docker_usage ;;
|
||||
2) cleanup_containers ;;
|
||||
3) cleanup_images ;;
|
||||
4) cleanup_volumes ;;
|
||||
5) cleanup_networks ;;
|
||||
6) cleanup_build_cache ;;
|
||||
7) cleanup_system ;;
|
||||
8) cleanup_packages ;;
|
||||
9) cleanup_all ;;
|
||||
0)
|
||||
log_info "Exiting..."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
log_error "Invalid option. Please try again."
|
||||
;;
|
||||
esac
|
||||
|
||||
echo
|
||||
read -rp "Press Enter to continue..."
|
||||
clear_screen
|
||||
done
|
||||
}
|
||||
|
||||
main() {
|
||||
parse_args "$@"
|
||||
check_docker || exit 1
|
||||
|
||||
# Handle CLI flags
|
||||
if [[ "$CLEANUP_ALL" == "true" ]]; then
|
||||
cleanup_all
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[[ "$CLEANUP_CONTAINERS" == "true" ]] && cleanup_containers
|
||||
[[ "$CLEANUP_IMAGES" == "true" ]] && cleanup_images
|
||||
[[ "$CLEANUP_VOLUMES" == "true" ]] && cleanup_volumes
|
||||
[[ "$CLEANUP_NETWORKS" == "true" ]] && cleanup_networks
|
||||
[[ "$CLEANUP_BUILDCACHE" == "true" ]] && cleanup_build_cache
|
||||
[[ "$CLEANUP_SYSTEM" == "true" ]] && cleanup_system
|
||||
[[ "$CLEANUP_PACKAGES" == "true" ]] && cleanup_packages
|
||||
|
||||
# If no flags provided, run interactive menu
|
||||
if [[ "$CLEANUP_CONTAINERS" == "false" ]] && \
|
||||
[[ "$CLEANUP_IMAGES" == "false" ]] && \
|
||||
[[ "$CLEANUP_VOLUMES" == "false" ]] && \
|
||||
[[ "$CLEANUP_NETWORKS" == "false" ]] && \
|
||||
[[ "$CLEANUP_BUILDCACHE" == "false" ]] && \
|
||||
[[ "$CLEANUP_SYSTEM" == "false" ]] && \
|
||||
[[ "$CLEANUP_PACKAGES" == "false" ]]; then
|
||||
clear_screen
|
||||
main_menu
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
746
bash/docker-manager.sh
Executable file
746
bash/docker-manager.sh
Executable file
@@ -0,0 +1,746 @@
|
||||
#!/bin/bash
|
||||
# Docker Stack Manager Script
|
||||
# Combines stack management and update functionality
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Version
|
||||
VERSION="2.6.2"
|
||||
|
||||
# Resolve script directory and source shared library
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=lib/common.sh
|
||||
source "$SCRIPT_DIR/lib/common.sh"
|
||||
|
||||
# Enable debug trace after sourcing common.sh (so DEBUG variable is available)
|
||||
enable_debug_trace
|
||||
|
||||
# Default configuration
|
||||
LOG_FILE="${LOG_FILE:-/tmp/docwell/docker-manager.log}"
|
||||
|
||||
# CLI flags
|
||||
LIST_ONLY=false
|
||||
START_STACK=""
|
||||
STOP_STACK=""
|
||||
RESTART_STACK=""
|
||||
STATUS_STACK=""
|
||||
LOGS_STACK=""
|
||||
CHECK_UPDATES=false
|
||||
UPDATE_ALL=false
|
||||
UPDATE_STACK=""
|
||||
AUTO_UPDATE=false
|
||||
|
||||
# Load config file overrides
|
||||
load_config
|
||||
|
||||
parse_args() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--version)
|
||||
echo "docker-manager.sh v$VERSION"
|
||||
exit 0
|
||||
;;
|
||||
--quiet|-q)
|
||||
QUIET=true
|
||||
shift
|
||||
;;
|
||||
--yes|-y)
|
||||
YES=true
|
||||
shift
|
||||
;;
|
||||
--list|-l)
|
||||
LIST_ONLY=true
|
||||
shift
|
||||
;;
|
||||
--start)
|
||||
START_STACK="$2"
|
||||
shift 2
|
||||
;;
|
||||
--stop)
|
||||
STOP_STACK="$2"
|
||||
shift 2
|
||||
;;
|
||||
--restart)
|
||||
RESTART_STACK="$2"
|
||||
shift 2
|
||||
;;
|
||||
--status)
|
||||
STATUS_STACK="$2"
|
||||
shift 2
|
||||
;;
|
||||
--logs)
|
||||
LOGS_STACK="$2"
|
||||
shift 2
|
||||
;;
|
||||
--check)
|
||||
CHECK_UPDATES=true
|
||||
shift
|
||||
;;
|
||||
--update-all|-a)
|
||||
UPDATE_ALL=true
|
||||
shift
|
||||
;;
|
||||
--update|-u)
|
||||
UPDATE_STACK="$2"
|
||||
shift 2
|
||||
;;
|
||||
--auto)
|
||||
AUTO_UPDATE=true
|
||||
shift
|
||||
;;
|
||||
--install-deps)
|
||||
AUTO_INSTALL=true
|
||||
shift
|
||||
;;
|
||||
--log)
|
||||
LOG_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
shift
|
||||
;;
|
||||
--debug|-d)
|
||||
DEBUG=true
|
||||
shift
|
||||
;;
|
||||
--verbose|-v)
|
||||
VERBOSE=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Unknown option: $1${NC}" >&2
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
show_help() {
|
||||
cat << EOF
|
||||
Docker Stack Manager Script v$VERSION
|
||||
|
||||
Combines stack lifecycle management and update functionality.
|
||||
|
||||
Usage: $0 [OPTIONS]
|
||||
|
||||
Stack Management:
|
||||
--list, -l List all stacks and their status
|
||||
--start STACK Start a specific stack
|
||||
--stop STACK Stop a specific stack
|
||||
--restart STACK Restart a specific stack
|
||||
--status STACK Get status of a specific stack
|
||||
--logs STACK View logs for a specific stack
|
||||
|
||||
Update Operations:
|
||||
--check Check for available image updates
|
||||
--update-all, -a Update all stacks
|
||||
--update STACK, -u Update a specific stack
|
||||
--auto Auto-update mode with zero-downtime
|
||||
|
||||
General Options:
|
||||
--quiet, -q Suppress non-error output
|
||||
--yes, -y Auto-confirm all prompts
|
||||
--dry-run Show what would be done without actually doing it
|
||||
--debug, -d Enable debug mode (verbose output + xtrace)
|
||||
--verbose, -v Enable verbose logging
|
||||
--install-deps Auto-install missing dependencies (requires root)
|
||||
--log FILE Log file path (default: /tmp/docwell/docker-manager.log)
|
||||
--version Show version information
|
||||
--help, -h Show this help message
|
||||
|
||||
Examples:
|
||||
$0 --list # List all stacks
|
||||
$0 --start myapp # Start a stack
|
||||
$0 --check # Check for updates
|
||||
$0 --update-all # Update all stacks
|
||||
$0 --update myapp # Update specific stack
|
||||
$0 --auto --yes # Auto-update all stacks
|
||||
$0 --update-all --dry-run # Preview update without executing
|
||||
EOF
|
||||
}
|
||||
|
||||
# ─── Stack Management Functions ──────────────────────────────────────────────
|
||||
|
||||
list_stacks_display() {
|
||||
show_spinner "Loading stacks..."
|
||||
local stacks
|
||||
mapfile -t stacks < <(get_stacks)
|
||||
hide_spinner
|
||||
|
||||
if [[ ${#stacks[@]} -eq 0 ]]; then
|
||||
log_info "No stacks found"
|
||||
return 1
|
||||
fi
|
||||
|
||||
for stack in "${stacks[@]}"; do
|
||||
local status="stopped"
|
||||
if is_stack_running "$stack"; then
|
||||
status="running"
|
||||
fi
|
||||
echo -e "$stack\t$status"
|
||||
done
|
||||
}
|
||||
|
||||
start_stack_cmd() {
|
||||
local stack="$1"
|
||||
local stack_path="$STACKS_DIR/$stack"
|
||||
local compose_file
|
||||
compose_file=$(get_compose_file "$stack_path") || {
|
||||
log_error "No compose file found"
|
||||
return 1
|
||||
}
|
||||
|
||||
if run_or_dry "start $stack" docker compose -f "$stack_path/$compose_file" up -d >/dev/null 2>&1; then
|
||||
log_success "Started $stack"
|
||||
return 0
|
||||
else
|
||||
log_error "Failed to start $stack"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
stop_stack_cmd() {
|
||||
local stack="$1"
|
||||
local stack_path="$STACKS_DIR/$stack"
|
||||
local compose_file
|
||||
compose_file=$(get_compose_file "$stack_path") || {
|
||||
log_error "No compose file found"
|
||||
return 1
|
||||
}
|
||||
|
||||
if run_or_dry "stop $stack" docker compose -f "$stack_path/$compose_file" down >/dev/null 2>&1; then
|
||||
log_success "Stopped $stack"
|
||||
return 0
|
||||
else
|
||||
log_error "Failed to stop $stack"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
restart_stack_cmd() {
|
||||
local stack="$1"
|
||||
local stack_path="$STACKS_DIR/$stack"
|
||||
local compose_file
|
||||
compose_file=$(get_compose_file "$stack_path") || {
|
||||
log_error "No compose file found"
|
||||
return 1
|
||||
}
|
||||
|
||||
if run_or_dry "restart $stack" docker compose -f "$stack_path/$compose_file" restart >/dev/null 2>&1; then
|
||||
log_success "Restarted $stack"
|
||||
return 0
|
||||
else
|
||||
log_error "Failed to restart $stack"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
get_stack_status_cmd() {
|
||||
local stack="$1"
|
||||
if is_stack_running "$stack"; then
|
||||
echo "running"
|
||||
else
|
||||
echo "stopped"
|
||||
fi
|
||||
}
|
||||
|
||||
view_logs() {
|
||||
local stack="$1"
|
||||
local stack_path="$STACKS_DIR/$stack"
|
||||
local compose_file
|
||||
compose_file=$(get_compose_file "$stack_path") || {
|
||||
log_error "No compose file found"
|
||||
return 1
|
||||
}
|
||||
|
||||
docker compose -f "$stack_path/$compose_file" logs -f
|
||||
}
|
||||
|
||||
# ─── Update Functions ────────────────────────────────────────────────────────
|
||||
|
||||
get_stack_images() {
|
||||
local stack_path="$1"
|
||||
local compose_file
|
||||
compose_file=$(get_compose_file "$stack_path") || return 1
|
||||
|
||||
docker compose -f "$stack_path/$compose_file" config --images 2>/dev/null | grep -v '^$' || true
|
||||
}
|
||||
|
||||
get_image_digest() {
|
||||
local image="$1"
|
||||
docker inspect --format='{{.Id}}' "$image" 2>/dev/null || echo ""
|
||||
}
|
||||
|
||||
get_remote_digest() {
|
||||
local image="$1"
|
||||
|
||||
local manifest_output
|
||||
manifest_output=$(docker manifest inspect "$image" 2>/dev/null) || true
|
||||
|
||||
if [[ -n "$manifest_output" ]]; then
|
||||
# Check for manifest list (multi-platform)
|
||||
if echo "$manifest_output" | grep -q '"mediaType"[[:space:]]*:[[:space:]]*"application/vnd.docker.distribution.manifest.list.v2+json"'; then
|
||||
local platform_digest
|
||||
platform_digest=$(echo "$manifest_output" | \
|
||||
grep -A 20 '"platform"' | \
|
||||
grep -B 5 -E '"architecture"[[:space:]]*:[[:space:]]*"(amd64|x86_64)"' | \
|
||||
grep '"digest"' | head -1 | cut -d'"' -f4)
|
||||
|
||||
if [[ -z "$platform_digest" ]]; then
|
||||
platform_digest=$(echo "$manifest_output" | \
|
||||
grep -A 10 '"platform"' | \
|
||||
grep '"digest"' | head -1 | cut -d'"' -f4)
|
||||
fi
|
||||
|
||||
if [[ -n "$platform_digest" ]]; then
|
||||
local platform_manifest
|
||||
platform_manifest=$(docker manifest inspect "$image@$platform_digest" 2>/dev/null)
|
||||
|
||||
if [[ -n "$platform_manifest" ]]; then
|
||||
local config_digest
|
||||
config_digest=$(echo "$platform_manifest" | \
|
||||
grep -A 5 '"config"' | \
|
||||
grep '"digest"' | head -1 | cut -d'"' -f4)
|
||||
|
||||
if [[ -n "$config_digest" ]]; then
|
||||
echo "$config_digest"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
else
|
||||
local config_digest
|
||||
config_digest=$(echo "$manifest_output" | \
|
||||
grep -A 5 '"config"' | \
|
||||
grep '"digest"' | head -1 | cut -d'"' -f4)
|
||||
|
||||
if [[ -n "$config_digest" ]]; then
|
||||
echo "$config_digest"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
check_stack_updates_display() {
|
||||
local stack_path="$1"
|
||||
local stack_name="$2"
|
||||
local images
|
||||
mapfile -t images < <(get_stack_images "$stack_path")
|
||||
local has_updates=false
|
||||
|
||||
local temp_dir
|
||||
temp_dir=$(mktemp -d /tmp/docwell_update_check.XXXXXX)
|
||||
register_cleanup "rm -rf '$temp_dir'"
|
||||
|
||||
local pids=()
|
||||
local image_index=0
|
||||
|
||||
for image in "${images[@]}"; do
|
||||
[[ -z "$image" ]] && continue
|
||||
|
||||
(
|
||||
local output_file="$temp_dir/image_${image_index}.out"
|
||||
local status_file="$temp_dir/image_${image_index}.status"
|
||||
|
||||
local local_digest
|
||||
local_digest=$(get_image_digest "$image")
|
||||
|
||||
if [[ -z "$local_digest" ]]; then
|
||||
echo -e " ${YELLOW}?${NC} $image ${GRAY}[not present locally]${NC}" > "$output_file"
|
||||
echo "has_update" > "$status_file"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
local remote_digest
|
||||
remote_digest=$(get_remote_digest "$image") || true
|
||||
local used_manifest=true
|
||||
|
||||
if [[ -z "$remote_digest" ]]; then
|
||||
used_manifest=false
|
||||
if ! docker pull "$image" >/dev/null 2>&1; then
|
||||
echo -e " ${RED}✗${NC} $image ${GRAY}[check failed]${NC}" > "$output_file"
|
||||
exit 0
|
||||
fi
|
||||
remote_digest=$(get_image_digest "$image")
|
||||
fi
|
||||
|
||||
if [[ -n "$local_digest" && -n "$remote_digest" && "$local_digest" != "$remote_digest" ]]; then
|
||||
echo -e " ${YELLOW}↑${NC} $image ${GRAY}[update available]${NC}" > "$output_file"
|
||||
echo "has_update" > "$status_file"
|
||||
else
|
||||
local method_str=""
|
||||
[[ "$used_manifest" == "true" ]] && method_str=" [manifest]"
|
||||
echo -e " ${GREEN}✓${NC} $image ${GRAY}[up to date${method_str}]${NC}" > "$output_file"
|
||||
fi
|
||||
) &
|
||||
pids+=($!)
|
||||
((image_index++))
|
||||
done
|
||||
|
||||
for pid in "${pids[@]}"; do
|
||||
wait "$pid" 2>/dev/null || true
|
||||
done
|
||||
|
||||
for ((i=0; i<image_index; i++)); do
|
||||
local output_file="$temp_dir/image_${i}.out"
|
||||
local status_file="$temp_dir/image_${i}.status"
|
||||
|
||||
[[ -f "$output_file" ]] && cat "$output_file"
|
||||
|
||||
if [[ -f "$status_file" ]] && [[ "$(cat "$status_file")" == "has_update" ]]; then
|
||||
has_updates=true
|
||||
fi
|
||||
done
|
||||
|
||||
[[ "$has_updates" == "true" ]]
|
||||
}
|
||||
|
||||
check_for_updates() {
|
||||
show_spinner "Scanning stacks for available updates..."
|
||||
local stacks
|
||||
mapfile -t stacks < <(get_stacks)
|
||||
hide_spinner
|
||||
|
||||
local updates_available=()
|
||||
local total=${#stacks[@]}
|
||||
local idx=0
|
||||
|
||||
for stack in "${stacks[@]}"; do
|
||||
((idx++))
|
||||
local stack_path="$STACKS_DIR/$stack"
|
||||
if ! has_compose_file "$stack_path"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
echo -e "${CYAN}[$idx/$total]${NC} ${BOLD}$stack:${NC}"
|
||||
if check_stack_updates_display "$stack_path" "$stack"; then
|
||||
updates_available+=("$stack")
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
if [[ ${#updates_available[@]} -gt 0 ]]; then
|
||||
log_warn "${#updates_available[@]} stack(s) have updates available:"
|
||||
for stack in "${updates_available[@]}"; do
|
||||
echo " - $stack"
|
||||
done
|
||||
else
|
||||
log_success "All stacks are up to date"
|
||||
fi
|
||||
}
|
||||
|
||||
update_stack_cmd() {
|
||||
local stack="$1"
|
||||
local stack_path="$STACKS_DIR/$stack"
|
||||
|
||||
if ! has_compose_file "$stack_path"; then
|
||||
log_warn "No compose file found for $stack"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local compose_file
|
||||
compose_file=$(get_compose_file "$stack_path")
|
||||
local was_running=false
|
||||
|
||||
if is_stack_running "$stack"; then
|
||||
was_running=true
|
||||
fi
|
||||
|
||||
log_info "Updating $stack..."
|
||||
|
||||
if ! run_or_dry "pull images for $stack" docker compose -f "$stack_path/$compose_file" pull >/dev/null 2>&1; then
|
||||
log_warn "Failed to pull images for $stack"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ "$was_running" == "true" ]]; then
|
||||
if ! run_or_dry "recreate $stack" docker compose -f "$stack_path/$compose_file" up -d --force-recreate >/dev/null 2>&1; then
|
||||
log_error "Failed to recreate containers for $stack"
|
||||
log_warn "Images pulled but containers not recreated"
|
||||
return 1
|
||||
fi
|
||||
log_success "Updated $stack"
|
||||
else
|
||||
log_info "Stack was stopped, not starting $stack"
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
update_all_stacks() {
|
||||
log_warn "This will pull latest images and recreate all containers"
|
||||
if ! confirm "Continue?"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
local stacks
|
||||
mapfile -t stacks < <(get_stacks)
|
||||
local failed_stacks=()
|
||||
local total=${#stacks[@]}
|
||||
local idx=0
|
||||
local start_time=$SECONDS
|
||||
|
||||
for stack in "${stacks[@]}"; do
|
||||
((idx++))
|
||||
echo -e "${CYAN}[$idx/$total]${NC} Processing $stack..."
|
||||
if ! update_stack_cmd "$stack"; then
|
||||
failed_stacks+=("$stack")
|
||||
fi
|
||||
done
|
||||
|
||||
local elapsed=$(( SECONDS - start_time ))
|
||||
if [[ ${#failed_stacks[@]} -gt 0 ]]; then
|
||||
log_warn "Failed to update ${#failed_stacks[@]} stack(s): ${failed_stacks[*]}"
|
||||
else
|
||||
log_success "All stacks updated"
|
||||
fi
|
||||
log_info "Completed in $(format_elapsed "$elapsed")"
|
||||
}
|
||||
|
||||
auto_update_mode() {
|
||||
log_warn "This mode will:"
|
||||
echo " 1. Check for updates"
|
||||
echo " 2. Update stacks with available updates"
|
||||
echo
|
||||
|
||||
if ! confirm "Enable auto-update mode?"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
local stacks
|
||||
mapfile -t stacks < <(get_stacks)
|
||||
local total=${#stacks[@]}
|
||||
local idx=0
|
||||
local start_time=$SECONDS
|
||||
|
||||
for stack in "${stacks[@]}"; do
|
||||
((idx++))
|
||||
local stack_path="$STACKS_DIR/$stack"
|
||||
if ! has_compose_file "$stack_path"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
echo -e "${CYAN}[$idx/$total]${NC} Processing $stack..."
|
||||
|
||||
local images
|
||||
mapfile -t images < <(get_stack_images "$stack_path")
|
||||
local needs_update=false
|
||||
|
||||
for image in "${images[@]}"; do
|
||||
[[ -z "$image" ]] && continue
|
||||
local local_digest
|
||||
local_digest=$(get_image_digest "$image")
|
||||
[[ -z "$local_digest" ]] && continue
|
||||
|
||||
local remote_digest
|
||||
remote_digest=$(get_remote_digest "$image") || true
|
||||
|
||||
if [[ -z "$remote_digest" ]]; then
|
||||
docker pull "$image" >/dev/null 2>&1 || continue
|
||||
remote_digest=$(get_image_digest "$image")
|
||||
fi
|
||||
|
||||
if [[ -n "$local_digest" && -n "$remote_digest" && "$local_digest" != "$remote_digest" ]]; then
|
||||
needs_update=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$needs_update" == "false" ]]; then
|
||||
log_success "$stack already up to date"
|
||||
continue
|
||||
fi
|
||||
|
||||
log_warn "Updates available for $stack, updating..."
|
||||
if update_stack_cmd "$stack"; then
|
||||
sleep 3
|
||||
if is_stack_running "$stack"; then
|
||||
log_success "Update successful"
|
||||
else
|
||||
log_error "Update may have failed, check logs"
|
||||
fi
|
||||
fi
|
||||
echo
|
||||
done
|
||||
|
||||
local elapsed=$(( SECONDS - start_time ))
|
||||
log_success "Auto-update complete in $(format_elapsed "$elapsed")"
|
||||
}
|
||||
|
||||
# ─── Interactive UI Functions ────────────────────────────────────────────────
|
||||
|
||||
interactive_stack_menu() {
|
||||
local selected_stack="$1"
|
||||
|
||||
while true; do
|
||||
clear_screen
|
||||
log_header "Manage: $selected_stack"
|
||||
echo -e "${GRAY}DocWell Manager v$VERSION${NC}"
|
||||
echo
|
||||
|
||||
local status_str="${GRAY}●${NC} Stopped"
|
||||
if is_stack_running "$selected_stack"; then
|
||||
status_str="${GREEN}●${NC} Running"
|
||||
fi
|
||||
echo "Status: $status_str"
|
||||
echo
|
||||
echo "Stack Management:"
|
||||
echo " 1) Start"
|
||||
echo " 2) Stop"
|
||||
echo " 3) Restart"
|
||||
echo
|
||||
echo "Updates:"
|
||||
echo " 4) Update images"
|
||||
echo " 5) Check for updates"
|
||||
echo
|
||||
echo "Other:"
|
||||
echo " 6) View logs"
|
||||
echo " 7) View compose file"
|
||||
echo " 0) Back"
|
||||
echo
|
||||
|
||||
read -rp "Select action: " action
|
||||
|
||||
case $action in
|
||||
1) start_stack_cmd "$selected_stack" ;;
|
||||
2) stop_stack_cmd "$selected_stack" ;;
|
||||
3) restart_stack_cmd "$selected_stack" ;;
|
||||
4) update_stack_cmd "$selected_stack" ;;
|
||||
5)
|
||||
local stack_path="$STACKS_DIR/$selected_stack"
|
||||
if has_compose_file "$stack_path"; then
|
||||
echo -e "${BOLD}$selected_stack:${NC}"
|
||||
check_stack_updates_display "$stack_path" "$selected_stack" || true
|
||||
fi
|
||||
;;
|
||||
6) view_logs "$selected_stack" ;;
|
||||
7)
|
||||
local stack_path="$STACKS_DIR/$selected_stack"
|
||||
local compose_file
|
||||
compose_file=$(get_compose_file "$stack_path") && \
|
||||
less "$stack_path/$compose_file" || \
|
||||
log_error "No compose file found"
|
||||
;;
|
||||
0) break ;;
|
||||
*) log_error "Invalid selection" ;;
|
||||
esac
|
||||
|
||||
echo
|
||||
read -rp "Press Enter to continue..."
|
||||
done
|
||||
}
|
||||
|
||||
interactive_main_menu() {
|
||||
while true; do
|
||||
clear_screen
|
||||
log_header "Docker Stack Manager"
|
||||
echo -e "${GRAY}DocWell Manager v$VERSION${NC}"
|
||||
echo
|
||||
|
||||
show_spinner "Loading stacks..."
|
||||
local stacks
|
||||
mapfile -t stacks < <(get_stacks)
|
||||
hide_spinner
|
||||
|
||||
if [[ ${#stacks[@]} -eq 0 ]]; then
|
||||
log_error "No stacks found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Available stacks:"
|
||||
for i in "${!stacks[@]}"; do
|
||||
local stack="${stacks[$i]}"
|
||||
local status_str="${GRAY}[stopped]${NC}"
|
||||
if is_stack_running "$stack"; then
|
||||
status_str="${GREEN}[running]${NC}"
|
||||
fi
|
||||
echo -e " ${BOLD}$((i+1))${NC}) ${stack:0:30} $status_str"
|
||||
done
|
||||
echo
|
||||
log_separator
|
||||
echo -e " ${BOLD}r${NC}) Restart all"
|
||||
echo -e " ${BOLD}s${NC}) Stop all"
|
||||
echo -e " ${BOLD}u${NC}) Update all"
|
||||
echo -e " ${BOLD}c${NC}) Check for updates"
|
||||
echo -e " ${BOLD}0${NC}) Exit"
|
||||
echo
|
||||
|
||||
read -rp "Select stack number or action: " choice
|
||||
|
||||
if [[ "$choice" == "0" ]] || [[ "$choice" == "q" ]] || [[ "$choice" == "quit" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "$choice" in
|
||||
r)
|
||||
for stack in "${stacks[@]}"; do
|
||||
restart_stack_cmd "$stack" || true
|
||||
done
|
||||
read -rp "Press Enter to continue..."
|
||||
;;
|
||||
s)
|
||||
for stack in "${stacks[@]}"; do
|
||||
stop_stack_cmd "$stack" || true
|
||||
done
|
||||
read -rp "Press Enter to continue..."
|
||||
;;
|
||||
u)
|
||||
update_all_stacks
|
||||
read -rp "Press Enter to continue..."
|
||||
;;
|
||||
c)
|
||||
check_for_updates
|
||||
read -rp "Press Enter to continue..."
|
||||
;;
|
||||
*)
|
||||
if [[ "$choice" =~ ^[0-9]+$ ]] && [[ "$choice" -ge 1 ]] && [[ "$choice" -le "${#stacks[@]}" ]]; then
|
||||
local idx=$((choice-1))
|
||||
interactive_stack_menu "${stacks[$idx]}"
|
||||
else
|
||||
log_error "Invalid selection"
|
||||
read -rp "Press Enter to continue..."
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# ─── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
main() {
|
||||
parse_args "$@"
|
||||
|
||||
if ! check_docker; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Handle list only
|
||||
[[ "$LIST_ONLY" == "true" ]] && { list_stacks_display; exit 0; }
|
||||
|
||||
# Handle stack management CLI operations
|
||||
[[ -n "$START_STACK" ]] && { start_stack_cmd "$START_STACK"; exit $?; }
|
||||
[[ -n "$STOP_STACK" ]] && { stop_stack_cmd "$STOP_STACK"; exit $?; }
|
||||
[[ -n "$RESTART_STACK" ]] && { restart_stack_cmd "$RESTART_STACK"; exit $?; }
|
||||
[[ -n "$STATUS_STACK" ]] && { get_stack_status_cmd "$STATUS_STACK"; exit 0; }
|
||||
[[ -n "$LOGS_STACK" ]] && { view_logs "$LOGS_STACK"; exit $?; }
|
||||
|
||||
# Handle update CLI operations
|
||||
[[ "$CHECK_UPDATES" == "true" ]] && { check_for_updates; exit 0; }
|
||||
[[ "$UPDATE_ALL" == "true" ]] && { update_all_stacks; exit 0; }
|
||||
[[ -n "$UPDATE_STACK" ]] && { update_stack_cmd "$UPDATE_STACK"; exit $?; }
|
||||
[[ "$AUTO_UPDATE" == "true" ]] && { auto_update_mode; exit 0; }
|
||||
|
||||
# Interactive mode
|
||||
interactive_main_menu
|
||||
}
|
||||
|
||||
main "$@"
|
||||
758
bash/docker-migrate.sh
Executable file
758
bash/docker-migrate.sh
Executable file
@@ -0,0 +1,758 @@
|
||||
#!/bin/bash
|
||||
# Docker Service Migration Script
|
||||
# Enhanced version matching Go docwell functionality
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Version
|
||||
VERSION="2.6.2"
|
||||
|
||||
# Resolve script directory and source shared library
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=lib/common.sh
|
||||
source "$SCRIPT_DIR/lib/common.sh"
|
||||
|
||||
# Enable debug trace after sourcing common.sh (so DEBUG variable is available)
|
||||
enable_debug_trace
|
||||
|
||||
# Default configuration
|
||||
LOG_FILE="${LOG_FILE:-/tmp/docwell/docker-migrate.log}"
|
||||
HOSTNAME=$(hostname 2>/dev/null | tr -cd 'a-zA-Z0-9._-' || echo "unknown")
|
||||
|
||||
# Source/destination configuration
|
||||
OLD_HOST="${OLD_HOST:-local}"
|
||||
OLD_PORT="${OLD_PORT:-22}"
|
||||
OLD_USER="${OLD_USER:-$(whoami)}"
|
||||
NEW_HOST="${NEW_HOST:-}"
|
||||
NEW_PORT="${NEW_PORT:-22}"
|
||||
NEW_USER="${NEW_USER:-$(whoami)}"
|
||||
|
||||
# Transfer options
|
||||
TRANSFER_METHOD="rsync"
|
||||
TRANSFER_MODE="clone" # clone (non-disruptive) or transfer (stop source)
|
||||
COMPRESSION="zstd"
|
||||
BANDWIDTH_MB="${BANDWIDTH_MB:-0}"
|
||||
TRANSFER_RETRIES="${TRANSFER_RETRIES:-3}"
|
||||
SERVICE_NAME=""
|
||||
|
||||
# Load config file overrides
|
||||
load_config
|
||||
|
||||
parse_args() {
|
||||
# Support legacy positional argument: first arg as service name
|
||||
if [[ $# -gt 0 ]] && [[ ! "$1" =~ ^-- ]]; then
|
||||
SERVICE_NAME="$1"
|
||||
shift
|
||||
fi
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--version)
|
||||
echo "docker-migrate.sh v$VERSION"
|
||||
exit 0
|
||||
;;
|
||||
--quiet|-q)
|
||||
QUIET=true
|
||||
shift
|
||||
;;
|
||||
--yes|-y)
|
||||
YES=true
|
||||
shift
|
||||
;;
|
||||
--service)
|
||||
require_arg "$1" "${2:-}" || exit 1
|
||||
SERVICE_NAME="$2"
|
||||
shift 2
|
||||
;;
|
||||
--source)
|
||||
require_arg "$1" "${2:-}" || exit 1
|
||||
OLD_HOST="$2"
|
||||
shift 2
|
||||
;;
|
||||
--dest)
|
||||
require_arg "$1" "${2:-}" || exit 1
|
||||
NEW_HOST="$2"
|
||||
shift 2
|
||||
;;
|
||||
--method)
|
||||
TRANSFER_METHOD="$2"
|
||||
shift 2
|
||||
;;
|
||||
--transfer)
|
||||
TRANSFER_MODE="transfer"
|
||||
shift
|
||||
;;
|
||||
--compression)
|
||||
COMPRESSION="$2"
|
||||
shift 2
|
||||
;;
|
||||
--bwlimit)
|
||||
BANDWIDTH_MB="$2"
|
||||
shift 2
|
||||
;;
|
||||
--retries)
|
||||
TRANSFER_RETRIES="$2"
|
||||
shift 2
|
||||
;;
|
||||
--stacks-dir)
|
||||
STACKS_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--install-deps)
|
||||
AUTO_INSTALL=true
|
||||
shift
|
||||
;;
|
||||
--log)
|
||||
LOG_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
shift
|
||||
;;
|
||||
--debug|-d)
|
||||
DEBUG=true
|
||||
shift
|
||||
;;
|
||||
--verbose|-v)
|
||||
VERBOSE=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Unknown option: $1${NC}" >&2
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
show_help() {
|
||||
cat << EOF
|
||||
Docker Service Migration Script v$VERSION
|
||||
|
||||
Usage: $0 [SERVICE] [OPTIONS]
|
||||
|
||||
Options:
|
||||
--service SERVICE Service name to migrate
|
||||
--source HOST Source host (default: local)
|
||||
--dest HOST Destination host/user@host
|
||||
--method METHOD Transfer method: rsync, tar, rclone (default: rsync)
|
||||
--transfer Transfer mode (stop source), default is clone mode
|
||||
--compression METHOD Compression: zstd or gzip (default: zstd)
|
||||
--bwlimit MB Bandwidth limit in MB/s for rsync (0 = unlimited)
|
||||
--retries N Number of retries for failed transfers (default: 3)
|
||||
--stacks-dir DIR Stacks directory (default: /opt/stacks)
|
||||
--quiet, -q Suppress non-error output
|
||||
--yes, -y Auto-confirm all prompts
|
||||
--dry-run Show what would be done without actually doing it
|
||||
--debug, -d Enable debug mode (verbose output + xtrace)
|
||||
--verbose, -v Enable verbose logging
|
||||
--install-deps Auto-install missing dependencies (requires root)
|
||||
--log FILE Log file path
|
||||
--version Show version information
|
||||
--help, -h Show this help message
|
||||
|
||||
Examples:
|
||||
$0 myapp --dest user@remote-host
|
||||
$0 --service myapp --source local --dest user@remote --method rsync
|
||||
$0 --service myapp --source old-server --dest new-server --transfer
|
||||
$0 --service myapp --dest user@remote --bwlimit 50 --retries 5
|
||||
$0 --service myapp --dest user@remote --dry-run
|
||||
EOF
|
||||
}
|
||||
|
||||
# ─── Dependency checks ───────────────────────────────────────────────────────
|
||||
|
||||
check_dependencies() {
|
||||
local required_cmds=("docker" "ssh")
|
||||
local missing=()
|
||||
|
||||
case "$TRANSFER_METHOD" in
|
||||
rsync) required_cmds+=("rsync") ;;
|
||||
rclone) required_cmds+=("rclone") ;;
|
||||
esac
|
||||
|
||||
for cmd in "${required_cmds[@]}"; do
|
||||
if ! command -v "$cmd" &>/dev/null; then
|
||||
missing+=("$cmd")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#missing[@]} -gt 0 ]]; then
|
||||
log_error "Missing dependencies: ${missing[*]}"
|
||||
if [[ "$AUTO_INSTALL" == "true" ]]; then
|
||||
install_dependencies "${missing[@]}"
|
||||
return $?
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# ─── Parse user@host:port ─────────────────────────────────────────────────────
|
||||
|
||||
parse_host_string() {
|
||||
local input="$1"
|
||||
local -n _host="$2"
|
||||
local -n _user="$3"
|
||||
local -n _port="$4"
|
||||
|
||||
if [[ "$input" == "local" ]]; then
|
||||
_host="local"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Parse user@host:port
|
||||
if [[ "$input" =~ ^(.+)@(.+):([0-9]+)$ ]]; then
|
||||
_user="${BASH_REMATCH[1]}"
|
||||
_host="${BASH_REMATCH[2]}"
|
||||
_port="${BASH_REMATCH[3]}"
|
||||
elif [[ "$input" =~ ^(.+)@(.+)$ ]]; then
|
||||
_user="${BASH_REMATCH[1]}"
|
||||
_host="${BASH_REMATCH[2]}"
|
||||
elif [[ "$input" =~ ^(.+):([0-9]+)$ ]]; then
|
||||
_host="${BASH_REMATCH[1]}"
|
||||
_port="${BASH_REMATCH[2]}"
|
||||
else
|
||||
_host="$input"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Pre-flight checks ───────────────────────────────────────────────────────
|
||||
|
||||
preflight_checks() {
|
||||
local service="$1"
|
||||
|
||||
log_info "Running pre-flight checks..."
|
||||
|
||||
# Validate service name
|
||||
if ! validate_stack_name "$service"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check source
|
||||
if [[ "$OLD_HOST" == "local" ]]; then
|
||||
if [[ ! -d "$STACKS_DIR/$service" ]]; then
|
||||
log_error "Service not found locally: $STACKS_DIR/$service"
|
||||
return 1
|
||||
fi
|
||||
log_success "Source: local ($STACKS_DIR/$service)"
|
||||
else
|
||||
log_info "Source: $OLD_USER@$OLD_HOST:$OLD_PORT"
|
||||
if ! test_ssh "$OLD_HOST" "$OLD_PORT" "$OLD_USER"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Verify service exists on source
|
||||
if ! ssh_cmd "$OLD_HOST" "$OLD_PORT" "$OLD_USER" "test -d '$STACKS_DIR/$service'" 2>/dev/null; then
|
||||
log_error "Service not found on source: $STACKS_DIR/$service"
|
||||
return 1
|
||||
fi
|
||||
log_success "Service found on source"
|
||||
fi
|
||||
|
||||
# Check destination
|
||||
if [[ -z "$NEW_HOST" ]]; then
|
||||
log_error "Destination host not specified"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Destination: $NEW_USER@$NEW_HOST:$NEW_PORT"
|
||||
if ! test_ssh "$NEW_HOST" "$NEW_PORT" "$NEW_USER"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Service conflict check: verify service doesn't already exist on destination
|
||||
if ssh_cmd "$NEW_HOST" "$NEW_PORT" "$NEW_USER" "test -d '$STACKS_DIR/$service'" 2>/dev/null; then
|
||||
log_warn "Service already exists on destination: $STACKS_DIR/$service"
|
||||
if ! confirm "Overwrite existing service on destination?"; then
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
log_success "Pre-flight checks passed"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ─── Transfer functions ──────────────────────────────────────────────────────
|
||||
|
||||
rsync_transfer() {
|
||||
local src="$1" dst_host="$2" dst_port="$3" dst_user="$4" dst_path="$5"
|
||||
local attempt=0
|
||||
local max_retries="$TRANSFER_RETRIES"
|
||||
|
||||
local rsync_opts=(-avz --progress --delete)
|
||||
|
||||
# Bandwidth limiting
|
||||
if [[ "$BANDWIDTH_MB" -gt 0 ]]; then
|
||||
local bw_kbps=$((BANDWIDTH_MB * 1024))
|
||||
rsync_opts+=(--bwlimit="$bw_kbps")
|
||||
log_info "Bandwidth limited to ${BANDWIDTH_MB}MB/s"
|
||||
fi
|
||||
|
||||
# Compression
|
||||
local comp_info
|
||||
comp_info=$(get_compressor "$COMPRESSION")
|
||||
local compressor="${comp_info%%:*}"
|
||||
if [[ "$compressor" == "zstd" ]]; then
|
||||
rsync_opts+=(--compress-choice=zstd)
|
||||
fi
|
||||
|
||||
rsync_opts+=(-e "ssh -p $dst_port -o StrictHostKeyChecking=no -o ConnectTimeout=10")
|
||||
|
||||
while [[ $attempt -lt $max_retries ]]; do
|
||||
((attempt++))
|
||||
if [[ $attempt -gt 1 ]]; then
|
||||
local backoff=$((attempt * 5))
|
||||
log_warn "Retry $attempt/$max_retries in ${backoff}s..."
|
||||
sleep "$backoff"
|
||||
fi
|
||||
|
||||
if run_or_dry "rsync $src to $dst_user@$dst_host:$dst_path" \
|
||||
rsync "${rsync_opts[@]}" "$src" "$dst_user@$dst_host:$dst_path"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_warn "Transfer attempt $attempt failed"
|
||||
done
|
||||
|
||||
log_error "Transfer failed after $max_retries attempts"
|
||||
return 1
|
||||
}
|
||||
|
||||
tar_transfer() {
|
||||
local src="$1" dst_host="$2" dst_port="$3" dst_user="$4" dst_path="$5"
|
||||
|
||||
local comp_info
|
||||
comp_info=$(get_compressor "$COMPRESSION")
|
||||
local compressor="${comp_info%%:*}"
|
||||
|
||||
log_info "Transferring via tar+$compressor..."
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log_info "[DRY-RUN] Would tar $src | $compressor | ssh -> $dst_path"
|
||||
return 0
|
||||
fi
|
||||
|
||||
ssh_cmd "$dst_host" "$dst_port" "$dst_user" "mkdir -p '$dst_path'" || return 1
|
||||
|
||||
tar -cf - -C "$(dirname "$src")" "$(basename "$src")" | \
|
||||
$compressor | \
|
||||
ssh -p "$dst_port" -o StrictHostKeyChecking=no "$dst_user@$dst_host" \
|
||||
"cd '$dst_path' && $compressor -d | tar -xf -"
|
||||
}
|
||||
|
||||
rclone_transfer() {
|
||||
local src="$1" dst_host="$2" dst_port="$3" dst_user="$4" dst_path="$5"
|
||||
|
||||
if ! command -v rclone &>/dev/null; then
|
||||
log_error "rclone not installed, falling back to rsync"
|
||||
rsync_transfer "$src" "$dst_host" "$dst_port" "$dst_user" "$dst_path"
|
||||
return $?
|
||||
fi
|
||||
|
||||
log_info "Transferring via rclone SFTP..."
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log_info "[DRY-RUN] Would rclone sync $src to :sftp:$dst_host:$dst_path"
|
||||
return 0
|
||||
fi
|
||||
|
||||
rclone sync "$src" ":sftp,host=$dst_host,user=$dst_user,port=$dst_port:$dst_path" \
|
||||
--transfers "$((MAX_PARALLEL > 16 ? 16 : MAX_PARALLEL))" \
|
||||
--progress \
|
||||
--sftp-ask-password \
|
||||
2>&1 || {
|
||||
log_error "rclone transfer failed"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
file_transfer() {
|
||||
local method="$1" src="$2" dst_host="$3" dst_port="$4" dst_user="$5" dst_path="$6"
|
||||
|
||||
case "$method" in
|
||||
rsync) rsync_transfer "$src" "$dst_host" "$dst_port" "$dst_user" "$dst_path" ;;
|
||||
tar) tar_transfer "$src" "$dst_host" "$dst_port" "$dst_user" "$dst_path" ;;
|
||||
rclone) rclone_transfer "$src" "$dst_host" "$dst_port" "$dst_user" "$dst_path" ;;
|
||||
*)
|
||||
log_error "Unknown transfer method: $method"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ─── Volume operations ────────────────────────────────────────────────────────
|
||||
|
||||
backup_volume() {
|
||||
local vol="$1"
|
||||
local backup_dir="$2"
|
||||
|
||||
local comp_info
|
||||
comp_info=$(get_compressor "$COMPRESSION")
|
||||
local compressor="${comp_info%%:*}"
|
||||
local ext="${comp_info#*:}"
|
||||
|
||||
local archive="$backup_dir/${vol}${ext}"
|
||||
|
||||
log_info "Backing up volume: $vol"
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log_info "[DRY-RUN] Would backup volume $vol to $archive"
|
||||
return 0
|
||||
fi
|
||||
|
||||
docker run --rm \
|
||||
-v "${vol}:/data:ro" \
|
||||
-v "$backup_dir:/backup" \
|
||||
alpine \
|
||||
sh -c "tar -cf - -C /data . | $compressor > /backup/${vol}${ext}" 2>/dev/null || {
|
||||
log_error "Failed to backup volume: $vol"
|
||||
return 1
|
||||
}
|
||||
log_success "Volume $vol backed up"
|
||||
}
|
||||
|
||||
restore_volume() {
|
||||
local vol="$1"
|
||||
local backup_dir="$2"
|
||||
local host="$3" port="$4" user="$5"
|
||||
|
||||
# Detect archive format
|
||||
local archive=""
|
||||
local decompressor=""
|
||||
if [[ -f "$backup_dir/${vol}.tar.zst" ]]; then
|
||||
archive="$backup_dir/${vol}.tar.zst"
|
||||
decompressor="zstd -d"
|
||||
elif [[ -f "$backup_dir/${vol}.tar.gz" ]]; then
|
||||
archive="$backup_dir/${vol}.tar.gz"
|
||||
decompressor="gzip -d"
|
||||
else
|
||||
log_error "No backup found for volume: $vol"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Restoring volume: $vol"
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log_info "[DRY-RUN] Would restore volume $vol from $archive"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$host" == "local" ]]; then
|
||||
docker run --rm \
|
||||
-v "${vol}:/data" \
|
||||
-v "$backup_dir:/backup:ro" \
|
||||
alpine \
|
||||
sh -c "cd /data && cat /backup/$(basename "$archive") | $decompressor | tar -xf -" 2>/dev/null || {
|
||||
log_error "Failed to restore volume: $vol"
|
||||
return 1
|
||||
}
|
||||
else
|
||||
ssh_cmd "$host" "$port" "$user" \
|
||||
"docker run --rm -v '${vol}:/data' -v '$backup_dir:/backup:ro' alpine sh -c 'cd /data && cat /backup/$(basename "$archive") | $decompressor | tar -xf -'" 2>/dev/null || {
|
||||
log_error "Failed to restore volume on remote: $vol"
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
|
||||
log_success "Volume $vol restored"
|
||||
}
|
||||
|
||||
# ─── Post-migration verification ─────────────────────────────────────────────
|
||||
|
||||
verify_migration() {
|
||||
local service="$1"
|
||||
local host="$2" port="$3" user="$4"
|
||||
|
||||
log_info "Verifying migration..."
|
||||
|
||||
# Check service directory exists on destination
|
||||
if ! ssh_cmd "$host" "$port" "$user" "test -d '$STACKS_DIR/$service'" 2>/dev/null; then
|
||||
log_error "Service directory not found on destination"
|
||||
return 1
|
||||
fi
|
||||
log_success "Service directory present"
|
||||
|
||||
# Check compose file exists
|
||||
local compose_found=false
|
||||
for f in compose.yaml compose.yml docker-compose.yaml docker-compose.yml; do
|
||||
if ssh_cmd "$host" "$port" "$user" "test -f '$STACKS_DIR/$service/$f'" 2>/dev/null; then
|
||||
compose_found=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$compose_found" == "false" ]]; then
|
||||
log_error "No compose file found on destination"
|
||||
return 1
|
||||
fi
|
||||
log_success "Compose file present"
|
||||
|
||||
# Wait and check if service started
|
||||
log_info "Waiting for service health check (5s)..."
|
||||
sleep 5
|
||||
|
||||
if ssh_cmd "$host" "$port" "$user" \
|
||||
"cd '$STACKS_DIR/$service' && docker compose ps -q 2>/dev/null | head -1" 2>/dev/null | grep -q .; then
|
||||
log_success "Service is running on destination"
|
||||
else
|
||||
log_warn "Service may not be running on destination yet"
|
||||
fi
|
||||
|
||||
log_success "Migration verification passed"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ─── Main migration logic ────────────────────────────────────────────────────
|
||||
|
||||
migrate_service() {
|
||||
local service="$1"
|
||||
local start_time=$SECONDS
|
||||
|
||||
log_header "Migrating Service: $service"
|
||||
log_info "Mode: $TRANSFER_MODE | Method: $TRANSFER_METHOD | Compression: $COMPRESSION"
|
||||
write_log "Starting migration of $service from $OLD_HOST to $NEW_HOST"
|
||||
|
||||
# Create temp dir for volume backups
|
||||
local vol_backup_dir
|
||||
vol_backup_dir=$(mktemp -d /tmp/docwell_migrate.XXXXXX)
|
||||
register_cleanup "rm -rf '$vol_backup_dir'"
|
||||
|
||||
# Step 1: Transfer configuration
|
||||
log_info "[Step 1/5] Transferring configuration..."
|
||||
local stack_path="$STACKS_DIR/$service"
|
||||
|
||||
if [[ "$OLD_HOST" == "local" ]]; then
|
||||
file_transfer "$TRANSFER_METHOD" "$stack_path/" "$NEW_HOST" "$NEW_PORT" "$NEW_USER" "$STACKS_DIR/$service/"
|
||||
else
|
||||
# Remote to remote: pull from source then push to dest
|
||||
local local_temp
|
||||
local_temp=$(mktemp -d /tmp/docwell_config.XXXXXX)
|
||||
register_cleanup "rm -rf '$local_temp'"
|
||||
|
||||
rsync -avz -e "ssh -p $OLD_PORT -o StrictHostKeyChecking=no" \
|
||||
"$OLD_USER@$OLD_HOST:$stack_path/" "$local_temp/$service/" >/dev/null 2>&1 || {
|
||||
log_error "Failed to pull config from source"
|
||||
return 1
|
||||
}
|
||||
file_transfer "$TRANSFER_METHOD" "$local_temp/$service/" "$NEW_HOST" "$NEW_PORT" "$NEW_USER" "$STACKS_DIR/$service/"
|
||||
fi
|
||||
log_success "Configuration transferred"
|
||||
|
||||
# Step 2: Get volumes
|
||||
log_info "[Step 2/5] Discovering volumes..."
|
||||
local volumes
|
||||
mapfile -t volumes < <(get_service_volumes "$service")
|
||||
|
||||
if [[ ${#volumes[@]} -eq 0 ]] || [[ -z "${volumes[0]}" ]]; then
|
||||
log_info "No volumes to migrate"
|
||||
else
|
||||
log_info "Found ${#volumes[@]} volume(s)"
|
||||
|
||||
# Step 3: Backup volumes
|
||||
log_info "[Step 3/5] Backing up volumes..."
|
||||
local vol_idx=0
|
||||
for vol in "${volumes[@]}"; do
|
||||
[[ -z "$vol" ]] && continue
|
||||
((vol_idx++))
|
||||
echo -e "${CYAN}[$vol_idx/${#volumes[@]}]${NC} Volume: $vol"
|
||||
backup_volume "$vol" "$vol_backup_dir" || {
|
||||
log_error "Failed to backup volume: $vol"
|
||||
return 1
|
||||
}
|
||||
done
|
||||
|
||||
# Step 4: Transfer and restore volumes
|
||||
log_info "[Step 4/5] Transferring volumes..."
|
||||
file_transfer "$TRANSFER_METHOD" "$vol_backup_dir/" "$NEW_HOST" "$NEW_PORT" "$NEW_USER" "$vol_backup_dir/"
|
||||
|
||||
log_info "Restoring volumes on destination..."
|
||||
vol_idx=0
|
||||
for vol in "${volumes[@]}"; do
|
||||
[[ -z "$vol" ]] && continue
|
||||
((vol_idx++))
|
||||
|
||||
# Create volume on destination
|
||||
ssh_cmd "$NEW_HOST" "$NEW_PORT" "$NEW_USER" "docker volume create '$vol'" >/dev/null 2>&1 || true
|
||||
|
||||
echo -e "${CYAN}[$vol_idx/${#volumes[@]}]${NC} Restoring: $vol"
|
||||
restore_volume "$vol" "$vol_backup_dir" "$NEW_HOST" "$NEW_PORT" "$NEW_USER" || {
|
||||
log_warn "Failed to restore volume: $vol"
|
||||
}
|
||||
done
|
||||
fi
|
||||
|
||||
# Step 5: Start on destination and optionally stop source
|
||||
log_info "[Step 5/5] Starting service on destination..."
|
||||
local remote_compose
|
||||
for f in compose.yaml compose.yml docker-compose.yaml docker-compose.yml; do
|
||||
if ssh_cmd "$NEW_HOST" "$NEW_PORT" "$NEW_USER" "test -f '$STACKS_DIR/$service/$f'" 2>/dev/null; then
|
||||
remote_compose="$f"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -n "${remote_compose:-}" ]]; then
|
||||
if run_or_dry "start service on destination" \
|
||||
ssh_cmd "$NEW_HOST" "$NEW_PORT" "$NEW_USER" \
|
||||
"cd '$STACKS_DIR/$service' && docker compose -f '$remote_compose' up -d" >/dev/null 2>&1; then
|
||||
log_success "Service started on destination"
|
||||
else
|
||||
log_error "Failed to start service on destination"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Transfer mode: stop source
|
||||
if [[ "$TRANSFER_MODE" == "transfer" ]]; then
|
||||
log_info "Transfer mode: stopping source service..."
|
||||
if [[ "$OLD_HOST" == "local" ]]; then
|
||||
local compose_file
|
||||
compose_file=$(get_compose_file "$stack_path") && \
|
||||
run_or_dry "stop local source" docker compose -f "$stack_path/$compose_file" down >/dev/null 2>&1
|
||||
else
|
||||
run_or_dry "stop remote source" ssh_cmd "$OLD_HOST" "$OLD_PORT" "$OLD_USER" \
|
||||
"cd '$STACKS_DIR/$service' && docker compose down" >/dev/null 2>&1 || {
|
||||
log_warn "Could not stop source service"
|
||||
}
|
||||
fi
|
||||
log_success "Source service stopped"
|
||||
fi
|
||||
|
||||
# Post-migration verification
|
||||
verify_migration "$service" "$NEW_HOST" "$NEW_PORT" "$NEW_USER" || {
|
||||
log_warn "Post-migration verification had warnings"
|
||||
}
|
||||
|
||||
local elapsed=$(( SECONDS - start_time ))
|
||||
log_success "Migration completed in $(format_elapsed "$elapsed")"
|
||||
}
|
||||
|
||||
# ─── Interactive mode ─────────────────────────────────────────────────────────
|
||||
|
||||
interactive_migrate() {
|
||||
clear_screen
|
||||
log_header "Docker Service Migration"
|
||||
echo -e "${GRAY}DocWell Migration v$VERSION${NC}"
|
||||
echo
|
||||
|
||||
# Service selection
|
||||
show_spinner "Loading stacks..."
|
||||
local stacks
|
||||
mapfile -t stacks < <(get_stacks)
|
||||
hide_spinner
|
||||
|
||||
if [[ ${#stacks[@]} -eq 0 ]]; then
|
||||
log_error "No stacks found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Available services:"
|
||||
for i in "${!stacks[@]}"; do
|
||||
local status="${GRAY}●${NC}"
|
||||
is_stack_running "${stacks[$i]}" && status="${GREEN}●${NC}"
|
||||
echo -e " ${BOLD}$((i+1))${NC}) $status ${stacks[$i]}"
|
||||
done
|
||||
echo
|
||||
|
||||
read -rp "Select service to migrate: " choice
|
||||
if [[ ! "$choice" =~ ^[0-9]+$ ]] || [[ "$choice" -lt 1 ]] || [[ "$choice" -gt "${#stacks[@]}" ]]; then
|
||||
log_error "Invalid selection"
|
||||
exit 1
|
||||
fi
|
||||
SERVICE_NAME="${stacks[$((choice-1))]}"
|
||||
|
||||
# Source
|
||||
read -rp "Source host [${OLD_HOST}]: " input_host
|
||||
[[ -n "$input_host" ]] && OLD_HOST="$input_host"
|
||||
|
||||
# Destination
|
||||
read -rp "Destination host: " NEW_HOST
|
||||
if [[ -z "$NEW_HOST" ]]; then
|
||||
log_error "Destination required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse user@host for destination
|
||||
parse_host_string "$NEW_HOST" NEW_HOST NEW_USER NEW_PORT
|
||||
|
||||
# Transfer method
|
||||
echo
|
||||
echo "Transfer methods:"
|
||||
echo " 1) rsync (recommended)"
|
||||
echo " 2) tar over SSH"
|
||||
echo " 3) rclone"
|
||||
read -rp "Select method [1]: " method_choice
|
||||
case "${method_choice:-1}" in
|
||||
2) TRANSFER_METHOD="tar" ;;
|
||||
3) TRANSFER_METHOD="rclone" ;;
|
||||
*) TRANSFER_METHOD="rsync" ;;
|
||||
esac
|
||||
|
||||
# Transfer mode
|
||||
echo
|
||||
echo "Transfer modes:"
|
||||
echo " 1) Clone (keep source running)"
|
||||
echo " 2) Transfer (stop source after)"
|
||||
read -rp "Select mode [1]: " mode_choice
|
||||
case "${mode_choice:-1}" in
|
||||
2) TRANSFER_MODE="transfer" ;;
|
||||
*) TRANSFER_MODE="clone" ;;
|
||||
esac
|
||||
|
||||
echo
|
||||
log_separator
|
||||
echo -e " Service: ${BOLD}$SERVICE_NAME${NC}"
|
||||
echo -e " Source: $OLD_USER@$OLD_HOST:$OLD_PORT"
|
||||
echo -e " Destination: $NEW_USER@$NEW_HOST:$NEW_PORT"
|
||||
echo -e " Method: $TRANSFER_METHOD"
|
||||
echo -e " Mode: $TRANSFER_MODE"
|
||||
log_separator
|
||||
echo
|
||||
|
||||
if ! confirm "Proceed with migration?"; then
|
||||
log_info "Migration cancelled"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! preflight_checks "$SERVICE_NAME"; then
|
||||
log_error "Pre-flight checks failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
migrate_service "$SERVICE_NAME"
|
||||
}
|
||||
|
||||
# ─── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
main() {
|
||||
parse_args "$@"
|
||||
|
||||
if ! check_dependencies; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse destination user@host if provided
|
||||
if [[ -n "$NEW_HOST" ]]; then
|
||||
parse_host_string "$NEW_HOST" NEW_HOST NEW_USER NEW_PORT
|
||||
fi
|
||||
|
||||
# CLI mode
|
||||
if [[ -n "$SERVICE_NAME" ]] && [[ -n "$NEW_HOST" ]]; then
|
||||
if ! preflight_checks "$SERVICE_NAME"; then
|
||||
exit 1
|
||||
fi
|
||||
migrate_service "$SERVICE_NAME"
|
||||
exit $?
|
||||
fi
|
||||
|
||||
# Interactive mode
|
||||
interactive_migrate
|
||||
}
|
||||
|
||||
main "$@"
|
||||
712
bash/lib/common.sh
Executable file
712
bash/lib/common.sh
Executable file
@@ -0,0 +1,712 @@
|
||||
#!/bin/bash
|
||||
# DocWell Shared Library
|
||||
# Common functions used across all docker-tools bash scripts
|
||||
# Source this file: source "$(dirname "$0")/lib/common.sh"
|
||||
|
||||
# Version
|
||||
LIB_VERSION="2.6.2"
|
||||
|
||||
# ─── Colors (with NO_COLOR support) ──────────────────────────────────────────
|
||||
if [[ -n "${NO_COLOR:-}" ]] || [[ ! -t 1 ]]; then
|
||||
RED='' GREEN='' YELLOW='' BLUE='' GRAY='' NC='' BOLD='' CYAN=''
|
||||
else
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
GRAY='\033[0;90m'
|
||||
NC='\033[0m'
|
||||
BOLD='\033[1m'
|
||||
CYAN='\033[0;36m'
|
||||
fi
|
||||
|
||||
# ─── Common defaults ─────────────────────────────────────────────────────────
|
||||
STACKS_DIR="${STACKS_DIR:-/opt/stacks}"
|
||||
QUIET="${QUIET:-false}"
|
||||
YES="${YES:-false}"
|
||||
DRY_RUN="${DRY_RUN:-false}"
|
||||
AUTO_INSTALL="${AUTO_INSTALL:-false}"
|
||||
LOG_FILE="${LOG_FILE:-/tmp/docwell/docwell.log}"
|
||||
DEBUG="${DEBUG:-false}"
|
||||
VERBOSE="${VERBOSE:-false}"
|
||||
|
||||
# Concurrency limit (bounded by nproc)
|
||||
MAX_PARALLEL="${MAX_PARALLEL:-$(nproc 2>/dev/null || echo 4)}"
|
||||
|
||||
# ─── Debug and Tracing ──────────────────────────────────────────────────────
|
||||
# Enable xtrace if DEBUG is set (but preserve existing set options)
|
||||
# Note: This should be called after scripts set their own 'set' options
|
||||
enable_debug_trace() {
|
||||
if [[ "${DEBUG}" == "true" ]]; then
|
||||
# Check if we're already in a script context with set -euo pipefail
|
||||
# If so, preserve those flags when enabling -x
|
||||
if [[ "${-}" == *e* ]] && [[ "${-}" == *u* ]] && [[ "${-}" == *o* ]]; then
|
||||
set -euxo pipefail
|
||||
else
|
||||
set -x
|
||||
fi
|
||||
export PS4='+ [${BASH_SOURCE##*/}:${LINENO}] ${FUNCNAME[0]:+${FUNCNAME[0]}(): }'
|
||||
log_debug "Debug trace enabled (xtrace)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function call tracing (when DEBUG or VERBOSE is enabled)
|
||||
debug_trace() {
|
||||
[[ "$DEBUG" == "true" || "$VERBOSE" == "true" ]] || return 0
|
||||
local func="${FUNCNAME[1]:-main}"
|
||||
local line="${BASH_LINENO[0]:-?}"
|
||||
local file="${BASH_SOURCE[1]##*/}"
|
||||
echo -e "${GRAY}[DEBUG]${NC} ${file}:${line} ${func}()" >&2
|
||||
}
|
||||
|
||||
# Debug logging
|
||||
log_debug() {
|
||||
[[ "$DEBUG" == "true" || "$VERBOSE" == "true" ]] || return 0
|
||||
local msg="$*"
|
||||
local timestamp
|
||||
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
write_log "$timestamp [DEBUG] $msg"
|
||||
echo -e "${GRAY}[DEBUG]${NC} $msg" >&2
|
||||
}
|
||||
|
||||
# Verbose logging
|
||||
log_verbose() {
|
||||
[[ "$VERBOSE" == "true" ]] || return 0
|
||||
local msg="$*"
|
||||
local timestamp
|
||||
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
write_log "$timestamp [VERBOSE] $msg"
|
||||
[[ "$QUIET" == "false" ]] && echo -e "${GRAY}[VERBOSE]${NC} $msg"
|
||||
}
|
||||
|
||||
# ─── Spinner ──────────────────────────────────────────────────────────────────
|
||||
_SPINNER_PID=""
|
||||
_SPINNER_FRAMES=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏")
|
||||
|
||||
show_spinner() {
|
||||
local msg="${1:-Working...}"
|
||||
[[ "$QUIET" == "true" ]] && return
|
||||
[[ "$DEBUG" == "true" ]] && return # Don't show spinner in debug mode
|
||||
|
||||
(
|
||||
local i=0
|
||||
while true; do
|
||||
printf "\r%b%s%b %s" "$CYAN" "${_SPINNER_FRAMES[$((i % ${#_SPINNER_FRAMES[@]}))]}" "$NC" "$msg"
|
||||
((i++))
|
||||
sleep 0.08
|
||||
done
|
||||
) &
|
||||
_SPINNER_PID=$!
|
||||
disown "$_SPINNER_PID" 2>/dev/null || true
|
||||
log_debug "Spinner started (PID: $_SPINNER_PID)"
|
||||
}
|
||||
|
||||
hide_spinner() {
|
||||
if [[ -n "$_SPINNER_PID" ]]; then
|
||||
log_debug "Stopping spinner (PID: $_SPINNER_PID)"
|
||||
kill "$_SPINNER_PID" 2>/dev/null || true
|
||||
wait "$_SPINNER_PID" 2>/dev/null || true
|
||||
_SPINNER_PID=""
|
||||
printf "\r\033[K" # Clear the spinner line
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Logging ──────────────────────────────────────────────────────────────────
|
||||
write_log() {
|
||||
local msg="$1"
|
||||
local log_dir
|
||||
log_dir=$(dirname "$LOG_FILE")
|
||||
|
||||
# Try to create log directory if it doesn't exist
|
||||
if [[ ! -d "$log_dir" ]]; then
|
||||
mkdir -p "$log_dir" 2>/dev/null || {
|
||||
# If we can't create the directory, try /tmp as fallback
|
||||
if [[ "$log_dir" != "/tmp"* ]]; then
|
||||
log_dir="/tmp/docwell"
|
||||
mkdir -p "$log_dir" 2>/dev/null || return 0
|
||||
else
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
fi
|
||||
|
||||
# Check if we can write to the log file
|
||||
if [[ -w "$log_dir" ]] 2>/dev/null || [[ -w "$(dirname "$log_dir")" ]] 2>/dev/null; then
|
||||
echo "$msg" >> "$LOG_FILE" 2>/dev/null || {
|
||||
# Fallback to /tmp if original location fails
|
||||
if [[ "$LOG_FILE" != "/tmp/"* ]]; then
|
||||
echo "$msg" >> "/tmp/docwell/fallback.log" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
fi
|
||||
}
|
||||
|
||||
log_info() {
|
||||
local msg="$*"
|
||||
local timestamp
|
||||
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
write_log "$timestamp [INFO] $msg"
|
||||
[[ "$QUIET" == "false" ]] && echo -e "${GREEN}[INFO]${NC} $msg"
|
||||
debug_trace
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
local msg="$*"
|
||||
local timestamp
|
||||
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
write_log "$timestamp [WARN] $msg"
|
||||
[[ "$QUIET" == "false" ]] && echo -e "${YELLOW}[WARN]${NC} $msg" >&2
|
||||
}
|
||||
|
||||
log_error() {
|
||||
local msg="$*"
|
||||
local timestamp
|
||||
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
write_log "$timestamp [ERROR] $msg"
|
||||
echo -e "${RED}[ERROR]${NC} $msg" >&2
|
||||
debug_trace
|
||||
# In debug mode, show stack trace
|
||||
if [[ "$DEBUG" == "true" ]]; then
|
||||
local i=0
|
||||
while caller $i >/dev/null 2>&1; do
|
||||
local frame
|
||||
frame=$(caller $i)
|
||||
echo -e "${GRAY} ->${NC} $frame" >&2
|
||||
((i++))
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
log_success() {
|
||||
local msg="$*"
|
||||
local timestamp
|
||||
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
write_log "$timestamp [OK] $msg"
|
||||
[[ "$QUIET" == "false" ]] && echo -e "${GREEN}[✓]${NC} $msg"
|
||||
}
|
||||
|
||||
log_header() {
|
||||
local msg="$1"
|
||||
write_log "=== $msg ==="
|
||||
echo -e "${BLUE}${BOLD}═══════════════════════════════════════════════${NC}"
|
||||
echo -e "${BLUE}${BOLD} $msg${NC}"
|
||||
echo -e "${BLUE}${BOLD}═══════════════════════════════════════════════${NC}"
|
||||
}
|
||||
|
||||
log_separator() {
|
||||
echo -e "${GRAY}───────────────────────────────────────────────${NC}"
|
||||
}
|
||||
|
||||
# ─── Prompts ──────────────────────────────────────────────────────────────────
|
||||
confirm() {
|
||||
local prompt="$1"
|
||||
if [[ "$YES" == "true" ]]; then
|
||||
return 0
|
||||
fi
|
||||
read -p "$(echo -e "${YELLOW}?${NC} $prompt (y/N): ")" -n 1 -r
|
||||
echo
|
||||
[[ $REPLY =~ ^[Yy]$ ]]
|
||||
}
|
||||
|
||||
# ─── Argument validation ──────────────────────────────────────────────────────
|
||||
# Call from parse_args for options that require a value. Returns 1 if missing/invalid.
|
||||
require_arg() {
|
||||
local opt="${1:-}"
|
||||
local val="${2:-}"
|
||||
if [[ -z "$val" ]] || [[ "$val" == -* ]]; then
|
||||
log_error "Option $opt requires a value"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# ─── Validation ───────────────────────────────────────────────────────────────
|
||||
validate_stack_name() {
|
||||
local name="$1"
|
||||
debug_trace
|
||||
log_debug "Validating stack name: '$name'"
|
||||
|
||||
if [[ -z "$name" ]] || [[ "$name" == "." ]] || [[ "$name" == ".." ]]; then
|
||||
log_error "Invalid stack name: cannot be empty, '.', or '..'"
|
||||
return 1
|
||||
fi
|
||||
if [[ "$name" =~ [^a-zA-Z0-9._-] ]]; then
|
||||
log_error "Invalid stack name: contains invalid characters (allowed: a-z, A-Z, 0-9, ., _, -)"
|
||||
return 1
|
||||
fi
|
||||
if [[ ${#name} -gt 64 ]]; then
|
||||
log_error "Invalid stack name: too long (max 64 characters, got ${#name})"
|
||||
return 1
|
||||
fi
|
||||
log_debug "Stack name validation passed"
|
||||
return 0
|
||||
}
|
||||
|
||||
validate_port() {
|
||||
local port="$1"
|
||||
debug_trace
|
||||
log_debug "Validating port: '$port'"
|
||||
|
||||
if ! [[ "$port" =~ ^[0-9]+$ ]] || [[ "$port" -lt 1 ]] || [[ "$port" -gt 65535 ]]; then
|
||||
log_error "Invalid port: $port (must be 1-65535)"
|
||||
return 1
|
||||
fi
|
||||
log_debug "Port validation passed"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ─── Config file support ─────────────────────────────────────────────────────
|
||||
DOCWELL_CONFIG_FILE="${HOME}/.config/docwell/config"
|
||||
|
||||
load_config() {
|
||||
[[ -f "$DOCWELL_CONFIG_FILE" ]] || return 0
|
||||
|
||||
while IFS= read -r line; do
|
||||
# Skip comments and empty lines
|
||||
line=$(echo "$line" | xargs)
|
||||
[[ -z "$line" || "$line" == \#* ]] && continue
|
||||
|
||||
# Split on first '=' so values can contain '='
|
||||
key="${line%%=*}"
|
||||
key=$(echo "$key" | xargs)
|
||||
value="${line#*=}"
|
||||
value=$(echo "$value" | xargs | sed 's/^"//;s/"$//')
|
||||
|
||||
[[ -z "$key" ]] && continue
|
||||
|
||||
case "$key" in
|
||||
StacksDir) STACKS_DIR="$value" ;;
|
||||
BackupBase) BACKUP_BASE="$value" ;;
|
||||
LogFile) LOG_FILE="$value" ;;
|
||||
OldHost) OLD_HOST="$value" ;;
|
||||
OldPort) OLD_PORT="$value" ;;
|
||||
OldUser) OLD_USER="$value" ;;
|
||||
NewHost) NEW_HOST="$value" ;;
|
||||
NewPort) NEW_PORT="$value" ;;
|
||||
NewUser) NEW_USER="$value" ;;
|
||||
BandwidthMB) BANDWIDTH_MB="$value" ;;
|
||||
TransferRetries) TRANSFER_RETRIES="$value" ;;
|
||||
esac
|
||||
done < "$DOCWELL_CONFIG_FILE"
|
||||
}
|
||||
|
||||
# ─── Compose file detection ──────────────────────────────────────────────────
|
||||
get_compose_file() {
|
||||
local stack_path="$1"
|
||||
debug_trace
|
||||
log_debug "Looking for compose file in: $stack_path"
|
||||
|
||||
if [[ ! -d "$stack_path" ]]; then
|
||||
log_debug "Stack path does not exist: $stack_path"
|
||||
return 1
|
||||
fi
|
||||
|
||||
for f in compose.yaml compose.yml docker-compose.yaml docker-compose.yml; do
|
||||
if [[ -f "$stack_path/$f" ]]; then
|
||||
log_debug "Found compose file: $f"
|
||||
echo "$f"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
log_debug "No compose file found in: $stack_path"
|
||||
return 1
|
||||
}
|
||||
|
||||
has_compose_file() {
|
||||
local stack_path="$1"
|
||||
get_compose_file "$stack_path" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# ─── Stack operations ────────────────────────────────────────────────────────
|
||||
get_stacks() {
|
||||
debug_trace
|
||||
log_debug "Discovering stacks in: $STACKS_DIR"
|
||||
|
||||
if [[ ! -d "$STACKS_DIR" ]]; then
|
||||
log_debug "Stacks directory does not exist: $STACKS_DIR"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local stacks=()
|
||||
local dir_count=0
|
||||
|
||||
while IFS= read -r -d '' dir; do
|
||||
((dir_count++))
|
||||
local name
|
||||
name=$(basename "$dir")
|
||||
log_debug "Found directory: $name"
|
||||
if validate_stack_name "$name" >/dev/null 2>&1; then
|
||||
stacks+=("$name")
|
||||
log_debug "Added valid stack: $name"
|
||||
else
|
||||
log_debug "Skipped invalid stack name: $name"
|
||||
fi
|
||||
done < <(find "$STACKS_DIR" -maxdepth 1 -mindepth 1 -type d -print0 2>/dev/null | sort -z)
|
||||
|
||||
log_debug "Found $dir_count directories, ${#stacks[@]} valid stacks"
|
||||
|
||||
# Fallback: also discover stacks from docker ps
|
||||
log_debug "Checking running containers for additional stacks..."
|
||||
local docker_stacks
|
||||
docker_stacks=$(docker ps --format '{{.Labels}}' 2>/dev/null | \
|
||||
grep -oP 'com\.docker\.compose\.project=\K[^,]+' | sort -u || true)
|
||||
|
||||
local added_from_docker=0
|
||||
for ds in $docker_stacks; do
|
||||
[[ -z "$ds" ]] && continue
|
||||
local found=false
|
||||
for s in "${stacks[@]}"; do
|
||||
[[ "$s" == "$ds" ]] && { found=true; break; }
|
||||
done
|
||||
if [[ "$found" == "false" ]] && validate_stack_name "$ds" >/dev/null 2>&1; then
|
||||
stacks+=("$ds")
|
||||
((added_from_docker++))
|
||||
log_debug "Added stack from docker ps: $ds"
|
||||
fi
|
||||
done
|
||||
|
||||
log_debug "Total stacks discovered: ${#stacks[@]} (${added_from_docker} from docker ps)"
|
||||
printf '%s\n' "${stacks[@]}"
|
||||
}
|
||||
|
||||
is_stack_running() {
|
||||
local stack="$1"
|
||||
local stack_path="${2:-$STACKS_DIR/$stack}"
|
||||
local compose_file
|
||||
|
||||
debug_trace
|
||||
log_debug "Checking if stack '$stack' is running (path: $stack_path)"
|
||||
|
||||
compose_file=$(get_compose_file "$stack_path") || {
|
||||
log_debug "No compose file found for stack: $stack"
|
||||
return 1
|
||||
}
|
||||
|
||||
local running
|
||||
running=$(docker compose -f "$stack_path/$compose_file" ps -q 2>/dev/null | grep -c . || echo "0")
|
||||
|
||||
if [[ "$running" -gt 0 ]]; then
|
||||
log_debug "Stack '$stack' is running ($running container(s))"
|
||||
return 0
|
||||
else
|
||||
log_debug "Stack '$stack' is not running"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
get_stack_size() {
|
||||
local stack="$1"
|
||||
local stack_path="$STACKS_DIR/$stack"
|
||||
debug_trace
|
||||
|
||||
if [[ ! -d "$stack_path" ]]; then
|
||||
log_debug "Stack path does not exist: $stack_path"
|
||||
echo "?"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local size
|
||||
size=$(du -sh "$stack_path" 2>/dev/null | awk '{print $1}' || echo "?")
|
||||
log_debug "Stack size for $stack: $size"
|
||||
echo "$size"
|
||||
}
|
||||
|
||||
get_service_volumes() {
|
||||
local service="$1"
|
||||
local stack_path="${2:-$STACKS_DIR/$service}"
|
||||
local compose_file
|
||||
debug_trace
|
||||
log_debug "Getting volumes for service: $service (path: $stack_path)"
|
||||
|
||||
compose_file=$(get_compose_file "$stack_path") || {
|
||||
log_debug "No compose file found, cannot get volumes"
|
||||
return 1
|
||||
}
|
||||
|
||||
local volumes
|
||||
volumes=$(docker compose -f "$stack_path/$compose_file" config --volumes 2>/dev/null || true)
|
||||
|
||||
if [[ -n "$volumes" ]]; then
|
||||
local vol_count
|
||||
vol_count=$(echo "$volumes" | grep -c . || echo "0")
|
||||
log_debug "Found $vol_count volume(s) for service: $service"
|
||||
else
|
||||
log_debug "No volumes found for service: $service"
|
||||
fi
|
||||
|
||||
echo "$volumes"
|
||||
}
|
||||
|
||||
# ─── Dependency management ───────────────────────────────────────────────────
|
||||
install_deps_debian() {
|
||||
local deps=("$@")
|
||||
local apt_pkgs=()
|
||||
for dep in "${deps[@]}"; do
|
||||
case "$dep" in
|
||||
docker) apt_pkgs+=("docker.io") ;;
|
||||
zstd) apt_pkgs+=("zstd") ;;
|
||||
rsync) apt_pkgs+=("rsync") ;;
|
||||
rclone) apt_pkgs+=("rclone") ;;
|
||||
*) apt_pkgs+=("$dep") ;;
|
||||
esac
|
||||
done
|
||||
if [[ ${#apt_pkgs[@]} -gt 0 ]]; then
|
||||
sudo apt update && sudo apt install -y "${apt_pkgs[@]}"
|
||||
fi
|
||||
}
|
||||
|
||||
install_deps_arch() {
|
||||
local deps=("$@")
|
||||
local pacman_pkgs=()
|
||||
for dep in "${deps[@]}"; do
|
||||
case "$dep" in
|
||||
docker) pacman_pkgs+=("docker") ;;
|
||||
zstd) pacman_pkgs+=("zstd") ;;
|
||||
rsync) pacman_pkgs+=("rsync") ;;
|
||||
rclone) pacman_pkgs+=("rclone") ;;
|
||||
*) pacman_pkgs+=("$dep") ;;
|
||||
esac
|
||||
done
|
||||
if [[ ${#pacman_pkgs[@]} -gt 0 ]]; then
|
||||
sudo pacman -Sy --noconfirm "${pacman_pkgs[@]}"
|
||||
fi
|
||||
}
|
||||
|
||||
install_dependencies() {
|
||||
local deps=("$@")
|
||||
if command -v apt &>/dev/null; then
|
||||
install_deps_debian "${deps[@]}"
|
||||
elif command -v pacman &>/dev/null; then
|
||||
install_deps_arch "${deps[@]}"
|
||||
else
|
||||
log_error "No supported package manager found (apt/pacman)"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_docker() {
|
||||
debug_trace
|
||||
log_debug "Checking Docker installation and daemon status"
|
||||
|
||||
if ! command -v docker &>/dev/null; then
|
||||
log_error "Docker not found. Please install Docker first."
|
||||
echo -e "${BLUE}Install commands:${NC}"
|
||||
command -v apt &>/dev/null && echo -e " ${GREEN}Debian/Ubuntu:${NC} sudo apt install docker.io"
|
||||
command -v pacman &>/dev/null && echo -e " ${GREEN}Arch Linux:${NC} sudo pacman -S docker"
|
||||
|
||||
if [[ "$AUTO_INSTALL" == "true" ]]; then
|
||||
log_info "Auto-installing dependencies..."
|
||||
install_dependencies "docker"
|
||||
return $?
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_debug "Docker command found: $(command -v docker)"
|
||||
log_debug "Docker version: $(docker --version 2>&1 || echo 'unknown')"
|
||||
|
||||
if ! docker info &>/dev/null; then
|
||||
log_error "Docker daemon not running or not accessible."
|
||||
log_debug "Attempting to get more details..."
|
||||
docker info 2>&1 | head -5 | while IFS= read -r line; do
|
||||
log_debug " $line"
|
||||
done || true
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_debug "Docker daemon is accessible"
|
||||
return 0
|
||||
}
|
||||
|
||||
check_root() {
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
if ! sudo -n true 2>/dev/null; then
|
||||
log_error "Root/sudo privileges required"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# ─── Screen control ───────────────────────────────────────────────────────────
|
||||
clear_screen() {
|
||||
printf '\033[H\033[2J'
|
||||
}
|
||||
|
||||
# ─── Elapsed time formatting ─────────────────────────────────────────────────
|
||||
format_elapsed() {
|
||||
local seconds="$1"
|
||||
# Handle invalid input
|
||||
if ! [[ "$seconds" =~ ^[0-9]+$ ]]; then
|
||||
echo "0s"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$seconds" -lt 60 ]]; then
|
||||
echo "${seconds}s"
|
||||
elif [[ "$seconds" -lt 3600 ]]; then
|
||||
local mins=$((seconds / 60))
|
||||
local secs=$((seconds % 60))
|
||||
echo "${mins}m ${secs}s"
|
||||
else
|
||||
local hours=$((seconds / 3600))
|
||||
local mins=$((seconds % 3600 / 60))
|
||||
local secs=$((seconds % 60))
|
||||
echo "${hours}h ${mins}m ${secs}s"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Compression helper ──────────────────────────────────────────────────────
|
||||
get_compressor() {
|
||||
local method="${1:-zstd}"
|
||||
case "$method" in
|
||||
gzip)
|
||||
echo "gzip:.tar.gz"
|
||||
;;
|
||||
zstd|*)
|
||||
if command -v zstd &>/dev/null; then
|
||||
echo "zstd -T0:.tar.zst"
|
||||
else
|
||||
log_warn "zstd not found, falling back to gzip"
|
||||
echo "gzip:.tar.gz"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ─── Cleanup trap helper ─────────────────────────────────────────────────────
|
||||
# Usage: register_cleanup "command to run"
|
||||
_CLEANUP_CMDS=()
|
||||
|
||||
register_cleanup() {
|
||||
local cmd="$1"
|
||||
debug_trace
|
||||
log_debug "Registering cleanup command: $cmd"
|
||||
_CLEANUP_CMDS+=("$cmd")
|
||||
}
|
||||
|
||||
_run_cleanups() {
|
||||
hide_spinner
|
||||
if [[ ${#_CLEANUP_CMDS[@]} -gt 0 ]]; then
|
||||
log_debug "Running ${#_CLEANUP_CMDS[@]} cleanup command(s)"
|
||||
for cmd in "${_CLEANUP_CMDS[@]}"; do
|
||||
log_debug "Executing cleanup: $cmd"
|
||||
eval "$cmd" 2>/dev/null || {
|
||||
log_warn "Cleanup command failed: $cmd"
|
||||
}
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
trap _run_cleanups EXIT INT TERM
|
||||
|
||||
# ─── Dry-run wrapper ─────────────────────────────────────────────────────────
|
||||
# Usage: run_or_dry "description" command arg1 arg2 ...
|
||||
run_or_dry() {
|
||||
local desc="$1"
|
||||
shift
|
||||
debug_trace
|
||||
log_debug "run_or_dry: $desc"
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log_info "[DRY-RUN] Would: $desc"
|
||||
log_verbose "[DRY-RUN] Command: $*"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_verbose "Executing: $*"
|
||||
"$@"
|
||||
local rc=$?
|
||||
log_debug "Command exit code: $rc"
|
||||
return $rc
|
||||
}
|
||||
|
||||
# ─── Parallel execution with bounded concurrency ─────────────────────────────
|
||||
# Usage: parallel_run max_jobs command arg1 arg2 ...
|
||||
# Each arg is processed by spawning: command arg &
|
||||
_parallel_pids=()
|
||||
|
||||
parallel_wait() {
|
||||
debug_trace
|
||||
if [[ ${#_parallel_pids[@]} -gt 0 ]]; then
|
||||
log_debug "Waiting for ${#_parallel_pids[@]} parallel jobs to complete"
|
||||
local failed_count=0
|
||||
for pid in "${_parallel_pids[@]}"; do
|
||||
local exit_code=0
|
||||
if ! wait "$pid" 2>/dev/null; then
|
||||
exit_code=$?
|
||||
log_debug "Job $pid failed with exit code: $exit_code"
|
||||
((failed_count++)) || true
|
||||
else
|
||||
log_debug "Job $pid completed successfully"
|
||||
fi
|
||||
done
|
||||
if [[ $failed_count -gt 0 ]]; then
|
||||
log_warn "$failed_count parallel job(s) failed"
|
||||
fi
|
||||
fi
|
||||
_parallel_pids=()
|
||||
}
|
||||
|
||||
parallel_throttle() {
|
||||
local max_jobs="${1:-$MAX_PARALLEL}"
|
||||
debug_trace
|
||||
log_debug "Throttling parallel jobs: ${#_parallel_pids[@]}/$max_jobs"
|
||||
|
||||
while [[ ${#_parallel_pids[@]} -ge "$max_jobs" ]]; do
|
||||
local new_pids=()
|
||||
for pid in "${_parallel_pids[@]}"; do
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
new_pids+=("$pid")
|
||||
else
|
||||
log_debug "Job $pid completed, removing from throttle list"
|
||||
fi
|
||||
done
|
||||
_parallel_pids=("${new_pids[@]+"${new_pids[@]}"}")
|
||||
[[ ${#_parallel_pids[@]} -ge "$max_jobs" ]] && sleep 0.1
|
||||
done
|
||||
log_debug "Throttle check passed: ${#_parallel_pids[@]}/$max_jobs"
|
||||
}
|
||||
|
||||
# ─── SSH helpers ──────────────────────────────────────────────────────────────
|
||||
ssh_cmd() {
|
||||
local host="$1" port="$2" user="$3"
|
||||
shift 3
|
||||
|
||||
debug_trace
|
||||
log_debug "Executing SSH command: host=$host, port=$port, user=$user"
|
||||
log_verbose "Command: $*"
|
||||
|
||||
if [[ "$host" == "local" ]]; then
|
||||
log_debug "Local execution (no SSH)"
|
||||
"$@"
|
||||
local rc=$?
|
||||
log_debug "Local command exit code: $rc"
|
||||
return $rc
|
||||
fi
|
||||
|
||||
validate_port "$port" || return 1
|
||||
|
||||
log_debug "SSH connection: $user@$host:$port"
|
||||
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 -p "$port" "$user@$host" "$@"
|
||||
local rc=$?
|
||||
log_debug "SSH command exit code: $rc"
|
||||
return $rc
|
||||
}
|
||||
|
||||
test_ssh() {
|
||||
local host="$1" port="$2" user="$3"
|
||||
|
||||
[[ "$host" == "local" ]] && return 0
|
||||
|
||||
validate_port "$port" || return 1
|
||||
|
||||
if ssh_cmd "$host" "$port" "$user" 'echo OK' >/dev/null 2>&1; then
|
||||
log_success "SSH connection to $user@$host:$port successful"
|
||||
return 0
|
||||
else
|
||||
log_error "SSH connection failed to $user@$host:$port"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
1
go
Submodule
1
go
Submodule
Submodule go added at 036f0a5a8b
5
test-stack/.dockerignore
Executable file
5
test-stack/.dockerignore
Executable file
@@ -0,0 +1,5 @@
|
||||
README.md
|
||||
*.md
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
197
test-stack/README.md
Executable file
197
test-stack/README.md
Executable file
@@ -0,0 +1,197 @@
|
||||
# DocWell Test Stack
|
||||
|
||||
A simple HTTP server stack for testing DocWell features.
|
||||
|
||||
## Overview
|
||||
|
||||
This is a minimal Docker Compose stack that runs an Nginx web server. It's designed to be used for testing all DocWell features including:
|
||||
|
||||
- Backup and restore operations
|
||||
- Stack management (start/stop/restart)
|
||||
- Image updates
|
||||
- Service migration
|
||||
- Volume management
|
||||
- Health checks
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install the Stack
|
||||
|
||||
```bash
|
||||
# Copy to stacks directory (default: /opt/stacks)
|
||||
sudo cp -r test-stack /opt/stacks/
|
||||
|
||||
# Or use your configured stacks directory
|
||||
sudo cp -r test-stack /path/to/your/stacks/dir/
|
||||
```
|
||||
|
||||
### 2. Start the Stack
|
||||
|
||||
```bash
|
||||
cd /opt/stacks/test-stack
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Or use DocWell:
|
||||
```bash
|
||||
./docwell --stack-start test-stack
|
||||
```
|
||||
|
||||
### 3. Verify It's Running
|
||||
|
||||
```bash
|
||||
# Check status
|
||||
docker compose ps
|
||||
|
||||
# Or use DocWell
|
||||
./docwell --stack-status test-stack
|
||||
|
||||
# Access the web interface
|
||||
curl http://localhost:8080
|
||||
# Or open in browser: http://localhost:8080
|
||||
```
|
||||
|
||||
## Testing DocWell Features
|
||||
|
||||
### Backup Operations
|
||||
|
||||
```bash
|
||||
# Backup this stack
|
||||
./docwell --backup-stack test-stack
|
||||
|
||||
# Or backup all stacks
|
||||
./docwell --backup
|
||||
```
|
||||
|
||||
### Stack Management
|
||||
|
||||
```bash
|
||||
# List all stacks
|
||||
./docwell --stack-list
|
||||
|
||||
# Start stack
|
||||
./docwell --stack-start test-stack
|
||||
|
||||
# Stop stack
|
||||
./docwell --stack-stop test-stack
|
||||
|
||||
# Restart stack
|
||||
./docwell --stack-restart test-stack
|
||||
|
||||
# View logs
|
||||
./docwell --stack-logs test-stack
|
||||
```
|
||||
|
||||
### Update Operations
|
||||
|
||||
```bash
|
||||
# Check for updates
|
||||
./docwell --update-check
|
||||
|
||||
# Update this stack
|
||||
./docwell --update-stack test-stack
|
||||
|
||||
# Update all stacks
|
||||
./docwell --update-all
|
||||
```
|
||||
|
||||
### Migration Testing
|
||||
|
||||
```bash
|
||||
# Migrate to another server
|
||||
./docwell --migrate \
|
||||
--migrate-service test-stack \
|
||||
--migrate-source local \
|
||||
--migrate-dest user@remote-host \
|
||||
--migrate-method rsync
|
||||
```
|
||||
|
||||
## Stack Details
|
||||
|
||||
- **Service**: Nginx web server
|
||||
- **Image**: `nginx:alpine` (lightweight)
|
||||
- **Port**: 8080 (host) → 80 (container)
|
||||
- **Volume**: `web_data` (persistent cache storage)
|
||||
- **Health Check**: Enabled (checks every 30s)
|
||||
|
||||
## Files
|
||||
|
||||
- `compose.yaml` - Docker Compose configuration
|
||||
- `html/index.html` - Web interface served by Nginx
|
||||
- `README.md` - This file
|
||||
|
||||
## Customization
|
||||
|
||||
### Change Port
|
||||
|
||||
Edit `compose.yaml`:
|
||||
```yaml
|
||||
ports:
|
||||
- "9090:80" # Change 8080 to your desired port
|
||||
```
|
||||
|
||||
### Change Image Version
|
||||
|
||||
Edit `compose.yaml`:
|
||||
```yaml
|
||||
image: nginx:latest # or nginx:1.25, etc.
|
||||
```
|
||||
|
||||
### Add More Services
|
||||
|
||||
You can add additional services to test more complex scenarios:
|
||||
```yaml
|
||||
services:
|
||||
web:
|
||||
# ... existing config ...
|
||||
|
||||
db:
|
||||
image: postgres:alpine
|
||||
# ... database config ...
|
||||
```
|
||||
|
||||
## Cleanup
|
||||
|
||||
To remove the test stack:
|
||||
|
||||
```bash
|
||||
# Stop and remove
|
||||
cd /opt/stacks/test-stack
|
||||
docker compose down -v
|
||||
|
||||
# Remove directory
|
||||
sudo rm -rf /opt/stacks/test-stack
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Port Already in Use
|
||||
|
||||
If port 8080 is already in use, change it in `compose.yaml`:
|
||||
```yaml
|
||||
ports:
|
||||
- "8081:80" # Use different port
|
||||
```
|
||||
|
||||
### Permission Issues
|
||||
|
||||
Ensure the stacks directory is accessible:
|
||||
```bash
|
||||
sudo chown -R $USER:$USER /opt/stacks/test-stack
|
||||
```
|
||||
|
||||
### Container Won't Start
|
||||
|
||||
Check logs:
|
||||
```bash
|
||||
docker compose logs
|
||||
# Or
|
||||
./docwell --stack-logs test-stack
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- This stack uses a named volume (`web_data`) to test volume migration features
|
||||
- The health check ensures the service is actually running, not just started
|
||||
- The web interface shows real-time information about the stack
|
||||
|
||||
263
test-stack/TESTING.md
Executable file
263
test-stack/TESTING.md
Executable file
@@ -0,0 +1,263 @@
|
||||
# DocWell Testing Guide
|
||||
|
||||
This document provides specific test scenarios for using the test-stack with DocWell.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Install the test stack:
|
||||
```bash
|
||||
cd test-stack
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
2. Ensure DocWell is built:
|
||||
```bash
|
||||
cd go
|
||||
go build -o docwell docwell.go
|
||||
```
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### 1. Stack Management Tests
|
||||
|
||||
#### Test: List Stacks
|
||||
```bash
|
||||
./docwell --stack-list
|
||||
```
|
||||
**Expected**: Should show `test-stack` with status (running/stopped)
|
||||
|
||||
#### Test: Start Stack
|
||||
```bash
|
||||
./docwell --stack-start test-stack
|
||||
```
|
||||
**Expected**: Stack starts, container runs, web interface accessible at http://localhost:8080
|
||||
|
||||
#### Test: Check Status
|
||||
```bash
|
||||
./docwell --stack-status test-stack
|
||||
```
|
||||
**Expected**: Outputs "running" or "stopped"
|
||||
|
||||
#### Test: View Logs
|
||||
```bash
|
||||
./docwell --stack-logs test-stack
|
||||
```
|
||||
**Expected**: Shows Nginx logs
|
||||
|
||||
#### Test: Restart Stack
|
||||
```bash
|
||||
./docwell --stack-restart test-stack
|
||||
```
|
||||
**Expected**: Stack restarts, service remains available
|
||||
|
||||
#### Test: Stop Stack
|
||||
```bash
|
||||
./docwell --stack-stop test-stack
|
||||
```
|
||||
**Expected**: Stack stops, container removed, port 8080 no longer accessible
|
||||
|
||||
### 2. Backup Tests
|
||||
|
||||
#### Test: Backup Single Stack
|
||||
```bash
|
||||
./docwell --backup-stack test-stack
|
||||
```
|
||||
**Expected**:
|
||||
- Creates backup in configured backup directory
|
||||
- Backup file: `docker-<hostname>/<date>/docker-test-stack.tar.zst`
|
||||
- Stack restarts if it was running
|
||||
|
||||
#### Test: List Stacks for Backup
|
||||
```bash
|
||||
./docwell --backup-list
|
||||
```
|
||||
**Expected**: Lists all stacks with their status
|
||||
|
||||
#### Test: Backup All Stacks
|
||||
```bash
|
||||
./docwell --backup
|
||||
```
|
||||
**Expected**: Backs up all stacks including test-stack
|
||||
|
||||
### 3. Update Tests
|
||||
|
||||
#### Test: Check for Updates
|
||||
```bash
|
||||
./docwell --update-check
|
||||
```
|
||||
**Expected**: Shows update status for nginx:alpine image
|
||||
|
||||
#### Test: Update Single Stack
|
||||
```bash
|
||||
./docwell --update-stack test-stack
|
||||
```
|
||||
**Expected**:
|
||||
- Pulls latest nginx:alpine image
|
||||
- Recreates container if stack was running
|
||||
- Service continues to work
|
||||
|
||||
#### Test: Update All Stacks
|
||||
```bash
|
||||
./docwell --update-all
|
||||
```
|
||||
**Expected**: Updates all stacks including test-stack
|
||||
|
||||
### 4. Cleanup Tests
|
||||
|
||||
#### Test: Cleanup Containers
|
||||
```bash
|
||||
# Stop the stack first
|
||||
./docwell --stack-stop test-stack
|
||||
./docwell --cleanup-containers
|
||||
```
|
||||
**Expected**: Removes stopped containers
|
||||
|
||||
#### Test: Cleanup Images
|
||||
```bash
|
||||
./docwell --cleanup-images
|
||||
```
|
||||
**Expected**: Removes unused images (may remove old nginx images)
|
||||
|
||||
#### Test: Cleanup Volumes
|
||||
```bash
|
||||
./docwell --cleanup-volumes
|
||||
```
|
||||
**Expected**: Removes unused volumes (be careful - this removes data!)
|
||||
|
||||
### 5. Migration Tests
|
||||
|
||||
#### Test: Migrate to Another Server (Clone Mode)
|
||||
```bash
|
||||
./docwell --migrate \
|
||||
--migrate-service test-stack \
|
||||
--migrate-source local \
|
||||
--migrate-dest user@remote-host \
|
||||
--migrate-method rsync
|
||||
```
|
||||
**Expected**:
|
||||
- Creates backup of config and volumes
|
||||
- Transfers to destination
|
||||
- Starts service on destination
|
||||
- Original service keeps running
|
||||
|
||||
#### Test: Migrate (Transfer Mode)
|
||||
```bash
|
||||
./docwell --migrate \
|
||||
--migrate-service test-stack \
|
||||
--migrate-source local \
|
||||
--migrate-dest user@remote-host \
|
||||
--migrate-method rsync \
|
||||
--migrate-transfer
|
||||
```
|
||||
**Expected**:
|
||||
- Stops service on source
|
||||
- Transfers everything
|
||||
- Starts on destination
|
||||
- Source service is stopped
|
||||
|
||||
### 6. Interactive Mode Tests
|
||||
|
||||
#### Test: Interactive Backup
|
||||
```bash
|
||||
./docwell
|
||||
# Select option 1 (Backup Stacks)
|
||||
# Select test-stack
|
||||
```
|
||||
**Expected**: Interactive menu works, backup completes
|
||||
|
||||
#### Test: Interactive Stack Manager
|
||||
```bash
|
||||
./docwell
|
||||
# Select option 4 (Stack Manager)
|
||||
# Select test-stack
|
||||
# Try start/stop/restart/update/logs
|
||||
```
|
||||
**Expected**: All operations work through interactive menu
|
||||
|
||||
## Verification Steps
|
||||
|
||||
After each test, verify:
|
||||
|
||||
1. **Service Status**:
|
||||
```bash
|
||||
docker compose -f /opt/stacks/test-stack/compose.yaml ps
|
||||
```
|
||||
|
||||
2. **Web Interface**:
|
||||
```bash
|
||||
curl http://localhost:8080
|
||||
# Should return HTML content
|
||||
```
|
||||
|
||||
3. **Volume Exists**:
|
||||
```bash
|
||||
docker volume ls | grep test-stack
|
||||
```
|
||||
|
||||
4. **Backup Created**:
|
||||
```bash
|
||||
ls -lh /storage/backups/docker-*/$(date +%Y-%m-%d)/
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Port Already in Use
|
||||
If port 8080 is in use, edit `compose.yaml`:
|
||||
```yaml
|
||||
ports:
|
||||
- "8081:80" # Change port
|
||||
```
|
||||
|
||||
### Permission Denied
|
||||
```bash
|
||||
sudo chown -R $USER:$USER /opt/stacks/test-stack
|
||||
```
|
||||
|
||||
### Container Won't Start
|
||||
```bash
|
||||
cd /opt/stacks/test-stack
|
||||
docker compose logs
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Backup Fails
|
||||
- Check backup directory permissions
|
||||
- Ensure enough disk space
|
||||
- Check logs: `tail -f /var/log/docwell.log`
|
||||
|
||||
## Test Checklist
|
||||
|
||||
- [ ] Stack listing works
|
||||
- [ ] Start/stop/restart operations work
|
||||
- [ ] Status checking works
|
||||
- [ ] Log viewing works
|
||||
- [ ] Backup creates valid archive
|
||||
- [ ] Update pulls new images
|
||||
- [ ] Cleanup removes unused resources
|
||||
- [ ] Migration transfers correctly
|
||||
- [ ] Interactive mode works
|
||||
- [ ] Web interface accessible after operations
|
||||
|
||||
## Performance Testing
|
||||
|
||||
For stress testing:
|
||||
|
||||
1. **Multiple Stacks**: Create several test stacks
|
||||
2. **Parallel Operations**: Run multiple operations simultaneously
|
||||
3. **Large Volumes**: Add data to volumes to test transfer speeds
|
||||
4. **Network Testing**: Test migration over slow networks
|
||||
|
||||
## Cleanup After Testing
|
||||
|
||||
```bash
|
||||
# Stop and remove stack
|
||||
cd /opt/stacks/test-stack
|
||||
docker compose down -v
|
||||
|
||||
# Remove directory
|
||||
sudo rm -rf /opt/stacks/test-stack
|
||||
|
||||
# Clean up backups (optional)
|
||||
rm -rf /storage/backups/docker-*/$(date +%Y-%m-%d)/docker-test-stack.tar.zst
|
||||
```
|
||||
|
||||
27
test-stack/compose.yaml
Executable file
27
test-stack/compose.yaml
Executable file
@@ -0,0 +1,27 @@
|
||||
services:
|
||||
web:
|
||||
image: nginx:alpine
|
||||
container_name: test-stack_web
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- ./html:/usr/share/nginx/html:ro
|
||||
- web_data:/var/cache/nginx
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "com.docker.compose.project=test-stack"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
web_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: test-stack_network
|
||||
|
||||
115
test-stack/html/index.html
Executable file
115
test-stack/html/index.html
Executable file
@@ -0,0 +1,115 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DocWell Test Stack</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
.container {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
text-align: center;
|
||||
}
|
||||
.info {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 5px 15px;
|
||||
border-radius: 20px;
|
||||
background: #4CAF50;
|
||||
font-weight: bold;
|
||||
}
|
||||
code {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
ul {
|
||||
line-height: 1.8;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🚀 DocWell Test Stack</h1>
|
||||
<div class="info">
|
||||
<p><span class="status">✓ Running</span></p>
|
||||
<p>This is a test stack for DocWell Docker management tool.</p>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<h2>Stack Information</h2>
|
||||
<ul>
|
||||
<li><strong>Stack Name:</strong> <code>test-stack</code></li>
|
||||
<li><strong>Service:</strong> Nginx Web Server</li>
|
||||
<li><strong>Image:</strong> nginx:alpine</li>
|
||||
<li><strong>Port:</strong> 8080 (host) → 80 (container)</li>
|
||||
<li><strong>Volume:</strong> <code>web_data</code> (persistent cache)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<h2>Testing DocWell Features</h2>
|
||||
<p>Use this stack to test:</p>
|
||||
<ul>
|
||||
<li>✅ Stack backup and restore</li>
|
||||
<li>✅ Start/stop/restart operations</li>
|
||||
<li>✅ Image updates</li>
|
||||
<li>✅ Service migration</li>
|
||||
<li>✅ Volume management</li>
|
||||
<li>✅ Health checks</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<h2>Quick Commands</h2>
|
||||
<pre style="background: rgba(0,0,0,0.3); padding: 15px; border-radius: 5px; overflow-x: auto;">
|
||||
# View logs
|
||||
docker compose logs -f
|
||||
|
||||
# Check status
|
||||
docker compose ps
|
||||
|
||||
# Restart service
|
||||
docker compose restart
|
||||
|
||||
# Stop service
|
||||
docker compose down
|
||||
|
||||
# Start service
|
||||
docker compose up -d
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<p style="text-align: center; margin-bottom: 0;">
|
||||
<small>Generated: <span id="timestamp"></span></small>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('timestamp').textContent = new Date().toLocaleString();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
56
test-stack/setup.sh
Executable file
56
test-stack/setup.sh
Executable file
@@ -0,0 +1,56 @@
|
||||
#!/bin/bash
|
||||
# Setup script for DocWell test stack
|
||||
|
||||
set -e
|
||||
|
||||
# Default stacks directory
|
||||
STACKS_DIR="${STACKS_DIR:-/opt/stacks}"
|
||||
STACK_NAME="test-stack"
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${GREEN}DocWell Test Stack Setup${NC}"
|
||||
echo "================================"
|
||||
echo ""
|
||||
|
||||
# Check if running as root or with sudo
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo -e "${YELLOW}Note: You may need sudo to copy to $STACKS_DIR${NC}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Get absolute path of test-stack directory
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Create stacks directory if it doesn't exist
|
||||
if [ ! -d "$STACKS_DIR" ]; then
|
||||
echo "Creating stacks directory: $STACKS_DIR"
|
||||
sudo mkdir -p "$STACKS_DIR"
|
||||
fi
|
||||
|
||||
# Copy test stack
|
||||
echo "Copying test stack to $STACKS_DIR/$STACK_NAME..."
|
||||
sudo cp -r "$SCRIPT_DIR" "$STACKS_DIR/$STACK_NAME"
|
||||
|
||||
# Set permissions
|
||||
echo "Setting permissions..."
|
||||
sudo chown -R "$USER:$USER" "$STACKS_DIR/$STACK_NAME"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}✓ Setup complete!${NC}"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Start the stack:"
|
||||
echo " cd $STACKS_DIR/$STACK_NAME"
|
||||
echo " docker compose up -d"
|
||||
echo ""
|
||||
echo " 2. Or use DocWell:"
|
||||
echo " ./docwell --stack-start $STACK_NAME"
|
||||
echo ""
|
||||
echo " 3. Access the web interface:"
|
||||
echo " http://localhost:8080"
|
||||
echo ""
|
||||
|
||||
Reference in New Issue
Block a user