mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-12 20:11:02 +00:00
Merge pull request #13 from lostmind84/feat/linux-b2-container-db
feat: database access through the container for Docker installations
This commit is contained in:
3 files changed
+127
-6
No files matched your search
+115
-4
@@ -1,4 +1,5 @@
|
||||
//! Database access through the MySQL client tools bundled with the repack (`mysql`, `mysqldump`, `mysqladmin`).
|
||||
//! Database access through the MySQL client tools bundled with the repack (`mysql`, `mysqldump`, `mysqladmin`), or,
|
||||
//! for a Docker installation, the same tools run inside the database container with `docker exec`.
|
||||
//!
|
||||
//! Passwords travel only in the child's environment (`MYSQL_PWD`), never on a command line or in a log.
|
||||
|
||||
@@ -45,6 +46,8 @@ pub struct Db {
|
||||
user: &'static str,
|
||||
password: Secret,
|
||||
realm: crate::realms::Mode,
|
||||
/// Name of the database container of a Docker installation; the client tools then run inside it.
|
||||
container: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -89,6 +92,9 @@ impl Db {
|
||||
let creds: Credentials = fsx::read_json(&root.join("Settings/database.json"))
|
||||
.map_err(|_| Error::Invalid("The database settings of this server could not be read.".into()))?;
|
||||
let ports: Ports = read_ports(root);
|
||||
if crate::docker::is_docker(root) {
|
||||
return Db::in_container(root, creds, account);
|
||||
}
|
||||
let bin = root.join("mysql/bin");
|
||||
if !bin.join("mysqldump.exe").is_file() || !bin.join("mysql.exe").is_file() {
|
||||
return Err(Error::Invalid("The bundled database tools are missing.".into()));
|
||||
@@ -97,7 +103,20 @@ impl Db {
|
||||
Account::Admin => ("root", creds.root_password),
|
||||
Account::App => ("acore", creds.app_password),
|
||||
};
|
||||
Ok(Db { bin, port: ports.mysql, user, password: Secret(password), realm: crate::realms::state(root)?.active })
|
||||
Ok(Db { bin, port: ports.mysql, user, password: Secret(password), realm: crate::realms::state(root)?.active, container: None })
|
||||
}
|
||||
|
||||
/// The database of a Docker installation: the client tools of the database image, run with `docker exec`. The
|
||||
/// database is not published on the host, so nothing about this depends on a port or on tools installed here.
|
||||
fn in_container(root: &Path, creds: Credentials, account: Account) -> Result<Db> {
|
||||
let container = crate::docker::Config::load(root)?.database_container();
|
||||
let (user, password) = match account {
|
||||
Account::Admin => ("root", creds.root_password),
|
||||
Account::App => ("acore", creds.app_password),
|
||||
};
|
||||
// Inside the container the server listens on its standard port; the commands below connect to it over TCP on
|
||||
// the container's own loopback, exactly like the commands of a repack do on the host.
|
||||
Ok(Db { bin: PathBuf::new(), port: 3306, user, password: Secret(password), realm: crate::realms::state(root)?.active, container: Some(container) })
|
||||
}
|
||||
|
||||
pub fn for_realm(mut self, realm: crate::realms::Mode) -> Self { self.realm = realm; self }
|
||||
@@ -133,7 +152,15 @@ impl Db {
|
||||
}
|
||||
|
||||
fn command(&self, tool: &str) -> Command {
|
||||
let mut c = Command::new(self.bin.join(tool));
|
||||
let mut c = match &self.container {
|
||||
None => Command::new(self.bin.join(tool)),
|
||||
Some(name) => {
|
||||
// `-e MYSQL_PWD` without a value takes it from this process's environment: never on a command line.
|
||||
let mut c = Command::new("docker");
|
||||
c.args(["exec", "-i", "-e", "MYSQL_PWD", name, tool.trim_end_matches(".exe")]);
|
||||
c
|
||||
}
|
||||
};
|
||||
c.env("MYSQL_PWD", self.password.expose())
|
||||
.arg("--protocol=tcp")
|
||||
.arg("--host=127.0.0.1")
|
||||
@@ -148,7 +175,8 @@ impl Db {
|
||||
|
||||
fn fail(&self, stderr: &[u8]) -> Error {
|
||||
let text = String::from_utf8_lossy(stderr);
|
||||
let code = if text.contains("2003") || text.contains("Can't connect") { ErrorCode::DatabaseNotRunning } else { ErrorCode::Unknown };
|
||||
let down = text.contains("2003") || text.contains("Can't connect") || text.contains("is not running") || text.contains("No such container");
|
||||
let code = if down { ErrorCode::DatabaseNotRunning } else { ErrorCode::Unknown };
|
||||
match code {
|
||||
ErrorCode::DatabaseNotRunning => Error::Invalid("The database is not running. Start the server first.".into()),
|
||||
_ => Error::Invalid(format!("database command failed: {}", text.trim().lines().last().unwrap_or(""))),
|
||||
@@ -380,3 +408,86 @@ mod realm_tests {
|
||||
assert_eq!(route_sql(sql, crate::realms::Mode::Wildcard), "SELECT * FROM `acore_characters_wildcard`.characters WHERE name='acore_world' AND x=\"acore_characters\"; -- acore_world\nSELECT * FROM acore_world_wildcard.items;");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::ffi::OsStr;
|
||||
|
||||
const ROOT_PW: &str = "rootpw-ZZ1";
|
||||
const APP_PW: &str = "apppw-QQ2";
|
||||
|
||||
fn folder(docker: bool) -> (tempfile::TempDir, PathBuf) {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let root = d.path().join("srv");
|
||||
std::fs::create_dir_all(root.join("Settings")).unwrap();
|
||||
std::fs::write(root.join("Settings/database.json"), format!(r#"{{"rootPassword":"{ROOT_PW}","appPassword":"{APP_PW}"}}"#)).unwrap();
|
||||
if docker {
|
||||
std::fs::write(root.join("Settings/docker.json"), r#"{"project":"t1"}"#).unwrap();
|
||||
}
|
||||
(d, root)
|
||||
}
|
||||
|
||||
fn args(c: &Command) -> Vec<String> {
|
||||
c.get_args().map(|a| a.to_string_lossy().into_owned()).collect()
|
||||
}
|
||||
|
||||
fn env_of(c: &Command, key: &str) -> Option<String> {
|
||||
c.get_envs().find(|(k, _)| *k == OsStr::new(key)).and_then(|(_, v)| v.map(|v| v.to_string_lossy().into_owned()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_docker_installation_runs_the_client_tools_inside_the_database_container() {
|
||||
let (_d, root) = folder(true);
|
||||
let db = Db::from_repack(&root, Account::Admin).unwrap();
|
||||
for (tool, program) in [("mysql.exe", "mysql"), ("mysqldump.exe", "mysqldump"), ("mysqladmin.exe", "mysqladmin")] {
|
||||
let c = db.command(tool);
|
||||
assert_eq!(c.get_program(), "docker");
|
||||
let a = args(&c);
|
||||
assert_eq!(&a[..6], ["exec", "-i", "-e", "MYSQL_PWD", "coa-t1-db", program]);
|
||||
assert!(a.contains(&"--protocol=tcp".to_string()) && a.contains(&"--host=127.0.0.1".to_string()) && a.contains(&"--port=3306".to_string()));
|
||||
assert!(a.contains(&"--user=root".to_string()));
|
||||
assert!(a.iter().all(|x| !x.contains(ROOT_PW)), "the password must not be on the command line");
|
||||
assert_eq!(env_of(&c, "MYSQL_PWD").as_deref(), Some(ROOT_PW));
|
||||
}
|
||||
let app = Db::from_repack(&root, Account::App).unwrap().command("mysql.exe");
|
||||
assert!(args(&app).contains(&"--user=acore".to_string()));
|
||||
assert_eq!(env_of(&app, "MYSQL_PWD").as_deref(), Some(APP_PW));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repack_still_uses_its_bundled_tools_on_the_host() {
|
||||
let (_d, root) = folder(false);
|
||||
std::fs::create_dir_all(root.join("mysql/bin")).unwrap();
|
||||
for f in ["mysql.exe", "mysqldump.exe"] {
|
||||
std::fs::write(root.join("mysql/bin").join(f), b"x").unwrap();
|
||||
}
|
||||
std::fs::write(root.join("Settings/repack.json"), r#"{"mysqlPort":3999}"#).unwrap();
|
||||
let c = Db::from_repack(&root, Account::Admin).unwrap().command("mysql.exe");
|
||||
assert_eq!(c.get_program(), root.join("mysql/bin").join("mysql.exe").as_os_str());
|
||||
let a = args(&c);
|
||||
assert!(!a.contains(&"exec".to_string()));
|
||||
assert!(a.contains(&"--port=3999".to_string()) && a.contains(&"--host=127.0.0.1".to_string()));
|
||||
assert_eq!(env_of(&c, "MYSQL_PWD").as_deref(), Some(ROOT_PW));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_docker_installation_does_not_need_tools_in_the_server_folder() {
|
||||
let (_d, root) = folder(true);
|
||||
assert!(!root.join("mysql").exists());
|
||||
assert!(Db::from_repack(&root, Account::Admin).is_ok());
|
||||
// ...but a broken Docker settings file is reported, not ignored.
|
||||
std::fs::write(root.join("Settings/docker.json"), r#"{"project":"Bad Name"}"#).unwrap();
|
||||
assert!(Db::from_repack(&root, Account::Admin).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stopped_database_container_is_reported_as_a_database_that_is_not_running() {
|
||||
let (_d, root) = folder(true);
|
||||
let db = Db::from_repack(&root, Account::Admin).unwrap();
|
||||
for text in ["Error response from daemon: container abc is not running", "Error response from daemon: No such container: coa-t1-db", "ERROR 2003 (HY000): Can't connect to MySQL server"] {
|
||||
assert!(db.fail(text.as_bytes()).to_string().contains("not running"), "{text}");
|
||||
}
|
||||
assert!(db.fail(b"ERROR 1045 (28000): Access denied for user 'root'").to_string().contains("Access denied"));
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,11 @@ impl Config {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Name of the database container (the one `docker exec` talks to).
|
||||
pub(crate) fn database_container(&self) -> String {
|
||||
self.names().db
|
||||
}
|
||||
|
||||
pub(crate) fn names(&self) -> Names {
|
||||
let p = &self.project;
|
||||
Names { network: format!("coa-{p}"), volume: format!("coa-{p}-db"), db: format!("coa-{p}-db"), world: format!("coa-{p}-world"), auth: format!("coa-{p}-auth") }
|
||||
|
||||
@@ -365,8 +365,13 @@ hand over to `coa_core::docker`; nothing else needed to change for start, stop,
|
||||
inside listens, so a plain connect proves nothing.
|
||||
* **Configuration path in the binaries**: Linux builds must use `-DCONF_DIR=configs` (see `coa-server-build`),
|
||||
otherwise the core looks for module configs in a path baked in at build time.
|
||||
* **Not done yet**: installation and the database setup (schemas, `acore` user), backups and database access through the
|
||||
container, updates, port conflict detection, the firewall and exposure checks, and the client under Wine/Proton.
|
||||
* **Database access** (`db.rs`): for a Docker installation `Db` runs `mysql`, `mysqldump` and `mysqladmin` inside the
|
||||
database container with `docker exec -i`, connecting over TCP on the container's loopback exactly as the repack's
|
||||
commands do on the host (password through `MYSQL_PWD`, never on a command line). Nothing else changes: accounts,
|
||||
backups, the population query, migrations and the RA service account all go through `Db`, and the host needs no
|
||||
MySQL client. A repack keeps using its bundled tools.
|
||||
* **Not done yet**: installation and the database setup (schemas, `acore` user), updates, port conflict detection,
|
||||
the firewall and exposure checks, the Wildcard realm profiles, and the client under Wine/Proton.
|
||||
|
||||
---------------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
Reference in new issue
Block a user