├── .fmf └── version ├── .github └── workflows │ └── pypi-publish.yml ├── .gitignore ├── .packit.yaml ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── DCO ├── Dockerfile.tests ├── LICENSE ├── MANIFEST.in ├── Makefile ├── Makefile.docs ├── README.md ├── bash-complete └── colin ├── colin.spec ├── colin ├── __init__.py ├── checks │ ├── .fmf │ │ └── version │ ├── __init__.py │ ├── best_practices.fmf │ ├── best_practices.py │ ├── deprecated_labels.fmf │ ├── deprecated_labels.py │ ├── dockerfile.fmf │ ├── dockerfile.py │ ├── dynamic.fmf │ ├── dynamic.py │ ├── labels.fmf │ └── labels.py ├── cli │ ├── __init__.py │ ├── colin.py │ └── default_group.py ├── core │ ├── __init__.py │ ├── check_runner.py │ ├── checks │ │ ├── __init__.py │ │ ├── abstract_check.py │ │ ├── check_utils.py │ │ ├── cmd.py │ │ ├── dockerfile.py │ │ ├── envs.py │ │ ├── filesystem.py │ │ ├── fmf_check.py │ │ ├── images.py │ │ └── labels.py │ ├── colin.py │ ├── constant.py │ ├── exceptions.py │ ├── fmf_extension.py │ ├── loader.py │ ├── result.py │ ├── ruleset │ │ ├── __init__.py │ │ ├── loader.py │ │ └── ruleset.py │ └── target.py ├── utils │ ├── __init__.py │ ├── caching_iterable.py │ ├── cmd_tools.py │ └── cont.py └── version.py ├── docs ├── asciinema.cast ├── conf.py ├── example.gif ├── index.rst ├── list_of_checks.rst └── python_api.rst ├── files ├── packit-testing-farm-prepare.yaml └── tasks │ ├── generic-dnf-requirements.yaml │ ├── python-compile-deps.yaml │ └── rpm-test-deps.yaml ├── plans ├── README.md ├── full.fmf ├── linters.fmf ├── rulesets.fmf └── smoke.fmf ├── rulesets ├── default.json └── fedora.json ├── setup.py └── tests ├── __init__.py ├── conftest.py ├── data ├── Dockerfile ├── Dockerfile-bash ├── Dockerfile-ls ├── Dockerfile-parent ├── a_check │ ├── __init__.py │ └── another_checks.py ├── files │ └── usage ├── lol-ruleset.json └── lol-ruleset.yaml ├── integration ├── __init__.py ├── test_cont.py ├── test_dockerfile.py ├── test_dynamic_checks.py ├── test_fs_checks.py ├── test_labels.py ├── test_ruleset_file.py └── test_targets.py └── unit ├── __init__.py ├── test_cli.py ├── test_image_name.py ├── test_loader.py ├── test_ruleset.py └── test_utils.py /.fmf/version: -------------------------------------------------------------------------------- 1 | 1 2 | -------------------------------------------------------------------------------- /.github/workflows/pypi-publish.yml: -------------------------------------------------------------------------------- 1 | # Upload a Python package when a release is created 2 | # https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows 3 | 4 | name: Publish Python 🐍 distributions 📦 to PyPI 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | build-n-publish: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | id-token: write # for trusted publishing 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: actions/setup-python@v4 19 | 20 | - name: Build a source tarball and a binary wheel 21 | # https://pypa-build.readthedocs.io 22 | run: | 23 | python -m pip install build 24 | python -m build 25 | 26 | - name: Publish 📦 to PyPI 27 | # https://github.com/pypa/gh-action-pypi-publish 28 | uses: pypa/gh-action-pypi-publish@release/v1 29 | with: 30 | verbose: true 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | .pytest_cache/ 3 | noarch/ 4 | *.tar.gz 5 | .coverage 6 | -------------------------------------------------------------------------------- /.packit.yaml: -------------------------------------------------------------------------------- 1 | specfile_path: colin.spec 2 | files_to_sync: 3 | - colin.spec 4 | - .packit.yaml 5 | upstream_package_name: colin 6 | downstream_package_name: colin 7 | jobs: 8 | - job: sync_from_downstream 9 | trigger: commit 10 | - job: propose_downstream 11 | trigger: release 12 | copy_upstream_release_description: true 13 | metadata: 14 | dist-git-branch: fedora-all 15 | - job: copr_build 16 | trigger: pull_request 17 | metadata: 18 | targets: 19 | - fedora-all 20 | - job: tests 21 | trigger: pull_request 22 | metadata: 23 | targets: 24 | - fedora-all 25 | # downstream Koji build 26 | - job: koji_build 27 | trigger: commit 28 | metadata: 29 | dist_git_branches: 30 | - fedora-all 31 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # HOWTO: https://pre-commit.com/#usage 2 | # pip3 install pre-commit 3 | # pre-commit install -t pre-commit -t pre-push 4 | 5 | repos: 6 | - repo: https://github.com/asottile/pyupgrade 7 | rev: v3.10.1 8 | hooks: 9 | - id: pyupgrade 10 | - repo: https://github.com/psf/black 11 | rev: 23.7.0 12 | hooks: 13 | - id: black 14 | - repo: https://github.com/pre-commit/mirrors-prettier 15 | rev: v3.0.3 16 | hooks: 17 | - id: prettier 18 | - repo: https://github.com/pre-commit/pre-commit-hooks 19 | rev: v4.4.0 20 | hooks: 21 | - id: check-added-large-files 22 | - id: check-ast 23 | - id: check-merge-conflict 24 | - id: check-yaml 25 | - id: detect-private-key 26 | - id: end-of-file-fixer 27 | - id: trailing-whitespace 28 | - repo: https://github.com/PyCQA/flake8 29 | rev: 6.1.0 30 | hooks: 31 | - id: flake8 32 | args: [--max-line-length=100] 33 | - repo: https://github.com/pre-commit/mirrors-mypy 34 | rev: v1.5.1 35 | hooks: 36 | - id: mypy 37 | args: [--no-strict-optional, --ignore-missing-imports] 38 | additional_dependencies: [types-six, types-PyYAML] 39 | - repo: https://github.com/packit/pre-commit-hooks 40 | rev: v1.2.0 41 | hooks: 42 | - id: check-rebase 43 | args: 44 | - git://github.com/user-cont/colin.git 45 | stages: [manual, push] 46 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.5.3 2 | 3 | ## Fixes 4 | 5 | - Add additional global location of rulesets for default installation from PyPI 6 | 7 | ## Minor 8 | 9 | - Run tests in upstream on the Testing Farm via Packit 10 | - Few code-style changes suggested by Sourcery 11 | 12 | # 0.5.2 13 | 14 | ## Fixes 15 | 16 | - The global location of rulesests is now correctly found. 17 | 18 | # 0.5.1 19 | 20 | - Badly done release (version not updated everywhere). 21 | - Replaced by `0.5.2`. 22 | 23 | # 0.5.0 24 | 25 | ## Features 26 | 27 | - Result can be converted to xunit xml file. (You can use `--xunit` CLI option to set the file we save the xunit output to.) 28 | - Support for scanning images in OCI format. 29 | 30 | ## Breaking changes 31 | 32 | - Minimal supported version of Python has been raised to 3.6. 33 | - Support for images in ostree format has been removed because Fedora 30 was the latest one 34 | which had support for ostree in Skopeo. (Replaced by newly added support for OCI format.) 35 | 36 | ## Minor 37 | 38 | - The timeout overwriting now works as expected. 39 | 40 | # 0.4.0 41 | 42 | ## Features 43 | 44 | - Create a new check which makes sure that certain labels are overridden in 45 | layered images. 46 | - Certain remote API calls are now being retried - this should help in environments where network is unreliable. 47 | 48 | ## Breaking changes 49 | 50 | - Python 2 is now completely unsupported. 51 | 52 | ## Minor 53 | 54 | - A bunch of usability issues: when things go wrong, colin should not 55 | produce more helpful error messages and logs. 56 | 57 | # 0.3.1 58 | 59 | ## Fixes 60 | 61 | - Fix metadata checks (ENV, USER) for podman images. 62 | - Fix Fedora packaging. (Conu was temporarily removed from requirements.) 63 | - Documentation updated. 64 | 65 | ## Breaking changes 66 | 67 | - Remove support for Python 2. 68 | 69 | # 0.3.0 70 | 71 | ## New Features 72 | 73 | - You can configure timeout for checks now: 74 | - This can be done via CLI or add `timeout: ` to a check in a ruleset. 75 | - Default timeout is set to 10 minutes. 76 | - Checks can be skipped via CLI option `--skip`. 77 | 78 | ## Breaking changes 79 | 80 | - Colin searches a value in label now instead of matching it using a regex. 81 | 82 | ## Fixes 83 | 84 | - Output a sensible error message when the check code cannot be found. 85 | - Handle the situation when the instruction FROM is missing in testing image tag. 86 | 87 | # 0.2.1 88 | 89 | ## New Features 90 | 91 | - Allow setting CLI options via environment variables 92 | - Allow loading rulesets from virtualenv 93 | - Add info subcommand 94 | 95 | # 0.2.0 96 | 97 | ## Breaking changes 98 | 99 | - switch from docker to podman, thanks to @lachmanfrantisek 100 | - remove `container` target type 101 | - new cli arg: target type (defaults to image -- for podman) 102 | 103 | ## New Features 104 | 105 | - add `ostree` target, thanks to @TomasTomecek 106 | - use fmf format in checks, thanks to @jscotka 107 | - allow rulesets in the YAML format, thanks to @SkullTech 108 | 109 | ## Fixes 110 | 111 | - many code style fixes 112 | - use Centos CI, thanks to @jpopelka 113 | - better loading of the ruleset files (subdir -> user -> system), thanks to @SkullTech 114 | - check existence of json output file directory 115 | - simpler loading of checks 116 | - tinker CONTRIBUTING.md 117 | - do not mount whole FS when checking for files 118 | - improve tests quality 119 | 120 | # 0.1.0 121 | 122 | Welcome to the first official release of colin. With `0.0.*` releases we tried to iterate on a minimal viable product and with this `0.1.0` release we believe it's finally here. 123 | 124 | # Features 125 | 126 | - Validate a selected artifact against a ruleset. 127 | - Artifacts can be container images, containers and dockerfiles. 128 | - We provide a default ruleset we believe every container should satisfy. 129 | - There is a ruleset to validate an artifact whether it complies to [Fedora Container Guidelines](https://fedoraproject.org/wiki/Container:Guidelines) 130 | - Colin can list available rulesets and list checks in a ruleset. 131 | - There is a python API available 132 | - Colin can be integrated into your workflow easily - it can provide results in json format. 133 | -------------------------------------------------------------------------------- /DCO: -------------------------------------------------------------------------------- 1 | Developer Certificate of Origin 2 | Version 1.1 3 | 4 | Copyright (C) 2004, 2006 The Linux Foundation and its contributors. 5 | 1 Letterman Drive 6 | Suite D4700 7 | San Francisco, CA, 94129 8 | 9 | Everyone is permitted to copy and distribute verbatim copies of this 10 | license document, but changing it is not allowed. 11 | 12 | 13 | Developer's Certificate of Origin 1.1 14 | 15 | By making a contribution to this project, I certify that: 16 | 17 | (a) The contribution was created in whole or in part by me and I 18 | have the right to submit it under the open source license 19 | indicated in the file; or 20 | 21 | (b) The contribution is based upon previous work that, to the best 22 | of my knowledge, is covered under an appropriate open source 23 | license and I have the right under that license to submit that 24 | work with modifications, whether created in whole or in part 25 | by me, under the same open source license (unless I am 26 | permitted to submit under a different license), as indicated 27 | in the file; or 28 | 29 | (c) The contribution was provided directly to me by some other 30 | person who certified (a), (b) or (c) and I have not modified 31 | it. 32 | 33 | (d) I understand and agree that this project and the contribution 34 | are public and that a record of the contribution (including all 35 | personal information I submit with it, including my sign-off) is 36 | maintained indefinitely and may be redistributed consistent with 37 | this project or the open source license(s) involved. 38 | -------------------------------------------------------------------------------- /Dockerfile.tests: -------------------------------------------------------------------------------- 1 | FROM registry.fedoraproject.org/fedora:32 2 | 3 | ENV PYTHONDONTWRITEBYTECODE=yes-please 4 | 5 | RUN dnf install -y --setopt=install_weak_deps=False --disablerepo=fedora-cisco-openh264 \ 6 | make python3-pytest python3-pyxattr python3-pytest-cov \ 7 | python3-pip \ 8 | skopeo podman buildah runc \ 9 | python3-ipdb \ 10 | && dnf clean all \ 11 | && curl -L -o /usr/local/bin/umoci https://github.com/opencontainers/umoci/releases/download/v0.4.6/umoci.amd64 \ 12 | && chmod a+x /usr/local/bin/umoci 13 | 14 | RUN cp /usr/share/containers/containers.conf /etc/containers/containers.conf \ 15 | # remove unnecessary warning due to missing unix socket to journald 16 | # ERRO[0001] unable to write build event: "write unixgram @00656->/run/systemd/journal/socket: sendmsg: no such file or directory" 17 | && sed -e '/events_logger =/s/^.*$/events_logger = "file"/' -i /etc/containers/containers.conf \ 18 | # Error: 'overlay' is not supported over overlayfs, a mount_program is required: backing file system is unsupported for this graph driver 19 | && sed '/^graphroot/s/.*/graphroot="\/var\/tmp\/containers"/' -i /etc/containers/storage.conf \ 20 | # Failure on CentOS 7 21 | # Error: failed to mount overlay for metacopy check with "nodev,metacopy=on" options: invalid argument 22 | && sed -e '/mountopt/s/,\?metacopy=on,\?//' -i /etc/containers/storage.conf 23 | 24 | # # podman 25 | # RUN useradd podm 26 | # RUN echo "podm:231072:65536" > /etc/subuid 27 | # RUN echo "podm:231072:65536" > /etc/subgid 28 | # USER podm 29 | 30 | WORKDIR /src 31 | 32 | COPY . /src 33 | 34 | RUN pip3 install --user . 35 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include MANIFEST.in 2 | include README.md 3 | include LICENSE 4 | include bash-complete/colin 5 | recursive-include rulesets * 6 | recursive-include colin * 7 | recursive-include docs * 8 | recursive-include examples * 9 | recursive-include tests * 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: check build-test-image test-in-container exec-test check-local check-code-style check-pylint check-bandit setup-ci 2 | 3 | TEST_IMAGE_NAME := colin-test 4 | TEST_TARGET = ./tests 5 | 6 | check: build-test-image test-in-container 7 | 8 | build-test-image: 9 | docker build --network host --tag=$(TEST_IMAGE_NAME) -f ./Dockerfile.tests . 10 | 11 | test-in-container: build-test-image test-in-container-now 12 | 13 | test-in-container-now: 14 | @# use it like this: `make test-in-container TEST_TARGET=./tests/integration/test_utils.py` 15 | docker run --rm --privileged --security-opt label=disable \ 16 | --name=colin \ 17 | --cap-add SYS_ADMIN -ti \ 18 | -v /var/tmp/ \ 19 | -v $(CURDIR):/src \ 20 | $(TEST_IMAGE_NAME) \ 21 | make exec-test TEST_TARGET="$(TEST_TARGET)" 22 | 23 | test-in-ci: test-in-container 24 | 25 | exec-test: 26 | PYTHONPATH=$(CURDIR) py.test-3 --cov=colin $(TEST_TARGET) 27 | 28 | check-code-style: check-pylint check-bandit 29 | 30 | check-pylint: 31 | pylint colin || true 32 | 33 | clean: 34 | python3 setup.py clean 35 | rm -rf build/* dist/* 36 | git clean -fx 37 | 38 | html: 39 | make -f Makefile.docs html 40 | 41 | sdist: 42 | ./setup.py sdist -d . 43 | 44 | rpm: sdist 45 | rpmbuild ./*.spec -bb --define "_sourcedir $(CURDIR)" --define "_specdir $(CURDIR)" --define "_buildir $(CURDIR)" --define "_srcrpmdir $(CURDIR)" --define "_rpmdir $(CURDIR)" 46 | 47 | srpm: sdist 48 | rpmbuild ./*.spec -bs --define "_sourcedir $(CURDIR)" --define "_specdir $(CURDIR)" --define "_buildir $(CURDIR)" --define "_srcrpmdir $(CURDIR)" --define "_rpmdir $(CURDIR)" 49 | 50 | rpm-in-mock-f27: srpm 51 | mock --rebuild -r fedora-27-x86_64 ./*.src.rpm 52 | 53 | rpm-in-mock-el7: srpm 54 | mock --rebuild -r epel-7-x86_64 ./*.src.rpm 55 | 56 | install: uninstall clean 57 | pip3 install --user . 58 | 59 | uninstall: 60 | pip3 show colin && pip3 uninstall -y colin || true 61 | rm /usr/lib/python*/site-packages/colin\* -rf 62 | -------------------------------------------------------------------------------- /Makefile.docs: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build-3 7 | PAPER = 8 | BUILDDIR = build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) docs 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | 28 | clean: 29 | rm -rf $(BUILDDIR)/* 30 | 31 | html: 32 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 33 | @echo 34 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Colin 2 | 3 | ![PyPI](https://img.shields.io/pypi/v/colin.svg) 4 | ![PyPI - License](https://img.shields.io/pypi/l/colin.svg) 5 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/colin.svg) 6 | ![PyPI - Status](https://img.shields.io/pypi/status/colin.svg) 7 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/427eb0c5dfc040cea798b23575dba025)](https://www.codacy.com/app/user-cont/colin?utm_source=github.com&utm_medium=referral&utm_content=user-cont/colin&utm_campaign=Badge_Grade) 8 | [![Build Status](https://ci.centos.org/job/user-cont-colin-master/badge/icon)](https://ci.centos.org/job/user-cont-colin-master/) 9 | 10 | Tool to check generic rules and best-practices for container images and dockerfiles. 11 | 12 | For more information, please check our [documentation on colin.readthedocs.io](https://colin.readthedocs.io/en/latest/). 13 | 14 | ![example](./docs/example.gif) 15 | 16 | # Features 17 | 18 | - Validate a selected artifact against a ruleset. 19 | - Artifacts can be container images and dockerfiles. 20 | - We provide a default ruleset we believe every container image should satisfy. 21 | - There is a ruleset to validate an artifact whether it complies to [Fedora Container Guidelines](https://fedoraproject.org/wiki/Container:Guidelines) 22 | - Colin can list available rulesets and list checks in a ruleset. 23 | - There is a python API available 24 | - Colin can be integrated into your workflow easily - it can provide results in json format. 25 | 26 | ## Installation 27 | 28 | ### Via `pip` 29 | 30 | If you are on Fedora distribution, please install python3-pyxattr so you don't 31 | have to compile it yourself when getting it from PyPI. 32 | 33 | ```bash 34 | $ pip3 install --user colin 35 | ``` 36 | 37 | `colin` is supported on python 3.6+ only. 38 | 39 | ### On Fedora distribution 40 | 41 | colin is packaged in official Fedora repositories: 42 | 43 | ``` 44 | $ dnf install -y colin 45 | ``` 46 | 47 | ### Requirements 48 | 49 | - For checking `image` target-type, you have to install [podman](https://github.com/containers/libpod/blob/master/docs/tutorials/podman_tutorial.md). If you need to check local docker images, you need to prefix your images with `docker-daemon` (e.g. `colin check docker-daemon:docker.io/openshift/origin-web-console:v3.11`). 50 | 51 | - If you want to use `oci` target, you need to install following tools: 52 | - [umoci](https://github.com/opencontainers/umoci#install) 53 | - [skopeo](https://github.com/containers/skopeo#skopeo-) 54 | 55 | ## Usage 56 | 57 | ``` 58 | $ colin --help 59 | Usage: colin [OPTIONS] COMMAND [ARGS]... 60 | 61 | COLIN -- Container Linter 62 | 63 | Options: 64 | -V, --version Show the version and exit. 65 | -h, --help Show this message and exit. 66 | 67 | Commands: 68 | check Check the image/dockerfile (default). 69 | info Show info about colin and its dependencies. 70 | list-checks Print the checks. 71 | list-rulesets List available rulesets. 72 | ``` 73 | 74 | ``` 75 | $ colin check --help 76 | Usage: colin check [OPTIONS] TARGET 77 | 78 | Check the image/dockerfile (default). 79 | 80 | Options: 81 | -r, --ruleset TEXT Select a predefined ruleset (e.g. fedora). 82 | -f, --ruleset-file FILENAME Path to a file to use for validation (by 83 | default they are placed in 84 | /usr/share/colin/rulesets). 85 | --debug Enable debugging mode (debugging logs, full 86 | tracebacks). 87 | --json FILENAME File to save the output as json to. 88 | --stat Print statistics instead of full results. 89 | -s, --skip TEXT Name of the check to skip. (this option is 90 | repeatable) 91 | -t, --tag TEXT Filter checks with the tag. 92 | -v, --verbose Verbose mode. 93 | --checks-path DIRECTORY Path to directory containing checks (default 94 | ['/home/flachman/.local/lib/python3.7/site- 95 | packages/colin/checks']). 96 | --pull Pull the image from registry. 97 | --target-type TEXT Type of selected target (one of image, 98 | dockerfile, oci). For oci, please specify 99 | image name and path like this: oci:path:image 100 | --timeout INTEGER Timeout for each check in seconds. 101 | (default=600) 102 | --insecure Pull from an insecure registry (HTTP or invalid 103 | TLS). 104 | -h, --help Show this message and exit. 105 | ``` 106 | 107 | Let's give it a shot: 108 | 109 | ``` 110 | $ colin -f ./rulesets/fedora.json registry.fedoraproject.org/f29/cockpit 111 | PASS:Label 'architecture' has to be specified. 112 | PASS:Label 'build-date' has to be specified. 113 | FAIL:Label 'description' has to be specified. 114 | PASS:Label 'distribution-scope' has to be specified. 115 | : 116 | : 117 | PASS:10 FAIL:8 118 | ``` 119 | 120 | ### Directly from git 121 | 122 | It's possible to use colin directly from git: 123 | 124 | ``` 125 | $ git clone https://github.com/user-cont/colin.git 126 | $ cd colin 127 | ``` 128 | 129 | We can now run the analysis: 130 | 131 | ``` 132 | $ python3 -m colin.cli.colin -f ./rulesets/fedora.json registry.fedoraproject.org/f29/cockpit 133 | PASS:Label 'architecture' has to be specified. 134 | PASS:Label 'build-date' has to be specified. 135 | FAIL:Label 'description' has to be specified. 136 | PASS:Label 'distribution-scope' has to be specified. 137 | : 138 | : 139 | PASS:10 FAIL:8 140 | ``` 141 | 142 | ### Exit codes 143 | 144 | Colin can exit with several codes: 145 | 146 | - `0` --> OK 147 | - `1` --> error in the execution 148 | - `2` --> CLI error, wrong parameters 149 | - `3` --> at least one check failed 150 | -------------------------------------------------------------------------------- /bash-complete/colin: -------------------------------------------------------------------------------- 1 | _colin_completion() { 2 | COMPREPLY=( $( env COMP_WORDS="${COMP_WORDS[*]}" \ 3 | COMP_CWORD=$COMP_CWORD \ 4 | _COLIN_COMPLETE=complete $1 ) ) 5 | return 0 6 | } 7 | 8 | complete -F _colin_completion -o default colin; 9 | -------------------------------------------------------------------------------- /colin.spec: -------------------------------------------------------------------------------- 1 | %global pypi_name colin 2 | 3 | %{?python_enable_dependency_generator} 4 | 5 | %if 0%{?rhel} && 0%{?rhel} <= 7 6 | %bcond_with python3 7 | %else 8 | %bcond_without python3 9 | %endif 10 | 11 | Name: %{pypi_name} 12 | Version: 0.5.3 13 | Release: 1%{?dist} 14 | Summary: Tool to check generic rules/best-practices for containers/images/dockerfiles. 15 | 16 | License: GPLv3+ 17 | URL: https://github.com/user-cont/colin 18 | Source0: https://files.pythonhosted.org/packages/source/c/%{pypi_name}/%{pypi_name}-%{version}.tar.gz 19 | 20 | BuildArch: noarch 21 | Requires: python3-%{pypi_name} 22 | 23 | %description 24 | `colin` is a tool to check generic rules/best-practices 25 | for containers/images/dockerfiles 26 | 27 | %package -n python3-%{pypi_name} 28 | Summary: %{summary} 29 | %{?python_provide:%python_provide python3-%{pypi_name}} 30 | BuildRequires: python3-devel 31 | BuildRequires: python3-setuptools 32 | Recommends: moby-engine 33 | 34 | %description -n python3-%{pypi_name} 35 | `colin` as a tool to check generic rules/best-practices 36 | for containers/images/dockerfiles 37 | 38 | %package doc 39 | BuildRequires: python3-sphinx 40 | BuildRequires: python3-sphinx_rtd_theme 41 | Summary: colin documentation 42 | 43 | %description doc 44 | Documentation for colin 45 | 46 | %prep 47 | %autosetup -n %{pypi_name}-%{version} 48 | # Remove bundled egg-info 49 | rm -rf %{pypi_name}.egg-info 50 | 51 | %build 52 | %py3_build 53 | 54 | # generate html docs 55 | PYTHONPATH="${PWD}:${PWD}/docs/" sphinx-build docs html 56 | # remove the sphinx-build leftovers 57 | rm -rf html/.{doctrees,buildinfo} 58 | 59 | %install 60 | %py3_install 61 | 62 | %files 63 | %license LICENSE 64 | %{_bindir}/%{pypi_name} 65 | %{_datadir}/bash-completion/completions/%{pypi_name} 66 | 67 | %files -n python3-%{pypi_name} 68 | %license LICENSE 69 | %doc README.md 70 | %{python3_sitelib}/%{pypi_name}/ 71 | %{python3_sitelib}/%{pypi_name}-*.egg-info/ 72 | %{_datadir}/%{pypi_name}/ 73 | %exclude %{python3_sitelib}/tests 74 | 75 | %files doc 76 | %license LICENSE 77 | %doc html 78 | 79 | %changelog 80 | * Mon Mar 14 2022 Lukas Slebodnik - 0.5.3-1 81 | - New upstream release 0.5.3 82 | 83 | * Wed Jan 12 09:32:57 CET 2022 Frantisek Lachman - 0.5.2-1 84 | - New upstream release 0.5.2 85 | 86 | * Mon Jan 10 09:56:32 CET 2022 Frantisek Lachman - 0.5.1-1 87 | - New upstream release 0.5.1 88 | 89 | * Thu Mar 11 13:31:23 CET 2021 Frantisek Lachman - 0.5.0-1 90 | - new upstream release 0.5.0 91 | 92 | * Thu May 23 2019 Tomas Tomecek - 0.4.0-1 93 | - new upstream release: 0.4.0 94 | 95 | * Wed May 01 2019 Lukas Slebodnik 0.3.1-4 96 | - Change weak dependency in rawhide (docker -> moby-engine) 97 | 98 | * Wed May 01 2019 Lukas Slebodnik 0.3.1-3 99 | - rhbz#1684558 - Remove hard dependency on docker 100 | 101 | * Thu Jan 31 2019 Fedora Release Engineering - 0.3.1-2 102 | - Rebuilt for https://fedoraproject.org/wiki/Fedora_30_Mass_Rebuild 103 | 104 | * Mon Jan 21 2019 Tomas Tomecek 0.3.1-1 105 | - 0.3.1 release 106 | 107 | * Wed Nov 14 2018 Frantisek Lachman - 0.3.0-1 108 | - 0.3.0 release 109 | 110 | * Mon Oct 22 2018 lachmanfrantisek 0.2.1-1 111 | - 0.2.1 release 112 | 113 | * Wed Sep 19 2018 Jiri Popelka 0.2.0-1 114 | - 0.2.0 release 115 | 116 | * Thu Jul 12 2018 Fedora Release Engineering - 0.1.0-3 117 | - Rebuilt for https://fedoraproject.org/wiki/Fedora_29_Mass_Rebuild 118 | 119 | * Tue Jun 19 2018 Miro Hrončok - 0.1.0-2 120 | - Rebuilt for Python 3.7 121 | 122 | * Wed May 30 2018 Jiri Popelka 0.1.0-1 123 | - 0.1.0 release 124 | 125 | * Wed May 02 2018 Petr Hracek - 0.0.4-3 126 | - Polishing texts and remove leftovers (#1572084) 127 | 128 | * Wed May 02 2018 Petr Hracek - 0.0.4-2 129 | - Fix issues catched by BZ review process (#1572084) 130 | 131 | * Wed Apr 25 2018 lachmanfrantisek - 0.0.4-1 132 | - bash completion 133 | - better cli 134 | - better ruleset files and loading 135 | - dockerfile support 136 | - python2 compatibility 137 | - better error handling 138 | 139 | * Mon Apr 09 2018 Petr Hracek - 0.0.3-1 140 | - Initial package. 141 | -------------------------------------------------------------------------------- /colin/__init__.py: -------------------------------------------------------------------------------- 1 | from .core.colin import get_checks, run 2 | 3 | __all__ = [run.__name__, get_checks.__name__] 4 | -------------------------------------------------------------------------------- /colin/checks/.fmf/version: -------------------------------------------------------------------------------- 1 | 1 2 | -------------------------------------------------------------------------------- /colin/checks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/user-cont/colin/723bfc3dc906ecfefb38fec2d68d185e60456cc9/colin/checks/__init__.py -------------------------------------------------------------------------------- /colin/checks/best_practices.fmf: -------------------------------------------------------------------------------- 1 | test: "best_practices.py" 2 | 3 | /cmd_or_entrypoint: 4 | class: "CmdOrEntrypointCheck" 5 | message: "Cmd or Entrypoint has to be specified" 6 | description: "An ENTRYPOINT allows you to configure a container that will run as an executable. The main purpose of a CMD is to provide defaults for an executing container." 7 | reference_url: "https://fedoraproject.org/wiki/Container:Guidelines#CMD.2FENTRYPOINT_2" 8 | tags: ["cmd", "entrypoint"] 9 | 10 | /help_file_or_readme: 11 | class: "HelpFileOrReadmeCheck" 12 | message: "The 'helpfile' has to be provided." 13 | description: "Just like traditional packages, containers need some 'man page' information about how they are to be used, configured, and integrated into a larger stack." 14 | reference_url: "https://fedoraproject.org/wiki/Container:Guidelines#Help_File" 15 | files: ['/help.1', '/README.md'] 16 | tags: ['filesystem', 'helpfile', 'man'] 17 | all_must_be_present: False 18 | 19 | /no_root: 20 | class: "NoRootCheck" 21 | message: "Service should not run as root by default." 22 | description: "It can be insecure to run service as root." 23 | reference_url: "?????" 24 | tags: ["root", "user"] 25 | -------------------------------------------------------------------------------- /colin/checks/best_practices.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | 16 | import logging 17 | 18 | from colin.core.checks.filesystem import FileCheck 19 | from colin.core.checks.fmf_check import FMFAbstractCheck 20 | from colin.core.checks.abstract_check import ImageAbstractCheck 21 | from colin.core.result import CheckResult 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | 26 | class CmdOrEntrypointCheck(FMFAbstractCheck, ImageAbstractCheck): 27 | name = "cmd_or_entrypoint" 28 | 29 | def check(self, target): 30 | metadata = target.config_metadata["ContainerConfig"] 31 | cmd_present = "Cmd" in metadata and metadata["Cmd"] 32 | msg_cmd_present = f"Cmd {'' if cmd_present else 'not '}specified." 33 | logger.debug(msg_cmd_present) 34 | 35 | entrypoint_present = "Entrypoint" in metadata and metadata["Entrypoint"] 36 | msg_entrypoint_present = "Entrypoint {}specified.".format( 37 | "" if entrypoint_present else "not " 38 | ) 39 | logger.debug(msg_entrypoint_present) 40 | 41 | passed = cmd_present or entrypoint_present 42 | return CheckResult( 43 | ok=passed, 44 | description=self.description, 45 | message=self.message, 46 | reference_url=self.reference_url, 47 | check_name=self.name, 48 | logs=[msg_cmd_present, msg_entrypoint_present], 49 | ) 50 | 51 | 52 | class HelpFileOrReadmeCheck(FMFAbstractCheck, FileCheck): 53 | name = "help_file_or_readme" 54 | 55 | 56 | class NoRootCheck(FMFAbstractCheck, ImageAbstractCheck): 57 | name = "no_root" 58 | 59 | def check(self, target): 60 | metadata = target.config_metadata 61 | root_present = "User" in metadata and metadata["User"] in ["", "0", "root"] 62 | 63 | return CheckResult( 64 | ok=not root_present, 65 | description=self.description, 66 | message=self.message, 67 | reference_url=self.reference_url, 68 | check_name=self.name, 69 | logs=[], 70 | ) 71 | -------------------------------------------------------------------------------- /colin/checks/deprecated_labels.fmf: -------------------------------------------------------------------------------- 1 | test: "deprecated_labels.py" 2 | reference_url: "?????" 3 | tags: ["label", "deprecated"] 4 | 5 | /architecture_label_capital_deprecated: 6 | class: ArchitectureLabelCapitalDeprecatedCheck 7 | message: "Label 'Architecture' is deprecated." 8 | description: "Replace with 'architecture'." 9 | tags+: ["architecture", "capital"] 10 | old_label: "Architecture" 11 | new_label: "architecture" 12 | 13 | /bzcomponent_deprecated: 14 | class: BZComponentDeprecatedCheck 15 | message: "Label 'BZComponent' is deprecated." 16 | description: "Replace with 'com.redhat.component'." 17 | tags+: ["com.redhat.component", "bzcomponent"] 18 | old_label: "BZComponent" 19 | new_label: "com.redhat.component" 20 | 21 | /install_label_capital_deprecated: 22 | class: InstallLabelCapitalDeprecatedCheck 23 | message: "Label 'INSTALL' is deprecated." 24 | description: "Replace with 'install'." 25 | tags+: ["install", "capital"] 26 | old_label: "INSTALL" 27 | new_label: "install" 28 | 29 | /name_label_capital_deprecated: 30 | class: NameLabelCapitalDeprecatedCheck 31 | message: "Label 'Name' is deprecated." 32 | description: "Replace with 'name'." 33 | tags+: ["name", "capital"] 34 | old_label: "Name" 35 | new_label: "name" 36 | 37 | /release_label_capital_deprecated: 38 | class: ReleaseLabelCapitalDeprecatedCheck 39 | message: "Label 'Release' is deprecated." 40 | description: "Replace with 'release'." 41 | tags+: ["release", "capital"] 42 | old_label: "Release" 43 | new_label: "release" 44 | 45 | /uninstall_label_capital_deprecated: 46 | class: UninstallLabelCapitalDeprecatedCheck 47 | message: "Label 'UNINSTALL' is deprecated." 48 | description: "Replace with 'uninstall'." 49 | tags+: ["uninstall", "capital"] 50 | old_label: "UNINSTALL" 51 | new_label: "uninstall" 52 | 53 | /version_label_capital_deprecated: 54 | class: VersionLabelCapitalDeprecatedCheck 55 | message: "Label 'Version' is deprecated." 56 | description: "Replace with 'version'." 57 | tags+: ["version", "capital"] 58 | old_label: "Version" 59 | new_label: "version" 60 | -------------------------------------------------------------------------------- /colin/checks/deprecated_labels.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | 16 | from colin.core.checks.labels import DeprecatedLabelAbstractCheck 17 | from colin.core.checks.fmf_check import FMFAbstractCheck 18 | 19 | 20 | class ArchitectureLabelCapitalDeprecatedCheck( 21 | FMFAbstractCheck, DeprecatedLabelAbstractCheck 22 | ): 23 | name = "architecture_label_capital_deprecated" 24 | 25 | 26 | class BZComponentDeprecatedCheck(FMFAbstractCheck, DeprecatedLabelAbstractCheck): 27 | name = "bzcomponent_deprecated" 28 | 29 | 30 | class InstallLabelCapitalDeprecatedCheck( 31 | FMFAbstractCheck, DeprecatedLabelAbstractCheck 32 | ): 33 | name = "install_label_capital_deprecated" 34 | 35 | 36 | class NameLabelCapitalDeprecatedCheck(FMFAbstractCheck, DeprecatedLabelAbstractCheck): 37 | name = "name_label_capital_deprecated" 38 | 39 | 40 | class ReleaseLabelCapitalDeprecatedCheck( 41 | FMFAbstractCheck, DeprecatedLabelAbstractCheck 42 | ): 43 | name = "release_label_capital_deprecated" 44 | 45 | 46 | class UninstallLabelCapitalDeprecatedCheck( 47 | FMFAbstractCheck, DeprecatedLabelAbstractCheck 48 | ): 49 | name = "uninstall_label_capital_deprecated" 50 | 51 | 52 | class VersionLabelCapitalDeprecatedCheck( 53 | FMFAbstractCheck, DeprecatedLabelAbstractCheck 54 | ): 55 | name = "version_label_capital_deprecated" 56 | -------------------------------------------------------------------------------- /colin/checks/dockerfile.fmf: -------------------------------------------------------------------------------- 1 | description: "Generic description for dockerfile test if not specific description given" 2 | test: "dockerfile.py" 3 | tags: ["dockerfile"] 4 | 5 | /from_tag_not_latest: 6 | class: "FromTagNotLatestCheck" 7 | message: "In FROM, tag has to be specified and not 'latest'." 8 | description: "Using the 'latest' tag may cause unpredictable builds.It is recommended that a specific tag is used in the FROM." 9 | reference_url: "https://fedoraproject.org/wiki/Container:Guidelines#FROM" 10 | tags+: ["from", "baseimage", "latest"] 11 | 12 | /maintainer_deprecated: 13 | class: "MaintainerDeprecatedCheck" 14 | message: "Dockerfile instruction `MAINTAINER` is deprecated." 15 | description: "Replace with label 'maintainer'." 16 | reference_url: "https://docs.docker.com/engine/reference/builder/#maintainer-deprecated" 17 | tags+: ["maintainer", "deprecated"] 18 | instruction: "MAINTAINER" 19 | max_count: 0 20 | -------------------------------------------------------------------------------- /colin/checks/dockerfile.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | 16 | from colin.core.checks.abstract_check import DockerfileAbstractCheck 17 | from colin.core.checks.dockerfile import InstructionCountAbstractCheck 18 | from colin.core.checks.fmf_check import FMFAbstractCheck 19 | from colin.core.exceptions import ColinException 20 | from colin.core.result import CheckResult 21 | from colin.utils.cont import ImageName 22 | 23 | 24 | class FromTagNotLatestCheck(FMFAbstractCheck, DockerfileAbstractCheck): 25 | name = "from_tag_not_latest" 26 | 27 | def check(self, target): 28 | if not target.instance.parent_images: 29 | raise ColinException("Cannot find FROM instruction.") 30 | 31 | im = ImageName.parse(target.instance.baseimage) 32 | passed = im.tag and im.tag != "latest" 33 | return CheckResult( 34 | ok=passed, 35 | description=self.description, 36 | message=self.message, 37 | reference_url=self.reference_url, 38 | check_name=self.name, 39 | logs=[], 40 | ) 41 | 42 | 43 | class MaintainerDeprecatedCheck(FMFAbstractCheck, InstructionCountAbstractCheck): 44 | name = "maintainer_deprecated" 45 | -------------------------------------------------------------------------------- /colin/checks/dynamic.fmf: -------------------------------------------------------------------------------- 1 | test: "dynamic.py" 2 | 3 | /shell_runnable: 4 | class: ShellRunableCheck 5 | message: "Shell has to be runnable." 6 | description: "The target has to be able to invoke shell." 7 | reference_url: "https://docs.docker.com/engine/reference/run/" 8 | tags: ["sh", "cmd", "shell", "output"] 9 | cmd: ['sh', '-c', 'exit', '0'] 10 | -------------------------------------------------------------------------------- /colin/checks/dynamic.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | 16 | from colin.core.checks.cmd import CmdAbstractCheck 17 | from colin.core.checks.fmf_check import FMFAbstractCheck 18 | 19 | 20 | class ShellRunableCheck(FMFAbstractCheck, CmdAbstractCheck): 21 | name = "shell_runnable" 22 | -------------------------------------------------------------------------------- /colin/checks/labels.fmf: -------------------------------------------------------------------------------- 1 | test: labels.py 2 | tags: ["label"] 3 | 4 | /architecture_label: 5 | class: ArchitectureLabelCheck 6 | message: "Label 'architecture' has to be specified." 7 | description: "Architecture the software in the image should target. (Optional: if omitted, it will be built for all supported Fedora Architectures)" 8 | reference_url: "https://fedoraproject.org/wiki/Container:Guidelines#LABELS" 9 | tags+: ["architecture"] 10 | labels: ["architecture"] 11 | required: True 12 | value_regex: Null 13 | 14 | /authoritative_source-url_label: 15 | class: AuthoritativeSourceUrlLabelCheck 16 | message: "Label 'authoritative-source-url' has to be specified." 17 | description: "The authoritative registry in which the image is published." 18 | reference_url: "https://fedoraproject.org/wiki/Container:Guidelines#LABELS" 19 | tags+: ["authoritative-source-url"] 20 | labels: ["authoritative-source-url"] 21 | required: True 22 | value_regex: Null 23 | 24 | /build-date_label: 25 | class: BuildDateLabelCheck 26 | message: "Label 'build-date' has to be specified." 27 | description: "Date/Time image was built as RFC 3339 date-time." 28 | reference_url: "https://github.com/projectatomic/ContainerApplicationGenericLabels/blob/master/vendor/redhat/labels.md" 29 | tags+: ["build-date"] 30 | labels: ["build-date"] 31 | required: True 32 | value_regex: Null 33 | 34 | /com.redhat.build-host_label: 35 | class: BuildHostLabelCheck 36 | message: "Label 'com.redhat.build-host' has to be specified." 37 | description: "The build host used to create an image for internal use and auditability, similar to the use in RPM." 38 | reference_url: "https://fedoraproject.org/wiki/Container:Guidelines#LABELS" 39 | tags+: ["com.redhat.build-host", "build-host"] 40 | labels: ["com.redhat.build-host"] 41 | required: True 42 | value_regex: Null 43 | 44 | /com.redhat.component_label: 45 | class: ComRedhatComponentLabelCheck 46 | message: "Label 'com.redhat.component' has to be specified." 47 | description: "The Bugzilla component name where bugs against this container should be reported by users." 48 | reference_url: "https://fedoraproject.org/wiki/Container:Guidelines#LABELS" 49 | tags+: ["com.redhat.component"] 50 | labels: ["com.redhat.component"] 51 | required: True 52 | value_regex: Null 53 | 54 | /description_label: 55 | class: DescriptionLabelCheck 56 | message: "Label 'description' has to be specified." 57 | description: "Detailed description of the image." 58 | reference_url: "https://github.com/projectatomic/ContainerApplicationGenericLabels/blob/master/vendor/redhat/labels.md" 59 | tags+: ["description"] 60 | labels: ["description"] 61 | required: True 62 | value_regex: Null 63 | 64 | /description_or_io.k8s.description_label: 65 | class: DescriptionOrIoK8sDescriptionLabelCheck 66 | message: "Label 'description' or 'io.k8s.description' has to be specified." 67 | description: "Detailed description of the image." 68 | reference_url: "https://github.com/projectatomic/ContainerApplicationGenericLabels/blob/master/vendor/redhat/labels.md" 69 | tags+: ["description"] 70 | labels: ["description", "io.k8s.description"] 71 | required: True 72 | value_regex: Null 73 | 74 | /distribution-scope_label: 75 | class: DistributionScopeLabelCheck 76 | message: "Label 'distribution-scope' has to be specified." 77 | description: "Scope of intended distribution of the image. (private/authoritative-source-only/restricted/public)" 78 | reference_url: "https://github.com/projectatomic/ContainerApplicationGenericLabels/blob/master/vendor/redhat/labels.md" 79 | tags+: ["distribution-scope"] 80 | labels: ["distribution-scope"] 81 | required: True 82 | value_regex: Null 83 | 84 | /help_label: 85 | class: HelpLabelCheck 86 | message: "Label 'help' has to be specified." 87 | description: "A runnable command which results in display of Help information." 88 | reference_url: "https://fedoraproject.org/wiki/Container:Guidelines#LABELS" 89 | tags+: ["help"] 90 | labels: ["help"] 91 | required: True 92 | value_regex: Null 93 | 94 | /io.k8s.description_label: 95 | class: IoK8sDescriptionLabelCheck 96 | message: "Label 'io.k8s.description' has to be specified." 97 | description: "Description of the container displayed in Kubernetes" 98 | reference_url: 99 | - "https://github.com/projectatomic/ContainerApplicationGenericLabels/blob/master/vendor/redhat/labels.md" 100 | - "https://github.com/projectatomic/ContainerApplicationGenericLabels/blob/master/vendor/redhat/labels.md#other-labels" 101 | tags+: ["io.k8s.description", "description"] 102 | labels: ["io.k8s.description"] 103 | required: True 104 | value_regex: Null 105 | 106 | /io.k8s.display-name_label: 107 | class: IoK8sDisplayNameLabelCheck 108 | message: "Label 'io.k8s.display-name' has to be specified." 109 | description: "This label is used to display a human readable name of an image inside the Image / Repo Overview page." 110 | reference_url: "https://fedoraproject.org/wiki/Container:Guidelines#LABELS" 111 | tags+: ["io.k8s.display-name"] 112 | labels: ["io.k8s.display-name"] 113 | required: True 114 | value_regex: Null 115 | 116 | /maintainer_label: 117 | class: "MaintainerLabelCheck" 118 | message: "Label 'maintainer' has to be specified." 119 | description: "The name and email of the maintainer (usually the submitter)." 120 | reference_url: "https://fedoraproject.org/wiki/Container:Guidelines#LABELS" 121 | tags+: ["maintainer"] 122 | labels: ["maintainer"] 123 | required: True 124 | value_regex: Null 125 | 126 | /name_label: 127 | class: NameLabelCheck 128 | message: "Label 'name' has to be specified." 129 | description: "Name of the Image or Container." 130 | reference_url: "https://fedoraproject.org/wiki/Container:Guidelines#LABELS" 131 | tags+: ["name"] 132 | labels: ["name"] 133 | required: True 134 | value_regex: Null 135 | 136 | /release_label: 137 | class: ReleaseLabelCheck 138 | message: "Label 'release' has to be specified." 139 | description: "Release Number for this version." 140 | reference_url: "https://fedoraproject.org/wiki/Container:Guidelines#LABELS" 141 | tags+: ["release"] 142 | labels: ["release"] 143 | required: True 144 | value_regex: Null 145 | 146 | /summary_label: 147 | class: SummaryLabelCheck 148 | message: "Label 'summary' has to be specified." 149 | description: "A short description of the image." 150 | reference_url: "https://fedoraproject.org/wiki/Container:Guidelines#LABELS" 151 | tags+: ["summary"] 152 | labels: ["summary"] 153 | required: True 154 | value_regex: Null 155 | 156 | /url_label: 157 | class: UrlLabelCheck 158 | message: "Label 'url' has to be specified." 159 | description: "A URL where the user can find more information about the image." 160 | reference_url: "https://fedoraproject.org/wiki/Container:Guidelines#LABELS" 161 | tags+: ["url"] 162 | labels: ["url"] 163 | required: True 164 | value_regex: Null 165 | 166 | /run_or_usage_label: 167 | class: RunOrUsageLabelCheck 168 | message: "Label 'usage' has to be specified." 169 | description: "A human readable example of container execution." 170 | reference_url: "https://fedoraproject.org/wiki/Container:Guidelines#LABELS" 171 | tags+: ["usage"] 172 | labels: ["run", "usage"] 173 | required: True 174 | value_regex: Null 175 | 176 | /vcs-ref_label: 177 | class: VcsRefLabelCheck 178 | message: "Label 'vcs-ref' has to be specified." 179 | description: "A 'reference' within the version control repository; e.g. a git commit, or a subversion branch." 180 | reference_url: "https://github.com/projectatomic/ContainerApplicationGenericLabels/blob/master/vendor/redhat/labels.md" 181 | tags+: ["vcs-ref", "vcs"] 182 | labels: ["vcs-ref"] 183 | required: True 184 | value_regex: Null 185 | 186 | /vcs-type_label: 187 | class: VcsTypeLabelCheck 188 | message: "Label 'vcs-type' has to be specified." 189 | description: "The type of version control used by the container source. Generally one of git, hg, svn, bzr, cvs" 190 | reference_url: "https://github.com/projectatomic/ContainerApplicationGenericLabels/blob/master/vendor/redhat/labels.md" 191 | tags+: ["vcs-type", "vcs"] 192 | labels: ["vcs-type"] 193 | required: True 194 | value_regex: Null 195 | 196 | /vcs-url_label: 197 | class: VcsUrlLabelCheck 198 | message: "Label 'vcs-url' has to be specified." 199 | description: "URL of the version control repository." 200 | reference_url: "https://github.com/projectatomic/ContainerApplicationGenericLabels/blob/master/vendor/redhat/labels.md" 201 | tags+: ["vcs-url", "vcs"] 202 | labels: ["vcs-url"] 203 | required: True 204 | value_regex: Null 205 | 206 | /vendor_label: 207 | class: VendorLabelCheck 208 | message: "Label 'vendor' has to be specified." 209 | description: "Name of the vendor." 210 | reference_url: "https://github.com/projectatomic/ContainerApplicationGenericLabels/blob/master/vendor/redhat/labels.md" 211 | tags+: ["vendor"] 212 | labels: ["vendor"] 213 | required: True 214 | 215 | /version_label: 216 | class: VersionLabelCheck 217 | message: "Label 'version' has to be specified." 218 | description: "Version of the image." 219 | reference_url: "https://fedoraproject.org/wiki/Container:Guidelines#LABELS" 220 | tags+: ["version"] 221 | labels: ["version"] 222 | required: True 223 | value_regex: Null 224 | 225 | /inherited_labels: 226 | class: InheritedOptionalLabelCheck 227 | message: "Optional labels are only inherited" 228 | description: "Check if optional labels provided with 'labels_list' argument, are only inherited" 229 | reference_url: "?????" 230 | tags+: ["inherited"] 231 | -------------------------------------------------------------------------------- /colin/checks/labels.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | 16 | from colin.core.checks.labels import ( 17 | LabelAbstractCheck, 18 | InheritedOptionalLabelAbstractCheck, 19 | ) 20 | from colin.core.checks.fmf_check import FMFAbstractCheck 21 | 22 | 23 | class ArchitectureLabelCheck(FMFAbstractCheck, LabelAbstractCheck): 24 | name = "architecture_label" 25 | 26 | 27 | class AuthoritativeSourceUrlLabelCheck(FMFAbstractCheck, LabelAbstractCheck): 28 | name = "authoritative_source-url_label" 29 | 30 | 31 | class BuildDateLabelCheck(FMFAbstractCheck, LabelAbstractCheck): 32 | name = "build-date_label" 33 | # TODO: Check the RFC 3339 date-time format 34 | 35 | 36 | class BuildHostLabelCheck(FMFAbstractCheck, LabelAbstractCheck): 37 | name = "com.redhat.build-host_label" 38 | 39 | 40 | class ComRedhatComponentLabelCheck(FMFAbstractCheck, LabelAbstractCheck): 41 | name = "com.redhat.component_label" 42 | # TODO: Check the format 43 | 44 | 45 | class DescriptionLabelCheck(FMFAbstractCheck, LabelAbstractCheck): 46 | name = "description_label" 47 | 48 | 49 | class DescriptionOrIoK8sDescriptionLabelCheck(FMFAbstractCheck, LabelAbstractCheck): 50 | name = "description_or_io.k8s.description_label" 51 | 52 | 53 | class DistributionScopeLabelCheck(FMFAbstractCheck, LabelAbstractCheck): 54 | name = "distribution-scope_label" 55 | 56 | 57 | class HelpLabelCheck(FMFAbstractCheck, LabelAbstractCheck): 58 | name = "help_label" 59 | 60 | 61 | class IoK8sDescriptionLabelCheck(FMFAbstractCheck, LabelAbstractCheck): 62 | name = "io.k8s.description_label" 63 | 64 | 65 | class IoK8sDisplayNameLabelCheck(FMFAbstractCheck, LabelAbstractCheck): 66 | name = "io.k8s.display-name_label" 67 | 68 | 69 | class MaintainerLabelCheck(FMFAbstractCheck, LabelAbstractCheck): 70 | name = "maintainer_label" 71 | 72 | 73 | class NameLabelCheck(FMFAbstractCheck, LabelAbstractCheck): 74 | name = "name_label" 75 | 76 | 77 | class ReleaseLabelCheck(FMFAbstractCheck, LabelAbstractCheck): 78 | name = "release_label" 79 | 80 | 81 | class SummaryLabelCheck(FMFAbstractCheck, LabelAbstractCheck): 82 | name = "summary_label" 83 | 84 | 85 | class UrlLabelCheck(FMFAbstractCheck, LabelAbstractCheck): 86 | name = "url_label" 87 | 88 | 89 | class RunOrUsageLabelCheck(FMFAbstractCheck, LabelAbstractCheck): 90 | name = "run_or_usage_label" 91 | 92 | 93 | class VcsRefLabelCheck(FMFAbstractCheck, LabelAbstractCheck): 94 | name = "vcs-ref_label" 95 | 96 | 97 | class VcsTypeLabelCheck(FMFAbstractCheck, LabelAbstractCheck): 98 | name = "vcs-type_label" 99 | 100 | 101 | class VcsUrlLabelCheck(FMFAbstractCheck, LabelAbstractCheck): 102 | name = "vcs-url_label" 103 | 104 | 105 | class VendorLabelCheck(FMFAbstractCheck, LabelAbstractCheck): 106 | name = "vendor_label" 107 | 108 | 109 | class VersionLabelCheck(FMFAbstractCheck, LabelAbstractCheck): 110 | name = "version_label" 111 | 112 | 113 | class InheritedOptionalLabelCheck( 114 | FMFAbstractCheck, InheritedOptionalLabelAbstractCheck 115 | ): 116 | name = "inherited_labels" 117 | -------------------------------------------------------------------------------- /colin/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/user-cont/colin/723bfc3dc906ecfefb38fec2d68d185e60456cc9/colin/cli/__init__.py -------------------------------------------------------------------------------- /colin/cli/default_group.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | 16 | import click 17 | 18 | 19 | class DefaultGroup(click.Group): 20 | """ 21 | Allow to set default command for the group. 22 | """ 23 | 24 | ignore_unknown_options = True 25 | 26 | def __init__(self, *args, **kwargs): 27 | default_command = kwargs.pop("default_command", None) 28 | super().__init__(*args, **kwargs) 29 | self.default_cmd_name = None 30 | if default_command is not None: 31 | self.set_default_command(default_command) 32 | 33 | def set_default_command(self, command): 34 | if isinstance(command, str): 35 | cmd_name = command 36 | else: 37 | cmd_name = command.name 38 | self.add_command(command) 39 | self.default_cmd_name = cmd_name 40 | 41 | def parse_args(self, ctx, args): 42 | if not args and self.default_cmd_name is not None: 43 | args.insert(0, self.default_cmd_name) 44 | return super().parse_args(ctx, args) 45 | 46 | def get_command(self, ctx, cmd_name): 47 | if cmd_name not in self.commands and self.default_cmd_name is not None: 48 | ctx.args0 = cmd_name 49 | cmd_name = self.default_cmd_name 50 | return super().get_command(ctx, cmd_name) 51 | 52 | def resolve_command(self, ctx, args): 53 | cmd_name, cmd, args = super().resolve_command(ctx, args) 54 | args0 = getattr(ctx, "args0", None) 55 | if args0 is not None: 56 | args.insert(0, args0) 57 | return cmd_name, cmd, args 58 | -------------------------------------------------------------------------------- /colin/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/user-cont/colin/723bfc3dc906ecfefb38fec2d68d185e60456cc9/colin/core/__init__.py -------------------------------------------------------------------------------- /colin/core/check_runner.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | 16 | import logging 17 | import traceback 18 | 19 | from .constant import CHECK_TIMEOUT 20 | from .result import CheckResults, FailedCheckResult 21 | from ..utils.cmd_tools import exit_after 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | 26 | def go_through_checks(target, checks, timeout=None): 27 | logger.debug("Going through checks.") 28 | results = _result_generator(target=target, checks=checks, timeout=timeout) 29 | return CheckResults(results=results) 30 | 31 | 32 | def _result_generator(target, checks, timeout=None): 33 | try: 34 | for check in checks: 35 | logger.debug("Checking %s", check.name) 36 | try: 37 | _timeout = timeout or check.timeout or CHECK_TIMEOUT 38 | logger.debug("Check timeout: %s", _timeout) 39 | yield exit_after(_timeout)(check.check)(target) 40 | except TimeoutError as ex: 41 | logger.warning("The check hit the timeout: %s", _timeout) 42 | yield FailedCheckResult(check, logs=[str(ex)]) 43 | except Exception as ex: 44 | tb = traceback.format_exc() 45 | logger.warning("There was an error while performing check: %s", tb) 46 | yield FailedCheckResult(check, logs=[str(ex)]) 47 | finally: 48 | target.clean_up() 49 | -------------------------------------------------------------------------------- /colin/core/checks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/user-cont/colin/723bfc3dc906ecfefb38fec2d68d185e60456cc9/colin/core/checks/__init__.py -------------------------------------------------------------------------------- /colin/core/checks/abstract_check.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | import json 16 | from typing import Optional 17 | 18 | 19 | class AbstractCheck: 20 | name: Optional[str] = None 21 | check_type: Optional[str] = None 22 | 23 | def __init__(self, message, description, reference_url, tags): 24 | self.message = message 25 | self.description = description 26 | self.reference_url = reference_url 27 | self.tags = tags 28 | self.timeout = None 29 | 30 | def check(self, target): 31 | pass 32 | 33 | def __str__(self): 34 | return ( 35 | f"{self.name}\n" 36 | f" -> {self.message}\n" 37 | f" -> {self.description}\n" 38 | f" -> {self.reference_url}\n" 39 | f" -> {', '.join(self.tags)}\n" 40 | ) 41 | 42 | @property 43 | def json(self): 44 | """ 45 | Get json representation of the check 46 | 47 | :return: dict (str -> obj) 48 | """ 49 | return { 50 | "name": self.name, 51 | "message": self.message, 52 | "description": self.description, 53 | "reference_url": self.reference_url, 54 | "tags": self.tags, 55 | } 56 | 57 | @staticmethod 58 | def json_from_all_checks(checks): 59 | result_json = {} 60 | for group, group_checks in checks.items(): 61 | result_list = [check.json for check in group_checks] 62 | result_json[group] = result_list 63 | return result_json 64 | 65 | @staticmethod 66 | def save_checks_to_json(file, checks): 67 | json.dump( 68 | obj=AbstractCheck.json_from_all_checks(checks=checks), fp=file, indent=4 69 | ) 70 | 71 | 72 | class DockerfileAbstractCheck(AbstractCheck): 73 | check_type = "dockerfile" 74 | 75 | 76 | class ImageAbstractCheck(AbstractCheck): 77 | check_type = "image" 78 | 79 | 80 | class FilesystemAbstractCheck(AbstractCheck): 81 | pass 82 | -------------------------------------------------------------------------------- /colin/core/checks/check_utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from .abstract_check import DockerfileAbstractCheck, ImageAbstractCheck 4 | from ..exceptions import ColinException 5 | 6 | 7 | def check_label(labels, required, value_regex, target_labels): 8 | """ 9 | Check if the label is required and match the regex 10 | 11 | :param labels: [str] 12 | :param required: bool (if the presence means pass or not) 13 | :param value_regex: str (using search method) 14 | :param target_labels: [str] 15 | :return: bool (required==True: True if the label is present and match the regex if specified) 16 | (required==False: True if the label is not present) 17 | """ 18 | present = target_labels is not None and not set(labels).isdisjoint( 19 | set(target_labels) 20 | ) 21 | 22 | if not present: 23 | return not required 24 | if required and not value_regex: 25 | return True 26 | elif value_regex: 27 | pattern = re.compile(value_regex) 28 | present_labels = set(labels) & set(target_labels) 29 | return all( 30 | bool(pattern.search(target_labels[label])) for label in present_labels 31 | ) 32 | 33 | else: 34 | return False 35 | 36 | 37 | class NotLoadedCheck(DockerfileAbstractCheck, ImageAbstractCheck): 38 | def __init__(self, check_name, reason): 39 | self.name = check_name 40 | super().__init__( 41 | message=f"Check code '{check_name}' {reason}.", 42 | description="Did you set the right name in the ruleset file?", 43 | reference_url="", 44 | tags=[], 45 | ) 46 | 47 | def check(self, target): 48 | raise ColinException(self.message) 49 | -------------------------------------------------------------------------------- /colin/core/checks/cmd.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | import re 16 | 17 | from .abstract_check import ImageAbstractCheck 18 | from ..exceptions import ColinException 19 | from ..result import CheckResult, FailedCheckResult 20 | 21 | 22 | class CmdAbstractCheck(ImageAbstractCheck): 23 | def __init__( 24 | self, 25 | message, 26 | description, 27 | reference_url, 28 | tags, 29 | cmd, 30 | expected_output=None, 31 | expected_regex=None, 32 | substring=None, 33 | ): 34 | super().__init__(message, description, reference_url, tags) 35 | self.cmd = cmd 36 | self.expected_output = expected_output 37 | self.expected_regex = expected_regex 38 | self.substring = substring 39 | 40 | def check(self, target): 41 | try: 42 | output = target.get_output(cmd=self.cmd) 43 | 44 | """ 45 | except ConuException as ex: 46 | if str(ex).endswith("exit code 126") or str(ex).endswith("error: 127"): 47 | return CheckResult(ok=False, 48 | description=self.description, 49 | message=self.message, 50 | reference_url=self.reference_url, 51 | check_name=self.name, 52 | logs=[( 53 | "exec: '{}': executable file not found in $PATH" 54 | ).format( 55 | self.cmd)]) 56 | return FailedCheckResult(check=self, 57 | logs=[str(ex)]) 58 | """ 59 | except ColinException as ex: 60 | return FailedCheckResult(check=self, logs=[str(ex)]) 61 | passed = True 62 | logs = [f"Output:\n{output}"] 63 | if self.substring is not None: 64 | substring_present = self.substring in output 65 | passed = passed and substring_present 66 | logs.append( 67 | "{}: Substring '{}' is {}present in the output of the command '{}'.".format( 68 | "ok" if substring_present else "nok", 69 | self.substring, 70 | "" if substring_present else "not ", 71 | self.cmd, 72 | ) 73 | ) 74 | 75 | if self.expected_output is not None: 76 | expected_output = self.expected_output == output 77 | if expected_output: 78 | logs.append(f"ok: Output of the command '{self.cmd}' was as expected.") 79 | else: 80 | logs.append( 81 | f"nok: Output of the command '{self.cmd}' " 82 | f"does not match the expected one: '{self.expected_output}'." 83 | ) 84 | 85 | passed = False 86 | 87 | if self.expected_regex is not None: 88 | pattern = re.compile(self.expected_regex) 89 | if pattern.match(output): 90 | logs.append( 91 | f"ok: Output of the command '{self.cmd}' " 92 | f"match the regex '{self.expected_regex}'." 93 | ) 94 | else: 95 | logs.append( 96 | f"nok: Output of the command '{self.cmd}' does not match" 97 | f" the expected regex: '{self.expected_regex}'." 98 | ) 99 | 100 | passed = False 101 | 102 | return CheckResult( 103 | ok=passed, 104 | description=self.description, 105 | message=self.message, 106 | reference_url=self.reference_url, 107 | check_name=self.name, 108 | logs=logs, 109 | ) 110 | -------------------------------------------------------------------------------- /colin/core/checks/dockerfile.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | import logging 16 | import re 17 | 18 | from .abstract_check import DockerfileAbstractCheck 19 | from .check_utils import check_label 20 | from ..result import CheckResult 21 | 22 | logger = logging.getLogger(__name__) 23 | 24 | 25 | def get_instructions_from_dockerfile_parse(dfp, instruction): 26 | """ 27 | Get the list of instruction dictionary for given instruction name. 28 | (Subset of DockerfileParser.structure only for given instruction.) 29 | 30 | :param dfp: DockerfileParser 31 | :param instruction: str 32 | :return: list 33 | """ 34 | return [inst for inst in dfp.structure if inst["instruction"] == instruction] 35 | 36 | 37 | class InstructionAbstractCheck(DockerfileAbstractCheck): 38 | def __init__( 39 | self, 40 | message, 41 | description, 42 | reference_url, 43 | tags, 44 | instruction, 45 | value_regex, 46 | required, 47 | ): 48 | super().__init__(message, description, reference_url, tags) 49 | self.instruction = instruction 50 | self.value_regex = value_regex 51 | self.required = required 52 | 53 | def check(self, target): 54 | instructions = get_instructions_from_dockerfile_parse( 55 | target.instance, self.instruction 56 | ) 57 | pattern = re.compile(self.value_regex) 58 | logs = [] 59 | passed = True 60 | for inst in instructions: 61 | match = bool(pattern.match(inst["value"])) 62 | passed = match == self.required 63 | log = "Value for instruction {} " "{}mach regex: '{}'.".format( 64 | inst["content"], "" if match else "does not ", self.value_regex 65 | ) 66 | logs.append(log) 67 | logger.debug(log) 68 | 69 | return CheckResult( 70 | ok=passed, 71 | description=self.description, 72 | message=self.message, 73 | reference_url=self.reference_url, 74 | check_name=self.name, 75 | logs=logs, 76 | ) 77 | 78 | 79 | class InstructionCountAbstractCheck(DockerfileAbstractCheck): 80 | def __init__( 81 | self, 82 | message, 83 | description, 84 | reference_url, 85 | tags, 86 | instruction, 87 | min_count=None, 88 | max_count=None, 89 | ): 90 | super().__init__(message, description, reference_url, tags) 91 | self.instruction = instruction 92 | self.min_count = min_count 93 | self.max_count = max_count 94 | 95 | def check(self, target): 96 | count = len( 97 | get_instructions_from_dockerfile_parse(target.instance, self.instruction) 98 | ) 99 | 100 | log = "Found {} occurrences of the {} instruction. Needed: min {} | max {}".format( 101 | count, self.instruction, self.min_count, self.max_count 102 | ) 103 | logger.debug(log) 104 | passed = True 105 | if self.min_count is not None: 106 | passed = passed and self.min_count <= count 107 | if self.max_count is not None: 108 | passed = passed and count <= self.max_count 109 | 110 | return CheckResult( 111 | ok=passed, 112 | description=self.description, 113 | message=self.message, 114 | reference_url=self.reference_url, 115 | check_name=self.name, 116 | logs=[log], 117 | ) 118 | 119 | 120 | class DockerfileLabelAbstractCheck(DockerfileAbstractCheck): 121 | def __init__( 122 | self, 123 | message, 124 | description, 125 | reference_url, 126 | tags, 127 | label, 128 | required, 129 | value_regex=None, 130 | ): 131 | super().__init__(message, description, reference_url, tags) 132 | self.label = label 133 | self.required = required 134 | self.value_regex = value_regex 135 | 136 | def check(self, target): 137 | labels = target.instance.labels 138 | passed = check_label( 139 | labels=self.label, 140 | required=self.required, 141 | value_regex=self.value_regex, 142 | target_labels=labels, 143 | ) 144 | 145 | return CheckResult( 146 | ok=passed, 147 | description=self.description, 148 | message=self.message, 149 | reference_url=self.reference_url, 150 | check_name=self.name, 151 | logs=[], 152 | ) 153 | -------------------------------------------------------------------------------- /colin/core/checks/envs.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | 16 | import re 17 | 18 | from .abstract_check import ImageAbstractCheck 19 | from ..result import CheckResult 20 | 21 | 22 | class EnvCheck(ImageAbstractCheck): 23 | def __init__( 24 | self, 25 | message, 26 | description, 27 | reference_url, 28 | tags, 29 | env_var, 30 | required, 31 | value_regex=None, 32 | ): 33 | super().__init__(message, description, reference_url, tags) 34 | self.env_var = env_var 35 | self.required = required 36 | self.value_regex = value_regex 37 | 38 | def check(self, target): 39 | env_vars = target.config_metadata["Env"] 40 | 41 | env_vars_dict = {} 42 | if env_vars: 43 | for key_value in env_vars: 44 | key, value = key_value.split("=") 45 | env_vars_dict[key] = value 46 | present = self.env_var in env_vars_dict 47 | else: 48 | present = False 49 | 50 | if present: 51 | if self.required and not self.value_regex: 52 | passed = True 53 | elif self.value_regex: 54 | pattern = re.compile(self.value_regex) 55 | passed = bool(pattern.match(env_vars_dict[self.env_var])) 56 | else: 57 | passed = False 58 | 59 | else: 60 | passed = not self.required 61 | 62 | return CheckResult( 63 | ok=passed, 64 | description=self.description, 65 | message=self.message, 66 | reference_url=self.reference_url, 67 | check_name=self.name, 68 | logs=[], 69 | ) 70 | -------------------------------------------------------------------------------- /colin/core/checks/filesystem.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | import logging 16 | 17 | from .abstract_check import ImageAbstractCheck 18 | from ..result import CheckResult 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | class FileCheck(ImageAbstractCheck): 24 | """Check presence of files; w/o mounting the whole FS""" 25 | 26 | def __init__( 27 | self, message, description, reference_url, tags, files, all_must_be_present 28 | ): 29 | super().__init__(message, description, reference_url, tags) 30 | self.files = files 31 | self.all_must_be_present = all_must_be_present 32 | 33 | def _handle_image(self, target): 34 | passed = self.all_must_be_present 35 | 36 | logs = [] 37 | for f in self.files: 38 | try: 39 | f_present = target.file_is_present(f) 40 | logs.append(f"File '{f}' is {'' if f_present else 'not '}present.") 41 | except OSError as ex: 42 | logger.info("File %s is not present, ex: %s", f, ex) 43 | f_present = False 44 | logs.append(f"File {f} is not present.") 45 | if self.all_must_be_present: 46 | passed = f_present and passed 47 | else: 48 | passed = f_present or passed 49 | 50 | for log in logs: 51 | logger.debug(log) 52 | 53 | return CheckResult( 54 | ok=passed, 55 | description=self.description, 56 | message=self.message, 57 | reference_url=self.reference_url, 58 | check_name=self.name, 59 | logs=logs, 60 | ) 61 | 62 | def _handle_container(self, target): 63 | passed = self.all_must_be_present 64 | cont = target.instance 65 | 66 | logs = [] 67 | for f in self.files: 68 | cmd = ["/bin/ls", "-1", f] 69 | try: 70 | f_present = cont.execute(cmd) 71 | logs.append(f"File '{f}' is {'' if f_present else 'not '}present.") 72 | except Exception as ex: 73 | logger.info("File %s is not present, ex: %s", f, ex) 74 | f_present = False 75 | logs.append(f"File {f} is not present.") 76 | if self.all_must_be_present: 77 | passed = f_present and passed 78 | else: 79 | passed = f_present or passed 80 | 81 | return CheckResult( 82 | ok=passed, 83 | description=self.description, 84 | message=self.message, 85 | reference_url=self.reference_url, 86 | check_name=self.name, 87 | logs=logs, 88 | ) 89 | 90 | def check(self, target): 91 | return self._handle_image(target) 92 | 93 | 94 | # class FileSystemCheck(ImageAbstractCheck): 95 | # """ check for presence of files using `docker save` """ 96 | # 97 | # def __init__(self, message, description, reference_url, tags, files, all_must_be_present): 98 | # super(FileSystemCheck, self) \ 99 | # .__init__(message, description, reference_url, tags) 100 | # self.files = files 101 | # self.all_must_be_present = all_must_be_present 102 | # 103 | # def check(self, target): 104 | # try: 105 | # with target.instance.mount() as fs: 106 | # passed = self.all_must_be_present 107 | # 108 | # logs = [] 109 | # for f in self.files: 110 | # try: 111 | # f_present = fs.file_is_present(f) 112 | # logs.append("File '{}' is {}present." 113 | # .format(f, "" if f_present else "not ")) 114 | # except IOError as ex: 115 | # f_present = False 116 | # logs.append("Error: {}".format(str(ex))) 117 | # 118 | # if self.all_must_be_present: 119 | # passed = f_present and passed 120 | # else: 121 | # passed = f_present or passed 122 | # 123 | # return CheckResult(ok=passed, 124 | # description=self.description, 125 | # message=self.message, 126 | # reference_url=self.reference_url, 127 | # check_name=self.name, 128 | # logs=logs) 129 | # except Exception as ex: 130 | # raise ColinException("There was an error while operating on filesystem of {}: {}" 131 | # .format(target.instance, str(ex))) 132 | -------------------------------------------------------------------------------- /colin/core/checks/fmf_check.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module with FMF abstract check class 3 | """ 4 | 5 | import logging 6 | import inspect 7 | import os 8 | from typing import Optional 9 | 10 | from .abstract_check import AbstractCheck 11 | from ..fmf_extension import ExtendedTree 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | def receive_fmf_metadata(name, path, object_list=False): 17 | """ 18 | search node identified by name fmfpath 19 | 20 | :param path: path to filesystem 21 | :param name: str - name as pattern to search - "/name" (prepended hierarchy item) 22 | :param object_list: bool, if true, return whole list of found items 23 | :return: Tree Object or list 24 | """ 25 | output = {} 26 | fmf_tree = ExtendedTree(path) 27 | logger.debug("get FMF metadata for test (path:%s name=%s)", path, name) 28 | # ignore items with @ in names, to avoid using unreferenced items 29 | items = [ 30 | x for x in fmf_tree.climb() if x.name.endswith("/" + name) and "@" not in x.name 31 | ] 32 | if object_list: 33 | return items 34 | if len(items) == 1: 35 | output = items[0] 36 | elif len(items) > 1: 37 | raise Exception( 38 | "There is more FMF test metadata for item by name:{}({}) {}".format( 39 | name, len(items), [x.name for x in items] 40 | ) 41 | ) 42 | elif not items: 43 | raise Exception(f"Unable to get FMF metadata for: {name}") 44 | return output 45 | 46 | 47 | class FMFAbstractCheck(AbstractCheck): 48 | """ 49 | Abstract class for checks and loading metadata from FMF format 50 | """ 51 | 52 | metadata = None 53 | name: Optional[str] = None 54 | fmf_metadata_path = None 55 | 56 | def __init__(self): 57 | """ 58 | wraps parameters to COLIN __init__ method format 59 | """ 60 | if not self.metadata: 61 | if not self.fmf_metadata_path: 62 | logger.info( 63 | "setting self.fmf_metadata_path by class location." 64 | " DO NOT use it in this way." 65 | " Metadata are set in colin.core.loader (use proper path)" 66 | ) 67 | self.fmf_metadata_path = os.path.dirname( 68 | inspect.getfile(self.__class__) 69 | ) 70 | self.metadata = receive_fmf_metadata( 71 | name=self.name, path=self.fmf_metadata_path 72 | ) 73 | master_class = super() 74 | kwargs = {} 75 | try: 76 | # this is not available in python2, but second function is deprecated 77 | args_names = list(inspect.signature(master_class.__init__).parameters) 78 | except NameError: 79 | args_names = inspect.getargspec(master_class.__init__).args 80 | for arg in args_names: 81 | # copy all arguments from metadata.data to class __init__ kwargs 82 | try: 83 | kwargs[arg] = self.metadata.data[arg] 84 | except KeyError: 85 | pass 86 | try: 87 | master_class.__init__(**kwargs) 88 | except TypeError as error: 89 | logger.debug( 90 | "missing argument (%s) in FMF metadata key (%s): %s", 91 | error, 92 | self.metadata.name, 93 | self.metadata.data, 94 | ) 95 | -------------------------------------------------------------------------------- /colin/core/checks/images.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | -------------------------------------------------------------------------------- /colin/core/checks/labels.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | import logging 16 | 17 | from .abstract_check import ImageAbstractCheck, DockerfileAbstractCheck 18 | from .check_utils import check_label 19 | from ..result import CheckResult 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | class LabelAbstractCheck(ImageAbstractCheck, DockerfileAbstractCheck): 25 | def __init__( 26 | self, 27 | message, 28 | description, 29 | reference_url, 30 | tags, 31 | labels, 32 | required, 33 | value_regex=None, 34 | ): 35 | """ 36 | Abstract check for Dockerfile/Image labels. 37 | 38 | :param message: str 39 | :param description: str 40 | :param reference_url: str 41 | :param tags: [str] 42 | :param labels: [str] 43 | :param required: bool 44 | :param value_regex: str (using search method) 45 | """ 46 | super().__init__(message, description, reference_url, tags) 47 | self.labels = labels 48 | self.required = required 49 | self.value_regex = value_regex 50 | 51 | def check(self, target): 52 | passed = check_label( 53 | labels=self.labels, 54 | required=self.required, 55 | value_regex=self.value_regex, 56 | target_labels=target.labels, 57 | ) 58 | 59 | return CheckResult( 60 | ok=passed, 61 | description=self.description, 62 | message=self.message, 63 | reference_url=self.reference_url, 64 | check_name=self.name, 65 | logs=[], 66 | ) 67 | 68 | 69 | class DeprecatedLabelAbstractCheck(ImageAbstractCheck, DockerfileAbstractCheck): 70 | def __init__(self, message, description, reference_url, tags, old_label, new_label): 71 | super().__init__(message, description, reference_url, tags) 72 | self.old_label = old_label 73 | self.new_label = new_label 74 | 75 | def check(self, target): 76 | labels = target.labels 77 | old_present = labels is not None and self.old_label in labels 78 | 79 | passed = (not old_present) or (self.new_label in labels) 80 | 81 | return CheckResult( 82 | ok=passed, 83 | description=self.description, 84 | message=self.message, 85 | reference_url=self.reference_url, 86 | check_name=self.name, 87 | logs=[], 88 | ) 89 | 90 | 91 | class InheritedOptionalLabelAbstractCheck(ImageAbstractCheck): 92 | def __init__(self, message, description, reference_url, tags): 93 | """ 94 | Abstract check for Dockerfile/Image labels. 95 | 96 | :param message: str 97 | :param description: str 98 | :param reference_url: str 99 | :param tags: [str] 100 | """ 101 | super().__init__(message, description, reference_url, tags) 102 | self.labels_list = [] 103 | 104 | def check(self, target): 105 | passed = True 106 | logs = [] 107 | 108 | if target.parent_target: 109 | labels_to_check = ( 110 | set(self.labels_list) 111 | & set(target.labels) 112 | & set(target.parent_target.labels) 113 | ) 114 | for label in labels_to_check: 115 | if target.labels[label] == target.parent_target.labels[label]: 116 | passed = False 117 | log = f"optional label inherited: {label}" 118 | logs.append(log) 119 | logger.debug(log) 120 | 121 | return CheckResult( 122 | ok=passed, 123 | description=self.description, 124 | message=self.message, 125 | reference_url=self.reference_url, 126 | check_name=self.name, 127 | logs=logs, 128 | ) 129 | -------------------------------------------------------------------------------- /colin/core/colin.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | 16 | import logging 17 | 18 | from .check_runner import go_through_checks 19 | from .ruleset.ruleset import Ruleset 20 | from .target import Target 21 | 22 | logger = logging.getLogger(__name__) 23 | 24 | 25 | def run( 26 | target, 27 | target_type, 28 | parent_target=None, 29 | tags=None, 30 | ruleset_name=None, 31 | ruleset_file=None, 32 | ruleset=None, 33 | logging_level=logging.WARNING, 34 | checks_paths=None, 35 | pull=None, 36 | insecure=False, 37 | skips=None, 38 | timeout=None, 39 | ): 40 | """ 41 | Runs the sanity checks for the target. 42 | 43 | :param timeout: timeout per-check (in seconds) 44 | :param skips: name of checks to skip 45 | :param target: str (image name, oci, or dockertar) 46 | or ImageTarget 47 | or path/file-like object for dockerfile 48 | :param parent_target: Target for parent image 49 | :param target_type: string, either image, dockerfile, dockertar 50 | :param tags: list of str (if not None, the checks will be filtered by tags.) 51 | :param ruleset_name: str (e.g. fedora; if None, default would be used) 52 | :param ruleset_file: fileobj instance holding ruleset configuration 53 | :param ruleset: dict, content of a ruleset file 54 | :param logging_level: logging level (default logging.WARNING) 55 | :param checks_paths: list of str, directories where the checks are present 56 | :param pull: bool, pull the image from registry 57 | :param insecure: bool, pull from an insecure registry (HTTP/invalid TLS) 58 | :return: Results instance 59 | """ 60 | _set_logging(level=logging_level) 61 | logger.debug("Checking started.") 62 | 63 | parent = None 64 | if parent_target is not None and target_type != "dockerfile": 65 | parent = Target.get_instance( 66 | target=parent_target, 67 | logging_level=logging_level, 68 | pull=pull, 69 | target_type=target_type, 70 | insecure=insecure, 71 | ) 72 | 73 | target = Target.get_instance( 74 | target=target, 75 | parent_target=parent, 76 | logging_level=logging_level, 77 | pull=pull, 78 | target_type=target_type, 79 | insecure=insecure, 80 | ) 81 | 82 | checks_to_run = _get_checks( 83 | target_type=target.__class__, 84 | tags=tags, 85 | ruleset_name=ruleset_name, 86 | ruleset_file=ruleset_file, 87 | ruleset=ruleset, 88 | checks_paths=checks_paths, 89 | skips=skips, 90 | ) 91 | return go_through_checks(target=target, checks=checks_to_run, timeout=timeout) 92 | 93 | 94 | def get_checks( 95 | target_type=None, 96 | tags=None, 97 | ruleset_name=None, 98 | ruleset_file=None, 99 | ruleset=None, 100 | logging_level=logging.WARNING, 101 | checks_paths=None, 102 | skips=None, 103 | ): 104 | """ 105 | Get the sanity checks for the target. 106 | 107 | :param skips: name of checks to skip 108 | :param target_type: TargetType enum 109 | :param tags: list of str (if not None, the checks will be filtered by tags.) 110 | :param ruleset_name: str (e.g. fedora; if None, default would be used) 111 | :param ruleset_file: fileobj instance holding ruleset configuration 112 | :param ruleset: dict, content of a ruleset file 113 | :param logging_level: logging level (default logging.WARNING) 114 | :param checks_paths: list of str, directories where the checks are present 115 | :return: list of check instances 116 | """ 117 | _set_logging(level=logging_level) 118 | logger.debug("Finding checks started.") 119 | return _get_checks( 120 | target_type=target_type, 121 | tags=tags, 122 | ruleset_name=ruleset_name, 123 | ruleset_file=ruleset_file, 124 | ruleset=ruleset, 125 | checks_paths=checks_paths, 126 | skips=skips, 127 | ) 128 | 129 | 130 | def _get_checks( 131 | target_type, 132 | tags=None, 133 | ruleset_name=None, 134 | ruleset_file=None, 135 | ruleset=None, 136 | checks_paths=None, 137 | skips=None, 138 | ): 139 | ruleset = Ruleset( 140 | ruleset_name=ruleset_name, 141 | ruleset_file=ruleset_file, 142 | ruleset=ruleset, 143 | checks_paths=checks_paths, 144 | ) 145 | return ruleset.get_checks(tags=tags, target_type=target_type, skips=skips) 146 | 147 | 148 | def _set_logging( 149 | logger_name="colin", 150 | level=logging.INFO, 151 | handler_class=logging.StreamHandler, 152 | handler_kwargs=None, 153 | format="%(asctime)s.%(msecs).03d %(filename)-17s %(levelname)-6s %(message)s", 154 | date_format="%H:%M:%S", 155 | ): 156 | """ 157 | Set personal logger for this library. 158 | 159 | :param logger_name: str, name of the logger 160 | :param level: int, see logging.{DEBUG,INFO,ERROR,...}: level of logger and handler 161 | :param handler_class: logging.Handler instance, default is StreamHandler (/dev/stderr) 162 | :param handler_kwargs: dict, keyword arguments to handler's constructor 163 | :param format: str, formatting style 164 | :param date_format: str, date style in the logs 165 | """ 166 | if level != logging.NOTSET: 167 | logger = logging.getLogger(logger_name) 168 | logger.setLevel(level) 169 | 170 | # do not readd handlers if they are already present 171 | if not [x for x in logger.handlers if isinstance(x, handler_class)]: 172 | handler_kwargs = handler_kwargs or {} 173 | handler = handler_class(**handler_kwargs) 174 | handler.setLevel(level) 175 | 176 | formatter = logging.Formatter(format, date_format) 177 | handler.setFormatter(formatter) 178 | logger.addHandler(handler) 179 | -------------------------------------------------------------------------------- /colin/core/constant.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | 16 | RULESET_DIRECTORY_NAME = "rulesets" 17 | RULESET_DIRECTORY = "share/colin/" + RULESET_DIRECTORY_NAME 18 | EXTS = [".yaml", ".yml", ".json"] 19 | 20 | PASSED = "PASS" 21 | FAILED = "FAIL" 22 | ERROR = "ERROR" 23 | 24 | COLOURS = {PASSED: "green", FAILED: "red", ERROR: "red"} 25 | 26 | OUTPUT_CHARS = {PASSED: ".", FAILED: "x", ERROR: "#"} 27 | 28 | COLIN_CHECKS_PATH = "CHECKS_PATH" 29 | 30 | CHECK_TIMEOUT = 10 * 60 # s 31 | -------------------------------------------------------------------------------- /colin/core/exceptions.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | 16 | 17 | class ColinException(Exception): 18 | """Generic exception when something goes wrong with colin.""" 19 | 20 | 21 | class ColinRulesetException(Exception): 22 | """Exception raise when there is a problem with ruleset files.""" 23 | -------------------------------------------------------------------------------- /colin/core/fmf_extension.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module handling FMF stored metadata for classes 3 | """ 4 | 5 | import logging 6 | import re 7 | 8 | from fmf import Tree 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class ExtendedTree(Tree): 14 | """ 15 | FMF Extension. Allows to use references via @ to another items -> usefull for rulesets 16 | """ 17 | 18 | def __remove_append_items(self, whole=False): 19 | """ 20 | internal method, delete all append items (ends with +) 21 | :param whole: pass thru 'whole' param to climb 22 | :return: None 23 | """ 24 | for node in self.climb(whole=whole): 25 | for key in sorted(node.data.keys()): 26 | if key.endswith("+"): 27 | del node.data[key] 28 | 29 | def references(self, datatrees, whole=False): 30 | """ 31 | Reference name resolver (eg. /a/b/c/d@.x.y or /a/b/c/@y will search data in .x.y or y nodes) 32 | there are used regular expressions (re.search) to match names 33 | it uses simple references schema, do not use references to another references, 34 | avoid usind / in reference because actual solution creates also these tree items. 35 | 36 | datatree contains for example data like (original check data) 37 | /dockerfile/maintainer_check: 38 | class: SomeClass 39 | tags: [dockerfile] 40 | 41 | and reference could be like (ruleset) 42 | /default/check1@maintainer_check: 43 | tags+: [required] 44 | 45 | will produce output (output ruleset tree): 46 | /default/check1@maintainer_check: 47 | class: SomeClass 48 | tags: [dockerfile, required] 49 | 50 | 51 | :param whole: 'whole' param of original climb method, in colin this is not used anyhow now 52 | iterate over all items not only leaves if True 53 | :param datatrees: list of original trees with testcases to contain parent nodes 54 | :return: None 55 | """ 56 | if not isinstance(datatrees, list): 57 | raise ValueError("datatrees argument has to be list of fmf trees") 58 | reference_nodes = self.prune(whole=whole, names=["@"]) 59 | for node in reference_nodes: 60 | node.data = node.original_data 61 | ref_item_name = node.name.rsplit("@", 1)[1] 62 | # match item what does not contain @ before name, otherwise it 63 | # match same item 64 | reference_node = None 65 | for datatree in datatrees: 66 | reference_node = datatree.search(f"[^@]{ref_item_name}") 67 | if reference_node is not None: 68 | break 69 | if not reference_node: 70 | raise ValueError( 71 | "Unable to find reference for node: %s via name search: %s" 72 | % (node.name, ref_item_name) 73 | ) 74 | logger.debug( 75 | "MERGING: %s @ %s from %s", 76 | node.name, 77 | reference_node.name, 78 | reference_node.root, 79 | ) 80 | node.merge(parent=reference_node) 81 | 82 | self.__remove_append_items(whole=whole) 83 | 84 | def search(self, name): 85 | """Search node with given name based on regexp, basic method (find) uses equality""" 86 | for node in self.climb(): 87 | if re.search(name, node.name): 88 | return node 89 | return None 90 | -------------------------------------------------------------------------------- /colin/core/loader.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | """ 16 | This piece of code searches for python code on specific path and 17 | loads AbstractCheck classes from it. 18 | """ 19 | 20 | import inspect 21 | import logging 22 | import os 23 | from importlib import import_module 24 | from importlib.util import module_from_spec 25 | from importlib.util import spec_from_file_location 26 | 27 | from ..core.checks.fmf_check import receive_fmf_metadata, FMFAbstractCheck 28 | 29 | logger = logging.getLogger(__name__) 30 | 31 | 32 | def path_to_module(path): 33 | if path.endswith(".py"): 34 | path = path[:-3] 35 | cleaned_path = path.replace(".", "").replace("-", "_") 36 | path_comps = cleaned_path.split(os.sep)[-2:] 37 | import_name = ".".join(path_comps) 38 | if import_name[0] == ".": 39 | import_name = import_name[1:] 40 | return import_name 41 | 42 | 43 | def _load_module(path): 44 | module_name = path_to_module(path) 45 | logger.debug("Will try to load selected file as module '%s'.", module_name) 46 | 47 | s = spec_from_file_location(module_name, path) 48 | m = module_from_spec(s) 49 | s.loader.exec_module(m) 50 | return m 51 | 52 | 53 | def should_we_load(kls): 54 | """should we load this class as a check?""" 55 | # we don't load abstract classes 56 | if kls.__name__.endswith("AbstractCheck"): 57 | return False 58 | # and we only load checks 59 | if not kls.__name__.endswith("Check"): 60 | return False 61 | mro = kls.__mro__ 62 | return any(m.__name__ == "AbstractCheck" for m in mro) 63 | 64 | 65 | def load_check_classes_from_file(path): 66 | logger.debug("Getting check(s) from the file '%s'.", path) 67 | m = _load_module(path) 68 | 69 | check_classes = [] 70 | for _, obj in inspect.getmembers(m, inspect.isclass): 71 | if should_we_load(obj): 72 | if issubclass(obj, FMFAbstractCheck): 73 | node_metadata = receive_fmf_metadata( 74 | name=obj.name, path=os.path.dirname(path) 75 | ) 76 | obj.metadata = node_metadata 77 | check_classes.append(obj) 78 | # Uncomment when debugging this code. 79 | logger.debug( 80 | "Check class '%s' loaded, module: '%s'", obj.__name__, obj.__module__ 81 | ) 82 | return check_classes 83 | 84 | 85 | class CheckLoader: 86 | """ 87 | find recursively all checks on a given path 88 | """ 89 | 90 | def __init__(self, checks_paths): 91 | """ 92 | :param checks_paths: list of str, directories where the checks are present 93 | """ 94 | logger.debug("Will load checks from paths '%s'.", checks_paths) 95 | for p in checks_paths: 96 | if os.path.isfile(p): 97 | raise RuntimeError(f"Provided path {p} is not a directory.") 98 | self._check_classes = None 99 | self._mapping = None 100 | self.paths = checks_paths 101 | 102 | def obtain_check_classes(self): 103 | """find children of AbstractCheck class and return them as a list""" 104 | check_classes = set() 105 | for path in self.paths: 106 | for root, _, files in os.walk(path): 107 | for fi in files: 108 | if not fi.endswith(".py"): 109 | continue 110 | path = os.path.join(root, fi) 111 | check_classes = check_classes.union( 112 | set(load_check_classes_from_file(path)) 113 | ) 114 | return list(check_classes) 115 | 116 | def import_class(self, import_name): 117 | """ 118 | import selected class 119 | 120 | :param import_name, str, e.g. some.module.MyClass 121 | :return the class 122 | """ 123 | module_name, class_name = import_name.rsplit(".", 1) 124 | mod = import_module(module_name) 125 | check_class = getattr(mod, class_name) 126 | self.mapping[check_class.name] = check_class 127 | logger.info("successfully loaded class %s", check_class) 128 | return check_class 129 | 130 | @property 131 | def check_classes(self): 132 | if self._check_classes is None: 133 | self._check_classes = self.obtain_check_classes() 134 | return self._check_classes 135 | 136 | @property 137 | def mapping(self): 138 | if self._mapping is None: 139 | self._mapping = {c.name: c for c in self.check_classes} 140 | return self._mapping 141 | -------------------------------------------------------------------------------- /colin/core/result.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | 16 | import json 17 | from xml.etree.ElementTree import Element, SubElement, tostring 18 | from xml.dom import minidom 19 | 20 | from .constant import COLOURS, ERROR, FAILED, OUTPUT_CHARS, PASSED 21 | from ..utils.caching_iterable import CachingIterable 22 | 23 | 24 | class CheckResult: 25 | def __init__(self, ok, description, message, reference_url, check_name, logs): 26 | self.ok = ok 27 | self.description = description 28 | self.message = message 29 | self.reference_url = reference_url 30 | self.check_name = check_name 31 | self.logs = logs 32 | 33 | @property 34 | def status(self): 35 | return PASSED if self.ok else FAILED 36 | 37 | def __str__(self): 38 | return f"{self.status}:{self.message}" 39 | 40 | 41 | class DockerfileCheckResult(CheckResult): 42 | def __init__( 43 | self, 44 | ok, 45 | description, 46 | message, 47 | reference_url, 48 | check_name, 49 | lines=None, 50 | correction_diff=None, 51 | ): 52 | super().__init__(ok, description, message, reference_url, check_name) 53 | self.lines = lines 54 | self.correction_diff = correction_diff 55 | 56 | 57 | class CheckResults: 58 | def __init__(self, results): 59 | self.results = CachingIterable(results) 60 | 61 | @property 62 | def results_per_check(self): 63 | return {r.check_name: r for r in self.results} 64 | 65 | @property 66 | def _dict_of_results(self): 67 | """ 68 | Get the dictionary representation of results 69 | 70 | :return: dict (str -> dict (str -> str)) 71 | """ 72 | result_list = [ 73 | { 74 | "name": r.check_name, 75 | "ok": r.ok, 76 | "status": r.status, 77 | "description": r.description, 78 | "message": r.message, 79 | "reference_url": r.reference_url, 80 | "logs": r.logs, 81 | } 82 | for r in self.results 83 | ] 84 | return {"checks": result_list} 85 | 86 | @property 87 | def json(self): 88 | """ 89 | Get the json representation of results 90 | 91 | :return: str 92 | """ 93 | return json.dumps(self._dict_of_results, indent=4) 94 | 95 | def save_json_to_file(self, file): 96 | json.dump(obj=self._dict_of_results, fp=file, indent=4) 97 | 98 | @property 99 | def xunit(self): 100 | """ 101 | Get the xunit representation of results 102 | 103 | :return: str 104 | """ 105 | 106 | top = Element("testsuites") 107 | 108 | testsuite = SubElement(top, "testsuite") 109 | 110 | for r in self.results: 111 | testcase = SubElement( 112 | testsuite, 113 | "testcase", 114 | { 115 | "name": r.check_name, 116 | # Can't use PASSED or FAILED global variables because their values are PASS 117 | # and FAIL respectively and xunit wants them suffixed with -ED. 118 | "status": "PASSED" if r.ok else "FAILED", 119 | "url": r.reference_url, 120 | }, 121 | ) 122 | if r.logs: 123 | logs = SubElement(testcase, "logs") 124 | for log in r.logs: 125 | log = SubElement( 126 | logs, 127 | "log", 128 | { 129 | "message": log, 130 | "result": "INFO", 131 | "waiver_authorization": "Not Waivable", 132 | }, 133 | ) 134 | 135 | rough_string = tostring(top, "utf-8") 136 | reparsed = minidom.parseString(rough_string) 137 | return reparsed.toprettyxml(indent=" ") 138 | 139 | def save_xunit_to_file(self, file): 140 | """ 141 | Write the contents of xunit to the passed file pointer. 142 | :param file: the file to which to write 143 | :return: return code of the write command 144 | """ 145 | file.write(self.xunit) 146 | 147 | @property 148 | def statistics(self): 149 | """ 150 | Get the dictionary with the count of the check-statuses 151 | 152 | :return: dict(str -> int) 153 | """ 154 | result = {} 155 | for r in self.results: 156 | result.setdefault(r.status, 0) 157 | result[r.status] += 1 158 | return result 159 | 160 | @property 161 | def ok(self): 162 | """ 163 | If the results ended without any error 164 | 165 | 166 | :return: True, if there is no check which ends with error status 167 | """ 168 | return ERROR not in self.statistics 169 | 170 | @property 171 | def fail(self): 172 | """ 173 | If the results ended without any fail 174 | 175 | 176 | :return: True, if there is no check which ends with fail status 177 | """ 178 | return FAILED in self.statistics 179 | 180 | def generate_pretty_output(self, stat, verbose, output_function, logs=True): 181 | """ 182 | Send the formated to the provided function 183 | 184 | :param stat: if True print stat instead of full output 185 | :param verbose: bool 186 | :param output_function: function to send output to 187 | """ 188 | 189 | has_check = False 190 | for r in self.results: 191 | has_check = True 192 | if stat: 193 | output_function(OUTPUT_CHARS[r.status], fg=COLOURS[r.status], nl=False) 194 | else: 195 | output_function(str(r), fg=COLOURS[r.status]) 196 | if verbose: 197 | output_function( 198 | f" -> {r.description}\n -> {r.reference_url}", 199 | fg=COLOURS[r.status], 200 | ) 201 | if logs and r.logs: 202 | output_function(" -> logs:", fg=COLOURS[r.status]) 203 | for line in r.logs: 204 | output_function(f" -> {line}", fg=COLOURS[r.status]) 205 | 206 | if not has_check: 207 | output_function("No check found.") 208 | elif stat and not verbose: 209 | output_function("") 210 | else: 211 | output_function("") 212 | for status, count in self.statistics.items(): 213 | output_function(f"{status}:{count} ", nl=False) 214 | output_function("") 215 | 216 | def get_pretty_string(self, stat, verbose): 217 | """ 218 | Pretty string representation of the results 219 | 220 | :param stat: bool 221 | :param verbose: bool 222 | :return: str 223 | """ 224 | pretty_output = _PrettyOutputToStr() 225 | self.generate_pretty_output( 226 | stat=stat, verbose=verbose, output_function=pretty_output.save_output 227 | ) 228 | return pretty_output.result 229 | 230 | 231 | class FailedCheckResult(CheckResult): 232 | def __init__(self, check, logs=None): 233 | super().__init__( 234 | ok=False, 235 | message=check.message, 236 | description=check.description, 237 | reference_url=check.reference_url, 238 | check_name=check.name, 239 | logs=logs or [], 240 | ) 241 | 242 | @property 243 | def status(self): 244 | return ERROR 245 | 246 | 247 | class _PrettyOutputToStr: 248 | def __init__(self): 249 | self.result = "" 250 | 251 | def save_output(self, text=None, nl=True): 252 | text = text or "" 253 | self.result += text 254 | if nl: 255 | self.result += "\n" 256 | -------------------------------------------------------------------------------- /colin/core/ruleset/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/user-cont/colin/723bfc3dc906ecfefb38fec2d68d185e60456cc9/colin/core/ruleset/__init__.py -------------------------------------------------------------------------------- /colin/core/ruleset/loader.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | 16 | """ 17 | This module is responsible for loading rulesets: reading from disk, parsing/validating 18 | """ 19 | import yaml 20 | import logging 21 | 22 | from ..exceptions import ColinRulesetException 23 | 24 | logger = logging.getLogger(__name__) 25 | 26 | 27 | def get_ruleset_struct_from_fileobj(fileobj): 28 | try: 29 | logger.debug("Loading ruleset from file '%s'.", fileobj.name) 30 | return RulesetStruct(yaml.safe_load(fileobj)) 31 | except Exception as ex: 32 | msg = f"Ruleset file '{fileobj.name}' cannot be loaded: {ex}" 33 | logger.error(msg) 34 | raise ColinRulesetException(msg) 35 | 36 | 37 | def get_ruleset_struct_from_file(file_path): 38 | try: 39 | with open(file_path) as fd: 40 | return get_ruleset_struct_from_fileobj(fd) 41 | except ColinRulesetException as ex: 42 | raise ex 43 | except Exception as ex: 44 | msg = f"Ruleset '{file_path}' cannot be loaded: {ex}" 45 | 46 | logger.error(msg) 47 | raise ColinRulesetException(msg) 48 | 49 | 50 | def nicer_get(di, required, *path): 51 | """ 52 | this is a nicer way of doing dict.get() 53 | 54 | :param di: dict 55 | :param required: bool, raises an exc if value is not found, otherwise returns None 56 | :param path: list of str to navigate in the dict 57 | :return: your value 58 | """ 59 | 60 | r = di 61 | for p in path: 62 | try: 63 | r = r[p] 64 | except KeyError: 65 | if required: 66 | logger.error( 67 | "can't locate %s in ruleset dict, keys present: %s", 68 | p, 69 | list(r.keys()), 70 | ) 71 | logger.debug("full dict = %s", r) 72 | raise ColinRulesetException( 73 | f"Validation error: can't locate {p} in ruleset." 74 | ) 75 | return 76 | return r 77 | 78 | 79 | class CheckStruct: 80 | """ 81 | { 82 | "name": "label_name", 83 | "tags": ["foo", "bar"], 84 | "additional_tags": ["baz"], 85 | "usable_targets": ["image", "dockerfile"], 86 | } 87 | """ 88 | 89 | def __init__(self, check_dict): 90 | self.c = check_dict 91 | # TODO: validate the dict 92 | # TODO: get check class and merry them here 93 | 94 | def _get(self, required, *path): 95 | return nicer_get(self.c, required, *path) 96 | 97 | def __str__(self): 98 | return f"{self.name}" 99 | 100 | @property 101 | def import_name(self): 102 | """module name + class, e.g. our.colin.checks.CustomCheck""" 103 | return self._get(False, "import_name") 104 | 105 | @property 106 | def name(self): 107 | return self._get(True, "name") 108 | 109 | @property 110 | def tags(self): 111 | return self._get(False, "tags") 112 | 113 | @property 114 | def additional_tags(self): 115 | return self._get(False, "additional_tags") 116 | 117 | @property 118 | def usable_targets(self): 119 | return self._get(False, "usable_targets") 120 | 121 | @property 122 | def other_attributes(self): 123 | """return dict with all other data except for the described above""" 124 | return { 125 | k: v 126 | for k, v in self.c.items() 127 | if k not in ["name", "names", "tags", "additional_tags", "usable_targets"] 128 | } 129 | 130 | 131 | class RulesetStruct: 132 | """ 133 | { 134 | "version": "1", 135 | "name": "Mandatory checks for Red Hat container images" 136 | "description": "This set of checks is required to pass on every container image ..." 137 | "contact_email": "cvp@redhat.com?" 138 | "checks": [{ 139 | "name": "label_name", 140 | "tags": ["foo", "bar"], 141 | "additional_tags": ["baz"], 142 | "usable_targets": ["image", "dockerfile"], 143 | }, {}... 144 | ]} 145 | """ 146 | 147 | def __init__(self, ruleset_dict): 148 | self.d = ruleset_dict 149 | # TODO: validate ruleset 150 | self._checks = None 151 | 152 | def _get(self, *path): 153 | return nicer_get(self.d, True, *path) 154 | 155 | def __str__(self): 156 | return f"{self.name}" 157 | 158 | @property 159 | def version(self): 160 | return self._get("version") 161 | 162 | @property 163 | def name(self): 164 | return self._get("name") 165 | 166 | @property 167 | def description(self): 168 | return self._get("description") 169 | 170 | @property 171 | def contact_email(self): 172 | return self._get("contact_email") 173 | 174 | @property 175 | def checks(self): 176 | if self._checks is None: 177 | self._checks = [] 178 | for c in self._get("checks"): 179 | if "name" in c: 180 | self._checks.append(CheckStruct(c)) 181 | elif "names" in c: 182 | for n in c["names"]: 183 | new_check = dict(c) 184 | del new_check["names"] 185 | new_check["name"] = n 186 | self._checks.append(CheckStruct(new_check)) 187 | 188 | return self._checks 189 | -------------------------------------------------------------------------------- /colin/core/ruleset/ruleset.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | 16 | import logging 17 | import os 18 | import sys 19 | 20 | from .loader import ( 21 | RulesetStruct, 22 | get_ruleset_struct_from_file, 23 | get_ruleset_struct_from_fileobj, 24 | ) 25 | from ..constant import EXTS, RULESET_DIRECTORY, RULESET_DIRECTORY_NAME 26 | from ..exceptions import ColinRulesetException 27 | from ..loader import CheckLoader 28 | from ..target import is_compatible 29 | 30 | logger = logging.getLogger(__name__) 31 | 32 | 33 | class Ruleset: 34 | def __init__( 35 | self, ruleset_name=None, ruleset_file=None, ruleset=None, checks_paths=None 36 | ): 37 | """ 38 | Load ruleset for colin. 39 | 40 | :param ruleset_name: str (name of the ruleset file (without any file extension), default is 41 | "default" 42 | :param ruleset_file: fileobj instance holding ruleset configuration 43 | :param ruleset: dict, content of a ruleset file 44 | :param checks_paths: list of str, directories where the checks are present 45 | """ 46 | self.check_loader = CheckLoader(get_checks_paths(checks_paths)) 47 | if ruleset: 48 | self.ruleset_struct = RulesetStruct(ruleset) 49 | elif ruleset_file: 50 | self.ruleset_struct = get_ruleset_struct_from_fileobj(ruleset_file) 51 | else: 52 | logger.debug("Loading ruleset with the name '%s'.", ruleset_name) 53 | ruleset_path = get_ruleset_file(ruleset=ruleset_name) 54 | self.ruleset_struct = get_ruleset_struct_from_file(ruleset_path) 55 | if self.ruleset_struct.version not in ["1", 1]: 56 | raise ColinRulesetException( 57 | "colin accepts only ruleset version '1'. You provided %r" 58 | % self.ruleset_struct.version 59 | ) 60 | 61 | def get_checks(self, target_type, tags=None, skips=None): 62 | """ 63 | Get all checks for given type/tags. 64 | 65 | :param skips: list of str 66 | :param target_type: TargetType class 67 | :param tags: list of str 68 | :return: list of check instances 69 | """ 70 | skips = skips or [] 71 | result = [] 72 | for check_struct in self.ruleset_struct.checks: 73 | if check_struct.name in skips: 74 | continue 75 | 76 | logger.debug("Processing check_struct %s.", check_struct) 77 | 78 | usable_targets = check_struct.usable_targets 79 | if ( 80 | target_type 81 | and usable_targets 82 | and target_type.get_compatible_check_class().check_type 83 | not in usable_targets 84 | ): 85 | logger.info("Skipping... Target type does not match.") 86 | continue 87 | 88 | if check_struct.import_name: 89 | check_class = self.check_loader.import_class(check_struct.import_name) 90 | else: 91 | try: 92 | check_class = self.check_loader.mapping[check_struct.name] 93 | except KeyError: 94 | logger.error( 95 | "Check %s was not found -- it can't be loaded", 96 | check_struct.name, 97 | ) 98 | raise ColinRulesetException( 99 | f"Check {check_struct.name} can't be loaded, we couldn't find it." 100 | ) 101 | check_instance = check_class() 102 | 103 | if check_struct.tags: 104 | logger.info( 105 | "Overriding check's tags %s with the one defined in ruleset: %s", 106 | check_instance.tags, 107 | check_struct.tags, 108 | ) 109 | check_instance.tags = check_struct.tags[:] 110 | if check_struct.additional_tags: 111 | logger.info("Adding additional tags: %s", check_struct.additional_tags) 112 | check_instance.tags += check_struct.additional_tags 113 | 114 | if not is_compatible( 115 | target_type=target_type, check_instance=check_instance 116 | ): 117 | logger.error( 118 | "Check '%s' not compatible with the target type: %s", 119 | check_instance.name, 120 | target_type.get_compatible_check_class().check_type, 121 | ) 122 | raise ColinRulesetException( 123 | f"Check {check_instance} can't be used for target type " 124 | f"{target_type.get_compatible_check_class().check_type}" 125 | ) 126 | 127 | if tags and not set(tags) < set(check_instance.tags): 128 | logger.debug( 129 | "Check '%s' not passed the tag control: %s", 130 | check_instance.name, 131 | tags, 132 | ) 133 | continue 134 | 135 | # and finally, attach attributes from ruleset to the check instance 136 | for k, v in check_struct.other_attributes.items(): 137 | # yes, this overrides things; yes, users may easily and severely broke their setup 138 | setattr(check_instance, k, v) 139 | 140 | result.append(check_instance) 141 | logger.debug("Check instance %s added.", check_instance.name) 142 | 143 | return result 144 | 145 | 146 | def get_checks_paths(checks_paths=None): 147 | """ 148 | Get path to checks. 149 | 150 | :param checks_paths: list of str, directories where the checks are present 151 | :return: list of str (absolute path of directory with checks) 152 | """ 153 | p = os.path.join(__file__, os.pardir, os.pardir, os.pardir, "checks") 154 | p = os.path.abspath(p) 155 | # let's utilize the default upstream checks always 156 | if checks_paths: 157 | p += [os.path.abspath(x) for x in checks_paths] 158 | return [p] 159 | 160 | 161 | def get_ruleset_file(ruleset=None): 162 | """ 163 | Get the ruleset file from name 164 | 165 | :param ruleset: str 166 | :return: str 167 | """ 168 | ruleset = ruleset or "default" 169 | 170 | ruleset_dirs = get_ruleset_dirs() 171 | for ruleset_directory in ruleset_dirs: 172 | possible_ruleset_files = [ 173 | os.path.join(ruleset_directory, ruleset + ext) for ext in EXTS 174 | ] 175 | 176 | for ruleset_file in possible_ruleset_files: 177 | if os.path.isfile(ruleset_file): 178 | logger.debug("Ruleset file '%s' found.", ruleset_file) 179 | return ruleset_file 180 | 181 | logger.warning( 182 | "Ruleset with the name '%s' cannot be found at '%s'.", ruleset, ruleset_dirs 183 | ) 184 | raise ColinRulesetException(f"Ruleset with the name '{ruleset}' cannot be found.") 185 | 186 | 187 | def get_ruleset_dirs(): 188 | """ 189 | Get the directory with ruleset files 190 | First directory to check: ./rulesets 191 | Second directory to check: $HOME/.local/share/colin/rulesets 192 | Third directory to check: /usr/local/share/colin/rulesets 193 | :return: str 194 | """ 195 | 196 | ruleset_dirs = [] 197 | 198 | cwd_rulesets = os.path.join(".", RULESET_DIRECTORY_NAME) 199 | if os.path.isdir(cwd_rulesets): 200 | logger.debug( 201 | "Ruleset directory found in current directory ('%s').", cwd_rulesets 202 | ) 203 | ruleset_dirs.append(cwd_rulesets) 204 | 205 | if "VIRTUAL_ENV" in os.environ: 206 | venv_local_share = os.path.join(os.environ["VIRTUAL_ENV"], RULESET_DIRECTORY) 207 | if os.path.isdir(venv_local_share): 208 | logger.debug( 209 | "Virtual env ruleset directory found ('%s').", venv_local_share 210 | ) 211 | ruleset_dirs.append(venv_local_share) 212 | 213 | local_share = os.path.join(os.path.expanduser("~"), ".local", RULESET_DIRECTORY) 214 | if os.path.isdir(local_share): 215 | logger.debug("Local ruleset directory found ('%s').", local_share) 216 | ruleset_dirs.append(local_share) 217 | 218 | usr_local_share = os.path.join("/usr/local", RULESET_DIRECTORY) 219 | if os.path.isdir(usr_local_share): 220 | logger.debug("Global ruleset directory found ('%s').", usr_local_share) 221 | ruleset_dirs.append(usr_local_share) 222 | 223 | if sys.prefix != "/usr/local": 224 | global_share = os.path.join(sys.prefix, RULESET_DIRECTORY) 225 | if os.path.isdir(global_share): 226 | logger.debug("Global ruleset directory found ('%s').", global_share) 227 | ruleset_dirs.append(global_share) 228 | 229 | if not ruleset_dirs: 230 | msg = "Ruleset directory cannot be found." 231 | logger.warning(msg) 232 | raise ColinRulesetException(msg) 233 | 234 | return ruleset_dirs 235 | 236 | 237 | def get_rulesets(): 238 | """ " 239 | Get available rulesets. 240 | """ 241 | rulesets_dirs = get_ruleset_dirs() 242 | ruleset_files = [] 243 | for rulesets_dir in rulesets_dirs: 244 | for f in os.listdir(rulesets_dir): 245 | for ext in EXTS: 246 | file_path = os.path.join(rulesets_dir, f) 247 | if os.path.isfile(file_path) and f.lower().endswith(ext): 248 | ruleset_files.append((f[: -len(ext)], file_path)) 249 | return ruleset_files 250 | -------------------------------------------------------------------------------- /colin/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/user-cont/colin/723bfc3dc906ecfefb38fec2d68d185e60456cc9/colin/utils/__init__.py -------------------------------------------------------------------------------- /colin/utils/caching_iterable.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | 3 | 4 | class CachingIterable: 5 | def __init__(self, iterable): 6 | self.iterable = iterable 7 | self.iter = iter(iterable) 8 | self.done = False 9 | self.vals = [] 10 | 11 | def __iter__(self): 12 | if self.done: 13 | return iter(self.vals) 14 | return itertools.chain(self.vals, self._gen_iter()) 15 | 16 | def _gen_iter(self): 17 | for new_val in self.iter: 18 | self.vals.append(new_val) 19 | yield new_val 20 | self.done = True 21 | -------------------------------------------------------------------------------- /colin/utils/cmd_tools.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | import functools 16 | import logging 17 | import subprocess 18 | import threading 19 | import time 20 | 21 | try: 22 | import thread 23 | except ImportError: 24 | import _thread as thread # type: ignore 25 | 26 | logger = logging.getLogger(__name__) 27 | 28 | 29 | def get_version_of_the_python_package(module): 30 | """ 31 | Return the str containing the name version and package location. 32 | 33 | :param module: module to show info about 34 | :return: str 'name version path' 35 | """ 36 | return ( 37 | f"{getattr(module, '__name__', None)} " 38 | f"{getattr(module, '__version__', None)} " 39 | f"{getattr(module, '__path__', [None])[0]}" 40 | ) 41 | 42 | 43 | def get_version_msg_from_the_cmd( 44 | package_name, cmd=None, use_rpm=None, max_lines_of_the_output=None 45 | ): 46 | """ 47 | Get str with the version (or string representation of the error). 48 | 49 | :param package_name: str 50 | :param cmd: str or [str] (defaults to [package_name, "--version"]) 51 | :param use_rpm: True/False/None (whether to use rpm -q for getting a version) 52 | :param max_lines_of_the_output: use first n lines of the output 53 | :return: str 54 | """ 55 | if use_rpm is None: 56 | use_rpm = is_rpm_installed() 57 | if use_rpm: 58 | rpm_version = get_rpm_version(package_name=package_name) 59 | if rpm_version: 60 | return f"{rpm_version} (rpm)" 61 | 62 | try: 63 | cmd = cmd or [package_name, "--version"] 64 | version_result = subprocess.run( 65 | cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE 66 | ) 67 | if version_result.returncode != 0: 68 | return f"{package_name}: cannot get version with {cmd}" 69 | version_output = version_result.stdout.decode().rstrip() 70 | if max_lines_of_the_output: 71 | version_output = " ".join( 72 | version_output.split("\n")[:max_lines_of_the_output] 73 | ) 74 | return version_output 75 | 76 | except FileNotFoundError: 77 | return f"{package_name} not accessible!" 78 | 79 | 80 | def get_rpm_version(package_name): 81 | """Get a version of the package with 'rpm -q' command.""" 82 | version_result = subprocess.run( 83 | ["rpm", "-q", package_name], stdout=subprocess.PIPE, stderr=subprocess.PIPE 84 | ) 85 | if version_result.returncode == 0: 86 | return version_result.stdout.decode().rstrip() 87 | else: 88 | return None 89 | 90 | 91 | def is_rpm_installed(): 92 | """Tests if the rpm command is present.""" 93 | try: 94 | version_result = subprocess.run( 95 | ["rpm", "--usage"], stdout=subprocess.PIPE, stderr=subprocess.PIPE 96 | ) 97 | rpm_installed = not version_result.returncode 98 | except FileNotFoundError: 99 | rpm_installed = False 100 | return rpm_installed 101 | 102 | 103 | def exit_after(s): 104 | """ 105 | Use as decorator to exit process if 106 | function takes longer than s seconds. 107 | 108 | Direct call is available via exit_after(TIMEOUT_IN_S)(fce)(args). 109 | 110 | Inspired by https://stackoverflow.com/a/31667005 111 | """ 112 | 113 | def outer(fn): 114 | def inner(*args, **kwargs): 115 | timer = threading.Timer(s, thread.interrupt_main) 116 | timer.start() 117 | try: 118 | result = fn(*args, **kwargs) 119 | except KeyboardInterrupt: 120 | raise TimeoutError(f"Function '{fn.__name__}' hit the timeout ({s}s).") 121 | finally: 122 | timer.cancel() 123 | return result 124 | 125 | return inner 126 | 127 | return outer 128 | 129 | 130 | def retry(retry_count=5, delay=2): 131 | """ 132 | Use as decorator to retry functions few times with delays 133 | 134 | Exception will be raised if last call fails 135 | 136 | :param retry_count: int could of retries in case of failures. It must be 137 | a positive number 138 | :param delay: int delay between retries 139 | """ 140 | if retry_count <= 0: 141 | raise ValueError("retry_count have to be positive") 142 | 143 | def decorator(f): 144 | @functools.wraps(f) 145 | def wrapper(*args, **kwargs): 146 | for i in range(retry_count, 0, -1): 147 | try: 148 | return f(*args, **kwargs) 149 | except Exception: 150 | if i <= 1: 151 | raise 152 | time.sleep(delay) 153 | 154 | return wrapper 155 | 156 | return decorator 157 | -------------------------------------------------------------------------------- /colin/utils/cont.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is a temporary module to support unpriv way of interacting with container images. 3 | 4 | """ 5 | import logging 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class ImageName: 11 | """parse image references and access their components easily""" 12 | 13 | def __init__( 14 | self, registry=None, namespace=None, repository=None, tag=None, digest=None 15 | ): 16 | self.registry = registry 17 | self.namespace = namespace 18 | self.repository = repository 19 | self.digest = digest 20 | self.tag = tag 21 | 22 | @classmethod 23 | def parse(cls, image_name): 24 | """ 25 | Get the instance of ImageName from the string representation. 26 | 27 | :param image_name: str (any possible form of image name) 28 | :return: ImageName instance 29 | """ 30 | result = cls() 31 | 32 | # registry.org/namespace/repo:tag 33 | s = image_name.split("/", 2) 34 | 35 | if len(s) == 2: 36 | if "." in s[0] or ":" in s[0]: 37 | result.registry = s[0] 38 | else: 39 | result.namespace = s[0] 40 | elif len(s) == 3: 41 | result.registry = s[0] 42 | result.namespace = s[1] 43 | result.repository = s[-1] 44 | 45 | try: 46 | result.repository, result.digest = result.repository.rsplit("@", 1) 47 | except ValueError: 48 | try: 49 | result.repository, result.tag = result.repository.rsplit(":", 1) 50 | except ValueError: 51 | result.tag = "latest" 52 | 53 | return result 54 | 55 | def __str__(self): 56 | return ( 57 | f"Image: registry='{self.registry}' namespace='{self.namespace}' " 58 | f"repository='{self.repository}' tag='{self.tag}' digest='{self.digest}'" 59 | ) 60 | 61 | @property 62 | def name(self): 63 | """ 64 | Get the string representation of the image 65 | (registry, namespace, repository and digest together). 66 | 67 | :return: str 68 | """ 69 | name_parts = [] 70 | if self.registry: 71 | name_parts.append(self.registry) 72 | 73 | if self.namespace: 74 | name_parts.append(self.namespace) 75 | 76 | if self.repository: 77 | name_parts.append(self.repository) 78 | name = "/".join(name_parts) 79 | 80 | if self.digest: 81 | name += f"@{self.digest}" 82 | elif self.tag: 83 | name += f":{self.tag}" 84 | 85 | return name 86 | -------------------------------------------------------------------------------- /colin/version.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | 16 | __version__ = "0.5.3" 17 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # 2 | import os 3 | import sys 4 | 5 | # If extensions (or modules to document with autodoc) are in another directory, 6 | # add these directories to sys.path here. If the directory is relative to the 7 | # documentation root, use os.path.abspath to make it absolute, like shown here. 8 | sys.path.insert(0, os.path.abspath(".")) 9 | mtf_dir = os.path.abspath("..") 10 | sys.path.insert(0, mtf_dir) 11 | 12 | # Determine if this script is running inside RTD build environment 13 | on_rtd = os.environ.get("READTHEDOCS", None) == "True" 14 | 15 | if not on_rtd: 16 | # Use RTD theme when building locally 17 | import sphinx_rtd_theme 18 | 19 | 20 | # -- General configuration ------------------------------------------------ 21 | 22 | # If your documentation needs a minimal Sphinx version, state it here. 23 | # needs_sphinx = '1.0' 24 | 25 | # Add any Sphinx extension module names here, as strings. They can be 26 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 27 | # ones. 28 | extensions = [ 29 | "sphinx.ext.autodoc", 30 | "sphinx.ext.doctest", 31 | "sphinx.ext.intersphinx", 32 | "sphinx.ext.todo", 33 | "sphinx.ext.coverage", 34 | "sphinx.ext.mathjax", 35 | "sphinx.ext.ifconfig", 36 | "sphinx.ext.viewcode", 37 | ] 38 | 39 | # Add any paths that contain templates here, relative to this directory. 40 | templates_path = ["_templates"] 41 | 42 | # The suffix of source filenames. 43 | source_suffix = ".rst" 44 | 45 | # The encoding of source files. 46 | # source_encoding = 'utf-8-sig' 47 | 48 | # The master toctree document. 49 | master_doc = "index" 50 | 51 | # General information about the project. 52 | project = "colin" 53 | copyright = "2017-2018, Red Hat" 54 | 55 | 56 | # List of patterns, relative to source directory, that match files and 57 | # directories to ignore when looking for source files. 58 | exclude_patterns = ["_build"] 59 | 60 | # The reST default role (used for this markup: `text`) to use for all 61 | # documents. 62 | # default_role = None 63 | 64 | # If true, '()' will be appended to :func: etc. cross-reference text. 65 | # add_function_parentheses = True 66 | 67 | # If true, the current module name will be prepended to all description 68 | # unit titles (such as .. function::). 69 | # add_module_names = True 70 | 71 | # If true, sectionauthor and moduleauthor directives will be shown in the 72 | # output. They are ignored by default. 73 | # show_authors = False 74 | 75 | # The name of the Pygments (syntax highlighting) style to use. 76 | pygments_style = "sphinx" 77 | 78 | # A list of ignored prefixes for module index sorting. 79 | # modindex_common_prefix = [] 80 | 81 | 82 | # -- Options for HTML output ---------------------------------------------- 83 | 84 | # The theme to use for HTML and HTML Help pages. See the documentation for 85 | # a list of builtin themes. 86 | html_theme = "default" if on_rtd else "sphinx_rtd_theme" 87 | 88 | # Theme options are theme-specific and customize the look and feel of a theme 89 | # further. For a list of options available for each theme, see the 90 | # documentation. 91 | # html_theme_options = {} 92 | 93 | # Add any paths that contain custom themes here, relative to this directory. 94 | html_theme_path = [] if on_rtd else [sphinx_rtd_theme.get_html_theme_path()] 95 | 96 | # The name for this set of Sphinx documents. If None, it defaults to 97 | # " v documentation". 98 | # html_title = None 99 | 100 | # A shorter title for the navigation bar. Default is the same as html_title. 101 | # html_short_title = None 102 | 103 | # The name of an image file (relative to this directory) to place at the top 104 | # of the sidebar. 105 | # html_logo = None 106 | 107 | # The name of an image file (within the static path) to use as favicon of the 108 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 109 | # pixels large. 110 | # html_favicon = None 111 | 112 | # Add any paths that contain custom static files (such as style sheets) here, 113 | # relative to this directory. They are copied after the builtin static files, 114 | # so a file named "default.css" will overwrite the builtin "default.css". 115 | # html_static_path = ['_static'] 116 | 117 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 118 | # using the given strftime format. 119 | # html_last_updated_fmt = '%b %d, %Y' 120 | 121 | # If true, SmartyPants will be used to convert quotes and dashes to 122 | # typographically correct entities. 123 | # html_use_smartypants = True 124 | 125 | # Custom sidebar templates, maps document names to template names. 126 | # html_sidebars = {} 127 | 128 | # Additional templates that should be rendered to pages, maps page names to 129 | # template names. 130 | # html_additional_pages = {} 131 | 132 | # If false, no module index is generated. 133 | # html_domain_indices = True 134 | 135 | # If false, no index is generated. 136 | # html_use_index = True 137 | 138 | # If true, the index is split into individual pages for each letter. 139 | # html_split_index = False 140 | 141 | # If true, links to the reST sources are added to the pages. 142 | # html_show_sourcelink = True 143 | 144 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 145 | # html_show_sphinx = True 146 | 147 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 148 | # html_show_copyright = True 149 | 150 | # If true, an OpenSearch description file will be output, and all pages will 151 | # contain a tag referring to it. The value of this option must be the 152 | # base URL from which the finished HTML is served. 153 | # html_use_opensearch = '' 154 | 155 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 156 | # html_file_suffix = None 157 | 158 | # Output file base name for HTML help builder. 159 | htmlhelp_basename = "ColinDoc" 160 | -------------------------------------------------------------------------------- /docs/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/user-cont/colin/723bfc3dc906ecfefb38fec2d68d185e60456cc9/docs/example.gif -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Colin 2 | ===== 3 | 4 | Welcome to the Colin documentation ! 5 | 6 | About 7 | ----- 8 | 9 | Colin is a tool to check generic rules/best-practices for containers/images/dockerfiles. 10 | 11 | Colin is a short cut for **CO**\ ntainer **LIN**\ ter 12 | 13 | CLI Usage 14 | --------- 15 | 16 | This is how you can use colin afterwards: 17 | 18 | .. code-block:: bash 19 | 20 | $ colin --help 21 | Usage: colin [OPTIONS] COMMAND [ARGS]... 22 | 23 | COLIN -- Container Linter 24 | 25 | Options: 26 | -V, --version Show the version and exit. 27 | -h, --help Show this message and exit. 28 | 29 | Commands: 30 | check Check the image/dockerfile (default). 31 | info Show info about colin and its dependencies. 32 | list-checks Print the checks. 33 | list-rulesets List available rulesets. 34 | 35 | Let's give it a shot: 36 | 37 | .. code-block:: bash 38 | 39 | $ colin -f ./rulesets/fedora.json registry.fedoraproject.org/f29/cockpit 40 | PASS:Label 'architecture' has to be specified. 41 | PASS:Label 'build-date' has to be specified. 42 | FAIL:Label 'description' has to be specified. 43 | PASS:Label 'distribution-scope' has to be specified. 44 | FAIL:Label 'help' has to be specified. 45 | FAIL:Label 'io.k8s.description' has to be specified. 46 | FAIL:Label 'url' has to be specified. 47 | PASS:Label 'vcs-ref' has to be specified. 48 | PASS:Label 'vcs-type' has to be specified. 49 | FAIL:Label 'vcs-url' has to be specified. 50 | PASS:Label 'com.redhat.component' has to be specified. 51 | FAIL:Label 'maintainer' has to be specified. 52 | PASS:Label 'name' has to be specified. 53 | PASS:Label 'release' has to be specified. 54 | FAIL:Label 'summary' has to be specified. 55 | PASS:Label 'version' has to be specified. 56 | FAIL:The 'helpfile' has to be provided. 57 | PASS:Label 'usage' has to be specified. 58 | 59 | PASS:10 FAIL:8 60 | 61 | 62 | Source code 63 | ----------- 64 | 65 | You may also wish to follow the `GitHub colin repo`_ if you have a GitHub account. This stores the source code and the issue tracker for sharing bugs and feature ideas. The repository should be forked into your personal GitHub account where all work will be done. Any changes should be submitted through the pull request process. 66 | 67 | .. _GitHub colin repo: https://github.com/user-cont/colin 68 | 69 | Content 70 | ======= 71 | 72 | .. toctree:: 73 | :maxdepth: 1 74 | 75 | python_api 76 | list_of_checks 77 | 78 | Index and Search 79 | ================ 80 | * :ref:`genindex` 81 | * :ref:`search` 82 | -------------------------------------------------------------------------------- /docs/list_of_checks.rst: -------------------------------------------------------------------------------- 1 | List of all checks 2 | ================== 3 | 4 | Colin checks several labels and the best practises (e.g. helpfile check). 5 | 6 | Since there can be many platforms/setups with different requirements, 7 | we can define so-called rulesets, that defines: 8 | 9 | - subset of checks to be used, 10 | - metadata changes/extensions. 11 | 12 | *Ruleset* is only a json/yaml file with following structure: 13 | 14 | .. code-block:: json 15 | 16 | { 17 | "version": "1", 18 | "name": "Ruleset for Fedora containers/images/dockerfiles.", 19 | "description": "This set of checks is defined by the Fedora Container Guidelines.", 20 | "contact_email": "user-cont-team@redhat.com", 21 | "checks": [ 22 | { 23 | "name": "architecture_label" 24 | }, 25 | { 26 | "name": "build-date_label" 27 | }, 28 | : 29 | : 30 | ] 31 | } 32 | 33 | 34 | Rulesets in the *standard* location can be shown with ``colin list-rulesets`` 35 | and we can use them by name in other commands. (e.g. ``colin check -r fedora``). 36 | 37 | .. code-block:: bash 38 | 39 | $ colin list-rulesets 40 | default (./rulesets/default.json) 41 | fedora (./rulesets/fedora.json) 42 | fedora (/home/flachman/.local/share/colin/rulesets/fedora.json) 43 | default (/home/flachman/.local/share/colin/rulesets/default.json) 44 | fedora (/usr/local/share/colin/rulesets/fedora.json) 45 | default (/usr/local/share/colin/rulesets/default.json) 46 | 47 | 48 | Colin can use ruleset-files in the following directories: 49 | 50 | - ``./rulesets/`` (subdirectory of the current working directory) 51 | - ``~/.local/share/colin/rulesets/`` (user installation) 52 | - ``/usr/local/share/colin/rulesets/`` (system-wide installation if `sys.prefix` is not `/usr/local`) 53 | - `sys.prefix`_\ ``/share/colin/rulesets/`` (system-wide installation) 54 | 55 | .. _sys.prefix: https://docs.python.org/3/library/sys.html?highlight=sys%20prefix#sys.prefix 56 | 57 | We can easily list the checks with the following command: 58 | 59 | .. code-block:: bash 60 | 61 | $ colin list-checks -f rulesets/fedora.json 62 | architecture_label 63 | -> Label 'architecture' has to be specified. 64 | -> Architecture the software in the image should target. (Optional: if omitted, it will be built for all supported Fedora Architectures) 65 | -> https://fedoraproject.org/wiki/Container:Guidelines#LABELS 66 | -> label, architecture 67 | 68 | build-date_label 69 | -> Label 'build-date' has to be specified. 70 | -> Date/Time image was built as RFC 3339 date-time. 71 | -> https://github.com/projectatomic/ContainerApplicationGenericLabels/blob/master/vendor/redhat/labels.md 72 | -> label, build-date 73 | 74 | description_label 75 | -> Label 'description' has to be specified. 76 | -> Detailed description of the image. 77 | -> https://github.com/projectatomic/ContainerApplicationGenericLabels/blob/master/vendor/redhat/labels.md 78 | -> label, description 79 | 80 | distribution-scope_label 81 | -> Label 'distribution-scope' has to be specified. 82 | -> Scope of intended distribution of the image. (private/authoritative-source-only/restricted/public) 83 | -> https://github.com/projectatomic/ContainerApplicationGenericLabels/blob/master/vendor/redhat/labels.md 84 | -> label, distribution-scope 85 | 86 | help_label 87 | -> Label 'help' has to be specified. 88 | -> A runnable command which results in display of Help information. 89 | -> https://fedoraproject.org/wiki/Container:Guidelines#LABELS 90 | -> label, help 91 | 92 | io.k8s.description_label 93 | -> Label 'io.k8s.description' has to be specified. 94 | -> Description of the container displayed in Kubernetes 95 | -> ['https://github.com/projectatomic/ContainerApplicationGenericLabels/blob/master/vendor/redhat/labels.md', 'https://github.com/projectatomic/ContainerApplicationGenericLabels/blob/master/vendor/redhat/labels.md#other-labels'] 96 | -> label, io.k8s.description, description 97 | 98 | url_label 99 | -> Label 'url' has to be specified. 100 | -> A URL where the user can find more information about the image. 101 | -> https://fedoraproject.org/wiki/Container:Guidelines#LABELS 102 | -> label, url 103 | 104 | vcs-ref_label 105 | -> Label 'vcs-ref' has to be specified. 106 | -> A 'reference' within the version control repository; e.g. a git commit, or a subversion branch. 107 | -> https://github.com/projectatomic/ContainerApplicationGenericLabels/blob/master/vendor/redhat/labels.md 108 | -> label, vcs-ref, vcs 109 | 110 | vcs-type_label 111 | -> Label 'vcs-type' has to be specified. 112 | -> The type of version control used by the container source. Generally one of git, hg, svn, bzr, cvs 113 | -> https://github.com/projectatomic/ContainerApplicationGenericLabels/blob/master/vendor/redhat/labels.md 114 | -> label, vcs-type, vcs 115 | 116 | vcs-url_label 117 | -> Label 'vcs-url' has to be specified. 118 | -> URL of the version control repository. 119 | -> https://github.com/projectatomic/ContainerApplicationGenericLabels/blob/master/vendor/redhat/labels.md 120 | -> label, vcs-url, vcs 121 | 122 | com.redhat.component_label 123 | -> Label 'com.redhat.component' has to be specified. 124 | -> The Bugzilla component name where bugs against this container should be reported by users. 125 | -> https://fedoraproject.org/wiki/Container:Guidelines#LABELS 126 | -> label, com.redhat.component, required 127 | 128 | maintainer_label 129 | -> Label 'maintainer' has to be specified. 130 | -> The name and email of the maintainer (usually the submitter). 131 | -> https://fedoraproject.org/wiki/Container:Guidelines#LABELS 132 | -> label, maintainer, required 133 | 134 | name_label 135 | -> Label 'name' has to be specified. 136 | -> Name of the Image or Container. 137 | -> https://fedoraproject.org/wiki/Container:Guidelines#LABELS 138 | -> label, name, required 139 | 140 | release_label 141 | -> Label 'release' has to be specified. 142 | -> Release Number for this version. 143 | -> https://fedoraproject.org/wiki/Container:Guidelines#LABELS 144 | -> label, release, required 145 | 146 | summary_label 147 | -> Label 'summary' has to be specified. 148 | -> A short description of the image. 149 | -> https://fedoraproject.org/wiki/Container:Guidelines#LABELS 150 | -> label, summary, required 151 | 152 | version_label 153 | -> Label 'version' has to be specified. 154 | -> Version of the image. 155 | -> https://fedoraproject.org/wiki/Container:Guidelines#LABELS 156 | -> label, version, required 157 | 158 | from_tag_not_latest 159 | -> In FROM, tag has to be specified and not 'latest'. 160 | -> Using the 'latest' tag may cause unpredictable builds.It is recommended that a specific tag is used in the FROM. 161 | -> https://fedoraproject.org/wiki/Container:Guidelines#FROM 162 | -> dockerfile, from, baseimage, latest, required 163 | 164 | maintainer_deprecated 165 | -> Dockerfile instruction `MAINTAINER` is deprecated. 166 | -> Replace with label 'maintainer'. 167 | -> https://docs.docker.com/engine/reference/builder/#maintainer-deprecated 168 | -> dockerfile, maintainer, deprecated, required 169 | 170 | description_or_io.k8s.description_label 171 | -> Label 'description' or 'io.k8s.description' has to be specified. 172 | -> Detailed description of the image. 173 | -> https://github.com/projectatomic/ContainerApplicationGenericLabels/blob/master/vendor/redhat/labels.md 174 | -> label, description, required 175 | 176 | help_file_or_readme 177 | -> The 'helpfile' has to be provided. 178 | -> Just like traditional packages, containers need some 'man page' information about how they are to be used, configured, and integrated into a larger stack. 179 | -> https://fedoraproject.org/wiki/Container:Guidelines#Help_File 180 | -> filesystem, helpfile, man, required 181 | 182 | run_or_usage_label 183 | -> Label 'usage' has to be specified. 184 | -> A human readable example of container execution. 185 | -> https://fedoraproject.org/wiki/Container:Guidelines#LABELS 186 | -> label, usage, required 187 | -------------------------------------------------------------------------------- /docs/python_api.rst: -------------------------------------------------------------------------------- 1 | Usage of colin in Python code 2 | ========================== 3 | 4 | The colin CLI is only a wrapper around the colin's python library. 5 | 6 | All functionality can be accessed directly from the python code: 7 | 8 | Module colin.core.colin 9 | ======================= 10 | 11 | Functions 12 | --------- 13 | 14 | `get_checks(target_type=None, tags=None, ruleset_name=None, ruleset_file=None, ruleset=None, logging_level=30, checks_paths=None, skips=None)` 15 | : Get the sanity checks for the target. 16 | 17 | :param skips: name of checks to skip 18 | :param target_type: TargetType enum 19 | :param tags: list of str (if not None, the checks will be filtered by tags.) 20 | :param ruleset_name: str (e.g. fedora; if None, default would be used) 21 | :param ruleset_file: fileobj instance holding ruleset configuration 22 | :param ruleset: dict, content of a ruleset file 23 | :param logging_level: logging level (default logging.WARNING) 24 | :param checks_paths: list of str, directories where the checks are present 25 | :return: list of check instances 26 | 27 | `run(target, target_type, tags=None, ruleset_name=None, ruleset_file=None, ruleset=None, logging_level=30, checks_paths=None, pull=None, insecure=False, skips=None, timeout=None)` 28 | : Runs the sanity checks for the target. 29 | 30 | :param timeout: timeout per-check (in seconds) 31 | :param skips: name of checks to skip 32 | :param target: str (image name, oci or dockertar) 33 | or ImageTarget 34 | or path/file-like object for dockerfile 35 | :param target_type: string, either image, dockerfile, dockertar 36 | :param tags: list of str (if not None, the checks will be filtered by tags.) 37 | :param ruleset_name: str (e.g. fedora; if None, default would be used) 38 | :param ruleset_file: fileobj instance holding ruleset configuration 39 | :param ruleset: dict, content of a ruleset file 40 | :param logging_level: logging level (default logging.WARNING) 41 | :param checks_paths: list of str, directories where the checks are present 42 | :param pull: bool, pull the image from registry 43 | :param insecure: bool, pull from an insecure registry (HTTP/invalid TLS) 44 | :return: Results instance 45 | -------------------------------------------------------------------------------- /files/packit-testing-farm-prepare.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: This is a recipe for preparing the environment when running tests inside testing-farm 3 | hosts: all 4 | tasks: 5 | - include_tasks: tasks/generic-dnf-requirements.yaml 6 | - include_tasks: tasks/python-compile-deps.yaml 7 | - include_tasks: tasks/rpm-test-deps.yaml 8 | -------------------------------------------------------------------------------- /files/tasks/generic-dnf-requirements.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Install generic RPM packages 3 | dnf: 4 | name: 5 | - make 6 | - git 7 | - dnf-utils 8 | - python3-pip 9 | become: true 10 | -------------------------------------------------------------------------------- /files/tasks/python-compile-deps.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Install specific RPMs to be able to use PIP installation 3 | dnf: 4 | name: 5 | - python3-devel 6 | become: true 7 | -------------------------------------------------------------------------------- /files/tasks/rpm-test-deps.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Install test dependencies 3 | dnf: 4 | name: 5 | - python3-setuptools 6 | - python3-pytest 7 | - python3-pytest-cov 8 | - python3-pyxattr 9 | - moby-engine 10 | - podman 11 | - buildah 12 | - skopeo 13 | - runc 14 | - wget 15 | state: present 16 | become: true 17 | - name: Download umoci 18 | command: 19 | cmd: wget -O /usr/local/bin/umoci "https://github.com/opencontainers/umoci/releases/download/v0.4.7/umoci.amd64" 20 | - name: Make umoci executable 21 | file: dest=/usr/local/bin/umoci mode=a+x 22 | -------------------------------------------------------------------------------- /plans/README.md: -------------------------------------------------------------------------------- 1 | Run tests locally: 2 | 3 | $ tmt -v run -a provision -h local 4 | 5 | Run tests in a container using podman: 6 | 7 | $ sudo dnf install tmt-provision-container 8 | $ tmt -v run -a provision -h container 9 | 10 | For more info see: 11 | 12 | - https://packit.dev/docs/testing-farm/ 13 | - [tmt @ DevConf 2021 slides](https://static.sched.com/hosted_files/devconfcz2021/37/tmt-slides.pdf) 14 | - [fmf docs](https://fmf.readthedocs.io) 15 | - [tmt docs](https://tmt.readthedocs.io) 16 | -------------------------------------------------------------------------------- /plans/full.fmf: -------------------------------------------------------------------------------- 1 | summary: 2 | Unit & integration tests. 3 | prepare: 4 | how: ansible 5 | playbooks: 6 | - files/packit-testing-farm-prepare.yaml 7 | execute: 8 | script: 9 | - make exec-test 10 | -------------------------------------------------------------------------------- /plans/linters.fmf: -------------------------------------------------------------------------------- 1 | summary: 2 | Run linters on source code and packaging files 3 | prepare: 4 | - name: packages 5 | how: install 6 | package: 7 | - rpmlint 8 | execute: 9 | script: 10 | - rpmlint colin.spec 11 | -------------------------------------------------------------------------------- /plans/rulesets.fmf: -------------------------------------------------------------------------------- 1 | summary: 2 | Basic smoke test. 3 | execute: 4 | script: 5 | - sh -c "cd / && colin list-rulesets" 6 | - sh -c "cd / && colin list-checks" 7 | -------------------------------------------------------------------------------- /plans/smoke.fmf: -------------------------------------------------------------------------------- 1 | summary: 2 | Basic smoke test. 3 | execute: 4 | script: 5 | - colin --version 6 | - colin --help 7 | -------------------------------------------------------------------------------- /rulesets/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1", 3 | "name": "Default ruleset for checking containers/images/dockerfiles.", 4 | "description": "This set contains general checks applicable to any target.", 5 | "contact_email": "user-cont-team@redhat.com", 6 | "checks": [ 7 | { 8 | "name": "maintainer_label", 9 | "additional_tags": ["required"] 10 | }, 11 | { 12 | "name": "from_tag_not_latest", 13 | "additional_tags": ["required"], 14 | "usable_targets": ["dockerfile"] 15 | }, 16 | { 17 | "name": "maintainer_deprecated", 18 | "additional_tags": ["required"], 19 | "usable_targets": ["dockerfile"] 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /rulesets/fedora.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1", 3 | "name": "Ruleset for Fedora containers/images/dockerfiles.", 4 | "description": "This set of checks is defined by the Fedora Container Guidelines.", 5 | "contact_email": "user-cont-team@redhat.com", 6 | "checks": [ 7 | { 8 | "name": "architecture_label" 9 | }, 10 | { 11 | "name": "build-date_label" 12 | }, 13 | { 14 | "name": "description_label" 15 | }, 16 | { 17 | "name": "distribution-scope_label" 18 | }, 19 | { 20 | "name": "help_label" 21 | }, 22 | { 23 | "name": "io.k8s.description_label" 24 | }, 25 | { 26 | "name": "url_label" 27 | }, 28 | { 29 | "name": "vcs-ref_label" 30 | }, 31 | { 32 | "name": "vcs-type_label" 33 | }, 34 | { 35 | "name": "vcs-url_label" 36 | }, 37 | { 38 | "names": [ 39 | "com.redhat.component_label", 40 | "maintainer_label", 41 | "name_label", 42 | "release_label", 43 | "summary_label", 44 | "version_label" 45 | ], 46 | "additional_tags": ["required"] 47 | }, 48 | { 49 | "names": [ 50 | "from_tag_not_latest", 51 | "maintainer_deprecated", 52 | "description_or_io.k8s.description_label" 53 | ], 54 | "additional_tags": ["required"], 55 | "usable_targets": ["dockerfile"] 56 | }, 57 | { 58 | "names": ["help_file_or_readme", "run_or_usage_label"], 59 | "additional_tags": ["required"], 60 | "usable_targets": ["image", "container"] 61 | }, 62 | { 63 | "names": ["inherited_labels"], 64 | "labels_list": [ 65 | "summary", 66 | "description", 67 | "io.k8s.description", 68 | "io.k8s.display-name", 69 | "io.openshift.tags" 70 | ], 71 | "additional_tags": ["required"], 72 | "usable_targets": ["image"] 73 | } 74 | ] 75 | } 76 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | # 16 | 17 | import os 18 | from typing import Any 19 | 20 | from setuptools import find_packages, setup 21 | 22 | BASE_PATH = os.path.dirname(__file__) 23 | 24 | # https://packaging.python.org/guides/single-sourcing-package-version/ 25 | version: Any = {} 26 | with open("./colin/version.py") as fp: 27 | exec(fp.read(), version) 28 | 29 | long_description = "".join(open("README.md").readlines()) 30 | 31 | setup( 32 | name="colin", 33 | version=version["__version__"], 34 | description="Tool to check generic rules/best-practices for containers/images/dockerfiles.", 35 | long_description=long_description, 36 | long_description_content_type="text/markdown", 37 | packages=find_packages(exclude=["examples", "tests"]), 38 | install_requires=["Click", "six", "dockerfile_parse", "fmf", "PyYAML"], 39 | entry_points=""" 40 | [console_scripts] 41 | colin=colin.cli.colin:cli 42 | """, 43 | data_files=[ 44 | ("share/colin/rulesets/", ["rulesets/default.json", "rulesets/fedora.json"]), 45 | ("share/bash-completion/completions/", ["bash-complete/colin"]), 46 | ], 47 | license="GPLv3+", 48 | python_requires=">=3.6", 49 | classifiers=[ 50 | "Development Status :: 4 - Beta", 51 | "Environment :: Console", 52 | "Intended Audience :: Developers", 53 | "License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)", 54 | "Operating System :: POSIX :: Linux", 55 | "Programming Language :: Python", 56 | "Programming Language :: Python :: 3.6", 57 | "Programming Language :: Python :: 3.7", 58 | "Programming Language :: Python :: 3.8", 59 | "Programming Language :: Python :: 3 :: Only", 60 | "Topic :: Software Development", 61 | "Topic :: Software Development :: Quality Assurance", 62 | "Topic :: Utilities", 63 | ], 64 | keywords="containers,sanity,linter", 65 | author="Red Hat", 66 | author_email="user-cont-team@redhat.com", 67 | url="https://github.com/user-cont/colin", 68 | package_data={"": ["*.fmf"]}, 69 | include_package_data=True, 70 | ) 71 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/user-cont/colin/723bfc3dc906ecfefb38fec2d68d185e60456cc9/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import shutil 4 | import subprocess 5 | import tempfile 6 | 7 | import pytest 8 | 9 | from colin.core.colin import _set_logging 10 | from colin.core.target import ImageTarget, OciTarget, DockerfileTarget 11 | 12 | _set_logging(level=logging.DEBUG) 13 | 14 | BASH_IMAGE = "colin-test-bash" 15 | LS_IMAGE = "colin-test-ls" 16 | BUSYBOX_IMAGE = "busybox:latest" 17 | LABELS_IMAGE = "colin-labels" 18 | LABELS_IMAGE_PARENT = "colin-labels-parent" 19 | IMAGES = { 20 | BASH_IMAGE: {"dockerfile_path": "Dockerfile-bash"}, 21 | LS_IMAGE: {"dockerfile_path": "Dockerfile-ls"}, 22 | LABELS_IMAGE: {"dockerfile_path": "Dockerfile"}, 23 | LABELS_IMAGE_PARENT: {"dockerfile_path": "Dockerfile-parent"}, 24 | } 25 | 26 | 27 | def build_image_if_not_exists(image_name): 28 | try: 29 | subprocess.check_call(["podman", "image", "exists", image_name]) 30 | except subprocess.CalledProcessError: 31 | this_dir = os.path.abspath(os.path.dirname(__file__)) 32 | data_dir = os.path.join(this_dir, "data") 33 | 34 | dockerfile_path = IMAGES[image_name]["dockerfile_path"] 35 | cmd_create = [ 36 | "podman", 37 | "build", 38 | "-t", 39 | image_name, 40 | "-f", 41 | dockerfile_path, 42 | data_dir, 43 | ] 44 | subprocess.check_call(cmd_create) 45 | 46 | 47 | def pull_image_if_not_exists(image_name): 48 | try: 49 | subprocess.check_call(["podman", "image", "exists", image_name]) 50 | except subprocess.CalledProcessError: 51 | subprocess.check_call(["podman", "pull", image_name]) 52 | 53 | 54 | def convert_image_to_oci(image_name): 55 | tmpdir_path = tempfile.mkdtemp(prefix="pytest-", dir="/var/tmp") 56 | oci_path = os.path.join(tmpdir_path, "oci") 57 | os.makedirs(oci_path) 58 | skopeo_target = get_skopeo_oci_target(image_name=image_name, oci_path=oci_path) 59 | 60 | cmd = ["podman", "push", image_name, skopeo_target] 61 | subprocess.check_call(cmd) 62 | return oci_path 63 | 64 | 65 | def get_target(name, type): 66 | if type == "image": 67 | target = ImageTarget(target=name, pull=False) 68 | yield target 69 | target.clean_up() 70 | 71 | elif type == "oci": 72 | oci_path = convert_image_to_oci(name) 73 | skopeo_target = get_skopeo_oci_target(image_name=name, oci_path=oci_path) 74 | 75 | oci_target = OciTarget(target=skopeo_target) 76 | yield oci_target 77 | oci_target.clean_up() 78 | shutil.rmtree(oci_path) 79 | 80 | elif type == "dockerfile": 81 | this_dir = os.path.abspath(os.path.dirname(__file__)) 82 | data_dir = os.path.join(this_dir, "data") 83 | dockerfile_path = os.path.join(data_dir, IMAGES[name]["dockerfile_path"]) 84 | 85 | yield DockerfileTarget(target=dockerfile_path) 86 | 87 | 88 | def get_skopeo_oci_target(image_name, oci_path): 89 | return f"oci:{oci_path}:{image_name}" 90 | 91 | 92 | @pytest.fixture(scope="session", autouse=True) 93 | def label_image(): 94 | build_image_if_not_exists(LABELS_IMAGE) 95 | 96 | 97 | @pytest.fixture(scope="session", params=["image", "oci", "dockerfile"]) 98 | def target_label(request, label_image): 99 | yield from get_target(name=LABELS_IMAGE, type=request.param) 100 | 101 | 102 | @pytest.fixture(scope="session", params=["image", "oci", "dockerfile"]) 103 | def target_label_image_and_dockerfile(request, label_image): 104 | yield from get_target(name=LABELS_IMAGE, type=request.param) 105 | 106 | 107 | @pytest.fixture(scope="session", autouse=True) 108 | def target_bash_image(): 109 | build_image_if_not_exists(BASH_IMAGE) 110 | 111 | 112 | @pytest.fixture(scope="session", params=["image", "oci"]) 113 | def target_bash(request, target_bash_image): 114 | yield from get_target(name=BASH_IMAGE, type=request.param) 115 | 116 | 117 | @pytest.fixture(scope="session", autouse=True) 118 | def target_ls_image(): 119 | build_image_if_not_exists(LS_IMAGE) 120 | 121 | 122 | @pytest.fixture(scope="session", params=["image", "oci"]) 123 | def target_ls(request, target_ls_image): 124 | yield from get_target(name=LS_IMAGE, type=request.param) 125 | 126 | 127 | @pytest.fixture(scope="session", params=[LS_IMAGE, BASH_IMAGE]) 128 | def target_help_file(request, target_ls, target_bash): 129 | if request.param == LS_IMAGE: 130 | return target_ls, False 131 | if request.param == BASH_IMAGE: 132 | return target_bash, True 133 | 134 | 135 | @pytest.fixture(scope="session", autouse=True) 136 | def target_busybox_image(): 137 | pull_image_if_not_exists(image_name=BUSYBOX_IMAGE) 138 | 139 | 140 | @pytest.fixture(scope="session", params=["image", "oci"]) 141 | def target_busybox(request, target_busybox_image): 142 | yield from get_target(name=BUSYBOX_IMAGE, type=request.param) 143 | -------------------------------------------------------------------------------- /tests/data/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | 3 | ENV NAME=colin-labels \ 4 | VERSION=0.0.4 5 | 6 | LABEL name="${NAME}" \ 7 | summary="Colin image used for testing Dockerfile labels" \ 8 | maintainer="Petr Hracek " \ 9 | version="${VERSION}" \ 10 | com.redhat.component="colin-labels" \ 11 | description="The image contains labels which are used for testing colin functionality." \ 12 | io.k8s.description="The image contains labels which are used for testing colin functionality." \ 13 | run="docker run " \ 14 | url="https://project.example.com/" 15 | 16 | COPY files/usage /files/usage 17 | 18 | CMD ["/files/usage"] 19 | -------------------------------------------------------------------------------- /tests/data/Dockerfile-bash: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | COPY files/usage README.md 3 | -------------------------------------------------------------------------------- /tests/data/Dockerfile-ls: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | COPY files/usage usage 3 | LABEL name=ls 4 | ENTRYPOINT ["/bin/ls"] 5 | CMD ["/"] 6 | -------------------------------------------------------------------------------- /tests/data/Dockerfile-parent: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | 3 | ENV NAME=colin-labels-parent \ 4 | VERSION=0.0.4 5 | 6 | LABEL name="${NAME}" \ 7 | summary="Colin image used for testing Dockerfile labels for parent" \ 8 | maintainer="Petr Hracek " \ 9 | version="${VERSION}" \ 10 | com.redhat.component="colin-labels" \ 11 | description="The image contains labels which are used for testing colin functionality for parent." \ 12 | io.k8s.description="The image contains labels which are used for testing colin functionality for parent." \ 13 | run="docker run " \ 14 | url="https://project.example.com/" 15 | 16 | COPY files/usage /files/usage 17 | 18 | CMD ["/files/usage"] 19 | -------------------------------------------------------------------------------- /tests/data/a_check/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | 16 | from colin.core.checks.abstract_check import AbstractCheck 17 | 18 | 19 | class FunkyCheck(AbstractCheck): 20 | name = "this-is-a-funky-check" 21 | 22 | def __init__(self): 23 | super().__init__( 24 | message="yes!", 25 | description="no", 26 | reference_url="https://nope.example.com/", 27 | tags=["yes", "and", "no"], 28 | ) 29 | 30 | 31 | class ThisIsNotAChekk(AbstractCheck): 32 | name = "this-is-not-a-check" 33 | 34 | def __init__(self): 35 | super().__init__( 36 | message="yes!", 37 | description="no", 38 | reference_url="https://nope.example.com/", 39 | tags=["yes", "and", "no"], 40 | ) 41 | 42 | 43 | class ThisIsAlsoNotAChekk: 44 | name = "this-is-also-not-a-check" 45 | 46 | def __init__(self): 47 | super().__init__( 48 | message="yes!", 49 | description="no", 50 | reference_url="https://nope.example.com/", 51 | tags=["yes", "and", "no"], 52 | ) 53 | -------------------------------------------------------------------------------- /tests/data/a_check/another_checks.py: -------------------------------------------------------------------------------- 1 | from colin.core.checks.filesystem import FileCheck 2 | 3 | 4 | class PeterFileCheck(FileCheck): 5 | """ 6 | reference: http://theitcrowd.wikia.com/wiki/Peter_File 7 | """ 8 | 9 | name = "a-peter-file-check" 10 | -------------------------------------------------------------------------------- /tests/data/files/usage: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | cat < 8 | EOF 9 | -------------------------------------------------------------------------------- /tests/data/lol-ruleset.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1", 3 | "name": "Laughing out loud ruleset", 4 | "description": "This set of checks is required to pass because we said it", 5 | "contact_email": "forgot-to-reply@example.nope", 6 | "checks": [ 7 | { 8 | "name": "name_label", 9 | "additional_tags": ["fuj", "bar"], 10 | "usable_targets": ["image", "dockerfile"] 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /tests/data/lol-ruleset.yaml: -------------------------------------------------------------------------------- 1 | checks: 2 | - additional_tags: [fuj, bar] 3 | name: name_label 4 | usable_targets: [image, dockerfile] 5 | contact_email: forgot-to-reply@example.nope 6 | description: This set of checks is required to pass because we said it 7 | name: Laughing out loud ruleset 8 | version: "1" 9 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/user-cont/colin/723bfc3dc906ecfefb38fec2d68d185e60456cc9/tests/integration/__init__.py -------------------------------------------------------------------------------- /tests/integration/test_cont.py: -------------------------------------------------------------------------------- 1 | """ 2 | Testing of low level interaction with images. 3 | """ 4 | import pytest 5 | 6 | 7 | def test_file_is_present(target_busybox): 8 | assert target_busybox.file_is_present("/etc/passwd") 9 | assert not target_busybox.file_is_present("/oglogoblogologlo") 10 | with pytest.raises(IOError): 11 | target_busybox.file_is_present("/etc") 12 | 13 | 14 | def test_labels_are_present(target_label): 15 | assert isinstance(target_label.labels, dict) 16 | -------------------------------------------------------------------------------- /tests/integration/test_dockerfile.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | import pytest 16 | import six 17 | 18 | import colin 19 | 20 | 21 | @pytest.fixture() 22 | def dockerfile_with_missing_from(): 23 | return six.StringIO("FROMM base\nENV a=b\nLABEL c=d\n") 24 | 25 | 26 | @pytest.fixture() 27 | def ruleset_from_tag_not_latest(): 28 | return { 29 | "version": "1", 30 | "name": "Laughing out loud ruleset", 31 | "description": "This set of checks is required to pass because we said it", 32 | "contact_email": "forgot-to-reply@example.nope", 33 | "checks": [ 34 | {"name": "from_tag_not_latest"}, 35 | ], 36 | } 37 | 38 | 39 | def test_missing_from(dockerfile_with_missing_from, ruleset_from_tag_not_latest): 40 | result = colin.run( 41 | target=dockerfile_with_missing_from, 42 | target_type="dockerfile", 43 | ruleset=ruleset_from_tag_not_latest, 44 | ) 45 | assert result 46 | assert not result.ok 47 | assert result.results_per_check["from_tag_not_latest"] 48 | assert not result.results_per_check["from_tag_not_latest"].ok 49 | assert result.results_per_check["from_tag_not_latest"].status == "ERROR" 50 | assert ( 51 | result.results_per_check["from_tag_not_latest"].logs[0] 52 | == "Cannot find FROM instruction." 53 | ) 54 | -------------------------------------------------------------------------------- /tests/integration/test_dynamic_checks.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | import pytest 16 | 17 | 18 | @pytest.fixture() 19 | def ruleset(): 20 | return { 21 | "version": "1", 22 | "name": "Laughing out loud ruleset", 23 | "description": "This set of checks is required to pass because we said it", 24 | "contact_email": "forgot-to-reply@example.nope", 25 | "checks": [{"name": "shell_runnable"}], 26 | } 27 | 28 | 29 | # def test_dynamic_check_ls(ruleset): 30 | # results = colin.run(target=LS_IMAGE, ruleset=ruleset, logging_level=10) 31 | # assert not results.ok 32 | # 33 | # 34 | # def test_dynamic_check_bash(ruleset): 35 | # results = colin.run(target=BASH_IMAGE, ruleset=ruleset, logging_level=10) 36 | # assert results.ok 37 | -------------------------------------------------------------------------------- /tests/integration/test_fs_checks.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | import pytest 16 | 17 | import colin 18 | 19 | 20 | @pytest.fixture() 21 | def ruleset(): 22 | """simple ruleset as a pytest fixture""" 23 | return { 24 | "version": "1", 25 | "name": "Laughing out loud ruleset", 26 | "description": "This set of checks is required to pass because we said it", 27 | "contact_email": "forgot-to-reply@example.nope", 28 | "checks": [{"name": "help_file_or_readme"}], 29 | } 30 | 31 | 32 | def test_help_file_or_readme_bash(ruleset, target_bash): 33 | help_file_or_readme_test(ruleset=ruleset, image=target_bash, should_pass=True) 34 | 35 | 36 | def test_help_file_or_readme_ls(ruleset, target_ls): 37 | help_file_or_readme_test(ruleset=ruleset, image=target_ls, should_pass=False) 38 | 39 | 40 | def help_file_or_readme_test(ruleset, image, should_pass): 41 | """verify that help_file_or_readme check works well""" 42 | results = colin.run( 43 | target=image.target_name, 44 | target_type=image.target_type, 45 | ruleset=ruleset, 46 | logging_level=10, 47 | pull=False, 48 | ) 49 | assert results.ok 50 | assert results.fail is not should_pass 51 | -------------------------------------------------------------------------------- /tests/integration/test_labels.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | import logging 16 | 17 | import pytest 18 | 19 | import colin 20 | from colin.checks.labels import RunOrUsageLabelCheck 21 | from colin.core.target import DockerfileTarget 22 | from tests.conftest import get_target, LABELS_IMAGE_PARENT, build_image_if_not_exists 23 | 24 | 25 | @pytest.fixture() 26 | def empty_ruleset(): 27 | return { 28 | "version": "1", 29 | "name": "Laughing out loud ruleset", 30 | "description": "This set of checks is required to pass because we said it", 31 | "contact_email": "forgot-to-reply@example.nope", 32 | "checks": [], 33 | } 34 | 35 | 36 | def get_results_from_colin_labels_image(target, parent_target=None): 37 | return colin.run( 38 | target=target.target_name, 39 | parent_target=parent_target, 40 | target_type=target.target_type, 41 | ruleset_name="fedora", 42 | logging_level=logging.DEBUG, 43 | pull=False, 44 | ) 45 | 46 | 47 | @pytest.mark.parametrize("same_parent_target", [None, True, False]) 48 | def test_labels_in_image(target_label, same_parent_target): 49 | parent = None 50 | if same_parent_target: 51 | parent = target_label.target_name 52 | elif same_parent_target is False: 53 | if not isinstance(target_label, DockerfileTarget): 54 | build_image_if_not_exists(LABELS_IMAGE_PARENT) 55 | parent_gen = get_target(LABELS_IMAGE_PARENT, target_label.target_type) 56 | parent_target = next(parent_gen) 57 | parent = parent_target.target_name 58 | 59 | result = get_results_from_colin_labels_image( 60 | target=target_label, parent_target=parent 61 | ) 62 | assert result 63 | expected_dict = { 64 | "maintainer_label": "PASS", 65 | "name_label": "PASS", 66 | "com.redhat.component_label": "PASS", 67 | "summary_label": "PASS", 68 | "version_label": "PASS", 69 | "run_or_usage_label": "PASS", 70 | "release_label": "FAIL", 71 | "architecture_label": "FAIL", 72 | "url_label": "PASS", 73 | "help_label": "FAIL", 74 | "build-date_label": "FAIL", 75 | "distribution-scope_label": "FAIL", 76 | "vcs-ref_label": "FAIL", 77 | "vcs-type_label": "FAIL", 78 | "description_label": "PASS", 79 | "io.k8s.description_label": "PASS", 80 | "vcs-url_label": "FAIL", 81 | "help_file_or_readme": "FAIL", 82 | "inherited_labels": "PASS", 83 | # "cmd_or_entrypoint": "PASS", 84 | # "no_root": "FAIL", 85 | } 86 | if same_parent_target: 87 | expected_dict["inherited_labels"] = "FAIL" 88 | 89 | if isinstance(target_label, DockerfileTarget): 90 | expected_dict.update( 91 | { 92 | "description_or_io.k8s.description_label": "PASS", 93 | "from_tag_not_latest": "FAIL", 94 | "maintainer_deprecated": "PASS", 95 | } 96 | ) 97 | del expected_dict["help_file_or_readme"] 98 | del expected_dict["run_or_usage_label"] 99 | del expected_dict["inherited_labels"] 100 | labels_dict = {res.check_name: res.status for res in result.results} 101 | assert labels_dict == expected_dict 102 | 103 | 104 | @pytest.mark.parametrize( 105 | "labels, should_pass", 106 | [ 107 | (["run"], True), 108 | (["usage"], False), 109 | (["run", "usage"], True), 110 | (["something", "different"], False), 111 | (["something", "completely", "different"], False), 112 | ], 113 | ) 114 | def test_multiple_labels_check(labels, should_pass, target_label): 115 | check = RunOrUsageLabelCheck() 116 | check.labels = labels 117 | 118 | result = check.check(target_label) 119 | 120 | assert result.ok == should_pass 121 | -------------------------------------------------------------------------------- /tests/integration/test_ruleset_file.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | 16 | import tempfile 17 | 18 | import pytest 19 | import yaml 20 | 21 | import colin 22 | from colin.core.exceptions import ColinRulesetException 23 | 24 | 25 | @pytest.fixture() 26 | def ruleset(): 27 | return { 28 | "version": "1", 29 | "name": "Laughing out loud ruleset", 30 | "description": "This set of checks is required to pass because we said it", 31 | "contact_email": "forgot-to-reply@example.nope", 32 | "checks": [ 33 | {"name": "maintainer_label"}, 34 | {"name": "name_label"}, 35 | {"name": "com.redhat.component_label"}, 36 | {"name": "help_label"}, 37 | ], 38 | } 39 | 40 | 41 | @pytest.fixture() 42 | def ruleset_unknown_check(): 43 | return { 44 | "version": "1", 45 | "name": "Laughing out loud ruleset", 46 | "description": "This set of checks is required to pass because we said it", 47 | "contact_email": "forgot-to-reply@example.nope", 48 | "checks": [{"name": "maintainer_label"}, {"name": "i_forgot_the_name"}], 49 | } 50 | 51 | 52 | @pytest.fixture() 53 | def ruleset_coupled(): 54 | return { 55 | "version": "1", 56 | "name": "Laughing out loud coublet ruleset", 57 | "description": "This set of checks is required to pass because we said it", 58 | "contact_email": "forgot-to-reply@example.nope", 59 | "checks": [ 60 | { 61 | "names": [ 62 | "maintainer_label", 63 | "name_label", 64 | "com.redhat.component_label", 65 | ], 66 | "additional_tags": ["required"], 67 | } 68 | ], 69 | } 70 | 71 | 72 | @pytest.fixture() 73 | def expected_dict(): 74 | return { 75 | "maintainer_label": "PASS", 76 | "name_label": "PASS", 77 | "com.redhat.component_label": "PASS", 78 | "help_label": "FAIL", 79 | } 80 | 81 | 82 | def get_results_from_colin_labels_image( 83 | image, ruleset_name=None, ruleset_file=None, ruleset=None 84 | ): 85 | return colin.run( 86 | image.target_name, 87 | image.target_type, 88 | ruleset_name=ruleset_name, 89 | ruleset_file=ruleset_file, 90 | ruleset=ruleset, 91 | ) 92 | 93 | 94 | def test_specific_ruleset_as_fileobj(tmpdir, ruleset, expected_dict, target_label): 95 | (_, t) = tempfile.mkstemp(dir=str(tmpdir)) 96 | 97 | with open(t, "w") as f: 98 | yaml.dump(ruleset, f) 99 | with open(t) as f: 100 | result = get_results_from_colin_labels_image(image=target_label, ruleset_file=f) 101 | assert result 102 | labels_dict = {res.check_name: res.status for res in result.results} 103 | for key in expected_dict.keys(): 104 | assert labels_dict[key] == expected_dict[key] 105 | 106 | 107 | def test_specific_ruleset_directly(ruleset, expected_dict, target_label): 108 | result = get_results_from_colin_labels_image(image=target_label, ruleset=ruleset) 109 | assert result 110 | labels_dict = {res.check_name: res.status for res in result.results} 111 | for key in expected_dict.keys(): 112 | assert labels_dict[key] == expected_dict[key] 113 | 114 | 115 | def test_get_checks_directly(ruleset): 116 | checks = colin.get_checks(ruleset=ruleset) 117 | assert checks 118 | 119 | 120 | def test_coupled_ruleset(ruleset_coupled): 121 | checks = colin.get_checks(ruleset=ruleset_coupled) 122 | assert checks 123 | assert len(checks) == 3 124 | for c in checks: 125 | assert "required" in c.tags 126 | 127 | 128 | def test_unknown_check(ruleset_unknown_check): 129 | with pytest.raises(ColinRulesetException) as ex: 130 | colin.get_checks(ruleset=ruleset_unknown_check) 131 | assert ( 132 | str(ex.value) == "Check i_forgot_the_name can't be loaded, we couldn't find it." 133 | ) 134 | 135 | 136 | def test_skip(ruleset): 137 | checks = colin.get_checks(ruleset=ruleset, skips=["name_label", "help_label"]) 138 | assert len(checks) == 2 139 | for check in checks: 140 | assert check.name in ["com.redhat.component_label", "maintainer_label"] 141 | -------------------------------------------------------------------------------- /tests/integration/test_targets.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test different target types. 3 | """ 4 | 5 | import pytest 6 | 7 | import colin 8 | from tests.conftest import ( 9 | LABELS_IMAGE, 10 | convert_image_to_oci, 11 | get_skopeo_oci_target, 12 | ) 13 | 14 | 15 | @pytest.fixture() 16 | def ruleset(): 17 | """simple ruleset as a pytest fixture""" 18 | return { 19 | "version": "1", 20 | "name": "Laughing out loud ruleset", 21 | "description": "This set of checks is required to pass because we said it", 22 | "contact_email": "forgot-to-reply@example.nope", 23 | "checks": [ 24 | { 25 | "name": "com.redhat.component_label", 26 | }, 27 | { 28 | "name": "name_label", 29 | }, 30 | { 31 | "name": "version_label", 32 | }, 33 | { 34 | "name": "description_label", 35 | }, 36 | { 37 | "name": "io.k8s.description_label", 38 | }, 39 | { 40 | "name": "vcs-ref_label", 41 | }, 42 | { 43 | "name": "vcs-type_label", 44 | }, 45 | { 46 | "name": "architecture_label", 47 | }, 48 | { 49 | "name": "com.redhat.build-host_label", 50 | }, 51 | { 52 | "name": "authoritative_source-url_label", 53 | }, 54 | { 55 | "name": "vendor_label", 56 | }, 57 | { 58 | "name": "release_label", 59 | }, 60 | {"name": "url_label", "usable_targets": ["image"]}, 61 | { 62 | "name": "build-date_label", 63 | }, 64 | { 65 | "name": "distribution-scope_label", 66 | }, 67 | { 68 | "name": "run_or_usage_label", 69 | }, 70 | { 71 | "name": "help_file_or_readme", 72 | }, 73 | { 74 | "name": "maintainer_label", 75 | }, 76 | { 77 | "name": "summary_label", 78 | }, 79 | { 80 | "name": "install_label_capital_deprecated", 81 | }, 82 | { 83 | "name": "architecture_label_capital_deprecated", 84 | }, 85 | { 86 | "name": "bzcomponent_deprecated", 87 | }, 88 | { 89 | "name": "name_label_capital_deprecated", 90 | }, 91 | { 92 | "name": "release_label_capital_deprecated", 93 | }, 94 | { 95 | "name": "uninstall_label_capital_deprecated", 96 | }, 97 | { 98 | "name": "version_label_capital_deprecated", 99 | }, 100 | ], 101 | } 102 | 103 | 104 | def test_podman_image_target(ruleset): 105 | results = colin.run( 106 | LABELS_IMAGE, "image", ruleset=ruleset, logging_level=10, pull=False 107 | ) 108 | assert results.ok 109 | assert results.results_per_check["url_label"].ok 110 | 111 | 112 | def test_oci_target(ruleset): 113 | image_name = "colin-labels" 114 | oci_path = convert_image_to_oci(image_name=image_name) 115 | skopeo_target = get_skopeo_oci_target(image_name=image_name, oci_path=oci_path) 116 | results = colin.run( 117 | skopeo_target, "oci", ruleset=ruleset, logging_level=10, pull=False 118 | ) 119 | assert results.ok 120 | assert results.results_per_check["url_label"].ok 121 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/user-cont/colin/723bfc3dc906ecfefb38fec2d68d185e60456cc9/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/test_cli.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | 16 | from click.testing import CliRunner 17 | 18 | from colin.cli.colin import check, info, list_checks, list_rulesets 19 | from colin.version import __version__ 20 | 21 | 22 | def _call_colin(fnc, parameters=None, envs=None): 23 | runner = CliRunner() 24 | envs = envs or {} 25 | if not parameters: 26 | return runner.invoke(fnc, env=envs) 27 | else: 28 | return runner.invoke(fnc, parameters, env=envs) 29 | 30 | 31 | def _common_help_options(result): 32 | assert "-r, --ruleset TEXT" in result.output 33 | assert "-f, --ruleset-file FILENAME" in result.output 34 | assert "--debug" in result.output 35 | assert "--json FILENAME" in result.output 36 | assert "-t, --tag TEXT" in result.output 37 | assert "-v, --verbose" in result.output 38 | assert "-h, --help" in result.output 39 | 40 | 41 | def test_check_command(): 42 | result = _call_colin(check) 43 | expected_output1 = "Usage: check [OPTIONS] TARGET" 44 | expected_output2 = "Error: Missing argument 'TARGET'" 45 | assert result.exit_code == 2 46 | assert expected_output1 in result.output 47 | assert expected_output2 in result.output 48 | 49 | 50 | def test_check_help_command(): 51 | result = _call_colin(check, parameters=["-h"]) 52 | assert result.exit_code == 0 53 | _common_help_options(result) 54 | assert "--stat" in result.output 55 | assert "--xunit FILENAME" in result.output 56 | 57 | 58 | def test_list_checks(): 59 | result = _call_colin(list_checks) 60 | expected_output = ( 61 | """maintainer_label 62 | -> Label 'maintainer' has to be specified. 63 | -> The name and email of the maintainer (usually the submitter). 64 | -> https://fedoraproject.org/wiki/Container:Guidelines#LABELS 65 | -> label, maintainer, required 66 | 67 | from_tag_not_latest 68 | -> In FROM, tag has to be specified and not 'latest'. 69 | -> Using the 'latest' tag may cause unpredictable builds.""" 70 | + """It is recommended that a specific tag is used in the FROM. 71 | -> https://fedoraproject.org/wiki/Container:Guidelines#FROM 72 | -> dockerfile, from, baseimage, latest, required 73 | 74 | maintainer_deprecated 75 | -> Dockerfile instruction `MAINTAINER` is deprecated. 76 | -> Replace with label 'maintainer'. 77 | -> https://docs.docker.com/engine/reference/builder/#maintainer-deprecated 78 | -> dockerfile, maintainer, deprecated, required 79 | 80 | """ 81 | ) 82 | assert result.exit_code == 0 83 | assert result.output == expected_output 84 | assert ( 85 | _call_colin(list_checks, parameters=["-r", "default"]).output 86 | == _call_colin(list_checks).output 87 | ) 88 | 89 | 90 | def test_list_checks_help_command(): 91 | result = _call_colin(list_checks, parameters=["-h"]) 92 | _common_help_options(result) 93 | assert result.exit_code == 0 94 | 95 | 96 | def test_list_checks_fedora(): 97 | result = _call_colin(list_checks, parameters=["-r", "fedora"]) 98 | assert result.exit_code == 0 99 | assert "maintainer_label" in result.output 100 | assert "maintainer_deprecated" in result.output 101 | assert "from_tag_not_latest" in result.output 102 | 103 | 104 | def test_list_rulesets(): 105 | result = _call_colin(list_rulesets) 106 | assert "fedora" in result.output 107 | assert "default" in result.output 108 | assert result.exit_code == 0 109 | 110 | 111 | def test_list_rulesets_help_command(): 112 | result = _call_colin(list_rulesets, parameters=["-h"]) 113 | expected_result = """Usage: list-rulesets [OPTIONS] 114 | 115 | List available rulesets. 116 | 117 | Options: 118 | --debug Enable debugging mode (debugging logs, full tracebacks). 119 | -h, --help Show this message and exit. 120 | """ 121 | assert result.exit_code == 0 122 | assert result.output == expected_result 123 | 124 | 125 | def test_info(): 126 | result = _call_colin(info) 127 | assert result.exit_code == 0 128 | 129 | output = result.output.split("\n") 130 | assert output[0].startswith("colin") 131 | assert output[0].endswith("/colin") 132 | assert __version__ in output[0] 133 | assert "cli/colin.py" in output[1] 134 | 135 | assert output[3].startswith("podman") 136 | assert output[4].startswith("skopeo") 137 | assert output[5].startswith("umoci") 138 | 139 | 140 | def test_env(): 141 | result = _call_colin(info, envs={"COLIN_HELP": "1"}) 142 | assert "Usage" in result.output 143 | -------------------------------------------------------------------------------- /tests/unit/test_image_name.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | import pytest 16 | 17 | from colin.utils.cont import ImageName 18 | 19 | 20 | @pytest.mark.parametrize( 21 | "string_input,image_result", 22 | [ 23 | ("fedora", (None, None, "fedora", "latest", None)), 24 | ("fedora:27", (None, None, "fedora", "27", None)), 25 | ("docker.io/fedora", ("docker.io", None, "fedora", "latest", None)), 26 | ("docker.io/fedora:latest", ("docker.io", None, "fedora", "latest", None)), 27 | ( 28 | "docker.io/modularitycontainers/conu", 29 | ("docker.io", "modularitycontainers", "conu", "latest", None), 30 | ), 31 | ( 32 | "docker.io/centos/postgresql-96-centos7", 33 | ("docker.io", "centos", "postgresql-96-centos7", "latest", None), 34 | ), 35 | ( 36 | "some-registry.example.com:8888/image6", 37 | ("some-registry.example.com:8888", None, "image6", "latest", None), 38 | ), 39 | ( 40 | "some-registry.example.com:8888/" 41 | "image6:some-example-6.10-something-26365-20180322014912", 42 | ( 43 | "some-registry.example.com:8888", 44 | None, 45 | "image6", 46 | "some-example-6.10-something-26365-20180322014912", 47 | None, 48 | ), 49 | ), 50 | ( 51 | "fedora@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 52 | ( 53 | None, 54 | None, 55 | "fedora", 56 | None, 57 | "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 58 | ), 59 | ), 60 | ( 61 | "docker.io/fedora@sha256:" 62 | "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 63 | ( 64 | "docker.io", 65 | None, 66 | "fedora", 67 | None, 68 | "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 69 | ), 70 | ), 71 | ( 72 | "docker.io/centos/postgresql-96-centos7@sha256:" 73 | "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 74 | ( 75 | "docker.io", 76 | "centos", 77 | "postgresql-96-centos7", 78 | None, 79 | "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 80 | ), 81 | ), 82 | ], 83 | ) 84 | def test_image_class(string_input, image_result): 85 | image_name = ImageName.parse(string_input) 86 | registry, namespace, repository, tag, digest = image_result 87 | assert image_name.registry == registry 88 | assert image_name.namespace == namespace 89 | assert image_name.repository == repository 90 | assert image_name.tag == tag 91 | assert image_name.digest == digest 92 | 93 | 94 | @pytest.mark.parametrize( 95 | "string_input, name_result, tag_result", 96 | [ 97 | ("fedora", "fedora:latest", "latest"), 98 | ("fedora:27", "fedora:27", "27"), 99 | ("docker.io/fedora", "docker.io/fedora:latest", "latest"), 100 | ("docker.io/fedora:latest", "docker.io/fedora:latest", "latest"), 101 | ( 102 | "docker.io/modularitycontainers/conu", 103 | "docker.io/modularitycontainers/conu:latest", 104 | "latest", 105 | ), 106 | ( 107 | "docker.io/centos/postgresql-96-centos7", 108 | "docker.io/centos/postgresql-96-centos7:latest", 109 | "latest", 110 | ), 111 | ( 112 | "some-registry.example.com:8888/image6", 113 | "some-registry.example.com:8888/image6:latest", 114 | "latest", 115 | ), 116 | ( 117 | "some-registry.example.com:8888/image6:" 118 | "some-example-6.10-something-26365-20180322014912", 119 | "some-registry.example.com:8888/image6:" 120 | "some-example-6.10-something-26365-20180322014912", 121 | "some-example-6.10-something-26365-20180322014912", 122 | ), 123 | ( 124 | "fedora@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 125 | "fedora@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 126 | None, 127 | ), 128 | ( 129 | "docker.io/fedora@sha256:" 130 | "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 131 | "docker.io/fedora@sha256:" 132 | "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 133 | None, 134 | ), 135 | ( 136 | "docker.io/centos/postgresql-96-centos7@sha256:" 137 | "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 138 | "docker.io/centos/postgresql-96-centos7@sha256:" 139 | "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 140 | None, 141 | ), 142 | ], 143 | ) 144 | def test_image_class_name_tag(string_input, name_result, tag_result): 145 | image_name = ImageName.parse(string_input) 146 | assert image_name.name == name_result 147 | assert image_name.tag == tag_result 148 | -------------------------------------------------------------------------------- /tests/unit/test_loader.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | 16 | import os 17 | import shutil 18 | 19 | import colin.checks 20 | from colin.core.loader import CheckLoader 21 | 22 | 23 | def test_upstream_checks_can_be_loaded(): 24 | """check whether all upstream checks can be loaded""" 25 | colin_checks_path = colin.checks.__file__ 26 | colin_checks_dir = os.path.dirname(colin_checks_path) 27 | check_loader = CheckLoader([colin_checks_dir]) 28 | assert check_loader.check_classes 29 | assert check_loader.mapping["bzcomponent_deprecated"] 30 | assert check_loader.mapping["architecture_label_capital_deprecated"] 31 | assert check_loader.mapping["bzcomponent_deprecated"] 32 | assert check_loader.mapping["install_label_capital_deprecated"] 33 | assert check_loader.mapping["name_label_capital_deprecated"] 34 | assert check_loader.mapping["release_label_capital_deprecated"] 35 | assert check_loader.mapping["uninstall_label_capital_deprecated"] 36 | assert check_loader.mapping["version_label_capital_deprecated"] 37 | assert check_loader.mapping["architecture_label"] 38 | assert check_loader.mapping["authoritative_source-url_label"] 39 | assert check_loader.mapping["build-date_label"] 40 | assert check_loader.mapping["com.redhat.build-host_label"] 41 | assert check_loader.mapping["com.redhat.component_label"] 42 | assert check_loader.mapping["description_label"] 43 | assert check_loader.mapping["description_or_io.k8s.description_label"] 44 | assert check_loader.mapping["distribution-scope_label"] 45 | assert check_loader.mapping["help_label"] 46 | assert check_loader.mapping["io.k8s.description_label"] 47 | assert check_loader.mapping["io.k8s.display-name_label"] 48 | assert check_loader.mapping["maintainer_label"] 49 | assert check_loader.mapping["name_label"] 50 | assert check_loader.mapping["release_label"] 51 | assert check_loader.mapping["summary_label"] 52 | assert check_loader.mapping["url_label"] 53 | assert check_loader.mapping["run_or_usage_label"] 54 | assert check_loader.mapping["vcs-ref_label"] 55 | assert check_loader.mapping["vcs-type_label"] 56 | assert check_loader.mapping["vcs-url_label"] 57 | assert check_loader.mapping["vendor_label"] 58 | assert check_loader.mapping["version_label"] 59 | assert check_loader.mapping["cmd_or_entrypoint"] 60 | assert check_loader.mapping["help_file_or_readme"] 61 | assert check_loader.mapping["no_root"] 62 | # assert check_loader.mapping["shell_runnable"] # FIXME: commented out before move to podman 63 | assert check_loader.mapping["from_tag_not_latest"] 64 | assert check_loader.mapping["maintainer_deprecated"] 65 | 66 | 67 | def test_loading_custom_check(tmpdir): 68 | tests_dir = os.path.dirname(os.path.dirname(__file__)) 69 | a_check_dir = os.path.join(tests_dir, "data", "a_check") 70 | shutil.copytree(a_check_dir, str(tmpdir.join("a_check"))) 71 | check_loader = CheckLoader([str(tmpdir)]) 72 | assert len(check_loader.check_classes) == 3 73 | assert check_loader.mapping["a-peter-file-check"] 74 | assert check_loader.mapping["this-is-a-funky-check"] 75 | 76 | 77 | def test_import_class(): 78 | check_loader = CheckLoader([]) 79 | check_name = "ArchitectureLabelCheck" 80 | imported_class = check_loader.import_class(f"colin.checks.labels.{check_name}") 81 | assert imported_class.name == "architecture_label" 82 | -------------------------------------------------------------------------------- /tests/unit/test_ruleset.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | 16 | import os 17 | 18 | import pytest 19 | 20 | from colin.core.exceptions import ColinRulesetException 21 | from colin.core.ruleset.ruleset import Ruleset 22 | 23 | 24 | def test_ruleset_yaml(): 25 | tests_dir = os.path.dirname(os.path.dirname(__file__)) 26 | lol_ruleset_path = os.path.join(tests_dir, "data", "lol-ruleset.yaml") 27 | with open(lol_ruleset_path) as fd: 28 | r = Ruleset(ruleset_file=fd) 29 | checks = r.get_checks(None) 30 | assert len(checks) == 1 31 | 32 | 33 | def test_ruleset_json(): 34 | tests_dir = os.path.dirname(os.path.dirname(__file__)) 35 | lol_ruleset_path = os.path.join(tests_dir, "data", "lol-ruleset.json") 36 | with open(lol_ruleset_path) as fd: 37 | r = Ruleset(ruleset_file=fd) 38 | checks = r.get_checks(None) 39 | assert len(checks) == 1 40 | 41 | 42 | def test_ruleset_tags(): 43 | tags = ["a", "banana"] 44 | r = {"version": "1", "checks": [{"name": "name_label", "tags": tags[:]}]} 45 | r = Ruleset(ruleset=r) 46 | checks = r.get_checks(None) 47 | assert len(checks) == 1 48 | assert checks[0].tags == tags 49 | 50 | 51 | def test_ruleset_additional_tags(): 52 | tags = ["a"] 53 | r = {"version": "1", "checks": [{"name": "name_label", "additional_tags": tags[:]}]} 54 | r = Ruleset(ruleset=r) 55 | checks = r.get_checks(None) 56 | assert len(checks) == 1 57 | assert list(set(tags).intersection(set(checks[0].tags))) == tags 58 | 59 | 60 | @pytest.mark.parametrize( 61 | "tags,expected_check_name", [(["banana"], None), (["name"], "name_label")] 62 | ) 63 | def test_ruleset_tags_filtering(tags, expected_check_name): 64 | r = {"version": "1", "checks": [{"name": "name_label"}]} 65 | r = Ruleset(ruleset=r) 66 | checks = r.get_checks(None, tags=tags) 67 | if expected_check_name: 68 | assert len(checks) == 1 69 | assert checks[0].name == expected_check_name 70 | else: 71 | assert len(checks) == 0 72 | 73 | 74 | # version in ruleset, should this case raise an exception? 75 | @pytest.mark.parametrize( 76 | "version,should_raise", 77 | [ 78 | (1, False), 79 | ("1", False), 80 | ("banana", True), 81 | (None, True), 82 | ("", True), 83 | ("", True), 84 | ], 85 | ) 86 | def test_ruleset_version(version, should_raise): 87 | r = {"banana": 123} if version == "" else {"version": version} 88 | if should_raise: 89 | with pytest.raises(ColinRulesetException): 90 | Ruleset(ruleset=r) 91 | else: 92 | assert Ruleset(ruleset=r) 93 | 94 | 95 | def test_ruleset_override(): 96 | m = "my-message!" 97 | r = { 98 | "version": "1", 99 | "checks": [ 100 | {"name": "name_label", "tags": ["a", "b"], "just": "testing", "message": m} 101 | ], 102 | } 103 | r = Ruleset(ruleset=r) 104 | checks = r.get_checks(None) 105 | assert len(checks) == 1 106 | assert checks[0].message == m 107 | assert checks[0].just == "testing" 108 | -------------------------------------------------------------------------------- /tests/unit/test_utils.py: -------------------------------------------------------------------------------- 1 | # 2 | # This program is free software: you can redistribute it and/or modify 3 | # it under the terms of the GNU General Public License as published by 4 | # the Free Software Foundation, either version 3 of the License, or 5 | # (at your option) any later version. 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU General Public License 13 | # along with this program. If not, see . 14 | # 15 | import time 16 | 17 | import pytest 18 | 19 | from colin.core.exceptions import ColinException 20 | from colin.utils.cmd_tools import exit_after 21 | from colin.utils.cmd_tools import retry 22 | 23 | 24 | @exit_after(1) 25 | def fast_fce(): 26 | pass 27 | 28 | 29 | @exit_after(1) 30 | def slow_fce(): 31 | time.sleep(2) 32 | 33 | 34 | @exit_after(1) 35 | def bad_fce(): 36 | raise ColinException("Error") 37 | 38 | 39 | def test_timeout_fast_fce(): 40 | fast_fce() 41 | 42 | 43 | def test_timeout_slow_fce(): 44 | with pytest.raises(TimeoutError): 45 | slow_fce() 46 | 47 | 48 | def test_timeout_bad_fce(): 49 | with pytest.raises(ColinException): 50 | bad_fce() 51 | 52 | 53 | def test_timeout_dirrect(): 54 | with pytest.raises(TimeoutError): 55 | exit_after(1)(time.sleep)(2) 56 | 57 | 58 | COUNTER = 0 59 | 60 | 61 | def raise_exception(): 62 | global COUNTER 63 | COUNTER += 1 64 | 65 | raise Exception("I am bad function") 66 | 67 | 68 | def test_no_retry_for_success(): 69 | global COUNTER 70 | COUNTER = 0 71 | 72 | @retry(5, 0) 73 | def always_success(): 74 | global COUNTER 75 | COUNTER = COUNTER + 1 76 | 77 | return 42 78 | 79 | assert always_success() == 42 80 | assert COUNTER == 1 81 | 82 | 83 | def test_retry_with_exception(): 84 | global COUNTER 85 | COUNTER = 0 86 | 87 | @retry(5, 0) 88 | def always_raise_exception(): 89 | raise_exception() 90 | 91 | with pytest.raises(Exception) as ex: 92 | always_raise_exception() 93 | 94 | assert str(ex.value) == "I am bad function" 95 | assert COUNTER == 5 96 | 97 | 98 | def test_wrong_parameter(): 99 | with pytest.raises(ValueError) as ex: 100 | retry(-1, 1) 101 | assert str(ex.value) == "retry_count have to be positive" 102 | 103 | with pytest.raises(ValueError) as ex: 104 | retry(0, 1) 105 | assert str(ex.value) == "retry_count have to be positive" 106 | 107 | @retry(5, -1) 108 | def fail_negative_sleep(): 109 | raise_exception() 110 | 111 | with pytest.raises(ValueError) as ex: 112 | fail_negative_sleep() 113 | assert str(ex.value) == "sleep length must be non-negative" 114 | 115 | 116 | def test_retry_with_sleep(): 117 | global COUNTER 118 | COUNTER = 0 119 | 120 | @retry(4, 0.5) 121 | def fail_and_sleep(): 122 | raise_exception() 123 | 124 | time_start = time.time() 125 | with pytest.raises(Exception) as ex: 126 | fail_and_sleep() 127 | time_end = time.time() 128 | 129 | assert str(ex.value) == "I am bad function" 130 | assert COUNTER == 4 131 | 132 | # there are 3 sleeps between 4 delays 133 | assert time_end - time_start >= 1.5 134 | # there were not 4 sleeps 135 | assert time_end - time_start < 4 136 | 137 | 138 | def test_recover_after_few_failures(): 139 | global COUNTER 140 | COUNTER = 0 141 | 142 | @retry(5, 0) 143 | def sleep_like_a_baby(): 144 | global COUNTER 145 | if COUNTER < 3: 146 | COUNTER += 1 147 | raise Exception("sleeping") 148 | return [] 149 | 150 | assert sleep_like_a_baby() == [] 151 | assert COUNTER == 3 152 | --------------------------------------------------------------------------------