1pub mod catalog;
21pub mod coolify;
22pub mod dokploy;
23pub mod generate;
24mod planning;
25mod shared;
26
27use std::collections::{BTreeMap, BTreeSet};
28use std::net::IpAddr;
29
30use serde::{Deserialize, Serialize};
31use serde_json::{Value, json};
32
33use crate::app::{AppSpec, Resources};
34use crate::error::{Error, Result};
35use crate::org::OrgId;
36
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39#[serde(deny_unknown_fields)]
40pub struct Template {
41 pub id: String,
43 pub name: String,
44 #[serde(default, skip_serializing_if = "String::is_empty")]
45 pub description: String,
46 #[serde(default, skip_serializing_if = "String::is_empty")]
48 pub version: String,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub logo: Option<String>,
52 #[serde(default, skip_serializing_if = "Vec::is_empty")]
53 pub tags: Vec<String>,
54 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
56 pub links: BTreeMap<String, String>,
57 #[serde(default, skip_serializing_if = "Vec::is_empty")]
58 pub variables: Vec<Variable>,
59 pub apps: Vec<AppTemplate>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub main: Option<String>,
63 #[serde(default, skip_serializing_if = "Vec::is_empty")]
65 pub notes: Vec<String>,
66}
67
68#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "lowercase")]
71pub enum VarKind {
72 #[default]
74 String,
75 Email,
76 Url,
77 Int,
78 Domain,
80 Password,
82 Base64,
84 Hex,
86 Uuid,
88 Port,
90 Username,
92 Timestamp,
94 Jwt,
96}
97
98impl VarKind {
99 fn generated(self) -> bool {
100 !matches!(
101 self,
102 VarKind::String | VarKind::Email | VarKind::Url | VarKind::Int | VarKind::Domain
103 )
104 }
105
106 fn secret_by_default(self) -> bool {
107 matches!(
108 self,
109 VarKind::Password | VarKind::Base64 | VarKind::Hex | VarKind::Jwt
110 )
111 }
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(deny_unknown_fields)]
116pub struct JwtSpec {
117 pub secret: String,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub payload: Option<String>,
123}
124
125#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(deny_unknown_fields)]
127pub struct Variable {
128 pub name: String,
130 #[serde(rename = "type", default)]
131 pub kind: VarKind,
132 #[serde(default, skip_serializing_if = "String::is_empty")]
133 pub label: String,
134 #[serde(default, skip_serializing_if = "String::is_empty")]
135 pub description: String,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub default: Option<String>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub value: Option<String>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub required: Option<bool>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub length: Option<u32>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub bytes: Option<u32>,
150 #[serde(default, skip_serializing_if = "Vec::is_empty")]
151 pub choices: Vec<String>,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub min_length: Option<u32>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub max_length: Option<u32>,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub min: Option<i64>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub max: Option<i64>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub secret: Option<bool>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub jwt: Option<JwtSpec>,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub at: Option<String>,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub unit: Option<String>,
171}
172
173impl Variable {
174 pub fn is_input(&self) -> bool {
175 self.value.is_none()
176 }
177
178 fn required(&self) -> bool {
179 self.required.unwrap_or(
180 self.value.is_none() && self.default.is_none() && !self.kind.generated() && {
181 self.kind != VarKind::Domain
182 },
183 )
184 }
185
186 fn declared_secret(&self) -> bool {
187 self.secret.unwrap_or(self.kind.secret_by_default())
188 }
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(deny_unknown_fields)]
194pub struct FileTemplate {
195 pub path: String,
196 pub content: String,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub mode: Option<String>,
201}
202
203#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
206#[serde(deny_unknown_fields)]
207pub struct AppTemplate {
208 pub name: String,
210 pub image: String,
212 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
213 pub env: BTreeMap<String, String>,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub port: Option<u16>,
216 #[serde(default, skip_serializing_if = "Vec::is_empty")]
219 pub domains: Vec<serde_json::Map<String, Value>>,
220 #[serde(default, skip_serializing_if = "Vec::is_empty")]
222 pub volumes: Vec<String>,
223 #[serde(default, skip_serializing_if = "Vec::is_empty")]
225 pub ports: Vec<String>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub replicas: Option<u32>,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub command: Option<Value>,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub args: Option<Value>,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub healthcheck: Option<Value>,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub resources: Option<Resources>,
240 #[serde(default, skip_serializing_if = "Vec::is_empty")]
241 pub files: Vec<FileTemplate>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub user: Option<String>,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub working_dir: Option<String>,
246 #[serde(default, skip_serializing_if = "Vec::is_empty")]
248 pub depends_on: Vec<String>,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq)]
255enum Part {
256 Lit(String),
257 Var(String),
258 Host(String),
259}
260
261fn parse_expr(s: &str) -> std::result::Result<Vec<Part>, String> {
264 let mut out = Vec::new();
265 let mut lit = String::new();
266 let b = s.as_bytes();
267 let mut i = 0;
268 while i < b.len() {
269 if b[i] == b'$' && b.get(i + 1) == Some(&b'$') {
270 lit.push('$');
271 i += 2;
272 } else if b[i] == b'$' && b.get(i + 1) == Some(&b'{') {
273 let end = s[i + 2..]
274 .find('}')
275 .ok_or_else(|| format!("unterminated ${{ in {s:?}"))?;
276 let inner = &s[i + 2..i + 2 + end];
277 if !lit.is_empty() {
278 out.push(Part::Lit(std::mem::take(&mut lit)));
279 }
280 if let Some(k) = inner.strip_prefix("host:") {
281 out.push(Part::Host(k.to_string()));
282 } else if valid_var_name(inner) {
283 out.push(Part::Var(inner.to_string()));
284 } else {
285 return Err(format!(
286 "${{{inner}}}: only ${{variable}} and ${{host:APP}} are expanded (write $${{ for a literal ${{)"
287 ));
288 }
289 i += 2 + end + 1;
290 } else {
291 let c = s[i..].chars().next().unwrap_or('\0');
292 lit.push(c);
293 i += c.len_utf8();
294 }
295 }
296 if !lit.is_empty() {
297 out.push(Part::Lit(lit));
298 }
299 Ok(out)
300}
301
302fn valid_var_name(s: &str) -> bool {
303 !s.is_empty()
304 && s.len() <= 64
305 && s.starts_with(|c: char| c.is_ascii_lowercase())
306 && s.chars()
307 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
308}
309
310fn valid_key(s: &str) -> bool {
311 !s.is_empty()
312 && s.len() <= 30
313 && s.starts_with(|c: char| c.is_ascii_lowercase())
314 && !s.ends_with('-')
315 && s.chars()
316 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
317}
318
319#[derive(Debug, Clone, PartialEq, Eq)]
321enum Seg {
322 Lit(String),
323 Secret { var: String, value: String },
324}
325
326fn concat(segs: &[Seg]) -> String {
327 segs.iter()
328 .map(|s| match s {
329 Seg::Lit(t) => t.as_str(),
330 Seg::Secret { value, .. } => value.as_str(),
331 })
332 .collect()
333}
334
335fn has_secret(segs: &[Seg]) -> bool {
336 segs.iter().any(|s| matches!(s, Seg::Secret { .. }))
337}
338
339fn template_strings(t: &Template) -> Vec<(String, String)> {
342 let mut v = Vec::new();
343 for var in &t.variables {
344 for (what, s) in [("default", &var.default), ("value", &var.value)] {
345 if let Some(s) = s {
346 v.push((format!("variable {} {what}", var.name), s.clone()));
347 }
348 }
349 if let Some(j) = &var.jwt {
350 if let Some(p) = &j.payload {
351 v.push((format!("variable {} jwt payload", var.name), p.clone()));
352 }
353 }
354 }
355 for a in &t.apps {
356 let at = |w: &str| format!("app {} {w}", a.name);
357 v.push((at("image"), a.image.clone()));
358 for (k, s) in &a.env {
359 v.push((at(&format!("env {k}")), s.clone()));
360 }
361 for d in &a.domains {
362 for (k, s) in d {
363 if let Some(s) = s.as_str() {
364 v.push((at(&format!("domain {k}")), s.to_string()));
365 }
366 }
367 }
368 for s in a.volumes.iter().chain(&a.ports) {
369 v.push((at("volumes/ports"), s.clone()));
370 }
371 for c in [&a.command, &a.args, &a.healthcheck].into_iter().flatten() {
372 collect_strings(c, &mut |s| {
373 v.push((at("command/healthcheck"), s.to_string()))
374 });
375 }
376 for f in &a.files {
377 v.push((at(&format!("file {}", f.path)), f.content.clone()));
378 }
379 for s in [&a.user, &a.working_dir].into_iter().flatten() {
380 v.push((at("user/working_dir"), s.clone()));
381 }
382 }
383 v
384}
385
386fn collect_strings(v: &Value, f: &mut dyn FnMut(&str)) {
387 match v {
388 Value::String(s) => f(s),
389 Value::Array(a) => a.iter().for_each(|x| collect_strings(x, f)),
390 Value::Object(o) => o.values().for_each(|x| collect_strings(x, f)),
391 _ => {}
392 }
393}
394
395fn argv(v: &Value, what: &str) -> Result<Vec<String>> {
396 match v {
397 Value::String(s) => {
398 crate::flex::split_words(s).map_err(|e| Error::invalid(format!("{what}: {e}")))
399 }
400 Value::Array(a) => a
401 .iter()
402 .map(|x| match x {
403 Value::String(s) => Ok(s.clone()),
404 Value::Number(n) => Ok(n.to_string()),
405 Value::Bool(b) => Ok(b.to_string()),
406 _ => Err(Error::invalid(format!("{what}: arguments are strings"))),
407 })
408 .collect(),
409 _ => Err(Error::invalid(format!(
410 "{what}: a list of arguments, or a command line"
411 ))),
412 }
413}
414
415impl Template {
416 pub fn from_yaml(text: &str) -> Result<Template> {
418 let v: Value =
419 serde_yaml_ng::from_str(text).map_err(|e| Error::invalid(format!("template: {e}")))?;
420 let t: Template =
421 serde_json::from_value(v).map_err(|e| Error::invalid(format!("template: {e}")))?;
422 t.validate()?;
423 Ok(t)
424 }
425
426 pub fn var(&self, name: &str) -> Option<&Variable> {
427 self.variables.iter().find(|v| v.name == name)
428 }
429
430 pub fn main_key(&self) -> Option<&str> {
432 match &self.main {
433 Some(m) => Some(m.as_str()),
434 None if self.apps.len() == 1 => Some(self.apps[0].name.as_str()),
435 None => None,
436 }
437 }
438
439 pub fn validate(&self) -> Result<()> {
441 let bad = |m: String| Err(Error::invalid(format!("template {}: {m}", self.id)));
442 if !valid_key(&self.id) {
443 return bad("the id is [a-z0-9-], starting with a letter".into());
444 }
445 if self.name.trim().is_empty() {
446 return bad("a template needs a name".into());
447 }
448 if self.apps.is_empty() {
449 return bad("a template needs at least one app".into());
450 }
451 let mut names = BTreeSet::new();
452 for v in &self.variables {
453 if !valid_var_name(&v.name) {
454 return bad(format!(
455 "variable {:?}: [a-z][a-z0-9_]*, at most 64 characters",
456 v.name
457 ));
458 }
459 if !names.insert(v.name.as_str()) {
460 return bad(format!("variable {} is declared twice", v.name));
461 }
462 if v.kind == VarKind::Jwt && v.jwt.is_none() {
463 return bad(format!("variable {}: a jwt needs jwt.secret", v.name));
464 }
465 if let Some(j) = &v.jwt {
466 if !self.variables.iter().any(|x| x.name == j.secret) {
467 return bad(format!(
468 "variable {}: jwt.secret names no variable ({})",
469 v.name, j.secret
470 ));
471 }
472 }
473 if v.value.is_some() && v.kind.generated() {
474 return bad(format!(
475 "variable {}: a generated type takes no value",
476 v.name
477 ));
478 }
479 }
480 let mut keys = BTreeSet::new();
481 for a in &self.apps {
482 if !valid_key(&a.name) {
483 return bad(format!(
484 "app {:?}: [a-z0-9-], starting with a letter, at most 30",
485 a.name
486 ));
487 }
488 if !keys.insert(a.name.as_str()) {
489 return bad(format!("app {} is declared twice", a.name));
490 }
491 if a.command.is_some() && a.args.is_some() {
492 return bad(format!(
493 "app {}: give command (the whole command line) or args (after the image's entrypoint), not both",
494 a.name
495 ));
496 }
497 }
498 if let Some(m) = &self.main {
499 if !keys.contains(m.as_str()) {
500 return bad(format!("main names no app ({m})"));
501 }
502 }
503 for a in &self.apps {
504 for d in &a.depends_on {
505 if !keys.contains(d.as_str()) || d == &a.name {
506 return bad(format!("app {}: depends_on {d}: no such other app", a.name));
507 }
508 }
509 }
510 self.order()?;
511 for (at, s) in template_strings(self) {
512 let parts = match parse_expr(&s) {
513 Ok(p) => p,
514 Err(e) => return bad(format!("{at}: {e}")),
515 };
516 for p in parts {
517 match p {
518 Part::Var(v) if !names.contains(v.as_str()) => {
519 return bad(format!("{at}: ${{{v}}} names no variable"));
520 }
521 Part::Host(k) if !keys.contains(k.as_str()) => {
522 return bad(format!("{at}: ${{host:{k}}} names no app"));
523 }
524 _ => {}
525 }
526 }
527 }
528 self.var_order()?;
529 Ok(())
530 }
531
532 pub fn order(&self) -> Result<Vec<String>> {
534 let mut done: Vec<String> = Vec::new();
535 let mut left: Vec<&AppTemplate> = self.apps.iter().collect();
536 while !left.is_empty() {
537 let i = left
538 .iter()
539 .position(|a| a.depends_on.iter().all(|d| done.contains(d)))
540 .ok_or_else(|| {
541 Error::invalid(format!(
542 "template {}: depends_on has a cycle among {}",
543 self.id,
544 left.iter()
545 .map(|a| a.name.as_str())
546 .collect::<Vec<_>>()
547 .join(", ")
548 ))
549 })?;
550 done.push(left.remove(i).name.clone());
551 }
552 Ok(done)
553 }
554
555 fn var_order(&self) -> Result<Vec<&Variable>> {
557 let deps = |v: &Variable| -> Vec<String> {
558 let mut out = Vec::new();
559 for s in [&v.value, &v.default].into_iter().flatten() {
560 for p in parse_expr(s).unwrap_or_default() {
561 if let Part::Var(n) = p {
562 out.push(n);
563 }
564 }
565 }
566 if let Some(j) = &v.jwt {
567 out.push(j.secret.clone());
568 for p in j
569 .payload
570 .as_deref()
571 .map(parse_expr)
572 .and_then(|r| r.ok())
573 .unwrap_or_default()
574 {
575 if let Part::Var(n) = p {
576 out.push(n);
577 }
578 }
579 }
580 out
581 };
582 let mut done: Vec<&Variable> = Vec::new();
583 let mut left: Vec<&Variable> = self.variables.iter().collect();
584 while !left.is_empty() {
585 let i = left
586 .iter()
587 .position(|v| deps(v).iter().all(|d| done.iter().any(|x| &x.name == d)))
588 .ok_or_else(|| {
589 Error::invalid(format!(
590 "template {}: variables refer to each other in a cycle among {}",
591 self.id,
592 left.iter()
593 .map(|v| v.name.as_str())
594 .collect::<Vec<_>>()
595 .join(", ")
596 ))
597 })?;
598 done.push(left.remove(i));
599 }
600 Ok(done)
601 }
602}
603
604#[derive(Debug, Clone)]
608pub struct Params {
609 pub org: OrgId,
610 pub project: String,
611 pub environment: String,
612 pub instance: String,
614 pub values: BTreeMap<String, String>,
615}
616
617pub type EntrypointFn = dyn Fn(&str) -> Result<Option<Vec<String>>> + Send + Sync;
619
620pub struct Context<'a> {
622 pub public_ip: Option<IpAddr>,
624 pub entrypoint: &'a EntrypointFn,
625 pub free_port: &'a (dyn Fn() -> Option<u16> + Send + Sync),
626}
627
628#[derive(Debug, Clone, Serialize)]
630pub struct PlannedSecret {
631 pub name: String,
632 pub holds: String,
634 #[serde(skip)]
635 pub value: String,
636}
637
638#[derive(Debug, Clone, Serialize)]
640pub struct PlannedVar {
641 pub name: String,
642 #[serde(skip_serializing_if = "Option::is_none")]
643 pub value: Option<String>,
644 pub secret: bool,
645 pub source: String,
647}
648
649#[derive(Debug, Clone, Serialize)]
651pub struct Plan {
652 pub template: String,
653 #[serde(skip_serializing_if = "String::is_empty")]
654 pub version: String,
655 pub instance: String,
656 pub project: String,
657 pub environment: String,
658 pub stack: String,
659 pub apps: Vec<AppSpec>,
660 pub order: Vec<String>,
662 pub secrets: Vec<PlannedSecret>,
663 pub variables: Vec<PlannedVar>,
664 pub urls: Vec<String>,
666 pub notes: Vec<String>,
667}
668
669pub fn var_secret(instance: &str, var: &str) -> String {
671 format!("tpl.{instance}.{var}")
672}
673
674pub fn secret_prefix(instance: &str) -> String {
676 format!("tpl.{instance}.")
677}
678
679pub fn app_name(instance: &str, key: &str, main: bool) -> String {
681 if main || key == instance {
682 instance.to_string()
683 } else if key.starts_with(&format!("{instance}-")) {
684 key.to_string()
685 } else {
686 format!("{instance}-{key}")
687 }
688}
689
690#[derive(Debug, Clone)]
691struct Resolved {
692 value: Option<String>,
694 secret: bool,
695 auto: bool,
697}
698
699struct Renderer<'a> {
700 p: &'a Params,
701 stack: String,
702 names: BTreeMap<String, String>,
703 vars: BTreeMap<String, Resolved>,
704}
705
706impl Renderer<'_> {
707 fn render(&self, s: &str, at: &str) -> Result<Vec<Seg>> {
708 let parts = parse_expr(s).map_err(|e| Error::invalid(format!("{at}: {e}")))?;
709 let mut out = Vec::new();
710 for p in parts {
711 match p {
712 Part::Lit(l) => out.push(Seg::Lit(l)),
713 Part::Host(k) => {
714 let n = self.names.get(&k).ok_or_else(|| {
715 Error::invalid(format!("{at}: ${{host:{k}}} names no app"))
716 })?;
717 out.push(Seg::Lit(format!("{n}.{}", self.stack)));
718 }
719 Part::Var(v) => {
720 let r = self
721 .vars
722 .get(&v)
723 .ok_or_else(|| Error::invalid(format!("{at}: ${{{v}}} is not set yet")))?;
724 let value = r.value.clone().ok_or_else(|| {
725 Error::invalid(format!(
726 "{at}: ${{{v}}} is a generated hostname, and this server has no public address for one (isb serve --ingress-public-ip); give {v} a domain"
727 ))
728 })?;
729 out.push(if r.secret {
730 Seg::Secret { var: v, value }
731 } else {
732 Seg::Lit(value)
733 });
734 }
735 }
736 }
737 Ok(out)
738 }
739
740 fn plain(&self, s: &str, at: &str) -> Result<String> {
742 let segs = self.render(s, at)?;
743 if has_secret(&segs) {
744 return Err(Error::invalid(format!(
745 "{at}: a secret variable cannot go here (only into env, files, command and healthcheck)"
746 )));
747 }
748 Ok(concat(&segs))
749 }
750}
751
752fn validate_input(v: &Variable, s: &str) -> std::result::Result<(), String> {
753 let n = s.chars().count() as u32;
754 if let Some(m) = v.min_length {
755 if n < m {
756 return Err(format!("at least {m} characters"));
757 }
758 }
759 if let Some(m) = v.max_length {
760 if n > m {
761 return Err(format!("at most {m} characters"));
762 }
763 }
764 if !v.choices.is_empty() && !v.choices.iter().any(|c| c == s) {
765 return Err(format!("one of {}", v.choices.join(", ")));
766 }
767 if s.contains('\0') {
768 return Err("no NUL characters".into());
769 }
770 match v.kind {
771 VarKind::Email => {
772 let ok = s.split_once('@').is_some_and(|(a, d)| {
773 !a.is_empty() && d.contains('.') && !d.starts_with('.') && !d.ends_with('.')
774 }) && !s.contains(char::is_whitespace);
775 if !ok {
776 return Err("an email address".into());
777 }
778 }
779 VarKind::Url => {
780 if !(s.starts_with("http://") || s.starts_with("https://"))
781 || s.contains(char::is_whitespace)
782 {
783 return Err("an http(s) URL".into());
784 }
785 }
786 VarKind::Int | VarKind::Port | VarKind::Timestamp => {
787 let i: i64 = s.parse().map_err(|_| "a whole number".to_string())?;
788 let (lo, hi) = match v.kind {
789 VarKind::Port => (Some(1), Some(65535)),
790 _ => (v.min, v.max),
791 };
792 if lo.is_some_and(|l| i < l) || hi.is_some_and(|h| i > h) {
793 return Err(format!(
794 "between {} and {}",
795 lo.map(|x| x.to_string()).unwrap_or("-".into()),
796 hi.map(|x| x.to_string()).unwrap_or("-".into())
797 ));
798 }
799 }
800 VarKind::Domain => {
801 if !(s.is_empty() || s == "auto" || is_hostname(s)) {
802 return Err("a hostname (example.com), or empty for a generated one".into());
803 }
804 }
805 _ => {
806 if s.is_empty() && v.required() {
807 return Err("required".into());
808 }
809 }
810 }
811 Ok(())
812}
813
814fn is_hostname(s: &str) -> bool {
815 s.len() <= 253
816 && s.contains('.')
817 && s.split('.').all(|l| {
818 !l.is_empty()
819 && l.len() <= 63
820 && !l.starts_with('-')
821 && !l.ends_with('-')
822 && l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
823 })
824}
825
826fn sh_single(s: &str) -> String {
827 format!("'{}'", s.replace('\'', "'\\''"))
828}
829
830fn secret_env_name(var: &str) -> String {
832 format!("ISB_TPL_{}", var.to_ascii_uppercase())
833}
834
835pub fn plan(t: &Template, p: &Params, ctx: &Context) -> Result<Plan> {
838 t.validate()?;
839 crate::app::validate_app_name(&p.instance)
840 .map_err(|_| Error::invalid(format!("name {:?}: [a-z0-9-], starting with a letter, at most 30 characters (it names the apps)", p.instance)))?;
841 let stack = crate::app::stack_name(&p.project, &p.environment)?;
842 planning::check_values(t, p)?;
843 let names = planning::app_names(t, p)?;
844 let uses = planning::domain_uses(t);
845 let mut r = Renderer {
846 p,
847 stack: stack.clone(),
848 names,
849 vars: BTreeMap::new(),
850 };
851 let mut out = planning::Out::default();
852 let variables = planning::resolve_vars(t, &mut r, ctx, &uses, &mut out)?;
853 let apps = t
854 .apps
855 .iter()
856 .map(|a| planning::plan_app(a, &r, ctx, &uses, &mut out))
857 .collect::<Result<Vec<_>>>()?;
858 let order = t
859 .order()?
860 .into_iter()
861 .map(|k| r.names[&k].clone())
862 .collect();
863 let mut seen = BTreeSet::new();
864 out.secrets.retain(|s| seen.insert(s.name.clone()));
865 out.notes.extend(t.notes.iter().cloned());
866 Ok(Plan {
867 template: t.id.clone(),
868 version: t.version.clone(),
869 instance: p.instance.clone(),
870 project: p.project.clone(),
871 environment: p.environment.clone(),
872 stack,
873 apps,
874 order,
875 secrets: out.secrets,
876 variables,
877 urls: out.urls,
878 notes: out.notes,
879 })
880}
881
882pub fn skopeo_entrypoint(image: &str) -> Result<Option<Vec<String>>> {
885 use std::io::Read;
886 use std::time::{Duration, Instant};
887 let src = crate::plan::ImageSource::parse(image)?;
888 if !src.is_oci() {
889 return Err(Error::invalid(format!("{image} is not an OCI image")));
890 }
891 let host = src
892 .server
893 .as_deref()
894 .and_then(|s| s.strip_prefix("https://"))
895 .ok_or_else(|| Error::invalid(format!("{image}: no registry")))?;
896 let r = format!("docker://{host}/{}", src.alias);
897 let mut child = std::process::Command::new("skopeo")
898 .args(["inspect", "--config", &r])
899 .stdin(std::process::Stdio::null())
900 .stdout(std::process::Stdio::piped())
901 .stderr(std::process::Stdio::null())
902 .spawn()
903 .map_err(|e| Error::invalid(format!("skopeo: {e}")))?;
904 let mut out = Vec::new();
905 let mut stdout = child.stdout.take().expect("piped");
906 let reader = std::thread::spawn(move || {
907 let _ = stdout.read_to_end(&mut out);
908 out
909 });
910 let started = Instant::now();
911 let status = loop {
912 match child.try_wait()? {
913 Some(s) => break s,
914 None if started.elapsed() > Duration::from_secs(60) => {
915 let _ = child.kill();
916 let _ = child.wait();
917 return Err(Error::invalid(format!("skopeo inspect {r}: timed out")));
918 }
919 None => std::thread::sleep(Duration::from_millis(100)),
920 }
921 };
922 let out = reader.join().unwrap_or_default();
923 if !status.success() {
924 return Err(Error::invalid(format!("skopeo inspect {r} failed")));
925 }
926 let v: Value = serde_json::from_slice(&out)
927 .map_err(|e| Error::invalid(format!("skopeo inspect {r}: {e}")))?;
928 Ok(v["config"]["Entrypoint"].as_array().map(|a| {
929 a.iter()
930 .filter_map(|x| x.as_str().map(String::from))
931 .collect()
932 }))
933}
934
935#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
937pub struct Instance {
938 pub name: String,
939 pub template: String,
941 #[serde(default, skip_serializing_if = "String::is_empty")]
942 pub version: String,
943 pub project: String,
944 pub environment: String,
945 pub apps: Vec<String>,
946 pub secrets: Vec<String>,
947 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
949 pub variables: BTreeMap<String, String>,
950 #[serde(default, skip_serializing_if = "Vec::is_empty")]
951 pub urls: Vec<String>,
952 pub created_at: u64,
953 pub created_by: String,
954 #[serde(default, skip_serializing_if = "Option::is_none")]
957 pub stopped: Option<Stopped>,
958}
959
960#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
962pub struct Stopped {
963 pub app: String,
964 pub reason: String,
965 #[serde(default)]
967 pub not_started: Vec<String>,
968}
969
970#[cfg(test)]
971mod tests;