ODIAN Launcher: hub, installer, local stack and remembered sign-in (M2)
- Tauri 2 + Svelte 5 hub in ODIAN dark: patch notes and roadmap, install and update progress, local stack status, settings with DHP and MCP addresses. - crates/odian-packs: Rust installer for pack format v1 (signed manifests, resumable verified downloads from a DHP or an ODIAN drive), byte-compatible with ODIAN_MCP_Server's Python odian_packs. - Local stack on 127.0.0.1: Postgres -> Engine API -> tile server -> Odian.exe, one Job Object, clean shutdown; database snapshot restore on a fresh PC only. - Keycloak sign-in through the DHP's MCP, remembered until Sign out with a launcher session that renews the Engine token silently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
//! The PC's own Postgres cluster (`data\pg`): create it, start and stop it, load a snapshot.
|
||||
//!
|
||||
//! It listens on 127.0.0.1 only, with a password generated on this PC. The
|
||||
//! snapshot is restored in the order proven on the Data PC's dumps: pre-data,
|
||||
//! every table's data (in parallel), sequences, post-data.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::stack::{command, log_output, Layout, PG_PORT};
|
||||
|
||||
pub const PG_USER: &str = "odian";
|
||||
pub const PG_DATABASE: &str = "odian";
|
||||
|
||||
/// One dump file from the manifest's `db.files`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DbFile {
|
||||
pub section: String,
|
||||
/// Relative to `data\`, e.g. `db/20260915T082529Z/data/public.db_ground_poi.dump`.
|
||||
pub path: String,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
/// Restore progress: (done bytes, total bytes, done files, total files, current file).
|
||||
pub type RestoreProgress<'a> = &'a (dyn Fn(u64, u64, usize, usize, &str) + Sync);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Pg {
|
||||
bin: PathBuf,
|
||||
data: PathBuf,
|
||||
logs: PathBuf,
|
||||
password: String,
|
||||
}
|
||||
|
||||
impl Pg {
|
||||
pub fn new(layout: &Layout, password: &str) -> Pg {
|
||||
Pg {
|
||||
bin: layout.pg_bin(),
|
||||
data: layout.pg_data(),
|
||||
logs: layout.state_dir().join("pg"),
|
||||
password: password.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn tool(&self, name: &str) -> Command {
|
||||
let mut cmd = command(&self.bin.join(format!("{name}.exe")), &self.logs, &[self.bin.clone()]);
|
||||
cmd.env("PGPASSWORD", &self.password).env("PGCONNECT_TIMEOUT", "5").env("PGAPPNAME", "odian-launcher");
|
||||
cmd
|
||||
}
|
||||
|
||||
fn run(mut cmd: Command, what: &str) -> Result<String, String> {
|
||||
let out = cmd
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.map_err(|e| format!("cannot run {what}: {e}"))?;
|
||||
if out.status.success() {
|
||||
return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let tail: String = stderr.chars().rev().take(1500).collect::<Vec<_>>().into_iter().rev().collect();
|
||||
Err(format!("{what} failed ({}): {}", out.status, tail.trim()))
|
||||
}
|
||||
|
||||
fn ensure_logs(&self) -> Result<(), String> {
|
||||
std::fs::create_dir_all(&self.logs).map_err(|e| format!("cannot create {}: {e}", self.logs.display()))
|
||||
}
|
||||
|
||||
pub fn is_initialized(&self) -> bool {
|
||||
self.data.join("PG_VERSION").is_file()
|
||||
}
|
||||
|
||||
/// Create the cluster. A `data\pg` without PG_VERSION is a failed earlier attempt and is replaced.
|
||||
pub fn init(&self) -> Result<(), String> {
|
||||
self.ensure_logs()?;
|
||||
if self.data.exists() && !self.is_initialized() {
|
||||
std::fs::remove_dir_all(&self.data).map_err(|e| format!("cannot clear {}: {e}", self.data.display()))?;
|
||||
}
|
||||
let pwfile = self.logs.join("initdb.pw");
|
||||
std::fs::write(&pwfile, &self.password).map_err(|e| format!("cannot write {}: {e}", pwfile.display()))?;
|
||||
let mut cmd = self.tool("initdb");
|
||||
cmd
|
||||
.arg("-D")
|
||||
.arg(&self.data)
|
||||
.args(["-U", PG_USER, "-A", "scram-sha-256", "-E", "UTF8", "--locale=C"])
|
||||
.args(["--locale-provider=builtin", "--builtin-locale=C.UTF-8"])
|
||||
.arg(format!("--pwfile={}", pwfile.display()));
|
||||
let result = Self::run(cmd, "initdb");
|
||||
let _ = std::fs::remove_file(&pwfile);
|
||||
result.map(|_| ())
|
||||
}
|
||||
|
||||
pub fn running(&self) -> bool {
|
||||
let mut cmd = self.tool("pg_ctl");
|
||||
cmd.arg("status").arg("-D").arg(&self.data).stdout(Stdio::null()).stderr(Stdio::null());
|
||||
cmd.status().map(|status| status.success()).unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn start(&self) -> Result<(), String> {
|
||||
if !self.is_initialized() {
|
||||
return Err("The local database isn't set up. Reinstall from the DHP or a drive.".to_string());
|
||||
}
|
||||
self.ensure_logs()?;
|
||||
if self.running() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut cmd = self.tool("pg_ctl");
|
||||
cmd
|
||||
.arg("start")
|
||||
.arg("-D")
|
||||
.arg(&self.data)
|
||||
.arg("-l")
|
||||
.arg(self.logs.join("postgres.log"))
|
||||
.args(["-w", "-t", "120", "-o"])
|
||||
.arg(format!("-p {PG_PORT} -c listen_addresses=127.0.0.1"));
|
||||
// The postmaster inherits pg_ctl's handles and outlives it, so a pipe would never
|
||||
// reach EOF: its output goes to a file.
|
||||
log_output(&mut cmd, &self.logs.join("pg_ctl.log"))?;
|
||||
let status = cmd.status().map_err(|e| format!("cannot run pg_ctl: {e}"))?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Postgres did not start ({status}). See state\\pg\\postgres.log."))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop(&self) -> Result<(), String> {
|
||||
if !self.is_initialized() || !self.running() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut cmd = self.tool("pg_ctl");
|
||||
cmd.arg("stop").arg("-D").arg(&self.data).args(["-m", "fast", "-w", "-t", "60"]);
|
||||
log_output(&mut cmd, &self.logs.join("pg_ctl.log"))?;
|
||||
let status = cmd.status().map_err(|e| format!("cannot run pg_ctl: {e}"))?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Postgres did not stop cleanly ({status})."))
|
||||
}
|
||||
}
|
||||
|
||||
fn psql(&self, database: &str, sql: &str) -> Result<String, String> {
|
||||
let port = PG_PORT.to_string();
|
||||
let mut cmd = self.tool("psql");
|
||||
cmd
|
||||
.args(["-X", "-A", "-t", "-v", "ON_ERROR_STOP=1", "-h", "127.0.0.1", "-p", port.as_str()])
|
||||
.args(["-U", PG_USER, "-d", database, "-c", sql]);
|
||||
Self::run(cmd, "psql")
|
||||
}
|
||||
|
||||
/// Replace the `odian` database with a snapshot. Only for a PC whose restore never
|
||||
/// completed: anything an operator did in the old database is dropped.
|
||||
pub fn restore(
|
||||
&self,
|
||||
data_root: &Path,
|
||||
files: &[DbFile],
|
||||
workers: usize,
|
||||
progress: RestoreProgress<'_>,
|
||||
cancelled: &(dyn Fn() -> bool + Sync),
|
||||
) -> Result<(), String> {
|
||||
self.ensure_logs()?;
|
||||
self.psql("postgres", &format!("DROP DATABASE IF EXISTS {PG_DATABASE}"))?;
|
||||
self.psql("postgres", &format!("CREATE DATABASE {PG_DATABASE} TEMPLATE template0 ENCODING 'UTF8'"))?;
|
||||
|
||||
let total_bytes: u64 = files.iter().map(|f| f.size).sum();
|
||||
let done_bytes = AtomicU64::new(0);
|
||||
let done_files = AtomicUsize::new(0);
|
||||
let finished = |file: &DbFile| {
|
||||
let bytes = done_bytes.fetch_add(file.size, Ordering::Relaxed) + file.size;
|
||||
let count = done_files.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
progress(bytes, total_bytes, count, files.len(), &file.path);
|
||||
};
|
||||
let section = |name: &'static str| files.iter().filter(move |f| f.section == name);
|
||||
|
||||
for file in section("pre-data") {
|
||||
self.restore_one(data_root, file, true)?;
|
||||
finished(file);
|
||||
}
|
||||
|
||||
let queue = Mutex::new(section("data").collect::<VecDeque<_>>());
|
||||
let failure: Mutex<Option<String>> = Mutex::new(None);
|
||||
std::thread::scope(|scope| {
|
||||
for _ in 0..workers.max(1) {
|
||||
scope.spawn(|| loop {
|
||||
if cancelled() || failure.lock().map(|f| f.is_some()).unwrap_or(true) {
|
||||
return;
|
||||
}
|
||||
let Some(file) = queue.lock().ok().and_then(|mut q| q.pop_front()) else {
|
||||
return;
|
||||
};
|
||||
match self.restore_one(data_root, file, false) {
|
||||
Ok(()) => finished(file),
|
||||
Err(message) => {
|
||||
if let Ok(mut slot) = failure.lock() {
|
||||
slot.get_or_insert(message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
if let Some(message) = failure.into_inner().unwrap_or(None) {
|
||||
return Err(message);
|
||||
}
|
||||
if cancelled() {
|
||||
return Err("cancelled".to_string());
|
||||
}
|
||||
|
||||
for file in section("sequences").chain(section("post-data")) {
|
||||
self.restore_one(data_root, file, false)?;
|
||||
finished(file);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restore_one(&self, data_root: &Path, file: &DbFile, pre_data: bool) -> Result<(), String> {
|
||||
let dump = file.path.split('/').fold(data_root.to_path_buf(), |path, part| path.join(part));
|
||||
let port = PG_PORT.to_string();
|
||||
let mut cmd = self.tool("pg_restore");
|
||||
cmd
|
||||
.args(["-h", "127.0.0.1", "-p", port.as_str(), "-U", PG_USER, "-d", PG_DATABASE])
|
||||
.args(["--no-owner", "--no-privileges", "--exit-on-error"]);
|
||||
if pre_data {
|
||||
cmd.arg("-L").arg(self.pre_data_list(&dump)?);
|
||||
}
|
||||
cmd.arg(&dump);
|
||||
Self::run(cmd, &format!("pg_restore {}", file.path)).map(|_| ())
|
||||
}
|
||||
|
||||
/// The pre-data table of contents without `CREATE SCHEMA public`: a fresh database
|
||||
/// already has that schema, and --exit-on-error would stop on the duplicate.
|
||||
fn pre_data_list(&self, dump: &Path) -> Result<PathBuf, String> {
|
||||
let mut cmd = self.tool("pg_restore");
|
||||
cmd.arg("-l").arg(dump);
|
||||
let listing = Self::run(cmd, "pg_restore -l")?;
|
||||
let kept: String = listing
|
||||
.lines()
|
||||
.filter(|line| !(line.contains(" SCHEMA - public ") || line.contains(" COMMENT - SCHEMA public ")))
|
||||
.map(|line| format!("{line}\n"))
|
||||
.collect();
|
||||
let path = self.logs.join("pre-data.list");
|
||||
std::fs::write(&path, kept).map_err(|e| format!("cannot write {}: {e}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user