Skip to content

Commit f500963

Browse files
authored
Merge pull request #1530 from Gijsreyn/gh-1529/main/add-whatif-firewall
feat: Add `--what-if` for `Microsoft.Windows/FirewallRuleList`
2 parents 0ee5659 + ed65102 commit f500963

8 files changed

Lines changed: 549 additions & 41 deletions

File tree

.github/skills/create-dsc-resource/SKILL.md

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,3 +262,302 @@ someError = "Failed to do something: %{error}"
262262
#### Build and Deployment
263263

264264
- The resource should be built using `build.ps1 -project <resource_name>` from the root of the repository, which will handle building the Rust code and ensure it is found in PATH for testing
265+
266+
## What-If support
267+
268+
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.
269+
270+
What-if must:
271+
272+
1. Project the **final state** the resource would produce, without mutating the system.
273+
2. Echo back the relevant input fields (`keyPath`, `valueName`, `valueData`, etc.) so the engine can diff before/after.
274+
3. Attach human-readable "would do …" messages under `_metadata.whatIf` (an array of strings).
275+
4. Exit `0` on success — what-if is not an error path.
276+
277+
### 1. Resource manifest changes
278+
279+
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`:
280+
281+
```json
282+
"set": {
283+
"executable": "<resource_name>",
284+
"args": [
285+
"config", "set",
286+
{ "jsonInputArg": "--input", "mandatory": true },
287+
{ "whatIfArg": "--what-if" }
288+
],
289+
"whatIfReturns": "state"
290+
},
291+
"delete": {
292+
"executable": "<resource_name>",
293+
"args": [
294+
"config", "delete",
295+
{ "jsonInputArg": "--input", "mandatory": true },
296+
{ "whatIfArg": "--what-if" }
297+
]
298+
}
299+
```
300+
301+
- `whatIfArg` is the literal CLI flag DSC will append when the user runs `dsc config set --what-if`. Always use `"--what-if"` (long form) for consistency across resources.
302+
- `whatIfReturns: "state"` tells DSC the executable prints the projected post-state JSON on stdout (same shape as `get`/`set` returns).
303+
- The `--list` (bulk) variant of a resource uses the same two manifest additions; do not invent new flag names.
304+
305+
### 2. CLI args (`args.rs`) changes
306+
307+
Add a `-w` / `--what-if` boolean to every `ConfigSubCommand` variant that can support what-if:
308+
309+
```rust
310+
#[derive(Debug, PartialEq, Eq, Subcommand)]
311+
pub enum ConfigSubCommand {
312+
#[clap(name = "set", about = t!("args.configSetAbout").to_string())]
313+
Set {
314+
#[clap(short, long, required = true, help = t!("args.configArgsInputHelp").to_string())]
315+
input: String,
316+
#[clap(short = 'w', long, help = t!("args.configArgsWhatIfHelp").to_string())]
317+
what_if: bool,
318+
},
319+
#[clap(name = "delete", about = t!("args.configDeleteAbout").to_string())]
320+
Delete {
321+
#[clap(short, long, required = true, help = t!("args.configArgsInputHelp").to_string())]
322+
input: String,
323+
#[clap(short = 'w', long, help = t!("args.configArgsWhatIfHelp").to_string())]
324+
what_if: bool,
325+
},
326+
}
327+
```
328+
329+
Naming is fixed: clap field is `what_if`, short flag is `-w`, long flag is `--what-if`, help key is `args.configArgsWhatIfHelp`.
330+
331+
### 3. `main.rs` dispatch
332+
333+
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.
334+
335+
```rust
336+
args::ConfigSubCommand::Set { input, what_if } => {
337+
trace!("Set input: {input}, what_if: {what_if}");
338+
let mut helper = match Helper::new_from_json(&input) {
339+
Ok(h) => h,
340+
Err(err) => { error!("{err}"); exit(EXIT_INVALID_INPUT); }
341+
};
342+
if what_if { helper.enable_what_if(); }
343+
344+
match helper.set() {
345+
Ok(Some(state)) => {
346+
// Set returns Some(state) when what_if is true (projected state)
347+
// OR when whatIfReturns == "state" and the resource emits final state.
348+
let json = serde_json::to_string(&state).unwrap();
349+
println!("{json}");
350+
}
351+
Ok(None) => {}
352+
Err(err) => { error!("{err}"); exit(EXIT_RESOURCE_ERROR); }
353+
}
354+
exit(EXIT_SUCCESS);
355+
}
356+
```
357+
358+
For the `--list` bulk variant, accumulate projected states in a `Vec`, then print the whole list once at the end.
359+
360+
### 4. Library / helper changes
361+
362+
In the resource's `dsc-lib-*` crate:
363+
364+
- Add a `what_if: bool` field on the helper struct, defaulting to `false` in every constructor.
365+
- Expose `pub fn enable_what_if(&mut self) { self.what_if = true; }`.
366+
- Change `set()` (and `remove()`) to return `Result<Option<T>, Error>` where `Some(T)` is the projected state when `what_if` is true.
367+
- Inside `set` / `remove`, build a `Vec<String> what_if_metadata`, push localized "Would …" strings at each side-effecting branch, and **short-circuit** before the real OS call when `self.what_if`:
368+
369+
```rust
370+
if self.what_if {
371+
what_if_metadata.push(t!("<resource>_helper.whatIfCreate<Thing>", name = name).to_string());
372+
} else {
373+
// perform the real OS mutation
374+
}
375+
```
376+
377+
- Return the projected state with the metadata attached:
378+
379+
```rust
380+
return Ok(Some(<ResourceState> {
381+
// identity + projected fields the engine needs to diff
382+
metadata: if what_if_metadata.is_empty() {
383+
None
384+
} else {
385+
Some(Metadata { what_if: Some(what_if_metadata) })
386+
},
387+
..Default::default()
388+
}));
389+
```
390+
391+
- 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:
392+
393+
```rust
394+
fn handle_error_or_what_if(&self, error: Error) -> Result<Option<T>, Error> {
395+
if self.what_if {
396+
return Ok(Some(T {
397+
// identity fields from self.config
398+
metadata: Some(Metadata { what_if: Some(vec![error.to_string()]) }),
399+
..Default::default()
400+
}));
401+
}
402+
Err(error)
403+
}
404+
```
405+
406+
- 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.
407+
408+
### 5. Types (`config.rs` / `types.rs`) changes
409+
410+
Add a `_metadata` field of type `Option<Metadata>` to every public state struct, and define `Metadata` exactly once per crate:
411+
412+
```rust
413+
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
414+
#[serde(rename = "<ResourceName>", deny_unknown_fields)]
415+
pub struct <ResourceName> {
416+
// ... resource properties ...
417+
418+
#[serde(rename = "_metadata", skip_serializing_if = "Option::is_none")]
419+
pub metadata: Option<Metadata>,
420+
421+
#[serde(rename = "_exist", skip_serializing_if = "Option::is_none")]
422+
pub exist: Option<bool>,
423+
}
424+
425+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
426+
#[serde(deny_unknown_fields)]
427+
pub struct Metadata {
428+
#[serde(rename = "whatIf", skip_serializing_if = "Option::is_none")]
429+
pub what_if: Option<Vec<String>>,
430+
}
431+
```
432+
433+
Naming is fixed: JSON field is `_metadata`, nested array is `whatIf`, Rust field is `what_if: Option<Vec<String>>`.
434+
435+
### 6. Localization strings
436+
437+
Add localized what-if messages to `locales/en-us.toml` under the helper's section, all starting with the verb **"Would"**:
438+
439+
```toml
440+
[<resource>_helper]
441+
whatIfCreate<Thing> = "Would create %{name}"
442+
whatIfUpdate<Thing> = "Would update %{name} to '%{value}'"
443+
whatIfDelete<Thing> = "Would delete %{name} '%{value}'"
444+
```
445+
446+
Examples:
447+
448+
```toml
449+
whatIfCreate<Thing> = "<Thing> '%{name}' not found, would create it"
450+
whatIfDelete<Thing> = "Would delete <thing> '%{name}'"
451+
```
452+
453+
Also add `args.configArgsWhatIfHelp = "Run the operation in what-if mode"` (or equivalent) for the clap flag.
454+
455+
### 7. What-if Pester tests
456+
457+
Create one dedicated test file per resource variant. Names are fixed:
458+
459+
- `<resource>.config.whatif.tests.ps1` — single-instance what-if
460+
- `<resource>list_whatif.tests.ps1``--list` bulk what-if (only if the resource has a list variant)
461+
462+
Structure:
463+
464+
```powershell
465+
# Copyright (c) Microsoft Corporation.
466+
# Licensed under the MIT License.
467+
468+
Describe '<resource> config whatif tests' {
469+
BeforeAll {
470+
# Ensure a clean starting state
471+
}
472+
473+
AfterEach {
474+
# Roll back anything a test may have created
475+
}
476+
477+
It 'Can whatif a new <thing>' -Skip:(!$IsWindows) {
478+
$json = @'
479+
{ "<key>": "<value>" }
480+
'@
481+
# 1. Capture pre-state
482+
$get_before = <resource> config get --input $json 2>$null
483+
484+
# 2. Run what-if
485+
$result = <resource> config set -w --input $json 2>$null | ConvertFrom-Json
486+
487+
# 3. Assert success + projected state + whatIf metadata
488+
$LASTEXITCODE | Should -Be 0
489+
$result.<key> | Should -Be '<value>'
490+
$result._metadata.whatIf[0] | Should -Match '.*<expected fragment>.*'
491+
492+
# 4. Assert NO mutation happened
493+
$get_after = <resource> config get --input $json 2>$null
494+
$get_before | Should -EQ $get_after
495+
}
496+
497+
It 'Can whatif delete an existing <thing> using _exist is false' -Skip:(!$IsWindows) {
498+
# ... arrange real state via plain `config set` ...
499+
$whatif_delete = @'
500+
{ "<key>": "<value>", "_exist": false }
501+
'@
502+
$result = <resource> config set -w --input $whatif_delete 2>$null | ConvertFrom-Json
503+
$LASTEXITCODE | Should -Be 0
504+
$result._metadata.whatIf | Should -Match "Would delete .*"
505+
}
506+
507+
It 'Can whatif delete an existing <thing>' -Skip:(!$IsWindows) {
508+
# Same as above, but via the `delete` subcommand:
509+
$result = <resource> config delete -w --input $whatif_delete 2>$null | ConvertFrom-Json
510+
$LASTEXITCODE | Should -Be 0
511+
$result._metadata.whatIf | Should -Match "Would delete .*"
512+
# For delete what-if, payload should only include identity fields (and _metadata)
513+
($result.psobject.properties | Where-Object { $_.Name -ne '_metadata' } | Measure-Object).Count |
514+
Should -Be 1
515+
}
516+
}
517+
```
518+
519+
Rules for what-if tests:
520+
521+
- Always call the executable directly (`<resource> config set -w --input ...`), **not** via `dsc resource`, so the test pins the CLI contract used by the manifest.
522+
- Use `-w` (not `--what-if`) in tests to lock in the short flag.
523+
- Redirect stderr with `2>$null` to keep test output clean; for failing-test debugging, prefer `2>$testdrive/error.log` + `-Because`.
524+
- Pipe through `ConvertFrom-Json` and assert on `_metadata.whatIf` entries with `Should -Match`.
525+
- Always include at least one assertion that the system state did **not** change (compare `config get` before/after).
526+
- Always include both `set -w` (with and without `_exist: false`) and `delete -w` coverage if the manifest exposes `delete` what-if.
527+
- Top-level `Describe` block uses `-Skip:(!$IsWindows)` for Windows-only resources (or the appropriate platform guard).
528+
529+
### 8. Naming convention summary (do not deviate)
530+
531+
| Concern | Name |
532+
|---|---|
533+
| CLI short flag | `-w` |
534+
| CLI long flag | `--what-if` |
535+
| Clap field | `what_if: bool` |
536+
| Helper field | `what_if: bool` |
537+
| Helper enable method | `enable_what_if()` |
538+
| Manifest arg entry | `{ "whatIfArg": "--what-if" }` |
539+
| Manifest declaration | `"whatIfReturns": "state"` (on `set`) |
540+
| State field | `_metadata` (`metadata: Option<Metadata>` in Rust) |
541+
| Metadata field | `whatIf` (`what_if: Option<Vec<String>>` in Rust) |
542+
| Locale section | `[<resource>_helper]` |
543+
| Locale key prefix | `whatIfCreate*`, `whatIfUpdate*`, `whatIfDelete*` |
544+
| Locale message style | starts with `"Would "` |
545+
| Error-to-whatif helper | `handle_error_or_what_if(error)` |
546+
| Test file (single) | `<resource>.config.whatif.tests.ps1` |
547+
| Test file (list) | `<resource>list_whatif.tests.ps1` |
548+
| Describe block title | `'<resource> config whatif tests'` |
549+
550+
### 9. Implementation checklist
551+
552+
When asked to add what-if to a new resource, perform these steps in order:
553+
554+
1. Update the resource manifest: add `whatIfArg` to `set` (and `delete`) args, add `whatIfReturns: "state"` to `set`.
555+
2. Add `what_if: bool` to the relevant clap `ConfigSubCommand` variants in `args.rs`.
556+
3. Destructure `what_if` in `main.rs`, call `helper.enable_what_if()`, print projected state JSON.
557+
4. Add `what_if` field, `enable_what_if()` method, and `handle_error_or_what_if()` helper to the resource library struct.
558+
5. Change `set()` / `remove()` to short-circuit OS mutations when `what_if`, accumulate `Vec<String>` of "Would …" messages, return `Some(state)` with `_metadata.whatIf` attached.
559+
6. Add `_metadata: Option<Metadata>` to public state structs and define `Metadata { what_if: Option<Vec<String>> }` with the JSON renames shown above.
560+
7. Add `whatIf*` localized strings under `[<resource>_helper]` and `args.configArgsWhatIfHelp` in `locales/en-us.toml`.
561+
8. Create `<resource>.config.whatif.tests.ps1` (and the list variant if applicable) following the test template; cover create, update, delete-via-`_exist`, and `delete -w`.
562+
9. Build with `./build.ps1 -project <resource_name>` and run the new Pester file.
563+

resources/windows_firewall/locales/en-us.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,7 @@ portsNotAllowed = "Ports cannot be specified for firewall rule '%{name}' because
3030
invalidProfiles = "Invalid profiles value '%{value}'. Valid values are Domain, Private, Public, or All"
3131
invalidInterfaceType = "Invalid interface type '%{value}'. Valid values are RemoteAccess, Wireless, Lan, or All"
3232
invalidProtocol = "Invalid protocol number '%{value}'. Must be between 0 and 256"
33+
34+
[firewall_helper]
35+
whatIfCreateRule = "Would create firewall rule '%{name}'"
36+
whatIfRemoveRule = "Would remove firewall rule '%{name}'"

0 commit comments

Comments
 (0)