From e04a2f6f023136108c970459e2872b08626adbc8 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Sat, 15 Aug 2026 08:53:27 -0600 Subject: [PATCH 1/2] feat: harden BYOC deployment workflows --- crates/alien-cli/src/commands/deploy.rs | 38 +- crates/alien-cli/src/commands/mod.rs | 4 + crates/alien-cli/src/commands/packages.rs | 465 ++++++++++++++++++ crates/alien-cli/src/lib.rs | 45 ++ crates/alien-core/src/bin/schema_exporter.rs | 5 +- crates/alien-core/src/instance_catalog.rs | 89 +++- crates/alien-core/src/lib.rs | 2 +- crates/alien-deploy-cli/src/commands/down.rs | 5 +- crates/alien-deploy-cli/src/commands/list.rs | 7 +- crates/alien-deploy-cli/src/commands/up.rs | 96 +++- crates/alien-deploy-cli/src/lib.rs | 94 +++- crates/alien-deploy-cli/src/main.rs | 5 +- .../src/manager_api_transport.rs | 38 +- .../alien-deployment/tests/test_platform.rs | 18 +- crates/alien-gcp-clients/src/gcp/compute.rs | 35 ++ packages/core/src/__tests__/stack.test.ts | 12 + packages/core/src/compute-cluster.ts | 14 + 17 files changed, 929 insertions(+), 43 deletions(-) create mode 100644 crates/alien-cli/src/commands/packages.rs diff --git a/crates/alien-cli/src/commands/deploy.rs b/crates/alien-cli/src/commands/deploy.rs index a57c69589..3133315de 100644 --- a/crates/alien-cli/src/commands/deploy.rs +++ b/crates/alien-cli/src/commands/deploy.rs @@ -283,20 +283,38 @@ fn resolve_deploy_args(args: &DeployArgs) -> Result { } fn read_deploy_config(path: &Path) -> Result { + let resolved_path = resolved_config_path(path); let contents = std::fs::read_to_string(path).into_alien_error().context( ErrorData::FileOperationFailed { operation: "read".to_string(), - file_path: path.display().to_string(), - reason: "Failed to read deploy config".to_string(), + file_path: resolved_path.display().to_string(), + reason: format!( + "Failed to read deploy config '{}' (relative paths are resolved from the current working directory)", + path.display() + ), }, )?; toml::from_str(&contents) .into_alien_error() .context(ErrorData::ConfigurationError { - message: format!("Failed to parse deploy config {}", path.display()), + message: format!( + "Failed to parse deploy config '{}' (resolved as '{}')", + path.display(), + resolved_path.display() + ), }) } +fn resolved_config_path(path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map(|cwd| cwd.join(path)) + .unwrap_or_else(|_| path.to_path_buf()) + } +} + fn resolve_network_settings( args: &DeployArgs, config: Option<&DeployConfigFile>, @@ -1772,6 +1790,20 @@ fn target_release_from_json( mod tests { use super::*; + #[test] + fn missing_relative_config_error_shows_resolved_path_rule() { + let path = Path::new("definitely-missing/deployment.toml"); + let error = read_deploy_config(path).expect_err("missing config should fail"); + assert!(error.message.contains("current working directory")); + assert!(error.message.contains("definitely-missing/deployment.toml")); + assert!(error.message.contains( + &std::env::current_dir() + .expect("current directory") + .display() + .to_string() + )); + } + #[test] fn target_release_requires_a_stack_for_the_deployment_platform() { let error = target_release_from_json( diff --git a/crates/alien-cli/src/commands/mod.rs b/crates/alien-cli/src/commands/mod.rs index 48550a79d..1ce16fb74 100644 --- a/crates/alien-cli/src/commands/mod.rs +++ b/crates/alien-cli/src/commands/mod.rs @@ -9,6 +9,8 @@ pub mod dev_helpers; pub mod init; pub mod logs; pub mod onboard; +#[cfg(feature = "platform")] +pub mod packages; pub mod release; pub mod releases; pub mod render; @@ -47,6 +49,8 @@ pub use dev_helpers::{ pub use init::{init_task, InitArgs}; pub use logs::{logs_task, LogsArgs}; pub use onboard::{onboard_task, OnboardArgs}; +#[cfg(feature = "platform")] +pub use packages::{packages_task, PackagesArgs}; pub use release::{release_command, ReleaseArgs}; pub use releases::{releases_task, ReleasesArgs}; pub use render::{render_task, RenderArgs}; diff --git a/crates/alien-cli/src/commands/packages.rs b/crates/alien-cli/src/commands/packages.rs new file mode 100644 index 000000000..36f9fff0b --- /dev/null +++ b/crates/alien-cli/src/commands/packages.rs @@ -0,0 +1,465 @@ +//! Platform package inspection and artifact download commands. + +use std::path::{Path, PathBuf}; + +use alien_error::{AlienError, Context, IntoAlienError}; +use clap::{Parser, Subcommand}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use crate::error::{ErrorData, Result}; +use crate::execution_context::ExecutionMode; + +#[derive(Parser, Debug, Clone)] +#[command(about = "List, inspect, and download project packages")] +pub struct PackagesArgs { + #[command(subcommand)] + pub action: PackagesAction, + + /// Project ID or name. Defaults to the linked project. + #[arg(long, global = true)] + pub project: Option, + + /// Emit machine-readable JSON. + #[arg(long, global = true)] + pub json: bool, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum PackagesAction { + /// List packages for a project. + List { + /// Filter by package type. + #[arg(long = "type")] + package_type: Option, + /// Filter by package status. + #[arg(long)] + status: Option, + /// Search package type or version. + #[arg(long)] + search: Option, + }, + /// Show one package and its outputs. + Get { + /// Package ID. + id: String, + }, + /// Download an artifact from a ready package. + Download { + /// Package ID. + id: String, + /// Artifact path or unique suffix, such as `binaries.linux-x64` or `linux-x64`. + #[arg(long)] + artifact: Option, + /// Destination file. Defaults to the artifact URL filename. + #[arg(long, short = 'o')] + output: Option, + /// Replace an existing destination file. + #[arg(long)] + force: bool, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Artifact { + path: String, + url: String, + checksum: Option, +} + +pub async fn packages_task(args: PackagesArgs, ctx: ExecutionMode) -> Result<()> { + let auth = ctx.auth_http().await?; + let workspace = ctx.resolve_workspace_with_bootstrap(!args.json).await?; + let (_, project_link) = ctx + .resolve_project(args.project.as_deref(), !args.json) + .await?; + let project = project_link.project_id; + + match args.action { + PackagesAction::List { + package_type, + status, + search, + } => { + let mut url = package_api_url(&auth.base_url, "/v1/packages", &workspace)?; + { + let mut query = url.query_pairs_mut(); + query.append_pair("project", &project); + if let Some(package_type) = package_type { + query.append_pair("type", &package_type); + } + if let Some(status) = status { + query.append_pair("status", &status); + } + if let Some(search) = search { + query.append_pair("search", &search); + } + } + let body = get_json(&auth, url, "listing packages").await?; + if args.json { + print_json(&body)?; + } else { + let items = body + .get("items") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + if items.is_empty() { + println!("No packages found."); + } else { + for package in items { + println!( + "{} {} {} {}", + string_field(package, "id"), + string_field(package, "type"), + string_field(package, "version"), + string_field(package, "status") + ); + } + } + } + } + PackagesAction::Get { id } => { + let url = package_api_url(&auth.base_url, &format!("/v1/packages/{id}"), &workspace)?; + let package = get_json(&auth, url, "getting package").await?; + if args.json { + print_json(&package)?; + } else { + println!("ID: {}", string_field(&package, "id")); + println!("Type: {}", string_field(&package, "type")); + println!("Version: {}", string_field(&package, "version")); + println!("Status: {}", string_field(&package, "status")); + let artifacts = package + .get("outputs") + .map(collect_artifacts) + .unwrap_or_default(); + if !artifacts.is_empty() { + println!("Artifacts:"); + for artifact in artifacts { + println!(" {}", artifact.path); + } + } + } + } + PackagesAction::Download { + id, + artifact, + output, + force, + } => { + let url = package_api_url(&auth.base_url, &format!("/v1/packages/{id}"), &workspace)?; + let package = get_json(&auth, url, "getting package").await?; + let artifacts = package + .get("outputs") + .map(collect_artifacts) + .unwrap_or_default(); + let selected = select_artifact(&artifacts, artifact.as_deref())?; + let destination = output.unwrap_or_else(|| artifact_filename(selected)); + download_artifact(selected, &destination, force).await?; + if args.json { + print_json(&serde_json::json!({ + "packageId": id, + "artifact": selected.path, + "path": destination, + "sha256": selected.checksum, + }))?; + } else { + println!("Downloaded {} to {}", selected.path, destination.display()); + } + } + } + + Ok(()) +} + +async fn get_json( + auth: &crate::auth::AuthHttp, + url: reqwest::Url, + operation: &str, +) -> Result { + let response = auth + .reqwest_client() + .get(url.clone()) + .send() + .await + .into_alien_error() + .context(ErrorData::ApiRequestFailed { + message: operation.to_string(), + url: Some(url.to_string()), + })?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(AlienError::new(ErrorData::ApiRequestFailed { + message: format!("{operation} failed ({status}): {body}"), + url: Some(url.to_string()), + })); + } + response + .json() + .await + .into_alien_error() + .context(ErrorData::ApiRequestFailed { + message: format!("parsing response while {operation}"), + url: Some(url.to_string()), + }) +} + +async fn download_artifact(artifact: &Artifact, destination: &Path, force: bool) -> Result<()> { + if destination.exists() && !force { + return Err(AlienError::new(ErrorData::ConfigurationError { + message: format!( + "Destination '{}' already exists; pass --force to replace it.", + destination.display() + ), + })); + } + // Artifact URLs can point at a different host (for example, object + // storage). Never forward the platform API bearer token to that host. + let response = reqwest::Client::new() + .get(&artifact.url) + .send() + .await + .into_alien_error() + .context(ErrorData::ApiRequestFailed { + message: format!("downloading artifact '{}'", artifact.path), + url: Some(artifact.url.clone()), + })?; + if !response.status().is_success() { + let status = response.status(); + return Err(AlienError::new(ErrorData::ApiRequestFailed { + message: format!("artifact download failed ({status})"), + url: Some(artifact.url.clone()), + })); + } + let bytes = response + .bytes() + .await + .into_alien_error() + .context(ErrorData::ApiRequestFailed { + message: format!("reading artifact '{}'", artifact.path), + url: Some(artifact.url.clone()), + })?; + if let Some(expected) = &artifact.checksum { + let actual = hex::encode(Sha256::digest(&bytes)); + if !actual.eq_ignore_ascii_case(expected) { + return Err(AlienError::new(ErrorData::ConfigurationError { + message: format!( + "Checksum mismatch for '{}': expected {}, received {}.", + artifact.path, expected, actual + ), + })); + } + } + if let Some(parent) = destination + .parent() + .filter(|path| !path.as_os_str().is_empty()) + { + tokio::fs::create_dir_all(parent) + .await + .into_alien_error() + .context(ErrorData::FileOperationFailed { + operation: "create directory".to_string(), + file_path: parent.display().to_string(), + reason: "Failed to create artifact destination directory".to_string(), + })?; + } + tokio::fs::write(destination, &bytes) + .await + .into_alien_error() + .context(ErrorData::FileOperationFailed { + operation: "write".to_string(), + file_path: destination.display().to_string(), + reason: "Failed to write downloaded artifact".to_string(), + }) +} + +fn package_api_url(base_url: &str, path: &str, workspace: &str) -> Result { + let mut url = reqwest::Url::parse(base_url).into_alien_error().context( + ErrorData::ConfigurationError { + message: "platform base URL is invalid".to_string(), + }, + )?; + url.set_path(path); + url.query_pairs_mut().append_pair("workspace", workspace); + Ok(url) +} + +fn collect_artifacts(outputs: &Value) -> Vec { + fn visit(value: &Value, path: &mut Vec, artifacts: &mut Vec) { + let Some(object) = value.as_object() else { + return; + }; + for (key, child) in object { + path.push(key.clone()); + if matches!(key.as_str(), "url" | "downloadUrl" | "templateUrl") { + if let Some(url) = child.as_str().filter(|url| url.starts_with("http")) { + let artifact_path = path[..path.len() - 1].join("."); + let checksum = object + .get("sha256") + .or_else(|| object.get("shasum")) + .and_then(Value::as_str) + .map(str::to_string); + artifacts.push(Artifact { + path: artifact_path, + url: url.to_string(), + checksum, + }); + } + } else { + visit(child, path, artifacts); + } + path.pop(); + } + } + + let mut artifacts = Vec::new(); + visit(outputs, &mut Vec::new(), &mut artifacts); + artifacts.sort_by(|left, right| left.path.cmp(&right.path)); + artifacts.dedup_by(|left, right| left.path == right.path && left.url == right.url); + artifacts +} + +fn select_artifact<'a>(artifacts: &'a [Artifact], requested: Option<&str>) -> Result<&'a Artifact> { + if artifacts.is_empty() { + return Err(AlienError::new(ErrorData::ConfigurationError { + message: "This package has no downloadable artifacts.".to_string(), + })); + } + if let Some(requested) = requested { + let matches = artifacts + .iter() + .filter(|artifact| { + artifact.path == requested || artifact.path.ends_with(&format!(".{requested}")) + }) + .collect::>(); + return match matches.as_slice() { + [artifact] => Ok(*artifact), + [] => Err(artifact_selection_error( + artifacts, + format!("Artifact '{requested}' was not found."), + )), + _ => Err(artifact_selection_error( + artifacts, + format!("Artifact selector '{requested}' is ambiguous."), + )), + }; + } + if artifacts.len() == 1 { + return Ok(&artifacts[0]); + } + if let Some(native_target) = native_binary_target() { + let suffix = format!(".{native_target}"); + let native = artifacts + .iter() + .filter(|artifact| artifact.path == native_target || artifact.path.ends_with(&suffix)) + .collect::>(); + if let [artifact] = native.as_slice() { + return Ok(*artifact); + } + } + Err(artifact_selection_error( + artifacts, + "The package contains multiple downloadable artifacts.".to_string(), + )) +} + +fn artifact_selection_error(artifacts: &[Artifact], reason: String) -> AlienError { + AlienError::new(ErrorData::ConfigurationError { + message: format!( + "{reason} Pass --artifact with one of: {}", + artifacts + .iter() + .map(|artifact| artifact.path.as_str()) + .collect::>() + .join(", ") + ), + }) +} + +fn native_binary_target() -> Option<&'static str> { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("linux", "x86_64") => Some("linux-x64"), + ("linux", "aarch64") => Some("linux-arm64"), + ("macos", "aarch64") => Some("darwin-arm64"), + ("windows", "x86_64") => Some("windows-x64"), + _ => None, + } +} + +fn artifact_filename(artifact: &Artifact) -> PathBuf { + reqwest::Url::parse(&artifact.url) + .ok() + .and_then(|url| url.path_segments()?.next_back().map(str::to_string)) + .filter(|name| !name.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(artifact.path.replace('.', "-"))) +} + +fn string_field<'a>(value: &'a Value, field: &str) -> &'a str { + value.get(field).and_then(Value::as_str).unwrap_or("-") +} + +fn print_json(value: &Value) -> Result<()> { + println!( + "{}", + serde_json::to_string_pretty(value) + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to serialize JSON output".to_string(), + })? + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn artifact(path: &str) -> Artifact { + Artifact { + path: path.to_string(), + url: format!("https://packages.example/{path}"), + checksum: None, + } + } + + #[test] + fn collects_downloadable_outputs_with_checksums() { + let outputs = serde_json::json!({ + "type": "cli", + "binaries": { + "linux-x64": { + "url": "https://packages.example/tool-linux-x64", + "sha256": "abc" + } + }, + "buildInfo": { "source": "ignored" } + }); + assert_eq!( + collect_artifacts(&outputs), + vec![Artifact { + path: "binaries.linux-x64".to_string(), + url: "https://packages.example/tool-linux-x64".to_string(), + checksum: Some("abc".to_string()), + }] + ); + } + + #[test] + fn explicit_artifact_suffix_must_be_unique() { + let artifacts = vec![artifact("modules.aws"), artifact("modules.gcp")]; + assert_eq!( + select_artifact(&artifacts, Some("aws")).unwrap().path, + "modules.aws" + ); + assert!(select_artifact(&artifacts, Some("missing")).is_err()); + } + + #[test] + fn multiple_artifacts_require_a_selector_when_no_native_match_exists() { + let artifacts = vec![artifact("modules.aws"), artifact("modules.gcp")]; + let error = select_artifact(&artifacts, None).unwrap_err(); + assert!(error.message.contains("--artifact")); + } +} diff --git a/crates/alien-cli/src/lib.rs b/crates/alien-cli/src/lib.rs index 7cf6e345f..18894d17b 100644 --- a/crates/alien-cli/src/lib.rs +++ b/crates/alien-cli/src/lib.rs @@ -25,6 +25,8 @@ use crate::commands::manager::{managers_task, ManagersArgs}; #[cfg(feature = "platform")] use crate::commands::operations::{operations_task, OperationsArgs}; #[cfg(feature = "platform")] +use crate::commands::packages::{packages_task, PackagesArgs}; +#[cfg(feature = "platform")] use crate::commands::platform::{ link_task, login_task, logout_task, project_task, unlink_task, workspace_task, PlatformCommand, }; @@ -118,6 +120,8 @@ impl Cli { Some(Commands::Managers(args)) => args.json, #[cfg(feature = "platform")] Some(Commands::Operations(args)) => args.json, + #[cfg(feature = "platform")] + Some(Commands::Packages(args)) => args.json, _ => false, } } @@ -173,6 +177,10 @@ pub enum Commands { #[cfg(feature = "platform")] #[command(alias = "operation")] Operations(OperationsArgs), + + /// List, inspect, and download project packages + #[cfg(feature = "platform")] + Packages(PackagesArgs), } #[derive(Parser, Debug, Clone)] @@ -508,6 +516,41 @@ mod tests { ]) .expect("dev release --version should parse"); } + + #[cfg(feature = "platform")] + #[test] + fn packages_list_flags_parse() { + Cli::command() + .try_get_matches_from([ + "alien", + "packages", + "list", + "--project", + "demo", + "--type", + "cli", + "--json", + ]) + .expect("packages list flags should parse"); + } + + #[cfg(feature = "platform")] + #[test] + fn packages_download_flags_parse() { + Cli::command() + .try_get_matches_from([ + "alien", + "packages", + "download", + "pkg_abc", + "--artifact", + "linux-x64", + "--output", + "tool", + "--force", + ]) + .expect("packages download flags should parse"); + } } async fn serve_task(args: ServeArgs) -> Result<()> { @@ -1523,6 +1566,8 @@ pub async fn run_cli(cli: Cli) -> Result<()> { Some(Commands::Managers(args)) => managers_task(args, ctx).await?, #[cfg(feature = "platform")] Some(Commands::Operations(args)) => operations_task(args, ctx).await?, + #[cfg(feature = "platform")] + Some(Commands::Packages(args)) => packages_task(args, ctx).await?, } Ok(()) diff --git a/crates/alien-core/src/bin/schema_exporter.rs b/crates/alien-core/src/bin/schema_exporter.rs index 99f1267f8..b58360b4b 100644 --- a/crates/alien-core/src/bin/schema_exporter.rs +++ b/crates/alien-core/src/bin/schema_exporter.rs @@ -249,5 +249,8 @@ fn main() { let mut file = File::create(&args.gateability_output).unwrap(); file.write_all(serde_json::to_string_pretty(&manifest).unwrap().as_bytes()) .unwrap(); - println!("Gateability manifest exported to {}", args.gateability_output); + println!( + "Gateability manifest exported to {}", + args.gateability_output + ); } diff --git a/crates/alien-core/src/instance_catalog.rs b/crates/alien-core/src/instance_catalog.rs index 96fcf3b7f..a63b9fc48 100644 --- a/crates/alien-core/src/instance_catalog.rs +++ b/crates/alien-core/src/instance_catalog.rs @@ -162,27 +162,31 @@ pub struct InstanceTypeSpec { } impl InstanceTypeSpec { - /// Whether this instance type supports - /// `CpuOptions.NestedVirtualization=enabled` on AWS launch. + /// Whether this instance type supports nested virtualization. /// - /// Per AWS docs (`aws ec2 create-launch-template help`), nested - /// virtualization is only supported on 8th-generation Intel instance - /// types: c8i, m8i, r8i, and their `-flex` variants. We classify by - /// family-name prefix rather than a per-row bool so the existing 70+ - /// catalog rows don't need an extra field. + /// Classify by documented provider families rather than adding a flag to + /// every catalog row. GCP still requires the instance template to opt in; + /// Azure exposes the capability automatically on supported VM sizes. pub fn is_nested_virt_capable(&self) -> bool { - if self.platform != Platform::Aws { - // GCP/Azure equivalents would need their own family lists. - // Today nested virt is wired through only for AWS. - return false; + match self.platform { + Platform::Aws => { + let name = self.name; + name.starts_with("m8i.") + || name.starts_with("c8i.") + || name.starts_with("r8i.") + || name.starts_with("m8i-flex.") + || name.starts_with("c8i-flex.") + || name.starts_with("r8i-flex.") + } + Platform::Gcp => self.name.starts_with("n2-standard-"), + Platform::Azure => { + let name = self.name; + (name.starts_with("Standard_D") && name.ends_with("s_v5")) + || (name.starts_with("Standard_E") && name.ends_with("s_v5")) + || (name.starts_with("Standard_F") && name.ends_with("s_v2")) + } + _ => false, } - let name = self.name; - name.starts_with("m8i.") - || name.starts_with("c8i.") - || name.starts_with("r8i.") - || name.starts_with("m8i-flex.") - || name.starts_with("c8i-flex.") - || name.starts_with("r8i-flex.") } /// Convert this catalog entry into a `MachineProfile` for use in `CapacityGroup`. @@ -1255,7 +1259,7 @@ pub fn select_instance_type( if requirements.nested_virt { spec.is_nested_virt_capable() } else { - !spec.is_nested_virt_capable() + platform != Platform::Aws || !spec.is_nested_virt_capable() } }) .collect(); @@ -1263,8 +1267,7 @@ pub fn select_instance_type( if candidates.is_empty() { return Err(if requirements.nested_virt { format!( - "no nested-virt-capable {family:?} instance types in catalog for platform {platform}; \ - only 8th-gen Intel families (m8i/c8i/r8i) support nested virtualization on AWS" + "no nested-virt-capable {family:?} instance types in catalog for platform {platform}" ) } else { format!("no {family:?} instance types in catalog for platform {platform}") @@ -1864,6 +1867,50 @@ mod tests { assert!(spec.is_nested_virt_capable()); } + #[test] + fn test_select_gcp_picks_n2_when_nested_virt_required() { + let req = WorkloadRequirements { + total_cpu_at_desired: 4.0, + total_memory_bytes_at_desired: 8 * GI, + total_cpu_at_max: 4.0, + total_memory_bytes_at_max: 8 * GI, + max_cpu_per_container: 4.0, + max_memory_per_container: 8 * GI, + max_ephemeral_storage_bytes: 0, + architecture: Some(Architecture::X86_64), + gpu: None, + nested_virt: true, + }; + + let selection = select_instance_type(Platform::Gcp, &req).unwrap(); + assert_eq!(selection.instance_type, "n2-standard-8"); + assert!(find_instance_type(Platform::Gcp, selection.instance_type) + .unwrap() + .is_nested_virt_capable()); + } + + #[test] + fn test_select_azure_picks_dsv5_when_nested_virt_required() { + let req = WorkloadRequirements { + total_cpu_at_desired: 4.0, + total_memory_bytes_at_desired: 8 * GI, + total_cpu_at_max: 4.0, + total_memory_bytes_at_max: 8 * GI, + max_cpu_per_container: 4.0, + max_memory_per_container: 8 * GI, + max_ephemeral_storage_bytes: 0, + architecture: Some(Architecture::X86_64), + gpu: None, + nested_virt: true, + }; + + let selection = select_instance_type(Platform::Azure, &req).unwrap(); + assert_eq!(selection.instance_type, "Standard_D8s_v5"); + assert!(find_instance_type(Platform::Azure, selection.instance_type) + .unwrap() + .is_nested_virt_capable()); + } + #[test] fn test_select_aws_defaults_to_image_target_architecture() { let req = WorkloadRequirements { diff --git a/crates/alien-core/src/lib.rs b/crates/alien-core/src/lib.rs index 3eacd6ea5..33a68cc3a 100644 --- a/crates/alien-core/src/lib.rs +++ b/crates/alien-core/src/lib.rs @@ -91,8 +91,8 @@ pub use presigned::*; pub mod embedded_config; pub mod sync; -pub mod commands_types; pub mod access_request_crd; +pub mod commands_types; pub use commands_types::*; pub mod debug_session; diff --git a/crates/alien-deploy-cli/src/commands/down.rs b/crates/alien-deploy-cli/src/commands/down.rs index 89382af3f..a8a8278cb 100644 --- a/crates/alien-deploy-cli/src/commands/down.rs +++ b/crates/alien-deploy-cli/src/commands/down.rs @@ -87,7 +87,10 @@ pub async fn down_command(args: DownArgs, embedded_config: Option<&DeployCliConf } }; - output::header("Alien Deploy — Destroy"); + let display_name = embedded_config + .and_then(|config| config.display_name.as_deref()) + .unwrap_or("Alien Deploy"); + output::header(&format!("{display_name} — Destroy")); output::status("Name:", &args.name); output::status("Manager:", &manager_url); diff --git a/crates/alien-deploy-cli/src/commands/list.rs b/crates/alien-deploy-cli/src/commands/list.rs index 7a04779fc..10118ae49 100644 --- a/crates/alien-deploy-cli/src/commands/list.rs +++ b/crates/alien-deploy-cli/src/commands/list.rs @@ -69,7 +69,12 @@ pub async fn list_command(args: ListArgs, embedded_config: Option<&DeployCliConf let deployments = tracker.list(); if deployments.is_empty() { - output::info("No tracked deployments. Use 'alien-deploy deploy' to create one."); + let command_name = embedded_config + .and_then(|config| config.name.as_deref()) + .unwrap_or("alien-deploy"); + output::info(&format!( + "No tracked deployments. Use '{command_name} deploy' to create one." + )); return Ok(()); } diff --git a/crates/alien-deploy-cli/src/commands/up.rs b/crates/alien-deploy-cli/src/commands/up.rs index 61c15ec49..392aad9a2 100644 --- a/crates/alien-deploy-cli/src/commands/up.rs +++ b/crates/alien-deploy-cli/src/commands/up.rs @@ -28,8 +28,8 @@ use alien_deployment::{ }; use alien_error::{AlienError, Context, ContextError, IntoAlienError}; use alien_infra::ClientConfigExt; -use alien_manager_api::{Client as ServerClient, SdkResultExt as ManagerSdkResultExt}; use alien_manager_api::SdkResultExtReadingBody as _; +use alien_manager_api::{Client as ServerClient, SdkResultExt as ManagerSdkResultExt}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use clap::Parser; use serde::{Deserialize, Serialize}; @@ -765,6 +765,36 @@ machine = "m8i.xlarge" assert_eq!(selection.machine(), Some("m8i.xlarge")); assert_eq!(selection.min_size(), 2); assert_eq!(selection.max_size(), 5); + assert_eq!( + effective_compute_summary(&settings), + "general: 2-5 autoscale, m8i.xlarge" + ); + } + + #[test] + fn omitted_compute_selection_is_explicit_in_summary() { + assert_eq!( + effective_compute_summary(&StackSettings::default()), + "provider defaults (no compute pools selected)" + ); + } + + #[test] + fn missing_relative_config_error_shows_resolved_path_rule() { + let args = UpArgs::parse_from([ + "alien-deploy", + "--config", + "definitely-missing/deployment.toml", + ]); + let error = load_deploy_config(&args).expect_err("missing config should fail"); + assert!(error.message.contains("definitely-missing/deployment.toml")); + assert!(error.message.contains("current working directory")); + assert!(error.message.contains( + &std::env::current_dir() + .expect("current directory") + .display() + .to_string() + )); } #[test] @@ -1095,6 +1125,8 @@ pub async fn up_command(args: UpArgs, embedded_config: Option<&DeployCliConfig>) } }; + let stack_settings = load_stack_settings(&args, platform, deploy_config.as_ref())?; + if print_progress { let banner_title = embedded_config .and_then(|c| c.display_name.as_deref()) @@ -1106,6 +1138,7 @@ pub async fn up_command(args: UpArgs, embedded_config: Option<&DeployCliConfig>) } output::label_value("Manager", &manager_url); output::label_value("Name", &name); + output::label_value("Compute", &effective_compute_summary(&stack_settings)); if let Some(public_endpoints) = public_endpoints.as_ref() { let endpoint_count: usize = public_endpoints.values().map(HashMap::len).sum(); output::label_value("Public endpoints", &endpoint_count.to_string()); @@ -1113,8 +1146,6 @@ pub async fn up_command(args: UpArgs, embedded_config: Option<&DeployCliConfig>) eprintln!(); } - let stack_settings = load_stack_settings(&args, platform, deploy_config.as_ref())?; - // Create authenticated manager client let client = create_manager_client(&token, &manager_url)?; @@ -1598,20 +1629,68 @@ fn load_deploy_config(args: &UpArgs) -> Result> { return Ok(None); }; + let resolved_path = resolved_config_path(path); let text = std::fs::read_to_string(path).into_alien_error().context( ErrorData::ConfigurationError { - message: format!("Failed to read deployment config {}", path.display()), + message: format!( + "Failed to read deployment config '{}' (resolved as '{}'; relative paths are resolved from the current working directory)", + path.display(), + resolved_path.display() + ), }, )?; let config = toml::from_str(&text) .into_alien_error() .context(ErrorData::ConfigurationError { - message: format!("Failed to parse deployment config {}", path.display()), + message: format!( + "Failed to parse deployment config '{}' (resolved as '{}')", + path.display(), + resolved_path.display() + ), })?; Ok(Some(config)) } +fn resolved_config_path(path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map(|cwd| cwd.join(path)) + .unwrap_or_else(|_| path.to_path_buf()) + } +} + +fn effective_compute_summary(settings: &StackSettings) -> String { + let Some(compute) = settings + .compute + .as_ref() + .filter(|compute| !compute.pools.is_empty()) + else { + return "provider defaults (no compute pools selected)".to_string(); + }; + let mut pools = compute.pools.iter().collect::>(); + pools.sort_by(|(left, _), (right, _)| left.cmp(right)); + pools + .into_iter() + .map(|(name, selection)| { + let size = if selection.min_size() == selection.max_size() { + format!("{} fixed", selection.min_size()) + } else { + format!( + "{}-{} autoscale", + selection.min_size(), + selection.max_size() + ) + }; + let machine = selection.machine().unwrap_or("provider default machine"); + format!("{name}: {size}, {machine}") + }) + .collect::>() + .join("; ") +} + fn load_public_endpoints( args: &UpArgs, platform: Platform, @@ -2714,7 +2793,12 @@ async fn run_local_pull_model( output::success("alien-operator installed and running as a system service."); output::info("The operator will sync with the manager and deploy updates automatically."); - output::info("Use 'alien-deploy operator status' to check the service."); + let command_name = embedded_config + .and_then(|config| config.name.as_deref()) + .unwrap_or("alien-deploy"); + output::info(&format!( + "Use '{command_name} operator status' to check the service." + )); Ok(()) } diff --git a/crates/alien-deploy-cli/src/lib.rs b/crates/alien-deploy-cli/src/lib.rs index 4ad5b9b2f..efb79a041 100644 --- a/crates/alien-deploy-cli/src/lib.rs +++ b/crates/alien-deploy-cli/src/lib.rs @@ -15,7 +15,7 @@ use crate::commands::{ }; use crate::error::Result; use alien_core::embedded_config::{load_embedded_config, DeployCliConfig}; -use clap::{Parser, Subcommand}; +use clap::{Command, CommandFactory, FromArgMatches, Parser, Subcommand}; use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; #[derive(Parser)] @@ -54,6 +54,70 @@ pub enum Commands { Leave(LeaveArgs), } +/// Parse command-line arguments using any branding embedded in this binary. +pub fn parse_cli() -> Cli { + let embedded_config: Option = load_embedded_config().ok().flatten(); + let command = command_with_branding(embedded_config.as_ref()); + let matches = command.get_matches(); + Cli::from_arg_matches(&matches).unwrap_or_else(|error| error.exit()) +} + +fn command_with_branding(embedded_config: Option<&DeployCliConfig>) -> Command { + let Some(config) = embedded_config else { + return Cli::command(); + }; + let command_name = config.name.as_deref().unwrap_or("alien-deploy"); + let display_name = config.display_name.as_deref().unwrap_or("Alien Deploy"); + brand_command_help(Cli::command(), command_name, display_name, true) +} + +fn brand_command_help( + mut command: Command, + command_name: &str, + display_name: &str, + root: bool, +) -> Command { + if root { + command = command.name(leak_string(command_name.to_string())); + } + if let Some(about) = command.get_about() { + let branded = replace_branding(&about.to_string(), command_name, display_name); + command = command.about(leak_string(branded)); + } + if let Some(long_about) = command.get_long_about() { + let branded = replace_branding(&long_about.to_string(), command_name, display_name); + command = command.long_about(leak_string(branded)); + } + if let Some(before_help) = command.get_before_help() { + let branded = replace_branding(&before_help.to_string(), command_name, display_name); + command = command.before_help(leak_string(branded)); + } + if let Some(after_help) = command.get_after_help() { + let branded = replace_branding(&after_help.to_string(), command_name, display_name); + command = command.after_help(leak_string(branded)); + } + let subcommands = command + .get_subcommands() + .map(|subcommand| subcommand.get_name().to_string()) + .collect::>(); + for subcommand in subcommands { + command = command.mut_subcommand(subcommand, |child| { + brand_command_help(child, command_name, display_name, false) + }); + } + command +} + +fn replace_branding(value: &str, command_name: &str, display_name: &str) -> String { + value + .replace("Alien Deploy", display_name) + .replace("alien-deploy", command_name) +} + +fn leak_string(value: String) -> &'static str { + Box::leak(value.into_boxed_str()) +} + pub fn setup_tracing(verbose: bool) { let filter = if verbose { EnvFilter::try_from_default_env() @@ -109,6 +173,34 @@ mod tests { assert!(matches!(cli.command, Commands::Deploy(_))); } + #[test] + fn embedded_branding_updates_command_name_and_help_examples() { + let config = DeployCliConfig { + token: None, + deployment_group_id: None, + default_platform: None, + api_base_url: None, + agent_binary_url: None, + machine_bundle_url: None, + install_script_url: None, + token_env_var: None, + name: Some("acmectl".to_string()), + display_name: Some("Acme Deployment CLI".to_string()), + }; + let mut command = command_with_branding(Some(&config)); + assert_eq!(command.get_name(), "acmectl"); + let help = command.render_long_help().to_string(); + assert!(help.contains("Acme Deployment CLI")); + assert!(!help.contains("Alien Deploy")); + + let deploy = command + .find_subcommand_mut("deploy") + .expect("deploy subcommand"); + let help = deploy.render_long_help().to_string(); + assert!(help.contains("acmectl deploy")); + assert!(!help.contains("alien-deploy deploy")); + } + #[test] fn test_parse_up_command_token_file() { let cli = Cli::try_parse_from([ diff --git a/crates/alien-deploy-cli/src/main.rs b/crates/alien-deploy-cli/src/main.rs index dcc2766b4..f74eae314 100644 --- a/crates/alien-deploy-cli/src/main.rs +++ b/crates/alien-deploy-cli/src/main.rs @@ -1,9 +1,8 @@ -use alien_deploy_cli::{run_cli, Cli}; -use clap::Parser; +use alien_deploy_cli::{parse_cli, run_cli}; #[tokio::main] async fn main() { - let cli = Cli::parse(); + let cli = parse_cli(); if let Err(e) = run_cli(cli).await { eprintln!("\x1b[31mError:\x1b[0m {}", e); std::process::exit(1); diff --git a/crates/alien-deployment/src/manager_api_transport.rs b/crates/alien-deployment/src/manager_api_transport.rs index 3d8f3686f..34a754dc0 100644 --- a/crates/alien-deployment/src/manager_api_transport.rs +++ b/crates/alien-deployment/src/manager_api_transport.rs @@ -10,7 +10,7 @@ use alien_core::{DeploymentModel, DeploymentState, ObservedInventoryBatch, ResourceHeartbeat}; use alien_error::{AlienError, Context, IntoAlienError}; -use alien_manager_api::{Client as ManagerClient, SdkResultExt}; +use alien_manager_api::{Client as ManagerClient, SdkResultExt, SdkResultExtReadingBody as _}; use async_trait::async_trait; use serde::Serialize; use tracing::{error, info}; @@ -481,7 +481,18 @@ pub async fn final_reconcile( }) .send() .await + .into_sdk_error_reading_body() + .await { + if state.status == alien_core::DeploymentStatus::Deleted + && is_missing_deployment_response(&e) + { + info!( + deployment_id = %deployment_id, + "Deployment was removed before final deletion reconciliation" + ); + return; + } error!( deployment_id = %deployment_id, error = %e, @@ -508,6 +519,18 @@ mod tests { }; use chrono::TimeZone; + #[test] + fn only_not_found_means_deleted_cleanup_is_already_complete() { + let mut error = AlienError::new(alien_error::GenericError { + message: "test".to_string(), + }); + error.http_status_code = Some(404); + assert!(is_missing_deployment_response(&error)); + + error.http_status_code = Some(409); + assert!(!is_missing_deployment_response(&error)); + } + fn sample_heartbeat() -> ResourceHeartbeat { ResourceHeartbeat { deployment_id: Some("dep_test".to_string()), @@ -616,7 +639,16 @@ pub async fn release_deployment(client: &ManagerClient, deployment_id: &str, ses }) .send() .await + .into_sdk_error_reading_body() + .await { + if is_missing_deployment_response(&e) { + info!( + deployment_id = %deployment_id, + "Deployment was already removed; no sync lock remains to release" + ); + return; + } error!( deployment_id = %deployment_id, error = %e, @@ -624,3 +656,7 @@ pub async fn release_deployment(client: &ManagerClient, deployment_id: &str, ses ); } } + +fn is_missing_deployment_response(error: &AlienError) -> bool { + error.http_status_code == Some(404) +} diff --git a/crates/alien-deployment/tests/test_platform.rs b/crates/alien-deployment/tests/test_platform.rs index 0f5008e23..0542d52ee 100644 --- a/crates/alien-deployment/tests/test_platform.rs +++ b/crates/alien-deployment/tests/test_platform.rs @@ -1753,9 +1753,14 @@ async fn a_declined_live_worker_keeps_its_derived_baseline_across_updates() { .resources .contains_key("proxy")); assert!( - prepared_stack_of(&state).resources.contains_key("default-sa"), + prepared_stack_of(&state) + .resources + .contains_key("default-sa"), "the profile-derived service account belongs to the prepared stack: {:?}", - prepared_stack_of(&state).resources.keys().collect::>() + prepared_stack_of(&state) + .resources + .keys() + .collect::>() ); // Declined on an update: the worker is deprovisioned, the deployment @@ -1772,9 +1777,14 @@ async fn a_declined_live_worker_keeps_its_derived_baseline_across_updates() { .resources .contains_key("proxy")); assert!( - prepared_stack_of(&state).resources.contains_key("default-sa"), + prepared_stack_of(&state) + .resources + .contains_key("default-sa"), "declining the worker must not strip its derived baseline: {:?}", - prepared_stack_of(&state).resources.keys().collect::>() + prepared_stack_of(&state) + .resources + .keys() + .collect::>() ); assert!( !prepared_stack_of(&state).resources.contains_key("proxy"), diff --git a/crates/alien-gcp-clients/src/gcp/compute.rs b/crates/alien-gcp-clients/src/gcp/compute.rs index fb298005e..ebe0eefd9 100644 --- a/crates/alien-gcp-clients/src/gcp/compute.rs +++ b/crates/alien-gcp-clients/src/gcp/compute.rs @@ -4509,6 +4509,19 @@ pub struct InstanceProperties { /// Confidential instance configuration. #[serde(skip_serializing_if = "Option::is_none")] pub confidential_instance_config: Option, + + /// Advanced machine features for the instance. + #[serde(skip_serializing_if = "Option::is_none")] + pub advanced_machine_features: Option, +} + +/// Advanced machine features for an instance. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct AdvancedMachineFeatures { + /// Whether nested virtualization is enabled. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_nested_virtualization: Option, } /// Attached disk configuration. @@ -5830,4 +5843,26 @@ mod tests { .any(|line| line.eq_ignore_ascii_case("authorization: Bearer test-token"))); assert!(delete_body.is_empty()); } + + #[test] + fn instance_properties_serialize_nested_virtualization() { + let properties = InstanceProperties::builder() + .machine_type("n2-standard-8".to_string()) + .advanced_machine_features( + AdvancedMachineFeatures::builder() + .enable_nested_virtualization(true) + .build(), + ) + .build(); + + assert_eq!( + serde_json::to_value(properties).expect("instance properties should serialize"), + serde_json::json!({ + "machineType": "n2-standard-8", + "advancedMachineFeatures": { + "enableNestedVirtualization": true + } + }) + ); + } } diff --git a/packages/core/src/__tests__/stack.test.ts b/packages/core/src/__tests__/stack.test.ts index e55044e65..9060c3797 100644 --- a/packages/core/src/__tests__/stack.test.ts +++ b/packages/core/src/__tests__/stack.test.ts @@ -20,6 +20,7 @@ describe("Stack builder validation", () => { type: "fixed", machines: { min: 2, max: 4, default: 2 }, }, + failureDomainSpread: 2, }) .build() @@ -42,6 +43,17 @@ describe("Stack builder validation", () => { nestedVirtualization: true, }, ]) + expect(compute.config.failureDomainSpread).toEqual({ nested: 2 }) + }) + + it("rejects invalid compute-pool failure-domain spreads", () => { + expect(() => + new alien.ComputeCluster("runtime").pool("servers", { + requirements: { cpu: 2, memory: "4Gi" }, + scale: { type: "fixed", machines: 3 }, + failureDomainSpread: 0, + }), + ).toThrow(/failureDomainSpread must be an integer from 1 to 255/) }) it("builds stack input definitions for deployment forms", () => { diff --git a/packages/core/src/compute-cluster.ts b/packages/core/src/compute-cluster.ts index 39a3caf82..3c9e37034 100644 --- a/packages/core/src/compute-cluster.ts +++ b/packages/core/src/compute-cluster.ts @@ -58,6 +58,8 @@ export type ComputePoolScale = export type ComputePoolInput = { requirements: ComputePoolRequirements scale: ComputePoolScale + /** Number of provider failure domains across which the pool must be spread. */ + failureDomainSpread?: number } /** @@ -89,6 +91,14 @@ export class ComputeCluster { } public pool(groupId: string, config: ComputePoolInput): this { + if ( + config.failureDomainSpread !== undefined && + (!Number.isInteger(config.failureDomainSpread) || + config.failureDomainSpread < 1 || + config.failureDomainSpread > 255) + ) { + throw new Error("Compute pool failureDomainSpread must be an integer from 1 to 255") + } const { minSize, maxSize } = selectedScaleBounds(config.scale) this._config.capacityGroups!.push({ groupId, @@ -98,6 +108,10 @@ export class ComputeCluster { scalePolicy: scalePolicyFromInput(config.scale), nestedVirtualization: config.requirements.nestedVirtualization, }) + if (config.failureDomainSpread !== undefined) { + this._config.failureDomainSpread ??= {} + this._config.failureDomainSpread[groupId] = config.failureDomainSpread + } return this } From 5573fea844c56770959b658d649f73fa66f95d28 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Sat, 15 Aug 2026 22:49:10 -0600 Subject: [PATCH 2/2] fix(cli): preserve package API base paths --- crates/alien-cli/src/commands/packages.rs | 30 ++++++++++++++++++----- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/crates/alien-cli/src/commands/packages.rs b/crates/alien-cli/src/commands/packages.rs index 36f9fff0b..5941717f9 100644 --- a/crates/alien-cli/src/commands/packages.rs +++ b/crates/alien-cli/src/commands/packages.rs @@ -275,12 +275,15 @@ async fn download_artifact(artifact: &Artifact, destination: &Path, force: bool) } fn package_api_url(base_url: &str, path: &str, workspace: &str) -> Result { - let mut url = reqwest::Url::parse(base_url).into_alien_error().context( - ErrorData::ConfigurationError { - message: "platform base URL is invalid".to_string(), - }, - )?; - url.set_path(path); + let mut url = reqwest::Url::parse(&format!( + "{}/{}", + base_url.trim_end_matches('/'), + path.trim_start_matches('/') + )) + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "platform base URL is invalid".to_string(), + })?; url.query_pairs_mut().append_pair("workspace", workspace); Ok(url) } @@ -462,4 +465,19 @@ mod tests { let error = select_artifact(&artifacts, None).unwrap_err(); assert!(error.message.contains("--artifact")); } + + #[test] + fn package_api_url_preserves_base_path_prefix() { + let url = package_api_url( + "https://platform.example/proxy/api/", + "/v1/packages/pkg_123", + "example-workspace", + ) + .unwrap(); + + assert_eq!( + url.as_str(), + "https://platform.example/proxy/api/v1/packages/pkg_123?workspace=example-workspace" + ); + } }