From a01a09fa632242ff200fed5798f7c2588ed42f44 Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Sun, 31 May 2026 19:58:02 +0300 Subject: [PATCH 1/8] refactor(manager): make DeploymentRecord.stack_settings optional Matches the optional fields next to it. A caller that builds a record without stack settings leaves it absent instead of inserting an empty default that would serialize as if it were real. Stores set it; execution paths expect it. --- crates/alien-manager/src/loops/deployment.rs | 15 +++++++++++++-- crates/alien-manager/src/providers/oss_authz.rs | 2 +- crates/alien-manager/src/routes/deployments.rs | 5 ++++- crates/alien-manager/src/routes/stack.rs | 10 +++++++--- crates/alien-manager/src/routes/sync.rs | 11 +++++++++-- .../src/stores/sqlite/command_registry.rs | 6 +++++- .../alien-manager/src/stores/sqlite/deployment.rs | 6 +++--- .../alien-manager/src/traits/deployment_store.rs | 5 ++++- 8 files changed, 46 insertions(+), 14 deletions(-) diff --git a/crates/alien-manager/src/loops/deployment.rs b/crates/alien-manager/src/loops/deployment.rs index a0437b2b0..de8d9f730 100644 --- a/crates/alien-manager/src/loops/deployment.rs +++ b/crates/alien-manager/src/loops/deployment.rs @@ -245,7 +245,13 @@ impl DeploymentLoop { // Pull-mode deployments are entirely driven by the alien-agent running in the // target environment. The manager must not attempt to provision or deploy them. - if deployment.stack_settings.deployment_model == alien_core::DeploymentModel::Pull { + if deployment + .stack_settings + .as_ref() + .expect("stored deployment carries stack_settings") + .deployment_model + == alien_core::DeploymentModel::Pull + { debug!( deployment_id = %deployment_id, "Skipping pull-mode deployment — handled by alien-agent" @@ -407,13 +413,18 @@ impl DeploymentLoop { } else { DeploymentConfig { deployment_name: Some(deployment.name.clone()), - stack_settings: deployment.stack_settings.clone(), + stack_settings: deployment + .stack_settings + .clone() + .expect("stored deployment carries stack_settings"), management_config, environment_variables, allow_frozen_changes: false, compute_backend: None, external_bindings: deployment .stack_settings + .as_ref() + .expect("stored deployment carries stack_settings") .external_bindings .clone() .unwrap_or_default(), diff --git a/crates/alien-manager/src/providers/oss_authz.rs b/crates/alien-manager/src/providers/oss_authz.rs index 801ed7cf8..77db23e4c 100644 --- a/crates/alien-manager/src/providers/oss_authz.rs +++ b/crates/alien-manager/src/providers/oss_authz.rs @@ -255,7 +255,7 @@ mod tests { platform: alien_core::Platform::Local, base_platform: None, status: "pending".to_string(), - stack_settings: alien_core::StackSettings::default(), + stack_settings: Some(alien_core::StackSettings::default()), stack_state: None, environment_info: None, runtime_metadata: None, diff --git a/crates/alien-manager/src/routes/deployments.rs b/crates/alien-manager/src/routes/deployments.rs index ef2af4760..5178a43cb 100644 --- a/crates/alien-manager/src/routes/deployments.rs +++ b/crates/alien-manager/src/routes/deployments.rs @@ -197,7 +197,10 @@ fn record_to_response( platform: r.platform.clone(), status: r.status.clone(), deployment_group_id: r.deployment_group_id.clone(), - stack_settings: Some(serde_json::to_value(&r.stack_settings).unwrap_or_default()), + stack_settings: r + .stack_settings + .as_ref() + .map(|s| serde_json::to_value(s).unwrap_or_default()), stack_state: r .stack_state .as_ref() diff --git a/crates/alien-manager/src/routes/stack.rs b/crates/alien-manager/src/routes/stack.rs index baa9c60cc..00199d31a 100644 --- a/crates/alien-manager/src/routes/stack.rs +++ b/crates/alien-manager/src/routes/stack.rs @@ -273,7 +273,9 @@ pub async fn stack_import( Json(StackImportResponse { deployment_id: updated.id, deployment_token: updated.deployment_token, - stack_settings: updated.stack_settings, + stack_settings: updated + .stack_settings + .expect("imported deployment carries stack_settings"), stack_state, }), ) @@ -351,7 +353,9 @@ pub async fn stack_import( Json(StackImportResponse { deployment_id: created.id, deployment_token: Some(raw_token), - stack_settings: created.stack_settings, + stack_settings: created + .stack_settings + .expect("created deployment carries stack_settings"), stack_state, }), ) @@ -510,7 +514,7 @@ fn import_changes_deployment( || existing.setup_target.as_deref() != Some(req.setup_target.as_str()) || existing.setup_fingerprint.as_deref() != Some(req.setup_fingerprint.as_str()) || existing.setup_fingerprint_version != Some(req.setup_fingerprint_version) - || existing.stack_settings != req.stack_settings + || existing.stack_settings.as_ref() != Some(&req.stack_settings) || existing.environment_info.as_ref() != environment_info.as_ref() || existing.runtime_metadata.as_ref() != Some(runtime_metadata) || !imported_resources_are_unchanged(existing, imported_stack_state) diff --git a/crates/alien-manager/src/routes/sync.rs b/crates/alien-manager/src/routes/sync.rs index 00832fdf4..0b45f718f 100644 --- a/crates/alien-manager/src/routes/sync.rs +++ b/crates/alien-manager/src/routes/sync.rs @@ -607,7 +607,7 @@ mod tests { deployment_protocol_version: CURRENT_DEPLOYMENT_PROTOCOL_VERSION, base_platform: Some(Platform::Aws), status: status.to_string(), - stack_settings: StackSettings::default(), + stack_settings: Some(StackSettings::default()), stack_state, environment_info: None, runtime_metadata: None, @@ -829,7 +829,12 @@ async fn agent_sync( } else { DeploymentConfig::builder() .deployment_name(deployment.name.clone()) - .stack_settings(deployment.stack_settings.clone()) + .stack_settings( + deployment + .stack_settings + .clone() + .expect("synced deployment carries stack_settings"), + ) .maybe_management_config(management_config) .environment_variables(EnvironmentVariablesSnapshot { variables: env_vars, @@ -840,6 +845,8 @@ async fn agent_sync( .external_bindings( deployment .stack_settings + .as_ref() + .expect("synced deployment carries stack_settings") .external_bindings .clone() .unwrap_or_default(), diff --git a/crates/alien-manager/src/stores/sqlite/command_registry.rs b/crates/alien-manager/src/stores/sqlite/command_registry.rs index 4d9b4fdc6..c3cf43666 100644 --- a/crates/alien-manager/src/stores/sqlite/command_registry.rs +++ b/crates/alien-manager/src/stores/sqlite/command_registry.rs @@ -148,7 +148,11 @@ impl CommandRegistry for SqliteCommandRegistry { let deployment_model = match deployment.platform { alien_core::Platform::Kubernetes | alien_core::Platform::Local => DeploymentModel::Pull, - _ => deployment.stack_settings.deployment_model, + _ => deployment + .stack_settings + .as_ref() + .expect("stored deployment carries stack_settings") + .deployment_model, }; let deployment_model_str = serialize_enum(&deployment_model); diff --git a/crates/alien-manager/src/stores/sqlite/deployment.rs b/crates/alien-manager/src/stores/sqlite/deployment.rs index 66df1d908..8dedf128c 100644 --- a/crates/alien-manager/src/stores/sqlite/deployment.rs +++ b/crates/alien-manager/src/stores/sqlite/deployment.rs @@ -108,7 +108,7 @@ impl SqliteDeploymentStore { deployment_protocol_version, base_platform, status: p.string(6, "status")?, - stack_settings: p.json(7, "stack_settings")?, + stack_settings: Some(p.json(7, "stack_settings")?), stack_state: p.optional_json(8, "stack_state")?, environment_info: p.optional_json(9, "environment_info")?, runtime_metadata: p.optional_json(10, "runtime_metadata")?, @@ -278,7 +278,7 @@ impl DeploymentStore for SqliteDeploymentStore { deployment_protocol_version: params.deployment_protocol_version, base_platform: None, status: "pending".to_string(), - stack_settings: params.stack_settings, + stack_settings: Some(params.stack_settings), stack_state: params.stack_state, environment_info: None, runtime_metadata: None, @@ -435,7 +435,7 @@ impl DeploymentStore for SqliteDeploymentStore { deployment_protocol_version: params.deployment_protocol_version, base_platform: params.base_platform, status: params.status, - stack_settings: params.stack_settings, + stack_settings: Some(params.stack_settings), stack_state: Some(params.stack_state), environment_info: params.environment_info, runtime_metadata: Some(params.runtime_metadata), diff --git a/crates/alien-manager/src/traits/deployment_store.rs b/crates/alien-manager/src/traits/deployment_store.rs index 396fb1a3b..858951ee4 100644 --- a/crates/alien-manager/src/traits/deployment_store.rs +++ b/crates/alien-manager/src/traits/deployment_store.rs @@ -27,7 +27,10 @@ pub struct DeploymentRecord { #[serde(default, skip_serializing_if = "Option::is_none")] pub base_platform: Option, pub status: String, - pub stack_settings: StackSettings, + /// `None` when a record is built from a source that doesn't carry stack + /// settings; records produced by this crate's stores always set it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stack_settings: Option, pub stack_state: Option, pub environment_info: Option, pub runtime_metadata: Option, From 741623ceb68407ca6a46bdeb3ce509a8cb78677e Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Sun, 31 May 2026 20:06:34 +0300 Subject: [PATCH 2/8] fix(cli): route commands invoke through the resolved manager commands invoke used server_sdk_client(), which errors in platform mode, and a token-only commands client that drops the workspace header. It now resolves the manager like deployments and reuses that workspace-aware client. --- crates/alien-cli/src/commands/commands.rs | 21 ++++++++++++--------- crates/alien-commands-client/src/client.rs | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/crates/alien-cli/src/commands/commands.rs b/crates/alien-cli/src/commands/commands.rs index 6752812bc..7d1ac9371 100644 --- a/crates/alien-cli/src/commands/commands.rs +++ b/crates/alien-cli/src/commands/commands.rs @@ -64,9 +64,6 @@ pub enum CommandsAction { /// Execute commands task — works in all execution modes. pub async fn commands_task(args: CommandsArgs, ctx: ExecutionMode) -> Result<()> { - let manager = ctx.server_sdk_client()?; - let manager_url = ctx.manager_url(); - match args.action { CommandsAction::Invoke { deployment, @@ -75,11 +72,17 @@ pub async fn commands_task(args: CommandsArgs, ctx: ExecutionMode) -> Result<()> timeout, } => { let is_dev = ctx.is_dev(); - let auth_token = ctx.auth_token().unwrap_or_default().to_string(); + + // Resolve the manager the same way `deployments` does. In platform + // mode this discovers the manager URL and builds a workspace-aware + // client; server_sdk_client() isn't available there. + let (_, project_link) = ctx.resolve_project(None, true).await?; + let manager = ctx.resolve_manager(&project_link.project_id, "aws").await?; + invoke_command( - &manager, - &manager_url, - &auth_token, + &manager.client, + &manager.manager_url, + manager.http_client.clone(), &deployment, &command, ¶ms, @@ -100,7 +103,7 @@ pub async fn commands_task_dev(args: CommandsArgs, port: u16) -> Result<()> { async fn invoke_command( manager: &alien_manager_api::Client, manager_url: &str, - auth_token: &str, + http_client: reqwest::Client, deployment_name: &str, command: &str, params_json: &str, @@ -125,7 +128,7 @@ async fn invoke_command( ..Default::default() }; let commands_url = format!("{}/v1", manager_url.trim_end_matches('/')); - let client = CommandsClient::with_config(&commands_url, &deployment_id, auth_token, config); + let client = CommandsClient::with_http_client(&commands_url, &deployment_id, http_client, config); let result: serde_json::Value = client .invoke(command, params) diff --git a/crates/alien-commands-client/src/client.rs b/crates/alien-commands-client/src/client.rs index 4d1c8c5be..303c2875a 100644 --- a/crates/alien-commands-client/src/client.rs +++ b/crates/alien-commands-client/src/client.rs @@ -147,6 +147,24 @@ impl CommandsClient { } } + /// Build a client over a caller-supplied HTTP client, reusing the headers + /// it already carries (the auth header, and the workspace header used in + /// platform mode). `with_config` builds a token-only client and can't add + /// those. + pub fn with_http_client( + manager_url: &str, + deployment_id: &str, + http_client: reqwest::Client, + config: CommandsClientConfig, + ) -> Self { + Self { + manager_url: manager_url.trim_end_matches('/').to_string(), + deployment_id: deployment_id.to_string(), + http_client, + config, + } + } + /// Invoke a command and wait for the result. /// /// Sends params inline, polls for completion, and decodes the response. From 5651da783c3309dd5d13b9178c02c28477f8d2c5 Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Sun, 31 May 2026 20:24:56 +0300 Subject: [PATCH 3/8] feat(manager): add GET /v1/releases list endpoint Adds list_releases to the ReleaseStore trait, its sqlite implementation (newest first), the GET handler, and the OpenAPI registration. Returns only releases the caller may read. The CLI command and SDK regeneration land separately. --- crates/alien-manager/src/api.rs | 2 + crates/alien-manager/src/routes/releases.rs | 47 ++++++++++++++++++- .../src/stores/sqlite/release.rs | 30 ++++++++++++ .../alien-manager/src/traits/release_store.rs | 5 ++ 4 files changed, 83 insertions(+), 1 deletion(-) diff --git a/crates/alien-manager/src/api.rs b/crates/alien-manager/src/api.rs index 90d09e185..5c3d966c9 100644 --- a/crates/alien-manager/src/api.rs +++ b/crates/alien-manager/src/api.rs @@ -27,6 +27,7 @@ use utoipa::OpenApi; crate::routes::deployments::redeploy, // Releases crate::routes::releases::create_release, + crate::routes::releases::list_releases, crate::routes::releases::get_release, crate::routes::releases::get_latest_release, // Stack import @@ -58,6 +59,7 @@ use utoipa::OpenApi; crate::routes::releases::CreateReleaseRequest, crate::routes::releases::GitMetadata, crate::routes::releases::ReleaseResponse, + crate::routes::releases::ListReleasesResponse, // Stack import types (mirror the alien-core request schemas). alien_core::import::request::StackImportRequest, alien_core::import::request::StackImportResponse, diff --git a/crates/alien-manager/src/routes/releases.rs b/crates/alien-manager/src/routes/releases.rs index 6028666d8..b1c545287 100644 --- a/crates/alien-manager/src/routes/releases.rs +++ b/crates/alien-manager/src/routes/releases.rs @@ -74,6 +74,13 @@ pub struct ReleaseResponse { pub created_at: String, } +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ListReleasesResponse { + pub items: Vec, +} + #[derive(Debug, Serialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] @@ -90,7 +97,7 @@ pub struct GitMetadataResponse { pub fn router() -> Router { Router::new() - .route("/v1/releases", post(create_release)) + .route("/v1/releases", post(create_release).get(list_releases)) .route("/v1/releases/latest", get(get_latest_release)) .route("/v1/releases/{id}", get(get_release)) } @@ -306,6 +313,44 @@ async fn get_release( } } +#[cfg_attr(feature = "openapi", utoipa::path( + get, + path = "/v1/releases", + tag = "releases", + responses( + (status = 200, description = "Releases listed", body = ListReleasesResponse), + (status = 401, description = "Unauthorized") + ), + security( + ("bearer" = []) + ) +))] +async fn list_releases(State(state): State, headers: HeaderMap) -> Response { + let subject = match auth::require_auth(&state, &headers).await { + Ok(s) => s, + Err(e) => return e.into_response(), + }; + + let releases = match state.release_store.list_releases(&subject).await { + Ok(r) => r, + Err(e) => return e.into_response(), + }; + + // Like get_release, return only the releases the caller may read. + let mut items = Vec::with_capacity(releases.len()); + for release in &releases { + if !state.authz.can_read_release(&subject, release) { + continue; + } + match record_to_response(release) { + Ok(resp) => items.push(resp), + Err(e) => return e.into_response(), + } + } + + Json(ListReleasesResponse { items }).into_response() +} + #[cfg_attr(feature = "openapi", utoipa::path( get, path = "/v1/releases/latest", diff --git a/crates/alien-manager/src/stores/sqlite/release.rs b/crates/alien-manager/src/stores/sqlite/release.rs index 934a947a6..03afbda8d 100644 --- a/crates/alien-manager/src/stores/sqlite/release.rs +++ b/crates/alien-manager/src/stores/sqlite/release.rs @@ -166,4 +166,34 @@ impl ReleaseStore for SqliteReleaseStore { None => Ok(None), } } + + async fn list_releases( + &self, + _caller: &crate::auth::Subject, + ) -> Result, AlienError> { + // Single-tenant store: no workspace filter (every row is the one + // workspace). Multi-tenant scoping is the embedder's job. + let sql = Query::select() + .columns(Self::RELEASE_COLUMNS) + .from(Releases::Table) + .order_by(Releases::CreatedAt, Order::Desc) + .to_string(SqliteQueryBuilder); + + let conn = self.db.conn().lock().await; + let mut rows = conn + .query(&sql, ()) + .await + .into_alien_error() + .context(GenericError { + message: "Failed to query releases".to_string(), + })?; + + let mut releases = Vec::new(); + while let Some(row) = rows.next().await.into_alien_error().context(GenericError { + message: "Failed to fetch release row".to_string(), + })? { + releases.push(Self::parse_release(&row)?); + } + Ok(releases) + } } diff --git a/crates/alien-manager/src/traits/release_store.rs b/crates/alien-manager/src/traits/release_store.rs index 272587ed2..2eaa35801 100644 --- a/crates/alien-manager/src/traits/release_store.rs +++ b/crates/alien-manager/src/traits/release_store.rs @@ -61,4 +61,9 @@ pub trait ReleaseStore: Send + Sync { &self, caller: &crate::auth::Subject, ) -> Result, AlienError>; + + async fn list_releases( + &self, + caller: &crate::auth::Subject, + ) -> Result, AlienError>; } From 6cfcfd5585057bf72a337b9f6b50da94f6be922e Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Sun, 31 May 2026 20:29:33 +0300 Subject: [PATCH 4/8] chore(sdk): regenerate manager OpenAPI spec for GET /v1/releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the list_releases path and ListReleasesResponse schema via the schema exporter + openapi-down-convert. The progenitor build.rs picks these up to generate the typed list_releases() client method. No other spec changes — it was otherwise in sync. --- client-sdks/manager/openapi.json | 40 +++++++++++++++++++++++ client-sdks/manager/rust/openapi-3.0.json | 40 +++++++++++++++++++++++ crates/alien-manager/openapi.json | 40 +++++++++++++++++++++++ 3 files changed, 120 insertions(+) diff --git a/client-sdks/manager/openapi.json b/client-sdks/manager/openapi.json index 14402c3a9..b44264a57 100644 --- a/client-sdks/manager/openapi.json +++ b/client-sdks/manager/openapi.json @@ -930,6 +930,32 @@ } }, "/v1/releases": { + "get": { + "tags": [ + "releases" + ], + "operationId": "list_releases", + "responses": { + "200": { + "description": "Releases listed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListReleasesResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer": [] + } + ] + }, "post": { "tags": [ "releases" @@ -9173,6 +9199,20 @@ } } }, + "ListReleasesResponse": { + "type": "object", + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReleaseResponse" + } + } + } + }, "LocalArtifactRegistryHeartbeatData": { "type": "object", "required": [ diff --git a/client-sdks/manager/rust/openapi-3.0.json b/client-sdks/manager/rust/openapi-3.0.json index 62063bb3c..7b349d79f 100644 --- a/client-sdks/manager/rust/openapi-3.0.json +++ b/client-sdks/manager/rust/openapi-3.0.json @@ -930,6 +930,32 @@ } }, "/v1/releases": { + "get": { + "tags": [ + "releases" + ], + "operationId": "list_releases", + "responses": { + "200": { + "description": "Releases listed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListReleasesResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer": [] + } + ] + }, "post": { "tags": [ "releases" @@ -7811,6 +7837,20 @@ } } }, + "ListReleasesResponse": { + "type": "object", + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReleaseResponse" + } + } + } + }, "LocalArtifactRegistryHeartbeatData": { "type": "object", "required": [ diff --git a/crates/alien-manager/openapi.json b/crates/alien-manager/openapi.json index 14402c3a9..b44264a57 100644 --- a/crates/alien-manager/openapi.json +++ b/crates/alien-manager/openapi.json @@ -930,6 +930,32 @@ } }, "/v1/releases": { + "get": { + "tags": [ + "releases" + ], + "operationId": "list_releases", + "responses": { + "200": { + "description": "Releases listed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListReleasesResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer": [] + } + ] + }, "post": { "tags": [ "releases" @@ -9173,6 +9199,20 @@ } } }, + "ListReleasesResponse": { + "type": "object", + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReleaseResponse" + } + } + } + }, "LocalArtifactRegistryHeartbeatData": { "type": "object", "required": [ From 4b83d9ef308f52dbf95c40c5e26e82064b467d8a Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Sun, 31 May 2026 20:38:04 +0300 Subject: [PATCH 5/8] feat(cli): add releases ls Lists releases newest-first through the resolved manager, mirroring deployments ls (ID, created, commit, platforms). Available in normal and dev modes. --- crates/alien-cli/src/commands/deployments.rs | 2 +- crates/alien-cli/src/commands/mod.rs | 2 + crates/alien-cli/src/commands/releases.rs | 102 +++++++++++++++++++ crates/alien-cli/src/lib.rs | 12 ++- 4 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 crates/alien-cli/src/commands/releases.rs diff --git a/crates/alien-cli/src/commands/deployments.rs b/crates/alien-cli/src/commands/deployments.rs index 43c9f2407..4a3fbacf4 100644 --- a/crates/alien-cli/src/commands/deployments.rs +++ b/crates/alien-cli/src/commands/deployments.rs @@ -270,7 +270,7 @@ pub async fn deployments_task(args: DeploymentsArgs, ctx: ExecutionMode) -> Resu /// /// Uses the linked project (or `--project` override) to discover the manager URL /// in platform mode. In dev/standalone modes, the manager URL is known directly. -async fn resolve_manager_client( +pub(crate) async fn resolve_manager_client( ctx: &ExecutionMode, project_override: Option<&str>, ) -> Result { diff --git a/crates/alien-cli/src/commands/mod.rs b/crates/alien-cli/src/commands/mod.rs index b2d4d65f2..dd7569dc8 100644 --- a/crates/alien-cli/src/commands/mod.rs +++ b/crates/alien-cli/src/commands/mod.rs @@ -7,6 +7,7 @@ pub mod dev_helpers; pub mod init; pub mod onboard; pub mod release; +pub mod releases; pub mod render; pub mod vault; pub mod whoami; @@ -37,6 +38,7 @@ pub use dev_helpers::{ pub use init::{init_task, InitArgs}; pub use onboard::{onboard_task, OnboardArgs}; pub use release::{release_command, ReleaseArgs}; +pub use releases::{releases_task, ReleasesArgs}; pub use render::{render_task, RenderArgs}; pub use vault::{vault_remote_task, vault_task, VaultArgs, VaultRemoteArgs}; pub use whoami::{whoami_task, WhoamiArgs}; diff --git a/crates/alien-cli/src/commands/releases.rs b/crates/alien-cli/src/commands/releases.rs new file mode 100644 index 000000000..fcf480b7d --- /dev/null +++ b/crates/alien-cli/src/commands/releases.rs @@ -0,0 +1,102 @@ +//! CLI command for listing releases. + +use crate::error::{ErrorData, Result}; +use crate::execution_context::ExecutionMode; +use crate::ui::{make_table, print_table}; +use alien_error::Context; +use alien_manager_api::types::{ReleaseResponse, StackByPlatform}; +use alien_manager_api::SdkResultExt as _; +use clap::{Parser, Subcommand}; + +#[derive(Parser, Debug, Clone)] +#[command(about = "List releases")] +pub struct ReleasesArgs { + #[command(subcommand)] + pub cmd: ReleasesCmd, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum ReleasesCmd { + /// List releases, newest first + Ls { + /// Project to list releases for (optional, uses linked project by default) + #[arg(long)] + project: Option, + }, +} + +pub async fn releases_task(args: ReleasesArgs, ctx: ExecutionMode) -> Result<()> { + ctx.ensure_ready().await?; + + match args.cmd { + ReleasesCmd::Ls { project } => { + // Releases are a core feature, so they go through the manager, not + // the platform API directly. + let manager = + crate::commands::deployments::resolve_manager_client(&ctx, project.as_deref()) + .await?; + list_releases_task(&manager).await + } + } +} + +async fn list_releases_task(client: &alien_manager_api::Client) -> Result<()> { + let response = client + .list_releases() + .send() + .await + .into_sdk_error() + .context(ErrorData::ApiRequestFailed { + message: "listing releases".to_string(), + url: None, + })? + .into_inner(); + + if response.items.is_empty() { + println!("(no releases)"); + return Ok(()); + } + + let mut table = make_table(&["ID", "Created", "Commit", "Platforms"]); + for release in &response.items { + let row: Vec = vec![ + release.id.clone().into(), + release.created_at.clone().into(), + commit_cell(release).into(), + platforms_cell(&release.stack).into(), + ]; + table.add_row(row); + } + print_table(table); + + Ok(()) +} + +/// A branch/tag ref reads better than a bare SHA, so prefer it. +fn commit_cell(release: &ReleaseResponse) -> String { + release + .git_metadata + .as_ref() + .and_then(|g| g.commit_ref.clone().or_else(|| g.commit_sha.clone())) + .unwrap_or_else(|| "—".to_string()) +} + +fn platforms_cell(stack: &StackByPlatform) -> String { + let names: Vec<&str> = [ + ("aws", stack.aws.is_some()), + ("gcp", stack.gcp.is_some()), + ("azure", stack.azure.is_some()), + ("kubernetes", stack.kubernetes.is_some()), + ("local", stack.local.is_some()), + ("test", stack.test.is_some()), + ] + .into_iter() + .filter_map(|(name, present)| present.then_some(name)) + .collect(); + + if names.is_empty() { + "—".to_string() + } else { + names.join(", ") + } +} diff --git a/crates/alien-cli/src/lib.rs b/crates/alien-cli/src/lib.rs index 710515f87..63e2525db 100644 --- a/crates/alien-cli/src/lib.rs +++ b/crates/alien-cli/src/lib.rs @@ -26,9 +26,9 @@ use crate::commands::{ commands_task_dev, deploy_task, deployments_task, destroy_task, ensure_server_running_for_dev_session, ensure_server_running_with_env, fetch_all_dev_deployment_live_states, init_task, onboard_task, prepare_dev_session_deployment, - release_command, render_task, vault_remote_task, vault_task, whoami_task, write_dev_status, - BuildArgs, CliEnvVar, CommandsArgs, DeployArgs, DeploymentsArgs, DestroyArgs, InitArgs, - OnboardArgs, ReleaseArgs, RenderArgs, WhoamiArgs, + release_command, releases_task, render_task, vault_remote_task, vault_task, whoami_task, + write_dev_status, BuildArgs, CliEnvVar, CommandsArgs, DeployArgs, DeploymentsArgs, DestroyArgs, + InitArgs, OnboardArgs, ReleaseArgs, ReleasesArgs, RenderArgs, WhoamiArgs, }; use crate::error::{ErrorData, Result}; use crate::execution_context::ExecutionMode; @@ -121,6 +121,8 @@ pub enum Commands { /// Deployment commands #[command(alias = "deployment")] Deployments(DeploymentsArgs), + /// Release commands + Releases(ReleasesArgs), /// Deploy to a cloud platform Deploy(DeployArgs), /// Destroy resources from a deployment @@ -207,6 +209,8 @@ pub enum DevSubcommand { /// Deployment commands against the local manager #[command(alias = "deployment")] Deployments(DeploymentsArgs), + /// Release commands against the local manager + Releases(ReleasesArgs), /// Show local manager identity information Whoami(WhoamiArgs), /// Deploy to the local manager @@ -857,6 +861,7 @@ async fn handle_dev_command(dev_cmd: DevCommand) -> Result<()> { run_dev_server_only(port, dev_cmd.status_file, parsed_env_vars).await?; } Some(DevSubcommand::Deployments(args)) => deployments_task(args, ctx).await?, + Some(DevSubcommand::Releases(args)) => releases_task(args, ctx).await?, Some(DevSubcommand::Whoami(args)) => whoami_task(args, ctx).await?, Some(DevSubcommand::Deploy(args)) => deploy_task(args, ctx).await?, Some(DevSubcommand::Destroy(args)) => destroy_task(args, ctx).await?, @@ -1438,6 +1443,7 @@ pub async fn run_cli(cli: Cli) -> Result<()> { Some(Commands::Release(args)) => release_command(args, ctx).await?, Some(Commands::Onboard(args)) => onboard_task(args, ctx).await?, Some(Commands::Deployments(args)) => deployments_task(args, ctx).await?, + Some(Commands::Releases(args)) => releases_task(args, ctx).await?, Some(Commands::Deploy(args)) => deploy_task(args, ctx).await?, Some(Commands::Destroy(args)) => destroy_task(args, ctx).await?, Some(Commands::Vault(args)) => vault_remote_task(args, ctx).await?, From 0b5629b8d0a48f1084e3b04f2101c4556d0eb1ea Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Mon, 1 Jun 2026 13:46:29 +0300 Subject: [PATCH 6/8] fix(cli): use the workspace name and deployment token when provisioning a deploy Provisioning passed the workspace id where the deploy APIs select a workspace by name, and used the install token for the manager's sync step, which needs the deployment's own token. Both blocked onboarding. - create_deployment / get_deployment_group now receive the workspace name from whoami; fail fast if it is missing. - the provisioning client re-authenticates as the deployment (its own token holds managers.sync for itself) in platform mode; standalone is unchanged. --- crates/alien-cli/src/commands/deploy.rs | 18 +++++++++++++++--- crates/alien-cli/src/deployment_tracking.rs | 18 ++++++++++++------ 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/crates/alien-cli/src/commands/deploy.rs b/crates/alien-cli/src/commands/deploy.rs index 34feb54b0..b846f6d5a 100644 --- a/crates/alien-cli/src/commands/deploy.rs +++ b/crates/alien-cli/src/commands/deploy.rs @@ -203,7 +203,7 @@ pub async fn deploy_task(args: DeployArgs, ctx: ExecutionMode) -> Result<()> { } DeploymentToken::DeploymentGroup { deployment_group_name, - workspace_id, + workspace_name, project_id, .. } => { @@ -261,7 +261,7 @@ pub async fn deploy_task(args: DeployArgs, ctx: ExecutionMode) -> Result<()> { let create_response = sdk_client .create_deployment() - .workspace(&workspace_id) + .workspace(&workspace_name) .body(alien_platform_api::types::NewDeploymentRequest { name: args.name.clone().try_into().into_alien_error().context( ErrorData::ValidationError { @@ -366,7 +366,19 @@ pub async fn deploy_task(args: DeployArgs, ctx: ExecutionMode) -> Result<()> { let manager_ctx = ctx .resolve_manager(&tracked_deployment.project_id, &args.platform) .await?; - let manager_client = manager_ctx.client; + // Provisioning calls the manager's sync endpoints, which require + // `managers.sync` — held by the deployment's own token, not the install + // token that resolved the manager. In platform mode (workspace is set), + // re-authenticate as the deployment for these calls. + let manager_client = if let Some(workspace) = manager_ctx.workspace.clone() { + let http_client = crate::auth::client_with_auth_and_workspace( + &format!("Bearer {}", tracked_deployment.api_key), + &workspace, + )?; + alien_manager_api::Client::new_with_client(&manager_ctx.manager_url, http_client) + } else { + manager_ctx.client + }; steps.complete(1, Some(format!("Manager: {}", manager_ctx.manager_url))); diff --git a/crates/alien-cli/src/deployment_tracking.rs b/crates/alien-cli/src/deployment_tracking.rs index 07e9040ae..92c628ed2 100644 --- a/crates/alien-cli/src/deployment_tracking.rs +++ b/crates/alien-cli/src/deployment_tracking.rs @@ -115,7 +115,7 @@ pub enum DeploymentToken { deployment_group_id: String, deployment_group_name: String, project_id: String, - workspace_id: String, + workspace_name: String, max_deployments: u32, }, } @@ -187,10 +187,16 @@ pub async fn validate_token(api_key: &str, base_url: &str) -> Result { - // Fetch deployment group details + // The group lookup keys on workspace name, not id (whoami provides the name). + let workspace_name = sa.workspace_name.clone().ok_or_else(|| { + AlienError::new(ErrorData::ValidationError { + field: "workspace".to_string(), + message: "token response is missing the workspace name".to_string(), + }) + })?; let deployment_group = fetch_deployment_group( &deployment_group_id, - &sa.workspace_id, + &workspace_name, api_key, base_url, ) @@ -200,7 +206,7 @@ pub async fn validate_token(api_key: &str, base_url: &str) -> Result Result Result { @@ -254,7 +260,7 @@ async fn fetch_deployment_group( let response = sdk_client .get_deployment_group() .id(deployment_group_id) - .workspace(workspace_id) + .workspace(workspace_name) .send() .await .into_alien_error() From d3085e731a0e6f214ec95ac9fc996fe11a30b9c2 Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Mon, 1 Jun 2026 13:46:30 +0300 Subject: [PATCH 7/8] refactor: simplify releases ls table cells and document the list endpoint --- crates/alien-cli/src/commands/releases.rs | 23 +++++++++++---------- crates/alien-manager/src/routes/releases.rs | 4 ++++ 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/crates/alien-cli/src/commands/releases.rs b/crates/alien-cli/src/commands/releases.rs index fcf480b7d..a1352688e 100644 --- a/crates/alien-cli/src/commands/releases.rs +++ b/crates/alien-cli/src/commands/releases.rs @@ -59,13 +59,12 @@ async fn list_releases_task(client: &alien_manager_api::Client) -> Result<()> { let mut table = make_table(&["ID", "Created", "Commit", "Platforms"]); for release in &response.items { - let row: Vec = vec![ + table.add_row(vec![ release.id.clone().into(), release.created_at.clone().into(), - commit_cell(release).into(), - platforms_cell(&release.stack).into(), - ]; - table.add_row(row); + commit_cell(release), + platforms_cell(&release.stack), + ]); } print_table(table); @@ -73,15 +72,16 @@ async fn list_releases_task(client: &alien_manager_api::Client) -> Result<()> { } /// A branch/tag ref reads better than a bare SHA, so prefer it. -fn commit_cell(release: &ReleaseResponse) -> String { - release +fn commit_cell(release: &ReleaseResponse) -> comfy_table::Cell { + let label = release .git_metadata .as_ref() .and_then(|g| g.commit_ref.clone().or_else(|| g.commit_sha.clone())) - .unwrap_or_else(|| "—".to_string()) + .unwrap_or_else(|| "—".to_string()); + comfy_table::Cell::new(label) } -fn platforms_cell(stack: &StackByPlatform) -> String { +fn platforms_cell(stack: &StackByPlatform) -> comfy_table::Cell { let names: Vec<&str> = [ ("aws", stack.aws.is_some()), ("gcp", stack.gcp.is_some()), @@ -94,9 +94,10 @@ fn platforms_cell(stack: &StackByPlatform) -> String { .filter_map(|(name, present)| present.then_some(name)) .collect(); - if names.is_empty() { + let label = if names.is_empty() { "—".to_string() } else { names.join(", ") - } + }; + comfy_table::Cell::new(label) } diff --git a/crates/alien-manager/src/routes/releases.rs b/crates/alien-manager/src/routes/releases.rs index b1c545287..a06c34f0b 100644 --- a/crates/alien-manager/src/routes/releases.rs +++ b/crates/alien-manager/src/routes/releases.rs @@ -78,6 +78,7 @@ pub struct ReleaseResponse { #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] pub struct ListReleasesResponse { + /// Releases the caller may read, newest first. pub items: Vec, } @@ -313,6 +314,9 @@ async fn get_release( } } +/// `GET /v1/releases` — Inbound: workspace / project bearer (or authenticated +/// user). Outbound: caller bearer (passthrough). Returns only releases the +/// caller may read. #[cfg_attr(feature = "openapi", utoipa::path( get, path = "/v1/releases", From 759a800cada3045b11af520bda49ba99914ce6f9 Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Mon, 1 Jun 2026 15:54:26 +0300 Subject: [PATCH 8/8] fix(manager): return 500 from sync/stack handlers on missing stack_settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sync and stack-import HTTP handlers asserted the stack_settings invariant with .expect(). Axum doesn't catch handler panics, so a broken invariant would drop the connection instead of answering. Return a structured 500 instead. The internal deployment loop and stores keep .expect() — the loop already isolates a panicking tick. --- crates/alien-manager/src/routes/stack.rs | 24 +++++++++++++++------ crates/alien-manager/src/routes/sync.rs | 27 ++++++++++++------------ 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/crates/alien-manager/src/routes/stack.rs b/crates/alien-manager/src/routes/stack.rs index 00199d31a..a7285a023 100644 --- a/crates/alien-manager/src/routes/stack.rs +++ b/crates/alien-manager/src/routes/stack.rs @@ -268,14 +268,21 @@ pub async fn stack_import( updated }; + let stack_settings = match updated.stack_settings { + Some(settings) => settings, + None => { + return ErrorData::internal( + "imported deployment is missing stack_settings", + ) + .into_response(); + } + }; return ( StatusCode::OK, Json(StackImportResponse { deployment_id: updated.id, deployment_token: updated.deployment_token, - stack_settings: updated - .stack_settings - .expect("imported deployment carries stack_settings"), + stack_settings, stack_state, }), ) @@ -348,14 +355,19 @@ pub async fn stack_import( return e.into_response(); } + let stack_settings = match created.stack_settings { + Some(settings) => settings, + None => { + return ErrorData::internal("created deployment is missing stack_settings") + .into_response(); + } + }; ( StatusCode::CREATED, Json(StackImportResponse { deployment_id: created.id, deployment_token: Some(raw_token), - stack_settings: created - .stack_settings - .expect("created deployment carries stack_settings"), + stack_settings, stack_state, }), ) diff --git a/crates/alien-manager/src/routes/sync.rs b/crates/alien-manager/src/routes/sync.rs index 0b45f718f..c295575db 100644 --- a/crates/alien-manager/src/routes/sync.rs +++ b/crates/alien-manager/src/routes/sync.rs @@ -827,14 +827,21 @@ async fn agent_sync( config.native_image_host = native_image_host; config } else { + // Records loaded for sync always carry stack settings; in a + // handler, answer with a 500 rather than panic-dropping the + // connection if that invariant is ever broken. + let stack_settings = match deployment.stack_settings.clone() { + Some(settings) => settings, + None => { + return ErrorData::internal( + "synced deployment is missing stack_settings", + ) + .into_response(); + } + }; DeploymentConfig::builder() .deployment_name(deployment.name.clone()) - .stack_settings( - deployment - .stack_settings - .clone() - .expect("synced deployment carries stack_settings"), - ) + .stack_settings(stack_settings.clone()) .maybe_management_config(management_config) .environment_variables(EnvironmentVariablesSnapshot { variables: env_vars, @@ -843,13 +850,7 @@ async fn agent_sync( }) .allow_frozen_changes(false) .external_bindings( - deployment - .stack_settings - .as_ref() - .expect("synced deployment carries stack_settings") - .external_bindings - .clone() - .unwrap_or_default(), + stack_settings.external_bindings.clone().unwrap_or_default(), ) .maybe_base_platform(deployment.base_platform) .maybe_manager_url(Some(manager_url))