Skip to main content

opencode_cloud_core/docker/
version.rs

1//! Docker image version detection
2//!
3//! Reads version information from Docker image labels.
4
5use super::registry::fetch_registry_version;
6use super::{DockerClient, DockerError, IMAGE_NAME_DOCKERHUB, IMAGE_NAME_GHCR, IMAGE_TAG_DEFAULT};
7
8/// Version label key in Docker image
9pub const VERSION_LABEL: &str = "org.opencode-cloud.version";
10
11/// Get version from image label
12///
13/// Returns None if image doesn't exist or has no version label.
14/// Version label is set during automated builds; local builds have "dev".
15pub async fn get_image_version(
16    client: &DockerClient,
17    image_name: &str,
18) -> Result<Option<String>, DockerError> {
19    let inspect = match client.inner().inspect_image(image_name).await {
20        Ok(info) => info,
21        Err(bollard::errors::Error::DockerResponseServerError {
22            status_code: 404, ..
23        }) => {
24            return Ok(None);
25        }
26        Err(e) => {
27            return Err(DockerError::Connection(format!(
28                "Failed to inspect image: {e}"
29            )));
30        }
31    };
32
33    // Extract version from labels
34    let version = inspect
35        .config
36        .and_then(|c| c.labels)
37        .and_then(|labels| labels.get(VERSION_LABEL).cloned());
38
39    Ok(version)
40}
41
42pub async fn get_registry_latest_version(
43    client: &DockerClient,
44) -> Result<Option<String>, DockerError> {
45    match fetch_ghcr_registry_version(client).await {
46        Ok(version) => Ok(version),
47        Err(ghcr_err) => fetch_dockerhub_registry_version(client).await.map_err(|dockerhub_err| {
48            DockerError::Connection(format!(
49                "Failed to fetch registry version. GHCR: {ghcr_err}. Docker Hub: {dockerhub_err}"
50            ))
51        }),
52    }
53}
54
55async fn fetch_ghcr_registry_version(client: &DockerClient) -> Result<Option<String>, DockerError> {
56    let repo = IMAGE_NAME_GHCR
57        .strip_prefix("ghcr.io/")
58        .unwrap_or(IMAGE_NAME_GHCR);
59    let reference = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
60    let digest = fetch_registry_digest(client, &reference).await;
61    fetch_registry_version(
62        "https://ghcr.io",
63        &format!("https://ghcr.io/token?scope=repository:{repo}:pull"),
64        repo,
65        IMAGE_TAG_DEFAULT,
66        digest.as_deref(),
67        VERSION_LABEL,
68    )
69    .await
70}
71
72async fn fetch_dockerhub_registry_version(
73    client: &DockerClient,
74) -> Result<Option<String>, DockerError> {
75    let repo = IMAGE_NAME_DOCKERHUB;
76    let reference = format!("{IMAGE_NAME_DOCKERHUB}:{IMAGE_TAG_DEFAULT}");
77    let digest = fetch_registry_digest(client, &reference).await;
78    fetch_registry_version(
79        "https://registry-1.docker.io",
80        &format!(
81            "https://auth.docker.io/token?service=registry.docker.io&scope=repository:{repo}:pull"
82        ),
83        repo,
84        IMAGE_TAG_DEFAULT,
85        digest.as_deref(),
86        VERSION_LABEL,
87    )
88    .await
89}
90
91async fn fetch_registry_digest(client: &DockerClient, reference: &str) -> Option<String> {
92    client
93        .inner()
94        .inspect_registry_image(reference, None)
95        .await
96        .ok()
97        .and_then(|info| info.descriptor.digest)
98}
99
100/// CLI version from Cargo.toml
101pub fn get_cli_version() -> &'static str {
102    env!("CARGO_PKG_VERSION")
103}
104
105/// Compare versions and determine if they match
106///
107/// Returns true if versions are compatible (same or dev build).
108/// Returns false if versions differ and user should be prompted.
109pub fn versions_compatible(cli_version: &str, image_version: Option<&str>) -> bool {
110    match image_version {
111        None => true,        // No version label = local build, assume compatible
112        Some("dev") => true, // Dev build, assume compatible
113        Some(img_ver) => cli_version == img_ver,
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn test_versions_compatible_none() {
123        assert!(versions_compatible("1.0.8", None));
124    }
125
126    #[test]
127    fn test_versions_compatible_dev() {
128        assert!(versions_compatible("1.0.8", Some("dev")));
129    }
130
131    #[test]
132    fn test_versions_compatible_same() {
133        assert!(versions_compatible("1.0.8", Some("1.0.8")));
134    }
135
136    #[test]
137    fn test_versions_compatible_different() {
138        assert!(!versions_compatible("1.0.8", Some("1.0.7")));
139    }
140
141    #[test]
142    fn test_get_cli_version_format() {
143        let version = get_cli_version();
144        // Should be semver format
145        assert!(version.contains('.'));
146        let parts: Vec<&str> = version.split('.').collect();
147        assert_eq!(parts.len(), 3);
148    }
149
150    #[test]
151    fn test_version_label_constant() {
152        assert_eq!(VERSION_LABEL, "org.opencode-cloud.version");
153    }
154}