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 @@
+