Skip to main content

lightshuttle_spec/
spec.rs

1//! Self-contained container specification, derived from a manifest
2//! resource declaration.
3//!
4//! This module contains the resolved types consumed by
5//! `lightshuttle-runtime` and `lightshuttle-export`, together with the
6//! private resolution helpers that apply v0 defaults. The public entry
7//! point is [`from_resource`].
8
9use std::collections::HashMap;
10use std::time::Duration;
11
12use indexmap::IndexMap;
13use lightshuttle_manifest::{
14    Command, ContainerConfig, DockerfileConfig, Healthcheck, PortMapping, PostgresConfig,
15    RedisConfig, ResourceKind, Volume,
16};
17
18use crate::error::{Result, SpecError};
19
20/// Key/value properties that a managed resource exposes to its
21/// dependents.
22///
23/// The map is ordered by insertion order (backed by [`indexmap::IndexMap`])
24/// so that export serializers produce deterministic output.
25///
26/// # Key conventions (manifest-v0)
27///
28/// | Resource kind | Available keys |
29/// |---|---|
30/// | `postgres` | `host`, `port`, `database`, `user`, `password`, `url` |
31/// | `redis` | `host`, `port`, `password`, `url` |
32/// | `container` / `dockerfile` | `host`, `ports` (comma-separated list) |
33///
34/// These keys are surfaced at runtime as `LSH_<RESOURCE>_<KEY>` env
35/// vars and substituted into `${resources.<name>.<key>}` expressions
36/// in sibling resource declarations.
37///
38/// # Example
39///
40/// ```rust
41/// use lightshuttle_spec::ResourceOutputs;
42///
43/// let mut outputs = ResourceOutputs::new();
44/// outputs.insert("host".into(), "myproject_db".into());
45/// outputs.insert("port".into(), "5432".into());
46///
47/// assert_eq!(outputs["host"], "myproject_db");
48/// ```
49pub type ResourceOutputs = IndexMap<String, String>;
50
51/// A [`ContainerSpec`] bundled with the [`ResourceOutputs`] the
52/// resource exposes to its dependents at runtime.
53///
54/// Produced by [`from_resource`] and consumed by both
55/// `lightshuttle-runtime` (to launch the container) and
56/// `lightshuttle-export` (to emit a Compose/Helm artifact).
57///
58/// # Example
59///
60/// ```rust,no_run
61/// use lightshuttle_manifest::{PostgresConfig, ResourceKind};
62/// use lightshuttle_spec::from_resource;
63///
64/// // Resolve a postgres resource declared in a manifest.
65/// let kind = ResourceKind::Postgres(PostgresConfig::default());
66/// let resolved = from_resource("myproject", "db", &kind).unwrap();
67///
68/// // The spec carries the container description.
69/// assert_eq!(resolved.spec.resource, "db");
70/// // The outputs expose the connection URL to dependents.
71/// assert!(resolved.outputs.contains_key("url"));
72/// ```
73#[derive(Debug, Clone)]
74pub struct ResolvedResource {
75    /// Container specification consumed by the runtime and the export
76    /// pipeline to describe the container to launch.
77    pub spec: ContainerSpec,
78    /// Key/value properties exposed to dependents, resolved into
79    /// `LSH_*` env vars and substituted into
80    /// `${resources.<name>.<property>}` expressions.
81    pub outputs: ResourceOutputs,
82}
83
84const DEFAULT_PG_VERSION: &str = "16";
85const DEFAULT_PG_USER: &str = "postgres";
86const DEFAULT_PG_PORT: u16 = 5432;
87const DEFAULT_REDIS_VERSION: &str = "7";
88const DEFAULT_REDIS_PORT: u16 = 6379;
89const HEALTHCHECK_DEFAULT_INTERVAL: Duration = Duration::from_secs(5);
90const HEALTHCHECK_DEFAULT_TIMEOUT: Duration = Duration::from_secs(3);
91const HEALTHCHECK_DEFAULT_RETRIES: u32 = 5;
92const HEALTHCHECK_DEFAULT_START_PERIOD: Duration = Duration::from_secs(5);
93
94/// Self-contained description of a container to start, derived from a
95/// manifest resource declaration.
96///
97/// All fields are fully resolved: defaults have been applied, optional
98/// values materialised, and duration strings parsed. Consumers (the
99/// runtime and the export pipeline) can use this struct directly
100/// without any further resolution.
101///
102/// Produced by [`from_resource`] when resolving a manifest, or built
103/// directly via [`ContainerSpec::new`] by consumers that synthesize a
104/// spec themselves.
105#[non_exhaustive]
106#[derive(Debug, Clone)]
107pub struct ContainerSpec {
108    /// Container name, of the form `<project>_<resource>`.
109    ///
110    /// Used as the actual container name when starting the container
111    /// and as the DNS hostname reachable by other containers in the
112    /// same network.
113    pub name: String,
114    /// Project name as declared in the manifest.
115    ///
116    /// Attached as a container label so that `lightshuttle ps` and
117    /// `lightshuttle down` can filter by project.
118    pub project: String,
119    /// Resource name as declared in the manifest.
120    ///
121    /// Attached as a container label so that the CLI can address a
122    /// single resource by name within a project.
123    pub resource: String,
124    /// How the container image is obtained: pulled from a registry or
125    /// built locally from a Dockerfile.
126    pub image: ImageSource,
127    /// Environment variables to inject into the container at startup.
128    pub env: HashMap<String, String>,
129    /// Host-to-container port bindings to publish.
130    pub ports: Vec<PortBinding>,
131    /// Volume and bind-mount mappings to attach.
132    pub volumes: Vec<VolumeBinding>,
133    /// Optional override for the image `ENTRYPOINT`, the executable the
134    /// container runs.
135    ///
136    /// A `Command::Single` string is wrapped as `["sh", "-c", ...]`;
137    /// a `Command::Args` list is passed through as-is. `None` leaves the
138    /// image entrypoint in place.
139    pub entrypoint: Option<Vec<String>>,
140    /// Optional command that overrides the image default `CMD`.
141    ///
142    /// A `Command::Single` string is wrapped as `["sh", "-c", ...]`;
143    /// a `Command::Args` list is passed through as-is.
144    pub command: Option<Vec<String>>,
145    /// Optional healthcheck. For `postgres` and `redis`, a sensible
146    /// default is injected when the manifest omits one.
147    pub healthcheck: Option<HealthcheckSpec>,
148    /// Optional working directory override inside the container.
149    pub working_dir: Option<String>,
150}
151
152impl ContainerSpec {
153    /// Builds a [`ContainerSpec`] with no env, ports, volumes, entrypoint,
154    /// command, healthcheck or working directory.
155    ///
156    /// Intended for consumers that synthesize a spec directly rather than
157    /// resolving one from a manifest via [`from_resource`]. Callers set the
158    /// remaining fields as needed.
159    #[must_use]
160    pub fn new(name: String, project: String, resource: String, image: ImageSource) -> Self {
161        Self {
162            name,
163            project,
164            resource,
165            image,
166            env: HashMap::new(),
167            ports: Vec::new(),
168            volumes: Vec::new(),
169            entrypoint: None,
170            command: None,
171            healthcheck: None,
172            working_dir: None,
173        }
174    }
175}
176
177/// How the container image is obtained.
178///
179/// Produced during resolution and consumed by the runtime (to decide
180/// whether to call `docker pull` or `docker build`) and by the export
181/// pipeline (to emit the correct Compose `image` or `build` stanza).
182///
183/// # Example
184///
185/// ```rust
186/// use lightshuttle_spec::ImageSource;
187/// use std::collections::HashMap;
188///
189/// // A pre-built image pulled from a registry.
190/// let pulled = ImageSource::Pull("postgres:16-alpine".into());
191///
192/// // An image built locally from a Dockerfile.
193/// let built = ImageSource::Build {
194///     context: ".".into(),
195///     dockerfile: "Dockerfile".into(),
196///     build_args: HashMap::new(),
197///     target: None,
198///     tag: "lightshuttle/myproject_app:dev".into(),
199/// };
200/// ```
201#[derive(Debug, Clone)]
202pub enum ImageSource {
203    /// Pull the named image reference from a registry.
204    ///
205    /// The inner `String` is a fully qualified image reference such as
206    /// `postgres:16-alpine` or `ghcr.io/org/image:tag`.
207    Pull(String),
208    /// Build the image locally from a Dockerfile.
209    ///
210    /// The runtime calls `docker build` (or equivalent) with these
211    /// parameters before starting the container.
212    Build {
213        /// Build context directory, relative to the manifest file.
214        context: String,
215        /// Dockerfile path within `context`.
216        dockerfile: String,
217        /// Build-time `--build-arg` key/value pairs.
218        build_args: HashMap<String, String>,
219        /// Optional multi-stage `--target` stage name.
220        target: Option<String>,
221        /// Tag applied to the resulting image (e.g.
222        /// `lightshuttle/<project>_<resource>:dev`).
223        tag: String,
224    },
225}
226
227/// Host-to-container port binding resolved from the manifest.
228///
229/// Corresponds to the `-p` / `--publish` Docker flag. The manifest
230/// supports three forms:
231///
232/// | Manifest form | Result |
233/// |---|---|
234/// | `8080` (short) | `container_port = 8080`, `host_port = 8080`, no address |
235/// | `"8080:80"` | `host_port = 8080`, `container_port = 80` |
236/// | `"127.0.0.1:8080:80"` | as above plus `host_address = Some("127.0.0.1")` |
237///
238/// # Example
239///
240/// ```rust
241/// use lightshuttle_spec::PortBinding;
242///
243/// let binding = PortBinding {
244///     container_port: 80,
245///     host_address: Some("127.0.0.1".into()),
246///     host_port: 8080,
247/// };
248/// assert_eq!(binding.host_port, 8080);
249/// ```
250#[derive(Debug, Clone, PartialEq, Eq)]
251pub struct PortBinding {
252    /// Port exposed by the container.
253    pub container_port: u16,
254    /// Optional host interface to bind to. When `None` the runtime
255    /// binds to all interfaces (`0.0.0.0`).
256    pub host_address: Option<String>,
257    /// Port published on the host. Mirrors `container_port` when the
258    /// short integer form is used in the manifest.
259    pub host_port: u16,
260}
261
262/// Volume or bind-mount mapping resolved from the manifest.
263///
264/// Covers the three forms supported by the manifest `volumes` list:
265/// named volumes (`data:/var/lib/data`), host bind-mounts
266/// (`./src:/app` or `/abs/path:/app`), and the implicit anonymous
267/// volume injected for `postgres` and `redis` when no explicit volume
268/// is declared.
269///
270/// # Example
271///
272/// ```rust
273/// use lightshuttle_spec::{VolumeBinding, VolumeSource};
274///
275/// let named = VolumeBinding {
276///     source: VolumeSource::Named("pgdata".into()),
277///     target: "/var/lib/postgresql/data".into(),
278/// };
279/// let bind = VolumeBinding {
280///     source: VolumeSource::HostPath("./src".into()),
281///     target: "/app".into(),
282/// };
283/// ```
284#[derive(Debug, Clone, PartialEq, Eq)]
285pub struct VolumeBinding {
286    /// Origin of the volume content.
287    pub source: VolumeSource,
288    /// Absolute path of the mount point inside the container.
289    pub target: String,
290}
291
292/// Origin of the content mounted into the container.
293///
294/// Determines how the runtime creates the volume and whether it
295/// survives container removal.
296#[derive(Debug, Clone, PartialEq, Eq)]
297pub enum VolumeSource {
298    /// Bind-mount from a host path (starts with `.` or `/` in the
299    /// manifest).
300    ///
301    /// The inner `String` is the path as written in the manifest,
302    /// relative or absolute.
303    HostPath(String),
304    /// Named volume managed by the container runtime.
305    ///
306    /// The inner `String` is the volume name (no `.` or `/` prefix).
307    /// Template-unsafe characters (`{`, `}`) are rejected during
308    /// resolution.
309    Named(String),
310    /// Anonymous volume whose lifetime is tied to the container.
311    ///
312    /// Injected automatically for `postgres` and `redis` when the
313    /// manifest sets `volume: true` or omits the field entirely.
314    Anonymous,
315}
316
317/// Healthcheck resolved from the manifest, with duration strings
318/// already parsed into [`std::time::Duration`] values.
319///
320/// For `postgres` resources the default test is
321/// `["CMD", "pg_isready", "-U", <user>]`. For `redis` it is
322/// `["CMD", "redis-cli", "ping"]`. Generic `container` and
323/// `dockerfile` resources have no default: the manifest must provide
324/// one explicitly if a healthcheck is needed.
325///
326/// Default timing values when the manifest omits them:
327/// `interval = 5s`, `timeout = 3s`, `retries = 5`,
328/// `start_period = 5s`.
329///
330/// # Example
331///
332/// ```rust
333/// use lightshuttle_spec::HealthcheckSpec;
334/// use std::time::Duration;
335///
336/// let hc = HealthcheckSpec {
337///     test: vec!["CMD".into(), "pg_isready".into(), "-U".into(), "postgres".into()],
338///     interval: Duration::from_secs(5),
339///     timeout: Duration::from_secs(3),
340///     retries: 5,
341///     start_period: Duration::from_secs(5),
342/// };
343/// assert_eq!(hc.retries, 5);
344/// ```
345#[derive(Debug, Clone, PartialEq, Eq)]
346pub struct HealthcheckSpec {
347    /// Command the runtime runs to probe container health.
348    ///
349    /// The first element is typically `"CMD"` or `"CMD-SHELL"` as per
350    /// the Docker healthcheck specification.
351    pub test: Vec<String>,
352    /// Time between consecutive health checks.
353    pub interval: Duration,
354    /// Maximum wall-clock time a single check invocation may take.
355    pub timeout: Duration,
356    /// Number of consecutive failures before the container is
357    /// considered unhealthy.
358    pub retries: u32,
359    /// Grace period after container start before health checks begin.
360    pub start_period: Duration,
361}
362
363/// Resolve a manifest resource declaration into a [`ResolvedResource`].
364///
365/// This is the single entry point into `lightshuttle-spec`. It
366/// dispatches on `kind` and applies all v0 defaults:
367///
368/// - **Image**: falls back to the official Alpine image for the
369///   declared version (e.g. `postgres:16-alpine`, `redis:7-alpine`).
370/// - **Database name** (`postgres`): defaults to `resource_name`.
371/// - **User** (`postgres`): defaults to `"postgres"`.
372/// - **Password**: generated with a 24-character CSPRNG-backed
373///   alphabet when absent from the manifest.
374/// - **Ports**: uses the canonical default port for `postgres`
375///   (5432) and `redis` (6379).
376/// - **Healthcheck**: injects `pg_isready` / `redis-cli ping` when
377///   the manifest omits a healthcheck for managed services.
378///
379/// The container name is always `<project>_<resource_name>` and also
380/// serves as the DNS hostname inside the project network.
381///
382/// # Errors
383///
384/// Returns [`crate::SpecError::InvalidSpec`] when a port mapping,
385/// volume string, or duration in the manifest is syntactically invalid.
386///
387/// # Example
388///
389/// ```rust,no_run
390/// use lightshuttle_manifest::{PostgresConfig, ResourceKind};
391/// use lightshuttle_spec::from_resource;
392///
393/// let kind = ResourceKind::Postgres(PostgresConfig::default());
394/// let resolved = from_resource("acme", "db", &kind).unwrap();
395///
396/// // Container name follows the `<project>_<resource>` convention.
397/// assert_eq!(resolved.spec.name, "acme_db");
398/// // A connection URL is always present for postgres.
399/// assert!(resolved.outputs["url"].starts_with("postgres://"));
400/// ```
401pub fn from_resource(
402    project: &str,
403    resource_name: &str,
404    kind: &ResourceKind,
405) -> Result<ResolvedResource> {
406    let name = format!("{project}_{resource_name}");
407    match kind {
408        ResourceKind::Postgres(c) => spec_postgres(name, project, resource_name, c),
409        ResourceKind::Redis(c) => spec_redis(name, project, resource_name, c),
410        ResourceKind::Container(c) => spec_container(name, project, resource_name, c),
411        ResourceKind::Dockerfile(c) => spec_dockerfile(name, project, resource_name, c),
412    }
413}
414
415#[allow(clippy::needless_pass_by_value)]
416fn spec_postgres(
417    name: String,
418    project: &str,
419    resource_name: &str,
420    c: &PostgresConfig,
421) -> Result<ResolvedResource> {
422    let version = c.version.as_deref().unwrap_or(DEFAULT_PG_VERSION);
423    let image = c
424        .image
425        .clone()
426        .unwrap_or_else(|| format!("postgres:{version}-alpine"));
427    let database = c
428        .database
429        .clone()
430        .unwrap_or_else(|| resource_name.to_owned());
431    let user = c.user.clone().unwrap_or_else(|| DEFAULT_PG_USER.to_owned());
432    let password = c.password.clone().unwrap_or_else(generate_random_password);
433    let port = c.port.unwrap_or(DEFAULT_PG_PORT);
434
435    let mut env = HashMap::new();
436    env.insert("POSTGRES_DB".to_owned(), database);
437    env.insert("POSTGRES_USER".to_owned(), user.clone());
438    env.insert("POSTGRES_PASSWORD".to_owned(), password);
439
440    let ports = vec![PortBinding {
441        container_port: port,
442        host_address: None,
443        host_port: port,
444    }];
445
446    let volumes = volume_to_binding(c.volume.as_ref(), "/var/lib/postgresql/data");
447
448    let healthcheck = c
449        .healthcheck
450        .as_ref()
451        .map(parse_healthcheck)
452        .transpose()?
453        .or_else(|| {
454            Some(HealthcheckSpec {
455                test: vec![
456                    "CMD".to_owned(),
457                    "pg_isready".to_owned(),
458                    "-U".to_owned(),
459                    user,
460                ],
461                interval: HEALTHCHECK_DEFAULT_INTERVAL,
462                timeout: HEALTHCHECK_DEFAULT_TIMEOUT,
463                retries: HEALTHCHECK_DEFAULT_RETRIES,
464                start_period: HEALTHCHECK_DEFAULT_START_PERIOD,
465            })
466        });
467
468    let spec = ContainerSpec {
469        name: name.clone(),
470        project: project.to_owned(),
471        resource: resource_name.to_owned(),
472        image: ImageSource::Pull(image),
473        env: env.clone(),
474        ports,
475        volumes,
476        entrypoint: None,
477        command: None,
478        healthcheck,
479        working_dir: None,
480    };
481
482    let mut outputs = ResourceOutputs::new();
483    outputs.insert("host".to_owned(), name.clone());
484    outputs.insert("port".to_owned(), port.to_string());
485    let user_out = env.get("POSTGRES_USER").cloned().unwrap_or_default();
486    let pwd_out = env.get("POSTGRES_PASSWORD").cloned().unwrap_or_default();
487    let db_out = env.get("POSTGRES_DB").cloned().unwrap_or_default();
488    outputs.insert("user".to_owned(), user_out.clone());
489    outputs.insert("password".to_owned(), pwd_out.clone());
490    outputs.insert("database".to_owned(), db_out.clone());
491    outputs.insert(
492        "url".to_owned(),
493        format!("postgres://{user_out}:{pwd_out}@{name}:{port}/{db_out}"),
494    );
495
496    Ok(ResolvedResource { spec, outputs })
497}
498
499#[allow(clippy::needless_pass_by_value)]
500fn spec_redis(
501    name: String,
502    project: &str,
503    resource_name: &str,
504    c: &RedisConfig,
505) -> Result<ResolvedResource> {
506    let version = c.version.as_deref().unwrap_or(DEFAULT_REDIS_VERSION);
507    let image = c
508        .image
509        .clone()
510        .unwrap_or_else(|| format!("redis:{version}-alpine"));
511    let port = c.port.unwrap_or(DEFAULT_REDIS_PORT);
512
513    let mut command = vec!["redis-server".to_owned()];
514    if let Some(password) = c.password.as_deref()
515        && !password.is_empty()
516    {
517        command.push("--requirepass".to_owned());
518        command.push(password.to_owned());
519    }
520
521    let ports = vec![PortBinding {
522        container_port: port,
523        host_address: None,
524        host_port: port,
525    }];
526
527    let volumes = volume_to_binding(c.volume.as_ref(), "/data");
528
529    let healthcheck = c
530        .healthcheck
531        .as_ref()
532        .map(parse_healthcheck)
533        .transpose()?
534        .or_else(|| {
535            Some(HealthcheckSpec {
536                test: vec!["CMD".to_owned(), "redis-cli".to_owned(), "ping".to_owned()],
537                interval: HEALTHCHECK_DEFAULT_INTERVAL,
538                timeout: HEALTHCHECK_DEFAULT_TIMEOUT,
539                retries: HEALTHCHECK_DEFAULT_RETRIES,
540                start_period: HEALTHCHECK_DEFAULT_START_PERIOD,
541            })
542        });
543
544    let password_out = c.password.clone().unwrap_or_default();
545    let spec = ContainerSpec {
546        name: name.clone(),
547        project: project.to_owned(),
548        resource: resource_name.to_owned(),
549        image: ImageSource::Pull(image),
550        env: HashMap::new(),
551        ports,
552        volumes,
553        entrypoint: None,
554        command: Some(command),
555        healthcheck,
556        working_dir: None,
557    };
558
559    let mut outputs = ResourceOutputs::new();
560    outputs.insert("host".to_owned(), name.clone());
561    outputs.insert("port".to_owned(), port.to_string());
562    outputs.insert("password".to_owned(), password_out.clone());
563    let url = if password_out.is_empty() {
564        format!("redis://{name}:{port}")
565    } else {
566        format!("redis://:{password_out}@{name}:{port}")
567    };
568    outputs.insert("url".to_owned(), url);
569
570    Ok(ResolvedResource { spec, outputs })
571}
572
573#[allow(clippy::needless_pass_by_value)]
574fn spec_container(
575    name: String,
576    project: &str,
577    resource_name: &str,
578    c: &ContainerConfig,
579) -> Result<ResolvedResource> {
580    let env: HashMap<String, String> = c.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
581
582    let ports = c
583        .ports
584        .iter()
585        .map(parse_port_mapping)
586        .collect::<Result<Vec<_>>>()?;
587    let volumes = c
588        .volumes
589        .iter()
590        .map(|s| parse_volume_string(s))
591        .collect::<Result<Vec<_>>>()?;
592    let entrypoint = c.entrypoint.as_ref().map(parse_command);
593    let command = c
594        .command
595        .as_ref()
596        .map(parse_command)
597        .filter(|cmd| !cmd.is_empty());
598    let healthcheck = c.healthcheck.as_ref().map(parse_healthcheck).transpose()?;
599
600    let ports_csv: String = ports
601        .iter()
602        .map(|p| p.container_port.to_string())
603        .collect::<Vec<_>>()
604        .join(",");
605    let spec = ContainerSpec {
606        name: name.clone(),
607        project: project.to_owned(),
608        resource: resource_name.to_owned(),
609        image: ImageSource::Pull(c.image.clone()),
610        env,
611        ports,
612        volumes,
613        entrypoint,
614        command,
615        healthcheck,
616        working_dir: c.working_dir.clone(),
617    };
618
619    let mut outputs = ResourceOutputs::new();
620    outputs.insert("host".to_owned(), name);
621    outputs.insert("ports".to_owned(), ports_csv);
622
623    Ok(ResolvedResource { spec, outputs })
624}
625
626#[allow(clippy::needless_pass_by_value)]
627fn spec_dockerfile(
628    name: String,
629    project: &str,
630    resource_name: &str,
631    c: &DockerfileConfig,
632) -> Result<ResolvedResource> {
633    let tag = format!("lightshuttle/{name}:dev");
634
635    let env: HashMap<String, String> = c.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
636
637    let build_args: HashMap<String, String> = c
638        .build_args
639        .iter()
640        .map(|(k, v)| (k.clone(), v.clone()))
641        .collect();
642
643    let ports = c
644        .ports
645        .iter()
646        .map(parse_port_mapping)
647        .collect::<Result<Vec<_>>>()?;
648    let volumes = c
649        .volumes
650        .iter()
651        .map(|s| parse_volume_string(s))
652        .collect::<Result<Vec<_>>>()?;
653    let entrypoint = c.entrypoint.as_ref().map(parse_command);
654    let command = c
655        .command
656        .as_ref()
657        .map(parse_command)
658        .filter(|cmd| !cmd.is_empty());
659    let healthcheck = c.healthcheck.as_ref().map(parse_healthcheck).transpose()?;
660
661    let ports_csv: String = ports
662        .iter()
663        .map(|p| p.container_port.to_string())
664        .collect::<Vec<_>>()
665        .join(",");
666    let spec = ContainerSpec {
667        name: name.clone(),
668        project: project.to_owned(),
669        resource: resource_name.to_owned(),
670        image: ImageSource::Build {
671            context: c.context.clone(),
672            dockerfile: c.dockerfile.clone(),
673            build_args,
674            target: c.target.clone(),
675            tag,
676        },
677        env,
678        ports,
679        volumes,
680        entrypoint,
681        command,
682        healthcheck,
683        working_dir: c.working_dir.clone(),
684    };
685
686    let mut outputs = ResourceOutputs::new();
687    outputs.insert("host".to_owned(), name);
688    outputs.insert("ports".to_owned(), ports_csv);
689
690    Ok(ResolvedResource { spec, outputs })
691}
692
693fn volume_to_binding(volume: Option<&Volume>, target: &str) -> Vec<VolumeBinding> {
694    match volume {
695        None | Some(Volume::Boolean(true)) => vec![VolumeBinding {
696            source: VolumeSource::Anonymous,
697            target: target.to_owned(),
698        }],
699        Some(Volume::Boolean(false)) => Vec::new(),
700        Some(Volume::Named(name)) => vec![VolumeBinding {
701            source: VolumeSource::Named(name.clone()),
702            target: target.to_owned(),
703        }],
704    }
705}
706
707fn parse_port_mapping(mapping: &PortMapping) -> Result<PortBinding> {
708    match mapping {
709        PortMapping::Container(port) => Ok(PortBinding {
710            container_port: *port,
711            host_address: None,
712            host_port: *port,
713        }),
714        PortMapping::Mapping(s) => parse_port_string(s),
715    }
716}
717
718fn parse_port_string(input: &str) -> Result<PortBinding> {
719    let parts: Vec<&str> = input.split(':').collect();
720    match parts.as_slice() {
721        [host_port, container_port] => {
722            let host_port: u16 = host_port
723                .parse()
724                .map_err(|_| SpecError::InvalidSpec(format!("invalid host port `{host_port}`")))?;
725            let container_port: u16 = container_port.parse().map_err(|_| {
726                SpecError::InvalidSpec(format!("invalid container port `{container_port}`"))
727            })?;
728            Ok(PortBinding {
729                container_port,
730                host_address: None,
731                host_port,
732            })
733        }
734        [host_address, host_port, container_port] => {
735            let host_port: u16 = host_port
736                .parse()
737                .map_err(|_| SpecError::InvalidSpec(format!("invalid host port `{host_port}`")))?;
738            let container_port: u16 = container_port.parse().map_err(|_| {
739                SpecError::InvalidSpec(format!("invalid container port `{container_port}`"))
740            })?;
741            Ok(PortBinding {
742                container_port,
743                host_address: Some((*host_address).to_owned()),
744                host_port,
745            })
746        }
747        _ => Err(SpecError::InvalidSpec(format!(
748            "invalid port mapping `{input}`"
749        ))),
750    }
751}
752
753fn parse_volume_string(input: &str) -> Result<VolumeBinding> {
754    let (source, target) = input.split_once(':').ok_or_else(|| {
755        SpecError::InvalidSpec(format!(
756            "invalid volume mapping `{input}`: expected `src:target`"
757        ))
758    })?;
759    let source = if source.starts_with('.') || source.starts_with('/') {
760        VolumeSource::HostPath(source.to_owned())
761    } else {
762        if source.contains(['{', '}']) {
763            return Err(SpecError::InvalidSpec(format!(
764                "volume name `{source}` must not contain '{{' or '}}': unsafe in export templates"
765            )));
766        }
767        VolumeSource::Named(source.to_owned())
768    };
769    Ok(VolumeBinding {
770        source,
771        target: target.to_owned(),
772    })
773}
774
775fn parse_command(command: &Command) -> Vec<String> {
776    match command {
777        Command::Single(s) => vec!["sh".to_owned(), "-c".to_owned(), s.clone()],
778        Command::Args(args) => args.clone(),
779    }
780}
781
782fn parse_healthcheck(hc: &Healthcheck) -> Result<HealthcheckSpec> {
783    Ok(HealthcheckSpec {
784        test: hc.test.clone(),
785        interval: parse_duration(&hc.interval)?,
786        timeout: parse_duration(&hc.timeout)?,
787        retries: hc.retries,
788        start_period: parse_duration(&hc.start_period)?,
789    })
790}
791
792fn parse_duration(input: &str) -> Result<Duration> {
793    let trimmed = input.trim();
794    let (digits, unit) = split_duration(trimmed)
795        .ok_or_else(|| SpecError::InvalidSpec(format!("invalid duration `{input}`")))?;
796    let value: f64 = digits
797        .parse()
798        .map_err(|_| SpecError::InvalidSpec(format!("invalid duration `{input}`")))?;
799    let nanos = match unit {
800        "ns" => value,
801        "us" => value * 1_000.0,
802        "ms" => value * 1_000_000.0,
803        "s" => value * 1_000_000_000.0,
804        "m" => value * 60.0 * 1_000_000_000.0,
805        "h" => value * 3_600.0 * 1_000_000_000.0,
806        _ => {
807            return Err(SpecError::InvalidSpec(format!(
808                "invalid duration unit `{unit}`"
809            )));
810        }
811    };
812    if nanos.is_sign_negative() || !nanos.is_finite() {
813        return Err(SpecError::InvalidSpec(format!(
814            "invalid duration `{input}`"
815        )));
816    }
817    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
818    Ok(Duration::from_nanos(nanos as u64))
819}
820
821fn split_duration(input: &str) -> Option<(&str, &str)> {
822    let bytes = input.as_bytes();
823    let mut idx = 0;
824    while idx < bytes.len() && (bytes[idx].is_ascii_digit() || bytes[idx] == b'.') {
825        idx += 1;
826    }
827    if idx == 0 || idx == bytes.len() {
828        return None;
829    }
830    Some((&input[..idx], &input[idx..]))
831}
832
833/// Generate a 24-character alphanumeric password from a cryptographically
834/// secure random source.
835///
836/// The alphabet excludes visually ambiguous characters (`0`, `O`, `1`,
837/// `I`, `l`). The password is for local development and is surfaced
838/// through `lightshuttle ps`; production export still requires an
839/// explicit password.
840fn generate_random_password() -> String {
841    use rand::Rng;
842
843    const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789";
844    const LEN: usize = 24;
845
846    let mut rng = rand::rng();
847    (0..LEN)
848        .map(|_| ALPHABET[rng.random_range(0..ALPHABET.len())] as char)
849        .collect()
850}
851
852#[cfg(test)]
853mod tests {
854    use super::{
855        VolumeSource, from_resource, generate_random_password, parse_command, parse_duration,
856        parse_port_string, parse_volume_string,
857    };
858    use lightshuttle_manifest::Command;
859    use std::time::Duration;
860
861    #[test]
862    fn parse_port_string_two_part() {
863        let b = parse_port_string("8080:80").unwrap();
864        assert_eq!(b.host_port, 8080);
865        assert_eq!(b.container_port, 80);
866        assert_eq!(b.host_address, None);
867    }
868
869    #[test]
870    fn parse_port_string_three_part() {
871        let b = parse_port_string("127.0.0.1:8080:80").unwrap();
872        assert_eq!(b.host_port, 8080);
873        assert_eq!(b.container_port, 80);
874        assert_eq!(b.host_address.as_deref(), Some("127.0.0.1"));
875    }
876
877    #[test]
878    fn parse_port_string_single_part_is_error() {
879        assert!(parse_port_string("80").is_err());
880    }
881
882    #[test]
883    fn parse_port_string_non_numeric_is_error() {
884        assert!(parse_port_string("abc:80").is_err());
885    }
886
887    #[test]
888    fn parse_volume_string_named() {
889        let b = parse_volume_string("data:/var/lib/data").unwrap();
890        assert!(matches!(b.source, VolumeSource::Named(_)));
891        assert_eq!(b.target, "/var/lib/data");
892    }
893
894    #[test]
895    fn parse_volume_string_relative_host() {
896        let b = parse_volume_string("./src:/app").unwrap();
897        assert!(matches!(b.source, VolumeSource::HostPath(_)));
898        assert_eq!(b.target, "/app");
899    }
900
901    #[test]
902    fn parse_volume_string_absolute_host() {
903        let b = parse_volume_string("/abs/path:/app").unwrap();
904        assert!(matches!(b.source, VolumeSource::HostPath(_)));
905        assert_eq!(b.target, "/app");
906    }
907
908    #[test]
909    fn parse_volume_string_no_colon_is_error() {
910        assert!(parse_volume_string("nodatahere").is_err());
911    }
912
913    #[test]
914    fn parse_volume_string_braces_in_name_is_error() {
915        assert!(parse_volume_string("my{vol}:/data").is_err());
916    }
917
918    #[test]
919    fn parse_duration_seconds() {
920        assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
921    }
922
923    #[test]
924    fn parse_duration_milliseconds() {
925        assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
926    }
927
928    #[test]
929    fn parse_duration_minutes() {
930        assert_eq!(parse_duration("1m").unwrap(), Duration::from_secs(60));
931    }
932
933    #[test]
934    fn parse_duration_unknown_unit_is_error() {
935        assert!(parse_duration("10x").is_err());
936    }
937
938    #[test]
939    fn parse_duration_no_unit_is_error() {
940        assert!(parse_duration("10").is_err());
941    }
942
943    #[test]
944    fn parse_duration_no_digits_is_error() {
945        assert!(parse_duration("s").is_err());
946    }
947
948    #[test]
949    fn parse_command_empty_args_produces_empty_vec() {
950        assert!(parse_command(&Command::Args(vec![])).is_empty());
951    }
952
953    #[test]
954    fn parse_command_single_becomes_sh_c() {
955        let v = parse_command(&Command::Single("echo hi".to_owned()));
956        assert_eq!(v, vec!["sh", "-c", "echo hi"]);
957    }
958
959    #[test]
960    fn generated_password_has_expected_shape() {
961        let password = generate_random_password();
962        assert_eq!(password.len(), 24);
963        assert!(
964            password
965                .chars()
966                .all(|c| c.is_ascii_alphanumeric() && !"0O1Il".contains(c)),
967            "password must be unambiguous alphanumeric, got `{password}`"
968        );
969    }
970
971    #[test]
972    fn generated_passwords_are_distinct() {
973        // A clock-seeded generator would collide for calls within the
974        // same instant; a CSPRNG must not.
975        let first = generate_random_password();
976        let second = generate_random_password();
977        assert_ne!(first, second);
978    }
979
980    #[test]
981    fn entrypoint_resolves_to_argv_and_leaves_command_alone() {
982        let yaml = r#"
983project:
984  name: app
985resources:
986  svc:
987    dockerfile:
988      context: .
989      entrypoint: ["sh", "-c"]
990      command: ["echo hi"]
991"#;
992        let manifest = lightshuttle_manifest::Manifest::parse(yaml).expect("manifest parses");
993        let resolved =
994            from_resource("app", "svc", &manifest.resources["svc"]).expect("resolution succeeds");
995        assert_eq!(
996            resolved.spec.entrypoint,
997            Some(vec!["sh".to_owned(), "-c".to_owned()])
998        );
999        assert_eq!(
1000            resolved.spec.command,
1001            Some(vec!["echo hi".to_owned()]),
1002            "resolving an entrypoint must not disturb the command"
1003        );
1004    }
1005
1006    #[test]
1007    fn entrypoint_without_command_leaves_command_none() {
1008        let yaml = r#"
1009project:
1010  name: app
1011resources:
1012  svc:
1013    dockerfile:
1014      context: .
1015      entrypoint: ["sh", "-c", "entrypoint.sh"]
1016"#;
1017        let manifest = lightshuttle_manifest::Manifest::parse(yaml).expect("manifest parses");
1018        let resolved =
1019            from_resource("app", "svc", &manifest.resources["svc"]).expect("resolution succeeds");
1020        assert_eq!(
1021            resolved.spec.entrypoint,
1022            Some(vec![
1023                "sh".to_owned(),
1024                "-c".to_owned(),
1025                "entrypoint.sh".to_owned()
1026            ])
1027        );
1028        assert_eq!(
1029            resolved.spec.command, None,
1030            "entrypoint alone must not synthesise a command: the image CMD, not the manifest, decides what runs"
1031        );
1032    }
1033
1034    #[test]
1035    fn absent_entrypoint_resolves_to_none() {
1036        let yaml = r"
1037project:
1038  name: app
1039resources:
1040  svc:
1041    dockerfile:
1042      context: .
1043";
1044        let manifest = lightshuttle_manifest::Manifest::parse(yaml).expect("manifest parses");
1045        let resolved =
1046            from_resource("app", "svc", &manifest.resources["svc"]).expect("resolution succeeds");
1047        assert_eq!(
1048            resolved.spec.entrypoint, None,
1049            "existing manifests must be unaffected"
1050        );
1051    }
1052
1053    #[test]
1054    fn generated_resources_declare_no_entrypoint() {
1055        let yaml = r"
1056project:
1057  name: app
1058resources:
1059  cache:
1060    redis:
1061      version: '7'
1062  db:
1063    postgres:
1064      version: '16'
1065";
1066        let manifest = lightshuttle_manifest::Manifest::parse(yaml).expect("manifest parses");
1067        for name in ["cache", "db"] {
1068            let resolved =
1069                from_resource("app", name, &manifest.resources[name]).expect("resolution succeeds");
1070            assert_eq!(
1071                resolved.spec.entrypoint, None,
1072                "{name} must keep the image entrypoint"
1073            );
1074        }
1075        let cache = from_resource("app", "cache", &manifest.resources["cache"])
1076            .expect("resolution succeeds");
1077        assert_eq!(
1078            cache.spec.command,
1079            Some(vec!["redis-server".to_owned()]),
1080            "the redis command must be untouched"
1081        );
1082    }
1083}