agent-workspace-contract 0.7.0

Transport-neutral contracts for Agent Infra workspace APIs
Documentation
//! Admin contracts for persistent Workspace provider and launch configuration.

use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::net::IpAddr;
use std::num::NonZeroU16;

use crate::SnapshotBuildSpec;

pub const WORKSPACE_MOULIN_BUILD_CONFIG_PATH: &str = "/internal/v1/workspace/moulin-build-config";
pub const ADMIN_WORKSPACE_MOULIN_BUILD_CONFIG_PATH: &str =
    "/admin/api/workspace-moulin-build-config";

/// Non-secret defaults materialized into direct Workspace Moulin BuildJobs.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct WorkspaceMoulinBuildAdminConfig {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub http_proxy: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub https_proxy: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub no_proxy: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub registry_mirror: Option<String>,
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub build_args: BTreeMap<String, String>,
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub build_env: BTreeMap<String, String>,
}

/// Provider-enforced Build connectivity sent through Agent Deploy to Moulin.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
pub enum MoulinBuildNetworkPolicy {
    Direct,
    ManagedProxy {
        #[serde(default, skip_serializing_if = "Option::is_none")]
        http_proxy: Option<String>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        https_proxy: Option<String>,
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
        no_proxy: Vec<String>,
    },
}

impl WorkspaceMoulinBuildAdminConfig {
    /// Ordinary non-secret variables retain Docker-compatible ENV precedence.
    pub fn effective_build_env(&self) -> BTreeMap<String, String> {
        self.build_env.clone()
    }

    /// Preserve platform ownership instead of degrading proxy configuration to
    /// ordinary build_env that Dockerfile ENV may shadow.
    pub fn effective_network_policy(&self) -> Option<MoulinBuildNetworkPolicy> {
        if self.http_proxy.is_none() && self.https_proxy.is_none() {
            return None;
        }
        let mut seen = BTreeSet::new();
        let no_proxy = self
            .no_proxy
            .as_deref()
            .into_iter()
            .flat_map(|value| value.split(','))
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .filter(|value| seen.insert((*value).to_string()))
            .map(str::to_string)
            .collect();
        Some(MoulinBuildNetworkPolicy::ManagedProxy {
            http_proxy: self.http_proxy.clone(),
            https_proxy: self.https_proxy.clone(),
            no_proxy,
        })
    }
}

/// Exact destination accepted by provider defaults and per-sandbox overrides.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct MoulinEgressRule {
    host: String,
    port: NonZeroU16,
}

impl MoulinEgressRule {
    pub fn new(host: impl Into<String>, port: u16) -> Result<Self, String> {
        let host = validate_exact_hostname(host.into())?;
        let port =
            NonZeroU16::new(port).ok_or_else(|| "egress port must be non-zero".to_string())?;
        Ok(Self { host, port })
    }

    pub fn host(&self) -> &str {
        &self.host
    }

    pub fn port(&self) -> u16 {
        self.port.get()
    }
}

impl<'de> Deserialize<'de> for MoulinEgressRule {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        #[derive(Deserialize)]
        #[serde(rename_all = "camelCase", deny_unknown_fields)]
        struct WireRule {
            host: String,
            port: u16,
        }

        let rule = WireRule::deserialize(deserializer)?;
        Self::new(rule.host, rule.port).map_err(serde::de::Error::custom)
    }
}

/// Validate the bounded request-level or provider-level Moulin policy.
pub fn validate_moulin_egress_rules(rules: &[MoulinEgressRule]) -> Result<(), String> {
    if rules.len() > 20 {
        return Err("egressRules supports at most 20 entries".into());
    }
    let unique = rules
        .iter()
        .map(|rule| (rule.host(), rule.port()))
        .collect::<BTreeSet<_>>();
    if unique.len() != rules.len() {
        return Err("egressRules contains duplicates".into());
    }
    Ok(())
}

fn validate_exact_hostname(host: String) -> Result<String, String> {
    let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
    if !host.is_ascii() || host.len() > 253 || host.parse::<IpAddr>().is_ok() || host.contains('*')
    {
        return Err("egress host must be an exact ASCII hostname".into());
    }
    let labels = host.split('.').collect::<Vec<_>>();
    if labels.len() < 2
        || labels.iter().any(|label| {
            label.is_empty()
                || label.len() > 63
                || label.starts_with('-')
                || label.ends_with('-')
                || !label
                    .bytes()
                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
        })
    {
        return Err("egress host must be an exact ASCII hostname".into());
    }
    Ok(host)
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EnvironmentProviderSlotView {
    pub base_url: String,
    #[serde(default)]
    pub build_url: String,
    pub credential_configured: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default_build: Option<SnapshotBuildSpec>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default_template_id: Option<String>,
    pub active: bool,
    #[serde(default)]
    pub egress_rules: Vec<MoulinEgressRule>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EnvironmentProviderAdminConfig {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default_snapshot_id: Option<String>,
    pub moulin: EnvironmentProviderSlotView,
    pub e2b: EnvironmentProviderSlotView,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default_provider: Option<String>,
    #[serde(default)]
    pub default_resources: crate::SandboxResources,
    pub managed: bool,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateEnvironmentProviderSlotRequest {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub base_url: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub build_url: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub credential: Option<String>,
    #[serde(default)]
    pub clear_credential: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default_build: Option<SnapshotBuildSpec>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default_template_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub egress_rules: Option<Vec<MoulinEgressRule>>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateEnvironmentProviderAdminConfigRequest {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default_snapshot_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub moulin: Option<UpdateEnvironmentProviderSlotRequest>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub e2b: Option<UpdateEnvironmentProviderSlotRequest>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default_provider: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default_resources: Option<crate::SandboxResources>,
}