diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f3d096a --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c88a9f8..f07f21a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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/ diff --git a/.gitignore b/.gitignore index 484c883..c4967d6 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ \ No newline at end of file +# 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 diff --git a/Dockerfile b/Dockerfile index abac81a..8256b72 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/kubernetes/kube-manifest.yaml b/kubernetes/kube-manifest.yaml index 82aabb6..c0b3486 100644 --- a/kubernetes/kube-manifest.yaml +++ b/kubernetes/kube-manifest.yaml @@ -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