diff --git a/.github/skills/create-dsc-resource/SKILL.md b/.github/skills/create-dsc-resource/SKILL.md index 15f8e713b..9e29a4bab 100644 --- a/.github/skills/create-dsc-resource/SKILL.md +++ b/.github/skills/create-dsc-resource/SKILL.md @@ -262,3 +262,302 @@ someError = "Failed to do something: %{error}" #### Build and Deployment - The resource should be built using `build.ps1 -project ` from the root of the repository, which will handle building the Rust code and ensure it is found in PATH for testing + +## What-If support + +Follow this pattern exactly when adding what-if (a.k.a. `dsc config set --what-if`) support to any resource so the implementation path, manifest changes, tests, and naming stay consistent across the repository. + +What-if must: + +1. Project the **final state** the resource would produce, without mutating the system. +2. Echo back the relevant input fields (`keyPath`, `valueName`, `valueData`, etc.) so the engine can diff before/after. +3. Attach human-readable "would do …" messages under `_metadata.whatIf` (an array of strings). +4. Exit `0` on success — what-if is not an error path. + +### 1. Resource manifest changes + +In the resource's `*.dsc.resource.json`, add `whatIfArg` to the `set` (and `delete`, if it supports what-if) args array, and declare `whatIfReturns: "state"` on `set`: + +```json +"set": { + "executable": "", + "args": [ + "config", "set", + { "jsonInputArg": "--input", "mandatory": true }, + { "whatIfArg": "-w" } + ], + "whatIfReturns": "state" +}, +"delete": { + "executable": "", + "args": [ + "config", "delete", + { "jsonInputArg": "--input", "mandatory": true }, + { "whatIfArg": "-w" } + ] +} +``` + +- `whatIfArg` is the literal CLI flag DSC will append when the user runs `dsc config set --what-if`. Always use `"-w"` (short form) for consistency across resources. +- `whatIfReturns: "state"` tells DSC the executable prints the projected post-state JSON on stdout (same shape as `get`/`set` returns). +- The `--list` (bulk) variant of a resource uses the same two manifest additions; do not invent new flag names. + +### 2. CLI args (`args.rs`) changes + +Add a `-w` / `--what-if` boolean to every `ConfigSubCommand` variant that can support what-if: + +```rust +#[derive(Debug, PartialEq, Eq, Subcommand)] +pub enum ConfigSubCommand { + #[clap(name = "set", about = t!("args.configSetAbout").to_string())] + Set { + #[clap(short, long, required = true, help = t!("args.configArgsInputHelp").to_string())] + input: String, + #[clap(short = 'w', long, help = t!("args.configArgsWhatIfHelp").to_string())] + what_if: bool, + }, + #[clap(name = "delete", about = t!("args.configDeleteAbout").to_string())] + Delete { + #[clap(short, long, required = true, help = t!("args.configArgsInputHelp").to_string())] + input: String, + #[clap(short = 'w', long, help = t!("args.configArgsWhatIfHelp").to_string())] + what_if: bool, + }, +} +``` + +Naming is fixed: clap field is `what_if`, short flag is `-w`, long flag is `--what-if`, help key is `args.configArgsWhatIfHelp`. + +### 3. `main.rs` dispatch + +In each `Set` / `Delete` arm, destructure `what_if`, call `helper.enable_what_if()` when true, and print the returned projected state on stdout. **Never** mutate state when `what_if` is true. + +```rust +args::ConfigSubCommand::Set { input, what_if } => { + trace!("Set input: {input}, what_if: {what_if}"); + let mut helper = match Helper::new_from_json(&input) { + Ok(h) => h, + Err(err) => { error!("{err}"); exit(EXIT_INVALID_INPUT); } + }; + if what_if { helper.enable_what_if(); } + + match helper.set() { + Ok(Some(state)) => { + // Set returns Some(state) when what_if is true (projected state) + // OR when whatIfReturns == "state" and the resource emits final state. + let json = serde_json::to_string(&state).unwrap(); + println!("{json}"); + } + Ok(None) => {} + Err(err) => { error!("{err}"); exit(EXIT_RESOURCE_ERROR); } + } + exit(EXIT_SUCCESS); +} +``` + +For the `--list` bulk variant, accumulate projected states in a `Vec`, then print the whole list once at the end. + +### 4. Library / helper changes + +In the resource's `dsc-lib-*` crate: + +- Add a `what_if: bool` field on the helper struct, defaulting to `false` in every constructor. +- Expose `pub fn enable_what_if(&mut self) { self.what_if = true; }`. +- Change `set()` (and `remove()`) to return `Result, Error>` where `Some(T)` is the projected state when `what_if` is true. +- Inside `set` / `remove`, build a `Vec what_if_metadata`, push localized "Would …" strings at each side-effecting branch, and **short-circuit** before the real OS call when `self.what_if`: + + ```rust + if self.what_if { + what_if_metadata.push(t!("_helper.whatIfCreate", name = name).to_string()); + } else { + // perform the real OS mutation + } + ``` + +- Return the projected state with the metadata attached: + + ```rust + return Ok(Some( { + // identity + projected fields the engine needs to diff + metadata: if what_if_metadata.is_empty() { + None + } else { + Some(Metadata { what_if: Some(what_if_metadata) }) + }, + ..Default::default() + })); + ``` + +- Add a `handle_error_or_what_if(error)` helper that, in what-if mode, turns an error into a projected state whose `_metadata.whatIf` contains the error message, instead of failing the run: + + ```rust + fn handle_error_or_what_if(&self, error: Error) -> Result, Error> { + if self.what_if { + return Ok(Some(T { + // identity fields from self.config + metadata: Some(Metadata { what_if: Some(vec![error.to_string()]) }), + ..Default::default() + })); + } + Err(error) + } + ``` + +- Handle the `_exist: false` delete case inside `set()` by routing through `remove()` (with `what_if` honored), so users get a single what-if message describing the deletion. + +### 5. Types (`config.rs` / `types.rs`) changes + +Add a `_metadata` field of type `Option` to every public state struct, and define `Metadata` exactly once per crate: + +```rust +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename = "", deny_unknown_fields)] +pub struct { + // ... resource properties ... + + #[serde(rename = "_metadata", skip_serializing_if = "Option::is_none")] + pub metadata: Option, + + #[serde(rename = "_exist", skip_serializing_if = "Option::is_none")] + pub exist: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Metadata { + #[serde(rename = "whatIf", skip_serializing_if = "Option::is_none")] + pub what_if: Option>, +} +``` + +Naming is fixed: JSON field is `_metadata`, nested array is `whatIf`, Rust field is `what_if: Option>`. + +### 6. Localization strings + +Add localized what-if messages to `locales/en-us.toml` under the helper's section, all starting with the verb **"Would"**: + +```toml +[_helper] +whatIfCreate = "Would create %{name}" +whatIfUpdate = "Would update %{name} to '%{value}'" +whatIfDelete = "Would delete %{name} '%{value}'" +``` + +Examples: + +```toml +whatIfCreate = " '%{name}' not found, would create it" +whatIfDelete = "Would delete '%{name}'" +``` + +Also add `args.configArgsWhatIfHelp = "Run the operation in what-if mode"` (or equivalent) for the clap flag. + +### 7. What-if Pester tests + +Create one dedicated test file per resource variant. Names are fixed: + +- `.config.whatif.tests.ps1` — single-instance what-if +- `list_whatif.tests.ps1` — `--list` bulk what-if (only if the resource has a list variant) + +Structure: + +```powershell +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe ' config whatif tests' { + BeforeAll { + # Ensure a clean starting state + } + + AfterEach { + # Roll back anything a test may have created + } + + It 'Can whatif a new ' -Skip:(!$IsWindows) { + $json = @' + { "": "" } +'@ + # 1. Capture pre-state + $get_before = config get --input $json 2>$null + + # 2. Run what-if + $result = config set -w --input $json 2>$null | ConvertFrom-Json + + # 3. Assert success + projected state + whatIf metadata + $LASTEXITCODE | Should -Be 0 + $result. | Should -Be '' + $result._metadata.whatIf[0] | Should -Match '.*.*' + + # 4. Assert NO mutation happened + $get_after = config get --input $json 2>$null + $get_before | Should -EQ $get_after + } + + It 'Can whatif delete an existing using _exist is false' -Skip:(!$IsWindows) { + # ... arrange real state via plain `config set` ... + $whatif_delete = @' + { "": "", "_exist": false } +'@ + $result = config set -w --input $whatif_delete 2>$null | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $result._metadata.whatIf | Should -Match "Would delete .*" + } + + It 'Can whatif delete an existing ' -Skip:(!$IsWindows) { + # Same as above, but via the `delete` subcommand: + $result = config delete -w --input $whatif_delete 2>$null | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $result._metadata.whatIf | Should -Match "Would delete .*" + # For delete what-if, payload should only include identity fields (and _metadata) + ($result.psobject.properties | Where-Object { $_.Name -ne '_metadata' } | Measure-Object).Count | + Should -Be 1 + } +} +``` + +Rules for what-if tests: + +- Always call the executable directly (` config set -w --input ...`), **not** via `dsc resource`, so the test pins the CLI contract used by the manifest. +- Use `-w` (not `--what-if`) in tests to lock in the short flag. +- Redirect stderr with `2>$null` to keep test output clean; for failing-test debugging, prefer `2>$testdrive/error.log` + `-Because`. +- Pipe through `ConvertFrom-Json` and assert on `_metadata.whatIf` entries with `Should -Match`. +- Always include at least one assertion that the system state did **not** change (compare `config get` before/after). +- Always include both `set -w` (with and without `_exist: false`) and `delete -w` coverage if the manifest exposes `delete` what-if. +- Top-level `Describe` block uses `-Skip:(!$IsWindows)` for Windows-only resources (or the appropriate platform guard). + +### 8. Naming convention summary (do not deviate) + +| Concern | Name | +|---|---| +| CLI short flag | `-w` | +| CLI long flag | `--what-if` | +| Clap field | `what_if: bool` | +| Helper field | `what_if: bool` | +| Helper enable method | `enable_what_if()` | +| Manifest arg entry | `{ "whatIfArg": "-w" }` | +| Manifest declaration | `"whatIfReturns": "state"` (on `set`) | +| State field | `_metadata` (`metadata: Option` in Rust) | +| Metadata field | `whatIf` (`what_if: Option>` in Rust) | +| Locale section | `[_helper]` | +| Locale key prefix | `whatIfCreate*`, `whatIfUpdate*`, `whatIfDelete*` | +| Locale message style | starts with `"Would "` | +| Error-to-whatif helper | `handle_error_or_what_if(error)` | +| Test file (single) | `.config.whatif.tests.ps1` | +| Test file (list) | `list_whatif.tests.ps1` | +| Describe block title | `' config whatif tests'` | + +### 9. Implementation checklist + +When asked to add what-if to a new resource, perform these steps in order: + +1. Update the resource manifest: add `whatIfArg` to `set` (and `delete`) args, add `whatIfReturns: "state"` to `set`. +2. Add `what_if: bool` to the relevant clap `ConfigSubCommand` variants in `args.rs`. +3. Destructure `what_if` in `main.rs`, call `helper.enable_what_if()`, print projected state JSON. +4. Add `what_if` field, `enable_what_if()` method, and `handle_error_or_what_if()` helper to the resource library struct. +5. Change `set()` / `remove()` to short-circuit OS mutations when `what_if`, accumulate `Vec` of "Would …" messages, return `Some(state)` with `_metadata.whatIf` attached. +6. Add `_metadata: Option` to public state structs and define `Metadata { what_if: Option> }` with the JSON renames shown above. +7. Add `whatIf*` localized strings under `[_helper]` and `args.configArgsWhatIfHelp` in `locales/en-us.toml`. +8. Create `.config.whatif.tests.ps1` (and the list variant if applicable) following the test template; cover create, update, delete-via-`_exist`, and `delete -w`. +9. Build with `./build.ps1 -project ` and run the new Pester file. + diff --git a/resources/windows_firewall/locales/en-us.toml b/resources/windows_firewall/locales/en-us.toml index aade66190..5d848feab 100644 --- a/resources/windows_firewall/locales/en-us.toml +++ b/resources/windows_firewall/locales/en-us.toml @@ -30,3 +30,7 @@ portsNotAllowed = "Ports cannot be specified for firewall rule '%{name}' because invalidProfiles = "Invalid profiles value '%{value}'. Valid values are Domain, Private, Public, or All" invalidInterfaceType = "Invalid interface type '%{value}'. Valid values are RemoteAccess, Wireless, Lan, or All" invalidProtocol = "Invalid protocol number '%{value}'. Must be between 0 and 256" + +[firewall_helper] +whatIfCreateRule = "Would create firewall rule '%{name}'" +whatIfRemoveRule = "Would remove firewall rule '%{name}'" diff --git a/resources/windows_firewall/src/firewall.rs b/resources/windows_firewall/src/firewall.rs index 468ca3284..3a88b4662 100644 --- a/resources/windows_firewall/src/firewall.rs +++ b/resources/windows_firewall/src/firewall.rs @@ -10,7 +10,7 @@ use windows::Win32::System::Com::{CLSCTX_INPROC_SERVER, CoCreateInstance, CoInit use windows::Win32::System::Ole::IEnumVARIANT; use windows::Win32::System::Variant::{VARIANT, VariantClear}; -use crate::types::{FirewallError, FirewallRule, FirewallRuleList, RuleAction, RuleDirection}; +use crate::types::{FirewallError, FirewallRule, FirewallRuleList, Metadata, RuleAction, RuleDirection}; use crate::util::matches_any_filter; /// RAII wrapper for VARIANT that automatically calls VariantClear on drop @@ -274,6 +274,7 @@ fn rule_to_model(rule: &INetFwRule) -> Result { Ok(FirewallRule { name: Some(name.clone()), exist: None, + metadata: None, description: bstr_to_option(unsafe { rule.Description() }.map_err(&err)?)?, application_name: bstr_to_option(unsafe { rule.ApplicationName() }.map_err(&err)?)?, service_name: bstr_to_option(unsafe { rule.ServiceName() }.map_err(&err)?)?, @@ -402,7 +403,30 @@ pub fn get_rules(input: &FirewallRuleList) -> Result Result { +fn project_rule(current: &FirewallRule, desired: &FirewallRule) -> FirewallRule { + FirewallRule { + name: current.name.clone(), + exist: None, + metadata: None, + description: desired.description.clone().or_else(|| current.description.clone()), + application_name: desired.application_name.clone().or_else(|| current.application_name.clone()), + service_name: desired.service_name.clone().or_else(|| current.service_name.clone()), + protocol: desired.protocol.or(current.protocol), + local_ports: desired.local_ports.clone().or_else(|| current.local_ports.clone()), + remote_ports: desired.remote_ports.clone().or_else(|| current.remote_ports.clone()), + local_addresses: desired.local_addresses.clone().or_else(|| current.local_addresses.clone()), + remote_addresses: desired.remote_addresses.clone().or_else(|| current.remote_addresses.clone()), + direction: desired.direction.clone().or_else(|| current.direction.clone()), + action: desired.action.clone().or_else(|| current.action.clone()), + enabled: desired.enabled.or(current.enabled), + profiles: desired.profiles.clone().or_else(|| current.profiles.clone()), + grouping: desired.grouping.clone().or_else(|| current.grouping.clone()), + interface_types: desired.interface_types.clone().or_else(|| current.interface_types.clone()), + edge_traversal: desired.edge_traversal.or(current.edge_traversal), + } +} + +pub fn set_rules(input: &FirewallRuleList, what_if: bool) -> Result { if input.rules.is_empty() { return Err(t!("set.rulesArrayEmpty").to_string().into()); } @@ -421,13 +445,23 @@ pub fn set_rules(input: &FirewallRuleList) -> Result { if desired.exist == Some(false) { @@ -437,21 +471,28 @@ pub fn set_rules(input: &FirewallRuleList) -> Result"), error = "created rule not found").to_string())?; - results.push(rule_to_model(&created)?); + + if what_if { + let mut projected = desired.clone(); + projected.metadata = Some(Metadata { what_if: Some(vec![t!("firewall_helper.whatIfCreateRule", name = rule_name).to_string()]) }); + results.push(projected); + } else { + let rule = store.create_rule_object()?; + unsafe { rule.SetName(&BSTR::from(rule_name.as_str())) } + .map_err(|error| t!("firewall.ruleAddFailed", name = rule_name.as_str(), error = error.to_string()).to_string())?; + + apply_rule_properties(&rule, desired, None)?; + unsafe { store.rules.Add(&rule) } + .map_err(|error| t!("firewall.ruleAddFailed", name = rule_name.as_str(), error = error.to_string()).to_string())?; + + let created = store + .find_by_selector(&FirewallRule { + name: Some(rule_name), + ..FirewallRule::default() + })? + .ok_or_else(|| t!("firewall.ruleLookupFailed", name = desired.selector_name().unwrap_or(""), error = "created rule not found").to_string())?; + results.push(rule_to_model(&created)?); + } } } } diff --git a/resources/windows_firewall/src/main.rs b/resources/windows_firewall/src/main.rs index 3036ed2f1..97ee20ec0 100644 --- a/resources/windows_firewall/src/main.rs +++ b/resources/windows_firewall/src/main.rs @@ -83,8 +83,9 @@ fn main() { } } "set" => { + let what_if = parse_what_if_arg(&args); let input = require_input(input_json); - match firewall::set_rules(&input) { + match firewall::set_rules(&input, what_if) { Ok(result) => { print_json(&result); exit(EXIT_SUCCESS); @@ -139,3 +140,7 @@ fn parse_input_arg(args: &[String]) -> Option { } None } + +fn parse_what_if_arg(args: &[String]) -> bool { + args.iter().any(|arg| arg == "-w" || arg == "--what-if") +} diff --git a/resources/windows_firewall/src/types.rs b/resources/windows_firewall/src/types.rs index 54ac6962a..8362f7648 100644 --- a/resources/windows_firewall/src/types.rs +++ b/resources/windows_firewall/src/types.rs @@ -3,6 +3,12 @@ use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Metadata { + #[serde(rename = "whatIf", skip_serializing_if = "Option::is_none")] + pub what_if: Option>, +} + #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] pub enum RuleDirection { Inbound, @@ -34,6 +40,9 @@ pub struct FirewallRule { #[serde(rename = "_exist", skip_serializing_if = "Option::is_none")] pub exist: Option, + #[serde(rename = "_metadata", skip_serializing_if = "Option::is_none")] + pub metadata: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, diff --git a/resources/windows_firewall/tests/windows_firewall_get.tests.ps1 b/resources/windows_firewall/tests/windows_firewall_get.tests.ps1 index 5a7be2dd1..daf95e307 100644 --- a/resources/windows_firewall/tests/windows_firewall_get.tests.ps1 +++ b/resources/windows_firewall/tests/windows_firewall_get.tests.ps1 @@ -1,16 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -Describe 'Microsoft.Windows/FirewallRuleList - get operation' -Skip:(!$isElevated) { - BeforeDiscovery { - $isElevated = if ($IsWindows) { - ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( - [Security.Principal.WindowsBuiltInRole]::Administrator) - } else { - $false - } - } - +Describe 'Microsoft.Windows/FirewallRuleList - get operation' -Skip:(!$IsWindows) { BeforeAll { $resourceType = 'Microsoft.Windows/FirewallRuleList' @@ -22,13 +13,16 @@ Describe 'Microsoft.Windows/FirewallRuleList - get operation' -Skip:(!$isElevate if (-not $exportedRules -or $exportedRules.Count -eq 0) { throw 'No firewall rules were found on the machine.' } - $knownRuleName = $exportedRules[0].name - if (-not $knownRuleName) { - throw 'The first exported firewall rule has a null or empty name.' + # Skip AppX/UWP rules whose names are ms-resource:// URIs - the COM Item() lookup + # cannot resolve them by name even though enumeration returns them. + $knownRule = $exportedRules | Where-Object { $_.name -and $_.name -notmatch 'ms-resource://' } | Select-Object -First 1 + if (-not $knownRule) { + throw 'No resolvable firewall rule name found on the machine.' } + $knownRuleName = $knownRule.name } - It 'returns an existing rule by name' -Skip:(!$isElevated) { + It 'returns an existing rule by name' { $json = @{ rules = @(@{ name = $knownRuleName }) } | ConvertTo-Json -Compress -Depth 5 $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) @@ -40,7 +34,7 @@ Describe 'Microsoft.Windows/FirewallRuleList - get operation' -Skip:(!$isElevate $result.action | Should -BeIn @('Allow', 'Block') } - It 'returns an existing rule when name matches' -Skip:(!$isElevated) { + It 'returns an existing rule when name matches' { $json = @{ rules = @(@{ name = $knownRuleName }) } | ConvertTo-Json -Compress -Depth 5 $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) @@ -50,7 +44,7 @@ Describe 'Microsoft.Windows/FirewallRuleList - get operation' -Skip:(!$isElevate $result.name | Should -BeExactly $knownRuleName } - It 'returns _exist false with only input properties when the rule is not found' -Skip:(!$isElevated) { + It 'returns _exist false with only input properties when the rule is not found' { $json = @{ rules = @(@{ name = 'DSC-Missing-FirewallRule'; description = 'input only' }) } | ConvertTo-Json -Compress -Depth 5 $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) @@ -62,13 +56,13 @@ Describe 'Microsoft.Windows/FirewallRuleList - get operation' -Skip:(!$isElevate $result.PSObject.Properties.Name | Should -Not -Contain 'direction' } - It 'fails when rules array is empty' -Skip:(!$isElevated) { + It 'fails when rules array is empty' { $json = '{"rules":[]}' $out = $json | dsc resource get -r $resourceType -f - 2>&1 $LASTEXITCODE | Should -Not -Be 0 } - It 'handles multiple rules in a single request' -Skip:(!$isElevated) { + It 'handles multiple rules in a single request' { $json = @{ rules = @(@{ name = $knownRuleName }, @{ name = 'DSC-Missing-FirewallRule' }) } | ConvertTo-Json -Compress -Depth 5 $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) diff --git a/resources/windows_firewall/tests/windows_firewall_whatif.tests.ps1 b/resources/windows_firewall/tests/windows_firewall_whatif.tests.ps1 new file mode 100644 index 000000000..5bd0f085e --- /dev/null +++ b/resources/windows_firewall/tests/windows_firewall_whatif.tests.ps1 @@ -0,0 +1,137 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'windows_firewall config whatif tests' -Skip:(!$isElevated -or !$hasNetSecurity) { + BeforeDiscovery { + $isElevated = if ($IsWindows) { + ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) + } else { + $false + } + $hasNetSecurity = $null -ne (Get-Command 'Get-NetFirewallRule' -ErrorAction SilentlyContinue) + } + + BeforeAll { + $testRuleName = 'DSC-WindowsFirewall-WhatIf-Test' + + function Initialize-TestFirewallRule { + $existing = Get-NetFirewallRule -Name $testRuleName -ErrorAction SilentlyContinue + if (-not $existing) { + New-NetFirewallRule -Name $testRuleName -DisplayName $testRuleName -Direction Inbound -Action Allow -Protocol TCP -LocalPort 32456 | Out-Null + } + } + + function Get-RuleExists { + param([string]$Name = $testRuleName) + $null -ne (Get-NetFirewallRule -Name $Name -ErrorAction SilentlyContinue) + } + } + + AfterEach { + Remove-NetFirewallRule -Name $testRuleName -ErrorAction SilentlyContinue + Remove-NetFirewallRule -Name 'DSC-WindowsFirewall-WhatIf-Create-Test' -ErrorAction SilentlyContinue + } + + It 'Can whatif create a new rule' -Skip:(!$isElevated -or !$hasNetSecurity) { + $createRuleName = 'DSC-WindowsFirewall-WhatIf-Create-Test' + Remove-NetFirewallRule -Name $createRuleName -ErrorAction SilentlyContinue + + $json = @{ + rules = @(@{ + name = $createRuleName + direction = 'Inbound' + action = 'Block' + protocol = 6 + enabled = $true + }) + } | ConvertTo-Json -Compress -Depth 5 + + $existsBefore = Get-RuleExists -Name $createRuleName + + $result = windows_firewall set -w --input $json 2>$null | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $result.rules[0].name | Should -BeExactly $createRuleName + $result.rules[0].direction | Should -BeExactly 'Inbound' + $result.rules[0].action | Should -BeExactly 'Block' + $result.rules[0]._metadata.whatIf | Should -Match "Would create firewall rule '$createRuleName'" + + # Assert no mutation happened + $existsAfter = Get-RuleExists -Name $createRuleName + $existsBefore | Should -Be $existsAfter + } + + It 'Can whatif update an existing rule' -Skip:(!$isElevated -or !$hasNetSecurity) { + Initialize-TestFirewallRule + + $json = @{ + rules = @(@{ + name = $testRuleName + description = 'WhatIf updated description' + enabled = $false + }) + } | ConvertTo-Json -Compress -Depth 5 + + $stateBefore = Get-NetFirewallRule -Name $testRuleName -ErrorAction SilentlyContinue + + $result = windows_firewall set -w --input $json 2>$null | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $result.rules[0].name | Should -BeExactly $testRuleName + $result.rules[0].description | Should -BeExactly 'WhatIf updated description' + $result.rules[0].enabled | Should -BeFalse + + # Assert no mutation happened + $stateAfter = Get-NetFirewallRule -Name $testRuleName -ErrorAction SilentlyContinue + $stateAfter.Description | Should -Be $stateBefore.Description + $stateAfter.Enabled | Should -Be $stateBefore.Enabled + } + + It 'Can whatif remove an existing rule using _exist is false' -Skip:(!$isElevated -or !$hasNetSecurity) { + Initialize-TestFirewallRule + + $json = @{ + rules = @(@{ + name = $testRuleName + '_exist' = $false + }) + } | ConvertTo-Json -Compress -Depth 5 + + $result = windows_firewall set -w --input $json 2>$null | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $result.rules[0].name | Should -BeExactly $testRuleName + $result.rules[0]._exist | Should -BeFalse + $result.rules[0]._metadata.whatIf | Should -Match "Would remove firewall rule '$testRuleName'" + + # Assert no mutation happened — rule should still exist + Get-RuleExists | Should -BeTrue + } + + It 'Can whatif multiple rules in a single request' -Skip:(!$isElevated -or !$hasNetSecurity) { + Initialize-TestFirewallRule + $createRuleName = 'DSC-WindowsFirewall-WhatIf-Create-Test' + Remove-NetFirewallRule -Name $createRuleName -ErrorAction SilentlyContinue + + $json = @{ + rules = @( + @{ name = $testRuleName; description = 'WhatIf multi test' } + @{ name = $createRuleName; direction = 'Outbound'; action = 'Allow'; protocol = 17; enabled = $true } + ) + } | ConvertTo-Json -Compress -Depth 5 + + $result = windows_firewall set -w --input $json 2>$null | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $result.rules.Count | Should -Be 2 + + # First rule — update projection, no create metadata + $result.rules[0].name | Should -BeExactly $testRuleName + $result.rules[0].description | Should -BeExactly 'WhatIf multi test' + + # Second rule — create projection, includes whatIf metadata + $result.rules[1].name | Should -BeExactly $createRuleName + $result.rules[1]._metadata.whatIf | Should -Match "Would create firewall rule '$createRuleName'" + + # Assert no mutations happened + (Get-RuleExists) | Should -BeTrue + (Get-RuleExists -Name $createRuleName) | Should -BeFalse + } +} diff --git a/resources/windows_firewall/windows_firewall.dsc.resource.json b/resources/windows_firewall/windows_firewall.dsc.resource.json index 24c1399f7..0c9a0f08a 100644 --- a/resources/windows_firewall/windows_firewall.dsc.resource.json +++ b/resources/windows_firewall/windows_firewall.dsc.resource.json @@ -6,7 +6,7 @@ "Windows", "Firewall" ], - "version": "0.1.0", + "version": "0.1.1", "get": { "executable": "windows_firewall", "args": [ @@ -24,10 +24,15 @@ { "jsonInputArg": "--input", "mandatory": true + }, + { + "whatIfArg": "-w" } ], "implementsPretest": false, + "handlesExist": true, "return": "state", + "whatIfReturns": "state", "requireSecurityContext": "elevated" }, "export": { @@ -177,6 +182,20 @@ "type": "boolean", "title": "Edge traversal", "description": "Indicates whether edge traversal is enabled for the rule." + }, + "_metadata": { + "type": "object", + "title": "Metadata", + "description": "Metadata populated during what-if operations.", + "readOnly": true, + "additionalProperties": false, + "properties": { + "whatIf": { + "type": "array", + "description": "Messages describing what the set operation would do.", + "items": { "type": "string" } + } + } } } }