alien-core 1.11.0

Deploy software into your customers' cloud accounts and keep it fully managed
Documentation
//! Deployment state, step results, and runtime metadata.

use crate::{ObservedInventoryBatch, Platform, ResourceHeartbeat, StackState};
use alien_error::AlienError;
use bon::Builder;
use serde::{Deserialize, Serialize};

use super::{DeploymentStatus, EnvironmentInfo, ReleaseInfo};

/// Runtime metadata for deployment
///
/// Stores deployment state that needs to persist across step calls.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct RuntimeMetadata {
    /// Hash of the environment variables snapshot that was last synced to the vault
    /// Used to avoid redundant sync operations during incremental deployment
    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_synced_env_vars_hash: Option<String>,

    /// The prepared (mutated) stack from the last successful deployment phase
    /// This is the stack AFTER mutations have been applied (with service accounts, vault, etc.)
    /// Used for compatibility checks during updates to compare mutated stacks
    #[serde(skip_serializing_if = "Option::is_none")]
    pub prepared_stack: Option<crate::Stack>,

    /// 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
    /// every reconcile tick.
    #[serde(default, skip_serializing_if = "is_false")]
    pub registry_access_granted: bool,
}

/// Deployment state
///
/// Represents the current state of deployed infrastructure, including release tracking.
/// This is platform-agnostic - no backend IDs or database relationships.
///
/// The deployment engine manages releases internally: when a deployment succeeds,
/// it promotes `target_release` to `current_release` and clears `target_release`.
#[derive(Debug, Clone, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct DeploymentState {
    /// Current lifecycle phase
    pub status: DeploymentStatus,
    /// Target cloud platform (AWS, GCP, Azure, Kubernetes)
    pub platform: Platform,
    /// Currently deployed release (None for first deployment)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub current_release: Option<ReleaseInfo>,
    /// Target release to deploy (None when synced with current)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub target_release: Option<ReleaseInfo>,
    /// Infrastructure resource tracking (which resources exist, their status, outputs)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub stack_state: Option<StackState>,
    /// Deployment-level error for failures not owned by a specific resource.
    ///
    /// Resource controller failures belong in `stack_state.resources[*].error`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<AlienError>,
    /// Cloud account details (account ID, project number, region)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub environment_info: Option<EnvironmentInfo>,
    /// Deployment-specific data (prepared stacks, phase tracking, etc.)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub runtime_metadata: Option<RuntimeMetadata>,
    /// Whether a retry has been requested for a failed deployment
    /// When true and status is a failed state, the deployment system will retry failed resources
    #[serde(default, skip_serializing_if = "is_false")]
    pub retry_requested: bool,
    /// Protocol version for cross-actor compatibility.
    /// All actors (manager, push client, agent) check this before stepping.
    /// Mismatched versions produce a clear error instead of silent corruption.
    /// See docs/02-manager/10-deployment-protocol.md.
    pub protocol_version: u32,
}

impl DeploymentState {
    /// Returns whether this state carries desired infrastructure for the
    /// deployment runner to converge.
    pub fn has_desired(&self) -> bool {
        self.current_release.is_some()
            || self.target_release.is_some()
            || self.stack_state.is_some()
    }
}

/// Result of a deployment step
///
/// Contains the complete next deployment state along with hints for the platform.
/// This replaces the old delta-based `DeploymentStateUpdate` approach.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct DeploymentStepResult {
    /// The complete next deployment state
    pub state: DeploymentState,

    /// Suggested delay before next step (optimization hint)
    /// - `None`: No suggested delay, can poll immediately
    /// - `Some(ms)`: Wait this many milliseconds before next step
    #[serde(skip_serializing_if = "Option::is_none")]
    pub suggested_delay_ms: Option<u64>,

    /// Whether to update heartbeat timestamp (monitoring signal)
    /// - `false`: Don't update heartbeat (default for most steps)
    /// - `true`: Update lastHeartbeatAt (for successful health checks in Running state)
    #[serde(default, skip_serializing_if = "is_false")]
    pub update_heartbeat: bool,

    /// Managed Alien resource status samples emitted by controllers during this step.
    #[serde(
        default,
        rename = "resourceHeartbeats",
        skip_serializing_if = "Vec::is_empty"
    )]
    pub heartbeats: Vec<ResourceHeartbeat>,

    /// Observed raw-resource inventory batches read during this step.
    #[serde(
        default,
        rename = "observedInventoryBatches",
        skip_serializing_if = "Vec::is_empty"
    )]
    pub observed_inventory_batches: Vec<ObservedInventoryBatch>,
}

pub(crate) fn is_false(b: &bool) -> bool {
    !*b
}

/// Oldest deployment protocol version this binary can read.
pub const MIN_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION: u32 = 1;

/// Deployment protocol version this binary writes.
/// Bump when making incompatible changes to DeploymentState semantics.
pub const CURRENT_DEPLOYMENT_PROTOCOL_VERSION: u32 = 1;

/// Backwards-compatible alias for older call sites.
pub const DEPLOYMENT_PROTOCOL_VERSION: u32 = CURRENT_DEPLOYMENT_PROTOCOL_VERSION;

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{Platform, ReleaseInfo, Stack, StackState};
    use indexmap::IndexMap;

    fn empty_stack() -> Stack {
        Stack {
            id: "stack_test".to_string(),
            resources: IndexMap::new(),
            inputs: vec![],
            permissions: crate::PermissionsConfig::default(),
            supported_platforms: None,
        }
    }

    fn release_info(id: &str) -> ReleaseInfo {
        ReleaseInfo {
            release_id: Some(id.to_string()),
            version: None,
            description: None,
            stack: empty_stack(),
        }
    }

    fn state() -> DeploymentState {
        DeploymentState {
            status: DeploymentStatus::Pending,
            platform: Platform::Kubernetes,
            current_release: None,
            target_release: None,
            stack_state: None,
            error: None,
            environment_info: None,
            runtime_metadata: None,
            retry_requested: false,
            protocol_version: DEPLOYMENT_PROTOCOL_VERSION,
        }
    }

    #[test]
    fn deployment_state_has_desired_when_release_or_stack_state_exists() {
        let observe_only = state();
        assert!(!observe_only.has_desired());

        let mut current = state();
        current.current_release = Some(release_info("rel_current"));
        assert!(current.has_desired());

        let mut target = state();
        target.target_release = Some(release_info("rel_target"));
        assert!(target.has_desired());

        let mut imported = state();
        imported.stack_state = Some(StackState::new(Platform::Kubernetes));
        assert!(imported.has_desired());
    }
}