diff --git a/api/v1beta2/appwrapper_types.go b/api/v1beta2/appwrapper_types.go index b6ec126..0fc328a 100644 --- a/api/v1beta2/appwrapper_types.go +++ b/api/v1beta2/appwrapper_types.go @@ -105,6 +105,34 @@ type AppWrapperStatus struct { //+listType=map //+listMapKey=type Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` + + // ComponentStatus parallels the Components array in the Spec and tracks the actually deployed resources + ComponentStatus []AppWrapperComponentStatus `json:"componentStatus,omitempty"` +} + +// AppWrapperComponentStatus tracks the status of a single managed Component +type AppWrapperComponentStatus struct { + // Name is the name of the Component + Name string `json:"name"` + + // Kind is the Kind of the Component + Kind string `json:"kind"` + + // APIVersion is the APIVersion of the Component + APIVersion string `json:"apiVersion"` + + // Conditions hold the latest available observations of the Component's current state. + // + // The type of the condition could be: + // + // - ResourcesDeployed: The component is deployed on the cluster + // + //+optional + //+patchMergeKey=type + //+patchStrategy=merge + //+listType=map + //+listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` } // AppWrapperPhase is the phase of the appwrapper diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go index 776ad88..74cf26f 100644 --- a/api/v1beta2/zz_generated.deepcopy.go +++ b/api/v1beta2/zz_generated.deepcopy.go @@ -90,6 +90,28 @@ func (in *AppWrapperComponent) DeepCopy() *AppWrapperComponent { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AppWrapperComponentStatus) DeepCopyInto(out *AppWrapperComponentStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppWrapperComponentStatus. +func (in *AppWrapperComponentStatus) DeepCopy() *AppWrapperComponentStatus { + if in == nil { + return nil + } + out := new(AppWrapperComponentStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AppWrapperList) DeepCopyInto(out *AppWrapperList) { *out = *in @@ -217,6 +239,13 @@ func (in *AppWrapperStatus) DeepCopyInto(out *AppWrapperStatus) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.ComponentStatus != nil { + in, out := &in.ComponentStatus, &out.ComponentStatus + *out = make([]AppWrapperComponentStatus, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppWrapperStatus. diff --git a/config/crd/bases/workload.codeflare.dev_appwrappers.yaml b/config/crd/bases/workload.codeflare.dev_appwrappers.yaml index ca331c8..f126c37 100644 --- a/config/crd/bases/workload.codeflare.dev_appwrappers.yaml +++ b/config/crd/bases/workload.codeflare.dev_appwrappers.yaml @@ -165,6 +165,111 @@ spec: status: description: AppWrapperStatus defines the observed state of the appwrapper properties: + componentStatus: + description: ComponentStatus parallels the Components array in the + Spec and tracks the actually deployed resources + items: + description: AppWrapperComponentStatus tracks the status of a single + managed Component + properties: + apiVersion: + description: APIVersion is the APIVersion of the Component + type: string + conditions: + description: |- + Conditions hold the latest available observations of the Component's current state. + + + The type of the condition could be: + + + - ResourcesDeployed: The component is deployed on the cluster + items: + description: "Condition contains details for one aspect of + the current state of this API Resource.\n---\nThis struct + is intended for direct use as an array at the field path + .status.conditions. For example,\n\n\n\ttype FooStatus + struct{\n\t // Represents the observations of a foo's + current state.\n\t // Known .status.conditions.type are: + \"Available\", \"Progressing\", and \"Degraded\"\n\t // + +patchMergeKey=type\n\t // +patchStrategy=merge\n\t // + +listType=map\n\t // +listMapKey=type\n\t Conditions + []metav1.Condition `json:\"conditions,omitempty\" patchStrategy:\"merge\" + patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"`\n\n\n\t + \ // other fields\n\t}" + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, + Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: |- + type of condition in CamelCase or in foo.example.com/CamelCase. + --- + Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be + useful (see .node.status.conditions), the ability to deconflict is important. + The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + kind: + description: Kind is the Kind of the Component + type: string + name: + description: Name is the name of the Component + type: string + required: + - apiVersion + - kind + - name + type: object + type: array conditions: description: |- Conditions hold the latest available observations of the AppWrapper current state. diff --git a/internal/controller/appwrapper/appwrapper_controller.go b/internal/controller/appwrapper/appwrapper_controller.go index dbee642..41f1515 100644 --- a/internal/controller/appwrapper/appwrapper_controller.go +++ b/internal/controller/appwrapper/appwrapper_controller.go @@ -144,6 +144,7 @@ func (r *AppWrapperReconciler) Reconcile(ctx context.Context, req ctrl.Request) return ctrl.Result{}, err } } + aw.Status.ComponentStatus = make([]workloadv1beta2.AppWrapperComponentStatus, len(aw.Spec.Components)) return r.updateStatus(ctx, aw, workloadv1beta2.AppWrapperSuspended) case workloadv1beta2.AppWrapperSuspended: // no components deployed diff --git a/internal/controller/appwrapper/resource_management.go b/internal/controller/appwrapper/resource_management.go index 84bded8..c8a5006 100644 --- a/internal/controller/appwrapper/resource_management.go +++ b/internal/controller/appwrapper/resource_management.go @@ -179,42 +179,67 @@ func (r *AppWrapperReconciler) createComponent(ctx context.Context, aw *workload func (r *AppWrapperReconciler) createComponents(ctx context.Context, aw *workloadv1beta2.AppWrapper) (error, bool) { for componentIdx := range aw.Spec.Components { - _, err, fatal := r.createComponent(ctx, aw, componentIdx) - if err != nil { - return err, fatal + if !meta.IsStatusConditionTrue(aw.Status.ComponentStatus[componentIdx].Conditions, string(workloadv1beta2.ResourcesDeployed)) { + obj, err, fatal := r.createComponent(ctx, aw, componentIdx) + if err != nil { + return err, fatal + } + aw.Status.ComponentStatus[componentIdx].Name = obj.GetName() + aw.Status.ComponentStatus[componentIdx].Kind = obj.GetKind() + aw.Status.ComponentStatus[componentIdx].APIVersion = obj.GetAPIVersion() + meta.SetStatusCondition(&aw.Status.ComponentStatus[componentIdx].Conditions, metav1.Condition{ + Type: string(workloadv1beta2.ResourcesDeployed), + Status: metav1.ConditionTrue, + Reason: "CompononetCreated", + }) } } return nil, false } func (r *AppWrapperReconciler) deleteComponents(ctx context.Context, aw *workloadv1beta2.AppWrapper) bool { + deleteIfPresent := func(idx int, opts ...client.DeleteOption) bool { + cs := &aw.Status.ComponentStatus[idx] + if !meta.IsStatusConditionTrue(cs.Conditions, string(workloadv1beta2.ResourcesDeployed)) { + return false // not present + } + obj := &metav1.PartialObjectMetadata{ + TypeMeta: metav1.TypeMeta{Kind: cs.Kind, APIVersion: cs.APIVersion}, + ObjectMeta: metav1.ObjectMeta{Name: cs.Name, Namespace: aw.Namespace}, + } + if err := r.Delete(ctx, obj, opts...); err != nil { + if apierrors.IsNotFound(err) { + // Has already been undeployed; update componentStatus and return not present + meta.SetStatusCondition(&cs.Conditions, metav1.Condition{ + Type: string(workloadv1beta2.ResourcesDeployed), + Status: metav1.ConditionFalse, + Reason: "CompononetDeleted", + }) + return false + } else { + log.FromContext(ctx).Error(err, "Deletion error") + return true // unexpected error ==> still present + } + } + return true // still present + } + meta.SetStatusCondition(&aw.Status.Conditions, metav1.Condition{ Type: string(workloadv1beta2.DeletingResources), Status: metav1.ConditionTrue, Reason: "DeletionInitiated", }) - log := log.FromContext(ctx) - remaining := 0 - for _, component := range aw.Spec.Components { - obj, err := parseComponent(aw, component.Template.Raw) - if err != nil { - log.Error(err, "Parsing error") - continue - } - if err := r.Delete(ctx, obj, client.PropagationPolicy(metav1.DeletePropagationBackground)); err != nil { - if !apierrors.IsNotFound(err) { - log.Error(err, "Deletion error") - } - continue - } - remaining++ // no error deleting resource, resource therefore still exists + + componentsRemaining := false + for componentIdx := range aw.Spec.Components { + componentsRemaining = deleteIfPresent(componentIdx, client.PropagationPolicy(metav1.DeletePropagationBackground)) || componentsRemaining } deletionGracePeriod := r.forcefulDeletionGraceDuration(ctx, aw) whenInitiated := meta.FindStatusCondition(aw.Status.Conditions, string(workloadv1beta2.DeletingResources)).LastTransitionTime gracePeriodExpired := time.Now().After(whenInitiated.Time.Add(deletionGracePeriod)) - if remaining > 0 && !gracePeriodExpired { + if componentsRemaining && !gracePeriodExpired { // Resources left and deadline hasn't expired, just requeue the deletion return false } @@ -224,10 +249,10 @@ func (r *AppWrapperReconciler) deleteComponents(ctx context.Context, aw *workloa client.UnsafeDisableDeepCopy, client.InNamespace(aw.Namespace), client.MatchingLabels{AppWrapperLabel: aw.Name}); err != nil { - log.Error(err, "Pod list error") + log.FromContext(ctx).Error(err, "Pod list error") } - if remaining == 0 && len(pods.Items) == 0 { + if !componentsRemaining && len(pods.Items) == 0 { // no resources or pods left; deletion is complete clearCondition(aw, workloadv1beta2.DeletingResources, "DeletionComplete", "") return true @@ -238,20 +263,13 @@ func (r *AppWrapperReconciler) deleteComponents(ctx context.Context, aw *workloa // force deletion of pods first for _, pod := range pods.Items { if err := r.Delete(ctx, &pod, client.GracePeriodSeconds(0)); err != nil { - log.Error(err, "Forceful pod deletion error") + log.FromContext(ctx).Error(err, "Forceful pod deletion error") } } } else { // force deletion of wrapped resources once pods are gone - for _, component := range aw.Spec.Components { - obj, err := parseComponent(aw, component.Template.Raw) - if err != nil { - log.Error(err, "Parsing error") - continue - } - if err := r.Delete(ctx, obj, client.GracePeriodSeconds(0)); err != nil && !apierrors.IsNotFound(err) { - log.Error(err, "Forceful deletion error") - } + for componentIdx := range aw.Spec.Components { + _ = deleteIfPresent(componentIdx, client.GracePeriodSeconds(0)) } } }