Channel source selection lives in coa-core; e2e checks for the published channel and SQUID servers

preview_channel_e2e installs from the stable channel, takes the preview from the published preview channel
the way the Manager does, and checks the server is current afterwards. preview_migration_e2e gains a squid
mode that keeps SQUID's SQL journals and expects SQUID's Playerbots instead of Companions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ДмитрийandClaude Opus 5.5 committed 2026-11-06 07:23:41 +05:00
1 parent 4facefc5dc
commit 048d746d29
4 files changed
+171 -29

No files matched your search

@@ -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 <new folder ending in -fixture>
//! 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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
let a: Vec<String> = std::env::args().skip(1).collect();
assert_eq!(a.len(), 1, "usage: preview_channel_e2e <new-folder-fixture>");
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(&reg_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: &reg, 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(())
}
@@ -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 <new folder ending in -fixture> <stable release URL> <preview package dir>
//! usage: preview_migration_e2e <new folder ending in -fixture> <stable release URL> <preview package dir or URL> [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<dyn std::error::Error>> {
let a: Vec<String> = std::env::args().skip(1).collect();
assert_eq!(a.len(), 3, "usage: preview_migration_e2e <new-folder-fixture> <stable release URL> <preview package dir>");
assert!(a.len() == 3 || (a.len() == 4 && a[3] == "squid"), "usage: preview_migration_e2e <new-folder-fixture> <stable release URL> <preview package dir or URL> [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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
&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<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
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<String> = 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(())
}
+23
View File
@@ -78,6 +78,29 @@ pub fn resolve(channel: &str) -> Result<Source> {
)))
}
/// 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<Source> {
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<Source> {
match resolve("preview") {
Ok(source) => Ok(source),
Err(Error::PackageNotPublished(_)) => stable_source(),
Err(error) => Err(error),
}
}
pub fn sign(pointer: &Pointer, seed: &str) -> Result<Vec<u8>> {
use base64::{engine::general_purpose::STANDARD, Engine};
use ed25519_dalek::{Signer, SigningKey};
+2 -25
View File
@@ -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<String>) -> Result<Source> {
let pick = custom
.filter(|s| !s.trim().is_empty())
@@ -275,17 +270,7 @@ fn update_source(custom: Option<String>) -> Result<Source> {
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<String>) -> Result<Source> {
});
// 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<Source> {
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(