Scanners Integration (#18)

* Research scanning

* Move files around

* Reports the list

* Scanner happens

* Commit

* Work on alternative

* refactorings

* Progress

* Save the state

* Commit

* Display trivy Results

* Checkov also reports

* Better display

* Correct trivy numbers

* Scan pre-install manifest

* Readme items

* Static checks
This commit is contained in:
Andrey Pokhilko
2022-10-17 13:41:08 +01:00
committed by GitHub
parent 5cae4b5adf
commit f86a4a93a7
22 changed files with 995 additions and 439 deletions

View File

@@ -2,13 +2,14 @@ package dashboard
import (
"embed"
"errors"
"github.com/gin-gonic/gin"
"github.com/komodorio/helm-dashboard/pkg/dashboard/handlers"
"github.com/komodorio/helm-dashboard/pkg/dashboard/subproc"
"github.com/komodorio/helm-dashboard/pkg/dashboard/utils"
log "github.com/sirupsen/logrus"
"net/http"
"os"
"path"
"strconv"
)
//go:embed static/*
@@ -33,7 +34,17 @@ func errorHandler(c *gin.Context) {
}
}
func NewRouter(abortWeb ControlChan, data *DataLayer, version string) *gin.Engine {
func contextSetter(data *subproc.DataLayer) gin.HandlerFunc {
return func(c *gin.Context) {
if context, ok := c.Request.Header["X-Kubecontext"]; ok {
log.Debugf("Setting current context to: %s", context)
data.KubeContext = context[0]
}
c.Next()
}
}
func NewRouter(abortWeb utils.ControlChan, data *subproc.DataLayer, version string) *gin.Engine {
var api *gin.Engine
if os.Getenv("DEBUG") == "" {
api = gin.New()
@@ -52,7 +63,7 @@ func NewRouter(abortWeb ControlChan, data *DataLayer, version string) *gin.Engin
return api
}
func configureRoutes(abortWeb ControlChan, data *DataLayer, api *gin.Engine, version string) {
func configureRoutes(abortWeb utils.ControlChan, data *subproc.DataLayer, api *gin.Engine, version string) {
// server shutdown handler
api.DELETE("/", func(c *gin.Context) {
abortWeb <- struct{}{}
@@ -65,10 +76,11 @@ func configureRoutes(abortWeb ControlChan, data *DataLayer, api *gin.Engine, ver
configureHelms(api.Group("/api/helm"), data)
configureKubectls(api.Group("/api/kube"), data)
configureScanners(api.Group("/api/scanners"), data)
}
func configureHelms(api *gin.RouterGroup, data *DataLayer) {
h := HelmHandler{Data: data}
func configureHelms(api *gin.RouterGroup, data *subproc.DataLayer) {
h := handlers.HelmHandler{Data: data}
api.GET("/charts", h.GetCharts)
api.DELETE("/charts", h.Uninstall)
api.POST("/charts/rollback", h.Rollback)
@@ -81,8 +93,8 @@ func configureHelms(api *gin.RouterGroup, data *DataLayer) {
api.GET("/charts/:section", h.GetInfoSection)
}
func configureKubectls(api *gin.RouterGroup, data *DataLayer) {
h := KubeHandler{Data: data}
func configureKubectls(api *gin.RouterGroup, data *subproc.DataLayer) {
h := handlers.KubeHandler{Data: data}
api.GET("/contexts", h.GetContexts)
api.GET("/resources/:kind", h.GetResourceInfo)
api.GET("/describe/:kind", h.Describe)
@@ -118,36 +130,9 @@ func configureStatic(api *gin.Engine) {
}
}
func contextSetter(data *DataLayer) gin.HandlerFunc {
return func(c *gin.Context) {
if context, ok := c.Request.Header["X-Kubecontext"]; ok {
log.Debugf("Setting current context to: %s", context)
data.KubeContext = context[0]
}
c.Next()
}
}
type QueryProps struct {
Namespace string
Name string
Revision int
}
func getQueryProps(c *gin.Context, revRequired bool) (*QueryProps, error) {
qp := QueryProps{}
qp.Namespace = c.Query("namespace")
qp.Name = c.Query("name")
if qp.Name == "" {
return nil, errors.New("missing required query string parameter: name")
}
cRev, err := strconv.Atoi(c.Query("revision"))
if err != nil && revRequired {
return nil, err
}
qp.Revision = cRev
return &qp, nil
func configureScanners(api *gin.RouterGroup, data *subproc.DataLayer) {
h := handlers.ScannersHandler{Data: data}
api.GET("", h.List)
api.POST("/manifests", h.ScanDraftManifest)
api.GET("/resource/:kind", h.ScanResource)
}

View File

@@ -1,14 +1,17 @@
package dashboard
package handlers
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/komodorio/helm-dashboard/pkg/dashboard/subproc"
"github.com/komodorio/helm-dashboard/pkg/dashboard/utils"
"net/http"
"strconv"
"strings"
)
type HelmHandler struct {
Data *DataLayer
Data *subproc.DataLayer
}
func (h *HelmHandler) GetCharts(c *gin.Context) {
@@ -23,7 +26,7 @@ func (h *HelmHandler) GetCharts(c *gin.Context) {
// TODO: helm show chart komodorio/k8s-watcher to get the icon URL
func (h *HelmHandler) Uninstall(c *gin.Context) {
qp, err := getQueryProps(c, false)
qp, err := utils.GetQueryProps(c, false)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
@@ -37,7 +40,7 @@ func (h *HelmHandler) Uninstall(c *gin.Context) {
}
func (h *HelmHandler) Rollback(c *gin.Context) {
qp, err := getQueryProps(c, true)
qp, err := utils.GetQueryProps(c, true)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
@@ -52,7 +55,7 @@ func (h *HelmHandler) Rollback(c *gin.Context) {
}
func (h *HelmHandler) History(c *gin.Context) {
qp, err := getQueryProps(c, false)
qp, err := utils.GetQueryProps(c, false)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
@@ -67,7 +70,7 @@ func (h *HelmHandler) History(c *gin.Context) {
}
func (h *HelmHandler) Resources(c *gin.Context) {
qp, err := getQueryProps(c, true)
qp, err := utils.GetQueryProps(c, true)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
@@ -82,7 +85,7 @@ func (h *HelmHandler) Resources(c *gin.Context) {
}
func (h *HelmHandler) RepoSearch(c *gin.Context) {
qp, err := getQueryProps(c, false)
qp, err := utils.GetQueryProps(c, false)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
@@ -97,7 +100,7 @@ func (h *HelmHandler) RepoSearch(c *gin.Context) {
}
func (h *HelmHandler) RepoUpdate(c *gin.Context) {
qp, err := getQueryProps(c, false)
qp, err := utils.GetQueryProps(c, false)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
@@ -112,7 +115,7 @@ func (h *HelmHandler) RepoUpdate(c *gin.Context) {
}
func (h *HelmHandler) Install(c *gin.Context) {
qp, err := getQueryProps(c, false)
qp, err := utils.GetQueryProps(c, false)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
@@ -127,13 +130,20 @@ func (h *HelmHandler) Install(c *gin.Context) {
if !justTemplate {
c.Header("Content-Type", "application/json")
} else {
manifests, err := h.Data.RevisionManifests(qp.Namespace, qp.Name, 0, false)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
out = subproc.GetDiff(strings.TrimSpace(manifests), out, "current.yaml", "upgraded.yaml")
}
c.String(http.StatusAccepted, out)
}
func (h *HelmHandler) GetInfoSection(c *gin.Context) {
qp, err := getQueryProps(c, true)
qp, err := utils.GetQueryProps(c, true)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
@@ -158,8 +168,8 @@ func (h *HelmHandler) RepoValues(c *gin.Context) {
c.String(http.StatusOK, out)
}
func handleGetSection(data *DataLayer, section string, rDiff string, qp *QueryProps, flag bool) (string, error) {
sections := map[string]SectionFn{
func handleGetSection(data *subproc.DataLayer, section string, rDiff string, qp *utils.QueryProps, flag bool) (string, error) {
sections := map[string]subproc.SectionFn{
"manifests": data.RevisionManifests,
"values": data.RevisionValues,
"notes": data.RevisionNotes,
@@ -181,7 +191,7 @@ func handleGetSection(data *DataLayer, section string, rDiff string, qp *QueryPr
ext = ".txt"
}
res, err := RevisionDiff(functor, ext, qp.Namespace, qp.Name, cRevDiff, qp.Revision, flag)
res, err := subproc.RevisionDiff(functor, ext, qp.Namespace, qp.Name, cRevDiff, qp.Revision, flag)
if err != nil {
return "", err
}

View File

@@ -1,14 +1,16 @@
package dashboard
package handlers
import (
"github.com/gin-gonic/gin"
"github.com/komodorio/helm-dashboard/pkg/dashboard/subproc"
"github.com/komodorio/helm-dashboard/pkg/dashboard/utils"
"k8s.io/apimachinery/pkg/apis/meta/v1"
v12 "k8s.io/apimachinery/pkg/apis/testapigroup/v1"
"net/http"
)
type KubeHandler struct {
Data *DataLayer
Data *subproc.DataLayer
}
func (h *KubeHandler) GetContexts(c *gin.Context) {
@@ -21,7 +23,7 @@ func (h *KubeHandler) GetContexts(c *gin.Context) {
}
func (h *KubeHandler) GetResourceInfo(c *gin.Context) {
qp, err := getQueryProps(c, false)
qp, err := utils.GetQueryProps(c, false)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
@@ -53,7 +55,7 @@ func (h *KubeHandler) GetResourceInfo(c *gin.Context) {
}
func (h *KubeHandler) Describe(c *gin.Context) {
qp, err := getQueryProps(c, false)
qp, err := utils.GetQueryProps(c, false)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return

View File

@@ -0,0 +1,68 @@
package handlers
import (
"github.com/gin-gonic/gin"
"github.com/komodorio/helm-dashboard/pkg/dashboard/subproc"
"github.com/komodorio/helm-dashboard/pkg/dashboard/utils"
"net/http"
)
type ScannersHandler struct {
Data *subproc.DataLayer
}
func (h *ScannersHandler) List(c *gin.Context) {
var res []string
for _, scanner := range h.Data.Scanners {
res = append(res, scanner.Name())
}
c.JSON(http.StatusOK, res)
}
func (h *ScannersHandler) ScanDraftManifest(c *gin.Context) {
qp, err := utils.GetQueryProps(c, false)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
mnf, err := h.Data.ChartUpgrade(qp.Namespace, qp.Name, c.Query("chart"), c.Query("version"), true, c.PostForm("values"))
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
reps := map[string]*subproc.ScanResults{}
for _, scanner := range h.Data.Scanners {
sr, err := scanner.ScanManifests(mnf)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
reps[scanner.Name()] = sr
}
c.IndentedJSON(http.StatusOK, reps)
}
func (h *ScannersHandler) ScanResource(c *gin.Context) {
qp, err := utils.GetQueryProps(c, false)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
reps := map[string]*subproc.ScanResults{}
for _, scanner := range h.Data.Scanners {
sr, err := scanner.ScanResource(qp.Namespace, c.Param("kind"), qp.Name)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
reps[scanner.Name()] = sr
}
c.IndentedJSON(http.StatusOK, reps)
}

View File

@@ -0,0 +1,107 @@
package scanners
import (
"github.com/komodorio/helm-dashboard/pkg/dashboard/subproc"
"github.com/komodorio/helm-dashboard/pkg/dashboard/utils"
log "github.com/sirupsen/logrus"
v1 "k8s.io/apimachinery/pkg/apis/testapigroup/v1"
"strconv"
"strings"
)
type Checkov struct {
Data *subproc.DataLayer
}
func (c *Checkov) Name() string {
return "Checkov"
}
func (c *Checkov) Test() bool {
res, err := utils.RunCommand([]string{"checkov", "--version"}, nil)
if err != nil {
return false
}
log.Infof("Discovered Checkov version: %s", strings.TrimSpace(res))
return true
}
func (c *Checkov) ScanManifests(mnf string) (*subproc.ScanResults, error) {
fname, fclose, err := utils.TempFile(mnf)
if err != nil {
return nil, err
}
defer fclose()
cmd := []string{"checkov", "--quiet", "--soft-fail", "--framework", "kubernetes", "--output", "cli", "--file", fname}
out, err := utils.RunCommand(cmd, nil)
if err != nil {
return nil, err
}
res := &subproc.ScanResults{}
res.OrigReport = out
return res, nil
}
func (c *Checkov) ScanResource(ns string, kind string, name string) (*subproc.ScanResults, error) {
carp := v1.Carp{}
carp.Kind = kind
carp.Name = name
mnf, err := c.Data.GetResourceYAML(ns, &carp)
if err != nil {
return nil, err
}
fname, fclose, err := utils.TempFile(mnf)
if err != nil {
return nil, err
}
defer fclose()
cmd := []string{"checkov", "--quiet", "--soft-fail", "--framework", "kubernetes", "--output", "cli", "--file", fname}
out, err := utils.RunCommand(cmd, nil)
if err != nil {
return nil, err
}
res := subproc.ScanResults{}
_, out, _ = strings.Cut(out, "\n") // kubernetes scan results:
_, out, _ = strings.Cut(out, "\n") // empty line
line, out, found := strings.Cut(out, "\n") // status line
if found {
parts := strings.FieldsFunc(line, func(r rune) bool {
return r == ':' || r == ','
})
if cnt, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil {
res.PassedCount = cnt
} else {
log.Warnf("Failed to parse Checkov output: %s", err)
}
if cnt, err := strconv.Atoi(strings.TrimSpace(parts[3])); err == nil {
res.FailedCount = cnt
} else {
log.Warnf("Failed to parse Checkov output: %s", err)
}
} else {
log.Warnf("Failed to parse Checkov output")
}
res.OrigReport = strings.TrimSpace(out)
return &res, nil
}
type CheckovResults struct {
Summary CheckovSummary
}
type CheckovSummary struct {
Failed int `json:"failed"`
Passed int `json:"passed"`
ResourceCount int `json:"resource_count"`
// parsing errors?
// skipped ?
}

View File

@@ -0,0 +1,87 @@
package scanners
import (
"github.com/komodorio/helm-dashboard/pkg/dashboard/subproc"
"github.com/komodorio/helm-dashboard/pkg/dashboard/utils"
log "github.com/sirupsen/logrus"
"strconv"
"strings"
)
type Trivy struct {
Data *subproc.DataLayer
}
func (c *Trivy) Name() string {
return "Trivy"
}
func (c *Trivy) Test() bool {
res, err := utils.RunCommand([]string{"trivy", "--version"}, nil)
if err != nil {
return false
}
parts := strings.Split(res, "\n")
log.Infof("Discovered Trivy: %s", strings.TrimSpace(parts[0]))
return true
}
func (c *Trivy) ScanManifests(_ string) (*subproc.ScanResults, error) {
return nil, nil // Trivy is unable to scan manifests
}
func (c *Trivy) scanResource(ns string, kind string, name string) (string, error) {
cmd := []string{"trivy", "kubernetes", "--quiet", "--format", "table", "--report", "all", "--no-progress",
"--context", c.Data.KubeContext, "--namespace", ns, kind + "/" + name}
out, err := utils.RunCommand(cmd, nil)
if err != nil {
return "", err
}
return out, nil
}
func (c *Trivy) ScanResource(ns string, kind string, name string) (*subproc.ScanResults, error) {
res := subproc.ScanResults{}
resource, err := c.scanResource(ns, kind, name)
if err != nil {
return nil, err
}
for _, line := range strings.Split(resource, "\n") {
if strings.HasPrefix(line, "Tests:") {
parts := strings.FieldsFunc(line, func(r rune) bool {
return r == ':' || r == ',' || r == ')'
})
if cnt, err := strconv.Atoi(strings.TrimSpace(parts[2])); err == nil {
res.PassedCount += cnt
} else {
log.Warnf("Failed to parse Trivy output: %s", err)
}
if cnt, err := strconv.Atoi(strings.TrimSpace(parts[4])); err == nil {
res.FailedCount += cnt
} else {
log.Warnf("Failed to parse Trivy output: %s", err)
}
}
if strings.HasPrefix(line, "Total:") {
parts := strings.FieldsFunc(line, func(r rune) bool {
return r == ':' || r == ',' || r == '('
})
if cnt, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil {
res.FailedCount += cnt
} else {
log.Warnf("Failed to parse Trivy output: %s", err)
}
}
}
res.OrigReport = resource
return &res, nil
}

View File

@@ -3,19 +3,24 @@ package dashboard
import (
"context"
"github.com/gin-gonic/gin"
"github.com/komodorio/helm-dashboard/pkg/dashboard/scanners"
"github.com/komodorio/helm-dashboard/pkg/dashboard/subproc"
"github.com/komodorio/helm-dashboard/pkg/dashboard/utils"
log "github.com/sirupsen/logrus"
"net/http"
"os"
)
func StartServer(version string) (string, ControlChan) {
data := DataLayer{}
func StartServer(version string) (string, utils.ControlChan) {
data := subproc.DataLayer{}
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?
}
discoverScanners(&data)
address := os.Getenv("HD_BIND")
if address == "" {
address = "localhost"
@@ -27,15 +32,15 @@ func StartServer(version string) (string, ControlChan) {
address += ":" + os.Getenv("HD_PORT")
}
abort := make(ControlChan)
abort := make(utils.ControlChan)
api := NewRouter(abort, &data, version)
done := startBackgroundServer(address, api, abort)
return "http://" + address, done
}
func startBackgroundServer(addr string, routes *gin.Engine, abort ControlChan) ControlChan {
done := make(ControlChan)
func startBackgroundServer(addr string, routes *gin.Engine, abort utils.ControlChan) utils.ControlChan {
done := make(utils.ControlChan)
server := &http.Server{Addr: addr, Handler: routes}
go func() {
@@ -56,3 +61,17 @@ func startBackgroundServer(addr string, routes *gin.Engine, abort ControlChan) C
return done
}
func discoverScanners(data *subproc.DataLayer) {
potential := []subproc.Scanner{
&scanners.Checkov{Data: data},
&scanners.Trivy{Data: data},
}
data.Scanners = []subproc.Scanner{}
for _, scanner := range potential {
if scanner.Test() {
data.Scanners = append(data.Scanners, scanner)
}
}
}

View File

@@ -4,7 +4,7 @@ $("#btnUpgradeCheck").click(function () {
self.find(".spinner-border").show()
const repoName = self.data("repo")
$("#btnUpgrade span").text("Checking...")
$("#btnUpgrade .icon").removeClass("bi-arrow-up bi-pencil").addClass("bi-hourglass")
$("#btnUpgrade .icon").removeClass("bi-arrow-up bi-pencil").addClass("bi-hourglass-split")
$.post("/api/helm/repo/update?name=" + repoName).fail(function (xhr) {
reportError("Failed to update chart repo", xhr)
}).done(function () {
@@ -63,8 +63,9 @@ function checkUpgradeable(name) {
function popUpUpgrade(self, verCur, elm) {
const name = getHashParam("chart");
let url = "/api/helm/charts/install?namespace=" + getHashParam("namespace") + "&name=" + name + "&chart=" + elm.name;
$('#upgradeModal select').data("url", url).data("chart", elm.name)
const qstr = "?namespace=" + getHashParam("namespace") + "&name=" + name + "&chart=" + elm.name;
let url = "/api/helm/charts/install" + qstr
$('#upgradeModal select').data("qstr", qstr).data("url", url).data("chart", elm.name)
$("#upgradeModalLabel .name").text(name)
$("#upgradeModal .ver-old").text(verCur)
@@ -84,7 +85,6 @@ function popUpUpgrade(self, verCur, elm) {
}).fail(function (xhr) {
reportError("Failed to upgrade the chart", xhr)
}).done(function (data) {
console.log(data)
if (data.version) {
setHashParam("revision", data.version)
window.location.reload()
@@ -130,6 +130,40 @@ $('#upgradeModal select').change(function () {
})
})
$('#upgradeModal .btn-scan').click(function () {
const self = $(this)
self.prop("disabled", true).prepend('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>')
const qstr = $('#upgradeModal select').data("qstr")
$.ajax({
type: "POST",
url: "/api/scanners/manifests" + qstr + "&version=" + $('#upgradeModal select').val(),
data: $("#upgradeModal form").serialize(),
}).fail(function (xhr) {
reportError("Failed to scan the manifest", xhr)
}).done(function (data) {
self.prop("disabled", false).find(".spinner-border").hide()
const container = $("<div></div>")
for (let name in data) {
const res = data[name]
if (!res) {
continue
}
const pre = $("<pre></pre>").text(res.OrigReport)
container.append("<h2>" + name + " Scan Results</h2>")
container.append(pre)
}
const tab = window.open('about:blank', '_blank');
tab.document.write(container.prop('outerHTML')); // where 'html' is a variable containing your HTML
tab.document.close(); // to finish loading the page
})
})
function requestChangeDiff() {
const self = $('#upgradeModal select');
const diffBody = $("#upgradeModalBody");
@@ -140,11 +174,11 @@ function requestChangeDiff() {
if ($("#upgradeModal textarea").data("dirty")) {
$("#upgradeModal .invalid-feedback").hide()
values = $("#upgradeModal form").serialize()
try {
jsyaml.load($("#upgradeModal textarea").val())
} catch (e) {
$("#upgradeModal .invalid-feedback").text("YAML parse error: "+e.message).show()
$("#upgradeModal .invalid-feedback").text("YAML parse error: " + e.message).show()
$("#upgradeModalBody").html("Invalid values YAML")
return
}
@@ -155,7 +189,7 @@ function requestChangeDiff() {
url: self.data("url") + "&version=" + self.val(),
data: values,
}).fail(function (xhr) {
$("#upgradeModalBody").html("<p class='text-danger'>Failed to get upgrade info: "+ xhr.responseText+"</p>")
$("#upgradeModalBody").html("<p class='text-danger'>Failed to get upgrade info: " + xhr.responseText + "</p>")
}).done(function (data) {
diffBody.empty();
$("#upgradeModal .btn-confirm").prop("disabled", false)

View File

@@ -97,7 +97,7 @@ $('#specRev').keyup(function (event) {
}
});
$("form").submit(function(e){
$("form").submit(function (e) {
e.preventDefault();
});
@@ -140,6 +140,7 @@ $("#nav-tab [data-tab]").click(function () {
}
})
function showResources(namespace, chart, revision) {
const resBody = $("#nav-resources .body");
resBody.empty().append('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>');
@@ -156,9 +157,9 @@ function showResources(namespace, chart, revision) {
<div class="row px-3 py-2 mb-3 bg-white rounded">
<div class="col-2 res-kind text-break"></div>
<div class="col-3 res-name text-break fw-bold"></div>
<div class="col-1 res-status"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></div>
<div class="col-5 res-statusmsg"><span class="text-muted small">Getting status...</span></div>
<div class="col-1 res-actions"></div>
<div class="col-1 res-status overflow-hidden"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></div>
<div class="col-4 res-statusmsg"><span class="text-muted small">Getting status...</span></div>
<div class="col-2 res-actions"></div>
</div>
`)
@@ -182,16 +183,23 @@ function showResources(namespace, chart, revision) {
}
const statusBlock = resBlock.find(".res-status");
statusBlock.empty().append(badge)
statusBlock.empty().append(badge).attr("title", data.status.phase)
resBlock.find(".res-statusmsg").html("<span class='text-muted small'>" + (data.status.message ? data.status.message : '') + "</span>")
if (badge.text() !== "NotFound") {
resBlock.find(".res-actions")
const btn = $("<button class=\"btn btn-sm btn-white border-secondary\">Describe</button>");
resBlock.find(".res-actions").append(btn)
btn.click(function () {
showDescribe(ns, res.kind, res.metadata.name, badge.clone())
})
const btn2 = $("<button class='btn btn-sm btn-white border-secondary ms-2'>Scan</button>");
resBlock.find(".res-actions").append(btn2)
btn2.click(function () {
scanResource(ns, res.kind, res.metadata.name, badge.clone())
})
}
})
}
@@ -212,3 +220,42 @@ function showDescribe(ns, kind, name, badge) {
$("#describeModalBody").empty().append("<pre class='bg-white rounded p-3'></pre>").find("pre").html(data)
})
}
function scanResource(ns, kind, name, badge) {
$("#describeModal .offcanvas-header p").text(kind)
$("#describeModalLabel").text(name).append(badge.addClass("ms-3 small fw-normal"))
const body = $("#describeModalBody");
body.empty().append('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Scanning...')
const myModal = new bootstrap.Offcanvas(document.getElementById('describeModal'));
myModal.show()
$.get("/api/scanners/resource/" + kind.toLowerCase() + "?name=" + name + "&namespace=" + ns).fail(function (xhr) {
reportError("Failed to scan resource", xhr)
}).done(function (data) {
body.empty()
if ($.isEmptyObject(data)) {
body.append("No information from scanners. Make sure you have installed some and scanned object is supported.")
}
for (let name in data) {
const res = data[name]
if (!res.OrigReport) continue
const hdr = $("<h3>" + name + " Scan Results</h3>");
if (res.FailedCount) {
hdr.append("<span class='badge bg-danger ms-3'>" + res.FailedCount + " failed</span>")
}
if (res.PassedCount) {
hdr.append("<span class='badge bg-info ms-3'>" + res.PassedCount + " passed</span>")
}
body.append(hdr)
const hl = hljs.highlight(res.OrigReport, {language: 'yaml'}).value
const pre = $("<pre class='bg-white rounded p-3' style='font-size: inherit; overflow: unset'></pre>").html(hl)
body.append(pre)
}
})
}

View File

@@ -174,15 +174,15 @@
Resources
</button>
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#nav-manifest" data-tab="manifests"
type="button" role="tab" aria-controls="nav-manifest-diff" aria-selected="false"
type="button" role="tab" aria-controls="nav-manifest" aria-selected="false"
tabindex="-1">Manifests
</button>
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#nav-manifest" data-tab="values"
type="button" role="tab" aria-controls="nav-disabled" aria-selected="false" tabindex="-1">
type="button" role="tab" aria-controls="nav-manifest" aria-selected="false" tabindex="-1">
Values
</button>
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#nav-manifest" data-tab="notes"
type="button" role="tab" aria-controls="nav-disabled" aria-selected="false" tabindex="-1">
type="button" role="tab" aria-controls="nav-manifest" aria-selected="false" tabindex="-1">
Notes
</button>
</div>
@@ -200,7 +200,7 @@
<div class="body"></div>
</div>
<div class="tab-pane" id="nav-manifest" role="tabpanel">
<nav class="navbar bg-light">
<nav class="navbar bg-white rounded border border-secondary">
<form class="container-fluid" id="modePanel">
<label class="form-check-label" for="diffModeNone">
<input class="form-check-input" type="radio" name="diffMode" id="diffModeNone"
@@ -217,17 +217,11 @@
data-mode="diff-rev">
Diff with specific revision: <input class="form-input" size="3" id="specRev">
</label>
<label class="form-check-label" for="userDefinedVals">
<input class="form-check-input" type="checkbox" id="userDefinedVals"> User-defined only
</label>
</form>
</nav>
<div id="manifestText" class="mt-2 bg-white"></div>
</div>
<div class="tab-pane" id="nav-disabled" role="tabpanel" aria-labelledby="nav-disabled-tab"
tabindex="0">...
</div>
</div>
</div>
</div>
@@ -235,7 +229,7 @@
<!-- Modals -->
<div id="errorAlert" style="z-index: 2000"
<div id="errorAlert" style="z-index: 2000; max-width: 95%; overflow: auto"
class="display-none alert alert-sm alert-danger alert-dismissible position-absolute position-absolute top-0 start-50 translate-middle-x mt-3 border-danger"
role="alert">
<h4 class="alert-heading"><i class="bi-exclamation-triangle-fill"></i> <span></span></h4>
@@ -245,7 +239,7 @@
</div>
<div class="offcanvas offcanvas-end rounded-start" tabindex="-1" id="describeModal"
aria-labelledby="describeModalLabel">
aria-labelledby="describeModalLabel" style="overflow-x: auto">
<div class="offcanvas-header border-bottom p-4">
<div>
<h5 id="describeModalLabel"></h5>
@@ -302,7 +296,7 @@
</div>
<div class="row">
<div class="col-6 pe-3">
<textarea name="values" class="form-control w-100 h-100" rows="5"></textarea>
<textarea name="values" class="form-control w-100 h-100" rows="5" style="font-family: monospace"></textarea>
</div>
<div class="col-6 ps-3">
<pre class="ref-vals fs-6 w-100 bg-secondary p-2 rounded" style="max-height: 20rem"></pre>
@@ -314,10 +308,11 @@
<span class="invalid-feedback small mb-3"> (wrong YAML)</span>
</div>
</div>
<label class="form-label mt-5">Manifest changes:</label>
<label class="form-label mt-4">Manifest changes:</label>
<div id="upgradeModalBody" class="small"></div>
</form>
<div class="modal-footer">
<div class="modal-footer d-flex">
<button type="button" class="btn btn-scan bg-white border-secondary">Scan for Problems</button>
<button type="button" class="btn btn-primary btn-confirm">Confirm Upgrade</button>
</div>
</div>
@@ -336,6 +331,7 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js"
integrity="sha512-CSBhVREyzHAjAFfBlIBakjoRUKp5h7VSweP0InR/pAJyptH7peuhCsqAI/snV+TwZmXZqoUklpXp6R6wMnYf5Q=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="static/list-view.js"></script>
<script src="static/revisions-view.js"></script>
<script src="static/details-view.js"></script>

View File

@@ -19,6 +19,23 @@ $(function () {
loadChartHistory(namespace, chart)
}
})
$.getJSON("/api/scanners").fail(function (xhr) {
reportError("Failed to get list of scanners", xhr)
}).done(function (data) {
for (let n = 0; n < data.length; n++) {
const item = $(`
<label class="form-check-label me-4">
<input class="form-check-input me-1" type="checkbox" checked name="scanner" value="` + data[n] + `"> ` + data[n] + `
</label>`)
$("#nav-scanners form span").prepend(item)
}
if (!data.length) {
$("#upgradeModal .btn-scan").hide()
}
})
})
@@ -69,14 +86,14 @@ function statusStyle(status, card, txt) {
}
function getCleanClusterName(rawClusterName) {
if (rawClusterName.indexOf('arn')==0) {
if (rawClusterName.indexOf('arn') == 0) {
// AWS cluster
clusterSplit = rawClusterName.split(':')
clusterName = clusterSplit.at(-1).split("/").at(-1)
region = clusterSplit.at(-3)
return region + "/" + clusterName + ' [AWS]'
}
if (rawClusterName.indexOf('gke')==0) {
if (rawClusterName.indexOf('gke') == 0) {
// GKE cluster
return rawClusterName.split('_').at(-2) + '/' + rawClusterName.split('_').at(-1) + ' [GKE]'
}

View File

@@ -1,4 +1,4 @@
package dashboard
package subproc
import (
"bytes"
@@ -8,12 +8,11 @@ import (
"github.com/hexops/gotextdiff"
"github.com/hexops/gotextdiff/myers"
"github.com/hexops/gotextdiff/span"
"github.com/komodorio/helm-dashboard/pkg/dashboard/utils"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
"helm.sh/helm/v3/pkg/release"
v1 "k8s.io/apimachinery/pkg/apis/testapigroup/v1"
"os"
"os/exec"
"regexp"
"sort"
"strconv"
@@ -21,61 +20,17 @@ import (
"time"
)
type CmdError struct {
Command []string
OrigError error
StdErr []byte
}
func (e CmdError) Error() string {
//return fmt.Sprintf("failed to run command %s:\nError: %s\nSTDERR:%s", e.Command, e.OrigError, e.StdErr)
return string(e.StdErr)
}
type DataLayer struct {
KubeContext string
Helm string
Kubectl string
Scanners []Scanner
}
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 "", CmdError{
Command: cmd,
StdErr: serr,
OrigError: eerr,
}
}
return "", CmdError{
Command: cmd,
StdErr: serr,
OrigError: 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
return utils.RunCommand(cmd, map[string]string{"HELM_KUBECONTEXT": d.KubeContext})
}
func (d *DataLayer) runCommandHelm(cmd ...string) (string, error) {
@@ -166,7 +121,7 @@ func (d *DataLayer) ListContexts() (res []KubeContext, err error) {
return res, nil
}
func (d *DataLayer) ListInstalled() (res []releaseElement, err error) {
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
@@ -179,7 +134,7 @@ func (d *DataLayer) ListInstalled() (res []releaseElement, err error) {
return res, nil
}
func (d *DataLayer) ChartHistory(namespace string, chartName string) (res []*historyElement, err error) {
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 {
@@ -192,7 +147,7 @@ func (d *DataLayer) ChartHistory(namespace string, chartName string) (res []*his
}
for _, elm := range res {
chartRepoName, curVer, err := chartAndVersion(elm.Chart)
chartRepoName, curVer, err := utils.ChartAndVersion(elm.Chart)
if err != nil {
return nil, err
}
@@ -204,7 +159,7 @@ func (d *DataLayer) ChartHistory(namespace string, chartName string) (res []*his
return res, nil
}
func (d *DataLayer) ChartRepoVersions(chartName string) (res []repoChartElement, err error) {
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 {
@@ -257,6 +212,11 @@ func (d *DataLayer) RevisionManifestsParsed(namespace string, chartName string,
return nil, err
}
if doc.Kind == "" {
log.Warnf("Manifest piece is not k8s resource: %s", jsoned)
continue
}
res = append(res, &doc)
}
@@ -328,6 +288,15 @@ func (d *DataLayer) GetResource(namespace string, def *v1.Carp) (*v1.Carp, error
return &res, nil
}
func (d *DataLayer) GetResourceYAML(namespace string, def *v1.Carp) (string, error) {
out, err := d.runCommandKubectl("get", strings.ToLower(def.Kind), def.Name, "--namespace", namespace, "--output", "yaml")
if err != nil {
return "", err
}
return out, 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 {
@@ -375,7 +344,7 @@ func (d *DataLayer) ChartUpgrade(namespace string, name string, repoChart string
values = oldVals
}
oldValsFile, close1, err := tempFile(values)
oldValsFile, close1, err := utils.TempFile(values)
defer close1()
if err != nil {
return "", err
@@ -390,26 +359,13 @@ func (d *DataLayer) ChartUpgrade(namespace string, name string, repoChart string
if err != nil {
return "", err
}
res := release.Release{}
err = json.Unmarshal([]byte(out), &res)
if err != nil {
return "", err
}
if justTemplate {
res := release.Release{}
err = json.Unmarshal([]byte(out), &res)
if err != nil {
return "", err
}
manifests, err := d.RevisionManifests(namespace, name, 0, false)
if err != nil {
return "", err
}
out = getDiff(strings.TrimSpace(manifests), strings.TrimSpace(res.Manifest), "current.yaml", "upgraded.yaml")
} else {
res := release.Release{}
err = json.Unmarshal([]byte(out), &res)
if err != nil {
return "", err
}
_ = res
out = strings.TrimSpace(res.Manifest)
}
return out, nil
@@ -435,11 +391,11 @@ func RevisionDiff(functor SectionFn, ext string, namespace string, name string,
return "", err
}
diff := getDiff(manifest1, manifest2, strconv.Itoa(revision1)+ext, strconv.Itoa(revision2)+ext)
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 {
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)

View File

@@ -1,6 +1,7 @@
package dashboard
package subproc
import (
"github.com/komodorio/helm-dashboard/pkg/dashboard/utils"
log "github.com/sirupsen/logrus"
"helm.sh/helm/v3/pkg/release"
v1 "k8s.io/apimachinery/pkg/apis/testapigroup/v1"
@@ -45,7 +46,7 @@ func TestFlow(t *testing.T) {
}
_ = history
chartRepoName, curVer, err := chartAndVersion(chart.Chart)
chartRepoName, curVer, err := utils.ChartAndVersion(chart.Chart)
if err != nil {
t.Fatal(err)
}

View File

@@ -1,4 +1,4 @@
package dashboard
package subproc
import (
"helm.sh/helm/v3/pkg/release"
@@ -6,7 +6,7 @@ import (
)
// unpleasant copy from Helm sources, where they have it non-public
type releaseElement struct {
type ReleaseElement struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
Revision string `json:"revision"`
@@ -16,7 +16,7 @@ type releaseElement struct {
AppVersion string `json:"app_version"`
}
type historyElement struct {
type HistoryElement struct {
Revision int `json:"revision"`
Updated helmtime.Time `json:"updated"`
Status release.Status `json:"status"`
@@ -27,7 +27,7 @@ type historyElement struct {
ChartVer string `json:"chart_ver"`
}
type repoChartElement struct {
type RepoChartElement struct {
Name string `json:"name"`
Version string `json:"version"`
AppVersion string `json:"app_version"`

View File

@@ -0,0 +1,15 @@
package subproc
type Scanner interface {
Name() string // returns string label for the scanner
Test() bool // test if the scanner is available
ScanManifests(mnf string) (*ScanResults, error) // run the scanner on manifests
ScanResource(ns string, kind string, name string) (*ScanResults, error) // run the scanner on k8s resource
}
type ScanResults struct {
PassedCount int
FailedCount int
OrigReport interface{}
Error error
}

View File

@@ -1,33 +0,0 @@
package dashboard
import (
"errors"
"io/ioutil"
"os"
"strings"
)
type ControlChan = chan struct{}
func chartAndVersion(x string) (string, string, error) {
lastInd := strings.LastIndex(x, "-")
if lastInd < 0 {
return "", "", errors.New("can't parse chart version string")
}
return x[:lastInd], x[lastInd+1:], nil
}
func tempFile(txt string) (string, func(), error) {
file, err := ioutil.TempFile("", "helm_vals_")
if err != nil {
return "", nil, err
}
err = ioutil.WriteFile(file.Name(), []byte(txt), 0600)
if err != nil {
return "", nil, err
}
return file.Name(), func() { os.Remove(file.Name()) }, nil
}

View File

@@ -0,0 +1,115 @@
package utils
import (
"bytes"
"errors"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"io/ioutil"
"os"
"os/exec"
"strconv"
"strings"
)
type ControlChan = chan struct{}
func ChartAndVersion(x string) (string, string, error) {
lastInd := strings.LastIndex(x, "-")
if lastInd < 0 {
return "", "", errors.New("can't parse chart version string")
}
return x[:lastInd], x[lastInd+1:], nil
}
func TempFile(txt string) (string, func(), error) {
file, err := ioutil.TempFile("", "helm_dahsboard_*.yaml")
if err != nil {
return "", nil, err
}
err = ioutil.WriteFile(file.Name(), []byte(txt), 0600)
if err != nil {
return "", nil, err
}
return file.Name(), func() { os.Remove(file.Name()) }, nil
}
type CmdError struct {
Command []string
OrigError error
StdErr string
}
func (e CmdError) Error() string {
//return fmt.Sprintf("failed to run command %s:\nError: %s\nSTDERR:%s", e.Command, e.OrigError, e.StdErr)
return string(e.StdErr)
}
func RunCommand(cmd []string, env map[string]string) (string, error) {
prog := exec.Command(cmd[0], cmd[1:]...)
prog.Env = os.Environ()
for k, v := range env {
prog.Env = append(prog.Env, k+"="+v)
}
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 "", CmdError{
Command: cmd,
StdErr: string(serr),
OrigError: eerr,
}
}
return "", CmdError{
Command: cmd,
StdErr: string(serr),
OrigError: 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
}
type QueryProps struct {
Namespace string
Name string
Revision int
}
func GetQueryProps(c *gin.Context, revRequired bool) (*QueryProps, error) {
qp := QueryProps{}
qp.Namespace = c.Query("namespace")
qp.Name = c.Query("name")
if qp.Name == "" {
return nil, errors.New("missing required query string parameter: name")
}
cRev, err := strconv.Atoi(c.Query("revision"))
if err != nil && revRequired {
return nil, err
}
qp.Revision = cRev
return &qp, nil
}