Files
Felis/internal/submit/s3store.go
T

218 lines
8.7 KiB
Go

package submit
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"path"
"strings"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
// s3Client is the minimal object-store surface S3ContextStore needs. *minio.Client
// satisfies it, and a fake satisfies it in tests — so the store's key derivation
// and not-found handling are unit-verifiable without a live bucket.
type s3Client interface {
PutObject(ctx context.Context, bucket, object string, reader io.Reader, size int64, opts minio.PutObjectOptions) (minio.UploadInfo, error)
StatObject(ctx context.Context, bucket, object string, opts minio.StatObjectOptions) (minio.ObjectInfo, error)
}
// S3ContextStore is the object-store-backed build-context blob store: it writes
// each submission's uploaded modpack to {prefix}/{id}/context.tar.gz inside an S3
// bucket. It is the second implemented Blobs backend (alongside LocalContextStore),
// selected by cmd/felis when user_uploads_context is an s3:// base.
//
// The bucket + key prefix are parsed from that same base (parseS3Base), so an
// object written here lands at exactly s3://{bucket}/{prefix}/{id}/context.tar.gz —
// the ref deriveContextRef records and Kaniko's native s3:// --context reads.
// Credentials are static V4 keys resolved by cmd/felis from the environment (the
// setup wizard injects them into felis-api from the felis-uploads-s3 Secret); they
// never touch felis.toml.
//
// Kaniko reading the S3 context at build time needs its own credentials + egress
// on the sandboxed build Job — a separate deployment integration, exactly like the
// LocalContextStore PVC mount. This transport only makes the upload durable at the
// derived location.
type S3ContextStore struct {
client s3Client
bucket string
prefix string // key prefix within the bucket; may be empty
}
// S3StoreConfig is the resolved input for NewS3ContextStore. Base is the s3://
// user_uploads_context (bucket + optional prefix are parsed from it, so the write
// path matches deriveContextRef); Endpoint may carry an http:// or https:// scheme
// (a bare host defaults to TLS); the keys come from the environment.
type S3StoreConfig struct {
Base string
Endpoint string
Region string
AccessKey string
SecretKey string
}
// NewS3ContextStore builds a store backed by a real minio client. It fails fast
// when the base is malformed or the credentials are missing, so cmd/felis leaves
// Manager.Blobs nil (upload endpoint → 503) rather than wiring a store that cannot
// authenticate.
func NewS3ContextStore(cfg S3StoreConfig) (*S3ContextStore, error) {
bucket, prefix, err := parseS3Base(cfg.Base)
if err != nil {
return nil, err
}
if cfg.AccessKey == "" || cfg.SecretKey == "" {
return nil, errors.New("submit: s3 store requires credentials")
}
host, secure, err := splitS3Endpoint(cfg.Endpoint)
if err != nil {
return nil, fmt.Errorf("submit: s3 endpoint: %w", err)
}
client, err := minio.New(host, &minio.Options{
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
Secure: secure,
Region: cfg.Region,
})
if err != nil {
return nil, fmt.Errorf("submit: s3 client: %w", err)
}
return &S3ContextStore{client: client, bucket: bucket, prefix: prefix}, nil
}
// CheckS3Access verifies the S3 coordinates before they are committed to config:
// it builds a client from the entered endpoint/credentials and probes the bucket.
// It is the install-time preflight that turns a mistyped key, wrong endpoint, or
// missing bucket into an immediate, legible error at the keyboard instead of a 503
// at the first real upload.
func CheckS3Access(ctx context.Context, cfg S3StoreConfig) error {
store, err := NewS3ContextStore(cfg)
if err != nil {
return err
}
return checkBucketAccess(ctx, store.client, store.bucket)
}
// checkBucketAccess probes the bucket with the SAME object-level HEAD the upload
// path uses (StatObject on a key that will not exist), not a bucket-level
// HeadBucket. This matters: a least-privilege key scoped to object Put/Get may lack
// s3:ListBucket, so a HeadBucket would falsely reject a key that uploads fine. A
// NoSuchKey/absent result means the endpoint is reachable and the credentials are
// accepted — exactly the runtime dependency Exists() relies on. Split from
// CheckS3Access so the error mapping is unit-testable against a fake.
func checkBucketAccess(ctx context.Context, client s3Client, bucket string) error {
const probe = "felis-access-probe/does-not-exist"
if _, err := client.StatObject(ctx, bucket, probe, minio.StatObjectOptions{}); err != nil {
resp := minio.ToErrorResponse(err)
switch resp.Code {
case "NoSuchKey", "NotFound":
return nil // reachable + authorized; the probe object is simply absent
case "NoSuchBucket":
return fmt.Errorf("submit: bucket %q not found", bucket)
case "AccessDenied", "SignatureDoesNotMatch", "InvalidAccessKeyId":
return fmt.Errorf("submit: s3 credentials rejected: %w", err)
default:
// A bare 404 with no bucket-specific code = object absent in a live bucket.
if resp.StatusCode == http.StatusNotFound {
return nil
}
return fmt.Errorf("submit: cannot reach s3 (endpoint unreachable or credentials rejected): %w", err)
}
}
return nil // the probe object improbably exists — access clearly works
}
// keyFor derives the object key for a submission, re-validating the id at the
// storage boundary (the same defense-in-depth as LocalContextStore: a validated id
// carries no path separator, so it cannot alter the key layout).
func (s *S3ContextStore) keyFor(id string) (string, error) {
if !idRE.MatchString(id) {
return "", fmt.Errorf("submit: invalid submission id %q", id)
}
return path.Join(s.prefix, id, contextBlobName), nil
}
// Put streams r to the derived object key. Size is unknown (the Manager hands us a
// size-capped reader), so it is uploaded with size -1 (multipart). PutObject is
// atomic from a reader's perspective — a partial upload never becomes a readable
// object — so a failed or oversize upload never replaces a good context. It
// returns the number of bytes stored.
func (s *S3ContextStore) Put(ctx context.Context, id string, r io.Reader) (int64, error) {
key, err := s.keyFor(id)
if err != nil {
return 0, err
}
info, err := s.client.PutObject(ctx, s.bucket, key, r, -1, minio.PutObjectOptions{ContentType: "application/gzip"})
if err != nil {
return 0, fmt.Errorf("submit: put context blob: %w", err)
}
return info.Size, nil
}
// Exists reports whether a context blob has been stored for id. Approve consults
// it so a submission whose context was never uploaded is refused BEFORE the CAS.
func (s *S3ContextStore) Exists(ctx context.Context, id string) (bool, error) {
key, err := s.keyFor(id)
if err != nil {
return false, err
}
if _, err := s.client.StatObject(ctx, s.bucket, key, minio.StatObjectOptions{}); err != nil {
if isS3NotFound(err) {
return false, nil
}
return false, fmt.Errorf("submit: stat context blob: %w", err)
}
return true, nil
}
// isS3NotFound recognizes the "object is absent" outcome across S3
// implementations: a GET-shaped NoSuchKey code or a bare 404 from the HEAD that
// StatObject issues.
func isS3NotFound(err error) bool {
resp := minio.ToErrorResponse(err)
return resp.Code == "NoSuchKey" || resp.StatusCode == http.StatusNotFound
}
// splitS3Endpoint separates a configured endpoint into the host[:port] minio.New
// wants and a TLS flag. A bare host defaults to TLS (the safe default); an
// explicit http:// opts out for a plaintext dev store.
func splitS3Endpoint(ep string) (host string, secure bool, err error) {
ep = strings.TrimSpace(ep)
switch {
case ep == "":
return "", false, errors.New("empty endpoint")
case strings.HasPrefix(ep, "https://"):
return strings.Trim(strings.TrimPrefix(ep, "https://"), "/"), true, nil
case strings.HasPrefix(ep, "http://"):
return strings.Trim(strings.TrimPrefix(ep, "http://"), "/"), false, nil
default:
return strings.Trim(ep, "/"), true, nil
}
}
// parseS3Base splits an s3://bucket[/prefix] base into its bucket and key prefix.
// It is the single source of truth for how a user_uploads_context s3:// base maps
// onto object storage, kept beside the store so the write path and deriveContextRef
// can never disagree about where the blob lands.
func parseS3Base(base string) (bucket, prefix string, err error) {
rest := base
if i := strings.Index(strings.ToLower(rest), "://"); i >= 0 {
rest = rest[i+3:]
}
rest = strings.Trim(rest, "/")
if rest == "" {
return "", "", fmt.Errorf("submit: s3 base %q has no bucket", base)
}
parts := strings.SplitN(rest, "/", 2)
bucket = parts[0]
if len(parts) == 2 {
prefix = strings.Trim(parts[1], "/")
}
return bucket, prefix, nil
}
// Compile-time proof that the object store satisfies the Blobs transport.
var _ Blobs = (*S3ContextStore)(nil)