From 269895ae312ee20b91f760e2975b78558802987c Mon Sep 17 00:00:00 2001 From: Andrey Pokhilko Date: Wed, 14 Sep 2022 13:20:10 +0100 Subject: [PATCH] Repo version info and upgrade (#10) * Repo block shows * repository update * API progress * Fix problem of stopping after uninstall * Upgrade preview backend * Upgrade preview displays fine * Install flow works * Weird check for updates * Update action * Fix action * Still trying * Fix lint error * Refactor out helm handlers * refactor out kube handlers * save * Change icon collection * Reworked upgrade check --- .github/workflows/build.yml | 8 +- pkg/dashboard/api.go | 210 +++----------------------- pkg/dashboard/data.go | 86 ++++++++++- pkg/dashboard/helmHandlers.go | 205 ++++++++++++++++++++++++++ pkg/dashboard/kubeHandlers.go | 69 +++++++++ pkg/dashboard/static/index.html | 48 ++++-- pkg/dashboard/static/scripts.js | 254 ++++++++++++++++++++++++-------- pkg/dashboard/static/styles.css | 4 + 8 files changed, 616 insertions(+), 268 deletions(-) create mode 100644 pkg/dashboard/helmHandlers.go create mode 100644 pkg/dashboard/kubeHandlers.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b32ce4f..bcb2bb5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,4 +34,10 @@ jobs: - name: Test Binary is Runnable run: "dist/helm-dashboard_linux_amd64_v1/helm-dashboard --help" - name: golangci-lint - uses: golangci/golangci-lint-action@v3 + uses: golangci/golangci-lint-action@v3.2.0 + with: + # version: latest + # skip-go-installation: true + skip-pkg-cache: true + skip-build-cache: true + # args: --timeout=15m \ No newline at end of file diff --git a/pkg/dashboard/api.go b/pkg/dashboard/api.go index 35d476f..e303e54 100644 --- a/pkg/dashboard/api.go +++ b/pkg/dashboard/api.go @@ -5,8 +5,6 @@ import ( "errors" "github.com/gin-gonic/gin" log "github.com/sirupsen/logrus" - "k8s.io/apimachinery/pkg/apis/meta/v1" - v12 "k8s.io/apimachinery/pkg/apis/testapigroup/v1" "net/http" "os" "path" @@ -44,12 +42,14 @@ func NewRouter(abortWeb ControlChan, data *DataLayer) *gin.Engine { api = gin.Default() } - api.Use(noCache) api.Use(contextSetter(data)) - api.Use(errorHandler) - configureStatic(api) + configureStatic(api) configureRoutes(abortWeb, data, api) + + api.Use(noCache) + api.Use(errorHandler) + return api } @@ -59,191 +59,29 @@ func configureRoutes(abortWeb ControlChan, data *DataLayer, api *gin.Engine) { abortWeb <- struct{}{} }) - configureHelms(api, data) - configureKubectls(api, data) + configureHelms(api.Group("/api/helm"), data) + configureKubectls(api.Group("/api/kube"), data) } -func configureHelms(api *gin.Engine, data *DataLayer) { - 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.DELETE("/api/helm/charts", func(c *gin.Context) { - qp, err := getQueryProps(c, false) - if err != nil { - _ = c.AbortWithError(http.StatusBadRequest, err) - return - } - err = data.UninstallChart(qp.Namespace, qp.Name) - if err != nil { - _ = c.AbortWithError(http.StatusInternalServerError, err) - return - } - c.Redirect(http.StatusFound, "/") - }) - - api.POST("/api/helm/charts/rollback", func(c *gin.Context) { - qp, err := getQueryProps(c, true) - if err != nil { - _ = c.AbortWithError(http.StatusBadRequest, err) - return - } - - err = data.Revert(qp.Namespace, qp.Name, qp.Revision) - if err != nil { - _ = c.AbortWithError(http.StatusInternalServerError, err) - return - } - c.Redirect(http.StatusFound, "/") - }) - - api.GET("/api/helm/charts/history", func(c *gin.Context) { - qp, err := getQueryProps(c, false) - if err != nil { - _ = c.AbortWithError(http.StatusBadRequest, err) - return - } - - res, err := data.ChartHistory(qp.Namespace, qp.Name) - if err != nil { - _ = c.AbortWithError(http.StatusInternalServerError, err) - return - } - c.IndentedJSON(http.StatusOK, res) - }) - - api.GET("/api/helm/charts/resources", func(c *gin.Context) { - qp, err := getQueryProps(c, true) - if err != nil { - _ = c.AbortWithError(http.StatusBadRequest, err) - return - } - - res, err := data.RevisionManifestsParsed(qp.Namespace, qp.Name, qp.Revision) - if err != nil { - _ = c.AbortWithError(http.StatusInternalServerError, err) - return - } - c.IndentedJSON(http.StatusOK, res) - }) - - api.GET("/api/helm/charts/:section", func(c *gin.Context) { - qp, err := getQueryProps(c, true) - if err != nil { - _ = c.AbortWithError(http.StatusBadRequest, err) - return - } - - flag := c.Query("flag") == "true" - rDiff := c.Query("revisionDiff") - res, err := handleGetSection(data, c.Param("section"), rDiff, qp, flag) - if err != nil { - _ = c.AbortWithError(http.StatusInternalServerError, err) - return - } - c.String(http.StatusOK, res) - }) +func configureHelms(api *gin.RouterGroup, data *DataLayer) { + h := HelmHandler{Data: data} + api.GET("/charts", h.GetCharts) + api.DELETE("/charts", h.Uninstall) + api.POST("/charts/rollback", h.Rollback) + api.GET("/charts/history", h.History) + api.GET("/charts/resources", h.Resources) + api.GET("/repo/search", h.RepoSearch) + api.POST("/repo/update", h.RepoUpdate) + api.GET("/charts/install", h.InstallPreview) + api.POST("/charts/install", h.Install) + api.GET("/charts/:section", h.GetInfoSection) } -func handleGetSection(data *DataLayer, section string, rDiff string, qp *QueryProps, flag bool) (string, error) { - sections := map[string]SectionFn{ - "manifests": data.RevisionManifests, - "values": data.RevisionValues, - "notes": data.RevisionNotes, - } - - functor, found := sections[section] - if !found { - return "", errors.New("unsupported section: " + section) - } - - if rDiff != "" { - cRevDiff, err := strconv.Atoi(rDiff) - if err != nil { - return "", err - } - - ext := ".yaml" - if section == "notes" { - ext = ".txt" - } - - res, err := RevisionDiff(functor, ext, qp.Namespace, qp.Name, cRevDiff, qp.Revision, flag) - if err != nil { - return "", err - } - return res, nil - } else { - res, err := functor(qp.Namespace, qp.Name, qp.Revision, flag) - if err != nil { - return "", err - } - return res, nil - } -} - -func configureKubectls(api *gin.Engine, data *DataLayer) { - 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) - }) - - api.GET("/api/kube/resources/:kind", func(c *gin.Context) { - qp, err := getQueryProps(c, false) - if err != nil { - _ = c.AbortWithError(http.StatusBadRequest, err) - return - } - - res, err := data.GetResource(qp.Namespace, &GenericResource{ - TypeMeta: v1.TypeMeta{Kind: c.Param("kind")}, - ObjectMeta: v1.ObjectMeta{Name: qp.Name}, - }) - if err != nil { - _ = c.AbortWithError(http.StatusInternalServerError, err) - return - } - - if res.Status.Phase == "Active" || res.Status.Phase == "Error" { - _ = res.Name + "" - } else if res.Status.Phase == "" && len(res.Status.Conditions) > 0 { - res.Status.Phase = v12.CarpPhase(res.Status.Conditions[len(res.Status.Conditions)-1].Type) - res.Status.Message = res.Status.Conditions[len(res.Status.Conditions)-1].Message - res.Status.Reason = res.Status.Conditions[len(res.Status.Conditions)-1].Reason - if res.Status.Conditions[len(res.Status.Conditions)-1].Status == "False" { - res.Status.Phase = "Not" + res.Status.Phase - } - } else if res.Status.Phase == "" { - res.Status.Phase = "Exists" - } - - c.IndentedJSON(http.StatusOK, res) - }) - - api.GET("/api/kube/describe/:kind", func(c *gin.Context) { - qp, err := getQueryProps(c, false) - if err != nil { - _ = c.AbortWithError(http.StatusBadRequest, err) - return - } - - res, err := data.DescribeResource(qp.Namespace, c.Param("kind"), qp.Name) - if err != nil { - _ = c.AbortWithError(http.StatusInternalServerError, err) - return - } - - c.String(http.StatusOK, res) - }) +func configureKubectls(api *gin.RouterGroup, data *DataLayer) { + h := KubeHandler{Data: data} + api.GET("/contexts", h.GetContexts) + api.GET("/resources/:kind", h.GetResourceInfo) + api.GET("/describe/:kind", h.Describe) } func configureStatic(api *gin.Engine) { diff --git a/pkg/dashboard/data.go b/pkg/dashboard/data.go index f9871a9..91ad5be 100644 --- a/pkg/dashboard/data.go +++ b/pkg/dashboard/data.go @@ -11,6 +11,7 @@ import ( "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" @@ -214,7 +215,8 @@ func (d *DataLayer) ChartHistory(namespace string, chartName string) (res []*his } func (d *DataLayer) ChartRepoVersions(chartName string) (res []repoChartElement, err error) { - out, err := d.runCommandHelm("search", "repo", "--regexp", "/"+chartName+"\v", "--versions", "--output", "json") + cmd := []string{"search", "repo", "--regexp", "/" + chartName + "\v", "--versions", "--output", "json"} + out, err := d.runCommandHelm(cmd...) if err != nil { return nil, err } @@ -229,7 +231,12 @@ func (d *DataLayer) ChartRepoVersions(chartName string) (res []repoChartElement, 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) { - out, err := d.runCommandHelm("get", "manifest", chartName, "--namespace", namespace, "--revision", strconv.Itoa(revision)) + 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 } @@ -275,7 +282,12 @@ func (d *DataLayer) RevisionNotes(namespace string, chartName string, revision i } func (d *DataLayer) RevisionValues(namespace string, chartName string, revision int, onlyUserDefined bool) (res string, err error) { - cmd := []string{"get", "values", chartName, "--namespace", namespace, "--revision", strconv.Itoa(revision), "--output", "yaml"} + 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") } @@ -341,6 +353,61 @@ func (d *DataLayer) Revert(namespace string, name string, rev int) error { 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"}, 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) @@ -357,11 +424,16 @@ func RevisionDiff(functor SectionFn, ext string, namespace string, name string, return "", err } - edits := myers.ComputeEdits(span.URIFromPath(""), manifest1, manifest2) - unified := gotextdiff.ToUnified(strconv.Itoa(revision1)+ext, strconv.Itoa(revision2)+ext, manifest1, edits) - diff := fmt.Sprint(unified) - log.Debugf("The diff is: %s", diff) + 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 diff --git a/pkg/dashboard/helmHandlers.go b/pkg/dashboard/helmHandlers.go new file mode 100644 index 0000000..c99a90e --- /dev/null +++ b/pkg/dashboard/helmHandlers.go @@ -0,0 +1,205 @@ +package dashboard + +import ( + "encoding/json" + "errors" + "github.com/gin-gonic/gin" + "helm.sh/helm/v3/pkg/release" + "net/http" + "strconv" +) + +type HelmHandler struct { + Data *DataLayer +} + +func (h *HelmHandler) GetCharts(c *gin.Context) { + res, err := h.Data.ListInstalled() + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, err) + return + } + c.IndentedJSON(http.StatusOK, res) +} + +func (h *HelmHandler) Uninstall(c *gin.Context) { + qp, err := getQueryProps(c, false) + if err != nil { + _ = c.AbortWithError(http.StatusBadRequest, err) + return + } + err = h.Data.UninstallChart(qp.Namespace, qp.Name) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, err) + return + } + c.Status(http.StatusAccepted) +} + +func (h *HelmHandler) Rollback(c *gin.Context) { + qp, err := getQueryProps(c, true) + if err != nil { + _ = c.AbortWithError(http.StatusBadRequest, err) + return + } + + err = h.Data.Revert(qp.Namespace, qp.Name, qp.Revision) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, err) + return + } + c.Redirect(http.StatusOK, "/") +} + +func (h *HelmHandler) History(c *gin.Context) { + qp, err := getQueryProps(c, false) + if err != nil { + _ = c.AbortWithError(http.StatusBadRequest, err) + return + } + + res, err := h.Data.ChartHistory(qp.Namespace, qp.Name) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, err) + return + } + c.IndentedJSON(http.StatusOK, res) +} + +func (h *HelmHandler) Resources(c *gin.Context) { + qp, err := getQueryProps(c, true) + if err != nil { + _ = c.AbortWithError(http.StatusBadRequest, err) + return + } + + res, err := h.Data.RevisionManifestsParsed(qp.Namespace, qp.Name, qp.Revision) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, err) + return + } + c.IndentedJSON(http.StatusOK, res) +} + +func (h *HelmHandler) RepoSearch(c *gin.Context) { + qp, err := getQueryProps(c, false) + if err != nil { + _ = c.AbortWithError(http.StatusBadRequest, err) + return + } + + res, err := h.Data.ChartRepoVersions(qp.Name) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, err) + return + } + c.IndentedJSON(http.StatusOK, res) +} + +func (h *HelmHandler) RepoUpdate(c *gin.Context) { + qp, err := getQueryProps(c, false) + if err != nil { + _ = c.AbortWithError(http.StatusBadRequest, err) + return + } + + err = h.Data.ChartRepoUpdate(qp.Name) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, err) + return + } + c.Status(http.StatusNoContent) +} + +func (h *HelmHandler) InstallPreview(c *gin.Context) { + out, err := chartInstall(c, h.Data, true) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, err) + return + } + c.String(http.StatusOK, out) +} + +func (h *HelmHandler) Install(c *gin.Context) { + out, err := chartInstall(c, h.Data, false) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, err) + return + } + + res := release.Release{} + err = json.Unmarshal([]byte(out), &res) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, err) + return + } + + c.IndentedJSON(http.StatusAccepted, res) +} + +func (h *HelmHandler) GetInfoSection(c *gin.Context) { + qp, err := getQueryProps(c, true) + if err != nil { + _ = c.AbortWithError(http.StatusBadRequest, err) + return + } + + flag := c.Query("flag") == "true" + rDiff := c.Query("revisionDiff") + res, err := handleGetSection(h.Data, c.Param("section"), rDiff, qp, flag) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, err) + return + } + c.String(http.StatusOK, res) +} + +func chartInstall(c *gin.Context, data *DataLayer, justTemplate bool) (string, error) { + qp, err := getQueryProps(c, false) + if err != nil { + return "", err + } + + out, err := data.ChartUpgrade(qp.Namespace, qp.Name, c.Query("chart"), c.Query("version"), justTemplate) + if err != nil { + return "", err + } + return out, nil +} + +func handleGetSection(data *DataLayer, section string, rDiff string, qp *QueryProps, flag bool) (string, error) { + sections := map[string]SectionFn{ + "manifests": data.RevisionManifests, + "values": data.RevisionValues, + "notes": data.RevisionNotes, + } + + functor, found := sections[section] + if !found { + return "", errors.New("unsupported section: " + section) + } + + if rDiff != "" { + cRevDiff, err := strconv.Atoi(rDiff) + if err != nil { + return "", err + } + + ext := ".yaml" + if section == "notes" { + ext = ".txt" + } + + res, err := RevisionDiff(functor, ext, qp.Namespace, qp.Name, cRevDiff, qp.Revision, flag) + if err != nil { + return "", err + } + return res, nil + } else { + res, err := functor(qp.Namespace, qp.Name, qp.Revision, flag) + if err != nil { + return "", err + } + return res, nil + } +} diff --git a/pkg/dashboard/kubeHandlers.go b/pkg/dashboard/kubeHandlers.go new file mode 100644 index 0000000..5064bd5 --- /dev/null +++ b/pkg/dashboard/kubeHandlers.go @@ -0,0 +1,69 @@ +package dashboard + +import ( + "github.com/gin-gonic/gin" + "k8s.io/apimachinery/pkg/apis/meta/v1" + v12 "k8s.io/apimachinery/pkg/apis/testapigroup/v1" + "net/http" +) + +type KubeHandler struct { + Data *DataLayer +} + +func (h *KubeHandler) GetContexts(c *gin.Context) { + res, err := h.Data.ListContexts() + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, err) + return + } + c.IndentedJSON(http.StatusOK, res) +} + +func (h *KubeHandler) GetResourceInfo(c *gin.Context) { + qp, err := getQueryProps(c, false) + if err != nil { + _ = c.AbortWithError(http.StatusBadRequest, err) + return + } + + res, err := h.Data.GetResource(qp.Namespace, &GenericResource{ + TypeMeta: v1.TypeMeta{Kind: c.Param("kind")}, + ObjectMeta: v1.ObjectMeta{Name: qp.Name}, + }) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, err) + return + } + + if res.Status.Phase == "Active" || res.Status.Phase == "Error" { + _ = res.Name + "" + } else if res.Status.Phase == "" && len(res.Status.Conditions) > 0 { + res.Status.Phase = v12.CarpPhase(res.Status.Conditions[len(res.Status.Conditions)-1].Type) + res.Status.Message = res.Status.Conditions[len(res.Status.Conditions)-1].Message + res.Status.Reason = res.Status.Conditions[len(res.Status.Conditions)-1].Reason + if res.Status.Conditions[len(res.Status.Conditions)-1].Status == "False" { + res.Status.Phase = "Not" + res.Status.Phase + } + } else if res.Status.Phase == "" { + res.Status.Phase = "Exists" + } + + c.IndentedJSON(http.StatusOK, res) +} + +func (h *KubeHandler) Describe(c *gin.Context) { + qp, err := getQueryProps(c, false) + if err != nil { + _ = c.AbortWithError(http.StatusBadRequest, err) + return + } + + res, err := h.Data.DescribeResource(qp.Namespace, c.Param("kind"), qp.Name) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, err) + return + } + + c.String(http.StatusOK, res) +} diff --git a/pkg/dashboard/static/index.html b/pkg/dashboard/static/index.html index 591de9a..933799a 100644 --- a/pkg/dashboard/static/index.html +++ b/pkg/dashboard/static/index.html @@ -7,8 +7,7 @@ Helm Dashboard - - + @@ -16,8 +15,7 @@
- -