mirror of
https://github.com/anthonyoteri/dredge.git
synced 2026-06-05 15:26:53 -04:00
Compare commits
34 Commits
v0.1.0
..
dbc567ef57
| Author | SHA1 | Date | |
|---|---|---|---|
| dbc567ef57 | |||
| 0d51c5034f | |||
| d37ca2de44 | |||
| f55c72aa34 | |||
| 52910538df | |||
| 01ef25b953 | |||
| d2d51b3a2d | |||
| 353fd94b55 | |||
| 9b602f70a6 | |||
| f12615a7f2 | |||
| 39c03a2b5b | |||
| f836b26930 | |||
| 0d9761178c | |||
| 358a19f6f7 | |||
| 0527b6268d | |||
| 67c02581f3 | |||
| b6fda7162f | |||
| 07746429bb | |||
| 23f1d21a2e | |||
| 5e7d0e4e1e | |||
| 600c2d86ac | |||
| 0e4219b405 | |||
| c7305f8cc4 | |||
| 02dd2ec90a | |||
| b60d433508 | |||
| 0712af9d23 | |||
| 42f8f46bd3 | |||
| 80d1acf295 | |||
| 12dd298706 | |||
| de42860be8 | |||
| b4d6002a20 | |||
| fbe43f03f1 | |||
| 48070cff1f | |||
| 13ae092b91 |
@@ -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
@@ -3,9 +3,23 @@ updates:
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
interval: "weekly"
|
||||
groups:
|
||||
patch-updates:
|
||||
update-types:
|
||||
- "patch"
|
||||
minor-updates:
|
||||
update-types:
|
||||
- "minor"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
interval: "weekly"
|
||||
groups:
|
||||
patch-updates:
|
||||
update-types:
|
||||
- "patch"
|
||||
minor-updates:
|
||||
update-types:
|
||||
- "minor"
|
||||
|
||||
@@ -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.100
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Check MSRV builds
|
||||
run: cargo check
|
||||
|
||||
|
||||
+117
-83
@@ -3,113 +3,147 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v[0-9]+.[0-9]+.[0-9]+*"
|
||||
- "v[0-9]*.[0-9]*.[0-9]*"
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: "-D warnings"
|
||||
|
||||
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
|
||||
needs: ci
|
||||
permissions:
|
||||
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:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust Toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
toolchain: stable
|
||||
components: rust-docs
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cache build dependencies
|
||||
- name: Install stable toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Run cargo doc
|
||||
run:
|
||||
cargo doc
|
||||
--bin=dredge
|
||||
--no-deps
|
||||
--all-features
|
||||
--document-private-items
|
||||
--release
|
||||
|
||||
- name: Archive the Docs
|
||||
run:
|
||||
tar --directory target/doc -czf
|
||||
dredge-${{ github.ref_name}}-docs.tar.gz
|
||||
dredge
|
||||
|
||||
- name: Upload documentation assets
|
||||
uses: shogo82148/actions-upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Install cocogitto
|
||||
uses: cocogitto/cocogitto-action@v3
|
||||
with:
|
||||
upload_url: ${{ needs.create_release.outputs.upload_url }}
|
||||
asset_name: dredge-${{ github.ref_name }}-docs.tar.gz
|
||||
asset_path: dredge-${{ github.ref_name }}-docs.tar.gz
|
||||
asset_content_type: application/gzip
|
||||
check: false
|
||||
|
||||
release_assets:
|
||||
name: Release assets
|
||||
needs: create_release
|
||||
runs-on: ${{ matrix.config.os }}
|
||||
- name: Generate release notes for this tag
|
||||
id: changelog
|
||||
run: |
|
||||
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:
|
||||
contents: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
config:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
platform: linux
|
||||
arch: x86_64
|
||||
ext: ''
|
||||
- os: macos-latest
|
||||
platform: macos
|
||||
arch: x86_64
|
||||
ext: ''
|
||||
- os: windows-latest
|
||||
platform: win
|
||||
arch: x86_64
|
||||
ext: .exe
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
artifact: dredge
|
||||
upload_name: dredge-linux-x86_64.tar.gz
|
||||
target: x86_64-unknown-linux-musl
|
||||
artifact_dir: target/x86_64-unknown-linux-musl/release
|
||||
|
||||
- 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
|
||||
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
|
||||
|
||||
- name: Run cargo build
|
||||
run: cargo build --release
|
||||
- name: Build release binary
|
||||
run: >
|
||||
cargo build --release
|
||||
${{ matrix.target != '' && format('--target {0}', matrix.target) || '' }}
|
||||
|
||||
- name: Create release assets
|
||||
run:
|
||||
tar --directory target/release -czf
|
||||
dredge-${{ github.ref_name }}-${{ matrix.config.platform }}.${{ matrix.config.arch }}.tar.gz
|
||||
dredge${{ matrix.config.ext }}
|
||||
- name: Strip binary (Unix only)
|
||||
if: runner.os != 'Windows'
|
||||
run: strip ${{ matrix.artifact_dir }}/${{ matrix.artifact }}
|
||||
|
||||
- name: Upload release assets
|
||||
uses: shogo82148/actions-upload-release-asset@v1
|
||||
- name: Ad-hoc sign binary (macOS only)
|
||||
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:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ needs.create_release.outputs.upload_url }}
|
||||
asset_name: dredge-${{ github.ref_name }}-${{ matrix.config.platform }}.${{ matrix.config.arch }}.tar.gz
|
||||
asset_path: dredge-${{ github.ref_name }}-${{ matrix.config.platform }}.${{ matrix.config.arch }}.tar.gz
|
||||
asset_content_type: application/gzip
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
<component name="CopyrightManager">
|
||||
<copyright>
|
||||
<option name="notice" value="Copyright 2023 Anthony Oteri Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your option. This file may not be copied, modified, or distributed except according to those terms." />
|
||||
<option name="myName" value="MIT OR Apache-2.0 (personal)" />
|
||||
</copyright>
|
||||
</component>
|
||||
Generated
+1
-1
@@ -1,7 +1,7 @@
|
||||
<component name="CopyrightManager">
|
||||
<settings>
|
||||
<module2copyright>
|
||||
<element module="Project Files" copyright="Apache License (personal)" />
|
||||
<element module="Project Files" copyright="MIT OR Apache-2.0 (personal)" />
|
||||
</module2copyright>
|
||||
</settings>
|
||||
</component>
|
||||
@@ -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).
|
||||
+23
-20
@@ -1,13 +1,13 @@
|
||||
[package]
|
||||
name = "dredge"
|
||||
version = "0.1.0"
|
||||
name = "dredge-tool"
|
||||
version = "1.2.0"
|
||||
edition = "2021"
|
||||
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"
|
||||
repository = "https://github.com/anthonyoteri/dredge"
|
||||
rust-version = "1.72"
|
||||
license-file = "LICENSE"
|
||||
rust-version = "1.88"
|
||||
license = "MIT OR Apache-2.0"
|
||||
keywords = [
|
||||
"docker",
|
||||
"registry",
|
||||
@@ -18,23 +18,26 @@ categories = [
|
||||
"api-bindings",
|
||||
]
|
||||
|
||||
|
||||
[[bin]]
|
||||
path = "src/main.rs"
|
||||
name = "dredge"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
async-std = { version = "1.12.0", features = ["async-attributes", "attributes", "tokio1"] }
|
||||
clap = { version = "4.4.3", features = ["derive", "env", "wrap_help"] }
|
||||
femme = "2.2.1"
|
||||
http = "0.2.9"
|
||||
indoc = "2.0.4"
|
||||
log = "0.4.20"
|
||||
reqwest = { version = "0.11.20", features = ["json", "gzip", "multipart", "native-tls-vendored"] }
|
||||
serde = { version = "1.0.188", features = ["derive"] }
|
||||
serde_toml = "0.0.1"
|
||||
serde_yaml = "0.9.25"
|
||||
thiserror = "1.0.48"
|
||||
toml = "0.8.0"
|
||||
url = { version = "2.4.1", features = ["serde"] }
|
||||
xdg = "2.5.2"
|
||||
clap = { version = "4.6", features = ["derive", "env", "wrap_help"] }
|
||||
simple_logger = { version = "5.2", features = ["timestamps", "colors", "stderr"] }
|
||||
http = "1.4"
|
||||
indoc = "2.0"
|
||||
log = "0.4"
|
||||
reqwest = { version = "0.12", features = ["json", "gzip", "multipart", "native-tls-vendored"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
thiserror = "2.0"
|
||||
url = { version = "2.5", features = ["serde"] }
|
||||
tokio = { version = "1.52", features = ["macros"] }
|
||||
serde_norway = "0.9.42"
|
||||
|
||||
[dev-dependencies]
|
||||
mockito = "1.2.0"
|
||||
mockito = "1.7"
|
||||
env_logger = "0.11"
|
||||
|
||||
@@ -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"
|
||||
+24
@@ -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.
|
||||
|
||||
@@ -1,99 +1,226 @@
|
||||
# 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
|
||||
|
||||
```shell
|
||||
Dredge is a command line tool for working with the Docker Registry V2 API.
|
||||
|
||||
Usage: dredge [OPTIONS] <REGISTRY> <COMMAND>
|
||||
|
||||
Commands:
|
||||
catalog Fetch the list of available repositories from the catalog
|
||||
tags Fetch the list of tags for a given image
|
||||
show Show detailed information about a particular image
|
||||
delete Delete a tagged image from the registry
|
||||
check Perform a simple API Version check towards the configured registry endpoint
|
||||
help Print this message or the help of the given subcommand(s)
|
||||
|
||||
Arguments:
|
||||
<REGISTRY>
|
||||
The host or host:port or full base URL of the Docker Registry
|
||||
|
||||
Options:
|
||||
--log-level[=<LEVEL>]
|
||||
[default: info]
|
||||
[possible values: trace, debug, info, warn, error, off]
|
||||
|
||||
-h, --help
|
||||
Print help (see a summary with '-h')
|
||||
|
||||
-V, --version
|
||||
Print version
|
||||
```
|
||||
### Checking the API Version
|
||||
dredge [OPTIONS] <REGISTRY> <COMMAND>
|
||||
```
|
||||
|
||||
Perform a simple API Version check towards the registry endpoint
|
||||
### `<REGISTRY>` argument format
|
||||
|
||||
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
|
||||
Usage: dredge <REGISTRY> check
|
||||
|
||||
Options:
|
||||
-h, --help Print help
|
||||
dredge registry.example.com check
|
||||
# Ok
|
||||
```
|
||||
|
||||
### 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
|
||||
Usage: dredge <REGISTRY> catalog
|
||||
|
||||
Options:
|
||||
-h, --help Print help
|
||||
dredge registry.example.com catalog
|
||||
# myorg/frontend
|
||||
# myorg/backend
|
||||
# myorg/worker
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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>
|
||||
|
||||
Arguments:
|
||||
<NAME>
|
||||
|
||||
Options:
|
||||
-h, --help Print help
|
||||
```
|
||||
dredge <REGISTRY> tags <NAME>
|
||||
```
|
||||
|
||||
### 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
|
||||
Usage: dredge <REGISTRY> show <IMAGE> [TAG]
|
||||
|
||||
Arguments:
|
||||
<IMAGE>
|
||||
[TAG]
|
||||
|
||||
Options:
|
||||
-h, --help Print help
|
||||
```sh
|
||||
dredge registry.example.com tags myorg/backend
|
||||
# latest
|
||||
# v1.0.0
|
||||
# v1.1.0
|
||||
# v2.0.0-rc1
|
||||
```
|
||||
|
||||
### Deleteing a tagged image
|
||||
---
|
||||
|
||||
Delete a tagged image from the registry
|
||||
### Showing image details
|
||||
|
||||
```shell
|
||||
Usage: dredge <REGISTRY> delete <IMAGE> <TAG>
|
||||
Show detailed manifest information for a specific tagged image, including the architecture, filesystem layers, digest, and ETag. Output is formatted as YAML.
|
||||
|
||||
Arguments:
|
||||
<IMAGE>
|
||||
<TAG>
|
||||
```
|
||||
dredge <REGISTRY> show <IMAGE> [TAG]
|
||||
```
|
||||
|
||||
Options:
|
||||
-h, --help Print help
|
||||
```
|
||||
| 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.
|
||||
|
||||
+21
-2
@@ -1,13 +1,32 @@
|
||||
# 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
|
||||
|
||||
* The delete command is currently not implemented and will return an error
|
||||
if called.
|
||||
* Docker authentication is not currently supported, and attempts to query a
|
||||
registry which requires authentication will fail.
|
||||
|
||||
## Changelog
|
||||
- v1.1.0
|
||||
|
||||
- Change License by Anthony Oteri 0e4219b
|
||||
|
||||
- v1.0.0
|
||||
|
||||
- Rename project to dredge-tool by Anthony Oteri b60d433
|
||||
- Replace async_std::test with tokio::test by Anthony Oteri 42f8f46
|
||||
- Replace async-std with tokio by Anthony Oteri 80d1acf
|
||||
- Update known issues in release notes by Anthony Oteri 12dd298
|
||||
|
||||
- v0.2.0
|
||||
|
||||
- Support deleting an image tag by Anthony Oteri fbe43f0
|
||||
- Replace femme logger with simple_logger by Anthony Oteri 13ae092
|
||||
|
||||
- v0.1.0
|
||||
|
||||
- Additional scripts for managing the release process by Anthony Oteri cfdefb2
|
||||
|
||||
@@ -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 }
|
||||
@@ -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"
|
||||
@@ -0,0 +1,4 @@
|
||||
- v0.2.0
|
||||
|
||||
- Support deleting an image tag by Anthony Oteri fbe43f0
|
||||
- Replace femme logger with simple_logger by Anthony Oteri 13ae092
|
||||
@@ -0,0 +1,6 @@
|
||||
- v1.0.0
|
||||
|
||||
- Rename project to dredge-tool by Anthony Oteri b60d433
|
||||
- Replace async_std::test with tokio::test by Anthony Oteri 42f8f46
|
||||
- Replace async-std with tokio by Anthony Oteri 80d1acf
|
||||
- Update known issues in release notes by Anthony Oteri 12dd298
|
||||
@@ -0,0 +1,3 @@
|
||||
- v1.1.0
|
||||
|
||||
- Change License by Anthony Oteri 0e4219b
|
||||
@@ -1,9 +1,12 @@
|
||||
# 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
|
||||
|
||||
* The delete command is currently not implemented and will return an error
|
||||
if called.
|
||||
* Docker authentication is not currently supported, and attempts to query a
|
||||
registry which requires authentication will fail.
|
||||
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
#
|
||||
# Copyright 2023 Anthony Oteri
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
#!/usr/bin/bash
|
||||
|
||||
#
|
||||
# Copyright 2023 Anthony Oteri
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
|
||||
# http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
|
||||
# http://opensource.org/licenses/MIT>, at your option. This file may not be
|
||||
# copied, modified, or distributed except according to those terms.
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
|
||||
+8
-13
@@ -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");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
|
||||
# http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
|
||||
# http://opensource.org/licenses/MIT>, at your option. This file may not be
|
||||
# copied, modified, or distributed except according to those terms.
|
||||
#
|
||||
|
||||
#!/usr/bin/bash -e
|
||||
set -e
|
||||
|
||||
version=$1
|
||||
previous=$2
|
||||
|
||||
@@ -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");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
|
||||
# http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
|
||||
# http://opensource.org/licenses/MIT>, at your option. This file may not be
|
||||
# copied, modified, or distributed except according to those terms.
|
||||
#
|
||||
|
||||
#!/usr/bin/bash
|
||||
set -e
|
||||
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
|
||||
|
||||
+489
-121
@@ -1,42 +1,82 @@
|
||||
/*
|
||||
* Copyright 2023 Anthony Oteri
|
||||
* Copyright 2023 Anthony Oteri
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
* Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
|
||||
* http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
|
||||
* http://opensource.org/licenses/MIT>, at your option. This file may not be
|
||||
* copied, modified, or distributed except according to those terms.
|
||||
*/
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::header;
|
||||
use reqwest::header::HeaderValue;
|
||||
use reqwest::StatusCode;
|
||||
use serde::Deserialize;
|
||||
use url::Url;
|
||||
|
||||
use crate::error::ApiError;
|
||||
|
||||
/// Iterate over a paginated result set, collecting and returning the response
|
||||
/// set.
|
||||
/// The MIME type for Docker Image Manifest V2, Schema 2.
|
||||
///
|
||||
/// The Docker Registry API specifies that when making a GET request, the
|
||||
/// response will be paginated using a Link response header for the Next URI.
|
||||
/// The URL will be encoded using [RFC5988](https://tools.ietf.org/html/rfc5988)
|
||||
/// 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";
|
||||
|
||||
/// Connect timeout applied when establishing a TCP connection.
|
||||
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.
|
||||
///
|
||||
/// This function will continuously request the "Next" link as long as it is
|
||||
/// returned, collecting and returning the deserialized response bodies as a
|
||||
/// Vec<T>.
|
||||
/// All outbound HTTP requests should use this client to prevent hung
|
||||
/// connections from blocking the process indefinitely.
|
||||
///
|
||||
/// # Errors:
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an `ApiError` if there is a problem constructing the URL from the
|
||||
/// configured `registry_url` base and the given `path`, or if there is an
|
||||
/// error deserializing the HTTP response body as JSON, or if there is an
|
||||
/// error parsing the `Link` header value as an RFC5988 URL.
|
||||
/// 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.
|
||||
///
|
||||
/// The Docker Registry HTTP API V2 paginates list responses using a `Link`
|
||||
/// response header whose value is an [RFC 5988](https://tools.ietf.org/html/rfc5988)
|
||||
/// URL pointing to the next page. This function follows every `Link` header
|
||||
/// 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>>(
|
||||
client: &reqwest::Client,
|
||||
origin: &Url,
|
||||
path: &str,
|
||||
) -> Result<Vec<T>, ApiError> {
|
||||
@@ -47,11 +87,14 @@ pub async fn fetch_paginated<T: for<'de> Deserialize<'de>>(
|
||||
loop {
|
||||
let url = origin.join(&next_path)?;
|
||||
|
||||
let resp = reqwest::get(url).await?;
|
||||
let resp = client.get(url).send().await?;
|
||||
parse_response_status(&resp)?;
|
||||
|
||||
let headers = resp.headers().clone();
|
||||
|
||||
responses.push(resp.json().await?);
|
||||
|
||||
if let Some(p) = parse_rfc5988(headers.get(http::header::LINK))? {
|
||||
if let Some(p) = parse_rfc5988(headers.get(header::LINK))? {
|
||||
next_path = p;
|
||||
} else {
|
||||
break;
|
||||
@@ -60,118 +103,146 @@ pub async fn fetch_paginated<T: for<'de> Deserialize<'de>>(
|
||||
Ok(responses)
|
||||
}
|
||||
|
||||
/// Given an optional header value possibly containing an RFC5988 formatted
|
||||
/// URL, parse said URL into a `String`.
|
||||
/// Extract the URL from an optional RFC 5988 `Link` header value.
|
||||
///
|
||||
/// If the `header_value` does not contain a correctly formatted RFC5988 URL,
|
||||
/// or if the `header_value` is not properly formatted containing a URL
|
||||
/// surrounded by angle brackets, separated from the link relation by a ';'
|
||||
/// character, the `None` variant will be returned.
|
||||
/// The Docker Registry API uses `Link` headers of the form
|
||||
/// `<URL>; rel="next"` to signal the next page of a paginated result.
|
||||
/// This function extracts the URL between the angle brackets from the
|
||||
/// 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
|
||||
/// supplied header value.
|
||||
fn parse_rfc5988(header_value: Option<&http::HeaderValue>) -> Result<Option<String>, ApiError> {
|
||||
/// # Errors
|
||||
///
|
||||
/// 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:?})");
|
||||
|
||||
if let Some(link_value) = header_value {
|
||||
let link_str = link_value.to_str()?;
|
||||
let parts: Vec<&str> = link_str.split(';').collect();
|
||||
if let Some(url_part) = parts.first() {
|
||||
if let Some(path) = url_part
|
||||
.trim()
|
||||
.strip_prefix('<')
|
||||
.and_then(|s| s.strip_suffix('>'))
|
||||
{
|
||||
return Ok(Some(String::from(path)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(link_value) = header_value else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(None)
|
||||
let link_str = link_value.to_str()?;
|
||||
// RFC 5988 link header format: `<URL>; rel="next"` — take everything
|
||||
// before the first ';', strip the surrounding angle brackets.
|
||||
let url_part = link_str.split_once(';').map_or(link_str, |(url, _)| url);
|
||||
let path = url_part
|
||||
.trim()
|
||||
.strip_prefix('<')
|
||||
.and_then(|s| s.strip_suffix('>'));
|
||||
|
||||
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)
|
||||
/// 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.
|
||||
/// Returns `Ok(())` when the header is correct.
|
||||
///
|
||||
/// If a 401 Unauthorized response is returned, the client should take action
|
||||
/// based on the contents of the "WWW-Authenticate" header and try the endpoint
|
||||
/// again. Depending on access control setup, the client may still have to
|
||||
/// authenticate against different resources, even if this check succeeds.
|
||||
/// # Errors
|
||||
///
|
||||
/// If 404 Not Found response status, or other unexpected status, is returned,
|
||||
/// the client should proceed with the assumption that the registry does not
|
||||
/// implement V2 of the API.
|
||||
///
|
||||
/// When a 200 OK or 401 Unauthorized response is returned, the
|
||||
/// "Docker-Distribution-API-Version" header should be set to "registry/2.0".
|
||||
/// Clients may require this header value to determine if the endpoint serves
|
||||
/// this API. When this header is omitted, clients may fallback to an older
|
||||
/// API version.
|
||||
///
|
||||
/// # Errors:
|
||||
///
|
||||
/// Returns an `ApiError` on the following conditions:
|
||||
///
|
||||
/// * There is an error parsing the "Docker-Distribution-API-Version" header.
|
||||
/// * The value of the above header is not the expected result.
|
||||
/// * The above header is missing from the response.
|
||||
/// * A non 200 HTTP response status code is returned.
|
||||
pub fn parse_response_status(response: &reqwest::Response) -> Result<(), ApiError> {
|
||||
log::trace!("parse_response_status(response: {response:?})");
|
||||
|
||||
match response.status() {
|
||||
http::StatusCode::OK => {
|
||||
let headers = response.headers();
|
||||
if let Some(header_value) = headers.get("Docker-Distribution-API-Version") {
|
||||
if header_value.to_str()? == "registry/2.0" {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiError::UnsupportedVersion(header_value.to_str()?.into()))
|
||||
}
|
||||
} else {
|
||||
Err(ApiError::UnexpectedResponse(
|
||||
"Missing version header".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
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)
|
||||
} else {
|
||||
Err(ApiError::UnsupportedVersion(header_value.to_str()?.into()))
|
||||
}
|
||||
} else {
|
||||
Err(ApiError::UnexpectedResponse(
|
||||
"Missing version header".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
http::StatusCode::NOT_FOUND => Err(ApiError::NotFound),
|
||||
_ => Err(ApiError::UnexpectedResponse(
|
||||
"Undocumented status code".into(),
|
||||
/// * [`ApiError::ResponseHeaderParseError`] — the header value contains
|
||||
/// non-UTF-8 bytes.
|
||||
/// * [`ApiError::UnsupportedVersion`] — the header is present but its value
|
||||
/// is not `"registry/2.0"`.
|
||||
/// * [`ApiError::UnexpectedResponse`] — the header is entirely absent.
|
||||
fn check_api_version_header(response: &reqwest::Response) -> Result<(), ApiError> {
|
||||
match response.headers().get("Docker-Distribution-API-Version") {
|
||||
Some(v) if v.to_str()? == "registry/2.0" => Ok(()),
|
||||
Some(v) => Err(ApiError::UnsupportedVersion(v.to_str()?.into())),
|
||||
None => Err(ApiError::UnexpectedResponse(
|
||||
"Missing version header".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the HTTP status code of a Docker Registry API response.
|
||||
///
|
||||
/// The Docker Registry API contract requires that `2xx` responses include a
|
||||
/// `Docker-Distribution-API-Version: registry/2.0` header. `401 Unauthorized`
|
||||
/// responses must also carry this header; when they do the caller should
|
||||
/// authenticate and retry. All other non-success codes are treated as errors.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// * [`ApiError::ResponseHeaderParseError`] — the `Docker-Distribution-API-Version`
|
||||
/// header value contains non-UTF-8 bytes (only checked on `2xx` and `401`).
|
||||
/// * [`ApiError::UnsupportedVersion`] — a `2xx` or `401` response contains the
|
||||
/// version header with a value other than `"registry/2.0"`.
|
||||
/// * [`ApiError::UnexpectedResponse`] — a `2xx` or `401` response is missing the
|
||||
/// 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> {
|
||||
log::trace!("parse_response_status(response: {response:?})");
|
||||
|
||||
match response.status() {
|
||||
StatusCode::OK | StatusCode::ACCEPTED => check_api_version_header(response),
|
||||
StatusCode::UNAUTHORIZED => {
|
||||
check_api_version_header(response)?;
|
||||
Err(ApiError::AuthorizationFailed)
|
||||
}
|
||||
StatusCode::NOT_FOUND => Err(ApiError::NotFound),
|
||||
StatusCode::METHOD_NOT_ALLOWED => Err(ApiError::MethodNotAllowed),
|
||||
e => Err(ApiError::UnexpectedResponse(format!(
|
||||
"Undocumented status code: {e:?}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the content digest for the manifest at `url`.
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// * [`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> {
|
||||
log::trace!("get_digest(client: {client:?}, url: {url}");
|
||||
let resp = client
|
||||
.head(url.as_ref())
|
||||
.header(header::ACCEPT, MANIFEST_V2)
|
||||
.send()
|
||||
.await?;
|
||||
parse_response_status(&resp)?;
|
||||
|
||||
let headers = resp.headers();
|
||||
Ok(String::from(
|
||||
headers
|
||||
.get("docker-content-digest")
|
||||
.ok_or(ApiError::UnexpectedResponse(String::from(
|
||||
"Missing docker-content-digest header",
|
||||
)))?
|
||||
.to_str()?,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use http::header::HeaderValue;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Test parsing a valid RFC5988 header value.
|
||||
///
|
||||
/// Attempt to parse a valid RFC5988 header value, and ensure that the
|
||||
/// parsed URL was returned as expected.
|
||||
#[async_std::test]
|
||||
#[tokio::test]
|
||||
async fn test_parse_rfc5988_valid() {
|
||||
// Mock a valid RFC5988 header value
|
||||
let valid_header_value =
|
||||
@@ -189,16 +260,313 @@ mod tests {
|
||||
///
|
||||
/// Attempt to parse an invalid string as RFC5988, ensuring that the `None`
|
||||
/// variant is returned.
|
||||
#[async_std::test]
|
||||
#[tokio::test]
|
||||
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");
|
||||
|
||||
// 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();
|
||||
|
||||
// Assert that the function returned the expected URL as Some(String)
|
||||
// Assert that the function returned None
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
/// Test that `parse_rfc5988` with `None` input returns `Ok(None)`.
|
||||
///
|
||||
/// 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 correct digest is returned and that the mock server had the expected
|
||||
/// interactions.
|
||||
#[tokio::test]
|
||||
async fn test_get_digest() -> Result<(), ApiError> {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let path = "/v2/foo/manifests/latest";
|
||||
|
||||
// Mock the HTTP response for the Docker Registry API
|
||||
let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL");
|
||||
let mock_response = server
|
||||
.mock("HEAD", path)
|
||||
.match_header(http::header::ACCEPT.as_str(), MANIFEST_V2)
|
||||
.with_status(http::status::StatusCode::OK.as_u16().into())
|
||||
.with_header(http::header::CONTENT_TYPE.as_str(), "application/json")
|
||||
.with_header("Docker-Distribution-API-Version", "registry/2.0")
|
||||
.with_header(
|
||||
"docker-content-digest",
|
||||
"sha256:0259571889ac87efbfca5b79a0abe9baf626d058ec5f9a5744bace2229d9ed50",
|
||||
)
|
||||
.with_header(
|
||||
"etag",
|
||||
"sha256:0259571889ac87efbfca5b79a0abe9baf626d058ec5f9a5744bace2229d9ed50",
|
||||
)
|
||||
.create();
|
||||
|
||||
let url = registry_url.join(path)?;
|
||||
let client = reqwest::Client::new();
|
||||
let result = get_digest(&client, &url).await;
|
||||
|
||||
assert!(result.is_ok(), "{:?}", result.unwrap_err());
|
||||
assert_eq!(
|
||||
result.unwrap(),
|
||||
*"sha256:0259571889ac87efbfca5b79a0abe9baf626d058ec5f9a5744bace2229d9ed50"
|
||||
);
|
||||
|
||||
mock_response.assert();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test `get_digest` when the `docker-content-digest` header is missing.
|
||||
///
|
||||
/// The function must return `ApiError::UnexpectedResponse` when the registry
|
||||
/// omits the `docker-content-digest` header from an otherwise successful
|
||||
/// `HEAD` response.
|
||||
#[tokio::test]
|
||||
async fn test_get_digest_missing_digest_header() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let path = "/v2/foo/manifests/latest";
|
||||
|
||||
let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL");
|
||||
let mock_response = server
|
||||
.mock("HEAD", path)
|
||||
.with_status(http::status::StatusCode::OK.as_u16().into())
|
||||
.with_header("Docker-Distribution-API-Version", "registry/2.0")
|
||||
// No docker-content-digest header
|
||||
.create();
|
||||
|
||||
let url = registry_url.join(path)?;
|
||||
let client = reqwest::Client::new();
|
||||
let result = get_digest(&client, &url).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
matches!(result.unwrap_err(), ApiError::UnexpectedResponse(_)),
|
||||
"Expected ApiError::UnexpectedResponse"
|
||||
);
|
||||
|
||||
mock_response.assert();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test `fetch_paginated` happy path — single page with no `Link` header.
|
||||
///
|
||||
/// When the registry returns a single page (no pagination link), the
|
||||
/// function should return a `Vec` containing exactly one parsed response.
|
||||
#[tokio::test]
|
||||
async fn test_fetch_paginated_single_page() -> Result<(), Box<dyn std::error::Error>> {
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Resp {
|
||||
items: Vec<String>,
|
||||
}
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let path = "/v2/test/list";
|
||||
|
||||
let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL");
|
||||
let mock_response = server
|
||||
.mock("GET", path)
|
||||
.with_status(http::status::StatusCode::OK.as_u16().into())
|
||||
.with_header(http::header::CONTENT_TYPE.as_str(), "application/json")
|
||||
.with_header("Docker-Distribution-API-Version", "registry/2.0")
|
||||
.with_body(r#"{"items": ["a", "b", "c"]}"#)
|
||||
.create();
|
||||
|
||||
let client = build_client().expect("Failed to build client");
|
||||
let result: Vec<Resp> = fetch_paginated(&client, ®istry_url, path).await?;
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].items, vec!["a", "b", "c"]);
|
||||
|
||||
mock_response.assert();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that `fetch_paginated` propagates a JSON decode error on an empty body.
|
||||
///
|
||||
/// When the registry returns a success status but no body, the JSON
|
||||
/// deserializer will fail. The error must be surfaced to the caller rather
|
||||
/// than silently swallowed.
|
||||
#[tokio::test]
|
||||
async fn test_fetch_paginated_empty_body_returns_error() {
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Resp {
|
||||
#[allow(dead_code)]
|
||||
items: Vec<String>,
|
||||
}
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let path = "/v2/test/empty";
|
||||
|
||||
let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL");
|
||||
server
|
||||
.mock("GET", path)
|
||||
.with_status(http::status::StatusCode::OK.as_u16().into())
|
||||
.with_header("Docker-Distribution-API-Version", "registry/2.0")
|
||||
// No body — JSON deserialisation must fail and be propagated.
|
||||
.create();
|
||||
|
||||
let client = build_client().expect("Failed to build client");
|
||||
let result: Result<Vec<Resp>, _> = fetch_paginated(&client, ®istry_url, path).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Expected an error on empty body but got Ok"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test `parse_response_status` with `UNAUTHORIZED` and valid version header
|
||||
/// returns `ApiError::AuthorizationFailed`.
|
||||
#[tokio::test]
|
||||
async fn test_parse_response_status_unauthorized_valid_version() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let path = "/v2/";
|
||||
|
||||
let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL");
|
||||
server
|
||||
.mock("GET", path)
|
||||
.with_status(http::status::StatusCode::UNAUTHORIZED.as_u16().into())
|
||||
.with_header("Docker-Distribution-API-Version", "registry/2.0")
|
||||
.create();
|
||||
|
||||
let url = registry_url.join(path).expect("Failed to join URL");
|
||||
let resp = reqwest::get(url).await.expect("Request failed");
|
||||
let result = parse_response_status(&resp);
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(ApiError::AuthorizationFailed)),
|
||||
"Expected AuthorizationFailed, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test `parse_response_status` with `UNAUTHORIZED` and wrong version header
|
||||
/// returns `ApiError::UnsupportedVersion`.
|
||||
#[tokio::test]
|
||||
async fn test_parse_response_status_unauthorized_wrong_version() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let path = "/v2/";
|
||||
|
||||
let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL");
|
||||
server
|
||||
.mock("GET", path)
|
||||
.with_status(http::status::StatusCode::UNAUTHORIZED.as_u16().into())
|
||||
.with_header("Docker-Distribution-API-Version", "registry/1.0")
|
||||
.create();
|
||||
|
||||
let url = registry_url.join(path).expect("Failed to join URL");
|
||||
let resp = reqwest::get(url).await.expect("Request failed");
|
||||
let result = parse_response_status(&resp);
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(ApiError::UnsupportedVersion(_))),
|
||||
"Expected UnsupportedVersion, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test `parse_response_status` with `UNAUTHORIZED` and missing version header
|
||||
/// returns `ApiError::UnexpectedResponse`.
|
||||
#[tokio::test]
|
||||
async fn test_parse_response_status_unauthorized_missing_version() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let path = "/v2/";
|
||||
|
||||
let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL");
|
||||
server
|
||||
.mock("GET", path)
|
||||
.with_status(http::status::StatusCode::UNAUTHORIZED.as_u16().into())
|
||||
// No Docker-Distribution-API-Version header
|
||||
.create();
|
||||
|
||||
let url = registry_url.join(path).expect("Failed to join URL");
|
||||
let resp = reqwest::get(url).await.expect("Request failed");
|
||||
let result = parse_response_status(&resp);
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(ApiError::UnexpectedResponse(_))),
|
||||
"Expected UnexpectedResponse, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test `parse_response_status` with `NOT_FOUND` returns `ApiError::NotFound`.
|
||||
#[tokio::test]
|
||||
async fn test_parse_response_status_not_found() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let path = "/v2/";
|
||||
|
||||
let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL");
|
||||
server
|
||||
.mock("GET", path)
|
||||
.with_status(http::status::StatusCode::NOT_FOUND.as_u16().into())
|
||||
.create();
|
||||
|
||||
let url = registry_url.join(path).expect("Failed to join URL");
|
||||
let resp = reqwest::get(url).await.expect("Request failed");
|
||||
let result = parse_response_status(&resp);
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(ApiError::NotFound)),
|
||||
"Expected NotFound, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test `parse_response_status` with `METHOD_NOT_ALLOWED` returns
|
||||
/// `ApiError::MethodNotAllowed`.
|
||||
#[tokio::test]
|
||||
async fn test_parse_response_status_method_not_allowed() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let path = "/v2/";
|
||||
|
||||
let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL");
|
||||
server
|
||||
.mock("GET", path)
|
||||
.with_status(http::status::StatusCode::METHOD_NOT_ALLOWED.as_u16().into())
|
||||
.create();
|
||||
|
||||
let url = registry_url.join(path).expect("Failed to join URL");
|
||||
let resp = reqwest::get(url).await.expect("Request failed");
|
||||
let result = parse_response_status(&resp);
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(ApiError::MethodNotAllowed)),
|
||||
"Expected MethodNotAllowed, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test `parse_response_status` with an unexpected status code returns
|
||||
/// `ApiError::UnexpectedResponse`.
|
||||
#[tokio::test]
|
||||
async fn test_parse_response_status_unexpected_status() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let path = "/v2/";
|
||||
|
||||
let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL");
|
||||
server
|
||||
.mock("GET", path)
|
||||
.with_status(
|
||||
http::status::StatusCode::INTERNAL_SERVER_ERROR
|
||||
.as_u16()
|
||||
.into(),
|
||||
)
|
||||
.create();
|
||||
|
||||
let url = registry_url.join(path).expect("Failed to join URL");
|
||||
let resp = reqwest::get(url).await.expect("Request failed");
|
||||
let result = parse_response_status(&resp);
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(ApiError::UnexpectedResponse(_))),
|
||||
"Expected UnexpectedResponse, got {result:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+110
-27
@@ -1,38 +1,40 @@
|
||||
/*
|
||||
* Copyright 2023 Anthony Oteri
|
||||
* Copyright 2023 Anthony Oteri
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
* Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
|
||||
* http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
|
||||
* http://opensource.org/licenses/MIT>, at your option. This file may not be
|
||||
* copied, modified, or distributed except according to those terms.
|
||||
*/
|
||||
|
||||
#![allow(unused_imports)]
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Args;
|
||||
use clap::Parser;
|
||||
use clap::Subcommand;
|
||||
use clap::ValueEnum;
|
||||
|
||||
/// Dredge is a command line tool for working with the Docker Registry
|
||||
/// V2 API.
|
||||
/// Command-line interface for `dredge`.
|
||||
///
|
||||
/// `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)]
|
||||
#[command(name = "dredge", version, author)]
|
||||
#[command(about, long_about)]
|
||||
pub(crate) struct Cli {
|
||||
/// The subcommand to execute.
|
||||
#[command(subcommand)]
|
||||
pub command: Commands,
|
||||
|
||||
/// Minimum log level for messages written to stderr.
|
||||
///
|
||||
/// Possible values: `trace`, `debug`, `info`, `warn`, `error`, `off`.
|
||||
/// Defaults to `info`.
|
||||
#[arg(
|
||||
long = "log-level",
|
||||
require_equals = true,
|
||||
@@ -44,17 +46,32 @@ pub(crate) struct Cli {
|
||||
)]
|
||||
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,
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
pub enum LogLevel {
|
||||
/// Extremely verbose output, including internal trace points.
|
||||
Trace,
|
||||
/// Verbose output useful for debugging.
|
||||
Debug,
|
||||
/// Informational messages (default).
|
||||
Info,
|
||||
/// Warnings about potentially unexpected conditions.
|
||||
Warn,
|
||||
/// Only error messages.
|
||||
Error,
|
||||
/// Suppress all log output.
|
||||
Off,
|
||||
}
|
||||
|
||||
@@ -71,28 +88,94 @@ impl From<LogLevel> for log::LevelFilter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Available `dredge` subcommands.
|
||||
#[derive(Debug, Subcommand, PartialEq, Eq)]
|
||||
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,
|
||||
|
||||
/// 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)]
|
||||
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)]
|
||||
Show {
|
||||
/// The repository name of the image to inspect (e.g. `myorg/backend`).
|
||||
image: String,
|
||||
/// The tag to inspect. Defaults to `latest` when omitted.
|
||||
#[arg(default_missing_value = "latest")]
|
||||
tag: Option<String>,
|
||||
},
|
||||
|
||||
/// 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)]
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
+254
-130
@@ -1,17 +1,10 @@
|
||||
/*
|
||||
* Copyright 2023 Anthony Oteri
|
||||
* Copyright 2023 Anthony Oteri
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
* Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
|
||||
* http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
|
||||
* http://opensource.org/licenses/MIT>, at your option. This file may not be
|
||||
* copied, modified, or distributed except according to those terms.
|
||||
*/
|
||||
|
||||
use std::io::Write;
|
||||
@@ -23,78 +16,164 @@ use url::Url;
|
||||
use crate::api;
|
||||
use crate::error::ApiError;
|
||||
|
||||
/// Handler for the `Catalog` endpoint
|
||||
/// Deserialized body of a `/v2/_catalog` response page.
|
||||
#[derive(Deserialize)]
|
||||
struct CatalogResponse {
|
||||
repositories: Vec<String>,
|
||||
}
|
||||
|
||||
/// Deserialized body of a `/v2/<name>/tags/list` response page.
|
||||
#[derive(Deserialize)]
|
||||
struct TagsResponse {
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// A single filesystem layer entry within a V1 image manifest.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct FsLayer {
|
||||
blob_sum: String,
|
||||
}
|
||||
|
||||
/// Deserialized body of a `/v2/<image>/manifests/<tag>` response, augmented
|
||||
/// with the `digest` and `etag` values extracted from response headers.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct ManifestResponse {
|
||||
name: String,
|
||||
tag: String,
|
||||
architecture: String,
|
||||
|
||||
#[serde(rename = "fsLayers")]
|
||||
fslayers: Vec<FsLayer>,
|
||||
|
||||
/// Content digest from the `docker-content-digest` response header.
|
||||
#[serde(skip_deserializing)]
|
||||
digest: String,
|
||||
|
||||
/// `ETag` value from the response header (quotes stripped), or the digest
|
||||
/// when the `ETag` header is absent.
|
||||
#[serde(skip_deserializing)]
|
||||
etag: String,
|
||||
}
|
||||
|
||||
/// Fetch all repository names from the registry catalog and write them to `buf`.
|
||||
///
|
||||
/// Fetch the list of repository names from the Docker Registry API, and
|
||||
/// simply print the resulting names to stdout.
|
||||
/// Queries `/v2/_catalog` via [`api::fetch_paginated`], collects all pages,
|
||||
/// and writes one repository name per line to the provided writer.
|
||||
///
|
||||
/// # Errors:
|
||||
/// # Arguments
|
||||
///
|
||||
/// Returns an `ApiError` if there is a problem fetching or parsing the
|
||||
/// responses from the Docker Registry API.
|
||||
/// * `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> {
|
||||
#[derive(Deserialize)]
|
||||
struct Response {
|
||||
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();
|
||||
let client = api::build_client()?;
|
||||
let responses: Vec<CatalogResponse> =
|
||||
api::fetch_paginated(&client, registry_url, "v2/_catalog").await?;
|
||||
|
||||
for repository in repository_list {
|
||||
writeln!(buf, "{repository}")?;
|
||||
for repo in responses.iter().flat_map(|r| r.repositories.iter()) {
|
||||
writeln!(buf, "{repo}")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handler for the `Tags` endpoint
|
||||
/// Fetch all tags for an image from the registry and write them to `buf`.
|
||||
///
|
||||
/// Fetch the list of tags names for a given image from the Docker Registry API, and
|
||||
/// simply print the resulting names to stdout.
|
||||
/// Queries `/v2/<name>/tags/list` via [`api::fetch_paginated`], collects all
|
||||
/// pages, and writes one tag name per line to the provided writer.
|
||||
///
|
||||
/// # Errors:
|
||||
/// # Arguments
|
||||
///
|
||||
/// Returns an `ApiError` if there is a problem fetching or parsing the
|
||||
/// responses from the Docker Registry API.
|
||||
/// * `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> {
|
||||
#[derive(Deserialize)]
|
||||
struct Response {
|
||||
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();
|
||||
let client = api::build_client()?;
|
||||
let responses: Vec<TagsResponse> =
|
||||
api::fetch_paginated(&client, registry_url, &format!("/v2/{name}/tags/list")).await?;
|
||||
|
||||
for tag in tag_list {
|
||||
for tag in responses.iter().flat_map(|r| r.tags.iter()) {
|
||||
writeln!(buf, "{tag}")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handler function for showing manifest details
|
||||
/// Fetch and display the manifest for a tagged image.
|
||||
///
|
||||
/// # Errors:
|
||||
/// 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`.
|
||||
///
|
||||
/// 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.
|
||||
/// 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,
|
||||
@@ -102,69 +181,79 @@ pub async fn show_handler(
|
||||
image: &str,
|
||||
tag: &str,
|
||||
) -> Result<(), ApiError> {
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct FsLayer {
|
||||
blob_sum: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Response {
|
||||
name: String,
|
||||
tag: String,
|
||||
architecture: String,
|
||||
|
||||
#[serde(rename = "fsLayers")]
|
||||
fslayers: Vec<FsLayer>,
|
||||
|
||||
#[serde(skip_deserializing)]
|
||||
digest: String,
|
||||
|
||||
#[serde(skip_deserializing)]
|
||||
etag: String,
|
||||
}
|
||||
log::trace!("show_handler(registry_url: {registry_url:?}, image: {image}, tag: {tag})");
|
||||
let path = format!("/v2/{image}/manifests/{tag}");
|
||||
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)?;
|
||||
|
||||
let headers = resp.headers();
|
||||
let digest: String = String::from(
|
||||
headers
|
||||
.get("docker-content-digest")
|
||||
.ok_or(ApiError::UnexpectedResponse(String::from(
|
||||
"Missing docker-content-digest header",
|
||||
)))?
|
||||
.to_str()?,
|
||||
);
|
||||
let digest = headers
|
||||
.get("docker-content-digest")
|
||||
.ok_or_else(|| ApiError::UnexpectedResponse("Missing docker-content-digest header".into()))?
|
||||
.to_str()?
|
||||
.to_owned();
|
||||
|
||||
let etag: String = String::from(
|
||||
headers
|
||||
.get("etag")
|
||||
.ok_or(ApiError::UnexpectedResponse(String::from(
|
||||
"Missing etag header",
|
||||
)))?
|
||||
.to_str()?
|
||||
.strip_prefix("'\"")
|
||||
.and_then(|s| s.strip_suffix("\"'"))
|
||||
.unwrap_or(&digest),
|
||||
);
|
||||
// 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: Response = resp.json().await?;
|
||||
let mut body: ManifestResponse = resp.json().await?;
|
||||
body.digest = digest;
|
||||
body.etag = etag;
|
||||
|
||||
serde_yaml::to_writer(buf, &body)?;
|
||||
serde_norway::to_writer(buf, &body)?;
|
||||
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
|
||||
/// manifest digest, or if there is a problem deleting the manifest from the
|
||||
/// Docker Registry API.
|
||||
/// The registry must have storage deletion enabled. Set the environment
|
||||
/// variable `REGISTRY_STORAGE_DELETE_ENABLED=true` on the registry container.
|
||||
/// 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)]
|
||||
pub async fn delete_handler(
|
||||
_buf: &mut dyn Write,
|
||||
@@ -173,25 +262,53 @@ pub async fn delete_handler(
|
||||
tag: &str,
|
||||
) -> Result<(), ApiError> {
|
||||
log::trace!("delete_handler(registry_url: {registry_url:?}, image: {image}, tag: {tag})");
|
||||
todo!()
|
||||
|
||||
let client = api::build_client()?;
|
||||
let url = registry_url.join(&format!("/v2/{image}/manifests/{tag}"))?;
|
||||
let digest = api::get_digest(&client, &url).await?;
|
||||
|
||||
log::debug!("Deleting digest {digest}");
|
||||
let url = registry_url.join(&format!("/v2/{image}/manifests/{digest}"))?;
|
||||
let resp = client.delete(url).send().await?;
|
||||
api::parse_response_status(&resp)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Path to the Docker Registry APIs "api version check" endpoint.
|
||||
|
||||
/// Handler for the API Version Check.
|
||||
/// Verify that the registry endpoint implements Docker Distribution API v2.
|
||||
///
|
||||
/// # 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
|
||||
/// endpoint or if the required version is not supported.
|
||||
/// # 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 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> {
|
||||
log::trace!("check_handler(registry_url: {registry_url:?})");
|
||||
|
||||
let path = "/v2";
|
||||
let url = registry_url.join(path)?;
|
||||
|
||||
let response = reqwest::get(url).await?;
|
||||
api::parse_response_status(&response)?;
|
||||
let client = api::build_client()?;
|
||||
let resp = client.get(url).send().await?;
|
||||
api::parse_response_status(&resp)?;
|
||||
writeln!(buf, "Ok")?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -212,7 +329,7 @@ mod tests {
|
||||
/// This test spins up a mock server, and makes a request to the catalog
|
||||
/// endpoint. It checks that the handler both called the request the
|
||||
/// expected number of times, and did not return an error.
|
||||
#[async_std::test]
|
||||
#[tokio::test]
|
||||
async fn test_catalog_handler() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let path = "/v2/_catalog";
|
||||
@@ -222,12 +339,13 @@ mod tests {
|
||||
.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#"{"repositories": ["image1", "image2", "image3"]}"#)
|
||||
.create();
|
||||
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
let result = catalog_handler(&mut buf, ®istry_url).await;
|
||||
assert!(result.is_ok());
|
||||
assert!(result.is_ok(), "{:?}", result.unwrap_err());
|
||||
assert_eq!(String::from_utf8(buf).unwrap(), *"image1\nimage2\nimage3\n");
|
||||
|
||||
mock_response.assert();
|
||||
@@ -240,7 +358,7 @@ mod tests {
|
||||
/// should follow, resulting in the combined list. It checks that the
|
||||
/// handler both called the request the expected number of times, and did
|
||||
/// not return an error.
|
||||
#[async_std::test]
|
||||
#[tokio::test]
|
||||
async fn test_catalog_handler_with_pagination() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let path = "/v2/_catalog";
|
||||
@@ -251,9 +369,10 @@ mod tests {
|
||||
.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_header(
|
||||
http::header::LINK.as_str(),
|
||||
&format!(r#"<{path2}>; rel=next"#),
|
||||
&format!(r"<{path2}>; rel=next"),
|
||||
)
|
||||
.with_body(r#"{"repositories": ["image1", "image2"]}"#)
|
||||
.create();
|
||||
@@ -262,12 +381,13 @@ mod tests {
|
||||
.mock("GET", path2)
|
||||
.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#"{"repositories": ["image3"]}"#)
|
||||
.create();
|
||||
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
let result = catalog_handler(&mut buf, ®istry_url).await;
|
||||
assert!(result.is_ok());
|
||||
assert!(result.is_ok(), "{:?}", result.unwrap_err());
|
||||
assert_eq!(String::from_utf8(buf).unwrap(), *"image1\nimage2\nimage3\n");
|
||||
|
||||
mock_response.assert();
|
||||
@@ -279,7 +399,7 @@ mod tests {
|
||||
/// This test spins up a mock server, and makes a request to the tags
|
||||
/// endpoint. It checks that the handler both called the request the
|
||||
/// expected number of times, and did not return an error.
|
||||
#[async_std::test]
|
||||
#[tokio::test]
|
||||
async fn test_tags_handler() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let path = "/v2/some_image/tags/list";
|
||||
@@ -290,25 +410,26 @@ mod tests {
|
||||
.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#"{"tags": ["tag1", "tag2", "tag3"]}"#)
|
||||
.create();
|
||||
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
let result = tags_handler(&mut buf, ®istry_url, "some_image").await;
|
||||
assert!(result.is_ok());
|
||||
assert!(result.is_ok(), "{:?}", result.unwrap_err());
|
||||
assert_eq!(String::from_utf8(buf).unwrap(), *"tag1\ntag2\ntag3\n");
|
||||
|
||||
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
|
||||
/// should follow, resulting in the combined list. It checks that the
|
||||
/// handler both called the request the expected number of times, and did
|
||||
/// not return an error.
|
||||
#[async_std::test]
|
||||
#[tokio::test]
|
||||
async fn test_tags_handler_with_pagination() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let path = "/v2/some_image/tags/list";
|
||||
@@ -320,9 +441,10 @@ mod tests {
|
||||
.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_header(
|
||||
http::header::LINK.as_str(),
|
||||
&format!(r#"<{path2}>; rel=next"#),
|
||||
&format!(r"<{path2}>; rel=next"),
|
||||
)
|
||||
.with_body(r#"{"tags": ["tag1", "tag2"]}"#)
|
||||
.create();
|
||||
@@ -331,12 +453,13 @@ mod tests {
|
||||
.mock("GET", path2)
|
||||
.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#"{"tags": ["tag3"]}"#)
|
||||
.create();
|
||||
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
let result = tags_handler(&mut buf, ®istry_url, "some_image").await;
|
||||
assert!(result.is_ok());
|
||||
assert!(result.is_ok(), "{:?}", result.unwrap_err());
|
||||
assert_eq!(String::from_utf8(buf).unwrap(), *"tag1\ntag2\ntag3\n");
|
||||
|
||||
mock_response.assert();
|
||||
@@ -348,7 +471,7 @@ mod tests {
|
||||
/// This test spins up a mock server, and makes a request to the check
|
||||
/// endpoint. It checks that the handler both called the request the
|
||||
/// expected number of times, and did not return an error.
|
||||
#[async_std::test]
|
||||
#[tokio::test]
|
||||
async fn test_check_handler() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let path = "/v2";
|
||||
@@ -364,17 +487,17 @@ mod tests {
|
||||
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
let result = check_handler(&mut buf, ®istry_url).await;
|
||||
assert!(result.is_ok());
|
||||
assert!(result.is_ok(), "{:?}", result.unwrap_err());
|
||||
assert_eq!(String::from_utf8(buf).unwrap(), *"Ok\n");
|
||||
|
||||
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
|
||||
/// is missing in the response, the appropriate error is returned.
|
||||
#[async_std::test]
|
||||
#[tokio::test]
|
||||
async fn test_check_handler_missing_api_version() -> Result<(), Box<dyn Error>> {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let path = "/v2";
|
||||
@@ -402,12 +525,12 @@ mod tests {
|
||||
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
|
||||
/// is present in the response but contains an unexpected value, the
|
||||
/// appropriate error is returned.
|
||||
#[async_std::test]
|
||||
#[tokio::test]
|
||||
async fn test_check_handler_invalid_api_version() -> Result<(), Box<dyn Error>> {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let path = "/v2";
|
||||
@@ -441,7 +564,7 @@ mod tests {
|
||||
/// This test spins up a mock server, and makes a request to the image
|
||||
/// manifests endpoint. It checks that the handler both called the request
|
||||
/// the expected number of times, and did not return an error.
|
||||
#[async_std::test]
|
||||
#[tokio::test]
|
||||
async fn test_show_handler() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let path = "/v2/foo/manifests/latest";
|
||||
@@ -492,6 +615,7 @@ mod tests {
|
||||
.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_header(
|
||||
"docker-content-digest",
|
||||
"sha256:0259571889ac87efbfca5b79a0abe9baf626d058ec5f9a5744bace2229d9ed50",
|
||||
@@ -517,7 +641,7 @@ mod tests {
|
||||
etag: sha256:0259571889ac87efbfca5b79a0abe9baf626d058ec5f9a5744bace2229d9ed50\n"
|
||||
};
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(result.is_ok(), "{:?}", result.unwrap_err());
|
||||
assert_eq!(String::from_utf8(buf).unwrap(), *expected_body);
|
||||
|
||||
mock_response.assert();
|
||||
|
||||
+126
-22
@@ -1,17 +1,10 @@
|
||||
/*
|
||||
* Copyright 2023 Anthony Oteri
|
||||
* Copyright 2023 Anthony Oteri
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
* Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
|
||||
* http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
|
||||
* http://opensource.org/licenses/MIT>, at your option. This file may not be
|
||||
* copied, modified, or distributed except according to those terms.
|
||||
*/
|
||||
|
||||
#![allow(clippy::enum_variant_names)]
|
||||
@@ -19,56 +12,167 @@
|
||||
|
||||
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)]
|
||||
pub enum DredgeError {
|
||||
/// An error communicating with the Registry API
|
||||
/// An error returned by the Docker Registry API layer.
|
||||
#[error(transparent)]
|
||||
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}")]
|
||||
RegistryUrlError(String),
|
||||
|
||||
/// An I/O error writing output to stdout.
|
||||
#[error(transparent)]
|
||||
IOError(#[from] std::io::Error),
|
||||
|
||||
/// The logging subsystem could not be initialised.
|
||||
#[error(transparent)]
|
||||
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)]
|
||||
pub enum ApiError {
|
||||
/// Error parsing a URL
|
||||
/// A URL could not be constructed or parsed.
|
||||
#[error(transparent)]
|
||||
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)]
|
||||
HttpError(#[from] reqwest::Error),
|
||||
|
||||
#[error("Failed to parse response headers")]
|
||||
ResponseHeaderParseError(Box<dyn std::error::Error>),
|
||||
/// A response header contained bytes that could not be decoded as UTF-8.
|
||||
#[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}")]
|
||||
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}")]
|
||||
UnexpectedResponse(String),
|
||||
|
||||
/// The registry returned `401 Unauthorized`. Authentication is not
|
||||
/// currently supported; the request cannot be retried automatically.
|
||||
#[error("HTTP Authorization failed")]
|
||||
AuthorizationFailed,
|
||||
|
||||
/// The requested resource does not exist in the registry (`404 Not Found`).
|
||||
#[error("Resource not found")]
|
||||
NotFound,
|
||||
|
||||
/// An I/O error writing serialized output to the output buffer.
|
||||
#[error(transparent)]
|
||||
IOError(#[from] std::io::Error),
|
||||
|
||||
/// The manifest response body could not be serialized to YAML for output.
|
||||
#[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")]
|
||||
MethodNotAllowed,
|
||||
}
|
||||
|
||||
impl From<reqwest::header::ToStrError> for ApiError {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+67
-26
@@ -1,17 +1,10 @@
|
||||
/*
|
||||
* Copyright 2023 Anthony Oteri
|
||||
* Copyright 2023 Anthony Oteri
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
* Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
|
||||
* http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
|
||||
* http://opensource.org/licenses/MIT>, at your option. This file may not be
|
||||
* copied, modified, or distributed except according to those terms.
|
||||
*/
|
||||
|
||||
#![deny(clippy::pedantic)]
|
||||
@@ -19,6 +12,7 @@
|
||||
use std::io::{self, Write};
|
||||
|
||||
use clap::Parser;
|
||||
use simple_logger::SimpleLogger;
|
||||
use url::Url;
|
||||
|
||||
use crate::cli::Cli;
|
||||
@@ -30,39 +24,59 @@ pub(crate) mod cli;
|
||||
mod commands;
|
||||
mod error;
|
||||
|
||||
/// Name of "latest" tag
|
||||
/// The default image tag used when no tag is specified by the caller.
|
||||
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`
|
||||
/// instance.
|
||||
/// Accepts a bare hostname (`registry.example.com`), a host-and-port pair
|
||||
/// (`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
|
||||
/// as-is.
|
||||
/// # Errors
|
||||
///
|
||||
/// # 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
|
||||
/// `DredgeError::RegistryUrlError` will be returned.
|
||||
/// # Examples
|
||||
///
|
||||
/// ```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> {
|
||||
log::trace!("make_registry_url(host: {host})");
|
||||
log::trace!("parse_registry_arg(host: {host})");
|
||||
|
||||
let mut host = String::from(host);
|
||||
if !host.starts_with("http://") && !host.starts_with("https://") {
|
||||
host = format!("https://{host}");
|
||||
}
|
||||
|
||||
Url::parse(&host).or(Err(DredgeError::RegistryUrlError(host.to_string())))
|
||||
Url::parse(&host).or(Err(DredgeError::RegistryUrlError(host.clone())))
|
||||
}
|
||||
|
||||
#[async_std::main]
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> Result<(), DredgeError> {
|
||||
let args = Cli::parse();
|
||||
|
||||
// -- Initialize logging
|
||||
let log_level = args.log_level;
|
||||
femme::with_level(log::LevelFilter::from(log_level));
|
||||
SimpleLogger::new()
|
||||
.with_colors(true)
|
||||
.with_utc_timestamps()
|
||||
.with_level(log_level.into())
|
||||
.env()
|
||||
.init()?;
|
||||
|
||||
// -- Parse the given <REGISTRY> argument into a complete URL
|
||||
let registry_url: Url = parse_registry_arg(&args.registry)?;
|
||||
@@ -77,7 +91,7 @@ async fn main() -> Result<(), DredgeError> {
|
||||
&mut buf,
|
||||
®istry_url,
|
||||
&image,
|
||||
&tag.unwrap_or(LATEST.to_string()),
|
||||
tag.as_deref().unwrap_or(LATEST),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
@@ -157,4 +171,31 @@ mod tests {
|
||||
_ => panic!("Expected RegistryUrlError, got a different error"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test that an HTTP (non-HTTPS) URL is returned as-is without prepending
|
||||
/// the HTTPS scheme.
|
||||
#[test]
|
||||
fn test_parse_registry_arg_http_url() {
|
||||
let host = "http://example.com/registry";
|
||||
let result = parse_registry_arg(host);
|
||||
|
||||
assert!(result.is_ok());
|
||||
let url = result.unwrap();
|
||||
assert_eq!(url.scheme(), "http");
|
||||
assert_eq!(url.host_str(), Some("example.com"));
|
||||
assert_eq!(url.path(), "/registry");
|
||||
}
|
||||
|
||||
/// Test that a trailing slash in the registry argument is preserved.
|
||||
#[test]
|
||||
fn test_parse_registry_arg_trailing_slash() {
|
||||
let host = "example.com/registry/";
|
||||
let result = parse_registry_arg(host);
|
||||
|
||||
assert!(result.is_ok());
|
||||
let url = result.unwrap();
|
||||
assert_eq!(url.scheme(), "https");
|
||||
assert_eq!(url.host_str(), Some("example.com"));
|
||||
assert_eq!(url.path(), "/registry/");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user