Cutover: promote the installer from edge to main #17

Merged
whitlocktech merged 24 commits from edge into main 2026-08-07 20:07:39 +00:00
Showing only changes of commit b7d1bbbc78 - Show all commits

View File

@@ -44,6 +44,11 @@ pub const WINDOWS_SERVICE: &str = "RunicGatewayLink";
pub const SERVICE_USER: &str = "runicgateway"; pub const SERVICE_USER: &str = "runicgateway";
/// What both platforms show a human. /// What both platforms show a human.
const DISPLAY_NAME: &str = "Runic Gateway uo-link sidecar"; const DISPLAY_NAME: &str = "Runic Gateway uo-link sidecar";
/// The first `link` release whose sidecar speaks the Windows SCM startup protocol, and so the
/// oldest one that can be started as a service at all. Named only in the 1053 diagnosis; nothing
/// enforces it, because the Linux side has no such floor and a version gate on an installed binary
/// would refuse deployments that are working.
const MIN_SERVICE_SIDECAR: &str = "v1.2.0";
/// Which service manager this host has — or why it has none this installer can drive. /// Which service manager this host has — or why it has none this installer can drive.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -307,6 +312,57 @@ pub fn windows_bin_path(binary: &Path, config: &Path) -> String {
format!("\"{}\" --config \"{}\"", binary.display(), config.display()) format!("\"{}\" --config \"{}\"", binary.display(), config.display())
} }
/// What to tell the operator when `sc.exe start` fails.
///
/// Pure and tested on both platforms, because the *wrong* explanation here is expensive. This
/// originally blamed every failure on the config file — "a service that exits immediately usually
/// cannot read its config" — which for the one error code that actually shows up sends the reader
/// to inspect a file that is almost certainly fine.
///
/// **1053 is not a crash.** It is the SCM giving up after 30 seconds waiting for the service
/// process to call `StartServiceCtrlDispatcher` and identify itself. The process starts, runs, and
/// is very likely serving traffic; it simply never had the conversation the SCM required. A sidecar
/// older than the one that speaks the SCM protocol produces this *every time*, on a perfectly good
/// config — so the config is the last thing to look at, not the first.
pub fn windows_start_failure(code: i32, binary: &Path, config: &Path) -> String {
let command = crate::util::command_line("sc.exe", &["start", WINDOWS_SERVICE]);
match code {
1053 => format!(
"`{command}` failed with 1053 — the service did not respond to the start request in \
time.\n\n This is a handshake failure, not a crash: Windows waited 30 seconds for \
the process to identify itself to the service control manager. The usual cause is a \
sidecar built before the service support was added, which runs perfectly in the \
foreground and can never start as a service. Check its version:\n\n \
\"{binary}\" --version\n\n and confirm it is at least {MIN_SERVICE_SIDECAR}. To \
see whether the sidecar itself is healthy, run it in the foreground — if that works, \
the binary is the problem, not the configuration:\n\n \"{binary}\" --config \
\"{config}\"",
binary = binary.display(),
config = config.display(),
),
// ERROR_SERVICE_LOGON_FAILED. The account is the virtual one the SCM makes itself, so this
// is a policy that forbids virtual service accounts rather than a wrong password.
1069 => format!(
"`{command}` failed with 1069 — the service could not log on as {account}.\n\n \
That account is a virtual service account created by the SCM itself and has no \
password, so this is a local policy forbidding them rather than a bad credential. \
Register the service by hand against an account this host allows — INSTALL.md \
Appendix A4.",
account = windows_service_account(),
),
_ => format!(
"`{command}` failed with exit code {code}.\n\n Check the Windows event log \
(System, source \"Service Control Manager\"), and `sc query {WINDOWS_SERVICE}` for \
the service's own exit code. A sidecar that exits immediately usually cannot read its \
config: {}\n\n Running it in the foreground prints the reason:\n\n \
\"{}\" --config \"{}\"",
config.display(),
binary.display(),
config.display(),
),
}
}
#[cfg(windows)] #[cfg(windows)]
fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outcome> { fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outcome> {
let bin_path = windows_bin_path(binary, config); let bin_path = windows_bin_path(binary, config);
@@ -373,13 +429,11 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outco
// 1056 is ERROR_SERVICE_ALREADY_RUNNING, which is the desired end state, not a failure. // 1056 is ERROR_SERVICE_ALREADY_RUNNING, which is the desired end state, not a failure.
let start = run("sc.exe", &["start", WINDOWS_SERVICE])?; let start = run("sc.exe", &["start", WINDOWS_SERVICE])?;
if !start.status.success() && start.status.code() != Some(1056) { if !start.status.success() && start.status.code() != Some(1056) {
anyhow::bail!( anyhow::bail!(windows_start_failure(
"`{}` failed with exit code {}. Check the Windows event log; a service that exits \
immediately usually cannot read its config: {}",
crate::util::command_line("sc.exe", &["start", WINDOWS_SERVICE]),
start.status.code().unwrap_or(-1), start.status.code().unwrap_or(-1),
config.display() binary,
); config
));
} }
Ok(Outcome::Registered { Ok(Outcome::Registered {
@@ -994,6 +1048,45 @@ mod tests {
} }
} }
#[test]
fn error_1053_is_diagnosed_as_a_handshake_not_a_bad_config() {
// The regression this guards: 1053 used to be reported as "a service that exits immediately
// usually cannot read its config", which is the one thing it almost never is. A reader who
// follows that sentence goes and stares at a config file that is fine.
let msg = windows_start_failure(
1053,
Path::new(r"C:\Program Files\RunicGateway\uo-link-sidecar.exe"),
Path::new(r"C:\ProgramData\RunicGateway\sidecar.toml"),
);
assert!(msg.contains("1053"), "{msg}");
assert!(msg.contains("handshake"), "{msg}");
assert!(!msg.contains("cannot read its config"), "{msg}");
// It has to name the two things that actually resolve it: check the version, and prove the
// binary is healthy by running it in the foreground.
assert!(msg.contains("--version"), "{msg}");
assert!(msg.contains(MIN_SERVICE_SIDECAR), "{msg}");
assert!(msg.contains("uo-link-sidecar.exe"), "{msg}");
assert!(msg.contains("sidecar.toml"), "{msg}");
}
#[test]
fn a_logon_failure_points_at_policy_not_a_password() {
let msg = windows_start_failure(1069, Path::new("bin.exe"), Path::new("c.toml"));
assert!(msg.contains("NT SERVICE\\RunicGatewayLink"), "{msg}");
assert!(msg.contains("policy"), "{msg}");
}
#[test]
fn an_unrecognized_code_still_says_how_to_see_the_real_error() {
// The fallback must not pretend to know the cause; it must hand over the two places the
// cause is actually written down.
let msg = windows_start_failure(5, Path::new("bin.exe"), Path::new("c.toml"));
assert!(msg.contains("exit code 5"), "{msg}");
assert!(msg.contains("event log"), "{msg}");
assert!(msg.contains("sc query RunicGatewayLink"), "{msg}");
assert!(msg.contains("--config"), "{msg}");
}
#[test] #[test]
fn the_manual_steps_are_a_complete_recipe() { fn the_manual_steps_are_a_complete_recipe() {
// This text is all an operator gets on a host the installer cannot drive, so it has to name // This text is all an operator gets on a host the installer cannot drive, so it has to name