her sey
This commit is contained in:
@@ -1,26 +1,38 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::Cursor;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use anyhow::Result;
|
||||
use bollard::container::{
|
||||
AttachContainerOptions, Config, CreateContainerOptions, LogsOptions, RemoveContainerOptions, StartContainerOptions,
|
||||
StopContainerOptions, StatsOptions, Stats,
|
||||
AttachContainerOptions, Config, CreateContainerOptions, ListContainersOptions, LogsOptions, RemoveContainerOptions, StartContainerOptions,
|
||||
StopContainerOptions, StatsOptions, Stats, UploadToContainerOptions,
|
||||
};
|
||||
use bollard::image::CreateImageOptions;
|
||||
use bollard::models::{HostConfig, PortBinding};
|
||||
use bollard::models::{HostConfig, MountPointTypeEnum, PortBinding};
|
||||
use futures::StreamExt;
|
||||
use tokio::time::{sleep, Duration};
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::docker::DockerManager;
|
||||
use crate::server::ServerSpec;
|
||||
use crate::server::state::ServerState;
|
||||
|
||||
/// Container name prefix for all managed game servers.
|
||||
const CONTAINER_PREFIX: &str = "gp_";
|
||||
const SATISFACTORY_RUN_SH: &str = include_str!("../game/satisfactory_run.sh");
|
||||
|
||||
pub fn container_name(server_uuid: &str) -> String {
|
||||
format!("{}{}", CONTAINER_PREFIX, server_uuid)
|
||||
}
|
||||
|
||||
fn uuid_from_container_name(name: &str) -> Option<String> {
|
||||
let trimmed = name.trim_start_matches('/');
|
||||
trimmed
|
||||
.strip_prefix(CONTAINER_PREFIX)
|
||||
.filter(|uuid| !uuid.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn container_data_path_for_image(image: &str) -> &'static str {
|
||||
let normalized = image.to_ascii_lowercase();
|
||||
if normalized.contains("cm2network/cs2") || normalized.contains("joedwards32/cs2") {
|
||||
@@ -29,9 +41,30 @@ fn container_data_path_for_image(image: &str) -> &'static str {
|
||||
if normalized.contains("cm2network/csgo") {
|
||||
return "/home/steam/csgo-dedicated";
|
||||
}
|
||||
if normalized.contains("spritsail/fivem") {
|
||||
return "/config";
|
||||
}
|
||||
if normalized.contains("wolveix/satisfactory-server") {
|
||||
return "/config";
|
||||
}
|
||||
"/data"
|
||||
}
|
||||
|
||||
fn is_wolveix_satisfactory_image(image: &str) -> bool {
|
||||
image
|
||||
.to_ascii_lowercase()
|
||||
.contains("wolveix/satisfactory-server")
|
||||
}
|
||||
|
||||
fn server_state_from_container_status(status: &str) -> ServerState {
|
||||
match status {
|
||||
"running" | "restarting" | "paused" => ServerState::Running,
|
||||
"created" | "exited" => ServerState::Stopped,
|
||||
"dead" => ServerState::Error,
|
||||
_ => ServerState::Error,
|
||||
}
|
||||
}
|
||||
|
||||
impl DockerManager {
|
||||
async fn attach_command_stream(
|
||||
&self,
|
||||
@@ -136,6 +169,33 @@ impl DockerManager {
|
||||
Err(anyhow::anyhow!("exec command timeout"))
|
||||
}
|
||||
|
||||
async fn patch_satisfactory_run_script(&self, container_name: &str) -> Result<()> {
|
||||
let mut archive = Vec::new();
|
||||
{
|
||||
let mut builder = tar::Builder::new(&mut archive);
|
||||
let bytes = SATISFACTORY_RUN_SH.as_bytes();
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_mode(0o755);
|
||||
header.set_cksum();
|
||||
builder.append_data(&mut header, "run.sh", Cursor::new(bytes))?;
|
||||
builder.finish()?;
|
||||
}
|
||||
|
||||
self.client()
|
||||
.upload_to_container(
|
||||
container_name,
|
||||
Some(UploadToContainerOptions {
|
||||
path: "/home/steam",
|
||||
no_overwrite_dir_non_dir: "false",
|
||||
}),
|
||||
archive.into(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rcon_command(&self, server_uuid: &str, command: &str) -> Result<String> {
|
||||
let name = container_name(server_uuid);
|
||||
self.run_exec(&name, vec!["rcon-cli".to_string(), command.to_string()])
|
||||
@@ -248,6 +308,10 @@ impl DockerManager {
|
||||
let options = CreateContainerOptions { name: name.as_str(), platform: None };
|
||||
let response = self.client().create_container(Some(options), config).await?;
|
||||
|
||||
if is_wolveix_satisfactory_image(&spec.docker_image) {
|
||||
self.patch_satisfactory_run_script(&name).await?;
|
||||
}
|
||||
|
||||
info!(container_id = %response.id, uuid = %spec.uuid, "Container created");
|
||||
Ok(response.id)
|
||||
}
|
||||
@@ -380,6 +444,162 @@ impl DockerManager {
|
||||
Ok((image, env_map))
|
||||
}
|
||||
|
||||
/// Discover existing managed containers and rebuild in-memory server specs.
|
||||
pub async fn recover_managed_server_specs(&self, data_root: &Path) -> Result<Vec<ServerSpec>> {
|
||||
let containers = self
|
||||
.client()
|
||||
.list_containers(Some(ListContainersOptions::<String> {
|
||||
all: true,
|
||||
..Default::default()
|
||||
}))
|
||||
.await?;
|
||||
|
||||
let mut recovered = Vec::new();
|
||||
|
||||
for container in containers {
|
||||
let uuid = container
|
||||
.names
|
||||
.as_ref()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find_map(|name| uuid_from_container_name(name));
|
||||
|
||||
let Some(uuid) = uuid else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let info = self.client().inspect_container(&container_name(&uuid), None).await?;
|
||||
|
||||
let image = info
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|cfg| cfg.image.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let data_mount_path = info
|
||||
.mounts
|
||||
.as_ref()
|
||||
.and_then(|mounts| {
|
||||
mounts.iter().find_map(|mount| {
|
||||
if mount.typ != Some(MountPointTypeEnum::BIND) {
|
||||
return None;
|
||||
}
|
||||
mount.source.as_ref().map(PathBuf::from)
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| data_root.join(&uuid));
|
||||
|
||||
let data_destination = info
|
||||
.mounts
|
||||
.as_ref()
|
||||
.and_then(|mounts| {
|
||||
mounts.iter().find_map(|mount| {
|
||||
if mount.typ != Some(MountPointTypeEnum::BIND) {
|
||||
return None;
|
||||
}
|
||||
mount.destination.clone()
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| container_data_path_for_image(&image).to_string());
|
||||
|
||||
let startup_command = info
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|cfg| {
|
||||
let working_dir = cfg.working_dir.as_deref().unwrap_or_default();
|
||||
if working_dir != data_destination {
|
||||
return None;
|
||||
}
|
||||
cfg.cmd.as_ref().map(|cmd| cmd.join(" "))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut environment = HashMap::new();
|
||||
if let Some(env_vars) = info
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|cfg| cfg.env.clone())
|
||||
{
|
||||
for entry in env_vars {
|
||||
if let Some((key, value)) = entry.split_once('=') {
|
||||
environment.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ports = info
|
||||
.host_config
|
||||
.as_ref()
|
||||
.and_then(|cfg| cfg.port_bindings.as_ref())
|
||||
.map(|bindings| {
|
||||
bindings
|
||||
.iter()
|
||||
.flat_map(|(container_port, host_bindings)| {
|
||||
let (container_port, protocol) = match container_port.split_once('/') {
|
||||
Some((port, protocol)) => (port, protocol),
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
let Ok(container_port_num) = container_port.parse::<u16>() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
host_bindings
|
||||
.as_ref()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|binding| {
|
||||
let host_port = binding.host_port.as_deref()?.parse::<u16>().ok()?;
|
||||
Some(crate::server::PortMap {
|
||||
host_port,
|
||||
container_port: container_port_num,
|
||||
protocol: protocol.to_string(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let memory_limit = info
|
||||
.host_config
|
||||
.as_ref()
|
||||
.and_then(|cfg| cfg.memory)
|
||||
.unwrap_or_default();
|
||||
|
||||
let cpu_limit = info
|
||||
.host_config
|
||||
.as_ref()
|
||||
.and_then(|cfg| cfg.nano_cpus)
|
||||
.map(|nano_cpus| (nano_cpus / 10_000_000) as i32)
|
||||
.unwrap_or_default();
|
||||
|
||||
let state = info
|
||||
.state
|
||||
.as_ref()
|
||||
.and_then(|state| state.status.as_ref())
|
||||
.map(|status| server_state_from_container_status(&format!("{status:?}").to_lowercase()))
|
||||
.unwrap_or(ServerState::Error);
|
||||
|
||||
recovered.push(ServerSpec {
|
||||
uuid,
|
||||
docker_image: image,
|
||||
memory_limit,
|
||||
disk_limit: 0,
|
||||
cpu_limit,
|
||||
startup_command,
|
||||
environment,
|
||||
ports,
|
||||
data_path: data_mount_path,
|
||||
state,
|
||||
container_id: info.id,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(recovered)
|
||||
}
|
||||
|
||||
/// Stream container logs (stdout + stderr). Returns an owned stream.
|
||||
pub fn stream_logs(
|
||||
self: &Arc<Self>,
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
NUMCHECK='^[0-9]+$'
|
||||
MSGWARNING="\033[0;33mWARNING:\033[0m"
|
||||
|
||||
if ! [[ "$SERVERGAMEPORT" =~ $NUMCHECK ]]; then
|
||||
printf "Invalid server port given: %s\n" "$SERVERGAMEPORT"
|
||||
SERVERGAMEPORT="7777"
|
||||
fi
|
||||
printf "Setting server port to %s\n" "$SERVERGAMEPORT"
|
||||
|
||||
if ! [[ "$SERVERMESSAGINGPORT" =~ $NUMCHECK ]]; then
|
||||
printf "Invalid messaging port given: %s\n" "$SERVERMESSAGINGPORT"
|
||||
SERVERMESSAGINGPORT="8888"
|
||||
fi
|
||||
printf "Setting messaging port to %s\n" "$SERVERMESSAGINGPORT"
|
||||
|
||||
if ! [[ "$AUTOSAVENUM" =~ $NUMCHECK ]]; then
|
||||
printf "Invalid autosave number given: %s\n" "$AUTOSAVENUM"
|
||||
AUTOSAVENUM="5"
|
||||
fi
|
||||
printf "Setting autosave number to %s\n" "$AUTOSAVENUM"
|
||||
|
||||
if ! [[ "$MAXOBJECTS" =~ $NUMCHECK ]]; then
|
||||
printf "Invalid max objects number given: %s\n" "$MAXOBJECTS"
|
||||
MAXOBJECTS="2162688"
|
||||
fi
|
||||
printf "Setting max objects to %s\n" "$MAXOBJECTS"
|
||||
|
||||
if ! [[ "$MAXTICKRATE" =~ $NUMCHECK ]]; then
|
||||
printf "Invalid max tick rate number given: %s\n" "$MAXTICKRATE"
|
||||
MAXTICKRATE="30"
|
||||
fi
|
||||
printf "Setting max tick rate to %s\n" "$MAXTICKRATE"
|
||||
|
||||
[[ "${SERVERSTREAMING,,}" == "true" ]] && SERVERSTREAMING="1" || SERVERSTREAMING="0"
|
||||
printf "Setting server streaming to %s\n" "$SERVERSTREAMING"
|
||||
|
||||
if ! [[ "$TIMEOUT" =~ $NUMCHECK ]]; then
|
||||
printf "Invalid timeout number given: %s\n" "$TIMEOUT"
|
||||
TIMEOUT="30"
|
||||
fi
|
||||
printf "Setting timeout to %s\n" "$TIMEOUT"
|
||||
|
||||
if ! [[ "$MAXPLAYERS" =~ $NUMCHECK ]]; then
|
||||
printf "Invalid max players given: %s\n" "$MAXPLAYERS"
|
||||
MAXPLAYERS="4"
|
||||
fi
|
||||
printf "Setting max players to %s\n" "$MAXPLAYERS"
|
||||
|
||||
if [[ "${DISABLESEASONALEVENTS,,}" == "true" ]]; then
|
||||
printf "Disabling seasonal events\n"
|
||||
DISABLESEASONALEVENTS="-DisableSeasonalEvents"
|
||||
else
|
||||
DISABLESEASONALEVENTS=""
|
||||
fi
|
||||
|
||||
if [[ "$MULTIHOME" != "" ]]; then
|
||||
if [[ "$MULTIHOME" == "::" ]]; then
|
||||
printf "Multihome will accept IPv4 and IPv6 connections\n"
|
||||
fi
|
||||
printf "Setting multihome to %s\n" "$MULTIHOME"
|
||||
MULTIHOME="-multihome=$MULTIHOME"
|
||||
fi
|
||||
|
||||
ini_args=(
|
||||
"-ini:Engine:[/Script/FactoryGame.FGSaveSession]:mNumRotatingAutosaves=$AUTOSAVENUM"
|
||||
"-ini:Engine:[/Script/Engine.GarbageCollectionSettings]:gc.MaxObjectsInEditor=$MAXOBJECTS"
|
||||
"-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:LanServerMaxTickRate=$MAXTICKRATE"
|
||||
"-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:NetServerMaxTickRate=$MAXTICKRATE"
|
||||
"-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:ConnectionTimeout=$TIMEOUT"
|
||||
"-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:InitialConnectTimeout=$TIMEOUT"
|
||||
"-ini:Engine:[ConsoleVariables]:wp.Runtime.EnableServerStreaming=$SERVERSTREAMING"
|
||||
"-ini:Game:[/Script/Engine.GameSession]:ConnectionTimeout=$TIMEOUT"
|
||||
"-ini:Game:[/Script/Engine.GameSession]:InitialConnectTimeout=$TIMEOUT"
|
||||
"-ini:Game:[/Script/Engine.GameSession]:MaxPlayers=$MAXPLAYERS"
|
||||
"-ini:GameUserSettings:[/Script/Engine.GameSession]:MaxPlayers=$MAXPLAYERS"
|
||||
"$DISABLESEASONALEVENTS"
|
||||
"$MULTIHOME"
|
||||
)
|
||||
|
||||
if [[ "${SKIPUPDATE,,}" != "false" ]] && [ ! -f "/config/gamefiles/FactoryServer.sh" ]; then
|
||||
printf "%s Skip update is set, but no game files exist. Updating anyway\n" "$MSGWARNING"
|
||||
SKIPUPDATE="false"
|
||||
fi
|
||||
|
||||
if [[ "${SKIPUPDATE,,}" != "true" ]]; then
|
||||
STEAMBETAPASSWORD=""
|
||||
|
||||
if [[ -n "${STEAMBETAID}" ]]; then
|
||||
printf "STEAMBETAID is set. Using beta ID: %s\n" "$STEAMBETAID"
|
||||
STEAMBETAFLAG="$STEAMBETAID"
|
||||
if [[ -n "${STEAMBETAKEY}" ]]; then
|
||||
STEAMBETAPASSWORD="-betapassword $STEAMBETAKEY"
|
||||
printf "Beta password provided\n"
|
||||
fi
|
||||
elif [[ "${STEAMBETA,,}" == "true" ]]; then
|
||||
printf "Experimental flag is set. Experimental will be downloaded instead of Early Access.\n"
|
||||
STEAMBETAFLAG="experimental"
|
||||
else
|
||||
STEAMBETAFLAG=""
|
||||
fi
|
||||
|
||||
STORAGEAVAILABLE=$(stat -f -c "%a*%S" .)
|
||||
STORAGEAVAILABLE=$((STORAGEAVAILABLE/1024/1024/1024))
|
||||
printf "Checking available storage: %sGB detected\n" "$STORAGEAVAILABLE"
|
||||
|
||||
if [[ "$STORAGEAVAILABLE" -lt 8 ]]; then
|
||||
printf "You have less than 8GB (%sGB detected) of available storage to download the game.\nIf this is a fresh install, it will probably fail.\n" "$STORAGEAVAILABLE"
|
||||
fi
|
||||
|
||||
printf "\nDownloading the latest version of the game...\n"
|
||||
if [ -f "/config/gamefiles/steamapps/appmanifest_1690800.acf" ]; then
|
||||
printf "\nRemoving the app manifest to force Steam to check for an update...\n"
|
||||
rm "/config/gamefiles/steamapps/appmanifest_1690800.acf" || true
|
||||
fi
|
||||
|
||||
if [[ -n "$STEAMBETAFLAG" ]]; then
|
||||
steamcmd +force_install_dir /config/gamefiles +login anonymous +app_update "$STEAMAPPID" -beta "$STEAMBETAFLAG" $STEAMBETAPASSWORD validate +quit
|
||||
else
|
||||
steamcmd +force_install_dir /config/gamefiles +login anonymous +app_update "$STEAMAPPID" validate +quit
|
||||
fi
|
||||
|
||||
cp -r /home/steam/.steam/steam/logs/* "/config/logs/steam" || printf "Failed to store Steam logs\n"
|
||||
else
|
||||
printf "Skipping update as flag is set\n"
|
||||
fi
|
||||
|
||||
printf "Launching game server\n\n"
|
||||
|
||||
cp -r "/config/saved/server/." "/config/backups/" 2>/dev/null || true
|
||||
cp -r "${GAMESAVESDIR}/server/." "/config/backups" 2>/dev/null || true
|
||||
rm -rf "$GAMESAVESDIR"
|
||||
ln -sf "/config/saved" "$GAMESAVESDIR"
|
||||
|
||||
if [ ! -f "/config/gamefiles/FactoryServer.sh" ]; then
|
||||
printf "FactoryServer launch script is missing.\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd /config/gamefiles || exit 1
|
||||
|
||||
chmod +x FactoryServer.sh || true
|
||||
./FactoryServer.sh -Port="$SERVERGAMEPORT" -ReliablePort="$SERVERMESSAGINGPORT" -ExternalReliablePort="$SERVERMESSAGINGPORT" "${ini_args[@]}" "$@" &
|
||||
|
||||
sleep 2
|
||||
satisfactory_pid=$(ps --ppid ${!} o pid=)
|
||||
|
||||
shutdown() {
|
||||
printf "\nReceived SIGINT. Shutting down.\n"
|
||||
kill -INT $satisfactory_pid 2>/dev/null
|
||||
}
|
||||
trap shutdown SIGINT SIGTERM
|
||||
|
||||
wait
|
||||
@@ -303,6 +303,28 @@ impl DaemonService for DaemonServiceImpl {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn import_database_sql(
|
||||
&self,
|
||||
request: Request<ImportDatabaseSqlRequest>,
|
||||
) -> Result<Response<Empty>, Status> {
|
||||
self.check_auth(&request)?;
|
||||
let req = request.into_inner();
|
||||
|
||||
if req.database_name.trim().is_empty() {
|
||||
return Err(Status::invalid_argument("Database name is required"));
|
||||
}
|
||||
if req.sql.trim().is_empty() {
|
||||
return Err(Status::invalid_argument("SQL payload is required"));
|
||||
}
|
||||
|
||||
self.managed_mysql
|
||||
.import_sql(req.database_name.trim(), &req.sql)
|
||||
.await
|
||||
.map_err(Status::from)?;
|
||||
|
||||
Ok(Response::new(Empty {}))
|
||||
}
|
||||
|
||||
async fn update_database_password(
|
||||
&self,
|
||||
request: Request<UpdateDatabasePasswordRequest>,
|
||||
|
||||
@@ -49,6 +49,9 @@ async fn main() -> Result<()> {
|
||||
let server_manager = Arc::new(ServerManager::new(docker, &config));
|
||||
info!("Server manager initialized");
|
||||
|
||||
let recovered_servers = server_manager.recover_existing_servers().await?;
|
||||
info!(recovered_servers, "Recovered managed servers from Docker");
|
||||
|
||||
// Initialize shared command dispatcher (single command pipeline for all games/sources)
|
||||
let command_dispatcher = Arc::new(CommandDispatcher::new(server_manager.clone()));
|
||||
info!("Command dispatcher initialized");
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use std::io::ErrorKind;
|
||||
use std::process::Stdio;
|
||||
|
||||
use reqwest::Url;
|
||||
use thiserror::Error;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::Command;
|
||||
use tonic::Status;
|
||||
use uuid::Uuid;
|
||||
@@ -164,6 +166,28 @@ impl ManagedMysqlManager {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn import_sql(
|
||||
&self,
|
||||
database_name: &str,
|
||||
sql: &str,
|
||||
) -> Result<(), ManagedMysqlError> {
|
||||
let config = self.config.as_ref().ok_or(ManagedMysqlError::NotConfigured)?;
|
||||
let database_name = database_name.trim();
|
||||
if database_name.is_empty() {
|
||||
return Err(ManagedMysqlError::CommandFailed(
|
||||
"Database name is required".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if sql.trim().is_empty() {
|
||||
return Err(ManagedMysqlError::CommandFailed(
|
||||
"SQL payload is required".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
self.run_sql_script(config, database_name, sql).await
|
||||
}
|
||||
|
||||
pub async fn delete_database(
|
||||
&self,
|
||||
database_name: &str,
|
||||
@@ -242,6 +266,76 @@ impl ManagedMysqlManager {
|
||||
|
||||
Err(ManagedMysqlError::ClientMissing)
|
||||
}
|
||||
|
||||
async fn run_sql_script(
|
||||
&self,
|
||||
config: &ManagedMysqlRuntimeConfig,
|
||||
database_name: &str,
|
||||
sql: &str,
|
||||
) -> Result<(), ManagedMysqlError> {
|
||||
let binaries = match config.client_bin.as_deref() {
|
||||
Some(bin) if !bin.trim().is_empty() => vec![bin.to_string()],
|
||||
_ => vec!["mariadb".to_string(), "mysql".to_string()],
|
||||
};
|
||||
|
||||
let mut missing_binary = false;
|
||||
|
||||
for binary in binaries {
|
||||
let child = Command::new(&binary)
|
||||
.args([
|
||||
"--protocol=TCP",
|
||||
"--batch",
|
||||
"--skip-column-names",
|
||||
"-h",
|
||||
&config.admin_host,
|
||||
"-P",
|
||||
&config.admin_port.to_string(),
|
||||
"-u",
|
||||
&config.admin_username,
|
||||
database_name,
|
||||
])
|
||||
.env("MYSQL_PWD", &config.admin_password)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn();
|
||||
|
||||
match child {
|
||||
Ok(mut child) => {
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin.write_all(sql.as_bytes()).await?;
|
||||
}
|
||||
|
||||
let output = child.wait_with_output().await?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let message = if !stderr.is_empty() {
|
||||
stderr
|
||||
} else if !stdout.is_empty() {
|
||||
stdout
|
||||
} else {
|
||||
format!("{} exited with status {}", binary, output.status)
|
||||
};
|
||||
return Err(ManagedMysqlError::CommandFailed(message));
|
||||
}
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => {
|
||||
missing_binary = true;
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(ManagedMysqlError::Io(error)),
|
||||
}
|
||||
}
|
||||
|
||||
if missing_binary {
|
||||
return Err(ManagedMysqlError::ClientMissing);
|
||||
}
|
||||
|
||||
Err(ManagedMysqlError::ClientMissing)
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_runtime_config(
|
||||
|
||||
@@ -49,6 +49,32 @@ impl ServerManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild in-memory server specs from existing managed Docker containers.
|
||||
pub async fn recover_existing_servers(&self) -> Result<usize, DaemonError> {
|
||||
let recovered = self
|
||||
.docker
|
||||
.recover_managed_server_specs(&self.data_root)
|
||||
.await
|
||||
.map_err(|error| DaemonError::Internal(format!("Failed to recover managed containers: {}", error)))?;
|
||||
|
||||
let recovered_count = recovered.len();
|
||||
let mut servers = self.servers.write().await;
|
||||
servers.clear();
|
||||
|
||||
for spec in recovered {
|
||||
self.ensure_server_data_dir(&spec.data_path).await?;
|
||||
info!(
|
||||
uuid = %spec.uuid,
|
||||
state = %spec.state,
|
||||
image = %spec.docker_image,
|
||||
"Recovered managed server from Docker runtime"
|
||||
);
|
||||
servers.insert(spec.uuid.clone(), spec);
|
||||
}
|
||||
|
||||
Ok(recovered_count)
|
||||
}
|
||||
|
||||
/// Get server spec by UUID.
|
||||
pub async fn get_server(&self, uuid: &str) -> Result<ServerSpec, DaemonError> {
|
||||
let servers = self.servers.read().await;
|
||||
@@ -200,6 +226,10 @@ impl ServerManager {
|
||||
})?;
|
||||
}
|
||||
|
||||
self.docker.pull_image(&desired_spec.docker_image).await.map_err(|e| {
|
||||
DaemonError::Internal(format!("Failed to pull updated image during server update: {}", e))
|
||||
})?;
|
||||
|
||||
match self.docker.create_container(&desired_spec).await {
|
||||
Ok(container_id) => {
|
||||
desired_spec.container_id = Some(container_id);
|
||||
|
||||
Reference in New Issue
Block a user