From 4020fedf9a3c701c57c86e0e99bd00d681178afb Mon Sep 17 00:00:00 2001 From: Dmitrii Krosh Date: Thu, 16 Jul 2026 19:06:33 +0300 Subject: [PATCH] add k8s support and upload image to ghcr.io --- .dockerignore | 25 +++++++++ .env.example | 2 + .github/workflows/container.yml | 55 +++++++++++++++++++ Dockerfile | 27 +++++++-- README.md | 97 ++++++++++++++++++++++++++++++++- k8s/namespace.yaml | 7 +++ k8s/tt-simple.yaml | 87 +++++++++++++++++++++++++++++ 7 files changed, 293 insertions(+), 7 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/container.yml create mode 100644 k8s/namespace.yaml create mode 100644 k8s/tt-simple.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1d5924f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +.git +.github + +.env +.env.* + +.venv +venv +ENV +__pycache__ +*.py[cod] + +stats.db +*.db +*.db-journal +*.db-shm +*.db-wal + +.pytest_cache +.mypy_cache +.ruff_cache + +run.sh +docker-compose*.yml +docker-compose*.yaml diff --git a/.env.example b/.env.example index 52b67d9..8684891 100644 --- a/.env.example +++ b/.env.example @@ -3,4 +3,6 @@ MARZBAN_URL=https://your-marzban.example.com MARZBAN_USERNAME= MARZBAN_PASSWORD= ADMIN_IDS=12345678,87654321 +# Optional; remove/comment this line when no proxy is required. +# HTTP_PROXY=http://shared-http-proxy.proxy.svc.cluster.local:3128 DB_PATH=stats.db diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml new file mode 100644 index 0000000..3be7c2a --- /dev/null +++ b/.github/workflows/container.yml @@ -0,0 +1,55 @@ +name: Publish container + +on: + push: + branches: + - main + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + 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 + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/kr0sh512/tt-simple + tags: | + type=ref,event=tag + type=sha,prefix=sha- + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and publish image + uses: docker/build-push-action@v6 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index a414dfc..67d92cc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,26 @@ -FROM python:3.12-slim +FROM python:3.12-slim-bookworm + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 WORKDIR /app -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +RUN groupadd --gid 1000 app \ + && useradd \ + --uid 1000 \ + --gid 1000 \ + --no-create-home \ + --shell /usr/sbin/nologin \ + app -COPY bot.py . -COPY .env . +COPY requirements.txt ./ +RUN pip install \ + --no-cache-dir \ + --disable-pip-version-check \ + -r requirements.txt -CMD sh -c "export $(grep -v '^#' .env | xargs) && python bot.py" \ No newline at end of file +COPY --chown=app:app bot.py ./ + +USER app:app + +CMD ["python", "bot.py"] diff --git a/README.md b/README.md index a8a40e8..1ffe93c 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,105 @@ cp .env.example .env Fill `.env` and run: ```bash -export $(grep -v '^#' .env | xargs) +set -a +source .env +set +a python bot.py ``` +## Container image + +The image contains only the application and Python dependencies. Runtime secrets +and the SQLite database are deliberately excluded. + +Build and verify it locally: + +```bash +docker build --pull -t tt-simple:dev . + +docker run --rm --entrypoint sh tt-simple:dev -c \ + 'test ! -e /app/.env && test ! -e /app/stats.db && test -e /app/bot.py' +``` + +Run it with environment variables and persistent SQLite storage: + +```bash +docker volume create tt-simple-data + +docker run --rm \ + --env-file .env \ + --env DB_PATH=/data/stats.db \ + --volume tt-simple-data:/data \ + tt-simple:dev +``` + +### Publishing to GHCR + +`.github/workflows/container.yml` publishes a multi-architecture image to: + +```text +ghcr.io/kr0sh512/tt-simple +``` + +A push to `main` publishes `latest` and `sha-` tags. A Git tag such as +`v0.1.0` publishes the matching version tag: + +```bash +git tag v0.1.0 +git push origin main v0.1.0 +``` + +### K3s deployment + +Create the namespace first: + +```bash +kubectl apply -f k8s/namespace.yaml +``` + +Create or update the application Secret from the local `.env` file. The Secret +is never stored in Git: + +```bash +kubectl -n tt-simple create secret generic tt-simple-env \ + --from-env-file=.env \ + --dry-run=client -o yaml | kubectl apply -f - +``` + +For a private GHCR package, create a classic GitHub token with `read:packages` +and create the registry pull secret: + +```bash +read -rsp "GHCR token: " GHCR_TOKEN +echo +kubectl -n tt-simple create secret docker-registry ghcr-creds \ + --docker-server=ghcr.io \ + --docker-username=kr0sh512 \ + --docker-password="$GHCR_TOKEN" \ + --dry-run=client -o yaml | kubectl apply -f - +unset GHCR_TOKEN +``` + +If the package is public, remove `imagePullSecrets` from +`k8s/tt-simple.yaml`. Deploy the PVC and bot after publishing the `v0.1.0` +image: + +```bash +kubectl apply -f k8s/tt-simple.yaml +kubectl -n tt-simple rollout status deployment/tt-simple +kubectl -n tt-simple logs -f deployment/tt-simple +``` + +The manifest injects the Secret, mounts persistent storage at `/data`, and sets +`DB_PATH=/data/stats.db`. To use the shared non-Russian proxy, add this to the +local `.env` before updating `tt-simple-env`: + +```dotenv +HTTP_PROXY=http://shared-http-proxy.proxy.svc.cluster.local:3128 +``` + +The included namespace and pod labels satisfy the proxy `NetworkPolicy`. + ## Notes - Telegram Stars invoices use `currency="XTR"` and empty `provider_token`. - Local SQLite database stores only payment statistics. User state is read from Marzban API. diff --git a/k8s/namespace.yaml b/k8s/namespace.yaml new file mode 100644 index 0000000..934c622 --- /dev/null +++ b/k8s/namespace.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: tt-simple + labels: + # Required by the shared proxy NetworkPolicy when HTTP_PROXY is enabled. + proxy-access: "true" diff --git a/k8s/tt-simple.yaml b/k8s/tt-simple.yaml new file mode 100644 index 0000000..e9eebcd --- /dev/null +++ b/k8s/tt-simple.yaml @@ -0,0 +1,87 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: tt-simple-data + namespace: tt-simple +spec: + accessModes: + - ReadWriteOnce + storageClassName: local-path + resources: + requests: + storage: 256Mi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tt-simple + namespace: tt-simple + labels: + app.kubernetes.io/name: tt-simple +spec: + replicas: 1 + strategy: + type: Recreate + revisionHistoryLimit: 2 + selector: + matchLabels: + app.kubernetes.io/name: tt-simple + template: + metadata: + labels: + app.kubernetes.io/name: tt-simple + # Required by the shared proxy NetworkPolicy when HTTP_PROXY is enabled. + proxy-client: "true" + spec: + automountServiceAccountToken: false + terminationGracePeriodSeconds: 30 + imagePullSecrets: + - name: ghcr-creds + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault + containers: + - name: bot + # Replace this with an immutable release tag or image digest. + image: ghcr.io/kr0sh512/tt-simple:v0.1.0 + imagePullPolicy: IfNotPresent + envFrom: + - secretRef: + name: tt-simple-env + env: + - name: DB_PATH + value: /data/stats.db + - name: PYTHONUNBUFFERED + value: "1" + - name: PYTHONDONTWRITEBYTECODE + value: "1" + resources: + requests: + cpu: 25m + memory: 64Mi + limits: + cpu: 250m + memory: 256Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + volumeMounts: + - name: data + mountPath: /data + - name: tmp + mountPath: /tmp + volumes: + - name: data + persistentVolumeClaim: + claimName: tt-simple-data + - name: tmp + emptyDir: + sizeLimit: 32Mi