Skip to main content

lightshuttle_spec/
spec.rs

1//! Self-contained container specification, derived from a manifest
2//! resource declaration.
3
4use std::collections::HashMap;
5use std::time::Duration;
6
7use indexmap::IndexMap;
8use lightshuttle_manifest::{
9    Command, ContainerConfig, DockerfileConfig, Healthcheck, PortMapping, PostgresConfig,
10    RedisConfig, ResourceKind, Volume,
11};
12
13use crate::error::{Result, SpecError};
14
15/// Properties a managed resource exposes to its dependents.
16///
17/// Keys follow the conventions documented in
18/// `docs/spec/manifest-v0.md`:
19///
20/// - `host`, `port`, `database`, `user`, `password`, `url` for
21///   `postgres`.
22/// - `host`, `port`, `password`, `url` for `redis`.
23/// - `host`, `ports` (comma-separated) for `container` and
24///   `dockerfile`.
25pub type ResourceOutputs = IndexMap<String, String>;
26
27/// A [`ContainerSpec`] together with the outputs the resource exposes
28/// to its dependents at runtime.
29#[derive(Debug, Clone)]
30pub struct ResolvedResource {
31    /// Container specification consumed by the runtime.
32    pub spec: ContainerSpec,
33    /// Properties exposed to dependents (resolved into LSH_* env vars
34    /// and substituted into `${resources.<name>.<property>}`
35    /// expressions).
36    pub outputs: ResourceOutputs,
37}
38
39const DEFAULT_PG_VERSION: &str = "16";
40const DEFAULT_PG_USER: &str = "postgres";
41const DEFAULT_PG_PORT: u16 = 5432;
42const DEFAULT_REDIS_VERSION: &str = "7";
43const DEFAULT_REDIS_PORT: u16 = 6379;
44const HEALTHCHECK_DEFAULT_INTERVAL: Duration = Duration::from_secs(5);
45const HEALTHCHECK_DEFAULT_TIMEOUT: Duration = Duration::from_secs(3);
46const HEALTHCHECK_DEFAULT_RETRIES: u32 = 5;
47const HEALTHCHECK_DEFAULT_START_PERIOD: Duration = Duration::from_secs(5);
48
49/// Self-contained description of a container to start, derived from a
50/// manifest resource.
51#[derive(Debug, Clone)]
52pub struct ContainerSpec {
53    /// Container name, of the form `<project>_<resource>`.
54    pub name: String,
55    /// Project name as declared in the manifest. Used as a Docker
56    /// label for discovery by `ps` and `down`.
57    pub project: String,
58    /// Resource name as declared in the manifest. Used as a Docker
59    /// label so the CLI can find a single resource by name.
60    pub resource: String,
61    /// How the container image is obtained.
62    pub image: ImageSource,
63    /// Environment variables to inject into the container.
64    pub env: HashMap<String, String>,
65    /// Ports to publish.
66    pub ports: Vec<PortBinding>,
67    /// Volumes to mount.
68    pub volumes: Vec<VolumeBinding>,
69    /// Optional command override.
70    pub command: Option<Vec<String>>,
71    /// Optional healthcheck.
72    pub healthcheck: Option<HealthcheckSpec>,
73}
74
75/// How the container image is obtained.
76#[derive(Debug, Clone)]
77pub enum ImageSource {
78    /// Pull the image from a registry.
79    Pull(String),
80    /// Build the image locally from a Dockerfile.
81    Build {
82        /// Build context path, relative to the manifest file.
83        context: String,
84        /// Dockerfile path within the context.
85        dockerfile: String,
86        /// Build-time arguments.
87        build_args: HashMap<String, String>,
88        /// Optional multi-stage target.
89        target: Option<String>,
90        /// Tag applied to the resulting image.
91        tag: String,
92    },
93}
94
95/// Port mapping resolved from the manifest.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct PortBinding {
98    /// Container-side port.
99    pub container_port: u16,
100    /// Optional host bind address.
101    pub host_address: Option<String>,
102    /// Host-side port. Mirrors the container port when the short form is used.
103    pub host_port: u16,
104}
105
106/// Volume mapping resolved from the manifest.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct VolumeBinding {
109    /// Source on the host or the named volume registry.
110    pub source: VolumeSource,
111    /// Mount point inside the container.
112    pub target: String,
113}
114
115/// Where the volume content lives.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum VolumeSource {
118    /// Bind mount from a host path.
119    HostPath(String),
120    /// Named volume managed by the runtime.
121    Named(String),
122    /// Anonymous volume (lifetime tied to the container).
123    Anonymous,
124}
125
126/// Healthcheck resolved from the manifest, with manifest-side durations
127/// already parsed.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct HealthcheckSpec {
130    /// Command to run for the check.
131    pub test: Vec<String>,
132    /// Interval between consecutive checks.
133    pub interval: Duration,
134    /// Maximum time a single check is allowed.
135    pub timeout: Duration,
136    /// Number of consecutive failures before marking unhealthy.
137    pub retries: u32,
138    /// Grace period after start.
139    pub start_period: Duration,
140}
141
142/// Build a [`ContainerSpec`] from a manifest resource declaration.
143///
144/// Applies the v0 defaults documented in `docs/spec/manifest-v0.md`:
145/// version expansion to official images, database name derived from
146/// the resource name, default ports, healthcheck materialisation.
147pub fn from_resource(
148    project: &str,
149    resource_name: &str,
150    kind: &ResourceKind,
151) -> Result<ResolvedResource> {
152    let name = format!("{project}_{resource_name}");
153    match kind {
154        ResourceKind::Postgres(c) => spec_postgres(name, project, resource_name, c),
155        ResourceKind::Redis(c) => spec_redis(name, project, resource_name, c),
156        ResourceKind::Container(c) => spec_container(name, project, resource_name, c),
157        ResourceKind::Dockerfile(c) => spec_dockerfile(name, project, resource_name, c),
158    }
159}
160
161#[allow(clippy::needless_pass_by_value)]
162fn spec_postgres(
163    name: String,
164    project: &str,
165    resource_name: &str,
166    c: &PostgresConfig,
167) -> Result<ResolvedResource> {
168    let version = c.version.as_deref().unwrap_or(DEFAULT_PG_VERSION);
169    let image = c
170        .image
171        .clone()
172        .unwrap_or_else(|| format!("postgres:{version}-alpine"));
173    let database = c
174        .database
175        .clone()
176        .unwrap_or_else(|| resource_name.to_owned());
177    let user = c.user.clone().unwrap_or_else(|| DEFAULT_PG_USER.to_owned());
178    let password = c.password.clone().unwrap_or_else(generate_random_password);
179    let port = c.port.unwrap_or(DEFAULT_PG_PORT);
180
181    let mut env = HashMap::new();
182    env.insert("POSTGRES_DB".to_owned(), database);
183    env.insert("POSTGRES_USER".to_owned(), user.clone());
184    env.insert("POSTGRES_PASSWORD".to_owned(), password);
185
186    let ports = vec![PortBinding {
187        container_port: port,
188        host_address: None,
189        host_port: port,
190    }];
191
192    let volumes = volume_to_binding(c.volume.as_ref(), "/var/lib/postgresql/data");
193
194    let healthcheck = c
195        .healthcheck
196        .as_ref()
197        .map(parse_healthcheck)
198        .transpose()?
199        .or_else(|| {
200            Some(HealthcheckSpec {
201                test: vec![
202                    "CMD".to_owned(),
203                    "pg_isready".to_owned(),
204                    "-U".to_owned(),
205                    user,
206                ],
207                interval: HEALTHCHECK_DEFAULT_INTERVAL,
208                timeout: HEALTHCHECK_DEFAULT_TIMEOUT,
209                retries: HEALTHCHECK_DEFAULT_RETRIES,
210                start_period: HEALTHCHECK_DEFAULT_START_PERIOD,
211            })
212        });
213
214    let spec = ContainerSpec {
215        name: name.clone(),
216        project: project.to_owned(),
217        resource: resource_name.to_owned(),
218        image: ImageSource::Pull(image),
219        env: env.clone(),
220        ports,
221        volumes,
222        command: None,
223        healthcheck,
224    };
225
226    let mut outputs = ResourceOutputs::new();
227    outputs.insert("host".to_owned(), name.clone());
228    outputs.insert("port".to_owned(), port.to_string());
229    let user_out = env.get("POSTGRES_USER").cloned().unwrap_or_default();
230    let pwd_out = env.get("POSTGRES_PASSWORD").cloned().unwrap_or_default();
231    let db_out = env.get("POSTGRES_DB").cloned().unwrap_or_default();
232    outputs.insert("user".to_owned(), user_out.clone());
233    outputs.insert("password".to_owned(), pwd_out.clone());
234    outputs.insert("database".to_owned(), db_out.clone());
235    outputs.insert(
236        "url".to_owned(),
237        format!("postgres://{user_out}:{pwd_out}@{name}:{port}/{db_out}"),
238    );
239
240    Ok(ResolvedResource { spec, outputs })
241}
242
243#[allow(clippy::needless_pass_by_value)]
244fn spec_redis(
245    name: String,
246    project: &str,
247    resource_name: &str,
248    c: &RedisConfig,
249) -> Result<ResolvedResource> {
250    let version = c.version.as_deref().unwrap_or(DEFAULT_REDIS_VERSION);
251    let image = c
252        .image
253        .clone()
254        .unwrap_or_else(|| format!("redis:{version}-alpine"));
255    let port = c.port.unwrap_or(DEFAULT_REDIS_PORT);
256
257    let mut command = vec!["redis-server".to_owned()];
258    if let Some(password) = c.password.as_deref()
259        && !password.is_empty()
260    {
261        command.push("--requirepass".to_owned());
262        command.push(password.to_owned());
263    }
264
265    let ports = vec![PortBinding {
266        container_port: port,
267        host_address: None,
268        host_port: port,
269    }];
270
271    let volumes = volume_to_binding(c.volume.as_ref(), "/data");
272
273    let healthcheck = c
274        .healthcheck
275        .as_ref()
276        .map(parse_healthcheck)
277        .transpose()?
278        .or_else(|| {
279            Some(HealthcheckSpec {
280                test: vec!["CMD".to_owned(), "redis-cli".to_owned(), "ping".to_owned()],
281                interval: HEALTHCHECK_DEFAULT_INTERVAL,
282                timeout: HEALTHCHECK_DEFAULT_TIMEOUT,
283                retries: HEALTHCHECK_DEFAULT_RETRIES,
284                start_period: HEALTHCHECK_DEFAULT_START_PERIOD,
285            })
286        });
287
288    let password_out = c.password.clone().unwrap_or_default();
289    let spec = ContainerSpec {
290        name: name.clone(),
291        project: project.to_owned(),
292        resource: resource_name.to_owned(),
293        image: ImageSource::Pull(image),
294        env: HashMap::new(),
295        ports,
296        volumes,
297        command: Some(command),
298        healthcheck,
299    };
300
301    let mut outputs = ResourceOutputs::new();
302    outputs.insert("host".to_owned(), name.clone());
303    outputs.insert("port".to_owned(), port.to_string());
304    outputs.insert("password".to_owned(), password_out.clone());
305    let url = if password_out.is_empty() {
306        format!("redis://{name}:{port}")
307    } else {
308        format!("redis://:{password_out}@{name}:{port}")
309    };
310    outputs.insert("url".to_owned(), url);
311
312    Ok(ResolvedResource { spec, outputs })
313}
314
315#[allow(clippy::needless_pass_by_value)]
316fn spec_container(
317    name: String,
318    project: &str,
319    resource_name: &str,
320    c: &ContainerConfig,
321) -> Result<ResolvedResource> {
322    let env: HashMap<String, String> = c.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
323
324    let ports = c
325        .ports
326        .iter()
327        .map(parse_port_mapping)
328        .collect::<Result<Vec<_>>>()?;
329    let volumes = c
330        .volumes
331        .iter()
332        .map(|s| parse_volume_string(s))
333        .collect::<Result<Vec<_>>>()?;
334    let command = c
335        .command
336        .as_ref()
337        .map(parse_command)
338        .filter(|cmd| !cmd.is_empty());
339    let healthcheck = c.healthcheck.as_ref().map(parse_healthcheck).transpose()?;
340
341    let ports_csv: String = ports
342        .iter()
343        .map(|p| p.container_port.to_string())
344        .collect::<Vec<_>>()
345        .join(",");
346    let spec = ContainerSpec {
347        name: name.clone(),
348        project: project.to_owned(),
349        resource: resource_name.to_owned(),
350        image: ImageSource::Pull(c.image.clone()),
351        env,
352        ports,
353        volumes,
354        command,
355        healthcheck,
356    };
357
358    let mut outputs = ResourceOutputs::new();
359    outputs.insert("host".to_owned(), name);
360    outputs.insert("ports".to_owned(), ports_csv);
361
362    Ok(ResolvedResource { spec, outputs })
363}
364
365#[allow(clippy::needless_pass_by_value)]
366fn spec_dockerfile(
367    name: String,
368    project: &str,
369    resource_name: &str,
370    c: &DockerfileConfig,
371) -> Result<ResolvedResource> {
372    let tag = format!("lightshuttle/{name}:dev");
373
374    let env: HashMap<String, String> = c.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
375
376    let build_args: HashMap<String, String> = c
377        .build_args
378        .iter()
379        .map(|(k, v)| (k.clone(), v.clone()))
380        .collect();
381
382    let ports = c
383        .ports
384        .iter()
385        .map(parse_port_mapping)
386        .collect::<Result<Vec<_>>>()?;
387    let volumes = c
388        .volumes
389        .iter()
390        .map(|s| parse_volume_string(s))
391        .collect::<Result<Vec<_>>>()?;
392    let command = c
393        .command
394        .as_ref()
395        .map(parse_command)
396        .filter(|cmd| !cmd.is_empty());
397    let healthcheck = c.healthcheck.as_ref().map(parse_healthcheck).transpose()?;
398
399    let ports_csv: String = ports
400        .iter()
401        .map(|p| p.container_port.to_string())
402        .collect::<Vec<_>>()
403        .join(",");
404    let spec = ContainerSpec {
405        name: name.clone(),
406        project: project.to_owned(),
407        resource: resource_name.to_owned(),
408        image: ImageSource::Build {
409            context: c.context.clone(),
410            dockerfile: c.dockerfile.clone(),
411            build_args,
412            target: c.target.clone(),
413            tag,
414        },
415        env,
416        ports,
417        volumes,
418        command,
419        healthcheck,
420    };
421
422    let mut outputs = ResourceOutputs::new();
423    outputs.insert("host".to_owned(), name);
424    outputs.insert("ports".to_owned(), ports_csv);
425
426    Ok(ResolvedResource { spec, outputs })
427}
428
429fn volume_to_binding(volume: Option<&Volume>, target: &str) -> Vec<VolumeBinding> {
430    match volume {
431        None | Some(Volume::Boolean(true)) => vec![VolumeBinding {
432            source: VolumeSource::Anonymous,
433            target: target.to_owned(),
434        }],
435        Some(Volume::Boolean(false)) => Vec::new(),
436        Some(Volume::Named(name)) => vec![VolumeBinding {
437            source: VolumeSource::Named(name.clone()),
438            target: target.to_owned(),
439        }],
440    }
441}
442
443fn parse_port_mapping(mapping: &PortMapping) -> Result<PortBinding> {
444    match mapping {
445        PortMapping::Container(port) => Ok(PortBinding {
446            container_port: *port,
447            host_address: None,
448            host_port: *port,
449        }),
450        PortMapping::Mapping(s) => parse_port_string(s),
451    }
452}
453
454fn parse_port_string(input: &str) -> Result<PortBinding> {
455    let parts: Vec<&str> = input.split(':').collect();
456    match parts.as_slice() {
457        [host_port, container_port] => {
458            let host_port: u16 = host_port
459                .parse()
460                .map_err(|_| SpecError::InvalidSpec(format!("invalid host port `{host_port}`")))?;
461            let container_port: u16 = container_port.parse().map_err(|_| {
462                SpecError::InvalidSpec(format!("invalid container port `{container_port}`"))
463            })?;
464            Ok(PortBinding {
465                container_port,
466                host_address: None,
467                host_port,
468            })
469        }
470        [host_address, host_port, container_port] => {
471            let host_port: u16 = host_port
472                .parse()
473                .map_err(|_| SpecError::InvalidSpec(format!("invalid host port `{host_port}`")))?;
474            let container_port: u16 = container_port.parse().map_err(|_| {
475                SpecError::InvalidSpec(format!("invalid container port `{container_port}`"))
476            })?;
477            Ok(PortBinding {
478                container_port,
479                host_address: Some((*host_address).to_owned()),
480                host_port,
481            })
482        }
483        _ => Err(SpecError::InvalidSpec(format!(
484            "invalid port mapping `{input}`"
485        ))),
486    }
487}
488
489fn parse_volume_string(input: &str) -> Result<VolumeBinding> {
490    let (source, target) = input.split_once(':').ok_or_else(|| {
491        SpecError::InvalidSpec(format!(
492            "invalid volume mapping `{input}`: expected `src:target`"
493        ))
494    })?;
495    let source = if source.starts_with('.') || source.starts_with('/') {
496        VolumeSource::HostPath(source.to_owned())
497    } else {
498        VolumeSource::Named(source.to_owned())
499    };
500    Ok(VolumeBinding {
501        source,
502        target: target.to_owned(),
503    })
504}
505
506fn parse_command(command: &Command) -> Vec<String> {
507    match command {
508        Command::Single(s) => vec!["sh".to_owned(), "-c".to_owned(), s.clone()],
509        Command::Args(args) => args.clone(),
510    }
511}
512
513fn parse_healthcheck(hc: &Healthcheck) -> Result<HealthcheckSpec> {
514    Ok(HealthcheckSpec {
515        test: hc.test.clone(),
516        interval: parse_duration(&hc.interval)?,
517        timeout: parse_duration(&hc.timeout)?,
518        retries: hc.retries,
519        start_period: parse_duration(&hc.start_period)?,
520    })
521}
522
523fn parse_duration(input: &str) -> Result<Duration> {
524    let trimmed = input.trim();
525    let (digits, unit) = split_duration(trimmed)
526        .ok_or_else(|| SpecError::InvalidSpec(format!("invalid duration `{input}`")))?;
527    let value: f64 = digits
528        .parse()
529        .map_err(|_| SpecError::InvalidSpec(format!("invalid duration `{input}`")))?;
530    let nanos = match unit {
531        "ns" => value,
532        "us" => value * 1_000.0,
533        "ms" => value * 1_000_000.0,
534        "s" => value * 1_000_000_000.0,
535        "m" => value * 60.0 * 1_000_000_000.0,
536        "h" => value * 3_600.0 * 1_000_000_000.0,
537        _ => {
538            return Err(SpecError::InvalidSpec(format!(
539                "invalid duration unit `{unit}`"
540            )));
541        }
542    };
543    if nanos.is_sign_negative() || !nanos.is_finite() {
544        return Err(SpecError::InvalidSpec(format!(
545            "invalid duration `{input}`"
546        )));
547    }
548    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
549    Ok(Duration::from_nanos(nanos as u64))
550}
551
552fn split_duration(input: &str) -> Option<(&str, &str)> {
553    let bytes = input.as_bytes();
554    let mut idx = 0;
555    while idx < bytes.len() && (bytes[idx].is_ascii_digit() || bytes[idx] == b'.') {
556        idx += 1;
557    }
558    if idx == 0 || idx == bytes.len() {
559        return None;
560    }
561    Some((&input[..idx], &input[idx..]))
562}
563
564/// Generate a 24-character alphanumeric password from a cryptographically
565/// secure random source.
566///
567/// The alphabet excludes visually ambiguous characters (`0`, `O`, `1`,
568/// `I`, `l`). The password is for local development and is surfaced
569/// through `lightshuttle ps`; production export still requires an
570/// explicit password.
571fn generate_random_password() -> String {
572    use rand::Rng;
573
574    const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789";
575    const LEN: usize = 24;
576
577    let mut rng = rand::rng();
578    (0..LEN)
579        .map(|_| ALPHABET[rng.random_range(0..ALPHABET.len())] as char)
580        .collect()
581}
582
583#[cfg(test)]
584mod tests {
585    use super::{
586        VolumeSource, generate_random_password, parse_command, parse_duration, parse_port_string,
587        parse_volume_string,
588    };
589    use lightshuttle_manifest::Command;
590    use std::time::Duration;
591
592    #[test]
593    fn parse_port_string_two_part() {
594        let b = parse_port_string("8080:80").unwrap();
595        assert_eq!(b.host_port, 8080);
596        assert_eq!(b.container_port, 80);
597        assert_eq!(b.host_address, None);
598    }
599
600    #[test]
601    fn parse_port_string_three_part() {
602        let b = parse_port_string("127.0.0.1:8080:80").unwrap();
603        assert_eq!(b.host_port, 8080);
604        assert_eq!(b.container_port, 80);
605        assert_eq!(b.host_address.as_deref(), Some("127.0.0.1"));
606    }
607
608    #[test]
609    fn parse_port_string_single_part_is_error() {
610        assert!(parse_port_string("80").is_err());
611    }
612
613    #[test]
614    fn parse_port_string_non_numeric_is_error() {
615        assert!(parse_port_string("abc:80").is_err());
616    }
617
618    #[test]
619    fn parse_volume_string_named() {
620        let b = parse_volume_string("data:/var/lib/data").unwrap();
621        assert!(matches!(b.source, VolumeSource::Named(_)));
622        assert_eq!(b.target, "/var/lib/data");
623    }
624
625    #[test]
626    fn parse_volume_string_relative_host() {
627        let b = parse_volume_string("./src:/app").unwrap();
628        assert!(matches!(b.source, VolumeSource::HostPath(_)));
629        assert_eq!(b.target, "/app");
630    }
631
632    #[test]
633    fn parse_volume_string_absolute_host() {
634        let b = parse_volume_string("/abs/path:/app").unwrap();
635        assert!(matches!(b.source, VolumeSource::HostPath(_)));
636        assert_eq!(b.target, "/app");
637    }
638
639    #[test]
640    fn parse_volume_string_no_colon_is_error() {
641        assert!(parse_volume_string("nodatahere").is_err());
642    }
643
644    #[test]
645    fn parse_duration_seconds() {
646        assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
647    }
648
649    #[test]
650    fn parse_duration_milliseconds() {
651        assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
652    }
653
654    #[test]
655    fn parse_duration_minutes() {
656        assert_eq!(parse_duration("1m").unwrap(), Duration::from_secs(60));
657    }
658
659    #[test]
660    fn parse_duration_unknown_unit_is_error() {
661        assert!(parse_duration("10x").is_err());
662    }
663
664    #[test]
665    fn parse_duration_no_unit_is_error() {
666        assert!(parse_duration("10").is_err());
667    }
668
669    #[test]
670    fn parse_duration_no_digits_is_error() {
671        assert!(parse_duration("s").is_err());
672    }
673
674    #[test]
675    fn parse_command_empty_args_produces_empty_vec() {
676        assert!(parse_command(&Command::Args(vec![])).is_empty());
677    }
678
679    #[test]
680    fn parse_command_single_becomes_sh_c() {
681        let v = parse_command(&Command::Single("echo hi".to_owned()));
682        assert_eq!(v, vec!["sh", "-c", "echo hi"]);
683    }
684
685    #[test]
686    fn generated_password_has_expected_shape() {
687        let password = generate_random_password();
688        assert_eq!(password.len(), 24);
689        assert!(
690            password
691                .chars()
692                .all(|c| c.is_ascii_alphanumeric() && !"0O1Il".contains(c)),
693            "password must be unambiguous alphanumeric, got `{password}`"
694        );
695    }
696
697    #[test]
698    fn generated_passwords_are_distinct() {
699        // A clock-seeded generator would collide for calls within the
700        // same instant; a CSPRNG must not.
701        let first = generate_random_password();
702        let second = generate_random_password();
703        assert_ne!(first, second);
704    }
705}