From 86c82b2f76c7380aa25cd2bf32b5b35740bf6069 Mon Sep 17 00:00:00 2001 From: Anthony Oteri Date: Wed, 27 Sep 2023 10:07:34 -0400 Subject: [PATCH] Support detail view of a tagged image Running the `dredge show ` now responds with additional output in the form: ```yaml name: foobar tag: latest architecture: amd64 fsLayers: - blobSum: sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4 - blobSum: sha256:d4228a16bba21ff0eabab548df8f4933103d1a83e5894216c7eb32e3058a8e5e - blobSum: sha256:1f25ec90921b9d511541f9d38ce2b339de2afffc586e55d75b7345b2057f1993 - blobSum: sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4 - blobSum: sha256:7d97e254a0461b0a30b3f443f1daa0d620a3cc6ff4e2714cc1cfd96ace5b7a7e digest: sha256:0259571889ac87efbfca5b79a0abe9baf626d058ec5f9a5744bace2229d9ed50 etag: sha256:0259571889ac87efbfca5b79a0abe9baf626d058ec5f9a5744bace2229d9ed50 ``` --- Cargo.toml | 2 + src/commands.rs | 145 +++++++++++++++++++++++++++++++++++++++++++++++- src/error.rs | 3 + 3 files changed, 147 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 150d47b..09bf1d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,10 +25,12 @@ async-std = { version = "1.12.0", features = ["async-attributes", "attributes", clap = { version = "4.4.3", features = ["derive", "env", "wrap_help"] } femme = "2.2.1" http = "0.2.9" +indoc = "2.0.4" log = "0.4.20" reqwest = { version = "0.11.20", features = ["json", "gzip", "multipart", "native-tls-vendored"] } serde = { version = "1.0.188", features = ["derive"] } serde_toml = "0.0.1" +serde_yaml = "0.9.25" thiserror = "1.0.48" toml = "0.8.0" url = { version = "2.4.1", features = ["serde"] } diff --git a/src/commands.rs b/src/commands.rs index 0206291..4c216e9 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -17,6 +17,7 @@ use std::io::Write; use serde::Deserialize; +use serde::Serialize; use url::Url; use crate::api; @@ -94,16 +95,66 @@ pub async fn tags_handler( /// /// Returns an `ApiError` if there is a problem fetching the manifest or if there /// is a problem parsing the response from the Docker Registry API. -#[allow(clippy::unused_async)] +#[allow(clippy::similar_names)] pub async fn show_handler( - _buf: &mut dyn Write, + buf: &mut dyn Write, registry_url: &Url, image: &str, tag: &str, ) -> Result<(), ApiError> { + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + struct FsLayer { + blob_sum: String, + } + + #[derive(Debug, Serialize, Deserialize)] + struct Response { + name: String, + tag: String, + architecture: String, + + #[serde(rename = "fsLayers")] + fslayers: Vec, + + #[serde(skip_deserializing)] + digest: String, + + #[serde(skip_deserializing)] + etag: String, + } log::trace!("show_handler(registry_url: {registry_url:?}, image: {image}, tag: {tag})"); let path = format!("/v2/{image}/manifests/{tag}"); - let _url = registry_url.join(&path)?; + let url = registry_url.join(&path)?; + + let resp = reqwest::get(url).await?; + let headers = resp.headers(); + let digest: String = String::from( + headers + .get("docker-content-digest") + .ok_or(ApiError::UnexpectedResponse(String::from( + "Missing docker-content-digest header", + )))? + .to_str()?, + ); + + let etag: String = String::from( + headers + .get("etag") + .ok_or(ApiError::UnexpectedResponse(String::from( + "Missing etag header", + )))? + .to_str()? + .strip_prefix("'\"") + .and_then(|s| s.strip_suffix("\"'")) + .unwrap_or(&digest), + ); + + let mut body: Response = resp.json().await?; + body.digest = digest; + body.etag = etag; + + serde_yaml::to_writer(buf, &body)?; Ok(()) } @@ -149,6 +200,7 @@ pub async fn check_handler(buf: &mut dyn Write, registry_url: &Url) -> Result<() mod tests { use std::error::Error; + use indoc::indoc; use url::Url; use crate::error; @@ -383,4 +435,91 @@ mod tests { mock_response.assert(); Ok(()) } + + /// Validate the happy path for the show handler. + /// + /// This test spins up a mock server, and makes a request to the image + /// manifests endpoint. It checks that the handler both called the request + /// the expected number of times, and did not return an error. + #[async_std::test] + async fn test_show_handler() { + let mut server = mockito::Server::new_async().await; + let path = "/v2/foo/manifests/latest"; + + let response_body = r#" + { + "schemaVersion": 1, + "name": "foo", + "tag": "latest", + "architecture": "amd64", + "fsLayers": [ + { + "blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4" + }, + { + "blobSum": "sha256:7d97e254a0461b0a30b3f443f1daa0d620a3cc6ff4e2714cc1cfd96ace5b7a7e" + } + ], + "history": [ + { + "v1Compatibility": "{\"id\":\"7fe38ce3fe63caeaacf6be64933d0d55adc5c5f48762b20ec6129d1a41691a84\",\"parent\":\"8ca907037d044ff942e9c95562b786f1913d3b05a4bda16ad3ed3e7ee67e8c76\",\"created\":\"2023-09-07T00:21:13.838729514Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) CMD [\\\"bash\\\"]\"]},\"throwaway\":true}" + }, + { + "v1Compatibility": "{\"id\":\"8ca907037d044ff942e9c95562b786f1913d3b05a4bda16ad3ed3e7ee67e8c76\",\"created\":\"2023-09-07T00:21:13.444807009Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) ADD file:cb5fcc80c057b356a31492a20c6e3a75b70ed70a663506c8e97ad730ae32a02d in / \"]}}" + } + ], + "signatures": [ + { + "header": { + "jwk": { + "crv": "P-256", + "kid": "7ZLW:DJCO:GYG4:DCZD:TRO6:QW3Y:Q7Q3:PTXB:JDQX:4DLY:NB2B:4GJJ", + "kty": "EC", + "x": "LXquBoF1_XI3fawa-7UW9Y1Le7j7FiDGS3KB_4gF5hY", + "y": "UT5SniKpELMqL-j9YwL2fZLUHmRIFwori9rUBG18b_k" + }, + "alg": "ES256" + }, + "signature": "5_paRRhUCmwkAZJrjBfbvOJ341atEjUQuhG7i4kITyG3e_U2yuDqs9X7bHHMtmUTbChSp59NHi124uauAjoxIg", + "protected": "eyJmb3JtYXRMZW5ndGgiOjI3MDIsImZvcm1hdFRhaWwiOiJDbjAiLCJ0aW1lIjoiMjAyMy0wOS0yN1QxMzoyMTo1MloifQ" + } + ] + } + "#; + // Mock the HTTP response for the Docker Registry API + let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL"); + let mock_response = server + .mock("GET", path) + .with_status(http::status::StatusCode::OK.as_u16().into()) + .with_header(http::header::CONTENT_TYPE.as_str(), "application/json") + .with_header( + "docker-content-digest", + "sha256:0259571889ac87efbfca5b79a0abe9baf626d058ec5f9a5744bace2229d9ed50", + ) + .with_header( + "etag", + "sha256:0259571889ac87efbfca5b79a0abe9baf626d058ec5f9a5744bace2229d9ed50", + ) + .with_body(response_body) + .create(); + + let mut buf: Vec = Vec::new(); + let result = show_handler(&mut buf, ®istry_url, "foo", "latest").await; + + let expected_body = indoc! {" + name: foo + tag: latest + architecture: amd64 + fsLayers: + - blobSum: sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4 + - blobSum: sha256:7d97e254a0461b0a30b3f443f1daa0d620a3cc6ff4e2714cc1cfd96ace5b7a7e + digest: sha256:0259571889ac87efbfca5b79a0abe9baf626d058ec5f9a5744bace2229d9ed50 + etag: sha256:0259571889ac87efbfca5b79a0abe9baf626d058ec5f9a5744bace2229d9ed50\n" + }; + + assert!(result.is_ok()); + assert_eq!(String::from_utf8(buf).unwrap(), *expected_body); + + mock_response.assert(); + } } diff --git a/src/error.rs b/src/error.rs index 763b788..7b51be1 100644 --- a/src/error.rs +++ b/src/error.rs @@ -62,6 +62,9 @@ pub enum ApiError { #[error(transparent)] IOError(#[from] std::io::Error), + + #[error(transparent)] + SerializerError(#[from] serde_yaml::Error), } impl From for ApiError {