feat(core): rework HelmClusterAddonRepository status semantics - #66
Merged
Conversation
Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Signed-off-by: Ilya Drey <ilya.drey@flant.com>
… chart versions Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Signed-off-by: Ilya Drey <ilya.drey@flant.com>
…eneration bump - Gate evaluateStalled's carry-forward branch on the condition having been set for the current generation, mirroring hasEvidence, so a generation bump no longer republishes a Stalled reason that described the old spec. - Add TestEvaluateDecisionErr to lock in that only cluster-write failures (SecretsErr, InternalRepositoryErr, Catalog.Err) reach Decision.Err; repository-read failures (Fetch.Err, ConfigErr) are excluded because their retry is scheduled through nextSyncTime instead. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Signed-off-by: Ilya Drey <ilya.drey@flant.com>
…heck error Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Signed-off-by: Ilya Drey <ilya.drey@flant.com>
…ider Signed-off-by: Ilya Drey <ilya.drey@flant.com>
…EnsureInternalRepositoryState Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Ready now reports whether the repository is usable: auxiliary resources are in place, the internal source object is healthy and the repository responded to a catalog read on the current spec. Reconciling and Stalled are added following kstatus and are present only while applicable. Synchronization is scheduled from status fields with an exponential backoff instead of a fixed interval. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
…r parses The url validation regex on the CRD is looser than url.Parse, so a repository whose internal objects already exist can be edited to an unparsable url and then deleted. reconcileDelete matched no case for an unknown repository type and left the internal HelmRepository and both auxiliary secrets behind; the helm cleanup is now the default branch. Also log a repository read failure, which is reported only through a condition, and drop a duplicated error wrap. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Signed-off-by: Ilya Drey <ilya.drey@flant.com>
main split auth secret reconciliation per repository kind (#65): HelmRepository resolves HTTP basic auth from an Opaque secret, OCIRepository only accepts a kubernetes.io/dockerconfigjson one. This branch had gone the other way and unified both call sites into BaseRepoService.EnsureSecrets, whose success is the gate for attempting a catalog synchronization. Resolved by making EnsureSecrets kind-aware rather than picking a side: it now takes the repository type and dispatches to reconcileBasicAuthSecret or reconcileDockerConfigAuthSecret, keeping main's per-kind shapes and this branch's single gate. The two blocks main edited inside EnsureInternalHelmRepository and EnsureRepositorySecrets are dropped, because both methods were deleted here and their secret handling now lives in EnsureSecrets. Added a test for the OCI branch: a merge that collapsed the shapes back would otherwise reintroduce the exact bug #65 fixed, silently. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
The merge that made EnsureSecrets kind-aware staged only internal/services, so this call site was left behind and the merge commit did not compile. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
A "date" print column shows how long ago its value was, and kubectl's HumanDuration returns "<invalid>" for anything more than a second in the future. nextSyncTime is always in the future by design, so the column read "<invalid>" for every repository: NAME STATUS SYNCED LAST SYNC AGE NEXT SYNC bitnami True True 4m 5m24s <invalid> Switched to a string column, which prints the absolute RFC3339 instant. Last Sync stays a date: its value is in the past, where the relative rendering is what you want. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
controller-gen folds every non-marker line of a type's doc comment into the resource description, so the note explaining why "Next Sync" is a string column ended up as user-facing documentation on HelmClusterAddonRepository itself, visible in kubectl explain and in the rendered module docs, and out of step with the Russian mirror. Moved the note into its own comment block above the doc comment, separated by a blank line so controller-gen does not pick it up. The generated description is again byte-identical to the one on main; the column stays a string. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
UntilModuleEnabled asserted that the ValidatingWebhookConfiguration and the operator-helm-controller-tls secret were both created after the suite enabled the module. That holds only when the module was not already deployed in the cluster: enabling an already-deployed module leaves both objects in place, and the assertion then fails with a bare "false" after the full 600s timeout, which is what SynchronizedBeforeSuite has been failing on. Both freshness assertions are removed. What the block still verifies is the part that describes the module rather than the environment: the webhook exists and has entries, the TLS secret exists and carries ca.crt, and the webhook's caBundle matches that certificate. The tradeoff is deliberate: a stale deployment can now pass this gate, so the suite no longer proves it is exercising a freshly rolled-out module. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
The build job pushes the module under a mutable tag (pr<number> or the branch name) and prints the resulting digest, but nothing carried that value forward, so the e2e job could only ask for the tag and had no way to tell which artifact the cluster actually pulled. deckhouse/modules-actions/build declares no outputs, so the digest is resolved in a step of its own right after the build, while the setup action's registry login and its crane install are still in scope. From there it travels the same route the tag already takes: step output, job output, then the e2e job's env as E2E_MODULE_DIGEST. The suite compares it against ModulePullOverride's status.imageDigest, which is where Deckhouse records the digest the tag resolved to. The check is skipped when the variable is empty, so a local run without it behaves as before. This also restores, precisely, what the removed webhook and TLS-secret freshness assertions were groping at: proof that the cluster runs the artifact under test rather than whatever an older build left behind the tag. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
The digest comparison was guarded by a non-empty check, so a run without E2E_MODULE_DIGEST skipped it silently. A skipped verification reads exactly like a passing one in the output, which is how a suite ends up exercising whatever an older build left behind a mutable tag while still looking green. The digest is now mandatory. The assertion sits at the top of EnsureModuleConfig, before anything is created in the cluster, so a missing value fails in milliseconds rather than after a 600s timeout, and the message names the variable and the crane invocation that produces it. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
The comparison sat between creating the ModulePullOverride and creating the ModuleConfig, so it ran before the module was enabled. Deckhouse records which artifact the tag resolved to when it actually pulls the module, and it pulls it once the module is enabled, so status.imageDigest was still an empty string and the check timed out after the full 600s with an empty actual value. Moved to the end of UntilModuleEnabled, after the module reports Ready. The mandatory non-empty assertion on E2E_MODULE_DIGEST stays where it was, at the top of EnsureModuleConfig, so a missing value still fails in milliseconds. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
drey
force-pushed
the
feat/rework-repository-sync
branch
from
September 2, 2026 18:29
52ab9df to
64bfab1
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
HelmClusterAddonRepositorystatus is reworked so thatReadymeans one thing for both repository kinds, the synchronization schedule lives in status fields instead of a condition's timestamp, and failures to read a repository are retried with an exponential backoff instead of a fixed 5-minute poll. The whole status is now derived by a pure function,Evaluate(Inputs) Decision, which the reconciler feeds and applies in a single patch.ReconcilingandStalledare added following the kstatus convention, so the resource is legible tokubectl wait, CD pipelines and dashboards. The controller image had no unit tests before this branch; it now has 60+, including a cross-check against the realsigs.k8s.io/cli-utilskstatus implementation.Why
Four concrete problems, all visible in the code being replaced:
Readyconfirmed nothing for OCI repositories. For a helm repository it mirrored the internalHelmRepository; for OCI, which has no such object at the repository level,EnsureRepositorySecretsreturned a synthetic success with an empty artifact:Writing a secret was the only thing that had actually happened.
The schedule was stored inside a condition.
isRepoSyncRequiredandrequeueAtSyncIntervalboth readSynced.lastTransitionTime, which forced a two-phase state machine inside one reconcile pass — the first phase existed only to advance that timestamp, as its own comment conceded:No backoff and no observability of the schedule.
ChartsSyncIntervalwas a hard-coded 5 minutes; an unreachable repository was polled at that rate forever, and neither the last successful sync nor the next scheduled one appeared anywhere in the status.Not legible to kstatus. For a custom resource, kstatus decides on
metadata.deletionTimestamp,status.observedGenerationversusmetadata.generation, and theReconciling/Stalledconditions. Without the latter two, a broken repository read asCurrent.Key changes
Status API —
api/v1alpha1/{conditions,helm_cluster_addon_repository}.go,crds/ReconcilingandStalled, plus reasonsAuxiliaryResourcesFailed,CatalogUpdateFailed,AwaitingInitialSync,ProgressingWithRetry,RetriesExceeded,AuthenticationFailed,SourceNotFound,SourceRejectedRequest,InvalidRepositoryURL,UnsupportedRepositoryType.lastSuccessfulSyncTime,nextSyncTime,consecutiveFetchFailures; regenerated CRD, deepcopy and the Russian description mirror.Last SyncandAgeby default,Next Syncand theReadymessage under-o wide.Pure status evaluation —
internal/reconcile/helmclusteraddonrepository/evaluate.go(new)Evaluate(Inputs) Decisionreturns the entire desired status, aRequeueAfterand an error that is only ever a cluster-write failure. No client, no clock, no randomness inside:NowandJitterare inputs, so every expected timestamp in the tests is exact.Readyis an ordered switch: secrets failure → internal repository not ready → stalled → a successful fetch this pass → inherited evidence → otherwiseUnknown. The "inherited evidence" arm is a deliberate latch: a transient read failure does not flipReadytoFalse, because installed addons keep working and only the catalog goes stale.Reconciling/Stalledare removed from the array (apimeta.RemoveStatusCondition), not set toFalse— matching upstream Flux and keeping the UI clean.Scheduling and backoff — same file
SyncInterval = 5m,MaxSyncBackoff = 1h,MaxFetchFailures = 5,SyncBackoffJitter = 0.1. Delay ismin(SyncInterval * 2^(n-1), MaxSyncBackoff): 5m → 10m → 20m → 40m → 1h, thenStalled/RetriesExceeded. The base equals the normal cadence on purpose, so a broken repository is never polled more often than a healthy one.ShouldAttemptdecides whether a pass attempts a sync at all;nextSyncTimesurvives restarts and leader changes.Terminal versus transient read failures —
internal/client/repository/{errors.go,helm.go,oci.go}TerminalErrorwithAsTerminal/TerminalFromStatusCode: 401/403 →AuthenticationFailed, 404 →SourceNotFound, other 4xx →SourceRejectedRequest; 5xx, DNS and connection failures stay retriable. OCI maps*transport.Errorthrough the same table and rejects a url with no image name asInvalidRepositoryURL.timed out waiting for the condition.Reconcile flow and service reshaping —
internal/reconcile/.../reconciler.go,internal/services/*status.Manager.PatchStatus. The two-phase machine is gone.BaseRepoService.EnsureSecretsreplaces two divergent call sites;HelmRepoService.EnsureInternalHelmRepositoryreturns(InternalRepositoryState, error)— an unhealthy object is information, an API failure is the caller's problem;RepoSyncService.SyncreturnsSyncOutcome{Fetch, Catalog}and takes an injected client factory so the catalog write and pruning are testable.HelmRepoResult,RepoSyncResult,EnsureAddonCharts,isRepoSyncRequired,isRepoSyncInProgress,EnsureRepositorySecrets,ChartsSyncInterval,requeueAtSyncInterval, and the never-calledstatus.Manager.InitializeConditions.^(https?|oci)://.+$) admits stringsurl.Parserejects, so a repository can acquire an unparsable url after its internal objects exist. The cleanup switch now has adefault:branch, otherwise deleting such a repository orphaned the internalHelmRepositoryand both secrets.Tests — from zero to 60+ in the controller image
Evaluatebranch, asserting the three invariants on every case; scheduling arithmetic pinned exactly; fake-client tests for secrets, internal state, catalog write and pruning, and the reconcile flow end to end.kstatus_test.gorenders the produced status as unstructured and asks the realcli-utilsfor its verdict, so the contract is checked against the library rather than against our reading of it. Addssigs.k8s.io/cli-utils(test-only usage).UntilConditionAbsenthelper, assertions that a healthy repository carries neither abnormal-true condition, and a stall-then-recover scenario.Documentation —
docs/README.md,docs/README.ru.md, CRD descriptionsReady/Syncedcombinations to what an operator should do.Incidental chore —
internal/webhook/helmclusteraddon/webhook.gofmt.Errorf("...helmclusteraddon/%s owns chart claim: %w", err)had%sconsumingerrand%wwith no argument, so the message was malformed and the cause unwrapped. It failedgo vet, andgo vetruns insidego test, so it blocked the whole module's test gate. Fixed in its own commit.Review focus / risks
Readychanged meaning with no schema signal. It used to report only the internal source object's readiness; it now reports whether the repository is usable as a whole. Anything alerting onReady, and the Deckhouse UI, should be reviewed. Deliberately shipped asfeat(core):rather thanfeat(core)!:—cliff.tomlsetsbreaking_always_bump_major = true, and a jump from v0.1.0 to v1.0.0 was judged disproportionate for a change that breaks no schema and no existing manifest.Ready=TruealongsideSynced=Falseis legal and means "installed addons keep working, the catalog is stale". It is bounded — it escalates toReady=False/Stalled=RetriesExceededonce the backoff caps, roughly 75 minutes of failures. Known residual gap: if the internal object is unhealthy and the catalog write fails in the same pass, neitherReadynorSyncedrecords evidence andReadydrops toUnknownuntil the next attempt. Closing it fully would need a dedicatedlastSuccessfulFetchTimefield, which was deliberately not added.Reconciling/Stalledare removed, not set toFalse, which relies onclient.MergeFromproducing a JSON merge patch that replaces the whole array.TestReconcileRemovesStalledOnRecoverycovers it against the fake client — worth confirming once against a real API server, since a silent failure here would leave a repository reportingFailedto kstatus forever.nextSyncTime == nilforces an attempt, so the exposure is seconds. Two things to expect anyway: an inheritedReady=Truesatisfies the new "evidence" definition before any read has happened under the new code, and objects whosestatus.observedGenerationlagged (the old status manager took the minimum across conditions) may flapTrue → Unknown → Trueonce. A repository already dead at upgrade time keepsReady=Truefor about 80 minutes beforeRetriesExceeded.go vet, and its first real run will be in CI. One CI-specific trap is already handled: the deliberate-stall scenario makes the controller log errors, and the e2e log watcher accumulates errors suite-wide and asserts them empty at teardown, sotests/e2e/default_config.yamlexcludes that repository's name. Watch the helm lifecycle spec's duration on the first run.spec.interval; the cadence is observable throughnextSyncTimebut not configurable. Adding a field later is additive.bin/golangci-lint-v2.8.0is built with go1.25.5 and refuses its config against a newer local toolchain, andtask validation:doc-changesdoes not compile onmain(undefined: RunNoCyrillicValidation). Linting was verified with golangci-lint v2.12.2 and the repo's own config —--new-from-revreports zero new issues in both modules.