diff --git a/api/v1alpha1/package_types.go b/api/v1alpha1/package_types.go index e1a3184b..288d456d 100644 --- a/api/v1alpha1/package_types.go +++ b/api/v1alpha1/package_types.go @@ -86,4 +86,15 @@ type PackageStatus struct { // Conditions represents the latest available observations of a Package's state // +optional Conditions []metav1.Condition `json:"conditions,omitempty"` + + // Dependencies tracks the readiness status of each dependency + // Key is the dependency package name, value indicates if the dependency is ready + // +optional + Dependencies map[string]DependencyStatus `json:"dependencies,omitempty"` +} + +// DependencyStatus represents the readiness status of a dependency +type DependencyStatus struct { + // Ready indicates whether the dependency is ready + Ready bool `json:"ready"` } diff --git a/api/v1alpha1/packagesource_types.go b/api/v1alpha1/packagesource_types.go index 9586b568..c0b943e8 100644 --- a/api/v1alpha1/packagesource_types.go +++ b/api/v1alpha1/packagesource_types.go @@ -21,7 +21,7 @@ import ( ) // +kubebuilder:object:root=true -// +kubebuilder:resource:scope=Cluster,shortName={pkgsrc,pkgsrcs} +// +kubebuilder:resource:scope=Cluster,shortName={pks} // +kubebuilder:subresource:status // +kubebuilder:printcolumn:name="Variants",type="string",JSONPath=".status.variants",description="Package variants (comma-separated)" // +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status",description="Ready status" diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 10f6ebad..7fe4f3e2 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -308,6 +308,21 @@ func (in *CozystackResourceDefinitionSpec) DeepCopy() *CozystackResourceDefiniti return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DependencyStatus) DeepCopyInto(out *DependencyStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DependencyStatus. +func (in *DependencyStatus) DeepCopy() *DependencyStatus { + if in == nil { + return nil + } + out := new(DependencyStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Library) DeepCopyInto(out *Library) { *out = *in @@ -567,6 +582,13 @@ func (in *PackageStatus) DeepCopyInto(out *PackageStatus) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.Dependencies != nil { + in, out := &in.Dependencies, &out.Dependencies + *out = make(map[string]DependencyStatus, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PackageStatus. diff --git a/cmd/cozystack-operator/main.go b/cmd/cozystack-operator/main.go index 145a9e80..d9c79057 100644 --- a/cmd/cozystack-operator/main.go +++ b/cmd/cozystack-operator/main.go @@ -31,6 +31,7 @@ import ( cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1" helmv2 "github.com/fluxcd/helm-controller/api/v2" sourcev1 "github.com/fluxcd/source-controller/api/v1" + sourcewatcherv1beta1 "github.com/fluxcd/source-watcher/api/v2/v1beta1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -45,6 +46,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook" "github.com/cozystack/cozystack/internal/fluxinstall" + "github.com/cozystack/cozystack/internal/operator" // +kubebuilder:scaffold:imports ) @@ -59,6 +61,7 @@ func init() { utilruntime.Must(cozyv1alpha1.AddToScheme(scheme)) utilruntime.Must(helmv2.AddToScheme(scheme)) utilruntime.Must(sourcev1.AddToScheme(scheme)) + utilruntime.Must(sourcewatcherv1beta1.AddToScheme(scheme)) // +kubebuilder:scaffold:scheme } @@ -166,6 +169,24 @@ func main() { } } + // Setup PackageSource reconciler + if err := (&operator.PackageSourceReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "PackageSource") + os.Exit(1) + } + + // Setup Package reconciler + if err := (&operator.PackageReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Package") + os.Exit(1) + } + // +kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/go.mod b/go.mod index 846e8718..94029a8e 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ go 1.25.0 require ( github.com/fluxcd/helm-controller/api v1.4.3 github.com/fluxcd/source-controller/api v1.7.4 + github.com/fluxcd/source-watcher/api/v2 v2.0.3 github.com/go-logr/logr v1.4.3 github.com/go-logr/zapr v1.3.0 github.com/google/gofuzz v1.2.0 diff --git a/go.sum b/go.sum index fd2afd4c..d2fbc50a 100644 --- a/go.sum +++ b/go.sum @@ -45,6 +45,8 @@ github.com/fluxcd/pkg/apis/meta v1.23.0 h1:fLis5YcHnOsyKYptzBtituBm5EWNx13I0bXQs github.com/fluxcd/pkg/apis/meta v1.23.0/go.mod h1:UWsIbBPCxYvoVklr2mV2uLFBf/n17dNAmKFjRfApdDo= github.com/fluxcd/source-controller/api v1.7.4 h1:+EOVnRA9LmLxOx7J273l7IOEU39m+Slt/nQGBy69ygs= github.com/fluxcd/source-controller/api v1.7.4/go.mod h1:ruf49LEgZRBfcP+eshl2n9SX1MfHayCcViAIGnZcaDY= +github.com/fluxcd/source-watcher/api/v2 v2.0.3 h1:SsVGAaMBxzvcgrOz/Kl6c2ybMHVqoiEFwtI+bDuSeSs= +github.com/fluxcd/source-watcher/api/v2 v2.0.3/go.mod h1:Nx3QZweVyuhaOtSNrw+oxifG+qrakPvjgNAN9qlUTb0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= diff --git a/internal/operator/package_reconciler.go b/internal/operator/package_reconciler.go new file mode 100644 index 00000000..879fe3d9 --- /dev/null +++ b/internal/operator/package_reconciler.go @@ -0,0 +1,944 @@ +/* +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" + helmv2 "github.com/fluxcd/helm-controller/api/v2" + corev1 "k8s.io/api/core/v1" + 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/handler" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// PackageReconciler reconciles Package resources +type PackageReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups=cozystack.io,resources=packages,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=cozystack.io,resources=packages/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=cozystack.io,resources=packagesources,verbs=get;list;watch +// +kubebuilder:rbac:groups=helm.toolkit.fluxcd.io,resources=helmreleases,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 *PackageReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + pkg := &cozyv1alpha1.Package{} + if err := r.Get(ctx, req.NamespacedName, pkg); err != nil { + if apierrors.IsNotFound(err) { + // Resource not found, return (ownerReference will handle cleanup) + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + // Get PackageSource with the same name + packageSource := &cozyv1alpha1.PackageSource{} + if err := r.Get(ctx, types.NamespacedName{Name: pkg.Name}, packageSource); err != nil { + if apierrors.IsNotFound(err) { + meta.SetStatusCondition(&pkg.Status.Conditions, metav1.Condition{ + Type: "Ready", + Status: metav1.ConditionFalse, + Reason: "PackageSourceNotFound", + Message: fmt.Sprintf("PackageSource %s not found", pkg.Name), + }) + if err := r.Status().Update(ctx, pkg); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + // Determine variant (default to "default" if not specified) + variantName := pkg.Spec.Variant + if variantName == "" { + variantName = "default" + } + + // Find the variant in PackageSource + var variant *cozyv1alpha1.Variant + for i := range packageSource.Spec.Variants { + if packageSource.Spec.Variants[i].Name == variantName { + variant = &packageSource.Spec.Variants[i] + break + } + } + + if variant == nil { + meta.SetStatusCondition(&pkg.Status.Conditions, metav1.Condition{ + Type: "Ready", + Status: metav1.ConditionFalse, + Reason: "VariantNotFound", + Message: fmt.Sprintf("Variant %s not found in PackageSource %s", variantName, pkg.Name), + }) + if err := r.Status().Update(ctx, pkg); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil + } + + // Reconcile namespaces from components + if err := r.reconcileNamespaces(ctx, pkg, variant); err != nil { + logger.Error(err, "failed to reconcile namespaces") + return ctrl.Result{}, err + } + + // Update dependencies status + if err := r.updateDependenciesStatus(ctx, pkg, variant); err != nil { + logger.Error(err, "failed to update dependencies status") + // Don't return error, continue with reconciliation + } + + // Validate variant dependencies before creating HelmReleases + // Check if all dependencies are ready based on status + if !r.areDependenciesReady(pkg, variant) { + logger.Info("variant dependencies not ready, skipping HelmRelease creation", "package", pkg.Name) + meta.SetStatusCondition(&pkg.Status.Conditions, metav1.Condition{ + Type: "Ready", + Status: metav1.ConditionFalse, + Reason: "DependenciesNotReady", + Message: "One or more dependencies are not ready", + }) + if err := r.Status().Update(ctx, pkg); err != nil { + return ctrl.Result{}, err + } + // Return success to avoid requeue, but don't create HelmReleases + return ctrl.Result{}, nil + } + + // Create HelmReleases for components with Install section + helmReleaseCount := 0 + for _, component := range variant.Components { + // Skip components without Install section + if component.Install == nil { + continue + } + + // Check if component is disabled via Package spec + if pkgComponent, ok := pkg.Spec.Components[component.Name]; ok { + if pkgComponent.Enabled != nil && !*pkgComponent.Enabled { + logger.V(1).Info("skipping disabled component", "package", pkg.Name, "component", component.Name) + continue + } + } + + // Build artifact name: -- (with dots replaced by dashes) + artifactName := fmt.Sprintf("%s-%s-%s", + strings.ReplaceAll(packageSource.Name, ".", "-"), + strings.ReplaceAll(variantName, ".", "-"), + strings.ReplaceAll(component.Name, ".", "-")) + + // Namespace must be set + namespace := component.Install.Namespace + if namespace == "" { + logger.Error(fmt.Errorf("component %s has empty namespace in Install section", component.Name), "namespace validation failed") + meta.SetStatusCondition(&pkg.Status.Conditions, metav1.Condition{ + Type: "Ready", + Status: metav1.ConditionFalse, + Reason: "InvalidConfiguration", + Message: fmt.Sprintf("Component %s has empty namespace in Install section", component.Name), + }) + if err := r.Status().Update(ctx, pkg); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, fmt.Errorf("component %s has empty namespace in Install section", component.Name) + } + + // Determine release name (from Install or use component name) + releaseName := component.Install.ReleaseName + if releaseName == "" { + releaseName = component.Name + } + + // Build labels + labels := make(map[string]string) + labels["cozystack.io/package"] = pkg.Name + if component.Install.Privileged { + labels["cozystack.io/privileged"] = "true" + } + + // Create HelmRelease + hr := &helmv2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: releaseName, + Namespace: namespace, + Labels: labels, + }, + Spec: helmv2.HelmReleaseSpec{ + Interval: metav1.Duration{Duration: 5 * 60 * 1000000000}, // 5m + ChartRef: &helmv2.CrossNamespaceSourceReference{ + Kind: "ExternalArtifact", + Name: artifactName, + Namespace: "cozy-system", + }, + Install: &helmv2.Install{ + Remediation: &helmv2.InstallRemediation{ + Retries: -1, + }, + }, + Upgrade: &helmv2.Upgrade{ + Remediation: &helmv2.UpgradeRemediation{ + Retries: -1, + }, + }, + }, + } + + // Set ownerReference + gvk, err := apiutil.GVKForObject(pkg, r.Scheme) + if err != nil { + logger.Error(err, "failed to get GVK for Package") + meta.SetStatusCondition(&pkg.Status.Conditions, metav1.Condition{ + Type: "Ready", + Status: metav1.ConditionFalse, + Reason: "InternalError", + Message: fmt.Sprintf("Failed to get GVK for Package: %v", err), + }) + if err := r.Status().Update(ctx, pkg); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, fmt.Errorf("failed to get GVK for Package: %w", err) + } + hr.OwnerReferences = []metav1.OwnerReference{ + { + APIVersion: gvk.GroupVersion().String(), + Kind: gvk.Kind, + Name: pkg.Name, + UID: pkg.UID, + Controller: func() *bool { b := true; return &b }(), + }, + } + + // Merge values from Package spec if provided + if pkgComponent, ok := pkg.Spec.Components[component.Name]; ok && pkgComponent.Values != nil { + hr.Spec.Values = pkgComponent.Values + } + + // Build DependsOn from component Install and variant DependsOn + dependsOn, err := r.buildDependsOn(ctx, pkg, packageSource, variant, &component) + if err != nil { + logger.Error(err, "failed to build DependsOn", "component", component.Name) + meta.SetStatusCondition(&pkg.Status.Conditions, metav1.Condition{ + Type: "Ready", + Status: metav1.ConditionFalse, + Reason: "DependsOnFailed", + Message: fmt.Sprintf("Failed to build DependsOn for component %s: %v", component.Name, err), + }) + if err := r.Status().Update(ctx, pkg); err != nil { + return ctrl.Result{}, err + } + // Return nil to stop reconciliation, error is recorded in status + return ctrl.Result{}, nil + } + if len(dependsOn) > 0 { + hr.Spec.DependsOn = dependsOn + } + + // Set valuesFiles annotation + if len(component.ValuesFiles) > 0 { + if hr.Annotations == nil { + hr.Annotations = make(map[string]string) + } + hr.Annotations["cozyhr.cozystack.io/values-files"] = strings.Join(component.ValuesFiles, ",") + } + + if err := r.createOrUpdateHelmRelease(ctx, hr); err != nil { + logger.Error(err, "failed to reconcile HelmRelease", "name", releaseName, "namespace", namespace) + meta.SetStatusCondition(&pkg.Status.Conditions, metav1.Condition{ + Type: "Ready", + Status: metav1.ConditionFalse, + Reason: "HelmReleaseFailed", + Message: fmt.Sprintf("Failed to create HelmRelease %s: %v", releaseName, err), + }) + if err := r.Status().Update(ctx, pkg); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, err + } + + helmReleaseCount++ + logger.Info("reconciled HelmRelease", "package", pkg.Name, "component", component.Name, "releaseName", releaseName, "namespace", namespace) + } + + // Cleanup orphaned HelmReleases + if err := r.cleanupOrphanedHelmReleases(ctx, pkg, variant); err != nil { + logger.Error(err, "failed to cleanup orphaned HelmReleases") + // Don't return error, continue with status update + } + + // Update status with success message + message := fmt.Sprintf("reconciliation succeeded, generated %d helmrelease(s)", helmReleaseCount) + meta.SetStatusCondition(&pkg.Status.Conditions, metav1.Condition{ + Type: "Ready", + Status: metav1.ConditionTrue, + Reason: "ReconciliationSucceeded", + Message: message, + }) + + if err := r.Status().Update(ctx, pkg); err != nil { + return ctrl.Result{}, err + } + + logger.Info("reconciled Package", "name", pkg.Name, "helmReleaseCount", helmReleaseCount) + + // Update dependencies status for Packages that depend on this Package + // This ensures they get re-enqueued when their dependency becomes ready + if err := r.updateDependentPackagesDependencies(ctx, pkg.Name); err != nil { + logger.V(1).Error(err, "failed to update dependent packages dependencies", "package", pkg.Name) + // Don't return error, this is best-effort + } + + // Dependent Packages will be automatically enqueued by the watch handler + // when this Package's status is updated (see SetupWithManager watch handler) + + return ctrl.Result{}, nil +} + +// createOrUpdateHelmRelease creates or updates a HelmRelease +func (r *PackageReconciler) createOrUpdateHelmRelease(ctx context.Context, hr *helmv2.HelmRelease) error { + existing := &helmv2.HelmRelease{} + key := types.NamespacedName{ + Name: hr.Name, + Namespace: hr.Namespace, + } + + err := r.Get(ctx, key, existing) + if apierrors.IsNotFound(err) { + return r.Create(ctx, hr) + } else if err != nil { + return err + } + + // Preserve resource version + hr.SetResourceVersion(existing.GetResourceVersion()) + + // Merge labels + labels := hr.GetLabels() + if labels == nil { + labels = make(map[string]string) + } + for k, v := range existing.GetLabels() { + if _, ok := labels[k]; !ok { + labels[k] = v + } + } + hr.SetLabels(labels) + + // Merge annotations + annotations := hr.GetAnnotations() + if annotations == nil { + annotations = make(map[string]string) + } + for k, v := range existing.GetAnnotations() { + if _, ok := annotations[k]; !ok { + annotations[k] = v + } + } + hr.SetAnnotations(annotations) + + // Update Spec + existing.Spec = hr.Spec + existing.SetLabels(hr.GetLabels()) + existing.SetAnnotations(hr.GetAnnotations()) + existing.SetOwnerReferences(hr.GetOwnerReferences()) + + return r.Update(ctx, existing) +} + +// getVariantForPackage retrieves the Variant for a given Package +// Returns the Variant and an error if not found +// If c is nil, uses the reconciler's client +func (r *PackageReconciler) getVariantForPackage(ctx context.Context, pkg *cozyv1alpha1.Package, c client.Client) (*cozyv1alpha1.Variant, error) { + // Use provided client or fall back to reconciler's client + cl := c + if cl == nil { + cl = r.Client + } + + // Determine variant name (default to "default" if not specified) + variantName := pkg.Spec.Variant + if variantName == "" { + variantName = "default" + } + + // Get the PackageSource + packageSource := &cozyv1alpha1.PackageSource{} + if err := cl.Get(ctx, types.NamespacedName{Name: pkg.Name}, packageSource); err != nil { + if apierrors.IsNotFound(err) { + return nil, fmt.Errorf("PackageSource %s not found", pkg.Name) + } + return nil, fmt.Errorf("failed to get PackageSource %s: %w", pkg.Name, err) + } + + // Find the variant in PackageSource + var variant *cozyv1alpha1.Variant + for i := range packageSource.Spec.Variants { + if packageSource.Spec.Variants[i].Name == variantName { + variant = &packageSource.Spec.Variants[i] + break + } + } + + if variant == nil { + return nil, fmt.Errorf("variant %s not found in PackageSource %s", variantName, pkg.Name) + } + + return variant, nil +} + +// buildDependsOn builds DependsOn list for a component +// Includes: +// 1. Dependencies from component.Install.DependsOn (with namespace from referenced component) +// 2. Dependencies from variant.DependsOn (all components with Install from referenced Package) +func (r *PackageReconciler) buildDependsOn(ctx context.Context, pkg *cozyv1alpha1.Package, packageSource *cozyv1alpha1.PackageSource, variant *cozyv1alpha1.Variant, component *cozyv1alpha1.Component) ([]helmv2.DependencyReference, error) { + logger := log.FromContext(ctx) + dependsOn := []helmv2.DependencyReference{} + + // Build map of component names to their release names and namespaces in current variant + componentMap := make(map[string]struct { + releaseName string + namespace string + }) + for _, comp := range variant.Components { + if comp.Install == nil { + continue + } + compNamespace := comp.Install.Namespace + if compNamespace == "" { + return nil, fmt.Errorf("component %s has empty namespace in Install section", comp.Name) + } + compReleaseName := comp.Install.ReleaseName + if compReleaseName == "" { + compReleaseName = comp.Name + } + componentMap[comp.Name] = struct { + releaseName string + namespace string + }{ + releaseName: compReleaseName, + namespace: compNamespace, + } + } + + // Add dependencies from component.Install.DependsOn + if len(component.Install.DependsOn) > 0 { + for _, depName := range component.Install.DependsOn { + depComp, ok := componentMap[depName] + if !ok { + return nil, fmt.Errorf("component %s not found in variant for dependency %s", depName, component.Name) + } + dependsOn = append(dependsOn, helmv2.DependencyReference{ + Name: depComp.releaseName, + Namespace: depComp.namespace, + }) + logger.V(1).Info("added component dependency", "component", component.Name, "dependsOn", depName, "releaseName", depComp.releaseName, "namespace", depComp.namespace) + } + } + + // Add dependencies from variant.DependsOn + if len(variant.DependsOn) > 0 { + for _, depPackageName := range variant.DependsOn { + // Check if dependency is in IgnoreDependencies + ignore := false + for _, ignoreDep := range pkg.Spec.IgnoreDependencies { + if ignoreDep == depPackageName { + ignore = true + break + } + } + if ignore { + logger.V(1).Info("ignoring dependency", "package", pkg.Name, "dependency", depPackageName) + continue + } + + // Get the Package + depPackage := &cozyv1alpha1.Package{} + if err := r.Get(ctx, types.NamespacedName{Name: depPackageName}, depPackage); err != nil { + if apierrors.IsNotFound(err) { + return nil, fmt.Errorf("dependent Package %s not found", depPackageName) + } + return nil, fmt.Errorf("failed to get dependent Package %s: %w", depPackageName, err) + } + + // Get the variant from dependent Package + depVariant, err := r.getVariantForPackage(ctx, depPackage, nil) + if err != nil { + return nil, fmt.Errorf("failed to get variant for dependent Package %s: %w", depPackageName, err) + } + + // Add all components with Install from dependent variant + for _, depComp := range depVariant.Components { + if depComp.Install == nil { + continue + } + + // Check if component is disabled in dependent Package + if depPkgComponent, ok := depPackage.Spec.Components[depComp.Name]; ok { + if depPkgComponent.Enabled != nil && !*depPkgComponent.Enabled { + continue + } + } + + depCompNamespace := depComp.Install.Namespace + if depCompNamespace == "" { + return nil, fmt.Errorf("component %s in dependent Package %s has empty namespace in Install section", depComp.Name, depPackageName) + } + depCompReleaseName := depComp.Install.ReleaseName + if depCompReleaseName == "" { + depCompReleaseName = depComp.Name + } + + dependsOn = append(dependsOn, helmv2.DependencyReference{ + Name: depCompReleaseName, + Namespace: depCompNamespace, + }) + logger.V(1).Info("added variant dependency", "package", pkg.Name, "dependency", depPackageName, "component", depComp.Name, "releaseName", depCompReleaseName, "namespace", depCompNamespace) + } + } + } + + return dependsOn, nil +} + +// updateDependenciesStatus updates the dependencies status in Package status +// It checks the readiness of each dependency and updates pkg.Status.Dependencies +// Old dependency keys that are no longer in the dependency list are removed +func (r *PackageReconciler) updateDependenciesStatus(ctx context.Context, pkg *cozyv1alpha1.Package, variant *cozyv1alpha1.Variant) error { + logger := log.FromContext(ctx) + + // Initialize dependencies map if nil + if pkg.Status.Dependencies == nil { + pkg.Status.Dependencies = make(map[string]cozyv1alpha1.DependencyStatus) + } + + // Build set of current dependencies (excluding ignored ones) + currentDeps := make(map[string]bool) + if len(variant.DependsOn) > 0 { + for _, depPackageName := range variant.DependsOn { + // Check if dependency is in IgnoreDependencies + ignore := false + for _, ignoreDep := range pkg.Spec.IgnoreDependencies { + if ignoreDep == depPackageName { + ignore = true + break + } + } + if ignore { + logger.V(1).Info("ignoring dependency", "package", pkg.Name, "dependency", depPackageName) + continue + } + currentDeps[depPackageName] = true + } + } + + // Remove old dependencies that are no longer in the list + for depName := range pkg.Status.Dependencies { + if !currentDeps[depName] { + delete(pkg.Status.Dependencies, depName) + logger.V(1).Info("removed old dependency from status", "package", pkg.Name, "dependency", depName) + } + } + + // Update status for each current dependency + for depPackageName := range currentDeps { + // Get the Package + depPackage := &cozyv1alpha1.Package{} + if err := r.Get(ctx, types.NamespacedName{Name: depPackageName}, depPackage); err != nil { + if apierrors.IsNotFound(err) { + // Dependency not found, mark as not ready + pkg.Status.Dependencies[depPackageName] = cozyv1alpha1.DependencyStatus{ + Ready: false, + } + logger.V(1).Info("dependency not found, marking as not ready", "package", pkg.Name, "dependency", depPackageName) + continue + } + // Error getting dependency, keep existing status or mark as not ready + if _, exists := pkg.Status.Dependencies[depPackageName]; !exists { + pkg.Status.Dependencies[depPackageName] = cozyv1alpha1.DependencyStatus{ + Ready: false, + } + } + logger.V(1).Error(err, "failed to get dependency, keeping existing status", "package", pkg.Name, "dependency", depPackageName) + continue + } + + // Check Ready condition + readyCondition := meta.FindStatusCondition(depPackage.Status.Conditions, "Ready") + isReady := readyCondition != nil && readyCondition.Status == metav1.ConditionTrue + + // Update dependency status + pkg.Status.Dependencies[depPackageName] = cozyv1alpha1.DependencyStatus{ + Ready: isReady, + } + logger.V(1).Info("updated dependency status", "package", pkg.Name, "dependency", depPackageName, "ready", isReady) + } + + return nil +} + +// areDependenciesReady checks if all dependencies are ready based on status +func (r *PackageReconciler) areDependenciesReady(pkg *cozyv1alpha1.Package, variant *cozyv1alpha1.Variant) bool { + if len(variant.DependsOn) == 0 { + return true + } + + for _, depPackageName := range variant.DependsOn { + // Check if dependency is in IgnoreDependencies + ignore := false + for _, ignoreDep := range pkg.Spec.IgnoreDependencies { + if ignoreDep == depPackageName { + ignore = true + break + } + } + if ignore { + continue + } + + // Check dependency status + depStatus, exists := pkg.Status.Dependencies[depPackageName] + if !exists || !depStatus.Ready { + return false + } + } + + return true +} + +// updateDependentPackagesDependencies updates dependencies status for all Packages that depend on the given Package +// This ensures dependent packages get re-enqueued when their dependency status changes +func (r *PackageReconciler) updateDependentPackagesDependencies(ctx context.Context, packageName string) error { + logger := log.FromContext(ctx) + + // Get all Packages + packageList := &cozyv1alpha1.PackageList{} + if err := r.List(ctx, packageList); err != nil { + return fmt.Errorf("failed to list Packages: %w", err) + } + + // Get the updated Package to check its readiness + updatedPkg := &cozyv1alpha1.Package{} + if err := r.Get(ctx, types.NamespacedName{Name: packageName}, updatedPkg); err != nil { + if apierrors.IsNotFound(err) { + return nil // Package not found, nothing to update + } + return fmt.Errorf("failed to get Package %s: %w", packageName, err) + } + + // Check Ready condition of the updated Package + readyCondition := meta.FindStatusCondition(updatedPkg.Status.Conditions, "Ready") + isReady := readyCondition != nil && readyCondition.Status == metav1.ConditionTrue + + // For each Package, check if it depends on the given Package + for _, pkg := range packageList.Items { + // Skip the Package itself + if pkg.Name == packageName { + continue + } + + // Get variant + variant, err := r.getVariantForPackage(ctx, &pkg, nil) + if err != nil { + // Continue if PackageSource or variant not found (best-effort operation) + logger.V(1).Info("skipping package, failed to get variant", "package", pkg.Name, "error", err) + continue + } + + // Check if this Package depends on the given Package + dependsOn := false + for _, dep := range variant.DependsOn { + // Check if dependency is in IgnoreDependencies + ignore := false + for _, ignoreDep := range pkg.Spec.IgnoreDependencies { + if ignoreDep == dep { + ignore = true + break + } + } + if ignore { + continue + } + + if dep == packageName { + dependsOn = true + break + } + } + + if dependsOn { + // Update the dependency status in this Package + if pkg.Status.Dependencies == nil { + pkg.Status.Dependencies = make(map[string]cozyv1alpha1.DependencyStatus) + } + pkg.Status.Dependencies[packageName] = cozyv1alpha1.DependencyStatus{ + Ready: isReady, + } + if err := r.Status().Update(ctx, &pkg); err != nil { + logger.V(1).Error(err, "failed to update dependency status for dependent Package", "package", pkg.Name, "dependency", packageName) + continue + } + logger.V(1).Info("updated dependency status for dependent Package", "package", pkg.Name, "dependency", packageName, "ready", isReady) + } + } + + return nil +} + +// reconcileNamespaces creates or updates namespaces based on components in the variant +func (r *PackageReconciler) reconcileNamespaces(ctx context.Context, pkg *cozyv1alpha1.Package, variant *cozyv1alpha1.Variant) error { + logger := log.FromContext(ctx) + + // Collect namespaces from components + // Map: namespace -> {isPrivileged} + type namespaceInfo struct { + privileged bool + } + namespacesMap := make(map[string]namespaceInfo) + + for _, component := range variant.Components { + // Skip components without Install section + if component.Install == nil { + continue + } + + // Check if component is disabled via Package spec + if pkgComponent, ok := pkg.Spec.Components[component.Name]; ok { + if pkgComponent.Enabled != nil && !*pkgComponent.Enabled { + continue + } + } + + // Namespace must be set + namespace := component.Install.Namespace + if namespace == "" { + return fmt.Errorf("component %s has empty namespace in Install section", component.Name) + } + + info, exists := namespacesMap[namespace] + if !exists { + info = namespaceInfo{ + privileged: false, + } + } + + // If component is privileged, mark namespace as privileged + if component.Install.Privileged { + info.privileged = true + } + + namespacesMap[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" + } + + 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) + } + + return nil +} + +// createOrUpdateNamespace creates or updates a namespace using server-side apply +func (r *PackageReconciler) createOrUpdateNamespace(ctx context.Context, namespace *corev1.Namespace) error { + // Ensure TypeMeta is set for server-side apply + namespace.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("Namespace")) + + // Use server-side apply with field manager + // This is atomic and avoids race conditions from Get/Create/Update pattern + // Labels and annotations will be merged automatically by the server + // Each label/annotation key is treated as a separate field, so existing ones are preserved + return r.Patch(ctx, namespace, client.Apply, client.FieldOwner("cozystack-package-controller")) +} + +// cleanupOrphanedHelmReleases removes HelmReleases that are no longer needed +func (r *PackageReconciler) cleanupOrphanedHelmReleases(ctx context.Context, pkg *cozyv1alpha1.Package, variant *cozyv1alpha1.Variant) error { + logger := log.FromContext(ctx) + + // Build map of desired HelmRelease names (from components with Install) + desiredReleases := make(map[types.NamespacedName]bool) + for _, component := range variant.Components { + if component.Install == nil { + continue + } + + // Check if component is disabled via Package spec + if pkgComponent, ok := pkg.Spec.Components[component.Name]; ok { + if pkgComponent.Enabled != nil && !*pkgComponent.Enabled { + continue + } + } + + namespace := component.Install.Namespace + if namespace == "" { + // Skip components with empty namespace (they shouldn't exist anyway) + continue + } + + releaseName := component.Install.ReleaseName + if releaseName == "" { + releaseName = component.Name + } + + desiredReleases[types.NamespacedName{ + Name: releaseName, + Namespace: namespace, + }] = true + } + + // Find all HelmReleases owned by this Package + hrList := &helmv2.HelmReleaseList{} + if err := r.List(ctx, hrList, client.MatchingLabels{ + "cozystack.io/package": pkg.Name, + }); err != nil { + return err + } + + // Delete HelmReleases that are not in desired list + for _, hr := range hrList.Items { + key := types.NamespacedName{ + Name: hr.Name, + Namespace: hr.Namespace, + } + if !desiredReleases[key] { + logger.Info("deleting orphaned HelmRelease", "name", hr.Name, "namespace", hr.Namespace, "package", pkg.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) + } + } + } + + return nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *PackageReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + Named("cozystack-package"). + For(&cozyv1alpha1.Package{}). + Watches( + &cozyv1alpha1.PackageSource{}, + handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request { + ps, ok := obj.(*cozyv1alpha1.PackageSource) + if !ok { + return nil + } + // Find Package with the same name as PackageSource + // PackageSource and Package share the same name + pkg := &cozyv1alpha1.Package{} + if err := mgr.GetClient().Get(ctx, types.NamespacedName{Name: ps.Name}, pkg); err != nil { + // Package not found, that's ok - it might not exist yet + return nil + } + // Trigger reconcile for the corresponding Package + return []reconcile.Request{{ + NamespacedName: types.NamespacedName{ + Name: pkg.Name, + }, + }} + }), + ). + Watches( + &cozyv1alpha1.Package{}, + handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request { + updatedPkg, ok := obj.(*cozyv1alpha1.Package) + if !ok { + return nil + } + // Find all Packages that depend on this Package + packageList := &cozyv1alpha1.PackageList{} + if err := mgr.GetClient().List(ctx, packageList); err != nil { + return nil + } + var requests []reconcile.Request + for _, pkg := range packageList.Items { + if pkg.Name == updatedPkg.Name { + continue // Skip the Package itself + } + // Get variant to check dependencies + variant, err := r.getVariantForPackage(ctx, &pkg, mgr.GetClient()) + if err != nil { + // Continue if PackageSource or variant not found + continue + } + // Check if this variant depends on updatedPkg + for _, dep := range variant.DependsOn { + // Check if dependency is in IgnoreDependencies + ignore := false + for _, ignoreDep := range pkg.Spec.IgnoreDependencies { + if ignoreDep == dep { + ignore = true + break + } + } + if ignore { + continue + } + if dep == updatedPkg.Name { + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: pkg.Name, + }, + }) + break + } + } + } + return requests + }), + ). + Complete(r) +} diff --git a/internal/operator/packagesource_reconciler.go b/internal/operator/packagesource_reconciler.go new file mode 100644 index 00000000..dfbc096d --- /dev/null +++ b/internal/operator/packagesource_reconciler.go @@ -0,0 +1,434 @@ +/* +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/handler" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// 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: -- + // 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{}). + Watches( + &sourcewatcherv1beta1.ArtifactGenerator{}, + handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request { + ag, ok := obj.(*sourcewatcherv1beta1.ArtifactGenerator) + if !ok { + return nil + } + // Find the PackageSource that owns this ArtifactGenerator by ownerReference + for _, ownerRef := range ag.OwnerReferences { + if ownerRef.Kind == "PackageSource" { + return []reconcile.Request{{ + NamespacedName: types.NamespacedName{ + Name: ownerRef.Name, + }, + }} + } + } + return nil + }), + ). + Complete(r) +} + diff --git a/packages/core/installer/definitions/cozystack.io_packages.yaml b/packages/core/installer/definitions/cozystack.io_packages.yaml index e048b636..cb7e2763 100644 --- a/packages/core/installer/definitions/cozystack.io_packages.yaml +++ b/packages/core/installer/definitions/cozystack.io_packages.yaml @@ -148,6 +148,21 @@ spec: - type type: object type: array + dependencies: + additionalProperties: + description: DependencyStatus represents the readiness status of + a dependency + properties: + ready: + description: Ready indicates whether the dependency is ready + type: boolean + required: + - ready + type: object + description: |- + Dependencies tracks the readiness status of each dependency + Key is the dependency package name, value indicates if the dependency is ready + type: object type: object type: object served: true diff --git a/packages/core/installer/definitions/cozystack.io_packagesources.yaml b/packages/core/installer/definitions/cozystack.io_packagesources.yaml index 92a6335f..0acfcdd2 100644 --- a/packages/core/installer/definitions/cozystack.io_packagesources.yaml +++ b/packages/core/installer/definitions/cozystack.io_packagesources.yaml @@ -12,8 +12,7 @@ spec: listKind: PackageSourceList plural: packagesources shortNames: - - pkgsrc - - pkgsrcs + - pks singular: packagesource scope: Cluster versions: