Implement DepdendencyManager for non-packaged

Non-packaged charts that don't have their dependencies present in
charts/ will now have these dependencies built using the
DependencyManager. The idea behind it is to replicate the logic
implemeneted in Helm's downloader.Manager with the support for already
existing HelmRepository resources and their chart retrieval capabilities.

Signed-off-by: Aurel Canciu <aurelcanciu@gmail.com>
This commit is contained in:
Aurel Canciu 2020-10-28 00:19:29 +02:00
parent 38317ab7c0
commit f1362bd3a9
No known key found for this signature in database
GPG Key ID: AB25339971E6F81E
6 changed files with 733 additions and 9 deletions

View File

@ -0,0 +1,142 @@
/*
Copyright 2020 The Flux CD contributors.
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"
"path"
"path/filepath"
"strings"
"github.com/Masterminds/semver/v3"
"github.com/fluxcd/source-controller/internal/helm"
"golang.org/x/sync/errgroup"
helmchart "helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/chart/loader"
)
// DependencyWithRepository is a container for a dependency and its respective
// repository
type DependencyWithRepository struct {
Dependency *helmchart.Dependency
Repo *helm.ChartRepository
}
// DependencyManager manages dependencies for helm charts
type DependencyManager struct {
Chart *helmchart.Chart
ChartPath string
Dependencies []*DependencyWithRepository
}
// Build compiles and builds the chart dependencies
func (dm *DependencyManager) Build() error {
if dm.Dependencies == nil {
return nil
}
ctx := context.Background()
errs, ctx := errgroup.WithContext(ctx)
for _, item := range dm.Dependencies {
dep := item.Dependency
chartRepo := item.Repo
errs.Go(func() error {
var (
ch *helmchart.Chart
err error
)
if strings.HasPrefix(dep.Repository, "file://") {
ch, err = chartForLocalDependency(dep, dm.ChartPath)
} else {
ch, err = chartForRemoteDependency(dep, chartRepo)
}
if err != nil {
return err
}
dm.Chart.AddDependency(ch)
return nil
})
}
return errs.Wait()
}
func chartForLocalDependency(dep *helmchart.Dependency, cp string) (*helmchart.Chart, error) {
origPath, err := filepath.Abs(path.Join(cp, strings.TrimPrefix(dep.Repository, "file://")))
if err != nil {
return nil, err
}
if _, err := os.Stat(origPath); os.IsNotExist(err) {
err := fmt.Errorf("chart path %s not found: %w", origPath, err)
return nil, err
} else if err != nil {
return nil, err
}
ch, err := loader.Load(origPath)
if err != nil {
return nil, err
}
constraint, err := semver.NewConstraint(dep.Version)
if err != nil {
err := fmt.Errorf("dependency %s has an invalid version/constraint format: %w", dep.Name, err)
return nil, err
}
v, err := semver.NewVersion(ch.Metadata.Version)
if err != nil {
return nil, err
}
if !constraint.Check(v) {
err = fmt.Errorf("can't get a valid version for dependency %s", dep.Name)
return nil, err
}
return ch, nil
}
func chartForRemoteDependency(dep *helmchart.Dependency, chartrepo *helm.ChartRepository) (*helmchart.Chart, error) {
if chartrepo == nil {
err := fmt.Errorf("chartrepo should not be nil")
return nil, err
}
// Lookup the chart version in the chart repository index
chartVer, err := chartrepo.Get(dep.Name, dep.Version)
if err != nil {
return nil, err
}
// Download chart
res, err := chartrepo.DownloadChart(chartVer)
if err != nil {
return nil, err
}
ch, err := loader.LoadArchive(res)
if err != nil {
return nil, err
}
return ch, nil
}

View File

@ -0,0 +1,203 @@
/*
Copyright 2020 The Flux CD contributors.
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"
"io/ioutil"
"strings"
"testing"
"github.com/fluxcd/source-controller/internal/helm"
helmchart "helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/getter"
"helm.sh/helm/v3/pkg/repo"
)
var (
helmPackageFile = "testdata/charts/helmchart-0.1.0.tgz"
localDepFixture helmchart.Dependency = helmchart.Dependency{
Name: "helmchart",
Version: "0.1.0",
Repository: "file://../helmchart",
}
remoteDepFixture helmchart.Dependency = helmchart.Dependency{
Name: "helmchart",
Version: "0.1.0",
Repository: "https://example.com/charts",
}
chartFixture helmchart.Chart = helmchart.Chart{
Metadata: &helmchart.Metadata{
Name: "test",
},
}
)
func TestBuild_WithEmptyDependencies(t *testing.T) {
dm := DependencyManager{
Dependencies: nil,
}
if err := dm.Build(); err != nil {
t.Errorf("Build() should return nil")
}
}
func TestBuild_WithLocalChart(t *testing.T) {
loc := localDepFixture
chart := chartFixture
dm := DependencyManager{
Chart: &chart,
ChartPath: "testdata/charts/helmchart",
Dependencies: []*DependencyWithRepository{
{
Dependency: &loc,
Repo: nil,
},
},
}
if err := dm.Build(); err != nil {
t.Errorf("Build() expected to not return error: %s", err)
}
deps := dm.Chart.Dependencies()
if len(deps) != 1 {
t.Fatalf("chart expected to have one dependency registered")
}
if deps[0].Metadata.Name != localDepFixture.Name {
t.Errorf("chart dependency has incorrect name, expected: %s, got: %s", localDepFixture.Name, deps[0].Metadata.Name)
}
if deps[0].Metadata.Version != localDepFixture.Version {
t.Errorf("chart dependency has incorrect version, expected: %s, got: %s", localDepFixture.Version, deps[0].Metadata.Version)
}
tests := []struct {
name string
dep helmchart.Dependency
expectError string
}{
{
name: "invalid path",
dep: helmchart.Dependency{
Name: "helmchart",
Version: "0.1.0",
Repository: "file://../invalid",
},
expectError: "no such file or directory",
},
{
name: "invalid version constraint format",
dep: helmchart.Dependency{
Name: "helmchart",
Version: "!2.0",
Repository: "file://../helmchart",
},
expectError: "has an invalid version/constraint format",
},
{
name: "invalid version",
dep: helmchart.Dependency{
Name: "helmchart",
Version: "1.0.0",
Repository: "file://../helmchart",
},
expectError: "can't get a valid version for dependency",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := chartFixture
dm = DependencyManager{
Chart: &c,
ChartPath: "testdata/charts/helmchart",
Dependencies: []*DependencyWithRepository{
{
Dependency: &tt.dep,
Repo: nil,
},
},
}
if err := dm.Build(); err == nil {
t.Errorf("Build() expected to return error")
} else if !strings.Contains(err.Error(), tt.expectError) {
t.Errorf("Build() expected to return error: %s, got: %s", tt.expectError, err)
}
if len(dm.Chart.Dependencies()) > 0 {
t.Fatalf("chart expected to have no dependencies registered")
}
})
}
}
func TestBuild_WithRemoteChart(t *testing.T) {
chart := chartFixture
b, err := ioutil.ReadFile(helmPackageFile)
if err != nil {
t.Fatal(err)
}
i := repo.NewIndexFile()
i.Add(&helmchart.Metadata{Name: "helmchart", Version: "0.1.0"}, "helmchart-0.1.0.tgz", "http://example.com/charts", "sha256:1234567890")
mg := mockGetter{response: b}
cr := &helm.ChartRepository{
URL: remoteDepFixture.Repository,
Index: i,
Client: &mg,
}
dm := DependencyManager{
Chart: &chart,
Dependencies: []*DependencyWithRepository{
{
Dependency: &remoteDepFixture,
Repo: cr,
},
},
}
if err := dm.Build(); err != nil {
t.Errorf("Build() expected to not return error: %s", err)
}
deps := dm.Chart.Dependencies()
if len(deps) != 1 {
t.Fatalf("chart expected to have one dependency registered")
}
if deps[0].Metadata.Name != remoteDepFixture.Name {
t.Errorf("chart dependency has incorrect name, expected: %s, got: %s", remoteDepFixture.Name, deps[0].Metadata.Name)
}
if deps[0].Metadata.Version != remoteDepFixture.Version {
t.Errorf("chart dependency has incorrect version, expected: %s, got: %s", remoteDepFixture.Version, deps[0].Metadata.Version)
}
// When repo is not set
dm.Dependencies[0].Repo = nil
if err := dm.Build(); err == nil {
t.Errorf("Build() expected to return error")
} else if !strings.Contains(err.Error(), "chartrepo should not be nil") {
t.Errorf("Build() expected to return different error, got: %s", err)
}
}
type mockGetter struct {
response []byte
}
func (g *mockGetter) Get(url string, options ...getter.Option) (*bytes.Buffer, error) {
return bytes.NewBuffer(g.response), nil
}

View File

@ -28,6 +28,7 @@ import (
"github.com/fluxcd/pkg/apis/meta"
"github.com/go-logr/logr"
helmchart "helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/chart/loader"
"helm.sh/helm/v3/pkg/chartutil"
"helm.sh/helm/v3/pkg/getter"
@ -433,18 +434,130 @@ func (r *HelmChartReconciler) reconcileFromTarballArtifact(ctx context.Context,
// Either (re)package the chart with the declared default values file,
// or write the chart directly to storage.
pkgPath := chartPath
isDir := chartFileInfo.IsDir()
if isDir || (chart.Spec.ValuesFile != "" && chart.Spec.ValuesFile != chartutil.ValuesfileName) {
isValuesFileOverriden := false
if chart.Spec.ValuesFile != "" && chart.Spec.ValuesFile != chartutil.ValuesfileName {
// Overwrite default values if configured
if changed, err := helm.OverwriteChartDefaultValues(helmChart, chart.Spec.ValuesFile); err != nil {
isValuesFileOverriden, err = helm.OverwriteChartDefaultValues(helmChart, chart.Spec.ValuesFile)
if err != nil {
return sourcev1.HelmChartNotReady(chart, sourcev1.ChartPackageFailedReason, err.Error()), err
} else if isDir || changed {
// Package the chart
pkgPath, err = chartutil.Save(helmChart, tmpDir)
if err != nil {
err = fmt.Errorf("chart package error: %w", err)
return sourcev1.HelmChartNotReady(chart, sourcev1.ChartPackageFailedReason, err.Error()), err
}
}
isDir := chartFileInfo.IsDir()
switch {
case isDir:
// Load dependencies
if err = chartutil.ProcessDependencies(helmChart, helmChart.Values); err != nil {
err = fmt.Errorf("failed to process chart dependencies: %w", err)
return sourcev1.HelmChartNotReady(chart, sourcev1.StorageOperationFailedReason, err.Error()), err
}
// Determine chart dependencies
deps := helmChart.Dependencies()
reqs := helmChart.Metadata.Dependencies
lock := helmChart.Lock
if lock != nil {
// Load from lockfile if exists
reqs = lock.Dependencies
}
var dwr []*DependencyWithRepository
for _, dep := range reqs {
// Exclude existing dependencies
for _, existing := range deps {
if existing.Name() == dep.Name {
continue
}
}
// Continue loop if file scheme detected
if strings.HasPrefix(dep.Repository, "file://") {
dwr = append(dwr, &DependencyWithRepository{
Dependency: dep,
Repo: nil,
})
continue
}
// Discover existing HelmRepository by URL
repository, err := r.resolveDependencyRepository(ctx, dep, chart.Namespace)
if err != nil {
repository = &sourcev1.HelmRepository{
Spec: sourcev1.HelmRepositorySpec{
URL: dep.Repository,
},
}
}
// Configure ChartRepository getter options
var clientOpts []getter.Option
if secret, err := r.getHelmRepositorySecret(ctx, repository); err != nil {
return sourcev1.HelmChartNotReady(chart, sourcev1.AuthenticationFailedReason, err.Error()), err
} else if secret != nil {
opts, cleanup, err := helm.ClientOptionsFromSecret(*secret)
if err != nil {
err = fmt.Errorf("auth options error: %w", err)
return sourcev1.HelmChartNotReady(chart, sourcev1.AuthenticationFailedReason, err.Error()), err
}
defer cleanup()
clientOpts = opts
}
// Initialize the chart repository and load the index file
chartRepo, err := helm.NewChartRepository(repository.Spec.URL, r.Getters, clientOpts)
if err != nil {
switch err.(type) {
case *url.Error:
return sourcev1.HelmChartNotReady(chart, sourcev1.URLInvalidReason, err.Error()), err
default:
return sourcev1.HelmChartNotReady(chart, sourcev1.ChartPullFailedReason, err.Error()), err
}
}
if repository.Status.Artifact != nil {
indexFile, err := os.Open(r.Storage.LocalPath(*repository.GetArtifact()))
if err != nil {
return sourcev1.HelmChartNotReady(chart, sourcev1.StorageOperationFailedReason, err.Error()), err
}
b, err := ioutil.ReadAll(indexFile)
if err != nil {
return sourcev1.HelmChartNotReady(chart, sourcev1.ChartPullFailedReason, err.Error()), err
}
if err = chartRepo.LoadIndex(b); err != nil {
return sourcev1.HelmChartNotReady(chart, sourcev1.ChartPullFailedReason, err.Error()), err
}
} else {
// Download index
err = chartRepo.DownloadIndex()
if err != nil {
return sourcev1.HelmChartNotReady(chart, sourcev1.ChartPullFailedReason, err.Error()), err
}
}
dwr = append(dwr, &DependencyWithRepository{
Dependency: dep,
Repo: chartRepo,
})
}
// Construct dependencies for chart if any
if len(dwr) > 0 {
dm := &DependencyManager{
Chart: helmChart,
ChartPath: chartPath,
Dependencies: dwr,
}
err = dm.Build()
if err != nil {
return sourcev1.HelmChartNotReady(chart, sourcev1.StorageOperationFailedReason, err.Error()), err
}
}
fallthrough
case isValuesFileOverriden:
pkgPath, err = chartutil.Save(helmChart, tmpDir)
if err != nil {
err = fmt.Errorf("chart package error: %w", err)
return sourcev1.HelmChartNotReady(chart, sourcev1.ChartPackageFailedReason, err.Error()), err
}
}
@ -590,6 +703,28 @@ func (r *HelmChartReconciler) indexHelmRepositoryByURL(o runtime.Object) []strin
return nil
}
func (r *HelmChartReconciler) resolveDependencyRepository(ctx context.Context, dep *helmchart.Dependency, namespace string) (*sourcev1.HelmRepository, error) {
url := helm.NormalizeChartRepositoryURL(dep.Repository)
if url == "" {
return nil, fmt.Errorf("invalid repository URL")
}
listOpts := []client.ListOption{
client.InNamespace(namespace),
client.MatchingField(sourcev1.HelmRepositoryURLIndexKey, url),
}
var list sourcev1.HelmRepositoryList
err := r.Client.List(ctx, &list, listOpts...)
if err != nil {
return nil, fmt.Errorf("unable to retrieve HelmRepositoryList: %w", err)
}
if len(list.Items) > 0 {
return &list.Items[0], nil
}
return nil, fmt.Errorf("no HelmRepository found")
}
func (r *HelmChartReconciler) getHelmRepositorySecret(ctx context.Context, repository *sourcev1.HelmRepository) (*corev1.Secret, error) {
if repository.Spec.SecretRef != nil {
name := types.NamespacedName{

View File

@ -693,4 +693,247 @@ var _ = Describe("HelmChartReconciler", func() {
}, timeout, interval).Should(BeTrue())
})
})
Context("HelmChart from GitRepository with HelmRepository dependency", func() {
var (
namespace *corev1.Namespace
gitServer *gittestserver.GitServer
helmServer *helmtestserver.HelmServer
err error
)
BeforeEach(func() {
namespace = &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{Name: "test-git-repository-" + randStringRunes(5)},
}
err = k8sClient.Create(context.Background(), namespace)
Expect(err).NotTo(HaveOccurred(), "failed to create test namespace")
gitServer, err = gittestserver.NewTempGitServer()
Expect(err).NotTo(HaveOccurred())
gitServer.AutoCreate()
Expect(gitServer.StartHTTP()).To(Succeed())
helmServer, err = helmtestserver.NewTempHelmServer()
Expect(err).To(Succeed())
helmServer.Start()
})
AfterEach(func() {
gitServer.StopHTTP()
os.RemoveAll(gitServer.Root())
os.RemoveAll(helmServer.Root())
helmServer.Stop()
err = k8sClient.Delete(context.Background(), namespace)
Expect(err).NotTo(HaveOccurred(), "failed to delete test namespace")
})
It("Creates artifacts for", func() {
helmServer.Stop()
var username, password = "john", "doe"
helmServer.WithMiddleware(func(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, p, ok := r.BasicAuth()
if !ok || username != u || password != p {
w.WriteHeader(401)
return
}
handler.ServeHTTP(w, r)
})
})
helmServer.Start()
Expect(helmServer.PackageChart(path.Join("testdata/charts/helmchart"))).Should(Succeed())
Expect(helmServer.GenerateIndex()).Should(Succeed())
secretKey := types.NamespacedName{
Name: "helmrepository-auth-" + randStringRunes(5),
Namespace: namespace.Name,
}
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: secretKey.Name,
Namespace: secretKey.Namespace,
},
Data: map[string][]byte{
"username": []byte(username),
"password": []byte(password),
},
}
Expect(k8sClient.Create(context.Background(), secret)).Should(Succeed())
By("Creating repository and waiting for artifact")
helmRepositoryKey := types.NamespacedName{
Name: "helmrepository-sample-" + randStringRunes(5),
Namespace: namespace.Name,
}
helmRepository := &sourcev1.HelmRepository{
ObjectMeta: metav1.ObjectMeta{
Name: helmRepositoryKey.Name,
Namespace: helmRepositoryKey.Namespace,
},
Spec: sourcev1.HelmRepositorySpec{
URL: helmServer.URL(),
SecretRef: &corev1.LocalObjectReference{
Name: secretKey.Name,
},
Interval: metav1.Duration{Duration: pullInterval},
},
}
Expect(k8sClient.Create(context.Background(), helmRepository)).Should(Succeed())
defer k8sClient.Delete(context.Background(), helmRepository)
Eventually(func() bool {
_ = k8sClient.Get(context.Background(), helmRepositoryKey, helmRepository)
return helmRepository.Status.Artifact != nil
}, timeout, interval).Should(BeTrue())
fs := memfs.New()
gitrepo, err := git.Init(memory.NewStorage(), fs)
Expect(err).NotTo(HaveOccurred())
wt, err := gitrepo.Worktree()
Expect(err).NotTo(HaveOccurred())
u, err := url.Parse(gitServer.HTTPAddress())
Expect(err).NotTo(HaveOccurred())
u.Path = path.Join(u.Path, fmt.Sprintf("repository-%s.git", randStringRunes(5)))
_, err = gitrepo.CreateRemote(&config.RemoteConfig{
Name: "origin",
URLs: []string{u.String()},
})
Expect(err).NotTo(HaveOccurred())
chartDir := "testdata/charts/helmchartwithdeps"
Expect(filepath.Walk(chartDir, func(p string, fi os.FileInfo, err error) error {
if err != nil {
return err
}
switch {
case fi.Mode().IsDir():
return fs.MkdirAll(p, os.ModeDir)
case !fi.Mode().IsRegular():
return nil
}
b, err := ioutil.ReadFile(p)
if err != nil {
return err
}
ff, err := fs.Create(p)
if err != nil {
return err
}
if _, err := ff.Write(b); err != nil {
return err
}
_ = ff.Close()
_, err = wt.Add(p)
return err
})).To(Succeed())
By("Configuring the chart dependency")
filePath := fs.Join(chartDir, chartutil.ChartfileName)
f, err := fs.OpenFile(filePath, os.O_RDWR, os.FileMode(0600))
Expect(err).NotTo(HaveOccurred())
b := make([]byte, 2048)
n, err := f.Read(b)
Expect(err).NotTo(HaveOccurred())
b = b[0:n]
err = f.Close()
Expect(err).NotTo(HaveOccurred())
y := new(helmchart.Metadata)
err = yaml.Unmarshal(b, y)
Expect(err).NotTo(HaveOccurred())
y.Dependencies = []*helmchart.Dependency{
{
Name: "helmchart",
Version: ">=0.1.0",
Repository: helmRepository.Spec.URL,
},
}
b, err = yaml.Marshal(y)
Expect(err).NotTo(HaveOccurred())
ff, err := fs.Create(filePath)
Expect(err).NotTo(HaveOccurred())
_, err = ff.Write(b)
Expect(err).NotTo(HaveOccurred())
err = ff.Close()
Expect(err).NotTo(HaveOccurred())
_, err = wt.Commit("Helm charts", &git.CommitOptions{
Author: &object.Signature{
Name: "John Doe",
Email: "john@example.com",
When: time.Now(),
},
All: true,
})
Expect(err).NotTo(HaveOccurred())
err = gitrepo.Push(&git.PushOptions{})
Expect(err).NotTo(HaveOccurred())
repositoryKey := types.NamespacedName{
Name: fmt.Sprintf("git-repository-sample-%s", randStringRunes(5)),
Namespace: namespace.Name,
}
repository := &sourcev1.GitRepository{
ObjectMeta: metav1.ObjectMeta{
Name: repositoryKey.Name,
Namespace: repositoryKey.Namespace,
},
Spec: sourcev1.GitRepositorySpec{
URL: u.String(),
Interval: metav1.Duration{Duration: indexInterval},
},
}
Expect(k8sClient.Create(context.Background(), repository)).Should(Succeed())
defer k8sClient.Delete(context.Background(), repository)
key := types.NamespacedName{
Name: "helmchart-sample-" + randStringRunes(5),
Namespace: namespace.Name,
}
chart := &sourcev1.HelmChart{
ObjectMeta: metav1.ObjectMeta{
Name: key.Name,
Namespace: key.Namespace,
},
Spec: sourcev1.HelmChartSpec{
Chart: "testdata/charts/helmchartwithdeps",
Version: "*",
SourceRef: sourcev1.LocalHelmChartSourceReference{
Kind: sourcev1.GitRepositoryKind,
Name: repositoryKey.Name,
},
Interval: metav1.Duration{Duration: pullInterval},
},
}
Expect(k8sClient.Create(context.Background(), chart)).Should(Succeed())
defer k8sClient.Delete(context.Background(), chart)
By("Expecting artifact")
got := &sourcev1.HelmChart{}
Eventually(func() bool {
_ = k8sClient.Get(context.Background(), key, got)
return got.Status.Artifact != nil &&
storage.ArtifactExist(*got.Status.Artifact)
}, timeout, interval).Should(BeTrue())
})
})
})

Binary file not shown.

1
go.mod
View File

@ -21,6 +21,7 @@ require (
github.com/minio/minio-go/v7 v7.0.5
github.com/onsi/ginkgo v1.12.1
github.com/onsi/gomega v1.10.1
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e
helm.sh/helm/v3 v3.3.4
k8s.io/api v0.18.9
k8s.io/apimachinery v0.18.9