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, RestartPolicy,
33    ResourceLimits, SchemaVersion, StopPolicy, TierTag, VolumeMount, VolumeSource, WorkloadSpec,
34};
35
36// ── Public types ──────────────────────────────────────────────────────────────
37
38/// Output of [`import_compose`].
39///
40/// Serializable so the CLI can emit it as JSON and tests can compare against
41/// fixture snapshots without a bespoke equality codec.
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43pub struct ImportResult {
44    /// One spec per compose service. Order matches the `services:` map's
45    /// iteration order (sorted by service name for determinism).
46    pub specs: Vec<WorkloadSpec>,
47
48    /// Soft warnings for lossy translations. Each carries a compose path so
49    /// the operator can find the original block.
50    #[serde(default, skip_serializing_if = "Vec::is_empty")]
51    pub warnings: Vec<ImportWarning>,
52}
53
54/// A non-fatal lossy translation noted during import.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct ImportWarning {
57    /// Compose path for the affected block, e.g. `"services.web.build"`.
58    pub path: String,
59    /// Operator-facing explanation.
60    pub message: String,
61}
62
63/// A hard rejection during import.
64#[derive(Debug, Error, PartialEq)]
65pub enum ImportError {
66    /// The YAML failed to parse as a compose file.
67    #[error("compose YAML parse error: {0}")]
68    Parse(String),
69
70    /// The compose file has no `services:` block to translate.
71    #[error("compose file has no `services:` block")]
72    NoServices,
73
74    /// A service uses `network_mode: host`. Yubaba has no host-networking
75    /// escape hatch — every workload runs on the mesh. See arch doc
76    /// §"What's deliberately not in the schema".
77    #[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    /// A service has neither `image:` nor a usable image fallback. Yubaba
85    /// can't deploy without an image reference.
86    #[error("service {service:?}: no `image:` field — yubaba requires an image reference")]
87    MissingImage { service: String },
88
89    /// A service's `image:` reference lacks an `@sha256:<hex>` digest pin.
90    /// R438-T3 made digest-pinning structurally required; bare-tag references
91    /// like `node:20` no longer construct an [`ImageRef`]. The operator should
92    /// pin the digest in the compose file (`image: node:20@sha256:<hex>`).
93    #[error("service {service:?}: image {image:?} is not digest-pinned ({reason})")]
94    UnpinnedImage {
95        service: String,
96        image: String,
97        reason: String,
98    },
99}
100
101// ── Entry point ───────────────────────────────────────────────────────────────
102
103/// Parse a compose v3 YAML string into a set of [`WorkloadSpec`] values.
104///
105/// Multi-service composes produce one spec per service. Compose service names
106/// become mesh idents (with `_` rewritten to `-` and a warning). `depends_on`
107/// translates by compose service name → mesh ident.
108///
109/// Returns the first hard rejection ([`ImportError`]) on rejection paths;
110/// otherwise returns [`ImportResult`] with one entry per service.
111pub 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    // Top-level networks: yubaba flattens to one mesh, so any custom networks
122    // are lossy.
123    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
153// ── Translation ───────────────────────────────────────────────────────────────
154
155fn 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
291/// Sanitize a compose service name into a DNS-friendly mesh ident.
292///
293/// Returns `(sanitized, warning)`. The warning is `Some` when the input had
294/// to be modified — operators see it on stderr and in the JSON output.
295fn 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
312/// Parse a compose image reference into an [`ImageRef`]. The reference must
313/// be digest-pinned — bare-tag references like `nginx:1.25` are rejected per
314/// R438-T3. The accepted shape is `[registry/]repo[:tag]@sha256:<hex>`.
315///
316/// Examples (accepted):
317/// - `nginx:1.25@sha256:<hex>` → `docker.io / library/nginx : 1.25 @ sha256:<hex>`
318/// - `ghcr.io/foo/bar:v1@sha256:<hex>` → `ghcr.io / foo/bar : v1 @ sha256:<hex>`
319/// - `repo@sha256:<hex>` → defaults `tag = "latest"`
320///
321/// Examples (rejected):
322/// - `nginx`, `nginx:1.25`, `ghcr.io/foo/bar:v1` — no digest pin
323pub(crate) fn parse_image_ref(s: &str) -> Result<ImageRef, String> {
324    parse_pinned_image_ref(s)
325}
326
327/// Parse an image reference and **require** an `@sha256:<hex>` digest pin.
328/// Bare-tag references (e.g. `node:20`) are rejected — the digest is the only
329/// thing that survives an upstream tag retag and is what W164's reproducibility
330/// rule and W165's CI-fidelity rule both depend on.
331///
332/// Used by the string-form deserializer for [`ImageRef`]; the struct-form
333/// deserializer is unchanged (legacy `WorkloadSpec` configs keep working).
334pub(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
361/// Split a `repo:tag` or `repo` reference. Careful with `localhost:5000/foo` —
362/// the colon there is part of the registry, not a tag. We identify a tag as
363/// the colon AFTER the last slash.
364fn 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
377/// Split a `registry/repo` head into `(registry, repo)`. A first segment is
378/// treated as a registry hostname when it contains `.` or `:`, or equals
379/// `localhost`. Otherwise we default to docker.io and prepend `library/` for
380/// official images (compose `nginx` ⇒ docker.io/library/nginx, mirrors the
381/// docker CLI default).
382fn 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// ── Compose parse types ───────────────────────────────────────────────────────
560
561#[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
623/// Minimal whitespace tokeniser for compose's string-form `command:` / `entrypoint:`.
624/// Compose uses `/bin/sh -c` style strings; we don't honor quoting, just split on
625/// whitespace (the rare quoted-arg case stays a hand-edit).
626fn 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/// Compose's `ports:` list is heterogeneous: short strings ("8080:80"), bare
658/// numbers (8080), or long-form maps. We only need the container-side port.
659#[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
691/// Compose short-form port shapes: `"80"`, `"8080:80"`, `"127.0.0.1:8080:80"`,
692/// `"8080:80/udp"`. We extract the container-side port and ignore host-side
693/// binding (yubaba's mesh handles that).
694fn 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}