pub mod oci;
pub mod tls;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::client::{Client, encode_segment};
use crate::error::{Error, Result};
use crate::org::OrgId;
pub const PROJECT: &str = "isb-system";
pub const INSTANCE: &str = "registry";
pub const VOLUME: &str = "registry-data";
pub const IMAGE: &str =
"docker:registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373";
pub const DEFAULT_PORT: u16 = 5480;
pub const DEFAULT_KEEP: usize = 10;
const INNER_SOCKET: &str = "/tmp/registry.sock";
const KEY_ADDR: &str = "user.isb.registry.addr";
const KEY_CA: &str = "user.isb.registry.ca";
const CERT_DIR: &str = "/certs";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImageRef {
pub app: String,
pub tag: Option<String>,
pub digest: Option<String>,
}
pub fn valid_app(a: &str) -> bool {
!a.is_empty()
&& a.len() <= 128
&& a.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit())
&& a.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "._-".contains(c))
}
pub fn valid_tag(t: &str) -> bool {
!t.is_empty()
&& t.len() <= 128
&& !t.starts_with(['.', '-'])
&& t.chars()
.all(|c| c.is_ascii_alphanumeric() || "_.-".contains(c))
}
impl ImageRef {
pub fn parse(s: &str) -> Result<ImageRef> {
let bad = |why: &str| {
Error::invalid(format!(
"registry image {s:?}: {why} (want registry:APP[:TAG][@sha256:DIGEST], the app's image in this org)"
))
};
let (rest, digest) = match s.split_once('@') {
Some((r, d)) => {
if !oci::valid_digest(d) {
return Err(bad("the digest must be sha256: and 64 hex digits"));
}
(r, Some(d.to_string()))
}
None => (s, None),
};
let (app, tag) = match rest.split_once(':') {
Some((a, t)) => (a, Some(t.to_string())),
None => (rest, None),
};
if app.contains('/') {
return Err(bad(
"images are named by app alone; the org is implied and other orgs' images cannot be named",
));
}
if !valid_app(app) {
return Err(bad("the app is [a-z0-9][a-z0-9._-]*"));
}
if let Some(t) = &tag {
if !valid_tag(t) {
return Err(bad("bad tag"));
}
}
Ok(ImageRef {
app: app.to_string(),
tag,
digest,
})
}
pub fn tag_or_latest(&self) -> &str {
self.tag.as_deref().unwrap_or("latest")
}
pub fn render(&self) -> String {
let mut s = self.app.clone();
if let Some(t) = &self.tag {
s.push(':');
s.push_str(t);
}
if let Some(d) = &self.digest {
s.push('@');
s.push_str(d);
}
s
}
pub fn pinned(&self, digest: &str) -> ImageRef {
ImageRef {
digest: Some(digest.to_string()),
..self.clone()
}
}
pub fn pull_alias(&self, org: &OrgId) -> String {
match &self.digest {
Some(d) => format!("{}@{d}", repo(org, &self.app)),
None => format!("{}:{}", repo(org, &self.app), self.tag_or_latest()),
}
}
}
pub fn repo(org: &OrgId, app: &str) -> String {
format!("{org}/{app}")
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Info {
pub addr: String,
pub ca_pem: String,
}
impl Info {
pub fn url(&self) -> String {
format!("https://{}", self.addr)
}
}
fn host(base: &Client) -> Client {
base.clone().project("default")
}
fn sys(base: &Client) -> Client {
base.clone().project(PROJECT)
}
pub fn info(base: &Client) -> Result<Option<Info>> {
let Some(p) = host(base).get_opt(&format!("/1.0/projects/{PROJECT}"))? else {
return Ok(None);
};
let addr = p["config"][KEY_ADDR].as_str().unwrap_or_default();
let ca = p["config"][KEY_CA].as_str().unwrap_or_default();
if addr.is_empty() || ca.is_empty() {
return Ok(None);
}
Ok(Some(Info {
addr: addr.to_string(),
ca_pem: ca.to_string(),
}))
}
pub fn host_ca_path(addr: &str) -> PathBuf {
Path::new("/etc/containers/certs.d")
.join(addr)
.join("ca.crt")
}
#[expect(
clippy::too_many_lines,
reason = "predates the lint ratchet; split it when next changed"
)]
pub fn setup(
base: &Client,
state: &Path,
port: u16,
renew: bool,
report: &mut dyn FnMut(&str),
) -> Result<Info> {
let ip: std::net::IpAddr = "127.0.0.1".parse().expect("constant");
let addr = format!("{ip}:{port}");
let mat = tls::ensure(&state.join("registry"), ip, renew)?;
let h = host(base);
let other = h.get_timeouts().other;
let proj_path = format!("/1.0/projects/{PROJECT}");
let config = json!({
"features.images": "false",
"features.profiles": "true",
"features.storage.volumes": "true",
"features.networks": "false",
KEY_ADDR: addr,
KEY_CA: mat.ca_cert,
});
match h.get_opt(&proj_path)? {
None => {
report(&format!("creating project {PROJECT}"));
h.mutate(
"POST",
"/1.0/projects",
Some(&json!({"name": PROJECT, "description": "isb system services (not an org)", "config": config})),
&format!("create project {PROJECT}"),
other,
)?;
}
Some(p) => {
let mut merged = p["config"].clone();
for (k, v) in config.as_object().expect("object") {
merged[k] = v.clone();
}
h.mutate(
"PUT",
&proj_path,
Some(&json!({"description": p["description"], "config": merged})),
&format!("update project {PROJECT}"),
other,
)?;
}
}
let s = sys(base);
let facts = crate::sandbox::host_facts(&h)?;
let pool = facts.pick_pool(None)?;
s.mutate(
"PUT",
"/1.0/profiles/default",
Some(&json!({
"description": "isb system services: no network",
"config": {},
"devices": {"root": {"type": "disk", "path": "/", "pool": pool}},
})),
"set the system project's default profile",
other,
)?;
let vol_path = format!(
"/1.0/storage-pools/{}/volumes/custom/{VOLUME}",
encode_segment(&pool)
);
if s.get_opt(&vol_path)?.is_none() {
report(&format!("creating volume {VOLUME} on {pool}"));
s.mutate(
"POST",
&format!("/1.0/storage-pools/{}/volumes/custom", encode_segment(&pool)),
Some(&json!({"name": VOLUME, "type": "custom", "content_type": "filesystem", "config": {}})),
&format!("create volume {VOLUME}"),
other,
)?;
}
let env = [
("REGISTRY_HTTP_NET", "unix".into()),
("REGISTRY_HTTP_ADDR", INNER_SOCKET.to_string()),
(
"REGISTRY_HTTP_TLS_CERTIFICATE",
format!("{CERT_DIR}/tls.crt"),
),
("REGISTRY_HTTP_TLS_KEY", format!("{CERT_DIR}/tls.key")),
("REGISTRY_STORAGE_DELETE_ENABLED", "true".into()),
(
"REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY",
"/var/lib/registry".into(),
),
("REGISTRY_LOG_LEVEL", "warn".into()),
];
let mut cfg = json!({
"boot.autostart": "true",
"limits.cpu": "2",
"limits.memory": "1GiB",
"user.isb.role": "registry",
});
for (k, v) in &env {
cfg[format!("environment.{k}")] = json!(v);
}
let devices = json!({
"data": {"type": "disk", "pool": pool, "source": VOLUME, "path": "/var/lib/registry"},
"https": {
"type": "proxy",
"listen": format!("tcp:{addr}"),
"connect": format!("unix:{INNER_SOCKET}"),
"bind": "host",
},
});
let inst_path = format!("/1.0/instances/{INSTANCE}");
let mut restart = false;
match s.get_opt(&inst_path)? {
None => {
report(&format!("creating {INSTANCE} from {IMAGE}"));
let src = crate::plan::ImageSource::parse(IMAGE)?;
s.mutate(
"POST",
"/1.0/instances",
Some(&json!({
"name": INSTANCE,
"type": "container",
"source": src.to_api(None),
"config": cfg,
"devices": devices,
"profiles": ["default"],
})),
&format!("create {INSTANCE}"),
s.get_timeouts().create,
)?;
}
Some(i) => {
let mut c = i["config"].clone();
let mut d = i["devices"].clone();
let mut changed = false;
for (k, v) in cfg.as_object().expect("object") {
if c[k] != *v {
c[k] = v.clone();
changed = true;
}
}
for (k, v) in devices.as_object().expect("object") {
if d[k] != *v {
d[k] = v.clone();
changed = true;
}
}
if changed {
report(&format!("updating {INSTANCE}"));
s.mutate(
"PATCH",
&inst_path,
Some(&json!({"config": c, "devices": d})),
&format!("update {INSTANCE}"),
other,
)?;
restart = true;
}
}
}
s.make_dir(INSTANCE, CERT_DIR, 0, 0, 0o700)?;
for (name, text, mode) in [("tls.crt", &mat.cert, 0o644), ("tls.key", &mat.key, 0o600)] {
let path = format!("{CERT_DIR}/{name}");
if s.read_file(INSTANCE, &path)?.as_deref() != Some(text.as_bytes()) {
s.push_file(INSTANCE, &path, text.as_bytes(), 0, 0, mode)?;
restart = true;
}
}
let state_now = s.get(&format!("{inst_path}/state"))?;
let running = state_now["status"].as_str() == Some("Running");
let action = match (running, restart) {
(false, _) => Some("start"),
(true, true) => Some("restart"),
(true, false) => None,
};
if let Some(a) = action {
report(&format!("{a}ing {INSTANCE}"));
s.mutate(
"PUT",
&format!("{inst_path}/state"),
Some(&json!({"action": a, "timeout": 30, "force": true})),
&format!("{a} {INSTANCE}"),
other,
)?;
}
let info = Info {
addr,
ca_pem: mat.ca_cert,
};
wait_up(&info, Duration::from_secs(60))?;
Ok(info)
}
fn wait_up(info: &Info, deadline: Duration) -> Result<()> {
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(5)))
.http_status_as_error(false)
.tls_config(
ureq::tls::TlsConfig::builder()
.root_certs(ureq::tls::RootCerts::new_with_certs(&[
ureq::tls::Certificate::from_pem(info.ca_pem.as_bytes())
.map_err(|e| Error::invalid(format!("registry CA: {e}")))?,
]))
.build(),
)
.build()
.into();
let started = Instant::now();
let mut last = String::new();
while started.elapsed() < deadline {
match agent.get(format!("{}/v2/", info.url())).call() {
Ok(r) if r.status().as_u16() == 200 => return Ok(()),
Ok(r) => last = format!("HTTP {}", r.status()),
Err(e) => last = e.to_string(),
}
std::thread::sleep(Duration::from_millis(500));
}
Err(Error::invalid(format!(
"registry {} not answering after {deadline:?}: {last}",
info.url()
)))
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct PushIndex {
#[serde(default)]
repos: BTreeMap<String, BTreeMap<String, (String, u64)>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct TagInfo {
pub tag: String,
pub digest: String,
pub pushed_at: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct RepoInfo {
pub repo: String,
pub org: String,
pub app: String,
pub tags: Vec<TagInfo>,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct GcReport {
pub deleted: Vec<String>,
pub kept: usize,
pub dry_run: bool,
pub collect: String,
}
pub fn select_deletions(
tags: &[TagInfo],
keep: usize,
protected: &BTreeSet<String>,
) -> Vec<String> {
let aside = |t: &TagInfo| t.tag.starts_with(KEEP_TAG) || is_preview_tag(&t.tag);
let mut sorted: Vec<&TagInfo> = tags.iter().collect();
sorted.sort_by(|a, b| {
aside(a)
.cmp(&aside(b))
.then_with(|| b.pushed_at.cmp(&a.pushed_at))
.then_with(|| b.tag.cmp(&a.tag))
});
let newest = sorted.iter().filter(|t| !aside(t)).count().min(keep);
let mut kept: BTreeSet<&str> = protected.iter().map(String::as_str).collect();
for t in sorted.iter().take(newest) {
kept.insert(&t.digest);
}
let mut seen = BTreeSet::new();
sorted
.iter()
.skip(newest)
.filter(|t| !kept.contains(t.digest.as_str()))
.map(|t| t.digest.clone())
.filter(|d| seen.insert(d.clone()))
.collect()
}
pub const KEEP_TAG: &str = "isb-keep-";
pub fn is_preview_tag(t: &str) -> bool {
preview_tag_number(t).is_some()
}
pub fn preview_tag_number(t: &str) -> Option<u64> {
let rest = t.strip_prefix("pr-")?;
let (n, sha) = rest.split_once('-')?;
if sha.is_empty() || n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
n.parse().ok()
}
pub struct Registry {
base: Client,
info: Info,
remote: oci::Remote,
dir: Option<PathBuf>,
lock: Mutex<()>,
}
static SHARED: OnceLock<Arc<Registry>> = OnceLock::new();
pub fn install(r: Arc<Registry>) {
let _ = SHARED.set(r);
}
impl Registry {
pub fn open(base: &Client, state: Option<&Path>) -> Result<Option<Registry>> {
let Some(info) = info(base)? else {
return Ok(None);
};
let remote = oci::Remote::new(&info.url(), Some(&info.ca_pem), Duration::from_secs(600))?;
Ok(Some(Registry {
base: base.clone().project("default"),
remote,
info,
dir: state.map(|s| s.join("registry")),
lock: Mutex::new(()),
}))
}
pub fn shared(base: &Client) -> Result<Arc<Registry>> {
if let Some(r) = SHARED.get() {
return Ok(r.clone());
}
let state = crate::stack::Store::default_dir();
let state = std::env::var_os("ISB_SERVE_STATE_DIR")
.map(PathBuf::from)
.unwrap_or(state);
Registry::open(base, Some(&state))?
.map(Arc::new)
.ok_or_else(not_set_up)
}
pub fn info(&self) -> &Info {
&self.info
}
pub fn remote(&self) -> &oci::Remote {
&self.remote
}
fn index_path(&self) -> Option<PathBuf> {
self.dir.as_ref().map(|d| d.join("pushes.json"))
}
fn load_index(&self) -> PushIndex {
self.index_path()
.and_then(|p| std::fs::read(p).ok())
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
}
fn save_index(&self, idx: &PushIndex) -> Result<()> {
let Some(p) = self.index_path() else {
return Ok(());
};
if let Some(d) = p.parent() {
std::fs::create_dir_all(d)?;
}
let tmp = p.with_extension("tmp");
std::fs::write(&tmp, serde_json::to_vec_pretty(idx)?)?;
std::fs::rename(tmp, p)?;
Ok(())
}
pub fn push(
&self,
org: &OrgId,
app: &str,
tag: &str,
tar: &Path,
log: &mut dyn FnMut(&str),
) -> Result<String> {
if !valid_app(app) {
return Err(Error::invalid(format!(
"app name {app:?}: [a-z0-9][a-z0-9._-]*"
)));
}
if !valid_tag(tag) {
return Err(Error::invalid(format!(
"tag {tag:?}: [A-Za-z0-9_][A-Za-z0-9_.-]*"
)));
}
let layout = oci::Layout::open(tar)?;
let _g = self.lock.lock().unwrap();
let r = repo(org, app);
let digest = self.remote.push(&layout, &r, tag, log)?;
let mut idx = self.load_index();
idx.repos
.entry(r)
.or_default()
.insert(tag.to_string(), (digest.clone(), crate::stack::now_secs()));
self.save_index(&idx)?;
Ok(digest)
}
pub fn resolve(&self, org: &OrgId, r: &ImageRef) -> Result<String> {
let name = repo(org, &r.app);
let (reference, sep) = match &r.digest {
Some(d) => (d.as_str(), '@'),
None => (r.tag_or_latest(), ':'),
};
self.remote.resolve(&name, reference)?.ok_or_else(|| {
Error::NotFound(format!(
"image registry:{} in org {org} (no {name}{sep}{reference} in the local registry)",
r.render()
))
})
}
pub fn list(&self, org: Option<&OrgId>) -> Result<Vec<RepoInfo>> {
let idx = self.load_index();
let mut out = Vec::new();
for name in self.remote.catalog()? {
let Some((o, app)) = name.split_once('/') else {
continue;
};
if org.is_some_and(|x| x.as_str() != o) {
continue;
}
let mut tags = Vec::new();
for t in self.remote.tags(&name)? {
let Some(d) = self.remote.resolve(&name, &t)? else {
continue;
};
let pushed_at = idx
.repos
.get(&name)
.and_then(|m| m.get(&t))
.filter(|(pd, _)| *pd == d)
.map(|(_, at)| *at)
.unwrap_or(0);
tags.push(TagInfo {
tag: t,
digest: d,
pushed_at,
});
}
if tags.is_empty() {
continue;
}
tags.sort_by(|a, b| {
b.pushed_at
.cmp(&a.pushed_at)
.then_with(|| a.tag.cmp(&b.tag))
});
out.push(RepoInfo {
org: o.to_string(),
app: app.to_string(),
repo: name.clone(),
tags,
});
}
Ok(out)
}
pub fn delete_tags(
&self,
org: &OrgId,
app: &str,
doomed: &dyn Fn(&str) -> bool,
spare: &BTreeSet<String>,
) -> Result<Vec<String>> {
let _g = self.lock.lock().unwrap();
let r = repo(org, app);
let mut by_digest: BTreeMap<String, Vec<String>> = BTreeMap::new();
for t in self.remote.tags(&r)? {
if let Some(d) = self.remote.resolve(&r, &t)? {
by_digest.entry(d).or_default().push(t);
}
}
let mut out = Vec::new();
for (d, tags) in &by_digest {
if spare.contains(d) || !tags.iter().all(|t| doomed(t)) {
continue;
}
self.remote.delete_manifest(&r, d)?;
out.push(format!("{d} ({})", tags.join(", ")));
}
if !out.is_empty() {
let mut idx = self.load_index();
if let Some(m) = idx.repos.get_mut(&r) {
m.retain(|_, (d, _)| !out.iter().any(|x| x.starts_with(d.as_str())));
}
self.save_index(&idx)?;
}
Ok(out)
}
pub fn gc(
&self,
keep: usize,
protected: &BTreeSet<String>,
dry_run: bool,
log: &mut dyn FnMut(&str),
) -> Result<GcReport> {
let _g = self.lock.lock().unwrap();
let mut report = GcReport {
dry_run,
..Default::default()
};
if !dry_run {
for p in protected {
let Some((repo, digest)) = p.split_once('@') else {
continue;
};
let tag = format!("{KEEP_TAG}{}", &digest[7..19.min(digest.len())]);
if self.remote.resolve(repo, &tag)?.as_deref() == Some(digest) {
continue;
}
if let Some((mt, bytes)) = self.remote.get_manifest(repo, digest)? {
self.remote.put_manifest(repo, &tag, &mt, &bytes)?;
}
}
}
for r in self.list(None)? {
let prot: BTreeSet<String> = protected
.iter()
.filter_map(|p| p.strip_prefix(&format!("{}@", r.repo)).map(String::from))
.collect();
let del = select_deletions(&r.tags, keep, &prot);
report.kept += r.tags.iter().filter(|t| !del.contains(&t.digest)).count();
for d in del {
let tags: Vec<&str> = r
.tags
.iter()
.filter(|t| t.digest == d)
.map(|t| t.tag.as_str())
.collect();
let what = format!("{}@{d} ({})", r.repo, tags.join(", "));
log(&format!(
"{}{what}",
if dry_run {
"would delete "
} else {
"deleting "
}
));
if !dry_run {
self.remote.delete_manifest(&r.repo, &d)?;
}
report.deleted.push(what);
}
}
if dry_run {
return Ok(report);
}
let mut idx = self.load_index();
for (repo, tags) in idx.repos.iter_mut() {
tags.retain(|_, (d, _)| {
!report
.deleted
.iter()
.any(|x| x.starts_with(&format!("{repo}@{d}")))
});
}
self.save_index(&idx)?;
log("collecting untagged manifests and unreferenced blobs");
let s = sys(&self.base);
let sb = crate::sandbox::Sandbox::get(&s, INSTANCE)?;
let out = sb
.exec_stream(
[
"/bin/registry",
"garbage-collect",
"--delete-untagged",
"/etc/docker/registry/config.yml",
],
crate::exec::ExecOptions::default()
.env(
"REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY",
"/var/lib/registry",
)
.env("REGISTRY_STORAGE_DELETE_ENABLED", "true")
.timeout(Duration::from_secs(1800)),
)?
.collect_output()?;
let text = format!("{}{}", out.stdout_text(), out.stderr_text());
let tail: Vec<&str> = text.lines().rev().take(20).collect();
report.collect = tail.into_iter().rev().collect::<Vec<_>>().join("\n");
if !out.success() {
return Err(Error::invalid(format!(
"registry garbage-collect failed ({}): {}",
out.exit_code, report.collect
)));
}
s.mutate(
"PUT",
&format!("/1.0/instances/{INSTANCE}/state"),
Some(&json!({"action": "restart", "timeout": 30, "force": true})),
&format!("restart {INSTANCE}"),
s.get_timeouts().other,
)?;
wait_up(&self.info, Duration::from_secs(60))?;
Ok(report)
}
}
fn not_set_up() -> Error {
Error::invalid(
"no local registry on this host: run `isb registry setup`, then `sudo isb host setup`",
)
}
pub fn require_info(base: &Client) -> Result<Info> {
info(base)?.ok_or_else(not_set_up)
}
pub fn protected_by(defs: &[Arc<crate::stack::StackDef>]) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for d in defs {
let mut cur: Option<&crate::stack::StackDef> = Some(d);
while let Some(def) = cur {
for (svc, spec) in &def.file.services {
let Some(r) = spec.image.strip_prefix("registry:") else {
continue;
};
let Ok(r) = ImageRef::parse(r) else { continue };
let digest = r.digest.clone().or_else(|| def.images.get(svc).cloned());
if let Some(dg) = digest {
out.insert(format!("{}@{dg}", repo(&def.org, &r.app)));
}
}
cur = def.previous.as_deref();
}
}
out
}
pub fn status_json(r: &Registry) -> Value {
json!({"addr": r.info.addr, "url": r.info.url()})
}
#[cfg(test)]
mod tests {
use super::*;
fn d(n: u8) -> String {
oci::digest_of(&[n])
}
#[test]
fn refs_parse_and_never_name_another_org() {
let r = ImageRef::parse("web:v1").unwrap();
assert_eq!((r.app.as_str(), r.tag.as_deref()), ("web", Some("v1")));
let r = ImageRef::parse("web").unwrap();
assert_eq!(r.tag_or_latest(), "latest");
let dg = d(1);
let r = ImageRef::parse(&format!("web:v2@{dg}")).unwrap();
assert_eq!(r.digest.as_deref(), Some(dg.as_str()));
assert_eq!(r.render(), format!("web:v2@{dg}"));
let org = OrgId::new("acme").unwrap();
assert_eq!(r.pull_alias(&org), format!("acme/web@{dg}"));
assert_eq!(
ImageRef::parse("web:v1").unwrap().pull_alias(&org),
"acme/web:v1"
);
assert_eq!(
ImageRef::parse("web:v1").unwrap().pinned(&dg).render(),
format!("web:v1@{dg}")
);
for bad in [
"other/web:v1",
"../web",
"Web",
"web:bad tag",
"web@sha256:abc",
"",
"web:",
":v1",
"web:-x",
] {
assert!(ImageRef::parse(bad).is_err(), "{bad}");
}
}
#[test]
fn retention_keeps_newest_and_deployed() {
let t = |tag: &str, n: u8, at: u64| TagInfo {
tag: tag.into(),
digest: d(n),
pushed_at: at,
};
let tags = vec![
t("v1", 1, 100),
t("v2", 2, 200),
t("v3", 3, 300),
t("v4", 4, 400),
t("old", 4, 50),
t("unknown", 5, 0),
];
let none = BTreeSet::new();
let del = select_deletions(&tags, 2, &none);
assert_eq!(del, vec![d(2), d(1), d(5)]);
let prot: BTreeSet<String> = [d(1)].into();
assert_eq!(select_deletions(&tags, 2, &prot), vec![d(2), d(5)]);
assert!(select_deletions(&tags, 10, &none).is_empty());
assert_eq!(select_deletions(&tags, 0, &none).len(), 5);
let mut with_keep = tags.clone();
with_keep.push(t(&format!("{KEEP_TAG}000000000001"), 1, 999));
assert_eq!(select_deletions(&with_keep, 2, &prot), vec![d(2), d(5)]);
assert_eq!(
select_deletions(&with_keep, 2, &none),
vec![d(2), d(1), d(5)]
);
let mut with_pr = tags.clone();
with_pr.push(t("pr-7-abc", 8, 9999));
with_pr.push(t("pr-7-def", 9, 9998));
let prot9: BTreeSet<String> = [d(9)].into();
assert_eq!(
select_deletions(&with_pr, 2, &prot9),
vec![d(2), d(1), d(5), d(8)]
);
assert!(is_preview_tag("pr-12-0123abc"));
assert_eq!(preview_tag_number("pr-12-0123abc"), Some(12));
for t in ["pr-x-abc", "pr-12", "pr--a", "v1", "pr-12-"] {
assert!(!is_preview_tag(t), "{t}");
}
}
#[test]
fn deployed_images_are_protected() {
let dg = d(7);
let file: crate::spec::ComposeFile = serde_yaml_ng::from_str(&format!(
"services:\n web: {{image: 'registry:web:v2'}}\n api: {{image: 'registry:api@{dg}'}}\n db: {{image: 'docker:postgres'}}\n"
))
.unwrap();
let mut def = crate::stack::StackDef {
name: "s".into(),
org: OrgId::new("acme").unwrap(),
file: file.clone(),
base_dir: "/".into(),
secrets: Default::default(),
force: Default::default(),
images: [("web".to_string(), d(2))].into(),
deployed_at: 0,
deployed_by: String::new(),
previous: None,
};
let mut prev = def.clone();
prev.images = [("web".to_string(), d(1))].into();
def.previous = Some(Box::new(prev));
let p = protected_by(&[Arc::new(def)]);
assert!(p.contains(&format!("acme/web@{}", d(2))));
assert!(
p.contains(&format!("acme/web@{}", d(1))),
"the previous deployment"
);
assert!(p.contains(&format!("acme/api@{dg}")));
assert_eq!(p.len(), 3);
}
#[test]
fn gc_against_a_fake_registry() {
let (base, st) = oci::tests::fake();
let remote = oci::Remote::new(&base, None, Duration::from_secs(10)).unwrap();
let dir = tempfile::tempdir().unwrap();
let reg = Registry {
base: Client::with_socket("/nonexistent"),
info: Info {
addr: base.trim_start_matches("http://").into(),
ca_pem: String::new(),
},
remote,
dir: Some(dir.path().join("registry")),
lock: Mutex::new(()),
};
let org = OrgId::new("acme").unwrap();
let mut digests = Vec::new();
for i in 0..3u8 {
let (t, _) = oci::tests::image_tar(&[i; 10]);
let p = dir.path().join(format!("{i}.tar"));
std::fs::write(&p, t).unwrap();
digests.push(
reg.push(&org, "web", &format!("v{i}"), &p, &mut |_| {})
.unwrap(),
);
let mut idx = reg.load_index();
idx.repos
.get_mut("acme/web")
.unwrap()
.get_mut(&format!("v{i}"))
.unwrap()
.1 = 1000 + i as u64;
reg.save_index(&idx).unwrap();
}
let ls = reg.list(Some(&org)).unwrap();
assert_eq!(
ls[0]
.tags
.iter()
.map(|t| t.tag.as_str())
.collect::<Vec<_>>(),
["v2", "v1", "v0"]
);
assert!(
reg.list(Some(&OrgId::new("other").unwrap()))
.unwrap()
.is_empty()
);
let r = reg
.resolve(&org, &ImageRef::parse("web:v1").unwrap())
.unwrap();
assert_eq!(r, digests[1]);
assert!(
reg.resolve(
&OrgId::new("other").unwrap(),
&ImageRef::parse("web:v1").unwrap()
)
.is_err()
);
let prot: BTreeSet<String> = [format!("acme/web@{}", digests[0])].into();
let rep = reg.gc(1, &prot, true, &mut |_| {}).unwrap();
assert_eq!(rep.deleted.len(), 1);
assert!(rep.deleted[0].contains(&digests[1]) && rep.deleted[0].contains("(v1)"));
assert!(
st.lock()
.unwrap()
.log
.iter()
.all(|l| !l.starts_with("DELETE"))
);
}
}