Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Keep the build context small, reproducible and secret-free.
.git
.github
.gitignore
.idea/
.vscode/
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
coverage.xml
.venv/
venv/
.env
.env.*
configuration.toml
secrets.yaml
*.pem
*.key
sbom.json
README.md
Dockerfile
docker-compose.yml
kubernetes/
tests/
requirements-dev.txt
pyproject.toml
48 changes: 42 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,26 @@ jobs:
- name: Mypy
run: mypy
- name: Actionlint (workflow self-check)
run: docker run --rm -v "$PWD:/repo" ghcr.io/rhysd/actionlint:latest
# ghcr.io refuses anonymous pulls of this image: its token endpoint answers
# 403 DENIED. The pinned release binary is downloaded from github.com instead
# and verified against the published SHA-256 before being executed.
env:
ACTIONLINT_VERSION: 1.7.12
ACTIONLINT_SHA256_AMD64: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8
ACTIONLINT_SHA256_ARM64: 325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6
run: |
set -euo pipefail
case "$(uname -m)" in
x86_64) arch=amd64; sum="$ACTIONLINT_SHA256_AMD64" ;;
aarch64|arm64) arch=arm64; sum="$ACTIONLINT_SHA256_ARM64" ;;
*) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;;
esac
archive="actionlint_${ACTIONLINT_VERSION}_linux_${arch}.tar.gz"
curl -sSfL -o "$RUNNER_TEMP/$archive" \
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${archive}"
echo "$sum $RUNNER_TEMP/$archive" | sha256sum --check
tar -xzf "$RUNNER_TEMP/$archive" -C "$RUNNER_TEMP" actionlint
"$RUNNER_TEMP/actionlint" -color

unit-tests:
name: Unit tests
Expand Down Expand Up @@ -119,8 +138,25 @@ jobs:
needs: lint
steps:
- uses: actions/checkout@v7
- uses: helm/kubeconform-action@v1.6.0
with:
strict: true
summary: true
manifests: kubernetes/kube-manifest.yaml
# There is no official helm/kubeconform-action, so the pinned release binary is
# fetched from github.com and checked against the published checksum before use.
- name: Validate manifests with kubeconform
env:
KUBECONFORM_VERSION: 0.8.0
KUBERNETES_VERSION: 1.33.0
KUBECONFORM_SHA256_AMD64: 9bc2bffbf71f261128533edaf912153948b7ff238f9a531ae6d34466ec287883
KUBECONFORM_SHA256_ARM64: 1f53fc8e81258197a35e8603054162a5af1de8c5af13746c71ab680d9534ed87
run: |
set -euo pipefail
case "$(uname -m)" in
x86_64) arch=amd64; sum="$KUBECONFORM_SHA256_AMD64" ;;
aarch64|arm64) arch=arm64; sum="$KUBECONFORM_SHA256_ARM64" ;;
*) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;;
esac
archive="kubeconform-linux-${arch}.tar.gz"
curl -sSfL -o "$RUNNER_TEMP/$archive" \
"https://github.com/yannh/kubeconform/releases/download/v${KUBECONFORM_VERSION}/${archive}"
echo "$sum $RUNNER_TEMP/$archive" | sha256sum --check
tar -xzf "$RUNNER_TEMP/$archive" -C "$RUNNER_TEMP" kubeconform
"$RUNNER_TEMP/kubeconform" -strict -summary -verbose \
-kubernetes-version "$KUBERNETES_VERSION" kubernetes/
44 changes: 36 additions & 8 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -152,12 +152,40 @@ dmypy.json
# pytype static type analyzer
.pytype/

# Cython debug symbols
cython_debug/

# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# JetBrains template: https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
#.idea/

# --- Project-specific additions -------------------------------------------
# Secrets & local config (never commit)
.env.*
!.env.example
*.pem
*.key
*.p12
secrets.yaml
!secret.example.yaml
configuration.*.toml

# Tooling caches
.ruff_cache/
.mypy_cache/
.dmypy.json

# IDEs / editors
.vscode/
.idea/
*.swp

# OS noise
.DS_Store
Thumbs.db
desktop.ini

# Build / release artifacts
sbom.json
*.cdx.json
coverage.xml

# Docker local overrides
docker-compose.override.yml
30 changes: 21 additions & 9 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
# Use an Alpine Linux image with Python 3 installed
FROM python:3.12.2-alpine3.19
# Hardened runtime image: pinned versions, non-root user, read-friendly defaults.
FROM python:3.13.5-alpine3.22

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1

# Non-root user (matches the Kubernetes securityContext runAsUser).
RUN addgroup -g 10001 app && adduser -S -D -u 10001 -G app app

# Set the working directory in the container
WORKDIR /app

# Copy the requirements and install Python dependencies
COPY requirements.txt /app/
RUN pip3 install --no-cache-dir -r requirements.txt
# Install dependencies first to maximise layer caching.
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy the bot package into the container
COPY bot/ /app/bot/
COPY bot/ ./bot/

# Writable heartbeat directory (mounted as emptyDir in Kubernetes).
RUN mkdir -p /app/state && chown -R app:app /app

USER 10001
ENV HEARTBEAT_FILE=/app/state/heartbeat

# Run the bot (loads configuration from ENV Vars / .env at runtime)
CMD ["python", "-m", "bot"]
# Exec form: python runs as PID 1 and receives SIGTERM for graceful shutdown.
ENTRYPOINT ["python", "-m", "bot"]
140 changes: 109 additions & 31 deletions kubernetes/kube-manifest.yaml
Original file line number Diff line number Diff line change
@@ -1,51 +1,129 @@
# Deploy telegram-rss-bot (singleton worker) in a dedicated namespace.
# Best practices applied: least-privilege SecurityContexts, pinned image,
# ConfigMap/Secret split, resource limits, meaningful liveness probe.
---
# Deploy telegram-rss-bot in a namespace
apiVersion: v1
kind: Namespace
metadata:
name: telegram-rss-bot
labels:
app.kubernetes.io/name: telegram-rss-bot
---
apiVersion: v1
kind: ConfigMap
metadata:
name: telegram-rss-bot-config
namespace: telegram-rss-bot
labels:
app.kubernetes.io/name: telegram-rss-bot
data:
TIME_INTERVAL_MIN: "20" # run a feed cycle every 20 minutes
ENTRY_MAX_TIME_OLD: "1200" # publish entries younger than 20 minutes
LOG_LEVEL: "INFO"
HEARTBEAT_FILE: "/app/state/heartbeat"
RSS_URLS: |
https://www.ictjournal.ch/taxonomy/term/404/feed
https://www.ictjournal.ch/taxonomy/term/31/feed
---
apiVersion: v1
kind: Secret
metadata:
name: telegram-rss-bot-secrets
namespace: telegram-rss-bot
labels:
app.kubernetes.io/name: telegram-rss-bot
type: Opaque
stringData:
# Replace via CI/CD (sealed-secrets, SOPS, external secrets) — never commit real values.
BOT_TOKEN: "CHANGEME"
CHANNEL_ID: "@YourChannel"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: telegram-rss-bot
namespace: telegram-rss-bot
labels:
app.kubernetes.io/name: telegram-rss-bot
app.kubernetes.io/part-of: telegram-rss-bot
spec:
replicas: 1
revisionHistoryLimit: 3
# Recreate avoids two pods posting to the same channel during rollout.
strategy:
type: Recreate
selector:
matchLabels:
app: telegram-rss-bot
strategy:
rollingUpdate:
maxSurge: 25%
maxUnavailable: 25%
type: RollingUpdate
app.kubernetes.io/name: telegram-rss-bot
template:
metadata:
labels:
app: telegram-rss-bot
app.kubernetes.io/name: telegram-rss-bot
spec:
automountServiceAccountToken: false
terminationGracePeriodSeconds: 15
# imagePullSecrets:
# - name: exo-registry-credentials # required for private registries
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: telegram-rss-bot
image: exo.container-registry.com/pittet-sebastien/telegram-rss-bot:latest
env:
- name: TIME_INTERVAL_MIN
value: "20"
- name: ENTRY_MAX_TIME_OLD
value: "1200"
- name: RSS_URLS
value: |
"https://www.ictjournal.ch/taxonomy/term/404/feed
https://www.ictjournal.ch/taxonomy/term/31/feed"
resources:
requests:
memory: 20M
cpu: 0.1
livenessProbe:
exec:
command:
- ls
- /app
initialDelaySeconds: 5
periodSeconds: 60
---
- name: telegram-rss-bot
# Pin an immutable version (git-sha or semver tag produced by CI).
image: exo.container-registry.com/pittet-sebastien/telegram-rss-bot:v2.0.0
imagePullPolicy: IfNotPresent
envFrom:
- configMapRef:
name: telegram-rss-bot-config
- secretRef:
name: telegram-rss-bot-secrets
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 250m
memory: 192Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: state
mountPath: /app/state
# Liveness: the bot must refresh its heartbeat file (3x the 20-min cycle).
livenessProbe:
exec:
command:
- python
- -c
- |
import os, sys, time
p = os.environ.get("HEARTBEAT_FILE", "/app/state/heartbeat")
ok = os.path.exists(p) and time.time() - os.path.getmtime(p) < 3900
sys.exit(0 if ok else 1)
initialDelaySeconds: 90
periodSeconds: 300
timeoutSeconds: 5
failureThreshold: 3
# Readiness: heartbeat file exists at all.
readinessProbe:
exec:
command:
- python
- -c
- |
import os, sys
sys.exit(0 if os.path.exists(os.environ.get("HEARTBEAT_FILE", "/app/state/heartbeat")) else 1)
initialDelaySeconds: 30
periodSeconds: 60
timeoutSeconds: 5
volumes:
- name: state
emptyDir:
sizeLimit: 1Mi
Loading