[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>
This commit is contained in:
parent
b31c98fe3b
commit
841ed7ae66
|
@ -31,6 +31,11 @@ const (
|
||||||
// HelmRepositoryURLIndexKey is the key used for indexing HelmRepository
|
// HelmRepositoryURLIndexKey is the key used for indexing HelmRepository
|
||||||
// objects by their HelmRepositorySpec.URL.
|
// objects by their HelmRepositorySpec.URL.
|
||||||
HelmRepositoryURLIndexKey = ".metadata.helmRepositoryURL"
|
HelmRepositoryURLIndexKey = ".metadata.helmRepositoryURL"
|
||||||
|
// HelmRepositoryTypeDefault is the default HelmRepository type.
|
||||||
|
// It is used when no type is specified and corresponds to a Helm repository.
|
||||||
|
HelmRepositoryTypeDefault = "default"
|
||||||
|
// HelmRepositoryTypeOCI is the type for an OCI repository.
|
||||||
|
HelmRepositoryTypeOCI = "oci"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HelmRepositorySpec specifies the required configuration to produce an
|
// HelmRepositorySpec specifies the required configuration to produce an
|
||||||
|
@ -78,6 +83,12 @@ type HelmRepositorySpec struct {
|
||||||
// NOTE: Not implemented, provisional as of https://github.com/fluxcd/flux2/pull/2092
|
// NOTE: Not implemented, provisional as of https://github.com/fluxcd/flux2/pull/2092
|
||||||
// +optional
|
// +optional
|
||||||
AccessFrom *acl.AccessFrom `json:"accessFrom,omitempty"`
|
AccessFrom *acl.AccessFrom `json:"accessFrom,omitempty"`
|
||||||
|
|
||||||
|
// Type of the HelmRepository.
|
||||||
|
// When this field is set to "oci", the URL field value must be prefixed with "oci://".
|
||||||
|
// +kubebuilder:validation:Enum=default;oci
|
||||||
|
// +optional
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HelmRepositoryStatus records the observed state of the HelmRepository.
|
// HelmRepositoryStatus records the observed state of the HelmRepository.
|
||||||
|
|
|
@ -330,6 +330,13 @@ spec:
|
||||||
default: 60s
|
default: 60s
|
||||||
description: Timeout of the index fetch operation, defaults to 60s.
|
description: Timeout of the index fetch operation, defaults to 60s.
|
||||||
type: string
|
type: string
|
||||||
|
type:
|
||||||
|
description: Type of the HelmRepository. When this field is set to "oci",
|
||||||
|
the URL field value must be prefixed with "oci://".
|
||||||
|
enum:
|
||||||
|
- default
|
||||||
|
- oci
|
||||||
|
type: string
|
||||||
url:
|
url:
|
||||||
description: URL of the Helm repository, a valid URL contains at least
|
description: URL of the Helm repository, a valid URL contains at least
|
||||||
a protocol and host.
|
a protocol and host.
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
---
|
||||||
|
apiVersion: source.toolkit.fluxcd.io/v1beta2
|
||||||
|
kind: HelmRepository
|
||||||
|
metadata:
|
||||||
|
name: podinfo
|
||||||
|
spec:
|
||||||
|
url: oci://ghcr.io/stefanprodan/charts
|
||||||
|
type: "oci"
|
||||||
|
interval: 1m
|
||||||
|
---
|
||||||
|
apiVersion: source.toolkit.fluxcd.io/v1beta2
|
||||||
|
kind: HelmChart
|
||||||
|
metadata:
|
||||||
|
name: podinfo
|
||||||
|
spec:
|
||||||
|
chart: podinfo
|
||||||
|
sourceRef:
|
||||||
|
kind: HelmRepository
|
||||||
|
name: podinfo
|
||||||
|
version: '6.1.*'
|
||||||
|
interval: 1m
|
|
@ -29,6 +29,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
helmgetter "helm.sh/helm/v3/pkg/getter"
|
helmgetter "helm.sh/helm/v3/pkg/getter"
|
||||||
|
"helm.sh/helm/v3/pkg/registry"
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
apierrs "k8s.io/apimachinery/pkg/api/errors"
|
apierrs "k8s.io/apimachinery/pkg/api/errors"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
@ -116,9 +117,10 @@ type HelmChartReconciler struct {
|
||||||
kuberecorder.EventRecorder
|
kuberecorder.EventRecorder
|
||||||
helper.Metrics
|
helper.Metrics
|
||||||
|
|
||||||
Storage *Storage
|
RegistryClientGenerator RegistryClientGeneratorFunc
|
||||||
Getters helmgetter.Providers
|
Storage *Storage
|
||||||
ControllerName string
|
Getters helmgetter.Providers
|
||||||
|
ControllerName string
|
||||||
|
|
||||||
Cache *cache.Cache
|
Cache *cache.Cache
|
||||||
TTL time.Duration
|
TTL time.Duration
|
||||||
|
@ -378,15 +380,19 @@ func (r *HelmChartReconciler) reconcileSource(ctx context.Context, obj *sourcev1
|
||||||
|
|
||||||
// Assert source has an artifact
|
// Assert source has an artifact
|
||||||
if s.GetArtifact() == nil || !r.Storage.ArtifactExist(*s.GetArtifact()) {
|
if s.GetArtifact() == nil || !r.Storage.ArtifactExist(*s.GetArtifact()) {
|
||||||
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, "NoSourceArtifact",
|
if helmRepo, ok := s.(*sourcev1.HelmRepository); !ok || !registry.IsOCI(helmRepo.Spec.URL) {
|
||||||
"no artifact available for %s source '%s'", obj.Spec.SourceRef.Kind, obj.Spec.SourceRef.Name)
|
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, "NoSourceArtifact",
|
||||||
r.eventLogf(ctx, obj, events.EventTypeTrace, "NoSourceArtifact",
|
"no artifact available for %s source '%s'", obj.Spec.SourceRef.Kind, obj.Spec.SourceRef.Name)
|
||||||
"no artifact available for %s source '%s'", obj.Spec.SourceRef.Kind, obj.Spec.SourceRef.Name)
|
r.eventLogf(ctx, obj, events.EventTypeTrace, "NoSourceArtifact",
|
||||||
return sreconcile.ResultRequeue, nil
|
"no artifact available for %s source '%s'", obj.Spec.SourceRef.Kind, obj.Spec.SourceRef.Name)
|
||||||
|
return sreconcile.ResultRequeue, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record current artifact revision as last observed
|
if s.GetArtifact() != nil {
|
||||||
obj.Status.ObservedSourceArtifactRevision = s.GetArtifact().Revision
|
// Record current artifact revision as last observed
|
||||||
|
obj.Status.ObservedSourceArtifactRevision = s.GetArtifact().Revision
|
||||||
|
}
|
||||||
|
|
||||||
// Defer observation of build result
|
// Defer observation of build result
|
||||||
defer func() {
|
defer func() {
|
||||||
|
@ -439,7 +445,10 @@ func (r *HelmChartReconciler) reconcileSource(ctx context.Context, obj *sourcev1
|
||||||
// object, and returns early.
|
// object, and returns early.
|
||||||
func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj *sourcev1.HelmChart,
|
func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj *sourcev1.HelmChart,
|
||||||
repo *sourcev1.HelmRepository, b *chart.Build) (sreconcile.Result, error) {
|
repo *sourcev1.HelmRepository, b *chart.Build) (sreconcile.Result, error) {
|
||||||
var tlsConfig *tls.Config
|
var (
|
||||||
|
tlsConfig *tls.Config
|
||||||
|
logOpts []registry.LoginOption
|
||||||
|
)
|
||||||
|
|
||||||
// Construct the Getter options from the HelmRepository data
|
// Construct the Getter options from the HelmRepository data
|
||||||
clientOpts := []helmgetter.Option{
|
clientOpts := []helmgetter.Option{
|
||||||
|
@ -481,32 +490,93 @@ func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj *
|
||||||
// Requeue as content of secret might change
|
// Requeue as content of secret might change
|
||||||
return sreconcile.ResultEmpty, e
|
return sreconcile.ResultEmpty, e
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build registryClient options from secret
|
||||||
|
logOpt, err := loginOptionFromSecret(*secret)
|
||||||
|
if err != nil {
|
||||||
|
e := &serror.Event{
|
||||||
|
Err: fmt.Errorf("failed to configure Helm client with secret data: %w", err),
|
||||||
|
Reason: sourcev1.AuthenticationFailedReason,
|
||||||
|
}
|
||||||
|
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Err.Error())
|
||||||
|
// Requeue as content of secret might change
|
||||||
|
return sreconcile.ResultEmpty, e
|
||||||
|
}
|
||||||
|
|
||||||
|
logOpts = append([]registry.LoginOption{}, logOpt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize the chart repository
|
// Initialize the chart repository
|
||||||
chartRepo, err := repository.NewChartRepository(repo.Spec.URL, r.Storage.LocalPath(*repo.GetArtifact()), r.Getters, tlsConfig, clientOpts,
|
var chartRepo chart.Remote
|
||||||
repository.WithMemoryCache(r.Storage.LocalPath(*repo.GetArtifact()), r.Cache, r.TTL, func(event string) {
|
switch repo.Spec.Type {
|
||||||
r.IncCacheEvents(event, obj.Name, obj.Namespace)
|
case sourcev1.HelmRepositoryTypeOCI:
|
||||||
}))
|
if !registry.IsOCI(repo.Spec.URL) {
|
||||||
if err != nil {
|
err := fmt.Errorf("invalid OCI registry URL: %s", repo.Spec.URL)
|
||||||
// Any error requires a change in generation,
|
return chartRepoErrorReturn(err, obj)
|
||||||
// which we should be informed about by the watcher
|
|
||||||
switch err.(type) {
|
|
||||||
case *url.Error:
|
|
||||||
e := &serror.Stalling{
|
|
||||||
Err: fmt.Errorf("invalid Helm repository URL: %w", err),
|
|
||||||
Reason: sourcev1.URLInvalidReason,
|
|
||||||
}
|
|
||||||
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Err.Error())
|
|
||||||
return sreconcile.ResultEmpty, e
|
|
||||||
default:
|
|
||||||
e := &serror.Stalling{
|
|
||||||
Err: fmt.Errorf("failed to construct Helm client: %w", err),
|
|
||||||
Reason: meta.FailedReason,
|
|
||||||
}
|
|
||||||
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Err.Error())
|
|
||||||
return sreconcile.ResultEmpty, e
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// with this function call, we create a temporary file to store the credentials if needed.
|
||||||
|
// this is needed because otherwise the credentials are stored in ~/.docker/config.json.
|
||||||
|
// TODO@souleb: remove this once the registry move to Oras v2
|
||||||
|
// or rework to enable reusing credentials to avoid the unneccessary handshake operations
|
||||||
|
registryClient, file, err := r.RegistryClientGenerator(logOpts != nil)
|
||||||
|
if err != nil {
|
||||||
|
return chartRepoErrorReturn(err, obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
if file != "" {
|
||||||
|
defer func() {
|
||||||
|
os.Remove(file)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tell the chart repository to use the OCI client with the configured getter
|
||||||
|
clientOpts = append(clientOpts, helmgetter.WithRegistryClient(registryClient))
|
||||||
|
ociChartRepo, err := repository.NewOCIChartRepository(repo.Spec.URL, repository.WithOCIGetter(r.Getters), repository.WithOCIGetterOptions(clientOpts), repository.WithOCIRegistryClient(registryClient))
|
||||||
|
if err != nil {
|
||||||
|
return chartRepoErrorReturn(err, obj)
|
||||||
|
}
|
||||||
|
chartRepo = ociChartRepo
|
||||||
|
|
||||||
|
// If login options are configured, use them to login to the registry
|
||||||
|
// The OCIGetter will later retrieve the stored credentials to pull the chart
|
||||||
|
if logOpts != nil {
|
||||||
|
err = ociChartRepo.Login(logOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return chartRepoErrorReturn(err, obj)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
var httpChartRepo *repository.ChartRepository
|
||||||
|
httpChartRepo, err := repository.NewChartRepository(repo.Spec.URL, r.Storage.LocalPath(*repo.GetArtifact()), r.Getters, tlsConfig, clientOpts,
|
||||||
|
repository.WithMemoryCache(r.Storage.LocalPath(*repo.GetArtifact()), r.Cache, r.TTL, func(event string) {
|
||||||
|
r.IncCacheEvents(event, obj.Name, obj.Namespace)
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
return chartRepoErrorReturn(err, obj)
|
||||||
|
}
|
||||||
|
chartRepo = httpChartRepo
|
||||||
|
defer func() {
|
||||||
|
if httpChartRepo == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Cache the index if it was successfully retrieved
|
||||||
|
// and the chart was successfully built
|
||||||
|
if r.Cache != nil && httpChartRepo.Index != nil {
|
||||||
|
// The cache key have to be safe in multi-tenancy environments,
|
||||||
|
// as otherwise it could be used as a vector to bypass the helm repository's authentication.
|
||||||
|
// Using r.Storage.LocalPath(*repo.GetArtifact() is safe as the path is in the format /<helm-repository-name>/<chart-name>/<filename>.
|
||||||
|
err := httpChartRepo.CacheIndexInMemory()
|
||||||
|
if err != nil {
|
||||||
|
r.eventLogf(ctx, obj, events.EventTypeTrace, sourcev1.CacheOperationFailedReason, "failed to cache index: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the index reference
|
||||||
|
if httpChartRepo.Index != nil {
|
||||||
|
httpChartRepo.Unload()
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construct the chart builder with scoped configuration
|
// Construct the chart builder with scoped configuration
|
||||||
|
@ -532,25 +602,6 @@ func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj *
|
||||||
return sreconcile.ResultEmpty, err
|
return sreconcile.ResultEmpty, err
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
|
||||||
// Cache the index if it was successfully retrieved
|
|
||||||
// and the chart was successfully built
|
|
||||||
if r.Cache != nil && chartRepo.Index != nil {
|
|
||||||
// The cache key have to be safe in multi-tenancy environments,
|
|
||||||
// as otherwise it could be used as a vector to bypass the helm repository's authentication.
|
|
||||||
// Using r.Storage.LocalPath(*repo.GetArtifact() is safe as the path is in the format /<helm-repository-name>/<chart-name>/<filename>.
|
|
||||||
err := chartRepo.CacheIndexInMemory()
|
|
||||||
if err != nil {
|
|
||||||
r.eventLogf(ctx, obj, events.EventTypeTrace, sourcev1.CacheOperationFailedReason, "failed to cache index: %s", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the index reference
|
|
||||||
if chartRepo.Index != nil {
|
|
||||||
chartRepo.Unload()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
*b = *build
|
*b = *build
|
||||||
return sreconcile.ResultSuccess, nil
|
return sreconcile.ResultSuccess, nil
|
||||||
}
|
}
|
||||||
|
@ -1090,3 +1141,22 @@ func reasonForBuild(build *chart.Build) string {
|
||||||
}
|
}
|
||||||
return sourcev1.ChartPullSucceededReason
|
return sourcev1.ChartPullSucceededReason
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func chartRepoErrorReturn(err error, obj *sourcev1.HelmChart) (sreconcile.Result, error) {
|
||||||
|
switch err.(type) {
|
||||||
|
case *url.Error:
|
||||||
|
e := &serror.Stalling{
|
||||||
|
Err: fmt.Errorf("invalid Helm repository URL: %w", err),
|
||||||
|
Reason: sourcev1.URLInvalidReason,
|
||||||
|
}
|
||||||
|
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Err.Error())
|
||||||
|
return sreconcile.ResultEmpty, e
|
||||||
|
default:
|
||||||
|
e := &serror.Stalling{
|
||||||
|
Err: fmt.Errorf("failed to construct Helm client: %w", err),
|
||||||
|
Reason: meta.FailedReason,
|
||||||
|
}
|
||||||
|
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Err.Error())
|
||||||
|
return sreconcile.ResultEmpty, e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -17,10 +17,12 @@ limitations under the License.
|
||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
@ -31,6 +33,9 @@ import (
|
||||||
|
|
||||||
"github.com/darkowlzz/controller-check/status"
|
"github.com/darkowlzz/controller-check/status"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
|
hchart "helm.sh/helm/v3/pkg/chart"
|
||||||
|
"helm.sh/helm/v3/pkg/chart/loader"
|
||||||
|
"helm.sh/helm/v3/pkg/registry"
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
@ -45,10 +50,10 @@ import (
|
||||||
"github.com/fluxcd/pkg/runtime/conditions"
|
"github.com/fluxcd/pkg/runtime/conditions"
|
||||||
"github.com/fluxcd/pkg/runtime/patch"
|
"github.com/fluxcd/pkg/runtime/patch"
|
||||||
"github.com/fluxcd/pkg/testserver"
|
"github.com/fluxcd/pkg/testserver"
|
||||||
|
|
||||||
sourcev1 "github.com/fluxcd/source-controller/api/v1beta2"
|
sourcev1 "github.com/fluxcd/source-controller/api/v1beta2"
|
||||||
serror "github.com/fluxcd/source-controller/internal/error"
|
serror "github.com/fluxcd/source-controller/internal/error"
|
||||||
"github.com/fluxcd/source-controller/internal/helm/chart"
|
"github.com/fluxcd/source-controller/internal/helm/chart"
|
||||||
|
"github.com/fluxcd/source-controller/internal/helm/util"
|
||||||
sreconcile "github.com/fluxcd/source-controller/internal/reconcile"
|
sreconcile "github.com/fluxcd/source-controller/internal/reconcile"
|
||||||
"github.com/fluxcd/source-controller/internal/reconcile/summarize"
|
"github.com/fluxcd/source-controller/internal/reconcile/summarize"
|
||||||
)
|
)
|
||||||
|
@ -776,6 +781,214 @@ func TestHelmChartReconciler_buildFromHelmRepository(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHelmChartReconciler_buildFromOCIHelmRepository(t *testing.T) {
|
||||||
|
g := NewWithT(t)
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
const (
|
||||||
|
chartPath = "testdata/charts/helmchart-0.1.0.tgz"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Login to the registry
|
||||||
|
err := testRegistryserver.RegistryClient.Login(testRegistryserver.DockerRegistryHost,
|
||||||
|
registry.LoginOptBasicAuth(testUsername, testPassword),
|
||||||
|
registry.LoginOptInsecure(true))
|
||||||
|
g.Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
// Load a test chart
|
||||||
|
chartData, err := ioutil.ReadFile(chartPath)
|
||||||
|
g.Expect(err).NotTo(HaveOccurred())
|
||||||
|
metadata, err := extractChartMeta(chartData)
|
||||||
|
g.Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
// Upload the test chart
|
||||||
|
ref := fmt.Sprintf("%s/testrepo/%s:%s", testRegistryserver.DockerRegistryHost, metadata.Name, metadata.Version)
|
||||||
|
_, err = testRegistryserver.RegistryClient.Push(chartData, ref)
|
||||||
|
g.Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
storage, err := NewStorage(tmpDir, "example.com", retentionTTL, retentionRecords)
|
||||||
|
g.Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
cachedArtifact := &sourcev1.Artifact{
|
||||||
|
Revision: "0.1.0",
|
||||||
|
Path: metadata.Name + "-" + metadata.Version + ".tgz",
|
||||||
|
}
|
||||||
|
g.Expect(storage.CopyFromPath(cachedArtifact, "testdata/charts/helmchart-0.1.0.tgz")).To(Succeed())
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
secret *corev1.Secret
|
||||||
|
beforeFunc func(obj *sourcev1.HelmChart, repository *sourcev1.HelmRepository)
|
||||||
|
want sreconcile.Result
|
||||||
|
wantErr error
|
||||||
|
assertFunc func(g *WithT, obj *sourcev1.HelmChart, build chart.Build)
|
||||||
|
cleanFunc func(g *WithT, build *chart.Build)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Reconciles chart build with repository credentials",
|
||||||
|
secret: &corev1.Secret{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "auth",
|
||||||
|
},
|
||||||
|
Data: map[string][]byte{
|
||||||
|
"username": []byte(testUsername),
|
||||||
|
"password": []byte(testPassword),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
beforeFunc: func(obj *sourcev1.HelmChart, repository *sourcev1.HelmRepository) {
|
||||||
|
obj.Spec.Chart = metadata.Name
|
||||||
|
obj.Spec.Version = metadata.Version
|
||||||
|
repository.Spec.SecretRef = &meta.LocalObjectReference{Name: "auth"}
|
||||||
|
},
|
||||||
|
want: sreconcile.ResultSuccess,
|
||||||
|
assertFunc: func(g *WithT, _ *sourcev1.HelmChart, build chart.Build) {
|
||||||
|
g.Expect(build.Name).To(Equal(metadata.Name))
|
||||||
|
g.Expect(build.Version).To(Equal(metadata.Version))
|
||||||
|
g.Expect(build.Path).ToNot(BeEmpty())
|
||||||
|
g.Expect(build.Path).To(BeARegularFile())
|
||||||
|
},
|
||||||
|
cleanFunc: func(g *WithT, build *chart.Build) {
|
||||||
|
g.Expect(os.Remove(build.Path)).To(Succeed())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Uses artifact as build cache",
|
||||||
|
beforeFunc: func(obj *sourcev1.HelmChart, repository *sourcev1.HelmRepository) {
|
||||||
|
obj.Spec.Chart = metadata.Name
|
||||||
|
obj.Spec.Version = metadata.Version
|
||||||
|
obj.Status.Artifact = &sourcev1.Artifact{Path: metadata.Name + "-" + metadata.Version + ".tgz"}
|
||||||
|
},
|
||||||
|
want: sreconcile.ResultSuccess,
|
||||||
|
assertFunc: func(g *WithT, obj *sourcev1.HelmChart, build chart.Build) {
|
||||||
|
g.Expect(build.Name).To(Equal(metadata.Name))
|
||||||
|
g.Expect(build.Version).To(Equal(metadata.Version))
|
||||||
|
g.Expect(build.Path).To(Equal(storage.LocalPath(*cachedArtifact.DeepCopy())))
|
||||||
|
g.Expect(build.Path).To(BeARegularFile())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Forces build on generation change",
|
||||||
|
beforeFunc: func(obj *sourcev1.HelmChart, repository *sourcev1.HelmRepository) {
|
||||||
|
obj.Generation = 3
|
||||||
|
obj.Spec.Chart = metadata.Name
|
||||||
|
obj.Spec.Version = metadata.Version
|
||||||
|
|
||||||
|
obj.Status.ObservedGeneration = 2
|
||||||
|
obj.Status.Artifact = &sourcev1.Artifact{Path: metadata.Name + "-" + metadata.Version + ".tgz"}
|
||||||
|
},
|
||||||
|
want: sreconcile.ResultSuccess,
|
||||||
|
assertFunc: func(g *WithT, obj *sourcev1.HelmChart, build chart.Build) {
|
||||||
|
g.Expect(build.Name).To(Equal(metadata.Name))
|
||||||
|
g.Expect(build.Version).To(Equal(metadata.Version))
|
||||||
|
fmt.Println("buildpath", build.Path)
|
||||||
|
fmt.Println("storage Path", storage.LocalPath(*cachedArtifact.DeepCopy()))
|
||||||
|
g.Expect(build.Path).ToNot(Equal(storage.LocalPath(*cachedArtifact.DeepCopy())))
|
||||||
|
g.Expect(build.Path).To(BeARegularFile())
|
||||||
|
},
|
||||||
|
cleanFunc: func(g *WithT, build *chart.Build) {
|
||||||
|
g.Expect(os.Remove(build.Path)).To(Succeed())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Event on unsuccessful secret retrieval",
|
||||||
|
beforeFunc: func(_ *sourcev1.HelmChart, repository *sourcev1.HelmRepository) {
|
||||||
|
repository.Spec.SecretRef = &meta.LocalObjectReference{
|
||||||
|
Name: "invalid",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
want: sreconcile.ResultEmpty,
|
||||||
|
wantErr: &serror.Event{Err: errors.New("failed to get secret 'invalid'")},
|
||||||
|
assertFunc: func(g *WithT, obj *sourcev1.HelmChart, build chart.Build) {
|
||||||
|
g.Expect(build.Complete()).To(BeFalse())
|
||||||
|
|
||||||
|
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{
|
||||||
|
*conditions.TrueCondition(sourcev1.FetchFailedCondition, sourcev1.AuthenticationFailedReason, "failed to get secret 'invalid'"),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Stalling on invalid client options",
|
||||||
|
beforeFunc: func(obj *sourcev1.HelmChart, repository *sourcev1.HelmRepository) {
|
||||||
|
repository.Spec.URL = "https://unsupported" // Unsupported protocol
|
||||||
|
},
|
||||||
|
want: sreconcile.ResultEmpty,
|
||||||
|
wantErr: &serror.Stalling{Err: errors.New("failed to construct Helm client: invalid OCI registry URL: https://unsupported")},
|
||||||
|
assertFunc: func(g *WithT, obj *sourcev1.HelmChart, build chart.Build) {
|
||||||
|
g.Expect(build.Complete()).To(BeFalse())
|
||||||
|
|
||||||
|
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{
|
||||||
|
*conditions.TrueCondition(sourcev1.FetchFailedCondition, meta.FailedReason, "failed to construct Helm client"),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "BuildError on temporary build error",
|
||||||
|
beforeFunc: func(obj *sourcev1.HelmChart, _ *sourcev1.HelmRepository) {
|
||||||
|
obj.Spec.Chart = "invalid"
|
||||||
|
},
|
||||||
|
want: sreconcile.ResultEmpty,
|
||||||
|
wantErr: &chart.BuildError{Err: errors.New("failed to get chart version for remote reference")},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
g := NewWithT(t)
|
||||||
|
|
||||||
|
clientBuilder := fake.NewClientBuilder()
|
||||||
|
if tt.secret != nil {
|
||||||
|
clientBuilder.WithObjects(tt.secret.DeepCopy())
|
||||||
|
}
|
||||||
|
|
||||||
|
r := &HelmChartReconciler{
|
||||||
|
Client: clientBuilder.Build(),
|
||||||
|
EventRecorder: record.NewFakeRecorder(32),
|
||||||
|
Getters: testGetters,
|
||||||
|
Storage: storage,
|
||||||
|
RegistryClientGenerator: util.RegistryClientGenerator,
|
||||||
|
}
|
||||||
|
|
||||||
|
repository := &sourcev1.HelmRepository{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
GenerateName: "helmrepository-",
|
||||||
|
},
|
||||||
|
Spec: sourcev1.HelmRepositorySpec{
|
||||||
|
URL: fmt.Sprintf("oci://%s/testrepo", testRegistryserver.DockerRegistryHost),
|
||||||
|
Timeout: &metav1.Duration{Duration: timeout},
|
||||||
|
Type: sourcev1.HelmRepositoryTypeOCI,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
obj := &sourcev1.HelmChart{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
GenerateName: "helmrepository-",
|
||||||
|
},
|
||||||
|
Spec: sourcev1.HelmChartSpec{},
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.beforeFunc != nil {
|
||||||
|
tt.beforeFunc(obj, repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
var b chart.Build
|
||||||
|
if tt.cleanFunc != nil {
|
||||||
|
defer tt.cleanFunc(g, &b)
|
||||||
|
}
|
||||||
|
got, err := r.buildFromHelmRepository(context.TODO(), obj, repository, &b)
|
||||||
|
|
||||||
|
g.Expect(err != nil).To(Equal(tt.wantErr != nil))
|
||||||
|
if tt.wantErr != nil {
|
||||||
|
g.Expect(reflect.TypeOf(err).String()).To(Equal(reflect.TypeOf(tt.wantErr).String()))
|
||||||
|
g.Expect(err.Error()).To(ContainSubstring(tt.wantErr.Error()))
|
||||||
|
}
|
||||||
|
g.Expect(got).To(Equal(tt.want))
|
||||||
|
|
||||||
|
if tt.assertFunc != nil {
|
||||||
|
tt.assertFunc(g, obj, b)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHelmChartReconciler_buildFromTarballArtifact(t *testing.T) {
|
func TestHelmChartReconciler_buildFromTarballArtifact(t *testing.T) {
|
||||||
g := NewWithT(t)
|
g := NewWithT(t)
|
||||||
|
|
||||||
|
@ -1690,3 +1903,12 @@ func TestHelmChartReconciler_notify(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractChartMeta is used to extract a chart metadata from a byte array
|
||||||
|
func extractChartMeta(chartData []byte) (*hchart.Metadata, error) {
|
||||||
|
ch, err := loader.LoadArchive(bytes.NewReader(chartData))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ch.Metadata, nil
|
||||||
|
}
|
||||||
|
|
|
@ -48,6 +48,7 @@ import (
|
||||||
serror "github.com/fluxcd/source-controller/internal/error"
|
serror "github.com/fluxcd/source-controller/internal/error"
|
||||||
"github.com/fluxcd/source-controller/internal/helm/getter"
|
"github.com/fluxcd/source-controller/internal/helm/getter"
|
||||||
"github.com/fluxcd/source-controller/internal/helm/repository"
|
"github.com/fluxcd/source-controller/internal/helm/repository"
|
||||||
|
intpredicates "github.com/fluxcd/source-controller/internal/predicates"
|
||||||
sreconcile "github.com/fluxcd/source-controller/internal/reconcile"
|
sreconcile "github.com/fluxcd/source-controller/internal/reconcile"
|
||||||
"github.com/fluxcd/source-controller/internal/reconcile/summarize"
|
"github.com/fluxcd/source-controller/internal/reconcile/summarize"
|
||||||
)
|
)
|
||||||
|
@ -123,7 +124,15 @@ func (r *HelmRepositoryReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||||||
func (r *HelmRepositoryReconciler) SetupWithManagerAndOptions(mgr ctrl.Manager, opts HelmRepositoryReconcilerOptions) error {
|
func (r *HelmRepositoryReconciler) SetupWithManagerAndOptions(mgr ctrl.Manager, opts HelmRepositoryReconcilerOptions) error {
|
||||||
return ctrl.NewControllerManagedBy(mgr).
|
return ctrl.NewControllerManagedBy(mgr).
|
||||||
For(&sourcev1.HelmRepository{}).
|
For(&sourcev1.HelmRepository{}).
|
||||||
WithEventFilter(predicate.Or(predicate.GenerationChangedPredicate{}, predicates.ReconcileRequestedPredicate{})).
|
WithEventFilter(
|
||||||
|
predicate.And(
|
||||||
|
predicate.Or(
|
||||||
|
intpredicates.HelmRepositoryTypePredicate{RepositoryType: sourcev1.HelmRepositoryTypeDefault},
|
||||||
|
intpredicates.HelmRepositoryTypePredicate{RepositoryType: ""},
|
||||||
|
),
|
||||||
|
predicate.Or(predicate.GenerationChangedPredicate{}, predicates.ReconcileRequestedPredicate{}),
|
||||||
|
),
|
||||||
|
).
|
||||||
WithOptions(controller.Options{
|
WithOptions(controller.Options{
|
||||||
MaxConcurrentReconciles: opts.MaxConcurrentReconciles,
|
MaxConcurrentReconciles: opts.MaxConcurrentReconciles,
|
||||||
RateLimiter: opts.RateLimiter,
|
RateLimiter: opts.RateLimiter,
|
||||||
|
@ -191,7 +200,8 @@ func (r *HelmRepositoryReconciler) Reconcile(ctx context.Context, req ctrl.Reque
|
||||||
}
|
}
|
||||||
|
|
||||||
// Examine if the object is under deletion
|
// Examine if the object is under deletion
|
||||||
if !obj.ObjectMeta.DeletionTimestamp.IsZero() {
|
// or if a type change has happened
|
||||||
|
if !obj.ObjectMeta.DeletionTimestamp.IsZero() || (obj.Spec.Type != "" && obj.Spec.Type != sourcev1.HelmRepositoryTypeDefault) {
|
||||||
recResult, retErr = r.reconcileDelete(ctx, obj)
|
recResult, retErr = r.reconcileDelete(ctx, obj)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -538,8 +548,10 @@ func (r *HelmRepositoryReconciler) reconcileDelete(ctx context.Context, obj *sou
|
||||||
return sreconcile.ResultEmpty, err
|
return sreconcile.ResultEmpty, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove our finalizer from the list
|
// Remove our finalizer from the list if we are deleting the object
|
||||||
controllerutil.RemoveFinalizer(obj, sourcev1.SourceFinalizer)
|
if !obj.DeletionTimestamp.IsZero() {
|
||||||
|
controllerutil.RemoveFinalizer(obj, sourcev1.SourceFinalizer)
|
||||||
|
}
|
||||||
|
|
||||||
// Stop reconciliation as the object is being deleted
|
// Stop reconciliation as the object is being deleted
|
||||||
return sreconcile.ResultEmpty, nil
|
return sreconcile.ResultEmpty, nil
|
||||||
|
@ -547,11 +559,12 @@ func (r *HelmRepositoryReconciler) reconcileDelete(ctx context.Context, obj *sou
|
||||||
|
|
||||||
// garbageCollect performs a garbage collection for the given object.
|
// garbageCollect performs a garbage collection for the given object.
|
||||||
//
|
//
|
||||||
// It removes all but the current Artifact from the Storage, unless the
|
// It removes all but the current Artifact from the Storage, unless:
|
||||||
// deletion timestamp on the object is set. Which will result in the
|
// - the deletion timestamp on the object is set
|
||||||
// removal of all Artifacts for the objects.
|
// - the obj.Spec.Type has changed and artifacts are not supported by the new type
|
||||||
|
// Which will result in the removal of all Artifacts for the objects.
|
||||||
func (r *HelmRepositoryReconciler) garbageCollect(ctx context.Context, obj *sourcev1.HelmRepository) error {
|
func (r *HelmRepositoryReconciler) garbageCollect(ctx context.Context, obj *sourcev1.HelmRepository) error {
|
||||||
if !obj.DeletionTimestamp.IsZero() {
|
if !obj.DeletionTimestamp.IsZero() || (obj.Spec.Type != "" && obj.Spec.Type != sourcev1.HelmRepositoryTypeDefault) {
|
||||||
if deleted, err := r.Storage.RemoveAll(r.Storage.NewArtifactFor(obj.Kind, obj.GetObjectMeta(), "", "*")); err != nil {
|
if deleted, err := r.Storage.RemoveAll(r.Storage.NewArtifactFor(obj.Kind, obj.GetObjectMeta(), "", "*")); err != nil {
|
||||||
return &serror.Event{
|
return &serror.Event{
|
||||||
Err: fmt.Errorf("garbage collection for deleted resource failed: %w", err),
|
Err: fmt.Errorf("garbage collection for deleted resource failed: %w", err),
|
||||||
|
@ -561,7 +574,11 @@ func (r *HelmRepositoryReconciler) garbageCollect(ctx context.Context, obj *sour
|
||||||
r.eventLogf(ctx, obj, events.EventTypeTrace, "GarbageCollectionSucceeded",
|
r.eventLogf(ctx, obj, events.EventTypeTrace, "GarbageCollectionSucceeded",
|
||||||
"garbage collected artifacts for deleted resource")
|
"garbage collected artifacts for deleted resource")
|
||||||
}
|
}
|
||||||
|
// Clean status sub-resource
|
||||||
obj.Status.Artifact = nil
|
obj.Status.Artifact = nil
|
||||||
|
obj.Status.URL = ""
|
||||||
|
// Remove the condition as the artifact doesn't exist.
|
||||||
|
conditions.Delete(obj, sourcev1.ArtifactInStorageCondition)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if obj.GetArtifact() != nil {
|
if obj.GetArtifact() != nil {
|
||||||
|
|
|
@ -0,0 +1,362 @@
|
||||||
|
/*
|
||||||
|
Copyright 2022 The Flux authors
|
||||||
|
|
||||||
|
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 controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fluxcd/pkg/apis/meta"
|
||||||
|
"github.com/fluxcd/pkg/runtime/conditions"
|
||||||
|
helper "github.com/fluxcd/pkg/runtime/controller"
|
||||||
|
"github.com/fluxcd/pkg/runtime/patch"
|
||||||
|
"github.com/fluxcd/pkg/runtime/predicates"
|
||||||
|
sourcev1 "github.com/fluxcd/source-controller/api/v1beta2"
|
||||||
|
serror "github.com/fluxcd/source-controller/internal/error"
|
||||||
|
"github.com/fluxcd/source-controller/internal/helm/repository"
|
||||||
|
intpredicates "github.com/fluxcd/source-controller/internal/predicates"
|
||||||
|
sreconcile "github.com/fluxcd/source-controller/internal/reconcile"
|
||||||
|
"github.com/fluxcd/source-controller/internal/reconcile/summarize"
|
||||||
|
helmgetter "helm.sh/helm/v3/pkg/getter"
|
||||||
|
"helm.sh/helm/v3/pkg/registry"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/types"
|
||||||
|
kuberecorder "k8s.io/client-go/tools/record"
|
||||||
|
ctrl "sigs.k8s.io/controller-runtime"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/controller"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/predicate"
|
||||||
|
)
|
||||||
|
|
||||||
|
var helmRepositoryOCIReadyCondition = summarize.Conditions{
|
||||||
|
Target: meta.ReadyCondition,
|
||||||
|
Owned: []string{
|
||||||
|
sourcev1.FetchFailedCondition,
|
||||||
|
meta.ReadyCondition,
|
||||||
|
meta.ReconcilingCondition,
|
||||||
|
meta.StalledCondition,
|
||||||
|
},
|
||||||
|
Summarize: []string{
|
||||||
|
sourcev1.FetchFailedCondition,
|
||||||
|
meta.StalledCondition,
|
||||||
|
meta.ReconcilingCondition,
|
||||||
|
},
|
||||||
|
NegativePolarity: []string{
|
||||||
|
sourcev1.FetchFailedCondition,
|
||||||
|
meta.StalledCondition,
|
||||||
|
meta.ReconcilingCondition,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// helmRepositoryOCIFailConditions contains the conditions that represent a
|
||||||
|
// failure.
|
||||||
|
var helmRepositoryOCIFailConditions = []string{
|
||||||
|
sourcev1.FetchFailedCondition,
|
||||||
|
}
|
||||||
|
|
||||||
|
// +kubebuilder:rbac:groups=source.toolkit.fluxcd.io,resources=helmrepositories,verbs=get;list;watch;create;update;patch;delete
|
||||||
|
// +kubebuilder:rbac:groups=source.toolkit.fluxcd.io,resources=helmrepositories/status,verbs=get;update;patch
|
||||||
|
// +kubebuilder:rbac:groups=source.toolkit.fluxcd.io,resources=helmrepositories/finalizers,verbs=get;create;update;patch;delete
|
||||||
|
// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch
|
||||||
|
|
||||||
|
// HelmRepositoryOCI Reconciler reconciles a v1beta2.HelmRepository object of type OCI.
|
||||||
|
type HelmRepositoryOCIReconciler struct {
|
||||||
|
client.Client
|
||||||
|
kuberecorder.EventRecorder
|
||||||
|
helper.Metrics
|
||||||
|
Getters helmgetter.Providers
|
||||||
|
ControllerName string
|
||||||
|
RegistryClientGenerator RegistryClientGeneratorFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistryClientGeneratorFunc is a function that returns a registry client
|
||||||
|
// and an optional file name.
|
||||||
|
// The file is used to store the registry client credentials.
|
||||||
|
// The caller is responsible for deleting the file.
|
||||||
|
type RegistryClientGeneratorFunc func(isLogin bool) (*registry.Client, string, error)
|
||||||
|
|
||||||
|
// helmRepositoryOCIReconcileFunc is the function type for all the
|
||||||
|
// v1beta2.HelmRepository (sub)reconcile functions for OCI type. The type implementations
|
||||||
|
// are grouped and executed serially to perform the complete reconcile of the
|
||||||
|
// object.
|
||||||
|
type helmRepositoryOCIReconcileFunc func(ctx context.Context, obj *sourcev1.HelmRepository) (sreconcile.Result, error)
|
||||||
|
|
||||||
|
func (r *HelmRepositoryOCIReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||||||
|
return r.SetupWithManagerAndOptions(mgr, HelmRepositoryReconcilerOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HelmRepositoryOCIReconciler) SetupWithManagerAndOptions(mgr ctrl.Manager, opts HelmRepositoryReconcilerOptions) error {
|
||||||
|
return ctrl.NewControllerManagedBy(mgr).
|
||||||
|
For(&sourcev1.HelmRepository{}).
|
||||||
|
WithEventFilter(
|
||||||
|
predicate.And(
|
||||||
|
intpredicates.HelmRepositoryTypePredicate{RepositoryType: sourcev1.HelmRepositoryTypeOCI},
|
||||||
|
predicate.Or(predicate.GenerationChangedPredicate{}, predicates.ReconcileRequestedPredicate{}),
|
||||||
|
),
|
||||||
|
).
|
||||||
|
WithOptions(controller.Options{
|
||||||
|
MaxConcurrentReconciles: opts.MaxConcurrentReconciles,
|
||||||
|
RateLimiter: opts.RateLimiter,
|
||||||
|
}).
|
||||||
|
Complete(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HelmRepositoryOCIReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, retErr error) {
|
||||||
|
start := time.Now()
|
||||||
|
log := ctrl.LoggerFrom(ctx)
|
||||||
|
|
||||||
|
// Fetch the HelmRepository
|
||||||
|
obj := &sourcev1.HelmRepository{}
|
||||||
|
if err := r.Get(ctx, req.NamespacedName, obj); err != nil {
|
||||||
|
return ctrl.Result{}, client.IgnoreNotFound(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record suspended status metric
|
||||||
|
r.RecordSuspend(ctx, obj, obj.Spec.Suspend)
|
||||||
|
|
||||||
|
// Return early if the object is suspended
|
||||||
|
if obj.Spec.Suspend {
|
||||||
|
log.Info("reconciliation is suspended for this object")
|
||||||
|
return ctrl.Result{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the patch helper with the current version of the object.
|
||||||
|
patchHelper, err := patch.NewHelper(obj, r.Client)
|
||||||
|
if err != nil {
|
||||||
|
return ctrl.Result{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// recResult stores the abstracted reconcile result.
|
||||||
|
var recResult sreconcile.Result
|
||||||
|
|
||||||
|
// Always attempt to patch the object after each reconciliation.
|
||||||
|
// NOTE: The final runtime result and error are set in this block.
|
||||||
|
defer func() {
|
||||||
|
summarizeHelper := summarize.NewHelper(r.EventRecorder, patchHelper)
|
||||||
|
summarizeOpts := []summarize.Option{
|
||||||
|
summarize.WithConditions(helmRepositoryOCIReadyCondition),
|
||||||
|
summarize.WithReconcileResult(recResult),
|
||||||
|
summarize.WithReconcileError(retErr),
|
||||||
|
summarize.WithIgnoreNotFound(),
|
||||||
|
summarize.WithProcessors(
|
||||||
|
summarize.RecordContextualError,
|
||||||
|
summarize.RecordReconcileReq,
|
||||||
|
),
|
||||||
|
summarize.WithResultBuilder(sreconcile.AlwaysRequeueResultBuilder{RequeueAfter: obj.GetRequeueAfter()}),
|
||||||
|
summarize.WithPatchFieldOwner(r.ControllerName),
|
||||||
|
}
|
||||||
|
result, retErr = summarizeHelper.SummarizeAndPatch(ctx, obj, summarizeOpts...)
|
||||||
|
|
||||||
|
// Always record readiness and duration metrics
|
||||||
|
r.Metrics.RecordReadiness(ctx, obj)
|
||||||
|
r.Metrics.RecordDuration(ctx, obj, start)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Add finalizer first if not exist to avoid the race condition
|
||||||
|
// between init and delete
|
||||||
|
if !controllerutil.ContainsFinalizer(obj, sourcev1.SourceFinalizer) {
|
||||||
|
controllerutil.AddFinalizer(obj, sourcev1.SourceFinalizer)
|
||||||
|
recResult = sreconcile.ResultRequeue
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Examine if the object is under deletion
|
||||||
|
if !obj.ObjectMeta.DeletionTimestamp.IsZero() {
|
||||||
|
recResult, retErr = r.reconcileDelete(ctx, obj)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Examine if a type change has happened and act accordingly
|
||||||
|
if obj.Spec.Type != sourcev1.HelmRepositoryTypeOCI {
|
||||||
|
// just ignore the object if the type has changed
|
||||||
|
recResult, retErr = sreconcile.ResultEmpty, nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile actual object
|
||||||
|
reconcilers := []helmRepositoryOCIReconcileFunc{
|
||||||
|
r.reconcileSource,
|
||||||
|
}
|
||||||
|
recResult, retErr = r.reconcile(ctx, obj, reconcilers)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileDelete handles the deletion of the object.
|
||||||
|
// Removing the finalizer from the object if successful.
|
||||||
|
func (r *HelmRepositoryOCIReconciler) reconcileDelete(ctx context.Context, obj *sourcev1.HelmRepository) (sreconcile.Result, error) {
|
||||||
|
// Remove our finalizer from the list
|
||||||
|
controllerutil.RemoveFinalizer(obj, sourcev1.SourceFinalizer)
|
||||||
|
|
||||||
|
// Stop reconciliation as the object is being deleted
|
||||||
|
return sreconcile.ResultEmpty, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// notify emits notification related to the reconciliation.
|
||||||
|
func (r *HelmRepositoryOCIReconciler) notify(oldObj, newObj *sourcev1.HelmRepository, res sreconcile.Result, resErr error) {
|
||||||
|
// Notify successful recovery from any failure.
|
||||||
|
if resErr == nil && res == sreconcile.ResultSuccess {
|
||||||
|
if sreconcile.FailureRecovery(oldObj, newObj, helmRepositoryOCIFailConditions) {
|
||||||
|
r.Eventf(newObj, corev1.EventTypeNormal,
|
||||||
|
meta.SucceededReason, "Helm repository %q has been successfully reconciled", newObj.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HelmRepositoryOCIReconciler) reconcile(ctx context.Context, obj *sourcev1.HelmRepository, reconcilers []helmRepositoryOCIReconcileFunc) (sreconcile.Result, error) {
|
||||||
|
oldObj := obj.DeepCopy()
|
||||||
|
|
||||||
|
// Mark as reconciling if generation differs.
|
||||||
|
if obj.Generation != obj.Status.ObservedGeneration {
|
||||||
|
conditions.MarkReconciling(obj, "NewGeneration", "reconciling new object generation (%d)", obj.Generation)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the sub-reconcilers and build the result of reconciliation.
|
||||||
|
var res sreconcile.Result
|
||||||
|
var resErr error
|
||||||
|
for _, rec := range reconcilers {
|
||||||
|
recResult, err := rec(ctx, obj)
|
||||||
|
// Exit immediately on ResultRequeue.
|
||||||
|
if recResult == sreconcile.ResultRequeue {
|
||||||
|
return sreconcile.ResultRequeue, nil
|
||||||
|
}
|
||||||
|
// If an error is received, prioritize the returned results because an
|
||||||
|
// error also means immediate requeue.
|
||||||
|
if err != nil {
|
||||||
|
resErr = err
|
||||||
|
res = recResult
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Prioritize requeue request in the result for successful results.
|
||||||
|
res = sreconcile.LowestRequeuingResult(res, recResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.notify(oldObj, obj, res, resErr)
|
||||||
|
|
||||||
|
return res, resErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HelmRepositoryOCIReconciler) reconcileSource(ctx context.Context, obj *sourcev1.HelmRepository) (sreconcile.Result, error) {
|
||||||
|
var logOpts []registry.LoginOption
|
||||||
|
// Configure any authentication related options
|
||||||
|
if obj.Spec.SecretRef != nil {
|
||||||
|
// Attempt to retrieve secret
|
||||||
|
name := types.NamespacedName{
|
||||||
|
Namespace: obj.GetNamespace(),
|
||||||
|
Name: obj.Spec.SecretRef.Name,
|
||||||
|
}
|
||||||
|
var secret corev1.Secret
|
||||||
|
if err := r.Client.Get(ctx, name, &secret); err != nil {
|
||||||
|
e := &serror.Event{
|
||||||
|
Err: fmt.Errorf("failed to get secret '%s': %w", name.String(), err),
|
||||||
|
Reason: sourcev1.AuthenticationFailedReason,
|
||||||
|
}
|
||||||
|
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Err.Error())
|
||||||
|
return sreconcile.ResultEmpty, e
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct actual options
|
||||||
|
logOpt, err := loginOptionFromSecret(secret)
|
||||||
|
if err != nil {
|
||||||
|
e := &serror.Event{
|
||||||
|
Err: fmt.Errorf("failed to configure Helm client with secret data: %w", err),
|
||||||
|
Reason: sourcev1.AuthenticationFailedReason,
|
||||||
|
}
|
||||||
|
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Err.Error())
|
||||||
|
// Return err as the content of the secret may change.
|
||||||
|
return sreconcile.ResultEmpty, e
|
||||||
|
}
|
||||||
|
|
||||||
|
logOpts = append(logOpts, logOpt)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result, err := r.validateSource(ctx, obj, logOpts...); err != nil || result == sreconcile.ResultEmpty {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return sreconcile.ResultSuccess, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateSource the HelmRepository object by checking the url and connecting to the underlying registry
|
||||||
|
// with he provided credentials.
|
||||||
|
func (r *HelmRepositoryOCIReconciler) validateSource(ctx context.Context, obj *sourcev1.HelmRepository, logOpts ...registry.LoginOption) (sreconcile.Result, error) {
|
||||||
|
registryClient, file, err := r.RegistryClientGenerator(logOpts != nil)
|
||||||
|
if err != nil {
|
||||||
|
e := &serror.Stalling{
|
||||||
|
Err: fmt.Errorf("failed to create registry client:: %w", err),
|
||||||
|
Reason: meta.FailedReason,
|
||||||
|
}
|
||||||
|
conditions.MarkFalse(obj, meta.ReadyCondition, e.Reason, e.Err.Error())
|
||||||
|
return sreconcile.ResultEmpty, e
|
||||||
|
}
|
||||||
|
|
||||||
|
if file != "" {
|
||||||
|
defer func() {
|
||||||
|
os.Remove(file)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
chartRepo, err := repository.NewOCIChartRepository(obj.Spec.URL, repository.WithOCIRegistryClient(registryClient))
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "parse") {
|
||||||
|
e := &serror.Stalling{
|
||||||
|
Err: fmt.Errorf("failed to parse URL '%s': %w", obj.Spec.URL, err),
|
||||||
|
Reason: sourcev1.URLInvalidReason,
|
||||||
|
}
|
||||||
|
conditions.MarkFalse(obj, meta.ReadyCondition, e.Reason, e.Err.Error())
|
||||||
|
return sreconcile.ResultEmpty, e
|
||||||
|
} else if strings.Contains(err.Error(), "the url scheme is not supported") {
|
||||||
|
e := &serror.Event{
|
||||||
|
Err: err,
|
||||||
|
Reason: sourcev1.URLInvalidReason,
|
||||||
|
}
|
||||||
|
conditions.MarkFalse(obj, meta.ReadyCondition, e.Reason, e.Err.Error())
|
||||||
|
return sreconcile.ResultEmpty, e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to login to the registry if credentials are provided.
|
||||||
|
if logOpts != nil {
|
||||||
|
err = chartRepo.Login(logOpts...)
|
||||||
|
if err != nil {
|
||||||
|
e := &serror.Event{
|
||||||
|
Err: fmt.Errorf("failed to create temporary file: %w", err),
|
||||||
|
Reason: meta.FailedReason,
|
||||||
|
}
|
||||||
|
conditions.MarkFalse(obj, meta.ReadyCondition, e.Reason, e.Err.Error())
|
||||||
|
return sreconcile.ResultEmpty, e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conditions.MarkTrue(obj, meta.ReadyCondition, meta.SucceededReason, "Helm repository %q is ready", obj.Name)
|
||||||
|
|
||||||
|
return sreconcile.ResultSuccess, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginOptionFromSecret(secret corev1.Secret) (registry.LoginOption, error) {
|
||||||
|
username, password := string(secret.Data["username"]), string(secret.Data["password"])
|
||||||
|
switch {
|
||||||
|
case username == "" && password == "":
|
||||||
|
return nil, nil
|
||||||
|
case username == "" || password == "":
|
||||||
|
return nil, fmt.Errorf("invalid '%s' secret data: required fields 'username' and 'password'", secret.Name)
|
||||||
|
}
|
||||||
|
return registry.LoginOptBasicAuth(username, password), nil
|
||||||
|
}
|
|
@ -0,0 +1,132 @@
|
||||||
|
/*
|
||||||
|
Copyright 2022 The Flux authors
|
||||||
|
|
||||||
|
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 controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/darkowlzz/controller-check/status"
|
||||||
|
"github.com/fluxcd/pkg/apis/meta"
|
||||||
|
"github.com/fluxcd/pkg/runtime/conditions"
|
||||||
|
"github.com/fluxcd/pkg/runtime/patch"
|
||||||
|
sourcev1 "github.com/fluxcd/source-controller/api/v1beta2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
kstatus "sigs.k8s.io/cli-utils/pkg/kstatus/status"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHelmRepositoryOCIReconciler_Reconcile(t *testing.T) {
|
||||||
|
g := NewWithT(t)
|
||||||
|
|
||||||
|
ns, err := testEnv.CreateNamespace(ctx, "helmrepository-oci-reconcile-test")
|
||||||
|
g.Expect(err).ToNot(HaveOccurred())
|
||||||
|
defer func() { g.Expect(testEnv.Delete(ctx, ns)).To(Succeed()) }()
|
||||||
|
|
||||||
|
secret := &corev1.Secret{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
GenerateName: "helmrepository-",
|
||||||
|
Namespace: ns.Name,
|
||||||
|
},
|
||||||
|
Data: map[string][]byte{
|
||||||
|
"username": []byte(testUsername),
|
||||||
|
"password": []byte(testPassword),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Expect(testEnv.CreateAndWait(ctx, secret)).To(Succeed())
|
||||||
|
|
||||||
|
obj := &sourcev1.HelmRepository{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
GenerateName: "helmrepository-oci-reconcile-",
|
||||||
|
Namespace: ns.Name,
|
||||||
|
},
|
||||||
|
Spec: sourcev1.HelmRepositorySpec{
|
||||||
|
Interval: metav1.Duration{Duration: interval},
|
||||||
|
URL: fmt.Sprintf("oci://%s", testRegistryserver.DockerRegistryHost),
|
||||||
|
SecretRef: &meta.LocalObjectReference{
|
||||||
|
Name: secret.Name,
|
||||||
|
},
|
||||||
|
Type: sourcev1.HelmRepositoryTypeOCI,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
g.Expect(testEnv.Create(ctx, obj)).To(Succeed())
|
||||||
|
|
||||||
|
key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace}
|
||||||
|
|
||||||
|
// Wait for finalizer to be set
|
||||||
|
g.Eventually(func() bool {
|
||||||
|
if err := testEnv.Get(ctx, key, obj); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return len(obj.Finalizers) > 0
|
||||||
|
}, timeout).Should(BeTrue())
|
||||||
|
|
||||||
|
// Wait for HelmRepository to be Ready
|
||||||
|
g.Eventually(func() bool {
|
||||||
|
if err := testEnv.Get(ctx, key, obj); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !conditions.IsReady(obj) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
readyCondition := conditions.Get(obj, meta.ReadyCondition)
|
||||||
|
return obj.Generation == readyCondition.ObservedGeneration &&
|
||||||
|
obj.Generation == obj.Status.ObservedGeneration
|
||||||
|
}, timeout).Should(BeTrue())
|
||||||
|
|
||||||
|
// Check if the object status is valid.
|
||||||
|
condns := &status.Conditions{NegativePolarity: helmRepositoryReadyCondition.NegativePolarity}
|
||||||
|
checker := status.NewChecker(testEnv.Client, condns)
|
||||||
|
checker.CheckErr(ctx, obj)
|
||||||
|
|
||||||
|
// kstatus client conformance check.
|
||||||
|
u, err := patch.ToUnstructured(obj)
|
||||||
|
g.Expect(err).ToNot(HaveOccurred())
|
||||||
|
res, err := kstatus.Compute(u)
|
||||||
|
g.Expect(err).ToNot(HaveOccurred())
|
||||||
|
g.Expect(res.Status).To(Equal(kstatus.CurrentStatus))
|
||||||
|
|
||||||
|
// Patch the object with reconcile request annotation.
|
||||||
|
patchHelper, err := patch.NewHelper(obj, testEnv.Client)
|
||||||
|
g.Expect(err).ToNot(HaveOccurred())
|
||||||
|
annotations := map[string]string{
|
||||||
|
meta.ReconcileRequestAnnotation: "now",
|
||||||
|
}
|
||||||
|
obj.SetAnnotations(annotations)
|
||||||
|
g.Expect(patchHelper.Patch(ctx, obj)).ToNot(HaveOccurred())
|
||||||
|
g.Eventually(func() bool {
|
||||||
|
if err := testEnv.Get(ctx, key, obj); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return obj.Status.LastHandledReconcileAt == "now"
|
||||||
|
}, timeout).Should(BeTrue())
|
||||||
|
|
||||||
|
g.Expect(testEnv.Delete(ctx, obj)).To(Succeed())
|
||||||
|
|
||||||
|
// Wait for HelmRepository to be deleted
|
||||||
|
g.Eventually(func() bool {
|
||||||
|
if err := testEnv.Get(ctx, key, obj); err != nil {
|
||||||
|
return apierrors.IsNotFound(err)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}, timeout).Should(BeTrue())
|
||||||
|
|
||||||
|
}
|
|
@ -1085,3 +1085,210 @@ func TestHelmRepositoryReconciler_notify(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHelmRepositoryReconciler_ReconcileTypeUpdatePredicateFilter(t *testing.T) {
|
||||||
|
g := NewWithT(t)
|
||||||
|
|
||||||
|
testServer, err := helmtestserver.NewTempHelmServer()
|
||||||
|
g.Expect(err).NotTo(HaveOccurred())
|
||||||
|
defer os.RemoveAll(testServer.Root())
|
||||||
|
|
||||||
|
g.Expect(testServer.PackageChart("testdata/charts/helmchart")).To(Succeed())
|
||||||
|
g.Expect(testServer.GenerateIndex()).To(Succeed())
|
||||||
|
|
||||||
|
testServer.Start()
|
||||||
|
defer testServer.Stop()
|
||||||
|
|
||||||
|
obj := &sourcev1.HelmRepository{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
GenerateName: "helmrepository-reconcile-",
|
||||||
|
Namespace: "default",
|
||||||
|
},
|
||||||
|
Spec: sourcev1.HelmRepositorySpec{
|
||||||
|
Interval: metav1.Duration{Duration: interval},
|
||||||
|
URL: testServer.URL(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
g.Expect(testEnv.Create(ctx, obj)).To(Succeed())
|
||||||
|
|
||||||
|
key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace}
|
||||||
|
|
||||||
|
// Wait for finalizer to be set
|
||||||
|
g.Eventually(func() bool {
|
||||||
|
if err := testEnv.Get(ctx, key, obj); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return len(obj.Finalizers) > 0
|
||||||
|
}, timeout).Should(BeTrue())
|
||||||
|
|
||||||
|
// Wait for HelmRepository to be Ready
|
||||||
|
g.Eventually(func() bool {
|
||||||
|
if err := testEnv.Get(ctx, key, obj); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !conditions.IsReady(obj) && obj.Status.Artifact == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
readyCondition := conditions.Get(obj, meta.ReadyCondition)
|
||||||
|
return readyCondition.Status == metav1.ConditionTrue &&
|
||||||
|
obj.Generation == readyCondition.ObservedGeneration &&
|
||||||
|
obj.Generation == obj.Status.ObservedGeneration
|
||||||
|
}, timeout).Should(BeTrue())
|
||||||
|
|
||||||
|
// Check if the object status is valid.
|
||||||
|
condns := &status.Conditions{NegativePolarity: helmRepositoryReadyCondition.NegativePolarity}
|
||||||
|
checker := status.NewChecker(testEnv.Client, condns)
|
||||||
|
checker.CheckErr(ctx, obj)
|
||||||
|
|
||||||
|
// kstatus client conformance check.
|
||||||
|
u, err := patch.ToUnstructured(obj)
|
||||||
|
g.Expect(err).ToNot(HaveOccurred())
|
||||||
|
res, err := kstatus.Compute(u)
|
||||||
|
g.Expect(err).ToNot(HaveOccurred())
|
||||||
|
g.Expect(res.Status).To(Equal(kstatus.CurrentStatus))
|
||||||
|
|
||||||
|
// Switch to a OCI helm repository type
|
||||||
|
secret := &corev1.Secret{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
GenerateName: "helmrepository-reconcile-",
|
||||||
|
Namespace: "default",
|
||||||
|
},
|
||||||
|
Data: map[string][]byte{
|
||||||
|
"username": []byte(testUsername),
|
||||||
|
"password": []byte(testPassword),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
g.Expect(testEnv.CreateAndWait(ctx, secret)).To(Succeed())
|
||||||
|
|
||||||
|
obj.Spec.Type = sourcev1.HelmRepositoryTypeOCI
|
||||||
|
obj.Spec.URL = fmt.Sprintf("oci://%s", testRegistryserver.DockerRegistryHost)
|
||||||
|
obj.Spec.SecretRef = &meta.LocalObjectReference{
|
||||||
|
Name: secret.Name,
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Expect(testEnv.Update(ctx, obj)).To(Succeed())
|
||||||
|
|
||||||
|
// Wait for HelmRepository to be Ready
|
||||||
|
g.Eventually(func() bool {
|
||||||
|
if err := testEnv.Get(ctx, key, obj); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !conditions.IsReady(obj) && obj.Status.Artifact != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
readyCondition := conditions.Get(obj, meta.ReadyCondition)
|
||||||
|
return readyCondition.Status == metav1.ConditionTrue &&
|
||||||
|
obj.Generation == readyCondition.ObservedGeneration &&
|
||||||
|
obj.Generation == obj.Status.ObservedGeneration
|
||||||
|
}, timeout).Should(BeTrue())
|
||||||
|
|
||||||
|
// Check if the object status is valid.
|
||||||
|
condns = &status.Conditions{NegativePolarity: helmRepositoryOCIReadyCondition.NegativePolarity}
|
||||||
|
checker = status.NewChecker(testEnv.Client, condns)
|
||||||
|
checker.CheckErr(ctx, obj)
|
||||||
|
|
||||||
|
g.Expect(testEnv.Delete(ctx, obj)).To(Succeed())
|
||||||
|
|
||||||
|
// Wait for HelmRepository to be deleted
|
||||||
|
g.Eventually(func() bool {
|
||||||
|
if err := testEnv.Get(ctx, key, obj); err != nil {
|
||||||
|
return apierrors.IsNotFound(err)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}, timeout).Should(BeTrue())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHelmRepositoryReconciler_ReconcileSpecUpdatePredicateFilter(t *testing.T) {
|
||||||
|
g := NewWithT(t)
|
||||||
|
|
||||||
|
testServer, err := helmtestserver.NewTempHelmServer()
|
||||||
|
g.Expect(err).NotTo(HaveOccurred())
|
||||||
|
defer os.RemoveAll(testServer.Root())
|
||||||
|
|
||||||
|
g.Expect(testServer.PackageChart("testdata/charts/helmchart")).To(Succeed())
|
||||||
|
g.Expect(testServer.GenerateIndex()).To(Succeed())
|
||||||
|
|
||||||
|
testServer.Start()
|
||||||
|
defer testServer.Stop()
|
||||||
|
|
||||||
|
obj := &sourcev1.HelmRepository{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
GenerateName: "helmrepository-reconcile-",
|
||||||
|
Namespace: "default",
|
||||||
|
},
|
||||||
|
Spec: sourcev1.HelmRepositorySpec{
|
||||||
|
Interval: metav1.Duration{Duration: interval},
|
||||||
|
URL: testServer.URL(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
g.Expect(testEnv.Create(ctx, obj)).To(Succeed())
|
||||||
|
|
||||||
|
key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace}
|
||||||
|
|
||||||
|
// Wait for finalizer to be set
|
||||||
|
g.Eventually(func() bool {
|
||||||
|
if err := testEnv.Get(ctx, key, obj); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return len(obj.Finalizers) > 0
|
||||||
|
}, timeout).Should(BeTrue())
|
||||||
|
|
||||||
|
// Wait for HelmRepository to be Ready
|
||||||
|
g.Eventually(func() bool {
|
||||||
|
if err := testEnv.Get(ctx, key, obj); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !conditions.IsReady(obj) && obj.Status.Artifact == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
readyCondition := conditions.Get(obj, meta.ReadyCondition)
|
||||||
|
return readyCondition.Status == metav1.ConditionTrue &&
|
||||||
|
obj.Generation == readyCondition.ObservedGeneration &&
|
||||||
|
obj.Generation == obj.Status.ObservedGeneration
|
||||||
|
}, timeout).Should(BeTrue())
|
||||||
|
|
||||||
|
// Check if the object status is valid.
|
||||||
|
condns := &status.Conditions{NegativePolarity: helmRepositoryReadyCondition.NegativePolarity}
|
||||||
|
checker := status.NewChecker(testEnv.Client, condns)
|
||||||
|
checker.CheckErr(ctx, obj)
|
||||||
|
|
||||||
|
// kstatus client conformance check.
|
||||||
|
u, err := patch.ToUnstructured(obj)
|
||||||
|
g.Expect(err).ToNot(HaveOccurred())
|
||||||
|
res, err := kstatus.Compute(u)
|
||||||
|
g.Expect(err).ToNot(HaveOccurred())
|
||||||
|
g.Expect(res.Status).To(Equal(kstatus.CurrentStatus))
|
||||||
|
|
||||||
|
// Change spec Interval to validate spec update
|
||||||
|
obj.Spec.Interval = metav1.Duration{Duration: interval + time.Second}
|
||||||
|
g.Expect(testEnv.Update(ctx, obj)).To(Succeed())
|
||||||
|
|
||||||
|
// Wait for HelmRepository to be Ready
|
||||||
|
g.Eventually(func() bool {
|
||||||
|
if err := testEnv.Get(ctx, key, obj); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !conditions.IsReady(obj) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
readyCondition := conditions.Get(obj, meta.ReadyCondition)
|
||||||
|
return readyCondition.Status == metav1.ConditionTrue &&
|
||||||
|
obj.Generation == readyCondition.ObservedGeneration &&
|
||||||
|
obj.Generation == obj.Status.ObservedGeneration
|
||||||
|
}, timeout).Should(BeTrue())
|
||||||
|
|
||||||
|
// Check if the object status is valid.
|
||||||
|
condns = &status.Conditions{NegativePolarity: helmRepositoryReadyCondition.NegativePolarity}
|
||||||
|
checker = status.NewChecker(testEnv.Client, condns)
|
||||||
|
checker.CheckErr(ctx, obj)
|
||||||
|
|
||||||
|
g.Expect(testEnv.Delete(ctx, obj)).To(Succeed())
|
||||||
|
|
||||||
|
// Wait for HelmRepository to be deleted
|
||||||
|
g.Eventually(func() bool {
|
||||||
|
if err := testEnv.Get(ctx, key, obj); err != nil {
|
||||||
|
return apierrors.IsNotFound(err)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}, timeout).Should(BeTrue())
|
||||||
|
}
|
||||||
|
|
|
@ -17,14 +17,20 @@ limitations under the License.
|
||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
"helm.sh/helm/v3/pkg/getter"
|
"helm.sh/helm/v3/pkg/getter"
|
||||||
|
"helm.sh/helm/v3/pkg/registry"
|
||||||
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
|
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
|
||||||
"k8s.io/client-go/kubernetes/scheme"
|
"k8s.io/client-go/kubernetes/scheme"
|
||||||
"k8s.io/client-go/tools/record"
|
"k8s.io/client-go/tools/record"
|
||||||
|
@ -33,9 +39,16 @@ import (
|
||||||
"github.com/fluxcd/pkg/runtime/controller"
|
"github.com/fluxcd/pkg/runtime/controller"
|
||||||
"github.com/fluxcd/pkg/runtime/testenv"
|
"github.com/fluxcd/pkg/runtime/testenv"
|
||||||
"github.com/fluxcd/pkg/testserver"
|
"github.com/fluxcd/pkg/testserver"
|
||||||
|
"github.com/phayes/freeport"
|
||||||
|
|
||||||
|
"github.com/distribution/distribution/v3/configuration"
|
||||||
|
dockerRegistry "github.com/distribution/distribution/v3/registry"
|
||||||
|
_ "github.com/distribution/distribution/v3/registry/auth/htpasswd"
|
||||||
|
_ "github.com/distribution/distribution/v3/registry/storage/driver/inmemory"
|
||||||
|
|
||||||
sourcev1 "github.com/fluxcd/source-controller/api/v1beta2"
|
sourcev1 "github.com/fluxcd/source-controller/api/v1beta2"
|
||||||
"github.com/fluxcd/source-controller/internal/cache"
|
"github.com/fluxcd/source-controller/internal/cache"
|
||||||
|
"github.com/fluxcd/source-controller/internal/helm/util"
|
||||||
// +kubebuilder:scaffold:imports
|
// +kubebuilder:scaffold:imports
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -66,6 +79,10 @@ var (
|
||||||
Schemes: []string{"http", "https"},
|
Schemes: []string{"http", "https"},
|
||||||
New: getter.NewHTTPGetter,
|
New: getter.NewHTTPGetter,
|
||||||
},
|
},
|
||||||
|
getter.Provider{
|
||||||
|
Schemes: []string{"oci"},
|
||||||
|
New: getter.NewOCIGetter,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -75,10 +92,90 @@ var (
|
||||||
tlsCA []byte
|
tlsCA []byte
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
testRegistryClient *registry.Client
|
||||||
|
testRegistryserver *RegistryClientTestServer
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
testWorkspaceDir = "registry-test"
|
||||||
|
testHtpasswdFileBasename = "authtest.htpasswd"
|
||||||
|
testUsername = "myuser"
|
||||||
|
testPassword = "mypass"
|
||||||
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rand.Seed(time.Now().UnixNano())
|
rand.Seed(time.Now().UnixNano())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RegistryClientTestServer struct {
|
||||||
|
Out io.Writer
|
||||||
|
DockerRegistryHost string
|
||||||
|
WorkspaceDir string
|
||||||
|
RegistryClient *registry.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetupServer(server *RegistryClientTestServer) string {
|
||||||
|
// Create a temporary workspace directory for the registry
|
||||||
|
server.WorkspaceDir = testWorkspaceDir
|
||||||
|
os.RemoveAll(server.WorkspaceDir)
|
||||||
|
err := os.Mkdir(server.WorkspaceDir, 0700)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("failed to create workspace directory: %s", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
server.Out = &out
|
||||||
|
|
||||||
|
// init test client
|
||||||
|
server.RegistryClient, err = registry.NewClient(
|
||||||
|
registry.ClientOptDebug(true),
|
||||||
|
registry.ClientOptWriter(server.Out),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("failed to create registry client: %s", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// create htpasswd file (w BCrypt, which is required)
|
||||||
|
pwBytes, err := bcrypt.GenerateFromPassword([]byte(testPassword), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("failed to generate password: %s", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
htpasswdPath := filepath.Join(testWorkspaceDir, testHtpasswdFileBasename)
|
||||||
|
err = ioutil.WriteFile(htpasswdPath, []byte(fmt.Sprintf("%s:%s\n", testUsername, string(pwBytes))), 0644)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("failed to create htpasswd file: %s", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registry config
|
||||||
|
config := &configuration.Configuration{}
|
||||||
|
port, err := freeport.GetFreePort()
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("failed to get free port: %s", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
server.DockerRegistryHost = fmt.Sprintf("localhost:%d", port)
|
||||||
|
config.HTTP.Addr = fmt.Sprintf("127.0.0.1:%d", port)
|
||||||
|
config.HTTP.DrainTimeout = time.Duration(10) * time.Second
|
||||||
|
config.Storage = map[string]configuration.Parameters{"inmemory": map[string]interface{}{}}
|
||||||
|
config.Auth = configuration.Auth{
|
||||||
|
"htpasswd": configuration.Parameters{
|
||||||
|
"realm": "localhost",
|
||||||
|
"path": htpasswdPath,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dockerRegistry, err := dockerRegistry.NewRegistry(context.Background(), config)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("failed to create docker registry: %s", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start Docker registry
|
||||||
|
go dockerRegistry.ListenAndServe()
|
||||||
|
|
||||||
|
return server.WorkspaceDir
|
||||||
|
}
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
initTestTLS()
|
initTestTLS()
|
||||||
|
|
||||||
|
@ -101,6 +198,14 @@ func TestMain(m *testing.M) {
|
||||||
|
|
||||||
testMetricsH = controller.MustMakeMetrics(testEnv)
|
testMetricsH = controller.MustMakeMetrics(testEnv)
|
||||||
|
|
||||||
|
testRegistryserver = &RegistryClientTestServer{}
|
||||||
|
registryWorkspaceDir := SetupServer(testRegistryserver)
|
||||||
|
|
||||||
|
testRegistryClient, err = registry.NewClient(registry.ClientOptWriter(os.Stdout))
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("Failed to create OCI registry client"))
|
||||||
|
}
|
||||||
|
|
||||||
if err := (&GitRepositoryReconciler{
|
if err := (&GitRepositoryReconciler{
|
||||||
Client: testEnv,
|
Client: testEnv,
|
||||||
EventRecorder: record.NewFakeRecorder(32),
|
EventRecorder: record.NewFakeRecorder(32),
|
||||||
|
@ -129,6 +234,16 @@ func TestMain(m *testing.M) {
|
||||||
panic(fmt.Sprintf("Failed to start HelmRepositoryReconciler: %v", err))
|
panic(fmt.Sprintf("Failed to start HelmRepositoryReconciler: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err = (&HelmRepositoryOCIReconciler{
|
||||||
|
Client: testEnv,
|
||||||
|
EventRecorder: record.NewFakeRecorder(32),
|
||||||
|
Metrics: testMetricsH,
|
||||||
|
Getters: testGetters,
|
||||||
|
RegistryClientGenerator: util.RegistryClientGenerator,
|
||||||
|
}).SetupWithManager(testEnv); err != nil {
|
||||||
|
panic(fmt.Sprintf("Failed to start HelmRepositoryOCIReconciler: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
c := cache.New(5, 1*time.Second)
|
c := cache.New(5, 1*time.Second)
|
||||||
cacheRecorder := cache.MustMakeMetrics()
|
cacheRecorder := cache.MustMakeMetrics()
|
||||||
if err := (&HelmChartReconciler{
|
if err := (&HelmChartReconciler{
|
||||||
|
@ -165,6 +280,10 @@ func TestMain(m *testing.M) {
|
||||||
panic(fmt.Sprintf("Failed to remove storage server dir: %v", err))
|
panic(fmt.Sprintf("Failed to remove storage server dir: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := os.RemoveAll(registryWorkspaceDir); err != nil {
|
||||||
|
panic(fmt.Sprintf("Failed to remove registry workspace dir: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
os.Exit(code)
|
os.Exit(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -848,6 +848,19 @@ references to this object.
|
||||||
NOTE: Not implemented, provisional as of <a href="https://github.com/fluxcd/flux2/pull/2092">https://github.com/fluxcd/flux2/pull/2092</a></p>
|
NOTE: Not implemented, provisional as of <a href="https://github.com/fluxcd/flux2/pull/2092">https://github.com/fluxcd/flux2/pull/2092</a></p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<code>type</code><br>
|
||||||
|
<em>
|
||||||
|
string
|
||||||
|
</em>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<em>(Optional)</em>
|
||||||
|
<p>Type of the HelmRepository.
|
||||||
|
When this field is set to “oci”, the URL field value must be prefixed with “oci://”.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -2093,6 +2106,19 @@ references to this object.
|
||||||
NOTE: Not implemented, provisional as of <a href="https://github.com/fluxcd/flux2/pull/2092">https://github.com/fluxcd/flux2/pull/2092</a></p>
|
NOTE: Not implemented, provisional as of <a href="https://github.com/fluxcd/flux2/pull/2092">https://github.com/fluxcd/flux2/pull/2092</a></p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<code>type</code><br>
|
||||||
|
<em>
|
||||||
|
string
|
||||||
|
</em>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<em>(Optional)</em>
|
||||||
|
<p>Type of the HelmRepository.
|
||||||
|
When this field is set to “oci”, the URL field value must be prefixed with “oci://”.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,9 +1,15 @@
|
||||||
# Helm Repositories
|
# Helm Repositories
|
||||||
|
|
||||||
The `HelmRepository` API defines a Source to produce an Artifact for a Helm
|
There are 2 [Helm repository types](#type) defined by the `HelmRepository` API:
|
||||||
repository index YAML (`index.yaml`).
|
- Helm HTTP/S repository, which defines a Source to produce an Artifact for a Helm
|
||||||
|
repository index YAML (`index.yaml`).
|
||||||
|
- OCI Helm repository, which defines a source that does not produce an Artifact.
|
||||||
|
Instead a validation of the Helm repository is performed and the outcome is reported in the
|
||||||
|
`.status.conditions` field.
|
||||||
|
|
||||||
## Example
|
## Examples
|
||||||
|
|
||||||
|
### Helm HTTP/S repository
|
||||||
|
|
||||||
The following is an example of a HelmRepository. It creates a YAML (`.yaml`)
|
The following is an example of a HelmRepository. It creates a YAML (`.yaml`)
|
||||||
Artifact from the fetched Helm repository index (in this example the [podinfo
|
Artifact from the fetched Helm repository index (in this example the [podinfo
|
||||||
|
@ -83,6 +89,63 @@ You can run this example by saving the manifest into `helmrepository.yaml`.
|
||||||
Normal NewArtifact 1m source-controller fetched index of size 30.88kB from 'https://stefanprodan.github.io/podinfo'
|
Normal NewArtifact 1m source-controller fetched index of size 30.88kB from 'https://stefanprodan.github.io/podinfo'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Helm OCI repository
|
||||||
|
|
||||||
|
The following is an example of an OCI HelmRepository.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
apiVersion: source.toolkit.fluxcd.io/v1beta2
|
||||||
|
kind: HelmRepository
|
||||||
|
metadata:
|
||||||
|
name: podinfo
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
type: "oci"
|
||||||
|
interval: 5m0s
|
||||||
|
url: oci://ghcr.io/stefanprodan/charts
|
||||||
|
```
|
||||||
|
|
||||||
|
In the above example:
|
||||||
|
|
||||||
|
- A HelmRepository named `podinfo` is created, indicated by the
|
||||||
|
`.metadata.name` field.
|
||||||
|
- The source-controller performs the Helm repository url validation i.e. the url
|
||||||
|
is a valid OCI registry url, every five minutes with the information indicated by the
|
||||||
|
`.spec.interval` and `.spec.url` fields.
|
||||||
|
|
||||||
|
You can run this example by saving the manifest into `helmrepository.yaml`.
|
||||||
|
|
||||||
|
1. Apply the resource on the cluster:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kubectl apply -f helmrepository.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Run `kubectl get helmrepository` to see the HelmRepository:
|
||||||
|
|
||||||
|
```console
|
||||||
|
NAME URL AGE READY STATUS
|
||||||
|
podinfo oci://ghcr.io/stefanprodan/charts 3m22s True Helm repository "podinfo" is ready
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Run `kubectl describe helmrepository podinfo` to see the [Conditions](#conditions)
|
||||||
|
in the HelmRepository's Status:
|
||||||
|
|
||||||
|
```console
|
||||||
|
...
|
||||||
|
Status:
|
||||||
|
Conditions:
|
||||||
|
Last Transition Time: 2022-05-12T14:02:12Z
|
||||||
|
Message: Helm repository "podinfo" is ready
|
||||||
|
Observed Generation: 1
|
||||||
|
Reason: Succeeded
|
||||||
|
Status: True
|
||||||
|
Type: Ready
|
||||||
|
Observed Generation: 1
|
||||||
|
Events: <none>
|
||||||
|
```
|
||||||
|
|
||||||
## Writing a HelmRepository spec
|
## Writing a HelmRepository spec
|
||||||
|
|
||||||
As with all other Kubernetes config, a HelmRepository needs `apiVersion`,
|
As with all other Kubernetes config, a HelmRepository needs `apiVersion`,
|
||||||
|
@ -92,6 +155,13 @@ valid [DNS subdomain name](https://kubernetes.io/docs/concepts/overview/working-
|
||||||
A HelmRepository also needs a
|
A HelmRepository also needs a
|
||||||
[`.spec` section](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#spec-and-status).
|
[`.spec` section](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#spec-and-status).
|
||||||
|
|
||||||
|
|
||||||
|
### Type
|
||||||
|
|
||||||
|
`.spec.type` is an optional field that specifies the Helm repository type.
|
||||||
|
|
||||||
|
Possible values are `default` for a Helm HTTP/S repository, or `oci` for an OCI Helm repository.
|
||||||
|
|
||||||
### Interval
|
### Interval
|
||||||
|
|
||||||
`.spec.interval` is a required field that specifies the interval which the
|
`.spec.interval` is a required field that specifies the interval which the
|
||||||
|
@ -107,9 +177,12 @@ change to the spec), this is handled instantly outside the interval window.
|
||||||
|
|
||||||
### URL
|
### URL
|
||||||
|
|
||||||
`.spec.url` is a required field that specifies the HTTP/S address of the Helm
|
`.spec.url` is a required field that depending on the [type of the HelmRepository object](#type)
|
||||||
repository. For Helm repositories which require authentication, see
|
specifies the HTTP/S or OCI address of a Helm repository.
|
||||||
[Secret reference](#secret-reference).
|
|
||||||
|
For OCI, the URL is expected to point to a registry repository, e.g. `oci://ghcr.io/fluxcd/source-controller`.
|
||||||
|
|
||||||
|
For Helm repositories which require authentication, see [Secret reference](#secret-reference).
|
||||||
|
|
||||||
### Timeout
|
### Timeout
|
||||||
|
|
||||||
|
@ -156,8 +229,36 @@ stringData:
|
||||||
password: 123456
|
password: 123456
|
||||||
```
|
```
|
||||||
|
|
||||||
|
OCI Helm repository example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
apiVersion: source.toolkit.fluxcd.io/v1beta2
|
||||||
|
kind: HelmRepository
|
||||||
|
metadata:
|
||||||
|
name: podinfo
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
interval: 5m0s
|
||||||
|
url: oci://ghcr.io/stefanprodan/charts
|
||||||
|
type: "oci"
|
||||||
|
secretRef:
|
||||||
|
name: oci-creds
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: oci-creds
|
||||||
|
namespace: default
|
||||||
|
stringData:
|
||||||
|
username: example
|
||||||
|
password: 123456
|
||||||
|
```
|
||||||
|
|
||||||
#### TLS authentication
|
#### TLS authentication
|
||||||
|
|
||||||
|
**Note:** TLS authentication is not yet supported by OCI Helm repositories.
|
||||||
|
|
||||||
To provide TLS credentials to use while connecting with the Helm repository,
|
To provide TLS credentials to use while connecting with the Helm repository,
|
||||||
the referenced Secret is expected to contain `.data.certFile` and
|
the referenced Secret is expected to contain `.data.certFile` and
|
||||||
`.data.keyFile`, and/or `.data.caFile` values.
|
`.data.keyFile`, and/or `.data.caFile` values.
|
||||||
|
@ -197,7 +298,8 @@ match the host as defined in URL. This may for example be required if the host
|
||||||
advertised chart URLs in the index differ from the specified URL.
|
advertised chart URLs in the index differ from the specified URL.
|
||||||
|
|
||||||
Enabling this should be done with caution, as it can potentially result in
|
Enabling this should be done with caution, as it can potentially result in
|
||||||
credentials getting stolen in a man-in-the-middle attack.
|
credentials getting stolen in a man-in-the-middle attack. This feature only applies
|
||||||
|
to HTTP/S Helm repositories.
|
||||||
|
|
||||||
### Suspend
|
### Suspend
|
||||||
|
|
||||||
|
@ -379,6 +481,8 @@ specific HelmRepository, e.g. `flux logs --level=error --kind=HelmRepository --n
|
||||||
|
|
||||||
### Artifact
|
### Artifact
|
||||||
|
|
||||||
|
**Note:** This section does not apply to [OCI Helm Repositories](#oci-helm-repositories), they do not emit artifacts.
|
||||||
|
|
||||||
The HelmRepository reports the last fetched repository index as an Artifact
|
The HelmRepository reports the last fetched repository index as an Artifact
|
||||||
object in the `.status.artifact` of the resource.
|
object in the `.status.artifact` of the resource.
|
||||||
|
|
||||||
|
@ -418,6 +522,9 @@ and reports `Reconciling` and `Stalled` conditions where applicable to
|
||||||
provide better (timeout) support to solutions polling the HelmRepository to become
|
provide better (timeout) support to solutions polling the HelmRepository to become
|
||||||
`Ready`.
|
`Ready`.
|
||||||
|
|
||||||
|
OCI Helm repositories use only `Reconciling`, `Ready`, `FetchFailed`, and `Stalled`
|
||||||
|
condition types.
|
||||||
|
|
||||||
#### Reconciling HelmRepository
|
#### Reconciling HelmRepository
|
||||||
|
|
||||||
The source-controller marks a HelmRepository as _reconciling_ when one of the following
|
The source-controller marks a HelmRepository as _reconciling_ when one of the following
|
||||||
|
|
7
go.mod
7
go.mod
|
@ -17,6 +17,7 @@ require (
|
||||||
github.com/ProtonMail/go-crypto v0.0.0-20220407094043-a94812496cf5
|
github.com/ProtonMail/go-crypto v0.0.0-20220407094043-a94812496cf5
|
||||||
github.com/cyphar/filepath-securejoin v0.2.3
|
github.com/cyphar/filepath-securejoin v0.2.3
|
||||||
github.com/darkowlzz/controller-check v0.0.0-20220325122359-11f5827b7981
|
github.com/darkowlzz/controller-check v0.0.0-20220325122359-11f5827b7981
|
||||||
|
github.com/distribution/distribution/v3 v3.0.0-20211118083504-a29a3c99a684
|
||||||
github.com/docker/go-units v0.4.0
|
github.com/docker/go-units v0.4.0
|
||||||
github.com/elazarl/goproxy v0.0.0-20220417044921-416226498f94
|
github.com/elazarl/goproxy v0.0.0-20220417044921-416226498f94
|
||||||
github.com/fluxcd/gitkit v0.5.0
|
github.com/fluxcd/gitkit v0.5.0
|
||||||
|
@ -39,6 +40,7 @@ require (
|
||||||
github.com/minio/minio-go/v7 v7.0.24
|
github.com/minio/minio-go/v7 v7.0.24
|
||||||
github.com/onsi/gomega v1.19.0
|
github.com/onsi/gomega v1.19.0
|
||||||
github.com/otiai10/copy v1.7.0
|
github.com/otiai10/copy v1.7.0
|
||||||
|
github.com/phayes/freeport v0.0.0-20180830031419-95f893ade6f2
|
||||||
github.com/prometheus/client_golang v1.12.1
|
github.com/prometheus/client_golang v1.12.1
|
||||||
github.com/spf13/pflag v1.0.5
|
github.com/spf13/pflag v1.0.5
|
||||||
golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f
|
golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f
|
||||||
|
@ -88,6 +90,7 @@ require (
|
||||||
github.com/Microsoft/go-winio v0.5.2 // indirect
|
github.com/Microsoft/go-winio v0.5.2 // indirect
|
||||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||||
|
github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d // indirect
|
||||||
github.com/acomagu/bufpipe v1.0.3 // indirect
|
github.com/acomagu/bufpipe v1.0.3 // indirect
|
||||||
github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535 // indirect
|
github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
|
@ -103,6 +106,7 @@ require (
|
||||||
github.com/docker/docker v20.10.12+incompatible // indirect
|
github.com/docker/docker v20.10.12+incompatible // indirect
|
||||||
github.com/docker/docker-credential-helpers v0.6.4 // indirect
|
github.com/docker/docker-credential-helpers v0.6.4 // indirect
|
||||||
github.com/docker/go-connections v0.4.0 // indirect
|
github.com/docker/go-connections v0.4.0 // indirect
|
||||||
|
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect
|
||||||
github.com/docker/go-metrics v0.0.1 // indirect
|
github.com/docker/go-metrics v0.0.1 // indirect
|
||||||
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect
|
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.0 // indirect
|
github.com/dustin/go-humanize v1.0.0 // indirect
|
||||||
|
@ -110,6 +114,7 @@ require (
|
||||||
github.com/evanphx/json-patch v5.6.0+incompatible // indirect
|
github.com/evanphx/json-patch v5.6.0+incompatible // indirect
|
||||||
github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect
|
github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect
|
||||||
github.com/fatih/color v1.13.0 // indirect
|
github.com/fatih/color v1.13.0 // indirect
|
||||||
|
github.com/felixge/httpsnoop v1.0.1 // indirect
|
||||||
github.com/fluxcd/pkg/apis/acl v0.0.3 // indirect
|
github.com/fluxcd/pkg/apis/acl v0.0.3 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.5.1 // indirect
|
github.com/fsnotify/fsnotify v1.5.1 // indirect
|
||||||
github.com/go-errors/errors v1.0.1 // indirect
|
github.com/go-errors/errors v1.0.1 // indirect
|
||||||
|
@ -124,6 +129,7 @@ require (
|
||||||
github.com/golang-jwt/jwt v3.2.1+incompatible // indirect
|
github.com/golang-jwt/jwt v3.2.1+incompatible // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
github.com/golang/protobuf v1.5.2 // indirect
|
github.com/golang/protobuf v1.5.2 // indirect
|
||||||
|
github.com/gomodule/redigo v1.8.2 // indirect
|
||||||
github.com/google/btree v1.0.1 // indirect
|
github.com/google/btree v1.0.1 // indirect
|
||||||
github.com/google/go-cmp v0.5.7 // indirect
|
github.com/google/go-cmp v0.5.7 // indirect
|
||||||
github.com/google/gofuzz v1.2.0 // indirect
|
github.com/google/gofuzz v1.2.0 // indirect
|
||||||
|
@ -131,6 +137,7 @@ require (
|
||||||
github.com/googleapis/gax-go/v2 v2.3.0 // indirect
|
github.com/googleapis/gax-go/v2 v2.3.0 // indirect
|
||||||
github.com/googleapis/gnostic v0.5.5 // indirect
|
github.com/googleapis/gnostic v0.5.5 // indirect
|
||||||
github.com/googleapis/go-type-adapters v1.0.0 // indirect
|
github.com/googleapis/go-type-adapters v1.0.0 // indirect
|
||||||
|
github.com/gorilla/handlers v1.5.1 // indirect
|
||||||
github.com/gorilla/mux v1.8.0 // indirect
|
github.com/gorilla/mux v1.8.0 // indirect
|
||||||
github.com/gosuri/uitable v0.0.4 // indirect
|
github.com/gosuri/uitable v0.0.4 // indirect
|
||||||
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 // indirect
|
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 // indirect
|
||||||
|
|
1
go.sum
1
go.sum
|
@ -171,6 +171,7 @@ github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+Ce
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
||||||
|
github.com/bitly/go-simplejson v0.5.0 h1:6IH+V8/tVMab511d5bn4M7EwGXZf9Hj6i2xSwkNEM+Y=
|
||||||
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
|
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
|
||||||
github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84=
|
github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84=
|
||||||
github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM=
|
github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM=
|
||||||
|
|
|
@ -155,3 +155,9 @@ kubectl -n source-system wait --for=condition=ready --timeout=1m -l app=source-c
|
||||||
echo "Re-run large libgit2 repo test with managed transport"
|
echo "Re-run large libgit2 repo test with managed transport"
|
||||||
kubectl -n source-system wait gitrepository/large-repo-libgit2 --for=condition=ready --timeout=2m15s
|
kubectl -n source-system wait gitrepository/large-repo-libgit2 --for=condition=ready --timeout=2m15s
|
||||||
kubectl -n source-system exec deploy/source-controller -- printenv | grep EXPERIMENTAL_GIT_TRANSPORT=true
|
kubectl -n source-system exec deploy/source-controller -- printenv | grep EXPERIMENTAL_GIT_TRANSPORT=true
|
||||||
|
|
||||||
|
|
||||||
|
echo "Run HelmChart from OCI registry tests"
|
||||||
|
kubectl -n source-system apply -f "${ROOT_DIR}/config/testdata/helmchart-from-oci/source.yaml"
|
||||||
|
kubectl -n source-system wait helmrepository/podinfo --for=condition=ready --timeout=1m
|
||||||
|
kubectl -n source-system wait helmchart/podinfo --for=condition=ready --timeout=1m
|
||||||
|
|
|
@ -17,6 +17,7 @@ limitations under the License.
|
||||||
package chart
|
package chart
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
@ -24,24 +25,34 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/Masterminds/semver/v3"
|
"github.com/Masterminds/semver/v3"
|
||||||
|
"github.com/fluxcd/source-controller/internal/helm/repository"
|
||||||
helmchart "helm.sh/helm/v3/pkg/chart"
|
helmchart "helm.sh/helm/v3/pkg/chart"
|
||||||
"helm.sh/helm/v3/pkg/chartutil"
|
"helm.sh/helm/v3/pkg/chartutil"
|
||||||
|
"helm.sh/helm/v3/pkg/repo"
|
||||||
"sigs.k8s.io/yaml"
|
"sigs.k8s.io/yaml"
|
||||||
|
|
||||||
"github.com/fluxcd/pkg/runtime/transform"
|
"github.com/fluxcd/pkg/runtime/transform"
|
||||||
|
|
||||||
"github.com/fluxcd/source-controller/internal/fs"
|
"github.com/fluxcd/source-controller/internal/fs"
|
||||||
"github.com/fluxcd/source-controller/internal/helm/chart/secureloader"
|
"github.com/fluxcd/source-controller/internal/helm/chart/secureloader"
|
||||||
"github.com/fluxcd/source-controller/internal/helm/repository"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Remote is a repository.ChartRepository or a repository.OCIChartRepository.
|
||||||
|
// It is used to download a chart from a remote Helm repository or OCI registry.
|
||||||
|
type Remote interface {
|
||||||
|
// GetChart returns a chart.Chart from the remote repository.
|
||||||
|
Get(name, version string) (*repo.ChartVersion, error)
|
||||||
|
// GetChartVersion returns a chart.ChartVersion from the remote repository.
|
||||||
|
DownloadChart(chart *repo.ChartVersion) (*bytes.Buffer, error)
|
||||||
|
}
|
||||||
|
|
||||||
type remoteChartBuilder struct {
|
type remoteChartBuilder struct {
|
||||||
remote *repository.ChartRepository
|
remote Remote
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRemoteBuilder returns a Builder capable of building a Helm
|
// NewRemoteBuilder returns a Builder capable of building a Helm
|
||||||
// chart with a RemoteReference in the given repository.ChartRepository.
|
// chart with a RemoteReference in the given repository.ChartRepository.
|
||||||
func NewRemoteBuilder(repository *repository.ChartRepository) Builder {
|
func NewRemoteBuilder(repository Remote) Builder {
|
||||||
return &remoteChartBuilder{
|
return &remoteChartBuilder{
|
||||||
remote: repository,
|
remote: repository,
|
||||||
}
|
}
|
||||||
|
@ -72,65 +83,35 @@ func (b *remoteChartBuilder) Build(_ context.Context, ref Reference, p string, o
|
||||||
return nil, &BuildError{Reason: ErrChartReference, Err: err}
|
return nil, &BuildError{Reason: ErrChartReference, Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the repository index if not already present.
|
var (
|
||||||
if err := b.remote.StrategicallyLoadIndex(); err != nil {
|
res *bytes.Buffer
|
||||||
err = fmt.Errorf("could not load repository index for remote chart reference: %w", err)
|
err error
|
||||||
return nil, &BuildError{Reason: ErrChartPull, Err: err}
|
)
|
||||||
}
|
|
||||||
|
|
||||||
// Get the current version for the RemoteReference
|
|
||||||
cv, err := b.remote.Get(remoteRef.Name, remoteRef.Version)
|
|
||||||
if err != nil {
|
|
||||||
err = fmt.Errorf("failed to get chart version for remote reference: %w", err)
|
|
||||||
return nil, &BuildError{Reason: ErrChartReference, Err: err}
|
|
||||||
}
|
|
||||||
|
|
||||||
result := &Build{}
|
result := &Build{}
|
||||||
result.Name = cv.Name
|
switch b.remote.(type) {
|
||||||
result.Version = cv.Version
|
case *repository.ChartRepository:
|
||||||
|
res, err = b.downloadFromRepository(b.remote.(*repository.ChartRepository), remoteRef, result, opts)
|
||||||
// Set build specific metadata if instructed
|
|
||||||
if opts.VersionMetadata != "" {
|
|
||||||
ver, err := semver.NewVersion(result.Version)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = fmt.Errorf("failed to parse version from chart metadata as SemVer: %w", err)
|
return nil, &BuildError{Reason: ErrChartPull, Err: err}
|
||||||
return nil, &BuildError{Reason: ErrChartMetadataPatch, Err: err}
|
|
||||||
}
|
}
|
||||||
if *ver, err = ver.SetMetadata(opts.VersionMetadata); err != nil {
|
if res == nil {
|
||||||
err = fmt.Errorf("failed to set SemVer metadata on chart version: %w", err)
|
return result, nil
|
||||||
return nil, &BuildError{Reason: ErrChartMetadataPatch, Err: err}
|
|
||||||
}
|
}
|
||||||
result.Version = ver.String()
|
case *repository.OCIChartRepository:
|
||||||
|
res, err = b.downloadFromOCIRepository(b.remote.(*repository.OCIChartRepository), remoteRef, result, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &BuildError{Reason: ErrChartPull, Err: err}
|
||||||
|
}
|
||||||
|
if res == nil {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, &BuildError{Reason: ErrChartReference, Err: fmt.Errorf("unsupported remote type %T", b.remote)}
|
||||||
}
|
}
|
||||||
|
|
||||||
requiresPackaging := len(opts.GetValuesFiles()) != 0 || opts.VersionMetadata != ""
|
requiresPackaging := len(opts.GetValuesFiles()) != 0 || opts.VersionMetadata != ""
|
||||||
|
|
||||||
// If all the following is true, we do not need to download and/or build the chart:
|
|
||||||
// - Chart name from cached chart matches resolved name
|
|
||||||
// - Chart version from cached chart matches calculated version
|
|
||||||
// - BuildOptions.Force is False
|
|
||||||
if opts.CachedChart != "" && !opts.Force {
|
|
||||||
if curMeta, err := LoadChartMetadataFromArchive(opts.CachedChart); err == nil {
|
|
||||||
// If the cached metadata is corrupt, we ignore its existence
|
|
||||||
// and continue the build
|
|
||||||
if err = curMeta.Validate(); err == nil {
|
|
||||||
if result.Name == curMeta.Name && result.Version == curMeta.Version {
|
|
||||||
result.Path = opts.CachedChart
|
|
||||||
result.ValuesFiles = opts.GetValuesFiles()
|
|
||||||
result.Packaged = requiresPackaging
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download the package for the resolved version
|
|
||||||
res, err := b.remote.DownloadChart(cv)
|
|
||||||
if err != nil {
|
|
||||||
err = fmt.Errorf("failed to download chart for remote reference: %w", err)
|
|
||||||
return result, &BuildError{Reason: ErrChartPull, Err: err}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use literal chart copy from remote if no custom values files options are
|
// Use literal chart copy from remote if no custom values files options are
|
||||||
// set or version metadata isn't set.
|
// set or version metadata isn't set.
|
||||||
if !requiresPackaging {
|
if !requiresPackaging {
|
||||||
|
@ -171,6 +152,121 @@ func (b *remoteChartBuilder) Build(_ context.Context, ref Reference, p string, o
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *remoteChartBuilder) downloadFromOCIRepository(remote *repository.OCIChartRepository, remoteRef RemoteReference, buildResult *Build, opts BuildOptions) (*bytes.Buffer, error) {
|
||||||
|
cv, err := remote.Get(remoteRef.Name, remoteRef.Version)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("failed to get chart version for remote reference: %w", err)
|
||||||
|
return nil, &BuildError{Reason: ErrChartPull, Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, shouldReturn, err := generateBuildResult(cv, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldReturn {
|
||||||
|
*buildResult = *result
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download the package for the resolved version
|
||||||
|
res, err := remote.DownloadChart(cv)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("failed to download chart for remote reference: %w", err)
|
||||||
|
return nil, &BuildError{Reason: ErrChartPull, Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
*buildResult = *result
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *remoteChartBuilder) downloadFromRepository(remote *repository.ChartRepository, remoteRef RemoteReference, buildResult *Build, opts BuildOptions) (*bytes.Buffer, error) {
|
||||||
|
if err := remote.StrategicallyLoadIndex(); err != nil {
|
||||||
|
err = fmt.Errorf("could not load repository index for remote chart reference: %w", err)
|
||||||
|
return nil, &BuildError{Reason: ErrChartPull, Err: err}
|
||||||
|
}
|
||||||
|
defer remote.Unload()
|
||||||
|
|
||||||
|
// Get the current version for the RemoteReference
|
||||||
|
cv, err := remote.Get(remoteRef.Name, remoteRef.Version)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("failed to get chart version for remote reference: %w", err)
|
||||||
|
return nil, &BuildError{Reason: ErrChartReference, Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, shouldReturn, err := generateBuildResult(cv, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldReturn {
|
||||||
|
*buildResult = *result
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download the package for the resolved version
|
||||||
|
res, err := remote.DownloadChart(cv)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("failed to download chart for remote reference: %w", err)
|
||||||
|
return nil, &BuildError{Reason: ErrChartPull, Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
*buildResult = *result
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateBuildResult(cv *repo.ChartVersion, opts BuildOptions) (*Build, bool, error) {
|
||||||
|
result := &Build{}
|
||||||
|
result.Version = cv.Version
|
||||||
|
result.Name = cv.Name
|
||||||
|
|
||||||
|
// Set build specific metadata if instructed
|
||||||
|
if opts.VersionMetadata != "" {
|
||||||
|
ver, err := setBuildMetaData(result.Version, opts.VersionMetadata)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, &BuildError{Reason: ErrChartMetadataPatch, Err: err}
|
||||||
|
}
|
||||||
|
result.Version = ver.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
requiresPackaging := len(opts.GetValuesFiles()) != 0 || opts.VersionMetadata != ""
|
||||||
|
|
||||||
|
// If all the following is true, we do not need to download and/or build the chart:
|
||||||
|
// - Chart name from cached chart matches resolved name
|
||||||
|
// - Chart version from cached chart matches calculated version
|
||||||
|
// - BuildOptions.Force is False
|
||||||
|
if opts.CachedChart != "" && !opts.Force {
|
||||||
|
if curMeta, err := LoadChartMetadataFromArchive(opts.CachedChart); err == nil {
|
||||||
|
// If the cached metadata is corrupt, we ignore its existence
|
||||||
|
// and continue the build
|
||||||
|
if err = curMeta.Validate(); err == nil {
|
||||||
|
if result.Name == curMeta.Name && result.Version == curMeta.Version {
|
||||||
|
result.Path = opts.CachedChart
|
||||||
|
result.ValuesFiles = opts.GetValuesFiles()
|
||||||
|
result.Packaged = requiresPackaging
|
||||||
|
return result, true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setBuildMetaData(version, versionMetadata string) (*semver.Version, error) {
|
||||||
|
ver, err := semver.NewVersion(version)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse version from chart metadata as SemVer: %w", err)
|
||||||
|
}
|
||||||
|
if *ver, err = ver.SetMetadata(versionMetadata); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to set SemVer metadata on chart version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ver, nil
|
||||||
|
}
|
||||||
|
|
||||||
// mergeChartValues merges the given chart.Chart Files paths into a single "values.yaml" map.
|
// mergeChartValues merges the given chart.Chart Files paths into a single "values.yaml" map.
|
||||||
// It returns the merge result, or an error.
|
// It returns the merge result, or an error.
|
||||||
func mergeChartValues(chart *helmchart.Chart, paths []string) (map[string]interface{}, error) {
|
func mergeChartValues(chart *helmchart.Chart, paths []string) (map[string]interface{}, error) {
|
||||||
|
|
|
@ -19,6 +19,8 @@ package chart
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -29,11 +31,35 @@ import (
|
||||||
helmchart "helm.sh/helm/v3/pkg/chart"
|
helmchart "helm.sh/helm/v3/pkg/chart"
|
||||||
"helm.sh/helm/v3/pkg/chartutil"
|
"helm.sh/helm/v3/pkg/chartutil"
|
||||||
helmgetter "helm.sh/helm/v3/pkg/getter"
|
helmgetter "helm.sh/helm/v3/pkg/getter"
|
||||||
|
"helm.sh/helm/v3/pkg/registry"
|
||||||
|
|
||||||
"github.com/fluxcd/source-controller/internal/helm/chart/secureloader"
|
"github.com/fluxcd/source-controller/internal/helm/chart/secureloader"
|
||||||
"github.com/fluxcd/source-controller/internal/helm/repository"
|
"github.com/fluxcd/source-controller/internal/helm/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type mockRegistryClient struct {
|
||||||
|
tags map[string][]string
|
||||||
|
requestedURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockRegistryClient) Tags(url string) ([]string, error) {
|
||||||
|
m.requestedURL = url
|
||||||
|
if tags, ok := m.tags[url]; ok {
|
||||||
|
return tags, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no tags found for %s", url)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockRegistryClient) Login(url string, opts ...registry.LoginOption) error {
|
||||||
|
m.requestedURL = url
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockRegistryClient) Logout(url string, opts ...registry.LogoutOption) error {
|
||||||
|
m.requestedURL = url
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// mockIndexChartGetter returns specific response for index and chart queries.
|
// mockIndexChartGetter returns specific response for index and chart queries.
|
||||||
type mockIndexChartGetter struct {
|
type mockIndexChartGetter struct {
|
||||||
IndexResponse []byte
|
IndexResponse []byte
|
||||||
|
@ -54,7 +80,7 @@ func (g *mockIndexChartGetter) LastGet() string {
|
||||||
return g.requestedURL
|
return g.requestedURL
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRemoteBuilder_Build(t *testing.T) {
|
func TestRemoteBuilder__BuildFromChartRepository(t *testing.T) {
|
||||||
g := NewWithT(t)
|
g := NewWithT(t)
|
||||||
|
|
||||||
chartGrafana, err := os.ReadFile("./../testdata/charts/helmchart-0.1.0.tgz")
|
chartGrafana, err := os.ReadFile("./../testdata/charts/helmchart-0.1.0.tgz")
|
||||||
|
@ -195,6 +221,140 @@ entries:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRemoteBuilder_BuildFromOCIChatRepository(t *testing.T) {
|
||||||
|
g := NewWithT(t)
|
||||||
|
|
||||||
|
chartGrafana, err := os.ReadFile("./../testdata/charts/helmchart-0.1.0.tgz")
|
||||||
|
g.Expect(err).ToNot(HaveOccurred())
|
||||||
|
g.Expect(chartGrafana).ToNot(BeEmpty())
|
||||||
|
|
||||||
|
registryClient := &mockRegistryClient{
|
||||||
|
tags: map[string][]string{
|
||||||
|
"localhost:5000/my_repo/grafana": {"6.17.4"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mockGetter := &mockIndexChartGetter{
|
||||||
|
ChartResponse: chartGrafana,
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse("oci://localhost:5000/my_repo")
|
||||||
|
g.Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
mockRepo := func() *repository.OCIChartRepository {
|
||||||
|
return &repository.OCIChartRepository{
|
||||||
|
URL: *u,
|
||||||
|
Client: mockGetter,
|
||||||
|
RegistryClient: registryClient,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
reference Reference
|
||||||
|
buildOpts BuildOptions
|
||||||
|
repository *repository.OCIChartRepository
|
||||||
|
wantValues chartutil.Values
|
||||||
|
wantVersion string
|
||||||
|
wantPackaged bool
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "invalid reference",
|
||||||
|
reference: LocalReference{},
|
||||||
|
wantErr: "expected remote chart reference",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid reference - no name",
|
||||||
|
reference: RemoteReference{},
|
||||||
|
wantErr: "no name set for remote chart reference",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "chart not in repository",
|
||||||
|
reference: RemoteReference{Name: "foo"},
|
||||||
|
repository: mockRepo(),
|
||||||
|
wantErr: "failed to get chart version for remote reference",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "chart version not in repository",
|
||||||
|
reference: RemoteReference{Name: "grafana", Version: "1.1.1"},
|
||||||
|
repository: mockRepo(),
|
||||||
|
wantErr: "failed to get chart version for remote reference",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid version metadata",
|
||||||
|
reference: RemoteReference{Name: "grafana"},
|
||||||
|
repository: mockRepo(),
|
||||||
|
buildOpts: BuildOptions{VersionMetadata: "^"},
|
||||||
|
wantErr: "Invalid Metadata string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with version metadata",
|
||||||
|
reference: RemoteReference{Name: "grafana"},
|
||||||
|
repository: mockRepo(),
|
||||||
|
buildOpts: BuildOptions{VersionMetadata: "foo"},
|
||||||
|
wantVersion: "6.17.4+foo",
|
||||||
|
wantPackaged: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "default values",
|
||||||
|
reference: RemoteReference{Name: "grafana"},
|
||||||
|
repository: mockRepo(),
|
||||||
|
wantVersion: "0.1.0",
|
||||||
|
wantValues: chartutil.Values{
|
||||||
|
"replicaCount": float64(1),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "merge values",
|
||||||
|
reference: RemoteReference{Name: "grafana"},
|
||||||
|
buildOpts: BuildOptions{
|
||||||
|
ValuesFiles: []string{"a.yaml", "b.yaml", "c.yaml"},
|
||||||
|
},
|
||||||
|
repository: mockRepo(),
|
||||||
|
wantVersion: "6.17.4",
|
||||||
|
wantValues: chartutil.Values{
|
||||||
|
"a": "b",
|
||||||
|
"b": "d",
|
||||||
|
},
|
||||||
|
wantPackaged: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
g := NewWithT(t)
|
||||||
|
|
||||||
|
tmpDir, err := os.MkdirTemp("", "remote-chart-builder-")
|
||||||
|
g.Expect(err).ToNot(HaveOccurred())
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
targetPath := filepath.Join(tmpDir, "chart.tgz")
|
||||||
|
|
||||||
|
b := NewRemoteBuilder(tt.repository)
|
||||||
|
|
||||||
|
cb, err := b.Build(context.TODO(), tt.reference, targetPath, tt.buildOpts)
|
||||||
|
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
g.Expect(err).To(HaveOccurred())
|
||||||
|
g.Expect(err.Error()).To(ContainSubstring(tt.wantErr))
|
||||||
|
g.Expect(cb).To(BeZero())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g.Expect(err).ToNot(HaveOccurred())
|
||||||
|
g.Expect(cb.Packaged).To(Equal(tt.wantPackaged), "unexpected Build.Packaged value")
|
||||||
|
g.Expect(cb.Path).ToNot(BeEmpty(), "empty Build.Path")
|
||||||
|
|
||||||
|
// Load the resulting chart and verify the values.
|
||||||
|
resultChart, err := secureloader.LoadFile(cb.Path)
|
||||||
|
g.Expect(err).ToNot(HaveOccurred())
|
||||||
|
g.Expect(resultChart.Metadata.Version).To(Equal(tt.wantVersion))
|
||||||
|
|
||||||
|
for k, v := range tt.wantValues {
|
||||||
|
g.Expect(v).To(Equal(resultChart.Values[k]))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRemoteBuilder_Build_CachedChart(t *testing.T) {
|
func TestRemoteBuilder_Build_CachedChart(t *testing.T) {
|
||||||
g := NewWithT(t)
|
g := NewWithT(t)
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,252 @@
|
||||||
|
/*
|
||||||
|
Copyright 2022 The Flux authors
|
||||||
|
|
||||||
|
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 repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"helm.sh/helm/v3/pkg/chart"
|
||||||
|
"helm.sh/helm/v3/pkg/getter"
|
||||||
|
"helm.sh/helm/v3/pkg/registry"
|
||||||
|
"helm.sh/helm/v3/pkg/repo"
|
||||||
|
|
||||||
|
"github.com/Masterminds/semver/v3"
|
||||||
|
"github.com/fluxcd/pkg/version"
|
||||||
|
"github.com/fluxcd/source-controller/internal/transport"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegistryClient is an interface for interacting with OCI registries
|
||||||
|
// It is used by the OCIChartRepository to retrieve chart versions
|
||||||
|
// from OCI registries
|
||||||
|
type RegistryClient interface {
|
||||||
|
Login(host string, opts ...registry.LoginOption) error
|
||||||
|
Logout(host string, opts ...registry.LogoutOption) error
|
||||||
|
Tags(url string) ([]string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OCIChartRepository represents a Helm chart repository, and the configuration
|
||||||
|
// required to download the repository tags and charts from the repository.
|
||||||
|
// All methods are thread safe unless defined otherwise.
|
||||||
|
type OCIChartRepository struct {
|
||||||
|
// URL is the location of the repository.
|
||||||
|
URL url.URL
|
||||||
|
// Client to use while accessing the repository's contents.
|
||||||
|
Client getter.Getter
|
||||||
|
// Options to configure the Client with while downloading tags
|
||||||
|
// or a chart from the URL.
|
||||||
|
Options []getter.Option
|
||||||
|
|
||||||
|
tlsConfig *tls.Config
|
||||||
|
|
||||||
|
// RegistryClient is a client to use while downloading tags or charts from a registry.
|
||||||
|
RegistryClient RegistryClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// OCIChartRepositoryOption is a function that can be passed to NewOCIChartRepository
|
||||||
|
// to configure an OCIChartRepository.
|
||||||
|
type OCIChartRepositoryOption func(*OCIChartRepository) error
|
||||||
|
|
||||||
|
// WithOCIRegistryClient returns a ChartRepositoryOption that will set the registry client
|
||||||
|
func WithOCIRegistryClient(client RegistryClient) OCIChartRepositoryOption {
|
||||||
|
return func(r *OCIChartRepository) error {
|
||||||
|
r.RegistryClient = client
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithOCIGetter returns a ChartRepositoryOption that will set the getter.Getter
|
||||||
|
func WithOCIGetter(providers getter.Providers) OCIChartRepositoryOption {
|
||||||
|
return func(r *OCIChartRepository) error {
|
||||||
|
c, err := providers.ByScheme(r.URL.Scheme)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.Client = c
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithOCIGetterOptions returns a ChartRepositoryOption that will set the getter.Options
|
||||||
|
func WithOCIGetterOptions(getterOpts []getter.Option) OCIChartRepositoryOption {
|
||||||
|
return func(r *OCIChartRepository) error {
|
||||||
|
r.Options = getterOpts
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOCIChartRepository constructs and returns a new ChartRepository with
|
||||||
|
// the ChartRepository.Client configured to the getter.Getter for the
|
||||||
|
// repository URL scheme. It returns an error on URL parsing failures.
|
||||||
|
// It assumes that the url scheme has been validated to be an OCI scheme.
|
||||||
|
func NewOCIChartRepository(repositoryURL string, chartRepoOpts ...OCIChartRepositoryOption) (*OCIChartRepository, error) {
|
||||||
|
u, err := url.Parse(repositoryURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
r := &OCIChartRepository{}
|
||||||
|
r.URL = *u
|
||||||
|
for _, opt := range chartRepoOpts {
|
||||||
|
if err := opt(r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the repo.ChartVersion for the given name, the version is expected
|
||||||
|
// to be a semver.Constraints compatible string. If version is empty, the latest
|
||||||
|
// stable version will be returned and prerelease versions will be ignored.
|
||||||
|
// adapted from https://github.com/helm/helm/blob/49819b4ef782e80b0c7f78c30bd76b51ebb56dc8/pkg/downloader/chart_downloader.go#L162
|
||||||
|
func (r *OCIChartRepository) Get(name, ver string) (*repo.ChartVersion, error) {
|
||||||
|
// Find chart versions matching the given name.
|
||||||
|
// Either in an index file or from a registry.
|
||||||
|
cvs, err := r.getTags(fmt.Sprintf("%s/%s", r.URL.String(), name))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cvs) == 0 {
|
||||||
|
return nil, fmt.Errorf("unable to locate any tags in provided repository: %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if version provided
|
||||||
|
// If empty, try to get the highest available tag
|
||||||
|
// If exact version, try to find it
|
||||||
|
// If semver constraint string, try to find a match
|
||||||
|
tag, err := getLastMatchingVersionOrConstraint(cvs, ver)
|
||||||
|
return &repo.ChartVersion{
|
||||||
|
URLs: []string{fmt.Sprintf("%s/%s:%s", r.URL.String(), name, tag)},
|
||||||
|
Metadata: &chart.Metadata{
|
||||||
|
Name: name,
|
||||||
|
Version: tag,
|
||||||
|
},
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// This function shall be called for OCI registries only
|
||||||
|
// It assumes that the ref has been validated to be an OCI reference.
|
||||||
|
func (r *OCIChartRepository) getTags(ref string) ([]string, error) {
|
||||||
|
// Retrieve list of repository tags
|
||||||
|
tags, err := r.RegistryClient.Tags(strings.TrimPrefix(ref, fmt.Sprintf("%s://", registry.OCIScheme)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(tags) == 0 {
|
||||||
|
return nil, fmt.Errorf("unable to locate any tags in provided repository: %s", ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadChart confirms the given repo.ChartVersion has a downloadable URL,
|
||||||
|
// and then attempts to download the chart using the Client and Options of the
|
||||||
|
// ChartRepository. It returns a bytes.Buffer containing the chart data.
|
||||||
|
// In case of an OCI hosted chart, this function assumes that the chartVersion url is valid.
|
||||||
|
func (r *OCIChartRepository) DownloadChart(chart *repo.ChartVersion) (*bytes.Buffer, error) {
|
||||||
|
if len(chart.URLs) == 0 {
|
||||||
|
return nil, fmt.Errorf("chart '%s' has no downloadable URLs", chart.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
ref := chart.URLs[0]
|
||||||
|
u, err := url.Parse(ref)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("invalid chart URL format '%s': %w", ref, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
t := transport.NewOrIdle(r.tlsConfig)
|
||||||
|
clientOpts := append(r.Options, getter.WithTransport(t))
|
||||||
|
defer transport.Release(t)
|
||||||
|
|
||||||
|
// trim the oci scheme prefix if needed
|
||||||
|
return r.Client.Get(strings.TrimPrefix(u.String(), fmt.Sprintf("%s://", registry.OCIScheme)), clientOpts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login attempts to login to the OCI registry.
|
||||||
|
// It returns an error on failure.
|
||||||
|
func (r *OCIChartRepository) Login(opts ...registry.LoginOption) error {
|
||||||
|
err := r.RegistryClient.Login(r.URL.Host, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout attempts to logout from the OCI registry.
|
||||||
|
// It returns an error on failure.
|
||||||
|
func (r *OCIChartRepository) Logout() error {
|
||||||
|
err := r.RegistryClient.Logout(r.URL.Host)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLastMatchingVersionOrConstraint returns the last version that matches the given version string.
|
||||||
|
// If the version string is empty, the highest available version is returned.
|
||||||
|
func getLastMatchingVersionOrConstraint(cvs []string, ver string) (string, error) {
|
||||||
|
// Check for exact matches first
|
||||||
|
if ver != "" {
|
||||||
|
for _, cv := range cvs {
|
||||||
|
if ver == cv {
|
||||||
|
return cv, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue to look for a (semantic) version match
|
||||||
|
verConstraint, err := semver.NewConstraint("*")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
latestStable := ver == "" || ver == "*"
|
||||||
|
if !latestStable {
|
||||||
|
verConstraint, err = semver.NewConstraint(ver)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
matchingVersions := make([]string, 0, len(cvs))
|
||||||
|
for _, cv := range cvs {
|
||||||
|
v, err := version.ParseVersion(cv)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !verConstraint.Check(v) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
matchingVersions = append(matchingVersions, cv)
|
||||||
|
}
|
||||||
|
if len(matchingVersions) == 0 {
|
||||||
|
return "", fmt.Errorf("could not locate a version matching provided version string %s", ver)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort versions
|
||||||
|
sort.Sort(sort.Reverse(sort.StringSlice(matchingVersions)))
|
||||||
|
|
||||||
|
return matchingVersions[0], nil
|
||||||
|
}
|
|
@ -0,0 +1,238 @@
|
||||||
|
/*
|
||||||
|
Copyright 2022 The Flux authors
|
||||||
|
|
||||||
|
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 repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
"helm.sh/helm/v3/pkg/chart"
|
||||||
|
helmgetter "helm.sh/helm/v3/pkg/getter"
|
||||||
|
"helm.sh/helm/v3/pkg/registry"
|
||||||
|
"helm.sh/helm/v3/pkg/repo"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OCIMockGetter struct {
|
||||||
|
Response []byte
|
||||||
|
LastCalledURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *OCIMockGetter) Get(u string, _ ...helmgetter.Option) (*bytes.Buffer, error) {
|
||||||
|
r := g.Response
|
||||||
|
g.LastCalledURL = u
|
||||||
|
return bytes.NewBuffer(r), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockRegistryClient struct {
|
||||||
|
tags []string
|
||||||
|
LastCalledURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockRegistryClient) Tags(url string) ([]string, error) {
|
||||||
|
m.LastCalledURL = url
|
||||||
|
return m.tags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockRegistryClient) Login(url string, opts ...registry.LoginOption) error {
|
||||||
|
m.LastCalledURL = url
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockRegistryClient) Logout(url string, opts ...registry.LogoutOption) error {
|
||||||
|
m.LastCalledURL = url
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewOCIChartRepository(t *testing.T) {
|
||||||
|
registryClient := &mockRegistryClient{}
|
||||||
|
url := "oci://localhost:5000/my_repo"
|
||||||
|
providers := helmgetter.Providers{
|
||||||
|
helmgetter.Provider{
|
||||||
|
Schemes: []string{"oci"},
|
||||||
|
New: helmgetter.NewOCIGetter,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
options := []helmgetter.Option{helmgetter.WithBasicAuth("username", "password")}
|
||||||
|
t.Run("should construct chart registry", func(t *testing.T) {
|
||||||
|
g := NewWithT(t)
|
||||||
|
r, err := NewOCIChartRepository(url, WithOCIGetter(providers), WithOCIGetterOptions(options), WithOCIRegistryClient(registryClient))
|
||||||
|
g.Expect(err).ToNot(HaveOccurred())
|
||||||
|
g.Expect(r).ToNot(BeNil())
|
||||||
|
g.Expect(r.URL.Host).To(Equal("localhost:5000"))
|
||||||
|
g.Expect(r.Client).ToNot(BeNil())
|
||||||
|
g.Expect(r.Options).To(Equal(options))
|
||||||
|
g.Expect(r.RegistryClient).To(Equal(registryClient))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should return error on invalid url", func(t *testing.T) {
|
||||||
|
g := NewWithT(t)
|
||||||
|
r, err := NewOCIChartRepository("oci://localhost:5000 /my_repo", WithOCIGetter(providers), WithOCIGetterOptions(options), WithOCIRegistryClient(registryClient))
|
||||||
|
g.Expect(err).To(HaveOccurred())
|
||||||
|
g.Expect(r).To(BeNil())
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOCIChartRepoisitory_Get(t *testing.T) {
|
||||||
|
registryClient := &mockRegistryClient{
|
||||||
|
tags: []string{
|
||||||
|
"0.0.1",
|
||||||
|
"0.1.0",
|
||||||
|
"0.1.1",
|
||||||
|
"0.1.5+b.min.minute",
|
||||||
|
"0.1.5+a.min.hour",
|
||||||
|
"0.1.5+c.now",
|
||||||
|
"0.2.0",
|
||||||
|
"1.0.0",
|
||||||
|
"1.1.0-rc.1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
providers := helmgetter.Providers{
|
||||||
|
helmgetter.Provider{
|
||||||
|
Schemes: []string{"oci"},
|
||||||
|
New: helmgetter.NewOCIGetter,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
version string
|
||||||
|
expected string
|
||||||
|
expectedErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "should return latest stable version",
|
||||||
|
version: "",
|
||||||
|
expected: "1.0.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "should return latest stable version (asterisk)",
|
||||||
|
version: "*",
|
||||||
|
expected: "1.0.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "should return latest stable version (semver range)",
|
||||||
|
version: ">=0.1.5",
|
||||||
|
expected: "1.0.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "should return 0.2.0 (semver range)",
|
||||||
|
version: "0.2.x",
|
||||||
|
expected: "0.2.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "should return a perfect match",
|
||||||
|
version: "0.1.0",
|
||||||
|
expected: "0.1.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "should an error for unfunfilled range",
|
||||||
|
version: ">2.0.0",
|
||||||
|
expectedErr: "could not locate a version matching provided version string >2.0.0",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
url := "oci://localhost:5000/my_repo"
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
g := NewWithT(t)
|
||||||
|
r, err := NewOCIChartRepository(url, WithOCIRegistryClient(registryClient), WithOCIGetter(providers))
|
||||||
|
g.Expect(err).ToNot(HaveOccurred())
|
||||||
|
g.Expect(r).ToNot(BeNil())
|
||||||
|
|
||||||
|
chart := "podinfo"
|
||||||
|
cv, err := r.Get(chart, tc.version)
|
||||||
|
if tc.expectedErr != "" {
|
||||||
|
g.Expect(err).To(HaveOccurred())
|
||||||
|
g.Expect(err.Error()).To(Equal(tc.expectedErr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Expect(err).ToNot(HaveOccurred())
|
||||||
|
g.Expect(cv.URLs[0]).To(Equal(fmt.Sprintf("%s/%s:%s", url, chart, tc.expected)))
|
||||||
|
g.Expect(registryClient.LastCalledURL).To(Equal(fmt.Sprintf("%s/%s", strings.TrimPrefix(url, fmt.Sprintf("%s://", registry.OCIScheme)), chart)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOCIChartRepoisitory_DownloadChart(t *testing.T) {
|
||||||
|
client := &mockRegistryClient{}
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
chartVersion *repo.ChartVersion
|
||||||
|
expected string
|
||||||
|
expectedErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "should download chart",
|
||||||
|
url: "oci://localhost:5000/my_repo",
|
||||||
|
chartVersion: &repo.ChartVersion{
|
||||||
|
Metadata: &chart.Metadata{Name: "chart"},
|
||||||
|
URLs: []string{"oci://localhost:5000/my_repo/podinfo:1.0.0"},
|
||||||
|
},
|
||||||
|
expected: "oci://localhost:5000/my_repo/podinfo:1.0.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no chart URL",
|
||||||
|
url: "",
|
||||||
|
chartVersion: &repo.ChartVersion{Metadata: &chart.Metadata{Name: "chart"}},
|
||||||
|
expectedErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid chart URL",
|
||||||
|
url: "oci://localhost:5000/my_repo",
|
||||||
|
chartVersion: &repo.ChartVersion{
|
||||||
|
Metadata: &chart.Metadata{Name: "chart"},
|
||||||
|
URLs: []string{"oci://localhost:5000 /my_repo/podinfo:1.0.0"},
|
||||||
|
},
|
||||||
|
expectedErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
g := NewWithT(t)
|
||||||
|
t.Parallel()
|
||||||
|
mg := OCIMockGetter{}
|
||||||
|
u, err := url.Parse(tc.url)
|
||||||
|
g.Expect(err).ToNot(HaveOccurred())
|
||||||
|
r := OCIChartRepository{
|
||||||
|
Client: &mg,
|
||||||
|
URL: *u,
|
||||||
|
}
|
||||||
|
r.Client = &mg
|
||||||
|
g.Expect(err).ToNot(HaveOccurred())
|
||||||
|
g.Expect(r).ToNot(BeNil())
|
||||||
|
res, err := r.DownloadChart(tc.chartVersion)
|
||||||
|
if tc.expectedErr {
|
||||||
|
g.Expect(err).To(HaveOccurred())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Expect(err).ToNot(HaveOccurred())
|
||||||
|
g.Expect(client.LastCalledURL).To(Equal(tc.expected))
|
||||||
|
g.Expect(res).ToNot(BeNil())
|
||||||
|
g.Expect(err).ToNot(HaveOccurred())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -16,7 +16,9 @@ limitations under the License.
|
||||||
|
|
||||||
package repository
|
package repository
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
// NormalizeURL normalizes a ChartRepository URL by ensuring it ends with a
|
// NormalizeURL normalizes a ChartRepository URL by ensuring it ends with a
|
||||||
// single "/".
|
// single "/".
|
||||||
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
/*
|
||||||
|
Copyright 2022 The Flux authors
|
||||||
|
|
||||||
|
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 util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"helm.sh/helm/v3/pkg/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegistryClientGenerator generates a registry client and a temporary credential file.
|
||||||
|
// The client is meant to be used for a single reconciliation.
|
||||||
|
// The file is meant to be used for a single reconciliation and deleted after.
|
||||||
|
func RegistryClientGenerator(isLogin bool) (*registry.Client, string, error) {
|
||||||
|
if isLogin {
|
||||||
|
// create a temporary file to store the credentials
|
||||||
|
// this is needed because otherwise the credentials are stored in ~/.docker/config.json.
|
||||||
|
credentialFile, err := os.CreateTemp("", "credentials")
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
rClient, err := registry.NewClient(registry.ClientOptWriter(io.Discard), registry.ClientOptCredentialsFile(credentialFile.Name()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return rClient, credentialFile.Name(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rClient, err := registry.NewClient(registry.ClientOptWriter(io.Discard))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return rClient, "", nil
|
||||||
|
}
|
|
@ -0,0 +1,86 @@
|
||||||
|
/*
|
||||||
|
Copyright 2022 The Flux authors
|
||||||
|
|
||||||
|
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 predicates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/event"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/predicate"
|
||||||
|
|
||||||
|
sourcev1 "github.com/fluxcd/source-controller/api/v1beta2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// helmRepositoryTypeFilter filters events for a given HelmRepository type.
|
||||||
|
// It returns true if the event is for a HelmRepository of the given type.
|
||||||
|
func helmRepositoryTypeFilter(repositoryType string, o client.Object) bool {
|
||||||
|
if o == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// return true if the object is a HelmRepository
|
||||||
|
// and the type is the same as the one we are looking for.
|
||||||
|
hr, ok := o.(*sourcev1.HelmRepository)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return hr.Spec.Type == repositoryType
|
||||||
|
}
|
||||||
|
|
||||||
|
// HelmRepositoryTypePredicate is a predicate that filters events for a given HelmRepository type.
|
||||||
|
type HelmRepositoryTypePredicate struct {
|
||||||
|
RepositoryType string
|
||||||
|
predicate.Funcs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create returns true if the Create event is for a HelmRepository of the given type.
|
||||||
|
func (h HelmRepositoryTypePredicate) Create(e event.CreateEvent) bool {
|
||||||
|
return helmRepositoryTypeFilter(h.RepositoryType, e.Object)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update returns true if the Update event is for a HelmRepository of the given type.
|
||||||
|
func (h HelmRepositoryTypePredicate) Update(e event.UpdateEvent) bool {
|
||||||
|
if e.ObjectOld == nil || e.ObjectNew == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if the old object is a HelmRepository
|
||||||
|
oldObj, ok := e.ObjectOld.(*sourcev1.HelmRepository)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if the new object is a HelmRepository
|
||||||
|
newObj, ok := e.ObjectNew.(*sourcev1.HelmRepository)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
isOfRepositoryType := newObj.Spec.Type == h.RepositoryType
|
||||||
|
wasOfRepositoryType := oldObj.Spec.Type == h.RepositoryType && !isOfRepositoryType
|
||||||
|
return isOfRepositoryType || wasOfRepositoryType
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete returns true if the Delete event is for a HelmRepository of the given type.
|
||||||
|
func (h HelmRepositoryTypePredicate) Delete(e event.DeleteEvent) bool {
|
||||||
|
return helmRepositoryTypeFilter(h.RepositoryType, e.Object)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic returns true if the Generic event is for a HelmRepository of the given type.
|
||||||
|
func (h HelmRepositoryTypePredicate) Generic(e event.GenericEvent) bool {
|
||||||
|
return helmRepositoryTypeFilter(h.RepositoryType, e.Object)
|
||||||
|
}
|
|
@ -0,0 +1,127 @@
|
||||||
|
/*
|
||||||
|
Copyright 2022 The Flux authors
|
||||||
|
|
||||||
|
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 predicates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
sourcev1 "github.com/fluxcd/source-controller/api/v1beta2"
|
||||||
|
"github.com/onsi/gomega"
|
||||||
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/event"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHelmRepositoryTypePredicate_Create(t *testing.T) {
|
||||||
|
obj := &sourcev1.HelmRepository{Spec: sourcev1.HelmRepositorySpec{}}
|
||||||
|
http := &sourcev1.HelmRepository{Spec: sourcev1.HelmRepositorySpec{Type: "default"}}
|
||||||
|
oci := &sourcev1.HelmRepository{Spec: sourcev1.HelmRepositorySpec{Type: "oci"}}
|
||||||
|
not := &unstructured.Unstructured{}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
obj client.Object
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "new", obj: obj, want: false},
|
||||||
|
{name: "http", obj: http, want: true},
|
||||||
|
{name: "oci", obj: oci, want: false},
|
||||||
|
{name: "not a HelmRepository", obj: not, want: false},
|
||||||
|
{name: "nil", obj: nil, want: false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
g := gomega.NewWithT(t)
|
||||||
|
|
||||||
|
so := HelmRepositoryTypePredicate{RepositoryType: "default"}
|
||||||
|
e := event.CreateEvent{
|
||||||
|
Object: tt.obj,
|
||||||
|
}
|
||||||
|
g.Expect(so.Create(e)).To(gomega.Equal(tt.want))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHelmRepositoryTypePredicate_Update(t *testing.T) {
|
||||||
|
repoA := &sourcev1.HelmRepository{Spec: sourcev1.HelmRepositorySpec{
|
||||||
|
Type: sourcev1.HelmRepositoryTypeDefault,
|
||||||
|
}}
|
||||||
|
|
||||||
|
repoB := &sourcev1.HelmRepository{Spec: sourcev1.HelmRepositorySpec{
|
||||||
|
Type: sourcev1.HelmRepositoryTypeOCI,
|
||||||
|
}}
|
||||||
|
|
||||||
|
empty := &sourcev1.HelmRepository{}
|
||||||
|
not := &unstructured.Unstructured{}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
old client.Object
|
||||||
|
new client.Object
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "diff type", old: repoA, new: repoB, want: true},
|
||||||
|
{name: "new with type", old: empty, new: repoA, want: true},
|
||||||
|
{name: "old with type", old: repoA, new: empty, want: true},
|
||||||
|
{name: "old not a HelmRepository", old: not, new: repoA, want: false},
|
||||||
|
{name: "new not a HelmRepository", old: repoA, new: not, want: false},
|
||||||
|
{name: "old nil", old: nil, new: repoA, want: false},
|
||||||
|
{name: "new nil", old: repoA, new: nil, want: false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
g := gomega.NewWithT(t)
|
||||||
|
|
||||||
|
so := HelmRepositoryTypePredicate{RepositoryType: "default"}
|
||||||
|
e := event.UpdateEvent{
|
||||||
|
ObjectOld: tt.old,
|
||||||
|
ObjectNew: tt.new,
|
||||||
|
}
|
||||||
|
g.Expect(so.Update(e)).To(gomega.Equal(tt.want))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHelmRepositoryTypePredicate_Delete(t *testing.T) {
|
||||||
|
obj := &sourcev1.HelmRepository{Spec: sourcev1.HelmRepositorySpec{}}
|
||||||
|
http := &sourcev1.HelmRepository{Spec: sourcev1.HelmRepositorySpec{Type: "default"}}
|
||||||
|
oci := &sourcev1.HelmRepository{Spec: sourcev1.HelmRepositorySpec{Type: "oci"}}
|
||||||
|
not := &unstructured.Unstructured{}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
obj client.Object
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "new", obj: obj, want: false},
|
||||||
|
{name: "http", obj: http, want: true},
|
||||||
|
{name: "oci", obj: oci, want: false},
|
||||||
|
{name: "not a HelmRepository", obj: not, want: false},
|
||||||
|
{name: "nil", obj: nil, want: false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
g := gomega.NewWithT(t)
|
||||||
|
|
||||||
|
so := HelmRepositoryTypePredicate{RepositoryType: "default"}
|
||||||
|
e := event.DeleteEvent{
|
||||||
|
Object: tt.obj,
|
||||||
|
}
|
||||||
|
g.Expect(so.Delete(e)).To(gomega.Equal(tt.want))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
39
main.go
39
main.go
|
@ -42,6 +42,7 @@ import (
|
||||||
"github.com/fluxcd/pkg/runtime/pprof"
|
"github.com/fluxcd/pkg/runtime/pprof"
|
||||||
"github.com/fluxcd/pkg/runtime/probes"
|
"github.com/fluxcd/pkg/runtime/probes"
|
||||||
"github.com/fluxcd/source-controller/internal/features"
|
"github.com/fluxcd/source-controller/internal/features"
|
||||||
|
"github.com/fluxcd/source-controller/internal/helm/util"
|
||||||
|
|
||||||
sourcev1 "github.com/fluxcd/source-controller/api/v1beta2"
|
sourcev1 "github.com/fluxcd/source-controller/api/v1beta2"
|
||||||
"github.com/fluxcd/source-controller/controllers"
|
"github.com/fluxcd/source-controller/controllers"
|
||||||
|
@ -62,6 +63,10 @@ var (
|
||||||
Schemes: []string{"http", "https"},
|
Schemes: []string{"http", "https"},
|
||||||
New: getter.NewHTTPGetter,
|
New: getter.NewHTTPGetter,
|
||||||
},
|
},
|
||||||
|
getter.Provider{
|
||||||
|
Schemes: []string{"oci"},
|
||||||
|
New: getter.NewOCIGetter,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -228,6 +233,21 @@ func main() {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err = (&controllers.HelmRepositoryOCIReconciler{
|
||||||
|
Client: mgr.GetClient(),
|
||||||
|
EventRecorder: eventRecorder,
|
||||||
|
Metrics: metricsH,
|
||||||
|
Getters: getters,
|
||||||
|
ControllerName: controllerName,
|
||||||
|
RegistryClientGenerator: util.RegistryClientGenerator,
|
||||||
|
}).SetupWithManagerAndOptions(mgr, controllers.HelmRepositoryReconcilerOptions{
|
||||||
|
MaxConcurrentReconciles: concurrent,
|
||||||
|
RateLimiter: helper.GetRateLimiter(rateLimiterOptions),
|
||||||
|
}); err != nil {
|
||||||
|
setupLog.Error(err, "unable to create controller", "controller", sourcev1.HelmRepositoryKind)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
var c *cache.Cache
|
var c *cache.Cache
|
||||||
var ttl time.Duration
|
var ttl time.Duration
|
||||||
if helmCacheMaxSize > 0 {
|
if helmCacheMaxSize > 0 {
|
||||||
|
@ -249,15 +269,16 @@ func main() {
|
||||||
cacheRecorder := cache.MustMakeMetrics()
|
cacheRecorder := cache.MustMakeMetrics()
|
||||||
|
|
||||||
if err = (&controllers.HelmChartReconciler{
|
if err = (&controllers.HelmChartReconciler{
|
||||||
Client: mgr.GetClient(),
|
Client: mgr.GetClient(),
|
||||||
Storage: storage,
|
RegistryClientGenerator: util.RegistryClientGenerator,
|
||||||
Getters: getters,
|
Storage: storage,
|
||||||
EventRecorder: eventRecorder,
|
Getters: getters,
|
||||||
Metrics: metricsH,
|
EventRecorder: eventRecorder,
|
||||||
ControllerName: controllerName,
|
Metrics: metricsH,
|
||||||
Cache: c,
|
ControllerName: controllerName,
|
||||||
TTL: ttl,
|
Cache: c,
|
||||||
CacheRecorder: cacheRecorder,
|
TTL: ttl,
|
||||||
|
CacheRecorder: cacheRecorder,
|
||||||
}).SetupWithManagerAndOptions(mgr, controllers.HelmChartReconcilerOptions{
|
}).SetupWithManagerAndOptions(mgr, controllers.HelmChartReconcilerOptions{
|
||||||
MaxConcurrentReconciles: concurrent,
|
MaxConcurrentReconciles: concurrent,
|
||||||
RateLimiter: helper.GetRateLimiter(rateLimiterOptions),
|
RateLimiter: helper.GetRateLimiter(rateLimiterOptions),
|
||||||
|
|
Loading…
Reference in New Issue