Skip to content

Commit 841ed7a

Browse files
makkessouleb
andauthored
[RFC 0002] Flux OCI support for Helm (#690)
* Add OCI Helm support * users will be able to declare OCI HelmRepository by using the `.spec.type` field of the HelmRepository API. Contrary to the HTTP/S HelmRepository no index.yaml is reconciled from source, instead a simple url and credentials validation is performed. * For backwards-compatibility, an empty `.spec.type` field leads to the HelmRepository being treated as a plain old HTTP Helm repository. * users will be able to declare the new OCI HelmRepository type as source using the .Spec.SourceRef field of the HelmChart API. This will result in reconciling a chart from an OCI repository. * Add registryTestServer in the test suite and OCI HelmRepository test case * Add a new OCI chart repository type that manage tags and charts from an OCI registry. * Adapat RemoteBuilder to accept both repository types * discard output from OCI registry client; The client has no way to set a verbosity level and spamming the controller logs with "Login succeeded" every time the object is reconciled doesn't help much. Signed-off-by: Soule BA <soule@weave.works> Signed-off-by: Max Jonas Werner <mail@makk.es> Co-authored-by: Soule BA <soule@weave.works>
1 parent b31c98f commit 841ed7a

24 files changed

+2477
-130
lines changed

api/v1beta2/helmrepository_types.go

+11
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ const (
3131
// HelmRepositoryURLIndexKey is the key used for indexing HelmRepository
3232
// objects by their HelmRepositorySpec.URL.
3333
HelmRepositoryURLIndexKey = ".metadata.helmRepositoryURL"
34+
// HelmRepositoryTypeDefault is the default HelmRepository type.
35+
// It is used when no type is specified and corresponds to a Helm repository.
36+
HelmRepositoryTypeDefault = "default"
37+
// HelmRepositoryTypeOCI is the type for an OCI repository.
38+
HelmRepositoryTypeOCI = "oci"
3439
)
3540

3641
// HelmRepositorySpec specifies the required configuration to produce an
@@ -78,6 +83,12 @@ type HelmRepositorySpec struct {
7883
// NOTE: Not implemented, provisional as of https://github.com/fluxcd/flux2/pull/2092
7984
// +optional
8085
AccessFrom *acl.AccessFrom `json:"accessFrom,omitempty"`
86+
87+
// Type of the HelmRepository.
88+
// When this field is set to "oci", the URL field value must be prefixed with "oci://".
89+
// +kubebuilder:validation:Enum=default;oci
90+
// +optional
91+
Type string `json:"type,omitempty"`
8192
}
8293

8394
// HelmRepositoryStatus records the observed state of the HelmRepository.

config/crd/bases/source.toolkit.fluxcd.io_helmrepositories.yaml

+7
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,13 @@ spec:
330330
default: 60s
331331
description: Timeout of the index fetch operation, defaults to 60s.
332332
type: string
333+
type:
334+
description: Type of the HelmRepository. When this field is set to "oci",
335+
the URL field value must be prefixed with "oci://".
336+
enum:
337+
- default
338+
- oci
339+
type: string
333340
url:
334341
description: URL of the Helm repository, a valid URL contains at least
335342
a protocol and host.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
apiVersion: source.toolkit.fluxcd.io/v1beta2
3+
kind: HelmRepository
4+
metadata:
5+
name: podinfo
6+
spec:
7+
url: oci://ghcr.io/stefanprodan/charts
8+
type: "oci"
9+
interval: 1m
10+
---
11+
apiVersion: source.toolkit.fluxcd.io/v1beta2
12+
kind: HelmChart
13+
metadata:
14+
name: podinfo
15+
spec:
16+
chart: podinfo
17+
sourceRef:
18+
kind: HelmRepository
19+
name: podinfo
20+
version: '6.1.*'
21+
interval: 1m

controllers/helmchart_controller.go

+120-50
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929
"time"
3030

3131
helmgetter "helm.sh/helm/v3/pkg/getter"
32+
"helm.sh/helm/v3/pkg/registry"
3233
corev1 "k8s.io/api/core/v1"
3334
apierrs "k8s.io/apimachinery/pkg/api/errors"
3435
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -116,9 +117,10 @@ type HelmChartReconciler struct {
116117
kuberecorder.EventRecorder
117118
helper.Metrics
118119

119-
Storage *Storage
120-
Getters helmgetter.Providers
121-
ControllerName string
120+
RegistryClientGenerator RegistryClientGeneratorFunc
121+
Storage *Storage
122+
Getters helmgetter.Providers
123+
ControllerName string
122124

123125
Cache *cache.Cache
124126
TTL time.Duration
@@ -378,15 +380,19 @@ func (r *HelmChartReconciler) reconcileSource(ctx context.Context, obj *sourcev1
378380

379381
// Assert source has an artifact
380382
if s.GetArtifact() == nil || !r.Storage.ArtifactExist(*s.GetArtifact()) {
381-
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, "NoSourceArtifact",
382-
"no artifact available for %s source '%s'", obj.Spec.SourceRef.Kind, obj.Spec.SourceRef.Name)
383-
r.eventLogf(ctx, obj, events.EventTypeTrace, "NoSourceArtifact",
384-
"no artifact available for %s source '%s'", obj.Spec.SourceRef.Kind, obj.Spec.SourceRef.Name)
385-
return sreconcile.ResultRequeue, nil
383+
if helmRepo, ok := s.(*sourcev1.HelmRepository); !ok || !registry.IsOCI(helmRepo.Spec.URL) {
384+
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, "NoSourceArtifact",
385+
"no artifact available for %s source '%s'", obj.Spec.SourceRef.Kind, obj.Spec.SourceRef.Name)
386+
r.eventLogf(ctx, obj, events.EventTypeTrace, "NoSourceArtifact",
387+
"no artifact available for %s source '%s'", obj.Spec.SourceRef.Kind, obj.Spec.SourceRef.Name)
388+
return sreconcile.ResultRequeue, nil
389+
}
386390
}
387391

388-
// Record current artifact revision as last observed
389-
obj.Status.ObservedSourceArtifactRevision = s.GetArtifact().Revision
392+
if s.GetArtifact() != nil {
393+
// Record current artifact revision as last observed
394+
obj.Status.ObservedSourceArtifactRevision = s.GetArtifact().Revision
395+
}
390396

391397
// Defer observation of build result
392398
defer func() {
@@ -439,7 +445,10 @@ func (r *HelmChartReconciler) reconcileSource(ctx context.Context, obj *sourcev1
439445
// object, and returns early.
440446
func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj *sourcev1.HelmChart,
441447
repo *sourcev1.HelmRepository, b *chart.Build) (sreconcile.Result, error) {
442-
var tlsConfig *tls.Config
448+
var (
449+
tlsConfig *tls.Config
450+
logOpts []registry.LoginOption
451+
)
443452

444453
// Construct the Getter options from the HelmRepository data
445454
clientOpts := []helmgetter.Option{
@@ -481,32 +490,93 @@ func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj *
481490
// Requeue as content of secret might change
482491
return sreconcile.ResultEmpty, e
483492
}
484-
}
485493

486-
// Initialize the chart repository
487-
chartRepo, err := repository.NewChartRepository(repo.Spec.URL, r.Storage.LocalPath(*repo.GetArtifact()), r.Getters, tlsConfig, clientOpts,
488-
repository.WithMemoryCache(r.Storage.LocalPath(*repo.GetArtifact()), r.Cache, r.TTL, func(event string) {
489-
r.IncCacheEvents(event, obj.Name, obj.Namespace)
490-
}))
491-
if err != nil {
492-
// Any error requires a change in generation,
493-
// which we should be informed about by the watcher
494-
switch err.(type) {
495-
case *url.Error:
496-
e := &serror.Stalling{
497-
Err: fmt.Errorf("invalid Helm repository URL: %w", err),
498-
Reason: sourcev1.URLInvalidReason,
494+
// Build registryClient options from secret
495+
logOpt, err := loginOptionFromSecret(*secret)
496+
if err != nil {
497+
e := &serror.Event{
498+
Err: fmt.Errorf("failed to configure Helm client with secret data: %w", err),
499+
Reason: sourcev1.AuthenticationFailedReason,
499500
}
500501
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Err.Error())
502+
// Requeue as content of secret might change
501503
return sreconcile.ResultEmpty, e
502-
default:
503-
e := &serror.Stalling{
504-
Err: fmt.Errorf("failed to construct Helm client: %w", err),
505-
Reason: meta.FailedReason,
504+
}
505+
506+
logOpts = append([]registry.LoginOption{}, logOpt)
507+
}
508+
509+
// Initialize the chart repository
510+
var chartRepo chart.Remote
511+
switch repo.Spec.Type {
512+
case sourcev1.HelmRepositoryTypeOCI:
513+
if !registry.IsOCI(repo.Spec.URL) {
514+
err := fmt.Errorf("invalid OCI registry URL: %s", repo.Spec.URL)
515+
return chartRepoErrorReturn(err, obj)
516+
}
517+
518+
// with this function call, we create a temporary file to store the credentials if needed.
519+
// this is needed because otherwise the credentials are stored in ~/.docker/config.json.
520+
// TODO@souleb: remove this once the registry move to Oras v2
521+
// or rework to enable reusing credentials to avoid the unneccessary handshake operations
522+
registryClient, file, err := r.RegistryClientGenerator(logOpts != nil)
523+
if err != nil {
524+
return chartRepoErrorReturn(err, obj)
525+
}
526+
527+
if file != "" {
528+
defer func() {
529+
os.Remove(file)
530+
}()
531+
}
532+
533+
// Tell the chart repository to use the OCI client with the configured getter
534+
clientOpts = append(clientOpts, helmgetter.WithRegistryClient(registryClient))
535+
ociChartRepo, err := repository.NewOCIChartRepository(repo.Spec.URL, repository.WithOCIGetter(r.Getters), repository.WithOCIGetterOptions(clientOpts), repository.WithOCIRegistryClient(registryClient))
536+
if err != nil {
537+
return chartRepoErrorReturn(err, obj)
538+
}
539+
chartRepo = ociChartRepo
540+
541+
// If login options are configured, use them to login to the registry
542+
// The OCIGetter will later retrieve the stored credentials to pull the chart
543+
if logOpts != nil {
544+
err = ociChartRepo.Login(logOpts...)
545+
if err != nil {
546+
return chartRepoErrorReturn(err, obj)
506547
}
507-
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Err.Error())
508-
return sreconcile.ResultEmpty, e
509548
}
549+
default:
550+
var httpChartRepo *repository.ChartRepository
551+
httpChartRepo, err := repository.NewChartRepository(repo.Spec.URL, r.Storage.LocalPath(*repo.GetArtifact()), r.Getters, tlsConfig, clientOpts,
552+
repository.WithMemoryCache(r.Storage.LocalPath(*repo.GetArtifact()), r.Cache, r.TTL, func(event string) {
553+
r.IncCacheEvents(event, obj.Name, obj.Namespace)
554+
}))
555+
if err != nil {
556+
return chartRepoErrorReturn(err, obj)
557+
}
558+
chartRepo = httpChartRepo
559+
defer func() {
560+
if httpChartRepo == nil {
561+
return
562+
}
563+
// Cache the index if it was successfully retrieved
564+
// and the chart was successfully built
565+
if r.Cache != nil && httpChartRepo.Index != nil {
566+
// The cache key have to be safe in multi-tenancy environments,
567+
// as otherwise it could be used as a vector to bypass the helm repository's authentication.
568+
// Using r.Storage.LocalPath(*repo.GetArtifact() is safe as the path is in the format /<helm-repository-name>/<chart-name>/<filename>.
569+
err := httpChartRepo.CacheIndexInMemory()
570+
if err != nil {
571+
r.eventLogf(ctx, obj, events.EventTypeTrace, sourcev1.CacheOperationFailedReason, "failed to cache index: %s", err)
572+
}
573+
}
574+
575+
// Delete the index reference
576+
if httpChartRepo.Index != nil {
577+
httpChartRepo.Unload()
578+
}
579+
}()
510580
}
511581

512582
// Construct the chart builder with scoped configuration
@@ -532,25 +602,6 @@ func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj *
532602
return sreconcile.ResultEmpty, err
533603
}
534604

535-
defer func() {
536-
// Cache the index if it was successfully retrieved
537-
// and the chart was successfully built
538-
if r.Cache != nil && chartRepo.Index != nil {
539-
// The cache key have to be safe in multi-tenancy environments,
540-
// as otherwise it could be used as a vector to bypass the helm repository's authentication.
541-
// Using r.Storage.LocalPath(*repo.GetArtifact() is safe as the path is in the format /<helm-repository-name>/<chart-name>/<filename>.
542-
err := chartRepo.CacheIndexInMemory()
543-
if err != nil {
544-
r.eventLogf(ctx, obj, events.EventTypeTrace, sourcev1.CacheOperationFailedReason, "failed to cache index: %s", err)
545-
}
546-
}
547-
548-
// Delete the index reference
549-
if chartRepo.Index != nil {
550-
chartRepo.Unload()
551-
}
552-
}()
553-
554605
*b = *build
555606
return sreconcile.ResultSuccess, nil
556607
}
@@ -1090,3 +1141,22 @@ func reasonForBuild(build *chart.Build) string {
10901141
}
10911142
return sourcev1.ChartPullSucceededReason
10921143
}
1144+
1145+
func chartRepoErrorReturn(err error, obj *sourcev1.HelmChart) (sreconcile.Result, error) {
1146+
switch err.(type) {
1147+
case *url.Error:
1148+
e := &serror.Stalling{
1149+
Err: fmt.Errorf("invalid Helm repository URL: %w", err),
1150+
Reason: sourcev1.URLInvalidReason,
1151+
}
1152+
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Err.Error())
1153+
return sreconcile.ResultEmpty, e
1154+
default:
1155+
e := &serror.Stalling{
1156+
Err: fmt.Errorf("failed to construct Helm client: %w", err),
1157+
Reason: meta.FailedReason,
1158+
}
1159+
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Err.Error())
1160+
return sreconcile.ResultEmpty, e
1161+
}
1162+
}

0 commit comments

Comments
 (0)