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