geist_supervisor 0.1.28

Generic OTA supervisor for field devices
Documentation
use crate::config::Config;
use anyhow::{Context, Result};
use reqwest::{
    blocking::Client,
    header::{HeaderMap, HeaderValue, AUTHORIZATION},
};
use std::fs;
use std::path::Path;
use std::time::Duration;

pub struct GcsService {
    client: Client,
    token: String,
    registry_path: String,
    release_bundle_name: String,
    checksum_file_name: String,
}

impl GcsService {
    pub fn new(token: String, registry_path: String) -> Self {
        Self::with_bundle_names(
            token,
            registry_path,
            "release.tar.gz".to_string(),
            "checksums.txt".to_string(),
        )
    }

    pub fn with_bundle_names(
        token: String,
        registry_path: String,
        release_bundle_name: String,
        checksum_file_name: String,
    ) -> Self {
        // Configure client with proper timeouts for OTA downloads
        let client = Client::builder()
            .timeout(Duration::from_secs(120)) // 2 minutes for large file downloads
            .connect_timeout(Duration::from_secs(30)) // 30 seconds to establish connection
            .build()
            .expect("Failed to build HTTP client");

        Self {
            client,
            token,
            registry_path,
            release_bundle_name,
            checksum_file_name,
        }
    }

    pub fn download_binary(&self, version: &str, output_path: &Path) -> Result<()> {
        let normalized_version = Config::normalize_version(version);
        let url = format!(
            "{}/releases/{}/{}",
            self.registry_path, normalized_version, self.release_bundle_name
        );

        let mut request = self.client.get(&url);

        // Only add authorization if token is not empty
        if !self.token.is_empty() {
            let mut headers = HeaderMap::new();
            headers.insert(
                AUTHORIZATION,
                HeaderValue::from_str(&format!("Bearer {}", self.token))?,
            );
            request = request.headers(headers);
        }

        let response = request.send().context("Failed to download binary")?;

        if !response.status().is_success() {
            anyhow::bail!("Failed to download binary: HTTP {}", response.status());
        }

        let content = response
            .bytes()
            .context("Failed to read response content")?;

        fs::write(output_path, content).context("Failed to save binary")?;

        Ok(())
    }

    pub fn verify_version(&self, version: &str) -> Result<bool> {
        let normalized_version = Config::normalize_version(version);
        let url = format!(
            "{}/releases/{}/{}",
            self.registry_path, normalized_version, self.checksum_file_name
        );

        let mut request = self.client.head(&url);

        // Only add authorization if token is not empty
        if !self.token.is_empty() {
            let mut headers = HeaderMap::new();
            headers.insert(
                AUTHORIZATION,
                HeaderValue::from_str(&format!("Bearer {}", self.token))?,
            );
            request = request.headers(headers);
        }

        let response = request.send().context("Failed to verify version")?;

        Ok(response.status().is_success())
    }

    pub fn get_latest_version(&self) -> Result<String> {
        // Add cache-busting timestamp to force fresh data
        let timestamp = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();
        let url = format!("{}/latest-version.txt?t={}", self.registry_path, timestamp);

        let mut request = self.client.get(&url);

        // Add cache-busting headers
        let mut headers = HeaderMap::new();
        headers.insert(
            "Cache-Control",
            HeaderValue::from_static("no-cache, no-store, must-revalidate"),
        );
        headers.insert("Pragma", HeaderValue::from_static("no-cache"));
        headers.insert("Expires", HeaderValue::from_static("0"));

        // Only add authorization if token is not empty
        if !self.token.is_empty() {
            headers.insert(
                AUTHORIZATION,
                HeaderValue::from_str(&format!("Bearer {}", self.token))?,
            );
        }

        request = request.headers(headers);

        let response = request.send().context("Failed to fetch latest version")?;

        if !response.status().is_success() {
            anyhow::bail!("Failed to fetch latest version: HTTP {}", response.status());
        }

        let version = response
            .text()
            .context("Failed to read latest version from response")?;

        // Always return version with "v" prefix for consistency
        Ok(Config::format_version(version.trim()))
    }

    pub fn download_signature(&self, version: &str, output_path: &Path) -> Result<()> {
        let normalized_version = Config::normalize_version(version);
        let url = format!(
            "{}/releases/{}/{}.sig",
            self.registry_path, normalized_version, self.release_bundle_name
        );

        tracing::debug!("Attempting to download signature from URL: {}", url);

        let mut request = self.client.get(&url);

        if !self.token.is_empty() {
            let mut headers = HeaderMap::new();
            headers.insert(
                AUTHORIZATION,
                HeaderValue::from_str(&format!("Bearer {}", self.token))?,
            );
            request = request.headers(headers);
        }

        let response = request.send().context("Failed to download signature")?;

        if !response.status().is_success() {
            anyhow::bail!("Signature not found: HTTP {}", response.status());
        }

        let content = response.bytes().context("Failed to read signature")?;
        fs::write(output_path, content).context("Failed to save signature")?;

        Ok(())
    }

    pub fn download_release_bundle(&self, version: &str, output_path: &Path) -> Result<()> {
        let normalized_version = Config::normalize_version(version);

        let url = format!(
            "{}/releases/{}/{}",
            self.registry_path, normalized_version, self.release_bundle_name
        );

        tracing::debug!("Attempting to download from URL: {}", url);

        let mut request = self.client.get(&url);

        // Only add authorization if token is not empty
        if !self.token.is_empty() {
            let mut headers = HeaderMap::new();
            headers.insert(
                AUTHORIZATION,
                HeaderValue::from_str(&format!("Bearer {}", self.token))?,
            );
            request = request.headers(headers);
        }

        let response = request
            .send()
            .context("Failed to download release bundle")?;

        if !response.status().is_success() {
            anyhow::bail!(
                "Failed to download release bundle: HTTP {}",
                response.status()
            );
        }

        let content = response
            .bytes()
            .context("Failed to read response content")?;

        fs::write(output_path, content).context("Failed to save release bundle")?;

        Ok(())
    }
}