22 Commits

Author SHA1 Message Date
dependabot[bot] cc3c55f143 Bump cocogitto/cocogitto-action from 3 to 4
Bumps [cocogitto/cocogitto-action](https://github.com/cocogitto/cocogitto-action) from 3 to 4.
- [Release notes](https://github.com/cocogitto/cocogitto-action/releases)
- [Changelog](https://github.com/cocogitto/cocogitto-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/cocogitto/cocogitto-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: cocogitto/cocogitto-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-13 18:52:44 +00:00
Anthony Oteri 0d51c5034f fix: upgrade cocogitto-action to v4 and fix commit range syntax
The v3 action inputs (check:, from:) no longer match the v4 API.
Switch to v4 with command: check and pass the git range as a positional
args value (SHA..HEAD) which is what cog check accepts.
2026-05-13 14:51:42 -04:00
Anthony Oteri d37ca2de44 fix: resolve remaining CI check failures
- cargo deny: add MPL-2.0 and BSD-3-Clause to allow list (colored via
  simple_logger/mockito; encoding_rs via reqwest)
- conventional commits: use explicit SHA of last pre-conventional commit
  instead of from_latest_tag (no tags exist yet in the repo)
- MSRV: raise rust-version 1.80 -> 1.88 to match the actual minimum
  required by the dependency tree (simple_logger -> time 0.3 -> 1.88)
- MSRV CI: update toolchain pin to 1.88 to match
- macOS test: remove Swatinem/rust-cache from test matrix job to avoid
  stale cache corrupting the cargo binary path on arm64 runners
2026-05-13 14:51:42 -04:00
Anthony Oteri f55c72aa34 fix: resolve CI check failures
- Replace serde_yml with serde_norway (RUSTSEC-2025-0068: serde_yml is
  unsound and archived; serde_norway is the recommended maintained fork)
- Remove unused toml dependency (was resolving to v1.1.2 which requires
  edition2024/Rust 1.85, breaking the MSRV 1.80 check)
- Run cargo fmt to fix formatting diffs caught by lint job
- Fix cog commit check to use from_latest_tag so pre-conventional-commits
  history does not cause the check to fail
- Remove semver job: dredge is a binary-only crate with no lib target,
  cargo-semver-checks cannot check it
2026-05-13 14:51:42 -04:00
Anthony Oteri 52910538df ci: overhaul CI/CD pipeline and add tooling configs
- Add ci.yml: test matrix (ubuntu/macos/windows), lint, conventional
  commits check (cocogitto), cargo-deny, MSRV, and semver jobs
- Add release.yml: tag-triggered publish to crates.io + GitHub release
- Remove stale rust.yml and rust-clippy.yml workflows
- Add deny.toml for cargo-deny license/advisory/ban/source checks
- Add cog.toml for cocogitto conventional commits and changelog generation
- Add Justfile with test, check, fmt, commits, release, push-tag targets
- Add CHANGELOG.md seed file
- Add PULL_REQUEST_TEMPLATE.md with conventional commits checklist
- Update dependabot.yml: weekly schedule with grouped patch/minor updates
2026-05-13 14:51:42 -04:00
Anthony Oteri 01ef25b953 docs: rewrite README with comprehensive usage examples and installation guide
- Add features list, installation section, and prerequisites
- Document all subcommands with realistic examples and argument tables
- Add REGISTRY argument format table
- Fix typo 'Deleteing' -> 'Deleting'
- Add known limitations section
2026-05-13 14:51:42 -04:00
Anthony Oteri d2d51b3a2d refactor: simplify codebase and fix correctness issues
- api: extract check_api_version_header() helper, eliminating duplicated
  header-checking logic in parse_response_status()
- api: simplify parse_rfc5988() using split_once and let-else
- api: propagate JSON decode errors in fetch_paginated() instead of
  silently swallowing them
- api: add connect/request timeouts via a shared build_client() helper;
  all handlers now use a configured client instead of reqwest::get()
- api: fix stale log trace name get_manifest -> get_digest
- commands: promote inline response structs to module-level for clarity
- commands: fix etag stripping logic (was using wrong quote/apostrophe
  pattern; now correctly strips RFC 7232 double-quotes)
- commands: simplify iterator chains in catalog/tags handlers
- error: simplify ResponseHeaderParseError from Box<dyn Error> to String
- main: fix stale log trace name make_registry_url -> parse_registry_arg
- main: use as_deref().unwrap_or() instead of allocating via to_owned()
- cli: remove unused imports and #![allow(unused_imports)] attribute
2026-05-13 14:51:42 -04:00
Anthony Oteri 353fd94b55 chore: update all dependencies to latest versions
- Replace deprecated serde_yaml with serde_yml
- Remove unused dependencies: serde_toml, xdg
- Bump thiserror 1→2, toml 0.8→1.0, reqwest 0.12.3→0.12 (latest),
  clap 4.4→4.6, tokio 1.32→1.52, and all other deps to latest
- Raise rust-version minimum from 1.72 to 1.80
- Fix description typo: 'interracting' → 'interacting'
- Bump version to 1.2.0
- Update release workflow action versions
2026-05-13 14:51:42 -04:00
dependabot[bot] 9b602f70a6 Merge pull request #64 from anthonyoteri/dependabot/cargo/simple_logger-5.0.0 2024-05-21 18:54:24 +00:00
dependabot[bot] f12615a7f2 Update simple_logger requirement from 4.2.0 to 5.0.0
Updates the requirements on [simple_logger](https://github.com/borntyping/rust-simple_logger) to permit the latest version.
- [Release notes](https://github.com/borntyping/rust-simple_logger/releases)
- [Commits](https://github.com/borntyping/rust-simple_logger/compare/v4.2.0...v5.0.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-07 18:23:41 +00:00
dependabot[bot] 39c03a2b5b Merge pull request #59 from anthonyoteri/dependabot/cargo/env_logger-0.11.3 2024-05-06 19:28:45 +00:00
dependabot[bot] f836b26930 Merge pull request #63 from anthonyoteri/dependabot/cargo/reqwest-0.12.3 2024-05-06 19:28:33 +00:00
dependabot[bot] 0d9761178c Update reqwest requirement from 0.11.20 to 0.12.3
Updates the requirements on [reqwest](https://github.com/seanmonstar/reqwest) to permit the latest version.
- [Release notes](https://github.com/seanmonstar/reqwest/releases)
- [Changelog](https://github.com/seanmonstar/reqwest/blob/master/CHANGELOG.md)
- [Commits](https://github.com/seanmonstar/reqwest/compare/v0.11.20...v0.12.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-05 18:44:24 +00:00
dependabot[bot] 358a19f6f7 Update env_logger requirement from 0.10.0 to 0.11.3
Updates the requirements on [env_logger](https://github.com/rust-cli/env_logger) to permit the latest version.
- [Release notes](https://github.com/rust-cli/env_logger/releases)
- [Changelog](https://github.com/rust-cli/env_logger/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-cli/env_logger/compare/v0.10.0...v0.11.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-05 18:16:31 +00:00
dependabot[bot] 0527b6268d Merge pull request #55 from anthonyoteri/dependabot/github_actions/github/codeql-action-3 2023-12-27 13:25:38 +00:00
dependabot[bot] 67c02581f3 Bump github/codeql-action from 2 to 3
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 2 to 3.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-13 18:20:08 +00:00
dependabot[bot] b6fda7162f Merge pull request #54 from anthonyoteri/dependabot/cargo/http-1.0.0 2023-11-16 16:18:29 +00:00
Anthony Oteri 07746429bb Be consistent with use of http and reqwest libraries
The types may have similar names but are distinct types and should be used consistently.  This changes
the code to use the header and status code information directly from the `reqwests` library instead of
mixing the two.

Signed-off-by: Anthony Oteri <anthony.oteri@gmail.com>
2023-11-16 10:30:03 -05:00
dependabot[bot] 23f1d21a2e Update http requirement from 0.2.9 to 1.0.0
Updates the requirements on [http](https://github.com/hyperium/http) to permit the latest version.
- [Release notes](https://github.com/hyperium/http/releases)
- [Changelog](https://github.com/hyperium/http/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/http/compare/v0.2.9...v1.0.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-15 18:32:17 +00:00
Anthony Oteri 5e7d0e4e1e Release version 1.1.0 2023-10-03 09:55:09 -04:00
Anthony Oteri 600c2d86ac Merge pull request #52 from anthonyoteri/license
Change License to MIT/Apache
2023-10-03 09:48:38 -04:00
Anthony Oteri 0e4219b405 Change License
This changes the effective license from a simple Apache-2.0 license to a
dual license consisting of both Apache-2.0 and the MIT license to be more
compatible for use in GPL Code.

The MIT license requires reproducing countless copies of the same copyright
header with diffferent names in the copyright field, for every MIT library
in use.  The Apache license does not have this drawback.  However, this is not
the primary motivation for creating these issues.  The Apache license also has
protections from patent trolls and explicit contribuition licensing clause.
However the Apache license is incompatible with GPLv2.  This is why Rust is
dual-licensed as MIT/Apache (the "primary" license being Apache, MIT only for
GPLv2 compat), and doing so would be wise for this project.  This also makes
this crate suitable for inclusion and unrestricted sharing in the Rust
standard distribution and other projects using dual MIT/Apache.
2023-10-03 09:36:43 -04:00
27 changed files with 1706 additions and 642 deletions
+18
View File
@@ -0,0 +1,18 @@
## 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
+16 -2
View File
@@ -3,9 +3,23 @@ updates:
- package-ecosystem: "cargo" - package-ecosystem: "cargo"
directory: "/" directory: "/"
schedule: schedule:
interval: "daily" interval: "weekly"
groups:
patch-updates:
update-types:
- "patch"
minor-updates:
update-types:
- "minor"
- package-ecosystem: "github-actions" - package-ecosystem: "github-actions"
directory: "/" directory: "/"
schedule: schedule:
interval: "daily" interval: "weekly"
groups:
patch-updates:
update-types:
- "patch"
minor-updates:
update-types:
- "minor"
+101
View File
@@ -0,0 +1,101 @@
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.88
- name: Cache dependencies
uses: Swatinem/rust-cache@v2
- name: Check MSRV builds
run: cargo check
+117 -83
View File
@@ -3,113 +3,147 @@ 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: # ---------------------------------------------------------------------------
name: Create release # Gate: all CI checks must pass before we publish anything.
# ---------------------------------------------------------------------------
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:
- name: Create release
id: create_release
uses: ncipollo/release-action@v1
build-docs:
name: Build documentation
needs: create_release
runs-on: ubuntu-latest
permissions:
contents: write
steps: steps:
- name: Checkout code - uses: actions/checkout@v4
uses: actions/checkout@v4
- name: Install Rust Toolchain
uses: dtolnay/rust-toolchain@stable
with: with:
toolchain: stable fetch-depth: 0
components: rust-docs
- name: Cache build dependencies - name: Install stable toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache dependencies
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
- name: Run cargo doc - name: Install cocogitto
run: uses: cocogitto/cocogitto-action@v4
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:
upload_url: ${{ needs.create_release.outputs.upload_url }} check: false
asset_name: dredge-${{ github.ref_name }}-docs.tar.gz
asset_path: dredge-${{ github.ref_name }}-docs.tar.gz
asset_content_type: application/gzip
release_assets: - name: Generate release notes for this tag
name: Release assets id: changelog
needs: create_release run: |
runs-on: ${{ matrix.config.os }} VERSION="${GITHUB_REF_NAME#v}"
# 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:
config: include:
- os: ubuntu-latest - os: ubuntu-latest
platform: linux artifact: dredge
arch: x86_64 upload_name: dredge-linux-x86_64.tar.gz
ext: '' target: x86_64-unknown-linux-musl
- os: macos-latest artifact_dir: target/x86_64-unknown-linux-musl/release
platform: macos
arch: x86_64
ext: ''
- os: windows-latest
platform: win
arch: x86_64
ext: .exe
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust Toolchain - os: macos-latest
artifact: dredge
upload_name: dredge-macos-aarch64.tar.gz
target: ""
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
artifact: dredge.exe
upload_name: dredge-windows-x86_64.exe
target: ""
artifact_dir: target/release
steps:
- uses: actions/checkout@v4
- name: Install stable toolchain
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
with: with:
toolchain: stable targets: ${{ matrix.target }}
- name: Cache build dependencies - name: Install musl tools (Linux only)
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: Run cargo build - name: Build release binary
run: cargo build --release run: >
cargo build --release
${{ matrix.target != '' && format('--target {0}', matrix.target) || '' }}
- name: Create release assets - name: Strip binary (Unix only)
run: if: runner.os != 'Windows'
tar --directory target/release -czf run: strip ${{ matrix.artifact_dir }}/${{ matrix.artifact }}
dredge-${{ github.ref_name }}-${{ matrix.config.platform }}.${{ matrix.config.arch }}.tar.gz
dredge${{ matrix.config.ext }}
- name: Upload release assets - name: Ad-hoc sign binary (macOS only)
uses: shogo82148/actions-upload-release-asset@v1 if: runner.os == 'macOS'
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:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_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
@@ -1,51 +0,0 @@
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@v2
with:
sarif_file: rust-clippy-results.sarif
wait-for-processing: true
-56
View File
@@ -1,56 +0,0 @@
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
+6
View File
@@ -0,0 +1,6 @@
<component name="CopyrightManager">
<copyright>
<option name="notice" value="Copyright 2023 Anthony Oteri&#10;&#10;Licensed under the Apache License, Version 2.0, &lt;LICENSE-APACHE or&#10;http://apache.org/licenses/LICENSE-2.0&gt; or the MIT license &lt;LICENSE-MIT or&#10;http://opensource.org/licenses/MIT&gt;, at your option. This file may not be&#10;copied, modified, or distributed except according to those terms." />
<option name="myName" value="MIT OR Apache-2.0 (personal)" />
</copyright>
</component>
+1 -1
View File
@@ -1,7 +1,7 @@
<component name="CopyrightManager"> <component name="CopyrightManager">
<settings> <settings>
<module2copyright> <module2copyright>
<element module="Project Files" copyright="Apache License (personal)" /> <element module="Project Files" copyright="MIT OR Apache-2.0 (personal)" />
</module2copyright> </module2copyright>
</settings> </settings>
</component> </component>
+5
View File
@@ -0,0 +1,5 @@
# Changelog
All notable changes to this project will be documented in this file.
This changelog is generated by [cocogitto](https://github.com/oknozor/cocogitto).
+17 -20
View File
@@ -1,13 +1,13 @@
[package] [package]
name = "dredge-tool" name = "dredge-tool"
version = "1.0.0" version = "1.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 interacting 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.72" rust-version = "1.88"
license-file = "LICENSE" license = "MIT OR Apache-2.0"
keywords = [ keywords = [
"docker", "docker",
"registry", "registry",
@@ -26,21 +26,18 @@ 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.4.3", features = ["derive", "env", "wrap_help"] } clap = { version = "4.6", features = ["derive", "env", "wrap_help"] }
simple_logger = { version = "4.2.0", features = ["timestamps", "colors", "stderr"] } simple_logger = { version = "5.2", features = ["timestamps", "colors", "stderr"] }
http = "0.2.9" http = "1.4"
indoc = "2.0.4" indoc = "2.0"
log = "0.4.20" log = "0.4"
reqwest = { version = "0.11.20", features = ["json", "gzip", "multipart", "native-tls-vendored"] } reqwest = { version = "0.12", features = ["json", "gzip", "multipart", "native-tls-vendored"] }
serde = { version = "1.0.188", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_toml = "0.0.1" thiserror = "2.0"
serde_yaml = "0.9.25" url = { version = "2.5", features = ["serde"] }
thiserror = "1.0.48" tokio = { version = "1.52", features = ["macros"] }
toml = "0.8.0" serde_norway = "0.9.42"
url = { version = "2.4.1", features = ["serde"] }
xdg = "2.5.2"
tokio = { version = "1.32.0", features = ["macros"] }
[dev-dependencies] [dev-dependencies]
mockito = "1.2.0" mockito = "1.7"
env_logger = "0.10.0" env_logger = "0.11"
+168
View File
@@ -0,0 +1,168 @@
# 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"
View File
+24
View File
@@ -0,0 +1,24 @@
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
+196 -79
View File
@@ -1,109 +1,226 @@
# dredge # dredge
Dredge is a command line tool for working with the Docker Registry V2 API. `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.
## 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 is a command line tool for working with the Docker Registry V2 API. dredge [OPTIONS] <REGISTRY> <COMMAND>
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
``` ```
### Checking the API Version ### `<REGISTRY>` argument format
Perform a simple API Version check towards the registry endpoint The `<REGISTRY>` positional argument accepts any of the following forms:
| 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
Usage: dredge <REGISTRY> check dredge registry.example.com check
# Ok
Options:
-h, --help Print help
``` ```
### Fetch Repository List ---
Fetch the list of available repositories from the catalog ### Listing repositories (catalog)
Fetch the full list of repositories available in the registry. Handles paginated responses automatically.
```
dredge <REGISTRY> catalog
```
**Example:**
```sh ```sh
Usage: dredge <REGISTRY> catalog dredge registry.example.com catalog
# myorg/frontend
Options: # myorg/backend
-h, --help Print help # myorg/worker
``` ```
---
### Listing tags for an image ### Listing tags for an image
Fetch the list of tags for a given image Fetch the list of all tags published for a given image. Handles paginated responses automatically.
```shell ```
Usage: dredge <REGISTRY> tags <NAME> dredge <REGISTRY> tags <NAME>
Arguments:
<NAME>
Options:
-h, --help Print help
``` ```
### Viewing details of a tagged image | Argument | Description |
|---|---|
| `<NAME>` | The repository name (e.g. `myorg/backend`). |
Show detailed information about a particular image **Example:**
```shell ```sh
Usage: dredge <REGISTRY> show <IMAGE> [TAG] dredge registry.example.com tags myorg/backend
# latest
Arguments: # v1.0.0
<IMAGE> # v1.1.0
[TAG] # v2.0.0-rc1
Options:
-h, --help Print help
``` ```
### Deleteing a tagged image ---
Delete a tagged image from the registry ### Showing image details
Note! This requires that the registry has storage delete rights enabled. For Show detailed manifest information for a specific tagged image, including the architecture, filesystem layers, digest, and ETag. Output is formatted as YAML.
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
Licensed under either of
- 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>)
at your option.
---
## Contributing
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.
+9
View File
@@ -1,11 +1,20 @@
# Dredge Release Notes # Dredge Release Notes
## Legal
As of version 1.1.0, this software license has been changed from Apache-2.0
to a dual-licensed Apache-2.0 OR MIT license.
## Known Issues ## Known Issues
* Docker authentication is not currently supported, and attempts to query a * Docker authentication is not currently supported, and attempts to query a
registry which requires authentication will fail. registry which requires authentication will fail.
## Changelog ## Changelog
- v1.1.0
- Change License by Anthony Oteri 0e4219b
- v1.0.0 - v1.0.0
- Rename project to dredge-tool by Anthony Oteri b60d433 - Rename project to dredge-tool by Anthony Oteri b60d433
+32
View File
@@ -0,0 +1,32 @@
# 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
@@ -0,0 +1,46 @@
# 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"
+3
View File
@@ -0,0 +1,3 @@
- v1.1.0
- Change License by Anthony Oteri 0e4219b
+5
View File
@@ -1,5 +1,10 @@
# Dredge Release Notes # Dredge Release Notes
## Legal
As of version 1.1.0, this software license has been changed from Apache-2.0
to a dual-licensed Apache-2.0 OR MIT license.
## Known Issues ## Known Issues
* Docker authentication is not currently supported, and attempts to query a * Docker authentication is not currently supported, and attempts to query a
+6 -13
View File
@@ -1,20 +1,13 @@
#!/usr/bin/bash
# #
# Copyright 2023 Anthony Oteri # Copyright 2023 Anthony Oteri
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
# you may not use this file except in compliance with the License. # http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
# You may obtain a copy of the License at # http://opensource.org/licenses/MIT>, at your option. This file may not be
# copied, modified, or distributed except according to those terms.
# #
# 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 set -e
+7 -12
View File
@@ -1,20 +1,15 @@
#!/usr/bin/bash
# #
# Copyright 2023 Anthony Oteri # Copyright 2023 Anthony Oteri
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
# you may not use this file except in compliance with the License. # http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
# You may obtain a copy of the License at # http://opensource.org/licenses/MIT>, at your option. This file may not be
# # copied, modified, or distributed except according to those terms.
# 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 set -e
version=$1 version=$1
previous=$2 previous=$2
+7 -12
View File
@@ -1,20 +1,15 @@
#!/usr/bin/bash
# #
# Copyright 2023 Anthony Oteri # Copyright 2023 Anthony Oteri
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
# you may not use this file except in compliance with the License. # http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
# You may obtain a copy of the License at # http://opensource.org/licenses/MIT>, at your option. This file may not be
# # copied, modified, or distributed except according to those terms.
# 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) REPO_ROOT=$(git rev-parse --show-toplevel)
+405 -117
View File
@@ -1,45 +1,82 @@
/* /*
* Copyright 2023 Anthony Oteri * Copyright 2023 Anthony Oteri
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
* you may not use this file except in compliance with the License. * http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
* You may obtain a copy of the License at * http://opensource.org/licenses/MIT>, at your option. This file may not be
* * copied, modified, or distributed except according to those terms.
* 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.
*/ */
use http::header; use std::time::Duration;
use reqwest::header;
use reqwest::header::HeaderValue;
use reqwest::StatusCode;
use serde::Deserialize; use serde::Deserialize;
use url::Url; use url::Url;
use crate::error::ApiError; use crate::error::ApiError;
/// The MIME type for Docker Image Manifest V2, Schema 2.
///
/// This value is sent in `Accept` headers when fetching manifests so that
/// the registry returns the canonical V2 manifest rather than a legacy V1
/// manifest.
const MANIFEST_V2: &str = "application/vnd.docker.distribution.manifest.v2+json"; const MANIFEST_V2: &str = "application/vnd.docker.distribution.manifest.v2+json";
/// Iterate over a paginated result set, collecting and returning the response /// Connect timeout applied when establishing a TCP connection.
/// set. const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
/// Overall request timeout from first byte sent to last byte received.
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
/// Build a shared [`reqwest::Client`] with sensible default timeouts.
/// ///
/// The Docker Registry API specifies that when making a GET request, the /// All outbound HTTP requests should use this client to prevent hung
/// response will be paginated using a Link response header for the Next URI. /// connections from blocking the process indefinitely.
/// The URL will be encoded using [RFC5988](https://tools.ietf.org/html/rfc5988)
/// ///
/// This function will continuously request the "Next" link as long as it is /// # Errors
/// returned, collecting and returning the deserialized response bodies as a
/// Vec<T>.
/// ///
/// # Errors: /// Returns [`ApiError::HttpError`] if the underlying TLS backend fails to
/// initialise and the client cannot be constructed.
pub fn build_client() -> Result<reqwest::Client, ApiError> {
reqwest::Client::builder()
.connect_timeout(CONNECT_TIMEOUT)
.timeout(REQUEST_TIMEOUT)
.build()
.map_err(ApiError::HttpError)
}
/// Fetch all pages of a paginated Docker Registry API endpoint and return the
/// collected, deserialized response bodies.
/// ///
/// Returns an `ApiError` if there is a problem constructing the URL from the /// The Docker Registry HTTP API V2 paginates list responses using a `Link`
/// configured `registry_url` base and the given `path`, or if there is an /// response header whose value is an [RFC 5988](https://tools.ietf.org/html/rfc5988)
/// error deserializing the HTTP response body as JSON, or if there is an /// URL pointing to the next page. This function follows every `Link` header
/// error parsing the `Link` header value as an RFC5988 URL. /// until no further pages remain, accumulating each page's deserialized JSON
/// body into the returned `Vec<T>`.
///
/// # Arguments
///
/// * `client` — A configured [`reqwest::Client`] used to send requests.
/// * `origin` — The base URL of the Docker Registry (e.g.
/// `https://registry.example.com`).
/// * `path` — The API path to request (e.g. `v2/_catalog`).
///
/// # Errors
///
/// Returns an [`ApiError`] in any of the following situations:
///
/// * [`ApiError::UrlParseError`] — `origin` and `path` cannot be joined into a
/// valid URL.
/// * [`ApiError::HttpError`] — an HTTP request fails at the transport layer, or
/// a response body cannot be deserialized as JSON into `T`.
/// * [`ApiError::ResponseHeaderParseError`] — a `Link` header value contains
/// non-UTF-8 bytes.
/// * Any variant returned by [`parse_response_status`] — see that function for
/// the full list of status-code error conditions.
pub async fn fetch_paginated<T: for<'de> Deserialize<'de>>( pub async fn fetch_paginated<T: for<'de> Deserialize<'de>>(
client: &reqwest::Client,
origin: &Url, origin: &Url,
path: &str, path: &str,
) -> Result<Vec<T>, ApiError> { ) -> Result<Vec<T>, ApiError> {
@@ -50,16 +87,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 = reqwest::get(url).await?; let resp = client.get(url).send().await?;
parse_response_status(&resp)?; parse_response_status(&resp)?;
let headers = resp.headers().clone(); let headers = resp.headers().clone();
if let Ok(json) = resp.json().await { responses.push(resp.json().await?);
responses.push(json);
}
if let Some(p) = parse_rfc5988(headers.get(http::header::LINK))? { if let Some(p) = parse_rfc5988(headers.get(header::LINK))? {
next_path = p; next_path = p;
} else { } else {
break; break;
@@ -68,117 +103,119 @@ pub async fn fetch_paginated<T: for<'de> Deserialize<'de>>(
Ok(responses) Ok(responses)
} }
/// Given an optional header value possibly containing an RFC5988 formatted /// Extract the URL from an optional RFC 5988 `Link` header value.
/// URL, parse said URL into a `String`.
/// ///
/// If the `header_value` does not contain a correctly formatted RFC5988 URL, /// The Docker Registry API uses `Link` headers of the form
/// or if the `header_value` is not properly formatted containing a URL /// `<URL>; rel="next"` to signal the next page of a paginated result.
/// surrounded by angle brackets, separated from the link relation by a ';' /// This function extracts the URL between the angle brackets from the
/// character, the `None` variant will be returned. /// portion before the first `;`.
/// ///
/// # Errors: /// Returns `Ok(Some(url))` when a valid bracketed URL is found,
/// `Ok(None)` when the header is absent or does not contain a
/// bracketed URL (e.g. it is malformed or uses a different format).
/// ///
/// Returns and `ApiError` if there is a problem parsing contents of the /// # Errors
/// supplied header value. ///
fn parse_rfc5988(header_value: Option<&http::HeaderValue>) -> Result<Option<String>, ApiError> { /// Returns an [`ApiError`] if the header value contains non-UTF-8 bytes.
fn parse_rfc5988(header_value: Option<&HeaderValue>) -> Result<Option<String>, ApiError> {
log::trace!("parse_rfc5988(header_value: {header_value:?})"); log::trace!("parse_rfc5988(header_value: {header_value:?})");
if let Some(link_value) = header_value { let Some(link_value) = header_value else {
return Ok(None);
};
let link_str = link_value.to_str()?; let link_str = link_value.to_str()?;
let parts: Vec<&str> = link_str.split(';').collect(); // RFC 5988 link header format: `<URL>; rel="next"` — take everything
if let Some(url_part) = parts.first() { // before the first ';', strip the surrounding angle brackets.
if let Some(path) = url_part let url_part = link_str.split_once(';').map_or(link_str, |(url, _)| url);
let path = url_part
.trim() .trim()
.strip_prefix('<') .strip_prefix('<')
.and_then(|s| s.strip_suffix('>')) .and_then(|s| s.strip_suffix('>'));
{
return Ok(Some(String::from(path)));
}
}
}
Ok(None) Ok(path.map(String::from))
} }
/// Parse the response according to the API Documentation. /// Check that the `Docker-Distribution-API-Version` response header is present
/// and equals `"registry/2.0"`.
/// ///
/// If a 200 OK response is returned, the registry implements the V2(.1) /// Returns `Ok(())` when the header is correct.
/// registry API and the client may proceed safely with other V2 operations.
/// Optionally, the response may contain information about the supported
/// paths in the response body. The client should be prepared to ignore this data.
/// ///
/// If a 401 Unauthorized response is returned, the client should take action /// # Errors
/// based on the contents of the "WWW-Authenticate" header and try the endpoint
/// again. Depending on access control setup, the client may still have to
/// authenticate against different resources, even if this check succeeds.
/// ///
/// If 404 Not Found response status, or other unexpected status, is returned, /// * [`ApiError::ResponseHeaderParseError`] — the header value contains
/// the client should proceed with the assumption that the registry does not /// non-UTF-8 bytes.
/// implement V2 of the API. /// * [`ApiError::UnsupportedVersion`] — the header is present but its value
/// is not `"registry/2.0"`.
/// * [`ApiError::UnexpectedResponse`] — the header is entirely absent.
fn check_api_version_header(response: &reqwest::Response) -> Result<(), ApiError> {
match response.headers().get("Docker-Distribution-API-Version") {
Some(v) if v.to_str()? == "registry/2.0" => Ok(()),
Some(v) => Err(ApiError::UnsupportedVersion(v.to_str()?.into())),
None => Err(ApiError::UnexpectedResponse(
"Missing version header".into(),
)),
}
}
/// Validate the HTTP status code of a Docker Registry API response.
/// ///
/// When a 200 OK or 401 Unauthorized response is returned, the /// The Docker Registry API contract requires that `2xx` responses include a
/// "Docker-Distribution-API-Version" header should be set to "registry/2.0". /// `Docker-Distribution-API-Version: registry/2.0` header. `401 Unauthorized`
/// Clients may require this header value to determine if the endpoint serves /// responses must also carry this header; when they do the caller should
/// this API. When this header is omitted, clients may fallback to an older /// authenticate and retry. All other non-success codes are treated as errors.
/// API version.
/// ///
/// # Errors: /// # Errors
/// ///
/// Returns an `ApiError` on the following conditions: /// * [`ApiError::ResponseHeaderParseError`] — the `Docker-Distribution-API-Version`
/// /// header value contains non-UTF-8 bytes (only checked on `2xx` and `401`).
/// * There is an error parsing the "Docker-Distribution-API-Version" header. /// * [`ApiError::UnsupportedVersion`] — a `2xx` or `401` response contains the
/// * The value of the above header is not the expected result. /// version header with a value other than `"registry/2.0"`.
/// * The above header is missing from the response. /// * [`ApiError::UnexpectedResponse`] — a `2xx` or `401` response is missing the
/// * A non 200 HTTP response status code is returned. /// version header entirely.
/// * [`ApiError::AuthorizationFailed`] — the status code is `401 Unauthorized`
/// and the version header is valid.
/// * [`ApiError::NotFound`] — the status code is `404 Not Found`.
/// * [`ApiError::MethodNotAllowed`] — the status code is `405 Method Not Allowed`.
/// * [`ApiError::UnexpectedResponse`] — any other undocumented status code is
/// received.
pub fn parse_response_status(response: &reqwest::Response) -> Result<(), ApiError> { pub fn parse_response_status(response: &reqwest::Response) -> Result<(), ApiError> {
log::trace!("parse_response_status(response: {response:?})"); log::trace!("parse_response_status(response: {response:?})");
match response.status() { match response.status() {
http::StatusCode::OK | http::StatusCode::ACCEPTED => { StatusCode::OK | StatusCode::ACCEPTED => check_api_version_header(response),
let headers = response.headers(); StatusCode::UNAUTHORIZED => {
if let Some(header_value) = headers.get("Docker-Distribution-API-Version") { check_api_version_header(response)?;
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(),
))
}
}
http::StatusCode::METHOD_NOT_ALLOWED => Err(ApiError::MethodNotAllowed),
http::StatusCode::UNAUTHORIZED => {
let headers = response.headers();
if let Some(header_value) = headers.get("Docker-Distribution-API-Version") {
if header_value.to_str()? == "registry/2.0" {
Err(ApiError::AuthorizationFailed) Err(ApiError::AuthorizationFailed)
} else {
Err(ApiError::UnsupportedVersion(header_value.to_str()?.into()))
} }
} else { StatusCode::NOT_FOUND => Err(ApiError::NotFound),
Err(ApiError::UnexpectedResponse( StatusCode::METHOD_NOT_ALLOWED => Err(ApiError::MethodNotAllowed),
"Missing version header".into(),
))
}
}
http::StatusCode::NOT_FOUND => Err(ApiError::NotFound),
e => Err(ApiError::UnexpectedResponse(format!( e => Err(ApiError::UnexpectedResponse(format!(
"Undocumented status code: {e:?}" "Undocumented status code: {e:?}"
))), ))),
} }
} }
/// Fetch the V2 Registry Digest for the specific manifest referenced in the /// Fetch the content digest for the manifest at `url`.
/// provided `url`.
/// ///
/// # Errors: /// Sends a `HEAD` request with an `Accept: application/vnd.docker.distribution.manifest.v2+json`
/// header and returns the value of the `docker-content-digest` response header.
/// This digest is required to delete a manifest, since the Docker Registry API
/// only accepts deletions by digest, not by tag name.
/// ///
/// This will return an `ApiError` if there is a problem fetching the manifest /// # Errors
/// headers. ///
/// * [`ApiError::HttpError`] — the HTTP request fails at the transport layer.
/// * [`ApiError::ResponseHeaderParseError`] — a response header contains
/// non-UTF-8 bytes.
/// * [`ApiError::UnexpectedResponse`] — the `docker-content-digest` header is
/// absent, or a `2xx` response is missing the version header.
/// * [`ApiError::UnsupportedVersion`] — the version header has an unexpected value.
/// * [`ApiError::AuthorizationFailed`] — the registry returns `401 Unauthorized`.
/// * [`ApiError::NotFound`] — the registry returns `404 Not Found`.
/// * [`ApiError::MethodNotAllowed`] — the registry returns `405 Method Not Allowed`.
pub async fn get_digest(client: &reqwest::Client, url: &Url) -> Result<String, ApiError> { pub async fn get_digest(client: &reqwest::Client, url: &Url) -> Result<String, ApiError> {
log::trace!("get_manifest(client: {client:?}, url: {url}"); log::trace!("get_digest(client: {client:?}, url: {url}");
let resp = client let resp = client
.head(url.as_ref()) .head(url.as_ref())
.header(header::ACCEPT, MANIFEST_V2) .header(header::ACCEPT, MANIFEST_V2)
@@ -199,8 +236,6 @@ pub async fn get_digest(client: &reqwest::Client, url: &Url) -> Result<String, A
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use http::header::HeaderValue;
use super::*; use super::*;
/// Test parsing a valid RFC5988 header value. /// Test parsing a valid RFC5988 header value.
@@ -227,20 +262,29 @@ mod tests {
/// variant is returned. /// variant is returned.
#[tokio::test] #[tokio::test]
async fn test_parse_rfc5988_invalid() { async fn test_parse_rfc5988_invalid() {
// Mock a valid RFC5988 header value let invalid_header_value = HeaderValue::from_str(r"invalid header value")
let invalid_header_value = HeaderValue::from_str(r#"invalid header value"#)
.expect("Failed to create valid header value"); .expect("Failed to create valid header value");
// Call the parse_rfc5988 function with the valid header value // Call the parse_rfc5988 function with the invalid header value
let result = parse_rfc5988(Some(&invalid_header_value)).unwrap(); let result = parse_rfc5988(Some(&invalid_header_value)).unwrap();
// Assert that the function returned the expected URL as Some(String) // Assert that the function returned None
assert_eq!(result, None); assert_eq!(result, None);
} }
/// Validates the happy path for the get_digest function /// Test that `parse_rfc5988` with `None` input returns `Ok(None)`.
/// ///
/// This tests starts up a mock server, and the client makes a request for /// When no `Link` header is present in the response, the function should
/// return `Ok(None)` to signal that there is no next page.
#[test]
fn test_parse_rfc5988_none_input() {
let result = parse_rfc5988(None).unwrap();
assert_eq!(result, None);
}
/// Validates the happy path for the `get_digest` function.
///
/// This test starts up a mock server, and the client makes a request for
/// the digest with the proper headers set. The test then validates that /// the digest with the proper headers set. The test then validates that
/// the correct digest is returned and that the mock server had the expected /// the correct digest is returned and that the mock server had the expected
/// interactions. /// interactions.
@@ -281,4 +325,248 @@ 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:?}"
);
}
} }
+109 -26
View File
@@ -1,38 +1,40 @@
/* /*
* Copyright 2023 Anthony Oteri * Copyright 2023 Anthony Oteri
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
* you may not use this file except in compliance with the License. * http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
* You may obtain a copy of the License at * http://opensource.org/licenses/MIT>, at your option. This file may not be
* * copied, modified, or distributed except according to those terms.
* 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.
*/ */
#![allow(unused_imports)]
use std::ffi::OsString;
use std::path::PathBuf;
use clap::Args;
use clap::Parser; use clap::Parser;
use clap::Subcommand; use clap::Subcommand;
use clap::ValueEnum; use clap::ValueEnum;
/// Dredge is a command line tool for working with the Docker Registry /// Command-line interface for `dredge`.
/// V2 API. ///
/// `dredge` is a tool for interacting with Docker Registry HTTP API V2
/// endpoints. It supports listing repositories and tags, inspecting image
/// manifests, deleting tagged images, and verifying registry API
/// compatibility.
///
/// The `<REGISTRY>` positional argument accepts a bare hostname
/// (`registry.example.com`), a host-and-port pair
/// (`registry.example.com:5000`), or a full URL with an explicit scheme
/// (`https://registry.example.com` or `http://registry.example.com`).
/// When no scheme is given, `https://` is assumed.
#[derive(Debug, Parser, PartialEq, Eq)] #[derive(Debug, Parser, PartialEq, Eq)]
#[command(name = "dredge", version, author)] #[command(name = "dredge", version, author)]
#[command(about, long_about)] #[command(about, long_about)]
pub(crate) struct Cli { pub(crate) struct Cli {
/// The subcommand to execute.
#[command(subcommand)] #[command(subcommand)]
pub command: Commands, pub command: Commands,
/// Minimum log level for messages written to stderr.
///
/// Possible values: `trace`, `debug`, `info`, `warn`, `error`, `off`.
/// Defaults to `info`.
#[arg( #[arg(
long = "log-level", long = "log-level",
require_equals = true, require_equals = true,
@@ -44,17 +46,32 @@ pub(crate) struct Cli {
)] )]
pub log_level: LogLevel, pub log_level: LogLevel,
/// The host or host:port or full base URL of the Docker Registry /// The Docker Registry endpoint.
///
/// Accepts a hostname (`registry.example.com`), host and port
/// (`registry.example.com:5000`), or a full URL with an explicit scheme
/// (`https://registry.example.com` or `http://registry.example.com`).
/// The `https://` scheme is assumed when no scheme is provided.
pub registry: String, pub registry: String,
} }
/// Log verbosity level for the `--log-level` CLI option.
///
/// Maps directly to the corresponding [`log::LevelFilter`] variants. Use
/// `Off` to suppress all log output.
#[derive(ValueEnum, Copy, Clone, Debug, PartialEq, Eq)] #[derive(ValueEnum, Copy, Clone, Debug, PartialEq, Eq)]
pub enum LogLevel { pub enum LogLevel {
/// Extremely verbose output, including internal trace points.
Trace, Trace,
/// Verbose output useful for debugging.
Debug, Debug,
/// Informational messages (default).
Info, Info,
/// Warnings about potentially unexpected conditions.
Warn, Warn,
/// Only error messages.
Error, Error,
/// Suppress all log output.
Off, Off,
} }
@@ -71,28 +88,94 @@ impl From<LogLevel> for log::LevelFilter {
} }
} }
/// Available `dredge` subcommands.
#[derive(Debug, Subcommand, PartialEq, Eq)] #[derive(Debug, Subcommand, PartialEq, Eq)]
pub enum Commands { pub enum Commands {
/// Fetch the list of available repositories from the catalog. /// List all repositories available in the registry catalog.
///
/// Queries the `/v2/_catalog` endpoint and prints one repository name per
/// line. Paginated responses are followed automatically.
///
/// **Example:**
/// ```text
/// dredge registry.example.com catalog
/// ```
Catalog, Catalog,
/// Fetch the list of tags for a given image. /// List all tags published for an image.
///
/// Queries the `/v2/<NAME>/tags/list` endpoint and prints one tag per
/// line. Paginated responses are followed automatically.
///
/// **Example:**
/// ```text
/// dredge registry.example.com tags myorg/backend
/// ```
#[command(arg_required_else_help = true)] #[command(arg_required_else_help = true)]
Tags { name: String }, Tags {
/// The repository name whose tags should be listed
/// (e.g. `myorg/backend`).
name: String,
},
/// Show detailed information about a particular image. /// Show detailed manifest information for a tagged image.
///
/// Queries the `/v2/<IMAGE>/manifests/<TAG>` endpoint and prints the
/// parsed manifest as YAML, including the image name, tag, architecture,
/// filesystem layers, content digest, and `ETag`.
///
/// When `[TAG]` is omitted, `latest` is used.
///
/// **Examples:**
/// ```text
/// dredge registry.example.com show myorg/backend
/// dredge registry.example.com show myorg/backend v2.0.0
/// ```
#[command(arg_required_else_help = true)] #[command(arg_required_else_help = true)]
Show { Show {
/// The repository name of the image to inspect (e.g. `myorg/backend`).
image: String, image: String,
/// The tag to inspect. Defaults to `latest` when omitted.
#[arg(default_missing_value = "latest")] #[arg(default_missing_value = "latest")]
tag: Option<String>, tag: Option<String>,
}, },
/// Delete a tagged image from the registry. /// Delete a tagged image from the registry.
///
/// Resolves the tag to its content digest via a `HEAD` request, then
/// sends a `DELETE` request for that digest to the
/// `/v2/<IMAGE>/manifests/<DIGEST>` endpoint.
///
/// Requires the registry to have storage deletion enabled (set
/// `REGISTRY_STORAGE_DELETE_ENABLED=true` on the registry container).
/// If deletion is not enabled the registry returns a
/// `405 Method Not Allowed` response.
///
/// Only the manifest is removed; unreferenced layer blobs remain on disk
/// until the registry garbage collector is run.
///
/// **Example:**
/// ```text
/// dredge registry.example.com delete myorg/backend v1.0.0
/// ```
#[command(arg_required_else_help = true)] #[command(arg_required_else_help = true)]
Delete { image: String, tag: String }, Delete {
/// The repository name of the image to delete (e.g. `myorg/backend`).
image: String,
/// The tag to delete (e.g. `v1.0.0`).
tag: String,
},
/// Perform a simple version check towards the Docker Registry API /// Verify that the registry endpoint implements Docker Distribution API v2.
///
/// Sends a `GET` request to `/v2` and checks that the response contains a
/// `Docker-Distribution-API-Version: registry/2.0` header. Prints `Ok`
/// on success.
///
/// **Example:**
/// ```text
/// dredge registry.example.com check
/// ```
Check, Check,
} }
+235 -130
View File
@@ -1,17 +1,10 @@
/* /*
* Copyright 2023 Anthony Oteri * Copyright 2023 Anthony Oteri
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
* you may not use this file except in compliance with the License. * http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
* You may obtain a copy of the License at * http://opensource.org/licenses/MIT>, at your option. This file may not be
* * copied, modified, or distributed except according to those terms.
* 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.
*/ */
use std::io::Write; use std::io::Write;
@@ -23,93 +16,29 @@ use url::Url;
use crate::api; use crate::api;
use crate::error::ApiError; use crate::error::ApiError;
/// Handler for the `Catalog` endpoint /// Deserialized body of a `/v2/_catalog` response page.
/// #[derive(Deserialize)]
/// Fetch the list of repository names from the Docker Registry API, and struct CatalogResponse {
/// simply print the resulting names to stdout.
///
/// # Errors:
///
/// Returns an `ApiError` if there is a problem fetching or parsing the
/// responses from the Docker Registry API.
pub async fn catalog_handler(buf: &mut dyn Write, registry_url: &Url) -> Result<(), ApiError> {
#[derive(Deserialize)]
struct Response {
repositories: Vec<String>, repositories: Vec<String>,
}
log::trace!("catalog_handler(registry_url: {registry_url:?})");
let path = "v2/_catalog";
let responses: Vec<Response> = api::fetch_paginated(registry_url, path).await?;
let repository_list: Vec<&str> = responses
.iter()
.flat_map(|r| r.repositories.iter().map(String::as_str))
.collect();
for repository in repository_list {
writeln!(buf, "{repository}")?;
}
Ok(())
} }
/// Handler for the `Tags` endpoint /// Deserialized body of a `/v2/<name>/tags/list` response page.
/// #[derive(Deserialize)]
/// Fetch the list of tags names for a given image from the Docker Registry API, and struct TagsResponse {
/// simply print the resulting names to stdout.
///
/// # Errors:
///
/// Returns an `ApiError` if there is a problem fetching or parsing the
/// responses from the Docker Registry API.
pub async fn tags_handler(
buf: &mut dyn Write,
registry_url: &Url,
name: &str,
) -> Result<(), ApiError> {
#[derive(Deserialize)]
struct Response {
tags: Vec<String>, tags: Vec<String>,
}
log::trace!("tags_handler(registry_url: {registry_url:?}, name: {name})");
let path = format!("/v2/{name}/tags/list");
let responses: Vec<Response> = api::fetch_paginated(registry_url, &path).await?;
let tag_list: Vec<&str> = responses
.iter()
.flat_map(|r| r.tags.iter().map(String::as_str))
.collect();
for tag in tag_list {
writeln!(buf, "{tag}")?;
}
Ok(())
} }
/// Handler function for showing manifest details /// A single filesystem layer entry within a V1 image manifest.
/// #[derive(Debug, Serialize, Deserialize)]
/// # Errors: #[serde(rename_all = "camelCase")]
/// struct FsLayer {
/// Returns an `ApiError` if there is a problem fetching the manifest or if there
/// is a problem parsing the response from the Docker Registry API.
#[allow(clippy::similar_names)]
pub async fn show_handler(
buf: &mut dyn Write,
registry_url: &Url,
image: &str,
tag: &str,
) -> Result<(), ApiError> {
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct FsLayer {
blob_sum: String, blob_sum: String,
} }
#[derive(Debug, Serialize, Deserialize)] /// Deserialized body of a `/v2/<image>/manifests/<tag>` response, augmented
struct Response { /// with the `digest` and `etag` values extracted from response headers.
#[derive(Debug, Serialize, Deserialize)]
struct ManifestResponse {
name: String, name: String,
tag: String, tag: String,
architecture: String, architecture: String,
@@ -117,56 +46,214 @@ pub async fn show_handler(
#[serde(rename = "fsLayers")] #[serde(rename = "fsLayers")]
fslayers: Vec<FsLayer>, fslayers: Vec<FsLayer>,
/// Content digest from the `docker-content-digest` response header.
#[serde(skip_deserializing)] #[serde(skip_deserializing)]
digest: String, digest: String,
/// `ETag` value from the response header (quotes stripped), or the digest
/// when the `ETag` header is absent.
#[serde(skip_deserializing)] #[serde(skip_deserializing)]
etag: String, etag: String,
}
/// Fetch all repository names from the registry catalog and write them to `buf`.
///
/// Queries `/v2/_catalog` via [`api::fetch_paginated`], collects all pages,
/// and writes one repository name per line to the provided writer.
///
/// # Arguments
///
/// * `buf` — Output sink (typically stdout or a test buffer).
/// * `registry_url` — Base URL of the Docker Registry.
///
/// # Errors
///
/// * [`ApiError::HttpError`] — the HTTP client could not be constructed, or a
/// request to the registry failed at the transport layer, or a response body
/// could not be decoded as JSON.
/// * [`ApiError::UrlParseError`] — the catalog URL could not be constructed.
/// * [`ApiError::ResponseHeaderParseError`] — a response header contains
/// non-UTF-8 bytes.
/// * [`ApiError::UnsupportedVersion`] — the registry version header has an
/// unexpected value.
/// * [`ApiError::UnexpectedResponse`] — a required response header is absent.
/// * [`ApiError::AuthorizationFailed`] — the registry requires authentication.
/// * [`ApiError::NotFound`] — the catalog endpoint does not exist.
/// * [`ApiError::MethodNotAllowed`] — the registry rejected the request method.
/// * [`ApiError::IOError`] — writing a repository name to `buf` failed.
pub async fn catalog_handler(buf: &mut dyn Write, registry_url: &Url) -> Result<(), ApiError> {
log::trace!("catalog_handler(registry_url: {registry_url:?})");
let client = api::build_client()?;
let responses: Vec<CatalogResponse> =
api::fetch_paginated(&client, registry_url, "v2/_catalog").await?;
for repo in responses.iter().flat_map(|r| r.repositories.iter()) {
writeln!(buf, "{repo}")?;
} }
Ok(())
}
/// Fetch all tags for an image from the registry and write them to `buf`.
///
/// Queries `/v2/<name>/tags/list` via [`api::fetch_paginated`], collects all
/// pages, and writes one tag name per line to the provided writer.
///
/// # Arguments
///
/// * `buf` — Output sink (typically stdout or a test buffer).
/// * `registry_url` — Base URL of the Docker Registry.
/// * `name` — The repository name whose tags should be listed
/// (e.g. `"myorg/backend"`).
///
/// # Errors
///
/// * [`ApiError::HttpError`] — the HTTP client could not be constructed, or a
/// request to the registry failed at the transport layer, or a response body
/// could not be decoded as JSON.
/// * [`ApiError::UrlParseError`] — the tags URL could not be constructed.
/// * [`ApiError::ResponseHeaderParseError`] — a response header contains
/// non-UTF-8 bytes.
/// * [`ApiError::UnsupportedVersion`] — the registry version header has an
/// unexpected value.
/// * [`ApiError::UnexpectedResponse`] — a required response header is absent.
/// * [`ApiError::AuthorizationFailed`] — the registry requires authentication.
/// * [`ApiError::NotFound`] — the image does not exist in the registry.
/// * [`ApiError::MethodNotAllowed`] — the registry rejected the request method.
/// * [`ApiError::IOError`] — writing a tag name to `buf` failed.
pub async fn tags_handler(
buf: &mut dyn Write,
registry_url: &Url,
name: &str,
) -> Result<(), ApiError> {
log::trace!("tags_handler(registry_url: {registry_url:?}, name: {name})");
let client = api::build_client()?;
let responses: Vec<TagsResponse> =
api::fetch_paginated(&client, registry_url, &format!("/v2/{name}/tags/list")).await?;
for tag in responses.iter().flat_map(|r| r.tags.iter()) {
writeln!(buf, "{tag}")?;
}
Ok(())
}
/// Fetch and display the manifest for a tagged image.
///
/// Queries `/v2/<image>/manifests/<tag>`, extracts the
/// `docker-content-digest` and `etag` response headers, deserializes the
/// manifest JSON body, and serializes the result as YAML to `buf`.
///
/// The output includes the image name, tag, target architecture, filesystem
/// layer digests, content digest, and `ETag`.
///
/// # Arguments
///
/// * `buf` — Output sink (typically stdout or a test buffer).
/// * `registry_url` — Base URL of the Docker Registry.
/// * `image` — The repository name (e.g. `"myorg/backend"`).
/// * `tag` — The tag to inspect (e.g. `"v2.0.0"`). Pass `"latest"` when
/// no explicit tag was provided by the caller.
///
/// # Errors
///
/// * [`ApiError::HttpError`] — the HTTP client could not be constructed, or the
/// request failed at the transport layer, or the response body could not be
/// decoded as JSON.
/// * [`ApiError::UrlParseError`] — the manifest URL could not be constructed.
/// * [`ApiError::ResponseHeaderParseError`] — a response header contains
/// non-UTF-8 bytes.
/// * [`ApiError::UnexpectedResponse`] — the `docker-content-digest` header is
/// absent, or a required version header is missing.
/// * [`ApiError::UnsupportedVersion`] — the registry version header has an
/// unexpected value.
/// * [`ApiError::AuthorizationFailed`] — the registry requires authentication.
/// * [`ApiError::NotFound`] — the image or tag does not exist in the registry.
/// * [`ApiError::MethodNotAllowed`] — the registry rejected the request method.
/// * [`ApiError::SerializerError`] — the manifest could not be serialized to YAML.
/// * [`ApiError::IOError`] — writing the YAML output to `buf` failed.
#[allow(clippy::similar_names)]
pub async fn show_handler(
buf: &mut dyn Write,
registry_url: &Url,
image: &str,
tag: &str,
) -> Result<(), ApiError> {
log::trace!("show_handler(registry_url: {registry_url:?}, image: {image}, tag: {tag})"); log::trace!("show_handler(registry_url: {registry_url:?}, image: {image}, tag: {tag})");
let path = format!("/v2/{image}/manifests/{tag}"); let path = format!("/v2/{image}/manifests/{tag}");
let url = registry_url.join(&path)?; let url = registry_url.join(&path)?;
let resp = reqwest::get(url).await?; let client = api::build_client()?;
let resp = client.get(url).send().await?;
api::parse_response_status(&resp)?; api::parse_response_status(&resp)?;
let headers = resp.headers(); let headers = resp.headers();
let digest: String = String::from( let digest = headers
headers
.get("docker-content-digest") .get("docker-content-digest")
.ok_or(ApiError::UnexpectedResponse(String::from( .ok_or_else(|| ApiError::UnexpectedResponse("Missing docker-content-digest header".into()))?
"Missing docker-content-digest header",
)))?
.to_str()?,
);
let etag: String = String::from(
headers
.get("etag")
.ok_or(ApiError::UnexpectedResponse(String::from(
"Missing etag header",
)))?
.to_str()? .to_str()?
.strip_prefix("'\"") .to_owned();
.and_then(|s| s.strip_suffix("\"'"))
.unwrap_or(&digest),
);
let mut body: Response = resp.json().await?; // Docker Registry API ETags are quoted strings per RFC 7232, e.g.
// `"sha256:abc123"`. Strip surrounding double-quotes when present; fall
// back to the digest when the header is absent.
let etag = match headers.get("etag") {
Some(v) => {
let raw = v.to_str()?;
raw.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or(raw)
.to_owned()
}
None => digest.clone(),
};
let mut body: ManifestResponse = resp.json().await?;
body.digest = digest; body.digest = digest;
body.etag = etag; body.etag = etag;
serde_yaml::to_writer(buf, &body)?; serde_norway::to_writer(buf, &body)?;
Ok(()) Ok(())
} }
/// Handler function for deleting a manifest for a given tagged image. /// Delete the manifest for a tagged image from the registry.
/// ///
/// # Errors: /// Resolves `tag` to its content digest by sending a `HEAD` request to
/// `/v2/<image>/manifests/<tag>`, then deletes the manifest by digest via
/// `DELETE /v2/<image>/manifests/<digest>`.
/// ///
/// Returns and `ApiError` if there is a problem converting the given tag to a /// The registry must have storage deletion enabled. Set the environment
/// manifest digest, or if there is a problem deleting the manifest from the /// variable `REGISTRY_STORAGE_DELETE_ENABLED=true` on the registry container.
/// Docker Registry API. /// If deletion is not enabled the registry returns `405 Method Not Allowed`
/// and this function returns [`ApiError::MethodNotAllowed`].
///
/// Only the manifest is removed. Unreferenced layer blobs remain on disk
/// until the registry garbage collector is run separately.
///
/// # Arguments
///
/// * `_buf` — Unused output sink (reserved for future use).
/// * `registry_url` — Base URL of the Docker Registry.
/// * `image` — The repository name (e.g. `"myorg/backend"`).
/// * `tag` — The tag to delete (e.g. `"v1.0.0"`).
///
/// # Errors
///
/// * [`ApiError::HttpError`] — the HTTP client could not be constructed, or a
/// request failed at the transport layer.
/// * [`ApiError::UrlParseError`] — a manifest URL could not be constructed.
/// * [`ApiError::ResponseHeaderParseError`] — a response header contains
/// non-UTF-8 bytes.
/// * [`ApiError::UnexpectedResponse`] — the `docker-content-digest` header is
/// absent from the `HEAD` response, or a required version header is missing.
/// * [`ApiError::UnsupportedVersion`] — the registry version header has an
/// unexpected value.
/// * [`ApiError::AuthorizationFailed`] — the registry requires authentication.
/// * [`ApiError::NotFound`] — the image or tag does not exist in the registry.
/// * [`ApiError::MethodNotAllowed`] — the registry does not permit deletion;
/// ensure `REGISTRY_STORAGE_DELETE_ENABLED=true` is set on the registry.
#[allow(clippy::unused_async)] #[allow(clippy::unused_async)]
pub async fn delete_handler( pub async fn delete_handler(
_buf: &mut dyn Write, _buf: &mut dyn Write,
@@ -176,7 +263,7 @@ pub async fn delete_handler(
) -> Result<(), ApiError> { ) -> Result<(), ApiError> {
log::trace!("delete_handler(registry_url: {registry_url:?}, image: {image}, tag: {tag})"); log::trace!("delete_handler(registry_url: {registry_url:?}, image: {image}, tag: {tag})");
let client = reqwest::Client::new(); let client = api::build_client()?;
let url = registry_url.join(&format!("/v2/{image}/manifests/{tag}"))?; let url = registry_url.join(&format!("/v2/{image}/manifests/{tag}"))?;
let digest = api::get_digest(&client, &url).await?; let digest = api::get_digest(&client, &url).await?;
@@ -188,21 +275,39 @@ pub async fn delete_handler(
Ok(()) Ok(())
} }
// Path to the Docker Registry APIs "api version check" endpoint. /// Verify that the registry endpoint implements Docker Distribution API v2.
/// Handler for the API Version Check.
/// ///
/// # Errors: /// Sends a `GET` request to `/v2` and validates the response with
/// [`api::parse_response_status`]. On success writes `"Ok\n"` to `buf`.
/// ///
/// Returns an `ApiError` if there is a problem communicating with the /// # Arguments
/// endpoint or if the required version is not supported. ///
/// * `buf` — Output sink (typically stdout or a test buffer).
/// * `registry_url` — Base URL of the Docker Registry.
///
/// # Errors
///
/// * [`ApiError::HttpError`] — the HTTP client could not be constructed, or the
/// request failed at the transport layer.
/// * [`ApiError::UrlParseError`] — the `/v2` URL could not be constructed.
/// * [`ApiError::ResponseHeaderParseError`] — the version header contains
/// non-UTF-8 bytes.
/// * [`ApiError::UnexpectedResponse`] — the `Docker-Distribution-API-Version`
/// header is absent from the response.
/// * [`ApiError::UnsupportedVersion`] — the version header has a value other
/// than `"registry/2.0"`.
/// * [`ApiError::AuthorizationFailed`] — the registry requires authentication.
/// * [`ApiError::NotFound`] — the `/v2` endpoint does not exist.
/// * [`ApiError::MethodNotAllowed`] — the registry rejected the request method.
/// * [`ApiError::IOError`] — writing `"Ok\n"` to `buf` failed.
pub async fn check_handler(buf: &mut dyn Write, registry_url: &Url) -> Result<(), ApiError> { pub async fn check_handler(buf: &mut dyn Write, registry_url: &Url) -> Result<(), ApiError> {
log::trace!("check_handler(registry_url: {registry_url:?})"); log::trace!("check_handler(registry_url: {registry_url:?})");
let path = "/v2"; let path = "/v2";
let url = registry_url.join(path)?; let url = registry_url.join(path)?;
let resp = reqwest::get(url).await?; let client = api::build_client()?;
let resp = client.get(url).send().await?;
api::parse_response_status(&resp)?; api::parse_response_status(&resp)?;
writeln!(buf, "Ok")?; writeln!(buf, "Ok")?;
Ok(()) Ok(())
@@ -267,7 +372,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();
@@ -317,9 +422,9 @@ mod tests {
mock_response.assert(); mock_response.assert();
} }
/// Validate the pagination of the catalog handler. /// Validate the pagination of the tags handler.
/// ///
/// This test spins up a mock server, and makes a request to the catalog /// This test spins up a mock server, and makes a request to the tags
/// endpoint. The response includes a pagination link, which the handler /// endpoint. The response includes a pagination link, which the handler
/// should follow, resulting in the combined list. It checks that the /// should follow, resulting in the combined list. It checks that the
/// handler both called the request the expected number of times, and did /// handler both called the request the expected number of times, and did
@@ -339,7 +444,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();
@@ -388,7 +493,7 @@ mod tests {
mock_response.assert(); mock_response.assert();
} }
/// Validate the the check handler on invalid API version /// Validate the check handler when the API version header is missing.
/// ///
/// This validates that if the "Docker-Distribution-API-Version" header /// This validates that if the "Docker-Distribution-API-Version" header
/// is missing in the response, the appropriate error is returned. /// is missing in the response, the appropriate error is returned.
@@ -420,7 +525,7 @@ mod tests {
Ok(()) Ok(())
} }
/// Validate the the check handler on invalid API version /// Validate the check handler when the API version header has an unexpected value.
/// ///
/// This validates that if the "Docker-Distribution-API-Version" header /// This validates that if the "Docker-Distribution-API-Version" header
/// is present in the response but contains an unexpected value, the /// is present in the response but contains an unexpected value, the
+119 -21
View File
@@ -1,17 +1,10 @@
/* /*
* Copyright 2023 Anthony Oteri * Copyright 2023 Anthony Oteri
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
* you may not use this file except in compliance with the License. * http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
* You may obtain a copy of the License at * http://opensource.org/licenses/MIT>, at your option. This file may not be
* * copied, modified, or distributed except according to those terms.
* 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.
*/ */
#![allow(clippy::enum_variant_names)] #![allow(clippy::enum_variant_names)]
@@ -19,62 +12,167 @@
use thiserror::Error; use thiserror::Error;
/// The common error type for this Application. /// The top-level error type for the `dredge` application.
///
/// Wraps lower-level errors from the registry API, URL parsing, I/O, and
/// the logging subsystem so they can all be returned from `main`.
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum DredgeError { pub enum DredgeError {
/// An error communicating with the Registry API /// An error returned by the Docker Registry API layer.
#[error(transparent)] #[error(transparent)]
ApiError(#[from] ApiError), ApiError(#[from] ApiError),
/// An error building the registry URL /// The `<REGISTRY>` argument could not be parsed as a valid URL.
#[error("Error determining registry URL from {0}")] #[error("Error determining registry URL from {0}")]
RegistryUrlError(String), RegistryUrlError(String),
/// An I/O error writing output to stdout.
#[error(transparent)] #[error(transparent)]
IOError(#[from] std::io::Error), IOError(#[from] std::io::Error),
/// The logging subsystem could not be initialised.
#[error(transparent)] #[error(transparent)]
LoggerError(#[from] log::SetLoggerError), LoggerError(#[from] log::SetLoggerError),
} }
/// An error related to the communication with the registry API. /// Errors that can occur while communicating with the Docker Registry API.
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum ApiError { pub enum ApiError {
/// Error parsing a URL /// A URL could not be constructed or parsed.
#[error(transparent)] #[error(transparent)]
UrlParseError(#[from] url::ParseError), UrlParseError(#[from] url::ParseError),
/// Error in HTTP Request /// An HTTP transport-level error (connection refused, TLS failure, timeout,
/// etc.) or a response body that could not be decoded.
#[error(transparent)] #[error(transparent)]
HttpError(#[from] reqwest::Error), HttpError(#[from] reqwest::Error),
#[error("Failed to parse response headers")] /// A response header contained bytes that could not be decoded as UTF-8.
ResponseHeaderParseError(Box<dyn std::error::Error>), #[error("Failed to parse response header: {0}")]
ResponseHeaderParseError(String),
/// The registry returned a `Docker-Distribution-API-Version` header value
/// other than `"registry/2.0"`. The inner `String` holds the actual value.
#[error("Version Mismatch {0}")] #[error("Version Mismatch {0}")]
UnsupportedVersion(String), UnsupportedVersion(String),
/// The registry returned a response that did not match the expected API
/// contract (e.g. a required header was absent). The inner `String`
/// describes the specific problem.
#[error("Unexpected response from API: {0}")] #[error("Unexpected response from API: {0}")]
UnexpectedResponse(String), UnexpectedResponse(String),
/// The registry returned `401 Unauthorized`. Authentication is not
/// currently supported; the request cannot be retried automatically.
#[error("HTTP Authorization failed")] #[error("HTTP Authorization failed")]
AuthorizationFailed, AuthorizationFailed,
/// The requested resource does not exist in the registry (`404 Not Found`).
#[error("Resource not found")] #[error("Resource not found")]
NotFound, NotFound,
/// An I/O error writing serialized output to the output buffer.
#[error(transparent)] #[error(transparent)]
IOError(#[from] std::io::Error), IOError(#[from] std::io::Error),
/// The manifest response body could not be serialized to YAML for output.
#[error(transparent)] #[error(transparent)]
SerializerError(#[from] serde_yaml::Error), SerializerError(#[from] serde_norway::Error),
/// The registry returned `405 Method Not Allowed`, typically because
/// storage deletion has not been enabled on the registry.
#[error("Method not allowed")] #[error("Method not allowed")]
MethodNotAllowed, MethodNotAllowed,
} }
impl From<reqwest::header::ToStrError> for ApiError { impl From<reqwest::header::ToStrError> for ApiError {
fn from(other: reqwest::header::ToStrError) -> Self { fn from(other: reqwest::header::ToStrError) -> Self {
Self::ResponseHeaderParseError(Box::from(other)) Self::ResponseHeaderParseError(other.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Test that `DredgeError::from(ApiError::NotFound)` works via the `From` impl.
#[test]
fn test_dredge_error_from_api_error_not_found() {
let api_err = ApiError::NotFound;
let dredge_err = DredgeError::from(api_err);
assert!(matches!(
dredge_err,
DredgeError::ApiError(ApiError::NotFound)
));
}
/// Test that `DredgeError::from(ApiError::AuthorizationFailed)` works.
#[test]
fn test_dredge_error_from_api_error_authorization_failed() {
let api_err = ApiError::AuthorizationFailed;
let dredge_err = DredgeError::from(api_err);
assert!(matches!(
dredge_err,
DredgeError::ApiError(ApiError::AuthorizationFailed)
));
}
/// Test that `DredgeError::from(ApiError::MethodNotAllowed)` works.
#[test]
fn test_dredge_error_from_api_error_method_not_allowed() {
let api_err = ApiError::MethodNotAllowed;
let dredge_err = DredgeError::from(api_err);
assert!(matches!(
dredge_err,
DredgeError::ApiError(ApiError::MethodNotAllowed)
));
}
/// Test Display output for `ApiError::NotFound`.
#[test]
fn test_api_error_not_found_display() {
let err = ApiError::NotFound;
assert_eq!(err.to_string(), "Resource not found");
}
/// Test Display output for `ApiError::AuthorizationFailed`.
#[test]
fn test_api_error_authorization_failed_display() {
let err = ApiError::AuthorizationFailed;
assert_eq!(err.to_string(), "HTTP Authorization failed");
}
/// Test Display output for `ApiError::MethodNotAllowed`.
#[test]
fn test_api_error_method_not_allowed_display() {
let err = ApiError::MethodNotAllowed;
assert_eq!(err.to_string(), "Method not allowed");
}
/// Test Display output for `ApiError::UnsupportedVersion`.
#[test]
fn test_api_error_unsupported_version_display() {
let err = ApiError::UnsupportedVersion(String::from("registry/1.0"));
assert_eq!(err.to_string(), "Version Mismatch registry/1.0");
}
/// Test Display output for `ApiError::UnexpectedResponse`.
#[test]
fn test_api_error_unexpected_response_display() {
let err = ApiError::UnexpectedResponse(String::from("Missing header"));
assert_eq!(
err.to_string(),
"Unexpected response from API: Missing header"
);
}
/// Test Display output for `DredgeError::RegistryUrlError`.
#[test]
fn test_dredge_error_registry_url_error_display() {
let err = DredgeError::RegistryUrlError(String::from("bad-url"));
assert_eq!(
err.to_string(),
"Error determining registry URL from bad-url"
);
} }
} }
+58 -23
View File
@@ -1,17 +1,10 @@
/* /*
* Copyright 2023 Anthony Oteri * Copyright 2023 Anthony Oteri
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
* you may not use this file except in compliance with the License. * http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
* You may obtain a copy of the License at * http://opensource.org/licenses/MIT>, at your option. This file may not be
* * copied, modified, or distributed except according to those terms.
* 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.
*/ */
#![deny(clippy::pedantic)] #![deny(clippy::pedantic)]
@@ -31,30 +24,45 @@ pub(crate) mod cli;
mod commands; mod commands;
mod error; mod error;
/// Name of "latest" tag /// The default image tag used when no tag is specified by the caller.
const LATEST: &str = "latest"; const LATEST: &str = "latest";
/// Parse the "<REGISTRY>" argument into a complete Docker Registry URL. /// Parse the `<REGISTRY>` CLI argument into a complete Docker Registry [`Url`].
/// ///
/// This prepends the HTTPS scheme and converts the given string to a `Url` /// Accepts a bare hostname (`registry.example.com`), a host-and-port pair
/// instance. /// (`registry.example.com:5000`), or a full URL
/// (`https://registry.example.com:5000`). When no URL scheme is present,
/// `https://` is prepended automatically before parsing.
/// ///
/// If the given `host` value is already a valid URL, then it will be returned /// # Errors
/// as-is.
/// ///
/// # Errors: /// Returns [`DredgeError::RegistryUrlError`] containing the attempted URL
/// string if it cannot be parsed as a valid URL after the scheme is prepended.
/// ///
/// If there is a problem parsing the resulting string as a valid URL, a /// # Examples
/// `DredgeError::RegistryUrlError` will be returned. ///
/// ```rust,ignore
/// // Bare hostname — HTTPS is assumed
/// let url = parse_registry_arg("registry.example.com").unwrap();
/// assert_eq!(url.scheme(), "https");
///
/// // Host with port
/// let url = parse_registry_arg("registry.example.com:5000").unwrap();
/// assert_eq!(url.port(), Some(5000));
///
/// // Full URL returned as-is
/// let url = parse_registry_arg("https://registry.example.com").unwrap();
/// assert_eq!(url.as_str(), "https://registry.example.com/");
/// ```
fn parse_registry_arg(host: &str) -> Result<Url, DredgeError> { fn parse_registry_arg(host: &str) -> Result<Url, DredgeError> {
log::trace!("make_registry_url(host: {host})"); log::trace!("parse_registry_arg(host: {host})");
let mut host = String::from(host); let mut host = String::from(host);
if !host.starts_with("http://") && !host.starts_with("https://") { if !host.starts_with("http://") && !host.starts_with("https://") {
host = format!("https://{host}"); host = format!("https://{host}");
} }
Url::parse(&host).or(Err(DredgeError::RegistryUrlError(host.to_string()))) Url::parse(&host).or(Err(DredgeError::RegistryUrlError(host.clone())))
} }
#[tokio::main(flavor = "current_thread")] #[tokio::main(flavor = "current_thread")]
@@ -83,7 +91,7 @@ async fn main() -> Result<(), DredgeError> {
&mut buf, &mut buf,
&registry_url, &registry_url,
&image, &image,
&tag.unwrap_or(LATEST.to_string()), tag.as_deref().unwrap_or(LATEST),
) )
.await?; .await?;
} }
@@ -163,4 +171,31 @@ mod tests {
_ => panic!("Expected RegistryUrlError, got a different error"), _ => panic!("Expected RegistryUrlError, got a different error"),
} }
} }
/// Test that an HTTP (non-HTTPS) URL is returned as-is without prepending
/// the HTTPS scheme.
#[test]
fn test_parse_registry_arg_http_url() {
let host = "http://example.com/registry";
let result = parse_registry_arg(host);
assert!(result.is_ok());
let url = result.unwrap();
assert_eq!(url.scheme(), "http");
assert_eq!(url.host_str(), Some("example.com"));
assert_eq!(url.path(), "/registry");
}
/// Test that a trailing slash in the registry argument is preserved.
#[test]
fn test_parse_registry_arg_trailing_slash() {
let host = "example.com/registry/";
let result = parse_registry_arg(host);
assert!(result.is_ok());
let url = result.unwrap();
assert_eq!(url.scheme(), "https");
assert_eq!(url.host_str(), Some("example.com"));
assert_eq!(url.path(), "/registry/");
}
} }