carlos / platform Public

objstore: minio contract test pinning the delivered S3 contract

TestDeliveredContractMinio (internal/objstore/contract_test.go) builds an
S3 client exactly the way a consuming app would from delivered grant env
— static credentials, region us-east-1, BaseEndpoint, UsePathStyle, and
the RequestChecksumCalculation/ResponseChecksumValidation WhenRequired
pair mirroring aws.go's NewAWSProvisioner endpoint options — and asserts
Put/Get/List/Delete round-trip plus cross-bucket denial (PutObject to an
ungranted bucket fails with a smithy.APIError code "AccessDenied"). It
skips unless CARLOS_MINIO_TEST=1, reading MINIO_ENDPOINT,
MINIO_SCOPED_KEY, MINIO_SCOPED_SECRET, MINIO_BUCKET, MINIO_DENIED_BUCKET.

New minio-contract job in .github/workflows/ci.yml starts a real Minio
container, mints a scoped IAM user restricted to one bucket via mc, and
runs the test with CARLOS_MINIO_TEST=1 set.

This sandbox has no docker: the live path (skip-bypassed, against a real
Minio) has only ever been exercised by go vet/build reasoning here, not
run. Verified locally: the skip path runs green
(`go test ./internal/objstore/ -run TestDeliveredContractMinio -v` ->
SKIP), the full gate (go build/vet/test -count=1, gofmt -l, GOOS=darwin
build) stays green across all 21 packages, and ci.yml parses as YAML
(python3 + pyyaml). The first CI run on the PR is the real proof of the
live path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 35e3ff8033bfaa9949b1fc6eba6c9ae9e983fc89 parent 56a3f88
3 files changed, +227 −0
  • .github/workflows/ci.yml +45 −0
  • go.sum +5 −0
  • internal/objstore/contract_test.go +177 −0
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b461422e5..46acb187c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -70,6 +70,51 @@ jobs:
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
+ minio-contract:
+ # Exercises internal/objstore's TestDeliveredContractMinio: the S3
+ # contract a member app actually gets from delivered grant env (static
+ # creds, path-style, us-east-1, checksum WhenRequired), against a real
+ # S3-compatible server plus a scoped user IAM never granted the second
+ # bucket. This is the only place in the gate that runs that test live —
+ # see internal/objstore/contract_test.go's doc comment.
+ name: minio contract
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+ - name: start minio
+ run: |
+ docker run -d --name minio -p 9000:9000 \
+ -e MINIO_ROOT_USER=root -e MINIO_ROOT_PASSWORD=rootsecret \
+ quay.io/minio/minio server /data
+ for i in $(seq 1 30); do curl -sf http://127.0.0.1:9000/minio/health/live && break; sleep 1; done
+ - name: mint scoped user
+ run: |
+ curl -sfL https://dl.min.io/client/mc/release/linux-amd64/mc -o mc && chmod +x mc
+ ./mc alias set local http://127.0.0.1:9000 root rootsecret
+ ./mc mb local/contract-bucket local/denied-bucket
+ cat > /tmp/policy.json <<'EOF'
+ {"Version": "2012-10-17", "Statement": [
+ {"Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": ["arn:aws:s3:::contract-bucket"]},
+ {"Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"], "Resource": ["arn:aws:s3:::contract-bucket/*"]}
+ ]}
+ EOF
+ ./mc admin policy create local contract-only /tmp/policy.json
+ ./mc admin user add local scopeduser scopedsecret1
+ ./mc admin policy attach local contract-only --user scopeduser
+ - name: contract test
+ env:
+ CARLOS_MINIO_TEST: "1"
+ MINIO_ENDPOINT: http://127.0.0.1:9000
+ MINIO_SCOPED_KEY: scopeduser
+ MINIO_SCOPED_SECRET: scopedsecret1
+ MINIO_BUCKET: contract-bucket
+ MINIO_DENIED_BUCKET: denied-bucket
+ run: go test ./internal/objstore/ -run TestDeliveredContractMinio -count=1 -v
+
gofmt:
name: gofmt (changed files)
runs-on: ubuntu-latest
diff --git a/go.sum b/go.sum
index a6019d188..0a0183997 100644
--- a/go.sum
+++ b/go.sum
@@ -103,6 +103,7 @@ github.com/hablullah/go-juliandays v1.0.0 h1:A8YM7wIj16SzlKT0SRJc9CD29iiaUzpBLzh
github.com/hablullah/go-juliandays v1.0.0/go.mod h1:0JOYq4oFOuDja+oospuc61YoX+uNEn7Z6uHYTbBzdGc=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 h1:qxLoi6CAcXVzjfvu+KXIXJOAsQB62LXjsfbOaErsVzE=
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958/go.mod h1:Wqfu7mjUHj9WDzSSPI5KfBclTTEnLveRUFr/ujWnTgE=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
@@ -119,6 +120,7 @@ github.com/markusmobius/go-dateparser v1.2.4 h1:2e8XJozaERVxGwsRg72coi51L2aiYqE2
github.com/markusmobius/go-dateparser v1.2.4/go.mod h1:CBAUADJuMNhJpyM6IYaWAoFhtKaqnUcznY2cL7gNugY=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI=
github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
@@ -180,6 +182,7 @@ go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
+golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
@@ -210,6 +213,8 @@ google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
diff --git a/internal/objstore/contract_test.go b/internal/objstore/contract_test.go
new file mode 100644
index 000000000..6a9489a57
--- /dev/null
+++ b/internal/objstore/contract_test.go
@@ -0,0 +1,177 @@
+package objstore
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "os"
+ "testing"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/credentials"
+ awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
+ s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
+ "github.com/aws/smithy-go"
+)
+
+// TestDeliveredContractMinio pins spec §1's delivered contract from outside
+// the package: given exactly the four values a consuming app receives after
+// grant (endpoint, key, secret, bucket) plus a second bucket the scoped user
+// was never granted, build an S3 client the way that consuming app would —
+// not via NewAWSProvisioner or any objstore helper — and exercise Put/Get/
+// List/Delete plus cross-bucket denial against a real (Minio) S3-compatible
+// server.
+//
+// This is deliberately not a unit test against a stub: internal/objstore's
+// own tests already cover the provisioner logic with fakes. What has never
+// been exercised anywhere in this tree is the actual wire contract a member
+// app's client.Options() produces from delivered env, against a real
+// S3-compatible implementation. Skipped by default because it needs a
+// running Minio; CI's minio-contract job (.github/workflows/ci.yml) sets
+// CARLOS_MINIO_TEST=1 and stands one up. There is no docker in this
+// sandbox, so the live path here has only ever been verified in CI — see
+// the commit body.
+func TestDeliveredContractMinio(t *testing.T) {
+ if os.Getenv("CARLOS_MINIO_TEST") != "1" {
+ t.Skip("set CARLOS_MINIO_TEST=1 (with a running Minio and the MINIO_* env vars) to run the delivered-contract test; see .github/workflows/ci.yml's minio-contract job")
+ }
+
+ endpoint := requireEnv(t, "MINIO_ENDPOINT")
+ key := requireEnv(t, "MINIO_SCOPED_KEY")
+ secret := requireEnv(t, "MINIO_SCOPED_SECRET")
+ bucket := requireEnv(t, "MINIO_BUCKET")
+ deniedBucket := requireEnv(t, "MINIO_DENIED_BUCKET")
+
+ ctx := context.Background()
+
+ // Built exactly the way a consuming app builds it from delivered env:
+ // static credentials, region us-east-1 (spec §1's delivered region),
+ // BaseEndpoint + UsePathStyle for addressing, and the checksum
+ // WhenRequired pair — the same four option lines aws.go's
+ // NewAWSProvisioner applies for a non-empty endpoint, mirrored here
+ // independently rather than by calling into objstore's own client
+ // construction, since the whole point is to pin what a member app gets,
+ // not to test this package's internals against themselves.
+ client := awss3.New(awss3.Options{
+ Region: "us-east-1",
+ Credentials: credentials.NewStaticCredentialsProvider(key, secret, ""),
+ BaseEndpoint: aws.String(endpoint),
+ UsePathStyle: true,
+ RequestChecksumCalculation: aws.RequestChecksumCalculationWhenRequired,
+ ResponseChecksumValidation: aws.ResponseChecksumValidationWhenRequired,
+ })
+
+ const objectKey = "contract-test/hello.txt"
+ const body = "hello from the delivered contract test"
+
+ t.Run("put", func(t *testing.T) {
+ _, err := client.PutObject(ctx, &awss3.PutObjectInput{
+ Bucket: aws.String(bucket),
+ Key: aws.String(objectKey),
+ Body: bytes.NewReader([]byte(body)),
+ })
+ if err != nil {
+ t.Fatalf("PutObject: %v", err)
+ }
+ })
+
+ t.Run("get round-trips the body", func(t *testing.T) {
+ out, err := client.GetObject(ctx, &awss3.GetObjectInput{
+ Bucket: aws.String(bucket),
+ Key: aws.String(objectKey),
+ })
+ if err != nil {
+ t.Fatalf("GetObject: %v", err)
+ }
+ defer out.Body.Close()
+ got, err := io.ReadAll(out.Body)
+ if err != nil {
+ t.Fatalf("read GetObject body: %v", err)
+ }
+ if string(got) != body {
+ t.Fatalf("GetObject body = %q, want %q", got, body)
+ }
+ })
+
+ t.Run("list shows the key", func(t *testing.T) {
+ out, err := client.ListObjectsV2(ctx, &awss3.ListObjectsV2Input{
+ Bucket: aws.String(bucket),
+ })
+ if err != nil {
+ t.Fatalf("ListObjectsV2: %v", err)
+ }
+ if !containsKey(out.Contents, objectKey) {
+ t.Fatalf("ListObjectsV2 contents = %v, want to contain %q", keyNames(out.Contents), objectKey)
+ }
+ })
+
+ t.Run("delete removes it", func(t *testing.T) {
+ _, err := client.DeleteObject(ctx, &awss3.DeleteObjectInput{
+ Bucket: aws.String(bucket),
+ Key: aws.String(objectKey),
+ })
+ if err != nil {
+ t.Fatalf("DeleteObject: %v", err)
+ }
+ out, err := client.ListObjectsV2(ctx, &awss3.ListObjectsV2Input{
+ Bucket: aws.String(bucket),
+ })
+ if err != nil {
+ t.Fatalf("ListObjectsV2 after delete: %v", err)
+ }
+ if containsKey(out.Contents, objectKey) {
+ t.Fatalf("ListObjectsV2 after delete still contains %q", objectKey)
+ }
+ })
+
+ t.Run("cross-bucket denial", func(t *testing.T) {
+ _, err := client.PutObject(ctx, &awss3.PutObjectInput{
+ Bucket: aws.String(deniedBucket),
+ Key: aws.String(objectKey),
+ Body: bytes.NewReader([]byte(body)),
+ })
+ if err == nil {
+ t.Fatalf("PutObject to %q: want AccessDenied, got success", deniedBucket)
+ }
+ var apiErr smithy.APIError
+ if !errors.As(err, &apiErr) {
+ t.Fatalf("PutObject to %q: err = %v (%T), want a smithy.APIError", deniedBucket, err, err)
+ }
+ if apiErr.ErrorCode() != "AccessDenied" {
+ t.Fatalf("PutObject to %q: error code = %q, want %q (message: %s)", deniedBucket, apiErr.ErrorCode(), "AccessDenied", apiErr.ErrorMessage())
+ }
+ })
+}
+
+// requireEnv fetches name from the environment, failing the test fast with
+// a clear message if it is unset — a missing var here means the CI job's
+// env block and this test's expectations have drifted, not that the
+// contract itself is broken.
+func requireEnv(t *testing.T, name string) string {
+ t.Helper()
+ v := os.Getenv(name)
+ if v == "" {
+ t.Fatalf("%s is not set (required when CARLOS_MINIO_TEST=1)", name)
+ }
+ return v
+}
+
+// containsKey and keyNames are small helpers over ListObjectsV2's Contents so
+// the assertions above read as intent, not index arithmetic.
+func containsKey(objs []s3types.Object, key string) bool {
+ for _, o := range objs {
+ if aws.ToString(o.Key) == key {
+ return true
+ }
+ }
+ return false
+}
+
+func keyNames(objs []s3types.Object) []string {
+ names := make([]string, len(objs))
+ for i, o := range objs {
+ names[i] = aws.ToString(o.Key)
+ }
+ return names
+}