diff --git a/Cargo.lock b/Cargo.lock index 969561253..18d7b49ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4297,6 +4297,16 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_service" +version = "0.1.0" +dependencies = [ + "rust-i18n", + "serde", + "serde_json", + "windows 0.62.2", +] + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index f5301114c..33423b842 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ members = [ "lib/dsc-lib-security_context", "resources/sshdconfig", "resources/WindowsUpdate", + "resources/windows_service", "tools/dsctest", "tools/test_group_resource", "grammars/tree-sitter-dscexpression", @@ -26,6 +27,7 @@ members = [ "y2j", "xtask" ] + # This value is modified by the `Set-DefaultWorkspaceMember` helper. # Be sure to use `Reset-DefaultWorkspaceMember` before committing to # avoid unintentionally modifying this value. @@ -46,6 +48,7 @@ default-members = [ "lib/dsc-lib-security_context", "resources/sshdconfig", "resources/WindowsUpdate", + "resources/windows_service", "tools/dsctest", "tools/test_group_resource", "grammars/tree-sitter-dscexpression", @@ -75,6 +78,7 @@ Windows = [ "lib/dsc-lib-security_context", "resources/sshdconfig", "resources/WindowsUpdate", + "resources/windows_service", "tools/dsctest", "tools/test_group_resource", "grammars/tree-sitter-dscexpression", @@ -246,11 +250,12 @@ urlencoding = { version = "2.1" } which = { version = "8.0" } # dsc-lib ipnetwork = { version = "0.21" } -# WindowsUpdate +# WindowsUpdate, windows_service windows = { version = "0.62", features = [ "Win32_Foundation", "Win32_System_Com", "Win32_System_Ole", + "Win32_System_Services", "Win32_System_Variant", "Win32_System_UpdateAgent" ] } diff --git a/data.build.json b/data.build.json index 8f9f63e1e..4eacde32a 100644 --- a/data.build.json +++ b/data.build.json @@ -84,6 +84,8 @@ "windowspowershell.dsc.resource.json", "windowsupdate.dsc.resource.json", "wu_dsc.exe", + "windows_service.exe", + "windows_service.dsc.resource.json", "wmi.dsc.resource.json", "wmi.resource.ps1", "wmiAdapter.psd1", @@ -402,6 +404,21 @@ ] } }, + { + "Name": "windows_service", + "Kind": "Resource", + "RelativePath": "resources/windows_service", + "SupportedPlatformOS": "Windows", + "IsRust": true, + "Binaries": [ + "windows_service" + ], + "CopyFiles": { + "Windows": [ + "windows_service.dsc.resource.json" + ] + } + }, { "Name": "dsctest", "Kind": "Resource", diff --git a/resources/windows_service/.project.data.json b/resources/windows_service/.project.data.json new file mode 100644 index 000000000..3ad65d929 --- /dev/null +++ b/resources/windows_service/.project.data.json @@ -0,0 +1,14 @@ +{ + "Name": "windows_service", + "Kind": "Resource", + "IsRust": true, + "SupportedPlatformOS": "Windows", + "Binaries": [ + "windows_service" + ], + "CopyFiles": { + "Windows": [ + "windows_service.dsc.resource.json" + ] + } +} diff --git a/resources/windows_service/Cargo.toml b/resources/windows_service/Cargo.toml new file mode 100644 index 000000000..fc06300af --- /dev/null +++ b/resources/windows_service/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "windows_service" +version = "0.1.0" +edition = "2024" + +[package.metadata.i18n] +available-locales = ["en-us"] +default-locale = "en-us" +load-path = "locales" + +[dependencies] +rust-i18n = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[target.'cfg(windows)'.dependencies] +windows = { workspace = true } diff --git a/resources/windows_service/locales/en-us.toml b/resources/windows_service/locales/en-us.toml new file mode 100644 index 000000000..01484a926 --- /dev/null +++ b/resources/windows_service/locales/en-us.toml @@ -0,0 +1,37 @@ +_version = 1 + +[main] +missingOperation = "Missing operation. Usage: windows_service get --input | set --input | export [--input ]" +unknownOperation = "Unknown operation: '%{operation}'. Expected: get, set, or export" +missingInput = "Missing --input argument" +missingInputValue = "Missing value for --input argument" +invalidJson = "Invalid JSON input: %{error}" +windowsOnly = "This resource is only supported on Windows" + +[get] +nameOrDisplayNameRequired = "At least one of 'name' or 'displayName' must be provided" +openScmFailed = "Failed to open Service Control Manager: %{error}" +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}" +openServiceFailed = "Failed to open service '%{name}': %{error}" + +[set] +nameRequired = "'name' is required for the set operation" +openScmFailed = "Failed to open Service Control Manager: %{error}" +openServiceFailed = "Failed to open service: %{error}" +changeConfigFailed = "Failed to change service configuration: %{error}" +changeConfig2Failed = "Failed to change service extended configuration: %{error}" +startFailed = "Failed to start service: %{error}" +stopFailed = "Failed to stop service: %{error}" +pauseFailed = "Failed to pause service: %{error}" +continueFailed = "Failed to continue service: %{error}" +unsupportedTransition = "Unsupported status transition from '%{current}' to '%{desired}'" +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}'" diff --git a/resources/windows_service/src/main.rs b/resources/windows_service/src/main.rs new file mode 100644 index 000000000..0273ac11d --- /dev/null +++ b/resources/windows_service/src/main.rs @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +mod types; + +#[cfg(windows)] +mod service; + +use rust_i18n::t; +use std::process::exit; + +use types::WindowsService; + +/// Write a JSON error object to stderr: `{"error":""}` +fn write_error(message: &str) { + eprintln!("{}", serde_json::json!({"error": message})); +} + +rust_i18n::i18n!("locales", fallback = "en-us"); + +const EXIT_SUCCESS: i32 = 0; +const EXIT_INVALID_ARGS: i32 = 1; +const EXIT_INVALID_INPUT: i32 = 2; +const EXIT_SERVICE_ERROR: i32 = 3; + +/// Deserialize the required JSON input into a `WindowsService`, or exit with an error. +fn require_input(input_json: Option) -> WindowsService { + let json = match input_json { + Some(j) => j, + None => { + write_error(&t!("main.missingInput")); + exit(EXIT_INVALID_ARGS); + } + }; + match serde_json::from_str(&json) { + Ok(v) => v, + Err(e) => { + write_error(&t!("main.invalidJson", error = e.to_string())); + exit(EXIT_INVALID_INPUT); + } + } +} + +/// Serialize a value to JSON and print it to stdout, or exit with an error. +fn print_json(value: &impl serde::Serialize) { + match serde_json::to_string(value) { + Ok(json) => println!("{json}"), + Err(e) => { + write_error(&t!("main.invalidJson", error = e.to_string())); + exit(EXIT_SERVICE_ERROR); + } + } +} + +#[cfg(not(windows))] +fn main() { + write_error(&t!("main.windowsOnly")); + exit(EXIT_SERVICE_ERROR); +} + +#[cfg(windows)] +fn main() { + let args: Vec = std::env::args().collect(); + + if args.len() < 2 { + write_error(&t!("main.missingOperation")); + exit(EXIT_INVALID_ARGS); + } + + let operation = args[1].as_str(); + let input_json = parse_input_arg(&args); + + match operation { + "get" => { + let input = require_input(input_json); + + match service::get_service(&input) { + Ok(result) => { + print_json(&result); + exit(EXIT_SUCCESS); + } + Err(e) => { + write_error(&e.to_string()); + exit(EXIT_SERVICE_ERROR); + } + } + } + "set" => { + let input = require_input(input_json); + + match service::set_service(&input) { + Ok(result) => { + print_json(&result); + exit(EXIT_SUCCESS); + } + Err(e) => { + write_error(&e.to_string()); + exit(EXIT_SERVICE_ERROR); + } + } + } + "export" => { + let filter: Option = match input_json { + Some(json) => match serde_json::from_str(&json) { + Ok(s) => Some(s), + Err(e) => { + write_error(&t!("main.invalidJson", error = e.to_string())); + exit(EXIT_INVALID_INPUT); + } + }, + None => None, + }; + + match service::export_services(filter.as_ref()) { + Ok(services) => { + for svc in &services { + print_json(svc); + } + exit(EXIT_SUCCESS); + } + Err(e) => { + write_error(&e.to_string()); + exit(EXIT_SERVICE_ERROR); + } + } + } + _ => { + write_error(&t!("main.unknownOperation", operation = operation)); + exit(EXIT_INVALID_ARGS); + } + } +} + +/// Parse the `--input ` argument from the command-line args. +fn parse_input_arg(args: &[String]) -> Option { + let mut i = 2; // skip binary name and operation + while i < args.len() { + if args[i] == "--input" || args[i] == "-i" { + if i + 1 < args.len() { + return Some(args[i + 1].clone()); + } + write_error(&t!("main.missingInputValue")); + exit(EXIT_INVALID_ARGS); + } + i += 1; + } + None +} diff --git a/resources/windows_service/src/service.rs b/resources/windows_service/src/service.rs new file mode 100644 index 000000000..dbf425b50 --- /dev/null +++ b/resources/windows_service/src/service.rs @@ -0,0 +1,968 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use rust_i18n::t; +use std::mem; +use windows::core::{PCWSTR, PWSTR}; +use windows::Win32::Foundation::{ERROR_INSUFFICIENT_BUFFER, ERROR_MORE_DATA, ERROR_SERVICE_DOES_NOT_EXIST}; +use windows::Win32::System::Services::*; + +const SERVICE_NO_CHANGE_VALUE: u32 = 0xFFFF_FFFF; +const STATUS_WAIT_TIMEOUT_SECS: u64 = 30; +const STATUS_POLL_INTERVAL_MS: u64 = 250; + +use crate::types::*; + +/// Encode a Rust string as a null-terminated UTF-16 buffer. +fn to_wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +/// Convert a `PWSTR` to an `Option`. +/// +/// # Safety +/// +/// The pointer must be valid or null. +unsafe fn pwstr_to_string(p: PWSTR) -> Option { + if p.is_null() { + None + } else { + unsafe { p.to_string().ok() } + } +} + +/// Parse a double-null-terminated multi-string into individual strings. +/// +/// # Safety +/// +/// The pointer must point to a valid double-null-terminated buffer, or be null. +unsafe fn parse_multi_string(ptr: PWSTR) -> Vec { + if ptr.is_null() { + return Vec::new(); + } + let mut result = Vec::new(); + let mut current = ptr.0; + unsafe { + loop { + if *current == 0 { + break; + } + let pcwstr = PCWSTR(current); + match pcwstr.to_string() { + Ok(s) => { + current = current.add(s.encode_utf16().count() + 1); + result.push(s); + } + Err(_) => break, + } + } + } + result +} + +/// RAII wrapper for `SC_HANDLE` that calls `CloseServiceHandle` on drop. +struct ScHandle(SC_HANDLE); + +impl Drop for ScHandle { + fn drop(&mut self) { + unsafe { + let _ = CloseServiceHandle(self.0); + } + } +} + +impl From for ServiceError { + fn from(e: windows::core::Error) -> Self { + Self { message: e.to_string() } + } +} + +/// Read the full configuration and status of an already-opened service handle. +/// +/// # Safety +/// +/// `service_handle` must be a valid, open service handle with `SERVICE_QUERY_CONFIG` +/// and `SERVICE_QUERY_STATUS` access rights. +unsafe fn read_service_state( + service_handle: SC_HANDLE, + service_name: &str, +) -> Result { + // Query basic configuration + let mut bytes_needed: u32 = 0; + let sizing_result = unsafe { + QueryServiceConfigW(service_handle, None, 0, &mut bytes_needed) + }; + if let Err(e) = sizing_result { + if e.code() != ERROR_INSUFFICIENT_BUFFER.to_hresult() { + return Err(t!("get.queryConfigFailed", error = e.to_string()).to_string().into()); + } + } + if bytes_needed == 0 { + return Err(t!("get.queryConfigFailed", error = "buffer size is 0").to_string().into()); + } + let mut config_buffer = vec![0u8; bytes_needed as usize]; + let config_ptr = config_buffer.as_mut_ptr().cast::(); + unsafe { + QueryServiceConfigW( + service_handle, + Some(&mut *config_ptr), + bytes_needed, + &mut bytes_needed, + ) + .map_err(|e| ServiceError::from(t!("get.queryConfigFailed", error = e.to_string()).to_string()))?; + } + + let config = unsafe { &*config_ptr }; + let display_name = unsafe { pwstr_to_string(config.lpDisplayName) }; + + let start_type = match config.dwStartType { + SERVICE_AUTO_START => { + if unsafe { is_delayed_auto_start(service_handle) } { + Some(StartType::AutomaticDelayedStart) + } else { + Some(StartType::Automatic) + } + } + SERVICE_DEMAND_START => Some(StartType::Manual), + SERVICE_DISABLED => Some(StartType::Disabled), + _ => None, + }; + + let error_control = match config.dwErrorControl { + SERVICE_ERROR_IGNORE => Some(ErrorControl::Ignore), + SERVICE_ERROR_NORMAL => Some(ErrorControl::Normal), + SERVICE_ERROR_SEVERE => Some(ErrorControl::Severe), + SERVICE_ERROR_CRITICAL => Some(ErrorControl::Critical), + _ => None, + }; + + let executable_path = unsafe { pwstr_to_string(config.lpBinaryPathName) }; + let logon_account = unsafe { pwstr_to_string(config.lpServiceStartName) }; + let deps = unsafe { parse_multi_string(config.lpDependencies) }; + let dependencies = if deps.is_empty() { None } else { Some(deps) }; + + let description = unsafe { query_description(service_handle) }; + let status = unsafe { query_status(service_handle) }?; + + Ok(WindowsService { + name: Some(service_name.to_string()), + display_name, + description, + exist: Some(true), + status: Some(status), + start_type, + executable_path, + logon_account, + error_control, + dependencies, + }) +} + +/// Look up a service by `name` and/or `display_name` and return the full service info. +/// +/// - If only `name` is provided, the service is looked up by name. +/// - If only `display_name` is provided, the service key name is resolved first. +/// - If both are provided, the service is looked up by name and the display name is verified. +/// +/// If the service does not exist, returns a `WindowsService` with `_exist: false`. +pub fn get_service(input: &WindowsService) -> Result { + if input.name.is_none() && input.display_name.is_none() { + return Err(t!("get.nameOrDisplayNameRequired").to_string().into()); + } + + // Open Service Control Manager + let scm = unsafe { OpenSCManagerW(None, None, SC_MANAGER_CONNECT) } + .map_err(|e| ServiceError::from(t!("get.openScmFailed", error = e.to_string()).to_string()))?; + let scm = ScHandle(scm); + + // Resolve the service key name + let service_key_name = match &input.name { + Some(name) => Some(name.clone()), + None => { + let display_name = input.display_name.as_ref().unwrap(); + unsafe { resolve_key_name_from_display_name(scm.0, display_name) }? + } + }; + + // If we couldn't resolve a key name, the service doesn't exist + let service_key_name = match service_key_name { + Some(n) => n, + None => { + return Ok(WindowsService { + name: input.name.clone(), + display_name: input.display_name.clone(), + exist: Some(false), + ..Default::default() + }); + } + }; + + // Open the service + let name_wide = to_wide(&service_key_name); + let service_handle = match unsafe { + 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: input.name.clone(), + display_name: input.display_name.clone(), + exist: Some(false), + ..Default::default() + }); + } + Err(e) => { + return Err(ServiceError::from(t!("get.openServiceFailed", error = e.to_string()).to_string())); + } + }; + + 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() && input.display_name.is_some() { + 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) +} + +/// Resolve a service key name from its display name via SCM. +unsafe fn resolve_key_name_from_display_name( + scm: SC_HANDLE, + display_name: &str, +) -> Result, ServiceError> { + let dn_wide = to_wide(display_name); + let mut size: u32 = 0; + + // First call to determine the required buffer size + let sizing_result = unsafe { + GetServiceKeyNameW(scm, PCWSTR(dn_wide.as_ptr()), None, &mut size) + }; + + if let Err(e) = sizing_result { + if e.code() == ERROR_SERVICE_DOES_NOT_EXIST.to_hresult() { + return Ok(None); + } + if size == 0 { + return Err( + t!("get.getKeyNameFailed", error = e.to_string()).to_string().into(), + ); + } + } + + size += 1; // null terminator + let mut buffer = vec![0u16; size as usize]; + + unsafe { + GetServiceKeyNameW( + scm, + PCWSTR(dn_wide.as_ptr()), + Some(PWSTR(buffer.as_mut_ptr())), + &mut size, + ) + .map_err(|e| t!("get.getKeyNameFailed", error = e.to_string()).to_string())?; + } + + Ok(Some(String::from_utf16_lossy(&buffer[..size as usize]))) +} + +/// Check whether the service is configured for delayed automatic start. +unsafe fn is_delayed_auto_start(service_handle: SC_HANDLE) -> bool { + let mut bytes_needed: u32 = 0; + let _ = unsafe { + QueryServiceConfig2W( + service_handle, + SERVICE_CONFIG_DELAYED_AUTO_START_INFO, + None, + &mut bytes_needed, + ) + }; + + if bytes_needed == 0 { + return false; + } + + let mut buffer = vec![0u8; bytes_needed as usize]; + if unsafe { + QueryServiceConfig2W( + service_handle, + SERVICE_CONFIG_DELAYED_AUTO_START_INFO, + Some(&mut buffer), + &mut bytes_needed, + ) + } + .is_ok() + { + let info = + unsafe { &*(buffer.as_ptr().cast::()) }; + info.fDelayedAutostart.as_bool() + } else { + false + } +} + +/// Query the service description string. +unsafe fn query_description(service_handle: SC_HANDLE) -> Option { + let mut bytes_needed: u32 = 0; + let _ = unsafe { + QueryServiceConfig2W( + service_handle, + SERVICE_CONFIG_DESCRIPTION, + None, + &mut bytes_needed, + ) + }; + + if bytes_needed == 0 { + return None; + } + + let mut buffer = vec![0u8; bytes_needed as usize]; + if unsafe { + QueryServiceConfig2W( + service_handle, + SERVICE_CONFIG_DESCRIPTION, + Some(&mut buffer), + &mut bytes_needed, + ) + } + .is_ok() + { + let desc = unsafe { &*(buffer.as_ptr().cast::()) }; + if desc.lpDescription.is_null() { + None + } else { + unsafe { desc.lpDescription.to_string().ok() } + } + } else { + None + } +} + +/// Query the current runtime status of a service. +unsafe fn query_status(service_handle: SC_HANDLE) -> Result { + let mut buffer = vec![0u8; mem::size_of::()]; + let mut bytes_needed: u32 = 0; + + unsafe { + QueryServiceStatusEx( + service_handle, + SC_STATUS_PROCESS_INFO, + Some(&mut buffer), + &mut bytes_needed, + ) + .map_err(|e| t!("get.queryStatusFailed", error = e.to_string()).to_string())?; + } + + let status = unsafe { &*(buffer.as_ptr().cast::()) }; + + match status.dwCurrentState { + SERVICE_STOPPED => Ok(ServiceStatus::Stopped), + SERVICE_START_PENDING => Ok(ServiceStatus::StartPending), + SERVICE_STOP_PENDING => Ok(ServiceStatus::StopPending), + SERVICE_RUNNING => Ok(ServiceStatus::Running), + SERVICE_CONTINUE_PENDING => Ok(ServiceStatus::ContinuePending), + SERVICE_PAUSE_PENDING => Ok(ServiceStatus::PausePending), + SERVICE_PAUSED => Ok(ServiceStatus::Paused), + other => Err( + t!("get.queryStatusFailed", error = format!("unknown state: {}", other.0)) + .to_string() + .into(), + ), + } +} + +/// Convert a `ServiceStatus` to the corresponding Windows `SERVICE_STATUS_CURRENT_STATE` constant. +fn status_to_current_state(status: &ServiceStatus) -> SERVICE_STATUS_CURRENT_STATE { + match status { + ServiceStatus::Running => SERVICE_RUNNING, + ServiceStatus::Stopped => SERVICE_STOPPED, + ServiceStatus::Paused => SERVICE_PAUSED, + ServiceStatus::StartPending => SERVICE_START_PENDING, + ServiceStatus::StopPending => SERVICE_STOP_PENDING, + ServiceStatus::PausePending => SERVICE_PAUSE_PENDING, + ServiceStatus::ContinuePending => SERVICE_CONTINUE_PENDING, + } +} + +/// Export (enumerate) all services, optionally filtering by the provided criteria. +/// Returns a list of matching services. +pub fn export_services(filter: Option<&WindowsService>) -> Result, ServiceError> { + let scm = unsafe { OpenSCManagerW(None, None, SC_MANAGER_CONNECT | SC_MANAGER_ENUMERATE_SERVICE) } + .map_err(|e| ServiceError::from(t!("get.openScmFailed", error = e.to_string()).to_string()))?; + let scm = ScHandle(scm); + + let services = unsafe { enumerate_services(scm.0) }?; + let mut results = Vec::new(); + + // Pre-compute the status filter value for early rejection before expensive per-service queries + let status_filter_dw = filter.and_then(|f| f.status.as_ref()).map(status_to_current_state); + + for (service_name, current_state) in &services { + // Quick reject based on status before opening the service handle + if let Some(expected_state) = status_filter_dw { + if *current_state != expected_state { + continue; + } + } + + let svc = match unsafe { get_service_details(scm.0, service_name) } { + Ok(s) => s, + Err(_) => continue, // skip services we can't query + }; + + if let Some(f) = filter { + if !matches_filter(&svc, f) { + continue; + } + } + + results.push(svc); + } + + Ok(results) +} + +/// Enumerate all Win32 services using `EnumServicesStatusExW`, handling pagination. +/// Returns a list of `(service_name, current_state)` tuples. +unsafe fn enumerate_services(scm: SC_HANDLE) -> Result, ServiceError> { + unsafe { + let mut all_services = Vec::new(); + let mut bytes_needed: u32 = 0; + let mut services_returned: u32 = 0; + let mut resume_handle: u32 = 0; + + // Sizing call to determine required buffer size + let sizing_result = EnumServicesStatusExW( + scm, + SC_ENUM_PROCESS_INFO, + SERVICE_WIN32, + SERVICE_STATE_ALL, + None, + &mut bytes_needed, + &mut services_returned, + Some(&mut resume_handle), + None, + ); + + if let Err(e) = sizing_result { + if e.code() != ERROR_MORE_DATA.to_hresult() + && e.code() != ERROR_INSUFFICIENT_BUFFER.to_hresult() + { + return Err(t!("export.enumServicesFailed", error = e.to_string()).to_string().into()); + } + } + + if bytes_needed == 0 { + return Ok(Vec::new()); + } + + resume_handle = 0; + + loop { + let mut buffer = vec![0u8; bytes_needed as usize]; + services_returned = 0; + + let result = EnumServicesStatusExW( + scm, + SC_ENUM_PROCESS_INFO, + SERVICE_WIN32, + SERVICE_STATE_ALL, + Some(&mut buffer), + &mut bytes_needed, + &mut services_returned, + Some(&mut resume_handle), + None, + ); + + if services_returned > 0 { + let entries = std::slice::from_raw_parts( + buffer.as_ptr().cast::(), + services_returned as usize, + ); + + for entry in entries { + if let Ok(name) = entry.lpServiceName.to_string() { + all_services.push((name, entry.ServiceStatusProcess.dwCurrentState)); + } + } + } + + match result { + Ok(()) => break, + Err(e) => { + if e.code() == ERROR_MORE_DATA.to_hresult() { + // More services to enumerate; bytes_needed has the required buffer size + continue; + } + return Err(t!("export.enumServicesFailed", error = e.to_string()).to_string().into()); + } + } + } + + Ok(all_services) + } +} + +/// Get full service details given an SCM handle and a service key name. +unsafe fn get_service_details(scm: SC_HANDLE, service_name: &str) -> Result { + let name_wide = to_wide(service_name); + let service_handle = unsafe { + OpenServiceW( + scm, + PCWSTR(name_wide.as_ptr()), + SERVICE_QUERY_CONFIG | SERVICE_QUERY_STATUS, + ) + } + .map_err(|e| ServiceError::from(t!("export.openServiceFailed", name = service_name, error = e.to_string()).to_string()))?; + let service_handle = ScHandle(service_handle); + + unsafe { read_service_state(service_handle.0, service_name) } +} + +/// Match a string against a pattern supporting `*` wildcards. +/// If no wildcard is present, performs an exact case-insensitive comparison. +fn matches_wildcard(text: &str, pattern: &str) -> bool { + if pattern == "*" { + return true; + } + + let text_lower = text.to_lowercase(); + let pattern_lower = pattern.to_lowercase(); + + let parts: Vec<&str> = pattern_lower.split('*').collect(); + + // No wildcard → exact match + if parts.len() == 1 { + return text_lower == pattern_lower; + } + + let starts_with_wildcard = pattern_lower.starts_with('*'); + let ends_with_wildcard = pattern_lower.ends_with('*'); + + let mut pos = 0; + + for (i, part) in parts.iter().enumerate() { + if part.is_empty() { + continue; + } + + if i == 0 && !starts_with_wildcard { + if !text_lower.starts_with(part) { + return false; + } + pos = part.len(); + } else if let Some(found) = text_lower[pos..].find(part) { + pos += found + part.len(); + } else { + return false; + } + } + + if !ends_with_wildcard { + if let Some(last) = parts.last() { + if !last.is_empty() && !text_lower.ends_with(last) { + return false; + } + } + } + + true +} + +/// Build a double-null-terminated UTF-16 multi-string from a list of dependency names. +fn deps_to_multi_string(deps: &[String]) -> Vec { + let mut buf = Vec::new(); + for dep in deps { + buf.extend(dep.encode_utf16()); + buf.push(0); // null terminator for each string + } + if buf.is_empty() { + buf.push(0); // first null for empty multi-string + } + buf.push(0); // final null terminator (double-null) + buf +} + +/// Apply the desired service configuration and status changes, then return the final state. +/// Check whether the given account name is a built-in service account. +fn is_builtin_service_account(account: &str) -> bool { + let normalized = account.to_ascii_uppercase(); + matches!( + normalized.as_str(), + "LOCALSYSTEM" + | "LOCAL SYSTEM" + | "NT AUTHORITY\\LOCALSERVICE" + | "NT AUTHORITY\\NETWORKSERVICE" + | "NT AUTHORITY\\LOCAL SERVICE" + | "NT AUTHORITY\\NETWORK SERVICE" + | ".\\LOCALSYSTEM" + ) +} + +pub fn set_service(input: &WindowsService) -> Result { + let name = input.name.as_deref() + .ok_or_else(|| t!("set.nameRequired").to_string())?; + + if let Some(ref account) = input.logon_account { + if !is_builtin_service_account(account) { + return Err(ServiceError::from( + t!("set.unsupportedLogonAccount", account = account).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 needs_config_change = input.start_type.is_some() + || input.executable_path.is_some() + || input.logon_account.is_some() + || input.error_control.is_some() + || input.display_name.is_some() + || input.dependencies.is_some() + || input.description.is_some(); + + let mut access = SERVICE_QUERY_CONFIG | SERVICE_QUERY_STATUS; + if needs_config_change { + access |= SERVICE_CHANGE_CONFIG; + } + if let Some(ref status) = input.status { + match status { + ServiceStatus::Running => { + access |= SERVICE_START | SERVICE_PAUSE_CONTINUE; + } + ServiceStatus::Stopped => { + access |= SERVICE_STOP; + } + ServiceStatus::Paused => { + access |= SERVICE_PAUSE_CONTINUE; + } + _ => {} + } + } + + let service_handle = OpenServiceW(scm.0, PCWSTR(name_wide.as_ptr()), access) + .map_err(|e| t!("set.openServiceFailed", error = e.to_string()).to_string())?; + let service_handle = ScHandle(service_handle); + + // 1. Apply configuration changes via ChangeServiceConfigW + let has_config = input.start_type.is_some() + || input.executable_path.is_some() + || input.logon_account.is_some() + || input.error_control.is_some() + || input.display_name.is_some() + || input.dependencies.is_some(); + + if has_config { + let dw_start_type = match &input.start_type { + Some(StartType::Automatic | StartType::AutomaticDelayedStart) => SERVICE_AUTO_START, + Some(StartType::Manual) => SERVICE_DEMAND_START, + Some(StartType::Disabled) => SERVICE_DISABLED, + None => SERVICE_START_TYPE(SERVICE_NO_CHANGE_VALUE), + }; + + let dw_error_control = match &input.error_control { + Some(ErrorControl::Ignore) => SERVICE_ERROR_IGNORE, + Some(ErrorControl::Normal) => SERVICE_ERROR_NORMAL, + Some(ErrorControl::Severe) => SERVICE_ERROR_SEVERE, + Some(ErrorControl::Critical) => SERVICE_ERROR_CRITICAL, + None => SERVICE_ERROR(SERVICE_NO_CHANGE_VALUE), + }; + + // When changing the logon account to a non-LocalSystem account, the + // deprecated SERVICE_INTERACTIVE_PROCESS flag (0x100) must be stripped + // from the service type, because Windows only allows interactive + // services under LocalSystem. Query the current type and clear the + // flag when necessary; otherwise pass SERVICE_NO_CHANGE. + const SERVICE_INTERACTIVE_PROCESS_FLAG: u32 = 0x100; + let dw_service_type = if let Some(ref account) = input.logon_account { + let upper = account.to_ascii_uppercase(); + let is_local_system = matches!( + upper.as_str(), + "LOCALSYSTEM" | "LOCAL SYSTEM" | ".\\LOCALSYSTEM" + ); + if !is_local_system { + let mut qc_bytes: u32 = 0; + let _ = QueryServiceConfigW(service_handle.0, None, 0, &mut qc_bytes); + if qc_bytes > 0 { + let mut qc_buf = vec![0u8; qc_bytes as usize]; + let qc_ptr = qc_buf.as_mut_ptr().cast::(); + if QueryServiceConfigW( + service_handle.0, + Some(&mut *qc_ptr), + qc_bytes, + &mut qc_bytes, + ).is_ok() { + let cur_type = (*qc_ptr).dwServiceType.0; + ENUM_SERVICE_TYPE(cur_type & !SERVICE_INTERACTIVE_PROCESS_FLAG) + } else { + ENUM_SERVICE_TYPE(SERVICE_NO_CHANGE_VALUE) + } + } else { + ENUM_SERVICE_TYPE(SERVICE_NO_CHANGE_VALUE) + } + } else { + ENUM_SERVICE_TYPE(SERVICE_NO_CHANGE_VALUE) + } + } else { + ENUM_SERVICE_TYPE(SERVICE_NO_CHANGE_VALUE) + }; + + // When setting AutomaticDelayedStart, the service must not belong to a load + // order group — Windows rejects ChangeServiceConfig2W for the delayed auto-start + // flag with ERROR_INVALID_PARAMETER if one is set. Clear the group by passing an + // empty string instead of null (which means "no change"). + let clear_group_wide; + let load_order_group_ptr = if matches!(&input.start_type, Some(StartType::AutomaticDelayedStart)) { + clear_group_wide = to_wide(""); + PCWSTR(clear_group_wide.as_ptr()) + } else { + PCWSTR::null() + }; + + // Build wide strings; they must live through the API call. + let exe_wide = input.executable_path.as_ref().map(|s| to_wide(s)); + let logon_wide = input.logon_account.as_ref().map(|s| to_wide(s)); + let display_wide = input.display_name.as_ref().map(|s| to_wide(s)); + let deps_wide = input.dependencies.as_ref().map(|d| deps_to_multi_string(d)); + + let exe_ptr = exe_wide.as_ref().map_or(PCWSTR::null(), |w| PCWSTR(w.as_ptr())); + let logon_ptr = logon_wide.as_ref().map_or(PCWSTR::null(), |w| PCWSTR(w.as_ptr())); + let display_ptr = display_wide.as_ref().map_or(PCWSTR::null(), |w| PCWSTR(w.as_ptr())); + let deps_ptr = deps_wide.as_ref().map_or(PCWSTR::null(), |w| PCWSTR(w.as_ptr())); + + // When changing the logon account to a built-in service account, + // Windows requires an empty string password rather than null. + let password_wide = input.logon_account.as_ref().map(|_| to_wide("")); + let password_ptr = password_wide.as_ref().map_or(PCWSTR::null(), |w| PCWSTR(w.as_ptr())); + + ChangeServiceConfigW( + service_handle.0, + dw_service_type, + dw_start_type, + dw_error_control, + exe_ptr, + load_order_group_ptr, + None, // tag id unchanged + deps_ptr, + logon_ptr, + password_ptr, + display_ptr, + ) + .map_err(|e| t!("set.changeConfigFailed", error = e.to_string()).to_string())?; + } + + // 2. Set delayed auto-start flag when start type is specified + if let Some(ref start_type) = input.start_type { + let delayed = matches!(start_type, StartType::AutomaticDelayedStart); + let mut info = SERVICE_DELAYED_AUTO_START_INFO { + fDelayedAutostart: delayed.into(), + }; + ChangeServiceConfig2W( + service_handle.0, + SERVICE_CONFIG_DELAYED_AUTO_START_INFO, + Some(std::ptr::from_mut(&mut info).cast()), + ) + .map_err(|e| t!("set.changeConfig2Failed", error = e.to_string()).to_string())?; + } + + // 3. Set description + if let Some(ref desc) = input.description { + let mut desc_wide = to_wide(desc); + let mut info = SERVICE_DESCRIPTIONW { + lpDescription: PWSTR(desc_wide.as_mut_ptr()), + }; + ChangeServiceConfig2W( + service_handle.0, + SERVICE_CONFIG_DESCRIPTION, + Some(std::ptr::from_mut(&mut info).cast()), + ) + .map_err(|e| t!("set.changeConfig2Failed", error = e.to_string()).to_string())?; + } + + // 4. Handle service status transitions + if let Some(ref desired_status) = input.status { + let mut current = query_status(service_handle.0)?; + + // Wait for any pending state to resolve first + current = match current { + ServiceStatus::StopPending => { + wait_for_status(service_handle.0, &ServiceStatus::Stopped)?; + ServiceStatus::Stopped + } + ServiceStatus::StartPending | ServiceStatus::ContinuePending => { + wait_for_status(service_handle.0, &ServiceStatus::Running)?; + ServiceStatus::Running + } + ServiceStatus::PausePending => { + wait_for_status(service_handle.0, &ServiceStatus::Paused)?; + ServiceStatus::Paused + } + other => other, + }; + + if current != *desired_status { + match desired_status { + ServiceStatus::Running => { + match current { + ServiceStatus::Stopped => { + StartServiceW(service_handle.0, None) + .map_err(|e| t!("set.startFailed", error = e.to_string()).to_string())?; + } + ServiceStatus::Paused => { + let mut svc_status = SERVICE_STATUS::default(); + ControlService(service_handle.0, SERVICE_CONTROL_CONTINUE, &mut svc_status) + .map_err(|e| t!("set.continueFailed", error = e.to_string()).to_string())?; + } + _ => { + return Err(t!("set.unsupportedTransition", + current = current.to_string(), + desired = desired_status.to_string() + ).to_string().into()); + } + } + wait_for_status(service_handle.0, desired_status)?; + } + ServiceStatus::Stopped => { + let mut svc_status = SERVICE_STATUS::default(); + ControlService(service_handle.0, SERVICE_CONTROL_STOP, &mut svc_status) + .map_err(|e| t!("set.stopFailed", error = e.to_string()).to_string())?; + wait_for_status(service_handle.0, desired_status)?; + } + ServiceStatus::Paused => { + if current != ServiceStatus::Running { + return Err(t!("set.unsupportedTransition", + current = current.to_string(), + desired = desired_status.to_string() + ).to_string().into()); + } + let mut svc_status = SERVICE_STATUS::default(); + ControlService(service_handle.0, SERVICE_CONTROL_PAUSE, &mut svc_status) + .map_err(|e| t!("set.pauseFailed", error = e.to_string()).to_string())?; + wait_for_status(service_handle.0, desired_status)?; + } + _ => { + return Err(t!("set.unsupportedStatus", + status = desired_status.to_string() + ).to_string().into()); + } + } + } + } + + // Return final state + read_service_state(service_handle.0, name) + } +} + +/// Wait for a service to reach the desired status, with a timeout. +unsafe fn wait_for_status( + service_handle: SC_HANDLE, + desired: &ServiceStatus, +) -> Result<(), ServiceError> { + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(STATUS_WAIT_TIMEOUT_SECS); + loop { + let current = unsafe { query_status(service_handle)? }; + if current == *desired { + return Ok(()); + } + if start.elapsed() > timeout { + return Err(t!("set.statusTimeout", + expected = desired.to_string(), + actual = current.to_string() + ).to_string().into()); + } + std::thread::sleep(std::time::Duration::from_millis(STATUS_POLL_INTERVAL_MS)); + } +} + +/// Check whether `service` matches all non-`None` fields in `filter`. +fn matches_filter(service: &WindowsService, filter: &WindowsService) -> bool { + // name — wildcard match + if let Some(ref pattern) = filter.name { + let name = service.name.as_deref().unwrap_or(""); + if !matches_wildcard(name, pattern) { + return false; + } + } + + // display_name — wildcard match + if let Some(ref pattern) = filter.display_name { + let dn = service.display_name.as_deref().unwrap_or(""); + if !matches_wildcard(dn, pattern) { + return false; + } + } + + // description — wildcard match + if let Some(ref pattern) = filter.description { + let desc = service.description.as_deref().unwrap_or(""); + if !matches_wildcard(desc, pattern) { + return false; + } + } + + // exist — exact match + if let Some(expected_exist) = filter.exist { + let actual_exist = service.exist.unwrap_or(false); + if actual_exist != expected_exist { + return false; + } + } + + // status — exact match + if let Some(ref expected_status) = filter.status { + match &service.status { + Some(actual_status) if actual_status == expected_status => {} + _ => return false, + } + } + + // start_type — exact match + if let Some(ref expected_start) = filter.start_type { + match &service.start_type { + Some(actual_start) if actual_start == expected_start => {} + _ => return false, + } + } + + // logon_account — exact case-insensitive match + if let Some(ref expected_account) = filter.logon_account { + let actual = service.logon_account.as_deref().unwrap_or(""); + if !actual.eq_ignore_ascii_case(expected_account) { + return false; + } + } + + // Note: executable_path and error_control are intentionally not filtered. + + // dependencies — service must have at least all specified dependencies + if let Some(ref expected_deps) = filter.dependencies { + let actual_deps = service.dependencies.as_deref().unwrap_or(&[]); + for dep in expected_deps { + let dep_lower = dep.to_lowercase(); + if !actual_deps.iter().any(|d| d.to_lowercase() == dep_lower) { + return false; + } + } + } + + true +} diff --git a/resources/windows_service/src/types.rs b/resources/windows_service/src/types.rs new file mode 100644 index 000000000..69241c58a --- /dev/null +++ b/resources/windows_service/src/types.rs @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use serde::{Deserialize, Serialize}; + +/// Represents the start type of a Windows service. +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub enum StartType { + /// The service is started automatically by the Service Control Manager during system startup. + Automatic, + /// The service is started automatically, with a delayed start after other auto-start services. + AutomaticDelayedStart, + /// The service is started only manually (e.g., via `sc start` or the Services console). + Manual, + /// The service is disabled and cannot be started. + Disabled, +} + +/// Represents the current status of a Windows service. +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub enum ServiceStatus { + Running, + Stopped, + Paused, + StartPending, + StopPending, + PausePending, + ContinuePending, +} + +/// Represents the error control level for a Windows service. +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub enum ErrorControl { + /// The startup program ignores the error and continues. + Ignore, + /// The startup program logs the error and continues. + Normal, + /// The startup program logs the error. If the last-known-good configuration is being started, + /// startup continues. Otherwise, the system is restarted with the last-known-good configuration. + Severe, + /// The startup program logs the error. If the last-known-good configuration is being started, + /// the startup operation fails. Otherwise, the system is restarted with the last-known-good + /// configuration. + Critical, +} + +/// Represents a Windows service resource. +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct WindowsService { + /// The name of the service (used to identify the service in the Service Control Manager). + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// The display name of the service shown in the Services console. + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + + /// A description of the service. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Indicates if the service exists. + #[serde(rename = "_exist", skip_serializing_if = "Option::is_none")] + pub exist: Option, + + /// The current status of the service. + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + + /// The start type of the service. + #[serde(skip_serializing_if = "Option::is_none")] + pub start_type: Option, + + /// The fully qualified path to the service binary. + #[serde(skip_serializing_if = "Option::is_none")] + pub executable_path: Option, + + /// The account under which the service runs (e.g., `LocalSystem`, `NT AUTHORITY\NetworkService`). + #[serde(skip_serializing_if = "Option::is_none")] + pub logon_account: Option, + + /// The error control level for the service. + #[serde(skip_serializing_if = "Option::is_none")] + pub error_control: Option, + + /// A list of service names that this service depends on. + #[serde(skip_serializing_if = "Option::is_none")] + pub dependencies: Option>, +} + +impl WindowsService { + #[must_use] + #[allow(dead_code)] + pub fn new(name: &str) -> Self { + Self { + name: Some(name.to_string()), + display_name: None, + description: None, + exist: None, + status: None, + start_type: None, + executable_path: None, + logon_account: None, + error_control: None, + dependencies: None, + } + } +} + +impl std::fmt::Display for StartType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StartType::Automatic => write!(f, "Automatic"), + StartType::AutomaticDelayedStart => write!(f, "AutomaticDelayedStart"), + StartType::Manual => write!(f, "Manual"), + StartType::Disabled => write!(f, "Disabled"), + } + } +} + +impl std::fmt::Display for ServiceStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ServiceStatus::Running => write!(f, "Running"), + ServiceStatus::Stopped => write!(f, "Stopped"), + ServiceStatus::Paused => write!(f, "Paused"), + ServiceStatus::StartPending => write!(f, "StartPending"), + ServiceStatus::StopPending => write!(f, "StopPending"), + ServiceStatus::PausePending => write!(f, "PausePending"), + ServiceStatus::ContinuePending => write!(f, "ContinuePending"), + } + } +} + +impl std::fmt::Display for ErrorControl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ErrorControl::Ignore => write!(f, "Ignore"), + ErrorControl::Normal => write!(f, "Normal"), + ErrorControl::Severe => write!(f, "Severe"), + ErrorControl::Critical => write!(f, "Critical"), + } + } +} + +/// Represents an error from a Windows service operation. +#[derive(Debug)] +pub struct ServiceError { + pub message: String, +} + +impl std::fmt::Display for ServiceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for ServiceError {} + +impl From for ServiceError { + fn from(message: String) -> Self { + Self { message } + } +} diff --git a/resources/windows_service/tests/windows_service_export.tests.ps1 b/resources/windows_service/tests/windows_service_export.tests.ps1 new file mode 100644 index 000000000..c9d4c3922 --- /dev/null +++ b/resources/windows_service/tests/windows_service_export.tests.ps1 @@ -0,0 +1,249 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Windows Service export tests' -Skip:(!$IsWindows) { + BeforeAll { + $resourceType = 'Microsoft.Windows/Service' + + function Invoke-DscExport { + param( + [string]$InputJson + ) + if ($InputJson) { + $raw = $InputJson | dsc resource export -r $resourceType -f - 2>$testdrive/error.log + } else { + $raw = dsc resource export -r $resourceType 2>$testdrive/error.log + } + $parsed = $raw | ConvertFrom-Json + return $parsed + } + } + + Context 'Export without filter' { + It 'Returns multiple services' { + $result = Invoke-DscExport + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result.resources.Count | Should -BeGreaterThan 10 + } + + It 'Each exported service has required properties' -Skip:(!$IsWindows) { + $result = Invoke-DscExport + foreach ($resource in $result.resources | Select-Object -First 5) { + $svc = $resource.properties + $svc.name | Should -Not -BeNullOrEmpty + $svc.displayName | Should -Not -BeNullOrEmpty + $svc._exist | Should -BeTrue + $svc.status | Should -Not -BeNullOrEmpty + $svc.startType | Should -Not -BeNullOrEmpty + $svc.executablePath | Should -Not -BeNullOrEmpty + $svc.logonAccount | Should -Not -BeNullOrEmpty + $svc.errorControl | Should -Not -BeNullOrEmpty + } + } + + It 'Sets the correct resource type on each entry' { + $result = Invoke-DscExport + foreach ($resource in $result.resources | Select-Object -First 5) { + $resource.type | Should -BeExactly $resourceType + } + } + } + + Context 'Export with name filter' { + It 'Filters by exact service name' { + $json = @{ name = 'wuauserv' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result.resources.Count | Should -Be 1 + $result.resources[0].properties.name | Should -BeExactly 'wuauserv' + } + + It 'Filters by name with leading wildcard' { + $json = @{ name = '*serv' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result.resources.Count | Should -BeGreaterThan 0 + foreach ($resource in $result.resources) { + $resource.properties.name | Should -BeLike '*serv' + } + } + + It 'Filters by name with trailing wildcard' { + $json = @{ name = 'w*' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result.resources.Count | Should -BeGreaterThan 0 + foreach ($resource in $result.resources) { + $resource.properties.name | Should -BeLike 'w*' + } + } + + It 'Filters by name with surrounding wildcards' { + $json = @{ name = '*update*' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result.resources.Count | Should -BeGreaterThan 0 + foreach ($resource in $result.resources) { + $resource.properties.name | Should -BeLike '*update*' + } + } + + It 'Returns empty when name filter matches nothing' { + $json = @{ name = 'nonexistent_service_xyz_12345' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result.resources.Count | Should -Be 0 + } + } + + Context 'Export with displayName filter' { + It 'Filters by display name with wildcard' { + $json = @{ displayName = '*Update*' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result.resources.Count | Should -BeGreaterThan 0 + foreach ($resource in $result.resources) { + $resource.properties.displayName | Should -BeLike '*Update*' + } + } + + It 'Filters by exact display name' { + $service = Get-Service -Name 'wuauserv' -ErrorAction Stop + $knownDisplayName = $service.DisplayName + $json = @{ displayName = $knownDisplayName } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result.resources.Count | Should -Be 1 + $result.resources[0].properties.displayName | Should -BeExactly $knownDisplayName + $result.resources[0].properties.name | Should -BeExactly 'wuauserv' + } + } + + Context 'Export with status filter' { + It 'Filters by Running status' { + $json = @{ status = 'Running' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result.resources.Count | Should -BeGreaterThan 0 + foreach ($resource in $result.resources) { + $resource.properties.status | Should -BeExactly 'Running' + } + } + + It 'Filters by Stopped status' { + $json = @{ status = 'Stopped' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result.resources.Count | Should -BeGreaterThan 0 + foreach ($resource in $result.resources) { + $resource.properties.status | Should -BeExactly 'Stopped' + } + } + } + + Context 'Export with startType filter' { + It 'Filters by Automatic start type' { + $json = @{ startType = 'Automatic' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result.resources.Count | Should -BeGreaterThan 0 + foreach ($resource in $result.resources) { + $resource.properties.startType | Should -BeExactly 'Automatic' + } + } + + It 'Filters by Manual start type' { + $json = @{ startType = 'Manual' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result.resources.Count | Should -BeGreaterThan 0 + foreach ($resource in $result.resources) { + $resource.properties.startType | Should -BeExactly 'Manual' + } + } + + It 'Filters by Disabled start type' { + $json = @{ startType = 'Disabled' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result.resources.Count | Should -BeGreaterOrEqual 0 + foreach ($resource in $result.resources) { + $resource.properties.startType | Should -BeExactly 'Disabled' + } + } + } + + Context 'Export with multi-field filter' { + It 'Filters by status AND startType together' { + $json = @{ status = 'Running'; startType = 'Automatic' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result.resources.Count | Should -BeGreaterThan 0 + foreach ($resource in $result.resources) { + $resource.properties.status | Should -BeExactly 'Running' + $resource.properties.startType | Should -BeExactly 'Automatic' + } + } + + It 'Filters by name wildcard AND status' { + $json = @{ name = 'w*'; status = 'Stopped' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + foreach ($resource in $result.resources) { + $resource.properties.name | Should -BeLike 'w*' + $resource.properties.status | Should -BeExactly 'Stopped' + } + } + } + + Context 'Export with dependencies filter' { + It 'Filters by a single dependency' { + $json = @{ dependencies = @('rpcss') } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result.resources.Count | Should -BeGreaterThan 0 + foreach ($resource in $result.resources) { + $resource.properties.dependencies | Should -Not -BeNullOrEmpty + ($resource.properties.dependencies | ForEach-Object { $_.ToLower() }) | Should -Contain 'rpcss' + } + } + } + + Context 'Export property validation' { + It 'All exported services have valid startType values' { + $json = @{ name = 'w*' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $validStartTypes = @('Automatic', 'AutomaticDelayedStart', 'Manual', 'Disabled') + foreach ($resource in $result.resources) { + $resource.properties.startType | Should -BeIn $validStartTypes + } + } + + It 'All exported services have valid status values' { + $json = @{ name = 'w*' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $validStatuses = @('Running', 'Stopped', 'Paused', 'StartPending', 'StopPending', 'PausePending', 'ContinuePending') + foreach ($resource in $result.resources) { + $resource.properties.status | Should -BeIn $validStatuses + } + } + + It 'All exported services have valid errorControl values' { + $json = @{ name = 'w*' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $validErrorControls = @('Ignore', 'Normal', 'Severe', 'Critical') + foreach ($resource in $result.resources) { + $resource.properties.errorControl | Should -BeIn $validErrorControls + } + } + + It 'Dependencies is an array when present' { + $json = @{ dependencies = @('rpcss') } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + foreach ($resource in $result.resources | Select-Object -First 3) { + $resource.properties.dependencies | Should -BeOfType [System.Object] + $resource.properties.dependencies.Count | Should -BeGreaterThan 0 + } + } + } +} diff --git a/resources/windows_service/tests/windows_service_get.tests.ps1 b/resources/windows_service/tests/windows_service_get.tests.ps1 new file mode 100644 index 000000000..69fe7a1c2 --- /dev/null +++ b/resources/windows_service/tests/windows_service_get.tests.ps1 @@ -0,0 +1,119 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Windows Service get tests' -Skip:(!$IsWindows) { + BeforeAll { + # Use a well-known Windows service that exists on all Windows machines + $resourceType = 'Microsoft.Windows/Service' + $knownServiceName = 'wuauserv' + $service = Get-Service -Name $knownServiceName -ErrorAction Stop + $knownDisplayName = $service.DisplayName + } + + Context 'Get by name' { + It 'Returns service info for an existing service' { + $json = @{ name = $knownServiceName } | ConvertTo-Json -Compress + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $output = $out | ConvertFrom-Json + $result = $output.actualState + $result.name | Should -BeExactly $knownServiceName + $result.displayName | Should -BeExactly $knownDisplayName + $result._exist | Should -BeTrue + $result.status | Should -Not -BeNullOrEmpty + $result.startType | Should -Not -BeNullOrEmpty + $result.executablePath | Should -Not -BeNullOrEmpty + $result.logonAccount | Should -Not -BeNullOrEmpty + $result.errorControl | Should -Not -BeNullOrEmpty + } + + It 'Returns _exist false for a non-existent service' { + $json = @{ name = 'nonexistent_service_xyz' } | ConvertTo-Json -Compress + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $output = $out | ConvertFrom-Json + $result = $output.actualState + $result.name | Should -BeExactly 'nonexistent_service_xyz' + $result._exist | Should -BeFalse + $result.PSObject.Properties.Name | Should -Not -Contain 'status' + } + } + + Context 'Get by displayName' { + It 'Returns service info when only displayName is provided' { + $json = @{ displayName = $knownDisplayName } | ConvertTo-Json -Compress + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $output = $out | ConvertFrom-Json + $result = $output.actualState + $result.name | Should -BeExactly $knownServiceName + $result.displayName | Should -BeExactly $knownDisplayName + $result._exist | Should -BeTrue + } + + It 'Returns _exist false for a non-existent display name' { + $json = @{ displayName = 'Nonexistent Display Name XYZ' } | ConvertTo-Json -Compress + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $output = $out | ConvertFrom-Json + $result = $output.actualState + $result._exist | Should -BeFalse + } + } + + Context 'Get by both name and displayName' { + It 'Returns service info when both name and displayName match' { + $json = @{ name = $knownServiceName; displayName = $knownDisplayName } | ConvertTo-Json -Compress + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $output = $out | ConvertFrom-Json + $result = $output.actualState + $result.name | Should -BeExactly $knownServiceName + $result.displayName | Should -BeExactly $knownDisplayName + $result._exist | Should -BeTrue + } + + It 'Returns error when name and displayName do not match' { + $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 + } + } + + Context 'Service properties' { + It 'Returns valid startType values' { + $json = @{ name = $knownServiceName } | ConvertTo-Json -Compress + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $output = $out | ConvertFrom-Json + $result = $output.actualState + $result.startType | Should -BeIn @('Automatic', 'AutomaticDelayedStart', 'Manual', 'Disabled') + } + + It 'Returns valid status values' { + $json = @{ name = $knownServiceName } | ConvertTo-Json -Compress + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $output = $out | ConvertFrom-Json + $result = $output.actualState + $result.status | Should -BeIn @('Running', 'Stopped', 'Paused', 'StartPending', 'StopPending', 'PausePending', 'ContinuePending') + } + + It 'Returns valid errorControl values' { + $json = @{ name = $knownServiceName } | ConvertTo-Json -Compress + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $output = $out | ConvertFrom-Json + $result = $output.actualState + $result.errorControl | Should -BeIn @('Ignore', 'Normal', 'Severe', 'Critical') + } + + It 'Returns dependencies as an array when present' { + $json = @{ name = $knownServiceName } | ConvertTo-Json -Compress + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $output = $out | ConvertFrom-Json + $result = $output.actualState + if ($null -ne $result.dependencies) { + $result.dependencies | Should -BeOfType [System.Object] + $result.dependencies.Count | Should -BeGreaterThan 0 + } + } + } +} diff --git a/resources/windows_service/tests/windows_service_set.tests.ps1 b/resources/windows_service/tests/windows_service_set.tests.ps1 new file mode 100644 index 000000000..5ed285119 --- /dev/null +++ b/resources/windows_service/tests/windows_service_set.tests.ps1 @@ -0,0 +1,271 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Windows Service set tests' -Skip:(!$IsWindows) { + BeforeDiscovery { + $isAdmin = if ($IsWindows) { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]$identity + $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } + else { + $false + } + } + + BeforeAll { + $resourceType = 'Microsoft.Windows/Service' + # Use the Print Spooler service for set tests — it exists on all Windows + # machines and is safe to reconfigure briefly. + $testServiceName = 'Spooler' + + function Get-ServiceState { + param([string]$Name) + $json = @{ name = $Name } | ConvertTo-Json -Compress + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + return ($out | ConvertFrom-Json).actualState + } + } + + Context 'Input validation' -Skip:(!$isAdmin) { + BeforeAll { + $script:originalState = Get-ServiceState -Name $testServiceName + } + + AfterAll { + # Restore original logon account + if ($script:originalState -and $script:originalState.logonAccount) { + $restoreJson = @{ + name = $testServiceName + logonAccount = $script:originalState.logonAccount + } | ConvertTo-Json -Compress + $restoreJson | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + } + } + + It 'Fails when name is not provided' { + $json = @{ startType = 'Manual' } | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'Fails when input JSON is invalid' { + $out = 'not-json' | dsc resource set -r $resourceType -f - 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'Fails when logonAccount is not a built-in service account' { + $json = @{ name = 'Spooler'; logonAccount = 'DOMAIN\SomeUser' } | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'Accepts LocalSystem as logonAccount' { + $json = @{ name = 'Spooler'; logonAccount = 'LocalSystem' } | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + } + + It 'Accepts NT AUTHORITY\NetworkService as logonAccount' { + $json = @{ name = 'Spooler'; logonAccount = 'NT AUTHORITY\NetworkService' } | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + } + } + + Context 'Set startType' -Skip:(!$isAdmin) { + BeforeAll { + $script:originalState = Get-ServiceState -Name $testServiceName + } + + AfterAll { + # Restore original start type + if ($script:originalState) { + $restoreJson = @{ + name = $testServiceName + startType = $script:originalState.startType + } | ConvertTo-Json -Compress + $restoreJson | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + } + } + + It 'Can set startType to Disabled' { + $json = @{ name = $testServiceName; startType = 'Disabled' } | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = ($out | ConvertFrom-Json).afterState + $result.name | Should -BeExactly $testServiceName + $result.startType | Should -BeExactly 'Disabled' + } + + It 'Can set startType to Manual' { + $json = @{ name = $testServiceName; startType = 'Manual' } | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = ($out | ConvertFrom-Json).afterState + $result.startType | Should -BeExactly 'Manual' + } + + It 'Can set startType to Automatic' { + $json = @{ name = $testServiceName; startType = 'Automatic' } | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = ($out | ConvertFrom-Json).afterState + $result.startType | Should -BeExactly 'Automatic' + } + + It 'Can set startType to AutomaticDelayedStart' { + $json = @{ name = $testServiceName; startType = 'AutomaticDelayedStart' } | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = ($out | ConvertFrom-Json).afterState + $result.startType | Should -BeExactly 'AutomaticDelayedStart' + } + } + + Context 'Set description' -Skip:(!$isAdmin) { + BeforeAll { + $script:originalState = Get-ServiceState -Name $testServiceName + } + + AfterAll { + # Restore original description + if ($script:originalState -and $script:originalState.description) { + $restoreJson = @{ + name = $testServiceName + description = $script:originalState.description + } | ConvertTo-Json -Compress + $restoreJson | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + } + } + + It 'Can set description' { + $testDesc = 'DSC test description' + $json = @{ name = $testServiceName; description = $testDesc } | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = ($out | ConvertFrom-Json).afterState + $result.description | Should -BeExactly $testDesc + } + } + + Context 'Set service status' -Skip:(!$isAdmin) { + BeforeAll { + $script:originalState = Get-ServiceState -Name $testServiceName + # Ensure the service is startable (not disabled) + if ($script:originalState.startType -eq 'Disabled') { + $json = @{ name = $testServiceName; startType = 'Manual' } | ConvertTo-Json -Compress + $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + } + } + + AfterAll { + # Restore original status and start type + if ($script:originalState) { + $restoreJson = @{ + name = $testServiceName + startType = $script:originalState.startType + status = $script:originalState.status + } | ConvertTo-Json -Compress + $restoreJson | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + } + } + + It 'Can start a stopped service' { + # First ensure it is stopped + $json = @{ name = $testServiceName; status = 'Stopped' } | ConvertTo-Json -Compress + $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + + # Now start it + $json = @{ name = $testServiceName; status = 'Running' } | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = ($out | ConvertFrom-Json).afterState + $result.status | Should -BeExactly 'Running' + } + + It 'Can stop a running service' { + # First ensure it is running + $json = @{ name = $testServiceName; status = 'Running' } | ConvertTo-Json -Compress + $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + + # Now stop it + $json = @{ name = $testServiceName; status = 'Stopped' } | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = ($out | ConvertFrom-Json).afterState + $result.status | Should -BeExactly 'Stopped' + } + + It 'Is idempotent when service is already in desired status' { + # Ensure it is stopped + $json = @{ name = $testServiceName; status = 'Stopped' } | ConvertTo-Json -Compress + $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + + # Set to Stopped again — should succeed without error + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = ($out | ConvertFrom-Json).afterState + $result.status | Should -BeExactly 'Stopped' + } + } + + Context 'Set multiple properties at once' -Skip:(!$isAdmin) { + BeforeAll { + $script:originalState = Get-ServiceState -Name $testServiceName + } + + AfterAll { + # Restore original state + if ($script:originalState) { + $restoreJson = @{ + name = $testServiceName + startType = $script:originalState.startType + description = $script:originalState.description + status = $script:originalState.status + } | ConvertTo-Json -Compress + $restoreJson | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + } + } + + It 'Can set startType and description together' { + $json = @{ + name = $testServiceName + startType = 'Manual' + description = 'DSC combined test' + } | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = ($out | ConvertFrom-Json).afterState + $result.startType | Should -BeExactly 'Manual' + $result.description | Should -BeExactly 'DSC combined test' + } + + It 'Returns full service state after set' { + $json = @{ name = $testServiceName; startType = 'Manual' } | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = ($out | ConvertFrom-Json).afterState + $result.name | Should -Not -BeNullOrEmpty + $result.displayName | Should -Not -BeNullOrEmpty + $result._exist | Should -BeTrue + $result.status | Should -Not -BeNullOrEmpty + $result.startType | Should -Not -BeNullOrEmpty + $result.executablePath | Should -Not -BeNullOrEmpty + $result.logonAccount | Should -Not -BeNullOrEmpty + $result.errorControl | Should -Not -BeNullOrEmpty + } + } + + Context 'Set with no changes (idempotent)' -Skip:(!$isAdmin) { + It 'Succeeds when only name is provided (no properties to change)' { + $json = @{ name = $testServiceName } | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = ($out | ConvertFrom-Json).afterState + $result.name | Should -BeExactly $testServiceName + $result._exist | Should -BeTrue + } + } +} diff --git a/resources/windows_service/windows_service.dsc.resource.json b/resources/windows_service/windows_service.dsc.resource.json new file mode 100644 index 000000000..f37083a82 --- /dev/null +++ b/resources/windows_service/windows_service.dsc.resource.json @@ -0,0 +1,116 @@ +{ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Microsoft.Windows/Service", + "description": "Manage Windows services", + "tags": [ + "Windows" + ], + "version": "0.1.0", + "get": { + "executable": "windows_service", + "args": [ + "get", + { + "jsonInputArg": "--input", + "mandatory": true + } + ] + }, + "set": { + "executable": "windows_service", + "args": [ + "set", + { + "jsonInputArg": "--input", + "mandatory": true + } + ], + "implementsPretest": false, + "return": "state", + "requireSecurityContext": "elevated" + }, + "export": { + "executable": "windows_service", + "args": [ + "export", + { + "jsonInputArg": "--input", + "mandatory": false + } + ] + }, + "exitCodes": { + "0": "Success", + "1": "Invalid arguments", + "2": "Invalid input", + "3": "Service error" + }, + "schema": { + "embedded": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Windows Service", + "description": "Manage Windows services.", + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "Service name", + "description": "The name of the service in the Service Control Manager." + }, + "displayName": { + "type": "string", + "title": "Display name", + "description": "The display name of the service shown in the Services console." + }, + "description": { + "type": "string", + "title": "Description", + "description": "A description of the service." + }, + "_exist": { + "type": "boolean", + "title": "Exists", + "description": "Indicates whether the service exists.", + "readOnly": true + }, + "status": { + "type": "string", + "title": "Status", + "description": "The status of the service. When used as desired state, only \"Running\", \"Stopped\", and \"Paused\" are accepted. Additional values (\"StartPending\", \"StopPending\", \"PausePending\", \"ContinuePending\") may be returned to report the current status but must not be used as desired values.", + "enum": ["Running", "Stopped", "Paused", "StartPending", "StopPending", "PausePending", "ContinuePending"] + }, + "startType": { + "type": "string", + "title": "Start type", + "description": "The start type of the service.", + "enum": ["Automatic", "AutomaticDelayedStart", "Manual", "Disabled"] + }, + "executablePath": { + "type": "string", + "title": "Executable path", + "description": "The fully qualified path to the service binary." + }, + "logonAccount": { + "type": "string", + "title": "Logon account", + "description": "The account under which the service runs." + }, + "errorControl": { + "type": "string", + "title": "Error control", + "description": "The error control level for the service.", + "enum": ["Ignore", "Normal", "Severe", "Critical"] + }, + "dependencies": { + "type": "array", + "title": "Dependencies", + "description": "A list of service names that this service depends on.", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + } +}