Skip to main content

isb_apps/app/
preview.rs

1//! Preview deployments: one per open pull request, built from its head and
2//! served on its own URL until it closes.
3//!
4//! An app opts in with `previews: {enabled: true, ...}`. A pull request
5//! event on the app's webhook (GitHub and Gitea/Forgejo `pull_request`,
6//! GitLab `Merge Request Hook`) against a watched base branch then:
7//! - opened, reopened, new commits: fetches the forge's ref for the
8//!   request's head (`refs/pull/<n>/head`, `refs/merge-requests/<n>/head`)
9//!   with the app's hardened git code, builds it (tag `pr-<n>-<sha>`) and
10//!   deploys it as the app's service in the preview stack
11//!   `<project>-<env>-pr-<n>`;
12//! - closed or merged: removes that service, its volumes, its images and
13//!   its records.
14//!
15//! A preview never touches production: its own stack, its own volumes
16//! (`<stack>_<app>_<name>`), no published host ports, its own build cache,
17//! and an environment of its own (the app's only with `inherit_env`).
18//! Pull requests from forks carry code nobody with push access wrote: they
19//! get no preview unless `forks: true`, and then build in a VM and receive
20//! none of the app's secrets, only the preview secrets listed in
21//! `fork_secrets`.
22//!
23//! ```text
24//! apps/<app>/previews/<n>/preview.json
25//! apps/<app>/previews/<n>/deployments/<id>.json, <id>.log
26//! sources/<app>/previews/<n>/repo
27//! ```
28
29use 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
46/// Label (`user.isb.preview`) on a preview's instances: its pull request.
47pub const LABEL_PREVIEW: &str = "isb.preview";
48
49/// Preview deployment records kept per preview.
50const KEEP_DEPLOYMENTS: usize = 10;
51
52/// What an app sets for its previews.
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54#[serde(deny_unknown_fields)]
55pub struct PreviewSettings {
56    #[serde(default)]
57    pub enabled: bool,
58    /// Base branches whose pull requests get previews. Default: the app's
59    /// git ref.
60    #[serde(default, skip_serializing_if = "Vec::is_empty")]
61    pub branches: Vec<String>,
62    /// At most this many previews at once (default 3); a pull request over
63    /// the limit gets none until another closes.
64    #[serde(default = "default_max")]
65    pub max: u32,
66    /// The preview's environment: `.env` text or a `{KEY: value | {secret:
67    /// NAME}}` map, laid over the app's when `inherit_env`.
68    #[serde(default, skip_serializing_if = "env_is_empty")]
69    pub env: EnvFile,
70    /// Start from the app's environment (default false: a preview gets only
71    /// `env`, so it never points at production's database by accident).
72    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
73    pub inherit_env: bool,
74    /// `auto` (default: a generated sslip.io name) or `*.<suffix>` for
75    /// `<app>-pr-<n>.<suffix>`, within the org's allowed domains.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub domain: Option<String>,
78    /// The port to route to; default the app's.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub port: Option<u16>,
81    #[serde(default = "one")]
82    pub replicas: u32,
83    /// Per replica; default the app's.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub resources: Option<Resources>,
86    /// Remove a preview that has not been updated for this long (`7d`,
87    /// `36h`). Default: kept until its pull request closes.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub ttl: Option<String>,
90    /// Previews for pull requests from forks (default false). They build in
91    /// a VM and get none of the app's secrets.
92    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
93    pub forks: bool,
94    /// Secrets in `env` a fork's preview may receive. Any other secret is
95    /// left out of a fork's preview.
96    #[serde(default, skip_serializing_if = "Vec::is_empty")]
97    pub fork_secrets: Vec<String>,
98    /// Post the preview's state and URL as a commit status.
99    #[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    /// Check the settings against their app.
117    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    /// Secrets the settings name (they must exist).
157    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    /// The base branches watched: `branches`, or the app's ref.
166    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
188/// `pr-<n>` or `...-pr-<n>`: a name previews own.
189pub 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
195/// The stack a preview runs in: `<project>-<env>-pr-<n>`, or
196/// `<project>-pr-<n>` when that is too long for a stack or its instances.
197pub 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
209/// A preview's image tag.
210pub fn preview_tag(n: u64, sha: &str) -> String {
211    format!("pr-{n}-{sha}")
212}
213
214/// The build cache a preview uses: one per pull request for forks (removed
215/// with the preview), one shared by the app's other previews. Never
216/// production's.
217pub 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
225/// The domain host for a preview: `auto`, or `<app>-pr-<n>.<suffix>` for
226/// a `*.<suffix>` pattern.
227pub 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
254/// The app as its preview runs: the preview's environment, replicas,
255/// resources and domain, no published host ports, no preview settings.
256/// `notes` says what was left out and why.
257pub 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    // Files are secret values too: they follow the env's rules, so a preview
291    // gets the app's only with `inherit_env`, and a fork's only those in
292    // `fork_secrets`.
293    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
337/// Render a preview's service: the app's rendering, labelled with its pull
338/// request, its named volumes made its own (`<stack>_<app>_<name>`).
339pub 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/// One preview: a pull request of an app and what runs for it.
355#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
356pub struct Preview {
357    pub app: String,
358    pub number: u64,
359    pub stack: String,
360    /// `github`, `gitea` or `gitlab`: which forge refs and API it uses.
361    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    /// The head commit the last event named.
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    pub head_sha: Option<String>,
372    /// What runs: the commit, the image, the URL.
373    #[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    /// Being removed: no more deploys.
385    #[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
407/// What a pull request event led to.
408pub enum Requested {
409    Queued(Box<Preview>, Box<Deployment>),
410    /// The app has `max` previews already.
411    Limit(u32),
412    Removing,
413    /// A sync naming the commit the preview already has (Gitea sends one
414    /// right after opening).
415    Unchanged(String),
416}
417
418/// A teardown: state dir, org, app, pull request.
419type TeardownKey = (PathBuf, String, String, u64);
420
421/// Teardowns in progress, so the janitor does not start a second one.
422fn 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    // --- records ---------------------------------------------------------
433
434    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    /// An app's previews, by pull request number.
472    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    /// Newest first.
524    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    /// A preview deployment's log from byte `offset`: `(text, next offset,
548    /// finished)`.
549    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    /// Wait until a preview deployment finishes, or `timeout`.
568    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    /// A preview as the tools show it: the record, plus its last
587    /// deployment's status.
588    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    // --- events ----------------------------------------------------------
606
607    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    // --- webhooks --------------------------------------------------------
629
630    /// A verified pull request event for `app`: `(status, body)`.
631    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        // A closed request's preview goes even if previews were turned off
646        // since it was made.
647        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    /// Create or update the preview of `pr` and queue a deploy of it.
739    #[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            // Once a fork, always a fork: the head cannot move repositories.
807            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    /// Build and deploy a preview's head again.
830    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    // --- the deploy queue ------------------------------------------------
852
853    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 &notes {
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        // This preview's older images: only the running one is kept.
1155        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    /// The URL the ingress serves a service on, once it shows (a few
1189    /// seconds at most after the service converges).
1190    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    /// Post a commit status when the app's previews have `status` set.
1209    /// Never fails the deploy: problems go to the log.
1210    #[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    /// Drop the oldest deployment records beyond [`KEEP_DEPLOYMENTS`].
1267    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    // --- removal ---------------------------------------------------------
1281
1282    /// Remove a preview: its service (and the stack with its last), its
1283    /// volumes, its build cache, its images and its records. With `wait`,
1284    /// returns when done; else in the background.
1285    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        // A deploy waiting to start never will.
1303        {
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    /// Remove every preview of an app (before the app goes).
1327    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        // A deploy running now finishes first (it is bounded).
1352        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        // Its volumes: every name a deployment of it used. A volume is in
1381        // use until the instances are gone, so retry for a while.
1382        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        // Its images: tags `pr-<n>-*`, unless a deployed service runs the
1411        // same digest.
1412        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    /// Digests of `<org>/<app>` that deployed stacks run now.
1440    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    // --- upkeep ----------------------------------------------------------
1465
1466    /// Mark preview deployments a stopped daemon left unfinished as failed.
1467    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    /// One pass of upkeep: finish removals that failed or were interrupted,
1485    /// and remove previews idle past their app's `ttl`.
1486    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    /// Run [`Self::previews_upkeep`] every five minutes (the daemon).
1522    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
1533/// A preview deployment's log: a file, and each line on the events feed
1534/// under the preview's stack.
1535struct 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        // Too long for `<project>-<env>-pr-<n>`: the shorter form.
1607        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        // Only the preview's env: production's database URL and secret do
1704        // not come along.
1705        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        // inherit_env: the app's env under the preview's.
1738        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        // Without inherit_env a preview takes none of the app's files.
1766        let s = settings(json!({"enabled": true}));
1767        assert!(preview_spec(&a, &s, 1, false, &mut notes).files.is_empty());
1768        // With it, a same-repo preview takes them all, a fork only fork_secrets'.
1769        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        // The same settings for a branch of the repository itself: the
1807        // secrets it was given.
1808        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}