Add central CI/CD controller for all Gitea projects

- Webhook-based controller listens for push events from all repos
- Auto-detects Dockerfile, triggers Kaniko build, pushes to registry
- Updates gitops-infra kustomization with new image tag
- Auto-scaffolds gitops environment for new projects
- Ignores non-main branches and repos in ignore list (gitops-infra)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 21:08:02 +00:00
parent 478fc5a2b6
commit 9fad0b80c5
3 changed files with 683 additions and 0 deletions

View File

@@ -0,0 +1,572 @@
#!/usr/bin/env python3
"""
Gitea CI/CD Webhook Controller
Listens for push events from all Gitea repos and triggers automated build & deploy.
"""
import json
import os
import subprocess
import hmac
import hashlib
import logging
import time
import threading
from http.server import HTTPServer, BaseHTTPRequestHandler
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
logger = logging.getLogger('ci-controller')
# Configuration from environment
GITEA_URL = os.environ.get('GITEA_URL', 'http://gitea-http.gitea.svc:3000')
GITEA_USER = os.environ.get('GITEA_USER', 'gitea_admin')
GITEA_PASSWORD = os.environ.get('GITEA_PASSWORD', '')
REGISTRY = os.environ.get('REGISTRY', '10.0.0.3:31427')
GITOPS_REPO = os.environ.get('GITOPS_REPO', f'{GITEA_URL}/{GITEA_USER}/gitops-infra.git')
WEBHOOK_SECRET = os.environ.get('WEBHOOK_SECRET', '')
BUILD_NAMESPACE = os.environ.get('BUILD_NAMESPACE', 'build')
REGISTRY_SECRET = os.environ.get('REGISTRY_SECRET', 'gitea-registry')
IGNORED_REPOS = set(os.environ.get('IGNORED_REPOS', 'gitops-infra').split(','))
def verify_signature(payload, signature):
"""Verify Gitea webhook signature."""
if not WEBHOOK_SECRET:
return True
expected = hmac.HMAC(WEBHOOK_SECRET.encode(), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
def repo_has_dockerfile(owner, repo):
"""Check if repo has a Dockerfile via Gitea API."""
try:
result = subprocess.run(
['curl', '-sf', '-u', f'{GITEA_USER}:{GITEA_PASSWORD}',
f'{GITEA_URL}/api/v1/repos/{owner}/{repo}/contents/Dockerfile'],
capture_output=True, text=True, timeout=10
)
return result.returncode == 0
except Exception as e:
logger.error(f'Error checking Dockerfile: {e}')
return False
def gitops_app_exists(repo_name):
"""Check if gitops environment exists for this repo."""
kustomization = f'environments/{repo_name}/overlays/production/kustomization.yaml'
try:
result = subprocess.run(
['curl', '-sf', '-u', f'{GITEA_USER}:{GITEA_PASSWORD}',
f'{GITEA_URL}/api/v1/repos/{GITEA_USER}/gitops-infra/contents/{kustomization}'],
capture_output=True, text=True, timeout=10
)
return result.returncode == 0
except Exception:
return False
def create_kaniko_pod(owner, repo, short_sha):
"""Create a Kaniko build pod."""
pod_name = f'kaniko-{repo}'
clone_url = f'{GITEA_URL}/{owner}/{repo}.git'
clone_url_auth = f'http://{GITEA_USER}:{GITEA_PASSWORD}@gitea-http.gitea.svc:3000/{owner}/{repo}.git'
image_dest = f'{REGISTRY}/{owner}/{repo}'
# Delete existing pod if any
subprocess.run(
['kubectl', 'delete', 'pod', pod_name, '-n', BUILD_NAMESPACE, '--ignore-not-found=true'],
capture_output=True, timeout=30
)
time.sleep(2)
manifest = f"""
apiVersion: v1
kind: Pod
metadata:
name: {pod_name}
namespace: {BUILD_NAMESPACE}
labels:
app: kaniko-build
repo: {repo}
commit: "{short_sha}"
spec:
restartPolicy: Never
tolerations:
- key: node-role.kubernetes.io/control-plane
effect: NoSchedule
initContainers:
- name: git-clone
image: alpine/git:latest
command: ["sh", "-c"]
args:
- git clone {clone_url_auth} /workspace
volumeMounts:
- name: workspace
mountPath: /workspace
containers:
- name: kaniko
image: gcr.io/kaniko-project/executor:latest
args:
- --dockerfile=Dockerfile
- --context=/workspace
- "--destination={image_dest}:{short_sha}"
- "--destination={image_dest}:latest"
- --insecure
- --insecure-pull
- --skip-tls-verify
- --skip-tls-verify-pull
- "--insecure-registry={REGISTRY}"
- --snapshot-mode=redo
- --use-new-run
- --compressed-caching=false
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: "2"
memory: 3Gi
volumeMounts:
- name: workspace
mountPath: /workspace
- name: docker-config
mountPath: /kaniko/.docker
volumes:
- name: workspace
emptyDir: {{}}
- name: docker-config
secret:
secretName: {REGISTRY_SECRET}
items:
- key: .dockerconfigjson
path: config.json
"""
result = subprocess.run(
['kubectl', 'apply', '-f', '-'],
input=manifest, capture_output=True, text=True, timeout=30
)
if result.returncode != 0:
logger.error(f'Failed to create Kaniko pod: {result.stderr}')
return False
logger.info(f'Kaniko pod {pod_name} created for {owner}/{repo}:{short_sha}')
return True
def wait_for_build(repo, timeout=900):
"""Wait for Kaniko build to complete."""
pod_name = f'kaniko-{repo}'
deadline = time.time() + timeout
while time.time() < deadline:
result = subprocess.run(
['kubectl', 'get', 'pod', pod_name, '-n', BUILD_NAMESPACE,
'-o', 'jsonpath={.status.phase}'],
capture_output=True, text=True, timeout=10
)
phase = result.stdout.strip()
if phase == 'Succeeded':
logger.info(f'Build succeeded for {repo}')
return True
elif phase == 'Failed':
logger.error(f'Build failed for {repo}')
logs = subprocess.run(
['kubectl', 'logs', pod_name, '-n', BUILD_NAMESPACE, '--tail=20'],
capture_output=True, text=True, timeout=10
)
logger.error(f'Build logs: {logs.stdout}')
return False
time.sleep(15)
logger.error(f'Build timed out for {repo}')
return False
def update_gitops(owner, repo, short_sha):
"""Update gitops-infra with new image tag."""
import tempfile
gitops_dir = tempfile.mkdtemp()
gitops_url_auth = f'http://{GITEA_USER}:{GITEA_PASSWORD}@gitea-http.gitea.svc:3000/{GITEA_USER}/gitops-infra.git'
try:
# Clone gitops repo
subprocess.run(['git', 'clone', gitops_url_auth, gitops_dir],
capture_output=True, timeout=30, check=True)
kustomization = os.path.join(gitops_dir,
f'environments/{repo}/overlays/production/kustomization.yaml')
if not os.path.exists(kustomization):
logger.warning(f'No gitops environment for {repo}, skipping update')
return False
# Update image tag
subprocess.run(
['sed', '-i', f's/newTag: ".*"/newTag: "{short_sha}"/', kustomization],
check=True, timeout=10
)
# Commit and push
subprocess.run(['git', 'config', 'user.name', 'CI Controller'],
cwd=gitops_dir, check=True, timeout=5)
subprocess.run(['git', 'config', 'user.email', 'ci@gitea.local'],
cwd=gitops_dir, check=True, timeout=5)
subprocess.run(['git', 'add', '.'], cwd=gitops_dir, check=True, timeout=5)
# Check if there are changes
diff = subprocess.run(['git', 'diff', '--cached', '--quiet'],
cwd=gitops_dir, capture_output=True)
if diff.returncode == 0:
logger.info(f'No changes for {repo}, already at {short_sha}')
return True
subprocess.run(['git', 'commit', '-m', f'Deploy {repo} {short_sha}'],
cwd=gitops_dir, check=True, timeout=10)
subprocess.run(['git', 'push'], cwd=gitops_dir, check=True, timeout=30)
logger.info(f'GitOps updated: {repo} -> {short_sha}')
return True
except subprocess.CalledProcessError as e:
logger.error(f'GitOps update failed: {e}')
return False
finally:
subprocess.run(['rm', '-rf', gitops_dir], capture_output=True)
def scaffold_gitops(owner, repo):
"""Create gitops scaffolding for a new project."""
import tempfile
gitops_dir = tempfile.mkdtemp()
gitops_url_auth = f'http://{GITEA_USER}:{GITEA_PASSWORD}@gitea-http.gitea.svc:3000/{GITEA_USER}/gitops-infra.git'
try:
subprocess.run(['git', 'clone', gitops_url_auth, gitops_dir],
capture_output=True, timeout=30, check=True)
env_base = os.path.join(gitops_dir, f'environments/{repo}/base')
env_prod = os.path.join(gitops_dir, f'environments/{repo}/overlays/production')
apps_dir = os.path.join(gitops_dir, 'apps')
os.makedirs(env_base, exist_ok=True)
os.makedirs(env_prod, exist_ok=True)
image = f'{REGISTRY}/{owner}/{repo}'
# Base kustomization
with open(os.path.join(env_base, 'kustomization.yaml'), 'w') as f:
f.write(f"""apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: {repo}
resources:
- deployment.yaml
- service.yaml
commonLabels:
app.kubernetes.io/managed-by: argocd
""")
# Deployment
with open(os.path.join(env_base, 'deployment.yaml'), 'w') as f:
f.write(f"""apiVersion: apps/v1
kind: Deployment
metadata:
name: {repo}
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: {repo}
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
template:
metadata:
labels:
app.kubernetes.io/name: {repo}
spec:
automountServiceAccountToken: false
imagePullSecrets:
- name: gitea-registry
containers:
- name: {repo}
image: {image}:latest
ports:
- name: http
containerPort: 3000
protocol: TCP
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
startupProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 30
livenessProbe:
httpGet:
path: /
port: http
periodSeconds: 30
failureThreshold: 3
readinessProbe:
httpGet:
path: /
port: http
periodSeconds: 10
failureThreshold: 2
""")
# Service
with open(os.path.join(env_base, 'service.yaml'), 'w') as f:
f.write(f"""apiVersion: v1
kind: Service
metadata:
name: {repo}
spec:
type: ClusterIP
ports:
- port: 80
targetPort: http
protocol: TCP
name: http
selector:
app.kubernetes.io/name: {repo}
""")
# Production overlay
with open(os.path.join(env_prod, 'kustomization.yaml'), 'w') as f:
f.write(f"""apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
images:
- name: {image}
newTag: "latest"
""")
# ArgoCD Application
with open(os.path.join(apps_dir, f'{repo}.yaml'), 'w') as f:
f.write(f"""apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: {repo}
namespace: argocd
spec:
project: infinicaretech
source:
repoURL: http://gitea-http.gitea.svc:3000/{GITEA_USER}/gitops-infra.git
targetRevision: main
path: environments/{repo}/overlays/production
destination:
server: https://kubernetes.default.svc
namespace: {repo}
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- PrunePropagationPolicy=foreground
- PruneLast=true
retry:
limit: 3
backoff:
duration: 5s
factor: 2
maxDuration: 3m
""")
# Commit and push
subprocess.run(['git', 'config', 'user.name', 'CI Controller'],
cwd=gitops_dir, check=True, timeout=5)
subprocess.run(['git', 'config', 'user.email', 'ci@gitea.local'],
cwd=gitops_dir, check=True, timeout=5)
subprocess.run(['git', 'add', '.'], cwd=gitops_dir, check=True, timeout=5)
subprocess.run(['git', 'commit', '-m',
f'Scaffold gitops for new project: {repo}'],
cwd=gitops_dir, check=True, timeout=10)
subprocess.run(['git', 'push'], cwd=gitops_dir, check=True, timeout=30)
logger.info(f'GitOps scaffolding created for {repo}')
return True
except subprocess.CalledProcessError as e:
logger.error(f'Scaffold failed: {e}')
return False
finally:
subprocess.run(['rm', '-rf', gitops_dir], capture_output=True)
def handle_push(payload):
"""Handle a push event."""
repo_name = payload['repository']['name']
owner = payload['repository']['owner']['login']
ref = payload.get('ref', '')
after = payload.get('after', '')
# Only build on push to main/master
if ref not in ('refs/heads/main', 'refs/heads/master'):
logger.info(f'Ignoring push to {ref} for {owner}/{repo_name}')
return
# Skip ignored repos
if repo_name in IGNORED_REPOS:
logger.info(f'Ignoring push to {repo_name} (in ignore list)')
return
# Skip empty commits (branch delete)
if after == '0' * 40:
logger.info(f'Ignoring branch delete for {owner}/{repo_name}')
return
short_sha = after[:7]
logger.info(f'Processing push: {owner}/{repo_name}@{short_sha}')
# Check if repo has a Dockerfile
if not repo_has_dockerfile(owner, repo_name):
logger.info(f'{owner}/{repo_name} has no Dockerfile, skipping')
return
# Check if gitops environment exists, if not scaffold it
if not gitops_app_exists(repo_name):
logger.info(f'No gitops environment for {repo_name}, creating scaffold...')
if not scaffold_gitops(owner, repo_name):
return
# Build with Kaniko
if not create_kaniko_pod(owner, repo_name, short_sha):
return
# Wait for build
if not wait_for_build(repo_name):
return
# Update gitops
update_gitops(owner, repo_name, short_sha)
class WebhookHandler(BaseHTTPRequestHandler):
def do_POST(self):
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length)
# Verify signature
signature = self.headers.get('X-Gitea-Signature', '')
if WEBHOOK_SECRET and not verify_signature(body, signature):
self.send_response(403)
self.end_headers()
self.wfile.write(b'Invalid signature')
return
event = self.headers.get('X-Gitea-Event', '')
if event != 'push':
self.send_response(200)
self.end_headers()
self.wfile.write(b'OK - ignored event')
return
try:
payload = json.loads(body)
self.send_response(202)
self.end_headers()
self.wfile.write(b'Accepted')
# Process in background thread to not block webhook response
t = threading.Thread(target=handle_push, args=(payload,), daemon=True)
t.start()
except Exception as e:
logger.error(f'Error processing webhook: {e}')
self.send_response(500)
self.end_headers()
self.wfile.write(str(e).encode())
def do_GET(self):
"""Health check endpoint."""
self.send_response(200)
self.end_headers()
self.wfile.write(b'CI Controller OK')
def log_message(self, format, *args):
logger.info(f'{self.client_address[0]} - {format % args}')
def setup_gitea_webhook():
"""Create a system-level webhook in Gitea for all repos."""
import urllib.request
import urllib.error
webhook_url = 'http://ci-controller.build.svc:8080/webhook'
api_url = f'{GITEA_URL}/api/v1/user/hooks'
# Check existing webhooks
try:
req = urllib.request.Request(api_url)
credentials = f'{GITEA_USER}:{GITEA_PASSWORD}'
import base64
auth = base64.b64encode(credentials.encode()).decode()
req.add_header('Authorization', f'Basic {auth}')
resp = urllib.request.urlopen(req, timeout=10)
hooks = json.loads(resp.read())
for hook in hooks:
if hook.get('config', {}).get('url') == webhook_url:
logger.info('Webhook already exists')
return True
except Exception as e:
logger.warning(f'Could not check existing webhooks: {e}')
# Create webhook
hook_data = json.dumps({
'type': 'gitea',
'config': {
'url': webhook_url,
'content_type': 'json',
'secret': WEBHOOK_SECRET,
},
'events': ['push'],
'active': True,
}).encode()
try:
req = urllib.request.Request(api_url, data=hook_data, method='POST')
credentials = f'{GITEA_USER}:{GITEA_PASSWORD}'
import base64
auth = base64.b64encode(credentials.encode()).decode()
req.add_header('Authorization', f'Basic {auth}')
req.add_header('Content-Type', 'application/json')
resp = urllib.request.urlopen(req, timeout=10)
logger.info(f'Webhook created: {resp.status}')
return True
except urllib.error.HTTPError as e:
logger.error(f'Failed to create webhook: {e.code} {e.read().decode()}')
return False
if __name__ == '__main__':
port = int(os.environ.get('PORT', '8080'))
# Register webhook on startup
logger.info('Setting up Gitea webhook...')
setup_gitea_webhook()
logger.info(f'Starting CI Controller on port {port}')
server = HTTPServer(('0.0.0.0', port), WebhookHandler)
server.serve_forever()