Skip to content
14 changes: 12 additions & 2 deletions resources/windows_service/locales/en-us.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
_version = 1

[main]
missingOperation = "Missing operation. Usage: windows_service get --input <json> | set --input <json> | export [--input <json>]"
missingOperation = "Missing operation. Usage: windows_service get --input <json> | set [--what-if] --input <json> | export [--input <json>]"
unknownOperation = "Unknown operation: '%{operation}'. Expected: get, set, or export"
missingInput = "Missing --input argument"
missingInputValue = "Missing value for --input argument"
Expand All @@ -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}"
Expand All @@ -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"
23 changes: 22 additions & 1 deletion resources/windows_service/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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" => {
Expand All @@ -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) {
Comment thread
Gijsreyn marked this conversation as resolved.
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);
Expand Down Expand Up @@ -146,3 +162,8 @@ fn parse_input_arg(args: &[String]) -> Option<String> {
}
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")
}
193 changes: 180 additions & 13 deletions resources/windows_service/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ unsafe fn read_service_state(
logon_account,
error_control,
dependencies,
metadata: None,
})
}

Expand Down Expand Up @@ -220,18 +221,6 @@ pub fn get_service(input: &WindowsService) -> Result<WindowsService, ServiceErro

let svc = unsafe { read_service_state(service_handle.0, &service_key_name) }?;

// If both name and display_name were provided, verify they match
Comment thread
Gijsreyn marked this conversation as resolved.
if input.name.is_some() && let Some(expected_dn) = input.display_name.as_ref() {
// let expected_dn = input.display_name.as_ref().unwrap();
let actual_dn = svc.display_name.as_deref().unwrap_or("");
if !actual_dn.eq_ignore_ascii_case(expected_dn) {
return Err(
t!("get.displayNameMismatch", expected = expected_dn, actual = actual_dn)
.to_string()
.into(),
);
}
}

Ok(svc)
}
Expand Down Expand Up @@ -603,7 +592,7 @@ fn is_builtin_service_account(account: &str) -> bool {
)
}

pub fn set_service(input: &WindowsService) -> Result<WindowsService, ServiceError> {
pub fn set_service(input: &WindowsService, what_if: bool) -> Result<WindowsService, ServiceError> {
let name = input.name.as_deref()
.ok_or_else(|| t!("set.nameRequired").to_string())?;

Expand All @@ -613,6 +602,10 @@ pub fn set_service(input: &WindowsService) -> Result<WindowsService, ServiceErro
));
}

if what_if {
return what_if_set(input, name);
}

unsafe {
let scm = OpenSCManagerW(None, None, SC_MANAGER_CONNECT)
.map_err(|e| t!("set.openScmFailed", error = e.to_string()).to_string())?;
Expand Down Expand Up @@ -955,3 +948,177 @@ fn matches_filter(service: &WindowsService, filter: &WindowsService) -> 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<WindowsService, ServiceError> {
// 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<String> = 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<WindowsService, ServiceError> {
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)
}
}
15 changes: 15 additions & 0 deletions resources/windows_service/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<String>>,

/// 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>,
}

/// 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(skip_serializing_if = "Option::is_none")]
pub what_if: Option<Vec<String>>,
}

impl WindowsService {
Expand All @@ -104,6 +118,7 @@ impl WindowsService {
logon_account: None,
error_control: None,
dependencies: None,
metadata: None,
}
}
}
Expand Down
10 changes: 7 additions & 3 deletions resources/windows_service/tests/windows_service_get.tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
Loading
Loading