cozystack/internal/operator/packagesource_reconciler.go
Andrei Kvapil 05b2244b36
feat(operator): add secret replicator and reconciler improvements
Add namespace-based secret replication with label selector approach.
The implementation uses configurable secret name, namespace, and
target namespace selector. Cache filtering optimizes memory usage.

Co-Authored-By: Timofei Larkin <lllamnyp@gmail.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
2026-01-06 16:31:40 +01:00

413 lines
14 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"
"fmt"
"strings"
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
sourcewatcherv1beta1 "github.com/fluxcd/source-watcher/api/v2/v1beta1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
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/client/apiutil"
"sigs.k8s.io/controller-runtime/pkg/log"
)
// PackageSourceReconciler reconciles PackageSource resources
type PackageSourceReconciler struct {
client.Client
Scheme *runtime.Scheme
}
// +kubebuilder:rbac:groups=cozystack.io,resources=packagesources,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=cozystack.io,resources=packagesources/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=source.extensions.fluxcd.io,resources=artifactgenerators,verbs=get;list;watch;create;update;patch;delete
// Reconcile is part of the main kubernetes reconciliation loop
func (r *PackageSourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
packageSource := &cozyv1alpha1.PackageSource{}
if err := r.Get(ctx, req.NamespacedName, packageSource); err != nil {
if apierrors.IsNotFound(err) {
// Resource not found, return (ownerReference will handle cleanup)
return ctrl.Result{}, nil
}
return ctrl.Result{}, err
}
// Generate ArtifactGenerator for package source
if err := r.reconcileArtifactGenerators(ctx, packageSource); err != nil {
logger.Error(err, "failed to reconcile ArtifactGenerator")
return ctrl.Result{}, err
}
// Update PackageSource status (variants and conditions from ArtifactGenerator)
if err := r.updateStatus(ctx, packageSource); err != nil {
logger.Error(err, "failed to update status")
// Don't return error, status update is not critical
}
return ctrl.Result{}, nil
}
// reconcileArtifactGenerators generates a single ArtifactGenerator for the package source
// Creates one ArtifactGenerator per package source with all OutputArtifacts from components
func (r *PackageSourceReconciler) reconcileArtifactGenerators(ctx context.Context, packageSource *cozyv1alpha1.PackageSource) error {
logger := log.FromContext(ctx)
// Check if SourceRef is set
if packageSource.Spec.SourceRef == nil {
logger.Info("skipping ArtifactGenerator creation, SourceRef not set", "packageSource", packageSource.Name)
return nil
}
// Namespace is always cozy-system
namespace := "cozy-system"
// ArtifactGenerator name is the package source name
agName := packageSource.Name
// Collect all OutputArtifacts
outputArtifacts := []sourcewatcherv1beta1.OutputArtifact{}
// Process all variants and their components
for _, variant := range packageSource.Spec.Variants {
// Build library map for this variant
// Map key is the library name (from lib.Name or extracted from path)
// This allows components in this variant to reference libraries by name
// Libraries are scoped per variant to avoid conflicts between variants
libraryMap := make(map[string]cozyv1alpha1.Library)
for _, lib := range variant.Libraries {
libName := lib.Name
if libName == "" {
// If library name is not set, extract from path
libName = r.getPackageNameFromPath(lib.Path)
}
if libName != "" {
// Store library with the resolved name
libraryMap[libName] = lib
}
}
for _, component := range variant.Components {
// Skip components without path
if component.Path == "" {
logger.V(1).Info("skipping component without path", "packageSource", packageSource.Name, "variant", variant.Name, "component", component.Name)
continue
}
logger.V(1).Info("processing component", "packageSource", packageSource.Name, "variant", variant.Name, "component", component.Name, "path", component.Path)
// Extract component name from path (last component)
componentPathName := r.getPackageNameFromPath(component.Path)
if componentPathName == "" {
logger.Info("skipping component with invalid path", "packageSource", packageSource.Name, "variant", variant.Name, "component", component.Name, "path", component.Path)
continue
}
// Get basePath with default values
basePath := r.getBasePath(packageSource)
// Build copy operations
copyOps := []sourcewatcherv1beta1.CopyOperation{
{
From: r.buildSourcePath(packageSource.Spec.SourceRef.Name, basePath, component.Path),
To: fmt.Sprintf("@artifact/%s/", componentPathName),
},
}
// Add libraries if specified
for _, libName := range component.Libraries {
if lib, ok := libraryMap[libName]; ok {
copyOps = append(copyOps, sourcewatcherv1beta1.CopyOperation{
From: r.buildSourcePath(packageSource.Spec.SourceRef.Name, basePath, lib.Path),
To: fmt.Sprintf("@artifact/%s/charts/%s/", componentPathName, libName),
})
}
}
// Add valuesFiles if specified
for i, valuesFile := range component.ValuesFiles {
strategy := "Merge"
if i == 0 {
strategy = "Overwrite"
}
copyOps = append(copyOps, sourcewatcherv1beta1.CopyOperation{
From: r.buildSourceFilePath(packageSource.Spec.SourceRef.Name, basePath, fmt.Sprintf("%s/%s", component.Path, valuesFile)),
To: fmt.Sprintf("@artifact/%s/values.yaml", componentPathName),
Strategy: strategy,
})
}
// Artifact name: <packagesource>-<variant>-<componentname>
// Replace dots with dashes to comply with Kubernetes naming requirements
artifactName := fmt.Sprintf("%s-%s-%s",
strings.ReplaceAll(packageSource.Name, ".", "-"),
strings.ReplaceAll(variant.Name, ".", "-"),
strings.ReplaceAll(component.Name, ".", "-"))
outputArtifacts = append(outputArtifacts, sourcewatcherv1beta1.OutputArtifact{
Name: artifactName,
Copy: copyOps,
})
logger.Info("added OutputArtifact for component", "packageSource", packageSource.Name, "variant", variant.Name, "component", component.Name, "artifactName", artifactName)
}
}
// If there are no OutputArtifacts, return (ownerReference will handle cleanup if needed)
if len(outputArtifacts) == 0 {
logger.Info("no OutputArtifacts to generate, skipping ArtifactGenerator creation", "packageSource", packageSource.Name)
return nil
}
// Build labels
labels := make(map[string]string)
labels["cozystack.io/packagesource"] = packageSource.Name
// Create single ArtifactGenerator for the package source
ag := &sourcewatcherv1beta1.ArtifactGenerator{
ObjectMeta: metav1.ObjectMeta{
Name: agName,
Namespace: namespace,
Labels: labels,
},
Spec: sourcewatcherv1beta1.ArtifactGeneratorSpec{
Sources: []sourcewatcherv1beta1.SourceReference{
{
Alias: packageSource.Spec.SourceRef.Name,
Kind: packageSource.Spec.SourceRef.Kind,
Name: packageSource.Spec.SourceRef.Name,
Namespace: packageSource.Spec.SourceRef.Namespace,
},
},
OutputArtifacts: outputArtifacts,
},
}
// Set ownerReference
gvk, err := apiutil.GVKForObject(packageSource, r.Scheme)
if err != nil {
return fmt.Errorf("failed to get GVK for PackageSource: %w", err)
}
ag.OwnerReferences = []metav1.OwnerReference{
{
APIVersion: gvk.GroupVersion().String(),
Kind: gvk.Kind,
Name: packageSource.Name,
UID: packageSource.UID,
Controller: func() *bool { b := true; return &b }(),
},
}
logger.Info("creating ArtifactGenerator for package source", "packageSource", packageSource.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 package source", "name", agName, "namespace", namespace, "outputArtifactCount", len(outputArtifacts))
return nil
}
// Helper functions
func (r *PackageSourceReconciler) 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 *PackageSourceReconciler) getBasePath(packageSource *cozyv1alpha1.PackageSource) string {
// If path is explicitly set in SourceRef, use it (but normalize "/" to empty)
if packageSource.Spec.SourceRef.Path != "" {
path := strings.Trim(packageSource.Spec.SourceRef.Path, "/")
// If path is "/" or empty after trim, return empty string
if path == "" {
return ""
}
return path
}
// Default values based on kind
if packageSource.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 *PackageSourceReconciler) buildSourcePath(sourceName, basePath, path string) string {
// Remove leading/trailing slashes and combine
parts := []string{}
if basePath != "" {
trimmed := strings.Trim(basePath, "/")
if trimmed != "" {
parts = append(parts, trimmed)
}
}
if path != "" {
trimmed := strings.Trim(path, "/")
if trimmed != "" {
parts = append(parts, trimmed)
}
}
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 *PackageSourceReconciler) buildSourceFilePath(sourceName, basePath, path string) string {
// Remove leading/trailing slashes and combine
parts := []string{}
if basePath != "" {
trimmed := strings.Trim(basePath, "/")
if trimmed != "" {
parts = append(parts, trimmed)
}
}
if path != "" {
trimmed := strings.Trim(path, "/")
if trimmed != "" {
parts = append(parts, trimmed)
}
}
fullPath := strings.Join(parts, "/")
if fullPath == "" {
return fmt.Sprintf("@%s", sourceName)
}
return fmt.Sprintf("@%s/%s", sourceName, fullPath)
}
// createOrUpdate creates or updates a resource using server-side apply
func (r *PackageSourceReconciler) createOrUpdate(ctx context.Context, obj client.Object) error {
// Ensure TypeMeta is set for server-side apply
// Use type assertion to set GVK if the object supports it
if runtimeObj, ok := obj.(runtime.Object); ok {
gvk, err := apiutil.GVKForObject(obj, r.Scheme)
if err != nil {
return fmt.Errorf("failed to get GVK for object: %w", err)
}
runtimeObj.GetObjectKind().SetGroupVersionKind(gvk)
}
// Use server-side apply with field manager
// This is atomic and avoids race conditions from Get/Create/Update pattern
// Labels, annotations, and spec will be merged automatically by the server
// Each field is treated separately, so existing ones are preserved
return r.Patch(ctx, obj, client.Apply, client.FieldOwner("cozystack-packagesource-controller"))
}
// updateStatus updates PackageSource status (variants and conditions from ArtifactGenerator)
func (r *PackageSourceReconciler) updateStatus(ctx context.Context, packageSource *cozyv1alpha1.PackageSource) error {
logger := log.FromContext(ctx)
// Update variants in status from spec
variantNames := make([]string, 0, len(packageSource.Spec.Variants))
for _, variant := range packageSource.Spec.Variants {
variantNames = append(variantNames, variant.Name)
}
packageSource.Status.Variants = strings.Join(variantNames, ",")
// Check if SourceRef is set
if packageSource.Spec.SourceRef == nil {
// Set status to unknown if SourceRef is not set
meta.SetStatusCondition(&packageSource.Status.Conditions, metav1.Condition{
Type: "Ready",
Status: metav1.ConditionUnknown,
Reason: "SourceRefNotSet",
Message: "SourceRef is not configured",
})
return r.Status().Update(ctx, packageSource)
}
// Get ArtifactGenerator
ag := &sourcewatcherv1beta1.ArtifactGenerator{}
agKey := types.NamespacedName{
Name: packageSource.Name,
Namespace: "cozy-system",
}
if err := r.Get(ctx, agKey, ag); err != nil {
if apierrors.IsNotFound(err) {
// ArtifactGenerator not found, set status to unknown
meta.SetStatusCondition(&packageSource.Status.Conditions, metav1.Condition{
Type: "Ready",
Status: metav1.ConditionUnknown,
Reason: "ArtifactGeneratorNotFound",
Message: "ArtifactGenerator not found",
})
return r.Status().Update(ctx, packageSource)
}
return fmt.Errorf("failed to get ArtifactGenerator: %w", err)
}
// Find Ready condition in ArtifactGenerator
readyCondition := meta.FindStatusCondition(ag.Status.Conditions, "Ready")
if readyCondition == nil {
// No Ready condition in ArtifactGenerator, set status to unknown
meta.SetStatusCondition(&packageSource.Status.Conditions, metav1.Condition{
Type: "Ready",
Status: metav1.ConditionUnknown,
Reason: "ArtifactGeneratorNotReady",
Message: "ArtifactGenerator Ready condition not found",
})
return r.Status().Update(ctx, packageSource)
}
// Copy Ready condition from ArtifactGenerator to PackageSource
meta.SetStatusCondition(&packageSource.Status.Conditions, metav1.Condition{
Type: "Ready",
Status: readyCondition.Status,
Reason: readyCondition.Reason,
Message: readyCondition.Message,
ObservedGeneration: packageSource.Generation,
LastTransitionTime: readyCondition.LastTransitionTime,
})
logger.V(1).Info("updated PackageSource status from ArtifactGenerator",
"packageSource", packageSource.Name,
"status", readyCondition.Status,
"reason", readyCondition.Reason)
return r.Status().Update(ctx, packageSource)
}
// SetupWithManager sets up the controller with the Manager.
func (r *PackageSourceReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
Named("cozystack-packagesource").
For(&cozyv1alpha1.PackageSource{}).
Owns(&sourcewatcherv1beta1.ArtifactGenerator{}).
Complete(r)
}