feat: initial implementation of hubmanager v0.1.0

Add a Bash CLI tool to manage Docker Registry images remotely.
Supports Docker Hub and self-hosted Docker Registry v2 API with
automatic auth detection (bearer token or HTTP basic auth).

Subcommands: login, list, tags, inspect, delete, copy, prune
Dependencies: curl, jq, bash 4+

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 14:37:31 +01:00
commit 661de2f3d8
5 changed files with 2111 additions and 0 deletions

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
tests/*
PLAN.md

167
CLAUDE.md Normal file
View File

@@ -0,0 +1,167 @@
# hubmanager — AI Session Context
This file provides context for AI assistants working on this project.
## Project Summary
**hubmanager** is a single-file Bash CLI tool to manage Docker Registry images remotely.
It supports both Docker Hub and any self-hosted Docker Registry v2 API, with automatic
authentication detection (bearer token or HTTP basic auth).
---
## What Was Built (Session 2025-02-21)
### Primary file
`hubmanager` — executable Bash script, ~600 lines, no dependencies beyond `curl`, `jq`, Bash 4+.
### Subcommands implemented
| Command | Description |
| --- | --- |
| `login` | Test credentials against a registry; `--save` writes to `~/.hubmanager.conf` |
| `list` | List repositories (`_catalog` for self-hosted; Hub REST API for Docker Hub) |
| `tags` | List tags for an image with pagination |
| `inspect` | Show manifest digest, size, OS/arch, layers, labels; multi-arch support |
| `delete` | Resolve tag → digest → `DELETE`; requires confirmation or `--yes` |
| `copy` | Copy/retag within or across registries; blob mount for same-registry retag |
| `prune` | Delete outdated tags sorted by image creation date |
### Supporting files
- `PLAN.md` — implementation plan written before coding
- `README.md` — full user-facing documentation with examples
- `tests/fixtures/` — sample JSON responses for testing (token, catalog, tags, manifests, config blob)
---
## Architecture
### Single-file, section-based layout
```txt
#!/usr/bin/env bash
set -euo pipefail
# --- Constants ---
# --- Global state ---
# --- Output / Formatting helpers ---
# --- Dependency check ---
# --- Config loading ---
# --- HTTP helpers --- raw_http(), get_response_header()
# --- Authentication --- probe_registry_auth(), get_bearer_token(), make_auth_header()
# --- Registry request --- registry_request() ← main HTTP wrapper
# --- Subcommands --- cmd_login/list/tags/inspect/delete/copy/prune
# --- Global arg parsing --- parse_global_args()
# --- Main dispatcher --- main()
main "$@"
```
### Key design decisions
1. **`raw_http METHOD URL [curl-args]`** — thin curl wrapper. Writes headers to a temp file
(`HM_LAST_HEADERS_FILE`), sets `HM_LAST_HTTP_CODE`, outputs body to stdout.
2. **`registry_request METHOD REGISTRY PATH [options]`** — higher-level wrapper that:
- Probes auth mode via `probe_registry_auth` (result cached per registry)
- Fetches and caches bearer tokens per `"registry|scope"` key
- Retries once on 401 using the challenge scope from `WWW-Authenticate`
- Calls `_handle_http_error` on non-2xx (unless `--no-die`)
3. **`make_auth_header REGISTRY SCOPE [USER] [PASS]`** — returns the correct
`Authorization:` header string for either bearer or basic auth.
4. **Authentication flow**:
- `GET /v2/` → parse `WWW-Authenticate` → detect `bearer` or `basic`
- Bearer: `GET <realm>?scope=S&service=S` with `-u user:pass` → cache `.token`
- Basic: construct `Authorization: Basic <base64>` directly
- Tokens cached in `HM_TOKEN_CACHE["registry|scope"]` with epoch expiry
5. **Docker Hub differences**:
- `list` uses `hub.docker.com/v2/repositories/<user>/` (not `_catalog`)
- `delete` and `prune` are blocked (Hub v2 API doesn't support deletion)
- Hub REST API uses a separate JWT from `hub.docker.com/v2/users/login`
6. **Config file** (`~/.hubmanager.conf`):
- `KEY=VALUE` format, parsed with `while IFS='=' read` (not `source`)
- Supports named registry aliases: `REGISTRY_<ALIAS>_URL/USERNAME/PASSWORD`
- Aliases are resolved in `resolve_registry_alias()` before any operations
7. **Prune logic**: fetches all tags → filters by `--exclude` pattern → inspects
each tag's config blob for creation date → sorts oldest-first → deletes oldest
(beyond `--keep` count or before `--older-than` days). Supports `--dry-run`.
8. **Copy blob transfer**:
- Same registry, different repos → attempt cross-repo blob mount (`POST ?mount=`)
- Blob already at destination (`HEAD` returns 200) → skip
- Otherwise → download to temp file → `POST` initiate upload → `PUT` with digest
---
## Global Variables (key ones)
| Variable | Purpose |
| --- | --- |
| `HM_REGISTRY` | Active registry URL (no trailing slash) |
| `HM_USERNAME` / `HM_PASSWORD` | Active credentials |
| `HM_CONFIG_FILE` | Path to config file |
| `HM_OPT_JSON/VERBOSE/QUIET/NO_COLOR` | Output flags |
| `HM_AUTH_MODE["registry"]` | `bearer`, `basic`, or `none` |
| `HM_AUTH_REALM["registry"]` | Bearer token endpoint URL |
| `HM_TOKEN_CACHE["registry\|scope"]` | Cached bearer token |
| `HM_TOKEN_EXPIRY["registry\|scope"]` | Token expiry (epoch seconds) |
| `HM_LAST_HTTP_CODE` | HTTP status of most recent request |
| `HM_LAST_HEADERS_FILE` | Temp file path with response headers |
| `HM_TMPFILES` | Array of temp files, cleaned up via `trap EXIT` |
| `MANIFEST_ACCEPT_HEADERS` | Array of `-H Accept:` args for all manifest types |
---
## Adding a New Subcommand
1. Write a `cmd_<name>()` function following the same pattern:
- Parse args with a `while case` loop; handle `--help`
- Set `HM_REGISTRY` default if empty
- Use `registry_request` for API calls
- Check `HM_OPT_JSON` and branch for table vs. JSON output
2. Add it to the `case` block in `main()`:
```bash
newcmd) cmd_newcmd "$@" ;;
```
3. Add it to `show_usage()`.
4. Run `shellcheck hubmanager` — it must pass with zero warnings.
---
## Exit Codes
| Code | Meaning |
| --- | --- |
| 0 | Success |
| 1 | General / usage error (`die`) |
| 2 | Authentication failure (`die_auth`) |
| 3 | Not found / 404 (`die_notfound`) |
| 4 | Permission denied / 403 (`die_permission`) |
| 5 | Registry server error (`die_server`) |
| 6 | Network error (`die_network`) |
| 7 | Operation not supported (`die_notsup`) |
---
## Known Limitations / Future Work
- **Garbage collection** after `delete`/`prune`: the registry only frees disk space
after running `registry garbage-collect`. This tool does not trigger GC; it must
be run separately on the registry host.
- **Docker Hub rate limits**: the `inspect`-per-tag loop in `prune` makes one API
call per tag, which can hit pull rate limits on Docker Hub (prune is blocked on
Hub anyway).
- **Token scope granularity**: the retry-on-401 flow handles most scope mismatches,
but registries that require compound scopes (e.g. `pull,push` together) may need
explicit `--scope` passing inside `registry_request` calls.
- **No `bats` tests yet**: `tests/fixtures/` contains sample API responses. Wire up
`bats-core` tests that mock `curl` with a shell function and feed fixture data.

391
README.md Normal file
View File

@@ -0,0 +1,391 @@
# hubmanager
A Bash CLI tool to manage Docker Registry images remotely. Supports Docker Hub and any self-hosted Docker Registry v2 API, with flexible authentication.
## Requirements
- **Bash** 4.0+
- **curl**
- **jq**
## Installation
```bash
# System-wide
sudo install -m 755 hubmanager /usr/local/bin/hubmanager
# User-local
install -m 755 hubmanager ~/bin/hubmanager
```
## Quick Start
```bash
# Test your credentials and save them
hubmanager login --registry https://registry.example.com \
--user admin --password secret --save
# List all repositories
hubmanager list
# List tags for an image
hubmanager tags myuser/myapp
# Inspect an image
hubmanager inspect myuser/myapp:latest
# Delete an old tag
hubmanager delete myuser/myapp:v1.0.0
# Prune old tags (keep the 3 most recent)
hubmanager prune myuser/myapp --keep 3 --dry-run
```
## Configuration
Credentials and registry settings are stored in `~/.hubmanager.conf`.
The file uses a simple `KEY=VALUE` format:
```bash
# ~/.hubmanager.conf
# chmod 600 ~/.hubmanager.conf
# Default registry and credentials
REGISTRY=https://registry.example.com
USERNAME=admin
PASSWORD=mysecretpassword
# Named registry aliases (use with --registry <alias>)
REGISTRY_STAGING_URL=https://staging-registry.example.com
REGISTRY_STAGING_USERNAME=deploy
REGISTRY_STAGING_PASSWORD=deploytoken
REGISTRY_HUB_URL=https://registry-1.docker.io
REGISTRY_HUB_USERNAME=myuser
REGISTRY_HUB_PASSWORD=myhubtoken
```
> The file must be readable only by the owner (`chmod 600`). hubmanager will warn if permissions are too open.
Named aliases let you switch registries with a short name:
```bash
hubmanager --registry staging list
hubmanager --registry hub tags myuser/myapp
```
## Global Options
```
hubmanager [OPTIONS] <command> [COMMAND OPTIONS]
-r, --registry <url> Registry base URL
Default: https://registry-1.docker.io
-u, --user <username> Username (overrides config file)
-p, --password <pass> Password or token (overrides config file)
--config <file> Config file path (default: ~/.hubmanager.conf)
--json Output raw JSON (pipe-friendly)
--no-color Disable ANSI color
-v, --verbose Show HTTP request details (with auth redacted)
-q, --quiet Suppress all non-error output
-h, --help Show help
--version Show version
```
---
## Commands
### `login` — Test and save credentials
```
hubmanager login [--registry URL] [--user USER] [--password PASS] [--save]
```
Validates credentials against the registry. Use `--save` to write them to the config file.
```bash
hubmanager login --registry https://registry.example.com \
--user admin --password secret --save
# Login Succeeded — bearer auth, registry: https://registry.example.com
# Credentials saved to /home/user/.hubmanager.conf
```
---
### `list` — List repositories
```
hubmanager list [--limit N] [--last REPO]
```
Lists all repositories in the registry. On **Docker Hub**, lists repositories for the authenticated user (Docker Hub restricts the `_catalog` endpoint).
```bash
hubmanager list
# REPOSITORY
# myuser/myapp
# myuser/myapi
# myuser/nginx-custom
# Paginate
hubmanager list --limit 50 --last myuser/myapi
# JSON output
hubmanager list --json | jq '.repositories[]'
```
---
### `tags` — List tags for an image
```
hubmanager tags <image> [--limit N] [--last TAG]
```
```bash
hubmanager tags myuser/myapp
# TAG
# latest
# v1.2.3
# v1.2.2
# develop
# Official Docker Hub images
hubmanager tags nginx
# JSON
hubmanager tags myuser/myapp --json | jq '.tags[]'
```
---
### `inspect` — Show image details
```
hubmanager inspect <image>:<tag|digest> [--platform OS/ARCH]
```
Shows manifest digest, size, OS/arch, creation date, labels, and layer breakdown.
```bash
hubmanager inspect myuser/myapp:latest
# Image: myuser/myapp:latest
# Digest: sha256:abc123...
# MediaType: application/vnd.docker.distribution.manifest.v2+json
# CompressedSize: 32.7 MB (34299597 bytes)
# OS/Arch: linux/amd64
# Created: 2024-01-15T10:30:00.000000000Z
# Labels:
# maintainer=dev@example.com
# version=1.2.3
# Layers: 3
# [0] sha256:1111... (27.8 MB)
# [1] sha256:2222... (4.4 MB)
# [2] sha256:3333... (1000.0 KB)
# Multi-arch image — shows all platforms
hubmanager inspect nginx:latest
# Multi-arch — drill into a specific platform
hubmanager inspect nginx:latest --platform linux/arm64
# Inspect by digest
hubmanager inspect myuser/myapp@sha256:abc123...
# JSON output (includes _digest field)
hubmanager inspect myuser/myapp:latest --json | jq .
```
---
### `delete` — Delete a tag or manifest
```
hubmanager delete <image>:<tag|digest> [--yes]
```
Deletes a manifest by resolving the tag to its content-addressable digest, then issuing a `DELETE`. Requires `REGISTRY_STORAGE_DELETE_ENABLED=true` on self-hosted registries. Not supported on Docker Hub.
```bash
hubmanager delete myuser/myapp:v1.0.0
# About to delete: myuser/myapp @ sha256:abc123...
# Registry: https://registry.example.com
# Type 'yes' to confirm: yes
# Deleted: myuser/myapp @ sha256:abc123...
# Skip confirmation
hubmanager delete myuser/myapp:v1.0.0 --yes
# Delete by digest directly
hubmanager delete myuser/myapp@sha256:abc123... --yes
```
---
### `copy` — Copy or retag an image
```
hubmanager copy <src-image>:<tag> <dst-image>:<tag> [options]
Options:
--src-registry URL Source registry (default: global --registry)
--dst-registry URL Destination registry (default: global --registry)
--src-user USER Source username
--src-password PASS Source password
--dst-user USER Destination username
--dst-password PASS Destination password
--platform OS/ARCH Copy only one platform from a multi-arch image
```
**Same-registry retag** — attempts cross-repo blob mount (no data transfer):
```bash
hubmanager copy myuser/myapp:v1.2.3 myuser/myapp:stable
```
**Cross-registry copy** — streams blobs from source to destination:
```bash
hubmanager copy myuser/myapp:latest \
--src-registry https://registry-1.docker.io \
--dst-registry https://registry.example.com \
mycompany/myapp:latest
```
**Copy specific platform** from a multi-arch image:
```bash
hubmanager copy nginx:latest myuser/nginx-amd64:latest --platform linux/amd64
```
---
### `prune` — Delete outdated tags
```
hubmanager prune <image> [options]
Options:
--keep N Number of recent tags to keep (default: 3)
--older-than DAYS Delete tags older than N days (overrides --keep)
--exclude PATTERN Extended regex of tags to never delete
Default: "^(latest|stable|main|master|release)$"
--no-exclude Disable the default exclusion pattern
-n, --dry-run Show what would be deleted without deleting
-y, --yes Skip confirmation prompt
```
Tags are sorted by image creation date (newest first). The newest N are kept; the rest are deleted. Tags matching the exclusion pattern are always preserved.
```bash
# Preview: keep 5 most recent, protect default tags
hubmanager prune myuser/myapp --keep 5 --dry-run
# Keep 3 most recent, auto-confirm
hubmanager prune myuser/myapp --keep 3 --yes
# Delete anything older than 30 days
hubmanager prune myuser/myapp --older-than 30 --dry-run
# Custom exclusion: never delete latest or any semver tag
hubmanager prune myuser/myapp --keep 5 \
--exclude "^(latest|v[0-9]+\.[0-9]+(\.[0-9]+)?)$"
# Prune everything (no exclusions)
hubmanager prune myuser/myapp --keep 1 --no-exclude --dry-run
```
---
## Authentication
hubmanager automatically detects the authentication method by probing the registry's `/v2/` endpoint:
| Registry type | Auth method |
|---|---|
| Docker Hub | Bearer tokens via `auth.docker.io` |
| Harbor, self-hosted with token server | Bearer tokens via registry-configured realm |
| Basic-auth self-hosted | HTTP Basic Auth on every request |
| Public/anonymous registries | No auth |
Bearer tokens are cached in memory for the duration of the session and refreshed automatically when they expire.
### Docker Hub notes
- `list` uses the Docker Hub REST API (`hub.docker.com`) because the `_catalog` endpoint is restricted on Docker Hub.
- `delete` is not supported via the v2 API on Docker Hub. Use the web UI at https://hub.docker.com.
- `prune` is not supported on Docker Hub for the same reason.
---
## JSON output
All commands support `--json` for machine-readable output:
```bash
# Get all tags as a JSON array
hubmanager tags myapp --json | jq '.tags'
# Get digest of latest
hubmanager inspect myapp:latest --json | jq '._digest'
# Delete and capture result
hubmanager delete myapp:old --yes --json
# {"deleted":true,"digest":"sha256:..."}
# Prune and capture counts
hubmanager prune myapp --keep 3 --yes --json
# {"deleted":5,"failed":0}
```
---
## Exit Codes
| Code | Meaning |
|------|---------|
| 0 | Success |
| 1 | General / usage error |
| 2 | Authentication failure |
| 3 | Resource not found (404) |
| 4 | Permission denied (403) |
| 5 | Registry server error (5xx) |
| 6 | Network / connectivity error |
| 7 | Operation not supported by registry |
---
## Examples
```bash
# Mirror all tags of an image to a private registry
for tag in $(hubmanager tags nginx --json | jq -r '.tags[]'); do
hubmanager copy nginx:$tag \
--src-registry https://registry-1.docker.io \
--dst-registry https://registry.example.com \
mycompany/nginx:$tag
done
# List images older than 60 days (dry run)
hubmanager prune myuser/myapp --older-than 60 --dry-run
# Get the SHA256 digest of the production image
DIGEST=$(hubmanager inspect myuser/myapp:production --json | jq -r '._digest')
echo "Production image: $DIGEST"
# Promote staging image to production (retag)
hubmanager copy myuser/myapp:staging myuser/myapp:production
```
---
## Configuration reference
| Key | Description |
|-----|-------------|
| `REGISTRY` | Default registry URL |
| `USERNAME` | Default username |
| `PASSWORD` | Default password or token |
| `REGISTRY_<ALIAS>_URL` | URL for a named registry alias |
| `REGISTRY_<ALIAS>_USERNAME` | Username for a named alias |
| `REGISTRY_<ALIAS>_PASSWORD` | Password for a named alias |
Aliases are case-insensitive and treat `-` as `_`. For example, alias `my-reg` maps to `REGISTRY_MY_REG_URL`.

1539
hubmanager Executable file

File diff suppressed because it is too large Load Diff