[WIP] Major release 1.0 (#147)

* Object model with self-sufficient binary (#131)

* Code cosmetics

* Experimenting with object model and direct HELM usage

* Experiment with object model

* replacing the kubectl

* Progressing

* Save the progress

* Able to start with migration in mind

* Migrated two pieces

* List releases via Helm

* Forgotten field

* Cristallized the problem of ctx switcher

* Reworked to multi-context

* Rollback is also new style

* More migration

* Refactoring

* Describe via code

* Bye-bye kubectl binary

* Eliminate more old code

* Refactor a bit

* Merges

* No binaries in dockerfile

* Commit

* Progress with getting the data

* Learned the thing about get

* One field less

* Sstart with repos

* Repo add

* repo remove

* Repos! Icons!

* Simplified access to data

* Ver listing works

* Ver check works

* Caching and values

* fixup

* Done with repos

* Working on install

* Install work-ish

* Fix UI failing on install

* Upgrade flow works

* Fix image building

* Remove outdated test file

* Move files around

* REfactorings

* Cosmetics

* Test for cache control (#151)

* Files import formatted

* Added go-test tools

* Added test for no-cache header

* added changes

* test for cache behaviour of app

* test for static route (#153)

* Tests: route configuration & context setter (#154)

* Test for route configuration

* Test for context setter middleware

* implemented changes

* Restore coverage profile

Fixes #156

* Cosmetics

* Test for `NewRouter` function (#157)

* Test for `configureScanners` (#158)

* Test for `configureKubectls` (#163)

* Test for repository loading (#169)

- Created `repos_test.go`
- Test: `Load()` of Repositories

* Build all PRs

* Fixes failing test (#171)

* Fixes failing test
- Fixes failing test of repo loading

* handles error for

* Did some changes

* Test for listing of repos (#173)

- and did some code formatting

Signed-off-by: OmAxiani0 <aximaniom@gmail.com>

Signed-off-by: OmAxiani0 <aximaniom@gmail.com>

* Test for adding repo (#175)

- Modified the `repositories.yml` file

Signed-off-by: OmAxiani0 <aximaniom@gmail.com>

Signed-off-by: OmAxiani0 <aximaniom@gmail.com>

* Test for deleting the repository (#176)

* Test for deleting the repository
- Also added cleanup function for `TestAdd`

* Fixes failing test

* Add auto labeler for PR's (#174)

* Add auto labeler for PR's

* Add all file under .github/workflow to 'ci' label

Co-authored-by: Harshit Mehta <harshitm@nvidia.com>

* Test for getting repository (#177)

* Add github workflow for auto PR labeling (#181)

Co-authored-by: Harshit Mehta <harshitm@nvidia.com>

* Stub compilation

* Fixes around installing

* More complex test

* Using object model to execute helm test (#191)

* Expand test

* More test

* Coverage

* Add mutex for operations

* Rectore cluster detection code

* Change receiver to pointer

* Support multiple namespaces

* Cosmetics

* Update repos periodically

* fix tests

* Fix error display

* Allow reconfiguring chart without repo

* mute  linter

* Cosmetics

* Failing approach to parse manifests

Relates to #30

* Report the error properly

*  Add test for dashboard/objects/data.go NewDataLayer (#199)

* Fix problem of wrong namespace

* Added unit tests for releases (#204)

* Rework API routes (#197)

* Bootstrap OpenAPI doc

* Renaming some routes

* Listing namespaces

* k8s part of things

* Repositories section

* Document scanners API

* One more API call

* Progress

* Reworked install flow

* History endpoint

* Textual info section

* Resources endpoint

* Rollback endpoint

* Rollback endpoint

* Unit tests

* Cleanup

* Forgotten tags

* Fix tests

* TODOs

* Rework manifest scanning

* add hasTests flag

* Adding more information on UI for helm test API response (#195)

* Hide test button when no tests

Fixes #115
Improves #195

---------

Signed-off-by: OmAxiani0 <aximaniom@gmail.com>
Co-authored-by: Om Aximani <75031769+OmAximani0@users.noreply.github.com>
Co-authored-by: Harshit Mehta <hdm23061993@gmail.com>
Co-authored-by: Harshit Mehta <harshitm@nvidia.com>
Co-authored-by: Todd Turner <todd@toddtee.sh>
Co-authored-by: arvindsundararajan98 <109727359+arvindsundararajan98@users.noreply.github.com>
This commit is contained in:
Andrey Pokhilko
2023-02-01 13:24:34 +00:00
committed by GitHub
parent 6ffcdf2b8e
commit e13aa2fde6
44 changed files with 4338 additions and 1278 deletions

View File

@@ -0,0 +1,49 @@
package objects
import (
"github.com/joomcode/errorx"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/cli"
// Import to initialize client auth plugins.
// From https://github.com/kubernetes/client-go/issues/242
_ "k8s.io/client-go/plugin/pkg/client/auth"
)
type HelmConfigGetter = func(sett *cli.EnvSettings, ns string) (*action.Configuration, error)
type HelmNSConfigGetter = func(ns string) (*action.Configuration, error)
type Application struct {
Settings *cli.EnvSettings
HelmConfig HelmNSConfigGetter
K8s *K8s
Releases *Releases
Repositories *Repositories
}
func NewApplication(settings *cli.EnvSettings, helmConfig HelmNSConfigGetter, namespaces []string) (*Application, error) {
hc, err := helmConfig(settings.Namespace())
if err != nil {
return nil, errorx.Decorate(err, "failed to get helm config for namespace '%s'", "")
}
k8s, err := NewK8s(hc, namespaces)
if err != nil {
return nil, errorx.Decorate(err, "failed to get k8s client")
}
return &Application{
HelmConfig: helmConfig,
K8s: k8s,
Releases: &Releases{
Namespaces: namespaces,
Settings: settings,
HelmConfig: helmConfig,
},
Repositories: &Repositories{
Settings: settings,
HelmConfig: hc,
},
}, nil
}

View File

@@ -0,0 +1,76 @@
package objects
import (
"context"
"errors"
"github.com/eko/gocache/v3/marshaler"
"github.com/eko/gocache/v3/store"
gocache "github.com/patrickmn/go-cache"
log "github.com/sirupsen/logrus"
"time"
)
type CacheKey = string
type Cache struct {
Marshaler *marshaler.Marshaler `json:"-"`
HitCount int
MissCount int
}
func NewCache() *Cache {
gocacheClient := gocache.New(60*time.Minute, 10*time.Minute)
gocacheStore := store.NewGoCache(gocacheClient)
// TODO: use tiered cache with some disk backend, allow configuring that static cache folder
// Initializes marshaler
marshal := marshaler.New(gocacheStore)
return &Cache{
Marshaler: marshal,
}
}
func (c *Cache) String(key CacheKey, tags []string, callback func() (string, error)) (string, error) {
if tags == nil {
tags = make([]string, 0)
}
tags = append(tags, key)
ctx := context.Background()
out := ""
_, err := c.Marshaler.Get(ctx, key, &out)
if err == nil {
log.Debugf("Using cached value for %s", key)
c.HitCount++
return out, nil
} else if !errors.Is(err, store.NotFound{}) {
return "", err
}
c.MissCount++
out, err = callback()
if err != nil {
return "", err
}
err = c.Marshaler.Set(ctx, key, out, store.WithTags(tags))
if err != nil {
return "", err
}
return out, nil
}
func (c *Cache) Invalidate(tags ...CacheKey) {
log.Debugf("Invalidating tags %v", tags)
err := c.Marshaler.Invalidate(context.Background(), store.WithInvalidateTags(tags))
if err != nil {
log.Warnf("Failed to invalidate tags %v: %s", tags, err)
}
}
func (c *Cache) Clear() error {
c.HitCount = 0
c.MissCount = 0
return c.Marshaler.Clear(context.Background())
}

View File

@@ -0,0 +1,232 @@
package objects
import (
"bytes"
"context"
"encoding/json"
"sync"
"time"
"github.com/joomcode/errorx"
"github.com/komodorio/helm-dashboard/pkg/dashboard/subproc"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/cli"
"helm.sh/helm/v3/pkg/release"
"io"
v1 "k8s.io/apimachinery/pkg/apis/testapigroup/v1"
"k8s.io/client-go/tools/clientcmd"
)
type DataLayer struct {
KubeContext string
Scanners []subproc.Scanner
StatusInfo *StatusInfo
Namespaces []string
Cache *Cache
ConfGen HelmConfigGetter
appPerContext map[string]*Application
appPerContextMx *sync.Mutex
}
type StatusInfo struct {
CurVer string
LatestVer string
Analytics bool
CacheHitRatio float64
ClusterMode bool
}
func NewDataLayer(ns []string, ver string, cg HelmConfigGetter) (*DataLayer, error) {
if cg == nil {
return nil, errors.New("HelmConfigGetter can't be nil")
}
return &DataLayer{
Namespaces: ns,
Cache: NewCache(),
StatusInfo: &StatusInfo{
CurVer: ver,
Analytics: false,
},
ConfGen: cg,
appPerContext: map[string]*Application{},
appPerContextMx: new(sync.Mutex),
}, nil
}
func (d *DataLayer) ListContexts() ([]KubeContext, error) {
res := []KubeContext{}
if d.StatusInfo.ClusterMode {
return res, nil
}
cfg, err := clientcmd.NewDefaultPathOptions().GetStartingConfig()
if err != nil {
return nil, errorx.Decorate(err, "failed to get kubectl config")
}
for name, ctx := range cfg.Contexts {
res = append(res, KubeContext{
IsCurrent: cfg.CurrentContext == name,
Name: name,
Cluster: ctx.Cluster,
AuthInfo: ctx.AuthInfo,
Namespace: ctx.Namespace,
})
}
return res, nil
}
func (d *DataLayer) GetStatus() *StatusInfo {
sum := float64(d.Cache.HitCount + d.Cache.MissCount)
if sum > 0 {
d.StatusInfo.CacheHitRatio = float64(d.Cache.HitCount) / sum
} else {
d.StatusInfo.CacheHitRatio = 0
}
return d.StatusInfo
}
type SectionFn = func(*release.Release, bool) (string, error)
func ParseManifests(out string) ([]*v1.Carp, error) {
dec := yaml.NewDecoder(bytes.NewReader([]byte(out)))
res := make([]*v1.Carp, 0)
var tmp interface{}
for {
err := dec.Decode(&tmp)
if err == io.EOF {
break
}
if err != nil {
return nil, errorx.Decorate(err, "failed to parse manifest document #%d", len(res)+1)
}
// k8s libs uses only JSON tags defined, say hello to https://github.com/go-yaml/yaml/issues/424
// we can juggle it
jsoned, err := json.Marshal(tmp)
if err != nil {
return nil, err
}
var doc v1.Carp
err = json.Unmarshal(jsoned, &doc)
if err != nil {
return nil, err
}
if doc.Kind == "" {
log.Warnf("Manifest piece is not k8s resource: %s", jsoned)
continue
}
res = append(res, &doc)
}
return res, nil
}
func (d *DataLayer) SetContext(ctx string) error {
if d.KubeContext != ctx {
err := d.Cache.Clear()
if err != nil {
return errors.Wrap(err, "failed to set context")
}
}
d.KubeContext = ctx
return nil
}
func (d *DataLayer) AppForCtx(ctx string) (*Application, error) {
d.appPerContextMx.Lock()
defer d.appPerContextMx.Unlock()
app, ok := d.appPerContext[ctx]
if !ok {
settings := cli.New()
settings.KubeContext = ctx
settings.SetNamespace(d.nsForCtx(ctx))
cfgGetter := func(ns string) (*action.Configuration, error) {
return d.ConfGen(settings, ns)
}
a, err := NewApplication(settings, cfgGetter, d.Namespaces)
if err != nil {
return nil, errorx.Decorate(err, "Failed to create application for context '%s'", ctx)
}
app = a
d.appPerContext[ctx] = app
}
return app, nil
}
func (d *DataLayer) nsForCtx(ctx string) string {
lst, err := d.ListContexts()
if err != nil {
log.Debugf("Failed to get contexts for NS lookup: %+v", err)
}
for _, c := range lst {
if c.Name == ctx {
return c.Namespace
}
}
log.Debugf("Strange: no context found for '%s'", ctx)
return ""
}
func (d *DataLayer) PeriodicTasks(ctx context.Context) {
if !d.StatusInfo.ClusterMode { // TODO: maybe have a separate flag for that?
log.Debugf("Not in cluster mode, not starting background tasks")
return
}
// auto-update repos
go d.loopUpdateRepos(ctx, 10*time.Minute) // TODO: parameterize interval?
// auto-scan
}
func (d *DataLayer) loopUpdateRepos(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
for {
app, err := d.AppForCtx("")
if err != nil {
log.Warnf("Failed to get app object while in background repo update: %v", err)
break // no point in retrying
} else {
repos, err := app.Repositories.List()
if err != nil {
log.Warnf("Failed to get list of repos while in background update: %v", err)
}
for _, repo := range repos {
err := repo.Update()
if err != nil {
log.Warnf("Failed to update repo %s: %v", repo.Orig.Name, err)
}
}
}
select {
case <-ctx.Done():
ticker.Stop()
return
case <-ticker.C:
continue
}
}
log.Debugf("Update repo loop done.")
}

View File

@@ -0,0 +1,58 @@
package objects
import (
"testing"
"github.com/stretchr/testify/assert"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/cli"
)
func TestNewDataLayer(t *testing.T) {
testCases := []struct {
name string
namespaces []string
version string
helmConfig HelmConfigGetter
errorExpected bool
}{
{
name: "should return error when helm config is nil",
namespaces: []string{"namespace1", "namespace2"},
version: "1.0.0",
helmConfig: nil,
errorExpected: true,
},
{
name: "should return data layer when all parameters are correct",
namespaces: []string{
"namespace1",
"namespace2",
},
version: "1.0.0",
helmConfig: func(sett *cli.EnvSettings, ns string) (*action.Configuration, error) {
return &action.Configuration{}, nil
},
errorExpected: false,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
dl, err := NewDataLayer(tt.namespaces, tt.version, tt.helmConfig)
if tt.errorExpected {
assert.Error(t, err, "Expected error but got nil")
} else {
assert.Nil(t, err, "NewDataLayer returned an error: %v", err)
assert.NotNil(t, dl, "NewDataLayer returned nil")
assert.Equal(t, tt.namespaces, dl.Namespaces, "NewDataLayer returned incorrect namespaces: %v", dl.Namespaces)
assert.NotNil(t, dl.Cache, "NewDataLayer returned nil cache")
assert.Equal(t, tt.version, dl.StatusInfo.CurVer, "NewDataLayer returned incorrect version: %v", dl.StatusInfo.CurVer)
assert.False(t, dl.StatusInfo.Analytics, "NewDataLayer returned incorrect version: %v", dl.StatusInfo.CurVer)
assert.NotNil(t, dl.appPerContext, "NewDataLayer returned nil appPerContext")
assert.NotNil(t, dl.ConfGen, "NewDataLayer returned nil ConfGen")
}
})
}
}

View File

@@ -0,0 +1,198 @@
package objects
import (
"context"
"encoding/json"
"github.com/joomcode/errorx"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/kube"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
testapiv1 "k8s.io/apimachinery/pkg/apis/testapigroup/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/cli-runtime/pkg/genericclioptions"
"k8s.io/cli-runtime/pkg/resource"
"k8s.io/client-go/discovery"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
describecmd "k8s.io/kubectl/pkg/cmd/describe"
cmdutil "k8s.io/kubectl/pkg/cmd/util"
"k8s.io/kubectl/pkg/describe"
"k8s.io/utils/strings/slices"
"sort"
)
type KubeContext struct {
IsCurrent bool
Name string
Cluster string
AuthInfo string
Namespace string
}
// maps action.RESTClientGetter into genericclioptions.RESTClientGetter
type cfgProxyObject struct {
Impl action.RESTClientGetter
}
func (p *cfgProxyObject) ToRESTConfig() (*rest.Config, error) {
return p.Impl.ToRESTConfig()
}
func (p *cfgProxyObject) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) {
return p.Impl.ToDiscoveryClient()
}
func (p *cfgProxyObject) ToRESTMapper() (meta.RESTMapper, error) {
return p.Impl.ToRESTMapper()
}
func (p *cfgProxyObject) ToRawKubeConfigLoader() clientcmd.ClientConfig {
panic("Not implemented, stub")
}
type K8s struct {
Namespaces []string
Factory kube.Factory
RestClientGetter genericclioptions.RESTClientGetter
}
func NewK8s(helmConfig *action.Configuration, namespaces []string) (*K8s, error) {
factory := cmdutil.NewFactory(&cfgProxyObject{Impl: helmConfig.RESTClientGetter})
return &K8s{
Namespaces: namespaces,
Factory: factory,
RestClientGetter: factory,
}, nil
}
func (k *K8s) GetNameSpaces() (res *corev1.NamespaceList, err error) {
clientset, err := k.Factory.KubernetesClientSet()
if err != nil {
return nil, errors.Wrap(err, "failed to get KubernetesClientSet")
}
lst, err := clientset.CoreV1().Namespaces().List(context.Background(), metav1.ListOptions{})
if err != nil {
return nil, errors.Wrap(err, "failed to get list of namespaces")
}
if !slices.Contains(k.Namespaces, "") {
filtered := []corev1.Namespace{}
for _, ns := range lst.Items {
if slices.Contains(k.Namespaces, ns.Name) {
filtered = append(filtered, ns)
}
}
lst.Items = filtered
}
return lst, nil
}
func (k *K8s) DescribeResource(kind string, ns string, name string) (string, error) {
log.Debugf("Describing resource: %s %s in %s", kind, name, ns)
streams, _, out, errout := genericclioptions.NewTestIOStreams()
o := &describecmd.DescribeOptions{
Describer: func(mapping *meta.RESTMapping) (describe.ResourceDescriber, error) {
return describe.DescriberFn(k.RestClientGetter, mapping)
},
FilenameOptions: &resource.FilenameOptions{},
DescriberSettings: &describe.DescriberSettings{
ShowEvents: true,
ChunkSize: cmdutil.DefaultChunkSize,
},
IOStreams: streams,
NewBuilder: k.Factory.NewBuilder,
}
o.Namespace = ns
o.BuilderArgs = []string{kind, name}
err := o.Run()
if err != nil {
return "", errorx.Decorate(err, "Failed to run describe command: %s", errout.String())
}
return out.String(), nil
}
func (k *K8s) GetResource(kind string, namespace string, name string) (*runtime.Object, error) {
builder := k.Factory.NewBuilder()
resp := builder.Unstructured().NamespaceParam(namespace).Flatten().ResourceNames(kind, name).Do()
if resp.Err() != nil {
return nil, errorx.Decorate(resp.Err(), "failed to get k8s resource")
}
obj, err := resp.Object()
if err != nil {
return nil, errorx.Decorate(err, "failed to get k8s resulting object")
}
return &obj, nil
}
func (k *K8s) GetResourceInfo(kind string, namespace string, name string) (*testapiv1.Carp, error) {
obj, err := k.GetResource(kind, namespace, name)
if err != nil {
return nil, errorx.Decorate(err, "failed to get k8s object")
}
data, err := json.Marshal(obj)
if err != nil {
return nil, errorx.Decorate(err, "failed to marshal k8s object into JSON")
}
res := new(testapiv1.Carp)
err = json.Unmarshal(data, &res)
if err != nil {
return nil, errorx.Decorate(err, "failed to decode k8s object from JSON")
}
sort.Slice(res.Status.Conditions, func(i, j int) bool {
// some condition types always bubble up
if res.Status.Conditions[i].Type == "Available" {
return false
}
if res.Status.Conditions[j].Type == "Available" {
return true
}
t1 := res.Status.Conditions[i].LastTransitionTime
t2 := res.Status.Conditions[j].LastTransitionTime
return t1.Time.Before(t2.Time)
})
return res, nil
}
func (k *K8s) GetResourceYAML(kind string, namespace string, name string) (string, error) {
obj, err := k.GetResource(kind, namespace, name)
if err != nil {
return "", errorx.Decorate(err, "failed to get k8s object")
}
data, err := json.Marshal(obj)
if err != nil {
return "", errorx.Decorate(err, "failed to marshal k8s object into JSON")
}
res := map[string]interface{}{}
err = json.Unmarshal(data, &res)
if err != nil {
return "", errorx.Decorate(err, "failed to decode k8s object from JSON")
}
ydata, err := yaml.Marshal(res)
if err != nil {
return "", errorx.Decorate(err, "failed to marshal k8s object into JSON")
}
return string(ydata), nil
}

View File

@@ -0,0 +1,399 @@
package objects
import (
"bytes"
"fmt"
"gopkg.in/yaml.v3"
"io/ioutil"
"os"
"path"
"sync"
"github.com/joomcode/errorx"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/chart/loader"
"helm.sh/helm/v3/pkg/cli"
"helm.sh/helm/v3/pkg/downloader"
"helm.sh/helm/v3/pkg/getter"
"helm.sh/helm/v3/pkg/release"
v1 "k8s.io/apimachinery/pkg/apis/testapigroup/v1"
)
type Releases struct {
Namespaces []string
HelmConfig HelmNSConfigGetter
Settings *cli.EnvSettings
mx sync.Mutex
}
func (a *Releases) List() ([]*Release, error) {
a.mx.Lock()
defer a.mx.Unlock()
releases := []*Release{}
for _, ns := range a.Namespaces {
log.Debugf("Listing releases in namespace: %s", ns)
hc, err := a.HelmConfig(ns)
if err != nil {
return nil, errorx.Decorate(err, "failed to get helm config for namespace '%s'", "")
}
client := action.NewList(hc)
client.All = true
client.AllNamespaces = true
client.Limit = 0
rels, err := client.Run()
if err != nil {
return nil, errorx.Decorate(err, "failed to get list of releases")
}
for _, r := range rels {
releases = append(releases, &Release{HelmConfig: a.HelmConfig, Orig: r, Settings: a.Settings})
}
}
return releases, nil
}
func (a *Releases) ByName(namespace string, name string) (*Release, error) {
rels, err := a.List()
if err != nil {
return nil, errorx.Decorate(err, "failed to get list of releases")
}
for _, r := range rels {
if r.Orig.Namespace == namespace && r.Orig.Name == name {
return r, nil
}
}
return nil, errorx.DataUnavailable.New(fmt.Sprintf("release '%s' is not found in namespace '%s'", name, namespace))
}
func (a *Releases) Install(namespace string, name string, repoChart string, version string, justTemplate bool, values map[string]interface{}) (*release.Release, error) {
a.mx.Lock()
defer a.mx.Unlock()
if namespace == "" {
namespace = a.Settings.Namespace()
}
hc, err := a.HelmConfig(namespace)
if err != nil {
return nil, errorx.Decorate(err, "failed to get helm config for namespace '%s'", "")
}
cmd := action.NewInstall(hc)
cmd.ReleaseName = name
cmd.CreateNamespace = true
cmd.Namespace = namespace
cmd.Version = version
cmd.DryRun = justTemplate
chrt, err := locateChart(cmd.ChartPathOptions, repoChart, a.Settings)
if err != nil {
return nil, err
}
res, err := cmd.Run(chrt, values)
if err != nil {
return nil, err
}
if !justTemplate {
log.Infof("Installed new release: %s/%s", namespace, name)
}
return res, nil
}
func locateChart(pathOpts action.ChartPathOptions, chart string, settings *cli.EnvSettings) (*chart.Chart, error) {
// from cmd/helm/install.go and cmd/helm/upgrade.go
cp, err := pathOpts.LocateChart(chart, settings)
if err != nil {
return nil, err
}
log.Debugf("Located chart %s: %s\n", chart, cp)
p := getter.All(settings)
// Check chart dependencies to make sure all are present in /charts
chartRequested, err := loader.Load(cp)
if err != nil {
return nil, err
}
if err := checkIfInstallable(chartRequested); err != nil {
return nil, err
}
if req := chartRequested.Metadata.Dependencies; req != nil {
// If CheckDependencies returns an error, we have unfulfilled dependencies.
// As of Helm 2.4.0, this is treated as a stopping condition:
// https://github.com/helm/helm/issues/2209
if err := action.CheckDependencies(chartRequested, req); err != nil {
err = errorx.Decorate(err, "An error occurred while checking for chart dependencies. You may need to run `helm dependency build` to fetch missing dependencies")
if true { // client.DependencyUpdate
man := &downloader.Manager{
Out: ioutil.Discard,
ChartPath: cp,
Keyring: pathOpts.Keyring,
SkipUpdate: false,
Getters: p,
RepositoryConfig: settings.RepositoryConfig,
RepositoryCache: settings.RepositoryCache,
Debug: settings.Debug,
}
if err := man.Update(); err != nil {
return nil, err
}
// Reload the chart with the updated Chart.lock file.
if chartRequested, err = loader.Load(cp); err != nil {
return nil, errorx.Decorate(err, "failed reloading chart after repo update")
}
} else {
return nil, err
}
}
}
return chartRequested, nil
}
type Release struct {
Settings *cli.EnvSettings
HelmConfig HelmNSConfigGetter
Orig *release.Release
revisions []*Release
mx sync.Mutex
restoredChartPath string
}
func (r *Release) History() ([]*Release, error) {
r.mx.Lock()
defer r.mx.Unlock()
hc, err := r.HelmConfig(r.Orig.Namespace)
if err != nil {
return nil, errorx.Decorate(err, "failed to get helm config for namespace '%s'", "")
}
client := action.NewHistory(hc)
revs, err := client.Run(r.Orig.Name)
if err != nil {
return nil, errorx.Decorate(err, "failed to get revisions of release")
}
r.revisions = []*Release{}
for _, rev := range revs {
r.revisions = append(r.revisions, &Release{HelmConfig: r.HelmConfig, Orig: rev, Settings: r.Settings})
}
return r.revisions, nil
}
func (r *Release) Uninstall() error {
r.mx.Lock()
defer r.mx.Unlock()
hc, err := r.HelmConfig(r.Orig.Namespace)
if err != nil {
return errorx.Decorate(err, "failed to get helm config for namespace '%s'", "")
}
client := action.NewUninstall(hc)
_, err = client.Run(r.Orig.Name)
if err != nil {
return errorx.Decorate(err, "failed to uninstall release")
}
return nil
}
func (r *Release) Rollback(toRevision int) error {
r.mx.Lock()
defer r.mx.Unlock()
hc, err := r.HelmConfig(r.Orig.Namespace)
if err != nil {
return errorx.Decorate(err, "failed to get helm config for namespace '%s'", "")
}
client := action.NewRollback(hc)
client.Version = toRevision
err = client.Run(r.Orig.Name)
if err != nil {
return errorx.Decorate(err, "failed to rollback the release")
}
log.Infof("Rolled back %s/%s to %d=>%d", r.Orig.Namespace, r.Orig.Name, r.Orig.Version, toRevision)
return nil
}
func (r *Release) RunTests() (string, error) {
r.mx.Lock()
defer r.mx.Unlock()
hc, err := r.HelmConfig(r.Orig.Namespace)
if err != nil {
return "", errorx.Decorate(err, "failed to get helm config for namespace '%s'", r.Orig.Namespace)
}
client := action.NewReleaseTesting(hc)
client.Namespace = r.Orig.Namespace
rel, err := client.Run(r.Orig.Name)
if err != nil {
return "", errorx.Decorate(err, "failed to execute 'helm test' for release '%s'", r.Orig.Name)
}
var buf bytes.Buffer
if err := client.GetPodLogs(&buf, rel); err != nil {
return "", errorx.Decorate(err, "failed to fetch logs for 'helm test' command")
}
return buf.String(), nil
}
func (r *Release) ParsedManifests() ([]*v1.Carp, error) {
carps, err := ParseManifests(r.Orig.Manifest)
if err != nil {
return nil, err
}
for _, carp := range carps {
if carp.Namespace == "" {
carp.Namespace = r.Orig.Namespace
}
}
return carps, err
}
func (r *Release) GetRev(revNo int) (*Release, error) {
if revNo == 0 {
revNo = r.Orig.Version
}
hist, err := r.History()
if err != nil {
return nil, errorx.Decorate(err, "failed to get history")
}
for _, rev := range hist {
if rev.Orig.Version == revNo {
return rev, nil
}
}
return nil, errorx.InternalError.New("No revision found for number %d", revNo)
}
func (r *Release) Upgrade(repoChart string, version string, justTemplate bool, values map[string]interface{}) (*release.Release, error) {
r.mx.Lock()
defer r.mx.Unlock()
// if repo chart is not passed, let's try to restore it from secret
if repoChart == "" {
var err error
repoChart, err = r.restoreChart()
if err != nil {
return nil, errorx.Decorate(err, "failed to revive chart for release")
}
}
ns := r.Settings.Namespace()
if r.Orig != nil {
ns = r.Orig.Namespace
}
hc, err := r.HelmConfig(ns)
if err != nil {
return nil, errorx.Decorate(err, "failed to get helm config for namespace '%s'", ns)
}
cmd := action.NewUpgrade(hc)
cmd.Namespace = r.Settings.Namespace()
cmd.Version = version
cmd.DryRun = justTemplate
chrt, err := locateChart(cmd.ChartPathOptions, repoChart, r.Settings)
if err != nil {
return nil, err
}
res, err := cmd.Run(r.Orig.Name, chrt, values)
if err != nil {
return nil, err
}
if !justTemplate {
log.Infof("Upgraded release: %s/%s#%d", res.Namespace, res.Name, res.Version)
}
return res, nil
}
func (r *Release) restoreChart() (string, error) {
if r.restoredChartPath != "" {
return r.restoredChartPath, nil
}
// we're unlikely to have the original chart, let's try the cheesy thing...
log.Infof("Attempting to restore the chart for %s", r.Orig.Name)
dir, err := ioutil.TempDir("", "khd-*")
if err != nil {
return "", errorx.Decorate(err, "failed to get temporary directory")
}
//restore Chart.yaml
cdata, err := yaml.Marshal(r.Orig.Chart.Metadata)
if err != nil {
return "", errorx.Decorate(err, "failed to restore Chart.yaml")
}
err = ioutil.WriteFile(path.Join(dir, "Chart.yaml"), cdata, 0644)
if err != nil {
return "", errorx.Decorate(err, "failed to write file Chart.yaml")
}
//restore known values
vdata, err := yaml.Marshal(r.Orig.Chart.Values)
if err != nil {
return "", errorx.Decorate(err, "failed to restore values.yaml")
}
err = ioutil.WriteFile(path.Join(dir, "values.yaml"), vdata, 0644)
if err != nil {
return "", errorx.Decorate(err, "failed to write file values.yaml")
}
// if possible, overwrite files with better alternatives
for _, f := range append(r.Orig.Chart.Raw, r.Orig.Chart.Templates...) {
fname := path.Join(dir, f.Name)
log.Debugf("Restoring file: %s", fname)
err := os.MkdirAll(path.Dir(fname), 0755)
if err != nil {
return "", errorx.Decorate(err, "failed to create directory for file: %s", fname)
}
err = ioutil.WriteFile(fname, f.Data, 0644)
if err != nil {
return "", errorx.Decorate(err, "failed to write file to restore chart: %s", fname)
}
}
r.restoredChartPath = dir
return dir, nil
}
func checkIfInstallable(ch *chart.Chart) error {
switch ch.Metadata.Type {
case "", "application":
return nil
}
return errors.Errorf("%s charts are not installable", ch.Metadata.Type)
}

View File

@@ -0,0 +1,80 @@
package objects
import (
"sync"
"testing"
"gotest.tools/v3/assert"
"helm.sh/helm/v3/pkg/action"
kubefake "helm.sh/helm/v3/pkg/kube/fake"
"helm.sh/helm/v3/pkg/release"
"helm.sh/helm/v3/pkg/storage"
"helm.sh/helm/v3/pkg/storage/driver"
)
var (
fakeKubeClient *kubefake.PrintingKubeClient
fakeStorage *storage.Storage
)
func fakeHelmNSConfigGetter(ns string) (*action.Configuration, error) {
return &action.Configuration{
KubeClient: fakeKubeClient,
Releases: fakeStorage,
}, nil
}
func TestListReleases(t *testing.T) {
fakeStorage = storage.Init(driver.NewMemory())
err := fakeStorage.Create(&release.Release{
Name: "release1",
Info: &release.Info{
Status: release.StatusDeployed,
},
})
assert.NilError(t, err)
err = fakeStorage.Create(&release.Release{
Name: "release2",
Info: &release.Info{
Status: release.StatusDeployed,
},
})
assert.NilError(t, err)
err = fakeStorage.Create(&release.Release{
Name: "release3",
Info: &release.Info{
Status: release.StatusDeployed,
},
})
assert.NilError(t, err)
err = fakeStorage.Create(&release.Release{
Name: "release4",
Info: &release.Info{
Status: release.StatusDeployed,
},
})
assert.NilError(t, err)
err = fakeStorage.Create(&release.Release{
Name: "release5",
Info: &release.Info{
Status: release.StatusDeployed,
},
})
assert.NilError(t, err)
releases := &Releases{
Namespaces: []string{"testNamespace"},
HelmConfig: fakeHelmNSConfigGetter,
mx: sync.Mutex{},
}
res, err := releases.List()
assert.NilError(t, err)
assert.Equal(t, len(res), 5)
assert.Equal(t, res[0].Orig.Name, "release1")
assert.Equal(t, res[1].Orig.Name, "release2")
assert.Equal(t, res[2].Orig.Name, "release3")
assert.Equal(t, res[3].Orig.Name, "release4")
assert.Equal(t, res[4].Orig.Name, "release5")
}

View File

@@ -0,0 +1,312 @@
package objects
import (
"github.com/joomcode/errorx"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/chart/loader"
"helm.sh/helm/v3/pkg/cli"
"helm.sh/helm/v3/pkg/getter"
"helm.sh/helm/v3/pkg/helmpath"
"helm.sh/helm/v3/pkg/repo"
"os"
"path/filepath"
"strings"
"sync"
)
const AnnRepo = "helm-dashboard/repository-name"
type Repositories struct {
Settings *cli.EnvSettings
HelmConfig *action.Configuration
mx sync.Mutex
}
func (r *Repositories) Load() (*repo.File, error) {
r.mx.Lock()
defer r.mx.Unlock()
// copied from cmd/helm/repo_list.go
f, err := repo.LoadFile(r.Settings.RepositoryConfig)
if err != nil && !isNotExist(err) {
return nil, errorx.Decorate(err, "failed to load repository list")
}
return f, nil
}
func (r *Repositories) List() ([]*Repository, error) {
f, err := r.Load()
if err != nil {
return nil, errorx.Decorate(err, "failed to load repo information")
}
res := []*Repository{}
for _, item := range f.Repositories {
res = append(res, &Repository{
Settings: r.Settings,
Orig: item,
})
}
return res, nil
}
func (r *Repositories) Add(name string, url string) error {
if name == "" || url == "" {
return errors.New("Name and URL are required parameters to add the repository")
}
// copied from cmd/helm/repo_add.go
repoFile := r.Settings.RepositoryConfig
// Ensure the file directory exists as it is required for file locking
err := os.MkdirAll(filepath.Dir(repoFile), os.ModePerm)
if err != nil && !os.IsExist(err) {
return err
}
f, err := r.Load()
if err != nil {
return errorx.Decorate(err, "Failed to load repo config")
}
r.mx.Lock()
defer r.mx.Unlock()
c := repo.Entry{
Name: name,
URL: url,
//Username: o.username,
//Password: o.password,
//PassCredentialsAll: o.passCredentialsAll,
//CertFile: o.certFile,
//KeyFile: o.keyFile,
//CAFile: o.caFile,
//InsecureSkipTLSverify: o.insecureSkipTLSverify,
}
// Check if the repo name is legal
if strings.Contains(c.Name, "/") {
return errors.Errorf("repository name (%s) contains '/', please specify a different name without '/'", c.Name)
}
rep, err := repo.NewChartRepository(&c, getter.All(r.Settings))
if err != nil {
return err
}
if _, err := rep.DownloadIndexFile(); err != nil {
return errors.Wrapf(err, "looks like %q is not a valid chart repository or cannot be reached", url)
}
f.Update(&c)
if err := f.WriteFile(repoFile, 0644); err != nil {
return err
}
return nil
}
func (r *Repositories) Delete(name string) error {
f, err := r.Load()
if err != nil {
return errorx.Decorate(err, "failed to load repo information")
}
r.mx.Lock()
defer r.mx.Unlock()
// copied from cmd/helm/repo_remove.go
if !f.Remove(name) {
return errors.Errorf("no repo named %q found", name)
}
if err := f.WriteFile(r.Settings.RepositoryConfig, 0644); err != nil {
return err
}
if err := removeRepoCache(r.Settings.RepositoryCache, name); err != nil {
return err
}
return nil
}
func (r *Repositories) Get(name string) (*Repository, error) {
f, err := r.Load()
if err != nil {
return nil, errorx.Decorate(err, "failed to load repo information")
}
for _, entry := range f.Repositories {
if entry.Name == name {
return &Repository{
Settings: r.Settings,
Orig: entry,
}, nil
}
}
return nil, errorx.DataUnavailable.New("Could not find reposiroty '%s'", name)
}
func (r *Repositories) Containing(name string) (repo.ChartVersions, error) {
list, err := r.List()
if err != nil {
return nil, errorx.Decorate(err, "failed to get list of repos")
}
res := repo.ChartVersions{}
for _, rep := range list {
vers, err := rep.ByName(name)
if err != nil {
log.Warnf("Failed to get data from repo '%s', updating it might help", rep.Orig.Name)
log.Debugf("The error was: %v", err)
continue
}
for _, v := range vers {
// just using annotations here to attach a bit of information to the object
// it has nothing to do with k8s annotations and should not get into manifests
if v.Annotations == nil {
v.Annotations = map[string]string{}
}
v.Annotations[AnnRepo] = rep.Orig.Name
}
res = append(res, vers...) // TODO filter dev versions here, relates to #139
}
return res, nil
}
func (r *Repositories) GetChart(chart string, ver string) (*chart.Chart, error) {
// TODO: unused method?
client := action.NewShowWithConfig(action.ShowAll, r.HelmConfig)
client.Version = ver
cp, err := client.ChartPathOptions.LocateChart(chart, r.Settings)
if err != nil {
return nil, errorx.Decorate(err, "failed to locate chart '%s'", chart)
}
chrt, err := loader.Load(cp)
if err != nil {
return nil, errorx.Decorate(err, "failed to load chart from '%s'", cp)
}
return chrt, nil
}
func (r *Repositories) GetChartValues(chart string, ver string) (string, error) {
// comes from cmd/helm/show.go
client := action.NewShowWithConfig(action.ShowValues, r.HelmConfig)
client.Version = ver
cp, err := client.ChartPathOptions.LocateChart(chart, r.Settings)
if err != nil {
return "", err
}
out, err := client.Run(cp)
if err != nil {
return "", errorx.Decorate(err, "failed to get values for chart '%s'", chart)
}
return out, nil
}
type Repository struct {
Settings *cli.EnvSettings
Orig *repo.Entry
mx sync.Mutex
}
func (r *Repository) indexFileName() string {
return filepath.Join(r.Settings.RepositoryCache, helmpath.CacheIndexFile(r.Orig.Name))
}
func (r *Repository) getIndex() (*repo.IndexFile, error) {
r.mx.Lock()
defer r.mx.Unlock()
f := r.indexFileName()
ind, err := repo.LoadIndexFile(f)
if err != nil {
return nil, errorx.Decorate(err, "Repo index is corrupt or missing. Try updating repo")
}
ind.SortEntries()
return ind, nil
}
func (r *Repository) Charts() ([]*repo.ChartVersion, error) {
ind, err := r.getIndex()
if err != nil {
return nil, errorx.Decorate(err, "failed to get repo index")
}
res := []*repo.ChartVersion{}
for _, v := range ind.Entries {
if len(v) > 0 { // TODO filter dev versions here, relates to #139
res = append(res, v[0])
}
}
return res, nil
}
func (r *Repository) ByName(name string) (repo.ChartVersions, error) {
ind, err := r.getIndex()
if err != nil {
return nil, errorx.Decorate(err, "failed to get repo index")
}
nx, ok := ind.Entries[name]
if ok {
return nx, nil
}
return repo.ChartVersions{}, nil
}
func (r *Repository) Update() error {
r.mx.Lock()
defer r.mx.Unlock()
log.Infof("Updating repository: %s", r.Orig.Name)
// from cmd/helm/repo_update.go
// TODO: make this object to be an `Orig`?
rep, err := repo.NewChartRepository(r.Orig, getter.All(r.Settings))
if err != nil {
return errorx.Decorate(err, "could not create repository object")
}
rep.CachePath = r.Settings.RepositoryCache
_, err = rep.DownloadIndexFile()
if err != nil {
return errorx.Decorate(err, "failed to download repo index file")
}
return nil
}
// copied from cmd/helm/repo.go
func isNotExist(err error) bool {
return os.IsNotExist(errors.Cause(err))
}
// copied from cmd/helm/repo_remove.go
func removeRepoCache(root, name string) error {
idx := filepath.Join(root, helmpath.CacheChartsFile(name))
if _, err := os.Stat(idx); err == nil {
_ = os.Remove(idx)
}
idx = filepath.Join(root, helmpath.CacheIndexFile(name))
if _, err := os.Stat(idx); os.IsNotExist(err) {
return nil
} else if err != nil {
return errors.Wrapf(err, "can't remove index file %s", idx)
}
return os.Remove(idx)
}

View File

@@ -0,0 +1,149 @@
package objects
import (
"helm.sh/helm/v3/pkg/action"
"testing"
"gotest.tools/v3/assert"
"helm.sh/helm/v3/pkg/cli"
"helm.sh/helm/v3/pkg/repo"
)
var filePath = "./testdata/repositories.yaml"
func initRepository(t *testing.T, filePath string) *Repositories {
t.Helper()
settings := cli.New()
// Sets the repository file path
settings.RepositoryConfig = filePath
testRepository := &Repositories{
Settings: settings,
HelmConfig: &action.Configuration{}, // maybe use copy of getFakeHelmConfig from api_test.go
}
return testRepository
}
func TestLoadRepo(t *testing.T) {
res, err := repo.LoadFile(filePath)
if err != nil {
t.Fatal(err)
}
testRepository := initRepository(t, filePath)
file, err := testRepository.Load()
if err != nil {
t.Fatal(err)
}
assert.Equal(t, file.Generated, res.Generated)
}
func TestList(t *testing.T) {
res, err := repo.LoadFile(filePath)
if err != nil {
t.Fatal(err)
}
testRepository := initRepository(t, filePath)
repos, err := testRepository.List()
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(repos), len(res.Repositories))
}
func TestAdd(t *testing.T) {
testRepoName := "TEST"
testRepoUrl := "https://helm.github.io/examples"
res, err := repo.LoadFile(filePath)
if err != nil {
t.Fatal(err)
}
// Delete the repository if already exist
res.Remove(testRepoName)
testRepository := initRepository(t, filePath)
err = testRepository.Add(testRepoName, testRepoUrl)
if err != nil {
t.Fatal(err, "Failed to add repo")
}
// Reload the file
res, err = repo.LoadFile(filePath)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, res.Has(testRepoName), true)
// Removes test repository which is added for testing
t.Cleanup(func() {
removed := res.Remove(testRepoName)
if removed != true {
t.Log("Failed to clean the test repository file")
}
err = res.WriteFile(filePath, 0644)
if err != nil {
t.Log("Failed to write the file while cleaning test repo")
}
})
}
func TestDelete(t *testing.T) {
testRepoName := "TEST DELETE"
testRepoUrl := "https://helm.github.io/examples"
res, err := repo.LoadFile(filePath)
if err != nil {
t.Fatal(err)
}
// Add a test entry
res.Add(&repo.Entry{Name: testRepoName, URL: testRepoUrl})
err = res.WriteFile(filePath, 0644)
if err != nil {
t.Fatal("Failed to write the file while creating test repo")
}
testRepository := initRepository(t, filePath)
err = testRepository.Delete(testRepoName)
if err != nil {
t.Fatal(err, "Failed to delete the repo")
}
// Reload the file
res, err = repo.LoadFile(filePath)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, res.Has(testRepoName), false)
}
func TestGet(t *testing.T) {
// Initial repositiry name in test file
repoName := "charts"
testRepository := initRepository(t, filePath)
repo, err := testRepository.Get(repoName)
if err != nil {
t.Fatal(err, "Failed to get th repo")
}
assert.Equal(t, repo.Orig.Name, repoName)
}

View File

@@ -0,0 +1,30 @@
apiVersion: ""
generated: "0001-01-01T00:00:00Z"
repositories:
- caFile: ""
certFile: ""
insecure_skip_tls_verify: false
keyFile: ""
name: charts
pass_credentials_all: false
password: ""
url: https://charts.helm.sh/stable
username: ""
- caFile: ""
certFile: ""
insecure_skip_tls_verify: false
keyFile: ""
name: firstexample
pass_credentials_all: false
password: ""
url: http://firstexample.com
username: ""
- caFile: ""
certFile: ""
insecure_skip_tls_verify: false
keyFile: ""
name: secondexample
pass_credentials_all: false
password: ""
url: http://secondexample.com
username: ""