157 lines
10 KiB
Rust
157 lines
10 KiB
Rust
use std::collections::BTreeSet;
|
|
use std::fmt::{Display, Formatter};
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use channel_gateway_core::{ManagedTemplateRecord, UnraidTemplateSpec};
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct UnraidTemplateManager {
|
|
template_dir: PathBuf,
|
|
archive_dir: PathBuf,
|
|
template_prefix: String,
|
|
}
|
|
|
|
impl UnraidTemplateManager {
|
|
pub fn new(
|
|
template_dir: impl Into<PathBuf>,
|
|
archive_dir: impl Into<PathBuf>,
|
|
template_prefix: impl Into<String>,
|
|
) -> Self {
|
|
Self {
|
|
template_dir: template_dir.into(),
|
|
archive_dir: archive_dir.into(),
|
|
template_prefix: template_prefix.into(),
|
|
}
|
|
}
|
|
|
|
pub fn write_worker_template(
|
|
&self,
|
|
profile_id: &str,
|
|
template: &UnraidTemplateSpec,
|
|
) -> Result<ManagedTemplateRecord, TemplateError> {
|
|
std::fs::create_dir_all(&self.template_dir)?;
|
|
let path = self.template_path(profile_id);
|
|
let temp_path = path.with_extension("xml.tmp");
|
|
std::fs::write(&temp_path, template.render_xml())?;
|
|
std::fs::rename(temp_path, &path)?;
|
|
Ok(ManagedTemplateRecord {
|
|
profile_id: profile_id.to_string(),
|
|
file_path: path,
|
|
})
|
|
}
|
|
|
|
pub fn archive_removed_templates(
|
|
&self,
|
|
active_profile_ids: &BTreeSet<String>,
|
|
) -> Result<Vec<ManagedTemplateRecord>, TemplateError> {
|
|
std::fs::create_dir_all(&self.archive_dir)?;
|
|
let mut archived = Vec::new();
|
|
if !self.template_dir.exists() {
|
|
return Ok(archived);
|
|
}
|
|
for entry in std::fs::read_dir(&self.template_dir)? {
|
|
let entry = entry?;
|
|
let path = entry.path();
|
|
if !path.is_file() {
|
|
continue;
|
|
}
|
|
let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
|
|
continue;
|
|
};
|
|
if !file_name.starts_with(&self.template_prefix) || !file_name.ends_with(".xml") {
|
|
continue;
|
|
}
|
|
let profile_id = file_name
|
|
.trim_start_matches(&self.template_prefix)
|
|
.trim_end_matches(".xml")
|
|
.to_string();
|
|
if active_profile_ids.contains(&profile_id) {
|
|
continue;
|
|
}
|
|
let archived_path =
|
|
self.archive_dir
|
|
.join(format!("{}-{}", current_timestamp_secs(), file_name));
|
|
std::fs::rename(&path, &archived_path)?;
|
|
archived.push(ManagedTemplateRecord {
|
|
profile_id,
|
|
file_path: archived_path,
|
|
});
|
|
}
|
|
Ok(archived)
|
|
}
|
|
|
|
pub fn archive_template_for_profile(
|
|
&self,
|
|
profile_id: &str,
|
|
) -> Result<Option<ManagedTemplateRecord>, TemplateError> {
|
|
std::fs::create_dir_all(&self.archive_dir)?;
|
|
let path = self.template_path(profile_id);
|
|
if !path.exists() {
|
|
return Ok(None);
|
|
}
|
|
let archived_path = self.archive_dir.join(format!(
|
|
"{}-{}",
|
|
current_timestamp_secs(),
|
|
path.file_name()
|
|
.and_then(|value| value.to_str())
|
|
.unwrap_or("template.xml")
|
|
));
|
|
std::fs::rename(&path, &archived_path)?;
|
|
Ok(Some(ManagedTemplateRecord {
|
|
profile_id: profile_id.to_string(),
|
|
file_path: archived_path,
|
|
}))
|
|
}
|
|
|
|
pub fn template_path(&self, profile_id: &str) -> PathBuf {
|
|
self.template_dir
|
|
.join(format!("{}{}.xml", self.template_prefix, profile_id))
|
|
}
|
|
|
|
pub fn render_gateway_example(
|
|
&self,
|
|
destination: impl AsRef<Path>,
|
|
repository: &str,
|
|
) -> Result<(), TemplateError> {
|
|
let xml = format!(
|
|
"<?xml version=\"1.0\"?>\n<Container version=\"2\">\n <Name>claw-telegram-gateway</Name>\n <Repository>{repository}</Repository>\n <Registry></Registry>\n <Network>claw_gateway</Network>\n <MyIP></MyIP>\n <Shell>sh</Shell>\n <Privileged>false</Privileged>\n <Support>https://git.wylab.me/wylab/claw-code-parity</Support>\n <Project>claw-code-parity</Project>\n <Overview>Telegram gateway with Docker-socket worker management. Warning: mounting the Docker socket grants the container root-equivalent control over the Unraid host.</Overview>\n <Category>AI:Tools</Category>\n <WebUI></WebUI>\n <TemplateURL></TemplateURL>\n <Icon></Icon>\n <ExtraParams>--entrypoint claw-telegram</ExtraParams>\n <PostArgs>gateway serve</PostArgs>\n <CPUset></CPUset>\n <DateInstalled>{}</DateInstalled>\n <DonateText></DonateText>\n <DonateLink></DonateLink>\n <Requires></Requires>\n <Config Name=\"Docker Socket\" Target=\"/var/run/docker.sock\" Default=\"/var/run/docker.sock\" Mode=\"rw\" Description=\"Docker socket for worker orchestration; this grants root-equivalent control of the host\" Type=\"Path\" Display=\"always\" Required=\"true\" Mask=\"false\">/var/run/docker.sock</Config>\n <Config Name=\"Unraid Templates\" Target=\"/unraid/templates-user\" Default=\"/boot/config/plugins/dockerMan/templates-user\" Mode=\"rw\" Description=\"Unraid template directory\" Type=\"Path\" Display=\"always\" Required=\"true\" Mask=\"false\">/boot/config/plugins/dockerMan/templates-user</Config>\n <Config Name=\"Gateway AppData\" Target=\"/appdata\" Default=\"/mnt/user/appdata/claw-telegram-gateway\" Mode=\"rw\" Description=\"Gateway state and manifest directory\" Type=\"Path\" Display=\"always\" Required=\"true\" Mask=\"false\">/mnt/user/appdata/claw-telegram-gateway</Config>\n <Config Name=\"CLAW_GATEWAY_TELEGRAM_BOT_TOKEN\" Target=\"CLAW_GATEWAY_TELEGRAM_BOT_TOKEN\" Default=\"\" Mode=\"\" Description=\"Telegram bot token used by the gateway ingress\" Type=\"Variable\" Display=\"always\" Required=\"true\" Mask=\"true\"></Config>\n <Config Name=\"CLAW_WORKER_AUTH_TOKEN\" Target=\"CLAW_WORKER_AUTH_TOKEN\" Default=\"\" Mode=\"\" Description=\"Shared bearer token for worker API calls\" Type=\"Variable\" Display=\"always\" Required=\"true\" Mask=\"true\"></Config>\n <Config Name=\"CLAW_GATEWAY_WORKER_IMAGE\" Target=\"CLAW_GATEWAY_WORKER_IMAGE\" Default=\"{repository}\" Mode=\"\" Description=\"Worker image pulled by the gateway for per-profile containers\" Type=\"Variable\" Display=\"always\" Required=\"true\" Mask=\"false\">{repository}</Config>\n <Config Name=\"CLAW_GATEWAY_STATE_ROOT\" Target=\"CLAW_GATEWAY_STATE_ROOT\" Default=\"/appdata/state\" Mode=\"\" Description=\"Gateway runtime state root for offsets and downloaded files\" Type=\"Variable\" Display=\"advanced\" Required=\"true\" Mask=\"false\">/appdata/state</Config>\n <Config Name=\"CLAW_GATEWAY_MANIFEST\" Target=\"CLAW_GATEWAY_MANIFEST\" Default=\"/appdata/profiles.json\" Mode=\"\" Description=\"Manifest path for channel identities and worker layout\" Type=\"Variable\" Display=\"advanced\" Required=\"true\" Mask=\"false\">/appdata/profiles.json</Config>\n <Config Name=\"CLAW_GATEWAY_DOCKER_SOCKET\" Target=\"CLAW_GATEWAY_DOCKER_SOCKET\" Default=\"/var/run/docker.sock\" Mode=\"\" Description=\"Docker socket path inside the gateway container\" Type=\"Variable\" Display=\"advanced\" Required=\"true\" Mask=\"false\">/var/run/docker.sock</Config>\n <Config Name=\"CLAW_GATEWAY_TEMPLATE_DIR\" Target=\"CLAW_GATEWAY_TEMPLATE_DIR\" Default=\"/unraid/templates-user\" Mode=\"\" Description=\"Unraid template directory path inside the gateway container\" Type=\"Variable\" Display=\"advanced\" Required=\"true\" Mask=\"false\">/unraid/templates-user</Config>\n <Config Name=\"CLAW_GATEWAY_TEMPLATE_ARCHIVE_DIR\" Target=\"CLAW_GATEWAY_TEMPLATE_ARCHIVE_DIR\" Default=\"/appdata/template-archive\" Mode=\"\" Description=\"Archive location for removed worker templates\" Type=\"Variable\" Display=\"advanced\" Required=\"true\" Mask=\"false\">/appdata/template-archive</Config>\n <Config Name=\"CLAW_GATEWAY_WORKER_NETWORK\" Target=\"CLAW_GATEWAY_WORKER_NETWORK\" Default=\"claw_gateway\" Mode=\"\" Description=\"Docker network name shared by the gateway and workers\" Type=\"Variable\" Display=\"advanced\" Required=\"true\" Mask=\"false\">claw_gateway</Config>\n <Config Name=\"CLAW_GATEWAY_INHERITED_ENV\" Target=\"CLAW_GATEWAY_INHERITED_ENV\" Default=\"ANTHROPIC_AUTH_TOKEN,ANTHROPIC_API_KEY\" Mode=\"\" Description=\"Comma-separated env vars copied from gateway into worker containers\" Type=\"Variable\" Display=\"advanced\" Required=\"false\" Mask=\"false\">ANTHROPIC_AUTH_TOKEN,ANTHROPIC_API_KEY</Config>\n <Config Name=\"CLAW_GATEWAY_WORKER_READY_TIMEOUT_SECS\" Target=\"CLAW_GATEWAY_WORKER_READY_TIMEOUT_SECS\" Default=\"15\" Mode=\"\" Description=\"Seconds to wait for a worker container to pass its health check\" Type=\"Variable\" Display=\"advanced\" Required=\"false\" Mask=\"false\">15</Config>\n <Config Name=\"ANTHROPIC_AUTH_TOKEN\" Target=\"ANTHROPIC_AUTH_TOKEN\" Default=\"\" Mode=\"\" Description=\"Claude subscription OAuth token (sk-ant-oat...) inherited by worker containers\" Type=\"Variable\" Display=\"always\" Required=\"false\" Mask=\"true\"></Config>\n <Config Name=\"ANTHROPIC_API_KEY\" Target=\"ANTHROPIC_API_KEY\" Default=\"\" Mode=\"\" Description=\"Anthropic API key inherited by worker containers for direct API access\" Type=\"Variable\" Display=\"always\" Required=\"false\" Mask=\"true\"></Config>\n <TailscaleStateDir></TailscaleStateDir>\n</Container>\n",
|
|
current_timestamp_secs()
|
|
);
|
|
let destination = destination.as_ref();
|
|
if let Some(parent) = destination.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
std::fs::write(destination, xml)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn current_timestamp_secs() -> u64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|duration| duration.as_secs())
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum TemplateError {
|
|
Io(std::io::Error),
|
|
}
|
|
|
|
impl Display for TemplateError {
|
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::Io(error) => write!(f, "{error}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for TemplateError {}
|
|
|
|
impl From<std::io::Error> for TemplateError {
|
|
fn from(value: std::io::Error) -> Self {
|
|
Self::Io(value)
|
|
}
|
|
}
|