+2
-1
@@ -1,2 +1,3 @@
|
||||
.devcontainer
|
||||
deployment.yml
|
||||
deployment.yml
|
||||
README.md
|
||||
@@ -0,0 +1,3 @@
|
||||
LOKI_URL=https://loki.example.com
|
||||
LOKI_USERNAME=your_username
|
||||
LOKI_PASSWORD=your_password
|
||||
@@ -0,0 +1,19 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and Push
|
||||
runs-on: build-htz-01
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build and push Docker image
|
||||
run: |
|
||||
docker buildx build --push -t cr.jdbnet.co.uk/public/discord-to-loki:latest .
|
||||
@@ -1,33 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
# push:
|
||||
# branches: [ main ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and Push
|
||||
runs-on: build-htz-01
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build and push Docker image
|
||||
run: |
|
||||
docker build -t cr.jdbnet.co.uk/jdbnet/project:latest .
|
||||
docker push cr.jdbnet.co.uk/jdbnet/project:latest
|
||||
|
||||
deploy:
|
||||
name: Deploy to Kubernetes
|
||||
needs: build-and-push
|
||||
runs-on: k3s-internal-htz-01
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Apply manifests
|
||||
run: |
|
||||
sudo kubectl replace -f deployment.yml --grace-period=60 --force
|
||||
+2
-1
@@ -1 +1,2 @@
|
||||
__pycache__/
|
||||
__pycache__/
|
||||
.env
|
||||
@@ -0,0 +1,37 @@
|
||||
# Webhook to Loki Logger
|
||||
|
||||
Accepts Discord-style webhook POSTs and forwards the extracted content to a Loki instance (HTTP basic auth). Intended for FiveM scripts that only support Discord webhooks—point them at this service instead and logs go to Loki.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------------|--------------------------------------|
|
||||
| `LOKI_URL` | Loki base URL (e.g. `https://loki.example.com`) |
|
||||
| `LOKI_USERNAME`| HTTP basic auth username for Loki |
|
||||
| `LOKI_PASSWORD`| HTTP basic auth password for Loki |
|
||||
|
||||
Copy `.env.example` to `.env` and set these values for local runs.
|
||||
|
||||
## Running locally
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env # edit with your Loki URL and credentials
|
||||
python app.py
|
||||
```
|
||||
|
||||
The app listens on port 5000 by default.
|
||||
|
||||
## Running with Docker
|
||||
|
||||
```bash
|
||||
docker run -p 5000:5000 \
|
||||
-e LOKI_URL=https://loki.example.com \
|
||||
-e LOKI_USERNAME=your_username \
|
||||
-e LOKI_PASSWORD=your_password \
|
||||
cr.jdbnet.co.uk/public/discord-to-loki:latest
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Send a `POST /` request with a JSON body in Discord webhook format (`content` and/or `embeds`). The service extracts the text and pushes it to Loki. Use this URL as the “webhook” target in your FiveM script instead of a Discord webhook URL.
|
||||
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
Discord webhook to Loki logger.
|
||||
Accepts Discord-style webhook POSTs and pushes extracted log lines to Loki (HTTP basic auth).
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from flask import Flask, request
|
||||
import os
|
||||
import requests
|
||||
|
||||
load_dotenv()
|
||||
|
||||
LOKI_URL = os.environ.get("LOKI_URL")
|
||||
LOKI_USERNAME = os.environ.get("LOKI_USERNAME")
|
||||
LOKI_PASSWORD = os.environ.get("LOKI_PASSWORD")
|
||||
|
||||
for name, value in [("LOKI_URL", LOKI_URL), ("LOKI_USERNAME", LOKI_USERNAME), ("LOKI_PASSWORD", LOKI_PASSWORD)]:
|
||||
if not value:
|
||||
raise RuntimeError(f"Missing required environment variable: {name}")
|
||||
|
||||
PUSH_URL = f"{LOKI_URL.rstrip('/')}/loki/api/v1/push"
|
||||
AUTH = (LOKI_USERNAME, LOKI_PASSWORD)
|
||||
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
def discord_payload_to_log_lines(payload: dict) -> list[str]:
|
||||
"""Extract log lines from a Discord-style webhook payload (content + embeds)."""
|
||||
lines = []
|
||||
if not payload:
|
||||
return lines
|
||||
|
||||
content = payload.get("content")
|
||||
if isinstance(content, str) and content.strip():
|
||||
lines.append(f"content: {content.strip()}")
|
||||
|
||||
for embed in payload.get("embeds") or []:
|
||||
if not isinstance(embed, dict):
|
||||
continue
|
||||
title = embed.get("title")
|
||||
if isinstance(title, str) and title.strip():
|
||||
lines.append(f"embed: {title.strip()}")
|
||||
description = embed.get("description")
|
||||
if isinstance(description, str) and description.strip():
|
||||
lines.append(f"embed: {description.strip()}")
|
||||
for field in embed.get("fields") or []:
|
||||
if not isinstance(field, dict):
|
||||
continue
|
||||
name = field.get("name") or ""
|
||||
value = field.get("value") or ""
|
||||
if isinstance(name, str) and isinstance(value, str):
|
||||
lines.append(f"field: {name.strip()} | {value.strip()}")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def push_to_loki(log_lines: list[str]) -> None:
|
||||
"""Send log lines to Loki via the push API (HTTP basic auth)."""
|
||||
if not log_lines:
|
||||
return
|
||||
now_ns = str(int(time.time() * 1_000_000_000))
|
||||
values = [[now_ns, line] for line in log_lines]
|
||||
body = {
|
||||
"streams": [
|
||||
{
|
||||
"stream": {"source": "webhook", "job": "webhook-to-loki"},
|
||||
"values": values,
|
||||
}
|
||||
]
|
||||
}
|
||||
resp = requests.post(
|
||||
PUSH_URL,
|
||||
json=body,
|
||||
auth=AUTH,
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
"""Health check for k8s/Docker (no Loki call)."""
|
||||
return "", 200
|
||||
|
||||
|
||||
@app.route("/ready")
|
||||
def ready():
|
||||
"""Readiness check for k8s/Docker (no Loki call)."""
|
||||
return "", 200
|
||||
|
||||
|
||||
@app.route("/", methods=["POST"])
|
||||
def webhook():
|
||||
"""Accept Discord-style webhook POST; extract content/embeds and push to Loki."""
|
||||
if request.content_type and "application/json" not in request.content_type:
|
||||
return "", 400
|
||||
|
||||
try:
|
||||
payload = request.get_json(force=True, silent=False)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return "", 400
|
||||
|
||||
if payload is None:
|
||||
return "", 400
|
||||
|
||||
log_lines = discord_payload_to_log_lines(payload)
|
||||
if not log_lines:
|
||||
return "", 400
|
||||
|
||||
try:
|
||||
push_to_loki(log_lines)
|
||||
except requests.RequestException as e:
|
||||
logger.exception("Loki push failed: %s", e)
|
||||
return "", 502
|
||||
|
||||
return "", 204
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=5000)
|
||||
+21
-14
@@ -1,34 +1,41 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: project
|
||||
namespace: project
|
||||
name: discord-to-loki
|
||||
namespace: discord-to-loki
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: project
|
||||
app: discord-to-loki
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: project
|
||||
app: discord-to-loki
|
||||
spec:
|
||||
containers:
|
||||
- name: project
|
||||
image: ghcr.io/jdb-net/project:latest
|
||||
- name: discord-to-loki
|
||||
image: cr.jdbnet.co.uk/public/discord-to-loki:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 5000
|
||||
name: "project"
|
||||
name: "discord-to-loki"
|
||||
env:
|
||||
- name: LOKI_URL
|
||||
value: "https://loki.example.com"
|
||||
- name: LOKI_USERNAME
|
||||
value: "your_username"
|
||||
- name: LOKI_PASSWORD
|
||||
value: "your_password"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: project-ingress-service
|
||||
namespace: project
|
||||
name: discord-to-loki-ingress-service
|
||||
namespace: discord-to-loki
|
||||
spec:
|
||||
selector:
|
||||
app: project
|
||||
app: discord-to-loki
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
@@ -37,17 +44,17 @@ spec:
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: project-ingress
|
||||
namespace: project
|
||||
name: discord-to-loki-ingress
|
||||
namespace: discord-to-loki
|
||||
spec:
|
||||
rules:
|
||||
- host: project.jdbnet.co.uk
|
||||
- host: discord-to-loki.jdbnet.co.uk
|
||||
http:
|
||||
paths:
|
||||
- pathType: Prefix
|
||||
path: "/"
|
||||
backend:
|
||||
service:
|
||||
name: project-ingress-service
|
||||
name: discord-to-loki-ingress-service
|
||||
port:
|
||||
number: 80
|
||||
+3
-1
@@ -1,2 +1,4 @@
|
||||
Flask
|
||||
gunicorn
|
||||
gunicorn
|
||||
python-dotenv
|
||||
requests
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user