Skip to content

feat(s3): bring koc s3 to parity with s5cmd - #18

Merged
ftarasenko merged 3 commits into
masterfrom
claude/kind-allen-kr9gqs
Sep 14, 2026
Merged

ftarasenko merged 3 commits into
masterfrom
claude/kind-allen-kr9gqs

Conversation

@ftarasenko

Copy link
Copy Markdown
Owner

Closes the gap between koc s3 and s5cmd, measured against its actual command surface (command/*.go in that repo) rather than its README.

Before this, koc s3 could list, show, download and upload one object at a time. Nothing could create a bucket, remove anything, move data larger than 5 GiB, or work on more than one object per invocation.

What lands

Bucket and object lifecyclebucket create/delete/show/set, object delete, du. A bucket's whole lifecycle no longer needs s5cmd or s3cmd alongside koc.

Transfers

  • Uploads are multipart past --part-size (16 MiB default), --concurrency parts at once, so the 5 GiB single-PUT ceiling is gone. A failure aborts the upload, so a half-written object never becomes visible.
  • upload - reads standard input: mysqldump | gzip | koc s3 upload - db-backups/nightly.sql.gz. Multipart is what makes this possible — SigV4 must hash a single-PUT body before sending it, and a pipe cannot be rewound.
  • --recursive (or a wildcard reference) on download, upload, copy, move and object delete, --concurrency objects at a time, with --include/--exclude globs and --dry-run.

New verbscopy/move (server-side, so the bytes never travel through koc), presign (local signing, no request), sync (local↔S3 and S3↔S3, transferring only what differs).

Other — retry with exponential backoff (--s3-retries), --s3-anonymous, batched DeleteObjects (1000 keys per request instead of one DELETE each), object list --delimiter /, --human sizes, and object versioning (bucket show/set --versioning, --all-versions, --version-id).

Deliberately not implemented: s5cmd's select (S3 Select SQL — AWS-only, absent from Garage) and run (a batch-command file, which exists to feed s5cmd's worker pool).

Cost

Zero new modules — the graph is still 9, and vendor/, go.mod and go.sum are untouched. No new stdlib import either. The binary grew 16,490,658 → 16,744,610 bytes (+253,952, +1.54%), of which sync alone was +40,960 (+0.245%).

Design decisions worth reviewing

  • sync requires the s3:// prefix, alone in this group. Every other verb takes a bare <bucket>/<key> because it has one subject; sync has two, and mistaking which side is local is how a --delete removes the wrong one.
  • sync compares size and Last-Modified. S3 keeps no record of a source file's mtime, and — decisively — a listing would not return one if it did, so x-amz-meta-mtime would cost a HEAD per object. Last-Modified is correct in the steady state: an upload lands after the file was written, a download after the object was, so neither direction re-transfers what it just moved. The one seam is a round trip, where a restored tree carries the restore time and syncing it back re-uploads once before settling; documented, with --size-only as the way out. Timestamps compare truncated to the second, or a nanosecond local mtime makes every file look newer than its own copy.
  • --delete is refused when the source turned out to be empty unless --force is also given. A mistyped source that lists nothing would erase the destination, and that is not recoverable by re-running the command. Entries --include/--exclude leave out are outside the sync and are never deleted.
  • A recursive download refuses a key that would escape the destination. ../../etc/cron.d/x is a legal S3 key; writing it where it says would let anyone who can write to the bucket write anywhere koc can.
  • Recursive download skips existing files so an interrupted restore resumes by re-running; the single-object form still errors without --force, keeping the existing "don't clobber a backup" behaviour.
  • --concurrency is not squared. Under --recursive the pool's workers are objects and each object's multipart then goes out a part at a time, so --concurrency 8 does not mean 64 live part buffers.
  • A copy and a multipart completion can both be refused inside a 200 response. Both paths check the body for an <Error> document rather than decoding only the fields they want and reporting success.
  • Exact bytes stay the default everywhere; --human is opt-in, and the formatter went into internal/output rather than inline.
  • request.body is now an io.ReadSeeker so a retry can replay it, rewound to the caller's offset rather than to byte zero. ListObjects/ListObjectsFunc take a ListOptions struct instead of three positional arguments.

Verification

Presigned URLs are asserted against golden signatures from an independent SigV4 query-signing implementation written from the AWS specification. A URL is only useful if a server accepts it, so agreeing with a second implementation is the property worth testing — a test that re-derived the value with these same functions would pass however wrong both were.

The whole surface was then exercised end to end against a mock endpoint that verifies SigV4 itself (header and query) and speaks the real multipart protocol: stdin upload; a 12 MiB three-part upload with part ordering and reassembly asserted; recursive download/upload with skip-on-exists; wildcard selection; server-side copy and move; batch delete with a checked Content-MD5; bucket show; du --group --human; a presigned URL fetched with curl (and a tampered one rejected); and all three sync directions including --delete both ways, its empty-source refusal, --dry-run, --include scoping both sides, and the documented round-trip seam re-uploading once and then settling. Every request in those runs verified its signature; the only failures logged were the deliberate ones.

Offline gate on each commit: gofmt clean, go vet clean, golangci-lint v2.13.2 → 0 issues, go test ./... green, -race green, the -mod=vendor GOPROXY=off static build, and all six cross-build targets.

docs/coverage.md was re-derived mechanically rather than edited by hand — 565 leaf commands, 50 koc-native, all three arithmetic identities hold.

Note for the maintainer

gofmt from go1.27.1 disagrees with the go1.27.0-built golangci-lint this repo pins, and wants to reformat three files this PR does not touch (internal/auth/provider_test.go, internal/cli/baremetal/allocation.go, internal/cli/network/qos.go) in exactly the multi-value-composite-literal-return shape AGENTS.md warns about. New code here avoids that construct entirely, so the lint gate is clean — but make fmt under 1.27.1 would revert those three and the lint job would then reject them. AGENTS.md's "gofmt here means Go 1.27's" may need a patch-level qualifier.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TQ2Xt7Epc3n8XAXiAgPXiC


Generated by Claude Code

ftarasenko and others added 3 commits September 14, 2026 10:58
Closes the write side of "koc s3": the group could list, show, download
and upload, but nothing could create a bucket or remove anything, so a
bucket's whole lifecycle still needed s5cmd or s3cmd next to koc.

  koc s3 bucket create <bucket>            # s5cmd mb
  koc s3 bucket delete <bucket> [...]      # s5cmd rb
  koc s3 object delete <bucket>/<key> [...] # s5cmd rm
  koc s3 object delete <bucket>/<prefix> --recursive
  koc s3 du <bucket>[/<prefix>] [--group]  # s5cmd du

Notes on the shapes chosen:

- "bucket create" states the bucket's location as the signing region,
  which is the only value a store can accept — a request signed for one
  region is not served by another — so there is no separate location
  flag. us-east-1 is special-cased: AWS rejects a body that names it.
  Re-creating a bucket the credentials own fails rather than succeeding
  quietly, so a script can tell "I made this" from "it was there".
- "bucket delete" never removes objects implicitly; BucketNotEmpty is
  turned into the recursive-delete command that fixes it.
- "object delete --recursive" is the one destructive shape in the group,
  so it is spelled out rather than inferred from a trailing slash or a
  wildcard, and --dry-run prints exactly the keys it would remove. It is
  also how a bucket is emptied before "bucket delete". Deleting is
  idempotent because S3 answers a delete of an absent key with success;
  that is documented rather than papered over.
- "du" is a listing folded into a sum, because S3 has no size call. It
  streams via the new ListObjectsFunc, so emptying or totalling a bucket
  costs one page of memory whatever its size; ListObjects is now a thin
  wrapper over it and its behaviour is unchanged.
- Deletes follow koc's batch contract (internal/cli/batchdelete): every
  ref is attempted, failures are joined.

parseBucketRef rejects a ref carrying a key, so "bucket create a/b" is an
error instead of silently making a bucket named "a".

Exercised end to end against a mock S3 endpoint (create, re-create,
upload, list, du, du --group, recursive dry-run, recursive delete,
not-empty refusal, delete, and the s3:// ref spelling), plus unit tests
at both the client and the runXxx seam.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQ2Xt7Epc3n8XAXiAgPXiC
Closes the rest of the gap against s5cmd. Every item below was something
"koc s3" could not do at all, and several were hard limits rather than
missing conveniences.

Transfers
  - Uploads are multipart past --part-size (16 MiB default, --concurrency
    parts at once), so the 5 GiB single-PUT ceiling is gone: a dump of any
    size uploads. A failure aborts the upload, so a half-written object
    never becomes visible and the stored parts stop accruing cost.
  - "koc s3 upload -" reads standard input, which multipart is what makes
    possible: SigV4 has to hash a single-PUT body before sending it, and a
    pipe cannot be rewound. "mysqldump | gzip | koc s3 upload - b/k" now
    works without staging the dump on disk.
  - --recursive (and a wildcard reference) on download, upload, copy, move
    and object delete works over a whole prefix or tree, --concurrency
    objects at a time — a thousand small objects were a thousand serial
    round trips. --include/--exclude take globs where "*" spans "/", as in
    s5cmd; --dry-run prints exactly what would move.
  - A recursive download mirrors each key's path below the prefix, skips
    files that already exist (so an interrupted restore resumes by
    re-running), and refuses a key that would land outside the destination:
    "../../etc/cron.d/x" is a legal S3 key, and writing it where it says
    would let whoever can write to the bucket write anywhere koc can.

New verbs
  - "copy"/"move": server-side, 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.
  - "presign": a URL that reads (or, --method put, writes) one object with
    no key attached. Pure local signing, no request, so it works offline.
  - "bucket show": HEAD-based existence/reachability probe plus the
    versioning state, which is also how to tell Garage from a store that
    has versioning.
  - "bucket set --versioning enabled|suspended".

Other
  - A retryable failure (5xx, 429, 408, SlowDown, or a transport error) is
    retried with exponential backoff, --s3-retries times, default 5. A
    failure raised by the response sink is never replayed: it has already
    written part of the object to a file or to stdout.
  - --s3-anonymous sends requests unsigned, for a publicly readable bucket;
    it is the only mode that needs no credentials.
  - Recursive deletes go out in batches of up to 1000 keys per request
    instead of one DELETE per key, with the Content-MD5 AWS requires.
    Per-key refusals arrive inside a 200 and are reported as failures while
    the rest of the batch is still reported deleted.
  - "object list --delimiter /" collapses each subtree into one entry, so a
    deep bucket walks one level at a time.
  - --human renders sizes for a reader on object list, object show and du;
    exact bytes stay the default everywhere, because a rounded "14.2 GiB"
    is not a number a script can add up. The formatter lives in
    internal/output, not inline.
  - --all-versions / --version-id on list, show, delete, download and copy.
    Garage implements no versioning, so these are for AWS, Ceph RGW or
    MinIO; Garage's own answer ("unversioned") is reported honestly.

Implementation notes
  - request.body is now an io.ReadSeeker so a retry can replay it, rewound
    to the caller's offset rather than to byte zero.
  - ListObjects/ListObjectsFunc take a ListOptions struct (prefix,
    delimiter, limit, versions) instead of three positional arguments.
  - A copy and a multipart completion can both be refused inside a 200
    response; both paths check the body for an <Error> document rather than
    decoding only the fields they want and reporting success.
  - Batch deletes and PutBucketVersioning need MD5 (Content-MD5), which is
    fixed by the protocol and not a security choice; the reasoning sits in
    internal/s3/integrity.go next to the nolint.
  - Under --recursive the pool's workers are objects and each object's own
    multipart then goes out a part at a time, so --concurrency is not
    squared into that many part buffers.

Verification. Presigned URLs are asserted against golden signatures from an
independent SigV4 query-signing implementation written from the AWS spec —
a URL is only useful if a server accepts it, so agreeing with a second
implementation is the property worth testing. The whole surface was then
exercised end to end against a mock endpoint that verifies SigV4 itself
(header and query) and speaks the multipart protocol: stdin upload, a
12 MiB three-part upload, recursive download/upload with skip-on-exists,
wildcard selection, copy, move, batch delete with a checked Content-MD5,
bucket show, du --group --human, and a presigned URL fetched with curl
(plus a tampered one rejected). Every request in that run verified; the
only signature failures logged were the deliberate ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQ2Xt7Epc3n8XAXiAgPXiC
The last s5cmd capability koc did not have. It needed no new dependency and
no new import: sync is a comparison layer over the verbs the previous
commit landed, so it cost +40,960 bytes of binary (+0.245%) and the module
graph is still 9.

  koc s3 sync ./restore s3://db-backups/2026/     # local -> S3
  koc s3 sync s3://db-backups/2026/ ./restore     # S3    -> local
  koc s3 sync s3://db-backups/ s3://archive/      # S3    -> S3, server-side

The decisions, which are the actual content here:

- The s3:// prefix is mandatory on this command alone. Every other verb
  guesses nothing because it has one subject; sync has two, and mistaking
  which side is local is how a --delete removes the wrong one. A bare path
  is always local.
- An object moves when it is absent at the destination, 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.
- The S3-side timestamp is Last-Modified. S3 keeps no record of the source
  file's mtime, and — decisively — a listing would not return one if it
  did, so using x-amz-meta-mtime would cost a HEAD per object and defeat
  the point. Last-Modified is correct in the steady state: an upload lands
  after the file was written, a download after the object was, so neither
  direction re-transfers what it just moved. The one seam is a round trip,
  where a restored tree carries the restore time and syncing it back
  re-uploads once before settling. Documented, with --size-only as the way
  out. Timestamps are compared truncated to the second, because S3 reports
  Last-Modified at that resolution and a nanosecond local mtime would
  otherwise make every file look newer than its own copy.
- --delete is refused when the source turned out to be empty unless --force
  is also given. A mistyped source that lists nothing would erase the
  destination, and that is not recoverable by re-running the command.
  Entries --include/--exclude leave out are outside the sync and are never
  deleted. Local deletion removes files, not the directories behind them.
- The destination is the side held in memory, not the source: a lookup per
  source entry decides each transfer, and --delete then wants exactly the
  entries nothing looked up. The two sides cannot be streamed against each
  other because S3 lists keys lexicographically while a directory walk
  descends depth-first, so "a.txt" and "a/b" come out in opposite orders.
- A local side must be a directory. A single file as the source would
  otherwise walk to the relative path "." and upload itself under that key.

Reuses downloadToFile (atomic, removes a partial file), PutObjectStream
(multipart), CopyObject, the batched DeleteObjects, the worker pool and the
glob filter. Uploads run one part at a time inside the pool so
--concurrency is not squared into that many part buffers. A download
destination goes through the same traversal check as the recursive one,
since an object key is server-supplied data.

Exercised end to end against a mock endpoint that verifies SigV4 itself and
records real per-object Last-Modified: a tree synced up, then reported
already in sync; one edited and one added file moving alone; all three
directions; --delete in both directions and its empty-source refusal;
--dry-run writing nothing; --include scoping both sides; and the
documented round-trip seam re-uploading once and then settling. Every
request in that run verified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQ2Xt7Epc3n8XAXiAgPXiC
@ftarasenko
ftarasenko merged commit 5d317e5 into master Sep 14, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant