feat(minio): initial stable release v1.0.0 — Client interface, xerrors, GetObject, doc examples

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
This commit is contained in:
2026-05-19 21:42:30 -06:00
commit 3d12d18200
14 changed files with 950 additions and 0 deletions

1
.gitea/CODEOWNERS Normal file
View File

@@ -0,0 +1 @@
* @go/CoreDevelopers @go/Agents

38
.gitignore vendored Normal file
View File

@@ -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

15
CHANGELOG.md Normal file
View File

@@ -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

107
CLAUDE.md Normal file
View File

@@ -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 /<bucket>?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 /<bucket>?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).

21
LICENSE Normal file
View File

@@ -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.

102
README.md Normal file
View File

@@ -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)
```

9
compliance_test.go Normal file
View File

@@ -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{})

46
doc.go Normal file
View File

@@ -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

27
errors.go Normal file
View File

@@ -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)
}

55
errors_test.go Normal file
View File

@@ -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)
}

35
go.mod Normal file
View File

@@ -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
)

59
go.sum Normal file
View File

@@ -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=

165
minio.go Normal file
View File

@@ -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 }

270
minio_test.go Normal file
View File

@@ -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 }