1use std::collections::BTreeSet;
30use std::io::Write;
31use std::path::PathBuf;
32use std::sync::{Mutex, OnceLock};
33use std::time::{Duration, Instant};
34
35use serde::{Deserialize, Serialize};
36use serde_json::{Value, json};
37
38use super::deploy::{Commit, Deployment, Status, Trigger};
39use super::forge::{self, ForgeKind, StatusSettings};
40use super::webhook::{PrAction, Provider, PullRequest};
41use super::{App, AppSpec, EnvFile, EnvValue, Rendered, Resources, Source, git};
42use crate::build::BuildRequest;
43use crate::error::{Error, Result};
44use crate::org::OrgId;
45
46pub const LABEL_PREVIEW: &str = "isb.preview";
48
49const KEEP_DEPLOYMENTS: usize = 10;
51
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54#[serde(deny_unknown_fields)]
55pub struct PreviewSettings {
56 #[serde(default)]
57 pub enabled: bool,
58 #[serde(default, skip_serializing_if = "Vec::is_empty")]
61 pub branches: Vec<String>,
62 #[serde(default = "default_max")]
65 pub max: u32,
66 #[serde(default, skip_serializing_if = "env_is_empty")]
69 pub env: EnvFile,
70 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
73 pub inherit_env: bool,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub domain: Option<String>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub port: Option<u16>,
81 #[serde(default = "one")]
82 pub replicas: u32,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub resources: Option<Resources>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub ttl: Option<String>,
90 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
93 pub forks: bool,
94 #[serde(default, skip_serializing_if = "Vec::is_empty")]
97 pub fork_secrets: Vec<String>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub status: Option<StatusSettings>,
101}
102
103fn default_max() -> u32 {
104 3
105}
106
107fn one() -> u32 {
108 1
109}
110
111fn env_is_empty(e: &EnvFile) -> bool {
112 e.vars().next().is_none()
113}
114
115impl PreviewSettings {
116 pub fn validate(&self, app: &AppSpec) -> Result<()> {
118 let bad = |m: String| Err(Error::invalid(format!("previews: {m}")));
119 if self.enabled && !matches!(app.source, Source::Git(_)) {
120 return bad("only an app with a git source has pull requests".into());
121 }
122 for b in &self.branches {
123 git::validate_ref(b)?;
124 if git::is_sha(b) {
125 return bad(format!("branch {b:?} is a commit, not a branch"));
126 }
127 }
128 if !(1..=50).contains(&self.max) {
129 return bad("max: 1 to 50".into());
130 }
131 if !(1..=10).contains(&self.replicas) {
132 return bad("replicas: 1 to 10".into());
133 }
134 if let Some(d) = &self.domain {
135 preview_host(d, "x", 1).map_err(|e| Error::invalid(format!("previews: {e}")))?;
136 }
137 if self.enabled && self.port.or(app.port).is_none() {
138 return bad("set the app's port (or previews.port): a preview is served on it".into());
139 }
140 if let Some(t) = &self.ttl {
141 let d = crate::flex::parse_duration(t)
142 .map_err(|e| Error::invalid(format!("previews.ttl: {e}")))?;
143 if d < Duration::from_secs(600) {
144 return bad("ttl: at least 10m".into());
145 }
146 }
147 for s in &self.fork_secrets {
148 crate::secrets::validate_name(s)?;
149 }
150 if let Some(s) = &self.status {
151 s.validate()?;
152 }
153 Ok(())
154 }
155
156 pub fn secret_names(&self) -> Vec<String> {
158 let mut n = self.env.secret_names();
159 if let Some(s) = &self.status {
160 n.push(s.token_secret.clone());
161 }
162 n
163 }
164
165 pub fn bases(&self, app: &AppSpec) -> Vec<String> {
167 if !self.branches.is_empty() {
168 return self.branches.clone();
169 }
170 match &app.source {
171 Source::Git(g) => vec![
172 g.reference
173 .strip_prefix("refs/heads/")
174 .unwrap_or(&g.reference)
175 .to_string(),
176 ],
177 Source::Image(_) | Source::Database(_) => vec![],
178 }
179 }
180
181 fn ttl(&self) -> Option<Duration> {
182 self.ttl
183 .as_deref()
184 .and_then(|t| crate::flex::parse_duration(t).ok())
185 }
186}
187
188pub fn is_pr_suffix(s: &str) -> bool {
190 let tail = s.rsplit("pr-").next().unwrap_or("");
191 let n_ok = !tail.is_empty() && tail.bytes().all(|b| b.is_ascii_digit());
192 n_ok && (s == format!("pr-{tail}") || s.ends_with(&format!("-pr-{tail}")))
193}
194
195pub fn preview_stack(project: &str, environment: &str, app: &str, n: u64) -> Result<String> {
198 for s in [
199 format!("{project}-{environment}-pr-{n}"),
200 format!("{project}-pr-{n}"),
201 ] {
202 if crate::stack::validate_stack_name(&s).is_ok()
203 && crate::stack::instance_name(&s, app, 100, "0000").is_ok()
204 {
205 return Ok(s);
206 }
207 }
208 Err(Error::invalid(format!(
209 "app {app}: no preview stack name fits for pull request {n}; shorten the app or project name"
210 )))
211}
212
213pub fn preview_tag(n: u64, sha: &str) -> String {
215 format!("pr-{n}-{sha}")
216}
217
218pub fn cache_key(app: &str, n: u64, fork: bool) -> String {
222 if fork {
223 format!("{app}-pr-{n}")
224 } else {
225 format!("{app}-preview")
226 }
227}
228
229pub fn preview_host(pattern: &str, app: &str, n: u64) -> std::result::Result<String, String> {
232 let p = pattern.trim().to_ascii_lowercase();
233 if p == "auto" {
234 return Ok(p);
235 }
236 let Some(suffix) = p.strip_prefix("*.") else {
237 return Err(format!(
238 "domain {pattern:?}: `auto` or a wildcard suffix like *.preview.example.com"
239 ));
240 };
241 let label_ok = |l: &str| {
242 !l.is_empty()
243 && l.len() <= 63
244 && !l.starts_with('-')
245 && !l.ends_with('-')
246 && l.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
247 };
248 if suffix.split('.').count() < 2 || !suffix.split('.').all(label_ok) {
249 return Err(format!("domain {pattern:?}: *.<a DNS name with a dot>"));
250 }
251 let host = format!("{app}-pr-{n}.{suffix}");
252 if host.len() > 253 || !label_ok(&format!("{app}-pr-{n}")) {
253 return Err(format!("domain {pattern:?}: {host} is not a valid name"));
254 }
255 Ok(host)
256}
257
258pub fn preview_spec(
262 app: &AppSpec,
263 s: &PreviewSettings,
264 n: u64,
265 fork: bool,
266 notes: &mut Vec<String>,
267) -> AppSpec {
268 let mut spec = app.clone();
269 let mut env = EnvFile::default();
270 if s.inherit_env {
271 for (k, v) in app.env.vars() {
272 match v {
273 EnvValue::Secret { secret } if fork => {
274 notes.push(format!(
275 "{k}: the app's secret {secret} is withheld from a fork's pull request"
276 ));
277 }
278 v => env.set(k, v.clone()),
279 }
280 }
281 }
282 for (k, v) in s.env.vars() {
283 match v {
284 EnvValue::Secret { secret } if fork && !s.fork_secrets.contains(secret) => {
285 env.remove(k);
286 notes.push(format!(
287 "{k}: secret {secret} is not in fork_secrets; withheld from a fork's pull request"
288 ));
289 }
290 v => env.set(k, v.clone()),
291 }
292 }
293 spec.env = env;
294 spec.files.retain(|f| {
298 let keep = s.inherit_env && (!fork || s.fork_secrets.contains(&f.secret));
299 if !keep {
300 notes.push(format!(
301 "{}: the app's file from secret {} is left out{}",
302 f.path,
303 f.secret,
304 if s.inherit_env {
305 "; not in fork_secrets, withheld from a fork's pull request"
306 } else {
307 " (previews take the app's files only with inherit_env)"
308 }
309 ));
310 }
311 keep
312 });
313 if !spec.ports.is_empty() {
314 notes.push(format!(
315 "published ports ({}) are production's; previews are served on their domain only",
316 spec.ports.join(", ")
317 ));
318 spec.ports.clear();
319 }
320 spec.replicas = s.replicas;
321 if s.resources.is_some() {
322 spec.resources = s.resources.clone();
323 }
324 spec.port = s.port.or(app.port);
325 spec.domains.clear();
326 let pattern = s.domain.as_deref().unwrap_or("auto");
327 match (preview_host(pattern, &app.name, n), spec.port) {
328 (Ok(host), Some(port)) => {
329 let mut d = serde_json::Map::new();
330 d.insert("host".into(), json!(host));
331 d.insert("port".into(), json!(port));
332 spec.domains.push(d);
333 }
334 (Err(e), _) => notes.push(format!("no domain: {e}")),
335 (_, None) => notes.push("no domain: the app has no port".into()),
336 }
337 spec.previews = None;
338 spec
339}
340
341pub fn render_preview(
344 spec: &AppSpec,
345 image: &str,
346 stack: &str,
347 n: u64,
348 notes: &mut Vec<String>,
349) -> Result<Rendered> {
350 let mut r = super::render(spec, image, notes)?;
351 r.service.labels.insert(LABEL_PREVIEW.into(), n.to_string());
352 for (key, v) in r.volumes.iter_mut() {
353 v.name = Some(format!("{stack}_{key}"));
354 }
355 Ok(r)
356}
357
358#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
360pub struct Preview {
361 pub app: String,
362 pub number: u64,
363 pub stack: String,
364 pub provider: String,
366 pub fork: bool,
367 #[serde(default)]
368 pub title: String,
369 #[serde(default)]
370 pub head_ref: String,
371 #[serde(default)]
372 pub base_ref: String,
373 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub head_sha: Option<String>,
376 #[serde(default, skip_serializing_if = "Option::is_none")]
378 pub sha: Option<String>,
379 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub image: Option<String>,
381 #[serde(default, skip_serializing_if = "Option::is_none")]
382 pub url: Option<String>,
383 pub created_at: u64,
384 pub updated_at: u64,
385 pub next_deployment: u64,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub current: Option<u64>,
388 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
390 pub removing: bool,
391}
392
393fn provider_name(p: Provider) -> &'static str {
394 match p {
395 Provider::GitHub => "github",
396 Provider::Gitea => "gitea",
397 Provider::GitLab => "gitlab",
398 Provider::Generic => "generic",
399 }
400}
401
402fn provider_of(s: &str) -> Provider {
403 match s {
404 "gitlab" => Provider::GitLab,
405 "gitea" => Provider::Gitea,
406 "github" => Provider::GitHub,
407 _ => Provider::Generic,
408 }
409}
410
411pub enum Requested {
413 Queued(Box<Preview>, Box<Deployment>),
414 Limit(u32),
416 Removing,
417 Unchanged(String),
420}
421
422type TeardownKey = (PathBuf, String, String, u64);
424
425fn tearing_down() -> &'static Mutex<BTreeSet<TeardownKey>> {
427 static S: OnceLock<Mutex<BTreeSet<TeardownKey>>> = OnceLock::new();
428 S.get_or_init(Default::default)
429}
430
431fn queue_key(app: &str, n: u64) -> String {
432 format!("{app}#pr-{n}")
433}
434
435impl super::Apps {
436 fn previews_dir(&self, org: &OrgId, app: &str) -> PathBuf {
439 self.app_dir(org, app).join("previews")
440 }
441
442 fn preview_dir(&self, org: &OrgId, app: &str, n: u64) -> PathBuf {
443 self.previews_dir(org, app).join(n.to_string())
444 }
445
446 fn preview_source_dir(&self, org: &OrgId, app: &str, n: u64) -> PathBuf {
447 self.source_dir(org, app)
448 .join("previews")
449 .join(n.to_string())
450 }
451
452 fn pdep_path(&self, org: &OrgId, app: &str, n: u64, id: u64) -> PathBuf {
453 self.preview_dir(org, app, n)
454 .join("deployments")
455 .join(format!("{id}.json"))
456 }
457
458 fn plog_path(&self, org: &OrgId, app: &str, n: u64, id: u64) -> PathBuf {
459 self.preview_dir(org, app, n)
460 .join("deployments")
461 .join(format!("{id}.log"))
462 }
463
464 pub fn preview_get(&self, org: &OrgId, app: &str, n: u64) -> Result<Preview> {
465 super::validate_app_name(app)?;
466 match std::fs::read(self.preview_dir(org, app, n).join("preview.json")) {
467 Ok(b) => Ok(serde_json::from_slice(&b)?),
468 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(Error::NotFound(format!(
469 "preview of pull request {n} of app {app}"
470 ))),
471 Err(e) => Err(e.into()),
472 }
473 }
474
475 pub fn preview_list(&self, org: &OrgId, app: &str) -> Result<Vec<Preview>> {
477 super::validate_app_name(app)?;
478 let mut out = Vec::new();
479 let Ok(rd) = std::fs::read_dir(self.previews_dir(org, app)) else {
480 return Ok(out);
481 };
482 for e in rd.flatten() {
483 let Some(n) = e.file_name().to_str().and_then(|s| s.parse::<u64>().ok()) else {
484 continue;
485 };
486 match self.preview_get(org, app, n) {
487 Ok(p) => out.push(p),
488 Err(e) if e.is_not_found() => {}
489 Err(e) => eprintln!("isb serve: app {app}: preview {n}: {e}"),
490 }
491 }
492 out.sort_by_key(|p| p.number);
493 Ok(out)
494 }
495
496 fn preview_save(&self, org: &OrgId, p: &Preview) -> Result<()> {
497 super::write_atomic(
498 &self.preview_dir(org, &p.app, p.number).join("preview.json"),
499 &serde_json::to_vec_pretty(p)?,
500 )
501 }
502
503 fn pdep_save(&self, org: &OrgId, n: u64, d: &Deployment) -> Result<()> {
504 super::write_atomic(
505 &self.pdep_path(org, &d.app, n, d.id),
506 &serde_json::to_vec_pretty(d)?,
507 )
508 }
509
510 pub fn preview_deployment(
511 &self,
512 org: &OrgId,
513 app: &str,
514 n: u64,
515 id: u64,
516 ) -> Result<Deployment> {
517 super::validate_app_name(app)?;
518 match std::fs::read(self.pdep_path(org, app, n, id)) {
519 Ok(b) => Ok(serde_json::from_slice(&b)?),
520 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(Error::NotFound(format!(
521 "deployment {id} of the preview of pull request {n} of app {app}"
522 ))),
523 Err(e) => Err(e.into()),
524 }
525 }
526
527 pub fn preview_deployments(&self, org: &OrgId, app: &str, n: u64) -> Result<Vec<Deployment>> {
529 let mut ids: Vec<u64> =
530 match std::fs::read_dir(self.preview_dir(org, app, n).join("deployments")) {
531 Ok(rd) => rd
532 .flatten()
533 .filter_map(|e| {
534 e.file_name()
535 .into_string()
536 .ok()?
537 .strip_suffix(".json")?
538 .parse()
539 .ok()
540 })
541 .collect(),
542 Err(_) => vec![],
543 };
544 ids.sort_unstable_by(|a, b| b.cmp(a));
545 Ok(ids
546 .into_iter()
547 .filter_map(|id| self.preview_deployment(org, app, n, id).ok())
548 .collect())
549 }
550
551 pub fn preview_log(
554 &self,
555 org: &OrgId,
556 app: &str,
557 n: u64,
558 id: u64,
559 offset: u64,
560 ) -> Result<(String, u64, bool)> {
561 let d = self.preview_deployment(org, app, n, id)?;
562 let b = std::fs::read(self.plog_path(org, app, n, id)).unwrap_or_default();
563 let start = (offset as usize).min(b.len());
564 Ok((
565 String::from_utf8_lossy(&b[start..]).into_owned(),
566 b.len() as u64,
567 d.status.finished(),
568 ))
569 }
570
571 pub fn preview_wait(
573 &self,
574 org: &OrgId,
575 app: &str,
576 n: u64,
577 id: u64,
578 timeout: Duration,
579 ) -> Result<Deployment> {
580 let started = Instant::now();
581 loop {
582 let d = self.preview_deployment(org, app, n, id)?;
583 if d.status.finished() || started.elapsed() >= timeout {
584 return Ok(d);
585 }
586 std::thread::sleep(Duration::from_millis(500));
587 }
588 }
589
590 pub fn preview_json(&self, org: &OrgId, p: &Preview) -> Value {
593 let mut v = serde_json::to_value(p).unwrap_or_default();
594 let last = self
595 .preview_deployments(org, &p.app, p.number)
596 .ok()
597 .and_then(|d| d.into_iter().next());
598 v["status"] = json!(if p.removing {
599 "removing".to_string()
600 } else {
601 last.as_ref()
602 .map(|d| format!("{:?}", d.status).to_lowercase())
603 .unwrap_or_else(|| "new".into())
604 });
605 v["last_deployment"] = last.map(|d| d.summary()).unwrap_or(Value::Null);
606 v
607 }
608
609 fn pevent(&self, org: &OrgId, p: &Preview, level: &str, message: String) {
612 let q = crate::stack::qualified(org, &p.stack);
613 self.inner.ctl.note_service(
614 level,
615 &q,
616 &p.app,
617 format!("app {} preview #{}: {message}", p.app, p.number),
618 );
619 }
620
621 fn pkind(&self, org: &OrgId, p: &Preview, kind: &str, level: &str, message: String) {
622 let q = crate::stack::qualified(org, &p.stack);
623 self.inner.ctl.event(
624 kind,
625 level,
626 &q,
627 &p.app,
628 format!("app {} preview #{}: {message}", p.app, p.number),
629 );
630 }
631
632 pub(super) fn preview_webhook(
636 &self,
637 org: &OrgId,
638 app: &App,
639 provider: Provider,
640 by: &str,
641 pr: &PullRequest,
642 ) -> (u16, Value) {
643 let name = &app.spec.name;
644 let n = pr.number;
645 let ignored = |why: String| (200, json!({"ignored": why}));
646 if provider == Provider::Generic {
647 return ignored("pull requests need a forge's signature, not ?token=".into());
648 }
649 if let PrAction::Close { merged } = pr.action {
652 if self.preview_get(org, name, n).is_err() {
653 return ignored(format!("pull request {n} has no preview"));
654 }
655 let why = if merged { "merged" } else { "closed" };
656 return match self.preview_remove(org, name, n, why, false) {
657 Ok(()) => (202, json!({"preview": n, "removing": true})),
658 Err(e) => (500, json!({"error": "preview", "message": e.to_string()})),
659 };
660 }
661 let Some(s) = app.spec.previews.as_ref().filter(|s| s.enabled) else {
662 return ignored(format!("previews are off for app {name}"));
663 };
664 match pr.action {
665 PrAction::Other | PrAction::Close { .. } => {
666 ignored(format!("pull request {n}: {}", pr.raw_action))
667 }
668 PrAction::Open | PrAction::Sync => {
669 let bases = s.bases(&app.spec);
670 if !bases.contains(&pr.base_ref) {
671 return ignored(format!(
672 "pull request {n} targets {}, not {}",
673 pr.base_ref,
674 bases.join(", ")
675 ));
676 }
677 if pr.fork && !s.forks {
678 return ignored(format!(
679 "pull request {n} is from a fork; previews for forks are off (previews.forks)"
680 ));
681 }
682 let requested = format!(
683 "pull request #{n} {} {}: {}",
684 pr.raw_action,
685 pr.head_sha.as_deref().unwrap_or("?"),
686 pr.title
687 );
688 match self.preview_request(org, name, provider, pr, Trigger::Webhook, by, requested)
689 {
690 Ok(Requested::Queued(_, d)) => (
691 202,
692 json!({"preview": n, "deployment": d.id, "status": d.status}),
693 ),
694 Ok(Requested::Limit(max)) => {
695 if let Some(sha) = &pr.head_sha {
696 let p = Preview {
697 app: name.clone(),
698 number: n,
699 stack: String::new(),
700 provider: provider_name(provider).into(),
701 fork: pr.fork,
702 title: pr.title.clone(),
703 head_ref: pr.head_ref.clone(),
704 base_ref: pr.base_ref.clone(),
705 head_sha: Some(sha.clone()),
706 sha: None,
707 image: None,
708 url: None,
709 created_at: 0,
710 updated_at: 0,
711 next_deployment: 1,
712 current: None,
713 removing: false,
714 };
715 self.forge_status(
716 org,
717 app,
718 &p,
719 sha,
720 "error",
721 None,
722 &format!("no preview: the app has its {max} previews already"),
723 &mut |_| {},
724 );
725 }
726 ignored(format!(
727 "app {name} has its {max} previews already (previews.max)"
728 ))
729 }
730 Ok(Requested::Unchanged(sha)) => ignored(format!(
731 "the preview of pull request {n} is at {sha} already"
732 )),
733 Ok(Requested::Removing) => {
734 ignored(format!("the preview of pull request {n} is being removed"))
735 }
736 Err(e) => (500, json!({"error": "preview", "message": e.to_string()})),
737 }
738 }
739 }
740 }
741
742 #[expect(clippy::too_many_arguments)]
744 pub fn preview_request(
745 &self,
746 org: &OrgId,
747 name: &str,
748 provider: Provider,
749 pr: &PullRequest,
750 trigger: Trigger,
751 by: &str,
752 requested: String,
753 ) -> Result<Requested> {
754 let n = pr.number;
755 let (p, created) = {
756 let _g = self.inner.edit.lock().unwrap();
757 let app = self.get(org, name)?;
758 let s = app
759 .spec
760 .previews
761 .clone()
762 .filter(|s| s.enabled)
763 .ok_or_else(|| Error::invalid(format!("previews are off for app {name}")))?;
764 let now = crate::stack::now_secs();
765 let (mut p, created) = match self.preview_get(org, name, n) {
766 Ok(p) if p.removing => return Ok(Requested::Removing),
767 Ok(p)
768 if pr.action == PrAction::Sync
769 && pr.head_sha.is_some()
770 && p.head_sha == pr.head_sha =>
771 {
772 return Ok(Requested::Unchanged(p.head_sha.unwrap_or_default()));
773 }
774 Ok(p) => (p, false),
775 Err(e) if e.is_not_found() => {
776 let live = self
777 .preview_list(org, name)?
778 .iter()
779 .filter(|p| !p.removing)
780 .count();
781 if live >= s.max as usize {
782 return Ok(Requested::Limit(s.max));
783 }
784 let stack = preview_stack(&app.spec.project, &app.spec.environment, name, n)?;
785 (
786 Preview {
787 app: name.into(),
788 number: n,
789 stack,
790 provider: provider_name(provider).into(),
791 fork: pr.fork,
792 title: String::new(),
793 head_ref: String::new(),
794 base_ref: String::new(),
795 head_sha: None,
796 sha: None,
797 image: None,
798 url: None,
799 created_at: now,
800 updated_at: now,
801 next_deployment: 1,
802 current: None,
803 removing: false,
804 },
805 true,
806 )
807 }
808 Err(e) => return Err(e),
809 };
810 p.fork |= pr.fork;
812 p.title = pr.title.clone();
813 p.head_ref = pr.head_ref.clone();
814 p.base_ref = pr.base_ref.clone();
815 p.head_sha = pr.head_sha.clone();
816 p.updated_at = now;
817 self.preview_save(org, &p)?;
818 (p, created)
819 };
820 if created {
821 self.pkind(
822 org,
823 &p,
824 "preview.created",
825 "info",
826 format!("created for {} ({})", p.head_ref, p.title),
827 );
828 }
829 let d = self.preview_enqueue(org, name, n, trigger, by, Some(requested))?;
830 Ok(Requested::Queued(Box::new(p), Box::new(d)))
831 }
832
833 pub fn preview_redeploy(
835 &self,
836 org: &OrgId,
837 name: &str,
838 n: u64,
839 trigger: Trigger,
840 by: &str,
841 ) -> Result<Deployment> {
842 let app = self.get(org, name)?;
843 if !app.spec.previews.as_ref().is_some_and(|s| s.enabled) {
844 return Err(Error::invalid(format!("previews are off for app {name}")));
845 }
846 let p = self.preview_get(org, name, n)?;
847 if p.removing {
848 return Err(Error::invalid(format!(
849 "the preview of pull request {n} is being removed"
850 )));
851 }
852 self.preview_enqueue(org, name, n, trigger, by, Some("redeploy".into()))
853 }
854
855 fn preview_enqueue(
858 &self,
859 org: &OrgId,
860 name: &str,
861 n: u64,
862 trigger: Trigger,
863 by: &str,
864 requested: Option<String>,
865 ) -> Result<Deployment> {
866 let (p, dep) = {
867 let _g = self.inner.edit.lock().unwrap();
868 let mut p = self.preview_get(org, name, n)?;
869 if p.removing {
870 return Err(Error::invalid(format!(
871 "the preview of pull request {n} is being removed"
872 )));
873 }
874 let id = p.next_deployment;
875 p.next_deployment += 1;
876 self.preview_save(org, &p)?;
877 let d = Deployment {
878 id,
879 app: name.into(),
880 trigger,
881 by: by.into(),
882 status: Status::Queued,
883 requested,
884 rollback_of: None,
885 commit: None,
886 image: None,
887 digest: None,
888 error: None,
889 created_at: crate::stack::controller::now_ms(),
890 started_at: None,
891 finished_at: None,
892 rendered: None,
893 };
894 self.pdep_save(org, n, &d)?;
895 self.preview_prune(org, &p);
896 (p, d)
897 };
898 self.pevent(
899 org,
900 &p,
901 "info",
902 format!("deployment {} queued by {by}", dep.id),
903 );
904 let key = (org.clone(), queue_key(name, n));
905 let start = {
906 let mut qs = self.inner.queues.lock().unwrap();
907 let q = qs.entry(key).or_default();
908 if let Some(old) = q.next.replace(dep.id) {
909 self.supersede(org, name, n, old, dep.id);
910 }
911 !std::mem::replace(&mut q.running, true)
912 };
913 if start {
914 let me = self.clone();
915 let (org, name) = (org.clone(), name.to_string());
916 std::thread::spawn(move || me.preview_drain(&org, &name, n));
917 }
918 Ok(dep)
919 }
920
921 fn supersede(&self, org: &OrgId, name: &str, n: u64, old: u64, by: u64) {
922 if let Ok(mut d) = self.preview_deployment(org, name, n, old) {
923 if d.advance(Status::Superseded).is_ok() {
924 d.error = Some(if by == 0 {
925 "the preview is being removed".into()
926 } else {
927 format!("superseded by deployment {by}")
928 });
929 let _ = self.pdep_save(org, n, &d);
930 }
931 }
932 }
933
934 fn preview_drain(&self, org: &OrgId, name: &str, n: u64) {
935 let key = (org.clone(), queue_key(name, n));
936 loop {
937 let next = {
938 let mut qs = self.inner.queues.lock().unwrap();
939 let q = qs.entry(key.clone()).or_default();
940 match q.next.take() {
941 Some(id) => id,
942 None => {
943 qs.remove(&key);
944 return;
945 }
946 }
947 };
948 self.preview_run(org, name, n, next);
949 }
950 }
951
952 fn preview_run(&self, org: &OrgId, name: &str, n: u64, id: u64) {
953 let (Ok(mut dep), Ok(p)) = (
954 self.preview_deployment(org, name, n, id),
955 self.preview_get(org, name, n),
956 ) else {
957 return;
958 };
959 let mut log = match PreviewLog::open(self, org, &p, id) {
960 Ok(l) => l,
961 Err(e) => {
962 eprintln!("isb serve: app {name} preview #{n}: deployment {id}: no log: {e}");
963 return;
964 }
965 };
966 let r = self.preview_pipeline(org, name, n, &mut dep, &mut log);
967 let p = self.preview_get(org, name, n).unwrap_or(p);
968 match &r {
969 Ok(()) => {
970 let url = p.url.clone().unwrap_or_else(|| "no URL".into());
971 log.line(&format!("deployment {id} done: {url}"));
972 self.pkind(
973 org,
974 &p,
975 "deploy.succeeded",
976 "info",
977 format!("deployment {id} done: {url}"),
978 );
979 }
980 Err(e) => {
981 log.line(&format!("deployment {id} failed: {e}"));
982 dep.error = Some(e.to_string());
983 let _ = dep.advance(Status::Failed);
984 self.pkind(
985 org,
986 &p,
987 "deploy.failed",
988 "error",
989 format!("deployment {id} failed: {e}"),
990 );
991 if let (Ok(app), Some(sha)) = (
992 self.get(org, name),
993 dep.commit
994 .as_ref()
995 .map(|c| c.sha.clone())
996 .or(p.head_sha.clone()),
997 ) {
998 let first: String = e.to_string().chars().take(120).collect();
999 self.forge_status(
1000 org,
1001 &app,
1002 &p,
1003 &sha,
1004 "failure",
1005 None,
1006 &format!("preview failed: {first}"),
1007 &mut |l| log.line(l),
1008 );
1009 }
1010 }
1011 }
1012 if let Err(e) = self.pdep_save(org, n, &dep) {
1013 eprintln!("isb serve: app {name} preview #{n}: deployment {id}: {e}");
1014 }
1015 }
1016
1017 fn pstatus(&self, org: &OrgId, n: u64, dep: &mut Deployment, s: Status) -> Result<()> {
1018 dep.advance(s)?;
1019 self.pdep_save(org, n, dep)
1020 }
1021
1022 #[expect(
1023 clippy::too_many_lines,
1024 reason = "predates the lint ratchet; split it when next changed"
1025 )]
1026 fn preview_pipeline(
1027 &self,
1028 org: &OrgId,
1029 name: &str,
1030 n: u64,
1031 dep: &mut Deployment,
1032 log: &mut PreviewLog,
1033 ) -> Result<()> {
1034 self.pstatus(org, n, dep, Status::Building)?;
1035 let app = self.get(org, name)?;
1036 let s = app
1037 .spec
1038 .previews
1039 .clone()
1040 .filter(|s| s.enabled)
1041 .ok_or_else(|| Error::invalid(format!("previews are off for app {name}")))?;
1042 let p = self.preview_get(org, name, n)?;
1043 let Source::Git(g) = &app.spec.source else {
1044 return Err(Error::invalid(format!("app {name} has no git source")));
1045 };
1046 if let Some(sha) = &p.head_sha {
1047 self.forge_status(
1048 org,
1049 &app,
1050 &p,
1051 sha,
1052 "pending",
1053 None,
1054 "building the preview",
1055 &mut |l| log.line(l),
1056 );
1057 }
1058 let mut src = g.clone();
1059 src.reference = PullRequest::head_ref_in_base(provider_of(&p.provider), n);
1060 let creds = self.credentials(org, &g.auth)?;
1061 let co = git::fetch(
1062 &src,
1063 &creds,
1064 &self.preview_source_dir(org, name, n),
1065 &mut |l| log.line(l),
1066 )?;
1067 if let Some(want) = &p.head_sha {
1068 if !co.sha.eq_ignore_ascii_case(want) {
1069 log.line(&format!(
1070 "note: the pull request's head is {} now, not {want} as the event said",
1071 co.sha
1072 ));
1073 }
1074 }
1075 dep.commit = Some(Commit {
1076 sha: co.sha.clone(),
1077 message: co.message.clone(),
1078 });
1079 self.pdep_save(org, n, dep)?;
1080 let b = app
1081 .spec
1082 .build
1083 .as_ref()
1084 .ok_or_else(|| Error::invalid("a git source needs build settings"))?;
1085 let untrusted = b.untrusted || p.fork;
1086 if p.fork && !b.untrusted {
1087 log.line("a fork's pull request: building in a VM");
1088 }
1089 let req = BuildRequest {
1090 org: org.clone(),
1091 app: name.into(),
1092 context: co.dir.clone(),
1093 subdir: g.subdir.clone(),
1094 builder: b.builder.clone(),
1095 args: b.args.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
1096 tag: preview_tag(n, &co.sha),
1097 untrusted,
1098 cache: Some(cache_key(name, n, p.fork)),
1099 };
1100 log.line(&format!("building {} with {:?}", co.sha, b.builder));
1101 let built = (self.inner.build)(&self.inner.client, &req, &mut |l| log.line(l))?;
1102 log.line(&format!("built {} ({})", built.image, built.digest));
1103 let mut notes = Vec::new();
1104 let spec = preview_spec(&app.spec, &s, n, p.fork, &mut notes);
1105 let rendered = render_preview(&spec, &built.image, &p.stack, n, &mut notes)?;
1106 for note in ¬es {
1107 log.line(&format!("note: {note}"));
1108 }
1109 dep.image = Some(built.image.clone());
1110 dep.digest = Some(built.digest.clone());
1111 dep.rendered = Some(rendered.clone());
1112 self.pstatus(org, n, dep, Status::Deploying)?;
1113 let q = crate::stack::qualified(org, &p.stack);
1114 let mark = self.event_mark();
1115 {
1116 let _s = self.inner.stacks.lock().unwrap();
1117 if self.preview_get(org, name, n)?.removing {
1118 return Err(Error::invalid("the preview is being removed"));
1119 }
1120 let cur = self.inner.ctl.definition(&q).ok();
1121 let file = super::splice(
1122 cur.as_ref().map(|d| &d.file),
1123 &p.stack,
1124 name,
1125 Some(&rendered),
1126 );
1127 let def = self.stack_def(org, &p.stack, file, &dep.by)?;
1128 let changes = self.inner.ctl.deploy(def)?;
1129 match changes.iter().find(|c| c.service == name) {
1130 Some(c) if c.change == "unchanged" => {
1131 log.line("settings unchanged: replacing the instances anyway");
1132 self.inner.ctl.redeploy(&q, name)?;
1133 }
1134 Some(c) => log.line(&format!(
1135 "stack {}: {} {} (rev {}, {} replicas)",
1136 p.stack, c.service, c.change, c.rev, c.replicas
1137 )),
1138 None => {}
1139 }
1140 }
1141 let (ok, msg) = self.wait_service_with(&q, name, Some(mark), |m| log.line(m))?;
1142 if !ok {
1143 return Err(Error::invalid(format!("service {name}: {msg}")));
1144 }
1145 log.line(&format!("service {name}.{} converged", p.stack));
1146 let url = self.preview_url(&q, name);
1147 self.pstatus(org, n, dep, Status::Done)?;
1148 let p = {
1149 let _g = self.inner.edit.lock().unwrap();
1150 let mut p = self.preview_get(org, name, n)?;
1151 p.current = Some(dep.id);
1152 p.sha = Some(co.sha.clone());
1153 p.image = Some(built.image.clone());
1154 p.url = url.clone();
1155 self.preview_save(org, &p)?;
1156 p
1157 };
1158 let mine = format!("pr-{n}-");
1160 let keep = built.digest.clone();
1161 match crate::registry::Registry::shared(&self.inner.client).and_then(|reg| {
1162 reg.delete_tags(
1163 org,
1164 name,
1165 &|t: &str| t.starts_with(&mine),
1166 &[keep.clone()].into(),
1167 )
1168 }) {
1169 Ok(gone) => {
1170 for g in gone {
1171 log.line(&format!("deleted the older image {g}"));
1172 }
1173 }
1174 Err(e) => log.line(&format!("note: older images kept: {e}")),
1175 }
1176 self.forge_status(
1177 org,
1178 &app,
1179 &p,
1180 &co.sha,
1181 "success",
1182 url.as_deref(),
1183 match &url {
1184 Some(_) => "preview ready",
1185 None => "preview deployed (no URL)",
1186 },
1187 &mut |l| log.line(l),
1188 );
1189 Ok(())
1190 }
1191
1192 fn preview_url(&self, q: &str, svc: &str) -> Option<String> {
1195 let started = Instant::now();
1196 loop {
1197 let url = self.inner.ctl.status(q).ok().and_then(|st| {
1198 st.services
1199 .into_iter()
1200 .find(|s| s.service == svc)?
1201 .domains
1202 .into_iter()
1203 .find_map(|d| d.url)
1204 });
1205 if url.is_some() || started.elapsed() > Duration::from_secs(15) {
1206 return url;
1207 }
1208 std::thread::sleep(Duration::from_millis(500));
1209 }
1210 }
1211
1212 #[expect(clippy::too_many_arguments)]
1215 fn forge_status(
1216 &self,
1217 org: &OrgId,
1218 app: &App,
1219 p: &Preview,
1220 sha: &str,
1221 state: &str,
1222 url: Option<&str>,
1223 description: &str,
1224 log: &mut dyn FnMut(&str),
1225 ) {
1226 let Some(st) = app.spec.previews.as_ref().and_then(|s| s.status.as_ref()) else {
1227 return;
1228 };
1229 let Source::Git(g) = &app.spec.source else {
1230 return;
1231 };
1232 let r = (|| -> Result<()> {
1233 let kind = match (st.kind, provider_of(&p.provider)) {
1234 (Some(k), _) => k,
1235 (None, Provider::GitHub) => ForgeKind::Github,
1236 (None, Provider::Gitea) => ForgeKind::Gitea,
1237 (None, _) => {
1238 return Err(Error::invalid(format!(
1239 "commit statuses for {} are not supported; set previews.status.kind",
1240 p.provider
1241 )));
1242 }
1243 };
1244 let api = forge::api_base(kind, &g.url, st.api_url.as_deref())?;
1245 let (_, _, owner_repo) = forge::repo_of(&g.url).ok_or_else(|| {
1246 Error::invalid(format!("cannot tell the repository of {}", g.url))
1247 })?;
1248 let (token, _) = self.inner.secrets.get(org, &st.token_secret)?;
1249 let token = String::from_utf8(token)
1250 .map_err(|_| Error::invalid("the forge token is not text"))?;
1251 forge::post_status(
1252 kind,
1253 &api,
1254 &owner_repo,
1255 &token,
1256 sha,
1257 &forge::Status {
1258 state,
1259 target_url: url,
1260 description,
1261 },
1262 )
1263 })();
1264 match r {
1265 Ok(()) => log(&format!("commit status {state} posted")),
1266 Err(e) => log(&format!("note: commit status not posted: {e}")),
1267 }
1268 }
1269
1270 fn preview_prune(&self, org: &OrgId, p: &Preview) {
1272 let ds = self
1273 .preview_deployments(org, &p.app, p.number)
1274 .unwrap_or_default();
1275 for d in ds.iter().skip(KEEP_DEPLOYMENTS) {
1276 if Some(d.id) == p.current {
1277 continue;
1278 }
1279 let _ = std::fs::remove_file(self.pdep_path(org, &p.app, p.number, d.id));
1280 let _ = std::fs::remove_file(self.plog_path(org, &p.app, p.number, d.id));
1281 }
1282 }
1283
1284 pub fn preview_remove(
1290 &self,
1291 org: &OrgId,
1292 name: &str,
1293 n: u64,
1294 why: &str,
1295 wait: bool,
1296 ) -> Result<()> {
1297 {
1298 let _g = self.inner.edit.lock().unwrap();
1299 let mut p = self.preview_get(org, name, n)?;
1300 if !p.removing {
1301 p.removing = true;
1302 self.preview_save(org, &p)?;
1303 self.pevent(org, &p, "info", format!("removing ({why})"));
1304 }
1305 }
1306 {
1308 let mut qs = self.inner.queues.lock().unwrap();
1309 if let Some(q) = qs.get_mut(&(org.clone(), queue_key(name, n))) {
1310 if let Some(old) = q.next.take() {
1311 self.supersede(org, name, n, old, 0);
1312 }
1313 }
1314 }
1315 if wait {
1316 return self.preview_teardown(org, name, n, why);
1317 }
1318 let me = self.clone();
1319 let (org, name, why) = (org.clone(), name.to_string(), why.to_string());
1320 std::thread::spawn(move || {
1321 if let Err(e) = me.preview_teardown(&org, &name, n, &why) {
1322 eprintln!(
1323 "isb serve: app {name} preview #{n}: removal failed: {e} (retried later)"
1324 );
1325 }
1326 });
1327 Ok(())
1328 }
1329
1330 pub(super) fn previews_remove_all(&self, org: &OrgId, name: &str) -> Result<()> {
1332 for p in self.preview_list(org, name)? {
1333 self.preview_remove(org, name, p.number, "app deleted", true)?;
1334 }
1335 Ok(())
1336 }
1337
1338 fn preview_teardown(&self, org: &OrgId, name: &str, n: u64, why: &str) -> Result<()> {
1339 let key = (
1340 self.inner.state.clone(),
1341 org.to_string(),
1342 name.to_string(),
1343 n,
1344 );
1345 if !tearing_down().lock().unwrap().insert(key.clone()) {
1346 return Ok(());
1347 }
1348 struct Done(TeardownKey);
1349 impl Drop for Done {
1350 fn drop(&mut self) {
1351 tearing_down().lock().unwrap().remove(&self.0);
1352 }
1353 }
1354 let _done = Done(key);
1355 let started = Instant::now();
1357 let qkey = (org.clone(), queue_key(name, n));
1358 while self.inner.queues.lock().unwrap().contains_key(&qkey) {
1359 if started.elapsed() > self.inner.timeout + Duration::from_secs(45 * 60) {
1360 return Err(Error::invalid(format!(
1361 "preview #{n} of {name}: its deploy is still running"
1362 )));
1363 }
1364 std::thread::sleep(Duration::from_millis(500));
1365 }
1366 let p = self.preview_get(org, name, n)?;
1367 let deps = self.preview_deployments(org, name, n)?;
1368 let q = crate::stack::qualified(org, &p.stack);
1369 {
1370 let _s = self.inner.stacks.lock().unwrap();
1371 if let Ok(cur) = self.inner.ctl.definition(&q) {
1372 if cur.file.services.contains_key(name) {
1373 let file = super::splice(Some(&cur.file), &p.stack, name, None);
1374 if file.services.is_empty() {
1375 self.inner.ctl.remove(&q, true, Duration::from_secs(300))?;
1376 } else {
1377 let def = self.stack_def(org, &p.stack, file, "preview removal")?;
1378 self.inner.ctl.deploy(def)?;
1379 }
1380 }
1381 }
1382 }
1383 let oc = crate::org::client(&self.inner.client, org);
1384 let volumes: BTreeSet<String> = deps
1387 .iter()
1388 .filter_map(|d| d.rendered.as_ref())
1389 .flat_map(|r| r.volumes.values().filter_map(|v| v.name.clone()))
1390 .collect();
1391 let caches: Vec<String> = [true, false]
1392 .iter()
1393 .map(|vm| crate::build::cache_volume(&cache_key(name, n, true), *vm))
1394 .collect();
1395 if !volumes.is_empty() || p.fork {
1396 let pool = crate::sandbox::host_facts(&oc)?.pick_pool(None)?;
1397 for v in volumes.iter().chain(caches.iter()) {
1398 let started = Instant::now();
1399 loop {
1400 match crate::volume::remove(&oc, &pool, v) {
1401 Ok(()) => {
1402 self.pevent(org, &p, "info", format!("volume {v} deleted"));
1403 break;
1404 }
1405 Err(e) if e.is_not_found() => break,
1406 Err(e) if started.elapsed() > Duration::from_secs(180) => {
1407 return Err(Error::invalid(format!("volume {v}: {e}")));
1408 }
1409 Err(_) => std::thread::sleep(Duration::from_secs(2)),
1410 }
1411 }
1412 }
1413 }
1414 if let Ok(reg) = crate::registry::Registry::shared(&self.inner.client) {
1417 let spare = self.deployed_digests(org, name);
1418 let gone = reg.delete_tags(
1419 org,
1420 name,
1421 &|t: &str| crate::registry::preview_tag_number(t) == Some(n),
1422 &spare,
1423 )?;
1424 for g in gone {
1425 self.pevent(org, &p, "info", format!("image {g} deleted"));
1426 }
1427 }
1428 {
1429 let _g = self.inner.edit.lock().unwrap();
1430 let _ = std::fs::remove_dir_all(self.preview_source_dir(org, name, n));
1431 std::fs::remove_dir_all(self.preview_dir(org, name, n))?;
1432 }
1433 self.pkind(
1434 org,
1435 &p,
1436 "preview.removed",
1437 "info",
1438 format!("removed ({why})"),
1439 );
1440 Ok(())
1441 }
1442
1443 fn deployed_digests(&self, org: &OrgId, app: &str) -> BTreeSet<String> {
1445 let mut out = BTreeSet::new();
1446 for def in self.inner.ctl.definitions() {
1447 if &def.org != org {
1448 continue;
1449 }
1450 for (svc, spec) in &def.file.services {
1451 let Some(r) = spec.image.strip_prefix("registry:") else {
1452 continue;
1453 };
1454 let Ok(r) = crate::registry::ImageRef::parse(r) else {
1455 continue;
1456 };
1457 if r.app != app {
1458 continue;
1459 }
1460 if let Some(d) = r.digest.clone().or_else(|| def.images.get(svc).cloned()) {
1461 out.insert(d);
1462 }
1463 }
1464 }
1465 out
1466 }
1467
1468 pub(super) fn recover_previews(&self, org: &OrgId, app: &str) {
1472 for p in self.preview_list(org, app).unwrap_or_default() {
1473 for mut d in self
1474 .preview_deployments(org, app, p.number)
1475 .unwrap_or_default()
1476 {
1477 if d.status.finished() {
1478 continue;
1479 }
1480 d.error = Some("interrupted: the daemon stopped during it".into());
1481 d.status = Status::Failed;
1482 d.finished_at = Some(crate::stack::controller::now_ms());
1483 let _ = self.pdep_save(org, p.number, &d);
1484 }
1485 }
1486 }
1487
1488 pub fn previews_upkeep(&self) {
1491 let mut orgs = vec![OrgId::default_org()];
1492 if let Ok(rd) = std::fs::read_dir(self.inner.state.join("orgs")) {
1493 for e in rd.flatten() {
1494 if let Some(o) = e.file_name().to_str().and_then(|s| OrgId::new(s).ok()) {
1495 orgs.push(o);
1496 }
1497 }
1498 }
1499 let now = crate::stack::now_secs();
1500 for org in orgs {
1501 for app in self.list(&org).unwrap_or_default() {
1502 let ttl = app.spec.previews.as_ref().and_then(|s| s.ttl());
1503 for p in self.preview_list(&org, &app.spec.name).unwrap_or_default() {
1504 let expired =
1505 ttl.is_some_and(|t| now.saturating_sub(p.updated_at) > t.as_secs());
1506 let why = if p.removing {
1507 "retrying removal"
1508 } else if expired {
1509 "expired: not updated within previews.ttl"
1510 } else {
1511 continue;
1512 };
1513 if let Err(e) = self.preview_remove(&org, &app.spec.name, p.number, why, false)
1514 {
1515 eprintln!(
1516 "isb serve: app {} preview #{}: {e}",
1517 app.spec.name, p.number
1518 );
1519 }
1520 }
1521 }
1522 }
1523 }
1524
1525 pub fn start_preview_upkeep(&self) {
1527 let me = self.clone();
1528 std::thread::spawn(move || {
1529 loop {
1530 std::thread::sleep(Duration::from_secs(300));
1531 me.previews_upkeep();
1532 }
1533 });
1534 }
1535}
1536
1537struct PreviewLog {
1540 file: std::fs::File,
1541 apps: super::Apps,
1542 org: OrgId,
1543 preview: Preview,
1544 id: u64,
1545}
1546
1547impl PreviewLog {
1548 fn open(apps: &super::Apps, org: &OrgId, p: &Preview, id: u64) -> Result<PreviewLog> {
1549 use std::os::unix::fs::OpenOptionsExt;
1550 let path = apps.plog_path(org, &p.app, p.number, id);
1551 if let Some(d) = path.parent() {
1552 std::fs::create_dir_all(d)?;
1553 }
1554 let file = std::fs::OpenOptions::new()
1555 .create(true)
1556 .append(true)
1557 .mode(0o600)
1558 .open(path)?;
1559 Ok(PreviewLog {
1560 file,
1561 apps: apps.clone(),
1562 org: org.clone(),
1563 preview: p.clone(),
1564 id,
1565 })
1566 }
1567
1568 fn line(&mut self, l: &str) {
1569 let _ = writeln!(self.file, "{l}");
1570 self.apps.pevent(
1571 &self.org,
1572 &self.preview,
1573 "log",
1574 format!("#{}: {l}", self.id),
1575 );
1576 }
1577}
1578
1579#[cfg(test)]
1580mod tests {
1581 use super::*;
1582
1583 fn app(v: Value) -> AppSpec {
1584 serde_json::from_value(v).unwrap()
1585 }
1586
1587 fn git_app() -> AppSpec {
1588 app(json!({
1589 "name": "web", "project": "shop",
1590 "source": {"git": {"url": "https://git.example.com/acme/web.git", "ref": "main"}},
1591 "build": {"builder": {"type": "dockerfile"}},
1592 "port": 8080,
1593 "env": {"DATABASE_URL": "postgres://db.shop-production/app", "TOKEN": {"secret": "api_token"}},
1594 "volumes": ["data:/data"],
1595 "ports": ["127.0.0.1:18080:8080"],
1596 "replicas": 3,
1597 }))
1598 }
1599
1600 fn settings(v: Value) -> PreviewSettings {
1601 serde_json::from_value(v).unwrap()
1602 }
1603
1604 #[test]
1605 fn naming() {
1606 assert_eq!(
1607 preview_stack("shop", "production", "web", 12).unwrap(),
1608 "shop-production-pr-12"
1609 );
1610 assert_eq!(
1612 preview_stack("a-long-project-name", "production", "web", 12345).unwrap(),
1613 "a-long-project-name-pr-12345"
1614 );
1615 assert_eq!(preview_tag(7, "abc"), "pr-7-abc");
1616 assert!(crate::registry::valid_tag(&preview_tag(7, &"f".repeat(40))));
1617 assert_eq!(cache_key("web", 7, true), "web-pr-7");
1618 assert_eq!(cache_key("web", 7, false), "web-preview");
1619 assert_eq!(preview_host("auto", "web", 3).unwrap(), "auto");
1620 assert_eq!(
1621 preview_host("*.preview.example.com", "web", 3).unwrap(),
1622 "web-pr-3.preview.example.com"
1623 );
1624 for bad in ["preview.example.com", "*.com", "*.-x.com", "*.a b.com", ""] {
1625 assert!(preview_host(bad, "web", 3).is_err(), "{bad}");
1626 }
1627 assert!(is_pr_suffix("pr-3"));
1628 assert!(is_pr_suffix("production-pr-12"));
1629 assert!(!is_pr_suffix("pr-"));
1630 assert!(!is_pr_suffix("production"));
1631 assert!(!is_pr_suffix("sprint-pr-x"));
1632 assert!(!is_pr_suffix("expr-3"));
1633 assert!(super::super::validate_part("environment", "staging-pr-4").is_err());
1634 assert_eq!(
1635 PullRequest::head_ref_in_base(Provider::GitHub, 4),
1636 "refs/pull/4/head"
1637 );
1638 assert_eq!(
1639 PullRequest::head_ref_in_base(Provider::Gitea, 4),
1640 "refs/pull/4/head"
1641 );
1642 assert_eq!(
1643 PullRequest::head_ref_in_base(Provider::GitLab, 4),
1644 "refs/merge-requests/4/head"
1645 );
1646 crate::app::git::validate_ref("refs/merge-requests/4/head").unwrap();
1647 }
1648
1649 #[test]
1650 fn settings_validate() {
1651 let mut a = git_app();
1652 a.previews = Some(settings(json!({"enabled": true})));
1653 a.validate().unwrap();
1654 let p = a.previews.clone().unwrap();
1655 assert_eq!(
1656 (p.max, p.replicas, p.forks, p.inherit_env),
1657 (3, 1, false, false)
1658 );
1659 assert_eq!(p.bases(&a), ["main"]);
1660 let bad = |v: Value| {
1661 let mut b = git_app();
1662 b.previews = Some(settings(v));
1663 b.validate().is_err()
1664 };
1665 assert!(bad(json!({"enabled": true, "max": 0})));
1666 assert!(bad(json!({"enabled": true, "replicas": 0})));
1667 assert!(bad(json!({"enabled": true, "ttl": "1m"})));
1668 assert!(bad(json!({"enabled": true, "ttl": "soon"})));
1669 assert!(bad(
1670 json!({"enabled": true, "domain": "preview.example.com"})
1671 ));
1672 assert!(bad(json!({"enabled": true, "branches": ["--x"]})));
1673 assert!(bad(
1674 json!({"enabled": true, "status": {"token_secret": "t", "api_url": "ftp://x"}})
1675 ));
1676 assert!(
1677 serde_json::from_value::<PreviewSettings>(json!({"enabled": true, "bogus": 1}))
1678 .is_err()
1679 );
1680 let mut noport = git_app();
1681 noport.port = None;
1682 noport.previews = Some(settings(json!({"enabled": true})));
1683 assert!(noport.validate().is_err());
1684 noport.previews = Some(settings(json!({"enabled": true, "port": 80})));
1685 noport.validate().unwrap();
1686 let mut img =
1687 app(json!({"name": "x", "project": "p", "source": {"image": "docker:nginx"}}));
1688 img.previews = Some(settings(json!({"enabled": true})));
1689 assert!(img.validate().is_err(), "an image app has no pull requests");
1690 let s = settings(json!({"env": {"K": {"secret": "s1"}}, "status": {"token_secret": "gt"}}));
1691 assert_eq!(s.secret_names(), ["s1", "gt"]);
1692 assert_eq!(
1693 settings(json!({"branches": ["main", "release"]})).bases(&git_app()),
1694 ["main", "release"]
1695 );
1696 }
1697
1698 #[test]
1699 fn spec_is_isolated_from_production() {
1700 let a = git_app();
1701 let s = settings(json!({
1702 "enabled": true,
1703 "env": {"DATABASE_URL": "postgres://db.shop-production-pr-1/app", "PREVIEW_KEY": {"secret": "preview_key"}},
1704 }));
1705 let mut notes = vec![];
1706 let p = preview_spec(&a, &s, 5, false, &mut notes);
1707 assert_eq!(
1710 p.env.get("DATABASE_URL"),
1711 Some(&EnvValue::Plain(
1712 "postgres://db.shop-production-pr-1/app".into()
1713 ))
1714 );
1715 assert!(p.env.get("TOKEN").is_none());
1716 assert!(p.env.get("PREVIEW_KEY").is_some());
1717 assert!(p.ports.is_empty(), "no published host ports");
1718 assert_eq!(p.replicas, 1);
1719 assert_eq!(p.domains.len(), 1);
1720 assert_eq!(p.domains[0]["host"], "auto");
1721 assert_eq!(p.domains[0]["port"], 8080);
1722 assert!(p.previews.is_none());
1723 let r = render_preview(
1724 &p,
1725 "registry:web:pr-5-abc@sha256:00",
1726 "shop-production-pr-5",
1727 5,
1728 &mut notes,
1729 )
1730 .unwrap();
1731 assert_eq!(r.service.labels[LABEL_PREVIEW], "5");
1732 assert_eq!(r.service.labels[super::super::LABEL_APP], "web");
1733 assert_eq!(
1734 r.volumes["web_data"].name.as_deref(),
1735 Some("shop-production-pr-5_web_data"),
1736 "never production's volume"
1737 );
1738 assert!(r.secrets.contains_key("web.preview_key"));
1739 assert!(!r.secrets.contains_key("web.api_token"));
1740
1741 let s2 =
1743 settings(json!({"enabled": true, "inherit_env": true, "env": {"DATABASE_URL": "x"}}));
1744 let p2 = preview_spec(&a, &s2, 5, false, &mut notes);
1745 assert_eq!(
1746 p2.env.get("DATABASE_URL"),
1747 Some(&EnvValue::Plain("x".into()))
1748 );
1749 assert!(p2.env.get("TOKEN").is_some());
1750 }
1751
1752 #[test]
1753 fn app_files_follow_the_env_rules() {
1754 let mut a = git_app();
1755 a.files = vec![
1756 crate::app::AppFile {
1757 path: "/etc/app/prod.conf".into(),
1758 secret: "prod_conf".into(),
1759 mode: None,
1760 },
1761 crate::app::AppFile {
1762 path: "/etc/app/public.pem".into(),
1763 secret: "public_key".into(),
1764 mode: None,
1765 },
1766 ];
1767 let mut notes = vec![];
1768 let paths = |p: &AppSpec| p.files.iter().map(|f| f.path.clone()).collect::<Vec<_>>();
1769 let s = settings(json!({"enabled": true}));
1771 assert!(preview_spec(&a, &s, 1, false, &mut notes).files.is_empty());
1772 let s = settings(json!({
1774 "enabled": true, "forks": true, "inherit_env": true, "fork_secrets": ["public_key"],
1775 }));
1776 assert_eq!(paths(&preview_spec(&a, &s, 1, false, &mut notes)).len(), 2);
1777 assert_eq!(
1778 paths(&preview_spec(&a, &s, 1, true, &mut notes)),
1779 vec!["/etc/app/public.pem".to_string()]
1780 );
1781 assert!(notes.iter().any(|n| n.contains("prod_conf")), "{notes:?}");
1782 }
1783
1784 #[test]
1785 fn forks_get_no_app_secrets() {
1786 let a = git_app();
1787 let s = settings(json!({
1788 "enabled": true, "forks": true, "inherit_env": true,
1789 "env": {"SAFE": {"secret": "public_key"}, "UNSAFE": {"secret": "preview_db"}, "PLAIN": "1"},
1790 "fork_secrets": ["public_key"],
1791 "domain": "*.preview.example.com",
1792 }));
1793 let mut notes = vec![];
1794 let p = preview_spec(&a, &s, 9, true, &mut notes);
1795 assert!(p.env.get("TOKEN").is_none(), "the app's secret is withheld");
1796 assert!(p.env.get("UNSAFE").is_none(), "not marked safe for forks");
1797 assert!(p.env.get("SAFE").is_some());
1798 assert_eq!(p.env.get("PLAIN"), Some(&EnvValue::Plain("1".into())));
1799 assert_eq!(
1800 p.env.get("DATABASE_URL"),
1801 Some(&EnvValue::Plain("postgres://db.shop-production/app".into())),
1802 "plain values are inherited as asked"
1803 );
1804 assert_eq!(
1805 notes.iter().filter(|n| n.contains("withheld")).count(),
1806 2,
1807 "{notes:?}"
1808 );
1809 assert_eq!(p.domains[0]["host"], "web-pr-9.preview.example.com");
1810 let mut notes = vec![];
1813 let p = preview_spec(&a, &s, 9, false, &mut notes);
1814 assert!(p.env.get("TOKEN").is_some() && p.env.get("UNSAFE").is_some());
1815 }
1816}