mirror of
https://github.com/anthonyoteri/dredge.git
synced 2026-06-05 15:26:53 -04:00
refactor: simplify codebase and fix correctness issues
- api: extract check_api_version_header() helper, eliminating duplicated header-checking logic in parse_response_status() - api: simplify parse_rfc5988() using split_once and let-else - api: propagate JSON decode errors in fetch_paginated() instead of silently swallowing them - api: add connect/request timeouts via a shared build_client() helper; all handlers now use a configured client instead of reqwest::get() - api: fix stale log trace name get_manifest -> get_digest - commands: promote inline response structs to module-level for clarity - commands: fix etag stripping logic (was using wrong quote/apostrophe pattern; now correctly strips RFC 7232 double-quotes) - commands: simplify iterator chains in catalog/tags handlers - error: simplify ResponseHeaderParseError from Box<dyn Error> to String - main: fix stale log trace name make_registry_url -> parse_registry_arg - main: use as_deref().unwrap_or() instead of allocating via to_owned() - cli: remove unused imports and #![allow(unused_imports)] attribute
This commit is contained in:
+390
-99
@@ -7,6 +7,8 @@
|
|||||||
* copied, modified, or distributed except according to those terms.
|
* copied, modified, or distributed except according to those terms.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use reqwest::header;
|
use reqwest::header;
|
||||||
use reqwest::header::HeaderValue;
|
use reqwest::header::HeaderValue;
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
@@ -15,26 +17,66 @@ use url::Url;
|
|||||||
|
|
||||||
use crate::error::ApiError;
|
use crate::error::ApiError;
|
||||||
|
|
||||||
|
/// The MIME type for Docker Image Manifest V2, Schema 2.
|
||||||
|
///
|
||||||
|
/// This value is sent in `Accept` headers when fetching manifests so that
|
||||||
|
/// the registry returns the canonical V2 manifest rather than a legacy V1
|
||||||
|
/// manifest.
|
||||||
const MANIFEST_V2: &str = "application/vnd.docker.distribution.manifest.v2+json";
|
const MANIFEST_V2: &str = "application/vnd.docker.distribution.manifest.v2+json";
|
||||||
|
|
||||||
/// Iterate over a paginated result set, collecting and returning the response
|
/// Connect timeout applied when establishing a TCP connection.
|
||||||
/// set.
|
const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
/// Overall request timeout from first byte sent to last byte received.
|
||||||
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
/// Build a shared [`reqwest::Client`] with sensible default timeouts.
|
||||||
///
|
///
|
||||||
/// The Docker Registry API specifies that when making a GET request, the
|
/// All outbound HTTP requests should use this client to prevent hung
|
||||||
/// response will be paginated using a Link response header for the Next URI.
|
/// connections from blocking the process indefinitely.
|
||||||
/// The URL will be encoded using [RFC5988](https://tools.ietf.org/html/rfc5988)
|
|
||||||
///
|
///
|
||||||
/// This function will continuously request the "Next" link as long as it is
|
/// # Errors
|
||||||
/// returned, collecting and returning the deserialized response bodies as a
|
|
||||||
/// Vec<T>.
|
|
||||||
///
|
///
|
||||||
/// # Errors:
|
/// Returns [`ApiError::HttpError`] if the underlying TLS backend fails to
|
||||||
|
/// initialise and the client cannot be constructed.
|
||||||
|
pub fn build_client() -> Result<reqwest::Client, ApiError> {
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.connect_timeout(CONNECT_TIMEOUT)
|
||||||
|
.timeout(REQUEST_TIMEOUT)
|
||||||
|
.build()
|
||||||
|
.map_err(ApiError::HttpError)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch all pages of a paginated Docker Registry API endpoint and return the
|
||||||
|
/// collected, deserialized response bodies.
|
||||||
///
|
///
|
||||||
/// Returns an `ApiError` if there is a problem constructing the URL from the
|
/// The Docker Registry HTTP API V2 paginates list responses using a `Link`
|
||||||
/// configured `registry_url` base and the given `path`, or if there is an
|
/// response header whose value is an [RFC 5988](https://tools.ietf.org/html/rfc5988)
|
||||||
/// error deserializing the HTTP response body as JSON, or if there is an
|
/// URL pointing to the next page. This function follows every `Link` header
|
||||||
/// error parsing the `Link` header value as an RFC5988 URL.
|
/// until no further pages remain, accumulating each page's deserialized JSON
|
||||||
|
/// body into the returned `Vec<T>`.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `client` — A configured [`reqwest::Client`] used to send requests.
|
||||||
|
/// * `origin` — The base URL of the Docker Registry (e.g.
|
||||||
|
/// `https://registry.example.com`).
|
||||||
|
/// * `path` — The API path to request (e.g. `v2/_catalog`).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an [`ApiError`] in any of the following situations:
|
||||||
|
///
|
||||||
|
/// * [`ApiError::UrlParseError`] — `origin` and `path` cannot be joined into a
|
||||||
|
/// valid URL.
|
||||||
|
/// * [`ApiError::HttpError`] — an HTTP request fails at the transport layer, or
|
||||||
|
/// a response body cannot be deserialized as JSON into `T`.
|
||||||
|
/// * [`ApiError::ResponseHeaderParseError`] — a `Link` header value contains
|
||||||
|
/// non-UTF-8 bytes.
|
||||||
|
/// * Any variant returned by [`parse_response_status`] — see that function for
|
||||||
|
/// the full list of status-code error conditions.
|
||||||
pub async fn fetch_paginated<T: for<'de> Deserialize<'de>>(
|
pub async fn fetch_paginated<T: for<'de> Deserialize<'de>>(
|
||||||
|
client: &reqwest::Client,
|
||||||
origin: &Url,
|
origin: &Url,
|
||||||
path: &str,
|
path: &str,
|
||||||
) -> Result<Vec<T>, ApiError> {
|
) -> Result<Vec<T>, ApiError> {
|
||||||
@@ -45,14 +87,12 @@ pub async fn fetch_paginated<T: for<'de> Deserialize<'de>>(
|
|||||||
loop {
|
loop {
|
||||||
let url = origin.join(&next_path)?;
|
let url = origin.join(&next_path)?;
|
||||||
|
|
||||||
let resp = reqwest::get(url).await?;
|
let resp = client.get(url).send().await?;
|
||||||
parse_response_status(&resp)?;
|
parse_response_status(&resp)?;
|
||||||
|
|
||||||
let headers = resp.headers().clone();
|
let headers = resp.headers().clone();
|
||||||
|
|
||||||
if let Ok(json) = resp.json().await {
|
responses.push(resp.json().await?);
|
||||||
responses.push(json);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(p) = parse_rfc5988(headers.get(header::LINK))? {
|
if let Some(p) = parse_rfc5988(headers.get(header::LINK))? {
|
||||||
next_path = p;
|
next_path = p;
|
||||||
@@ -63,117 +103,119 @@ pub async fn fetch_paginated<T: for<'de> Deserialize<'de>>(
|
|||||||
Ok(responses)
|
Ok(responses)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Given an optional header value possibly containing an RFC5988 formatted
|
/// Extract the URL from an optional RFC 5988 `Link` header value.
|
||||||
/// URL, parse said URL into a `String`.
|
|
||||||
///
|
///
|
||||||
/// If the `header_value` does not contain a correctly formatted RFC5988 URL,
|
/// The Docker Registry API uses `Link` headers of the form
|
||||||
/// or if the `header_value` is not properly formatted containing a URL
|
/// `<URL>; rel="next"` to signal the next page of a paginated result.
|
||||||
/// surrounded by angle brackets, separated from the link relation by a ';'
|
/// This function extracts the URL between the angle brackets from the
|
||||||
/// character, the `None` variant will be returned.
|
/// portion before the first `;`.
|
||||||
///
|
///
|
||||||
/// # Errors:
|
/// Returns `Ok(Some(url))` when a valid bracketed URL is found,
|
||||||
|
/// `Ok(None)` when the header is absent or does not contain a
|
||||||
|
/// bracketed URL (e.g. it is malformed or uses a different format).
|
||||||
///
|
///
|
||||||
/// Returns and `ApiError` if there is a problem parsing contents of the
|
/// # Errors
|
||||||
/// supplied header value.
|
///
|
||||||
|
/// Returns an [`ApiError`] if the header value contains non-UTF-8 bytes.
|
||||||
fn parse_rfc5988(header_value: Option<&HeaderValue>) -> Result<Option<String>, ApiError> {
|
fn parse_rfc5988(header_value: Option<&HeaderValue>) -> Result<Option<String>, ApiError> {
|
||||||
log::trace!("parse_rfc5988(header_value: {header_value:?})");
|
log::trace!("parse_rfc5988(header_value: {header_value:?})");
|
||||||
|
|
||||||
if let Some(link_value) = header_value {
|
let Some(link_value) = header_value else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
let link_str = link_value.to_str()?;
|
let link_str = link_value.to_str()?;
|
||||||
let parts: Vec<&str> = link_str.split(';').collect();
|
// RFC 5988 link header format: `<URL>; rel="next"` — take everything
|
||||||
if let Some(url_part) = parts.first() {
|
// before the first ';', strip the surrounding angle brackets.
|
||||||
if let Some(path) = url_part
|
let url_part = link_str.split_once(';').map_or(link_str, |(url, _)| url);
|
||||||
|
let path = url_part
|
||||||
.trim()
|
.trim()
|
||||||
.strip_prefix('<')
|
.strip_prefix('<')
|
||||||
.and_then(|s| s.strip_suffix('>'))
|
.and_then(|s| s.strip_suffix('>'));
|
||||||
{
|
|
||||||
return Ok(Some(String::from(path)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(None)
|
Ok(path.map(String::from))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse the response according to the API Documentation.
|
/// Check that the `Docker-Distribution-API-Version` response header is present
|
||||||
|
/// and equals `"registry/2.0"`.
|
||||||
///
|
///
|
||||||
/// If a 200 OK response is returned, the registry implements the V2(.1)
|
/// Returns `Ok(())` when the header is correct.
|
||||||
/// registry API and the client may proceed safely with other V2 operations.
|
|
||||||
/// Optionally, the response may contain information about the supported
|
|
||||||
/// paths in the response body. The client should be prepared to ignore this data.
|
|
||||||
///
|
///
|
||||||
/// If a 401 Unauthorized response is returned, the client should take action
|
/// # Errors
|
||||||
/// based on the contents of the "WWW-Authenticate" header and try the endpoint
|
|
||||||
/// again. Depending on access control setup, the client may still have to
|
|
||||||
/// authenticate against different resources, even if this check succeeds.
|
|
||||||
///
|
///
|
||||||
/// If 404 Not Found response status, or other unexpected status, is returned,
|
/// * [`ApiError::ResponseHeaderParseError`] — the header value contains
|
||||||
/// the client should proceed with the assumption that the registry does not
|
/// non-UTF-8 bytes.
|
||||||
/// implement V2 of the API.
|
/// * [`ApiError::UnsupportedVersion`] — the header is present but its value
|
||||||
|
/// is not `"registry/2.0"`.
|
||||||
|
/// * [`ApiError::UnexpectedResponse`] — the header is entirely absent.
|
||||||
|
fn check_api_version_header(response: &reqwest::Response) -> Result<(), ApiError> {
|
||||||
|
match response.headers().get("Docker-Distribution-API-Version") {
|
||||||
|
Some(v) if v.to_str()? == "registry/2.0" => Ok(()),
|
||||||
|
Some(v) => Err(ApiError::UnsupportedVersion(v.to_str()?.into())),
|
||||||
|
None => Err(ApiError::UnexpectedResponse(
|
||||||
|
"Missing version header".into(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate the HTTP status code of a Docker Registry API response.
|
||||||
///
|
///
|
||||||
/// When a 200 OK or 401 Unauthorized response is returned, the
|
/// The Docker Registry API contract requires that `2xx` responses include a
|
||||||
/// "Docker-Distribution-API-Version" header should be set to "registry/2.0".
|
/// `Docker-Distribution-API-Version: registry/2.0` header. `401 Unauthorized`
|
||||||
/// Clients may require this header value to determine if the endpoint serves
|
/// responses must also carry this header; when they do the caller should
|
||||||
/// this API. When this header is omitted, clients may fallback to an older
|
/// authenticate and retry. All other non-success codes are treated as errors.
|
||||||
/// API version.
|
|
||||||
///
|
///
|
||||||
/// # Errors:
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Returns an `ApiError` on the following conditions:
|
/// * [`ApiError::ResponseHeaderParseError`] — the `Docker-Distribution-API-Version`
|
||||||
///
|
/// header value contains non-UTF-8 bytes (only checked on `2xx` and `401`).
|
||||||
/// * There is an error parsing the "Docker-Distribution-API-Version" header.
|
/// * [`ApiError::UnsupportedVersion`] — a `2xx` or `401` response contains the
|
||||||
/// * The value of the above header is not the expected result.
|
/// version header with a value other than `"registry/2.0"`.
|
||||||
/// * The above header is missing from the response.
|
/// * [`ApiError::UnexpectedResponse`] — a `2xx` or `401` response is missing the
|
||||||
/// * A non 200 HTTP response status code is returned.
|
/// version header entirely.
|
||||||
|
/// * [`ApiError::AuthorizationFailed`] — the status code is `401 Unauthorized`
|
||||||
|
/// and the version header is valid.
|
||||||
|
/// * [`ApiError::NotFound`] — the status code is `404 Not Found`.
|
||||||
|
/// * [`ApiError::MethodNotAllowed`] — the status code is `405 Method Not Allowed`.
|
||||||
|
/// * [`ApiError::UnexpectedResponse`] — any other undocumented status code is
|
||||||
|
/// received.
|
||||||
pub fn parse_response_status(response: &reqwest::Response) -> Result<(), ApiError> {
|
pub fn parse_response_status(response: &reqwest::Response) -> Result<(), ApiError> {
|
||||||
log::trace!("parse_response_status(response: {response:?})");
|
log::trace!("parse_response_status(response: {response:?})");
|
||||||
|
|
||||||
match response.status() {
|
match response.status() {
|
||||||
StatusCode::OK | StatusCode::ACCEPTED => {
|
StatusCode::OK | StatusCode::ACCEPTED => check_api_version_header(response),
|
||||||
let headers = response.headers();
|
|
||||||
if let Some(header_value) = headers.get("Docker-Distribution-API-Version") {
|
|
||||||
if header_value.to_str()? == "registry/2.0" {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(ApiError::UnsupportedVersion(header_value.to_str()?.into()))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Err(ApiError::UnexpectedResponse(
|
|
||||||
"Missing version header".into(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
StatusCode::METHOD_NOT_ALLOWED => Err(ApiError::MethodNotAllowed),
|
|
||||||
StatusCode::UNAUTHORIZED => {
|
StatusCode::UNAUTHORIZED => {
|
||||||
let headers = response.headers();
|
check_api_version_header(response)?;
|
||||||
if let Some(header_value) = headers.get("Docker-Distribution-API-Version") {
|
|
||||||
if header_value.to_str()? == "registry/2.0" {
|
|
||||||
Err(ApiError::AuthorizationFailed)
|
Err(ApiError::AuthorizationFailed)
|
||||||
} else {
|
|
||||||
Err(ApiError::UnsupportedVersion(header_value.to_str()?.into()))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Err(ApiError::UnexpectedResponse(
|
|
||||||
"Missing version header".into(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
StatusCode::NOT_FOUND => Err(ApiError::NotFound),
|
StatusCode::NOT_FOUND => Err(ApiError::NotFound),
|
||||||
|
StatusCode::METHOD_NOT_ALLOWED => Err(ApiError::MethodNotAllowed),
|
||||||
e => Err(ApiError::UnexpectedResponse(format!(
|
e => Err(ApiError::UnexpectedResponse(format!(
|
||||||
"Undocumented status code: {e:?}"
|
"Undocumented status code: {e:?}"
|
||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch the V2 Registry Digest for the specific manifest referenced in the
|
/// Fetch the content digest for the manifest at `url`.
|
||||||
/// provided `url`.
|
|
||||||
///
|
///
|
||||||
/// # Errors:
|
/// Sends a `HEAD` request with an `Accept: application/vnd.docker.distribution.manifest.v2+json`
|
||||||
|
/// header and returns the value of the `docker-content-digest` response header.
|
||||||
|
/// This digest is required to delete a manifest, since the Docker Registry API
|
||||||
|
/// only accepts deletions by digest, not by tag name.
|
||||||
///
|
///
|
||||||
/// This will return an `ApiError` if there is a problem fetching the manifest
|
/// # Errors
|
||||||
/// headers.
|
///
|
||||||
|
/// * [`ApiError::HttpError`] — the HTTP request fails at the transport layer.
|
||||||
|
/// * [`ApiError::ResponseHeaderParseError`] — a response header contains
|
||||||
|
/// non-UTF-8 bytes.
|
||||||
|
/// * [`ApiError::UnexpectedResponse`] — the `docker-content-digest` header is
|
||||||
|
/// absent, or a `2xx` response is missing the version header.
|
||||||
|
/// * [`ApiError::UnsupportedVersion`] — the version header has an unexpected value.
|
||||||
|
/// * [`ApiError::AuthorizationFailed`] — the registry returns `401 Unauthorized`.
|
||||||
|
/// * [`ApiError::NotFound`] — the registry returns `404 Not Found`.
|
||||||
|
/// * [`ApiError::MethodNotAllowed`] — the registry returns `405 Method Not Allowed`.
|
||||||
pub async fn get_digest(client: &reqwest::Client, url: &Url) -> Result<String, ApiError> {
|
pub async fn get_digest(client: &reqwest::Client, url: &Url) -> Result<String, ApiError> {
|
||||||
log::trace!("get_manifest(client: {client:?}, url: {url}");
|
log::trace!("get_digest(client: {client:?}, url: {url}");
|
||||||
let resp = client
|
let resp = client
|
||||||
.head(url.as_ref())
|
.head(url.as_ref())
|
||||||
.header(header::ACCEPT, MANIFEST_V2)
|
.header(header::ACCEPT, MANIFEST_V2)
|
||||||
@@ -220,20 +262,29 @@ mod tests {
|
|||||||
/// variant is returned.
|
/// variant is returned.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_parse_rfc5988_invalid() {
|
async fn test_parse_rfc5988_invalid() {
|
||||||
// Mock a valid RFC5988 header value
|
let invalid_header_value = HeaderValue::from_str(r"invalid header value")
|
||||||
let invalid_header_value = HeaderValue::from_str(r#"invalid header value"#)
|
|
||||||
.expect("Failed to create valid header value");
|
.expect("Failed to create valid header value");
|
||||||
|
|
||||||
// Call the parse_rfc5988 function with the valid header value
|
// Call the parse_rfc5988 function with the invalid header value
|
||||||
let result = parse_rfc5988(Some(&invalid_header_value)).unwrap();
|
let result = parse_rfc5988(Some(&invalid_header_value)).unwrap();
|
||||||
|
|
||||||
// Assert that the function returned the expected URL as Some(String)
|
// Assert that the function returned None
|
||||||
assert_eq!(result, None);
|
assert_eq!(result, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validates the happy path for the get_digest function
|
/// Test that `parse_rfc5988` with `None` input returns `Ok(None)`.
|
||||||
///
|
///
|
||||||
/// This tests starts up a mock server, and the client makes a request for
|
/// When no `Link` header is present in the response, the function should
|
||||||
|
/// return `Ok(None)` to signal that there is no next page.
|
||||||
|
#[test]
|
||||||
|
fn test_parse_rfc5988_none_input() {
|
||||||
|
let result = parse_rfc5988(None).unwrap();
|
||||||
|
assert_eq!(result, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates the happy path for the `get_digest` function.
|
||||||
|
///
|
||||||
|
/// This test 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 digest with the proper headers set. The test then validates that
|
||||||
/// the correct digest is returned and that the mock server had the expected
|
/// the correct digest is returned and that the mock server had the expected
|
||||||
/// interactions.
|
/// interactions.
|
||||||
@@ -274,4 +325,244 @@ mod tests {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test `get_digest` when the `docker-content-digest` header is missing.
|
||||||
|
///
|
||||||
|
/// The function must return `ApiError::UnexpectedResponse` when the registry
|
||||||
|
/// omits the `docker-content-digest` header from an otherwise successful
|
||||||
|
/// `HEAD` response.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_digest_missing_digest_header() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let path = "/v2/foo/manifests/latest";
|
||||||
|
|
||||||
|
let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL");
|
||||||
|
let mock_response = server
|
||||||
|
.mock("HEAD", path)
|
||||||
|
.with_status(http::status::StatusCode::OK.as_u16().into())
|
||||||
|
.with_header("Docker-Distribution-API-Version", "registry/2.0")
|
||||||
|
// No docker-content-digest header
|
||||||
|
.create();
|
||||||
|
|
||||||
|
let url = registry_url.join(path)?;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let result = get_digest(&client, &url).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(
|
||||||
|
matches!(result.unwrap_err(), ApiError::UnexpectedResponse(_)),
|
||||||
|
"Expected ApiError::UnexpectedResponse"
|
||||||
|
);
|
||||||
|
|
||||||
|
mock_response.assert();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test `fetch_paginated` happy path — single page with no `Link` header.
|
||||||
|
///
|
||||||
|
/// When the registry returns a single page (no pagination link), the
|
||||||
|
/// function should return a `Vec` containing exactly one parsed response.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_fetch_paginated_single_page() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Resp {
|
||||||
|
items: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let path = "/v2/test/list";
|
||||||
|
|
||||||
|
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-Distribution-API-Version", "registry/2.0")
|
||||||
|
.with_body(r#"{"items": ["a", "b", "c"]}"#)
|
||||||
|
.create();
|
||||||
|
|
||||||
|
let client = build_client().expect("Failed to build client");
|
||||||
|
let result: Vec<Resp> = fetch_paginated(&client, ®istry_url, path).await?;
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
assert_eq!(result[0].items, vec!["a", "b", "c"]);
|
||||||
|
|
||||||
|
mock_response.assert();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that `fetch_paginated` propagates a JSON decode error on an empty body.
|
||||||
|
///
|
||||||
|
/// When the registry returns a success status but no body, the JSON
|
||||||
|
/// deserializer will fail. The error must be surfaced to the caller rather
|
||||||
|
/// than silently swallowed.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_fetch_paginated_empty_body_returns_error() {
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Resp {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
items: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let path = "/v2/test/empty";
|
||||||
|
|
||||||
|
let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL");
|
||||||
|
server
|
||||||
|
.mock("GET", path)
|
||||||
|
.with_status(http::status::StatusCode::OK.as_u16().into())
|
||||||
|
.with_header("Docker-Distribution-API-Version", "registry/2.0")
|
||||||
|
// No body — JSON deserialisation must fail and be propagated.
|
||||||
|
.create();
|
||||||
|
|
||||||
|
let client = build_client().expect("Failed to build client");
|
||||||
|
let result: Result<Vec<Resp>, _> = fetch_paginated(&client, ®istry_url, path).await;
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"Expected an error on empty body but got Ok"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test `parse_response_status` with `UNAUTHORIZED` and valid version header
|
||||||
|
/// returns `ApiError::AuthorizationFailed`.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_parse_response_status_unauthorized_valid_version() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let path = "/v2/";
|
||||||
|
|
||||||
|
let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL");
|
||||||
|
server
|
||||||
|
.mock("GET", path)
|
||||||
|
.with_status(http::status::StatusCode::UNAUTHORIZED.as_u16().into())
|
||||||
|
.with_header("Docker-Distribution-API-Version", "registry/2.0")
|
||||||
|
.create();
|
||||||
|
|
||||||
|
let url = registry_url.join(path).expect("Failed to join URL");
|
||||||
|
let resp = reqwest::get(url).await.expect("Request failed");
|
||||||
|
let result = parse_response_status(&resp);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(ApiError::AuthorizationFailed)),
|
||||||
|
"Expected AuthorizationFailed, got {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test `parse_response_status` with `UNAUTHORIZED` and wrong version header
|
||||||
|
/// returns `ApiError::UnsupportedVersion`.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_parse_response_status_unauthorized_wrong_version() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let path = "/v2/";
|
||||||
|
|
||||||
|
let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL");
|
||||||
|
server
|
||||||
|
.mock("GET", path)
|
||||||
|
.with_status(http::status::StatusCode::UNAUTHORIZED.as_u16().into())
|
||||||
|
.with_header("Docker-Distribution-API-Version", "registry/1.0")
|
||||||
|
.create();
|
||||||
|
|
||||||
|
let url = registry_url.join(path).expect("Failed to join URL");
|
||||||
|
let resp = reqwest::get(url).await.expect("Request failed");
|
||||||
|
let result = parse_response_status(&resp);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(ApiError::UnsupportedVersion(_))),
|
||||||
|
"Expected UnsupportedVersion, got {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test `parse_response_status` with `UNAUTHORIZED` and missing version header
|
||||||
|
/// returns `ApiError::UnexpectedResponse`.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_parse_response_status_unauthorized_missing_version() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let path = "/v2/";
|
||||||
|
|
||||||
|
let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL");
|
||||||
|
server
|
||||||
|
.mock("GET", path)
|
||||||
|
.with_status(http::status::StatusCode::UNAUTHORIZED.as_u16().into())
|
||||||
|
// No Docker-Distribution-API-Version header
|
||||||
|
.create();
|
||||||
|
|
||||||
|
let url = registry_url.join(path).expect("Failed to join URL");
|
||||||
|
let resp = reqwest::get(url).await.expect("Request failed");
|
||||||
|
let result = parse_response_status(&resp);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(ApiError::UnexpectedResponse(_))),
|
||||||
|
"Expected UnexpectedResponse, got {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test `parse_response_status` with `NOT_FOUND` returns `ApiError::NotFound`.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_parse_response_status_not_found() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let path = "/v2/";
|
||||||
|
|
||||||
|
let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL");
|
||||||
|
server
|
||||||
|
.mock("GET", path)
|
||||||
|
.with_status(http::status::StatusCode::NOT_FOUND.as_u16().into())
|
||||||
|
.create();
|
||||||
|
|
||||||
|
let url = registry_url.join(path).expect("Failed to join URL");
|
||||||
|
let resp = reqwest::get(url).await.expect("Request failed");
|
||||||
|
let result = parse_response_status(&resp);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(ApiError::NotFound)),
|
||||||
|
"Expected NotFound, got {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test `parse_response_status` with `METHOD_NOT_ALLOWED` returns
|
||||||
|
/// `ApiError::MethodNotAllowed`.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_parse_response_status_method_not_allowed() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let path = "/v2/";
|
||||||
|
|
||||||
|
let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL");
|
||||||
|
server
|
||||||
|
.mock("GET", path)
|
||||||
|
.with_status(http::status::StatusCode::METHOD_NOT_ALLOWED.as_u16().into())
|
||||||
|
.create();
|
||||||
|
|
||||||
|
let url = registry_url.join(path).expect("Failed to join URL");
|
||||||
|
let resp = reqwest::get(url).await.expect("Request failed");
|
||||||
|
let result = parse_response_status(&resp);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(ApiError::MethodNotAllowed)),
|
||||||
|
"Expected MethodNotAllowed, got {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test `parse_response_status` with an unexpected status code returns
|
||||||
|
/// `ApiError::UnexpectedResponse`.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_parse_response_status_unexpected_status() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let path = "/v2/";
|
||||||
|
|
||||||
|
let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL");
|
||||||
|
server
|
||||||
|
.mock("GET", path)
|
||||||
|
.with_status(http::status::StatusCode::INTERNAL_SERVER_ERROR.as_u16().into())
|
||||||
|
.create();
|
||||||
|
|
||||||
|
let url = registry_url.join(path).expect("Failed to join URL");
|
||||||
|
let resp = reqwest::get(url).await.expect("Request failed");
|
||||||
|
let result = parse_response_status(&resp);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(ApiError::UnexpectedResponse(_))),
|
||||||
|
"Expected UnexpectedResponse, got {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+105
-15
@@ -7,25 +7,34 @@
|
|||||||
* copied, modified, or distributed except according to those terms.
|
* copied, modified, or distributed except according to those terms.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#![allow(unused_imports)]
|
|
||||||
|
|
||||||
use std::ffi::OsString;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use clap::Args;
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use clap::Subcommand;
|
use clap::Subcommand;
|
||||||
use clap::ValueEnum;
|
use clap::ValueEnum;
|
||||||
|
|
||||||
/// Dredge is a command line tool for working with the Docker Registry
|
/// Command-line interface for `dredge`.
|
||||||
/// V2 API.
|
///
|
||||||
|
/// `dredge` is a tool for interacting with Docker Registry HTTP API V2
|
||||||
|
/// endpoints. It supports listing repositories and tags, inspecting image
|
||||||
|
/// manifests, deleting tagged images, and verifying registry API
|
||||||
|
/// compatibility.
|
||||||
|
///
|
||||||
|
/// The `<REGISTRY>` positional argument accepts a bare hostname
|
||||||
|
/// (`registry.example.com`), a host-and-port pair
|
||||||
|
/// (`registry.example.com:5000`), or a full URL with an explicit scheme
|
||||||
|
/// (`https://registry.example.com` or `http://registry.example.com`).
|
||||||
|
/// When no scheme is given, `https://` is assumed.
|
||||||
#[derive(Debug, Parser, PartialEq, Eq)]
|
#[derive(Debug, Parser, PartialEq, Eq)]
|
||||||
#[command(name = "dredge", version, author)]
|
#[command(name = "dredge", version, author)]
|
||||||
#[command(about, long_about)]
|
#[command(about, long_about)]
|
||||||
pub(crate) struct Cli {
|
pub(crate) struct Cli {
|
||||||
|
/// The subcommand to execute.
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
pub command: Commands,
|
pub command: Commands,
|
||||||
|
|
||||||
|
/// Minimum log level for messages written to stderr.
|
||||||
|
///
|
||||||
|
/// Possible values: `trace`, `debug`, `info`, `warn`, `error`, `off`.
|
||||||
|
/// Defaults to `info`.
|
||||||
#[arg(
|
#[arg(
|
||||||
long = "log-level",
|
long = "log-level",
|
||||||
require_equals = true,
|
require_equals = true,
|
||||||
@@ -37,17 +46,32 @@ pub(crate) struct Cli {
|
|||||||
)]
|
)]
|
||||||
pub log_level: LogLevel,
|
pub log_level: LogLevel,
|
||||||
|
|
||||||
/// The host or host:port or full base URL of the Docker Registry
|
/// The Docker Registry endpoint.
|
||||||
|
///
|
||||||
|
/// Accepts a hostname (`registry.example.com`), host and port
|
||||||
|
/// (`registry.example.com:5000`), or a full URL with an explicit scheme
|
||||||
|
/// (`https://registry.example.com` or `http://registry.example.com`).
|
||||||
|
/// The `https://` scheme is assumed when no scheme is provided.
|
||||||
pub registry: String,
|
pub registry: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Log verbosity level for the `--log-level` CLI option.
|
||||||
|
///
|
||||||
|
/// Maps directly to the corresponding [`log::LevelFilter`] variants. Use
|
||||||
|
/// `Off` to suppress all log output.
|
||||||
#[derive(ValueEnum, Copy, Clone, Debug, PartialEq, Eq)]
|
#[derive(ValueEnum, Copy, Clone, Debug, PartialEq, Eq)]
|
||||||
pub enum LogLevel {
|
pub enum LogLevel {
|
||||||
|
/// Extremely verbose output, including internal trace points.
|
||||||
Trace,
|
Trace,
|
||||||
|
/// Verbose output useful for debugging.
|
||||||
Debug,
|
Debug,
|
||||||
|
/// Informational messages (default).
|
||||||
Info,
|
Info,
|
||||||
|
/// Warnings about potentially unexpected conditions.
|
||||||
Warn,
|
Warn,
|
||||||
|
/// Only error messages.
|
||||||
Error,
|
Error,
|
||||||
|
/// Suppress all log output.
|
||||||
Off,
|
Off,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,28 +88,94 @@ impl From<LogLevel> for log::LevelFilter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Available `dredge` subcommands.
|
||||||
#[derive(Debug, Subcommand, PartialEq, Eq)]
|
#[derive(Debug, Subcommand, PartialEq, Eq)]
|
||||||
pub enum Commands {
|
pub enum Commands {
|
||||||
/// Fetch the list of available repositories from the catalog.
|
/// List all repositories available in the registry catalog.
|
||||||
|
///
|
||||||
|
/// Queries the `/v2/_catalog` endpoint and prints one repository name per
|
||||||
|
/// line. Paginated responses are followed automatically.
|
||||||
|
///
|
||||||
|
/// **Example:**
|
||||||
|
/// ```text
|
||||||
|
/// dredge registry.example.com catalog
|
||||||
|
/// ```
|
||||||
Catalog,
|
Catalog,
|
||||||
|
|
||||||
/// Fetch the list of tags for a given image.
|
/// List all tags published for an image.
|
||||||
|
///
|
||||||
|
/// Queries the `/v2/<NAME>/tags/list` endpoint and prints one tag per
|
||||||
|
/// line. Paginated responses are followed automatically.
|
||||||
|
///
|
||||||
|
/// **Example:**
|
||||||
|
/// ```text
|
||||||
|
/// dredge registry.example.com tags myorg/backend
|
||||||
|
/// ```
|
||||||
#[command(arg_required_else_help = true)]
|
#[command(arg_required_else_help = true)]
|
||||||
Tags { name: String },
|
Tags {
|
||||||
|
/// The repository name whose tags should be listed
|
||||||
|
/// (e.g. `myorg/backend`).
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
|
||||||
/// Show detailed information about a particular image.
|
/// Show detailed manifest information for a tagged image.
|
||||||
|
///
|
||||||
|
/// Queries the `/v2/<IMAGE>/manifests/<TAG>` endpoint and prints the
|
||||||
|
/// parsed manifest as YAML, including the image name, tag, architecture,
|
||||||
|
/// filesystem layers, content digest, and `ETag`.
|
||||||
|
///
|
||||||
|
/// When `[TAG]` is omitted, `latest` is used.
|
||||||
|
///
|
||||||
|
/// **Examples:**
|
||||||
|
/// ```text
|
||||||
|
/// dredge registry.example.com show myorg/backend
|
||||||
|
/// dredge registry.example.com show myorg/backend v2.0.0
|
||||||
|
/// ```
|
||||||
#[command(arg_required_else_help = true)]
|
#[command(arg_required_else_help = true)]
|
||||||
Show {
|
Show {
|
||||||
|
/// The repository name of the image to inspect (e.g. `myorg/backend`).
|
||||||
image: String,
|
image: String,
|
||||||
|
/// The tag to inspect. Defaults to `latest` when omitted.
|
||||||
#[arg(default_missing_value = "latest")]
|
#[arg(default_missing_value = "latest")]
|
||||||
tag: Option<String>,
|
tag: Option<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Delete a tagged image from the registry.
|
/// Delete a tagged image from the registry.
|
||||||
|
///
|
||||||
|
/// Resolves the tag to its content digest via a `HEAD` request, then
|
||||||
|
/// sends a `DELETE` request for that digest to the
|
||||||
|
/// `/v2/<IMAGE>/manifests/<DIGEST>` endpoint.
|
||||||
|
///
|
||||||
|
/// Requires the registry to have storage deletion enabled (set
|
||||||
|
/// `REGISTRY_STORAGE_DELETE_ENABLED=true` on the registry container).
|
||||||
|
/// If deletion is not enabled the registry returns a
|
||||||
|
/// `405 Method Not Allowed` response.
|
||||||
|
///
|
||||||
|
/// Only the manifest is removed; unreferenced layer blobs remain on disk
|
||||||
|
/// until the registry garbage collector is run.
|
||||||
|
///
|
||||||
|
/// **Example:**
|
||||||
|
/// ```text
|
||||||
|
/// dredge registry.example.com delete myorg/backend v1.0.0
|
||||||
|
/// ```
|
||||||
#[command(arg_required_else_help = true)]
|
#[command(arg_required_else_help = true)]
|
||||||
Delete { image: String, tag: String },
|
Delete {
|
||||||
|
/// The repository name of the image to delete (e.g. `myorg/backend`).
|
||||||
|
image: String,
|
||||||
|
/// The tag to delete (e.g. `v1.0.0`).
|
||||||
|
tag: String,
|
||||||
|
},
|
||||||
|
|
||||||
/// Perform a simple version check towards the Docker Registry API
|
/// Verify that the registry endpoint implements Docker Distribution API v2.
|
||||||
|
///
|
||||||
|
/// Sends a `GET` request to `/v2` and checks that the response contains a
|
||||||
|
/// `Docker-Distribution-API-Version: registry/2.0` header. Prints `Ok`
|
||||||
|
/// on success.
|
||||||
|
///
|
||||||
|
/// **Example:**
|
||||||
|
/// ```text
|
||||||
|
/// dredge registry.example.com check
|
||||||
|
/// ```
|
||||||
Check,
|
Check,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+233
-119
@@ -16,93 +16,29 @@ use url::Url;
|
|||||||
use crate::api;
|
use crate::api;
|
||||||
use crate::error::ApiError;
|
use crate::error::ApiError;
|
||||||
|
|
||||||
/// Handler for the `Catalog` endpoint
|
/// Deserialized body of a `/v2/_catalog` response page.
|
||||||
///
|
#[derive(Deserialize)]
|
||||||
/// Fetch the list of repository names from the Docker Registry API, and
|
struct CatalogResponse {
|
||||||
/// simply print the resulting names to stdout.
|
|
||||||
///
|
|
||||||
/// # Errors:
|
|
||||||
///
|
|
||||||
/// Returns an `ApiError` if there is a problem fetching or parsing the
|
|
||||||
/// responses from the Docker Registry API.
|
|
||||||
pub async fn catalog_handler(buf: &mut dyn Write, registry_url: &Url) -> Result<(), ApiError> {
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct Response {
|
|
||||||
repositories: Vec<String>,
|
repositories: Vec<String>,
|
||||||
}
|
|
||||||
|
|
||||||
log::trace!("catalog_handler(registry_url: {registry_url:?})");
|
|
||||||
let path = "v2/_catalog";
|
|
||||||
|
|
||||||
let responses: Vec<Response> = api::fetch_paginated(registry_url, path).await?;
|
|
||||||
let repository_list: Vec<&str> = responses
|
|
||||||
.iter()
|
|
||||||
.flat_map(|r| r.repositories.iter().map(String::as_str))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for repository in repository_list {
|
|
||||||
writeln!(buf, "{repository}")?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handler for the `Tags` endpoint
|
/// Deserialized body of a `/v2/<name>/tags/list` response page.
|
||||||
///
|
#[derive(Deserialize)]
|
||||||
/// Fetch the list of tags names for a given image from the Docker Registry API, and
|
struct TagsResponse {
|
||||||
/// simply print the resulting names to stdout.
|
|
||||||
///
|
|
||||||
/// # Errors:
|
|
||||||
///
|
|
||||||
/// Returns an `ApiError` if there is a problem fetching or parsing the
|
|
||||||
/// responses from the Docker Registry API.
|
|
||||||
pub async fn tags_handler(
|
|
||||||
buf: &mut dyn Write,
|
|
||||||
registry_url: &Url,
|
|
||||||
name: &str,
|
|
||||||
) -> Result<(), ApiError> {
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct Response {
|
|
||||||
tags: Vec<String>,
|
tags: Vec<String>,
|
||||||
}
|
|
||||||
|
|
||||||
log::trace!("tags_handler(registry_url: {registry_url:?}, name: {name})");
|
|
||||||
let path = format!("/v2/{name}/tags/list");
|
|
||||||
|
|
||||||
let responses: Vec<Response> = api::fetch_paginated(registry_url, &path).await?;
|
|
||||||
let tag_list: Vec<&str> = responses
|
|
||||||
.iter()
|
|
||||||
.flat_map(|r| r.tags.iter().map(String::as_str))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for tag in tag_list {
|
|
||||||
writeln!(buf, "{tag}")?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handler function for showing manifest details
|
/// A single filesystem layer entry within a V1 image manifest.
|
||||||
///
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
/// # Errors:
|
#[serde(rename_all = "camelCase")]
|
||||||
///
|
struct FsLayer {
|
||||||
/// 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::similar_names)]
|
|
||||||
pub async fn show_handler(
|
|
||||||
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,
|
blob_sum: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
/// Deserialized body of a `/v2/<image>/manifests/<tag>` response, augmented
|
||||||
struct Response {
|
/// with the `digest` and `etag` values extracted from response headers.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct ManifestResponse {
|
||||||
name: String,
|
name: String,
|
||||||
tag: String,
|
tag: String,
|
||||||
architecture: String,
|
architecture: String,
|
||||||
@@ -110,56 +46,216 @@ pub async fn show_handler(
|
|||||||
#[serde(rename = "fsLayers")]
|
#[serde(rename = "fsLayers")]
|
||||||
fslayers: Vec<FsLayer>,
|
fslayers: Vec<FsLayer>,
|
||||||
|
|
||||||
|
/// Content digest from the `docker-content-digest` response header.
|
||||||
#[serde(skip_deserializing)]
|
#[serde(skip_deserializing)]
|
||||||
digest: String,
|
digest: String,
|
||||||
|
|
||||||
|
/// `ETag` value from the response header (quotes stripped), or the digest
|
||||||
|
/// when the `ETag` header is absent.
|
||||||
#[serde(skip_deserializing)]
|
#[serde(skip_deserializing)]
|
||||||
etag: String,
|
etag: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch all repository names from the registry catalog and write them to `buf`.
|
||||||
|
///
|
||||||
|
/// Queries `/v2/_catalog` via [`api::fetch_paginated`], collects all pages,
|
||||||
|
/// and writes one repository name per line to the provided writer.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `buf` — Output sink (typically stdout or a test buffer).
|
||||||
|
/// * `registry_url` — Base URL of the Docker Registry.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// * [`ApiError::HttpError`] — the HTTP client could not be constructed, or a
|
||||||
|
/// request to the registry failed at the transport layer, or a response body
|
||||||
|
/// could not be decoded as JSON.
|
||||||
|
/// * [`ApiError::UrlParseError`] — the catalog URL could not be constructed.
|
||||||
|
/// * [`ApiError::ResponseHeaderParseError`] — a response header contains
|
||||||
|
/// non-UTF-8 bytes.
|
||||||
|
/// * [`ApiError::UnsupportedVersion`] — the registry version header has an
|
||||||
|
/// unexpected value.
|
||||||
|
/// * [`ApiError::UnexpectedResponse`] — a required response header is absent.
|
||||||
|
/// * [`ApiError::AuthorizationFailed`] — the registry requires authentication.
|
||||||
|
/// * [`ApiError::NotFound`] — the catalog endpoint does not exist.
|
||||||
|
/// * [`ApiError::MethodNotAllowed`] — the registry rejected the request method.
|
||||||
|
/// * [`ApiError::IOError`] — writing a repository name to `buf` failed.
|
||||||
|
pub async fn catalog_handler(buf: &mut dyn Write, registry_url: &Url) -> Result<(), ApiError> {
|
||||||
|
log::trace!("catalog_handler(registry_url: {registry_url:?})");
|
||||||
|
|
||||||
|
let client = api::build_client()?;
|
||||||
|
let responses: Vec<CatalogResponse> =
|
||||||
|
api::fetch_paginated(&client, registry_url, "v2/_catalog").await?;
|
||||||
|
|
||||||
|
for repo in responses.iter().flat_map(|r| r.repositories.iter()) {
|
||||||
|
writeln!(buf, "{repo}")?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch all tags for an image from the registry and write them to `buf`.
|
||||||
|
///
|
||||||
|
/// Queries `/v2/<name>/tags/list` via [`api::fetch_paginated`], collects all
|
||||||
|
/// pages, and writes one tag name per line to the provided writer.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `buf` — Output sink (typically stdout or a test buffer).
|
||||||
|
/// * `registry_url` — Base URL of the Docker Registry.
|
||||||
|
/// * `name` — The repository name whose tags should be listed
|
||||||
|
/// (e.g. `"myorg/backend"`).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// * [`ApiError::HttpError`] — the HTTP client could not be constructed, or a
|
||||||
|
/// request to the registry failed at the transport layer, or a response body
|
||||||
|
/// could not be decoded as JSON.
|
||||||
|
/// * [`ApiError::UrlParseError`] — the tags URL could not be constructed.
|
||||||
|
/// * [`ApiError::ResponseHeaderParseError`] — a response header contains
|
||||||
|
/// non-UTF-8 bytes.
|
||||||
|
/// * [`ApiError::UnsupportedVersion`] — the registry version header has an
|
||||||
|
/// unexpected value.
|
||||||
|
/// * [`ApiError::UnexpectedResponse`] — a required response header is absent.
|
||||||
|
/// * [`ApiError::AuthorizationFailed`] — the registry requires authentication.
|
||||||
|
/// * [`ApiError::NotFound`] — the image does not exist in the registry.
|
||||||
|
/// * [`ApiError::MethodNotAllowed`] — the registry rejected the request method.
|
||||||
|
/// * [`ApiError::IOError`] — writing a tag name to `buf` failed.
|
||||||
|
pub async fn tags_handler(
|
||||||
|
buf: &mut dyn Write,
|
||||||
|
registry_url: &Url,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
log::trace!("tags_handler(registry_url: {registry_url:?}, name: {name})");
|
||||||
|
|
||||||
|
let client = api::build_client()?;
|
||||||
|
let responses: Vec<TagsResponse> =
|
||||||
|
api::fetch_paginated(&client, registry_url, &format!("/v2/{name}/tags/list")).await?;
|
||||||
|
|
||||||
|
for tag in responses.iter().flat_map(|r| r.tags.iter()) {
|
||||||
|
writeln!(buf, "{tag}")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch and display the manifest for a tagged image.
|
||||||
|
///
|
||||||
|
/// Queries `/v2/<image>/manifests/<tag>`, extracts the
|
||||||
|
/// `docker-content-digest` and `etag` response headers, deserializes the
|
||||||
|
/// manifest JSON body, and serializes the result as YAML to `buf`.
|
||||||
|
///
|
||||||
|
/// The output includes the image name, tag, target architecture, filesystem
|
||||||
|
/// layer digests, content digest, and `ETag`.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `buf` — Output sink (typically stdout or a test buffer).
|
||||||
|
/// * `registry_url` — Base URL of the Docker Registry.
|
||||||
|
/// * `image` — The repository name (e.g. `"myorg/backend"`).
|
||||||
|
/// * `tag` — The tag to inspect (e.g. `"v2.0.0"`). Pass `"latest"` when
|
||||||
|
/// no explicit tag was provided by the caller.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// * [`ApiError::HttpError`] — the HTTP client could not be constructed, or the
|
||||||
|
/// request failed at the transport layer, or the response body could not be
|
||||||
|
/// decoded as JSON.
|
||||||
|
/// * [`ApiError::UrlParseError`] — the manifest URL could not be constructed.
|
||||||
|
/// * [`ApiError::ResponseHeaderParseError`] — a response header contains
|
||||||
|
/// non-UTF-8 bytes.
|
||||||
|
/// * [`ApiError::UnexpectedResponse`] — the `docker-content-digest` header is
|
||||||
|
/// absent, or a required version header is missing.
|
||||||
|
/// * [`ApiError::UnsupportedVersion`] — the registry version header has an
|
||||||
|
/// unexpected value.
|
||||||
|
/// * [`ApiError::AuthorizationFailed`] — the registry requires authentication.
|
||||||
|
/// * [`ApiError::NotFound`] — the image or tag does not exist in the registry.
|
||||||
|
/// * [`ApiError::MethodNotAllowed`] — the registry rejected the request method.
|
||||||
|
/// * [`ApiError::SerializerError`] — the manifest could not be serialized to YAML.
|
||||||
|
/// * [`ApiError::IOError`] — writing the YAML output to `buf` failed.
|
||||||
|
#[allow(clippy::similar_names)]
|
||||||
|
pub async fn show_handler(
|
||||||
|
buf: &mut dyn Write,
|
||||||
|
registry_url: &Url,
|
||||||
|
image: &str,
|
||||||
|
tag: &str,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
log::trace!("show_handler(registry_url: {registry_url:?}, image: {image}, tag: {tag})");
|
log::trace!("show_handler(registry_url: {registry_url:?}, image: {image}, tag: {tag})");
|
||||||
let path = format!("/v2/{image}/manifests/{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 client = api::build_client()?;
|
||||||
|
let resp = client.get(url).send().await?;
|
||||||
api::parse_response_status(&resp)?;
|
api::parse_response_status(&resp)?;
|
||||||
|
|
||||||
let headers = resp.headers();
|
let headers = resp.headers();
|
||||||
let digest: String = String::from(
|
let digest = headers
|
||||||
headers
|
|
||||||
.get("docker-content-digest")
|
.get("docker-content-digest")
|
||||||
.ok_or(ApiError::UnexpectedResponse(String::from(
|
.ok_or_else(|| {
|
||||||
"Missing docker-content-digest header",
|
ApiError::UnexpectedResponse("Missing docker-content-digest header".into())
|
||||||
)))?
|
})?
|
||||||
.to_str()?,
|
|
||||||
);
|
|
||||||
|
|
||||||
let etag: String = String::from(
|
|
||||||
headers
|
|
||||||
.get("etag")
|
|
||||||
.ok_or(ApiError::UnexpectedResponse(String::from(
|
|
||||||
"Missing etag header",
|
|
||||||
)))?
|
|
||||||
.to_str()?
|
.to_str()?
|
||||||
.strip_prefix("'\"")
|
.to_owned();
|
||||||
.and_then(|s| s.strip_suffix("\"'"))
|
|
||||||
.unwrap_or(&digest),
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut body: Response = resp.json().await?;
|
// Docker Registry API ETags are quoted strings per RFC 7232, e.g.
|
||||||
|
// `"sha256:abc123"`. Strip surrounding double-quotes when present; fall
|
||||||
|
// back to the digest when the header is absent.
|
||||||
|
let etag = match headers.get("etag") {
|
||||||
|
Some(v) => {
|
||||||
|
let raw = v.to_str()?;
|
||||||
|
raw.strip_prefix('"')
|
||||||
|
.and_then(|s| s.strip_suffix('"'))
|
||||||
|
.unwrap_or(raw)
|
||||||
|
.to_owned()
|
||||||
|
}
|
||||||
|
None => digest.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut body: ManifestResponse = resp.json().await?;
|
||||||
body.digest = digest;
|
body.digest = digest;
|
||||||
body.etag = etag;
|
body.etag = etag;
|
||||||
|
|
||||||
serde_yaml::to_writer(buf, &body)?;
|
serde_yml::to_writer(buf, &body)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handler function for deleting a manifest for a given tagged image.
|
/// Delete the manifest for a tagged image from the registry.
|
||||||
///
|
///
|
||||||
/// # Errors:
|
/// Resolves `tag` to its content digest by sending a `HEAD` request to
|
||||||
|
/// `/v2/<image>/manifests/<tag>`, then deletes the manifest by digest via
|
||||||
|
/// `DELETE /v2/<image>/manifests/<digest>`.
|
||||||
///
|
///
|
||||||
/// Returns and `ApiError` if there is a problem converting the given tag to a
|
/// The registry must have storage deletion enabled. Set the environment
|
||||||
/// manifest digest, or if there is a problem deleting the manifest from the
|
/// variable `REGISTRY_STORAGE_DELETE_ENABLED=true` on the registry container.
|
||||||
/// Docker Registry API.
|
/// If deletion is not enabled the registry returns `405 Method Not Allowed`
|
||||||
|
/// and this function returns [`ApiError::MethodNotAllowed`].
|
||||||
|
///
|
||||||
|
/// Only the manifest is removed. Unreferenced layer blobs remain on disk
|
||||||
|
/// until the registry garbage collector is run separately.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `_buf` — Unused output sink (reserved for future use).
|
||||||
|
/// * `registry_url` — Base URL of the Docker Registry.
|
||||||
|
/// * `image` — The repository name (e.g. `"myorg/backend"`).
|
||||||
|
/// * `tag` — The tag to delete (e.g. `"v1.0.0"`).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// * [`ApiError::HttpError`] — the HTTP client could not be constructed, or a
|
||||||
|
/// request failed at the transport layer.
|
||||||
|
/// * [`ApiError::UrlParseError`] — a manifest URL could not be constructed.
|
||||||
|
/// * [`ApiError::ResponseHeaderParseError`] — a response header contains
|
||||||
|
/// non-UTF-8 bytes.
|
||||||
|
/// * [`ApiError::UnexpectedResponse`] — the `docker-content-digest` header is
|
||||||
|
/// absent from the `HEAD` response, or a required version header is missing.
|
||||||
|
/// * [`ApiError::UnsupportedVersion`] — the registry version header has an
|
||||||
|
/// unexpected value.
|
||||||
|
/// * [`ApiError::AuthorizationFailed`] — the registry requires authentication.
|
||||||
|
/// * [`ApiError::NotFound`] — the image or tag does not exist in the registry.
|
||||||
|
/// * [`ApiError::MethodNotAllowed`] — the registry does not permit deletion;
|
||||||
|
/// ensure `REGISTRY_STORAGE_DELETE_ENABLED=true` is set on the registry.
|
||||||
#[allow(clippy::unused_async)]
|
#[allow(clippy::unused_async)]
|
||||||
pub async fn delete_handler(
|
pub async fn delete_handler(
|
||||||
_buf: &mut dyn Write,
|
_buf: &mut dyn Write,
|
||||||
@@ -169,7 +265,7 @@ pub async fn delete_handler(
|
|||||||
) -> 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})");
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
let client = api::build_client()?;
|
||||||
let url = registry_url.join(&format!("/v2/{image}/manifests/{tag}"))?;
|
let url = registry_url.join(&format!("/v2/{image}/manifests/{tag}"))?;
|
||||||
let digest = api::get_digest(&client, &url).await?;
|
let digest = api::get_digest(&client, &url).await?;
|
||||||
|
|
||||||
@@ -181,21 +277,39 @@ pub async fn delete_handler(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path to the Docker Registry APIs "api version check" endpoint.
|
/// Verify that the registry endpoint implements Docker Distribution API v2.
|
||||||
|
|
||||||
/// Handler for the API Version Check.
|
|
||||||
///
|
///
|
||||||
/// # Errors:
|
/// Sends a `GET` request to `/v2` and validates the response with
|
||||||
|
/// [`api::parse_response_status`]. On success writes `"Ok\n"` to `buf`.
|
||||||
///
|
///
|
||||||
/// Returns an `ApiError` if there is a problem communicating with the
|
/// # Arguments
|
||||||
/// endpoint or if the required version is not supported.
|
///
|
||||||
|
/// * `buf` — Output sink (typically stdout or a test buffer).
|
||||||
|
/// * `registry_url` — Base URL of the Docker Registry.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// * [`ApiError::HttpError`] — the HTTP client could not be constructed, or the
|
||||||
|
/// request failed at the transport layer.
|
||||||
|
/// * [`ApiError::UrlParseError`] — the `/v2` URL could not be constructed.
|
||||||
|
/// * [`ApiError::ResponseHeaderParseError`] — the version header contains
|
||||||
|
/// non-UTF-8 bytes.
|
||||||
|
/// * [`ApiError::UnexpectedResponse`] — the `Docker-Distribution-API-Version`
|
||||||
|
/// header is absent from the response.
|
||||||
|
/// * [`ApiError::UnsupportedVersion`] — the version header has a value other
|
||||||
|
/// than `"registry/2.0"`.
|
||||||
|
/// * [`ApiError::AuthorizationFailed`] — the registry requires authentication.
|
||||||
|
/// * [`ApiError::NotFound`] — the `/v2` endpoint does not exist.
|
||||||
|
/// * [`ApiError::MethodNotAllowed`] — the registry rejected the request method.
|
||||||
|
/// * [`ApiError::IOError`] — writing `"Ok\n"` to `buf` failed.
|
||||||
pub async fn check_handler(buf: &mut dyn Write, registry_url: &Url) -> Result<(), ApiError> {
|
pub async fn check_handler(buf: &mut dyn Write, registry_url: &Url) -> Result<(), ApiError> {
|
||||||
log::trace!("check_handler(registry_url: {registry_url:?})");
|
log::trace!("check_handler(registry_url: {registry_url:?})");
|
||||||
|
|
||||||
let path = "/v2";
|
let path = "/v2";
|
||||||
let url = registry_url.join(path)?;
|
let url = registry_url.join(path)?;
|
||||||
|
|
||||||
let resp = reqwest::get(url).await?;
|
let client = api::build_client()?;
|
||||||
|
let resp = client.get(url).send().await?;
|
||||||
api::parse_response_status(&resp)?;
|
api::parse_response_status(&resp)?;
|
||||||
writeln!(buf, "Ok")?;
|
writeln!(buf, "Ok")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -260,7 +374,7 @@ mod tests {
|
|||||||
.with_header("Docker-Distribution-API-Version", "registry/2.0")
|
.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"),
|
||||||
)
|
)
|
||||||
.with_body(r#"{"repositories": ["image1", "image2"]}"#)
|
.with_body(r#"{"repositories": ["image1", "image2"]}"#)
|
||||||
.create();
|
.create();
|
||||||
@@ -310,9 +424,9 @@ mod tests {
|
|||||||
mock_response.assert();
|
mock_response.assert();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate the pagination of the catalog handler.
|
/// Validate the pagination of the tags handler.
|
||||||
///
|
///
|
||||||
/// This test spins up a mock server, and makes a request to the catalog
|
/// This test spins up a mock server, and makes a request to the tags
|
||||||
/// endpoint. The response includes a pagination link, which the handler
|
/// endpoint. The response includes a pagination link, which the handler
|
||||||
/// should follow, resulting in the combined list. It checks that the
|
/// should follow, resulting in the combined list. It checks that the
|
||||||
/// handler both called the request the expected number of times, and did
|
/// handler both called the request the expected number of times, and did
|
||||||
@@ -332,7 +446,7 @@ mod tests {
|
|||||||
.with_header("Docker-Distribution-API-Version", "registry/2.0")
|
.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"),
|
||||||
)
|
)
|
||||||
.with_body(r#"{"tags": ["tag1", "tag2"]}"#)
|
.with_body(r#"{"tags": ["tag1", "tag2"]}"#)
|
||||||
.create();
|
.create();
|
||||||
@@ -381,7 +495,7 @@ mod tests {
|
|||||||
mock_response.assert();
|
mock_response.assert();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate the the check handler on invalid API version
|
/// Validate the check handler when the API version header is missing.
|
||||||
///
|
///
|
||||||
/// This validates that if the "Docker-Distribution-API-Version" header
|
/// This validates that if the "Docker-Distribution-API-Version" header
|
||||||
/// is missing in the response, the appropriate error is returned.
|
/// is missing in the response, the appropriate error is returned.
|
||||||
@@ -413,7 +527,7 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate the the check handler on invalid API version
|
/// Validate the check handler when the API version header has an unexpected value.
|
||||||
///
|
///
|
||||||
/// This validates that if the "Docker-Distribution-API-Version" header
|
/// This validates that if the "Docker-Distribution-API-Version" header
|
||||||
/// is present in the response but contains an unexpected value, the
|
/// is present in the response but contains an unexpected value, the
|
||||||
|
|||||||
+112
-10
@@ -12,62 +12,164 @@
|
|||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
/// The common error type for this Application.
|
/// The top-level error type for the `dredge` application.
|
||||||
|
///
|
||||||
|
/// Wraps lower-level errors from the registry API, URL parsing, I/O, and
|
||||||
|
/// the logging subsystem so they can all be returned from `main`.
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum DredgeError {
|
pub enum DredgeError {
|
||||||
/// An error communicating with the Registry API
|
/// An error returned by the Docker Registry API layer.
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
ApiError(#[from] ApiError),
|
ApiError(#[from] ApiError),
|
||||||
|
|
||||||
/// An error building the registry URL
|
/// The `<REGISTRY>` argument could not be parsed as a valid URL.
|
||||||
#[error("Error determining registry URL from {0}")]
|
#[error("Error determining registry URL from {0}")]
|
||||||
RegistryUrlError(String),
|
RegistryUrlError(String),
|
||||||
|
|
||||||
|
/// An I/O error writing output to stdout.
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
IOError(#[from] std::io::Error),
|
IOError(#[from] std::io::Error),
|
||||||
|
|
||||||
|
/// The logging subsystem could not be initialised.
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
LoggerError(#[from] log::SetLoggerError),
|
LoggerError(#[from] log::SetLoggerError),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An error related to the communication with the registry API.
|
/// Errors that can occur while communicating with the Docker Registry API.
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum ApiError {
|
pub enum ApiError {
|
||||||
/// Error parsing a URL
|
/// A URL could not be constructed or parsed.
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
UrlParseError(#[from] url::ParseError),
|
UrlParseError(#[from] url::ParseError),
|
||||||
|
|
||||||
/// Error in HTTP Request
|
/// An HTTP transport-level error (connection refused, TLS failure, timeout,
|
||||||
|
/// etc.) or a response body that could not be decoded.
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
HttpError(#[from] reqwest::Error),
|
HttpError(#[from] reqwest::Error),
|
||||||
|
|
||||||
#[error("Failed to parse response headers")]
|
/// A response header contained bytes that could not be decoded as UTF-8.
|
||||||
ResponseHeaderParseError(Box<dyn std::error::Error>),
|
#[error("Failed to parse response header: {0}")]
|
||||||
|
ResponseHeaderParseError(String),
|
||||||
|
|
||||||
|
/// The registry returned a `Docker-Distribution-API-Version` header value
|
||||||
|
/// other than `"registry/2.0"`. The inner `String` holds the actual value.
|
||||||
#[error("Version Mismatch {0}")]
|
#[error("Version Mismatch {0}")]
|
||||||
UnsupportedVersion(String),
|
UnsupportedVersion(String),
|
||||||
|
|
||||||
|
/// The registry returned a response that did not match the expected API
|
||||||
|
/// contract (e.g. a required header was absent). The inner `String`
|
||||||
|
/// describes the specific problem.
|
||||||
#[error("Unexpected response from API: {0}")]
|
#[error("Unexpected response from API: {0}")]
|
||||||
UnexpectedResponse(String),
|
UnexpectedResponse(String),
|
||||||
|
|
||||||
|
/// The registry returned `401 Unauthorized`. Authentication is not
|
||||||
|
/// currently supported; the request cannot be retried automatically.
|
||||||
#[error("HTTP Authorization failed")]
|
#[error("HTTP Authorization failed")]
|
||||||
AuthorizationFailed,
|
AuthorizationFailed,
|
||||||
|
|
||||||
|
/// The requested resource does not exist in the registry (`404 Not Found`).
|
||||||
#[error("Resource not found")]
|
#[error("Resource not found")]
|
||||||
NotFound,
|
NotFound,
|
||||||
|
|
||||||
|
/// An I/O error writing serialized output to the output buffer.
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
IOError(#[from] std::io::Error),
|
IOError(#[from] std::io::Error),
|
||||||
|
|
||||||
|
/// The manifest response body could not be serialized to YAML for output.
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
SerializerError(#[from] serde_yaml::Error),
|
SerializerError(#[from] serde_yml::Error),
|
||||||
|
|
||||||
|
/// The registry returned `405 Method Not Allowed`, typically because
|
||||||
|
/// storage deletion has not been enabled on the registry.
|
||||||
#[error("Method not allowed")]
|
#[error("Method not allowed")]
|
||||||
MethodNotAllowed,
|
MethodNotAllowed,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<reqwest::header::ToStrError> for ApiError {
|
impl From<reqwest::header::ToStrError> for ApiError {
|
||||||
fn from(other: reqwest::header::ToStrError) -> Self {
|
fn from(other: reqwest::header::ToStrError) -> Self {
|
||||||
Self::ResponseHeaderParseError(Box::from(other))
|
Self::ResponseHeaderParseError(other.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Test that `DredgeError::from(ApiError::NotFound)` works via the `From` impl.
|
||||||
|
#[test]
|
||||||
|
fn test_dredge_error_from_api_error_not_found() {
|
||||||
|
let api_err = ApiError::NotFound;
|
||||||
|
let dredge_err = DredgeError::from(api_err);
|
||||||
|
assert!(matches!(dredge_err, DredgeError::ApiError(ApiError::NotFound)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that `DredgeError::from(ApiError::AuthorizationFailed)` works.
|
||||||
|
#[test]
|
||||||
|
fn test_dredge_error_from_api_error_authorization_failed() {
|
||||||
|
let api_err = ApiError::AuthorizationFailed;
|
||||||
|
let dredge_err = DredgeError::from(api_err);
|
||||||
|
assert!(matches!(
|
||||||
|
dredge_err,
|
||||||
|
DredgeError::ApiError(ApiError::AuthorizationFailed)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that `DredgeError::from(ApiError::MethodNotAllowed)` works.
|
||||||
|
#[test]
|
||||||
|
fn test_dredge_error_from_api_error_method_not_allowed() {
|
||||||
|
let api_err = ApiError::MethodNotAllowed;
|
||||||
|
let dredge_err = DredgeError::from(api_err);
|
||||||
|
assert!(matches!(
|
||||||
|
dredge_err,
|
||||||
|
DredgeError::ApiError(ApiError::MethodNotAllowed)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test Display output for `ApiError::NotFound`.
|
||||||
|
#[test]
|
||||||
|
fn test_api_error_not_found_display() {
|
||||||
|
let err = ApiError::NotFound;
|
||||||
|
assert_eq!(err.to_string(), "Resource not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test Display output for `ApiError::AuthorizationFailed`.
|
||||||
|
#[test]
|
||||||
|
fn test_api_error_authorization_failed_display() {
|
||||||
|
let err = ApiError::AuthorizationFailed;
|
||||||
|
assert_eq!(err.to_string(), "HTTP Authorization failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test Display output for `ApiError::MethodNotAllowed`.
|
||||||
|
#[test]
|
||||||
|
fn test_api_error_method_not_allowed_display() {
|
||||||
|
let err = ApiError::MethodNotAllowed;
|
||||||
|
assert_eq!(err.to_string(), "Method not allowed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test Display output for `ApiError::UnsupportedVersion`.
|
||||||
|
#[test]
|
||||||
|
fn test_api_error_unsupported_version_display() {
|
||||||
|
let err = ApiError::UnsupportedVersion(String::from("registry/1.0"));
|
||||||
|
assert_eq!(err.to_string(), "Version Mismatch registry/1.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test Display output for `ApiError::UnexpectedResponse`.
|
||||||
|
#[test]
|
||||||
|
fn test_api_error_unexpected_response_display() {
|
||||||
|
let err = ApiError::UnexpectedResponse(String::from("Missing header"));
|
||||||
|
assert_eq!(
|
||||||
|
err.to_string(),
|
||||||
|
"Unexpected response from API: Missing header"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test Display output for `DredgeError::RegistryUrlError`.
|
||||||
|
#[test]
|
||||||
|
fn test_dredge_error_registry_url_error_display() {
|
||||||
|
let err = DredgeError::RegistryUrlError(String::from("bad-url"));
|
||||||
|
assert_eq!(
|
||||||
|
err.to_string(),
|
||||||
|
"Error determining registry URL from bad-url"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-12
@@ -24,30 +24,45 @@ pub(crate) mod cli;
|
|||||||
mod commands;
|
mod commands;
|
||||||
mod error;
|
mod error;
|
||||||
|
|
||||||
/// Name of "latest" tag
|
/// The default image tag used when no tag is specified by the caller.
|
||||||
const LATEST: &str = "latest";
|
const LATEST: &str = "latest";
|
||||||
|
|
||||||
/// Parse the "<REGISTRY>" argument into a complete Docker Registry URL.
|
/// Parse the `<REGISTRY>` CLI argument into a complete Docker Registry [`Url`].
|
||||||
///
|
///
|
||||||
/// This prepends the HTTPS scheme and converts the given string to a `Url`
|
/// Accepts a bare hostname (`registry.example.com`), a host-and-port pair
|
||||||
/// instance.
|
/// (`registry.example.com:5000`), or a full URL
|
||||||
|
/// (`https://registry.example.com:5000`). When no URL scheme is present,
|
||||||
|
/// `https://` is prepended automatically before parsing.
|
||||||
///
|
///
|
||||||
/// If the given `host` value is already a valid URL, then it will be returned
|
/// # Errors
|
||||||
/// as-is.
|
|
||||||
///
|
///
|
||||||
/// # Errors:
|
/// Returns [`DredgeError::RegistryUrlError`] containing the attempted URL
|
||||||
|
/// string if it cannot be parsed as a valid URL after the scheme is prepended.
|
||||||
///
|
///
|
||||||
/// If there is a problem parsing the resulting string as a valid URL, a
|
/// # Examples
|
||||||
/// `DredgeError::RegistryUrlError` will be returned.
|
///
|
||||||
|
/// ```rust,ignore
|
||||||
|
/// // Bare hostname — HTTPS is assumed
|
||||||
|
/// let url = parse_registry_arg("registry.example.com").unwrap();
|
||||||
|
/// assert_eq!(url.scheme(), "https");
|
||||||
|
///
|
||||||
|
/// // Host with port
|
||||||
|
/// let url = parse_registry_arg("registry.example.com:5000").unwrap();
|
||||||
|
/// assert_eq!(url.port(), Some(5000));
|
||||||
|
///
|
||||||
|
/// // Full URL returned as-is
|
||||||
|
/// let url = parse_registry_arg("https://registry.example.com").unwrap();
|
||||||
|
/// assert_eq!(url.as_str(), "https://registry.example.com/");
|
||||||
|
/// ```
|
||||||
fn parse_registry_arg(host: &str) -> Result<Url, DredgeError> {
|
fn parse_registry_arg(host: &str) -> Result<Url, DredgeError> {
|
||||||
log::trace!("make_registry_url(host: {host})");
|
log::trace!("parse_registry_arg(host: {host})");
|
||||||
|
|
||||||
let mut host = String::from(host);
|
let mut host = String::from(host);
|
||||||
if !host.starts_with("http://") && !host.starts_with("https://") {
|
if !host.starts_with("http://") && !host.starts_with("https://") {
|
||||||
host = format!("https://{host}");
|
host = format!("https://{host}");
|
||||||
}
|
}
|
||||||
|
|
||||||
Url::parse(&host).or(Err(DredgeError::RegistryUrlError(host.to_string())))
|
Url::parse(&host).or(Err(DredgeError::RegistryUrlError(host.clone())))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main(flavor = "current_thread")]
|
#[tokio::main(flavor = "current_thread")]
|
||||||
@@ -76,7 +91,7 @@ async fn main() -> Result<(), DredgeError> {
|
|||||||
&mut buf,
|
&mut buf,
|
||||||
®istry_url,
|
®istry_url,
|
||||||
&image,
|
&image,
|
||||||
&tag.unwrap_or(LATEST.to_string()),
|
tag.as_deref().unwrap_or(LATEST),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
@@ -156,4 +171,31 @@ mod tests {
|
|||||||
_ => panic!("Expected RegistryUrlError, got a different error"),
|
_ => panic!("Expected RegistryUrlError, got a different error"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test that an HTTP (non-HTTPS) URL is returned as-is without prepending
|
||||||
|
/// the HTTPS scheme.
|
||||||
|
#[test]
|
||||||
|
fn test_parse_registry_arg_http_url() {
|
||||||
|
let host = "http://example.com/registry";
|
||||||
|
let result = parse_registry_arg(host);
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let url = result.unwrap();
|
||||||
|
assert_eq!(url.scheme(), "http");
|
||||||
|
assert_eq!(url.host_str(), Some("example.com"));
|
||||||
|
assert_eq!(url.path(), "/registry");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that a trailing slash in the registry argument is preserved.
|
||||||
|
#[test]
|
||||||
|
fn test_parse_registry_arg_trailing_slash() {
|
||||||
|
let host = "example.com/registry/";
|
||||||
|
let result = parse_registry_arg(host);
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let url = result.unwrap();
|
||||||
|
assert_eq!(url.scheme(), "https");
|
||||||
|
assert_eq!(url.host_str(), Some("example.com"));
|
||||||
|
assert_eq!(url.path(), "/registry/");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user