diff --git a/resources/windows_service/locales/en-us.toml b/resources/windows_service/locales/en-us.toml index 01484a926..8d02eca4e 100644 --- a/resources/windows_service/locales/en-us.toml +++ b/resources/windows_service/locales/en-us.toml @@ -1,7 +1,7 @@ _version = 1 [main] -missingOperation = "Missing operation. Usage: windows_service get --input | set --input | export [--input ]" +missingOperation = "Missing operation. Usage: windows_service get --input | set [-w] --input | export [--input ]" unknownOperation = "Unknown operation: '%{operation}'. Expected: get, set, or export" missingInput = "Missing --input argument" missingInputValue = "Missing value for --input argument" @@ -15,7 +15,6 @@ queryConfigFailed = "Failed to query service configuration: %{error}" queryStatusFailed = "Failed to query service status: %{error}" openServiceFailed = "Failed to open service: %{error}" getKeyNameFailed = "Failed to resolve service name from display name: %{error}" -displayNameMismatch = "Service display name mismatch: expected '%{expected}', got '%{actual}'" [export] enumServicesFailed = "Failed to enumerate services: %{error}" @@ -35,3 +34,14 @@ unsupportedTransition = "Unsupported status transition from '%{current}' to '%{d unsupportedLogonAccount = "Unsupported logon account '%{account}'; only built-in service accounts are supported (LocalSystem, NT AUTHORITY\\LocalService, NT AUTHORITY\\NetworkService)" unsupportedStatus = "Cannot set service to status '%{status}'; only Running, Stopped, and Paused are supported" statusTimeout = "Timed out waiting for service to reach status '%{expected}'; current status is '%{actual}'" +whatIfServiceNotFound = "Service '%{name}' does not exist; cannot set" +whatIfChangeDisplayName = "Would change displayName from '%{current}' to '%{desired}'" +whatIfChangeDescription = "Would change description from '%{current}' to '%{desired}'" +whatIfChangeStartType = "Would change startType from '%{current}' to '%{desired}'" +whatIfChangeExecutablePath = "Would change executablePath from '%{current}' to '%{desired}'" +whatIfChangeLogonAccount = "Would change logonAccount from '%{current}' to '%{desired}'" +whatIfChangeErrorControl = "Would change errorControl from '%{current}' to '%{desired}'" +whatIfChangeDependencies = "Would set dependencies to '%{desired}'" +whatIfChangeStatus = "Would change status from '%{current}' to '%{desired}'" +whatIfDeleteService = "Would delete service '%{name}'" +whatIfDeleteServiceNotFound = "Service '%{name}' does not exist; no delete needed" diff --git a/resources/windows_service/src/main.rs b/resources/windows_service/src/main.rs index 0273ac11d..c5f3c9968 100644 --- a/resources/windows_service/src/main.rs +++ b/resources/windows_service/src/main.rs @@ -69,6 +69,7 @@ fn main() { let operation = args[1].as_str(); let input_json = parse_input_arg(&args); + let what_if = parse_what_if_flag(&args); match operation { "get" => { @@ -88,7 +89,22 @@ fn main() { "set" => { let input = require_input(input_json); - match service::set_service(&input) { + // In what-if, if the desired state is _exist: false, route to delete + // so the projected state and metadata describe a delete operation. + if what_if && matches!(input.exist, Some(false)) { + match service::what_if_delete_service(&input) { + Ok(result) => { + print_json(&result); + exit(EXIT_SUCCESS); + } + Err(e) => { + write_error(&e.to_string()); + exit(EXIT_SERVICE_ERROR); + } + } + } + + match service::set_service(&input, what_if) { Ok(result) => { print_json(&result); exit(EXIT_SUCCESS); @@ -146,3 +162,8 @@ fn parse_input_arg(args: &[String]) -> Option { } None } + +/// Parse the `--what-if` / `-w` flag from the command-line args. +fn parse_what_if_flag(args: &[String]) -> bool { + args.iter().skip(2).any(|a| a == "--what-if" || a == "-w") +} diff --git a/resources/windows_service/src/service.rs b/resources/windows_service/src/service.rs index c54d5e979..75cbeaaad 100644 --- a/resources/windows_service/src/service.rs +++ b/resources/windows_service/src/service.rs @@ -153,6 +153,7 @@ unsafe fn read_service_state( logon_account, error_control, dependencies, + metadata: None, }) } @@ -220,18 +221,6 @@ pub fn get_service(input: &WindowsService) -> Result bool { ) } -pub fn set_service(input: &WindowsService) -> Result { +pub fn set_service(input: &WindowsService, what_if: bool) -> Result { let name = input.name.as_deref() .ok_or_else(|| t!("set.nameRequired").to_string())?; @@ -613,6 +602,10 @@ pub fn set_service(input: &WindowsService) -> Result bool { true } + +/// Compute the projected state of a service after applying `input`, without +/// making any changes. Populates `_metadata.whatIf` with one entry per change +/// that would be applied. If the service does not exist, returns the desired +/// state with a single `whatIf` entry describing the failure to open it. +fn what_if_set(input: &WindowsService, name: &str) -> Result { + // Look up the current state using only the service key name; we want the + // live values to compare against, not a re-validation of the desired ones. + let lookup = WindowsService { + name: Some(name.to_string()), + ..Default::default() + }; + let current = match get_service(&lookup) { + Ok(svc) => svc, + Err(e) => { + // Surface the error via metadata so what-if never errors out. + return Ok(WindowsService { + name: Some(name.to_string()), + exist: Some(false), + metadata: Some(Metadata { + what_if: Some(vec![e.to_string()]), + }), + ..Default::default() + }); + } + }; + + let mut messages: Vec = Vec::new(); + let exists = current.exist.unwrap_or(true); + + if !exists { + messages.push( + t!("set.whatIfServiceNotFound", name = name).to_string(), + ); + return Ok(WindowsService { + name: Some(name.to_string()), + display_name: input.display_name.clone(), + description: input.description.clone(), + status: input.status.clone(), + start_type: input.start_type.clone(), + executable_path: input.executable_path.clone(), + logon_account: input.logon_account.clone(), + error_control: input.error_control.clone(), + dependencies: input.dependencies.clone(), + exist: Some(false), + metadata: Some(Metadata { what_if: Some(messages) }), + }); + } + + // Compare each desired field to the current state and emit messages only + // when the value would actually change. + if let Some(ref desired) = input.display_name + && current.display_name.as_deref() != Some(desired.as_str()) { + messages.push(t!("set.whatIfChangeDisplayName", + current = current.display_name.clone().unwrap_or_default(), + desired = desired + ).to_string()); + } + if let Some(ref desired) = input.description + && current.description.as_deref() != Some(desired.as_str()) { + messages.push(t!("set.whatIfChangeDescription", + current = current.description.clone().unwrap_or_default(), + desired = desired + ).to_string()); + } + if let Some(ref desired) = input.start_type + && current.start_type.as_ref() != Some(desired) { + messages.push(t!("set.whatIfChangeStartType", + current = current.start_type.as_ref().map_or_else(String::new, ToString::to_string), + desired = desired.to_string() + ).to_string()); + } + if let Some(ref desired) = input.executable_path + && current.executable_path.as_deref() != Some(desired.as_str()) { + messages.push(t!("set.whatIfChangeExecutablePath", + current = current.executable_path.clone().unwrap_or_default(), + desired = desired + ).to_string()); + } + if let Some(ref desired) = input.logon_account + && current.logon_account.as_deref().is_none_or(|c| !c.eq_ignore_ascii_case(desired)) { + messages.push(t!("set.whatIfChangeLogonAccount", + current = current.logon_account.clone().unwrap_or_default(), + desired = desired + ).to_string()); + } + if let Some(ref desired) = input.error_control + && current.error_control.as_ref() != Some(desired) { + messages.push(t!("set.whatIfChangeErrorControl", + current = current.error_control.as_ref().map_or_else(String::new, ToString::to_string), + desired = desired.to_string() + ).to_string()); + } + if let Some(ref desired) = input.dependencies + && current.dependencies.as_deref() != Some(desired.as_slice()) { + messages.push(t!("set.whatIfChangeDependencies", + desired = desired.join(", ") + ).to_string()); + } + if let Some(ref desired) = input.status + && current.status.as_ref() != Some(desired) { + messages.push(t!("set.whatIfChangeStatus", + current = current.status.as_ref().map_or_else(String::new, ToString::to_string), + desired = desired.to_string() + ).to_string()); + } + + // Project the desired state — fields explicitly provided in input override + // the corresponding values from the current state. + let projected = WindowsService { + name: Some(name.to_string()), + display_name: input.display_name.clone().or(current.display_name), + description: input.description.clone().or(current.description), + exist: Some(true), + status: input.status.clone().or(current.status), + start_type: input.start_type.clone().or(current.start_type), + executable_path: input.executable_path.clone().or(current.executable_path), + logon_account: input.logon_account.clone().or(current.logon_account), + error_control: input.error_control.clone().or(current.error_control), + dependencies: input.dependencies.clone().or(current.dependencies), + metadata: if messages.is_empty() { + None + } else { + Some(Metadata { what_if: Some(messages) }) + }, + }; + + Ok(projected) +} + +/// Project the state for a service deletion without making any changes. +pub fn what_if_delete_service(input: &WindowsService) -> Result { + let name = input.name.as_deref() + .ok_or_else(|| t!("set.nameRequired").to_string())?; + + unsafe { + let scm = OpenSCManagerW(None, None, SC_MANAGER_CONNECT) + .map_err(|e| t!("set.openScmFailed", error = e.to_string()).to_string())?; + let scm = ScHandle(scm); + + let name_wide = to_wide(name); + let service_handle = match OpenServiceW( + scm.0, + PCWSTR(name_wide.as_ptr()), + SERVICE_QUERY_CONFIG | SERVICE_QUERY_STATUS, + ) { + Ok(h) => ScHandle(h), + Err(e) if e.code() == ERROR_SERVICE_DOES_NOT_EXIST.to_hresult() => { + return Ok(WindowsService { + name: Some(name.to_string()), + exist: Some(false), + metadata: Some(Metadata { + what_if: Some(vec![ + t!("set.whatIfDeleteServiceNotFound", name = name).to_string(), + ]), + }), + ..Default::default() + }); + } + Err(e) => { + return Err(t!("set.openServiceFailed", error = e.to_string()).to_string().into()); + } + }; + + let mut current = read_service_state(service_handle.0, name)?; + current.exist = Some(false); + current.metadata = Some(Metadata { + what_if: Some(vec![ + t!("set.whatIfDeleteService", name = name).to_string(), + ]), + }); + Ok(current) + } +} diff --git a/resources/windows_service/src/types.rs b/resources/windows_service/src/types.rs index 69241c58a..167a31f91 100644 --- a/resources/windows_service/src/types.rs +++ b/resources/windows_service/src/types.rs @@ -87,6 +87,20 @@ pub struct WindowsService { /// A list of service names that this service depends on. #[serde(skip_serializing_if = "Option::is_none")] pub dependencies: Option>, + + /// Metadata returned by what-if operations describing the changes that + /// would be applied if the operation ran for real. + #[serde(rename = "_metadata", skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +/// Metadata returned by what-if operations. +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Metadata { + /// Human-readable descriptions of the changes that would be applied. + #[serde(rename = "whatIf", skip_serializing_if = "Option::is_none")] + pub what_if: Option>, } impl WindowsService { @@ -104,6 +118,7 @@ impl WindowsService { logon_account: None, error_control: None, dependencies: None, + metadata: None, } } } diff --git a/resources/windows_service/tests/windows_service_get.tests.ps1 b/resources/windows_service/tests/windows_service_get.tests.ps1 index 69fe7a1c2..19442feff 100644 --- a/resources/windows_service/tests/windows_service_get.tests.ps1 +++ b/resources/windows_service/tests/windows_service_get.tests.ps1 @@ -73,10 +73,14 @@ Describe 'Windows Service get tests' -Skip:(!$IsWindows) { $result._exist | Should -BeTrue } - It 'Returns error when name and displayName do not match' { + It 'Returns the live displayName when the desired displayName differs (name is authoritative)' { $json = @{ name = $knownServiceName; displayName = 'Wrong Display Name' } | ConvertTo-Json -Compress - $out = $json | dsc resource get -r $resourceType -f - 2>&1 - $LASTEXITCODE | Should -Not -Be 0 + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = ($out | ConvertFrom-Json).actualState + $result.name | Should -BeExactly $knownServiceName + $result.displayName | Should -BeExactly $knownDisplayName + $result._exist | Should -BeTrue } } diff --git a/resources/windows_service/tests/windows_service_set.tests.ps1 b/resources/windows_service/tests/windows_service_set.tests.ps1 index 5ed285119..2c7b9a416 100644 --- a/resources/windows_service/tests/windows_service_set.tests.ps1 +++ b/resources/windows_service/tests/windows_service_set.tests.ps1 @@ -268,4 +268,65 @@ Describe 'Windows Service set tests' -Skip:(!$IsWindows) { $result._exist | Should -BeTrue } } + + Context 'What-if set' -Skip:(!$isAdmin) { + It 'Projects a startType change without modifying the service' { + $before = Get-ServiceState -Name $testServiceName + $desiredStartType = if ($before.startType -eq 'Disabled') { 'Manual' } else { 'Disabled' } + $json = @{ name = $testServiceName; startType = $desiredStartType } | ConvertTo-Json -Compress + + $out = $json | dsc resource set --what-if -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = $out | ConvertFrom-Json + $after = $result.afterState + + $after.name | Should -BeExactly $testServiceName + $after.startType | Should -BeExactly $desiredStartType + $after._exist | Should -BeTrue + $after._metadata.whatIf | Should -Not -BeNullOrEmpty + $after._metadata.whatIf | Should -Contain "Would change startType from '$($before.startType)' to '$desiredStartType'" + + $current = Get-ServiceState -Name $testServiceName + $current.startType | Should -BeExactly $before.startType + } + + It 'Projects a displayName change without modifying the service' { + $before = Get-ServiceState -Name $testServiceName + $desiredDisplayName = "$($before.displayName) (whatif)" + $json = @{ name = $testServiceName; displayName = $desiredDisplayName } | ConvertTo-Json -Compress + + $out = $json | dsc resource set --what-if -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = $out | ConvertFrom-Json + $after = $result.afterState + + $after.name | Should -BeExactly $testServiceName + $after.displayName | Should -BeExactly $desiredDisplayName + $after._exist | Should -BeTrue + $after._metadata.whatIf | Should -Not -BeNullOrEmpty + $after._metadata.whatIf | Should -Contain "Would change displayName from '$($before.displayName)' to '$desiredDisplayName'" + + $current = Get-ServiceState -Name $testServiceName + $current.displayName | Should -BeExactly $before.displayName + } + + It 'Projects deletion when _exist is false without modifying the service' { + $before = Get-ServiceState -Name $testServiceName + $json = @{ name = $testServiceName; _exist = $false } | ConvertTo-Json -Compress + + $out = $json | dsc resource set --what-if -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = $out | ConvertFrom-Json + $after = $result.afterState + + $after.name | Should -BeExactly $testServiceName + $after._exist | Should -BeFalse + $after._metadata.whatIf | Should -Not -BeNullOrEmpty + $after._metadata.whatIf | Should -Contain "Would delete service '$testServiceName'" + + $current = Get-ServiceState -Name $testServiceName + $current._exist | Should -BeTrue + $current.startType | Should -BeExactly $before.startType + } + } } diff --git a/resources/windows_service/windows_service.dsc.resource.json b/resources/windows_service/windows_service.dsc.resource.json index f37083a82..d70058e7f 100644 --- a/resources/windows_service/windows_service.dsc.resource.json +++ b/resources/windows_service/windows_service.dsc.resource.json @@ -23,11 +23,15 @@ { "jsonInputArg": "--input", "mandatory": true + }, + { + "whatIfArg": "-w" } ], "implementsPretest": false, "return": "state", - "requireSecurityContext": "elevated" + "requireSecurityContext": "elevated", + "whatIfReturns": "state" }, "export": { "executable": "windows_service", @@ -108,6 +112,18 @@ "items": { "type": "string" } + }, + "_metadata": { + "type": "object", + "title": "Metadata", + "description": "Metadata returned by what-if operations describing the changes that would be applied.", + "readOnly": true, + "properties": { + "whatIf": { + "type": "array", + "items": { "type": "string" } + } + } } }, "additionalProperties": false