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
134 changes: 104 additions & 30 deletions miniupdate/update_automator.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,20 +68,16 @@ 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")
Expand All @@ -91,8 +87,8 @@ def __init__(self, config: Config):

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 @@ -124,6 +120,68 @@ 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} "
f"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:
Expand Down Expand Up @@ -287,7 +345,7 @@ def process_host_automated_update(
)

# 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 @@ -319,7 +377,7 @@ def process_host_automated_update(
)

# 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 @@ -367,7 +425,7 @@ def process_host_automated_update(
# Clean up snapshot if successful and configured
if (
snapshot_name
and self.proxmox_client
and self.default_proxmox_config
and vm_mapping
and self.update_config.get("cleanup_snapshots", False)
):
Expand Down Expand Up @@ -406,11 +464,15 @@ def _create_snapshot(
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 @@ -421,9 +483,7 @@ def _create_snapshot(
# 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}"
)
Expand All @@ -447,20 +507,27 @@ def _create_snapshot(
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} "
f"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
)

# 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}"
)
Expand All @@ -472,7 +539,7 @@ def _revert_snapshot(self, vm_mapping: VMMapping, snapshot_name: str) -> bool:
logger.info(
f"Ensuring VM {vm_mapping.vmid} is powered on after snapshot restore"
)
if not self.proxmox_client.start_vm(
if not proxmox_client.start_vm(
vm_mapping.node, vm_mapping.vmid, timeout=60
):
logger.error(
Expand Down Expand Up @@ -508,7 +575,7 @@ def _handle_reboot_and_verification(
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 @@ -540,7 +607,7 @@ def _handle_reboot_and_verification(
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 @@ -567,11 +634,18 @@ def _handle_reboot_and_verification(
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} "
f"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 @@ -644,7 +718,7 @@ def _cleanup_old_snapshots(self, vm_mapping: VMMapping):
logger.info(
f"Deleting old snapshot {snap_name} for VM {vm_mapping.vmid}"
)
self.proxmox_client.delete_snapshot(
proxmox_client.delete_snapshot(
vm_mapping.node, vm_mapping.vmid, snap_name
)

Expand Down
9 changes: 9 additions & 0 deletions miniupdate/vm_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,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 @@ -79,6 +82,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(
Expand Down Expand Up @@ -115,6 +121,9 @@ def _load_mappings(self) -> Dict[str, VMMapping]:
vmid=vmid,
host_name=host_name,
max_snapshots=max_snapshots,
endpoint=endpoint,
username=username,
password=password,
)

logger.info("Loaded VM mappings for %s hosts", len(mappings))
Expand Down
Loading