1use crate::common::SecretRef;
39use k8s_openapi::api::core::v1::{PodSecurityContext, ResourceRequirements, SecurityContext};
40use schemars::JsonSchema;
41use serde::{Deserialize, Serialize};
42use std::collections::BTreeMap;
43
44pub const DEFAULT_SERVER_PORT: u16 = 51515;
46
47#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
49#[serde(rename_all = "camelCase")]
50pub struct ServerSpec {
51 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub auth: Option<ServerAuth>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub read_only: Option<bool>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub service: Option<ServerService>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub resources: Option<ResourceRequirements>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub security_context: Option<SecurityContext>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub pod_security_context: Option<PodSecurityContext>,
69}
70
71#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
73#[serde(rename_all = "camelCase")]
74pub struct ClusterServerSpec {
75 pub namespace: String,
77 #[serde(flatten)]
79 pub server: ServerSpec,
80}
81
82#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
84#[serde(rename_all = "camelCase")]
85pub enum ServerAuth {
86 Generate(GenerateAuth),
88 SecretRef(ServerSecretRef),
90 Insecure(InsecureAuth),
92}
93
94impl ServerAuth {
95 pub fn kind_str(&self) -> &'static str {
97 match self {
98 ServerAuth::Generate(_) => "Generate",
99 ServerAuth::SecretRef(_) => "SecretRef",
100 ServerAuth::Insecure(_) => "Insecure",
101 }
102 }
103}
104
105#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
107#[serde(rename_all = "camelCase")]
108pub struct GenerateAuth {
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub username: Option<String>,
112}
113
114#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
116#[serde(rename_all = "camelCase")]
117pub struct ServerSecretRef {
118 pub name: String,
120 pub username_key: String,
122 pub password_key: String,
124}
125
126#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
128#[serde(rename_all = "camelCase")]
129pub struct InsecureAuth {
130 #[serde(default)]
132 pub acknowledge_insecure: bool,
133}
134
135#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
137#[serde(rename_all = "camelCase")]
138pub struct ServerService {
139 #[serde(default)]
141 pub r#type: ServiceType,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
144 #[schemars(default = "default_server_port")]
145 pub port: Option<u16>,
146 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
149 pub annotations: BTreeMap<String, String>,
150}
151
152fn default_server_port() -> Option<u16> {
156 Some(DEFAULT_SERVER_PORT)
157}
158
159impl ServerService {
160 pub fn resolved_port(&self) -> u16 {
162 self.port.unwrap_or(DEFAULT_SERVER_PORT)
163 }
164}
165
166#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
168pub enum ServiceType {
169 #[default]
171 ClusterIP,
172 NodePort,
174 LoadBalancer,
176}
177
178impl ServiceType {
179 pub fn as_str(&self) -> &'static str {
181 match self {
182 ServiceType::ClusterIP => "ClusterIP",
183 ServiceType::NodePort => "NodePort",
184 ServiceType::LoadBalancer => "LoadBalancer",
185 }
186 }
187}
188
189#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
191#[serde(rename_all = "camelCase")]
192pub struct ServerStatus {
193 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub endpoint: Option<String>,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub namespace: Option<String>,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub auth_mode: Option<String>,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub read_only: Option<bool>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub generated_secret_ref: Option<SecretRef>,
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use crate::testutil::from_yaml;
214
215 #[test]
216 fn generate_auth_is_externally_tagged_empty_object() {
217 let auth: ServerAuth = from_yaml("generate: {}\n");
220 assert_eq!(auth.kind_str(), "Generate");
221 let v = serde_json::to_value(&auth).unwrap();
222 assert!(v.get("generate").is_some(), "wire shape: {v}");
223 assert!(v["generate"].is_object());
224 }
225
226 #[test]
227 fn secret_ref_auth_roundtrips() {
228 let auth: ServerAuth =
229 from_yaml("secretRef:\n name: ui\n usernameKey: username\n passwordKey: password\n");
230 match &auth {
231 ServerAuth::SecretRef(s) => {
232 assert_eq!(s.name, "ui");
233 assert_eq!(s.username_key, "username");
234 assert_eq!(s.password_key, "password");
235 }
236 other => panic!("expected SecretRef, got {}", other.kind_str()),
237 }
238 let reparsed: ServerAuth =
239 serde_json::from_value(serde_json::to_value(&auth).unwrap()).unwrap();
240 assert_eq!(auth, reparsed);
241 }
242
243 #[test]
244 fn insecure_auth_carries_acknowledgement() {
245 let auth: ServerAuth = from_yaml("insecure:\n acknowledgeInsecure: true\n");
246 match &auth {
247 ServerAuth::Insecure(i) => assert!(i.acknowledge_insecure),
248 other => panic!("expected Insecure, got {}", other.kind_str()),
249 }
250 let auth2: ServerAuth = from_yaml("insecure: {}\n");
252 assert!(matches!(auth2, ServerAuth::Insecure(i) if !i.acknowledge_insecure));
253 }
254
255 #[test]
256 fn unknown_auth_variant_is_rejected() {
257 let value: serde_json::Value = serde_yaml::from_str("oauth: {}\n").unwrap();
258 assert!(serde_json::from_value::<ServerAuth>(value).is_err());
259 }
260
261 #[test]
262 fn service_type_serializes_with_exact_k8s_casing() {
263 assert_eq!(
265 serde_json::to_value(ServiceType::ClusterIP).unwrap(),
266 serde_json::json!("ClusterIP")
267 );
268 assert_eq!(
269 serde_json::to_value(ServiceType::NodePort).unwrap(),
270 serde_json::json!("NodePort")
271 );
272 assert_eq!(
273 serde_json::to_value(ServiceType::LoadBalancer).unwrap(),
274 serde_json::json!("LoadBalancer")
275 );
276 let t: ServiceType = serde_json::from_value(serde_json::json!("LoadBalancer")).unwrap();
277 assert_eq!(t, ServiceType::LoadBalancer);
278 }
279
280 #[test]
281 fn service_defaults_type_clusterip_and_resolves_default_port() {
282 let svc = ServerService::default();
283 assert_eq!(svc.r#type, ServiceType::ClusterIP);
284 assert_eq!(svc.resolved_port(), DEFAULT_SERVER_PORT);
285 assert_eq!(
286 ServerService {
287 port: Some(8080),
288 ..Default::default()
289 }
290 .resolved_port(),
291 8080
292 );
293 }
294
295 #[test]
296 fn read_only_defaults_off_and_roundtrips() {
297 let off: ServerSpec = from_yaml("auth:\n generate: {}\n");
299 assert_eq!(off.read_only, None);
300 let on: ServerSpec = from_yaml("readOnly: true\nauth:\n generate: {}\n");
302 assert_eq!(on.read_only, Some(true));
303 let v = serde_json::to_value(&on).unwrap();
304 assert_eq!(v["readOnly"], serde_json::json!(true));
305 let reparsed: ServerSpec = serde_json::from_value(v).unwrap();
306 assert_eq!(on, reparsed);
307 assert_eq!(ServerSpec::default().read_only, None);
309 }
310
311 #[test]
312 fn status_pins_effective_read_only() {
313 let st: ServerStatus = from_yaml("readOnly: true\nauthMode: Generate\n");
314 assert_eq!(st.read_only, Some(true));
315 let v = serde_json::to_value(&st).unwrap();
316 assert_eq!(v["readOnly"], serde_json::json!(true));
317 }
318
319 #[test]
320 fn server_spec_carries_pod_security_context_supplemental_groups() {
321 let spec: ServerSpec =
324 from_yaml("auth:\n generate: {}\npodSecurityContext:\n supplementalGroups: [3001]\n");
325 let psc = spec
326 .pod_security_context
327 .as_ref()
328 .expect("podSecurityContext present");
329 assert_eq!(psc.supplemental_groups.as_deref(), Some(&[3001i64][..]));
330 let reparsed: ServerSpec =
332 serde_json::from_value(serde_json::to_value(&spec).unwrap()).unwrap();
333 assert_eq!(spec, reparsed);
334 let bare: ServerSpec = from_yaml("auth:\n generate: {}\n");
336 assert!(bare.pod_security_context.is_none());
337 assert!(
338 serde_json::to_value(&bare)
339 .unwrap()
340 .get("podSecurityContext")
341 .is_none()
342 );
343 }
344
345 #[test]
346 fn cluster_server_spec_flattens_pod_security_context() {
347 let cs: ClusterServerSpec = from_yaml(
348 "namespace: kopiur-system\npodSecurityContext:\n supplementalGroups: [3001]\n fsGroup: 3001\n",
349 );
350 assert_eq!(cs.namespace, "kopiur-system");
351 let psc = cs.server.pod_security_context.as_ref().unwrap();
352 assert_eq!(psc.supplemental_groups.as_deref(), Some(&[3001i64][..]));
353 assert_eq!(psc.fs_group, Some(3001));
354 }
355
356 #[test]
357 fn cluster_server_spec_flattens_namespace_with_base() {
358 let cs: ClusterServerSpec = from_yaml(
359 "namespace: kopiur-system\nauth:\n generate: {}\nservice:\n type: NodePort\n port: 30515\n",
360 );
361 assert_eq!(cs.namespace, "kopiur-system");
362 assert_eq!(cs.server.auth.as_ref().unwrap().kind_str(), "Generate");
363 let svc = cs.server.service.as_ref().unwrap();
364 assert_eq!(svc.r#type, ServiceType::NodePort);
365 assert_eq!(svc.resolved_port(), 30515);
366 let reparsed: ClusterServerSpec =
368 serde_json::from_value(serde_json::to_value(&cs).unwrap()).unwrap();
369 assert_eq!(cs, reparsed);
370 }
371}