Cutover: promote the installer from edge to main #17
105
src/service.rs
105
src/service.rs
@@ -44,6 +44,11 @@ pub const WINDOWS_SERVICE: &str = "RunicGatewayLink";
|
||||
pub const SERVICE_USER: &str = "runicgateway";
|
||||
/// What both platforms show a human.
|
||||
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.
|
||||
#[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())
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outcome> {
|
||||
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.
|
||||
let start = run("sc.exe", &["start", WINDOWS_SERVICE])?;
|
||||
if !start.status.success() && start.status.code() != Some(1056) {
|
||||
anyhow::bail!(
|
||||
"`{}` 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]),
|
||||
anyhow::bail!(windows_start_failure(
|
||||
start.status.code().unwrap_or(-1),
|
||||
config.display()
|
||||
);
|
||||
binary,
|
||||
config
|
||||
));
|
||||
}
|
||||
|
||||
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]
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user