1use std::collections::HashMap;
26use std::path::PathBuf;
27
28use serde::{Deserialize, Serialize};
29use thiserror::Error;
30
31use crate::{
32 EnvValue, EnvVar, ExposeSpec, ImageRef, MeshExpose, MeshIdent, Millis, RestartPolicy,
33 ResourceLimits, SchemaVersion, StopPolicy, TierTag, VolumeMount, VolumeSource, WorkloadSpec,
34};
35
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43pub struct ImportResult {
44 pub specs: Vec<WorkloadSpec>,
47
48 #[serde(default, skip_serializing_if = "Vec::is_empty")]
51 pub warnings: Vec<ImportWarning>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct ImportWarning {
57 pub path: String,
59 pub message: String,
61}
62
63#[derive(Debug, Error, PartialEq)]
65pub enum ImportError {
66 #[error("compose YAML parse error: {0}")]
68 Parse(String),
69
70 #[error("compose file has no `services:` block")]
72 NoServices,
73
74 #[error(
78 "service {service:?}: network_mode=host is not supported on the yubaba mesh \
79 (every workload runs through the mesh; see \
80 .yah/docs/architecture/A054-yah-workload-spec.md §\"What's deliberately not in the schema\")"
81 )]
82 HostNetwork { service: String },
83
84 #[error("service {service:?}: no `image:` field — yubaba requires an image reference")]
87 MissingImage { service: String },
88
89 #[error("service {service:?}: image {image:?} is not digest-pinned ({reason})")]
94 UnpinnedImage {
95 service: String,
96 image: String,
97 reason: String,
98 },
99}
100
101pub fn import_compose(yaml: &str) -> Result<ImportResult, ImportError> {
112 let compose: ComposeFile =
113 serde_yaml::from_str(yaml).map_err(|e| ImportError::Parse(e.to_string()))?;
114
115 if compose.services.is_empty() {
116 return Err(ImportError::NoServices);
117 }
118
119 let mut warnings = Vec::new();
120
121 if !compose.networks.is_empty() {
124 warnings.push(ImportWarning {
125 path: "networks".into(),
126 message: format!(
127 "compose declared {} custom network(s) ({}); yubaba flattens all workloads \
128 onto one mesh — segmentation must be re-expressed via tier `allow_from`",
129 compose.networks.len(),
130 compose
131 .networks
132 .keys()
133 .cloned()
134 .collect::<Vec<_>>()
135 .join(", ")
136 ),
137 });
138 }
139
140 let mut specs = Vec::with_capacity(compose.services.len());
141
142 let mut service_names: Vec<&String> = compose.services.keys().collect();
143 service_names.sort();
144 for service_name in service_names {
145 let svc = &compose.services[service_name];
146 let spec = translate_service(service_name, svc, &mut warnings)?;
147 specs.push(spec);
148 }
149
150 Ok(ImportResult { specs, warnings })
151}
152
153fn translate_service(
156 name: &str,
157 svc: &ComposeService,
158 warnings: &mut Vec<ImportWarning>,
159) -> Result<WorkloadSpec, ImportError> {
160 if svc.network_mode.as_deref() == Some("host") {
161 return Err(ImportError::HostNetwork { service: name.into() });
162 }
163
164 if let Some(mode) = &svc.network_mode {
165 if mode != "host" && mode != "default" && mode != "bridge" {
166 warnings.push(ImportWarning {
167 path: format!("services.{name}.network_mode"),
168 message: format!(
169 "network_mode={mode:?} ignored — yubaba runs every workload on the mesh"
170 ),
171 });
172 }
173 }
174
175 if svc.build.is_some() {
176 warnings.push(ImportWarning {
177 path: format!("services.{name}.build"),
178 message: "build: blocks are ignored. Build externally (CI) and provide an \
179 image: reference; see arch doc §\"What's deliberately not in the schema\""
180 .into(),
181 });
182 }
183
184 if svc.healthcheck.is_some() {
185 warnings.push(ImportWarning {
186 path: format!("services.{name}.healthcheck"),
187 message: "compose healthcheck not translated in V1 — re-author against \
188 WorkloadSpec.healthcheck (HttpGet / Exec / TcpConnect)"
189 .into(),
190 });
191 }
192
193 if !svc.networks.is_empty() {
194 warnings.push(ImportWarning {
195 path: format!("services.{name}.networks"),
196 message: format!(
197 "service-level network attachments ({}) flattened to the mesh — \
198 segmentation must be re-expressed via tier `allow_from`",
199 svc.networks.join(", ")
200 ),
201 });
202 }
203
204 let (mesh_name, mesh_warning) = sanitize_mesh_ident(name);
205 if let Some(message) = mesh_warning {
206 warnings.push(ImportWarning {
207 path: format!("services.{name}"),
208 message,
209 });
210 }
211
212 let image = svc
213 .image
214 .as_deref()
215 .ok_or_else(|| ImportError::MissingImage { service: name.into() })?;
216 let image = parse_image_ref(image).map_err(|reason| ImportError::UnpinnedImage {
217 service: name.into(),
218 image: image.into(),
219 reason,
220 })?;
221
222 let env = translate_env(name, &svc.environment, warnings);
223
224 let mesh_ports = translate_ports(name, &svc.ports, warnings);
225
226 let depends_on = svc
227 .depends_on
228 .as_ref()
229 .map(|d| d.iter_names().map(|n| MeshIdent(sanitize_mesh_ident(n).0)).collect())
230 .unwrap_or_default();
231
232 let (volumes, has_bind) = translate_volumes(name, &svc.volumes, warnings);
233
234 let tier_str = if has_bind { "infra" } else { "private" };
235 if has_bind {
236 warnings.push(ImportWarning {
237 path: format!("services.{name}.volumes"),
238 message: "bind volume(s) detected; spec auto-promoted to tier=\"infra\" so it \
239 passes shape validation. Hand-review whether infra is the right tier"
240 .into(),
241 });
242 }
243
244 let restart_policy = translate_restart(name, svc.restart.as_deref(), warnings);
245
246 let command = svc.command.as_ref().map(StringOrList::into_argv);
247 let entrypoint = svc.entrypoint.as_ref().map(StringOrList::into_argv);
248 let workdir = svc.working_dir.as_ref().map(PathBuf::from);
249
250 let spec = WorkloadSpec {
251 schema_version: SchemaVersion::V1,
252 name: mesh_name.clone(),
253 image,
254 tier: TierTag(tier_str.into()),
255 replicas: 1,
256 command,
257 entrypoint,
258 workdir,
259 user: svc.user.clone(),
260 env,
261 secrets: vec![],
262 volumes,
263 resources: ResourceLimits {
264 memory_mb: 256,
265 cpu_shares: 512,
266 ephemeral_storage_mb: 512,
267 },
268 depends_on,
269 healthcheck: None,
270 restart_policy,
271 stop_policy: StopPolicy {
272 signal: 15,
273 grace_period: Millis::from_secs(10),
274 },
275 expose: ExposeSpec {
276 mesh: MeshExpose {
277 identity: MeshIdent(mesh_name),
278 ports: mesh_ports,
279 allow_from: vec![],
280 },
281 public: None,
282 operator: None,
283 },
284 labels: HashMap::new(),
285 annotations: HashMap::new(),
286 };
287
288 Ok(spec)
289}
290
291fn sanitize_mesh_ident(name: &str) -> (String, Option<String>) {
296 let lowered = name.to_ascii_lowercase();
297 let sanitized: String = lowered
298 .chars()
299 .map(|c| if c == '_' { '-' } else { c })
300 .collect();
301 if sanitized != name {
302 let msg = format!(
303 "compose service name {name:?} rewritten to {sanitized:?} \
304 (mesh idents must match ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$)"
305 );
306 (sanitized, Some(msg))
307 } else {
308 (sanitized, None)
309 }
310}
311
312pub(crate) fn parse_image_ref(s: &str) -> Result<ImageRef, String> {
324 parse_pinned_image_ref(s)
325}
326
327pub(crate) fn parse_pinned_image_ref(s: &str) -> Result<ImageRef, String> {
335 let (head, dig_str) = s.split_once('@').ok_or_else(|| {
336 format!(
337 "image reference {s:?} must be digest-pinned (e.g. `repo:tag@sha256:<hex>`); \
338 bare-tag images are rejected — pin with @sha256:<digest>"
339 )
340 })?;
341
342 let hex = dig_str.strip_prefix("sha256:").ok_or_else(|| {
343 format!("image digest must start with `sha256:`, got {dig_str:?}")
344 })?;
345 if hex.is_empty() || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
346 return Err(format!("sha256 digest must be non-empty hex, got {hex:?}"));
347 }
348
349 let (head2, tag_opt) = split_repo_and_tag(head);
350 let tag = tag_opt.unwrap_or_else(|| "latest".into());
351 let (registry, repository) = split_registry_and_repo(head2);
352
353 Ok(ImageRef {
354 registry,
355 repository,
356 tag,
357 digest: format!("sha256:{hex}"),
358 })
359}
360
361fn split_repo_and_tag(s: &str) -> (&str, Option<String>) {
365 let last_slash = s.rfind('/');
366 let search_from = last_slash.map(|i| i + 1).unwrap_or(0);
367 if let Some(colon) = s[search_from..].find(':') {
368 let abs = search_from + colon;
369 let head = &s[..abs];
370 let tag = &s[abs + 1..];
371 (head, Some(tag.to_string()))
372 } else {
373 (s, None)
374 }
375}
376
377fn split_registry_and_repo(head: &str) -> (String, String) {
383 if let Some((first, rest)) = head.split_once('/') {
384 if first == "localhost" || first.contains('.') || first.contains(':') {
385 return (first.to_string(), rest.to_string());
386 }
387 }
388 let repo = if head.contains('/') {
389 head.to_string()
390 } else {
391 format!("library/{head}")
392 };
393 ("docker.io".into(), repo)
394}
395
396fn translate_env(
397 service: &str,
398 environment: &Option<EnvList>,
399 warnings: &mut Vec<ImportWarning>,
400) -> Vec<EnvVar> {
401 let Some(env) = environment else {
402 return Vec::new();
403 };
404 let mut out = Vec::new();
405 match env {
406 EnvList::List(items) => {
407 for (i, item) in items.iter().enumerate() {
408 if let Some((k, v)) = item.split_once('=') {
409 out.push(EnvVar {
410 name: k.into(),
411 value: EnvValue::Literal { value: v.into() },
412 });
413 } else {
414 warnings.push(ImportWarning {
415 path: format!("services.{service}.environment[{i}]"),
416 message: format!(
417 "{item:?} omits a value (compose pulls it from the host shell). \
418 Provide a literal value or use EnvValue::FromSecret"
419 ),
420 });
421 }
422 }
423 }
424 EnvList::Map(map) => {
425 let mut keys: Vec<&String> = map.keys().collect();
426 keys.sort();
427 for k in keys {
428 let v = &map[k];
429 let value = yaml_scalar_to_string(v);
430 out.push(EnvVar {
431 name: k.clone(),
432 value: EnvValue::Literal { value },
433 });
434 }
435 }
436 }
437 out
438}
439
440fn yaml_scalar_to_string(v: &serde_yaml::Value) -> String {
441 match v {
442 serde_yaml::Value::String(s) => s.clone(),
443 serde_yaml::Value::Number(n) => n.to_string(),
444 serde_yaml::Value::Bool(b) => b.to_string(),
445 serde_yaml::Value::Null => String::new(),
446 other => serde_yaml::to_string(other).unwrap_or_default().trim().to_string(),
447 }
448}
449
450fn translate_ports(
451 service: &str,
452 ports: &[PortSpec],
453 warnings: &mut Vec<ImportWarning>,
454) -> Vec<u16> {
455 let mut out = Vec::new();
456 for (i, p) in ports.iter().enumerate() {
457 match p.parse_container_port() {
458 Ok(port) => {
459 if !out.contains(&port) {
460 out.push(port);
461 }
462 }
463 Err(msg) => {
464 warnings.push(ImportWarning {
465 path: format!("services.{service}.ports[{i}]"),
466 message: msg,
467 });
468 }
469 }
470 }
471 out
472}
473
474fn translate_volumes(
475 service: &str,
476 items: &[String],
477 warnings: &mut Vec<ImportWarning>,
478) -> (Vec<VolumeMount>, bool) {
479 let mut out = Vec::new();
480 let mut has_bind = false;
481 for (i, raw) in items.iter().enumerate() {
482 let parts: Vec<&str> = raw.split(':').collect();
483 let (source, target, read_only) = match parts.as_slice() {
484 [target] => (None, *target, false),
485 [src, tgt] => (Some(*src), *tgt, false),
486 [src, tgt, mode] => (Some(*src), *tgt, mode.contains("ro")),
487 _ => {
488 warnings.push(ImportWarning {
489 path: format!("services.{service}.volumes[{i}]"),
490 message: format!("volume spec {raw:?} could not be parsed; skipped"),
491 });
492 continue;
493 }
494 };
495
496 let target = PathBuf::from(target);
497 let source = if let Some(src) = source {
498 if src.starts_with('/') || src.starts_with('.') || src.starts_with('~') {
499 has_bind = true;
500 VolumeSource::Bind {
501 host_path: PathBuf::from(src),
502 }
503 } else {
504 VolumeSource::Named { name: src.into() }
505 }
506 } else {
507 VolumeSource::Named {
508 name: format!("anon-{}-{}", service, i),
509 }
510 };
511
512 out.push(VolumeMount {
513 source,
514 target,
515 read_only,
516 });
517 }
518 (out, has_bind)
519}
520
521fn translate_restart(
522 service: &str,
523 restart: Option<&str>,
524 warnings: &mut Vec<ImportWarning>,
525) -> RestartPolicy {
526 match restart {
527 None => RestartPolicy::Always,
528 Some("always") => RestartPolicy::Always,
529 Some("no") => RestartPolicy::Never,
530 Some("unless-stopped") => {
531 warnings.push(ImportWarning {
532 path: format!("services.{service}.restart"),
533 message: "restart=unless-stopped translated to RestartPolicy::Always — \
534 yubaba has no manual-stop concept the policy can opt out of"
535 .into(),
536 });
537 RestartPolicy::Always
538 }
539 Some(other) if other.starts_with("on-failure") => RestartPolicy::OnFailure {
540 max_attempts: 5,
541 backoff: crate::BackoffPolicy {
542 initial_ms: 1000,
543 max_ms: 30_000,
544 multiplier: 2.0,
545 },
546 },
547 Some(other) => {
548 warnings.push(ImportWarning {
549 path: format!("services.{service}.restart"),
550 message: format!(
551 "unknown restart policy {other:?}; defaulted to RestartPolicy::Always"
552 ),
553 });
554 RestartPolicy::Always
555 }
556 }
557}
558
559#[derive(Debug, Deserialize)]
562struct ComposeFile {
563 #[serde(default)]
564 #[allow(dead_code)]
565 version: Option<String>,
566 #[serde(default)]
567 services: HashMap<String, ComposeService>,
568 #[serde(default)]
569 networks: HashMap<String, serde_yaml::Value>,
570 #[serde(default)]
571 #[allow(dead_code)]
572 volumes: HashMap<String, serde_yaml::Value>,
573}
574
575#[derive(Debug, Deserialize, Default)]
576struct ComposeService {
577 #[serde(default)]
578 image: Option<String>,
579 #[serde(default)]
580 build: Option<serde_yaml::Value>,
581 #[serde(default)]
582 command: Option<StringOrList>,
583 #[serde(default)]
584 entrypoint: Option<StringOrList>,
585 #[serde(default)]
586 environment: Option<EnvList>,
587 #[serde(default)]
588 ports: Vec<PortSpec>,
589 #[serde(default)]
590 depends_on: Option<DependsOn>,
591 #[serde(default)]
592 volumes: Vec<String>,
593 #[serde(default)]
594 network_mode: Option<String>,
595 #[serde(default)]
596 networks: Vec<String>,
597 #[serde(default)]
598 user: Option<String>,
599 #[serde(default)]
600 working_dir: Option<String>,
601 #[serde(default)]
602 restart: Option<String>,
603 #[serde(default)]
604 healthcheck: Option<serde_yaml::Value>,
605}
606
607#[derive(Debug, Deserialize)]
608#[serde(untagged)]
609enum StringOrList {
610 String(String),
611 List(Vec<String>),
612}
613
614impl StringOrList {
615 fn into_argv(&self) -> Vec<String> {
616 match self {
617 StringOrList::String(s) => shell_split(s),
618 StringOrList::List(v) => v.clone(),
619 }
620 }
621}
622
623fn shell_split(s: &str) -> Vec<String> {
627 s.split_whitespace().map(str::to_string).collect()
628}
629
630#[derive(Debug, Deserialize)]
631#[serde(untagged)]
632enum EnvList {
633 List(Vec<String>),
634 Map(HashMap<String, serde_yaml::Value>),
635}
636
637#[derive(Debug, Deserialize)]
638#[serde(untagged)]
639enum DependsOn {
640 List(Vec<String>),
641 Map(HashMap<String, serde_yaml::Value>),
642}
643
644impl DependsOn {
645 fn iter_names(&self) -> Box<dyn Iterator<Item = &str> + '_> {
646 match self {
647 DependsOn::List(v) => Box::new(v.iter().map(String::as_str)),
648 DependsOn::Map(m) => {
649 let mut keys: Vec<&String> = m.keys().collect();
650 keys.sort();
651 Box::new(keys.into_iter().map(String::as_str))
652 }
653 }
654 }
655}
656
657#[derive(Debug, Deserialize)]
660#[serde(untagged)]
661enum PortSpec {
662 Short(String),
663 Number(u16),
664 Long(LongPort),
665}
666
667#[derive(Debug, Deserialize)]
668struct LongPort {
669 target: u16,
670 #[serde(default)]
671 #[allow(dead_code)]
672 published: Option<serde_yaml::Value>,
673 #[serde(default)]
674 #[allow(dead_code)]
675 protocol: Option<String>,
676 #[serde(default)]
677 #[allow(dead_code)]
678 mode: Option<String>,
679}
680
681impl PortSpec {
682 fn parse_container_port(&self) -> Result<u16, String> {
683 match self {
684 PortSpec::Number(n) => Ok(*n),
685 PortSpec::Long(l) => Ok(l.target),
686 PortSpec::Short(s) => parse_short_port(s),
687 }
688 }
689}
690
691fn parse_short_port(s: &str) -> Result<u16, String> {
695 let no_proto = s.split('/').next().unwrap_or(s);
696 let segments: Vec<&str> = no_proto.split(':').collect();
697 let container = segments
698 .last()
699 .ok_or_else(|| format!("port spec {s:?} is empty"))?;
700 container
701 .parse::<u16>()
702 .map_err(|e| format!("port spec {s:?}: container-side port {container:?} not a u16 ({e})"))
703}
704
705#[cfg(test)]
706mod tests {
707 use super::*;
708
709 const PINNED_NGINX: &str =
710 "nginx:1.25@sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd";
711 const PINNED_GHCR: &str =
712 "ghcr.io/foo/bar:v1@sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd";
713 const PINNED_LOCALHOST: &str =
714 "localhost:5000/svc:dev@sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd";
715
716 #[test]
717 fn parse_image_ref_rejects_bare_tag() {
718 for bare in ["nginx", "nginx:1.25", "ghcr.io/foo/bar:v1"] {
719 let res = parse_image_ref(bare);
720 assert!(res.is_err(), "bare tag {bare:?} must reject");
721 }
722 }
723
724 #[test]
725 fn parse_image_ref_with_tag_and_digest() {
726 let r = parse_image_ref(PINNED_NGINX).expect("pinned parses");
727 assert_eq!(r.registry, "docker.io");
728 assert_eq!(r.repository, "library/nginx");
729 assert_eq!(r.tag, "1.25");
730 assert!(r.digest.starts_with("sha256:"));
731 }
732
733 #[test]
734 fn parse_image_ref_ghcr_pinned() {
735 let r = parse_image_ref(PINNED_GHCR).expect("pinned parses");
736 assert_eq!(r.registry, "ghcr.io");
737 assert_eq!(r.repository, "foo/bar");
738 assert_eq!(r.tag, "v1");
739 }
740
741 #[test]
742 fn parse_image_ref_localhost_port_pinned() {
743 let r = parse_image_ref(PINNED_LOCALHOST).expect("pinned parses");
744 assert_eq!(r.registry, "localhost:5000");
745 assert_eq!(r.repository, "svc");
746 assert_eq!(r.tag, "dev");
747 }
748
749 #[test]
750 fn parse_short_port_ok() {
751 assert_eq!(parse_short_port("80").unwrap(), 80);
752 assert_eq!(parse_short_port("8080:80").unwrap(), 80);
753 assert_eq!(parse_short_port("127.0.0.1:8080:80").unwrap(), 80);
754 assert_eq!(parse_short_port("8080:80/udp").unwrap(), 80);
755 }
756
757 #[test]
758 fn sanitize_mesh_ident_underscore_to_dash() {
759 let (n, w) = sanitize_mesh_ident("web_app");
760 assert_eq!(n, "web-app");
761 assert!(w.is_some());
762 }
763
764 #[test]
765 fn sanitize_mesh_ident_passthrough() {
766 let (n, w) = sanitize_mesh_ident("web-app");
767 assert_eq!(n, "web-app");
768 assert!(w.is_none());
769 }
770}