Skip to content

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.

python
VMAutoscaler(config_path: str, logging_config_path: Optional[str] = None)
MethodSignatureNotes
run() -> NoneBlocking main loop. Exits on KeyboardInterrupt
process_vm(host: dict, vm: dict) -> NoneOne VM, one cycle. Catches and notifies its own errors
_load_config(config_path: str) -> dictStatic. Raises FileNotFoundError or ConfigurationError
_setup_logging(path: Optional[str]) -> LoggerJSON config wins over the YAML logging section
_get_vm_manager(ssh_client, vm_id) -> VMResourceManagerCached per VMID; rebinds the SSH client
_handle_cpu_scaling(vm_manager, vm_id, cpu_usage, thresholds=None) -> NoneIgnores a None reading
_handle_ram_scaling(vm_manager, vm_id, ram_usage, thresholds=None) -> NoneIgnores a None reading
_thresholds_for(vm: dict, resource: str) -> dictPer-VM overrides on top of the global thresholds
_record_vm_state(vm_id, running: bool) -> NoneWrites a billing record on transitions only
_maybe_generate_billing_reports() -> NoneEmits period reports when due
_record_billing_spec(vm_manager, vm_id) -> NoneNo-op when billing is disabled

class NotificationManager

python
NotificationManager(config: dict, logger: logging.Logger)

Validates channel configuration in the constructor.

MethodSignatureNotes
send_notification(message, priority: Optional[int] = None) -> NoneFans out to all enabled channels; never raises
send_gotify_notification(message: str, priority=None) -> NoneRaises on HTTP failure
send_smtp_notification(message: str) -> NoneRaises on SMTP failure
validate_notification_config() -> NoneRaises ConfigurationError
_format_message(message) -> strJoins tuples, stringifies everything else

class ConfigurationError(Exception)

Raised for missing sections and incomplete channel configuration.

vm_manager.py

class VMResourceManager

python
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

MethodSignatureReturns
is_vm_running(retries=3, delay=5) -> boolFalse 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") -> boolRead-only cooldown check
scale_cpu(direction: "up" | "down") -> boolTrue only if a change was made
scale_ram(direction: "up" | "down") -> boolTrue only if a change was made

Internal helpers worth knowing

MethodPurpose
_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_coresResolved limits
_get_min_ram / _get_max_ramResolved limits, MB
_get_current_cores / _get_current_vcpus / _get_current_ramParsed from qm config
_check_hotplug_enabled()(cpu_hotplug, memory_hotplug)
_check_numa_enabled()bool
_set_cores / _set_vcpus / _set_ramIssue 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_percentPercentages 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

python
SSHClient(host, user, password=None, key_path=None, port=22,
          host_key_policy="accept-new", known_hosts="/etc/vm_autoscale/known_hosts")
MethodSignatureNotes
connect() -> NoneReuses 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() -> NoneIdempotent
_load_private_key() -> paramiko.PKeyAny supported key type; raises SSHException naming the path on failure
_apply_host_key_policy(client) -> NoneApplies 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

python
HostResourceChecker(ssh_client)
MethodSignatureNotes
check_host_resources(max_cpu_pct, max_ram_pct) -> boolTrue 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.

bash
python3 check_dependencies.py requirements.txt

Exits 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.

FunctionSignatureNotes
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

MethodSignatureNotes
describe(name, kind, help_text) -> NoneRegisters # HELP / # TYPE
inc(name, labels=None, amount=1.0) -> NoneCounter
set(name, value, labels=None) -> NoneGauge
unset(name, labels=None) -> NoneDrops a series — used when a metric is unreadable
render() -> strExposition format

class MetricsServer

python
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

python
BillingTracker(config: dict, logger: logging.Logger)

Creates csv_output_dir and loads billing_data.json on construction.

MethodSignatureCalled by the service?
record_spec_change(vm_id, cpu_cores, ram_mb, timestamp=None) -> NoneYes, after each scaling action
_webhook_script_is_safe() -> boolRefuses a webhook script writable by group or others
record_vm_state_change(vm_id, state: "started" | "stopped", timestamp=None) -> NoneYes, on transitions only
is_period_due(now=None) -> boolYes, 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_timeYes
calculate_billing_period(vm_id, period_start, period_end) -> BillingReportVia generate_period_report
export_csv(report, output_path=None) -> strVia generate_period_report
run_webhook(report) -> NoneVia generate_period_report
set_vm_name(vm_id, vm_name) -> NoneNo — 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

python
@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: float

All three expose to_dict(); BillingReport.to_dict() is what webhooks receive.

Importing from your own code

python
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.