greentic-deploy-spec 0.1.0

Greentic deployment object-model schemas (Environment, Revision, TrafficSplit, BundleDeployment, Credentials, PackConfig, RuntimeConfig)
Documentation
//! `greentic.environment.v1` (`§5.1`).
//!
//! Top-level Environment compose-view. Decomposes into three persistence units
//! on disk (`environment.json`, `env-packs/<slot>/answers.json`, `runtime.json`)
//! — the in-memory `Environment` is the union of those, owned by A2's
//! `EnvironmentStore`.

use crate::bundle_deployment::BundleDeployment;
use crate::capability_slot::{CapabilitySlot, PackDescriptor};
use crate::error::SpecError;
use crate::ids::PackId;
use crate::refs::SecretRef;
use crate::retention::{HealthStatus, RetentionPolicy, RevocationConfig};
use crate::revision::Revision;
use crate::traffic_split::TrafficSplit;
use crate::version::SchemaVersion;
use greentic_types::EnvId;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

/// Host-level config moved out of `greentic-config-types::EnvironmentConfig`
/// (`§5.1`). Identity-only — connectivity, region, and deployment ctx; nothing
/// secret, nothing tenant-scoped.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnvironmentHostConfig {
    pub env_id: EnvId,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub region: Option<String>,
    /// Tenant organization the env belongs to. `None` for `local`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tenant_org_id: Option<String>,
}

/// Binding from a [`CapabilitySlot`] to a concrete pack (`§5.1`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnvPackBinding {
    pub slot: CapabilitySlot,
    pub kind: PackDescriptor,
    pub pack_ref: PackId,
    /// `env-packs/<slot>/answers.json` (env-relative path).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub answers_ref: Option<PathBuf>,
    /// Bumped on attach/update/remove/rollback.
    #[serde(default)]
    pub generation: u64,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub previous_binding_ref: Option<PathBuf>,
}

/// `greentic.environment.v1` compose-view (`§5.1`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Environment {
    pub schema: SchemaVersion,
    pub environment_id: EnvId,
    pub name: String,
    pub host_config: EnvironmentHostConfig,
    /// One entry per [`CapabilitySlot`]. Use [`Environment::validate`] to enforce.
    pub packs: Vec<EnvPackBinding>,
    /// `secret://<env>/credentials/...` reference into `packs[secrets]` (P5).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub credentials_ref: Option<SecretRef>,
    #[serde(default)]
    pub bundles: Vec<BundleDeployment>,
    #[serde(default)]
    pub revisions: Vec<Revision>,
    #[serde(default)]
    pub traffic_splits: Vec<TrafficSplit>,
    #[serde(default)]
    pub revocation: RevocationConfig,
    #[serde(default)]
    pub retention: RetentionPolicy,
    #[serde(default)]
    pub health: HealthStatus,
}

impl Environment {
    pub fn schema_str() -> &'static str {
        SchemaVersion::ENVIRONMENT_V1
    }

    /// Returns the binding for a slot, if any.
    pub fn pack_for_slot(&self, slot: CapabilitySlot) -> Option<&EnvPackBinding> {
        self.packs.iter().find(|b| b.slot == slot)
    }

    /// Validates spec-level invariants:
    /// - schema discriminator matches `greentic.environment.v1`,
    /// - slot uniqueness across `packs`,
    /// - basis-points sums on contained `TrafficSplit` / `BundleDeployment`,
    /// - `env_id` ownership across `host_config`, `revisions`, `bundles`, and
    ///   `traffic_splits` (every nested doc carries the same env identifier),
    /// - referential integrity: split entries reference a `Revision` in this
    ///   env whose `deployment_id` + `bundle_id` match the split's, and every
    ///   bundle's `current_revisions` references a `Revision` whose
    ///   `deployment_id` matches the bundle's. Lifecycle-state checks (e.g.
    ///   `lifecycle == Ready` for split entries per `§5.3`) stay at apply
    ///   time — pure data invariants only here.
    pub fn validate(&self) -> Result<(), SpecError> {
        if self.schema.as_str() != SchemaVersion::ENVIRONMENT_V1 {
            return Err(SpecError::SchemaMismatch {
                expected: SchemaVersion::ENVIRONMENT_V1,
                actual: self.schema.as_str().to_string(),
            });
        }

        if self.host_config.env_id != self.environment_id {
            return Err(SpecError::EnvIdMismatch {
                context: "host_config",
                expected: self.environment_id.clone(),
                actual: self.host_config.env_id.clone(),
            });
        }

        let mut seen = [false; 6];
        for binding in &self.packs {
            let idx = binding.slot as usize;
            if seen[idx] {
                return Err(SpecError::DuplicateCapabilitySlot(binding.slot));
            }
            seen[idx] = true;
        }

        for revision in &self.revisions {
            if revision.env_id != self.environment_id {
                return Err(SpecError::EnvIdMismatch {
                    context: "revision",
                    expected: self.environment_id.clone(),
                    actual: revision.env_id.clone(),
                });
            }
        }

        for bundle in &self.bundles {
            if bundle.env_id != self.environment_id {
                return Err(SpecError::EnvIdMismatch {
                    context: "bundle_deployment",
                    expected: self.environment_id.clone(),
                    actual: bundle.env_id.clone(),
                });
            }
            bundle.validate()?;
            for rev_id in &bundle.current_revisions {
                let referenced = self
                    .revisions
                    .iter()
                    .find(|r| r.revision_id == *rev_id)
                    .ok_or(SpecError::UnknownRevision(*rev_id))?;
                if referenced.deployment_id != bundle.deployment_id {
                    return Err(SpecError::BundleRevisionWrongDeployment {
                        deployment: bundle.deployment_id,
                        revision: *rev_id,
                        actual_deployment: referenced.deployment_id,
                    });
                }
            }
        }

        for split in &self.traffic_splits {
            if split.env_id != self.environment_id {
                return Err(SpecError::EnvIdMismatch {
                    context: "traffic_split",
                    expected: self.environment_id.clone(),
                    actual: split.env_id.clone(),
                });
            }
            split.validate()?;
            if !self
                .bundles
                .iter()
                .any(|b| b.deployment_id == split.deployment_id)
            {
                return Err(SpecError::UnknownDeployment(split.deployment_id));
            }
            for entry in &split.entries {
                let referenced = self
                    .revisions
                    .iter()
                    .find(|r| r.revision_id == entry.revision_id)
                    .ok_or(SpecError::UnknownRevision(entry.revision_id))?;
                if referenced.deployment_id != split.deployment_id {
                    return Err(SpecError::SplitRevisionWrongDeployment {
                        revision: entry.revision_id,
                        expected_deployment: split.deployment_id,
                        actual_deployment: referenced.deployment_id,
                    });
                }
                if referenced.bundle_id != split.bundle_id {
                    return Err(SpecError::SplitRevisionWrongBundle {
                        revision: entry.revision_id,
                        expected_bundle: split.bundle_id.clone(),
                        actual_bundle: referenced.bundle_id.clone(),
                    });
                }
            }
        }

        Ok(())
    }
}