feat: store assets in database (#3290)

* feat: use database as asset storage

* being only uploading assets if allowed

* tests

* fixes

* cleanup after merge

* renaming

* various fixes

* fix: change to repository event types and removed unused code

* feat: set default features

* error handling

* error handling and naming

* fix tests

* fix tests

* fix merge

* rename
This commit is contained in:
Livio Amstutz
2022-04-06 08:13:40 +02:00
committed by GitHub
parent b949b8fc65
commit 4a0d61d75a
36 changed files with 2016 additions and 967 deletions

View File

@@ -1,114 +1,30 @@
package config
import (
"context"
"encoding/json"
"io"
"net/url"
"time"
"database/sql"
"github.com/caos/zitadel/internal/domain"
"github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/static"
"github.com/caos/zitadel/internal/static/database"
"github.com/caos/zitadel/internal/static/s3"
)
type AssetStorageConfig struct {
Type string
Config static.Config
Config map[string]interface{} `mapstructure:",remain"`
}
var storage = map[string]func() static.Config{
"s3": func() static.Config { return &s3.Config{} },
"none": func() static.Config { return &NoStorage{} },
"": func() static.Config { return &NoStorage{} },
}
func (c *AssetStorageConfig) UnmarshalJSON(data []byte) error {
var rc struct {
Type string
Config json.RawMessage
}
if err := json.Unmarshal(data, &rc); err != nil {
return errors.ThrowInternal(err, "STATIC-Bfn5r", "error parsing config")
}
c.Type = rc.Type
var err error
c.Config, err = newStorageConfig(c.Type, rc.Config)
if err != nil {
return err
}
return nil
}
func newStorageConfig(storageType string, configData []byte) (static.Config, error) {
t, ok := storage[storageType]
func (a *AssetStorageConfig) NewStorage(client *sql.DB) (static.Storage, error) {
t, ok := storage[a.Type]
if !ok {
return nil, errors.ThrowInternalf(nil, "STATIC-dsbjh", "config type %s not supported", storageType)
return nil, errors.ThrowInternalf(nil, "STATIC-dsbjh", "config type %s not supported", a.Type)
}
staticConfig := t()
if len(configData) == 0 {
return staticConfig, nil
}
if err := json.Unmarshal(configData, staticConfig); err != nil {
return nil, errors.ThrowInternal(err, "STATIC-GB4nw", "Could not read config: %v")
}
return staticConfig, nil
return t(client, a.Config)
}
var (
errNoStorage = errors.ThrowInternal(nil, "STATIC-ashg4", "Errors.Assets.Store.NotConfigured")
)
type NoStorage struct{}
func (_ *NoStorage) NewStorage() (static.Storage, error) {
return &NoStorage{}, nil
}
func (_ *NoStorage) CreateBucket(ctx context.Context, name, location string) error {
return errNoStorage
}
func (_ *NoStorage) RemoveBucket(ctx context.Context, name string) error {
return errNoStorage
}
func (_ *NoStorage) ListBuckets(ctx context.Context) ([]*domain.BucketInfo, error) {
return nil, errNoStorage
}
func (_ *NoStorage) PutObject(ctx context.Context, bucketName, objectName, contentType string, object io.Reader, objectSize int64, createBucketIfNotExisting bool) (*domain.AssetInfo, error) {
return nil, errNoStorage
}
func (_ *NoStorage) GetObjectInfo(ctx context.Context, bucketName, objectName string) (*domain.AssetInfo, error) {
return nil, errNoStorage
}
func (_ *NoStorage) GetObject(ctx context.Context, bucketName, objectName string) (io.Reader, func() (*domain.AssetInfo, error), error) {
return nil, nil, errNoStorage
}
func (_ *NoStorage) ListObjectInfos(ctx context.Context, bucketName, prefix string, recursive bool) ([]*domain.AssetInfo, error) {
return nil, errNoStorage
}
func (_ *NoStorage) GetObjectPresignedURL(ctx context.Context, bucketName, objectName string, expiration time.Duration) (*url.URL, error) {
return nil, errNoStorage
}
func (_ *NoStorage) RemoveObject(ctx context.Context, bucketName, objectName string) error {
return errNoStorage
}
func (_ *NoStorage) RemoveObjects(ctx context.Context, bucketName, path string, recursive bool) error {
return errNoStorage
var storage = map[string]static.CreateStorage{
"db": database.NewStorage,
"": database.NewStorage,
"s3": s3.NewStorage,
}

View File

@@ -0,0 +1,182 @@
package database
import (
"context"
"database/sql"
errs "errors"
"fmt"
"io"
"time"
"github.com/Masterminds/squirrel"
caos_errors "github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/static"
)
var _ static.Storage = (*crdbStorage)(nil)
const (
assetsTable = "system.assets"
AssetColInstanceID = "instance_id"
AssetColType = "asset_type"
AssetColLocation = "location"
AssetColResourceOwner = "resource_owner"
AssetColName = "name"
AssetColData = "data"
AssetColContentType = "content_type"
AssetColHash = "hash"
AssetColUpdatedAt = "updated_at"
)
type crdbStorage struct {
client *sql.DB
}
func NewStorage(client *sql.DB, _ map[string]interface{}) (static.Storage, error) {
return &crdbStorage{client: client}, nil
}
func (c *crdbStorage) PutObject(ctx context.Context, instanceID, location, resourceOwner, name, contentType string, objectType static.ObjectType, object io.Reader, objectSize int64) (*static.Asset, error) {
data, err := io.ReadAll(object)
if err != nil {
return nil, caos_errors.ThrowInternal(err, "DATAB-Dfwvq", "Errors.Internal")
}
stmt, args, err := squirrel.Insert(assetsTable).
Columns(AssetColInstanceID, AssetColResourceOwner, AssetColName, AssetColType, AssetColContentType, AssetColData, AssetColUpdatedAt).
Values(instanceID, resourceOwner, name, objectType, contentType, data, "now()").
Suffix(fmt.Sprintf(
"ON CONFLICT (%s, %s, %s) DO UPDATE"+
" SET %s = $5, %s = $6"+
" RETURNING %s, %s", AssetColInstanceID, AssetColResourceOwner, AssetColName, AssetColContentType, AssetColData, AssetColHash, AssetColUpdatedAt)).
PlaceholderFormat(squirrel.Dollar).
ToSql()
if err != nil {
return nil, caos_errors.ThrowInternal(err, "DATAB-32DG1", "Errors.Internal")
}
var hash string
var updatedAt time.Time
err = c.client.QueryRowContext(ctx, stmt, args...).Scan(&hash, &updatedAt)
if err != nil {
return nil, caos_errors.ThrowInternal(err, "DATAB-D2g2q", "Errors.Internal")
}
return &static.Asset{
InstanceID: instanceID,
Name: name,
Hash: hash,
Size: objectSize,
LastModified: updatedAt,
Location: location,
ContentType: contentType,
}, nil
}
func (c *crdbStorage) GetObject(ctx context.Context, instanceID, resourceOwner, name string) ([]byte, func() (*static.Asset, error), error) {
query, args, err := squirrel.Select(AssetColData, AssetColContentType, AssetColHash, AssetColUpdatedAt).
From(assetsTable).
Where(squirrel.Eq{
AssetColInstanceID: instanceID,
AssetColResourceOwner: resourceOwner,
AssetColName: name,
}).
PlaceholderFormat(squirrel.Dollar).
ToSql()
if err != nil {
return nil, nil, caos_errors.ThrowInternal(err, "DATAB-GE3hz", "Errors.Internal")
}
var data []byte
asset := &static.Asset{
InstanceID: instanceID,
ResourceOwner: resourceOwner,
Name: name,
}
err = c.client.QueryRowContext(ctx, query, args...).
Scan(
&data,
&asset.ContentType,
&asset.Hash,
&asset.LastModified,
)
if err != nil {
if errs.Is(err, sql.ErrNoRows) {
return nil, nil, caos_errors.ThrowNotFound(err, "DATAB-pCP8P", "Errors.Assets.Object.NotFound")
}
return nil, nil, caos_errors.ThrowInternal(err, "DATAB-Sfgb3", "Errors.Assets.Object.GetFailed")
}
asset.Size = int64(len(data))
return data,
func() (*static.Asset, error) {
return asset, nil
},
nil
}
func (c *crdbStorage) GetObjectInfo(ctx context.Context, instanceID, resourceOwner, name string) (*static.Asset, error) {
query, args, err := squirrel.Select(AssetColContentType, AssetColLocation, "length("+AssetColData+")", AssetColHash, AssetColUpdatedAt).
From(assetsTable).
Where(squirrel.Eq{
AssetColInstanceID: instanceID,
AssetColResourceOwner: resourceOwner,
AssetColName: name,
}).
PlaceholderFormat(squirrel.Dollar).
ToSql()
if err != nil {
return nil, caos_errors.ThrowInternal(err, "DATAB-rggt2", "Errors.Internal")
}
asset := &static.Asset{
InstanceID: instanceID,
ResourceOwner: resourceOwner,
Name: name,
}
err = c.client.QueryRowContext(ctx, query, args...).
Scan(
&asset.ContentType,
&asset.Location,
&asset.Size,
&asset.Hash,
&asset.LastModified,
)
if err != nil {
return nil, caos_errors.ThrowInternal(err, "DATAB-Dbh2s", "Errors.Internal")
}
return asset, nil
}
func (c *crdbStorage) RemoveObject(ctx context.Context, instanceID, resourceOwner, name string) error {
stmt, args, err := squirrel.Delete(assetsTable).
Where(squirrel.Eq{
AssetColInstanceID: instanceID,
AssetColResourceOwner: resourceOwner,
AssetColName: name,
}).
PlaceholderFormat(squirrel.Dollar).
ToSql()
if err != nil {
return caos_errors.ThrowInternal(err, "DATAB-Sgvwq", "Errors.Internal")
}
_, err = c.client.ExecContext(ctx, stmt, args...)
if err != nil {
return caos_errors.ThrowInternal(err, "DATAB-RHNgf", "Errors.Assets.Object.RemoveFailed")
}
return nil
}
func (c *crdbStorage) RemoveObjects(ctx context.Context, instanceID, resourceOwner string, objectType static.ObjectType) error {
stmt, args, err := squirrel.Delete(assetsTable).
Where(squirrel.Eq{
AssetColInstanceID: instanceID,
AssetColResourceOwner: resourceOwner,
AssetColType: objectType,
}).
PlaceholderFormat(squirrel.Dollar).
ToSql()
if err != nil {
return caos_errors.ThrowInternal(err, "DATAB-Sfgeq", "Errors.Internal")
}
_, err = c.client.ExecContext(ctx, stmt, args...)
if err != nil {
return caos_errors.ThrowInternal(err, "DATAB-Efgt2", "Errors.Assets.Object.RemoveFailed")
}
return nil
}

View File

@@ -0,0 +1,203 @@
package database
import (
"bytes"
"context"
"database/sql"
"database/sql/driver"
"io"
"reflect"
"regexp"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/caos/zitadel/internal/static"
)
var (
testNow = time.Now()
)
const (
objectStmt = "INSERT INTO system.assets" +
" (instance_id,resource_owner,name,asset_type,content_type,data,updated_at)" +
" VALUES ($1,$2,$3,$4,$5,$6,$7)" +
" ON CONFLICT (instance_id, resource_owner, name) DO UPDATE SET" +
" content_type = $5, data = $6" +
" RETURNING hash"
)
func Test_crdbStorage_CreateObject(t *testing.T) {
type fields struct {
client db
}
type args struct {
ctx context.Context
instanceID string
location string
resourceOwner string
name string
contentType string
objectType static.ObjectType
data io.Reader
objectSize int64
}
tests := []struct {
name string
fields fields
args args
want *static.Asset
wantErr bool
}{
{
"create ok",
fields{
client: prepareDB(t,
expectQuery(
objectStmt,
[]string{
"hash",
"updated_at",
},
[][]driver.Value{
{
"md5Hash",
testNow,
},
},
"instanceID",
"resourceOwner",
"name",
static.ObjectTypeUserAvatar,
"contentType",
[]byte("test"),
"now()",
)),
},
args{
ctx: context.Background(),
instanceID: "instanceID",
location: "location",
resourceOwner: "resourceOwner",
name: "name",
contentType: "contentType",
data: bytes.NewReader([]byte("test")),
objectSize: 4,
},
&static.Asset{
InstanceID: "instanceID",
Name: "name",
Hash: "md5Hash",
Size: 4,
LastModified: testNow,
Location: "location",
ContentType: "contentType",
},
false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &crdbStorage{
client: tt.fields.client.db,
}
got, err := c.PutObject(tt.args.ctx, tt.args.instanceID, tt.args.location, tt.args.resourceOwner, tt.args.name, tt.args.contentType, tt.args.objectType, tt.args.data, tt.args.objectSize)
if (err != nil) != tt.wantErr {
t.Errorf("CreateObject() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("CreateObject() got = %v, want %v", got, tt.want)
}
})
}
}
type db struct {
mock sqlmock.Sqlmock
db *sql.DB
}
func prepareDB(t *testing.T, expectations ...expectation) db {
t.Helper()
client, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("unable to create sql mock: %v", err)
}
for _, expectation := range expectations {
expectation(mock)
}
return db{
mock: mock,
db: client,
}
}
type expectation func(m sqlmock.Sqlmock)
func expectExists(query string, value bool, args ...driver.Value) expectation {
return func(m sqlmock.Sqlmock) {
m.ExpectQuery(regexp.QuoteMeta(query)).WithArgs(args...).WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(value))
}
}
func expectQueryErr(query string, err error, args ...driver.Value) expectation {
return func(m sqlmock.Sqlmock) {
m.ExpectQuery(regexp.QuoteMeta(query)).WithArgs(args...).WillReturnError(err)
}
}
func expectQuery(stmt string, cols []string, rows [][]driver.Value, args ...driver.Value) func(m sqlmock.Sqlmock) {
return func(m sqlmock.Sqlmock) {
q := m.ExpectQuery(regexp.QuoteMeta(stmt)).WithArgs(args...)
result := sqlmock.NewRows(cols)
count := uint64(len(rows))
for _, row := range rows {
if cols[len(cols)-1] == "count" {
row = append(row, count)
}
result.AddRow(row...)
}
q.WillReturnRows(result)
q.RowsWillBeClosed()
}
}
func expectExec(stmt string, err error, args ...driver.Value) expectation {
return func(m sqlmock.Sqlmock) {
query := m.ExpectExec(regexp.QuoteMeta(stmt)).WithArgs(args...)
if err != nil {
query.WillReturnError(err)
return
}
query.WillReturnResult(sqlmock.NewResult(1, 1))
}
}
func expectBegin(err error) expectation {
return func(m sqlmock.Sqlmock) {
query := m.ExpectBegin()
if err != nil {
query.WillReturnError(err)
}
}
}
func expectCommit(err error) expectation {
return func(m sqlmock.Sqlmock) {
query := m.ExpectCommit()
if err != nil {
query.WillReturnError(err)
}
}
}
func expectRollback(err error) expectation {
return func(m sqlmock.Sqlmock) {
query := m.ExpectRollback()
if err != nil {
query.WillReturnError(err)
}
}
}

View File

@@ -22,6 +22,7 @@ Errors:
Object:
PutFailed: Objekt konnte nicht erstellt werden
GetFailed: Objekt konnte nicht gelesen werden
NotFound: Objekt konnte nicht gefunden werden
PresignedTokenFailed: Signiertes Token konnte nicht erstellt werden
ListFailed: Objektliste konnte nicht gelesen werden
RemoveFailed: Objekt konnte nicht gelöscht werden

View File

@@ -22,6 +22,7 @@ Errors:
Object:
PutFailed: Object not created
GetFailed: Object could not be read
NotFound: Object could not be found
PresignedTokenFailed: Signed token could not be created
ListFailed: Objectlist could not be read
RemoveFailed: Object could not be removed

View File

@@ -22,6 +22,7 @@ Errors:
Object:
PutFailed: Oggetto non creato
GetFailed: Oggetto non può essere letto
NotFound: Oggetto non trovato
PresignedTokenFailed: Il token non può essere creato
ListFailed: La lista degli oggetti non può essere letta
RemoveFailed: L'oggetto non può essere rimosso

View File

@@ -7,11 +7,8 @@ package mock
import (
context "context"
io "io"
url "net/url"
reflect "reflect"
time "time"
domain "github.com/caos/zitadel/internal/domain"
static "github.com/caos/zitadel/internal/static"
gomock "github.com/golang/mock/gomock"
)
@@ -39,187 +36,76 @@ func (m *MockStorage) EXPECT() *MockStorageMockRecorder {
return m.recorder
}
// CreateBucket mocks base method.
func (m *MockStorage) CreateBucket(ctx context.Context, name, location string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CreateBucket", ctx, name, location)
ret0, _ := ret[0].(error)
return ret0
}
// CreateBucket indicates an expected call of CreateBucket.
func (mr *MockStorageMockRecorder) CreateBucket(ctx, name, location interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateBucket", reflect.TypeOf((*MockStorage)(nil).CreateBucket), ctx, name, location)
}
// GetObject mocks base method.
func (m *MockStorage) GetObject(ctx context.Context, bucketName, objectName string) (io.Reader, func() (*domain.AssetInfo, error), error) {
func (m *MockStorage) GetObject(ctx context.Context, instanceID, resourceOwner, name string) ([]byte, func() (*static.Asset, error), error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetObject", ctx, bucketName, objectName)
ret0, _ := ret[0].(io.Reader)
ret1, _ := ret[1].(func() (*domain.AssetInfo, error))
ret := m.ctrl.Call(m, "GetObject", ctx, instanceID, resourceOwner, name)
ret0, _ := ret[0].([]byte)
ret1, _ := ret[1].(func() (*static.Asset, error))
ret2, _ := ret[2].(error)
return ret0, ret1, ret2
}
// GetObject indicates an expected call of GetObject.
func (mr *MockStorageMockRecorder) GetObject(ctx, bucketName, objectName interface{}) *gomock.Call {
func (mr *MockStorageMockRecorder) GetObject(ctx, instanceID, resourceOwner, name interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetObject", reflect.TypeOf((*MockStorage)(nil).GetObject), ctx, bucketName, objectName)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetObject", reflect.TypeOf((*MockStorage)(nil).GetObject), ctx, instanceID, resourceOwner, name)
}
// GetObjectInfo mocks base method.
func (m *MockStorage) GetObjectInfo(ctx context.Context, bucketName, objectName string) (*domain.AssetInfo, error) {
func (m *MockStorage) GetObjectInfo(ctx context.Context, instanceID, resourceOwner, name string) (*static.Asset, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetObjectInfo", ctx, bucketName, objectName)
ret0, _ := ret[0].(*domain.AssetInfo)
ret := m.ctrl.Call(m, "GetObjectInfo", ctx, instanceID, resourceOwner, name)
ret0, _ := ret[0].(*static.Asset)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetObjectInfo indicates an expected call of GetObjectInfo.
func (mr *MockStorageMockRecorder) GetObjectInfo(ctx, bucketName, objectName interface{}) *gomock.Call {
func (mr *MockStorageMockRecorder) GetObjectInfo(ctx, instanceID, resourceOwner, name interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetObjectInfo", reflect.TypeOf((*MockStorage)(nil).GetObjectInfo), ctx, bucketName, objectName)
}
// GetObjectPresignedURL mocks base method.
func (m *MockStorage) GetObjectPresignedURL(ctx context.Context, bucketName, objectName string, expiration time.Duration) (*url.URL, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetObjectPresignedURL", ctx, bucketName, objectName, expiration)
ret0, _ := ret[0].(*url.URL)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetObjectPresignedURL indicates an expected call of GetObjectPresignedURL.
func (mr *MockStorageMockRecorder) GetObjectPresignedURL(ctx, bucketName, objectName, expiration interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetObjectPresignedURL", reflect.TypeOf((*MockStorage)(nil).GetObjectPresignedURL), ctx, bucketName, objectName, expiration)
}
// ListBuckets mocks base method.
func (m *MockStorage) ListBuckets(ctx context.Context) ([]*domain.BucketInfo, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListBuckets", ctx)
ret0, _ := ret[0].([]*domain.BucketInfo)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListBuckets indicates an expected call of ListBuckets.
func (mr *MockStorageMockRecorder) ListBuckets(ctx interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListBuckets", reflect.TypeOf((*MockStorage)(nil).ListBuckets), ctx)
}
// ListObjectInfos mocks base method.
func (m *MockStorage) ListObjectInfos(ctx context.Context, bucketName, prefix string, recursive bool) ([]*domain.AssetInfo, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListObjectInfos", ctx, bucketName, prefix, recursive)
ret0, _ := ret[0].([]*domain.AssetInfo)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListObjectInfos indicates an expected call of ListObjectInfos.
func (mr *MockStorageMockRecorder) ListObjectInfos(ctx, bucketName, prefix, recursive interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListObjectInfos", reflect.TypeOf((*MockStorage)(nil).ListObjectInfos), ctx, bucketName, prefix, recursive)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetObjectInfo", reflect.TypeOf((*MockStorage)(nil).GetObjectInfo), ctx, instanceID, resourceOwner, name)
}
// PutObject mocks base method.
func (m *MockStorage) PutObject(ctx context.Context, bucketName, objectName, contentType string, object io.Reader, objectSize int64, createBucketIfNotExisting bool) (*domain.AssetInfo, error) {
func (m *MockStorage) PutObject(ctx context.Context, instanceID, location, resourceOwner, name, contentType string, objectType static.ObjectType, object io.Reader, objectSize int64) (*static.Asset, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "PutObject", ctx, bucketName, objectName, contentType, object, objectSize, createBucketIfNotExisting)
ret0, _ := ret[0].(*domain.AssetInfo)
ret := m.ctrl.Call(m, "PutObject", ctx, instanceID, location, resourceOwner, name, contentType, objectType, object, objectSize)
ret0, _ := ret[0].(*static.Asset)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// PutObject indicates an expected call of PutObject.
func (mr *MockStorageMockRecorder) PutObject(ctx, bucketName, objectName, contentType, object, objectSize, createBucketIfNotExisting interface{}) *gomock.Call {
func (mr *MockStorageMockRecorder) PutObject(ctx, instanceID, location, resourceOwner, name, contentType, objectType, object, objectSize interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PutObject", reflect.TypeOf((*MockStorage)(nil).PutObject), ctx, bucketName, objectName, contentType, object, objectSize, createBucketIfNotExisting)
}
// RemoveBucket mocks base method.
func (m *MockStorage) RemoveBucket(ctx context.Context, name string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "RemoveBucket", ctx, name)
ret0, _ := ret[0].(error)
return ret0
}
// RemoveBucket indicates an expected call of RemoveBucket.
func (mr *MockStorageMockRecorder) RemoveBucket(ctx, name interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveBucket", reflect.TypeOf((*MockStorage)(nil).RemoveBucket), ctx, name)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PutObject", reflect.TypeOf((*MockStorage)(nil).PutObject), ctx, instanceID, location, resourceOwner, name, contentType, objectType, object, objectSize)
}
// RemoveObject mocks base method.
func (m *MockStorage) RemoveObject(ctx context.Context, bucketName, objectName string) error {
func (m *MockStorage) RemoveObject(ctx context.Context, instanceID, resourceOwner, name string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "RemoveObject", ctx, bucketName, objectName)
ret := m.ctrl.Call(m, "RemoveObject", ctx, instanceID, resourceOwner, name)
ret0, _ := ret[0].(error)
return ret0
}
// RemoveObject indicates an expected call of RemoveObject.
func (mr *MockStorageMockRecorder) RemoveObject(ctx, bucketName, objectName interface{}) *gomock.Call {
func (mr *MockStorageMockRecorder) RemoveObject(ctx, instanceID, resourceOwner, name interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveObject", reflect.TypeOf((*MockStorage)(nil).RemoveObject), ctx, bucketName, objectName)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveObject", reflect.TypeOf((*MockStorage)(nil).RemoveObject), ctx, instanceID, resourceOwner, name)
}
// RemoveObjects mocks base method.
func (m *MockStorage) RemoveObjects(ctx context.Context, bucketName, path string, recursive bool) error {
func (m *MockStorage) RemoveObjects(ctx context.Context, instanceID, resourceOwner string, objectType static.ObjectType) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "RemoveObjects", ctx, bucketName, path, recursive)
ret := m.ctrl.Call(m, "RemoveObjects", ctx, instanceID, resourceOwner, objectType)
ret0, _ := ret[0].(error)
return ret0
}
// RemoveObjects indicates an expected call of RemoveObjects.
func (mr *MockStorageMockRecorder) RemoveObjects(ctx, bucketName, path, recursive interface{}) *gomock.Call {
func (mr *MockStorageMockRecorder) RemoveObjects(ctx, instanceID, resourceOwner, objectType interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveObjects", reflect.TypeOf((*MockStorage)(nil).RemoveObjects), ctx, bucketName, path, recursive)
}
// MockConfig is a mock of Config interface.
type MockConfig struct {
ctrl *gomock.Controller
recorder *MockConfigMockRecorder
}
// MockConfigMockRecorder is the mock recorder for MockConfig.
type MockConfigMockRecorder struct {
mock *MockConfig
}
// NewMockConfig creates a new mock instance.
func NewMockConfig(ctrl *gomock.Controller) *MockConfig {
mock := &MockConfig{ctrl: ctrl}
mock.recorder = &MockConfigMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockConfig) EXPECT() *MockConfigMockRecorder {
return m.recorder
}
// NewStorage mocks base method.
func (m *MockConfig) NewStorage() (static.Storage, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "NewStorage")
ret0, _ := ret[0].(static.Storage)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// NewStorage indicates an expected call of NewStorage.
func (mr *MockConfigMockRecorder) NewStorage() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewStorage", reflect.TypeOf((*MockConfig)(nil).NewStorage))
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveObjects", reflect.TypeOf((*MockStorage)(nil).RemoveObjects), ctx, instanceID, resourceOwner, objectType)
}

View File

@@ -1,34 +1,49 @@
package mock
import (
"context"
"io"
"testing"
"time"
"github.com/golang/mock/gomock"
caos_errors "github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/static"
)
func NewStorage(t *testing.T) *MockStorage {
return NewMockStorage(gomock.NewController(t))
}
func (m *MockStorage) ExpectAddObjectNoError() *MockStorage {
func (m *MockStorage) ExpectPutObject() *MockStorage {
m.EXPECT().
PutObject(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil, nil)
PutObject(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, instanceID, location, resourceOwner, name, contentType string, objectType static.ObjectType, object io.Reader, objectSize int64) (*static.Asset, error) {
hash, _ := io.ReadAll(object)
return &static.Asset{
InstanceID: instanceID,
Name: name,
Hash: string(hash),
Size: objectSize,
LastModified: time.Now(),
Location: location,
ContentType: contentType,
}, nil
})
return m
}
func (m *MockStorage) ExpectAddObjectError() *MockStorage {
func (m *MockStorage) ExpectPutObjectError() *MockStorage {
m.EXPECT().
PutObject(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
PutObject(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil, caos_errors.ThrowInternal(nil, "", ""))
return m
}
func (m *MockStorage) ExpectRemoveObjectNoError() *MockStorage {
m.EXPECT().
RemoveObject(gomock.Any(), gomock.Any(), gomock.Any()).
RemoveObject(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil)
return m
}
@@ -42,7 +57,7 @@ func (m *MockStorage) ExpectRemoveObjectsNoError() *MockStorage {
func (m *MockStorage) ExpectRemoveObjectError() *MockStorage {
m.EXPECT().
RemoveObject(gomock.Any(), gomock.Any(), gomock.Any()).
RemoveObject(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(caos_errors.ThrowInternal(nil, "", ""))
return m
}

View File

@@ -1,9 +1,13 @@
package s3
import (
"database/sql"
"encoding/json"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"github.com/caos/zitadel/internal/errors"
caos_errs "github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/static"
)
@@ -34,3 +38,15 @@ func (c *Config) NewStorage() (static.Storage, error) {
MultiDelete: c.MultiDelete,
}, nil
}
func NewStorage(_ *sql.DB, rawConfig map[string]interface{}) (static.Storage, error) {
configData, err := json.Marshal(rawConfig)
if err != nil {
return nil, errors.ThrowInternal(err, "MINIO-Ef2f2", "could not map config")
}
c := new(Config)
if err := json.Unmarshal(configData, c); err != nil {
return nil, errors.ThrowInternal(err, "MINIO-GB4nw", "could not map config")
}
return c.NewStorage()
}

View File

@@ -2,11 +2,10 @@ package s3
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/caos/logging"
"github.com/minio/minio-go/v7"
@@ -14,8 +13,11 @@ import (
"github.com/caos/zitadel/internal/domain"
caos_errs "github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/static"
)
var _ static.Storage = (*Minio)(nil)
type Minio struct {
Client *minio.Client
Location string
@@ -23,129 +25,66 @@ type Minio struct {
MultiDelete bool
}
func (m *Minio) CreateBucket(ctx context.Context, name, location string) error {
if location == "" {
location = m.Location
func (m *Minio) PutObject(ctx context.Context, instanceID, location, resourceOwner, name, contentType string, objectType static.ObjectType, object io.Reader, objectSize int64) (*static.Asset, error) {
err := m.createBucket(ctx, instanceID, location)
if err != nil && !caos_errs.IsErrorAlreadyExists(err) {
return nil, err
}
name = m.prefixBucketName(name)
exists, err := m.Client.BucketExists(ctx, name)
if err != nil {
logging.LogWithFields("MINIO-ADvf3", "bucketname", name).WithError(err).Error("cannot check if bucket exists")
return caos_errs.ThrowInternal(err, "MINIO-1b8fs", "Errors.Assets.Bucket.Internal")
}
if exists {
return caos_errs.ThrowAlreadyExists(nil, "MINIO-9n3MK", "Errors.Assets.Bucket.AlreadyExists")
}
err = m.Client.MakeBucket(ctx, name, minio.MakeBucketOptions{Region: location})
if err != nil {
return caos_errs.ThrowInternal(err, "MINIO-4m90d", "Errors.Assets.Bucket.CreateFailed")
}
return nil
}
func (m *Minio) ListBuckets(ctx context.Context) ([]*domain.BucketInfo, error) {
infos, err := m.Client.ListBuckets(ctx)
if err != nil {
return nil, caos_errs.ThrowInternal(err, "MINIO-390OP", "Errors.Assets.Bucket.ListFailed")
}
buckets := make([]*domain.BucketInfo, len(infos))
for i, info := range infos {
buckets[i] = &domain.BucketInfo{
Name: info.Name,
CreationDate: info.CreationDate,
}
}
return buckets, nil
}
func (m *Minio) RemoveBucket(ctx context.Context, name string) error {
name = m.prefixBucketName(name)
err := m.Client.RemoveBucket(ctx, name)
if err != nil {
return caos_errs.ThrowInternal(err, "MINIO-338Hs", "Errors.Assets.Bucket.RemoveFailed")
}
return nil
}
func (m *Minio) PutObject(ctx context.Context, bucketName, objectName, contentType string, object io.Reader, objectSize int64, createBucketIfNotExisting bool) (*domain.AssetInfo, error) {
if createBucketIfNotExisting {
err := m.CreateBucket(ctx, bucketName, "")
if err != nil && !caos_errs.IsErrorAlreadyExists(err) {
return nil, err
}
}
bucketName = m.prefixBucketName(bucketName)
bucketName := m.prefixBucketName(instanceID)
objectName := fmt.Sprintf("%s/%s", resourceOwner, name)
info, err := m.Client.PutObject(ctx, bucketName, objectName, object, objectSize, minio.PutObjectOptions{ContentType: contentType})
if err != nil {
return nil, caos_errs.ThrowInternal(err, "MINIO-590sw", "Errors.Assets.Object.PutFailed")
}
return &domain.AssetInfo{
Bucket: info.Bucket,
Key: info.Key,
ETag: info.ETag,
Size: info.Size,
LastModified: info.LastModified,
Location: info.Location,
VersionID: info.VersionID,
return &static.Asset{
InstanceID: info.Bucket,
ResourceOwner: resourceOwner,
Name: info.Key,
Hash: info.ETag,
Size: info.Size,
LastModified: info.LastModified,
Location: info.Location,
ContentType: contentType,
}, nil
}
func (m *Minio) GetObjectInfo(ctx context.Context, bucketName, objectName string) (*domain.AssetInfo, error) {
bucketName = m.prefixBucketName(bucketName)
objectinfo, err := m.Client.StatObject(ctx, bucketName, objectName, minio.StatObjectOptions{})
func (m *Minio) GetObject(ctx context.Context, instanceID, resourceOwner, name string) ([]byte, func() (*static.Asset, error), error) {
bucketName := m.prefixBucketName(instanceID)
objectName := fmt.Sprintf("%s/%s", resourceOwner, name)
object, err := m.Client.GetObject(ctx, bucketName, objectName, minio.GetObjectOptions{})
if err != nil {
return nil, nil, caos_errs.ThrowInternal(err, "MINIO-VGDgv", "Errors.Assets.Object.GetFailed")
}
info := func() (*static.Asset, error) {
info, err := object.Stat()
if err != nil {
return nil, caos_errs.ThrowInternal(err, "MINIO-F96xF", "Errors.Assets.Object.GetFailed")
}
return m.objectToAssetInfo(instanceID, resourceOwner, info), nil
}
asset, err := io.ReadAll(object)
if err != nil {
return nil, nil, caos_errs.ThrowInternal(err, "MINIO-SFef1", "Errors.Assets.Object.GetFailed")
}
return asset, info, nil
}
func (m *Minio) GetObjectInfo(ctx context.Context, instanceID, resourceOwner, name string) (*static.Asset, error) {
bucketName := m.prefixBucketName(instanceID)
objectName := fmt.Sprintf("%s/%s", resourceOwner, name)
objectInfo, err := m.Client.StatObject(ctx, bucketName, objectName, minio.StatObjectOptions{})
if err != nil {
if errResp := minio.ToErrorResponse(err); errResp.StatusCode == http.StatusNotFound {
return nil, caos_errs.ThrowNotFound(err, "MINIO-Gdfh4", "Errors.Assets.Object.GetFailed")
}
return nil, caos_errs.ThrowInternal(err, "MINIO-1vySX", "Errors.Assets.Object.GetFailed")
}
return m.objectToAssetInfo(bucketName, objectinfo), nil
return m.objectToAssetInfo(instanceID, resourceOwner, objectInfo), nil
}
func (m *Minio) GetObject(ctx context.Context, bucketName, objectName string) (io.Reader, func() (*domain.AssetInfo, error), error) {
bucketName = m.prefixBucketName(bucketName)
object, err := m.Client.GetObject(ctx, bucketName, objectName, minio.GetObjectOptions{})
if err != nil {
return nil, nil, caos_errs.ThrowInternal(err, "MINIO-VGDgv", "Errors.Assets.Object.GetFailed")
}
info := func() (*domain.AssetInfo, error) {
info, err := object.Stat()
if err != nil {
return nil, caos_errs.ThrowInternal(err, "MINIO-F96xF", "Errors.Assets.Object.GetFailed")
}
return m.objectToAssetInfo(bucketName, info), nil
}
return object, info, nil
}
func (m *Minio) GetObjectPresignedURL(ctx context.Context, bucketName, objectName string, expiration time.Duration) (*url.URL, error) {
bucketName = m.prefixBucketName(bucketName)
reqParams := make(url.Values)
presignedURL, err := m.Client.PresignedGetObject(ctx, bucketName, objectName, expiration, reqParams)
if err != nil {
return nil, caos_errs.ThrowInternal(err, "MINIO-19Mp0", "Errors.Assets.Object.PresignedTokenFailed")
}
return presignedURL, nil
}
func (m *Minio) ListObjectInfos(ctx context.Context, bucketName, prefix string, recursive bool) ([]*domain.AssetInfo, error) {
bucketName = m.prefixBucketName(bucketName)
assetInfos := make([]*domain.AssetInfo, 0)
objects, cancel := m.listObjects(ctx, bucketName, prefix, recursive)
defer cancel()
for object := range objects {
if object.Err != nil {
logging.LogWithFields("MINIO-wC8sd", "bucket-name", bucketName, "prefix", prefix).WithError(object.Err).Debug("unable to get object")
return nil, caos_errs.ThrowInternal(object.Err, "MINIO-1m09S", "Errors.Assets.Object.ListFailed")
}
assetInfos = append(assetInfos, m.objectToAssetInfo(bucketName, object))
}
return assetInfos, nil
}
func (m *Minio) RemoveObject(ctx context.Context, bucketName, objectName string) error {
bucketName = m.prefixBucketName(bucketName)
func (m *Minio) RemoveObject(ctx context.Context, instanceID, resourceOwner, name string) error {
bucketName := m.prefixBucketName(instanceID)
objectName := fmt.Sprintf("%s/%s", resourceOwner, name)
err := m.Client.RemoveObject(ctx, bucketName, objectName, minio.RemoveObjectOptions{})
if err != nil {
return caos_errs.ThrowInternal(err, "MINIO-x85RT", "Errors.Assets.Object.RemoveFailed")
@@ -153,19 +92,27 @@ func (m *Minio) RemoveObject(ctx context.Context, bucketName, objectName string)
return nil
}
func (m *Minio) RemoveObjects(ctx context.Context, bucketName, path string, recursive bool) error {
bucketName = m.prefixBucketName(bucketName)
func (m *Minio) RemoveObjects(ctx context.Context, instanceID, resourceOwner string, objectType static.ObjectType) error {
bucketName := m.prefixBucketName(instanceID)
objectsCh := make(chan minio.ObjectInfo)
g := new(errgroup.Group)
var path string
switch objectType {
case static.ObjectTypeStyling:
path = domain.LabelPolicyPrefix + "/"
default:
return nil
}
g.Go(func() error {
defer close(objectsCh)
objects, cancel := m.listObjects(ctx, bucketName, path, recursive)
objects, cancel := m.listObjects(ctx, bucketName, resourceOwner, true)
for object := range objects {
if err := object.Err; err != nil {
cancel()
if errResp := minio.ToErrorResponse(err); errResp.StatusCode == http.StatusNotFound {
logging.LogWithFields("MINIO-ss8va", "bucketName", bucketName, "path", path).Warn("list objects for remove failed with not found")
logging.WithFields("bucketName", bucketName, "path", path).Warn("list objects for remove failed with not found")
continue
}
return caos_errs.ThrowInternal(object.Err, "MINIO-WQF32", "Errors.Assets.Object.ListFailed")
@@ -189,6 +136,26 @@ func (m *Minio) RemoveObjects(ctx context.Context, bucketName, path string, recu
return g.Wait()
}
func (m *Minio) createBucket(ctx context.Context, name, location string) error {
if location == "" {
location = m.Location
}
name = m.prefixBucketName(name)
exists, err := m.Client.BucketExists(ctx, name)
if err != nil {
logging.WithFields("bucketname", name).WithError(err).Error("cannot check if bucket exists")
return caos_errs.ThrowInternal(err, "MINIO-1b8fs", "Errors.Assets.Bucket.Internal")
}
if exists {
return caos_errs.ThrowAlreadyExists(nil, "MINIO-9n3MK", "Errors.Assets.Bucket.AlreadyExists")
}
err = m.Client.MakeBucket(ctx, name, minio.MakeBucketOptions{Region: location})
if err != nil {
return caos_errs.ThrowInternal(err, "MINIO-4m90d", "Errors.Assets.Bucket.CreateFailed")
}
return nil
}
func (m *Minio) listObjects(ctx context.Context, bucketName, prefix string, recursive bool) (<-chan minio.ObjectInfo, context.CancelFunc) {
ctxCancel, cancel := context.WithCancel(ctx)
@@ -198,17 +165,15 @@ func (m *Minio) listObjects(ctx context.Context, bucketName, prefix string, recu
}), cancel
}
func (m *Minio) objectToAssetInfo(bucketName string, object minio.ObjectInfo) *domain.AssetInfo {
return &domain.AssetInfo{
Bucket: bucketName,
Key: object.Key,
ETag: object.ETag,
Size: object.Size,
LastModified: object.LastModified,
VersionID: object.VersionID,
Expiration: object.Expires,
ContentType: object.ContentType,
AutheticatedURL: m.Client.EndpointURL().String() + "/" + bucketName + "/" + object.Key,
func (m *Minio) objectToAssetInfo(bucketName string, resourceOwner string, object minio.ObjectInfo) *static.Asset {
return &static.Asset{
InstanceID: bucketName,
ResourceOwner: resourceOwner,
Name: object.Key,
Hash: object.ETag,
Size: object.Size,
LastModified: object.LastModified,
ContentType: object.ContentType,
}
}

View File

@@ -2,25 +2,36 @@ package static
import (
"context"
"database/sql"
"io"
"net/url"
"time"
"github.com/caos/zitadel/internal/domain"
)
type CreateStorage func(client *sql.DB, rawConfig map[string]interface{}) (Storage, error)
type Storage interface {
CreateBucket(ctx context.Context, name, location string) error
RemoveBucket(ctx context.Context, name string) error
ListBuckets(ctx context.Context) ([]*domain.BucketInfo, error)
PutObject(ctx context.Context, bucketName, objectName, contentType string, object io.Reader, objectSize int64, createBucketIfNotExisting bool) (*domain.AssetInfo, error)
GetObjectInfo(ctx context.Context, bucketName, objectName string) (*domain.AssetInfo, error)
GetObject(ctx context.Context, bucketName, objectName string) (io.Reader, func() (*domain.AssetInfo, error), error)
ListObjectInfos(ctx context.Context, bucketName, prefix string, recursive bool) ([]*domain.AssetInfo, error)
GetObjectPresignedURL(ctx context.Context, bucketName, objectName string, expiration time.Duration) (*url.URL, error)
RemoveObject(ctx context.Context, bucketName, objectName string) error
RemoveObjects(ctx context.Context, bucketName, path string, recursive bool) error
PutObject(ctx context.Context, instanceID, location, resourceOwner, name, contentType string, objectType ObjectType, object io.Reader, objectSize int64) (*Asset, error)
GetObject(ctx context.Context, instanceID, resourceOwner, name string) ([]byte, func() (*Asset, error), error)
GetObjectInfo(ctx context.Context, instanceID, resourceOwner, name string) (*Asset, error)
RemoveObject(ctx context.Context, instanceID, resourceOwner, name string) error
RemoveObjects(ctx context.Context, instanceID, resourceOwner string, objectType ObjectType) error
//TODO: add functionality to move asset location
}
type Config interface {
NewStorage() (Storage, error)
type ObjectType int32
const (
ObjectTypeUserAvatar = iota
ObjectTypeStyling
)
type Asset struct {
InstanceID string
ResourceOwner string
Name string
Hash string
Size int64
LastModified time.Time
Location string
ContentType string
}