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-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-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/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..a1352688e --- /dev/null +++ b/crates/alien-cli/src/commands/releases.rs @@ -0,0 +1,103 @@ +//! 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 { + table.add_row(vec![ + release.id.clone().into(), + release.created_at.clone().into(), + commit_cell(release), + platforms_cell(&release.stack), + ]); + } + print_table(table); + + Ok(()) +} + +/// A branch/tag ref reads better than a bare SHA, so prefer it. +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()); + comfy_table::Cell::new(label) +} + +fn platforms_cell(stack: &StackByPlatform) -> comfy_table::Cell { + 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(); + + let label = if names.is_empty() { + "—".to_string() + } else { + names.join(", ") + }; + comfy_table::Cell::new(label) +} 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() 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?, 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. 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": [ 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/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/releases.rs b/crates/alien-manager/src/routes/releases.rs index 6028666d8..a06c34f0b 100644 --- a/crates/alien-manager/src/routes/releases.rs +++ b/crates/alien-manager/src/routes/releases.rs @@ -74,6 +74,14 @@ pub struct ReleaseResponse { pub created_at: String, } +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ListReleasesResponse { + /// Releases the caller may read, newest first. + pub items: Vec, +} + #[derive(Debug, Serialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] @@ -90,7 +98,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 +314,47 @@ 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", + 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/routes/stack.rs b/crates/alien-manager/src/routes/stack.rs index baa9c60cc..a7285a023 100644 --- a/crates/alien-manager/src/routes/stack.rs +++ b/crates/alien-manager/src/routes/stack.rs @@ -268,12 +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, + stack_settings, stack_state, }), ) @@ -346,12 +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, + stack_settings, stack_state, }), ) @@ -510,7 +526,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..c295575db 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, @@ -827,9 +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()) + .stack_settings(stack_settings.clone()) .maybe_management_config(management_config) .environment_variables(EnvironmentVariablesSnapshot { variables: env_vars, @@ -838,11 +850,7 @@ async fn agent_sync( }) .allow_frozen_changes(false) .external_bindings( - deployment - .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)) 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/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/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, 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>; }