8 Commits

Author SHA1 Message Date
Anthony Oteri de42860be8 Release version 0.2.0 2023-09-29 18:09:46 -04:00
Anthony Oteri b4d6002a20 Merge pull request #46 from anthonyoteri/support-delete
Support delete command
2023-09-29 18:08:40 -04:00
Anthony Oteri fbe43f03f1 Support deleting an image tag
If the Docker Registry API allows for images to be deleted, issuing the
command `delete <image> <tag>` will result in the tag being removed from
the server.

A limitation of this change is that it will cause any unreferenced tags
to become orphaned, requiring the garbage collector within the docker
registry server to clean these up.  This is because there is no way to
efficiently determine if each of the layers are reused by other tags or
images on the server.

Implement delete logic

Fix broken unit tests
2023-09-29 18:04:08 -04:00
Anthony Oteri 48070cff1f Merge pull request #45 from anthonyoteri/replace-femme-with-simple-logger
Replace Femme with SimpleLogger
2023-09-29 15:13:45 -04:00
Anthony Oteri 13ae092b91 Replace femme logger with simple_logger 2023-09-29 15:05:23 -04:00
Anthony Oteri b0239fb049 Fix script to update release notes 2023-09-29 14:53:35 -04:00
Anthony Oteri ed84e92112 Release version 0.1.0 2023-09-29 14:34:34 -04:00
Anthony Oteri cfdefb287c Additional scripts for managing the release process 2023-09-29 14:29:35 -04:00
13 changed files with 286 additions and 18 deletions
+4 -3
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "dredge" name = "dredge"
version = "0.1.0" version = "0.2.0"
edition = "2021" edition = "2021"
authors = ["Anthony Oteri"] authors = ["Anthony Oteri"]
description = "A Command Line tool for interracting with the Docker Registry API" description = "A Command Line tool for interracting with the Docker Registry API"
@@ -23,7 +23,7 @@ categories = [
[dependencies] [dependencies]
async-std = { version = "1.12.0", features = ["async-attributes", "attributes", "tokio1"] } async-std = { version = "1.12.0", features = ["async-attributes", "attributes", "tokio1"] }
clap = { version = "4.4.3", features = ["derive", "env", "wrap_help"] } clap = { version = "4.4.3", features = ["derive", "env", "wrap_help"] }
femme = "2.2.1" simple_logger = { version = "4.2.0", features = ["timestamps", "colors", "stderr"] }
http = "0.2.9" http = "0.2.9"
indoc = "2.0.4" indoc = "2.0.4"
log = "0.4.20" log = "0.4.20"
@@ -37,4 +37,5 @@ url = { version = "2.4.1", features = ["serde"] }
xdg = "2.5.2" xdg = "2.5.2"
[dev-dependencies] [dev-dependencies]
mockito = "1.2.0" mockito = "1.2.0"
env_logger = "0.10.0"
+10
View File
@@ -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>
+19
View File
@@ -0,0 +1,19 @@
# Dredge Release Notes
## Known Issues
* The delete command is currently not implemented and will return an error
if called.
* Docker authentication is not currently supported, and attempts to query a
registry which requires authentication will fail.
## Changelog
- v0.2.0
- Support deleting an image tag by Anthony Oteri fbe43f0
- Replace femme logger with simple_logger by Anthony Oteri 13ae092
- v0.1.0
- Additional scripts for managing the release process by Anthony Oteri cfdefb2
+3
View File
@@ -0,0 +1,3 @@
- v0.1.0
- Additional scripts for managing the release process by Anthony Oteri cfdefb2
+4
View File
@@ -0,0 +1,4 @@
- v0.2.0
- Support deleting an image tag by Anthony Oteri fbe43f0
- Replace femme logger with simple_logger by Anthony Oteri 13ae092
+10
View File
@@ -0,0 +1,10 @@
# Dredge Release Notes
## Known Issues
* The delete command is currently not implemented and will return an error
if called.
* Docker authentication is not currently supported, and attempts to query a
registry which requires authentication will fail.
## Changelog
+37
View File
@@ -0,0 +1,37 @@
#
# Copyright 2023 Anthony Oteri
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
#!/usr/bin/bash
set -e
REPO_ROOT=$(git rev-parse --show-toplevel)
version=$1
if [ -z "$1" ]; then
echo "Usage $0 <version> [from tag]"
exit 1
fi
previous=${2:-$(git describe --abbrev=0 --match='v*')}
changelog="${REPO_ROOT}/docs/changelog-${previous}-${version}.md"
echo "- ${version}" | tee ${changelog}
echo "" | tee -a ${changelog}
git log --pretty=format:' - %s by %an %h' --no-merges ${previous}.. | tee -a ${changelog}
+45
View File
@@ -0,0 +1,45 @@
#
# Copyright 2023 Anthony Oteri
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
#!/usr/bin/bash -e
version=$1
previous=$2
if [ -z "$1" ]; then
echo "Usage $0 <version> [previous]"
exit 1
fi
REPO_ROOT=$(git rev-parse --show-toplevel)
SCRIPTS="${REPO_ROOT}/scripts"
${SCRIPTS}/generate-changelog.sh "v${version}" "${previous}" && \
${SCRIPTS}/update-release-notes.sh && \
git add "${REPO_ROOT}/docs" "${REPO_ROOT}/RELEASE_NOTES.md"
sed -i "s/^version = \".*\"/version = \"${version}\"/" \
"${REPO_ROOT}/Cargo.toml" && git add "${REPO_ROOT}/Cargo.toml"
echo "*************************************************************************"
echo "Release is ready, please use the following to commit changes"
echo
echo "git commit -am \"Release version ${version}\" && \ "
echo " git tag -a -m v${version} v${version}"
echo
echo "*************************************************************************"
+28
View File
@@ -0,0 +1,28 @@
#
# Copyright 2023 Anthony Oteri
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
#!/usr/bin/bash
REPO_ROOT=$(git rev-parse --show-toplevel)
RELEASE_NOTES="${REPO_ROOT}/RELEASE_NOTES.md"
/usr/bin/cat "${REPO_ROOT}/docs/release-notes-template.md" | tee "${RELEASE_NOTES}"
for note in $(/usr/bin/find "${REPO_ROOT}/docs" -name "changelog*.md" -print | sort -rn); do
/usr/bin/cat "${note}" | tee -a "${RELEASE_NOTES}"
echo "" | tee -a "${RELEASE_NOTES}"
echo "" | tee -a "${RELEASE_NOTES}"
done
+85 -5
View File
@@ -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
View File
@@ -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, &registry_url).await; let result = catalog_handler(&mut buf, &registry_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, &registry_url).await; let result = catalog_handler(&mut buf, &registry_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, &registry_url, "some_image").await; let result = tags_handler(&mut buf, &registry_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, &registry_url, "some_image").await; let result = tags_handler(&mut buf, &registry_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, &registry_url).await; let result = check_handler(&mut buf, &registry_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();
+6
View File
@@ -32,6 +32,9 @@ pub enum DredgeError {
#[error(transparent)] #[error(transparent)]
IOError(#[from] std::io::Error), IOError(#[from] std::io::Error),
#[error(transparent)]
LoggerError(#[from] log::SetLoggerError),
} }
/// An error related to the communication with the registry API. /// An error related to the communication with the registry API.
@@ -65,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 {
+7 -1
View File
@@ -19,6 +19,7 @@
use std::io::{self, Write}; use std::io::{self, Write};
use clap::Parser; use clap::Parser;
use simple_logger::SimpleLogger;
use url::Url; use url::Url;
use crate::cli::Cli; use crate::cli::Cli;
@@ -62,7 +63,12 @@ async fn main() -> Result<(), DredgeError> {
// -- Initialize logging // -- Initialize logging
let log_level = args.log_level; let log_level = args.log_level;
femme::with_level(log::LevelFilter::from(log_level)); SimpleLogger::new()
.with_colors(true)
.with_utc_timestamps()
.with_level(log_level.into())
.env()
.init()?;
// -- Parse the given <REGISTRY> argument into a complete URL // -- Parse the given <REGISTRY> argument into a complete URL
let registry_url: Url = parse_registry_arg(&args.registry)?; let registry_url: Url = parse_registry_arg(&args.registry)?;