Skip to main content

mitosis/pg/
managed.rs

1//! Managed Postgres builds: `mitosis pg install | list | remove`. The only code in Mitosis that
2//! downloads, and only when the user runs `pg install`. Runs in the CLI process, not the daemon.
3//!
4//! Source: the `theseus-rs/postgresql-binaries` GitHub releases. Checked by reading the release
5//! list on 2026-10-20 (nothing downloaded):
6//! - Tags are `<pg major>.<pg minor>.<build>`, e.g. `17.9.1`. Majors 14 to 18 are built for macOS and Linux;
7//!   a minor can have several builds (`17.9.0`, `17.9.1`), and the newest is taken.
8//! - Per release and target there is `postgresql-<tag>-<target>.tar.gz` plus `.sha256`, for both
9//!   `aarch64-apple-darwin`, `x86_64-apple-darwin`, `aarch64-unknown-linux-gnu` and
10//!   `x86_64-unknown-linux-gnu` (Linux assets checked 2026-10-21 for majors 14 to 18). Also `.manifest.json`, `.provenance.json`,
11//!   `.sbom-attestation.json` and `.spdx.json`, which are not used here.
12//! - The `.sha256` file is one line, `<64 hex>  <asset file name>` (sha256sum format, ~112 bytes).
13//! - URL shape: `<base>/download/<tag>/<asset>`; GitHub redirects it to a CDN, which ureq follows.
14//! - Not verifiable without a download: the archive's top-level folder (handled both ways below:
15//!   `bin/` at the root, or one folder holding `bin/`) and whether contrib extensions are in.
16//!
17//! `file://<folder>` bases (tests) read the same layout from disk: `<folder>/download/<tag>/<asset>`.
18//! An `http(s)` base other than the GitHub one lists its tags from `<base>/tags.txt` (one per line).
19
20use crate::error::{ErrorCode, MitosisError, Result};
21use crate::paths::Home;
22use crate::pg::binaries::{discover_with, parse_version, version_key};
23use serde::Serialize;
24use sha2::{Digest, Sha256};
25use std::io::{Read, Write};
26use std::path::{Path, PathBuf};
27use std::process::Command;
28use std::time::Duration;
29
30pub const DEFAULT_RELEASES_URL: &str = "https://github.com/theseus-rs/postgresql-binaries/releases";
31
32/// How many of the newest builds of a major to try before giving up on this target.
33const MAX_TRIES: usize = 5;
34
35#[derive(Clone, Debug)]
36pub struct ReleaseSource {
37    pub base_url: String,
38}
39
40impl ReleaseSource {
41    /// `MITOSIS_PG_RELEASES_URL` (an `http://` or `file://` base), else the GitHub releases.
42    pub fn from_env() -> ReleaseSource {
43        let base_url = std::env::var("MITOSIS_PG_RELEASES_URL")
44            .ok()
45            .filter(|v| !v.trim().is_empty())
46            .unwrap_or_else(|| DEFAULT_RELEASES_URL.to_string());
47        ReleaseSource { base_url }
48    }
49}
50
51impl Default for ReleaseSource {
52    fn default() -> Self {
53        ReleaseSource {
54            base_url: DEFAULT_RELEASES_URL.to_string(),
55        }
56    }
57}
58
59#[derive(Clone, Debug, PartialEq, Serialize)]
60pub struct ManagedInstall {
61    /// `x.y`, also the folder name under `<home>/pg/`.
62    pub version: String,
63    pub major: u32,
64    pub dir: PathBuf,
65    /// Discovery picks this build (no system install of the same major, no newer managed one).
66    pub used: bool,
67}
68
69pub fn target_triple() -> &'static str {
70    match (cfg!(target_os = "linux"), cfg!(target_arch = "aarch64")) {
71        (true, true) => "aarch64-unknown-linux-gnu",
72        (true, false) => "x86_64-unknown-linux-gnu",
73        (false, true) => "aarch64-apple-darwin",
74        (false, false) => "x86_64-apple-darwin",
75    }
76}
77
78fn invalid(msg: impl Into<String>) -> MitosisError {
79    MitosisError::new(ErrorCode::InvalidRequest, msg)
80}
81
82fn net_error(what: &str, e: impl std::fmt::Display) -> MitosisError {
83    MitosisError::new(ErrorCode::Internal, format!("{what}: {e}")).with_hint(
84        "check the network, or point MITOSIS_PG_RELEASES_URL at a mirror (http:// or file://)",
85    )
86}
87
88// ---- fetching -------------------------------------------------------------------------------
89
90fn agent() -> ureq::Agent {
91    ureq::Agent::config_builder()
92        .timeout_connect(Some(Duration::from_secs(30)))
93        .timeout_global(Some(Duration::from_secs(30 * 60)))
94        .build()
95        .into()
96}
97
98/// Opens `url` for reading; `None` when it does not exist (404 or a missing file).
99fn open(url: &str) -> Result<Option<Box<dyn Read>>> {
100    if let Some(path) = url.strip_prefix("file://") {
101        return match std::fs::File::open(path) {
102            Ok(f) => Ok(Some(Box::new(f))),
103            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
104            Err(e) => Err(MitosisError::new(
105                ErrorCode::Internal,
106                format!("read {path}: {e}"),
107            )),
108        };
109    }
110    match agent().get(url).call() {
111        Ok(resp) => Ok(Some(Box::new(resp.into_body().into_reader()))),
112        Err(ureq::Error::StatusCode(404)) => Ok(None),
113        Err(ureq::Error::StatusCode(403 | 429)) if url.starts_with("https://api.github.com/") => {
114            Err(MitosisError::new(
115                ErrorCode::Internal,
116                "GitHub refused the release lookup (rate limit)",
117            )
118            .with_hint(
119                "wait a while, or pass the exact tag: mitosis pg install <major> --version <x.y.z>",
120            ))
121        }
122        Err(e) => Err(net_error(&format!("GET {url}"), e)),
123    }
124}
125
126/// Small text files only (checksums, tag lists); capped at 1 MiB.
127fn fetch_text(url: &str) -> Result<Option<String>> {
128    let Some(r) = open(url)? else { return Ok(None) };
129    let mut s = String::new();
130    r.take(1 << 20)
131        .read_to_string(&mut s)
132        .map_err(|e| net_error(&format!("read {url}"), e))?;
133    Ok(Some(s))
134}
135
136/// Streams `url` into `dest` and returns the SHA-256 (lowercase hex) of the bytes written.
137fn download(url: &str, dest: &Path) -> Result<String> {
138    let mut r = open(url)?.ok_or_else(|| {
139        MitosisError::new(
140            ErrorCode::NoMatchingPostgres,
141            format!("{url} does not exist"),
142        )
143    })?;
144    let mut f = std::fs::File::create(dest)?;
145    let mut hasher = Sha256::new();
146    let mut buf = vec![0u8; 256 * 1024];
147    let mut total = 0u64;
148    let mut next_report = 16u64 << 20;
149    loop {
150        let n = r
151            .read(&mut buf)
152            .map_err(|e| net_error(&format!("download {url}"), e))?;
153        if n == 0 {
154            break;
155        }
156        hasher.update(&buf[..n]);
157        f.write_all(&buf[..n])?;
158        total += n as u64;
159        if total >= next_report {
160            println!("  {} MiB", total >> 20);
161            next_report += 16 << 20;
162        }
163    }
164    f.flush()?;
165    Ok(hasher
166        .finalize()
167        .iter()
168        .map(|b| format!("{b:02x}"))
169        .collect())
170}
171
172// ---- release lookup -------------------------------------------------------------------------
173
174fn tag_key(tag: &str) -> Option<Vec<u32>> {
175    let parts: Vec<&str> = tag.split('.').collect();
176    if parts.len() != 3
177        || !parts
178            .iter()
179            .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
180    {
181        return None;
182    }
183    Some(version_key(tag))
184}
185
186/// All tags `<major>.<minor>.<build>` of `major`, newest first.
187fn list_tags(src: &ReleaseSource, major: u32) -> Result<Vec<String>> {
188    let base = src.base_url.trim_end_matches('/');
189    let raw: Vec<String> = if let Some(dir) = base.strip_prefix("file://") {
190        std::fs::read_dir(Path::new(dir).join("download"))
191            .map(|rd| {
192                rd.flatten()
193                    .map(|e| e.file_name().to_string_lossy().into_owned())
194                    .collect()
195            })
196            .unwrap_or_default()
197    } else if let Some(repo) = base
198        .strip_prefix("https://github.com/")
199        .and_then(|r| r.strip_suffix("/releases"))
200    {
201        let url = format!(
202            "https://api.github.com/repos/{repo}/git/matching-refs/tags/{major}.?per_page=100"
203        );
204        let body = fetch_text(&url)?.unwrap_or_default();
205        let refs: Vec<serde_json::Value> = serde_json::from_str(&body).unwrap_or_default();
206        refs.iter()
207            .filter_map(|r| {
208                r["ref"]
209                    .as_str()?
210                    .strip_prefix("refs/tags/")
211                    .map(String::from)
212            })
213            .collect()
214    } else {
215        fetch_text(&format!("{base}/tags.txt"))?
216            .unwrap_or_default()
217            .lines()
218            .map(|l| l.trim().to_string())
219            .collect()
220    };
221    let mut tags: Vec<String> = raw
222        .into_iter()
223        .filter(|t| tag_key(t).is_some_and(|k| k[0] == major))
224        .collect();
225    tags.sort_by_key(|t| std::cmp::Reverse(tag_key(t)));
226    Ok(tags)
227}
228
229/// `x.y` of a tag `x.y.z`.
230fn minor_of(tag: &str) -> String {
231    tag.rsplit_once('.')
232        .map(|(xy, _)| xy)
233        .unwrap_or(tag)
234        .to_string()
235}
236
237fn asset_name(tag: &str) -> String {
238    format!("postgresql-{tag}-{}.tar.gz", target_triple())
239}
240
241fn asset_url(src: &ReleaseSource, tag: &str, asset: &str) -> String {
242    format!(
243        "{}/download/{tag}/{asset}",
244        src.base_url.trim_end_matches('/')
245    )
246}
247
248/// The tag to install and its checksum file text. Candidates are tried newest first; one with no
249/// build for this target (no `.sha256` asset) is skipped.
250fn resolve(src: &ReleaseSource, major: u32, version: Option<&str>) -> Result<(String, String)> {
251    let candidates: Vec<String> = match version {
252        None => list_tags(src, major)?,
253        Some(v) => {
254            let parts: Vec<&str> = v.split('.').collect();
255            let numeric = parts
256                .iter()
257                .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()));
258            if !numeric || !(2..=3).contains(&parts.len()) {
259                return Err(invalid(format!("invalid version {v:?}")).with_hint(
260                    "use x.y (newest build of that minor) or the exact release tag x.y.z",
261                ));
262            }
263            if parts[0].parse::<u32>().ok() != Some(major) {
264                return Err(invalid(format!(
265                    "version {v} is not a Postgres {major} version"
266                )));
267            }
268            if parts.len() == 3 {
269                vec![v.to_string()]
270            } else {
271                list_tags(src, major)?
272                    .into_iter()
273                    .filter(|t| minor_of(t) == v)
274                    .collect()
275            }
276        }
277    };
278    for tag in candidates.iter().take(MAX_TRIES) {
279        let url = asset_url(src, tag, &format!("{}.sha256", asset_name(tag)));
280        if let Some(text) = fetch_text(&url)? {
281            return Ok((tag.clone(), text));
282        }
283    }
284    Err(MitosisError::new(
285        ErrorCode::NoMatchingPostgres,
286        format!(
287            "no Postgres {} build for {} in {}",
288            version
289                .map(str::to_string)
290                .unwrap_or_else(|| major.to_string()),
291            target_triple(),
292            src.base_url
293        ),
294    )
295    .with_hint(
296        "check the number; releases: https://github.com/theseus-rs/postgresql-binaries/releases",
297    ))
298}
299
300/// The hash in a `sha256sum`-style line (`<hex>  <file>`); a bare hash also works.
301fn parse_sha256(text: &str) -> Result<String> {
302    let h = text
303        .split_whitespace()
304        .next()
305        .unwrap_or("")
306        .to_ascii_lowercase();
307    if h.len() == 64 && h.bytes().all(|b| b.is_ascii_hexdigit()) {
308        Ok(h)
309    } else {
310        Err(invalid("the published checksum file is not a SHA-256 line"))
311    }
312}
313
314// ---- install --------------------------------------------------------------------------------
315
316fn existing(dir: &Path, major: u32, version: &str) -> Option<ManagedInstall> {
317    dir.join("bin/postgres").is_file().then(|| ManagedInstall {
318        version: version.to_string(),
319        major,
320        dir: dir.to_path_buf(),
321        used: false,
322    })
323}
324
325/// The folder holding `bin/postgres`: the unpack folder itself, or its only child folder.
326fn archive_root(part: &Path) -> Option<PathBuf> {
327    if part.join("bin/postgres").is_file() {
328        return Some(part.to_path_buf());
329    }
330    let dirs: Vec<PathBuf> = std::fs::read_dir(part)
331        .ok()?
332        .flatten()
333        .map(|e| e.path())
334        .filter(|p| p.is_dir())
335        .collect();
336    (dirs.len() == 1 && dirs[0].join("bin/postgres").is_file()).then(|| dirs[0].clone())
337}
338
339fn unpack(tarball: &Path, part: &Path) -> Result<()> {
340    let gz = flate2::read::GzDecoder::new(std::fs::File::open(tarball)?);
341    // `unpack` skips entries that would escape `part` (absolute paths, `..`).
342    tar::Archive::new(gz)
343        .unpack(part)
344        .map_err(|e| invalid(format!("cannot unpack the archive: {e}")))
345}
346
347/// Unpacks, checks and moves a verified tarball into place. The caller removes `part` on error.
348fn finish(tarball: &Path, part: &Path, final_dir: &Path, major: u32, xy: &str) -> Result<()> {
349    unpack(tarball, part)?;
350    let root = archive_root(part).ok_or_else(|| {
351        invalid("the archive has no bin/postgres")
352            .with_hint("this build looks broken; try --version for another one")
353    })?;
354    let out = Command::new(root.join("bin/postgres"))
355        .arg("--version")
356        .output()
357        .map_err(|e| invalid(format!("the unpacked bin/postgres does not run: {e}")))?;
358    let (got_major, got) = parse_version(&String::from_utf8_lossy(&out.stdout))
359        .filter(|_| out.status.success())
360        .ok_or_else(|| invalid("the unpacked bin/postgres --version gave no version"))?;
361    if got_major != major || got != xy {
362        return Err(invalid(format!(
363            "the archive holds Postgres {got}, expected {xy}"
364        )));
365    }
366    if final_dir.exists() {
367        // A half-removed or broken folder of the same name (a valid one returned earlier).
368        std::fs::remove_dir_all(final_dir)?;
369    }
370    std::fs::rename(&root, final_dir)?;
371    Ok(())
372}
373
374/// Downloads, verifies and unpacks the newest build of `major` (or the exact `version`, `x.y` or
375/// the release tag `x.y.z`) into `<home>/pg/<x.y>/`. Prints progress lines. Installing a version
376/// that is already there downloads nothing.
377// shortcut: two `pg install` of the same version at once can trip over each other's `.part`
378// folder; the loser errors out and nothing half-built is ever visible under the final name.
379pub fn install(
380    home: &Home,
381    major: u32,
382    version: Option<&str>,
383    src: &ReleaseSource,
384) -> Result<ManagedInstall> {
385    home.ensure_dirs()?;
386    let pg = home.pg_dir();
387    std::fs::create_dir_all(&pg)?;
388    println!(
389        "looking up Postgres {} for {}",
390        version
391            .map(str::to_string)
392            .unwrap_or_else(|| major.to_string()),
393        target_triple()
394    );
395    let (tag, sha_text) = resolve(src, major, version)?;
396    let xy = minor_of(&tag);
397    let final_dir = pg.join(&xy);
398    if let Some(i) = existing(&final_dir, major, &xy) {
399        println!(
400            "Postgres {xy} is already installed at {}",
401            final_dir.display()
402        );
403        return Ok(i);
404    }
405    let want = parse_sha256(&sha_text)?;
406    let asset = asset_name(&tag);
407    let tmp = pg.join(format!(".download-{}.tmp", std::process::id()));
408    let part = pg.join(format!(".{xy}.part"));
409    let cleanup = || {
410        let _ = std::fs::remove_file(&tmp);
411        let _ = std::fs::remove_dir_all(&part);
412    };
413    cleanup();
414
415    println!("downloading {asset}");
416    let got = download(&asset_url(src, &tag, &asset), &tmp).inspect_err(|_| cleanup())?;
417    if got != want {
418        cleanup();
419        return Err(invalid(format!(
420            "checksum mismatch for {asset}: published {want}, downloaded {got}"
421        ))
422        .with_hint("nothing was installed; retry, or report it if it keeps happening"));
423    }
424    println!("checksum ok (sha256 {want})");
425    println!("unpacking into {}", final_dir.display());
426    std::fs::create_dir_all(&part)?;
427    let done = finish(&tmp, &part, &final_dir, major, &xy);
428    cleanup();
429    done?;
430    println!("installed Postgres {xy}");
431    Ok(ManagedInstall {
432        version: xy,
433        major,
434        dir: final_dir,
435        used: false,
436    })
437}
438
439// ---- list and remove ------------------------------------------------------------------------
440
441/// The builds under `<home>/pg/`, newest first. `used` says which one discovery picks.
442pub fn list(home: &Home) -> Result<Vec<ManagedInstall>> {
443    let Ok(rd) = std::fs::read_dir(home.pg_dir()) else {
444        return Ok(vec![]);
445    };
446    let mut out: Vec<ManagedInstall> = rd
447        .flatten()
448        .filter_map(|e| {
449            let name = e.file_name().to_string_lossy().into_owned();
450            let major = name.split('.').next()?.parse().ok()?;
451            if name.starts_with('.') || !name.contains('.') {
452                return None;
453            }
454            existing(&e.path(), major, &name)
455        })
456        .collect();
457    out.sort_by_key(|p| std::cmp::Reverse(version_key(&p.version)));
458    let found = discover_with(Some(home));
459    for i in &mut out {
460        i.used = found
461            .iter()
462            .find(|f| f.major == i.major)
463            .is_some_and(|f| f.bin_dir == i.dir.join("bin"));
464    }
465    Ok(out)
466}
467
468/// Removes the build named by `spec` (a major like `17`, or `x.y`). `in_use` gets the build's
469/// `bin` folder (what templates record as `bin_dir`) and refuses the removal when it says true.
470pub fn remove(home: &Home, spec: &str, in_use: &dyn Fn(&Path) -> bool) -> Result<ManagedInstall> {
471    let by_major = !spec.is_empty() && spec.bytes().all(|b| b.is_ascii_digit());
472    let mut hits: Vec<ManagedInstall> = list(home)?
473        .into_iter()
474        .filter(|i| {
475            if by_major {
476                spec.parse() == Ok(i.major)
477            } else {
478                i.version == spec
479            }
480        })
481        .collect();
482    let install = match hits.len() {
483        0 => {
484            return Err(invalid(format!("no managed Postgres {spec}"))
485                .with_hint("mitosis pg list shows what is installed"))
486        }
487        1 => hits.remove(0),
488        _ => {
489            let names: Vec<&str> = hits.iter().map(|i| i.version.as_str()).collect();
490            return Err(invalid(format!(
491                "more than one managed Postgres {spec}: {}",
492                names.join(", ")
493            ))
494            .with_hint("name one: mitosis pg remove <x.y>"));
495        }
496    };
497    if in_use(&install.dir.join("bin")) {
498        return Err(invalid(format!(
499            "Postgres {} is used by a template or a clone",
500            install.version
501        ))
502        .with_hint(
503            "destroy those clones and rebuild the templates with another Postgres, then retry",
504        ));
505    }
506    // Rename first so discovery never sees a half-deleted install.
507    let gone = home.pg_dir().join(format!(".{}.removing", install.version));
508    let _ = std::fs::remove_dir_all(&gone);
509    std::fs::rename(&install.dir, &gone)?;
510    std::fs::remove_dir_all(&gone)?;
511    Ok(install)
512}
513
514/// True when a template generation records a `bin_dir` inside `bin_dir`, or a clone folder's
515/// `postmaster.opts` names it (a clone that runs, or ran last, with that build). Reads `state.db`
516/// read-only like `doctor` does, so it works with or without a daemon. A store it cannot read
517/// (an unmigrated v0.1 store, say) counts as in use: start the daemon once, or fix `state.db`.
518pub fn used_by_store(home: &Home, bin_dir: &Path) -> bool {
519    use rusqlite::{Connection, OpenFlags};
520    let path = home.state_db();
521    if !path.is_file() {
522        return false;
523    }
524    let Ok(conn) = Connection::open_with_flags(
525        &path,
526        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
527    ) else {
528        return true;
529    };
530    let column = |sql: &str| -> Option<Vec<String>> {
531        conn.prepare(sql)
532            .ok()?
533            .query_map([], |r| r.get::<_, String>(0))
534            .ok()?
535            .collect::<std::result::Result<_, _>>()
536            .ok()
537    };
538    let Some(bins) = column("SELECT bin_dir FROM generations") else {
539        return true;
540    };
541    if bins.iter().any(|b| Path::new(b).starts_with(bin_dir)) {
542        return true;
543    }
544    let Some(dirs) = column("SELECT dir FROM clones") else {
545        return true;
546    };
547    let needle = bin_dir.to_string_lossy();
548    dirs.iter().any(|d| {
549        std::fs::read_to_string(Path::new(d).join("postmaster.opts"))
550            .is_ok_and(|s| s.contains(needle.as_ref()))
551    })
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557
558    #[test]
559    fn tags_are_filtered_and_sorted() {
560        let d = tempfile::tempdir().unwrap();
561        for t in [
562            "17.9.0", "17.10.1", "17.9.1", "16.4.0", "17.9", "latest", "17.x.1",
563        ] {
564            std::fs::create_dir_all(d.path().join("download").join(t)).unwrap();
565        }
566        let src = ReleaseSource {
567            base_url: format!("file://{}", d.path().display()),
568        };
569        assert_eq!(
570            list_tags(&src, 17).unwrap(),
571            vec!["17.10.1", "17.9.1", "17.9.0"]
572        );
573    }
574
575    #[test]
576    fn parses_checksum_lines() {
577        let h = "a".repeat(64);
578        assert_eq!(
579            parse_sha256(&format!("{h}  postgresql-x.tar.gz\n")).unwrap(),
580            h
581        );
582        assert_eq!(parse_sha256(&h.to_uppercase()).unwrap(), h);
583        assert!(parse_sha256("nope").is_err());
584        assert!(parse_sha256("").is_err());
585    }
586
587    #[test]
588    fn minor_and_urls() {
589        assert_eq!(minor_of("17.9.1"), "17.9");
590        let src = ReleaseSource {
591            base_url: "https://x/releases/".into(),
592        };
593        assert_eq!(
594            asset_url(&src, "17.9.1", "a.tar.gz"),
595            "https://x/releases/download/17.9.1/a.tar.gz"
596        );
597        assert!(asset_name("17.9.1").starts_with("postgresql-17.9.1-"));
598        #[cfg(target_os = "macos")]
599        assert!(asset_name("17.9.1").ends_with("-apple-darwin.tar.gz"));
600    }
601
602    #[test]
603    fn target_triple_matches_os() {
604        let t = target_triple();
605        if cfg!(target_os = "linux") {
606            assert!(t.ends_with("-unknown-linux-gnu"));
607        } else {
608            assert!(t.ends_with("-apple-darwin"));
609        }
610        assert!(t.starts_with(if cfg!(target_arch = "aarch64") {
611            "aarch64"
612        } else {
613            "x86_64"
614        }));
615    }
616}