feat(s3): bring koc s3 to parity with s5cmd - #18
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the gap between
koc s3and s5cmd, measured against its actual command surface (command/*.goin that repo) rather than its README.Before this,
koc s3could 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 lifecycle —
bucket create/delete/show/set,object delete,du. A bucket's whole lifecycle no longer needs s5cmd or s3cmd alongsidekoc.Transfers
--part-size(16 MiB default),--concurrencyparts 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) ondownload,upload,copy,moveandobject delete,--concurrencyobjects at a time, with--include/--excludeglobs and--dry-run.New verbs —
copy/move(server-side, so the bytes never travel throughkoc),presign(local signing, no request),sync(local↔S3 and S3↔S3, transferring only what differs).Other — retry with exponential backoff (
--s3-retries),--s3-anonymous, batchedDeleteObjects(1000 keys per request instead of one DELETE each),object list --delimiter /,--humansizes, 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) andrun(a batch-command file, which exists to feed s5cmd's worker pool).Cost
Zero new modules — the graph is still 9, and
vendor/,go.modandgo.sumare untouched. No new stdlib import either. The binary grew 16,490,658 → 16,744,610 bytes (+253,952, +1.54%), of whichsyncalone was +40,960 (+0.245%).Design decisions worth reviewing
syncrequires thes3://prefix, alone in this group. Every other verb takes a bare<bucket>/<key>because it has one subject;synchas two, and mistaking which side is local is how a--deleteremoves the wrong one.synccompares size andLast-Modified. S3 keeps no record of a source file's mtime, and — decisively — a listing would not return one if it did, sox-amz-meta-mtimewould cost a HEAD per object.Last-Modifiedis 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-onlyas the way out. Timestamps compare truncated to the second, or a nanosecond local mtime makes every file look newer than its own copy.--deleteis refused when the source turned out to be empty unless--forceis also given. A mistyped source that lists nothing would erase the destination, and that is not recoverable by re-running the command. Entries--include/--excludeleave out are outside the sync and are never deleted.../../etc/cron.d/xis a legal S3 key; writing it where it says would let anyone who can write to the bucket write anywherekoccan.--force, keeping the existing "don't clobber a backup" behaviour.--concurrencyis not squared. Under--recursivethe pool's workers are objects and each object's multipart then goes out a part at a time, so--concurrency 8does not mean 64 live part buffers.<Error>document rather than decoding only the fields they want and reporting success.--humanis opt-in, and the formatter went intointernal/outputrather than inline.request.bodyis now anio.ReadSeekerso a retry can replay it, rewound to the caller's offset rather than to byte zero.ListObjects/ListObjectsFunctake aListOptionsstruct 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 withcurl(and a tampered one rejected); and all threesyncdirections including--deleteboth ways, its empty-source refusal,--dry-run,--includescoping 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:
gofmtclean,go vetclean, golangci-lint v2.13.2 → 0 issues,go test ./...green,-racegreen, the-mod=vendor GOPROXY=offstatic build, and all six cross-build targets.docs/coverage.mdwas re-derived mechanically rather than edited by hand — 565 leaf commands, 50 koc-native, all three arithmetic identities hold.Note for the maintainer
gofmtfrom 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 — butmake fmtunder 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