Introduce a Supervisor trait (async-trait) so the agent manages games with different models behind one wire contract. ProcessSupervisor (spawned process: rust/conan/soulmask) and the new DockerComposeSupervisor (dune) both impl it; Agent.supervisors is now HashMap<String, Arc<dyn Supervisor>> and instancecmd dispatch is game-agnostic — start/stop/restart/status identical across games, selected by a per-game factory in main. InstanceState moved to the shared supervisor module. DockerComposeSupervisor drives docker-compose up-d / stop / restart against the instance's compose project, with -f/-p/single-service support and a configurable compose binary. New [instance.docker_compose] config block. First cut = lifecycle + cached state; container crash-detection + restart adoption deferred to Phase 3b (reconcilable with a compose ps probe). Trait choice (dyn over enum) per Commander: scales to future planes (kubectl, AMP/podman, SSH) as new struct+impl, no central match. 56 tests green (6 new docker-compose mock-binary tests + 5 refactored process tests), zero warnings. Live verification pending a real Dune stack. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
246 lines
7.9 KiB
Rust
246 lines
7.9 KiB
Rust
//! Agent configuration: TOML file + environment overrides.
|
|
//!
|
|
//! Multi-instance is foundational, not bolted on: one agent supervises N game
|
|
//! instances on the host, each declared as an `[[instance]]` block. Connection
|
|
//! secrets may come from env so the config file can be world-readable-ish
|
|
//! while the token is not.
|
|
|
|
use anyhow::{bail, Context, Result};
|
|
use serde::Deserialize;
|
|
use std::collections::HashSet;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use crate::docker_compose::DockerComposeConfig;
|
|
use crate::rcon::RconConfig;
|
|
use crate::steamcmd::SteamcmdConfig;
|
|
|
|
/// Instance ids share the NATS subject namespace with host-level segments.
|
|
const RESERVED_INSTANCE_IDS: &[&str] = &["host", "cmd", "files", "update", "agent"];
|
|
|
|
pub const SUPPORTED_GAMES: &[&str] = &["rust", "conan", "soulmask", "dune"];
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ConfigFile {
|
|
pub agent: AgentSection,
|
|
#[serde(default, rename = "instance")]
|
|
pub instances: Vec<InstanceConfig>,
|
|
#[serde(default)]
|
|
pub prober: ProberSection,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct AgentSection {
|
|
pub license_id: Option<String>,
|
|
pub nats_url: Option<String>,
|
|
pub nats_token: Option<String>,
|
|
/// NATS username for per-license auth. Defaults to license_id when a
|
|
/// password is set but no user is given.
|
|
pub nats_user: Option<String>,
|
|
/// NATS password (the per-license token). When set, the agent authenticates
|
|
/// with user+password instead of a bare token.
|
|
pub nats_password: Option<String>,
|
|
#[serde(default = "default_heartbeat_seconds")]
|
|
pub heartbeat_seconds: u64,
|
|
#[serde(default = "default_log_level")]
|
|
pub log_level: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct InstanceConfig {
|
|
/// Short slug, unique per license: becomes a NATS subject segment.
|
|
pub id: String,
|
|
/// One of SUPPORTED_GAMES.
|
|
pub game: String,
|
|
/// Install root for this instance on the host.
|
|
pub root: PathBuf,
|
|
/// Optional human label shown in the panel.
|
|
#[serde(default)]
|
|
pub label: Option<String>,
|
|
/// Game server executable. Relative paths resolve against `root`.
|
|
/// Absent = unmanaged instance (telemetry only, no process control).
|
|
#[serde(default)]
|
|
pub executable: Option<PathBuf>,
|
|
/// Arguments as a proper list — no shell splitting, quoted values survive.
|
|
#[serde(default)]
|
|
pub args: Vec<String>,
|
|
/// Working directory for the process. Defaults to the executable's directory.
|
|
#[serde(default)]
|
|
pub working_dir: Option<PathBuf>,
|
|
/// RCON connection settings for this instance. Absent = rcon unavailable.
|
|
/// Protocol defaults to WebRcon for rust, Source for conan/soulmask.
|
|
#[serde(default)]
|
|
pub rcon: Option<RconConfig>,
|
|
/// SteamCMD update settings. Absent = defaults apply (steamcmd on PATH,
|
|
/// validate = false).
|
|
#[serde(default)]
|
|
pub steamcmd: Option<SteamcmdConfig>,
|
|
/// Docker-compose settings for container-managed games (Dune). Absent =
|
|
/// defaults apply (compose file in the instance root, project = instance id).
|
|
#[serde(default)]
|
|
pub docker_compose: Option<DockerComposeConfig>,
|
|
}
|
|
|
|
impl InstanceConfig {
|
|
/// Absolute executable path, if this instance is process-managed.
|
|
pub fn resolved_executable(&self) -> Option<PathBuf> {
|
|
self.executable.as_ref().map(|exe| {
|
|
if exe.is_absolute() {
|
|
exe.clone()
|
|
} else {
|
|
self.root.join(exe)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ProberSection {
|
|
#[serde(default = "default_probe_interval")]
|
|
pub interval_seconds: u64,
|
|
/// Extra TCP targets beyond the built-in defaults.
|
|
#[serde(default, rename = "target")]
|
|
pub targets: Vec<ProbeTargetConfig>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ProbeTargetConfig {
|
|
pub name: String,
|
|
pub host: String,
|
|
pub port: u16,
|
|
}
|
|
|
|
fn default_heartbeat_seconds() -> u64 {
|
|
60
|
|
}
|
|
|
|
fn default_probe_interval() -> u64 {
|
|
300
|
|
}
|
|
|
|
fn default_log_level() -> String {
|
|
"info".to_string()
|
|
}
|
|
|
|
/// Fully-resolved settings after merging file + env. Everything required is
|
|
/// present and validated.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Settings {
|
|
pub license_id: String,
|
|
pub nats_url: String,
|
|
pub nats_token: Option<String>,
|
|
pub nats_user: Option<String>,
|
|
pub nats_password: Option<String>,
|
|
pub heartbeat_seconds: u64,
|
|
pub log_level: String,
|
|
pub instances: Vec<InstanceConfig>,
|
|
pub probe_interval_seconds: u64,
|
|
pub probe_targets: Vec<ProbeTargetConfig>,
|
|
}
|
|
|
|
pub fn default_config_path() -> PathBuf {
|
|
#[cfg(windows)]
|
|
{
|
|
PathBuf::from(r"C:\ProgramData\Corrosion\agent.toml")
|
|
}
|
|
#[cfg(not(windows))]
|
|
{
|
|
PathBuf::from("/etc/corrosion/agent.toml")
|
|
}
|
|
}
|
|
|
|
pub fn load(path: &Path) -> Result<Settings> {
|
|
let raw = std::fs::read_to_string(path)
|
|
.with_context(|| format!("reading config file {}", path.display()))?;
|
|
let file: ConfigFile = toml::from_str(&raw)
|
|
.with_context(|| format!("parsing config file {}", path.display()))?;
|
|
resolve(file)
|
|
}
|
|
|
|
/// Merge env overrides (env wins) and validate.
|
|
fn resolve(file: ConfigFile) -> Result<Settings> {
|
|
let license_id = std::env::var("CORROSION_LICENSE_ID")
|
|
.ok()
|
|
.filter(|v| !v.is_empty())
|
|
.or(file.agent.license_id)
|
|
.context("license_id missing: set [agent].license_id or CORROSION_LICENSE_ID")?;
|
|
|
|
let nats_url = std::env::var("CORROSION_NATS_URL")
|
|
.ok()
|
|
.filter(|v| !v.is_empty())
|
|
.or(file.agent.nats_url)
|
|
.context("nats_url missing: set [agent].nats_url or CORROSION_NATS_URL")?;
|
|
|
|
let nats_token = std::env::var("CORROSION_NATS_TOKEN")
|
|
.ok()
|
|
.filter(|v| !v.is_empty())
|
|
.or(file.agent.nats_token);
|
|
|
|
let nats_user = std::env::var("CORROSION_NATS_USER")
|
|
.ok()
|
|
.filter(|v| !v.is_empty())
|
|
.or(file.agent.nats_user);
|
|
|
|
let nats_password = std::env::var("CORROSION_NATS_PASSWORD")
|
|
.ok()
|
|
.filter(|v| !v.is_empty())
|
|
.or(file.agent.nats_password);
|
|
|
|
validate_subject_segment("license_id", &license_id)?;
|
|
|
|
let mut seen: HashSet<&str> = HashSet::new();
|
|
for inst in &file.instances {
|
|
validate_subject_segment("instance id", &inst.id)?;
|
|
if RESERVED_INSTANCE_IDS.contains(&inst.id.as_str()) {
|
|
bail!("instance id '{}' is reserved", inst.id);
|
|
}
|
|
if !seen.insert(inst.id.as_str()) {
|
|
bail!("duplicate instance id '{}'", inst.id);
|
|
}
|
|
if !SUPPORTED_GAMES.contains(&inst.game.as_str()) {
|
|
bail!(
|
|
"instance '{}': unsupported game '{}' (supported: {})",
|
|
inst.id,
|
|
inst.game,
|
|
SUPPORTED_GAMES.join(", ")
|
|
);
|
|
}
|
|
}
|
|
|
|
if file.agent.heartbeat_seconds < 10 {
|
|
bail!("[agent].heartbeat_seconds must be >= 10");
|
|
}
|
|
|
|
Ok(Settings {
|
|
license_id,
|
|
nats_url,
|
|
nats_token,
|
|
nats_user,
|
|
nats_password,
|
|
heartbeat_seconds: file.agent.heartbeat_seconds,
|
|
log_level: file.agent.log_level,
|
|
instances: file.instances,
|
|
probe_interval_seconds: file.prober.interval_seconds.max(30),
|
|
probe_targets: file.prober.targets,
|
|
})
|
|
}
|
|
|
|
/// NATS subject segments must not contain '.', '*', '>', whitespace, etc.
|
|
/// Keep it strict: lowercase alphanumerics plus '-' and '_', max 64 chars.
|
|
fn validate_subject_segment(what: &str, value: &str) -> Result<()> {
|
|
if value.is_empty() || value.len() > 64 {
|
|
bail!("{what} '{value}' must be 1-64 characters");
|
|
}
|
|
if !value
|
|
.chars()
|
|
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
|
|
{
|
|
bail!("{what} '{value}' may only contain lowercase letters, digits, '-' and '_'");
|
|
}
|
|
Ok(())
|
|
}
|