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 = "Option::is_none")]
249 pub secret_on_change: Option<crate::spec::OnChange>,
250 #[serde(default, skip_serializing_if = "Vec::is_empty")]
252 pub depends_on: Vec<String>,
253}
254
255#[derive(Debug, Clone, PartialEq, Eq)]
259enum Part {
260 Lit(String),
261 Var(String),
262 Host(String),
263}
264
265fn parse_expr(s: &str) -> std::result::Result<Vec<Part>, String> {
268 let mut out = Vec::new();
269 let mut lit = String::new();
270 let b = s.as_bytes();
271 let mut i = 0;
272 while i < b.len() {
273 if b[i] == b'$' && b.get(i + 1) == Some(&b'$') {
274 lit.push('$');
275 i += 2;
276 } else if b[i] == b'$' && b.get(i + 1) == Some(&b'{') {
277 let end = s[i + 2..]
278 .find('}')
279 .ok_or_else(|| format!("unterminated ${{ in {s:?}"))?;
280 let inner = &s[i + 2..i + 2 + end];
281 if !lit.is_empty() {
282 out.push(Part::Lit(std::mem::take(&mut lit)));
283 }
284 if let Some(k) = inner.strip_prefix("host:") {
285 out.push(Part::Host(k.to_string()));
286 } else if valid_var_name(inner) {
287 out.push(Part::Var(inner.to_string()));
288 } else {
289 return Err(format!(
290 "${{{inner}}}: only ${{variable}} and ${{host:APP}} are expanded (write $${{ for a literal ${{)"
291 ));
292 }
293 i += 2 + end + 1;
294 } else {
295 let c = s[i..].chars().next().unwrap_or('\0');
296 lit.push(c);
297 i += c.len_utf8();
298 }
299 }
300 if !lit.is_empty() {
301 out.push(Part::Lit(lit));
302 }
303 Ok(out)
304}
305
306fn valid_var_name(s: &str) -> bool {
307 !s.is_empty()
308 && s.len() <= 64
309 && s.starts_with(|c: char| c.is_ascii_lowercase())
310 && s.chars()
311 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
312}
313
314fn valid_key(s: &str) -> bool {
315 !s.is_empty()
316 && s.len() <= 30
317 && s.starts_with(|c: char| c.is_ascii_lowercase())
318 && !s.ends_with('-')
319 && s.chars()
320 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
321}
322
323#[derive(Debug, Clone, PartialEq, Eq)]
325enum Seg {
326 Lit(String),
327 Secret { var: String, value: String },
328}
329
330fn concat(segs: &[Seg]) -> String {
331 segs.iter()
332 .map(|s| match s {
333 Seg::Lit(t) => t.as_str(),
334 Seg::Secret { value, .. } => value.as_str(),
335 })
336 .collect()
337}
338
339fn has_secret(segs: &[Seg]) -> bool {
340 segs.iter().any(|s| matches!(s, Seg::Secret { .. }))
341}
342
343fn template_strings(t: &Template) -> Vec<(String, String)> {
346 let mut v = Vec::new();
347 for var in &t.variables {
348 for (what, s) in [("default", &var.default), ("value", &var.value)] {
349 if let Some(s) = s {
350 v.push((format!("variable {} {what}", var.name), s.clone()));
351 }
352 }
353 if let Some(j) = &var.jwt {
354 if let Some(p) = &j.payload {
355 v.push((format!("variable {} jwt payload", var.name), p.clone()));
356 }
357 }
358 }
359 for a in &t.apps {
360 let at = |w: &str| format!("app {} {w}", a.name);
361 v.push((at("image"), a.image.clone()));
362 for (k, s) in &a.env {
363 v.push((at(&format!("env {k}")), s.clone()));
364 }
365 for d in &a.domains {
366 for (k, s) in d {
367 if let Some(s) = s.as_str() {
368 v.push((at(&format!("domain {k}")), s.to_string()));
369 }
370 }
371 }
372 for s in a.volumes.iter().chain(&a.ports) {
373 v.push((at("volumes/ports"), s.clone()));
374 }
375 for c in [&a.command, &a.args, &a.healthcheck].into_iter().flatten() {
376 collect_strings(c, &mut |s| {
377 v.push((at("command/healthcheck"), s.to_string()))
378 });
379 }
380 for f in &a.files {
381 v.push((at(&format!("file {}", f.path)), f.content.clone()));
382 }
383 for s in [&a.user, &a.working_dir].into_iter().flatten() {
384 v.push((at("user/working_dir"), s.clone()));
385 }
386 }
387 v
388}
389
390fn collect_strings(v: &Value, f: &mut dyn FnMut(&str)) {
391 match v {
392 Value::String(s) => f(s),
393 Value::Array(a) => a.iter().for_each(|x| collect_strings(x, f)),
394 Value::Object(o) => o.values().for_each(|x| collect_strings(x, f)),
395 _ => {}
396 }
397}
398
399fn argv(v: &Value, what: &str) -> Result<Vec<String>> {
400 match v {
401 Value::String(s) => {
402 crate::flex::split_words(s).map_err(|e| Error::invalid(format!("{what}: {e}")))
403 }
404 Value::Array(a) => a
405 .iter()
406 .map(|x| match x {
407 Value::String(s) => Ok(s.clone()),
408 Value::Number(n) => Ok(n.to_string()),
409 Value::Bool(b) => Ok(b.to_string()),
410 _ => Err(Error::invalid(format!("{what}: arguments are strings"))),
411 })
412 .collect(),
413 _ => Err(Error::invalid(format!(
414 "{what}: a list of arguments, or a command line"
415 ))),
416 }
417}
418
419impl Template {
420 pub fn from_yaml(text: &str) -> Result<Template> {
422 let v: Value =
423 serde_yaml_ng::from_str(text).map_err(|e| Error::invalid(format!("template: {e}")))?;
424 let t: Template =
425 serde_json::from_value(v).map_err(|e| Error::invalid(format!("template: {e}")))?;
426 t.validate()?;
427 Ok(t)
428 }
429
430 pub fn var(&self, name: &str) -> Option<&Variable> {
431 self.variables.iter().find(|v| v.name == name)
432 }
433
434 pub fn main_key(&self) -> Option<&str> {
436 match &self.main {
437 Some(m) => Some(m.as_str()),
438 None if self.apps.len() == 1 => Some(self.apps[0].name.as_str()),
439 None => None,
440 }
441 }
442
443 pub fn validate(&self) -> Result<()> {
445 let bad = |m: String| Err(Error::invalid(format!("template {}: {m}", self.id)));
446 if !valid_key(&self.id) {
447 return bad("the id is [a-z0-9-], starting with a letter".into());
448 }
449 if self.name.trim().is_empty() {
450 return bad("a template needs a name".into());
451 }
452 if self.apps.is_empty() {
453 return bad("a template needs at least one app".into());
454 }
455 let mut names = BTreeSet::new();
456 for v in &self.variables {
457 if !valid_var_name(&v.name) {
458 return bad(format!(
459 "variable {:?}: [a-z][a-z0-9_]*, at most 64 characters",
460 v.name
461 ));
462 }
463 if !names.insert(v.name.as_str()) {
464 return bad(format!("variable {} is declared twice", v.name));
465 }
466 if v.kind == VarKind::Jwt && v.jwt.is_none() {
467 return bad(format!("variable {}: a jwt needs jwt.secret", v.name));
468 }
469 if let Some(j) = &v.jwt {
470 if !self.variables.iter().any(|x| x.name == j.secret) {
471 return bad(format!(
472 "variable {}: jwt.secret names no variable ({})",
473 v.name, j.secret
474 ));
475 }
476 }
477 if v.value.is_some() && v.kind.generated() {
478 return bad(format!(
479 "variable {}: a generated type takes no value",
480 v.name
481 ));
482 }
483 }
484 let mut keys = BTreeSet::new();
485 for a in &self.apps {
486 if !valid_key(&a.name) {
487 return bad(format!(
488 "app {:?}: [a-z0-9-], starting with a letter, at most 30",
489 a.name
490 ));
491 }
492 if !keys.insert(a.name.as_str()) {
493 return bad(format!("app {} is declared twice", a.name));
494 }
495 if a.command.is_some() && a.args.is_some() {
496 return bad(format!(
497 "app {}: give command (the whole command line) or args (after the image's entrypoint), not both",
498 a.name
499 ));
500 }
501 }
502 if let Some(m) = &self.main {
503 if !keys.contains(m.as_str()) {
504 return bad(format!("main names no app ({m})"));
505 }
506 }
507 for a in &self.apps {
508 for d in &a.depends_on {
509 if !keys.contains(d.as_str()) || d == &a.name {
510 return bad(format!("app {}: depends_on {d}: no such other app", a.name));
511 }
512 }
513 }
514 self.order()?;
515 for (at, s) in template_strings(self) {
516 let parts = match parse_expr(&s) {
517 Ok(p) => p,
518 Err(e) => return bad(format!("{at}: {e}")),
519 };
520 for p in parts {
521 match p {
522 Part::Var(v) if !names.contains(v.as_str()) => {
523 return bad(format!("{at}: ${{{v}}} names no variable"));
524 }
525 Part::Host(k) if !keys.contains(k.as_str()) => {
526 return bad(format!("{at}: ${{host:{k}}} names no app"));
527 }
528 _ => {}
529 }
530 }
531 }
532 self.var_order()?;
533 Ok(())
534 }
535
536 pub fn order(&self) -> Result<Vec<String>> {
538 let mut done: Vec<String> = Vec::new();
539 let mut left: Vec<&AppTemplate> = self.apps.iter().collect();
540 while !left.is_empty() {
541 let i = left
542 .iter()
543 .position(|a| a.depends_on.iter().all(|d| done.contains(d)))
544 .ok_or_else(|| {
545 Error::invalid(format!(
546 "template {}: depends_on has a cycle among {}",
547 self.id,
548 left.iter()
549 .map(|a| a.name.as_str())
550 .collect::<Vec<_>>()
551 .join(", ")
552 ))
553 })?;
554 done.push(left.remove(i).name.clone());
555 }
556 Ok(done)
557 }
558
559 fn var_order(&self) -> Result<Vec<&Variable>> {
561 let deps = |v: &Variable| -> Vec<String> {
562 let mut out = Vec::new();
563 for s in [&v.value, &v.default].into_iter().flatten() {
564 for p in parse_expr(s).unwrap_or_default() {
565 if let Part::Var(n) = p {
566 out.push(n);
567 }
568 }
569 }
570 if let Some(j) = &v.jwt {
571 out.push(j.secret.clone());
572 for p in j
573 .payload
574 .as_deref()
575 .map(parse_expr)
576 .and_then(|r| r.ok())
577 .unwrap_or_default()
578 {
579 if let Part::Var(n) = p {
580 out.push(n);
581 }
582 }
583 }
584 out
585 };
586 let mut done: Vec<&Variable> = Vec::new();
587 let mut left: Vec<&Variable> = self.variables.iter().collect();
588 while !left.is_empty() {
589 let i = left
590 .iter()
591 .position(|v| deps(v).iter().all(|d| done.iter().any(|x| &x.name == d)))
592 .ok_or_else(|| {
593 Error::invalid(format!(
594 "template {}: variables refer to each other in a cycle among {}",
595 self.id,
596 left.iter()
597 .map(|v| v.name.as_str())
598 .collect::<Vec<_>>()
599 .join(", ")
600 ))
601 })?;
602 done.push(left.remove(i));
603 }
604 Ok(done)
605 }
606}
607
608#[derive(Debug, Clone)]
612pub struct Params {
613 pub org: OrgId,
614 pub project: String,
615 pub environment: String,
616 pub instance: String,
618 pub values: BTreeMap<String, String>,
619}
620
621pub type EntrypointFn = dyn Fn(&str) -> Result<Option<Vec<String>>> + Send + Sync;
623
624pub struct Context<'a> {
626 pub public_ip: Option<IpAddr>,
628 pub entrypoint: &'a EntrypointFn,
629 pub free_port: &'a (dyn Fn() -> Option<u16> + Send + Sync),
630}
631
632#[derive(Debug, Clone, Serialize)]
634pub struct PlannedSecret {
635 pub name: String,
636 pub holds: String,
638 #[serde(skip)]
639 pub value: String,
640}
641
642#[derive(Debug, Clone, Serialize)]
644pub struct PlannedVar {
645 pub name: String,
646 #[serde(skip_serializing_if = "Option::is_none")]
647 pub value: Option<String>,
648 pub secret: bool,
649 pub source: String,
651}
652
653#[derive(Debug, Clone, Serialize)]
655pub struct Plan {
656 pub template: String,
657 #[serde(skip_serializing_if = "String::is_empty")]
658 pub version: String,
659 pub instance: String,
660 pub project: String,
661 pub environment: String,
662 pub stack: String,
663 pub apps: Vec<AppSpec>,
664 pub order: Vec<String>,
666 pub secrets: Vec<PlannedSecret>,
667 pub variables: Vec<PlannedVar>,
668 pub urls: Vec<String>,
670 pub notes: Vec<String>,
671}
672
673pub fn var_secret(instance: &str, var: &str) -> String {
675 format!("tpl.{instance}.{var}")
676}
677
678pub fn secret_prefix(instance: &str) -> String {
680 format!("tpl.{instance}.")
681}
682
683pub fn app_name(instance: &str, key: &str, main: bool) -> String {
685 if main || key == instance {
686 instance.to_string()
687 } else if key.starts_with(&format!("{instance}-")) {
688 key.to_string()
689 } else {
690 format!("{instance}-{key}")
691 }
692}
693
694#[derive(Debug, Clone)]
695struct Resolved {
696 value: Option<String>,
698 secret: bool,
699 auto: bool,
701}
702
703struct Renderer<'a> {
704 p: &'a Params,
705 stack: String,
706 names: BTreeMap<String, String>,
707 vars: BTreeMap<String, Resolved>,
708}
709
710impl Renderer<'_> {
711 fn render(&self, s: &str, at: &str) -> Result<Vec<Seg>> {
712 let parts = parse_expr(s).map_err(|e| Error::invalid(format!("{at}: {e}")))?;
713 let mut out = Vec::new();
714 for p in parts {
715 match p {
716 Part::Lit(l) => out.push(Seg::Lit(l)),
717 Part::Host(k) => {
718 let n = self.names.get(&k).ok_or_else(|| {
719 Error::invalid(format!("{at}: ${{host:{k}}} names no app"))
720 })?;
721 out.push(Seg::Lit(format!("{n}.{}", self.stack)));
722 }
723 Part::Var(v) => {
724 let r = self
725 .vars
726 .get(&v)
727 .ok_or_else(|| Error::invalid(format!("{at}: ${{{v}}} is not set yet")))?;
728 let value = r.value.clone().ok_or_else(|| {
729 Error::invalid(format!(
730 "{at}: ${{{v}}} is a generated hostname, and this server has no public address for one (isb serve --ingress-public-ip); give {v} a domain"
731 ))
732 })?;
733 out.push(if r.secret {
734 Seg::Secret { var: v, value }
735 } else {
736 Seg::Lit(value)
737 });
738 }
739 }
740 }
741 Ok(out)
742 }
743
744 fn plain(&self, s: &str, at: &str) -> Result<String> {
746 let segs = self.render(s, at)?;
747 if has_secret(&segs) {
748 return Err(Error::invalid(format!(
749 "{at}: a secret variable cannot go here (only into env, files, command and healthcheck)"
750 )));
751 }
752 Ok(concat(&segs))
753 }
754}
755
756fn validate_input(v: &Variable, s: &str) -> std::result::Result<(), String> {
757 let n = s.chars().count() as u32;
758 if let Some(m) = v.min_length {
759 if n < m {
760 return Err(format!("at least {m} characters"));
761 }
762 }
763 if let Some(m) = v.max_length {
764 if n > m {
765 return Err(format!("at most {m} characters"));
766 }
767 }
768 if !v.choices.is_empty() && !v.choices.iter().any(|c| c == s) {
769 return Err(format!("one of {}", v.choices.join(", ")));
770 }
771 if s.contains('\0') {
772 return Err("no NUL characters".into());
773 }
774 match v.kind {
775 VarKind::Email => {
776 let ok = s.split_once('@').is_some_and(|(a, d)| {
777 !a.is_empty() && d.contains('.') && !d.starts_with('.') && !d.ends_with('.')
778 }) && !s.contains(char::is_whitespace);
779 if !ok {
780 return Err("an email address".into());
781 }
782 }
783 VarKind::Url => {
784 if !(s.starts_with("http://") || s.starts_with("https://"))
785 || s.contains(char::is_whitespace)
786 {
787 return Err("an http(s) URL".into());
788 }
789 }
790 VarKind::Int | VarKind::Port | VarKind::Timestamp => {
791 let i: i64 = s.parse().map_err(|_| "a whole number".to_string())?;
792 let (lo, hi) = match v.kind {
793 VarKind::Port => (Some(1), Some(65535)),
794 _ => (v.min, v.max),
795 };
796 if lo.is_some_and(|l| i < l) || hi.is_some_and(|h| i > h) {
797 return Err(format!(
798 "between {} and {}",
799 lo.map(|x| x.to_string()).unwrap_or("-".into()),
800 hi.map(|x| x.to_string()).unwrap_or("-".into())
801 ));
802 }
803 }
804 VarKind::Domain => {
805 if !(s.is_empty() || s == "auto" || is_hostname(s)) {
806 return Err("a hostname (example.com), or empty for a generated one".into());
807 }
808 }
809 _ => {
810 if s.is_empty() && v.required() {
811 return Err("required".into());
812 }
813 }
814 }
815 Ok(())
816}
817
818fn is_hostname(s: &str) -> bool {
819 s.len() <= 253
820 && s.contains('.')
821 && s.split('.').all(|l| {
822 !l.is_empty()
823 && l.len() <= 63
824 && !l.starts_with('-')
825 && !l.ends_with('-')
826 && l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
827 })
828}
829
830fn sh_single(s: &str) -> String {
831 format!("'{}'", s.replace('\'', "'\\''"))
832}
833
834fn secret_env_name(var: &str) -> String {
836 format!("ISB_TPL_{}", var.to_ascii_uppercase())
837}
838
839pub fn plan(t: &Template, p: &Params, ctx: &Context) -> Result<Plan> {
842 t.validate()?;
843 crate::app::validate_app_name(&p.instance)
844 .map_err(|_| Error::invalid(format!("name {:?}: [a-z0-9-], starting with a letter, at most 30 characters (it names the apps)", p.instance)))?;
845 let stack = crate::app::stack_name(&p.project, &p.environment)?;
846 planning::check_values(t, p)?;
847 let names = planning::app_names(t, p)?;
848 let uses = planning::domain_uses(t);
849 let mut r = Renderer {
850 p,
851 stack: stack.clone(),
852 names,
853 vars: BTreeMap::new(),
854 };
855 let mut out = planning::Out::default();
856 let variables = planning::resolve_vars(t, &mut r, ctx, &uses, &mut out)?;
857 let apps = t
858 .apps
859 .iter()
860 .map(|a| planning::plan_app(a, &r, ctx, &uses, &mut out))
861 .collect::<Result<Vec<_>>>()?;
862 let order = t
863 .order()?
864 .into_iter()
865 .map(|k| r.names[&k].clone())
866 .collect();
867 let mut seen = BTreeSet::new();
868 out.secrets.retain(|s| seen.insert(s.name.clone()));
869 out.notes.extend(t.notes.iter().cloned());
870 Ok(Plan {
871 template: t.id.clone(),
872 version: t.version.clone(),
873 instance: p.instance.clone(),
874 project: p.project.clone(),
875 environment: p.environment.clone(),
876 stack,
877 apps,
878 order,
879 secrets: out.secrets,
880 variables,
881 urls: out.urls,
882 notes: out.notes,
883 })
884}
885
886pub fn skopeo_entrypoint(image: &str) -> Result<Option<Vec<String>>> {
889 use std::io::Read;
890 use std::time::{Duration, Instant};
891 let src = crate::plan::ImageSource::parse(image)?;
892 if !src.is_oci() {
893 return Err(Error::invalid(format!("{image} is not an OCI image")));
894 }
895 let host = src
896 .server
897 .as_deref()
898 .and_then(|s| s.strip_prefix("https://"))
899 .ok_or_else(|| Error::invalid(format!("{image}: no registry")))?;
900 let r = format!("docker://{host}/{}", src.alias);
901 let mut child = std::process::Command::new("skopeo")
902 .args(["inspect", "--config", &r])
903 .stdin(std::process::Stdio::null())
904 .stdout(std::process::Stdio::piped())
905 .stderr(std::process::Stdio::null())
906 .spawn()
907 .map_err(|e| Error::invalid(format!("skopeo: {e}")))?;
908 let mut out = Vec::new();
909 let mut stdout = child.stdout.take().expect("piped");
910 let reader = std::thread::spawn(move || {
911 let _ = stdout.read_to_end(&mut out);
912 out
913 });
914 let started = Instant::now();
915 let status = loop {
916 match child.try_wait()? {
917 Some(s) => break s,
918 None if started.elapsed() > Duration::from_secs(60) => {
919 let _ = child.kill();
920 let _ = child.wait();
921 return Err(Error::invalid(format!("skopeo inspect {r}: timed out")));
922 }
923 None => std::thread::sleep(Duration::from_millis(100)),
924 }
925 };
926 let out = reader.join().unwrap_or_default();
927 if !status.success() {
928 return Err(Error::invalid(format!("skopeo inspect {r} failed")));
929 }
930 let v: Value = serde_json::from_slice(&out)
931 .map_err(|e| Error::invalid(format!("skopeo inspect {r}: {e}")))?;
932 Ok(v["config"]["Entrypoint"].as_array().map(|a| {
933 a.iter()
934 .filter_map(|x| x.as_str().map(String::from))
935 .collect()
936 }))
937}
938
939#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
941pub struct Instance {
942 pub name: String,
943 pub template: String,
945 #[serde(default, skip_serializing_if = "String::is_empty")]
946 pub version: String,
947 pub project: String,
948 pub environment: String,
949 pub apps: Vec<String>,
950 pub secrets: Vec<String>,
951 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
953 pub variables: BTreeMap<String, String>,
954 #[serde(default, skip_serializing_if = "Vec::is_empty")]
955 pub urls: Vec<String>,
956 pub created_at: u64,
957 pub created_by: String,
958 #[serde(default, skip_serializing_if = "Option::is_none")]
961 pub stopped: Option<Stopped>,
962}
963
964#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
966pub struct Stopped {
967 pub app: String,
968 pub reason: String,
969 #[serde(default)]
971 pub not_started: Vec<String>,
972}
973
974#[cfg(test)]
975mod tests;