diff --git a/CLAUDE.md b/CLAUDE.md index 35522de..af4c2cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,7 @@ Lightweight Rust agent for remote SNMP polling. Deployed on customer networks to - Heartbeat every 60 seconds - Cleanup every hour - Poll check every 5 seconds + - Update check and auto-update every hour - [x] **Executor** (`poller/executor.rs`) - poll_sensors() - Poll configured sensors @@ -51,11 +52,14 @@ Lightweight Rust agent for remote SNMP polling. Deployed on customer networks to - Graceful startup/shutdown - Docker image version checking on startup -- [x] **Version Checking** (`version.rs`) +- [x] **Version Checking & Auto-Update** (`version.rs`) - Checks Docker Hub for newer image versions on startup + - Performs periodic checks every hour (configurable in scheduler) + - Automatically pulls new image and restarts when update available - Compares current version with latest available - Logs warnings if updates are available - Non-blocking, fails gracefully if Docker Hub unavailable + - Requires Docker socket mount (`/var/run/docker.sock`) for self-update ### Protocol Buffers Integration - [x] **Protobuf Definitions** (`proto/agent.proto`) diff --git a/Dockerfile b/Dockerfile index 797283a..dd5f92a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -45,7 +45,8 @@ RUN RUST_TARGET=$(cat /tmp/rust-target) && \ FROM alpine:3.19 # Install runtime dependencies -RUN apk add --no-cache ca-certificates su-exec +# docker-cli is needed for self-update functionality +RUN apk add --no-cache ca-certificates su-exec docker-cli # Create data directory RUN mkdir -p /data diff --git a/README.md b/README.md index 2feac89..324328c 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ The Towerops agent enables customers to deploy SNMP polling infrastructure on th - **Secure Communication**: All communication with Towerops API uses HTTPS with token authentication - **Metric Buffering**: Stores up to 24 hours of metrics in SQLite when API is unavailable - **Automatic Configuration**: Fetches polling configuration from the Towerops API +- **Automatic Updates**: Checks for new versions hourly and self-updates when available (requires Docker socket access) - **Low Resource Usage**: Built in Rust for minimal memory and CPU footprint (< 256 MB RAM typical) - **Docker Ready**: Pre-built Docker images for easy deployment @@ -40,6 +41,8 @@ services: - TOWEROPS_AGENT_TOKEN=your-agent-token-here volumes: - ./data:/data + # Required for automatic self-updates + - /var/run/docker.sock:/var/run/docker.sock ``` 2. Start the agent: @@ -48,6 +51,8 @@ services: docker-compose up -d ``` +**Note**: The Docker socket mount (`/var/run/docker.sock`) is required for automatic updates. The agent checks for new versions hourly and will automatically pull and restart with the latest version when available. + ### Configuration The agent is configured via environment variables: diff --git a/docker-compose.example.yml b/docker-compose.example.yml index eb8c505..2b7ec5f 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -30,6 +30,10 @@ services: # Persistent storage for metrics buffering - ./data:/data + # Docker socket (required for automatic self-updates) + # The agent will check for updates hourly and automatically update itself + - /var/run/docker.sock:/var/run/docker.sock + # Optional: Resource limits deploy: resources: diff --git a/src/poller/scheduler.rs b/src/poller/scheduler.rs index f2c7df4..85a3a15 100644 --- a/src/poller/scheduler.rs +++ b/src/poller/scheduler.rs @@ -101,6 +101,7 @@ impl Scheduler { let mut heartbeat_ticker = interval(Duration::from_secs(60)); let mut cleanup_ticker = interval(Duration::from_secs(3600)); // Cleanup every hour let mut poll_ticker = interval(Duration::from_secs(5)); // Check if polling needed every 5s + let mut update_ticker = interval(Duration::from_secs(3600)); // Check for updates every hour loop { tokio::select! { @@ -133,6 +134,10 @@ impl Scheduler { error!("Polling error: {}", e); } } + + _ = update_ticker.tick() => { + self.check_and_update().await; + } } } } @@ -250,4 +255,27 @@ impl Scheduler { Ok(()) } + + async fn check_and_update(&self) { + info!("Checking for agent updates"); + + // Run version check in blocking thread to avoid blocking event loop + let result = tokio::task::spawn_blocking(crate::version::perform_self_update).await; + + match result { + Ok(Ok(true)) => { + info!("Update initiated, container will restart with new version"); + // perform_self_update calls std::process::exit(0), so we won't reach here + } + Ok(Ok(false)) => { + info!("Already running latest version"); + } + Ok(Err(e)) => { + warn!("Failed to perform self-update: {}", e); + } + Err(e) => { + error!("Update check task failed: {}", e); + } + } + } } diff --git a/src/version.rs b/src/version.rs index 49e02ac..d95e4f9 100644 --- a/src/version.rs +++ b/src/version.rs @@ -1,5 +1,6 @@ use log::{info, warn}; use serde::Deserialize; +use std::process::Command; const DOCKER_IMAGE: &str = "gmcintire/towerops-agent"; const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -16,32 +17,92 @@ struct DockerHubTag { last_updated: String, } +#[derive(Debug, Clone, PartialEq)] +pub struct UpdateInfo { + pub current_version: String, + pub latest_version: Option, + pub update_available: bool, +} + /// Check if a newer version of the Docker image is available pub fn check_for_updates() { info!("Current version: {}", CURRENT_VERSION); info!("Checking for newer Docker image versions..."); - match check_docker_hub() { - Ok(Some(latest_version)) => { - if latest_version != CURRENT_VERSION { - warn!( - "⚠️ Newer version available: {} (current: {})", - latest_version, CURRENT_VERSION - ); - warn!(" Update with: docker pull {}:latest", DOCKER_IMAGE); + match get_update_info() { + Ok(info) => { + if info.update_available { + if let Some(ref latest) = info.latest_version { + warn!( + "⚠️ Newer version available: {} (current: {})", + latest, info.current_version + ); + warn!(" Update with: docker pull {}:latest", DOCKER_IMAGE); + } } else { - info!("✓ Running latest version ({})", CURRENT_VERSION); + info!("✓ Running latest version ({})", info.current_version); } } - Ok(None) => { - info!("Unable to determine latest version from Docker Hub"); - } Err(e) => { warn!("Failed to check for updates: {}", e); } } } +/// Get update information without logging +pub fn get_update_info() -> Result { + let latest_version = check_docker_hub().map_err(|e| e.to_string())?; + let update_available = if let Some(ref latest) = latest_version { + latest != CURRENT_VERSION + } else { + false + }; + + Ok(UpdateInfo { + current_version: CURRENT_VERSION.to_string(), + latest_version, + update_available, + }) +} + +/// Perform self-update by pulling new image and exiting +/// Returns Ok(true) if update was initiated, Ok(false) if already up-to-date +pub fn perform_self_update() -> Result { + let info = get_update_info()?; + + if !info.update_available { + info!("Already running latest version, no update needed"); + return Ok(false); + } + + if let Some(ref latest) = info.latest_version { + warn!( + "Performing self-update: {} -> {}", + info.current_version, latest + ); + + // Pull the new image + info!("Pulling new Docker image: {}:latest", DOCKER_IMAGE); + let output = Command::new("docker") + .args(["pull", &format!("{}:latest", DOCKER_IMAGE)]) + .output() + .map_err(|e| format!("Failed to execute docker command: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("Failed to pull image: {}", stderr)); + } + + info!("Successfully pulled new image"); + info!("Exiting to allow restart with new version..."); + + // Exit with success code - orchestrator (docker-compose/k8s) will restart with new image + std::process::exit(0); + } + + Ok(true) +} + fn check_docker_hub() -> Result, Box> { let url = format!( "https://hub.docker.com/v2/repositories/{}/tags?page_size=10",