Linting docker containers

Linting docker containers

The problem

When developing in a dockerized environment (for example with docker compose) you often find yourself in the following situtation:

  1. Several containers are running at once.
  2. Each container has it's own linter (eslint, tslint, pylint, …) with it's own settings.
  3. However..you git commit changes from your host/dev machine which doesn't have any of the linters installed.

In this article I'll show a quick solution so you can run every linter in the right container and always keep your code clean and pretty.

We'll do that with two methods:

  1. 🐶Husky +🚫💩 Lint Staged (the "do-it-yourself" method)
  2. 🥊Lefthook (the "all-in-one" method) (my favorite ⭐)

The final project can be found here: docker-linters-example

Setup

To demonstrate the problem I've created two sample containers:

  1. container1 (containers/container1 ) - NodeJS project using ESlint linter.
  2. container2 (containers/container2) - Python project with Pylint linter.

The docker-compose.yml file looks something like this:

version: "3.4"

services:
  container1:
    image: container1
    container_name: container1    
    build: containers/container1
    volumes:
      - ./containers/container1/src:/app/src
  container2:
    image: container2
    container_name: container2
    build: containers/container2
    volumes:
      - ./containers/container2/src:/app/src

The general solution

In order to run each linter in it's container will do the following:

  1. Install a git hooks framework on the host (your dev machine) so it will install a "pre-commit" hook that runs whenever git commit is called.
  2. Configure this hook to check if the staged files match any of the containers source files (for example container/container/src/**/*.js for all the javascript files in container1 )
  3. Run the linter command with docker run.
  4. Additionally: mount the source directories as volumes to automatically fix files (for example with eslint --fix ).

For example for "container 1" (NodeJS) the command will be:

docker run --rm -v container1/src:/app/src container1 sh -c "yarn lint --fix <GIT_STAGED_FILES>"

And for container 2 (Python) it will be:

docker run --rm -v container2/src:/app/src container2 sh -c "pylint <GIT_STAGED_FILES>"

Husky + Lint Staged ("do-it-yourself" method)

erin-minuskin-uZFwrgLPNOs-unsplash.jpg

We'll Start by installing the required libraries:

npm install --no-save husky lint-staged

This will install two libraries:

  1. Husky - Will add a pre-commit hook to our .git/hooks folder.
  2. Lint staged - will run a linter according to matched patterns on the staged files.

(Why --no-save? Because we want the git hooks on the host and not include it as part of the dev/production code)

Then we'll create a .huskyrc configuration file:

{
  "hooks": {
    "pre-commit": "lint-staged -r -p false"
   }
}

This will run "lint-staged" whenever we git commit files with the following options:

  1. -r - pass the file as relative to the root of the project instead of absolute ones. This means that instead of /home/user/docker-linters-example/containers/container1/src/file1.js the file path passed to the command will be: containers/containers1/src/file1.js (you'll see why soon).
  2. -p false - run commands sequentially (optional: to prevent some issues)

Now for the second part: matching files to linters. For that we'll create a .lintstagedrc.js configuration file:

const path = require("path");

module.exports = {
  "containers/container1/src/**/*.js": (absolutePaths) => {
    const cwd = process.cwd();
    const relativePaths = absolutePaths
      .map((file) =>
        path.relative(cwd, file).replace("containers/container1/", "")
      )
      .join(" ");
    return `docker run --rm -v ${cwd}/containers/container1/src:/app/src container1 sh -c \"yarn lint  --fix ${relativePaths}\"`;
  },
  "containers/container2/src/**/*.py": (absolutePaths) => {
    const cwd = process.cwd();
    const relativePaths = absolutePaths
      .map((file) =>
        path.relative(cwd, file).replace("containers/container2/", "")
      )
      .join(" ");
    return `docker run --rm -v ${cwd}/containers/container2/src:/app/src container2 sh -c \"pylint ${relativePaths}\"`;
  },
};

This configuration will match the staged files by using patterns to find to which container they belong. For pattern match will do the following (for example lines 4–11 for container1 files) :

  1. Tranform the path of every file to match the path of the container, for example containers/container1/src/file1.js will become src/file1.js. that is because container1's working directory is already in containers/container1 so we need to trim every path.
  2. Execute a docker run command with all the files in it, for example:
docker run  - rm -v ${cwd}/containers/container1/src:/app/src container1 sh -c \"yarn lint - fix ${relativePaths}\"

Let's break the parts:

  1. --rm - terminate the container after running.
  2. -v - mount the source volumes from host to container to reflect changes.
  3. sh -c yarn lint --fix ${relativePaths} (the command) run the linter (in this case: ESlint)

So for container1 the final command will look like:

docker run --rm -v /home/user/docker-linters-example/containers/container1/src:/app/src container 1 sh -c "yarn lint --fix src/file1.js src/file2.js

Voila, we now get our files linted and fixed in every commit:

husky > pre-commit (node v12.18.1)
[ 'containers/container2/src/file1.py' ]
✔ Preparing...
⚠ Running tasks...
  ❯ Running tasks for containers/container1/src/**/*.js
    ✖ docker run --rm -v /home/user/docker-linters-example/containers/container1/src:/app/src container1 sh -c "yarn lint  --fix src/file1.js src/file2.js" [FAILED]
  ❯ Running tasks for containers/container2/src/**/*.py
    ✖ docker run --rm -v /home/user/docker-linters-example/containers/container2/src:/app/src container2 sh -c "pylint src/file1.py" [FAILED]
↓ Skipped because of errors from tasks. [SKIPPED]
✔ Reverting to original state because of errors...
✔ Cleaning up... 

✖ docker run --rm -v /home/user/docker-linters-example/containers/container1/src:/app/src container1 sh -c "yarn lint  --fix src/file1.js src/file2.js":
error Command failed with exit code 1.
yarn run v1.22.4
$ eslint --fix src/file1.js src/file2.js

/app/src/file1.js
  1:1  warning  Unexpected console statement            no-console
  3:7  error    'x' is assigned a value but never used  no-unused-vars

/app/src/file2.js
  1:1  warning  Unexpected console statement            no-console
  3:7  error    'x' is assigned a value but never used  no-unused-vars

✖ 4 problems (2 errors, 2 warnings)

info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

✖ docker run --rm -v /home/user/docker-linters-example/containers/container2/src:/app/src container2 sh -c "pylint src/file1.py":
************* Module file1
src/file1.py:1:0: C0114: Missing module docstring (missing-module-docstring)

-----------------------------------
Your code has been rated at 0.00/10

husky > pre-commit hook failed (add --no-verify to bypass)

Lefthook ("all-in-one" method) 🥊

hermes-rivera-_rNVw54xZZg-unsplash.jpg

This method is my favorite because Lefthook includes the features of both Husky and Lint Staged. In other words it has both a git hook manager and can track the staged files from git which saves a lot of time and configuration.

We'll start again by installing the required libraries:

npm install --no-save @arkweid/lefthook

Then we'll configure lefthook by adding a lefthook.yml file:

pre-commit:
    parallel: true
    commands:
        lint_container1:
            root: "containers/container1/"
            glob: "*.js"
            run: docker run --rm -v ${PWD}/src:/app/src container1 sh -c "yarn lint --fix {staged_files}" && git add {staged_files}
        lint_container2:
            root: "containers/container2/"
            glob: "*.py"
            run: docker run --rm -v ${PWD}/src:/app/src container2 sh -c "pylint {staged_files}" && git add {staged_files}

This does the same as the Husky+Lint Staged method above:

  1. Matches each container files to their correct linter configuration.
  2. Automatically transforms the paths from the host ones to the container ones (by using the root property)
  3. runs docker run with the correct linter.

Result:

Lefthook v0.7.2
RUNNING HOOKS GROUP: pre-commit

  EXECUTE > lint_container2
************* Module file1
src/file1.py:1:0: C0114: Missing module docstring (missing-module-docstring)

-----------------------------------
Your code has been rated at 0.00/10


  EXECUTE > lint_container1
yarn run v1.22.4
$ eslint --fix ./src/file1.js ./src/file2.js

/app/src/file1.js
  1:1  warning  Unexpected console statement            no-console
  3:7  error    'x' is assigned a value but never used  no-unused-vars

/app/src/file2.js
  1:1  warning  Unexpected console statement            no-console
  3:7  error    'x' is assigned a value but never used  no-unused-vars

✖ 4 problems (2 errors, 2 warnings)

error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

SUMMARY: (done in 3.31 seconds)
🥊  lint_container2
🥊  lint_container1