1use 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
15pub type ResourceOutputs = IndexMap<String, String>;
26
27#[derive(Debug, Clone)]
30pub struct ResolvedResource {
31 pub spec: ContainerSpec,
33 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#[derive(Debug, Clone)]
52pub struct ContainerSpec {
53 pub name: String,
55 pub project: String,
58 pub resource: String,
61 pub image: ImageSource,
63 pub env: HashMap<String, String>,
65 pub ports: Vec<PortBinding>,
67 pub volumes: Vec<VolumeBinding>,
69 pub command: Option<Vec<String>>,
71 pub healthcheck: Option<HealthcheckSpec>,
73}
74
75#[derive(Debug, Clone)]
77pub enum ImageSource {
78 Pull(String),
80 Build {
82 context: String,
84 dockerfile: String,
86 build_args: HashMap<String, String>,
88 target: Option<String>,
90 tag: String,
92 },
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct PortBinding {
98 pub container_port: u16,
100 pub host_address: Option<String>,
102 pub host_port: u16,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct VolumeBinding {
109 pub source: VolumeSource,
111 pub target: String,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum VolumeSource {
118 HostPath(String),
120 Named(String),
122 Anonymous,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct HealthcheckSpec {
130 pub test: Vec<String>,
132 pub interval: Duration,
134 pub timeout: Duration,
136 pub retries: u32,
138 pub start_period: Duration,
140}
141
142pub 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
556fn 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 let first = generate_random_password();
596 let second = generate_random_password();
597 assert_ne!(first, second);
598 }
599}