Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
539c642
feat(api): add media type and availability to chart versions
drey Sep 2, 2026
7f39130
feat(oci): identify a chart artifact by manifest, config and layer me…
drey Sep 2, 2026
41005e7
feat(oci): resolve chart layer media type per tag with an incremental…
drey Sep 2, 2026
e46bb10
fix(oci): share one puller per pass and surface context cancellation
drey Sep 2, 2026
71309d2
feat(core): merge chart versions incrementally and protect referenced…
drey Sep 2, 2026
1803348
fix(core): stop reporting a pre-fetch cluster failure as a successful…
drey Sep 2, 2026
e56a78c
feat(core): report an incomplete first catalog read as PartialSync
drey Sep 2, 2026
1bbc84e
feat(core): build the internal OCIRepository from the recorded chart …
drey Sep 2, 2026
5bd52b7
test(core): cover the OCI media-type deploy gate and the ready/remove…
drey Sep 2, 2026
f217362
feat(chart-values): take the chart layer media type from the catalog …
drey Sep 2, 2026
9973369
fix(chart-values): treat ResolvePending as retryable, cover RemovedFr…
drey Sep 2, 2026
3eb39e5
fix(chart-values): treat empty unavailableReason as pending, not valu…
drey Sep 2, 2026
c8ebfec
fix(core): keep a referenced version's media type on a fresh unsuppor…
drey Sep 2, 2026
5d0533a
refactor(core): move AddonRepository field index into internal/index
drey Sep 3, 2026
f9a902d
test(core): cover the deploy gate across a repository type switch
drey Sep 3, 2026
d3b13f6
test(e2e): probe the HelmClusterAddon webhook's real TLS admission path
drey Sep 3, 2026
fb86cdf
fix(core): roll the controller pods when the webhook certificate rotates
drey Sep 3, 2026
fa5d33f
fix(e2e): make two dead assertions in the module setup actually run
drey Sep 3, 2026
7000169
fix(e2e): ignore terminating pods in e2e pod assertions
drey Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions api/naming/naming.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
Copyright 2026 Flant JSC.

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 naming

import (
"crypto/sha256"
"fmt"
"strings"
)

// HelmClusterAddonChartName derives the name of the HelmClusterAddonChart object
// that mirrors one chart of a repository. It lives in the api module because
// operator-helm-controller writes those objects while chart-values-controller reads
// them: the name is a truncated hash, so both must derive it identically.
func HelmClusterAddonChartName(repoName, chartName string) string {
hash := hash(fmt.Sprintf("%s-chart-%s", repoName, chartName))

var result, postfix string

if len(repoName) > 20 {
result += repoName[:20] + "-chart-"
postfix = "-" + hash
} else {
result += repoName + "-chart-"
}

if len(chartName) > 20 {
result += chartName[:20]
postfix = "-" + hash
} else {
result += chartName
}

return strings.TrimRight(result, "-") + postfix
}

func hash(s string) string {
h := sha256.New()
h.Write([]byte(s))

return fmt.Sprintf("%x", h.Sum(nil))[:12]
}
55 changes: 55 additions & 0 deletions api/naming/naming_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
Copyright 2026 Flant JSC.

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 naming

import "testing"

func TestHelmClusterAddonChartName(t *testing.T) {
cases := []struct {
name string
repo string
chart string
want string
}{
{
name: "short names are joined verbatim",
repo: "example",
chart: "podinfo",
want: "example-chart-podinfo",
},
{
name: "long names are truncated and suffixed with a hash",
repo: "yandex-cloud-marketplace-mirror",
chart: "cert-manager-webhook-yandex",
want: "yandex-cloud-marketp-chart-cert-manager-webhook-a3ee4a8a584e",
},
{
name: "an empty chart name leaves no trailing dash",
repo: "repo",
chart: "",
want: "repo-chart",
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := HelmClusterAddonChartName(tc.repo, tc.chart); got != tc.want {
t.Fatalf("HelmClusterAddonChartName(%q, %q) = %q, want %q", tc.repo, tc.chart, got, tc.want)
}
})
}
}
6 changes: 6 additions & 0 deletions api/v1alpha1/conditions.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ const (
ReasonSourceRejectedRequest = "SourceRejectedRequest"
ReasonInvalidRepositoryURL = "InvalidRepositoryURL"
ReasonUnsupportedRepositoryType = "UnsupportedRepositoryType"
// ReasonChartVersionRemoved marks an addon whose chart version is still recorded in
// the catalog but is no longer offered by the repository.
ReasonChartVersionRemoved = "ChartVersionRemoved"
// ReasonPartialSync marks a repository whose first catalog read left some chart
// versions unresolved.
ReasonPartialSync = "PartialSync"

// HelmRelease error reasons
ReasonReleaseFailed = "ReleaseFailed"
Expand Down
33 changes: 32 additions & 1 deletion api/v1alpha1/helm_cluster_addon_chart.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,22 @@ const (
HelmClusterAddonChartResource = "helmclusteraddoncharts"

HelmClusterAddonChartLabelSourceName = "helm.deckhouse.io/cluster-addon-chart"

// UnavailableReason* are the values of HelmClusterAddonChartVersion.UnavailableReason.
// They are field values rather than condition reasons, so they live next to the
// type that carries them instead of conditions.go.
//
// UnavailableReasonRemovedFromRepository means the tag is no longer offered by the
// repository. The entry is retained only because an addon still references it, and
// the marker is dropped automatically once the tag is listed again.
UnavailableReasonRemovedFromRepository = "RemovedFromRepository"
// UnavailableReasonUnsupportedMediaType means the manifest was read but the artifact
// is not a packaged Helm chart. It is a verdict about the artifact, so it is kept
// until a force reconcile re-examines every tag.
UnavailableReasonUnsupportedMediaType = "UnsupportedMediaType"
// UnavailableReasonResolvePending means the manifest request failed and no verdict
// was reached. Such a tag is re-examined on every normal synchronization.
UnavailableReasonResolvePending = "ResolvePending"
)

// HelmClusterAddonChart represents a specific Helm chart discovered within a HelmClusterAddonRepository. These resources are automatically managed during repository synchronization and are immutable to user modifications.
Expand Down Expand Up @@ -71,7 +87,9 @@ type HelmClusterAddonChartStatus struct {
Conditions []metav1.Condition `json:"conditions,omitempty"`
// Generation represents resource generation that was last processed by the controller.
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// Available helm chart versions
// Versions lists every chart version the controller has examined. A version is
// usable when it has no unavailableReason; for an OCI repository a usable version
// also carries the media type of the layer that holds it.
// +optional
Versions []HelmClusterAddonChartVersion `json:"versions"`
}
Expand All @@ -80,6 +98,19 @@ type HelmClusterAddonChartVersion struct {
// Helm chart version
// +kubebuilder:validation:MinLength=1
Version string `json:"version"`
// MediaType is the OCI media type of the layer that holds this chart version. It is
// set only for versions from an OCI repository, and only when the layer is supported:
// an empty value means the version cannot be deployed.
// +optional
MediaType string `json:"mediaType,omitempty"`
// UnavailableReason explains why this version cannot be deployed. Its absence means
// the version is usable.
// +optional
// +kubebuilder:validation:Enum=RemovedFromRepository;UnsupportedMediaType;ResolvePending
UnavailableReason string `json:"unavailableReason,omitempty"`
// UnavailableMessage carries human readable detail for UnavailableReason.
// +optional
UnavailableMessage string `json:"unavailableMessage,omitempty"`
}

// HelmClusterAddonChartList contains a list of HelmClusterAddonCharts.
Expand Down
8 changes: 7 additions & 1 deletion crds/doc-ru-helmclusteraddoncharts.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,14 @@ spec:
observedGeneration:
description: Поколение ресурса, обработанное контроллером последним.
versions:
description: Доступные версии Helm-чарта.
description: Список всех версий Helm-чарта, изученных контроллером. Версия пригодна к использованию, если у неё нет unavailableReason; для OCI-репозитория у пригодной версии также заполнен media type слоя, который её содержит.
items:
properties:
version:
description: Версия Helm-чарта.
mediaType:
description: "OCI media type слоя, содержащего эту версию чарта. Заполняется только для версий из OCI-репозитория и только когда слой поддерживается: пустое значение означает, что версию нельзя задеплоить."
unavailableReason:
description: Причина, по которой версию нельзя задеплоить. Отсутствие поля означает, что версия пригодна.
unavailableMessage:
description: Человекочитаемые подробности к unavailableReason.
24 changes: 23 additions & 1 deletion crds/helmclusteraddoncharts.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,31 @@ spec:
format: int64
type: integer
versions:
description: Available helm chart versions
description: |-
Versions lists every chart version the controller has examined. A version is
usable when it has no unavailableReason; for an OCI repository a usable version
also carries the media type of the layer that holds it.
items:
properties:
mediaType:
description: |-
MediaType is the OCI media type of the layer that holds this chart version. It is
set only for versions from an OCI repository, and only when the layer is supported:
an empty value means the version cannot be deployed.
type: string
unavailableMessage:
description: UnavailableMessage carries human readable detail
for UnavailableReason.
type: string
unavailableReason:
description: |-
UnavailableReason explains why this version cannot be deployed. Its absence means
the version is usable.
enum:
- RemovedFromRepository
- UnsupportedMediaType
- ResolvePending
type: string
version:
description: Helm chart version
minLength: 1
Expand Down
77 changes: 70 additions & 7 deletions images/chart-values-controller/internal/resolver/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,10 @@ import (
"github.com/deckhouse/chart-values-controller/internal/cache"
"github.com/deckhouse/chart-values-controller/internal/labels"
"github.com/deckhouse/chart-values-controller/internal/naming"
apinaming "github.com/deckhouse/operator-helm/api/naming"
helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1"
)

// helmChartLayerMediaType is the OCI media type of the layer that holds a
// packaged Helm chart.
const helmChartLayerMediaType = "application/vnd.cncf.helm.chart.content.v1.tar+gzip"

// RepositoryKind identifies the kind of repository a chart lives in. New
// repository kinds are added as new constants plus a case in Resolve.
type RepositoryKind string
Expand Down Expand Up @@ -126,6 +123,64 @@ func (r *Resolver) Resolve(ctx context.Context, req Request) (Result, error) {
}
}

// chartVersionMediaType reads the OCI layer media type recorded for the requested
// version by operator-helm-controller. That status is the single source of truth:
// resolving the media type here would duplicate the logic and spend registry requests
// on an answer that is already in the cluster.
//
// A non-nil Result means the caller must stop and return it.
func (r *Resolver) chartVersionMediaType(ctx context.Context, req Request) (string, *Result, error) {
chart := &helmv1alpha1.HelmClusterAddonChart{}
key := types.NamespacedName{Name: apinaming.HelmClusterAddonChartName(req.RepositoryName, req.Chart)}

if err := r.client.Get(ctx, key, chart); err != nil {
if apierrors.IsNotFound(err) {
// The chart object is created by operator-helm-controller when it synchronizes
// the repository: until then the catalog simply has not caught up.
return "", &Result{Outcome: OutcomePending}, nil
}

return "", nil, fmt.Errorf("getting chart: %w", err)
}

for _, version := range chart.Status.Versions {
if version.Version != req.Version {
continue
}

if version.MediaType == "" {
if version.UnavailableReason == helmv1alpha1.UnavailableReasonResolvePending || version.UnavailableReason == "" {
// Both an explicit ResolvePending and an empty reason mean the catalog has
// not reached a verdict yet, so the caller should retry rather than being
// told the version is permanently unreadable. An empty reason alongside an
// empty media type is the pre-upgrade shape of a version entry (written
// before this controller recorded verdicts at all): the client's
// KnownVersions treats it as never examined and re-resolves it on the very
// next normal synchronization, exactly like ResolvePending.
return "", &Result{Outcome: OutcomePending}, nil
}

// Every other reason is a durable verdict that will not change without a
// change in the repository (e.g. an unsupported media type, or a removed tag
// with no media type on record), so it is reported as values-not-found,
// naming why.
detail := version.UnavailableReason
if version.UnavailableMessage != "" {
detail += ": " + version.UnavailableMessage
}

return "", &Result{
Outcome: OutcomeValuesNotFound,
Message: fmt.Sprintf("chart version %s is not readable (%s)", req.Version, detail),
}, nil
}

return version.MediaType, nil, nil
}

return "", &Result{Outcome: OutcomePending}, nil
}

// resolveHelmClusterAddon ensures the auxiliary source resource for a chart from
// a HelmClusterAddonRepository exists, inspects its status and returns the
// chart's values.yaml once the artifact is ready.
Expand Down Expand Up @@ -154,7 +209,15 @@ func (r *Resolver) resolveHelmClusterAddon(ctx context.Context, req Request) (Re

switch {
case isOCI(repo.Spec.URL):
ociRepo, err := r.ensureOCIRepository(ctx, repo, req, name, expiresAt)
mediaType, done, err := r.chartVersionMediaType(ctx, req)
if err != nil {
return Result{}, err
}
if done != nil {
return *done, nil
}

ociRepo, err := r.ensureOCIRepository(ctx, repo, req, name, expiresAt, mediaType)
if err != nil {
return Result{}, err
}
Expand Down Expand Up @@ -234,7 +297,7 @@ func (r *Resolver) ensureHelmChart(ctx context.Context, repo *helmv1alpha1.HelmC
return chart, false, nil
}

func (r *Resolver) ensureOCIRepository(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository, req Request, name, expiresAt string) (*sourcev1.OCIRepository, error) {
func (r *Resolver) ensureOCIRepository(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository, req Request, name, expiresAt, mediaType string) (*sourcev1.OCIRepository, error) {
authSecret, tlsSecret, err := r.findRepositorySecretNames(ctx, repo.Name)
if err != nil {
return nil, err
Expand All @@ -254,7 +317,7 @@ func (r *Resolver) ensureOCIRepository(ctx context.Context, repo *helmv1alpha1.H
ociRepo.Spec.Interval = r.sourceInterval
ociRepo.Spec.Insecure = repo.Spec.InsecureSkipVerify
ociRepo.Spec.LayerSelector = &sourcev1.OCILayerSelector{
MediaType: helmChartLayerMediaType,
MediaType: mediaType,
Operation: "copy",
}

Expand Down
Loading
Loading