Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -101,5 +104,4 @@ glob = "0.3"
wiremock = "0.5.22"
assert_cmd = "2.0"
predicates = "3.0"
tempfile = "3"
mockito = "1"
3 changes: 2 additions & 1 deletion src/cli/install_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};

Expand Down
3 changes: 2 additions & 1 deletion src/cli/stacker_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};

Expand Down
3 changes: 2 additions & 1 deletion src/console/commands/cli/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};

Expand Down
195 changes: 194 additions & 1 deletion src/console/commands/cli/update.rs
Original file line number Diff line number Diff line change
@@ -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<String, CliError> {
Expand All @@ -18,6 +26,131 @@ pub fn parse_channel(channel: Option<&str>) -> Result<String, CliError> {
}
}

/// 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<GithubAsset>,
}

#[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<Option<GithubRelease>, Box<dyn std::error::Error>> {
let client = reqwest::blocking::Client::builder()
.user_agent(format!("stacker-cli/{}", CURRENT_VERSION))
.build()?;

let releases: Vec<GithubRelease> = 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<tempfile::NamedTempFile, Box<dyn std::error::Error>> {
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<Vec<u8>, Box<dyn std::error::Error>> {
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<GzDecoder<fs::File>> = 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<u8>) -> Result<(), Box<dyn std::error::Error>> {
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, &current_exe)?;
Ok(())
}

/// `stacker update [--channel stable|beta]`
///
/// Checks for updates and self-updates the stacker binary.
Expand All @@ -35,7 +168,48 @@ impl CallableTrait for UpdateCommand {
fn call(&self) -> Result<(), Box<dyn std::error::Error>> {
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(())
}
}
Expand Down Expand Up @@ -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"));
}
}
62 changes: 42 additions & 20 deletions src/helpers/mq_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: ?Sized + Serialize>(
&self,
exchange: String,
Expand All @@ -60,8 +82,9 @@ impl MqManager {
) -> Result<PublisherConfirm, String> {
let payload = serde_json::to_string::<T>(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(),
Expand Down Expand Up @@ -108,26 +131,17 @@ impl MqManager {
) -> Result<Channel, String> {
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 {
Expand All @@ -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,
Expand All @@ -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)
Expand Down
Loading