src/service_windows.rs
//! The Windows Service Control Manager renderer (lane B4: it stays in the root
//! with the MSI limb it reuses; the systemd lifecycle crate does not carry it).
use skidbladnir_systemd::service::{
memory_not_honoured_note, oom_not_honoured_note, oom_score_not_honoured_note, start_limit_not_honoured_note, windows_arg,
};
use crate::service::*;
/// Windows service (SCM) renderer — projects the neutral [`ServiceSpec`] onto a
/// Windows Service Control Manager service. Like the k8s/OCI backends it targets a
/// non-init world, so it REUSES the MSI limb's [`crate::msi::WindowsServiceSpec`]
/// vocabulary (L5) rather than inventing a second Windows service model: it builds a
/// `WindowsServiceSpec` from the neutral spec and renders the WiX
/// `<ServiceInstall>`/`<ServiceControl>` XML through the SHARED
/// [`crate::msi::render_service_install`] (the exact fragment the MSI builder embeds).
/// Pure: no root, no IO — the privileged driver writes the `.wxs` and runs the verbs.
///
/// The mapping onto the Windows model (stated where it differs from an init unit):
/// - **File.** a standalone WiX `.wxs` authoring fragment at
/// `C:\ProgramData\<name>\<name>.wxs` — the shared `<ServiceInstall>` (Type
/// `ownProcess`, delayed-auto start, the account) + `<ServiceControl>` wrapped in a
/// minimal `<Wix><Fragment>` envelope, so the same XML feeds either the MSI builder
/// or a direct WiX author.
/// - **Account.** Windows SCM services are **machine-scoped** — there is no per-user
/// SCM service — so [`ServiceSpec::scope`] does not branch here (documented, not
/// faked); the run-as identity is [`ServiceSpec::user`] (an `obj=`/`Account=`), or
/// the built-in `LocalSystem` when unset.
/// - **Start / enable.** enable-at-boot = `start= auto` (delayed-auto). The `sc.exe
/// create` verb IS the enable (it registers the service auto-start).
/// - **Restart.** SCM crash-recovery: [`RestartPolicy::OnFailure`]/[`RestartPolicy::Always`]
/// → a `util:ServiceConfig` first/second/third-failure `restart` after
/// [`ServiceSpec::restart_sec`] seconds (so — unlike the OCI backend — `restart_sec`
/// IS expressible here); [`RestartPolicy::No`] ⇒ no recovery block.
/// - **Command / deps.** `sc.exe create <name> binPath= <exec + args> start= auto obj=
/// <account>` + `depend= <a>/<b>` for `needs`/`after` (SCM's dependency list,
/// deduped + order-preserving) + `DisplayName= <description>`. `sc.exe` cannot
/// set env or the
/// working dir, so env is carried in the `.wxs` (the `ServiceConfig` env block) and
/// [`ServiceSpec::working_dir`] is **not** expressible on the SCM path (documented
/// limit, same honest class as the OCI backend's `restart_sec`).
/// - **Verbs.** `sc.exe create …` (enable), `sc.exe start <name>` (start), `sc.exe
/// query <name>` (status), `sc.exe stop <name>` (stop).
pub struct WindowsServiceRenderer;
impl ServiceRenderer for WindowsServiceRenderer {
fn backend(&self) -> Backend {
Backend::WindowsService
}
fn render(&self, spec: &ServiceSpec) -> anyhow::Result<RenderedService> {
spec.validate()?;
let n = &spec.name;
// Config files carry POSIX paths; there is no honest place to put them in
// an MSI built from this spec, so refuse rather than drop them.
if !spec.config_files.is_empty() {
anyhow::bail!(
"service `{n}` declares {} config file(s), and the Windows renderer has no \
projection for them",
spec.config_files.len()
);
}
// The SCM env block lands in the `.wxs` and then in the registry, readable by
// every local user — there is no private channel to put a secret in, so refuse.
if !spec.secret_env.is_empty() {
anyhow::bail!(
"service `{n}` declares {} secret env var(s), and a Windows service has no \
private env channel (the env block is stored world-readable in the \
registry) — refusing rather than rendering the secret in the clear",
spec.secret_env.len()
);
}
// Windows SCM is machine-scoped: the account (not a --user scope) selects the
// identity. Default to the built-in LocalSystem when the spec sets no user.
let account = spec
.user
.clone()
.unwrap_or_else(|| "LocalSystem".to_string());
// Project onto the shared MSI Windows-service vocabulary (L5). Enable-at-boot =
// delayed-auto; RestartPolicy governs crash-recovery, not boot-start.
let win = crate::msi::WindowsServiceSpec {
account: account.clone(),
start: crate::msi::ServiceStart::Auto,
delayed: true,
env_block: spec.env.clone(),
};
// RestartPolicy → SCM crash-recovery delay (restart_sec IS expressible on
// Windows, unlike the OCI backend). No ⇒ no recovery actions.
let restart_delay = match spec.restart {
RestartPolicy::No => None,
_ => Some(spec.restart_sec),
};
// The shared ServiceInstall/ServiceControl fragment, wrapped in a minimal WiX
// envelope so the artifact is a complete, standalone `.wxs`.
let frag = crate::msi::render_service_install(n, &spec.description, &win, restart_delay);
let mut xml = String::new();
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
xml.push_str("<Wix xmlns=\"http://wixtoolset.org/schemas/v4/wxs\">\n");
xml.push_str(&format!(" <Fragment Id=\"{n}Service\">\n"));
xml.push_str(&frag);
// THE RESOURCE CEILING — the Windows SCM has none reachable from here. The
// nearest real mechanism is a JOB OBJECT (`JOB_OBJECT_LIMIT_JOB_MEMORY`), and
// neither `sc.exe` nor a WiX `<ServiceInstall>` can set one: it has to be
// applied by the process itself or by a launcher that creates the job first.
// Named in the authored `.wxs` as an XML comment — visible to whoever opens
// the artifact — rather than dropped in silence.
if let Some(m) = &spec.memory {
xml.push_str(" <!--\n");
xml.push_str(&memory_not_honoured_note(
" ",
"the Windows SCM (sc.exe / WiX ServiceInstall)",
&[
"Its nearest equivalent is a job object's JOB_OBJECT_LIMIT_JOB_MEMORY,",
"which is real but is settable only by the process itself or by a",
"launcher that creates the job before starting it — not by service",
"registration, which is all this backend controls.",
],
m,
));
xml.push_str(" -->\n");
}
// …and THE REACTION. Windows has no cgroup and no OOM killer of this shape at
// all: the kernel does not reap a process group under memory pressure, it fails
// the allocation. There is nothing here to react TO, which is a stronger "not
// honoured" than the others and is said in the artifact rather than assumed
// away.
if let Some(pol) = spec.oom_policy {
xml.push_str(" <!--\n");
xml.push_str(&oom_not_honoured_note(
" ",
"the Windows SCM (sc.exe / WiX ServiceInstall)",
&[
"Windows has no cgroup OOM killer to react to: under commit pressure",
"the allocation FAILS inside the process rather than the kernel reaping",
"a group of them. The nearest construct, a job object, can be told to",
"terminate on limit — but only by the launcher that creates it, not by",
"service registration, which is all this backend controls.",
],
pol,
));
xml.push_str(" -->\n");
}
// …and THE RATE LIMIT. The SCM genuinely HAS this — `sc failure` sets a reset
// period and a per-failure action list, which is the same idea — but it is a
// separate command against an already-registered service, not part of the
// registration this backend renders. Named, with the by-hand command.
if let Some(l) = spec.start_limit {
xml.push_str(" <!--\n");
xml.push_str(&start_limit_not_honoured_note(
" ",
"the Windows SCM (sc.exe / WiX ServiceInstall)",
&[
"The SCM DOES have this idea — `sc failure <svc> reset= <sec>",
"actions= restart/<ms>/...` bounds how many times it will act before",
"the counter resets — but it is a separate command against an",
"already-registered service, not part of the registration this",
"backend renders, and the mapping from a burst to an action LIST is",
"not one-to-one. Run it by hand from the numbers above.",
],
l,
spec.restart_sec,
));
xml.push_str(" -->\n");
}
// WHICH service the SCM's host reaps — Windows has no oom_score_adj and no
// per-service ranking under memory pressure at all, so this is the one note
// that cannot even offer a by-hand route on this platform.
if let Some(a) = spec.oom_score_adjust {
xml.push_str(" <!--\n");
xml.push_str(&oom_score_not_honoured_note(
" ",
"the Windows SCM (sc.exe / WiX ServiceInstall)",
a,
));
xml.push_str(" -->\n");
}
xml.push_str(" </Fragment>\n</Wix>\n");
// enable = sc.exe create: binPath carries exec+args, obj the account, start=
// auto the boot enable, depend= the SCM dependency list, DisplayName the desc.
let mut enable: Vec<String> = vec![
"sc.exe".into(),
"create".into(),
n.clone(),
"binPath=".into(),
std::iter::once(&spec.exec)
.chain(&spec.args)
.map(|w| windows_arg(w))
.collect::<Vec<_>>()
.join(" "),
"start=".into(),
"auto".into(),
"obj=".into(),
account,
];
// needs + after both fold into SCM's single dependency list (deduped, order-
// preserving), joined by `/` — sc.exe's `depend=` syntax.
let mut deps: Vec<&String> = Vec::new();
for dep in spec.needs.iter().chain(spec.after.iter()) {
if !deps.contains(&dep) {
deps.push(dep);
}
}
if !deps.is_empty() {
enable.push("depend=".into());
enable.push(
deps.iter()
.map(|d| d.as_str())
.collect::<Vec<_>>()
.join("/"),
);
}
enable.push("DisplayName=".into());
enable.push(spec.description.clone());
Ok(RenderedService {
backend: Backend::WindowsService,
files: vec![RenderedFile {
path: format!("C:\\ProgramData\\{n}\\{n}.wxs"),
contents: xml,
mode: 0o644,
}],
enable,
start: vec!["sc.exe".into(), "start".into(), n.clone()],
status: vec!["sc.exe".into(), "query".into(), n.clone()],
stop: vec!["sc.exe".into(), "stop".into(), n.clone()],
remove: vec!["sc.exe".into(), "delete".into(), n.clone()],
})
}
}