Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 [-w] --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
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(rename = "whatIf", 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