Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,23 @@ max_snapshots = 2
[vms.db1]
node = "pve-node2"
vmid = 200

# Example: Standalone Proxmox node with dedicated endpoint
# For non-clustered Proxmox nodes, specify per-node connection details
[vms.app1]
node = "bingus"
vmid = 300
# Per-node endpoint for standalone (non-clustered) Proxmox nodes
endpoint = "https://bingus.example.com:8006"
username = "root@pam" # Optional: defaults to global config
password = "node-specific-password" # Optional: defaults to global config
```

**Proxmox Cluster vs Standalone Nodes:**

- **Clustered Nodes**: If your Proxmox nodes are in a cluster, you only need the global `endpoint` in `config.toml`. All nodes can be managed through a single API connection.
- **Standalone Nodes**: For independent Proxmox servers (not in a cluster), specify per-node `endpoint`, `username`, and `password` in the VM mapping. This allows managing VMs across multiple isolated Proxmox installations.

**Per-Host Snapshot Quota:**
- Use `max_snapshots` to set a maximum number of automated snapshots per VM
- When set, miniupdate will keep only the N newest snapshots and delete older ones
Expand Down
117 changes: 93 additions & 24 deletions miniupdate/update_automator.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,29 +66,25 @@ def __init__(self, config: Config):
self.ssh_config = config.ssh_config

# Initialize components
self.proxmox_client = None
self.proxmox_clients = {} # Dictionary mapping endpoint -> ProxmoxClient
self.default_proxmox_config = None
self.vm_mapper = None
self.host_checker = HostChecker(self.ssh_config)

# Setup Proxmox client if configured
if self.proxmox_config:
try:
self.proxmox_client = ProxmoxClient(
endpoint=self.proxmox_config['endpoint'],
username=self.proxmox_config['username'],
password=self.proxmox_config['password'],
verify_ssl=self.proxmox_config.get('verify_ssl', True),
timeout=self.proxmox_config.get('timeout', 30)
)
# Store default Proxmox config for fallback
self.default_proxmox_config = self.proxmox_config

# Setup VM mapper
vm_mapping_file = self.proxmox_config.get('vm_mapping_file')
self.vm_mapper = VMMapper(self._resolve_vm_mapping_path(vm_mapping_file))

logger.info("Proxmox integration enabled")
except Exception as e:
logger.error(f"Failed to initialize Proxmox client: {e}")
self.proxmox_client = None
logger.error(f"Failed to initialize Proxmox configuration: {e}")
self.default_proxmox_config = None
else:
logger.info("Proxmox integration disabled - no configuration provided")

Expand Down Expand Up @@ -120,6 +116,65 @@ def _resolve_vm_mapping_path(self, vm_mapping_file: Optional[str]) -> Optional[s

return str(path_obj)

def _get_proxmox_client(self, vm_mapping: VMMapping) -> Optional[ProxmoxClient]:
"""
Get or create appropriate Proxmox client for the given VM mapping.

Supports per-node endpoints for standalone (non-clustered) Proxmox nodes.
Falls back to global endpoint if no per-node endpoint is specified.

Args:
vm_mapping: VM mapping containing optional per-node endpoint

Returns:
ProxmoxClient instance or None if configuration is missing
"""
if not self.default_proxmox_config:
logger.error("No Proxmox configuration available")
return None

# Determine endpoint, username, and password
# Priority: per-node config > global config
endpoint = vm_mapping.endpoint or self.default_proxmox_config.get('endpoint')
username = vm_mapping.username or self.default_proxmox_config.get('username')
password = vm_mapping.password or self.default_proxmox_config.get('password')

if not endpoint or not username or not password:
logger.error(f"Incomplete Proxmox configuration for VM {vm_mapping.vmid} on node {vm_mapping.node}")
return None

# Normalize endpoint for consistent key
endpoint = endpoint.rstrip('/')

# Return existing client if already created for this endpoint
if endpoint in self.proxmox_clients:
return self.proxmox_clients[endpoint]

# Create new client for this endpoint
try:
client = ProxmoxClient(
endpoint=endpoint,
username=username,
password=password,
verify_ssl=self.default_proxmox_config.get('verify_ssl', True),
timeout=self.default_proxmox_config.get('timeout', 30)
)

# Authenticate immediately to validate credentials
if not client.authenticate():
logger.error(f"Failed to authenticate to Proxmox at {endpoint}")
return None

# Cache the client
self.proxmox_clients[endpoint] = client
logger.info(f"Created Proxmox client for endpoint {endpoint}")

return client

except Exception as e:
logger.error(f"Failed to create Proxmox client for {endpoint}: {e}")
return None

def process_host_automated_update(self, host: Host, timeout: int = 120) -> AutomatedUpdateReport:
"""
Process automated updates for a single host.
Expand Down Expand Up @@ -256,7 +311,7 @@ def process_host_automated_update(self, host: Host, timeout: int = 120) -> Autom
f"({sum(1 for u in updates if u.security)} security)")

# Create snapshot if Proxmox is configured and VM mapping exists
if self.proxmox_client and vm_mapping:
if self.default_proxmox_config and vm_mapping:
snapshot_name = self._create_snapshot(vm_mapping, start_time)
if not snapshot_name:
return AutomatedUpdateReport(
Expand Down Expand Up @@ -284,7 +339,7 @@ def process_host_automated_update(self, host: Host, timeout: int = 120) -> Autom
command_output=error_output)

# Revert snapshot if available
if snapshot_name and self.proxmox_client and vm_mapping:
if snapshot_name and self.default_proxmox_config and vm_mapping:
if self._revert_snapshot(vm_mapping, snapshot_name):
result = UpdateResult.REVERTED
error_details += " - reverted to snapshot"
Expand Down Expand Up @@ -319,7 +374,7 @@ def process_host_automated_update(self, host: Host, timeout: int = 120) -> Autom
logger.info(f"Reboot after updates is disabled - skipping reboot for {host.name}")

# Clean up snapshot if successful and configured
if (snapshot_name and self.proxmox_client and vm_mapping and
if (snapshot_name and self.default_proxmox_config and vm_mapping and
self.update_config.get('cleanup_snapshots', False)):
self._cleanup_old_snapshots(vm_mapping)

Expand Down Expand Up @@ -354,11 +409,13 @@ def _create_snapshot(self, vm_mapping: VMMapping, start_time: datetime) -> Optio
snapshot_name = f"{prefix}-{timestamp}"

try:
if not self.proxmox_client.authenticate():
logger.error("Failed to authenticate to Proxmox")
# Get appropriate Proxmox client for this VM
proxmox_client = self._get_proxmox_client(vm_mapping)
if not proxmox_client:
logger.error(f"Failed to get Proxmox client for VM {vm_mapping.vmid} on node {vm_mapping.node}")
return None

response = self.proxmox_client.create_snapshot(
response = proxmox_client.create_snapshot(
vm_mapping.node,
vm_mapping.vmid,
snapshot_name,
Expand All @@ -369,7 +426,7 @@ def _create_snapshot(self, vm_mapping: VMMapping, start_time: datetime) -> Optio
# Wait for snapshot task to complete if UPID is returned
if 'data' in response and isinstance(response['data'], str):
upid = response['data']
if self.proxmox_client.wait_for_task(vm_mapping.node, upid, timeout=300):
if proxmox_client.wait_for_task(vm_mapping.node, upid, timeout=300):
logger.info(f"Snapshot {snapshot_name} created successfully for VM {vm_mapping.vmid}")
return snapshot_name
else:
Expand All @@ -387,9 +444,15 @@ def _create_snapshot(self, vm_mapping: VMMapping, start_time: datetime) -> Optio
def _revert_snapshot(self, vm_mapping: VMMapping, snapshot_name: str) -> bool:
"""Revert VM to snapshot."""
try:
# Get appropriate Proxmox client for this VM
proxmox_client = self._get_proxmox_client(vm_mapping)
if not proxmox_client:
logger.error(f"Failed to get Proxmox client for VM {vm_mapping.vmid} on node {vm_mapping.node}")
return False

logger.warning(f"Reverting VM {vm_mapping.vmid} to snapshot {snapshot_name}")

response = self.proxmox_client.rollback_snapshot(
response = proxmox_client.rollback_snapshot(
vm_mapping.node,
vm_mapping.vmid,
snapshot_name
Expand All @@ -398,15 +461,15 @@ def _revert_snapshot(self, vm_mapping: VMMapping, snapshot_name: str) -> bool:
# Wait for rollback task to complete if UPID is returned
if 'data' in response and isinstance(response['data'], str):
upid = response['data']
if not self.proxmox_client.wait_for_task(vm_mapping.node, upid, timeout=300):
if not proxmox_client.wait_for_task(vm_mapping.node, upid, timeout=300):
logger.error(f"Snapshot rollback task failed for VM {vm_mapping.vmid}")
return False

logger.warning(f"VM {vm_mapping.vmid} reverted to snapshot {snapshot_name}")

# Ensure VM is powered on after rollback
logger.info(f"Ensuring VM {vm_mapping.vmid} is powered on after snapshot restore")
if not self.proxmox_client.start_vm(vm_mapping.node, vm_mapping.vmid, timeout=60):
if not proxmox_client.start_vm(vm_mapping.node, vm_mapping.vmid, timeout=60):
logger.error(f"Failed to power on VM {vm_mapping.vmid} after snapshot restore")
return False

Expand All @@ -429,7 +492,7 @@ def _handle_reboot_and_verification(self, host: Host, vm_mapping: Optional[VMMap
error_details = "Failed to send reboot command"

# Revert snapshot if available
if snapshot_name and self.proxmox_client and vm_mapping:
if snapshot_name and self.default_proxmox_config and vm_mapping:
if self._revert_snapshot(vm_mapping, snapshot_name):
result = UpdateResult.REVERTED
error_details += " - reverted to snapshot"
Expand Down Expand Up @@ -461,7 +524,7 @@ def _handle_reboot_and_verification(self, host: Host, vm_mapping: Optional[VMMap
error_details = f"Host did not become available within {ping_timeout} seconds after reboot"

# Revert snapshot if available
if snapshot_name and self.proxmox_client and vm_mapping:
if snapshot_name and self.default_proxmox_config and vm_mapping:
if self._revert_snapshot(vm_mapping, snapshot_name):
result = UpdateResult.REVERTED
error_details += " - reverted to snapshot"
Expand All @@ -488,9 +551,15 @@ def _handle_reboot_and_verification(self, host: Host, vm_mapping: Optional[VMMap
def _cleanup_old_snapshots(self, vm_mapping: VMMapping):
"""Clean up old automated snapshots based on retention policy or count limit."""
try:
# Get appropriate Proxmox client for this VM
proxmox_client = self._get_proxmox_client(vm_mapping)
if not proxmox_client:
logger.error(f"Failed to get Proxmox client for VM {vm_mapping.vmid} on node {vm_mapping.node}")
return

prefix = self.update_config.get('snapshot_name_prefix', 'pre-update')

snapshots = self.proxmox_client.list_snapshots(vm_mapping.node, vm_mapping.vmid)
snapshots = proxmox_client.list_snapshots(vm_mapping.node, vm_mapping.vmid)

# Filter to only automated snapshots with valid timestamps
automated_snapshots = []
Expand Down Expand Up @@ -549,7 +618,7 @@ def _cleanup_old_snapshots(self, vm_mapping: VMMapping):
for snap in snapshots_to_delete:
snap_name = snap['name']
logger.info(f"Deleting old snapshot {snap_name} for VM {vm_mapping.vmid}")
self.proxmox_client.delete_snapshot(vm_mapping.node, vm_mapping.vmid, snap_name)
proxmox_client.delete_snapshot(vm_mapping.node, vm_mapping.vmid, snap_name)

except Exception as e:
logger.warning(f"Failed to cleanup old snapshots for VM {vm_mapping.vmid}: {e}")
25 changes: 23 additions & 2 deletions miniupdate/vm_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ class VMMapping(NamedTuple):
vmid: int
host_name: str
max_snapshots: Optional[int] = None
endpoint: Optional[str] = None # Optional per-node Proxmox endpoint
username: Optional[str] = None # Optional per-node credentials
password: Optional[str] = None # Optional per-node credentials


class VMMapper:
Expand Down Expand Up @@ -74,6 +77,9 @@ def _load_mappings(self) -> Dict[str, VMMapping]:
node = vm_info.get('node')
vmid = vm_info.get('vmid')
max_snapshots = vm_info.get('max_snapshots')
endpoint = vm_info.get('endpoint') # Optional per-node endpoint
username = vm_info.get('username') # Optional per-node credentials
password = vm_info.get('password') # Optional per-node credentials

if not node or not vmid:
logger.warning(f"Incomplete VM mapping for {host_name}: "
Expand Down Expand Up @@ -101,7 +107,10 @@ def _load_mappings(self) -> Dict[str, VMMapping]:
node=node,
vmid=vmid,
host_name=host_name,
max_snapshots=max_snapshots
max_snapshots=max_snapshots,
endpoint=endpoint,
username=username,
password=password
)

logger.info(f"Loaded VM mappings for {len(mappings)} hosts")
Expand Down Expand Up @@ -146,6 +155,14 @@ def create_example_vm_mapping(path: str = "vm_mapping.toml.example") -> None:
"db1": {
"node": "pve-node2",
"vmid": 200
},
"app1": {
"node": "bingus",
"vmid": 300,
# Optional: Per-node Proxmox endpoint for standalone (non-clustered) nodes
"endpoint": "https://bingus.example.com:8006",
"username": "root@pam", # Optional: defaults to global config
"password": "node-specific-password" # Optional: defaults to global config
}
}
}
Expand All @@ -154,6 +171,10 @@ def create_example_vm_mapping(path: str = "vm_mapping.toml.example") -> None:
# Write with comments
f.write("# VM Mapping Configuration for miniupdate\n")
f.write("# Maps Ansible inventory host names to Proxmox VM IDs and nodes\n")
f.write("# Optional: Set max_snapshots per VM to limit snapshot count for capacity-limited storage\n\n")
f.write("# Optional: Set max_snapshots per VM to limit snapshot count for capacity-limited storage\n")
f.write("#\n")
f.write("# For Proxmox clusters: Only the global endpoint in config.toml is needed\n")
f.write("# For standalone nodes: Specify per-node 'endpoint', 'username', and 'password'\n")
f.write("# (username and password default to global config if not specified)\n\n")

toml.dump(example_config, f)
Loading