Files
docker2pbs/docker2pbs.py

319 lines
13 KiB
Python
Raw Normal View History

#!/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 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.load_compose_file()
print(f"Successfully loaded compose file and found service: {args.service_name}")
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
if __name__ == '__main__':
main()