mirror of
https://github.com/anthonyoteri/dredge.git
synced 2026-06-05 15:26:53 -04:00
Merge pull request #46 from anthonyoteri/support-delete
Support delete command
This commit is contained in:
@@ -38,3 +38,4 @@ xdg = "2.5.2"
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
mockito = "1.2.0"
|
mockito = "1.2.0"
|
||||||
|
env_logger = "0.10.0"
|
||||||
@@ -32,6 +32,7 @@ Options:
|
|||||||
-V, --version
|
-V, --version
|
||||||
Print version
|
Print version
|
||||||
```
|
```
|
||||||
|
|
||||||
### Checking the API Version
|
### Checking the API Version
|
||||||
|
|
||||||
Perform a simple API Version check towards the registry endpoint
|
Perform a simple API Version check towards the registry endpoint
|
||||||
@@ -87,6 +88,15 @@ Options:
|
|||||||
|
|
||||||
Delete a tagged image from the registry
|
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
|
```shell
|
||||||
Usage: dredge <REGISTRY> delete <IMAGE> <TAG>
|
Usage: dredge <REGISTRY> delete <IMAGE> <TAG>
|
||||||
|
|
||||||
|
|||||||
+85
-5
@@ -14,11 +14,14 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
use http::header;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use crate::error::ApiError;
|
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
|
/// Iterate over a paginated result set, collecting and returning the response
|
||||||
/// set.
|
/// set.
|
||||||
///
|
///
|
||||||
@@ -48,8 +51,13 @@ pub async fn fetch_paginated<T: for<'de> Deserialize<'de>>(
|
|||||||
let url = origin.join(&next_path)?;
|
let url = origin.join(&next_path)?;
|
||||||
|
|
||||||
let resp = reqwest::get(url).await?;
|
let resp = reqwest::get(url).await?;
|
||||||
|
parse_response_status(&resp)?;
|
||||||
|
|
||||||
let headers = resp.headers().clone();
|
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))? {
|
if let Some(p) = parse_rfc5988(headers.get(http::header::LINK))? {
|
||||||
next_path = p;
|
next_path = p;
|
||||||
@@ -126,7 +134,7 @@ pub fn parse_response_status(response: &reqwest::Response) -> Result<(), ApiErro
|
|||||||
log::trace!("parse_response_status(response: {response:?})");
|
log::trace!("parse_response_status(response: {response:?})");
|
||||||
|
|
||||||
match response.status() {
|
match response.status() {
|
||||||
http::StatusCode::OK => {
|
http::StatusCode::OK | http::StatusCode::ACCEPTED => {
|
||||||
let headers = response.headers();
|
let headers = response.headers();
|
||||||
if let Some(header_value) = headers.get("Docker-Distribution-API-Version") {
|
if let Some(header_value) = headers.get("Docker-Distribution-API-Version") {
|
||||||
if header_value.to_str()? == "registry/2.0" {
|
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 => {
|
http::StatusCode::UNAUTHORIZED => {
|
||||||
let headers = response.headers();
|
let headers = response.headers();
|
||||||
if let Some(header_value) = headers.get("Docker-Distribution-API-Version") {
|
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),
|
http::StatusCode::NOT_FOUND => Err(ApiError::NotFound),
|
||||||
_ => Err(ApiError::UnexpectedResponse(
|
e => Err(ApiError::UnexpectedResponse(format!(
|
||||||
"Undocumented status code".into(),
|
"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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use http::header::HeaderValue;
|
use http::header::HeaderValue;
|
||||||
@@ -201,4 +237,48 @@ mod tests {
|
|||||||
// Assert that the function returned the expected URL as Some(String)
|
// Assert that the function returned the expected URL as Some(String)
|
||||||
assert_eq!(result, None);
|
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 url = registry_url.join(&path)?;
|
||||||
|
|
||||||
let resp = reqwest::get(url).await?;
|
let resp = reqwest::get(url).await?;
|
||||||
|
api::parse_response_status(&resp)?;
|
||||||
|
|
||||||
let headers = resp.headers();
|
let headers = resp.headers();
|
||||||
let digest: String = String::from(
|
let digest: String = String::from(
|
||||||
headers
|
headers
|
||||||
@@ -173,7 +175,17 @@ pub async fn delete_handler(
|
|||||||
tag: &str,
|
tag: &str,
|
||||||
) -> Result<(), ApiError> {
|
) -> Result<(), ApiError> {
|
||||||
log::trace!("delete_handler(registry_url: {registry_url:?}, image: {image}, tag: {tag})");
|
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.
|
// 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 path = "/v2";
|
||||||
let url = registry_url.join(path)?;
|
let url = registry_url.join(path)?;
|
||||||
|
|
||||||
let response = reqwest::get(url).await?;
|
let resp = reqwest::get(url).await?;
|
||||||
api::parse_response_status(&response)?;
|
api::parse_response_status(&resp)?;
|
||||||
writeln!(buf, "Ok")?;
|
writeln!(buf, "Ok")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -222,12 +234,13 @@ mod tests {
|
|||||||
.mock("GET", path)
|
.mock("GET", path)
|
||||||
.with_status(http::status::StatusCode::OK.as_u16().into())
|
.with_status(http::status::StatusCode::OK.as_u16().into())
|
||||||
.with_header(http::header::CONTENT_TYPE.as_str(), "application/json")
|
.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"]}"#)
|
.with_body(r#"{"repositories": ["image1", "image2", "image3"]}"#)
|
||||||
.create();
|
.create();
|
||||||
|
|
||||||
let mut buf: Vec<u8> = Vec::new();
|
let mut buf: Vec<u8> = Vec::new();
|
||||||
let result = catalog_handler(&mut buf, ®istry_url).await;
|
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");
|
assert_eq!(String::from_utf8(buf).unwrap(), *"image1\nimage2\nimage3\n");
|
||||||
|
|
||||||
mock_response.assert();
|
mock_response.assert();
|
||||||
@@ -251,6 +264,7 @@ mod tests {
|
|||||||
.mock("GET", path)
|
.mock("GET", path)
|
||||||
.with_status(http::status::StatusCode::OK.as_u16().into())
|
.with_status(http::status::StatusCode::OK.as_u16().into())
|
||||||
.with_header(http::header::CONTENT_TYPE.as_str(), "application/json")
|
.with_header(http::header::CONTENT_TYPE.as_str(), "application/json")
|
||||||
|
.with_header("Docker-Distribution-API-Version", "registry/2.0")
|
||||||
.with_header(
|
.with_header(
|
||||||
http::header::LINK.as_str(),
|
http::header::LINK.as_str(),
|
||||||
&format!(r#"<{path2}>; rel=next"#),
|
&format!(r#"<{path2}>; rel=next"#),
|
||||||
@@ -262,12 +276,13 @@ mod tests {
|
|||||||
.mock("GET", path2)
|
.mock("GET", path2)
|
||||||
.with_status(http::status::StatusCode::OK.as_u16().into())
|
.with_status(http::status::StatusCode::OK.as_u16().into())
|
||||||
.with_header(http::header::CONTENT_TYPE.as_str(), "application/json")
|
.with_header(http::header::CONTENT_TYPE.as_str(), "application/json")
|
||||||
|
.with_header("Docker-Distribution-API-Version", "registry/2.0")
|
||||||
.with_body(r#"{"repositories": ["image3"]}"#)
|
.with_body(r#"{"repositories": ["image3"]}"#)
|
||||||
.create();
|
.create();
|
||||||
|
|
||||||
let mut buf: Vec<u8> = Vec::new();
|
let mut buf: Vec<u8> = Vec::new();
|
||||||
let result = catalog_handler(&mut buf, ®istry_url).await;
|
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");
|
assert_eq!(String::from_utf8(buf).unwrap(), *"image1\nimage2\nimage3\n");
|
||||||
|
|
||||||
mock_response.assert();
|
mock_response.assert();
|
||||||
@@ -290,12 +305,13 @@ mod tests {
|
|||||||
.mock("GET", path)
|
.mock("GET", path)
|
||||||
.with_status(http::status::StatusCode::OK.as_u16().into())
|
.with_status(http::status::StatusCode::OK.as_u16().into())
|
||||||
.with_header(http::header::CONTENT_TYPE.as_str(), "application/json")
|
.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"]}"#)
|
.with_body(r#"{"tags": ["tag1", "tag2", "tag3"]}"#)
|
||||||
.create();
|
.create();
|
||||||
|
|
||||||
let mut buf: Vec<u8> = Vec::new();
|
let mut buf: Vec<u8> = Vec::new();
|
||||||
let result = tags_handler(&mut buf, ®istry_url, "some_image").await;
|
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");
|
assert_eq!(String::from_utf8(buf).unwrap(), *"tag1\ntag2\ntag3\n");
|
||||||
|
|
||||||
mock_response.assert();
|
mock_response.assert();
|
||||||
@@ -320,6 +336,7 @@ mod tests {
|
|||||||
.mock("GET", path)
|
.mock("GET", path)
|
||||||
.with_status(http::status::StatusCode::OK.as_u16().into())
|
.with_status(http::status::StatusCode::OK.as_u16().into())
|
||||||
.with_header(http::header::CONTENT_TYPE.as_str(), "application/json")
|
.with_header(http::header::CONTENT_TYPE.as_str(), "application/json")
|
||||||
|
.with_header("Docker-Distribution-API-Version", "registry/2.0")
|
||||||
.with_header(
|
.with_header(
|
||||||
http::header::LINK.as_str(),
|
http::header::LINK.as_str(),
|
||||||
&format!(r#"<{path2}>; rel=next"#),
|
&format!(r#"<{path2}>; rel=next"#),
|
||||||
@@ -331,12 +348,13 @@ mod tests {
|
|||||||
.mock("GET", path2)
|
.mock("GET", path2)
|
||||||
.with_status(http::status::StatusCode::OK.as_u16().into())
|
.with_status(http::status::StatusCode::OK.as_u16().into())
|
||||||
.with_header(http::header::CONTENT_TYPE.as_str(), "application/json")
|
.with_header(http::header::CONTENT_TYPE.as_str(), "application/json")
|
||||||
|
.with_header("Docker-Distribution-API-Version", "registry/2.0")
|
||||||
.with_body(r#"{"tags": ["tag3"]}"#)
|
.with_body(r#"{"tags": ["tag3"]}"#)
|
||||||
.create();
|
.create();
|
||||||
|
|
||||||
let mut buf: Vec<u8> = Vec::new();
|
let mut buf: Vec<u8> = Vec::new();
|
||||||
let result = tags_handler(&mut buf, ®istry_url, "some_image").await;
|
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");
|
assert_eq!(String::from_utf8(buf).unwrap(), *"tag1\ntag2\ntag3\n");
|
||||||
|
|
||||||
mock_response.assert();
|
mock_response.assert();
|
||||||
@@ -364,7 +382,7 @@ mod tests {
|
|||||||
|
|
||||||
let mut buf: Vec<u8> = Vec::new();
|
let mut buf: Vec<u8> = Vec::new();
|
||||||
let result = check_handler(&mut buf, ®istry_url).await;
|
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");
|
assert_eq!(String::from_utf8(buf).unwrap(), *"Ok\n");
|
||||||
|
|
||||||
mock_response.assert();
|
mock_response.assert();
|
||||||
@@ -492,6 +510,7 @@ mod tests {
|
|||||||
.mock("GET", path)
|
.mock("GET", path)
|
||||||
.with_status(http::status::StatusCode::OK.as_u16().into())
|
.with_status(http::status::StatusCode::OK.as_u16().into())
|
||||||
.with_header(http::header::CONTENT_TYPE.as_str(), "application/json")
|
.with_header(http::header::CONTENT_TYPE.as_str(), "application/json")
|
||||||
|
.with_header("Docker-Distribution-API-Version", "registry/2.0")
|
||||||
.with_header(
|
.with_header(
|
||||||
"docker-content-digest",
|
"docker-content-digest",
|
||||||
"sha256:0259571889ac87efbfca5b79a0abe9baf626d058ec5f9a5744bace2229d9ed50",
|
"sha256:0259571889ac87efbfca5b79a0abe9baf626d058ec5f9a5744bace2229d9ed50",
|
||||||
@@ -517,7 +536,7 @@ mod tests {
|
|||||||
etag: sha256:0259571889ac87efbfca5b79a0abe9baf626d058ec5f9a5744bace2229d9ed50\n"
|
etag: sha256:0259571889ac87efbfca5b79a0abe9baf626d058ec5f9a5744bace2229d9ed50\n"
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok(), "{:?}", result.unwrap_err());
|
||||||
assert_eq!(String::from_utf8(buf).unwrap(), *expected_body);
|
assert_eq!(String::from_utf8(buf).unwrap(), *expected_body);
|
||||||
|
|
||||||
mock_response.assert();
|
mock_response.assert();
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ pub enum ApiError {
|
|||||||
|
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
SerializerError(#[from] serde_yaml::Error),
|
SerializerError(#[from] serde_yaml::Error),
|
||||||
|
|
||||||
|
#[error("Method not allowed")]
|
||||||
|
MethodNotAllowed,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<reqwest::header::ToStrError> for ApiError {
|
impl From<reqwest::header::ToStrError> for ApiError {
|
||||||
|
|||||||
Reference in New Issue
Block a user