- Comprehensive dry-run mode with detailed preview - Complete backup process orchestration - Service restart in finally block for reliability - Archive preview showing what would be backed up - Detailed configuration summary in dry-run mode
387 lines
16 KiB
Python
387 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Docker Compose to Proxmox Backup Server
|
|
Backup script that reads docker-compose.yaml, extracts service configuration,
|
|
stops the service, creates backup on PBS, and restarts the service.
|
|
"""
|
|
|
|
import argparse
|
|
import sys
|
|
import os
|
|
import subprocess
|
|
import yaml
|
|
import json
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
from typing import Dict, List, Any, Optional
|
|
|
|
|
|
class Docker2PBS:
|
|
def __init__(self, compose_file: str, service_name: str, pbs_config: Dict[str, str], dry_run: bool = False):
|
|
self.compose_file = Path(compose_file)
|
|
self.service_name = service_name
|
|
self.pbs_config = pbs_config
|
|
self.dry_run = dry_run
|
|
self.compose_data = None
|
|
self.service_config = None
|
|
self.volumes = []
|
|
self.networks = []
|
|
|
|
def load_compose_file(self) -> None:
|
|
"""Load and parse docker-compose.yaml file"""
|
|
if not self.compose_file.exists():
|
|
raise FileNotFoundError(f"Docker compose file not found: {self.compose_file}")
|
|
|
|
with open(self.compose_file, 'r') as f:
|
|
self.compose_data = yaml.safe_load(f)
|
|
|
|
if 'services' not in self.compose_data:
|
|
raise ValueError("No 'services' section found in docker-compose.yaml")
|
|
|
|
if self.service_name not in self.compose_data['services']:
|
|
available_services = list(self.compose_data['services'].keys())
|
|
raise ValueError(f"Service '{self.service_name}' not found. Available services: {available_services}")
|
|
|
|
self.service_config = self.compose_data['services'][self.service_name]
|
|
|
|
def parse_volumes(self) -> List[Dict[str, Any]]:
|
|
"""Parse volumes configuration for the service"""
|
|
volumes = []
|
|
|
|
# Get service volumes
|
|
if 'volumes' in self.service_config:
|
|
for volume in self.service_config['volumes']:
|
|
volume_info = {'type': 'bind', 'external': False}
|
|
|
|
if isinstance(volume, str):
|
|
# Short syntax: "host_path:container_path"
|
|
parts = volume.split(':')
|
|
if len(parts) >= 2:
|
|
volume_info.update({
|
|
'source': parts[0],
|
|
'target': parts[1],
|
|
'mode': parts[2] if len(parts) > 2 else 'rw'
|
|
})
|
|
elif isinstance(volume, dict):
|
|
# Long syntax
|
|
volume_info.update(volume)
|
|
if volume_info.get('type') == 'volume':
|
|
# Check if it's external volume
|
|
volume_name = volume_info.get('source')
|
|
if volume_name and self.is_external_volume(volume_name):
|
|
volume_info['external'] = True
|
|
|
|
volumes.append(volume_info)
|
|
|
|
return volumes
|
|
|
|
def is_external_volume(self, volume_name: str) -> bool:
|
|
"""Check if volume is external"""
|
|
if 'volumes' in self.compose_data:
|
|
volume_config = self.compose_data['volumes'].get(volume_name, {})
|
|
return volume_config.get('external', False)
|
|
return False
|
|
|
|
def parse_networks(self) -> List[Dict[str, Any]]:
|
|
"""Parse network configuration for the service"""
|
|
networks = []
|
|
|
|
if 'networks' in self.service_config:
|
|
service_networks = self.service_config['networks']
|
|
|
|
if isinstance(service_networks, list):
|
|
# Short syntax
|
|
for network in service_networks:
|
|
networks.append({
|
|
'name': network,
|
|
'external': self.is_external_network(network)
|
|
})
|
|
elif isinstance(service_networks, dict):
|
|
# Long syntax
|
|
for network_name, config in service_networks.items():
|
|
network_info = {
|
|
'name': network_name,
|
|
'external': self.is_external_network(network_name)
|
|
}
|
|
if config:
|
|
network_info.update(config)
|
|
networks.append(network_info)
|
|
|
|
return networks
|
|
|
|
def is_external_network(self, network_name: str) -> bool:
|
|
"""Check if network is external"""
|
|
if 'networks' in self.compose_data:
|
|
network_config = self.compose_data['networks'].get(network_name, {})
|
|
return network_config.get('external', False)
|
|
return False
|
|
|
|
def stop_service(self) -> None:
|
|
"""Stop the Docker service"""
|
|
print(f"Stopping service: {self.service_name}")
|
|
cmd = ['docker-compose', '-f', str(self.compose_file), 'stop', self.service_name]
|
|
|
|
if self.dry_run:
|
|
print(f"[DRY-RUN] Would run: {' '.join(cmd)}")
|
|
print(f"[DRY-RUN] Service {self.service_name} would be stopped")
|
|
return
|
|
|
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f"Failed to stop service: {result.stderr}")
|
|
|
|
print(f"Service {self.service_name} stopped successfully")
|
|
|
|
def start_service(self) -> None:
|
|
"""Start the Docker service"""
|
|
print(f"Starting service: {self.service_name}")
|
|
cmd = ['docker-compose', '-f', str(self.compose_file), 'up', '-d', self.service_name]
|
|
|
|
if self.dry_run:
|
|
print(f"[DRY-RUN] Would run: {' '.join(cmd)}")
|
|
print(f"[DRY-RUN] Service {self.service_name} would be started")
|
|
return
|
|
|
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f"Failed to start service: {result.stderr}")
|
|
|
|
print(f"Service {self.service_name} started successfully")
|
|
|
|
def get_docker_volume_path(self, volume_name: str) -> Optional[str]:
|
|
"""Get host path for Docker volume"""
|
|
try:
|
|
cmd = ['docker', 'volume', 'inspect', volume_name]
|
|
|
|
if self.dry_run:
|
|
print(f"[DRY-RUN] Would run: {' '.join(cmd)}")
|
|
return f"/var/lib/docker/volumes/{volume_name}/_data"
|
|
|
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
|
|
if result.returncode == 0:
|
|
volume_info = json.loads(result.stdout)
|
|
if volume_info and len(volume_info) > 0:
|
|
return volume_info[0].get('Mountpoint')
|
|
except Exception as e:
|
|
print(f"Warning: Could not get path for volume {volume_name}: {e}")
|
|
|
|
return None
|
|
|
|
def prepare_volume_backups(self) -> List[Dict[str, str]]:
|
|
"""Prepare volume backup information"""
|
|
volume_backups = []
|
|
|
|
for volume in self.volumes:
|
|
if volume.get('type') == 'bind' and not volume.get('external'):
|
|
source_path = volume.get('source')
|
|
if self.dry_run or (source_path and os.path.exists(source_path)):
|
|
# Create safe archive name from path
|
|
archive_name = self.path_to_archive_name(source_path, 'bind')
|
|
volume_backups.append({
|
|
'archive_name': archive_name,
|
|
'source_path': source_path,
|
|
'volume_type': 'bind_mount'
|
|
})
|
|
elif volume.get('type') == 'volume':
|
|
# For Docker volumes, we need to find their host path
|
|
volume_name = volume.get('source')
|
|
if volume_name:
|
|
volume_path = self.get_docker_volume_path(volume_name)
|
|
if volume_path:
|
|
archive_name = f"volume_{volume_name}.pxar"
|
|
volume_backups.append({
|
|
'archive_name': archive_name,
|
|
'source_path': volume_path,
|
|
'volume_type': 'named_volume'
|
|
})
|
|
|
|
return volume_backups
|
|
|
|
def path_to_archive_name(self, path: str, prefix: str) -> str:
|
|
"""Convert file system path to safe archive name"""
|
|
# Normalize path and make it safe for archive names
|
|
normalized = path.replace('/', '_').replace('.', 'dot').replace('..', 'dotdot')
|
|
# Remove leading underscores and clean up
|
|
normalized = normalized.strip('_')
|
|
if not normalized:
|
|
normalized = 'root'
|
|
return f"{prefix}_{normalized}.pxar"
|
|
|
|
def create_backup_paths_file(self) -> str:
|
|
"""Create temporary file with paths to backup"""
|
|
volume_backups = self.prepare_volume_backups()
|
|
|
|
if not volume_backups:
|
|
raise ValueError("No backup paths found for the service")
|
|
|
|
backup_paths = [vb['source_path'] for vb in volume_backups]
|
|
|
|
# Create temporary file with paths
|
|
paths_file = f"/tmp/backup_paths_{self.service_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
|
|
|
|
if self.dry_run:
|
|
print(f"[DRY-RUN] Would create paths file: {paths_file}")
|
|
print(f"[DRY-RUN] Paths to backup: {backup_paths}")
|
|
return paths_file
|
|
|
|
with open(paths_file, 'w') as f:
|
|
for path in backup_paths:
|
|
f.write(f"{path}\n")
|
|
|
|
return paths_file
|
|
|
|
def create_pbs_backup(self) -> None:
|
|
"""Create backup on Proxmox Backup Server"""
|
|
timestamp = datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
|
|
backup_id = f"{self.service_name}_{timestamp}"
|
|
|
|
paths_file = self.create_backup_paths_file()
|
|
|
|
try:
|
|
print(f"Creating backup {backup_id} on Proxmox Backup Server")
|
|
|
|
# Build proxmox-backup-client command
|
|
cmd = [
|
|
'proxmox-backup-client',
|
|
'backup',
|
|
f"--repository={self.pbs_config['repository']}",
|
|
f"--backup-id={backup_id}",
|
|
f"--backup-type=host"
|
|
]
|
|
|
|
# Add authentication if provided
|
|
if 'username' in self.pbs_config:
|
|
cmd.append(f"--userid={self.pbs_config['username']}")
|
|
if 'password' in self.pbs_config:
|
|
cmd.append(f"--password={self.pbs_config['password']}")
|
|
if 'fingerprint' in self.pbs_config:
|
|
cmd.append(f"--fingerprint={self.pbs_config['fingerprint']}")
|
|
|
|
# Add paths from file
|
|
cmd.append(f"root.pxar:@{paths_file}")
|
|
|
|
if self.dry_run:
|
|
print(f"[DRY-RUN] Would run: {' '.join(cmd)}")
|
|
print(f"[DRY-RUN] Backup {backup_id} would be created on PBS")
|
|
return
|
|
|
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f"Backup failed: {result.stderr}")
|
|
|
|
print(f"Backup {backup_id} created successfully")
|
|
|
|
finally:
|
|
# Clean up temporary file
|
|
if not self.dry_run and os.path.exists(paths_file):
|
|
os.remove(paths_file)
|
|
elif self.dry_run:
|
|
print(f"[DRY-RUN] Would remove temporary file: {paths_file}")
|
|
|
|
def show_dry_run_summary(self) -> None:
|
|
"""Show summary of what would be done in dry-run mode"""
|
|
print("\n=== DRY-RUN SUMMARY ===")
|
|
print(f"Service: {self.service_name}")
|
|
print(f"Compose file: {self.compose_file}")
|
|
print(f"Volumes found: {len(self.volumes)}")
|
|
print(f"Networks found: {len(self.networks)}")
|
|
|
|
print("\nVolumes configuration:")
|
|
for i, volume in enumerate(self.volumes, 1):
|
|
vol_type = volume.get('type', 'bind')
|
|
source = volume.get('source', 'N/A')
|
|
target = volume.get('target', 'N/A')
|
|
external = " (external)" if volume.get('external') else ""
|
|
print(f" {i}. {vol_type}: {source} -> {target}{external}")
|
|
|
|
print("\nNetworks configuration:")
|
|
for i, network in enumerate(self.networks, 1):
|
|
name = network.get('name', 'N/A')
|
|
external = " (external)" if network.get('external') else ""
|
|
print(f" {i}. {name}{external}")
|
|
|
|
print(f"\nPBS Repository: {self.pbs_config['repository']}")
|
|
print("\n=== END SUMMARY ===")
|
|
|
|
def run_backup(self) -> None:
|
|
"""Run the complete backup process"""
|
|
try:
|
|
mode_str = "[DRY-RUN] " if self.dry_run else ""
|
|
print(f"{mode_str}Starting backup process for service: {self.service_name}")
|
|
|
|
# Load and parse compose file
|
|
self.load_compose_file()
|
|
|
|
# Parse configuration
|
|
self.volumes = self.parse_volumes()
|
|
self.networks = self.parse_networks()
|
|
|
|
print(f"Found {len(self.volumes)} volumes and {len(self.networks)} networks")
|
|
|
|
if self.dry_run:
|
|
self.show_dry_run_summary()
|
|
print("\n=== ARCHIVE PREVIEW ===")
|
|
volume_backups = self.prepare_volume_backups()
|
|
for vb in volume_backups:
|
|
print(f" {vb['archive_name']}: {vb['source_path']} ({vb['volume_type']})")
|
|
print("=== END PREVIEW ===")
|
|
|
|
# Stop service
|
|
self.stop_service()
|
|
|
|
# Create backup
|
|
self.create_pbs_backup()
|
|
|
|
mode_str = "[DRY-RUN] " if self.dry_run else ""
|
|
print(f"{mode_str}Backup process completed successfully")
|
|
|
|
except Exception as e:
|
|
mode_str = "[DRY-RUN] " if self.dry_run else ""
|
|
print(f"{mode_str}Backup process failed: {e}")
|
|
raise
|
|
finally:
|
|
# Always try to restart the service
|
|
try:
|
|
self.start_service()
|
|
except Exception as e:
|
|
mode_str = "[DRY-RUN] " if self.dry_run else ""
|
|
print(f"{mode_str}Warning: Failed to restart service: {e}")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description='Backup Docker service to Proxmox Backup Server')
|
|
parser.add_argument('compose_file', help='Path to docker-compose.yaml file')
|
|
parser.add_argument('service_name', help='Name of the service to backup')
|
|
parser.add_argument('--pbs-repository', required=True, help='PBS repository (user@host:datastore)')
|
|
parser.add_argument('--pbs-username', help='PBS username')
|
|
parser.add_argument('--pbs-password', help='PBS password')
|
|
parser.add_argument('--pbs-fingerprint', help='PBS server fingerprint')
|
|
parser.add_argument('--dry-run', action='store_true', help='Show what would be done without executing commands')
|
|
|
|
args = parser.parse_args()
|
|
|
|
pbs_config = {
|
|
'repository': args.pbs_repository
|
|
}
|
|
|
|
if args.pbs_username:
|
|
pbs_config['username'] = args.pbs_username
|
|
if args.pbs_password:
|
|
pbs_config['password'] = args.pbs_password
|
|
if args.pbs_fingerprint:
|
|
pbs_config['fingerprint'] = args.pbs_fingerprint
|
|
|
|
try:
|
|
backup_tool = Docker2PBS(args.compose_file, args.service_name, pbs_config, args.dry_run)
|
|
backup_tool.run_backup()
|
|
except Exception as e:
|
|
print(f"Error: {e}")
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main() |