[cozystack-operator] Add Package and PackageSource reconcilers

Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
This commit is contained in:
Andrei Kvapil 2025-12-24 21:29:41 +01:00
parent 2d4e568efe
commit f28d639aea
No known key found for this signature in database
GPG key ID: 931CF7FEACEAF765
10 changed files with 1452 additions and 3 deletions

View file

@ -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"`
}

View file

@ -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"

View file

@ -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.

View file

@ -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 {

1
go.mod
View file

@ -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

2
go.sum
View file

@ -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=

View file

@ -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: <packagesource>-<variant>-<componentname> (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)
}

View file

@ -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: <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{}).
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)
}

View file

@ -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

View file

@ -12,8 +12,7 @@ spec:
listKind: PackageSourceList
plural: packagesources
shortNames:
- pkgsrc
- pkgsrcs
- pks
singular: packagesource
scope: Cluster
versions: