1use 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
32const MAX_TRIES: usize = 5;
34
35#[derive(Clone, Debug)]
36pub struct ReleaseSource {
37 pub base_url: String,
38}
39
40impl ReleaseSource {
41 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 pub version: String,
63 pub major: u32,
64 pub dir: PathBuf,
65 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
88fn 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
98fn 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
126fn 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
136fn 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
172fn 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
186fn 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
229fn 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
248fn 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
300fn 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
314fn 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
325fn 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 tar::Archive::new(gz)
343 .unpack(part)
344 .map_err(|e| invalid(format!("cannot unpack the archive: {e}")))
345}
346
347fn 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 std::fs::remove_dir_all(final_dir)?;
369 }
370 std::fs::rename(&root, final_dir)?;
371 Ok(())
372}
373
374pub 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
439pub 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
468pub 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 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
514pub 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}