cozystack/internal/operator/bundle_reconciler.go
Andrei Kvapil 66ab048612
fix ci pipeline
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
2025-11-27 19:34:48 +01:00

1235 lines
41 KiB
Go

/*
Copyright 2025 The Cozystack Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License 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 operator
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
helmv2 "github.com/fluxcd/helm-controller/api/v2"
sourcewatcherv1beta1 "github.com/fluxcd/source-watcher/api/v2/v1beta1"
corev1 "k8s.io/api/core/v1"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
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"
)
// BundleReconciler reconciles Bundle resources
type BundleReconciler struct {
client.Client
Scheme *runtime.Scheme
}
// +kubebuilder:rbac:groups=cozystack.io,resources=bundles,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=cozystack.io,resources=bundles/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=helm.toolkit.fluxcd.io,resources=helmreleases,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=source.extensions.fluxcd.io,resources=artifactgenerators,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=core,resources=namespaces,verbs=get;list;watch;create;update;patch
// Reconcile is part of the main kubernetes reconciliation loop
func (r *BundleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
bundle := &cozyv1alpha1.Bundle{}
if err := r.Get(ctx, req.NamespacedName, bundle); err != nil {
if apierrors.IsNotFound(err) {
// Cleanup orphaned resources
return r.cleanupOrphanedResources(ctx, req.NamespacedName)
}
return ctrl.Result{}, err
}
// Resolve dependencies from other bundles
resolvedPackages, err := r.resolveDependencies(ctx, bundle)
if err != nil {
// If dependency bundle is not found, requeue to try again later
// Check if the error is wrapped IsNotFound
unwrappedErr := errors.Unwrap(err)
if unwrappedErr != nil && apierrors.IsNotFound(unwrappedErr) {
logger.Info("Dependency bundle not found, requeuing", "bundle", bundle.Name, "error", err)
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
}
logger.Error(err, "failed to resolve dependencies")
return ctrl.Result{}, err
}
// Reconcile namespaces from packages
if err := r.reconcileNamespaces(ctx, bundle, resolvedPackages); err != nil {
logger.Error(err, "failed to reconcile namespaces")
return ctrl.Result{}, err
}
// Check for conflicts between packages with artifact and artifacts
if err := r.checkArtifactConflicts(ctx, bundle, resolvedPackages); err != nil {
logger.Error(err, "failed to check artifact conflicts")
return ctrl.Result{}, err
}
// Generate ArtifactGenerator for bundle (one generator per bundle with all OutputArtifacts)
if err := r.reconcileArtifactGenerators(ctx, bundle, resolvedPackages); err != nil {
logger.Error(err, "failed to reconcile ArtifactGenerator")
return ctrl.Result{}, err
}
// Generate HelmReleases for packages
if err := r.reconcileHelmReleases(ctx, bundle, resolvedPackages); err != nil {
logger.Error(err, "failed to reconcile HelmReleases")
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
// resolveDependencies resolves dependencies from other bundles
func (r *BundleReconciler) resolveDependencies(ctx context.Context, bundle *cozyv1alpha1.Bundle) ([]cozyv1alpha1.BundleRelease, error) {
resolved := make([]cozyv1alpha1.BundleRelease, 0, len(bundle.Spec.Packages))
packageMap := make(map[string]bool)
// Add all packages from this bundle
for _, pkg := range bundle.Spec.Packages {
if !pkg.Disabled {
resolved = append(resolved, pkg)
packageMap[pkg.Name] = true
}
}
// Resolve dependencies from other bundles
for _, dependsOn := range bundle.Spec.DependsOn {
parts := strings.Split(dependsOn, "/")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid dependsOn format: %s (expected bundleName/target)", dependsOn)
}
bundleName := parts[0]
targetName := parts[1]
// Get the bundle
depBundle := &cozyv1alpha1.Bundle{}
if err := r.Get(ctx, types.NamespacedName{Name: bundleName}, depBundle); err != nil {
// If bundle is not found, return wrapped error so we can check it in Reconcile
if apierrors.IsNotFound(err) {
return nil, fmt.Errorf("failed to get bundle %s: %w", bundleName, err)
}
return nil, fmt.Errorf("failed to get bundle %s: %w", bundleName, err)
}
// Find the target
var target *cozyv1alpha1.BundleDependencyTarget
for i := range depBundle.Spec.DependencyTargets {
if depBundle.Spec.DependencyTargets[i].Name == targetName {
target = &depBundle.Spec.DependencyTargets[i]
break
}
}
if target == nil {
return nil, fmt.Errorf("target %s not found in bundle %s", targetName, bundleName)
}
// Add packages from target to all packages in this bundle
for i := range resolved {
// Add target packages to dependsOn
for _, targetPkg := range target.Packages {
// Check if already in dependsOn
found := false
for _, dep := range resolved[i].DependsOn {
if dep == targetPkg {
found = true
break
}
}
if !found {
resolved[i].DependsOn = append(resolved[i].DependsOn, targetPkg)
}
}
}
}
return resolved, nil
}
// reconcileArtifactGenerators generates a single ArtifactGenerator for the bundle
// Creates one ArtifactGenerator per bundle with all OutputArtifacts from packages and artifacts
func (r *BundleReconciler) reconcileArtifactGenerators(ctx context.Context, bundle *cozyv1alpha1.Bundle, packages []cozyv1alpha1.BundleRelease) error {
logger := log.FromContext(ctx)
libraryMap := make(map[string]cozyv1alpha1.BundleLibrary)
for _, lib := range bundle.Spec.Libraries {
libraryMap[lib.Name] = lib
}
// Namespace is always cozy-system
namespace := "cozy-system"
// ArtifactGenerator name is the bundle name
agName := bundle.Name
// Collect all OutputArtifacts
outputArtifacts := []sourcewatcherv1beta1.OutputArtifact{}
// Process packages
for _, pkg := range packages {
logger.V(1).Info("processing package for artifact", "bundle", bundle.Name, "package", pkg.Name, "path", pkg.Path, "disabled", pkg.Disabled)
// Skip packages without path (they might use artifacts)
if pkg.Path == "" {
logger.V(1).Info("skipping package without path", "name", pkg.Name)
continue
}
// Extract package name from path (last component)
pkgName := r.getPackageNameFromPath(pkg.Path)
if pkgName == "" {
logger.Info("skipping package with invalid path", "name", pkg.Name, "path", pkg.Path)
continue
}
logger.V(1).Info("extracted package name from path", "name", pkg.Name, "path", pkg.Path, "pkgName", pkgName)
// Get basePath with default values
basePath := r.getBasePath(bundle)
// Build copy operations
copyOps := []sourcewatcherv1beta1.CopyOperation{
{
From: r.buildSourcePath(bundle.Spec.SourceRef.Name, basePath, pkg.Path),
To: fmt.Sprintf("@artifact/%s/", pkgName),
},
}
// Add libraries if specified
for _, libName := range pkg.Libraries {
if lib, ok := libraryMap[libName]; ok {
copyOps = append(copyOps, sourcewatcherv1beta1.CopyOperation{
From: r.buildSourcePath(bundle.Spec.SourceRef.Name, basePath, lib.Path),
To: fmt.Sprintf("@artifact/%s/charts/%s/", pkgName, libName),
})
}
}
// Add valuesFiles if specified
for i, valuesFile := range pkg.ValuesFiles {
strategy := "Merge"
if i == 0 {
strategy = "Overwrite"
}
copyOps = append(copyOps, sourcewatcherv1beta1.CopyOperation{
From: r.buildSourceFilePath(bundle.Spec.SourceRef.Name, basePath, fmt.Sprintf("%s/%s", pkg.Path, valuesFile)),
To: fmt.Sprintf("@artifact/%s/values.yaml", pkgName),
Strategy: strategy,
})
}
// Artifact name: bundle-name-package-name (e.g., cozystack-system-cilium)
artifactName := fmt.Sprintf("%s-%s", bundle.Name, pkgName)
outputArtifacts = append(outputArtifacts, sourcewatcherv1beta1.OutputArtifact{
Name: artifactName,
Copy: copyOps,
})
logger.Info("added OutputArtifact for package", "bundle", bundle.Name, "package", pkg.Name, "artifactName", artifactName)
}
// Process artifacts
for _, artifact := range bundle.Spec.Artifacts {
logger.Info("processing artifact", "bundle", bundle.Name, "artifact", artifact.Name, "path", artifact.Path)
// Extract artifact name from path (last component)
artifactPathName := r.getPackageNameFromPath(artifact.Path)
if artifactPathName == "" {
logger.Info("skipping artifact with invalid path", "name", artifact.Name, "path", artifact.Path)
continue
}
// Get basePath with default values
basePath := r.getBasePath(bundle)
// Build copy operations
copyOps := []sourcewatcherv1beta1.CopyOperation{
{
From: r.buildSourcePath(bundle.Spec.SourceRef.Name, basePath, artifact.Path),
To: fmt.Sprintf("@artifact/%s/", artifactPathName),
},
}
// Add libraries if specified
for _, libName := range artifact.Libraries {
if lib, ok := libraryMap[libName]; ok {
copyOps = append(copyOps, sourcewatcherv1beta1.CopyOperation{
From: r.buildSourcePath(bundle.Spec.SourceRef.Name, basePath, lib.Path),
To: fmt.Sprintf("@artifact/%s/charts/%s/", artifactPathName, libName),
})
}
}
// Artifact name: {bundle-name}-{artifact-name}
artifactName := fmt.Sprintf("%s-%s", bundle.Name, artifact.Name)
outputArtifacts = append(outputArtifacts, sourcewatcherv1beta1.OutputArtifact{
Name: artifactName,
Copy: copyOps,
})
logger.Info("added OutputArtifact for artifact", "bundle", bundle.Name, "artifact", artifact.Name, "artifactName", artifactName)
}
// If there are no OutputArtifacts, cleanup and return
if len(outputArtifacts) == 0 {
logger.Info("no OutputArtifacts to generate, skipping ArtifactGenerator creation", "bundle", bundle.Name)
// Cleanup orphaned ArtifactGenerators (to remove existing generator if it exists)
return r.cleanupOrphanedArtifactGenerators(ctx, bundle)
}
// Build labels: merge bundle labels with default cozystack.io/bundle label
labels := make(map[string]string)
if bundle.Spec.Labels != nil {
for k, v := range bundle.Spec.Labels {
labels[k] = v
}
}
labels["cozystack.io/bundle"] = bundle.Name
// Create single ArtifactGenerator for the bundle
ag := &sourcewatcherv1beta1.ArtifactGenerator{
ObjectMeta: metav1.ObjectMeta{
Name: agName,
Namespace: namespace,
Labels: labels,
},
Spec: sourcewatcherv1beta1.ArtifactGeneratorSpec{
Sources: []sourcewatcherv1beta1.SourceReference{
{
Alias: bundle.Spec.SourceRef.Name,
Kind: bundle.Spec.SourceRef.Kind,
Name: bundle.Spec.SourceRef.Name,
Namespace: bundle.Spec.SourceRef.Namespace,
},
},
OutputArtifacts: outputArtifacts,
},
}
// Set ownerReference only if deletionPolicy is not Orphan
if bundle.Spec.DeletionPolicy != cozyv1alpha1.DeletionPolicyOrphan {
ag.OwnerReferences = []metav1.OwnerReference{
{
APIVersion: bundle.APIVersion,
Kind: bundle.Kind,
Name: bundle.Name,
UID: bundle.UID,
Controller: func() *bool { b := true; return &b }(),
},
}
} else {
// Explicitly set empty ownerReferences for Orphan policy
ag.OwnerReferences = []metav1.OwnerReference{}
}
logger.Info("creating ArtifactGenerator for bundle", "bundle", bundle.Name, "agName", agName, "namespace", namespace, "outputArtifactCount", len(outputArtifacts))
if err := r.createOrUpdate(ctx, ag); err != nil {
return fmt.Errorf("failed to reconcile ArtifactGenerator %s: %w", agName, err)
}
logger.Info("reconciled ArtifactGenerator for bundle", "name", agName, "namespace", namespace, "outputArtifactCount", len(outputArtifacts))
// Cleanup orphaned ArtifactGenerators
return r.cleanupOrphanedArtifactGenerators(ctx, bundle)
}
// reconcileHelmReleases generates HelmReleases from bundle packages
func (r *BundleReconciler) reconcileHelmReleases(ctx context.Context, bundle *cozyv1alpha1.Bundle, packages []cozyv1alpha1.BundleRelease) error {
logger := log.FromContext(ctx)
// Build package name map for dependency resolution (from current bundle)
packageNameMap := make(map[string]cozyv1alpha1.BundleRelease)
for _, pkg := range packages {
packageNameMap[pkg.Name] = pkg
}
// Build global package name map from all bundles for finding dependencies
globalPackageMap := make(map[string]cozyv1alpha1.BundleRelease)
bundleList := &cozyv1alpha1.BundleList{}
if err := r.List(ctx, bundleList); err == nil {
for _, b := range bundleList.Items {
for _, pkg := range b.Spec.Packages {
// Only add if not already in map (first occurrence wins, or use current bundle's packages)
if _, exists := globalPackageMap[pkg.Name]; !exists {
globalPackageMap[pkg.Name] = pkg
}
}
}
}
// Override with packages from current bundle (they take precedence)
for _, pkg := range packages {
globalPackageMap[pkg.Name] = pkg
}
// Build artifact name map from bundle artifacts for conflict checking
artifactNameMap := make(map[string]bool)
for _, artifact := range bundle.Spec.Artifacts {
artifactNameMap[artifact.Name] = true
}
// Create HelmRelease for each package
for _, pkg := range packages {
// Skip disabled packages
if pkg.Disabled {
logger.V(1).Info("skipping disabled package", "name", pkg.Name, "namespace", pkg.Namespace)
continue
}
var artifactName string
artifactNamespace := "cozy-system"
if pkg.Artifact != "" {
// Package uses an artifact reference
// Check if artifact exists in bundle
if !artifactNameMap[pkg.Artifact] {
logger.Error(fmt.Errorf("artifact %s not found in bundle artifacts", pkg.Artifact), "skipping package", "name", pkg.Name)
continue
}
// Artifact name format: {bundle-name}-{artifact-name}
artifactName = fmt.Sprintf("%s-%s", bundle.Name, pkg.Artifact)
} else if pkg.Path != "" {
// Package uses a path
pkgName := r.getPackageNameFromPath(pkg.Path)
if pkgName == "" {
logger.Info("skipping package with invalid path", "name", pkg.Name, "path", pkg.Path)
continue
}
// Artifact name format: {bundle-name}-{package-name}
artifactName = fmt.Sprintf("%s-%s", bundle.Name, pkgName)
} else {
logger.Error(fmt.Errorf("neither artifact nor path specified"), "skipping package", "name", pkg.Name)
continue
}
// Build labels: merge bundle labels, package labels, and default cozystack.io/bundle label
hrLabels := make(map[string]string)
// First, add bundle-level labels
if bundle.Spec.Labels != nil {
for k, v := range bundle.Spec.Labels {
hrLabels[k] = v
}
}
// Then, add package-level labels (they override bundle labels)
if pkg.Labels != nil {
for k, v := range pkg.Labels {
hrLabels[k] = v
}
}
// Finally, add default bundle label (it always takes precedence)
hrLabels["cozystack.io/bundle"] = bundle.Name
// Add system-app label if namespace starts with "cozy-"
if pkg.Namespace == "kube-system" || strings.HasPrefix(pkg.Namespace, "cozy-") {
hrLabels["cozystack.io/system-app"] = "true"
}
// Create HelmRelease
hr := &helmv2.HelmRelease{
ObjectMeta: metav1.ObjectMeta{
Name: pkg.Name,
Namespace: pkg.Namespace,
Labels: hrLabels,
},
Spec: helmv2.HelmReleaseSpec{
Interval: metav1.Duration{Duration: 5 * 60 * 1000000000}, // 5m
ReleaseName: pkg.ReleaseName,
ChartRef: &helmv2.CrossNamespaceSourceReference{
Kind: "ExternalArtifact",
Name: artifactName,
Namespace: artifactNamespace,
},
Install: &helmv2.Install{
Remediation: &helmv2.InstallRemediation{
Retries: -1,
},
},
Upgrade: &helmv2.Upgrade{
Remediation: &helmv2.UpgradeRemediation{
Retries: -1,
},
},
},
}
// Set ownerReference only if deletionPolicy is not Orphan
if bundle.Spec.DeletionPolicy != cozyv1alpha1.DeletionPolicyOrphan {
hr.OwnerReferences = []metav1.OwnerReference{
{
APIVersion: bundle.APIVersion,
Kind: bundle.Kind,
Name: bundle.Name,
UID: bundle.UID,
Controller: func() *bool { b := true; return &b }(),
},
}
} else {
// Explicitly set empty ownerReferences for Orphan policy
hr.OwnerReferences = []metav1.OwnerReference{}
}
// Set values if provided
if pkg.Values != nil {
hr.Spec.Values = pkg.Values
}
// Add system-app label if TargetNamespace starts with "cozy-"
if hr.Spec.TargetNamespace != "" && (hr.Spec.TargetNamespace == "kube-system" || strings.HasPrefix(hr.Spec.TargetNamespace, "cozy-")) {
hr.Labels["cozystack.io/system-app"] = "true"
}
// Set DependsOn
if len(pkg.DependsOn) > 0 {
dependsOn := make([]helmv2.DependencyReference, 0, len(pkg.DependsOn))
for _, depName := range pkg.DependsOn {
depPkg, ok := globalPackageMap[depName]
if !ok {
logger.Info("dependent package not found in any bundle, using same namespace", "name", pkg.Name, "dependsOn", depName)
dependsOn = append(dependsOn, helmv2.DependencyReference{
Name: depName,
Namespace: pkg.Namespace,
})
} else {
dependsOn = append(dependsOn, helmv2.DependencyReference{
Name: depPkg.Name,
Namespace: depPkg.Namespace,
})
}
}
hr.Spec.DependsOn = dependsOn
}
// Set valuesFiles annotation
if len(pkg.ValuesFiles) > 0 {
if hr.Annotations == nil {
hr.Annotations = make(map[string]string)
}
hr.Annotations["cozypkg.cozystack.io/values-files"] = strings.Join(pkg.ValuesFiles, ",")
}
if err := r.createOrUpdate(ctx, hr); err != nil {
return fmt.Errorf("failed to reconcile HelmRelease %s: %w", pkg.Name, err)
}
logger.Info("reconciled HelmRelease", "name", pkg.Name, "namespace", pkg.Namespace)
}
// Cleanup orphaned HelmReleases
return r.cleanupOrphanedHelmReleases(ctx, bundle)
}
// createOrUpdate creates or updates a resource
func (r *BundleReconciler) createOrUpdate(ctx context.Context, obj client.Object) error {
existing := obj.DeepCopyObject().(client.Object)
key := client.ObjectKeyFromObject(obj)
err := r.Get(ctx, key, existing)
if apierrors.IsNotFound(err) {
return r.Create(ctx, obj)
} else if err != nil {
return err
}
// Preserve resource version
obj.SetResourceVersion(existing.GetResourceVersion())
// Merge labels and annotations
labels := obj.GetLabels()
if labels == nil {
labels = make(map[string]string)
}
for k, v := range existing.GetLabels() {
if _, ok := labels[k]; !ok {
labels[k] = v
}
}
obj.SetLabels(labels)
annotations := obj.GetAnnotations()
if annotations == nil {
annotations = make(map[string]string)
}
for k, v := range existing.GetAnnotations() {
if _, ok := annotations[k]; !ok {
annotations[k] = v
}
}
obj.SetAnnotations(annotations)
// Update ownerReferences: always use the ones from obj
// This allows deletionPolicy = Orphan to work by setting empty ownerReferences array
// When ownerReferences field is set in obj (even as empty array), use it
// Empty array will clear ownerReferences (for deletionPolicy = Orphan or when policy changes)
// If ownerReferences field is not set in obj (nil), preserve existing ones
objOwnerRefs := obj.GetOwnerReferences()
if objOwnerRefs != nil {
// obj has ownerReferences set (either populated or empty array), use them
// Empty array (len == 0) means we want to remove all ownerReferences (deletionPolicy = Orphan)
// This handles policy changes from Delete to Orphan
// objOwnerRefs is already set in obj, so it will be used in Update
// No need to do anything else - Update will use the ownerReferences from obj
} else if len(existing.GetOwnerReferences()) > 0 {
// obj doesn't have ownerReferences set (nil), but existing does
// Preserve existing ones (they might be from other owners)
obj.SetOwnerReferences(existing.GetOwnerReferences())
}
// For ArtifactGenerator, explicitly update Spec (OutputArtifacts and Sources)
// This ensures that OutputArtifacts from both packages and artifacts are properly updated
if ag, ok := obj.(*sourcewatcherv1beta1.ArtifactGenerator); ok {
if existingAG, ok := existing.(*sourcewatcherv1beta1.ArtifactGenerator); ok {
logger := log.FromContext(ctx)
logger.V(1).Info("updating ArtifactGenerator Spec", "name", ag.Name, "namespace", ag.Namespace,
"outputArtifactCount", len(ag.Spec.OutputArtifacts))
// Update Spec from obj (which contains the desired state with all OutputArtifacts)
existingAG.Spec = ag.Spec
// Preserve metadata updates we made above
existingAG.SetLabels(ag.GetLabels())
existingAG.SetAnnotations(ag.GetAnnotations())
existingAG.SetOwnerReferences(ag.GetOwnerReferences())
// Use existingAG for Update
obj = existingAG
}
}
// For HelmRelease, explicitly update Spec to ensure values and dependsOn are properly updated
if hr, ok := obj.(*helmv2.HelmRelease); ok {
if existingHR, ok := existing.(*helmv2.HelmRelease); ok {
logger := log.FromContext(ctx)
logger.V(1).Info("updating HelmRelease Spec", "name", hr.Name, "namespace", hr.Namespace)
// Check if this HelmRelease is managed through Application API or Controller
// If it has apps.cozystack.io/application.* labels OR cozystack.io/ui=true label, merge values with bundle priority
isApplicationManaged := existingHR.Labels["apps.cozystack.io/application.kind"] != "" &&
existingHR.Labels["apps.cozystack.io/application.group"] != ""
isControllerManaged := existingHR.Labels["cozystack.io/ui"] == "true"
if isApplicationManaged || isControllerManaged {
// For Application/Controller-managed HelmReleases, merge values with bundle priority
logger.V(1).Info("merging values for Application/Controller-managed HelmRelease with bundle priority", "name", hr.Name, "namespace", hr.Namespace, "isApplicationManaged", isApplicationManaged, "isControllerManaged", isControllerManaged)
existingHR.Spec.Chart = hr.Spec.Chart
existingHR.Spec.ChartRef = hr.Spec.ChartRef
existingHR.Spec.Interval = hr.Spec.Interval
existingHR.Spec.Timeout = hr.Spec.Timeout
existingHR.Spec.ReleaseName = hr.Spec.ReleaseName
existingHR.Spec.DependsOn = hr.Spec.DependsOn
existingHR.Spec.Install = hr.Spec.Install
existingHR.Spec.Upgrade = hr.Spec.Upgrade
existingHR.Spec.Uninstall = hr.Spec.Uninstall
existingHR.Spec.Rollback = hr.Spec.Rollback
existingHR.Spec.StorageNamespace = hr.Spec.StorageNamespace
existingHR.Spec.KubeConfig = hr.Spec.KubeConfig
existingHR.Spec.TargetNamespace = hr.Spec.TargetNamespace
existingHR.Spec.PostRenderers = hr.Spec.PostRenderers
existingHR.Spec.ServiceAccountName = hr.Spec.ServiceAccountName
existingHR.Spec.Suspend = hr.Spec.Suspend
// Merge values: bundle values have priority (override existing)
mergedValues, err := mergeHelmReleaseValuesWithBundlePriority(existingHR.Spec.Values, hr.Spec.Values)
if err != nil {
logger.Error(err, "failed to merge values, using bundle values", "name", hr.Name, "namespace", hr.Namespace)
existingHR.Spec.Values = hr.Spec.Values
} else {
existingHR.Spec.Values = mergedValues
}
} else {
// For bundle-managed HelmReleases, update everything including values
existingHR.Spec = hr.Spec
}
// Preserve metadata updates we made above
existingHR.SetLabels(hr.GetLabels())
existingHR.SetAnnotations(hr.GetAnnotations())
existingHR.SetOwnerReferences(hr.GetOwnerReferences())
// Use existingHR for Update
obj = existingHR
}
}
return r.Update(ctx, obj)
}
// mergeHelmReleaseValues merges two HelmRelease values JSON objects
// Existing values have priority (bundle values are merged into existing)
func mergeHelmReleaseValues(existingValues, bundleValues *apiextensionsv1.JSON) (*apiextensionsv1.JSON, error) {
// If bundle has no values, preserve existing
if bundleValues == nil || len(bundleValues.Raw) == 0 {
return existingValues, nil
}
// If existing has no values, use bundle values
if existingValues == nil || len(existingValues.Raw) == 0 {
return bundleValues, nil
}
// Parse both values
var existingMap map[string]interface{}
if err := json.Unmarshal(existingValues.Raw, &existingMap); err != nil {
return nil, fmt.Errorf("failed to unmarshal existing values: %w", err)
}
var bundleMap map[string]interface{}
if err := json.Unmarshal(bundleValues.Raw, &bundleMap); err != nil {
return nil, fmt.Errorf("failed to unmarshal bundle values: %w", err)
}
// Merge: existing values have priority (bundle is merged into existing)
mergedMap := deepMergeMaps(bundleMap, existingMap)
// Marshal back to JSON
mergedJSON, err := json.Marshal(mergedMap)
if err != nil {
return nil, fmt.Errorf("failed to marshal merged values: %w", err)
}
return &apiextensionsv1.JSON{Raw: mergedJSON}, nil
}
// mergeHelmReleaseValuesWithBundlePriority merges two HelmRelease values JSON objects
// Bundle values have priority (override existing values)
// All fields from bundle override existing, except nested merges for maps
func mergeHelmReleaseValuesWithBundlePriority(existingValues, bundleValues *apiextensionsv1.JSON) (*apiextensionsv1.JSON, error) {
// If bundle has no values, preserve existing
if bundleValues == nil || len(bundleValues.Raw) == 0 {
return existingValues, nil
}
// If existing has no values, use bundle values
if existingValues == nil || len(existingValues.Raw) == 0 {
return bundleValues, nil
}
// Parse both values
var existingMap map[string]interface{}
if err := json.Unmarshal(existingValues.Raw, &existingMap); err != nil {
return nil, fmt.Errorf("failed to unmarshal existing values: %w", err)
}
var bundleMap map[string]interface{}
if err := json.Unmarshal(bundleValues.Raw, &bundleMap); err != nil {
return nil, fmt.Errorf("failed to unmarshal bundle values: %w", err)
}
// Merge: start with existing values, then bundle values override (bundle has priority)
mergedMap := deepMergeMaps(existingMap, bundleMap)
// Marshal back to JSON
mergedJSON, err := json.Marshal(mergedMap)
if err != nil {
return nil, fmt.Errorf("failed to marshal merged values: %w", err)
}
return &apiextensionsv1.JSON{Raw: mergedJSON}, nil
}
// deepMergeMaps performs a deep merge of two maps
// Values from override map take precedence, but nested maps are merged recursively
func deepMergeMaps(base, override map[string]interface{}) map[string]interface{} {
result := make(map[string]interface{})
// Copy base map
for k, v := range base {
result[k] = v
}
// Merge override map
for k, v := range override {
if baseVal, exists := result[k]; exists {
// If both are maps, recursively merge
if baseMap, ok := baseVal.(map[string]interface{}); ok {
if overrideMap, ok := v.(map[string]interface{}); ok {
result[k] = deepMergeMaps(baseMap, overrideMap)
continue
}
}
}
// Override takes precedence for non-map values or new keys
result[k] = v
}
return result
}
// Helper functions
func (r *BundleReconciler) getPackageNameFromPath(path string) string {
parts := strings.Split(path, "/")
if len(parts) > 0 {
return parts[len(parts)-1]
}
return ""
}
// getBasePath returns the basePath with default values based on source kind
func (r *BundleReconciler) getBasePath(bundle *cozyv1alpha1.Bundle) string {
// If basePath is explicitly set, use it
if bundle.Spec.BasePath != "" {
return bundle.Spec.BasePath
}
// Default values based on kind
if bundle.Spec.SourceRef.Kind == "OCIRepository" {
return "" // Root for OCI
}
// Default for GitRepository
return "packages"
}
// buildSourcePath builds the full source path using basePath with glob pattern
func (r *BundleReconciler) buildSourcePath(sourceName, basePath, path string) string {
// Remove leading/trailing slashes and combine
parts := []string{}
if basePath != "" {
parts = append(parts, strings.Trim(basePath, "/"))
}
if path != "" {
parts = append(parts, strings.Trim(path, "/"))
}
fullPath := strings.Join(parts, "/")
if fullPath == "" {
return fmt.Sprintf("@%s/**", sourceName)
}
return fmt.Sprintf("@%s/%s/**", sourceName, fullPath)
}
// buildSourceFilePath builds the full source path for a specific file (without glob pattern)
func (r *BundleReconciler) buildSourceFilePath(sourceName, basePath, path string) string {
// Remove leading/trailing slashes and combine
parts := []string{}
if basePath != "" {
parts = append(parts, strings.Trim(basePath, "/"))
}
if path != "" {
parts = append(parts, strings.Trim(path, "/"))
}
fullPath := strings.Join(parts, "/")
if fullPath == "" {
return fmt.Sprintf("@%s", sourceName)
}
return fmt.Sprintf("@%s/%s", sourceName, fullPath)
}
func (r *BundleReconciler) cleanupOrphanedArtifactGenerators(ctx context.Context, bundle *cozyv1alpha1.Bundle) error {
logger := log.FromContext(ctx)
// Find ArtifactGenerators by label
agList := &sourcewatcherv1beta1.ArtifactGeneratorList{}
if err := r.List(ctx, agList, client.MatchingLabels{
"cozystack.io/bundle": bundle.Name,
}); err != nil {
if apierrors.IsNotFound(err) {
return nil
}
return err
}
// Desired name: bundle name (one ArtifactGenerator per bundle)
desiredName := bundle.Name
// Find ArtifactGenerators with this bundle label
for _, ag := range agList.Items {
// Check if it's the desired name
isDesired := ag.Name == desiredName
if !isDesired {
// Delete ArtifactGenerators that don't match the desired name
// This includes old pattern ArtifactGenerators (for migration from per-package/per-artifact to per-bundle)
logger.Info("deleting orphaned ArtifactGenerator", "name", ag.Name, "bundle", bundle.Name, "desiredName", desiredName)
if err := r.Delete(ctx, &ag); err != nil {
if !apierrors.IsNotFound(err) {
logger.Error(err, "failed to delete orphaned ArtifactGenerator", "name", ag.Name)
}
} else {
logger.Info("deleted orphaned ArtifactGenerator", "name", ag.Name)
}
}
}
return nil
}
func (r *BundleReconciler) cleanupOrphanedHelmReleases(ctx context.Context, bundle *cozyv1alpha1.Bundle) error {
logger := log.FromContext(ctx)
// Find HelmReleases by label
hrList := &helmv2.HelmReleaseList{}
if err := r.List(ctx, hrList, client.MatchingLabels{
"cozystack.io/bundle": bundle.Name,
}); err != nil {
return err
}
// Build desired names (excluding disabled packages)
desiredNames := make(map[types.NamespacedName]bool)
for _, pkg := range bundle.Spec.Packages {
// Only include non-disabled packages in desired names
if !pkg.Disabled {
desiredNames[types.NamespacedName{Name: pkg.Name, Namespace: pkg.Namespace}] = true
}
}
// Find HelmReleases with this bundle label
for _, hr := range hrList.Items {
key := types.NamespacedName{Name: hr.Name, Namespace: hr.Namespace}
if !desiredNames[key] {
logger.Info("deleting orphaned HelmRelease", "name", hr.Name, "namespace", hr.Namespace, "bundle", bundle.Name)
if err := r.Delete(ctx, &hr); err != nil {
if !apierrors.IsNotFound(err) {
logger.Error(err, "failed to delete HelmRelease", "name", hr.Name, "namespace", hr.Namespace)
}
} else {
logger.Info("deleted orphaned HelmRelease", "name", hr.Name, "namespace", hr.Namespace)
}
}
}
return nil
}
func (r *BundleReconciler) cleanupOrphanedResources(ctx context.Context, bundleKey types.NamespacedName) (ctrl.Result, error) {
logger := log.FromContext(ctx)
// Cleanup ArtifactGenerators by label
// Only delete if they have ownerReferences to this bundle (deletionPolicy != Orphan)
agList := &sourcewatcherv1beta1.ArtifactGeneratorList{}
if err := r.List(ctx, agList, client.MatchingLabels{
"cozystack.io/bundle": bundleKey.Name,
}); err == nil {
for _, ag := range agList.Items {
// Check if this resource has ownerReference to the deleted bundle
hasOwnerRef := false
for _, ownerRef := range ag.OwnerReferences {
if ownerRef.Kind == "Bundle" && ownerRef.Name == bundleKey.Name {
hasOwnerRef = true
break
}
}
// Only delete if it has ownerReference (deletionPolicy != Orphan)
// If no ownerReference, it means deletionPolicy was Orphan, so we should not delete it
if hasOwnerRef {
logger.Info("deleting orphaned ArtifactGenerator", "name", ag.Name, "bundle", bundleKey.Name)
if err := r.Delete(ctx, &ag); err != nil && !apierrors.IsNotFound(err) {
logger.Error(err, "failed to delete orphaned ArtifactGenerator", "name", ag.Name)
}
} else {
logger.Info("skipping ArtifactGenerator deletion (deletionPolicy=Orphan)", "name", ag.Name, "bundle", bundleKey.Name)
}
}
}
// Cleanup HelmReleases by label
// Only delete if they have ownerReferences to this bundle (deletionPolicy != Orphan)
hrList := &helmv2.HelmReleaseList{}
if err := r.List(ctx, hrList, client.MatchingLabels{
"cozystack.io/bundle": bundleKey.Name,
}); err == nil {
for _, hr := range hrList.Items {
// Check if this resource has ownerReference to the deleted bundle
hasOwnerRef := false
for _, ownerRef := range hr.OwnerReferences {
if ownerRef.Kind == "Bundle" && ownerRef.Name == bundleKey.Name {
hasOwnerRef = true
break
}
}
// Only delete if it has ownerReference (deletionPolicy != Orphan)
// If no ownerReference, it means deletionPolicy was Orphan, so we should not delete it
if hasOwnerRef {
logger.Info("deleting orphaned HelmRelease", "name", hr.Name, "namespace", hr.Namespace, "bundle", bundleKey.Name)
if err := r.Delete(ctx, &hr); err != nil && !apierrors.IsNotFound(err) {
logger.Error(err, "failed to delete orphaned HelmRelease", "name", hr.Name, "namespace", hr.Namespace)
}
} else {
logger.Info("skipping HelmRelease deletion (deletionPolicy=Orphan)", "name", hr.Name, "namespace", hr.Namespace, "bundle", bundleKey.Name)
}
}
}
return ctrl.Result{}, nil
}
// reconcileNamespaces creates or updates namespaces based on packages in the bundle.
func (r *BundleReconciler) reconcileNamespaces(ctx context.Context, bundle *cozyv1alpha1.Bundle, packages []cozyv1alpha1.BundleRelease) error {
logger := log.FromContext(ctx)
// Collect namespaces from packages
// Map: namespace -> {isPrivileged, labels, annotations}
type namespaceInfo struct {
privileged bool
labels map[string]string
annotations map[string]string
}
namespacesMap := make(map[string]namespaceInfo)
for _, pkg := range packages {
// Skip disabled packages
if pkg.Disabled {
continue
}
// Skip if namespace is empty
if pkg.Namespace == "" {
continue
}
info, exists := namespacesMap[pkg.Namespace]
if !exists {
info = namespaceInfo{
privileged: false,
labels: make(map[string]string),
annotations: make(map[string]string),
}
}
// If package is privileged, mark namespace as privileged
if pkg.Privileged {
info.privileged = true
}
// Merge namespace labels from package
if pkg.NamespaceLabels != nil {
for k, v := range pkg.NamespaceLabels {
info.labels[k] = v
}
}
// Merge namespace annotations from package
if pkg.NamespaceAnnotations != nil {
for k, v := range pkg.NamespaceAnnotations {
info.annotations[k] = v
}
}
namespacesMap[pkg.Namespace] = info
}
// Create or update all namespaces
for nsName, info := range namespacesMap {
namespace := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: nsName,
Labels: make(map[string]string),
Annotations: map[string]string{
"helm.sh/resource-policy": "keep",
},
},
}
// Add system label only for non-tenant namespaces
if !strings.HasPrefix(nsName, "tenant-") {
namespace.Labels["cozystack.io/system"] = "true"
}
// Add privileged label if needed
if info.privileged {
namespace.Labels["pod-security.kubernetes.io/enforce"] = "privileged"
}
// Merge namespace labels from packages
for k, v := range info.labels {
namespace.Labels[k] = v
}
// Merge namespace annotations from packages
for k, v := range info.annotations {
namespace.Annotations[k] = v
}
if err := r.createOrUpdateNamespace(ctx, namespace); err != nil {
logger.Error(err, "failed to reconcile namespace", "name", nsName, "privileged", info.privileged)
return fmt.Errorf("failed to reconcile namespace %s: %w", nsName, err)
}
logger.Info("reconciled namespace", "name", nsName, "privileged", info.privileged, "labels", info.labels, "annotations", info.annotations)
}
return nil
}
// createOrUpdateNamespace creates or updates a namespace.
func (r *BundleReconciler) createOrUpdateNamespace(ctx context.Context, namespace *corev1.Namespace) error {
existing := &corev1.Namespace{}
key := types.NamespacedName{Name: namespace.Name}
err := r.Get(ctx, key, existing)
if apierrors.IsNotFound(err) {
return r.Create(ctx, namespace)
} else if err != nil {
return err
}
// Preserve resource version
namespace.SetResourceVersion(existing.GetResourceVersion())
// Merge labels
labels := namespace.GetLabels()
if labels == nil {
labels = make(map[string]string)
}
for k, v := range existing.GetLabels() {
if _, ok := labels[k]; !ok {
labels[k] = v
}
}
namespace.SetLabels(labels)
// Merge annotations
annotations := namespace.GetAnnotations()
if annotations == nil {
annotations = make(map[string]string)
}
for k, v := range existing.GetAnnotations() {
if _, ok := annotations[k]; !ok {
annotations[k] = v
}
}
namespace.SetAnnotations(annotations)
return r.Update(ctx, namespace)
}
// checkArtifactConflicts checks for conflicts between packages using artifacts and bundle artifacts
func (r *BundleReconciler) checkArtifactConflicts(ctx context.Context, bundle *cozyv1alpha1.Bundle, packages []cozyv1alpha1.BundleRelease) error {
// Build artifact name map from bundle artifacts
artifactNameMap := make(map[string]bool)
for _, artifact := range bundle.Spec.Artifacts {
artifactNameMap[artifact.Name] = true
}
// Check packages that use artifacts
for _, pkg := range packages {
if pkg.Artifact != "" {
if !artifactNameMap[pkg.Artifact] {
return fmt.Errorf("package %s references artifact %s which is not defined in bundle artifacts", pkg.Name, pkg.Artifact)
}
}
}
return nil
}
// removeOwnerReferences removes ownerReferences from all resources with bundle label
func (r *BundleReconciler) removeOwnerReferences(ctx context.Context, bundle *cozyv1alpha1.Bundle) error {
logger := log.FromContext(ctx)
// Remove ownerReferences from ArtifactGenerators by label
agList := &sourcewatcherv1beta1.ArtifactGeneratorList{}
if err := r.List(ctx, agList, client.MatchingLabels{
"cozystack.io/bundle": bundle.Name,
}); err == nil {
for i := range agList.Items {
ag := &agList.Items[i]
updated := false
newOwnerRefs := []metav1.OwnerReference{}
for _, ownerRef := range ag.OwnerReferences {
if ownerRef.Kind == "Bundle" && ownerRef.Name == bundle.Name {
// Skip this ownerReference (remove it)
// Check by name only, not UID, to handle bundle updates
updated = true
} else {
// Keep other ownerReferences
newOwnerRefs = append(newOwnerRefs, ownerRef)
}
}
if updated {
ag.SetOwnerReferences(newOwnerRefs)
if err := r.Update(ctx, ag); err != nil {
logger.Error(err, "failed to remove ownerReference from ArtifactGenerator", "name", ag.Name, "namespace", ag.Namespace)
} else {
logger.Info("removed ownerReference from ArtifactGenerator", "name", ag.Name, "namespace", ag.Namespace)
}
}
}
}
// Remove ownerReferences from HelmReleases by label
hrList := &helmv2.HelmReleaseList{}
if err := r.List(ctx, hrList, client.MatchingLabels{
"cozystack.io/bundle": bundle.Name,
}); err == nil {
for i := range hrList.Items {
hr := &hrList.Items[i]
updated := false
newOwnerRefs := []metav1.OwnerReference{}
for _, ownerRef := range hr.OwnerReferences {
if ownerRef.Kind == "Bundle" && ownerRef.Name == bundle.Name {
// Skip this ownerReference (remove it)
// Check by name only, not UID, to handle bundle updates
updated = true
} else {
// Keep other ownerReferences
newOwnerRefs = append(newOwnerRefs, ownerRef)
}
}
if updated {
hr.SetOwnerReferences(newOwnerRefs)
if err := r.Update(ctx, hr); err != nil {
logger.Error(err, "failed to remove ownerReference from HelmRelease", "name", hr.Name, "namespace", hr.Namespace)
} else {
logger.Info("removed ownerReference from HelmRelease", "name", hr.Name, "namespace", hr.Namespace)
}
}
}
}
return nil
}
// SetupWithManager sets up the controller with the Manager.
func (r *BundleReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
Named("cozystack-bundle").
For(&cozyv1alpha1.Bundle{}).
Watches(
&helmv2.HelmRelease{},
handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request {
hr, ok := obj.(*helmv2.HelmRelease)
if !ok {
return nil
}
// Find the bundle that owns this HelmRelease by label
bundleName := hr.Labels["cozystack.io/bundle"]
if bundleName == "" {
return nil
}
// Reconcile the bundle to recreate the HelmRelease if it was deleted
return []reconcile.Request{{
NamespacedName: types.NamespacedName{
Name: bundleName,
},
}}
}),
).
Complete(r)
}