* 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>
* 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: 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>
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>
- VmService.ExecuteGuestCommand: guard against null elements in -Args.
The old JSON-serialization tolerated nulls (as "null"); the repeated-key
path would NRE in EncodeFormValue. Throw a clear ArgumentException instead.
- findings.json: refresh the stale counters block (untouched since F085) to
the actual ledger state — next_id 92, resolved 83 — and bump last_updated
to 2026-05-22. last_scan_date stays 2026-03-26 (F086–F091 came from issue
triage, not a formal review scan).
- VmServiceTests: add empty-array (single command entry) and null-element
(throws) cases.
Note: F091 is the correct next ID — F086–F090 already exist from prior
merged PRs (#60/#61/#66/#67); only the counters were lagging.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ExecuteGuestCommand JSON-serialized the args array into the agent/exec
'input-data' field — which is the process's STDIN, not its arguments. So
guest commands ran with no argv: cmd.exe started interactively and the
JSON blob ['/c','echo',...] arrived at its prompt.
PVE's agent/exec 'command' parameter is itself an array (element 0 = the
executable, the rest = argv) sent as repeated form keys. The low-level
client couldn't express repeated keys (Dictionary<string,string> only),
so:
- Add PostAsync(string, IEnumerable<KeyValuePair<string,string>>) to
IPveHttpClient/PveHttpClient; BuildFormContent now emits one key=value
field per pair, so a key may repeat.
- ExecuteGuestCommand builds command = [exe] + args as repeated 'command'
fields and no longer touches input-data.
Tests: form-encoder repeated-key + per-value encoding cases; VmService
tests asserting the command array, order, and absence of input-data; an
integration regression guard that echoes an arg and checks it round-trips
as stdout.
Tracked as F091. Closes#68.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Test was still expecting GetAsync on cloudinit/dump but the service
now calls PutAsync on cloudinit (regenerate endpoint). Updated mock
setup and assertions to match.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>