diff --git a/kustomize/base/deployment.yaml b/kustomize/base/deployment.yaml index 81a1aec..d40cec9 100644 --- a/kustomize/base/deployment.yaml +++ b/kustomize/base/deployment.yaml @@ -15,6 +15,9 @@ spec: - name: kots-lint image: kots-lint imagePullPolicy: IfNotPresent + env: + - name: LOG_LEVEL + value: debug ports: - name: kots-lint containerPort: 8082 diff --git a/pkg/handlers/builders_lint.go b/pkg/handlers/builders_lint.go index ab59967..44c5d63 100644 --- a/pkg/handlers/builders_lint.go +++ b/pkg/handlers/builders_lint.go @@ -28,6 +28,8 @@ type LintBuildersReleaseResponse struct { func LintBuildersRelease(c *gin.Context) { log.Infof("Received builders lint request with content-length=%s, content-type=%s, client-ip=%s", c.GetHeader("content-length"), c.ContentType(), c.ClientIP()) + ctx := c.Request.Context() + specFiles := kots.SpecFiles{} numChartsRendered := 0 @@ -51,7 +53,7 @@ func LintBuildersRelease(c *gin.Context) { } log.Debugf("adding files for chart %s", header.Name) - files, err := kots.GetFilesFromChartReader(tarReader) + files, err := kots.GetFilesFromChartReader(ctx, tarReader) if err != nil { log.Infof("failed to get files from chart %s: %v", header.Name, err) lintExpressions = append(lintExpressions, kots.LintExpression{ @@ -63,11 +65,14 @@ func LintBuildersRelease(c *gin.Context) { continue } + troubleshootSpecs := kots.GetEmbeddedTroubleshootSpecs(ctx, files) + numChartsRendered += 1 specFiles = append(specFiles, files...) + specFiles = append(specFiles, troubleshootSpecs...) } } else if c.ContentType() == "application/gzip" { - files, err := kots.GetFilesFromChartReader(c.Request.Body) + files, err := kots.GetFilesFromChartReader(ctx, c.Request.Body) if err != nil { log.Infof("failed to get files from request: %v", err) lintExpressions = append(lintExpressions, kots.LintExpression{ @@ -76,8 +81,11 @@ func LintBuildersRelease(c *gin.Context) { Message: err.Error(), }) } else { + troubleshootSpecs := kots.GetEmbeddedTroubleshootSpecs(ctx, files) + numChartsRendered += 1 specFiles = append(specFiles, files...) + specFiles = append(specFiles, troubleshootSpecs...) } } else { c.JSON(http.StatusBadRequest, gin.H{"error": "content type must be application/gzip or application/tar"}) @@ -86,7 +94,7 @@ func LintBuildersRelease(c *gin.Context) { // Only lint if at least one chart was rendered, otherwise we get missing spec warnings/errors if numChartsRendered > 0 { - lint, err := kots.LintBuilders(c.Request.Context(), specFiles) + lint, err := kots.LintBuilders(ctx, specFiles) if err != nil { log.Errorf("failed to lint builders charts: %v", err) c.AbortWithError(http.StatusInternalServerError, err) diff --git a/pkg/handlers/builders_lint_test.go b/pkg/handlers/builders_lint_test.go index e888b3d..a3b7471 100644 --- a/pkg/handlers/builders_lint_test.go +++ b/pkg/handlers/builders_lint_test.go @@ -2,7 +2,6 @@ package handlers import ( "archive/tar" - "embed" "encoding/json" "fmt" "io" @@ -15,13 +14,6 @@ import ( "github.com/stretchr/testify/require" ) -func init() { - kots.InitOPALinting() -} - -//go:embed test-data/* -var testdata embed.FS - func Test_LintBuildersRelease(t *testing.T) { type resultType struct { @@ -81,6 +73,16 @@ func Test_LintBuildersRelease(t *testing.T) { }, }, }, + { + name: "one valid chart with preflights", + chartReader: func() io.ReadCloser { + return io.NopCloser(getTarReader([]string{"testchart-with-labels-with-preflightspec-in-secret-16.2.2.tgz"})) + }, + contentType: "application/tar", + want: resultType{ + LintExpressions: nil, + }, + }, { name: "one valid chart without preflights and one invalid chart", chartReader: func() io.ReadCloser { @@ -169,7 +171,7 @@ func Test_LintBuildersRelease(t *testing.T) { var got resultType err = json.Unmarshal(body, &got) req.NoError(err) - req.Equal(tt.want, got) + req.ElementsMatch(tt.want.LintExpressions, got.LintExpressions) }) } } diff --git a/pkg/handlers/common_test.go b/pkg/handlers/common_test.go new file mode 100644 index 0000000..9a27fc6 --- /dev/null +++ b/pkg/handlers/common_test.go @@ -0,0 +1,16 @@ +package handlers + +import ( + "embed" + + kjs "github.com/replicatedhq/kots-lint/kubernetes_json_schema" + "github.com/replicatedhq/kots-lint/pkg/kots" +) + +func init() { + kjs.KubernetesJsonSchemaDir = "../../kubernetes_json_schema/schema" + kots.InitOPALinting() +} + +//go:embed test-data/* +var testdata embed.FS diff --git a/pkg/handlers/lint.go b/pkg/handlers/lint.go index ee4b59b..e9a0231 100644 --- a/pkg/handlers/lint.go +++ b/pkg/handlers/lint.go @@ -4,7 +4,7 @@ import ( "bytes" "encoding/json" "fmt" - "io/ioutil" + "io" "net/http" "github.com/gin-gonic/gin" @@ -38,8 +38,10 @@ type LintReleaseResponse struct { func LintRelease(c *gin.Context) { log.Infof("Received lint request with content-length=%s, content-type=%s, client-ip=%s", c.GetHeader("content-length"), c.ContentType(), c.ClientIP()) + ctx := c.Request.Context() + // read before binding to check if body is a tar stream - data, err := ioutil.ReadAll(c.Request.Body) + data, err := io.ReadAll(c.Request.Body) c.Request.Body.Close() if err != nil { log.Errorf("failed to read request body: %v", err) @@ -58,7 +60,7 @@ func LintRelease(c *gin.Context) { specFiles = f } else { // restore request body to its original state to be able to bind it - c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(data)) + c.Request.Body = io.NopCloser(bytes.NewBuffer(data)) var request LintReleaseParameters if err := c.Bind(&request.Body); err != nil { @@ -74,7 +76,7 @@ func LintRelease(c *gin.Context) { } } - lintExpressions, isComplete, err := kots.LintSpecFiles(specFiles) + lintExpressions, isComplete, err := kots.LintSpecFiles(ctx, specFiles) if err != nil { fmt.Printf("failed to lint spec files: %v", err) c.AbortWithError(http.StatusInternalServerError, err) diff --git a/pkg/handlers/lint_test.go b/pkg/handlers/lint_test.go new file mode 100644 index 0000000..75f8123 --- /dev/null +++ b/pkg/handlers/lint_test.go @@ -0,0 +1,220 @@ +package handlers + +import ( + "archive/tar" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/replicatedhq/kots-lint/pkg/kots" + "github.com/stretchr/testify/require" + "gopkg.in/stretchr/testify.v1/assert" +) + +func Test_LintRelease(t *testing.T) { + + type resultType struct { + LintExpressions []kots.LintExpression `json:"lintExpressions"` + } + + getTarReader := func(filesNames []string) io.Reader { + pipeReader, pipeWriter := io.Pipe() + + go func() { + defer pipeWriter.Close() + + tarWriter := tar.NewWriter(pipeWriter) + defer tarWriter.Close() + + for _, fileName := range filesNames { + data, err := testdata.ReadFile(fileName) + if err != nil { + t.Fatalf("failed to open file: %v", err) + } + + if strings.HasSuffix(fileName, ".tgz") { + data = []byte(base64.StdEncoding.EncodeToString(data)) + } + + header := &tar.Header{ + Name: filepath.Base(fileName), + Mode: 0644, + Size: int64(len(data)), + } + + tarWriter.WriteHeader(header) + tarWriter.Write(data) + } + }() + + return pipeReader + } + + tests := []struct { + name string + chartReader func(t *testing.T) io.ReadCloser + contentType string + want resultType + }{ + { + name: "one valid chart without kotskinds", + chartReader: func(t *testing.T) io.ReadCloser { + yamlFiles, err := testdata.ReadDir("test-data/kots/without-preflight") + assert.NoError(t, err) + + files := []string{ + "test-data/builders/testchart-with-labels-16.2.2.tgz", + } + for _, f := range yamlFiles { + files = append(files, filepath.Join("test-data/kots/without-preflight", f.Name())) + } + + return io.NopCloser(getTarReader(files)) + }, + contentType: "application/tar", + want: resultType{ + LintExpressions: []kots.LintExpression{ + { + Rule: "preflight-spec", + Type: "warn", + Message: "Missing preflight spec", + Path: "", + Positions: nil, + }, + { + Rule: "application-spec", + Type: "warn", + Message: "Missing application spec", + Path: "", + Positions: nil, + }, + { + Rule: "config-spec", + Type: "warn", + Message: "Missing config spec", + Path: "", + Positions: nil, + }, + { + Rule: "troubleshoot-spec", + Type: "warn", + Message: "Missing troubleshoot spec", + Path: "", + Positions: nil, + }, + }, + }, + }, + { + name: "one valid chart without kotskinds but with preflights", + chartReader: func(t *testing.T) io.ReadCloser { + yamlFiles, err := testdata.ReadDir("test-data/kots/with-preflight") + assert.NoError(t, err) + + files := []string{ + "test-data/builders/testchart-with-labels-with-preflightspec-in-secret-16.2.2.tgz", + } + for _, f := range yamlFiles { + files = append(files, filepath.Join("test-data/kots/with-preflight", f.Name())) + } + + return io.NopCloser(getTarReader(files)) + }, + contentType: "application/tar", + want: resultType{ + LintExpressions: []kots.LintExpression{ + { + Rule: "application-spec", + Type: "warn", + Message: "Missing application spec", + Path: "", + Positions: nil, + }, + { + Rule: "config-spec", + Type: "warn", + Message: "Missing config spec", + Path: "", + Positions: nil, + }, + { + Rule: "troubleshoot-spec", + Type: "warn", + Message: "Missing troubleshoot spec", + Path: "", + Positions: nil, + }, + }, + }, + }, + { + name: "one valid chart with kotskinds but without preflights", + chartReader: func(t *testing.T) io.ReadCloser { + yamlFiles, err := testdata.ReadDir("test-data/kots/with-kots-kinds") + assert.NoError(t, err) + + files := []string{ + "test-data/builders/testchart-with-labels-16.2.2.tgz", + } + for _, f := range yamlFiles { + files = append(files, filepath.Join("test-data/kots/with-kots-kinds", f.Name())) + } + + return io.NopCloser(getTarReader(files)) + }, + contentType: "application/tar", + want: resultType{ + LintExpressions: []kots.LintExpression{ + { + Rule: "application-statusInformers", + Type: "warn", + Message: "Missing application statusInformers", + Path: "kots-app.yaml", + Positions: []kots.LintExpressionItemPosition{ + { + Start: kots.LintExpressionItemLinePosition{ + Line: 5, + }, + }, + }, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := require.New(t) + + clientRequest := &http.Request{ + Body: tt.chartReader(t), + Header: http.Header{ + "Content-Type": []string{tt.contentType}, + }, + } + respWriter := httptest.NewRecorder() + + c, _ := gin.CreateTestContext(respWriter) + c.Request = clientRequest + + LintRelease(c) + + req.Equal(http.StatusOK, respWriter.Result().StatusCode) + + body, err := io.ReadAll(respWriter.Body) + req.NoError(err) + + var got resultType + err = json.Unmarshal(body, &got) + req.NoError(err) + req.ElementsMatch(tt.want.LintExpressions, got.LintExpressions) + }) + } +} diff --git a/pkg/handlers/test-data/builders/testchart-with-labels-with-preflightspec-in-secret-16.2.2.tgz b/pkg/handlers/test-data/builders/testchart-with-labels-with-preflightspec-in-secret-16.2.2.tgz new file mode 100644 index 0000000..2783c77 Binary files /dev/null and b/pkg/handlers/test-data/builders/testchart-with-labels-with-preflightspec-in-secret-16.2.2.tgz differ diff --git a/pkg/handlers/test-data/kots/with-kots-kinds/chart.yaml b/pkg/handlers/test-data/kots/with-kots-kinds/chart.yaml new file mode 100644 index 0000000..ff22928 --- /dev/null +++ b/pkg/handlers/test-data/kots/with-kots-kinds/chart.yaml @@ -0,0 +1,8 @@ +apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: testchart-with-labels +spec: + chart: + name: testchart-with-labels + chartVersion: 16.2.2 diff --git a/pkg/handlers/test-data/kots/with-kots-kinds/kots-app.yaml b/pkg/handlers/test-data/kots/with-kots-kinds/kots-app.yaml new file mode 100644 index 0000000..66f1c74 --- /dev/null +++ b/pkg/handlers/test-data/kots/with-kots-kinds/kots-app.yaml @@ -0,0 +1,7 @@ +apiVersion: kots.io/v1beta1 +kind: Application +metadata: + name: alpine-app +spec: + title: App Name + icon: https://raw.githubusercontent.com/cncf/artwork/master/projects/kubernetes/icon/color/kubernetes-icon-color.png diff --git a/pkg/handlers/test-data/kots/with-kots-kinds/kots-config.yaml b/pkg/handlers/test-data/kots/with-kots-kinds/kots-config.yaml new file mode 100644 index 0000000..e10e9e5 --- /dev/null +++ b/pkg/handlers/test-data/kots/with-kots-kinds/kots-config.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: kots.io/v1beta1 +kind: Config +metadata: + name: config-sample +spec: + groups: + - name: example_settings + title: My Example Config + description: Configuration to serve as an example for creating your own. See [https://kots.io/reference/v1beta1/config/](https://kots.io/reference/v1beta1/config/) for configuration docs. In this case, we provide example fields for configuring an Nginx welcome page. + items: + - name: command_delay_seconds + title: Command Delay Seconds + type: text + default: "10" diff --git a/pkg/handlers/test-data/kots/with-kots-kinds/preflight.yaml b/pkg/handlers/test-data/kots/with-kots-kinds/preflight.yaml new file mode 100644 index 0000000..c2476dc --- /dev/null +++ b/pkg/handlers/test-data/kots/with-kots-kinds/preflight.yaml @@ -0,0 +1,6 @@ +apiVersion: troubleshoot.sh/v1beta2 +kind: Preflight +metadata: + name: preflight-tutorial +spec: + analyzers: [] diff --git a/pkg/handlers/test-data/kots/with-kots-kinds/support-bundle.yaml b/pkg/handlers/test-data/kots/with-kots-kinds/support-bundle.yaml new file mode 100644 index 0000000..64d958a --- /dev/null +++ b/pkg/handlers/test-data/kots/with-kots-kinds/support-bundle.yaml @@ -0,0 +1,7 @@ +apiVersion: troubleshoot.sh/v1beta2 +kind: SupportBundle +metadata: + name: test-support-bundle +spec: + collectors: [] + analyzers: [] diff --git a/pkg/handlers/test-data/kots/with-preflight/chart.yaml b/pkg/handlers/test-data/kots/with-preflight/chart.yaml new file mode 100644 index 0000000..367fb2b --- /dev/null +++ b/pkg/handlers/test-data/kots/with-preflight/chart.yaml @@ -0,0 +1,8 @@ +apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: testchart-with-labels-with-preflightspec-in-secret +spec: + chart: + name: testchart-with-labels-with-preflightspec-in-secret + chartVersion: 16.2.2 diff --git a/pkg/handlers/test-data/kots/without-preflight/chart.yaml b/pkg/handlers/test-data/kots/without-preflight/chart.yaml new file mode 100644 index 0000000..ff22928 --- /dev/null +++ b/pkg/handlers/test-data/kots/without-preflight/chart.yaml @@ -0,0 +1,8 @@ +apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: testchart-with-labels +spec: + chart: + name: testchart-with-labels + chartVersion: 16.2.2 diff --git a/pkg/kots/builders_lint_test.go b/pkg/kots/builders_lint_test.go index db23bae..eb90127 100644 --- a/pkg/kots/builders_lint_test.go +++ b/pkg/kots/builders_lint_test.go @@ -128,7 +128,7 @@ func TestLintBuilders(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gotFiles, err := GetFilesFromChartReader(tt.chartReader()) + gotFiles, err := GetFilesFromChartReader(context.Background(), tt.chartReader()) if tt.isValidChart { assert.NoError(t, err) } else { diff --git a/pkg/kots/helm.go b/pkg/kots/helm.go index 7bdf076..f73c36e 100644 --- a/pkg/kots/helm.go +++ b/pkg/kots/helm.go @@ -1,6 +1,7 @@ package kots import ( + "context" _ "embed" "io" "path/filepath" @@ -14,7 +15,7 @@ import ( // GetFilesFromChartReader will render chart templates and return the resulting files // This function will ignore missing required values. // This function will also not validate value types. -func GetFilesFromChartReader(r io.Reader) (SpecFiles, error) { +func GetFilesFromChartReader(ctx context.Context, r io.Reader) (SpecFiles, error) { chart, err := loader.LoadArchive(r) if err != nil { return nil, errors.Wrap(err, "load chart archive") diff --git a/pkg/kots/lint.go b/pkg/kots/lint.go index e16d0e0..c9d02a1 100644 --- a/pkg/kots/lint.go +++ b/pkg/kots/lint.go @@ -4,6 +4,7 @@ import ( "bytes" "context" _ "embed" + "encoding/base64" "fmt" "io" "net/http" @@ -135,7 +136,7 @@ func InitOPALinting() error { return nil } -func LintSpecFiles(specFiles SpecFiles) ([]LintExpression, bool, error) { +func LintSpecFiles(ctx context.Context, specFiles SpecFiles) ([]LintExpression, bool, error) { unnestedFiles := specFiles.unnest() tarGzFiles := SpecFiles{} @@ -149,6 +150,40 @@ func LintSpecFiles(specFiles SpecFiles) ([]LintExpression, bool, error) { } } + // Extract troubleshoot specs from ConfigMaps and Secrets, which may also be in Helm charts + troubleshootSpecs := GetEmbeddedTroubleshootSpecs(ctx, yamlFiles) + for _, tsSpec := range troubleshootSpecs { + yamlFiles = append(yamlFiles, SpecFile{ + Name: tsSpec.Name, + Path: tsSpec.Path, + Content: tsSpec.Content, + DocIndex: len(yamlFiles), + }) + } + + for _, tarGtarGzFile := range tarGzFiles { + content, err := base64.StdEncoding.DecodeString(tarGtarGzFile.Content) + if err != nil { + log.Debugf("failed to base64 decode tarGz content: %v", err) + continue + } + + files, err := GetFilesFromChartReader(ctx, bytes.NewReader(content)) + if err != nil { + log.Debugf("failed to get files from tgz file %s: %v", tarGtarGzFile.Name, err) + continue + } + troubleshootSpecs := GetEmbeddedTroubleshootSpecs(ctx, files) + for _, tsSpec := range troubleshootSpecs { + yamlFiles = append(yamlFiles, SpecFile{ + Name: tsSpec.Name, + Path: tsSpec.Path, + Content: tsSpec.Content, + DocIndex: len(yamlFiles), + }) + } + } + // if there are yaml errors, end early there yamlLintExpressions := lintIsValidYAML(yamlFiles) if lintExpressionsHaveErrors(yamlLintExpressions) { diff --git a/pkg/kots/troubleshoot.go b/pkg/kots/troubleshoot.go new file mode 100644 index 0000000..b9c97d7 --- /dev/null +++ b/pkg/kots/troubleshoot.go @@ -0,0 +1,116 @@ +package kots + +import ( + "context" + _ "embed" + "fmt" + "path" + "strings" + + troubleshootscheme "github.com/replicatedhq/troubleshoot/pkg/client/troubleshootclientset/scheme" + "github.com/replicatedhq/troubleshoot/pkg/constants" + log "github.com/sirupsen/logrus" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +var decoder runtime.Decoder + +func init() { + _ = v1.AddToScheme(troubleshootscheme.Scheme) // for secrets and configmaps + decoder = troubleshootscheme.Codecs.UniversalDeserializer() +} + +func GetEmbeddedTroubleshootSpecs(ctx context.Context, specsFiles SpecFiles) SpecFiles { + tsSpecs := SpecFiles{} + + for _, specFile := range specsFiles { + troubleshootSpecs := findTroubleshootSpecs(ctx, specFile.Content) + for _, tsSpec := range troubleshootSpecs { + tsSpecs = append(tsSpecs, SpecFile{ + Name: path.Join(specFile.Name, tsSpec.Name), + Path: specFile.Name, + Content: tsSpec.Content, + }) + } + } + + return tsSpecs +} + +// Extract troubleshoot specs from ConfigMap and Secret specs +func findTroubleshootSpecs(ctx context.Context, fileData string) SpecFiles { + tsSpecs := SpecFiles{} + + srcDocs := strings.Split(fileData, "\n---\n") + for _, srcDoc := range srcDocs { + obj, _, err := decoder.Decode([]byte(srcDoc), nil, nil) + if err != nil { + log.Debugf("failed to decode raw spec: %s", srcDoc) + continue + } + + switch v := obj.(type) { + case *v1.ConfigMap: + specs := getSpecFromConfigMap(v, fmt.Sprintf("%s-", v.Name)) + tsSpecs = append(tsSpecs, specs...) + case *v1.Secret: + specs := getSpecFromSecret(v, fmt.Sprintf("%s-", v.Name)) + tsSpecs = append(tsSpecs, specs...) + } + } + + return tsSpecs +} + +func getSpecFromConfigMap(cm *v1.ConfigMap, namePrefix string) SpecFiles { + possibleKeys := []string{ + constants.SupportBundleKey, + constants.RedactorKey, + constants.PreflightKey, + constants.PreflightKey2, + } + + specs := SpecFiles{} + for _, key := range possibleKeys { + str, ok := cm.Data[key] + if ok { + specs = append(specs, SpecFile{ + Name: namePrefix + key, + Content: str, + }) + } + } + + return specs +} + +func getSpecFromSecret(secret *v1.Secret, namePrefix string) SpecFiles { + possibleKeys := []string{ + constants.SupportBundleKey, + constants.RedactorKey, + constants.PreflightKey, + constants.PreflightKey2, + } + + specs := SpecFiles{} + for _, key := range possibleKeys { + data, ok := secret.Data[key] + if ok { + specs = append(specs, SpecFile{ + Name: namePrefix + key, + Content: string(data), + }) + } + + str, ok := secret.StringData[key] + if ok { + specs = append(specs, SpecFile{ + Name: namePrefix + key, + Content: str, + }) + } + } + + return specs +}