M3-02: S3 driver against SeaweedFS¶
Status: ready | Depends on: M1-09, M1-22, M3-01 | Milestone: M3
Goal¶
After this ticket the module files can keep its objects in any S3-compatible service: internal/files/adapters/s3 is the second implementation of the object storage port ObjectStore (12 4.1), selected with file.storage.driver = s3. The driver uses only core S3 (PUT, GET with a range, HEAD, DELETE of single objects, multipart upload and presigned URLs for upload parts) through one narrow Go interface, takes its access keys only from the secret provider, sends every request through the egress client, and leaves SDK checksums to file.s3.checksum_mode (12 4.3, ADR 0010). The conformance suite that M3-01 runs against the file system driver runs unchanged and green against SeaweedFS in a throwaway container, an architecture rule keeps the S3 SDK inside this one adapter, and no code path can send DeleteObjects. Why: several API nodes and managed cloud storage need the S3 driver (ADR 0010, consequence on the file system driver), and staying with the few operations every compatible service implements keeps the product portable (B4, R-Objektspeicher); presigned part uploads are proven to bind their length against SeaweedFS (12 3.3, RK-42), so the optional presigned mode has its first proof.
Read first¶
- Dependencies: "Depends on" is M1-09, M1-22 and M3-01; M1-07, M1-05 and M1-01 follow from them. The ticket builds on the secret provider (M1-09) and the egress client (M1-22), which the plan row lists; see "Boundaries". The keys
http.transfer_timeoutandfile.max_upload_bytesare registered by M1-18 (register them here if the merged schema lacks them). docs/PLAN.md: section "9. M3 Platform services" (Goal, Scope, Out of scope, Exit criteria item 1, Test strategy "conformance suite against the file system and SeaweedFS", Red team T-NW-01, Risks "presigned uploads may not bind the part length"), rows M3-01, M3-02, M3-03, M3-04, M3-05, M3-06 and M3-08; section "3. Way of working per ticket"; section "5. Test strategy" (row "Test doubles": SeaweedFS container for M3-02); P-09, P-10 and P-11 in "19. Decisions of this plan".docs/spec/12-files.md: "1. Goals and limits" (item 3), "3.1 Creating an upload session" (one part plan for both drivers, the S3 part limits [Q1]), "3.2 Transferring parts (modedirect, default)" (the S3 driver passes each part through as a multipart part, no buffer in the server), "3.3 Presigned PUT (modepresigned, optional)" (length binding, mode stays off), "3.4 Completion and verification pass" (CompleteMultipartUpload, the own SHA-256), "4.1 Port", "4.2 File system driver (default)" (key grammar), "4.3 S3 driver (core S3 only)", "4.4 Key schema" (file.s3.prefix, objects are never overwritten), "12. Retention, deletion, deduplication" (a missing object counts as deleted), "14. Observability", "15. Tests" (rows "Driver conformance" and "The S3 driver depends only on a Go interface with the port operations"), "16. Decisions of this chapter" (R-FI-02, R-FI-03), "18. Configuration values of this chapter" (file.storage.driver, everyfile.s3.*key), "19. Sources" (Q1, Q3, Q4, Q5).- ADR 0010 (Decision Outcome "core S3", Confirmation, More Information on
when_required), ADR 0048 (Confirmation: onlyinternal/files/adapters/s3imports the S3 SDK, and the port has no operation for signed download URLs), ADR 0011 (secret provider), ADR 0008 (depguard as the fallback for rules per file pattern), ADR 0050 (module register), ADR 0041 (pinned images and Renovate). docs/spec/02-architecture.md: "5. Layers within a module" (rule 4, foreign libraries per adapter), "6. Code layout" (test helpers ininternal/<module>/adapters/<module>test), "7.1 Module register as the source", "7.2 go-arch-lint", "13. Configuration loading" (items 3 to 6), "14.1 Error kinds" (dependency_unavailable), "14.2 Deadlines" (rows "upload and download" and "outbound calls"), "14.3 Retries", "14.4 Failure of a dependency" (rows object storage and secret provider), "15.2 Health".docs/spec/03-data-model.md"4.7 Files (files)", tablefiles(object_key,size_bytesup tofile.max_upload_bytes). Read only.docs/spec/06-api.md: "5. Errors", "10. Files".docs/spec/16-operations.md: "4.2 Two listeners" (readiness of the object storage from M3), "5.2 Secret provider (ADR 0011)", "5.3 Secrets in the Compose stack", "8.1 What is backed up" (objects with drivers3), "12. Status page" (files.storage).docs/spec/19-security.md: "2. Transport" (SEC-TLS-4), "7. Upload hardening" (SEC-UP-8, SEC-UP-11), "8. SSRF ban and outgoing connections" (SEC-NET-1 to SEC-NET-5), "9. Logs without secrets and without content" (SEC-LOG-1, SEC-LOG-3), "10. Secrets" (row Object storage credentials, SEC-SEC-1 to SEC-SEC-3), "14. Tests" (SEC-T02, SEC-T09, SEC-T11), "15. Decisions of this chapter" (R-SE-04, R-SE-10), "16. Configuration values of this chapter" (egress.*).docs/operations/configuration.md: "2. Naming rule and formats" (secret_ref, Egress target), "3. Check", the rows of chapter 12 in section 4, "5. Secrets" (s3_access_key,s3_secret_keyand the paragraph "Generators of later milestones"), "9. Compose variables" (BOTHY_STORAGE_DRIVER,BOTHY_S3_ENDPOINT,BOTHY_S3_BUCKET,BOTHY_SEAWEEDFS_VERSION).infra/compose/compose.yaml(anchorx-server-env, serviceseaweedfs),infra/compose/.env.example,infra/compose/README.md(profiles3),infra/tofu/modules/aws/README.md(row "Files", note 2).docs/security/threat-model.md: "2. System boundaries" (TB7), "5.8 Files, object storage, virus scanner", register rows T-NW-01 and T-UP-01.docs/RISKS.md: RK-42.docs/DECISIONS.md: D-R-OBJEKTSPEICHER, D-FI-02, D-FI-11, D-W-100 and section "10. Rulings of the build milestones".docs/observability/signals.md: the counterbothy.egress.blocked; the egress log of a blocked attempt carries the purpose (19 SEC-NET-5, M1-22 design note 8).- Briefs:
M3-01-object-storage-fs.md(written together with this brief; design notes 1 to 4 on port, errors and key grammar, 7f on replacing a key, 10 on multipart, 13 on configuration and wiring, 14 on the conformance suite),M1-22-egress-client.md,M1-09-secret-provider.md,M1-07-config-schema-loader.md,M1-05-server-skeleton.md,M1-01-server-gates.md,M1-12-postgres-migrations.md(design note D7 onpgtest: the pattern for throwaway containers),M1-18-http-chain.md(keys it registers),M1-34-identity-sessions.md(precedent for a depguard rule). - Code (merged on the base branch):
services/server/internal/platform/egress(New,Config,OptionswithPurpose,Hosts,Redirects,Timeout,MaxResponseBytes,Deps,HostPolicy,RedirectNever,Client.HTTPClient,ErrBlocked,ErrResponseTooLarge, its README),internal/wire/egress.go(EgressConfig),internal/wire/config.go(configKeyGroups),internal/wire/configchecks.go(configChecks),internal/wire/configchapters.go,internal/wire/config_test.go(TestConfigSchemaBuilds,TestConfigKeysMatchChapterTables,TestDefaultsPassCrossChecks),internal/platform/config(Key, the kinds; an empty default is allowed only for string and list keys),internal/kernel/errs(Wrap,CodeUnavailable,CauseDependencyUnavailable;LogValuewrites the wrapped error's text into the log),internal/platform/secrets(M1-09), and from M3-01:internal/files/app/objectstore.go(ObjectStore,ByteRange,ObjectInfo,Part,PresignedPartand the errorsErrInvalidKey,ErrNotFound,ErrInvalidRange,ErrSizeMismatch,ErrUploadNotFound,ErrInvalidPart,ErrNotSupported),internal/files/domain/objectkey.go(ValidateKey,ParseOriginalKey),internal/files/adapters/fs,internal/files/adapters/filestest(RunObjectStoreConformance,Options),internal/wire/configkeys_files.go(filesConfigKeys) andinternal/wire/files.go(filesStorageValues,FilesObjectStore, which still refuses drivers3); alsoarchitecture/modules.yaml,.golangci.yml(forbidigo with type analysis),.semgrep/bothy-go-egress-only.yaml.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the number. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md) is not edited by this ticket.
Product requirements implemented here:
- Core S3 only: PUT, GET with a range, HEAD and DELETE of single objects, multipart upload and presigned URLs for upload parts. No listing, copying, batch delete (
DeleteObjects), versioning, bucket policies, lifecycle or encryption headers, and no presigned GET (12 4.1, ADR 0010, ADR 0048). - The S3 driver depends only on a Go interface with exactly these operations; an architecture rule allows the S3 SDK only in
internal/files/adapters/s3(12 15, ADR 0010 and ADR 0048 Confirmation). - AWS SDK for Go v2 with its own endpoint
file.s3.endpoint, regionfile.s3.region, bucketfile.s3.bucket, path stylefile.s3.use_path_styleand the optionalfile.s3.prefixbefore every key (12 4.3, 4.4). - Checksum mode
file.s3.checksum_modefor request and response, defaultwhen_required; the own SHA-256 of 12 3.4 replaces the SDK checksums (12 4.3, Q3). - Credentials only as the references
file.s3.access_key_refandfile.s3.secret_key_refthrough the secret provider, read only withfile.storage.driver = s3; a rotation works without restart aftersecrets.cache_ttl; a secret that cannot be read stops the start (12 4.3, 12 18, 16 5.2, 02 14.4, 19 10). - Every S3 request goes through the egress client; the object storage is a target its consumer names;
httpand private addresses only with anegress.allowed_hostsentry withallow_private; TLS is always verified (19 SEC-NET-1 to SEC-NET-3, SEC-TLS-4). - The same conformance suite runs against the file system driver and against SeaweedFS in a throwaway container (12 15, ADR 0010 Confirmation, PLAN 9 "Test strategy").
- Deleting a missing object is no error; parts pass through as multipart parts without a buffer in the server (12 4.1, 12 3.2, 12 12).
- An unreachable object storage makes uploads and downloads
503: the driver reports it as a storage failure without a port sentinel, which callers map to503 unavailable(M3-01 note 3); no retry of external calls in the request path (02 14.3, 14.4). - Keys carry no names and no channel or space IDs; the driver refuses keys outside the one key grammar of all drivers (12 4.2,
domain.ValidateKeyof M3-01) before any request (12 4.4, SEC-UP-8). - A presigned upload part binds the part length (12 3.3, R-FI-02, RK-42); the mode
presignedstays off by default (file.s3.presigned_upload). - Logs, errors and telemetry carry no secret, no request URL and no presigned URL (SEC-LOG-1, SEC-LOG-3, SEC-SEC-3).
Scope¶
All paths under services/server/ unless noted. No migration, no operation to enable in api/operations.yaml, no event, no job, no audit entry, no new metric and no new top-level Makefile target.
Create (package internal/files/adapters/s3):
doc.go: package comment with the rules of this brief and a construction example.config.go:type Config struct { Endpoint, Region, Bucket, Prefix string; UsePathStyle bool; ChecksumMode string; AccessKeyRef, SecretKeyRef string }withconfig:"file.s3.<name>"tags;func (c Config) Validate(allowed []egress.Rule) error(design note 9);func (c Config) HostPolicy() egress.HostPolicy(design note 2).store.go:type Store struct;type Deps struct { Egress egress.Config; EgressDeps egress.Deps; Secrets SecretResolver; TransferTimeout time.Duration; MaxObjectBytes int64 };func New(ctx context.Context, cfg Config, deps Deps) (*Store, error); every method of the port with the signatures of M3-01; a compile-time assertion that*Storeimplements the port.api.go: the unexported interfacescoreAPIandpresignAPI(design note 3).credentials.go:type SecretResolver interface { Resolve(ctx context.Context, raw string) (secrets.Value, secrets.Version, error) }(implemented by*secrets.Provider) and the unexported credentials provider (design note 7).keys.go:objectName(prefix, key string) (string, error)(design note 4).errors.go: the error mapping and the error type with a clean text (design note 6).- Tests of "Tests (write them first)",
export_test.go(test seam that wraps the HTTP client of the driver with a recorder) andseaweed_test.go(container helper with the pinned image constant, design note 13).
Extend in internal/wire:
filesConfigKeys()inconfigkeys_files.go(M3-01): thefile.s3.*keys of design note 9.configchecks.go: the cross-checkfile_s3(design note 9).files.go(M3-01):filesStorageValuesgains thefile.s3.*fields, and the branchs3ofFilesObjectStorebuilds the S3 driver instead of refusing it (design note 10).
Change:
internal/files/app/objectstore.go(M3-01):PresignUploadPartgains the parametersize int64beforettl(design note 8);internal/files/adapters/fsfollows the signature and keeps returningErrNotSupported; their tests follow.internal/wire/config_test.go: expected keys inTestConfigSchemaBuilds; registerhttp.transfer_timeout(chapter 02) andfile.max_upload_bytes(chapter 03) with their chapter defaults if the merged schema lacks them. If the base branch has the schema export of M1-08 (services/server/tools/configschema), regenerate it withgo generate ./tools/configschemaas M3-01 does.cmd/bothy:bothy config check --secretsresolvesfile.s3.access_key_refandfile.s3.secret_key_refonly with drivers3(12 18), if the merged command resolves every*_refkey unconditionally.architecture/modules.yaml: vendor groupawssdkandadapter_vendors: [awssdk]for modulefiles; regenerate.go-arch-lint.ymlwithgo generate ./tools/archgen(design note 11)..golangci.yml: the depguard rule and two forbidigo entries (design note 11).go.mod,go.sum:github.com/aws/aws-sdk-go-v2,github.com/aws/aws-sdk-go-v2/service/s3andgithub.com/aws/smithy-goat the newest release when you build (record the versions in the report);github.com/testcontainers/testcontainers-goif not yet present.renovate.json(repository root): a custom regex manager for the image constant of design note 13, unless an existing manager already matches Go image constants with a// renovate:comment.infra/compose/compose.yaml, serviceseaweedfs: only if design note 13 shows that SeaweedFS accepts wrong credentials with the current command.- Docs:
docs/spec/12-files.md(3.3 result of the length test, 4.1 part size ofPresignUploadPart, 4.3 egress client,allow_privateforhttpor private endpoints, required endpoint, no retries, unsigned streamed bodies, credential rotation; 18 value set offile.s3.checksum_modeand the two reference keys in two rows);docs/spec/02-architecture.md13 item 4 (new cross-check);docs/operations/configuration.mdsection 4 regenerated;docs/security/threat-model.mdrow TB7 (object bytes to an S3 service through the egress client, keys from the secret provider);infra/tofu/modules/aws/README.mdrow "Files" (access with the keys offile.s3.access_key_refandfile.s3.secret_key_ref, not the instance role, because 12 4.3 allows only secret references and 19 SEC-NET-3 always blocks the metadata address);internal/files/README.md;services/server/CHANGELOG.md;docs/DECISIONS.mdsection 10 rowsD-M3-02-<n>.
Out of scope¶
- The port
ObjectStore, the key schema, the file system driver and the cases of the conformance suite (M3-01); this ticket only changes the port where design note 8 says so. - Opening the store in the server roles and the image directory for the file system driver (M3-03, as M3-01 decided); upload sessions, parts, completion,
file_verify, the choice of a plain PUT for a single part,file.upload_part_bytesand its start checks (M3-03); scanning (M3-04); downloads with tickets (M3-05); image processing andorig-2(M3-06); deletion and cleanup jobs, including aborting expired multipart uploads (M3-08). - The
presignedmode in the upload flow: URLs in the session answer, ETags at completion,file.s3.presigned_upload,file.s3.presign_ttland the 12 15 row "Total checksum wrong (manipulated part in presigned mode, fake S3)". No plan row owns it (reported as a plan gap); this ticket builds and proves onlyPresignUploadPart. - The readiness check "the object storage answers" of 16 4.2: no plan row owns it and 02 15.2 names no check (reported); the status source
files.storage(M7-09). - The Compose profile
s3end to end: generateds3_access_keyands3_secret_keyinsecrets-init, creating the bucket, theegress.allowed_hostsentry for the stack's endpoint (reported as a plan gap; installation and portability are M7-29 and M7-14). - Backups of objects with driver
s3(16 8.1, M7); a CA bundle for private PKI (no configuration key exists, as in M1-22). - Changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M3-02-object-storage-s3; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(files): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0; SeaweedFS runs only as a throwaway container from the pinned image on a random loopback port, started and removed by the tests (P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip. - Stop only processes and containers you started, by PID or container ID. Temporary directories only via
mktemp -d(in Go testst.TempDir()), removed at the end. - Never read or print secrets; test keys are generated per run and contain
EXAMPLE. No network calls except package downloads for pinned tools and Go modules and the pull of the pinned SeaweedFS image. Run Go withGOTOOLCHAIN=local. Never contact a real S3 service or a cloud account; the only S3 endpoints in tests are the throwaway container andhttptestfakes. - Never call the SDK's default configuration loading or credential chain (environment, shared files, instance metadata service); never build an own
http.Client, transport or dialer, and add nonosemgrepforbothy-go-egress-only. - Never lower a threshold, disable a gate or delete a test.
- If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 or M2 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>. - The brief of M3-01 was written together with this one. Where the merged code of M3-01 names the port, its errors, the key check or the conformance suite differently, follow the code and list each difference in the report.
- The secret provider (M1-09) and the egress client (M1-22) are in "Depends on". If either is not merged into the base branch, stop with
Verdict: BLOCKED dependency M1-09 or M1-22 not merged.
Design notes¶
-
Layering. The driver is an adapter of module
files(02 5): it implements the outgoing portObjectStoreof M3-01 and may import the module's own packages,kernel,platform/egress,platform/secretsand the vendor groupawssdk.internal/wireuses onlys3.Config,s3.Depsands3.New; the app layer never knows which driver runs. -
Construction,
New, in this order. a.cfg.Validate(deps.Egress.AllowedHosts)with the rules of design note 9, so a direct caller cannot skip them. b. ResolveAccessKeyRefandSecretKeyRefonce throughdeps.Secrets. An error stops the start and names the key (file.s3.access_key_reforfile.s3.secret_key_ref), never a value (02 14.4 "secret provider", fail fast; the provider's file rules give SEC-SEC-1). c. One egress client:egress.New(deps.Egress, egress.Options{Purpose: "objectstore", Hosts: cfg.HostPolicy(), Redirects: egress.RedirectNever, Timeout: deps.TransferTimeout, MaxResponseBytes: deps.MaxObjectBytes}, deps.EgressDeps). The host policy allows exactly the request host and port: the endpoint host with path style,<bucket>.<endpoint host>with virtual-hosted style, the port of the endpoint or the default port of its scheme (19 SEC-NET-2: a consumer names its own targets).TransferTimeoutishttp.transfer_timeout: 02 14.2 lets a chapter name the limit of an outside call, and these bytes serve uploads and downloads, whose limit it is.MaxObjectBytesisfile.max_upload_bytes: no stored object is larger (03files.size_bytes), so a body is neither cut nor unbounded (19 SEC-NET-5). Redirects are never followed: an S3 redirect means a wrong endpoint or region. Ruling for docs/DECISIONS.md. d. Buildaws.Configby hand: region, the credentials provider of design note 7,HTTPClient: client.HTTPClient(), retryeraws.NopRetryer{}(02 14.3: no retry of external calls in the request path; jobs retry through River), request checksum calculation and response checksum validation fromChecksumMode(design note 9), no SDK log output, no tracer or meter provider (SDK spans and metrics have no names insignals.md, 02 5 rule 5). Thens3.NewFromConfigwithBaseEndpointset to the endpoint andUsePathStyle. Never importgithub.com/aws/aws-sdk-go-v2/configor callLoadDefaultConfig: it reads the environment and shared files and asks the instance metadata service, whose address 19 SEC-NET-3 always blocks and whose credentials 12 4.3 does not allow. Ruling for docs/DECISIONS.md. e. No request at construction; readiness is out of scope. -
Narrow interface.
coreAPIhas exactlyPutObject,GetObject,HeadObject,DeleteObject,CreateMultipartUpload,UploadPart,CompleteMultipartUploadandAbortMultipartUploadwith the SDK signatures;presignAPIhas exactlyPresignUploadPart.*s3.Clientand*s3.PresignClientsatisfy them, andStoreholds only these interfaces, so batch delete, listing, copying, bucket calls, versioning and presigned GET are unreachable (12 4.1, ADR 0010, ADR 0048). Mapping to the port of M3-01 (note 2 there):PuttoPutObject; an existing key is replaced, as on the file system (M3-01 note 7f).Get(ctx, key, rng)toGetObject; a non-nilByteRangebecomesRange: bytes=<Offset>-<Offset+Length-1>; S3 cuts a range at the end of the object exactly as the contract wants.Offset < 0andLength < 1giveErrInvalidRangebefore any request.HeadtoHeadObject, returningObjectInfo{Size};DeletetoDeleteObject.CreateMultipartaccepts only original keys (domain.ParseOriginalKey), elseErrInvalidKey, as the file system driver does (M3-01 note 10), and returns the S3 upload ID.UploadPartreturnsPart{Number, ETag}with the ETag exactly as S3 sent it, quotes included;n < 1givesErrInvalidPartbefore any request.CompleteMultipartchecks the list shape first (not empty, numbers strictly ascending; elseErrInvalidPart), then sends the list as completed parts.AbortMultiparttoAbortMultipartUpload;PresignUploadPartto the presign client (design note 8). No request sets server-side encryption, ACL, tagging, object lock, storage class or conditional headers (12 4.1 names encryption headers as not in the port; core S3 has no conditional write).
-
Keys and prefix. Every operation first runs
domain.ValidateKey(M3-01 note 4: one grammar for all drivers, which also refuses empty segments and a leading or trailing/, so path-style URLs can never collapse two keys into one object); a refused key returnsErrInvalidKeyand sends nothing. The object name is<file.s3.prefix>/<key>with a prefix, else the key (12 4.4); the prefix must passValidateKeyas well. Ruling for docs/DECISIONS.md. The driver adds nothing else to a key, so keys keep carrying no names and no channel or space IDs (SEC-UP-8). -
Bodies.
PutandUploadPartstream the caller's reader withContentLengthset to the size and never buffer it (12 3.2). The reader is not seekable and the endpoint may be plainhttpinside a private network, where the SDK cannot hash the payload before sending; sign the payload asUNSIGNED-PAYLOADwith the SDK's option for it (in current releasesv4.SwapComputePayloadSHA256ForUnsignedPayloadMiddleware; check the name in the version you pin). Integrity comes from the part digest (12 3.2) and the file SHA-256 of the single read pass (12 3.4), which 12 4.3 names as the replacement of SDK checksums. A body of another length must giveErrSizeMismatchwith nothing stored (M3-01 note 3): wrap the reader so that it holds back the last byte until the source reports the end. A longer body fails before the last byte leaves, a shorter one ends beforeContentLengthis reached; in both cases S3 never receives a complete request and stores nothing. A negative size givesErrSizeMismatchbefore any request. Ruling for docs/DECISIONS.md. -
Errors. The S3 driver keeps the error contract of M3-01 (note 3 there), so every case of the conformance suite gives the same result on both drivers:
NoSuchKey,NotFoundor status 404 onGetandHead:ErrNotFound;InvalidRangeor status 416:ErrInvalidRange;NoSuchUploadonUploadPartandCompleteMultipart:ErrUploadNotFound; onAbortMultipart: nil (an unknown, aborted or completed upload);InvalidPart,InvalidPartOrderandEntityTooSmallonCompleteMultipart:ErrInvalidPart; S3 keeps the upload open, so a later correct call succeeds;DeleteObjectof a missing key succeeds at S3 and returns nil (12 4.1, 12 12);- everything else a call returns (
NoSuchBucket,AccessDenied,InvalidAccessKeyId,SignatureDoesNotMatch,SlowDown, any 5xx, time-outs, connection errors,egress.ErrBlocked,egress.ErrResponseTooLarge, credential errors) is a storage failure: no port sentinel, which callers map to503 unavailablewith the internal causedependency_unavailable(M3-01 note 3, 02 14.1, 02 14.4 row object storage); - errors while the caller reads a
Getbody keep wrapping their cause (egress.ErrResponseTooLarge, a broken connection) and follow the text rule below. If SeaweedFS answers a case differently from the S3 behaviour this list assumes, fix the mapping in the driver, never the suite; if a case cannot pass with core S3, stop withVerdict: BLOCKED <case>. Read the S3 code witherrors.Asonsmithy.APIErrorand the status from the SDK's response error. As M3-01 requires, errors name the operation and the key and wrap their cause with%w. They never contain a request URL, a query, a presigned URL, the endpoint or credentials: net/http errors carry the full request URL, and callers that wrap the error intokernel/errsget its text into the log throughLogValue(SEC-LOG-3, SEC-SEC-3). Use an own error type whose text iss3 <operation> <key>: <S3 code, HTTP status or "transport">and whoseUnwrapkeepserrors.Isworking for the port sentinel and foregress.ErrBlocked. Ruling for docs/DECISIONS.md.
-
Credentials. An unexported
aws.CredentialsProviderresolves both references throughSecretResolveron everyRetrieveand marks the credentials as expiring, so that the SDK's credential cache asks again on the next request; the driver keeps no copy of its own. A rotated key takes effect without restart once the secret provider re-reads it aftersecrets.cache_ttl(16 5.2, 02 13 item 6). A failed re-read keeps the old value inside the provider (M1-09); any provider error becomes a storage failure. Secret bytes never reach logs, errors or telemetry (SEC-SEC-3). Ruling for docs/DECISIONS.md. -
Presigned upload parts. The port operation becomes
PresignUploadPart(ctx context.Context, key, uploadID string, n int, size int64, ttl time.Duration) (PresignedPart, error). The S3 driver presignsUploadPartwithContentLengthequal tosizeand the expiryttl(callers passfile.s3.presign_ttl), so the signature binds the part length (12 3.3 first open point, R-FI-02, RK-42), and returnsPresignedPart{URL, ExpiresAt}withExpiresAtequal to the signing time plusttl. 12 4.1 lists the operation without a size, and no driver can bind a length it does not know: add the parameter to the port, to the file system driver (which keeps returningErrNotSupportedafter the key check) and to 12 4.1 in the same change. Ruling for docs/DECISIONS.md (chapter 12 is not core contract). If the merged port already carries the size, keep it.n < 1givesErrInvalidPart,size < 1givesErrSizeMismatch, a non-positivettlis an error, each before signing. The URL goes to the caller only; it is never logged and never part of an error. The modepresignedstays off (file.s3.presigned_uploadkeeps its default); building it is out of scope. -
Configuration. Register in
internal/wire, chapter12-files.md, with the defaults of the chapter table "18.":file.s3.endpoint(string, empty),file.s3.region(string),file.s3.bucket(string, empty),file.s3.prefix(string, empty),file.s3.use_path_style(bool),file.s3.checksum_mode(enumwhen_required,when_supported),file.s3.access_key_refandfile.s3.secret_key_ref(secret_ref with the<secrets.dir>defaults).file.s3.endpointis a string key, not a URL key, because the schema allows an empty default only for string and list keys; the cross-check parses it.file.storage.drivercomes from M3-01 with the valuesfsands3. Do not registerfile.s3.presigned_uploadorfile.s3.presign_ttl: their consumer is thepresignedmode (02 13 item 5). Cross-checkfile_s3overfile.storage.driver, the six non-secretfile.s3.*keys andegress.allowed_hosts; it runs only with drivers3, bindss3.Config, parses the entries withegress.ParseRuleand callsConfig.Validate, so the rules live in one place; every message names keys, never values (02 13 item 4): a.file.s3.endpointis set and is an absolutehttporhttpsURL with a host and without user info, query, fragment or a path other than/. The driver never uses the SDK's endpoint resolution, so the host policy names exactly one host. Ruling for docs/DECISIONS.md. b.file.s3.bucketis set and follows the S3 bucket naming rules;file.s3.regionis set. c.file.s3.prefixis empty or follows the key grammar of design note 4. d. Withfile.s3.use_path_style = falsethe endpoint host is a DNS name, not an IP literal. e. Anhttpendpoint needs anegress.allowed_hostsentry withallow_privatefor the request host and port of design note 2c, because 19 SEC-NET-2 allowshttponly there; without the entry the server would start and fail at the first request. Ruling for docs/DECISIONS.md.when_requiredmaps toaws.RequestChecksumCalculationWhenRequiredandaws.ResponseChecksumValidationWhenRequired,when_supportedto the twoWhenSupportedvalues; 12 18 names the default but not the value set. Ruling for docs/DECISIONS.md. The two references are read only with drivers3(12 18). The chapter 12 table holds both references in one row andTestConfigKeysMatchChapterTablesreads one key per row: split the row into two rows with the same content (make config-referencekeeps section 4 unchanged). Add the check to the list in 02 13 item 4. -
Wiring. M3-01 built
FilesObjectStore(v *config.Values)ininternal/wire/files.gowith a branch that refuses drivers3until this ticket. Replace that branch: binds3.Configfrom the loaded values (extendfilesStorageValues), and build the store withs3.Newfromwire.EgressConfig(v), the egress logger and metrics that the other egress consumers get, the process's secret provider,http.transfer_timeoutandfile.max_upload_bytes. The S3 branch needs a context, the secret provider and the egress dependencies, which the current signature does not carry: extendFilesObjectStorewith them (for example aFilesDepsstruct) and keep thefsbranch unchanged. No role opens the store yet; M3-03 does that together with the image directory (M3-01 note 13), so its doc comment keeps saying so. A construction error is an error ofFilesObjectStorethat names the key, like any configuration or secret error. -
Architecture rule (12 15, ADR 0010 and ADR 0048 Confirmation).
architecture/modules.yaml: vendor groupawssdk: [github.com/aws/aws-sdk-go-v2, github.com/aws/aws-sdk-go-v2/**, github.com/aws/smithy-go, github.com/aws/smithy-go/**]andadapter_vendors: [awssdk]for modulefiles(entries, not fields, 02 7.1). go-arch-lint grants vendors to the componentinternal/files/adapters/**as a whole, so it cannot narrow the SDK to one adapter..golangci.yml: enabledepguardwith one rule that deniesgithub.com/awsin every file except**/internal/files/adapters/s3/**, production and test files alike (ADR 0008 names depguard for rules per file pattern; M1-34 uses it the same way).forbidigoentries fors3.Client.DeleteObjectsands3.PresignClient.PresignGetObjectin the form of the existingexec.Cmd.Environentry,pkg^github\.com/aws/aws-sdk-go-v2/service/s3$, with a message that names 12 4.1 and ADR 0048.- Show that both rules fail once (a temporary SDK import in
internal/files/adapters/fs, a temporaryDeleteObjectscall in the s3 package), revert, and put the lint output into the report. - Ruling for docs/DECISIONS.md.
-
Conformance suite. Run
filestest.RunObjectStoreConformance(t, newStore, filestest.Options{PartSize: ...})of M3-01 unchanged against the S3 driver.newStorereturns a store on a fresh, empty bucket for every subtest;PartSizeis the smallest part size S3 accepts for every part but the last, the limit that 12 3.1 cites [Q1] (M3-01 note 14), written in the test as a constant with that source in its comment. -
SeaweedFS in a throwaway container (P-09, P-10, 12 15 "testcontainers").
- testcontainers-go as in
pgtest(M1-12 D7): Ryuk disabled, the S3 port bound to127.0.0.1with a random host port, labelorg.bothy.test=seaweedfs, auto remove, one container per test binary started inTestMainand stopped afterm.Run. Without a Docker-compatible runtimeTestMainprints the reason and exits with code 1; never skip. - The image constant is
<repository>:<version>@sha256:<digest>with the image repository of the Compose serviceseaweedfsand a// renovate: datasource=docker depName=<repository>comment; the version equalsBOTHY_SEAWEEDFS_VERSIONininfra/compose/.env.exampleand the default ininfra/compose/compose.yaml, so the test proves the version the stack runs. Ruling for docs/DECISIONS.md. - Start command and credentials as in the Compose service
seaweedfs(weed serverwith the S3 gateway, keys asAWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEY), with generated test keys that containEXAMPLE. The driver reads the same keys from a temporary secrets directory (files at mode0600) through the real secret provider. IfTestS3WrongCredentialsRefusedshows that SeaweedFS accepts a wrong key with this command, switch the test andinfra/compose/compose.yamlto SeaweedFS's identity configuration and record why: an S3 gateway without authentication in the stack is a finding of this ticket. - Each test creates its own bucket with a random name, so tests run in parallel.
- Test code may use a full SDK client to create buckets and to inspect state (for example open multipart uploads after
AbortMultipart); the driver never does. Ruling for docs/DECISIONS.md. - The egress configuration of the tests holds the entry
127.0.0.1:<port>;allow_private, so the tests take the production path through the egress client.
- testcontainers-go as in
-
Telemetry and logs. The driver adds no metric, no span and no log line;
bothy.egress.blockedand the egress log with purposeobjectstoreare its only signals (19 SEC-NET-5). Callers log the port errors, whose texts design note 6 keeps clean. -
Rulings for docs/DECISIONS.md (section 10,
D-M3-02-<n>, one row each with reason): egress options and host policy (note 2c); no SDK default configuration and a required endpoint (notes 2d, 9a); no SDK retries (note 2d);UNSIGNED-PAYLOADfor streamed bodies (note 5); error texts without URL (note 6); credentials re-read on every SDK refresh (note 7); the prefix joined with/and checked with the key grammar (note 4); the held-back last byte forErrSizeMismatch(note 5);PresignUploadPartwith the part size (note 8); value set offile.s3.checksum_mode(note 9);httpendpoint only with anallow_privateentry, checked at load (note 9e); architecture rule with depguard and forbidigo (note 11); SeaweedFS test image version equal to the stack's and test-only use of the full SDK client (note 13). Record the result ofTestPresignedPartBindsLengthin 12 3.3: the length binding is proven for SeaweedFS at the pinned version; client reachability of the storage stays open.
Tests (write them first)¶
The plan row names no AG, BR, AZ, TM, ID, SEC or G test. It holds two rows of 12 15: "Driver conformance" (TestS3Conformance) and "The S3 driver depends only on a Go interface with the port operations; architecture rule" (TestCoreAPIMethodSet, TestS3SDKOnlyInAdapter, TestS3RequestsAreCoreS3, the lint rules). Security tests this ticket touches: SEC-T02 (lint: no own HTTP client and no InsecureSkipVerify in the driver; make semgrep and make lint stay green), SEC-T09 (the driver's traffic passes the egress checks: TestEgressBlocksUnlistedEndpoint), SEC-T11 (an S3 secret file with mode 0644 stops the start: TestStartFailsWithInsecureSecretFile).
Unit (package internal/files/adapters/s3, no Docker; a fake S3 is an httptest server on 127.0.0.1:0 behind the real egress client with an allow_private entry, the recorder of export_test.go sees every request):
TestCoreAPIMethodSet: by reflectioncoreAPIhas exactly the eight methods of design note 3, and*s3.Clientsatisfies it.TestPresignAPIMethodSet:presignAPIhas exactlyPresignUploadPart; no presign method for GET, HEAD, DELETE or a whole-object PUT.TestObjectName: table with and without prefix; keys with.., a leading or trailing/,//, upper case, a space or non-ASCII giveErrInvalidKey; keys of the schema in 12 4.4 pass; a prefix outside the key grammar is refused.TestChecksBeforeAnyRequest: an invalid key, a range with a negative offset or length 0,UploadPartandPresignUploadPartwithn0,CompleteMultipartwith an empty or a descending list,CreateMultipartwith a variant key,Putwith a negative size andPresignUploadPartwith size 0: each returns its error of M3-01 note 3 and the fake counts no request.FuzzObjectName: for any prefix and key the result is an error or a name in the grammar that starts with the prefix and has no empty or..segment; the table cases are the seed corpus, which runs in everygo test.TestConfigValidate: each rule of design note 9 a to e fails with a message that names the key; a canary value in the key never appears in the message.TestChecksumModeMapping: both values map to their SDK pairs.TestHostPolicy: path style allows only the endpoint host and port, virtual-hosted style only<bucket>.<host>; every other host is refused.TestNoDefaultCredentialChain: withAWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEYandAWS_PROFILEset throught.Setenvto other values, the fake sees only the access key ID from the secret provider inAuthorization.TestCredentialsFollowRotation: the secret provider runs on a fake clock; after the key files change and the clock passessecrets.cache_ttl, the next request of the sameStoresigns with the new access key ID.TestStartFailsWithoutSecret:Newwithout the access key file returns an error that namesfile.s3.access_key_refand contains no value.TestStartFailsWithInsecureSecretFile(SEC-T11 path): the key file at mode0644makesNewfail with the provider's insecure-file error.TestNoRetries: the fake answers 503SlowDown; it receives exactly one request and the caller gets a storage failure that is none of the port sentinels (02 14.3).TestRedirectNotFollowed: the fake answers 307 with another host; exactly one request; a storage failure.TestEgressBlocksUnlistedEndpoint: anhttpsendpoint on loopback without anallow_privateentry:errors.Is(err, egress.ErrBlocked), no request at the fake,bothy.egress.blockedcounted once through a fakeegress.Metrics.TestOnlyGivenHTTPClient: after every port operation, the requests at the fake equal the requests the recorder saw, so the SDK opens no connection of its own (SEC-NET-1).TestErrorsCarryNoURLOrSecret: unknown key, wrong credentials, a closed port, an oversized body and a failedPresignUploadPart: the error text names the operation and the key and contains neitherhttp://,https://, the endpoint host and port,X-Amz-, the access key ID nor the secret; the same holds for theslogrendering of the error wrapped intoerrs.Wrap(errs.CodeUnavailable, err, ...).TestResponseLimitIsMaxObjectBytes: with a smallMaxObjectBytesfrom the test, aGetbody of exactly that size reads completely; with one byte more the read fails with an error that wrapsegress.ErrResponseTooLarge, never a short read without error.TestPresignUploadPartSignsLength: the URL'sX-Amz-SignedHeaderscontainscontent-lengthandhost,X-Amz-Expiresequalsttlin seconds, the URL contains no secret; attlof zero is refused.TestS3SDKOnlyInAdapter:go listover the module with imports, test imports and external test imports; every package that imports a path belowgithub.com/aws/isinternal/files/adapters/s3.TestSeaweedImagePinned: the constant has the image repository of the Compose serviceseaweedfs, a version and asha256digest; the version equalsBOTHY_SEAWEEDFS_VERSIONininfra/compose/.env.exampleand the default ininfra/compose/compose.yaml.
Unit and integration in internal/wire:
TestConfigSchemaBuildswith the new keys, their kinds, defaults and chapter.TestFileS3Check: with driverfseveryfile.s3.*value is ignored; with drivers3each violation of design note 9 fails with a problem that names the keys; a validhttpsendpoint and a validhttpendpoint with a matchingallow_privateentry load.TestDefaultsPassCrossChecksstays green.TestWireBuildsS3Store: drivers3with anhttpendpoint on anhttptestfake at127.0.0.1and a matchingallow_privateentry: aPutthrough the wired store reaches the fake with the access key ID from the test secrets directory; the same wiring with anhttpsendpoint on loopback without an entry is refused by the egress client and the fake counts no request. No container here: the SeaweedFS helper stays in the test files of the s3 package (design note 13).
Integration (SeaweedFS container, package internal/files/adapters/s3):
TestS3Conformance: the suite of M3-01 (Put, Get with a range, Head, Delete of a missing object, multipart with abort and every other case it holds) against SeaweedFS, with the same results as the file system driver.TestS3RequestsAreCoreS3: during the suite the recorder sees only these requests on/<bucket>/<object>: PUT, GET, HEAD and DELETE without query (the SDK'sx-id=<operation>allowed),POST ?uploads,PUT ?partNumber&uploadId,POST ?uploadId,DELETE ?uploadId. NeverPOST ?delete(DeleteObjects), never a request on the bucket itself, no listing query, nox-amz-copy-source, nox-amz-server-side-encryption*, and with the default mode nox-amz-checksum-*,x-amz-sdk-checksum-algorithmorx-amz-trailerheader.TestS3StreamsUnseekableBodies:PutandUploadPartwith readers that are not anio.Seekersucceed over plainhttp, and the requests carryUNSIGNED-PAYLOAD.TestS3PrefixSeparatesEnvironments: two stores on one bucket with prefixesenv-aandenv-b; the full client seesenv-a/<key>; neither store reads the object of the other.TestS3WrongCredentialsRefused: a store with a wrong secret key gets a storage failure, and the full client finds nothing written.TestS3UploadPartSizeMismatch: a part body one byte shorter and one byte longer thansizegivesErrSizeMismatch, and the full client lists no such part for the upload (the suite casePutSizeMismatchholds the same forPut).TestPresignedPartBindsLength: create a multipart upload and presign part 1 with a size of the test; a plain PUT of exactly that many bytes succeeds and the upload completes; a PUT with one byte more, a PUT with chunked transfer encoding and a PUT afterttlare refused and leave no part (checked with the full client). If SeaweedFS stores a part of another length, do not weaken the test: stop withVerdict: BLOCKED presigned part length not bound by SeaweedFSand report it (12 3.3 keeps the mode off).
Platform runs: the integration tests run on Linux and macOS with a Docker-compatible runtime; the pinned image digest is a multi-architecture index (amd64 and arm64).
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/files/... ./internal/wire/... ./cmd/...
make arch
make codegen-check
make lint
make semgrep
make licenses
make osv
make config-reference && git diff --exit-code docs/operations/configuration.md
make docs
make check
Run FuzzObjectName once locally with go test -run=^$ -fuzz=FuzzObjectName -fuzztime=<duration of your choice> ./internal/files/adapters/s3 from services/server and report duration and result; commit any crashing input as a seed after the fix.
Acceptance criteria¶
- The conformance suite of M3-01 runs unchanged in its cases against SeaweedFS in a throwaway container from the pinned image and is green (Put, Get with a range, Head, Delete of missing objects, multipart with abort).
- The S3 SDK is imported only by
internal/files/adapters/s3: the depguard rule is part ofmake lint,TestS3SDKOnlyInAdapteris green, and a temporary import elsewhere failedmake lint(output in the report). - No
DeleteObjects: the driver depends only on the eight-method core interface and the one-method presign interface, forbidigo forbidsDeleteObjectsandPresignGetObject, and every request recorded against SeaweedFS is a core S3 request. - Credentials come only from
file.s3.access_key_refandfile.s3.secret_key_refthrough the secret provider; a rotation works without restart; a missing or insecure secret file stops the start; SeaweedFS refuses a wrong key. - Every S3 request passes the egress client with purpose
objectstore;httpworks only with anallow_privateentry andbothy config checkfails without it; no retries, no redirects; checksum headers stay off withwhen_required. - A presigned upload part binds its length against SeaweedFS, and 12 3.3 records the result.
- Driver errors name the operation and the key and contain no request URL, query, endpoint, presigned URL or credentials.
- The
file.s3.*keys of design note 9 are in the schema, the cross-checkfile_s3holds, 12, 02 and the configuration reference are updated. -
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
internal/files/README.mdupdated (adapters3, its keys, the egress and secret needs, the vendor rule of design note 11, the conformance suite and how to run it with Docker).- Rulings of design note 15 recorded in
docs/DECISIONS.md(one row each with reason). services/server/CHANGELOG.mdentry under 'Unreleased'.- An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- What is not proven.
- Follow-ups that could not be done in this scope, each with its reason, and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.