mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 20:01:01 +00:00
Install current stable full images and qualify clean installation
This commit is contained in:
1 parent
52b80232fa
commit
97c024d95c
9 files changed
+183
-5
No files matched your search
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: changed
|
||||
audience: admins
|
||||
title: New servers install the current stable build directly
|
||||
---
|
||||
A clean installation downloads the complete package of the selected stable release, including its prepared database. It no longer installs an old base and asks for a separate update. Missing or mismatched full packages stop installation before changing the destination.
|
||||
@@ -24,7 +24,7 @@ pub struct Params<'a> {
|
||||
}
|
||||
|
||||
/// Auth tables that hold player data. Everything else (permissions, build info, realmlist) is configuration.
|
||||
const AUTH_PLAYER_TABLES: [&str; 11] =
|
||||
pub(crate) const AUTH_PLAYER_TABLES: [&str; 11] =
|
||||
["account", "account_access", "account_banned", "account_muted", "account_password_history", "ip_banned", "logs", "logs_ip_actions", "realmcharacters", "uptime", "rbac_account_permissions"];
|
||||
|
||||
fn copy_dir(src: &Path, dst: &Path, skip: &dyn Fn(&str) -> bool, base: &Path) -> Result<u64> {
|
||||
|
||||
@@ -201,6 +201,10 @@ fn bootstrap_database_inner(root: &Path) -> Result<()> {
|
||||
sql.push_str("FLUSH PRIVILEGES;\n");
|
||||
db.query(&sql)?;
|
||||
fsx::atomic_write_json(&target, &serde_json::json!({ "rootPassword": root_pw, "appPassword": app_pw }))?;
|
||||
fsx::atomic_write(&root.join("mysql/admin-client.ini"), format!(
|
||||
"[client]\nuser=root\npassword={root_pw}\nhost=127.0.0.1\nport={}\nprotocol=tcp\ndefault-character-set=utf8mb4\n",
|
||||
crate::layout::read_ports(root).mysql,
|
||||
).as_bytes())?;
|
||||
// The server console gets its own account with a random password instead of a shared default.
|
||||
let console_pw = Db::from_repack(root, Account::Admin)?.provision_service_account()?; // new root password
|
||||
db::write_console_credentials(root, &console_pw)?;
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::signing;
|
||||
/// Where a package comes from.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Source {
|
||||
Installation(Box<Source>),
|
||||
/// `https://.../` base URL holding manifest.json, manifest.json.sig and the parts.
|
||||
Url(String),
|
||||
/// A folder produced by `package::build` plus its `manifest.json.sig` (offline / local installs).
|
||||
@@ -23,6 +24,7 @@ pub enum Source {
|
||||
/// Read a small file (manifest, signature) from the source.
|
||||
pub fn fetch_small(source: &Source, name: &str) -> Result<Vec<u8>> {
|
||||
match source {
|
||||
Source::Installation(source) => fetch_small(source, &if matches!(name, "manifest.json" | "manifest.json.sig") { format!("install-{name}") } else { name.into() }),
|
||||
Source::Dir(d) => Ok(fs::read(fsx::safe_join(d, name)?).map_err(|_| Error::Invalid(format!("{name} was not found in the package folder.")))?),
|
||||
Source::Url(base) => {
|
||||
let url = format!("{}/{name}", base.trim_end_matches('/'));
|
||||
@@ -48,6 +50,14 @@ pub fn fetch_manifest(source: &Source, trusted_key: &str) -> Result<(Manifest, V
|
||||
let sig = String::from_utf8_lossy(&fetch_small(source, "manifest.json.sig")?).into_owned();
|
||||
signing::verify(&bytes, &sig, trusted_key)?;
|
||||
let manifest = Manifest::parse(&bytes)?;
|
||||
if let Source::Installation(release) = source {
|
||||
let (update, _) = fetch_manifest(release, trusted_key)?;
|
||||
if manifest.kind != crate::manifest::Kind::Base || update.kind != crate::manifest::Kind::Update || manifest.version != update.version
|
||||
|| manifest.core.commit != update.core.commit
|
||||
|| manifest.bots.as_ref().and_then(|value| value.commit.as_ref()) != update.bots.as_ref().and_then(|value| value.commit.as_ref()) {
|
||||
return Err(Error::InvalidManifest("The full installation package does not match the selected server release.".into()));
|
||||
}
|
||||
}
|
||||
Ok((manifest, bytes))
|
||||
}
|
||||
|
||||
@@ -68,6 +78,7 @@ pub fn human_bytes(n: u64) -> String {
|
||||
pub fn fetch_parts(source: &Source, manifest: &Manifest, download_dir: &Path, cancel: &Cancel, report: &dyn Fn(f64, Option<String>)) -> Result<PathBuf> {
|
||||
let archive = manifest.archive.as_ref().ok_or_else(|| Error::InvalidManifest("manifest has no archive".into()))?;
|
||||
match source {
|
||||
Source::Installation(source) => fetch_parts(source, manifest, download_dir, cancel, report),
|
||||
Source::Dir(d) => Ok(d.clone()),
|
||||
Source::Url(base) => {
|
||||
let all: u64 = archive.parts.iter().map(|p| p.size).sum::<u64>().max(1);
|
||||
@@ -90,6 +101,38 @@ pub fn fetch_parts(source: &Source, manifest: &Manifest, download_dir: &Path, ca
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clean_installation_requires_the_signed_full_image_of_the_selected_release() {
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
let folder = tempfile::tempdir().unwrap();
|
||||
let signing_key = SigningKey::from_bytes(&[23; 32]);
|
||||
let key = STANDARD.encode(signing_key.verifying_key().to_bytes());
|
||||
let source = Source::Installation(Box::new(Source::Dir(folder.path().into())));
|
||||
let mut manifest = serde_json::json!({"schema": 1, "kind": "update", "version": "0.261014.1",
|
||||
"core": {"commit": "a".repeat(40)}, "builtAt": "test", "minManagerVersion": "0.6.16",
|
||||
"files": [], "migrations": [], "archive": null});
|
||||
let write = |name: &str, value: &serde_json::Value| {
|
||||
let bytes = serde_json::to_vec(value).unwrap();
|
||||
fs::write(folder.path().join(name), &bytes).unwrap();
|
||||
fs::write(folder.path().join(format!("{name}.sig")), STANDARD.encode(signing_key.sign(&bytes).to_bytes())).unwrap();
|
||||
};
|
||||
write("manifest.json", &manifest);
|
||||
assert!(fetch_manifest(&source, &key).is_err());
|
||||
manifest["kind"] = "base".into();
|
||||
write("install-manifest.json", &manifest);
|
||||
assert_eq!(fetch_manifest(&source, &key).unwrap().0.version, "0.261014.1");
|
||||
manifest["version"] = "0.2.0".into();
|
||||
write("install-manifest.json", &manifest);
|
||||
assert!(fetch_manifest(&source, &key).is_err());
|
||||
manifest["version"] = "0.261014.1".into();
|
||||
manifest["core"]["commit"] = "b".repeat(40).into();
|
||||
write("install-manifest.json", &manifest);
|
||||
assert!(fetch_manifest(&source, &key).is_err());
|
||||
fs::write(folder.path().join("install-manifest.json"), b"tampered").unwrap();
|
||||
assert!(fetch_manifest(&source, &key).unwrap_err().to_string().contains("signature"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sizes_are_shown_in_binary_units() {
|
||||
assert_eq!(human_bytes(0), "0 B");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Database acceptance for release packages, using a freshly extracted signed base fixture.
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use crate::{backup, fsx, install, manifest::{Kind, Migration}, migrations, package, pkgsource, release::{self, SqlFile}, schema_check, Error, Result};
|
||||
|
||||
@@ -90,6 +91,66 @@ pub fn qualify_upgrade(base: &Path, source: &Path, candidate: &Path, fixture: &P
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn compose_install(base: &Path, candidate: &Path, fixture: &Path, trusted_key: &str) -> Result<()> {
|
||||
qualify_upgrade(base, base, candidate, fixture, trusted_key)?;
|
||||
let fixture = fsx::canonicalize_lenient(fixture)?;
|
||||
let (_, bytes) = pkgsource::fetch_manifest(&pkgsource::Source::Dir(candidate.into()), trusted_key)?;
|
||||
let manifest = crate::manifest::Manifest::parse(&bytes)?;
|
||||
install::with_scratch_ports(&fixture, || backup::with_database(&fixture, |db| {
|
||||
let players = db.query(&format!("SELECT COUNT(*) FROM acore_characters.characters WHERE account<>{};", crate::companions::TEMPLATE_ACCOUNT))?;
|
||||
if players.trim() != "0" || crate::companions::template_count(db)? != 21 {
|
||||
return Err(Error::Invalid("The installation image contains unexpected player characters.".into()));
|
||||
}
|
||||
for table in crate::cleanbase::AUTH_PLAYER_TABLES {
|
||||
if db.tables("acore_auth")?.iter().any(|name| name == table) {
|
||||
db.query(&format!("DELETE FROM acore_auth.`{table}`;"))?;
|
||||
}
|
||||
}
|
||||
if let Some(problem) = schema_check::check(db, &fixture)?.first() {
|
||||
return Err(Error::Invalid(format!("Installation image schema mismatch: {}.{}", problem.table, problem.column)));
|
||||
}
|
||||
Ok(())
|
||||
}))?;
|
||||
fs::remove_file(fixture.join("Core/configs/coa-qualification-user.conf"))?;
|
||||
let path = fixture.join("Settings/repack.json");
|
||||
let mut settings: serde_json::Value = fsx::read_json(&path)?;
|
||||
settings["raPassword"] = "".into();
|
||||
fsx::atomic_write_json(&path, &settings)?;
|
||||
fsx::atomic_write_json(&fixture.join("Settings/install-source.json"), &serde_json::json!({
|
||||
"version": manifest.version, "core": manifest.core, "bots": manifest.bots,
|
||||
"updateManifestSha256": fsx::sha256_bytes(&bytes),
|
||||
}))?;
|
||||
fs::remove_file(fixture.join(MARKER))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn qualify_install(package: &Path, fixture: &Path, trusted_key: &str) -> Result<serde_json::Value> {
|
||||
use crate::update::Env;
|
||||
if !cfg!(windows) || fixture.exists()
|
||||
|| !fixture.file_name().is_some_and(|name| name.to_string_lossy().starts_with("coa-schema-fixture-")) {
|
||||
return Err(Error::Invalid("Clean installation qualification requires a new disposable Windows fixture.".into()));
|
||||
}
|
||||
let fixture = std::path::absolute(fixture)?;
|
||||
let package = fsx::canonicalize_lenient(package)?;
|
||||
let (manifest, bytes) = pkgsource::fetch_manifest(&pkgsource::Source::Dir(package.clone()), trusted_key)?;
|
||||
let registry_dir = tempfile::tempdir()?;
|
||||
let registry = crate::registry::Registry::at(registry_dir.path().join("registry.json"));
|
||||
let installed = install::install_base(&install::Params {
|
||||
source: pkgsource::Source::Dir(package), dest: fixture.clone(), trusted_key,
|
||||
registry: ®istry, cancel: crate::download::Cancel::default(),
|
||||
}, &|_| {})?;
|
||||
let metadata = crate::registry::metadata_dir_for(&fixture)?;
|
||||
let env = QualificationEnv(crate::update::RepackEnv { root: &fixture, meta_dir: &metadata });
|
||||
env.validate()?;
|
||||
env.ensure_stopped()?;
|
||||
if installed.version != manifest.version || crate::update::pending_checked(&metadata)?.is_some() {
|
||||
return Err(Error::Invalid("Clean installation did not finish at the packaged release.".into()));
|
||||
}
|
||||
Ok(serde_json::json!({ "schema": 1, "result": "passed", "version": installed.version,
|
||||
"manifestSha256": fsx::sha256_bytes(&bytes),
|
||||
"checks": ["clean-install", "signed-full-package", "credential-rotation", "schema-contract", "private-auth-world-startup"] }))
|
||||
}
|
||||
|
||||
pub fn collect(core: &Path, bots: Option<&Path>, repairs: Option<&Path>) -> Result<Vec<SqlFile>> {
|
||||
let mut files = release::collect_core_sql(core)?;
|
||||
if let Some(bots) = bots { files.extend(release::collect_bots_sql(bots)?); }
|
||||
@@ -221,7 +282,15 @@ pub fn validate_startup(fixture: &Path, tree: &Path, base: &Path, trusted_key: &
|
||||
}
|
||||
|
||||
pub fn verify_package(dir: &Path, trusted_key: &str) -> Result<()> {
|
||||
let (manifest, _) = pkgsource::fetch_manifest(&pkgsource::Source::Dir(dir.into()), trusted_key)?;
|
||||
verify_package_source(dir, &pkgsource::Source::Dir(dir.into()), trusted_key)
|
||||
}
|
||||
|
||||
pub fn verify_install_package(dir: &Path, trusted_key: &str) -> Result<()> {
|
||||
verify_package_source(dir, &pkgsource::Source::Installation(Box::new(pkgsource::Source::Dir(dir.into()))), trusted_key)
|
||||
}
|
||||
|
||||
fn verify_package_source(dir: &Path, source: &pkgsource::Source, trusted_key: &str) -> Result<()> {
|
||||
let (manifest, _) = pkgsource::fetch_manifest(source, trusted_key)?;
|
||||
if !manifest.files.iter().any(|f| f.path == schema_check::CONTRACT && f.size > 0) {
|
||||
return Err(Error::Invalid("The release is missing its database schema contract.".into()));
|
||||
}
|
||||
@@ -250,6 +319,34 @@ mod tests {
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
|
||||
#[test]
|
||||
#[ignore = "Requires a composed disposable installation image"]
|
||||
fn real_full_package_installs_the_current_version_without_an_update_transaction() {
|
||||
let image = std::path::PathBuf::from(std::env::var_os("COA_INSTALL_IMAGE_DIR").expect("COA_INSTALL_IMAGE_DIR"));
|
||||
let provenance: serde_json::Value = fsx::read_json(&image.join("Settings/install-source.json")).unwrap();
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let package = root.path().join("package");
|
||||
let options = package::BuildOptions {
|
||||
kind: Kind::Base, version: provenance["version"].as_str().unwrap().into(),
|
||||
core_commit: provenance["core"]["commit"].as_str().map(String::from),
|
||||
bots_commit: provenance["bots"]["commit"].as_str().map(String::from),
|
||||
built_at: "qualification".into(), part_size: package::DEFAULT_PART_SIZE, migrations: vec![],
|
||||
};
|
||||
package::build(&image, &package, &options, &|_| {}).unwrap();
|
||||
let signing_key = SigningKey::generate(&mut rand_core::OsRng);
|
||||
let bytes = fs::read(package.join("manifest.json")).unwrap();
|
||||
fs::write(package.join("manifest.json.sig"), STANDARD.encode(signing_key.sign(&bytes).to_bytes())).unwrap();
|
||||
let trusted_key = STANDARD.encode(signing_key.verifying_key().to_bytes());
|
||||
let fixture = root.path().join("coa-schema-fixture-clean-install");
|
||||
let report = qualify_install(&package, &fixture, &trusted_key).unwrap();
|
||||
assert_eq!(report["version"], provenance["version"]);
|
||||
assert_eq!(report["result"], "passed");
|
||||
let metadata = crate::registry::metadata_dir_for(&fixture).unwrap();
|
||||
let (_, installed) = crate::registry::MetaDir::open(&metadata).unwrap();
|
||||
assert_eq!(installed.core.version.as_deref(), provenance["version"].as_str());
|
||||
assert!(!metadata.join("updates").read_dir().is_ok_and(|mut entries| entries.next().is_some()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_startup_fixture_targets_replace_the_original_location() {
|
||||
let directory = tempfile::Builder::new().prefix("coa-schema-fixture-path-test-").tempdir_in(".").unwrap();
|
||||
|
||||
@@ -47,6 +47,24 @@ fn run() -> Result<(), String> {
|
||||
let e = |x: coa_core::Error| x.to_string();
|
||||
let part = a.get("part-size").and_then(|v| v.parse().ok()).unwrap_or(DEFAULT_PART_SIZE);
|
||||
match cmd.as_str() {
|
||||
"verify-install" => {
|
||||
coa_core::release_schema::verify_install_package(
|
||||
&PathBuf::from(need(&a, "dir")?), coa_core::signing::EMBEDDED_PUBLIC_KEY,
|
||||
).map_err(e)?;
|
||||
}
|
||||
"compose-install" => {
|
||||
coa_core::release_schema::compose_install(
|
||||
&PathBuf::from(need(&a, "base")?), &PathBuf::from(need(&a, "candidate")?),
|
||||
&PathBuf::from(need(&a, "fixture")?), coa_core::signing::EMBEDDED_PUBLIC_KEY,
|
||||
).map_err(e)?;
|
||||
}
|
||||
"qualify-install" => {
|
||||
let report = coa_core::release_schema::qualify_install(
|
||||
&PathBuf::from(need(&a, "package")?), &PathBuf::from(need(&a, "fixture")?),
|
||||
coa_core::signing::EMBEDDED_PUBLIC_KEY,
|
||||
).map_err(e)?;
|
||||
coa_core::fsx::atomic_write_json(&PathBuf::from(need(&a, "report")?), &report).map_err(e)?;
|
||||
}
|
||||
"qualify-upgrade" => {
|
||||
let report = coa_core::release_schema::qualify_upgrade(
|
||||
&PathBuf::from(need(&a, "base")?), &PathBuf::from(need(&a, "source")?),
|
||||
|
||||
@@ -107,3 +107,5 @@ reproduced the missing alerts before the fix; all 12 frontend tests pass afterwa
|
||||
|
||||
This batch does not publish a Manager installer, promote a server package,
|
||||
close user reports or claim the complete fresh-install/update/gameplay matrix.
|
||||
|
||||
Clean Windows installs now select the full image attached to the same signed stable release used by updates. They never fall back to the legacy base when the current full image is absent. Explicit offline/custom full packages remain supported, while Repair and release upgrade fixtures retain the legacy base source. The release worker composes and validates the complete current database image before publication; users do not run an intermediate base server or an update transaction.
|
||||
@@ -63,6 +63,13 @@ fn package_source(custom: Option<String>) -> Source {
|
||||
}
|
||||
}
|
||||
|
||||
fn installation_source(custom: Option<String>) -> Result<Source> {
|
||||
let custom = custom.filter(|value| !value.trim().is_empty())
|
||||
.or_else(|| std::env::var("COA_PACKAGE_SOURCE").ok().filter(|value| !value.trim().is_empty()));
|
||||
if custom.is_some() { return Ok(package_source(custom)); }
|
||||
Ok(Source::Installation(Box::new(update_source(None)?)))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ServerSummary {
|
||||
id: String,
|
||||
@@ -519,7 +526,7 @@ async fn install_requirements(package: Option<String>) -> std::result::Result<In
|
||||
return docker_install::requirements(package).await;
|
||||
}
|
||||
blocking(move || {
|
||||
let (m, _) = coa_core::pkgsource::fetch_manifest(&package_source(package), coa_core::signing::EMBEDDED_PUBLIC_KEY)?;
|
||||
let (m, _) = coa_core::pkgsource::fetch_manifest(&installation_source(package)?, coa_core::signing::EMBEDDED_PUBLIC_KEY)?;
|
||||
let archive = m.archive.ok_or_else(|| Error::InvalidManifest("no archive".into()))?;
|
||||
Ok(InstallRequirements { download_bytes: archive.parts.iter().map(|p| p.size).sum(), unpacked_bytes: archive.unpacked_size, version: m.version })
|
||||
})
|
||||
@@ -533,10 +540,10 @@ async fn install_new(app: AppHandle, state: State<'_, AppState>, dest: String, p
|
||||
}
|
||||
let cancel = Cancel::default();
|
||||
*state.install_cancel.lock().map_err(|_| Error::Invalid("state poisoned".into()))? = Some(cancel.clone());
|
||||
let source = package_source(package);
|
||||
let registry = Registry::at(data_dir().join("installs.json"));
|
||||
let dest_path = PathBuf::from(dest);
|
||||
let result = tauri::async_runtime::spawn_blocking(move || {
|
||||
let source = installation_source(package)?;
|
||||
install::install_base(
|
||||
&install::Params { source, dest: dest_path, trusted_key: coa_core::signing::EMBEDDED_PUBLIC_KEY, registry: ®istry, cancel },
|
||||
&|step| {
|
||||
|
||||
+1
-1
@@ -96,7 +96,7 @@ export {};
|
||||
if(cmd==="set_game_data_folder") return args.path;
|
||||
if(cmd==="install_environment"){const docker=new URLSearchParams(location.search).get("flavor")==="docker";return docker?{flavor:"docker",default_dir:"/home/you/CoaServer",docker_problem:new URLSearchParams(location.search).has("nodocker")?"permission denied while trying to connect to the Docker daemon socket":null}:{flavor:"repack",default_dir:"C:\\Games\\CoA Server",docker_problem:null};}
|
||||
if(cmd==="install_preflight") return {ok:true,problems:[],free_bytes:210*2**30};
|
||||
if(cmd==="install_requirements") return {download_bytes:Math.round(5.9*2**30),unpacked_bytes:Math.round(11.4*2**30),version:"0.2.0"};
|
||||
if(cmd==="install_requirements") return {download_bytes:Math.round(5.9*2**30),unpacked_bytes:Math.round(11.4*2**30),version:"0.261014.1"};
|
||||
if(cmd==="list_accounts"){const w=window as any;w.__acc=w.__acc??[{id:2,name:"ALICE",access:3,online:true,last_login:"2026-10-01 12:04:11",characters:4},{id:5,name:"BOB",access:0,online:false,last_login:null,characters:0},{id:6,name:"CAROL",access:2,online:false,last_login:"2026-09-30 21:40:02",characters:2}];return w.__acc;}
|
||||
if(cmd==="account_set_access"){const a=(window as any).__acc.find((x: any)=>x.name===args.name);if(a)a.access=args.level;return null;}
|
||||
if(cmd==="account_set_password") return null;
|
||||
|
||||
Reference in new issue
Block a user