Skip to main content

lightshuttle_runtime/
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, RuntimeError};
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.command.as_ref().map(parse_command);
335    let healthcheck = c.healthcheck.as_ref().map(parse_healthcheck).transpose()?;
336
337    let ports_csv: String = ports
338        .iter()
339        .map(|p| p.container_port.to_string())
340        .collect::<Vec<_>>()
341        .join(",");
342    let spec = ContainerSpec {
343        name: name.clone(),
344        project: project.to_owned(),
345        resource: resource_name.to_owned(),
346        image: ImageSource::Pull(c.image.clone()),
347        env,
348        ports,
349        volumes,
350        command,
351        healthcheck,
352    };
353
354    let mut outputs = ResourceOutputs::new();
355    outputs.insert("host".to_owned(), name);
356    outputs.insert("ports".to_owned(), ports_csv);
357
358    Ok(ResolvedResource { spec, outputs })
359}
360
361#[allow(clippy::needless_pass_by_value)]
362fn spec_dockerfile(
363    name: String,
364    project: &str,
365    resource_name: &str,
366    c: &DockerfileConfig,
367) -> Result<ResolvedResource> {
368    let tag = format!("lightshuttle/{name}:dev");
369
370    let env: HashMap<String, String> = c.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
371
372    let build_args: HashMap<String, String> = c
373        .build_args
374        .iter()
375        .map(|(k, v)| (k.clone(), v.clone()))
376        .collect();
377
378    let ports = c
379        .ports
380        .iter()
381        .map(parse_port_mapping)
382        .collect::<Result<Vec<_>>>()?;
383    let volumes = c
384        .volumes
385        .iter()
386        .map(|s| parse_volume_string(s))
387        .collect::<Result<Vec<_>>>()?;
388    let command = c.command.as_ref().map(parse_command);
389    let healthcheck = c.healthcheck.as_ref().map(parse_healthcheck).transpose()?;
390
391    let ports_csv: String = ports
392        .iter()
393        .map(|p| p.container_port.to_string())
394        .collect::<Vec<_>>()
395        .join(",");
396    let spec = ContainerSpec {
397        name: name.clone(),
398        project: project.to_owned(),
399        resource: resource_name.to_owned(),
400        image: ImageSource::Build {
401            context: c.context.clone(),
402            dockerfile: c.dockerfile.clone(),
403            build_args,
404            target: c.target.clone(),
405            tag,
406        },
407        env,
408        ports,
409        volumes,
410        command,
411        healthcheck,
412    };
413
414    let mut outputs = ResourceOutputs::new();
415    outputs.insert("host".to_owned(), name);
416    outputs.insert("ports".to_owned(), ports_csv);
417
418    Ok(ResolvedResource { spec, outputs })
419}
420
421fn volume_to_binding(volume: Option<&Volume>, target: &str) -> Vec<VolumeBinding> {
422    match volume {
423        None | Some(Volume::Boolean(true)) => vec![VolumeBinding {
424            source: VolumeSource::Anonymous,
425            target: target.to_owned(),
426        }],
427        Some(Volume::Boolean(false)) => Vec::new(),
428        Some(Volume::Named(name)) => vec![VolumeBinding {
429            source: VolumeSource::Named(name.clone()),
430            target: target.to_owned(),
431        }],
432    }
433}
434
435fn parse_port_mapping(mapping: &PortMapping) -> Result<PortBinding> {
436    match mapping {
437        PortMapping::Container(port) => Ok(PortBinding {
438            container_port: *port,
439            host_address: None,
440            host_port: *port,
441        }),
442        PortMapping::Mapping(s) => parse_port_string(s),
443    }
444}
445
446fn parse_port_string(input: &str) -> Result<PortBinding> {
447    let parts: Vec<&str> = input.split(':').collect();
448    match parts.as_slice() {
449        [host_port, container_port] => {
450            let host_port: u16 = host_port.parse().map_err(|_| {
451                RuntimeError::InvalidSpec(format!("invalid host port `{host_port}`"))
452            })?;
453            let container_port: u16 = container_port.parse().map_err(|_| {
454                RuntimeError::InvalidSpec(format!("invalid container port `{container_port}`"))
455            })?;
456            Ok(PortBinding {
457                container_port,
458                host_address: None,
459                host_port,
460            })
461        }
462        [host_address, host_port, container_port] => {
463            let host_port: u16 = host_port.parse().map_err(|_| {
464                RuntimeError::InvalidSpec(format!("invalid host port `{host_port}`"))
465            })?;
466            let container_port: u16 = container_port.parse().map_err(|_| {
467                RuntimeError::InvalidSpec(format!("invalid container port `{container_port}`"))
468            })?;
469            Ok(PortBinding {
470                container_port,
471                host_address: Some((*host_address).to_owned()),
472                host_port,
473            })
474        }
475        _ => Err(RuntimeError::InvalidSpec(format!(
476            "invalid port mapping `{input}`"
477        ))),
478    }
479}
480
481fn parse_volume_string(input: &str) -> Result<VolumeBinding> {
482    let (source, target) = input.split_once(':').ok_or_else(|| {
483        RuntimeError::InvalidSpec(format!(
484            "invalid volume mapping `{input}`: expected `src:target`"
485        ))
486    })?;
487    let source = if source.starts_with('.') || source.starts_with('/') {
488        VolumeSource::HostPath(source.to_owned())
489    } else {
490        VolumeSource::Named(source.to_owned())
491    };
492    Ok(VolumeBinding {
493        source,
494        target: target.to_owned(),
495    })
496}
497
498fn parse_command(command: &Command) -> Vec<String> {
499    match command {
500        Command::Single(s) => vec!["sh".to_owned(), "-c".to_owned(), s.clone()],
501        Command::Args(args) => args.clone(),
502    }
503}
504
505fn parse_healthcheck(hc: &Healthcheck) -> Result<HealthcheckSpec> {
506    Ok(HealthcheckSpec {
507        test: hc.test.clone(),
508        interval: parse_duration(&hc.interval)?,
509        timeout: parse_duration(&hc.timeout)?,
510        retries: hc.retries,
511        start_period: parse_duration(&hc.start_period)?,
512    })
513}
514
515fn parse_duration(input: &str) -> Result<Duration> {
516    let trimmed = input.trim();
517    let (digits, unit) = split_duration(trimmed)
518        .ok_or_else(|| RuntimeError::InvalidSpec(format!("invalid duration `{input}`")))?;
519    let value: f64 = digits
520        .parse()
521        .map_err(|_| RuntimeError::InvalidSpec(format!("invalid duration `{input}`")))?;
522    let nanos = match unit {
523        "ns" => value,
524        "us" => value * 1_000.0,
525        "ms" => value * 1_000_000.0,
526        "s" => value * 1_000_000_000.0,
527        "m" => value * 60.0 * 1_000_000_000.0,
528        "h" => value * 3_600.0 * 1_000_000_000.0,
529        _ => {
530            return Err(RuntimeError::InvalidSpec(format!(
531                "invalid duration unit `{unit}`"
532            )));
533        }
534    };
535    if nanos.is_sign_negative() || !nanos.is_finite() {
536        return Err(RuntimeError::InvalidSpec(format!(
537            "invalid duration `{input}`"
538        )));
539    }
540    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
541    Ok(Duration::from_nanos(nanos as u64))
542}
543
544fn split_duration(input: &str) -> Option<(&str, &str)> {
545    let bytes = input.as_bytes();
546    let mut idx = 0;
547    while idx < bytes.len() && (bytes[idx].is_ascii_digit() || bytes[idx] == b'.') {
548        idx += 1;
549    }
550    if idx == 0 || idx == bytes.len() {
551        return None;
552    }
553    Some((&input[..idx], &input[idx..]))
554}
555
556/// Generate a 24-character alphanumeric password from a cryptographically
557/// secure random source.
558///
559/// The alphabet excludes visually ambiguous characters (`0`, `O`, `1`,
560/// `I`, `l`). The password is for local development and is surfaced
561/// through `lightshuttle ps`; production export still requires an
562/// explicit password.
563fn generate_random_password() -> String {
564    use rand::Rng;
565
566    const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789";
567    const LEN: usize = 24;
568
569    let mut rng = rand::rng();
570    (0..LEN)
571        .map(|_| ALPHABET[rng.random_range(0..ALPHABET.len())] as char)
572        .collect()
573}
574
575#[cfg(test)]
576mod tests {
577    use super::generate_random_password;
578
579    #[test]
580    fn generated_password_has_expected_shape() {
581        let password = generate_random_password();
582        assert_eq!(password.len(), 24);
583        assert!(
584            password
585                .chars()
586                .all(|c| c.is_ascii_alphanumeric() && !"0O1Il".contains(c)),
587            "password must be unambiguous alphanumeric, got `{password}`"
588        );
589    }
590
591    #[test]
592    fn generated_passwords_are_distinct() {
593        // A clock-seeded generator would collide for calls within the
594        // same instant; a CSPRNG must not.
595        let first = generate_random_password();
596        let second = generate_random_password();
597        assert_ne!(first, second);
598    }
599}