Skip to content

Commit

Permalink
Add a method to apply kyverno policies on objects in the cluster (#17)
Browse files Browse the repository at this point in the history
Allow chaining commands to apply kyverno policies, pointing at a policy
directory in the cluster. This can allow for testing of objects in the
cluster locally before pushing.

Updated CI scripts to pull into separate files for linting vs testing.
Added kyverno CLI for tests.
  • Loading branch information
allenporter authored Feb 6, 2023
1 parent 68ed2ec commit fa221c2
Show file tree
Hide file tree
Showing 12 changed files with 193 additions and 65 deletions.
41 changes: 41 additions & 0 deletions .github/workflows/lint.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
name: Lint

on:
push:
branches:
- main
pull_request:
branches:
- main

jobs:
build:

runs-on: ubuntu-latest
strategy:
fail-fast: false

steps:
- uses: actions/checkout@v3
- uses: codespell-project/actions-codespell@master
- name: Run yamllint
uses: ibiqlik/action-yamllint@v3
with:
file_or_dir: "./"
config_file: "./.yaml-lint.yaml"
strict: true
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.10"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
2 changes: 1 addition & 1 deletion .github/workflows/pages.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Simple workflow for deploying static content to GitHub Pages
---
name: Deploy static content to Pages

on:
Expand Down
46 changes: 46 additions & 0 deletions .github/workflows/python-package.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
name: Python package

on:
push:
branches:
- main
pull_request:
branches:
- main

jobs:
build:

runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version:
- "3.10"
- "3.11"

steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- uses: supplypike/setup-bin@v3
with:
uri: https://github.com/kyverno/kyverno/releases/download/v1.9.0/kyverno-cli_v1.9.0_linux_x86_64.tar.gz
name: kyverno-cli
version: v1.9.0
- name: Test with pytest
run: |
pytest --cov=flux_local --cov-report=term-missing
- uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
env_vars: OS,PYTHON
fail_ci_if_error: true
verbose: true
46 changes: 0 additions & 46 deletions .github/workflows/python-package.yml

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
---
name: Upload Python Package

on:
Expand Down
3 changes: 2 additions & 1 deletion flux_local/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ async def run_piped(cmds: list[list[str]]) -> str:
if err:
_LOGGER.error(err.decode("utf-8"))
raise CommandException(
f"Subprocess '{cmd_text}' failed with return code {proc.returncode}"
f"Subprocess '{cmd_text}' failed with return code {proc.returncode}: "
+ out.decode("utf-8")
)
stdin = out
return out.decode("utf-8") if out else ""
Expand Down
20 changes: 20 additions & 0 deletions flux_local/kustomize.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
print(f"Found ConfigMap: {object['metadata']['name']}")
```
You can apply kyverno policies to the objects with the `validate` method.
"""

from pathlib import Path
Expand All @@ -32,6 +33,7 @@
from . import command

KUSTOMIZE_BIN = "kustomize"
KYVERNO_BIN = "kyverno"


class Kustomize:
Expand Down Expand Up @@ -75,3 +77,21 @@ async def _docs(self) -> AsyncGenerator[dict[str, Any], None]:
async def objects(self) -> list[dict[str, Any]]:
"""Run the kustomize command and return the result cluster objects as a list."""
return [doc async for doc in self._docs()]

async def validate(self, policy_path: Path) -> None:
"""Apply kyverno policies from the directory to any objects built so far.
The specified `policy_path` is a file or directory containing policy objects. All
secrets will stripped since otherwise they fail the kyverno cli.
"""
kustomize = self.grep("kind=^Secret$", invert=True)
cmds = kustomize._cmds + [ # pylint: disable=protected-access
[
KYVERNO_BIN,
"apply",
str(policy_path),
"--resource",
"-",
],
]
await command.run_piped(cmds)
23 changes: 9 additions & 14 deletions tests/test_helm.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,33 @@
"""Tests for helm library."""

from aiofiles.os import mkdir
from pathlib import Path
from typing import Any, Generator
import yaml

import pytest
from aiofiles.os import mkdir

from flux_local.manifest import HelmRepository
from flux_local.manifest import HelmRelease
from flux_local.kustomize import Kustomize
from flux_local.helm import Helm
from flux_local.kustomize import Kustomize
from flux_local.manifest import HelmRelease, HelmRepository

TESTDATA_DIR = Path("tests/testdata/") / "helm-repo"


@pytest.fixture(name="tmp_config_path")
def tmp_config_path_fixture(tmp_path_factory: Any) -> Generator[Path, None, None]:
"""Fixture for creating a path used for helm config shared across tests."""
yield tmp_path_factory.mktemp("test_helm")


@pytest.fixture(name="helm_repos")
async def helm_repos_fixture() -> list[HelmRepository]:
async def helm_repos_fixture() -> list[dict[str, Any]]:
"""Fixture for creating the HelmRepository objects"""
kustomize = Kustomize.build(TESTDATA_DIR).grep("kind=^HelmRepository$")
return await kustomize.objects()


@pytest.fixture(name="helm")
async def helm_fixture(tmp_config_path: Path, helm_repos: list[dict[str, any]]) -> Helm:
async def helm_fixture(tmp_config_path: Path, helm_repos: list[dict[str, Any]]) -> Helm:
"""Fixture for creating the Helm object."""
await mkdir(tmp_config_path / "helm")
await mkdir(tmp_config_path / "cache")
Expand Down Expand Up @@ -60,12 +59,8 @@ async def test_template(helm: Helm, helm_releases: list[dict[str, Any]]) -> None
assert len(helm_releases) == 1
release = helm_releases[0]
kustomize = await helm.template(
HelmRelease.from_doc(release),
release["spec"].get("values")
HelmRelease.from_doc(release), release["spec"].get("values")
)
docs = await kustomize.grep("kind=ServiceAccount").objects()
names = [ doc.get("metadata", {}).get("name") for doc in docs ]
assert names == [
'metallb-controller',
'metallb-speaker'
]
names = [doc.get("metadata", {}).get("name") for doc in docs]
assert names == ["metallb-controller", "metallb-speaker"]
18 changes: 18 additions & 0 deletions tests/test_kustomize.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

from pathlib import Path

import pytest

from flux_local import command
from flux_local.kustomize import Kustomize

TESTDATA_DIR = Path("tests/testdata")
Expand Down Expand Up @@ -30,3 +33,18 @@ async def test_objects() -> None:
assert len(result) == 1
assert result[0].get("kind") == "ConfigMap"
assert result[0].get("apiVersion") == "v1"


async def test_validate_pass() -> None:
"""Test applying policies to validate resources."""
kustomize = Kustomize.build(TESTDATA_DIR / "repo")
await kustomize.validate(TESTDATA_DIR / "policies/pass.yaml")


async def test_validate_fail() -> None:
"""Test applying policies to validate resources."""
kustomize = Kustomize.build(TESTDATA_DIR / "repo")
with pytest.raises(
command.CommandException, match="require-test-annotation: validation error"
):
await kustomize.validate(TESTDATA_DIR / "policies/fail.yaml")
8 changes: 5 additions & 3 deletions tests/test_manifest.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"""Tests for manifest library."""

import yaml
from pathlib import Path

from flux_local.manifest import HelmRelease, HelmRepository
import yaml

from flux_local.manifest import HelmRelease, HelmRepository

TESTDATA_DIR = Path("tests/testdata/helm-repo")

Expand All @@ -13,7 +13,9 @@ def test_parse_helm_release() -> None:
"""Test parsing a helm release doc."""

release = HelmRelease.from_doc(
yaml.load((TESTDATA_DIR / "metallb-release.yaml").read_text(), Loader=yaml.CLoader)
yaml.load(
(TESTDATA_DIR / "metallb-release.yaml").read_text(), Loader=yaml.CLoader
)
)
assert release.name == "metallb"
assert release.namespace == "metallb"
Expand Down
25 changes: 25 additions & 0 deletions tests/testdata/policies/fail.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: test-deny-policy
annotations:
policies.kyverno.io/title: Test Allow Policy
policies.kyverno.io/description: >-
Policy that is expected to fail resources under test since no resources
should have the needed annotation.
spec:
validationFailureAction: audit
background: true
rules:
- name: require-test-annotation
match:
resources:
kinds:
- ConfigMap
validate:
message: "Missing test-annotation"
pattern:
metadata:
annotations:
flux-local/test-annotation: "null"
25 changes: 25 additions & 0 deletions tests/testdata/policies/pass.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: test-allow-policy
annotations:
policies.kyverno.io/title: Test Allow Policy
policies.kyverno.io/description: >-
Policy that is expected to allow resources under test through since no
resources should have this annotation.
spec:
validationFailureAction: audit
background: true
rules:
- name: forbid-test-annotation
match:
resources:
kinds:
- ConfigMap
validate:
message: "Found test-annotation"
pattern:
metadata:
=(annotations):
X(flux-local/test-annotation): "null"

0 comments on commit fa221c2

Please sign in to comment.