Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/Skidbladnir

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()],
        })
    }
}