From b431f5cadbe5f0b16c79173b5d57f46b21ab5bf4 Mon Sep 17 00:00:00 2001 From: eding Date: Sat, 22 Aug 2026 13:58:28 +0200 Subject: [PATCH] feat: add preview orphan reconciler --- catalog/kustomization.yaml | 1 + catalog/maidn-preview-orphan-reconciler.yaml | 276 +++++++++++++++++++ catalog/test-preview-orphan-reconciler.sh | 115 ++++++++ 3 files changed, 392 insertions(+) create mode 100644 catalog/maidn-preview-orphan-reconciler.yaml create mode 100644 catalog/test-preview-orphan-reconciler.sh diff --git a/catalog/kustomization.yaml b/catalog/kustomization.yaml index b70b0c7..3698186 100644 --- a/catalog/kustomization.yaml +++ b/catalog/kustomization.yaml @@ -2,3 +2,4 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - maidn-node-static-image.yaml + - maidn-preview-orphan-reconciler.yaml diff --git a/catalog/maidn-preview-orphan-reconciler.yaml b/catalog/maidn-preview-orphan-reconciler.yaml new file mode 100644 index 0000000..1cbd84d --- /dev/null +++ b/catalog/maidn-preview-orphan-reconciler.yaml @@ -0,0 +1,276 @@ +apiVersion: tekton.dev/v1 +kind: Task +metadata: + name: maidn-preview-orphan-reconciler + namespace: tekton-pipelines + annotations: + # The environment GitOps repo owns this ConfigMap; render it before enabling this Task. + maidn.io/delivery-config: maidn-preview-delivery-config + # The TaskRun service account supplies this annotated Git Secret through Tekton's initializer. + maidn.io/git-credentials: forgejo-git-credentials +spec: + stepTemplate: + env: + - name: HOME + value: /tekton/home + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + seccompProfile: + type: RuntimeDefault + params: + - name: app-name + type: string + - name: app-repository + type: string + - name: pr-number + type: string + steps: + - name: verify-closed-pr + image: python:3.13-alpine3.21 + env: + - name: TRUSTED_FORGEJO_ORIGIN + valueFrom: + configMapKeyRef: + name: maidn-preview-delivery-config + key: forgejo-origin + - name: TRUSTED_MANIFESTS_URL + valueFrom: + configMapKeyRef: + name: maidn-preview-delivery-config + key: manifests-url + - name: TRUSTED_MANIFESTS_BRANCH + valueFrom: + configMapKeyRef: + name: maidn-preview-delivery-config + key: manifests-branch + - name: APP_NAME + value: $(params.app-name) + - name: APP_REPOSITORY + value: $(params.app-repository) + - name: PR_NUMBER + value: $(params.pr-number) + script: | + #!/bin/sh + set -eu + fail() { exit 1; } + valid_forgejo_origin() { + case "$1" in https://*) ;; *) fail ;; esac + host=${1#https://} + case "$host" in ''|.*|*..*|*.) fail ;; esac + case "$host" in *[!A-Za-z0-9.-]*) fail ;; esac + } + valid_git_url() { + case "$1" in https://*/*.git) ;; *) fail ;; esac + repository=${1#https://} + host=${repository%%/*} + path=${repository#*/} + case "$host" in ''|.*|*..*|*.) fail ;; esac + case "$host" in *[!A-Za-z0-9.-]*) fail ;; esac + case "$path" in ''|*[!A-Za-z0-9._/-]*|/*|*//*|*..*) fail ;; esac + [ "https://$host" = "$TRUSTED_FORGEJO_ORIGIN" ] || fail + } + valid_branch() { + case "$1" in [A-Za-z0-9]*) ;; *) fail ;; esac + case "$1" in *[!A-Za-z0-9._/-]*|*..*|*//*|*/) fail ;; esac + } + valid_name() { + case "$1" in ''|*[!a-z0-9-]*|-*|*-) fail ;; esac + [ "${#1}" -le 63 ] || fail + } + valid_repository() { + case "$1" in */*) ;; *) fail ;; esac + owner=${1%%/*} + repository=${1#*/} + [ "$owner/$repository" = "$1" ] || fail + case "$owner" in ''|.*|*.|*[!A-Za-z0-9._-]*|*..*) fail ;; esac + case "$repository" in ''|.*|*.|*[!A-Za-z0-9._-]*|*..*) fail ;; esac + } + valid_pr_number() { + case "$1" in [1-9]*) ;; *) fail ;; esac + case "$1" in *[!0-9]*) fail ;; esac + [ $((${#APP_NAME} + ${#1} + 4)) -le 63 ] || fail + } + valid_forgejo_origin "$TRUSTED_FORGEJO_ORIGIN" + valid_git_url "$TRUSTED_MANIFESTS_URL" + valid_branch "$TRUSTED_MANIFESTS_BRANCH" + valid_name "$APP_NAME" + valid_repository "$APP_REPOSITORY" + valid_pr_number "$PR_NUMBER" + python3 - <<'PY' + import base64 + import json + import os + import sys + from pathlib import Path + from urllib.error import URLError + from urllib.parse import urlsplit + from urllib.request import HTTPRedirectHandler, Request, build_opener + + origin = urlsplit(os.environ["TRUSTED_FORGEJO_ORIGIN"]) + try: + lines = Path(os.environ["HOME"], ".git-credentials").read_text().splitlines() + matches = [] + for line in lines: + credential = urlsplit(line) + if ( + credential.scheme == "https" + and credential.hostname == origin.hostname + and credential.path in ("", "/") + and not credential.query + and not credential.fragment + and credential.username + and credential.password + ): + matches.append((credential.username, credential.password)) + except (OSError, ValueError): + sys.exit(1) + if len(matches) != 1: + sys.exit(1) + repository = os.environ["APP_REPOSITORY"] + number = int(os.environ["PR_NUMBER"]) + credentials = "%s:%s" % matches[0] + request = Request("%s/api/v1/repos/%s/pulls/%s" % (os.environ["TRUSTED_FORGEJO_ORIGIN"], repository, number)) + request.add_header("Authorization", "Basic " + base64.b64encode(credentials.encode()).decode()) + request.add_header("Accept", "application/json") + class NoRedirect(HTTPRedirectHandler): + def redirect_request(self, request, fp, code, msg, headers, url): + return None + try: + with build_opener(NoRedirect).open(request, timeout=20) as response: + pull = json.load(response) + except (URLError, ValueError, json.JSONDecodeError): + sys.exit(1) + if type(pull) is not dict or type(pull.get("number")) is not int or pull["number"] != number or pull.get("state") != "closed": + sys.exit(1) + PY + - name: reconcile + image: alpine/git:2.47.2 + env: + - name: TRUSTED_FORGEJO_ORIGIN + valueFrom: + configMapKeyRef: + name: maidn-preview-delivery-config + key: forgejo-origin + - name: TRUSTED_MANIFESTS_URL + valueFrom: + configMapKeyRef: + name: maidn-preview-delivery-config + key: manifests-url + - name: TRUSTED_MANIFESTS_BRANCH + valueFrom: + configMapKeyRef: + name: maidn-preview-delivery-config + key: manifests-branch + - name: APP_NAME + value: $(params.app-name) + - name: APP_REPOSITORY + value: $(params.app-repository) + - name: PR_NUMBER + value: $(params.pr-number) + script: | + #!/bin/sh + set -eu + fail() { exit 1; } + valid_forgejo_origin() { + case "$1" in https://*) ;; *) fail ;; esac + host=${1#https://} + case "$host" in ''|.*|*..*|*.) fail ;; esac + case "$host" in *[!A-Za-z0-9.-]*) fail ;; esac + } + valid_git_url() { + case "$1" in https://*/*.git) ;; *) fail ;; esac + repository=${1#https://} + host=${repository%%/*} + path=${repository#*/} + case "$host" in ''|.*|*..*|*.) fail ;; esac + case "$host" in *[!A-Za-z0-9.-]*) fail ;; esac + case "$path" in ''|*[!A-Za-z0-9._/-]*|/*|*//*|*..*) fail ;; esac + [ "https://$host" = "$TRUSTED_FORGEJO_ORIGIN" ] || fail + } + valid_branch() { + case "$1" in [A-Za-z0-9]*) ;; *) fail ;; esac + case "$1" in *[!A-Za-z0-9._/-]*|*..*|*//*|*/) fail ;; esac + } + valid_name() { + case "$1" in ''|*[!a-z0-9-]*|-*|*-) fail ;; esac + [ "${#1}" -le 63 ] || fail + } + valid_repository() { + case "$1" in */*) ;; *) fail ;; esac + owner=${1%%/*} + repository=${1#*/} + [ "$owner/$repository" = "$1" ] || fail + case "$owner" in ''|.*|*.|*[!A-Za-z0-9._-]*|*..*) fail ;; esac + case "$repository" in ''|.*|*.|*[!A-Za-z0-9._-]*|*..*) fail ;; esac + } + valid_pr_number() { + case "$1" in [1-9]*) ;; *) fail ;; esac + case "$1" in *[!0-9]*) fail ;; esac + [ $((${#APP_NAME} + ${#1} + 4)) -le 63 ] || fail + } + valid_forgejo_origin "$TRUSTED_FORGEJO_ORIGIN" + valid_git_url "$TRUSTED_MANIFESTS_URL" + valid_branch "$TRUSTED_MANIFESTS_BRANCH" + valid_name "$APP_NAME" + valid_repository "$APP_REPOSITORY" + valid_pr_number "$PR_NUMBER" + namespace="$APP_NAME-pr-$PR_NUMBER" + preview_path="apps/previews/$namespace" + umask 077 + private_dir=$(mktemp -d) + chmod 700 "$private_dir" + trap 'rm -rf -- "$private_dir"' EXIT HUP INT TERM + repository_path="$private_dir/manifests" + git clone --branch "$TRUSTED_MANIFESTS_BRANCH" "$TRUSTED_MANIFESTS_URL" "$repository_path" + [ "$(git -C "$repository_path" remote get-url origin)" = "$TRUSTED_MANIFESTS_URL" ] || fail + git -C "$repository_path" rev-parse --verify "refs/heads/$TRUSTED_MANIFESTS_BRANCH" >/dev/null + cd "$repository_path" + [ -d apps ] && [ ! -L apps ] || fail + [ -d apps/previews ] && [ ! -L apps/previews ] || fail + [ -d "$preview_path" ] && [ ! -L "$preview_path" ] || fail + marker="$preview_path/ownership.yaml" + [ -f "$marker" ] && [ ! -L "$marker" ] || fail + for existing in "$preview_path"/* "$preview_path"/.[!.]* "$preview_path"/..?*; do + [ -e "$existing" ] || [ -L "$existing" ] || continue + [ -f "$existing" ] && [ ! -L "$existing" ] || fail + case "${existing##*/}" in namespace.yaml|ownership.yaml|values.yaml|kustomization.yaml|release.yaml) ;; *) fail ;; esac + done + expected_marker=$(mktemp "$private_dir/expected-marker.XXXXXX") + cat > "$expected_marker" < "$tmp/reconcile.sh" + +mkdir "$tmp/bin" "$tmp/fixture" +cat > "$tmp/bin/git" <<'EOF' +#!/bin/sh +set -eu +case "$1" in + clone) + for arg; do target=$arg; done + cp -R "$TEST_FIXTURE" "$target" + ;; + -C) + shift 2 + case "$1" in + remote) + [ "$2" = get-url ] && [ "$3" = origin ] || exit 1 + printf '%s\n' "$TEST_ORIGIN" + ;; + rev-parse|config|add|push) exit 0 ;; + diff) exit 1 ;; + esac + ;; + diff) exit 1 ;; + commit) + [ ! -e "$PWD/apps/previews/web-ui-pr-42" ] + ! grep -qF ' - web-ui-pr-42' "$PWD/apps/previews/kustomization.yaml" + ;; + config|add|push) exit 0 ;; + *) exit 1 ;; +esac +EOF +chmod +x "$tmp/bin/git" + +marker() { + cat <<'EOF' +apiVersion: v1 +kind: ConfigMap +metadata: + name: maidn-preview-owner + namespace: web-ui-pr-42 + labels: + maidn.io/preview-owner: "true" + maidn.io/preview-app: "web-ui" + maidn.io/preview-pr: "42" + annotations: + maidn.io/preview-repository: "platform/web-ui" +data: + app: "web-ui" + repository: "platform/web-ui" + pr-number: "42" +EOF +} + +fixture() { + dir=$1 + mkdir -p "$dir/apps/previews/web-ui-pr-42" + cat > "$dir/apps/previews/kustomization.yaml" <<'EOF' +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - web-ui-pr-42 +EOF + marker > "$dir/apps/previews/web-ui-pr-42/ownership.yaml" + : > "$dir/apps/previews/web-ui-pr-42/namespace.yaml" + : > "$dir/apps/previews/web-ui-pr-42/values.yaml" + : > "$dir/apps/previews/web-ui-pr-42/kustomization.yaml" + : > "$dir/apps/previews/web-ui-pr-42/release.yaml" +} + +run() { + TRUSTED_FORGEJO_ORIGIN=https://git.example.invalid \ + TRUSTED_MANIFESTS_URL=https://git.example.invalid/platform/manifests.git \ + TRUSTED_MANIFESTS_BRANCH=main APP_NAME="$1" APP_REPOSITORY=platform/web-ui PR_NUMBER=42 \ + TEST_FIXTURE="$tmp/fixture" PATH="$tmp/bin:$PATH" \ + TEST_ORIGIN="${TEST_ORIGIN:-https://git.example.invalid/platform/manifests.git}" \ + sh "$tmp/reconcile.sh" +} + +fixture "$tmp/fixture" +run web-ui +[ -f "$tmp/fixture/apps/previews/web-ui-pr-42/ownership.yaml" ] +grep -qF ' - web-ui-pr-42' "$tmp/fixture/apps/previews/kustomization.yaml" + +rm -rf "$tmp/fixture" +fixture "$tmp/fixture" +printf '\n' >> "$tmp/fixture/apps/previews/web-ui-pr-42/ownership.yaml" +if run web-ui; then exit 1; fi +[ -f "$tmp/fixture/apps/previews/web-ui-pr-42/ownership.yaml" ] + +rm -rf "$tmp/fixture" +fixture "$tmp/fixture" +rm "$tmp/fixture/apps/previews/web-ui-pr-42/ownership.yaml" +ln -s /tmp/not-owned "$tmp/fixture/apps/previews/web-ui-pr-42/ownership.yaml" +if run web-ui; then exit 1; fi + +rm -rf "$tmp/fixture" +fixture "$tmp/fixture" +TEST_ORIGIN=https://git.example.invalid/platform/other.git +if run web-ui; then exit 1; fi +if run 'web-ui/escape'; then exit 1; fi