refactor: SDK small command modules — services, cluster, manifests, gitea, update, auth
This commit is contained in:
443
sunbeam-sdk/src/update/mod.rs
Normal file
443
sunbeam-sdk/src/update/mod.rs
Normal file
@@ -0,0 +1,443 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user