use std::time::Duration;
const DEFAULT_REGISTRY: &str = "ghcr.io";
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, thiserror::Error)]
pub enum GhcrError {
#[error("target version `{0}` is not valid semver")]
Version(String),
#[error("registry request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("registry response for {repo} had no {what}")]
Malformed {
repo: String,
what: &'static str,
},
#[error("could not obtain a GCP access token for registry `{registry}`: {reason}")]
GcpToken {
registry: String,
reason: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Component {
pub name: &'static str,
pub deployment: &'static str,
pub container: &'static str,
pub image_env: &'static str,
pub default_image: &'static str,
}
impl Component {
#[must_use]
pub fn ghcr_basename(&self) -> &str {
self.default_image
.rsplit('/')
.next()
.unwrap_or(self.default_image)
}
}
pub const COMPONENTS: [Component; 6] = [
Component {
name: "control-plane",
deployment: "polychrome-control-plane",
container: "polychrome",
image_env: "POLYCHROME_UPGRADE_IMAGE_CONTROL_PLANE",
default_image: "ghcr.io/officialunofficial/polychrome",
},
Component {
name: "harness",
deployment: "polychrome-harness",
container: "polychrome-harness",
image_env: "POLYCHROME_UPGRADE_IMAGE_HARNESS",
default_image: "ghcr.io/officialunofficial/polychrome-harness",
},
Component {
name: "slack",
deployment: "polychrome-slack",
container: "polychrome-slack",
image_env: "POLYCHROME_UPGRADE_IMAGE_SLACK",
default_image: "ghcr.io/officialunofficial/polychrome-slack",
},
Component {
name: "telegram",
deployment: "polychrome-telegram",
container: "polychrome-telegram",
image_env: "POLYCHROME_UPGRADE_IMAGE_TELEGRAM",
default_image: "ghcr.io/officialunofficial/polychrome-telegram",
},
Component {
name: "trigger",
deployment: "polychrome-trigger",
container: "polychrome-trigger",
image_env: "POLYCHROME_UPGRADE_IMAGE_TRIGGER",
default_image: "ghcr.io/officialunofficial/polychrome-trigger",
},
Component {
name: "scaffold",
deployment: "polychrome-scaffold",
container: "scaffold",
image_env: "POLYCHROME_UPGRADE_IMAGE_SCAFFOLD",
default_image: "ghcr.io/officialunofficial/polychrome-scaffold",
},
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImageRef {
pub registry: String,
pub repository: String,
}
#[must_use]
pub fn parse_image_ref(full: &str) -> ImageRef {
match full.split_once('/') {
Some((host, rest)) if host.contains('.') || host.contains(':') => ImageRef {
registry: host.to_owned(),
repository: rest.to_owned(),
},
_ => ImageRef {
registry: DEFAULT_REGISTRY.to_owned(),
repository: full.to_owned(),
},
}
}
#[must_use]
pub fn image_ref<F>(component: &Component, env_lookup: F) -> ImageRef
where
F: Fn(&str) -> Option<String>,
{
let raw = env_lookup(component.image_env).unwrap_or_else(|| component.default_image.to_owned());
parse_image_ref(&raw)
}
pub fn image_tag_for_version(version: &str) -> Result<String, GhcrError> {
let tag = version.trim().trim_start_matches('v');
semver::Version::parse(tag).map_err(|_| GhcrError::Version(version.to_owned()))?;
Ok(tag.to_owned())
}
#[must_use]
pub fn pinned_reference(image: &ImageRef, digest: &str) -> String {
format!("{}/{}@{}", image.registry, image.repository, digest)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RegistryAuth {
AnonymousToken,
GcpMetadata,
}
#[must_use]
pub fn auth_for(registry: &str) -> RegistryAuth {
if registry.ends_with(".pkg.dev") || registry == "gcr.io" || registry.ends_with(".gcr.io") {
RegistryAuth::GcpMetadata
} else {
RegistryAuth::AnonymousToken
}
}
pub fn http_client() -> Result<reqwest::Client, GhcrError> {
Ok(reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.connect_timeout(CONNECT_TIMEOUT)
.user_agent(concat!("polychrome/", env!("CARGO_PKG_VERSION")))
.build()?)
}
pub async fn resolve_digest(
client: &reqwest::Client,
registry: &str,
repository: &str,
tag: &str,
) -> Result<String, GhcrError> {
let token = match auth_for(registry) {
RegistryAuth::AnonymousToken => anonymous_pull_token(client, registry, repository).await?,
RegistryAuth::GcpMetadata => gcp_access_token(client, registry).await?,
};
let url = format!("https://{registry}/v2/{repository}/manifests/{tag}");
let resp = client
.get(&url)
.bearer_auth(&token)
.header(
reqwest::header::ACCEPT,
"application/vnd.oci.image.index.v1+json, \
application/vnd.docker.distribution.manifest.list.v2+json, \
application/vnd.oci.image.manifest.v1+json, \
application/vnd.docker.distribution.manifest.v2+json",
)
.send()
.await?
.error_for_status()?;
let digest = resp
.headers()
.get("docker-content-digest")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| GhcrError::Malformed {
repo: repository.to_owned(),
what: "digest header",
})?
.to_owned();
Ok(digest)
}
async fn anonymous_pull_token(
client: &reqwest::Client,
registry: &str,
repository: &str,
) -> Result<String, GhcrError> {
let url =
format!("https://{registry}/token?service={registry}&scope=repository:{repository}:pull");
let body: serde_json::Value = client
.get(&url)
.send()
.await?
.error_for_status()?
.json()
.await?;
body["token"]
.as_str()
.or_else(|| body["access_token"].as_str())
.map(str::to_owned)
.ok_or_else(|| GhcrError::Malformed {
repo: repository.to_owned(),
what: "pull token",
})
}
async fn gcp_access_token(client: &reqwest::Client, registry: &str) -> Result<String, GhcrError> {
const METADATA_TOKEN_URL: &str = "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token";
let body: serde_json::Value = client
.get(METADATA_TOKEN_URL)
.header("Metadata-Flavor", "Google")
.send()
.await
.map_err(|e| GhcrError::GcpToken {
registry: registry.to_owned(),
reason: format!("metadata request failed: {e}"),
})?
.error_for_status()
.map_err(|e| GhcrError::GcpToken {
registry: registry.to_owned(),
reason: format!("metadata server returned an error: {e}"),
})?
.json()
.await
.map_err(|e| GhcrError::GcpToken {
registry: registry.to_owned(),
reason: format!("metadata response was not JSON: {e}"),
})?;
body["access_token"]
.as_str()
.map(str::to_owned)
.ok_or_else(|| GhcrError::GcpToken {
registry: registry.to_owned(),
reason: "metadata response carried no `access_token`".to_owned(),
})
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery)]
use super::*;
fn component(name: &str) -> &'static Component {
COMPONENTS.iter().find(|c| c.name == name).unwrap()
}
#[test]
fn image_tag_strips_v_and_validates() {
assert_eq!(image_tag_for_version("v0.4.0").unwrap(), "0.4.0");
assert_eq!(image_tag_for_version("0.4.0").unwrap(), "0.4.0");
assert_eq!(image_tag_for_version(" v1.2.3 ").unwrap(), "1.2.3");
}
#[test]
fn image_tag_rejects_non_semver() {
assert!(image_tag_for_version("latest").is_err());
assert!(image_tag_for_version("1.2").is_err());
assert!(image_tag_for_version("v").is_err());
}
#[test]
fn parse_splits_gar_host_from_repository() {
let r = parse_image_ref(
"us-east4-docker.pkg.dev/official-unofficial/docker/polychrome-control-plane",
);
assert_eq!(r.registry, "us-east4-docker.pkg.dev");
assert_eq!(
r.repository,
"official-unofficial/docker/polychrome-control-plane"
);
}
#[test]
fn parse_defaults_hostless_ref_to_ghcr() {
let r = parse_image_ref("officialunofficial/polychrome");
assert_eq!(r.registry, "ghcr.io");
assert_eq!(r.repository, "officialunofficial/polychrome");
}
#[test]
fn parse_treats_host_port_as_registry() {
let r = parse_image_ref("localhost:5000/team/app");
assert_eq!(r.registry, "localhost:5000");
assert_eq!(r.repository, "team/app");
}
#[test]
fn image_ref_defaults_to_ghcr_with_empty_env() {
let cp = image_ref(component("control-plane"), |_| None);
assert_eq!(cp.registry, "ghcr.io");
assert_eq!(cp.repository, "officialunofficial/polychrome");
let slack = image_ref(component("slack"), |_| None);
assert_eq!(
pinned_reference(&slack, "sha256:x"),
"ghcr.io/officialunofficial/polychrome-slack@sha256:x"
);
}
#[test]
fn image_ref_honors_gar_override() {
let env = |k: &str| match k {
"POLYCHROME_UPGRADE_IMAGE_CONTROL_PLANE" => Some(
"us-east4-docker.pkg.dev/official-unofficial/docker/polychrome-control-plane"
.to_owned(),
),
_ => None,
};
let cp = image_ref(component("control-plane"), env);
assert_eq!(cp.registry, "us-east4-docker.pkg.dev");
assert_eq!(
cp.repository,
"official-unofficial/docker/polychrome-control-plane"
);
let slack = image_ref(component("slack"), env);
assert_eq!(slack.registry, "ghcr.io");
}
#[test]
fn pinned_reference_format_for_ghcr_and_gar() {
let ghcr = ImageRef {
registry: "ghcr.io".to_owned(),
repository: "officialunofficial/polychrome".to_owned(),
};
assert_eq!(
pinned_reference(&ghcr, "sha256:abc"),
"ghcr.io/officialunofficial/polychrome@sha256:abc"
);
let gar = ImageRef {
registry: "us-east4-docker.pkg.dev".to_owned(),
repository: "official-unofficial/docker/polychrome-control-plane".to_owned(),
};
assert_eq!(
pinned_reference(&gar, "sha256:def"),
"us-east4-docker.pkg.dev/official-unofficial/docker/polychrome-control-plane@sha256:def"
);
}
#[test]
fn auth_for_selects_by_host() {
assert_eq!(auth_for("ghcr.io"), RegistryAuth::AnonymousToken);
assert_eq!(
auth_for("us-east4-docker.pkg.dev"),
RegistryAuth::GcpMetadata
);
assert_eq!(auth_for("us.gcr.io"), RegistryAuth::GcpMetadata);
assert_eq!(auth_for("gcr.io"), RegistryAuth::GcpMetadata);
assert_eq!(auth_for("docker.io"), RegistryAuth::AnonymousToken);
}
#[test]
fn components_carry_stable_names() {
let names: Vec<&str> = COMPONENTS.iter().map(|c| c.name).collect();
assert_eq!(
names,
vec![
"control-plane",
"harness",
"slack",
"telegram",
"trigger",
"scaffold"
]
);
}
#[test]
fn ghcr_basename_matches_the_published_image_name_not_component_name() {
let basenames: Vec<&str> = COMPONENTS.iter().map(Component::ghcr_basename).collect();
assert_eq!(
basenames,
vec![
"polychrome",
"polychrome-harness",
"polychrome-slack",
"polychrome-telegram",
"polychrome-trigger",
"polychrome-scaffold",
]
);
}
}