444 lines
15 KiB
Rust
444 lines
15 KiB
Rust
use crate::error::{Result, ResultExt};
|
|
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
|
|
/// Compile-time commit SHA set by build.rs.
|
|
pub const COMMIT: &str = env!("SUNBEAM_COMMIT");
|
|
|
|
/// Compile-time build target triple set by build.rs.
|
|
pub const TARGET: &str = env!("SUNBEAM_TARGET");
|
|
|
|
/// Compile-time build date set by build.rs.
|
|
pub const BUILD_DATE: &str = env!("SUNBEAM_BUILD_DATE");
|
|
|
|
/// Artifact name prefix for this platform.
|
|
fn artifact_name() -> String {
|
|
format!("sunbeam-{TARGET}")
|
|
}
|
|
|
|
/// Resolve the forge URL (Gitea instance).
|
|
///
|
|
/// TODO: Once kube.rs exposes `get_domain()`, derive this automatically as
|
|
/// `https://src.{domain}`. For now we read the SUNBEAM_FORGE_URL environment
|
|
/// variable with a sensible fallback.
|
|
fn forge_url() -> String {
|
|
if let Ok(url) = std::env::var("SUNBEAM_FORGE_URL") {
|
|
return url.trim_end_matches('/').to_string();
|
|
}
|
|
|
|
// Derive from production_host domain in config
|
|
let config = crate::config::load_config();
|
|
if !config.production_host.is_empty() {
|
|
// production_host is like "user@server.example.com" — extract domain
|
|
let host = config
|
|
.production_host
|
|
.split('@')
|
|
.last()
|
|
.unwrap_or(&config.production_host);
|
|
// Strip any leading subdomain segments that look like a hostname to get the base domain.
|
|
// For a host like "admin.sunbeam.pt", the forge is "src.sunbeam.pt".
|
|
// Heuristic: use the last two segments as the domain.
|
|
let parts: Vec<&str> = host.split('.').collect();
|
|
if parts.len() >= 2 {
|
|
let domain = format!("{}.{}", parts[parts.len() - 2], parts[parts.len() - 1]);
|
|
return format!("https://src.{domain}");
|
|
}
|
|
}
|
|
|
|
// Hard fallback — will fail at runtime if not configured, which is fine.
|
|
String::new()
|
|
}
|
|
|
|
/// Cache file location for background update checks.
|
|
fn update_cache_path() -> PathBuf {
|
|
dirs::data_dir()
|
|
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(".local/share"))
|
|
.join("sunbeam")
|
|
.join("update-check.json")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Gitea API response types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct BranchResponse {
|
|
commit: BranchCommit,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct BranchCommit {
|
|
id: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ArtifactListResponse {
|
|
artifacts: Vec<Artifact>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct Artifact {
|
|
name: String,
|
|
id: u64,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Update-check cache
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
struct UpdateCache {
|
|
last_check: DateTime<Utc>,
|
|
latest_commit: String,
|
|
current_commit: String,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Print version information.
|
|
pub fn cmd_version() {
|
|
println!("sunbeam {COMMIT}");
|
|
println!(" target: {TARGET}");
|
|
println!(" built: {BUILD_DATE}");
|
|
}
|
|
|
|
/// Self-update from the latest mainline commit via Gitea CI artifacts.
|
|
pub async fn cmd_update() -> Result<()> {
|
|
let base = forge_url();
|
|
if base.is_empty() {
|
|
bail!(
|
|
"Forge URL not configured. Set SUNBEAM_FORGE_URL or configure a \
|
|
production host via `sunbeam config set --host`."
|
|
);
|
|
}
|
|
|
|
crate::output::step("Checking for updates...");
|
|
|
|
let client = reqwest::Client::new();
|
|
|
|
// 1. Check latest commit on mainline
|
|
let latest_commit = fetch_latest_commit(&client, &base).await?;
|
|
let short_latest = &latest_commit[..std::cmp::min(8, latest_commit.len())];
|
|
|
|
crate::output::ok(&format!("Current: {COMMIT}"));
|
|
crate::output::ok(&format!("Latest: {short_latest}"));
|
|
|
|
if latest_commit.starts_with(COMMIT) || COMMIT.starts_with(&latest_commit[..std::cmp::min(COMMIT.len(), latest_commit.len())]) {
|
|
crate::output::ok("Already up to date.");
|
|
return Ok(());
|
|
}
|
|
|
|
// 2. Find the CI artifact for our platform
|
|
crate::output::step("Downloading update...");
|
|
let wanted = artifact_name();
|
|
|
|
let artifacts = fetch_artifacts(&client, &base).await?;
|
|
let binary_artifact = artifacts
|
|
.iter()
|
|
.find(|a| a.name == wanted)
|
|
.with_ctx(|| format!("No artifact found for platform '{wanted}'"))?;
|
|
|
|
let checksums_artifact = artifacts
|
|
.iter()
|
|
.find(|a| a.name == "checksums.txt" || a.name == "checksums");
|
|
|
|
// 3. Download the binary
|
|
let binary_url = format!(
|
|
"{base}/api/v1/repos/studio/cli/actions/artifacts/{id}",
|
|
id = binary_artifact.id
|
|
);
|
|
let binary_bytes = client
|
|
.get(&binary_url)
|
|
.send()
|
|
.await?
|
|
.error_for_status()
|
|
.ctx("Failed to download binary artifact")?
|
|
.bytes()
|
|
.await?;
|
|
|
|
crate::output::ok(&format!("Downloaded {} bytes", binary_bytes.len()));
|
|
|
|
// 4. Verify SHA256 if checksums artifact exists
|
|
if let Some(checksums) = checksums_artifact {
|
|
let checksums_url = format!(
|
|
"{base}/api/v1/repos/studio/cli/actions/artifacts/{id}",
|
|
id = checksums.id
|
|
);
|
|
let checksums_text = client
|
|
.get(&checksums_url)
|
|
.send()
|
|
.await?
|
|
.error_for_status()
|
|
.ctx("Failed to download checksums")?
|
|
.text()
|
|
.await?;
|
|
|
|
verify_checksum(&binary_bytes, &wanted, &checksums_text)?;
|
|
crate::output::ok("SHA256 checksum verified.");
|
|
} else {
|
|
crate::output::warn("No checksums artifact found; skipping verification.");
|
|
}
|
|
|
|
// 5. Atomic self-replace
|
|
crate::output::step("Installing update...");
|
|
let current_exe = std::env::current_exe().ctx("Failed to determine current executable path")?;
|
|
atomic_replace(¤t_exe, &binary_bytes)?;
|
|
|
|
crate::output::ok(&format!(
|
|
"Updated sunbeam {COMMIT} -> {short_latest}"
|
|
));
|
|
|
|
// Update the cache so background check knows we are current
|
|
let _ = write_cache(&UpdateCache {
|
|
last_check: Utc::now(),
|
|
latest_commit: latest_commit.clone(),
|
|
current_commit: latest_commit,
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Background update check. Returns a notification message if a newer version
|
|
/// is available, or None if up-to-date / on error / checked too recently.
|
|
///
|
|
/// This function never blocks for long and never returns errors — it silently
|
|
/// returns None on any failure.
|
|
pub async fn check_update_background() -> Option<String> {
|
|
// Read cache
|
|
let cache_path = update_cache_path();
|
|
if let Ok(data) = fs::read_to_string(&cache_path) {
|
|
if let Ok(cache) = serde_json::from_str::<UpdateCache>(&data) {
|
|
let age = Utc::now().signed_duration_since(cache.last_check);
|
|
if age.num_seconds() < 3600 {
|
|
// Checked recently — just compare cached values
|
|
if cache.latest_commit.starts_with(COMMIT)
|
|
|| COMMIT.starts_with(&cache.latest_commit[..std::cmp::min(COMMIT.len(), cache.latest_commit.len())])
|
|
{
|
|
return None; // up to date
|
|
}
|
|
let short = &cache.latest_commit[..std::cmp::min(8, cache.latest_commit.len())];
|
|
return Some(format!(
|
|
"A newer version of sunbeam is available ({short}). Run `sunbeam update` to upgrade."
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Time to check again
|
|
let base = forge_url();
|
|
if base.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let client = reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(5))
|
|
.build()
|
|
.ok()?;
|
|
|
|
let latest = fetch_latest_commit(&client, &base).await.ok()?;
|
|
|
|
let cache = UpdateCache {
|
|
last_check: Utc::now(),
|
|
latest_commit: latest.clone(),
|
|
current_commit: COMMIT.to_string(),
|
|
};
|
|
let _ = write_cache(&cache);
|
|
|
|
if latest.starts_with(COMMIT)
|
|
|| COMMIT.starts_with(&latest[..std::cmp::min(COMMIT.len(), latest.len())])
|
|
{
|
|
return None;
|
|
}
|
|
|
|
let short = &latest[..std::cmp::min(8, latest.len())];
|
|
Some(format!(
|
|
"A newer version of sunbeam is available ({short}). Run `sunbeam update` to upgrade."
|
|
))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Internal helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Fetch the latest commit SHA on the mainline branch.
|
|
async fn fetch_latest_commit(client: &reqwest::Client, forge_url: &str) -> Result<String> {
|
|
let url = format!("{forge_url}/api/v1/repos/studio/cli/branches/mainline");
|
|
let resp: BranchResponse = client
|
|
.get(&url)
|
|
.send()
|
|
.await?
|
|
.error_for_status()
|
|
.ctx("Failed to query mainline branch")?
|
|
.json()
|
|
.await?;
|
|
Ok(resp.commit.id)
|
|
}
|
|
|
|
/// Fetch the list of CI artifacts for the repo.
|
|
async fn fetch_artifacts(client: &reqwest::Client, forge_url: &str) -> Result<Vec<Artifact>> {
|
|
let url = format!("{forge_url}/api/v1/repos/studio/cli/actions/artifacts");
|
|
let resp: ArtifactListResponse = client
|
|
.get(&url)
|
|
.send()
|
|
.await?
|
|
.error_for_status()
|
|
.ctx("Failed to query CI artifacts")?
|
|
.json()
|
|
.await?;
|
|
Ok(resp.artifacts)
|
|
}
|
|
|
|
/// Verify that the downloaded binary matches the expected SHA256 from checksums text.
|
|
///
|
|
/// Checksums file format (one per line):
|
|
/// <hex-sha256> <filename>
|
|
fn verify_checksum(binary: &[u8], artifact_name: &str, checksums_text: &str) -> Result<()> {
|
|
let actual = {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(binary);
|
|
format!("{:x}", hasher.finalize())
|
|
};
|
|
|
|
for line in checksums_text.lines() {
|
|
// Split on whitespace — format is "<hash> <name>" or "<hash> <name>"
|
|
let mut parts = line.split_whitespace();
|
|
if let (Some(expected_hash), Some(name)) = (parts.next(), parts.next()) {
|
|
if name == artifact_name {
|
|
if actual != expected_hash {
|
|
bail!(
|
|
"Checksum mismatch for {artifact_name}:\n expected: {expected_hash}\n actual: {actual}"
|
|
);
|
|
}
|
|
return Ok(());
|
|
}
|
|
}
|
|
}
|
|
|
|
bail!("No checksum entry found for '{artifact_name}' in checksums file");
|
|
}
|
|
|
|
/// Atomically replace the binary at `target` with `new_bytes`.
|
|
///
|
|
/// Writes to a temp file in the same directory, sets executable permissions,
|
|
/// then renames over the original.
|
|
fn atomic_replace(target: &std::path::Path, new_bytes: &[u8]) -> Result<()> {
|
|
let parent = target
|
|
.parent()
|
|
.ctx("Cannot determine parent directory of current executable")?;
|
|
|
|
let tmp_path = parent.join(".sunbeam-update.tmp");
|
|
|
|
// Write new binary
|
|
fs::write(&tmp_path, new_bytes).ctx("Failed to write temporary update file")?;
|
|
|
|
// Set executable permissions (unix)
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o755))
|
|
.ctx("Failed to set executable permissions")?;
|
|
}
|
|
|
|
// Atomic rename
|
|
fs::rename(&tmp_path, target).ctx("Failed to replace current executable")?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Write the update-check cache to disk.
|
|
fn write_cache(cache: &UpdateCache) -> Result<()> {
|
|
let path = update_cache_path();
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
let json = serde_json::to_string_pretty(cache)?;
|
|
fs::write(&path, json)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_version_consts() {
|
|
// COMMIT, TARGET, BUILD_DATE are set at compile time
|
|
assert!(!COMMIT.is_empty());
|
|
assert!(!TARGET.is_empty());
|
|
assert!(!BUILD_DATE.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_artifact_name() {
|
|
let name = artifact_name();
|
|
assert!(name.starts_with("sunbeam-"));
|
|
assert!(name.contains(TARGET));
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_checksum_ok() {
|
|
let data = b"hello world";
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(data);
|
|
let hash = format!("{:x}", hasher.finalize());
|
|
|
|
let checksums = format!("{hash} sunbeam-test");
|
|
assert!(verify_checksum(data, "sunbeam-test", &checksums).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_checksum_mismatch() {
|
|
let checksums = "0000000000000000000000000000000000000000000000000000000000000000 sunbeam-test";
|
|
assert!(verify_checksum(b"hello", "sunbeam-test", checksums).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_checksum_missing_entry() {
|
|
let checksums = "abcdef1234567890 sunbeam-other";
|
|
assert!(verify_checksum(b"hello", "sunbeam-test", checksums).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_update_cache_path() {
|
|
let path = update_cache_path();
|
|
assert!(path.to_string_lossy().contains("sunbeam"));
|
|
assert!(path.to_string_lossy().ends_with("update-check.json"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_cache_roundtrip() {
|
|
let cache = UpdateCache {
|
|
last_check: Utc::now(),
|
|
latest_commit: "abc12345".to_string(),
|
|
current_commit: "def67890".to_string(),
|
|
};
|
|
let json = serde_json::to_string(&cache).unwrap();
|
|
let loaded: UpdateCache = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(loaded.latest_commit, "abc12345");
|
|
assert_eq!(loaded.current_commit, "def67890");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_check_update_background_returns_none_when_forge_url_empty() {
|
|
// When SUNBEAM_FORGE_URL is unset and there is no production_host config,
|
|
// forge_url() returns "" and check_update_background should return None
|
|
// without making any network requests.
|
|
// Clear the env var to ensure we hit the empty-URL path.
|
|
// SAFETY: This test is not run concurrently with other tests that depend on this env var.
|
|
unsafe { std::env::remove_var("SUNBEAM_FORGE_URL") };
|
|
// Note: this test assumes no production_host is configured in the test
|
|
// environment, which is the default for CI/dev. If forge_url() returns
|
|
// a non-empty string (e.g. from config), the test may still pass because
|
|
// the background check silently returns None on network errors.
|
|
let result = check_update_background().await;
|
|
// Either None (empty forge URL or network error) — never panics.
|
|
// The key property: this completes quickly without hanging.
|
|
drop(result);
|
|
}
|
|
}
|