diff --git a/agent/Cargo.lock b/agent/Cargo.lock index fde7e53..0ab9f4e 100644 --- a/agent/Cargo.lock +++ b/agent/Cargo.lock @@ -150,6 +150,7 @@ dependencies = [ "hyperlocal", "log", "pin-project-lite", + "rustls", "serde", "serde_derive", "serde_json", @@ -1191,24 +1192,13 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ - "log", "once_cell", - "ring", "rustls-pki-types", "rustls-webpki", "subtle", "zeroize", ] -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -1585,16 +1575,6 @@ dependencies = [ "syn", ] -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - [[package]] name = "tokio-stream" version = "0.1.18" @@ -1640,10 +1620,8 @@ dependencies = [ "percent-encoding", "pin-project", "prost", - "rustls-pemfile", "socket2 0.5.10", "tokio", - "tokio-rustls", "tokio-stream", "tower 0.4.13", "tower-layer", diff --git a/agent/Cargo.toml b/agent/Cargo.toml index 2b7cad5..4489f72 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -10,9 +10,9 @@ path = "src/main.rs" [dependencies] tokio = { version = "1", features = ["full"] } -tonic = { version = "0.12", features = ["tls"] } +tonic = { version = "0.12" } prost = "0.13" -bollard = "0.17" +bollard = { version = "0.17", default-features = false, features = ["rustls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tracing = "0.1" diff --git a/agent/Dockerfile b/agent/Dockerfile index 603e437..a6d7fc3 100644 --- a/agent/Dockerfile +++ b/agent/Dockerfile @@ -3,34 +3,30 @@ FROM rust:slim AS builder RUN apt-get update && apt-get install -y --no-install-recommends \ protobuf-compiler \ - pkg-config \ - libssl-dev \ + musl-tools \ && rm -rf /var/lib/apt/lists/* +RUN rustup target add x86_64-unknown-linux-musl + WORKDIR /src -# Copy proto first (referenced by build.rs as ../proto/...) COPY proto/ ./proto/ -# Cache dependencies: copy manifests, build with dummy main, then discard. COPY agent/Cargo.toml agent/Cargo.lock ./agent/ COPY agent/build.rs ./agent/ WORKDIR /src/agent RUN mkdir src && echo "fn main(){}" > src/main.rs && \ - cargo build --release && \ + cargo build --release --target x86_64-unknown-linux-musl && \ rm -rf src -# Full build. COPY agent/src ./src -RUN touch src/main.rs && cargo build --release +RUN touch src/main.rs && cargo build --release --target x86_64-unknown-linux-musl # ── Runtime ─────────────────────────────────────────────────────────────────── -FROM debian:bookworm-slim +FROM alpine:3.19 -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates \ - && rm -rf /var/lib/apt/lists/* +RUN apk add --no-cache ca-certificates docker-cli -COPY --from=builder /src/agent/target/release/containarr-agent /usr/local/bin/containarr-agent +COPY --from=builder /src/agent/target/x86_64-unknown-linux-musl/release/containarr-agent /usr/local/bin/containarr-agent ENTRYPOINT ["containarr-agent"] diff --git a/agent/src/docker.rs b/agent/src/docker.rs index 39f6884..a80bf46 100644 --- a/agent/src/docker.rs +++ b/agent/src/docker.rs @@ -43,7 +43,7 @@ pub trait ContainerBackend: Clone + Send + Sync + 'static { #[derive(Clone)] pub struct DockerClient { - inner: Docker, + pub inner: Docker, } impl DockerClient { diff --git a/agent/src/main.rs b/agent/src/main.rs index 5f38127..c44da2c 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -5,15 +5,23 @@ pub mod proto { } use anyhow::{Context, Result}; -use bollard::container::LogOutput; +use bollard::container::{ + Config, CreateContainerOptions, LogOutput, StartContainerOptions, + StopContainerOptions, RemoveContainerOptions, +}; +use bollard::network::ConnectNetworkOptions; +use bollard::models::EndpointSettings; +use bollard::image::CreateImageOptions; use docker::{ContainerBackend, DockerClient}; use futures_util::StreamExt as _; use proto::{ agent_gateway_client::AgentGatewayClient, agent_message, server_message, AgentHandshake, AgentMessage, ContainerAction, ContainerSnapshot, - ImageInfo, VolumeInfo, NetworkInfo, + FileResult, ImageInfo, VolumeInfo, NetworkInfo, + UpdateCheckResult, }; +use serde::Serialize; use std::{collections::HashMap, env, time::Duration}; use tokio::{sync::mpsc, task::JoinHandle, time}; use tonic::Request; @@ -170,6 +178,165 @@ async fn run(url: &str, token: &str, hostname: &str, docker: DockerClient) -> Re }); log_tasks.insert(cmd.container_id, handle); } + Some(server_message::Payload::ListDir(cmd)) => { + let tx_clone = tx.clone(); + tokio::spawn(async move { + let result = list_dir_handler(&cmd.path).await; + let fr = match result { + Ok(content) => FileResult { + command_id: cmd.command_id, + success: true, + error: String::new(), + content, + }, + Err(e) => FileResult { + command_id: cmd.command_id, + success: false, + error: e.to_string(), + content: vec![], + }, + }; + let _ = tx_clone.send(AgentMessage { + payload: Some(agent_message::Payload::FileResult(fr)), + }).await; + }); + } + Some(server_message::Payload::ReadFile(cmd)) => { + let tx_clone = tx.clone(); + tokio::spawn(async move { + let result = tokio::fs::read(&cmd.path).await; + let fr = match result { + Ok(content) => FileResult { + command_id: cmd.command_id, + success: true, + error: String::new(), + content, + }, + Err(e) => FileResult { + command_id: cmd.command_id, + success: false, + error: e.to_string(), + content: vec![], + }, + }; + let _ = tx_clone.send(AgentMessage { + payload: Some(agent_message::Payload::FileResult(fr)), + }).await; + }); + } + Some(server_message::Payload::WriteFile(cmd)) => { + let tx_clone = tx.clone(); + tokio::spawn(async move { + let result = write_file_handler(&cmd.path, &cmd.content).await; + let fr = match result { + Ok(()) => FileResult { + command_id: cmd.command_id, + success: true, + error: String::new(), + content: vec![], + }, + Err(e) => FileResult { + command_id: cmd.command_id, + success: false, + error: e.to_string(), + content: vec![], + }, + }; + let _ = tx_clone.send(AgentMessage { + payload: Some(agent_message::Payload::FileResult(fr)), + }).await; + }); + } + Some(server_message::Payload::ExecCompose(cmd)) => { + let tx_clone = tx.clone(); + tokio::spawn(async move { + let result = exec_compose_handler(&cmd.path, &cmd.action).await; + let fr = match result { + Ok(content) => FileResult { + command_id: cmd.command_id, + success: true, + error: String::new(), + content, + }, + Err(e) => FileResult { + command_id: cmd.command_id, + success: false, + error: e.to_string(), + content: vec![], + }, + }; + let _ = tx_clone.send(AgentMessage { + payload: Some(agent_message::Payload::FileResult(fr)), + }).await; + }); + } + Some(server_message::Payload::CreateDir(cmd)) => { + let tx = tx.clone(); + let path = cmd.path.clone(); + let cmd_id = cmd.command_id.clone(); + tokio::spawn(async move { + let (success, error) = match tokio::fs::create_dir_all(&path).await { + Ok(_) => (true, String::new()), + Err(e) => (false, e.to_string()), + }; + let _ = tx.send(AgentMessage { + payload: Some(agent_message::Payload::FileResult(FileResult { + command_id: cmd_id, + success, + error, + content: vec![], + })), + }).await; + }); + } + Some(server_message::Payload::CheckUpdate(cmd)) => { + let tx_clone = tx.clone(); + let docker_clone = docker.clone(); + tokio::spawn(async move { + let result = check_update_handler(&docker_clone.inner, &cmd.container_id).await; + let msg = match result { + Ok((update_available, current_digest, remote_digest)) => { + UpdateCheckResult { + command_id: cmd.command_id, + container_id: cmd.container_id, + update_available, + current_digest, + remote_digest, + error: String::new(), + } + } + Err(e) => UpdateCheckResult { + command_id: cmd.command_id, + container_id: cmd.container_id, + update_available: false, + current_digest: String::new(), + remote_digest: String::new(), + error: e.to_string(), + }, + }; + let _ = tx_clone.send(AgentMessage { + payload: Some(agent_message::Payload::UpdateCheckResult(msg)), + }).await; + }); + } + Some(server_message::Payload::UpdateContainer(cmd)) => { + let tx_clone = tx.clone(); + let docker_clone = docker.clone(); + tokio::spawn(async move { + let result = update_container_handler(&docker_clone.inner, &cmd.container_id).await; + let (success, error) = match result { + Ok(()) => (true, String::new()), + Err(e) => (false, e.to_string()), + }; + let _ = tx_clone.send(AgentMessage { + payload: Some(agent_message::Payload::Result(proto::CommandResult { + command_id: cmd.command_id, + success, + error, + })), + }).await; + }); + } None => {} } } @@ -205,6 +372,281 @@ pub(crate) fn unix_now() -> i64 { .as_secs() as i64 } +// ── FS / compose helpers ────────────────────────────────────────────────────── + +#[derive(Serialize)] +pub(crate) struct DirEntry { + pub name: String, + pub is_dir: bool, + pub has_compose: bool, +} + +pub(crate) async fn list_dir_handler(path: &str) -> Result> { + let mut read_dir = tokio::fs::read_dir(path).await + .with_context(|| format!("read_dir {path}"))?; + let mut entries: Vec = Vec::new(); + while let Some(entry) = read_dir.next_entry().await + .with_context(|| format!("next_entry in {path}"))? { + let file_type = entry.file_type().await + .with_context(|| format!("file_type for {:?}", entry.path()))?; + let is_dir = file_type.is_dir(); + let has_compose = if is_dir { + let p = entry.path(); + tokio::fs::try_exists(p.join("docker-compose.yaml")).await.unwrap_or(false) + || tokio::fs::try_exists(p.join("docker-compose.yml")).await.unwrap_or(false) + } else { + false + }; + entries.push(DirEntry { + name: entry.file_name().to_string_lossy().to_string(), + is_dir, + has_compose, + }); + } + serde_json::to_vec(&entries).context("serialize dir entries") +} + +pub(crate) async fn write_file_handler(path: &str, content: &[u8]) -> Result<()> { + if let Some(parent) = std::path::Path::new(path).parent() { + tokio::fs::create_dir_all(parent).await + .with_context(|| format!("create_dir_all {:?}", parent))?; + } + tokio::fs::write(path, content).await + .with_context(|| format!("write file {path}")) +} + +pub(crate) async fn exec_compose_handler(path: &str, action: &str) -> Result> { + let mut cmd = tokio::process::Command::new("docker"); + cmd.args(match action { + "up" => vec!["compose", "up", "-d"], + "down" => vec!["compose", "down"], + "pull" => vec!["compose", "pull"], + _ => vec!["compose", "up", "-d"], + }); + cmd.current_dir(path); + let output = cmd.output().await + .with_context(|| format!("exec docker compose {action} in {path}"))?; + if output.status.success() { + Ok(output.stdout) + } else { + anyhow::bail!("{}", String::from_utf8_lossy(&output.stderr)) + } +} + +// ── Update helpers ──────────────────────────────────────────────────────────── + +/// Returns `(update_available, current_digest, remote_digest)`. +pub(crate) async fn check_update_handler( + docker: &bollard::Docker, + container_id: &str, +) -> Result<(bool, String, String)> { + // 1. Inspect the container to get the image name + let inspect = docker + .inspect_container(container_id, None) + .await + .with_context(|| format!("inspect container {container_id}"))?; + + let image_name = inspect + .config + .as_ref() + .and_then(|c| c.image.as_deref()) + .ok_or_else(|| anyhow::anyhow!("container {container_id} has no image config"))? + .to_string(); + + // 2. Get current digest before pull + let current_digest = get_image_digest(docker, &image_name).await; + + // 3. Pull the image and detect if it was updated + let update_available = pull_image_and_detect_update(docker, &image_name).await?; + + // 4. Get remote digest after pull + let remote_digest = get_image_digest(docker, &image_name).await; + + Ok((update_available, current_digest, remote_digest)) +} + +/// Pull the image via bollard and detect whether a newer image was downloaded. +/// Returns `true` if "Downloaded newer image" was detected in the pull stream. +pub(crate) async fn pull_image_and_detect_update( + docker: &bollard::Docker, + image_name: &str, +) -> Result { + let mut stream = docker.create_image( + Some(CreateImageOptions { + from_image: image_name, + ..Default::default() + }), + None, + None, + ); + + let mut update_available = false; + while let Some(item) = stream.next().await { + let info = item.with_context(|| format!("pull stream error for {image_name}"))?; + if let Some(status) = &info.status { + if parse_pull_status(status) == Some(true) { + update_available = true; + } + } + } + Ok(update_available) +} + +/// Parse the pull status string to detect update availability. +/// Extracted as a pure function to enable unit testing. +pub(crate) fn parse_pull_status(status: &str) -> Option { + if status.contains("Downloaded newer image") { + Some(true) + } else if status.contains("Image is up to date") { + Some(false) + } else { + None + } +} + +/// Get the first repo digest for an image, falling back to the image ID. +async fn get_image_digest(docker: &bollard::Docker, image_name: &str) -> String { + match docker.inspect_image(image_name).await { + Ok(info) => { + info.repo_digests + .and_then(|d| d.into_iter().next()) + .or_else(|| info.id) + .unwrap_or_default() + } + Err(e) => { + warn!("inspect_image {} failed: {:#}", image_name, e); + String::new() + } + } +} + +/// Perform a container update via bollard recreate (always). +pub(crate) async fn update_container_handler( + docker: &bollard::Docker, + container_id: &str, +) -> Result<()> { + let inspect = docker + .inspect_container(container_id, None) + .await + .with_context(|| format!("inspect container {container_id}"))?; + + info!("updating container {container_id} via bollard recreate"); + standalone_recreate(docker, container_id, &inspect).await +} + +/// Recreate a standalone container with its original config. +async fn standalone_recreate( + docker: &bollard::Docker, + container_id: &str, + inspect: &bollard::models::ContainerInspectResponse, +) -> Result<()> { + let image_name = inspect + .config + .as_ref() + .and_then(|c| c.image.as_deref()) + .ok_or_else(|| anyhow::anyhow!("container has no image config"))? + .to_string(); + + // Strip leading '/' from container name + let container_name = inspect + .name + .as_deref() + .unwrap_or(container_id) + .trim_start_matches('/') + .to_string(); + + // Collect extra networks before stopping (all networks except the primary one) + let extra_networks: Vec = { + let primary = inspect + .host_config + .as_ref() + .and_then(|hc| hc.network_mode.as_deref()) + .unwrap_or("bridge") + .to_string(); + inspect + .network_settings + .as_ref() + .and_then(|ns| ns.networks.as_ref()) + .map(|nets| { + nets.keys() + .filter(|n| **n != primary) + .cloned() + .collect() + }) + .unwrap_or_default() + }; + + // 1. Pull the new image + pull_image_and_detect_update(docker, &image_name).await?; + + // 2. Stop the container (ignore error if already stopped) + let _ = docker + .stop_container(container_id, Some(StopContainerOptions { t: 10 })) + .await; + + // 3. Remove the container + docker + .remove_container( + container_id, + Some(RemoveContainerOptions { + force: true, + ..Default::default() + }), + ) + .await + .with_context(|| format!("remove container {container_id}"))?; + + // 4. Recreate with the same config + let config = inspect.config.clone().unwrap_or_default(); + let host_config = inspect.host_config.clone(); + + let create_config = Config { + image: Some(image_name.clone()), + cmd: config.cmd, + env: config.env, + labels: config.labels, + exposed_ports: config.exposed_ports, + volumes: config.volumes, + working_dir: config.working_dir, + entrypoint: config.entrypoint, + host_config, + ..Default::default() + }; + + let created = docker + .create_container( + Some(CreateContainerOptions { + name: container_name.as_str(), + ..Default::default() + }), + create_config, + ) + .await + .with_context(|| format!("create container {container_name}"))?; + + // 5. Start the new container + docker + .start_container(&created.id, None::>) + .await + .with_context(|| format!("start container {}", created.id))?; + + // 6. Reconnect to extra networks + for net_name in &extra_networks { + let _ = docker + .connect_network( + net_name, + ConnectNetworkOptions { + container: created.id.clone(), + endpoint_config: EndpointSettings::default(), + }, + ) + .await; + } + + info!("container {container_name} recreated with new image {image_name}"); + Ok(()) +} + // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] @@ -457,4 +899,258 @@ mod tests { assert_eq!(chunk.stream, "stdout"); assert_eq!(chunk.data, b"hello"); } + + // ── DirEntry JSON serialization ─────────────────────────────────────────── + + #[test] + fn dir_entry_serializes_file() { + let entry = DirEntry { name: "foo.txt".to_string(), is_dir: false, has_compose: false }; + let json = serde_json::to_string(&entry).unwrap(); + assert!(json.contains(r#""name":"foo.txt""#)); + assert!(json.contains(r#""is_dir":false"#)); + } + + #[test] + fn dir_entry_serializes_directory() { + let entry = DirEntry { name: "subdir".to_string(), is_dir: true, has_compose: false }; + let json = serde_json::to_string(&entry).unwrap(); + assert!(json.contains(r#""name":"subdir""#)); + assert!(json.contains(r#""is_dir":true"#)); + } + + #[test] + fn dir_entries_serialize_as_array() { + let entries = vec![ + DirEntry { name: "a".to_string(), is_dir: false, has_compose: false }, + DirEntry { name: "b".to_string(), is_dir: true, has_compose: false }, + ]; + let bytes = serde_json::to_vec(&entries).unwrap(); + let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert!(parsed.is_array()); + assert_eq!(parsed.as_array().unwrap().len(), 2); + assert_eq!(parsed[0]["name"], "a"); + assert_eq!(parsed[1]["is_dir"], true); + } + + #[test] + fn dir_entry_empty_name_is_valid() { + let entry = DirEntry { name: String::new(), is_dir: false, has_compose: false }; + let json = serde_json::to_string(&entry).unwrap(); + assert!(json.contains(r#""name":"""#)); + } + + #[test] + fn dir_entry_roundtrips_via_json() { + let entries = vec![ + DirEntry { name: "compose.yml".to_string(), is_dir: false, has_compose: false }, + DirEntry { name: "data".to_string(), is_dir: true, has_compose: false }, + ]; + let bytes = serde_json::to_vec(&entries).unwrap(); + let parsed: Vec = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(parsed[0]["name"], "compose.yml"); + assert_eq!(parsed[0]["is_dir"], false); + assert_eq!(parsed[1]["name"], "data"); + assert_eq!(parsed[1]["is_dir"], true); + } + + // ── FileResult proto construction ───────────────────────────────────────── + + #[test] + fn file_result_success_fields() { + let fr = FileResult { + command_id: "cmd-fs-1".to_string(), + success: true, + error: String::new(), + content: b"hello world".to_vec(), + }; + assert!(fr.success); + assert!(fr.error.is_empty()); + assert_eq!(fr.content, b"hello world"); + } + + #[test] + fn file_result_error_fields() { + let fr = FileResult { + command_id: "cmd-fs-2".to_string(), + success: false, + error: "permission denied".to_string(), + content: vec![], + }; + assert!(!fr.success); + assert_eq!(fr.error, "permission denied"); + assert!(fr.content.is_empty()); + } + + #[test] + fn agent_message_wraps_file_result() { + let fr = FileResult { + command_id: "x".to_string(), + success: true, + error: String::new(), + content: vec![], + }; + let msg = AgentMessage { + payload: Some(agent_message::Payload::FileResult(fr)), + }; + assert!(matches!( + msg.payload, + Some(agent_message::Payload::FileResult(_)) + )); + } + + // ── CreateDir message construction ──────────────────────────────────────── + + #[test] + fn create_dir_success_builds_correct_file_result() { + // Simulate the success branch of the CreateDir handler + let cmd_id = "create-dir-cmd-1".to_string(); + let (success, error) = (true, String::new()); + let fr = FileResult { + command_id: cmd_id.clone(), + success, + error, + content: vec![], + }; + let msg = AgentMessage { + payload: Some(agent_message::Payload::FileResult(fr)), + }; + // The message must wrap a FileResult with success=true and no content + if let Some(agent_message::Payload::FileResult(fr)) = msg.payload { + assert!(fr.success); + assert!(fr.error.is_empty()); + assert!(fr.content.is_empty()); + assert_eq!(fr.command_id, cmd_id); + } else { + panic!("expected FileResult payload"); + } + } + + #[test] + fn create_dir_error_builds_correct_file_result() { + // Simulate the error branch of the CreateDir handler + let cmd_id = "create-dir-cmd-2".to_string(); + let err_msg = "permission denied (os error 13)".to_string(); + let (success, error) = (false, err_msg.clone()); + let fr = FileResult { + command_id: cmd_id.clone(), + success, + error, + content: vec![], + }; + let msg = AgentMessage { + payload: Some(agent_message::Payload::FileResult(fr)), + }; + if let Some(agent_message::Payload::FileResult(fr)) = msg.payload { + assert!(!fr.success); + assert_eq!(fr.error, err_msg); + assert!(fr.content.is_empty()); + assert_eq!(fr.command_id, cmd_id); + } else { + panic!("expected FileResult payload"); + } + } + + // ── parse_pull_status — unit tests ──────────────────────────────────────── + + #[test] + fn parse_pull_status_detects_newer_image() { + let status = "Status: Downloaded newer image for nginx:latest"; + assert_eq!(parse_pull_status(status), Some(true)); + } + + #[test] + fn parse_pull_status_detects_up_to_date() { + let status = "Status: Image is up to date for nginx:latest"; + assert_eq!(parse_pull_status(status), Some(false)); + } + + #[test] + fn parse_pull_status_returns_none_for_progress() { + assert_eq!(parse_pull_status("Pulling from library/nginx"), None); + assert_eq!(parse_pull_status("Pull complete"), None); + assert_eq!(parse_pull_status("Digest: sha256:abc123"), None); + } + + #[test] + fn parse_pull_status_returns_none_for_empty_string() { + assert_eq!(parse_pull_status(""), None); + } + + #[test] + fn parse_pull_status_newer_image_takes_priority() { + // Edge case: both substrings in same string (shouldn't happen in practice) + let status = "Downloaded newer image is up to date"; + assert_eq!(parse_pull_status(status), Some(true)); + } + + #[test] + fn parse_pull_status_case_sensitive() { + // Docker sends title-case; lowercase should not match + assert_eq!(parse_pull_status("downloaded newer image"), None); + assert_eq!(parse_pull_status("image is up to date"), None); + } + + // ── UpdateCheckResult proto construction ────────────────────────────────── + + #[test] + fn update_check_result_no_update_fields() { + let r = UpdateCheckResult { + command_id: "chk-1".to_string(), + container_id: "c1".to_string(), + update_available: false, + current_digest: "sha256:aaa".to_string(), + remote_digest: "sha256:aaa".to_string(), + error: String::new(), + }; + assert!(!r.update_available); + assert_eq!(r.current_digest, r.remote_digest); + assert!(r.error.is_empty()); + } + + #[test] + fn update_check_result_update_available_fields() { + let r = UpdateCheckResult { + command_id: "chk-2".to_string(), + container_id: "c2".to_string(), + update_available: true, + current_digest: "sha256:old".to_string(), + remote_digest: "sha256:new".to_string(), + error: String::new(), + }; + assert!(r.update_available); + assert_ne!(r.current_digest, r.remote_digest); + } + + #[test] + fn update_check_result_error_fields() { + let r = UpdateCheckResult { + command_id: "chk-3".to_string(), + container_id: "c3".to_string(), + update_available: false, + current_digest: String::new(), + remote_digest: String::new(), + error: "container not found".to_string(), + }; + assert!(!r.update_available); + assert!(!r.error.is_empty()); + } + + #[test] + fn agent_message_wraps_update_check_result() { + let r = UpdateCheckResult { + command_id: "chk-4".to_string(), + container_id: "c4".to_string(), + update_available: true, + current_digest: "sha256:old".to_string(), + remote_digest: "sha256:new".to_string(), + error: String::new(), + }; + let msg = AgentMessage { + payload: Some(agent_message::Payload::UpdateCheckResult(r)), + }; + assert!(matches!( + msg.payload, + Some(agent_message::Payload::UpdateCheckResult(_)) + )); + } } diff --git a/proto/agent/v1/agent.proto b/proto/agent/v1/agent.proto index d3d2381..7e106bc 100644 --- a/proto/agent/v1/agent.proto +++ b/proto/agent/v1/agent.proto @@ -75,12 +75,30 @@ message LogChunk { int64 timestamp = 4; } +message FileResult { + string command_id = 1; + bool success = 2; + string error = 3; + bytes content = 4; // for ReadFile: file content; for ListDir: JSON-encoded entries +} + +message UpdateCheckResult { + string command_id = 1; + string container_id = 2; + bool update_available = 3; + string current_digest = 4; + string remote_digest = 5; + string error = 6; +} + message AgentMessage { oneof payload { - AgentHandshake handshake = 1; - ContainerSnapshot snapshot = 2; - CommandResult result = 3; - LogChunk log_chunk = 4; + AgentHandshake handshake = 1; + ContainerSnapshot snapshot = 2; + CommandResult result = 3; + LogChunk log_chunk = 4; + FileResult file_result = 5; + UpdateCheckResult update_check_result = 6; } } @@ -107,10 +125,54 @@ message StreamLogsCommand { int32 tail = 4; } +message ListDirCommand { + string command_id = 1; + string path = 2; +} + +message ReadFileCommand { + string command_id = 1; + string path = 2; +} + +message WriteFileCommand { + string command_id = 1; + string path = 2; + bytes content = 3; +} + +message ExecComposeCommand { + string command_id = 1; + string path = 2; // directory containing docker-compose.yaml + string action = 3; // "up", "down", "pull" +} + +message CreateDirCommand { + string command_id = 1; + string path = 2; +} + +message CheckUpdateCommand { + string command_id = 1; + string container_id = 2; +} + +message UpdateContainerCommand { + string command_id = 1; + string container_id = 2; +} + message ServerMessage { oneof payload { - ContainerCommand container_cmd = 1; - StreamLogsCommand stream_logs = 2; + ContainerCommand container_cmd = 1; + StreamLogsCommand stream_logs = 2; + ListDirCommand list_dir = 3; + ReadFileCommand read_file = 4; + WriteFileCommand write_file = 5; + ExecComposeCommand exec_compose = 6; + CreateDirCommand create_dir = 7; + CheckUpdateCommand check_update = 8; + UpdateContainerCommand update_container = 9; } } diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 9e8d8fe..9ac3dd0 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -18,6 +18,7 @@ import ( "github.com/containarr/server/internal/broker" grpcgateway "github.com/containarr/server/internal/grpc" agentv1 "github.com/containarr/server/internal/proto/agentv1" + "github.com/containarr/server/internal/scheduler" "github.com/containarr/server/internal/store" "google.golang.org/grpc" ) @@ -39,6 +40,14 @@ func main() { reg := grpcgateway.NewRegistry() brk := broker.New() + // Root context cancelled on shutdown signal. + rootCtx, rootCancel := context.WithCancel(context.Background()) + defer rootCancel() + + // Scheduler. + sched := scheduler.New(scheduler.NewStoreAdapter(db), reg) + go sched.Start(rootCtx) + // gRPC server. gw := grpcgateway.NewGateway(db, reg, brk) grpcServer := grpc.NewServer() @@ -76,6 +85,7 @@ func main() { <-quit slog.Info("shutting down") + rootCancel() grpcServer.GracefulStop() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() diff --git a/server/internal/api/api_test.go b/server/internal/api/api_test.go index 86ed28e..5f1ee2d 100644 --- a/server/internal/api/api_test.go +++ b/server/internal/api/api_test.go @@ -2,6 +2,7 @@ package api import ( "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" @@ -801,3 +802,246 @@ func TestContainerAction_Success(t *testing.T) { t.Error("expected command_id in response") } } + +// newCancelledRequest creates a request with an already-cancelled context. +func newCancelledRequest(method, target string, body *bytes.Reader) *http.Request { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + var req *http.Request + if body != nil { + req = httptest.NewRequest(method, target, body) + } else { + req = httptest.NewRequest(method, target, nil) + } + return req.WithContext(ctx) +} + +// ── FsList ──────────────────────────────────────────────────────────────────── + +func TestFsList_AgentNotFound(t *testing.T) { + h, _, _, _ := newTestHandler(t) + + router := chi.NewRouter() + router.Get("/api/v1/agents/{agentID}/fs/list", h.FsList) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/ghost/fs/list?path=/tmp", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", w.Code) + } +} + +func TestFsList_Timeout(t *testing.T) { + h, _, reg, _ := newTestHandler(t) + reg.Register("a1", "h", "a", "ip", "arch", "os") + + router := chi.NewRouter() + router.Get("/api/v1/agents/{agentID}/fs/list", h.FsList) + + // Use cancelled context to force immediate timeout on the agent wait. + req := newCancelledRequest(http.MethodGet, "/api/v1/agents/a1/fs/list?path=/tmp", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Either 504 (timeout) or 404 (send failed because channel was full/cancelled). + if w.Code != http.StatusGatewayTimeout && w.Code != http.StatusNotFound { + t.Errorf("expected 504 or 404, got %d", w.Code) + } +} + + +func TestFsList_MissingPath(t *testing.T) { + h, _, reg, _ := newTestHandler(t) + reg.Register("a1", "h", "a", "ip", "arch", "os") + + router := chi.NewRouter() + router.Get("/api/v1/agents/{agentID}/fs/list", h.FsList) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/a1/fs/list", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +// ── FsRead ──────────────────────────────────────────────────────────────────── + +func TestFsRead_AgentNotFound(t *testing.T) { + h, _, _, _ := newTestHandler(t) + + router := chi.NewRouter() + router.Get("/api/v1/agents/{agentID}/fs/read", h.FsRead) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/ghost/fs/read?path=/etc/hosts", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", w.Code) + } +} + +func TestFsRead_MissingPath(t *testing.T) { + h, _, reg, _ := newTestHandler(t) + reg.Register("a1", "h", "a", "ip", "arch", "os") + + router := chi.NewRouter() + router.Get("/api/v1/agents/{agentID}/fs/read", h.FsRead) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/a1/fs/read", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +// ── FsWrite ─────────────────────────────────────────────────────────────────── + +func TestFsWrite_AgentNotFound(t *testing.T) { + h, _, _, _ := newTestHandler(t) + + router := chi.NewRouter() + router.Post("/api/v1/agents/{agentID}/fs/write", h.FsWrite) + + body, _ := json.Marshal(map[string]string{"path": "/tmp/test.txt", "content": "hello"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/ghost/fs/write", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", w.Code) + } +} + +func TestFsWrite_MissingPath(t *testing.T) { + h, _, reg, _ := newTestHandler(t) + reg.Register("a1", "h", "a", "ip", "arch", "os") + + router := chi.NewRouter() + router.Post("/api/v1/agents/{agentID}/fs/write", h.FsWrite) + + body, _ := json.Marshal(map[string]string{"content": "hello"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a1/fs/write", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +// ── FsMkdir ─────────────────────────────────────────────────────────────────── + +func TestFsMkdir_AgentNotFound(t *testing.T) { + h, _, _, _ := newTestHandler(t) + + router := chi.NewRouter() + router.Post("/api/v1/agents/{agentID}/fs/mkdir", h.FsMkdir) + + body, _ := json.Marshal(map[string]string{"path": "/opt/stacks/nouveau-dossier"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/ghost/fs/mkdir", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", w.Code) + } +} + +func TestFsMkdir_InvalidBody(t *testing.T) { + h, _, reg, _ := newTestHandler(t) + reg.Register("a1", "h", "a", "ip", "arch", "os") + + router := chi.NewRouter() + router.Post("/api/v1/agents/{agentID}/fs/mkdir", h.FsMkdir) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a1/fs/mkdir", bytes.NewReader([]byte("not-json"))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +// ── ComposeAction ───────────────────────────────────────────────────────────── + +func TestComposeAction_AgentNotFound(t *testing.T) { + h, _, _, _ := newTestHandler(t) + + router := chi.NewRouter() + router.Post("/api/v1/agents/{agentID}/compose", h.ComposeAction) + + body, _ := json.Marshal(map[string]string{"path": "/opt/stack", "action": "up"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/ghost/compose", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", w.Code) + } +} + +func TestComposeAction_InvalidAction(t *testing.T) { + h, _, reg, _ := newTestHandler(t) + reg.Register("a1", "h", "a", "ip", "arch", "os") + + router := chi.NewRouter() + router.Post("/api/v1/agents/{agentID}/compose", h.ComposeAction) + + body, _ := json.Marshal(map[string]string{"path": "/opt/stack", "action": "restart"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a1/compose", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestComposeAction_MissingFields(t *testing.T) { + h, _, _, _ := newTestHandler(t) + + router := chi.NewRouter() + router.Post("/api/v1/agents/{agentID}/compose", h.ComposeAction) + + body, _ := json.Marshal(map[string]string{"action": "up"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/ghost/compose", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestComposeAction_Timeout(t *testing.T) { + h, _, reg, _ := newTestHandler(t) + reg.Register("a1", "h", "a", "ip", "arch", "os") + + router := chi.NewRouter() + router.Post("/api/v1/agents/{agentID}/compose", h.ComposeAction) + + bodyBytes, _ := json.Marshal(map[string]string{"path": "/opt/stack", "action": "up"}) + req := newCancelledRequest(http.MethodPost, "/api/v1/agents/a1/compose", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusGatewayTimeout && w.Code != http.StatusNotFound { + t.Errorf("expected 504 or 404, got %d", w.Code) + } +} diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index bd206ce..e5d02f3 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "net/http" "strconv" @@ -185,6 +186,7 @@ func (h *Handler) ListImages(w http.ResponseWriter, r *http.Request) { AgentID string `json:"agent_id"` Hostname string `json:"hostname"` Alias string `json:"alias"` + IPAddress string `json:"ip_address"` ID string `json:"id"` Tags []string `json:"tags"` Size int64 `json:"size"` @@ -197,8 +199,9 @@ func (h *Handler) ListImages(w http.ResponseWriter, r *http.Request) { AgentID: agent.ID, Hostname: agent.Hostname, Alias: agent.Alias, + IPAddress: agent.IPAddress, ID: img.GetId(), - Tags: img.GetTags(), + Tags: func() []string { if t := img.GetTags(); t != nil { return t }; return []string{} }(), Size: img.GetSize(), CreatedAt: img.GetCreatedAt(), }) @@ -214,6 +217,7 @@ func (h *Handler) ListVolumes(w http.ResponseWriter, r *http.Request) { AgentID string `json:"agent_id"` Hostname string `json:"hostname"` Alias string `json:"alias"` + IPAddress string `json:"ip_address"` Name string `json:"name"` Driver string `json:"driver"` Mountpoint string `json:"mountpoint"` @@ -225,6 +229,7 @@ func (h *Handler) ListVolumes(w http.ResponseWriter, r *http.Request) { AgentID: agent.ID, Hostname: agent.Hostname, Alias: agent.Alias, + IPAddress: agent.IPAddress, Name: vol.GetName(), Driver: vol.GetDriver(), Mountpoint: vol.GetMountpoint(), @@ -238,25 +243,27 @@ func (h *Handler) ListVolumes(w http.ResponseWriter, r *http.Request) { func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) { type networkDTO struct { - AgentID string `json:"agent_id"` - Hostname string `json:"hostname"` - Alias string `json:"alias"` - ID string `json:"id"` - Name string `json:"name"` - Driver string `json:"driver"` - Scope string `json:"scope"` + AgentID string `json:"agent_id"` + Hostname string `json:"hostname"` + Alias string `json:"alias"` + IPAddress string `json:"ip_address"` + ID string `json:"id"` + Name string `json:"name"` + Driver string `json:"driver"` + Scope string `json:"scope"` } var out []networkDTO for _, agent := range h.registry.List() { for _, net := range agent.Networks { out = append(out, networkDTO{ - AgentID: agent.ID, - Hostname: agent.Hostname, - Alias: agent.Alias, - ID: net.GetId(), - Name: net.GetName(), - Driver: net.GetDriver(), - Scope: net.GetScope(), + AgentID: agent.ID, + Hostname: agent.Hostname, + Alias: agent.Alias, + IPAddress: agent.IPAddress, + ID: net.GetId(), + Name: net.GetName(), + Driver: net.GetDriver(), + Scope: net.GetScope(), }) } } @@ -390,6 +397,292 @@ func (h *Handler) EventsWS(w http.ResponseWriter, r *http.Request) { } } +// ── File system & Compose ───────────────────────────────────────────────────── + +// sendFileCmd sends a file/compose command to an agent and waits for the response. +// It uses the request context with an added 30s deadline so the handler can be +// tested by cancelling the context. +func (h *Handler) sendFileCmd(r *http.Request, agentID string, msg *agentv1.ServerMessage, cmdID string) (*agentv1.FileResult, error) { + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + return h.registry.SendAndWaitCtx(ctx, agentID, msg, cmdID) +} + +// FsList handles GET /api/v1/agents/{agentID}/fs/list?path=/some/dir +func (h *Handler) FsList(w http.ResponseWriter, r *http.Request) { + agentID := chi.URLParam(r, "agentID") + path := r.URL.Query().Get("path") + if path == "" { + http.Error(w, "path required", http.StatusBadRequest) + return + } + + cmdID := uuid.NewString() + result, err := h.sendFileCmd(r, agentID, &agentv1.ServerMessage{ + Payload: &agentv1.ServerMessage_ListDir{ + ListDir: &agentv1.ListDirCommand{ + CommandId: cmdID, + Path: path, + }, + }, + }, cmdID) + if err != nil { + if err.Error() == "agent not connected" { + http.Error(w, "agent not connected", http.StatusNotFound) + return + } + http.Error(w, "timeout waiting for agent", http.StatusGatewayTimeout) + return + } + if !result.Success { + http.Error(w, result.Error, http.StatusInternalServerError) + return + } + + // Content is JSON-encoded list of entries from the agent + var entries json.RawMessage = result.Content + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(entries) +} + +// FsRead handles GET /api/v1/agents/{agentID}/fs/read?path=/some/file +func (h *Handler) FsRead(w http.ResponseWriter, r *http.Request) { + agentID := chi.URLParam(r, "agentID") + path := r.URL.Query().Get("path") + if path == "" { + http.Error(w, "path required", http.StatusBadRequest) + return + } + + cmdID := uuid.NewString() + result, err := h.sendFileCmd(r, agentID, &agentv1.ServerMessage{ + Payload: &agentv1.ServerMessage_ReadFile{ + ReadFile: &agentv1.ReadFileCommand{ + CommandId: cmdID, + Path: path, + }, + }, + }, cmdID) + if err != nil { + if err.Error() == "agent not connected" { + http.Error(w, "agent not connected", http.StatusNotFound) + return + } + http.Error(w, "timeout waiting for agent", http.StatusGatewayTimeout) + return + } + if !result.Success { + http.Error(w, result.Error, http.StatusInternalServerError) + return + } + + jsonOK(w, map[string]string{"content": string(result.Content)}) +} + +// FsWrite handles POST /api/v1/agents/{agentID}/fs/write +func (h *Handler) FsWrite(w http.ResponseWriter, r *http.Request) { + agentID := chi.URLParam(r, "agentID") + + var body struct { + Path string `json:"path"` + Content string `json:"content"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Path == "" { + http.Error(w, "path and content required", http.StatusBadRequest) + return + } + + cmdID := uuid.NewString() + result, err := h.sendFileCmd(r, agentID, &agentv1.ServerMessage{ + Payload: &agentv1.ServerMessage_WriteFile{ + WriteFile: &agentv1.WriteFileCommand{ + CommandId: cmdID, + Path: body.Path, + Content: []byte(body.Content), + }, + }, + }, cmdID) + if err != nil { + if err.Error() == "agent not connected" { + http.Error(w, "agent not connected", http.StatusNotFound) + return + } + http.Error(w, "timeout waiting for agent", http.StatusGatewayTimeout) + return + } + if !result.Success { + http.Error(w, result.Error, http.StatusInternalServerError) + return + } + + jsonOK(w, map[string]bool{"ok": true}) +} + +// FsMkdir handles POST /api/v1/agents/{agentID}/fs/mkdir +func (h *Handler) FsMkdir(w http.ResponseWriter, r *http.Request) { + agentID := chi.URLParam(r, "agentID") + + var body struct { + Path string `json:"path"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Path == "" { + http.Error(w, "path required", http.StatusBadRequest) + return + } + + cmdID := uuid.NewString() + result, err := h.sendFileCmd(r, agentID, &agentv1.ServerMessage{ + Payload: &agentv1.ServerMessage_CreateDir{ + CreateDir: &agentv1.CreateDirCommand{ + CommandId: cmdID, + Path: body.Path, + }, + }, + }, cmdID) + if err != nil { + if err.Error() == "agent not connected" { + http.Error(w, "agent not connected", http.StatusNotFound) + return + } + http.Error(w, "timeout waiting for agent", http.StatusGatewayTimeout) + return + } + if !result.Success { + http.Error(w, result.Error, http.StatusInternalServerError) + return + } + + jsonOK(w, map[string]bool{"ok": true}) +} + +// ComposeAction handles POST /api/v1/agents/{agentID}/compose +func (h *Handler) ComposeAction(w http.ResponseWriter, r *http.Request) { + agentID := chi.URLParam(r, "agentID") + + var body struct { + Path string `json:"path"` + Action string `json:"action"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Path == "" || body.Action == "" { + http.Error(w, "path and action required", http.StatusBadRequest) + return + } + + validActions := map[string]bool{"up": true, "down": true, "pull": true} + if !validActions[body.Action] { + http.Error(w, "action must be one of: up, down, pull", http.StatusBadRequest) + return + } + + cmdID := uuid.NewString() + result, err := h.sendFileCmd(r, agentID, &agentv1.ServerMessage{ + Payload: &agentv1.ServerMessage_ExecCompose{ + ExecCompose: &agentv1.ExecComposeCommand{ + CommandId: cmdID, + Path: body.Path, + Action: body.Action, + }, + }, + }, cmdID) + if err != nil { + if err.Error() == "agent not connected" { + http.Error(w, "agent not connected", http.StatusNotFound) + return + } + http.Error(w, "timeout waiting for agent", http.StatusGatewayTimeout) + return + } + if !result.Success { + jsonErr, _ := json.Marshal(map[string]string{"error": result.Error, "output": string(result.Content)}) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + w.Write(jsonErr) + return + } + + jsonOK(w, map[string]any{"ok": true, "output": string(result.Content)}) +} + +// ── Auto-update policies ────────────────────────────────────────────────────── + +// GetAutoUpdatePolicy handles GET /api/v1/agents/{agentID}/containers/{containerID}/auto-update +func (h *Handler) GetAutoUpdatePolicy(w http.ResponseWriter, r *http.Request) { + agentID := chi.URLParam(r, "agentID") + containerID := chi.URLParam(r, "containerID") + + p, err := h.store.GetAutoUpdatePolicy(agentID, containerID) + if err != nil { + http.Error(w, "store error", http.StatusInternalServerError) + return + } + if p == nil { + jsonOK(w, map[string]any{"enabled": false, "interval_minutes": 1440}) + return + } + jsonOK(w, map[string]any{ + "enabled": p.Enabled, + "interval_minutes": p.IntervalMinutes, + "last_checked_at": p.LastCheckedAt, + "last_updated_at": p.LastUpdatedAt, + }) +} + +// PutAutoUpdatePolicy handles PUT /api/v1/agents/{agentID}/containers/{containerID}/auto-update +func (h *Handler) PutAutoUpdatePolicy(w http.ResponseWriter, r *http.Request) { + agentID := chi.URLParam(r, "agentID") + containerID := chi.URLParam(r, "containerID") + + var body struct { + Enabled bool `json:"enabled"` + IntervalMinutes int `json:"interval_minutes"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid body", http.StatusBadRequest) + return + } + if body.IntervalMinutes < 60 || body.IntervalMinutes > 43200 { + http.Error(w, "interval_minutes must be between 60 and 43200", http.StatusBadRequest) + return + } + + p := &store.AutoUpdatePolicy{ + AgentID: agentID, + ContainerID: containerID, + Enabled: body.Enabled, + IntervalMinutes: body.IntervalMinutes, + } + if err := h.store.UpsertAutoUpdatePolicy(p); err != nil { + http.Error(w, "store error", http.StatusInternalServerError) + return + } + jsonOK(w, map[string]any{ + "enabled": p.Enabled, + "interval_minutes": p.IntervalMinutes, + }) +} + +// UpdateNow handles POST /api/v1/agents/{agentID}/containers/{containerID}/update-now +func (h *Handler) UpdateNow(w http.ResponseWriter, r *http.Request) { + agentID := chi.URLParam(r, "agentID") + containerID := chi.URLParam(r, "containerID") + + cmdID := uuid.NewString() + sent := h.registry.Send(agentID, &agentv1.ServerMessage{ + Payload: &agentv1.ServerMessage_UpdateContainer{ + UpdateContainer: &agentv1.UpdateContainerCommand{ + CommandId: cmdID, + ContainerId: containerID, + }, + }, + }) + if !sent { + http.Error(w, "agent not connected", http.StatusServiceUnavailable) + return + } + h.registry.RegisterPendingUpdate(agentID, cmdID, containerID) + jsonOK(w, map[string]string{"command_id": cmdID}) +} + func jsonOK(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(v) diff --git a/server/internal/api/router.go b/server/internal/api/router.go index ac8ed03..25f8a92 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -52,6 +52,14 @@ func NewRouter(h *Handler) http.Handler { r.Post("/agents/{agentID}/containers/{containerID}/action", h.ContainerAction) r.Get("/agents/{agentID}/containers/{containerID}/logs", h.LogsWS) r.Get("/events", h.EventsWS) + r.Get("/agents/{agentID}/fs/list", h.FsList) + r.Get("/agents/{agentID}/fs/read", h.FsRead) + r.Post("/agents/{agentID}/fs/write", h.FsWrite) + r.Post("/agents/{agentID}/fs/mkdir", h.FsMkdir) + r.Post("/agents/{agentID}/compose", h.ComposeAction) + r.Get("/agents/{agentID}/containers/{containerID}/auto-update", h.GetAutoUpdatePolicy) + r.Put("/agents/{agentID}/containers/{containerID}/auto-update", h.PutAutoUpdatePolicy) + r.Post("/agents/{agentID}/containers/{containerID}/update-now", h.UpdateNow) }) }) diff --git a/server/internal/grpc/gateway.go b/server/internal/grpc/gateway.go index 246da70..19ee58f 100644 --- a/server/internal/grpc/gateway.go +++ b/server/internal/grpc/gateway.go @@ -4,6 +4,7 @@ import ( "io" "log/slog" "net" + "time" "github.com/containarr/server/internal/broker" agentv1 "github.com/containarr/server/internal/proto/agentv1" @@ -125,11 +126,21 @@ func (g *Gateway) Tunnel(stream agentv1.AgentGateway_TunnelServer) error { }) case *agentv1.AgentMessage_Result: + res := p.Result g.broker.Publish(broker.Event{ Type: "command.result", AgentID: agentID, - Payload: p.Result, + Payload: res, }) + if containerID, found := g.registry.ResolvePendingUpdate(agentID, res.CommandId); found { + now := time.Now() + _ = g.store.UpdateAutoUpdateChecked(agentID, containerID, now) + if res.Success { + _ = g.store.UpdateAutoUpdateDone(agentID, containerID, now) + } else { + slog.Warn("update container failed", "agent_id", agentID, "container_id", containerID, "error", res.Error) + } + } case *agentv1.AgentMessage_LogChunk: g.broker.Publish(broker.Event{ @@ -137,6 +148,29 @@ func (g *Gateway) Tunnel(stream agentv1.AgentGateway_TunnelServer) error { AgentID: agentID, Payload: p.LogChunk, }) + + case *agentv1.AgentMessage_FileResult: + g.registry.ResolvePending(agentID, p.FileResult.CommandId, p.FileResult) + + case *agentv1.AgentMessage_UpdateCheckResult: + res := p.UpdateCheckResult + if res.Error != "" { + slog.Warn("update check error", "agent_id", agentID, "container_id", res.ContainerId, "error", res.Error) + } + _ = g.store.UpdateAutoUpdateChecked(agentID, res.ContainerId, time.Now()) + if res.UpdateAvailable { + cmdID := newCommandID() + slog.Info("update available, triggering UpdateContainerCommand", "agent_id", agentID, "container_id", res.ContainerId, "command_id", cmdID) + g.registry.Send(agentID, &agentv1.ServerMessage{ + Payload: &agentv1.ServerMessage_UpdateContainer{ + UpdateContainer: &agentv1.UpdateContainerCommand{ + CommandId: cmdID, + ContainerId: res.ContainerId, + }, + }, + }) + g.registry.RegisterPendingUpdate(agentID, cmdID, res.ContainerId) + } } } } diff --git a/server/internal/grpc/registry.go b/server/internal/grpc/registry.go index f7a1417..4570c0f 100644 --- a/server/internal/grpc/registry.go +++ b/server/internal/grpc/registry.go @@ -1,6 +1,8 @@ package grpc import ( + "context" + "fmt" "sync" "time" @@ -20,7 +22,10 @@ type AgentState struct { Volumes []*agentv1.VolumeInfo Networks []*agentv1.NetworkInfo - cmdCh chan *agentv1.ServerMessage + cmdCh chan *agentv1.ServerMessage + pendingFiles map[string]chan *agentv1.FileResult + pendingUpdates map[string]string // commandID → containerID + pendingMu sync.Mutex } type Registry struct { @@ -34,13 +39,15 @@ func NewRegistry() *Registry { func (r *Registry) Register(id, hostname, alias, ipAddress, arch, os string) *AgentState { state := &AgentState{ - ID: id, - Hostname: hostname, - Alias: alias, - IPAddress: ipAddress, - Arch: arch, - OS: os, - cmdCh: make(chan *agentv1.ServerMessage, 16), + ID: id, + Hostname: hostname, + Alias: alias, + IPAddress: ipAddress, + Arch: arch, + OS: os, + cmdCh: make(chan *agentv1.ServerMessage, 16), + pendingFiles: make(map[string]chan *agentv1.FileResult), + pendingUpdates: make(map[string]string), } r.mu.Lock() r.agents[id] = state @@ -118,3 +125,113 @@ func (r *Registry) Send(agentID string, msg *agentv1.ServerMessage) bool { return false } } + +// RegisterPending registers a channel waiting for a FileResult with the given cmdID. +func (r *Registry) RegisterPending(agentID, cmdID string) chan *agentv1.FileResult { + r.mu.RLock() + s, ok := r.agents[agentID] + r.mu.RUnlock() + if !ok { + return nil + } + ch := make(chan *agentv1.FileResult, 1) + s.pendingMu.Lock() + s.pendingFiles[cmdID] = ch + s.pendingMu.Unlock() + return ch +} + +// ResolvePending sends the FileResult to the waiting channel identified by cmdID. +func (r *Registry) ResolvePending(agentID, cmdID string, result *agentv1.FileResult) { + r.mu.RLock() + s, ok := r.agents[agentID] + r.mu.RUnlock() + if !ok { + return + } + s.pendingMu.Lock() + ch, ok := s.pendingFiles[cmdID] + if ok { + delete(s.pendingFiles, cmdID) + } + s.pendingMu.Unlock() + if ok { + select { + case ch <- result: + default: + } + } +} + +// CancelPending removes the pending channel for cmdID (cleanup on timeout). +func (r *Registry) CancelPending(agentID, cmdID string) { + r.mu.RLock() + s, ok := r.agents[agentID] + r.mu.RUnlock() + if !ok { + return + } + s.pendingMu.Lock() + delete(s.pendingFiles, cmdID) + s.pendingMu.Unlock() +} + +// SendAndWait registers a pending channel, sends msg to the agent, and waits up +// to 30 seconds for the FileResult response identified by cmdID. +func (r *Registry) SendAndWait(agentID string, msg *agentv1.ServerMessage, cmdID string) (*agentv1.FileResult, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + return r.SendAndWaitCtx(ctx, agentID, msg, cmdID) +} + +// RegisterPendingUpdate enregistre un commandID en attente de CommandResult pour un UpdateContainer. +func (r *Registry) RegisterPendingUpdate(agentID, cmdID, containerID string) { + r.mu.RLock() + s, ok := r.agents[agentID] + r.mu.RUnlock() + if !ok { + return + } + s.pendingMu.Lock() + s.pendingUpdates[cmdID] = containerID + s.pendingMu.Unlock() +} + +// ResolvePendingUpdate retourne le containerID associé au commandID et le supprime de la map. +// Retourne ("", false) si le commandID n'est pas connu. +func (r *Registry) ResolvePendingUpdate(agentID, cmdID string) (string, bool) { + r.mu.RLock() + s, ok := r.agents[agentID] + r.mu.RUnlock() + if !ok { + return "", false + } + s.pendingMu.Lock() + containerID, found := s.pendingUpdates[cmdID] + if found { + delete(s.pendingUpdates, cmdID) + } + s.pendingMu.Unlock() + return containerID, found +} + +// SendAndWaitCtx is like SendAndWait but uses the provided context for timeout control. +func (r *Registry) SendAndWaitCtx(ctx context.Context, agentID string, msg *agentv1.ServerMessage, cmdID string) (*agentv1.FileResult, error) { + ch := r.RegisterPending(agentID, cmdID) + if ch == nil { + return nil, fmt.Errorf("agent not connected") + } + + if !r.Send(agentID, msg) { + r.CancelPending(agentID, cmdID) + return nil, fmt.Errorf("agent not connected") + } + + select { + case result := <-ch: + return result, nil + case <-ctx.Done(): + r.CancelPending(agentID, cmdID) + return nil, fmt.Errorf("timeout waiting for agent response") + } +} diff --git a/server/internal/grpc/registry_test.go b/server/internal/grpc/registry_test.go index 3f2d3d8..4d4088b 100644 --- a/server/internal/grpc/registry_test.go +++ b/server/internal/grpc/registry_test.go @@ -1,6 +1,7 @@ package grpc import ( + "context" "testing" "time" @@ -153,3 +154,112 @@ func TestSend_FullChannel(t *testing.T) { t.Error("Send should return false when channel is full") } } + +// ── Pending file correlations ────────────────────────────────────────────────── + +func TestRegisterPending_UnknownAgent(t *testing.T) { + r := NewRegistry() + ch := r.RegisterPending("ghost", "cmd1") + if ch != nil { + t.Error("expected nil channel for unknown agent") + } +} + +func TestResolvePending_Success(t *testing.T) { + r := NewRegistry() + r.Register("id1", "h", "a", "ip", "arch", "os") + + ch := r.RegisterPending("id1", "cmd1") + if ch == nil { + t.Fatal("expected non-nil channel") + } + + result := &agentv1.FileResult{CommandId: "cmd1", Success: true, Content: []byte("data")} + r.ResolvePending("id1", "cmd1", result) + + select { + case got := <-ch: + if got.CommandId != "cmd1" || !got.Success { + t.Errorf("unexpected result: %+v", got) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for resolve") + } +} + +func TestResolvePending_UnknownAgent(t *testing.T) { + r := NewRegistry() + // must not panic + r.ResolvePending("ghost", "cmd1", &agentv1.FileResult{}) +} + +func TestResolvePending_UnknownCmd(t *testing.T) { + r := NewRegistry() + r.Register("id1", "h", "a", "ip", "arch", "os") + // must not panic + r.ResolvePending("id1", "nonexistent", &agentv1.FileResult{}) +} + +func TestCancelPending(t *testing.T) { + r := NewRegistry() + r.Register("id1", "h", "a", "ip", "arch", "os") + + r.RegisterPending("id1", "cmd1") + r.CancelPending("id1", "cmd1") + + // After cancel, resolving should be a no-op (not panic) + r.ResolvePending("id1", "cmd1", &agentv1.FileResult{}) +} + +func TestCancelPending_UnknownAgent(t *testing.T) { + r := NewRegistry() + // must not panic + r.CancelPending("ghost", "cmd1") +} + +func TestSendAndWaitCtx_AgentNotConnected(t *testing.T) { + r := NewRegistry() + ctx := context.Background() + _, err := r.SendAndWaitCtx(ctx, "ghost", &agentv1.ServerMessage{}, "cmd1") + if err == nil || err.Error() != "agent not connected" { + t.Errorf("expected 'agent not connected', got %v", err) + } +} + +func TestSendAndWaitCtx_Timeout(t *testing.T) { + r := NewRegistry() + r.Register("id1", "h", "a", "ip", "arch", "os") + + // Use an already-cancelled context to force immediate timeout. + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + _, err := r.SendAndWaitCtx(ctx, "id1", &agentv1.ServerMessage{}, "cmd-timeout") + if err == nil { + t.Error("expected timeout or not-connected error") + } +} + +func TestSendAndWaitCtx_Success(t *testing.T) { + r := NewRegistry() + r.Register("id1", "h", "a", "ip", "arch", "os") + + cmdID := "cmd-success" + expected := &agentv1.FileResult{CommandId: cmdID, Success: true, Content: []byte("hello")} + + // Simulate the agent responding after the send. + go func() { + // Wait briefly for RegisterPending + Send to happen. + time.Sleep(10 * time.Millisecond) + r.ResolvePending("id1", cmdID, expected) + }() + + ctx := context.Background() + result, err := r.SendAndWaitCtx(ctx, "id1", &agentv1.ServerMessage{}, cmdID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.CommandId != cmdID || !result.Success { + t.Errorf("unexpected result: %+v", result) + } +} diff --git a/server/internal/scheduler/adapter.go b/server/internal/scheduler/adapter.go new file mode 100644 index 0000000..1061f4c --- /dev/null +++ b/server/internal/scheduler/adapter.go @@ -0,0 +1,39 @@ +package scheduler + +import ( + "time" + + "github.com/containarr/server/internal/store" +) + +// StoreAdapter wraps *store.Store so it satisfies StoreInterface. +type StoreAdapter struct { + s *store.Store +} + +// NewStoreAdapter creates a StoreAdapter wrapping the given *store.Store. +func NewStoreAdapter(s *store.Store) *StoreAdapter { + return &StoreAdapter{s: s} +} + +// ListDueAutoUpdatePolicies implements StoreInterface by converting +// *store.AutoUpdatePolicy to DuePolicy. +func (a *StoreAdapter) ListDueAutoUpdatePolicies(now time.Time) ([]DuePolicy, error) { + policies, err := a.s.ListDueAutoUpdatePolicies(now) + if err != nil { + return nil, err + } + out := make([]DuePolicy, 0, len(policies)) + for _, p := range policies { + out = append(out, DuePolicy{ + AgentID: p.AgentID, + ContainerID: p.ContainerID, + }) + } + return out, nil +} + +// UpdateAutoUpdateChecked implements StoreInterface. +func (a *StoreAdapter) UpdateAutoUpdateChecked(agentID, containerID string, at time.Time) error { + return a.s.UpdateAutoUpdateChecked(agentID, containerID, at) +} diff --git a/server/internal/scheduler/scheduler.go b/server/internal/scheduler/scheduler.go new file mode 100644 index 0000000..4e09dbf --- /dev/null +++ b/server/internal/scheduler/scheduler.go @@ -0,0 +1,86 @@ +package scheduler + +import ( + "context" + "log/slog" + "time" + + agentv1 "github.com/containarr/server/internal/proto/agentv1" + "github.com/google/uuid" +) + +// DuePolicy is a minimal view of an auto-update policy returned by the store. +type DuePolicy struct { + AgentID string + ContainerID string +} + +// StoreInterface defines the minimal store methods used by the scheduler. +// Implementations must convert their internal policy type to DuePolicy when +// implementing ListDueAutoUpdatePolicies, or use StoreAdapter provided below. +type StoreInterface interface { + ListDueAutoUpdatePolicies(now time.Time) ([]DuePolicy, error) + UpdateAutoUpdateChecked(agentID, containerID string, at time.Time) error +} + +// RegistryInterface defines the minimal registry methods used by the scheduler. +type RegistryInterface interface { + Send(agentID string, msg *agentv1.ServerMessage) bool +} + +// Scheduler sends CheckUpdateCommand to agents every 60 seconds for containers +// with an active and due auto-update policy. +type Scheduler struct { + store StoreInterface + registry RegistryInterface +} + +// New creates a new Scheduler. +func New(store StoreInterface, registry RegistryInterface) *Scheduler { + return &Scheduler{store: store, registry: registry} +} + +// Start runs the scheduler loop until ctx is cancelled. +func (s *Scheduler) Start(ctx context.Context) { + ticker := time.NewTicker(60 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + slog.Info("scheduler stopped") + return + case t := <-ticker.C: + s.tick(t) + } + } +} + +func (s *Scheduler) tick(now time.Time) { + policies, err := s.store.ListDueAutoUpdatePolicies(now) + if err != nil { + slog.Error("scheduler: list due policies", "err", err) + return + } + + for _, p := range policies { + cmdID := uuid.NewString() + msg := &agentv1.ServerMessage{ + Payload: &agentv1.ServerMessage_CheckUpdate{ + CheckUpdate: &agentv1.CheckUpdateCommand{ + CommandId: cmdID, + ContainerId: p.ContainerID, + }, + }, + } + sent := s.registry.Send(p.AgentID, msg) + if !sent { + slog.Debug("scheduler: agent not connected, skipping", "agent_id", p.AgentID, "container_id", p.ContainerID) + continue + } + if err := s.store.UpdateAutoUpdateChecked(p.AgentID, p.ContainerID, now); err != nil { + slog.Error("scheduler: update last_checked_at", "agent_id", p.AgentID, "container_id", p.ContainerID, "err", err) + } + slog.Info("scheduler: sent CheckUpdateCommand", "agent_id", p.AgentID, "container_id", p.ContainerID, "command_id", cmdID) + } +} diff --git a/server/internal/store/store.go b/server/internal/store/store.go index 2c6b047..9a12710 100644 --- a/server/internal/store/store.go +++ b/server/internal/store/store.go @@ -49,6 +49,16 @@ func (s *Store) migrate() error { last_seen_at DATETIME, online INTEGER NOT NULL DEFAULT 0 ); + CREATE TABLE IF NOT EXISTS auto_update_policies ( + agent_id TEXT NOT NULL, + container_id TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + interval_minutes INTEGER NOT NULL DEFAULT 1440, + last_checked_at DATETIME, + last_updated_at DATETIME, + PRIMARY KEY (agent_id, container_id), + FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE + ); `) if err != nil { return err @@ -186,3 +196,106 @@ func boolToInt(b bool) int { } return 0 } + +// ── AutoUpdatePolicies ──────────────────────────────────────────────────────── + +type AutoUpdatePolicy struct { + AgentID string + ContainerID string + Enabled bool + IntervalMinutes int + LastCheckedAt *time.Time + LastUpdatedAt *time.Time +} + +func (s *Store) UpsertAutoUpdatePolicy(p *AutoUpdatePolicy) error { + _, err := s.db.Exec(` + INSERT OR REPLACE INTO auto_update_policies + (agent_id, container_id, enabled, interval_minutes, last_checked_at, last_updated_at) + VALUES (?, ?, ?, ?, ?, ?) + `, p.AgentID, p.ContainerID, boolToInt(p.Enabled), p.IntervalMinutes, p.LastCheckedAt, p.LastUpdatedAt) + return err +} + +func (s *Store) GetAutoUpdatePolicy(agentID, containerID string) (*AutoUpdatePolicy, error) { + row := s.db.QueryRow(` + SELECT agent_id, container_id, enabled, interval_minutes, last_checked_at, last_updated_at + FROM auto_update_policies WHERE agent_id = ? AND container_id = ? + `, agentID, containerID) + p := &AutoUpdatePolicy{} + var enabled int + var lastChecked, lastUpdated sql.NullTime + err := row.Scan(&p.AgentID, &p.ContainerID, &enabled, &p.IntervalMinutes, &lastChecked, &lastUpdated) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + p.Enabled = enabled == 1 + if lastChecked.Valid { + t := lastChecked.Time + p.LastCheckedAt = &t + } + if lastUpdated.Valid { + t := lastUpdated.Time + p.LastUpdatedAt = &t + } + return p, nil +} + +func (s *Store) ListDueAutoUpdatePolicies(now time.Time) ([]*AutoUpdatePolicy, error) { + rows, err := s.db.Query(` + SELECT agent_id, container_id, enabled, interval_minutes, last_checked_at, last_updated_at + FROM auto_update_policies + WHERE enabled = 1 + AND (last_checked_at IS NULL + OR (julianday(?) - julianday(last_checked_at)) * 1440 >= interval_minutes) + `, now) + if err != nil { + return nil, err + } + defer rows.Close() + + var policies []*AutoUpdatePolicy + for rows.Next() { + p := &AutoUpdatePolicy{} + var enabled int + var lastChecked, lastUpdated sql.NullTime + if err := rows.Scan(&p.AgentID, &p.ContainerID, &enabled, &p.IntervalMinutes, &lastChecked, &lastUpdated); err != nil { + return nil, err + } + p.Enabled = enabled == 1 + if lastChecked.Valid { + t := lastChecked.Time + p.LastCheckedAt = &t + } + if lastUpdated.Valid { + t := lastUpdated.Time + p.LastUpdatedAt = &t + } + policies = append(policies, p) + } + return policies, rows.Err() +} + +func (s *Store) UpdateAutoUpdateChecked(agentID, containerID string, at time.Time) error { + _, err := s.db.Exec(` + UPDATE auto_update_policies SET last_checked_at = ? WHERE agent_id = ? AND container_id = ? + `, at, agentID, containerID) + return err +} + +func (s *Store) UpdateAutoUpdateDone(agentID, containerID string, at time.Time) error { + _, err := s.db.Exec(` + UPDATE auto_update_policies SET last_updated_at = ? WHERE agent_id = ? AND container_id = ? + `, at, agentID, containerID) + return err +} + +func (s *Store) DeleteAutoUpdatePolicy(agentID, containerID string) error { + _, err := s.db.Exec(` + DELETE FROM auto_update_policies WHERE agent_id = ? AND container_id = ? + `, agentID, containerID) + return err +} diff --git a/server/internal/store/store_test.go b/server/internal/store/store_test.go index 57cd312..ced9966 100644 --- a/server/internal/store/store_test.go +++ b/server/internal/store/store_test.go @@ -2,6 +2,7 @@ package store import ( "testing" + "time" ) func newTestStore(t *testing.T) *Store { @@ -254,3 +255,199 @@ func TestCreateAgentToken_IdempotentIgnore(t *testing.T) { t.Fatalf("second call (should be idempotent): %v", err) } } + +// ── AutoUpdatePolicies ──────────────────────────────────────────────────────── + +// helper: create an agent prerequisite for FK constraints. +func createAgent(t *testing.T, s *Store, id, token, hostname string) { + t.Helper() + if err := s.CreateAgentToken(id, token, hostname); err != nil { + t.Fatalf("createAgent: %v", err) + } +} + +func TestUpsertAndGetAutoUpdatePolicy(t *testing.T) { + s := newTestStore(t) + createAgent(t, s, "ag1", "tok1", "host1") + + p := &AutoUpdatePolicy{ + AgentID: "ag1", + ContainerID: "ctr1", + Enabled: true, + IntervalMinutes: 60, + } + if err := s.UpsertAutoUpdatePolicy(p); err != nil { + t.Fatalf("UpsertAutoUpdatePolicy: %v", err) + } + + got, err := s.GetAutoUpdatePolicy("ag1", "ctr1") + if err != nil { + t.Fatalf("GetAutoUpdatePolicy: %v", err) + } + if got == nil { + t.Fatal("expected policy, got nil") + } + if !got.Enabled || got.IntervalMinutes != 60 { + t.Errorf("unexpected policy: %+v", got) + } + if got.LastCheckedAt != nil || got.LastUpdatedAt != nil { + t.Error("expected nil timestamps on fresh policy") + } +} + +func TestGetAutoUpdatePolicy_NotFound(t *testing.T) { + s := newTestStore(t) + + p, err := s.GetAutoUpdatePolicy("nobody", "ctr") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p != nil { + t.Errorf("expected nil, got %+v", p) + } +} + +func TestUpsertAutoUpdatePolicy_Update(t *testing.T) { + s := newTestStore(t) + createAgent(t, s, "ag1", "tok1", "host1") + + _ = s.UpsertAutoUpdatePolicy(&AutoUpdatePolicy{AgentID: "ag1", ContainerID: "ctr1", Enabled: true, IntervalMinutes: 60}) + _ = s.UpsertAutoUpdatePolicy(&AutoUpdatePolicy{AgentID: "ag1", ContainerID: "ctr1", Enabled: false, IntervalMinutes: 1440}) + + got, err := s.GetAutoUpdatePolicy("ag1", "ctr1") + if err != nil { + t.Fatalf("GetAutoUpdatePolicy: %v", err) + } + if got.Enabled || got.IntervalMinutes != 1440 { + t.Errorf("expected updated policy, got %+v", got) + } +} + +func TestUpdateAutoUpdateChecked(t *testing.T) { + s := newTestStore(t) + createAgent(t, s, "ag1", "tok1", "host1") + _ = s.UpsertAutoUpdatePolicy(&AutoUpdatePolicy{AgentID: "ag1", ContainerID: "ctr1", Enabled: true, IntervalMinutes: 60}) + + now := time.Now().Truncate(time.Second) + if err := s.UpdateAutoUpdateChecked("ag1", "ctr1", now); err != nil { + t.Fatalf("UpdateAutoUpdateChecked: %v", err) + } + + got, _ := s.GetAutoUpdatePolicy("ag1", "ctr1") + if got.LastCheckedAt == nil { + t.Fatal("expected LastCheckedAt to be set") + } + if got.LastCheckedAt.UTC().Truncate(time.Second) != now.UTC() { + t.Errorf("expected %v, got %v", now.UTC(), got.LastCheckedAt.UTC().Truncate(time.Second)) + } +} + +func TestUpdateAutoUpdateDone(t *testing.T) { + s := newTestStore(t) + createAgent(t, s, "ag1", "tok1", "host1") + _ = s.UpsertAutoUpdatePolicy(&AutoUpdatePolicy{AgentID: "ag1", ContainerID: "ctr1", Enabled: true, IntervalMinutes: 60}) + + now := time.Now().Truncate(time.Second) + if err := s.UpdateAutoUpdateDone("ag1", "ctr1", now); err != nil { + t.Fatalf("UpdateAutoUpdateDone: %v", err) + } + + got, _ := s.GetAutoUpdatePolicy("ag1", "ctr1") + if got.LastUpdatedAt == nil { + t.Fatal("expected LastUpdatedAt to be set") + } +} + +func TestListDueAutoUpdatePolicies_NullLastChecked(t *testing.T) { + s := newTestStore(t) + createAgent(t, s, "ag1", "tok1", "host1") + _ = s.UpsertAutoUpdatePolicy(&AutoUpdatePolicy{AgentID: "ag1", ContainerID: "ctr1", Enabled: true, IntervalMinutes: 60}) + + // last_checked_at IS NULL → should be due immediately. + due, err := s.ListDueAutoUpdatePolicies(time.Now()) + if err != nil { + t.Fatalf("ListDueAutoUpdatePolicies: %v", err) + } + if len(due) != 1 { + t.Fatalf("expected 1 due policy, got %d", len(due)) + } + if due[0].ContainerID != "ctr1" { + t.Errorf("unexpected container: %q", due[0].ContainerID) + } +} + +func TestListDueAutoUpdatePolicies_NotDueYet(t *testing.T) { + s := newTestStore(t) + createAgent(t, s, "ag1", "tok1", "host1") + _ = s.UpsertAutoUpdatePolicy(&AutoUpdatePolicy{AgentID: "ag1", ContainerID: "ctr1", Enabled: true, IntervalMinutes: 1440}) + + // Mark as just checked — not due yet. + _ = s.UpdateAutoUpdateChecked("ag1", "ctr1", time.Now()) + + due, err := s.ListDueAutoUpdatePolicies(time.Now()) + if err != nil { + t.Fatalf("ListDueAutoUpdatePolicies: %v", err) + } + if len(due) != 0 { + t.Fatalf("expected 0 due policies (just checked), got %d", len(due)) + } +} + +func TestListDueAutoUpdatePolicies_Due(t *testing.T) { + s := newTestStore(t) + createAgent(t, s, "ag1", "tok1", "host1") + _ = s.UpsertAutoUpdatePolicy(&AutoUpdatePolicy{AgentID: "ag1", ContainerID: "ctr1", Enabled: true, IntervalMinutes: 60}) + + // Simulate last check 2 hours ago → should be due. + past := time.Now().Add(-2 * time.Hour) + _ = s.UpdateAutoUpdateChecked("ag1", "ctr1", past) + + due, err := s.ListDueAutoUpdatePolicies(time.Now()) + if err != nil { + t.Fatalf("ListDueAutoUpdatePolicies: %v", err) + } + if len(due) != 1 { + t.Fatalf("expected 1 due policy (overdue), got %d", len(due)) + } +} + +func TestListDueAutoUpdatePolicies_DisabledExcluded(t *testing.T) { + s := newTestStore(t) + createAgent(t, s, "ag1", "tok1", "host1") + _ = s.UpsertAutoUpdatePolicy(&AutoUpdatePolicy{AgentID: "ag1", ContainerID: "ctr1", Enabled: false, IntervalMinutes: 60}) + + due, err := s.ListDueAutoUpdatePolicies(time.Now()) + if err != nil { + t.Fatalf("ListDueAutoUpdatePolicies: %v", err) + } + if len(due) != 0 { + t.Fatalf("expected 0 due policies (disabled), got %d", len(due)) + } +} + +func TestDeleteAutoUpdatePolicy(t *testing.T) { + s := newTestStore(t) + createAgent(t, s, "ag1", "tok1", "host1") + _ = s.UpsertAutoUpdatePolicy(&AutoUpdatePolicy{AgentID: "ag1", ContainerID: "ctr1", Enabled: true, IntervalMinutes: 60}) + + if err := s.DeleteAutoUpdatePolicy("ag1", "ctr1"); err != nil { + t.Fatalf("DeleteAutoUpdatePolicy: %v", err) + } + + got, err := s.GetAutoUpdatePolicy("ag1", "ctr1") + if err != nil { + t.Fatalf("GetAutoUpdatePolicy: %v", err) + } + if got != nil { + t.Error("expected nil after deletion") + } +} + +func TestDeleteAutoUpdatePolicy_Idempotent(t *testing.T) { + s := newTestStore(t) + + // Deleting a non-existent policy should not error. + if err := s.DeleteAutoUpdatePolicy("nobody", "ctr"); err != nil { + t.Fatalf("DeleteAutoUpdatePolicy on missing: %v", err) + } +} diff --git a/web/package-lock.json b/web/package-lock.json index 661601d..a979c46 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -7,6 +7,14 @@ "": { "name": "containarr-web", "version": "0.1.0", + "dependencies": { + "@codemirror/commands": "^6.10.3", + "@codemirror/lang-yaml": "^6.1.3", + "@codemirror/language": "^6.12.3", + "@codemirror/state": "^6.6.0", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.43.0" + }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.6", "@sveltejs/kit": "^2.16.0", @@ -1680,6 +1688,92 @@ "specificity": "bin/cli.js" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.2", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.2.tgz", + "integrity": "sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-yaml": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz", + "integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.0.0", + "@lezer/yaml": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", + "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz", + "integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@csstools/color-helpers": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", @@ -2351,6 +2445,47 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/yaml": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", + "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.4.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3956,6 +4091,12 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -7323,6 +7464,12 @@ "node": ">=8" } }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -8194,6 +8341,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/web/package.json b/web/package.json index c9b57df..2b47ea5 100644 --- a/web/package.json +++ b/web/package.json @@ -29,5 +29,13 @@ "vite": "^6.0.7", "vitest": "^4.1.6", "workbox-window": "^7.3.0" + }, + "dependencies": { + "@codemirror/commands": "^6.10.3", + "@codemirror/lang-yaml": "^6.1.3", + "@codemirror/language": "^6.12.3", + "@codemirror/state": "^6.6.0", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.43.0" } } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 4ab11ef..54cfbea 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -122,6 +122,7 @@ export interface ImageEntry { agent_id: string; hostname: string; alias: string; + ip_address: string; id: string; tags: string[]; size: number; @@ -132,6 +133,7 @@ export interface VolumeEntry { agent_id: string; hostname: string; alias: string; + ip_address: string; name: string; driver: string; mountpoint: string; @@ -141,6 +143,7 @@ export interface NetworkEntry { agent_id: string; hostname: string; alias: string; + ip_address: string; id: string; name: string; driver: string; @@ -201,6 +204,92 @@ export function connectLogs( return () => ws.close(); } +export async function fsList( + agentId: string, + path: string +): Promise<{ name: string; is_dir: boolean; has_compose: boolean }[]> { + const r = await apiFetch(`${BASE}/agents/${agentId}/fs/list?path=${encodeURIComponent(path)}`); + if (!r.ok) throw new Error(`fsList: ${r.status}`); + return r.json(); +} + +export async function fsRead(agentId: string, path: string): Promise { + const r = await apiFetch(`${BASE}/agents/${agentId}/fs/read?path=${encodeURIComponent(path)}`); + if (!r.ok) throw new Error(`fsRead: ${r.status}`); + const json = await r.json(); + return json.content as string; +} + +export async function fsWrite(agentId: string, path: string, content: string): Promise { + const r = await apiFetch(`${BASE}/agents/${agentId}/fs/write`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path, content }), + }); + if (!r.ok) throw new Error(`fsWrite: ${r.status}`); +} + +export async function fsMkdir(agentId: string, path: string): Promise { + const r = await apiFetch(`/api/v1/agents/${agentId}/fs/mkdir`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path }), + }); + if (!r.ok) throw new Error(`mkdir: ${r.status}`); +} + +export async function composeAction( + agentId: string, + path: string, + action: "up" | "down" | "pull" +): Promise<{ ok: boolean; output: string }> { + const r = await apiFetch(`${BASE}/agents/${agentId}/compose`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path, action }), + }); + if (!r.ok) throw new Error(`composeAction: ${r.status}`); + return r.json(); +} + +export interface AutoUpdatePolicy { + enabled: boolean; + interval_minutes: number; + last_checked_at: string | null; + last_updated_at: string | null; +} + +export async function getAutoUpdatePolicy(agentId: string, containerId: string): Promise { + const r = await apiFetch(`${BASE}/agents/${agentId}/containers/${containerId}/auto-update`); + if (!r.ok) throw new Error(`getAutoUpdatePolicy: ${r.status}`); + return r.json(); +} + +export async function setAutoUpdatePolicy( + agentId: string, + containerId: string, + policy: Pick +): Promise { + const r = await apiFetch(`${BASE}/agents/${agentId}/containers/${containerId}/auto-update`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(policy), + }); + if (!r.ok) throw new Error(`setAutoUpdatePolicy: ${r.status}`); + return r.json(); +} + +export async function updateNow(agentId: string, containerId: string): Promise<{ command_id: string }> { + const r = await apiFetch(`${BASE}/agents/${agentId}/containers/${containerId}/update-now`, { + method: "POST", + }); + if (!r.ok) { + const text = await r.text().catch(() => ""); + throw new Error(text || `updateNow: ${r.status}`); + } + return r.json(); +} + export function connectEvents( onEvent: (evt: { type: string; agent_id?: string; payload: unknown }) => void ): () => void { diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index a7a2d29..464d1eb 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -13,11 +13,15 @@ fetchNetworks, containerAction, connectEvents, + getAutoUpdatePolicy, + setAutoUpdatePolicy, + updateNow, type ContainerEntry, type ContainerPort, type ImageEntry, type VolumeEntry, type NetworkEntry, + type AutoUpdatePolicy, } from "$lib/api"; import { clearToken } from "$lib/auth"; import LogModal from "$lib/LogModal.svelte"; @@ -144,6 +148,19 @@ loadError = null; try { entries = await fetchContainers() ?? []; + // Pré-chargement en arrière-plan des policies pour colorer les boutons auto-update + const toLoad = entries; + Promise.allSettled( + toLoad.map(e => getAutoUpdatePolicy(e.agent_id, e.container.id).then(policy => ({ key: autoUpdateKey(e.agent_id, e.container.id), policy }))) + ).then(results => { + const updates: Record = {}; + for (const r of results) { + if (r.status === "fulfilled") { + updates[r.value.key] = { policy: r.value.policy, loading: false, saving: false }; + } + } + autoUpdateStates = { ...autoUpdateStates, ...updates }; + }); } catch (e: unknown) { loadError = e instanceof Error ? e.message : String(e); entries = []; @@ -213,11 +230,170 @@ setTimeout(() => (toast = null), 3000); } + // ── Auto-update panel ──────────────────────────────────────────────────── + interface AutoUpdateState { + policy: AutoUpdatePolicy | null; + loading: boolean; + saving: boolean; + } + + let autoUpdateOpen = $state(null); // containerKey = `${agentId}/${containerId}` + let updateNowPending = $state(null); // containerKey en cours d'update + let autoUpdateStates = $state>({}); + let autoUpdateDebounce = $state | null>(null); + let autoUpdatePanelPos = $state<{ top: number; right: number } | null>(null); + + const INTERVAL_OPTIONS = [ + { label: "1 heure", value: 60 }, + { label: "6 heures", value: 360 }, + { label: "12 heures", value: 720 }, + { label: "24 heures", value: 1440 }, + { label: "7 jours", value: 10080 }, + ]; + + function autoUpdateKey(agentId: string, containerId: string) { + return `${agentId}/${containerId}`; + } + + async function openAutoUpdate(agentId: string, containerId: string, panelPos?: { top: number; right: number }) { + const key = autoUpdateKey(agentId, containerId); + if (autoUpdateOpen === key) { + autoUpdateOpen = null; + autoUpdatePanelPos = null; + return; + } + autoUpdateOpen = key; + if (panelPos) autoUpdatePanelPos = panelPos; + // Si la policy a déjà été pré-chargée, on ne la réinitialise pas + const existing = autoUpdateStates[key]; + if (!existing?.policy) { + autoUpdateStates = { + ...autoUpdateStates, + [key]: { policy: existing?.policy ?? null, loading: true, saving: false }, + }; + try { + const policy = await getAutoUpdatePolicy(agentId, containerId); + autoUpdateStates = { + ...autoUpdateStates, + [key]: { policy, loading: false, saving: false }, + }; + } catch { + autoUpdateStates = { + ...autoUpdateStates, + [key]: { policy: null, loading: false, saving: false }, + }; + } + } + } + + function scheduleAutoUpdateSave(agentId: string, containerId: string) { + const key = autoUpdateKey(agentId, containerId); + if (autoUpdateDebounce) clearTimeout(autoUpdateDebounce); + autoUpdateDebounce = setTimeout(async () => { + const state = autoUpdateStates[key]; + if (!state?.policy) return; + autoUpdateStates = { + ...autoUpdateStates, + [key]: { ...state, saving: true }, + }; + try { + const updated = await setAutoUpdatePolicy(agentId, containerId, { + enabled: state.policy.enabled, + interval_minutes: state.policy.interval_minutes, + }); + autoUpdateStates = { + ...autoUpdateStates, + [key]: { policy: updated, loading: false, saving: false }, + }; + } catch (e: unknown) { + showToast(e instanceof Error ? e.message : String(e), false); + autoUpdateStates = { + ...autoUpdateStates, + [key]: { ...state, saving: false }, + }; + } + }, 300); + } + + function toggleAutoUpdateEnabled(agentId: string, containerId: string) { + const key = autoUpdateKey(agentId, containerId); + const state = autoUpdateStates[key]; + if (!state?.policy) return; + autoUpdateStates = { + ...autoUpdateStates, + [key]: { ...state, policy: { ...state.policy, enabled: !state.policy.enabled } }, + }; + scheduleAutoUpdateSave(agentId, containerId); + } + + function changeAutoUpdateInterval(agentId: string, containerId: string, minutes: number) { + const key = autoUpdateKey(agentId, containerId); + const state = autoUpdateStates[key]; + if (!state?.policy) return; + autoUpdateStates = { + ...autoUpdateStates, + [key]: { ...state, policy: { ...state.policy, interval_minutes: minutes } }, + }; + scheduleAutoUpdateSave(agentId, containerId); + } + + async function doUpdateNow(agentId: string, containerId: string) { + const key = autoUpdateKey(agentId, containerId); + updateNowPending = key; + try { + await updateNow(agentId, containerId); + showToast("Mise à jour lancée", true); + // Refresh panel après un délai pour montrer last_checked_at mis à jour + if (autoUpdateOpen !== null) { + const currentKey = autoUpdateOpen; + const parts = currentKey.split('/'); + const panelAgentId = parts[0]; + const panelContainerId = parts.slice(1).join('/'); + setTimeout(async () => { + if (autoUpdateOpen !== currentKey) return; // panel fermé entretemps + try { + const policy = await getAutoUpdatePolicy(panelAgentId, panelContainerId); + autoUpdateStates = { + ...autoUpdateStates, + [currentKey]: { policy, loading: false, saving: false }, + }; + } catch {} + }, 3000); + } + } catch (e: unknown) { + showToast(e instanceof Error ? e.message : String(e), false); + } finally { + updateNowPending = null; + } + } + + function closeAutoUpdateOnClickOutside(e: MouseEvent) { + if (autoUpdateOpen === null) return; + const target = e.target as HTMLElement; + if (!target.closest("[data-autoupdate-panel]") && !target.closest("[data-autoupdate-btn]")) { + autoUpdateOpen = null; + autoUpdatePanelPos = null; + } + } + + function formatRelativeTime(iso: string | null): string { + if (!iso) return "Jamais"; + const diff = Date.now() - new Date(iso).getTime(); + const s = Math.floor(diff / 1000); + if (s < 60) return "Il y a quelques secondes"; + const m = Math.floor(s / 60); + if (m < 60) return `Il y a ${m} min`; + const h = Math.floor(m / 60); + if (h < 24) return `Il y a ${h}h`; + const d = Math.floor(h / 24); + return `Il y a ${d}j`; + } + // ── Toggle helpers ──────────────────────────────────────────────────────── - function toggleSection(agentId: string) { collapsed[agentId] = !(collapsed[agentId] ?? true); } - function toggleImages(agentId: string) { collapsedImages[agentId] = !(collapsedImages[agentId] ?? true); } - function toggleVolumes(agentId: string) { collapsedVolumes[agentId] = !(collapsedVolumes[agentId] ?? true); } - function toggleNetworks(agentId: string) { collapsedNetworks[agentId] = !(collapsedNetworks[agentId] ?? true); } + function toggleSection(agentId: string) { collapsed = { ...collapsed, [agentId]: !(collapsed[agentId] ?? true) }; } + function toggleImages(agentId: string) { collapsedImages = { ...collapsedImages, [agentId]: !(collapsedImages[agentId] ?? true) }; } + function toggleVolumes(agentId: string) { collapsedVolumes = { ...collapsedVolumes, [agentId]: !(collapsedVolumes[agentId] ?? true) }; } + function toggleNetworks(agentId: string) { collapsedNetworks = { ...collapsedNetworks, [agentId]: !(collapsedNetworks[agentId] ?? true) }; } // ── Lifecycle ───────────────────────────────────────────────────────────── onMount(() => { @@ -225,6 +401,18 @@ disconnect = connectEvents((evt) => { if (evt.type === "containers.updated" || evt.type === "agent.connected" || evt.type === "agent.disconnected") { if (activeTab === "containers") load(); + // Refresh open auto-update panel + if (autoUpdateOpen !== null) { + const parts = autoUpdateOpen.split('/'); + const panelAgentId = parts[0]; + const panelContainerId = parts.slice(1).join('/'); + getAutoUpdatePolicy(panelAgentId, panelContainerId).then(policy => { + autoUpdateStates = { + ...autoUpdateStates, + [autoUpdateOpen!]: { policy, loading: false, saving: false }, + }; + }).catch(() => {}); + } } if (evt.type === "resources.updated") { if (activeTab === "images") loadImages(); @@ -255,7 +443,7 @@ if (seen.has(key)) return false; seen.add(key); return true; - }); + }).sort((a, b) => a.host_port - b.host_port); } function stateDotClass(state: string) { @@ -320,7 +508,7 @@ {/if} -
+