Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,11 +197,14 @@ internal/auth/ one authenticated ProviderClient per invocation + per
debug.go --debug transport: redacts tokens+secrets, elides large bodies
internal/kube/ minimal read-only k8s REST client (kubeconfig + secret/Ironic reads); no client-go
internal/vault/ minimal Vault REST client (AppRole login / token + KV v2 read + X-Vault-Namespace)
internal/s3/ minimal S3 REST client: hand-rolled SigV4 + list/head/get/put (no aws-sdk-go-v2 / minio-go)
internal/s3/ minimal S3 REST client, no aws-sdk-go-v2 / minio-go: hand-rolled SigV4
(header + presigned query), list/head/get/put, multipart upload,
server-side copy, batch delete, versioning, retry with backoff
internal/output/ -f/--format {table,json,yaml,value,csv} and -c/--column layer
internal/cli/keyvrm/ KeyVRM (in-house catalog service); typed request layer (types.go/requests.go) + cobra verbs
internal/cli/vault/ "koc vault kv" list/get/copy/export/decrypt (package vaultcli); Vault creds only
internal/cli/s3/ "koc s3" bucket/object list+show, download, upload (package s3cli); S3 creds only
internal/cli/s3/ "koc s3" bucket/object lifecycle, du, download/upload (multipart, stdin,
recursive), server-side copy/move, presign, sync (package s3cli); S3 creds only
internal/cli/quota/ "koc quota show|set" — the one cross-service noun (nova+cinder+neutron)
internal/cli/ root.go wires every service's command group onto the root
internal/cli/resolve/ cross-service name→ID (image→glance, network→neutron, project→keystone)
Expand Down
82 changes: 81 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -521,17 +521,95 @@ S3 credentials alone are used, so it works on a host with no cloud credentials.

```sh
koc s3 bucket list
koc s3 bucket show db-backups # exists? reachable? versioned?
koc s3 bucket create scratch
koc s3 bucket delete scratch # the bucket must be empty
koc s3 object list db-backups
koc s3 object list db-backups --delimiter / # one level, like a directory
koc s3 object show db-backups/<key> # HEAD only, no transfer
koc s3 object delete db-backups/<key>
koc s3 object delete db-backups/e2e- -r # every key under a prefix
koc s3 du db-backups --human --group # size, per storage class
koc s3 download db-backups/<key> ./dump.mbs.gz.enc
koc s3 download db-backups/<key>.sha256 - # "-" streams to stdout, so it pipes
koc s3 download db-backups/2026/ ./restore -r
koc s3 upload ./dump.mbs.gz.enc db-backups/
koc s3 upload ./restore db-backups/2026/ -r
koc s3 copy db-backups/<key> db-backups/latest.mbs.gz.enc
koc s3 move db-backups/<key> archive/
koc s3 presign db-backups/<key> --expire 1h
koc s3 sync ./restore s3://db-backups/2026/ --delete
```

Every ref also accepts the `s3://<bucket>/<key>` spelling, so a path copied from
`s5cmd` or `aws s3` pastes in unchanged, and a wildcard (`"db-backups/2026/*.gz"`)
selects many where a command takes one.

`bucket list` is scoped to the **access key**, not to the store: Garage answers
with the buckets that key is granted, so a key made for one bucket lists exactly
that one.

**Transfers.** `upload` has no 5 GiB ceiling: anything past `--part-size` (16 MiB
by default) goes out as a multipart upload, `--concurrency` parts at a time, and
a failure aborts it so a half-written object never becomes visible. `-` as the
source reads **standard input**, which is what makes a dump streamable without
staging it on disk:

```sh
mysqldump --all-databases | gzip | koc s3 upload - db-backups/nightly.sql.gz
```

`--recursive` on `download`, `upload`, `copy`, `move` and `object delete` works
on a whole prefix or tree, `--concurrency` objects at a time — which is most of
why a bulk restore finishes, since a thousand small objects are otherwise a
thousand serial round trips. `--include`/`--exclude` take globs (where `*` spans
`/`, as in s5cmd) and `--dry-run` prints exactly what would move.

`copy` and `move` are **server-side**: the source is named in a header, so the
bytes never travel through koc and a 100 GiB object is one small request. A move
is the copy and then the delete, in that order — a failure leaves the source
intact rather than losing the object.

**`sync`** makes a destination match a source, transferring only what differs —
local→S3, S3→local, or S3→S3 (server-side). It is the one command in the group
that *requires* the `s3://` prefix to mark a remote side: it has two sides, and
mistaking which is local is how a `--delete` removes the wrong one. An object
moves when it is absent, when the sizes differ, or when the source is newer;
`--size-only` drops the timestamp test, which is the right rule for content that
never changes in place (a dated backup). The S3-side timestamp is the object's
Last-Modified, because S3 keeps no record of the source file's mtime and a
listing would not return one if it did — correct in the steady state, with one
seam: a tree restored by `download` carries the restore time, so syncing it
*back* re-uploads once and then settles. `--delete` turns the sync into a mirror
and is refused when the source turned out to be empty unless `--force` is also
given, so a mistyped source cannot erase the destination.

`object delete --recursive` is the one destructive shape in the group, so it is
never inferred from a trailing slash — and `--dry-run` prints exactly the keys it
would remove without touching any of them. It is also how a bucket is emptied
before `bucket delete`, which never removes objects implicitly. Keys go out in
batches of up to 1000 per request, so emptying a large bucket costs a thousandth
of the round trips.

`du` is a listing folded into a sum, because S3 has no "how big is this" call:
no object is downloaded, but every key is walked, one request per 1000 of them.
Sizes are exact bytes everywhere unless `--human` is given — a rounded
"14.2 GiB" is not a number a script can add up.

`presign` prints a URL that reads (or, with `--method put`, writes) one object
with no credentials attached to the recipient. It makes **no request** — pure
local signing — so it works offline, and the URL is a bearer credential for that
key until it expires: treat it like a password and keep `--expire` short.

**Versioning** (`bucket show`, `bucket set --versioning`, `--all-versions`,
`--version-id`) is implemented but Garage has none — it reports every bucket as
unversioned — so those are for a koc pointed at AWS, Ceph RGW or MinIO.

A failed request is retried with exponential backoff (`--s3-retries`, 5 by
default): on a cluster network a reset connection mid-transfer should not cost
the whole command. `--s3-anonymous` sends requests unsigned, for a bucket
granted to everyone.

Credentials come from flags, from the environment, or from a Kubernetes Secret:

| flag | env | default |
Expand All @@ -542,6 +620,8 @@ Credentials come from flags, from the environment, or from a Kubernetes Secret:
| `--s3-region` | `AWS_REGION`, `AWS_DEFAULT_REGION`, `S3_REGION`, `s3_region` | `garage` |
| `--s3-cacert` | `AWS_CA_BUNDLE`, `S3_CACERT` | system roots |
| `--s3-creds-from-ns` | `KOC_S3_CREDS_FROM_NS` | — |
| `--s3-anonymous` | `S3_ANONYMOUS` | off (no credentials needed or used) |
| `--s3-retries` | — | `5` |
| `--insecure-s3` | `S3_SKIP_VERIFY` | off (the global `--insecure` also applies) |

The three env families are deliberate: `AWS_*` so an existing `aws`/`boto`
Expand Down Expand Up @@ -590,7 +670,7 @@ internal/kube/ minimal read-only k8s REST client (no client-go)
internal/vault/ minimal Vault REST client (AppRole/token + KV v2)
internal/s3/ minimal S3 REST client (SigV4, no aws-sdk/minio-go)
internal/cli/vault/ "koc vault kv" list/get/copy/export/decrypt, no Keystone auth
internal/cli/s3/ "koc s3" bucket/object/download/upload, no Keystone auth
internal/cli/s3/ "koc s3" bucket/object/du/transfer/copy/presign, no Keystone auth
internal/output/ -f/-c formatter (table/json/yaml/value/csv)
internal/cli/ root command wiring
internal/cli/resolve/ cross-service name→ID resolution
Expand Down
16 changes: 8 additions & 8 deletions docs/coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
How much of the upstream OpenStack CLI surface `koc` implements, measured against
primary sources rather than documentation.

**Snapshot:** 2026-09-03 · `koc` @ this commit (base `3ba95db`) · 555 leaf
**Snapshot:** 2026-09-14 · `koc` @ this commit (base `82afd0a`) · 565 leaf
commands (visible tree; 2 more are hidden duplicates).

**Keep this file current** — see "Updating this document" below. Any commit that
Expand All @@ -28,8 +28,8 @@ PyPI is the source of record.

## Headline

**515 of 844 in-scope upstream commands (61%).** Of `koc`'s 555 leaf commands,
515 are upstream-equivalent and 40 are koc-native.
**515 of 844 in-scope upstream commands (61%).** Of `koc`'s 565 leaf commands,
515 are upstream-equivalent and 50 are koc-native.

The denominator grew by 13 against the 2026-08-07 snapshot without a single
command changing: `python-ironic-inspector-client` is now a **baseline** rather
Expand Down Expand Up @@ -433,14 +433,14 @@ limitations"; the fix is to make the other resolvers match `server`'s behaviour.

## koc-native commands

No upstream equivalent, by design — **40 leaves**, itemised so the total
reconciles with the headline (555 = 515 + 40):
No upstream equivalent, by design — **50 leaves**, itemised so the total
reconciles with the headline (565 = 515 + 50):

| Count | Commands | Why it has no upstream equivalent |
| --- | --- | --- |
| 18 | `koc keyvrm …` — `app-config` ×2, `availability-zone` ×2, `event` ×4, `host-aggregate-config` ×5, `recommendation` ×5 | in-house KeyVRM catalog service; no gophercloud package and no OSC plugin |
| 5 | `koc vault kv list/get/copy/export/decrypt` | Vault is not an OpenStack service; `copy` fills a gap in the Vault CLI itself |
| 5 | `koc s3 bucket list`, `koc s3 object list/show`, `koc s3 download/upload` | S3 is not an OpenStack service. Upstream's object-store commands speak **Swift**, which is a different API and is counted separately as not targeted (`openstack.object_store.v1`, 0/17); these talk to the LCM cluster's Garage, which holds GitLab's object storage and the `backup-db` pipeline's MariaDB dumps |
| 15 | `koc s3 bucket list/create/delete/show/set`, `koc s3 object list/show/delete`, `koc s3 du`, `koc s3 download/upload`, `koc s3 copy/move`, `koc s3 presign`, `koc s3 sync` | S3 is not an OpenStack service. Upstream's object-store commands speak **Swift**, which is a different API and is counted separately as not targeted (`openstack.object_store.v1`, 0/17); these talk to the LCM cluster's Garage, which holds GitLab's object storage and the `backup-db` pipeline's MariaDB dumps |
| 2 | `koc dns pool list/show` | designate's API and its Python SDK both expose `/v2/pools`, but `python-designateclient` registers no `openstack` command for it. Reads only — pool *writes* are a `designate-manage`/config operation on the servers |
| 2 | `koc server add/remove server-group` | KeyStack dynamic server groups |
| 2 | `koc network trunk subport add`/`remove` | upstream folds these into `network trunk set`/`unset --subport` flags rather than giving them verbs (`network subport list` does exist and is counted — see "Naming deviations") |
Expand Down Expand Up @@ -481,7 +481,7 @@ The tables are derived, not hand-maintained. To re-derive after a version bump
or a batch of new commands:

```sh
# 1. koc's own command tree (553 leaf commands at the snapshot above)
# 1. koc's own command tree (565 leaf commands at the snapshot above)
make build
# Walk `--help` recursively. Count a command when it is *runnable*, not merely when
# it is childless: `koc image import <image>` is a verb that also parents `koc image
Expand Down Expand Up @@ -511,7 +511,7 @@ Then **check the arithmetic**, because that is the only thing that makes these
tables worth reading. Three identities must hold at every snapshot:

1. every raw row numerator summed = the headline numerator (515);
2. leaf commands = headline numerator + koc-native (555 = 515 + 40);
2. leaf commands = headline numerator + koc-native (565 = 515 + 50);
3. every raw row denominator summed = 901, and minus the two not-targeted rows
(swift 17 + manila 40) = the in-scope denominator (844).

Expand Down
Loading