diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2aea3b6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +.venv/ +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +*.so +*.egg +*.egg-info/ +dist/ +build/ +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.git/ +.gitignore +.idea/ +*.md +config/ diff --git a/.github/workflows/publish-ghcr.yml b/.github/workflows/publish-ghcr.yml new file mode 100644 index 0000000..6c6a3d6 --- /dev/null +++ b/.github/workflows/publish-ghcr.yml @@ -0,0 +1,86 @@ +name: Build and publish container image to GHCR + +on: + push: + branches: + - main + tags: + - "v*" + pull_request: + branches: + - main + +permissions: + contents: read + packages: write + +jobs: + build-and-push: + runs-on: ubuntu-latest + env: + PR_IMAGE_TAG: "" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set image name + run: | + repo_lc=$(echo "${GITHUB_REPOSITORY}" | tr '[:upper:]' '[:lower:]') + echo "IMAGE_NAME=ghcr.io/${repo_lc}" >> "$GITHUB_ENV" + + - name: Set PR image tag + if: github.event_name == 'pull_request' + run: | + ref_lc=$(echo "${GITHUB_HEAD_REF}" | tr '[:upper:]' '[:lower:]') + ref_sanitized=$(echo "${ref_lc}" | sed 's/[^a-z0-9_.-]/-/g') + echo "PR_IMAGE_TAG=${ref_sanitized}-pr-${{ github.event.pull_request.number }}" >> "$GITHUB_ENV" + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=tag + type=sha + type=raw,value=${{ env.PR_IMAGE_TAG }},enable=${{ github.event_name == 'pull_request' }} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push (multi-arch) + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Build and load (single-arch) + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64 + push: false + load: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c19553 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.venv/ +venv/ +ENV/ +env/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# Environment +.env +.env.local + +# Functions use the .func directory for local runtime data which should +# generally not be tracked in source control. To instruct the system to track +# .func in source control, comment the following line (prefix it with '# '). +/.func diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cc9de50 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY pyproject.toml . +RUN pip install --no-cache-dir . + +COPY main.py . +COPY function ./function + +ENV PORT=8080 +EXPOSE 8080 + +CMD ["python", "main.py"] diff --git a/README.md b/README.md index 09b348f..c2f37a9 100644 --- a/README.md +++ b/README.md @@ -1 +1,318 @@ -# Na WebHook Source +# EOEPCA NA - Webhook Source - Knative Function + +A Knative function that receives GitHub and GitLab webhooks and forwards them as CloudEvents to a configured sink. Compatible with Knative SinkBinding. + +## Features + +- **Multi-Project Support**: Configure multiple projects with individual webhook secrets +- **GitHub Webhook Support**: Receives and validates GitHub webhooks with HMAC signature verification +- **GitLab Webhook Support**: Receives and validates GitLab webhooks with token authentication +- **CloudEvents**: Converts webhooks to CloudEvents format for standardized event processing +- **SinkBinding Compatible**: Works seamlessly with Knative SinkBinding for dynamic sink configuration +- **OIDC Authentication**: Automatically uses Kubernetes service account tokens for authenticating with the sink +- **Health Checks**: Includes `/health` and `/ready` endpoints for Kubernetes probes + +## Endpoints + +### Project-Specific Endpoints +- `POST //github` - GitHub webhook endpoint for a specific project +- `POST //gitlab` - GitLab webhook endpoint for a specific project + +### Global Endpoints (Backward Compatible) +- `POST /github` - GitHub webhook endpoint (uses global secret) +- `POST /gitlab` - GitLab webhook endpoint (uses global secret) +- `POST /` - Auto-detects GitHub or GitLab based on headers + +### Health Endpoints +- `GET /health` - Health check endpoint +- `GET /ready` - Readiness check endpoint + +## Environment Variables + +- `K_SINK` - Target sink URL (automatically injected by SinkBinding) +- `K_CE_OVERRIDES` - JSON object to override CloudEvents attributes (default: `{}`) +- `PROJECTS_CONFIG` - JSON object defining projects and their webhook secrets (default: `{}`) +- `GITHUB_WEBHOOK_SECRET` - Optional global secret for GitHub webhook signature verification +- `GITLAB_WEBHOOK_SECRET` - Optional global secret for GitLab webhook token verification +- `OIDC_TOKEN_PATH` - Path to Kubernetes service account token (default: `/var/run/secrets/kubernetes.io/serviceaccount/token`) +- `OIDC_AUDIENCE` - Optional OIDC audience claim for token validation +- `PORT` - HTTP server port (default: 8080) + +## Multi-Project Configuration + +You can configure multiple projects, each with their own webhook secrets. This allows you to use different endpoints for different repositories or teams. + +### Configuration Format + +The `PROJECTS_CONFIG` environment variable accepts a JSON object: + +```json +{ + "project-alpha": { + "github_secret": "github-webhook-secret-for-alpha", + "gitlab_secret": "gitlab-webhook-secret-for-alpha", + "description": "Alpha project webhooks" + }, + "project-beta": { + "github_secret": "github-webhook-secret-for-beta", + "gitlab_secret": "gitlab-webhook-secret-for-beta", + "description": "Beta project webhooks" + } +} +``` + +### Using Project Endpoints + +Once configured, you can use project-specific endpoints: + +- **GitHub**: `https://your-domain/project-alpha/github` +- **GitLab**: `https://your-domain/project-beta/gitlab` + +Each project endpoint will: +1. Validate the webhook using the project's specific secret +2. Add the project name to the CloudEvent `subject` attribute +3. Return a 404 if the project is not configured + +### Backward Compatibility + +The global endpoints (`/github`, `/gitlab`, `/`) continue to work using the `GITHUB_WEBHOOK_SECRET` and `GITLAB_WEBHOOK_SECRET` environment variables. This ensures existing integrations are not broken. + +## CloudEvent Format + +Events are forwarded with the following CloudEvent attributes: + +- **type**: `org.eoepca.webhook.{github|gitlab}.{event_type}` +- **source**: Repository URL (e.g., `https://github.com/user/repo`) +- **webhooksource**: Source type - either `github` or `gitlab` +- **subject**: Project name (only when using project-specific endpoints) +- **data**: Original webhook payload + +### CloudEvents Attribute Overrides + +You can override or add CloudEvents attributes using the `K_CE_OVERRIDES` environment variable. This follows the Knative convention for CloudEvents customization. + +**Example:** +```bash +export K_CE_OVERRIDES='{"type":"custom.webhook.event","subject":"my-repo"}' +``` + +This will: +- Override the default `type` attribute +- Add a new `subject` attribute to all events + +**Common use cases:** +- Setting a custom event type for filtering +- Adding a `subject` for event routing +- Adding custom extension attributes (e.g., `"myextension":"value"`) + +**Note:** Overrides are applied after the base attributes are set, so they will replace any default values. + +## OIDC Authentication + +The function automatically reads the Kubernetes service account token and includes it in the `Authorization: Bearer ` header when forwarding events to the sink. This enables secure communication with OIDC-protected endpoints. + +**Key Points:** +- The service account token is automatically mounted by Kubernetes at `/var/run/secrets/kubernetes.io/serviceaccount/token` +- The token is read on each request to ensure fresh credentials +- If the token is not available, the request is sent without authentication (useful for development) +- The deployment includes a dedicated `ServiceAccount` resource for proper RBAC configuration + +## Deployment + +### Using SinkBinding + +1. **Build and push the container image:** + +```bash +docker build -t your-registry/webhook-source:latest . +docker push your-registry/webhook-source:latest +``` + +2. **Update the image in deployment.yaml:** + +Edit `config/deployment.yaml` and replace `webhook-source:latest` with your image. + +3. **Create project configuration (optional):** + +For multi-project support, create a ConfigMap with your projects: + +```bash +kubectl apply -f config/configmap.yaml +``` + +Edit `config/configmap.yaml` to add your projects and their webhook secrets. + +**Alternative:** For global secrets only, edit `config/sinkbinding.yaml` and apply: + +```bash +kubectl apply -f config/sinkbinding.yaml +``` + +4. **Deploy the application:** + +```bash +kubectl apply -f config/deployment.yaml +``` + +This creates: +- A `ServiceAccount` for OIDC token access +- A `Deployment` configured to use the service account +- A `Service` to expose the webhook endpoints + +5. **Configure SinkBinding:** + +The SinkBinding will inject the `K_SINK` environment variable into your deployment. Edit `config/sinkbinding.yaml` to configure your sink (Broker, Channel, or Service). + +**Note:** The deployment automatically mounts the Kubernetes service account token, which is used for OIDC authentication with the sink. + +### Using Knative Functions CLI + +```bash +func deploy --registry your-registry +``` + +## Configuration Examples + +### GitHub Webhook Configuration + +**For project-specific webhooks:** +In your GitHub repository settings: +- **Payload URL**: `https://your-domain//github` +- **Content type**: `application/json` +- **Secret**: Set to match the project's `github_secret` in `PROJECTS_CONFIG` +- **Events**: Select the events you want to receive + +**For global webhooks:** +In your GitHub repository settings: +- **Payload URL**: `https://your-domain/github` or `https://your-domain/` +- **Content type**: `application/json` +- **Secret**: Set to match `GITHUB_WEBHOOK_SECRET` +- **Events**: Select the events you want to receive + +### GitLab Webhook Configuration + +**For project-specific webhooks:** +In your GitLab project settings: +- **URL**: `https://your-domain//gitlab` +- **Secret token**: Set to match the project's `gitlab_secret` in `PROJECTS_CONFIG` +- **Trigger**: Select the events you want to receive + +**For global webhooks:** +In your GitLab project settings: +- **URL**: `https://your-domain/gitlab` or `https://your-domain/` +- **Secret token**: Set to match `GITLAB_WEBHOOK_SECRET` +- **Trigger**: Select the events you want to receive + +### SinkBinding to Broker + +```yaml +apiVersion: sources.knative.dev/v1 +kind: SinkBinding +metadata: + name: webhook-source-binding +spec: + subject: + apiVersion: apps/v1 + kind: Deployment + name: webhook-source + sink: + ref: + apiVersion: eventing.knative.dev/v1 + kind: Broker + name: default +``` + +### SinkBinding to Service + +```yaml +apiVersion: sources.knative.dev/v1 +kind: SinkBinding +metadata: + name: webhook-source-binding +spec: + subject: + apiVersion: apps/v1 + kind: Deployment + name: webhook-source + sink: + ref: + apiVersion: v1 + kind: Service + name: event-display +``` + +## Local Development + +1. **Install dependencies:** + +```bash +pip install -e . +``` + +2. **Set environment variables:** + +```bash +export K_SINK=http://localhost:8081/events + +# Option 1: Multi-project configuration +export PROJECTS_CONFIG='{"my-project":{"github_secret":"gh-secret","gitlab_secret":"gl-secret"}}' + +# Option 2: Global secrets (backward compatible) +export GITHUB_WEBHOOK_SECRET=your-secret +export GITLAB_WEBHOOK_SECRET=your-secret + +# Optional: CloudEvents overrides +export K_CE_OVERRIDES='{"myextension":"value"}' +``` + +3. **Run the function:** + +```bash +python main.py +``` + +4. **Test with curl:** + +```bash +# Project-specific GitHub webhook +curl -X POST http://localhost:8080/my-project/github \ + -H "Content-Type: application/json" \ + -H "X-GitHub-Event: push" \ + -d '{"repository": {"html_url": "https://github.com/test/repo"}}' + +# Project-specific GitLab webhook +curl -X POST http://localhost:8080/my-project/gitlab \ + -H "Content-Type: application/json" \ + -H "X-Gitlab-Event: Push Hook" \ + -d '{"project": {"web_url": "https://gitlab.com/test/repo"}}' + +# Global GitHub webhook (backward compatible) +curl -X POST http://localhost:8080/github \ + -H "Content-Type: application/json" \ + -H "X-GitHub-Event: push" \ + -d '{"repository": {"html_url": "https://github.com/test/repo"}}' + +# Global GitLab webhook (backward compatible) +curl -X POST http://localhost:8080/gitlab \ + -H "Content-Type: application/json" \ + -H "X-Gitlab-Event: Push Hook" \ + -d '{"project": {"web_url": "https://gitlab.com/test/repo"}}' +``` + +## Security + +### Webhook Verification +- GitHub webhooks are validated using HMAC-SHA256 signature verification +- GitLab webhooks are validated using secret token comparison +- If secrets are not configured, signature verification is skipped (useful for development) +- Always configure secrets in production environments + +### OIDC Authentication +- The function uses Kubernetes service account tokens for authenticating with the sink +- Tokens are automatically rotated by Kubernetes +- The service account should be granted appropriate RBAC permissions for your use case +- The sink must be configured to accept and validate OIDC tokens from your Kubernetes cluster + +## License + +APACHE2 diff --git a/config/configmap.yaml b/config/configmap.yaml new file mode 100644 index 0000000..36f5dff --- /dev/null +++ b/config/configmap.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: webhook-projects-config + labels: + app: webhook-source +data: + projects.json: | + { + "project-alpha": { + "github_secret": "github-webhook-secret-for-alpha", + "gitlab_secret": "gitlab-webhook-secret-for-alpha", + "description": "Alpha project webhooks" + }, + "project-beta": { + "github_secret": "github-webhook-secret-for-beta", + "gitlab_secret": "gitlab-webhook-secret-for-beta", + "description": "Beta project webhooks" + } + } diff --git a/config/deployment.yaml b/config/deployment.yaml new file mode 100644 index 0000000..a0f3178 --- /dev/null +++ b/config/deployment.yaml @@ -0,0 +1,80 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: webhook-source + labels: + app: webhook-source +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: webhook-source + labels: + app: webhook-source +spec: + replicas: 1 + selector: + matchLabels: + app: webhook-source + template: + metadata: + labels: + app: webhook-source + spec: + serviceAccountName: webhook-source + containers: + - name: webhook-source + image: webhook-source:latest + ports: + - containerPort: 8080 + protocol: TCP + env: + - name: PORT + value: "8080" + - name: GITHUB_WEBHOOK_SECRET + valueFrom: + secretKeyRef: + name: webhook-secrets + key: github-secret + optional: true + - name: GITLAB_WEBHOOK_SECRET + valueFrom: + secretKeyRef: + name: webhook-secrets + key: gitlab-secret + optional: true + - name: OIDC_TOKEN_PATH + value: "/var/run/secrets/kubernetes.io/serviceaccount/token" + - name: PROJECTS_CONFIG + valueFrom: + configMapKeyRef: + name: webhook-projects-config + key: projects.json + optional: true + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /ready + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 +--- +apiVersion: v1 +kind: Service +metadata: + name: webhook-source + labels: + app: webhook-source +spec: + selector: + app: webhook-source + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + type: ClusterIP diff --git a/config/projects-config.example.json b/config/projects-config.example.json new file mode 100644 index 0000000..9156638 --- /dev/null +++ b/config/projects-config.example.json @@ -0,0 +1,17 @@ +{ + "project-alpha": { + "github_secret": "github-webhook-secret-for-alpha", + "gitlab_secret": "gitlab-webhook-secret-for-alpha", + "description": "Alpha project webhooks" + }, + "project-beta": { + "github_secret": "github-webhook-secret-for-beta", + "gitlab_secret": "gitlab-webhook-secret-for-beta", + "description": "Beta project webhooks" + }, + "eoepca-main": { + "github_secret": "eoepca-github-secret", + "gitlab_secret": "eoepca-gitlab-secret", + "description": "EOEPCA main project" + } +} diff --git a/config/sinkbinding.yaml b/config/sinkbinding.yaml new file mode 100644 index 0000000..a11635e --- /dev/null +++ b/config/sinkbinding.yaml @@ -0,0 +1,23 @@ +apiVersion: sources.knative.dev/v1 +kind: SinkBinding +metadata: + name: webhook-source-binding +spec: + subject: + apiVersion: apps/v1 + kind: Deployment + name: webhook-source + sink: + ref: + apiVersion: eventing.knative.dev/v1 + kind: Broker + name: default +--- +apiVersion: v1 +kind: Secret +metadata: + name: webhook-secrets +type: Opaque +stringData: + github-secret: "your-github-webhook-secret-here" + gitlab-secret: "your-gitlab-webhook-secret-here" diff --git a/func.yaml b/func.yaml new file mode 100644 index 0000000..bb78b76 --- /dev/null +++ b/func.yaml @@ -0,0 +1,17 @@ +# $schema: https://raw.githubusercontent.com/knative/func/70529438/schema/func_yaml-schema.json +# yaml-language-server: $schema=https://raw.githubusercontent.com/knative/func/70529438/schema/func_yaml-schema.json +specVersion: 0.36.0 +name: webhook-source +runtime: python +image: webhook-source:latest +created: 2026-01-26T08:40:00Z +invoke: http +build: + builder: pack + buildEnvs: + - name: BP_CPYTHON_VERSION + value: 3.12.* +deploy: + healthEndpoints: + liveness: /health + readiness: /ready diff --git a/function/__init__.py b/function/__init__.py new file mode 100644 index 0000000..8fb6852 --- /dev/null +++ b/function/__init__.py @@ -0,0 +1,6 @@ +from .func import new + + +async def handle(scope, receive, send): + fn = new() + await fn.handle(scope, receive, send) diff --git a/function/app.py b/function/app.py new file mode 100644 index 0000000..1702673 --- /dev/null +++ b/function/app.py @@ -0,0 +1,257 @@ +import os +import hmac +import hashlib +import json +from typing import Optional, Dict +from pathlib import Path +from flask import Flask, request, jsonify +from cloudevents.http import CloudEvent, to_structured +import requests + +app = Flask(__name__) + +K_SINK = os.environ.get('K_SINK') +K_CE_OVERRIDES = os.environ.get('K_CE_OVERRIDES', '{}') +PROJECTS_CONFIG = os.environ.get('PROJECTS_CONFIG', '{}') +GITHUB_SECRET = os.environ.get('GITHUB_WEBHOOK_SECRET') +GITLAB_SECRET = os.environ.get('GITLAB_WEBHOOK_SECRET') +OIDC_TOKEN_PATH = os.environ.get('OIDC_TOKEN_PATH', '/var/run/secrets/kubernetes.io/serviceaccount/token') +OIDC_AUDIENCE = os.environ.get('OIDC_AUDIENCE', '') + +_projects_cache: Optional[Dict] = None + + +def load_projects_config() -> Dict: + global _projects_cache + if _projects_cache is not None: + return _projects_cache + + try: + config = json.loads(PROJECTS_CONFIG) + if not isinstance(config, dict): + app.logger.warning('PROJECTS_CONFIG is not a valid JSON object, using empty config') + _projects_cache = {} + return _projects_cache + + _projects_cache = config + app.logger.info(f'Loaded {len(_projects_cache)} project(s) from configuration') + return _projects_cache + except json.JSONDecodeError as e: + app.logger.warning(f'Failed to parse PROJECTS_CONFIG: {e}, using empty config') + _projects_cache = {} + return _projects_cache + + +def get_project_config(project_name: str) -> Optional[Dict]: + projects = load_projects_config() + return projects.get(project_name) + + +def verify_github_signature(payload: bytes, signature: str, secret: Optional[str] = None) -> bool: + webhook_secret = secret or GITHUB_SECRET + + if not webhook_secret: + return True + + if not signature: + return False + + hash_algorithm, signature_value = signature.split('=', 1) + mac = hmac.new(webhook_secret.encode(), msg=payload, digestmod=hashlib.sha256) + expected_signature = mac.hexdigest() + + return hmac.compare_digest(expected_signature, signature_value) + + +def verify_gitlab_signature(token: str, secret: Optional[str] = None) -> bool: + webhook_secret = secret or GITLAB_SECRET + + if not webhook_secret: + return True + + if not token: + return False + + return hmac.compare_digest(webhook_secret, token) + + +def get_oidc_token() -> Optional[str]: + try: + token_path = Path(OIDC_TOKEN_PATH) + if token_path.exists(): + token = token_path.read_text().strip() + app.logger.debug('Successfully read OIDC token from service account') + return token + else: + app.logger.warning(f'OIDC token path does not exist: {OIDC_TOKEN_PATH}') + return None + except Exception as e: + app.logger.error(f'Failed to read OIDC token: {e}') + return None + + +def parse_ce_overrides() -> dict: + try: + overrides = json.loads(K_CE_OVERRIDES) + if not isinstance(overrides, dict): + app.logger.warning('K_CE_OVERRIDES is not a valid JSON object, ignoring') + return {} + return overrides + except json.JSONDecodeError as e: + app.logger.warning(f'Failed to parse K_CE_OVERRIDES: {e}') + return {} + + +def create_cloudevent(webhook_type: str, event_type: str, data: dict, source: str, project: Optional[str] = None) -> CloudEvent: + attributes = { + 'type': f'org.eoepca.webhook.{webhook_type}.{event_type}', + 'source': source, + 'webhooksource': webhook_type, + } + + if project: + attributes['subject'] = project + + overrides = parse_ce_overrides() + if overrides: + app.logger.debug(f'Applying CloudEvents overrides: {overrides}') + attributes.update(overrides) + + return CloudEvent(attributes, data) + + +def forward_to_sink(event: CloudEvent) -> bool: + if not K_SINK: + app.logger.warning('K_SINK not configured, skipping forward') + return False + + try: + headers, body = to_structured(event) + + oidc_token = get_oidc_token() + if oidc_token: + headers['Authorization'] = f'Bearer {oidc_token}' + app.logger.debug('Added OIDC token to request headers') + else: + app.logger.info('No OIDC token available, sending request without authentication') + + response = requests.post(K_SINK, headers=headers, data=body, timeout=30) + response.raise_for_status() + app.logger.info(f'Successfully forwarded event to sink: {K_SINK}') + return True + except Exception as e: + app.logger.error(f'Failed to forward event to sink: {e}') + raise + + +@app.route('/health', methods=['GET']) +def health(): + return jsonify({'status': 'healthy'}), 200 + + +@app.route('/ready', methods=['GET']) +def ready(): + return jsonify({'status': 'ready'}), 200 + + +def handle_github_webhook(project: Optional[str] = None): + signature = request.headers.get('X-Hub-Signature-256', '') + event_type = request.headers.get('X-GitHub-Event', 'unknown') + delivery_id = request.headers.get('X-GitHub-Delivery', '') + + payload = request.get_data() + + secret = None + if project: + project_config = get_project_config(project) + if not project_config: + app.logger.warning(f'Project "{project}" not found in configuration') + return jsonify({'error': 'Project not found'}), 404 + secret = project_config.get('github_secret') + app.logger.info(f'Processing GitHub webhook for project: {project}') + + if not verify_github_signature(payload, signature, secret): + app.logger.warning(f'Invalid GitHub signature for project: {project or "default"}') + return jsonify({'error': 'Invalid signature'}), 401 + + try: + data = request.get_json() + except Exception as e: + app.logger.error(f'Failed to parse JSON: {e}') + return jsonify({'error': 'Invalid JSON'}), 400 + + source = data.get('repository', {}).get('html_url', 'github.com/unknown') + + event = create_cloudevent('github', event_type, data, source, project) + + try: + forward_to_sink(event) + return jsonify({'status': 'accepted', 'delivery_id': delivery_id, 'project': project}), 202 + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +def handle_gitlab_webhook(project: Optional[str] = None): + token = request.headers.get('X-Gitlab-Token', '') + event_type = request.headers.get('X-Gitlab-Event', 'unknown') + + secret = None + if project: + project_config = get_project_config(project) + if not project_config: + app.logger.warning(f'Project "{project}" not found in configuration') + return jsonify({'error': 'Project not found'}), 404 + secret = project_config.get('gitlab_secret') + app.logger.info(f'Processing GitLab webhook for project: {project}') + + if not verify_gitlab_signature(token, secret): + app.logger.warning(f'Invalid GitLab token for project: {project or "default"}') + return jsonify({'error': 'Invalid token'}), 401 + + try: + data = request.get_json() + except Exception as e: + app.logger.error(f'Failed to parse JSON: {e}') + return jsonify({'error': 'Invalid JSON'}), 400 + + source = data.get('project', {}).get('web_url', 'gitlab.com/unknown') + + event = create_cloudevent('gitlab', event_type.replace(' ', '_').lower(), data, source, project) + + try: + forward_to_sink(event) + return jsonify({'status': 'accepted', 'project': project}), 202 + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('//github', methods=['POST']) +def project_github_webhook(project): + return handle_github_webhook(project) + + +@app.route('//gitlab', methods=['POST']) +def project_gitlab_webhook(project): + return handle_gitlab_webhook(project) + + +@app.route('/github', methods=['POST']) +def github_webhook(): + return handle_github_webhook() + + +@app.route('/gitlab', methods=['POST']) +def gitlab_webhook(): + return handle_gitlab_webhook() + + +@app.route('/', methods=['POST']) +def generic_webhook(): + user_agent = request.headers.get('User-Agent', '').lower() + + if 'github' in user_agent or 'X-GitHub-Event' in request.headers: + return handle_github_webhook() + elif 'gitlab' in user_agent or 'X-Gitlab-Event' in request.headers: + return handle_gitlab_webhook() + else: + return jsonify({'error': 'Unknown webhook source'}), 400 diff --git a/function/func.py b/function/func.py new file mode 100644 index 0000000..c6a4819 --- /dev/null +++ b/function/func.py @@ -0,0 +1,21 @@ +from asgiref.wsgi import WsgiToAsgi + +from .app import app as flask_app + + +def new(): + return Function() + + +class Function: + def __init__(self): + self._asgi_app = WsgiToAsgi(flask_app) + + async def handle(self, scope, receive, send): + await self._asgi_app(scope, receive, send) + + def alive(self): + return True, "alive" + + def ready(self): + return True, "ready" diff --git a/main.py b/main.py new file mode 100644 index 0000000..7597dce --- /dev/null +++ b/main.py @@ -0,0 +1,8 @@ +import os + +from function.app import app + + +if __name__ == '__main__': + port = int(os.environ.get('PORT', 8080)) + app.run(host='0.0.0.0', port=port) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..018990a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "function" +version = "0.1.0" +description = "Knative function for receiving GitHub/GitLab webhooks and forwarding to a sink" +requires-python = ">=3.9,<4.0" +dependencies = [ + "flask>=3.0.0", + "cloudevents>=1.10.0", + "requests>=2.31.0", + "asgiref>=3.7.0", +] + +[build-system] +requires = ["hatchling>=1.21.0"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["function"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..21a7d5f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +flask>=3.0.0 +cloudevents>=1.10.0 +requests>=2.31.0 +asgiref>=3.7.0