From 715abe06c6c275e1a4f9dd9f4548d7c5c7396829 Mon Sep 17 00:00:00 2001 From: Jon Edvald Date: Sun, 17 Nov 2019 12:09:54 +0000 Subject: [PATCH] feat: hadolint provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `hadolint` provider is a fairly simple plugin that uses the new `augmentGraph` handler. Simply add the `hadolint`  provider to your project config, and a linting test will be created for each `container` module in the project. See the `hadolint` provider and `hadolint` module type reference docs for details and more usage options. --- examples/demo-project/backend/Dockerfile | 3 +- examples/demo-project/frontend/Dockerfile | 4 +- examples/hadolint/README.md | 7 + examples/hadolint/backend/.dockerignore | 4 + examples/hadolint/backend/.gitignore | 27 ++ examples/hadolint/backend/Dockerfile | 13 + examples/hadolint/backend/garden.yml | 14 + examples/hadolint/backend/webserver/main.go | 17 + examples/hadolint/frontend/.dockerignore | 4 + examples/hadolint/frontend/Dockerfile | 12 + examples/hadolint/frontend/app.js | 27 ++ examples/hadolint/frontend/garden.yml | 27 ++ examples/hadolint/frontend/main.js | 3 + examples/hadolint/frontend/package.json | 22 + examples/hadolint/frontend/test/integ.js | 17 + examples/hadolint/garden.yml | 17 + .../src/plugins/hadolint/hadolint.ts | 223 +++++++++ .../src/types/plugin/provider/augmentGraph.ts | 61 ++- garden-service/src/util/string.ts | 7 + .../static/hadolint/default.hadolint.yaml | 2 + .../test/data/hadolint/errAndWarn.Dockerfile | 2 + .../hadolint/ignore-dl3007/.hadolint.yaml | 2 + .../ignore-dl3007/errAndWarn.Dockerfile | 2 + .../test/data/hadolint/warn.Dockerfile | 1 + .../integ/src/plugins/hadolint/hadolint.ts | 457 ++++++++++++++++++ 25 files changed, 948 insertions(+), 27 deletions(-) create mode 100644 examples/hadolint/README.md create mode 100644 examples/hadolint/backend/.dockerignore create mode 100644 examples/hadolint/backend/.gitignore create mode 100644 examples/hadolint/backend/Dockerfile create mode 100644 examples/hadolint/backend/garden.yml create mode 100644 examples/hadolint/backend/webserver/main.go create mode 100644 examples/hadolint/frontend/.dockerignore create mode 100644 examples/hadolint/frontend/Dockerfile create mode 100644 examples/hadolint/frontend/app.js create mode 100644 examples/hadolint/frontend/garden.yml create mode 100644 examples/hadolint/frontend/main.js create mode 100644 examples/hadolint/frontend/package.json create mode 100644 examples/hadolint/frontend/test/integ.js create mode 100644 examples/hadolint/garden.yml create mode 100644 garden-service/src/plugins/hadolint/hadolint.ts create mode 100644 garden-service/static/hadolint/default.hadolint.yaml create mode 100644 garden-service/test/data/hadolint/errAndWarn.Dockerfile create mode 100644 garden-service/test/data/hadolint/ignore-dl3007/.hadolint.yaml create mode 100644 garden-service/test/data/hadolint/ignore-dl3007/errAndWarn.Dockerfile create mode 100644 garden-service/test/data/hadolint/warn.Dockerfile create mode 100644 garden-service/test/integ/src/plugins/hadolint/hadolint.ts diff --git a/examples/demo-project/backend/Dockerfile b/examples/demo-project/backend/Dockerfile index eca3125f6e..d342f34923 100644 --- a/examples/demo-project/backend/Dockerfile +++ b/examples/demo-project/backend/Dockerfile @@ -1,5 +1,4 @@ FROM golang:1.8.3-alpine -MAINTAINER Aurelien PERRIER ENV webserver_path /go/src/github.com/perriea/webserver/ ENV PATH $PATH:$webserver_path @@ -9,6 +8,6 @@ COPY webserver/ . RUN go build . -ENTRYPOINT ./webserver +ENTRYPOINT ["./webserver"] EXPOSE 8080 diff --git a/examples/demo-project/frontend/Dockerfile b/examples/demo-project/frontend/Dockerfile index 4a418d7d1e..67be950357 100644 --- a/examples/demo-project/frontend/Dockerfile +++ b/examples/demo-project/frontend/Dockerfile @@ -4,9 +4,9 @@ ENV PORT=8080 EXPOSE ${PORT} WORKDIR /app -ADD package.json /app +COPY package.json /app RUN npm install -ADD . /app +COPY . /app CMD ["npm", "start"] diff --git a/examples/hadolint/README.md b/examples/hadolint/README.md new file mode 100644 index 0000000000..0d6349cdd8 --- /dev/null +++ b/examples/hadolint/README.md @@ -0,0 +1,7 @@ +# hadolint project + +A simple variation on the [demo-project](../demo-project/README.md) that adds the [hadolint provider](https://docs.garden.io/reference/providers/hadolint.md). This generates an additional Dockerfile linting test for each `container` module in your project that contains a Dockerfile. + +To test it, run `garden dev` in this directory, and wait for the initial processing to complete. Notice the two tests that are added and run by the `hadolint` provider. + +Now try editing `backend/Dockerfile`, adding the line `MAINTAINER me@myself.com`. You should quickly see a linting error in your console, telling you that the `MAINTAINER` field is deprecated. diff --git a/examples/hadolint/backend/.dockerignore b/examples/hadolint/backend/.dockerignore new file mode 100644 index 0000000000..1cd4736667 --- /dev/null +++ b/examples/hadolint/backend/.dockerignore @@ -0,0 +1,4 @@ +node_modules +Dockerfile +garden.yml +app.yaml diff --git a/examples/hadolint/backend/.gitignore b/examples/hadolint/backend/.gitignore new file mode 100644 index 0000000000..eb086d61c3 --- /dev/null +++ b/examples/hadolint/backend/.gitignore @@ -0,0 +1,27 @@ +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe +*.test +*.prof + +.vscode/settings.json +webserver/*server* diff --git a/examples/hadolint/backend/Dockerfile b/examples/hadolint/backend/Dockerfile new file mode 100644 index 0000000000..d342f34923 --- /dev/null +++ b/examples/hadolint/backend/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.8.3-alpine + +ENV webserver_path /go/src/github.com/perriea/webserver/ +ENV PATH $PATH:$webserver_path + +WORKDIR $webserver_path +COPY webserver/ . + +RUN go build . + +ENTRYPOINT ["./webserver"] + +EXPOSE 8080 diff --git a/examples/hadolint/backend/garden.yml b/examples/hadolint/backend/garden.yml new file mode 100644 index 0000000000..3d9408a9f2 --- /dev/null +++ b/examples/hadolint/backend/garden.yml @@ -0,0 +1,14 @@ +kind: Module +name: backend +description: Backend service container +type: container +services: + - name: backend + ports: + - name: http + containerPort: 8080 + # Maps service:80 -> container:8080 + servicePort: 80 + ingresses: + - path: /hello-backend + port: http diff --git a/examples/hadolint/backend/webserver/main.go b/examples/hadolint/backend/webserver/main.go new file mode 100644 index 0000000000..34ae7d9838 --- /dev/null +++ b/examples/hadolint/backend/webserver/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "fmt" + "net/http" +) + +func handler(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "Hello from Go!") +} + +func main() { + http.HandleFunc("/hello-backend", handler) + fmt.Println("Server running...") + + http.ListenAndServe(":8080", nil) +} diff --git a/examples/hadolint/frontend/.dockerignore b/examples/hadolint/frontend/.dockerignore new file mode 100644 index 0000000000..1cd4736667 --- /dev/null +++ b/examples/hadolint/frontend/.dockerignore @@ -0,0 +1,4 @@ +node_modules +Dockerfile +garden.yml +app.yaml diff --git a/examples/hadolint/frontend/Dockerfile b/examples/hadolint/frontend/Dockerfile new file mode 100644 index 0000000000..67be950357 --- /dev/null +++ b/examples/hadolint/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:9-alpine + +ENV PORT=8080 +EXPOSE ${PORT} +WORKDIR /app + +COPY package.json /app +RUN npm install + +COPY . /app + +CMD ["npm", "start"] diff --git a/examples/hadolint/frontend/app.js b/examples/hadolint/frontend/app.js new file mode 100644 index 0000000000..a721f38b9c --- /dev/null +++ b/examples/hadolint/frontend/app.js @@ -0,0 +1,27 @@ +const express = require('express'); +const request = require('request-promise') +const app = express(); + +const backendServiceEndpoint = `http://backend/hello-backend` + +app.get('/hello-frontend', (req, res) => res.send('Hello from the frontend!')); + +app.get('/call-backend', (req, res) => { + // Query the backend and return the response + request.get(backendServiceEndpoint) + .then(message => { + message = `Backend says: '${message}'` + res.json({ + message, + }) + }) + .catch(err => { + res.statusCode = 500 + res.json({ + error: err, + message: "Unable to reach service at " + backendServiceEndpoint, + }) + }); +}); + +module.exports = { app } diff --git a/examples/hadolint/frontend/garden.yml b/examples/hadolint/frontend/garden.yml new file mode 100644 index 0000000000..8db227776b --- /dev/null +++ b/examples/hadolint/frontend/garden.yml @@ -0,0 +1,27 @@ +kind: Module +name: frontend +description: Frontend service container +type: container +services: + - name: frontend + ports: + - name: http + containerPort: 8080 + healthCheck: + httpGet: + path: /hello-frontend + port: http + ingresses: + - path: /hello-frontend + port: http + - path: /call-backend + port: http + dependencies: + - backend +tests: + - name: unit + args: [npm, test] + - name: integ + args: [npm, run, integ] + dependencies: + - frontend diff --git a/examples/hadolint/frontend/main.js b/examples/hadolint/frontend/main.js new file mode 100644 index 0000000000..ab66491126 --- /dev/null +++ b/examples/hadolint/frontend/main.js @@ -0,0 +1,3 @@ +const { app } = require('./app'); + +app.listen(process.env.PORT, '0.0.0.0', () => console.log('Frontend service started')); diff --git a/examples/hadolint/frontend/package.json b/examples/hadolint/frontend/package.json new file mode 100644 index 0000000000..e3da030191 --- /dev/null +++ b/examples/hadolint/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "frontend", + "version": "1.0.0", + "description": "Simple Node.js docker service", + "main": "main.js", + "scripts": { + "start": "node main.js", + "test": "echo OK", + "integ": "node_modules/mocha/bin/mocha test/integ.js" + }, + "author": "garden.io ", + "license": "ISC", + "dependencies": { + "express": "^4.16.2", + "request": "^2.83.0", + "request-promise": "^4.2.2" + }, + "devDependencies": { + "mocha": "^5.1.1", + "supertest": "^3.0.0" + } +} diff --git a/examples/hadolint/frontend/test/integ.js b/examples/hadolint/frontend/test/integ.js new file mode 100644 index 0000000000..ea1ccd85ef --- /dev/null +++ b/examples/hadolint/frontend/test/integ.js @@ -0,0 +1,17 @@ +const supertest = require("supertest") +const { app } = require("../app") + +describe('GET /call-backend', () => { + const agent = supertest.agent(app) + + it('should respond with a message from the backend service', (done) => { + agent + .get("/call-backend") + .expect(200, { message: "Backend says: 'Hello from Go!'" }) + .end((err) => { + if (err) return done(err) + done() + }) + }) +}) + diff --git a/examples/hadolint/garden.yml b/examples/hadolint/garden.yml new file mode 100644 index 0000000000..bd32cfc91e --- /dev/null +++ b/examples/hadolint/garden.yml @@ -0,0 +1,17 @@ +kind: Project +name: demo-project +environments: + - name: local + - name: testing +providers: + - name: local-kubernetes + environments: [local] + - name: kubernetes + environments: [testing] + context: gke_garden-dev-200012_europe-west1-b_garden-dev-1 + namespace: demo-project-testing-${local.env.CIRCLE_BUILD_NUM || local.username} + defaultHostname: demo-project-testing.dev-1.sys.garden + buildMode: cluster-docker + clusterDocker: + enableBuildKit: true + - name: hadolint diff --git a/garden-service/src/plugins/hadolint/hadolint.ts b/garden-service/src/plugins/hadolint/hadolint.ts new file mode 100644 index 0000000000..f564beb29b --- /dev/null +++ b/garden-service/src/plugins/hadolint/hadolint.ts @@ -0,0 +1,223 @@ +/* + * Copyright (C) 2018 Garden Technologies, Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import Bluebird from "bluebird" +import { join, relative } from "path" +import { pathExists, readFile } from "fs-extra" +import { createGardenPlugin } from "../../types/plugin/plugin" +import { providerConfigBaseSchema, ProviderConfig, Provider } from "../../config/provider" +import { joi } from "../../config/common" +import { dedent, splitLines } from "../../util/string" +import { TestModuleParams } from "../../types/plugin/module/testModule" +import { Module } from "../../types/module" +import { BinaryCmd } from "../../util/ext-tools" +import { STATIC_DIR } from "../../constants" +import { padStart, padEnd } from "lodash" +import chalk from "chalk" +import { ConfigurationError } from "../../exceptions" +import { containerHelpers } from "../container/helpers" +import { baseBuildSpecSchema } from "../../config/module" + +const defaultConfigPath = join(STATIC_DIR, "hadolint", "default.hadolint.yaml") +const configFilename = ".hadolint.yaml" + +interface HadolintProviderConfig extends ProviderConfig { + autoInject: boolean + testFailureThreshold: "error" | "warning" | "none" +} + +interface HadolintProvider extends Provider {} + +const configSchema = providerConfigBaseSchema + .keys({ + autoInject: joi + .boolean() + .default(true) + .description( + dedent` + By default, the provider automatically creates a \`hadolint\` module for every \`container\` module in your + project. Set this to \`false\` to disable this behavior. + ` + ), + testFailureThreshold: joi + .string() + .allow("error", "warning", "none") + .default("error") + .description( + dedent` + Set this to \`"warning"\` if you'd like tests to be marked as failed if one or more warnings are returned. + Set to \`"none"\` to always mark the tests as successful. + ` + ), + }) + .unknown(false) + +interface HadolintModuleSpec { + dockerfilePath: string +} + +type HadolintModule = Module + +export const gardenPlugin = createGardenPlugin({ + name: "hadolint", + dependencies: ["container"], + configSchema, + handlers: { + augmentGraph: async ({ ctx, modules }) => { + const provider = ctx.provider as HadolintProvider + + if (!provider.config.autoInject) { + return {} + } + + return { + addModules: await Bluebird.filter(modules, async (module) => { + return module.compatibleTypes.includes("container") && (await containerHelpers.hasDockerfile(module)) + }).map((module) => { + return { + kind: "Module", + type: "hadolint", + name: "hadolint-" + module.name, + description: `hadolint test for module '${module.name}' (auto-generated)`, + path: module.path, + dockerfilePath: relative(module.path, containerHelpers.getDockerfileSourcePath(module)), + } + }), + } + }, + }, + createModuleTypes: [ + { + name: "hadolint", + docs: dedent` + Runs \`hadolint\` on the specified Dockerfile. + + > Note: In most cases, you'll let the provider create this module type automatically, but you may in some cases want or need to manually specify a Dockerfile to lint. + + To configure \`hadolint\`, you can use \`.hadolint.yaml\` config files. For each test, we first look for one in + the module root. If none is found there, we check the project root, and if none is there we fall back to default + configuration. Note that for reasons of portability, we do not fall back to global/user configuration files. + + See the [hadolint docs](https://github.com/hadolint/hadolint#configure) for details on how to configure it. + `, + schema: joi.object().keys({ + build: baseBuildSpecSchema, + dockerfilePath: joi + .string() + .posixPath({ relativeOnly: true, subPathOnly: true }) + .required() + .description("POSIX-style path to a Dockerfile that you want to lint with `hadolint`."), + }), + handlers: { + configure: async ({ moduleConfig }) => { + moduleConfig.include = [moduleConfig.spec.dockerfilePath] + moduleConfig.testConfigs = [{ name: "lint", dependencies: [], spec: {}, timeout: 10 }] + return { moduleConfig } + }, + testModule: async ({ ctx, log, module, testConfig }: TestModuleParams) => { + const dockerfilePath = join(module.path, module.spec.dockerfilePath) + const startedAt = new Date() + let dockerfile: string + + try { + dockerfile = (await readFile(dockerfilePath)).toString() + } catch { + throw new ConfigurationError(`hadolint: Could not find Dockerfile at ${module.spec.dockerfilePath}`, { + modulePath: module.path, + ...module.spec, + }) + } + + let configPath: string + const moduleConfigPath = join(module.path, configFilename) + const projectConfigPath = join(ctx.projectRoot, configFilename) + + if (await pathExists(moduleConfigPath)) { + // Prefer configuration from the module root + configPath = moduleConfigPath + } else if (await pathExists(projectConfigPath)) { + // 2nd preference is configuration in project root + configPath = projectConfigPath + } else { + // Fall back to empty default config + configPath = defaultConfigPath + } + + const args = ["--config", configPath, "--format", "json", dockerfilePath] + const result = await hadolint.exec({ log, args, ignoreError: true }) + + let success = true + + const parsed = JSON.parse(result.stdout) + const errors = parsed.filter((p: any) => p.level === "error") + const warnings = parsed.filter((p: any) => p.level === "warning") + const provider = ctx.provider as HadolintProvider + const threshold = provider.config.testFailureThreshold + + if (warnings.length > 0 && threshold === "warning") { + success = false + } else if (errors.length > 0 && threshold !== "none") { + success = false + } + + let formattedResult = "OK" + + if (parsed.length > 0) { + const dockerfileLines = splitLines(dockerfile) + + formattedResult = + `hadolint reported ${errors.length} error(s) and ${warnings.length} warning(s):\n\n` + + parsed + .map((msg: any) => { + const color = msg.level === "error" ? chalk.bold.red : chalk.bold.yellow + const rawLine = dockerfileLines[msg.line - 1] + const linePrefix = padEnd(`${msg.line}:`, 5, " ") + const columnCursorPosition = (msg.column || 1) + linePrefix.length + + return dedent` + ${color(msg.code + ":")} ${chalk.bold(msg.message || "")} + ${linePrefix}${chalk.gray(rawLine)} + ${chalk.gray(padStart("^", columnCursorPosition, "-"))} + ` + }) + .join("\n") + } + + return { + testName: testConfig.name, + moduleName: module.name, + command: ["hadolint", ...args], + version: module.version.versionString, + success, + startedAt, + completedAt: new Date(), + log: formattedResult, + } + }, + }, + }, + ], +}) + +const hadolint = new BinaryCmd({ + name: "hadolint", + specs: { + darwin: { + url: "https://github.com/hadolint/hadolint/releases/download/v1.17.2/hadolint-Darwin-x86_64", + sha256: "da3bd1fae47f1ba4c4bca6a86d2c70bdbd6705308bd300d1f897c162bc32189a", + }, + linux: { + url: "https://github.com/hadolint/hadolint/releases/download/v1.17.2/hadolint-Linux-x86_64", + sha256: "b23e4d0e8964774cc0f4dd7ff81f1d05b5d7538b0b80dae5235b1239ab60749d", + }, + win32: { + url: "https://github.com/hadolint/hadolint/releases/download/v1.17.2/hadolint-Windows-x86_64.exe", + sha256: "8ba81d1fe79b91afb7ee16ac4e9fc6635646c2f770071d1ba924a8d26debe298", + }, + }, +}) diff --git a/garden-service/src/types/plugin/provider/augmentGraph.ts b/garden-service/src/types/plugin/provider/augmentGraph.ts index 145ee49b01..ed3c3a1c36 100644 --- a/garden-service/src/types/plugin/provider/augmentGraph.ts +++ b/garden-service/src/types/plugin/provider/augmentGraph.ts @@ -56,15 +56,21 @@ export const augmentGraph = { providers: joiArray(providerSchema).description("All configured providers in the project."), }), resultSchema: joi.object().keys({ - addBuildDependencies: joiArray( - joi.object().keys({ - by: joiIdentifier().description( - "The _dependant_, i.e. the module that should have a build dependency on `on`." - ), - on: joiIdentifier().description("The _dependency, i.e. the module that `by` should depend on."), - }) - ).description( - dedent` + addBuildDependencies: joi + .array() + .items( + joi + .object() + .optional() + .keys({ + by: joiIdentifier().description( + "The _dependant_, i.e. the module that should have a build dependency on `on`." + ), + on: joiIdentifier().description("The _dependency, i.e. the module that `by` should depend on."), + }) + ) + .description( + dedent` Add build dependencies between two modules, where \`by\` depends on \`on\`. Both modules must be previously defined in the project, added by one of the providers that this provider depends @@ -73,16 +79,22 @@ export const augmentGraph = { The most common use case for this field is to make an existing module depend on one of the modules specified in \`addModules\`. ` - ), - addRuntimeDependencies: joiArray( - joi.object().keys({ - by: joiIdentifier().description( - "The _dependant_, i.e. the service or task that should have a runtime dependency on `on`." - ), - on: joiIdentifier().description("The _dependency, i.e. the service or task that `by` should depend on."), - }) - ).description( - dedent` + ), + addRuntimeDependencies: joi + .array() + .items( + joi + .object() + .optional() + .keys({ + by: joiIdentifier().description( + "The _dependant_, i.e. the service or task that should have a runtime dependency on `on`." + ), + on: joiIdentifier().description("The _dependency, i.e. the service or task that `by` should depend on."), + }) + ) + .description( + dedent` Add runtime dependencies between two services or tasks, where \`by\` depends on \`on\`. Both services/tasks must be previously defined in the project, added by one of the providers that this provider @@ -91,9 +103,12 @@ export const augmentGraph = { The most common use case for this field is to make an existing service or task depend on one of the services/tasks specified under \`addModules\`. ` - ), - addModules: joiArray(addModuleSchema).description( - dedent` + ), + addModules: joi + .array() + .items(addModuleSchema.optional()) + .description( + dedent` Add modules (of any defined kind) to the stack graph. Each should be a module spec in the same format as a normal module specified in a \`garden.yml\` config file (which will later be passed to the appropriate \`configure\` handler(s) for the module type), with the addition of \`path\` being required. @@ -101,6 +116,6 @@ export const augmentGraph = { The added modules can be referenced in \`addBuildDependencies\`, and their services/tasks can be referenced in \`addRuntimeDependencies\`. ` - ), + ), }), } diff --git a/garden-service/src/util/string.ts b/garden-service/src/util/string.ts index b17f374b0d..94bf3aa6e5 100644 --- a/garden-service/src/util/string.ts +++ b/garden-service/src/util/string.ts @@ -84,3 +84,10 @@ export function naturalList(list: string[]) { export function randomString(length = 8) { return [...Array(length)].map(() => (~~(Math.random() * 36)).toString(36)).join("") } + +/** + * Splits the given string by newlines. Works for both Windows and *nix style breaks. + */ +export function splitLines(s: string) { + return s.split(/\r?\n/) +} diff --git a/garden-service/static/hadolint/default.hadolint.yaml b/garden-service/static/hadolint/default.hadolint.yaml new file mode 100644 index 0000000000..305ee34965 --- /dev/null +++ b/garden-service/static/hadolint/default.hadolint.yaml @@ -0,0 +1,2 @@ +ignored: [] +trustedRegistries: [] \ No newline at end of file diff --git a/garden-service/test/data/hadolint/errAndWarn.Dockerfile b/garden-service/test/data/hadolint/errAndWarn.Dockerfile new file mode 100644 index 0000000000..52e93ea055 --- /dev/null +++ b/garden-service/test/data/hadolint/errAndWarn.Dockerfile @@ -0,0 +1,2 @@ +FROM busybox:latest +MAINTAINER foo diff --git a/garden-service/test/data/hadolint/ignore-dl3007/.hadolint.yaml b/garden-service/test/data/hadolint/ignore-dl3007/.hadolint.yaml new file mode 100644 index 0000000000..0883ea08e1 --- /dev/null +++ b/garden-service/test/data/hadolint/ignore-dl3007/.hadolint.yaml @@ -0,0 +1,2 @@ +ignored: +- DL3007 diff --git a/garden-service/test/data/hadolint/ignore-dl3007/errAndWarn.Dockerfile b/garden-service/test/data/hadolint/ignore-dl3007/errAndWarn.Dockerfile new file mode 100644 index 0000000000..52e93ea055 --- /dev/null +++ b/garden-service/test/data/hadolint/ignore-dl3007/errAndWarn.Dockerfile @@ -0,0 +1,2 @@ +FROM busybox:latest +MAINTAINER foo diff --git a/garden-service/test/data/hadolint/warn.Dockerfile b/garden-service/test/data/hadolint/warn.Dockerfile new file mode 100644 index 0000000000..9a3adf68b5 --- /dev/null +++ b/garden-service/test/data/hadolint/warn.Dockerfile @@ -0,0 +1 @@ +FROM busybox:latest diff --git a/garden-service/test/integ/src/plugins/hadolint/hadolint.ts b/garden-service/test/integ/src/plugins/hadolint/hadolint.ts new file mode 100644 index 0000000000..5479f95108 --- /dev/null +++ b/garden-service/test/integ/src/plugins/hadolint/hadolint.ts @@ -0,0 +1,457 @@ +import tmp from "tmp-promise" +import { ProjectConfig } from "../../../../../src/config/project" +import execa = require("execa") +import { DEFAULT_API_VERSION } from "../../../../../src/constants" +import { Garden } from "../../../../../src/garden" +import { getDataDir } from "../../../../helpers" +import { expect } from "chai" +import stripAnsi from "strip-ansi" +import { dedent } from "../../../../../src/util/string" +import { TestTask } from "../../../../../src/tasks/test" +import { writeFile, remove, pathExists } from "fs-extra" +import { join } from "path" + +describe("hadolint provider", () => { + let tmpDir: tmp.DirectoryResult + let tmpPath: string + let projectConfigFoo: ProjectConfig + let projectHadolintConfigPath: string + + before(async () => { + tmpDir = await tmp.dir({ unsafeCleanup: true }) + tmpPath = tmpDir.path + + await execa("git", ["init"], { cwd: tmpPath }) + + projectConfigFoo = { + apiVersion: DEFAULT_API_VERSION, + kind: "Project", + name: "test", + path: tmpPath, + defaultEnvironment: "default", + dotIgnoreFiles: [], + environments: [{ name: "default", variables: {} }], + providers: [{ name: "hadolint" }], + variables: {}, + } + + projectHadolintConfigPath = join(tmpPath, ".hadolint.yaml") + }) + + after(async () => { + await tmpDir.cleanup() + }) + + afterEach(async () => { + if (await pathExists(projectHadolintConfigPath)) { + await remove(projectHadolintConfigPath) + } + }) + + it("should add a hadolint module for each container module with a Dockerfile", async () => { + const garden = await Garden.factory(tmpPath, { + plugins: [], + config: projectConfigFoo, + }) + + garden["moduleConfigs"] = { + // With Dockerfile + foo: { + apiVersion: DEFAULT_API_VERSION, + name: "foo", + type: "container", + allowPublish: false, + build: { dependencies: [] }, + outputs: {}, + path: tmpPath, + serviceConfigs: [], + taskConfigs: [], + testConfigs: [], + spec: { dockerfile: "foo.Dockerfile" }, + }, + // Without Dockerfile + bar: { + apiVersion: DEFAULT_API_VERSION, + name: "bar", + type: "container", + allowPublish: false, + build: { dependencies: [] }, + outputs: {}, + path: tmpPath, + serviceConfigs: [], + taskConfigs: [], + testConfigs: [], + spec: {}, + }, + } + + const graph = await garden.getConfigGraph(garden.log) + const module = await graph.getModule("hadolint-foo") + + expect(module.path).to.equal(tmpPath) + expect(module.spec).to.eql({ + build: { dependencies: [] }, + dockerfilePath: "foo.Dockerfile", + }) + }) + + it("should add a hadolint module for module types inheriting from container", async () => { + const foo = { + name: "foo", + dependencies: ["container"], + createModuleTypes: [ + { + name: "foo", + base: "container", + docs: "foo", + handlers: {}, + }, + ], + } + + const garden = await Garden.factory(tmpPath, { + plugins: [foo], + config: { + ...projectConfigFoo, + providers: [...projectConfigFoo.providers, { name: "foo" }], + }, + }) + + garden["moduleConfigs"] = { + foo: { + apiVersion: DEFAULT_API_VERSION, + name: "foo", + type: "foo", + allowPublish: false, + build: { dependencies: [] }, + outputs: {}, + path: tmpPath, + serviceConfigs: [], + taskConfigs: [], + testConfigs: [], + spec: { dockerfile: "foo.Dockerfile" }, + }, + } + + const graph = await garden.getConfigGraph(garden.log) + const module = await graph.getModule("hadolint-foo") + + expect(module.path).to.equal(tmpPath) + expect(module.spec).to.eql({ + build: { dependencies: [] }, + dockerfilePath: "foo.Dockerfile", + }) + }) + + describe("testModule", () => { + const path = getDataDir("hadolint") + + it("should format warnings and errors nicely", async () => { + const garden = await Garden.factory(tmpPath, { + plugins: [], + config: projectConfigFoo, + }) + + garden["moduleConfigs"] = { + foo: { + apiVersion: DEFAULT_API_VERSION, + name: "foo", + type: "hadolint", + allowPublish: false, + build: { dependencies: [] }, + outputs: {}, + path, + serviceConfigs: [], + taskConfigs: [], + testConfigs: [{ name: "foo", dependencies: [], spec: {}, timeout: 10 }], + spec: { dockerfilePath: "errAndWarn.Dockerfile" }, + }, + } + + const graph = await garden.getConfigGraph(garden.log) + const module = await graph.getModule("foo") + + const testTask = new TestTask({ + garden, + module, + log: garden.log, + graph, + testConfig: module.testConfigs[0], + force: true, + forceBuild: false, + version: module.version, + }) + + const key = testTask.getKey() + const { [key]: result } = await garden.processTasks([testTask]) + + expect(result).to.exist + expect(result!.error).to.exist + expect(stripAnsi(result!.error!.message)).to.equal(dedent` + hadolint reported 1 error(s) and 1 warning(s): + + DL3007: Using latest is prone to errors if the image will ever update. Pin the version explicitly to a release tag + 1: FROM busybox:latest + -----^ + DL4000: MAINTAINER is deprecated + 2: MAINTAINER foo + -----^ + `) + }) + + it("should prefer a .hadolint.yaml in the module root if it's available", async () => { + const garden = await Garden.factory(tmpPath, { + plugins: [], + config: projectConfigFoo, + }) + + // Write a config to the project root, that should _not_ be used in this test + await writeFile( + projectHadolintConfigPath, + dedent` + ignored: + - DL4000 + ` + ) + + const modulePath = getDataDir("hadolint", "ignore-dl3007") + + garden["moduleConfigs"] = { + foo: { + apiVersion: DEFAULT_API_VERSION, + name: "foo", + type: "hadolint", + allowPublish: false, + build: { dependencies: [] }, + outputs: {}, + path: modulePath, + serviceConfigs: [], + taskConfigs: [], + testConfigs: [{ name: "foo", dependencies: [], spec: {}, timeout: 10 }], + spec: { dockerfilePath: "errAndWarn.Dockerfile" }, + }, + } + + const graph = await garden.getConfigGraph(garden.log) + const module = await graph.getModule("foo") + + const testTask = new TestTask({ + garden, + module, + log: garden.log, + graph, + testConfig: module.testConfigs[0], + force: true, + forceBuild: false, + version: module.version, + }) + + const key = testTask.getKey() + const { [key]: result } = await garden.processTasks([testTask]) + + expect(result).to.exist + expect(result!.error).to.exist + expect(stripAnsi(result!.error!.message)).to.equal(dedent` + hadolint reported 1 error(s) and 0 warning(s): + + DL4000: MAINTAINER is deprecated + 2: MAINTAINER foo + -----^ + `) + }) + + it("should use a .hadolint.yaml in the project root if there's none in the module root", async () => { + const garden = await Garden.factory(tmpPath, { + plugins: [], + config: projectConfigFoo, + }) + + // Write a config to the project root, that should _not_ be used in this test + await writeFile( + projectHadolintConfigPath, + dedent` + ignored: + - DL3007 + ` + ) + + garden["moduleConfigs"] = { + foo: { + apiVersion: DEFAULT_API_VERSION, + name: "foo", + type: "hadolint", + allowPublish: false, + build: { dependencies: [] }, + outputs: {}, + path, + serviceConfigs: [], + taskConfigs: [], + testConfigs: [{ name: "foo", dependencies: [], spec: {}, timeout: 10 }], + spec: { dockerfilePath: "errAndWarn.Dockerfile" }, + }, + } + + const graph = await garden.getConfigGraph(garden.log) + const module = await graph.getModule("foo") + + const testTask = new TestTask({ + garden, + module, + log: garden.log, + graph, + testConfig: module.testConfigs[0], + force: true, + forceBuild: false, + version: module.version, + }) + + const key = testTask.getKey() + const { [key]: result } = await garden.processTasks([testTask]) + + expect(result).to.exist + expect(result!.error).to.exist + expect(stripAnsi(result!.error!.message)).to.equal(dedent` + hadolint reported 1 error(s) and 0 warning(s): + + DL4000: MAINTAINER is deprecated + 2: MAINTAINER foo + -----^ + `) + }) + + it("should set success=false with a linting warning if testFailureThreshold=warning", async () => { + const garden = await Garden.factory(tmpPath, { + plugins: [], + config: { + ...projectConfigFoo, + providers: [{ name: "hadolint", testFailureThreshold: "warning" }], + }, + }) + + garden["moduleConfigs"] = { + foo: { + apiVersion: DEFAULT_API_VERSION, + name: "foo", + type: "hadolint", + allowPublish: false, + build: { dependencies: [] }, + outputs: {}, + path, + serviceConfigs: [], + taskConfigs: [], + testConfigs: [{ name: "foo", dependencies: [], spec: {}, timeout: 10 }], + spec: { dockerfilePath: "warn.Dockerfile" }, + }, + } + + const graph = await garden.getConfigGraph(garden.log) + const module = await graph.getModule("foo") + + const testTask = new TestTask({ + garden, + module, + log: garden.log, + graph, + testConfig: module.testConfigs[0], + force: true, + forceBuild: false, + version: module.version, + }) + + const key = testTask.getKey() + const { [key]: result } = await garden.processTasks([testTask]) + + expect(result).to.exist + expect(result!.error).to.exist + }) + + it("should set success=true with a linting warning if testFailureThreshold=error", async () => { + const garden = await Garden.factory(tmpPath, { + plugins: [], + config: projectConfigFoo, + }) + + garden["moduleConfigs"] = { + foo: { + apiVersion: DEFAULT_API_VERSION, + name: "foo", + type: "hadolint", + allowPublish: false, + build: { dependencies: [] }, + outputs: {}, + path, + serviceConfigs: [], + taskConfigs: [], + testConfigs: [{ name: "foo", dependencies: [], spec: {}, timeout: 10 }], + spec: { dockerfilePath: "warn.Dockerfile" }, + }, + } + + const graph = await garden.getConfigGraph(garden.log) + const module = await graph.getModule("foo") + + const testTask = new TestTask({ + garden, + module, + log: garden.log, + graph, + testConfig: module.testConfigs[0], + force: true, + forceBuild: false, + version: module.version, + }) + + const key = testTask.getKey() + const { [key]: result } = await garden.processTasks([testTask]) + + expect(result).to.exist + expect(result!.error).to.not.exist + }) + + it("should set success=true with warnings and errors if testFailureThreshold=none", async () => { + const garden = await Garden.factory(tmpPath, { + plugins: [], + config: { + ...projectConfigFoo, + providers: [{ name: "hadolint", testFailureThreshold: "none" }], + }, + }) + + garden["moduleConfigs"] = { + foo: { + apiVersion: DEFAULT_API_VERSION, + name: "foo", + type: "hadolint", + allowPublish: false, + build: { dependencies: [] }, + outputs: {}, + path, + serviceConfigs: [], + taskConfigs: [], + testConfigs: [{ name: "foo", dependencies: [], spec: {}, timeout: 10 }], + spec: { dockerfilePath: "errAndWarn.Dockerfile" }, + }, + } + + const graph = await garden.getConfigGraph(garden.log) + const module = await graph.getModule("foo") + + const testTask = new TestTask({ + garden, + module, + log: garden.log, + graph, + testConfig: module.testConfigs[0], + force: true, + forceBuild: false, + version: module.version, + }) + + const key = testTask.getKey() + const { [key]: result } = await garden.processTasks([testTask]) + + expect(result).to.exist + expect(result!.error).to.not.exist + }) + }) +})