Factor various bits out of reconciler

This commit moves various generic bits out of the reconciler into
separate modules, while adding more test coverage.

Some of the logic around merging chart values from references has been
improved to work with `client.Object`, instead of two separate maps.

In addition, the option to override the hostname of an Artifact has
been removed. It was undocumented and for testing purposes only, which
these days can be better achieved by e.g. configuring the
`--storage-adv-addr`.

Signed-off-by: Hidde Beydals <hello@hidde.co>
This commit is contained in:
Hidde Beydals 2022-05-05 18:36:31 +02:00
parent 206011643f
commit ae4f499e87
19 changed files with 1750 additions and 908 deletions

View File

@ -20,16 +20,13 @@ import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/hashicorp/go-retryablehttp"
"helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/chartutil"
"helm.sh/helm/v3/pkg/storage/driver"
"helm.sh/helm/v3/pkg/strvals"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
apimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
@ -56,11 +53,12 @@ import (
"github.com/fluxcd/pkg/runtime/events"
"github.com/fluxcd/pkg/runtime/metrics"
"github.com/fluxcd/pkg/runtime/predicates"
"github.com/fluxcd/pkg/runtime/transform"
sourcev1 "github.com/fluxcd/source-controller/api/v1beta2"
v2 "github.com/fluxcd/helm-controller/api/v2beta1"
intchartutil "github.com/fluxcd/helm-controller/internal/chartutil"
"github.com/fluxcd/helm-controller/internal/kube"
"github.com/fluxcd/helm-controller/internal/loader"
"github.com/fluxcd/helm-controller/internal/runner"
"github.com/fluxcd/helm-controller/internal/util"
)
@ -75,15 +73,21 @@ import (
// HelmReleaseReconciler reconciles a HelmRelease object
type HelmReleaseReconciler struct {
client.Client
httpClient *retryablehttp.Client
Config *rest.Config
Scheme *runtime.Scheme
requeueDependency time.Duration
EventRecorder kuberecorder.EventRecorder
MetricsRecorder *metrics.Recorder
DefaultServiceAccount string
NoCrossNamespaceRef bool
KubeConfigOpts fluxClient.KubeConfigOptions
httpClient *retryablehttp.Client
Config *rest.Config
Scheme *runtime.Scheme
requeueDependency time.Duration
EventRecorder kuberecorder.EventRecorder
MetricsRecorder *metrics.Recorder
NoCrossNamespaceRef bool
KubeConfigOpts fluxClient.KubeConfigOptions
}
type HelmReleaseReconcilerOptions struct {
MaxConcurrentReconciles int
HTTPRetry int
DependencyRequeueInterval time.Duration
RateLimiter ratelimiter.RateLimiter
}
func (r *HelmReleaseReconciler) SetupWithManager(mgr ctrl.Manager, opts HelmReleaseReconcilerOptions) error {
@ -261,14 +265,14 @@ func (r *HelmReleaseReconciler) reconcile(ctx context.Context, hr v2.HelmRelease
}
// Compose values
values, err := r.composeValues(ctx, hr)
values, err := intchartutil.ChartValuesFromReferences(ctx, r.Client, hr.Namespace, hr.GetValues(), hr.Spec.ValuesFrom...)
if err != nil {
r.event(ctx, hr, hr.Status.LastAttemptedRevision, events.EventSeverityError, err.Error())
return v2.HelmReleaseNotReady(hr, v2.InitFailedReason, err.Error()), ctrl.Result{Requeue: true}, nil
}
// Load chart from artifact
chart, err := r.loadHelmChart(hc)
chart, err := loader.SecureLoadChartFromURL(r.httpClient, hc.GetArtifact().URL, hc.GetArtifact().Checksum)
if err != nil {
r.event(ctx, hr, hr.Status.LastAttemptedRevision, events.EventSeverityError, err.Error())
return v2.HelmReleaseNotReady(hr, v2.ArtifactFailedReason, err.Error()), ctrl.Result{Requeue: true}, nil
@ -283,19 +287,12 @@ func (r *HelmReleaseReconciler) reconcile(ctx context.Context, hr v2.HelmRelease
return reconciledHr, ctrl.Result{RequeueAfter: hr.Spec.Interval.Duration}, reconcileErr
}
type HelmReleaseReconcilerOptions struct {
MaxConcurrentReconciles int
HTTPRetry int
DependencyRequeueInterval time.Duration
RateLimiter ratelimiter.RateLimiter
}
func (r *HelmReleaseReconciler) reconcileRelease(ctx context.Context,
hr v2.HelmRelease, chart *chart.Chart, values chartutil.Values) (v2.HelmRelease, error) {
log := ctrl.LoggerFrom(ctx)
// Initialize Helm action runner
getter, err := r.getRESTClientGetter(ctx, hr)
getter, err := r.buildRESTClientGetter(ctx, hr)
if err != nil {
return v2.HelmReleaseNotReady(hr, v2.InitFailedReason, err.Error()), err
}
@ -472,23 +469,11 @@ func (r *HelmReleaseReconciler) checkDependencies(hr v2.HelmRelease) error {
return nil
}
func (r *HelmReleaseReconciler) setImpersonationConfig(restConfig *rest.Config, hr v2.HelmRelease) string {
name := r.DefaultServiceAccount
if sa := hr.Spec.ServiceAccountName; sa != "" {
name = sa
func (r *HelmReleaseReconciler) buildRESTClientGetter(ctx context.Context, hr v2.HelmRelease) (genericclioptions.RESTClientGetter, error) {
var opts []kube.ClientGetterOption
if hr.Spec.ServiceAccountName != "" {
opts = append(opts, kube.WithImpersonate(hr.Spec.ServiceAccountName))
}
if name != "" {
username := fmt.Sprintf("system:serviceaccount:%s:%s", hr.GetNamespace(), name)
restConfig.Impersonate = rest.ImpersonationConfig{UserName: username}
return username
}
return ""
}
func (r *HelmReleaseReconciler) getRESTClientGetter(ctx context.Context, hr v2.HelmRelease) (genericclioptions.RESTClientGetter, error) {
config := *r.Config
impersonateAccount := r.setImpersonationConfig(&config, hr)
if hr.Spec.KubeConfig != nil {
secretName := types.NamespacedName{
Namespace: hr.GetNamespace(),
@ -498,147 +483,34 @@ func (r *HelmReleaseReconciler) getRESTClientGetter(ctx context.Context, hr v2.H
if err := r.Get(ctx, secretName, &secret); err != nil {
return nil, fmt.Errorf("could not find KubeConfig secret '%s': %w", secretName, err)
}
var kubeConfig []byte
switch {
case hr.Spec.KubeConfig.SecretRef.Key != "":
key := hr.Spec.KubeConfig.SecretRef.Key
kubeConfig = secret.Data[key]
if kubeConfig == nil {
return nil, fmt.Errorf("KubeConfig secret '%s' does not contain a '%s' key with a kubeconfig", secretName, key)
}
case secret.Data["value"] != nil:
kubeConfig = secret.Data["value"]
case secret.Data["value.yaml"] != nil:
kubeConfig = secret.Data["value.yaml"]
default:
// User did not specify a key, and the 'value' key was not defined.
return nil, fmt.Errorf("KubeConfig secret '%s' does not contain a 'value' key with a kubeconfig", secretName)
kubeConfig, err := kube.ConfigFromSecret(&secret, hr.Spec.KubeConfig.SecretRef.Key)
if err != nil {
return nil, err
}
return kube.NewMemoryRESTClientGetter(kubeConfig, hr.GetReleaseNamespace(), impersonateAccount, r.Config.QPS, r.Config.Burst, r.KubeConfigOpts), nil
opts = append(opts, kube.WithKubeConfig(kubeConfig, r.Config.QPS, r.Config.Burst, r.KubeConfigOpts))
}
if r.DefaultServiceAccount != "" || hr.Spec.ServiceAccountName != "" {
return kube.NewInClusterRESTClientGetter(&config, hr.GetReleaseNamespace()), nil
}
return kube.NewInClusterRESTClientGetter(r.Config, hr.GetReleaseNamespace()), nil
return kube.BuildClientGetter(r.Config, hr.GetReleaseNamespace(), opts...), nil
}
// composeValues attempts to resolve all v2beta1.ValuesReference resources
// and merges them as defined. Referenced resources are only retrieved once
// to ensure a single version is taken into account during the merge.
func (r *HelmReleaseReconciler) composeValues(ctx context.Context, hr v2.HelmRelease) (chartutil.Values, error) {
result := chartutil.Values{}
configMaps := make(map[string]*corev1.ConfigMap)
secrets := make(map[string]*corev1.Secret)
for _, v := range hr.Spec.ValuesFrom {
namespacedName := types.NamespacedName{Namespace: hr.Namespace, Name: v.Name}
var valuesData []byte
switch v.Kind {
case "ConfigMap":
resource, ok := configMaps[namespacedName.String()]
if !ok {
// The resource may not exist, but we want to act on a single version
// of the resource in case the values reference is marked as optional.
configMaps[namespacedName.String()] = nil
resource = &corev1.ConfigMap{}
if err := r.Get(ctx, namespacedName, resource); err != nil {
if apierrors.IsNotFound(err) {
if v.Optional {
(ctrl.LoggerFrom(ctx)).
Info(fmt.Sprintf("could not find optional %s '%s'", v.Kind, namespacedName))
continue
}
return nil, fmt.Errorf("could not find %s '%s'", v.Kind, namespacedName)
}
return nil, err
}
configMaps[namespacedName.String()] = resource
}
if resource == nil {
if v.Optional {
(ctrl.LoggerFrom(ctx)).Info(fmt.Sprintf("could not find optional %s '%s'", v.Kind, namespacedName))
continue
}
return nil, fmt.Errorf("could not find %s '%s'", v.Kind, namespacedName)
}
if data, ok := resource.Data[v.GetValuesKey()]; !ok {
return nil, fmt.Errorf("missing key '%s' in %s '%s'", v.GetValuesKey(), v.Kind, namespacedName)
} else {
valuesData = []byte(data)
}
case "Secret":
resource, ok := secrets[namespacedName.String()]
if !ok {
// The resource may not exist, but we want to act on a single version
// of the resource in case the values reference is marked as optional.
secrets[namespacedName.String()] = nil
resource = &corev1.Secret{}
if err := r.Get(ctx, namespacedName, resource); err != nil {
if apierrors.IsNotFound(err) {
if v.Optional {
(ctrl.LoggerFrom(ctx)).
Info(fmt.Sprintf("could not find optional %s '%s'", v.Kind, namespacedName))
continue
}
return nil, fmt.Errorf("could not find %s '%s'", v.Kind, namespacedName)
}
return nil, err
}
secrets[namespacedName.String()] = resource
}
if resource == nil {
if v.Optional {
(ctrl.LoggerFrom(ctx)).Info(fmt.Sprintf("could not find optional %s '%s'", v.Kind, namespacedName))
continue
}
return nil, fmt.Errorf("could not find %s '%s'", v.Kind, namespacedName)
}
if data, ok := resource.Data[v.GetValuesKey()]; !ok {
return nil, fmt.Errorf("missing key '%s' in %s '%s'", v.GetValuesKey(), v.Kind, namespacedName)
} else {
valuesData = data
}
default:
return nil, fmt.Errorf("unsupported ValuesReference kind '%s'", v.Kind)
}
switch v.TargetPath {
case "":
values, err := chartutil.ReadValues(valuesData)
if err != nil {
return nil, fmt.Errorf("unable to read values from key '%s' in %s '%s': %w", v.GetValuesKey(), v.Kind, namespacedName, err)
}
result = transform.MergeMaps(result, values)
default:
// TODO(hidde): this is a bit of hack, as it mimics the way the option string is passed
// to Helm from a CLI perspective. Given the parser is however not publicly accessible
// while it contains all logic around parsing the target path, it is a fair trade-off.
stringValuesData := string(valuesData)
const singleQuote = "'"
const doubleQuote = "\""
var err error
if (strings.HasPrefix(stringValuesData, singleQuote) && strings.HasSuffix(stringValuesData, singleQuote)) || (strings.HasPrefix(stringValuesData, doubleQuote) && strings.HasSuffix(stringValuesData, doubleQuote)) {
stringValuesData = strings.Trim(stringValuesData, singleQuote+doubleQuote)
singleValue := v.TargetPath + "=" + stringValuesData
err = strvals.ParseIntoString(singleValue, result)
} else {
singleValue := v.TargetPath + "=" + stringValuesData
err = strvals.ParseInto(singleValue, result)
}
if err != nil {
return nil, fmt.Errorf("unable to merge value from key '%s' in %s '%s' into target path '%s': %w", v.GetValuesKey(), v.Kind, namespacedName, v.TargetPath, err)
}
}
// getHelmChart retrieves the v1beta2.HelmChart for the given
// v2beta1.HelmRelease using the name that is advertised in the status
// object. It returns the v1beta2.HelmChart, or an error.
func (r *HelmReleaseReconciler) getHelmChart(ctx context.Context, hr *v2.HelmRelease) (*sourcev1.HelmChart, error) {
namespace, name := hr.Status.GetHelmChart()
chartName := types.NamespacedName{Namespace: namespace, Name: name}
if r.NoCrossNamespaceRef && chartName.Namespace != hr.Namespace {
return nil, acl.AccessDeniedError(fmt.Sprintf("can't access '%s/%s', cross-namespace references have been blocked",
hr.Spec.Chart.Spec.SourceRef.Kind, types.NamespacedName{
Namespace: hr.Spec.Chart.Spec.SourceRef.Namespace,
Name: hr.Spec.Chart.Spec.SourceRef.Name,
}))
}
return transform.MergeMaps(result, hr.GetValues()), nil
hc := sourcev1.HelmChart{}
if err := r.Client.Get(ctx, chartName, &hc); err != nil {
return nil, err
}
return &hc, nil
}
// reconcileDelete deletes the v1beta2.HelmChart of the v2beta1.HelmRelease,
@ -648,7 +520,7 @@ func (r *HelmReleaseReconciler) reconcileDelete(ctx context.Context, hr v2.HelmR
// Only uninstall the Helm Release if the resource is not suspended.
if !hr.Spec.Suspend {
getter, err := r.getRESTClientGetter(ctx, hr)
getter, err := r.buildRESTClientGetter(ctx, hr)
if err != nil {
return ctrl.Result{}, err
}

View File

@ -1,126 +0,0 @@
/*
Copyright 2020 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 (
"bytes"
"context"
"crypto/sha256"
"fmt"
"io"
"net/http"
"net/url"
"os"
"github.com/fluxcd/pkg/runtime/acl"
sourcev1 "github.com/fluxcd/source-controller/api/v1beta2"
"github.com/hashicorp/go-retryablehttp"
"helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/chart/loader"
"k8s.io/apimachinery/pkg/types"
v2 "github.com/fluxcd/helm-controller/api/v2beta1"
)
const (
// EnvArtifactHostOverwrite can be used to overwrite the hostname.
// The main purpose is while running controllers locally with e.g. mocked
// storage data during development.
EnvArtifactHostOverwrite = "ARTIFACT_HOST_OVERWRITE"
)
// getHelmChart retrieves the v1beta2.HelmChart for the given
// v2beta1.HelmRelease using the name that is advertised in the status
// object. It returns the v1beta2.HelmChart, or an error.
func (r *HelmReleaseReconciler) getHelmChart(ctx context.Context, hr *v2.HelmRelease) (*sourcev1.HelmChart, error) {
namespace, name := hr.Status.GetHelmChart()
chartName := types.NamespacedName{Namespace: namespace, Name: name}
if r.NoCrossNamespaceRef && chartName.Namespace != hr.Namespace {
return nil, acl.AccessDeniedError(fmt.Sprintf("can't access '%s/%s', cross-namespace references have been blocked",
hr.Spec.Chart.Spec.SourceRef.Kind, types.NamespacedName{
Namespace: hr.Spec.Chart.Spec.SourceRef.Namespace,
Name: hr.Spec.Chart.Spec.SourceRef.Name,
}))
}
hc := sourcev1.HelmChart{}
if err := r.Client.Get(ctx, chartName, &hc); err != nil {
return nil, err
}
return &hc, nil
}
// loadHelmChart attempts to download the advertised v1beta2.Artifact from the
// provided v1beta2.HelmChart. The SHA256 sum of the Artifact is confirmed to
// equal to the checksum of the retrieved bytes before loading the chart.
// It returns the loaded chart.Chart, or an error.
func (r *HelmReleaseReconciler) loadHelmChart(source *sourcev1.HelmChart) (*chart.Chart, error) {
artifactURL := source.GetArtifact().URL
if hostname := os.Getenv(EnvArtifactHostOverwrite); hostname != "" {
if replacedArtifactURL, err := replaceHostname(artifactURL, hostname); err == nil {
artifactURL = replacedArtifactURL
}
}
req, err := retryablehttp.NewRequest(http.MethodGet, artifactURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create a new request for artifact '%s': %w", source.GetArtifact().URL, err)
}
resp, err := r.httpClient.Do(req)
if err != nil || resp != nil && resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("artifact '%s' download failed: %w", source.GetArtifact().URL, err)
}
var c bytes.Buffer
if err := copyAndVerifyArtifact(source.GetArtifact(), resp.Body, &c); err != nil {
return nil, fmt.Errorf("artifact '%s' download failed: %w", source.GetArtifact().URL, err)
}
if err := resp.Body.Close(); err != nil {
return nil, fmt.Errorf("artifact '%s' download failed: %w", source.GetArtifact().URL, err)
}
return loader.LoadArchive(&c)
}
// copyAndVerifyArtifact copies from reader into writer while confirming the
// SHA256 checksum of the copied data matches the checksum from the provided
// v1beta2.Artifact. If this does not match, it returns an error.
func copyAndVerifyArtifact(artifact *sourcev1.Artifact, reader io.Reader, writer io.Writer) error {
hasher := sha256.New()
mw := io.MultiWriter(hasher, writer)
if _, err := io.Copy(mw, reader); err != nil {
return fmt.Errorf("failed to verify artifact: %w", err)
}
if checksum := fmt.Sprintf("%x", hasher.Sum(nil)); checksum != artifact.Checksum {
return fmt.Errorf("failed to verify artifact: computed checksum '%s' doesn't match advertised '%s'",
checksum, artifact.Checksum)
}
return nil
}
// replaceHostname parses the given URL and replaces the Host in the parsed
// result with the provided hostname. It returns the string result, or an
// error.
func replaceHostname(URL, hostname string) (string, error) {
parsedURL, err := url.Parse(URL)
if err != nil {
return "", err
}
parsedURL.Host = hostname
return parsedURL.String(), nil
}

View File

@ -1,326 +0,0 @@
/*
Copyright 2020 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 (
"bytes"
"context"
"crypto/sha256"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
v2 "github.com/fluxcd/helm-controller/api/v2beta1"
sourcev1 "github.com/fluxcd/source-controller/api/v1beta2"
"github.com/hashicorp/go-retryablehttp"
. "github.com/onsi/gomega"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
)
func TestHelmReleaseReconciler_getHelmChart(t *testing.T) {
g := NewWithT(t)
scheme := runtime.NewScheme()
g.Expect(v2.AddToScheme(scheme)).To(Succeed())
g.Expect(sourcev1.AddToScheme(scheme)).To(Succeed())
chart := &sourcev1.HelmChart{
ObjectMeta: metav1.ObjectMeta{
Namespace: "some-namespace",
Name: "some-chart-name",
},
}
tests := []struct {
name string
rel *v2.HelmRelease
chart *sourcev1.HelmChart
expectChart bool
wantErr bool
disallowCrossNS bool
}{
{
name: "retrieves HelmChart object from Status",
rel: &v2.HelmRelease{
Status: v2.HelmReleaseStatus{
HelmChart: "some-namespace/some-chart-name",
},
},
chart: chart,
expectChart: true,
},
{
name: "no HelmChart found",
rel: &v2.HelmRelease{
Status: v2.HelmReleaseStatus{
HelmChart: "some-namespace/some-chart-name",
},
},
chart: nil,
expectChart: false,
wantErr: true,
},
{
name: "no HelmChart in Status",
rel: &v2.HelmRelease{
Status: v2.HelmReleaseStatus{
HelmChart: "",
},
},
chart: chart,
expectChart: false,
wantErr: true,
},
{
name: "ACL disallows cross namespace",
rel: &v2.HelmRelease{
ObjectMeta: metav1.ObjectMeta{
Namespace: "default",
},
Status: v2.HelmReleaseStatus{
HelmChart: "some-namespace/some-chart-name",
},
},
chart: chart,
expectChart: false,
wantErr: true,
disallowCrossNS: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
builder := fake.NewClientBuilder()
builder.WithScheme(scheme)
if tt.chart != nil {
builder.WithObjects(tt.chart)
}
r := &HelmReleaseReconciler{
Client: builder.Build(),
EventRecorder: record.NewFakeRecorder(32),
NoCrossNamespaceRef: tt.disallowCrossNS,
}
got, err := r.getHelmChart(context.TODO(), tt.rel)
if tt.wantErr {
g.Expect(err).To(HaveOccurred())
g.Expect(got).To(BeNil())
return
}
g.Expect(err).ToNot(HaveOccurred())
expect := g.Expect(got.ObjectMeta)
if tt.expectChart {
expect.To(BeEquivalentTo(tt.chart.ObjectMeta))
} else {
expect.To(BeNil())
}
})
}
}
func TestHelmReleaseReconciler_loadHelmChart(t *testing.T) {
g := NewWithT(t)
b, err := os.ReadFile("testdata/chart-0.1.0.tgz")
g.Expect(err).ToNot(HaveOccurred())
g.Expect(b).ToNot(BeNil())
checksum := fmt.Sprintf("%x", sha256.Sum256(b))
const chartPath = "/chart.tgz"
server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
if req.URL.Path == chartPath {
res.WriteHeader(http.StatusOK)
_, _ = res.Write(b)
return
}
res.WriteHeader(http.StatusInternalServerError)
return
}))
t.Cleanup(func() {
server.Close()
})
chartURL := server.URL + chartPath
client := retryablehttp.NewClient()
client.Logger = nil
client.RetryMax = 2
t.Run("loads HelmChart from Artifact URL", func(t *testing.T) {
g := NewWithT(t)
r := &HelmReleaseReconciler{
Client: fake.NewClientBuilder().Build(),
EventRecorder: record.NewFakeRecorder(32),
httpClient: client,
}
got, err := r.loadHelmChart(&sourcev1.HelmChart{
Status: sourcev1.HelmChartStatus{
Artifact: &sourcev1.Artifact{
URL: chartURL,
Checksum: checksum,
},
},
})
g.Expect(err).ToNot(HaveOccurred())
g.Expect(got).ToNot(BeNil())
g.Expect(got.Name()).To(Equal("chart"))
g.Expect(got.Metadata.Version).To(Equal("0.1.0"))
})
t.Run("error on Artifact checksum mismatch", func(t *testing.T) {
g := NewWithT(t)
r := &HelmReleaseReconciler{
Client: fake.NewClientBuilder().Build(),
EventRecorder: record.NewFakeRecorder(32),
httpClient: client,
}
got, err := r.loadHelmChart(&sourcev1.HelmChart{
Status: sourcev1.HelmChartStatus{
Artifact: &sourcev1.Artifact{
URL: chartURL,
Checksum: "",
},
},
})
g.Expect(err).To(HaveOccurred())
g.Expect(got).To(BeNil())
})
t.Run("error on server error", func(t *testing.T) {
g := NewWithT(t)
r := &HelmReleaseReconciler{
Client: fake.NewClientBuilder().Build(),
EventRecorder: record.NewFakeRecorder(32),
httpClient: client,
}
got, err := r.loadHelmChart(&sourcev1.HelmChart{
Status: sourcev1.HelmChartStatus{
Artifact: &sourcev1.Artifact{
URL: server.URL + "/invalid.tgz",
Checksum: "",
},
},
})
g.Expect(err).To(HaveOccurred())
g.Expect(got).To(BeNil())
})
t.Run("EnvArtifactHostOverwrite overwrites Artifact hostname", func(t *testing.T) {
g := NewWithT(t)
t.Setenv(EnvArtifactHostOverwrite, strings.TrimPrefix(server.URL, "http://"))
r := &HelmReleaseReconciler{
Client: fake.NewClientBuilder().Build(),
EventRecorder: record.NewFakeRecorder(32),
httpClient: client,
}
got, err := r.loadHelmChart(&sourcev1.HelmChart{
Status: sourcev1.HelmChartStatus{
Artifact: &sourcev1.Artifact{
URL: "http://example.com" + chartPath,
Checksum: checksum,
},
},
})
g.Expect(err).To(Not(HaveOccurred()))
g.Expect(got).ToNot(BeNil())
})
}
func Test_copyAndVerifyArtifact(t *testing.T) {
g := NewWithT(t)
tmpDir := t.TempDir()
closedF, err := os.CreateTemp(tmpDir, "closed.txt")
g.Expect(err).ToNot(HaveOccurred())
g.Expect(closedF.Close()).ToNot(HaveOccurred())
tests := []struct {
name string
checksum string
in io.Reader
out io.Writer
wantErr bool
}{
{
name: "checksum match",
checksum: "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
in: bytes.NewReader([]byte("foo")),
out: io.Discard,
},
{
name: "checksum mismatch",
checksum: "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
in: bytes.NewReader([]byte("bar")),
out: io.Discard,
wantErr: true,
},
{
name: "copy failure (closed file)",
checksum: "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
in: bytes.NewReader([]byte("foo")),
out: closedF,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
err := copyAndVerifyArtifact(&sourcev1.Artifact{Checksum: tt.checksum}, tt.in, tt.out)
g.Expect(err != nil).To(Equal(tt.wantErr), err)
})
}
}
func Test_replaceHostname(t *testing.T) {
tests := []struct {
name string
URL string
hostname string
want string
wantErr bool
}{
{"hostname overwrite", "https://example.com/file.txt", "overwrite.com", "https://overwrite.com/file.txt", false},
{"hostname overwrite with port", "https://example.com:8080/file.txt", "overwrite.com:6666", "https://overwrite.com:6666/file.txt", false},
{"invalid url", ":malformed./com", "", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
got, err := replaceHostname(tt.URL, tt.hostname)
if tt.wantErr {
g.Expect(err).To(HaveOccurred())
g.Expect(got).To(BeEmpty())
return
}
g.Expect(err).ToNot(HaveOccurred())
g.Expect(got).To(Equal(tt.want))
})
}
}

View File

@ -18,276 +18,114 @@ package controllers
import (
"context"
"reflect"
"testing"
"github.com/go-logr/logr"
"helm.sh/helm/v3/pkg/chartutil"
corev1 "k8s.io/api/core/v1"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
v2 "github.com/fluxcd/helm-controller/api/v2beta1"
sourcev1 "github.com/fluxcd/source-controller/api/v1beta2"
. "github.com/onsi/gomega"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/yaml"
v2 "github.com/fluxcd/helm-controller/api/v2beta1"
)
func TestHelmReleaseReconciler_composeValues(t *testing.T) {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
_ = v2.AddToScheme(scheme)
func TestHelmReleaseReconciler_getHelmChart(t *testing.T) {
g := NewWithT(t)
tests := []struct {
name string
resources []runtime.Object
references []v2.ValuesReference
values string
want chartutil.Values
wantErr bool
}{
{
name: "merges",
resources: []runtime.Object{
valuesConfigMap("values", map[string]string{
"values.yaml": `flat: value
nested:
configuration: value
`,
}),
valuesSecret("values", map[string][]byte{
"values.yaml": []byte(`flat:
nested: value
nested: value
`),
}),
},
references: []v2.ValuesReference{
{
Kind: "ConfigMap",
Name: "values",
},
{
Kind: "Secret",
Name: "values",
},
},
values: `
other: values
`,
want: chartutil.Values{
"flat": map[string]interface{}{
"nested": "value",
},
"nested": "value",
"other": "values",
},
},
{
name: "target path",
resources: []runtime.Object{
valuesSecret("values", map[string][]byte{"single": []byte("value")}),
},
references: []v2.ValuesReference{
{
Kind: "Secret",
Name: "values",
ValuesKey: "single",
TargetPath: "merge.at.specific.path",
},
},
want: chartutil.Values{
"merge": map[string]interface{}{
"at": map[string]interface{}{
"specific": map[string]interface{}{
"path": "value",
},
},
},
},
},
{
name: "target path with boolean value",
resources: []runtime.Object{
valuesSecret("values", map[string][]byte{"single": []byte("true")}),
},
references: []v2.ValuesReference{
{
Kind: "Secret",
Name: "values",
ValuesKey: "single",
TargetPath: "merge.at.specific.path",
},
},
want: chartutil.Values{
"merge": map[string]interface{}{
"at": map[string]interface{}{
"specific": map[string]interface{}{
"path": true,
},
},
},
},
},
{
name: "target path with set-string behavior",
resources: []runtime.Object{
valuesSecret("values", map[string][]byte{"single": []byte("\"true\"")}),
},
references: []v2.ValuesReference{
{
Kind: "Secret",
Name: "values",
ValuesKey: "single",
TargetPath: "merge.at.specific.path",
},
},
want: chartutil.Values{
"merge": map[string]interface{}{
"at": map[string]interface{}{
"specific": map[string]interface{}{
"path": "true",
},
},
},
},
},
{
name: "values reference to non existing secret",
references: []v2.ValuesReference{
{
Kind: "Secret",
Name: "missing",
},
},
wantErr: true,
},
{
name: "optional values reference to non existing secret",
references: []v2.ValuesReference{
{
Kind: "Secret",
Name: "missing",
Optional: true,
},
},
want: chartutil.Values{},
wantErr: false,
},
{
name: "values reference to non existing config map",
references: []v2.ValuesReference{
{
Kind: "ConfigMap",
Name: "missing",
},
},
wantErr: true,
},
{
name: "optional values reference to non existing config map",
references: []v2.ValuesReference{
{
Kind: "ConfigMap",
Name: "missing",
Optional: true,
},
},
want: chartutil.Values{},
wantErr: false,
},
{
name: "missing secret key",
resources: []runtime.Object{
valuesSecret("values", nil),
},
references: []v2.ValuesReference{
{
Kind: "Secret",
Name: "values",
ValuesKey: "nonexisting",
},
},
wantErr: true,
},
{
name: "missing config map key",
resources: []runtime.Object{
valuesConfigMap("values", nil),
},
references: []v2.ValuesReference{
{
Kind: "ConfigMap",
Name: "values",
ValuesKey: "nonexisting",
},
},
wantErr: true,
},
{
name: "unsupported values reference kind",
references: []v2.ValuesReference{
{
Kind: "Unsupported",
},
},
wantErr: true,
},
{
name: "invalid values",
resources: []runtime.Object{
valuesConfigMap("values", map[string]string{
"values.yaml": `
invalid`,
}),
},
references: []v2.ValuesReference{
{
Kind: "ConfigMap",
Name: "values",
},
},
wantErr: true,
scheme := runtime.NewScheme()
g.Expect(v2.AddToScheme(scheme)).To(Succeed())
g.Expect(sourcev1.AddToScheme(scheme)).To(Succeed())
chart := &sourcev1.HelmChart{
ObjectMeta: metav1.ObjectMeta{
Namespace: "some-namespace",
Name: "some-chart-name",
},
}
tests := []struct {
name string
rel *v2.HelmRelease
chart *sourcev1.HelmChart
expectChart bool
wantErr bool
disallowCrossNS bool
}{
{
name: "retrieves HelmChart object from Status",
rel: &v2.HelmRelease{
Status: v2.HelmReleaseStatus{
HelmChart: "some-namespace/some-chart-name",
},
},
chart: chart,
expectChart: true,
},
{
name: "no HelmChart found",
rel: &v2.HelmRelease{
Status: v2.HelmReleaseStatus{
HelmChart: "some-namespace/some-chart-name",
},
},
chart: nil,
expectChart: false,
wantErr: true,
},
{
name: "no HelmChart in Status",
rel: &v2.HelmRelease{
Status: v2.HelmReleaseStatus{
HelmChart: "",
},
},
chart: chart,
expectChart: false,
wantErr: true,
},
{
name: "ACL disallows cross namespace",
rel: &v2.HelmRelease{
ObjectMeta: metav1.ObjectMeta{
Namespace: "default",
},
Status: v2.HelmReleaseStatus{
HelmChart: "some-namespace/some-chart-name",
},
},
chart: chart,
expectChart: false,
wantErr: true,
disallowCrossNS: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := fake.NewFakeClientWithScheme(scheme, tt.resources...)
r := &HelmReleaseReconciler{Client: c}
var values *apiextensionsv1.JSON
if tt.values != "" {
v, _ := yaml.YAMLToJSON([]byte(tt.values))
values = &apiextensionsv1.JSON{Raw: v}
builder := fake.NewClientBuilder()
builder.WithScheme(scheme)
if tt.chart != nil {
builder.WithObjects(tt.chart)
}
hr := v2.HelmRelease{
Spec: v2.HelmReleaseSpec{
ValuesFrom: tt.references,
Values: values,
},
r := &HelmReleaseReconciler{
Client: builder.Build(),
EventRecorder: record.NewFakeRecorder(32),
NoCrossNamespaceRef: tt.disallowCrossNS,
}
got, err := r.composeValues(logr.NewContext(context.TODO(), logr.Discard()), hr)
if (err != nil) != tt.wantErr {
t.Errorf("composeValues() error = %v, wantErr %v", err, tt.wantErr)
got, err := r.getHelmChart(context.TODO(), tt.rel)
if tt.wantErr {
g.Expect(err).To(HaveOccurred())
g.Expect(got).To(BeNil())
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("composeValues() got = %v, want %v", got, tt.want)
g.Expect(err).ToNot(HaveOccurred())
expect := g.Expect(got.ObjectMeta)
if tt.expectChart {
expect.To(BeEquivalentTo(tt.chart.ObjectMeta))
} else {
expect.To(BeNil())
}
})
}
}
func valuesSecret(name string, data map[string][]byte) *corev1.Secret {
return &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: name},
Data: data,
}
}
func valuesConfigMap(name string, data map[string]string) *corev1.ConfigMap {
return &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: name},
Data: data,
}
}

2
go.mod
View File

@ -24,6 +24,7 @@ require (
k8s.io/apimachinery v0.23.6
k8s.io/cli-runtime v0.23.6
k8s.io/client-go v0.23.6
k8s.io/utils v0.0.0-20220210201930-3a6ce19ff2f9
sigs.k8s.io/controller-runtime v0.11.2
sigs.k8s.io/kustomize/api v0.11.4
sigs.k8s.io/yaml v1.3.0
@ -165,7 +166,6 @@ require (
k8s.io/klog/v2 v2.50.0 // indirect
k8s.io/kube-openapi v0.0.0-20211115234752-e816edb12b65 // indirect
k8s.io/kubectl v0.23.5 // indirect
k8s.io/utils v0.0.0-20220210201930-3a6ce19ff2f9 // indirect
oras.land/oras-go v1.1.1 // indirect
sigs.k8s.io/json v0.0.0-20211208200746-9f7c6b3444d2 // indirect
sigs.k8s.io/kustomize/kyaml v0.13.6 // indirect

View File

@ -0,0 +1,271 @@
/*
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 chartutil
import (
"context"
"errors"
"fmt"
"strings"
"helm.sh/helm/v3/pkg/chartutil"
"helm.sh/helm/v3/pkg/strvals"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
kubeclient "sigs.k8s.io/controller-runtime/pkg/client"
"github.com/fluxcd/pkg/runtime/transform"
"github.com/fluxcd/helm-controller/api/v2beta1"
)
// ErrValuesRefReason is the descriptive reason for an ErrValuesReference.
type ErrValuesRefReason error
var (
// ErrResourceNotFound signals the referenced values resource could not be
// found.
ErrResourceNotFound = errors.New("resource not found")
// ErrKeyNotFound signals the key could not be found in the referenced
// values resource.
ErrKeyNotFound = errors.New("key not found")
// ErrUnsupportedRefKind signals the values reference kind is not
// supported.
ErrUnsupportedRefKind = errors.New("unsupported values reference kind")
// ErrValuesDataRead signals the referenced resource's values data could
// not be read.
ErrValuesDataRead = errors.New("failed to read values data")
// ErrValueMerge signals a single value could not be merged into the
// values.
ErrValueMerge = errors.New("failed to merge value")
// ErrUnknown signals the reason an error occurred is unknown.
ErrUnknown = errors.New("unknown error")
)
// ErrValuesReference is returned by ChartValuesFromReferences
type ErrValuesReference struct {
// Reason for the values reference error. Nil equals ErrUnknown.
// Can be used with Is to reason about a returned error:
// err := &ErrValuesReference{Reason: ErrResourceNotFound, ...}
// errors.Is(err, ErrResourceNotFound)
Reason ErrValuesRefReason
// Kind of the values reference the error is being reported for.
Kind string
// Name of the values reference the error is being reported for.
Name types.NamespacedName
// Key of the values reference the error is being reported for.
Key string
// Optional indicates if the error is being reported for an optional values
// reference.
Optional bool
// Err contains the further error chain leading to this error, it can be
// nil.
Err error
}
// Error returns an error string constructed out of the state of
// ErrValuesReference.
func (e *ErrValuesReference) Error() string {
b := strings.Builder{}
b.WriteString("could not resolve")
if e.Optional {
b.WriteString(" optional")
}
if kind := e.Kind; kind != "" {
b.WriteString(" " + kind)
}
b.WriteString(" chart values reference")
if name := e.Name.String(); name != "" {
b.WriteString(fmt.Sprintf(" '%s'", name))
}
if key := e.Key; key != "" {
b.WriteString(fmt.Sprintf(" with key '%s'", key))
}
reason := e.Reason.Error()
if reason == "" && e.Err == nil {
reason = ErrUnknown.Error()
}
if e.Err != nil {
reason = e.Err.Error()
}
b.WriteString(": " + reason)
return b.String()
}
// Is returns if target == Reason, or target == Err.
// Can be used to Reason about a returned error:
// err := &ErrValuesReference{Reason: ErrResourceNotFound, ...}
// errors.Is(err, ErrResourceNotFound)
func (e *ErrValuesReference) Is(target error) bool {
reason := e.Reason
if reason == nil {
reason = ErrUnknown
}
if reason == target {
return true
}
return errors.Is(e.Err, target)
}
// Unwrap returns the wrapped Err.
func (e *ErrValuesReference) Unwrap() error {
return e.Err
}
// NewErrValuesReference returns a new ErrValuesReference constructed from the
// provided values.
func NewErrValuesReference(name types.NamespacedName, ref v2beta1.ValuesReference, reason ErrValuesRefReason, err error) *ErrValuesReference {
return &ErrValuesReference{
Reason: reason,
Kind: ref.Kind,
Name: name,
Key: ref.GetValuesKey(),
Optional: ref.Optional,
Err: err,
}
}
const (
kindConfigMap = "ConfigMap"
kindSecret = "Secret"
)
// ChartValuesFromReferences attempts to construct new chart values by resolving
// the provided references using the client, merging them in the order given.
// If provided, the values map is merged in last. Overwriting values from
// references. It returns the merged values, or an ErrValuesReference error.
func ChartValuesFromReferences(ctx context.Context, client kubeclient.Client, namespace string,
values map[string]interface{}, refs ...v2beta1.ValuesReference) (chartutil.Values, error) {
log := ctrl.LoggerFrom(ctx)
result := chartutil.Values{}
resources := make(map[string]kubeclient.Object)
for _, ref := range refs {
namespacedName := types.NamespacedName{Namespace: namespace, Name: ref.Name}
var valuesData []byte
switch ref.Kind {
case kindConfigMap, kindSecret:
index := ref.Kind + namespacedName.String()
resource, ok := resources[index]
if !ok {
// The resource may not exist, but we want to act on a single version
// of the resource in case the values reference is marked as optional.
resources[index] = nil
switch ref.Kind {
case kindSecret:
resource = &corev1.Secret{}
case kindConfigMap:
resource = &corev1.ConfigMap{}
}
if resource != nil {
if err := client.Get(ctx, namespacedName, resource); err != nil {
if apierrors.IsNotFound(err) {
err := NewErrValuesReference(namespacedName, ref, ErrResourceNotFound, err)
if err.Optional {
log.Info(err.Error())
continue
}
return nil, err
}
return nil, err
}
resources[index] = resource
}
}
if resource == nil {
if ref.Optional {
continue
}
return nil, NewErrValuesReference(namespacedName, ref, ErrResourceNotFound, nil)
}
switch resource.(type) {
case *corev1.Secret:
data, ok := resource.(*corev1.Secret).Data[ref.GetValuesKey()]
if !ok {
err := NewErrValuesReference(namespacedName, ref, ErrKeyNotFound, nil)
if ref.Optional {
log.Info(err.Error())
continue
}
return nil, NewErrValuesReference(namespacedName, ref, ErrKeyNotFound, nil)
}
valuesData = data
case *corev1.ConfigMap:
data, ok := resource.(*corev1.ConfigMap).Data[ref.GetValuesKey()]
if !ok {
err := NewErrValuesReference(namespacedName, ref, ErrKeyNotFound, nil)
if ref.Optional {
log.Info(err.Error())
continue
}
return nil, err
}
valuesData = []byte(data)
default:
return nil, NewErrValuesReference(namespacedName, ref, ErrUnsupportedRefKind, nil)
}
default:
return nil, NewErrValuesReference(namespacedName, ref, ErrUnsupportedRefKind, nil)
}
if ref.TargetPath != "" {
// TODO(hidde): this is a bit of hack, as it mimics the way the option string is passed
// to Helm from a CLI perspective. Given the parser is however not publicly accessible
// while it contains all logic around parsing the target path, it is a fair trade-off.
if err := ReplacePathValue(result, ref.TargetPath, string(valuesData)); err != nil {
return nil, NewErrValuesReference(namespacedName, ref, ErrValueMerge, err)
}
continue
}
values, err := chartutil.ReadValues(valuesData)
if err != nil {
return nil, NewErrValuesReference(namespacedName, ref, ErrValuesDataRead, err)
}
result = transform.MergeMaps(result, values)
}
return transform.MergeMaps(result, values), nil
}
// ReplacePathValue replaces the value at the dot notation path with the given
// value using Helm's string value parser using strvals.ParseInto. Single or
// double-quoted values are merged using strvals.ParseIntoString.
func ReplacePathValue(values chartutil.Values, path string, value string) error {
const (
singleQuote = "'"
doubleQuote = `"`
)
isSingleQuoted := strings.HasPrefix(value, singleQuote) && strings.HasSuffix(value, singleQuote)
isDoubleQuoted := strings.HasPrefix(value, doubleQuote) && strings.HasSuffix(value, doubleQuote)
if isSingleQuoted || isDoubleQuoted {
value = strings.Trim(value, singleQuote+doubleQuote)
value = path + "=" + value
return strvals.ParseIntoString(value, values)
}
value = path + "=" + value
return strvals.ParseInto(value, values)
}

View File

@ -0,0 +1,390 @@
/*
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 chartutil
import (
"context"
"testing"
v2 "github.com/fluxcd/helm-controller/api/v2beta1"
"github.com/go-logr/logr"
. "github.com/onsi/gomega"
"helm.sh/helm/v3/pkg/chartutil"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
)
func TestChartValuesFromReferences(t *testing.T) {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
_ = v2.AddToScheme(scheme)
tests := []struct {
name string
resources []runtime.Object
namespace string
references []v2.ValuesReference
values string
want chartutil.Values
wantErr bool
}{
{
name: "merges",
resources: []runtime.Object{
mockConfigMap("values", map[string]string{
"values.yaml": `flat: value
nested:
configuration: value
`,
}),
mockSecret("values", map[string][]byte{
"values.yaml": []byte(`flat:
nested: value
nested: value
`),
}),
},
references: []v2.ValuesReference{
{
Kind: kindConfigMap,
Name: "values",
},
{
Kind: kindSecret,
Name: "values",
},
},
values: `
other: values
`,
want: chartutil.Values{
"flat": map[string]interface{}{
"nested": "value",
},
"nested": "value",
"other": "values",
},
},
{
name: "with target path",
resources: []runtime.Object{
mockSecret("values", map[string][]byte{"single": []byte("value")}),
},
references: []v2.ValuesReference{
{
Kind: kindSecret,
Name: "values",
ValuesKey: "single",
TargetPath: "merge.at.specific.path",
},
},
want: chartutil.Values{
"merge": map[string]interface{}{
"at": map[string]interface{}{
"specific": map[string]interface{}{
"path": "value",
},
},
},
},
},
{
name: "target path for string type array item",
resources: []runtime.Object{
mockConfigMap("values", map[string]string{
"values.yaml": `flat: value
nested:
configuration:
- list
- item
- option
`,
}),
mockSecret("values", map[string][]byte{
"values.yaml": []byte(`foo`),
}),
},
references: []v2.ValuesReference{
{
Kind: kindConfigMap,
Name: "values",
},
{
Kind: kindSecret,
Name: "values",
TargetPath: "nested.configuration[1]",
},
},
values: `
other: values
`,
want: chartutil.Values{
"flat": "value",
"nested": map[string]interface{}{
"configuration": []interface{}{"list", "foo", "option"},
},
"other": "values",
},
},
{
name: "values reference to non existing secret",
references: []v2.ValuesReference{
{
Kind: kindSecret,
Name: "missing",
},
},
wantErr: true,
},
{
name: "optional values reference to non existing secret",
references: []v2.ValuesReference{
{
Kind: kindSecret,
Name: "missing",
Optional: true,
},
},
want: chartutil.Values{},
wantErr: false,
},
{
name: "values reference to non existing config map",
references: []v2.ValuesReference{
{
Kind: kindConfigMap,
Name: "missing",
},
},
wantErr: true,
},
{
name: "optional values reference to non existing config map",
references: []v2.ValuesReference{
{
Kind: kindConfigMap,
Name: "missing",
Optional: true,
},
},
want: chartutil.Values{},
wantErr: false,
},
{
name: "missing secret key",
resources: []runtime.Object{
mockSecret("values", nil),
},
references: []v2.ValuesReference{
{
Kind: kindSecret,
Name: "values",
ValuesKey: "nonexisting",
},
},
wantErr: true,
},
{
name: "missing config map key",
resources: []runtime.Object{
mockConfigMap("values", nil),
},
references: []v2.ValuesReference{
{
Kind: kindConfigMap,
Name: "values",
ValuesKey: "nonexisting",
},
},
wantErr: true,
},
{
name: "unsupported values reference kind",
references: []v2.ValuesReference{
{
Kind: "Unsupported",
},
},
wantErr: true,
},
{
name: "invalid values",
resources: []runtime.Object{
mockConfigMap("values", map[string]string{
"values.yaml": `
invalid`,
}),
},
references: []v2.ValuesReference{
{
Kind: kindConfigMap,
Name: "values",
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
c := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(tt.resources...)
var values map[string]interface{}
if tt.values != "" {
m, err := chartutil.ReadValues([]byte(tt.values))
g.Expect(err).ToNot(HaveOccurred())
values = m
}
ctx := logr.NewContext(context.TODO(), logr.Discard())
got, err := ChartValuesFromReferences(ctx, c.Build(), tt.namespace, values, tt.references...)
if tt.wantErr {
g.Expect(err).To(HaveOccurred())
g.Expect(got).To(BeNil())
return
}
g.Expect(err).ToNot(HaveOccurred())
g.Expect(got).To(Equal(tt.want))
})
}
}
// This tests compatability with the formats described in:
// https://helm.sh/docs/intro/using_helm/#the-format-and-limitations-of---set
func TestReplacePathValue(t *testing.T) {
tests := []struct {
name string
value []byte
path string
want map[string]interface{}
wantErr bool
}{
{
name: "outer inner",
value: []byte("value"),
path: "outer.inner",
want: map[string]interface{}{
"outer": map[string]interface{}{
"inner": "value",
},
},
},
{
name: "inline list",
value: []byte("{a,b,c}"),
path: "name",
want: map[string]interface{}{
// TODO(hidde): figure out why the cap is off by len+1
"name": append(make([]interface{}, 0, 4), []interface{}{"a", "b", "c"}...),
},
},
{
name: "with escape",
value: []byte(`value1\,value2`),
path: "name",
want: map[string]interface{}{
"name": "value1,value2",
},
},
{
name: "target path with boolean value",
value: []byte("true"),
path: "merge.at.specific.path",
want: chartutil.Values{
"merge": map[string]interface{}{
"at": map[string]interface{}{
"specific": map[string]interface{}{
"path": true,
},
},
},
},
},
{
name: "target path with set-string behavior",
value: []byte(`"true"`),
path: "merge.at.specific.path",
want: chartutil.Values{
"merge": map[string]interface{}{
"at": map[string]interface{}{
"specific": map[string]interface{}{
"path": "true",
},
},
},
},
},
{
name: "target path with array item",
value: []byte("value"),
path: "merge.at[2]",
want: chartutil.Values{
"merge": map[string]interface{}{
"at": []interface{}{nil, nil, "value"},
},
},
},
{
name: "dot sequence escaping path",
value: []byte("master"),
path: `nodeSelector.kubernetes\.io/role`,
want: map[string]interface{}{
"nodeSelector": map[string]interface{}{
"kubernetes.io/role": "master",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
values := map[string]interface{}{}
err := ReplacePathValue(values, tt.path, string(tt.value))
if tt.wantErr {
g.Expect(err).To(HaveOccurred())
g.Expect(values).To(BeNil())
return
}
g.Expect(err).ToNot(HaveOccurred())
g.Expect(values).To(Equal(tt.want))
})
}
}
func mockSecret(name string, data map[string][]byte) *corev1.Secret {
return &corev1.Secret{
TypeMeta: metav1.TypeMeta{
Kind: kindSecret,
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{Name: name},
Data: data,
}
}
func mockConfigMap(name string, data map[string]string) *corev1.ConfigMap {
return &corev1.ConfigMap{
TypeMeta: metav1.TypeMeta{
Kind: kindConfigMap,
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{Name: name},
Data: data,
}
}

86
internal/kube/builder.go Normal file
View File

@ -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 kube
import (
"k8s.io/cli-runtime/pkg/genericclioptions"
"k8s.io/client-go/rest"
"github.com/fluxcd/pkg/runtime/client"
)
const (
// DefaultKubeConfigSecretKey is the default data key ConfigFromSecret
// looks at when no data key is provided.
DefaultKubeConfigSecretKey = "value"
// DefaultKubeConfigSecretKeyExt is the default data key ConfigFromSecret
// looks at when no data key is provided, and DefaultKubeConfigSecretKey
// does not exist.
DefaultKubeConfigSecretKeyExt = DefaultKubeConfigSecretKey + ".yaml"
)
// clientGetterOptions used to BuildClientGetter.
type clientGetterOptions struct {
config *rest.Config
namespace string
kubeConfig []byte
burst int
qps float32
impersonateAccount string
kubeConfigOptions client.KubeConfigOptions
}
// ClientGetterOption configures a genericclioptions.RESTClientGetter.
type ClientGetterOption func(o *clientGetterOptions)
// WithKubeConfig creates a MemoryRESTClientGetter configured with the provided
// KubeConfig and other values.
func WithKubeConfig(kubeConfig []byte, qps float32, burst int, opts client.KubeConfigOptions) func(o *clientGetterOptions) {
return func(o *clientGetterOptions) {
o.kubeConfig = kubeConfig
o.qps = qps
o.burst = burst
o.kubeConfigOptions = opts
}
}
// WithImpersonate configures the genericclioptions.RESTClientGetter to
// impersonate the provided account name.
func WithImpersonate(accountName string) func(o *clientGetterOptions) {
return func(o *clientGetterOptions) {
o.impersonateAccount = accountName
}
}
// BuildClientGetter builds a genericclioptions.RESTClientGetter based on the
// provided options and returns the result. config and namespace are mandatory,
// and not expected to be nil or empty.
func BuildClientGetter(config *rest.Config, namespace string, opts ...ClientGetterOption) genericclioptions.RESTClientGetter {
o := &clientGetterOptions{
config: config,
namespace: namespace,
}
for _, opt := range opts {
opt(o)
}
if len(o.kubeConfig) > 0 {
return NewMemoryRESTClientGetter(o.kubeConfig, namespace, o.impersonateAccount, o.qps, o.burst, o.kubeConfigOptions)
}
cfg := *config
SetImpersonationConfig(&cfg, namespace, o.impersonateAccount)
return NewInClusterRESTClientGetter(&cfg, namespace)
}

View File

@ -0,0 +1,109 @@
/*
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 kube
import (
"testing"
"github.com/fluxcd/pkg/runtime/client"
. "github.com/onsi/gomega"
"k8s.io/cli-runtime/pkg/genericclioptions"
"k8s.io/client-go/rest"
)
func TestBuildClientGetter(t *testing.T) {
t.Run("with config and namespace", func(t *testing.T) {
g := NewWithT(t)
cfg := &rest.Config{
BearerToken: "a-token",
}
namespace := "a-namespace"
getter := BuildClientGetter(cfg, namespace)
g.Expect(getter).To(BeAssignableToTypeOf(&genericclioptions.ConfigFlags{}))
flags := getter.(*genericclioptions.ConfigFlags)
g.Expect(flags.BearerToken).ToNot(BeNil())
g.Expect(*flags.BearerToken).To(Equal(cfg.BearerToken))
g.Expect(flags.Namespace).ToNot(BeNil())
g.Expect(*flags.Namespace).To(Equal(namespace))
})
t.Run("with kubeconfig and impersonate", func(t *testing.T) {
g := NewWithT(t)
namespace := "a-namespace"
cfg := []byte(`apiVersion: v1
clusters:
- cluster:
server: https://example.com
name: example-cluster
contexts:
- context:
cluster: example-cluster
namespace: flux-system
kind: Config
preferences: {}
users:`)
qps := float32(600)
burst := 1000
cfgOpts := client.KubeConfigOptions{InsecureTLS: true}
impersonate := "jane"
getter := BuildClientGetter(&rest.Config{}, namespace, WithKubeConfig(cfg, qps, burst, cfgOpts), WithImpersonate(impersonate))
g.Expect(getter).To(BeAssignableToTypeOf(&MemoryRESTClientGetter{}))
got := getter.(*MemoryRESTClientGetter)
g.Expect(got.namespace).To(Equal(namespace))
g.Expect(got.kubeConfig).To(Equal(cfg))
g.Expect(got.qps).To(Equal(qps))
g.Expect(got.burst).To(Equal(burst))
g.Expect(got.kubeConfigOpts).To(Equal(cfgOpts))
g.Expect(got.impersonateAccount).To(Equal(impersonate))
})
t.Run("with config and impersonate account", func(t *testing.T) {
g := NewWithT(t)
namespace := "a-namespace"
impersonate := "frank"
getter := BuildClientGetter(&rest.Config{}, namespace, WithImpersonate(impersonate))
g.Expect(getter).To(BeAssignableToTypeOf(&genericclioptions.ConfigFlags{}))
flags := getter.(*genericclioptions.ConfigFlags)
g.Expect(flags.Namespace).ToNot(BeNil())
g.Expect(*flags.Namespace).To(Equal(namespace))
g.Expect(flags.Impersonate).ToNot(BeNil())
g.Expect(*flags.Impersonate).To(Equal("system:serviceaccount:a-namespace:frank"))
})
t.Run("with config and DefaultServiceAccount", func(t *testing.T) {
g := NewWithT(t)
namespace := "a-namespace"
DefaultServiceAccountName = "frank"
getter := BuildClientGetter(&rest.Config{}, namespace)
g.Expect(getter).To(BeAssignableToTypeOf(&genericclioptions.ConfigFlags{}))
flags := getter.(*genericclioptions.ConfigFlags)
g.Expect(flags.Namespace).ToNot(BeNil())
g.Expect(*flags.Namespace).To(Equal(namespace))
g.Expect(flags.Impersonate).ToNot(BeNil())
g.Expect(*flags.Impersonate).To(Equal("system:serviceaccount:a-namespace:frank"))
})
}

View File

@ -24,53 +24,69 @@ import (
"k8s.io/client-go/rest"
"k8s.io/client-go/restmapper"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/utils/pointer"
"github.com/fluxcd/pkg/runtime/client"
)
// NewInClusterRESTClientGetter creates a new genericclioptions.RESTClientGetter
// using genericclioptions.NewConfigFlags, and configures it with the server,
// authentication, impersonation, and burst and QPS settings, and the provided
// namespace.
func NewInClusterRESTClientGetter(cfg *rest.Config, namespace string) genericclioptions.RESTClientGetter {
flags := genericclioptions.NewConfigFlags(false)
flags.APIServer = &cfg.Host
flags.BearerToken = &cfg.BearerToken
flags.CAFile = &cfg.CAFile
flags.Namespace = &namespace
flags.APIServer = pointer.String(cfg.Host)
flags.BearerToken = pointer.String(cfg.BearerToken)
flags.CAFile = pointer.String(cfg.CAFile)
flags.Namespace = pointer.String(namespace)
flags.WithDiscoveryBurst(cfg.Burst)
flags.WithDiscoveryQPS(cfg.QPS)
if sa := cfg.Impersonate.UserName; sa != "" {
flags.Impersonate = &sa
flags.Impersonate = pointer.String(sa)
}
return flags
}
// MemoryRESTClientGetter is an implementation of the genericclioptions.RESTClientGetter,
// capable of working with an in-memory kubeconfig file.
type MemoryRESTClientGetter struct {
kubeConfig []byte
namespace string
// kubeConfig used to load a rest.Config, after being sanitized.
kubeConfig []byte
// kubeConfigOpts control the sanitization of the kubeConfig.
kubeConfigOpts client.KubeConfigOptions
// namespace specifies the namespace the client is configured to.
namespace string
// impersonateAccount configures the rest.ImpersonationConfig account name.
impersonateAccount string
qps float32
burst int
kubeConfigOpts client.KubeConfigOptions
// qps configures the QPS on the discovery.DiscoveryClient.
qps float32
// burst configures the burst on the discovery.DiscoveryClient.
burst int
}
// NewMemoryRESTClientGetter returns a MemoryRESTClientGetter configured with
// the provided values and client.KubeConfigOptions. The provided KubeConfig is
// sanitized, configure the settings for this using client.KubeConfigOptions.
func NewMemoryRESTClientGetter(
kubeConfig []byte,
namespace string,
impersonateAccount string,
impersonate string,
qps float32,
burst int,
kubeConfigOpts client.KubeConfigOptions) genericclioptions.RESTClientGetter {
return &MemoryRESTClientGetter{
kubeConfig: kubeConfig,
namespace: namespace,
impersonateAccount: impersonateAccount,
impersonateAccount: impersonate,
qps: qps,
burst: burst,
kubeConfigOpts: kubeConfigOpts,
}
}
// ToRESTConfig creates a rest.Config with the rest.ImpersonationConfig configured
// with to the impersonation account. It loads the config the KubeConfig bytes and
// sanitizes it using the client.KubeConfigOptions.
func (c *MemoryRESTClientGetter) ToRESTConfig() (*rest.Config, error) {
cfg, err := clientcmd.RESTConfigFromKubeConfig(c.kubeConfig)
if err != nil {
@ -83,23 +99,25 @@ func (c *MemoryRESTClientGetter) ToRESTConfig() (*rest.Config, error) {
return cfg, nil
}
// ToDiscoveryClient returns a discovery.CachedDiscoveryInterface configured
// with ToRESTConfig, and the QPS and Burst settings.
func (c *MemoryRESTClientGetter) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) {
config, err := c.ToRESTConfig()
if err != nil {
return nil, err
}
if c.impersonateAccount != "" {
config.Impersonate = rest.ImpersonationConfig{UserName: c.impersonateAccount}
}
config.QPS = c.qps
config.Burst = c.burst
discoveryClient, _ := discovery.NewDiscoveryClientForConfig(config)
discoveryClient, err := discovery.NewDiscoveryClientForConfig(config)
if err != nil {
return nil, err
}
return memory.NewMemCacheClient(discoveryClient), nil
}
// ToRESTMapper returns a RESTMapper constructed from ToDiscoveryClient.
func (c *MemoryRESTClientGetter) ToRESTMapper() (meta.RESTMapper, error) {
discoveryClient, err := c.ToDiscoveryClient()
if err != nil {
@ -111,6 +129,9 @@ func (c *MemoryRESTClientGetter) ToRESTMapper() (meta.RESTMapper, error) {
return expander, nil
}
// ToRawKubeConfigLoader returns a clientcmd.ClientConfig using
// clientcmd.DefaultClientConfig. With clientcmd.ClusterDefaults, namespace, and
// impersonate configured as overwrites.
func (c *MemoryRESTClientGetter) ToRawKubeConfigLoader() clientcmd.ClientConfig {
loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()
// use the standard defaults for this client command

View File

@ -0,0 +1,179 @@
/*
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 kube
import (
"testing"
"github.com/fluxcd/pkg/runtime/client"
. "github.com/onsi/gomega"
"k8s.io/cli-runtime/pkg/genericclioptions"
"k8s.io/client-go/rest"
)
var cfg = []byte(`current-context: federal-context
apiVersion: v1
clusters:
- cluster:
api-version: v1
server: http://cow.org:8080
insecure-skip-tls-verify: true
name: cow-cluster
contexts:
- context:
cluster: cow-cluster
user: blue-user
name: federal-context
kind: Config
users:
- name: blue-user
user:
token: foo`)
func TestNewInClusterRESTClientGetter(t *testing.T) {
t.Run("api server config", func(t *testing.T) {
g := NewWithT(t)
cfg := &rest.Config{
Host: "https://example.com",
BearerToken: "chase-the-honey",
TLSClientConfig: rest.TLSClientConfig{
CAFile: "afile",
},
}
got := NewInClusterRESTClientGetter(cfg, "")
g.Expect(got).To(BeAssignableToTypeOf(&genericclioptions.ConfigFlags{}))
flags := got.(*genericclioptions.ConfigFlags)
fields := map[*string]*string{
flags.APIServer: &cfg.Host,
flags.BearerToken: &cfg.BearerToken,
flags.CAFile: &cfg.CAFile,
}
for f, ff := range fields {
g.Expect(f).ToNot(BeNil())
g.Expect(f).To(Equal(ff))
g.Expect(f).ToNot(BeIdenticalTo(ff))
}
})
t.Run("namespace", func(t *testing.T) {
g := NewWithT(t)
got := NewInClusterRESTClientGetter(&rest.Config{}, "a-space")
g.Expect(got).To(BeAssignableToTypeOf(&genericclioptions.ConfigFlags{}))
flags := got.(*genericclioptions.ConfigFlags)
g.Expect(flags.Namespace).ToNot(BeNil())
g.Expect(*flags.Namespace).To(Equal("a-space"))
})
t.Run("impersonation", func(t *testing.T) {
g := NewWithT(t)
cfg := &rest.Config{
Impersonate: rest.ImpersonationConfig{
UserName: "system:serviceaccount:namespace:foo",
},
}
got := NewInClusterRESTClientGetter(cfg, "")
g.Expect(got).To(BeAssignableToTypeOf(&genericclioptions.ConfigFlags{}))
flags := got.(*genericclioptions.ConfigFlags)
g.Expect(flags.Impersonate).ToNot(BeNil())
g.Expect(*flags.Impersonate).To(Equal(cfg.Impersonate.UserName))
})
}
func TestMemoryRESTClientGetter_ToRESTConfig(t *testing.T) {
t.Run("loads REST config from KubeConfig", func(t *testing.T) {
g := NewWithT(t)
getter := NewMemoryRESTClientGetter(cfg, "", "", 0, 0, client.KubeConfigOptions{})
got, err := getter.ToRESTConfig()
g.Expect(err).ToNot(HaveOccurred())
g.Expect(got.Host).To(Equal("http://cow.org:8080"))
g.Expect(got.TLSClientConfig.Insecure).To(BeFalse())
})
t.Run("sets ImpersonationConfig", func(t *testing.T) {
g := NewWithT(t)
getter := NewMemoryRESTClientGetter(cfg, "", "someone", 0, 0, client.KubeConfigOptions{})
got, err := getter.ToRESTConfig()
g.Expect(err).ToNot(HaveOccurred())
g.Expect(got.Impersonate.UserName).To(Equal("someone"))
})
t.Run("uses KubeConfigOptions", func(t *testing.T) {
g := NewWithT(t)
agent := "a static string forever," +
"but static strings can have dreams and hope too"
getter := NewMemoryRESTClientGetter(cfg, "", "someone", 0, 0, client.KubeConfigOptions{
UserAgent: agent,
})
got, err := getter.ToRESTConfig()
g.Expect(err).ToNot(HaveOccurred())
g.Expect(got.UserAgent).To(Equal(agent))
})
t.Run("invalid config", func(t *testing.T) {
g := NewWithT(t)
getter := NewMemoryRESTClientGetter([]byte(`invalid`), "", "", 0, 0, client.KubeConfigOptions{})
got, err := getter.ToRESTConfig()
g.Expect(err).To(HaveOccurred())
g.Expect(got).To(BeNil())
})
}
func TestMemoryRESTClientGetter_ToDiscoveryClient(t *testing.T) {
g := NewWithT(t)
getter := NewMemoryRESTClientGetter(cfg, "", "", 400, 800, client.KubeConfigOptions{})
got, err := getter.ToDiscoveryClient()
g.Expect(err).ToNot(HaveOccurred())
g.Expect(got).ToNot(BeNil())
}
func TestMemoryRESTClientGetter_ToRESTMapper(t *testing.T) {
g := NewWithT(t)
getter := NewMemoryRESTClientGetter(cfg, "", "", 400, 800, client.KubeConfigOptions{})
got, err := getter.ToRESTMapper()
g.Expect(err).ToNot(HaveOccurred())
g.Expect(got).ToNot(BeNil())
}
func TestMemoryRESTClientGetter_ToRawKubeConfigLoader(t *testing.T) {
g := NewWithT(t)
getter := NewMemoryRESTClientGetter(cfg, "a-namespace", "impersonate", 0, 0, client.KubeConfigOptions{})
got := getter.ToRawKubeConfigLoader()
g.Expect(got).ToNot(BeNil())
cfg, err := got.ClientConfig()
g.Expect(err).ToNot(HaveOccurred())
g.Expect(cfg.Impersonate.UserName).To(Equal("impersonate"))
ns, _, err := got.Namespace()
g.Expect(err).ToNot(HaveOccurred())
g.Expect(ns).To(Equal("a-namespace"))
}

51
internal/kube/config.go Normal file
View File

@ -0,0 +1,51 @@
/*
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 kube
import (
"fmt"
corev1 "k8s.io/api/core/v1"
)
// ConfigFromSecret returns the KubeConfig data from the provided key in the
// given Secret, or attempts to load the data from the default `value` and
// `value.yaml` keys. If a Secret is provided but no key with data can be
// found, an error is returned. The secret may be nil, in which case no bytes
// nor error are returned. Validation of the data is expected to happen while
// decoding the bytes.
func ConfigFromSecret(secret *corev1.Secret, key string) ([]byte, error) {
var kubeConfig []byte
if secret != nil {
secretName := fmt.Sprintf("%s/%s", secret.Namespace, secret.Name)
switch {
case key != "":
kubeConfig = secret.Data[key]
if kubeConfig == nil {
return nil, fmt.Errorf("KubeConfig secret '%s' does not contain a '%s' key with data", secretName, key)
}
case secret.Data[DefaultKubeConfigSecretKey] != nil:
kubeConfig = secret.Data[DefaultKubeConfigSecretKey]
case secret.Data[DefaultKubeConfigSecretKeyExt] != nil:
kubeConfig = secret.Data[DefaultKubeConfigSecretKeyExt]
default:
// User did not specify a key, and the 'value' key was not defined.
return nil, fmt.Errorf("KubeConfig secret '%s' does not contain a '%s' or '%s' key with data", secretName, DefaultKubeConfigSecretKey, DefaultKubeConfigSecretKeyExt)
}
}
return kubeConfig, nil
}

View File

@ -0,0 +1,143 @@
/*
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 kube
import (
"testing"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestConfigFromSecret(t *testing.T) {
t.Run("with default key", func(t *testing.T) {
g := NewWithT(t)
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "super-secret",
Namespace: "vault",
},
Data: map[string][]byte{
DefaultKubeConfigSecretKey: []byte("good"),
// Also confirm priority.
DefaultKubeConfigSecretKeyExt: []byte("bad"),
},
}
got, err := ConfigFromSecret(secret, "")
g.Expect(err).ToNot(HaveOccurred())
g.Expect(got).To(Equal(secret.Data[DefaultKubeConfigSecretKey]))
})
t.Run("with default key with ext", func(t *testing.T) {
g := NewWithT(t)
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "super-secret",
Namespace: "vault",
},
Data: map[string][]byte{
DefaultKubeConfigSecretKeyExt: []byte("good"),
},
}
got, err := ConfigFromSecret(secret, "")
g.Expect(err).ToNot(HaveOccurred())
g.Expect(got).To(Equal(secret.Data[DefaultKubeConfigSecretKeyExt]))
})
t.Run("with key", func(t *testing.T) {
g := NewWithT(t)
key := "cola.recipe"
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "super-secret",
Namespace: "vault",
},
Data: map[string][]byte{
key: []byte("snow"),
},
}
got, err := ConfigFromSecret(secret, key)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(got).To(Equal(secret.Data[key]))
})
t.Run("invalid key", func(t *testing.T) {
g := NewWithT(t)
key := "black-hole"
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "super-secret",
Namespace: "vault",
},
Data: map[string][]byte{},
}
got, err := ConfigFromSecret(secret, key)
g.Expect(err).To(HaveOccurred())
g.Expect(got).To(BeNil())
g.Expect(err.Error()).To(ContainSubstring("secret 'vault/super-secret' does not contain a 'black-hole' key "))
g.Expect(got).To(Equal(secret.Data[key]))
})
t.Run("key without data", func(t *testing.T) {
g := NewWithT(t)
key := "void"
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "super-secret",
Namespace: "vault",
},
Data: map[string][]byte{
key: nil,
},
}
got, err := ConfigFromSecret(secret, key)
g.Expect(err).To(HaveOccurred())
g.Expect(got).To(BeNil())
g.Expect(err.Error()).To(ContainSubstring("does not contain a 'void' key with data"))
})
t.Run("no keys", func(t *testing.T) {
g := NewWithT(t)
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "super-secret",
Namespace: "vault",
},
Data: map[string][]byte{},
}
got, err := ConfigFromSecret(secret, "")
g.Expect(err).To(HaveOccurred())
g.Expect(got).To(BeNil())
g.Expect(err.Error()).To(ContainSubstring("does not contain a 'value' or 'value.yaml'"))
})
t.Run("nil secret", func(t *testing.T) {
g := NewWithT(t)
got, err := ConfigFromSecret(nil, "")
g.Expect(err).ToNot(HaveOccurred())
g.Expect(got).To(BeNil())
})
}

View File

@ -0,0 +1,47 @@
/*
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 kube
import (
"fmt"
"k8s.io/client-go/rest"
)
// DefaultServiceAccountName can be set at runtime to enable a fallback account
// name when no service account name is provided to SetImpersonationConfig.
var DefaultServiceAccountName string
// userNameFormat is the format of a system service account user name string.
// It formats into `system:serviceaccount:namespace:name`.
const userNameFormat = "system:serviceaccount:%s:%s"
// SetImpersonationConfig configures the provided service account name if
// given, or the DefaultServiceAccountName as a fallback if set. It returns
// the configured impersonation username, or an empty string.
func SetImpersonationConfig(cfg *rest.Config, namespace, serviceAccount string) string {
name := DefaultServiceAccountName
if serviceAccount != "" {
name = serviceAccount
}
if name != "" && namespace != "" {
username := fmt.Sprintf(userNameFormat, namespace, name)
cfg.Impersonate = rest.ImpersonationConfig{UserName: username}
return username
}
return ""
}

View File

@ -0,0 +1,75 @@
/*
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 kube
import (
"testing"
. "github.com/onsi/gomega"
"k8s.io/client-go/rest"
)
func TestSetImpersonationConfig(t *testing.T) {
t.Run("DefaultServiceAccountName", func(t *testing.T) {
g := NewWithT(t)
DefaultServiceAccountName = "default"
namespace := "test"
expect := "system:serviceaccount:" + namespace + ":" + DefaultServiceAccountName
cfg := &rest.Config{}
name := SetImpersonationConfig(cfg, namespace, "")
g.Expect(name).To(Equal(expect))
g.Expect(cfg.Impersonate.UserName).ToNot(BeEmpty())
g.Expect(cfg.Impersonate.UserName).To(Equal(name))
})
t.Run("overwrite DefaultServiceAccountName", func(t *testing.T) {
g := NewWithT(t)
DefaultServiceAccountName = "default"
namespace := "test"
serviceAccount := "different"
expect := "system:serviceaccount:" + namespace + ":" + serviceAccount
cfg := &rest.Config{}
name := SetImpersonationConfig(cfg, namespace, serviceAccount)
g.Expect(name).To(Equal(expect))
g.Expect(cfg.Impersonate.UserName).ToNot(BeEmpty())
g.Expect(cfg.Impersonate.UserName).To(Equal(name))
})
t.Run("without namespace", func(t *testing.T) {
g := NewWithT(t)
serviceAccount := "account"
cfg := &rest.Config{}
name := SetImpersonationConfig(cfg, "", serviceAccount)
g.Expect(name).To(BeEmpty())
g.Expect(cfg.Impersonate.UserName).To(BeEmpty())
})
t.Run("no arguments", func(t *testing.T) {
g := NewWithT(t)
cfg := &rest.Config{}
name := SetImpersonationConfig(cfg, "", "")
g.Expect(name).To(BeEmpty())
g.Expect(cfg.Impersonate.UserName).To(BeEmpty())
})
}

View File

@ -0,0 +1,80 @@
/*
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 loader
import (
"bytes"
"crypto/sha256"
"errors"
"fmt"
"io"
"net/http"
"github.com/hashicorp/go-retryablehttp"
"helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/chart/loader"
)
var (
// ErrIntegrity signals a chart loader failed to verify the integrity of
// a chart, for example due to a checksum mismatch.
ErrIntegrity = errors.New("integrity failure")
)
// SecureLoadChartFromURL attempts to download a Helm chart from the given URL
// using the provided client. The SHA256 sum of the retrieved data is confirmed
// to equal the given checksum before loading the chart. It returns the loaded
// chart.Chart, or an error.
func SecureLoadChartFromURL(client *retryablehttp.Client, URL, checksum string) (*chart.Chart, error) {
req, err := retryablehttp.NewRequest(http.MethodGet, URL, nil)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil || resp != nil && resp.StatusCode != http.StatusOK {
if err != nil {
return nil, err
}
return nil, fmt.Errorf("failed to load chart from '%s': %s", URL, resp.Status)
}
var c bytes.Buffer
if err := copyAndSHA256Check(checksum, resp.Body, &c); err != nil {
return nil, err
}
if err := resp.Body.Close(); err != nil {
return nil, err
}
return loader.LoadArchive(&c)
}
// copyAndSHA256Check copies from reader into writer while confirming the
// SHA256 checksum of the copied data matches the provided checksum. If
// this does not match, it returns an error.
func copyAndSHA256Check(checksum string, reader io.Reader, writer io.Writer) error {
hasher := sha256.New()
mw := io.MultiWriter(hasher, writer)
if _, err := io.Copy(mw, reader); err != nil {
return fmt.Errorf("failed to verify artifact: %w", err)
}
if calc := fmt.Sprintf("%x", hasher.Sum(nil)); checksum != calc {
return fmt.Errorf("%w: computed checksum '%s' doesn't match '%s'", ErrIntegrity, calc, checksum)
}
return nil
}

View File

@ -0,0 +1,133 @@
/*
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 loader
import (
"bytes"
"crypto/sha256"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/hashicorp/go-retryablehttp"
. "github.com/onsi/gomega"
_ "helm.sh/helm/v3/pkg/chart"
)
func TestSecureLoadChartFromURL(t *testing.T) {
g := NewWithT(t)
b, err := os.ReadFile("testdata/chart-0.1.0.tgz")
g.Expect(err).ToNot(HaveOccurred())
g.Expect(b).ToNot(BeNil())
checksum := fmt.Sprintf("%x", sha256.Sum256(b))
const chartPath = "/chart.tgz"
server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
if req.URL.Path == chartPath {
res.WriteHeader(http.StatusOK)
_, _ = res.Write(b)
return
}
res.WriteHeader(http.StatusInternalServerError)
return
}))
t.Cleanup(func() {
server.Close()
})
chartURL := server.URL + chartPath
client := retryablehttp.NewClient()
client.Logger = nil
client.RetryMax = 2
t.Run("loads Helm chart from URL", func(t *testing.T) {
g := NewWithT(t)
got, err := SecureLoadChartFromURL(client, chartURL, checksum)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(got).ToNot(BeNil())
g.Expect(got.Name()).To(Equal("chart"))
g.Expect(got.Metadata.Version).To(Equal("0.1.0"))
})
t.Run("error on chart data checksum mismatch", func(t *testing.T) {
g := NewWithT(t)
got, err := SecureLoadChartFromURL(client, chartURL, "")
g.Expect(err).To(HaveOccurred())
g.Expect(got).To(BeNil())
})
t.Run("error on server error", func(t *testing.T) {
g := NewWithT(t)
got, err := SecureLoadChartFromURL(client, server.URL+"/invalid.tgz", checksum)
g.Expect(err).To(HaveOccurred())
g.Expect(got).To(BeNil())
})
}
func Test_copyAndSHA256Check(t *testing.T) {
g := NewWithT(t)
tmpDir := t.TempDir()
closedF, err := os.CreateTemp(tmpDir, "closed.txt")
g.Expect(err).ToNot(HaveOccurred())
g.Expect(closedF.Close()).ToNot(HaveOccurred())
tests := []struct {
name string
checksum string
in io.Reader
out io.Writer
wantErr bool
}{
{
name: "checksum match",
checksum: "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
in: bytes.NewReader([]byte("foo")),
out: io.Discard,
},
{
name: "checksum mismatch",
checksum: "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
in: bytes.NewReader([]byte("bar")),
out: io.Discard,
wantErr: true,
},
{
name: "copy failure (closed file)",
checksum: "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
in: bytes.NewReader([]byte("foo")),
out: closedF,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
err := copyAndSHA256Check(tt.checksum, tt.in, tt.out)
g.Expect(err != nil).To(Equal(tt.wantErr), err)
})
}
}

19
main.go
View File

@ -21,6 +21,7 @@ import (
"os"
"time"
intkube "github.com/fluxcd/helm-controller/internal/kube"
flag "github.com/spf13/pflag"
"helm.sh/helm/v3/pkg/kube"
"k8s.io/apimachinery/pkg/runtime"
@ -74,7 +75,6 @@ func main() {
aclOptions acl.Options
leaderElectionOptions leaderelection.Options
rateLimiterOptions helper.RateLimiterOptions
defaultServiceAccount string
)
flag.StringVar(&metricsAddr, "metrics-addr", ":8080", "The address the metric endpoint binds to.")
@ -85,7 +85,7 @@ func main() {
flag.BoolVar(&watchAllNamespaces, "watch-all-namespaces", true,
"Watch for custom resources in all namespaces, if set to false it will only watch the runtime namespace.")
flag.IntVar(&httpRetry, "http-retry", 9, "The maximum number of retries when failing to fetch artifacts over HTTP.")
flag.StringVar(&defaultServiceAccount, "default-service-account", "", "Default service account used for impersonation.")
flag.StringVar(&intkube.DefaultServiceAccountName, "default-service-account", "", "Default service account used for impersonation.")
clientOptions.BindFlags(flag.CommandLine)
logOptions.BindFlags(flag.CommandLine)
aclOptions.BindFlags(flag.CommandLine)
@ -151,14 +151,13 @@ func main() {
}
if err = (&controllers.HelmReleaseReconciler{
Client: mgr.GetClient(),
Config: mgr.GetConfig(),
Scheme: mgr.GetScheme(),
EventRecorder: eventRecorder,
MetricsRecorder: metricsH.MetricsRecorder,
NoCrossNamespaceRef: aclOptions.NoCrossNamespaceRefs,
DefaultServiceAccount: defaultServiceAccount,
KubeConfigOpts: kubeConfigOpts,
Client: mgr.GetClient(),
Config: mgr.GetConfig(),
Scheme: mgr.GetScheme(),
EventRecorder: eventRecorder,
MetricsRecorder: metricsH.MetricsRecorder,
NoCrossNamespaceRef: aclOptions.NoCrossNamespaceRefs,
KubeConfigOpts: kubeConfigOpts,
}).SetupWithManager(mgr, controllers.HelmReleaseReconcilerOptions{
MaxConcurrentReconciles: concurrent,
DependencyRequeueInterval: requeueDependency,