diff --git a/crates/coa-core/examples/preview_channel_e2e.rs b/crates/coa-core/examples/preview_channel_e2e.rs new file mode 100644 index 0000000..4a82f90 --- /dev/null +++ b/crates/coa-core/examples/preview_channel_e2e.rs @@ -0,0 +1,97 @@ +//! The published Modules Preview channel, end to end on a DISPOSABLE server, through the same source selection +//! the Manager uses when the owner does not choose a package: install from the stable channel's installation +//! image, check for updates (the preview channel), apply the preview update (modules are built on this PC and +//! the plugin host starts with them), then check again: the server must now be current. +//! +//! usage: preview_channel_e2e +//! The server gets its own free ports before anything starts; it never uses another server's database. +use std::path::Path; + +use coa_core::install::{install_base, Params, Source}; +use coa_core::registry::{MetaDir, Registry}; +use coa_core::update::{self, DatabaseAccess, Env, RepackEnv}; + +const KEY: &str = coa_core::signing::EMBEDDED_PUBLIC_KEY; + +fn free_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port() +} + +fn sql(root: &Path, q: &str) -> String { + coa_core::backup::with_database(root, |db| db.query(q)).unwrap_or_else(|e| panic!("{q}: {e}")) +} + +/// The Manager's "up to date" rule (src/lib/serverUpdateStatus.ts). +fn is_current(p: &update::Preview) -> bool { + p.from_version.as_deref() == Some(p.to_version.as_str()) + && p.items.iter().all(|i| format!("{:?}", i.action).eq_ignore_ascii_case("skip")) + && (p.database_check_error.is_some() || !p.database_checked || p.pending_migrations == 0) +} + +fn check(root: &Path, meta_dir: &Path) -> Result<(Source, update::Preview), Box> { + let (_, meta) = MetaDir::open(meta_dir)?; + let selection = coa_core::update_route::select(&meta, coa_core::channels::preview_update_source()?, KEY)?; + let preview = update::preview_with(root, &meta, &selection.source, KEY, &Default::default(), DatabaseAccess::Start)?; + Ok((selection.source, preview)) +} + +fn main() -> Result<(), Box> { + let a: Vec = std::env::args().skip(1).collect(); + assert_eq!(a.len(), 1, "usage: preview_channel_e2e "); + let root = Path::new(&a[0]); + assert!(a[0].ends_with("-fixture") && !root.exists(), "a new folder ending in -fixture is required"); + let reg_dir = std::env::temp_dir().join("coa-preview-channel-e2e-registry"); + let _ = std::fs::remove_dir_all(®_dir); + let reg = Registry::at(reg_dir.join("installs.json")); + + // 1. New server: the Manager installs the stable channel's image (the preview publishes updates only). + let image = coa_core::channels::stable_source()?; + println!("installation image: {image:?}"); + let t = std::time::Instant::now(); + let installed = install_base( + &Params { source: Source::Installation(Box::new(image)), dest: root.into(), trusted_key: KEY, registry: ®, cancel: Default::default() }, + &|s| eprintln!(" {:>3}% {}", s.percent, s.step), + )?; + println!("installed {} in {:?}", installed.version, t.elapsed()); + + let repack = root.join("Settings/repack.json"); + let mut cfg: serde_json::Value = serde_json::from_slice(&std::fs::read(&repack)?)?; + for k in ["mysqlPort", "authPort", "worldPort", "raPort"] { + cfg[k] = free_port().into(); + } + std::fs::write(&repack, serde_json::to_vec_pretty(&cfg)?)?; + sql(root, "INSERT INTO acore_auth.account (username, salt, verifier, email, reg_mail) \ + VALUES ('E2ECHANNEL', UNHEX(REPEAT('11',32)), UNHEX(REPEAT('22',32)), '', '')"); + let accounts = sql(root, "SELECT COUNT(*) FROM acore_auth.account").trim().to_string(); + + // 2. Check for updates: the preview channel offers the preview. + let meta_dir = coa_core::registry::metadata_dir_for(root)?; + let (source, preview) = check(root, &meta_dir)?; + println!("check: {:?} -> {} from {source:?}, {} items, {} migrations", preview.from_version, preview.to_version, preview.items.len(), preview.migrations); + assert!(!is_current(&preview), "a fresh stable server must be offered the preview"); + + // 3. Apply it, exactly as the Update button does. + let env = RepackEnv { root, meta_dir: &meta_dir }; + let t = std::time::Instant::now(); + let outcome = update::apply( + &update::Params { root, meta_dir: &meta_dir, source, trusted_key: KEY, cancel: Default::default(), resolutions: Default::default(), env: &env, fail_after_ops: None }, + &|phase, percent| println!("{percent}% {phase}"), + ); + let _ = coa_core::driver::run(root, coa_core::driver::Verb::StopAll); + let outcome = outcome?; + println!("update {:?} to {:?} in {:?}; modules {:?}", outcome.txn.state, outcome.txn.to_version, t.elapsed(), outcome.modules); + if let Ok(t) = std::fs::read_to_string(root.join("Core/plugins/plugin-boot-report.json")) { + println!("--- boot report\n{t}"); + } + assert_eq!(outcome.txn.state, update::State::Committed); + + // 4. Check again: nothing newer, nothing to change. + let (_, again) = check(root, &meta_dir)?; + let changing: Vec<_> = again.items.iter().filter(|i| !format!("{:?}", i.action).eq_ignore_ascii_case("skip")).map(|i| format!("{i:?}")).collect(); + println!("second check: {:?} -> {}, pending migrations {}, changing items {changing:?}", again.from_version, again.to_version, again.pending_migrations); + assert_eq!(sql(root, "SELECT COUNT(*) FROM acore_auth.account").trim(), accounts, "accounts changed"); + env.ensure_stopped()?; + assert!(is_current(&again), "the updated server is not reported as current"); + println!("PASS"); + Ok(()) +} diff --git a/crates/coa-core/examples/preview_migration_e2e.rs b/crates/coa-core/examples/preview_migration_e2e.rs index 8f05a46..eea6da9 100644 --- a/crates/coa-core/examples/preview_migration_e2e.rs +++ b/crates/coa-core/examples/preview_migration_e2e.rs @@ -3,7 +3,9 @@ //! builds the recommended modules and starts the plugin-host core with them), and check that the data and the //! setting survived. //! -//! usage: preview_migration_e2e +//! usage: preview_migration_e2e [squid] +//! With `squid` the stable server runs SQUID's Playerbots (started once so its SQL is imported) before the update; +//! SQUID's own SQL journals must come through unchanged. //! The server gets its own free ports before anything starts; it never uses another server's database. use std::path::Path; @@ -26,9 +28,30 @@ fn fingerprint(root: &Path) -> String { FROM acore_auth.account a JOIN acore_characters.characters c ON c.account = a.id WHERE a.username = 'E2EPREVIEW'") } +/// SQUID's journals and its database, read the same way before and after (absent tables read as "-"). +fn squid_journals(root: &Path) -> String { + let mut out = Vec::new(); + for db in ["acore_characters", "acore_world", "acore_playerbots"] { + for t in ["coa_squid_migrations", "coa_squid_pending", "coa_bots_installed"] { + let exists = sql(root, &format!("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='{db}' AND TABLE_NAME='{t}'")); + if exists.trim() == "0" { + out.push(format!("{db}.{t}=-")); + continue; + } + let cols = sql(root, &format!("SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY ORDINAL_POSITION) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='{db}' AND TABLE_NAME='{t}'")); + out.push(format!("{db}.{t}={}", sql(root, &format!( + "SELECT CONCAT(COUNT(*), ':', COALESCE(SUM(CRC32(CONCAT_WS('|', {}))), 0)) FROM {db}.{t}", cols.trim())) + .trim())); + } + } + out.push(format!("playerbots tables={}", sql(root, "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='acore_playerbots'").trim())); + out.join(" ") +} + fn main() -> Result<(), Box> { let a: Vec = std::env::args().skip(1).collect(); - assert_eq!(a.len(), 3, "usage: preview_migration_e2e "); + assert!(a.len() == 3 || (a.len() == 4 && a[3] == "squid"), "usage: preview_migration_e2e [squid]"); + let squid = a.len() == 4; let root = Path::new(&a[0]); assert!(a[0].ends_with("-fixture") && !root.exists(), "a new folder ending in -fixture is required"); let reg_dir = std::env::temp_dir().join("coa-preview-e2e-registry"); @@ -58,6 +81,17 @@ fn main() -> Result<(), Box> { std::fs::write(&repack, serde_json::to_vec_pretty(&cfg)?)?; println!("ports {}", serde_json::json!({"mysql": cfg["mysqlPort"], "auth": cfg["authPort"], "world": cfg["worldPort"], "ra": cfg["raPort"]})); + let meta = coa_core::registry::metadata_dir_for(root)?; + if squid { + // The owner switches the bots to SQUID (Companions off) and starts the server once: the launcher imports + // SQUID's SQL into its own journals. + coa_core::modules::set_enabled(root, &meta, "playerbots", true)?; + let started = coa_core::driver::run(root, coa_core::driver::Verb::StartAll)?; + println!("stable with SQUID started: {}", started.ok); + let stopped = coa_core::driver::run(root, coa_core::driver::Verb::StopAll)?; + assert!(started.ok && stopped.ok, "the stable server with SQUID did not start and stop"); + } + // 3. Test data: an account with a character and a spell; a changed module setting. let cols = sql(root, "SELECT GROUP_CONCAT(CONCAT(COLUMN_NAME, ':', DATA_TYPE)) FROM information_schema.COLUMNS \ WHERE TABLE_SCHEMA='acore_characters' AND TABLE_NAME='characters' AND IS_NULLABLE='NO' AND COLUMN_DEFAULT IS NULL"); @@ -82,11 +116,12 @@ fn main() -> Result<(), Box> { let changed = format!("{} = 7", line.split(" = ").next().unwrap().trim()); std::fs::write(&conf, text.replacen(&line, &changed, 1))?; let before = fingerprint(root); + let journals_before = squid_journals(root); + println!("squid journals before: {journals_before}"); println!("before: {before}\nsetting: {changed}"); assert!(!before.trim().is_empty()); // 4. The preview update, through the production updater. - let meta = coa_core::registry::metadata_dir_for(root)?; let (_, info) = MetaDir::open(&meta)?; assert_eq!(coa_core::fsx::canonicalize_lenient(Path::new(&info.server_path))?, coa_core::fsx::canonicalize_lenient(root)?); let env = RepackEnv { root, meta_dir: &meta }; @@ -95,7 +130,7 @@ fn main() -> Result<(), Box> { &update::Params { root, meta_dir: &meta, - source: Source::Dir(a[2].clone().into()), + source: if a[2].starts_with("http") { Source::Url(a[2].clone()) } else { Source::Dir(a[2].clone().into()) }, trusted_key: coa_core::signing::EMBEDDED_PUBLIC_KEY, cancel: Default::default(), resolutions: Default::default(), @@ -111,6 +146,8 @@ fn main() -> Result<(), Box> { // 5. Same data, same setting, plugin host with its modules. let after = fingerprint(root); + let journals_after = squid_journals(root); + println!("squid journals after: {journals_after}"); let text = std::fs::read_to_string(&conf)?; println!("after: {after}"); println!("setting kept: {}", text.lines().any(|l| l.trim() == changed)); @@ -123,6 +160,14 @@ fn main() -> Result<(), Box> { assert_eq!(outcome.txn.state, update::State::Committed); assert_eq!(before, after, "seeded character data changed"); assert!(text.lines().any(|l| l.trim() == changed), "module setting lost"); + assert_eq!(journals_before, journals_after, "SQUID's journals changed"); + let ids: Vec = outcome.modules.iter().map(|m| format!("{m:?}")).collect(); + let has = |id: &str| ids.iter().any(|m| m.contains(&format!("\"{id}\""))); + if squid { + assert!(has("playerbots") && !has("companions"), "SQUID server must get SQUID's Playerbots, not Companions: {ids:?}"); + } else { + assert!(has("companions") && !has("playerbots"), "Companions server must get Companions, not SQUID: {ids:?}"); + } println!("PASS"); Ok(()) } diff --git a/crates/coa-core/src/channels.rs b/crates/coa-core/src/channels.rs index 4dc5271..d28f0f1 100644 --- a/crates/coa-core/src/channels.rs +++ b/crates/coa-core/src/channels.rs @@ -78,6 +78,29 @@ pub fn resolve(channel: &str) -> Result { ))) } +/// The stable release used when the stable pointer has not been published yet. +pub const STABLE_FALLBACK_URL: &str = + "https://github.com/Corfirean/coa-server-build/releases/download/stable"; + +/// Stable updates and installation images: the signed stable pointer, else the fixed stable release. +pub fn stable_source() -> Result { + match resolve("stable") { + Ok(source) => Ok(source), + Err(Error::PackageNotPublished(_)) => Ok(Source::Url(STABLE_FALLBACK_URL.into())), + Err(error) => Err(error), + } +} + +/// Where the Modules Preview Manager looks for server updates when none is chosen: its own signed channel, +/// or the stable channel until a preview exists (a stable server is where every preview server starts). +pub fn preview_update_source() -> Result { + match resolve("preview") { + Ok(source) => Ok(source), + Err(Error::PackageNotPublished(_)) => stable_source(), + Err(error) => Err(error), + } +} + pub fn sign(pointer: &Pointer, seed: &str) -> Result> { use base64::{engine::general_purpose::STANDARD, Engine}; use ed25519_dalek::{Signer, SigningKey}; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7e406d0..747e064 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -263,11 +263,6 @@ async fn portable_launch( } } -/// Where official server packages are published (created by the release pipeline, Phase 6). -/// Where signed update packages are published; override with COA_UPDATE_SOURCE (URL or local package folder). -const DEFAULT_UPDATE_URL: &str = - "https://github.com/Corfirean/coa-server-build/releases/download/stable"; - fn update_source(custom: Option) -> Result { let pick = custom .filter(|s| !s.trim().is_empty()) @@ -275,17 +270,7 @@ fn update_source(custom: Option) -> Result { match pick { Some(p) if p.to_ascii_lowercase().starts_with("http") => Ok(Source::Url(p)), Some(p) => Ok(Source::Dir(PathBuf::from(p))), - // The Modules Preview follows its own signed channel; until the first preview release exists it - // falls back to the stable channel (a stable server is where every preview server starts). - None => match coa_core::channels::resolve("preview") { - Ok(source) => Ok(source), - Err(Error::PackageNotPublished(_)) => match coa_core::channels::resolve("stable") { - Ok(source) => Ok(source), - Err(Error::PackageNotPublished(_)) => Ok(Source::Url(DEFAULT_UPDATE_URL.into())), - Err(error) => Err(error), - }, - Err(error) => Err(error), - }, + None => coa_core::channels::preview_update_source(), } } @@ -311,15 +296,7 @@ fn installation_source(custom: Option) -> Result { }); // A new server is installed from the qualified stable image; the preview channel publishes updates only, // so the preview core arrives as the first update (the same path every existing server takes). - select_installation_source(custom, stable_update_source) -} - -fn stable_update_source() -> Result { - match coa_core::channels::resolve("stable") { - Ok(source) => Ok(source), - Err(Error::PackageNotPublished(_)) => Ok(Source::Url(DEFAULT_UPDATE_URL.into())), - Err(error) => Err(error), - } + select_installation_source(custom, coa_core::channels::stable_source) } fn select_installation_source(