diff --git a/CHANGELOG.md b/CHANGELOG.md index c07cb8d..9c1c0dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Conventional Commits](https://www.conventionalcommi ## [Unreleased] +### Added + +- Firewall management cmdlets (21): rules, security groups, aliases, IP sets, options at cluster/node/VM/container levels +- Backup/vzdump cmdlets (5): ad-hoc backup creation and scheduled backup job CRUD +- SDN IPAM cmdlets (3): Get/New/Remove-PveSdnIpam for IPAM plugin management +- SDN DNS cmdlets (3): Get/New/Remove-PveSdnDns for DNS plugin management +- SDN Controller cmdlets (3): Get/New/Remove-PveSdnController for controller management +- PSGallery version badge in README +- Integration tests for firewall rules, aliases, IP sets, backup jobs, and OVA import + +### Fixed + +- `Remove-PveRole` now has `ConfirmImpact.High` for safety +- URL-encode snapshot names in API paths for defense-in-depth +- Extract auth header magic strings to named constants in PveHttpClient + ## [0.1.0-preview] - 2026-03-19 ### Added diff --git a/README.md b/README.md index 06618ed..b573b1e 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![Build](https://github.com/goodolclint/PSProxmoxVE/actions/workflows/build.yml/badge.svg)](https://github.com/goodolclint/PSProxmoxVE/actions/workflows/build.yml) [![Unit Tests](https://github.com/goodolclint/PSProxmoxVE/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/goodolclint/PSProxmoxVE/actions/workflows/unit-tests.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/goodolclint/PSProxmoxVE/blob/main/LICENSE) +[![PowerShell Gallery](https://img.shields.io/powershellgallery/v/PSProxmoxVE)](https://www.powershellgallery.com/packages/PSProxmoxVE) A production-grade C# binary PowerShell module for managing Proxmox VE environments. @@ -325,6 +326,53 @@ SDN management requires Proxmox VE 8.0 or later. Connected server is version 7.4 | `Get-PveTask` | Get task status | | `Wait-PveTask` | Wait for a task to complete | +### Firewall +| Cmdlet | Description | +|---|---| +| `Get-PveFirewallRule` | List firewall rules (cluster/node/VM/container) | +| `New-PveFirewallRule` | Create a firewall rule | +| `Set-PveFirewallRule` | Update a firewall rule | +| `Remove-PveFirewallRule` | Delete a firewall rule | +| `Get-PveFirewallGroup` | List security groups or group rules | +| `New-PveFirewallGroup` | Create a security group | +| `Remove-PveFirewallGroup` | Delete a security group | +| `Get-PveFirewallAlias` | List firewall IP aliases | +| `New-PveFirewallAlias` | Create a firewall IP alias | +| `Set-PveFirewallAlias` | Update a firewall IP alias | +| `Remove-PveFirewallAlias` | Delete a firewall IP alias | +| `Get-PveFirewallIpSet` | List firewall IP sets | +| `New-PveFirewallIpSet` | Create a firewall IP set | +| `Remove-PveFirewallIpSet` | Delete a firewall IP set | +| `Get-PveFirewallIpSetEntry` | List entries in an IP set | +| `New-PveFirewallIpSetEntry` | Add an entry to an IP set | +| `Set-PveFirewallIpSetEntry` | Update an IP set entry | +| `Remove-PveFirewallIpSetEntry` | Remove an entry from an IP set | +| `Get-PveFirewallOptions` | Get firewall options | +| `Set-PveFirewallOptions` | Set firewall options | +| `Get-PveFirewallRef` | List firewall references (aliases, IP sets) | + +### Backup +| Cmdlet | Description | +|---|---| +| `New-PveBackup` | Create an ad-hoc backup (vzdump) | +| `Get-PveBackupJob` | List scheduled backup jobs | +| `New-PveBackupJob` | Create a scheduled backup job | +| `Set-PveBackupJob` | Update a scheduled backup job | +| `Remove-PveBackupJob` | Delete a scheduled backup job | + +### SDN — IPAM / DNS / Controllers (PVE 8.0+) +| Cmdlet | Description | +|---|---| +| `Get-PveSdnIpam` | List SDN IPAM plugins | +| `New-PveSdnIpam` | Create an SDN IPAM plugin | +| `Remove-PveSdnIpam` | Remove an SDN IPAM plugin | +| `Get-PveSdnDns` | List SDN DNS plugins | +| `New-PveSdnDns` | Create an SDN DNS plugin | +| `Remove-PveSdnDns` | Remove an SDN DNS plugin | +| `Get-PveSdnController` | List SDN controllers | +| `New-PveSdnController` | Create an SDN controller | +| `Remove-PveSdnController` | Remove an SDN controller | + ## Known Limitations (v1) - **No automatic retries**: Failed API calls are not retried. Implement your own retry logic if needed. diff --git a/REVIEW_REPORT.md b/REVIEW_REPORT.md index 7c6c5a0..f714461 100644 --- a/REVIEW_REPORT.md +++ b/REVIEW_REPORT.md @@ -1,6 +1,10 @@ # PSProxmoxVE Module Review Report -**Date:** 2026-03-19 +``` +Scan date: 2026-03-21 +Prior report date: 2026-03-19 +``` + **Reviewer:** Claude Code (Opus 4.6) **Module Version:** 0.1.0-preview **Repository:** PSProxmoxVE (C# binary PowerShell module) @@ -9,83 +13,101 @@ ## Executive Summary -1. **Well-structured project** with clean separation: `PSProxmoxVE.Core` (services, models, client) and `PSProxmoxVE` (cmdlets, module surface). -2. **66 cmdlets** covering VMs, containers, storage, networking, SDN, users/roles/permissions, templates, cloud-init, snapshots, and tasks. -3. **Excellent ShouldProcess coverage** -- all destructive cmdlets implement `SupportsShouldProcess` with appropriate `ConfirmImpact` levels. -4. **OutputType attributes** present on every cmdlet. ValueFromPipelineByPropertyName used consistently for pipeline chaining. -5. **Sync-over-async pattern** (`GetAwaiter().GetResult()`) used pervasively -- acceptable for PowerShell binary modules but a known tradeoff. -6. **Comprehensive integration tests** running against real PVE 8 and PVE 9 via Terraform-provisioned nested instances. -7. **Missing community files**: `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`, `.gitattributes`, issue/PR templates. -8. **No publish workflow**: No CI pipeline for publishing to PSGallery. -9. **One unapproved verb**: `Reset-PveVm` uses a hardcoded string `"Reset"` instead of `VerbsCommon` -- `Reset` is not an approved PowerShell verb. -10. **PVE API coverage**: Module covers core automation scenarios well but lacks firewall, backup/restore (PBS), Ceph, HA, and replication management. +**Delta since last scan: 22 fixed | 2 new findings | 0 regressed | 4 still open** + +1. **Significant progress since prior scan** — 22 of 28 findings from the initial report have been resolved, including all Critical items. +2. **83 cmdlets** (up from 66) covering VMs, containers (with snapshots and migration), storage, networking, SDN (zones, VNets, subnets), users/roles/permissions, templates, cloud-init, OVA/disk import, and tasks. +3. **All prior Critical items resolved**: publish workflow added, README updated with `Install-Module`, ReleaseNotes in manifest. +4. **Community files complete**: CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md, .gitattributes, issue/PR templates all added. +5. **Code quality improvements**: HelpMessage on all parameters, WriteVerbose throughout, ValidateRange on VmId, ConfirmImpact.High on Stop/Reset-PveVm, Reset verb constant fixed. +6. **MAML help file** present (PSProxmoxVE.dll-Help.xml) with 75 cmdlet documentation files in docs/cmdlets/. +7. **Remaining gaps**: No IconUri in manifest, Remove-PveRole missing ConfirmImpact.High, firewall/backup/pool cmdlets not yet implemented. +8. **Excellent test coverage**: 100% Pester unit tests, 84% integration test coverage, xUnit model tests with PVE 8 & 9 fixtures. +9. **Security posture is clean**: No hardcoded secrets, HTTPS enforced, SkipCertificateCheck emits warning, all dependencies pinned. +10. **Module is near PSGallery-ready** — no blocking issues remain. --- -## Phase 1 -- Repository Inventory & Structure +## Phase 1 — Repository Inventory & Structure -### Directory Tree (depth <= 4) +### Directory Tree (depth ≤ 4) ``` PSProxmoxVE/ -+-- PSProxmoxVE.sln -+-- README.md -+-- LICENSE (MIT) -+-- CHANGELOG.md -+-- .editorconfig -+-- .gitignore -+-- CLAUDE.md -+-- .github/ -| +-- workflows/ -| +-- build.yml -| +-- unit-tests.yml -| +-- integration-tests.yml -+-- src/ -| +-- PSProxmoxVE/ -| | +-- PSProxmoxVE.csproj -| | +-- PSProxmoxVE.psd1 -| | +-- PSProxmoxVE.format.ps1xml -| | +-- ModuleState.cs -| | +-- Cmdlets/ -| | +-- PveCmdletBase.cs -| | +-- CloudInit/ (3 cmdlets) -| | +-- Connection/ (3 cmdlets) -| | +-- Containers/ (8 cmdlets) -| | +-- Network/ (10 cmdlets) -| | +-- Nodes/ (2 cmdlets) -| | +-- Snapshots/ (4 cmdlets) -| | +-- Storage/ (5 cmdlets) -| | +-- Tasks/ (2 cmdlets) -| | +-- Templates/ (4 cmdlets) -| | +-- Users/ (11 cmdlets) -| | +-- Vms/ (17 cmdlets) -| +-- PSProxmoxVE.Core/ -| +-- PSProxmoxVE.Core.csproj -| +-- Authentication/ (PveAuthenticator, PveAuthMode, PveSession, PveVersion) -| +-- Client/ (PveHttpClient) -| +-- Exceptions/ (7 exception types) -| +-- Models/ (Cluster, Containers, Network, Nodes, Storage, Users, Vms) -| +-- Services/ (11 service classes) -+-- tests/ -| +-- PSProxmoxVE.Core.Tests/ (xUnit) -| | +-- Authentication/ (3 test files) -| | +-- Models/ (10 test files) -| | +-- TestHelper.cs -| +-- PSProxmoxVE.Tests/ (Pester 5) -| +-- _TestHelper.ps1 -| +-- CloudInit/ (1 test file) -| +-- Connection/ (3 test files) -| +-- Containers/ (3 test files) -| +-- Network/ (2 test files) -| +-- Nodes/ (1 test file) -| +-- Snapshots/ (1 test file) -| +-- Storage/ (3 test files) -| +-- Tasks/ (1 test file) -| +-- Templates/ (1 test file) -| +-- Users/ (3 test files) -| +-- Vms/ (6 test files) -| +-- Integration/ (1 comprehensive test file) -+-- tests/infrastructure/ (Terraform, Dockerfile, scripts for CI) +├── PSProxmoxVE.sln +├── README.md +├── LICENSE (MIT) +├── CHANGELOG.md +├── CONTRIBUTING.md +├── CODE_OF_CONDUCT.md +├── SECURITY.md +├── .editorconfig +├── .gitignore +├── .gitattributes +├── CLAUDE.md +├── REVIEW_REPORT.md +├── .github/ +│ ├── ISSUE_TEMPLATE/ +│ │ ├── bug_report.yml +│ │ └── feature_request.yml +│ ├── pull_request_template.md +│ └── workflows/ +│ ├── build.yml +│ ├── unit-tests.yml +│ ├── integration-tests.yml +│ └── publish.yml +├── docs/ +│ ├── PVE_API_COVERAGE.md +│ └── cmdlets/ (75 cmdlet documentation files) +├── src/ +│ ├── PSProxmoxVE/ +│ │ ├── PSProxmoxVE.csproj +│ │ ├── PSProxmoxVE.psd1 +│ │ ├── PSProxmoxVE.format.ps1xml +│ │ ├── PSProxmoxVE.dll-Help.xml +│ │ ├── ModuleState.cs +│ │ └── Cmdlets/ +│ │ ├── PveCmdletBase.cs +│ │ ├── CloudInit/ (3 cmdlets) +│ │ ├── Connection/ (3 cmdlets) +│ │ ├── Containers/ (14 cmdlets) +│ │ ├── Network/ (14 cmdlets) +│ │ ├── Nodes/ (2 cmdlets) +│ │ ├── Snapshots/ (4 cmdlets) +│ │ ├── Storage/ (6 cmdlets) +│ │ ├── Tasks/ (2 cmdlets) +│ │ ├── Templates/ (4 cmdlets) +│ │ ├── Users/ (12 cmdlets) +│ │ └── Vms/ (19 cmdlets) +│ └── PSProxmoxVE.Core/ +│ ├── PSProxmoxVE.Core.csproj +│ ├── Authentication/ (PveAuthenticator, PveAuthMode, PveSession, PveVersion) +│ ├── Client/ (PveHttpClient) +│ ├── Exceptions/ (7 exception types) +│ ├── Models/ (25 model classes) +│ └── Services/ (11 service classes) +├── tests/ +│ ├── PSProxmoxVE.Core.Tests/ (xUnit) +│ │ ├── Authentication/ (3 test files) +│ │ ├── Fixtures/ (23 JSON fixtures) +│ │ ├── Models/ (10 test files) +│ │ └── TestHelper.cs +│ ├── PSProxmoxVE.Tests/ (Pester 5) +│ │ ├── _TestHelper.ps1 +│ │ ├── CloudInit/ +│ │ ├── Connection/ (3 test files) +│ │ ├── Containers/ (4 test files) +│ │ ├── Network/ (3 test files) +│ │ ├── Nodes/ (1 test file) +│ │ ├── Snapshots/ (1 test file) +│ │ ├── Storage/ (3 test files) +│ │ ├── Tasks/ (1 test file) +│ │ ├── Templates/ (1 test file) +│ │ ├── Users/ (3 test files) +│ │ ├── Vms/ (9 test files) +│ │ └── Integration/ (1 comprehensive test file) +│ └── infrastructure/ (Terraform, Dockerfile, scripts for CI) +└── tools/ ``` ### Project Layout @@ -109,631 +131,649 @@ PSProxmoxVE/ | `build.yml` | Build + xUnit tests on net48 (Windows) and net9.0 (Windows + Ubuntu) | | `unit-tests.yml` | Build + Pester tests across PS 5.1/7.5 on Windows/Ubuntu/macOS | | `integration-tests.yml` | Full integration tests against nested PVE 8 + PVE 9 instances | +| `publish.yml` | Tag-triggered publish to PSGallery + GitHub Release creation | -### What's Present +### Inventory Checklist -- [x] Solution file (`.sln`) -- [x] Module manifest (`.psd1`) with explicit `CmdletsToExport` -- [x] Format definitions (`.format.ps1xml`) -- [x] `.editorconfig` -- [x] `.gitignore` -- [x] `LICENSE` (MIT) -- [x] `CHANGELOG.md` (Keep a Changelog format) -- [x] `README.md` (comprehensive) -- [x] CI/CD workflows (build, unit tests, integration tests) +| Item | Present | Prior Status | +|---|---|---| +| Solution file (`.sln`) | Yes | Open | +| Module manifest (`.psd1`) | Yes | Open | +| Format definitions (`.format.ps1xml`) | Yes | Open | +| MAML help (`.dll-Help.xml`) | Yes | New | +| `.editorconfig` | Yes | Open | +| `.gitignore` | Yes | Open | +| `.gitattributes` | Yes | Fixed | +| `LICENSE` (MIT) | Yes | Open | +| `CHANGELOG.md` | Yes | Open | +| `README.md` | Yes | Open | +| `CONTRIBUTING.md` | Yes | Fixed | +| `CODE_OF_CONDUCT.md` | Yes | Fixed | +| `SECURITY.md` | Yes | Fixed | +| `.github/ISSUE_TEMPLATE/` | Yes | Fixed | +| `.github/pull_request_template.md` | Yes | Fixed | +| PSGallery publish workflow | Yes | Fixed | +| `docs/PVE_API_COVERAGE.md` | Yes | New | +| `docs/cmdlets/` (75 files) | Yes | New | ### What's Missing -- [ ] `CONTRIBUTING.md` -- [ ] `CODE_OF_CONDUCT.md` -- [ ] `SECURITY.md` -- [ ] `.gitattributes` -- [ ] `.github/ISSUE_TEMPLATE/` -- [ ] `.github/pull_request_template.md` -- [ ] PSGallery publish workflow -- [ ] `ReleaseNotes` in manifest `PSData` -- [ ] `IconUri` in manifest `PSData` +- [ ] `IconUri` in manifest PSData (cosmetic) --- -## Phase 2 -- PVE API Coverage Audit +## Phase 2 — PVE API Coverage Audit -> **Note:** The PVE API documentation at `pve.proxmox.com/pve-docs/api-viewer/apidoc.js` was too large for automated extraction. Coverage assessment below is based on the author's knowledge of the PVE API and source code analysis. +> **Note:** Coverage assessment based on source code analysis and the project's own `docs/PVE_API_COVERAGE.md`. -### Implemented Cmdlets by Category +### Implemented Cmdlets by Category (83 total) #### Connection (3 cmdlets) -| Cmdlet | API Endpoint | Method | -|---|---|---| -| `Connect-PveServer` | `/access/ticket` | POST | -| `Disconnect-PveServer` | `/access/ticket` | DELETE | -| `Test-PveConnection` | `/version` | GET | +| Cmdlet | API Endpoint | Method | Prior Status | +|---|---|---|---| +| `Connect-PveServer` | `/access/ticket` | POST | Open | +| `Disconnect-PveServer` | `/access/ticket` | DELETE | Open | +| `Test-PveConnection` | `/version` | GET | Open | #### Nodes (2 cmdlets) -| Cmdlet | API Endpoint | Method | -|---|---|---| -| `Get-PveNode` | `/nodes` | GET | -| `Get-PveNodeStatus` | `/nodes/{node}/status` | GET | +| Cmdlet | API Endpoint | Method | Prior Status | +|---|---|---|---| +| `Get-PveNode` | `/nodes` | GET | Open | +| `Get-PveNodeStatus` | `/nodes/{node}/status` | GET | Open | -#### VMs / QEMU (17 cmdlets) -| Cmdlet | API Endpoint | Method | -|---|---|---| -| `Get-PveVm` | `/nodes/{node}/qemu` | GET | -| `New-PveVm` | `/nodes/{node}/qemu` | POST | -| `Remove-PveVm` | `/nodes/{node}/qemu/{vmid}` | DELETE | -| `Start-PveVm` | `/nodes/{node}/qemu/{vmid}/status/start` | POST | -| `Stop-PveVm` | `/nodes/{node}/qemu/{vmid}/status/stop` | POST | -| `Restart-PveVm` | `/nodes/{node}/qemu/{vmid}/status/reboot` | POST | -| `Suspend-PveVm` | `/nodes/{node}/qemu/{vmid}/status/suspend` | POST | -| `Resume-PveVm` | `/nodes/{node}/qemu/{vmid}/status/resume` | POST | -| `Reset-PveVm` | `/nodes/{node}/qemu/{vmid}/status/reset` | POST | -| `Copy-PveVm` | `/nodes/{node}/qemu/{vmid}/clone` | POST | -| `Move-PveVm` | `/nodes/{node}/qemu/{vmid}/migrate` | POST | -| `Get-PveVmConfig` | `/nodes/{node}/qemu/{vmid}/config` | GET | -| `Set-PveVmConfig` | `/nodes/{node}/qemu/{vmid}/config` | PUT | -| `Resize-PveVmDisk` | `/nodes/{node}/qemu/{vmid}/resize` | PUT | -| `Test-PveVmGuestAgent` | `/nodes/{node}/qemu/{vmid}/agent/ping` | POST | -| `Get-PveVmGuestNetwork` | `/nodes/{node}/qemu/{vmid}/agent/network-get-interfaces` | GET | -| `Invoke-PveVmGuestExec` | `/nodes/{node}/qemu/{vmid}/agent/exec` | POST | +#### VMs / QEMU (19 cmdlets) +| Cmdlet | API Endpoint | Method | Prior Status | +|---|---|---|---| +| `Get-PveVm` | `/nodes/{node}/qemu` | GET | Open | +| `New-PveVm` | `/nodes/{node}/qemu` | POST | Open | +| `Remove-PveVm` | `/nodes/{node}/qemu/{vmid}` | DELETE | Open | +| `Start-PveVm` | `/nodes/{node}/qemu/{vmid}/status/start` | POST | Open | +| `Stop-PveVm` | `/nodes/{node}/qemu/{vmid}/status/stop` | POST | Open | +| `Restart-PveVm` | `/nodes/{node}/qemu/{vmid}/status/reboot` | POST | Open | +| `Suspend-PveVm` | `/nodes/{node}/qemu/{vmid}/status/suspend` | POST | Open | +| `Resume-PveVm` | `/nodes/{node}/qemu/{vmid}/status/resume` | POST | Open | +| `Reset-PveVm` | `/nodes/{node}/qemu/{vmid}/status/reset` | POST | Open | +| `Copy-PveVm` | `/nodes/{node}/qemu/{vmid}/clone` | POST | Open | +| `Move-PveVm` | `/nodes/{node}/qemu/{vmid}/migrate` | POST | Open | +| `Get-PveVmConfig` | `/nodes/{node}/qemu/{vmid}/config` | GET | Open | +| `Set-PveVmConfig` | `/nodes/{node}/qemu/{vmid}/config` | PUT | Open | +| `Resize-PveVmDisk` | `/nodes/{node}/qemu/{vmid}/resize` | PUT | Open | +| `Import-PveVmDisk` | `/nodes/{node}/storage/{storage}/upload` | POST | New | +| `Import-PveOva` | Multiple (upload + config + import) | POST | New | +| `Test-PveVmGuestAgent` | `/nodes/{node}/qemu/{vmid}/agent/ping` | POST | Open | +| `Get-PveVmGuestNetwork` | `/nodes/{node}/qemu/{vmid}/agent/network-get-interfaces` | GET | Open | +| `Invoke-PveVmGuestExec` | `/nodes/{node}/qemu/{vmid}/agent/exec` | POST | Open | -#### Containers / LXC (8 cmdlets) -| Cmdlet | API Endpoint | Method | -|---|---|---| -| `Get-PveContainer` | `/nodes/{node}/lxc` | GET | -| `New-PveContainer` | `/nodes/{node}/lxc` | POST | -| `Remove-PveContainer` | `/nodes/{node}/lxc/{vmid}` | DELETE | -| `Start-PveContainer` | `/nodes/{node}/lxc/{vmid}/status/start` | POST | -| `Stop-PveContainer` | `/nodes/{node}/lxc/{vmid}/status/stop` | POST | -| `Restart-PveContainer` | `/nodes/{node}/lxc/{vmid}/status/shutdown` | POST | -| `Copy-PveContainer` | `/nodes/{node}/lxc/{vmid}/clone` | POST | -| `Get-PveContainerConfig` | `/nodes/{node}/lxc/{vmid}/config` | GET | -| `Set-PveContainerConfig` | `/nodes/{node}/lxc/{vmid}/config` | PUT | +#### Containers / LXC (10 cmdlets) +| Cmdlet | API Endpoint | Method | Prior Status | +|---|---|---|---| +| `Get-PveContainer` | `/nodes/{node}/lxc` | GET | Open | +| `New-PveContainer` | `/nodes/{node}/lxc` | POST | Open | +| `Remove-PveContainer` | `/nodes/{node}/lxc/{vmid}` | DELETE | Open | +| `Start-PveContainer` | `/nodes/{node}/lxc/{vmid}/status/start` | POST | Open | +| `Stop-PveContainer` | `/nodes/{node}/lxc/{vmid}/status/stop` | POST | Open | +| `Restart-PveContainer` | `/nodes/{node}/lxc/{vmid}/status/shutdown` | POST | Open | +| `Copy-PveContainer` | `/nodes/{node}/lxc/{vmid}/clone` | POST | Open | +| `Move-PveContainer` | `/nodes/{node}/lxc/{vmid}/migrate` | POST | New | +| `Get-PveContainerConfig` | `/nodes/{node}/lxc/{vmid}/config` | GET | Open | +| `Set-PveContainerConfig` | `/nodes/{node}/lxc/{vmid}/config` | PUT | Open | -#### Storage (5 cmdlets) -| Cmdlet | API Endpoint | Method | -|---|---|---| -| `Get-PveStorage` | `/storage` or `/nodes/{node}/storage` | GET | -| `Get-PveStorageContent` | `/nodes/{node}/storage/{storage}/content` | GET | -| `Send-PveIso` | `/nodes/{node}/storage/{storage}/upload` | POST | -| `Invoke-PveStorageDownload` | `/nodes/{node}/storage/{storage}/download-url` | POST | -| `New-PveStorage` | `/storage` | POST | -| `Remove-PveStorage` | `/storage/{storage}` | DELETE | +#### Container Snapshots (4 cmdlets — NEW) +| Cmdlet | API Endpoint | Method | Prior Status | +|---|---|---|---| +| `Get-PveContainerSnapshot` | `/nodes/{node}/lxc/{vmid}/snapshot` | GET | New | +| `New-PveContainerSnapshot` | `/nodes/{node}/lxc/{vmid}/snapshot` | POST | New | +| `Remove-PveContainerSnapshot` | `/nodes/{node}/lxc/{vmid}/snapshot/{name}` | DELETE | New | +| `Restore-PveContainerSnapshot` | `/nodes/{node}/lxc/{vmid}/snapshot/{name}/rollback` | POST | New | -#### Snapshots (4 cmdlets) -| Cmdlet | API Endpoint | Method | -|---|---|---| -| `Get-PveSnapshot` | `/nodes/{node}/qemu/{vmid}/snapshot` | GET | -| `New-PveSnapshot` | `/nodes/{node}/qemu/{vmid}/snapshot` | POST | -| `Remove-PveSnapshot` | `/nodes/{node}/qemu/{vmid}/snapshot/{name}` | DELETE | -| `Restore-PveSnapshot` | `/nodes/{node}/qemu/{vmid}/snapshot/{name}/rollback` | POST | +#### Storage (6 cmdlets) +| Cmdlet | API Endpoint | Method | Prior Status | +|---|---|---|---| +| `Get-PveStorage` | `/storage` or `/nodes/{node}/storage` | GET | Open | +| `Get-PveStorageContent` | `/nodes/{node}/storage/{storage}/content` | GET | Open | +| `Send-PveFile` | `/nodes/{node}/storage/{storage}/upload` | POST | Open | +| `Invoke-PveStorageDownload` | `/nodes/{node}/storage/{storage}/download-url` | POST | Open | +| `New-PveStorage` | `/storage` | POST | Open | +| `Remove-PveStorage` | `/storage/{storage}` | DELETE | Open | -#### Networking (4 + 6 SDN cmdlets) -| Cmdlet | API Endpoint | Method | -|---|---|---| -| `Get-PveNetwork` | `/nodes/{node}/network` | GET | -| `New-PveNetwork` | `/nodes/{node}/network` | POST | -| `Set-PveNetwork` | `/nodes/{node}/network/{iface}` | PUT | -| `Remove-PveNetwork` | `/nodes/{node}/network/{iface}` | DELETE | -| `Invoke-PveNetworkApply` | `/nodes/{node}/network` | PUT | -| `Get-PveSdnZone` | `/cluster/sdn/zones` | GET | -| `New-PveSdnZone` | `/cluster/sdn/zones` | POST | -| `Remove-PveSdnZone` | `/cluster/sdn/zones/{zone}` | DELETE | -| `Get-PveSdnVnet` | `/cluster/sdn/vnets` | GET | -| `New-PveSdnVnet` | `/cluster/sdn/vnets` | POST | -| `Remove-PveSdnVnet` | `/cluster/sdn/vnets/{vnet}` | DELETE | +#### VM Snapshots (4 cmdlets) +| Cmdlet | API Endpoint | Method | Prior Status | +|---|---|---|---| +| `Get-PveSnapshot` | `/nodes/{node}/qemu/{vmid}/snapshot` | GET | Open | +| `New-PveSnapshot` | `/nodes/{node}/qemu/{vmid}/snapshot` | POST | Open | +| `Remove-PveSnapshot` | `/nodes/{node}/qemu/{vmid}/snapshot/{name}` | DELETE | Open | +| `Restore-PveSnapshot` | `/nodes/{node}/qemu/{vmid}/snapshot/{name}/rollback` | POST | Open | -#### Users / ACLs / Tokens (11 cmdlets) -| Cmdlet | API Endpoint | Method | -|---|---|---| -| `Get-PveUser` | `/access/users` | GET | -| `New-PveUser` | `/access/users` | POST | -| `Remove-PveUser` | `/access/users/{userid}` | DELETE | -| `Set-PveUser` | `/access/users/{userid}` | PUT | -| `Get-PveRole` | `/access/roles` | GET | -| `New-PveRole` | `/access/roles` | POST | -| `Remove-PveRole` | `/access/roles/{roleid}` | DELETE | -| `Get-PvePermission` | `/access/acl` | GET | -| `Set-PvePermission` | `/access/acl` | PUT | -| `Get-PveApiToken` | `/access/users/{userid}/token` | GET | -| `New-PveApiToken` | `/access/users/{userid}/token/{tokenid}` | POST | -| `Remove-PveApiToken` | `/access/users/{userid}/token/{tokenid}` | DELETE | +#### Networking (5 cmdlets) +| Cmdlet | API Endpoint | Method | Prior Status | +|---|---|---|---| +| `Get-PveNetwork` | `/nodes/{node}/network` | GET | Open | +| `New-PveNetwork` | `/nodes/{node}/network` | POST | Open | +| `Set-PveNetwork` | `/nodes/{node}/network/{iface}` | PUT | Open | +| `Remove-PveNetwork` | `/nodes/{node}/network/{iface}` | DELETE | Open | +| `Invoke-PveNetworkApply` | `/nodes/{node}/network` | PUT | Open | + +#### SDN (9 cmdlets) +| Cmdlet | API Endpoint | Method | Prior Status | +|---|---|---|---| +| `Get-PveSdnZone` | `/cluster/sdn/zones` | GET | Open | +| `New-PveSdnZone` | `/cluster/sdn/zones` | POST | Open | +| `Remove-PveSdnZone` | `/cluster/sdn/zones/{zone}` | DELETE | Open | +| `Get-PveSdnVnet` | `/cluster/sdn/vnets` | GET | Open | +| `New-PveSdnVnet` | `/cluster/sdn/vnets` | POST | Open | +| `Remove-PveSdnVnet` | `/cluster/sdn/vnets/{vnet}` | DELETE | Open | +| `Get-PveSdnSubnet` | `/cluster/sdn/vnets/{vnet}/subnets` | GET | New | +| `New-PveSdnSubnet` | `/cluster/sdn/vnets/{vnet}/subnets` | POST | New | +| `Remove-PveSdnSubnet` | `/cluster/sdn/vnets/{vnet}/subnets/{subnet}` | DELETE | New | + +#### Users / ACLs / Tokens (12 cmdlets) +| Cmdlet | API Endpoint | Method | Prior Status | +|---|---|---|---| +| `Get-PveUser` | `/access/users` | GET | Open | +| `New-PveUser` | `/access/users` | POST | Open | +| `Remove-PveUser` | `/access/users/{userid}` | DELETE | Open | +| `Set-PveUser` | `/access/users/{userid}` | PUT | Open | +| `Get-PveRole` | `/access/roles` | GET | Open | +| `New-PveRole` | `/access/roles` | POST | Open | +| `Remove-PveRole` | `/access/roles/{roleid}` | DELETE | Open | +| `Get-PvePermission` | `/access/acl` | GET | Open | +| `Set-PvePermission` | `/access/acl` | PUT | Open | +| `Get-PveApiToken` | `/access/users/{userid}/token` | GET | Open | +| `New-PveApiToken` | `/access/users/{userid}/token/{tokenid}` | POST | Open | +| `Remove-PveApiToken` | `/access/users/{userid}/token/{tokenid}` | DELETE | Open | #### Templates (4 cmdlets) -| Cmdlet | API Endpoint | Method | -|---|---|---| -| `Get-PveTemplate` | `/nodes/{node}/qemu` (filtered) | GET | -| `New-PveTemplate` | `/nodes/{node}/qemu/{vmid}/template` | POST | -| `Remove-PveTemplate` | `/nodes/{node}/qemu/{vmid}` | DELETE | -| `New-PveVmFromTemplate` | `/nodes/{node}/qemu/{vmid}/clone` | POST | +| Cmdlet | API Endpoint | Method | Prior Status | +|---|---|---|---| +| `Get-PveTemplate` | `/nodes/{node}/qemu` (filtered) | GET | Open | +| `New-PveTemplate` | `/nodes/{node}/qemu/{vmid}/template` | POST | Open | +| `Remove-PveTemplate` | `/nodes/{node}/qemu/{vmid}` | DELETE | Open | +| `New-PveVmFromTemplate` | `/nodes/{node}/qemu/{vmid}/clone` | POST | Open | #### Cloud-Init (3 cmdlets) -| Cmdlet | API Endpoint | Method | -|---|---|---| -| `Get-PveCloudInitConfig` | `/nodes/{node}/qemu/{vmid}/config` | GET | -| `Set-PveCloudInitConfig` | `/nodes/{node}/qemu/{vmid}/config` | PUT | -| `Invoke-PveCloudInitRegenerate` | `/nodes/{node}/qemu/{vmid}/cloudinit` | PUT | +| Cmdlet | API Endpoint | Method | Prior Status | +|---|---|---|---| +| `Get-PveCloudInitConfig` | `/nodes/{node}/qemu/{vmid}/config` | GET | Open | +| `Set-PveCloudInitConfig` | `/nodes/{node}/qemu/{vmid}/config` | PUT | Open | +| `Invoke-PveCloudInitRegenerate` | `/nodes/{node}/qemu/{vmid}/cloudinit` | PUT | Open | #### Tasks (2 cmdlets) -| Cmdlet | API Endpoint | Method | -|---|---|---| -| `Get-PveTask` | `/nodes/{node}/tasks/{upid}/status` | GET | -| `Wait-PveTask` | `/nodes/{node}/tasks/{upid}/status` (polling) | GET | +| Cmdlet | API Endpoint | Method | Prior Status | +|---|---|---|---| +| `Get-PveTask` | `/nodes/{node}/tasks/{upid}/status` | GET | Open | +| `Wait-PveTask` | `/nodes/{node}/tasks/{upid}/status` (polling) | GET | Open | ### Missing API Coverage by Functional Area -| Area | Key Missing Endpoints | Priority | -|---|---|---| -| **Firewall** | `/nodes/{node}/firewall/rules`, `/cluster/firewall/*` | High | -| **Backup / Restore** | `/nodes/{node}/vzdump`, backup schedule management | High | -| **Pools** | `/pools`, `/pools/{poolid}` (CRUD) | Medium | -| **HA** | `/cluster/ha/*` (groups, resources, status) | Medium | -| **Ceph** | `/nodes/{node}/ceph/*` (OSD, pool, mon, fs) | Medium | -| **Replication** | `/cluster/replication` (CRUD) | Low | -| **Access Groups** | `/access/groups` (CRUD) | Low | -| **Access Domains** | `/access/domains` (CRUD) | Low | -| **Container Snapshots** | `/nodes/{node}/lxc/{vmid}/snapshot` | Medium | -| **Container Migrate** | `/nodes/{node}/lxc/{vmid}/migrate` | Medium | -| **PBS Integration** | Proxmox Backup Server operations | Low | -| **Cluster Config** | `/cluster/config/*` (join, nodes, totem) | Low | -| **ACME** | `/cluster/acme/*`, `/nodes/{node}/certificates/*` | Low | -| **Node Management** | `/nodes/{node}/apt/*`, `/nodes/{node}/disks/*`, `/nodes/{node}/services/*` | Low | -| **Metrics** | `/cluster/metrics/*` | Low | -| **SDN Subnets** | `/cluster/sdn/vnets/{vnet}/subnets` | Medium | -| **VM Agent** | Additional agent endpoints (file-read, file-write, etc.) | Low | +| Area | Key Missing Endpoints | Priority | Prior Status | +|---|---|---|---| +| **Firewall** | `/cluster/firewall/*`, `/nodes/{node}/firewall/*`, `/nodes/{node}/qemu/{vmid}/firewall/*` | High | Open | +| **Backup / vzdump** | `/nodes/{node}/vzdump`, `/cluster/backup/*` | High | Open | +| **Pools** | `/pools`, `/pools/{poolid}` (CRUD) | Medium | Open | +| **HA** | `/cluster/ha/*` (groups, resources, status) | Medium | Open | +| **Ceph** | `/nodes/{node}/ceph/*` (OSD, pool, mon, fs) | Medium | Open | +| **Replication** | `/cluster/replication` (CRUD) | Low | Open | +| **Access Groups** | `/access/groups` (CRUD) | Low | Open | +| **Access Domains** | `/access/domains` (CRUD) | Low | Open | +| **PBS Integration** | Proxmox Backup Server operations | Low | Open | +| **Cluster Config** | `/cluster/config/*` (join, nodes, totem) | Low | Open | +| **ACME** | `/cluster/acme/*`, `/nodes/{node}/certificates/*` | Low | Open | +| **Node Management** | `/nodes/{node}/apt/*`, `/nodes/{node}/disks/*`, `/nodes/{node}/services/*` | Low | Open | +| **Metrics** | `/cluster/metrics/*` | Low | Open | +| **SDN IPAM/DNS/Controllers** | `/cluster/sdn/ipams/*`, `/cluster/sdn/dns/*`, `/cluster/sdn/controllers/*` | Low | New | +| **VM Agent (extended)** | Additional agent endpoints (file-read, file-write, os-info) | Low | Open | ### High-Value Gaps -1. **Firewall management** -- Critical for security automation. No cmdlets for creating/managing firewall rules at cluster, node, or VM level. -2. **Backup/restore (vzdump)** -- Essential for DR automation. No cmdlets for creating backups, managing schedules, or restoring. -3. **Pool management** -- Required for multi-tenant environments. No cmdlets for creating/managing resource pools. -4. **Container snapshots** -- Snapshots are only implemented for VMs, not LXC containers. -5. **Container migration** -- `Move-PveVm` exists for VMs but no equivalent for containers. +1. **Firewall management** — Critical for security automation. No cmdlets for creating/managing firewall rules at cluster, node, or VM level. +2. **Backup/restore (vzdump)** — Essential for DR automation. No cmdlets for creating backups, managing schedules, or restoring. +3. **Pool management** — Required for multi-tenant environments. No cmdlets for creating/managing resource pools. --- -## Phase 3 -- Code Quality & Best Practices +## Phase 3 — Code Quality & Best Practices ### 3a. PowerShell Module Design #### Cmdlet Naming -- **All cmdlets use approved PowerShell verbs** from the `VerbsCommon`, `VerbsLifecycle`, `VerbsCommunications`, `VerbsDiagnostic`, and `VerbsData` classes. -- **One exception**: `Reset-PveVm` uses a hardcoded string `"Reset"` instead of a verb class constant: `[Cmdlet("Reset", "PveVm", ...)]`. While `Reset` IS an approved verb (in `VerbsCommon`), the implementation should use `VerbsCommon.Reset` for consistency. -- Noun prefix `Pve` is consistent across all cmdlets. +- **All cmdlets use approved PowerShell verbs** via verb class constants (`VerbsCommon.Get`, `VerbsLifecycle.Start`, etc.). +- Noun prefix `Pve` is consistent across all 83 cmdlets. +- Previous finding about `Reset-PveVm` using a string literal has been fixed — now uses `VerbsCommon.Reset`. #### Parameter Design - `[Parameter(Mandatory = ...)]` used appropriately throughout. -- `ValueFromPipelineByPropertyName = true` used on `Node`, `VmId`, and similar parameters enabling pipeline chaining (e.g., `Get-PveVm | Start-PveVm`). -- `Position` attributes used on key parameters for positional binding. -- `ValidateSet`, `ValidateRange`, `ValidateNotNullOrEmpty` used where appropriate (network types, port range, storage types, etc.). -- **Gap**: VmId parameters are `int` type but lack `[ValidateRange(100, 999999999)]` to match PVE's VMID constraints. +- `ValueFromPipelineByPropertyName = true` used on `Node`, `VmId`, and similar parameters. +- `Position` attributes used on key parameters. +- `ValidateSet`, `ValidateRange(100, 999999999)`, `ValidateNotNullOrEmpty`, `ValidatePattern` used where appropriate. +- **HelpMessage** present on virtually all parameters (previously missing). #### ShouldProcess / WhatIf / Confirm -- **Excellent**: Every destructive/mutating cmdlet implements `SupportsShouldProcess = true`. -- `ConfirmImpact = ConfirmImpact.High` is set on all `Remove-*` cmdlets, `New-PveTemplate` (irreversible), and `Restore-PveSnapshot`. -- **Gap**: `Stop-PveVm`, `Stop-PveContainer`, `Reset-PveVm` do not set `ConfirmImpact.High` -- stopping/resetting VMs can cause data loss. +- Every destructive/mutating cmdlet implements `SupportsShouldProcess = true`. +- `ConfirmImpact = ConfirmImpact.High` on all `Remove-*` cmdlets, `Stop-PveVm`, `Reset-PveVm`, `New-PveTemplate`, `Restore-PveSnapshot`, `Restore-PveContainerSnapshot`. +- **Gap**: `Remove-PveRole` does not set `ConfirmImpact.High` — deleting a role is a significant operation. #### OutputType -- `[OutputType]` attributes are present on **all 66 cmdlets**. This is exemplary. +- `[OutputType]` attributes present on all 83 cmdlets. #### Pipeline Support -- All cmdlets use `ValueFromPipelineByPropertyName` on `Node` and `VmId` parameters. -- `Get-PveVm` and `Get-PveContainer` output objects with `Node` and `VmId` properties, enabling chaining. -- **Gap**: None of the cmdlets implement `ProcessRecord` for pipeline batch processing -- all use `EndProcessing` (or equivalent). This means `Get-PveVm | Start-PveVm` processes only the last piped object. **This is a functional bug if multiple objects are piped.** +- All cmdlets use `ProcessRecord` for pipeline processing. +- `Get-PveVm`, `Get-PveContainer` output objects with `Node` and `VmId` enabling chaining. #### Error Handling -- `ThrowTerminatingError` used appropriately in `ConnectPveServerCmdlet`, `WaitPveTaskCmdlet`, `GetPveNodeCmdlet`, `GetPveNodeStatusCmdlet`. -- `WriteWarning` used in `DisconnectPveServerCmdlet` and `SetPveCloudInitConfigCmdlet` for non-fatal conditions. -- `WriteVerbose` used in connection cmdlets for diagnostic output. -- **Gap**: Most cmdlets let exceptions propagate without wrapping in `ErrorRecord` -- this means non-terminating errors are not always well-structured. +- `ThrowTerminatingError` used appropriately for fatal conditions. +- `WriteWarning` used for non-fatal conditions. +- Custom exception hierarchy provides structured errors. ### 3b. C# Code Quality #### Null-Reference Safety - `enable` in both `.csproj` files. -- Nullable annotations used on session, parameters, and return types. -- Null guards (`?? throw`) used in constructors and authentication methods. +- Consistent use of nullable annotations, null guards (`?? throw`), and null-coalescing patterns. +- No null-forgiving operators (`!`) used. #### Async/Await Pattern -- All HTTP operations are async at the `PveHttpClient` level. -- Sync wrappers use `.GetAwaiter().GetResult()` pattern. -- **Assessment**: This is the standard approach for PowerShell binary modules on .NET. True async cmdlets are only available via `BeginProcessing`/`EndProcessing` async overrides in PS 7.4+, which would break PS 5.1 compatibility. -- **Risk**: Low. PowerShell cmdlets run on a single pipeline thread, so deadlock risk from `GetAwaiter().GetResult()` is minimal given the `ConfigureAwait(false)` usage throughout. +- All HTTP operations are async in `PveHttpClient` with `.ConfigureAwait(false)`. +- Sync wrappers use `.GetAwaiter().GetResult()` — standard for PowerShell binary modules. +- Low deadlock risk given the single pipeline thread model. #### IDisposable Correctness -- `PveHttpClient` implements `IDisposable` correctly with a `_disposed` flag. -- All usages in cmdlets and services use `using var client = new PveHttpClient(session)` or `using (var client = ...)` blocks. -- `ProgressStream` disposes its inner stream correctly. -- File upload uses `try/finally` for stream cleanup with conditional `DisposeAsync` on .NET 9+. -- **Assessment**: Excellent. No resource leak risks identified. +- `PveHttpClient` implements `IDisposable` correctly with `_disposed` flag. +- All service methods use `using var client = new PveHttpClient(session)`. +- File upload uses framework-conditional async disposal. #### Exception Handling -- Custom exception hierarchy: `PveApiException`, `PveAuthenticationException`, `PveNotConnectedException`, `PveSessionExpiredException`, `PveTaskTimeoutException`, `PveTaskFailedException`, `PveVersionException`. -- `PveHttpClient.SendAsync` catches `HttpRequestException` and wraps it in `PveApiException`. -- Error message extraction from PVE API JSON responses is handled gracefully. -- The only bare `catch {}` is in `ExtractErrorMessage` where parse failure falls through to raw body -- this is acceptable. +- Well-defined exception hierarchy (7 custom types) with context properties. +- `PveHttpClient.SendAsync` wraps `HttpRequestException` in `PveApiException`. +- Silent catches are intentional and documented (node polling, status enrichment). #### Magic Strings -- API resource paths are constructed inline in service methods (e.g., `$"nodes/{node}/qemu/{vmid}/status/start"`). This is pragmatic for the size of the codebase. -- Auth header names (`PVEAPIToken=`, `PVEAuthCookie=`, `CSRFPreventionToken`) are inline. Could be constants but not a significant issue. - -#### LINQ -- No complex LINQ usage. JObject/JArray parsing uses Newtonsoft.Json's indexer pattern which is straightforward. +- Auth header names inline (minor, not problematic). +- API resource paths constructed inline (pragmatic for codebase size). +- `RandomNumberGenerator` used for boundary generation (previously `new Random()`). #### Logging / Verbose Output -- `WriteVerbose` used in `ConnectPveServerCmdlet` and `DisconnectPveServerCmdlet`. -- **Gap**: No `WriteVerbose` calls in any other cmdlets -- users cannot see which API calls are being made. This hinders debugging. -- `WriteDebug` is not used anywhere in the module. -- **Gap**: `WriteProgress` is only used in `Send-PveIso` (via progress callback). Not used in `Wait-PveTask` or other long-running operations. +- `WriteVerbose` used extensively throughout cmdlets (previously missing). +- `WriteProgress` used in `Send-PveFile`, `Import-PveOva`, and `Wait-PveTask`. ### 3c. General Hygiene -#### Unused Code / Dead Code -- No unused `using` directives detected (CS1591 suppressed for missing XML docs, not for unused code). -- No commented-out blocks found. -- **Clean**: Zero TODO/FIXME/HACK markers in source code. +- No unused `using` directives detected. +- No commented-out blocks or dead code. +- Zero TODO/FIXME/HACK markers. +- Consistent code style matching `.editorconfig`. +- XML doc comments on all public types and cmdlet classes. +- `CS1591` no longer suppressed in Core project. -#### Code Style -- Consistent 4-space indentation matching `.editorconfig`. -- Opening braces on same line (Allman-adjacent style used in some places, K&R in others -- minor inconsistency but not problematic). -- PascalCase for public members, camelCase for locals/fields. +### Code Quality Findings -#### XML Doc Comments -- Public types in `PSProxmoxVE.Core` have XML doc comments (enabled via `true`). -- `CS1591` is suppressed in Core project (`$(NoWarn);CS1591`) so not all public members have docs. -- Cmdlet classes have XML summary comments on the class level. -- **Gap**: Parameter help text (`[Parameter(HelpMessage = ...)]`) is not used on any cmdlet parameter. This means `Get-Help` shows no parameter descriptions. +| ID | File | Line | Severity | Description | Prior Status | +|---|---|---|---|---|---| +| CQ1 | `RemovePveRoleCmdlet.cs` | 13 | Medium | Missing `ConfirmImpact.High` — deleting roles is significant | New | +| CQ2 | `PveHttpClient.cs` | 316-323 | Low | Auth header names (`PVEAPIToken=`, `CSRFPreventionToken`) could be constants | Open | --- -## Phase 4 -- Testing Coverage Analysis +## Phase 4 — Testing Coverage Analysis ### Test Projects | Project | Framework | Type | Runner | |---|---|---|---| -| `PSProxmoxVE.Core.Tests` | xUnit 2.7 + Moq 4.20 | Unit tests | `dotnet test` | +| `PSProxmoxVE.Core.Tests` | xUnit 2.7 + Moq 4.20 | Unit tests (models, auth) | `dotnet test` | | `PSProxmoxVE.Tests` | Pester 5 | Cmdlet + Integration tests | `Invoke-Pester` | ### xUnit Tests (Core Library) -| Test File | Test Area | -|---|---| -| `Authentication/PveSessionTests.cs` | Session creation, expiry, auth modes | -| `Authentication/PveAuthenticatorTests.cs` | Authentication logic | -| `Authentication/PveVersionTests.cs` | Version parsing | -| `Models/ClusterModelTests.cs` | Cluster model deserialization | -| `Models/ContainerModelTests.cs` | Container model deserialization | -| `Models/NetworkModelTests.cs` | Network model deserialization | -| `Models/NodeModelTests.cs` | Node model deserialization | -| `Models/SdnModelTests.cs` | SDN model deserialization | -| `Models/SnapshotModelTests.cs` | Snapshot model deserialization | -| `Models/StorageModelTests.cs` | Storage model deserialization | -| `Models/TaskModelTests.cs` | Task model deserialization | -| `Models/UserModelTests.cs` | User model deserialization | -| `Models/VmModelTests.cs` | VM model deserialization | +| Test Area | Test File | ~Methods | +|---|---|---| +| Session lifecycle | `PveSessionTests.cs` | 9 | +| Authentication logic | `PveAuthenticatorTests.cs` | 7 | +| Version parsing | `PveVersionTests.cs` | 11 | +| Cluster models | `ClusterModelTests.cs` | 13 | +| Container models | `ContainerModelTests.cs` | 13 | +| Network models | `NetworkModelTests.cs` | 12 | +| Node models | `NodeModelTests.cs` | 13 | +| SDN models | `SdnModelTests.cs` | 17 | +| Snapshot models | `SnapshotModelTests.cs` | 9 | +| Storage models | `StorageModelTests.cs` | 19 | +| Task models | `TaskModelTests.cs` | 9 | +| User models | `UserModelTests.cs` | 24 | +| VM models | `VmModelTests.cs` | 18 | -### Pester Tests (Cmdlet Level) +**Total: ~174 xUnit tests** with PVE 8 and PVE 9 JSON fixtures. -| Test File | Cmdlet(s) Covered | -|---|---| -| `Connection/Connect-PveServer.Tests.ps1` | `Connect-PveServer` | -| `Connection/Disconnect-PveServer.Tests.ps1` | `Disconnect-PveServer` | -| `Connection/Test-PveConnection.Tests.ps1` | `Test-PveConnection` | -| `Containers/Get-PveContainer.Tests.ps1` | `Get-PveContainer` | -| `Containers/ContainerLifecycle.Tests.ps1` | Container start/stop/restart | -| `Containers/ContainerConfigCmdlets.Tests.ps1` | Container config cmdlets | -| `CloudInit/CloudInitCmdlets.Tests.ps1` | Cloud-init cmdlets | -| `Network/Get-PveNetwork.Tests.ps1` | `Get-PveNetwork` | -| `Network/SdnCmdlets.Tests.ps1` | SDN cmdlets | -| `Nodes/NodeCmdlets.Tests.ps1` | Node cmdlets | -| `Snapshots/SnapshotCmdlets.Tests.ps1` | Snapshot cmdlets | -| `Storage/Get-PveStorage.Tests.ps1` | `Get-PveStorage` | -| `Storage/StorageDownload.Tests.ps1` | `Invoke-PveStorageDownload` | -| `Storage/Send-PveIso.Tests.ps1` | `Send-PveIso` | -| `Tasks/TaskCmdlets.Tests.ps1` | Task cmdlets | -| `Templates/TemplateCmdlets.Tests.ps1` | Template cmdlets | -| `Users/UserCmdlets.Tests.ps1` | User CRUD | -| `Users/ApiTokenCmdlets.Tests.ps1` | API token CRUD | -| `Users/RoleCmdlets.Tests.ps1` | Role CRUD | -| `Vms/Get-PveVm.Tests.ps1` | `Get-PveVm` | -| `Vms/New-PveVm.Tests.ps1` | `New-PveVm` | -| `Vms/Remove-PveVm.Tests.ps1` | `Remove-PveVm` | -| `Vms/VmLifecycle.Tests.ps1` | VM start/stop/restart | -| `Vms/VmAdvancedOps.Tests.ps1` | Clone, migrate, resize | -| `Vms/VmConfigCmdlets.Tests.ps1` | VM config cmdlets | +### Pester Tests (30 files, ~500 tests) + +All 83 cmdlets have Pester unit tests covering command metadata, parameter validation, ShouldProcess presence, and pipeline binding. ### Integration Tests -The integration test file (`Integration.Tests.ps1`) is comprehensive and covers: +The integration test file covers 18+ major contexts with 90+ individual tests: -| Context | Operations Tested | -|---|---| -| Connection | Connect via API token, version detection | -| Nodes | List nodes, get node status | -| User CRUD | Create, list, update, remove user | -| Role CRUD | Create, list, remove role | -| API Token CRUD | Create, list, remove API token | -| Permissions | List and set permissions | -| VMs | List, create, config get/set, start/stop, hard reset, clone | -| Snapshots | Create, list, restore, remove | -| Storage | List storage, list content, upload ISO | -| Tasks | Get task, wait for task | -| Network | List networks | -| Templates | List templates, convert to template, clone from template | -| Cloud-Init | Get cloud-init config | -| Guest Agent | Ping, network interfaces, exec command | -| ACPI Lifecycle | Graceful restart, graceful stop | +- Connection, Nodes, User/Role/Token CRUD, Permissions +- VM lifecycle (create, config, start/stop, reset, clone, resize, suspend/resume) +- Snapshot CRUD (VM and container) +- Storage (list, content, upload, create/remove, download) +- Network CRUD with apply/revert +- SDN zones/VNets/subnets CRUD +- Templates, Cloud-Init +- Container lifecycle (create, config, start/stop/restart, clone, snapshots) +- Linux VM provisioning (cloud image upload, disk import, cloud-init, guest agent) -- **PVE 8 and PVE 9 both tested** via matrix strategy in CI. -- **Setup/teardown**: AfterAll block cleans up created VMs, users, and roles. -- **Skip logic**: Tests skip gracefully when env vars are not set. -- **Connection details**: Injected via environment variables (`PVETEST_HOST`, `PVETEST_APITOKEN`, etc.). +**Setup/teardown**: AfterAll cleans up all created resources. Environment variables inject connection details. Tests skip gracefully when env vars are missing. ### Test Coverage Gap List -| Cmdlet | Unit Test | Integration Test | Gap | -|---|---|---|---| -| `Set-PveNetwork` | Pester | No | No integration test for network modification | -| `New-PveNetwork` | Pester | No | No integration test for network creation | -| `Remove-PveNetwork` | Pester | No | No integration test for network removal | -| `Invoke-PveNetworkApply` | Pester | No | No integration test for network apply | -| `New-PveSdnZone` | Pester | No | No integration test for SDN zone creation | -| `Remove-PveSdnZone` | Pester | No | No integration test for SDN zone removal | -| `New-PveSdnVnet` | Pester | No | No integration test for SDN VNet creation | -| `Remove-PveSdnVnet` | Pester | No | No integration test for SDN VNet removal | -| `New-PveStorage` | Pester | No | No integration test for storage creation | -| `Remove-PveStorage` | Pester | No | No integration test for storage removal | -| `Invoke-PveStorageDownload` | Pester | No | No integration test for URL download | -| `Suspend-PveVm` | Pester | No | No integration test for VM suspend | -| `Resume-PveVm` | Pester | No | No integration test for VM resume | -| `Move-PveVm` | Pester | No | No integration test (requires multi-node) | -| `Resize-PveVmDisk` | Pester | No | No integration test for disk resize | -| `Copy-PveContainer` | Pester | No | No integration test for container clone | -| `Set-PveCloudInitConfig` | Pester | No | No integration test for cloud-init set | -| `Invoke-PveCloudInitRegenerate` | Pester | No | Skipped in CI due to bug | -| `Set-PvePermission` | Pester | Partial | Only tests add, not remove | +| Cmdlet | Unit Test | Integration Test | PVE 8 | PVE 9 | Prior Status | +|---|---|---|---|---|---| +| `Connect-PveServer` | ✓ | ✓ | ✓ | ✓ | Open | +| `Disconnect-PveServer` | ✓ | ✓ | ✓ | ✓ | Open | +| `Test-PveConnection` | ✓ | ✓ | ✓ | ✓ | Open | +| `Get-PveNode` | ✓ | ✓ | ✓ | ✓ | Open | +| `Get-PveNodeStatus` | ✓ | ✓ | ✓ | ✓ | Open | +| `Get-PveVm` | ✓ | ✓ | ✓ | ✓ | Open | +| `New-PveVm` | ✓ | ✓ | — | ✓ | Open | +| `Remove-PveVm` | ✓ | ✓ | — | ✓ | Open | +| `Start-PveVm` | ✓ | ✓ | ✓ | ✓ | Open | +| `Stop-PveVm` | ✓ | ✓ | ✓ | ✓ | Open | +| `Restart-PveVm` | ✓ | ✓ | ✓ | ✓ | Open | +| `Suspend-PveVm` | ✓ | ✓ | — | ✓ | Fixed | +| `Resume-PveVm` | ✓ | ✓ | — | ✓ | Fixed | +| `Reset-PveVm` | ✓ | ✓ | ✓ | ✓ | Open | +| `Copy-PveVm` | ✓ | ✓ | — | ✓ | Open | +| `Move-PveVm` | ✓ | ✗ | — | — | Open | +| `Get-PveVmConfig` | ✓ | ✓ | — | ✓ | Open | +| `Set-PveVmConfig` | ✓ | ✓ | — | ✓ | Open | +| `Resize-PveVmDisk` | ✓ | ✓ | — | ✓ | Fixed | +| `Import-PveVmDisk` | ✓ | ✓ | — | ✓ | New | +| `Import-PveOva` | ✓ | ✗ | — | — | New | +| `Test-PveVmGuestAgent` | ✓ | ✓ | — | ✓ | Open | +| `Get-PveVmGuestNetwork` | ✓ | ✓ | — | ✓ | Open | +| `Invoke-PveVmGuestExec` | ✓ | ✓ | — | ✓ | Open | +| `Get-PveContainer` | ✓ | ✓ | — | ✓ | Open | +| `New-PveContainer` | ✓ | ✓ | — | ✓ | Open | +| `Remove-PveContainer` | ✓ | ✓ | — | ✓ | Open | +| `Start-PveContainer` | ✓ | ✓ | — | ✓ | Open | +| `Stop-PveContainer` | ✓ | ✓ | — | ✓ | Open | +| `Restart-PveContainer` | ✓ | ✓ | — | ✓ | Open | +| `Copy-PveContainer` | ✓ | ✓ | — | ✓ | Fixed | +| `Move-PveContainer` | ✓ | ✗ | — | — | New | +| `Get-PveContainerConfig` | ✓ | ✓ | — | ✓ | Open | +| `Set-PveContainerConfig` | ✓ | ✓ | — | ✓ | Open | +| `Get-PveContainerSnapshot` | ✓ | ✓ | — | ✓ | New | +| `New-PveContainerSnapshot` | ✓ | ✓ | — | ✓ | New | +| `Remove-PveContainerSnapshot` | ✓ | ✓ | — | ✓ | New | +| `Restore-PveContainerSnapshot` | ✓ | ✓ | — | ✓ | New | +| `Get-PveStorage` | ✓ | ✓ | ✓ | ✓ | Open | +| `New-PveStorage` | ✓ | ✓ | — | ✓ | Fixed | +| `Remove-PveStorage` | ✓ | ✓ | — | ✓ | Fixed | +| `Get-PveStorageContent` | ✓ | ✓ | ✓ | ✓ | Open | +| `Send-PveFile` | ✓ | ✓ | — | ✓ | Open | +| `Invoke-PveStorageDownload` | ✓ | ✓ | — | ✓ | Fixed | +| `Get-PveSnapshot` | ✓ | ✓ | ✓ | ✓ | Open | +| `New-PveSnapshot` | ✓ | ✓ | ✓ | ✓ | Open | +| `Remove-PveSnapshot` | ✓ | ✓ | ✓ | ✓ | Open | +| `Restore-PveSnapshot` | ✓ | ✗ | — | — | Open | +| `Get-PveNetwork` | ✓ | ✓ | — | ✓ | Open | +| `New-PveNetwork` | ✓ | ✓ | — | ✓ | Fixed | +| `Set-PveNetwork` | ✓ | ✓ | — | ✓ | Fixed | +| `Remove-PveNetwork` | ✓ | ✓ | — | ✓ | Fixed | +| `Invoke-PveNetworkApply` | ✓ | ✓ | — | ✓ | Fixed | +| `Get-PveSdnZone` | ✓ | ✓ | — | ✓ | Open | +| `New-PveSdnZone` | ✓ | ✓ | — | ✓ | Fixed | +| `Remove-PveSdnZone` | ✓ | ✓ | — | ✓ | Fixed | +| `Get-PveSdnVnet` | ✓ | ✓ | — | ✓ | Open | +| `New-PveSdnVnet` | ✓ | ✓ | — | ✓ | Fixed | +| `Remove-PveSdnVnet` | ✓ | ✓ | — | ✓ | Fixed | +| `Get-PveSdnSubnet` | ✓ | ✓ | — | ✓ | New | +| `New-PveSdnSubnet` | ✓ | ✓ | — | ✓ | New | +| `Remove-PveSdnSubnet` | ✓ | ✓ | — | ✓ | New | +| `Get-PveUser` | ✓ | ✓ | ✓ | ✓ | Open | +| `New-PveUser` | ✓ | ✓ | ✓ | ✓ | Open | +| `Set-PveUser` | ✓ | ✓ | ✓ | ✓ | Open | +| `Remove-PveUser` | ✓ | ✓ | ✓ | ✓ | Open | +| `Get-PveRole` | ✓ | ✓ | ✓ | ✓ | Open | +| `New-PveRole` | ✓ | ✓ | ✓ | ✓ | Open | +| `Remove-PveRole` | ✓ | ✓ | ✓ | ✓ | Open | +| `Get-PveApiToken` | ✓ | ✓ | ✓ | ✓ | Open | +| `New-PveApiToken` | ✓ | ✓ | ✓ | ✓ | Open | +| `Remove-PveApiToken` | ✓ | ✓ | ✓ | ✓ | Open | +| `Get-PvePermission` | ✓ | ✓ | — | ✓ | Open | +| `Set-PvePermission` | ✓ | ✓ | — | ✓ | Open | +| `Get-PveTemplate` | ✓ | ✓ | — | ✓ | Open | +| `New-PveTemplate` | ✓ | ✗ | — | — | Open | +| `Remove-PveTemplate` | ✓ | ✗ | — | — | Open | +| `New-PveVmFromTemplate` | ✓ | ✗ | — | — | Open | +| `Get-PveCloudInitConfig` | ✓ | ✓ | — | ✓ | Open | +| `Set-PveCloudInitConfig` | ✓ | ✓ | — | ✓ | Fixed | +| `Invoke-PveCloudInitRegenerate` | ✓ | ✓ | — | ✓ | Fixed | +| `Get-PveTask` | ✓ | ✓ | ✓ | ✓ | Open | +| `Wait-PveTask` | ✓ | ✓ | — | ✓ | Open | -**Note**: Many of the gaps above are for operations that would require complex test infrastructure (multi-node clusters, specific storage backends, etc.) and are reasonable omissions for a pre-1.0 module. +**Summary**: 83/83 unit tested (100%), 70/83 integration tested (84%). Remaining gaps are for operations requiring multi-node clusters (`Move-PveVm`, `Move-PveContainer`), optional test assets (`Import-PveOva`), or template operations that would require extra setup. --- -## Phase 5 -- Security Review +## Phase 5 — Security Review ### 5.1 Credential Handling -| Aspect | Status | Details | -|---|---|---| -| PSCredential usage | **Good** | `Connect-PveServer` accepts `PSCredential` for password auth | -| Password extraction | **Acceptable** | `NetworkCredential.Password` is used only at the point of API call | -| API token in memory | **Acceptable** | Stored as plain `string` in `PveSession.ApiToken` -- standard for PowerShell modules | -| Credential logging | **Good** | No `WriteVerbose`/`WriteDebug` calls that include credentials | -| Ticket storage | **Good** | Tickets are stored in `PveSession` only, not written to disk | - -**Finding**: The password is extracted from `PSCredential` via `NetworkCredential.Password` which returns a plain `string`. This is the standard pattern for PowerShell binary modules since the PVE API requires the password as a form field. No improvement needed. +| Aspect | Status | Details | Prior Status | +|---|---|---|---| +| PSCredential usage | **Good** | `Connect-PveServer` accepts `PSCredential` for password auth | Open | +| Password extraction | **Acceptable** | `NetworkCredential.Password` used only at API call time | Open | +| API token in memory | **Acceptable** | Stored as plain `string` in `PveSession.ApiToken` | Open | +| Credential logging | **Good** | No `WriteVerbose`/`WriteDebug` calls include credentials | Open | +| Ticket storage | **Good** | Tickets stored in `PveSession` only, not written to disk | Open | +| Session expiry | **Good** | 2-hour expiry detected via `IsExpired` property | Open | ### 5.2 TLS/HTTPS -| Aspect | Status | Details | -|---|---|---| -| HTTPS enforced | **Good** | `BaseUrl` always constructs `https://` URLs | -| SkipCertificateCheck | **Good** | Explicit opt-in parameter on `Connect-PveServer` | -| Certificate bypass | **Acceptable** | Uses `ServerCertificateCustomValidationCallback = ... => true` when opted in | -| TLS version | **Good** | Left to OS/runtime negotiation (no pinning) | -| Warning on skip | **Gap** | No `WriteWarning` when `-SkipCertificateCheck` is used | +| Aspect | Status | Details | Prior Status | +|---|---|---|---| +| HTTPS enforced | **Good** | `BaseUrl` always constructs `https://` URLs | Open | +| SkipCertificateCheck | **Good** | Explicit opt-in parameter on `Connect-PveServer` | Open | +| Warning on skip | **Good** | `WriteWarning` emitted with man-in-the-middle advisory | Fixed | +| TLS version | **Good** | Left to OS/runtime negotiation (no pinning) | Open | +| Port validation | **Good** | `ValidateRange(1, 65535)` on port parameter | Open | ### 5.3 Input Validation -| Aspect | Status | Details | -|---|---|---| -| URL construction | **Good** | Resource paths use string interpolation with validated parameters | -| Path traversal | **Low risk** | Node names, VM IDs, and storage names are used in URL paths but come from API responses or validated parameters | -| VMID validation | **Gap** | VmId is `int` type (natural bounds) but no `[ValidateRange]` to enforce PVE's 100-999999999 range | -| Username format | **Good** | `PveAuthenticator` validates `user@realm` format | -| API token format | **Good** | Regex validation in `PveAuthenticator.AuthenticateWithApiToken` | +| Aspect | Status | Details | Prior Status | +|---|---|---|---| +| VMID validation | **Good** | `[ValidateRange(100, 999999999)]` on all VmId parameters | Fixed | +| URL construction | **Good** | String interpolation with validated parameters | Open | +| User ID encoding | **Good** | `Uri.EscapeDataString()` used for user/token IDs in URLs | Open | +| API token format | **Good** | Regex validation in `PveAuthenticator` | Open | +| Path parameters | **Low risk** | Node/snapshot names not URL-encoded but come from validated sources | Open | ### 5.4 Dependency Security -| Package | Version | Pinned | Notes | -|---|---|---|---| -| `Newtonsoft.Json` | 13.0.3 | Yes | Latest stable, no known CVEs | -| `System.Text.Json` | 8.0.5 | Yes | Latest 8.x patch | -| `PowerShellStandard.Library` | 5.1.1 | Yes | PrivateAssets=all | -| `System.Management.Automation` | 7.4.0 | Yes | PrivateAssets=all | -| `Microsoft.NET.Test.Sdk` | 17.9.0 | Yes | Test only | -| `xunit` | 2.7.0 | Yes | Test only | -| `Moq` | 4.20.70 | Yes | Test only | -| `coverlet.collector` | 6.0.1 | Yes | Test only | +| Package | Version | Pinned | Notes | Prior Status | +|---|---|---|---|---| +| `Newtonsoft.Json` | 13.0.3 | Yes | Latest stable, no known CVEs | Open | +| `System.Text.Json` | 8.0.5 | Yes | Latest 8.x patch | Open | +| `SharpCompress` | 0.38.0 | Yes | OVA/tar extraction support | New | +| `PowerShellStandard.Library` | 5.1.1 | Yes | PrivateAssets=all | Open | +| `System.Management.Automation` | 7.4.0 | Yes | PrivateAssets=all | Open | -All dependencies are pinned to exact versions. No floating version ranges detected. +All dependencies are pinned to exact versions. ### 5.5 Secret Scanning -| Finding | Severity | Location | -|---|---|---| -| Hardcoded test password | **Medium** | `integration-tests.yml:42` -- `PVE_PASSWORD: "Testpass123!"` | +No hardcoded credentials, tokens, or sensitive IPs found in source code. CI workflow uses GitHub Actions secrets with `::add-mask::` for test passwords. -The test password is for a throwaway nested PVE instance provisioned and destroyed during CI. It is masked in CI logs via `::add-mask::`. This is acceptable for CI test infrastructure but should be documented. +### Security Findings -No other hardcoded credentials, tokens, or sensitive IPs found in source code. +| ID | Area | File | Severity | Description | Prior Status | +|---|---|---|---|---|---| +| S1 | Input validation | `SnapshotService.cs`, `ContainerService.cs` | Low | Snapshot names and node names in URL paths are not URL-encoded — low risk as values come from validated/system sources | New | --- -## Phase 6 -- PSGallery Publication Readiness +## Phase 6 — PSGallery Publication Readiness ### 6.1 Module Manifest Assessment -| Field | Status | Value | -|---|---|---| -| `RootModule` | OK | `PSProxmoxVE.dll` | -| `ModuleVersion` | OK | `0.1.0` | -| `GUID` | OK | `a3f7c2d1-84e5-4b9f-a061-3e2d8c5f1a7b` | -| `Author` | OK | `goodolclint` | -| `CompanyName` | OK | `Worklab` | -| `Copyright` | OK | `(c) 2026 goodolclint. All rights reserved.` | -| `Description` | OK | Meaningful, not placeholder | -| `PowerShellVersion` | OK | `5.1` | -| `CompatiblePSEditions` | OK | `Desktop`, `Core` | -| `DotNetFrameworkVersion` | OK | `4.8` | -| `RequiredAssemblies` | OK | Lists Core DLL and Newtonsoft.Json | -| `FormatsToProcess` | OK | `PSProxmoxVE.format.ps1xml` | -| `CmdletsToExport` | **Excellent** | Explicit list of all 66 cmdlets | -| `FunctionsToExport` | OK | Empty (binary module) | -| `AliasesToExport` | OK | Empty | -| `VariablesToExport` | OK | Empty | -| `Prerelease` | OK | `preview` | -| `Tags` | OK | 8 relevant tags | -| `LicenseUri` | OK | Points to GitHub LICENSE | -| `ProjectUri` | OK | Points to GitHub repo | -| `ReleaseNotes` | **Missing** | Not present in PSData | -| `IconUri` | **Missing** | Not present in PSData | -| `RequiredModules` | N/A | None required | -| `HelpInfoURI` | **Missing** | No online help | +| Field | Status | Value | Prior Status | +|---|---|---|---| +| `RootModule` | Pass | `PSProxmoxVE.dll` | Open | +| `ModuleVersion` | Pass | `0.1.0` | Open | +| `GUID` | Pass | `a3f7c2d1-84e5-4b9f-a061-3e2d8c5f1a7b` | Open | +| `Author` | Pass | `goodolclint` | Open | +| `CompanyName` | Pass | `Worklab` | Open | +| `Copyright` | Pass | `(c) 2026 goodolclint. All rights reserved.` | Open | +| `Description` | Pass | Meaningful | Open | +| `PowerShellVersion` | Pass | `5.1` | Open | +| `CompatiblePSEditions` | Pass | `Desktop`, `Core` | Open | +| `DotNetFrameworkVersion` | Pass | `4.8` | Open | +| `RequiredAssemblies` | Pass | Lists Core DLL and Newtonsoft.Json | Open | +| `FormatsToProcess` | Pass | `PSProxmoxVE.format.ps1xml` | Open | +| `CmdletsToExport` | Pass | Explicit list of all cmdlets | Open | +| `FunctionsToExport` | Pass | Empty (binary module) | Open | +| `AliasesToExport` | Pass | 7 aliases defined | New | +| `Prerelease` | Pass | `preview` | Open | +| `Tags` | Pass | 8 relevant tags | Open | +| `LicenseUri` | Pass | GitHub LICENSE | Open | +| `ProjectUri` | Pass | GitHub repo | Open | +| `HelpInfoURI` | Pass | `docs/cmdlets` | Fixed | +| `ReleaseNotes` | Pass | Present in PSData | Fixed | +| `IconUri` | **Fail** | Not present | Open | ### 6.2 License -- MIT License present at repo root. -- OSI-approved (PSGallery requirement met). -- `LicenseUri` in manifest points to it. +| Check | Pass/Fail | Notes | Prior Status | +|---|---|---|---| +| LICENSE file present | Pass | MIT License at repo root | Open | +| OSI-approved license | Pass | MIT | Open | +| LicenseUri in manifest | Pass | Points to GitHub LICENSE | Open | ### 6.3 README Quality -| Aspect | Status | -|---|---| -| Installation instructions | **Present** but says "not PSGallery" -- needs update for publication | -| Quick-start examples | **Excellent** -- multiple scenarios covered | -| Authentication guide | **Excellent** -- both ticket and API token documented | -| Multi-cluster usage | **Excellent** -- `-Session` parameter documented | -| Cmdlet reference table | **Excellent** -- all cmdlets listed with descriptions | -| Known limitations | **Good** -- v1 limitations documented | -| Contributing section | **Present** -- basic development setup | -| Badges | **Missing** -- no build status, PSGallery version, or license badges | +| Check | Pass/Fail | Notes | Prior Status | +|---|---|---|---| +| Installation instructions | Pass | `Install-Module PSProxmoxVE` | Fixed | +| Quick-start examples | Pass | Multiple scenarios | Open | +| Authentication guide | Pass | Ticket + API token documented | Open | +| Badges | Pass | Build, Unit Tests, License badges | Fixed | +| Cmdlet reference | Pass | All cmdlets listed | Open | +| Known limitations | Pass | Documented | Open | +| Contributing section | Pass | References CONTRIBUTING.md | Open | ### 6.4 Build & Publish Pipeline -- **Build workflow**: Builds and tests on net48 (Windows) and net9.0 (Windows + Ubuntu). -- **Unit test workflow**: Tests Pester scripts across PS 5.1/7.5 on Windows/Ubuntu/macOS. -- **Integration test workflow**: Full tests against live PVE 8 and PVE 9. -- **PSGallery publish workflow**: **MISSING** -- no workflow for `Publish-Module` or release creation. -- **GitHub Releases**: Not configured (no release workflow, no tagged releases in git history). +| Check | Pass/Fail | Notes | Prior Status | +|---|---|---|---| +| Build workflow | Pass | net48 + net9.0, Windows + Ubuntu | Open | +| Unit test workflow | Pass | PS 5.1/7.5, Windows/Ubuntu/macOS | Open | +| Integration test workflow | Pass | PVE 8 + PVE 9 | Open | +| Publish workflow | Pass | Tag-triggered, PSGallery + GitHub Release | Fixed | +| API key handling | Pass | Via `PSGALLERY_API_KEY` secret | Fixed | ### 6.5 Versioning -- CHANGELOG follows Keep a Changelog format with Conventional Commits. -- Only an `[Unreleased]` section exists -- no versioned releases yet. -- Commit messages follow conventional commits (`feat:`, `fix:`, `test:`, `ci:`, etc.). +| Check | Pass/Fail | Notes | Prior Status | +|---|---|---|---| +| CHANGELOG format | Pass | Keep a Changelog with versioned entry | Open | +| Versioned release | Pass | `0.1.0-preview` entry present | Fixed | +| Commit conventions | Pass | Conventional Commits used consistently | Open | + +### PSGallery Readiness Checklist + +| Check | Pass/Fail | Notes | Prior Status | +|---|---|---|---| +| Module manifest complete | Pass | All required fields populated | Open | +| License present and OSI-approved | Pass | MIT | Open | +| README with install instructions | Pass | PSGallery install documented | Fixed | +| Publish pipeline configured | Pass | `publish.yml` on tag push | Fixed | +| ReleaseNotes present | Pass | In PSData section | Fixed | +| IconUri | **Fail** | Missing — cosmetic only | Open | +| MAML help file | Pass | `PSProxmoxVE.dll-Help.xml` present | New | --- -## Phase 7 -- Community & Repo Maintenance Standards +## Phase 7 — Community & Repo Maintenance Standards -### Files Present +| Check | Pass/Fail | Notes | Prior Status | +|---|---|---|---| +| `README.md` | Pass | Comprehensive with badges | Open | +| `LICENSE` | Pass | MIT | Open | +| `CHANGELOG.md` | Pass | Keep a Changelog format with versioned entry | Open | +| `CONTRIBUTING.md` | Pass | Dev setup, coding standards, test instructions, PR process | Fixed | +| `CODE_OF_CONDUCT.md` | Pass | Contributor Covenant v2.1 | Fixed | +| `SECURITY.md` | Pass | Vulnerability disclosure policy, 48h response SLA | Fixed | +| `.editorconfig` | Pass | Comprehensive rules | Open | +| `.gitignore` | Pass | Build outputs, IDE files, test results | Open | +| `.gitattributes` | Pass | Line ending normalization, binary detection | Fixed | +| Issue templates | Pass | Bug report + feature request (YAML format) | Fixed | +| PR template | Pass | Summary, type checkboxes, checklist | Fixed | +| Conventional commits | Pass | Consistent in recent history | Open | +| Default branch `main` | Pass | Confirmed | Open | +| API coverage documentation | Pass | `docs/PVE_API_COVERAGE.md` | New | +| Cmdlet documentation | Pass | 75 files in `docs/cmdlets/` | New | -| Item | Status | -|---|---| -| `README.md` | Present, comprehensive | -| `LICENSE` | Present (MIT) | -| `CHANGELOG.md` | Present | -| `.editorconfig` | Present | -| `.gitignore` | Present | +### Recommended Branch Protection (cannot verify via API) -### Files Missing - -| Item | Recommendation | -|---|---| -| `CONTRIBUTING.md` | Create with dev setup, coding standards, test instructions, PR process | -| `CODE_OF_CONDUCT.md` | Adopt Contributor Covenant | -| `SECURITY.md` | Create with vulnerability disclosure policy | -| `.gitattributes` | Create for line ending normalization (`* text=auto`) | -| `.github/ISSUE_TEMPLATE/` | Create bug report and feature request templates | -| `.github/pull_request_template.md` | Create with checklist | - -### Commit Conventions - -- Conventional Commits used consistently. -- Scopes include: `ci`, `test`, `fix`, `feat`, `refactor`, `chore`, `docs`. -- Recent history shows disciplined commit hygiene. - -### Branch Protection - -Recommended settings (cannot verify via API): -- Default branch: `main` (confirmed) - Require PR reviews before merge -- Require status checks to pass (build + unit tests + integration tests) +- Require status checks (build + unit tests + integration tests) - Require linear history or squash merging --- -## Phase 8 -- Prioritized Recommendations +## Phase 8 — Prioritized Recommendations -### Critical (blocks PSGallery publication or is a security risk) +### 🔴 Critical (blocks PSGallery publication or is a security risk) -| # | What | Where | Why | Fix | -|---|---|---|---|---| -| C1 | **No PSGallery publish workflow** | `.github/workflows/` | Cannot automate releases | Create `publish.yml` triggered on tag push that runs `Publish-Module` with PSGallery API key from secrets | -| C2 | **README says "not PSGallery"** | `README.md:30` | Contradicts publication goal | Update installation section to `Install-Module PSProxmoxVE` | -| C3 | **No ReleaseNotes in manifest** | `PSProxmoxVE.psd1:168-195` | PSGallery strongly recommends release notes | Add `ReleaseNotes` to `PSData` section | -| ~~C4~~ | ~~Pipeline processing bug~~ | *Verified: all cmdlets use `ProcessRecord`* | N/A -- pipeline processing is correct | No action needed | +**None** — all prior Critical items have been resolved. -### High (significantly impacts quality or community adoption) +### 🟠 High (significantly impacts quality or community adoption) -| # | What | Where | Why | Fix | -|---|---|---|---|---| -| H1 | **No `HelpMessage` on parameters** | All cmdlet files | `Get-Help` shows no parameter descriptions | Add `HelpMessage = "..."` to all `[Parameter]` attributes | -| H2 | **No `WriteVerbose` in cmdlets** | All cmdlets except Connection | Users cannot debug API calls | Add `WriteVerbose($"Calling {method} {resource}")` before API calls | -| H3 | **Missing `CONTRIBUTING.md`** | Repo root | Barrier to community contributions | Create with dev setup, coding standards, PR process | -| H4 | **Missing `SECURITY.md`** | Repo root | No vulnerability disclosure process | Create with responsible disclosure instructions | -| H5 | **No warning on `-SkipCertificateCheck`** | `ConnectPveServerCmdlet.cs` | Users may not realize they're disabling TLS verification | Add `WriteWarning("Certificate validation is disabled...")` | -| H6 | **`Reset-PveVm` uses string literal** | `ResetPveVmCmdlet.cs:16` | Inconsistent with all other cmdlets using verb class constants | Change `"Reset"` to `VerbsCommon.Reset` | -| ~~H7~~ | ~~No `WriteProgress` in `Wait-PveTask`~~ | *Verified: already implemented* | N/A -- `WaitPveTaskCmdlet` already uses `WriteProgress` with percentage and timeout tracking | No action needed | -| H8 | **Missing GitHub issue/PR templates** | `.github/` | No structured issue reporting | Create bug report, feature request, and PR templates | +| # | What | Where | Why | Fix | Prior Status | +|---|---|---|---|---|---| +| H1 | **No firewall cmdlets** | Module | High-value gap for security automation — most requested feature area | Implement firewall rule CRUD for cluster/node/VM | Open | +| H2 | **No backup/vzdump cmdlets** | Module | Essential for DR automation scenarios | Implement vzdump and backup schedule management | Open | -### Medium (best practice gaps, test coverage holes) +### 🟡 Medium (best practice gaps, test coverage holes) -| # | What | Where | Why | Fix | -|---|---|---|---|---| -| M1 | **`Stop-PveVm`/`Reset-PveVm` lack `ConfirmImpact.High`** | `StopPveVmCmdlet.cs`, `ResetPveVmCmdlet.cs` | These can cause data loss | Add `ConfirmImpact = ConfirmImpact.High` | -| M2 | **No `[ValidateRange]` on VmId** | All cmdlets with VmId parameter | PVE requires VMID 100-999999999 | Add `[ValidateRange(100, 999999999)]` to all VmId parameters | -| M3 | **Container snapshots not supported** | Services/Cmdlets | Containers support snapshots in PVE but module only handles VM snapshots | Add `Get/New/Remove/Restore-PveContainerSnapshot` cmdlets | -| M4 | **No integration tests for network modifications** | Integration tests | Network CRUD is tested only at Pester level | Add integration tests for network create/modify/delete | -| M5 | **No `CODE_OF_CONDUCT.md`** | Repo root | Expected for open-source projects | Add Contributor Covenant | -| M6 | **No `.gitattributes`** | Repo root | Line ending consistency across platforms | Add `* text=auto` and specific overrides | -| M7 | **CS1591 suppressed in Core project** | `PSProxmoxVE.Core.csproj:10` | Public API lacks XML documentation | Remove NoWarn and add XML docs to public members | -| M8 | **No badges in README** | `README.md` | Reduces discoverability and trust | Add build status, PSGallery version, license badges | -| M9 | **CHANGELOG has only [Unreleased]** | `CHANGELOG.md` | No release history | Create first versioned release entry | -| M10 | **`Invoke-PveVmGuestExec` lacks ShouldProcess** | `InvokePveVmGuestExecCmdlet.cs:14` | Executes commands inside a VM -- a mutating operation | Add `SupportsShouldProcess = true` | +| # | What | Where | Why | Fix | Prior Status | +|---|---|---|---|---|---| +| M1 | `Remove-PveRole` missing `ConfirmImpact.High` | `RemovePveRoleCmdlet.cs:13` | Deleting roles is a significant, potentially disruptive operation | Add `ConfirmImpact = ConfirmImpact.High` | New | +| M2 | No pool management | Module | Useful for multi-tenant environments | Implement pool CRUD | Open | +| M3 | `Move-PveVm` / `Move-PveContainer` lack integration tests | Integration tests | Migration is untested end-to-end (requires multi-node) | Add integration tests when multi-node CI is available | Open | +| M4 | `Import-PveOva` lacks integration test | Integration tests | OVA import only tested at parameter level | Add to integration tests (requires OVA test asset) | New | +| M5 | URL-encode snapshot/node names in API paths | `SnapshotService.cs`, `ContainerService.cs` | Defense-in-depth against path injection (low risk) | Apply `Uri.EscapeDataString()` to path parameters | New | -### Low / Nice-to-have (polish, discoverability, stretch goals) +### 🟢 Low / Nice-to-have (polish, discoverability, stretch goals) -| # | What | Where | Why | Fix | -|---|---|---|---|---| -| L1 | **No `IconUri` in manifest** | `PSProxmoxVE.psd1` | Improves PSGallery listing appearance | Create a module icon and host it, add URI | -| L2 | **No online help (MAML/platyPS)** | Module | `Get-Help -Online` doesn't work | Generate MAML help using platyPS | -| L3 | **No firewall cmdlets** | Module | High-value gap for security automation | Implement firewall rule CRUD for cluster/node/VM | -| L4 | **No backup/vzdump cmdlets** | Module | High-value gap for DR automation | Implement vzdump and backup schedule management | -| L5 | **No pool management** | Module | Useful for multi-tenant environments | Implement pool CRUD | -| L6 | **`new Random()` for boundary generation** | `PveHttpClient.cs:393` | Not cryptographically secure (not a security risk for multipart boundaries, but could use `RandomNumberGenerator`) | Consider `RandomNumberGenerator` for .NET 6+ | -| L7 | **No alias support** | Module manifest | Some users prefer short aliases | Consider adding aliases like `cpve` for `Connect-PveServer` | -| L8 | **Tagged releases in GitHub** | Repository | No GitHub Releases | Set up release workflow with auto-generated release notes | -| L9 | **Container migration cmdlet** | Module | VMs have `Move-PveVm` but containers have no equivalent | Add `Move-PveContainer` | -| L10 | **SDN subnet management** | Module | SDN zones/vnets exist but subnets are missing | Add SDN subnet CRUD | +| # | What | Where | Why | Fix | Prior Status | +|---|---|---|---|---|---| +| L1 | No `IconUri` in manifest | `PSProxmoxVE.psd1` | Improves PSGallery listing appearance | Create module icon, host it, add URI | Open | +| L2 | Auth header magic strings | `PveHttpClient.cs:316-323` | Minor code quality — could be named constants | Extract to `const string` fields | Open | +| L3 | HA/Ceph/Replication cmdlets | Module | Lower-priority API coverage gaps | Implement as demand grows | Open | +| L4 | Access groups/domains cmdlets | Module | Useful for LDAP/AD integration | Implement `/access/groups`, `/access/domains` CRUD | Open | +| L5 | PSGallery version badge | `README.md` | Shows published version to visitors | Add PSGallery badge after first publish | New | +| L6 | SDN IPAM/DNS/Controller cmdlets | Module | Complete SDN management surface | Implement as demand grows | New | --- ## Summary Statistics -| Metric | Count | -|---|---| -| Total cmdlets | 66 | -| Cmdlets with ShouldProcess | 43 (all destructive/mutating) | -| Cmdlets with OutputType | 66 (100%) | -| xUnit test files | 13 | -| Pester test files | 26 | -| Integration test contexts | 13 | -| PVE API areas covered | 10 of ~20 major areas | -| Critical issues | 4 | -| High issues | 8 | -| Medium issues | 10 | -| Low issues | 10 | -| NuGet dependencies (runtime) | 2 (Newtonsoft.Json, System.Text.Json) | -| Security vulnerabilities found | 0 | -| Hardcoded secrets | 1 (test password in CI, masked) | +| Metric | This Scan | Prior Scan | Delta | +|---|---|---|---| +| Total cmdlets | 83 | 66 | +17 | +| Cmdlets with ShouldProcess | All mutating | All mutating | — | +| Cmdlets with OutputType | 83 (100%) | 66 (100%) | — | +| Cmdlets with HelpMessage | 83 (100%) | 0 (0%) | +83 | +| xUnit test files | 13 | 13 | — | +| Pester test files | 30 | 26 | +4 | +| Integration test coverage | 84% | ~70% | +14% | +| PVE API areas covered | 12 of ~20 | 10 of ~20 | +2 | +| Critical issues | 0 | 3 | -3 | +| High issues | 2 | 8 | -6 | +| Medium issues | 5 | 10 | -5 | +| Low issues | 6 | 10 | -4 | +| **Total issues** | **13** | **31** | **-18** | +| NuGet dependencies (runtime) | 3 | 2 | +1 (SharpCompress) | +| Security vulnerabilities | 0 | 0 | — | +| Community files present | 9/9 | 3/9 | +6 | +| Documentation files | 78 | 1 | +77 | diff --git a/docs/PVE_API_COVERAGE.md b/docs/PVE_API_COVERAGE.md index 4f4162c..1e204f0 100644 --- a/docs/PVE_API_COVERAGE.md +++ b/docs/PVE_API_COVERAGE.md @@ -2,9 +2,9 @@ This document tracks which PVE API areas are implemented in PSProxmoxVE and which are planned for future releases. -**Last updated:** 2026-03-20 +**Last updated:** 2026-03-21 **Module version:** 0.1.0-preview -**Total cmdlets:** 75 +**Total cmdlets:** 110 ## Implemented @@ -28,17 +28,18 @@ This document tracks which PVE API areas are implemented in PSProxmoxVE and whic | **Templates** | 4 | VM template conversion, listing, cloning, removal | | **Cloud-Init** | 3 | `/nodes/{node}/qemu/{vmid}/config` (cloud-init fields), `/nodes/{node}/qemu/{vmid}/cloudinit/regenerate` | | **Tasks** | 2 | `/nodes/{node}/tasks/{upid}/status` (get, wait) | +| **Firewall** | 21 | `/cluster/firewall/*`, `/nodes/{node}/firewall/*`, `/nodes/{node}/qemu/{vmid}/firewall/*`, `/nodes/{node}/lxc/{vmid}/firewall/*` (rules, groups, aliases, IP sets, options, refs) | +| **Backup** | 5 | `/nodes/{node}/vzdump`, `/cluster/backup/*` (ad-hoc backup, scheduled job CRUD) | +| **SDN IPAM** | 3 | `/cluster/sdn/ipams/*` (CRUD) | +| **SDN DNS** | 3 | `/cluster/sdn/dns/*` (CRUD) | +| **SDN Controllers** | 3 | `/cluster/sdn/controllers/*` (CRUD) | ## Not Yet Implemented -### High-Value Gaps +### Gaps -These are the most impactful areas for real-world automation that are not yet covered. - -| Area | Key Endpoints | Use Case | Priority | -|------|--------------|----------|----------| -| **Firewall** | `/cluster/firewall/*`, `/nodes/{node}/firewall/*`, `/nodes/{node}/qemu/{vmid}/firewall/*` | Security automation, rule management, IP sets, aliases | High | -| **Backup / vzdump** | `/nodes/{node}/vzdump`, `/cluster/backup/*` | Disaster recovery, scheduled backups, backup job management | High | +| Area | Key Endpoints | Notes | Priority | +|------|--------------|-------|----------| | **Pool Management** | `/pools/*` | Multi-tenant environments, resource grouping | Medium | ### Lower Priority Gaps @@ -56,9 +57,6 @@ These are the most impactful areas for real-world automation that are not yet co | **Node Management** | `/nodes/{node}/apt/*`, `/nodes/{node}/disks/*`, `/nodes/{node}/services/*` | Package updates, disk management, service control | | **Metrics** | `/cluster/metrics/*` | External metrics server configuration | | **Additional VM Agent** | `/nodes/{node}/qemu/{vmid}/agent/*` | File read/write, OS info, suspend/resume via agent | -| **SDN IPAM** | `/cluster/sdn/ipams/*` | IP address management for SDN | -| **SDN DNS** | `/cluster/sdn/dns/*` | DNS integration for SDN | -| **SDN Controllers** | `/cluster/sdn/controllers/*` | SDN controller configuration | ## Contributing New Cmdlets diff --git a/src/PSProxmoxVE.Core/Client/PveHttpClient.cs b/src/PSProxmoxVE.Core/Client/PveHttpClient.cs index 76d4512..aa05a05 100644 --- a/src/PSProxmoxVE.Core/Client/PveHttpClient.cs +++ b/src/PSProxmoxVE.Core/Client/PveHttpClient.cs @@ -31,6 +31,10 @@ namespace PSProxmoxVE.Core.Client private readonly HttpClient _httpClient; private bool _disposed; + private const string ApiTokenPrefix = "PVEAPIToken="; + private const string AuthCookieName = "PVEAuthCookie="; + private const string CsrfHeaderName = "CSRFPreventionToken"; + /// /// Creates an HTTP client authenticated with the specified PVE session. /// @@ -313,14 +317,14 @@ namespace PSProxmoxVE.Core.Client if (_session.AuthMode == PveAuthMode.ApiToken) { - request.Headers.TryAddWithoutValidation("Authorization", $"PVEAPIToken={_session.ApiToken}"); + request.Headers.TryAddWithoutValidation("Authorization", $"{ApiTokenPrefix}{_session.ApiToken}"); } else { // Ticket auth - request.Headers.Add("Cookie", $"PVEAuthCookie={_session.Ticket}"); + request.Headers.Add("Cookie", $"{AuthCookieName}{_session.Ticket}"); if (mutating && !string.IsNullOrEmpty(_session.CsrfToken)) - request.Headers.Add("CSRFPreventionToken", _session.CsrfToken); + request.Headers.Add(CsrfHeaderName, _session.CsrfToken); } return request; diff --git a/src/PSProxmoxVE.Core/Models/Backup/PveBackupJob.cs b/src/PSProxmoxVE.Core/Models/Backup/PveBackupJob.cs new file mode 100644 index 0000000..c476e61 --- /dev/null +++ b/src/PSProxmoxVE.Core/Models/Backup/PveBackupJob.cs @@ -0,0 +1,138 @@ +using System.Text.Json.Serialization; +using Newtonsoft.Json; + +namespace PSProxmoxVE.Core.Models.Backup; + +/// +/// Represents a Proxmox VE scheduled backup job as returned by +/// the /cluster/backup endpoint. +/// +public class PveBackupJob +{ + /// + /// The backup job identifier. + /// + [JsonPropertyName("id")] + [JsonProperty("id")] + public string Id { get; set; } = string.Empty; + + /// + /// The job type (typically "vzdump"). + /// + [JsonPropertyName("type")] + [JsonProperty("type")] + public string? Type { get; set; } + + /// + /// Whether the job is enabled (1) or disabled (0). + /// + [JsonPropertyName("enabled")] + [JsonProperty("enabled")] + public int? Enabled { get; set; } + + /// + /// The cron-style schedule (e.g. "0 2 * * *"). + /// + [JsonPropertyName("schedule")] + [JsonProperty("schedule")] + public string? Schedule { get; set; } + + /// + /// The target storage for backup files. + /// + [JsonPropertyName("storage")] + [JsonProperty("storage")] + public string? Storage { get; set; } + + /// + /// Backup mode: "snapshot", "suspend", or "stop". + /// + [JsonPropertyName("mode")] + [JsonProperty("mode")] + public string? Mode { get; set; } + + /// + /// Comma-separated list of VM/container IDs to back up, or empty for all. + /// + [JsonPropertyName("vmid")] + [JsonProperty("vmid")] + public string? VmId { get; set; } + + /// + /// Whether to back up all VMs/containers (1 = all). + /// + [JsonPropertyName("all")] + [JsonProperty("all")] + public int? All { get; set; } + + /// + /// Compression algorithm (e.g. "zstd", "lzo", "gzip"). + /// + [JsonPropertyName("compress")] + [JsonProperty("compress")] + public string? Compress { get; set; } + + /// + /// Deprecated — maximum number of backup files to keep. Use prune-backups instead. + /// + [JsonPropertyName("maxfiles")] + [JsonProperty("maxfiles")] + public int? MaxFiles { get; set; } + + /// + /// Retention policy string (e.g. "keep-daily=7,keep-weekly=4"). + /// + [JsonPropertyName("prune-backups")] + [JsonProperty("prune-backups")] + public string? PruneBackups { get; set; } + + /// + /// Template for backup notes. + /// + [JsonPropertyName("notes-template")] + [JsonProperty("notes-template")] + public string? NotesTemplate { get; set; } + + /// + /// Optional comment describing the backup job. + /// + [JsonPropertyName("comment")] + [JsonProperty("comment")] + public string? Comment { get; set; } + + /// + /// Mail notification setting (e.g. "always", "failure"). + /// + [JsonPropertyName("mailnotification")] + [JsonProperty("mailnotification")] + public string? MailNotification { get; set; } + + /// + /// Email address to send notifications to. + /// + [JsonPropertyName("mailto")] + [JsonProperty("mailto")] + public string? MailTo { get; set; } + + /// + /// Specific node to run the backup on (empty = any node). + /// + [JsonPropertyName("node")] + [JsonProperty("node")] + public string? Node { get; set; } + + /// + /// Comma-separated list of excluded VM/container IDs. + /// + [JsonPropertyName("exclude")] + [JsonProperty("exclude")] + public string? Exclude { get; set; } + + /// + public override string ToString() + { + var state = Enabled == 1 ? "Enabled" : "Disabled"; + return $"Backup Job: {Id} | {state} | Schedule: {Schedule ?? "N/A"} | " + + $"Storage: {Storage ?? "N/A"} | Mode: {Mode ?? "N/A"}"; + } +} diff --git a/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallAlias.cs b/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallAlias.cs new file mode 100644 index 0000000..9d7e517 --- /dev/null +++ b/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallAlias.cs @@ -0,0 +1,38 @@ +using System.Text.Json.Serialization; +using Newtonsoft.Json; + +namespace PSProxmoxVE.Core.Models.Firewall; + +/// +/// Represents a Proxmox VE firewall IP alias as returned by +/// the firewall/aliases endpoints. +/// +public class PveFirewallAlias +{ + /// + /// The alias name. + /// + [JsonPropertyName("name")] + [JsonProperty("name")] + public string Name { get; set; } = string.Empty; + + /// + /// The CIDR network or IP address (e.g. "10.0.0.0/8" or "192.168.1.1"). + /// + [JsonPropertyName("cidr")] + [JsonProperty("cidr")] + public string Cidr { get; set; } = string.Empty; + + /// + /// Optional comment describing the alias. + /// + [JsonPropertyName("comment")] + [JsonProperty("comment")] + public string? Comment { get; set; } + + /// + public override string ToString() + { + return $"Alias: {Name} → {Cidr}"; + } +} diff --git a/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallGroup.cs b/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallGroup.cs new file mode 100644 index 0000000..7414d8f --- /dev/null +++ b/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallGroup.cs @@ -0,0 +1,38 @@ +using System.Text.Json.Serialization; +using Newtonsoft.Json; + +namespace PSProxmoxVE.Core.Models.Firewall; + +/// +/// Represents a Proxmox VE firewall security group as returned by +/// the /cluster/firewall/groups endpoint. +/// +public class PveFirewallGroup +{ + /// + /// The security group name. + /// + [JsonPropertyName("group")] + [JsonProperty("group")] + public string Group { get; set; } = string.Empty; + + /// + /// Optional comment describing the group. + /// + [JsonPropertyName("comment")] + [JsonProperty("comment")] + public string? Comment { get; set; } + + /// + /// Configuration digest for detecting concurrent modifications. + /// + [JsonPropertyName("digest")] + [JsonProperty("digest")] + public string? Digest { get; set; } + + /// + public override string ToString() + { + return $"Firewall Group: {Group} | Comment: {Comment ?? "N/A"}"; + } +} diff --git a/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallIpSet.cs b/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallIpSet.cs new file mode 100644 index 0000000..328eb6f --- /dev/null +++ b/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallIpSet.cs @@ -0,0 +1,38 @@ +using System.Text.Json.Serialization; +using Newtonsoft.Json; + +namespace PSProxmoxVE.Core.Models.Firewall; + +/// +/// Represents a Proxmox VE firewall IP set as returned by +/// the firewall/ipset endpoints. +/// +public class PveFirewallIpSet +{ + /// + /// The IP set name. + /// + [JsonPropertyName("name")] + [JsonProperty("name")] + public string Name { get; set; } = string.Empty; + + /// + /// Optional comment describing the IP set. + /// + [JsonPropertyName("comment")] + [JsonProperty("comment")] + public string? Comment { get; set; } + + /// + /// Configuration digest for detecting concurrent modifications. + /// + [JsonPropertyName("digest")] + [JsonProperty("digest")] + public string? Digest { get; set; } + + /// + public override string ToString() + { + return $"IP Set: {Name} | Comment: {Comment ?? "N/A"}"; + } +} diff --git a/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallIpSetEntry.cs b/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallIpSetEntry.cs new file mode 100644 index 0000000..4d58372 --- /dev/null +++ b/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallIpSetEntry.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; +using Newtonsoft.Json; + +namespace PSProxmoxVE.Core.Models.Firewall; + +/// +/// Represents an entry within a Proxmox VE firewall IP set as returned by +/// the firewall/ipset/{name} endpoints. +/// +public class PveFirewallIpSetEntry +{ + /// + /// The CIDR network or IP address (e.g. "192.168.1.0/24" or "10.0.0.1"). + /// + [JsonPropertyName("cidr")] + [JsonProperty("cidr")] + public string Cidr { get; set; } = string.Empty; + + /// + /// Whether this is an exclusion entry (1 = exclude from set). + /// + [JsonPropertyName("nomatch")] + [JsonProperty("nomatch")] + public int? NoMatch { get; set; } + + /// + /// Optional comment describing the entry. + /// + [JsonPropertyName("comment")] + [JsonProperty("comment")] + public string? Comment { get; set; } + + /// + public override string ToString() + { + var prefix = NoMatch == 1 ? "!" : ""; + return $"{prefix}{Cidr}"; + } +} diff --git a/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallOptions.cs b/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallOptions.cs new file mode 100644 index 0000000..c25623f --- /dev/null +++ b/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallOptions.cs @@ -0,0 +1,88 @@ +using System.Text.Json.Serialization; +using Newtonsoft.Json; + +namespace PSProxmoxVE.Core.Models.Firewall; + +/// +/// Represents Proxmox VE firewall options as returned by +/// the firewall/options endpoints at cluster, node, VM, or container level. +/// +public class PveFirewallOptions +{ + /// + /// Whether the firewall is enabled (1) or disabled (0). + /// + [JsonPropertyName("enable")] + [JsonProperty("enable")] + public int? Enable { get; set; } + + /// + /// Default policy for incoming traffic (ACCEPT, DROP, REJECT). + /// + [JsonPropertyName("policy_in")] + [JsonProperty("policy_in")] + public string? PolicyIn { get; set; } + + /// + /// Default policy for outgoing traffic (ACCEPT, DROP, REJECT). + /// + [JsonPropertyName("policy_out")] + [JsonProperty("policy_out")] + public string? PolicyOut { get; set; } + + /// + /// Log level for incoming traffic (nolog, emerg, alert, crit, err, warning, notice, info, debug). + /// + [JsonPropertyName("log_level_in")] + [JsonProperty("log_level_in")] + public string? LogLevelIn { get; set; } + + /// + /// Log level for outgoing traffic. + /// + [JsonPropertyName("log_level_out")] + [JsonProperty("log_level_out")] + public string? LogLevelOut { get; set; } + + /// + /// Whether to allow DHCP traffic (VM/container level). + /// + [JsonPropertyName("dhcp")] + [JsonProperty("dhcp")] + public int? Dhcp { get; set; } + + /// + /// Whether to allow NDP (IPv6 Neighbor Discovery Protocol). + /// + [JsonPropertyName("ndp")] + [JsonProperty("ndp")] + public int? Ndp { get; set; } + + /// + /// Whether to allow Router Advertisement. + /// + [JsonPropertyName("radv")] + [JsonProperty("radv")] + public int? Radv { get; set; } + + /// + /// Whether to enable MAC address filter (VM/container level). + /// + [JsonPropertyName("macfilter")] + [JsonProperty("macfilter")] + public int? MacFilter { get; set; } + + /// + /// Whether to enable IP filter (VM/container level). + /// + [JsonPropertyName("ipfilter")] + [JsonProperty("ipfilter")] + public int? IpFilter { get; set; } + + /// + public override string ToString() + { + var state = Enable == 1 ? "Enabled" : "Disabled"; + return $"Firewall: {state} | In: {PolicyIn ?? "N/A"} | Out: {PolicyOut ?? "N/A"}"; + } +} diff --git a/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallRef.cs b/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallRef.cs new file mode 100644 index 0000000..29491f8 --- /dev/null +++ b/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallRef.cs @@ -0,0 +1,38 @@ +using System.Text.Json.Serialization; +using Newtonsoft.Json; + +namespace PSProxmoxVE.Core.Models.Firewall; + +/// +/// Represents a Proxmox VE firewall reference item (alias, ipset, etc.) +/// as returned by the firewall/refs endpoints. +/// +public class PveFirewallRef +{ + /// + /// The reference type (e.g. "alias", "ipset"). + /// + [JsonPropertyName("type")] + [JsonProperty("type")] + public string Type { get; set; } = string.Empty; + + /// + /// The reference name. + /// + [JsonPropertyName("name")] + [JsonProperty("name")] + public string Name { get; set; } = string.Empty; + + /// + /// Optional comment. + /// + [JsonPropertyName("comment")] + [JsonProperty("comment")] + public string? Comment { get; set; } + + /// + public override string ToString() + { + return $"{Type}: {Name}"; + } +} diff --git a/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallRule.cs b/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallRule.cs new file mode 100644 index 0000000..eba503b --- /dev/null +++ b/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallRule.cs @@ -0,0 +1,111 @@ +using System.Text.Json.Serialization; +using Newtonsoft.Json; + +namespace PSProxmoxVE.Core.Models.Firewall; + +/// +/// Represents a Proxmox VE firewall rule as returned by +/// the firewall/rules endpoints at cluster, node, VM, or container level. +/// +public class PveFirewallRule +{ + /// + /// Rule position (used for ordering and as identifier for updates/deletes). + /// + [JsonPropertyName("pos")] + [JsonProperty("pos")] + public int? Pos { get; set; } + + /// + /// Rule type: "in", "out", or "group". + /// + [JsonPropertyName("type")] + [JsonProperty("type")] + public string Type { get; set; } = string.Empty; + + /// + /// Action to take: ACCEPT, DROP, or REJECT. + /// + [JsonPropertyName("action")] + [JsonProperty("action")] + public string Action { get; set; } = string.Empty; + + /// + /// Whether the rule is enabled (1) or disabled (0). + /// + [JsonPropertyName("enable")] + [JsonProperty("enable")] + public int? Enable { get; set; } + + /// + /// Source address or alias. + /// + [JsonPropertyName("source")] + [JsonProperty("source")] + public string? Source { get; set; } + + /// + /// Destination address or alias. + /// + [JsonPropertyName("dest")] + [JsonProperty("dest")] + public string? Dest { get; set; } + + /// + /// Protocol (e.g. tcp, udp, icmp). + /// + [JsonPropertyName("proto")] + [JsonProperty("proto")] + public string? Proto { get; set; } + + /// + /// Destination port or port range. + /// + [JsonPropertyName("dport")] + [JsonProperty("dport")] + public string? Dport { get; set; } + + /// + /// Source port or port range. + /// + [JsonPropertyName("sport")] + [JsonProperty("sport")] + public string? Sport { get; set; } + + /// + /// Optional comment describing the rule. + /// + [JsonPropertyName("comment")] + [JsonProperty("comment")] + public string? Comment { get; set; } + + /// + /// Predefined macro name (e.g. "SSH", "HTTP", "DNS"). + /// + [JsonPropertyName("macro")] + [JsonProperty("macro")] + public string? Macro { get; set; } + + /// + /// Log level for matched packets (e.g. "nolog", "info", "warning"). + /// + [JsonPropertyName("log")] + [JsonProperty("log")] + public string? Log { get; set; } + + /// + /// Network interface to match (e.g. "net0"). + /// + [JsonPropertyName("iface")] + [JsonProperty("iface")] + public string? Iface { get; set; } + + /// + public override string ToString() + { + var action = Macro != null ? $"{Action} ({Macro})" : Action; + return $"Rule {Pos}: {Type} {action} | " + + $"Src: {Source ?? "any"} → Dst: {Dest ?? "any"} | " + + $"Proto: {Proto ?? "any"} DPort: {Dport ?? "any"}"; + } +} diff --git a/src/PSProxmoxVE.Core/Models/Network/PveSdnController.cs b/src/PSProxmoxVE.Core/Models/Network/PveSdnController.cs new file mode 100644 index 0000000..425dd13 --- /dev/null +++ b/src/PSProxmoxVE.Core/Models/Network/PveSdnController.cs @@ -0,0 +1,52 @@ +using System.Text.Json.Serialization; +using Newtonsoft.Json; + +namespace PSProxmoxVE.Core.Models.Network; + +/// +/// Represents a Software-Defined Networking (SDN) controller as returned by +/// the /cluster/sdn/controllers endpoint. Requires Proxmox VE 8.0+. +/// +public class PveSdnController +{ + /// + /// The controller identifier. + /// + [JsonPropertyName("controller")] + [JsonProperty("controller")] + public string Controller { get; set; } = string.Empty; + + /// + /// The controller type (e.g. "evpn", "bgp"). + /// + [JsonPropertyName("type")] + [JsonProperty("type")] + public string Type { get; set; } = string.Empty; + + /// + /// The Autonomous System Number for BGP/EVPN. + /// + [JsonPropertyName("asn")] + [JsonProperty("asn")] + public int? Asn { get; set; } + + /// + /// Comma-separated list of BGP peer addresses. + /// + [JsonPropertyName("peers")] + [JsonProperty("peers")] + public string? Peers { get; set; } + + /// + /// The node this controller is configured on. + /// + [JsonPropertyName("node")] + [JsonProperty("node")] + public string? Node { get; set; } + + /// + public override string ToString() + { + return $"SDN Controller: {Controller} | Type: {Type} | ASN: {Asn?.ToString() ?? "N/A"}"; + } +} diff --git a/src/PSProxmoxVE.Core/Models/Network/PveSdnDns.cs b/src/PSProxmoxVE.Core/Models/Network/PveSdnDns.cs new file mode 100644 index 0000000..f825d49 --- /dev/null +++ b/src/PSProxmoxVE.Core/Models/Network/PveSdnDns.cs @@ -0,0 +1,59 @@ +using System.Text.Json.Serialization; +using Newtonsoft.Json; + +namespace PSProxmoxVE.Core.Models.Network; + +/// +/// Represents a Software-Defined Networking (SDN) DNS plugin as returned by +/// the /cluster/sdn/dns endpoint. Requires Proxmox VE 8.0+. +/// +public class PveSdnDns +{ + /// + /// The DNS plugin identifier. + /// + [JsonPropertyName("dns")] + [JsonProperty("dns")] + public string Dns { get; set; } = string.Empty; + + /// + /// The DNS plugin type (e.g. "powerdns"). + /// + [JsonPropertyName("type")] + [JsonProperty("type")] + public string Type { get; set; } = string.Empty; + + /// + /// The URL of the DNS service API. + /// + [JsonPropertyName("url")] + [JsonProperty("url")] + public string? Url { get; set; } + + /// + /// The API key for the DNS service. + /// + [JsonPropertyName("key")] + [JsonProperty("key")] + public string? Key { get; set; } + + /// + /// The IPv6 reverse zone mask length. + /// + [JsonPropertyName("reversemaskv6")] + [JsonProperty("reversemaskv6")] + public int? ReverseMaskV6 { get; set; } + + /// + /// The TTL (time-to-live) for DNS records. + /// + [JsonPropertyName("ttl")] + [JsonProperty("ttl")] + public int? Ttl { get; set; } + + /// + public override string ToString() + { + return $"SDN DNS: {Dns} | Type: {Type} | URL: {Url ?? "N/A"}"; + } +} diff --git a/src/PSProxmoxVE.Core/Models/Network/PveSdnIpam.cs b/src/PSProxmoxVE.Core/Models/Network/PveSdnIpam.cs new file mode 100644 index 0000000..0c04a7e --- /dev/null +++ b/src/PSProxmoxVE.Core/Models/Network/PveSdnIpam.cs @@ -0,0 +1,52 @@ +using System.Text.Json.Serialization; +using Newtonsoft.Json; + +namespace PSProxmoxVE.Core.Models.Network; + +/// +/// Represents a Software-Defined Networking (SDN) IPAM plugin as returned by +/// the /cluster/sdn/ipams endpoint. Requires Proxmox VE 8.0+. +/// +public class PveSdnIpam +{ + /// + /// The IPAM plugin identifier. + /// + [JsonPropertyName("ipam")] + [JsonProperty("ipam")] + public string Ipam { get; set; } = string.Empty; + + /// + /// The IPAM type (e.g. "pve", "netbox", "phpipam"). + /// + [JsonPropertyName("type")] + [JsonProperty("type")] + public string Type { get; set; } = string.Empty; + + /// + /// The URL of the external IPAM service (for netbox/phpipam types). + /// + [JsonPropertyName("url")] + [JsonProperty("url")] + public string? Url { get; set; } + + /// + /// The API token for the external IPAM service. + /// + [JsonPropertyName("token")] + [JsonProperty("token")] + public string? Token { get; set; } + + /// + /// The configuration section identifier. + /// + [JsonPropertyName("section")] + [JsonProperty("section")] + public int? Section { get; set; } + + /// + public override string ToString() + { + return $"SDN IPAM: {Ipam} | Type: {Type}"; + } +} diff --git a/src/PSProxmoxVE.Core/Services/BackupService.cs b/src/PSProxmoxVE.Core/Services/BackupService.cs new file mode 100644 index 0000000..53a2dc6 --- /dev/null +++ b/src/PSProxmoxVE.Core/Services/BackupService.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using PSProxmoxVE.Core.Authentication; +using PSProxmoxVE.Core.Client; +using PSProxmoxVE.Core.Models.Backup; +using PSProxmoxVE.Core.Models.Vms; + +namespace PSProxmoxVE.Core.Services +{ + /// + /// Service for Proxmox VE Backup (vzdump) and backup job API operations. + /// + public class BackupService + { + // ------------------------------------------------------------------------- + // Ad-hoc backup (vzdump) + // ------------------------------------------------------------------------- + + /// + /// Creates an ad-hoc backup via vzdump. Returns the task UPID. + /// + /// The authenticated PVE session. + /// The node to run the backup on. + /// Backup configuration parameters. + public PveTask CreateBackup(PveSession session, string node, Dictionary config) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node)); + if (config == null) throw new ArgumentNullException(nameof(config)); + + using var client = new PveHttpClient(session); + var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/vzdump", config) + .GetAwaiter().GetResult(); + return ParseTask(response, node); + } + + // ------------------------------------------------------------------------- + // Backup jobs (scheduled) + // ------------------------------------------------------------------------- + + /// + /// Returns all scheduled backup jobs. + /// + public PveBackupJob[] GetBackupJobs(PveSession session) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + + using var client = new PveHttpClient(session); + var response = client.GetAsync("cluster/backup").GetAwaiter().GetResult(); + var data = JObject.Parse(response)["data"]; + return data?.ToObject() ?? Array.Empty(); + } + + /// + /// Returns a single scheduled backup job by ID. + /// + public PveBackupJob? GetBackupJob(PveSession session, string id) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (string.IsNullOrWhiteSpace(id)) throw new ArgumentNullException(nameof(id)); + + using var client = new PveHttpClient(session); + var response = client.GetAsync($"cluster/backup/{Uri.EscapeDataString(id)}") + .GetAwaiter().GetResult(); + var data = JObject.Parse(response)["data"]; + return data?.ToObject(); + } + + /// + /// Creates a scheduled backup job. + /// + public void CreateBackupJob(PveSession session, Dictionary config) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (config == null) throw new ArgumentNullException(nameof(config)); + + using var client = new PveHttpClient(session); + client.PostAsync("cluster/backup", config).GetAwaiter().GetResult(); + } + + /// + /// Updates a scheduled backup job. + /// + public void UpdateBackupJob(PveSession session, string id, Dictionary config) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (string.IsNullOrWhiteSpace(id)) throw new ArgumentNullException(nameof(id)); + if (config == null) throw new ArgumentNullException(nameof(config)); + + using var client = new PveHttpClient(session); + client.PutAsync($"cluster/backup/{Uri.EscapeDataString(id)}", config) + .GetAwaiter().GetResult(); + } + + /// + /// Removes a scheduled backup job. + /// + public void RemoveBackupJob(PveSession session, string id) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (string.IsNullOrWhiteSpace(id)) throw new ArgumentNullException(nameof(id)); + + using var client = new PveHttpClient(session); + client.DeleteAsync($"cluster/backup/{Uri.EscapeDataString(id)}") + .GetAwaiter().GetResult(); + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private static PveTask ParseTask(string response, string node) + { + var data = JObject.Parse(response)["data"]; + if (data?.Type == JTokenType.String) + return new PveTask { Upid = data.ToString(), Node = node }; + + var task = data?.ToObject() ?? new PveTask(); + task.Node = node; + return task; + } + } +} diff --git a/src/PSProxmoxVE.Core/Services/ContainerService.cs b/src/PSProxmoxVE.Core/Services/ContainerService.cs index 84cd829..040ce7a 100644 --- a/src/PSProxmoxVE.Core/Services/ContainerService.cs +++ b/src/PSProxmoxVE.Core/Services/ContainerService.cs @@ -173,7 +173,7 @@ namespace PSProxmoxVE.Core.Services if (string.IsNullOrWhiteSpace(snapname)) throw new ArgumentNullException(nameof(snapname)); using var client = new PveHttpClient(session); - var response = client.DeleteAsync($"nodes/{node}/lxc/{vmid}/snapshot/{snapname}") + var response = client.DeleteAsync($"nodes/{node}/lxc/{vmid}/snapshot/{Uri.EscapeDataString(snapname)}") .GetAwaiter().GetResult(); return ParseTask(response, node); } @@ -192,7 +192,7 @@ namespace PSProxmoxVE.Core.Services if (string.IsNullOrWhiteSpace(snapname)) throw new ArgumentNullException(nameof(snapname)); using var client = new PveHttpClient(session); - var response = client.PostAsync($"nodes/{node}/lxc/{vmid}/snapshot/{snapname}/rollback") + var response = client.PostAsync($"nodes/{node}/lxc/{vmid}/snapshot/{Uri.EscapeDataString(snapname)}/rollback") .GetAwaiter().GetResult(); return ParseTask(response, node); } diff --git a/src/PSProxmoxVE.Core/Services/FirewallService.cs b/src/PSProxmoxVE.Core/Services/FirewallService.cs new file mode 100644 index 0000000..8e8ced3 --- /dev/null +++ b/src/PSProxmoxVE.Core/Services/FirewallService.cs @@ -0,0 +1,478 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using PSProxmoxVE.Core.Authentication; +using PSProxmoxVE.Core.Client; +using PSProxmoxVE.Core.Models.Firewall; + +namespace PSProxmoxVE.Core.Services +{ + /// + /// Service for Proxmox VE Firewall API operations. + /// Supports firewall management at cluster, node, VM, and container levels. + /// + public class FirewallService + { + // ------------------------------------------------------------------------- + // Rules + // ------------------------------------------------------------------------- + + /// + /// Returns firewall rules at the specified level. + /// + public PveFirewallRule[] GetRules(PveSession session, string level, string? node = null, int? vmid = null) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + var basePath = BuildBasePath(level, node, vmid); + + using var client = new PveHttpClient(session); + var response = client.GetAsync($"{basePath}/rules").GetAwaiter().GetResult(); + var data = JObject.Parse(response)["data"]; + return data?.ToObject() ?? Array.Empty(); + } + + /// + /// Creates a firewall rule at the specified level. + /// + public void CreateRule(PveSession session, string level, Dictionary config, + string? node = null, int? vmid = null) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (config == null) throw new ArgumentNullException(nameof(config)); + var basePath = BuildBasePath(level, node, vmid); + + using var client = new PveHttpClient(session); + client.PostAsync($"{basePath}/rules", config).GetAwaiter().GetResult(); + } + + /// + /// Updates a firewall rule at the specified position. + /// + public void UpdateRule(PveSession session, string level, int pos, Dictionary config, + string? node = null, int? vmid = null) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (config == null) throw new ArgumentNullException(nameof(config)); + var basePath = BuildBasePath(level, node, vmid); + + using var client = new PveHttpClient(session); + client.PutAsync($"{basePath}/rules/{pos}", config).GetAwaiter().GetResult(); + } + + /// + /// Removes a firewall rule at the specified position. + /// + public void RemoveRule(PveSession session, string level, int pos, + string? node = null, int? vmid = null) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + var basePath = BuildBasePath(level, node, vmid); + + using var client = new PveHttpClient(session); + client.DeleteAsync($"{basePath}/rules/{pos}").GetAwaiter().GetResult(); + } + + // ------------------------------------------------------------------------- + // Security Groups (cluster level only) + // ------------------------------------------------------------------------- + + /// + /// Returns all firewall security groups. + /// + public PveFirewallGroup[] GetGroups(PveSession session) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + + using var client = new PveHttpClient(session); + var response = client.GetAsync("cluster/firewall/groups").GetAwaiter().GetResult(); + var data = JObject.Parse(response)["data"]; + return data?.ToObject() ?? Array.Empty(); + } + + /// + /// Creates a firewall security group. + /// + public void CreateGroup(PveSession session, string name, string? comment = null) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullException(nameof(name)); + + var formData = new Dictionary { ["group"] = name }; + if (!string.IsNullOrEmpty(comment)) + formData["comment"] = comment!; + + using var client = new PveHttpClient(session); + client.PostAsync("cluster/firewall/groups", formData).GetAwaiter().GetResult(); + } + + /// + /// Removes a firewall security group. + /// + public void RemoveGroup(PveSession session, string name) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullException(nameof(name)); + + using var client = new PveHttpClient(session); + client.DeleteAsync($"cluster/firewall/groups/{Uri.EscapeDataString(name)}") + .GetAwaiter().GetResult(); + } + + /// + /// Returns firewall rules within a security group. + /// + public PveFirewallRule[] GetGroupRules(PveSession session, string group) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (string.IsNullOrWhiteSpace(group)) throw new ArgumentNullException(nameof(group)); + + using var client = new PveHttpClient(session); + var response = client.GetAsync($"cluster/firewall/groups/{Uri.EscapeDataString(group)}") + .GetAwaiter().GetResult(); + var data = JObject.Parse(response)["data"]; + return data?.ToObject() ?? Array.Empty(); + } + + /// + /// Creates a rule within a security group. + /// + public void CreateGroupRule(PveSession session, string group, Dictionary config) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (string.IsNullOrWhiteSpace(group)) throw new ArgumentNullException(nameof(group)); + if (config == null) throw new ArgumentNullException(nameof(config)); + + using var client = new PveHttpClient(session); + client.PostAsync($"cluster/firewall/groups/{Uri.EscapeDataString(group)}", config) + .GetAwaiter().GetResult(); + } + + /// + /// Updates a rule within a security group. + /// + public void UpdateGroupRule(PveSession session, string group, int pos, Dictionary config) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (string.IsNullOrWhiteSpace(group)) throw new ArgumentNullException(nameof(group)); + if (config == null) throw new ArgumentNullException(nameof(config)); + + using var client = new PveHttpClient(session); + client.PutAsync($"cluster/firewall/groups/{Uri.EscapeDataString(group)}/{pos}", config) + .GetAwaiter().GetResult(); + } + + /// + /// Removes a rule from a security group. + /// + public void RemoveGroupRule(PveSession session, string group, int pos) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (string.IsNullOrWhiteSpace(group)) throw new ArgumentNullException(nameof(group)); + + using var client = new PveHttpClient(session); + client.DeleteAsync($"cluster/firewall/groups/{Uri.EscapeDataString(group)}/{pos}") + .GetAwaiter().GetResult(); + } + + // ------------------------------------------------------------------------- + // Aliases + // ------------------------------------------------------------------------- + + /// + /// Returns firewall aliases at the specified level. + /// + public PveFirewallAlias[] GetAliases(PveSession session, string level, string? node = null, int? vmid = null) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + var basePath = BuildBasePath(level, node, vmid); + + using var client = new PveHttpClient(session); + var response = client.GetAsync($"{basePath}/aliases").GetAwaiter().GetResult(); + var data = JObject.Parse(response)["data"]; + return data?.ToObject() ?? Array.Empty(); + } + + /// + /// Creates a firewall alias. + /// + public void CreateAlias(PveSession session, string level, string name, string cidr, + string? comment = null, string? node = null, int? vmid = null) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullException(nameof(name)); + if (string.IsNullOrWhiteSpace(cidr)) throw new ArgumentNullException(nameof(cidr)); + var basePath = BuildBasePath(level, node, vmid); + + var formData = new Dictionary + { + ["name"] = name, + ["cidr"] = cidr + }; + if (!string.IsNullOrEmpty(comment)) + formData["comment"] = comment!; + + using var client = new PveHttpClient(session); + client.PostAsync($"{basePath}/aliases", formData).GetAwaiter().GetResult(); + } + + /// + /// Updates a firewall alias. + /// + public void UpdateAlias(PveSession session, string level, string name, + string? cidr = null, string? comment = null, string? node = null, int? vmid = null) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullException(nameof(name)); + var basePath = BuildBasePath(level, node, vmid); + + var formData = new Dictionary(); + if (!string.IsNullOrEmpty(cidr)) + formData["cidr"] = cidr!; + if (!string.IsNullOrEmpty(comment)) + formData["comment"] = comment!; + + using var client = new PveHttpClient(session); + client.PutAsync($"{basePath}/aliases/{Uri.EscapeDataString(name)}", formData) + .GetAwaiter().GetResult(); + } + + /// + /// Removes a firewall alias. + /// + public void RemoveAlias(PveSession session, string level, string name, + string? node = null, int? vmid = null) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullException(nameof(name)); + var basePath = BuildBasePath(level, node, vmid); + + using var client = new PveHttpClient(session); + client.DeleteAsync($"{basePath}/aliases/{Uri.EscapeDataString(name)}") + .GetAwaiter().GetResult(); + } + + // ------------------------------------------------------------------------- + // IP Sets + // ------------------------------------------------------------------------- + + /// + /// Returns firewall IP sets at the specified level. + /// + public PveFirewallIpSet[] GetIpSets(PveSession session, string level, string? node = null, int? vmid = null) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + var basePath = BuildBasePath(level, node, vmid); + + using var client = new PveHttpClient(session); + var response = client.GetAsync($"{basePath}/ipset").GetAwaiter().GetResult(); + var data = JObject.Parse(response)["data"]; + return data?.ToObject() ?? Array.Empty(); + } + + /// + /// Creates a firewall IP set. + /// + public void CreateIpSet(PveSession session, string level, string name, + string? comment = null, string? node = null, int? vmid = null) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullException(nameof(name)); + var basePath = BuildBasePath(level, node, vmid); + + var formData = new Dictionary { ["name"] = name }; + if (!string.IsNullOrEmpty(comment)) + formData["comment"] = comment!; + + using var client = new PveHttpClient(session); + client.PostAsync($"{basePath}/ipset", formData).GetAwaiter().GetResult(); + } + + /// + /// Removes a firewall IP set. + /// + public void RemoveIpSet(PveSession session, string level, string name, + string? node = null, int? vmid = null) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullException(nameof(name)); + var basePath = BuildBasePath(level, node, vmid); + + using var client = new PveHttpClient(session); + client.DeleteAsync($"{basePath}/ipset/{Uri.EscapeDataString(name)}") + .GetAwaiter().GetResult(); + } + + // ------------------------------------------------------------------------- + // IP Set Entries + // ------------------------------------------------------------------------- + + /// + /// Returns entries within an IP set. + /// + public PveFirewallIpSetEntry[] GetIpSetEntries(PveSession session, string level, string name, + string? node = null, int? vmid = null) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullException(nameof(name)); + var basePath = BuildBasePath(level, node, vmid); + + using var client = new PveHttpClient(session); + var response = client.GetAsync($"{basePath}/ipset/{Uri.EscapeDataString(name)}") + .GetAwaiter().GetResult(); + var data = JObject.Parse(response)["data"]; + return data?.ToObject() ?? Array.Empty(); + } + + /// + /// Adds an entry to an IP set. + /// + public void AddIpSetEntry(PveSession session, string level, string name, string cidr, + bool nomatch = false, string? comment = null, string? node = null, int? vmid = null) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullException(nameof(name)); + if (string.IsNullOrWhiteSpace(cidr)) throw new ArgumentNullException(nameof(cidr)); + var basePath = BuildBasePath(level, node, vmid); + + var formData = new Dictionary { ["cidr"] = cidr }; + if (nomatch) + formData["nomatch"] = "1"; + if (!string.IsNullOrEmpty(comment)) + formData["comment"] = comment!; + + using var client = new PveHttpClient(session); + client.PostAsync($"{basePath}/ipset/{Uri.EscapeDataString(name)}", formData) + .GetAwaiter().GetResult(); + } + + /// + /// Updates an entry in an IP set. + /// + public void UpdateIpSetEntry(PveSession session, string level, string name, string cidr, + bool? nomatch = null, string? comment = null, string? node = null, int? vmid = null) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullException(nameof(name)); + if (string.IsNullOrWhiteSpace(cidr)) throw new ArgumentNullException(nameof(cidr)); + var basePath = BuildBasePath(level, node, vmid); + + var formData = new Dictionary(); + if (nomatch.HasValue) + formData["nomatch"] = nomatch.Value ? "1" : "0"; + if (!string.IsNullOrEmpty(comment)) + formData["comment"] = comment!; + + using var client = new PveHttpClient(session); + client.PutAsync( + $"{basePath}/ipset/{Uri.EscapeDataString(name)}/{Uri.EscapeDataString(cidr)}", + formData).GetAwaiter().GetResult(); + } + + /// + /// Removes an entry from an IP set. + /// + public void RemoveIpSetEntry(PveSession session, string level, string name, string cidr, + string? node = null, int? vmid = null) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullException(nameof(name)); + if (string.IsNullOrWhiteSpace(cidr)) throw new ArgumentNullException(nameof(cidr)); + var basePath = BuildBasePath(level, node, vmid); + + using var client = new PveHttpClient(session); + client.DeleteAsync( + $"{basePath}/ipset/{Uri.EscapeDataString(name)}/{Uri.EscapeDataString(cidr)}") + .GetAwaiter().GetResult(); + } + + // ------------------------------------------------------------------------- + // Options + // ------------------------------------------------------------------------- + + /// + /// Returns firewall options at the specified level. + /// + public PveFirewallOptions GetOptions(PveSession session, string level, + string? node = null, int? vmid = null) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + var basePath = BuildBasePath(level, node, vmid); + + using var client = new PveHttpClient(session); + var response = client.GetAsync($"{basePath}/options").GetAwaiter().GetResult(); + var data = JObject.Parse(response)["data"]; + return data?.ToObject() ?? new PveFirewallOptions(); + } + + /// + /// Sets firewall options at the specified level. + /// + public void SetOptions(PveSession session, string level, Dictionary config, + string? node = null, int? vmid = null) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (config == null) throw new ArgumentNullException(nameof(config)); + var basePath = BuildBasePath(level, node, vmid); + + using var client = new PveHttpClient(session); + client.PutAsync($"{basePath}/options", config).GetAwaiter().GetResult(); + } + + // ------------------------------------------------------------------------- + // Refs + // ------------------------------------------------------------------------- + + /// + /// Returns firewall references (aliases, IP sets) at the specified level. + /// + public PveFirewallRef[] GetRefs(PveSession session, string level, string? type = null, + string? node = null, int? vmid = null) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + var basePath = BuildBasePath(level, node, vmid); + + var resource = $"{basePath}/refs"; + if (!string.IsNullOrEmpty(type)) + resource += $"?type={Uri.EscapeDataString(type!)}"; + + using var client = new PveHttpClient(session); + var response = client.GetAsync(resource).GetAwaiter().GetResult(); + var data = JObject.Parse(response)["data"]; + return data?.ToObject() ?? Array.Empty(); + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /// + /// Builds the API base path for the specified firewall level. + /// + private static string BuildBasePath(string level, string? node, int? vmid) + { + switch (level.ToLowerInvariant()) + { + case "cluster": + return "cluster/firewall"; + case "node": + if (string.IsNullOrWhiteSpace(node)) + throw new ArgumentException("Node is required for node-level firewall operations.", nameof(node)); + return $"nodes/{Uri.EscapeDataString(node)}/firewall"; + case "vm": + if (string.IsNullOrWhiteSpace(node)) + throw new ArgumentException("Node is required for VM-level firewall operations.", nameof(node)); + if (!vmid.HasValue) + throw new ArgumentException("VmId is required for VM-level firewall operations.", nameof(vmid)); + return $"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid.Value}/firewall"; + case "container": + if (string.IsNullOrWhiteSpace(node)) + throw new ArgumentException("Node is required for container-level firewall operations.", nameof(node)); + if (!vmid.HasValue) + throw new ArgumentException("VmId is required for container-level firewall operations.", nameof(vmid)); + return $"nodes/{Uri.EscapeDataString(node)}/lxc/{vmid.Value}/firewall"; + default: + throw new ArgumentException($"Invalid firewall level: {level}. Must be Cluster, Node, Vm, or Container.", nameof(level)); + } + } + } +} diff --git a/src/PSProxmoxVE.Core/Services/NetworkService.cs b/src/PSProxmoxVE.Core/Services/NetworkService.cs index 5b58d4d..791b893 100644 --- a/src/PSProxmoxVE.Core/Services/NetworkService.cs +++ b/src/PSProxmoxVE.Core/Services/NetworkService.cs @@ -304,6 +304,141 @@ namespace PSProxmoxVE.Core.Services .GetAwaiter().GetResult(); } + // ------------------------------------------------------------------------- + // SDN IPAM + // ------------------------------------------------------------------------- + + /// + /// Returns all SDN IPAM plugins. Requires PVE 8.0+. + /// + public PveSdnIpam[] GetSdnIpams(PveSession session) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + RequireSdn(session); + + using var client = new PveHttpClient(session); + var response = client.GetAsync("cluster/sdn/ipams").GetAwaiter().GetResult(); + var data = JObject.Parse(response)["data"]; + return data?.ToObject() ?? Array.Empty(); + } + + /// + /// Creates an SDN IPAM plugin. Requires PVE 8.0+. + /// + public void CreateSdnIpam(PveSession session, Dictionary config) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + RequireSdn(session); + if (config == null) throw new ArgumentNullException(nameof(config)); + + using var client = new PveHttpClient(session); + client.PostAsync("cluster/sdn/ipams", config).GetAwaiter().GetResult(); + } + + /// + /// Removes an SDN IPAM plugin. Requires PVE 8.0+. + /// + public void RemoveSdnIpam(PveSession session, string ipam) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + RequireSdn(session); + if (string.IsNullOrWhiteSpace(ipam)) throw new ArgumentNullException(nameof(ipam)); + + using var client = new PveHttpClient(session); + client.DeleteAsync($"cluster/sdn/ipams/{Uri.EscapeDataString(ipam)}") + .GetAwaiter().GetResult(); + } + + // ------------------------------------------------------------------------- + // SDN DNS + // ------------------------------------------------------------------------- + + /// + /// Returns all SDN DNS plugins. Requires PVE 8.0+. + /// + public PveSdnDns[] GetSdnDnsPlugins(PveSession session) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + RequireSdn(session); + + using var client = new PveHttpClient(session); + var response = client.GetAsync("cluster/sdn/dns").GetAwaiter().GetResult(); + var data = JObject.Parse(response)["data"]; + return data?.ToObject() ?? Array.Empty(); + } + + /// + /// Creates an SDN DNS plugin. Requires PVE 8.0+. + /// + public void CreateSdnDnsPlugin(PveSession session, Dictionary config) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + RequireSdn(session); + if (config == null) throw new ArgumentNullException(nameof(config)); + + using var client = new PveHttpClient(session); + client.PostAsync("cluster/sdn/dns", config).GetAwaiter().GetResult(); + } + + /// + /// Removes an SDN DNS plugin. Requires PVE 8.0+. + /// + public void RemoveSdnDnsPlugin(PveSession session, string dns) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + RequireSdn(session); + if (string.IsNullOrWhiteSpace(dns)) throw new ArgumentNullException(nameof(dns)); + + using var client = new PveHttpClient(session); + client.DeleteAsync($"cluster/sdn/dns/{Uri.EscapeDataString(dns)}") + .GetAwaiter().GetResult(); + } + + // ------------------------------------------------------------------------- + // SDN Controllers + // ------------------------------------------------------------------------- + + /// + /// Returns all SDN controllers. Requires PVE 8.0+. + /// + public PveSdnController[] GetSdnControllers(PveSession session) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + RequireSdn(session); + + using var client = new PveHttpClient(session); + var response = client.GetAsync("cluster/sdn/controllers").GetAwaiter().GetResult(); + var data = JObject.Parse(response)["data"]; + return data?.ToObject() ?? Array.Empty(); + } + + /// + /// Creates an SDN controller. Requires PVE 8.0+. + /// + public void CreateSdnController(PveSession session, Dictionary config) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + RequireSdn(session); + if (config == null) throw new ArgumentNullException(nameof(config)); + + using var client = new PveHttpClient(session); + client.PostAsync("cluster/sdn/controllers", config).GetAwaiter().GetResult(); + } + + /// + /// Removes an SDN controller. Requires PVE 8.0+. + /// + public void RemoveSdnController(PveSession session, string controller) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + RequireSdn(session); + if (string.IsNullOrWhiteSpace(controller)) throw new ArgumentNullException(nameof(controller)); + + using var client = new PveHttpClient(session); + client.DeleteAsync($"cluster/sdn/controllers/{Uri.EscapeDataString(controller)}") + .GetAwaiter().GetResult(); + } + // ------------------------------------------------------------------------- // Private helpers // ------------------------------------------------------------------------- diff --git a/src/PSProxmoxVE.Core/Services/SnapshotService.cs b/src/PSProxmoxVE.Core/Services/SnapshotService.cs index e69acd4..aa21bb8 100644 --- a/src/PSProxmoxVE.Core/Services/SnapshotService.cs +++ b/src/PSProxmoxVE.Core/Services/SnapshotService.cs @@ -84,7 +84,7 @@ namespace PSProxmoxVE.Core.Services if (string.IsNullOrWhiteSpace(snapname)) throw new ArgumentNullException(nameof(snapname)); using var client = new PveHttpClient(session); - var response = client.DeleteAsync($"nodes/{node}/qemu/{vmid}/snapshot/{snapname}") + var response = client.DeleteAsync($"nodes/{node}/qemu/{vmid}/snapshot/{Uri.EscapeDataString(snapname)}") .GetAwaiter().GetResult(); return ParseTask(response, node); } @@ -107,7 +107,7 @@ namespace PSProxmoxVE.Core.Services if (string.IsNullOrWhiteSpace(snapname)) throw new ArgumentNullException(nameof(snapname)); using var client = new PveHttpClient(session); - var response = client.PostAsync($"nodes/{node}/qemu/{vmid}/snapshot/{snapname}/rollback") + var response = client.PostAsync($"nodes/{node}/qemu/{vmid}/snapshot/{Uri.EscapeDataString(snapname)}/rollback") .GetAwaiter().GetResult(); return ParseTask(response, node); } diff --git a/src/PSProxmoxVE/Cmdlets/Backup/GetPveBackupJobCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Backup/GetPveBackupJobCmdlet.cs new file mode 100644 index 0000000..183e8b1 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Backup/GetPveBackupJobCmdlet.cs @@ -0,0 +1,46 @@ +using System.Management.Automation; +using PSProxmoxVE.Core.Models.Backup; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Backup +{ + /// + /// Lists Proxmox VE backup jobs. + /// + /// Returns scheduled backup job configurations from the Proxmox VE cluster. + /// Optionally filter by job ID. + /// + /// + [Cmdlet(VerbsCommon.Get, "PveBackupJob")] + [OutputType(typeof(PveBackupJob))] + public sealed class GetPveBackupJobCmdlet : PveCmdletBase + { + /// + /// The backup job ID to retrieve. When omitted, all jobs are returned. + /// + [Parameter(Mandatory = false, Position = 0, ValueFromPipelineByPropertyName = true, HelpMessage = "The backup job ID.")] + public string? Id { get; set; } + + protected override void ProcessRecord() + { + var session = GetSession(); + var service = new BackupService(); + + if (!string.IsNullOrEmpty(Id)) + { + WriteVerbose($"Getting backup job '{Id}'..."); + var job = service.GetBackupJob(session, Id!); + WriteObject(job); + } + else + { + WriteVerbose("Getting all backup jobs..."); + var jobs = service.GetBackupJobs(session); + foreach (var job in jobs) + { + WriteObject(job); + } + } + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Backup/NewPveBackupCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Backup/NewPveBackupCmdlet.cs new file mode 100644 index 0000000..e48b4eb --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Backup/NewPveBackupCmdlet.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Management.Automation; +using PSProxmoxVE.Core.Models.Vms; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Backup +{ + /// + /// Creates an ad-hoc backup of a virtual machine via vzdump. + /// + /// Triggers an immediate backup (vzdump) of the specified VM on the given node. + /// Returns a PveTask representing the backup operation. Use -Wait to block until + /// the backup completes. + /// + /// + [Cmdlet(VerbsCommon.New, "PveBackup", SupportsShouldProcess = true)] + [OutputType(typeof(PveTask))] + public sealed class NewPveBackupCmdlet : PveCmdletBase + { + /// + /// The node on which the VM resides. + /// + [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true, HelpMessage = "The PVE node name.")] + public string Node { get; set; } = string.Empty; + + /// + /// The ID of the VM to back up. + /// + [Parameter(Mandatory = true, Position = 1, ValueFromPipelineByPropertyName = true, HelpMessage = "The VM identifier.")] + [ValidateRange(100, 999999999)] + public int VmId { get; set; } + + /// + /// The target storage for the backup. + /// + [Parameter(Mandatory = false, HelpMessage = "The target storage for the backup.")] + public string? Storage { get; set; } + + /// + /// The backup mode (snapshot, suspend, or stop). + /// + [Parameter(Mandatory = false, HelpMessage = "The backup mode.")] + [ValidateSet("snapshot", "suspend", "stop")] + public string? Mode { get; set; } + + /// + /// The compression algorithm to use. + /// + [Parameter(Mandatory = false, HelpMessage = "The compression algorithm.")] + [ValidateSet("zstd", "lzo", "gzip", "none")] + public string? Compress { get; set; } + + /// + /// When specified, waits for the backup task to complete before returning. + /// + [Parameter(Mandatory = false, HelpMessage = "Wait for the task to complete before returning.")] + public SwitchParameter Wait { get; set; } + + /// + /// Maximum seconds to wait when -Wait is specified. Default 3600. + /// + [Parameter(Mandatory = false, HelpMessage = "Timeout in seconds for -Wait (default 3600).")] + public int Timeout { get; set; } = 3600; + + protected override void ProcessRecord() + { + if (!ShouldProcess($"VM {VmId} on node '{Node}'", "New-PveBackup")) + return; + + var session = GetSession(); + var service = new BackupService(); + + var formData = new Dictionary + { + ["vmid"] = VmId.ToString() + }; + + if (!string.IsNullOrEmpty(Storage)) formData["storage"] = Storage!; + if (!string.IsNullOrEmpty(Mode)) formData["mode"] = Mode!; + if (!string.IsNullOrEmpty(Compress)) formData["compress"] = Compress!; + + WriteVerbose($"Creating backup of VM {VmId} on node '{Node}'..."); + var task = service.CreateBackup(session, Node, formData); + + if (Wait.IsPresent) + { + var taskService = new TaskService(); + task = taskService.WaitForTask(session, task.Node ?? Node, task.Upid, TimeSpan.FromSeconds(Timeout)); + } + + WriteObject(task); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Backup/NewPveBackupJobCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Backup/NewPveBackupJobCmdlet.cs new file mode 100644 index 0000000..2a9ffe2 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Backup/NewPveBackupJobCmdlet.cs @@ -0,0 +1,122 @@ +using System.Collections.Generic; +using System.Management.Automation; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Backup +{ + /// + /// Creates a scheduled backup job on Proxmox VE. + /// + /// Creates a new scheduled backup job in the Proxmox VE cluster configuration. + /// Specify a cron-style schedule and target storage. Use VmId for specific VMs + /// or -All to back up all VMs. + /// + /// + [Cmdlet(VerbsCommon.New, "PveBackupJob", SupportsShouldProcess = true)] + public sealed class NewPveBackupJobCmdlet : PveCmdletBase + { + /// + /// Cron-style schedule expression (e.g., "0 2 * * *"). + /// + [Parameter(Mandatory = true, HelpMessage = "Cron-style schedule expression (e.g., '0 2 * * *').")] + public string Schedule { get; set; } = string.Empty; + + /// + /// The target storage for backups. + /// + [Parameter(Mandatory = true, HelpMessage = "The target storage for backups.")] + public string Storage { get; set; } = string.Empty; + + /// + /// The backup mode (snapshot, suspend, or stop). + /// + [Parameter(Mandatory = false, HelpMessage = "The backup mode.")] + [ValidateSet("snapshot", "suspend", "stop")] + public string? Mode { get; set; } + + /// + /// Comma-separated list of VM IDs to include in the backup job. + /// + [Parameter(Mandatory = false, HelpMessage = "Comma-separated list of VM IDs to back up.")] + public string? VmId { get; set; } + + /// + /// When specified, backs up all VMs on the node. + /// + [Parameter(Mandatory = false, HelpMessage = "Back up all VMs.")] + public SwitchParameter All { get; set; } + + /// + /// The compression algorithm to use. + /// + [Parameter(Mandatory = false, HelpMessage = "The compression algorithm.")] + [ValidateSet("zstd", "lzo", "gzip", "none")] + public string? Compress { get; set; } + + /// + /// The node to restrict the backup job to. + /// + [Parameter(Mandatory = false, HelpMessage = "The node to restrict the backup job to.")] + public string? Node { get; set; } + + /// + /// Email address(es) to send notifications to. + /// + [Parameter(Mandatory = false, HelpMessage = "Email address(es) for notifications.")] + public string? MailTo { get; set; } + + /// + /// When to send email notifications (always or failure). + /// + [Parameter(Mandatory = false, HelpMessage = "When to send email notifications.")] + [ValidateSet("always", "failure")] + public string? MailNotification { get; set; } + + /// + /// Whether the backup job is enabled. Default is true. + /// + [Parameter(Mandatory = false, HelpMessage = "Whether the backup job is enabled.")] + public SwitchParameter Enabled { get; set; } = true; + + /// + /// A comment or description for the backup job. + /// + [Parameter(Mandatory = false, HelpMessage = "A comment or description for the backup job.")] + public string? Comment { get; set; } + + /// + /// Prune-backups configuration string (e.g., "keep-daily=7,keep-weekly=4"). + /// + [Parameter(Mandatory = false, HelpMessage = "Prune-backups configuration string.")] + public string? PruneBackups { get; set; } + + protected override void ProcessRecord() + { + if (!ShouldProcess("cluster backup configuration", "New-PveBackupJob")) + return; + + var session = GetSession(); + var service = new BackupService(); + + var config = new Dictionary + { + ["schedule"] = Schedule, + ["storage"] = Storage, + ["enabled"] = Enabled.IsPresent ? "1" : "0" + }; + + if (!string.IsNullOrEmpty(Mode)) config["mode"] = Mode!; + if (!string.IsNullOrEmpty(VmId)) config["vmid"] = VmId!; + if (All.IsPresent) config["all"] = "1"; + if (!string.IsNullOrEmpty(Compress)) config["compress"] = Compress!; + if (!string.IsNullOrEmpty(Node)) config["node"] = Node!; + if (!string.IsNullOrEmpty(MailTo)) config["mailto"] = MailTo!; + if (!string.IsNullOrEmpty(MailNotification)) config["mailnotification"] = MailNotification!; + if (!string.IsNullOrEmpty(Comment)) config["comment"] = Comment!; + if (!string.IsNullOrEmpty(PruneBackups)) config["prune-backups"] = PruneBackups!; + + WriteVerbose("Creating backup job..."); + service.CreateBackupJob(session, config); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Backup/RemovePveBackupJobCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Backup/RemovePveBackupJobCmdlet.cs new file mode 100644 index 0000000..28e229e --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Backup/RemovePveBackupJobCmdlet.cs @@ -0,0 +1,36 @@ +using System.Management.Automation; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Backup +{ + /// + /// Removes a scheduled backup job from Proxmox VE. + /// + /// Deletes a backup job from the Proxmox VE cluster configuration. + /// This operation is destructive and requires confirmation unless -Force is specified. + /// + /// + [Cmdlet(VerbsCommon.Remove, "PveBackupJob", + SupportsShouldProcess = true, + ConfirmImpact = ConfirmImpact.High)] + public sealed class RemovePveBackupJobCmdlet : PveCmdletBase + { + /// + /// The backup job ID to remove. + /// + [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true, HelpMessage = "The backup job ID.")] + public string Id { get; set; } = string.Empty; + + protected override void ProcessRecord() + { + if (!ShouldProcess($"backup job '{Id}'", "Remove-PveBackupJob")) + return; + + var session = GetSession(); + var service = new BackupService(); + + WriteVerbose($"Removing backup job '{Id}'..."); + service.RemoveBackupJob(session, Id); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Backup/SetPveBackupJobCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Backup/SetPveBackupJobCmdlet.cs new file mode 100644 index 0000000..9d4ae24 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Backup/SetPveBackupJobCmdlet.cs @@ -0,0 +1,125 @@ +using System.Collections.Generic; +using System.Management.Automation; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Backup +{ + /// + /// Updates a scheduled backup job on Proxmox VE. + /// + /// Modifies properties of an existing backup job. Only specified parameters are + /// updated; omitted parameters retain their current values. + /// + /// + [Cmdlet(VerbsCommon.Set, "PveBackupJob", SupportsShouldProcess = true)] + public sealed class SetPveBackupJobCmdlet : PveCmdletBase + { + /// + /// The backup job ID to update. + /// + [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true, HelpMessage = "The backup job ID.")] + public string Id { get; set; } = string.Empty; + + /// + /// Updated cron-style schedule expression. + /// + [Parameter(Mandatory = false, HelpMessage = "Cron-style schedule expression (e.g., '0 2 * * *').")] + public string? Schedule { get; set; } + + /// + /// Updated target storage for backups. + /// + [Parameter(Mandatory = false, HelpMessage = "The target storage for backups.")] + public string? Storage { get; set; } + + /// + /// Updated backup mode (snapshot, suspend, or stop). + /// + [Parameter(Mandatory = false, HelpMessage = "The backup mode.")] + [ValidateSet("snapshot", "suspend", "stop")] + public string? Mode { get; set; } + + /// + /// Updated comma-separated list of VM IDs. + /// + [Parameter(Mandatory = false, HelpMessage = "Comma-separated list of VM IDs to back up.")] + public string? VmId { get; set; } + + /// + /// When specified, backs up all VMs. + /// + [Parameter(Mandatory = false, HelpMessage = "Back up all VMs.")] + public SwitchParameter All { get; set; } + + /// + /// Updated compression algorithm. + /// + [Parameter(Mandatory = false, HelpMessage = "The compression algorithm.")] + [ValidateSet("zstd", "lzo", "gzip", "none")] + public string? Compress { get; set; } + + /// + /// Updated node restriction. + /// + [Parameter(Mandatory = false, HelpMessage = "The node to restrict the backup job to.")] + public string? Node { get; set; } + + /// + /// Updated email address(es) for notifications. + /// + [Parameter(Mandatory = false, HelpMessage = "Email address(es) for notifications.")] + public string? MailTo { get; set; } + + /// + /// Updated notification policy. + /// + [Parameter(Mandatory = false, HelpMessage = "When to send email notifications.")] + [ValidateSet("always", "failure")] + public string? MailNotification { get; set; } + + /// + /// Enable or disable the backup job. + /// + [Parameter(Mandatory = false, HelpMessage = "Enable or disable the backup job.")] + public SwitchParameter Enabled { get; set; } + + /// + /// Updated comment or description. + /// + [Parameter(Mandatory = false, HelpMessage = "A comment or description for the backup job.")] + public string? Comment { get; set; } + + /// + /// Updated prune-backups configuration string. + /// + [Parameter(Mandatory = false, HelpMessage = "Prune-backups configuration string.")] + public string? PruneBackups { get; set; } + + protected override void ProcessRecord() + { + if (!ShouldProcess($"backup job '{Id}'", "Set-PveBackupJob")) + return; + + var session = GetSession(); + var service = new BackupService(); + + var config = new Dictionary(); + + if (!string.IsNullOrEmpty(Schedule)) config["schedule"] = Schedule!; + if (!string.IsNullOrEmpty(Storage)) config["storage"] = Storage!; + if (!string.IsNullOrEmpty(Mode)) config["mode"] = Mode!; + if (!string.IsNullOrEmpty(VmId)) config["vmid"] = VmId!; + if (All.IsPresent) config["all"] = "1"; + if (!string.IsNullOrEmpty(Compress)) config["compress"] = Compress!; + if (!string.IsNullOrEmpty(Node)) config["node"] = Node!; + if (!string.IsNullOrEmpty(MailTo)) config["mailto"] = MailTo!; + if (!string.IsNullOrEmpty(MailNotification)) config["mailnotification"] = MailNotification!; + if (MyInvocation.BoundParameters.ContainsKey("Enabled")) config["enabled"] = Enabled.IsPresent ? "1" : "0"; + if (!string.IsNullOrEmpty(Comment)) config["comment"] = Comment!; + if (!string.IsNullOrEmpty(PruneBackups)) config["prune-backups"] = PruneBackups!; + + WriteVerbose($"Updating backup job '{Id}'..."); + service.UpdateBackupJob(session, Id, config); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallAliasCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallAliasCmdlet.cs new file mode 100644 index 0000000..8275a0e --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallAliasCmdlet.cs @@ -0,0 +1,70 @@ +using System; +using System.Linq; +using System.Management.Automation; +using PSProxmoxVE.Core.Models.Firewall; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Firewall +{ + [Cmdlet(VerbsCommon.Get, "PveFirewallAlias")] + [OutputType(typeof(PveFirewallAlias))] + public class GetPveFirewallAliasCmdlet : PveCmdletBase + { + [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")] + [ValidateSet("Cluster", "Node", "Vm", "Container")] + public string Level { get; set; } = string.Empty; + + [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")] + public string? Node { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")] + [ValidateRange(100, 999999999)] + public int VmId { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Optional alias name to filter by.")] + public string? Name { get; set; } + + protected override void ProcessRecord() + { + var level = Level; + if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrEmpty(Node)) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("Node is required when Level is not Cluster."), + "NodeRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) || + string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase)) + { + if (VmId == 0) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("VmId is required when Level is Vm or Container."), + "VmIdRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + + var session = GetSession(); + var service = new FirewallService(); + int? vmid = VmId > 0 ? VmId : (int?)null; + + WriteVerbose($"Getting firewall aliases at level '{level}'..."); + var aliases = service.GetAliases(session, level, Node, vmid); + + if (!string.IsNullOrEmpty(Name)) + { + var filtered = aliases.Where(a => string.Equals(a.Name, Name, StringComparison.OrdinalIgnoreCase)).ToArray(); + WriteObject(filtered, true); + } + else + { + WriteObject(aliases, true); + } + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallGroupCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallGroupCmdlet.cs new file mode 100644 index 0000000..3128d93 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallGroupCmdlet.cs @@ -0,0 +1,34 @@ +using System; +using System.Management.Automation; +using PSProxmoxVE.Core.Models.Firewall; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Firewall +{ + [Cmdlet(VerbsCommon.Get, "PveFirewallGroup")] + [OutputType(typeof(PveFirewallGroup), typeof(PveFirewallRule))] + public class GetPveFirewallGroupCmdlet : PveCmdletBase + { + [Parameter(Mandatory = false, Position = 0, HelpMessage = "The security group name. If specified, returns rules within the group.")] + public string? Group { get; set; } + + protected override void ProcessRecord() + { + var session = GetSession(); + var service = new FirewallService(); + + if (!string.IsNullOrEmpty(Group)) + { + WriteVerbose($"Getting firewall rules for security group '{Group}'..."); + var rules = service.GetGroupRules(session, Group!); + WriteObject(rules, true); + } + else + { + WriteVerbose("Getting firewall security groups..."); + var groups = service.GetGroups(session); + WriteObject(groups, true); + } + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallIpSetCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallIpSetCmdlet.cs new file mode 100644 index 0000000..f1ac250 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallIpSetCmdlet.cs @@ -0,0 +1,70 @@ +using System; +using System.Linq; +using System.Management.Automation; +using PSProxmoxVE.Core.Models.Firewall; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Firewall +{ + [Cmdlet(VerbsCommon.Get, "PveFirewallIpSet")] + [OutputType(typeof(PveFirewallIpSet))] + public class GetPveFirewallIpSetCmdlet : PveCmdletBase + { + [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")] + [ValidateSet("Cluster", "Node", "Vm", "Container")] + public string Level { get; set; } = string.Empty; + + [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")] + public string? Node { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")] + [ValidateRange(100, 999999999)] + public int VmId { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Optional IP set name to filter by.")] + public string? Name { get; set; } + + protected override void ProcessRecord() + { + var level = Level; + if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrEmpty(Node)) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("Node is required when Level is not Cluster."), + "NodeRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) || + string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase)) + { + if (VmId == 0) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("VmId is required when Level is Vm or Container."), + "VmIdRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + + var session = GetSession(); + var service = new FirewallService(); + int? vmid = VmId > 0 ? VmId : (int?)null; + + WriteVerbose($"Getting firewall IP sets at level '{level}'..."); + var ipSets = service.GetIpSets(session, level, Node, vmid); + + if (!string.IsNullOrEmpty(Name)) + { + var filtered = ipSets.Where(s => string.Equals(s.Name, Name, StringComparison.OrdinalIgnoreCase)).ToArray(); + WriteObject(filtered, true); + } + else + { + WriteObject(ipSets, true); + } + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallIpSetEntryCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallIpSetEntryCmdlet.cs new file mode 100644 index 0000000..822449b --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallIpSetEntryCmdlet.cs @@ -0,0 +1,60 @@ +using System; +using System.Management.Automation; +using PSProxmoxVE.Core.Models.Firewall; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Firewall +{ + [Cmdlet(VerbsCommon.Get, "PveFirewallIpSetEntry")] + [OutputType(typeof(PveFirewallIpSetEntry))] + public class GetPveFirewallIpSetEntryCmdlet : PveCmdletBase + { + [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")] + [ValidateSet("Cluster", "Node", "Vm", "Container")] + public string Level { get; set; } = string.Empty; + + [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")] + public string? Node { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")] + [ValidateRange(100, 999999999)] + public int VmId { get; set; } + + [Parameter(Mandatory = true, HelpMessage = "The IP set name.")] + public string Name { get; set; } = string.Empty; + + protected override void ProcessRecord() + { + var level = Level; + if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrEmpty(Node)) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("Node is required when Level is not Cluster."), + "NodeRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) || + string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase)) + { + if (VmId == 0) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("VmId is required when Level is Vm or Container."), + "VmIdRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + + var session = GetSession(); + var service = new FirewallService(); + int? vmid = VmId > 0 ? VmId : (int?)null; + + WriteVerbose($"Getting entries for IP set '{Name}' at level '{level}'..."); + var entries = service.GetIpSetEntries(session, level, Name, Node, vmid); + WriteObject(entries, true); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallOptionsCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallOptionsCmdlet.cs new file mode 100644 index 0000000..f2b91fb --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallOptionsCmdlet.cs @@ -0,0 +1,57 @@ +using System; +using System.Management.Automation; +using PSProxmoxVE.Core.Models.Firewall; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Firewall +{ + [Cmdlet(VerbsCommon.Get, "PveFirewallOptions")] + [OutputType(typeof(PveFirewallOptions))] + public class GetPveFirewallOptionsCmdlet : PveCmdletBase + { + [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")] + [ValidateSet("Cluster", "Node", "Vm", "Container")] + public string Level { get; set; } = string.Empty; + + [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")] + public string? Node { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")] + [ValidateRange(100, 999999999)] + public int VmId { get; set; } + + protected override void ProcessRecord() + { + var level = Level; + if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrEmpty(Node)) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("Node is required when Level is not Cluster."), + "NodeRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) || + string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase)) + { + if (VmId == 0) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("VmId is required when Level is Vm or Container."), + "VmIdRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + + var session = GetSession(); + var service = new FirewallService(); + int? vmid = VmId > 0 ? VmId : (int?)null; + + WriteVerbose($"Getting firewall options at level '{level}'..."); + var options = service.GetOptions(session, level, Node, vmid); + WriteObject(options); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallRefCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallRefCmdlet.cs new file mode 100644 index 0000000..ce5b87f --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallRefCmdlet.cs @@ -0,0 +1,60 @@ +using System; +using System.Management.Automation; +using PSProxmoxVE.Core.Models.Firewall; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Firewall +{ + [Cmdlet(VerbsCommon.Get, "PveFirewallRef")] + [OutputType(typeof(PveFirewallRef))] + public class GetPveFirewallRefCmdlet : PveCmdletBase + { + [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")] + [ValidateSet("Cluster", "Node", "Vm", "Container")] + public string Level { get; set; } = string.Empty; + + [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")] + public string? Node { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")] + [ValidateRange(100, 999999999)] + public int VmId { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Optional type filter for references.")] + public string? Type { get; set; } + + protected override void ProcessRecord() + { + var level = Level; + if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrEmpty(Node)) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("Node is required when Level is not Cluster."), + "NodeRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) || + string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase)) + { + if (VmId == 0) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("VmId is required when Level is Vm or Container."), + "VmIdRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + + var session = GetSession(); + var service = new FirewallService(); + int? vmid = VmId > 0 ? VmId : (int?)null; + + WriteVerbose($"Getting firewall references at level '{level}'..."); + var refs = service.GetRefs(session, level, Type, Node, vmid); + WriteObject(refs, true); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallRuleCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallRuleCmdlet.cs new file mode 100644 index 0000000..76e5ad6 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallRuleCmdlet.cs @@ -0,0 +1,70 @@ +using System; +using System.Linq; +using System.Management.Automation; +using PSProxmoxVE.Core.Models.Firewall; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Firewall +{ + [Cmdlet(VerbsCommon.Get, "PveFirewallRule")] + [OutputType(typeof(PveFirewallRule))] + public class GetPveFirewallRuleCmdlet : PveCmdletBase + { + [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")] + [ValidateSet("Cluster", "Node", "Vm", "Container")] + public string Level { get; set; } = string.Empty; + + [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")] + public string? Node { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")] + [ValidateRange(100, 999999999)] + public int VmId { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Optional rule position to filter by.")] + public int? Position { get; set; } + + protected override void ProcessRecord() + { + var level = Level; + if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrEmpty(Node)) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("Node is required when Level is not Cluster."), + "NodeRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) || + string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase)) + { + if (VmId == 0) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("VmId is required when Level is Vm or Container."), + "VmIdRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + + var session = GetSession(); + var service = new FirewallService(); + int? vmid = VmId > 0 ? VmId : (int?)null; + + WriteVerbose($"Getting firewall rules at level '{level}'..."); + var rules = service.GetRules(session, level, Node, vmid); + + if (Position.HasValue) + { + var filtered = rules.Where(r => r.Pos == Position.Value).ToArray(); + WriteObject(filtered, true); + } + else + { + WriteObject(rules, true); + } + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/NewPveFirewallAliasCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/NewPveFirewallAliasCmdlet.cs new file mode 100644 index 0000000..1ca6fc8 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Firewall/NewPveFirewallAliasCmdlet.cs @@ -0,0 +1,68 @@ +using System; +using System.Management.Automation; +using PSProxmoxVE.Core.Models.Firewall; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Firewall +{ + [Cmdlet(VerbsCommon.New, "PveFirewallAlias", SupportsShouldProcess = true)] + [OutputType(typeof(void))] + public class NewPveFirewallAliasCmdlet : PveCmdletBase + { + [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")] + [ValidateSet("Cluster", "Node", "Vm", "Container")] + public string Level { get; set; } = string.Empty; + + [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")] + public string? Node { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")] + [ValidateRange(100, 999999999)] + public int VmId { get; set; } + + [Parameter(Mandatory = true, HelpMessage = "The alias name.")] + public string Name { get; set; } = string.Empty; + + [Parameter(Mandatory = true, HelpMessage = "The CIDR network address.")] + public string Cidr { get; set; } = string.Empty; + + [Parameter(Mandatory = false, HelpMessage = "Optional comment for the alias.")] + public string? Comment { get; set; } + + protected override void ProcessRecord() + { + var level = Level; + if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrEmpty(Node)) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("Node is required when Level is not Cluster."), + "NodeRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) || + string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase)) + { + if (VmId == 0) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("VmId is required when Level is Vm or Container."), + "VmIdRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + + if (!ShouldProcess($"firewall alias '{Name}' ({Level})", "Create")) + return; + + var session = GetSession(); + var service = new FirewallService(); + int? vmid = VmId > 0 ? VmId : (int?)null; + + WriteVerbose($"Creating firewall alias '{Name}' at level '{level}'..."); + service.CreateAlias(session, level, Name, Cidr, Comment, Node, vmid); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/NewPveFirewallGroupCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/NewPveFirewallGroupCmdlet.cs new file mode 100644 index 0000000..1f68ba4 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Firewall/NewPveFirewallGroupCmdlet.cs @@ -0,0 +1,30 @@ +using System; +using System.Management.Automation; +using PSProxmoxVE.Core.Models.Firewall; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Firewall +{ + [Cmdlet(VerbsCommon.New, "PveFirewallGroup", SupportsShouldProcess = true)] + [OutputType(typeof(void))] + public class NewPveFirewallGroupCmdlet : PveCmdletBase + { + [Parameter(Mandatory = true, Position = 0, HelpMessage = "The security group name.")] + public string Group { get; set; } = string.Empty; + + [Parameter(Mandatory = false, HelpMessage = "Optional comment for the security group.")] + public string? Comment { get; set; } + + protected override void ProcessRecord() + { + if (!ShouldProcess($"firewall security group '{Group}'", "Create")) + return; + + var session = GetSession(); + var service = new FirewallService(); + + WriteVerbose($"Creating firewall security group '{Group}'..."); + service.CreateGroup(session, Group, Comment); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/NewPveFirewallIpSetCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/NewPveFirewallIpSetCmdlet.cs new file mode 100644 index 0000000..a11c282 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Firewall/NewPveFirewallIpSetCmdlet.cs @@ -0,0 +1,65 @@ +using System; +using System.Management.Automation; +using PSProxmoxVE.Core.Models.Firewall; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Firewall +{ + [Cmdlet(VerbsCommon.New, "PveFirewallIpSet", SupportsShouldProcess = true)] + [OutputType(typeof(void))] + public class NewPveFirewallIpSetCmdlet : PveCmdletBase + { + [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")] + [ValidateSet("Cluster", "Node", "Vm", "Container")] + public string Level { get; set; } = string.Empty; + + [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")] + public string? Node { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")] + [ValidateRange(100, 999999999)] + public int VmId { get; set; } + + [Parameter(Mandatory = true, HelpMessage = "The IP set name.")] + public string Name { get; set; } = string.Empty; + + [Parameter(Mandatory = false, HelpMessage = "Optional comment for the IP set.")] + public string? Comment { get; set; } + + protected override void ProcessRecord() + { + var level = Level; + if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrEmpty(Node)) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("Node is required when Level is not Cluster."), + "NodeRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) || + string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase)) + { + if (VmId == 0) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("VmId is required when Level is Vm or Container."), + "VmIdRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + + if (!ShouldProcess($"firewall IP set '{Name}' ({Level})", "Create")) + return; + + var session = GetSession(); + var service = new FirewallService(); + int? vmid = VmId > 0 ? VmId : (int?)null; + + WriteVerbose($"Creating firewall IP set '{Name}' at level '{level}'..."); + service.CreateIpSet(session, level, Name, Comment, Node, vmid); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/NewPveFirewallIpSetEntryCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/NewPveFirewallIpSetEntryCmdlet.cs new file mode 100644 index 0000000..19dba6b --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Firewall/NewPveFirewallIpSetEntryCmdlet.cs @@ -0,0 +1,71 @@ +using System; +using System.Management.Automation; +using PSProxmoxVE.Core.Models.Firewall; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Firewall +{ + [Cmdlet(VerbsCommon.New, "PveFirewallIpSetEntry", SupportsShouldProcess = true)] + [OutputType(typeof(void))] + public class NewPveFirewallIpSetEntryCmdlet : PveCmdletBase + { + [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")] + [ValidateSet("Cluster", "Node", "Vm", "Container")] + public string Level { get; set; } = string.Empty; + + [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")] + public string? Node { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")] + [ValidateRange(100, 999999999)] + public int VmId { get; set; } + + [Parameter(Mandatory = true, HelpMessage = "The IP set name.")] + public string Name { get; set; } = string.Empty; + + [Parameter(Mandatory = true, HelpMessage = "The CIDR network address to add.")] + public string Cidr { get; set; } = string.Empty; + + [Parameter(Mandatory = false, HelpMessage = "Exclude this entry from the IP set match.")] + public SwitchParameter NoMatch { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Optional comment for the entry.")] + public string? Comment { get; set; } + + protected override void ProcessRecord() + { + var level = Level; + if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrEmpty(Node)) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("Node is required when Level is not Cluster."), + "NodeRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) || + string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase)) + { + if (VmId == 0) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("VmId is required when Level is Vm or Container."), + "VmIdRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + + if (!ShouldProcess($"IP set entry '{Cidr}' in '{Name}' ({Level})", "Create")) + return; + + var session = GetSession(); + var service = new FirewallService(); + int? vmid = VmId > 0 ? VmId : (int?)null; + + WriteVerbose($"Adding entry '{Cidr}' to IP set '{Name}' at level '{level}'..."); + service.AddIpSetEntry(session, level, Name, Cidr, NoMatch.IsPresent, Comment, Node, vmid); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/NewPveFirewallRuleCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/NewPveFirewallRuleCmdlet.cs new file mode 100644 index 0000000..94c1bf2 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Firewall/NewPveFirewallRuleCmdlet.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.Management.Automation; +using PSProxmoxVE.Core.Models.Firewall; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Firewall +{ + [Cmdlet(VerbsCommon.New, "PveFirewallRule", SupportsShouldProcess = true)] + [OutputType(typeof(PveFirewallRule))] + public class NewPveFirewallRuleCmdlet : PveCmdletBase + { + [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")] + [ValidateSet("Cluster", "Node", "Vm", "Container")] + public string Level { get; set; } = string.Empty; + + [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")] + public string? Node { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")] + [ValidateRange(100, 999999999)] + public int VmId { get; set; } + + [Parameter(Mandatory = true, HelpMessage = "The rule type: in, out, or group.")] + [ValidateSet("in", "out", "group")] + public string Type { get; set; } = string.Empty; + + [Parameter(Mandatory = true, HelpMessage = "The rule action: ACCEPT, DROP, or REJECT.")] + [ValidateSet("ACCEPT", "DROP", "REJECT")] + public string Action { get; set; } = string.Empty; + + [Parameter(Mandatory = false, HelpMessage = "Enable the rule.")] + public SwitchParameter Enable { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Source address or alias.")] + public string? Source { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Destination address or alias.")] + public string? Dest { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Protocol (e.g., tcp, udp, icmp).")] + public string? Proto { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Destination port.")] + public string? Dport { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Source port.")] + public string? Sport { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Rule comment.")] + public string? Comment { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Macro name.")] + public string? Macro { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Log level.")] + public string? Log { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Network interface.")] + public string? Iface { get; set; } + + protected override void ProcessRecord() + { + var level = Level; + if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrEmpty(Node)) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("Node is required when Level is not Cluster."), + "NodeRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) || + string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase)) + { + if (VmId == 0) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("VmId is required when Level is Vm or Container."), + "VmIdRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + + if (!ShouldProcess($"firewall rule ({Level})", "Create")) + return; + + var session = GetSession(); + var service = new FirewallService(); + int? vmid = VmId > 0 ? VmId : (int?)null; + + var config = new Dictionary + { + ["type"] = Type, + ["action"] = Action + }; + + if (Enable.IsPresent) + config["enable"] = "1"; + if (!string.IsNullOrEmpty(Source)) + config["source"] = Source!; + if (!string.IsNullOrEmpty(Dest)) + config["dest"] = Dest!; + if (!string.IsNullOrEmpty(Proto)) + config["proto"] = Proto!; + if (!string.IsNullOrEmpty(Dport)) + config["dport"] = Dport!; + if (!string.IsNullOrEmpty(Sport)) + config["sport"] = Sport!; + if (!string.IsNullOrEmpty(Comment)) + config["comment"] = Comment!; + if (!string.IsNullOrEmpty(Macro)) + config["macro"] = Macro!; + if (!string.IsNullOrEmpty(Log)) + config["log"] = Log!; + if (!string.IsNullOrEmpty(Iface)) + config["iface"] = Iface!; + + WriteVerbose($"Creating firewall rule at level '{level}'..."); + service.CreateRule(session, level, config, Node, vmid); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/RemovePveFirewallAliasCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/RemovePveFirewallAliasCmdlet.cs new file mode 100644 index 0000000..6ddc16d --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Firewall/RemovePveFirewallAliasCmdlet.cs @@ -0,0 +1,62 @@ +using System; +using System.Management.Automation; +using PSProxmoxVE.Core.Models.Firewall; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Firewall +{ + [Cmdlet(VerbsCommon.Remove, "PveFirewallAlias", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] + [OutputType(typeof(void))] + public class RemovePveFirewallAliasCmdlet : PveCmdletBase + { + [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")] + [ValidateSet("Cluster", "Node", "Vm", "Container")] + public string Level { get; set; } = string.Empty; + + [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")] + public string? Node { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")] + [ValidateRange(100, 999999999)] + public int VmId { get; set; } + + [Parameter(Mandatory = true, HelpMessage = "The alias name to remove.")] + public string Name { get; set; } = string.Empty; + + protected override void ProcessRecord() + { + var level = Level; + if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrEmpty(Node)) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("Node is required when Level is not Cluster."), + "NodeRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) || + string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase)) + { + if (VmId == 0) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("VmId is required when Level is Vm or Container."), + "VmIdRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + + if (!ShouldProcess($"firewall alias '{Name}' ({Level})", "Remove")) + return; + + var session = GetSession(); + var service = new FirewallService(); + int? vmid = VmId > 0 ? VmId : (int?)null; + + WriteVerbose($"Removing firewall alias '{Name}' at level '{level}'..."); + service.RemoveAlias(session, level, Name, Node, vmid); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/RemovePveFirewallGroupCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/RemovePveFirewallGroupCmdlet.cs new file mode 100644 index 0000000..2e24dc2 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Firewall/RemovePveFirewallGroupCmdlet.cs @@ -0,0 +1,27 @@ +using System; +using System.Management.Automation; +using PSProxmoxVE.Core.Models.Firewall; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Firewall +{ + [Cmdlet(VerbsCommon.Remove, "PveFirewallGroup", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] + [OutputType(typeof(void))] + public class RemovePveFirewallGroupCmdlet : PveCmdletBase + { + [Parameter(Mandatory = true, Position = 0, HelpMessage = "The security group name to remove.")] + public string Group { get; set; } = string.Empty; + + protected override void ProcessRecord() + { + if (!ShouldProcess($"firewall security group '{Group}'", "Remove")) + return; + + var session = GetSession(); + var service = new FirewallService(); + + WriteVerbose($"Removing firewall security group '{Group}'..."); + service.RemoveGroup(session, Group); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/RemovePveFirewallIpSetCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/RemovePveFirewallIpSetCmdlet.cs new file mode 100644 index 0000000..a7ae1e3 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Firewall/RemovePveFirewallIpSetCmdlet.cs @@ -0,0 +1,62 @@ +using System; +using System.Management.Automation; +using PSProxmoxVE.Core.Models.Firewall; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Firewall +{ + [Cmdlet(VerbsCommon.Remove, "PveFirewallIpSet", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] + [OutputType(typeof(void))] + public class RemovePveFirewallIpSetCmdlet : PveCmdletBase + { + [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")] + [ValidateSet("Cluster", "Node", "Vm", "Container")] + public string Level { get; set; } = string.Empty; + + [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")] + public string? Node { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")] + [ValidateRange(100, 999999999)] + public int VmId { get; set; } + + [Parameter(Mandatory = true, HelpMessage = "The IP set name to remove.")] + public string Name { get; set; } = string.Empty; + + protected override void ProcessRecord() + { + var level = Level; + if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrEmpty(Node)) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("Node is required when Level is not Cluster."), + "NodeRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) || + string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase)) + { + if (VmId == 0) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("VmId is required when Level is Vm or Container."), + "VmIdRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + + if (!ShouldProcess($"firewall IP set '{Name}' ({Level})", "Remove")) + return; + + var session = GetSession(); + var service = new FirewallService(); + int? vmid = VmId > 0 ? VmId : (int?)null; + + WriteVerbose($"Removing firewall IP set '{Name}' at level '{level}'..."); + service.RemoveIpSet(session, level, Name, Node, vmid); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/RemovePveFirewallIpSetEntryCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/RemovePveFirewallIpSetEntryCmdlet.cs new file mode 100644 index 0000000..97da0c6 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Firewall/RemovePveFirewallIpSetEntryCmdlet.cs @@ -0,0 +1,65 @@ +using System; +using System.Management.Automation; +using PSProxmoxVE.Core.Models.Firewall; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Firewall +{ + [Cmdlet(VerbsCommon.Remove, "PveFirewallIpSetEntry", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] + [OutputType(typeof(void))] + public class RemovePveFirewallIpSetEntryCmdlet : PveCmdletBase + { + [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")] + [ValidateSet("Cluster", "Node", "Vm", "Container")] + public string Level { get; set; } = string.Empty; + + [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")] + public string? Node { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")] + [ValidateRange(100, 999999999)] + public int VmId { get; set; } + + [Parameter(Mandatory = true, HelpMessage = "The IP set name.")] + public string Name { get; set; } = string.Empty; + + [Parameter(Mandatory = true, HelpMessage = "The CIDR network address to remove.")] + public string Cidr { get; set; } = string.Empty; + + protected override void ProcessRecord() + { + var level = Level; + if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrEmpty(Node)) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("Node is required when Level is not Cluster."), + "NodeRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) || + string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase)) + { + if (VmId == 0) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("VmId is required when Level is Vm or Container."), + "VmIdRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + + if (!ShouldProcess($"IP set entry '{Cidr}' from '{Name}' ({Level})", "Remove")) + return; + + var session = GetSession(); + var service = new FirewallService(); + int? vmid = VmId > 0 ? VmId : (int?)null; + + WriteVerbose($"Removing entry '{Cidr}' from IP set '{Name}' at level '{level}'..."); + service.RemoveIpSetEntry(session, level, Name, Cidr, Node, vmid); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/RemovePveFirewallRuleCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/RemovePveFirewallRuleCmdlet.cs new file mode 100644 index 0000000..5888161 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Firewall/RemovePveFirewallRuleCmdlet.cs @@ -0,0 +1,62 @@ +using System; +using System.Management.Automation; +using PSProxmoxVE.Core.Models.Firewall; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Firewall +{ + [Cmdlet(VerbsCommon.Remove, "PveFirewallRule", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] + [OutputType(typeof(void))] + public class RemovePveFirewallRuleCmdlet : PveCmdletBase + { + [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")] + [ValidateSet("Cluster", "Node", "Vm", "Container")] + public string Level { get; set; } = string.Empty; + + [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")] + public string? Node { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")] + [ValidateRange(100, 999999999)] + public int VmId { get; set; } + + [Parameter(Mandatory = true, HelpMessage = "The rule position to remove.")] + public int Position { get; set; } + + protected override void ProcessRecord() + { + var level = Level; + if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrEmpty(Node)) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("Node is required when Level is not Cluster."), + "NodeRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) || + string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase)) + { + if (VmId == 0) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("VmId is required when Level is Vm or Container."), + "VmIdRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + + if (!ShouldProcess($"firewall rule at position {Position} ({Level})", "Remove")) + return; + + var session = GetSession(); + var service = new FirewallService(); + int? vmid = VmId > 0 ? VmId : (int?)null; + + WriteVerbose($"Removing firewall rule at position {Position} ({level})..."); + service.RemoveRule(session, level, Position, Node, vmid); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/SetPveFirewallAliasCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/SetPveFirewallAliasCmdlet.cs new file mode 100644 index 0000000..3eea70e --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Firewall/SetPveFirewallAliasCmdlet.cs @@ -0,0 +1,68 @@ +using System; +using System.Management.Automation; +using PSProxmoxVE.Core.Models.Firewall; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Firewall +{ + [Cmdlet(VerbsCommon.Set, "PveFirewallAlias", SupportsShouldProcess = true)] + [OutputType(typeof(void))] + public class SetPveFirewallAliasCmdlet : PveCmdletBase + { + [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")] + [ValidateSet("Cluster", "Node", "Vm", "Container")] + public string Level { get; set; } = string.Empty; + + [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")] + public string? Node { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")] + [ValidateRange(100, 999999999)] + public int VmId { get; set; } + + [Parameter(Mandatory = true, HelpMessage = "The alias name to update.")] + public string Name { get; set; } = string.Empty; + + [Parameter(Mandatory = false, HelpMessage = "The updated CIDR network address.")] + public string? Cidr { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Updated comment for the alias.")] + public string? Comment { get; set; } + + protected override void ProcessRecord() + { + var level = Level; + if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrEmpty(Node)) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("Node is required when Level is not Cluster."), + "NodeRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) || + string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase)) + { + if (VmId == 0) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("VmId is required when Level is Vm or Container."), + "VmIdRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + + if (!ShouldProcess($"firewall alias '{Name}' ({Level})", "Update")) + return; + + var session = GetSession(); + var service = new FirewallService(); + int? vmid = VmId > 0 ? VmId : (int?)null; + + WriteVerbose($"Updating firewall alias '{Name}' at level '{level}'..."); + service.UpdateAlias(session, level, Name, Cidr, Comment, Node, vmid); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/SetPveFirewallIpSetEntryCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/SetPveFirewallIpSetEntryCmdlet.cs new file mode 100644 index 0000000..d178cc7 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Firewall/SetPveFirewallIpSetEntryCmdlet.cs @@ -0,0 +1,72 @@ +using System; +using System.Management.Automation; +using PSProxmoxVE.Core.Models.Firewall; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Firewall +{ + [Cmdlet(VerbsCommon.Set, "PveFirewallIpSetEntry", SupportsShouldProcess = true)] + [OutputType(typeof(void))] + public class SetPveFirewallIpSetEntryCmdlet : PveCmdletBase + { + [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")] + [ValidateSet("Cluster", "Node", "Vm", "Container")] + public string Level { get; set; } = string.Empty; + + [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")] + public string? Node { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")] + [ValidateRange(100, 999999999)] + public int VmId { get; set; } + + [Parameter(Mandatory = true, HelpMessage = "The IP set name.")] + public string Name { get; set; } = string.Empty; + + [Parameter(Mandatory = true, HelpMessage = "The CIDR network address to update.")] + public string Cidr { get; set; } = string.Empty; + + [Parameter(Mandatory = false, HelpMessage = "Exclude this entry from the IP set match.")] + public SwitchParameter NoMatch { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Updated comment for the entry.")] + public string? Comment { get; set; } + + protected override void ProcessRecord() + { + var level = Level; + if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrEmpty(Node)) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("Node is required when Level is not Cluster."), + "NodeRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) || + string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase)) + { + if (VmId == 0) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("VmId is required when Level is Vm or Container."), + "VmIdRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + + if (!ShouldProcess($"IP set entry '{Cidr}' in '{Name}' ({Level})", "Update")) + return; + + var session = GetSession(); + var service = new FirewallService(); + int? vmid = VmId > 0 ? VmId : (int?)null; + bool? nomatch = NoMatch.IsPresent ? true : (bool?)null; + + WriteVerbose($"Updating entry '{Cidr}' in IP set '{Name}' at level '{level}'..."); + service.UpdateIpSetEntry(session, level, Name, Cidr, nomatch, Comment, Node, vmid); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/SetPveFirewallOptionsCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/SetPveFirewallOptionsCmdlet.cs new file mode 100644 index 0000000..46ae7d7 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Firewall/SetPveFirewallOptionsCmdlet.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Management.Automation; +using PSProxmoxVE.Core.Models.Firewall; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Firewall +{ + [Cmdlet(VerbsCommon.Set, "PveFirewallOptions", SupportsShouldProcess = true)] + [OutputType(typeof(void))] + public class SetPveFirewallOptionsCmdlet : PveCmdletBase + { + [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")] + [ValidateSet("Cluster", "Node", "Vm", "Container")] + public string Level { get; set; } = string.Empty; + + [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")] + public string? Node { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")] + [ValidateRange(100, 999999999)] + public int VmId { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Enable or disable the firewall.")] + public SwitchParameter Enable { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Default policy for incoming traffic: ACCEPT, DROP, or REJECT.")] + [ValidateSet("ACCEPT", "DROP", "REJECT")] + public string? PolicyIn { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Default policy for outgoing traffic: ACCEPT, DROP, or REJECT.")] + [ValidateSet("ACCEPT", "DROP", "REJECT")] + public string? PolicyOut { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Log level for incoming traffic.")] + public string? LogLevelIn { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Log level for outgoing traffic.")] + public string? LogLevelOut { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Enable DHCP.")] + public SwitchParameter Dhcp { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Enable NDP.")] + public SwitchParameter Ndp { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Enable MAC address filter.")] + public SwitchParameter MacFilter { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Enable IP filter.")] + public SwitchParameter IpFilter { get; set; } + + protected override void ProcessRecord() + { + var level = Level; + if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrEmpty(Node)) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("Node is required when Level is not Cluster."), + "NodeRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) || + string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase)) + { + if (VmId == 0) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("VmId is required when Level is Vm or Container."), + "VmIdRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + + if (!ShouldProcess($"firewall options ({Level})", "Update")) + return; + + var session = GetSession(); + var service = new FirewallService(); + int? vmid = VmId > 0 ? VmId : (int?)null; + + var config = new Dictionary(); + + if (Enable.IsPresent) + config["enable"] = "1"; + if (!string.IsNullOrEmpty(PolicyIn)) + config["policy_in"] = PolicyIn!; + if (!string.IsNullOrEmpty(PolicyOut)) + config["policy_out"] = PolicyOut!; + if (!string.IsNullOrEmpty(LogLevelIn)) + config["log_level_in"] = LogLevelIn!; + if (!string.IsNullOrEmpty(LogLevelOut)) + config["log_level_out"] = LogLevelOut!; + if (Dhcp.IsPresent) + config["dhcp"] = "1"; + if (Ndp.IsPresent) + config["ndp"] = "1"; + if (MacFilter.IsPresent) + config["macfilter"] = "1"; + if (IpFilter.IsPresent) + config["ipfilter"] = "1"; + + WriteVerbose($"Setting firewall options at level '{level}'..."); + service.SetOptions(session, level, config, Node, vmid); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/SetPveFirewallRuleCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/SetPveFirewallRuleCmdlet.cs new file mode 100644 index 0000000..e65bd81 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Firewall/SetPveFirewallRuleCmdlet.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Management.Automation; +using PSProxmoxVE.Core.Models.Firewall; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Firewall +{ + [Cmdlet(VerbsCommon.Set, "PveFirewallRule", SupportsShouldProcess = true)] + [OutputType(typeof(void))] + public class SetPveFirewallRuleCmdlet : PveCmdletBase + { + [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")] + [ValidateSet("Cluster", "Node", "Vm", "Container")] + public string Level { get; set; } = string.Empty; + + [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")] + public string? Node { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")] + [ValidateRange(100, 999999999)] + public int VmId { get; set; } + + [Parameter(Mandatory = true, HelpMessage = "The rule position to update.")] + public int Position { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "The rule type: in, out, or group.")] + [ValidateSet("in", "out", "group")] + public string? Type { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "The rule action: ACCEPT, DROP, or REJECT.")] + [ValidateSet("ACCEPT", "DROP", "REJECT")] + public string? Action { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Enable the rule.")] + public SwitchParameter Enable { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Source address or alias.")] + public string? Source { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Destination address or alias.")] + public string? Dest { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Protocol (e.g., tcp, udp, icmp).")] + public string? Proto { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Destination port.")] + public string? Dport { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Source port.")] + public string? Sport { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Rule comment.")] + public string? Comment { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Macro name.")] + public string? Macro { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Log level.")] + public string? Log { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Network interface.")] + public string? Iface { get; set; } + + protected override void ProcessRecord() + { + var level = Level; + if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrEmpty(Node)) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("Node is required when Level is not Cluster."), + "NodeRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) || + string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase)) + { + if (VmId == 0) + { + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("VmId is required when Level is Vm or Container."), + "VmIdRequired", ErrorCategory.InvalidArgument, null)); + return; + } + } + + if (!ShouldProcess($"firewall rule at position {Position} ({Level})", "Update")) + return; + + var session = GetSession(); + var service = new FirewallService(); + int? vmid = VmId > 0 ? VmId : (int?)null; + + var config = new Dictionary(); + + if (!string.IsNullOrEmpty(Type)) + config["type"] = Type!; + if (!string.IsNullOrEmpty(Action)) + config["action"] = Action!; + if (Enable.IsPresent) + config["enable"] = "1"; + if (!string.IsNullOrEmpty(Source)) + config["source"] = Source!; + if (!string.IsNullOrEmpty(Dest)) + config["dest"] = Dest!; + if (!string.IsNullOrEmpty(Proto)) + config["proto"] = Proto!; + if (!string.IsNullOrEmpty(Dport)) + config["dport"] = Dport!; + if (!string.IsNullOrEmpty(Sport)) + config["sport"] = Sport!; + if (!string.IsNullOrEmpty(Comment)) + config["comment"] = Comment!; + if (!string.IsNullOrEmpty(Macro)) + config["macro"] = Macro!; + if (!string.IsNullOrEmpty(Log)) + config["log"] = Log!; + if (!string.IsNullOrEmpty(Iface)) + config["iface"] = Iface!; + + WriteVerbose($"Updating firewall rule at position {Position} ({level})..."); + service.UpdateRule(session, level, Position, config, Node, vmid); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnControllerCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnControllerCmdlet.cs new file mode 100644 index 0000000..62f6cc7 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnControllerCmdlet.cs @@ -0,0 +1,41 @@ +using System.Management.Automation; +using PSProxmoxVE.Core.Models.Network; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Network +{ + /// + /// Lists SDN controllers in Proxmox VE. + /// + /// Returns Software-Defined Networking controller definitions. + /// Optionally filters by controller identifier. + /// Requires Proxmox VE 8.0 or later. + /// + /// + [Cmdlet(VerbsCommon.Get, "PveSdnController")] + [OutputType(typeof(PveSdnController))] + public class GetPveSdnControllerCmdlet : PveCmdletBase + { + /// Optional controller identifier filter. + [Parameter(Mandatory = false, Position = 0, HelpMessage = "Filter by controller identifier.")] + public string? Controller { get; set; } + + protected override void ProcessRecord() + { + var session = GetSession(); + var service = new NetworkService(); + + WriteVerbose("Getting SDN controllers..."); + var controllers = service.GetSdnControllers(session); + + foreach (var item in controllers) + { + if (!string.IsNullOrEmpty(Controller) && + !string.Equals(item.Controller, Controller, System.StringComparison.OrdinalIgnoreCase)) + continue; + + WriteObject(item); + } + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnDnsCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnDnsCmdlet.cs new file mode 100644 index 0000000..5eb6fc5 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnDnsCmdlet.cs @@ -0,0 +1,41 @@ +using System.Management.Automation; +using PSProxmoxVE.Core.Models.Network; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Network +{ + /// + /// Lists SDN DNS plugins in Proxmox VE. + /// + /// Returns Software-Defined Networking DNS plugin definitions. + /// Optionally filters by DNS identifier. + /// Requires Proxmox VE 8.0 or later. + /// + /// + [Cmdlet(VerbsCommon.Get, "PveSdnDns")] + [OutputType(typeof(PveSdnDns))] + public class GetPveSdnDnsCmdlet : PveCmdletBase + { + /// Optional DNS plugin identifier filter. + [Parameter(Mandatory = false, Position = 0, HelpMessage = "Filter by DNS plugin identifier.")] + public string? Dns { get; set; } + + protected override void ProcessRecord() + { + var session = GetSession(); + var service = new NetworkService(); + + WriteVerbose("Getting SDN DNS plugins..."); + var plugins = service.GetSdnDnsPlugins(session); + + foreach (var item in plugins) + { + if (!string.IsNullOrEmpty(Dns) && + !string.Equals(item.Dns, Dns, System.StringComparison.OrdinalIgnoreCase)) + continue; + + WriteObject(item); + } + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnIpamCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnIpamCmdlet.cs new file mode 100644 index 0000000..1898587 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnIpamCmdlet.cs @@ -0,0 +1,41 @@ +using System.Management.Automation; +using PSProxmoxVE.Core.Models.Network; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Network +{ + /// + /// Lists SDN IPAM plugins in Proxmox VE. + /// + /// Returns Software-Defined Networking IPAM plugin definitions. + /// Optionally filters by IPAM identifier. + /// Requires Proxmox VE 8.0 or later. + /// + /// + [Cmdlet(VerbsCommon.Get, "PveSdnIpam")] + [OutputType(typeof(PveSdnIpam))] + public class GetPveSdnIpamCmdlet : PveCmdletBase + { + /// Optional IPAM identifier filter. + [Parameter(Mandatory = false, Position = 0, HelpMessage = "Filter by IPAM plugin identifier.")] + public string? Ipam { get; set; } + + protected override void ProcessRecord() + { + var session = GetSession(); + var service = new NetworkService(); + + WriteVerbose("Getting SDN IPAM plugins..."); + var ipams = service.GetSdnIpams(session); + + foreach (var item in ipams) + { + if (!string.IsNullOrEmpty(Ipam) && + !string.Equals(item.Ipam, Ipam, System.StringComparison.OrdinalIgnoreCase)) + continue; + + WriteObject(item); + } + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnControllerCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnControllerCmdlet.cs new file mode 100644 index 0000000..3faa7f2 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnControllerCmdlet.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using System.Management.Automation; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Network +{ + /// + /// Creates a new SDN controller in Proxmox VE. + /// + /// Adds a new Software-Defined Networking controller. + /// Requires Proxmox VE 8.0 or later. + /// + /// + [Cmdlet(VerbsCommon.New, "PveSdnController", SupportsShouldProcess = true)] + public class NewPveSdnControllerCmdlet : PveCmdletBase + { + /// The controller identifier. + [Parameter(Mandatory = true, Position = 0, HelpMessage = "The controller identifier.")] + public string Controller { get; set; } = string.Empty; + + /// The controller type. + [Parameter(Mandatory = true, Position = 1, HelpMessage = "The controller type.")] + [ValidateSet("evpn", "bgp")] + public string Type { get; set; } = string.Empty; + + /// The Autonomous System Number for BGP/EVPN. + [Parameter(Mandatory = false, HelpMessage = "The Autonomous System Number for BGP/EVPN.")] + public int? Asn { get; set; } + + /// Comma-separated list of BGP peer addresses. + [Parameter(Mandatory = false, HelpMessage = "Comma-separated list of BGP peer addresses.")] + public string? Peers { get; set; } + + /// The node this controller is configured on. + [Parameter(Mandatory = false, HelpMessage = "The node this controller is configured on.")] + public string? Node { get; set; } + + protected override void ProcessRecord() + { + if (!ShouldProcess($"SDN Controller '{Controller}'", "Create PVE SDN Controller")) + return; + + var session = GetSession(); + var service = new NetworkService(); + + WriteVerbose($"Creating SDN controller '{Controller}' of type '{Type}'..."); + var data = new Dictionary + { + ["controller"] = Controller, + ["type"] = Type + }; + + if (Asn.HasValue) data["asn"] = Asn.Value.ToString(); + if (!string.IsNullOrEmpty(Peers)) data["peers"] = Peers!; + if (!string.IsNullOrEmpty(Node)) data["node"] = Node!; + + service.CreateSdnController(session, data); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnDnsCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnDnsCmdlet.cs new file mode 100644 index 0000000..e00a164 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnDnsCmdlet.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.Management.Automation; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Network +{ + /// + /// Creates a new SDN DNS plugin in Proxmox VE. + /// + /// Adds a new Software-Defined Networking DNS plugin. + /// Requires Proxmox VE 8.0 or later. + /// + /// + [Cmdlet(VerbsCommon.New, "PveSdnDns", SupportsShouldProcess = true)] + public class NewPveSdnDnsCmdlet : PveCmdletBase + { + /// The DNS plugin identifier. + [Parameter(Mandatory = true, Position = 0, HelpMessage = "The DNS plugin identifier.")] + public string Dns { get; set; } = string.Empty; + + /// The DNS plugin type. + [Parameter(Mandatory = true, Position = 1, HelpMessage = "The DNS plugin type.")] + [ValidateSet("powerdns")] + public string Type { get; set; } = string.Empty; + + /// The URL of the DNS service API. + [Parameter(Mandatory = true, HelpMessage = "The URL of the DNS service API.")] + public string Url { get; set; } = string.Empty; + + /// The API key for the DNS service. + [Parameter(Mandatory = true, HelpMessage = "The API key for the DNS service.")] + public string Key { get; set; } = string.Empty; + + /// The IPv6 reverse zone mask length. + [Parameter(Mandatory = false, HelpMessage = "The IPv6 reverse zone mask length.")] + public int? ReverseMaskV6 { get; set; } + + /// The TTL (time-to-live) for DNS records. + [Parameter(Mandatory = false, HelpMessage = "The TTL (time-to-live) for DNS records.")] + public int? Ttl { get; set; } + + protected override void ProcessRecord() + { + if (!ShouldProcess($"SDN DNS '{Dns}'", "Create PVE SDN DNS")) + return; + + var session = GetSession(); + var service = new NetworkService(); + + WriteVerbose($"Creating SDN DNS plugin '{Dns}' of type '{Type}'..."); + var data = new Dictionary + { + ["dns"] = Dns, + ["type"] = Type, + ["url"] = Url, + ["key"] = Key + }; + + if (ReverseMaskV6.HasValue) data["reversemaskv6"] = ReverseMaskV6.Value.ToString(); + if (Ttl.HasValue) data["ttl"] = Ttl.Value.ToString(); + + service.CreateSdnDnsPlugin(session, data); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnIpamCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnIpamCmdlet.cs new file mode 100644 index 0000000..ff666d6 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnIpamCmdlet.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using System.Management.Automation; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Network +{ + /// + /// Creates a new SDN IPAM plugin in Proxmox VE. + /// + /// Adds a new Software-Defined Networking IPAM plugin. + /// Requires Proxmox VE 8.0 or later. + /// + /// + [Cmdlet(VerbsCommon.New, "PveSdnIpam", SupportsShouldProcess = true)] + public class NewPveSdnIpamCmdlet : PveCmdletBase + { + /// The IPAM plugin identifier. + [Parameter(Mandatory = true, Position = 0, HelpMessage = "The IPAM plugin identifier.")] + public string Ipam { get; set; } = string.Empty; + + /// The IPAM plugin type. + [Parameter(Mandatory = true, Position = 1, HelpMessage = "The IPAM plugin type.")] + [ValidateSet("pve", "netbox", "phpipam")] + public string Type { get; set; } = string.Empty; + + /// The URL of the external IPAM service. + [Parameter(Mandatory = false, HelpMessage = "The URL of the external IPAM service.")] + public string? Url { get; set; } + + /// The API token for the external IPAM service. + [Parameter(Mandatory = false, HelpMessage = "The API token for the external IPAM service.")] + public string? Token { get; set; } + + /// The configuration section identifier. + [Parameter(Mandatory = false, HelpMessage = "The configuration section identifier.")] + public int? Section { get; set; } + + protected override void ProcessRecord() + { + if (!ShouldProcess($"SDN IPAM '{Ipam}'", "Create PVE SDN IPAM")) + return; + + var session = GetSession(); + var service = new NetworkService(); + + WriteVerbose($"Creating SDN IPAM plugin '{Ipam}' of type '{Type}'..."); + var data = new Dictionary + { + ["ipam"] = Ipam, + ["type"] = Type + }; + + if (!string.IsNullOrEmpty(Url)) data["url"] = Url!; + if (!string.IsNullOrEmpty(Token)) data["token"] = Token!; + if (Section.HasValue) data["section"] = Section.Value.ToString(); + + service.CreateSdnIpam(session, data); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnControllerCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnControllerCmdlet.cs new file mode 100644 index 0000000..dfc85a1 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnControllerCmdlet.cs @@ -0,0 +1,32 @@ +using System.Management.Automation; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Network +{ + /// + /// Removes an SDN controller from Proxmox VE. + /// + /// Deletes the specified Software-Defined Networking controller. + /// Requires Proxmox VE 8.0 or later. + /// + /// + [Cmdlet(VerbsCommon.Remove, "PveSdnController", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] + public class RemovePveSdnControllerCmdlet : PveCmdletBase + { + /// The controller identifier to remove. + [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true, HelpMessage = "The controller identifier to remove.")] + public string Controller { get; set; } = string.Empty; + + protected override void ProcessRecord() + { + if (!ShouldProcess($"SDN Controller '{Controller}'", "Remove PVE SDN Controller")) + return; + + var session = GetSession(); + var service = new NetworkService(); + + WriteVerbose($"Removing SDN controller '{Controller}'..."); + service.RemoveSdnController(session, Controller); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnDnsCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnDnsCmdlet.cs new file mode 100644 index 0000000..508934c --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnDnsCmdlet.cs @@ -0,0 +1,32 @@ +using System.Management.Automation; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Network +{ + /// + /// Removes an SDN DNS plugin from Proxmox VE. + /// + /// Deletes the specified Software-Defined Networking DNS plugin. + /// Requires Proxmox VE 8.0 or later. + /// + /// + [Cmdlet(VerbsCommon.Remove, "PveSdnDns", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] + public class RemovePveSdnDnsCmdlet : PveCmdletBase + { + /// The DNS plugin identifier to remove. + [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true, HelpMessage = "The DNS plugin identifier to remove.")] + public string Dns { get; set; } = string.Empty; + + protected override void ProcessRecord() + { + if (!ShouldProcess($"SDN DNS '{Dns}'", "Remove PVE SDN DNS")) + return; + + var session = GetSession(); + var service = new NetworkService(); + + WriteVerbose($"Removing SDN DNS plugin '{Dns}'..."); + service.RemoveSdnDnsPlugin(session, Dns); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnIpamCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnIpamCmdlet.cs new file mode 100644 index 0000000..b9fa9fa --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnIpamCmdlet.cs @@ -0,0 +1,32 @@ +using System.Management.Automation; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Network +{ + /// + /// Removes an SDN IPAM plugin from Proxmox VE. + /// + /// Deletes the specified Software-Defined Networking IPAM plugin. + /// Requires Proxmox VE 8.0 or later. + /// + /// + [Cmdlet(VerbsCommon.Remove, "PveSdnIpam", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] + public class RemovePveSdnIpamCmdlet : PveCmdletBase + { + /// The IPAM plugin identifier to remove. + [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true, HelpMessage = "The IPAM plugin identifier to remove.")] + public string Ipam { get; set; } = string.Empty; + + protected override void ProcessRecord() + { + if (!ShouldProcess($"SDN IPAM '{Ipam}'", "Remove PVE SDN IPAM")) + return; + + var session = GetSession(); + var service = new NetworkService(); + + WriteVerbose($"Removing SDN IPAM plugin '{Ipam}'..."); + service.RemoveSdnIpam(session, Ipam); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Users/RemovePveRoleCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Users/RemovePveRoleCmdlet.cs index c627068..0770e8c 100644 --- a/src/PSProxmoxVE/Cmdlets/Users/RemovePveRoleCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Users/RemovePveRoleCmdlet.cs @@ -10,7 +10,7 @@ namespace PSProxmoxVE.Cmdlets.Users /// Built-in roles cannot be removed. /// /// - [Cmdlet(VerbsCommon.Remove, "PveRole", SupportsShouldProcess = true)] + [Cmdlet(VerbsCommon.Remove, "PveRole", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] public class RemovePveRoleCmdlet : PveCmdletBase { /// The role identifier to remove. diff --git a/src/PSProxmoxVE/PSProxmoxVE.format.ps1xml b/src/PSProxmoxVE/PSProxmoxVE.format.ps1xml index 90e7cf9..c0c2c79 100644 --- a/src/PSProxmoxVE/PSProxmoxVE.format.ps1xml +++ b/src/PSProxmoxVE/PSProxmoxVE.format.ps1xml @@ -442,5 +442,507 @@ + + + PSProxmoxVE.Core.Models.Firewall.PveFirewallRule + + PSProxmoxVE.Core.Models.Firewall.PveFirewallRule + + + + + + 5 + Right + + + + 6 + Left + + + + 8 + Left + + + + 6 + Left + + + + 12 + Left + + + + 20 + Left + + + + 20 + Left + + + + 7 + Left + + + + Left + + + + + + + Pos + + + Type + + + Action + + + Proto + + + Dport + + + Source + + + Dest + + + if ($_.Enable -eq 1) { 'Yes' } else { 'No' } + + + Comment + + + + + + + + + + PSProxmoxVE.Core.Models.Firewall.PveFirewallGroup + + PSProxmoxVE.Core.Models.Firewall.PveFirewallGroup + + + + + + 25 + Left + + + + Left + + + + + + + Group + + + Comment + + + + + + + + + + PSProxmoxVE.Core.Models.Firewall.PveFirewallAlias + + PSProxmoxVE.Core.Models.Firewall.PveFirewallAlias + + + + + + 25 + Left + + + + 25 + Left + + + + Left + + + + + + + Name + + + Cidr + + + Comment + + + + + + + + + + PSProxmoxVE.Core.Models.Firewall.PveFirewallIpSet + + PSProxmoxVE.Core.Models.Firewall.PveFirewallIpSet + + + + + + 25 + Left + + + + Left + + + + + + + Name + + + Comment + + + + + + + + + + PSProxmoxVE.Core.Models.Firewall.PveFirewallIpSetEntry + + PSProxmoxVE.Core.Models.Firewall.PveFirewallIpSetEntry + + + + + + 25 + Left + + + + 8 + Left + + + + Left + + + + + + + Cidr + + + if ($_.NoMatch -eq 1) { 'Yes' } else { 'No' } + + + Comment + + + + + + + + + + PSProxmoxVE.Core.Models.Firewall.PveFirewallOptions + + PSProxmoxVE.Core.Models.Firewall.PveFirewallOptions + + + + + + 8 + Left + + + + 10 + Left + + + + 10 + Left + + + + 12 + Left + + + + 12 + Left + + + + + + + if ($_.Enable -eq 1) { 'Yes' } else { 'No' } + + + PolicyIn + + + PolicyOut + + + LogLevelIn + + + LogLevelOut + + + + + + + + + + PSProxmoxVE.Core.Models.Backup.PveBackupJob + + PSProxmoxVE.Core.Models.Backup.PveBackupJob + + + + + + 20 + Left + + + + 8 + Left + + + + 16 + Left + + + + 15 + Left + + + + 10 + Left + + + + 15 + Left + + + + Left + + + + + + + Id + + + if ($_.Enabled -eq 1) { 'Yes' } else { 'No' } + + + Schedule + + + Storage + + + Mode + + + VmId + + + Comment + + + + + + + + + + PSProxmoxVE.Core.Models.Network.PveSdnIpam + + PSProxmoxVE.Core.Models.Network.PveSdnIpam + + + + + + 20 + Left + + + + 15 + Left + + + + + + + Ipam + + + Type + + + + + + + + + + PSProxmoxVE.Core.Models.Network.PveSdnDns + + PSProxmoxVE.Core.Models.Network.PveSdnDns + + + + + + 20 + Left + + + + 15 + Left + + + + 40 + Left + + + + + + + Dns + + + Type + + + Url + + + + + + + + + + PSProxmoxVE.Core.Models.Network.PveSdnController + + PSProxmoxVE.Core.Models.Network.PveSdnController + + + + + + 20 + Left + + + + 10 + Left + + + + 10 + Right + + + + 30 + Left + + + + 15 + Left + + + + + + + Controller + + + Type + + + Asn + + + Peers + + + Node + + + + + + + diff --git a/src/PSProxmoxVE/PSProxmoxVE.psd1 b/src/PSProxmoxVE/PSProxmoxVE.psd1 index 3d43515..6bcc6fa 100644 --- a/src/PSProxmoxVE/PSProxmoxVE.psd1 +++ b/src/PSProxmoxVE/PSProxmoxVE.psd1 @@ -170,7 +170,52 @@ # Tasks 'Get-PveTask', - 'Wait-PveTask' + 'Wait-PveTask', + + # Firewall + 'Get-PveFirewallRule', + 'New-PveFirewallRule', + 'Set-PveFirewallRule', + 'Remove-PveFirewallRule', + 'Get-PveFirewallGroup', + 'New-PveFirewallGroup', + 'Remove-PveFirewallGroup', + 'Get-PveFirewallAlias', + 'New-PveFirewallAlias', + 'Set-PveFirewallAlias', + 'Remove-PveFirewallAlias', + 'Get-PveFirewallIpSet', + 'New-PveFirewallIpSet', + 'Remove-PveFirewallIpSet', + 'Get-PveFirewallIpSetEntry', + 'New-PveFirewallIpSetEntry', + 'Set-PveFirewallIpSetEntry', + 'Remove-PveFirewallIpSetEntry', + 'Get-PveFirewallOptions', + 'Set-PveFirewallOptions', + 'Get-PveFirewallRef', + + # Backup + 'New-PveBackup', + 'Get-PveBackupJob', + 'New-PveBackupJob', + 'Set-PveBackupJob', + 'Remove-PveBackupJob', + + # SDN — IPAM + 'Get-PveSdnIpam', + 'New-PveSdnIpam', + 'Remove-PveSdnIpam', + + # SDN — DNS + 'Get-PveSdnDns', + 'New-PveSdnDns', + 'Remove-PveSdnDns', + + # SDN — Controller + 'Get-PveSdnController', + 'New-PveSdnController', + 'Remove-PveSdnController' ) # Variables to export from this module diff --git a/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_backup_jobs.json b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_backup_jobs.json new file mode 100644 index 0000000..c0faf3a --- /dev/null +++ b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_backup_jobs.json @@ -0,0 +1 @@ +{"data":[{"id":"backup-1a2b3c","type":"vzdump","enabled":1,"schedule":"0 2 * * *","storage":"local","mode":"snapshot","compress":"zstd","vmid":"100,101","comment":"Nightly backup"},{"id":"backup-4d5e6f","type":"vzdump","enabled":0,"schedule":"0 4 * * 0","storage":"nfs-backup","mode":"stop","all":1,"comment":"Weekly full"}]} \ No newline at end of file diff --git a/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_aliases.json b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_aliases.json new file mode 100644 index 0000000..a0ffdd7 --- /dev/null +++ b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_aliases.json @@ -0,0 +1 @@ +{"data":[{"name":"local_network","cidr":"10.0.0.0/8","comment":"RFC1918 private"},{"name":"dns_server","cidr":"8.8.8.8","comment":"Google DNS"}]} \ No newline at end of file diff --git a/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_groups.json b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_groups.json new file mode 100644 index 0000000..8b1bf90 --- /dev/null +++ b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_groups.json @@ -0,0 +1 @@ +{"data":[{"group":"webservers","comment":"HTTP/HTTPS servers","digest":"abc123"},{"group":"dbservers","comment":"Database servers","digest":"def456"}]} \ No newline at end of file diff --git a/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_ipset_entries.json b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_ipset_entries.json new file mode 100644 index 0000000..ffc3941 --- /dev/null +++ b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_ipset_entries.json @@ -0,0 +1 @@ +{"data":[{"cidr":"192.168.1.0/24","nomatch":0,"comment":"Local subnet"},{"cidr":"10.0.0.0/8","nomatch":1,"comment":"Exclude private"}]} \ No newline at end of file diff --git a/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_ipsets.json b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_ipsets.json new file mode 100644 index 0000000..ba5f149 --- /dev/null +++ b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_ipsets.json @@ -0,0 +1 @@ +{"data":[{"name":"blocklist","comment":"Blocked IPs","digest":"aaa111"},{"name":"allowlist","comment":"Allowed IPs","digest":"bbb222"}]} \ No newline at end of file diff --git a/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_options.json b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_options.json new file mode 100644 index 0000000..ddad993 --- /dev/null +++ b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_options.json @@ -0,0 +1 @@ +{"data":{"enable":1,"policy_in":"DROP","policy_out":"ACCEPT","log_level_in":"nolog","log_level_out":"nolog"}} \ No newline at end of file diff --git a/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_refs.json b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_refs.json new file mode 100644 index 0000000..fe3e9f9 --- /dev/null +++ b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_refs.json @@ -0,0 +1 @@ +{"data":[{"type":"alias","name":"local_network","comment":"RFC1918 private"},{"type":"ipset","name":"blocklist","comment":"Blocked IPs"}]} \ No newline at end of file diff --git a/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_rules.json b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_rules.json new file mode 100644 index 0000000..63110b9 --- /dev/null +++ b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_rules.json @@ -0,0 +1 @@ +{"data":[{"pos":0,"type":"in","action":"ACCEPT","proto":"tcp","dport":"80","enable":1,"comment":"Allow HTTP","log":"nolog"},{"pos":1,"type":"in","action":"ACCEPT","proto":"tcp","dport":"443","enable":1,"comment":"Allow HTTPS"},{"pos":2,"type":"out","action":"DROP","enable":0,"comment":"Block outbound (disabled)"}]} \ No newline at end of file diff --git a/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_sdn_controllers.json b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_sdn_controllers.json new file mode 100644 index 0000000..1b0d846 --- /dev/null +++ b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_sdn_controllers.json @@ -0,0 +1 @@ +{"data":[{"controller":"evpn1","type":"evpn","asn":65000,"peers":"10.0.0.1,10.0.0.2","node":"pve1"}]} \ No newline at end of file diff --git a/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_sdn_dns.json b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_sdn_dns.json new file mode 100644 index 0000000..f9c3461 --- /dev/null +++ b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_sdn_dns.json @@ -0,0 +1 @@ +{"data":[{"dns":"powerdns1","type":"powerdns","url":"http://dns.example.com:8081","key":"secret123","ttl":3600}]} \ No newline at end of file diff --git a/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_sdn_ipams.json b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_sdn_ipams.json new file mode 100644 index 0000000..ef3cc23 --- /dev/null +++ b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_sdn_ipams.json @@ -0,0 +1 @@ +{"data":[{"ipam":"pve","type":"pve"},{"ipam":"netbox1","type":"netbox","url":"https://netbox.example.com","token":"abc123","section":1}]} \ No newline at end of file diff --git a/tests/PSProxmoxVE.Core.Tests/Models/BackupModelTests.cs b/tests/PSProxmoxVE.Core.Tests/Models/BackupModelTests.cs new file mode 100644 index 0000000..d7671f5 --- /dev/null +++ b/tests/PSProxmoxVE.Core.Tests/Models/BackupModelTests.cs @@ -0,0 +1,126 @@ +using Xunit; +using Newtonsoft.Json.Linq; +using PSProxmoxVE.Core.Models.Backup; + +namespace PSProxmoxVE.Core.Tests.Models +{ + public class BackupModelTests + { + [Fact] + public void PveBackupJob_Deserialize_Pve9_ReturnsCorrectCount() + { + var json = TestHelper.LoadFixture("pve9_backup_jobs.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var jobs = data.ToObject(); + Assert.NotNull(jobs); + Assert.Equal(2, jobs.Length); + } + + [Fact] + public void PveBackupJob_Deserialize_Pve9_FirstJob_HasCorrectId() + { + var json = TestHelper.LoadFixture("pve9_backup_jobs.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var jobs = data.ToObject(); + Assert.NotNull(jobs); + Assert.Equal("backup-1a2b3c", jobs[0].Id); + } + + [Fact] + public void PveBackupJob_Deserialize_Pve9_FirstJob_IsEnabled() + { + var json = TestHelper.LoadFixture("pve9_backup_jobs.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var jobs = data.ToObject(); + Assert.NotNull(jobs); + Assert.Equal(1, jobs[0].Enabled); + } + + [Fact] + public void PveBackupJob_Deserialize_Pve9_FirstJob_HasCorrectProperties() + { + var json = TestHelper.LoadFixture("pve9_backup_jobs.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var jobs = data.ToObject(); + Assert.NotNull(jobs); + Assert.Equal("vzdump", jobs[0].Type); + Assert.Equal("0 2 * * *", jobs[0].Schedule); + Assert.Equal("local", jobs[0].Storage); + Assert.Equal("snapshot", jobs[0].Mode); + Assert.Equal("zstd", jobs[0].Compress); + Assert.Equal("100,101", jobs[0].VmId); + Assert.Equal("Nightly backup", jobs[0].Comment); + } + + [Fact] + public void PveBackupJob_Deserialize_Pve9_FirstJob_AllIsNull() + { + var json = TestHelper.LoadFixture("pve9_backup_jobs.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var jobs = data.ToObject(); + Assert.NotNull(jobs); + Assert.Null(jobs[0].All); + } + + [Fact] + public void PveBackupJob_Deserialize_Pve9_SecondJob_IsDisabled() + { + var json = TestHelper.LoadFixture("pve9_backup_jobs.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var jobs = data.ToObject(); + Assert.NotNull(jobs); + Assert.Equal(0, jobs[1].Enabled); + } + + [Fact] + public void PveBackupJob_Deserialize_Pve9_SecondJob_HasCorrectProperties() + { + var json = TestHelper.LoadFixture("pve9_backup_jobs.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var jobs = data.ToObject(); + Assert.NotNull(jobs); + Assert.Equal("backup-4d5e6f", jobs[1].Id); + Assert.Equal("vzdump", jobs[1].Type); + Assert.Equal("0 4 * * 0", jobs[1].Schedule); + Assert.Equal("nfs-backup", jobs[1].Storage); + Assert.Equal("stop", jobs[1].Mode); + Assert.Equal("Weekly full", jobs[1].Comment); + } + + [Fact] + public void PveBackupJob_Deserialize_Pve9_SecondJob_BacksUpAll() + { + var json = TestHelper.LoadFixture("pve9_backup_jobs.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var jobs = data.ToObject(); + Assert.NotNull(jobs); + Assert.Equal(1, jobs[1].All); + Assert.Null(jobs[1].VmId); + } + + [Fact] + public void PveBackupJob_Deserialize_Pve9_SecondJob_OptionalFieldsAreNull() + { + var json = TestHelper.LoadFixture("pve9_backup_jobs.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var jobs = data.ToObject(); + Assert.NotNull(jobs); + Assert.Null(jobs[1].Compress); + Assert.Null(jobs[1].MaxFiles); + Assert.Null(jobs[1].PruneBackups); + Assert.Null(jobs[1].MailNotification); + Assert.Null(jobs[1].MailTo); + Assert.Null(jobs[1].Node); + Assert.Null(jobs[1].Exclude); + } + } +} diff --git a/tests/PSProxmoxVE.Core.Tests/Models/FirewallModelTests.cs b/tests/PSProxmoxVE.Core.Tests/Models/FirewallModelTests.cs new file mode 100644 index 0000000..069e3a7 --- /dev/null +++ b/tests/PSProxmoxVE.Core.Tests/Models/FirewallModelTests.cs @@ -0,0 +1,330 @@ +using Xunit; +using Newtonsoft.Json.Linq; +using PSProxmoxVE.Core.Models.Firewall; + +namespace PSProxmoxVE.Core.Tests.Models +{ + public class FirewallModelTests + { + // ── PveFirewallRule ────────────────────────────────────────────── + + [Fact] + public void PveFirewallRule_Deserialize_Pve9_ReturnsCorrectCount() + { + var json = TestHelper.LoadFixture("pve9_firewall_rules.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var rules = data.ToObject(); + Assert.NotNull(rules); + Assert.Equal(3, rules.Length); + } + + [Fact] + public void PveFirewallRule_Deserialize_Pve9_FirstRule_HasCorrectProperties() + { + var json = TestHelper.LoadFixture("pve9_firewall_rules.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var rules = data.ToObject(); + Assert.NotNull(rules); + Assert.Equal(0, rules[0].Pos); + Assert.Equal("in", rules[0].Type); + Assert.Equal("ACCEPT", rules[0].Action); + Assert.Equal("tcp", rules[0].Proto); + Assert.Equal("80", rules[0].Dport); + Assert.Equal(1, rules[0].Enable); + Assert.Equal("Allow HTTP", rules[0].Comment); + } + + [Fact] + public void PveFirewallRule_Deserialize_Pve9_FirstRule_HasLogLevel() + { + var json = TestHelper.LoadFixture("pve9_firewall_rules.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var rules = data.ToObject(); + Assert.NotNull(rules); + Assert.NotNull(rules[0].Log); + Assert.Equal("nolog", rules[0].Log); + } + + [Fact] + public void PveFirewallRule_Deserialize_Pve9_SecondRule_LogIsNull() + { + var json = TestHelper.LoadFixture("pve9_firewall_rules.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var rules = data.ToObject(); + Assert.NotNull(rules); + Assert.Null(rules[1].Log); + } + + [Fact] + public void PveFirewallRule_Deserialize_Pve9_ThirdRule_IsOutboundDrop() + { + var json = TestHelper.LoadFixture("pve9_firewall_rules.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var rules = data.ToObject(); + Assert.NotNull(rules); + Assert.Equal(2, rules[2].Pos); + Assert.Equal("out", rules[2].Type); + Assert.Equal("DROP", rules[2].Action); + Assert.Equal(0, rules[2].Enable); + } + + [Fact] + public void PveFirewallRule_Deserialize_Pve9_ThirdRule_NullableFieldsAreNull() + { + var json = TestHelper.LoadFixture("pve9_firewall_rules.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var rules = data.ToObject(); + Assert.NotNull(rules); + Assert.Null(rules[2].Proto); + Assert.Null(rules[2].Dport); + Assert.Null(rules[2].Sport); + Assert.Null(rules[2].Source); + Assert.Null(rules[2].Dest); + Assert.Null(rules[2].Macro); + Assert.Null(rules[2].Iface); + } + + // ── PveFirewallGroup ──────────────────────────────────────────── + + [Fact] + public void PveFirewallGroup_Deserialize_Pve9_ReturnsCorrectCount() + { + var json = TestHelper.LoadFixture("pve9_firewall_groups.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var groups = data.ToObject(); + Assert.NotNull(groups); + Assert.Equal(2, groups.Length); + } + + [Fact] + public void PveFirewallGroup_Deserialize_Pve9_FirstGroup_HasCorrectProperties() + { + var json = TestHelper.LoadFixture("pve9_firewall_groups.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var groups = data.ToObject(); + Assert.NotNull(groups); + Assert.Equal("webservers", groups[0].Group); + Assert.Equal("HTTP/HTTPS servers", groups[0].Comment); + Assert.Equal("abc123", groups[0].Digest); + } + + [Fact] + public void PveFirewallGroup_Deserialize_Pve9_SecondGroup_HasCorrectProperties() + { + var json = TestHelper.LoadFixture("pve9_firewall_groups.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var groups = data.ToObject(); + Assert.NotNull(groups); + Assert.Equal("dbservers", groups[1].Group); + Assert.Equal("Database servers", groups[1].Comment); + Assert.Equal("def456", groups[1].Digest); + } + + // ── PveFirewallAlias ──────────────────────────────────────────── + + [Fact] + public void PveFirewallAlias_Deserialize_Pve9_ReturnsCorrectCount() + { + var json = TestHelper.LoadFixture("pve9_firewall_aliases.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var aliases = data.ToObject(); + Assert.NotNull(aliases); + Assert.Equal(2, aliases.Length); + } + + [Fact] + public void PveFirewallAlias_Deserialize_Pve9_FirstAlias_HasCorrectProperties() + { + var json = TestHelper.LoadFixture("pve9_firewall_aliases.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var aliases = data.ToObject(); + Assert.NotNull(aliases); + Assert.Equal("local_network", aliases[0].Name); + Assert.Equal("10.0.0.0/8", aliases[0].Cidr); + Assert.Equal("RFC1918 private", aliases[0].Comment); + } + + [Fact] + public void PveFirewallAlias_Deserialize_Pve9_SecondAlias_HasCorrectProperties() + { + var json = TestHelper.LoadFixture("pve9_firewall_aliases.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var aliases = data.ToObject(); + Assert.NotNull(aliases); + Assert.Equal("dns_server", aliases[1].Name); + Assert.Equal("8.8.8.8", aliases[1].Cidr); + Assert.Equal("Google DNS", aliases[1].Comment); + } + + // ── PveFirewallIpSet ──────────────────────────────────────────── + + [Fact] + public void PveFirewallIpSet_Deserialize_Pve9_ReturnsCorrectCount() + { + var json = TestHelper.LoadFixture("pve9_firewall_ipsets.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var ipsets = data.ToObject(); + Assert.NotNull(ipsets); + Assert.Equal(2, ipsets.Length); + } + + [Fact] + public void PveFirewallIpSet_Deserialize_Pve9_FirstIpSet_HasCorrectProperties() + { + var json = TestHelper.LoadFixture("pve9_firewall_ipsets.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var ipsets = data.ToObject(); + Assert.NotNull(ipsets); + Assert.Equal("blocklist", ipsets[0].Name); + Assert.Equal("Blocked IPs", ipsets[0].Comment); + Assert.Equal("aaa111", ipsets[0].Digest); + } + + [Fact] + public void PveFirewallIpSet_Deserialize_Pve9_SecondIpSet_HasCorrectProperties() + { + var json = TestHelper.LoadFixture("pve9_firewall_ipsets.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var ipsets = data.ToObject(); + Assert.NotNull(ipsets); + Assert.Equal("allowlist", ipsets[1].Name); + Assert.Equal("Allowed IPs", ipsets[1].Comment); + Assert.Equal("bbb222", ipsets[1].Digest); + } + + // ── PveFirewallIpSetEntry ─────────────────────────────────────── + + [Fact] + public void PveFirewallIpSetEntry_Deserialize_Pve9_ReturnsCorrectCount() + { + var json = TestHelper.LoadFixture("pve9_firewall_ipset_entries.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var entries = data.ToObject(); + Assert.NotNull(entries); + Assert.Equal(2, entries.Length); + } + + [Fact] + public void PveFirewallIpSetEntry_Deserialize_Pve9_FirstEntry_HasCorrectProperties() + { + var json = TestHelper.LoadFixture("pve9_firewall_ipset_entries.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var entries = data.ToObject(); + Assert.NotNull(entries); + Assert.Equal("192.168.1.0/24", entries[0].Cidr); + Assert.Equal(0, entries[0].NoMatch); + Assert.Equal("Local subnet", entries[0].Comment); + } + + [Fact] + public void PveFirewallIpSetEntry_Deserialize_Pve9_SecondEntry_IsExclusion() + { + var json = TestHelper.LoadFixture("pve9_firewall_ipset_entries.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var entries = data.ToObject(); + Assert.NotNull(entries); + Assert.Equal("10.0.0.0/8", entries[1].Cidr); + Assert.Equal(1, entries[1].NoMatch); + Assert.Equal("Exclude private", entries[1].Comment); + } + + // ── PveFirewallOptions ────────────────────────────────────────── + + [Fact] + public void PveFirewallOptions_Deserialize_Pve9_ReturnsNotNull() + { + var json = TestHelper.LoadFixture("pve9_firewall_options.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var options = data.ToObject(); + Assert.NotNull(options); + } + + [Fact] + public void PveFirewallOptions_Deserialize_Pve9_HasCorrectProperties() + { + var json = TestHelper.LoadFixture("pve9_firewall_options.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var options = data.ToObject(); + Assert.NotNull(options); + Assert.Equal(1, options.Enable); + Assert.Equal("DROP", options.PolicyIn); + Assert.Equal("ACCEPT", options.PolicyOut); + Assert.Equal("nolog", options.LogLevelIn); + Assert.Equal("nolog", options.LogLevelOut); + } + + [Fact] + public void PveFirewallOptions_Deserialize_Pve9_OptionalFieldsAreNull() + { + var json = TestHelper.LoadFixture("pve9_firewall_options.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var options = data.ToObject(); + Assert.NotNull(options); + Assert.Null(options.Dhcp); + Assert.Null(options.Ndp); + Assert.Null(options.Radv); + Assert.Null(options.MacFilter); + Assert.Null(options.IpFilter); + } + + // ── PveFirewallRef ────────────────────────────────────────────── + + [Fact] + public void PveFirewallRef_Deserialize_Pve9_ReturnsCorrectCount() + { + var json = TestHelper.LoadFixture("pve9_firewall_refs.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var refs = data.ToObject(); + Assert.NotNull(refs); + Assert.Equal(2, refs.Length); + } + + [Fact] + public void PveFirewallRef_Deserialize_Pve9_FirstRef_IsAlias() + { + var json = TestHelper.LoadFixture("pve9_firewall_refs.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var refs = data.ToObject(); + Assert.NotNull(refs); + Assert.Equal("alias", refs[0].Type); + Assert.Equal("local_network", refs[0].Name); + Assert.Equal("RFC1918 private", refs[0].Comment); + } + + [Fact] + public void PveFirewallRef_Deserialize_Pve9_SecondRef_IsIpSet() + { + var json = TestHelper.LoadFixture("pve9_firewall_refs.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var refs = data.ToObject(); + Assert.NotNull(refs); + Assert.Equal("ipset", refs[1].Type); + Assert.Equal("blocklist", refs[1].Name); + Assert.Equal("Blocked IPs", refs[1].Comment); + } + } +} diff --git a/tests/PSProxmoxVE.Core.Tests/Models/SdnModelTests.cs b/tests/PSProxmoxVE.Core.Tests/Models/SdnModelTests.cs index e351111..140f6d9 100644 --- a/tests/PSProxmoxVE.Core.Tests/Models/SdnModelTests.cs +++ b/tests/PSProxmoxVE.Core.Tests/Models/SdnModelTests.cs @@ -142,5 +142,125 @@ namespace PSProxmoxVE.Core.Tests.Models // Comments not present in fixture entries Assert.Null(vnets[0].Comments); } + + // ── PveSdnIpam ───────────────────────────────────────────────── + + [Fact] + public void PveSdnIpam_Deserialize_Pve9_ReturnsCorrectCount() + { + var json = TestHelper.LoadFixture("pve9_sdn_ipams.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var ipams = data.ToObject(); + Assert.NotNull(ipams); + Assert.Equal(2, ipams.Length); + } + + [Fact] + public void PveSdnIpam_Deserialize_Pve9_FirstIpam_IsPveType() + { + var json = TestHelper.LoadFixture("pve9_sdn_ipams.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var ipams = data.ToObject(); + Assert.NotNull(ipams); + Assert.Equal("pve", ipams[0].Ipam); + Assert.Equal("pve", ipams[0].Type); + } + + [Fact] + public void PveSdnIpam_Deserialize_Pve9_FirstIpam_OptionalFieldsAreNull() + { + var json = TestHelper.LoadFixture("pve9_sdn_ipams.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var ipams = data.ToObject(); + Assert.NotNull(ipams); + Assert.Null(ipams[0].Url); + Assert.Null(ipams[0].Token); + Assert.Null(ipams[0].Section); + } + + [Fact] + public void PveSdnIpam_Deserialize_Pve9_SecondIpam_HasNetboxProperties() + { + var json = TestHelper.LoadFixture("pve9_sdn_ipams.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var ipams = data.ToObject(); + Assert.NotNull(ipams); + Assert.Equal("netbox1", ipams[1].Ipam); + Assert.Equal("netbox", ipams[1].Type); + Assert.Equal("https://netbox.example.com", ipams[1].Url); + Assert.Equal("abc123", ipams[1].Token); + Assert.Equal(1, ipams[1].Section); + } + + // ── PveSdnDns ────────────────────────────────────────────────── + + [Fact] + public void PveSdnDns_Deserialize_Pve9_ReturnsCorrectCount() + { + var json = TestHelper.LoadFixture("pve9_sdn_dns.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var dnsEntries = data.ToObject(); + Assert.NotNull(dnsEntries); + Assert.Single(dnsEntries); + } + + [Fact] + public void PveSdnDns_Deserialize_Pve9_FirstEntry_HasCorrectProperties() + { + var json = TestHelper.LoadFixture("pve9_sdn_dns.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var dnsEntries = data.ToObject(); + Assert.NotNull(dnsEntries); + Assert.Equal("powerdns1", dnsEntries[0].Dns); + Assert.Equal("powerdns", dnsEntries[0].Type); + Assert.Equal("http://dns.example.com:8081", dnsEntries[0].Url); + Assert.Equal("secret123", dnsEntries[0].Key); + Assert.Equal(3600, dnsEntries[0].Ttl); + } + + [Fact] + public void PveSdnDns_Deserialize_Pve9_FirstEntry_OptionalFieldsAreNull() + { + var json = TestHelper.LoadFixture("pve9_sdn_dns.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var dnsEntries = data.ToObject(); + Assert.NotNull(dnsEntries); + Assert.Null(dnsEntries[0].ReverseMaskV6); + } + + // ── PveSdnController ─────────────────────────────────────────── + + [Fact] + public void PveSdnController_Deserialize_Pve9_ReturnsCorrectCount() + { + var json = TestHelper.LoadFixture("pve9_sdn_controllers.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var controllers = data.ToObject(); + Assert.NotNull(controllers); + Assert.Single(controllers); + } + + [Fact] + public void PveSdnController_Deserialize_Pve9_FirstController_HasCorrectProperties() + { + var json = TestHelper.LoadFixture("pve9_sdn_controllers.json"); + var data = JObject.Parse(json)["data"]; + Assert.NotNull(data); + var controllers = data.ToObject(); + Assert.NotNull(controllers); + Assert.Equal("evpn1", controllers[0].Controller); + Assert.Equal("evpn", controllers[0].Type); + Assert.Equal(65000, controllers[0].Asn); + Assert.Equal("10.0.0.1,10.0.0.2", controllers[0].Peers); + Assert.Equal("pve1", controllers[0].Node); + } } } diff --git a/tests/PSProxmoxVE.Tests/Backup/BackupCmdlets.Tests.ps1 b/tests/PSProxmoxVE.Tests/Backup/BackupCmdlets.Tests.ps1 new file mode 100644 index 0000000..8e675c8 --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Backup/BackupCmdlets.Tests.ps1 @@ -0,0 +1,291 @@ +#Requires -Module Pester +<# +.SYNOPSIS + Pester 5 tests for backup cmdlets: + New-PveBackup, Get-PveBackupJob, New-PveBackupJob, Set-PveBackupJob, Remove-PveBackupJob. + + All tests are fully offline — no live Proxmox VE target is required. + If a cmdlet is not yet compiled the test is marked Skipped. +#> + +BeforeAll { + . $PSScriptRoot/../_TestHelper.ps1 + + $script:Availability = @{} + foreach ($name in @('New-PveBackup', 'Get-PveBackupJob', 'New-PveBackupJob', 'Set-PveBackupJob', 'Remove-PveBackupJob')) { + $script:Availability[$name] = $null -ne (Get-Command $name -ErrorAction SilentlyContinue) + } + + function Skip-IfMissing([string]$Name) { + if (-not $script:Availability[$Name]) { + Set-ItResult -Skipped -Because "$Name is not yet implemented in this build" + } + } +} + +# --------------------------------------------------------------------------- +# Manifest contract +# --------------------------------------------------------------------------- +Describe 'Backup cmdlets — manifest declarations' { + BeforeAll { + $manifestPath = Join-Path (Get-Module PSProxmoxVE).ModuleBase 'PSProxmoxVE.psd1' + $script:Manifest = if (Test-Path $manifestPath) { Import-PowerShellDataFile $manifestPath } else { $null } + } + + It " should be declared in CmdletsToExport" -TestCases @( + @{ cmdName = 'New-PveBackup' } + @{ cmdName = 'Get-PveBackupJob' } + @{ cmdName = 'New-PveBackupJob' } + @{ cmdName = 'Set-PveBackupJob' } + @{ cmdName = 'Remove-PveBackupJob' } + ) { + if ($null -eq $script:Manifest) { Set-ItResult -Skipped -Because 'Manifest not found'; return } + $script:Manifest.CmdletsToExport | Should -Contain $cmdName + } +} + +# --------------------------------------------------------------------------- +# New-PveBackup +# --------------------------------------------------------------------------- +Describe 'New-PveBackup' { + + BeforeAll { $script:Cmd = Get-Command 'New-PveBackup' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'New-PveBackup' + $script:Cmd | Should -Not -BeNullOrEmpty + } + + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'New-PveBackup' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'ShouldProcess support' { + It 'Should support WhatIf' { + Skip-IfMissing 'New-PveBackup' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + } + + Context 'Parameter metadata' { + It 'Should have VmId parameter' { + Skip-IfMissing 'New-PveBackup' + $script:Cmd.Parameters.ContainsKey('VmId') | Should -BeTrue + } + + It 'Should have Storage parameter' { + Skip-IfMissing 'New-PveBackup' + $script:Cmd.Parameters.ContainsKey('Storage') | Should -BeTrue + } + + It 'Should have Node parameter' { + Skip-IfMissing 'New-PveBackup' + $script:Cmd.Parameters.ContainsKey('Node') | Should -BeTrue + } + + It 'Should have Session parameter' { + Skip-IfMissing 'New-PveBackup' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'New-PveBackup' + { New-PveBackup -VmId 100 -Storage 'local' -Node 'pve' -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Get-PveBackupJob +# --------------------------------------------------------------------------- +Describe 'Get-PveBackupJob' { + + BeforeAll { $script:Cmd = Get-Command 'Get-PveBackupJob' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Get-PveBackupJob' + $script:Cmd | Should -Not -BeNullOrEmpty + } + + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Get-PveBackupJob' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have optional Id parameter' { + Skip-IfMissing 'Get-PveBackupJob' + $script:Cmd.Parameters.ContainsKey('Id') | Should -BeTrue + } + + It 'Should have Session parameter' { + Skip-IfMissing 'Get-PveBackupJob' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Get-PveBackupJob' + { Get-PveBackupJob -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# New-PveBackupJob +# --------------------------------------------------------------------------- +Describe 'New-PveBackupJob' { + + BeforeAll { $script:Cmd = Get-Command 'New-PveBackupJob' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'New-PveBackupJob' + $script:Cmd | Should -Not -BeNullOrEmpty + } + } + + Context 'ShouldProcess support' { + It 'Should support WhatIf' { + Skip-IfMissing 'New-PveBackupJob' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + } + + Context 'Required parameters' { + It 'Storage should be Mandatory' { + Skip-IfMissing 'New-PveBackupJob' + $isMandatory = $script:Cmd.Parameters['Storage'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + + It 'Schedule should be Mandatory' { + Skip-IfMissing 'New-PveBackupJob' + $isMandatory = $script:Cmd.Parameters['Schedule'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + } + + Context 'Optional parameters' { + It 'Should have VmId parameter' { + Skip-IfMissing 'New-PveBackupJob' + $script:Cmd.Parameters.ContainsKey('VmId') | Should -BeTrue + } + + It 'Should have Mode parameter' { + Skip-IfMissing 'New-PveBackupJob' + $script:Cmd.Parameters.ContainsKey('Mode') | Should -BeTrue + } + + It 'Should have Compress parameter' { + Skip-IfMissing 'New-PveBackupJob' + $script:Cmd.Parameters.ContainsKey('Compress') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'New-PveBackupJob' + { New-PveBackupJob -Storage 'local' -Schedule 'daily' -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Set-PveBackupJob +# --------------------------------------------------------------------------- +Describe 'Set-PveBackupJob' { + + BeforeAll { $script:Cmd = Get-Command 'Set-PveBackupJob' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Set-PveBackupJob' + $script:Cmd | Should -Not -BeNullOrEmpty + } + } + + Context 'ShouldProcess support' { + It 'Should support WhatIf' { + Skip-IfMissing 'Set-PveBackupJob' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + } + + Context 'Required parameters' { + It 'Id should be Mandatory' { + Skip-IfMissing 'Set-PveBackupJob' + $isMandatory = $script:Cmd.Parameters['Id'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Set-PveBackupJob' + { Set-PveBackupJob -Id 'backup-test' -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Remove-PveBackupJob +# --------------------------------------------------------------------------- +Describe 'Remove-PveBackupJob' { + + BeforeAll { $script:Cmd = Get-Command 'Remove-PveBackupJob' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Remove-PveBackupJob' + $script:Cmd | Should -Not -BeNullOrEmpty + } + } + + Context 'ShouldProcess / ConfirmImpact' { + It 'Should support WhatIf' { + Skip-IfMissing 'Remove-PveBackupJob' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + + It 'Should declare ConfirmImpact High' { + Skip-IfMissing 'Remove-PveBackupJob' + $attr = $script:Cmd.ImplementingType.GetCustomAttributes( + [System.Management.Automation.CmdletAttribute], $false) | + Select-Object -First 1 + $attr.ConfirmImpact | Should -Be ([System.Management.Automation.ConfirmImpact]::High) + } + } + + Context 'Required parameters' { + It 'Id should be Mandatory' { + Skip-IfMissing 'Remove-PveBackupJob' + $isMandatory = $script:Cmd.Parameters['Id'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Remove-PveBackupJob' + { Remove-PveBackupJob -Id 'backup-test' -Confirm:$false -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} diff --git a/tests/PSProxmoxVE.Tests/Firewall/FirewallAliasCmdlets.Tests.ps1 b/tests/PSProxmoxVE.Tests/Firewall/FirewallAliasCmdlets.Tests.ps1 new file mode 100644 index 0000000..23864d5 --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Firewall/FirewallAliasCmdlets.Tests.ps1 @@ -0,0 +1,230 @@ +#Requires -Module Pester +<# +.SYNOPSIS + Pester 5 tests for firewall alias cmdlets: + Get-PveFirewallAlias, New-PveFirewallAlias, Set-PveFirewallAlias, Remove-PveFirewallAlias. + + All tests are fully offline — no live Proxmox VE target is required. + If a cmdlet is not yet compiled the test is marked Skipped. +#> + +BeforeAll { + . $PSScriptRoot/../_TestHelper.ps1 + + $script:Availability = @{} + foreach ($name in @('Get-PveFirewallAlias', 'New-PveFirewallAlias', 'Set-PveFirewallAlias', 'Remove-PveFirewallAlias')) { + $script:Availability[$name] = $null -ne (Get-Command $name -ErrorAction SilentlyContinue) + } + + function Skip-IfMissing([string]$Name) { + if (-not $script:Availability[$Name]) { + Set-ItResult -Skipped -Because "$Name is not yet implemented in this build" + } + } +} + +# --------------------------------------------------------------------------- +# Manifest contract +# --------------------------------------------------------------------------- +Describe 'Firewall alias cmdlets — manifest declarations' { + BeforeAll { + $manifestPath = Join-Path (Get-Module PSProxmoxVE).ModuleBase 'PSProxmoxVE.psd1' + $script:Manifest = if (Test-Path $manifestPath) { Import-PowerShellDataFile $manifestPath } else { $null } + } + + It " should be declared in CmdletsToExport" -TestCases @( + @{ cmdName = 'Get-PveFirewallAlias' } + @{ cmdName = 'New-PveFirewallAlias' } + @{ cmdName = 'Set-PveFirewallAlias' } + @{ cmdName = 'Remove-PveFirewallAlias' } + ) { + if ($null -eq $script:Manifest) { Set-ItResult -Skipped -Because 'Manifest not found'; return } + $script:Manifest.CmdletsToExport | Should -Contain $cmdName + } +} + +# --------------------------------------------------------------------------- +# Get-PveFirewallAlias +# --------------------------------------------------------------------------- +Describe 'Get-PveFirewallAlias' { + + BeforeAll { $script:Cmd = Get-Command 'Get-PveFirewallAlias' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Get-PveFirewallAlias' + $script:Cmd | Should -Not -BeNullOrEmpty + } + + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Get-PveFirewallAlias' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have optional Name parameter' { + Skip-IfMissing 'Get-PveFirewallAlias' + $script:Cmd.Parameters.ContainsKey('Name') | Should -BeTrue + } + + It 'Should have Session parameter' { + Skip-IfMissing 'Get-PveFirewallAlias' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Get-PveFirewallAlias' + { Get-PveFirewallAlias -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# New-PveFirewallAlias +# --------------------------------------------------------------------------- +Describe 'New-PveFirewallAlias' { + + BeforeAll { $script:Cmd = Get-Command 'New-PveFirewallAlias' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'New-PveFirewallAlias' + $script:Cmd | Should -Not -BeNullOrEmpty + } + } + + Context 'ShouldProcess support' { + It 'Should support WhatIf' { + Skip-IfMissing 'New-PveFirewallAlias' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + } + + Context 'Required parameters' { + It 'Name should be Mandatory' { + Skip-IfMissing 'New-PveFirewallAlias' + $isMandatory = $script:Cmd.Parameters['Name'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + + It 'Cidr should be Mandatory' { + Skip-IfMissing 'New-PveFirewallAlias' + $isMandatory = $script:Cmd.Parameters['Cidr'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + } + + Context 'Optional parameters' { + It 'Should have Comment parameter' { + Skip-IfMissing 'New-PveFirewallAlias' + $script:Cmd.Parameters.ContainsKey('Comment') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'New-PveFirewallAlias' + { New-PveFirewallAlias -Name 'testalias' -Cidr '10.0.0.0/24' -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Set-PveFirewallAlias +# --------------------------------------------------------------------------- +Describe 'Set-PveFirewallAlias' { + + BeforeAll { $script:Cmd = Get-Command 'Set-PveFirewallAlias' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Set-PveFirewallAlias' + $script:Cmd | Should -Not -BeNullOrEmpty + } + } + + Context 'ShouldProcess support' { + It 'Should support WhatIf' { + Skip-IfMissing 'Set-PveFirewallAlias' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + } + + Context 'Required parameters' { + It 'Name should be Mandatory' { + Skip-IfMissing 'Set-PveFirewallAlias' + $isMandatory = $script:Cmd.Parameters['Name'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + + It 'Cidr should be Mandatory' { + Skip-IfMissing 'Set-PveFirewallAlias' + $isMandatory = $script:Cmd.Parameters['Cidr'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Set-PveFirewallAlias' + { Set-PveFirewallAlias -Name 'testalias' -Cidr '10.0.0.0/24' -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Remove-PveFirewallAlias +# --------------------------------------------------------------------------- +Describe 'Remove-PveFirewallAlias' { + + BeforeAll { $script:Cmd = Get-Command 'Remove-PveFirewallAlias' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Remove-PveFirewallAlias' + $script:Cmd | Should -Not -BeNullOrEmpty + } + } + + Context 'ShouldProcess / ConfirmImpact' { + It 'Should support WhatIf' { + Skip-IfMissing 'Remove-PveFirewallAlias' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + + It 'Should declare ConfirmImpact High' { + Skip-IfMissing 'Remove-PveFirewallAlias' + $attr = $script:Cmd.ImplementingType.GetCustomAttributes( + [System.Management.Automation.CmdletAttribute], $false) | + Select-Object -First 1 + $attr.ConfirmImpact | Should -Be ([System.Management.Automation.ConfirmImpact]::High) + } + } + + Context 'Required parameters' { + It 'Name should be Mandatory' { + Skip-IfMissing 'Remove-PveFirewallAlias' + $isMandatory = $script:Cmd.Parameters['Name'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Remove-PveFirewallAlias' + { Remove-PveFirewallAlias -Name 'testalias' -Confirm:$false -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} diff --git a/tests/PSProxmoxVE.Tests/Firewall/FirewallGroupCmdlets.Tests.ps1 b/tests/PSProxmoxVE.Tests/Firewall/FirewallGroupCmdlets.Tests.ps1 new file mode 100644 index 0000000..f5c2bc3 --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Firewall/FirewallGroupCmdlets.Tests.ps1 @@ -0,0 +1,176 @@ +#Requires -Module Pester +<# +.SYNOPSIS + Pester 5 tests for firewall group cmdlets: + Get-PveFirewallGroup, New-PveFirewallGroup, Remove-PveFirewallGroup. + + All tests are fully offline — no live Proxmox VE target is required. + If a cmdlet is not yet compiled the test is marked Skipped. +#> + +BeforeAll { + . $PSScriptRoot/../_TestHelper.ps1 + + $script:Availability = @{} + foreach ($name in @('Get-PveFirewallGroup', 'New-PveFirewallGroup', 'Remove-PveFirewallGroup')) { + $script:Availability[$name] = $null -ne (Get-Command $name -ErrorAction SilentlyContinue) + } + + function Skip-IfMissing([string]$Name) { + if (-not $script:Availability[$Name]) { + Set-ItResult -Skipped -Because "$Name is not yet implemented in this build" + } + } +} + +# --------------------------------------------------------------------------- +# Manifest contract +# --------------------------------------------------------------------------- +Describe 'Firewall group cmdlets — manifest declarations' { + BeforeAll { + $manifestPath = Join-Path (Get-Module PSProxmoxVE).ModuleBase 'PSProxmoxVE.psd1' + $script:Manifest = if (Test-Path $manifestPath) { Import-PowerShellDataFile $manifestPath } else { $null } + } + + It " should be declared in CmdletsToExport" -TestCases @( + @{ cmdName = 'Get-PveFirewallGroup' } + @{ cmdName = 'New-PveFirewallGroup' } + @{ cmdName = 'Remove-PveFirewallGroup' } + ) { + if ($null -eq $script:Manifest) { Set-ItResult -Skipped -Because 'Manifest not found'; return } + $script:Manifest.CmdletsToExport | Should -Contain $cmdName + } +} + +# --------------------------------------------------------------------------- +# Get-PveFirewallGroup +# --------------------------------------------------------------------------- +Describe 'Get-PveFirewallGroup' { + + BeforeAll { $script:Cmd = Get-Command 'Get-PveFirewallGroup' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Get-PveFirewallGroup' + $script:Cmd | Should -Not -BeNullOrEmpty + } + + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Get-PveFirewallGroup' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have optional Group parameter' { + Skip-IfMissing 'Get-PveFirewallGroup' + $script:Cmd.Parameters.ContainsKey('Group') | Should -BeTrue + } + + It 'Should have Session parameter' { + Skip-IfMissing 'Get-PveFirewallGroup' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Get-PveFirewallGroup' + { Get-PveFirewallGroup -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# New-PveFirewallGroup +# --------------------------------------------------------------------------- +Describe 'New-PveFirewallGroup' { + + BeforeAll { $script:Cmd = Get-Command 'New-PveFirewallGroup' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'New-PveFirewallGroup' + $script:Cmd | Should -Not -BeNullOrEmpty + } + } + + Context 'ShouldProcess support' { + It 'Should support WhatIf' { + Skip-IfMissing 'New-PveFirewallGroup' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + } + + Context 'Required parameters' { + It 'Group should be Mandatory' { + Skip-IfMissing 'New-PveFirewallGroup' + $isMandatory = $script:Cmd.Parameters['Group'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + } + + Context 'Optional parameters' { + It 'Should have Comment parameter' { + Skip-IfMissing 'New-PveFirewallGroup' + $script:Cmd.Parameters.ContainsKey('Comment') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'New-PveFirewallGroup' + { New-PveFirewallGroup -Group 'testgroup' -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Remove-PveFirewallGroup +# --------------------------------------------------------------------------- +Describe 'Remove-PveFirewallGroup' { + + BeforeAll { $script:Cmd = Get-Command 'Remove-PveFirewallGroup' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Remove-PveFirewallGroup' + $script:Cmd | Should -Not -BeNullOrEmpty + } + } + + Context 'ShouldProcess / ConfirmImpact' { + It 'Should support WhatIf' { + Skip-IfMissing 'Remove-PveFirewallGroup' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + + It 'Should declare ConfirmImpact High' { + Skip-IfMissing 'Remove-PveFirewallGroup' + $attr = $script:Cmd.ImplementingType.GetCustomAttributes( + [System.Management.Automation.CmdletAttribute], $false) | + Select-Object -First 1 + $attr.ConfirmImpact | Should -Be ([System.Management.Automation.ConfirmImpact]::High) + } + } + + Context 'Required parameters' { + It 'Group should be Mandatory' { + Skip-IfMissing 'Remove-PveFirewallGroup' + $isMandatory = $script:Cmd.Parameters['Group'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Remove-PveFirewallGroup' + { Remove-PveFirewallGroup -Group 'testgroup' -Confirm:$false -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} diff --git a/tests/PSProxmoxVE.Tests/Firewall/FirewallIpSetCmdlets.Tests.ps1 b/tests/PSProxmoxVE.Tests/Firewall/FirewallIpSetCmdlets.Tests.ps1 new file mode 100644 index 0000000..6039589 --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Firewall/FirewallIpSetCmdlets.Tests.ps1 @@ -0,0 +1,376 @@ +#Requires -Module Pester +<# +.SYNOPSIS + Pester 5 tests for firewall IP set cmdlets: + Get-PveFirewallIpSet, New-PveFirewallIpSet, Remove-PveFirewallIpSet, + Get-PveFirewallIpSetEntry, New-PveFirewallIpSetEntry, Set-PveFirewallIpSetEntry, + Remove-PveFirewallIpSetEntry. + + All tests are fully offline — no live Proxmox VE target is required. + If a cmdlet is not yet compiled the test is marked Skipped. +#> + +BeforeAll { + . $PSScriptRoot/../_TestHelper.ps1 + + $script:Availability = @{} + foreach ($name in @( + 'Get-PveFirewallIpSet', 'New-PveFirewallIpSet', 'Remove-PveFirewallIpSet', + 'Get-PveFirewallIpSetEntry', 'New-PveFirewallIpSetEntry', 'Set-PveFirewallIpSetEntry', + 'Remove-PveFirewallIpSetEntry' + )) { + $script:Availability[$name] = $null -ne (Get-Command $name -ErrorAction SilentlyContinue) + } + + function Skip-IfMissing([string]$Name) { + if (-not $script:Availability[$Name]) { + Set-ItResult -Skipped -Because "$Name is not yet implemented in this build" + } + } +} + +# --------------------------------------------------------------------------- +# Manifest contract +# --------------------------------------------------------------------------- +Describe 'Firewall IP set cmdlets — manifest declarations' { + BeforeAll { + $manifestPath = Join-Path (Get-Module PSProxmoxVE).ModuleBase 'PSProxmoxVE.psd1' + $script:Manifest = if (Test-Path $manifestPath) { Import-PowerShellDataFile $manifestPath } else { $null } + } + + It " should be declared in CmdletsToExport" -TestCases @( + @{ cmdName = 'Get-PveFirewallIpSet' } + @{ cmdName = 'New-PveFirewallIpSet' } + @{ cmdName = 'Remove-PveFirewallIpSet' } + @{ cmdName = 'Get-PveFirewallIpSetEntry' } + @{ cmdName = 'New-PveFirewallIpSetEntry' } + @{ cmdName = 'Set-PveFirewallIpSetEntry' } + @{ cmdName = 'Remove-PveFirewallIpSetEntry' } + ) { + if ($null -eq $script:Manifest) { Set-ItResult -Skipped -Because 'Manifest not found'; return } + $script:Manifest.CmdletsToExport | Should -Contain $cmdName + } +} + +# --------------------------------------------------------------------------- +# Get-PveFirewallIpSet +# --------------------------------------------------------------------------- +Describe 'Get-PveFirewallIpSet' { + + BeforeAll { $script:Cmd = Get-Command 'Get-PveFirewallIpSet' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Get-PveFirewallIpSet' + $script:Cmd | Should -Not -BeNullOrEmpty + } + + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Get-PveFirewallIpSet' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have optional Name parameter' { + Skip-IfMissing 'Get-PveFirewallIpSet' + $script:Cmd.Parameters.ContainsKey('Name') | Should -BeTrue + } + + It 'Should have Session parameter' { + Skip-IfMissing 'Get-PveFirewallIpSet' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Get-PveFirewallIpSet' + { Get-PveFirewallIpSet -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# New-PveFirewallIpSet +# --------------------------------------------------------------------------- +Describe 'New-PveFirewallIpSet' { + + BeforeAll { $script:Cmd = Get-Command 'New-PveFirewallIpSet' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'New-PveFirewallIpSet' + $script:Cmd | Should -Not -BeNullOrEmpty + } + } + + Context 'ShouldProcess support' { + It 'Should support WhatIf' { + Skip-IfMissing 'New-PveFirewallIpSet' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + } + + Context 'Required parameters' { + It 'Name should be Mandatory' { + Skip-IfMissing 'New-PveFirewallIpSet' + $isMandatory = $script:Cmd.Parameters['Name'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + } + + Context 'Optional parameters' { + It 'Should have Comment parameter' { + Skip-IfMissing 'New-PveFirewallIpSet' + $script:Cmd.Parameters.ContainsKey('Comment') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'New-PveFirewallIpSet' + { New-PveFirewallIpSet -Name 'testipset' -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Remove-PveFirewallIpSet +# --------------------------------------------------------------------------- +Describe 'Remove-PveFirewallIpSet' { + + BeforeAll { $script:Cmd = Get-Command 'Remove-PveFirewallIpSet' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Remove-PveFirewallIpSet' + $script:Cmd | Should -Not -BeNullOrEmpty + } + } + + Context 'ShouldProcess / ConfirmImpact' { + It 'Should support WhatIf' { + Skip-IfMissing 'Remove-PveFirewallIpSet' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + + It 'Should declare ConfirmImpact High' { + Skip-IfMissing 'Remove-PveFirewallIpSet' + $attr = $script:Cmd.ImplementingType.GetCustomAttributes( + [System.Management.Automation.CmdletAttribute], $false) | + Select-Object -First 1 + $attr.ConfirmImpact | Should -Be ([System.Management.Automation.ConfirmImpact]::High) + } + } + + Context 'Required parameters' { + It 'Name should be Mandatory' { + Skip-IfMissing 'Remove-PveFirewallIpSet' + $isMandatory = $script:Cmd.Parameters['Name'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Remove-PveFirewallIpSet' + { Remove-PveFirewallIpSet -Name 'testipset' -Confirm:$false -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Get-PveFirewallIpSetEntry +# --------------------------------------------------------------------------- +Describe 'Get-PveFirewallIpSetEntry' { + + BeforeAll { $script:Cmd = Get-Command 'Get-PveFirewallIpSetEntry' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Get-PveFirewallIpSetEntry' + $script:Cmd | Should -Not -BeNullOrEmpty + } + + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Get-PveFirewallIpSetEntry' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Required parameters' { + It 'Name should be Mandatory' { + Skip-IfMissing 'Get-PveFirewallIpSetEntry' + $isMandatory = $script:Cmd.Parameters['Name'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + } + + Context 'Parameter metadata' { + It 'Should have Session parameter' { + Skip-IfMissing 'Get-PveFirewallIpSetEntry' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Get-PveFirewallIpSetEntry' + { Get-PveFirewallIpSetEntry -Name 'testipset' -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# New-PveFirewallIpSetEntry +# --------------------------------------------------------------------------- +Describe 'New-PveFirewallIpSetEntry' { + + BeforeAll { $script:Cmd = Get-Command 'New-PveFirewallIpSetEntry' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'New-PveFirewallIpSetEntry' + $script:Cmd | Should -Not -BeNullOrEmpty + } + } + + Context 'ShouldProcess support' { + It 'Should support WhatIf' { + Skip-IfMissing 'New-PveFirewallIpSetEntry' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + } + + Context 'Required parameters' { + It 'Name should be Mandatory' { + Skip-IfMissing 'New-PveFirewallIpSetEntry' + $isMandatory = $script:Cmd.Parameters['Name'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + + It 'Cidr should be Mandatory' { + Skip-IfMissing 'New-PveFirewallIpSetEntry' + $isMandatory = $script:Cmd.Parameters['Cidr'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'New-PveFirewallIpSetEntry' + { New-PveFirewallIpSetEntry -Name 'testipset' -Cidr '10.0.0.1' -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Set-PveFirewallIpSetEntry +# --------------------------------------------------------------------------- +Describe 'Set-PveFirewallIpSetEntry' { + + BeforeAll { $script:Cmd = Get-Command 'Set-PveFirewallIpSetEntry' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Set-PveFirewallIpSetEntry' + $script:Cmd | Should -Not -BeNullOrEmpty + } + } + + Context 'ShouldProcess support' { + It 'Should support WhatIf' { + Skip-IfMissing 'Set-PveFirewallIpSetEntry' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + } + + Context 'Required parameters' { + It 'Name should be Mandatory' { + Skip-IfMissing 'Set-PveFirewallIpSetEntry' + $isMandatory = $script:Cmd.Parameters['Name'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + + It 'Cidr should be Mandatory' { + Skip-IfMissing 'Set-PveFirewallIpSetEntry' + $isMandatory = $script:Cmd.Parameters['Cidr'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Set-PveFirewallIpSetEntry' + { Set-PveFirewallIpSetEntry -Name 'testipset' -Cidr '10.0.0.1' -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Remove-PveFirewallIpSetEntry +# --------------------------------------------------------------------------- +Describe 'Remove-PveFirewallIpSetEntry' { + + BeforeAll { $script:Cmd = Get-Command 'Remove-PveFirewallIpSetEntry' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Remove-PveFirewallIpSetEntry' + $script:Cmd | Should -Not -BeNullOrEmpty + } + } + + Context 'ShouldProcess / ConfirmImpact' { + It 'Should support WhatIf' { + Skip-IfMissing 'Remove-PveFirewallIpSetEntry' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + + It 'Should declare ConfirmImpact High' { + Skip-IfMissing 'Remove-PveFirewallIpSetEntry' + $attr = $script:Cmd.ImplementingType.GetCustomAttributes( + [System.Management.Automation.CmdletAttribute], $false) | + Select-Object -First 1 + $attr.ConfirmImpact | Should -Be ([System.Management.Automation.ConfirmImpact]::High) + } + } + + Context 'Required parameters' { + It 'Name should be Mandatory' { + Skip-IfMissing 'Remove-PveFirewallIpSetEntry' + $isMandatory = $script:Cmd.Parameters['Name'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + + It 'Cidr should be Mandatory' { + Skip-IfMissing 'Remove-PveFirewallIpSetEntry' + $isMandatory = $script:Cmd.Parameters['Cidr'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Remove-PveFirewallIpSetEntry' + { Remove-PveFirewallIpSetEntry -Name 'testipset' -Cidr '10.0.0.1' -Confirm:$false -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} diff --git a/tests/PSProxmoxVE.Tests/Firewall/FirewallOptionsCmdlets.Tests.ps1 b/tests/PSProxmoxVE.Tests/Firewall/FirewallOptionsCmdlets.Tests.ps1 new file mode 100644 index 0000000..0d123bb --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Firewall/FirewallOptionsCmdlets.Tests.ps1 @@ -0,0 +1,175 @@ +#Requires -Module Pester +<# +.SYNOPSIS + Pester 5 tests for firewall options/ref cmdlets: + Get-PveFirewallOptions, Set-PveFirewallOptions, Get-PveFirewallRef. + + All tests are fully offline — no live Proxmox VE target is required. + If a cmdlet is not yet compiled the test is marked Skipped. +#> + +BeforeAll { + . $PSScriptRoot/../_TestHelper.ps1 + + $script:Availability = @{} + foreach ($name in @('Get-PveFirewallOptions', 'Set-PveFirewallOptions', 'Get-PveFirewallRef')) { + $script:Availability[$name] = $null -ne (Get-Command $name -ErrorAction SilentlyContinue) + } + + function Skip-IfMissing([string]$Name) { + if (-not $script:Availability[$Name]) { + Set-ItResult -Skipped -Because "$Name is not yet implemented in this build" + } + } +} + +# --------------------------------------------------------------------------- +# Manifest contract +# --------------------------------------------------------------------------- +Describe 'Firewall options/ref cmdlets — manifest declarations' { + BeforeAll { + $manifestPath = Join-Path (Get-Module PSProxmoxVE).ModuleBase 'PSProxmoxVE.psd1' + $script:Manifest = if (Test-Path $manifestPath) { Import-PowerShellDataFile $manifestPath } else { $null } + } + + It " should be declared in CmdletsToExport" -TestCases @( + @{ cmdName = 'Get-PveFirewallOptions' } + @{ cmdName = 'Set-PveFirewallOptions' } + @{ cmdName = 'Get-PveFirewallRef' } + ) { + if ($null -eq $script:Manifest) { Set-ItResult -Skipped -Because 'Manifest not found'; return } + $script:Manifest.CmdletsToExport | Should -Contain $cmdName + } +} + +# --------------------------------------------------------------------------- +# Get-PveFirewallOptions +# --------------------------------------------------------------------------- +Describe 'Get-PveFirewallOptions' { + + BeforeAll { $script:Cmd = Get-Command 'Get-PveFirewallOptions' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Get-PveFirewallOptions' + $script:Cmd | Should -Not -BeNullOrEmpty + } + + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Get-PveFirewallOptions' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have mandatory Level parameter with ValidateSet' { + Skip-IfMissing 'Get-PveFirewallOptions' + $param = $script:Cmd.Parameters['Level'] + $param | Should -Not -BeNullOrEmpty + $isMandatory = $param.ParameterSets.Values | Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + $validateSet = $param.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } + $validateSet | Should -Not -BeNullOrEmpty + } + + It 'Should have Session parameter' { + Skip-IfMissing 'Get-PveFirewallOptions' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Get-PveFirewallOptions' + { Get-PveFirewallOptions -Level 'cluster' -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Set-PveFirewallOptions +# --------------------------------------------------------------------------- +Describe 'Set-PveFirewallOptions' { + + BeforeAll { $script:Cmd = Get-Command 'Set-PveFirewallOptions' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Set-PveFirewallOptions' + $script:Cmd | Should -Not -BeNullOrEmpty + } + } + + Context 'ShouldProcess support' { + It 'Should support WhatIf' { + Skip-IfMissing 'Set-PveFirewallOptions' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + } + + Context 'Parameter metadata' { + It 'Should have mandatory Level parameter with ValidateSet' { + Skip-IfMissing 'Set-PveFirewallOptions' + $param = $script:Cmd.Parameters['Level'] + $param | Should -Not -BeNullOrEmpty + $isMandatory = $param.ParameterSets.Values | Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + $validateSet = $param.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } + $validateSet | Should -Not -BeNullOrEmpty + } + + It 'Should have Enable parameter' { + Skip-IfMissing 'Set-PveFirewallOptions' + $script:Cmd.Parameters.ContainsKey('Enable') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Set-PveFirewallOptions' + { Set-PveFirewallOptions -Level 'cluster' -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Get-PveFirewallRef +# --------------------------------------------------------------------------- +Describe 'Get-PveFirewallRef' { + + BeforeAll { $script:Cmd = Get-Command 'Get-PveFirewallRef' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Get-PveFirewallRef' + $script:Cmd | Should -Not -BeNullOrEmpty + } + + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Get-PveFirewallRef' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have Session parameter' { + Skip-IfMissing 'Get-PveFirewallRef' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + + It 'Should have optional Type parameter' { + Skip-IfMissing 'Get-PveFirewallRef' + $script:Cmd.Parameters.ContainsKey('Type') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Get-PveFirewallRef' + { Get-PveFirewallRef -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} diff --git a/tests/PSProxmoxVE.Tests/Firewall/FirewallRuleCmdlets.Tests.ps1 b/tests/PSProxmoxVE.Tests/Firewall/FirewallRuleCmdlets.Tests.ps1 new file mode 100644 index 0000000..e52d392 --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Firewall/FirewallRuleCmdlets.Tests.ps1 @@ -0,0 +1,243 @@ +#Requires -Module Pester +<# +.SYNOPSIS + Pester 5 tests for firewall rule cmdlets: + Get-PveFirewallRule, New-PveFirewallRule, Set-PveFirewallRule, Remove-PveFirewallRule. + + All tests are fully offline — no live Proxmox VE target is required. + If a cmdlet is not yet compiled the test is marked Skipped. +#> + +BeforeAll { + . $PSScriptRoot/../_TestHelper.ps1 + + $script:Availability = @{} + foreach ($name in @('Get-PveFirewallRule', 'New-PveFirewallRule', 'Set-PveFirewallRule', 'Remove-PveFirewallRule')) { + $script:Availability[$name] = $null -ne (Get-Command $name -ErrorAction SilentlyContinue) + } + + function Skip-IfMissing([string]$Name) { + if (-not $script:Availability[$Name]) { + Set-ItResult -Skipped -Because "$Name is not yet implemented in this build" + } + } +} + +# --------------------------------------------------------------------------- +# Manifest contract +# --------------------------------------------------------------------------- +Describe 'Firewall rule cmdlets — manifest declarations' { + BeforeAll { + $manifestPath = Join-Path (Get-Module PSProxmoxVE).ModuleBase 'PSProxmoxVE.psd1' + $script:Manifest = if (Test-Path $manifestPath) { Import-PowerShellDataFile $manifestPath } else { $null } + } + + It " should be declared in CmdletsToExport" -TestCases @( + @{ cmdName = 'Get-PveFirewallRule' } + @{ cmdName = 'New-PveFirewallRule' } + @{ cmdName = 'Set-PveFirewallRule' } + @{ cmdName = 'Remove-PveFirewallRule' } + ) { + if ($null -eq $script:Manifest) { Set-ItResult -Skipped -Because 'Manifest not found'; return } + $script:Manifest.CmdletsToExport | Should -Contain $cmdName + } +} + +# --------------------------------------------------------------------------- +# Get-PveFirewallRule +# --------------------------------------------------------------------------- +Describe 'Get-PveFirewallRule' { + + BeforeAll { $script:Cmd = Get-Command 'Get-PveFirewallRule' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Get-PveFirewallRule' + $script:Cmd | Should -Not -BeNullOrEmpty + } + + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Get-PveFirewallRule' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have mandatory Level parameter with ValidateSet' { + Skip-IfMissing 'Get-PveFirewallRule' + $param = $script:Cmd.Parameters['Level'] + $param | Should -Not -BeNullOrEmpty + $isMandatory = $param.ParameterSets.Values | Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + $validateSet = $param.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } + $validateSet | Should -Not -BeNullOrEmpty + } + + It 'Should have Session parameter' { + Skip-IfMissing 'Get-PveFirewallRule' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Get-PveFirewallRule' + { Get-PveFirewallRule -Level 'cluster' -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# New-PveFirewallRule +# --------------------------------------------------------------------------- +Describe 'New-PveFirewallRule' { + + BeforeAll { $script:Cmd = Get-Command 'New-PveFirewallRule' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'New-PveFirewallRule' + $script:Cmd | Should -Not -BeNullOrEmpty + } + } + + Context 'ShouldProcess support' { + It 'Should support WhatIf' { + Skip-IfMissing 'New-PveFirewallRule' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + } + + Context 'Parameter metadata' { + It 'Should have mandatory Level parameter with ValidateSet' { + Skip-IfMissing 'New-PveFirewallRule' + $param = $script:Cmd.Parameters['Level'] + $param | Should -Not -BeNullOrEmpty + $isMandatory = $param.ParameterSets.Values | Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + $validateSet = $param.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } + $validateSet | Should -Not -BeNullOrEmpty + } + + It 'Should have Action parameter' { + Skip-IfMissing 'New-PveFirewallRule' + $script:Cmd.Parameters.ContainsKey('Action') | Should -BeTrue + } + + It 'Should have Type parameter' { + Skip-IfMissing 'New-PveFirewallRule' + $script:Cmd.Parameters.ContainsKey('Type') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'New-PveFirewallRule' + { New-PveFirewallRule -Level 'cluster' -Action 'ACCEPT' -Type 'in' -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Set-PveFirewallRule +# --------------------------------------------------------------------------- +Describe 'Set-PveFirewallRule' { + + BeforeAll { $script:Cmd = Get-Command 'Set-PveFirewallRule' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Set-PveFirewallRule' + $script:Cmd | Should -Not -BeNullOrEmpty + } + } + + Context 'ShouldProcess support' { + It 'Should support WhatIf' { + Skip-IfMissing 'Set-PveFirewallRule' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + } + + Context 'Parameter metadata' { + It 'Should have mandatory Level parameter with ValidateSet' { + Skip-IfMissing 'Set-PveFirewallRule' + $param = $script:Cmd.Parameters['Level'] + $param | Should -Not -BeNullOrEmpty + $isMandatory = $param.ParameterSets.Values | Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + $validateSet = $param.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } + $validateSet | Should -Not -BeNullOrEmpty + } + + It 'Should have Pos parameter' { + Skip-IfMissing 'Set-PveFirewallRule' + $script:Cmd.Parameters.ContainsKey('Pos') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Set-PveFirewallRule' + { Set-PveFirewallRule -Level 'cluster' -Pos 0 -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Remove-PveFirewallRule +# --------------------------------------------------------------------------- +Describe 'Remove-PveFirewallRule' { + + BeforeAll { $script:Cmd = Get-Command 'Remove-PveFirewallRule' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Remove-PveFirewallRule' + $script:Cmd | Should -Not -BeNullOrEmpty + } + } + + Context 'ShouldProcess / ConfirmImpact' { + It 'Should support WhatIf' { + Skip-IfMissing 'Remove-PveFirewallRule' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + + It 'Should declare ConfirmImpact High' { + Skip-IfMissing 'Remove-PveFirewallRule' + $attr = $script:Cmd.ImplementingType.GetCustomAttributes( + [System.Management.Automation.CmdletAttribute], $false) | + Select-Object -First 1 + $attr.ConfirmImpact | Should -Be ([System.Management.Automation.ConfirmImpact]::High) + } + } + + Context 'Parameter metadata' { + It 'Should have mandatory Level parameter with ValidateSet' { + Skip-IfMissing 'Remove-PveFirewallRule' + $param = $script:Cmd.Parameters['Level'] + $param | Should -Not -BeNullOrEmpty + $isMandatory = $param.ParameterSets.Values | Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + $validateSet = $param.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } + $validateSet | Should -Not -BeNullOrEmpty + } + + It 'Should have Pos parameter' { + Skip-IfMissing 'Remove-PveFirewallRule' + $script:Cmd.Parameters.ContainsKey('Pos') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Remove-PveFirewallRule' + { Remove-PveFirewallRule -Level 'cluster' -Pos 0 -Confirm:$false -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} diff --git a/tests/PSProxmoxVE.Tests/Integration/Integration.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/Integration.Tests.ps1 index 76c9ea2..af2c1ab 100644 --- a/tests/PSProxmoxVE.Tests/Integration/Integration.Tests.ps1 +++ b/tests/PSProxmoxVE.Tests/Integration/Integration.Tests.ps1 @@ -79,6 +79,8 @@ BeforeAll { $script:CreatedRoles = [System.Collections.Generic.List[string]]::new() $script:TestVmId = $null $script:TestContainerId = $null + $script:FirewallTestRulePos = $null + $script:BackupTestJobId = $null # Helper functions for skip logic function script:Skip-IfNoTarget { @@ -159,6 +161,32 @@ AfterAll { } catch { <# non-fatal #> } } + + # Cleanup: firewall test artifacts + try { + $rules = Get-PveFirewallRule -Level Cluster -ErrorAction SilentlyContinue + $testRules = $rules | Where-Object { $_.Comment -like 'pester-test*' } + foreach ($r in $testRules) { + Remove-PveFirewallRule -Level Cluster -Position $r.Pos -Confirm:$false -ErrorAction SilentlyContinue + } + } catch { } + + # Cleanup: firewall aliases + try { + Remove-PveFirewallAlias -Level Cluster -Name 'pester-alias' -Confirm:$false -ErrorAction SilentlyContinue + } catch { } + + # Cleanup: firewall IP sets + try { + Remove-PveFirewallIpSet -Level Cluster -Name 'pester-ipset' -Confirm:$false -ErrorAction SilentlyContinue + } catch { } + + # Cleanup: backup jobs + try { + if ($script:BackupTestJobId) { + Remove-PveBackupJob -Id $script:BackupTestJobId -Confirm:$false -ErrorAction SilentlyContinue + } + } catch { } } # =========================================================================== @@ -1343,6 +1371,161 @@ Describe 'Integration Tests' -Tag 'Integration' { } } + # ----------------------------------------------------------------------- + Context 'OVA Import' { + BeforeAll { + function Skip-IfNoOva { + if (-not $script:OvaPath) { + Set-ItResult -Skipped -Because 'PVETEST_OVA_PATH is not set' + } + } + } + + It 'Should import a VM from OVA file' { + Skip-IfNoOva + $vmId = 9900 + (Get-Random -Maximum 99) + $result = Import-PveOva -Node $script:Node -Path $script:OvaPath -Storage $script:Storage -NewVmId $vmId -ErrorAction Stop + $result | Should -Not -BeNullOrEmpty + $script:CreatedVmIds.Add($vmId) | Out-Null + } + + It 'Should find the imported VM' { + Skip-IfNoOva + if ($script:CreatedVmIds.Count -eq 0 -or $script:CreatedVmIds[-1] -lt 9900) { + Set-ItResult -Skipped -Because 'OVA import did not create a VM' + } + $vm = Get-PveVm -Node $script:Node | Where-Object { $_.VmId -eq $script:CreatedVmIds[-1] } + $vm | Should -Not -BeNullOrEmpty + } + } + + # ----------------------------------------------------------------------- + Context 'Firewall — Cluster Rules' { + It 'Should create a firewall rule' { + Skip-IfNoTarget + { New-PveFirewallRule -Level Cluster -Type in -Action ACCEPT -Proto tcp -Dport '8080' -Comment 'pester-test-rule' -Enable -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should list firewall rules and find the test rule' { + Skip-IfNoTarget + $rules = Get-PveFirewallRule -Level Cluster + $rules | Should -Not -BeNullOrEmpty + $testRule = $rules | Where-Object { $_.Comment -eq 'pester-test-rule' } + $testRule | Should -Not -BeNullOrEmpty + $script:FirewallTestRulePos = $testRule.Pos + } + + It 'Should update the firewall rule' { + Skip-IfNoTarget + if ($null -eq $script:FirewallTestRulePos) { + Set-ItResult -Skipped -Because 'No test rule was created' + } + { Set-PveFirewallRule -Level Cluster -Position $script:FirewallTestRulePos -Comment 'pester-test-updated' -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should remove the firewall rule' { + Skip-IfNoTarget + if ($null -eq $script:FirewallTestRulePos) { + Set-ItResult -Skipped -Because 'No test rule was created' + } + { Remove-PveFirewallRule -Level Cluster -Position $script:FirewallTestRulePos -Confirm:$false -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should get firewall options' { + Skip-IfNoTarget + $opts = Get-PveFirewallOptions -Level Cluster + $opts | Should -Not -BeNullOrEmpty + } + } + + # ----------------------------------------------------------------------- + Context 'Backup Jobs' { + It 'Should create a backup job' { + Skip-IfNoTarget + { New-PveBackupJob -Schedule '0 3 * * 6' -Storage $script:Storage -Mode snapshot -Comment 'pester-test-backup' -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should list backup jobs and find the test job' { + Skip-IfNoTarget + $jobs = Get-PveBackupJob + $testJob = $jobs | Where-Object { $_.Comment -eq 'pester-test-backup' } + $testJob | Should -Not -BeNullOrEmpty + $script:BackupTestJobId = $testJob.Id + } + + It 'Should update the backup job' { + Skip-IfNoTarget + if (-not $script:BackupTestJobId) { + Set-ItResult -Skipped -Because 'No test backup job was created' + } + { Set-PveBackupJob -Id $script:BackupTestJobId -Comment 'pester-test-updated' -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should remove the backup job' { + Skip-IfNoTarget + if (-not $script:BackupTestJobId) { + Set-ItResult -Skipped -Because 'No test backup job was created' + } + { Remove-PveBackupJob -Id $script:BackupTestJobId -Confirm:$false -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should verify the backup job was removed' { + Skip-IfNoTarget + if (-not $script:BackupTestJobId) { + Set-ItResult -Skipped -Because 'No test backup job was created' + } + $jobs = Get-PveBackupJob + $testJob = $jobs | Where-Object { $_.Id -eq $script:BackupTestJobId } + $testJob | Should -BeNullOrEmpty + } + } + + # ----------------------------------------------------------------------- + Context 'Firewall — Aliases and IP Sets' { + It 'Should create a firewall alias' { + Skip-IfNoTarget + { New-PveFirewallAlias -Level Cluster -Name 'pester-alias' -Cidr '192.168.99.0/24' -Comment 'test alias' -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should list and find the alias' { + Skip-IfNoTarget + $aliases = Get-PveFirewallAlias -Level Cluster + $aliases | Where-Object { $_.Name -eq 'pester-alias' } | Should -Not -BeNullOrEmpty + } + + It 'Should remove the alias' { + Skip-IfNoTarget + { Remove-PveFirewallAlias -Level Cluster -Name 'pester-alias' -Confirm:$false -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should create an IP set' { + Skip-IfNoTarget + { New-PveFirewallIpSet -Level Cluster -Name 'pester-ipset' -Comment 'test ipset' -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should add an entry to the IP set' { + Skip-IfNoTarget + { New-PveFirewallIpSetEntry -Level Cluster -Name 'pester-ipset' -Cidr '10.99.0.0/16' -Comment 'test entry' -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should list IP set entries' { + Skip-IfNoTarget + $entries = Get-PveFirewallIpSetEntry -Level Cluster -Name 'pester-ipset' + $entries | Should -Not -BeNullOrEmpty + ($entries | Where-Object { $_.Cidr -like '10.99.0.0*' }) | Should -Not -BeNullOrEmpty + } + + It 'Should remove the IP set entry' { + Skip-IfNoTarget + { Remove-PveFirewallIpSetEntry -Level Cluster -Name 'pester-ipset' -Cidr '10.99.0.0/16' -Confirm:$false -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should remove the IP set' { + Skip-IfNoTarget + { Remove-PveFirewallIpSet -Level Cluster -Name 'pester-ipset' -Confirm:$false -ErrorAction Stop } | Should -Not -Throw + } + } + # ----------------------------------------------------------------------- Context 'Disconnect' { It 'Should disconnect from PVE server (Disconnect-PveServer)' { diff --git a/tests/PSProxmoxVE.Tests/Network/SdnIpamDnsControllerCmdlets.Tests.ps1 b/tests/PSProxmoxVE.Tests/Network/SdnIpamDnsControllerCmdlets.Tests.ps1 new file mode 100644 index 0000000..9d580ca --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Network/SdnIpamDnsControllerCmdlets.Tests.ps1 @@ -0,0 +1,466 @@ +#Requires -Module Pester +<# +.SYNOPSIS + Pester 5 tests for SDN IPAM, DNS, and controller cmdlets: + Get-PveSdnIpam, New-PveSdnIpam, Remove-PveSdnIpam, + Get-PveSdnDns, New-PveSdnDns, Remove-PveSdnDns, + Get-PveSdnController, New-PveSdnController, Remove-PveSdnController. + + All tests are fully offline — no live Proxmox VE target is required. + If a cmdlet is not yet compiled the test is marked Skipped. +#> + +BeforeAll { + . $PSScriptRoot/../_TestHelper.ps1 + + $script:Availability = @{} + foreach ($name in @( + 'Get-PveSdnIpam', 'New-PveSdnIpam', 'Remove-PveSdnIpam', + 'Get-PveSdnDns', 'New-PveSdnDns', 'Remove-PveSdnDns', + 'Get-PveSdnController', 'New-PveSdnController', 'Remove-PveSdnController' + )) { + $script:Availability[$name] = $null -ne (Get-Command $name -ErrorAction SilentlyContinue) + } + + function Skip-IfMissing([string]$Name) { + if (-not $script:Availability[$Name]) { + Set-ItResult -Skipped -Because "$Name is not yet implemented in this build" + } + } +} + +# --------------------------------------------------------------------------- +# Manifest contract +# --------------------------------------------------------------------------- +Describe 'SDN IPAM/DNS/Controller cmdlets — manifest declarations' { + BeforeAll { + $manifestPath = Join-Path (Get-Module PSProxmoxVE).ModuleBase 'PSProxmoxVE.psd1' + $script:Manifest = if (Test-Path $manifestPath) { Import-PowerShellDataFile $manifestPath } else { $null } + } + + It " should be declared in CmdletsToExport" -TestCases @( + @{ cmdName = 'Get-PveSdnIpam' } + @{ cmdName = 'New-PveSdnIpam' } + @{ cmdName = 'Remove-PveSdnIpam' } + @{ cmdName = 'Get-PveSdnDns' } + @{ cmdName = 'New-PveSdnDns' } + @{ cmdName = 'Remove-PveSdnDns' } + @{ cmdName = 'Get-PveSdnController' } + @{ cmdName = 'New-PveSdnController' } + @{ cmdName = 'Remove-PveSdnController' } + ) { + if ($null -eq $script:Manifest) { Set-ItResult -Skipped -Because 'Manifest not found'; return } + $script:Manifest.CmdletsToExport | Should -Contain $cmdName + } +} + +# =========================================================================== +# IPAM cmdlets +# =========================================================================== + +# --------------------------------------------------------------------------- +# Get-PveSdnIpam +# --------------------------------------------------------------------------- +Describe 'Get-PveSdnIpam' { + + BeforeAll { $script:Cmd = Get-Command 'Get-PveSdnIpam' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Get-PveSdnIpam' + $script:Cmd | Should -Not -BeNullOrEmpty + } + + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Get-PveSdnIpam' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have optional Ipam parameter' { + Skip-IfMissing 'Get-PveSdnIpam' + $script:Cmd.Parameters.ContainsKey('Ipam') | Should -BeTrue + } + + It 'Should have Session parameter' { + Skip-IfMissing 'Get-PveSdnIpam' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Get-PveSdnIpam' + { Get-PveSdnIpam -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# New-PveSdnIpam +# --------------------------------------------------------------------------- +Describe 'New-PveSdnIpam' { + + BeforeAll { $script:Cmd = Get-Command 'New-PveSdnIpam' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'New-PveSdnIpam' + $script:Cmd | Should -Not -BeNullOrEmpty + } + } + + Context 'ShouldProcess support' { + It 'Should support WhatIf' { + Skip-IfMissing 'New-PveSdnIpam' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + } + + Context 'Required parameters' { + It 'Ipam should be Mandatory' { + Skip-IfMissing 'New-PveSdnIpam' + $isMandatory = $script:Cmd.Parameters['Ipam'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + + It 'Type should be Mandatory' { + Skip-IfMissing 'New-PveSdnIpam' + $isMandatory = $script:Cmd.Parameters['Type'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'New-PveSdnIpam' + { New-PveSdnIpam -Ipam 'testipam' -Type 'pve' -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Remove-PveSdnIpam +# --------------------------------------------------------------------------- +Describe 'Remove-PveSdnIpam' { + + BeforeAll { $script:Cmd = Get-Command 'Remove-PveSdnIpam' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Remove-PveSdnIpam' + $script:Cmd | Should -Not -BeNullOrEmpty + } + } + + Context 'ShouldProcess / ConfirmImpact' { + It 'Should support WhatIf' { + Skip-IfMissing 'Remove-PveSdnIpam' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + + It 'Should declare ConfirmImpact High' { + Skip-IfMissing 'Remove-PveSdnIpam' + $attr = $script:Cmd.ImplementingType.GetCustomAttributes( + [System.Management.Automation.CmdletAttribute], $false) | + Select-Object -First 1 + $attr.ConfirmImpact | Should -Be ([System.Management.Automation.ConfirmImpact]::High) + } + } + + Context 'Required parameters' { + It 'Ipam should be Mandatory' { + Skip-IfMissing 'Remove-PveSdnIpam' + $isMandatory = $script:Cmd.Parameters['Ipam'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Remove-PveSdnIpam' + { Remove-PveSdnIpam -Ipam 'testipam' -Confirm:$false -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# =========================================================================== +# DNS cmdlets +# =========================================================================== + +# --------------------------------------------------------------------------- +# Get-PveSdnDns +# --------------------------------------------------------------------------- +Describe 'Get-PveSdnDns' { + + BeforeAll { $script:Cmd = Get-Command 'Get-PveSdnDns' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Get-PveSdnDns' + $script:Cmd | Should -Not -BeNullOrEmpty + } + + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Get-PveSdnDns' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have optional Dns parameter' { + Skip-IfMissing 'Get-PveSdnDns' + $script:Cmd.Parameters.ContainsKey('Dns') | Should -BeTrue + } + + It 'Should have Session parameter' { + Skip-IfMissing 'Get-PveSdnDns' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Get-PveSdnDns' + { Get-PveSdnDns -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# New-PveSdnDns +# --------------------------------------------------------------------------- +Describe 'New-PveSdnDns' { + + BeforeAll { $script:Cmd = Get-Command 'New-PveSdnDns' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'New-PveSdnDns' + $script:Cmd | Should -Not -BeNullOrEmpty + } + } + + Context 'ShouldProcess support' { + It 'Should support WhatIf' { + Skip-IfMissing 'New-PveSdnDns' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + } + + Context 'Required parameters' { + It 'Dns should be Mandatory' { + Skip-IfMissing 'New-PveSdnDns' + $isMandatory = $script:Cmd.Parameters['Dns'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + + It 'Type should be Mandatory' { + Skip-IfMissing 'New-PveSdnDns' + $isMandatory = $script:Cmd.Parameters['Type'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'New-PveSdnDns' + { New-PveSdnDns -Dns 'testdns' -Type 'powerdns' -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Remove-PveSdnDns +# --------------------------------------------------------------------------- +Describe 'Remove-PveSdnDns' { + + BeforeAll { $script:Cmd = Get-Command 'Remove-PveSdnDns' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Remove-PveSdnDns' + $script:Cmd | Should -Not -BeNullOrEmpty + } + } + + Context 'ShouldProcess / ConfirmImpact' { + It 'Should support WhatIf' { + Skip-IfMissing 'Remove-PveSdnDns' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + + It 'Should declare ConfirmImpact High' { + Skip-IfMissing 'Remove-PveSdnDns' + $attr = $script:Cmd.ImplementingType.GetCustomAttributes( + [System.Management.Automation.CmdletAttribute], $false) | + Select-Object -First 1 + $attr.ConfirmImpact | Should -Be ([System.Management.Automation.ConfirmImpact]::High) + } + } + + Context 'Required parameters' { + It 'Dns should be Mandatory' { + Skip-IfMissing 'Remove-PveSdnDns' + $isMandatory = $script:Cmd.Parameters['Dns'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Remove-PveSdnDns' + { Remove-PveSdnDns -Dns 'testdns' -Confirm:$false -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# =========================================================================== +# Controller cmdlets +# =========================================================================== + +# --------------------------------------------------------------------------- +# Get-PveSdnController +# --------------------------------------------------------------------------- +Describe 'Get-PveSdnController' { + + BeforeAll { $script:Cmd = Get-Command 'Get-PveSdnController' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Get-PveSdnController' + $script:Cmd | Should -Not -BeNullOrEmpty + } + + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Get-PveSdnController' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have optional Controller parameter' { + Skip-IfMissing 'Get-PveSdnController' + $script:Cmd.Parameters.ContainsKey('Controller') | Should -BeTrue + } + + It 'Should have Session parameter' { + Skip-IfMissing 'Get-PveSdnController' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Get-PveSdnController' + { Get-PveSdnController -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# New-PveSdnController +# --------------------------------------------------------------------------- +Describe 'New-PveSdnController' { + + BeforeAll { $script:Cmd = Get-Command 'New-PveSdnController' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'New-PveSdnController' + $script:Cmd | Should -Not -BeNullOrEmpty + } + } + + Context 'ShouldProcess support' { + It 'Should support WhatIf' { + Skip-IfMissing 'New-PveSdnController' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + } + + Context 'Required parameters' { + It 'Controller should be Mandatory' { + Skip-IfMissing 'New-PveSdnController' + $isMandatory = $script:Cmd.Parameters['Controller'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + + It 'Type should be Mandatory' { + Skip-IfMissing 'New-PveSdnController' + $isMandatory = $script:Cmd.Parameters['Type'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'New-PveSdnController' + { New-PveSdnController -Controller 'testctrl' -Type 'evpn' -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Remove-PveSdnController +# --------------------------------------------------------------------------- +Describe 'Remove-PveSdnController' { + + BeforeAll { $script:Cmd = Get-Command 'Remove-PveSdnController' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Remove-PveSdnController' + $script:Cmd | Should -Not -BeNullOrEmpty + } + } + + Context 'ShouldProcess / ConfirmImpact' { + It 'Should support WhatIf' { + Skip-IfMissing 'Remove-PveSdnController' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + + It 'Should declare ConfirmImpact High' { + Skip-IfMissing 'Remove-PveSdnController' + $attr = $script:Cmd.ImplementingType.GetCustomAttributes( + [System.Management.Automation.CmdletAttribute], $false) | + Select-Object -First 1 + $attr.ConfirmImpact | Should -Be ([System.Management.Automation.ConfirmImpact]::High) + } + } + + Context 'Required parameters' { + It 'Controller should be Mandatory' { + Skip-IfMissing 'Remove-PveSdnController' + $isMandatory = $script:Cmd.Parameters['Controller'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Remove-PveSdnController' + { Remove-PveSdnController -Controller 'testctrl' -Confirm:$false -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +}