agent_workspace_contract/
provider_config.rs1use 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#[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#[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 pub fn effective_build_env(&self) -> BTreeMap<String, String> {
50 self.build_env.clone()
51 }
52
53 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#[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
120pub 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}