Python modules
Useful if you are extending the service, calling BillingTracker from your own script, or reading the source. Paths are relative to /usr/local/bin/vm_autoscale/.
autoscale.py
class VMAutoscaler
The orchestrator.
VMAutoscaler(config_path: str, logging_config_path: Optional[str] = None)| Method | Signature | Notes |
|---|---|---|
run | () -> None | Blocking main loop. Exits on KeyboardInterrupt |
process_vm | (host: dict, vm: dict) -> None | One VM, one cycle. Catches and notifies its own errors |
_load_config | (config_path: str) -> dict | Static. Raises FileNotFoundError or ConfigurationError |
_setup_logging | (path: Optional[str]) -> Logger | JSON config wins over the YAML logging section |
_get_vm_manager | (ssh_client, vm_id) -> VMResourceManager | Cached per VMID; rebinds the SSH client |
_handle_cpu_scaling | (vm_manager, vm_id, cpu_usage, thresholds=None) -> None | Ignores a None reading |
_handle_ram_scaling | (vm_manager, vm_id, ram_usage, thresholds=None) -> None | Ignores a None reading |
_thresholds_for | (vm: dict, resource: str) -> dict | Per-VM overrides on top of the global thresholds |
_record_vm_state | (vm_id, running: bool) -> None | Writes a billing record on transitions only |
_maybe_generate_billing_reports | () -> None | Emits period reports when due |
_record_billing_spec | (vm_manager, vm_id) -> None | No-op when billing is disabled |
class NotificationManager
NotificationManager(config: dict, logger: logging.Logger)Validates channel configuration in the constructor.
| Method | Signature | Notes |
|---|---|---|
send_notification | (message, priority: Optional[int] = None) -> None | Fans out to all enabled channels; never raises |
send_gotify_notification | (message: str, priority=None) -> None | Raises on HTTP failure |
send_smtp_notification | (message: str) -> None | Raises on SMTP failure |
validate_notification_config | () -> None | Raises ConfigurationError |
_format_message | (message) -> str | Joins tuples, stringifies everything else |
class ConfigurationError(Exception)
Raised for missing sections and incomplete channel configuration.
vm_manager.py
class VMResourceManager
VMResourceManager(ssh_client, vm_id, config: dict, vm_config: dict | None = None)Runs hotplug auto-configuration in the constructor when auto_configure_hotplug is true.
Public methods
| Method | Signature | Returns |
|---|---|---|
is_vm_running | (retries=3, delay=5) -> bool | False if undeterminable after retries |
get_resource_usage | () -> tuple[float | None, float | None] | (cpu_pct, ram_pct). Either element is None when that metric could not be read — never 0.0, which would read as idle. A powered-off guest reports (0.0, 0.0) |
can_scale | (resource: str = "cpu") -> bool | Read-only cooldown check |
scale_cpu | (direction: "up" | "down") -> bool | True only if a change was made |
scale_ram | (direction: "up" | "down") -> bool | True only if a change was made |
Internal helpers worth knowing
| Method | Purpose |
|---|---|
_run(command, check=True, mutating=False) | Runs a command; raises CommandFailed on a non-zero exit, and refuses mutating commands under dry_run |
_unpack(result) | Normalises an SSH result into (stdout, stderr, exit_status) |
_mark_scaled(resource) | Starts the cooldown for that resource |
_scaling_limit(key, legacy_key, default) | Per-VM scaling_limits → global scaling_limits → flat key → default |
_get_min_cores / _get_max_cores | Resolved limits |
_get_min_ram / _get_max_ram | Resolved limits, MB |
_get_current_cores / _get_current_vcpus / _get_current_ram | Parsed from qm config |
_check_hotplug_enabled() | (cpu_hotplug, memory_hotplug) |
_check_numa_enabled() | bool |
_set_cores / _set_vcpus / _set_ram | Issue qm set |
_fetch_cluster_resource() | This VM's row from pvesh get /cluster/resources --output-format json, matched on type == "qemu" and an exact vmid; None if absent or unparseable |
_cpu_percent / _ram_percent | Percentages from that row; None on missing or non-numeric fields |
Failing getters return conservative defaults — 1 core, 512 MB — rather than raising. A transient SSH failure during a config read therefore looks like a very small VM.
ssh_utils.py
class SSHClient
SSHClient(host, user, password=None, key_path=None, port=22,
host_key_policy="accept-new", known_hosts="/etc/vm_autoscale/known_hosts")| Method | Signature | Notes |
|---|---|---|
connect | () -> None | Reuses an active transport. 5 attempts, backoff 1/2/4/8/16 s. Auth failures raise immediately |
execute_command | (command: str, timeout=30) -> tuple[str, str, int] | (stdout, stderr, exit_status). Retries with reconnect; does not raise on non-zero exit |
close | () -> None | Idempotent |
_load_private_key | () -> paramiko.PKey | Any supported key type; raises SSHException naming the path on failure |
_apply_host_key_policy | (client) -> None | Applies accept-new / strict / auto and loads known_hosts |
is_connected | () -> bool | |
__enter__ / __exit__ | Context-manager support |
Notes: the missing-host-key policy is auto-add — see the threat model. key_path is loaded by _load_private_key(), which uses paramiko.PKey.from_path so Ed25519, ECDSA, RSA and DSS all work, falling back to trying each key class on paramiko releases without it. Encrypted keys are not supported.
host_resource_checker.py
class HostResourceChecker
HostResourceChecker(ssh_client)| Method | Signature | Notes |
|---|---|---|
check_host_resources | (max_cpu_pct, max_ram_pct) -> bool | True when both are within limits. Raises on JSON or field errors |
RAM is memory.used / memory.total, matching the Proxmox web UI.
check_dependencies.py
A standalone script, run by the installer and safe to run at any time, that compares the importable package versions against requirements.txt.
python3 check_dependencies.py requirements.txtExits 0 when everything is satisfied and 1 when it is not, listing each gap with both versions. The installer treats a failure as advisory: a working install on the distribution's slightly older packages beats a refused one, but it no longer happens silently.
config_schema.py
The configuration contract, validated once at startup.
| Function | Signature | Notes |
|---|---|---|
validate | (config: dict) -> list[str] | Returns warnings; raises ConfigurationInvalid carrying every error found |
ConfigurationInvalid.errors is the full list, one string per problem, each prefixed with the path it was found at (virtual_machines[0].proxmox_host). Unknown keys are warnings rather than errors, so a configuration carrying a key from a newer version still boots — but the typo is reported instead of silently doing nothing, which is how four separate historical defects shipped.
version.py
__version__, the single source of truth. Logged at startup and carried as a label on vm_autoscale_build_info. Keep it in step with the git tag and pyproject.toml; a test enforces that it matches the newest changelog entry.
metrics.py
Prometheus text exposition over http.server, in a daemon thread. No third-party dependency.
class MetricsRegistry
| Method | Signature | Notes |
|---|---|---|
describe | (name, kind, help_text) -> None | Registers # HELP / # TYPE |
inc | (name, labels=None, amount=1.0) -> None | Counter |
set | (name, value, labels=None) -> None | Gauge |
unset | (name, labels=None) -> None | Drops a series — used when a metric is unreadable |
render | () -> str | Exposition format |
class MetricsServer
MetricsServer(registry, logger, bind="127.0.0.1", port=9808, path="/metrics")start() returns False rather than raising when the port cannot be bound; a metrics endpoint is not worth taking the autoscaler down for. stop() shuts the thread down.
build_registry() returns a registry with every metric this service reports already described.
billing_tracker.py
class BillingTracker
BillingTracker(config: dict, logger: logging.Logger)Creates csv_output_dir and loads billing_data.json on construction.
| Method | Signature | Called by the service? |
|---|---|---|
record_spec_change | (vm_id, cpu_cores, ram_mb, timestamp=None) -> None | Yes, after each scaling action |
_webhook_script_is_safe | () -> bool | Refuses a webhook script writable by group or others |
record_vm_state_change | (vm_id, state: "started" | "stopped", timestamp=None) -> None | Yes, on transitions only |
is_period_due | (now=None) -> bool | Yes, once per cycle; the first call starts the clock |
generate_period_report | (vm_id) -> Optional[BillingReport] | Yes, when a period elapses |
get_last_report_time / set_last_report_time | Yes | |
calculate_billing_period | (vm_id, period_start, period_end) -> BillingReport | Via generate_period_report |
export_csv | (report, output_path=None) -> str | Via generate_period_report |
run_webhook | (report) -> None | Via generate_period_report |
set_vm_name | (vm_id, vm_name) -> None | No — call it yourself |
Costs are charged only for the hours a VM was up, and the spec in effect at period_start is carried in so a VM that never changed size is still billed. Every write persists the entire state file. See billing.
Dataclasses
@dataclass
class SpecChangeRecord:
timestamp: datetime
cpu_cores: int
ram_mb: int
@dataclass
class StateChangeRecord:
timestamp: datetime
state: str # "started" | "stopped"
@dataclass
class BillingReport:
vm_id: str
vm_name: str
period_start: datetime
period_end: datetime
min_cpu_cores: int
max_cpu_cores: int
avg_cpu_cores: float
min_ram_mb: int
max_ram_mb: int
avg_ram_mb: float
total_uptime_hours: float
total_downtime_hours: float
uptime_percentage: float
spec_changes: list[dict]
total_cost: floatAll three expose to_dict(); BillingReport.to_dict() is what webhooks receive.
Importing from your own code
import sys
sys.path.insert(0, "/usr/local/bin/vm_autoscale")
from ssh_utils import SSHClient
from vm_manager import VMResourceManager
config = {
"auto_configure_hotplug": False,
"scale_cooldown": 0,
"scaling_limits": {"min_cores": 1, "max_cores": 8,
"min_ram_mb": 1024, "max_ram_mb": 16384},
}
with SSHClient(host="10.0.0.11", user="root",
key_path="/root/.ssh/vm_autoscale_rsa") as ssh:
vm = VMResourceManager(ssh, 101, config)
print(vm.is_vm_running(), vm.get_resource_usage())There is no installable package and no stable API contract — these are scripts on a path. Pin to a commit if you build on them.