1235 lines
41 KiB
Go
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)
|
|
}
|