Add automatic self-update capability to agent

The agent now checks for updates every hour and automatically updates
itself when a new version is available on Docker Hub.

Features:
- Periodic update checks (hourly via scheduler)
- Automatic Docker image pull when update available
- Graceful exit and restart with new version
- Non-blocking, runs in background task
- Requires Docker socket mount for self-update

Changes:
- Add UpdateInfo struct and get_update_info() function
- Add perform_self_update() to pull new image and restart
- Add update check ticker to scheduler (hourly)
- Include docker-cli in Dockerfile runtime stage
- Update docker-compose.example.yml with socket mount
- Update README and CLAUDE.md with auto-update docs

The agent will log update status:
- "Already running latest version" - no action
- "Performing self-update: X -> Y" - pulling new image
- "Exiting to allow restart with new version" - restarting

Requires:
- Docker socket mounted: /var/run/docker.sock:/var/run/docker.sock
- restart: unless-stopped in docker-compose (to restart after exit)
This commit is contained in:
Graham McIntire 2026-01-14 16:50:32 -06:00
parent 3622cbf0ee
commit 824f4388eb
No known key found for this signature in database
6 changed files with 117 additions and 14 deletions

View file

@ -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`)

View file

@ -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

View file

@ -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:

View file

@ -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:

View file

@ -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);
}
}
}
}

View file

@ -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<String>,
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<UpdateInfo, String> {
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<bool, String> {
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<Option<String>, Box<dyn std::error::Error>> {
let url = format!(
"https://hub.docker.com/v2/repositories/{}/tags?page_size=10",