Skip to content

Commit

Permalink
Add readiness check for resources
Browse files Browse the repository at this point in the history
This feature ensures users are allowed to define when a
resource is ready or not.

For now this feature only allows to look within itself to see
whether it's ready or not, so only its spec and status can be
accessed using CEL fields.

An example with deployement-service RG will be included in this PR
  • Loading branch information
michaelhtm committed Oct 16, 2024
1 parent 465b437 commit 556dbef
Show file tree
Hide file tree
Showing 12 changed files with 307 additions and 9 deletions.
2 changes: 2 additions & 0 deletions api/v1alpha1/resource_group.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ type Resource struct {
Name string `json:"name,omitempty"`
// +kubebuilder:validation:Required
Definition runtime.RawExtension `json:"definition,omitempty"`
// +kubebuilder:validation:Optional
ReadyOn []string `json:"readyOn,omitempty"`
}

// ResourceGroupStatus defines the observed state of ResourceGroup
Expand Down
5 changes: 5 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions config/crd/bases/x.symphony.k8s.aws_resourcegroups.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ spec:
x-kubernetes-preserve-unknown-fields: true
name:
type: string
readyOn:
items:
type: string
x-kubernetes-preserve-unknown-fields: true
type: array
required:
- definition
- name
Expand Down
4 changes: 3 additions & 1 deletion examples/deployment-service/deployment-service.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ spec:
availableReplicas: ${deployment.status.availableReplicas}
resources:
- name: deployment
readyOn:
- ${spec.replicas == status.availableReplicas}
definition:
apiVersion: apps/v1
kind: Deployment
metadata:
name: ${spec.name}
spec:
replicas: 1
replicas: 50
selector:
matchLabels:
app: deployment
Expand Down
4 changes: 4 additions & 0 deletions internal/celutil/conversions.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,7 @@ func ConvertCELtoGo(v ref.Val) (interface{}, error) {
return v.Value(), fmt.Errorf("unsupported type: %v", v.Type())
}
}

func IsBoolType(v ref.Val) bool {
return v.Type() == types.BoolType
}
21 changes: 21 additions & 0 deletions internal/controller/instance/controller_reconcile.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,18 @@ func (igr *InstanceGraphReconciler) reconcileResource(ctx context.Context, resou
return resourceState.Err
}

log.V(1).Info("Checking if resource is Ready", "resource", resourceID)
if ready, err := igr.runtime.IsResourceReady(resourceID); err != nil {
log.V(1).Info("Resource not ready", "resource", resourceID)
resourceState.State = "CREATING"
resourceState.Err = fmt.Errorf("resource not ready: %w", err)
return igr.delayedRequeue(resourceState.Err)
} else if !ready {
log.V(1).Info("Resource not ready", "resource", resourceID)
resourceState.State = "CREATING"
resourceState.Err = fmt.Errorf("resource not ready")
return igr.delayedRequeue(resourceState.Err)
}
igr.runtime.SetLatestResource(resourceID, observed)
return igr.updateResource(ctx, rc, rUnstructured, observed, resourceID, resourceState)
}
Expand Down Expand Up @@ -316,6 +328,15 @@ func (igr *InstanceGraphReconciler) handleInstanceDeletion(ctx context.Context,
continue
}
if resourceStates[resourceID].State == "PENDING_DELETION" {
// we need to:
// - get resource
// - if not found
// continue
// - if found:
// if metadata.deletionTimestamp == nil {
// deleteResource{}
// }
// requeue
if err := igr.deleteResource(ctx, resourceID, resourceStates); err != nil {
return err
}
Expand Down
69 changes: 61 additions & 8 deletions internal/resourcegroup/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,17 +174,28 @@ func (b *GraphBuilder) NewResourceGroup(rg *v1alpha1.ResourceGroup) (*ResourceGr
})
}
}
// 6. Parse ReadyOn expressions
readyOn, err := parser.ParseReadyOn(rgResource.ReadyOn)
if err != nil {
return nil, fmt.Errorf("failed to parse readyOn expressions: %v", err)
}

readyOnExpressions := []ReadyOnExpression{}
for _, e := range readyOn {
readyOnExpressions = append(readyOnExpressions, ReadyOnExpression{Resolved: false, Expression: e})
}

_, isNamespaced := namespacedResources[gvk]

resources[rgResource.Name] = &Resource{
ID: rgResource.Name,
GroupVersionKind: gvk,
Schema: resourceSchema,
EmulatedObject: emulatedResource,
OriginalObject: unstructuredResource,
Variables: resourceVariables,
Namespaced: isNamespaced,
ID: rgResource.Name,
GroupVersionKind: gvk,
Schema: resourceSchema,
EmulatedObject: emulatedResource,
OriginalObject: unstructuredResource,
Variables: resourceVariables,
ReadyOnExpressions: readyOnExpressions,
Namespaced: isNamespaced,
}
}

Expand Down Expand Up @@ -335,6 +346,48 @@ func (b *GraphBuilder) validateResourceCELExpressions(resources map[string]*Reso
return fmt.Errorf("failed to dry-run expression %s: %w", expression, err)
}
}
// validate readyOn Expressions for resource
// Only accepting expressions accessing the status and spec for now
// and need to evaluate to a boolean type
//
// TODO(michaelhtm) It shares some of the logic with the loop from above..maybe
// we can refactor them or put it in one function.
// I would also suggest seperating the dryRuns of readyOnExpressions

Check failure on line 355 in internal/resourcegroup/builder.go

View workflow job for this annotation

GitHub Actions / lint

`seperating` is a misspelling of `separating` (misspell)
// and the resourceExpressions.
for _, expression := range resource.ReadyOnExpressions {
fieldNames := getResourceTopLevelFieldNames(resource.Schema)
fieldEnv, err := celutil.NewEnvironement(celutil.WithResourceNames(fieldNames))
if err != nil {
return fmt.Errorf("failed to create CEL environment: %w", err)
}

err = b.validateCELExpressionContext(fieldEnv, expression.Expression, fieldNames)
if err != nil {
return fmt.Errorf("failed to validate expression context: '%s' %w", expression.Expression, err)
}
// create context
// add resource fields to the context
context := map[string]*Resource{}
for _, n := range fieldNames {
context[n] = &Resource{
ID: n,
GroupVersionKind: resource.GroupVersionKind,
Schema: resource.Schema,
EmulatedObject: &unstructured.Unstructured{
Object: resource.EmulatedObject.Object[n].(map[string]interface{}),
},
}
}

output, err := b.dryRunExpression(fieldEnv, expression.Expression, context)

if err != nil {
return fmt.Errorf("failed to dry-run expression %s: %w", expression.Expression, err)
}
if !celutil.IsBoolType(output) {
return fmt.Errorf("output of readyOn expression %s can only be of type bool", expression.Expression)
}
}
}
}

Expand Down Expand Up @@ -501,7 +554,7 @@ func (b *GraphBuilder) buildInstanceResource(
instanceVariables := []*ResourceVariable{}
for _, statusVariable := range statusVariables {
// These variables needs to be injected into the status field of the instance.
path := ".status" + statusVariable.Path
path := ">status" + statusVariable.Path
statusVariable.Path = path
instanceVariables = append(instanceVariables, &ResourceVariable{
Kind: ResourceVariableKindDynamic,
Expand Down
40 changes: 40 additions & 0 deletions internal/resourcegroup/ready_on_expression.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License"). You may
// not use this file except in compliance with the License. A copy of the
// License is located at
//
// http://aws.amazon.com/apache2.0/
//
// or in the "license" file accompanying this file. This file 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 resourcegroup

import (
"slices"

"k8s.io/kube-openapi/pkg/validation/spec"
)


type ReadyOnExpression struct {
Resolved bool
Expression string
}

func getResourceTopLevelFieldNames(schema *spec.Schema) []string {

Check failure on line 28 in internal/resourcegroup/ready_on_expression.go

View workflow job for this annotation

GitHub Actions / lint

getResourceTopLevelFieldNames - result 0 ([]string) is always nil (unparam)

fieldNames := []string{}

for k, _ := range schema.Properties {
if k != "apiVersion" && k != "kind" {
fieldNames = append(fieldNames, k)
}
}

slices.Sort(fieldNames)
return nil
}
2 changes: 2 additions & 0 deletions internal/resourcegroup/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ type Resource struct {

Variables []*ResourceVariable

ReadyOnExpressions []ReadyOnExpression

Dependencies []string
Namespaced bool
}
Expand Down
56 changes: 56 additions & 0 deletions internal/resourcegroup/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,65 @@ func (rt *RuntimeResourceGroup) CanResolveResource(resource string) bool {
return false
}
}

return true
}

func (rt *RuntimeResourceGroup) IsResourceReady(resourceID string) (bool, error) {

expressions := rt.ResourceGroup.Resources[resourceID].ReadyOnExpressions
resource := rt.ResolvedResources[resourceID]
fieldNames := getResourceTopLevelFieldNames(rt.ResourceGroup.Resources[resourceID].Schema)
if len(expressions) == 0 {
return true, nil
}

// fieldNames := []string{"status", "spec"}
env, err := celutil.NewEnvironement(&celutil.EnvironementOptions{
ResourceNames: fieldNames,
})
// we should not expect errors here since we already compiled it
// in the dryRun
if err != nil {
return false, fmt.Errorf("failed creating new Environment: %w",err)
}
context := map[string]interface{}{}

for _, n := range fieldNames {
if obj, ok := resource.Object[n]; ok {
context[n] = obj.(map[string]interface{})
}
}
for _, e := range expressions {
if e.Resolved {
continue
}
ast, issues := env.Compile(e.Expression)
if issues != nil && issues.Err() != nil {
return false, fmt.Errorf("failed compiling expression %s: %w", e.Expression, err)
}
program, err := env.Program(ast)
if err != nil {
return false, fmt.Errorf("failed programming expression %s: %w", e.Expression, err)
}

output, _, err := program.Eval(context)
if err != nil {
return false, fmt.Errorf("failed evaluating expression %s: %w", e.Expression, err)
}
out, err := celutil.ConvertCELtoGo(output)
if err != nil {
return false, fmt.Errorf("failed converting output %w: %w", output, err)

Check failure on line 155 in internal/resourcegroup/runtime.go

View workflow job for this annotation

GitHub Actions / lint

printf: fmt.Errorf format %w has arg output of wrong type github.com/google/cel-go/common/types/ref.Val (govet)

Check failure on line 155 in internal/resourcegroup/runtime.go

View workflow job for this annotation

GitHub Actions / build (1.22)

fmt.Errorf format %w has arg output of wrong type github.com/google/cel-go/common/types/ref.Val
}
// keep checking for a false
if !out.(bool) {
return false, nil
}
e.Resolved = out.(bool)
}
return true, err
}

func (rt *RuntimeResourceGroup) SetLatestResource(name string, resource *unstructured.Unstructured) {
rt.ResolvedResources[name] = resource
}
Expand Down
38 changes: 38 additions & 0 deletions internal/typesystem/parser/conditions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License"). You may
// not use this file except in compliance with the License. A copy of the
// License is located at
//
// http://aws.amazon.com/apache2.0/
//
// or in the "license" file accompanying this file. This file 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 parser

import (
"fmt"
"strings"
)


func ParseReadyOn(readyOn []string) ([]string, error) {

expressions := make([]string, 0, len(readyOn))

for _, e := range readyOn {
ok, err := isOneShotExpression(e)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("single expression per line allowed")
}
expressions = append(expressions, strings.Trim(e, "${}"))
}

return expressions, nil
}
Loading

0 comments on commit 556dbef

Please sign in to comment.