mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-13 08:05:19 +09:00
cozystack-api writes healthCheckExprs and the resolved wait strategy from the ApplicationDefinition only when it builds a HelmRelease, and the definition reconciler that keeps existing releases in step copied neither. A release created before its definition exempted an object kept waiting on that object at every upgrade. For vm-disk that is every disk created before the DataVolume expression was added: a disk whose import is still running after a platform upgrade fails the upgrade at the timeout and, under RetryOnFailure, retries forever while holding a helm-controller worker. The reconciler now derives both fields the way the API does, in both directions, so an existing release ends up with what a fresh write would produce. On the shipped definitions this only relaxes waits: the vm-disk DataVolume and the postgres KEDA ScaledObject. A release that another chart renders carries Flux's origin label and keeps the gate its chart sets. A failed update used to be logged and dropped, and nothing else re-enqueues the definition for hours, so that release stayed out of sync. The errors are now returned and the definition is retried. Fixes https://github.com/cozystack/cozystack/issues/4409 Assisted-by: LLM Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
307 lines
12 KiB
Go
307 lines
12 KiB
Go
package controller
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
|
|
"github.com/cozystack/cozystack/internal/shared/appdefowner"
|
|
"github.com/cozystack/cozystack/pkg/config"
|
|
helmv2 "github.com/fluxcd/helm-controller/api/v2"
|
|
|
|
corev1 "k8s.io/api/core/v1"
|
|
"k8s.io/apimachinery/pkg/api/equality"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
"k8s.io/client-go/tools/record"
|
|
|
|
ctrl "sigs.k8s.io/controller-runtime"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
"sigs.k8s.io/controller-runtime/pkg/handler"
|
|
"sigs.k8s.io/controller-runtime/pkg/log"
|
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
|
)
|
|
|
|
// +kubebuilder:rbac:groups=cozystack.io,resources=applicationdefinitions,verbs=get;list;watch
|
|
// +kubebuilder:rbac:groups=helm.toolkit.fluxcd.io,resources=helmreleases,verbs=get;list;watch;update;patch
|
|
|
|
// ApplicationDefinitionHelmReconciler reconciles ApplicationDefinitions
|
|
// and updates related HelmReleases when an ApplicationDefinition changes.
|
|
// This controller does NOT watch HelmReleases to avoid mutual reconciliation storms
|
|
// with Flux's helm-controller.
|
|
type ApplicationDefinitionHelmReconciler struct {
|
|
client.Client
|
|
Scheme *runtime.Scheme
|
|
Recorder record.EventRecorder
|
|
}
|
|
|
|
func (r *ApplicationDefinitionHelmReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
|
logger := log.FromContext(ctx)
|
|
|
|
// Get the ApplicationDefinition that triggered this reconciliation
|
|
appDef := &cozyv1alpha1.ApplicationDefinition{}
|
|
if err := r.Get(ctx, req.NamespacedName, appDef); err != nil {
|
|
logger.Error(err, "failed to get ApplicationDefinition", "name", req.Name)
|
|
return ctrl.Result{}, client.IgnoreNotFound(err)
|
|
}
|
|
|
|
// Only the definition that owns its kind may rewrite that kind's releases.
|
|
// HelmReleases are selected by kind label alone, so without this a second
|
|
// definition declaring the same kind would repoint every release of it at
|
|
// its own artifact.
|
|
if kind := appDef.Spec.Application.Kind; kind != "" {
|
|
defs := &cozyv1alpha1.ApplicationDefinitionList{}
|
|
if err := r.List(ctx, defs); err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
if owner := appdefowner.Owners(defs.Items)[kind]; owner != appDef.Name {
|
|
logger.Info("skipping HelmRelease update: application kind is owned by another definition", "appDef", appDef.Name, "kind", kind, "owner", owner)
|
|
if r.Recorder != nil {
|
|
r.Recorder.Eventf(appDef, corev1.EventTypeWarning, "KindClaimed",
|
|
"application kind %s is already owned by ApplicationDefinition %s; this definition does not manage its releases", kind, owner)
|
|
}
|
|
return ctrl.Result{}, nil
|
|
}
|
|
}
|
|
|
|
// Update HelmReleases related to this specific ApplicationDefinition
|
|
if err := r.updateHelmReleasesForAppDef(ctx, appDef); err != nil {
|
|
logger.Error(err, "failed to update HelmReleases for ApplicationDefinition", "appDef", appDef.Name)
|
|
return ctrl.Result{}, err
|
|
}
|
|
|
|
return ctrl.Result{}, nil
|
|
}
|
|
|
|
func (r *ApplicationDefinitionHelmReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
|
return ctrl.NewControllerManagedBy(mgr).
|
|
Named("applicationdefinition-helm-reconciler").
|
|
Watches(&cozyv1alpha1.ApplicationDefinition{}, handler.EnqueueRequestsFromMapFunc(r.sameKindRequests)).
|
|
Complete(r)
|
|
}
|
|
|
|
// sameKindRequests enqueues every definition declaring the same kind as obj,
|
|
// obj included, so that when the owner of a kind is deleted or changes kind the
|
|
// next owner reconciles its releases without waiting for a change of its own.
|
|
func (r *ApplicationDefinitionHelmReconciler) sameKindRequests(ctx context.Context, obj client.Object) []reconcile.Request {
|
|
reqs := []reconcile.Request{{NamespacedName: types.NamespacedName{Name: obj.GetName()}}}
|
|
appDef, ok := obj.(*cozyv1alpha1.ApplicationDefinition)
|
|
if !ok || appDef.Spec.Application.Kind == "" {
|
|
return reqs
|
|
}
|
|
defs := &cozyv1alpha1.ApplicationDefinitionList{}
|
|
if err := r.List(ctx, defs); err != nil {
|
|
log.FromContext(ctx).Error(err, "failed to list ApplicationDefinitions for kind fan-out", "kind", appDef.Spec.Application.Kind)
|
|
return reqs
|
|
}
|
|
for i := range defs.Items {
|
|
if defs.Items[i].Name != obj.GetName() && defs.Items[i].Spec.Application.Kind == appDef.Spec.Application.Kind {
|
|
reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Name: defs.Items[i].Name}})
|
|
}
|
|
}
|
|
return reqs
|
|
}
|
|
|
|
// updateHelmReleasesForAppDef updates all HelmReleases that match the application labels from ApplicationDefinition
|
|
func (r *ApplicationDefinitionHelmReconciler) updateHelmReleasesForAppDef(ctx context.Context, appDef *cozyv1alpha1.ApplicationDefinition) error {
|
|
logger := log.FromContext(ctx)
|
|
|
|
// Use application labels to find HelmReleases
|
|
// Labels: apps.cozystack.io/application.kind and apps.cozystack.io/application.group
|
|
applicationKind := appDef.Spec.Application.Kind
|
|
|
|
// Validate that applicationKind is non-empty
|
|
if applicationKind == "" {
|
|
logger.V(4).Info("Skipping HelmRelease update: Application.Kind is empty", "appDef", appDef.Name)
|
|
return nil
|
|
}
|
|
|
|
applicationGroup := "apps.cozystack.io" // All applications use this group
|
|
|
|
// Build label selector for HelmReleases
|
|
// Only reconcile HelmReleases with apps.cozystack.io/application.* labels
|
|
labelSelector := client.MatchingLabels{
|
|
"apps.cozystack.io/application.kind": applicationKind,
|
|
"apps.cozystack.io/application.group": applicationGroup,
|
|
}
|
|
|
|
// List all HelmReleases with matching labels
|
|
hrList := &helmv2.HelmReleaseList{}
|
|
if err := r.List(ctx, hrList, labelSelector); err != nil {
|
|
logger.Error(err, "failed to list HelmReleases", "kind", applicationKind, "group", applicationGroup)
|
|
return err
|
|
}
|
|
|
|
logger.V(4).Info("Found HelmReleases to update", "appDef", appDef.Name, "kind", applicationKind, "count", len(hrList.Items))
|
|
|
|
// Update each HelmRelease. Only definition events and the manager's
|
|
// hours-long resync enqueue this reconciler, so a release whose update
|
|
// failed (a conflict with helm-controller writing it, say) would stay out
|
|
// of sync until then unless the failure is returned and retried.
|
|
var errs []error
|
|
for i := range hrList.Items {
|
|
hr := &hrList.Items[i]
|
|
if err := r.updateHelmReleaseChart(ctx, hr, appDef); err != nil {
|
|
logger.Error(err, "failed to update HelmRelease", "name", hr.Name, "namespace", hr.Namespace)
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
// expectedValuesFrom returns the expected valuesFrom configuration for HelmReleases
|
|
func expectedValuesFrom() []helmv2.ValuesReference {
|
|
return []helmv2.ValuesReference{
|
|
{
|
|
Kind: "Secret",
|
|
Name: "cozystack-values",
|
|
},
|
|
}
|
|
}
|
|
|
|
// valuesFromEqual compares two ValuesReference slices
|
|
func valuesFromEqual(a, b []helmv2.ValuesReference) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i].Kind != b[i].Kind ||
|
|
a[i].Name != b[i].Name ||
|
|
a[i].ValuesKey != b[i].ValuesKey ||
|
|
a[i].TargetPath != b[i].TargetPath ||
|
|
a[i].Optional != b[i].Optional {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// updateHelmReleaseChart updates the chart and valuesFrom in HelmRelease based on ApplicationDefinition
|
|
func (r *ApplicationDefinitionHelmReconciler) updateHelmReleaseChart(ctx context.Context, hr *helmv2.HelmRelease, appDef *cozyv1alpha1.ApplicationDefinition) error {
|
|
logger := log.FromContext(ctx)
|
|
hrCopy := hr.DeepCopy()
|
|
updated := false
|
|
|
|
// Validate ChartRef configuration exists
|
|
if appDef.Spec.Release.ChartRef == nil ||
|
|
appDef.Spec.Release.ChartRef.Kind == "" ||
|
|
appDef.Spec.Release.ChartRef.Name == "" ||
|
|
appDef.Spec.Release.ChartRef.Namespace == "" {
|
|
logger.Error(fmt.Errorf("invalid ChartRef in ApplicationDefinition"), "Skipping HelmRelease chartRef update: ChartRef is nil or incomplete",
|
|
"appDef", appDef.Name)
|
|
return nil
|
|
}
|
|
|
|
// Use ChartRef directly from ApplicationDefinition
|
|
expectedChartRef := appDef.Spec.Release.ChartRef
|
|
|
|
// Check if chartRef needs to be updated
|
|
if hrCopy.Spec.ChartRef == nil {
|
|
hrCopy.Spec.ChartRef = expectedChartRef
|
|
// Clear the old chart field when switching to chartRef
|
|
hrCopy.Spec.Chart = nil
|
|
updated = true
|
|
} else if hrCopy.Spec.ChartRef.Kind != expectedChartRef.Kind ||
|
|
hrCopy.Spec.ChartRef.Name != expectedChartRef.Name ||
|
|
hrCopy.Spec.ChartRef.Namespace != expectedChartRef.Namespace {
|
|
hrCopy.Spec.ChartRef = expectedChartRef
|
|
updated = true
|
|
}
|
|
|
|
// Check and update valuesFrom configuration
|
|
expected := expectedValuesFrom()
|
|
if !valuesFromEqual(hrCopy.Spec.ValuesFrom, expected) {
|
|
logger.V(4).Info("Updating HelmRelease valuesFrom", "name", hr.Name, "namespace", hr.Namespace)
|
|
hrCopy.Spec.ValuesFrom = expected
|
|
updated = true
|
|
}
|
|
|
|
// Check and update the readiness wait. cozystack-api reads
|
|
// release.cozystack.io/helm-install-disable-wait off the definition when it
|
|
// builds a release, and only there, so a release created before the
|
|
// annotation appeared keeps waiting on readiness until its Application is
|
|
// written through the apps API again. Where the wait is what broke, that is
|
|
// every existing instance of the kind at once, each looping upgrade and
|
|
// timeout until an operator rewrites it by hand. The definition is the
|
|
// source of truth in both directions, since the API rebuilds the whole spec
|
|
// on every write and emits DisableWait=false once the annotation is gone.
|
|
disableWait, err := config.ParseHelmInstallDisableWaitAnnotation(
|
|
appDef.Annotations[config.HelmInstallDisableWaitAnnotation],
|
|
)
|
|
if err != nil {
|
|
// cozystack-api refuses to start on this same value, so the cluster
|
|
// already has a loud signal; leave the field as it is rather than guess.
|
|
logger.Error(err, "Skipping HelmRelease wait update: invalid annotation",
|
|
"appDef", appDef.Name, "annotation", config.HelmInstallDisableWaitAnnotation)
|
|
} else {
|
|
// An absent install/upgrade block already behaves as DisableWait=false,
|
|
// so it is only materialised when the definition asks to disable the wait.
|
|
if disableWait && hrCopy.Spec.Install == nil {
|
|
hrCopy.Spec.Install = &helmv2.Install{}
|
|
}
|
|
if hrCopy.Spec.Install != nil && hrCopy.Spec.Install.DisableWait != disableWait {
|
|
logger.V(4).Info("Updating HelmRelease install wait", "name", hr.Name, "namespace", hr.Namespace, "disableWait", disableWait)
|
|
hrCopy.Spec.Install.DisableWait = disableWait
|
|
updated = true
|
|
}
|
|
if disableWait && hrCopy.Spec.Upgrade == nil {
|
|
hrCopy.Spec.Upgrade = &helmv2.Upgrade{}
|
|
}
|
|
if hrCopy.Spec.Upgrade != nil && hrCopy.Spec.Upgrade.DisableWait != disableWait {
|
|
logger.V(4).Info("Updating HelmRelease upgrade wait", "name", hr.Name, "namespace", hr.Namespace, "disableWait", disableWait)
|
|
hrCopy.Spec.Upgrade.DisableWait = disableWait
|
|
updated = true
|
|
}
|
|
}
|
|
|
|
// Check and update the health checks the wait applies. cozystack-api writes
|
|
// healthCheckExprs and the wait strategy only when it builds a release, so
|
|
// a release created before the definition exempted an object would keep
|
|
// waiting on it at every upgrade, and fail for good on one that never
|
|
// settles. Both are derived the way the API derives them, in both
|
|
// directions, so the release matches what a fresh write would produce. A
|
|
// release another chart renders carries Flux's origin label and takes its
|
|
// gate from that chart.
|
|
if _, rendered := hr.Labels["helm.toolkit.fluxcd.io/name"]; !rendered {
|
|
exprs := appDef.Spec.Release.HealthCheckExprs
|
|
// Semantic equality treats nil and empty as equal, so a release
|
|
// already in sync is not rewritten.
|
|
if !equality.Semantic.DeepEqual(hrCopy.Spec.HealthCheckExprs, exprs) {
|
|
logger.V(4).Info("Updating HelmRelease healthCheckExprs", "name", hr.Name, "namespace", hr.Namespace)
|
|
hrCopy.Spec.HealthCheckExprs = exprs
|
|
updated = true
|
|
}
|
|
waitStrategy := config.ResolveWaitStrategy(appDef.Spec.Release.WaitStrategy, len(exprs) > 0)
|
|
if !equality.Semantic.DeepEqual(hrCopy.Spec.WaitStrategy, waitStrategy) {
|
|
logger.V(4).Info("Updating HelmRelease waitStrategy", "name", hr.Name, "namespace", hr.Namespace, "waitStrategy", waitStrategy)
|
|
hrCopy.Spec.WaitStrategy = waitStrategy
|
|
updated = true
|
|
}
|
|
}
|
|
|
|
// Check and update labels from ApplicationDefinition
|
|
if len(appDef.Spec.Release.Labels) > 0 {
|
|
if hrCopy.Labels == nil {
|
|
hrCopy.Labels = make(map[string]string)
|
|
}
|
|
for key, value := range appDef.Spec.Release.Labels {
|
|
if hrCopy.Labels[key] != value {
|
|
logger.V(4).Info("Updating HelmRelease label", "name", hr.Name, "namespace", hr.Namespace, "label", key, "value", value)
|
|
hrCopy.Labels[key] = value
|
|
updated = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if updated {
|
|
logger.V(4).Info("Updating HelmRelease", "name", hr.Name, "namespace", hr.Namespace)
|
|
if err := r.Update(ctx, hrCopy); err != nil {
|
|
return fmt.Errorf("failed to update HelmRelease: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|