Compare commits

...
Sign in to create a new pull request.

3 commits

Author SHA1 Message Date
Myasnikov Daniil
e410b02e1d
feat(operator): expose Install/Upgrade timeouts and MaxHistory as flags
The HelmRelease generation in package_reconciler hardcoded three values
that have caused recurring pain:

- Install.Timeout = 10m (line 214)
- Upgrade.Timeout = 10m (line 220)
- MaxHistory = unset (Helm default 5)

The 10m timeout in particular contradicts the per-Application timeout
work done in `pkg/config/config.go` and
`pkg/registry/apps/application/rest.go` (commit 7b146cbe), which lets
individual Applications override the install/upgrade timeout via
annotation. The PackageSource side had no equivalent — etcd works around
it by hand-rolling its HR YAML in `packages/apps/tenant/templates/etcd.yaml`
to set a 30m timeout, harbor has had multiple commits chasing the same
problem from the other side. This closes that gap.

New operator flags (each maps to a chart value with empty default;
operator uses its own default when value is empty):

- `--helmrelease-install-timeout` (default 10m) → Spec.Install.Timeout
- `--helmrelease-upgrade-timeout` (default 10m) → Spec.Upgrade.Timeout
- `--helmrelease-max-history` (default 5)        → Spec.MaxHistory

Production behaviour unchanged. E2E and edge cases (cert rotation,
slow-installing charts) can override per-cluster without modifying
chart manifests.

Per-component overrides on `ComponentInstall` (mirroring the existing
`UpgradeCRDs` pattern) would be a strictly better fix for charts like
etcd that need an outlier timeout — left as a deliberate followup since
it touches the Package CR API.

Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-28 12:44:06 +05:00
Myasnikov Daniil
c4c70e0e18
feat(operator): switch HelmRelease retries to Strategy=RetryOnFailure with configurable RetryInterval
The operator generated HelmReleases with `Install.Remediation{Retries: -1}`
and `Upgrade.Remediation{Retries: -1}`, which is the v2 way of saying
"retry forever, never remediate". Combined with the spec-level
`Interval: 5m`, this meant a failed install/upgrade waited a full 5
minutes before the next attempt — even when the underlying race that
caused the failure (e.g. a chart artifact not quite Ready, a HR
dependency reconciling out of order) cleared in seconds.

Audit of the last 30 successful PR runs found that every single run had
its `Install Cozystack` step's `kubectl wait hr/seaweedfs-system` time
out at the 2-minute mark and recover only after an inline
`flux reconcile --force` workaround. Same root cause: 5-minute retry
interval was longer than the test's wait window. That workaround can be
removed once this change lands.

Switch to `Strategy.Name=RetryOnFailure` with `RetryInterval` exposed via
a new `--helmrelease-retry-interval` operator flag (default 30s):

- Functionally equivalent to the previous configuration ("retry forever
  on failure") since `Retries: -1` meant remediation never fired anyway.
- Decouples retry-on-failure timing from `spec.Interval` so failed
  releases recover at 30s without polling healthy releases at the same
  cadence (which would multiply controller load by ~10x for no benefit).
- Helm chart exposes `cozystackOperator.helmReleaseRetryInterval`,
  defaulting to empty (operator uses its own 30s default).
- Same flag pattern as the existing `--helmrelease-interval` (5m default),
  also added in this PR via cherry-pick.

Verified: go build green, go test ./internal/operator/... pass, helm
template renders both flags conditionally on values.

Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-28 12:40:13 +05:00
Myasnikov Daniil
e197aba625
feat(operator): make HelmRelease Interval configurable, override to 30s in E2E
Adds --helmrelease-interval (default 5m, matching today's behaviour) to the
operator and a corresponding cozystackOperator.helmReleaseInterval value
on the cozy-installer chart (default empty -> no flag rendered ->
operator default 5m applies).

E2E install sets it to 30s. Rationale: with the 5m default, dependency-
blocked HRs (waiting on cert-manager webhooks, CRDs) are requeued only
every 5 min, producing an 8-10 min dead zone in the E2E install where
the fast pack of HRs is Ready but the slow tail hasn't been retried yet.
30s collapses the gap.

Production defaults are unchanged. The override is opt-in per install.

Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-28 12:36:28 +05:00
5 changed files with 116 additions and 10 deletions

View file

@ -83,6 +83,11 @@ func main() {
var disableTelemetry bool
var telemetryEndpoint string
var telemetryInterval string
var helmReleaseInterval string
var helmReleaseRetryInterval string
var helmReleaseInstallTimeout string
var helmReleaseUpgradeTimeout string
var helmReleaseMaxHistory int
var cozyValuesSecretName string
var cozyValuesSecretNamespace string
var cozyValuesNamespaceSelector string
@ -107,6 +112,28 @@ func main() {
"Endpoint for sending telemetry data")
flag.StringVar(&telemetryInterval, "telemetry-interval", "15m",
"Interval between telemetry data collection (e.g. 15m, 1h)")
flag.StringVar(&helmReleaseInterval, "helmrelease-interval", "5m",
"Reconcile interval applied to HelmReleases created by the Package reconciler. "+
"Lower values speed up dependency-blocked retries (e.g. during E2E install) at the cost of "+
"controller load. Production default 5m matches existing behaviour.")
flag.StringVar(&helmReleaseRetryInterval, "helmrelease-retry-interval", "30s",
"Retry interval applied to Install.Strategy and Upgrade.Strategy of HelmReleases created "+
"by the Package reconciler. With Strategy.Name=RetryOnFailure, this controls how long the "+
"controller waits between failed install/upgrade attempts. Decoupled from --helmrelease-interval "+
"(which is the healthy reconcile cadence) so failures recover fast without polling healthy "+
"releases at the same fast cadence.")
flag.StringVar(&helmReleaseInstallTimeout, "helmrelease-install-timeout", "10m",
"Timeout for the Helm install action of HelmReleases created by the Package reconciler "+
"(Spec.Install.Timeout). Bounds how long an individual Kubernetes operation (Job/hook/wait) "+
"may take during install.")
flag.StringVar(&helmReleaseUpgradeTimeout, "helmrelease-upgrade-timeout", "10m",
"Timeout for the Helm upgrade action of HelmReleases created by the Package reconciler "+
"(Spec.Upgrade.Timeout). Bounds how long an individual Kubernetes operation (Job/hook/wait) "+
"may take during upgrade.")
flag.IntVar(&helmReleaseMaxHistory, "helmrelease-max-history", 5,
"Number of release revisions Helm keeps for HelmReleases created by the Package reconciler "+
"(Spec.MaxHistory). 0 means unlimited; 5 matches Helm's default. Lower values reduce "+
"per-release Secret accumulation in clusters that bounce HRs frequently (e.g. E2E sandboxes).")
flag.StringVar(&platformSourceURL, "platform-source-url", "", "Platform source URL (oci:// or https://). If specified, generates OCIRepository or GitRepository resource.")
flag.StringVar(&platformSourceName, "platform-source-name", "cozystack-platform", "Name for the generated platform source resource and PackageSource")
flag.StringVar(&platformSourceRef, "platform-source-ref", "", "Reference specification as key=value pairs (e.g., 'branch=main' or 'digest=sha256:...,tag=v1.0'). For OCI: digest, semver, semverFilter, tag. For Git: branch, tag, semver, name, commit.")
@ -122,6 +149,31 @@ func main() {
ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
hrIntervalDuration, err := time.ParseDuration(helmReleaseInterval)
if err != nil {
setupLog.Error(err, "invalid --helmrelease-interval value", "value", helmReleaseInterval)
os.Exit(1)
}
hrRetryIntervalDuration, err := time.ParseDuration(helmReleaseRetryInterval)
if err != nil {
setupLog.Error(err, "invalid --helmrelease-retry-interval value", "value", helmReleaseRetryInterval)
os.Exit(1)
}
hrInstallTimeoutDuration, err := time.ParseDuration(helmReleaseInstallTimeout)
if err != nil {
setupLog.Error(err, "invalid --helmrelease-install-timeout value", "value", helmReleaseInstallTimeout)
os.Exit(1)
}
hrUpgradeTimeoutDuration, err := time.ParseDuration(helmReleaseUpgradeTimeout)
if err != nil {
setupLog.Error(err, "invalid --helmrelease-upgrade-timeout value", "value", helmReleaseUpgradeTimeout)
os.Exit(1)
}
if helmReleaseMaxHistory < 0 {
setupLog.Error(fmt.Errorf("--helmrelease-max-history must be >= 0"), "invalid value", "value", helmReleaseMaxHistory)
os.Exit(1)
}
config := ctrl.GetConfigOrDie()
// Create a direct client (without cache) for pre-start operations
@ -258,8 +310,13 @@ func main() {
// Setup Package reconciler
if err := (&operator.PackageReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
HelmReleaseInterval: hrIntervalDuration,
HelmReleaseRetryInterval: hrRetryIntervalDuration,
HelmReleaseInstallTimeout: hrInstallTimeoutDuration,
HelmReleaseUpgradeTimeout: hrUpgradeTimeoutDuration,
HelmReleaseMaxHistory: helmReleaseMaxHistory,
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "Package")
os.Exit(1)

View file

@ -13,6 +13,7 @@
--install \
--namespace cozy-system \
--create-namespace \
--set cozystackOperator.helmReleaseInterval=30s \
--wait \
--timeout 2m

View file

@ -20,6 +20,7 @@ import (
"context"
"fmt"
"strings"
"time"
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
helmv2 "github.com/fluxcd/helm-controller/api/v2"
@ -58,7 +59,12 @@ func parseCRDPolicy(install *cozyv1alpha1.ComponentInstall) helmv2.CRDsPolicy {
// PackageReconciler reconciles Package resources
type PackageReconciler struct {
client.Client
Scheme *runtime.Scheme
Scheme *runtime.Scheme
HelmReleaseInterval time.Duration
HelmReleaseRetryInterval time.Duration
HelmReleaseInstallTimeout time.Duration
HelmReleaseUpgradeTimeout time.Duration
HelmReleaseMaxHistory int
}
// +kubebuilder:rbac:groups=cozystack.io,resources=packages,verbs=get;list;watch;create;update;patch;delete
@ -214,22 +220,30 @@ func (r *PackageReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
Labels: labels,
},
Spec: helmv2.HelmReleaseSpec{
Interval: metav1.Duration{Duration: 5 * 60 * 1000000000}, // 5m
Interval: metav1.Duration{Duration: r.HelmReleaseInterval},
MaxHistory: &r.HelmReleaseMaxHistory,
ChartRef: &helmv2.CrossNamespaceSourceReference{
Kind: "ExternalArtifact",
Name: artifactName,
Namespace: "cozy-system",
},
Install: &helmv2.Install{
Timeout: &metav1.Duration{Duration: 10 * 60 * 1000000000}, // 10m
Remediation: &helmv2.InstallRemediation{
Retries: -1,
Timeout: &metav1.Duration{Duration: r.HelmReleaseInstallTimeout},
// Strategy=RetryOnFailure (with RetryInterval) replaces the previous
// Remediation{Retries:-1} setup. Functionally equivalent ("retry forever
// on failure"), but decouples retry timing from spec.Interval so failed
// installs recover at HelmReleaseRetryInterval (default 30s) without
// also polling healthy releases at the same fast cadence.
Strategy: &helmv2.InstallStrategy{
Name: string(helmv2.ActionStrategyRetryOnFailure),
RetryInterval: &metav1.Duration{Duration: r.HelmReleaseRetryInterval},
},
},
Upgrade: &helmv2.Upgrade{
Timeout: &metav1.Duration{Duration: 10 * 60 * 1000000000}, // 10m
Remediation: &helmv2.UpgradeRemediation{
Retries: -1,
Timeout: &metav1.Duration{Duration: r.HelmReleaseUpgradeTimeout},
Strategy: &helmv2.UpgradeStrategy{
Name: string(helmv2.ActionStrategyRetryOnFailure),
RetryInterval: &metav1.Duration{Duration: r.HelmReleaseRetryInterval},
},
CRDs: parseCRDPolicy(component.Install),
},

View file

@ -68,6 +68,21 @@ spec:
{{- if .Values.cozystackOperator.disableTelemetry }}
- --disable-telemetry
{{- end }}
{{- if .Values.cozystackOperator.helmReleaseInterval }}
- --helmrelease-interval={{ .Values.cozystackOperator.helmReleaseInterval }}
{{- end }}
{{- if .Values.cozystackOperator.helmReleaseRetryInterval }}
- --helmrelease-retry-interval={{ .Values.cozystackOperator.helmReleaseRetryInterval }}
{{- end }}
{{- if .Values.cozystackOperator.helmReleaseInstallTimeout }}
- --helmrelease-install-timeout={{ .Values.cozystackOperator.helmReleaseInstallTimeout }}
{{- end }}
{{- if .Values.cozystackOperator.helmReleaseUpgradeTimeout }}
- --helmrelease-upgrade-timeout={{ .Values.cozystackOperator.helmReleaseUpgradeTimeout }}
{{- end }}
{{- if .Values.cozystackOperator.helmReleaseMaxHistory }}
- --helmrelease-max-history={{ .Values.cozystackOperator.helmReleaseMaxHistory }}
{{- end }}
- --platform-source-name=cozystack-platform
- --platform-source-url={{ .Values.cozystackOperator.platformSourceUrl }}
{{- if .Values.cozystackOperator.platformSourceRef }}

View file

@ -4,6 +4,25 @@ cozystackOperator:
image: ghcr.io/cozystack/cozystack/cozystack-operator:v1.3.0@sha256:62574f12486bb40c901cf5ed484cca264405ce5810196d86555cbb27cce1ba48
platformSourceUrl: 'oci://ghcr.io/cozystack/cozystack/cozystack-packages'
platformSourceRef: 'digest=sha256:a0b9ef938446b3132d3d22ad2262beb1027c48c9037b6c2346fdc2f19acd3036'
# When non-empty, overrides the operator's --helmrelease-interval flag
# (operator default: 5m). E2E sets this to 30s; production should leave empty.
helmReleaseInterval: ""
# When non-empty, overrides the operator's --helmrelease-retry-interval flag
# (operator default: 30s). Controls how fast failed install/upgrade attempts
# are retried. Decoupled from helmReleaseInterval (healthy reconcile cadence).
helmReleaseRetryInterval: ""
# When non-empty, overrides the operator's --helmrelease-install-timeout flag
# (operator default: 10m). Bounds individual k8s operations (job/hook/wait)
# during Helm install. Charts with long-running install hooks may need longer.
helmReleaseInstallTimeout: ""
# When non-empty, overrides the operator's --helmrelease-upgrade-timeout flag
# (operator default: 10m). Same semantics as helmReleaseInstallTimeout, for
# the Helm upgrade action.
helmReleaseUpgradeTimeout: ""
# When non-empty, overrides the operator's --helmrelease-max-history flag
# (operator default: 5). Number of release revisions Helm keeps per HR.
# Use 0 for unlimited; lower values reduce Secret accumulation in test clusters.
helmReleaseMaxHistory: ""
# Generic variant configuration (only used when cozystackOperator.variant=generic)
cozystack:
# Kubernetes API server host (IP only, no protocol/port)