mirror of
https://github.com/anthonyoteri/dredge.git
synced 2026-06-05 15:26:53 -04:00
Support deleting an image tag
If the Docker Registry API allows for images to be deleted, issuing the command `delete <image> <tag>` will result in the tag being removed from the server. A limitation of this change is that it will cause any unreferenced tags to become orphaned, requiring the garbage collector within the docker registry server to clean these up. This is because there is no way to efficiently determine if each of the layers are reused by other tags or images on the server. Implement delete logic Fix broken unit tests
This commit is contained in:
+85
-5
@@ -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<T: for<'de> 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<String, ApiError> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
+28
-9
@@ -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<u8> = 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<u8> = 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<u8> = 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<u8> = 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<u8> = 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();
|
||||
|
||||
@@ -68,6 +68,9 @@ pub enum ApiError {
|
||||
|
||||
#[error(transparent)]
|
||||
SerializerError(#[from] serde_yaml::Error),
|
||||
|
||||
#[error("Method not allowed")]
|
||||
MethodNotAllowed,
|
||||
}
|
||||
|
||||
impl From<reqwest::header::ToStrError> for ApiError {
|
||||
|
||||
Reference in New Issue
Block a user