1 Commits

Author SHA1 Message Date
dependabot[bot] aa315b45ce Update thiserror requirement from 1.0.48 to 2.0.11
Updates the requirements on [thiserror](https://github.com/dtolnay/thiserror) to permit the latest version.
- [Release notes](https://github.com/dtolnay/thiserror/releases)
- [Commits](https://github.com/dtolnay/thiserror/compare/1.0.48...2.0.11)

---
updated-dependencies:
- dependency-name: thiserror
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-10 18:29:31 +00:00
17 changed files with 539 additions and 1615 deletions
-18
View File
@@ -1,18 +0,0 @@
## Summary
<!-- Briefly describe what this PR does and why. -->
## Checklist
- [ ] Commits follow the [Conventional Commits](https://www.conventionalcommits.org/) spec
- `feat:` new feature
- `fix:` bug fix
- `docs:` documentation only
- `chore:` maintenance / tooling
- `ci:` CI/CD changes
- `refactor:` code change that neither fixes a bug nor adds a feature
- `test:` adding or correcting tests
- `perf:` performance improvement
- [ ] `cargo test` passes locally
- [ ] `cargo clippy --all-targets -- -W clippy::pedantic` passes with no warnings
- [ ] `cargo fmt --check` passes
+2 -16
View File
@@ -3,23 +3,9 @@ updates:
- package-ecosystem: "cargo" - package-ecosystem: "cargo"
directory: "/" directory: "/"
schedule: schedule:
interval: "weekly" interval: "daily"
groups:
patch-updates:
update-types:
- "patch"
minor-updates:
update-types:
- "minor"
- package-ecosystem: "github-actions" - package-ecosystem: "github-actions"
directory: "/" directory: "/"
schedule: schedule:
interval: "weekly" interval: "daily"
groups:
patch-updates:
update-types:
- "patch"
minor-updates:
update-types:
- "minor"
-101
View File
@@ -1,101 +0,0 @@
name: CI
on:
push:
branches: ["master"]
pull_request:
workflow_call:
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"
jobs:
test:
name: Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- name: Install stable toolchain
uses: dtolnay/rust-toolchain@stable
- name: Build
run: cargo build --all-targets
- name: Run tests
run: cargo test
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install stable toolchain with clippy and rustfmt
uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Cache dependencies
uses: Swatinem/rust-cache@v2
- name: Check formatting
run: cargo fmt --check --all
- name: Clippy
run: cargo clippy --all-targets -- -W clippy::pedantic
commits:
name: Conventional commits
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Full history is required for cog check to validate all commits.
fetch-depth: 0
# On PRs check the PR HEAD, not the merge commit.
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Check conventional commits
uses: cocogitto/cocogitto-action@v4
with:
command: check
# Only check commits since conventional commits were adopted.
# 9b602f7 is the last commit before the first conventional commit.
args: 9b602f70a60e5651771ae40a934a7d417d9e1214..HEAD
deny:
name: Cargo deny
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check licenses, advisories, and bans
uses: EmbarkStudios/cargo-deny-action@v2
msrv:
name: MSRV (1.88)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust 1.88
uses: dtolnay/rust-toolchain@1.100
- name: Cache dependencies
uses: Swatinem/rust-cache@v2
- name: Check MSRV builds
run: cargo check
+80 -114
View File
@@ -3,147 +3,113 @@ name: Release
on: on:
push: push:
tags: tags:
- "v[0-9]*.[0-9]*.[0-9]*" - "v[0-9]+.[0-9]+.[0-9]+*"
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"
jobs: jobs:
# --------------------------------------------------------------------------- create_release:
# Gate: all CI checks must pass before we publish anything. name: Create release
# ---------------------------------------------------------------------------
ci:
name: CI checks
uses: ./.github/workflows/ci.yml
# ---------------------------------------------------------------------------
# Publish to crates.io and create a GitHub release.
# ---------------------------------------------------------------------------
publish:
name: Publish
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: ci
permissions: permissions:
contents: write contents: write
outputs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
steps: steps:
- uses: actions/checkout@v4 - name: Create release
with: id: create_release
fetch-depth: 0 uses: ncipollo/release-action@v1
- name: Install stable toolchain build-docs:
name: Build documentation
needs: create_release
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust Toolchain
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
components: rust-docs
- name: Cache dependencies - name: Cache build dependencies
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
- name: Install cocogitto - name: Run cargo doc
uses: cocogitto/cocogitto-action@v3 run:
cargo doc
--bin=dredge
--no-deps
--all-features
--document-private-items
--release
- name: Archive the Docs
run:
tar --directory target/doc -czf
dredge-${{ github.ref_name}}-docs.tar.gz
dredge
- name: Upload documentation assets
uses: shogo82148/actions-upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
check: false upload_url: ${{ needs.create_release.outputs.upload_url }}
asset_name: dredge-${{ github.ref_name }}-docs.tar.gz
asset_path: dredge-${{ github.ref_name }}-docs.tar.gz
asset_content_type: application/gzip
- name: Generate release notes for this tag release_assets:
id: changelog name: Release assets
run: | needs: create_release
VERSION="${GITHUB_REF_NAME#v}" runs-on: ${{ matrix.config.os }}
# Write release notes outside the repo so cargo publish does not
# see an untracked file and refuse to run.
cog changelog --at "$GITHUB_REF_NAME" > /tmp/release_notes.md || \
echo "No structured changelog available for $GITHUB_REF_NAME." > /tmp/release_notes.md
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Publish dredge-tool to crates.io
run: cargo publish
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
- name: Create GitHub release
run: |
gh release create "$GITHUB_REF_NAME" \
--title "$GITHUB_REF_NAME" \
--notes-file /tmp/release_notes.md
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# ---------------------------------------------------------------------------
# Build release binaries and attach them to the GitHub release.
# Runs in parallel across platforms after the release is created.
# ---------------------------------------------------------------------------
build-binaries:
name: Build binary (${{ matrix.upload_name }})
needs: publish
runs-on: ${{ matrix.os }}
permissions: permissions:
contents: write contents: write
strategy: strategy:
fail-fast: false
matrix: matrix:
include: config:
- os: ubuntu-latest - os: ubuntu-latest
artifact: dredge platform: linux
upload_name: dredge-linux-x86_64.tar.gz arch: x86_64
target: x86_64-unknown-linux-musl ext: ''
artifact_dir: target/x86_64-unknown-linux-musl/release
- os: macos-latest - os: macos-latest
artifact: dredge platform: macos
upload_name: dredge-macos-aarch64.tar.gz arch: x86_64
target: "" ext: ''
artifact_dir: target/release
- os: macos-latest
artifact: dredge
upload_name: dredge-macos-x86_64.tar.gz
target: x86_64-apple-darwin
artifact_dir: target/x86_64-apple-darwin/release
- os: windows-latest - os: windows-latest
artifact: dredge.exe platform: win
upload_name: dredge-windows-x86_64.exe arch: x86_64
target: "" ext: .exe
artifact_dir: target/release
steps: steps:
- uses: actions/checkout@v4 - name: Checkout code
uses: actions/checkout@v4
- name: Install stable toolchain - name: Install Rust Toolchain
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
with: with:
targets: ${{ matrix.target }} toolchain: stable
- name: Install musl tools (Linux only) - name: Cache build dependencies
if: matrix.target == 'x86_64-unknown-linux-musl'
run: sudo apt-get install -y musl-tools
- name: Cache dependencies
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
- name: Build release binary - name: Run cargo build
run: > run: cargo build --release
cargo build --release
${{ matrix.target != '' && format('--target {0}', matrix.target) || '' }}
- name: Strip binary (Unix only) - name: Create release assets
if: runner.os != 'Windows' run:
run: strip ${{ matrix.artifact_dir }}/${{ matrix.artifact }} tar --directory target/release -czf
dredge-${{ github.ref_name }}-${{ matrix.config.platform }}.${{ matrix.config.arch }}.tar.gz
dredge${{ matrix.config.ext }}
- name: Ad-hoc sign binary (macOS only) - name: Upload release assets
if: runner.os == 'macOS' uses: shogo82148/actions-upload-release-asset@v1
run: codesign --sign - --force --preserve-metadata=entitlements,requirements,flags,runtime ${{ matrix.artifact_dir }}/${{ matrix.artifact }}
- name: Package binary (Unix)
if: runner.os != 'Windows'
shell: bash
run: tar -czf "${{ matrix.upload_name }}" -C "${{ matrix.artifact_dir }}" "${{ matrix.artifact }}"
- name: Copy binary (Windows)
if: runner.os == 'Windows'
shell: bash
run: cp "${{ matrix.artifact_dir }}/${{ matrix.artifact }}" "${{ matrix.upload_name }}"
- name: Upload to GitHub release
run: gh release upload "${{ github.ref_name }}" "${{ matrix.upload_name }}" --clobber
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create_release.outputs.upload_url }}
asset_name: dredge-${{ github.ref_name }}-${{ matrix.config.platform }}.${{ matrix.config.arch }}.tar.gz
asset_path: dredge-${{ github.ref_name }}-${{ matrix.config.platform }}.${{ matrix.config.arch }}.tar.gz
asset_content_type: application/gzip
+51
View File
@@ -0,0 +1,51 @@
name: Rust Clippy Analysis
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
schedule:
- cron: "18 21 * * 6"
env:
CARGO_TERM_COLOR: always
jobs:
rust-clippy:
name: Run rust-clippy analysis
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
actions: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust Toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
components: clippy
- name: Cache build dependencies
uses: Swatinem/rust-cache@v2
- name: Install clippy-sarif
run: cargo install clippy-sarif sarif-fmt
- name: Run rust-clippy
run:
cargo clippy
--all-features
--tests
--message-format=json
| clippy-sarif | tee rust-clippy-results.sarif | sarif-fmt
continue-on-error: true
- name: Upload analysis results ot GitHub
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: rust-clippy-results.sarif
wait-for-processing: true
+56
View File
@@ -0,0 +1,56 @@
name: Rust
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
env:
CARGO_TERM_COLOR: always
jobs:
rust-fmt-check:
name: Run Rust-fmt check
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust Toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
components: rustfmt
- name: Cache build dependencies
uses: Swatinem/rust-cache@v2
- name: Run rust-fmt --check
run: cargo fmt --check --all
test:
name: Run unit and integration tests
strategy:
matrix:
platform: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.platform }}
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust Toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
components: rustfmt
- name: Cache build dependencies
uses: Swatinem/rust-cache@v2
- name: Run cargo-test
run: cargo test
-28
View File
@@ -1,28 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
This changelog is generated by [cocogitto](https://github.com/oknozor/cocogitto).
- - -
## [1.2.0](https://github.com/anthonyoteri/dredge/compare/v1.1.0..v1.2.0) - 2026-05-13
#### Bug Fixes
- upgrade cocogitto-action to v4 and fix commit range syntax - ([0d51c50](https://github.com/anthonyoteri/dredge/commit/0d51c5034fbd11e7cc3c74498adac3f47f0eec4f)) - [@anthonyoteri](https://github.com/anthonyoteri)
- resolve remaining CI check failures - ([d37ca2d](https://github.com/anthonyoteri/dredge/commit/d37ca2de44c5fee2a76acc6134c73db31e106681)) - [@anthonyoteri](https://github.com/anthonyoteri)
- resolve CI check failures - ([f55c72a](https://github.com/anthonyoteri/dredge/commit/f55c72aa34369786b20917d6ddc4c33529e21d93)) - [@anthonyoteri](https://github.com/anthonyoteri)
#### CI
- overhaul CI/CD pipeline and add tooling configs - ([5291053](https://github.com/anthonyoteri/dredge/commit/5291053)) - [@anthonyoteri](https://github.com/anthonyoteri)
#### Documentation
- rewrite README with comprehensive usage examples and installation guide - ([01ef25b](https://github.com/anthonyoteri/dredge/commit/01ef25b9533448492869976f046f4ff5f5a82dc7)) - [@anthonyoteri](https://github.com/anthonyoteri)
#### Refactoring
- simplify codebase and fix correctness issues - ([d2d51b3](https://github.com/anthonyoteri/dredge/commit/d2d51b3a2d1b70860084fdd68aba5dc7df464274)) - [@anthonyoteri](https://github.com/anthonyoteri)
#### Miscellaneous
- update all dependencies to latest versions - ([353fd94](https://github.com/anthonyoteri/dredge/commit/353fd94b55ded441a9dd4b86f8fd870eaf6a96c3)) - [@anthonyoteri](https://github.com/anthonyoteri)
- - -
+19 -16
View File
@@ -1,12 +1,12 @@
[package] [package]
name = "dredge-tool" name = "dredge-tool"
version = "1.2.0" version = "1.1.0"
edition = "2021" edition = "2021"
authors = ["Anthony Oteri"] authors = ["Anthony Oteri"]
description = "A command line tool for interacting with the Docker Registry API" description = "A Command Line tool for interracting with the Docker Registry API"
readme = "README.md" readme = "README.md"
repository = "https://github.com/anthonyoteri/dredge" repository = "https://github.com/anthonyoteri/dredge"
rust-version = "1.88" rust-version = "1.72"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
keywords = [ keywords = [
"docker", "docker",
@@ -26,18 +26,21 @@ name = "dredge"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
clap = { version = "4.6", features = ["derive", "env", "wrap_help"] } clap = { version = "4.4.3", features = ["derive", "env", "wrap_help"] }
simple_logger = { version = "5.2", features = ["timestamps", "colors", "stderr"] } simple_logger = { version = "5.0.0", features = ["timestamps", "colors", "stderr"] }
http = "1.4" http = "1.0.0"
indoc = "2.0" indoc = "2.0.4"
log = "0.4" log = "0.4.20"
reqwest = { version = "0.12", features = ["json", "gzip", "multipart", "native-tls-vendored"] } reqwest = { version = "0.12.3", features = ["json", "gzip", "multipart", "native-tls-vendored"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0.188", features = ["derive"] }
thiserror = "2.0" serde_toml = "0.0.1"
url = { version = "2.5", features = ["serde"] } serde_yaml = "0.9.25"
tokio = { version = "1.52", features = ["macros"] } thiserror = "2.0.11"
serde_norway = "0.9.42" toml = "0.8.0"
url = { version = "2.4.1", features = ["serde"] }
xdg = "2.5.2"
tokio = { version = "1.32.0", features = ["macros"] }
[dev-dependencies] [dev-dependencies]
mockito = "1.7" mockito = "1.2.0"
env_logger = "0.11" env_logger = "0.11.3"
-168
View File
@@ -1,168 +0,0 @@
# Justfile for dredge
# Install just: https://just.systems
# Default: list available recipes
default:
@just --list
# Run the full test suite
test:
cargo test
# Check formatting and lints (mirrors CI)
check:
cargo fmt --check
cargo clippy --all-targets -- -W clippy::pedantic
# Auto-format the source
fmt:
cargo fmt
# Validate conventional commit history
commits:
cog check
# ---------------------------------------------------------------------------
# Release
# ---------------------------------------------------------------------------
#
# Two-step process to work with branch protection on master:
#
# Step 1 — just release
# Runs pre-flight checks, calls `cog bump --auto` on a local release
# branch, opens a PR. Review and merge the PR normally.
#
# Step 2 — just push-tag
# After the PR is merged, pulls master, tags the merge commit with the
# version from Cargo.toml, and pushes the tag.
# The tag push triggers release.yml which publishes to crates.io.
#
# Prerequisites:
# - `cocogitto` installed: cargo install cocogitto
# - `gh` CLI installed and authenticated: https://cli.github.com
# ---------------------------------------------------------------------------
# Step 1: open a release PR.
#
# Runs pre-flight checks, bumps the version on a release branch,
# generates CHANGELOG.md, and opens a pull request against master.
# After the PR is merged, run `just push-tag` to trigger the publish.
release:
#!/usr/bin/env bash
set -euo pipefail
# Guard: must be on master.
branch=$(git rev-parse --abbrev-ref HEAD)
if [[ "$branch" != "master" ]]; then
echo "error: releases must be cut from master (currently on '$branch')" >&2
exit 1
fi
# Guard: working tree must be clean.
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "error: working tree has uncommitted changes; commit or stash them first" >&2
exit 1
fi
# Guard: local master must not be behind origin.
git fetch --quiet origin master
if [[ $(git rev-list --count HEAD..origin/master) -gt 0 ]]; then
echo "error: local master is behind origin/master; run 'git pull' first" >&2
exit 1
fi
# Run the full test suite before touching anything.
echo "==> Running tests..."
cargo test
# Check formatting and lints.
echo "==> Checking formatting and lints..."
cargo fmt --check
cargo clippy --all-targets -- -W clippy::pedantic
# Determine the next version without making any changes yet.
# cog bump --auto --dry-run prints e.g. "v0.1.0" to stdout.
echo "==> Determining next version..."
next_version=$(cog bump --auto --dry-run)
echo " Next version: ${next_version}"
# Create and switch to a release branch.
release_branch="release/${next_version}"
git checkout -b "${release_branch}"
# Bump version, generate CHANGELOG.md, commit, and create the local tag.
# cog bump --auto:
# - Updates the version field in Cargo.toml
# - Writes CHANGELOG.md
# - Creates a commit "chore(version): bump to vX.Y.Z"
# - Creates an annotated tag vX.Y.Z (local only until push-tag)
echo "==> Bumping version with cog..."
cog bump --auto
# Push the release branch (not the tag — that comes after PR merge).
echo "==> Pushing release branch..."
git push -u origin "${release_branch}"
# Open the pull request.
echo "==> Opening pull request..."
gh pr create \
--title "chore(release): ${next_version}" \
--body "$(cat CHANGELOG.md)" \
--base master \
--head "${release_branch}"
echo ""
echo "==> Release PR opened."
echo " Review and merge the PR, then run:"
echo ""
echo " just push-tag"
echo ""
echo " to push the tag and trigger the crates.io publish."
# Step 2: tag master HEAD and push the tag.
#
# Run this after the release PR has been merged into master.
# Pulls the latest master, reads the version from Cargo.toml, creates an
# annotated tag on the current HEAD, and pushes it to trigger release.yml.
push-tag:
#!/usr/bin/env bash
set -euo pipefail
# Must be on master.
branch=$(git rev-parse --abbrev-ref HEAD)
if [[ "$branch" != "master" ]]; then
echo "error: push-tag must be run from master (currently on '$branch')" >&2
exit 1
fi
# Pull so we are at the merge commit.
echo "==> Pulling latest master..."
git pull --ff-only origin master
# Derive the version from Cargo.toml (set by `cog bump --auto`).
version=$(grep '^version' Cargo.toml | head -1 \
| sed 's/version = "\(.*\)"/\1/')
if [[ -z "$version" ]]; then
echo "error: could not read version from Cargo.toml" >&2
exit 1
fi
tag="v${version}"
echo "==> Tagging HEAD as ${tag}..."
# Guard: tag must not already exist on origin.
if git ls-remote --tags origin "${tag}" | grep -q "refs/tags/${tag}$"; then
echo "error: tag ${tag} already exists on origin" >&2
exit 1
fi
# Delete stale local tag if present (leftover from the release branch).
git tag -d "${tag}" 2>/dev/null || true
# Create a fresh annotated tag on the current (merged) HEAD.
git tag -a "${tag}" -m "chore(release): ${tag}"
echo "==> Pushing tag ${tag}..."
git push origin "${tag}"
echo "==> Done. Monitor the release workflow at:"
echo " https://github.com/anthonyoteri/dredge/actions"
+85 -186
View File
@@ -1,226 +1,125 @@
# dredge # dredge
`dredge` is a command-line tool for interacting with the [Docker Registry HTTP API V2](https://distribution.github.io/distribution/spec/api/). It lets you inspect, list, and delete images and tags in any V2-compatible container registry directly from your terminal. Dredge is a command line tool for working with the Docker Registry V2 API.
## Features
- List all repositories in a registry catalog
- List all tags for a given image
- Show detailed manifest information for a tagged image
- Delete a tagged image by resolving its digest and removing the manifest
- Verify that a registry endpoint speaks the Docker Distribution API v2
## Installation
Install from [crates.io](https://crates.io/crates/dredge-tool) using Cargo:
```sh
cargo install dredge-tool
```
The installed binary is named `dredge`.
### Prerequisites
- **Rust toolchain** 1.80 or later. Install via [rustup](https://rustup.rs/).
- A running **Docker Registry V2** endpoint. The registry must be accessible over the network from the machine running `dredge`.
- For delete operations, the registry must have storage deletion enabled (see [Deleting a tagged image](#deleting-a-tagged-image)).
## Usage ## Usage
``` ```shell
dredge [OPTIONS] <REGISTRY> <COMMAND> Dredge is a command line tool for working with the Docker Registry V2 API.
Usage: dredge [OPTIONS] <REGISTRY> <COMMAND>
Commands:
catalog Fetch the list of available repositories from the catalog
tags Fetch the list of tags for a given image
show Show detailed information about a particular image
delete Delete a tagged image from the registry
check Perform a simple API Version check towards the configured registry endpoint
help Print this message or the help of the given subcommand(s)
Arguments:
<REGISTRY>
The host or host:port or full base URL of the Docker Registry
Options:
--log-level[=<LEVEL>]
[default: info]
[possible values: trace, debug, info, warn, error, off]
-h, --help
Print help (see a summary with '-h')
-V, --version
Print version
``` ```
### `<REGISTRY>` argument format ### Checking the API Version
The `<REGISTRY>` positional argument accepts any of the following forms: Perform a simple API Version check towards the registry endpoint
| Form | Example |
|---|---|
| Hostname | `registry.example.com` |
| Host and port | `registry.example.com:5000` |
| Full URL | `https://registry.example.com:5000` |
When no scheme is provided, `https://` is assumed automatically.
### Global options
| Option | Default | Description |
|---|---|---|
| `--log-level=<LEVEL>` | `info` | Set the log verbosity. Possible values: `trace`, `debug`, `info`, `warn`, `error`, `off`. |
| `-h, --help` | | Print help information. |
| `-V, --version` | | Print version information. |
---
## Subcommands
### Checking the API version
Verify that the registry endpoint implements the Docker Distribution API v2.
```
dredge <REGISTRY> check
```
**Example:**
```sh ```sh
dredge registry.example.com check Usage: dredge <REGISTRY> check
# Ok
Options:
-h, --help Print help
``` ```
--- ### Fetch Repository List
### Listing repositories (catalog) Fetch the list of available repositories from the catalog
Fetch the full list of repositories available in the registry. Handles paginated responses automatically.
```
dredge <REGISTRY> catalog
```
**Example:**
```sh ```sh
dredge registry.example.com catalog Usage: dredge <REGISTRY> catalog
# myorg/frontend
# myorg/backend
# myorg/worker
```
--- Options:
-h, --help Print help
```
### Listing tags for an image ### Listing tags for an image
Fetch the list of all tags published for a given image. Handles paginated responses automatically. Fetch the list of tags for a given image
``` ```shell
dredge <REGISTRY> tags <NAME> Usage: dredge <REGISTRY> tags <NAME>
Arguments:
<NAME>
Options:
-h, --help Print help
``` ```
| Argument | Description | ### Viewing details of a tagged image
|---|---|
| `<NAME>` | The repository name (e.g. `myorg/backend`). |
**Example:** Show detailed information about a particular image
```sh ```shell
dredge registry.example.com tags myorg/backend Usage: dredge <REGISTRY> show <IMAGE> [TAG]
# latest
# v1.0.0 Arguments:
# v1.1.0 <IMAGE>
# v2.0.0-rc1 [TAG]
Options:
-h, --help Print help
``` ```
--- ### Deleteing a tagged image
### Showing image details Delete a tagged image from the registry
Show detailed manifest information for a specific tagged image, including the architecture, filesystem layers, digest, and ETag. Output is formatted as YAML. Note! This requires that the registry has storage delete rights enabled. For
example, when creating the registry, setting the environment variable
`REGISTRY_STORAGE_DELETE_ENABLED=true` to enable that feature. If that is not
enabled, a `MethodNotAllowed` error will be returned.
Note! This will only remove the tag from the registry, it will not remove
orphaned digests. For that, the garbage collector on the registry service must
be run separately.
```shell
Usage: dredge <REGISTRY> delete <IMAGE> <TAG>
Arguments:
<IMAGE>
<TAG>
Options:
-h, --help Print help
``` ```
dredge <REGISTRY> show <IMAGE> [TAG]
```
| Argument | Default | Description |
|---|---|---|
| `<IMAGE>` | | The repository name (e.g. `myorg/backend`). |
| `[TAG]` | `latest` | The tag to inspect. Defaults to `latest` if omitted. |
**Example:**
```sh
dredge registry.example.com show myorg/backend v2.0.0-rc1
# name: myorg/backend
# tag: v2.0.0-rc1
# architecture: amd64
# fsLayers:
# - blobSum: sha256:a3ed95caeb02ffe68...
# - blobSum: sha256:7d97e254a0461b0a3...
# digest: sha256:0259571889ac87efbf...
# etag: sha256:0259571889ac87efbf...
```
Omitting the tag inspects `latest`:
```sh
dredge registry.example.com show myorg/backend
```
---
### Deleting a tagged image
Delete a specific tagged image from the registry. The tag is resolved to its content digest, and the manifest is deleted by digest.
```
dredge <REGISTRY> delete <IMAGE> <TAG>
```
| Argument | Description |
|---|---|
| `<IMAGE>` | The repository name (e.g. `myorg/backend`). |
| `<TAG>` | The tag to delete (e.g. `v1.0.0`). |
**Example:**
```sh
dredge registry.example.com delete myorg/backend v1.0.0
```
> **Note:** This requires the registry to have storage deletion enabled. When
> running a registry container, set the environment variable
> `REGISTRY_STORAGE_DELETE_ENABLED=true`. If deletion is not enabled, the
> registry will return a `MethodNotAllowed` error.
> **Note:** This operation removes only the manifest referenced by the given
> tag. Unreferenced layer blobs (orphaned digests) are not removed
> automatically. Run the registry's garbage collector separately to reclaim
> storage space.
---
## Configuration
There is no configuration file. All settings are passed as command-line arguments.
**Enabling verbose logging:**
```sh
dredge --log-level=debug registry.example.com catalog
```
**Silencing all log output:**
```sh
dredge --log-level=off registry.example.com catalog
```
---
## Known Limitations
- **No authentication support.** Registries that require authentication (e.g., Docker Hub, private registries protected by HTTP Basic Auth or token-based auth) are not currently supported. Requests to such registries will fail with an `HTTP Authorization failed` error.
- **Delete only removes the manifest tag, not layer blobs.** After deletion, run the registry's garbage collector to free disk space.
- **HTTPS assumed by default.** Plain HTTP registries must be specified with an explicit `http://` scheme in the `<REGISTRY>` argument.
---
## License ## License
Licensed under either of Licensed under either of
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or <http://www.apache.org/licenses/LICENSE-2.0>) * Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license ([LICENSE-MIT](LICENSE-MIT) or <http://opensource.org/licenses/MIT>) * MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
at your option. at your option.
--- ### Contribution
## Contributing Unless you explicitly state otherwise, any contribution intentionally submitted
for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any
additional terms or conditions.
Contributions are welcome! Please open an issue or submit a pull request on [GitHub](https://github.com/anthonyoteri/dredge).
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.
-32
View File
@@ -1,32 +0,0 @@
# Cocogitto configuration
# https://docs.cocogitto.io/config/
ignore_merge_commits = true
tag_prefix = "v"
# Update Cargo.toml version before committing the bump.
# The .bak suffix is needed for macOS compatibility with sed -i.
pre_bump_hooks = [
"sed -i.bak 's/^version = \"[^\"]*\"/version = \"{{version}}\"/' Cargo.toml && rm -f Cargo.toml.bak",
]
[changelog]
path = "CHANGELOG.md"
template = "remote"
remote = "github.com"
repository = "dredge"
owner = "anthonyoteri"
authors = [
{ username = "anthonyoteri", signature = "Anthony Oteri" },
]
[commit_types]
feat = { changelog_title = "Features" }
fix = { changelog_title = "Bug Fixes" }
perf = { changelog_title = "Performance" }
refactor = { changelog_title = "Refactoring" }
docs = { changelog_title = "Documentation" }
test = { changelog_title = "Tests" }
chore = { changelog_title = "Miscellaneous" }
ci = { changelog_title = "CI", omit_from_changelog = true }
style = { changelog_title = "Style", omit_from_changelog = true }
-46
View File
@@ -1,46 +0,0 @@
# cargo-deny configuration
# https://embarkstudios.github.io/cargo-deny/
[graph]
# Check all targets, not just the host.
all-features = true
# ---------------------------------------------------------------------------
# Licenses
# ---------------------------------------------------------------------------
[licenses]
# Minimum confidence threshold for license detection (0.0 1.0).
confidence-threshold = 0.8
# Licenses we explicitly permit across the entire dependency tree.
allow = [
"MIT",
"Apache-2.0",
"Unicode-3.0",
"MPL-2.0",
"BSD-3-Clause",
]
# ---------------------------------------------------------------------------
# Security advisories
# ---------------------------------------------------------------------------
[advisories]
# Deny crates with known security vulnerabilities.
version = 2
# ---------------------------------------------------------------------------
# Crate bans
# ---------------------------------------------------------------------------
[bans]
# Deny multiple versions of the same crate (can cause bloat and confusion).
multiple-versions = "warn"
# Deny wildcard dependencies.
wildcards = "deny"
# ---------------------------------------------------------------------------
# Crate sources
# ---------------------------------------------------------------------------
[sources]
# Only allow crates from crates.io.
unknown-registry = "deny"
unknown-git = "deny"
+106 -401
View File
@@ -7,8 +7,6 @@
* 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;
@@ -17,66 +15,26 @@ 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";
/// Connect timeout applied when establishing a TCP connection. /// Iterate over a paginated result set, collecting and returning the response
const CONNECT_TIMEOUT: Duration = Duration::from_secs(30); /// set.
/// 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.
/// ///
/// All outbound HTTP requests should use this client to prevent hung /// The Docker Registry API specifies that when making a GET request, the
/// connections from blocking the process indefinitely. /// response will be paginated using a Link response header for the Next URI.
/// The URL will be encoded using [RFC5988](https://tools.ietf.org/html/rfc5988)
/// ///
/// # Errors /// This function will continuously request the "Next" link as long as it is
/// returned, collecting and returning the deserialized response bodies as a
/// Vec<T>.
/// ///
/// Returns [`ApiError::HttpError`] if the underlying TLS backend fails to /// # Errors:
/// 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.
/// ///
/// The Docker Registry HTTP API V2 paginates list responses using a `Link` /// Returns an `ApiError` if there is a problem constructing the URL from the
/// response header whose value is an [RFC 5988](https://tools.ietf.org/html/rfc5988) /// configured `registry_url` base and the given `path`, or if there is an
/// URL pointing to the next page. This function follows every `Link` header /// error deserializing the HTTP response body as JSON, or if there is an
/// until no further pages remain, accumulating each page's deserialized JSON /// error parsing the `Link` header value as an RFC5988 URL.
/// 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> {
@@ -87,12 +45,14 @@ 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 = client.get(url).send().await?; let resp = reqwest::get(url).await?;
parse_response_status(&resp)?; 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(header::LINK))? { if let Some(p) = parse_rfc5988(headers.get(header::LINK))? {
next_path = p; next_path = p;
@@ -103,119 +63,117 @@ pub async fn fetch_paginated<T: for<'de> Deserialize<'de>>(
Ok(responses) Ok(responses)
} }
/// Extract the URL from an optional RFC 5988 `Link` header value. /// Given an optional header value possibly containing an RFC5988 formatted
/// URL, parse said URL into a `String`.
/// ///
/// The Docker Registry API uses `Link` headers of the form /// If the `header_value` does not contain a correctly formatted RFC5988 URL,
/// `<URL>; rel="next"` to signal the next page of a paginated result. /// or if the `header_value` is not properly formatted containing a URL
/// This function extracts the URL between the angle brackets from the /// surrounded by angle brackets, separated from the link relation by a ';'
/// portion before the first `;`. /// character, the `None` variant will be returned.
/// ///
/// Returns `Ok(Some(url))` when a valid bracketed URL is found, /// # Errors:
/// `Ok(None)` when the header is absent or does not contain a
/// bracketed URL (e.g. it is malformed or uses a different format).
/// ///
/// # Errors /// Returns and `ApiError` if there is a problem parsing contents of the
/// /// 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:?})");
let Some(link_value) = header_value else { if let Some(link_value) = header_value {
return Ok(None); let link_str = link_value.to_str()?;
}; let parts: Vec<&str> = link_str.split(';').collect();
if let Some(url_part) = parts.first() {
let link_str = link_value.to_str()?; if let Some(path) = url_part
// RFC 5988 link header format: `<URL>; rel="next"` — take everything .trim()
// before the first ';', strip the surrounding angle brackets. .strip_prefix('<')
let url_part = link_str.split_once(';').map_or(link_str, |(url, _)| url); .and_then(|s| s.strip_suffix('>'))
let path = url_part {
.trim() return Ok(Some(String::from(path)));
.strip_prefix('<') }
.and_then(|s| s.strip_suffix('>')); }
Ok(path.map(String::from))
}
/// Check that the `Docker-Distribution-API-Version` response header is present
/// and equals `"registry/2.0"`.
///
/// Returns `Ok(())` when the header is correct.
///
/// # Errors
///
/// * [`ApiError::ResponseHeaderParseError`] — the header value contains
/// non-UTF-8 bytes.
/// * [`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(),
)),
} }
Ok(None)
} }
/// Validate the HTTP status code of a Docker Registry API response. /// Parse the response according to the API Documentation.
/// ///
/// The Docker Registry API contract requires that `2xx` responses include a /// If a 200 OK response is returned, the registry implements the V2(.1)
/// `Docker-Distribution-API-Version: registry/2.0` header. `401 Unauthorized` /// registry API and the client may proceed safely with other V2 operations.
/// responses must also carry this header; when they do the caller should /// Optionally, the response may contain information about the supported
/// authenticate and retry. All other non-success codes are treated as errors. /// paths in the response body. The client should be prepared to ignore this data.
/// ///
/// # Errors /// If a 401 Unauthorized response is returned, the client should take action
/// 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.
/// ///
/// * [`ApiError::ResponseHeaderParseError`] — the `Docker-Distribution-API-Version` /// If 404 Not Found response status, or other unexpected status, is returned,
/// header value contains non-UTF-8 bytes (only checked on `2xx` and `401`). /// the client should proceed with the assumption that the registry does not
/// * [`ApiError::UnsupportedVersion`] — a `2xx` or `401` response contains the /// implement V2 of the API.
/// version header with a value other than `"registry/2.0"`. ///
/// * [`ApiError::UnexpectedResponse`] — a `2xx` or `401` response is missing the /// When a 200 OK or 401 Unauthorized response is returned, the
/// version header entirely. /// "Docker-Distribution-API-Version" header should be set to "registry/2.0".
/// * [`ApiError::AuthorizationFailed`] — the status code is `401 Unauthorized` /// Clients may require this header value to determine if the endpoint serves
/// and the version header is valid. /// this API. When this header is omitted, clients may fallback to an older
/// * [`ApiError::NotFound`] — the status code is `404 Not Found`. /// API version.
/// * [`ApiError::MethodNotAllowed`] — the status code is `405 Method Not Allowed`. ///
/// * [`ApiError::UnexpectedResponse`] — any other undocumented status code is /// # Errors:
/// received. ///
/// Returns an `ApiError` on the following conditions:
///
/// * There is an error parsing the "Docker-Distribution-API-Version" header.
/// * The value of the above header is not the expected result.
/// * The above header is missing from the response.
/// * A non 200 HTTP response status code is returned.
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 => check_api_version_header(response), StatusCode::OK | StatusCode::ACCEPTED => {
let headers = response.headers();
if let Some(header_value) = headers.get("Docker-Distribution-API-Version") {
if header_value.to_str()? == "registry/2.0" {
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 => {
check_api_version_header(response)?; let headers = response.headers();
Err(ApiError::AuthorizationFailed) if let Some(header_value) = headers.get("Docker-Distribution-API-Version") {
if header_value.to_str()? == "registry/2.0" {
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 content digest for the manifest at `url`. /// Fetch the V2 Registry Digest for the specific manifest referenced in the
/// provided `url`.
/// ///
/// Sends a `HEAD` request with an `Accept: application/vnd.docker.distribution.manifest.v2+json` /// # Errors:
/// 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.
/// ///
/// # Errors /// This will return an `ApiError` if there is a problem fetching the manifest
/// /// 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_digest(client: {client:?}, url: {url}"); log::trace!("get_manifest(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)
@@ -262,29 +220,20 @@ mod tests {
/// variant is returned. /// variant is returned.
#[tokio::test] #[tokio::test]
async fn test_parse_rfc5988_invalid() { async fn test_parse_rfc5988_invalid() {
let invalid_header_value = HeaderValue::from_str(r"invalid header value") // Mock a valid RFC5988 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 invalid header value // Call the parse_rfc5988 function with the valid 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 None // Assert that the function returned the expected URL as Some(String)
assert_eq!(result, None); assert_eq!(result, None);
} }
/// Test that `parse_rfc5988` with `None` input returns `Ok(None)`. /// Validates the happy path for the get_digest function
/// ///
/// When no `Link` header is present in the response, the function should /// This tests starts up a mock server, and the client makes a request for
/// 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.
@@ -325,248 +274,4 @@ 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, &registry_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, &registry_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:?}"
);
}
} }
+15 -105
View File
@@ -7,34 +7,25 @@
* 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;
/// Command-line interface for `dredge`. /// Dredge is a command line tool for working with the Docker Registry
/// /// 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,
@@ -46,32 +37,17 @@ pub(crate) struct Cli {
)] )]
pub log_level: LogLevel, pub log_level: LogLevel,
/// The Docker Registry endpoint. /// The host or host:port or full base URL of the Docker Registry
///
/// 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,
} }
@@ -88,94 +64,28 @@ 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 {
/// List all repositories available in the registry catalog. /// Fetch the list of available repositories from the 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,
/// List all tags published for an image. /// Fetch the list of tags for a given 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 { Tags { name: String },
/// The repository name whose tags should be listed
/// (e.g. `myorg/backend`).
name: String,
},
/// Show detailed manifest information for a tagged image. /// Show detailed information about a particular 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 { Delete { image: String, tag: String },
/// 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,
},
/// Verify that the registry endpoint implements Docker Distribution API v2. /// Perform a simple version check towards the Docker Registry API
///
/// 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,
} }
+103 -215
View File
@@ -16,164 +16,78 @@ use url::Url;
use crate::api; use crate::api;
use crate::error::ApiError; use crate::error::ApiError;
/// Deserialized body of a `/v2/_catalog` response page. /// Handler for the `Catalog` endpoint
#[derive(Deserialize)]
struct CatalogResponse {
repositories: Vec<String>,
}
/// Deserialized body of a `/v2/<name>/tags/list` response page.
#[derive(Deserialize)]
struct TagsResponse {
tags: Vec<String>,
}
/// A single filesystem layer entry within a V1 image manifest.
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct FsLayer {
blob_sum: String,
}
/// Deserialized body of a `/v2/<image>/manifests/<tag>` response, augmented
/// with the `digest` and `etag` values extracted from response headers.
#[derive(Debug, Serialize, Deserialize)]
struct ManifestResponse {
name: String,
tag: String,
architecture: String,
#[serde(rename = "fsLayers")]
fslayers: Vec<FsLayer>,
/// Content digest from the `docker-content-digest` response header.
#[serde(skip_deserializing)]
digest: String,
/// `ETag` value from the response header (quotes stripped), or the digest
/// when the `ETag` header is absent.
#[serde(skip_deserializing)]
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, /// Fetch the list of repository names from the Docker Registry API, and
/// and writes one repository name per line to the provided writer. /// simply print the resulting names to stdout.
/// ///
/// # Arguments /// # Errors:
/// ///
/// * `buf` — Output sink (typically stdout or a test buffer). /// Returns an `ApiError` if there is a problem fetching or parsing the
/// * `registry_url` — Base URL of the Docker Registry. /// responses from the Docker Registry API.
///
/// # 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> { pub async fn catalog_handler(buf: &mut dyn Write, registry_url: &Url) -> Result<(), ApiError> {
#[derive(Deserialize)]
struct Response {
repositories: Vec<String>,
}
log::trace!("catalog_handler(registry_url: {registry_url:?})"); log::trace!("catalog_handler(registry_url: {registry_url:?})");
let path = "v2/_catalog";
let client = api::build_client()?; let responses: Vec<Response> = api::fetch_paginated(registry_url, path).await?;
let responses: Vec<CatalogResponse> = let repository_list: Vec<&str> = responses
api::fetch_paginated(&client, registry_url, "v2/_catalog").await?; .iter()
.flat_map(|r| r.repositories.iter().map(String::as_str))
.collect();
for repo in responses.iter().flat_map(|r| r.repositories.iter()) { for repository in repository_list {
writeln!(buf, "{repo}")?; writeln!(buf, "{repository}")?;
} }
Ok(()) Ok(())
} }
/// Fetch all tags for an image from the registry and write them to `buf`. /// Handler for the `Tags` endpoint
/// ///
/// Queries `/v2/<name>/tags/list` via [`api::fetch_paginated`], collects all /// Fetch the list of tags names for a given image from the Docker Registry API, and
/// pages, and writes one tag name per line to the provided writer. /// simply print the resulting names to stdout.
/// ///
/// # Arguments /// # Errors:
/// ///
/// * `buf` — Output sink (typically stdout or a test buffer). /// Returns an `ApiError` if there is a problem fetching or parsing the
/// * `registry_url` — Base URL of the Docker Registry. /// responses from the Docker Registry API.
/// * `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( pub async fn tags_handler(
buf: &mut dyn Write, buf: &mut dyn Write,
registry_url: &Url, registry_url: &Url,
name: &str, name: &str,
) -> Result<(), ApiError> { ) -> Result<(), ApiError> {
#[derive(Deserialize)]
struct Response {
tags: Vec<String>,
}
log::trace!("tags_handler(registry_url: {registry_url:?}, name: {name})"); log::trace!("tags_handler(registry_url: {registry_url:?}, name: {name})");
let path = format!("/v2/{name}/tags/list");
let client = api::build_client()?; let responses: Vec<Response> = api::fetch_paginated(registry_url, &path).await?;
let responses: Vec<TagsResponse> = let tag_list: Vec<&str> = responses
api::fetch_paginated(&client, registry_url, &format!("/v2/{name}/tags/list")).await?; .iter()
.flat_map(|r| r.tags.iter().map(String::as_str))
.collect();
for tag in responses.iter().flat_map(|r| r.tags.iter()) { for tag in tag_list {
writeln!(buf, "{tag}")?; writeln!(buf, "{tag}")?;
} }
Ok(()) Ok(())
} }
/// Fetch and display the manifest for a tagged image. /// Handler function for showing manifest details
/// ///
/// Queries `/v2/<image>/manifests/<tag>`, extracts the /// # Errors:
/// `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 /// Returns an `ApiError` if there is a problem fetching the manifest or if there
/// layer digests, content digest, and `ETag`. /// is a problem parsing the response from the Docker Registry API.
///
/// # 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)] #[allow(clippy::similar_names)]
pub async fn show_handler( pub async fn show_handler(
buf: &mut dyn Write, buf: &mut dyn Write,
@@ -181,79 +95,71 @@ pub async fn show_handler(
image: &str, image: &str,
tag: &str, tag: &str,
) -> Result<(), ApiError> { ) -> Result<(), ApiError> {
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct FsLayer {
blob_sum: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct Response {
name: String,
tag: String,
architecture: String,
#[serde(rename = "fsLayers")]
fslayers: Vec<FsLayer>,
#[serde(skip_deserializing)]
digest: String,
#[serde(skip_deserializing)]
etag: String,
}
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 client = api::build_client()?; let resp = reqwest::get(url).await?;
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 = headers let digest: String = String::from(
.get("docker-content-digest") headers
.ok_or_else(|| ApiError::UnexpectedResponse("Missing docker-content-digest header".into()))? .get("docker-content-digest")
.to_str()? .ok_or(ApiError::UnexpectedResponse(String::from(
.to_owned(); "Missing docker-content-digest header",
)))?
.to_str()?,
);
// Docker Registry API ETags are quoted strings per RFC 7232, e.g. let etag: String = String::from(
// `"sha256:abc123"`. Strip surrounding double-quotes when present; fall headers
// back to the digest when the header is absent. .get("etag")
let etag = match headers.get("etag") { .ok_or(ApiError::UnexpectedResponse(String::from(
Some(v) => { "Missing etag header",
let raw = v.to_str()?; )))?
raw.strip_prefix('"') .to_str()?
.and_then(|s| s.strip_suffix('"')) .strip_prefix("'\"")
.unwrap_or(raw) .and_then(|s| s.strip_suffix("\"'"))
.to_owned() .unwrap_or(&digest),
} );
None => digest.clone(),
};
let mut body: ManifestResponse = resp.json().await?; let mut body: Response = resp.json().await?;
body.digest = digest; body.digest = digest;
body.etag = etag; body.etag = etag;
serde_norway::to_writer(buf, &body)?; serde_yaml::to_writer(buf, &body)?;
Ok(()) Ok(())
} }
/// Delete the manifest for a tagged image from the registry. /// Handler function for deleting a manifest for a given tagged image.
/// ///
/// Resolves `tag` to its content digest by sending a `HEAD` request to /// # Errors:
/// `/v2/<image>/manifests/<tag>`, then deletes the manifest by digest via
/// `DELETE /v2/<image>/manifests/<digest>`.
/// ///
/// The registry must have storage deletion enabled. Set the environment /// Returns and `ApiError` if there is a problem converting the given tag to a
/// variable `REGISTRY_STORAGE_DELETE_ENABLED=true` on the registry container. /// manifest digest, or if there is a problem deleting the manifest from the
/// If deletion is not enabled the registry returns `405 Method Not Allowed` /// Docker Registry API.
/// 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,
@@ -263,7 +169,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 = api::build_client()?; let client = reqwest::Client::new();
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?;
@@ -275,39 +181,21 @@ pub async fn delete_handler(
Ok(()) Ok(())
} }
/// Verify that the registry endpoint implements Docker Distribution API v2. // Path to the Docker Registry APIs "api version check" endpoint.
/// Handler for the API Version Check.
/// ///
/// Sends a `GET` request to `/v2` and validates the response with /// # Errors:
/// [`api::parse_response_status`]. On success writes `"Ok\n"` to `buf`.
/// ///
/// # Arguments /// Returns an `ApiError` if there is a problem communicating with the
/// /// 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 client = api::build_client()?; let resp = reqwest::get(url).await?;
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(())
@@ -372,7 +260,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();
@@ -422,9 +310,9 @@ mod tests {
mock_response.assert(); mock_response.assert();
} }
/// Validate the pagination of the tags handler. /// Validate the pagination of the catalog handler.
/// ///
/// This test spins up a mock server, and makes a request to the tags /// This test spins up a mock server, and makes a request to the catalog
/// 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
@@ -444,7 +332,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();
@@ -493,7 +381,7 @@ mod tests {
mock_response.assert(); mock_response.assert();
} }
/// Validate the check handler when the API version header is missing. /// Validate the the check handler on invalid API version
/// ///
/// 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.
@@ -525,7 +413,7 @@ mod tests {
Ok(()) Ok(())
} }
/// Validate the check handler when the API version header has an unexpected value. /// Validate the the check handler on invalid API version
/// ///
/// 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
+10 -115
View File
@@ -12,167 +12,62 @@
use thiserror::Error; use thiserror::Error;
/// The top-level error type for the `dredge` application. /// The common error type for this 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 returned by the Docker Registry API layer. /// An error communicating with the Registry API
#[error(transparent)] #[error(transparent)]
ApiError(#[from] ApiError), ApiError(#[from] ApiError),
/// The `<REGISTRY>` argument could not be parsed as a valid URL. /// An error building the registry 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),
} }
/// Errors that can occur while communicating with the Docker Registry API. /// An error related to the communication with the registry API.
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum ApiError { pub enum ApiError {
/// A URL could not be constructed or parsed. /// Error parsing a URL
#[error(transparent)] #[error(transparent)]
UrlParseError(#[from] url::ParseError), UrlParseError(#[from] url::ParseError),
/// An HTTP transport-level error (connection refused, TLS failure, timeout, /// Error in HTTP Request
/// etc.) or a response body that could not be decoded.
#[error(transparent)] #[error(transparent)]
HttpError(#[from] reqwest::Error), HttpError(#[from] reqwest::Error),
/// A response header contained bytes that could not be decoded as UTF-8. #[error("Failed to parse response headers")]
#[error("Failed to parse response header: {0}")] ResponseHeaderParseError(Box<dyn std::error::Error>),
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_norway::Error), SerializerError(#[from] serde_yaml::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(other.to_string()) Self::ResponseHeaderParseError(Box::from(other))
}
}
#[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"
);
} }
} }
+12 -54
View File
@@ -24,45 +24,30 @@ pub(crate) mod cli;
mod commands; mod commands;
mod error; mod error;
/// The default image tag used when no tag is specified by the caller. /// Name of "latest" tag
const LATEST: &str = "latest"; const LATEST: &str = "latest";
/// Parse the `<REGISTRY>` CLI argument into a complete Docker Registry [`Url`]. /// Parse the "<REGISTRY>" argument into a complete Docker Registry URL.
/// ///
/// Accepts a bare hostname (`registry.example.com`), a host-and-port pair /// This prepends the HTTPS scheme and converts the given string to a `Url`
/// (`registry.example.com:5000`), or a full URL /// instance.
/// (`https://registry.example.com:5000`). When no URL scheme is present,
/// `https://` is prepended automatically before parsing.
/// ///
/// # Errors /// If the given `host` value is already a valid URL, then it will be returned
/// as-is.
/// ///
/// Returns [`DredgeError::RegistryUrlError`] containing the attempted URL /// # Errors:
/// string if it cannot be parsed as a valid URL after the scheme is prepended.
/// ///
/// # Examples /// If there is a problem parsing the resulting string as a valid URL, a
/// /// `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!("parse_registry_arg(host: {host})"); log::trace!("make_registry_url(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.clone()))) Url::parse(&host).or(Err(DredgeError::RegistryUrlError(host.to_string())))
} }
#[tokio::main(flavor = "current_thread")] #[tokio::main(flavor = "current_thread")]
@@ -91,7 +76,7 @@ async fn main() -> Result<(), DredgeError> {
&mut buf, &mut buf,
&registry_url, &registry_url,
&image, &image,
tag.as_deref().unwrap_or(LATEST), &tag.unwrap_or(LATEST.to_string()),
) )
.await?; .await?;
} }
@@ -171,31 +156,4 @@ 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/");
}
} }