feat(core): support legacy OCI chart media type with incremental indexing - #68
Merged
Conversation
Signed-off-by: Ilya Drey <ilya.drey@flant.com>
…dia type Signed-off-by: Ilya Drey <ilya.drey@flant.com>
… pass Signed-off-by: Ilya Drey <ilya.drey@flant.com>
- resolveChartVersions now builds one remote.Puller for the whole repository and passes it to every goroutine, instead of remote.Get building a fresh Puller (and paying a fresh /v2/ ping plus, on a bearer registry, a fresh token request) for every examined tag. - After group.Wait() succeeds, check the caller's context: a cancelled context previously surfaced as remote.Get failures that fell through to fabricated ResolvePending verdicts instead of an error, turning a cancelled pass into an apparently successful one. - KnownVersion carries UnavailableMessage so a skipped unsupported tag keeps its explanation across passes; carryKnown clears the message together with a cleared RemovedFromRepository reason. - truncate cuts on a UTF-8 rune boundary instead of a raw byte index. - Strengthened TestFetchChartsOCIDropsVanishedTag and TestFetchChartsOCIClearsRemovedFromRepository to be actual evidence for the behaviour they claim, and added tests for the cancelled context and the rune-safe truncation. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
… ones from pruning Signed-off-by: Ilya Drey <ilya.drey@flant.com>
… fetch Add SyncOutcome.FetchAttempted so the reconciler only trusts a Fetch outcome when the registry was actually contacted; without it a knownCharts listing failure reset ConsecutiveFetchFailures and could write Ready=True off a fetch that never ran. Also: guard the last-applied-version credit by repository/chart identity, make sortChartVersions a strict weak ordering, log the label-less prune and knownCharts-drop branches, and fix the startup log attribution for the shared field index. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Signed-off-by: Ilya Drey <ilya.drey@flant.com>
…media type Signed-off-by: Ilya Drey <ilya.drey@flant.com>
…d override Signed-off-by: Ilya Drey <ilya.drey@flant.com>
…status Signed-off-by: Ilya Drey <ilya.drey@flant.com>
…omRepository, name empty verdicts Signed-off-by: Ilya Drey <ilya.drey@flant.com>
…es-not-found An empty mediaType with an empty unavailableReason is the pre-upgrade shape of a version entry, re-resolved on the next normal sync exactly like ResolvePending. Reporting it as OutcomeValuesNotFound turned every OCI chart values request into a permanent 422 for the whole upgrade window, up to five minutes, instead of a retryable pending. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
…ted verdict mergeChartVersions wrote every fetched entry verbatim, so a tag re-pushed as a non-chart artifact wiped MediaType even for a version an addon still references, tripping the D4 deploy gate and bricking the addon. Carry the previously recorded media type forward for in-use versions while keeping the fresh UnavailableReason/Message, matching D4's table; leave unreferenced versions untouched. Also corrects a stale doc comment on resolveChartVersions that still claimed the only returned error is terminal, missing the puller- construction and cancelled-context cases added since. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Keep all HelmClusterAddon field indexes in one package: relocate the repository index out of internal/utils next to AddonChart, renaming it AddonRepository/SetupAddonRepository for consistency with the existing pair. Behaviour is unchanged, including the empty-repository guard. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Add two cases to the getHelmClusterAddonChart table test: an OCI-era catalog entry (media type set, no reason) evaluated against a Helm repository, which must still pass since the Helm gate never reads the media type; and a Helm-era entry (no media type, no reason) evaluated against an OCI repository, which must be rejected with the "has not resolved it yet" detail. These pin the two windows a repository's spec.url can pass through when it flips between oci:// and https://. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
The caBundle/ca.crt equality check in UntilModuleEnabled proves two API objects agree, but not that the API server can complete a TLS handshake with the certificate the running webhook pod serves. Add a dry-run create of a uniquely-named, schema-valid HelmClusterAddon at the end of setup so a certificate that isn't trusted yet fails there instead of mid-spec. Treat an Invalid response from the dry-run create as a hard, immediate failure of the probe object itself (schema validation runs before the webhook, so it proves nothing about reachability), rather than retrying it away until the timeout. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
The admission webhook's certificate is mounted from the operator-helm-controller-tls secret and read at startup, so rotating it updated the secret without rolling the pods and left them serving the previous one. That is not cosmetic staleness. The same values render both the secret and the caBundle of the ValidatingWebhookConfiguration, so the API server starts trusting the new CA while the pods still present the old certificate, and every HelmClusterAddon admission request fails with "certificate signed by unknown authority" until something else restarts them — which is exactly what CI hit. Hashing the certificate into the pod template makes the rollout part of the very release that rotates it, so the pods and the caBundle can never disagree. A pod-reloader annotation was considered instead and rejected: it reacts after the secret is written rather than atomically with it, and the pod-reloader module is absent from the Minimal bundle, where this failure is total. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Neither Eventually nor Consistently invokes its closure without a terminal matcher: .WithTimeout and .WithPolling only configure the AsyncAssertion, and Consistently's interval arguments do the same. Both blocks lacked .Should, so the step that removes deckhouse's webhook-handler pods never ran and the 60s stability check never ran either — the module setup only looked like it verified the webhook handler had settled. Two defects the dead code was hiding are fixed with them: the DeleteCollection block asserted with Expect instead of g.Expect, which would fail the spec outright instead of retrying, and the stability check listed pods by "app=webhook-hander", a selector that matches nothing. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
A terminating pod keeps appearing in List results until the kubelet finishes tearing it down, so counting it alongside its already-Running replacement makes a rollout or a deliberate delete look like the workload has twice as many pods as it actually does. This flaked SynchronizedBeforeSuite after the webhook-handler pods were deliberately deleted and immediately recreated. Extract the DeletionTimestamp check UntilPodCount already used into a shared notTerminating helper and apply it everywhere pods are counted or asserted on: UntilControllerReady, UntilAllPodsReady, the Consistently block in UntilModuleEnabled that reproduced the failure, the module-namespace pod loop above it, and (for consistency, given their >= semantics) AssertPodsExist and UntilPodsExist. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
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
The OCI layer media type of a packaged chart stops being a constant and becomes a fact about a specific chart version: the indexer resolves it once per new tag, records it in
HelmClusterAddonChart.status.versions[], and both controllers build their internalOCIRepositoryfrom that record. This makes charts pushed by older tooling — layerapplication/tar+gzipinstead ofapplication/vnd.cncf.helm.chart.content.v1.tar+gzip— deployable, and their values readable. Because resolving a media type costs a manifest request per tag while the catalog syncs every five minutes, the recorded verdicts double as the set of already-examined tags, so a steady-state pass with no new tags still costs exactly one registry request.status.versions[]gainsmediaType,unavailableReasonandunavailableMessage, and now lists every examined version rather than only the usable ones.Why
The media type was hardcoded in two places —
internal/services/oci_repo_service.go(the internalOCIRepositoryof aHelmClusterAddon) andimages/chart-values-controller/internal/resolver/resolver.go(the auxiliary one used to readvalues.yaml). Both assumed the current CNCF type. A real marketplace chart does not have it:For such a chart nelm-source-controller answers "failed to find layer with media type", the addon never gets an artifact, and
chart-values-controllerreturnsvalues_not_found— so a chart that is perfectly valid is simply undeployable.Three constraints shaped the design rather than the obvious one-line fix:
Dropping
layerSelectorentirely is not safe. With no media type the source controller takeslayers[0].helm pushof a chart with a.provfile produces a second layer and the order is not guaranteed by the spec, so the wrong layer can be selected silently.application/tar+gzipidentifies nothing. It is a generic type any tarball may carry, so accepting it means the layer alone can no longer tell a chart from an arbitrary artifact. The config media type is the authoritative marker.The media type is per version, not per repository, so it has to be discovered per tag — and the previous indexer was one
remote.Listwith a semver filter that never touched a manifest. Naively resolving would turn every five-minute sync into N manifest requests.Key changes
Chart version API —
api/v1alpha1/helm_cluster_addon_chart.go,crds/HelmClusterAddonChartVersiongainsmediaType(only ever a supported layer type — an empty value means the version is not deployable),unavailableReason(enumRemovedFromRepository/UnsupportedMediaType/ResolvePending, absence means usable) andunavailableMessage.status.versions[]now lists every examined semver tag, not only the deployable ones; its doc comment and the Russian mirror say so. A separateunresolvedVersions[]was considered and rejected: a version an addon still references has to stay inversions[]anyway, so an entry's list would depend on whether an addon references it and entries would migrate between lists asspec.chart.versionchanges.ReasonPartialSyncandReasonChartVersionRemoved.omitemptyleaves a usable entry with no field to filter on.Chart artifact identification —
internal/client/repository/oci_chart.go(new)config.mediaTypemust be in a closed list (application/vnd.cncf.helm.config.v1+json); the layer is the first entry of a closed priority list (…helm.chart.content.v1.tar+gzip, thenapplication/tar+gzip) present in the manifest. Priority comes from the list, not from the order of layers, so an artifact carrying both resolves deterministically.UnsupportedMediaType; which media type failed, and its observed value, go intounavailableMessage.Incremental OCI indexing —
internal/client/repository/{client,oci}.goFetchOptions{Known KnownCharts; Full bool}withNeedsExamination(chartName, tag): no entry → examine;mediaTypeset → skip;mediaTypeempty and reasonUnsupportedMediaType→ skip; anything else, including a recorded entry with no verdict at all (the upgrade migration) orResolvePending→ examine.Fulloverrides everything and is set by force reconcile.remote.Getreturns both the descriptor media type and the manifest bytes, which are parsed withv1.ParseManifest— nodesc.Image(), no second fetch. Oneremote.NewPulleris shared across the pass, becauseremote.Getbuilds a fresh puller per call and each one re-pings/v2/; on a bearer registry that was a token fetch per tag against a rate-limited endpoint.errgroupwithSetLimit(8); no cap on tags per pass (a cap would only mask missing incrementality). Per-tag requests use a single attempt — a failure is recorded as pending and retried by the next sync anyway.UnsupportedMediaType; 5xx/timeout/429/network →ResolvePendingwith the error in the message, everything else in the pass still published; 404 → the tag vanished between listing and request, omitted entirely; 401/403 → escalated to the repository level as terminal. The only errors that leaveresolveChartVersionsare that escalation, a puller-construction failure and a cancelled parent context.isSemverCompliantTag.Catalog merge and prune protection —
internal/services/repo_sync_service.goknownChartsreads the recorded verdicts back out of the chart statuses before fetching — the status is the only store of that state, and a separate fingerprint field was rejected as one more thing that can drift from the data it summarizes.existing.Status.Versions = lo.Map(...)becomes a merge: a still-listed version comes from the fetch result, a disappeared one is dropped unless an addon references it, in which case it is retained withRemovedFromRepositoryand keeps its media type. Without that media type the addon's internalOCIRepositorycould not be constructed at all, so pruning it would block every change to a running addon rather than just the pull. A referenced version whose fresh verdict isUnsupportedMediaTypekeeps its old media type for the same reason.HelmClusterAddonChartreferenced by an addon is not deleted even when the repository lists no tags.internal/indexso the service can use it without importing the webhook. The module's other field index (.spec.chart.helmClusterAddonRepository, previously ininternal/utils/mapper.go) moved there too, so both live in one place under one naming convention. Bothspec.chart.versionandstatus.lastAppliedChart.versioncount (they differ during an upgrade), and the last-applied reference is checked to belong to this chart.if len(chart.Versions) == 0 { continue }guard, which is what lets a chart whose every tag is pending survive the pruning loop instead of being deleted.SyncOutcome.FetchAttempteddistinguishes "the registry was never contacted" from "the fetch succeeded";FetchOutcome.Pendingcarries the count of unexamined tags as data, never as an error.Repository status —
internal/reconcile/helmclusteraddonrepository/evaluate.golastSuccessfulSyncTimeadvances only on a pass withPending == 0— its doc comment says "fully brought up to date", which is false with unresolved tags. The frozenLast Synccolumn becomes the signal, and it converges on its own.Synced=False/PartialSynconly while no full pass has ever happened. On the first passlastSuccessfulSyncTimeis empty either way, so the signal above does not exist yet and a user would seeSynced=Trueover a silently incomplete catalog; afterwards it stops flapping over one junk tag.Reconciling=Truewas rejected for this: pending tags are retried forever, so the condition would never clear andkubectl waitwould hang over a single junk tag. Nothing escalates — the failure counter is untouched andStalledis never reached.nextFailureCountcarries the counter forward when no fetch was attempted, so a failed status read can no longer resetConsecutiveFetchFailures, clearStalledand reportReady=Truewithout the registry being contacted.Deploy path —
internal/reconcile/helmclusteraddon/reconciler.go,internal/services/oci_repo_service.gomediaType != \"\"", which is the same as "we know enough to construct the internalOCIRepository". It gets four cases right at once: a normal version passes; a version retained after its tag disappeared passes deliberately, so the addon keeps reconciling its values, maintenance mode and deletion while the source controller reports the real pull failure; a non-chart or never-resolved tag is rejected with a message naming the reason; and a Helm repository keeps the old presence-only gate, since its versions never carry a media type.applyOCIRepositorySpectakes the recorded media type and never writes an empty one — there is deliberately no fallback, since falling back to "first layer" is the behaviour this PR removes.ChartVersionRemovedwhen its version is retained but no longer offered, instead of only the source controller's bare "not found".err— a guaranteed-nil leftover fromGetRepositoryType— tostatus.Failedinstead ofaddonChartErr, so the real cause was lost.chart-values-controller —
internal/resolver/resolver.go,templates/chart-values-controller/rbac-for-us.yamlget/list/watchonhelmclusteraddoncharts(the client cache starts an informer on first read, sogetalone would fail at runtime).ResolvePending, or an entry with no reason at all (the pre-upgrade state) →OutcomePending, so the caller retries; any other empty-media-type entry →OutcomeValuesNotFoundnaming the reason. ARemovedFromRepositoryversion keeps its media type and is still served.Shared naming —
api/naming/(new)GetHelmClusterAddonChartNamemoves out ofoperator-helm-controller/internal/utilsinto theapimodule: it is a truncated hash, both controllers must derive it identically, and it lived in aninternalpackage of another module. Golden tests pin its output, so no existing object is renamed.Tests — +~1800 lines
remote.Writereads manifests itself.PartialSyncbranch including many consecutive partial passes never reachingStalled.Review focus / risks
status.versions[]changed meaning with no schema signal. It used to list available versions; it now lists examined ones, ordered semver-descending rather than in registry order. Any consumer — the Deckhouse UI, scripts, dashboards — that treats the list as "available" without checkingunavailableReasonwill show unusable entries. Worth a release note."1.0.3": {}case inoci_test.go). Until that pass completes, every OCI addon reportsReady=Falsewith "the repository catalog has not resolved it yet" and values requests return a retryable pending. Nothing destructive happens — with no artifact the reconciler never callsEnsureHelmRelease, the running release is untouched, and deletion still works becausereconcileDeleteruns before the gate — but the window is up to one sync interval per repository, and the first post-upgrade pass examines every tag at concurrency 8.mediaTypeon every status write, so every tag would be re-examined every five minutes and every OCI addon would stay gated off. Module CRDs are applied ahead of workloads so this should not happen, but it degrades badly rather than gracefully. The reverse is benign and self-healing.UnsupportedMediaTypeis sticky by design, andValidatingAdmissionPolicyforbids everyone except the module's service accounts from editing or deleting aHelmClusterAddonChart— so force reconcile is the only way out. Known gap left in: the annotation is consumed whenever a pass was attempted, including one that failed before the fetch, so a force request can be swallowed. Cheap to gate on the fetch having run.BlockOwnerDeletion), so they are garbage-collected with it and the addon then fails on the repository lookup itself; the auth/TLS secrets go too, so the addon's surviving internalOCIRepositorystarts failing on a private registry. The workload keeps running and the addon stays deletable, and re-creating the repository under the same name recovers everything within a sync. Pre-existing behaviour, unchanged here — the prune protection added in this PR guards the sync path, not owner-reference GC. Closing it properly needs an admission rule or a finalizer on the repository, using the same field index this PR extracts.versions[]now retains every examined tag with up to 256 bytes of message each. Not a realistic shape today (cosign and non-semver tags are filtered before resolution), but a chart repository carrying thousands of non-chart semver tags would grow the object toward the etcd limit, at which point the status patch fails permanently.sync.Map/sync.Onceand the suite passes under-race, but only the happy path is covered — token refresh partway through a long pass is not.application/tar+gzipinlayerSelectorrests on thecraneevidence above and on reading its layer-selection code. Pushing a legacy fixture into the test registry was left as separate test-infrastructure work.task generate:apiwas never run in a working environment (remote taskfiles are disabled locally, soupdate-codegen.shwas invoked directly and prettier skipped). The CRD and deepcopy output is committed and consistent with the markers, buttask ci:generate:apiis the real check — worth running once before merge to avoid a whitespace-only red CI.