Files

274 lines
9.5 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package storage
import (
"bytes"
"context"
"fmt"
"path"
"github.com/google/uuid"
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/storagebuckets"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
madmin "github.com/minio/madmin-go/v3"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
)
var _ pb.StoragePluginServiceServer = (*StoragePlugin)(nil)
// StoragePlugin implements the StoragePluginServiceServer interface for the
// MinIO storage service plugin.
type StoragePlugin struct {
pb.UnimplementedStoragePluginServiceServer
}
// OnCreateStorageBucket is a hook that runs when a storage bucket is created.
func (sp *StoragePlugin) OnCreateStorageBucket(ctx context.Context, req *pb.OnCreateStorageBucketRequest) (*pb.OnCreateStorageBucketResponse, error) {
bucket := req.GetBucket()
if bucket == nil {
return nil, status.Error(codes.InvalidArgument, "no storage bucket information found")
}
if bucket.GetBucketName() == "" {
return nil, status.Error(codes.InvalidArgument, "storage bucket name is required")
}
sa, err := getStorageAttributes(bucket.GetAttributes())
if err != nil {
return nil, err // getStorageAttributes already returns a "status"-type error.
}
sec, err := getStorageSecrets(bucket.GetSecrets())
if err != nil {
return nil, err
}
err = ensureServiceAccount(ctx, sa, sec)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "failed to ensure service account: %v", err)
}
cl, err := minio.New(sa.EndpointUrl, &minio.Options{
Creds: credentials.NewStaticV4(sec.AccessKeyId, sec.SecretAccessKey, ""),
Secure: sa.UseSSL,
Region: sa.Region,
})
if err != nil {
return nil, status.Errorf(codes.Unknown, "failed to create minio sdk client: %v", err)
}
err = dryRun(ctx, cl, bucket)
if err != nil {
return nil, status.Errorf(codes.Unknown, "failed to verify provided minio environment: %v", err)
}
persistedData, err := structpb.NewStruct(sec.AsMap())
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to convert storage attributes to protobuf struct: %v", err)
}
return &pb.OnCreateStorageBucketResponse{
Persisted: &storagebuckets.StorageBucketPersisted{Data: persistedData},
}, nil
}
// OnUpdateStorageBucket is a hook that runs when a storage bucket is updated.
func (sp *StoragePlugin) OnUpdateStorageBucket(ctx context.Context, req *pb.OnUpdateStorageBucketRequest) (*pb.OnUpdateStorageBucketResponse, error) {
newBucket := req.GetNewBucket()
if newBucket == nil {
return nil, status.Error(codes.InvalidArgument, "new bucket is required")
}
if newBucket.BucketName == "" {
return nil, status.Error(codes.InvalidArgument, "new bucketName is required")
}
oldBucket := req.GetCurrentBucket()
if oldBucket == nil {
return nil, status.Error(codes.InvalidArgument, "current bucket is required")
}
// current attributes
osa, err := getStorageAttributes(oldBucket.GetAttributes())
if err != nil {
return nil, err
}
// new attributes and secrets
nsa, err := getStorageAttributes(newBucket.GetAttributes())
if err != nil {
return nil, err
}
sec, err := getStorageSecrets(newBucket.GetSecrets())
if err != nil {
return nil, err
}
switch {
case newBucket.GetBucketName() != oldBucket.GetBucketName():
return nil, status.Errorf(codes.InvalidArgument, "cannot update attribute BucketName")
case newBucket.GetBucketPrefix() != oldBucket.GetBucketPrefix():
return nil, status.Errorf(codes.InvalidArgument, "cannot update attribute BucketPrefix")
case nsa.Region != osa.Region:
return nil, status.Errorf(codes.InvalidArgument, "cannot update attribute Region")
case nsa.EndpointUrl != osa.EndpointUrl:
return nil, status.Errorf(codes.InvalidArgument, "cannot update attribute EndpointUrl")
}
if err = ensureServiceAccount(ctx, nsa, sec); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "failed to ensure service account: %s", err)
}
cl, err := minio.New(nsa.EndpointUrl, &minio.Options{
Creds: credentials.NewStaticV4(sec.AccessKeyId, sec.SecretAccessKey, ""),
Secure: nsa.UseSSL,
Region: nsa.Region,
})
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "failed to create minio sdk client: %s", err)
}
if err = dryRun(ctx, cl, newBucket); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "failed to verify provided minio environment: %s", err)
}
persisted, err := structpb.NewStruct(sec.AsMap())
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "failed to convert storage attributes to protobuf struct: %s", err)
}
return &pb.OnUpdateStorageBucketResponse{
Persisted: &storagebuckets.StorageBucketPersisted{Data: persisted},
}, nil
}
// OnDeleteStorageBucket is a hook that runs when a storage bucket is deleted.
// Since this plugin manages no state at the moment, this function is a no-op.
func (sp *StoragePlugin) OnDeleteStorageBucket(ctx context.Context, req *pb.OnDeleteStorageBucketRequest) (*pb.OnDeleteStorageBucketResponse, error) {
return &pb.OnDeleteStorageBucketResponse{}, nil
}
// ValidatePermissions is a hook that checks if the secrets associated with the
// storage bucket meet the requirements of the plugin.
func (sp *StoragePlugin) ValidatePermissions(ctx context.Context, req *pb.ValidatePermissionsRequest) (*pb.ValidatePermissionsResponse, error) {
bucket := req.GetBucket()
if bucket == nil {
return nil, status.Error(codes.InvalidArgument, "no storage bucket information found")
}
if bucket.GetBucketName() == "" {
return nil, status.Error(codes.InvalidArgument, "storage bucket name is required")
}
sa, err := getStorageAttributes(bucket.GetAttributes())
if err != nil {
return nil, err // getStorageAttributes already returns a "status"-type error.
}
sec, err := getStorageSecrets(bucket.GetSecrets())
if err != nil {
return nil, err
}
err = ensureServiceAccount(ctx, sa, sec)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "failed to ensure service account: %v", err)
}
cl, err := minio.New(sa.EndpointUrl, &minio.Options{
Creds: credentials.NewStaticV4(sec.AccessKeyId, sec.SecretAccessKey, ""),
Secure: sa.UseSSL,
Region: sa.Region,
})
if err != nil {
return nil, status.Errorf(codes.Unknown, "failed to create minio sdk client: %v", err)
}
err = dryRun(ctx, cl, bucket)
if err != nil {
return nil, status.Errorf(codes.Unknown, "failed to verify provided minio environment: %v", err)
}
return &pb.ValidatePermissionsResponse{}, nil
}
// HeadObject is a hook that retrieves metadata about an object.
func (sp *StoragePlugin) HeadObject(ctx context.Context, req *pb.HeadObjectRequest) (*pb.HeadObjectResponse, error) {
return nil, fmt.Errorf("unimplemented")
}
// GetObject is a hook that retrieves objects.
func (sp *StoragePlugin) GetObject(req *pb.GetObjectRequest, objServer pb.StoragePluginService_GetObjectServer) error {
return fmt.Errorf("unimplemented")
}
// PutObject is a hook that reads a file stored on local disk and stores it to
// an external object store.
func (sp *StoragePlugin) PutObject(ctx context.Context, req *pb.PutObjectRequest) (*pb.PutObjectResponse, error) {
return nil, fmt.Errorf("unimplemented")
}
// DeleteObjects deletes one or many files in an external object store via a
// provided key prefix.
func (sp *StoragePlugin) DeleteObjects(ctx context.Context, req *pb.DeleteObjectsRequest) (*pb.DeleteObjectsResponse, error) {
return nil, fmt.Errorf("unimplemented")
}
func dryRun(ctx context.Context, cl *minio.Client, bucket *storagebuckets.StorageBucket) error {
objectKey := path.Join(bucket.GetBucketPrefix(), uuid.New().String())
rd := bytes.NewReader([]byte("hashicorp boundary minio plugin access test"))
_, err := cl.PutObject(ctx, bucket.GetBucketName(), objectKey, rd, rd.Size(), minio.PutObjectOptions{})
if err != nil {
return fmt.Errorf("failed to put object at %q: %w", objectKey, err)
}
_, err = cl.StatObject(ctx, bucket.GetBucketName(), objectKey, minio.StatObjectOptions{})
if err != nil {
return fmt.Errorf("failed to stat object at %q: %w", objectKey, err)
}
_, err = cl.GetObject(ctx, bucket.GetBucketName(), objectKey, minio.GetObjectOptions{})
if err != nil {
return fmt.Errorf("failed to get object at %q: %w", objectKey, err)
}
oiCh := cl.ListObjects(ctx, bucket.GetBucketName(), minio.ListObjectsOptions{Recursive: true})
for oi := range oiCh {
if oi.Err != nil {
return fmt.Errorf("failed to list objects in bucket %q: %w", bucket.GetBucketName(), oi.Err)
}
}
err = cl.RemoveObject(ctx, bucket.GetBucketName(), objectKey, minio.RemoveObjectOptions{})
if err != nil {
return fmt.Errorf("failed to remove object at %q: %w", objectKey, err)
}
return nil
}
func newMadminClient(sa *StorageAttributes, sec *StorageSecrets) (*madmin.AdminClient, error) {
return madmin.NewWithOptions(sa.EndpointUrl, &madmin.Options{
Creds: credentials.NewStaticV4(sec.AccessKeyId, sec.SecretAccessKey, ""),
Secure: sa.UseSSL,
})
}
// ensureServiceAccount ensures the credentials we received belong to a MinIO
// service account. This plugin does not support using user credentials for its
// configuration.
func ensureServiceAccount(ctx context.Context, sa *StorageAttributes, sec *StorageSecrets) error {
cl, err := newMadminClient(sa, sec)
if err != nil {
return fmt.Errorf("failed to create madmin client: %w", err)
}
if _, err = cl.InfoServiceAccount(ctx, sec.AccessKeyId); err != nil {
return fmt.Errorf("failed to obtain service account info: %w", err)
}
return nil
}