diff --git a/README.md b/README.md index 7bede2c..4a7771c 100644 --- a/README.md +++ b/README.md @@ -275,7 +275,8 @@ spec: | `servicePrincipalsRef` | string | Reference to resolve a list of service principal names from `spec`, `status` or `context` (e.g., `spec.servicePrincipalConfig.names`) | | `target` | string | Required. Where to store the query results. Can be `status.` or `context.` | | `skipQueryWhenTargetHasData` | bool | Optional. When true, will skip the query if the target already has data | -| `identity.type | string | Optional. Type of identity credentials to use. Valid values: `AzureServicePrincipalCredentials`, `AzureWorkloadIdentityCredentials`. Default is `AzureServicePrincipalCredentials` | +| `FailOnEmpty` | bool | Optional. When true, the function will fail if the `users`, `groups`, or `servicePrincipals` lists are empty, or if their respective reference fields are empty lists. | +| `identity.type` | string | Optional. Type of identity credentials to use. Valid values: `AzureServicePrincipalCredentials`, `AzureWorkloadIdentityCredentials`. Default is `AzureServicePrincipalCredentials` | ## Result Targets diff --git a/fn.go b/fn.go index c34cfe6..7bd326c 100644 --- a/fn.go +++ b/fn.go @@ -497,11 +497,11 @@ func (g *GraphQuery) graphQuery(ctx context.Context, azureCreds map[string]strin // validateUsers validates if the provided user principal names (emails) exist func (g *GraphQuery) validateUsers(ctx context.Context, client *msgraphsdk.GraphServiceClient, in *v1beta1.Input) (interface{}, error) { - if len(in.Users) == 0 { + if in.FailOnEmpty != nil && *in.FailOnEmpty && len(in.Users) == 0 { return nil, errors.New("no users provided for validation") } - var results []interface{} + results := make([]interface{}, 0) for _, userPrincipalName := range in.Users { if userPrincipalName == nil { @@ -754,11 +754,11 @@ func (g *GraphQuery) getGroupMembers(ctx context.Context, client *msgraphsdk.Gra // getGroupObjectIDs retrieves object IDs for the specified group names func (g *GraphQuery) getGroupObjectIDs(ctx context.Context, client *msgraphsdk.GraphServiceClient, in *v1beta1.Input) (interface{}, error) { - if len(in.Groups) == 0 { + if in.FailOnEmpty != nil && *in.FailOnEmpty && len(in.Groups) == 0 { return nil, errors.New("no group names provided") } - var results []interface{} + results := make([]interface{}, 0) for _, groupName := range in.Groups { if groupName == nil { @@ -799,11 +799,11 @@ func (g *GraphQuery) getGroupObjectIDs(ctx context.Context, client *msgraphsdk.G // getServicePrincipalDetails retrieves details about service principals by name func (g *GraphQuery) getServicePrincipalDetails(ctx context.Context, client *msgraphsdk.GraphServiceClient, in *v1beta1.Input) (interface{}, error) { - if len(in.ServicePrincipals) == 0 { + if in.FailOnEmpty != nil && *in.FailOnEmpty && len(in.ServicePrincipals) == 0 { return nil, errors.New("no service principal names provided") } - var results []interface{} + results := make([]interface{}, 0) for _, spName := range in.ServicePrincipals { if spName == nil { @@ -1515,10 +1515,8 @@ func (f *Function) extractStringArrayFromMap(dataMap map[string]interface{}, fie result = append(result, &strCopy) } } - if len(result) > 0 { - return result, nil - } + return result, nil } - return nil, errors.Errorf("cannot resolve groupsRef: %s not a string array or empty", refKey) + return nil, errors.Errorf("cannot resolve groupsRef: %s not a string array", refKey) } diff --git a/fn_test.go b/fn_test.go index 0aec207..983d6cf 100644 --- a/fn_test.go +++ b/fn_test.go @@ -311,6 +311,205 @@ func TestResolveGroupsRef(t *testing.T) { }, }, }, + "GroupsRefEmptyDefault": { + reason: "The Function should resolve groupsRef from XR spec", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "GroupObjectIDs", + "groupsRef": "spec.groupConfig.groupNames", + "target": "status.groupObjectIDs" + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "groupConfig": { + "groupNames": [] + } + } + }`), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{ + { + Type: "FunctionSuccess", + Status: fnv1.Status_STATUS_CONDITION_TRUE, + Reason: "Success", + Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), + }, + }, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_NORMAL, + Message: `QueryType: "GroupObjectIDs"`, + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "groupConfig": { + "groupNames": [] + } + }, + "status": { + "groupObjectIDs": [] + }}`), + }, + }, + }, + }, + }, + "GroupsRefEmptyNoFail": { + reason: "The Function should resolve groupsRef from XR spec", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "GroupObjectIDs", + "groupsRef": "spec.groupConfig.groupNames", + "target": "status.groupObjectIDs", + "failOnEmpty": false + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "groupConfig": { + "groupNames": [] + } + } + }`), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{ + { + Type: "FunctionSuccess", + Status: fnv1.Status_STATUS_CONDITION_TRUE, + Reason: "Success", + Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), + }, + }, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_NORMAL, + Message: `QueryType: "GroupObjectIDs"`, + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "groupConfig": { + "groupNames": [] + } + }, + "status": { + "groupObjectIDs": [] + }}`), + }, + }, + }, + }, + }, + "GroupsRefEmptyFail": { + reason: "The Function should resolve groupsRef from XR spec", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "GroupObjectIDs", + "groupsRef": "spec.groupConfig.groupNames", + "target": "status.groupObjectIDs", + "failOnEmpty": true + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "groupConfig": { + "groupNames": [] + } + } + }`), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{}, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_FATAL, + Message: "no group names provided", + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "groupConfig": { + "groupNames": [] + } + }}`), + }, + }, + }, + }, + }, "GroupsRefNotFound": { reason: "The Function should handle an error when groupsRef cannot be resolved", args: args{ @@ -371,11 +570,11 @@ func TestResolveGroupsRef(t *testing.T) { mockQuery := &MockGraphQuery{ GraphQueryFunc: func(_ context.Context, _ map[string]string, in *v1beta1.Input) (interface{}, error) { if in.QueryType == "GroupObjectIDs" { - if len(in.Groups) == 0 { + if in.FailOnEmpty != nil && *in.FailOnEmpty && len(in.Groups) == 0 { return nil, errors.New("no group names provided") } - var results []interface{} + results := make([]interface{}, 0) for i, group := range in.Groups { if group == nil { continue @@ -1089,8 +1288,8 @@ func TestResolveUsersRef(t *testing.T) { }, }, }, - "UsersRefNotFound": { - reason: "The Function should handle an error when usersRef cannot be resolved", + "UsersRefEmptyDefault": { + reason: "The Function should resolve usersRef from XR spec", args: args{ ctx: context.Background(), req: &fnv1.RunFunctionRequest{ @@ -1099,12 +1298,20 @@ func TestResolveUsersRef(t *testing.T) { "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", "kind": "Input", "queryType": "UserValidation", - "usersRef": "context.nonexistent.value", + "usersRef": "spec.userAccess.emails", "target": "status.validatedUsers" }`), Observed: &fnv1.State{ Composite: &fnv1.Resource{ - Resource: resource.MustStructJSON(xr), + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "userAccess": { + "emails": [] + } + } + }`), }, }, Credentials: map[string]*fnv1.Credentials{ @@ -1117,10 +1324,18 @@ func TestResolveUsersRef(t *testing.T) { want: want{ rsp: &fnv1.RunFunctionResponse{ Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{ + { + Type: "FunctionSuccess", + Status: fnv1.Status_STATUS_CONDITION_TRUE, + Reason: "Success", + Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), + }, + }, Results: []*fnv1.Result{ { - Severity: fnv1.Severity_SEVERITY_FATAL, - Message: "cannot resolve usersRef: context.nonexistent.value not found", + Severity: fnv1.Severity_SEVERITY_NORMAL, + Message: `QueryType: "UserValidation"`, Target: fnv1.Target_TARGET_COMPOSITE.Enum(), }, }, @@ -1129,31 +1344,214 @@ func TestResolveUsersRef(t *testing.T) { Resource: resource.MustStructJSON(`{ "apiVersion": "example.org/v1", "kind": "XR", - "metadata": { - "name": "cool-xr" + "spec": { + "userAccess": { + "emails": [] + } }, + "status": { + "validatedUsers": [] + }}`), + }, + }, + }, + }, + }, + "UsersRefEmptyNoFail": { + reason: "The Function should resolve usersRef from XR spec", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "UserValidation", + "usersRef": "spec.userAccess.emails", + "target": "status.validatedUsers", + "failOnEmpty": false + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", "spec": { - "count": 2 + "userAccess": { + "emails": [] + } } }`), }, }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, }, }, - }, - } - - for name, tc := range cases { - t.Run(name, func(t *testing.T) { - // Create mock responders for each type of query - mockQuery := &MockGraphQuery{ - GraphQueryFunc: func(_ context.Context, _ map[string]string, in *v1beta1.Input) (interface{}, error) { + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{ + { + Type: "FunctionSuccess", + Status: fnv1.Status_STATUS_CONDITION_TRUE, + Reason: "Success", + Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), + }, + }, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_NORMAL, + Message: `QueryType: "UserValidation"`, + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "userAccess": { + "emails": [] + } + }, + "status": { + "validatedUsers": [] + }}`), + }, + }, + }, + }, + }, + "UsersRefEmptyFail": { + reason: "The Function should resolve usersRef from XR spec", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "UserValidation", + "usersRef": "spec.userAccess.emails", + "target": "status.validatedUsers", + "failOnEmpty": true + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "userAccess": { + "emails": [] + } + } + }`), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{}, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_FATAL, + Message: "no users provided for validation", + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "userAccess": { + "emails": [] + } + }}`), + }, + }, + }, + }, + }, + "UsersRefNotFound": { + reason: "The Function should handle an error when usersRef cannot be resolved", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "UserValidation", + "usersRef": "context.nonexistent.value", + "target": "status.validatedUsers" + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(xr), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_FATAL, + Message: "cannot resolve usersRef: context.nonexistent.value not found", + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "metadata": { + "name": "cool-xr" + }, + "spec": { + "count": 2 + } + }`), + }, + }, + }, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + // Create mock responders for each type of query + mockQuery := &MockGraphQuery{ + GraphQueryFunc: func(_ context.Context, _ map[string]string, in *v1beta1.Input) (interface{}, error) { if in.QueryType == "UserValidation" { - if len(in.Users) == 0 { + if in.FailOnEmpty != nil && *in.FailOnEmpty && len(in.Users) == 0 { return nil, errors.New("no users provided for validation") } - var results []interface{} + results := make([]interface{}, 0) for _, user := range in.Users { if user == nil { continue @@ -1498,6 +1896,205 @@ func TestResolveServicePrincipalsRef(t *testing.T) { }, }, }, + "ServicePrincipalsRefEmptyDefault": { + reason: "The Function should resolve servicePrincipalsRef from XR spec", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "ServicePrincipalDetails", + "servicePrincipalsRef": "spec.servicePrincipalConfig.names", + "target": "status.servicePrincipals" + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "servicePrincipalConfig": { + "names": [] + } + } + }`), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{ + { + Type: "FunctionSuccess", + Status: fnv1.Status_STATUS_CONDITION_TRUE, + Reason: "Success", + Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), + }, + }, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_NORMAL, + Message: `QueryType: "ServicePrincipalDetails"`, + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "servicePrincipalConfig": { + "names": [] + } + }, + "status": { + "servicePrincipals": [] + }}`), + }, + }, + }, + }, + }, + "ServicePrincipalsRefEmptyNoFail": { + reason: "The Function should resolve servicePrincipalsRef from XR spec", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "ServicePrincipalDetails", + "servicePrincipalsRef": "spec.servicePrincipalConfig.names", + "target": "status.servicePrincipals", + "failOnEmpty": false + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "servicePrincipalConfig": { + "names": [] + } + } + }`), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{ + { + Type: "FunctionSuccess", + Status: fnv1.Status_STATUS_CONDITION_TRUE, + Reason: "Success", + Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), + }, + }, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_NORMAL, + Message: `QueryType: "ServicePrincipalDetails"`, + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "servicePrincipalConfig": { + "names": [] + } + }, + "status": { + "servicePrincipals": [] + }}`), + }, + }, + }, + }, + }, + "ServicePrincipalsRefEmptyFail": { + reason: "The Function should resolve servicePrincipalsRef from XR spec", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "ServicePrincipalDetails", + "servicePrincipalsRef": "spec.servicePrincipalConfig.names", + "target": "status.servicePrincipals", + "failOnEmpty": true + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "servicePrincipalConfig": { + "names": [] + } + } + }`), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{}, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_FATAL, + Message: "no service principal names provided", + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "servicePrincipalConfig": { + "names": [] + } + }}`), + }, + }, + }, + }, + }, "ServicePrincipalsRefNotFound": { reason: "The Function should handle an error when servicePrincipalsRef cannot be resolved", args: args{ @@ -1558,11 +2155,11 @@ func TestResolveServicePrincipalsRef(t *testing.T) { mockQuery := &MockGraphQuery{ GraphQueryFunc: func(_ context.Context, _ map[string]string, in *v1beta1.Input) (interface{}, error) { if in.QueryType == "ServicePrincipalDetails" { - if len(in.ServicePrincipals) == 0 { + if in.FailOnEmpty != nil && *in.FailOnEmpty && len(in.ServicePrincipals) == 0 { return nil, errors.New("no service principal names provided") } - var results []interface{} + results := make([]interface{}, 0) for i, sp := range in.ServicePrincipals { if sp == nil { continue diff --git a/input/v1beta1/input.go b/input/v1beta1/input.go index 41ccefc..aa659ed 100644 --- a/input/v1beta1/input.go +++ b/input/v1beta1/input.go @@ -67,6 +67,12 @@ type Input struct { // +optional SkipQueryWhenTargetHasData *bool `json:"skipQueryWhenTargetHasData,omitempty"` + // FailOnEmpty controls whether the function should fail when input lists are empty. + // If true, the function will error on empty input lists. + // If false or unset, empty lists are valid and will result in an empty list at the target. + // +optional + FailOnEmpty *bool `json:"failOnEmpty,omitempty"` + // Identity defines the type of identity used for authentication to the Microsoft Graph API. Identity *Identity `json:"identity,omitempty"` }