2026-02-21 14:37:31 +01:00
|
|
|
# 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).
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
2026-03-01 05:04:07 +01:00
|
|
|
## What Changed (Session 2026-03-01b)
|
|
|
|
|
|
|
|
|
|
### Passphrase caching via Linux keyring (v0.3.0)
|
|
|
|
|
|
|
|
|
|
Added cross-invocation master passphrase caching using the Linux kernel keyring
|
|
|
|
|
(`keyctl` from `keyutils`). `keyctl` is an **optional** dependency — if not
|
|
|
|
|
installed, behaviour is unchanged (prompt every time).
|
|
|
|
|
|
|
|
|
|
**New functions** (Encryption helpers section):
|
|
|
|
|
|
|
|
|
|
| Function | Purpose |
|
|
|
|
|
| --- | --- |
|
|
|
|
|
| `_keyring_available()` | Returns 0 if `keyctl` is installed |
|
|
|
|
|
| `_keyring_get()` | Read cached passphrase from user keyring; return 1 if missing/expired |
|
|
|
|
|
| `_keyring_set(pass, ttl)` | Store passphrase in keyring with TTL |
|
|
|
|
|
| `_keyring_clear()` | Revoke the cached key |
|
|
|
|
|
|
|
|
|
|
**New subcommands**:
|
|
|
|
|
|
|
|
|
|
| Command | Purpose |
|
|
|
|
|
| --- | --- |
|
|
|
|
|
| `unlock` | Prompt for passphrase and cache in keyring (useful before scripting) |
|
|
|
|
|
| `lock` | Clear cached passphrase from keyring immediately |
|
|
|
|
|
|
|
|
|
|
**New global option**: `--cache-timeout <seconds>` (default 300 / 5 min).
|
|
|
|
|
Also configurable via `CACHE_TIMEOUT=<seconds>` in `hubmanager.conf`.
|
|
|
|
|
|
|
|
|
|
**New constants**: `HM_KEYRING_KEY`, `HM_KEYRING_DEFAULT_TIMEOUT`.
|
|
|
|
|
**New global variable**: `HM_CACHE_TIMEOUT`.
|
|
|
|
|
|
|
|
|
|
**Modified functions**:
|
|
|
|
|
|
|
|
|
|
- `_prompt_master_pass()` — checks keyring before prompting; stores after prompt
|
|
|
|
|
- `_prompt_set_master_pass()` — stores in keyring after confirmation
|
|
|
|
|
|
|
|
|
|
**Version bump**: `0.2.1` → `0.3.0`
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
2026-03-01 04:49:50 +01:00
|
|
|
## What Changed (Session 2026-03-01)
|
|
|
|
|
|
|
|
|
|
### Bug fixes and improvements (v0.2.1)
|
|
|
|
|
|
|
|
|
|
1. **Global arg parsing fix** (`parse_global_args`): global flags like `--user`,
|
|
|
|
|
`--registry`, `--password` placed after the subcommand name (e.g.
|
|
|
|
|
`hubmanager login --user ...`) were not recognised. The `*` catch-all used
|
|
|
|
|
`remaining+=("$@"); break` which stopped parsing. Changed to
|
|
|
|
|
`remaining+=("$1"); shift` so parsing continues through all arguments.
|
|
|
|
|
|
|
|
|
|
2. **Subshell variable fix** (`raw_http` / `registry_request`): `HM_LAST_HTTP_CODE`
|
|
|
|
|
and `HM_LAST_HEADERS_FILE` were set inside `raw_http`, but callers used
|
|
|
|
|
`body=$(raw_http ...)` which runs in a subshell — variables set inside never
|
|
|
|
|
propagated back. Fixed by adding two global temp files (`HM_HTTP_CODE_FILE`,
|
|
|
|
|
`HM_HEADERS_REF_FILE`) that `raw_http` writes to; callers read them back after
|
|
|
|
|
the subshell returns.
|
|
|
|
|
|
|
|
|
|
3. **Config inline-comment stripping removed** (`load_config`): the parser stripped
|
|
|
|
|
everything after the first `#` in config values (`value="${value%%#*}"`), which
|
|
|
|
|
could truncate base64 ciphertext containing `#`. Removed since comment-only
|
|
|
|
|
lines are already skipped.
|
|
|
|
|
|
|
|
|
|
4. **Decrypt newline fix** (`_decrypt_value`): `printf '%s'` piped ciphertext to
|
|
|
|
|
`openssl enc -d -a` without a trailing newline, causing `openssl` to fail with
|
|
|
|
|
"error reading input file". Changed to `printf '%s\n'`.
|
|
|
|
|
|
|
|
|
|
5. **Config path moved**: `~/.hubmanager.conf` → `~/.config/hubmanager.conf`.
|
|
|
|
|
`login --save` now runs `mkdir -p` on the parent directory before writing.
|
|
|
|
|
|
|
|
|
|
6. **README**: added "Cleaning up empty repositories" section explaining the
|
|
|
|
|
server-side steps needed to remove empty repos (registry v2 API limitation).
|
|
|
|
|
|
|
|
|
|
### New global variables
|
|
|
|
|
|
|
|
|
|
| Variable | Purpose |
|
|
|
|
|
| --- | --- |
|
|
|
|
|
| `HM_HTTP_CODE_FILE` | Temp file for HTTP code (survives subshells) |
|
|
|
|
|
| `HM_HEADERS_REF_FILE` | Temp file for headers path (survives subshells) |
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
2026-02-21 14:52:48 +01:00
|
|
|
## What Changed (Session 2026-02-21)
|
|
|
|
|
|
|
|
|
|
### Encrypted config values (`--encrypt`)
|
|
|
|
|
|
|
|
|
|
Added `openssl` AES-256-CBC encryption for passwords stored in the config file.
|
|
|
|
|
`openssl` is an **optional** dependency — it is only required when `enc:` prefixed values
|
|
|
|
|
are present in the config, or when `login --encrypt` is used.
|
|
|
|
|
|
|
|
|
|
**New functions** (Encryption helpers section):
|
|
|
|
|
|
|
|
|
|
| Function | Purpose |
|
|
|
|
|
| --- | --- |
|
|
|
|
|
| `_require_openssl()` | Die with a clear message if `openssl` is not installed |
|
|
|
|
|
| `_prompt_master_pass()` | Prompt once per session via `/dev/tty`; cache in `HM_MASTER_PASS` |
|
|
|
|
|
| `_prompt_set_master_pass()` | Prompt + confirm a new passphrase (used by `login --encrypt`) |
|
|
|
|
|
| `_encrypt_value(plaintext)` | AES-256-CBC encrypt → base64 ciphertext (no newlines) |
|
|
|
|
|
| `_decrypt_value(ciphertext)` | Decrypt base64 ciphertext → plaintext; die on wrong passphrase |
|
|
|
|
|
|
|
|
|
|
**Passphrase security**: passed to `openssl` via a `mktemp` file (`-pass file:`) to avoid
|
|
|
|
|
exposure in the process argument list (`ps aux`). The temp file is registered in
|
|
|
|
|
`HM_TMPFILES` and removed on exit.
|
|
|
|
|
|
|
|
|
|
**Config format**: encrypted values use an `enc:` prefix, e.g.:
|
|
|
|
|
|
|
|
|
|
```txt
|
|
|
|
|
PASSWORD=enc:U2FsdGVkX1+...base64ciphertext...
|
|
|
|
|
REGISTRY_PROD_PASSWORD=enc:U2FsdGVkX1+...
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Both `load_config()` and `resolve_registry_alias()` detect the prefix and call
|
|
|
|
|
`_decrypt_value` transparently.
|
|
|
|
|
|
|
|
|
|
**Version bump**: `0.1.0` → `0.2.0`
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
2026-02-21 14:37:31 +01:00
|
|
|
## What Was Built (Session 2025-02-21)
|
|
|
|
|
|
|
|
|
|
### Primary file
|
|
|
|
|
|
2026-02-21 14:52:48 +01:00
|
|
|
`hubmanager` — executable Bash script, ~680 lines, no mandatory dependencies beyond `curl`, `jq`, Bash 4+.
|
|
|
|
|
`openssl` is required only when encrypted config values are used.
|
2026-02-21 14:37:31 +01:00
|
|
|
|
|
|
|
|
### Subcommands implemented
|
|
|
|
|
|
|
|
|
|
| Command | Description |
|
|
|
|
|
| --- | --- |
|
2026-03-01 04:49:50 +01:00
|
|
|
| `login` | Test credentials against a registry; `--save` writes to `~/.config/hubmanager.conf` |
|
2026-02-21 14:37:31 +01:00
|
|
|
| `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 |
|
2026-03-01 05:04:07 +01:00
|
|
|
| `unlock` | Cache master passphrase in kernel keyring (requires `keyctl`) |
|
|
|
|
|
| `lock` | Clear cached master passphrase from keyring |
|
2026-02-21 14:37:31 +01:00
|
|
|
|
|
|
|
|
### 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 ---
|
2026-02-21 14:52:48 +01:00
|
|
|
# --- Encryption helpers --- _encrypt_value(), _decrypt_value(), _prompt_master_pass()
|
2026-03-01 05:04:07 +01:00
|
|
|
# _keyring_get(), _keyring_set(), _keyring_clear()
|
2026-02-21 14:37:31 +01:00
|
|
|
# --- 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
|
2026-03-01 05:04:07 +01:00
|
|
|
# --- Subcommands --- cmd_login/unlock/lock/list/tags/inspect/delete/copy/prune
|
2026-02-21 14:37:31 +01:00
|
|
|
# --- 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`
|
|
|
|
|
|
2026-03-01 04:49:50 +01:00
|
|
|
6. **Config file** (`~/.config/hubmanager.conf`):
|
2026-02-21 14:37:31 +01:00
|
|
|
- `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
|
|
|
|
|
|
2026-02-21 14:52:48 +01:00
|
|
|
9. **Encrypted config values** (v0.2.0):
|
|
|
|
|
- `login --save --encrypt` prompts for a master passphrase (with confirmation), encrypts
|
|
|
|
|
the password with `openssl enc -aes-256-cbc -pbkdf2 -a`, and writes `PASSWORD=enc:<b64>`.
|
|
|
|
|
- Passphrase is passed to `openssl` via a temp file (`-pass file:`) — never via argv or env.
|
|
|
|
|
- `load_config` and `resolve_registry_alias` both check for the `enc:` prefix and call
|
|
|
|
|
`_decrypt_value`, which triggers `_prompt_master_pass` (once per session, cached in
|
|
|
|
|
`HM_MASTER_PASS`).
|
|
|
|
|
- `openssl` is an optional dependency: not checked at startup, only on first `enc:` encounter.
|
|
|
|
|
|
2026-03-01 05:04:07 +01:00
|
|
|
10. **Passphrase caching** (v0.3.0):
|
|
|
|
|
- Uses Linux kernel keyring (`keyctl` from `keyutils`) to cache the master passphrase
|
|
|
|
|
across invocations with a configurable TTL (default 300s / 5 min).
|
|
|
|
|
- `_prompt_master_pass()` checks keyring before prompting; stores after prompt.
|
|
|
|
|
- `keyctl` is optional: if not installed, each invocation prompts as before (no errors).
|
|
|
|
|
- `unlock` pre-caches the passphrase; `lock` clears it immediately.
|
|
|
|
|
- TTL configurable via `--cache-timeout <seconds>` flag or `CACHE_TIMEOUT` config key.
|
|
|
|
|
|
2026-02-21 14:37:31 +01:00
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 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) |
|
2026-02-21 14:52:48 +01:00
|
|
|
| `HM_MASTER_PASS` | Master passphrase for `enc:` config values (session-cached) |
|
2026-03-01 05:04:07 +01:00
|
|
|
| `HM_CACHE_TIMEOUT` | Keyring cache TTL in seconds (default 300) |
|
2026-02-21 14:37:31 +01:00
|
|
|
| `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.
|