Files
cozystack/internal/operator/packagesource_reconciler.go
T
Aleksei Sviridkin adc2dcb5ca fix(operator): rebuild each changed ArtifactGenerator generation once
source-watcher builds an ArtifactGenerator from the spec it read when
its reconcile started, but its final status patch re-reads the live
object and marks Ready with the live generation. If the spec changes
during a build, which an upgrade does as it rolls out a new packages
source and new copy rules within seconds of each other, the generator
reports the new generation as built while its artifacts come from the
old one, and source-watcher never rebuilds them. A chart can then ship
without a library its templates now require.

The generator status cannot tell a stale build from a good one, so the
operator forces one rebuild of every changed generation once it
reports Ready, through the existing force-drift path. An unchanged spec
rebuilds to the same digest, so nothing downstream moves. The recovery
messages no longer name one upstream race, since they now serve two.

Upstream: fluxcd/source-watcher#384.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-09-30 01:22:49 +03:00

1125 lines
51 KiB
Go

/*
Copyright 2025 The Cozystack Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package operator
import (
"context"
"fmt"
"maps"
"strconv"
"strings"
"time"
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
"github.com/cozystack/cozystack/internal/marketplace/collision"
"github.com/cozystack/cozystack/internal/marketplace/naming"
"github.com/cozystack/cozystack/internal/marketplace/tapconst"
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"
)
// Constants tuning the workaround for fluxcd/pkg#934 (patch.Helper split-write
// race in source-watcher v2.1.0). See the block comment on
// maybeRecoverArtifactGenerator for the mechanism.
//
// Version-skew note. Comments in this file reference upstream source-watcher
// internals by exact file:line (e.g. `drift.go:52`, `controller.go:164/254`).
// Those anchors are for the DEPLOYED source-watcher binary, currently pinned
// to v2.1.0 at `internal/fluxinstall/manifests/fluxcd.yaml`. The Go module
// dependency in `go.mod` (`github.com/fluxcd/source-watcher/api/v2 v2.0.3`)
// only pulls the CRD types package — behavioural code lives in the deployed
// binary. When the flux distribution bumps source-watcher, re-verify the
// line anchors AND the watch predicate (`ReconcileRequestedPredicate`) and
// drift-detection heuristics against the new version — those are the
// load-bearing upstream assumptions this workaround stands on.
//
// The schedule is deliberately conservative:
//
// - `stuckGracePeriod = 30s` — source-watcher normally settles an
// ArtifactGenerator in under 10s once it takes the drifted branch, so a
// 30s Unknown window comfortably distinguishes a real stall from a
// legitimate in-flight rebuild.
// - `initialBackoff = 30s`, `maxBackoff = 4m`, `maxRecoveryAttempts = 5` —
// exponential 30s + 60s + 2m + 4m + 4m gives ~11.5m of total budget
// before we give up, safely inside the 15m upstream HelmRelease install
// timeout so a real stall surfaces as SourceWatcherStalled rather than
// silently timing out an install.
//
// HA note: the retry driver stores its state (attempt counter, last-force
// timestamp) as annotations on the ArtifactGenerator itself, so a single
// replica converges deterministically. Running cozystack-operator with
// multiple replicas WITHOUT leader election will let both replicas race on
// annotation writes, corrupting the counter and the elapsed-time math. Deploy
// this operator with `--leader-elect=true` (or replicas=1).
const (
stuckGracePeriod = 30 * time.Second
initialBackoff = 30 * time.Second
maxBackoff = 4 * time.Minute
maxRecoveryAttempts = 5
annotationFluxRequestedAt = "reconcile.fluxcd.io/requestedAt"
annotationRecoveryAttempts = "cozystack.io/source-watcher-recovery-attempts"
annotationLastRecoveryAt = "cozystack.io/source-watcher-last-recovery-at"
// annotationVerifiedGeneration records the last AG generation for which a
// rebuild was forced after source-watcher first reported it Ready; see
// verifyArtifactGeneratorGeneration.
annotationVerifiedGeneration = "cozystack.io/source-watcher-verified-generation"
reasonAwaitingRecovery = "AwaitingSourceWatcherRecovery"
reasonSourceWatcherBad = "SourceWatcherStalled"
// reasonRecoveryForced is the Reason we stamp on the AG's Ready condition
// when forcing drift. Kept as a constant because updateStatus needs to
// recognise its own writes reflecting back through the Owns() watch and
// skip the not-stuck / clearRecoveryTracking path for them (see B2 in
// lexfrei's review on PR #3182).
reasonRecoveryForced = "SourceWatcherRecoveryForced"
)
// nowFunc is overridable in tests; production code always uses time.Now.
var nowFunc = time.Now
// 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
}
// Skip work on objects that are already being torn down. Without this
// guard the retry driver could keep writing annotations to an
// ArtifactGenerator whose owning PackageSource is about to be garbage
// collected, and Status().Update below would leave a misleading
// "AwaitingSourceWatcherRecovery" condition on a doomed object.
// ownerReference cascade takes care of the actual AG cleanup.
if !packageSource.DeletionTimestamp.IsZero() {
return ctrl.Result{}, nil
}
// 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).
// The status update may schedule a follow-up reconcile via RequeueAfter when
// it detects a source-watcher status-patch stall and needs to wait for the
// next backoff window; that Result is honoured on the way out. `now` is
// threaded down as a single sample so the whole reconcile agrees on one
// wall-clock reading. Errors from updateStatus are propagated so
// controller-runtime's exponential backoff can retry transient failures —
// swallowing them would drop force-drift or backoff-schedule writes on the
// floor and leave the retry driver in a stale state.
return r.updateStatus(ctx, packageSource, nowFunc())
}
// 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{}
// Withhold a tap source's PRIVILEGED component artifacts until the registration
// Package is confirmed, so the helm-controller has no privileged content to
// upgrade a HelmRelease from. This narrows, but does not fully close, the window
// in which a benign component flipped to privileged in a new revision could be
// materialised by source-watcher before this reconciler removes it from the
// generator (see issue #4359); the Package reconciler's install-site and
// cleanup gates are the other best-effort layers. The registration Package
// shares the PackageSource's name; `cozypkg add --allow-privileged` sheds the
// tap label on it, and a Package watch re-runs this generation. Non-tap
// (platform) sources carry no marketplace-tap label and are never gated.
privilegedConfirmed := true
if packageSource.GetLabels()[tapconst.Label] == "true" {
privilegedConfirmed = false
var regPkg cozyv1alpha1.Package
err := r.Get(ctx, types.NamespacedName{Name: packageSource.Name}, &regPkg)
if err == nil {
privilegedConfirmed = collision.PrivilegedConfirmed(&regPkg)
} else if !apierrors.IsNotFound(err) {
return err
}
}
// 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)
// Withhold a privileged component's artifact until the registration is
// confirmed, so the helm-controller has no content to upgrade a
// HelmRelease to. See privilegedConfirmed above.
if component.Install != nil && component.Install.Privileged && !privilegedConfirmed {
logger.Info("withholding privileged component artifact until registration is confirmed", "packageSource", packageSource.Name, "variant", variant.Name, "component", component.Name)
continue
}
// 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>, dots
// replaced by dashes to comply with Kubernetes naming. This is the
// single definition shared with the marketplace backend and the
// validator via internal/marketplace/naming.
artifactName := naming.ArtifactName(packageSource.Name, variant.Name, 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). It may return a Result with RequeueAfter set when the
// ArtifactGenerator's upstream Ready condition is stuck and the reconciler is
// driving source-watcher through a bounded requeue schedule; see
// maybeRecoverArtifactGenerator for the strategy. `now` is passed by the
// caller so every time-sensitive check inside this reconcile agrees on the
// same wall-clock reading.
func (r *PackageSourceReconciler) updateStatus(ctx context.Context, packageSource *cozyv1alpha1.PackageSource, now time.Time) (ctrl.Result, 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",
ObservedGeneration: packageSource.Generation,
})
return ctrl.Result{}, 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",
ObservedGeneration: packageSource.Generation,
})
return ctrl.Result{}, r.Status().Update(ctx, packageSource)
}
return ctrl.Result{}, fmt.Errorf("failed to get ArtifactGenerator: %w", err)
}
// Find Ready condition in ArtifactGenerator
readyCondition := meta.FindStatusCondition(ag.Status.Conditions, "Ready")
// Route to the recovery state machine if any of the following holds:
//
// (a) the AG's Ready condition carries our own reasonRecoveryForced
// marker (Owns(&ArtifactGenerator{}) re-firing on the writes
// from forceArtifactGeneratorDrift, or a still-un-reacted force
// from a previous reconcile);
// (b) recovery is already in progress (attempts > 0) and source-
// watcher has moved off our marker into Progressing
// (Ready=Unknown on the current generation) — the rebuild write
// cycle after taking the drifted branch;
// (c) the AG has been sitting in the fluxcd/pkg#934 stall signature
// (Inventory populated + digest set + Ready=Unknown / absent past
// grace) — the classic first-time stuck-detection path.
//
// All three funnel into maybeRecoverArtifactGenerator so its
// decideRecovery state machine is the single arbiter of "wait vs
// re-force vs give up". Splitting (a) and (b) into a separate
// wait-forever helper would trap us in a tight requeue loop if
// source-watcher never responds — the state machine's Wait branch is
// bounded by the same backoff schedule that drives Force, so
// exhaustion always surfaces SourceWatcherStalled honestly.
//
// Signals for (a) and (b) are exact: reasonRecoveryForced is only ever
// written by forceArtifactGeneratorDrift, so any Ready condition
// carrying it is necessarily our own reflection; the attempts counter
// is only ever set by that same function. Neither is observable from
// outside this reconciler.
//
// Asymmetric ObservedGeneration handling — deliberate, not oversight.
// `isProgressingInRecovery` requires `ObservedGeneration == Generation`
// so a Progressing condition left over from a previous spec cycle does
// NOT match the pattern (the AG is being rebuilt against fresh spec —
// no need to hold tracking around a stale Progressing). `isOwnMarker`
// does NOT check ObservedGeneration on purpose: if we wrote our
// marker at gen=N and the user then edits PS spec so AG.generation=N+1,
// our marker sits with `condition.ObservedGeneration=N`. That marker
// is stale, but we must still route through the state machine on it
// rather than falling into the not-stuck / clearRecoveryTracking
// branch — otherwise the copy-through path would leak our synthetic
// Ready=False/SourceWatcherRecoveryForced onto the PackageSource
// (exactly the B2 bug this whole gate exists to prevent).
// source-watcher's GenerationChangedPredicate fires on the AG's own
// generation bump, so the rebuild is triggered regardless of our
// marker's staleness — one wasted attempt in the worst case, and the
// state machine's Wait branch usually absorbs it without incrementing.
attempts, _ := readRecoveryTracking(ag)
isOwnMarker := readyCondition != nil && readyCondition.Reason == reasonRecoveryForced
isProgressingInRecovery := attempts > 0 && readyCondition != nil &&
readyCondition.Status == metav1.ConditionUnknown &&
readyCondition.ObservedGeneration == ag.Generation
if isOwnMarker || isProgressingInRecovery || artifactGeneratorStuck(ag, readyCondition, now) {
logger.V(1).Info("routing to recovery state machine",
"packageSource", packageSource.Name,
"artifactGenerator", ag.Name,
"attempts", attempts,
"ownMarker", isOwnMarker,
"progressingInRecovery", isProgressingInRecovery)
return r.maybeRecoverArtifactGenerator(ctx, packageSource, ag, readyCondition, now)
}
// AG is not stuck and not mid-recovery — clear any recovery-tracking
// annotations left behind by a previous stuck cycle, then either
// surface the missing-Ready case or copy the real condition through.
if err := r.clearRecoveryTracking(ctx, ag); err != nil {
logger.Error(err, "failed to clear recovery tracking annotations", "artifactGenerator", ag.Name)
// Non-fatal: annotations are best-effort bookkeeping.
}
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",
ObservedGeneration: packageSource.Generation,
})
// Fresh AG with no Ready yet — if source-watcher doesn't act within
// the grace period, artifactGeneratorStuck will fire on the next
// reconcile. Schedule that reconcile explicitly so we don't sit
// waiting for an AG-status change that may never come.
return ctrl.Result{RequeueAfter: agFollowUpDelay(ag.CreationTimestamp.Time, now)}, r.Status().Update(ctx, packageSource)
}
if needsGenerationVerification(ag, readyCondition) {
return r.verifyArtifactGeneratorGeneration(ctx, packageSource, ag, now)
}
// 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)
// If we just copied Ready=Unknown across, the artifactGeneratorStuck
// predicate will fire once the grace period elapses. Without an
// explicit RequeueAfter here, this reconciler would wait for a fresh
// AG-status change to re-fire via the Owns() watch — and if
// source-watcher's next write is the one lost to the fluxcd/pkg#934
// split-patch race (i.e., exactly the scenario this workaround exists
// for), no such change will land and we would sit dormant until the
// 10h informer resync. Schedule a follow-up reconcile at grace-period
// expiry so the recovery driver can take over. On Ready=True/False,
// the AG has resolved and no explicit follow-up is needed.
result := ctrl.Result{}
if readyCondition.Status == metav1.ConditionUnknown {
result.RequeueAfter = agFollowUpDelay(readyCondition.LastTransitionTime.Time, now)
}
return result, r.Status().Update(ctx, packageSource)
}
// agFollowUpDelay returns how long to wait before re-reconciling an AG whose
// Ready=Unknown state is still within the grace period. Anchor is the last
// state transition (or AG creation, when no Ready condition exists yet). The
// result is at least one second so we never schedule a zero-delay requeue
// tight loop when the grace period has just elapsed.
func agFollowUpDelay(transitionOrCreation time.Time, now time.Time) time.Duration {
remaining := transitionOrCreation.Add(stuckGracePeriod).Sub(now)
if remaining < time.Second {
return time.Second
}
return remaining
}
// needsGenerationVerification reports whether source-watcher has just claimed
// a successful build of an AG generation this operator has not yet verified.
//
// source-watcher builds from the spec it read when its reconcile started, but
// its final status patch re-reads the live object and stamps Ready with the
// live generation (fluxcd/source-watcher#384). When this operator changes the
// spec mid-build, the AG ends up Ready on the new generation with artifacts
// from the old one, and detectDrift never rebuilds it. Nothing in the AG
// status tells the two apart, so every generation that reaches Ready=True is
// rebuilt once. An unchanged spec rebuilds to the same digest, leaving the
// ExternalArtifacts and their HelmReleases untouched.
//
// Generation 1 is exempt: the race needs a spec change during a build.
//
// TODO(remove once fluxcd/source-watcher#384 is fixed and rolled out).
func needsGenerationVerification(ag *sourcewatcherv1beta1.ArtifactGenerator, ready *metav1.Condition) bool {
if ag.Generation <= 1 || ready.Status != metav1.ConditionTrue || ready.ObservedGeneration != ag.Generation {
return false
}
return ag.Annotations[annotationVerifiedGeneration] != strconv.FormatInt(ag.Generation, 10)
}
// verifyArtifactGeneratorGeneration forces the one rebuild described on
// needsGenerationVerification through the fluxcd/pkg#934 force-drift path, so
// the follow-up reconciles are driven by maybeRecoverArtifactGenerator and the
// synthetic Ready=False never reaches the PackageSource. It starts a fresh
// attempt budget, since a counter left by an earlier stall may have survived
// the best-effort clearRecoveryTracking. The verified generation is recorded
// after the force: if that write fails, the next Ready=True costs one more
// rebuild rather than a skipped one.
func (r *PackageSourceReconciler) verifyArtifactGeneratorGeneration(ctx context.Context, packageSource *cozyv1alpha1.PackageSource, ag *sourcewatcherv1beta1.ArtifactGenerator, now time.Time) (ctrl.Result, error) {
if err := r.forceArtifactGeneratorDrift(ctx, ag, now, 1); err != nil {
return ctrl.Result{}, fmt.Errorf("failed to force ArtifactGenerator rebuild: %w", err)
}
patchBase := ag.DeepCopy()
ag.Annotations[annotationVerifiedGeneration] = strconv.FormatInt(ag.Generation, 10)
if err := r.Patch(ctx, ag, client.MergeFrom(patchBase)); err != nil {
return ctrl.Result{}, fmt.Errorf("failed to record verified ArtifactGenerator generation: %w", err)
}
meta.SetStatusCondition(&packageSource.Status.Conditions, metav1.Condition{
Type: "Ready",
Status: metav1.ConditionUnknown,
Reason: reasonAwaitingRecovery,
Message: fmt.Sprintf(
"rebuilding ArtifactGenerator generation %d once, since source-watcher may have built it from the previous spec (fluxcd/source-watcher#384).",
ag.Generation,
),
ObservedGeneration: packageSource.Generation,
})
if err := r.Status().Update(ctx, packageSource); err != nil {
return ctrl.Result{}, err
}
log.FromContext(ctx).Info("forced ArtifactGenerator rebuild to verify generation",
"packageSource", packageSource.Name, "artifactGenerator", ag.Name, "generation", ag.Generation)
return ctrl.Result{RequeueAfter: backoffFor(1)}, nil
}
// maybeRecoverArtifactGenerator advances the bounded-recovery schedule for an
// AG whose Ready condition is stuck in the fluxcd/pkg#934 window (Inventory
// and ObservedSourcesDigest persisted on the current spec generation, Ready
// condition write lost to the split patch). It forces source-watcher onto its
// drifted-branch reconcile by patching Ready=False on the AG's status
// subresource with exponential backoff and — after maxRecoveryAttempts
// fruitless attempts — surfaces the failure as PackageSource.Ready=False with
// reason SourceWatcherStalled so an operator can intervene.
//
// Why forcing Ready=False AND bumping requestedAt together works: the two
// signals are complementary — neither alone is enough.
//
// - The status write (Ready=False, reason=SourceWatcherRecoveryForced) is
// what source-watcher v2.1.0's detectDrift
// (internal/controller/artifactgenerator_drift.go:52) treats as drift =
// "NotReady", promoting the reconcile past the no-drift early-return at
// controller.go:164 that traps a lost-Ready-condition state.
// - The annotation write (reconcile.fluxcd.io/requestedAt = now) is what
// ReconcileRequestedPredicate on source-watcher's ArtifactGenerator
// watch keys on to enqueue that reconcile in the first place. A
// status-only patch changes neither the AG's generation nor the
// requestedAt annotation, so it never triggers a watch event — the AG
// would sit until the periodic self-requeue (hardcoded to 1h),
// well outside the 15m HR install timeout.
//
// On successful rebuild source-watcher writes Ready=True at
// controller.go:254; on a genuine rebuild failure it writes Ready=False
// with a real reason. Either way an honest condition from upstream
// replaces our synthetic False, and our Owns(&ArtifactGenerator{}) watch
// fires this reconciler to copy the resolved condition onto the
// PackageSource.
//
// The recovery state (attempt count + last-force timestamp) lives on the
// AG itself as annotations so it survives operator restarts. The counter
// is NOT reset on a fresh Ready.LastTransitionTime — source-watcher's
// Progressing write during rebuild always advances that timestamp, and a
// reset there would make the SourceWatcherStalled give-up unreachable
// under the very race this driver targets. The canonical reset is the
// natural clearRecoveryTracking on updateStatus's not-stuck path, once
// source-watcher lands a definitive Ready=True or Ready=False with a
// real upstream reason.
//
// TODO(remove once fluxcd/pkg#934 and fluxcd/source-watcher#384 are both
// fixed and rolled out): verifyArtifactGeneratorGeneration drives its rebuild
// through this driver, so it goes only together with that verification. Then
// updateStatus can copy the AG's Ready condition through unconditionally.
func (r *PackageSourceReconciler) maybeRecoverArtifactGenerator(ctx context.Context, packageSource *cozyv1alpha1.PackageSource, ag *sourcewatcherv1beta1.ArtifactGenerator, ready *metav1.Condition, now time.Time) (ctrl.Result, error) {
logger := log.FromContext(ctx)
// The attempt counter is intentionally NOT reset here on a fresh
// Ready.LastTransitionTime. source-watcher's response to a forced drift
// includes a Ready=Unknown (Progressing) write before the rebuild
// completes; that transition's timestamp is always newer than
// lastRecoveryAt, and resetting on it would make the give-up budget
// unreachable under the very race this driver targets — force → Progressing
// → reset → force → Progressing → reset → forever. Instead, the unified
// Owns()-re-entrancy gate in updateStatus routes those Progressing
// transitions back through this same maybeRecoverArtifactGenerator so
// decideRecovery holds tracking through Wait until either source-watcher
// resolves the AG or the attempt budget exhausts. The natural
// clearRecoveryTracking on the not-stuck path (Ready=True from
// source-watcher, or Ready=False with an upstream reason) is the
// canonical reset. The trade-off — an operator restart between a prior
// give-up and a genuine fresh stall would preserve the stale counter —
// is contained by the leader-election requirement documented on the
// constants block.
attempts, lastRecoveryAt := readRecoveryTracking(ag)
decision := decideRecovery(attempts, lastRecoveryAt, now)
switch decision.action {
case recoveryActionGiveUp:
message := fmt.Sprintf(
"ArtifactGenerator %s/%s has not been rebuilt through %d force-drift attempts; "+
"source-watcher is not recovering (see fluxcd/pkg#934 and fluxcd/source-watcher#384). "+
"An operator must restart source-watcher or manually inspect the ArtifactGenerator.",
ag.Namespace, ag.Name, maxRecoveryAttempts,
)
meta.SetStatusCondition(&packageSource.Status.Conditions, metav1.Condition{
Type: "Ready",
Status: metav1.ConditionFalse,
Reason: reasonSourceWatcherBad,
Message: message,
ObservedGeneration: packageSource.Generation,
})
logger.Info("source-watcher stalled after bounded force-drift attempts; surfacing PackageSource Ready=False",
"packageSource", packageSource.Name, "artifactGenerator", ag.Name, "attempts", attempts)
return ctrl.Result{}, r.Status().Update(ctx, packageSource)
case recoveryActionWait:
meta.SetStatusCondition(&packageSource.Status.Conditions, metav1.Condition{
Type: "Ready",
Status: metav1.ConditionUnknown,
Reason: reasonAwaitingRecovery,
Message: fmt.Sprintf(
"force-drift %d/%d issued, waiting for source-watcher to rebuild the ArtifactGenerator.",
attempts, maxRecoveryAttempts,
),
ObservedGeneration: packageSource.Generation,
})
if err := r.Status().Update(ctx, packageSource); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: decision.wait}, nil
case recoveryActionForce:
nextAttempt := attempts + 1
if err := r.forceArtifactGeneratorDrift(ctx, ag, now, nextAttempt); err != nil {
return ctrl.Result{}, fmt.Errorf("failed to force ArtifactGenerator drift: %w", err)
}
meta.SetStatusCondition(&packageSource.Status.Conditions, metav1.Condition{
Type: "Ready",
Status: metav1.ConditionUnknown,
Reason: reasonAwaitingRecovery,
Message: fmt.Sprintf(
"forced drift on AG.status.conditions[Ready]=False (attempt %d/%d) so source-watcher rebuilds.",
nextAttempt, maxRecoveryAttempts,
),
ObservedGeneration: packageSource.Generation,
})
if err := r.Status().Update(ctx, packageSource); err != nil {
return ctrl.Result{}, err
}
logger.Info("forced source-watcher drift via AG status Ready=False patch",
"packageSource", packageSource.Name, "artifactGenerator", ag.Name, "attempt", nextAttempt)
return ctrl.Result{RequeueAfter: backoffFor(nextAttempt)}, nil
default:
// Force a loud failure if a new recoveryAction is ever added without
// a matching case here — a silent return would leave the PackageSource
// Ready condition frozen at whatever value it had before this call.
// controller-runtime v0.15+ recovers panics inside Reconcile by
// default (Options.RecoverPanic), so this manifests as a requeue with
// a logged stack trace rather than a crash-loop; if that default is
// ever flipped off in SetupWithManager, this needs to become an
// error return instead.
panic(fmt.Sprintf("unhandled recoveryAction %d", decision.action))
}
}
// artifactGeneratorStuck detects the fluxcd/pkg#934 stall signature: artifacts
// are demonstrably produced (Inventory populated and ObservedSourcesDigest set)
// on the current spec generation, yet the Ready condition is either missing or
// has been sitting in Unknown longer than stuckGracePeriod. The grace period
// keeps this predicate off the fast path during a normal in-flight rebuild;
// only genuinely quiescent AGs land here.
//
// Ready=True and Ready=False are BOTH pass-through (return false) — the retry
// driver only runs on the specific stuck-Unknown case. A real regeneration
// failure surfaces as Ready=False and is copied through unchanged.
func artifactGeneratorStuck(ag *sourcewatcherv1beta1.ArtifactGenerator, ready *metav1.Condition, now time.Time) bool {
if len(ag.Status.Inventory) == 0 {
return false
}
if ag.Status.ObservedSourcesDigest == "" {
return false
}
if ready == nil {
// Ready condition entirely absent: this is the half-persisted case the
// PR was written for. Wait out the grace period from AG creation before
// intervening so we don't fight a fresh-install AG that just hasn't
// been touched yet.
return ag.CreationTimestamp.Time.Add(stuckGracePeriod).Before(now)
}
if ready.Status != metav1.ConditionUnknown {
return false
}
if ready.ObservedGeneration != ag.Generation {
return false
}
// Only intervene if Unknown has held for the grace period; otherwise source
// -watcher is legitimately mid-rebuild and will settle on its own.
return ready.LastTransitionTime.Time.Add(stuckGracePeriod).Before(now)
}
// recoveryAction enumerates what maybeRecoverArtifactGenerator should do given
// the current retry state.
type recoveryAction int
const (
recoveryActionForce recoveryAction = iota // enough time elapsed — issue a fresh force-drift status patch
recoveryActionWait // in backoff window — schedule a follow-up reconcile at wait
recoveryActionGiveUp // exceeded maxRecoveryAttempts — surface as Ready=False
)
type recoveryDecision struct {
action recoveryAction
wait time.Duration
}
// decideRecovery is the pure decision function driving maybeRecoverArtifactGenerator.
// Split out so it can be unit-tested without a cluster.
//
// The exhaustion branch (`attempts >= maxRecoveryAttempts`) also respects the
// final backoff window before switching to GiveUp. Without that gate, the
// Owns()-watch re-fire from the N-th force-drift's own writes triggers a
// reconcile milliseconds later, `decideRecovery` sees `attempts == max` and
// returns GiveUp immediately — preempting the response window the final
// force was supposed to grant source-watcher and collapsing the documented
// budget from ~11.5m (30s+60s+2m+4m+4m) down to ~7.5m (30s+60s+2m+4m). The
// wait branch here holds until the full final backoff has elapsed, so the
// last nudge actually gets its chance to succeed (Owns → Ready=True copy-
// through) before the driver surfaces SourceWatcherStalled.
func decideRecovery(attempts int, lastRecoveryAt time.Time, now time.Time) recoveryDecision {
if attempts >= maxRecoveryAttempts {
elapsed := now.Sub(lastRecoveryAt)
needed := backoffFor(maxRecoveryAttempts)
if elapsed < needed {
return recoveryDecision{action: recoveryActionWait, wait: needed - elapsed}
}
return recoveryDecision{action: recoveryActionGiveUp}
}
if attempts == 0 {
return recoveryDecision{action: recoveryActionForce}
}
elapsed := now.Sub(lastRecoveryAt)
needed := backoffFor(attempts)
if elapsed >= needed {
return recoveryDecision{action: recoveryActionForce}
}
return recoveryDecision{action: recoveryActionWait, wait: needed - elapsed}
}
// backoffFor returns the backoff duration to wait AFTER the Nth force before
// the (N+1)th. Attempts are 1-indexed. Exponential up to maxBackoff.
func backoffFor(attempt int) time.Duration {
if attempt < 1 {
attempt = 1
}
d := initialBackoff
for i := 1; i < attempt; i++ {
d *= 2
if d >= maxBackoff {
return maxBackoff
}
}
return d
}
// forceArtifactGeneratorDrift writes Ready=False on the AG's status subresource
// to trigger source-watcher's drifted-branch reconcile (see the block comment
// on maybeRecoverArtifactGenerator for why this is what actually recovers a
// stuck AG). Then updates our tracking annotations so the retry loop can back
// off.
//
// Two subresources are patched — status for the Ready condition, metadata for
// the annotations — because status is a separate endpoint and cannot be
// combined with metadata in a single PATCH. Order is: status first, then
// metadata. If the status patch fails, we return the error and leave both the
// caller's `ag` and the apiserver untouched. If the status patch succeeds but
// the annotations patch fails, we still return an error — the retry loop will
// pick up on the next reconcile and, because source-watcher already got the
// Ready=False signal from the successful status write, the extra force-drift
// on that retry is benign (source-watcher is already mid-rebuild).
//
// JSON merge patch semantics — honest trade-off. `client.MergeFrom` produces
// a JSON merge patch, which replaces arrays in full per RFC 7396 (custom
// resources like ArtifactGenerator do not carry the `patchStrategy: merge`
// / `patchMergeKey` markers that would enable strategic-merge-patch
// per-element merging on Kubernetes-native APIs). Consequences:
//
// - If source-watcher writes a fresh Reconciling condition between our
// Get (in updateStatus, several stack frames up) and this status Patch,
// our patch REPLACES the entire status.conditions array with the array
// we hold locally — Reconciling gets transiently wiped.
// source-watcher restores it on its next reconcile (which our
// requestedAt bump in step 2 is about to trigger), so the wipe is
// short-lived. Downstream tooling watching for Reconciling (Grafana
// panels, alertmanager rules) may still observe the momentary absence.
// - Similarly, any other condition source-watcher added concurrently
// (e.g. a custom Stalled marker in future versions) would be wiped.
// Currently source-watcher v2.1.0 only writes Ready and Reconciling,
// so the blast radius is Reconciling only.
//
// A full fix would require server-side apply with per-condition field
// ownership, at the cost of introducing SSA into the operator's write
// path. Not doing it here because the transient wipe is acceptable in
// this workaround's scope; if a future refactor moves the operator to
// SSA globally, this call should migrate too.
//
// On success, `ag.Status.Conditions` and `ag.Annotations` reflect the
// persisted state so the caller can re-read the same pointer. On any Patch
// failure the corresponding field is rolled back to its pre-call value.
func (r *PackageSourceReconciler) forceArtifactGeneratorDrift(ctx context.Context, ag *sourcewatcherv1beta1.ArtifactGenerator, now time.Time, nextAttempt int) error {
logger := log.FromContext(ctx)
// Step 1: patch AG.status.conditions[Ready]=False. This is the signal
// source-watcher's detectDrift picks up as `NotReady` drift.
statusBase := ag.DeepCopy()
priorConditions := cloneConditions(ag.Status.Conditions)
meta.SetStatusCondition(&ag.Status.Conditions, metav1.Condition{
Type: "Ready",
Status: metav1.ConditionFalse,
Reason: reasonRecoveryForced,
Message: "cozystack-operator forced drift; source-watcher will rebuild",
ObservedGeneration: ag.Generation,
})
if err := r.Status().Patch(ctx, ag, client.MergeFrom(statusBase)); err != nil {
ag.Status.Conditions = priorConditions
return fmt.Errorf("status patch to force drift: %w", err)
}
// Step 2: bump `reconcile.fluxcd.io/requestedAt` alongside the tracking
// annotations. Both parts are load-bearing:
//
// - source-watcher v2.1.0 registers the ArtifactGenerator watch with
// `predicate.Or(GenerationChangedPredicate, ReconcileRequestedPredicate)`
// (source-watcher `artifactgenerator_manager.go` in the v2.1.0 pinned
// distribution). A status-only patch changes neither the generation
// nor the requestedAt annotation, so it does NOT enqueue a reconcile.
// The only fallback is the AG's periodic self-requeue, hardcoded to
// `time.Hour` — well outside the 15m HR install timeout this driver
// exists to beat.
// - The Ready=False write from step 1 is what source-watcher's
// `detectDrift` keys on once it does reconcile (drift.go:52,
// `IsFalse(Ready)` → `NotReady` drift → drifted branch → MarkTrue).
//
// Together: the annotation triggers the reconcile, the status condition
// makes that reconcile take the drifted branch and rebuild. Either alone
// is a no-op.
//
// If this metadata patch fails, the status write from step 1 has already
// taken effect, but source-watcher will not have been enqueued yet. The
// retry loop notices on the next reconcile and re-issues both parts —
// benign, source-watcher is not doing anything with the stale Ready=False
// alone.
metadataBase := ag.DeepCopy()
priorAnnotations := cloneAnnotations(ag.Annotations)
if ag.Annotations == nil {
ag.Annotations = map[string]string{}
}
nowStr := now.UTC().Format(time.RFC3339Nano)
ag.Annotations[annotationFluxRequestedAt] = nowStr
ag.Annotations[annotationRecoveryAttempts] = strconv.Itoa(nextAttempt)
ag.Annotations[annotationLastRecoveryAt] = nowStr
if err := r.Patch(ctx, ag, client.MergeFrom(metadataBase)); err != nil {
ag.Annotations = priorAnnotations
// The status patch already landed, so source-watcher sees our
// Ready=False. If this metadata patch keeps failing on retry,
// the recovery tracking annotations never persist to the
// apiserver — every subsequent reconcile reads attempts=0 and
// re-forces from scratch instead of accumulating toward
// SourceWatcherStalled. Log loudly so operators can spot a
// metadata-write-only apiserver malfunction.
logger.Error(err, "recovery tracking metadata patch failed after successful status patch — "+
"source-watcher has been nudged but the attempt counter did not advance; "+
"if this repeats persistently, force-drift will loop without ever giving up",
"artifactGenerator", ag.Name,
"intendedAttempts", nextAttempt)
return fmt.Errorf("metadata patch to update recovery tracking: %w", err)
}
return nil
}
// clearRecoveryTracking removes our bookkeeping annotations once the AG is
// healthy again.
//
// Same success/failure contract as forceArtifactGeneratorDrift: on success
// `ag.Annotations` matches the persisted state; on Patch failure the caller's
// annotations are rolled back to their pre-call values.
func (r *PackageSourceReconciler) clearRecoveryTracking(ctx context.Context, ag *sourcewatcherv1beta1.ArtifactGenerator) error {
if ag.Annotations == nil {
return nil
}
_, hasAttempts := ag.Annotations[annotationRecoveryAttempts]
_, hasLast := ag.Annotations[annotationLastRecoveryAt]
if !hasAttempts && !hasLast {
return nil
}
patchBase := ag.DeepCopy()
priorAnnotations := cloneAnnotations(ag.Annotations)
delete(ag.Annotations, annotationRecoveryAttempts)
delete(ag.Annotations, annotationLastRecoveryAt)
if err := r.Patch(ctx, ag, client.MergeFrom(patchBase)); err != nil {
ag.Annotations = priorAnnotations
return err
}
return nil
}
// cloneConditions returns a shallow copy of a conditions slice suitable for
// rollback on Patch failure. Each metav1.Condition holds only value-typed
// fields (strings, ints, Time), so a shallow slice copy is sufficient.
func cloneConditions(src []metav1.Condition) []metav1.Condition {
if src == nil {
return nil
}
dst := make([]metav1.Condition, len(src))
copy(dst, src)
return dst
}
// cloneAnnotations returns a shallow copy suitable for rollback on Patch
// failure. A shallow copy is enough because annotation values are plain
// strings, which are immutable in Go; there is no shared mutable state to
// deep-copy. Nil in → nil out (preserved as a distinct sentinel from an empty
// map so the caller sees exactly the same state it started with).
func cloneAnnotations(src map[string]string) map[string]string {
if src == nil {
return nil
}
dst := make(map[string]string, len(src))
maps.Copy(dst, src)
return dst
}
// readRecoveryTracking pulls the retry-attempt counter and last-force timestamp
// off the AG. Missing/malformed annotations are treated as "no prior attempts"
// so a corrupted counter can't wedge the retry loop.
func readRecoveryTracking(ag *sourcewatcherv1beta1.ArtifactGenerator) (attempts int, lastRecoveryAt time.Time) {
if ag.Annotations == nil {
return 0, time.Time{}
}
if raw, ok := ag.Annotations[annotationRecoveryAttempts]; ok {
if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 0 {
attempts = parsed
}
}
if raw, ok := ag.Annotations[annotationLastRecoveryAt]; ok {
if parsed, err := time.Parse(time.RFC3339Nano, raw); err == nil {
lastRecoveryAt = parsed
}
}
return attempts, lastRecoveryAt
}
// mapPackageToSource enqueues the PackageSource that shares a Package's name, so
// confirming a registration (which sheds the marketplace-tap label on the
// Package) re-runs artifact generation and materialises the now-confirmed
// privileged components.
func mapPackageToSource(_ context.Context, obj client.Object) []ctrl.Request {
return []ctrl.Request{{NamespacedName: types.NamespacedName{Name: obj.GetName()}}}
}
// SetupWithManager sets up the controller with the Manager.
func (r *PackageSourceReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
Named("cozystack-packagesource").
For(&cozyv1alpha1.PackageSource{}).
Owns(&sourcewatcherv1beta1.ArtifactGenerator{}).
Watches(&cozyv1alpha1.Package{}, handler.EnqueueRequestsFromMapFunc(mapPackageToSource)).
Complete(r)
}