From d9a88feb7bd63f28631fc9864f1a56ed9f2e63ad Mon Sep 17 00:00:00 2001 From: Andrey Pokhilko Date: Wed, 24 Aug 2022 14:42:20 +0300 Subject: [PATCH] Initial features pt 2 (#3) * Less logging when not in DEBUG * Check helm is fine * Display kube context switch * Cosmetics * Displays list of chartss * Linter stuff * Fix option name --- go.mod | 3 +- go.sum | 5 +- pkg/dashboard/api.go | 27 +++++++- pkg/dashboard/main.go | 119 ++++++++++++++++++++++++++++++-- pkg/dashboard/server.go | 14 ++-- pkg/dashboard/static/index.html | 39 +++++++++++ pkg/dashboard/static/scripts.js | 58 ++++++++++++++++ pkg/dashboard/static/styles.css | 3 + 8 files changed, 249 insertions(+), 19 deletions(-) diff --git a/go.mod b/go.mod index 1dab26a..daeb84d 100644 --- a/go.mod +++ b/go.mod @@ -6,16 +6,15 @@ require ( github.com/gin-gonic/gin v1.8.1 github.com/sirupsen/logrus v1.8.1 github.com/toqueteos/webbrowser v1.2.0 - helm.sh/helm/v3 v3.9.3 ) require ( - github.com/Masterminds/semver/v3 v3.1.1 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.0 // indirect github.com/go-playground/universal-translator v0.18.0 // indirect github.com/go-playground/validator/v10 v10.11.0 // indirect github.com/goccy/go-json v0.9.11 // indirect + github.com/google/go-cmp v0.5.6 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/leodido/go-urn v1.2.1 // indirect github.com/mattn/go-isatty v0.0.16 // indirect diff --git a/go.sum b/go.sum index e480706..d8ac2b7 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= -github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -21,6 +19,7 @@ github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= @@ -101,5 +100,3 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -helm.sh/helm/v3 v3.9.3 h1:etd4Qc45/bnIkBofZIRwrAzYuG3bNWR1EdMN4fsfzoE= -helm.sh/helm/v3 v3.9.3/go.mod h1:3eaWAIqzvlRSD06gR9MMwmp2KBKwlu9av1/1BZpjeWY= diff --git a/pkg/dashboard/api.go b/pkg/dashboard/api.go index 2b3dc98..db3b285 100644 --- a/pkg/dashboard/api.go +++ b/pkg/dashboard/api.go @@ -13,7 +13,14 @@ import ( var staticFS embed.FS func newRouter(abortWeb ControlChan, data DataLayer) *gin.Engine { - api := gin.Default() + var api *gin.Engine + if os.Getenv("DEBUG") == "" { + api = gin.New() + api.Use(gin.Recovery()) + } else { + api = gin.Default() + } + fs := http.FS(staticFS) // local dev speed-up @@ -47,8 +54,22 @@ func newRouter(abortWeb ControlChan, data DataLayer) *gin.Engine { abortWeb <- struct{}{} }) - api.GET("/api", func(c *gin.Context) { - c.IndentedJSON(http.StatusOK, data.ListInstalled()) + api.GET("/api/helm/charts", func(c *gin.Context) { + res, err := data.ListInstalled() + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, err) + return + } + c.IndentedJSON(http.StatusOK, res) + }) + + api.GET("/api/kube/contexts", func(c *gin.Context) { + res, err := data.ListContexts() + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, err) + return + } + c.IndentedJSON(http.StatusOK, res) }) return api diff --git a/pkg/dashboard/main.go b/pkg/dashboard/main.go index 8b05121..fbf8133 100644 --- a/pkg/dashboard/main.go +++ b/pkg/dashboard/main.go @@ -1,16 +1,125 @@ package dashboard import ( - "helm.sh/helm/v3/pkg/release" + "bytes" + "encoding/json" + "errors" + "fmt" + log "github.com/sirupsen/logrus" + "os/exec" + "regexp" + "strings" ) type DataLayer struct { } -func (l *DataLayer) CheckConnectivity() { - // TODO: check that we can work with context and subcommands +func (l *DataLayer) runCommand(cmd ...string) (string, error) { + // TODO: --kube-context=context-name to juggle clusters + log.Debugf("Starting command: %s", cmd) + prog := exec.Command(cmd[0], cmd[1:]...) + + var stdout bytes.Buffer + prog.Stdout = &stdout + + var stderr bytes.Buffer + prog.Stderr = &stderr + + //prog.Stdout, prog.Stderr = os.Stdout, os.Stderr + if err := prog.Run(); err != nil { + if eerr, ok := err.(*exec.ExitError); ok { + return "", fmt.Errorf("failed to run command %s: %s", cmd, eerr) + } + 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 (l *DataLayer) ListInstalled() []*release.Release { - return nil // TODO +func (l *DataLayer) CheckConnectivity() error { + contexts, err := l.ListContexts() + if err != nil { + return err + } + + if len(contexts) < 1 { + return errors.New("did not find any kubectl contexts configured") + } + + _, err = l.runCommand("helm", "env") + if err != nil { + return err + } + + return nil +} + +type KubeContext struct { + IsCurrent bool + Name string + Cluster string + AuthInfo string + Namespace string +} + +func (l *DataLayer) ListContexts() (res []KubeContext, err error) { + out, err := l.runCommand("kubectl", "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 +} + +// unpleasant copy from Helm sources, where they have it non-public +type releaseElement struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + Revision string `json:"revision"` + Updated string `json:"updated"` + Status string `json:"status"` + Chart string `json:"chart"` + AppVersion string `json:"app_version"` +} + +func (l *DataLayer) ListInstalled() (res []releaseElement, err error) { + out, err := l.runCommand("helm", "ls", "--all", "--all-namespaces", "--output", "json") + if err != nil { + return nil, err + } + + err = json.Unmarshal([]byte(out), &res) + if err != nil { + return nil, err + } + return res, nil } diff --git a/pkg/dashboard/server.go b/pkg/dashboard/server.go index 4f8d9db..ac4b33d 100644 --- a/pkg/dashboard/server.go +++ b/pkg/dashboard/server.go @@ -6,14 +6,21 @@ import ( log "github.com/sirupsen/logrus" "net/http" "os" - "strings" ) func StartServer() (string, ControlChan) { data := DataLayer{} - data.CheckConnectivity() + err := data.CheckConnectivity() + if err != nil { + log.Errorf("Failed to check that Helm is operational, cannot continue. The error was: %s", err) + os.Exit(1) // TODO: propagate error instead? + } address := os.Getenv("HD_BIND") + if address == "" { + address = "localhost" + } + if os.Getenv("HD_PORT") == "" { address += ":8080" // TODO: better default port to clash less? } else { @@ -24,9 +31,6 @@ func StartServer() (string, ControlChan) { api := newRouter(abort, data) done := startBackgroundServer(address, api, abort) - if strings.HasPrefix(address, ":") { - address = "localhost" + address - } return "http://" + address, done } diff --git a/pkg/dashboard/static/index.html b/pkg/dashboard/static/index.html index 5853894..c4c3cb6 100644 --- a/pkg/dashboard/static/index.html +++ b/pkg/dashboard/static/index.html @@ -1,6 +1,7 @@ + Helm Dashboard @@ -10,10 +11,48 @@ +
+ + + +
+

Charts List

+
+ +
+
+
+ \ No newline at end of file diff --git a/pkg/dashboard/static/scripts.js b/pkg/dashboard/static/scripts.js index e69de29..ae22ed5 100644 --- a/pkg/dashboard/static/scripts.js +++ b/pkg/dashboard/static/scripts.js @@ -0,0 +1,58 @@ +const clusterSelect = $("#cluster"); +const chartsCards = $("#charts"); + +function reportError(err) { + alert(err) // TODO: nice modal/baloon/etc +} + +$(function () { + // cluster list + $.getJSON("/api/kube/contexts").fail(function () { + reportError("Failed to get list of clusters") + }).done(function (data) { + data.forEach(function (elm) { + // aws CLI uses complicated context names, the suffix does not work well + // maybe we should have an `if` statement here + let label = elm.Name //+ " (" + elm.Cluster + "/" + elm.AuthInfo + "/" + elm.Namespace + ")" + let opt = $("").val(elm.Name).text(label) + if (elm.IsCurrent) { + opt.attr("selected", "selected") + } + clusterSelect.append(opt) + }) + }) + clusterSelect.change(function () { + // TODO: remember it, respect it in the function above and in all other places + }) + + // charts list + $.getJSON("/api/helm/charts").fail(function () { + reportError("Failed to get list of clusters") + }).done(function (data) { + chartsCards.empty() + data.forEach(function (elm) { + const header = $("
") + header.append($('
#' + elm.revision + '

' + elm.status + "
")) + header.append($('
').text(elm.name)) + header.append($('

').append("Version: " + elm.app_version)) + + const body = $("
") + body.append($('

').append("Namespace: " + elm.namespace)) + body.append($('

').append("Chart: " + elm.chart)) + body.append($('

').append("Updated: " + elm.updated)) + + /* + "namespace": "default", + "revision": "4", + "updated": "2022-08-16 17:11:26.73393511 +0300 IDT", + "status": "deployed", + "chart": "k8s-watcher-0.17.1", + "app_version": "0.1.108" + + */ + + let card = $("
").append(header).append(body); + chartsCards.append($("
").append(card)) + }) + }) +}) diff --git a/pkg/dashboard/static/styles.css b/pkg/dashboard/static/styles.css index e69de29..e2afbb8 100644 --- a/pkg/dashboard/static/styles.css +++ b/pkg/dashboard/static/styles.css @@ -0,0 +1,3 @@ +#charts .card { + cursor: pointer; +} \ No newline at end of file