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