diff --git a/Dockerfile b/Dockerfile index 868cf26..1dd577e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,30 +46,16 @@ RUN RUST_TARGET=$(cat /tmp/rust-target) && \ FROM alpine:3.19 # Install runtime dependencies -# docker-cli is needed for self-update functionality # iputils provides ping with setuid root (doesn't require CAP_NET_RAW) -RUN apk add --no-cache ca-certificates su-exec docker-cli iputils - -# Create data directory -RUN mkdir -p /data +RUN apk add --no-cache ca-certificates iputils # Copy binary from builder COPY --from=builder /tmp/towerops-agent /usr/local/bin/towerops-agent -# Copy entrypoint script -COPY entrypoint.sh /usr/local/bin/entrypoint.sh -RUN chmod +x /usr/local/bin/entrypoint.sh - -# Volume for database -VOLUME ["/data"] - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --retries=3 \ - CMD test -f /data/towerops-agent.db || exit 1 - -# Create non-root user (entrypoint will drop to this user) +# Create non-root user RUN addgroup -g 1000 towerops && \ adduser -D -u 1000 -G towerops towerops -# Start as root, entrypoint will fix permissions and drop to towerops user -ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +USER towerops + +ENTRYPOINT ["towerops-agent"] diff --git a/entrypoint.sh b/entrypoint.sh deleted file mode 100644 index 583b023..0000000 --- a/entrypoint.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/sh -set -e - -# Ensure /data directory exists and has proper permissions -if [ -d /data ]; then - # Fix ownership if needed - chown -R towerops:towerops /data -fi - -# If Docker socket is mounted, add towerops user to docker group -if [ -S /var/run/docker.sock ]; then - # Get the GID of the docker socket - DOCKER_GID=$(stat -c '%g' /var/run/docker.sock 2>/dev/null || stat -f '%g' /var/run/docker.sock 2>/dev/null) - - # Create docker group with the same GID as host's docker socket - if ! getent group "$DOCKER_GID" >/dev/null 2>&1; then - addgroup -g "$DOCKER_GID" docker - fi - - # Add towerops user to the docker group - addgroup towerops docker 2>/dev/null || true -fi - -# Drop to towerops user and run the agent -exec su-exec towerops towerops-agent "$@" diff --git a/fly.toml b/fly.toml new file mode 100644 index 0000000..bae2c03 --- /dev/null +++ b/fly.toml @@ -0,0 +1,26 @@ +# fly.toml app configuration file generated for towerops-agent on 2026-01-24T12:26:30-06:00 +# +# See https://fly.io/docs/reference/configuration/ for information about how to use this file. +# + +app = 'towerops-agent' +primary_region = 'dfw' + +[build] + +[env] + RUST_LOG = 'info' + +[http_service] + internal_port = 8080 + force_https = true + auto_stop_machines = 'stop' + auto_start_machines = true + min_machines_running = 0 + processes = ['app'] + +[[vm]] + memory = '256mb' + cpu_kind = 'shared' + cpus = 1 + memory_mb = 256 diff --git a/src/health.rs b/src/health.rs deleted file mode 100644 index ca2b894..0000000 --- a/src/health.rs +++ /dev/null @@ -1,163 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::time::Instant; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; - -type Result = std::result::Result>; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HealthStatus { - pub status: String, - pub version: String, - pub uptime_seconds: u64, - pub connected: bool, -} - -/// Start a simple health check HTTP server using raw TCP. -/// -/// The health check returns: -/// - 200 OK with status "healthy" when WebSocket is connected -/// - 503 Service Unavailable with status "unhealthy" when WebSocket is disconnected -/// -/// This allows Kubernetes readiness probes to remove the pod from service -/// when the WebSocket connection is broken. -pub async fn start_health_server(port: u16, connected: Arc) -> Result<()> { - let start_time = Arc::new(Instant::now()); - let addr = format!("0.0.0.0:{}", port); - - crate::log_info!("Starting health endpoint on {}", addr); - let listener = TcpListener::bind(&addr).await?; - - loop { - match listener.accept().await { - Ok((mut socket, _)) => { - let start_time = Arc::clone(&start_time); - let connected = Arc::clone(&connected); - - tokio::spawn(async move { - let mut buffer = [0u8; 1024]; - - // Read the HTTP request - if let Ok(n) = socket.read(&mut buffer).await { - if n > 0 { - let request = String::from_utf8_lossy(&buffer[..n]); - - // Check if this is a GET request to /health - if request.starts_with("GET /health") { - let is_connected = connected.load(Ordering::Relaxed); - let uptime = start_time.elapsed().as_secs(); - - let status = HealthStatus { - status: if is_connected { - "healthy".to_string() - } else { - "unhealthy".to_string() - }, - version: env!("CARGO_PKG_VERSION").to_string(), - uptime_seconds: uptime, - connected: is_connected, - }; - - let json = serde_json::to_string(&status).unwrap_or_else(|_| { - r#"{"status":"error","message":"Failed to serialize health status"}"# - .to_string() - }); - - // Return 200 if connected, 503 if not - let http_status = if is_connected { - "200 OK" - } else { - "503 Service Unavailable" - }; - - let response = format!( - "HTTP/1.1 {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", - http_status, - json.len(), - json - ); - - let _ = socket.write_all(response.as_bytes()).await; - } else { - // 404 for other paths - let response = - "HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\n\r\nNot found"; - let _ = socket.write_all(response.as_bytes()).await; - } - } - } - - let _ = socket.shutdown().await; - }); - } - Err(e) => { - crate::log_warn!("Failed to accept health check connection: {}", e); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_health_status_serialization_healthy() { - let status = HealthStatus { - status: "healthy".to_string(), - version: "0.1.0".to_string(), - uptime_seconds: 42, - connected: true, - }; - - let json = serde_json::to_string(&status).unwrap(); - assert!(json.contains(r#""status":"healthy""#)); - assert!(json.contains(r#""version":"0.1.0""#)); - assert!(json.contains(r#""uptime_seconds":42"#)); - assert!(json.contains(r#""connected":true"#)); - } - - #[test] - fn test_health_status_serialization_unhealthy() { - let status = HealthStatus { - status: "unhealthy".to_string(), - version: "0.1.0".to_string(), - uptime_seconds: 100, - connected: false, - }; - - let json = serde_json::to_string(&status).unwrap(); - assert!(json.contains(r#""status":"unhealthy""#)); - assert!(json.contains(r#""connected":false"#)); - } - - #[test] - fn test_health_status_deserialization() { - let json = r#"{"status":"healthy","version":"0.1.0","uptime_seconds":42,"connected":true}"#; - let status: HealthStatus = serde_json::from_str(json).unwrap(); - assert_eq!(status.status, "healthy"); - assert_eq!(status.version, "0.1.0"); - assert_eq!(status.uptime_seconds, 42); - assert!(status.connected); - } - - #[test] - fn test_health_status_clone() { - let status = HealthStatus { - status: "healthy".to_string(), - version: "0.1.0".to_string(), - uptime_seconds: 42, - connected: true, - }; - let cloned = status.clone(); - assert_eq!(status.status, cloned.status); - assert_eq!(status.version, cloned.version); - assert_eq!(status.uptime_seconds, cloned.uptime_seconds); - assert_eq!(status.connected, cloned.connected); - } - - // Note: start_health_server is tested manually/via integration tests - // Unit testing it requires complex async server mocking which is not practical -} diff --git a/src/main.rs b/src/main.rs index d9ef450..318717f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,3 @@ -mod health; mod ping; mod proto; mod snmp; @@ -197,10 +196,9 @@ async fn main() { log_info!("WebSocket URL: {}", ws_url); - // Shared connection state for health check + // Shared connection state // Starts as false (not connected), updated when WebSocket connects/disconnects let connected = Arc::new(AtomicBool::new(false)); - let connected_for_health = Arc::clone(&connected); // Create shutdown signal channel let (shutdown_tx, shutdown_rx) = watch::channel(false); @@ -212,13 +210,6 @@ async fn main() { let _ = shutdown_tx.send(true); }); - // Start simple health endpoint with connection state - tokio::spawn(async move { - if let Err(e) = health::start_health_server(8080, connected_for_health).await { - log_warn!("Health server error: {}", e); - } - }); - // Retry loop with exponential backoff let mut retry_delay = Duration::from_secs(1); let max_retry_delay = Duration::from_secs(60);