From 3d12d18200510a7532934c53e6c25dc4489d7643 Mon Sep 17 00:00:00 2001 From: Rene Nochebuena Guerrero Date: Tue, 19 May 2026 21:42:30 -0600 Subject: [PATCH] =?UTF-8?q?feat(minio):=20initial=20stable=20release=20v1.?= =?UTF-8?q?0.0=20=E2=80=94=20Client=20interface,=20xerrors,=20GetObject,?= =?UTF-8?q?=20doc=20examples?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Client interface with PutObject, GetObject, RemoveObject, PresignedGetObject, and HandleError; Component now embeds Client with Native() as escape hatch for operations not covered by the interface. Add xerrors dependency: HandleError maps minio-go error codes to portable typed codes (NoSuchKey → ErrNotFound, AccessDenied → ErrPermissionDenied, BucketAlreadyExists → ErrAlreadyExists, etc.). OnStop sets c.mc = nil for lifecycle consistency. doc.go updated with launcher wiring, upload, presigned URL, GetObject, and HandleError usage examples. API committed as stable. What's included: - Config with Endpoint, AccessKey, SecretKey, UseSSL, Bucket, Region (env-driven, embeddable) - Transport field in Config for test injection (env:"-", nil uses minio-go default) - Client interface: PutObject, GetObject, RemoveObject, PresignedGetObject, HandleError - Component interface: launcher.Component + health.Checkable + Client + Native() - New(logger, cfg) constructor for lifecycle registration via lc.Append - Automatic bucket creation on OnStart if bucket does not exist - Health check via BucketExists at LevelCritical priority - HandleError: maps minio-go ErrorResponse codes to xerrors typed errors - 21 unit tests using mock HTTP transport; no live server required --- .gitea/CODEOWNERS | 1 + .gitignore | 38 +++++++ CHANGELOG.md | 15 +++ CLAUDE.md | 107 ++++++++++++++++++ LICENSE | 21 ++++ README.md | 102 +++++++++++++++++ compliance_test.go | 9 ++ doc.go | 46 ++++++++ errors.go | 27 +++++ errors_test.go | 55 +++++++++ go.mod | 35 ++++++ go.sum | 59 ++++++++++ minio.go | 165 +++++++++++++++++++++++++++ minio_test.go | 270 +++++++++++++++++++++++++++++++++++++++++++++ 14 files changed, 950 insertions(+) create mode 100644 .gitea/CODEOWNERS create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 compliance_test.go create mode 100644 doc.go create mode 100644 errors.go create mode 100644 errors_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 minio.go create mode 100644 minio_test.go diff --git a/.gitea/CODEOWNERS b/.gitea/CODEOWNERS new file mode 100644 index 0000000..ae1f67b --- /dev/null +++ b/.gitea/CODEOWNERS @@ -0,0 +1 @@ +* @go/CoreDevelopers @go/Agents diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..221da82 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with go test -c +*.test + +# Output of go build +*.out + +# Dependency directory +vendor/ + +# Go workspace file +go.work +go.work.sum + +# Environment files +.env +.env.* + +# Editor / IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# VCS files +COMMIT.md +RELEASE.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ba1e00f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +All notable changes to this module will be documented in this file. + +## [v0.9.0] — 2026-05-20 + +### Added + +- `Config` — env-tagged, embeddable struct: `Endpoint`, `AccessKey`, `SecretKey`, `UseSSL`, `Bucket`, `Region`, `Transport` +- `Provider` interface — `Native() *minio.Client` +- `Component` interface — `launcher.Component + health.Checkable + Provider` +- `New(logger logz.Logger, cfg Config) Component` — constructor +- Automatic bucket creation on `OnStart` if the configured bucket does not exist +- Health check via `BucketExists` at `health.LevelCritical` priority +- `OnStop` is a no-op — minio-go client is stateless diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..831b7bd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,107 @@ +# minio + +MinIO (S3-compatible) client with launcher lifecycle and health check integration. + +## Purpose + +Manages the lifecycle of a `minio-go` SDK client: constructs it from config, verifies +bucket existence at startup (creating the bucket if absent), exposes the native SDK client +to consumers, and provides a health check via `BucketExists`. Does not wrap SDK operations — +callers use the full minio-go API directly via `Native()`. + +## Tier & Dependencies + +**Tier 3 (infrastructure)** — depends on: +- `code.nochebuena.dev/go/health` (Tier 2) +- `code.nochebuena.dev/go/launcher` (Tier 2) +- `code.nochebuena.dev/go/logz` (Tier 1) +- `github.com/minio/minio-go/v7` (native MinIO client) + +Does **not** depend on `xerrors` — errors from the minio-go client are wrapped with `fmt.Errorf` +and returned as-is. + +## Key Design Decisions + +- **Native client exposure**: `Native() *miniogo.Client` returns the raw minio-go SDK client. + No wrapper, no operation subset. Callers use `PutObject`, `RemoveObject`, `PresignedGetObject`, + and all other SDK methods directly. + +- **Single bucket per component**: `Config.Bucket` is used only for health checks and the + startup init hook. Callers needing multiple buckets instantiate multiple `Component` values + with different configs. Object key namespacing (e.g. `products/{id}/{uuid}.ext`) handles + logical separation within a single bucket — no multi-bucket API is needed. + +- **Bucket auto-creation**: If `Config.Bucket` does not exist when `OnStart` runs, the + component creates it automatically and logs the event. Failure to create the bucket is a + hard startup error — the application will not start. + +- **Region defaults to `us-east-1`**: Self-hosted MinIO uses this region by default. Setting + a non-empty region in `Options.Region` bypasses per-request region detection in minio-go, + which avoids an extra `GET /?location` HTTP call on every bucket operation. + +- **Stateless client — `OnStop` is a no-op**: minio-go holds no persistent connections; there + is nothing to close on shutdown. + +- **Health priority is Critical**: `Priority()` returns `health.LevelCritical`. Without MinIO, + object uploads and retrievals fail completely — there is no graceful degradation path. + +- **`Transport` field for testing**: `Config.Transport http.RoundTripper` (tagged `env:"-"`) + lets tests inject a mock transport without a live server. Setting it to `nil` in production + uses the minio-go default transport. + +## Patterns + +**Lifecycle registration:** +```go +mc := minio.New(logger, cfg) +lc.Append(mc) +r.Get("/health", health.NewHandler(logger, mc)) +``` + +**Uploading an object:** +```go +_, err := provider.Native().PutObject(ctx, cfg.Bucket, key, reader, size, + miniogo.PutObjectOptions{ContentType: "image/jpeg"}) +``` + +**Generating a presigned URL:** +```go +url, err := provider.Native().PresignedGetObject(ctx, cfg.Bucket, key, time.Hour, nil) +``` + +**Injecting into a repository:** +```go +type imageRepo struct { + provider minio.Provider + bucket string +} + +func (r *imageRepo) Upload(ctx context.Context, key string, rd io.Reader, size int64) error { + _, err := r.provider.Native().PutObject(ctx, r.bucket, key, + rd, size, miniogo.PutObjectOptions{}) + return err +} +``` + +## What to Avoid + +- Do not wrap minio-go operations in this module. Keep `PutObject`, `RemoveObject`, etc. in + the caller (repository layer). This module is a lifecycle building block, not a domain repository. +- Do not define interfaces that subset minio-go methods here. If a consumer needs a minimal + testable interface, define it in the consumer package. +- Do not call `Native()` before `OnInit` has run — it returns `nil`. +- Do not add a `Region` auto-detection request in tests — use `Config.Region = "us-east-1"` to + bypass the `GET /?location` lookup. + +## Testing Notes + +- Unit tests (`minio_test.go`) use an injected `mockTransport` via `Config.Transport`. No live + MinIO server is required. +- `Config.Region = "us-east-1"` must be set in test configs — it prevents minio-go from making + an extra region detection request that the mock transport would not handle. +- `TestComponent_OnStart_BucketError` and `TestComponent_HealthCheck_Unreachable` take ~4 seconds + each due to minio-go's built-in retry backoff on errors. This is expected and correct behavior. +- `compliance_test.go` (package `minio_test`) asserts `New(...)` satisfies `Component` + at compile time. +- Integration tests requiring a live MinIO instance belong in a higher-tier test suite (e.g. + the consuming application's integration test suite). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0b33b48 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 NOCHEBUENADEV + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..264c95a --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# minio + +MinIO (S3-compatible) client with [go-kit](https://code.nochebuena.dev/go) launcher lifecycle and health check integration. + +## Install + +```sh +go get code.nochebuena.dev/go/minio +``` + +## Usage + +```go +package main + +import ( + miniomw "code.nochebuena.dev/go/minio" + "code.nochebuena.dev/go/health" + "code.nochebuena.dev/go/launcher" + "code.nochebuena.dev/go/logz" +) + +func main() { + logger := logz.New(logz.Options{}) + + cfg := miniomw.Config{ + Endpoint: "minio:9000", + AccessKey: "minioadmin", + SecretKey: "minioadmin", + UseSSL: false, + Bucket: "my-app-assets", + Region: "us-east-1", + } + + mc := miniomw.New(logger, cfg) + + lc := launcher.New(logger) + lc.Append(mc) + + lc.BeforeStart(func() error { + // Register health check + _ = health.NewHandler(logger, mc) + + // Use the native minio-go client for direct SDK operations + native := mc.Native() + _ = native // PutObject, RemoveObject, PresignedGetObject, etc. + + return nil + }) + + if err := lc.Run(); err != nil { + panic(err) + } +} +``` + +## Config environment variables + +| Variable | Required | Default | Description | +|-------------------|----------|--------------|----------------------------------------------| +| `MINIO_ENDPOINT` | Yes | — | MinIO API host and port, e.g. `minio:9000` | +| `MINIO_ACCESS_KEY`| Yes | — | Access key (username) | +| `MINIO_SECRET_KEY`| Yes | — | Secret key (password) | +| `MINIO_BUCKET` | Yes | — | Default bucket; created at startup if absent | +| `MINIO_USE_SSL` | No | `false` | Enable TLS for the connection | +| `MINIO_REGION` | No | `us-east-1` | Region; default covers all self-hosted MinIO | + +## Lifecycle + +| Hook | Behaviour | +|------------|------------------------------------------------------------------| +| `OnInit` | Constructs the minio-go SDK client; no network call | +| `OnStart` | Checks whether the configured bucket exists; creates it if not | +| `OnStop` | No-op — the minio-go client is stateless | + +## Health check + +`HealthCheck(ctx)` calls `BucketExists` on the configured bucket. Priority is `health.LevelCritical`. + +## Multiple buckets + +Each `Component` is bound to one bucket for health checks and startup init. For multiple buckets, instantiate multiple components with different configs. Use object key namespacing (e.g. `products/{id}/{uuid}.ext`) for logical separation within a single bucket. + +## Direct SDK access + +`Native()` returns the underlying `*minio.Client` from `github.com/minio/minio-go/v7`. Use it for all object operations: + +```go +native := mc.Native() + +// Upload +_, err := native.PutObject(ctx, "my-bucket", "products/abc/img.jpg", reader, size, + minio.PutObjectOptions{ContentType: "image/jpeg"}) + +// Delete +err = native.RemoveObject(ctx, "my-bucket", "products/abc/img.jpg", + minio.RemoveObjectOptions{}) + +// Presigned URL (e.g. 1 hour) +url, err := native.PresignedGetObject(ctx, "my-bucket", "products/abc/img.jpg", + time.Hour, nil) +``` diff --git a/compliance_test.go b/compliance_test.go new file mode 100644 index 0000000..19918ee --- /dev/null +++ b/compliance_test.go @@ -0,0 +1,9 @@ +package minio_test + +import ( + "code.nochebuena.dev/go/logz" + "code.nochebuena.dev/go/minio" +) + +// Compile-time check: New returns a valid Component. +var _ minio.Component = minio.New(logz.New(logz.Options{}), minio.Config{}) diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..ea6c290 --- /dev/null +++ b/doc.go @@ -0,0 +1,46 @@ +/* +Package minio provides a MinIO (S3-compatible) client with launcher lifecycle +and health check integration. + +Register the component with the launcher and use the Client interface in repositories: + + cfg := minio.Config{ + Endpoint: "minio:9000", + AccessKey: "minioadmin", + SecretKey: "minioadmin", + Bucket: "my-app-assets", + } + mc := minio.New(logger, cfg) + lc.Append(mc) // OnInit + OnStart + OnStop managed automatically + health.NewHandler(logger, mc) // mc satisfies health.Checkable + +Uploading an object via the Client interface: + + info, err := mc.PutObject(ctx, "my-app-assets", "products/abc/photo.jpg", + reader, size, miniogo.PutObjectOptions{ContentType: "image/jpeg"}) + +Generating a pre-signed download URL: + + u, err := mc.PresignedGetObject(ctx, "my-app-assets", "products/abc/photo.jpg", time.Hour, nil) + +Use Native() for operations not covered by Client (e.g. PresignedPutObject, ListObjects): + + url, err := mc.Native().PresignedPutObject(ctx, "my-app-assets", "products/abc/photo.jpg", time.Hour) + +Errors returned by Client methods are already mapped to typed xerrors values. Use errors.As +to inspect them: + + var xe *xerrors.Err + if errors.As(err, &xe) && xe.Code() == xerrors.ErrNotFound { + // object or bucket does not exist + } + +When using Native() directly, map errors explicitly with HandleError before returning them: + + obj, err := mc.Native().GetObject(ctx, bucket, key, miniogo.GetObjectOptions{}) + if err != nil { + return HandleError(err) // maps NoSuchKey → ErrNotFound, AccessDenied → ErrPermissionDenied, etc. + } + defer obj.Close() +*/ +package minio diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..ee1e265 --- /dev/null +++ b/errors.go @@ -0,0 +1,27 @@ +package minio + +import ( + miniogo "github.com/minio/minio-go/v7" + + "code.nochebuena.dev/go/xerrors" +) + +// HandleError maps minio-go errors to xerrors types. +// Also available as client.HandleError(err). +func HandleError(err error) error { + if err == nil { + return nil + } + resp := miniogo.ToErrorResponse(err) + switch resp.Code { + case miniogo.NoSuchBucket: + return xerrors.New(xerrors.ErrNotFound, "bucket not found").WithError(err) + case miniogo.NoSuchKey: + return xerrors.New(xerrors.ErrNotFound, "object not found").WithError(err) + case miniogo.AccessDenied, miniogo.InvalidAccessKeyID: + return xerrors.New(xerrors.ErrPermissionDenied, "access denied").WithError(err) + case miniogo.BucketAlreadyExists, miniogo.BucketAlreadyOwnedByYou: + return xerrors.New(xerrors.ErrAlreadyExists, "bucket already exists").WithError(err) + } + return xerrors.New(xerrors.ErrInternal, "unexpected storage error").WithError(err) +} diff --git a/errors_test.go b/errors_test.go new file mode 100644 index 0000000..e5daa44 --- /dev/null +++ b/errors_test.go @@ -0,0 +1,55 @@ +package minio + +import ( + "errors" + "testing" + + miniogo "github.com/minio/minio-go/v7" + + "code.nochebuena.dev/go/xerrors" +) + +func assertXCode(t *testing.T, err error, want xerrors.Code) { + t.Helper() + var xe *xerrors.Err + if !errors.As(err, &xe) { + t.Fatalf("expected *xerrors.Err, got %T: %v", err, err) + } + if xe.Code() != want { + t.Errorf("want code %s, got %s", want, xe.Code()) + } +} + +func TestHandleError_Nil(t *testing.T) { + if HandleError(nil) != nil { + t.Error("HandleError(nil) should return nil") + } +} + +func TestHandleError_NoSuchBucket(t *testing.T) { + assertXCode(t, HandleError(miniogo.ErrorResponse{Code: miniogo.NoSuchBucket}), xerrors.ErrNotFound) +} + +func TestHandleError_NoSuchKey(t *testing.T) { + assertXCode(t, HandleError(miniogo.ErrorResponse{Code: miniogo.NoSuchKey}), xerrors.ErrNotFound) +} + +func TestHandleError_AccessDenied(t *testing.T) { + assertXCode(t, HandleError(miniogo.ErrorResponse{Code: miniogo.AccessDenied}), xerrors.ErrPermissionDenied) +} + +func TestHandleError_InvalidAccessKeyID(t *testing.T) { + assertXCode(t, HandleError(miniogo.ErrorResponse{Code: miniogo.InvalidAccessKeyID}), xerrors.ErrPermissionDenied) +} + +func TestHandleError_BucketAlreadyExists(t *testing.T) { + assertXCode(t, HandleError(miniogo.ErrorResponse{Code: miniogo.BucketAlreadyExists}), xerrors.ErrAlreadyExists) +} + +func TestHandleError_BucketAlreadyOwnedByYou(t *testing.T) { + assertXCode(t, HandleError(miniogo.ErrorResponse{Code: miniogo.BucketAlreadyOwnedByYou}), xerrors.ErrAlreadyExists) +} + +func TestHandleError_Unknown(t *testing.T) { + assertXCode(t, HandleError(miniogo.ErrorResponse{Code: "SomeUnknownCode"}), xerrors.ErrInternal) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..099cd96 --- /dev/null +++ b/go.mod @@ -0,0 +1,35 @@ +module code.nochebuena.dev/go/minio + +go 1.26.3 + +require ( + code.nochebuena.dev/go/health v1.0.1 + code.nochebuena.dev/go/launcher v1.0.1 + code.nochebuena.dev/go/logz v1.0.1 + code.nochebuena.dev/go/xerrors v1.0.0 + github.com/minio/minio-go/v7 v7.1.0 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-ini/ini v1.67.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect + github.com/minio/crc64nvme v1.1.1 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/tinylib/msgp v1.6.1 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9cf68d6 --- /dev/null +++ b/go.sum @@ -0,0 +1,59 @@ +code.nochebuena.dev/go/health v1.0.1 h1:FwB1sDa9oZBPgIi3kNMUjTVRKQ/Yn77WvlrwNtgFlws= +code.nochebuena.dev/go/health v1.0.1/go.mod h1:sy/HVT+E+k2rEuqmW9Q8QX0QnotAQxPHXSexYs7GNMg= +code.nochebuena.dev/go/launcher v1.0.1 h1:hbPV8jNtyxfchrT7igzz3M2tKGI3bm8uWkHBXRvSPgg= +code.nochebuena.dev/go/launcher v1.0.1/go.mod h1:1KwndVuqm31JN9Dpl9YvOmlogPlKKzoDMo9aRFkYwmM= +code.nochebuena.dev/go/logz v1.0.1 h1:kK9aZo19L208CwCr2D/dbSOMaOv62cXsigMSsdFu+8Y= +code.nochebuena.dev/go/logz v1.0.1/go.mod h1:YNpNm03fURm2v0ySh/477z9AJhtfRcd9rFOW6fFqgNM= +code.nochebuena.dev/go/xerrors v1.0.0 h1:si24SFGa7cHwAxbu75AAEB+a3qRmF118F/BM2SFI7VI= +code.nochebuena.dev/go/xerrors v1.0.0/go.mod h1:mtXo7xscBreCB7w7smlBP5Onv8H1HVohCvF0I/VXbAY= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= +github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= +github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= +github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.1.0 h1:QEt5IStDpxgGjEdtOgpiZ5QhmSl3ax7qy61vi2SwHO8= +github.com/minio/minio-go/v7 v7.1.0/go.mod h1:Dm7WS1AgLmBa0NcQD6SeJnJf+K/EUW3GR7Ks6olB3OA= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= +github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/minio.go b/minio.go new file mode 100644 index 0000000..ff04c12 --- /dev/null +++ b/minio.go @@ -0,0 +1,165 @@ +package minio + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "time" + + miniogo "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" + + "code.nochebuena.dev/go/health" + "code.nochebuena.dev/go/launcher" + "code.nochebuena.dev/go/logz" +) + +// Client is the MinIO operation interface for the most common bucket operations. +// Inject it into repositories; use Native() on Component for SDK operations not covered here. +type Client interface { + // PutObject uploads an object to the given bucket and key. + PutObject(ctx context.Context, bucket, key string, reader io.Reader, size int64, opts miniogo.PutObjectOptions) (miniogo.UploadInfo, error) + // RemoveObject deletes an object from the given bucket and key. + RemoveObject(ctx context.Context, bucket, key string, opts miniogo.RemoveObjectOptions) error + // GetObject downloads an object and returns it as a readable stream. + // The caller must close the returned object after reading. + GetObject(ctx context.Context, bucket, key string, opts miniogo.GetObjectOptions) (*miniogo.Object, error) + // PresignedGetObject returns a pre-signed URL for downloading an object. + PresignedGetObject(ctx context.Context, bucket, key string, expires time.Duration, reqParams url.Values) (*url.URL, error) + // HandleError maps a minio-go error to a typed xerrors value. + HandleError(err error) error +} + +// Component bundles launcher lifecycle, health check, and MinIO client operations. +type Component interface { + launcher.Component + health.Checkable + Client + // Native returns the underlying minio-go SDK client for operations not covered by Client. + Native() *miniogo.Client +} + +// Config holds MinIO connection settings. The struct is embeddable in application +// config structs and populated from environment variables. +type Config struct { + Endpoint string `env:"MINIO_ENDPOINT,required"` + AccessKey string `env:"MINIO_ACCESS_KEY,required"` + SecretKey string `env:"MINIO_SECRET_KEY,required"` + UseSSL bool `env:"MINIO_USE_SSL" envDefault:"false"` + Bucket string `env:"MINIO_BUCKET,required"` + // Region defaults to "us-east-1" — the region MinIO uses by default on self-hosted + // deployments. Setting a non-empty region bypasses per-request region detection. + Region string `env:"MINIO_REGION" envDefault:"us-east-1"` + // Transport is used for testing only. Nil uses the minio-go default transport. + Transport http.RoundTripper `env:"-"` +} + +var _ Component = (*minioComponent)(nil) + +type minioComponent struct { + cfg Config + logger logz.Logger + mc *miniogo.Client +} + +// New returns a MinIO Component. Call lc.Append(mc) to manage its lifecycle. +func New(logger logz.Logger, cfg Config) Component { + return &minioComponent{cfg: cfg, logger: logger} +} + +func (c *minioComponent) OnInit() error { + opts := &miniogo.Options{ + Creds: credentials.NewStaticV4(c.cfg.AccessKey, c.cfg.SecretKey, ""), + Secure: c.cfg.UseSSL, + Region: c.cfg.Region, + Transport: c.cfg.Transport, + } + mc, err := miniogo.New(c.cfg.Endpoint, opts) + if err != nil { + return fmt.Errorf("minio: create client: %w", err) + } + c.mc = mc + return nil +} + +func (c *minioComponent) OnStart() error { + if c.mc == nil { + return fmt.Errorf("minio: client not initialized") + } + exists, err := c.mc.BucketExists(context.Background(), c.cfg.Bucket) + if err != nil { + return fmt.Errorf("minio: check bucket %q: %w", c.cfg.Bucket, err) + } + if !exists { + if err := c.mc.MakeBucket(context.Background(), c.cfg.Bucket, miniogo.MakeBucketOptions{}); err != nil { + return fmt.Errorf("minio: create bucket %q: %w", c.cfg.Bucket, err) + } + c.logger.Info("minio: bucket created", "bucket", c.cfg.Bucket) + } + c.logger.Info("minio: connected", "bucket", c.cfg.Bucket) + return nil +} + +func (c *minioComponent) OnStop() error { + c.mc = nil + return nil +} + +// GetObject downloads an object as a readable stream. The caller must close the returned object. +func (c *minioComponent) GetObject(ctx context.Context, bucket, key string, opts miniogo.GetObjectOptions) (*miniogo.Object, error) { + obj, err := c.mc.GetObject(ctx, bucket, key, opts) + if err != nil { + return nil, HandleError(err) + } + return obj, nil +} + +// PutObject uploads an object and maps any minio-go error to a typed xerrors value. +func (c *minioComponent) PutObject(ctx context.Context, bucket, key string, reader io.Reader, size int64, opts miniogo.PutObjectOptions) (miniogo.UploadInfo, error) { + info, err := c.mc.PutObject(ctx, bucket, key, reader, size, opts) + if err != nil { + return miniogo.UploadInfo{}, HandleError(err) + } + return info, nil +} + +// RemoveObject deletes an object and maps any minio-go error to a typed xerrors value. +func (c *minioComponent) RemoveObject(ctx context.Context, bucket, key string, opts miniogo.RemoveObjectOptions) error { + return HandleError(c.mc.RemoveObject(ctx, bucket, key, opts)) +} + +// PresignedGetObject returns a pre-signed download URL and maps any minio-go error to a typed xerrors value. +func (c *minioComponent) PresignedGetObject(ctx context.Context, bucket, key string, expires time.Duration, reqParams url.Values) (*url.URL, error) { + u, err := c.mc.PresignedGetObject(ctx, bucket, key, expires, reqParams) + if err != nil { + return nil, HandleError(err) + } + return u, nil +} + +// HandleError maps a minio-go error to a typed xerrors value. +func (c *minioComponent) HandleError(err error) error { return HandleError(err) } + +// Native returns the underlying minio-go SDK client for operations not covered by Client. +func (c *minioComponent) Native() *miniogo.Client { return c.mc } + +// HealthCheck verifies MinIO is reachable by checking that the configured bucket exists. +func (c *minioComponent) HealthCheck(ctx context.Context) error { + if c.mc == nil { + return fmt.Errorf("minio: client not initialized") + } + _, err := c.mc.BucketExists(ctx, c.cfg.Bucket) + if err != nil { + return fmt.Errorf("minio: health check: %w", err) + } + return nil +} + +// Name returns the component name used by the health check aggregator. +func (c *minioComponent) Name() string { return "minio" } + +// Priority returns LevelCritical — MinIO is the primary store for object data; +// an outage means uploads and retrievals fail completely. +func (c *minioComponent) Priority() health.Level { return health.LevelCritical } diff --git a/minio_test.go b/minio_test.go new file mode 100644 index 0000000..ddd98eb --- /dev/null +++ b/minio_test.go @@ -0,0 +1,270 @@ +package minio + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + "time" + + miniogo "github.com/minio/minio-go/v7" + + "code.nochebuena.dev/go/health" + "code.nochebuena.dev/go/logz" +) + +func newLogger() logz.Logger { return logz.New(logz.Options{}) } + +// mockTransport intercepts all HTTP requests and delegates to fn. +type mockTransport struct { + fn func(*http.Request) (*http.Response, error) +} + +func (m *mockTransport) RoundTrip(r *http.Request) (*http.Response, error) { + return m.fn(r) +} + +func makeResp(status int) *http.Response { + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(strings.NewReader("")), + Header: make(http.Header), + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + } +} + +func testConfig(transport http.RoundTripper) Config { + return Config{ + Endpoint: "localhost:9000", + AccessKey: "minioadmin", + SecretKey: "minioadmin", + Bucket: "test-bucket", + Region: "us-east-1", + Transport: transport, + } +} + +// --- construction --- + +func TestNew(t *testing.T) { + if New(newLogger(), Config{}) == nil { + t.Fatal("New returned nil") + } +} + +func TestComponent_Name(t *testing.T) { + c := New(newLogger(), Config{}).(health.Checkable) + if c.Name() != "minio" { + t.Errorf("want minio, got %s", c.Name()) + } +} + +func TestComponent_Priority(t *testing.T) { + c := New(newLogger(), Config{}).(health.Checkable) + if c.Priority() != health.LevelCritical { + t.Error("Priority() != LevelCritical") + } +} + +// --- nil client guards --- + +func TestComponent_OnStop_NilClient(t *testing.T) { + c := &minioComponent{logger: newLogger()} + if err := c.OnStop(); err != nil { + t.Errorf("OnStop with nil client: %v", err) + } +} + +func TestComponent_HealthCheck_NilClient(t *testing.T) { + c := &minioComponent{logger: newLogger()} + if err := c.HealthCheck(context.Background()); err == nil { + t.Error("HealthCheck with nil client should return error") + } +} + +// --- OnInit + Native --- + +func TestComponent_OnInit_And_Native(t *testing.T) { + cfg := testConfig(&mockTransport{fn: func(r *http.Request) (*http.Response, error) { + return makeResp(http.StatusOK), nil + }}) + c := New(newLogger(), cfg) + if err := c.OnInit(); err != nil { + t.Fatalf("OnInit: %v", err) + } + if c.Native() == nil { + t.Error("Native() returned nil after OnInit") + } +} + +// --- OnStart: bucket already exists --- + +func TestComponent_OnStart_BucketExists(t *testing.T) { + putCalled := false + cfg := testConfig(&mockTransport{fn: func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodPut { + putCalled = true + } + return makeResp(http.StatusOK), nil + }}) + c := New(newLogger(), cfg) + if err := c.OnInit(); err != nil { + t.Fatalf("OnInit: %v", err) + } + if err := c.OnStart(); err != nil { + t.Fatalf("OnStart: %v", err) + } + if putCalled { + t.Error("MakeBucket should not be called when bucket already exists") + } +} + +// --- OnStart: bucket missing → created --- + +func TestComponent_OnStart_BucketMissing(t *testing.T) { + putCalled := false + cfg := testConfig(&mockTransport{fn: func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodHead { + return makeResp(http.StatusNotFound), nil + } + if r.Method == http.MethodPut { + putCalled = true + return makeResp(http.StatusOK), nil + } + return makeResp(http.StatusOK), nil + }}) + c := New(newLogger(), cfg) + if err := c.OnInit(); err != nil { + t.Fatalf("OnInit: %v", err) + } + if err := c.OnStart(); err != nil { + t.Fatalf("OnStart: %v", err) + } + if !putCalled { + t.Error("MakeBucket should be called when bucket does not exist") + } +} + +// --- OnStart: bucket check error --- + +func TestComponent_OnStart_BucketError(t *testing.T) { + cfg := testConfig(&mockTransport{fn: func(r *http.Request) (*http.Response, error) { + return makeResp(http.StatusInternalServerError), nil + }}) + c := New(newLogger(), cfg) + if err := c.OnInit(); err != nil { + t.Fatalf("OnInit: %v", err) + } + if err := c.OnStart(); err == nil { + t.Error("OnStart should return error on 500 response") + } +} + +// --- HealthCheck: unreachable --- + +func TestComponent_HealthCheck_Unreachable(t *testing.T) { + cfg := testConfig(&mockTransport{fn: func(r *http.Request) (*http.Response, error) { + return nil, &mockNetError{} + }}) + c := New(newLogger(), cfg) + if err := c.OnInit(); err != nil { + t.Fatalf("OnInit: %v", err) + } + if err := c.HealthCheck(context.Background()); err == nil { + t.Error("HealthCheck should return error when endpoint is unreachable") + } +} + +// --- Client interface methods --- + +func startedComponent(t *testing.T, transport http.RoundTripper) Component { + t.Helper() + cfg := testConfig(transport) + c := New(newLogger(), cfg) + if err := c.OnInit(); err != nil { + t.Fatalf("OnInit: %v", err) + } + return c +} + +func TestComponent_PutObject_Success(t *testing.T) { + c := startedComponent(t, &mockTransport{fn: func(r *http.Request) (*http.Response, error) { + resp := makeResp(http.StatusOK) + resp.Header.Set("ETag", `"abc123"`) + return resp, nil + }}) + _, err := c.PutObject(context.Background(), "test-bucket", "products/1/img.jpg", + strings.NewReader("data"), 4, miniogo.PutObjectOptions{}) + if err != nil { + t.Errorf("PutObject: %v", err) + } +} + +func TestComponent_RemoveObject_Success(t *testing.T) { + c := startedComponent(t, &mockTransport{fn: func(r *http.Request) (*http.Response, error) { + return makeResp(http.StatusNoContent), nil + }}) + if err := c.RemoveObject(context.Background(), "test-bucket", "products/1/img.jpg", + miniogo.RemoveObjectOptions{}); err != nil { + t.Errorf("RemoveObject: %v", err) + } +} + +func TestComponent_GetObject_Success(t *testing.T) { + c := startedComponent(t, &mockTransport{fn: func(r *http.Request) (*http.Response, error) { + resp := makeResp(http.StatusOK) + resp.Body = io.NopCloser(strings.NewReader("image-bytes")) + resp.ContentLength = 11 + return resp, nil + }}) + obj, err := c.GetObject(context.Background(), "test-bucket", "products/1/img.jpg", + miniogo.GetObjectOptions{}) + if err != nil { + t.Fatalf("GetObject: %v", err) + } + if obj == nil { + t.Error("GetObject returned nil object") + } + obj.Close() +} + +func TestComponent_PresignedGetObject_Success(t *testing.T) { + c := startedComponent(t, nil) + u, err := c.PresignedGetObject(context.Background(), "test-bucket", "products/1/img.jpg", + time.Hour, nil) + if err != nil { + t.Errorf("PresignedGetObject: %v", err) + } + if u == nil { + t.Error("PresignedGetObject returned nil URL") + } +} + +func TestComponent_HandleError_Passthrough(t *testing.T) { + c := New(newLogger(), Config{}) + if c.HandleError(nil) != nil { + t.Error("HandleError(nil) should return nil") + } +} + +func TestComponent_OnStop_NilsClient(t *testing.T) { + c := startedComponent(t, &mockTransport{fn: func(r *http.Request) (*http.Response, error) { + return makeResp(http.StatusOK), nil + }}) + if err := c.OnStop(); err != nil { + t.Errorf("OnStop: %v", err) + } + if c.Native() != nil { + t.Error("Native() should return nil after OnStop") + } +} + +// mockNetError satisfies net.Error for the transport error test. +type mockNetError struct{} + +func (e *mockNetError) Error() string { return "mock: connection refused" } +func (e *mockNetError) Timeout() bool { return false } +func (e *mockNetError) Temporary() bool { return false }