From 5ab17e3736684e984bc80187a8ea0f5f805a511d Mon Sep 17 00:00:00 2001 From: Mike Shade Date: Tue, 12 Sep 2023 11:35:28 -0400 Subject: [PATCH] Feature: namespace allowlist (#15) * :sparkle: initial support for namespace allowlist * :lock: initial namespace allowlist implementation * :sparkle: helm support for env var config options * :fire: disable version bump check * :recycle: another way to disable version bump check * :recycle: chart linting syntax * tests for library namespace filter * Hardcode the test setting --- .github/workflows/build.yaml | 2 +- .github/workflows/chart-testing.yaml | 5 +- Dockerfile | 1 + README.md | 12 +++- app.py | 66 +++++++++++++++++++- chart/kronic/README.md | 5 +- chart/kronic/templates/deployment.yaml | 5 ++ chart/kronic/values.yaml | 6 +- config.py | 7 +++ docker-compose.yml | 2 + kron.py | 86 ++++++++++++++++++++------ templates/denied.html | 6 ++ templates/namespace.html | 8 ++- tests/notes.txt | 8 +++ tests/test_kron.py | 46 ++++++++++++-- 15 files changed, 228 insertions(+), 37 deletions(-) create mode 100644 config.py create mode 100644 templates/denied.html create mode 100644 tests/notes.txt diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 83d0757..12cb0bb 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -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 diff --git a/.github/workflows/chart-testing.yaml b/.github/workflows/chart-testing.yaml index a1c0e8a..afd0915 100644 --- a/.github/workflows/chart-testing.yaml +++ b/.github/workflows/chart-testing.yaml @@ -29,14 +29,15 @@ jobs: uses: helm/chart-testing-action@v2.4.0 - 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/kind-action@v1.8.0 - 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 }} diff --git a/Dockerfile b/Dockerfile index 52c4c62..9718c65 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,5 @@ FROM python:3.11-alpine as deps +ENV PYTHONUNBUFFERED=1 RUN apk add --no-cache curl diff --git a/README.md b/README.md index a843dd4..27dd782 100644 --- a/README.md +++ b/README.md @@ -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/). @@ -90,9 +96,11 @@ 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 @@ -100,6 +108,4 @@ Kronic is a small Flask app built with: - [ ] 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 diff --git a/app.py b/app.py index daa9d51..815e0c5 100644 --- a/app.py +++ b/app.py @@ -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, @@ -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", {}) @@ -34,6 +57,7 @@ def healthz(): @app.route("/") +@app.route("/namespaces/") def index(): cronjobs = get_cronjobs() namespaces = {} @@ -45,6 +69,7 @@ def index(): @app.route("/namespaces/") +@namespace_filter def view_namespace(namespace): cronjobs = get_cronjobs(namespace) cronjobs_with_details = [] @@ -63,6 +88,7 @@ def view_namespace(namespace): @app.route("/namespaces//cronjobs/", methods=["GET", "POST"]) +@namespace_filter def view_cronjob(namespace, cronjob_name): if request.method == "POST": edited_cronjob = yaml.safe_load(request.form["yaml"]) @@ -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) @@ -98,12 +147,14 @@ def api_index(): @app.route("/api/namespaces//cronjobs") @app.route("/api/namespaces/") +@namespace_filter def api_namespace(namespace): cronjobs = get_cronjobs(namespace) return cronjobs @app.route("/api/namespaces//cronjobs/") +@namespace_filter def api_get_cronjob(namespace, cronjob_name): cronjob = get_cronjob(namespace, cronjob_name) return cronjob @@ -112,6 +163,7 @@ def api_get_cronjob(namespace, cronjob_name): @app.route( "/api/namespaces//cronjobs//clone", methods=["POST"] ) +@namespace_filter def api_clone_cronjob(namespace, cronjob_name): cronjob_spec = get_cronjob(namespace, cronjob_name) new_name = request.json["name"] @@ -124,6 +176,7 @@ def api_clone_cronjob(namespace, cronjob_name): @app.route("/api/namespaces//cronjobs/create", methods=["POST"]) +@namespace_filter def api_create_cronjob(namespace): cronjob_spec = request.json["data"] cronjob = update_cronjob(namespace, cronjob_spec) @@ -133,6 +186,7 @@ def api_create_cronjob(namespace): @app.route( "/api/namespaces//cronjobs//delete", methods=["POST"] ) +@namespace_filter def api_delete_cronjob(namespace, cronjob_name): deleted = delete_cronjob(namespace, cronjob_name) return deleted @@ -142,6 +196,7 @@ def api_delete_cronjob(namespace, cronjob_name): "/api/namespaces//cronjobs//suspend", methods=["GET", "POST"], ) +@namespace_filter def api_toggle_cronjob_suspend(namespace, cronjob_name): if request.method == "GET": """Return the suspended status of the """ @@ -156,6 +211,7 @@ def api_toggle_cronjob_suspend(namespace, cronjob_name): @app.route( "/api/namespaces//cronjobs//trigger", methods=["POST"] ) +@namespace_filter def api_trigger_cronjob(namespace, cronjob_name): """Manually trigger a job from """ cronjob = trigger_cronjob(namespace, cronjob_name) @@ -167,24 +223,28 @@ def api_trigger_cronjob(namespace, cronjob_name): @app.route("/api/namespaces//cronjobs//getJobs") +@namespace_filter def api_get_jobs(namespace, cronjob_name): jobs = get_jobs_and_pods(namespace, cronjob_name) return jobs @app.route("/api/namespaces//pods") +@namespace_filter def api_get_pods(namespace): pods = get_pods(namespace) return pods @app.route("/api/namespaces//pods//logs") +@namespace_filter def api_get_pod_logs(namespace, pod_name): logs = get_pod_logs(namespace, pod_name) return logs @app.route("/api/namespaces//jobs//delete", methods=["POST"]) +@namespace_filter def api_delete_job(namespace, job_name): deleted = delete_job(namespace, job_name) return deleted diff --git a/chart/kronic/README.md b/chart/kronic/README.md index a0ed91e..dfa3a8f 100644 --- a/chart/kronic/README.md +++ b/chart/kronic/README.md @@ -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 | `""` | | @@ -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"` | | @@ -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) \ No newline at end of file +Autogenerated from chart metadata using [helm-docs v1.11.0](https://github.com/norwoodj/helm-docs/releases/v1.11.0) diff --git a/chart/kronic/templates/deployment.yaml b/chart/kronic/templates/deployment.yaml index 2a17daf..199ddbc 100644 --- a/chart/kronic/templates/deployment.yaml +++ b/chart/kronic/templates/deployment.yaml @@ -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 diff --git a/chart/kronic/values.yaml b/chart/kronic/values.yaml index 3ba4be0..d9a3687 100644 --- a/chart/kronic/values.yaml +++ b/chart/kronic/values.yaml @@ -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: diff --git a/config.py b/config.py new file mode 100644 index 0000000..b48da0e --- /dev/null +++ b/config.py @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml index 85b3b32..2b1c257 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,8 @@ services: build: context: . target: dev + environment: + KRONIC_ALLOW_NAMESPACES: "test" volumes: - .:/app - $HOME/.kube/kronic.yaml:/root/.kube/config diff --git a/kron.py b/kron.py index b6e34f7..b6e4098 100644 --- a/kron.py +++ b/kron.py @@ -1,21 +1,23 @@ import logging -import os -from kubernetes import client, config +from kubernetes import client +from kubernetes import config as kubeconfig from kubernetes.config import ConfigException from kubernetes.client.rest import ApiException from datetime import datetime, timezone from typing import List +import config + log = logging.getLogger("app.kron") -if not os.environ.get("KRONIC_TEST", False): +if not config.TEST: try: # Load configuration inside the Pod - config.load_incluster_config() + kubeconfig.load_incluster_config() except ConfigException: # Load configuration from KUBECONFIG - config.load_kube_config() + kubeconfig.load_kube_config() # Create the Api clients v1 = client.CoreV1Api() @@ -23,11 +25,27 @@ generic = client.ApiClient() -def _filter_object_fields( - response: object, fields: List[str] = ["name"] -) -> List[object]: +def namespace_filter(func): + """Decorator that short-circuits and returns False if the wrapped function attempts to access an unlisted namespace + + Args: + func (function): The function to wrap. Must have `namespace` as an arg to itself """ - Filter a given API object down to only the metadata fields listed. + def wrapper(namespace: str = None, *args, **kwargs): + if config.ALLOW_NAMESPACES and namespace: + if namespace in config.ALLOW_NAMESPACES.split(","): + return func(namespace, *args, **kwargs) + else: + return func(namespace, *args, **kwargs) + + return False + + return wrapper + + +def _filter_dict_fields(items: List[dict], fields: List[str] = ["name"]) -> List[dict]: + """ + Filter a given list of API object down to only the metadata fields listed. Args: response (Obj): A kubernetes client API object or object list. @@ -39,8 +57,7 @@ def _filter_object_fields( """ return [ - {field: getattr(item.metadata, field) for field in fields} - for item in response.items + {field: item.get("metadata").get(field) for field in fields} for item in items ] @@ -115,7 +132,8 @@ def _is_owned_by(object: object, owner_name: str) -> bool: return any(owner_ref["name"] == owner_name for owner_ref in owner_refernces) -def get_cronjobs(namespace: str = "") -> List[dict]: +@namespace_filter +def get_cronjobs(namespace: str = None) -> List[dict]: """Get names of cronjobs in a given namespace. If namespace is not provided, return CronJobs from all namespaces. @@ -126,16 +144,36 @@ def get_cronjobs(namespace: str = "") -> List[dict]: List of dict: A list of dicts containing the name and namespace of each cronjob. """ try: - if namespace == "": - cronjobs = batch.list_cron_job_for_all_namespaces() + cronjobs = [] + if not namespace: + if not config.ALLOW_NAMESPACES: + cronjobs = [ + _clean_api_object(item) + for item in batch.list_cron_job_for_all_namespaces().items + ] + else: + cronjobs = [] + for allowed in config.ALLOW_NAMESPACES.split(","): + cronjobs.extend( + [ + _clean_api_object(item) + for item in batch.list_namespaced_cron_job( + namespace=allowed + ).items + ] + ) else: - cronjobs = batch.list_namespaced_cron_job(namespace=namespace) + cronjobs = [ + _clean_api_object(item) + for item in batch.list_namespaced_cron_job(namespace=namespace).items + ] fields = ["name", "namespace"] sorted_cronjobs = sorted( - _filter_object_fields(cronjobs, fields), key=lambda x: x["name"] + _filter_dict_fields(cronjobs, fields), key=lambda x: x["name"] ) return sorted_cronjobs + except ApiException as e: log.error(e) response = { @@ -149,6 +187,7 @@ def get_cronjobs(namespace: str = "") -> List[dict]: return response +@namespace_filter def get_cronjob(namespace: str, cronjob_name: str) -> dict: """Get the details of a given CronJob as a dict @@ -166,6 +205,7 @@ def get_cronjob(namespace: str, cronjob_name: str) -> dict: return False +@namespace_filter def get_jobs(namespace: str, cronjob_name: str) -> List[dict]: """Return jobs belonging to a given CronJob name @@ -184,7 +224,7 @@ def get_jobs(namespace: str, cronjob_name: str) -> List[dict]: job for job in cleaned_jobs if _is_owned_by(job, cronjob_name) - or _has_label(job, "kron.mshade.org/created-from", cronjob_name) + or _has_label(job, "kronic.mshade.org/created-from", cronjob_name) ] for job in filtered_jobs: @@ -205,6 +245,7 @@ def get_jobs(namespace: str, cronjob_name: str) -> List[dict]: return response +@namespace_filter def get_pods(namespace: str, job_name: str = None) -> List[dict]: """Return pods related to jobs in a namespace @@ -240,6 +281,7 @@ def get_pods(namespace: str, job_name: str = None) -> List[dict]: return response +@namespace_filter def get_jobs_and_pods(namespace: str, cronjob_name: str) -> List[dict]: """Get jobs and their pods under a `pods` element for display purposes @@ -257,6 +299,7 @@ def get_jobs_and_pods(namespace: str, cronjob_name: str) -> List[dict]: return jobs +@namespace_filter def get_pod_logs(namespace: str, pod_name: str) -> str: """Return plain text logs for in """ try: @@ -270,6 +313,7 @@ def get_pod_logs(namespace: str, pod_name: str) -> str: return f"Kronic> Error fetching logs: {e.reason}" +@namespace_filter def trigger_cronjob(namespace: str, cronjob_name: str) -> dict: try: # Retrieve the CronJob template @@ -277,9 +321,9 @@ def trigger_cronjob(namespace: str, cronjob_name: str) -> dict: job_template = cronjob.spec.job_template # Create a unique name indicating a manual invocation - date_stamp = datetime.now().strftime("%Y%m%d%H%M%S-%f") + date_stamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S-%f") job_template.metadata.name = ( - f"{job_template.metadata.name[:16]}-manual-{date_stamp}"[:63] + f"{cronjob.metadata.name[:16]}-manual-{date_stamp}"[:63] ) # Set labels to identify jobs created by kronic @@ -306,6 +350,7 @@ def trigger_cronjob(namespace: str, cronjob_name: str) -> dict: return response +@namespace_filter def toggle_cronjob_suspend(namespace: str, cronjob_name: str) -> dict: """Toggle a CronJob's suspend flag on or off @@ -339,6 +384,7 @@ def toggle_cronjob_suspend(namespace: str, cronjob_name: str) -> dict: return response +@namespace_filter def update_cronjob(namespace: str, spec: str) -> dict: """Update/edit a CronJob configuration via patch @@ -370,6 +416,7 @@ def update_cronjob(namespace: str, spec: str) -> dict: return response +@namespace_filter def delete_cronjob(namespace: str, cronjob_name: str) -> dict: """Delete a CronJob @@ -397,6 +444,7 @@ def delete_cronjob(namespace: str, cronjob_name: str) -> dict: return response +@namespace_filter def delete_job(namespace: str, job_name: str) -> dict: """Delete a Job diff --git a/templates/denied.html b/templates/denied.html new file mode 100644 index 0000000..4d531c4 --- /dev/null +++ b/templates/denied.html @@ -0,0 +1,6 @@ +{% extends 'base.html' %} +{% block content %} +

{% block title %}Access denied to {{data.namespace}}{% endblock %}

+

Namespaces allowed: {{data.allowed_namespaces}}

+ +{% endblock %} \ No newline at end of file diff --git a/templates/namespace.html b/templates/namespace.html index b6929e6..1737dc6 100644 --- a/templates/namespace.html +++ b/templates/namespace.html @@ -2,7 +2,13 @@ {% block content %} «back -

{% block title %}CronJobs in {{ namespace }} {% endblock %}

+
+

{% block title %}CronJobs in {{ namespace }} {% endblock %}

+
Create CronJob
+
{% for cronjob in cronjobs %}