+2
-1
@@ -1,2 +1,3 @@
|
|||||||
.devcontainer
|
.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
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
name: project
|
name: discord-to-loki
|
||||||
namespace: project
|
namespace: discord-to-loki
|
||||||
spec:
|
spec:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: project
|
app: discord-to-loki
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
app: project
|
app: discord-to-loki
|
||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: project
|
- name: discord-to-loki
|
||||||
image: ghcr.io/jdb-net/project:latest
|
image: cr.jdbnet.co.uk/public/discord-to-loki:latest
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 5000
|
- 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
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
name: project-ingress-service
|
name: discord-to-loki-ingress-service
|
||||||
namespace: project
|
namespace: discord-to-loki
|
||||||
spec:
|
spec:
|
||||||
selector:
|
selector:
|
||||||
app: project
|
app: discord-to-loki
|
||||||
ports:
|
ports:
|
||||||
- protocol: TCP
|
- protocol: TCP
|
||||||
port: 80
|
port: 80
|
||||||
@@ -37,17 +44,17 @@ spec:
|
|||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
kind: Ingress
|
kind: Ingress
|
||||||
metadata:
|
metadata:
|
||||||
name: project-ingress
|
name: discord-to-loki-ingress
|
||||||
namespace: project
|
namespace: discord-to-loki
|
||||||
spec:
|
spec:
|
||||||
rules:
|
rules:
|
||||||
- host: project.jdbnet.co.uk
|
- host: discord-to-loki.jdbnet.co.uk
|
||||||
http:
|
http:
|
||||||
paths:
|
paths:
|
||||||
- pathType: Prefix
|
- pathType: Prefix
|
||||||
path: "/"
|
path: "/"
|
||||||
backend:
|
backend:
|
||||||
service:
|
service:
|
||||||
name: project-ingress-service
|
name: discord-to-loki-ingress-service
|
||||||
port:
|
port:
|
||||||
number: 80
|
number: 80
|
||||||
+3
-1
@@ -1,2 +1,4 @@
|
|||||||
Flask
|
Flask
|
||||||
gunicorn
|
gunicorn
|
||||||
|
python-dotenv
|
||||||
|
requests
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user