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:
Kelorastus
2026-09-15 15:23:17 +02:00
commit b7f3061be3
102 changed files with 47611 additions and 0 deletions
+249
View File
@@ -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)
}
}