Skip to main content

workload_spec/
compose_import.rs

1//! One-way, lossy compose YAML → [`WorkloadSpec`] import shim.
2//!
3//! Best-effort translation of a docker-compose v3 file into a set of
4//! `WorkloadSpec` values, one per compose service. The output may need
5//! hand-editing — compose's expressiveness exceeds ours by design and we lose
6//! the parts we don't want. This shim is for one-time migration, not
7//! round-trip authoring.
8//!
9//! Lossy areas (each emits an [`ImportWarning`] keyed by compose path):
10//!
11//! - `network_mode: host` → rejected as [`ImportError::HostNetwork`].
12//! - `build:` blocks → ignored with warning ("build externally; provide an
13//!   `image:` reference").
14//! - Custom networks → flattened to the mesh; warns when topology can't be
15//!   preserved.
16//! - Bind volumes → echoed as warning that yubaba requires `tier = "infra"`
17//!   (the importer auto-promotes the spec's tier when bind mounts are
18//!   present so the result still passes shape validation).
19//! - Healthcheck blocks → noted as warning; not translated in V1 (compose's
20//!   syntax is rich enough to deserve its own pass).
21//!
22//! See `.yah/docs/architecture/A054-yah-workload-spec.md` §"Compose-import shim" for
23//! the design contract.
24
25use 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// ── Public types ──────────────────────────────────────────────────────────────
38
39/// Output of [`import_compose`].
40///
41/// Serializable so the CLI can emit it as JSON and tests can compare against
42/// fixture snapshots without a bespoke equality codec.
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
44pub struct ImportResult {
45    /// One spec per compose service. Order matches the `services:` map's
46    /// iteration order (sorted by service name for determinism).
47    pub specs: Vec<WorkloadSpec>,
48
49    /// Soft warnings for lossy translations. Each carries a compose path so
50    /// the operator can find the original block.
51    #[serde(default, skip_serializing_if = "Vec::is_empty")]
52    pub warnings: Vec<ImportWarning>,
53}
54
55/// A non-fatal lossy translation noted during import.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct ImportWarning {
58    /// Compose path for the affected block, e.g. `"services.web.build"`.
59    pub path: String,
60    /// Operator-facing explanation.
61    pub message: String,
62}
63
64/// A hard rejection during import.
65#[derive(Debug, Error, PartialEq)]
66pub enum ImportError {
67    /// The YAML failed to parse as a compose file.
68    #[error("compose YAML parse error: {0}")]
69    Parse(String),
70
71    /// The compose file has no `services:` block to translate.
72    #[error("compose file has no `services:` block")]
73    NoServices,
74
75    /// A service uses `network_mode: host`. Yubaba has no host-networking
76    /// escape hatch — every workload runs on the mesh. See arch doc
77    /// §"What's deliberately not in the schema".
78    #[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    /// A service has neither `image:` nor a usable image fallback. Yubaba
86    /// can't deploy without an image reference.
87    #[error("service {service:?}: no `image:` field — yubaba requires an image reference")]
88    MissingImage { service: String },
89
90    /// A service's `image:` reference lacks an `@sha256:<hex>` digest pin.
91    /// R438-T3 made digest-pinning structurally required; bare-tag references
92    /// like `node:20` no longer construct an [`ImageRef`]. The operator should
93    /// pin the digest in the compose file (`image: node:20@sha256:<hex>`).
94    #[error("service {service:?}: image {image:?} is not digest-pinned ({reason})")]
95    UnpinnedImage {
96        service: String,
97        image: String,
98        reason: String,
99    },
100}
101
102// ── Entry point ───────────────────────────────────────────────────────────────
103
104/// Parse a compose v3 YAML string into a set of [`WorkloadSpec`] values.
105///
106/// Multi-service composes produce one spec per service. Compose service names
107/// become mesh idents (with `_` rewritten to `-` and a warning). `depends_on`
108/// translates by compose service name → mesh ident.
109///
110/// Returns the first hard rejection ([`ImportError`]) on rejection paths;
111/// otherwise returns [`ImportResult`] with one entry per service.
112pub 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    // Top-level networks: yubaba flattens to one mesh, so any custom networks
123    // are lossy.
124    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
154// ── Translation ───────────────────────────────────────────────────────────────
155
156fn 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        // compose has no locality/supply concept, so an import can only ever
275        // produce the `anywhere` + `wait` edges `depends_on` already carries
276        // (R860-T1).
277        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                // docker-compose has no port-name concept, so an import can
289                // only ever produce unnamed numbers (R844-F17).
290                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
307/// Sanitize a compose service name into a DNS-friendly mesh ident.
308///
309/// Returns `(sanitized, warning)`. The warning is `Some` when the input had
310/// to be modified — operators see it on stderr and in the JSON output.
311fn 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
328/// Parse a compose image reference into an [`ImageRef`]. The reference must
329/// be digest-pinned — bare-tag references like `nginx:1.25` are rejected per
330/// R438-T3. The accepted shape is `[registry/]repo[:tag]@sha256:<hex>`.
331///
332/// Examples (accepted):
333/// - `nginx:1.25@sha256:<hex>` → `docker.io / library/nginx : 1.25 @ sha256:<hex>`
334/// - `ghcr.io/foo/bar:v1@sha256:<hex>` → `ghcr.io / foo/bar : v1 @ sha256:<hex>`
335/// - `repo@sha256:<hex>` → defaults `tag = "latest"`
336///
337/// Examples (rejected):
338/// - `nginx`, `nginx:1.25`, `ghcr.io/foo/bar:v1` — no digest pin
339pub(crate) fn parse_image_ref(s: &str) -> Result<ImageRef, String> {
340    parse_pinned_image_ref(s)
341}
342
343/// Parse an image reference and **require** an `@sha256:<hex>` digest pin.
344/// Bare-tag references (e.g. `node:20`) are rejected — the digest is the only
345/// thing that survives an upstream tag retag and is what W164's reproducibility
346/// rule and W165's CI-fidelity rule both depend on.
347///
348/// Used by the string-form deserializer for [`ImageRef`]; the struct-form
349/// deserializer is unchanged (legacy `WorkloadSpec` configs keep working).
350pub(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
377/// Split a `repo:tag` or `repo` reference. Careful with `localhost:5000/foo` —
378/// the colon there is part of the registry, not a tag. We identify a tag as
379/// the colon AFTER the last slash.
380fn 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
393/// Split a `registry/repo` head into `(registry, repo)`. A first segment is
394/// treated as a registry hostname when it contains `.` or `:`, or equals
395/// `localhost`. Otherwise we default to docker.io and prepend `library/` for
396/// official images (compose `nginx` ⇒ docker.io/library/nginx, mirrors the
397/// docker CLI default).
398fn 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// ── Compose parse types ───────────────────────────────────────────────────────
577
578#[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
640/// Minimal whitespace tokeniser for compose's string-form `command:` / `entrypoint:`.
641/// Compose uses `/bin/sh -c` style strings; we don't honor quoting, just split on
642/// whitespace (the rare quoted-arg case stays a hand-edit).
643fn 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/// Compose's `ports:` list is heterogeneous: short strings ("8080:80"), bare
675/// numbers (8080), or long-form maps. We only need the container-side port.
676#[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
708/// Compose short-form port shapes: `"80"`, `"8080:80"`, `"127.0.0.1:8080:80"`,
709/// `"8080:80/udp"`. We extract the container-side port and ignore host-side
710/// binding (yubaba's mesh handles that).
711fn 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}