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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions crates/alien-core/src/deployment/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,21 @@ pub struct SetupUpdateAuthorization {
pub setup_fingerprint_version: u32,
}

/// One-shot request for Alien to rerun setup-owned reconciliation.
///
/// The control plane may issue this only for an operation it explicitly
/// authorizes. The deployment engine additionally requires a direct-setup
/// deployment and an unchanged release before honoring it.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct DirectSetupUpdateAuthorization {
/// Platform operation that owns this reconciliation and fences stale writes.
pub operation_id: String,
/// Release whose setup-owned resources may be reconciled.
pub release_id: String,
}

/// Runtime metadata for deployment
///
/// Stores deployment state that needs to persist across step calls.
Expand Down Expand Up @@ -91,6 +106,12 @@ pub struct RuntimeMetadata {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub setup_update_authorization: Option<SetupUpdateAuthorization>,

/// One-shot authority for an Alien-owned setup update. Unlike an imported
/// setup authorization, the deployment engine prepares and applies the
/// setup-owned target itself under administrator credentials.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub direct_setup_update_authorization: Option<DirectSetupUpdateAuthorization>,

/// Whether cross-account registry access has been successfully granted.
/// Set to true after the manager successfully sets the ECR/GAR repo policy
/// for this deployment's target account. Prevents redundant API calls on
Expand Down
40 changes: 39 additions & 1 deletion crates/alien-deploy-cli/src/commands/up.rs
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,28 @@ mod tests {
));
}

#[test]
fn hosted_compute_updates_cover_active_and_retryable_lifecycle_states() {
for status in [
"running",
"update-pending",
"updating",
"update-failed",
"refresh-failed",
"initial-setup",
"initial-setup-failed",
"provisioning",
"waiting-for-machines",
"provisioning-failed",
] {
assert!(supports_hosted_compute_update(status), "{status}");
}

for status in ["pending", "delete-pending", "deleted"] {
assert!(!supports_hosted_compute_update(status), "{status}");
}
}

#[test]
fn stable_channel_accepts_exact_semver_tag() {
assert_eq!(
Expand Down Expand Up @@ -1341,7 +1363,7 @@ pub async fn up_command(args: UpArgs, embedded_config: Option<&DeployCliConfig>)

let hosted_platform =
manager_url.trim_end_matches('/') != resolved.base_url.trim_end_matches('/');
if current_deployment.status == "running"
if supports_hosted_compute_update(&current_deployment.status)
&& init.deployment_model == DeploymentModel::Push
&& hosted_platform
&& stack_settings.compute.is_some()
Expand Down Expand Up @@ -2747,6 +2769,22 @@ fn has_explicit_non_compute_changes(
|| (telemetry && requested.telemetry != current.telemetry)
}

fn supports_hosted_compute_update(status: &str) -> bool {
matches!(
status,
"running"
| "update-pending"
| "updating"
| "update-failed"
| "refresh-failed"
| "initial-setup"
| "initial-setup-failed"
| "provisioning"
| "waiting-for-machines"
| "provisioning-failed"
)
}

async fn update_hosted_compute_settings(
base_url: &str,
token: &str,
Expand Down
1 change: 1 addition & 0 deletions crates/alien-deployment/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ pub async fn step(
info!("A newer target release is available; starting update reconciliation");
if let Some(metadata) = current.runtime_metadata.as_mut() {
metadata.setup_update_authorization = None;
metadata.direct_setup_update_authorization = None;
}
current.status = DeploymentStatus::UpdatePending;
}
Expand Down
1 change: 1 addition & 0 deletions crates/alien-deployment/src/pending.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ pub async fn prepare_direct_setup_update(
let mut metadata = existing_metadata.clone();
metadata.initial_setup_authority = alien_core::InitialSetupAuthority::DirectSetup;
metadata.prepared_stack = Some(mutated_stack);
metadata.pending_prepared_stack = None;
metadata.persisted_gate_answers = persisted_gate_answers;
metadata.setup_update_authorization = None;
Ok(metadata)
Expand Down
1 change: 1 addition & 0 deletions crates/alien-deployment/src/provisioning.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ pub async fn handle_provisioning(
} else if stack_status == StackStatus::Running {
info!("All live resources deployed successfully, transitioning to Running");

runtime_metadata.direct_setup_update_authorization = None;
next.status = DeploymentStatus::Running;
next.stack_state = Some(step_result.next_state);
next.error = None;
Expand Down
106 changes: 101 additions & 5 deletions crates/alien-deployment/src/updating.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ use crate::{
DeploymentConfig, DeploymentState, DeploymentStatus, DeploymentStepResult, ErrorData, Result,
};
use alien_core::{
ComputeClusterOutputs, Platform, ResourceLifecycle, ResourceStatus, Stack, StackState,
StackStatus,
ComputeClusterOutputs, Platform, ResourceLifecycle, ResourceStatus, RuntimeMetadata, Stack,
StackState, StackStatus,
};
use alien_error::{AlienError, Context};
use alien_infra::{state_utils::StackStateExt, RunningResourcePolicy, StackExecutor};
Expand All @@ -21,6 +21,23 @@ fn machines_deployment_has_zero_machines(platform: Platform, stack_state: &Stack
})
}

fn direct_setup_update_is_authorized(
metadata: Option<&RuntimeMetadata>,
current_release_id: Option<&str>,
target_release_id: Option<&str>,
) -> bool {
metadata.is_some_and(|metadata| {
metadata.initial_setup_authority == alien_core::InitialSetupAuthority::DirectSetup
&& metadata
.direct_setup_update_authorization
.as_ref()
.is_some_and(|authorization| {
Some(authorization.release_id.as_str()) == target_release_id
&& current_release_id == target_release_id
})
Comment on lines +32 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Operation fence is ignored

If a retained authorization belongs to an earlier Platform operation for the same release, direct_setup_update_is_authorized accepts it without checking operation_id, causing stale administrator-credentialed setup reconciliation after operation ownership changes.

How this was verified: The authorization predicate checks setup authority and release IDs only, while operation_id is explicitly documented as the stale-write fence.

Knowledge Base Used: CLI and deployment workflows

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/alien-deployment/src/updating.rs
Line: 32-37

Comment:
**Operation fence is ignored**

If a retained authorization belongs to an earlier Platform operation for the same release, `direct_setup_update_is_authorized` accepts it without checking `operation_id`, causing stale administrator-credentialed setup reconciliation after operation ownership changes.

**How this was verified:** The authorization predicate checks setup authority and release IDs only, while `operation_id` is explicitly documented as the stale-write fence.

**Knowledge Base Used:** [CLI and deployment workflows](https://app.greptile.com/alien/-/custom-context/knowledge-base/alienplatform/alien/-/docs/cli-and-deployment.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Codex

})
}

fn compute_update_status(
stack_state: &StackState,
target_stack: &Stack,
Expand Down Expand Up @@ -166,12 +183,47 @@ pub async fn handle_update_pending(
.and_then(|m| m.prepared_stack.as_ref())
.or(current.current_release.as_ref().map(|r| &r.stack));

// Run deployment-time preflights: compatibility checks + mutations + runtime checks
// Store the mutated stack to use for the actual update and for future compatibility checks
let target_release_id = current
.target_release
.as_ref()
.and_then(|release| release.release_id.as_deref());
let direct_setup_update_authorized = direct_setup_update_is_authorized(
current.runtime_metadata.as_ref(),
current
.current_release
.as_ref()
.and_then(|release| release.release_id.as_deref()),
target_release_id,
);
if direct_setup_update_authorized {
let existing_metadata = current.runtime_metadata.as_ref().ok_or_else(|| {
AlienError::new(ErrorData::MissingConfiguration {
message: "Runtime metadata required for direct setup update".to_string(),
})
})?;
let runtime_metadata = crate::pending::prepare_direct_setup_update(
target_stack,
&stack_state,
&config,
&client_config,
existing_metadata,
)
.await?;
next.status = DeploymentStatus::InitialSetup;
next.stack_state = Some(stack_state);
next.error = None;
next.runtime_metadata = Some(runtime_metadata);
return Ok(DeploymentStepResult {
state: next,
suggested_delay_ms: None,
update_heartbeat: false,
heartbeats: vec![],
observed_inventory_batches: vec![],
});
}

// Run deployment-time preflights: compatibility checks + mutations + runtime checks
// Store the mutated stack to use for the actual update and for future compatibility checks
let setup_update_authorization = current
.runtime_metadata
.as_ref()
Expand Down Expand Up @@ -383,6 +435,7 @@ pub async fn handle_updating(
next.error = None;
runtime_metadata.prepared_stack = runtime_metadata.pending_prepared_stack.take();
runtime_metadata.setup_update_authorization = None;
runtime_metadata.direct_setup_update_authorization = None;
next.runtime_metadata = Some(runtime_metadata);
// Promote target to current: update successful
next.current_release = next.target_release.clone();
Expand Down Expand Up @@ -550,7 +603,50 @@ fn prune_deprovisioned_resources(
#[cfg(test)]
mod tests {
use super::*;
use alien_core::{Kv, Resource, ResourceLifecycle, StackResourceState, Worker, WorkerCode};
use alien_core::{
DirectSetupUpdateAuthorization, InitialSetupAuthority, Kv, Resource, ResourceLifecycle,
StackResourceState, Worker, WorkerCode,
};

#[test]
fn direct_setup_update_requires_explicit_same_release_authority() {
let mut metadata = RuntimeMetadata {
initial_setup_authority: InitialSetupAuthority::DirectSetup,
direct_setup_update_authorization: Some(DirectSetupUpdateAuthorization {
operation_id: "operation".to_string(),
release_id: "release-a".to_string(),
}),
..RuntimeMetadata::default()
};

assert!(direct_setup_update_is_authorized(
Some(&metadata),
Some("release-a"),
Some("release-a")
));
assert!(!direct_setup_update_is_authorized(
Some(&metadata),
Some("release-a"),
Some("release-b")
));
assert!(!direct_setup_update_is_authorized(
Some(&metadata),
Some("release-b"),
Some("release-a")
));

metadata.initial_setup_authority = InitialSetupAuthority::ImportedHandoff;
assert!(!direct_setup_update_is_authorized(
Some(&metadata),
Some("release-a"),
Some("release-a")
));
assert!(!direct_setup_update_is_authorized(
None,
Some("release-a"),
Some("release-a")
));
}

fn state_entry(resource: Resource, status: ResourceStatus) -> StackResourceState {
let mut entry = StackResourceState::new_pending(
Expand Down
51 changes: 47 additions & 4 deletions crates/alien-deployment/tests/test_platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
//! These tests exercise the full alien_deployment::step() lifecycle with no cloud I/O.

use alien_core::{
ClientConfig, DeploymentConfig, DeploymentState, DeploymentStatus, EnvironmentVariable,
EnvironmentVariableType, EnvironmentVariablesSnapshot, Platform, ReleaseInfo, ResourceEntry,
ResourceLifecycle, RuntimeMetadata, SetupUpdateAuthorization, Stack, StackSettings, StackState,
Storage, Worker, WorkerCode,
ClientConfig, DeploymentConfig, DeploymentState, DeploymentStatus,
DirectSetupUpdateAuthorization, EnvironmentVariable, EnvironmentVariableType,
EnvironmentVariablesSnapshot, Platform, ReleaseInfo, ResourceEntry, ResourceLifecycle,
RuntimeMetadata, SetupUpdateAuthorization, Stack, StackSettings, StackState, Storage, Worker,
WorkerCode,
};
use chrono::Utc;
use indexmap::IndexMap;
Expand Down Expand Up @@ -786,6 +787,48 @@ async fn setup_authorized_update_clears_authority_only_on_success() {
);
}

#[tokio::test]
async fn direct_setup_update_reruns_setup_before_live_reconciliation() {
let config = create_test_config("hash_v1", false);
let mut state = run_to_completion(
create_initial_state(create_test_stack("test-stack", "test-function")),
config.clone(),
)
.await;
let current_release = state.current_release.clone().expect("current release");
let release_id = current_release
.release_id
.clone()
.expect("current release id");
state.target_release = Some(current_release);
state.status = DeploymentStatus::UpdatePending;
state
.runtime_metadata
.as_mut()
.expect("runtime metadata")
.direct_setup_update_authorization = Some(DirectSetupUpdateAuthorization {
operation_id: "direct-setup-operation".to_string(),
release_id,
});

let setup_step = alien_deployment::step(state, config.clone(), ClientConfig::Test, None)
.await
.expect("direct setup update should prepare setup-owned resources");
assert_eq!(setup_step.state.status, DeploymentStatus::InitialSetup);

let completed = run_to_completion(setup_step.state, config).await;
assert_eq!(completed.status, DeploymentStatus::Running);
assert!(
completed
.runtime_metadata
.as_ref()
.expect("runtime metadata")
.direct_setup_update_authorization
.is_none(),
"direct setup authority must be consumed only after provisioning returns to Running"
);
}

#[tokio::test]
async fn update_completes_after_removed_resource_is_deleted() {
let _vault_env = test_vault_env().await;
Expand Down
Loading