Skip-IfMissing skipped a test whenever the cmdlet under test was absent
from the build, including the test asserting it exists. Every cmdlet
compiles into one assembly, so there is no partial-build case for it to
serve; all it did was hide a missing cmdlet. Delete the 37 copies and
every call site, and drop the residual conditional skips in the same
family (CmdExists probes, an attribute-presence skip in SdnCmdlets, a
lifecycle helper skipping on a cmdlet-name collision fixed long ago).
894 It blocks only reflected [Cmdlet] and [Parameter] attributes back at
the compiler: existence, CommandType -eq 'Cmdlet', Parameters.ContainsKey,
IsMandatory reflection, and per-file CmdletsToExport asserts for whichever
names an author remembered. Nothing reflected over the built assembly, so
a new cmdlet missing from the manifest shipped invisible. One data-driven
file replaces them: it diffs CmdletsToExport against the assembly's cmdlet
types in both directions and asserts the conventions reflection can see.
Behavioural tests are untouched: no-session errors, binding rejections,
ShouldProcess and -WhatIf, ConfirmImpact, ValidateSet and ValidateRange
values, parameter types, positions and pipeline binding.
The generated help covered 169 of 194 cmdlets. Regenerated with the repo's
own generate-help.ps1: 25 new markdown stubs, 13 existing docs picking up
parameters added in earlier waves, and a rebuilt MAML.
Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
Collapses the ~24-line Level/Node/VmId/Group validation block duplicated
across 18 Firewall cmdlets into a single FirewallScope.TryValidate helper
in PSProxmoxVE.Core, beside FirewallService.BuildBasePath which already
owns the level-to-path mapping (ADR 0021: request-payload/validation
correctness is proven offline, not against a live cluster).
Each cmdlet now makes one TryValidate call and, on failure, one
ThrowTerminatingError with the same ErrorId (NodeRequired/VmIdRequired/
GroupRequired), ErrorCategory.InvalidArgument, target object (null) and
message text it used before, so the Pester assertions under
tests/PSProxmoxVE.Tests/Firewall/ keep passing unedited.
Part of #154.
Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
* refactor: route the storage cmdlets through StorageService (#126)
New-PveStorage, Invoke-PveStorageDownload and Send-PveFile each built
their own PveHttpClient. They now call StorageService, which already
had CreateStorage/DownloadUrl/UploadIso with zero callers.
StorageService.UploadIso previously hardcoded content=iso regardless
of the cmdlet's ContentType parameter (iso/vztmpl/import), which would
have silently broken vztmpl/import uploads on conversion; it now takes
an optional contentType parameter. DownloadUrl gained an optional
timeout parameter so Invoke-PveStorageDownload -TimeoutSeconds keeps
working, matching UploadIso's existing default. Per issue #194, both
cmdlets construct a fresh StorageService() with no injected client so
the timeout override reaches PveServiceBase.CreateClient instead of
being silently dropped.
ParseTask now stamps Status = "running" on the UPID-string branch,
matching what the cmdlets stamped locally before conversion (same
rule PR #196 established for SnapshotService).
* test: add StorageService coverage for the #126 storage seam
---------
Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
FirewallService.GetGroupRules/CreateGroupRule/UpdateGroupRule/RemoveGroupRule
already existed with zero callers. Get/New/Set/Remove-PveFirewallRule gain a
Group value in their -Level ValidateSet and a -Group parameter, required and
validated the same way Node/VmId are validated for the other levels, and
dispatch to those service methods instead of the generic BuildBasePath-driven
ones. No cmdlet in this area built its own client, so unlike the other #126
areas there is no inline request to strip.
Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
Wires the 12 direct-PveHttpClient Network/SDN cmdlets (Get/New/Set/Remove-PveNetwork,
Invoke-PveNetworkApply, Get/New-PveSdnZone, Get/New-PveSdnVnet, Get/New-PveSdnSubnet,
Remove-PveSdnSubnet) to the existing NetworkService methods, which previously had zero
callers for the New/Set/Get paths. NetworkService.ParseTask now stamps
Status = "running" on the UPID-string branch, matching SnapshotService's ParseTask from
PR #196 (the pattern PR). Offline tests for every wired service method are added to
NetworkServiceTests.cs.
Fold-in from issue #126's own comment: ValidatePattern identifier validation, already
present on Remove-PveSdnZone/Vnet and Remove-PveStorage from PR #161, is added to the
Zone/Vnet identifier parameters on the New-PveSdnZone/Vnet/Subnet and
Remove-PveSdnSubnet cmdlets converted here.
Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
Get-PveCloudInitConfig and Get-PveTemplate each built their own PveHttpClient
inline. They now go through CloudInitService and TemplateService, which is
offline-testable per ADR 0021.
Get-PveCloudInitConfig's shipped output is the full PveVmConfig (per its own
synopsis), not the CI-key-filtered PveCloudInitConfig that
CloudInitService.GetCloudInitConfig already returns for a different purpose.
CloudInitService gains GetFullVmConfig, a thin delegation to the existing
VmService.GetVmConfig (already used by Get-PveVmConfig) rather than a
parallel HTTP implementation.
Get-PveTemplate's TemplateService.GetTemplates now forwards VmService.GetVms's
onNodeSkipped callback, matching Get-PveVm/Get-PveContainer, so an unreachable
node during the all-nodes listing produces a warning instead of a silently
short result. Two review-found regressions were fixed before commit: an empty
-Node value used to mean "query all nodes" and now does again (it had
started reaching the API literally as nodes//qemu), and the per-node warning
above.
Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
New-PveContainerSnapshot, Remove-PveContainerSnapshot and
Restore-PveContainerSnapshot each built their own PveHttpClient and parsed
the response inline while ContainerService.CreateContainerSnapshot /
RemoveContainerSnapshot / RollbackContainerSnapshot carried the same three
requests with no callers. The cmdlets now call the service, so the path
and form each sends is asserted offline (ADR 0021).
New-PveContainer already called ContainerService.CreateContainer but
allocated its container ID with a private PveHttpClient hitting
cluster/nextid; it now goes through ClusterConfigService.GetNextId, the
seam Copy-PveVm, Copy-PveContainer, New-PveVm and Import-PveOva already
use.
ContainerService.ParseTask's UPID-string branch now stamps
Status = "running", matching SnapshotService.ParseTask from the #126
pattern PR (#196). ParseTask is shared by every ContainerService lifecycle
method, so this also changes the non-Wait Status output of ten cmdlets
beyond the four converted here, from null to "running" — pinned with a
dedicated test.
Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
Get-PveRole, Get-PveUser, New-PveRole and Set-PvePermission each built
their own PveHttpClient and hand-rolled requests, while UserService's
GetRoles/GetUsers/CreateRole/SetPermission carried the same requests
with zero callers. Wire the cmdlets to the service and change
SetPermission's propagate/delete parameters from bool with defaults
to nullable bool so the omitted-when-unset shipped behaviour of
Set-PvePermission survives the move.
Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
Get-PveNode and Get-PveNodeStatus built their own PveHttpClient and
parsed the response inline. Both now call the existing NodeService
methods (GetNodes, GetNodeStatus), which already carried matching
requests. NodeService.GetNodeStatus gained the node-name stamp the
cmdlet used to apply after deserializing, and both GetNodes and
GetNodeStatus regained the missing-data guard the cmdlets had, which
the pre-existing service implementation lacked.
Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
New-PveSnapshot, Remove-PveSnapshot and Restore-PveSnapshot built their own
PveHttpClient and parsed the response inline while SnapshotService carried
the same three requests with no callers. The cmdlets now call the service,
so the path and form each sends is asserted offline (ADR 0021).
Reconciled toward the shipped cmdlet behaviour: CreateSnapshot omits
vmstate unless it is true, and ParseTask stamps Status = "running" on a
UPID-string response.
Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
Every service method built and disposed its own PveHttpClient, and each
client owned a fresh HttpClientHandler, so every API call was a TCP connect
plus a TLS handshake and WaitForTask paid that 300 times over a ten-minute
wait.
PveServiceBase now owns the injected-or-fresh client lifetime behind one
Invoke helper; the 201 hand-written try/finally blocks across the 16
services collapse to calls on it, and the nested NodeService/VmService
instances receive the injected client. PveHttpClient takes its handler
from a process-wide PveHandlerCache keyed on (host, port,
skipCertificateCheck) and never disposes it, so the connection pool
outlives any one client. WaitForTask holds one client for the whole wait
and, when no pollInterval is supplied, backs off from 1 s toward a 10 s
cap, never sleeping past the deadline.
Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
Adds global.json (10.0 SDK, rollForward latestFeature) so local dev
resolves the same SDK line CI pins in five workflows, instead of
floating to whatever is installed.
Adds Directory.Build.props for LangVersion/Nullable, the two
properties identical across all three csproj (TargetFramework stays
per-project since the test project multi-targets net10.0;net48).
Adds Directory.Packages.props with central package management, moving
every PackageReference version out of the three csproj into one file.
Newtonsoft.Json bumped 13.0.3 -> 13.0.4 in the single place instead of
two independent declarations that could drift. xunit.runner.visualstudio
stays at 4.0.0 (it runs xUnit v1/v2/v3 per its own description; nothing
in the issue's stated xunit 2.9.3 pairing required a downgrade).
Removes the self-referential $(NoWarn) from the Core csproj, a no-op.
Adds an explicit System.Memory 4.6.3 PackageReference to the net48
leg of the test project, which resolves the MSB3277 conflict between
System.Memory 4.0.1.2 and 4.0.5.0 (110 warnings -> 0). Scoped to net48
only per the issue, which names only the net48 test target.
Adds PackageVersionCentralizationTests, an xUnit test asserting none
of the three csproj declare a PackageReference Version attribute
outside Directory.Packages.props.
Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
* fix: retarget Import-PveOva's not-found catch and add upload timeouts
VmService.GetVm throws InvalidOperationException when the VM is not yet
listed on the node, never PveApiException(NotFound) — the API call itself
returns 200. Import-PveOva's tail catch was for the exception GetVm never
throws, so a successful import without -Wait raised the InvalidOperationException
unhandled instead of falling back to a basic PveVm. Retarget the catch, and
narrow its try region to the GetVm call only so the fallback can no longer
fire for a WriteObject failure on an already-retrieved VM.
VmService.UploadOva and StorageService.UploadIso built their PveHttpClient
with no timeout override, so large OVA/ISO uploads inherited the session's
100s default and aborted mid-transfer. Both now take a TimeSpan? timeout
(default 30 minutes), matching Send-PveFile. Import-PveOva gains
-TimeoutSeconds mirroring Send-PveFileCmdlet's parameter.
Also drops the trailing null, null, null on WaitForTask calls in
ImportPveOvaCmdlet.cs and NewPveVmCmdlet.cs, left over from #140.
Closes#139
* fix: give UploadOva/UploadIso a timeout override and add coverage
Completes the #139 fix: VmService.UploadOva and StorageService.UploadIso
now take a TimeSpan? timeout (default 30 minutes) instead of always using
the session's 100s default. Adds a GetVm not-found regression test and a
timeout-propagation test suite for both upload methods.
* test: release the upload before deleting its temp file
The two default-timeout tests left the upload in flight and then
deleted the file it still had open. Windows refuses that, so both
build-and-test legs failed on windows-latest.
---------
Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
* fix: surface swallowed errors in status polling and per-node listing
WaitForStatusTransition's poll loop caught every exception except OOM/
StackOverflow and discarded it silently. An expired ticket, a deleted VM, or
a wrong node name under -Wait -Timeout spun for the full timeout and then
raised PveTaskTimeoutException instead of the real 401/403/404. The loop now
catches only PveApiException (excluding 401/403/404, which propagate) and
HttpRequestException, and WriteVerbose's what it swallows.
VmService.GetVms and ContainerService.GetContainers caught PveApiException
of any status per node and continued, so a permission problem or an
unreachable node looked identical to "no VMs". The per-node catch now
narrows to a 5xx/408/connectivity failure (IsNodeUnreachable) and takes an
optional onNodeSkipped callback; Get-PveVm and Get-PveContainer wire it to
WriteWarning. Any other status (401/403/404 included) propagates.
Adds xUnit coverage for the per-node aggregation path (403 propagates, 500/
408/connectivity failures are skipped and reported, the other nodes' results
still come back), reaching NodeService's internal client via reflection
since it is not otherwise constructor-injectable from VmService/
ContainerService.
Closes#142
* test: add per-node aggregation coverage and wire onNodeSkipped
Adds the remaining changes: VmService/ContainerService per-node catch
narrowing plus onNodeSkipped callback, the Get-PveVm/Get-PveContainer
WriteWarning wiring, and the xUnit coverage for the aggregation loop.
---------
Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
* fix: allocate a real VMID for Copy-PveVm/Copy-PveContainer and honor -Storage
Copy-PveVm and Copy-PveContainer defaulted newid to 0 when -NewVmId
was omitted, and never sent -Storage on the clone request even
though both cmdlets declare it. Both cmdlets now allocate via
ClusterConfigService.GetNextId when -NewVmId is null, and forward
-Storage into the clone form body. Since PVE rejects storage on a
linked clone, -Storage without -Full now fails fast client-side
instead of failing later against the API.
NewPveVmCmdlet and ImportPveOvaCmdlet hand-rolled the same
GET cluster/nextid call with a manual JObject parse; both now go
through ClusterConfigService.GetNextId so a response without a
data field raises the service's diagnosable InvalidOperationException
rather than a NullReferenceException.
Closes#135
* test: pin CloneVm/CloneContainer storage and newid form-body behavior
Offline xUnit coverage per ADR 0021: storage present in the clone
form body when supplied, absent when omitted, and newid forwarded
verbatim (never coerced to 0) by the service layer.
* fix: add missing storage parameter to VmService.CloneVm
VmService.cs was omitted from the earlier push; this restores the
storage parameter and form-body wiring that belongs with this fix.
* fix: drop the client-side -Storage/-Full guard from the Copy cmdlets
PVE returns the same error itself when storage is sent on a linked
clone, so the guard only saved one round trip, had no test, and rested
on a behaviour claim the OpenAPI spec does not document.
* test: cover the -Storage/-Full guard in Copy-PveVm and Copy-PveContainer
Offline Pester coverage for the client-side StorageRequiresFullClone
guard: -Storage without -Full throws before a session is required,
and -Storage with -Full does not trip the check.
* test: revert the Pester cases for the removed -Storage/-Full guard
The guard was dropped in bfe2483, so these cases assert an error the
cmdlets no longer raise.
---------
Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
WaitPveTaskCmdlet hand-rolled its own poll loop (while(true) /
Thread.Sleep / JObject.Parse), the exact pattern ADR 0001 forbids.
It now delegates to TaskService.WaitForTask, passing a progress
callback that drives WriteProgress — the seam WaitForTask was built
for and that nothing called until now.
Preserves the cmdlet's own documented contract (an omitted -Timeout
waits indefinitely) by passing a 100-year sentinel instead of null,
since TaskService.WaitForTask treats a null timeout as its own
10-minute default, not infinite. Reuses one PveHttpClient for the
whole wait instead of letting TaskService open a fresh one per poll.
Also strips the redundant trailing 'null, null, null' default
arguments from 12 other WaitForTask call sites so they use the short
form, per the issue. Left CopyPveContainerCmdlet.cs,
ImportPveOvaCmdlet.cs and NewPveVmCmdlet.cs alone — issue #135 is
touching those concurrently.
Adds three xUnit tests to TaskServiceTests.cs proving the behavior
the inline loop got wrong: no sleep before the first status check,
the 1-second MinPollInterval clamp, and the progress callback firing
on every poll. Mutation-tested by breaking each behavior in turn and
confirming the corresponding test fails.
Closes#140
Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
* fix: plumb skiplock parameter through Remove-PveVm and Remove-PveContainer
When -Force is specified, both cmdlets now pass skiplock=1 to PVE, which bypasses locks.
PVE honours the skiplock parameter for root@pam only.
Also updated help text on both cmdlets to clarify the limitation and behaviour.
Fixes#136.
* fix: plumb skiplock parameter for VMs, force for containers
VM removal: add skiplock=1 parameter when -Force is specified. PVE honors it
for root@pam only; non-root callers receive 403 errors. Updated help text.
Container removal: map -Force to force=1 (LXC-specific parameter for forcing
removal of running containers). Containers do not support skiplock.
Also: clarified class and parameter documentation to remove false claims about
-Force suppressing confirmation (it does not).
Fixes#136. Addresses correctness reviewer findings.
* fix: revert unrelated formatting churn, add container force test
The em-dash-to-hyphen sweep in commit 75f4bbf touched VmService.cs and
VmServiceTests.cs outside the skiplock/force change; restore the
original text there, including the <vmid> XML-doc escape that
had been un-escaped and was malforming the generated documentation.
Add ContainerServiceTests.cs, covering the untested force=1
query-string plumbing in ContainerService.RemoveContainer.
---------
Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
UserService.GetPermissions unwrapped the path-keyed /access/permissions
response and discarded prop.Value, so every returned PvePermission had a
path but no privilege data. Add a Privileges dictionary populated from
the privilege map; a key's presence means the privilege is granted and
its value is whether the grant propagates to sub-paths, matching PVE's
documented "propagate boolean" contract for that endpoint.
Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
* fix: remove defunct DELETE /access/ticket call and gain -Session parameter
Disconnect-PveServer was calling DELETE /access/ticket, an endpoint that does
not exist in the PVE API spec. The call always failed but errors were swallowed,
so the cmdlet succeeded while claiming server-side invalidation occurred.
Rebase on PveCmdletBase to gain the -Session parameter, making explicit session
disconnection possible (the cmdlet previously only worked with the active
session). Update help text to clarify that PVE tickets expire server-side after
two hours and cannot be revoked.
Remove the DELETE call entirely — tickets are stateless and expire on their own.
Clear ModuleState.ActiveSession only when disconnecting the active session
(no -Session given) or when explicitly disconnecting the active session.
* fix: address reviewer findings on -Session handling and test coverage
Fixes from correctness and api-compat reviews:
1. Use BoundParameters to distinguish -Session omitted from -Session $null,
preventing accidental active-session clear when $null is passed.
2. Warn and return early when -Session is supplied but not the active session,
avoiding silent no-ops that leave the user's session variable populated and
functional but with expectations misaligned (they passed a session to
disconnect it, but disconnecting a non-active session is now explicit).
3. Use ReferenceEquals() explicitly instead of == for the identity check,
future-proofing against PveSession ever gaining value-equality semantics.
4. Fix the lifecycle test to check observable behavior (warning output) instead
of reaching into null PrivateData. Tests now verify both "no session to
disconnect" and "non-active session supplied" paths.
* fix: remove unreliable offline lifecycle tests from Pester suite
The lifecycle tests attempted to capture warning output and verify module state,
but both approaches fail in the offline Pester environment:
1. WarningVariable captures don't work as expected in Pester contexts
2. ModuleState is not exposed through PrivateData and cannot be inspected from
outside the module in offline tests
Keep the reliable metadata test that verifies -Session parameter is exposed.
The behavioral verification of the warning logic happens in the correctness
review and will be validated in integration testing, not in offline unit tests.
* fix: move reference equality check before ShouldProcess
The ShouldProcess check was firing before validating whether the supplied
-Session is actually the active session. This means -WhatIf would prompt
"Disconnect host:port?" even for sessions that will not be disconnected
(where the cmdlet just warns and returns).
Move the ReferenceEquals check before ShouldProcess so mismatched sessions
short-circuit with a warning before any prompting occurs. This accurately
represents what -WhatIf/-Confirm is about to do.
* test: restore and harden offline coverage for Disconnect-PveServer session handling
Commit 3f75d4a deleted two offline Pester tests for the reference-equality
logic added in fa930db, with a wrong justification (WarningVariable
captures don't work in Pester). WarningVariable works fine; the real
defect in the deleted mismatched-session test was passing a
[PSCustomObject] to a parameter typed PveSession, a type-binding
failure, not a WarningVariable failure.
Restores both cases via reflection against PveSession's internal
constructor and ModuleState's internal static ActiveSession property
(both types are internal/have internal members, so Pester has no other
way to construct a real session or observe module state), and adds
coverage for the two branches the deleted tests never exercised: the
active-session clear itself, -Session pointing at the active session,
and -WhatIf leaving the active session untouched. Per ADR 0021 this
logic makes no server call and must be pinned offline.
* fix: word the mismatched-session warning for the session's auth mode
API-token sessions do not expire and can be revoked with Remove-PveApiToken,
so the ticket wording was wrong for them.
---------
Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
Import-PveOva ignored disk.BusType and always assigned scsi{i}; the
controller-mapping helper itself had CIM ResourceType 6 (Parallel SCSI HBA)
and 20 (Other storage device, VMware's SATA AHCI controller) swapped. Both
are fixed together since honouring BusType is what makes the mapping bug
observable. The cmdlet now tracks a running index per bus (scsi/sata/ide),
falls back to scsi for an unrecognized or missing bus type, and refuses to
emit a slot number beyond what PVE's qemu-server schema accepts (ide0-3,
sata0-5, scsi0-30), overflowing to scsi and erroring out before upload if
even that is exhausted.
OvfMetadata also trusted the OVF descriptor's ovf:href attribute (an
attacker-controlled disk file name inside a downloaded OVA) unvalidated,
letting a crafted href inject extra keys into the comma-separated PVE
property string written for import-from. hrefs are now checked against
^[A-Za-z0-9._-]+$ and rejected outright if they are exactly "." or "..",
closing a path-segment escape the character class alone did not block.
The regex is anchored with \A/\z rather than ^/$ so a trailing line feed
cannot slip past under .NET's default multiline-$ semantics.
The descriptor is now parsed through XmlReader with DtdProcessing.Prohibit
and XmlResolver = null instead of XmlDocument.LoadXml directly, closing
the internal-entity-expansion memory-exhaustion route a hostile descriptor
could otherwise use.
ParseOvfXml is now internal (assembly already has InternalsVisibleTo the
test project) so the new tests can drive it directly with inline OVF XML
strings instead of building TAR archives.
Reviewer findings acted on (Codex, correctness-reviewer, security-reviewer,
run in parallel against the working tree): the dot/dot-dot href bypass and
the missing \A/\z anchoring were found independently by all three and
fixed; the bus-slot ceiling was found by two and fixed. Findings not acted
on, with reasons: validating the local -Path OVA filename the same way
(security-reviewer, medium) — that name is operator-supplied, not drawn
from the attacker-controlled archive contents the issue names, and touching
it would widen the change past the two issues' stated scope; percent-
decoding/loosening the href character class to admit spec-legal names with
spaces or encoding (security-reviewer, correctness-reviewer, medium) —
issue #148 specifies this exact character class, and loosening it reopens
the property-string injection the issue asks to close; capping the raw
byte size of the extracted .ovf entry against decompression-bomb exhaustion
(security-reviewer, low/medium) — a distinct DoS vector from the DTD
entity expansion issue #148 names, not part of its stated scope; branching
CIM ResourceType 20 on ResourceSubType to separate SATA from NVMe
(security-reviewer, low) — issue #138 specifies the 6/20 mapping exactly
as fixed here. An existing Integration-tagged Pester assertion
(tests/PSProxmoxVE.Tests/Integration/12_OVA.Tests.ps1:66) checks
$config.Scsi0 specifically; if that suite's fixture OVA ever carries a
type-20 controller the disk will now land on sata0 instead and the
assertion will need updating — left alone here since it is excluded from
every offline run this change was verified against and touching it without
being able to run it against live PVE would be guessing.
Closes#138Closes#148
Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
* fix: recognize boolean and varied integer formats for guest exec exited status
The guest-agent schema declares 'exited' as a boolean, but the PVE API
may return it in different formats: as a JSON boolean true, or as integers
1/0, or string '1'/'0'. The cmdlet was only checking for long 1L, causing
it to timeout on any PVE build that passed a boolean true unchanged.
Add ApiValueHelper.IsExited() to normalize these values and recognize
true, 1L, 1, and '1' as exited. Update the polling loop to use it.
* test: add integration tests for ApiValueHelper with real JSON payloads
Add tests that feed JSON data through the actual JsonHelper.ToNative
parsing pipeline to verify ApiValueHelper.IsExited correctly recognizes
boolean true and numeric 1 values as they arrive from the PVE API.
These tests pin the contract between the JSON parsing layer and the
value-recognition logic, ensuring the fix for issue #141 works end-to-end
with real API response shapes.
* fix: restore correct IsExited implementation with type checks
The helper must handle boolean true, long/int 1, and string "1" as the
issue specifies. This restores the correct multi-type check that was
inadvertently reverted.
* fix: correct boolean value handling in IsExited
---------
Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
* fix: verify checksums for downloaded ISOs/images, keep sshpass off argv
ensure-base-iso.sh downloaded the PVE install ISO over plain HTTP with no
checksum, caching it on the persistent /opt/pve-integration mount and
booting it as the nested trust root the integration suite relies on.
ensure-cloud-images.sh fetched the Ubuntu cloud image and OVA over HTTPS
but never checked them either. prepare-test-environment.sh and
diagnose-cluster.sh passed the nested root password to sshpass via -p,
putting it in the process table. create-api-token.sh, unused anywhere in
the repo, minted a privsep=0 root token and echoed the secret unmasked.
- ensure-base-iso.sh now downloads from https://enterprise.proxmox.com/iso
and verifies against its SHA256SUMS on every run, including a cache hit.
download.proxmox.com's own TLS cert does not list download.proxmox.com in
its SAN (confirmed with curl/openssl from this environment), so https to
that name fails certificate validation; enterprise.proxmox.com serves the
identical ISO tree over a valid cert. Verification happens before the
downloaded file is moved to its canonical cache path.
- ensure-cloud-images.sh verifies the cloud image and OVA against Ubuntu's
published SHA256SUMS the same way, matching by upstream filename since
the cloud image is cached locally under a different extension (.img
upstream, .qcow2 cached — the bytes are already qcow2-formatted).
- prepare-test-environment.sh and diagnose-cluster.sh now export SSHPASS
and call sshpass -e, keeping the password out of argv/ps. This also fixes
a latent bug: the old unquoted `sshpass -p ${ROOT_PASS}` word-split any
password containing whitespace.
- create-api-token.sh deleted; grep across the repo found no caller.
Reviewers (codex:codex-rescue, correctness-reviewer, security-reviewer) all
independently found the same blocking bug in the first pass: when a cached
file failed verification and the subsequent redownload then failed,
ensure-cloud-images.sh fell through to a "keep the stale copy" branch and
returned that same known-bad file with exit 0 — verification could be
bypassed by inducing one failed redownload. Fixed by deleting the file
immediately on a failed verification, before the redownload is attempted,
so the later "is there a safe stale copy" check can no longer find it.
Added a test case (case 5) that reproduces this exact sequence and
mutation-tested it against the unfixed code. The three reviews also
flagged a real but separate bug already fixed in this same change: `trap
... RETURN` inside a function nested in another function is not scoped to
that function in bash — it re-fires on the OUTER function's return,
referencing an out-of-scope local. Both verify_checksum() helpers now
clean up their temp file explicitly instead of via trap.
Findings not acted on, judged out of scope for this fix:
- SHA256SUMS-fetch failures are treated the same as a checksum mismatch
(delete + fail) rather than left untouched — a transient network blip
destroys a good multi-GB cached ISO. This is the safer failure direction
(never silently trust unverified bytes) and was a deliberate trade-off,
not a defect.
- ensure-cloud-images.sh's 7-day cache window can span an upstream
republish of noble/current, causing a legitimate re-verification churn
(not a security issue, a cache-hit-rate one). Pre-existing cache design,
unrelated to adding verification.
- wait-for-pve.sh (curl -d with the password on argv) and
prepare-test-environment.sh's own positional password argument (from
run-integration.sh) carry the same password-on-argv pattern this issue
targeted in create-api-token.sh, sshpass -p and diagnose-cluster.sh, but
neither script nor run-integration.sh was named in the issue. Left
untouched per scope; worth a follow-up issue.
- GPG/detached-signature verification of the upstream SHA256SUMS was not
added — the new checks defend against cache poisoning and transit
corruption, not a compromised origin. Worth a follow-up issue.
- The two new self-checks (ensure-base-iso.test.sh,
ensure-cloud-images.test.sh) are not wired into
.github/workflows/unit-tests.yml's shell-selfchecks job. That file is
code-owned and out of scope for this change; needs an operator follow-up.
Password rotation (the Testpass123! value from before it moved to a
secret) is unaddressed here per the contract — flagged for the operator.
Mutation-tested: broke the post-download checksum check in
ensure-base-iso.sh, confirmed the affected test cases failed, restored it.
Broke the sshpass -e change back to -p, confirmed the new assertions in
prepare-test-environment.test.sh failed, restored it. Broke the fail-open
fix in ensure-cloud-images.sh, confirmed case 5 failed, restored it.
Closes#149
* fix: also verify the stale-by-age fallback copy in ensure-cloud-images.sh
PR review on #166 (COMMENTED, non-blocking) found the sibling of the
fail-open bug already fixed in this branch: when the cached cloud image
is stale by *age* (>= 7 days) rather than failed verification, the
redownload-failure fallback could hand back that file with exit 0
without ever re-verifying it in this run. A file that failed the
earlier verification is already deleted by the time the fallback runs,
but a stale-by-age file skips verification entirely on the way in.
Fixed by verifying the stale-by-age file at the point of actual
fallback use — after the redownload has failed, not proactively before
it's attempted, so a copy the redownload was about to replace anyway
isn't deleted along a path that would have succeeded. Added two test
cases (6, 7): a still-verifying stale-by-age copy is used as a
fallback; one that no longer verifies is not. Mutation-tested by
reverting to the unfixed fallback and confirming case 7 fails, then
restored.
---------
Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
PveHttpClientLockRetryTests set GuestLockRetry's retry budget via reflection
on a private field with no production writer, and scripted two lock
failures to land inside a 400ms window. A cold or loaded CI runner's
first-attempt JIT and scheduling could burn past 400ms before the second
attempt started, failing the test though the retry itself was correct
(#134).
GuestLockRetry.ExecuteAsync gains an internal overload that takes the
inter-attempt delay as a Func<TimeSpan, Task>; the public overload keeps
defaulting to Task.Delay, so production behaviour (45s window, budget/4
capped at 2s) is unchanged. PveHttpClient gains a matching internal
constructor seam (window, handler, delay), replacing the reflection the
tests used for both the private HttpClient and the retry window. Tests
now pass a no-op delay, so the retry loop's real elapsed time drops to
microseconds and the production 45s window can never be exhausted by
runner speed.
Two tests pin that production still waits for real: one records the
delay invocations through the internal seam and asserts the computed
interval, the other drives the public overload with a small window and
asserts wall-clock time actually advances. Both were mutation-tested
against a no-op-default regression and fail without the fix.
The give-up test was renamed (PutAsync_DoesNotReissueWhenTheRetryWindowIsAlreadySpent)
to describe what TimeSpan.Zero actually proves: the client never attempts
a reissue once the budget reads spent, not a multi-attempt exhaustion
sequence — a review finding on the original name.
Out of scope, noted for follow-up: GuestLockRetryTests.cs's synchronous
Execute() tests still use a 400ms ShortWindow with real Thread.Sleep,
which is the same flake shape on the sync path; Execute() has no delay
seam. PveHttpClientTimeoutTests.cs and PveHttpClientFormEncodingTests.cs
still reflect on the private _httpClient field, which the new handler
seam could also retire.
Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
* fix: escape dynamic path segments in three Remove-* cmdlets
Wrap Storage, Vnet, and Zone parameters with Uri.EscapeDataString() in
RemovePveStorageCmdlet, RemovePveSdnVnetCmdlet, and RemovePveSdnZoneCmdlet
to prevent path traversal attacks via the API path.
Add xUnit test demonstrating that escaped paths preserve percent-encoding
(preventing path collapse) while unescaped paths allow segment traversal.
Fixes#145
* fix: route Remove-Pve{Storage,SdnVnet,SdnZone} through their services
Delete the private PveHttpClient construction and inline
Uri.EscapeDataString call in RemovePveStorageCmdlet,
RemovePveSdnVnetCmdlet and RemovePveSdnZoneCmdlet; call
StorageService.RemoveStorage / NetworkService.RemoveSdnZone /
NetworkService.RemoveSdnVnet instead, which already escape the
identifier identically and are now the single place doing so.
Add ValidatePattern on the Storage/Vnet/Zone parameters as defense
in depth, anchored with \A/\z so a trailing newline cannot slip a
disallowed character past the gate.
Replace PveHttpClientPathEscapingTests with a version that actually
regression-tests the real Uri parser (asserts both that %2F survives
and that the unescaped form is absent), and add
StorageServiceTests/NetworkServiceTests cases that mock
IPveHttpClient and verify the exact escaped DELETE path for a
traversal-attempt name.
---------
Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
D001-D021 become ADR 0001-0021 in docs/decisions/, one decision per file.
D017's PESTER_VERSION amendment was a second decision in one entry and becomes
ADR 0022. ADR 0023 records the migration and reverses the lane2-change-plan
ruling that deliberately kept DECISIONS.md until the CI lane work landed.
DECISIONS.md is reduced to a stub with a D-to-ADR redirect table, so the four
released CHANGELOG entries and older issue bodies that cite it degrade to a
redirect rather than a dead reference.
docs/review/ and docs/lane2-change-plan.md are deleted (ADR 0024). Of 91
findings, 83 were resolved and six of the seven still open were already GitHub
issues; F021 was the exception and is now #130.
CLAUDE.md's Key Conventions list gains the two rules it was missing and becomes
the checklist, with the ADRs carrying rationale.
PveNetwork already deserialised bridge_vlan_aware as BridgeVlanAware, so a
VLAN-aware bridge could be read back but never created or changed. Both write
paths now take a -BridgeVlanAware switch.
Clearing the flag does not use bridge_vlan_aware=0. PVE merges the supplied
keys onto the stored stanza and accepts that 0 without acting on it, so the
obvious form is a silent no-op: an integration run against PVE 9 issued it and
Get-PveNetwork still reported 1. The endpoint's delete list is what actually
removes the key. The API schema advertises a plain boolean and gives no hint of
this, which is why the behaviour is pinned by an integration test rather than
inferred.
Set-PveNetwork guards the switch on BoundParameters so an update that omits it
leaves the flag alone; the create path follows the existing -Autostart form.
Only bridge_vlan_aware is added. bridge_vids is an independent parameter that
PVE defaults to 2-4094, and the issue asks only for the flag.
Coverage: the integration suite pins create, disable, re-enable, and that an
unrelated Set leaves the flag alone -- that last one kills a mutant that drops
the BoundParameters guard, which every other test survives. A model test pins
the read path the assertions depend on. The Pester unit tests assert only
parameter metadata; the defect is server-side, so nothing offline can catch it.
Closes#92
Two non-blocking review observations.
A 45s retry is indistinguishable from a hang with nothing on the wire, so
GuestLockRetry.Execute takes an onRetry hook and InvokeGuestTask reports
each reissue through WriteVerbose.
The gap between attempts now scales with the budget, capped at the 2s
production value. A caller passing a short window wants a fast answer
rather than one long sleep, which also takes the retrying unit tests off
a real 2s sleep each: the xUnit run drops from 8s to 4s. PveHttpClient's
window becomes a field so those tests can shorten it too.
WaitForStatusTransition refused to return while snapshot.Locked, and its
comment quoted the exact error it was meant to prevent. Locked reads the
guest config's lock: property; the failure is the flock on
/var/lock/qemu-server/lock-<vmid>.conf, which PVE exposes nowhere.
The flock cannot be observed, so it is retried. GuestLockRetry reissues an
operation for a bounded 45s while PVE reports failing to enter lock_config
for a guest, which it raises before doing any work.
Two seams, because the failure has two surfaces. PveHttpClient.SendAsync
retries the request for operations PVE serialises in the API handler; it
takes a request factory because an HttpRequestMessage cannot be resent.
PveCmdletBase.InvokeGuestTask reissues the call and re-waits its task for
operations serialised in the forked worker, where the POST returns 200 and
only the task fails. WaitForStatusTransition routes through the latter,
hence Func<PveTask>.
The predicate is path-specific and anchored at the start of what PVE said:
lock_file uses identical wording for storage, LVM and HA locks, and a
qmclone that fails after allocating disks must not be reissued into
"VM already exists". That requires the raw text, so it reads
PveTaskFailedException.ExitStatus and PveApiException.ApiMessage.
The Locked check stays — it is correct for the config lock — with a comment
that says so.
Closes#113
Pester was installed with -MinimumVersion 5.0 and no ceiling in the CI job
image, both install steps in unit-tests.yml, and both Import-Module calls, plus
the suite's own import inside the container. The image is rebuilt on every CI
run and Pester is installed fresh on every unit-test run, so PSGallery chose the
version — a new major could reach the required PR checks with no commit here,
surfacing as unexplained test breakage on whichever PR ran next.
It had already happened. Steps named "Install Pester 5" were resolving 6.1.0 on
both legs, because Pester 6 declares PowerShellVersion 5.1 and so installs on
Windows PowerShell too. Nothing broke — the suite uses only constructs common to
5 and 6, and runs 1566/0 under 6.1.0 with no deprecation warnings — but nobody
chose it. The step names are corrected; they had been describing an install that
stopped happening some time ago.
Pinning the install alone is not enough, in two ways review found:
An unset variable does not fail. -RequiredVersion accepts an empty value and
degrades to "latest" for Install-Module and to "any" for Import-Module, both
exiting 0, so a renamed or dropped env key would silently restore the float this
commit removes. A guard step now fails the job instead.
The point of use was still floored. run-integration.sh imported the suite's
Pester with -MinimumVersion 5.0, so a second Pester reaching PSModulePath would
win regardless of what was installed. The Dockerfile now promotes the ARG to ENV
so the version is discoverable at runtime, and that import is pinned to it.
The pin lives in two files, so shell-selfchecks asserts they agree — split-brain
between the workflow and the image is precisely the unexplained breakage this is
meant to prevent. CONTRIBUTING.md and CLAUDE.md are updated too; the contributor
instructions were a third floating install site.
Recorded as an amendment to D017 — the same principle as the nested PVE package
pin, applied to the lane's own tooling.
Two filed issues in one rewrite of preflight-cleanup.sh's ISO block, because
they are the same twenty lines.
#111 — ISO_FILENAME was interpolated into python3 -c PROGRAM TEXT inside a
single-quoted literal, so a quote in the value escaped it and executed
arbitrary Python in a container holding PVE_API_TOKEN, PVE_PASSWORD, the
Terraform state and the storage VM's SSH key. It now arrives through the
environment and is read with os.environ. The volid is passed to urllib's
quote() via argv for the same reason, and an empty encode result now skips
the volume instead of issuing a DELETE against the bare collection URL.
#105 — generated ISOs embed a hash of first-boot.sh, so every change to that
script mints a new filename. Deleting only the exact current name orphaned
each earlier ISO on the storage permanently, because force-cleanup wipes the
Terraform state that could otherwise reclaim it. The family is now swept by
rebuilding the full generated shape: the captured prefix plus twelve hex
characters plus .iso. A prefix test alone would also have matched a longer
FQDN's family and any hand-uploaded "-manual-backup.iso" sibling, which in a
script whose job is deletion is worse than the leak it fixes.
Multi-delete applies only to that family. A name that is not generated — the
storage VM's cloud image — keeps the original one-shot behaviour, since a
basename can repeat across content namespaces and a plain name carries nothing
that identifies a family.
Adds preflight-cleanup.test.sh, wired into shell-selfchecks. The script had no
coverage at all. It stubs curl and sleep, then asserts on the DELETEs issued:
the family goes, the pinned base ISO and unrelated uploads stay, a non-hash
sibling stays, the cloud image takes only itself, a quoted payload is data
rather than code, and unset storage skips only the ISO branch. Every case also
asserts the script ran to completion and removed the Terraform state, so a path
that dies early cannot pass by having issued the right DELETEs first.
The local dev environment had drifted badly from CI. Remove the parts that no
longer describe anything real, and make the rest match how CI actually runs.
Delete tests/dev.ps1. It wrapped run-integration.sh, which CI calls directly,
and duplicated the module build that script already performs internally. As a
second entry point it drifted: it still offered the PVE 8 leg retired in #88,
mounted the Docker socket for storage containers replaced by the storage VM in
#87, and pointed its remote-host examples at a runner decommissioned in the ARC
migration. All four documents describing it used a positional syntax that bound
the bare word to -Tests and then fell through to -Shell, so every documented
command silently opened a container shell. Recorded as D019.
Delete tests/infrastructure/runner/, a self-hosted-runner-in-Docker superseded
by Actions Runner Controller.
Make disk_storage and iso_storage required. Their defaults named a NAS that the
lab replaced with Ceph, and CI overrides both from repository variables, so the
defaults only ever misled local runs. require_env now fails at the top of a run
rather than at terraform apply, and the descriptions point at tests/.env.test
because cmd_provision deletes terraform.tfvars before applying.
preflight-cleanup.sh no longer falls back to the literal "local" storage. An
unset TF_VAR_iso_storage now skips only the ISO branch, leaving VM destroy and
state cleanup intact, and emits a workflow annotation: force-cleanup is the
only cleanup CI runs and it wipes Terraform state, so a silent skip strands the
uploaded ISO with nothing left to reclaim it.
Drop docker-ce-cli and the /var/run/docker.sock mount. Nothing in the container
has called docker since #87 moved storage into a VM; the remaining docker calls
run inside that VM over SSH. The CI job image is built from the same target, so
this also removes a third-party apt repository from its supply chain.
Rewrite tests/.env.test.example against what the code now requires, and fix the
documented commands in CLAUDE.md, README.md, copilot-instructions.md and the
integration README.
PVE publishes a guest's new status while the operation still holds
/var/lock/qemu-server/lock-<vmid>.conf, so WaitForStatusTransition could return
while the guest was still locked and the caller's next request would fail with
"can't lock file ... got timeout".
Integration run 183 failed four tests from this one cause: Restart-PveVm -Wait
returned after 4.1s having seen "running", the following Stop-PveVm spent exactly
10.0s failing to take the lock, and that cascaded into the template convert,
clone, and remove tests. Run 184 - same commit, re-run - passed because its status
poll happened to take 10.1s, by which point the lock had cleared. The same
settling happens either way; the only variable is whether the wait absorbs it or
the next caller does.
The check goes in WaitForStatusTransition because all nine lifecycle call sites
(Start/Stop/Restart/Reset/Resume across VMs and containers) route through it.
`lock` comes from the status/current response the poll already fetches - present
on both qemu and lxc since PVE 5.4, below the module's 7.0 floor - so it costs no
extra request.
If the status is reached but the lock outlasts -Timeout the cmdlet still returns
success, so a call that succeeded before this change cannot become an exception
after it.
Recorded as D015, the guest-lock sibling of D014.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`apt-get upgrade` holds back any package whose upgrade needs new dependencies,
so it neither pins nor updates — it produces whatever partial set the
dependency graph allows that day. On these nodes it upgraded pve-cluster to
9.1.6 while leaving libpve-cluster-api-perl at 9.1.0.
Those two ship the halves of the join: cfs_backup_database() in
PVE/Cluster.pm (pve-cluster) and finish_join() in PVE/Cluster/Setup.pm
(libpve-cluster-api-perl). Upstream removed `return $dbfile` from the former
and stopped relying on it in the latter, both at 9.1.1 — 9.1.6's finish_join
calls cfs_unlink_db_unsafe() instead. The 9.1.0 caller against the 9.1.6
callee unlinks an empty string, so the standalone config.db survives the join,
pmxcfs restarts in local mode, and the node reports online=0 forever while
corosync forms a healthy 2-node membership. That is the "2 nodes online"
failure, and it is not reachable on any coherent install.
The ISO is the pin, so drop the upgrade and install only what the harness
needs. Upgrades belong in a separate currency lane that records the package
set it tested.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Run 173 left node B with healthy corosync (2-member primary component, both
links connected) but no /etc/pve/corosync.conf, no dcdb/status journal lines,
and pvecm status reporting it is not part of a cluster. That file is
database-backed: pmxcfs creates it only when it starts with no config.db and
imports /etc/corosync/corosync.conf, so a surviving standalone config.db would
mean silent local mode.
Capture the package versions, pmxcfs command line, /etc/pve mount, .members,
the config.db and its backup dir, whether the database holds a corosync.conf
row, and the CPG group membership. Read-only; the sqlite3 CLI is not guaranteed
on a PVE node, so fall back to strings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
New-PveCluster -Wait now guarantees quorum, so the Start-Sleep -Seconds 5
that lost the race is replaced by an assertion on the new contract.
The link0 pin is dropped: the join-abort and the never-a-member modes both
occurred with and without it, so it was never implicated, and -Links is
already covered at the service level.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Returns once quorate, throws TimeoutException when quorum never arrives,
and keeps polling through a PveApiException from the restarting API.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Cluster join aborted!" is PVE's generic wrapper; the reason lives only in
the task log on the joining node. Run 172's log said "An error occurred on
the cluster node: cluster not ready - no quorum?", which is what identified
the race. Capture it so the evidence survives cleanup.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The API reports a joined-but-offline node as online=0 with no further
detail, and the cleanup job destroys the nodes minutes later, so the
reason corosync membership never forms has never reached a log. Read
corosync.conf, corosync-cfgtool, pvecm status and the corosync journal
off both nodes while they are still alive. Best-effort: never fails the
caller.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The "2 nodes online" check has never passed — run 159 shows the identical
failure while the job reported success (swallowed Pester exit code), and
run 170's 60 s poll expires with node B still online=0. Node B reaches
corosync.conf (Get-PveClusterConfigNode passes) but never becomes a
corosync member, and the suite captures nothing about why.
Pass link0 explicitly so ring0 is the address the harness verified node B
answers on, rather than whatever node B resolves its own DHCP-assigned
.test.local hostname to. Dump quorate and each node's ring0/online/local
unconditionally so the next run distinguishes a wrong ring0 address from
a working address with no corosync transport.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The join task completing does not mean corosync membership has reached
the status endpoint; the instant assertion failed intermittently (runs
159 and 169) while every other cluster check passed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The repo stops holding opinions about CI VLAN addressing: the VM boots
via DHCP with hostname pvetest-storage (deterministic MAC for an
optional reservation), registers in the operator's CI DNS zone, and
everything addresses it by STORAGE_VM_FQDN. Replaces the static-IP +
explicit-DNS variables.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>