Linting docker containers
Tags: docker, git, productivity, tutorial
Published: 2020-07-19 20:27:09


- [The problem](#the-problem)
- [Setup](#setup)
- [The general solution](#the-general-solution)
- [Husky + Lint Staged ("do-it-yourself" method)](#husky--lint-staged-do-it-yourself-method)
- [Lefthook ("all-in-one" method) 🥊](#lefthook-all-in-one-method)

# 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](https://github.com/typicode/husky) +🚫💩 [Lint Staged](https://github.com/okonet/lint-staged) (the "do-it-yourself" method)
2. 🥊[Lefthook](https://github.com/Arkweid/lefthook) (the "all-in-one" method) (my favorite ⭐)

The final project can be found here: [docker-linters-example](https://github.com/nitzano/docker-linters-example)

# Setup

To demonstrate the problem I've created two sample containers:
1. **container1** (containers/container1 ) - NodeJS project using [ESlint](https://github.com/eslint/eslint) linter.
2. **container2** (containers/container2) - Python project with [Pylint](https://github.com/PyCQA/pylint) linter.

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

```yml
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:

```bash
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:

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


# Husky + Lint Staged ("do-it-yourself" method)
![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/eq65274bj78u3r2luqlx.jpg)

We'll Start by installing the required libraries:

```bash
npm install --no-save husky lint-staged
```

This will install two libraries:

1. [Husky](https://github.com/typicode/husky) - Will add a pre-commit hook to our .git/hooks folder.
2. [Lint staged](https://github.com/okonet/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:

```json
{
  "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:

```javascript
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:

```bash
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:
```bash
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:

```bash
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) 🥊

![Lefthook](https://dev-to-uploads.s3.amazonaws.com/i/suttw56czlxzq556b3j2.jpg)

This method is my favorite because [Lefthook](https://github.com/Arkweid/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:

```bash
npm install --no-save @arkweid/lefthook
```

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

```yml
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:

```bash
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
```