Skip to content

Commit

Permalink
Feature: namespace allowlist (#15)
Browse files Browse the repository at this point in the history
* ❇️ initial support for namespace allowlist

* 🔒 initial namespace allowlist implementation

* ❇️ helm support for env var config options

* 🔥 disable version bump check

* ♻️ another way to disable version bump check

* ♻️ chart linting syntax

* tests for library namespace filter

* Hardcode the test setting
  • Loading branch information
mshade authored Sep 12, 2023
1 parent 13b8288 commit 5ab17e3
Show file tree
Hide file tree
Showing 15 changed files with 228 additions and 37 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
run: docker build --target dev -t ${IMAGE_NAME}:test .

- name: Run Unit Tests
run: docker run -i -e KRONIC_TEST=true --rm -v $PWD:/app ${IMAGE_NAME}:test pytest
run: docker run -i --rm -v $PWD:/app ${IMAGE_NAME}:test pytest

- name: Log into GHCR
uses: docker/login-action@v3
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/chart-testing.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,15 @@ jobs:
uses: helm/[email protected]

- name: Run chart-testing (lint)
run: ct lint --chart-dirs chart --target-branch ${{ github.event.repository.default_branch }}
run: |
ct lint --charts chart/kronic --target-branch ${{ github.event.repository.default_branch }}
- name: Create kind cluster
uses: helm/[email protected]

- name: Run chart-testing (install)
run: |
ct install --chart-dirs chart \
ct install --charts chart/kronic \
--helm-extra-args '--timeout 60s' \
--target-branch ${{ github.event.repository.default_branch }}
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
FROM python:3.11-alpine as deps
ENV PYTHONUNBUFFERED=1

RUN apk add --no-cache curl

Expand Down
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ trigger them ad-hoc, or create a new one-off job based on existing CronJob defin
Kronic aims to be a simple admin UI / dashboard / manager to view, suspend, trigger, edit, and delete CronJobs in a Kubernetes cluster.


## Configuration

Kronic can limit itself to a list of namespaces. Specify as a comma separated list in the `KRONIC_ALLOW_NAMESPACES` environment variable.
The helm chart exposes this option.


## Deploying to K8S

A helm chart is available at [./chart/kronic](./chart/kronic/).
Expand Down Expand Up @@ -90,16 +96,16 @@ Kronic is a small Flask app built with:

## Todo

- [x] CI/CD pipeline and versioning
- [x] Helm chart
- [x] Allow/Deny lists for namespaces
- [ ] Built-in auth options
- [ ] NetworkPolicy in helm chart
- [ ] Allow/Deny lists for namespaces
- [ ] Timeline / Cron schedule interpreter or display
- [ ] YAML/Spec Validation on Edit page
- [ ] Async refreshing of job/pods
- [ ] Error handling for js apiClient
- [ ] Better logging from Flask app and Kron module
- [ ] More unit tests
- [ ] Integration tests against ephemeral k3s cluster
- [x] CI/CD pipeline and versioning
- [x] Helm chart
- [ ] Improve localdev stack with automated k3d cluster provisioning
66 changes: 63 additions & 3 deletions app.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from flask import Flask, request, render_template, redirect
from boltons.iterutils import remap

from functools import wraps
import yaml

import config
from kron import (
get_cronjobs,
get_jobs,
Expand All @@ -20,6 +20,29 @@
app = Flask(__name__, static_url_path="", static_folder="static")


# A namespace filter decorator
def namespace_filter(func):
@wraps(func)
def wrapper(namespace, *args, **kwargs):
if config.ALLOW_NAMESPACES:
if namespace in config.ALLOW_NAMESPACES.split(","):
return func(namespace, *args, **kwargs)
else:
return func(namespace, *args, **kwargs)

data = {
"error": f"Request to {namespace} denied due to KRONIC_ALLOW_NAMESPACES setting",
"namespace": namespace,
"allowed_namespaces": config.ALLOW_NAMESPACES,
}
if request.headers.get("content-type", None) == "application/json":
return data, 403
else:
return render_template("denied.html", data=data)

return wrapper


def _strip_immutable_fields(spec):
spec.pop("status", None)
metadata = spec.get("metadata", {})
Expand All @@ -34,6 +57,7 @@ def healthz():


@app.route("/")
@app.route("/namespaces/")
def index():
cronjobs = get_cronjobs()
namespaces = {}
Expand All @@ -45,6 +69,7 @@ def index():


@app.route("/namespaces/<namespace>")
@namespace_filter
def view_namespace(namespace):
cronjobs = get_cronjobs(namespace)
cronjobs_with_details = []
Expand All @@ -63,6 +88,7 @@ def view_namespace(namespace):


@app.route("/namespaces/<namespace>/cronjobs/<cronjob_name>", methods=["GET", "POST"])
@namespace_filter
def view_cronjob(namespace, cronjob_name):
if request.method == "POST":
edited_cronjob = yaml.safe_load(request.form["yaml"])
Expand All @@ -82,7 +108,30 @@ def view_cronjob(namespace, cronjob_name):
"apiVersion": "batch/v1",
"kind": "CronJob",
"metadata": {"name": cronjob_name, "namespace": namespace},
"spec": {},
"spec": {
"schedule": "*/10 * * * *",
"jobTemplate": {
"spec": {
"template": {
"spec": {
"containers": [
{
"name": "example",
"image": "busybox:latest",
"imagePullPolicy": "IfNotPresent",
"command": [
"/bin/sh",
"-c",
"echo hello; date",
],
}
],
"restartPolicy": "OnFailure",
}
}
}
},
},
}

cronjob_yaml = yaml.dump(cronjob)
Expand All @@ -98,12 +147,14 @@ def api_index():

@app.route("/api/namespaces/<namespace>/cronjobs")
@app.route("/api/namespaces/<namespace>")
@namespace_filter
def api_namespace(namespace):
cronjobs = get_cronjobs(namespace)
return cronjobs


@app.route("/api/namespaces/<namespace>/cronjobs/<cronjob_name>")
@namespace_filter
def api_get_cronjob(namespace, cronjob_name):
cronjob = get_cronjob(namespace, cronjob_name)
return cronjob
Expand All @@ -112,6 +163,7 @@ def api_get_cronjob(namespace, cronjob_name):
@app.route(
"/api/namespaces/<namespace>/cronjobs/<cronjob_name>/clone", methods=["POST"]
)
@namespace_filter
def api_clone_cronjob(namespace, cronjob_name):
cronjob_spec = get_cronjob(namespace, cronjob_name)
new_name = request.json["name"]
Expand All @@ -124,6 +176,7 @@ def api_clone_cronjob(namespace, cronjob_name):


@app.route("/api/namespaces/<namespace>/cronjobs/create", methods=["POST"])
@namespace_filter
def api_create_cronjob(namespace):
cronjob_spec = request.json["data"]
cronjob = update_cronjob(namespace, cronjob_spec)
Expand All @@ -133,6 +186,7 @@ def api_create_cronjob(namespace):
@app.route(
"/api/namespaces/<namespace>/cronjobs/<cronjob_name>/delete", methods=["POST"]
)
@namespace_filter
def api_delete_cronjob(namespace, cronjob_name):
deleted = delete_cronjob(namespace, cronjob_name)
return deleted
Expand All @@ -142,6 +196,7 @@ def api_delete_cronjob(namespace, cronjob_name):
"/api/namespaces/<namespace>/cronjobs/<cronjob_name>/suspend",
methods=["GET", "POST"],
)
@namespace_filter
def api_toggle_cronjob_suspend(namespace, cronjob_name):
if request.method == "GET":
"""Return the suspended status of the <cronjob_name>"""
Expand All @@ -156,6 +211,7 @@ def api_toggle_cronjob_suspend(namespace, cronjob_name):
@app.route(
"/api/namespaces/<namespace>/cronjobs/<cronjob_name>/trigger", methods=["POST"]
)
@namespace_filter
def api_trigger_cronjob(namespace, cronjob_name):
"""Manually trigger a job from <cronjob_name>"""
cronjob = trigger_cronjob(namespace, cronjob_name)
Expand All @@ -167,24 +223,28 @@ def api_trigger_cronjob(namespace, cronjob_name):


@app.route("/api/namespaces/<namespace>/cronjobs/<cronjob_name>/getJobs")
@namespace_filter
def api_get_jobs(namespace, cronjob_name):
jobs = get_jobs_and_pods(namespace, cronjob_name)
return jobs


@app.route("/api/namespaces/<namespace>/pods")
@namespace_filter
def api_get_pods(namespace):
pods = get_pods(namespace)
return pods


@app.route("/api/namespaces/<namespace>/pods/<pod_name>/logs")
@namespace_filter
def api_get_pod_logs(namespace, pod_name):
logs = get_pod_logs(namespace, pod_name)
return logs


@app.route("/api/namespaces/<namespace>/jobs/<job_name>/delete", methods=["POST"])
@namespace_filter
def api_delete_job(namespace, job_name):
deleted = delete_job(namespace, job_name)
return deleted
5 changes: 3 additions & 2 deletions chart/kronic/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ kubectl -n kronic port-forward deployment/kronic 8000:8000
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| affinity | object | `{}` | Provide scheduling affinity selectors |
| env.KRONIC_ALLOW_NAMESPACES | string | `""` | Comma separated list of namespaces to allow access to, eg: "staging,qa,example" |
| image.pullPolicy | string | `"IfNotPresent"` | |
| image.repository | string | `"ghcr.io/mshade/kronic"` | |
| image.tag | string | `""` | |
Expand All @@ -59,7 +60,7 @@ kubectl -n kronic port-forward deployment/kronic 8000:8000
| nodeSelector | object | `{}` | |
| podAnnotations | object | `{}` | |
| podSecurityContext | object | `{}` | |
| rbac.enabled | bool | `true` | Create ClusterRole and ClusterRoleBindings for default cronjob/job/pod permissions |
| rbac.enabled | bool | `true` | Create ClusterRole and ClusterRoleBindings for default cluster-wide cronjob/job/pod permissions |
| replicaCount | int | `1` | Number of replicas in deployment - min 2 for HA |
| resources.limits.cpu | int | `1` | |
| resources.limits.memory | string | `"1024Mi"` | |
Expand All @@ -73,4 +74,4 @@ kubectl -n kronic port-forward deployment/kronic 8000:8000
| tolerations | list | `[]` | |

----------------------------------------------
Autogenerated from chart metadata using [helm-docs v1.11.0](https://github.com/norwoodj/helm-docs/releases/v1.11.0)
Autogenerated from chart metadata using [helm-docs v1.11.0](https://github.com/norwoodj/helm-docs/releases/v1.11.0)
5 changes: 5 additions & 0 deletions chart/kronic/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ spec:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
{{- range $name, $item := .Values.env }}
- name: {{ $name }}
value: {{ $item | quote }}
{{- end }}
ports:
- name: http
containerPort: 8000
Expand Down
6 changes: 5 additions & 1 deletion chart/kronic/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@ image:
# Overrides the image tag whose default is the chart appVersion.
tag: ""

env:
# -- Comma separated list of namespaces to allow access to, eg: "staging,qa,example"
KRONIC_ALLOW_NAMESPACES: ""

# Specify whether to create ClusterRole and ClusterRoleBinding
# for kronic. If disabled, you will need to handle permissions
# manually.
rbac:
# -- Create ClusterRole and ClusterRoleBindings for default cronjob/job/pod permissions
# -- Create ClusterRole and ClusterRoleBindings for default cluster-wide cronjob/job/pod permissions
enabled: true

serviceAccount:
Expand Down
7 changes: 7 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import os

## Configuration
# Comma separated list of namespaces to allow access to
ALLOW_NAMESPACES = os.environ.get("KRONIC_ALLOW_NAMESPACES", None)
# Boolean of whether this is a test environment, disables kubeconfig setup
TEST = os.environ.get("KRONIC_TEST", False)
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ services:
build:
context: .
target: dev
environment:
KRONIC_ALLOW_NAMESPACES: "test"
volumes:
- .:/app
- $HOME/.kube/kronic.yaml:/root/.kube/config
Expand Down
Loading

0 comments on commit 5ab17e3

Please sign in to comment.