diff --git a/client/apis/objectstorage/v1alpha2/definitions.go b/client/apis/objectstorage/v1alpha2/definitions.go index 162b76fc..e205ec55 100644 --- a/client/apis/objectstorage/v1alpha2/definitions.go +++ b/client/apis/objectstorage/v1alpha2/definitions.go @@ -25,6 +25,10 @@ const ( // Annotations const ( + // BucketClaimBeingDeletedAnnotation : This annotation is applied by the COSI Controller to a + // Bucket when its BucketClaim is being deleted. + BucketClaimBeingDeletedAnnotation = `objectstorage.k8.io/bucketclaim-being-deleted` + // HasBucketAccessReferencesAnnotation : This annotation is applied by the COSI Controller to a // BucketClaim when a BucketAccess that references the BucketClaim is created. The annotation // remains for as long as any BucketAccess references the BucketClaim. Once all BucketAccesses diff --git a/controller/internal/reconciler/bucketaccess.go b/controller/internal/reconciler/bucketaccess.go index 8e0f794b..3d4d6204 100644 --- a/controller/internal/reconciler/bucketaccess.go +++ b/controller/internal/reconciler/bucketaccess.go @@ -36,8 +36,8 @@ import ( cosiapi "sigs.k8s.io/container-object-storage-interface/client/apis/objectstorage/v1alpha2" objectstoragev1alpha2 "sigs.k8s.io/container-object-storage-interface/client/apis/objectstorage/v1alpha2" + "sigs.k8s.io/container-object-storage-interface/internal/bucketaccess" cosierr "sigs.k8s.io/container-object-storage-interface/internal/errors" - "sigs.k8s.io/container-object-storage-interface/internal/handoff" cosipredicate "sigs.k8s.io/container-object-storage-interface/internal/predicate" ) @@ -67,7 +67,7 @@ func (r *BucketAccessReconciler) Reconcile(ctx context.Context, req ctrl.Request return ctrl.Result{}, err } - if handoff.BucketAccessManagedBySidecar(access) { + if bucketaccess.ManagedBySidecar(access) { logger.V(1).Info("not reconciling BucketAccess that should be managed by sidecar") return ctrl.Result{}, nil } @@ -142,12 +142,12 @@ func (r *BucketAccessReconciler) reconcile( return cosierr.NonRetryableError(fmt.Errorf("deletion is not yet implemented")) // TODO } - needInit, err := needsControllerInitialization(&access.Status) + initialized, err := bucketaccess.SidecarRequirementsPresent(&access.Status) if err != nil { logger.Error(err, "processed a degraded BucketAccess") return cosierr.NonRetryableError(fmt.Errorf("processed a degraded BucketAccess: %w", err)) } - if !needInit { + if initialized { // BucketAccessClass info should only be copied to the BucketAccess status once, upon // initial provisioning. After the info is copied, make no attempt to fill in any missing or // lost info because we don't know whether the current Class is compatible with the info @@ -244,38 +244,6 @@ func (r *BucketAccessReconciler) reconcile( return nil } -// Return true if the Controller needs to initialize the BucketAccess with BucketClaim and -// BucketAccessClass info. Return false if required info is set. -// Return an error if any required info is only partially set. This indicates some sort of -// degradation or bug. -func needsControllerInitialization(s *cosiapi.BucketAccessStatus) (bool, error) { - requiredFields := map[string]bool{} - requiredFieldIsSet := func(fieldName string, isSet bool) { - requiredFields[fieldName] = isSet - } - - requiredFieldIsSet("status.accessedBuckets", len(s.AccessedBuckets) > 0) - requiredFieldIsSet("status.driverName", s.DriverName != "") - requiredFieldIsSet("status.authenticationType", string(s.AuthenticationType) != "") - - set := []string{} - for field, isSet := range requiredFields { - if isSet { - set = append(set, field) - } - } - - if len(set) == 0 { - return true, nil - } - - if len(set) == len(requiredFields) { - return false, nil - } - - return false, fmt.Errorf("required Controller-managed fields are only partially set: %v", requiredFields) -} - // Get all BucketClaims that this BucketAccess references. // If any claims don't exist, assume they don't exist YET; mark them nil in the resulting map // without treating nonexistence as an error. diff --git a/controller/internal/reconciler/bucketaccess_test.go b/controller/internal/reconciler/bucketaccess_test.go index 1e8ff808..e6f2d328 100644 --- a/controller/internal/reconciler/bucketaccess_test.go +++ b/controller/internal/reconciler/bucketaccess_test.go @@ -29,7 +29,7 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" cosiapi "sigs.k8s.io/container-object-storage-interface/client/apis/objectstorage/v1alpha2" - "sigs.k8s.io/container-object-storage-interface/internal/handoff" + "sigs.k8s.io/container-object-storage-interface/internal/bucketaccess" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" @@ -195,10 +195,10 @@ func TestBucketAccessReconcile(t *testing.T) { status.Parameters, ) - assert.True(t, handoff.BucketAccessManagedBySidecar(access)) // MUST hand off to sidecar - needInit, err := needsControllerInitialization(&access.Status) // MUST be fully initialized + assert.True(t, bucketaccess.ManagedBySidecar(access)) // MUST hand off to sidecar + initialized, err := bucketaccess.SidecarRequirementsPresent(&access.Status) // MUST be fully initialized assert.NoError(t, err) - assert.False(t, needInit) + assert.True(t, initialized) crw := &cosiapi.BucketClaim{} err = c.Get(ctx, readWriteClaimNsName, crw) @@ -267,10 +267,10 @@ func TestBucketAccessReconcile(t *testing.T) { assert.Empty(t, status.AuthenticationType) assert.Empty(t, status.Parameters) - assert.False(t, handoff.BucketAccessManagedBySidecar(access)) // MUST NOT hand off to sidecar - needInit, err := needsControllerInitialization(&access.Status) // MUST NOT be initialized + assert.False(t, bucketaccess.ManagedBySidecar(access)) // MUST NOT hand off to sidecar + initialized, err := bucketaccess.SidecarRequirementsPresent(&access.Status) // MUST NOT be initialized assert.NoError(t, err) - assert.True(t, needInit) + assert.False(t, initialized) crw := &cosiapi.BucketClaim{} err = c.Get(ctx, readWriteClaimNsName, crw) @@ -315,10 +315,10 @@ func TestBucketAccessReconcile(t *testing.T) { assert.Empty(t, status.AuthenticationType) assert.Empty(t, status.Parameters) - assert.False(t, handoff.BucketAccessManagedBySidecar(access)) // MUST NOT hand off to sidecar - needInit, err := needsControllerInitialization(&access.Status) // MUST NOT be initialized + assert.False(t, bucketaccess.ManagedBySidecar(access)) // MUST NOT hand off to sidecar + initialized, err := bucketaccess.SidecarRequirementsPresent(&access.Status) // MUST NOT be initialized assert.NoError(t, err) - assert.True(t, needInit) + assert.False(t, initialized) crw := &cosiapi.BucketClaim{} err = c.Get(ctx, readWriteClaimNsName, crw) @@ -371,10 +371,10 @@ func TestBucketAccessReconcile(t *testing.T) { assert.Empty(t, status.AuthenticationType) assert.Empty(t, status.Parameters) - assert.False(t, handoff.BucketAccessManagedBySidecar(access)) // MUST NOT hand off to sidecar - needInit, err := needsControllerInitialization(&access.Status) // MUST NOT be initialized + assert.False(t, bucketaccess.ManagedBySidecar(access)) // MUST NOT hand off to sidecar + initialized, err := bucketaccess.SidecarRequirementsPresent(&access.Status) // MUST NOT be initialized assert.NoError(t, err) - assert.True(t, needInit) + assert.False(t, initialized) crw := &cosiapi.BucketClaim{} err = c.Get(ctx, readWriteClaimNsName, crw) @@ -424,10 +424,10 @@ func TestBucketAccessReconcile(t *testing.T) { assert.Empty(t, status.AuthenticationType) assert.Empty(t, status.Parameters) - assert.False(t, handoff.BucketAccessManagedBySidecar(access)) // MUST NOT hand off to sidecar - needInit, err := needsControllerInitialization(&access.Status) // MUST NOT be initialized + assert.False(t, bucketaccess.ManagedBySidecar(access)) // MUST NOT hand off to sidecar + initialized, err := bucketaccess.SidecarRequirementsPresent(&access.Status) // MUST NOT be initialized assert.NoError(t, err) - assert.True(t, needInit) + assert.False(t, initialized) crw := &cosiapi.BucketClaim{} err = c.Get(ctx, readWriteClaimNsName, crw) @@ -474,10 +474,10 @@ func TestBucketAccessReconcile(t *testing.T) { assert.Empty(t, status.AuthenticationType) assert.Empty(t, status.Parameters) - assert.False(t, handoff.BucketAccessManagedBySidecar(access)) // MUST NOT hand off to sidecar - needInit, err := needsControllerInitialization(&access.Status) // MUST NOT be initialized + assert.False(t, bucketaccess.ManagedBySidecar(access)) // MUST NOT hand off to sidecar + initialized, err := bucketaccess.SidecarRequirementsPresent(&access.Status) // MUST NOT be initialized assert.NoError(t, err) - assert.True(t, needInit) + assert.False(t, initialized) crw := &cosiapi.BucketClaim{} err = c.Get(ctx, readWriteClaimNsName, crw) @@ -526,10 +526,10 @@ func TestBucketAccessReconcile(t *testing.T) { assert.Empty(t, status.AuthenticationType) assert.Empty(t, status.Parameters) - assert.False(t, handoff.BucketAccessManagedBySidecar(access)) // MUST NOT hand off to sidecar - needInit, err := needsControllerInitialization(&access.Status) // MUST NOT be initialized + assert.False(t, bucketaccess.ManagedBySidecar(access)) // MUST NOT hand off to sidecar + initialized, err := bucketaccess.SidecarRequirementsPresent(&access.Status) // MUST NOT be initialized assert.NoError(t, err) - assert.True(t, needInit) + assert.False(t, initialized) crw := &cosiapi.BucketClaim{} err = c.Get(ctx, readWriteClaimNsName, crw) @@ -594,10 +594,10 @@ func TestBucketAccessReconcile(t *testing.T) { status.Parameters, ) - assert.True(t, handoff.BucketAccessManagedBySidecar(access)) // MUST hand off to sidecar - needInit, err := needsControllerInitialization(&access.Status) // MUST be fully initialized + assert.True(t, bucketaccess.ManagedBySidecar(access)) // MUST hand off to sidecar + initialized, err := bucketaccess.SidecarRequirementsPresent(&access.Status) // MUST be fully initialized assert.NoError(t, err) - assert.False(t, needInit) + assert.True(t, initialized) crw := &cosiapi.BucketClaim{} err = c.Get(ctx, readWriteClaimNsName, crw) @@ -650,10 +650,10 @@ func TestBucketAccessReconcile(t *testing.T) { assert.Empty(t, status.AuthenticationType) assert.Empty(t, status.Parameters) - assert.False(t, handoff.BucketAccessManagedBySidecar(access)) // MUST NOT hand off to sidecar - needInit, err := needsControllerInitialization(&access.Status) // MUST NOT be initialized + assert.False(t, bucketaccess.ManagedBySidecar(access)) // MUST NOT hand off to sidecar + initialized, err := bucketaccess.SidecarRequirementsPresent(&access.Status) // MUST NOT be initialized assert.NoError(t, err) - assert.True(t, needInit) + assert.False(t, initialized) crw := &cosiapi.BucketClaim{} err = c.Get(ctx, readWriteClaimNsName, crw) @@ -706,10 +706,10 @@ func TestBucketAccessReconcile(t *testing.T) { assert.Empty(t, status.AuthenticationType) assert.Empty(t, status.Parameters) - assert.False(t, handoff.BucketAccessManagedBySidecar(access)) // MUST NOT hand off to sidecar - needInit, err := needsControllerInitialization(&access.Status) // MUST NOT be initialized + assert.False(t, bucketaccess.ManagedBySidecar(access)) // MUST NOT hand off to sidecar + initialized, err := bucketaccess.SidecarRequirementsPresent(&access.Status) // MUST NOT be initialized assert.NoError(t, err) - assert.True(t, needInit) + assert.False(t, initialized) crw := &cosiapi.BucketClaim{} err = c.Get(ctx, readWriteClaimNsName, crw) diff --git a/go.mod b/go.mod index 65fe09dd..c9f0e247 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/go-logr/logr v1.4.3 github.com/stretchr/testify v1.11.1 google.golang.org/grpc v1.75.1 + k8s.io/api v0.34.3 k8s.io/apimachinery v0.34.3 k8s.io/client-go v0.34.3 k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d @@ -99,7 +100,6 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.34.3 // indirect k8s.io/apiextensions-apiserver v0.34.1 // indirect k8s.io/apiserver v0.34.1 // indirect k8s.io/component-base v0.34.1 // indirect diff --git a/internal/handoff/handoff.go b/internal/bucketaccess/bucketaccess.go similarity index 50% rename from internal/handoff/handoff.go rename to internal/bucketaccess/bucketaccess.go index 492301cf..832b18f4 100644 --- a/internal/handoff/handoff.go +++ b/internal/bucketaccess/bucketaccess.go @@ -14,16 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package handoff defines logic needed for handing off control of resources between Controller and -// Sidecar. -package handoff +// Package bucketaccess defines logic for BucketAccess resources needed by both Controller and +// Sidecar. BucketAccesses need to be handed off between the two, and some logic is shared. +package bucketaccess import ( + "fmt" + cosiapi "sigs.k8s.io/container-object-storage-interface/client/apis/objectstorage/v1alpha2" ) -// BucketAccessManagedBySidecar returns true if a BucketAccess should be managed by the Sidecar. -// A false return value indicates that it should be managed by the Controller instead. +// ManagedBySidecar returns true if a BucketAccess should be managed by the Sidecar. +// It returns false if it should be managed by the Controller instead. // // In order for COSI Controller and any given Sidecar to work well together, they should avoid // managing the same BucketAccess resource at the same time. This will help prevent the Controller @@ -39,7 +41,7 @@ import ( // 2. Sidecar version low, Controller version high // 3. Sidecar version high, Controller version low // 4. Sidecar version high, Controller version high -func BucketAccessManagedBySidecar(ba *cosiapi.BucketAccess) bool { +func ManagedBySidecar(ba *cosiapi.BucketAccess) bool { // Allow a future-compatible mechanism by which the Controller can override the normal // BucketAccess management handoff logic in order to resolve a bug. // Instances where this is utilized should be infrequent -- ideally, never used. @@ -47,11 +49,11 @@ func BucketAccessManagedBySidecar(ba *cosiapi.BucketAccess) bool { return false } - // During provisioning, there are several status fields that the Controller needs to set before - // the Sidecar can provision an access. However, tying this function's logic to ALL of the - // status items could make long-term Controller-Sidecar handoff logic fragile. More logic means - // more risk of unmanaged resources and more difficulty reasoning about how changes will impact - // ownership during version skew. Minimize risk by relying on a single determining status field. + // During provisioning, there are several status fields that the Controller needs to initialize + // before the Sidecar can provision an access. However, tying this function's logic to ALL of + // the status items could make long-term Controller-Sidecar handoff logic fragile. More logic + // means more risk of unmanaged resources and more difficulty reasoning about how changes will + // impact ownership during version skew. Minimize risk by relying on a single status field. if ba.Status.DriverName == "" { return false } @@ -66,3 +68,40 @@ func BucketAccessManagedBySidecar(ba *cosiapi.BucketAccess) bool { return true } + +// SidecarRequirementsPresent verifies that BucketAccess status information required by the Sidecar +// to provision the BucketAccess is fully set. +// +// Return true if the fields needed by the Sidecar are all set (Controller initialization finished). +// Return false if the fields needed by the Sidecar are all unset (needs Controller initialization). +// Return an error if required info is only partially set, indicating some sort of degradation/bug. +// +// Do not use this function to determine whether a BucketAccess should be managed by the Sidecar or +// Controller, or whether handoff has occurred. Use ManagedBySidecar() for that purpose instead. +// This function is appropriate for use within a controller to check requirements before/after +// initialization/provisioning. +func SidecarRequirementsPresent(s *cosiapi.BucketAccessStatus) (bool, error) { + requiredFields := map[string]bool{} + set := []string{} + + requiredFieldIsSet := func(fieldName string, isSet bool) { + requiredFields[fieldName] = isSet + if isSet { + set = append(set, fieldName) + } + } + + requiredFieldIsSet("status.accessedBuckets", len(s.AccessedBuckets) > 0) + requiredFieldIsSet("status.driverName", s.DriverName != "") + requiredFieldIsSet("status.authenticationType", string(s.AuthenticationType) != "") + + if len(set) == 0 { + return false, nil + } + + if len(set) == len(requiredFields) { + return true, nil + } + + return false, fmt.Errorf("fields required for sidecar provisioning are only partially set: %v", requiredFields) +} diff --git a/internal/handoff/handoff_test.go b/internal/bucketaccess/bucketaccess_test.go similarity index 95% rename from internal/handoff/handoff_test.go rename to internal/bucketaccess/bucketaccess_test.go index 006f6296..f1371f46 100644 --- a/internal/handoff/handoff_test.go +++ b/internal/bucketaccess/bucketaccess_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package handoff +package bucketaccess import ( "testing" @@ -27,7 +27,7 @@ import ( cosiapi "sigs.k8s.io/container-object-storage-interface/client/apis/objectstorage/v1alpha2" ) -func TestBucketAccessManagedBySidecar(t *testing.T) { +func TestManagedBySidecar(t *testing.T) { tests := []struct { name string // description of this test case // input parameters for target function. @@ -125,8 +125,8 @@ func TestBucketAccessManagedBySidecar(t *testing.T) { if tt.isHandedOffToSidecar { copy.Status.AccessedBuckets = []cosiapi.AccessedBucket{ { - BucketName: "bc-asdfgh", - AccessMode: cosiapi.BucketAccessModeReadWrite, + BucketName: "bc-asdfgh", + BucketClaimName: "bc-1", }, } copy.Status.DriverName = "some.driver.io" @@ -142,12 +142,12 @@ func TestBucketAccessManagedBySidecar(t *testing.T) { copy.Annotations[cosiapi.SidecarCleanupFinishedAnnotation] = "" } - got := BucketAccessManagedBySidecar(copy) + got := ManagedBySidecar(copy) assert.Equal(t, tt.want, got) // for all cases,applying the controller override annotation makes it controller-managed copy.Annotations[cosiapi.ControllerManagementOverrideAnnotation] = "" - withOverride := BucketAccessManagedBySidecar(copy) + withOverride := ManagedBySidecar(copy) assert.False(t, withOverride) }) } diff --git a/internal/predicate/predicate.go b/internal/predicate/predicate.go index 795f76d8..36d57b23 100644 --- a/internal/predicate/predicate.go +++ b/internal/predicate/predicate.go @@ -32,7 +32,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/predicate" cosiapi "sigs.k8s.io/container-object-storage-interface/client/apis/objectstorage/v1alpha2" - "sigs.k8s.io/container-object-storage-interface/internal/handoff" + "sigs.k8s.io/container-object-storage-interface/internal/bucketaccess" ) // AnyCreate returns a predicate that enqueues a reconcile for any Create event. @@ -143,8 +143,8 @@ func BucketAccessHandoffOccurred(s *runtime.Scheme) predicate.Funcs { // Internal logic for determining if BucketAccess Controller-Sidecar handoff has occurred. func handoffOccurred(logger logr.Logger, old, new *cosiapi.BucketAccess) bool { - oldIsSidecar := handoff.BucketAccessManagedBySidecar(old) - newIsSidecar := handoff.BucketAccessManagedBySidecar(new) + oldIsSidecar := bucketaccess.ManagedBySidecar(old) + newIsSidecar := bucketaccess.ManagedBySidecar(new) if oldIsSidecar != newIsSidecar { toComponentName := func(isSidecar bool) string { if isSidecar { @@ -169,7 +169,7 @@ func BucketAccessManagedBySidecar(s *runtime.Scheme) predicate.Funcs { if !ok { return false // not a BucketAccess, so don't manage it } - return handoff.BucketAccessManagedBySidecar(ba) + return bucketaccess.ManagedBySidecar(ba) }) } @@ -183,7 +183,7 @@ func BucketAccessManagedByController(s *runtime.Scheme) predicate.Funcs { } // Note: cannot simply return predicate.Not() of BucketAccessManagedBySidecar() because // any failed type conversion must return false for both Sidecar and Controller - return !handoff.BucketAccessManagedBySidecar(ba) + return !bucketaccess.ManagedBySidecar(ba) }) } diff --git a/internal/protocol/azure.go b/internal/protocol/azure.go index d976779e..3d63e6ca 100644 --- a/internal/protocol/azure.go +++ b/internal/protocol/azure.go @@ -24,13 +24,23 @@ import ( cosiproto "sigs.k8s.io/container-object-storage-interface/proto" ) +type AzureProtocolGetter struct{} + +func (AzureProtocolGetter) ApiProtocol() cosiapi.ObjectProtocol { + return cosiapi.ObjectProtocolAzure +} + // AzureBucketInfoTranslator implements RpcApiTranslator for Azure bucket info. -type AzureBucketInfoTranslator struct{} +type AzureBucketInfoTranslator struct { + AzureProtocolGetter +} var _ RpcApiTranslator[*cosiproto.AzureBucketInfo, cosiapi.BucketInfoVar] = AzureBucketInfoTranslator{} // AzureCredentialTranslator implements RpcApiTranslator for Azure credentials. -type AzureCredentialTranslator struct{} +type AzureCredentialTranslator struct { + AzureProtocolGetter +} var _ RpcApiTranslator[*cosiproto.AzureCredentialInfo, cosiapi.CredentialVar] = AzureCredentialTranslator{} diff --git a/internal/protocol/gcs.go b/internal/protocol/gcs.go index 6b25280b..32e1b60a 100644 --- a/internal/protocol/gcs.go +++ b/internal/protocol/gcs.go @@ -24,13 +24,23 @@ import ( cosiproto "sigs.k8s.io/container-object-storage-interface/proto" ) +type GcsProtocolGetter struct{} + +func (GcsProtocolGetter) ApiProtocol() cosiapi.ObjectProtocol { + return cosiapi.ObjectProtocolGcs +} + // GcsBucketInfoTranslator implements RpcApiTranslator for GCS bucket info. -type GcsBucketInfoTranslator struct{} +type GcsBucketInfoTranslator struct { + GcsProtocolGetter +} var _ RpcApiTranslator[*cosiproto.GcsBucketInfo, cosiapi.BucketInfoVar] = GcsBucketInfoTranslator{} // GcsCredentialTranslator implements RpcApiTranslator for GCS credentials. -type GcsCredentialTranslator struct{} +type GcsCredentialTranslator struct { + GcsProtocolGetter +} var _ RpcApiTranslator[*cosiproto.GcsCredentialInfo, cosiapi.CredentialVar] = GcsCredentialTranslator{} diff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go index ca01a964..26641006 100644 --- a/internal/protocol/protocol.go +++ b/internal/protocol/protocol.go @@ -59,6 +59,9 @@ func (ObjectProtocolTranslator) ApiToRpc(in cosiapi.ObjectProtocol) (cosiproto.O // An RpcApiTranslator translates types between the RPC driver-domain and Kubernetes API user-domain // for a particular protocol. type RpcApiTranslator[RpcType any, ApiType comparable] interface { + // ApiProtocol returns the user-domain API object protocol. + ApiProtocol() cosiapi.ObjectProtocol + // RpcToApi translates bucket info from RPC to API with no validation. // If the input is nil, the result map MUST be nil. // All possible API info fields SHOULD be present in the result, even if the corresponding RPC diff --git a/internal/protocol/s3.go b/internal/protocol/s3.go index 4e3991a6..72acead7 100644 --- a/internal/protocol/s3.go +++ b/internal/protocol/s3.go @@ -35,13 +35,23 @@ var ( } ) +type S3ProtocolGetter struct{} + +func (S3ProtocolGetter) ApiProtocol() cosiapi.ObjectProtocol { + return cosiapi.ObjectProtocolS3 +} + // S3BucketInfoTranslator implements RpcApiTranslator for S3 bucket info. -type S3BucketInfoTranslator struct{} +type S3BucketInfoTranslator struct { + S3ProtocolGetter +} var _ RpcApiTranslator[*cosiproto.S3BucketInfo, cosiapi.BucketInfoVar] = S3BucketInfoTranslator{} // S3CredentialTranslator implements RpcApiTranslator for S3 credentials. -type S3CredentialTranslator struct{} +type S3CredentialTranslator struct { + S3ProtocolGetter +} var _ RpcApiTranslator[*cosiproto.S3CredentialInfo, cosiapi.CredentialVar] = S3CredentialTranslator{} @@ -126,6 +136,10 @@ func (S3BucketInfoTranslator) Validate( return nil } +func (S3BucketInfoTranslator) ApiProtocol() cosiapi.ObjectProtocol { + return cosiapi.ObjectProtocolS3 +} + func (S3CredentialTranslator) RpcToApi(c *cosiproto.S3CredentialInfo) map[cosiapi.CredentialVar]string { if c == nil { return nil diff --git a/proto/cosi.pb.go b/proto/cosi.pb.go index 177abc85..6040abed 100644 --- a/proto/cosi.pb.go +++ b/proto/cosi.pb.go @@ -958,6 +958,7 @@ func (x *AuthenticationType) GetType() AuthenticationType_Type { type AccessMode struct { state protoimpl.MessageState `protogen:"open.v1"` + Mode AccessMode_Mode `protobuf:"varint,1,opt,name=mode,proto3,enum=sigs.k8s.io.cosi.v1alpha2.AccessMode_Mode" json:"mode,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -992,6 +993,13 @@ func (*AccessMode) Descriptor() ([]byte, []int) { return file_cosi_proto_rawDescGZIP(), []int{13} } +func (x *AccessMode) GetMode() AccessMode_Mode { + if x != nil { + return x.Mode + } + return AccessMode_UNKNOWN +} + type DriverCreateBucketRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // REQUIRED. The suggested name for the backend bucket. @@ -2023,9 +2031,10 @@ const file_cosi_proto_rawDesc = "" + "\x04Type\x12\v\n" + "\aUNKNOWN\x10\x00\x12\a\n" + "\x03KEY\x10\x01\x12\x13\n" + - "\x0fSERVICE_ACCOUNT\x10\x02\"P\n" + + "\x0fSERVICE_ACCOUNT\x10\x02\"\x90\x01\n" + "\n" + - "AccessMode\"B\n" + + "AccessMode\x12>\n" + + "\x04mode\x18\x01 \x01(\x0e2*.sigs.k8s.io.cosi.v1alpha2.AccessMode.ModeR\x04mode\"B\n" + "\x04Mode\x12\v\n" + "\aUNKNOWN\x10\x00\x12\x0e\n" + "\n" + @@ -2197,49 +2206,50 @@ var file_cosi_proto_depIdxs = []int32{ 11, // 8: sigs.k8s.io.cosi.v1alpha2.S3BucketInfo.addressing_style:type_name -> sigs.k8s.io.cosi.v1alpha2.S3AddressingStyle 1, // 9: sigs.k8s.io.cosi.v1alpha2.S3AddressingStyle.style:type_name -> sigs.k8s.io.cosi.v1alpha2.S3AddressingStyle.Style 2, // 10: sigs.k8s.io.cosi.v1alpha2.AuthenticationType.type:type_name -> sigs.k8s.io.cosi.v1alpha2.AuthenticationType.Type - 6, // 11: sigs.k8s.io.cosi.v1alpha2.DriverCreateBucketRequest.protocols:type_name -> sigs.k8s.io.cosi.v1alpha2.ObjectProtocol - 28, // 12: sigs.k8s.io.cosi.v1alpha2.DriverCreateBucketRequest.parameters:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverCreateBucketRequest.ParametersEntry - 7, // 13: sigs.k8s.io.cosi.v1alpha2.DriverCreateBucketResponse.protocols:type_name -> sigs.k8s.io.cosi.v1alpha2.ObjectProtocolAndBucketInfo - 6, // 14: sigs.k8s.io.cosi.v1alpha2.DriverGetExistingBucketRequest.protocols:type_name -> sigs.k8s.io.cosi.v1alpha2.ObjectProtocol - 29, // 15: sigs.k8s.io.cosi.v1alpha2.DriverGetExistingBucketRequest.parameters:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverGetExistingBucketRequest.ParametersEntry - 7, // 16: sigs.k8s.io.cosi.v1alpha2.DriverGetExistingBucketResponse.protocols:type_name -> sigs.k8s.io.cosi.v1alpha2.ObjectProtocolAndBucketInfo - 30, // 17: sigs.k8s.io.cosi.v1alpha2.DriverDeleteBucketRequest.parameters:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverDeleteBucketRequest.ParametersEntry - 6, // 18: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest.protocol:type_name -> sigs.k8s.io.cosi.v1alpha2.ObjectProtocol - 16, // 19: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest.authentication_type:type_name -> sigs.k8s.io.cosi.v1alpha2.AuthenticationType - 31, // 20: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest.parameters:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest.ParametersEntry - 32, // 21: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest.buckets:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest.AccessedBucket - 33, // 22: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessResponse.buckets:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessResponse.BucketInfo - 8, // 23: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessResponse.credentials:type_name -> sigs.k8s.io.cosi.v1alpha2.CredentialInfo - 6, // 24: sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessRequest.protocol:type_name -> sigs.k8s.io.cosi.v1alpha2.ObjectProtocol - 16, // 25: sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessRequest.authentication_type:type_name -> sigs.k8s.io.cosi.v1alpha2.AuthenticationType - 34, // 26: sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessRequest.parameters:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessRequest.ParametersEntry - 35, // 27: sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessRequest.buckets:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessRequest.AccessedBucket - 17, // 28: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest.AccessedBucket.access_mode:type_name -> sigs.k8s.io.cosi.v1alpha2.AccessMode - 7, // 29: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessResponse.BucketInfo.bucket_info:type_name -> sigs.k8s.io.cosi.v1alpha2.ObjectProtocolAndBucketInfo - 36, // 30: sigs.k8s.io.cosi.v1alpha2.alpha_enum:extendee -> google.protobuf.EnumOptions - 37, // 31: sigs.k8s.io.cosi.v1alpha2.alpha_enum_value:extendee -> google.protobuf.EnumValueOptions - 38, // 32: sigs.k8s.io.cosi.v1alpha2.cosi_secret:extendee -> google.protobuf.FieldOptions - 38, // 33: sigs.k8s.io.cosi.v1alpha2.alpha_field:extendee -> google.protobuf.FieldOptions - 39, // 34: sigs.k8s.io.cosi.v1alpha2.alpha_message:extendee -> google.protobuf.MessageOptions - 40, // 35: sigs.k8s.io.cosi.v1alpha2.alpha_method:extendee -> google.protobuf.MethodOptions - 41, // 36: sigs.k8s.io.cosi.v1alpha2.alpha_service:extendee -> google.protobuf.ServiceOptions - 4, // 37: sigs.k8s.io.cosi.v1alpha2.Identity.DriverGetInfo:input_type -> sigs.k8s.io.cosi.v1alpha2.DriverGetInfoRequest - 18, // 38: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverCreateBucket:input_type -> sigs.k8s.io.cosi.v1alpha2.DriverCreateBucketRequest - 20, // 39: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverGetExistingBucket:input_type -> sigs.k8s.io.cosi.v1alpha2.DriverGetExistingBucketRequest - 22, // 40: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverDeleteBucket:input_type -> sigs.k8s.io.cosi.v1alpha2.DriverDeleteBucketRequest - 24, // 41: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverGrantBucketAccess:input_type -> sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest - 26, // 42: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverRevokeBucketAccess:input_type -> sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessRequest - 5, // 43: sigs.k8s.io.cosi.v1alpha2.Identity.DriverGetInfo:output_type -> sigs.k8s.io.cosi.v1alpha2.DriverGetInfoResponse - 19, // 44: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverCreateBucket:output_type -> sigs.k8s.io.cosi.v1alpha2.DriverCreateBucketResponse - 21, // 45: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverGetExistingBucket:output_type -> sigs.k8s.io.cosi.v1alpha2.DriverGetExistingBucketResponse - 23, // 46: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverDeleteBucket:output_type -> sigs.k8s.io.cosi.v1alpha2.DriverDeleteBucketResponse - 25, // 47: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverGrantBucketAccess:output_type -> sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessResponse - 27, // 48: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverRevokeBucketAccess:output_type -> sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessResponse - 43, // [43:49] is the sub-list for method output_type - 37, // [37:43] is the sub-list for method input_type - 37, // [37:37] is the sub-list for extension type_name - 30, // [30:37] is the sub-list for extension extendee - 0, // [0:30] is the sub-list for field type_name + 3, // 11: sigs.k8s.io.cosi.v1alpha2.AccessMode.mode:type_name -> sigs.k8s.io.cosi.v1alpha2.AccessMode.Mode + 6, // 12: sigs.k8s.io.cosi.v1alpha2.DriverCreateBucketRequest.protocols:type_name -> sigs.k8s.io.cosi.v1alpha2.ObjectProtocol + 28, // 13: sigs.k8s.io.cosi.v1alpha2.DriverCreateBucketRequest.parameters:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverCreateBucketRequest.ParametersEntry + 7, // 14: sigs.k8s.io.cosi.v1alpha2.DriverCreateBucketResponse.protocols:type_name -> sigs.k8s.io.cosi.v1alpha2.ObjectProtocolAndBucketInfo + 6, // 15: sigs.k8s.io.cosi.v1alpha2.DriverGetExistingBucketRequest.protocols:type_name -> sigs.k8s.io.cosi.v1alpha2.ObjectProtocol + 29, // 16: sigs.k8s.io.cosi.v1alpha2.DriverGetExistingBucketRequest.parameters:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverGetExistingBucketRequest.ParametersEntry + 7, // 17: sigs.k8s.io.cosi.v1alpha2.DriverGetExistingBucketResponse.protocols:type_name -> sigs.k8s.io.cosi.v1alpha2.ObjectProtocolAndBucketInfo + 30, // 18: sigs.k8s.io.cosi.v1alpha2.DriverDeleteBucketRequest.parameters:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverDeleteBucketRequest.ParametersEntry + 6, // 19: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest.protocol:type_name -> sigs.k8s.io.cosi.v1alpha2.ObjectProtocol + 16, // 20: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest.authentication_type:type_name -> sigs.k8s.io.cosi.v1alpha2.AuthenticationType + 31, // 21: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest.parameters:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest.ParametersEntry + 32, // 22: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest.buckets:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest.AccessedBucket + 33, // 23: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessResponse.buckets:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessResponse.BucketInfo + 8, // 24: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessResponse.credentials:type_name -> sigs.k8s.io.cosi.v1alpha2.CredentialInfo + 6, // 25: sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessRequest.protocol:type_name -> sigs.k8s.io.cosi.v1alpha2.ObjectProtocol + 16, // 26: sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessRequest.authentication_type:type_name -> sigs.k8s.io.cosi.v1alpha2.AuthenticationType + 34, // 27: sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessRequest.parameters:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessRequest.ParametersEntry + 35, // 28: sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessRequest.buckets:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessRequest.AccessedBucket + 17, // 29: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest.AccessedBucket.access_mode:type_name -> sigs.k8s.io.cosi.v1alpha2.AccessMode + 7, // 30: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessResponse.BucketInfo.bucket_info:type_name -> sigs.k8s.io.cosi.v1alpha2.ObjectProtocolAndBucketInfo + 36, // 31: sigs.k8s.io.cosi.v1alpha2.alpha_enum:extendee -> google.protobuf.EnumOptions + 37, // 32: sigs.k8s.io.cosi.v1alpha2.alpha_enum_value:extendee -> google.protobuf.EnumValueOptions + 38, // 33: sigs.k8s.io.cosi.v1alpha2.cosi_secret:extendee -> google.protobuf.FieldOptions + 38, // 34: sigs.k8s.io.cosi.v1alpha2.alpha_field:extendee -> google.protobuf.FieldOptions + 39, // 35: sigs.k8s.io.cosi.v1alpha2.alpha_message:extendee -> google.protobuf.MessageOptions + 40, // 36: sigs.k8s.io.cosi.v1alpha2.alpha_method:extendee -> google.protobuf.MethodOptions + 41, // 37: sigs.k8s.io.cosi.v1alpha2.alpha_service:extendee -> google.protobuf.ServiceOptions + 4, // 38: sigs.k8s.io.cosi.v1alpha2.Identity.DriverGetInfo:input_type -> sigs.k8s.io.cosi.v1alpha2.DriverGetInfoRequest + 18, // 39: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverCreateBucket:input_type -> sigs.k8s.io.cosi.v1alpha2.DriverCreateBucketRequest + 20, // 40: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverGetExistingBucket:input_type -> sigs.k8s.io.cosi.v1alpha2.DriverGetExistingBucketRequest + 22, // 41: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverDeleteBucket:input_type -> sigs.k8s.io.cosi.v1alpha2.DriverDeleteBucketRequest + 24, // 42: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverGrantBucketAccess:input_type -> sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest + 26, // 43: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverRevokeBucketAccess:input_type -> sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessRequest + 5, // 44: sigs.k8s.io.cosi.v1alpha2.Identity.DriverGetInfo:output_type -> sigs.k8s.io.cosi.v1alpha2.DriverGetInfoResponse + 19, // 45: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverCreateBucket:output_type -> sigs.k8s.io.cosi.v1alpha2.DriverCreateBucketResponse + 21, // 46: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverGetExistingBucket:output_type -> sigs.k8s.io.cosi.v1alpha2.DriverGetExistingBucketResponse + 23, // 47: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverDeleteBucket:output_type -> sigs.k8s.io.cosi.v1alpha2.DriverDeleteBucketResponse + 25, // 48: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverGrantBucketAccess:output_type -> sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessResponse + 27, // 49: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverRevokeBucketAccess:output_type -> sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessResponse + 44, // [44:50] is the sub-list for method output_type + 38, // [38:44] is the sub-list for method input_type + 38, // [38:38] is the sub-list for extension type_name + 31, // [31:38] is the sub-list for extension extendee + 0, // [0:31] is the sub-list for field type_name } func init() { file_cosi_proto_init() } diff --git a/proto/cosi.proto b/proto/cosi.proto index 78179070..897c4081 100644 --- a/proto/cosi.proto +++ b/proto/cosi.proto @@ -262,6 +262,7 @@ message AccessMode { // Write-only access mode. WRITE_ONLY = 3; } + Mode mode = 1; } message DriverCreateBucketRequest { diff --git a/proto/spec.md b/proto/spec.md index 6e6b62ac..26fd3926 100644 --- a/proto/spec.md +++ b/proto/spec.md @@ -419,6 +419,7 @@ message AccessMode { // Write-only access mode. WRITE_ONLY = 3; } + Mode mode = 1; } ``` diff --git a/sidecar/cmd/main.go b/sidecar/cmd/main.go index 8c812428..6bf7e53a 100644 --- a/sidecar/cmd/main.go +++ b/sidecar/cmd/main.go @@ -172,8 +172,9 @@ func main() { os.Exit(1) } if err := (&reconciler.BucketAccessReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + DriverInfo: *driverInfo, }).SetupWithManager(mgr); err != nil { logger.Error(err, "unable to create controller", "controller", "BucketAccess") os.Exit(1) diff --git a/sidecar/internal/reconciler/bucket.go b/sidecar/internal/reconciler/bucket.go index 63acad5a..b88e666a 100644 --- a/sidecar/internal/reconciler/bucket.go +++ b/sidecar/internal/reconciler/bucket.go @@ -128,7 +128,6 @@ func (r *BucketReconciler) SetupWithManager(mgr ctrl.Manager) error { func (r *BucketReconciler) reconcile(ctx context.Context, logger logr.Logger, bucket *cosiapi.Bucket) error { if bucket.Spec.DriverName != r.DriverInfo.name { - // TODO: configure the predicate to ignore any reconcile with non-matching driver // keep this log to help debug any issues that might arise with predicate logic logger.Info("not reconciling bucket with non-matching driver name %q", bucket.Spec.DriverName) return nil @@ -283,7 +282,12 @@ func (r *BucketReconciler) dynamicProvision( return nil, cosierr.NonRetryableError(fmt.Errorf("created bucket protocol response missing")) } - supportedProtos, allBucketInfo := parseProtocolBucketInfo(protoResp) + var noValidation *validationConfig = nil + supportedProtos, allBucketInfo, err := TranslateBucketInfoToApi(protoResp, noValidation) + if err != nil { + logger.Error(nil, "errors translating bucket info") + return nil, cosierr.NonRetryableError(err) + } details = &provisionedBucketDetails{ bucketId: resp.BucketId, @@ -293,35 +297,6 @@ func (r *BucketReconciler) dynamicProvision( return details, nil } -// Parse driver's per-protocol bucket info into raw user-facing info. Input must be non-nil. -func parseProtocolBucketInfo(pbi *cosiproto.ObjectProtocolAndBucketInfo) ( - supportedProtos []cosiapi.ObjectProtocol, - allProtoBucketInfo map[string]string, -) { - supportedProtos = []cosiapi.ObjectProtocol{} - allProtoBucketInfo = map[string]string{} - - if pbi.S3 != nil { - supportedProtos = append(supportedProtos, cosiapi.ObjectProtocolS3) - s3Translator := protocol.S3BucketInfoTranslator{} - mergeApiInfoIntoStringMap(s3Translator.RpcToApi(pbi.S3), allProtoBucketInfo) - } - - if pbi.Azure != nil { - supportedProtos = append(supportedProtos, cosiapi.ObjectProtocolAzure) - azureTranslator := protocol.AzureBucketInfoTranslator{} - mergeApiInfoIntoStringMap(azureTranslator.RpcToApi(pbi.Azure), allProtoBucketInfo) - } - - if pbi.Gcs != nil { - supportedProtos = append(supportedProtos, cosiapi.ObjectProtocolGcs) - gcsTranslator := protocol.GcsBucketInfoTranslator{} - mergeApiInfoIntoStringMap(gcsTranslator.RpcToApi(pbi.Gcs), allProtoBucketInfo) - } - - return supportedProtos, allProtoBucketInfo -} - // convert an API proto list into an RPC proto message list func objectProtocolListFromApiList(apiList []cosiapi.ObjectProtocol) ([]*cosiproto.ObjectProtocol, error) { errs := []error{} @@ -373,14 +348,3 @@ func validateBucketSupportsProtocols(supported, required []cosiapi.ObjectProtoco } return nil } - -func mergeApiInfoIntoStringMap[T cosiapi.BucketInfoVar | cosiapi.CredentialVar]( - varKey map[T]string, target map[string]string, -) { - if target == nil { - target = map[string]string{} - } - for k, v := range varKey { - target[string(k)] = v - } -} diff --git a/sidecar/internal/reconciler/bucket_test.go b/sidecar/internal/reconciler/bucket_test.go index 2017563f..acb72609 100644 --- a/sidecar/internal/reconciler/bucket_test.go +++ b/sidecar/internal/reconciler/bucket_test.go @@ -41,18 +41,6 @@ import ( "sigs.k8s.io/container-object-storage-interface/sidecar/internal/test" ) -type fakeProvisionerServer struct { - cosiproto.UnimplementedProvisionerServer - - createBucketFunc func(context.Context, *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) -} - -func (s *fakeProvisionerServer) DriverCreateBucket( - ctx context.Context, req *cosiproto.DriverCreateBucketRequest, -) (*cosiproto.DriverCreateBucketResponse, error) { - return s.createBucketFunc(ctx, req) -} - func TestBucketReconciler_Reconcile(t *testing.T) { baseBucket := cosiapi.Bucket{ ObjectMeta: meta.ObjectMeta{ @@ -91,8 +79,8 @@ func TestBucketReconciler_Reconcile(t *testing.T) { t.Run("dynamic provisioning, happy path", func(t *testing.T) { seenReq := []*cosiproto.DriverCreateBucketRequest{} var requestError error - fakeServer := fakeProvisionerServer{ - createBucketFunc: func(ctx context.Context, dcbr *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) { + fakeServer := test.FakeProvisionerServer{ + CreateBucketFunc: func(ctx context.Context, dcbr *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) { seenReq = append(seenReq, dcbr) ret := &cosiproto.DriverCreateBucketResponse{ BucketId: "cosi-" + dcbr.Name, @@ -244,8 +232,8 @@ func TestBucketReconciler_Reconcile(t *testing.T) { t.Run("dynamic provisioning, bucket missing", func(t *testing.T) { seenReq := []*cosiproto.DriverCreateBucketRequest{} - fakeServer := fakeProvisionerServer{ - createBucketFunc: func(ctx context.Context, dcbr *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) { + fakeServer := test.FakeProvisionerServer{ + CreateBucketFunc: func(ctx context.Context, dcbr *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) { seenReq = append(seenReq, dcbr) ret := &cosiproto.DriverCreateBucketResponse{ BucketId: "cosi-" + dcbr.Name, @@ -294,8 +282,8 @@ func TestBucketReconciler_Reconcile(t *testing.T) { t.Run("dynamic provisioning, driver name mismatch", func(t *testing.T) { seenReq := []*cosiproto.DriverCreateBucketRequest{} - fakeServer := fakeProvisionerServer{ - createBucketFunc: func(ctx context.Context, dcbr *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) { + fakeServer := test.FakeProvisionerServer{ + CreateBucketFunc: func(ctx context.Context, dcbr *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) { seenReq = append(seenReq, dcbr) ret := &cosiproto.DriverCreateBucketResponse{ BucketId: "cosi-" + dcbr.Name, @@ -354,8 +342,8 @@ func TestBucketReconciler_Reconcile(t *testing.T) { t.Run("dynamic provisioning, proto not supported", func(t *testing.T) { seenReq := []*cosiproto.DriverCreateBucketRequest{} - fakeServer := fakeProvisionerServer{ - createBucketFunc: func(ctx context.Context, dcbr *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) { + fakeServer := test.FakeProvisionerServer{ + CreateBucketFunc: func(ctx context.Context, dcbr *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) { seenReq = append(seenReq, dcbr) ret := &cosiproto.DriverCreateBucketResponse{ BucketId: "cosi-" + dcbr.Name, @@ -420,8 +408,8 @@ func TestBucketReconciler_Reconcile(t *testing.T) { t.Run("dynamic provisioning, provisioned bucket supports wrong proto", func(t *testing.T) { seenReq := []*cosiproto.DriverCreateBucketRequest{} - fakeServer := fakeProvisionerServer{ - createBucketFunc: func(ctx context.Context, dcbr *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) { + fakeServer := test.FakeProvisionerServer{ + CreateBucketFunc: func(ctx context.Context, dcbr *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) { seenReq = append(seenReq, dcbr) ret := &cosiproto.DriverCreateBucketResponse{ BucketId: "cosi-" + dcbr.Name, @@ -498,8 +486,8 @@ func TestBucketReconciler_dynamicProvision(t *testing.T) { t.Run("valid driver and bucket, successful provision", func(t *testing.T) { requestParams := map[string]string{} // record the params sent in the request to verify later - fakeServer := fakeProvisionerServer{ - createBucketFunc: func(ctx context.Context, dcbr *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) { + fakeServer := test.FakeProvisionerServer{ + CreateBucketFunc: func(ctx context.Context, dcbr *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) { requestParams = dcbr.Parameters ret := &cosiproto.DriverCreateBucketResponse{ BucketId: dcbr.Name, @@ -562,8 +550,8 @@ func TestBucketReconciler_dynamicProvision(t *testing.T) { }) t.Run("valid driver and bucket, retryable provision error", func(t *testing.T) { - fakeServer := fakeProvisionerServer{ - createBucketFunc: func(ctx context.Context, dcbr *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) { + fakeServer := test.FakeProvisionerServer{ + CreateBucketFunc: func(ctx context.Context, dcbr *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) { if len(dcbr.Parameters) != 0 { t.Errorf("expecting request parameters to be empty") } @@ -603,8 +591,8 @@ func TestBucketReconciler_dynamicProvision(t *testing.T) { }) t.Run("valid driver and bucket, non-retryable provision error", func(t *testing.T) { - fakeServer := fakeProvisionerServer{ - createBucketFunc: func(ctx context.Context, dcbr *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) { + fakeServer := test.FakeProvisionerServer{ + CreateBucketFunc: func(ctx context.Context, dcbr *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) { return &cosiproto.DriverCreateBucketResponse{}, status.Error(codes.InvalidArgument, "fake invalid arg err") }, } @@ -641,8 +629,8 @@ func TestBucketReconciler_dynamicProvision(t *testing.T) { }) t.Run("valid driver, claim ref malformed", func(t *testing.T) { - fakeServer := fakeProvisionerServer{ - createBucketFunc: func(ctx context.Context, dcbr *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) { + fakeServer := test.FakeProvisionerServer{ + CreateBucketFunc: func(ctx context.Context, dcbr *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) { return &cosiproto.DriverCreateBucketResponse{ BucketId: "bc-qwerty", Protocols: &cosiproto.ObjectProtocolAndBucketInfo{ @@ -689,8 +677,8 @@ func TestBucketReconciler_dynamicProvision(t *testing.T) { }) t.Run("valid driver, bucket ID missing", func(t *testing.T) { - fakeServer := fakeProvisionerServer{ - createBucketFunc: func(ctx context.Context, dcbr *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) { + fakeServer := test.FakeProvisionerServer{ + CreateBucketFunc: func(ctx context.Context, dcbr *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) { return &cosiproto.DriverCreateBucketResponse{ BucketId: "", // MISSING Protocols: &cosiproto.ObjectProtocolAndBucketInfo{ @@ -737,8 +725,8 @@ func TestBucketReconciler_dynamicProvision(t *testing.T) { }) t.Run("valid driver, proto response nil", func(t *testing.T) { - fakeServer := fakeProvisionerServer{ - createBucketFunc: func(ctx context.Context, dcbr *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) { + fakeServer := test.FakeProvisionerServer{ + CreateBucketFunc: func(ctx context.Context, dcbr *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) { return &cosiproto.DriverCreateBucketResponse{ BucketId: "bc-qwerty", Protocols: nil, @@ -778,8 +766,8 @@ func TestBucketReconciler_dynamicProvision(t *testing.T) { }) t.Run("valid driver, empty S3 proto response", func(t *testing.T) { - fakeServer := fakeProvisionerServer{ - createBucketFunc: func(ctx context.Context, dcbr *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) { + fakeServer := test.FakeProvisionerServer{ + CreateBucketFunc: func(ctx context.Context, dcbr *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) { return &cosiproto.DriverCreateBucketResponse{ BucketId: "bc-qwerty", Protocols: &cosiproto.ObjectProtocolAndBucketInfo{ @@ -825,8 +813,8 @@ func TestBucketReconciler_dynamicProvision(t *testing.T) { }) t.Run("valid driver, empty Azure proto response", func(t *testing.T) { - fakeServer := fakeProvisionerServer{ - createBucketFunc: func(ctx context.Context, dcbr *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) { + fakeServer := test.FakeProvisionerServer{ + CreateBucketFunc: func(ctx context.Context, dcbr *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) { return &cosiproto.DriverCreateBucketResponse{ BucketId: "bc-qwerty", Protocols: &cosiproto.ObjectProtocolAndBucketInfo{ @@ -872,8 +860,8 @@ func TestBucketReconciler_dynamicProvision(t *testing.T) { }) t.Run("valid driver, empty GCS proto response", func(t *testing.T) { - fakeServer := fakeProvisionerServer{ - createBucketFunc: func(ctx context.Context, dcbr *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) { + fakeServer := test.FakeProvisionerServer{ + CreateBucketFunc: func(ctx context.Context, dcbr *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) { return &cosiproto.DriverCreateBucketResponse{ BucketId: "bc-qwerty", Protocols: &cosiproto.ObjectProtocolAndBucketInfo{ @@ -919,8 +907,8 @@ func TestBucketReconciler_dynamicProvision(t *testing.T) { }) t.Run("valid driver, empty S3+Azure proto response", func(t *testing.T) { - fakeServer := fakeProvisionerServer{ - createBucketFunc: func(ctx context.Context, dcbr *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) { + fakeServer := test.FakeProvisionerServer{ + CreateBucketFunc: func(ctx context.Context, dcbr *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) { return &cosiproto.DriverCreateBucketResponse{ BucketId: "bc-qwerty", Protocols: &cosiproto.ObjectProtocolAndBucketInfo{ @@ -976,155 +964,6 @@ func TestBucketReconciler_dynamicProvision(t *testing.T) { }) } -func Test_parseProtocolBucketInfo(t *testing.T) { - tests := []struct { - name string // description of this test case - // Named input parameters for target function. - pbi *cosiproto.ObjectProtocolAndBucketInfo - wantProtos []cosiapi.ObjectProtocol - wantInfoVarPrefixes []string - }{ - {"no info", &cosiproto.ObjectProtocolAndBucketInfo{}, []cosiapi.ObjectProtocol{}, []string{}}, - {"s3 empty", - &cosiproto.ObjectProtocolAndBucketInfo{ - S3: &cosiproto.S3BucketInfo{}, - }, - []cosiapi.ObjectProtocol{ - cosiapi.ObjectProtocolS3, - }, - []string{ - "COSI_S3_", - }, - }, - {"s3 non-empty", - &cosiproto.ObjectProtocolAndBucketInfo{ - S3: &cosiproto.S3BucketInfo{ - BucketId: "something", - Endpoint: "cosi.corp.net", - }, - }, - []cosiapi.ObjectProtocol{ - cosiapi.ObjectProtocolS3, - }, - []string{ - "COSI_S3_", - }, - }, - {"azure empty", - &cosiproto.ObjectProtocolAndBucketInfo{ - Azure: &cosiproto.AzureBucketInfo{}, - }, - []cosiapi.ObjectProtocol{ - cosiapi.ObjectProtocolAzure, - }, - []string{ - "COSI_AZURE_", - }, - }, - {"azure non-empty", - &cosiproto.ObjectProtocolAndBucketInfo{ - Azure: &cosiproto.AzureBucketInfo{ - StorageAccount: "something", - }, - }, - []cosiapi.ObjectProtocol{ - cosiapi.ObjectProtocolAzure, - }, - []string{ - "COSI_AZURE_", - }, - }, - {"GCS empty", - &cosiproto.ObjectProtocolAndBucketInfo{ - Gcs: &cosiproto.GcsBucketInfo{}, - }, - []cosiapi.ObjectProtocol{ - cosiapi.ObjectProtocolGcs, - }, - []string{ - "COSI_GCS_", - }, - }, - {"GCS non-empty", - &cosiproto.ObjectProtocolAndBucketInfo{ - Gcs: &cosiproto.GcsBucketInfo{ - BucketName: "something", - }, - }, - []cosiapi.ObjectProtocol{ - cosiapi.ObjectProtocolGcs, - }, - []string{ - "COSI_GCS_", - }, - }, - {"s3+azure+GCS empty", - &cosiproto.ObjectProtocolAndBucketInfo{ - S3: &cosiproto.S3BucketInfo{}, - Azure: &cosiproto.AzureBucketInfo{}, - Gcs: &cosiproto.GcsBucketInfo{}, - }, - []cosiapi.ObjectProtocol{ - cosiapi.ObjectProtocolS3, - cosiapi.ObjectProtocolAzure, - cosiapi.ObjectProtocolGcs, - }, - []string{ - "COSI_S3_", - "COSI_AZURE_", - "COSI_GCS_", - }, - }, - {"s3+azure+GCS non-empty", - &cosiproto.ObjectProtocolAndBucketInfo{ - S3: &cosiproto.S3BucketInfo{ - BucketId: "something", - }, - Azure: &cosiproto.AzureBucketInfo{ - StorageAccount: "acct", - }, - Gcs: &cosiproto.GcsBucketInfo{ - BucketName: "something", - }, - }, - []cosiapi.ObjectProtocol{ - cosiapi.ObjectProtocolS3, - cosiapi.ObjectProtocolAzure, - cosiapi.ObjectProtocolGcs, - }, - []string{ - "COSI_S3_", - "COSI_AZURE_", - "COSI_GCS_", - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - protos, infoVars := parseProtocolBucketInfo(tt.pbi) - assert.Equal(t, tt.wantProtos, protos) - // If we check the exact results of details.allProtoBucketInfo, we will tie the unit - // tests to the specific implementation of bucket info translators, tested elsewhere. - // Instead, check only that prefixes match what we expect. - if len(tt.wantProtos) > 0 { - assert.NotZero(t, len(infoVars)) - } else { - assert.Zero(t, len(infoVars)) - } - for _, p := range tt.wantInfoVarPrefixes { - found := false - for k := range infoVars { - assert.True(t, strings.HasPrefix(k, "COSI_")) // all vars must be prefixed COSI_ - if strings.HasPrefix(k, p) { - found = true - } - } - assert.Truef(t, found, "prefix %q not found in %v keys", p, infoVars) - } - }) - } -} - func Test_objectProtocolListFromApiList(t *testing.T) { tests := []struct { name string // description of this test case diff --git a/sidecar/internal/reconciler/bucketaccess.go b/sidecar/internal/reconciler/bucketaccess.go index bf8b90f6..2aa37c00 100644 --- a/sidecar/internal/reconciler/bucketaccess.go +++ b/sidecar/internal/reconciler/bucketaccess.go @@ -18,46 +18,701 @@ package reconciler import ( "context" + "errors" + "fmt" + "time" + "github.com/go-logr/logr" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + corev1 "k8s.io/api/core/v1" + kerrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" - logf "sigs.k8s.io/controller-runtime/pkg/log" + ctrlutil "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + ctrlpredicate "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" - objectstoragev1alpha2 "sigs.k8s.io/container-object-storage-interface/client/apis/objectstorage/v1alpha2" + cosiapi "sigs.k8s.io/container-object-storage-interface/client/apis/objectstorage/v1alpha2" + "sigs.k8s.io/container-object-storage-interface/internal/bucketaccess" + cosierr "sigs.k8s.io/container-object-storage-interface/internal/errors" + cosipredicate "sigs.k8s.io/container-object-storage-interface/internal/predicate" + "sigs.k8s.io/container-object-storage-interface/internal/protocol" + cosiproto "sigs.k8s.io/container-object-storage-interface/proto" ) // BucketAccessReconciler reconciles a BucketAccess object type BucketAccessReconciler struct { client.Client - Scheme *runtime.Scheme + Scheme *runtime.Scheme + DriverInfo DriverInfo } -// +kubebuilder:rbac:groups=objectstorage.k8s.io,resources=bucketaccesses,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=objectstorage.k8s.io,resources=bucketaccesses,verbs=get;list;watch;update;patch // +kubebuilder:rbac:groups=objectstorage.k8s.io,resources=bucketaccesses/status,verbs=get;update;patch // +kubebuilder:rbac:groups=objectstorage.k8s.io,resources=bucketaccesses/finalizers,verbs=update // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. -// TODO(user): Modify the Reconcile function to compare the state specified by -// the BucketAccess object against the actual cluster state, and then -// perform operations to make the cluster state reflect the state specified by -// the user. -// -// For more details, check Reconcile and its Result here: -// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.21.0/pkg/reconcile func (r *BucketAccessReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - _ = logf.FromContext(ctx) + logger := ctrl.LoggerFrom(ctx, "driverName", r.DriverInfo.name) - // TODO(user): your logic here + access := &cosiapi.BucketAccess{} + if err := r.Get(ctx, req.NamespacedName, access); err != nil { + if kerrors.IsNotFound(err) { + logger.V(1).Info("not reconciling nonexistent BucketAccess") + return ctrl.Result{}, nil + } + // no resource to add status to or report an event for + logger.Error(err, "failed to get BucketAccess") + return ctrl.Result{}, err + } - return ctrl.Result{}, nil + if !bucketaccess.ManagedBySidecar(access) { + logger.V(1).Info("not reconciling BucketAccess that should be managed by controller") + return ctrl.Result{}, nil + } + + err := r.reconcile(ctx, logger, access) + if err != nil { + // Because the BucketAccess status is could be managed by either Sidecar or Controller, + // indicate that this error is coming from the Sidecar. + err = fmt.Errorf("COSI Sidecar error: %w", err) + + // Record any error as a timestamped error in the status. + if access.Status.ReadyToUse == nil { + access.Status.ReadyToUse = ptr.To(false) + } + access.Status.Error = cosiapi.NewTimestampedError(time.Now(), err.Error()) + if updErr := r.Status().Update(ctx, access); updErr != nil { + logger.Error(err, "failed to update BucketAccess status after reconcile error", "updateError", updErr) + // If status update fails, we must retry the error regardless of the reconcile return. + // The reconcile needs to run again to make sure the status is eventually be updated. + return reconcile.Result{}, err + } + + if errors.Is(err, cosierr.NonRetryableError(nil)) { + return reconcile.Result{}, reconcile.TerminalError(err) + } + return reconcile.Result{}, err + } + + // NOTE: Do not clear the error in the status on success. Success indicates 1 of 2 things: + // 1. BucketAccess was granted successfully, and error was cleared in reconcile() + // 2. BucketAccess deletion cleanup was finished, and finalization is now passed to Controller + + return reconcile.Result{}, err } // SetupWithManager sets up the controller with the Manager. func (r *BucketAccessReconciler) SetupWithManager(mgr ctrl.Manager) error { + // TODO: owns secrets, but don't reconcile secret changes made in this controller + return ctrl.NewControllerManagedBy(mgr). - For(&objectstoragev1alpha2.BucketAccess{}). - Named("bucketaccess"). + For(&cosiapi.BucketAccess{}). + WithEventFilter( + ctrlpredicate.And( + driverNameMatchesPredicate(r.DriverInfo.name), // only opt in to reconciles with matching driver name + ctrlpredicate.Or( + // when managed by sidecar, we should reconcile ALL Create/Delete/Generic events + cosipredicate.AnyCreate(), + cosipredicate.AnyDelete(), + cosipredicate.AnyGeneric(), + // opt in to desired Update events + cosipredicate.BucketAccessHandoffOccurred(r.Scheme), // reconcile any handoff change + cosipredicate.ProtectionFinalizerRemoved(r.Scheme), // re-add protection finalizer if removed + ), + ), + ). Complete(r) } + +func (r *BucketAccessReconciler) reconcile( + ctx context.Context, logger logr.Logger, access *cosiapi.BucketAccess, +) error { + if access.Status.DriverName != r.DriverInfo.name { + // keep this log to help debug any issues that might arise with predicate logic + logger.Info("not reconciling bucketaccess with non-matching driver name %q", access.Status.DriverName) + return nil + } + + if !access.GetDeletionTimestamp().IsZero() { + logger.V(1).Info("beginning BucketAccess deletion cleanup") + + // TODO: deletion logic + + ctrlutil.RemoveFinalizer(access, cosiapi.ProtectionFinalizer) + if err := r.Update(ctx, access); err != nil { + logger.Error(err, "failed to remove finalizer") + return fmt.Errorf("failed to remove finalizer: %w", err) + } + + return cosierr.NonRetryableError(fmt.Errorf("deletion is not yet implemented")) // TODO + } + + initialized, err := bucketaccess.SidecarRequirementsPresent(&access.Status) + if err != nil { + logger.Error(err, "processed a degraded BucketAccess") + return cosierr.NonRetryableError(fmt.Errorf("processed a degraded BucketAccess: %w", err)) + } + if !initialized { + // If we reach this condition, something is systemically wrong. Controller should have + // ownership, but we determined otherwise, and the Controller will likely also determine us + // to be the owner. + logger.Error(nil, "processed a BucketAccess that should be managed by COSI Controller") + return cosierr.NonRetryableError(fmt.Errorf("processed a BucketAccess that should be managed by COSI Controller")) + } + + logger.V(1).Info("reconciling BucketAccess") + + didAdd := ctrlutil.AddFinalizer(access, cosiapi.ProtectionFinalizer) + if didAdd { + if err := r.Update(ctx, access); err != nil { + logger.Error(err, "failed to add protection finalizer") + return fmt.Errorf("failed to add protection finalizer: %w", err) + } + } + + bucketsByName, err := getAndValidateAllAccessedBuckets(ctx, r.Client, access) + if err != nil { + logger.Error(err, "failed to validate accessed Buckets for BucketAccess") + return err + } + + // Ensure COSI can write to user-selected Secrets before attempting access provisioning. + // Avoids some hard-stop failures that might occur after RPC call is successful. + secretsByName, err := r.reserveAccessSecrets(ctx, access) + if err != nil { + logger.Error(err, "failed to reserve access Secrets for BucketAccess") + return err + } + + internalCfg, err := newInternalAccessConfig(access, bucketsByName, secretsByName) + if err != nil { + logger.Error(err, "failed to build internal representation of access configuration") + return fmt.Errorf("failed to build internal representation of access configuration: %w", err) + } + + resp, err := r.DriverInfo.provisionerClient.DriverGrantBucketAccess(ctx, + &cosiproto.DriverGrantBucketAccessRequest{ + AccountName: internalCfg.AccountName, + Protocol: &cosiproto.ObjectProtocol{Type: internalCfg.Protocol}, + AuthenticationType: &cosiproto.AuthenticationType{Type: internalCfg.AuthenticationType}, + ServiceAccountName: internalCfg.ServiceAccountName, + Parameters: internalCfg.Parameters, + Buckets: internalCfg.RpcAccessedBucketsList(), + }, + ) + if err != nil { + if status.Code(err) == codes.OutOfRange { + err = fmt.Errorf("driver does not support multi-bucket access: %w", err) + logger.Error(err, "DriverGrantBucketAccess error") + return cosierr.NonRetryableError(err) + } + + logger.Error(err, "DriverGrantBucketAccess error") + if rpcErrorIsRetryable(status.Code(err)) { + return err + } + return cosierr.NonRetryableError(err) + } + + validation := validationConfig{ + ExpectedProtocol: access.Spec.Protocol, + AuthenticationType: access.Status.AuthenticationType, + } + grantDetails, err := translateDriverGrantBucketAccessResponseToApi(resp, &validation) + if err != nil { + logger.Error(err, "failed processing BucketAccess RPC response") + return cosierr.NonRetryableError(err) + } + + if err := validateGrantedAccess(internalCfg, grantDetails); err != nil { + logger.Error(err, "granted BucketAccess is invalid") + return cosierr.NonRetryableError(err) + } + + if err := r.updateSecretsWithGrantedInfo(ctx, internalCfg, grantDetails); err != nil { + logger.Error(err, "failed to update BucketAccess Secret(s)") + return err + } + + access.Status.AccountID = grantDetails.AccountId + access.Status.ReadyToUse = ptr.To(true) + access.Status.Error = nil + if err := r.Status().Update(ctx, access); err != nil { + logger.Error(err, "failed to update BucketAccess status after successful access grant") + return fmt.Errorf("failed to update BucketAccess status after successful access grant: %w", err) + } + + return nil +} + +// Internal representation of access configuration. +type internalAccessConfig struct { + AccountName string + Protocol cosiproto.ObjectProtocol_Type + AuthenticationType cosiproto.AuthenticationType_Type + ServiceAccountName string + Parameters map[string]string + + AccessConfigsByBucketId map[string]bucketAccessConfig + + BucketsByName map[string]*cosiapi.Bucket + SecretsByName map[string]*corev1.Secret +} + +// Internal access configuration for a specific bucket. +type bucketAccessConfig struct { + AccessMode cosiproto.AccessMode_Mode + AccessSecretName string +} + +// Parse the access, and generate a new internal access config struct for follow-up. +func newInternalAccessConfig( + access *cosiapi.BucketAccess, + bucketsByName map[string]*cosiapi.Bucket, + secretsByName map[string]*corev1.Secret, +) (*internalAccessConfig, error) { + acctName := "ba-" + string(access.UID) // DO NOT CHANGE + + proto, err := protocol.ObjectProtocolTranslator{}.ApiToRpc(access.Spec.Protocol) + if err != nil { + return nil, cosierr.NonRetryableError(err) + } + + authType, err := authenticationTypeToRpc(access.Status.AuthenticationType) + if err != nil { + return nil, cosierr.NonRetryableError(err) + } + + // Only use ServiceAccount name in the driver RPC request if auth type is ServiceAccount + svcAcct := "" + if authType == cosiproto.AuthenticationType_SERVICE_ACCOUNT { + svcAcct = access.Spec.ServiceAccountName + } + + accessConfigsByBucketId, err := generateInternalAccessedBucketConfigs(access, bucketsByName) + if err != nil { + return nil, err + } + + d := &internalAccessConfig{ + AccountName: acctName, + Protocol: proto, + AuthenticationType: authType, + ServiceAccountName: svcAcct, + Parameters: access.Status.Parameters, + + AccessConfigsByBucketId: accessConfigsByBucketId, + BucketsByName: bucketsByName, + SecretsByName: secretsByName, + } + return d, nil +} + +// Parse the referenced BucketClaims and accessed Buckets, then cross-reference and collate the info +// into a form that makes internal operations easier. +// The implementation uses maps to simplify lookups and avoid having to search slices for entries +// repeatedly. This is less for efficiency and more for ease of coding. +func generateInternalAccessedBucketConfigs( + access *cosiapi.BucketAccess, + bucketsByName map[string]*cosiapi.Bucket, +) (accessConfigsByBucketId map[string]bucketAccessConfig, err error) { + errs := []error{} + + bucketNamesByClaimName := make(map[string]string, len(access.Status.AccessedBuckets)) + for _, ab := range access.Status.AccessedBuckets { + bucketNamesByClaimName[ab.BucketClaimName] = ab.BucketName + } + + accessConfigsByBucketId = make(map[string]bucketAccessConfig, len(access.Spec.BucketClaims)) + for _, claimRef := range access.Spec.BucketClaims { + claimName := claimRef.BucketClaimName + bucketName, ok := bucketNamesByClaimName[claimName] + if !ok { + // Should not happen as long as COSI Controller created status.accessedBuckets correctly. + errs = append(errs, fmt.Errorf("could not map BucketClaim %q to any accessed Bucket", claimName)) + continue + } + + bucket, ok := bucketsByName[bucketName] + if !ok { + // Should not happen except internal bugs in this controller. + errs = append(errs, fmt.Errorf("could not find Bucket %q by name internally", bucketName)) + continue + } + + id := bucket.Status.BucketID + if id == "" { + // Already checked for this, but double check. + //nolint:staticcheck // ST1005: okay to capitalize resource kind + errs = append(errs, fmt.Errorf("Bucket %q has no bucketID", bucketName)) + continue + } + + rpcMode, err := accessModeToRpc(claimRef.AccessMode) + if err != nil { + errs = append(errs, fmt.Errorf("failed to parse access mode for BucketClaim %q", claimRef.BucketClaimName)) + continue + } + + cfg := bucketAccessConfig{ + AccessMode: rpcMode, + AccessSecretName: claimRef.AccessSecretName, + } + + accessConfigsByBucketId[id] = cfg + } + + if len(errs) > 0 { + return nil, cosierr.NonRetryableError( // Retry won't resolve any of these issues + fmt.Errorf("failed to generate internal access configuration: %w", errors.Join(errs...))) + } + return accessConfigsByBucketId, nil +} + +// Generate bucket access configs for the RPC call. +func (d *internalAccessConfig) RpcAccessedBucketsList() []*cosiproto.DriverGrantBucketAccessRequest_AccessedBucket { + out := make([]*cosiproto.DriverGrantBucketAccessRequest_AccessedBucket, len(d.AccessConfigsByBucketId)) + + i := 0 + for id, cfg := range d.AccessConfigsByBucketId { + // Map iteration order in go is not predictable. This means the returned output order will + // differ between repeated reconciles. This is desirable because it ensures that drivers + // must not implicitly rely on element ordering. + out[i] = &cosiproto.DriverGrantBucketAccessRequest_AccessedBucket{ + BucketId: id, + AccessMode: &cosiproto.AccessMode{ + Mode: cfg.AccessMode, + }, + } + i++ + } + + return out +} + +// Internal API-domain details about a successfully-granted access. +type grantedAccessApiDetails struct { + AccountId string + SharedCredentialInfo map[string]string + BucketInfoByBucketId map[string]map[string]string +} + +// Translate an RPC grant-access response to internal API-domain details. +func translateDriverGrantBucketAccessResponseToApi( + resp *cosiproto.DriverGrantBucketAccessResponse, + validation *validationConfig, +) (*grantedAccessApiDetails, error) { + errs := []error{} + + if resp.AccountId == "" { + errs = append(errs, fmt.Errorf("missing account ID")) + } + + credInfo, err := TranslateCredentialsToApi(resp.Credentials, *validation) + if err != nil { + errs = append(errs, fmt.Errorf("shared credentials are invalid: %w", err)) + } + + bucketInfoByBucketId := map[string]map[string]string{} + for i, accessBktInfo := range resp.Buckets { + id := accessBktInfo.BucketId + if id == "" { + errs = append(errs, fmt.Errorf("missing bucket ID at index %d", i)) + continue + } + + _, info, err := TranslateBucketInfoToApi(accessBktInfo.BucketInfo, validation) + if err != nil { + errs = append(errs, fmt.Errorf("invalid bucket info at index %d: %w", i, err)) + continue + } + + bucketInfoByBucketId[id] = info + } + + if len(errs) > 0 { + return nil, fmt.Errorf("granted access response is invalid: %w", errors.Join(errs...)) + } + + d := &grantedAccessApiDetails{ + AccountId: resp.AccountId, // DO NOT ALTER RESPONSE + SharedCredentialInfo: credInfo, + BucketInfoByBucketId: bucketInfoByBucketId, + } + return d, nil +} + +// Even with a granted access response that translates successfully, there could be other errors +// related to the granted access not matching what was requested. +func validateGrantedAccess(internalCfg *internalAccessConfig, granted *grantedAccessApiDetails) error { + errs := []error{} + + for bucketId := range internalCfg.AccessConfigsByBucketId { + if _, ok := granted.BucketInfoByBucketId[bucketId]; !ok { + errs = append(errs, fmt.Errorf("granted access missing for bucket ID %q", bucketId)) + } + } + + for bucketId := range granted.BucketInfoByBucketId { + if _, ok := internalCfg.AccessConfigsByBucketId[bucketId]; !ok { + errs = append(errs, fmt.Errorf("granted access to unknown bucket with ID %q", bucketId)) + } + } + + if len(errs) > 0 { + return fmt.Errorf("granted access is invalid: %w", errors.Join(errs...)) + } + return nil +} + +// Update BucketAccess Secret(s)'s data fields with credential and bucket info. +func (r *BucketAccessReconciler) updateSecretsWithGrantedInfo( + ctx context.Context, + internalCfg *internalAccessConfig, + granted *grantedAccessApiDetails, +) error { + errs := []error{} + + for bucketId, bucketInfo := range granted.BucketInfoByBucketId { + cfg, ok := internalCfg.AccessConfigsByBucketId[bucketId] + if !ok { + // Should not happen, as checked in validateGrantedAccess() + errs = append(errs, + cosierr.NonRetryableError(fmt.Errorf("unknown bucket ID %q", bucketId))) + continue + } + + sec, ok := internalCfg.SecretsByName[cfg.AccessSecretName] + if !ok { + // Should not happen except developer error in this controller. + errs = append(errs, + cosierr.NonRetryableError(fmt.Errorf("failed internal lookup for Secret with name %q", cfg.AccessSecretName))) + continue + } + + data := map[string]string{} + mergeApiInfoIntoStringMap(granted.SharedCredentialInfo, data) + mergeApiInfoIntoStringMap(bucketInfo, data) + sec.StringData = data + + if err := r.Update(ctx, sec); err != nil { + errs = append(errs, fmt.Errorf("failed to update BucketAccess Secret %q with bucket and credential info", sec.Name)) + continue + } + } + + if len(errs) > 0 { + return fmt.Errorf("failed to update one or more BucketAccess Secrets: %w", errors.Join(errs...)) + } + return nil +} + +// Get all Buckets from BucketAccess status. Error if any Bucket Get() fails. +// Validate that all gotten buckets are ready for access. +func getAndValidateAllAccessedBuckets( + ctx context.Context, client client.Client, access *cosiapi.BucketAccess, +) (bucketsByName map[string]*cosiapi.Bucket, err error) { + errs := []error{} + bucketsByName = map[string]*cosiapi.Bucket{} + + for _, ab := range access.Status.AccessedBuckets { + nsName := types.NamespacedName{ + Namespace: "", // global resource + Name: ab.BucketName, + } + + bkt := &cosiapi.Bucket{} + err := client.Get(ctx, nsName, bkt) + if err != nil { + if kerrors.IsNotFound(err) { + errs = append(errs, cosierr.NonRetryableError(err)) + continue + } + + // other errors will likely resolve + errs = append(errs, err) + continue + } + + if err := validateBucketIsReadyForAccess(bkt); err != nil { + errs = append(errs, cosierr.NonRetryableError(err)) + continue + } + + bucketsByName[bkt.Name] = bkt + } + + if len(errs) > 0 { + outErr := fmt.Errorf("failed to get accessed Buckets: %w", errors.Join(errs...)) + return nil, outErr + } + + if len(bucketsByName) != len(access.Status.AccessedBuckets) { + // Should never happen, but double check to avoid propagating internal errors. + return nil, fmt.Errorf("did not get one or more accessed Buckets, but no errors observed") + } + + return bucketsByName, nil +} + +func validateBucketIsReadyForAccess(b *cosiapi.Bucket) error { + errs := []error{} + + if _, ok := b.Annotations[cosiapi.BucketClaimBeingDeletedAnnotation]; ok { + //nolint:staticcheck // ST1005: okay to capitalize resource kind + errs = append(errs, fmt.Errorf("BucketClaim for Bucket %q is deleting", b.Name)) + } + + if !b.DeletionTimestamp.IsZero() { + //nolint:staticcheck // ST1005: okay to capitalize resource kind + errs = append(errs, fmt.Errorf("Bucket %q is deleting", b.Name)) + } + + if b.Status.BucketID == "" { + //nolint:staticcheck // ST1005: okay to capitalize resource kind + errs = append(errs, fmt.Errorf("Bucket %q has no bucketID", b.Name)) + } + + // TODO: Controller has already verified that the Bucket supports the requested protocol. + // Do we need/want to check that again? + + if len(errs) > 0 { + return fmt.Errorf("cannot generate access for one or more Buckets: %w", errors.Join(errs...)) + } + return nil +} + +// Before access is provisioned, reserve all spec.bucketClaims access Secrets by creating new ones +// bound to the BucketAccess and/or binding existing Secrets. +func (r *BucketAccessReconciler) reserveAccessSecrets( + ctx context.Context, access *cosiapi.BucketAccess, +) (secretsByName map[string]*corev1.Secret, err error) { + errs := []error{} + secretsByName = map[string]*corev1.Secret{} + + for _, claimRef := range access.Spec.BucketClaims { + secretName := claimRef.AccessSecretName + + if _, ok := secretsByName[secretName]; ok { + errs = append(errs, cosierr.NonRetryableError( + fmt.Errorf("multiple referenced bucketClaims use the same accessSecretName %q", secretName))) + continue + } + + nsName := types.NamespacedName{ + Namespace: access.Namespace, + Name: secretName, + } + existingSecret := &corev1.Secret{} + err := r.Get(ctx, nsName, existingSecret) + if err != nil { + if !kerrors.IsNotFound(err) { + errs = append(errs, err) + continue + } + + newSecret, err := r.createNewBoundAccessSecret(ctx, r.Client, secretName, access) + if err != nil { + errs = append(errs, err) + continue + } + + secretsByName[secretName] = newSecret + continue + } + + err = r.bindExistingAccessSecret(ctx, r.Client, existingSecret, access) + if err != nil { + errs = append(errs, err) + continue + } + + secretsByName[secretName] = existingSecret + } + + if len(errs) > 0 { + return nil, fmt.Errorf("failed to reserve one or more Secrets for access info: %w", errors.Join(errs...)) + } + + if len(secretsByName) != len(access.Spec.BucketClaims) { + // Should never happen, but double check to avoid propagating internal errors. + return nil, fmt.Errorf("did not reserve one or more access Secrets, but no errors observed") + } + + return secretsByName, nil +} + +// Create a new BucketAccess access Secret without setting data fields. +func (r *BucketAccessReconciler) createNewBoundAccessSecret( + ctx context.Context, client client.Client, + secretName string, owningAccess *cosiapi.BucketAccess, +) (*corev1.Secret, error) { + s := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: owningAccess.Namespace, + Name: secretName, + }, + } + + err := r.setAccessSecretRequiredFields(s, owningAccess) + if err != nil { + return nil, err + } + + err = client.Create(ctx, s) + if err != nil { + return nil, err + } + + return s, nil +} + +// Update an existing BucketAccess access Secret without setting data fields. +func (r *BucketAccessReconciler) bindExistingAccessSecret( + ctx context.Context, client client.Client, + existingSecret *corev1.Secret, owningAccess *cosiapi.BucketAccess, +) error { + err := r.setAccessSecretRequiredFields(existingSecret, owningAccess) + if err != nil { + return err + } + + err = client.Update(ctx, existingSecret) + if err != nil { + return err + } + + return nil +} + +// Set required fields on a BucketAccess's access Secret including (but not limited to) binding the +// Secret to the owning BucketAccess. Do not set the Secret's data fields. +func (r *BucketAccessReconciler) setAccessSecretRequiredFields( + secret *corev1.Secret, owningAccess *cosiapi.BucketAccess, +) error { + ctrlutil.AddFinalizer(secret, cosiapi.ProtectionFinalizer) + + // Access Secrets are bound to a single BucketAccess by setting the BA as a controller owner + // reference on the Secret. By definition, only one controller owner reference is allowed, and + // if there is another, it's not safe for COSI to write to the Secret. + // This should allow other non-controller owner references the user might apply. + err := ctrlutil.SetControllerReference(owningAccess, secret, r.Scheme, ctrlutil.WithBlockOwnerDeletion(true)) + if err != nil { + t := &ctrlutil.AlreadyOwnedError{} + if errors.As(err, &t) { + return cosierr.NonRetryableError(err) + } + } + + // TODO: Consider using Secret type to help hint about COSI usage and the object protocol type? + secret.Type = corev1.SecretTypeOpaque + + return nil +} diff --git a/sidecar/internal/reconciler/bucketaccess_test.go b/sidecar/internal/reconciler/bucketaccess_test.go new file mode 100644 index 00000000..c9a98fda --- /dev/null +++ b/sidecar/internal/reconciler/bucketaccess_test.go @@ -0,0 +1,1179 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package reconciler + +import ( + "context" + "fmt" + "testing" + + "github.com/go-logr/logr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + ctrlutil "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + cosiapi "sigs.k8s.io/container-object-storage-interface/client/apis/objectstorage/v1alpha2" + cosiproto "sigs.k8s.io/container-object-storage-interface/proto" + "sigs.k8s.io/container-object-storage-interface/sidecar/internal/test" +) + +func TestBucketAccessReconciler_Reconcile(t *testing.T) { + // valid base access used for subtests + baseAccess := cosiapi.BucketAccess{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-access", + Namespace: "my-ns", + UID: "zxcvbn", + // no finalizer so that tests can ensure finalizer is always re-added as needed + }, + Spec: cosiapi.BucketAccessSpec{ + BucketClaims: []cosiapi.BucketClaimAccess{ + { + BucketClaimName: "readwrite-bucket", + AccessMode: cosiapi.BucketAccessModeReadWrite, + AccessSecretName: "readwrite-bucket-creds", + }, + { + BucketClaimName: "readonly-bucket", + AccessMode: cosiapi.BucketAccessModeReadOnly, + AccessSecretName: "readonly-bucket-creds", + }, + }, + BucketAccessClassName: "s3-class", + Protocol: cosiapi.ObjectProtocolS3, + ServiceAccountName: "my-app-sa", + }, + Status: cosiapi.BucketAccessStatus{ + ReadyToUse: ptr.To(false), // Controller should have set this false already + AccessedBuckets: []cosiapi.AccessedBucket{ + { + BucketName: "bc-qwerty", + BucketClaimName: "readwrite-bucket", + }, + { + BucketName: "bc-asdfgh", + BucketClaimName: "readonly-bucket", + }, + }, + DriverName: "cosi.s3.internal", + AuthenticationType: cosiapi.BucketAccessAuthenticationTypeKey, + Parameters: map[string]string{ + "maxSize": "100Gi", + "maxIops": "10", + }, + }, + } + + accessNsName := types.NamespacedName{ + Namespace: baseAccess.Namespace, + Name: baseAccess.Name, + } + + readWriteSecretNsName := types.NamespacedName{ + Namespace: baseAccess.Namespace, + Name: baseAccess.Spec.BucketClaims[0].AccessSecretName, + } + + readOnlySecretNsName := types.NamespacedName{ + Namespace: baseAccess.Namespace, + Name: baseAccess.Spec.BucketClaims[1].AccessSecretName, + } + + // first valid bucket referenced by above valid access + baseReadWriteBucket := cosiapi.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bc-qwerty", + Finalizers: []string{cosiapi.ProtectionFinalizer}, + }, + Spec: cosiapi.BucketSpec{ + DriverName: "cosi.s3.internal", + DeletionPolicy: cosiapi.BucketDeletionPolicyDelete, + BucketClaimRef: cosiapi.BucketClaimReference{ + Name: "readwrite-bucket", + Namespace: baseAccess.Namespace, + UID: "qwerty", + }, + }, + Status: cosiapi.BucketStatus{ + ReadyToUse: ptr.To(true), + BucketID: "cosi-bc-qwerty", + }, + } + + // second valid bucket referenced by above valid access + baseReadOnlyBucket := cosiapi.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bc-asdfgh", + Finalizers: []string{cosiapi.ProtectionFinalizer}, + }, + Spec: cosiapi.BucketSpec{ + DriverName: "cosi.s3.internal", + DeletionPolicy: cosiapi.BucketDeletionPolicyDelete, + BucketClaimRef: cosiapi.BucketClaimReference{ + Name: "readonly-bucket", + Namespace: baseAccess.Namespace, + UID: "asdfgh", + }, + }, + Status: cosiapi.BucketStatus{ + ReadyToUse: ptr.To(true), + BucketID: "cosi-bc-asdfgh", + }, + } + + ctx := context.Background() + nolog := logr.Discard() + scheme := runtime.NewScheme() + require.NoError(t, cosiapi.AddToScheme(scheme)) + require.NoError(t, corev1.AddToScheme(scheme)) + + newClient := func(withObj ...client.Object) client.Client { + return fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(withObj...). + WithStatusSubresource(withObj...). // assume all starting objects have status + Build() + } + + newReconciler := func(api client.Client, proto cosiproto.ProvisionerClient) BucketAccessReconciler { + return BucketAccessReconciler{ + Client: api, + Scheme: scheme, + DriverInfo: DriverInfo{ + name: "cosi.s3.internal", + supportedProtocols: []cosiproto.ObjectProtocol_Type{cosiproto.ObjectProtocol_S3}, + provisionerClient: proto, + }, + } + } + + // valid RPC response corresponding to above access and buckets + newBaseGrantResponse := func(accountName string) *cosiproto.DriverGrantBucketAccessResponse { + return &cosiproto.DriverGrantBucketAccessResponse{ + AccountId: "cosi-" + accountName, + Credentials: &cosiproto.CredentialInfo{ + S3: &cosiproto.S3CredentialInfo{ + AccessKeyId: "sharedaccesskey", + AccessSecretKey: "sharedsecretkey", + }, + }, + Buckets: []*cosiproto.DriverGrantBucketAccessResponse_BucketInfo{ + { + BucketId: "cosi-bc-qwerty", + BucketInfo: &cosiproto.ObjectProtocolAndBucketInfo{ + S3: &cosiproto.S3BucketInfo{ + BucketId: "corp-cosi-bc-qwerty", + Endpoint: "s3.corp.net", + Region: "us-east-1", + AddressingStyle: &cosiproto.S3AddressingStyle{Style: cosiproto.S3AddressingStyle_PATH}, + }, + }, + }, + { + BucketId: "cosi-bc-asdfgh", + BucketInfo: &cosiproto.ObjectProtocolAndBucketInfo{ + S3: &cosiproto.S3BucketInfo{ + BucketId: "corp-cosi-bc-asdfgh", + Endpoint: "s3.corp.net", + Region: "us-east-1", + AddressingStyle: &cosiproto.S3AddressingStyle{Style: cosiproto.S3AddressingStyle_PATH}, + }, + }, + }, + }, + } + } + + t.Run("happy path", func(t *testing.T) { + seenReq := []*cosiproto.DriverGrantBucketAccessRequest{} + var requestError error + fakeServer := test.FakeProvisionerServer{ + GrantBucketAccessFunc: func(ctx context.Context, dgbar *cosiproto.DriverGrantBucketAccessRequest) (*cosiproto.DriverGrantBucketAccessResponse, error) { + seenReq = append(seenReq, dgbar) + ret := newBaseGrantResponse(dgbar.AccountName) + return ret, requestError + }, + } + + cleanup, serve, tmpSock, err := test.Server(nil, &fakeServer) + defer cleanup() + require.NoError(t, err) + go serve() + + conn, err := test.ClientConn(tmpSock) + require.NoError(t, err) + rpcClient := cosiproto.NewProvisionerClient(conn) + + c := newClient( + baseAccess.DeepCopy(), + baseReadWriteBucket.DeepCopy(), + baseReadOnlyBucket.DeepCopy(), + ) + + r := newReconciler(c, rpcClient) + nctx := logr.NewContext(ctx, nolog) + + res, err := r.Reconcile(nctx, ctrl.Request{NamespacedName: accessNsName}) + assert.NoError(t, err) + assert.Empty(t, res) + + // ensure the expected RPC call was made + require.Len(t, seenReq, 1) + req := seenReq[0] + assert.Equal(t, "ba-zxcvbn", req.AccountName) + assert.Equal(t, cosiproto.AuthenticationType_KEY, req.AuthenticationType.Type) + assert.Equal(t, cosiproto.ObjectProtocol_S3, req.Protocol.Type) + assert.Equal(t, "", req.ServiceAccountName) + assert.Equal(t, + map[string]string{ + "maxSize": "100Gi", + "maxIops": "10", + }, + req.Parameters, + ) + require.Len(t, req.Buckets, 2) // by RPC spec, order of requested accessed buckets is random + assert.True(t, accessedBucketRequestExists(req.Buckets, &cosiproto.DriverGrantBucketAccessRequest_AccessedBucket{ + BucketId: "cosi-bc-qwerty", + AccessMode: &cosiproto.AccessMode{Mode: cosiproto.AccessMode_READ_WRITE}, + })) + assert.True(t, accessedBucketRequestExists(req.Buckets, &cosiproto.DriverGrantBucketAccessRequest_AccessedBucket{ + BucketId: "cosi-bc-asdfgh", + AccessMode: &cosiproto.AccessMode{Mode: cosiproto.AccessMode_READ_ONLY}, + })) + + access := &cosiapi.BucketAccess{} + require.NoError(t, c.Get(ctx, accessNsName, access)) + assert.Contains(t, access.GetFinalizers(), cosiapi.ProtectionFinalizer) + assert.Equal(t, baseAccess.Spec, access.Spec) // spec should not change + assert.True(t, *access.Status.ReadyToUse) + assert.Nil(t, access.Status.Error) + assert.Equal(t, "cosi-ba-zxcvbn", access.Status.AccountID) + assert.Equal(t, baseAccess.Status.AccessedBuckets, access.Status.AccessedBuckets) + assert.Equal(t, baseAccess.Status.AuthenticationType, access.Status.AuthenticationType) + assert.Equal(t, baseAccess.Status.DriverName, access.Status.DriverName) + assert.Equal(t, baseAccess.Status.Parameters, access.Status.Parameters) + + // ensure secrets are present with info + rws := &corev1.Secret{} + require.NoError(t, c.Get(ctx, readWriteSecretNsName, rws)) + + ros := &corev1.Secret{} + require.NoError(t, c.Get(ctx, readOnlySecretNsName, ros)) + + for _, s := range []*corev1.Secret{rws, ros} { + assert.Contains(t, s.GetFinalizers(), cosiapi.ProtectionFinalizer) + require.Len(t, s.OwnerReferences, 1) + assert.Equal(t, "zxcvbn", string(s.OwnerReferences[0].UID)) + assert.Equal(t, "sharedaccesskey", s.StringData[string(cosiapi.CredentialVar_S3_AccessKeyId)]) + } + assert.Equal(t, "corp-cosi-bc-qwerty", rws.StringData[string(cosiapi.BucketInfoVar_S3_BucketId)]) + assert.Equal(t, "corp-cosi-bc-asdfgh", ros.StringData[string(cosiapi.BucketInfoVar_S3_BucketId)]) + + t.Log("run Reconcile() a second time to ensure nothing is modified") + + seenReq = []*cosiproto.DriverGrantBucketAccessRequest{} // empty the seen rpc requests + requestError = nil + + res, err = r.Reconcile(ctx, ctrl.Request{NamespacedName: accessNsName}) + assert.NoError(t, err) + assert.Empty(t, res) + + // same RPC call is made + require.Len(t, seenReq, 1) + assert.Equal(t, "ba-zxcvbn", seenReq[0].AccountName) + + // access doesn't change + secondAccess := &cosiapi.BucketAccess{} + require.NoError(t, c.Get(ctx, accessNsName, secondAccess)) + assert.Equal(t, access.Finalizers, secondAccess.Finalizers) + assert.Equal(t, access.Spec, secondAccess.Spec) + assert.Equal(t, access.Status, secondAccess.Status) + + // secrets don't change + secondRws := &corev1.Secret{} + require.NoError(t, c.Get(ctx, readWriteSecretNsName, secondRws)) + secondRos := &corev1.Secret{} + require.NoError(t, c.Get(ctx, readOnlySecretNsName, secondRos)) + assert.Equal(t, rws.StringData, secondRws.StringData) + assert.Equal(t, ros.StringData, secondRos.StringData) + + t.Log("run Reconcile() that fails a third time to ensure status error") + + seenReq = []*cosiproto.DriverGrantBucketAccessRequest{} // empty the seen rpc requests + requestError = fmt.Errorf("fake rpc error") + + res, err = r.Reconcile(ctx, ctrl.Request{NamespacedName: accessNsName}) + assert.Error(t, err) + assert.NotErrorIs(t, err, reconcile.TerminalError(nil)) + assert.Empty(t, res) + + // same RPC call is made + require.Len(t, seenReq, 1) + assert.Equal(t, "ba-zxcvbn", seenReq[0].AccountName) + + // access has error but otherwise doesn't change + thirdAccess := &cosiapi.BucketAccess{} + require.NoError(t, c.Get(ctx, accessNsName, thirdAccess)) + assert.Equal(t, access.Finalizers, thirdAccess.Finalizers) + assert.Equal(t, access.Spec, thirdAccess.Spec) + require.NotNil(t, thirdAccess.Status.Error) + assert.NotNil(t, thirdAccess.Status.Error.Time) + assert.NotNil(t, thirdAccess.Status.Error.Message) + assert.Contains(t, *thirdAccess.Status.Error.Message, "fake rpc error") + { // non-error fields stay the same + thirdNoError := thirdAccess.DeepCopy() + thirdNoError.Status.Error = nil + assert.Equal(t, access.Status, thirdNoError.Status) + } + + // secrets don't change + thirdRws := &corev1.Secret{} + require.NoError(t, c.Get(ctx, readWriteSecretNsName, thirdRws)) + thirdRos := &corev1.Secret{} + require.NoError(t, c.Get(ctx, readOnlySecretNsName, thirdRos)) + assert.Equal(t, rws.StringData, thirdRws.StringData) + assert.Equal(t, ros.StringData, thirdRos.StringData) + + t.Log("run Reconcile() that passes a fourth time with rotated creds") + + fakeServer.GrantBucketAccessFunc = func(ctx context.Context, dgbar *cosiproto.DriverGrantBucketAccessRequest) (*cosiproto.DriverGrantBucketAccessResponse, error) { + // RPC checking here would be redundant + ret := newBaseGrantResponse(dgbar.AccountName) + ret.Credentials.S3.AccessKeyId = "rotatedsharedaccesskey" + return ret, nil + } + + res, err = r.Reconcile(ctx, ctrl.Request{NamespacedName: accessNsName}) + assert.NoError(t, err) + assert.Empty(t, res) + + fourthAccess := &cosiapi.BucketAccess{} + require.NoError(t, c.Get(ctx, accessNsName, fourthAccess)) + assert.Equal(t, access.Finalizers, fourthAccess.Finalizers) + assert.Equal(t, access.Spec, fourthAccess.Spec) + assert.Equal(t, access.Status, fourthAccess.Status) // error is cleared + + // secrets change their access key ID only + fourthRws := &corev1.Secret{} + require.NoError(t, c.Get(ctx, readWriteSecretNsName, fourthRws)) + fourthRos := &corev1.Secret{} + require.NoError(t, c.Get(ctx, readOnlySecretNsName, fourthRos)) + assert.Equal(t, "rotatedsharedaccesskey", fourthRws.StringData[string(cosiapi.CredentialVar_S3_AccessKeyId)]) + assert.Equal(t, "rotatedsharedaccesskey", fourthRos.StringData[string(cosiapi.CredentialVar_S3_AccessKeyId)]) + { // other secret data stays the same + rwsCopy := fourthRws.DeepCopy() + rosCopy := fourthRos.DeepCopy() + rwsCopy.StringData[string(cosiapi.CredentialVar_S3_AccessKeyId)] = "sharedaccesskey" + rosCopy.StringData[string(cosiapi.CredentialVar_S3_AccessKeyId)] = "sharedaccesskey" + assert.Equal(t, rws.StringData, rwsCopy.StringData) + assert.Equal(t, ros.StringData, rosCopy.StringData) + } + }) + + t.Run("secret already exists with incompatible owner", func(t *testing.T) { + fakeServer := test.FakeProvisionerServer{ + GrantBucketAccessFunc: func(ctx context.Context, dgbar *cosiproto.DriverGrantBucketAccessRequest) (*cosiproto.DriverGrantBucketAccessResponse, error) { + panic("should not be called") + }, + } + + cleanup, serve, tmpSock, err := test.Server(nil, &fakeServer) + defer cleanup() + require.NoError(t, err) + go serve() + + conn, err := test.ClientConn(tmpSock) + require.NoError(t, err) + rpcClient := cosiproto.NewProvisionerClient(conn) + + preExistingSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "readwrite-bucket-creds", + Namespace: baseAccess.Namespace, + }, + StringData: map[string]string{ + "PRE_EXISTING_DATA": "important_thing", + }, + } + require.NoError(t, + ctrlutil.SetControllerReference( + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "secret-operator", + Namespace: baseAccess.Namespace, + UID: "poiuyt", + }, + }, + preExistingSecret, + scheme, + ctrlutil.WithBlockOwnerDeletion(true), + ), + ) + + c := newClient( + baseAccess.DeepCopy(), + baseReadWriteBucket.DeepCopy(), + baseReadOnlyBucket.DeepCopy(), + preExistingSecret, + ) + + r := newReconciler(c, rpcClient) + nctx := logr.NewContext(ctx, nolog) + + res, err := r.Reconcile(nctx, ctrl.Request{NamespacedName: accessNsName}) + assert.Error(t, err) + assert.ErrorIs(t, err, reconcile.TerminalError(nil)) + assert.Empty(t, res) + + access := &cosiapi.BucketAccess{} + require.NoError(t, c.Get(ctx, accessNsName, access)) + assert.Equal(t, access.Finalizers, access.Finalizers) + assert.Equal(t, access.Spec, access.Spec) + require.NotNil(t, access.Status.Error) + assert.NotNil(t, access.Status.Error.Time) + assert.NotNil(t, access.Status.Error.Message) + assert.Contains(t, *access.Status.Error.Message, "failed to reserve one or more Secrets") + { // non-error fields stay the same + accessNoError := access.DeepCopy() + accessNoError.Status.Error = nil + assert.Equal(t, baseAccess.Status, accessNoError.Status) + } + + // pre-existing secret that was already owned hasn't been touched + rws := &corev1.Secret{} + require.NoError(t, c.Get(ctx, readWriteSecretNsName, rws)) + assert.Equal(t, preExistingSecret, rws) + + // other secret has been reserved successfully + ros := &corev1.Secret{} + require.NoError(t, c.Get(ctx, readOnlySecretNsName, ros)) + assert.Contains(t, ros.GetFinalizers(), cosiapi.ProtectionFinalizer) + require.Len(t, ros.OwnerReferences, 1) + assert.Equal(t, "zxcvbn", string(ros.OwnerReferences[0].UID)) + assert.Len(t, ros.StringData, 0) + }) + + t.Run("repeated secret name in spec.bucketClaims", func(t *testing.T) { + fakeServer := test.FakeProvisionerServer{ + GrantBucketAccessFunc: func(ctx context.Context, dgbar *cosiproto.DriverGrantBucketAccessRequest) (*cosiproto.DriverGrantBucketAccessResponse, error) { + panic("should not be called") + }, + } + + cleanup, serve, tmpSock, err := test.Server(nil, &fakeServer) + defer cleanup() + require.NoError(t, err) + go serve() + + conn, err := test.ClientConn(tmpSock) + require.NoError(t, err) + rpcClient := cosiproto.NewProvisionerClient(conn) + + accessWithRepeatedSecret := baseAccess.DeepCopy() + accessWithRepeatedSecret.Spec.BucketClaims[0].AccessSecretName = readWriteSecretNsName.Name + accessWithRepeatedSecret.Spec.BucketClaims[1].AccessSecretName = readWriteSecretNsName.Name + + c := newClient( + accessWithRepeatedSecret, + baseReadWriteBucket.DeepCopy(), + baseReadOnlyBucket.DeepCopy(), + ) + + r := newReconciler(c, rpcClient) + nctx := logr.NewContext(ctx, nolog) + + res, err := r.Reconcile(nctx, ctrl.Request{NamespacedName: accessNsName}) + assert.Error(t, err) + assert.ErrorIs(t, err, reconcile.TerminalError(nil)) + assert.Empty(t, res) + + access := &cosiapi.BucketAccess{} + require.NoError(t, c.Get(ctx, accessNsName, access)) + assert.Contains(t, access.GetFinalizers(), cosiapi.ProtectionFinalizer) + assert.Equal(t, accessWithRepeatedSecret.Spec, access.Spec) + require.NotNil(t, access.Status.Error) + assert.NotNil(t, access.Status.Error.Time) + assert.NotNil(t, access.Status.Error.Message) + assert.Contains(t, *access.Status.Error.Message, "same accessSecretName") + assert.Contains(t, *access.Status.Error.Message, readWriteSecretNsName.Name) + { // non-error fields stay the same + accessNoError := access.DeepCopy() + accessNoError.Status.Error = nil + assert.Equal(t, accessWithRepeatedSecret.Status, accessNoError.Status) + } + + // first secret has been reserved successfully + rws := &corev1.Secret{} + require.NoError(t, c.Get(ctx, readWriteSecretNsName, rws)) + assert.Contains(t, rws.GetFinalizers(), cosiapi.ProtectionFinalizer) + require.Len(t, rws.OwnerReferences, 1) + assert.Equal(t, "zxcvbn", string(rws.OwnerReferences[0].UID)) + assert.Len(t, rws.StringData, 0) + }) + + t.Run("status.accessedBuckets doesn't match spec.bucketClaims", func(t *testing.T) { + fakeServer := test.FakeProvisionerServer{ + GrantBucketAccessFunc: func(ctx context.Context, dgbar *cosiproto.DriverGrantBucketAccessRequest) (*cosiproto.DriverGrantBucketAccessResponse, error) { + panic("should not be called") + }, + } + + cleanup, serve, tmpSock, err := test.Server(nil, &fakeServer) + defer cleanup() + require.NoError(t, err) + go serve() + + conn, err := test.ClientConn(tmpSock) + require.NoError(t, err) + rpcClient := cosiproto.NewProvisionerClient(conn) + + malformedAccess := baseAccess.DeepCopy() + malformedAccess.Spec.BucketClaims[0].BucketClaimName = "something-different" + + c := newClient( + malformedAccess, + baseReadWriteBucket.DeepCopy(), + baseReadOnlyBucket.DeepCopy(), + ) + + r := newReconciler(c, rpcClient) + nctx := logr.NewContext(ctx, nolog) + + res, err := r.Reconcile(nctx, ctrl.Request{NamespacedName: accessNsName}) + assert.Error(t, err) + assert.ErrorIs(t, err, reconcile.TerminalError(nil)) + assert.Empty(t, res) + + access := &cosiapi.BucketAccess{} + require.NoError(t, c.Get(ctx, accessNsName, access)) + assert.Contains(t, access.GetFinalizers(), cosiapi.ProtectionFinalizer) + assert.Equal(t, malformedAccess.Spec, access.Spec) + require.NotNil(t, access.Status.Error) + assert.NotNil(t, access.Status.Error.Time) + assert.NotNil(t, access.Status.Error.Message) + assert.Contains(t, *access.Status.Error.Message, "something-different") + { // non-error fields stay the same + accessNoError := access.DeepCopy() + accessNoError.Status.Error = nil + assert.Equal(t, malformedAccess.Status, accessNoError.Status) + } + + // don't care if secrets exist or not + }) + + t.Run("a bucket has deleting annotation", func(t *testing.T) { + fakeServer := test.FakeProvisionerServer{ + GrantBucketAccessFunc: func(ctx context.Context, dgbar *cosiproto.DriverGrantBucketAccessRequest) (*cosiproto.DriverGrantBucketAccessResponse, error) { + panic("should not be called") + }, + } + + cleanup, serve, tmpSock, err := test.Server(nil, &fakeServer) + defer cleanup() + require.NoError(t, err) + go serve() + + conn, err := test.ClientConn(tmpSock) + require.NoError(t, err) + rpcClient := cosiproto.NewProvisionerClient(conn) + + deletingBucket := baseReadWriteBucket.DeepCopy() + deletingBucket.Annotations = map[string]string{ + cosiapi.BucketClaimBeingDeletedAnnotation: "", + } + + c := newClient( + baseAccess.DeepCopy(), + deletingBucket, + baseReadOnlyBucket.DeepCopy(), + ) + + r := newReconciler(c, rpcClient) + nctx := logr.NewContext(ctx, nolog) + + res, err := r.Reconcile(nctx, ctrl.Request{NamespacedName: accessNsName}) + assert.Error(t, err) + assert.ErrorIs(t, err, reconcile.TerminalError(nil)) + assert.Empty(t, res) + + access := &cosiapi.BucketAccess{} + require.NoError(t, c.Get(ctx, accessNsName, access)) + assert.Contains(t, access.GetFinalizers(), cosiapi.ProtectionFinalizer) + assert.Equal(t, baseAccess.Spec, access.Spec) + require.NotNil(t, access.Status.Error) + assert.NotNil(t, access.Status.Error.Time) + assert.NotNil(t, access.Status.Error.Message) + assert.Contains(t, *access.Status.Error.Message, deletingBucket.Name) + { // non-error fields stay the same + accessNoError := access.DeepCopy() + accessNoError.Status.Error = nil + assert.Equal(t, baseAccess.Status, accessNoError.Status) + } + + // don't care if secrets exist or not + }) + + t.Run("a bucket does not exist", func(t *testing.T) { + fakeServer := test.FakeProvisionerServer{ + GrantBucketAccessFunc: func(ctx context.Context, dgbar *cosiproto.DriverGrantBucketAccessRequest) (*cosiproto.DriverGrantBucketAccessResponse, error) { + panic("should not be called") + }, + } + + cleanup, serve, tmpSock, err := test.Server(nil, &fakeServer) + defer cleanup() + require.NoError(t, err) + go serve() + + conn, err := test.ClientConn(tmpSock) + require.NoError(t, err) + rpcClient := cosiproto.NewProvisionerClient(conn) + + deletingBucket := baseReadWriteBucket.DeepCopy() + deletingBucket.Annotations = map[string]string{ + cosiapi.BucketClaimBeingDeletedAnnotation: "", + } + + c := newClient( + baseAccess.DeepCopy(), + baseReadWriteBucket.DeepCopy(), + // baseReadOnlyBucket does not exist + ) + + r := newReconciler(c, rpcClient) + nctx := logr.NewContext(ctx, nolog) + + res, err := r.Reconcile(nctx, ctrl.Request{NamespacedName: accessNsName}) + assert.Error(t, err) + assert.ErrorIs(t, err, reconcile.TerminalError(nil)) + assert.Empty(t, res) + + access := &cosiapi.BucketAccess{} + require.NoError(t, c.Get(ctx, accessNsName, access)) + assert.Contains(t, access.GetFinalizers(), cosiapi.ProtectionFinalizer) + assert.Equal(t, baseAccess.Spec, access.Spec) + require.NotNil(t, access.Status.Error) + assert.NotNil(t, access.Status.Error.Time) + assert.NotNil(t, access.Status.Error.Message) + assert.Contains(t, *access.Status.Error.Message, baseReadOnlyBucket.Name) + { // non-error fields stay the same + accessNoError := access.DeepCopy() + accessNoError.Status.Error = nil + assert.Equal(t, baseAccess.Status, accessNoError.Status) + } + + // don't care if secrets exist or not + }) + + // driver name mismatch + t.Run("driver name mismatch", func(t *testing.T) { + fakeServer := test.FakeProvisionerServer{ + GrantBucketAccessFunc: func(ctx context.Context, dgbar *cosiproto.DriverGrantBucketAccessRequest) (*cosiproto.DriverGrantBucketAccessResponse, error) { + panic("should not be called") + }, + } + + cleanup, serve, tmpSock, err := test.Server(nil, &fakeServer) + defer cleanup() + require.NoError(t, err) + go serve() + + conn, err := test.ClientConn(tmpSock) + require.NoError(t, err) + rpcClient := cosiproto.NewProvisionerClient(conn) + + c := newClient( + baseAccess.DeepCopy(), + baseReadWriteBucket.DeepCopy(), + baseReadOnlyBucket.DeepCopy(), + ) + + r := newReconciler(c, rpcClient) + r.DriverInfo.name = "wrong.name" + nctx := logr.NewContext(ctx, nolog) + + res, err := r.Reconcile(nctx, ctrl.Request{NamespacedName: accessNsName}) + assert.NoError(t, err) + assert.Empty(t, res) + + access := &cosiapi.BucketAccess{} + require.NoError(t, c.Get(ctx, accessNsName, access)) + assert.NotContains(t, access.GetFinalizers(), cosiapi.ProtectionFinalizer) + assert.Equal(t, baseAccess.Spec, access.Spec) + assert.Equal(t, baseAccess.Status, access.Status) + + // don't care if secrets exist or not + }) + + t.Run("rpc return mistakes", func(t *testing.T) { + + type rpcReturnMistakeTest struct { + testName string + rpcReturn *cosiproto.DriverGrantBucketAccessResponse + } + tests := []rpcReturnMistakeTest{ + { + "account id missing", + func() *cosiproto.DriverGrantBucketAccessResponse { + ret := newBaseGrantResponse("ba-" + string(baseAccess.UID)) + ret.AccountId = "" + return ret + }(), + }, + { + "credentials nil", + func() *cosiproto.DriverGrantBucketAccessResponse { + ret := newBaseGrantResponse("ba-" + string(baseAccess.UID)) + ret.Credentials = nil + return ret + }(), + }, + { + "credentials expected proto nil", + func() *cosiproto.DriverGrantBucketAccessResponse { + ret := newBaseGrantResponse("ba-" + string(baseAccess.UID)) + ret.Credentials.S3 = nil + return ret + }(), + }, + { + "credentials add wrong proto", + func() *cosiproto.DriverGrantBucketAccessResponse { + ret := newBaseGrantResponse("ba-" + string(baseAccess.UID)) + ret.Credentials.Gcs = &cosiproto.GcsCredentialInfo{} + return ret + }(), + }, + { + "credentials invalid", + func() *cosiproto.DriverGrantBucketAccessResponse { + ret := newBaseGrantResponse("ba-" + string(baseAccess.UID)) + ret.Credentials.S3.AccessKeyId = "" // S3 requires access key ID for Key auth + return ret + }(), + }, + { + "bucket info response nil", + func() *cosiproto.DriverGrantBucketAccessResponse { + ret := newBaseGrantResponse("ba-" + string(baseAccess.UID)) + ret.Buckets = nil + return ret + }(), + }, + { + "bucket info response empty", + func() *cosiproto.DriverGrantBucketAccessResponse { + ret := newBaseGrantResponse("ba-" + string(baseAccess.UID)) + ret.Buckets = []*cosiproto.DriverGrantBucketAccessResponse_BucketInfo{} + return ret + }(), + }, + { + "a bucket info response is missing", + func() *cosiproto.DriverGrantBucketAccessResponse { + ret := newBaseGrantResponse("ba-" + string(baseAccess.UID)) + ret.Buckets = ret.Buckets[0:1] + return ret + }(), + }, + { + "extra bucket info response", + func() *cosiproto.DriverGrantBucketAccessResponse { + ret := newBaseGrantResponse("ba-" + string(baseAccess.UID)) + ret.Buckets = append(ret.Buckets, &cosiproto.DriverGrantBucketAccessResponse_BucketInfo{ + BucketId: "something", + BucketInfo: &cosiproto.ObjectProtocolAndBucketInfo{ + S3: &cosiproto.S3BucketInfo{ + BucketId: "something", + Endpoint: "something", + Region: "something", + AddressingStyle: &cosiproto.S3AddressingStyle{Style: cosiproto.S3AddressingStyle_PATH}, + }, + }, + }) + return ret + }(), + }, + { + "a bucket id missing", + func() *cosiproto.DriverGrantBucketAccessResponse { + ret := newBaseGrantResponse("ba-" + string(baseAccess.UID)) + ret.Buckets[0].BucketId = "" + return ret + }(), + }, + { + "a bucket info nil", + func() *cosiproto.DriverGrantBucketAccessResponse { + ret := newBaseGrantResponse("ba-" + string(baseAccess.UID)) + ret.Buckets[0].BucketInfo = nil + return ret + }(), + }, + { + "a bucket info adds wrong proto", + func() *cosiproto.DriverGrantBucketAccessResponse { + ret := newBaseGrantResponse("ba-" + string(baseAccess.UID)) + ret.Buckets[0].BucketInfo.Azure = &cosiproto.AzureBucketInfo{} + return ret + }(), + }, + { + "a bucket info expected proto nil", + func() *cosiproto.DriverGrantBucketAccessResponse { + ret := newBaseGrantResponse("ba-" + string(baseAccess.UID)) + ret.Buckets[0].BucketInfo.S3 = nil + return ret + }(), + }, + { + "a bucket info proto invalid", + func() *cosiproto.DriverGrantBucketAccessResponse { + ret := newBaseGrantResponse("ba-" + string(baseAccess.UID)) + ret.Buckets[0].BucketInfo.S3.Endpoint = "" // S3 requires endpoint to be set + return ret + }(), + }, + { + "a bucket info unknown bucket id", + func() *cosiproto.DriverGrantBucketAccessResponse { + ret := newBaseGrantResponse("ba-" + string(baseAccess.UID)) + ret.Buckets[0].BucketId = "something-random" + return ret + }(), + }, + } + + var requestReturn *cosiproto.DriverGrantBucketAccessResponse + fakeServer := test.FakeProvisionerServer{ + GrantBucketAccessFunc: func(ctx context.Context, dgbar *cosiproto.DriverGrantBucketAccessRequest) (*cosiproto.DriverGrantBucketAccessResponse, error) { + return requestReturn, nil + }, + } + + cleanup, serve, tmpSock, err := test.Server(nil, &fakeServer) + defer cleanup() + require.NoError(t, err) + go serve() + + conn, err := test.ClientConn(tmpSock) + require.NoError(t, err) + rpcClient := cosiproto.NewProvisionerClient(conn) + + for _, tt := range tests { + t.Run(tt.testName, func(t *testing.T) { + requestReturn = tt.rpcReturn + + c := newClient( + baseAccess.DeepCopy(), + baseReadWriteBucket.DeepCopy(), + baseReadOnlyBucket.DeepCopy(), + ) + + r := newReconciler(c, rpcClient) + nctx := logr.NewContext(ctx, nolog) + + res, err := r.Reconcile(nctx, ctrl.Request{NamespacedName: accessNsName}) + assert.Error(t, err) + assert.ErrorIs(t, err, reconcile.TerminalError(nil)) + assert.Empty(t, res) + + access := &cosiapi.BucketAccess{} + require.NoError(t, c.Get(ctx, accessNsName, access)) + assert.Contains(t, access.GetFinalizers(), cosiapi.ProtectionFinalizer) + assert.Equal(t, baseAccess.Spec, access.Spec) + require.NotNil(t, access.Status.Error) + assert.NotNil(t, access.Status.Error.Time) + assert.NotNil(t, access.Status.Error.Message) + t.Log("error message:", *access.Status.Error.Message) + assert.Contains(t, *access.Status.Error.Message, "granted access") + assert.Contains(t, *access.Status.Error.Message, "invalid") + { // non-error fields stay the same + accessNoError := access.DeepCopy() + accessNoError.Status.Error = nil + assert.Equal(t, baseAccess.Status, accessNoError.Status) + } + + // secrets should have been created to claim them, but not updated with data + rws := &corev1.Secret{} + require.NoError(t, c.Get(ctx, readWriteSecretNsName, rws)) + ros := &corev1.Secret{} + require.NoError(t, c.Get(ctx, readOnlySecretNsName, ros)) + for _, s := range []*corev1.Secret{rws, ros} { + assert.Contains(t, s.GetFinalizers(), cosiapi.ProtectionFinalizer) + require.Len(t, s.OwnerReferences, 1) + assert.Len(t, s.StringData, 0) + } + }) + } + }) + + t.Run("azure protocol and serviceaccount auth", func(t *testing.T) { + seenReq := []*cosiproto.DriverGrantBucketAccessRequest{} + var requestError error + fakeServer := test.FakeProvisionerServer{ + GrantBucketAccessFunc: func(ctx context.Context, dgbar *cosiproto.DriverGrantBucketAccessRequest) (*cosiproto.DriverGrantBucketAccessResponse, error) { + seenReq = append(seenReq, dgbar) + ret := &cosiproto.DriverGrantBucketAccessResponse{ + AccountId: "cosi-" + dgbar.AccountName, + Credentials: &cosiproto.CredentialInfo{ + Azure: &cosiproto.AzureCredentialInfo{ + // empty for ServiceAccount auth + }, + }, + Buckets: []*cosiproto.DriverGrantBucketAccessResponse_BucketInfo{ + { + BucketId: "cosi-bc-qwerty", + BucketInfo: &cosiproto.ObjectProtocolAndBucketInfo{ + Azure: &cosiproto.AzureBucketInfo{ + StorageAccount: "outputaccount", + }, + }, + }, + { + BucketId: "cosi-bc-asdfgh", + BucketInfo: &cosiproto.ObjectProtocolAndBucketInfo{ + Azure: &cosiproto.AzureBucketInfo{ + StorageAccount: "inputaccount", + }, + }, + }, + }, + } + return ret, requestError + }, + } + + cleanup, serve, tmpSock, err := test.Server(nil, &fakeServer) + defer cleanup() + require.NoError(t, err) + go serve() + + conn, err := test.ClientConn(tmpSock) + require.NoError(t, err) + rpcClient := cosiproto.NewProvisionerClient(conn) + + azureAccess := baseAccess.DeepCopy() + azureAccess.Spec.Protocol = cosiapi.ObjectProtocolAzure + azureAccess.Spec.ServiceAccountName = "azure-sa" + azureAccess.Status.AuthenticationType = cosiapi.BucketAccessAuthenticationTypeServiceAccount + azureAccess.Status.Parameters = map[string]string{} + + c := newClient( + azureAccess, + baseReadWriteBucket.DeepCopy(), + baseReadOnlyBucket.DeepCopy(), + ) + + r := newReconciler(c, rpcClient) + r.DriverInfo.supportedProtocols = []cosiproto.ObjectProtocol_Type{cosiproto.ObjectProtocol_AZURE} + nctx := logr.NewContext(ctx, nolog) + + res, err := r.Reconcile(nctx, ctrl.Request{NamespacedName: accessNsName}) + assert.NoError(t, err) + assert.Empty(t, res) + + // ensure the expected RPC call was made + require.Len(t, seenReq, 1) + req := seenReq[0] + assert.Equal(t, "ba-zxcvbn", req.AccountName) + assert.Equal(t, cosiproto.AuthenticationType_SERVICE_ACCOUNT, req.AuthenticationType.Type) + assert.Equal(t, cosiproto.ObjectProtocol_AZURE, req.Protocol.Type) + assert.Equal(t, "azure-sa", req.ServiceAccountName) + assert.Empty(t, req.Parameters) + require.Len(t, req.Buckets, 2) // by RPC spec, order of requested accessed buckets is random + assert.True(t, accessedBucketRequestExists(req.Buckets, &cosiproto.DriverGrantBucketAccessRequest_AccessedBucket{ + BucketId: "cosi-bc-qwerty", + AccessMode: &cosiproto.AccessMode{Mode: cosiproto.AccessMode_READ_WRITE}, + })) + assert.True(t, accessedBucketRequestExists(req.Buckets, &cosiproto.DriverGrantBucketAccessRequest_AccessedBucket{ + BucketId: "cosi-bc-asdfgh", + AccessMode: &cosiproto.AccessMode{Mode: cosiproto.AccessMode_READ_ONLY}, + })) + + access := &cosiapi.BucketAccess{} + require.NoError(t, c.Get(ctx, accessNsName, access)) + assert.Contains(t, access.GetFinalizers(), cosiapi.ProtectionFinalizer) + assert.Equal(t, azureAccess.Spec, access.Spec) // spec should not change + assert.True(t, *access.Status.ReadyToUse) + assert.Nil(t, access.Status.Error) + assert.Equal(t, "cosi-ba-zxcvbn", access.Status.AccountID) + assert.Equal(t, azureAccess.Status.AccessedBuckets, access.Status.AccessedBuckets) + assert.Equal(t, azureAccess.Status.AuthenticationType, access.Status.AuthenticationType) + assert.Equal(t, azureAccess.Status.DriverName, access.Status.DriverName) + assert.Empty(t, access.Status.Parameters) + + // ensure secrets are present with info + rws := &corev1.Secret{} + require.NoError(t, c.Get(ctx, readWriteSecretNsName, rws)) + + ros := &corev1.Secret{} + require.NoError(t, c.Get(ctx, readOnlySecretNsName, ros)) + + for _, s := range []*corev1.Secret{rws, ros} { + assert.Contains(t, s.GetFinalizers(), cosiapi.ProtectionFinalizer) + require.Len(t, s.OwnerReferences, 1) + assert.Equal(t, "zxcvbn", string(s.OwnerReferences[0].UID)) + } + assert.Equal(t, "outputaccount", rws.StringData[string(cosiapi.BucketInfoVar_Azure_StorageAccount)]) + assert.Equal(t, "inputaccount", ros.StringData[string(cosiapi.BucketInfoVar_Azure_StorageAccount)]) + }) + + t.Run("GCS protocol", func(t *testing.T) { + seenReq := []*cosiproto.DriverGrantBucketAccessRequest{} + var requestError error + fakeServer := test.FakeProvisionerServer{ + GrantBucketAccessFunc: func(ctx context.Context, dgbar *cosiproto.DriverGrantBucketAccessRequest) (*cosiproto.DriverGrantBucketAccessResponse, error) { + seenReq = append(seenReq, dgbar) + ret := &cosiproto.DriverGrantBucketAccessResponse{ + AccountId: "cosi-" + dgbar.AccountName, + Credentials: &cosiproto.CredentialInfo{ + Gcs: &cosiproto.GcsCredentialInfo{ + AccessId: "accessid", + AccessSecret: "accesssecret", + PrivateKeyName: "privatekeyname", + ServiceAccount: "serviceaccountname", + }, + }, + Buckets: []*cosiproto.DriverGrantBucketAccessResponse_BucketInfo{ + { + BucketId: "cosi-bc-qwerty", + BucketInfo: &cosiproto.ObjectProtocolAndBucketInfo{ + Gcs: &cosiproto.GcsBucketInfo{ + ProjectId: "projectid", + BucketName: "corp-cosi-bc-qwerty", + }, + }, + }, + { + BucketId: "cosi-bc-asdfgh", + BucketInfo: &cosiproto.ObjectProtocolAndBucketInfo{ + Gcs: &cosiproto.GcsBucketInfo{ + ProjectId: "projectid", + BucketName: "corp-cosi-bc-asdfgh", + }, + }, + }, + }, + } + return ret, requestError + }, + } + + cleanup, serve, tmpSock, err := test.Server(nil, &fakeServer) + defer cleanup() + require.NoError(t, err) + go serve() + + conn, err := test.ClientConn(tmpSock) + require.NoError(t, err) + rpcClient := cosiproto.NewProvisionerClient(conn) + + gcsAccess := baseAccess.DeepCopy() + gcsAccess.Spec.Protocol = cosiapi.ObjectProtocolGcs + + c := newClient( + gcsAccess, + baseReadWriteBucket.DeepCopy(), + baseReadOnlyBucket.DeepCopy(), + ) + + r := newReconciler(c, rpcClient) + nctx := logr.NewContext(ctx, nolog) + + res, err := r.Reconcile(nctx, ctrl.Request{NamespacedName: accessNsName}) + r.DriverInfo.supportedProtocols = append(r.DriverInfo.supportedProtocols, cosiproto.ObjectProtocol_GCS) + assert.NoError(t, err) + assert.Empty(t, res) + + // ensure the expected RPC call was made + require.Len(t, seenReq, 1) + req := seenReq[0] + assert.Equal(t, "ba-zxcvbn", req.AccountName) + assert.Equal(t, cosiproto.AuthenticationType_KEY, req.AuthenticationType.Type) + assert.Equal(t, cosiproto.ObjectProtocol_GCS, req.Protocol.Type) + assert.Equal(t, "", req.ServiceAccountName) + assert.Equal(t, + map[string]string{ + "maxSize": "100Gi", + "maxIops": "10", + }, + req.Parameters, + ) + require.Len(t, req.Buckets, 2) // by RPC spec, order of requested accessed buckets is random + assert.True(t, accessedBucketRequestExists(req.Buckets, &cosiproto.DriverGrantBucketAccessRequest_AccessedBucket{ + BucketId: "cosi-bc-qwerty", + AccessMode: &cosiproto.AccessMode{Mode: cosiproto.AccessMode_READ_WRITE}, + })) + assert.True(t, accessedBucketRequestExists(req.Buckets, &cosiproto.DriverGrantBucketAccessRequest_AccessedBucket{ + BucketId: "cosi-bc-asdfgh", + AccessMode: &cosiproto.AccessMode{Mode: cosiproto.AccessMode_READ_ONLY}, + })) + + access := &cosiapi.BucketAccess{} + require.NoError(t, c.Get(ctx, accessNsName, access)) + assert.Contains(t, access.GetFinalizers(), cosiapi.ProtectionFinalizer) + assert.Equal(t, gcsAccess.Spec, access.Spec) // spec should not change + assert.True(t, *access.Status.ReadyToUse) + assert.Nil(t, access.Status.Error) + assert.Equal(t, "cosi-ba-zxcvbn", access.Status.AccountID) + assert.Equal(t, gcsAccess.Status.AccessedBuckets, access.Status.AccessedBuckets) + assert.Equal(t, gcsAccess.Status.AuthenticationType, access.Status.AuthenticationType) + assert.Equal(t, gcsAccess.Status.DriverName, access.Status.DriverName) + assert.Equal(t, gcsAccess.Status.Parameters, access.Status.Parameters) + + // ensure secrets are present with info + rws := &corev1.Secret{} + require.NoError(t, c.Get(ctx, readWriteSecretNsName, rws)) + + ros := &corev1.Secret{} + require.NoError(t, c.Get(ctx, readOnlySecretNsName, ros)) + + for _, s := range []*corev1.Secret{rws, ros} { + assert.Contains(t, s.GetFinalizers(), cosiapi.ProtectionFinalizer) + require.Len(t, s.OwnerReferences, 1) + assert.Equal(t, "zxcvbn", string(s.OwnerReferences[0].UID)) + assert.Equal(t, "accessid", s.StringData[string(cosiapi.CredentialVar_GCS_AccessId)]) + } + assert.Equal(t, "corp-cosi-bc-qwerty", rws.StringData[string(cosiapi.BucketInfoVar_GCS_BucketName)]) + assert.Equal(t, "corp-cosi-bc-asdfgh", ros.StringData[string(cosiapi.BucketInfoVar_GCS_BucketName)]) + }) + // GCS proto +} + +func accessedBucketRequestExists( + requestList []*cosiproto.DriverGrantBucketAccessRequest_AccessedBucket, + want *cosiproto.DriverGrantBucketAccessRequest_AccessedBucket, +) bool { + for _, ab := range requestList { + modeEq := ab.AccessMode.Mode == want.AccessMode.Mode + idEq := ab.BucketId == want.BucketId + if modeEq && idEq { + return true + } + } + return false +} diff --git a/sidecar/internal/reconciler/driver.go b/sidecar/internal/reconciler/driver.go index 1e20f030..5cbddb4b 100644 --- a/sidecar/internal/reconciler/driver.go +++ b/sidecar/internal/reconciler/driver.go @@ -137,8 +137,8 @@ func driverNameMatchesPredicate(driverName string) ctrlpredicate.Funcs { switch t := object.(type) { case *cosiapi.Bucket: return object.(*cosiapi.Bucket).Spec.DriverName == driverName - // case *cosiapi.BucketAccess: // TODO: later - // return object.(*cosiapi.BucketAccess).Status.DriverName == driverName + case *cosiapi.BucketAccess: + return object.(*cosiapi.BucketAccess).Status.DriverName == driverName default: logger := ctrl.Log.WithName("driverName-predicate") logger.Error(nil, "cannot attempt to check driverName of type %T", t) diff --git a/sidecar/internal/reconciler/translators.go b/sidecar/internal/reconciler/translators.go new file mode 100644 index 00000000..a0d8ec13 --- /dev/null +++ b/sidecar/internal/reconciler/translators.go @@ -0,0 +1,212 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package reconciler + +import ( + "errors" + "fmt" + "reflect" + + cosiapi "sigs.k8s.io/container-object-storage-interface/client/apis/objectstorage/v1alpha2" + "sigs.k8s.io/container-object-storage-interface/internal/protocol" + cosiproto "sigs.k8s.io/container-object-storage-interface/proto" +) + +// validationConfig controls behavior of validation logic when translating bucket/credential info. +type validationConfig struct { + // ExpectedProtocol the singular object protocol response that is expected. + // Any responses for other protocols other will result in a validation error. + ExpectedProtocol cosiapi.ObjectProtocol + + // Validation logic uses BucketAccess authenticationType for internal logic. Set accordingly. + AuthenticationType cosiapi.BucketAccessAuthenticationType +} + +// TranslateBucketInfo translates all bucket info for all protocols from an RPC response. +// If validation is configured (non-nil), only the expected protocol is allowed to have bucket info. +func TranslateBucketInfoToApi( + bi *cosiproto.ObjectProtocolAndBucketInfo, + validation *validationConfig, +) ( + returnedProtos []cosiapi.ObjectProtocol, + allProtoBucketInfo map[string]string, + err error, +) { + if bi == nil { + return nil, nil, fmt.Errorf("bucket info response is nil") + } + + errs := []error{} + returnedProtos = []cosiapi.ObjectProtocol{} + allProtoBucketInfo = map[string]string{} + + if bi.S3 != nil { + returnedProtos = append(returnedProtos, cosiapi.ObjectProtocolS3) + } + info, err := translate(protocol.S3BucketInfoTranslator{}, bi.S3, validation) + if err != nil { + errs = append(errs, fmt.Errorf("errors translating %s bucket info: %w", cosiapi.ObjectProtocolS3, err)) + } else { + mergeApiInfoIntoStringMap(info, allProtoBucketInfo) + } + + if bi.Azure != nil { + returnedProtos = append(returnedProtos, cosiapi.ObjectProtocolAzure) + } + info, err = translate(protocol.AzureBucketInfoTranslator{}, bi.Azure, validation) + if err != nil { + errs = append(errs, fmt.Errorf("errors translating %s bucket info: %w", cosiapi.ObjectProtocolAzure, err)) + } else { + mergeApiInfoIntoStringMap(info, allProtoBucketInfo) + } + + if bi.Gcs != nil { + returnedProtos = append(returnedProtos, cosiapi.ObjectProtocolGcs) + } + info, err = translate(protocol.GcsBucketInfoTranslator{}, bi.Gcs, validation) + if err != nil { + errs = append(errs, fmt.Errorf("errors translating %s bucket info: %w", cosiapi.ObjectProtocolGcs, err)) + } else { + mergeApiInfoIntoStringMap(info, allProtoBucketInfo) + } + + if len(errs) > 0 { + return nil, nil, fmt.Errorf("errors translating bucket info: %w", errors.Join(errs...)) + } + return returnedProtos, allProtoBucketInfo, nil +} + +// TranslateCredentials translates all credential info for all protocols from an RPC response. +// Validation must be configured, and only the expected protocol is allowed to have credentials. +func TranslateCredentialsToApi( + ci *cosiproto.CredentialInfo, + validation validationConfig, // no pointer, validation required +) ( + allProtoCredentials map[string]string, + err error, +) { + errs := []error{} + allProtoCredentials = map[string]string{} + + if ci == nil { + return nil, fmt.Errorf("credential info is nil") + } + + info, err := translate(protocol.S3CredentialTranslator{}, ci.S3, &validation) + if err != nil { + errs = append(errs, fmt.Errorf("errors translating S3 bucket credentials: %w", err)) + } else { + mergeApiInfoIntoStringMap(info, allProtoCredentials) + } + + info, err = translate(protocol.AzureCredentialTranslator{}, ci.Azure, &validation) + if err != nil { + errs = append(errs, fmt.Errorf("errors translating Azure bucket credentials: %w", err)) + } else { + mergeApiInfoIntoStringMap(info, allProtoCredentials) + } + + info, err = translate(protocol.GcsCredentialTranslator{}, ci.Gcs, &validation) + if err != nil { + errs = append(errs, fmt.Errorf("errors translating GCS bucket credentials: %w", err)) + } else { + mergeApiInfoIntoStringMap(info, allProtoCredentials) + } + + if len(errs) > 0 { + return nil, fmt.Errorf("errors translating access credentials: %w", errors.Join(errs...)) + } + return allProtoCredentials, nil +} + +func translate[RpcType any, ApiType comparable, T protocol.RpcApiTranslator[RpcType, ApiType]]( + translator T, + rpcInfo RpcType, + validation *validationConfig, +) ( + map[ApiType]string, + error, +) { + thisProto := translator.ApiProtocol() + + tv := reflect.ValueOf(translator) + if (tv.Kind() == reflect.Interface || tv.Kind() == reflect.Ptr) && tv.IsNil() { + return nil, fmt.Errorf("cannot translate %q protocol", thisProto) + } + + if validation != nil && validation.ExpectedProtocol != thisProto { + if reflect.ValueOf(rpcInfo).IsNil() { + // Non-expected protocol is nil as required. Nothing to translate. + return map[ApiType]string{}, nil + } + // Non-expected protocol is unexpectedly set. + return nil, fmt.Errorf("received %q protocol response when only %q protocol is expected", + thisProto, validation.ExpectedProtocol) + } + + if validation != nil && reflect.ValueOf(rpcInfo).IsNil() { + return nil, fmt.Errorf("missing response for expected %q protocol", thisProto) + } + + out := translator.RpcToApi(rpcInfo) + + if validation != nil { + err := translator.Validate(out, validation.AuthenticationType) + if err != nil { + return nil, err + } + } + + return out, nil +} + +func mergeApiInfoIntoStringMap[T cosiapi.BucketInfoVar | cosiapi.CredentialVar | string]( + varKey map[T]string, target map[string]string, +) { + if target == nil { + target = map[string]string{} + } + for k, v := range varKey { + target[string(k)] = v + } +} + +// Translate COSI API authentication type to RPC authentication type. +func authenticationTypeToRpc(a cosiapi.BucketAccessAuthenticationType) (cosiproto.AuthenticationType_Type, error) { + switch a { + case cosiapi.BucketAccessAuthenticationTypeKey: + return cosiproto.AuthenticationType_KEY, nil + case cosiapi.BucketAccessAuthenticationTypeServiceAccount: + return cosiproto.AuthenticationType_SERVICE_ACCOUNT, nil + default: + return cosiproto.AuthenticationType_UNKNOWN, fmt.Errorf("unknown authentication type %q", string(a)) + } +} + +// Translate COSI API access mode to RPC access mode. +func accessModeToRpc(m cosiapi.BucketAccessMode) (cosiproto.AccessMode_Mode, error) { + switch m { + case cosiapi.BucketAccessModeReadOnly: + return cosiproto.AccessMode_READ_ONLY, nil + case cosiapi.BucketAccessModeReadWrite: + return cosiproto.AccessMode_READ_WRITE, nil + case cosiapi.BucketAccessModeWriteOnly: + return cosiproto.AccessMode_WRITE_ONLY, nil + default: + return cosiproto.AccessMode_UNKNOWN, fmt.Errorf("unknown access mode %q", string(m)) + } +} diff --git a/sidecar/internal/reconciler/translators_test.go b/sidecar/internal/reconciler/translators_test.go new file mode 100644 index 00000000..ce08892c --- /dev/null +++ b/sidecar/internal/reconciler/translators_test.go @@ -0,0 +1,552 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package reconciler + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + cosiapi "sigs.k8s.io/container-object-storage-interface/client/apis/objectstorage/v1alpha2" + cosiproto "sigs.k8s.io/container-object-storage-interface/proto" +) + +func TestTranslateBucketInfo(t *testing.T) { + tests := []struct { + name string // description of this test case + pbi *cosiproto.ObjectProtocolAndBucketInfo + validation *validationConfig + wantProtos []cosiapi.ObjectProtocol + wantInfoVarPrefixes []string + wantErr string + }{ + {"no info, no validation", + &cosiproto.ObjectProtocolAndBucketInfo{}, nil, + []cosiapi.ObjectProtocol{}, []string{}, "", + }, + {"no info, validate S3", + &cosiproto.ObjectProtocolAndBucketInfo{}, + &validationConfig{cosiapi.ObjectProtocolS3, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, nil, `missing response for expected "S3" protocol`, + }, + {"no info, validate Azure", + &cosiproto.ObjectProtocolAndBucketInfo{}, + &validationConfig{cosiapi.ObjectProtocolAzure, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, nil, `missing response for expected "Azure" protocol`, + }, + {"no info, validate GCS", + &cosiproto.ObjectProtocolAndBucketInfo{}, + &validationConfig{cosiapi.ObjectProtocolGcs, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, nil, `missing response for expected "GCS" protocol`, + }, + {"s3 empty, no validation", + &cosiproto.ObjectProtocolAndBucketInfo{ + S3: &cosiproto.S3BucketInfo{}, + }, + nil, // no validation + []cosiapi.ObjectProtocol{ + cosiapi.ObjectProtocolS3, + }, + []string{ + "COSI_S3_", + }, + "", + }, + {"s3 empty, validate S3", + &cosiproto.ObjectProtocolAndBucketInfo{ + S3: &cosiproto.S3BucketInfo{}, + }, + &validationConfig{cosiapi.ObjectProtocolS3, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, nil, "errors translating S3 bucket info", + }, + {"s3 empty, validate Azure", + &cosiproto.ObjectProtocolAndBucketInfo{ + S3: &cosiproto.S3BucketInfo{}, + }, + &validationConfig{cosiapi.ObjectProtocolAzure, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, nil, `missing response for expected "Azure" protocol`, + }, + {"s3 non-empty, no validation", + &cosiproto.ObjectProtocolAndBucketInfo{ + S3: &cosiproto.S3BucketInfo{ + BucketId: "something", + Endpoint: "cosi.corp.net", + }, + }, + nil, // no validation + []cosiapi.ObjectProtocol{ + cosiapi.ObjectProtocolS3, + }, + []string{ + "COSI_S3_", + }, + "", + }, + {"s3 non-empty, validate S3", + &cosiproto.ObjectProtocolAndBucketInfo{ + S3: &cosiproto.S3BucketInfo{ + BucketId: "something", + Endpoint: "cosi.corp.net", + // some required info missing to ensure validation is being activated + }, + }, + &validationConfig{cosiapi.ObjectProtocolS3, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, nil, "errors translating S3 bucket info", + }, + {"s3 non-empty, validate GCS", + &cosiproto.ObjectProtocolAndBucketInfo{ + S3: &cosiproto.S3BucketInfo{ + BucketId: "something", + Endpoint: "cosi.corp.net", + // some required info missing to ensure validation is being activated + }, + }, + &validationConfig{cosiapi.ObjectProtocolGcs, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, nil, `missing response for expected "GCS" protocol`, + }, + {"azure empty, no validation", + &cosiproto.ObjectProtocolAndBucketInfo{ + Azure: &cosiproto.AzureBucketInfo{}, + }, + nil, // no validation + []cosiapi.ObjectProtocol{ + cosiapi.ObjectProtocolAzure, + }, + []string{ + "COSI_AZURE_", + }, + "", + }, + {"azure empty, validate Azure", + &cosiproto.ObjectProtocolAndBucketInfo{ + Azure: &cosiproto.AzureBucketInfo{}, + }, + &validationConfig{cosiapi.ObjectProtocolAzure, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, nil, "errors translating Azure bucket info", + }, + {"azure non-empty, no validation", + &cosiproto.ObjectProtocolAndBucketInfo{ + Azure: &cosiproto.AzureBucketInfo{ + StorageAccount: "something", + }, + }, + nil, // no validation + []cosiapi.ObjectProtocol{ + cosiapi.ObjectProtocolAzure, + }, + []string{ + "COSI_AZURE_", + }, + "", + }, + {"azure non-empty, validate Azure", + &cosiproto.ObjectProtocolAndBucketInfo{ + Azure: &cosiproto.AzureBucketInfo{ + StorageAccount: "", // empty string to verify validation is being activated + }, + }, + &validationConfig{cosiapi.ObjectProtocolAzure, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, nil, "errors translating Azure bucket info", + }, + {"GCS empty, no validation", + &cosiproto.ObjectProtocolAndBucketInfo{ + Gcs: &cosiproto.GcsBucketInfo{}, + }, + nil, // no validation + []cosiapi.ObjectProtocol{ + cosiapi.ObjectProtocolGcs, + }, + []string{ + "COSI_GCS_", + }, + "", + }, + {"GCS empty, validate GCS", + &cosiproto.ObjectProtocolAndBucketInfo{ + Gcs: &cosiproto.GcsBucketInfo{}, + }, + &validationConfig{cosiapi.ObjectProtocolGcs, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, nil, "errors translating GCS bucket info", + }, + {"GCS non-empty, no validation", + &cosiproto.ObjectProtocolAndBucketInfo{ + Gcs: &cosiproto.GcsBucketInfo{ + BucketName: "something", + }, + }, + nil, // no validation + []cosiapi.ObjectProtocol{ + cosiapi.ObjectProtocolGcs, + }, + []string{ + "COSI_GCS_", + }, + "", + }, + {"GCS non-empty, validate GCS", + &cosiproto.ObjectProtocolAndBucketInfo{ + Gcs: &cosiproto.GcsBucketInfo{ + BucketName: "something", + // some required info missing to ensure validation is being activated + }, + }, + &validationConfig{cosiapi.ObjectProtocolGcs, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, nil, "errors translating GCS bucket info", + }, + {"s3+azure+GCS empty, no validation", + &cosiproto.ObjectProtocolAndBucketInfo{ + S3: &cosiproto.S3BucketInfo{}, + Azure: &cosiproto.AzureBucketInfo{}, + Gcs: &cosiproto.GcsBucketInfo{}, + }, + nil, // no validation + []cosiapi.ObjectProtocol{ + cosiapi.ObjectProtocolS3, + cosiapi.ObjectProtocolAzure, + cosiapi.ObjectProtocolGcs, + }, + []string{ + "COSI_S3_", + "COSI_AZURE_", + "COSI_GCS_", + }, + "", + }, + {"s3+azure+GCS empty, validate S3", + &cosiproto.ObjectProtocolAndBucketInfo{ + S3: &cosiproto.S3BucketInfo{}, + Azure: &cosiproto.AzureBucketInfo{}, + Gcs: &cosiproto.GcsBucketInfo{}, + }, + &validationConfig{cosiapi.ObjectProtocolS3, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, nil, `only "S3" protocol is expected`, + }, + {"s3+azure+GCS empty, validate Azure", + &cosiproto.ObjectProtocolAndBucketInfo{ + S3: &cosiproto.S3BucketInfo{}, + Azure: &cosiproto.AzureBucketInfo{}, + Gcs: &cosiproto.GcsBucketInfo{}, + }, + &validationConfig{cosiapi.ObjectProtocolAzure, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, nil, `only "Azure" protocol is expected`, + }, + {"s3+azure+GCS empty, validate GCS", + &cosiproto.ObjectProtocolAndBucketInfo{ + S3: &cosiproto.S3BucketInfo{}, + Azure: &cosiproto.AzureBucketInfo{}, + Gcs: &cosiproto.GcsBucketInfo{}, + }, + &validationConfig{cosiapi.ObjectProtocolGcs, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, nil, `only "GCS" protocol is expected`, + }, + {"s3+azure+GCS non-empty, no validation", + &cosiproto.ObjectProtocolAndBucketInfo{ + S3: &cosiproto.S3BucketInfo{ + BucketId: "something", + }, + Azure: &cosiproto.AzureBucketInfo{ + StorageAccount: "acct", + }, + Gcs: &cosiproto.GcsBucketInfo{ + BucketName: "something", + }, + }, + nil, // no validation + []cosiapi.ObjectProtocol{ + cosiapi.ObjectProtocolS3, + cosiapi.ObjectProtocolAzure, + cosiapi.ObjectProtocolGcs, + }, + []string{ + "COSI_S3_", + "COSI_AZURE_", + "COSI_GCS_", + }, + "", + }, + {"s3+azure+GCS non-empty, validate S3", + &cosiproto.ObjectProtocolAndBucketInfo{ + S3: &cosiproto.S3BucketInfo{ + BucketId: "something", + }, + Azure: &cosiproto.AzureBucketInfo{ + StorageAccount: "acct", + }, + Gcs: &cosiproto.GcsBucketInfo{ + BucketName: "something", + }, + }, + &validationConfig{cosiapi.ObjectProtocolS3, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, nil, `only "S3" protocol is expected`, + }, + {"s3+azure+GCS non-empty, validate Azure", + &cosiproto.ObjectProtocolAndBucketInfo{ + S3: &cosiproto.S3BucketInfo{ + BucketId: "something", + }, + Azure: &cosiproto.AzureBucketInfo{ + StorageAccount: "acct", + }, + Gcs: &cosiproto.GcsBucketInfo{ + BucketName: "something", + }, + }, + &validationConfig{cosiapi.ObjectProtocolAzure, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, nil, `only "Azure" protocol is expected`, + }, + {"s3+azure+GCS non-empty, validate GCS", + &cosiproto.ObjectProtocolAndBucketInfo{ + S3: &cosiproto.S3BucketInfo{ + BucketId: "something", + }, + Azure: &cosiproto.AzureBucketInfo{ + StorageAccount: "acct", + }, + Gcs: &cosiproto.GcsBucketInfo{ + BucketName: "something", + }, + }, + &validationConfig{cosiapi.ObjectProtocolGcs, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, nil, `only "GCS" protocol is expected`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + protos, infoVars, err := TranslateBucketInfoToApi(tt.pbi, tt.validation) + if tt.wantErr == "" { + assert.NoError(t, err) + } else { + t.Log("got error:", err) + assert.ErrorContains(t, err, tt.wantErr) + } + assert.Equal(t, tt.wantProtos, protos) + // If we check the exact results of details.allProtoBucketInfo, we will tie the unit + // tests to the specific implementation of bucket info translators, tested elsewhere. + // Instead, check only that prefixes match what we expect. + if len(tt.wantProtos) > 0 { + assert.NotZero(t, len(infoVars)) + } else { + assert.Zero(t, len(infoVars)) + } + // t.Log("got info map:", infoVars) + for _, p := range tt.wantInfoVarPrefixes { + found := false + for k := range infoVars { + assert.True(t, strings.HasPrefix(k, "COSI_")) // all vars must be prefixed COSI_ + if strings.HasPrefix(k, p) { + found = true + } + } + assert.Truef(t, found, "prefix %q not found in %v keys", p, infoVars) + } + }) + } +} + +func TestTranslateCredentials(t *testing.T) { + tests := []struct { + name string // description of this test case + pbi *cosiproto.CredentialInfo + validation validationConfig + wantInfoVarPrefixes []string + wantErr string + }{ + {"no info, validate S3", + &cosiproto.CredentialInfo{}, + validationConfig{cosiapi.ObjectProtocolS3, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, `missing response for expected "S3" protocol`, + }, + {"no info, validate Azure", + &cosiproto.CredentialInfo{}, + validationConfig{cosiapi.ObjectProtocolAzure, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, `missing response for expected "Azure" protocol`, + }, + {"no info, validate GCS", + &cosiproto.CredentialInfo{}, + validationConfig{cosiapi.ObjectProtocolGcs, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, `missing response for expected "GCS" protocol`, + }, + {"s3 empty, validate S3", + &cosiproto.CredentialInfo{ + S3: &cosiproto.S3CredentialInfo{}, + }, + validationConfig{cosiapi.ObjectProtocolS3, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, "errors translating S3 bucket credentials", + }, + {"s3 empty, validate Azure", + &cosiproto.CredentialInfo{ + S3: &cosiproto.S3CredentialInfo{}, + }, + validationConfig{cosiapi.ObjectProtocolAzure, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, `missing response for expected "Azure" protocol`, + }, + {"s3 non-empty, validate S3", + &cosiproto.CredentialInfo{ + S3: &cosiproto.S3CredentialInfo{ + AccessKeyId: "accesskey", + // some required info missing to ensure validation is being activated + }, + }, + validationConfig{cosiapi.ObjectProtocolS3, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, "errors translating S3 bucket credentials", + }, + {"s3 non-empty, validate GCS", + &cosiproto.CredentialInfo{ + S3: &cosiproto.S3CredentialInfo{ + AccessKeyId: "accesskey", + // some required info missing to ensure validation is being activated + }, + }, + validationConfig{cosiapi.ObjectProtocolGcs, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, `missing response for expected "GCS" protocol`, + }, + {"azure empty, validate Azure", + &cosiproto.CredentialInfo{ + Azure: &cosiproto.AzureCredentialInfo{}, + }, + validationConfig{cosiapi.ObjectProtocolAzure, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, "errors translating Azure bucket credentials", + }, + {"azure non-empty, validate Azure", + &cosiproto.CredentialInfo{ + Azure: &cosiproto.AzureCredentialInfo{ + AccessToken: "", // empty string to verify validation is being activated + }, + }, + validationConfig{cosiapi.ObjectProtocolAzure, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, "errors translating Azure bucket credentials", + }, + {"GCS empty, validate GCS", + &cosiproto.CredentialInfo{ + Gcs: &cosiproto.GcsCredentialInfo{}, + }, + validationConfig{cosiapi.ObjectProtocolGcs, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, "errors translating GCS bucket credentials", + }, + {"GCS non-empty, validate GCS", + &cosiproto.CredentialInfo{ + Gcs: &cosiproto.GcsCredentialInfo{ + AccessId: "accessId", + // some required info missing to ensure validation is being activated + }, + }, + validationConfig{cosiapi.ObjectProtocolGcs, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, "errors translating GCS bucket credentials", + }, + {"s3+azure+GCS empty, validate S3", + &cosiproto.CredentialInfo{ + S3: &cosiproto.S3CredentialInfo{}, + Azure: &cosiproto.AzureCredentialInfo{}, + Gcs: &cosiproto.GcsCredentialInfo{}, + }, + validationConfig{cosiapi.ObjectProtocolS3, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, `only "S3" protocol is expected`, + }, + {"s3+azure+GCS empty, validate Azure", + &cosiproto.CredentialInfo{ + S3: &cosiproto.S3CredentialInfo{}, + Azure: &cosiproto.AzureCredentialInfo{}, + Gcs: &cosiproto.GcsCredentialInfo{}, + }, + validationConfig{cosiapi.ObjectProtocolAzure, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, `only "Azure" protocol is expected`, + }, + {"s3+azure+GCS empty, validate GCS", + &cosiproto.CredentialInfo{ + S3: &cosiproto.S3CredentialInfo{}, + Azure: &cosiproto.AzureCredentialInfo{}, + Gcs: &cosiproto.GcsCredentialInfo{}, + }, + validationConfig{cosiapi.ObjectProtocolGcs, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, `only "GCS" protocol is expected`, + }, + {"s3+azure+GCS non-empty, validate S3", + &cosiproto.CredentialInfo{ + S3: &cosiproto.S3CredentialInfo{ + AccessKeyId: "something", + }, + Azure: &cosiproto.AzureCredentialInfo{ + AccessToken: "", + }, + Gcs: &cosiproto.GcsCredentialInfo{ + AccessId: "something", + }, + }, + validationConfig{cosiapi.ObjectProtocolS3, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, `only "S3" protocol is expected`, + }, + {"s3+azure+GCS non-empty, validate Azure", + &cosiproto.CredentialInfo{ + S3: &cosiproto.S3CredentialInfo{ + AccessKeyId: "something", + }, + Azure: &cosiproto.AzureCredentialInfo{ + AccessToken: "", + }, + Gcs: &cosiproto.GcsCredentialInfo{ + AccessId: "something", + }, + }, + validationConfig{cosiapi.ObjectProtocolAzure, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, `only "Azure" protocol is expected`, + }, + {"s3+azure+GCS non-empty, validate GCS", + &cosiproto.CredentialInfo{ + S3: &cosiproto.S3CredentialInfo{ + AccessKeyId: "something", + }, + Azure: &cosiproto.AzureCredentialInfo{ + AccessToken: "", + }, + Gcs: &cosiproto.GcsCredentialInfo{ + AccessId: "something", + }, + }, + validationConfig{cosiapi.ObjectProtocolGcs, cosiapi.BucketAccessAuthenticationTypeKey}, + nil, `only "GCS" protocol is expected`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + creds, err := TranslateCredentialsToApi(tt.pbi, tt.validation) + if tt.wantErr == "" { + assert.NoError(t, err) + } else { + t.Log("got error:", err) + assert.ErrorContains(t, err, tt.wantErr) + } + // If we check the exact results of details.allProtoBucketInfo, we will tie the unit + // tests to the specific implementation of bucket info translators, tested elsewhere. + // Instead, check only that prefixes match what we expect. + if tt.wantErr == "" { + assert.NotZero(t, len(creds)) + } + // t.Log("got info map:", infoVars) + for _, p := range tt.wantInfoVarPrefixes { + found := false + for k := range creds { + assert.True(t, strings.HasPrefix(k, "COSI_")) // all vars must be prefixed COSI_ + if strings.HasPrefix(k, p) { + found = true + } + } + assert.Truef(t, found, "prefix %q not found in %v keys", p, creds) + } + }) + } +} diff --git a/sidecar/internal/test/rpc.go b/sidecar/internal/test/rpc.go index d52a8fd2..2a684306 100644 --- a/sidecar/internal/test/rpc.go +++ b/sidecar/internal/test/rpc.go @@ -17,6 +17,7 @@ limitations under the License. package test import ( + "context" "net" "os" @@ -95,3 +96,30 @@ func ClientConn(tmpSockUri string) (*grpc.ClientConn, error) { ), ) } + +// FakeProvisionerServer implements COSI provisioner server with call stubs for use in unit tests. +// nolint:lll // don't care about the long lines here +type FakeProvisionerServer struct { + cosiproto.UnimplementedProvisionerServer + + CreateBucketFunc func(context.Context, *cosiproto.DriverCreateBucketRequest) (*cosiproto.DriverCreateBucketResponse, error) + GrantBucketAccessFunc func(context.Context, *cosiproto.DriverGrantBucketAccessRequest) (*cosiproto.DriverGrantBucketAccessResponse, error) +} + +func (s *FakeProvisionerServer) DriverCreateBucket( + ctx context.Context, req *cosiproto.DriverCreateBucketRequest, +) (*cosiproto.DriverCreateBucketResponse, error) { + if s.CreateBucketFunc != nil { + return s.CreateBucketFunc(ctx, req) + } + return s.UnimplementedProvisionerServer.DriverCreateBucket(ctx, req) +} + +func (s *FakeProvisionerServer) DriverGrantBucketAccess( + ctx context.Context, req *cosiproto.DriverGrantBucketAccessRequest, +) (*cosiproto.DriverGrantBucketAccessResponse, error) { + if s.GrantBucketAccessFunc != nil { + return s.GrantBucketAccessFunc(ctx, req) + } + return s.UnimplementedProvisionerServer.DriverGrantBucketAccess(ctx, req) +} diff --git a/vendor/sigs.k8s.io/container-object-storage-interface/client/apis/objectstorage/v1alpha2/definitions.go b/vendor/sigs.k8s.io/container-object-storage-interface/client/apis/objectstorage/v1alpha2/definitions.go index 162b76fc..e205ec55 100644 --- a/vendor/sigs.k8s.io/container-object-storage-interface/client/apis/objectstorage/v1alpha2/definitions.go +++ b/vendor/sigs.k8s.io/container-object-storage-interface/client/apis/objectstorage/v1alpha2/definitions.go @@ -25,6 +25,10 @@ const ( // Annotations const ( + // BucketClaimBeingDeletedAnnotation : This annotation is applied by the COSI Controller to a + // Bucket when its BucketClaim is being deleted. + BucketClaimBeingDeletedAnnotation = `objectstorage.k8.io/bucketclaim-being-deleted` + // HasBucketAccessReferencesAnnotation : This annotation is applied by the COSI Controller to a // BucketClaim when a BucketAccess that references the BucketClaim is created. The annotation // remains for as long as any BucketAccess references the BucketClaim. Once all BucketAccesses diff --git a/vendor/sigs.k8s.io/container-object-storage-interface/proto/cosi.pb.go b/vendor/sigs.k8s.io/container-object-storage-interface/proto/cosi.pb.go index 177abc85..6040abed 100644 --- a/vendor/sigs.k8s.io/container-object-storage-interface/proto/cosi.pb.go +++ b/vendor/sigs.k8s.io/container-object-storage-interface/proto/cosi.pb.go @@ -958,6 +958,7 @@ func (x *AuthenticationType) GetType() AuthenticationType_Type { type AccessMode struct { state protoimpl.MessageState `protogen:"open.v1"` + Mode AccessMode_Mode `protobuf:"varint,1,opt,name=mode,proto3,enum=sigs.k8s.io.cosi.v1alpha2.AccessMode_Mode" json:"mode,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -992,6 +993,13 @@ func (*AccessMode) Descriptor() ([]byte, []int) { return file_cosi_proto_rawDescGZIP(), []int{13} } +func (x *AccessMode) GetMode() AccessMode_Mode { + if x != nil { + return x.Mode + } + return AccessMode_UNKNOWN +} + type DriverCreateBucketRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // REQUIRED. The suggested name for the backend bucket. @@ -2023,9 +2031,10 @@ const file_cosi_proto_rawDesc = "" + "\x04Type\x12\v\n" + "\aUNKNOWN\x10\x00\x12\a\n" + "\x03KEY\x10\x01\x12\x13\n" + - "\x0fSERVICE_ACCOUNT\x10\x02\"P\n" + + "\x0fSERVICE_ACCOUNT\x10\x02\"\x90\x01\n" + "\n" + - "AccessMode\"B\n" + + "AccessMode\x12>\n" + + "\x04mode\x18\x01 \x01(\x0e2*.sigs.k8s.io.cosi.v1alpha2.AccessMode.ModeR\x04mode\"B\n" + "\x04Mode\x12\v\n" + "\aUNKNOWN\x10\x00\x12\x0e\n" + "\n" + @@ -2197,49 +2206,50 @@ var file_cosi_proto_depIdxs = []int32{ 11, // 8: sigs.k8s.io.cosi.v1alpha2.S3BucketInfo.addressing_style:type_name -> sigs.k8s.io.cosi.v1alpha2.S3AddressingStyle 1, // 9: sigs.k8s.io.cosi.v1alpha2.S3AddressingStyle.style:type_name -> sigs.k8s.io.cosi.v1alpha2.S3AddressingStyle.Style 2, // 10: sigs.k8s.io.cosi.v1alpha2.AuthenticationType.type:type_name -> sigs.k8s.io.cosi.v1alpha2.AuthenticationType.Type - 6, // 11: sigs.k8s.io.cosi.v1alpha2.DriverCreateBucketRequest.protocols:type_name -> sigs.k8s.io.cosi.v1alpha2.ObjectProtocol - 28, // 12: sigs.k8s.io.cosi.v1alpha2.DriverCreateBucketRequest.parameters:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverCreateBucketRequest.ParametersEntry - 7, // 13: sigs.k8s.io.cosi.v1alpha2.DriverCreateBucketResponse.protocols:type_name -> sigs.k8s.io.cosi.v1alpha2.ObjectProtocolAndBucketInfo - 6, // 14: sigs.k8s.io.cosi.v1alpha2.DriverGetExistingBucketRequest.protocols:type_name -> sigs.k8s.io.cosi.v1alpha2.ObjectProtocol - 29, // 15: sigs.k8s.io.cosi.v1alpha2.DriverGetExistingBucketRequest.parameters:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverGetExistingBucketRequest.ParametersEntry - 7, // 16: sigs.k8s.io.cosi.v1alpha2.DriverGetExistingBucketResponse.protocols:type_name -> sigs.k8s.io.cosi.v1alpha2.ObjectProtocolAndBucketInfo - 30, // 17: sigs.k8s.io.cosi.v1alpha2.DriverDeleteBucketRequest.parameters:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverDeleteBucketRequest.ParametersEntry - 6, // 18: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest.protocol:type_name -> sigs.k8s.io.cosi.v1alpha2.ObjectProtocol - 16, // 19: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest.authentication_type:type_name -> sigs.k8s.io.cosi.v1alpha2.AuthenticationType - 31, // 20: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest.parameters:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest.ParametersEntry - 32, // 21: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest.buckets:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest.AccessedBucket - 33, // 22: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessResponse.buckets:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessResponse.BucketInfo - 8, // 23: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessResponse.credentials:type_name -> sigs.k8s.io.cosi.v1alpha2.CredentialInfo - 6, // 24: sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessRequest.protocol:type_name -> sigs.k8s.io.cosi.v1alpha2.ObjectProtocol - 16, // 25: sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessRequest.authentication_type:type_name -> sigs.k8s.io.cosi.v1alpha2.AuthenticationType - 34, // 26: sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessRequest.parameters:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessRequest.ParametersEntry - 35, // 27: sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessRequest.buckets:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessRequest.AccessedBucket - 17, // 28: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest.AccessedBucket.access_mode:type_name -> sigs.k8s.io.cosi.v1alpha2.AccessMode - 7, // 29: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessResponse.BucketInfo.bucket_info:type_name -> sigs.k8s.io.cosi.v1alpha2.ObjectProtocolAndBucketInfo - 36, // 30: sigs.k8s.io.cosi.v1alpha2.alpha_enum:extendee -> google.protobuf.EnumOptions - 37, // 31: sigs.k8s.io.cosi.v1alpha2.alpha_enum_value:extendee -> google.protobuf.EnumValueOptions - 38, // 32: sigs.k8s.io.cosi.v1alpha2.cosi_secret:extendee -> google.protobuf.FieldOptions - 38, // 33: sigs.k8s.io.cosi.v1alpha2.alpha_field:extendee -> google.protobuf.FieldOptions - 39, // 34: sigs.k8s.io.cosi.v1alpha2.alpha_message:extendee -> google.protobuf.MessageOptions - 40, // 35: sigs.k8s.io.cosi.v1alpha2.alpha_method:extendee -> google.protobuf.MethodOptions - 41, // 36: sigs.k8s.io.cosi.v1alpha2.alpha_service:extendee -> google.protobuf.ServiceOptions - 4, // 37: sigs.k8s.io.cosi.v1alpha2.Identity.DriverGetInfo:input_type -> sigs.k8s.io.cosi.v1alpha2.DriverGetInfoRequest - 18, // 38: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverCreateBucket:input_type -> sigs.k8s.io.cosi.v1alpha2.DriverCreateBucketRequest - 20, // 39: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverGetExistingBucket:input_type -> sigs.k8s.io.cosi.v1alpha2.DriverGetExistingBucketRequest - 22, // 40: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverDeleteBucket:input_type -> sigs.k8s.io.cosi.v1alpha2.DriverDeleteBucketRequest - 24, // 41: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverGrantBucketAccess:input_type -> sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest - 26, // 42: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverRevokeBucketAccess:input_type -> sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessRequest - 5, // 43: sigs.k8s.io.cosi.v1alpha2.Identity.DriverGetInfo:output_type -> sigs.k8s.io.cosi.v1alpha2.DriverGetInfoResponse - 19, // 44: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverCreateBucket:output_type -> sigs.k8s.io.cosi.v1alpha2.DriverCreateBucketResponse - 21, // 45: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverGetExistingBucket:output_type -> sigs.k8s.io.cosi.v1alpha2.DriverGetExistingBucketResponse - 23, // 46: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverDeleteBucket:output_type -> sigs.k8s.io.cosi.v1alpha2.DriverDeleteBucketResponse - 25, // 47: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverGrantBucketAccess:output_type -> sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessResponse - 27, // 48: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverRevokeBucketAccess:output_type -> sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessResponse - 43, // [43:49] is the sub-list for method output_type - 37, // [37:43] is the sub-list for method input_type - 37, // [37:37] is the sub-list for extension type_name - 30, // [30:37] is the sub-list for extension extendee - 0, // [0:30] is the sub-list for field type_name + 3, // 11: sigs.k8s.io.cosi.v1alpha2.AccessMode.mode:type_name -> sigs.k8s.io.cosi.v1alpha2.AccessMode.Mode + 6, // 12: sigs.k8s.io.cosi.v1alpha2.DriverCreateBucketRequest.protocols:type_name -> sigs.k8s.io.cosi.v1alpha2.ObjectProtocol + 28, // 13: sigs.k8s.io.cosi.v1alpha2.DriverCreateBucketRequest.parameters:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverCreateBucketRequest.ParametersEntry + 7, // 14: sigs.k8s.io.cosi.v1alpha2.DriverCreateBucketResponse.protocols:type_name -> sigs.k8s.io.cosi.v1alpha2.ObjectProtocolAndBucketInfo + 6, // 15: sigs.k8s.io.cosi.v1alpha2.DriverGetExistingBucketRequest.protocols:type_name -> sigs.k8s.io.cosi.v1alpha2.ObjectProtocol + 29, // 16: sigs.k8s.io.cosi.v1alpha2.DriverGetExistingBucketRequest.parameters:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverGetExistingBucketRequest.ParametersEntry + 7, // 17: sigs.k8s.io.cosi.v1alpha2.DriverGetExistingBucketResponse.protocols:type_name -> sigs.k8s.io.cosi.v1alpha2.ObjectProtocolAndBucketInfo + 30, // 18: sigs.k8s.io.cosi.v1alpha2.DriverDeleteBucketRequest.parameters:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverDeleteBucketRequest.ParametersEntry + 6, // 19: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest.protocol:type_name -> sigs.k8s.io.cosi.v1alpha2.ObjectProtocol + 16, // 20: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest.authentication_type:type_name -> sigs.k8s.io.cosi.v1alpha2.AuthenticationType + 31, // 21: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest.parameters:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest.ParametersEntry + 32, // 22: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest.buckets:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest.AccessedBucket + 33, // 23: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessResponse.buckets:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessResponse.BucketInfo + 8, // 24: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessResponse.credentials:type_name -> sigs.k8s.io.cosi.v1alpha2.CredentialInfo + 6, // 25: sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessRequest.protocol:type_name -> sigs.k8s.io.cosi.v1alpha2.ObjectProtocol + 16, // 26: sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessRequest.authentication_type:type_name -> sigs.k8s.io.cosi.v1alpha2.AuthenticationType + 34, // 27: sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessRequest.parameters:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessRequest.ParametersEntry + 35, // 28: sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessRequest.buckets:type_name -> sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessRequest.AccessedBucket + 17, // 29: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest.AccessedBucket.access_mode:type_name -> sigs.k8s.io.cosi.v1alpha2.AccessMode + 7, // 30: sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessResponse.BucketInfo.bucket_info:type_name -> sigs.k8s.io.cosi.v1alpha2.ObjectProtocolAndBucketInfo + 36, // 31: sigs.k8s.io.cosi.v1alpha2.alpha_enum:extendee -> google.protobuf.EnumOptions + 37, // 32: sigs.k8s.io.cosi.v1alpha2.alpha_enum_value:extendee -> google.protobuf.EnumValueOptions + 38, // 33: sigs.k8s.io.cosi.v1alpha2.cosi_secret:extendee -> google.protobuf.FieldOptions + 38, // 34: sigs.k8s.io.cosi.v1alpha2.alpha_field:extendee -> google.protobuf.FieldOptions + 39, // 35: sigs.k8s.io.cosi.v1alpha2.alpha_message:extendee -> google.protobuf.MessageOptions + 40, // 36: sigs.k8s.io.cosi.v1alpha2.alpha_method:extendee -> google.protobuf.MethodOptions + 41, // 37: sigs.k8s.io.cosi.v1alpha2.alpha_service:extendee -> google.protobuf.ServiceOptions + 4, // 38: sigs.k8s.io.cosi.v1alpha2.Identity.DriverGetInfo:input_type -> sigs.k8s.io.cosi.v1alpha2.DriverGetInfoRequest + 18, // 39: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverCreateBucket:input_type -> sigs.k8s.io.cosi.v1alpha2.DriverCreateBucketRequest + 20, // 40: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverGetExistingBucket:input_type -> sigs.k8s.io.cosi.v1alpha2.DriverGetExistingBucketRequest + 22, // 41: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverDeleteBucket:input_type -> sigs.k8s.io.cosi.v1alpha2.DriverDeleteBucketRequest + 24, // 42: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverGrantBucketAccess:input_type -> sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessRequest + 26, // 43: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverRevokeBucketAccess:input_type -> sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessRequest + 5, // 44: sigs.k8s.io.cosi.v1alpha2.Identity.DriverGetInfo:output_type -> sigs.k8s.io.cosi.v1alpha2.DriverGetInfoResponse + 19, // 45: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverCreateBucket:output_type -> sigs.k8s.io.cosi.v1alpha2.DriverCreateBucketResponse + 21, // 46: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverGetExistingBucket:output_type -> sigs.k8s.io.cosi.v1alpha2.DriverGetExistingBucketResponse + 23, // 47: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverDeleteBucket:output_type -> sigs.k8s.io.cosi.v1alpha2.DriverDeleteBucketResponse + 25, // 48: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverGrantBucketAccess:output_type -> sigs.k8s.io.cosi.v1alpha2.DriverGrantBucketAccessResponse + 27, // 49: sigs.k8s.io.cosi.v1alpha2.Provisioner.DriverRevokeBucketAccess:output_type -> sigs.k8s.io.cosi.v1alpha2.DriverRevokeBucketAccessResponse + 44, // [44:50] is the sub-list for method output_type + 38, // [38:44] is the sub-list for method input_type + 38, // [38:38] is the sub-list for extension type_name + 31, // [31:38] is the sub-list for extension extendee + 0, // [0:31] is the sub-list for field type_name } func init() { file_cosi_proto_init() } diff --git a/vendor/sigs.k8s.io/container-object-storage-interface/proto/cosi.proto b/vendor/sigs.k8s.io/container-object-storage-interface/proto/cosi.proto index 78179070..897c4081 100644 --- a/vendor/sigs.k8s.io/container-object-storage-interface/proto/cosi.proto +++ b/vendor/sigs.k8s.io/container-object-storage-interface/proto/cosi.proto @@ -262,6 +262,7 @@ message AccessMode { // Write-only access mode. WRITE_ONLY = 3; } + Mode mode = 1; } message DriverCreateBucketRequest { diff --git a/vendor/sigs.k8s.io/container-object-storage-interface/proto/spec.md b/vendor/sigs.k8s.io/container-object-storage-interface/proto/spec.md index 6e6b62ac..26fd3926 100644 --- a/vendor/sigs.k8s.io/container-object-storage-interface/proto/spec.md +++ b/vendor/sigs.k8s.io/container-object-storage-interface/proto/spec.md @@ -419,6 +419,7 @@ message AccessMode { // Write-only access mode. WRITE_ONLY = 3; } + Mode mode = 1; } ```