diff --git a/CHANGELOG.md b/CHANGELOG.md index 300c967..40b2e84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Fixed — Casbin ACL for marketplace compose access +- Added Casbin policy granting `group_admin` role GET access to `/admin/project/:id/compose`. +- This allows the User Service OAuth client (which authenticates as `root` → `group_admin`) to fetch compose definitions for marketplace templates. +- Migration: `20260325140000_casbin_admin_compose_group_admin.up.sql` + ### Added — Agent Audit Ingest Endpoint and Query API - New database migration `20260321000000_agent_audit_log` creating the `agent_audit_log` table diff --git a/Cargo.toml b/Cargo.toml index ace1b9d..6d702d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,9 @@ actix-cors = "0.6.4" tracing-actix-web = "0.7.7" regex = "1.10.2" rand = "0.8.5" +tempfile = "3" +flate2 = "1.0" +tar = "0.4" ssh-key = { version = "0.6", features = ["ed25519", "rand_core"] } russh = "0.58" futures-util = "0.3.29" @@ -101,5 +104,4 @@ glob = "0.3" wiremock = "0.5.22" assert_cmd = "2.0" predicates = "3.0" -tempfile = "3" mockito = "1" diff --git a/src/cli/install_runner.rs b/src/cli/install_runner.rs index 7d684d3..998273a 100644 --- a/src/cli/install_runner.rs +++ b/src/cli/install_runner.rs @@ -990,7 +990,8 @@ fn build_remote_deploy_payload(config: &StackerConfig) -> serde_json::Value { .filter(|v| !v.is_empty()) .unwrap_or_else(|| "custom-stack".to_string()); let os = match provider.as_str() { - "do" => "docker-20-04", + "do" => "docker-20-04", // DigitalOcean marketplace image with Docker pre-installed + "htz" => "docker-ce", // Hetzner snapshot with Docker CE pre-installed (Ubuntu 24.04) _ => "ubuntu-22.04", }; diff --git a/src/cli/stacker_client.rs b/src/cli/stacker_client.rs index 0f462e0..6eb2b5a 100644 --- a/src/cli/stacker_client.rs +++ b/src/cli/stacker_client.rs @@ -1958,7 +1958,8 @@ pub fn build_deploy_form(config: &StackerConfig) -> serde_json::Value { let region = cloud.and_then(|c| c.region.clone()).unwrap_or_else(|| "nbg1".to_string()); let server_size = cloud.and_then(|c| c.size.clone()).unwrap_or_else(|| "cpx11".to_string()); let os = match provider.as_str() { - "do" => "docker-20-04", + "do" => "docker-20-04", // DigitalOcean marketplace image with Docker pre-installed + "htz" => "docker-ce", // Hetzner snapshot with Docker CE pre-installed (Ubuntu 24.04) _ => "ubuntu-22.04", }; diff --git a/src/console/commands/cli/config.rs b/src/console/commands/cli/config.rs index 4c1158d..eca01af 100644 --- a/src/console/commands/cli/config.rs +++ b/src/console/commands/cli/config.rs @@ -214,7 +214,8 @@ pub fn run_generate_remote_payload( .unwrap_or_else(|| "custom-stack".to_string()); let provider_code = provider_code_for_remote(provider); let os = match provider_code { - "do" => "docker-20-04", + "do" => "docker-20-04", // DigitalOcean marketplace image with Docker pre-installed + "htz" => "docker-ce", // Hetzner snapshot with Docker CE pre-installed (Ubuntu 24.04) _ => "ubuntu-22.04", }; diff --git a/src/console/commands/cli/update.rs b/src/console/commands/cli/update.rs index b6ec57f..2b82203 100644 --- a/src/console/commands/cli/update.rs +++ b/src/console/commands/cli/update.rs @@ -1,8 +1,16 @@ use crate::cli::error::CliError; use crate::console::commands::CallableTrait; +use flate2::read::GzDecoder; +use std::env; +use std::fs; +use std::io; +use std::path::PathBuf; const DEFAULT_CHANNEL: &str = "stable"; const VALID_CHANNELS: &[&str] = &["stable", "beta"]; +const GITHUB_API_RELEASES: &str = + "https://api.github.com/repos/trydirect/stacker/releases"; +const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); /// Parse and validate a release channel string. pub fn parse_channel(channel: Option<&str>) -> Result { @@ -18,6 +26,131 @@ pub fn parse_channel(channel: Option<&str>) -> Result { } } +/// Detect the current platform's asset suffix used in GitHub release filenames. +/// Format: `stacker-v{VERSION}-{arch}-{os}.tar.gz` +fn detect_asset_suffix() -> String { + let os = if cfg!(target_os = "macos") { + "darwin" + } else { + "linux" + }; + let arch = if cfg!(target_arch = "aarch64") { + "aarch64" + } else { + "x86_64" + }; + format!("{}-{}", arch, os) +} + +#[derive(Debug, serde::Deserialize)] +struct GithubRelease { + tag_name: String, + prerelease: bool, + assets: Vec, +} + +#[derive(Debug, serde::Deserialize)] +struct GithubAsset { + name: String, + browser_download_url: String, +} + +/// Fetch the latest release from GitHub that matches the channel. +/// - "stable" → non-prerelease releases +/// - "beta" → prerelease releases +fn fetch_latest_release(channel: &str) -> Result, Box> { + let client = reqwest::blocking::Client::builder() + .user_agent(format!("stacker-cli/{}", CURRENT_VERSION)) + .build()?; + + let releases: Vec = client + .get(GITHUB_API_RELEASES) + .send()? + .error_for_status()? + .json()?; + + let want_prerelease = channel == "beta"; + let release = releases + .into_iter() + .find(|r| r.prerelease == want_prerelease || (!want_prerelease && !r.prerelease)); + + Ok(release) +} + +/// Compare two semver strings (major.minor.patch) — returns true if `latest` > `current`. +fn is_newer(current: &str, latest: &str) -> bool { + let parse = |v: &str| -> Option<(u64, u64, u64)> { + let v = v.trim_start_matches('v'); + let parts: Vec<&str> = v.splitn(3, '.').collect(); + if parts.len() < 3 { + return None; + } + Some(( + parts[0].parse().ok()?, + parts[1].parse().ok()?, + parts[2].split('-').next()?.parse().ok()?, + )) + }; + match (parse(current), parse(latest)) { + (Some(c), Some(l)) => l > c, + _ => false, + } +} + +/// Download `url` into a temporary file and return its path. +fn download_to_tempfile(url: &str) -> Result> { + let client = reqwest::blocking::Client::builder() + .user_agent(format!("stacker-cli/{}", CURRENT_VERSION)) + .build()?; + let mut resp = client.get(url).send()?.error_for_status()?; + let mut tmp = tempfile::NamedTempFile::new()?; + io::copy(&mut resp, &mut tmp)?; + Ok(tmp) +} + +/// Extract the `stacker` binary from a `.tar.gz` archive and return its bytes. +fn extract_binary_from_targz(tmp: &tempfile::NamedTempFile) -> Result, Box> { + let file = fs::File::open(tmp.path())?; + let gz = GzDecoder::new(file); + let mut archive = tar::Archive::new(gz); + for entry in archive.entries()? { + let mut entry: tar::Entry> = entry?; + let path = entry.path()?.to_path_buf(); + let name = path.file_name().unwrap_or_default().to_string_lossy(); + if name == "stacker" { + let mut buf = Vec::new(); + io::copy(&mut entry, &mut buf)?; + return Ok(buf); + } + } + Err("stacker binary not found in archive".into()) +} + +/// Replace the running executable with `new_bytes`. +fn replace_current_exe(new_bytes: Vec) -> Result<(), Box> { + let current_exe: PathBuf = env::current_exe()?; + + // Write new binary to a sibling temp file, then atomically rename. + let parent = current_exe.parent().ok_or("Cannot determine binary parent directory")?; + let mut tmp = tempfile::Builder::new() + .prefix(".stacker-update-") + .tempfile_in(parent)?; + io::Write::write_all(&mut tmp, &new_bytes)?; + + // Make executable (Unix) + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = tmp.as_file().metadata()?.permissions(); + perms.set_mode(0o755); + tmp.as_file().set_permissions(perms)?; + } + + let (_, tmp_path) = tmp.keep()?; + fs::rename(&tmp_path, ¤t_exe)?; + Ok(()) +} + /// `stacker update [--channel stable|beta]` /// /// Checks for updates and self-updates the stacker binary. @@ -35,7 +168,48 @@ impl CallableTrait for UpdateCommand { fn call(&self) -> Result<(), Box> { let channel = parse_channel(self.channel.as_deref())?; eprintln!("Checking for updates on '{}' channel...", channel); - eprintln!("You are running the latest version."); + + let release = match fetch_latest_release(&channel)? { + Some(r) => r, + None => { + eprintln!("No releases found on '{}' channel.", channel); + return Ok(()); + } + }; + + let latest_version = release.tag_name.trim_start_matches('v'); + + if !is_newer(CURRENT_VERSION, latest_version) { + eprintln!( + "You are running the latest version (v{}).", + CURRENT_VERSION + ); + return Ok(()); + } + + eprintln!( + "New version available: v{} (you have v{}). Updating...", + latest_version, CURRENT_VERSION + ); + + let suffix = detect_asset_suffix(); + let asset_name = format!("stacker-v{}-{}.tar.gz", latest_version, suffix); + let asset = release + .assets + .iter() + .find(|a| a.name == asset_name) + .ok_or_else(|| format!("No release asset found for your platform: {}", asset_name))?; + + eprintln!("Downloading {}...", asset.name); + let tmp = download_to_tempfile(&asset.browser_download_url)?; + + eprintln!("Extracting..."); + let new_bytes = extract_binary_from_targz(&tmp)?; + + eprintln!("Installing..."); + replace_current_exe(new_bytes)?; + + eprintln!("✅ Updated to v{}. Run 'stacker --version' to confirm.", latest_version); Ok(()) } } @@ -65,4 +239,23 @@ mod tests { fn test_parse_channel_rejects_unknown() { assert!(parse_channel(Some("nightly")).is_err()); } + + #[test] + fn test_is_newer_detects_update() { + assert!(is_newer("0.2.4", "0.2.5")); + assert!(is_newer("0.2.4", "0.3.0")); + assert!(is_newer("0.2.4", "1.0.0")); + } + + #[test] + fn test_is_newer_no_update_needed() { + assert!(!is_newer("0.2.5", "0.2.5")); + assert!(!is_newer("0.2.5", "0.2.4")); + } + + #[test] + fn test_is_newer_handles_v_prefix() { + assert!(is_newer("0.2.4", "v0.2.5")); + assert!(!is_newer("v0.2.5", "v0.2.5")); + } } diff --git a/src/helpers/mq_manager.rs b/src/helpers/mq_manager.rs index be33b45..6729636 100644 --- a/src/helpers/mq_manager.rs +++ b/src/helpers/mq_manager.rs @@ -52,6 +52,28 @@ impl MqManager { }) } + async fn declare_exchange(channel: &Channel, exchange: &str) -> Result<(), String> { + channel + .exchange_declare( + exchange, + ExchangeKind::Topic, + ExchangeDeclareOptions { + passive: false, + durable: true, + auto_delete: false, + internal: false, + nowait: false, + }, + FieldTable::default(), + ) + .await + .map_err(|err| { + let msg = format!("declaring exchange '{}': {:?}", exchange, err); + tracing::error!(msg); + msg + }) + } + pub async fn publish( &self, exchange: String, @@ -60,8 +82,9 @@ impl MqManager { ) -> Result { let payload = serde_json::to_string::(msg).map_err(|err| format!("{:?}", err))?; - self.create_channel() - .await? + let channel = self.create_channel().await?; + Self::declare_exchange(&channel, &exchange).await?; + channel .basic_publish( exchange.as_str(), routing_key.as_str(), @@ -108,26 +131,17 @@ impl MqManager { ) -> Result { let channel = self.create_channel().await?; - channel - .exchange_declare( - exchange_name, - ExchangeKind::Topic, - ExchangeDeclareOptions { - passive: false, - durable: true, - auto_delete: false, - internal: false, - nowait: false, - }, - FieldTable::default(), - ) + Self::declare_exchange(&channel, exchange_name) .await - .expect("Exchange declare failed"); + .map_err(|e| { + tracing::error!("Exchange declare failed: {}", e); + e + })?; let mut args = FieldTable::default(); args.insert("x-expires".into(), AMQPValue::LongUInt(3600000)); - let _queue = channel + channel .queue_declare( queue_name, QueueDeclareOptions { @@ -140,9 +154,13 @@ impl MqManager { args, ) .await - .expect("Queue declare failed"); + .map_err(|err| { + let msg = format!("declaring queue '{}': {:?}", queue_name, err); + tracing::error!(msg); + msg + })?; - let _ = channel + channel .queue_bind( queue_name, exchange_name, @@ -151,7 +169,11 @@ impl MqManager { FieldTable::default(), ) .await - .map_err(|err| format!("error {:?}", err)); + .map_err(|err| { + let msg = format!("binding queue '{}' to exchange '{}': {:?}", queue_name, exchange_name, err); + tracing::error!(msg); + msg + })?; let channel = self.create_channel().await?; Ok(channel)