apiVersion: tekton.dev/v1 kind: Task metadata: name: {{ .AppName }}-update-manifest namespace: tekton-pipelines 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: manifests-url - name: manifests-branch - name: app-name - name: app-repository - name: image - name: tag - name: environment - name: pr-number default: "" - name: app-url default: "" - name: app-revision default: "" - name: forgejo-base-url default: "" - name: forgejo-owner default: "" - name: manifests-repo default: "" steps: - name: update image: alpine/git:2.47.2 env: - name: MANIFESTS_URL value: $(params.manifests-url) - name: MANIFESTS_BRANCH value: $(params.manifests-branch) - name: APP_NAME value: $(params.app-name) - name: APP_REPOSITORY value: $(params.app-repository) - name: IMAGE value: $(params.image) - name: TAG value: $(params.tag) - name: ENVIRONMENT value: $(params.environment) - name: PR_NUMBER value: $(params.pr-number) - name: APP_URL value: $(params.app-url) - name: APP_REVISION value: $(params.app-revision) - name: FORGEJO_BASE_URL value: $(params.forgejo-base-url) - name: FORGEJO_OWNER value: $(params.forgejo-owner) - name: MANIFESTS_REPO value: $(params.manifests-repo) script: | #!/bin/sh set -eu fail() { exit 1; } valid_name() { case "$1" in ''|*[!a-z0-9-]*|-*|*-) fail ;; esac; } valid_revision() { case "$1" in [A-Za-z0-9]*) ;; *) fail ;; esac; case "$1" in *[!A-Za-z0-9._/-]*|*..*|*//*|*/) fail ;; esac; } valid_commit() { [ "${#1}" -eq 40 ] || fail; case "$1" in *[!0-9a-fA-F]*) fail ;; esac; } valid_url() { case "$1" in https://*/*.git) ;; *) fail ;; esac; case "$1" in *[@?#]*) fail ;; esac; } valid_repository() { case "$1" in */*) ;; *) fail ;; esac; case "$1" in *..*|*//*|/*|*/) fail ;; esac; } valid_repository_part() { case "$1" in ''|*[!A-Za-z0-9._-]*|.*|*.) fail ;; esac; } valid_forgejo_base_url() { case "$1" in https://*) ;; *) fail ;; esac; host=${1#https://}; case "$host" in *:*) name=${host%:*}; port=${host##*:}; case "$port" in ''|*[!0-9]*) fail ;; esac ;; *) name=$host ;; esac; case "$name" 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_name "$APP_NAME" valid_url "$MANIFESTS_URL" valid_revision "$MANIFESTS_BRANCH" valid_repository "$APP_REPOSITORY" valid_commit "$TAG" if [ "$ENVIRONMENT" = production ]; then valid_forgejo_base_url "$FORGEJO_BASE_URL" valid_repository_part "$FORGEJO_OWNER" valid_repository_part "$MANIFESTS_REPO" [ "$MANIFESTS_URL" = "$FORGEJO_BASE_URL/$FORGEJO_OWNER/$MANIFESTS_REPO.git" ] || fail PROMOTION_BRANCH="maidn/promotion-$APP_NAME-$TAG" if git ls-remote --exit-code "$MANIFESTS_URL" "refs/heads/$PROMOTION_BRANCH" >/dev/null 2>&1; then git clone --branch "$PROMOTION_BRANCH" "$MANIFESTS_URL" /tmp/manifests else git clone --branch "$MANIFESTS_BRANCH" "$MANIFESTS_URL" /tmp/manifests git -C /tmp/manifests checkout -b "$PROMOTION_BRANCH" fi else git clone --branch "$MANIFESTS_BRANCH" "$MANIFESTS_URL" /tmp/manifests fi cd /tmp/manifests if [ "$ENVIRONMENT" = preview ]; then valid_pr_number "$PR_NUMBER" valid_url "$APP_URL" valid_revision "$APP_REVISION" app_dir="apps/previews/$APP_NAME-pr-$PR_NUMBER" marker="$app_dir/ownership.yaml" if [ -e "$app_dir" ]; then [ -d "$app_dir" ] && [ ! -L "$app_dir" ] && [ -f "$marker" ] && [ ! -L "$marker" ] || fail expected_marker=$(mktemp) trap 'rm -f "$expected_marker"' EXIT cat > "$expected_marker" < "$marker" < "$app_dir/namespace.yaml" < "$app_dir/release.yaml" < "$app_dir/kustomization.yaml" </dev/null || printf ' - %s-pr-%s\n' "$APP_NAME" "$PR_NUMBER" >> "$root" elif [ "$ENVIRONMENT" = staging ]; then app_dir="apps/staging/$APP_NAME" root=apps/staging/kustomization.yaml if [ -e "$app_dir/release.yaml" ]; then [ -d "$app_dir" ] && [ ! -L "$app_dir" ] && [ -f "$app_dir/release.yaml" ] && [ ! -L "$app_dir/release.yaml" ] || fail sed -i -E "s|^([[:space:]]*tag:).*|\1 $TAG|" "$app_dir/release.yaml" grep -qxF " namespace: staging" "$app_dir/release.yaml" || sed -i "/^ name: $APP_NAME$/a\ namespace: staging" "$app_dir/release.yaml" grep -qxF " namespace: staging" "$app_dir/release.yaml" || fail else [ ! -e "$app_dir" ] || fail mkdir -p "$app_dir" cat > "$app_dir/release.yaml" < "$app_dir/kustomization.yaml" < "$root" </dev/null || printf ' - %s\n' "$APP_NAME" >> "$root" fi elif [ "$ENVIRONMENT" = production ]; then app_dir="apps/production/$APP_NAME" root=apps/production/kustomization.yaml if [ -e "$app_dir" ]; then [ -d "$app_dir" ] && [ ! -L "$app_dir" ] || fail fi mkdir -p "$app_dir" for file in "$app_dir/release.yaml" "$app_dir/kustomization.yaml"; do [ ! -e "$file" ] || { [ -f "$file" ] && [ ! -L "$file" ]; } || fail done cat > "$app_dir/release.yaml" < "$app_dir/kustomization.yaml" < "$root" </dev/null || printf ' - %s\n' "$APP_NAME" >> "$root" else fail fi git config user.name Maidn git config user.email maidn@free-maidn.com git add apps git diff --cached --quiet || git commit -m "chore: deploy $APP_NAME $TAG" if [ "$ENVIRONMENT" = production ]; then git push origin "HEAD:$PROMOTION_BRANCH" credentials=$(printf 'url=%s\n\n' "$MANIFESTS_URL" | GIT_TERMINAL_PROMPT=0 git credential fill) forgejo_user=$(printf '%s\n' "$credentials" | sed -n 's/^username=//p') forgejo_password=$(printf '%s\n' "$credentials" | sed -n 's/^password=//p') unset credentials [ -n "$forgejo_user" ] && [ -n "$forgejo_password" ] || fail forgejo_auth=$(printf '%s:%s' "$forgejo_user" "$forgejo_password" | base64 | tr -d '\n') unset forgejo_user forgejo_password pr_endpoint="$FORGEJO_BASE_URL/api/v1/repos/$FORGEJO_OWNER/$MANIFESTS_REPO/pulls" pr_query="$pr_endpoint?state=open&head=$FORGEJO_OWNER%3A$PROMOTION_BRANCH" pr_response=$(mktemp) pr_body=$(mktemp) trap 'rm -f "$pr_response" "$pr_body"' EXIT open_pr_count() { grep -q '^[[:space:]]*\[' "$pr_response" || fail grep -o '"number"[[:space:]]*:[[:space:]]*[0-9][0-9]*' "$pr_response" | wc -l | tr -d ' ' } wget -q -O "$pr_response" --header="Authorization: Basic $forgejo_auth" "$pr_query" || fail case "$(open_pr_count)" in 0) printf '{"title":"chore: promote %s %s","head":"%s","base":"%s"}' "$APP_NAME" "$TAG" "$PROMOTION_BRANCH" "$MANIFESTS_BRANCH" > "$pr_body" wget -q -O "$pr_response" --header="Authorization: Basic $forgejo_auth" --header="Content-Type: application/json" --post-file "$pr_body" "$pr_endpoint" || true wget -q -O "$pr_response" --header="Authorization: Basic $forgejo_auth" "$pr_query" || fail [ "$(open_pr_count)" = 1 ] || fail ;; 1) ;; *) fail ;; esac promotion_pr_number=$(grep -o '"number"[[:space:]]*:[[:space:]]*[0-9][0-9]*' "$pr_response" | sed -n '1s/.*:[[:space:]]*//p') valid_pr_number "$promotion_pr_number" printf 'Promotion PR opened or updated: %s/%s/%s/pulls/%s\n' "$FORGEJO_BASE_URL" "$FORGEJO_OWNER" "$MANIFESTS_REPO" "$promotion_pr_number" unset forgejo_auth else git push origin "$MANIFESTS_BRANCH" fi --- apiVersion: tekton.dev/v1 kind: Task metadata: name: {{ .AppName }}-wait-delivery namespace: tekton-pipelines spec: params: - name: app-name - name: environment - name: image - name: tag - name: pr-number default: "" results: - name: preview-url description: Verified preview or staging HTTPRoute URL. stepTemplate: env: - name: HOME value: /tmp securityContext: runAsNonRoot: true runAsUser: 1000 runAsGroup: 1000 allowPrivilegeEscalation: false capabilities: drop: [ALL] seccompProfile: type: RuntimeDefault steps: - name: wait-for-traffic image: alpine/k8s:1.33.4 env: - name: APP_NAME value: $(params.app-name) - name: ENVIRONMENT value: $(params.environment) - name: PR_NUMBER value: $(params.pr-number) - name: IMAGE value: $(params.image) - name: TAG value: $(params.tag) - name: RESULT_PATH value: $(results.preview-url.path) script: | #!/bin/sh set -eu fail() { exit 1; } valid_app() { case "$1" in ''|*[!a-z0-9-]*|-*|*-) fail ;; esac; [ "${#1}" -le 47 ] || fail; } valid_pr() { case "$1" in [1-9]*) ;; *) fail ;; esac; case "$1" in *[!0-9]*) fail ;; esac; } valid_image() { case "$1" in ''|/*|*/|*..*|*//*|*[!A-Za-z0-9._/:-]*) fail ;; esac; } valid_tag() { [ "${#1}" -eq 40 ] || fail; case "$1" in *[!0-9a-fA-F]*) fail ;; esac; } valid_host() { case "$1" in ''|.*|*.) fail ;; esac; case "$1" in *[!A-Za-z0-9.-]*) fail ;; esac; } valid_app "$APP_NAME" valid_image "$IMAGE" valid_tag "$TAG" case "$ENVIRONMENT" in preview) valid_pr "$PR_NUMBER" namespace="$APP_NAME-pr-$PR_NUMBER" ;; staging) namespace=staging ;; *) fail ;; esac attempts=120 while :; do deployed_image=$(kubectl -n "$namespace" get "deployment/$APP_NAME" -o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || true) [ "$deployed_image" = "$IMAGE:$TAG" ] && break attempts=$((attempts - 1)) [ "$attempts" -gt 0 ] || fail sleep 5 done kubectl -n "$namespace" rollout status "deployment/$APP_NAME" --timeout=600s kubectl -n "$namespace" wait --for=condition=Available "deployment/$APP_NAME" --timeout=600s kubectl -n "$namespace" wait --for=jsonpath='{.status.parents[0].conditions[?(@.type=="Accepted")].status}'=True "httproute/$APP_NAME" --timeout=600s host=$(kubectl -n "$namespace" get "httproute/$APP_NAME" -o jsonpath='{.spec.hostnames[0]}') valid_host "$host" url="https://$host" attempts=12 while ! wget -q --spider --timeout=10 "$url" >/dev/null 2>&1; do attempts=$((attempts - 1)) [ "$attempts" -gt 0 ] || fail sleep 5 done printf '%s' "$url" > "$RESULT_PATH" --- apiVersion: tekton.dev/v1 kind: Task metadata: name: {{ .AppName }}-report-delivery namespace: tekton-pipelines spec: params: - name: app-name - name: app-repository - name: pr-number - name: event-action - name: pipeline-run - name: clone-status - name: build-status - name: push-status - name: update-status - name: readiness-status - name: cleanup-status volumes: - name: delivery-status emptyDir: {} stepTemplate: securityContext: runAsNonRoot: true runAsUser: 1000 runAsGroup: 1000 allowPrivilegeEscalation: false capabilities: drop: [ALL] seccompProfile: type: RuntimeDefault steps: - name: read-preview-url image: alpine/k8s:1.33.4 env: - name: APP_NAME value: $(params.app-name) - name: PR_NUMBER value: $(params.pr-number) - name: EVENT_ACTION value: $(params.event-action) - name: READINESS_STATUS value: $(params.readiness-status) volumeMounts: - name: delivery-status mountPath: /delivery script: | #!/bin/sh set -eu fail() { exit 1; } valid_app() { case "$1" in ''|*[!a-z0-9-]*|-*|*-) fail ;; esac; [ "${#1}" -le 47 ] || fail; } valid_pr() { case "$1" in [1-9]*) ;; *) fail ;; esac; case "$1" in *[!0-9]*) fail ;; esac; } valid_host() { case "$1" in ''|.*|*.) fail ;; esac; case "$1" in *[!A-Za-z0-9.-]*) fail ;; esac; } [ "$EVENT_ACTION" = closed ] && exit 0 [ "$READINESS_STATUS" = Succeeded ] || exit 0 valid_app "$APP_NAME" valid_pr "$PR_NUMBER" host=$(kubectl -n "$APP_NAME-pr-$PR_NUMBER" get "httproute/$APP_NAME" -o jsonpath='{.spec.hostnames[0]}') valid_host "$host" printf 'https://%s' "$host" > /delivery/preview-url - name: update-pr-comment image: python:3.13-alpine env: - name: FORGEJO_BASE_URL value: {{ quote .ForgejoBaseURL }} - name: APP_NAME value: $(params.app-name) - name: APP_REPOSITORY value: $(params.app-repository) - name: PR_NUMBER value: $(params.pr-number) - name: EVENT_ACTION value: $(params.event-action) - name: PIPELINE_RUN value: $(params.pipeline-run) - name: CLONE_STATUS value: $(params.clone-status) - name: BUILD_STATUS value: $(params.build-status) - name: PUSH_STATUS value: $(params.push-status) - name: UPDATE_STATUS value: $(params.update-status) - name: READINESS_STATUS value: $(params.readiness-status) - name: CLEANUP_STATUS value: $(params.cleanup-status) - name: FORGEJO_DELIVERY_TOKEN valueFrom: secretKeyRef: name: forgejo-delivery-status key: token - name: TEKTON_DASHBOARD_URL valueFrom: configMapKeyRef: name: maidn-preview-delivery-config key: tekton-dashboard-url optional: true volumeMounts: - name: delivery-status mountPath: /delivery script: | import json import os import re import sys from pathlib import Path from urllib.parse import quote, urlsplit from urllib.request import Request, urlopen marker = "" statuses = {"Succeeded", "Failed", "None", "Skipped", "Cancelled", "Unknown", "Pending"} def fail(): raise ValueError def origin(value): parsed = urlsplit(value) if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password or parsed.path or parsed.query or parsed.fragment: fail() if not re.fullmatch(r"[A-Za-z0-9.-]+(?::[0-9]{1,5})?", parsed.netloc): fail() return value def status(name): value = os.environ.get(name, "Unknown") return value if value in statuses else "Unknown" def request(method, endpoint, payload=None): data = None if payload is None else json.dumps(payload).encode() req = Request(endpoint, data=data, method=method) req.add_header("Authorization", "token " + token) req.add_header("Content-Type", "application/json") with urlopen(req, timeout=15) as response: return json.load(response) if response.length != 0 else None try: base = origin(os.environ["FORGEJO_BASE_URL"]) app = os.environ["APP_NAME"] repository = os.environ["APP_REPOSITORY"] pr = os.environ["PR_NUMBER"] run = os.environ["PIPELINE_RUN"] action = os.environ["EVENT_ACTION"] token = os.environ["FORGEJO_DELIVERY_TOKEN"] if not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,45}[a-z0-9])?", app) or not re.fullmatch(r"[1-9][0-9]{0,8}", pr) or not re.fullmatch(r"[a-z0-9](?:[a-z0-9.-]{0,61}[a-z0-9])?", run) or action not in {"opened", "reopened", "synchronize", "closed"} or not token: fail() owner, repo = repository.split("/", 1) if not all(re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?", part) and ".." not in part for part in (owner, repo)): fail() task_status = {name: status(name) for name in ("CLONE_STATUS", "BUILD_STATUS", "PUSH_STATUS", "UPDATE_STATUS", "READINESS_STATUS", "CLEANUP_STATUS")} if action == "closed": delivery = "closed" preview = "Preview cleanup requested." elif task_status["READINESS_STATUS"] == "Succeeded" and task_status["UPDATE_STATUS"] == "Succeeded": delivery = "ready" preview_url = Path("/delivery/preview-url").read_text() if Path("/delivery/preview-url").is_file() else "" parsed_preview = urlsplit(preview_url) if parsed_preview.scheme != "https" or not re.fullmatch(r"[A-Za-z0-9.-]+", parsed_preview.netloc) or parsed_preview.path or parsed_preview.query or parsed_preview.fragment: fail() preview = "Preview: " + preview_url else: delivery = "failed" preview = "Preview unavailable." dashboard = os.environ.get("TEKTON_DASHBOARD_URL", "").strip() run_text = "PipelineRun: `" + run + "`" if dashboard: dashboard = origin(dashboard.rstrip("/")) run_text += " ([details](" + dashboard + "/#/pipelineruns/tekton-pipelines/" + quote(run, safe="") + "))" summary = ", ".join(name.removesuffix("_STATUS").lower() + "=" + value for name, value in task_status.items()) body = "\n".join((marker, "## Maidn delivery", "Status: **" + delivery + "**", preview, run_text, "Summary: " + summary)) endpoint = base + "/api/v1/repos/" + quote(owner, safe="") + "/" + quote(repo, safe="") + "/issues/" + pr + "/comments" comments = request("GET", endpoint + "?limit=100") matches = [comment for comment in comments if marker in comment.get("body", "")] if len(matches) > 1: fail() if matches: request("PATCH", base + "/api/v1/repos/" + quote(owner, safe="") + "/" + quote(repo, safe="") + "/issues/comments/" + str(matches[0]["id"]), {"body": body}) else: request("POST", endpoint, {"body": body}) except Exception: sys.exit("delivery status update failed") --- apiVersion: tekton.dev/v1 kind: Pipeline metadata: name: {{ .AppName }} namespace: tekton-pipelines spec: params: - name: git-url default: {{ quote .AppRepoURL }} - name: git-revision default: {{ quote .AppRepoRef }} - name: branch default: {{ quote .AppRepoRef }} - name: event-type default: push - name: event-action default: "" - name: pr-number default: "" - name: image default: {{ quote .ImageRepository }} - name: manifests-url default: {{ quote .ManifestsURL }} - name: manifests-branch default: {{ quote .ManifestsBranch }} - name: forgejo-base-url default: {{ quote .ForgejoBaseURL }} - name: forgejo-owner default: {{ quote .ForgejoOwner }} - name: manifests-repo default: {{ quote .ManifestsRepo }} tasks: - name: build-layer when: - input: $(params.event-action) operator: notin values: [closed] taskRef: name: maidn-node-static-image params: - name: url value: {{ quote .AppRepoURL }} - name: revision value: $(params.git-revision) - name: image value: $(params.image) - name: output-directory value: {{ quote .BuildOutputDirectory }} - name: build-configuration value: {{ quote .BuildConfiguration }} - name: update-preview runAfter: [build-layer] when: - input: $(params.event-type) operator: in values: [pull_request] - input: $(params.event-action) operator: in values: [opened, reopened, synchronize] taskRef: name: {{ .AppName }}-update-manifest params: - name: manifests-url value: $(params.manifests-url) - name: manifests-branch value: $(params.manifests-branch) - name: app-name value: {{ quote .AppName }} - name: app-repository value: {{ quote .AppRepository }} - name: image value: $(params.image) - name: tag value: $(params.git-revision) - name: environment value: preview - name: pr-number value: $(params.pr-number) - name: app-url value: {{ quote .AppRepoURL }} - name: app-revision value: $(params.git-revision) - name: update-staging runAfter: [build-layer] when: - input: $(params.event-type) operator: in values: [push] - input: $(params.branch) operator: in values: [{{ quote .AppRepoRef }}] taskRef: name: {{ .AppName }}-update-manifest params: - name: manifests-url value: $(params.manifests-url) - name: manifests-branch value: $(params.manifests-branch) - name: app-name value: {{ quote .AppName }} - name: app-repository value: {{ quote .AppRepository }} - name: image value: $(params.image) - name: tag value: $(params.git-revision) - name: environment value: staging - name: wait-preview runAfter: [update-preview] when: - input: $(params.event-type) operator: in values: [pull_request] - input: $(params.event-action) operator: in values: [opened, reopened, synchronize] taskRef: name: {{ .AppName }}-wait-delivery params: - name: app-name value: {{ quote .AppName }} - name: environment value: preview - name: image value: $(params.image) - name: tag value: $(params.git-revision) - name: pr-number value: $(params.pr-number) - name: wait-staging runAfter: [update-staging] when: - input: $(params.event-type) operator: in values: [push] - input: $(params.branch) operator: in values: [{{ quote .AppRepoRef }}] taskRef: name: {{ .AppName }}-wait-delivery params: - name: app-name value: {{ quote .AppName }} - name: environment value: staging - name: image value: $(params.image) - name: tag value: $(params.git-revision) - name: promote-production runAfter: [build-layer] when: - input: $(params.event-type) operator: in values: [promotion] - input: $(params.branch) operator: in values: [{{ quote .ProductionBranch }}] taskRef: name: {{ .AppName }}-update-manifest params: - name: manifests-url value: $(params.manifests-url) - name: manifests-branch value: $(params.manifests-branch) - name: app-name value: {{ quote .AppName }} - name: app-repository value: {{ quote .AppRepository }} - name: image value: $(params.image) - name: tag value: $(params.git-revision) - name: environment value: production - name: forgejo-base-url value: $(params.forgejo-base-url) - name: forgejo-owner value: $(params.forgejo-owner) - name: manifests-repo value: $(params.manifests-repo) - name: cleanup-preview when: - input: $(params.event-type) operator: in values: [pull_request] - input: $(params.event-action) operator: in values: [closed] taskRef: name: maidn-preview-orphan-reconciler params: - name: app-name value: {{ quote .AppName }} - name: pr-number value: $(params.pr-number) - name: app-repository value: {{ quote .AppRepository }} finally: - name: report-delivery when: - input: $(params.event-type) operator: in values: [pull_request] taskRef: name: {{ .AppName }}-report-delivery params: - name: app-name value: {{ quote .AppName }} - name: app-repository value: {{ quote .AppRepository }} - name: pr-number value: $(params.pr-number) - name: event-action value: $(params.event-action) - name: pipeline-run value: $(context.pipelineRun.name) - name: clone-status value: $(tasks.build-layer.status) - name: build-status value: $(tasks.build-layer.status) - name: push-status value: $(tasks.build-layer.status) - name: update-status value: $(tasks.update-preview.status) - name: readiness-status value: $(tasks.wait-preview.status) - name: cleanup-status value: $(tasks.cleanup-preview.status)