Files
installer/src/rustgame/uninstall.rs
wtclaude 7027a78a23
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m7s
feat(rust): --game rust, named instances, and schema-2 bundles (phase 18)
Module-rust phase 18, step 5 of docs/modules/rust/PLAN.md §34.2.7 (D146,
D148, D149, D153).

Bundles: ServUO is read at schema 2 from v2/servuo/ and lowered into the
schema-1 model. Schema 1 at the root is the fallback, so a pin from before
schema 2 still reproduces. Rust bundles are read from v2/rust/. v2 reads use
the contents API, because /raw/ is CDN-cached for six hours.

--game rust runs install, update, doctor and uninstall for Rust servers
(src/rustgame/):
- the framework is detected from its marker files, which were read off both
  rigs; both or neither is refused;
- --server-id names an instance: its own service (runicgateway-rust@<id>, or
  RunicGatewayRust-<id>), config, database and ports;
- the plugin config is written once, with ServerId and Port only. An existing
  one is never rewritten, and one naming another server refuses the run;
- each instance's sidecar.toml is written once with its ports and an absolute
  database path, and the sidecar generates the token into it;
- one binary per host. update moves every instance, and a replaced binary
  restarts all of them;
- doctor checks the plugin file hash, the plugin config's ServerId, the
  required uMod plugins (a warning), the service and /health, and passes when
  the plugin is connected;
- uninstall removes our plugin and keeps its config. --purge also removes the
  sidecar config and database. The last instance takes the binary, the
  template and the record, and the shared user only when no ServUO record
  remains.

service.rs takes the service name as a parameter internally. The ServUO
public API is unchanged.

Finding: Carbon 2.0.259's config.json has no folder keys, so carbon/plugins
and carbon/configs are what the installer uses. The plan expected a moved
directory to be readable there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 23:40:11 -05:00

200 lines
7.0 KiB
Rust

//! `uninstall --game rust [--server-id <id>] [--purge]`.
//!
//! What it removes, per instance (docs/modules/rust/PLAN.md §34.2.3, §34.4):
//!
//! - **the service**, and **our plugin file** — `RunicGateway.cs` is the installer's;
//! - **never the plugin's config** — `RunicGateway.json` is the website's, and it holds the
//! server's id; a reinstall must find it;
//! - with `--purge`, the instance's sidecar config (its token) and database.
//!
//! With the last instance gone: the shared binary, the template unit, the record, and — only if
//! this installer created it and no ServUO deployment uses it — the service account.
use std::path::Path;
use anyhow::Result;
use super::record::RustRecord;
use crate::cli::Cli;
use crate::{paths, service, ui};
pub fn run(cli: &Cli) -> Result<i32> {
let layout = paths::layout();
let record_path = layout.rust_record();
println!(
"\nRunic Gateway installer {} — uninstall (Rust)",
env!("CARGO_PKG_VERSION")
);
let Some(mut record) = RustRecord::load(&record_path)? else {
println!();
ui::warn(&format!(
"Nothing to uninstall — no Rust deployment is recorded on this host (looked for {}).",
record_path.display()
));
return Ok(0);
};
let ids: Vec<String> = match &cli.server_id {
Some(id) if record.instances.contains_key(id) => vec![id.clone()],
Some(id) => anyhow::bail!(
"{id:?} is not a recorded Rust instance (recorded: {})",
record
.instances
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
),
None => record.instances.keys().cloned().collect(),
};
let last = ids.len() == record.instances.len();
// ── Say what will happen, then ask ───────────────────────────────────────
println!();
for id in &ids {
let i = &record.instances[id];
ui::heading(&format!("Instance {id}"));
if let Some(svc) = &i.service {
println!(" · remove the service {}", svc.name);
}
println!(" · remove {}", i.plugin_path);
println!(
" · keep {} (the website's; it names this server)",
i.plugin_config
);
if cli.purge {
println!(
" · remove {} and the database {}",
i.config_path, i.db_path
);
} else {
println!(
" · keep {} and {} (--purge removes them)",
i.config_path, i.db_path
);
}
}
if last {
println!(
"\n and, with no instance left: {} and {}",
record.binary.path,
record_path.display()
);
}
println!();
let proceed = if cli.assume_yes {
println!("Remove the components listed above? [y/N] (--yes)");
true
} else {
ui::confirm("Remove the components listed above?", false, false)?
};
if !proceed {
println!("\nNothing was removed.");
return Ok(0);
}
// ── Remove ───────────────────────────────────────────────────────────────
let mut done = Vec::new();
let mut problems = Vec::new();
for id in &ids {
let Some(instance) = record.instances.remove(id) else {
continue;
};
if let Some(svc) = &instance.service {
let removal = service::remove(svc);
done.extend(removal.done);
problems.extend(removal.problems);
}
remove_file(Path::new(&instance.plugin_path), &mut done, &mut problems);
if cli.purge {
remove_file(Path::new(&instance.config_path), &mut done, &mut problems);
let data_dir = layout.rust_data_dir(id);
if data_dir.exists() {
match std::fs::remove_dir_all(&data_dir) {
Ok(()) => done.push(format!("removed {}", data_dir.display())),
Err(e) => problems.push(format!("cannot remove {}: {e}", data_dir.display())),
}
}
}
}
if record.instances.is_empty() {
remove_file(Path::new(&record.binary.path), &mut done, &mut problems);
done.extend(service::remove_rust_template(&layout));
remove_shared_user(&record, &layout, &mut done, &mut problems);
remove_file(&record_path, &mut done, &mut problems);
} else {
match record.save(&record_path) {
Ok(()) => done.push(format!(
"{} keeps {} instance(s): {}",
record_path.display(),
record.instances.len(),
record
.instances
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
)),
Err(e) => problems.push(format!("cannot update {}: {e}", record_path.display())),
}
}
for line in &done {
println!(" · {line}");
}
for problem in &problems {
println!();
ui::warn(problem);
}
println!(
"\nRemove each server from Admin -> Rust -> Servers on the website too; the installer never \
contacts it."
);
Ok(if problems.is_empty() { 0 } else { 1 })
}
/// The `runicgateway` account is shared: every Rust instance runs as it, and so does ServUO's
/// sidecar. It goes only when this installer created it for Rust and no ServUO deployment remains.
fn remove_shared_user(
record: &RustRecord,
layout: &paths::Layout,
done: &mut Vec<String>,
problems: &mut Vec<String>,
) {
if !record.service_user_created || cfg!(windows) {
return;
}
if layout.install_record().exists() {
done.push(format!(
"left the {} account — the ServUO deployment on this host runs as it",
service::SERVICE_USER
));
return;
}
let removed = crate::util::run_ok("userdel", &[service::SERVICE_USER])
.or_else(|_| crate::util::run_ok("deluser", &[service::SERVICE_USER]));
match removed {
Ok(_) => done.push(format!(
"removed the {} service user",
service::SERVICE_USER
)),
Err(e) => problems.push(format!(
"cannot remove the {} service user ({}); remove it by hand if you want it gone",
service::SERVICE_USER,
e.to_string().replace('\n', " ")
)),
}
}
fn remove_file(path: &Path, done: &mut Vec<String>, problems: &mut Vec<String>) {
match std::fs::remove_file(path) {
Ok(()) => done.push(format!("removed {}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
done.push(format!("{} was already gone", path.display()))
}
Err(e) => problems.push(format!("cannot remove {}: {e}", path.display())),
}
}