use std::collections::HashMap;
use std::time::Duration;
use indexmap::IndexMap;
use lightshuttle_manifest::{
Command, ContainerConfig, DockerfileConfig, Healthcheck, PortMapping, PostgresConfig,
RedisConfig, ResourceKind, Volume,
};
use crate::error::{Result, SpecError};
pub type ResourceOutputs = IndexMap<String, String>;
#[derive(Debug, Clone)]
pub struct ResolvedResource {
pub spec: ContainerSpec,
pub outputs: ResourceOutputs,
}
const DEFAULT_PG_VERSION: &str = "16";
const DEFAULT_PG_USER: &str = "postgres";
const DEFAULT_PG_PORT: u16 = 5432;
const DEFAULT_REDIS_VERSION: &str = "7";
const DEFAULT_REDIS_PORT: u16 = 6379;
const HEALTHCHECK_DEFAULT_INTERVAL: Duration = Duration::from_secs(5);
const HEALTHCHECK_DEFAULT_TIMEOUT: Duration = Duration::from_secs(3);
const HEALTHCHECK_DEFAULT_RETRIES: u32 = 5;
const HEALTHCHECK_DEFAULT_START_PERIOD: Duration = Duration::from_secs(5);
#[derive(Debug, Clone)]
pub struct ContainerSpec {
pub name: String,
pub project: String,
pub resource: String,
pub image: ImageSource,
pub env: HashMap<String, String>,
pub ports: Vec<PortBinding>,
pub volumes: Vec<VolumeBinding>,
pub command: Option<Vec<String>>,
pub healthcheck: Option<HealthcheckSpec>,
pub working_dir: Option<String>,
}
#[derive(Debug, Clone)]
pub enum ImageSource {
Pull(String),
Build {
context: String,
dockerfile: String,
build_args: HashMap<String, String>,
target: Option<String>,
tag: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PortBinding {
pub container_port: u16,
pub host_address: Option<String>,
pub host_port: u16,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VolumeBinding {
pub source: VolumeSource,
pub target: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VolumeSource {
HostPath(String),
Named(String),
Anonymous,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HealthcheckSpec {
pub test: Vec<String>,
pub interval: Duration,
pub timeout: Duration,
pub retries: u32,
pub start_period: Duration,
}
pub fn from_resource(
project: &str,
resource_name: &str,
kind: &ResourceKind,
) -> Result<ResolvedResource> {
let name = format!("{project}_{resource_name}");
match kind {
ResourceKind::Postgres(c) => spec_postgres(name, project, resource_name, c),
ResourceKind::Redis(c) => spec_redis(name, project, resource_name, c),
ResourceKind::Container(c) => spec_container(name, project, resource_name, c),
ResourceKind::Dockerfile(c) => spec_dockerfile(name, project, resource_name, c),
}
}
#[allow(clippy::needless_pass_by_value)]
fn spec_postgres(
name: String,
project: &str,
resource_name: &str,
c: &PostgresConfig,
) -> Result<ResolvedResource> {
let version = c.version.as_deref().unwrap_or(DEFAULT_PG_VERSION);
let image = c
.image
.clone()
.unwrap_or_else(|| format!("postgres:{version}-alpine"));
let database = c
.database
.clone()
.unwrap_or_else(|| resource_name.to_owned());
let user = c.user.clone().unwrap_or_else(|| DEFAULT_PG_USER.to_owned());
let password = c.password.clone().unwrap_or_else(generate_random_password);
let port = c.port.unwrap_or(DEFAULT_PG_PORT);
let mut env = HashMap::new();
env.insert("POSTGRES_DB".to_owned(), database);
env.insert("POSTGRES_USER".to_owned(), user.clone());
env.insert("POSTGRES_PASSWORD".to_owned(), password);
let ports = vec![PortBinding {
container_port: port,
host_address: None,
host_port: port,
}];
let volumes = volume_to_binding(c.volume.as_ref(), "/var/lib/postgresql/data");
let healthcheck = c
.healthcheck
.as_ref()
.map(parse_healthcheck)
.transpose()?
.or_else(|| {
Some(HealthcheckSpec {
test: vec![
"CMD".to_owned(),
"pg_isready".to_owned(),
"-U".to_owned(),
user,
],
interval: HEALTHCHECK_DEFAULT_INTERVAL,
timeout: HEALTHCHECK_DEFAULT_TIMEOUT,
retries: HEALTHCHECK_DEFAULT_RETRIES,
start_period: HEALTHCHECK_DEFAULT_START_PERIOD,
})
});
let spec = ContainerSpec {
name: name.clone(),
project: project.to_owned(),
resource: resource_name.to_owned(),
image: ImageSource::Pull(image),
env: env.clone(),
ports,
volumes,
command: None,
healthcheck,
working_dir: None,
};
let mut outputs = ResourceOutputs::new();
outputs.insert("host".to_owned(), name.clone());
outputs.insert("port".to_owned(), port.to_string());
let user_out = env.get("POSTGRES_USER").cloned().unwrap_or_default();
let pwd_out = env.get("POSTGRES_PASSWORD").cloned().unwrap_or_default();
let db_out = env.get("POSTGRES_DB").cloned().unwrap_or_default();
outputs.insert("user".to_owned(), user_out.clone());
outputs.insert("password".to_owned(), pwd_out.clone());
outputs.insert("database".to_owned(), db_out.clone());
outputs.insert(
"url".to_owned(),
format!("postgres://{user_out}:{pwd_out}@{name}:{port}/{db_out}"),
);
Ok(ResolvedResource { spec, outputs })
}
#[allow(clippy::needless_pass_by_value)]
fn spec_redis(
name: String,
project: &str,
resource_name: &str,
c: &RedisConfig,
) -> Result<ResolvedResource> {
let version = c.version.as_deref().unwrap_or(DEFAULT_REDIS_VERSION);
let image = c
.image
.clone()
.unwrap_or_else(|| format!("redis:{version}-alpine"));
let port = c.port.unwrap_or(DEFAULT_REDIS_PORT);
let mut command = vec!["redis-server".to_owned()];
if let Some(password) = c.password.as_deref()
&& !password.is_empty()
{
command.push("--requirepass".to_owned());
command.push(password.to_owned());
}
let ports = vec![PortBinding {
container_port: port,
host_address: None,
host_port: port,
}];
let volumes = volume_to_binding(c.volume.as_ref(), "/data");
let healthcheck = c
.healthcheck
.as_ref()
.map(parse_healthcheck)
.transpose()?
.or_else(|| {
Some(HealthcheckSpec {
test: vec!["CMD".to_owned(), "redis-cli".to_owned(), "ping".to_owned()],
interval: HEALTHCHECK_DEFAULT_INTERVAL,
timeout: HEALTHCHECK_DEFAULT_TIMEOUT,
retries: HEALTHCHECK_DEFAULT_RETRIES,
start_period: HEALTHCHECK_DEFAULT_START_PERIOD,
})
});
let password_out = c.password.clone().unwrap_or_default();
let spec = ContainerSpec {
name: name.clone(),
project: project.to_owned(),
resource: resource_name.to_owned(),
image: ImageSource::Pull(image),
env: HashMap::new(),
ports,
volumes,
command: Some(command),
healthcheck,
working_dir: None,
};
let mut outputs = ResourceOutputs::new();
outputs.insert("host".to_owned(), name.clone());
outputs.insert("port".to_owned(), port.to_string());
outputs.insert("password".to_owned(), password_out.clone());
let url = if password_out.is_empty() {
format!("redis://{name}:{port}")
} else {
format!("redis://:{password_out}@{name}:{port}")
};
outputs.insert("url".to_owned(), url);
Ok(ResolvedResource { spec, outputs })
}
#[allow(clippy::needless_pass_by_value)]
fn spec_container(
name: String,
project: &str,
resource_name: &str,
c: &ContainerConfig,
) -> Result<ResolvedResource> {
let env: HashMap<String, String> = c.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
let ports = c
.ports
.iter()
.map(parse_port_mapping)
.collect::<Result<Vec<_>>>()?;
let volumes = c
.volumes
.iter()
.map(|s| parse_volume_string(s))
.collect::<Result<Vec<_>>>()?;
let command = c
.command
.as_ref()
.map(parse_command)
.filter(|cmd| !cmd.is_empty());
let healthcheck = c.healthcheck.as_ref().map(parse_healthcheck).transpose()?;
let ports_csv: String = ports
.iter()
.map(|p| p.container_port.to_string())
.collect::<Vec<_>>()
.join(",");
let spec = ContainerSpec {
name: name.clone(),
project: project.to_owned(),
resource: resource_name.to_owned(),
image: ImageSource::Pull(c.image.clone()),
env,
ports,
volumes,
command,
healthcheck,
working_dir: c.working_dir.clone(),
};
let mut outputs = ResourceOutputs::new();
outputs.insert("host".to_owned(), name);
outputs.insert("ports".to_owned(), ports_csv);
Ok(ResolvedResource { spec, outputs })
}
#[allow(clippy::needless_pass_by_value)]
fn spec_dockerfile(
name: String,
project: &str,
resource_name: &str,
c: &DockerfileConfig,
) -> Result<ResolvedResource> {
let tag = format!("lightshuttle/{name}:dev");
let env: HashMap<String, String> = c.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
let build_args: HashMap<String, String> = c
.build_args
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let ports = c
.ports
.iter()
.map(parse_port_mapping)
.collect::<Result<Vec<_>>>()?;
let volumes = c
.volumes
.iter()
.map(|s| parse_volume_string(s))
.collect::<Result<Vec<_>>>()?;
let command = c
.command
.as_ref()
.map(parse_command)
.filter(|cmd| !cmd.is_empty());
let healthcheck = c.healthcheck.as_ref().map(parse_healthcheck).transpose()?;
let ports_csv: String = ports
.iter()
.map(|p| p.container_port.to_string())
.collect::<Vec<_>>()
.join(",");
let spec = ContainerSpec {
name: name.clone(),
project: project.to_owned(),
resource: resource_name.to_owned(),
image: ImageSource::Build {
context: c.context.clone(),
dockerfile: c.dockerfile.clone(),
build_args,
target: c.target.clone(),
tag,
},
env,
ports,
volumes,
command,
healthcheck,
working_dir: c.working_dir.clone(),
};
let mut outputs = ResourceOutputs::new();
outputs.insert("host".to_owned(), name);
outputs.insert("ports".to_owned(), ports_csv);
Ok(ResolvedResource { spec, outputs })
}
fn volume_to_binding(volume: Option<&Volume>, target: &str) -> Vec<VolumeBinding> {
match volume {
None | Some(Volume::Boolean(true)) => vec![VolumeBinding {
source: VolumeSource::Anonymous,
target: target.to_owned(),
}],
Some(Volume::Boolean(false)) => Vec::new(),
Some(Volume::Named(name)) => vec![VolumeBinding {
source: VolumeSource::Named(name.clone()),
target: target.to_owned(),
}],
}
}
fn parse_port_mapping(mapping: &PortMapping) -> Result<PortBinding> {
match mapping {
PortMapping::Container(port) => Ok(PortBinding {
container_port: *port,
host_address: None,
host_port: *port,
}),
PortMapping::Mapping(s) => parse_port_string(s),
}
}
fn parse_port_string(input: &str) -> Result<PortBinding> {
let parts: Vec<&str> = input.split(':').collect();
match parts.as_slice() {
[host_port, container_port] => {
let host_port: u16 = host_port
.parse()
.map_err(|_| SpecError::InvalidSpec(format!("invalid host port `{host_port}`")))?;
let container_port: u16 = container_port.parse().map_err(|_| {
SpecError::InvalidSpec(format!("invalid container port `{container_port}`"))
})?;
Ok(PortBinding {
container_port,
host_address: None,
host_port,
})
}
[host_address, host_port, container_port] => {
let host_port: u16 = host_port
.parse()
.map_err(|_| SpecError::InvalidSpec(format!("invalid host port `{host_port}`")))?;
let container_port: u16 = container_port.parse().map_err(|_| {
SpecError::InvalidSpec(format!("invalid container port `{container_port}`"))
})?;
Ok(PortBinding {
container_port,
host_address: Some((*host_address).to_owned()),
host_port,
})
}
_ => Err(SpecError::InvalidSpec(format!(
"invalid port mapping `{input}`"
))),
}
}
fn parse_volume_string(input: &str) -> Result<VolumeBinding> {
let (source, target) = input.split_once(':').ok_or_else(|| {
SpecError::InvalidSpec(format!(
"invalid volume mapping `{input}`: expected `src:target`"
))
})?;
let source = if source.starts_with('.') || source.starts_with('/') {
VolumeSource::HostPath(source.to_owned())
} else {
if source.contains(['{', '}']) {
return Err(SpecError::InvalidSpec(format!(
"volume name `{source}` must not contain '{{' or '}}': unsafe in export templates"
)));
}
VolumeSource::Named(source.to_owned())
};
Ok(VolumeBinding {
source,
target: target.to_owned(),
})
}
fn parse_command(command: &Command) -> Vec<String> {
match command {
Command::Single(s) => vec!["sh".to_owned(), "-c".to_owned(), s.clone()],
Command::Args(args) => args.clone(),
}
}
fn parse_healthcheck(hc: &Healthcheck) -> Result<HealthcheckSpec> {
Ok(HealthcheckSpec {
test: hc.test.clone(),
interval: parse_duration(&hc.interval)?,
timeout: parse_duration(&hc.timeout)?,
retries: hc.retries,
start_period: parse_duration(&hc.start_period)?,
})
}
fn parse_duration(input: &str) -> Result<Duration> {
let trimmed = input.trim();
let (digits, unit) = split_duration(trimmed)
.ok_or_else(|| SpecError::InvalidSpec(format!("invalid duration `{input}`")))?;
let value: f64 = digits
.parse()
.map_err(|_| SpecError::InvalidSpec(format!("invalid duration `{input}`")))?;
let nanos = match unit {
"ns" => value,
"us" => value * 1_000.0,
"ms" => value * 1_000_000.0,
"s" => value * 1_000_000_000.0,
"m" => value * 60.0 * 1_000_000_000.0,
"h" => value * 3_600.0 * 1_000_000_000.0,
_ => {
return Err(SpecError::InvalidSpec(format!(
"invalid duration unit `{unit}`"
)));
}
};
if nanos.is_sign_negative() || !nanos.is_finite() {
return Err(SpecError::InvalidSpec(format!(
"invalid duration `{input}`"
)));
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
Ok(Duration::from_nanos(nanos as u64))
}
fn split_duration(input: &str) -> Option<(&str, &str)> {
let bytes = input.as_bytes();
let mut idx = 0;
while idx < bytes.len() && (bytes[idx].is_ascii_digit() || bytes[idx] == b'.') {
idx += 1;
}
if idx == 0 || idx == bytes.len() {
return None;
}
Some((&input[..idx], &input[idx..]))
}
fn generate_random_password() -> String {
use rand::Rng;
const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789";
const LEN: usize = 24;
let mut rng = rand::rng();
(0..LEN)
.map(|_| ALPHABET[rng.random_range(0..ALPHABET.len())] as char)
.collect()
}
#[cfg(test)]
mod tests {
use super::{
VolumeSource, generate_random_password, parse_command, parse_duration, parse_port_string,
parse_volume_string,
};
use lightshuttle_manifest::Command;
use std::time::Duration;
#[test]
fn parse_port_string_two_part() {
let b = parse_port_string("8080:80").unwrap();
assert_eq!(b.host_port, 8080);
assert_eq!(b.container_port, 80);
assert_eq!(b.host_address, None);
}
#[test]
fn parse_port_string_three_part() {
let b = parse_port_string("127.0.0.1:8080:80").unwrap();
assert_eq!(b.host_port, 8080);
assert_eq!(b.container_port, 80);
assert_eq!(b.host_address.as_deref(), Some("127.0.0.1"));
}
#[test]
fn parse_port_string_single_part_is_error() {
assert!(parse_port_string("80").is_err());
}
#[test]
fn parse_port_string_non_numeric_is_error() {
assert!(parse_port_string("abc:80").is_err());
}
#[test]
fn parse_volume_string_named() {
let b = parse_volume_string("data:/var/lib/data").unwrap();
assert!(matches!(b.source, VolumeSource::Named(_)));
assert_eq!(b.target, "/var/lib/data");
}
#[test]
fn parse_volume_string_relative_host() {
let b = parse_volume_string("./src:/app").unwrap();
assert!(matches!(b.source, VolumeSource::HostPath(_)));
assert_eq!(b.target, "/app");
}
#[test]
fn parse_volume_string_absolute_host() {
let b = parse_volume_string("/abs/path:/app").unwrap();
assert!(matches!(b.source, VolumeSource::HostPath(_)));
assert_eq!(b.target, "/app");
}
#[test]
fn parse_volume_string_no_colon_is_error() {
assert!(parse_volume_string("nodatahere").is_err());
}
#[test]
fn parse_volume_string_braces_in_name_is_error() {
assert!(parse_volume_string("my{vol}:/data").is_err());
}
#[test]
fn parse_duration_seconds() {
assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
}
#[test]
fn parse_duration_milliseconds() {
assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
}
#[test]
fn parse_duration_minutes() {
assert_eq!(parse_duration("1m").unwrap(), Duration::from_secs(60));
}
#[test]
fn parse_duration_unknown_unit_is_error() {
assert!(parse_duration("10x").is_err());
}
#[test]
fn parse_duration_no_unit_is_error() {
assert!(parse_duration("10").is_err());
}
#[test]
fn parse_duration_no_digits_is_error() {
assert!(parse_duration("s").is_err());
}
#[test]
fn parse_command_empty_args_produces_empty_vec() {
assert!(parse_command(&Command::Args(vec![])).is_empty());
}
#[test]
fn parse_command_single_becomes_sh_c() {
let v = parse_command(&Command::Single("echo hi".to_owned()));
assert_eq!(v, vec!["sh", "-c", "echo hi"]);
}
#[test]
fn generated_password_has_expected_shape() {
let password = generate_random_password();
assert_eq!(password.len(), 24);
assert!(
password
.chars()
.all(|c| c.is_ascii_alphanumeric() && !"0O1Il".contains(c)),
"password must be unambiguous alphanumeric, got `{password}`"
);
}
#[test]
fn generated_passwords_are_distinct() {
let first = generate_random_password();
let second = generate_random_password();
assert_ne!(first, second);
}
}