From 76a4987eef9bb9dd143853ba81c41288dd52316d Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 7 Mar 2026 21:09:31 +0000 Subject: [PATCH] [ci skip] add Forgejo task pipeline for OpenClaw AI agent Forgejo issues as a task queue for OpenClaw: - Forgejo OAuth2 with Authentik SSO, self-registration disabled - Webhook-triggered task processing (instant) + CronJob backup (5min poll) - Tasks processed via Mistral Large 3 (NVIDIA NIM API) - Results posted as issue comments, auto-labeled and closed - Comment follow-ups and reopened issues supported - n8n RBAC for OpenClaw pod exec (future workflow integration) --- scripts/setup-task-pipeline.sh | 231 +++++++++++++++++++++++++++ scripts/task-processor.sh | 261 +++++++++++++++++++++++++++++++ stacks/forgejo/main.tf | 28 ++++ stacks/n8n/main.tf | 44 ++++++ stacks/openclaw/main.tf | 278 +++++++++++++++++++++++++++++++++ 5 files changed, 842 insertions(+) create mode 100755 scripts/setup-task-pipeline.sh create mode 100755 scripts/task-processor.sh diff --git a/scripts/setup-task-pipeline.sh b/scripts/setup-task-pipeline.sh new file mode 100755 index 00000000..0b0a8fdd --- /dev/null +++ b/scripts/setup-task-pipeline.sh @@ -0,0 +1,231 @@ +#!/usr/bin/env bash +# +# Setup script for the Forgejo task ingestion pipeline. +# Creates Authentik OAuth2 provider/application, configures Forgejo OAuth2 auth source, +# creates "tasks" repo, and sets up webhook to n8n. +# +# Prerequisites: +# - Authentik admin API token +# - Forgejo admin API token (create at https://forgejo.viktorbarzin.me/user/settings/applications) +# +# Usage: +# AUTHENTIK_TOKEN="..." FORGEJO_TOKEN="..." bash scripts/setup-task-pipeline.sh + +set -euo pipefail + +AUTHENTIK_URL="${AUTHENTIK_URL:-https://authentik.viktorbarzin.me}" +FORGEJO_URL="${FORGEJO_URL:-https://forgejo.viktorbarzin.me}" +N8N_WEBHOOK_URL="${N8N_WEBHOOK_URL:-https://n8n.viktorbarzin.me/webhook/forgejo-tasks}" +FORGEJO_ADMIN_USER="${FORGEJO_ADMIN_USER:-viktor}" + +: "${AUTHENTIK_TOKEN:?Set AUTHENTIK_TOKEN (Authentik admin API token)}" +: "${FORGEJO_TOKEN:?Set FORGEJO_TOKEN (Forgejo admin API token)}" + +ak_api() { curl -sf -H "Authorization: Bearer $AUTHENTIK_TOKEN" -H "Content-Type: application/json" "$@"; } +fg_api() { curl -sf -H "Authorization: token $FORGEJO_TOKEN" -H "Content-Type: application/json" "$@"; } + +echo "=== Step 1: Create Authentik group 'Task Submitters' ===" +GROUP_RESP=$(ak_api "$AUTHENTIK_URL/api/v3/core/groups/" -d '{ + "name": "Task Submitters", + "is_superuser": false, + "parent": null +}' 2>/dev/null) || { + echo " Group may already exist, checking..." + GROUP_RESP=$(ak_api "$AUTHENTIK_URL/api/v3/core/groups/?name=Task+Submitters" | python3 -c "import sys,json; r=json.load(sys.stdin)['results']; print(json.dumps(r[0]) if r else '')") + if [ -z "$GROUP_RESP" ]; then echo "ERROR: Failed to create or find group"; exit 1; fi +} +GROUP_PK=$(echo "$GROUP_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['pk'])") +echo " Group PK: $GROUP_PK" + +echo "" +echo "=== Step 2: Create Authentik OAuth2 Provider for Forgejo ===" +# Find the explicit consent authorization flow +AUTH_FLOW=$(ak_api "$AUTHENTIK_URL/api/v3/flows/instances/?designation=authorization&search=explicit" | \ + python3 -c "import sys,json; r=json.load(sys.stdin)['results']; print(r[0]['pk'] if r else '')") +if [ -z "$AUTH_FLOW" ]; then + echo " WARNING: Could not find explicit consent flow, using implicit" + AUTH_FLOW=$(ak_api "$AUTHENTIK_URL/api/v3/flows/instances/?designation=authorization" | \ + python3 -c "import sys,json; r=json.load(sys.stdin)['results']; print(r[0]['pk'] if r else '')") +fi +echo " Authorization flow: $AUTH_FLOW" + +PROVIDER_RESP=$(ak_api "$AUTHENTIK_URL/api/v3/providers/oauth2/" -d "{ + \"name\": \"Forgejo\", + \"authorization_flow\": \"$AUTH_FLOW\", + \"client_type\": \"confidential\", + \"redirect_uris\": \"$FORGEJO_URL/user/oauth2/Authentik/callback\", + \"property_mappings\": [], + \"sub_mode\": \"hashed_user_id\", + \"include_claims_in_id_token\": true, + \"access_code_validity\": \"minutes=1\", + \"access_token_validity\": \"minutes=5\", + \"refresh_token_validity\": \"days=30\" +}" 2>/dev/null) || { + echo " Provider may already exist, checking..." + PROVIDER_RESP=$(ak_api "$AUTHENTIK_URL/api/v3/providers/oauth2/?name=Forgejo" | \ + python3 -c "import sys,json; r=json.load(sys.stdin)['results']; print(json.dumps(r[0]) if r else '')") + if [ -z "$PROVIDER_RESP" ]; then echo "ERROR: Failed to create or find provider"; exit 1; fi +} +PROVIDER_PK=$(echo "$PROVIDER_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['pk'])") +CLIENT_ID=$(echo "$PROVIDER_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['client_id'])") +CLIENT_SECRET=$(echo "$PROVIDER_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('client_secret',''))") +echo " Provider PK: $PROVIDER_PK" +echo " Client ID: $CLIENT_ID" +echo " Client Secret: $CLIENT_SECRET" + +echo "" +echo "=== Step 3: Create Authentik Application for Forgejo ===" +APP_RESP=$(ak_api "$AUTHENTIK_URL/api/v3/core/applications/" -d "{ + \"name\": \"Forgejo\", + \"slug\": \"forgejo\", + \"provider\": $PROVIDER_PK, + \"meta_launch_url\": \"$FORGEJO_URL\", + \"policy_engine_mode\": \"any\" +}" 2>/dev/null) || { + echo " Application may already exist, checking..." + APP_RESP=$(ak_api "$AUTHENTIK_URL/api/v3/core/applications/?slug=forgejo" | \ + python3 -c "import sys,json; r=json.load(sys.stdin)['results']; print(json.dumps(r[0]) if r else '')") +} +APP_SLUG=$(echo "$APP_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['slug'])") +echo " Application slug: $APP_SLUG" + +echo "" +echo "=== Step 4: Bind 'Task Submitters' group to Forgejo application ===" +# Create a policy binding that restricts access to the Task Submitters group +ak_api "$AUTHENTIK_URL/api/v3/policies/bindings/" -d "{ + \"target\": \"$(echo "$APP_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['pk'])")\", + \"group\": \"$GROUP_PK\", + \"enabled\": true, + \"order\": 0, + \"negate\": false, + \"timeout\": 30 +}" > /dev/null 2>&1 || echo " Binding may already exist (OK)" +echo " Group binding created" + +echo "" +echo "=== Step 5: Add users to 'Task Submitters' group ===" +echo " Adding Viktor Barzin..." +VIKTOR_PK=$(ak_api "$AUTHENTIK_URL/api/v3/core/users/?search=vbarzin" | \ + python3 -c "import sys,json; r=json.load(sys.stdin)['results']; print(r[0]['pk'] if r else '')") +if [ -n "$VIKTOR_PK" ]; then + ak_api "$AUTHENTIK_URL/api/v3/core/groups/$GROUP_PK/" -X PATCH -d "{}" > /dev/null 2>&1 || true + ak_api -X POST "$AUTHENTIK_URL/api/v3/core/groups/$GROUP_PK/add_user/" -d "{\"pk\": $VIKTOR_PK}" > /dev/null 2>&1 || true + echo " Added Viktor (PK: $VIKTOR_PK)" +fi + +echo "" +echo "=== Step 6: Configure Forgejo OAuth2 authentication source ===" +fg_api "$FORGEJO_URL/api/v1/admin/identity-sources" -d "{ + \"authentication_source\": { + \"name\": \"Authentik\", + \"type\": \"oauth2\", + \"is_active\": true, + \"is_sync_enabled\": false, + \"oauth2\": { + \"provider\": \"openidConnect\", + \"client_id\": \"$CLIENT_ID\", + \"client_secret\": \"$CLIENT_SECRET\", + \"open_id_connect_auto_discovery_url\": \"$AUTHENTIK_URL/application/o/forgejo/.well-known/openid-configuration\", + \"scopes\": [\"openid\", \"profile\", \"email\"], + \"required_claim_name\": \"\", + \"required_claim_value\": \"\", + \"group_claim_name\": \"\", + \"admin_group\": \"\", + \"restricted_group\": \"\", + \"icon_url\": \"\", + \"skip_local_2fa\": true, + \"attribute_ssn\": \"\" + } + } +}" > /dev/null 2>&1 && echo " OAuth2 source created" || { + echo " Forgejo identity-sources API may not be available." + echo " Falling back to legacy authentication-source API..." + fg_api "$FORGEJO_URL/api/v1/admin/auths" -d "{ + \"name\": \"Authentik\", + \"type\": 6, + \"is_active\": true, + \"is_sync_enabled\": false, + \"cfg\": { + \"Provider\": \"openidConnect\", + \"ClientID\": \"$CLIENT_ID\", + \"ClientSecret\": \"$CLIENT_SECRET\", + \"OpenIDConnectAutoDiscoveryURL\": \"$AUTHENTIK_URL/application/o/forgejo/.well-known/openid-configuration\", + \"Scopes\": [\"openid\", \"profile\", \"email\"], + \"SkipLocalTwoFA\": true + } + }" > /dev/null 2>&1 && echo " OAuth2 source created (legacy API)" || { + echo " ERROR: Could not create OAuth2 source via API." + echo " Please create it manually in Forgejo admin panel:" + echo " 1. Go to $FORGEJO_URL/-/admin/auths/new" + echo " 2. Auth Type: OAuth2" + echo " 3. Name: Authentik" + echo " 4. OAuth2 Provider: OpenID Connect" + echo " 5. Client ID: $CLIENT_ID" + echo " 6. Client Secret: $CLIENT_SECRET" + echo " 7. Discovery URL: $AUTHENTIK_URL/application/o/forgejo/.well-known/openid-configuration" + echo " 8. Scopes: openid profile email" + } +} + +echo "" +echo "=== Step 7: Create 'tasks' repository in Forgejo ===" +REPO_RESP=$(fg_api "$FORGEJO_URL/api/v1/user/repos" -d '{ + "name": "tasks", + "description": "Task queue for OpenClaw AI agent. Create an issue to submit a task.", + "private": false, + "auto_init": true, + "default_branch": "main" +}' 2>/dev/null) && echo " Repository created" || { + echo " Repository may already exist (OK)" + REPO_RESP=$(fg_api "$FORGEJO_URL/api/v1/repos/$FORGEJO_ADMIN_USER/tasks") +} +echo " Repo: $FORGEJO_URL/$FORGEJO_ADMIN_USER/tasks" + +echo "" +echo "=== Step 8: Disable non-issue features on tasks repo ===" +fg_api "$FORGEJO_URL/api/v1/repos/$FORGEJO_ADMIN_USER/tasks" -X PATCH -d '{ + "has_pull_requests": false, + "has_wiki": false, + "has_projects": false, + "has_releases": false, + "has_packages": false, + "has_actions": false +}' > /dev/null 2>&1 && echo " Disabled PRs, wiki, projects, releases, packages, actions" || echo " Some features may not be disableable (OK)" + +echo "" +echo "=== Step 9: Create issue labels ===" +for label_data in \ + '{"name":"pending","color":"#0075ca","description":"Task waiting to be processed"}' \ + '{"name":"processing","color":"#e4e669","description":"Task currently being processed by OpenClaw"}' \ + '{"name":"completed","color":"#0e8a16","description":"Task completed successfully"}' \ + '{"name":"failed","color":"#d73a4a","description":"Task failed during processing"}'; do + fg_api "$FORGEJO_URL/api/v1/repos/$FORGEJO_ADMIN_USER/tasks/labels" -d "$label_data" > /dev/null 2>&1 || true +done +echo " Labels created: pending, processing, completed, failed" + +echo "" +echo "=== Step 10: Create webhook on tasks repo → n8n ===" +fg_api "$FORGEJO_URL/api/v1/repos/$FORGEJO_ADMIN_USER/tasks/hooks" -d "{ + \"type\": \"gitea\", + \"config\": { + \"url\": \"$N8N_WEBHOOK_URL\", + \"content_type\": \"json\", + \"secret\": \"\" + }, + \"events\": [\"issues\"], + \"active\": true +}" > /dev/null 2>&1 && echo " Webhook created → $N8N_WEBHOOK_URL" || echo " Webhook may already exist (OK)" + +echo "" +echo "==========================================" +echo "Setup complete!" +echo "" +echo "Next steps:" +echo " 1. Add SOPS secrets:" +echo " forgejo_authentik_client_id = \"$CLIENT_ID\"" +echo " forgejo_authentik_client_secret = \"$CLIENT_SECRET\"" +echo " 2. Run: scripts/tg apply -target=module.forgejo" +echo " 3. Create n8n workflow (webhook trigger → OpenClaw exec → Forgejo comment)" +echo " 4. Add more users to 'Task Submitters' group in Authentik" +echo " 5. Test: Create an issue at $FORGEJO_URL/$FORGEJO_ADMIN_USER/tasks/issues/new" +echo "==========================================" diff --git a/scripts/task-processor.sh b/scripts/task-processor.sh new file mode 100755 index 00000000..25db2a22 --- /dev/null +++ b/scripts/task-processor.sh @@ -0,0 +1,261 @@ +#!/usr/bin/env bash +# +# Task processor for the Forgejo → OpenClaw pipeline. +# Polls Forgejo for new issues in the tasks repo, sends them to OpenClaw +# for processing, and posts results back as comments. +# +# Runs inside the OpenClaw pod via kubectl exec from a CronJob. +# +# Environment: +# FORGEJO_TOKEN — Forgejo API token with repo access +# FORGEJO_URL — Forgejo base URL (default: https://forgejo.viktorbarzin.me) +# FORGEJO_REPO — Repo in format "owner/repo" (default: vbarzin/tasks) +# OPENCLAW_URL — OpenClaw gateway URL (default: http://127.0.0.1:18789) +# OPENCLAW_TOKEN — OpenClaw gateway token +# SLACK_WEBHOOK_URL — Optional Slack webhook for notifications + +set -euo pipefail + +FORGEJO_URL="${FORGEJO_URL:-https://forgejo.viktorbarzin.me}" +FORGEJO_REPO="${FORGEJO_REPO:-viktor/tasks}" +OPENCLAW_URL="${OPENCLAW_URL:-https://integrate.api.nvidia.com}" +SLACK_WEBHOOK_URL="${SLACK_WEBHOOK_URL:-}" + +: "${FORGEJO_TOKEN:?FORGEJO_TOKEN is required}" +: "${OPENCLAW_TOKEN:?OPENCLAW_TOKEN is required}" +FORGEJO_BOT_USER="${FORGEJO_BOT_USER:-viktor}" + +fg_api() { + curl -sf -H "Authorization: token $FORGEJO_TOKEN" -H "Content-Type: application/json" "$@" +} + +get_label_id() { + local label_name="$1" + fg_api "$FORGEJO_URL/api/v1/repos/$FORGEJO_REPO/labels?limit=50" | \ + python3 -c " +import sys, json +labels = json.load(sys.stdin) +name = sys.argv[1] +for l in labels: + if l['name'] == name: + print(l['id']) + break +else: + print(0) +" "$label_name" +} + +add_label() { + local issue_id="$1" label_name="$2" + local label_id + label_id=$(get_label_id "$label_name") + if [ "$label_id" != "0" ]; then + fg_api "$FORGEJO_URL/api/v1/repos/$FORGEJO_REPO/issues/$issue_id/labels" \ + -d "{\"labels\":[$label_id]}" > /dev/null 2>&1 || true + fi +} + +remove_label() { + local issue_id="$1" label_name="$2" + local label_id + label_id=$(get_label_id "$label_name") + if [ "$label_id" != "0" ]; then + fg_api -X DELETE "$FORGEJO_URL/api/v1/repos/$FORGEJO_REPO/issues/$issue_id/labels/$label_id" > /dev/null 2>&1 || true + fi +} + +post_comment() { + local issue_id="$1" + # Read comment body from stdin to avoid quoting issues + python3 -c " +import sys, json +body = sys.stdin.read() +print(json.dumps({'body': body})) +" | fg_api "$FORGEJO_URL/api/v1/repos/$FORGEJO_REPO/issues/$issue_id/comments" -d @- > /dev/null 2>&1 +} + +close_issue() { + local issue_id="$1" + fg_api "$FORGEJO_URL/api/v1/repos/$FORGEJO_REPO/issues/$issue_id" \ + -X PATCH -d '{"state": "closed"}' > /dev/null 2>&1 +} + +get_comment_history() { + local issue_id="$1" + fg_api "$FORGEJO_URL/api/v1/repos/$FORGEJO_REPO/issues/$issue_id/comments?limit=20" 2>/dev/null | \ + python3 -c " +import sys, json +bot_user = sys.argv[1] +comments = json.load(sys.stdin) +history = [] +for c in comments: + user = c.get('user', {}).get('login', 'unknown') + body = c.get('body', '') + # Skip bot's own comments to keep context clean + if user == bot_user: + # Include a short summary of previous responses + if '## OpenClaw Task Result' in body: + # Extract just the result content (skip header/footer) + lines = body.split('\n') + content = [l for l in lines if not l.startswith('## ') and not l.startswith('---') and not l.startswith('*Processed')] + summary = '\n'.join(content).strip()[:500] + if summary: + history.append(f'[Previous AI response]: {summary}') + else: + history.append(f'[{user}]: {body}') +print('\n\n'.join(history)) +" "$FORGEJO_BOT_USER" 2>/dev/null +} + +notify_slack() { + if [ -n "$SLACK_WEBHOOK_URL" ]; then + python3 -c " +import json, sys +print(json.dumps({'text': sys.argv[1]})) +" "$1" | curl -sf -X POST "$SLACK_WEBHOOK_URL" \ + -H "Content-Type: application/json" -d @- > /dev/null 2>&1 || true + fi +} + +process_issue() { + local issue_id="$1" title="$2" body="$3" author="$4" + + echo "Processing issue #$issue_id: $title (by $author)" + + # Mark as processing + add_label "$issue_id" "processing" + remove_label "$issue_id" "pending" + remove_label "$issue_id" "completed" + + # Fetch comment history for context + local comment_history + comment_history=$(get_comment_history "$issue_id") + + # Call OpenClaw gateway API (OpenAI-compatible chat completions) + # Use python to safely build the JSON payload + local response + response=$(python3 -c " +import json, sys +title = sys.argv[1] +body = sys.argv[2] +author = sys.argv[3] +comment_history = sys.argv[4] + +prompt = f'''You are processing a task submitted by {author} via the Forgejo task queue. + +Task title: {title} + +Task description: +{body}''' + +if comment_history.strip(): + prompt += f''' + +Conversation history (follow-up comments): +{comment_history} + +The latest comment is the most recent request. Address it in context of the original task and prior conversation.''' + +prompt += ''' + +Please execute this task. When done, provide a clear summary of what was done and any results. +If the task requires infrastructure changes, describe what changes would be needed but do NOT apply them automatically — list the commands/changes for review.''' + +payload = { + 'model': 'mistralai/mistral-large-3-675b-instruct-2512', + 'messages': [ + {'role': 'system', 'content': 'You are an infrastructure AI assistant. Process the task and provide actionable results. Be concise.'}, + {'role': 'user', 'content': prompt} + ], + 'max_tokens': 8192, + 'temperature': 0.3 +} +print(json.dumps(payload)) +" "$title" "$body" "$author" "$comment_history" | \ + curl -sf --max-time 300 \ + -H "Authorization: Bearer $OPENCLAW_TOKEN" \ + -H "Content-Type: application/json" \ + "$OPENCLAW_URL/v1/chat/completions" \ + -d @- 2>&1) || { + echo " ERROR: OpenClaw API call failed" + echo "Failed to process this task. OpenClaw API returned an error. Please check the CronJob logs or process manually." | \ + post_comment "$issue_id" + add_label "$issue_id" "failed" + remove_label "$issue_id" "processing" + notify_slack ":x: Task #$issue_id failed: $title" + return 1 + } + + # Extract the response content and post as comment + python3 -c " +import sys, json +try: + data = json.load(sys.stdin) + msg = data['choices'][0]['message'] + # Some models put content in reasoning_content instead of content + result = msg.get('content') or msg.get('reasoning_content') or msg.get('reasoning') or 'No response generated.' +except Exception as e: + result = f'Error parsing OpenClaw response: {e}' + +body = f'## OpenClaw Task Result\n\n{result}\n\n---\n*Processed automatically by the OpenClaw task pipeline.*' +print(body) +" <<< "$response" | post_comment "$issue_id" + + # Update labels and close + add_label "$issue_id" "completed" + remove_label "$issue_id" "processing" + close_issue "$issue_id" + + echo " Issue #$issue_id processed and closed" + notify_slack ":white_check_mark: Task #$issue_id completed: $title" +} + +# --- Main --- + +echo "=== Task Processor $(date -u +%Y-%m-%dT%H:%M:%SZ) ===" + +# List open issues +ISSUES=$(fg_api "$FORGEJO_URL/api/v1/repos/$FORGEJO_REPO/issues?state=open&type=issues&limit=10&sort=created&direction=asc" 2>/dev/null) || { + echo "ERROR: Could not fetch issues from Forgejo" + exit 1 +} + +# Parse pending issues into a temp file (avoids delimiter issues) +PENDING_FILE=$(mktemp) +trap 'rm -f "$PENDING_FILE"' EXIT + +python3 -c " +import sys, json +issues = json.load(sys.stdin) +for issue in issues: + labels = [l['name'] for l in issue.get('labels', [])] + # Process if: no processing label AND (no completed label OR issue was reopened) + if 'processing' not in labels: + # Write each issue as a JSON line + print(json.dumps({ + 'id': issue['number'], + 'title': issue['title'], + 'body': (issue.get('body') or '')[:4000], + 'author': issue['user']['login'] + })) +" <<< "$ISSUES" > "$PENDING_FILE" + +ISSUE_COUNT=$(wc -l < "$PENDING_FILE" | tr -d ' ') + +if [ "$ISSUE_COUNT" = "0" ]; then + echo "No pending issues to process" + exit 0 +fi + +echo "Found $ISSUE_COUNT pending issue(s)" + +# Process each pending issue (one JSON object per line) +while IFS= read -r line; do + issue_id=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['id'])" "$line") + title=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['title'])" "$line") + body=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['body'])" "$line") + author=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['author'])" "$line") + process_issue "$issue_id" "$title" "$body" "$author" || true +done < "$PENDING_FILE" + +echo "=== Task processing complete ===" diff --git a/stacks/forgejo/main.tf b/stacks/forgejo/main.tf index ffc04e18..bc47b9c0 100644 --- a/stacks/forgejo/main.tf +++ b/stacks/forgejo/main.tf @@ -3,6 +3,11 @@ variable "tls_secret_name" { sensitive = true } variable "nfs_server" { type = string } +variable "forgejo_authentik_client_id" { type = string } +variable "forgejo_authentik_client_secret" { + type = string + sensitive = true +} resource "kubernetes_namespace" "forgejo" { @@ -66,6 +71,29 @@ resource "kubernetes_deployment" "forgejo" { name = "USER_GID" value = 1000 } + # Root URL for OAuth2 redirect callbacks + env { + name = "FORGEJO__server__ROOT_URL" + value = "https://forgejo.viktorbarzin.me" + } + # Disable local registration — only allow OAuth2 (Authentik) + env { + name = "FORGEJO__service__DISABLE_REGISTRATION" + value = "false" + } + env { + name = "FORGEJO__service__ALLOW_ONLY_EXTERNAL_REGISTRATION" + value = "true" + } + env { + name = "FORGEJO__openid__ENABLE_OPENID_SIGNIN" + value = "false" + } + # Allow webhook delivery to internal k8s services + env { + name = "FORGEJO__webhook__ALLOWED_HOST_LIST" + value = "*.svc.cluster.local" + } volume_mount { name = "data" mount_path = "/data" diff --git a/stacks/n8n/main.tf b/stacks/n8n/main.tf index 47fcfe56..d2b7ec4e 100644 --- a/stacks/n8n/main.tf +++ b/stacks/n8n/main.tf @@ -33,6 +33,49 @@ module "nfs_data" { nfs_path = "/mnt/main/n8n" } +# --- RBAC: Allow n8n to exec into OpenClaw pods for task execution --- + +resource "kubernetes_service_account" "n8n" { + metadata { + name = "n8n" + namespace = kubernetes_namespace.n8n.metadata[0].name + } +} + +resource "kubernetes_role" "n8n_openclaw_exec" { + metadata { + name = "n8n-openclaw-exec" + namespace = "openclaw" + } + rule { + api_groups = [""] + resources = ["pods"] + verbs = ["get", "list"] + } + rule { + api_groups = [""] + resources = ["pods/exec"] + verbs = ["create"] + } +} + +resource "kubernetes_role_binding" "n8n_openclaw_exec" { + metadata { + name = "n8n-openclaw-exec" + namespace = "openclaw" + } + subject { + kind = "ServiceAccount" + name = kubernetes_service_account.n8n.metadata[0].name + namespace = kubernetes_namespace.n8n.metadata[0].name + } + role_ref { + api_group = "rbac.authorization.k8s.io" + kind = "Role" + name = kubernetes_role.n8n_openclaw_exec.metadata[0].name + } +} + resource "kubernetes_deployment" "n8n" { metadata { name = "n8n" @@ -56,6 +99,7 @@ resource "kubernetes_deployment" "n8n" { } } spec { + service_account_name = kubernetes_service_account.n8n.metadata[0].name container { name = "n8n" image = "docker.n8n.io/n8nio/n8n" diff --git a/stacks/openclaw/main.tf b/stacks/openclaw/main.tf index a6e50a46..d1d6ed20 100644 --- a/stacks/openclaw/main.tf +++ b/stacks/openclaw/main.tf @@ -30,6 +30,10 @@ variable "openclaw_telegram_bot_token" { type = string sensitive = true } +variable "forgejo_api_token" { + type = string + sensitive = true +} variable "nfs_server" { type = string } @@ -660,6 +664,199 @@ module "ingress" { } } +# --- Webhook receiver: triggers task-processor Job on Forgejo issue events --- + +resource "kubernetes_config_map" "task_webhook" { + metadata { + name = "task-webhook" + namespace = kubernetes_namespace.openclaw.metadata[0].name + } + data = { + "server.py" = <<-PYEOF + from http.server import HTTPServer, BaseHTTPRequestHandler + import subprocess, time, json, os + + BOT_USER = os.environ.get('FORGEJO_BOT_USER', 'viktor') + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + try: + body = self.rfile.read(int(self.headers.get('Content-Length', 0))) + data = json.loads(body) + action = data.get('action', '') + + # Trigger on: new issue, reopened issue, or new comment + trigger = False + if action in ('opened', 'reopened'): + issue = data.get('issue', {}) + print(f"Issue #{issue.get('number','?')} {action}: {issue.get('title','?')}") + trigger = True + elif action == 'created' and 'comment' in data: + comment = data.get('comment', {}) + commenter = comment.get('user', {}).get('login', '') + # Skip comments from the bot itself to avoid loops + if commenter != BOT_USER: + issue = data.get('issue', {}) + print(f"Comment on #{issue.get('number','?')} by {commenter}") + trigger = True + else: + print(f"Skipping own comment on #{data.get('issue',{}).get('number','?')}") + + if trigger: + job_name = f"task-processor-{int(time.time())}" + subprocess.run([ + 'kubectl', 'create', 'job', job_name, + '--from=cronjob/task-processor', + '-n', 'openclaw' + ], check=True) + self.send_response(200) + self.end_headers() + self.wfile.write(b'{"ok":true}') + else: + self.send_response(200) + self.end_headers() + self.wfile.write(b'{"ok":true,"skipped":true}') + except Exception as e: + print(f"Error: {e}") + self.send_response(500) + self.end_headers() + self.wfile.write(f'{{"error":"{e}"}}'.encode()) + + def do_GET(self): + self.send_response(200) + self.end_headers() + self.wfile.write(b'{"status":"ok"}') + + def log_message(self, fmt, *args): + print(f"[webhook] {args[0]} {args[1]} {args[2]}") + + print("Task webhook receiver listening on :8080") + HTTPServer(('', 8080), Handler).serve_forever() + PYEOF + } +} + +resource "kubernetes_service_account" "task_webhook" { + metadata { + name = "task-webhook" + namespace = kubernetes_namespace.openclaw.metadata[0].name + } +} + +resource "kubernetes_role" "task_webhook" { + metadata { + name = "task-webhook-job-creator" + namespace = kubernetes_namespace.openclaw.metadata[0].name + } + rule { + api_groups = ["batch"] + resources = ["jobs", "cronjobs"] + verbs = ["get", "list", "create"] + } +} + +resource "kubernetes_role_binding" "task_webhook" { + metadata { + name = "task-webhook-job-creator" + namespace = kubernetes_namespace.openclaw.metadata[0].name + } + subject { + kind = "ServiceAccount" + name = kubernetes_service_account.task_webhook.metadata[0].name + namespace = kubernetes_namespace.openclaw.metadata[0].name + } + role_ref { + api_group = "rbac.authorization.k8s.io" + kind = "Role" + name = kubernetes_role.task_webhook.metadata[0].name + } +} + +resource "kubernetes_deployment" "task_webhook" { + metadata { + name = "task-webhook" + namespace = kubernetes_namespace.openclaw.metadata[0].name + labels = { + app = "task-webhook" + tier = local.tiers.aux + } + } + spec { + replicas = 1 + selector { + match_labels = { + app = "task-webhook" + } + } + template { + metadata { + labels = { + app = "task-webhook" + } + } + spec { + service_account_name = kubernetes_service_account.task_webhook.metadata[0].name + container { + name = "webhook" + image = "python:3-alpine" + command = ["sh", "-c", "apk add --no-cache curl > /dev/null 2>&1 && curl -sfL https://dl.k8s.io/release/v1.34.2/bin/linux/amd64/kubectl -o /usr/local/bin/kubectl && chmod +x /usr/local/bin/kubectl && exec python3 -u /app/server.py"] + port { + container_port = 8080 + } + volume_mount { + name = "app" + mount_path = "/app" + } + resources { + requests = { + cpu = "5m" + memory = "32Mi" + } + limits = { + cpu = "100m" + memory = "64Mi" + } + } + } + volume { + name = "app" + config_map { + name = kubernetes_config_map.task_webhook.metadata[0].name + } + } + } + } + } +} + +resource "kubernetes_service" "task_webhook" { + metadata { + name = "task-webhook" + namespace = kubernetes_namespace.openclaw.metadata[0].name + labels = { + app = "task-webhook" + } + } + spec { + selector = { + app = "task-webhook" + } + port { + port = 80 + target_port = 8080 + } + } +} + +module "task_webhook_ingress" { + source = "../../modules/kubernetes/ingress_factory" + namespace = kubernetes_namespace.openclaw.metadata[0].name + name = "task-webhook" + tls_secret_name = var.tls_secret_name + host = "task-webhook" + port = 80 +} + # --- CronJob: Scheduled cluster health check --- resource "kubernetes_service_account" "healthcheck" { @@ -768,3 +965,84 @@ resource "kubernetes_cron_job_v1" "cluster_healthcheck" { } } } + +# --- CronJob: Task processor — polls Forgejo issues and triggers OpenClaw --- + +resource "kubernetes_cron_job_v1" "task_processor" { + metadata { + name = "task-processor" + namespace = kubernetes_namespace.openclaw.metadata[0].name + labels = { + app = "task-processor" + tier = local.tiers.aux + } + } + spec { + schedule = "*/5 * * * *" + concurrency_policy = "Forbid" + failed_jobs_history_limit = 3 + successful_jobs_history_limit = 3 + + job_template { + metadata { + labels = { + app = "task-processor" + } + } + spec { + active_deadline_seconds = 600 + backoff_limit = 0 + template { + metadata { + labels = { + app = "task-processor" + } + } + spec { + service_account_name = kubernetes_service_account.healthcheck.metadata[0].name + restart_policy = "Never" + + container { + name = "task-processor" + image = "bitnami/kubectl:latest" + command = ["bash", "-c", <<-EOF + # Find the openclaw pod + POD=$(kubectl get pods -n openclaw -l app=openclaw -o jsonpath='{.items[0].metadata.name}' 2>/dev/null) + if [ -z "$POD" ]; then + echo "ERROR: OpenClaw pod not found" + exit 1 + fi + echo "Executing task processor in pod $POD..." + kubectl exec -n openclaw "$POD" -c openclaw -- \ + env FORGEJO_TOKEN="$FORGEJO_TOKEN" \ + OPENCLAW_TOKEN="$OPENCLAW_TOKEN" \ + OPENCLAW_URL="https://integrate.api.nvidia.com" \ + bash /workspace/infra/scripts/task-processor.sh + EOF + ] + + env { + name = "FORGEJO_TOKEN" + value = var.forgejo_api_token + } + env { + name = "OPENCLAW_TOKEN" + value = var.nvidia_api_key + } + + resources { + requests = { + cpu = "50m" + memory = "64Mi" + } + limits = { + memory = "128Mi" + } + } + } + } + } + } + } + } +}