Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ 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.<field>` or `context.<field>` |
| `skipQueryWhenTargetHasData` | bool | Optional. When true, will skip the query if the target already has data |
| `skipQueryWhenRefMissing` | bool | Optional. When true, a `groupRef`, `groupsRef`, `usersRef` or `servicePrincipalsRef` whose path does not exist yet skips the query with a warning instead of failing the pipeline. The target is left untouched. Default is false. See [Skipping when a reference is not resolvable yet](#skipping-when-a-reference-is-not-resolvable-yet). |
| `queryInterval` | string | Optional. Minimum interval between queries as a Go duration string (e.g. `10m`, `1h`, `90s`). Skips querying Microsoft Graph until the interval has elapsed since the last successful query, independent of reconcile frequency. Only effective in Composition mode with a `status.` target. |
| `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. |
| `activeAccount` | bool | Optional. `UserValidation` only. When true, only users whose Entra ID `accountEnabled` attribute is true are stored at the target; disabled users, and users whose account state Graph does not report, are omitted. |
Expand Down Expand Up @@ -471,6 +472,39 @@ servicePrincipalsRef: "spec.servicePrincipalConfig.names" # Get service princip
target: "status.servicePrincipals"
```

### Skipping when a reference is not resolvable yet

By default, a reference whose path does not exist is a fatal error, which aborts
the whole Composition pipeline. That can deadlock an XR when the referenced field
is written later in the pipeline or on a later reconcile, for example when a
later step creates the Entra ID group and records its name in the XR status.

Set `skipQueryWhenRefMissing: true` to skip the query instead:

```yaml
apiVersion: msgraph.fn.crossplane.io/v1alpha1
kind: Input
queryType: GroupMembership
groupRef: "status.groups.owners.name" # Written by a later step once the group exists
target: "status.ownerMembers"
skipQueryWhenRefMissing: true
```

While the path is missing, the function does not call Microsoft Graph, leaves the
target untouched, passes the desired XR and context through unchanged, emits a
Warning result and sets a `FunctionSkip` condition with reason `RefNotResolved`.
The rest of the pipeline runs, and the query runs on the first reconcile where the
reference resolves. In Operation mode, a skipped run does not update the drift
annotations.

Only a missing path is skipped. These remain fatal even with the flag set:

- an empty or unsupported reference (for example one that does not start with
`spec.`, `status.` or `context.`)
- a path that exists but holds the wrong type (not a map, not a string array, or
a non-string value for `groupRef`)
- any Microsoft Graph error, including a group that does not exist

## Using data from EnvironmentConfig

To use data from a Crossplane `EnvironmentConfig`, first load the data into the context using [function-environment-configs](https://github.com/crossplane-contrib/function-environment-configs). This function should be in the pipeline before you use function-msgraph.
Expand Down
10 changes: 10 additions & 0 deletions example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,13 @@ crossplane render xr.yaml user-validation-example.yaml functions.yaml --function
```

> The example intentionally omits `skipQueryWhenTargetHasData`. Enabling `activeAccount` on an XR whose target already holds disabled users does not purge them while the query is being skipped, so clear the target once (or drop the skip for one reconcile) when adopting the flag on an existing deployment.

### 7. Skip When a Reference Is Missing

With `skipQueryWhenRefMissing: true`, a reference whose path does not exist yet skips the query instead of failing the pipeline. `xr.yaml` has no `status.ownersGroup.name`, so the render below emits a Warning result and a `FunctionSkip`/`RefNotResolved` condition, makes no Graph call and leaves `status.ownerMembers` unset:

```shell
crossplane render xr.yaml group-membership-example-skip-ref-missing.yaml functions.yaml --function-credentials=./secrets/azure-creds.yaml -r
```

Remove `skipQueryWhenRefMissing` from the example to see the default, fatal `cannot resolve groupRef: status.ownersGroup.name not found`.
28 changes: 28 additions & 0 deletions example/group-membership-example-skip-ref-missing.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: group-membership-example-skip-ref-missing
spec:
compositeTypeRef:
apiVersion: example.crossplane.io/v1
kind: XR
mode: Pipeline
pipeline:
- step: get-owner-members
functionRef:
name: function-msgraph
input:
apiVersion: msgraph.fn.crossplane.io/v1alpha1
kind: Input
queryType: GroupMembership
# Written to the XR status by a later step once the group exists.
groupRef: status.ownersGroup.name
target: "status.ownerMembers"
# Skip (with a warning) instead of failing while the reference is missing.
skipQueryWhenRefMissing: true
credentials:
- name: azure-creds
source: Secret
secretRef:
namespace: crossplane-system
name: azure-account-creds
108 changes: 70 additions & 38 deletions fn.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ const (
unknownType = "unknown"
)

// errRefNotFound marks a reference whose path does not exist. Its text keeps
// the existing "cannot resolve ...: <ref> not found" messages unchanged.
var errRefNotFound = errors.New("not found")

// graphFieldNameRE matches the shape of a Microsoft Graph property name,
// including OData extension attributes (extension_<appId>_<name>).
var graphFieldNameRE = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`)
Expand Down Expand Up @@ -1168,31 +1172,48 @@ func ParseNestedKey(key string) ([]string, error) {

// GetNestedKey retrieves a nested string value from a map using dot notation keys.
func GetNestedKey(context map[string]interface{}, key string) (string, bool) {
parts, err := ParseNestedKey(key)
value, err := lookupNestedValue(context, key)
if err != nil {
return "", false
}
result, ok := value.(string)
return result, ok
}

// lookupNestedValue returns the value at a nested key. It returns
// errRefNotFound only when a key along the path is absent.
func lookupNestedValue(root map[string]interface{}, key string) (interface{}, error) {
parts, err := ParseNestedKey(key)
if err != nil {
return nil, err
}

currentValue := interface{}(context)
currentValue := interface{}(root)
for _, k := range parts {
// Check if the current value is a map
if nestedMap, ok := currentValue.(map[string]interface{}); ok {
// Get the next value in the nested map
if nextValue, exists := nestedMap[k]; exists {
currentValue = nextValue
} else {
return "", false
}
} else {
return "", false
nestedMap, ok := currentValue.(map[string]interface{})
if !ok {
return nil, errors.Errorf("%s is not a map", k)
}
nextValue, exists := nestedMap[k]
if !exists {
return nil, errRefNotFound
}
currentValue = nextValue
}
return currentValue, nil
}

// Convert the final value to a string
if result, ok := currentValue.(string); ok {
return result, true
// resolveGroupRefValue resolves a groupRef string value from a map.
func resolveGroupRefValue(data map[string]interface{}, field, refKey string) (string, error) {
value, err := lookupNestedValue(data, field)
if errors.Is(err, errRefNotFound) {
return "", errors.Errorf("cannot resolve groupRef: %s %w", refKey, errRefNotFound)
}
return "", false
result, ok := value.(string)
if err != nil || !ok {
return "", errors.Errorf("cannot resolve groupRef: %s not found", refKey)
}
return result, nil
}

// SetNestedKey sets a value to a nested key from a map using dot notation keys.
Expand Down Expand Up @@ -1531,7 +1552,7 @@ func (f *Function) processGroupRef(req *fnv1.RunFunctionRequest, in *v1beta1.Inp

groupName, err := f.resolveGroupRef(req, in.GroupRef, inOperation)
if err != nil {
response.Fatal(rsp, err)
f.failOrSkipOnRef(in, rsp, "groupRef", *in.GroupRef, err)
return false
}
in.Group = &groupName
Expand All @@ -1547,7 +1568,7 @@ func (f *Function) processGroupsRef(req *fnv1.RunFunctionRequest, in *v1beta1.In

groupNames, err := f.resolveGroupsRef(req, in.GroupsRef, inOperation)
if err != nil {
response.Fatal(rsp, err)
f.failOrSkipOnRef(in, rsp, "groupsRef", *in.GroupsRef, err)
return false
}
in.Groups = groupNames
Expand All @@ -1563,7 +1584,7 @@ func (f *Function) processUsersRef(req *fnv1.RunFunctionRequest, in *v1beta1.Inp

userNames, err := f.resolveUsersRef(req, in.UsersRef, inOperation)
if err != nil {
response.Fatal(rsp, err)
f.failOrSkipOnRef(in, rsp, "usersRef", *in.UsersRef, err)
return false
}
in.Users = userNames
Expand All @@ -1579,14 +1600,33 @@ func (f *Function) processServicePrincipalsRef(req *fnv1.RunFunctionRequest, in

spNames, err := f.resolveServicePrincipalsRef(req, in.ServicePrincipalsRef, inOperation)
if err != nil {
response.Fatal(rsp, err)
f.failOrSkipOnRef(in, rsp, "servicePrincipalsRef", *in.ServicePrincipalsRef, err)
return false
}
in.ServicePrincipals = spNames
f.log.Info("Resolved ServicePrincipalsRef to service principals", "spCount", len(spNames), "servicePrincipalsRef", *in.ServicePrincipalsRef)
return true
}

// failOrSkipOnRef reports a reference resolution error. With
// skipQueryWhenRefMissing set, a missing reference path skips the query with a
// warning instead of failing; every other error remains fatal.
func (f *Function) failOrSkipOnRef(in *v1beta1.Input, rsp *fnv1.RunFunctionResponse, refType, refKey string, err error) {
if !ptr.Deref(in.SkipQueryWhenRefMissing, false) || !errors.Is(err, errRefNotFound) {
response.Fatal(rsp, err)
return
}

f.log.Info("Reference not resolvable yet, skipping query", refType, refKey)
response.Warning(rsp, errors.Errorf("%s; skipping query (skipQueryWhenRefMissing)", err.Error())).
TargetCompositeAndClaim()
response.ConditionTrue(rsp, "FunctionSkip", "RefNotResolved").
WithMessage(fmt.Sprintf("Query skipped: %s %s could not be resolved yet", refType, refKey)).
TargetCompositeAndClaim()
response.ConditionTrue(rsp, "FunctionSuccess", "Success").
TargetCompositeAndClaim()
}

// executeAndProcessQuery executes the query and processes the results
func (f *Function) executeAndProcessQuery(ctx context.Context, req *fnv1.RunFunctionRequest, in *v1beta1.Input, azureCreds map[string]string, rsp *fnv1.RunFunctionResponse, inOperation bool) bool {
// Execute the query
Expand Down Expand Up @@ -1818,22 +1858,14 @@ func (f *Function) resolveFromStatus(req *fnv1.RunFunctionRequest, refKey string
}

statusField := strings.TrimPrefix(refKey, "status.")
value, ok := GetNestedKey(xrStatus, statusField)
if !ok {
return "", errors.Errorf("cannot resolve groupRef: %s not found", refKey)
}
return value, nil
return resolveGroupRefValue(xrStatus, statusField, refKey)
}

// resolveFromContext resolves a reference from function context
func (f *Function) resolveFromContext(req *fnv1.RunFunctionRequest, refKey string) (string, error) {
contextMap := req.GetContext().AsMap()
contextField := strings.TrimPrefix(refKey, "context.")
value, ok := GetNestedKey(contextMap, contextField)
if !ok {
return "", errors.Errorf("cannot resolve groupRef: %s not found", refKey)
}
return value, nil
return resolveGroupRefValue(contextMap, contextField, refKey)
}

// resolveFromSpec resolves a reference from XR spec
Expand All @@ -1852,11 +1884,7 @@ func (f *Function) resolveFromSpec(req *fnv1.RunFunctionRequest, refKey string,
}

specField := strings.TrimPrefix(refKey, "spec.")
value, ok := GetNestedKey(xrSpec, specField)
if !ok {
return "", errors.Errorf("cannot resolve groupRef: %s not found", refKey)
}
return value, nil
return resolveGroupRefValue(xrSpec, specField, refKey)
}

// resolveStringArrayRef resolves a list of string values from a reference in spec, status or context
Expand Down Expand Up @@ -1887,8 +1915,12 @@ func (f *Function) resolveStringArrayRef(req *fnv1.RunFunctionRequest, ref *stri
// If we got an error and it contains "groupsRef" but we're looking for a different ref type,
// replace it with the correct ref type
if err != nil && refType != "groupsRef" && strings.Contains(err.Error(), "groupsRef") {
errMsg := err.Error()
return nil, errors.New(strings.ReplaceAll(errMsg, "groupsRef", refType))
errMsg := strings.ReplaceAll(err.Error(), "groupsRef", refType)
if errors.Is(err, errRefNotFound) {
// Re-wrap so the sentinel survives the rewrite; the message is unchanged.
return nil, errors.Errorf("%s%w", strings.TrimSuffix(errMsg, errRefNotFound.Error()), errRefNotFound)
}
return nil, errors.New(errMsg)
}

return result, err
Expand Down Expand Up @@ -1958,7 +1990,7 @@ func (f *Function) extractStringArrayFromMap(dataMap map[string]interface{}, fie
if nextValue, exists := nestedMap[k]; exists {
currentValue = nextValue
} else {
return nil, errors.Errorf("cannot resolve groupsRef: %s not found", refKey)
return nil, errors.Errorf("cannot resolve groupsRef: %s %w", refKey, errRefNotFound)
}
} else {
return nil, errors.Errorf("cannot resolve groupsRef: %s not a map", refKey)
Expand Down
Loading