Skip to main content

agent_workspace_contract/
provider_config.rs

1//! Admin contracts for persistent Workspace provider and launch configuration.
2
3use serde::{Deserialize, Serialize};
4use std::collections::{BTreeMap, BTreeSet};
5use std::net::IpAddr;
6use std::num::NonZeroU16;
7
8use crate::SnapshotBuildSpec;
9
10pub const WORKSPACE_MOULIN_BUILD_CONFIG_PATH: &str = "/internal/v1/workspace/moulin-build-config";
11pub const ADMIN_WORKSPACE_MOULIN_BUILD_CONFIG_PATH: &str =
12    "/admin/api/workspace-moulin-build-config";
13
14/// Non-secret defaults materialized into direct Workspace Moulin BuildJobs.
15#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(rename_all = "camelCase", deny_unknown_fields)]
17pub struct WorkspaceMoulinBuildAdminConfig {
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub http_proxy: Option<String>,
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub https_proxy: Option<String>,
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub no_proxy: Option<String>,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub registry_mirror: Option<String>,
26    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
27    pub build_args: BTreeMap<String, String>,
28    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
29    pub build_env: BTreeMap<String, String>,
30}
31
32/// Provider-enforced Build connectivity sent through Agent Deploy to Moulin.
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
35pub enum MoulinBuildNetworkPolicy {
36    Direct,
37    ManagedProxy {
38        #[serde(default, skip_serializing_if = "Option::is_none")]
39        http_proxy: Option<String>,
40        #[serde(default, skip_serializing_if = "Option::is_none")]
41        https_proxy: Option<String>,
42        #[serde(default, skip_serializing_if = "Vec::is_empty")]
43        no_proxy: Vec<String>,
44    },
45}
46
47impl WorkspaceMoulinBuildAdminConfig {
48    /// Ordinary non-secret variables retain Docker-compatible ENV precedence.
49    pub fn effective_build_env(&self) -> BTreeMap<String, String> {
50        self.build_env.clone()
51    }
52
53    /// Preserve platform ownership instead of degrading proxy configuration to
54    /// ordinary build_env that Dockerfile ENV may shadow.
55    pub fn effective_network_policy(&self) -> Option<MoulinBuildNetworkPolicy> {
56        if self.http_proxy.is_none() && self.https_proxy.is_none() {
57            return None;
58        }
59        let mut seen = BTreeSet::new();
60        let no_proxy = self
61            .no_proxy
62            .as_deref()
63            .into_iter()
64            .flat_map(|value| value.split(','))
65            .map(str::trim)
66            .filter(|value| !value.is_empty())
67            .filter(|value| seen.insert((*value).to_string()))
68            .map(str::to_string)
69            .collect();
70        Some(MoulinBuildNetworkPolicy::ManagedProxy {
71            http_proxy: self.http_proxy.clone(),
72            https_proxy: self.https_proxy.clone(),
73            no_proxy,
74        })
75    }
76}
77
78/// Exact destination accepted by provider defaults and per-sandbox overrides.
79#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
80#[serde(rename_all = "camelCase")]
81pub struct MoulinEgressRule {
82    host: String,
83    port: NonZeroU16,
84}
85
86impl MoulinEgressRule {
87    pub fn new(host: impl Into<String>, port: u16) -> Result<Self, String> {
88        let host = validate_exact_hostname(host.into())?;
89        let port =
90            NonZeroU16::new(port).ok_or_else(|| "egress port must be non-zero".to_string())?;
91        Ok(Self { host, port })
92    }
93
94    pub fn host(&self) -> &str {
95        &self.host
96    }
97
98    pub fn port(&self) -> u16 {
99        self.port.get()
100    }
101}
102
103impl<'de> Deserialize<'de> for MoulinEgressRule {
104    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
105    where
106        D: serde::Deserializer<'de>,
107    {
108        #[derive(Deserialize)]
109        #[serde(rename_all = "camelCase", deny_unknown_fields)]
110        struct WireRule {
111            host: String,
112            port: u16,
113        }
114
115        let rule = WireRule::deserialize(deserializer)?;
116        Self::new(rule.host, rule.port).map_err(serde::de::Error::custom)
117    }
118}
119
120/// Validate the bounded request-level or provider-level Moulin policy.
121pub fn validate_moulin_egress_rules(rules: &[MoulinEgressRule]) -> Result<(), String> {
122    if rules.len() > 20 {
123        return Err("egressRules supports at most 20 entries".into());
124    }
125    let unique = rules
126        .iter()
127        .map(|rule| (rule.host(), rule.port()))
128        .collect::<BTreeSet<_>>();
129    if unique.len() != rules.len() {
130        return Err("egressRules contains duplicates".into());
131    }
132    Ok(())
133}
134
135fn validate_exact_hostname(host: String) -> Result<String, String> {
136    let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
137    if !host.is_ascii() || host.len() > 253 || host.parse::<IpAddr>().is_ok() || host.contains('*')
138    {
139        return Err("egress host must be an exact ASCII hostname".into());
140    }
141    let labels = host.split('.').collect::<Vec<_>>();
142    if labels.len() < 2
143        || labels.iter().any(|label| {
144            label.is_empty()
145                || label.len() > 63
146                || label.starts_with('-')
147                || label.ends_with('-')
148                || !label
149                    .bytes()
150                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
151        })
152    {
153        return Err("egress host must be an exact ASCII hostname".into());
154    }
155    Ok(host)
156}
157
158#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(rename_all = "camelCase")]
160pub struct EnvironmentProviderSlotView {
161    pub base_url: String,
162    #[serde(default)]
163    pub build_url: String,
164    pub credential_configured: bool,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub default_build: Option<SnapshotBuildSpec>,
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub default_template_id: Option<String>,
169    pub active: bool,
170    #[serde(default)]
171    pub egress_rules: Vec<MoulinEgressRule>,
172}
173
174#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "camelCase")]
176pub struct EnvironmentProviderAdminConfig {
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub default_snapshot_id: Option<String>,
179    pub moulin: EnvironmentProviderSlotView,
180    pub e2b: EnvironmentProviderSlotView,
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub default_provider: Option<String>,
183    #[serde(default)]
184    pub default_resources: crate::SandboxResources,
185    pub managed: bool,
186}
187
188#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(rename_all = "camelCase", deny_unknown_fields)]
190pub struct UpdateEnvironmentProviderSlotRequest {
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub base_url: Option<String>,
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub build_url: Option<String>,
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub credential: Option<String>,
197    #[serde(default)]
198    pub clear_credential: bool,
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub default_build: Option<SnapshotBuildSpec>,
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub default_template_id: Option<String>,
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub egress_rules: Option<Vec<MoulinEgressRule>>,
205}
206
207#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(rename_all = "camelCase", deny_unknown_fields)]
209pub struct UpdateEnvironmentProviderAdminConfigRequest {
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub default_snapshot_id: Option<String>,
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub moulin: Option<UpdateEnvironmentProviderSlotRequest>,
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub e2b: Option<UpdateEnvironmentProviderSlotRequest>,
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub default_provider: Option<String>,
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub default_resources: Option<crate::SandboxResources>,
220}