Changelog
Tags are not in chronological order
v1.2.0 was published in December 2025 and v0.1.1 in April 2026 — the two release lines were never reconciled. Entries below are chronological, so 1.2.0 appears after 0.1.1. From 1.3.0 onwards the numbering is monotonic again. Go by the releases page rather than by the highest number.
Unreleased
1.7.2 - 2026-09-07
Upgrade note. RAM scaling did not work on a guest whose
balloonsits at itsmemory, which is the default and the state the shipped example configuration produces: every scale-up was rejected by the hypervisor. CPU scaling was unaffected. No configuration changes are required.
Fixed
- RAM scaling was rejected by the hypervisor on the ordinary guest configuration.
memoryis a ceiling andballoonis the allocation; Proxmox refuses any configuration where the second exceeds the first. The service readmemoryas the current value and always wroteballoon, so a scale-up computedmemory + 512and issuedqm set -balloonwith it — answered byballoon value too large (must be smaller than assigned memory)on every guest whose balloon sits at its ceiling, which is the default and the state this project's own example configuration produces.vm_manager.pynow reads the balloon as the allocation, moves the balloon alone under the ceiling, and raises both in a single command when the target is above it. - A guest that could not scale live could not scale down at all. Where ballooning was not available the service issued
qm set -memoryalone, which Proxmox rejects for the same reason while the balloon sits above the new ceiling — and changes nothing. An explicit balloon target now travels with the ceiling, which also means a guest rebooting into a higher ceiling actually receives the memory instead of staying pinned at its old allocation. An absentballoonline already tracksmemoryandballoon: 0is a deliberate choice, so neither is written back. - A scale-down no longer lowers the ceiling of a running guest. That unplugs a DIMM, which the guest may refuse: the testbed returned
error unplug memory moduleafter Proxmox had written the new value, leaving the configuration and the guest disagreeing. Deflating the balloon returns the memory just as well, and a ceiling nobody reaches costs nothing. vm_autoscale_billing_degradedwas exported and never documented;config_schema.pyandversion.pywere missing from the module reference andcheck_dependencies.pyfrom the architecture page. All four were found by the documentation tests on their first run, which is the point of them.
Added
tests/e2e_support.pyandtests/test_e2e_real_node.py: an end-to-end suite that drives a real Proxmox node and resizes a real VM. Markede2eand skipped unlessVMA_E2E_HOSTis set; it is not in CI and never will be, since CI has no hypervisor to talk to. The entry criterion for a test is that a mock could not have told us. It refuses any VM whose name does not containtestbed, restores a fixed baseline around every test and asserts the VM is actually at it, and leaves the guest powered off if it found it that way. See Contributing.TestMemoryIsACeilingNotAnAllocationintests/test_real_proxmox_shapes.py, built from a configuration copied verbatim off the node, so the defect above is caught in CI rather than only on hardware.tests/test_docs_match_code.py: the documentation can no longer drift away from the code in silence. The build fails on a configuration key the schema accepts but the reference does not mention, a metric the service exports but the operations guide does not list, a module missing from the module reference, or a version claimed anywhere that is not the one inversion.py. REVIEW.md §8.3 recorded this risk and nothing had acted on it — thirteen files once needed correcting in a single change, and every stale claim was found after the code had already moved.
Changed
- The warning logged when memory cannot be applied live now names the single thing standing in the way — the guest is stopped,
hotplug: memoryis off, or NUMA is disabled — instead of describing two of them at once.
1.7.1 - 2026-09-07
Upgrade note. Two of these are serious enough to warrant upgrading promptly:
- On a node that writes any warning to stderr — a stale API token entry in
/etc/pve/user.cfgis enough — the autoscaler did nothing at all, every cycle, while sending an error notification each time.- On any VM without an explicit
vcpusline, which is the Proxmox default, a scale-up made the guest smaller.No configuration changes are required.
Fixed
Both found by running the service against a real Proxmox VE 9.1.7 node — the first time this project has ever been exercised against real hardware. 290 tests passed while both of these sat in the scaling path, because every fixture encoded the same assumptions the code did.
- A scale-up could halve a guest's online CPUs. Proxmox omits
vcpusfromqm configwhen every core is online, and_get_current_vcpusread that absence as1. On a 4-core guest with all cores online, a scale-up therefore issuedqm set -vcpus 2— a reduction — and a scale-down computedmax(1 - 1, 1)and dropped it straight to 1, past the one-step-at-a-time contract. Most VMs never setvcpusexplicitly, so most VMs were affected. Absence now means the core count. - A benign warning on stderr disabled the host gate entirely.
check_host_resourcesraised whenever stderr was non-empty, ignoring the exit status it already had. A node carrying a stale API token entry printsuser config - ignore invalid acl token '...'on a successful call, so every VM on that node was skipped every cycle, for the lifetime of the service, with an error notification each time. The gate now judges by exit status.
Added
tests/test_real_proxmox_shapes.py: 13 tests built from payloads copied verbatim off a real PVE 9.1.7 node rather than invented — aqm configwith novcpus, a successful command that writes to stderr, and the real/cluster/resourcesJSON. 8 of them fail against the previous commit.
Note
The claim in REVIEW.md §3.4 that the old pvesh table scrape was fragile to version drift was not borne out: it still parses correctly on PVE 9.1.7. The defects that justified replacing it were real — the substring match on VMID and the 0.0-on-failure fallback — but the format-drift argument was theoretical and is corrected here.
1.7.0 - 2026-09-05
Upgrade note. Three observable changes:
- An invalid configuration now refuses to start, listing every problem at once. A configuration that "worked" while quietly ignoring half its keys will stop working. That is the fix, but it will look like new breakage — read the errors, they name the path and the reason.
- A shrink now needs two consecutive low readings (
scale_down_after_cycles), so VMs scale down one cycle later than before. Growth is unchanged and still acts on the first reading.- Identical notifications are suppressed for 15 minutes (
notification_dedup_seconds). If you were alerting on notification volume rather than content, that signal is gone.
install.shnow installs the latest release tag rather than the default branch. Existing installations are unaffected until the next reinstall.
Everything here comes from acting on REVIEW.md, a full critical review of v1.6.0. Ordered by the risk register in that document.
Fixed
- Billing data could be destroyed by a routine
systemctl stop. The state file was opened with modew, which truncates before the first byte is written, and there was no signal handling anywhere — soSIGTERMkilled the process wherever it was, including mid-write. On restart the read failed, logged a warning nobody alerts on, and continued with empty state; the next scaling action then wrote that empty state over the damaged file. Writes are now atomic (temp file, fsync, rename). An unreadable file is quarantined as.corrupt-<timestamp>, writes are disabled for the run so nothing can overwrite it, and the service carries on scaling with billing off. SIGTERMandSIGINTare handled. OnlyKeyboardInterruptwas caught, which isSIGINTalone. The loop now stops between VMs and waits on an event instead of sleeping, so a stop is acted on at once rather than up tocheck_intervallater. The systemd unit gives it 45 seconds to do that.- A failed reconnect inside
execute_commandreported the wrong error.self.clientwas left asNone, so every remaining attempt raisedAttributeErrorand buried the actual transport failure. - Billing timestamps are timezone-aware UTC. They were naive, so any period spanning a DST transition was off by an hour with no way to detect it.
install.shinstalls a release, not a branch. It cloned the default branch, so the command the README puts first shipped unreleased code and the release tags were consumed by nobody following the documented path.
Added
- A configuration contract (
config_schema.py). Validation was one check that four top-level keys existed; everything else was read with an inline.get()and an inline default across 56 sites in three modules. A key that was written, documented and never read produced no error anywhere — which is exactly howscaling_limits, per-VMthresholds,ssh_portand per-VM limits each shipped broken. The whole file is now validated for types, ranges, enumerations, cross-field consistency and referential integrity, and every problem is reported at once. A VM naming a host that does not exist is an error rather than a VM silently skipped forever. Unknown keys are warnings, so a config carrying a newer key still boots. - Sustained shrink (
scale_down_after_cycles, default 2). Growing fails safe; reclaiming memory from a guest that is using it drives it into swap or to the OOM killer, and a vCPU unplug may be refused. The two were treated identically, on a single sample. - Notification deduplication (
notification_dedup_seconds, default 900). An unreachable node produced one priority-9 notification per VM per cycle — 240 an hour for twenty VMs, with the real alert somewhere underneath. - A version constant, logged at startup and carried as a label on
vm_autoscale_build_info. The service could not previously state what it was. pyproject.toml: installable, packageable, with an entry point and upper bounds on dependencies.- CI gates:
ruff, a coverage floor,pip-audit(whichSECURITY.mdhas been recommending to contributors while CI never ran it), and a check that the shippedconfig.yamlpasses its own validator. - 89 tests, 290 total.
Changed
qm configis fetched once per cycle instead of three times per scaling decision, and the running state is cached rather than re-queried seconds afterprocess_vmestablished it. Measured: 5 SSH commands perscale_cpudown to 2.- Test coverage no longer inverts the risk.
autoscale.pywas at 63% andssh_utils.pyat 63% while the optional metrics endpoint sat at 100%; the main loop, the entrypoint, the constructor andexecute_commandwere entirely untested. They are now 86% and 93%, 87% overall. - Tests build a real
VMAutoscaler. Every test patched__init__away and hand-assembled seven attributes, so the constructor was never executed and adding one field broke ten tests with no behaviour change. process_vmwent from 95 lines to 42, with each step named and the near-identical CPU and RAM branches collapsed into one.- Bare
Exceptionraises are typed asSSHCommandErrorandHostResourceUnavailable, so callers and tests can be specific. - A webhook script writable by group or others is refused. It executes as root at every billing period boundary.
Not addressed
Stated plainly rather than left to inference. From REVIEW.md: there is still no hypervisor abstraction (§3.3), the loop is still sequential with no parallelism (§3.5), host_limits is still global per fleet rather than per node, cooldown state is still in memory, the install location still violates FHS, and the strategic criticism in §2 — the reaction envelope, the billing feature's category error, the commercial model — is not something code can fix.
1.6.0 - 2026-09-05
Upgrade note. A scaling action that fails now raises instead of being logged as done. If something in your fleet has been silently failing to scale,
CommandFailedwill start appearing in the log. That is the fix working, not new breakage — but it will look like new breakage.Start with
dry_run: trueif you want to see what the service intends before letting it act.
Added
- Dry-run mode (
dry_run: true). Everything is evaluated and nothing is changed: no command that touches a VM is issued, hotplug auto-configuration included. Reads still happen, so the evaluation is real; the cooldown still applies, so the cadence in the log is the cadence you would get. Notifications carry a[DRY RUN]prefix and billing records nothing. - Prometheus metrics endpoint (
metrics.enabled), off by default and localhost-bound when on. Exports cycle count and duration, per-VM CPU/RAM, running state and errors, scaling actions by resource and direction, scaling failures, per-node utilisation and gate blocks. A metric that could not be read has its series removed rather than reported as zero. Standard library only — no new dependency. - Per-VM
scaling_limits. A VM entry may override any of the global limits, so a 2-core web server and a 16-core database can share one instance. Resolution: the VM's own block, then the global section, then the legacy flat keys, then the built-in default. tests/test_dryrun_metrics_limits.py: 31 regression tests (201 total).
Fixed
- A failed
qm setis no longer reported as a success.execute_commandreturns the exit status rather than raising, and every caller discarded it, so a command that exited non-zero still producedIncreased cores to 5 (hotplug applied)in the log, a success notification, a billing record and a consumed cooldown — which then delayed the retry by a fullscale_cooldown. Commands now run through a checked helper that raisesCommandFailedwith the command, the exit status and stderr. - A failed
qm configread no longer invents a value. The getters returned 1 core, 1 vCPU or 512 MB when the command failed, and those fabricated numbers went straight into a scaling decision — the service would happily "scale up from 1 core" a guest that actually had eight. They now raise. An absent key still means the documented Proxmox default, because that is what absence legitimately means. - A missing VM is no longer retried for 30 seconds.
qm statusexiting non-zero was treated as a transient fault and retried three times with backoff. It now fails fast and logs stderr.
1.5.0 - 2026-09-05
Upgrade note. Three behaviour changes land together:
- A
thresholds:block in a VM entry now takes effect. If you wrote one expecting it to work, that VM starts using it — re-read the numbers first.- Host keys are now verified. The first connection to each node records its key and a later change is refused. Rebuilt a node? Remove its line from
ssh_known_hosts.- Billing starts writing CSV reports and calling webhooks where it previously did nothing.
Fixed
- Per-VM
thresholdsare read. The block has been in the exampleconfig.yamlsince the beginning and nothing consulted it; every VM used the globalscaling_thresholds. Both the flat shape (cpu_high,cpu_low,ram_high,ram_low) and the nested one (cpu: { high, low }) now work, and any bound you omit keeps the global value. - Billing generates reports.
generate_period_reportexisted but nothing called it, so enabling billing produced a growing state file and no CSV, no webhook and no report. The main loop now emits one per VM once a billing period elapses; the period clock is persisted, so it survives restarts. - Billing records VM start/stop.
record_vm_state_changewas never called, which is why every report showed 100% uptime. Transitions are now recorded — transitions only, not one entry per poll. - Downtime is no longer billed as uptime.
_calculate_resource_costaccepted the uptime records and ignored them, so a VM powered off for a week was billed for that week at its last known spec. Cost is now charged only for the hours a VM was actually running. - A VM that never changed spec is no longer billed zero. Spec changes are events, so a guest that held one size for the whole period had no records inside it and cost nothing. The spec in effect at
period_startis now carried in, and so is the running state. ssh_porthas a default again. It was indexed directly rather than via.get(), so omitting it raisedKeyErrorand every VM on that host failed. The shipped example omitted it onhost2.
Security
- SSH host keys are verified. The client set
AutoAddPolicywithout ever loading or saving a known_hosts file, so every connection accepted whatever key it was offered and remembered nothing — no protection at all for a service holding root credentials. Newssh_host_key_policy, defaultaccept-new: a node's key is recorded on first contact and a later change is refused.strictrequires a pre-populatedssh_known_hosts;autoreproduces the old behaviour and logs a warning. A mismatch is fatal and is not retried.
Added
tests/test_thresholds_hostkeys_billing.py: 32 regression tests (170 total) covering threshold resolution and precedence, all three host key policies and the mismatch path, uptime-weighted cost, period carry-in, the persisted report clock, and the autoscaler's own billing plumbing.
Changed
- New logo. The bar-chart-and-arrow mark said "analytics" and only showed growth, which misrepresents a service whose point is moving in both directions. The replacement is a solid allocation between two chevrons, and it stays legible at 16px.
favicon.svgkeeps the orange tile for the browser tab and navigation;logo-mark.svgis a transparent variant used in the documentation hero.
1.4.0 - 2026-09-05
Upgrade note. A VM whose usage cannot be read now stops scaling instead of being treated as idle and walked down to its minimum. If something in your fleet was quietly shrinking, it will now hold its size and log
unavailable— that is the fix working, but a VM with a persistent metric problem will no longer scale at all. Alert on the warning.
Fixed
- Guest metrics no longer come from scraping a table. Usage was read with
pvesh get /cluster/resources | grep 'qemu/<vmid>' | awk -F '│' …, which depended on box-drawing separators and fixed column positions — both of which move between Proxmox versions — and whose substring match also caught VMID1010when looking for101. The service now requests--output-format jsonand matches ontype == "qemu"and an exactvmid. - An unreadable metric is no longer treated as zero. A parse failure used to report
0.0, which sits below every sensiblelowthreshold, so it was indistinguishable from an idle guest and walked the VM down to its minimum one step per cycle.get_resource_usage()now returnsNonefor a metric it could not read, the log saysunavailable, and scaling is skipped for that resource. A genuinely idle guest still reports0.0and still scales down. - SSH keys of any type now load (Ed25519 included).
RSAKey.from_private_key_filewas called directly, so an Ed25519 key — the typeSECURITY.mdrecommends — failed to load however valid it was. Key loading now goes throughparamiko.PKey.from_path, with a per-class fallback for older paramiko. Encrypted keys remain unsupported, and the error now names the file and the reason.
Added
- 30 regression tests (138 total), including real Ed25519, ECDSA and RSA keys generated on disk and loaded through the production path, and JSON payloads covering the VMID-prefix collision, LXC guests sharing a VMID, missing and non-numeric fields, and the distinction between unreadable and idle.
Removed
_parse_cpu_usage,_parse_ram_usageand_convert_to_gib— the regex parsers for the old table format.
1.3.0 - 2026-09-05
Upgrade note. Limits you set in
scaling_limitswere previously ignored; they now take effect. Re-read that section before upgrading, particularly if you configured amax_coresabove 8 or amin_ram_mbabove 512 and have been running against the hardcoded defaults without realising.This release also drops Python 3.9. See Removed below.
Fixed
scaling_limitsfromconfig.yamlis now actually enforced. The config validator required the section, butVMResourceManagerlooked the values up as flat top-level keys under names that did not exist (max_ram/min_raminstead ofmax_ram_mb/min_ram_mb). Every installation silently ran on the hardcoded defaults 1–8 cores and 512–16384 MB, so the documentedmin_ram_mb: 1024(needed because NUMA misbehaves below 1 GB) had no effect. Flat top-level keys are still accepted as a fallback for older configs.- CPU and RAM now have independent cooldowns (#30): a single shared timestamp meant any CPU threshold breach suppressed RAM scaling for the same cycle. The cooldown is also consumed only when a scaling command is actually issued — previously the mere check consumed it, so a VM already at its limit was rate-limited for doing nothing.
scale_cooldownnow applies between polling cycles.VMResourceManagerwas rebuilt on every iteration of the main loop, resetting the cooldown to zero; the effective interval wascheck_interval, notscale_cooldown. Managers are now cached per VM and rebound to each cycle's SSH connection. As a side effect, hotplug auto-configuration runs once per VM instead of issuing two extraqm configcalls per VM per cycle — and is retried on the next cycle if it failed, so a transient SSH error during the first cycle no longer leaves a VM permanently non-live-scalable.can_scale()and_mark_scaled()reject unknown resource names instead of silently treating them as never rate-limited.
Security
install.shno longer leavesconfig.yamlworld-readable. A recursivechmod 755over the install directory applied to the config file too, which stores the Proxmox root SSH password and SMTP credentials in plain text. The config and its backup are nowchown root:root+ mode600, and the backup directory is mode700— matching whatSECURITY.mdalready prescribed.
Added
- Documentation site at https://fabriziosalmi.github.io/proxmox-vm-autoscale/ — guide, configuration reference, architecture, module API, a full catalogue of known limitations, threat model, hardening guide and privacy statement. Built with VitePress and deployed by GitHub Actions, with
sitemap.xml,llms.txt,.well-known/security.txtand JSON-LD structured data. tests/test_scaling_limits_and_cooldown.py: 24 regression tests covering limit resolution (including a test that the limits shipped inconfig.yamlare the ones enforced), independent per-resource cooldowns, resource-name validation, and manager reuse and hotplug retry across cycles.
Changed
install.shnow installs thevm_autoscale.servicetracked in the repository instead of generating its own copy inline. The two had drifted: the generated unit had noRestartSec, so a crash-looping service restarted as fast as systemd allowed, and the tracked file was never actually used.install.shno longer aborts when pip cannot write to the system interpreter. On Proxmox VE 8 and Debian 12+ that is PEP 668'sexternally-managed-environment, and treating it as fatal failed an installation that was otherwise complete — the apt step already providesparamiko,PyYAMLandrequests.- Dependency floors raised to
paramiko>=5.0.0,PyYAML>=6.0.3andrequests>=2.34.2. - GitHub Actions bumped to current majors. Dependabot now watches the
github-actionsandnpmecosystems as well aspip. - README trimmed from 343 lines to 131,
ARCHITECTURE.mdfrom 290 to 91 andSECURITY.mdfrom 122 to 106, with the long-form material moved to the documentation site rather than maintained in two places and drifting apart.
Removed
- Python 3.9 support.
requests2.34.2 requires Python >= 3.10. Python 3.9 reached end of life in October 2025, and the only Proxmox release shipping it is VE 7, end of life since July 2024. The CI matrix is now 3.10–3.12. release-notes-v0.1.1.mdfrom the repository root — it duplicated the GitHub release verbatim.
0.1.1 - 2026-04-27
Fixed
- Host RAM usage calculation now matches Proxmox WebUI (#38): RAM usage is computed from the
usedfield reported bypvesh, which excludes reclaimable buff/cache. Previously,free + cachedwas used as the available memory estimate, leading to incorrectly high RAM usage percentages (e.g. ~90% instead of ~66%) and suppressed scaling on hosts with heavy caching.
Added
tests/test_host_resource_checker.py: full test coverage forHostResourceChecker, including the RAM calculation fix, threshold boundary cases, bytes output handling, and error paths.tests/test_autoscale.py: tests forNotificationManager(config validation, message formatting, routing with Gotify/SMTP fallback),VMAutoscalerconfig loading,_handle_cpu_scaling/_handle_ram_scalingdecision logic, andVMResourceManagerscaling helpers (scale_cpu,scale_ram,can_scale,_parse_cpu_usage,_parse_ram_usage).
Changed
- Removed unused
cached_memandfree_memvariables fromHostResourceChecker.check_host_resources.
1.2.0 - 2025-12-09
Documented retroactively. This tag was published before
0.1.1despite the higher number — the two release lines were never reconciled. Entries in this file are chronological, so1.2.0appears below0.1.1. From1.3.0onwards the numbering is monotonic again.
Added
- Hotplug support (#37):
auto_configure_hotplugenables hotplug and NUMA on managed VMs; RAM changes useballoonand CPU changes usevcpusso they apply to a running guest, with a fallback when hotplug is unavailable.coreschanges and NUMA itself still require a guest reboot. - Billing tracking (#33):
billing_tracker.py, recording CPU/RAM spec changes with timestamps, plus period cost calculation, CSV export and webhook support. - 30 unit tests covering hotplug and billing.
Known issues in this release
Recorded here because the original release notes overstate what shipped:
- Only spec recording is wired into the service.
generate_period_report,export_csv,run_webhook,record_vm_state_changeandset_vm_nameare public API that nothing calls, so no CSV is produced and no uptime is tracked unless you invoke them yourself. - The cost calculation accepts uptime records and ignores them, so downtime is billed as uptime.
auto_configure_hotplugran on every polling cycle, issuing two extraqm configcalls per VM per cycle. Fixed in 1.3.0.
[0.1.0-docs] - Unreleased documentation pass
Added
- Comprehensive documentation improvements across all markdown files
requirements.txtfor Python dependency managementARCHITECTURE.mdwith detailed system architecture documentation- Troubleshooting section in README with common issues and solutions
- Table of contents in README for better navigation
- Enhanced configuration examples with inline comments
- Development setup instructions in CONTRIBUTING.md
- Comprehensive security policy in SECURITY.md
- This CHANGELOG file to track project changes
Changed
- Enhanced README.md with improved structure and clarity
- Updated CONTRIBUTING.md with detailed contribution guidelines
- Expanded SECURITY.md with security best practices and reporting process
- Improved configuration examples with better annotations
- Updated prerequisites section with specific version requirements
Fixed
- Typo in config.yaml comment: "doeasnt" → "doesn't"
- Typo in README: "togheter" → "together"
- Improved formatting and consistency across documentation
- Clarified Python version requirement (3.6+)
0.1.0 - Initial Release
Added
- Initial release of Proxmox VM Autoscale
- Automatic CPU and RAM scaling for Proxmox VMs
- Multi-host support via SSH
- Gotify notification support
- Email notification support
- Systemd service integration
- Configuration via YAML file
- Comprehensive logging
- Host resource safety checks
- Scaling cooldown periods