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