mirror of
https://github.com/komodorio/helm-dashboard.git
synced 2026-03-24 11:48:04 +00:00
440 lines
11 KiB
Go
440 lines
11 KiB
Go
package dashboard
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"github.com/Masterminds/semver/v3"
|
|
"github.com/hexops/gotextdiff"
|
|
"github.com/hexops/gotextdiff/myers"
|
|
"github.com/hexops/gotextdiff/span"
|
|
log "github.com/sirupsen/logrus"
|
|
"gopkg.in/yaml.v3"
|
|
"io/ioutil"
|
|
v1 "k8s.io/apimachinery/pkg/apis/testapigroup/v1"
|
|
"os"
|
|
"os/exec"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type DataLayer struct {
|
|
KubeContext string
|
|
Helm string
|
|
Kubectl string
|
|
}
|
|
|
|
func (d *DataLayer) runCommand(cmd ...string) (string, error) {
|
|
log.Debugf("Starting command: %s", cmd)
|
|
prog := exec.Command(cmd[0], cmd[1:]...)
|
|
prog.Env = os.Environ()
|
|
prog.Env = append(prog.Env, "HELM_KUBECONTEXT="+d.KubeContext)
|
|
|
|
var stdout bytes.Buffer
|
|
prog.Stdout = &stdout
|
|
|
|
var stderr bytes.Buffer
|
|
prog.Stderr = &stderr
|
|
|
|
if err := prog.Run(); err != nil {
|
|
log.Warnf("Failed command: %s", cmd)
|
|
serr := stderr.Bytes()
|
|
if serr != nil {
|
|
log.Warnf("STDERR:\n%s", serr)
|
|
}
|
|
if eerr, ok := err.(*exec.ExitError); ok {
|
|
return "", fmt.Errorf("failed to run command %s:\nError: %s\nSTDERR:%s", cmd, eerr, serr)
|
|
}
|
|
return "", err
|
|
}
|
|
|
|
sout := stdout.Bytes()
|
|
serr := stderr.Bytes()
|
|
log.Debugf("Command STDOUT:\n%s", sout)
|
|
log.Debugf("Command STDERR:\n%s", serr)
|
|
return string(sout), nil
|
|
}
|
|
|
|
func (d *DataLayer) runCommandHelm(cmd ...string) (string, error) {
|
|
if d.Helm == "" {
|
|
d.Helm = "helm"
|
|
}
|
|
|
|
cmd = append([]string{d.Helm}, cmd...)
|
|
if d.KubeContext != "" {
|
|
cmd = append(cmd, "--kube-context", d.KubeContext)
|
|
}
|
|
|
|
return d.runCommand(cmd...)
|
|
}
|
|
|
|
func (d *DataLayer) runCommandKubectl(cmd ...string) (string, error) {
|
|
// TODO: migrate into using kubectl "k8s.io/kubectl/pkg/cmd" and kube API
|
|
if d.Kubectl == "" {
|
|
d.Kubectl = "kubectl"
|
|
}
|
|
|
|
cmd = append([]string{d.Kubectl}, cmd...)
|
|
|
|
if d.KubeContext != "" {
|
|
cmd = append(cmd, "--context", d.KubeContext)
|
|
}
|
|
|
|
return d.runCommand(cmd...)
|
|
}
|
|
|
|
func (d *DataLayer) CheckConnectivity() error {
|
|
contexts, err := d.ListContexts()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(contexts) < 1 {
|
|
return errors.New("did not find any kubectl contexts configured")
|
|
}
|
|
|
|
/*
|
|
_, err = d.runCommandHelm("env") // no point in doing is, since the default context may be invalid
|
|
if err != nil {
|
|
return err
|
|
}
|
|
*/
|
|
|
|
return nil
|
|
}
|
|
|
|
type KubeContext struct {
|
|
IsCurrent bool
|
|
Name string
|
|
Cluster string
|
|
AuthInfo string
|
|
Namespace string
|
|
}
|
|
|
|
func (d *DataLayer) ListContexts() (res []KubeContext, err error) {
|
|
out, err := d.runCommandKubectl("config", "get-contexts")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// kubectl has no JSON output for it, we'll have to do custom text parsing
|
|
lines := strings.Split(out, "\n")
|
|
|
|
// find field positions
|
|
fields := regexp.MustCompile(`(\w+\s+)`).FindAllString(lines[0], -1)
|
|
cur := len(fields[0])
|
|
name := cur + len(fields[1])
|
|
cluster := name + len(fields[2])
|
|
auth := cluster + len(fields[3])
|
|
|
|
// read items
|
|
for _, line := range lines[1:] {
|
|
if strings.TrimSpace(line) == "" {
|
|
continue
|
|
}
|
|
|
|
res = append(res, KubeContext{
|
|
IsCurrent: strings.TrimSpace(line[0:cur]) == "*",
|
|
Name: strings.TrimSpace(line[cur:name]),
|
|
Cluster: strings.TrimSpace(line[name:cluster]),
|
|
AuthInfo: strings.TrimSpace(line[cluster:auth]),
|
|
Namespace: strings.TrimSpace(line[auth:]),
|
|
})
|
|
}
|
|
|
|
return res, nil
|
|
}
|
|
|
|
func (d *DataLayer) ListInstalled() (res []releaseElement, err error) {
|
|
out, err := d.runCommandHelm("ls", "--all", "--all-namespaces", "--output", "json", "--time-format", time.RFC3339)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = json.Unmarshal([]byte(out), &res)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
func (d *DataLayer) ChartHistory(namespace string, chartName string) (res []*historyElement, err error) {
|
|
// TODO: there is `max` but there is no `offset`
|
|
out, err := d.runCommandHelm("history", chartName, "--namespace", namespace, "--output", "json", "--max", "18")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = json.Unmarshal([]byte(out), &res)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var aprev *semver.Version
|
|
var cprev *semver.Version
|
|
for _, elm := range res {
|
|
chartRepoName, curVer, err := chartAndVersion(elm.Chart)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
elm.ChartName = chartRepoName
|
|
elm.ChartVer = curVer
|
|
elm.Action = ""
|
|
elm.Updated.Time = elm.Updated.Time.Round(time.Second)
|
|
|
|
cver, err1 := semver.NewVersion(elm.ChartVer)
|
|
aver, err2 := semver.NewVersion(elm.AppVersion)
|
|
if err1 == nil && err2 == nil {
|
|
if aprev != nil && cprev != nil {
|
|
switch {
|
|
case aprev.LessThan(aver):
|
|
elm.Action = "app_upgrade"
|
|
case aprev.GreaterThan(aver):
|
|
elm.Action = "app_downgrade"
|
|
case cprev.LessThan(cver):
|
|
elm.Action = "chart_upgrade"
|
|
case cprev.GreaterThan(cver):
|
|
elm.Action = "chart_downgrade"
|
|
default:
|
|
elm.Action = "reconfigure"
|
|
}
|
|
}
|
|
} else {
|
|
log.Debugf("Semver parsing errors: %s=%s, %s=%s", elm.ChartVer, err1, elm.AppVersion, err2)
|
|
}
|
|
|
|
aprev = aver
|
|
cprev = cver
|
|
}
|
|
|
|
return res, nil
|
|
}
|
|
|
|
func (d *DataLayer) ChartRepoVersions(chartName string) (res []repoChartElement, err error) {
|
|
cmd := []string{"search", "repo", "--regexp", "/" + chartName + "\v", "--versions", "--output", "json"}
|
|
out, err := d.runCommandHelm(cmd...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = json.Unmarshal([]byte(out), &res)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
type SectionFn = func(string, string, int, bool) (string, error) // TODO: rework it into struct-based argument?
|
|
|
|
func (d *DataLayer) RevisionManifests(namespace string, chartName string, revision int, _ bool) (res string, err error) {
|
|
cmd := []string{"get", "manifest", chartName, "--namespace", namespace}
|
|
if revision > 0 {
|
|
cmd = append(cmd, "--revision", strconv.Itoa(revision))
|
|
}
|
|
|
|
out, err := d.runCommandHelm(cmd...)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (d *DataLayer) RevisionManifestsParsed(namespace string, chartName string, revision int) ([]*GenericResource, error) {
|
|
out, err := d.RevisionManifests(namespace, chartName, revision, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
dec := yaml.NewDecoder(bytes.NewReader([]byte(out)))
|
|
|
|
res := make([]*GenericResource, 0)
|
|
var tmp interface{}
|
|
for dec.Decode(&tmp) == nil {
|
|
// k8s libs uses only JSON tags defined, say hello to https://github.com/go-yaml/yaml/issues/424
|
|
// bug we can juggle it
|
|
jsoned, err := json.Marshal(tmp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var doc GenericResource
|
|
err = json.Unmarshal(jsoned, &doc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
res = append(res, &doc)
|
|
}
|
|
|
|
return res, nil
|
|
}
|
|
|
|
func (d *DataLayer) RevisionNotes(namespace string, chartName string, revision int, _ bool) (res string, err error) {
|
|
out, err := d.runCommandHelm("get", "notes", chartName, "--namespace", namespace, "--revision", strconv.Itoa(revision))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (d *DataLayer) RevisionValues(namespace string, chartName string, revision int, onlyUserDefined bool) (res string, err error) {
|
|
cmd := []string{"get", "values", chartName, "--namespace", namespace, "--output", "yaml"}
|
|
|
|
if revision > 0 {
|
|
cmd = append(cmd, "--revision", strconv.Itoa(revision))
|
|
}
|
|
|
|
if !onlyUserDefined {
|
|
cmd = append(cmd, "--all")
|
|
}
|
|
out, err := d.runCommandHelm(cmd...)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (d *DataLayer) GetResource(namespace string, def *GenericResource) (*GenericResource, error) {
|
|
out, err := d.runCommandKubectl("get", strings.ToLower(def.Kind), def.Name, "--namespace", namespace, "--output", "json")
|
|
if err != nil {
|
|
if strings.HasSuffix(strings.TrimSpace(err.Error()), " not found") {
|
|
return &GenericResource{
|
|
Status: v1.CarpStatus{
|
|
Phase: "NotFound",
|
|
Message: err.Error(),
|
|
Reason: "not found",
|
|
},
|
|
}, nil
|
|
} else {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
var res GenericResource
|
|
err = json.Unmarshal([]byte(out), &res)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sort.Slice(res.Status.Conditions, func(i, j int) bool {
|
|
t1 := res.Status.Conditions[i].LastTransitionTime
|
|
t2 := res.Status.Conditions[j].LastTransitionTime
|
|
return t1.Time.Before(t2.Time)
|
|
})
|
|
|
|
return &res, nil
|
|
}
|
|
|
|
func (d *DataLayer) DescribeResource(namespace string, kind string, name string) (string, error) {
|
|
out, err := d.runCommandKubectl("describe", strings.ToLower(kind), name, "--namespace", namespace)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (d *DataLayer) UninstallChart(namespace string, name string) error {
|
|
_, err := d.runCommandHelm("uninstall", name, "--namespace", namespace)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (d *DataLayer) Revert(namespace string, name string, rev int) error {
|
|
_, err := d.runCommandHelm("rollback", name, strconv.Itoa(rev), "--namespace", namespace)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (d *DataLayer) ChartRepoUpdate(name string) error {
|
|
cmd := []string{"repo", "update"}
|
|
if name != "" {
|
|
cmd = append(cmd, name)
|
|
}
|
|
|
|
_, err := d.runCommandHelm(cmd...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (d *DataLayer) ChartUpgrade(namespace string, name string, repoChart string, version string, justTemplate bool) (string, error) {
|
|
oldVals, err := d.RevisionValues(namespace, name, 0, false)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
file, err := ioutil.TempFile("", "helm_vals_")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer os.Remove(file.Name())
|
|
|
|
err = ioutil.WriteFile(file.Name(), []byte(oldVals), 0600)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
cmd := []string{name, repoChart, "--version", version, "--namespace", namespace, "--values", file.Name()}
|
|
if justTemplate {
|
|
cmd = append([]string{"template", "--skip-tests"}, cmd...)
|
|
} else {
|
|
cmd = append([]string{"upgrade"}, cmd...)
|
|
cmd = append(cmd, "--output", "json")
|
|
}
|
|
|
|
out, err := d.runCommandHelm(cmd...)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if justTemplate {
|
|
manifests, err := d.RevisionManifests(namespace, name, 0, false)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
out = getDiff(strings.TrimSpace(manifests), strings.TrimSpace(out), "current.yaml", "upgraded.yaml")
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func RevisionDiff(functor SectionFn, ext string, namespace string, name string, revision1 int, revision2 int, flag bool) (string, error) {
|
|
if revision1 == 0 || revision2 == 0 {
|
|
log.Debugf("One of revisions is zero: %d %d", revision1, revision2)
|
|
return "", nil
|
|
}
|
|
|
|
manifest1, err := functor(namespace, name, revision1, flag)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
manifest2, err := functor(namespace, name, revision2, flag)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
diff := getDiff(manifest1, manifest2, strconv.Itoa(revision1)+ext, strconv.Itoa(revision2)+ext)
|
|
return diff, nil
|
|
}
|
|
|
|
func getDiff(text1 string, text2 string, name1 string, name2 string) string {
|
|
edits := myers.ComputeEdits(span.URIFromPath(""), text1, text2)
|
|
unified := gotextdiff.ToUnified(name1, name2, text1, edits)
|
|
diff := fmt.Sprint(unified)
|
|
log.Debugf("The diff is: %s", diff)
|
|
return diff
|
|
}
|
|
|
|
type GenericResource = v1.Carp
|