diff --git a/Cargo.toml b/Cargo.toml index 580a857..46b4776 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,4 +37,5 @@ url = { version = "2.4.1", features = ["serde"] } xdg = "2.5.2" [dev-dependencies] -mockito = "1.2.0" \ No newline at end of file +mockito = "1.2.0" +env_logger = "0.10.0" \ No newline at end of file diff --git a/README.md b/README.md index 9f9e9cd..ecaa7f7 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Options: -V, --version Print version ``` + ### Checking the API Version Perform a simple API Version check towards the registry endpoint @@ -87,6 +88,15 @@ Options: Delete a tagged image from the registry +Note! This requires that the registry has storage delete rights enabled. For +example, when creating the registry, setting the environment variable +`REGISTRY_STORAGE_DELETE_ENABLED=true` to enable that feature. If that is not +enabled, a `MethodNotAllowed` error will be returned. + +Note! This will only remove the tag from the registry, it will not remove +orphaned digests. For that, the garbage collector on the registry service must +be run separately. + ```shell Usage: dredge delete diff --git a/src/api.rs b/src/api.rs index 150add9..9827b0e 100644 --- a/src/api.rs +++ b/src/api.rs @@ -14,11 +14,14 @@ * limitations under the License. */ +use http::header; use serde::Deserialize; use url::Url; use crate::error::ApiError; +const MANIFEST_V2: &str = "application/vnd.docker.distribution.manifest.v2+json"; + /// Iterate over a paginated result set, collecting and returning the response /// set. /// @@ -48,8 +51,13 @@ pub async fn fetch_paginated Deserialize<'de>>( let url = origin.join(&next_path)?; let resp = reqwest::get(url).await?; + parse_response_status(&resp)?; + let headers = resp.headers().clone(); - responses.push(resp.json().await?); + + if let Ok(json) = resp.json().await { + responses.push(json); + } if let Some(p) = parse_rfc5988(headers.get(http::header::LINK))? { next_path = p; @@ -126,7 +134,7 @@ pub fn parse_response_status(response: &reqwest::Response) -> Result<(), ApiErro log::trace!("parse_response_status(response: {response:?})"); match response.status() { - http::StatusCode::OK => { + http::StatusCode::OK | http::StatusCode::ACCEPTED => { let headers = response.headers(); if let Some(header_value) = headers.get("Docker-Distribution-API-Version") { if header_value.to_str()? == "registry/2.0" { @@ -140,6 +148,7 @@ pub fn parse_response_status(response: &reqwest::Response) -> Result<(), ApiErro )) } } + http::StatusCode::METHOD_NOT_ALLOWED => Err(ApiError::MethodNotAllowed), http::StatusCode::UNAUTHORIZED => { let headers = response.headers(); if let Some(header_value) = headers.get("Docker-Distribution-API-Version") { @@ -155,12 +164,39 @@ pub fn parse_response_status(response: &reqwest::Response) -> Result<(), ApiErro } } http::StatusCode::NOT_FOUND => Err(ApiError::NotFound), - _ => Err(ApiError::UnexpectedResponse( - "Undocumented status code".into(), - )), + e => Err(ApiError::UnexpectedResponse(format!( + "Undocumented status code: {e:?}" + ))), } } +/// Fetch the V2 Registry Digest for the specific manifest referenced in the +/// provided `url`. +/// +/// # Errors: +/// +/// This will return an `ApiError` if there is a problem fetching the manifest +/// headers. +pub async fn get_digest(client: &reqwest::Client, url: &Url) -> Result { + log::trace!("get_manifest(client: {client:?}, url: {url}"); + let resp = client + .head(url.as_ref()) + .header(header::ACCEPT, MANIFEST_V2) + .send() + .await?; + parse_response_status(&resp)?; + + let headers = resp.headers(); + Ok(String::from( + headers + .get("docker-content-digest") + .ok_or(ApiError::UnexpectedResponse(String::from( + "Missing docker-content-digest header", + )))? + .to_str()?, + )) +} + #[cfg(test)] mod tests { use http::header::HeaderValue; @@ -201,4 +237,48 @@ mod tests { // Assert that the function returned the expected URL as Some(String) assert_eq!(result, None); } + + /// Validates the happy path for the get_digest function + /// + /// This tests starts up a mock server, and the client makes a request for + /// the digest with the proper headers set. The test then validates that + /// the correct digest is returned and that the mock server had the expected + /// interactions. + #[async_std::test] + async fn test_get_digest() -> Result<(), ApiError> { + let mut server = mockito::Server::new_async().await; + let path = "/v2/foo/manifests/latest"; + + // 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("HEAD", path) + .match_header(http::header::ACCEPT.as_str(), MANIFEST_V2) + .with_status(http::status::StatusCode::OK.as_u16().into()) + .with_header(http::header::CONTENT_TYPE.as_str(), "application/json") + .with_header("Docker-Distribution-API-Version", "registry/2.0") + .with_header( + "docker-content-digest", + "sha256:0259571889ac87efbfca5b79a0abe9baf626d058ec5f9a5744bace2229d9ed50", + ) + .with_header( + "etag", + "sha256:0259571889ac87efbfca5b79a0abe9baf626d058ec5f9a5744bace2229d9ed50", + ) + .create(); + + let url = registry_url.join(path)?; + let client = reqwest::Client::new(); + let result = get_digest(&client, &url).await; + + assert!(result.is_ok(), "{:?}", result.unwrap_err()); + assert_eq!( + result.unwrap(), + *"sha256:0259571889ac87efbfca5b79a0abe9baf626d058ec5f9a5744bace2229d9ed50" + ); + + mock_response.assert(); + + Ok(()) + } } diff --git a/src/commands.rs b/src/commands.rs index 4c216e9..9ad39fb 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -128,6 +128,8 @@ pub async fn show_handler( let url = registry_url.join(&path)?; let resp = reqwest::get(url).await?; + api::parse_response_status(&resp)?; + let headers = resp.headers(); let digest: String = String::from( headers @@ -173,7 +175,17 @@ pub async fn delete_handler( tag: &str, ) -> Result<(), ApiError> { log::trace!("delete_handler(registry_url: {registry_url:?}, image: {image}, tag: {tag})"); - todo!() + + let client = reqwest::Client::new(); + let url = registry_url.join(&format!("/v2/{image}/manifests/{tag}"))?; + let digest = api::get_digest(&client, &url).await?; + + log::debug!("Deleting digest {digest}"); + let url = registry_url.join(&format!("/v2/{image}/manifests/{digest}"))?; + let resp = client.delete(url).send().await?; + api::parse_response_status(&resp)?; + + Ok(()) } // Path to the Docker Registry APIs "api version check" endpoint. @@ -190,8 +202,8 @@ pub async fn check_handler(buf: &mut dyn Write, registry_url: &Url) -> Result<() let path = "/v2"; let url = registry_url.join(path)?; - let response = reqwest::get(url).await?; - api::parse_response_status(&response)?; + let resp = reqwest::get(url).await?; + api::parse_response_status(&resp)?; writeln!(buf, "Ok")?; Ok(()) } @@ -222,12 +234,13 @@ mod tests { .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-Distribution-API-Version", "registry/2.0") .with_body(r#"{"repositories": ["image1", "image2", "image3"]}"#) .create(); let mut buf: Vec = Vec::new(); let result = catalog_handler(&mut buf, ®istry_url).await; - assert!(result.is_ok()); + assert!(result.is_ok(), "{:?}", result.unwrap_err()); assert_eq!(String::from_utf8(buf).unwrap(), *"image1\nimage2\nimage3\n"); mock_response.assert(); @@ -251,6 +264,7 @@ mod tests { .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-Distribution-API-Version", "registry/2.0") .with_header( http::header::LINK.as_str(), &format!(r#"<{path2}>; rel=next"#), @@ -262,12 +276,13 @@ mod tests { .mock("GET", path2) .with_status(http::status::StatusCode::OK.as_u16().into()) .with_header(http::header::CONTENT_TYPE.as_str(), "application/json") + .with_header("Docker-Distribution-API-Version", "registry/2.0") .with_body(r#"{"repositories": ["image3"]}"#) .create(); let mut buf: Vec = Vec::new(); let result = catalog_handler(&mut buf, ®istry_url).await; - assert!(result.is_ok()); + assert!(result.is_ok(), "{:?}", result.unwrap_err()); assert_eq!(String::from_utf8(buf).unwrap(), *"image1\nimage2\nimage3\n"); mock_response.assert(); @@ -290,12 +305,13 @@ mod tests { .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-Distribution-API-Version", "registry/2.0") .with_body(r#"{"tags": ["tag1", "tag2", "tag3"]}"#) .create(); let mut buf: Vec = Vec::new(); let result = tags_handler(&mut buf, ®istry_url, "some_image").await; - assert!(result.is_ok()); + assert!(result.is_ok(), "{:?}", result.unwrap_err()); assert_eq!(String::from_utf8(buf).unwrap(), *"tag1\ntag2\ntag3\n"); mock_response.assert(); @@ -320,6 +336,7 @@ mod tests { .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-Distribution-API-Version", "registry/2.0") .with_header( http::header::LINK.as_str(), &format!(r#"<{path2}>; rel=next"#), @@ -331,12 +348,13 @@ mod tests { .mock("GET", path2) .with_status(http::status::StatusCode::OK.as_u16().into()) .with_header(http::header::CONTENT_TYPE.as_str(), "application/json") + .with_header("Docker-Distribution-API-Version", "registry/2.0") .with_body(r#"{"tags": ["tag3"]}"#) .create(); let mut buf: Vec = Vec::new(); let result = tags_handler(&mut buf, ®istry_url, "some_image").await; - assert!(result.is_ok()); + assert!(result.is_ok(), "{:?}", result.unwrap_err()); assert_eq!(String::from_utf8(buf).unwrap(), *"tag1\ntag2\ntag3\n"); mock_response.assert(); @@ -364,7 +382,7 @@ mod tests { let mut buf: Vec = Vec::new(); let result = check_handler(&mut buf, ®istry_url).await; - assert!(result.is_ok()); + assert!(result.is_ok(), "{:?}", result.unwrap_err()); assert_eq!(String::from_utf8(buf).unwrap(), *"Ok\n"); mock_response.assert(); @@ -492,6 +510,7 @@ mod tests { .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-Distribution-API-Version", "registry/2.0") .with_header( "docker-content-digest", "sha256:0259571889ac87efbfca5b79a0abe9baf626d058ec5f9a5744bace2229d9ed50", @@ -517,7 +536,7 @@ mod tests { etag: sha256:0259571889ac87efbfca5b79a0abe9baf626d058ec5f9a5744bace2229d9ed50\n" }; - assert!(result.is_ok()); + assert!(result.is_ok(), "{:?}", result.unwrap_err()); assert_eq!(String::from_utf8(buf).unwrap(), *expected_body); mock_response.assert(); diff --git a/src/error.rs b/src/error.rs index dea2f06..a72c5f2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -68,6 +68,9 @@ pub enum ApiError { #[error(transparent)] SerializerError(#[from] serde_yaml::Error), + + #[error("Method not allowed")] + MethodNotAllowed, } impl From for ApiError {