Compare commits

...

30 Commits

Author SHA1 Message Date
forust 726b3ee544 fix(edu): run redis as statefulset
ci / deploy-userbot-panel (push) Has been skipped
ci / lint-ruff (push) Successful in 5s
ci / lint-yaml (push) Successful in 7s
ci / build (push) Successful in 3s
ci / lint-prettier (push) Successful in 11s
ci / lint-dockerfiles (push) Successful in 4s
ci / validate (push) Successful in 5s
Keep the existing Redis PVC and data while migrating the edu-master workload from Deployment to StatefulSet.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-09-10 23:35:59 +02:00
forust 13309b26e0 fix: add checkmk agent entrypoint
ci / lint-dockerfiles (push) Successful in 5s
ci / validate (push) Successful in 5s
ci / deploy-userbot-panel (push) Has been skipped
ci / lint-prettier (push) Successful in 10s
ci / lint-ruff (push) Successful in 5s
ci / lint-yaml (push) Successful in 7s
ci / build (push) Successful in 2s
update traefik to v3.7.13
2026-09-10 14:52:49 +02:00
forust eddc256bed chore: remove esp32/ingress.yaml from main
ci / lint-prettier (push) Successful in 7s
ci / lint-ruff (push) Successful in 4s
ci / deploy-userbot-panel (push) Has been skipped
ci / lint-yaml (push) Successful in 7s
ci / lint-dockerfiles (push) Successful in 5s
ci / validate (push) Successful in 5s
ci / build (push) Successful in 3s
2026-09-10 12:12:54 +02:00
forust 52f821cbba Merge branch 'main' of ssh://gitssh.forust.xyz:2221/forust/homelab
ci / lint-prettier (push) Failing after 8s
ci / lint-ruff (push) Successful in 4s
ci / lint-yaml (push) Failing after 7s
ci / lint-dockerfiles (push) Successful in 5s
ci / validate (push) Successful in 5s
ci / build (push) Has been skipped
ci / deploy-userbot-panel (push) Has been skipped
2026-09-10 11:47:25 +02:00
forust 0dbc2fff13 Merge branch 'sidetree' 2026-09-10 11:47:25 +02:00
forust 91ec83bc1c Merge branch 'main' of ssh://gitssh.forust.xyz:2221/forust/homelab
ci / lint-ruff (push) Successful in 4s
ci / lint-prettier (push) Failing after 8s
ci / lint-yaml (push) Failing after 7s
ci / lint-dockerfiles (push) Successful in 5s
ci / validate (push) Successful in 6s
ci / build (push) Has been skipped
ci / deploy-userbot-panel (push) Has been skipped
2026-09-10 11:47:20 +02:00
forust 9fec1dae39 Merge branch 'sidetree' 2026-09-10 11:47:15 +02:00
forust e5626b7b2d chore: remove untracked README.md
ci / lint-prettier (push) Successful in 7s
ci / lint-ruff (push) Successful in 5s
ci / lint-yaml (push) Successful in 6s
ci / lint-dockerfiles (push) Successful in 4s
ci / validate (push) Successful in 4s
ci / build (push) Has been skipped
ci / deploy-userbot-panel (push) Has been skipped
2026-09-10 11:45:00 +02:00
forust 8fb12a2176 chore: remove untracked README.md
ci / lint-prettier (push) Successful in 7s
ci / lint-ruff (push) Successful in 4s
ci / lint-yaml (push) Successful in 6s
ci / lint-dockerfiles (push) Successful in 4s
ci / validate (push) Successful in 5s
ci / build (push) Has been skipped
ci / deploy-userbot-panel (push) Has been skipped
2026-09-10 11:44:23 +02:00
forust fe0c7067b6 Merge branch 'main' of ssh://gitssh.forust.xyz:2221/forust/homelab
ci / validate (push) Successful in 4s
ci / build (push) Has been skipped
ci / lint-prettier (push) Failing after 8s
ci / lint-ruff (push) Successful in 7s
ci / lint-yaml (push) Failing after 6s
ci / lint-dockerfiles (push) Successful in 4s
ci / deploy-userbot-panel (push) Has been skipped
2026-09-10 11:42:17 +02:00
forust d0f0843774 Merge branch 'sidetree' 2026-09-10 11:41:35 +02:00
forust 81a5b207ac Merge branch 'main' of ssh://gitssh.forust.xyz:2221/forust/homelab
ci / lint-prettier (push) Failing after 8s
ci / lint-ruff (push) Successful in 5s
ci / lint-yaml (push) Failing after 7s
ci / lint-dockerfiles (push) Successful in 4s
ci / validate (push) Successful in 5s
ci / build (push) Has been skipped
ci / deploy-userbot-panel (push) Has been skipped
2026-09-10 11:41:31 +02:00
forust e3d5970ae3 chore: remove untracked README.md
ci / lint-prettier (push) Failing after 11s
ci / lint-ruff (push) Successful in 5s
ci / lint-yaml (push) Failing after 7s
ci / lint-dockerfiles (push) Successful in 4s
ci / validate (push) Successful in 5s
ci / build (push) Has been skipped
ci / deploy-userbot-panel (push) Has been skipped
2026-09-10 11:39:20 +02:00
forust 85f05c26cb feat(edu): activate k8s management for edu-master 2026-09-10 00:55:22 +02:00
forust 68630eb773 fix(edu): read diary event times directly from DOM, drop 25 AJAX clicks
ci / lint-prettier (push) Successful in 9s
ci / lint-ruff (push) Successful in 6s
ci / lint-yaml (push) Successful in 10s
ci / lint-dockerfiles (push) Successful in 7s
ci / validate (push) Successful in 6s
ci / build (push) Has been skipped
ci / deploy-userbot-panel (push) Has been skipped
_collect_event_times re-clicked every a.event-link and waited ~3s per
event for a visible span.data, but the calendar embeds all times in
div.event-full-info[data-event-full-info-id] span.data already. The old
loop took ~109s for 25 events and collected 0 (original divs stay
sf-hidden), effectively hanging /diary. Now a single evaluate reads all
times (~3.7s), parsing HH:MM from p.date span.data.
2026-09-10 00:31:04 +02:00
forust dad9cf2104 feat(edu): parse event times in /diary and drop weekend days
ci / lint-prettier (push) Successful in 9s
ci / lint-ruff (push) Successful in 4s
ci / lint-yaml (push) Successful in 6s
ci / lint-dockerfiles (push) Successful in 5s
ci / validate (push) Successful in 6s
ci / build (push) Has been skipped
ci / deploy-userbot-panel (push) Has been skipped
- fetch each event's time via Playwright (click event-link, read span.data, close fancybox) and render as 'title (HH:MM)'
- '08:00' placeholder renders as localized 'unknown' (time_unknown key in ru/uk/en)
- diary week view shows Mon-Fri only (title ends at Friday)
- diary month view skips Sat/Sun by weekday_idx with name-based fallback
- schedule keyboard drops Sat/Sun day buttons
2026-09-10 00:05:56 +02:00
forust 60886e8be2 style(edu): ruff-format webinar-checker/checker.py
ci / lint-prettier (push) Successful in 13s
ci / lint-ruff (push) Successful in 4s
ci / lint-yaml (push) Successful in 7s
ci / lint-dockerfiles (push) Successful in 5s
ci / validate (push) Successful in 6s
ci / build (push) Has been skipped
ci / deploy-userbot-panel (push) Has been skipped
2026-09-09 23:47:04 +02:00
forust 4528321225 fix(edu): add TZ to .env.example 2026-09-09 11:46:35 +02:00
forust b246a3dea1 fix(edu): review findings for checker.py i18n — group chat language via resolve_lang/chat: keys (default uk), weekday normalization with _norm_day + logging, drop dead translation keys, html.escape, single lang lookup, distinct whitelist emoji 2026-09-09 11:44:17 +02:00
forust 4c59a2d2fe lang(edu): translate k8s manifest comments to English only
- restore-seed-job.yaml.example: translate runbook to English
- secrets.yaml.example: translate section headers to English
- webinar-checker.yaml: translate initContainer dependency-order comments to English

Co-authored-by: assistant
2026-09-09 10:46:36 +02:00
forust fcc7b0d611 ci(deploy): gate redeploy behind manual workflow_dispatch
ci / lint-prettier (push) Successful in 8s
ci / lint-ruff (push) Successful in 4s
ci / lint-yaml (push) Successful in 7s
ci / lint-dockerfiles (push) Successful in 4s
ci / validate (push) Successful in 5s
ci / build (push) Successful in 2s
ci / deploy-userbot-panel (push) Has been skipped
removes automatic redeploy on push to main; deploy now runs only on
explicit manual trigger
2026-09-06 20:55:40 +02:00
forust 9633fe3a10 style(ci): add trailing newline to deploy workflows
ci / lint-prettier (push) Successful in 7s
ci / lint-ruff (push) Successful in 5s
ci / lint-yaml (push) Successful in 7s
ci / lint-dockerfiles (push) Successful in 5s
ci / validate (push) Successful in 5s
deploy / redeploy (push) Failing after 1s
ci / build (push) Successful in 2s
ci / deploy-userbot-panel (push) Has been skipped
2026-09-06 20:45:30 +02:00
forust d9f1c8325a feat(userbot): prereqs at startup, SPA path guard, provision lock
ci / lint-prettier (push) Failing after 8s
ci / lint-ruff (push) Successful in 4s
ci / lint-yaml (push) Failing after 7s
ci / lint-dockerfiles (push) Successful in 4s
ci / validate (push) Successful in 5s
deploy / redeploy (push) Failing after 0s
ci / build (push) Has been skipped
ci / deploy-userbot-panel (push) Has been skipped
- ensure_prerequisites runs on startup, not per-request; kube config
  errors surface as 503 PanelError
- serialize provisioning with a lock; drop per-endpoint prereq checks
- guard SPA fallback against path traversal (relative_to)
- add backend tests for auth flow, k8s service, spa routing; ci comment
  for legacy userbot deployments
2026-09-06 20:39:12 +02:00
forust 861d89d36a ci(deploy): split runtime by k8s/active marker
services marked k8s/active are applied via kubectl; the rest via docker
compose. inactive services with k8s/ keep only routing manifests
(external Services, EndpointSlices, Ingresses) to reach docker backends.
headscale/nextcloud routing moved to k8s/routing/.

validations: compose config --quiet + kubectl apply --dry-run=client.
namespace manifests applied first. pull_policy:build stacks get
build+push before up so the registry image stays fresh.
2026-09-06 20:39:12 +02:00
forust 71cddd6a91 feat(userbot): add Kubernetes control panel
Manage Telegram instances through Kubernetes with legacy adoption for forust and anna. Build and deploy the panel image alongside the runtime.
2026-09-06 20:39:12 +02:00
forust 30e6f05584 fix(edu_master): satisfy ruff in schedule scraper
- rename ambiguous loop var, merge nested if (E741, SIM102)
- use tempfile.gettempdir() for debug dump (S108)
- drop unused total_lessons assignment (F841)
2026-09-06 20:37:18 +02:00
forust bc8d74fd28 feat(schedule): per-user class picker, parser fixes
ci / lint-prettier (push) Successful in 8s
ci / lint-ruff (push) Failing after 5s
ci / lint-yaml (push) Successful in 7s
ci / lint-dockerfiles (push) Successful in 4s
ci / validate (push) Successful in 4s
ci / build (push) Has been skipped
- fix parser: capture tr attrs via finditer, strip HTML comments before
  cell parse (was leaving '-->' in subject names)
- store class choice in redis: user:{id}:schedule_class (private) and
  chat:{id}:schedule_class (groups, admin-only via /setclass)
- /schedule renders day for stored class, /setclass sets it directly
- drop teacher emoji, format grade as "N клас"
2026-09-06 16:17:48 +02:00
forust d2d4efb0d7 feat: add schedule scraper for lessons table
ci / lint-prettier (push) Successful in 9s
ci / lint-ruff (push) Failing after 4s
ci / lint-yaml (push) Successful in 7s
ci / lint-dockerfiles (push) Successful in 6s
ci / validate (push) Successful in 7s
ci / build (push) Has been skipped
Add /schedule command to scrape edu.edu.vn.ua/lessons/table via Playwright.
Inline keyboard flow: pick weekday (with today/tomorrow shortcuts),
then pick class. Cache 5h per user. Parse subjects/notes/teachers,
multi-lesson cells (hr-separated).
2026-09-06 15:46:41 +02:00
forust b752bf88bd feat: add edu_master k8s manifests for k0s migration
ci / lint-prettier (push) Successful in 10s
ci / lint-ruff (push) Successful in 4s
ci / lint-yaml (push) Successful in 7s
ci / lint-dockerfiles (push) Successful in 5s
ci / validate (push) Successful in 5s
ci / build (push) Successful in 35s
2026-09-06 15:00:51 +02:00
forust 587611ca88 chore: remove empty middlewares blocks from k8s ingresses 2026-09-02 12:17:04 +02:00
96 changed files with 9927 additions and 141 deletions
+41 -8
View File
@@ -124,6 +124,8 @@ jobs:
needs: [lint-prettier, lint-ruff, lint-yaml, lint-dockerfiles, validate] needs: [lint-prettier, lint-ruff, lint-yaml, lint-dockerfiles, validate]
if: github.event_name != 'pull_request' && (github.ref_name == 'main' || github.ref_name == 'dev') if: github.event_name != 'pull_request' && (github.ref_name == 'main' || github.ref_name == 'dev')
runs-on: [self-hosted, linux, arch, homelab] runs-on: [self-hosted, linux, arch, homelab]
outputs:
services: ${{ steps.services.outputs.services }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -243,7 +245,6 @@ jobs:
done done
;; ;;
userbot) userbot)
image="${REGISTRY}/forust/userbot"
tags=("latest") tags=("latest")
case "${GITHUB_REF_NAME}" in case "${GITHUB_REF_NAME}" in
main) main)
@@ -253,13 +254,25 @@ jobs:
tags+=("dev") tags+=("dev")
;; ;;
esac esac
build_args=() for target in runtime panel; do
for tag in "${tags[@]}"; do case "$target" in
build_args+=(-t "${image}:${tag}") runtime)
done context="userbot"
docker build "${build_args[@]}" userbot image="${REGISTRY}/forust/userbot"
for tag in "${tags[@]}"; do ;;
docker push "${image}:${tag}" panel)
context="userbot/panel"
image="${REGISTRY}/forust/userbot-panel"
;;
esac
build_args=()
for tag in "${tags[@]}"; do
build_args+=(-t "${image}:${tag}")
done
docker build "${build_args[@]}" "$context"
for tag in "${tags[@]}"; do
docker push "${image}:${tag}"
done
done done
;; ;;
homepages) homepages)
@@ -324,3 +337,23 @@ jobs:
;; ;;
esac esac
done done
deploy-userbot-panel:
needs: build
if: github.ref_name == 'main' && contains(needs.build.outputs.services, 'userbot')
runs-on: [self-hosted, linux, arch, homelab, prod]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Apply and roll out userbot panel
shell: bash
run: |
kubectl apply -f userbot/k8s/base/panel.yaml
kubectl get secret userbot-common-secrets -n default -o json \
| jq 'del(.metadata.annotations,.metadata.creationTimestamp,.metadata.resourceVersion,.metadata.uid,.metadata.managedFields) | .metadata.namespace = "userbot"' \
| kubectl apply -f -
# Keep legacy deployments (forust/anna) in sync with manifests; they have no replicas field, so apply leaves scaling to the user manager only.
kubectl apply -f userbot/k8s/base/userbots.yaml
kubectl rollout restart deployment/userbot-panel -n userbot
kubectl rollout status deployment/userbot-panel -n userbot --timeout=180s
+152
View File
@@ -0,0 +1,152 @@
name: deploy
on:
workflow_dispatch:
concurrency:
group: deploy-main
cancel-in-progress: false
jobs:
redeploy:
runs-on: [self-hosted, linux, arch, homelab, prod]
steps:
- name: Redeploy workstation
shell: bash
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
# Set APPLY_PRUNE=true to enable kubectl apply --prune. Requires every
# manifest to carry label app.kubernetes.io/managed-by=homelab-deploy,
# otherwise previously applied resources get deleted on the next run.
APPLY_PRUNE: ${{ vars.APPLY_PRUNE }}
run: |
set -euo pipefail
: "${DEPLOY_HOST:?missing DEPLOY_HOST}"
: "${DEPLOY_USER:?missing DEPLOY_USER}"
: "${DEPLOY_KEY:?missing DEPLOY_SSH_KEY}"
deploy_port="${DEPLOY_PORT:-22}"
deploy_path="${DEPLOY_PATH:-/srv/homelab}"
ssh_key="$RUNNER_TEMP/deploy_key"
mkdir -p "$RUNNER_TEMP"
printf '%s\n' "$DEPLOY_KEY" > "$ssh_key"
chmod 600 "$ssh_key"
ssh_opts=(
-i "$ssh_key"
-p "$deploy_port"
-o BatchMode=yes
-o StrictHostKeyChecking=accept-new
)
ssh "${ssh_opts[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" \
"DEPLOY_PATH=$(printf '%q' \"$deploy_path\") APPLY_PRUNE=$(printf '%q' \"${APPLY_PRUNE:-false}\") bash -se" <<'EOF'
set -euo pipefail
repo="${DEPLOY_PATH:-/srv/homelab}"
if [ ! -d "$repo/.git" ]; then
echo "Repository not found at $repo"
exit 1
fi
git -C "$repo" fetch origin main
git -C "$repo" reset --hard origin/main
# Runtime selection: a service is k8s-managed when $SERVICE/k8s/active
# exists. Otherwise it is compose-managed, and only k8s/routing/*
# manifests (external Services / EndpointSlices / ServersTransport /
# Ingresses that route to docker backends) are applied.
# migrate: touch SERVICE/k8s/active (+ move routing files up)
# rollback: rm SERVICE/k8s/active
collect_k8s() {
find "$1" -type f \( -name '*.yaml' -o -name '*.yml' \) \
! -path '*/routing/*' ! -path '*/overlays/*' \
! -name 'kustomization.y*ml' ! -name '*.example.y*ml' \
! -name '*values.y*ml' ! -name 'patch-*.y*ml' \
| sort
}
collect_k8s_inactive() {
find "$1" -type f \( -name '*.yaml' -o -name '*.yml' \) \
\( -name 'namespace.y*ml' -o -path '*/routing/*' \) \
! -path '*/overlays/*' ! -name '*.example.y*ml' \
| sort
}
mapfile -t compose_stacks < <(
find "$repo" -type f \( -name 'compose.yaml' -o -name 'compose.yml' \) | sort
)
mapfile -t k8s_manifests < <(
for kd in $(find "$repo" -type d -name k8s ! -path '*/.git/*' | sort); do
if [ -f "$kd/active" ]; then
collect_k8s "$kd"
else
collect_k8s_inactive "$kd"
fi
done
)
echo "== Validate compose stacks =="
for cf in "${compose_stacks[@]}"; do
dir=$(dirname "$cf")
if [ -f "$dir/k8s/active" ]; then
echo " skip (k8s-managed): $dir"
continue
fi
echo " config: $cf"
docker compose -f "$cf" config --quiet
done
echo "== Validate k8s manifests (kubectl dry-run) =="
for m in "${k8s_manifests[@]}"; do
echo " apply --dry-run=client $m"
kubectl apply --dry-run=client -f "$m" >/dev/null
done
echo "== Applying Kubernetes manifests =="
ns_files=()
other_files=()
for m in "${k8s_manifests[@]}"; do
case "$m" in
*/namespace.y?ml) ns_files+=("$m") ;;
*) other_files+=("$m") ;;
esac
done
prune_opts=()
if [ "${APPLY_PRUNE:-false}" = "true" ]; then
prune_opts=(--prune -l app.kubernetes.io/managed-by=homelab-deploy)
fi
if [ "${#ns_files[@]}" -gt 0 ]; then
echo " namespaces first: ${ns_files[*]}"
kubectl apply -f "${ns_files[@]}"
fi
if [ "${#other_files[@]}" -gt 0 ]; then
echo " resources: ${other_files[*]}"
kubectl apply "${prune_opts[@]}" -f "${other_files[@]}"
fi
echo "== Redeploying docker compose stacks =="
for cf in "${compose_stacks[@]}"; do
dir=$(dirname "$cf")
if [ -f "$dir/k8s/active" ]; then
echo " skip (k8s-managed): $dir"
continue
fi
echo " compose: $dir"
if grep -Eq '^\s+pull_policy:\s*build\b' "$cf"; then
docker compose -f "$cf" build
docker compose -f "$cf" push
fi
docker compose -f "$cf" up -d --pull always --remove-orphans
done
EOF
+41 -8
View File
@@ -124,6 +124,8 @@ jobs:
needs: [lint-prettier, lint-ruff, lint-yaml, lint-dockerfiles, validate] needs: [lint-prettier, lint-ruff, lint-yaml, lint-dockerfiles, validate]
if: github.event_name != 'pull_request' && (github.ref_name == 'main' || github.ref_name == 'dev') if: github.event_name != 'pull_request' && (github.ref_name == 'main' || github.ref_name == 'dev')
runs-on: [self-hosted, linux, arch, homelab] runs-on: [self-hosted, linux, arch, homelab]
outputs:
services: ${{ steps.services.outputs.services }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -243,7 +245,6 @@ jobs:
done done
;; ;;
userbot) userbot)
image="${REGISTRY}/forust/userbot"
tags=("latest") tags=("latest")
case "${GITHUB_REF_NAME}" in case "${GITHUB_REF_NAME}" in
main) main)
@@ -253,13 +254,25 @@ jobs:
tags+=("dev") tags+=("dev")
;; ;;
esac esac
build_args=() for target in runtime panel; do
for tag in "${tags[@]}"; do case "$target" in
build_args+=(-t "${image}:${tag}") runtime)
done context="userbot"
docker build "${build_args[@]}" userbot image="${REGISTRY}/forust/userbot"
for tag in "${tags[@]}"; do ;;
docker push "${image}:${tag}" panel)
context="userbot/panel"
image="${REGISTRY}/forust/userbot-panel"
;;
esac
build_args=()
for tag in "${tags[@]}"; do
build_args+=(-t "${image}:${tag}")
done
docker build "${build_args[@]}" "$context"
for tag in "${tags[@]}"; do
docker push "${image}:${tag}"
done
done done
;; ;;
homepages) homepages)
@@ -324,3 +337,23 @@ jobs:
;; ;;
esac esac
done done
deploy-userbot-panel:
needs: build
if: github.ref_name == 'main' && contains(needs.build.outputs.services, 'userbot')
runs-on: [self-hosted, linux, arch, homelab, prod]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Apply and roll out userbot panel
shell: bash
run: |
kubectl apply -f userbot/k8s/base/panel.yaml
kubectl get secret userbot-common-secrets -n default -o json \
| jq 'del(.metadata.annotations,.metadata.creationTimestamp,.metadata.resourceVersion,.metadata.uid,.metadata.managedFields) | .metadata.namespace = "userbot"' \
| kubectl apply -f -
# Keep legacy deployments (forust/anna) in sync with manifests; they have no replicas field, so apply leaves scaling to the user manager only.
kubectl apply -f userbot/k8s/base/userbots.yaml
kubectl rollout restart deployment/userbot-panel -n userbot
kubectl rollout status deployment/userbot-panel -n userbot --timeout=180s
+152
View File
@@ -0,0 +1,152 @@
name: deploy
on:
workflow_dispatch:
concurrency:
group: deploy-main
cancel-in-progress: false
jobs:
redeploy:
runs-on: [self-hosted, linux, arch, homelab, prod]
steps:
- name: Redeploy workstation
shell: bash
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
# Set APPLY_PRUNE=true to enable kubectl apply --prune. Requires every
# manifest to carry label app.kubernetes.io/managed-by=homelab-deploy,
# otherwise previously applied resources get deleted on the next run.
APPLY_PRUNE: ${{ vars.APPLY_PRUNE }}
run: |
set -euo pipefail
: "${DEPLOY_HOST:?missing DEPLOY_HOST}"
: "${DEPLOY_USER:?missing DEPLOY_USER}"
: "${DEPLOY_KEY:?missing DEPLOY_SSH_KEY}"
deploy_port="${DEPLOY_PORT:-22}"
deploy_path="${DEPLOY_PATH:-/srv/homelab}"
ssh_key="$RUNNER_TEMP/deploy_key"
mkdir -p "$RUNNER_TEMP"
printf '%s\n' "$DEPLOY_KEY" > "$ssh_key"
chmod 600 "$ssh_key"
ssh_opts=(
-i "$ssh_key"
-p "$deploy_port"
-o BatchMode=yes
-o StrictHostKeyChecking=accept-new
)
ssh "${ssh_opts[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" \
"DEPLOY_PATH=$(printf '%q' \"$deploy_path\") APPLY_PRUNE=$(printf '%q' \"${APPLY_PRUNE:-false}\") bash -se" <<'EOF'
set -euo pipefail
repo="${DEPLOY_PATH:-/srv/homelab}"
if [ ! -d "$repo/.git" ]; then
echo "Repository not found at $repo"
exit 1
fi
git -C "$repo" fetch origin main
git -C "$repo" reset --hard origin/main
# Runtime selection: a service is k8s-managed when $SERVICE/k8s/active
# exists. Otherwise it is compose-managed, and only k8s/routing/*
# manifests (external Services / EndpointSlices / ServersTransport /
# Ingresses that route to docker backends) are applied.
# migrate: touch SERVICE/k8s/active (+ move routing files up)
# rollback: rm SERVICE/k8s/active
collect_k8s() {
find "$1" -type f \( -name '*.yaml' -o -name '*.yml' \) \
! -path '*/routing/*' ! -path '*/overlays/*' \
! -name 'kustomization.y*ml' ! -name '*.example.y*ml' \
! -name '*values.y*ml' ! -name 'patch-*.y*ml' \
| sort
}
collect_k8s_inactive() {
find "$1" -type f \( -name '*.yaml' -o -name '*.yml' \) \
\( -name 'namespace.y*ml' -o -path '*/routing/*' \) \
! -path '*/overlays/*' ! -name '*.example.y*ml' \
| sort
}
mapfile -t compose_stacks < <(
find "$repo" -type f \( -name 'compose.yaml' -o -name 'compose.yml' \) | sort
)
mapfile -t k8s_manifests < <(
for kd in $(find "$repo" -type d -name k8s ! -path '*/.git/*' | sort); do
if [ -f "$kd/active" ]; then
collect_k8s "$kd"
else
collect_k8s_inactive "$kd"
fi
done
)
echo "== Validate compose stacks =="
for cf in "${compose_stacks[@]}"; do
dir=$(dirname "$cf")
if [ -f "$dir/k8s/active" ]; then
echo " skip (k8s-managed): $dir"
continue
fi
echo " config: $cf"
docker compose -f "$cf" config --quiet
done
echo "== Validate k8s manifests (kubectl dry-run) =="
for m in "${k8s_manifests[@]}"; do
echo " apply --dry-run=client $m"
kubectl apply --dry-run=client -f "$m" >/dev/null
done
echo "== Applying Kubernetes manifests =="
ns_files=()
other_files=()
for m in "${k8s_manifests[@]}"; do
case "$m" in
*/namespace.y?ml) ns_files+=("$m") ;;
*) other_files+=("$m") ;;
esac
done
prune_opts=()
if [ "${APPLY_PRUNE:-false}" = "true" ]; then
prune_opts=(--prune -l app.kubernetes.io/managed-by=homelab-deploy)
fi
if [ "${#ns_files[@]}" -gt 0 ]; then
echo " namespaces first: ${ns_files[*]}"
kubectl apply -f "${ns_files[@]}"
fi
if [ "${#other_files[@]}" -gt 0 ]; then
echo " resources: ${other_files[*]}"
kubectl apply "${prune_opts[@]}" -f "${other_files[@]}"
fi
echo "== Redeploying docker compose stacks =="
for cf in "${compose_stacks[@]}"; do
dir=$(dirname "$cf")
if [ -f "$dir/k8s/active" ]; then
echo " skip (k8s-managed): $dir"
continue
fi
echo " compose: $dir"
if grep -Eq '^\s+pull_policy:\s*build\b' "$cf"; then
docker compose -f "$cf" build
docker compose -f "$cf" push
fi
docker compose -f "$cf" up -d --pull always --remove-orphans
done
EOF
+1
View File
@@ -41,6 +41,7 @@ traefik/dynamic/fileservers.yml
traefik/dynamic/*.local.y*ml.* traefik/dynamic/*.local.y*ml.*
traefik/dynamic/*.external.y*ml traefik/dynamic/*.external.y*ml
traefik/k8s/fileservers.y*ml traefik/k8s/fileservers.y*ml
traefik/k8s/aliasHeadersStrategy.md
traefik/logs/* traefik/logs/*
View File
-2
View File
@@ -9,13 +9,11 @@ spec:
routes: routes:
- match: Host(`adguard.forust.xyz`) || Host(`dns.forust.xyz`) - match: Host(`adguard.forust.xyz`) || Host(`dns.forust.xyz`)
kind: Rule kind: Rule
middlewares:
services: services:
- name: adguard-service - name: adguard-service
port: 3000 port: 3000
- match: (Host(`adguard.forust.xyz`) || Host(`dns.forust.xyz`)) && PathPrefix(`/dns-query`) - match: (Host(`adguard.forust.xyz`) || Host(`dns.forust.xyz`)) && PathPrefix(`/dns-query`)
kind: Rule kind: Rule
middlewares:
services: services:
- name: adguard-service - name: adguard-service
port: 3000 port: 3000
View File
-1
View File
@@ -9,7 +9,6 @@ spec:
routes: routes:
- match: Host(`auth.forust.xyz`) - match: Host(`auth.forust.xyz`)
kind: Rule kind: Rule
middlewares:
services: services:
- name: authentik-server-service - name: authentik-server-service
port: 9000 port: 9000
View File
View File
+9 -2
View File
@@ -7,8 +7,12 @@ spec:
selector: selector:
app: checkmk app: checkmk
ports: ports:
- port: 5000 - name: web
port: 5000
targetPort: 5000 targetPort: 5000
- name: agent-receiver
port: 8000
targetPort: 8000
--- ---
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
@@ -34,7 +38,10 @@ spec:
- configMapRef: - configMapRef:
name: checkmk-config name: checkmk-config
ports: ports:
- containerPort: 5000 - name: web
containerPort: 5000
- name: agent-receiver
containerPort: 8000
volumeMounts: volumeMounts:
- name: sites - name: sites
mountPath: /omd/sites mountPath: /omd/sites
+16 -1
View File
@@ -9,7 +9,6 @@ spec:
routes: routes:
- match: Host(`cmk.forust.xyz`) - match: Host(`cmk.forust.xyz`)
kind: Rule kind: Rule
middlewares:
services: services:
- name: checkmk-service - name: checkmk-service
port: 5000 port: 5000
@@ -17,6 +16,22 @@ spec:
certResolver: letsencrypt certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRouteTCP
metadata:
name: checkmk-agent-receiver
namespace: checkmk
spec:
entryPoints:
- checkmk-agent
routes:
- match: HostSNI(`*`)
services:
- name: checkmk-service
port: 8000
tls:
passthrough: true
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
metadata: metadata:
name: checkmk-local name: checkmk-local
View File
View File
+1
View File
@@ -9,5 +9,6 @@ WEBINAR_CHECK_INTERVAL=60
REDIS_HOST=redis REDIS_HOST=redis
REDIS_PORT=6379 REDIS_PORT=6379
PLAYWRIGHT_WS=ws://playwright-service:3000/ws PLAYWRIGHT_WS=ws://playwright-service:3000/ws
TZ=Europe/Kyiv
WEBINAR_TELEGRAM_TOKEN=your_telegram_bot_token_here WEBINAR_TELEGRAM_TOKEN=your_telegram_bot_token_here
WEBINAR_ADMIN_ID=123456789 WEBINAR_ADMIN_ID=123456789
View File
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: edu-master
+57
View File
@@ -0,0 +1,57 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: playwright-service
namespace: edu-master
labels:
app: edu-master-playwright
spec:
replicas: 1
selector:
matchLabels:
app: edu-master-playwright
template:
metadata:
labels:
app: edu-master-playwright
spec:
containers:
- name: playwright
image: mcr.microsoft.com/playwright:v1.56.0-jammy
imagePullPolicy: IfNotPresent
command:
- npx
- -y
- playwright@1.56.0
- run-server
- --port
- "3000"
- --path
- /ws
ports:
- containerPort: 3000
readinessProbe:
tcpSocket:
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
livenessProbe:
tcpSocket:
port: 3000
initialDelaySeconds: 15
periodSeconds: 20
timeoutSeconds: 3
---
apiVersion: v1
kind: Service
metadata:
name: playwright-service
namespace: edu-master
spec:
selector:
app: edu-master-playwright
ports:
- name: ws
port: 3000
targetPort: 3000
+75
View File
@@ -0,0 +1,75 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: redis
namespace: edu-master
labels:
app: edu-master-redis
spec:
serviceName: redis
replicas: 1
selector:
matchLabels:
app: edu-master-redis
template:
metadata:
labels:
app: edu-master-redis
spec:
containers:
- name: redis
image: redis:alpine
imagePullPolicy: IfNotPresent
ports:
- containerPort: 6379
volumeMounts:
- name: redis-data
mountPath: /data
resources:
requests:
cpu: 25m
memory: 64Mi
limits:
cpu: 250m
memory: 256Mi
readinessProbe:
exec:
command: ["redis-cli", "ping"]
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
livenessProbe:
exec:
command: ["redis-cli", "ping"]
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
volumes:
- name: redis-data
persistentVolumeClaim:
claimName: redis-data-pvc
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: redis-data-pvc
namespace: edu-master
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
---
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: edu-master
spec:
selector:
app: edu-master-redis
ports:
- name: redis
port: 6379
targetPort: 6379
@@ -0,0 +1,50 @@
# One-time Job to migrate redis state from docker compose to k8s (maintenance window).
# The .example file is not applied by the deploy pipeline (mask *.example.yaml).
#
# Runbook:
# 1. docker compose -f <repo>/edu_master/compose.yaml stop # SIGTERM -> redis will flush dump.rdb
# 2. docker run --rm -v edu_master_redis-data:/data \
# -v /tmp/edu-master-backup:/backup \
# redis:alpine sh -c "cp /data/dump.rdb /backup/ && ls -la /backup"
# 3. kubectl apply -f edu_master/k8s/namespace.yaml
# 4. kubectl apply -f <only the PVC from redis.yaml> # seed must come BEFORE redis pod starts
# 5. kubectl apply -f edu_master/k8s/restore-seed-job.yaml.example
# kubectl wait --for=condition=complete job/redis-restore-seed -n edu-master --timeout=120s
# 6. kubectl delete job redis-restore-seed -n edu-master
# 7. kubectl apply -f edu_master/k8s/ -R # apply remaining manifests
apiVersion: batch/v1
kind: Job
metadata:
name: redis-restore-seed
namespace: edu-master
spec:
backoffLimit: 2
ttlSecondsAfterFinished: 3600
template:
spec:
restartPolicy: Never
containers:
- name: seed
image: redis:alpine
command:
- /bin/sh
- -ec
- |
ls -la /backup
cp /backup/dump.rdb /data/dump.rdb
chmod 644 /data/dump.rdb
ls -la /data
volumeMounts:
- name: redis-data
mountPath: /data
- name: backup
mountPath: /backup
readOnly: true
volumes:
- name: redis-data
persistentVolumeClaim:
claimName: redis-data-pvc
- name: backup
hostPath:
path: /tmp/edu-master-backup
type: DirectoryOrCreate
+27
View File
@@ -0,0 +1,27 @@
apiVersion: v1
kind: Secret
metadata:
name: edu-master-secrets
namespace: edu-master
type: Opaque
stringData:
# Session keeper credentials
KEEPER_LOGIN: ""
KEEPER_PASSWORD: ""
KEEPER_INTERVAL: "10"
# EDU links
EDU_URL_BASE: "https://edu.edu.vn.ua"
EDU_URL_LOGIN: "/user/login"
EDU_URL_COURSES: "/course/userlist"
EDU_URL_WEBINAR: "/webinar/useractive"
# Playwright
USER_AGENT: ""
PLAYWRIGHT_WS: "ws://playwright-service:3000/ws"
# Webinar-checker
WEBINAR_TELEGRAM_TOKEN: ""
WEBINAR_ADMIN_ID: ""
WEBINAR_CHECK_INTERVAL: "60"
# Database
REDIS_HOST: "redis"
REDIS_PORT: "6379"
TZ: "Europe/Kyiv"
+52
View File
@@ -0,0 +1,52 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: session-keeper
namespace: edu-master
labels:
app: edu-master-session-keeper
spec:
replicas: 1
selector:
matchLabels:
app: edu-master-session-keeper
template:
metadata:
labels:
app: edu-master-session-keeper
spec:
initContainers:
- name: wait-redis
image: redis:alpine
command:
- /bin/sh
- -ec
- |
i=0
until redis-cli -h redis ping | grep -q PONG; do
i=$((i+1))
[ "$i" -ge 300 ] && echo "TIMEOUT: redis not ready" && exit 1
sleep 2
done
echo "redis is ready"
containers:
- name: session-keeper
image: gcr.forust.xyz/forust/session-keeper:latest
imagePullPolicy: Always
envFrom:
- secretRef:
name: edu-master-secrets
resources:
requests:
cpu: 25m
memory: 96Mi
limits:
cpu: 250m
memory: 256Mi
readinessProbe:
exec:
command: ["/bin/sh", "-ec", "redis-cli -h redis EXISTS EDU_PHPSESSID | grep -q 1"]
initialDelaySeconds: 15
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 10
+62
View File
@@ -0,0 +1,62 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: webinar-checker
namespace: edu-master
labels:
app: edu-master-webinar-checker
spec:
replicas: 1
selector:
matchLabels:
app: edu-master-webinar-checker
template:
metadata:
labels:
app: edu-master-webinar-checker
spec:
# Enforces dependency order like compose depends_on:
# redis healthy -> session-keeper healthy (EXISTS EDU_PHPSESSID) -> playwright started
initContainers:
- name: wait-deps
image: redis:alpine
command:
- /bin/sh
- -ec
- |
i=0
until redis-cli -h redis ping | grep -q PONG; do
i=$((i+1))
[ "$i" -ge 300 ] && echo "TIMEOUT: redis not ready" && exit 1
sleep 2
done
echo "redis ok"
until [ "$(redis-cli -h redis EXISTS EDU_PHPSESSID)" = "1" ]; do
i=$((i+1))
[ "$i" -ge 300 ] && echo "TIMEOUT: no PHPSESSID (session-keeper down?)" && exit 1
sleep 2
done
echo "PHPSESSID ok"
until nc -z playwright-service 3000; do
i=$((i+1))
[ "$i" -ge 300 ] && echo "TIMEOUT: playwright-service not reachable" && exit 1
sleep 2
done
echo "playwright ok"
containers:
- name: webinar-checker
image: gcr.forust.xyz/forust/webinar-checker:latest
imagePullPolicy: Always
envFrom:
- secretRef:
name: edu-master-secrets
env:
- name: TZ
value: "Europe/Kyiv"
resources:
requests:
cpu: 25m
memory: 128Mi
limits:
cpu: 300m
memory: 384Mi
File diff suppressed because it is too large Load Diff
View File
-2
View File
@@ -9,13 +9,11 @@ spec:
routes: routes:
- match: Host(`gitea.forust.xyz`) - match: Host(`gitea.forust.xyz`)
kind: Rule kind: Rule
middlewares:
services: services:
- name: gitea-service - name: gitea-service
port: 3000 port: 3000
- match: Host(`gcr.forust.xyz`) && PathPrefix(`/v2`) - match: Host(`gcr.forust.xyz`) && PathPrefix(`/v2`)
kind: Rule kind: Rule
middlewares:
services: services:
- name: gitea-service - name: gitea-service
port: 3000 port: 3000
View File
@@ -18,19 +18,16 @@ spec:
routes: routes:
- match: Host(`hs.forust.xyz`) - match: Host(`hs.forust.xyz`)
kind: Rule kind: Rule
middlewares:
services: services:
- name: headscale-server-external - name: headscale-server-external
port: 8080 port: 8080
- match: Host(`hs.forust.xyz`) && PathPrefix(`/admin`) - match: Host(`hs.forust.xyz`) && PathPrefix(`/admin`)
kind: Rule kind: Rule
middlewares:
services: services:
- name: headscale-ui-external - name: headscale-ui-external
port: 80 port: 80
- match: Host(`hs.forust.xyz`) && PathPrefix(`/metrics`) - match: Host(`hs.forust.xyz`) && PathPrefix(`/metrics`)
kind: Rule kind: Rule
middlewares:
services: services:
- name: headscale-server-external - name: headscale-server-external
port: 9090 port: 9090
View File
-2
View File
@@ -9,7 +9,6 @@ spec:
routes: routes:
- match: Host(`forust.xyz`) || Host(`www.forust.xyz`) - match: Host(`forust.xyz`) || Host(`www.forust.xyz`)
kind: Rule kind: Rule
middlewares:
priority: 10 priority: 10
services: services:
- name: forust-homepage-service - name: forust-homepage-service
@@ -44,7 +43,6 @@ spec:
routes: routes:
- match: Host(`xdfnx.cfd`) - match: Host(`xdfnx.cfd`)
kind: Rule kind: Rule
middlewares:
services: services:
- name: xdfnx-homepage-service - name: xdfnx-homepage-service
port: 80 port: 80
-1
View File
@@ -9,7 +9,6 @@ spec:
routes: routes:
- match: Host(`status.forust.xyz`) - match: Host(`status.forust.xyz`)
kind: Rule kind: Rule
middlewares:
services: services:
- name: kener-service - name: kener-service
port: 3000 port: 3000
View File
View File
-1
View File
@@ -9,7 +9,6 @@ spec:
routes: routes:
- match: Host(`n8n.forust.xyz`) - match: Host(`n8n.forust.xyz`)
kind: Rule kind: Rule
middlewares:
services: services:
- name: n8n-service - name: n8n-service
port: 5678 port: 5678
View File
-1
View File
@@ -9,7 +9,6 @@ spec:
routes: routes:
- match: Host(`nm.forust.xyz`) - match: Host(`nm.forust.xyz`)
kind: Rule kind: Rule
middlewares:
services: services:
- name: netronome-service - name: netronome-service
port: 7575 port: 7575
View File
-1
View File
@@ -9,7 +9,6 @@ spec:
routes: routes:
- match: Host(`portainer.forust.xyz`) - match: Host(`portainer.forust.xyz`)
kind: Rule kind: Rule
middlewares:
services: services:
- name: portainer-service - name: portainer-service
port: 9000 port: 9000
View File
View File
-1
View File
@@ -9,7 +9,6 @@ spec:
routes: routes:
- match: Host(`s.forust.xyz`) || Host(`search.forust.xyz`) - match: Host(`s.forust.xyz`) || Host(`search.forust.xyz`)
kind: Rule kind: Rule
middlewares:
services: services:
- name: searxng-service - name: searxng-service
port: 8080 port: 8080
View File
-1
View File
@@ -9,7 +9,6 @@ spec:
routes: routes:
- match: Host(`termix.forust.xyz`) - match: Host(`termix.forust.xyz`)
kind: Rule kind: Rule
middlewares:
services: services:
- name: termix-service - name: termix-service
port: 8080 port: 8080
View File
-1
View File
@@ -10,7 +10,6 @@ spec:
routes: routes:
- match: Host(`traefik.forust.xyz`) - match: Host(`traefik.forust.xyz`)
kind: Rule kind: Rule
middlewares:
services: services:
- name: api@internal - name: api@internal
kind: TraefikService kind: TraefikService
+25 -7
View File
@@ -3,7 +3,7 @@ hostNetwork: false
image: image:
registry: docker.io/library registry: docker.io/library
repository: traefik repository: traefik
tag: v3.7.6 tag: v3.7.13
securityContext: securityContext:
capabilities: capabilities:
@@ -44,6 +44,7 @@ ports:
web: web:
port: 80 port: 80
http: http:
aliasHeadersStrategy: delete
redirections: redirections:
entryPoint: entryPoint:
to: websecure to: websecure
@@ -52,32 +53,54 @@ ports:
websecure: websecure:
port: 443 port: 443
http: http:
aliasHeadersStrategy: delete
tls: tls:
enabled: true enabled: true
traefik:
port: 8080
exposedPort: 8080
protocol: TCP
expose:
default: false
http:
aliasHeadersStrategy: delete
ssh: ssh:
port: 2221 port: 2221
exposedPort: 2221 exposedPort: 2221
protocol: TCP protocol: TCP
expose: expose:
default: true default: true
http:
aliasHeadersStrategy: delete
minecraft-tcp: minecraft-tcp:
port: 25565 port: 25565
exposedPort: 25565 exposedPort: 25565
protocol: TCP protocol: TCP
expose: expose:
default: true default: true
http:
aliasHeadersStrategy: delete
minecraft-udp: minecraft-udp:
port: 19132 port: 19132
exposedPort: 19132 exposedPort: 19132
protocol: UDP protocol: UDP
expose: expose:
default: true default: true
checkmk-agent:
port: 8000
protocol: TCP
expose:
default: true
http:
aliasHeadersStrategy: delete
metrics: metrics:
port: 9100 port: 9100
exposedPort: 9100 exposedPort: 9100
protocol: TCP protocol: TCP
expose: expose:
default: false default: false
http:
aliasHeadersStrategy: delete
metrics: metrics:
prometheus: prometheus:
@@ -122,11 +145,6 @@ additionalArguments:
- "--providers.file.directory=/etc/traefik/dynamic" - "--providers.file.directory=/etc/traefik/dynamic"
- "--providers.file.watch=true" - "--providers.file.watch=true"
- "--providers.kubernetesCRD.allowCrossNamespace=true" - "--providers.kubernetesCRD.allowCrossNamespace=true"
- "--providers.kubernetesCRD.safeNaming=false"
- "--entryPoints.websecure.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22,192.168.1.1,192.168.1.0/24,192.168.88.0/24,192.168.88.1" - "--entryPoints.websecure.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22,192.168.1.1,192.168.1.0/24,192.168.88.0/24,192.168.88.1"
- "--entryPoints.web.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22,192.168.1.1,192.168.1.0/24,192.168.88.0/24,192.168.88.1" - "--entryPoints.web.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22,192.168.1.1,192.168.1.0/24,192.168.88.0/24,192.168.88.1"
log:
level: INFO
accessLog:
enabled: true
format: common
View File
-1
View File
@@ -9,7 +9,6 @@ spec:
routes: routes:
- match: Host(`uptime.forust.xyz`) - match: Host(`uptime.forust.xyz`)
kind: Rule kind: Rule
middlewares:
services: services:
- name: uptime-kuma-service - name: uptime-kuma-service
port: 3001 port: 3001
View File
+1 -3
View File
@@ -3,7 +3,5 @@ kind: Kustomization
resources: resources:
- userbots.yaml - userbots.yaml
- common-secret.yaml - panel.yaml
- common-config.yaml - common-config.yaml
- forust-secrets.yaml
- anna-secrets.yaml
+264
View File
@@ -0,0 +1,264 @@
apiVersion: v1
kind: Namespace
metadata:
name: userbot
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: userbot-panel
namespace: userbot
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: userbot-runtime
namespace: userbot
automountServiceAccountToken: false
---
apiVersion: v1
kind: ConfigMap
metadata:
name: userbot-common-config
namespace: userbot
data:
DATABASE_TYPE: ""
DATABASE_NAME: ""
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: userbot-panel
namespace: userbot
rules:
- apiGroups:
- apps
resources:
- deployments
verbs:
- get
- list
- watch
- create
- patch
- delete
- apiGroups:
- apps
resources:
- deployments/scale
verbs:
- get
- patch
- update
- apiGroups:
- ""
resources:
- pods
verbs:
- get
- list
- watch
- apiGroups:
- ""
resources:
- pods/log
verbs:
- get
- apiGroups:
- ""
resources:
- persistentvolumeclaims
verbs:
- get
- list
- watch
- create
- delete
- apiGroups:
- ""
resources:
- secrets
verbs:
- get
- create
- delete
- apiGroups:
- ""
resources:
- configmaps
verbs:
- get
- apiGroups:
- metrics.k8s.io
resources:
- pods
verbs:
- get
- list
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: userbot-panel
namespace: userbot
subjects:
- kind: ServiceAccount
name: userbot-panel
namespace: userbot
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: userbot-panel
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: userbot-panel-legacy
namespace: default
rules:
- apiGroups:
- apps
resources:
- deployments
verbs:
- get
- list
- watch
- patch
- apiGroups:
- apps
resources:
- deployments/scale
verbs:
- get
- patch
- update
- apiGroups:
- ""
resources:
- pods
verbs:
- get
- list
- watch
- apiGroups:
- ""
resources:
- pods/log
verbs:
- get
- apiGroups:
- ""
resources:
- persistentvolumeclaims
verbs:
- get
- list
- apiGroups:
- metrics.k8s.io
resources:
- pods
verbs:
- get
- list
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: userbot-panel-legacy
namespace: default
subjects:
- kind: ServiceAccount
name: userbot-panel
namespace: userbot
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: userbot-panel-legacy
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: userbot-panel
namespace: userbot
labels:
app: userbot-panel
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: userbot-panel
template:
metadata:
labels:
app: userbot-panel
spec:
serviceAccountName: userbot-panel
containers:
- name: userbot-panel
image: gcr.forust.xyz/forust/userbot-panel:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 8080
env:
- name: USERBOT_NAMESPACE
value: userbot
- name: USERBOT_LEGACY_NAMESPACES
value: default
- name: USERBOT_IMAGE
value: gcr.forust.xyz/forust/userbot:latest
- name: USERBOT_STORAGE_CLASS
value: local-path-retain
- name: USERBOT_DOWNLOADS_HOST_PATH
value: /srv/homelab/userbot/Downloads
resources:
requests:
cpu: 20m
memory: 96Mi
limits:
cpu: 300m
memory: 384Mi
readinessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 3
periodSeconds: 10
livenessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 10
periodSeconds: 30
---
apiVersion: v1
kind: Service
metadata:
name: userbot-panel
namespace: userbot
spec:
type: ClusterIP
selector:
app: userbot-panel
ports:
- name: http
port: 8080
targetPort: http
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: userbot-panel-route
namespace: userbot
spec:
entryPoints:
- websecure
routes:
- match: Host(`userbot.workstation.internal`)
kind: Rule
services:
- name: userbot-panel
port: 8080
+30 -2
View File
@@ -4,8 +4,14 @@ metadata:
name: forust-userbot-deployment name: forust-userbot-deployment
labels: labels:
app: forust-userbot app: forust-userbot
app.kubernetes.io/name: userbot
app.kubernetes.io/instance: forust
annotations:
userbot.forust.xyz/display-name: forust
userbot.forust.xyz/legacy: "true"
userbot.forust.xyz/credentials-secret: userbot-forust-secrets
userbot.forust.xyz/pvc: forust-pvc
spec: spec:
replicas: 1
selector: selector:
matchLabels: matchLabels:
app: forust-userbot app: forust-userbot
@@ -13,6 +19,8 @@ spec:
metadata: metadata:
labels: labels:
app: forust-userbot app: forust-userbot
annotations:
kubectl.kubernetes.io/restartedAt: "2026-07-25T20:24:51+02:00"
spec: spec:
containers: containers:
- name: forust-userbot - name: forust-userbot
@@ -33,6 +41,8 @@ spec:
- secretRef: - secretRef:
name: userbot-forust-secrets name: userbot-forust-secrets
volumeMounts: volumeMounts:
- name: downloads
mountPath: /app/downloads
- name: forust-storage - name: forust-storage
mountPath: /app/data mountPath: /app/data
subPath: data subPath: data
@@ -40,6 +50,10 @@ spec:
mountPath: /app/logs mountPath: /app/logs
subPath: logs subPath: logs
volumes: volumes:
- name: downloads
hostPath:
path: /srv/homelab/userbot/Downloads
type: DirectoryOrCreate
- name: forust-storage - name: forust-storage
persistentVolumeClaim: persistentVolumeClaim:
claimName: forust-pvc claimName: forust-pvc
@@ -62,8 +76,14 @@ metadata:
name: anna-userbot-deployment name: anna-userbot-deployment
labels: labels:
app: anna-userbot app: anna-userbot
app.kubernetes.io/name: userbot
app.kubernetes.io/instance: anna
annotations:
userbot.forust.xyz/display-name: anna
userbot.forust.xyz/legacy: "true"
userbot.forust.xyz/credentials-secret: userbot-anna-secrets
userbot.forust.xyz/pvc: anna-pvc
spec: spec:
replicas: 1
selector: selector:
matchLabels: matchLabels:
app: anna-userbot app: anna-userbot
@@ -71,6 +91,8 @@ spec:
metadata: metadata:
labels: labels:
app: anna-userbot app: anna-userbot
annotations:
kubectl.kubernetes.io/restartedAt: "2026-07-25T20:24:51+02:00"
spec: spec:
containers: containers:
- name: anna-userbot - name: anna-userbot
@@ -91,6 +113,8 @@ spec:
- secretRef: - secretRef:
name: userbot-anna-secrets name: userbot-anna-secrets
volumeMounts: volumeMounts:
- name: downloads
mountPath: /app/downloads
- name: anna-storage - name: anna-storage
mountPath: /app/data mountPath: /app/data
subPath: data subPath: data
@@ -98,6 +122,10 @@ spec:
mountPath: /app/logs mountPath: /app/logs
subPath: logs subPath: logs
volumes: volumes:
- name: downloads
hostPath:
path: /srv/homelab/userbot/Downloads
type: DirectoryOrCreate
- name: anna-storage - name: anna-storage
persistentVolumeClaim: persistentVolumeClaim:
claimName: anna-pvc claimName: anna-pvc
+7
View File
@@ -0,0 +1,7 @@
backend/.venv
frontend/node_modules
frontend/dist
__pycache__
.pytest_cache
.ruff_cache
*.pyc
+7
View File
@@ -0,0 +1,7 @@
backend/.venv/
frontend/node_modules/
frontend/dist/
.pytest_cache/
.ruff_cache/
__pycache__/
*.pyc
+32
View File
@@ -0,0 +1,32 @@
FROM node:22-alpine AS frontend
WORKDIR /src
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM python:3.13-slim AS python-builder
WORKDIR /build
COPY backend/requirements.txt ./
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& pip install --no-cache-dir --prefix=/install -r requirements.txt \
&& rm -rf /var/lib/apt/lists/*
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PANEL_STATIC_DIR=/app/static
WORKDIR /app
COPY --from=python-builder /install /usr/local
COPY backend/app ./app
COPY --from=frontend /src/dist ./static
USER 65532:65532
EXPOSE 8080
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
+43
View File
@@ -0,0 +1,43 @@
# Userbot Control Panel
Локальная панель управляет userbot-инстансами напрямую через Kubernetes API.
Новые аккаунты создаются в namespace `userbot`; существующие `forust` и `anna`
остаются в `default` и принимаются под управление только через metadata labels.
## Первый запуск
Скопировать общий Secret в новый namespace, не печатая его значения:
```bash
kubectl create namespace userbot --dry-run=client -o yaml | kubectl apply -f -
kubectl get secret userbot-common-secrets -n default -o json \
| jq 'del(.metadata.annotations,.metadata.creationTimestamp,.metadata.resourceVersion,.metadata.uid,.metadata.managedFields) | .metadata.namespace = "userbot"' \
| kubectl apply -f -
```
Затем применить манифесты:
```bash
kubectl apply -f userbot/k8s/base/panel.yaml
kubectl apply -f userbot/k8s/base/userbots.yaml
kubectl rollout status deployment/userbot-panel -n userbot
```
Панель доступна только через внутренний Traefik route:
`https://userbot.workstation.internal`.
## Локальная разработка
```bash
cd userbot/panel/frontend
npm install
npm run dev
```
Backend использует in-cluster config, а вне кластера — текущий kubeconfig:
```bash
cd userbot/panel/backend
uv run --with-requirements requirements-dev.txt \
uvicorn app.main:app --reload --port 8080
```
+1
View File
@@ -0,0 +1 @@
"""Userbot Kubernetes control panel backend."""
+209
View File
@@ -0,0 +1,209 @@
from __future__ import annotations
import asyncio
import secrets
from contextlib import suppress
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pyrogram import Client
from pyrogram.errors import (
ApiIdInvalid,
FloodWait,
PasswordHashInvalid,
PhoneCodeExpired,
PhoneCodeInvalid,
PhoneNumberInvalid,
RPCError,
SessionPasswordNeeded,
Unauthorized,
)
from .errors import PanelError
from .models import AccountBase, PhoneStart, StringSessionStart
@dataclass
class AuthFlow:
flow_id: str
account: PhoneStart
client: Client
phone_code_hash: str
expires_at: datetime
@dataclass
class AuthorizedAccount:
account: AccountBase
session_string: str
class TelegramAuthService:
def __init__(self, ttl_seconds: int = 600, max_flows: int = 50) -> None:
self.ttl = timedelta(seconds=ttl_seconds)
self.max_flows = max_flows
self.flows: dict[str, AuthFlow] = {}
self._lock = asyncio.Lock()
async def start_phone(self, account: PhoneStart) -> str:
await self._cleanup_expired()
async with self._lock:
if len(self.flows) >= self.max_flows:
raise PanelError(
429,
"Too many pending authorization flows; try again later",
)
flow_id = secrets.token_urlsafe(24)
telegram = Client(
f"auth-{flow_id}",
api_id=account.api_id,
api_hash=account.api_hash,
in_memory=True,
no_updates=True,
)
try:
await telegram.connect()
sent = await telegram.send_code(account.phone)
except Exception as exc:
await self._disconnect(telegram)
raise self._translate(exc) from exc
flow = AuthFlow(
flow_id=flow_id,
account=account,
client=telegram,
phone_code_hash=sent.phone_code_hash,
expires_at=datetime.now(UTC) + self.ttl,
)
async with self._lock:
self.flows[flow_id] = flow
return flow_id
async def submit_code(
self,
flow_id: str,
code: str,
) -> AuthorizedAccount | None:
flow = await self._get(flow_id)
try:
await flow.client.sign_in(
flow.account.phone,
flow.phone_code_hash,
code,
)
except SessionPasswordNeeded:
return None
except PhoneCodeExpired as exc:
await self._discard(flow)
raise self._translate(exc) from exc
except Exception as exc:
raise self._translate(exc) from exc
return await self._finish(flow)
async def submit_password(
self,
flow_id: str,
password: str,
) -> AuthorizedAccount:
flow = await self._get(flow_id)
try:
await flow.client.check_password(password)
except Exception as exc:
raise self._translate(exc) from exc
return await self._finish(flow)
async def validate_string_session(
self,
account: StringSessionStart,
) -> AuthorizedAccount:
telegram = Client(
f"validate-{secrets.token_urlsafe(12)}",
api_id=account.api_id,
api_hash=account.api_hash,
session_string=account.session_string,
in_memory=True,
no_updates=True,
)
try:
await telegram.connect()
await telegram.get_me()
session_string = await telegram.export_session_string()
except Exception as exc:
raise self._translate(exc, session=True) from exc
finally:
await self._disconnect(telegram)
return AuthorizedAccount(account=account, session_string=session_string)
async def close(self) -> None:
async with self._lock:
flows = list(self.flows.values())
self.flows.clear()
await asyncio.gather(
*(self._disconnect(flow.client) for flow in flows),
return_exceptions=True,
)
async def _get(self, flow_id: str) -> AuthFlow:
await self._cleanup_expired()
async with self._lock:
flow = self.flows.get(flow_id)
if flow is None:
raise PanelError(410, "Authorization flow expired; start again")
return flow
async def _finish(self, flow: AuthFlow) -> AuthorizedAccount:
try:
await flow.client.get_me()
session_string = await flow.client.export_session_string()
except Exception as exc:
raise self._translate(exc) from exc
finally:
async with self._lock:
self.flows.pop(flow.flow_id, None)
await self._disconnect(flow.client)
return AuthorizedAccount(account=flow.account, session_string=session_string)
async def _discard(self, flow: AuthFlow) -> None:
async with self._lock:
self.flows.pop(flow.flow_id, None)
await self._disconnect(flow.client)
async def _cleanup_expired(self) -> None:
now = datetime.now(UTC)
async with self._lock:
expired = [
self.flows.pop(flow_id)
for flow_id, flow in list(self.flows.items())
if flow.expires_at <= now
]
if expired:
await asyncio.gather(
*(self._disconnect(flow.client) for flow in expired),
return_exceptions=True,
)
@staticmethod
async def _disconnect(telegram: Client) -> None:
with suppress(Exception):
if telegram.is_connected:
await telegram.disconnect()
@staticmethod
def _translate(exc: Exception, *, session: bool = False) -> PanelError:
if isinstance(exc, FloodWait):
return PanelError(429, f"Telegram rate limit; retry in {exc.value} seconds")
if isinstance(exc, ApiIdInvalid):
return PanelError(422, "Telegram API ID or API Hash is invalid")
if isinstance(exc, PhoneNumberInvalid):
return PanelError(422, "Phone number is invalid")
if isinstance(exc, PhoneCodeInvalid):
return PanelError(422, "Telegram code is invalid")
if isinstance(exc, PhoneCodeExpired):
return PanelError(410, "Telegram code expired; start again")
if isinstance(exc, PasswordHashInvalid):
return PanelError(422, "2FA password is invalid")
if session and isinstance(exc, (Unauthorized, RPCError)):
return PanelError(422, "StringSession is invalid or expired")
if isinstance(exc, RPCError):
return PanelError(422, "Telegram rejected the authorization request")
return PanelError(503, "Telegram authorization is unavailable")
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class Settings:
namespace: str = os.environ.get("USERBOT_NAMESPACE", "userbot")
legacy_namespaces: tuple[str, ...] = tuple(
value.strip()
for value in os.environ.get("USERBOT_LEGACY_NAMESPACES", "default").split(",")
if value.strip()
)
image: str = os.environ.get(
"USERBOT_IMAGE",
"gcr.forust.xyz/forust/userbot:latest",
)
common_secret: str = os.environ.get(
"USERBOT_COMMON_SECRET",
"userbot-common-secrets",
)
common_config: str = os.environ.get(
"USERBOT_COMMON_CONFIG",
"userbot-common-config",
)
storage_class: str = os.environ.get(
"USERBOT_STORAGE_CLASS",
"local-path-retain",
)
downloads_host_path: str = os.environ.get(
"USERBOT_DOWNLOADS_HOST_PATH",
"/srv/homelab/userbot/Downloads",
)
static_dir: Path = Path(os.environ.get("PANEL_STATIC_DIR", "/app/static"))
auth_ttl_seconds: int = int(os.environ.get("PANEL_AUTH_TTL_SECONDS", "600"))
default_storage: str = os.environ.get("USERBOT_DEFAULT_STORAGE", "1Gi")
default_cpu_limit: str = os.environ.get("USERBOT_DEFAULT_CPU_LIMIT", "300m")
default_memory_limit: str = os.environ.get(
"USERBOT_DEFAULT_MEMORY_LIMIT",
"1536Mi",
)
settings = Settings()
+5
View File
@@ -0,0 +1,5 @@
class PanelError(Exception):
def __init__(self, status_code: int, detail: str) -> None:
super().__init__(detail)
self.status_code = status_code
self.detail = detail
@@ -0,0 +1,544 @@
from __future__ import annotations
import logging
from datetime import UTC, datetime
from threading import Lock
from typing import Any
from kubernetes import client, config
from kubernetes.client.exceptions import ApiException
from .config import Settings
from .errors import PanelError
from .models import AccountBase, InstanceSummary
logger = logging.getLogger(__name__)
MANAGED_LABEL = "app.kubernetes.io/name=userbot"
INSTANCE_LABEL = "app.kubernetes.io/instance"
MANAGED_BY_LABEL = "app.kubernetes.io/managed-by"
DISPLAY_ANNOTATION = "userbot.forust.xyz/display-name"
LEGACY_ANNOTATION = "userbot.forust.xyz/legacy"
CREDENTIALS_ANNOTATION = "userbot.forust.xyz/credentials-secret"
PVC_ANNOTATION = "userbot.forust.xyz/pvc"
RESTART_ANNOTATION = "userbot.forust.xyz/restarted-at"
def _selector(labels: dict[str, str] | None) -> str:
return ",".join(f"{key}={value}" for key, value in (labels or {}).items())
def _as_datetime(value: Any) -> datetime | None:
if value is None:
return None
if isinstance(value, datetime):
return value
return getattr(value, "replace", lambda **_: None)(tzinfo=UTC)
class KubernetesService:
def __init__(
self,
settings: Settings,
*,
core: client.CoreV1Api | None = None,
apps: client.AppsV1Api | None = None,
custom: client.CustomObjectsApi | None = None,
) -> None:
self.settings = settings
if core is None or apps is None:
try:
config.load_incluster_config()
except config.ConfigException:
try:
config.load_kube_config()
except Exception as exc:
raise PanelError(
503,
"No in-cluster or kubeconfig configuration is available",
) from exc
self.core = core or client.CoreV1Api()
self.apps = apps or client.AppsV1Api()
self.custom = custom or client.CustomObjectsApi()
self._provision_lock = Lock()
def ensure_prerequisites(self) -> None:
try:
self.core.read_namespaced_secret(
self.settings.common_secret,
self.settings.namespace,
)
self.core.read_namespaced_config_map(
self.settings.common_config,
self.settings.namespace,
)
except ApiException as exc:
if exc.status == 404:
raise PanelError(
503,
"Userbot common Secret or ConfigMap is missing in the userbot namespace",
) from exc
raise self._api_error(exc, "Could not verify userbot prerequisites") from exc
def list_instances(
self,
query: str = "",
status: str = "",
) -> list[InstanceSummary]:
instances: list[InstanceSummary] = []
for namespace in (self.settings.namespace, *self.settings.legacy_namespaces):
try:
deployments = self.apps.list_namespaced_deployment(
namespace,
label_selector=MANAGED_LABEL,
).items
except ApiException as exc:
raise self._api_error(exc, f"Could not list Deployments in {namespace}") from exc
instances.extend(self._summarize(namespace, deployment) for deployment in deployments)
query = query.strip().lower()
if query:
instances = [
item
for item in instances
if query in item.instance_id.lower()
or query in item.display_name.lower()
or query in (item.pod or "").lower()
]
if status:
instances = [item for item in instances if item.status == status]
return sorted(instances, key=lambda item: (item.legacy, item.instance_id))
def get_instance(self, instance_id: str) -> InstanceSummary:
namespace, deployment = self._find_deployment(instance_id)
return self._summarize(namespace, deployment)
def assert_available(self, instance_id: str) -> None:
names = self._resource_names(instance_id)
checks = (
(self.apps.read_namespaced_deployment, names["deployment"], "Deployment"),
(self.core.read_namespaced_secret, names["secret"], "Secret"),
(self.core.read_namespaced_persistent_volume_claim, names["pvc"], "PVC"),
)
for read, name, kind in checks:
try:
read(name, self.settings.namespace)
except ApiException as exc:
if exc.status == 404:
continue
raise self._api_error(exc, f"Could not check {kind} {name}") from exc
raise PanelError(409, f"{kind} {name} already exists")
def provision(self, account: AccountBase, session_string: str) -> InstanceSummary:
with self._provision_lock:
self.assert_available(account.instance_id)
names = self._resource_names(account.instance_id)
created: list[tuple[str, str]] = []
try:
self.core.create_namespaced_secret(
self.settings.namespace,
self._secret(account, session_string, names),
)
created.append(("secret", names["secret"]))
self.core.create_namespaced_persistent_volume_claim(
self.settings.namespace,
self._pvc(account, names),
)
created.append(("pvc", names["pvc"]))
self.apps.create_namespaced_deployment(
self.settings.namespace,
self._deployment(account, names),
)
created.append(("deployment", names["deployment"]))
except ApiException as exc:
self._rollback(created)
if exc.status == 409:
raise PanelError(
409,
f"Instance {account.instance_id} already exists",
) from exc
raise self._api_error(exc, "Could not create userbot instance") from exc
return self.get_instance(account.instance_id)
def scale(self, instance_id: str, replicas: int) -> InstanceSummary:
namespace, deployment = self._find_deployment(instance_id)
try:
self.apps.patch_namespaced_deployment_scale(
deployment.metadata.name,
namespace,
{"spec": {"replicas": replicas}},
)
except ApiException as exc:
raise self._api_error(exc, "Could not scale userbot instance") from exc
return self.get_instance(instance_id)
def restart(self, instance_id: str) -> InstanceSummary:
namespace, deployment = self._find_deployment(instance_id)
if (deployment.spec.replicas or 0) == 0:
raise PanelError(409, "Stopped instance cannot be restarted")
timestamp = datetime.now(UTC).isoformat()
try:
self.apps.patch_namespaced_deployment(
deployment.metadata.name,
namespace,
{
"spec": {
"template": {
"metadata": {
"annotations": {RESTART_ANNOTATION: timestamp},
}
}
}
},
)
except ApiException as exc:
raise self._api_error(exc, "Could not restart userbot instance") from exc
return self.get_instance(instance_id)
def logs(self, instance_id: str, tail: int = 250) -> str:
namespace, deployment = self._find_deployment(instance_id)
pods = self._pods_for_deployment(namespace, deployment)
if not pods:
raise PanelError(409, "Userbot Pod is not running")
pod = pods[0]
container = deployment.spec.template.spec.containers[0].name
try:
return self.core.read_namespaced_pod_log(
pod.metadata.name,
namespace,
container=container,
tail_lines=max(1, min(tail, 1000)),
timestamps=True,
)
except ApiException as exc:
raise self._api_error(exc, "Could not read userbot logs") from exc
def delete(self, instance_id: str, *, delete_data: bool) -> None:
namespace, deployment = self._find_deployment(instance_id)
annotations = deployment.metadata.annotations or {}
if annotations.get(LEGACY_ANNOTATION) == "true" or namespace != self.settings.namespace:
raise PanelError(409, "Legacy instances cannot be deleted from the panel")
names = self._resource_names(instance_id)
secret_name = annotations.get(CREDENTIALS_ANNOTATION, names["secret"])
pvc_name = annotations.get(PVC_ANNOTATION, names["pvc"])
operations = [
(
self.apps.delete_namespaced_deployment,
(deployment.metadata.name, namespace),
{"propagation_policy": "Foreground"},
),
(self.core.delete_namespaced_secret, (secret_name, namespace), {}),
]
if delete_data:
operations.append(
(
self.core.delete_namespaced_persistent_volume_claim,
(pvc_name, namespace),
{},
)
)
for delete_resource, args, kwargs in operations:
try:
delete_resource(*args, **kwargs)
except ApiException as exc:
if exc.status != 404:
raise self._api_error(exc, "Could not delete userbot instance") from exc
def _find_deployment(self, instance_id: str) -> tuple[str, Any]:
selector = f"{MANAGED_LABEL},{INSTANCE_LABEL}={instance_id}"
for namespace in (self.settings.namespace, *self.settings.legacy_namespaces):
try:
items = self.apps.list_namespaced_deployment(
namespace,
label_selector=selector,
).items
except ApiException as exc:
raise self._api_error(exc, "Could not find userbot instance") from exc
if items:
return namespace, items[0]
raise PanelError(404, f"Instance {instance_id} does not exist")
def _summarize(self, namespace: str, deployment: Any) -> InstanceSummary:
labels = deployment.metadata.labels or {}
annotations = deployment.metadata.annotations or {}
instance_id = labels.get(INSTANCE_LABEL, deployment.metadata.name)
legacy = annotations.get(LEGACY_ANNOTATION) == "true"
pods = self._pods_for_deployment(namespace, deployment)
pod = pods[0] if pods else None
desired = deployment.spec.replicas or 0
ready = False
reason: str | None = None
restarts = 0
updated_at = deployment.metadata.creation_timestamp
if pod is not None:
statuses = pod.status.container_statuses or []
ready = bool(statuses) and all(item.ready for item in statuses)
restarts = sum(item.restart_count for item in statuses)
for item in statuses:
state = item.state
if state and state.waiting and state.waiting.reason:
reason = state.waiting.reason
break
if state and state.terminated and state.terminated.reason:
reason = state.terminated.reason
break
updated_at = pod.status.start_time or pod.metadata.creation_timestamp
if desired == 0:
status = "stopped"
elif reason in {
"CrashLoopBackOff",
"Error",
"ImagePullBackOff",
"ErrImagePull",
"CreateContainerConfigError",
"RunContainerError",
} or (pod is not None and pod.status.phase == "Failed"):
status = "error"
elif ready and (deployment.status.available_replicas or 0) > 0:
status = "running"
else:
status = "pending"
reason = reason or (pod.status.phase if pod is not None else "Scheduling")
container_spec = deployment.spec.template.spec.containers[0]
limits = (container_spec.resources.limits or {}) if container_spec.resources else {}
pvc_name = annotations.get(PVC_ANNOTATION) or self._deployment_pvc(deployment)
storage = self._pvc_storage(namespace, pvc_name)
cpu_usage, memory_usage = self._pod_metrics(namespace, pod.metadata.name if pod else None)
return InstanceSummary(
instance_id=instance_id,
display_name=annotations.get(DISPLAY_ANNOTATION, instance_id),
namespace=namespace,
deployment=deployment.metadata.name,
pod=pod.metadata.name if pod else None,
status=status,
reason=reason,
ready=ready,
restarts=restarts,
pvc=pvc_name,
storage=storage,
image=container_spec.image,
cpu_limit=limits.get("cpu"),
memory_limit=limits.get("memory"),
cpu_usage=cpu_usage,
memory_usage=memory_usage,
updated_at=_as_datetime(updated_at),
legacy=legacy,
deletable=not legacy and namespace == self.settings.namespace,
)
def _pods_for_deployment(self, namespace: str, deployment: Any) -> list[Any]:
selector = _selector(deployment.spec.selector.match_labels)
try:
pods = self.core.list_namespaced_pod(
namespace,
label_selector=selector,
).items
except ApiException as exc:
raise self._api_error(exc, "Could not list userbot Pods") from exc
return sorted(
pods,
key=lambda pod: pod.metadata.creation_timestamp or datetime.min.replace(tzinfo=UTC),
reverse=True,
)
def _pod_metrics(self, namespace: str, pod_name: str | None) -> tuple[str | None, str | None]:
if not pod_name:
return None, None
try:
metrics = self.custom.get_namespaced_custom_object(
"metrics.k8s.io",
"v1beta1",
namespace,
"pods",
pod_name,
)
except (ApiException, AttributeError):
return None, None
containers = metrics.get("containers", [])
cpu = containers[0].get("usage", {}).get("cpu") if containers else None
memory = containers[0].get("usage", {}).get("memory") if containers else None
return cpu, memory
def _pvc_storage(self, namespace: str, pvc_name: str | None) -> str | None:
if not pvc_name:
return None
try:
pvc = self.core.read_namespaced_persistent_volume_claim(pvc_name, namespace)
except ApiException:
return None
requests = pvc.spec.resources.requests or {}
return requests.get("storage")
@staticmethod
def _deployment_pvc(deployment: Any) -> str | None:
for volume in deployment.spec.template.spec.volumes or []:
if volume.persistent_volume_claim:
return volume.persistent_volume_claim.claim_name
return None
@staticmethod
def _resource_names(instance_id: str) -> dict[str, str]:
return {
"deployment": f"userbot-{instance_id}",
"secret": f"userbot-{instance_id}-credentials",
"pvc": f"userbot-{instance_id}-data",
}
def _metadata(
self,
account: AccountBase,
names: dict[str, str],
resource_name: str,
) -> client.V1ObjectMeta:
return client.V1ObjectMeta(
name=resource_name,
namespace=self.settings.namespace,
labels={
"app.kubernetes.io/name": "userbot",
INSTANCE_LABEL: account.instance_id,
MANAGED_BY_LABEL: "userbot-panel",
},
annotations={
DISPLAY_ANNOTATION: account.display_name,
CREDENTIALS_ANNOTATION: names["secret"],
PVC_ANNOTATION: names["pvc"],
},
)
def _secret(
self,
account: AccountBase,
session_string: str,
names: dict[str, str],
) -> client.V1Secret:
return client.V1Secret(
metadata=self._metadata(account, names, names["secret"]),
type="Opaque",
string_data={
"API_ID": str(account.api_id),
"API_HASH": account.api_hash,
"STRINGSESSION": session_string,
},
)
def _pvc(
self,
account: AccountBase,
names: dict[str, str],
) -> client.V1PersistentVolumeClaim:
return client.V1PersistentVolumeClaim(
metadata=self._metadata(account, names, names["pvc"]),
spec=client.V1PersistentVolumeClaimSpec(
access_modes=["ReadWriteOnce"],
storage_class_name=self.settings.storage_class,
resources=client.V1VolumeResourceRequirements(
requests={"storage": account.resources.storage},
),
),
)
def _deployment(
self,
account: AccountBase,
names: dict[str, str],
) -> client.V1Deployment:
pod_labels = {
"app.kubernetes.io/name": "userbot",
INSTANCE_LABEL: account.instance_id,
MANAGED_BY_LABEL: "userbot-panel",
}
container = client.V1Container(
name="userbot",
image=self.settings.image,
image_pull_policy="Always",
env_from=[
client.V1EnvFromSource(
secret_ref=client.V1SecretEnvSource(name=self.settings.common_secret)
),
client.V1EnvFromSource(
config_map_ref=client.V1ConfigMapEnvSource(name=self.settings.common_config)
),
client.V1EnvFromSource(
secret_ref=client.V1SecretEnvSource(name=names["secret"])
),
],
resources=client.V1ResourceRequirements(
requests={"cpu": "80m", "memory": "512Mi"},
limits={
"cpu": account.resources.cpu_limit,
"memory": account.resources.memory_limit,
},
),
volume_mounts=[
client.V1VolumeMount(name="data", mount_path="/app/data"),
client.V1VolumeMount(name="downloads", mount_path="/app/downloads"),
],
)
pod_spec = client.V1PodSpec(
service_account_name="userbot-runtime",
automount_service_account_token=False,
containers=[container],
termination_grace_period_seconds=30,
volumes=[
client.V1Volume(
name="data",
persistent_volume_claim=client.V1PersistentVolumeClaimVolumeSource(
claim_name=names["pvc"]
),
),
client.V1Volume(
name="downloads",
host_path=client.V1HostPathVolumeSource(
path=self.settings.downloads_host_path,
type="DirectoryOrCreate",
),
),
],
)
return client.V1Deployment(
metadata=self._metadata(account, names, names["deployment"]),
spec=client.V1DeploymentSpec(
replicas=1,
strategy=client.V1DeploymentStrategy(type="Recreate"),
selector=client.V1LabelSelector(match_labels=pod_labels),
template=client.V1PodTemplateSpec(
metadata=client.V1ObjectMeta(labels=pod_labels),
spec=pod_spec,
),
),
)
def _rollback(self, created: list[tuple[str, str]]) -> None:
for kind, name in reversed(created):
try:
if kind == "deployment":
self.apps.delete_namespaced_deployment(name, self.settings.namespace)
elif kind == "pvc":
self.core.delete_namespaced_persistent_volume_claim(
name,
self.settings.namespace,
)
else:
self.core.delete_namespaced_secret(name, self.settings.namespace)
except ApiException as exc:
logger.warning(
"Rollback of %s %s in %s failed: %s",
kind,
name,
self.settings.namespace,
exc,
)
@staticmethod
def _api_error(exc: ApiException, detail: str) -> PanelError:
if exc.status == 403:
return PanelError(503, f"{detail}: Kubernetes RBAC denied the operation")
if exc.status == 409:
return PanelError(409, f"{detail}: resource conflict")
if exc.status == 404:
return PanelError(404, f"{detail}: resource not found")
return PanelError(503, detail)
+205
View File
@@ -0,0 +1,205 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import FastAPI, Query, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from .auth_service import TelegramAuthService
from .config import settings
from .errors import PanelError
from .kubernetes_service import KubernetesService
from .models import (
AuthResult,
CodeSubmit,
DeleteRequest,
InstanceSummary,
PasswordSubmit,
PhoneStart,
StringSessionStart,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.kubernetes = KubernetesService(settings)
app.state.telegram = TelegramAuthService(settings.auth_ttl_seconds)
try:
app.state.kubernetes.ensure_prerequisites()
except PanelError as exc:
print(f"WARNING: userbot prerequisites check failed at startup: {exc.detail}")
yield
await app.state.telegram.close()
app = FastAPI(
title="Userbot Kubernetes Control",
version="1.0.0",
lifespan=lifespan,
)
@app.exception_handler(PanelError)
async def panel_error_handler(_request: Request, exc: PanelError) -> JSONResponse:
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
@app.exception_handler(RequestValidationError)
async def validation_error_handler(
_request: Request,
exc: RequestValidationError,
) -> JSONResponse:
errors = [
{
"loc": error.get("loc", ()),
"msg": error.get("msg", "Invalid value"),
"type": error.get("type", "value_error"),
}
for error in exc.errors()
]
return JSONResponse(status_code=422, content={"detail": errors})
def kube(request: Request) -> KubernetesService:
return request.app.state.kubernetes
def telegram(request: Request) -> TelegramAuthService:
return request.app.state.telegram
@app.get("/api/health")
def health(request: Request) -> dict[str, object]:
service = kube(request)
try:
service.apps.list_namespaced_deployment(
settings.namespace,
limit=1,
)
except Exception:
return {"ok": False, "kubernetes": False}
return {"ok": True, "kubernetes": True}
@app.get("/api/instances", response_model=list[InstanceSummary])
def list_instances(
request: Request,
query: str = "",
status: str = "",
) -> list[InstanceSummary]:
return kube(request).list_instances(query=query, status=status)
@app.get("/api/instances/{instance_id}", response_model=InstanceSummary)
def get_instance(instance_id: str, request: Request) -> InstanceSummary:
return kube(request).get_instance(instance_id)
@app.get("/api/instances/{instance_id}/logs")
def get_logs(
instance_id: str,
request: Request,
tail: Annotated[int, Query(ge=1, le=1000)] = 250,
) -> dict[str, str]:
return {"logs": kube(request).logs(instance_id, tail)}
@app.post("/api/instances/{instance_id}/start", response_model=InstanceSummary)
def start_instance(instance_id: str, request: Request) -> InstanceSummary:
return kube(request).scale(instance_id, 1)
@app.post("/api/instances/{instance_id}/stop", response_model=InstanceSummary)
def stop_instance(instance_id: str, request: Request) -> InstanceSummary:
return kube(request).scale(instance_id, 0)
@app.post("/api/instances/{instance_id}/restart", response_model=InstanceSummary)
def restart_instance(instance_id: str, request: Request) -> InstanceSummary:
return kube(request).restart(instance_id)
@app.post("/api/instances/{instance_id}/delete", status_code=204)
def delete_instance(
instance_id: str,
payload: DeleteRequest,
request: Request,
) -> Response:
if payload.confirmation != instance_id:
raise PanelError(422, "Type the instance id exactly to confirm deletion")
kube(request).delete(instance_id, delete_data=payload.delete_data)
return Response(status_code=204)
@app.post("/api/auth/phone/start", response_model=AuthResult)
async def auth_phone_start(
payload: PhoneStart,
request: Request,
) -> AuthResult:
service = kube(request)
service.assert_available(payload.instance_id)
flow_id = await telegram(request).start_phone(payload)
return AuthResult(status="code_required", flow_id=flow_id)
@app.post("/api/auth/phone/{flow_id}/code", response_model=AuthResult)
async def auth_phone_code(
flow_id: str,
payload: CodeSubmit,
request: Request,
) -> AuthResult:
authorized = await telegram(request).submit_code(flow_id, payload.code)
if authorized is None:
return AuthResult(status="password_required", flow_id=flow_id)
instance = kube(request).provision(authorized.account, authorized.session_string)
return AuthResult(status="ready", instance=instance)
@app.post("/api/auth/phone/{flow_id}/password", response_model=AuthResult)
async def auth_phone_password(
flow_id: str,
payload: PasswordSubmit,
request: Request,
) -> AuthResult:
authorized = await telegram(request).submit_password(flow_id, payload.password)
instance = kube(request).provision(authorized.account, authorized.session_string)
return AuthResult(status="ready", instance=instance)
@app.post("/api/auth/string-session", response_model=AuthResult)
async def auth_string_session(
payload: StringSessionStart,
request: Request,
) -> AuthResult:
service = kube(request)
service.assert_available(payload.instance_id)
authorized = await telegram(request).validate_string_session(payload)
instance = service.provision(authorized.account, authorized.session_string)
return AuthResult(status="ready", instance=instance)
static_dir = settings.static_dir
assets_dir = static_dir / "assets"
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
@app.get("/", include_in_schema=False)
def index() -> FileResponse:
return FileResponse(static_dir / "index.html")
@app.get("/{path:path}", include_in_schema=False)
def spa_fallback(path: str) -> FileResponse:
root = static_dir.resolve()
candidate = (static_dir / path).resolve()
try:
candidate.relative_to(root)
except ValueError:
return FileResponse(static_dir / "index.html")
if candidate.is_file():
return FileResponse(candidate)
return FileResponse(static_dir / "index.html")
+89
View File
@@ -0,0 +1,89 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field, field_validator
INSTANCE_PATTERN = r"^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$"
class Resources(BaseModel):
storage: str = "1Gi"
cpu_limit: str = "300m"
memory_limit: str = "1536Mi"
class AccountBase(BaseModel):
instance_id: str = Field(pattern=INSTANCE_PATTERN, max_length=40)
display_name: str = Field(min_length=1, max_length=80)
api_id: int = Field(gt=0)
api_hash: str = Field(min_length=16, max_length=128)
resources: Resources = Field(default_factory=Resources)
@field_validator("display_name", "api_hash")
@classmethod
def strip_text(cls, value: str) -> str:
return value.strip()
class PhoneStart(AccountBase):
phone: str = Field(min_length=7, max_length=32)
@field_validator("phone")
@classmethod
def normalize_phone(cls, value: str) -> str:
value = value.strip()
if not value.startswith("+"):
raise ValueError("phone must use international format")
return value
class StringSessionStart(AccountBase):
session_string: str = Field(min_length=32)
class CodeSubmit(BaseModel):
code: str = Field(min_length=3, max_length=12)
@field_validator("code")
@classmethod
def normalize_code(cls, value: str) -> str:
return "".join(value.split())
class PasswordSubmit(BaseModel):
password: str = Field(min_length=1, max_length=256)
class DeleteRequest(BaseModel):
confirmation: str
delete_data: bool = False
class InstanceSummary(BaseModel):
instance_id: str
display_name: str
namespace: str
deployment: str
pod: str | None = None
status: Literal["running", "stopped", "pending", "error"]
reason: str | None = None
ready: bool = False
restarts: int = 0
pvc: str | None = None
storage: str | None = None
image: str
cpu_limit: str | None = None
memory_limit: str | None = None
cpu_usage: str | None = None
memory_usage: str | None = None
updated_at: datetime | None = None
legacy: bool = False
deletable: bool = True
class AuthResult(BaseModel):
status: Literal["code_required", "password_required", "ready"]
flow_id: str | None = None
instance: InstanceSummary | None = None
@@ -0,0 +1,4 @@
-r requirements.txt
httpx==0.28.1
pytest==8.4.1
pytest-asyncio==1.1.0
+5
View File
@@ -0,0 +1,5 @@
fastapi==0.115.12
kubernetes==33.1.0
pyrofork==2.3.68
tgcrypto==1.2.5
uvicorn[standard]==0.34.2
@@ -0,0 +1,101 @@
from types import SimpleNamespace
import pytest
from app.auth_service import TelegramAuthService
from app.models import PhoneStart, StringSessionStart
class FakeClient:
instances = []
requires_password = False
def __init__(self, *_args, **kwargs):
self.kwargs = kwargs
self.is_connected = False
self.disconnected = False
self.__class__.instances.append(self)
async def connect(self):
self.is_connected = True
async def disconnect(self):
self.is_connected = False
self.disconnected = True
async def send_code(self, _phone):
return SimpleNamespace(phone_code_hash="hash")
async def sign_in(self, *_args):
if self.requires_password:
from pyrogram.errors import SessionPasswordNeeded
raise SessionPasswordNeeded()
async def check_password(self, _password):
return None
async def get_me(self):
return SimpleNamespace(id=1)
async def export_session_string(self):
return "exported-session"
def phone_payload() -> PhoneStart:
return PhoneStart(
instance_id="test",
display_name="Test",
api_id=123,
api_hash="0123456789abcdef",
phone="+421900000000",
)
@pytest.mark.asyncio
async def test_phone_code_success_closes_and_forgets_client(monkeypatch) -> None:
monkeypatch.setattr("app.auth_service.Client", FakeClient)
auth = TelegramAuthService()
flow_id = await auth.start_phone(phone_payload())
result = await auth.submit_code(flow_id, "12345")
assert result.session_string == "exported-session"
assert flow_id not in auth.flows
assert FakeClient.instances[-1].disconnected is True
assert FakeClient.instances[-1].kwargs["in_memory"] is True
assert FakeClient.instances[-1].kwargs["no_updates"] is True
@pytest.mark.asyncio
async def test_string_session_is_validated_and_closed(monkeypatch) -> None:
monkeypatch.setattr("app.auth_service.Client", FakeClient)
auth = TelegramAuthService()
payload = StringSessionStart(
instance_id="test",
display_name="Test",
api_id=123,
api_hash="0123456789abcdef",
session_string="x" * 64,
)
result = await auth.validate_string_session(payload)
assert result.session_string == "exported-session"
assert FakeClient.instances[-1].disconnected is True
@pytest.mark.asyncio
async def test_start_phone_rejects_overflow(monkeypatch) -> None:
from app.errors import PanelError
monkeypatch.setattr("app.auth_service.Client", FakeClient)
auth = TelegramAuthService(ttl_seconds=3600, max_flows=2)
await auth.start_phone(phone_payload())
await auth.start_phone(phone_payload())
with pytest.raises(PanelError) as error:
await auth.start_phone(phone_payload())
assert error.value.status_code == 429
assert "Too many pending" in error.value.detail
@@ -0,0 +1,200 @@
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from app.config import Settings
from app.errors import PanelError
from app.kubernetes_service import KubernetesService
from app.models import AccountBase
from kubernetes.client.exceptions import ApiException
def service() -> KubernetesService:
core = Mock()
apps = Mock()
custom = Mock()
custom.get_namespaced_custom_object.side_effect = ApiException(status=404)
return KubernetesService(Settings(), core=core, apps=apps, custom=custom)
def account() -> AccountBase:
return AccountBase(
instance_id="test-account",
display_name="Test Account",
api_id=12345,
api_hash="0123456789abcdef0123456789abcdef",
)
def test_renders_managed_resources_without_leaking_credentials() -> None:
kube = service()
names = kube._resource_names("test-account")
secret = kube._secret(account(), "SESSION", names)
pvc = kube._pvc(account(), names)
deployment = kube._deployment(account(), names)
assert secret.string_data == {
"API_ID": "12345",
"API_HASH": "0123456789abcdef0123456789abcdef",
"STRINGSESSION": "SESSION",
}
assert pvc.spec.storage_class_name == "local-path-retain"
assert pvc.spec.resources.requests["storage"] == "1Gi"
assert deployment.spec.strategy.type == "Recreate"
assert deployment.spec.replicas == 1
assert deployment.spec.template.spec.automount_service_account_token is False
assert deployment.spec.template.spec.service_account_name == "userbot-runtime"
assert deployment.spec.template.spec.volumes[1].host_path.path.endswith("/Downloads")
assert deployment.spec.template.spec.containers[0].resources.requests == {
"cpu": "80m",
"memory": "512Mi",
}
def test_collision_is_reported_before_auth() -> None:
kube = service()
kube.apps.read_namespaced_deployment.return_value = object()
with pytest.raises(PanelError) as error:
kube.assert_available("test-account")
assert error.value.status_code == 409
assert "Deployment" in error.value.detail
def test_partial_provision_rolls_back_only_created_resources() -> None:
kube = service()
kube.ensure_prerequisites = Mock()
kube.assert_available = Mock()
kube.core.create_namespaced_persistent_volume_claim.side_effect = ApiException(status=500)
with pytest.raises(PanelError):
kube.provision(account(), "SESSION")
kube.core.delete_namespaced_secret.assert_called_once_with(
"userbot-test-account-credentials",
"userbot",
)
kube.core.delete_namespaced_persistent_volume_claim.assert_not_called()
kube.apps.delete_namespaced_deployment.assert_not_called()
def test_provision_conflict_reports_existing_instance() -> None:
kube = service()
kube.assert_available = Mock()
kube.apps.create_namespaced_deployment.side_effect = ApiException(status=409)
with pytest.raises(PanelError) as error:
kube.provision(account(), "SESSION")
assert error.value.status_code == 409
assert "already exists" in error.value.detail
# Partial resources created before the 409 must be rolled back.
kube.core.delete_namespaced_secret.assert_called_once_with(
"userbot-test-account-credentials",
"userbot",
)
kube.core.delete_namespaced_persistent_volume_claim.assert_called_once_with(
"userbot-test-account-data",
"userbot",
)
def test_delete_retains_pvc_unless_explicitly_requested() -> None:
kube = service()
deployment = SimpleNamespace(
metadata=SimpleNamespace(
name="userbot-test-account",
annotations={
"userbot.forust.xyz/credentials-secret": "credentials",
"userbot.forust.xyz/pvc": "data",
},
)
)
kube._find_deployment = Mock(return_value=("userbot", deployment))
kube.delete("test-account", delete_data=False)
kube.apps.delete_namespaced_deployment.assert_called_once()
kube.core.delete_namespaced_secret.assert_called_once_with("credentials", "userbot")
kube.core.delete_namespaced_persistent_volume_claim.assert_not_called()
kube.delete("test-account", delete_data=True)
kube.core.delete_namespaced_persistent_volume_claim.assert_called_once_with(
"data",
"userbot",
)
def test_legacy_delete_is_blocked() -> None:
kube = service()
deployment = SimpleNamespace(
metadata=SimpleNamespace(
name="forust-userbot-deployment",
annotations={"userbot.forust.xyz/legacy": "true"},
)
)
kube._find_deployment = Mock(return_value=("default", deployment))
with pytest.raises(PanelError) as error:
kube.delete("forust", delete_data=False)
assert error.value.status_code == 409
def pod(*, ready: bool, phase: str = "Running", reason: str | None = None):
waiting = SimpleNamespace(reason=reason) if reason else None
state = SimpleNamespace(waiting=waiting, terminated=None)
status = SimpleNamespace(
container_statuses=[
SimpleNamespace(ready=ready, restart_count=2, state=state),
],
phase=phase,
start_time=datetime.now(UTC),
)
return SimpleNamespace(
metadata=SimpleNamespace(name="pod-1", creation_timestamp=datetime.now(UTC)),
status=status,
)
def deployment(replicas: int = 1):
resources = SimpleNamespace(limits={"cpu": "300m", "memory": "1536Mi"})
container = SimpleNamespace(image="userbot:latest", resources=resources)
template_spec = SimpleNamespace(containers=[container], volumes=[])
return SimpleNamespace(
metadata=SimpleNamespace(
name="userbot-test-account",
labels={"app.kubernetes.io/instance": "test-account"},
annotations={},
creation_timestamp=datetime.now(UTC),
),
spec=SimpleNamespace(
replicas=replicas,
selector=SimpleNamespace(match_labels={"app": "test"}),
template=SimpleNamespace(spec=template_spec),
),
status=SimpleNamespace(available_replicas=1 if replicas else 0),
)
@pytest.mark.parametrize(
("replicas", "pod_value", "expected"),
[
(0, None, "stopped"),
(1, pod(ready=True), "running"),
(1, pod(ready=False, phase="Pending"), "pending"),
(1, pod(ready=False, reason="CrashLoopBackOff"), "error"),
(1, pod(ready=False, phase="Failed"), "error"),
],
)
def test_status_classification(replicas, pod_value, expected) -> None:
kube = service()
kube._pods_for_deployment = Mock(return_value=[pod_value] if pod_value else [])
kube._pvc_storage = Mock(return_value=None)
kube._pod_metrics = Mock(return_value=(None, None))
result = kube._summarize("userbot", deployment(replicas))
assert result.status == expected
@@ -0,0 +1,44 @@
import pytest
from app.main import validation_error_handler
from app.models import AccountBase, DeleteRequest
from fastapi.exceptions import RequestValidationError
from pydantic import ValidationError
@pytest.mark.parametrize(
"instance_id",
["Upper", "has_space", "-leading", "trailing-", "x" * 41],
)
def test_invalid_instance_ids(instance_id: str) -> None:
with pytest.raises(ValidationError):
AccountBase(
instance_id=instance_id,
display_name="Test",
api_id=1,
api_hash="0123456789abcdef",
)
def test_delete_data_defaults_to_false() -> None:
request = DeleteRequest(confirmation="test")
assert request.delete_data is False
@pytest.mark.asyncio
async def test_validation_response_does_not_echo_secret_input() -> None:
sensitive_value = "-".join(("very", "private", "string", "session"))
error = RequestValidationError(
[
{
"type": "string_too_short",
"loc": ("body", "session_string"),
"msg": "String should have at least 32 characters",
"input": sensitive_value,
}
]
)
response = await validation_error_handler(None, error)
assert sensitive_value.encode() not in response.body
assert b'"input"' not in response.body
+48
View File
@@ -0,0 +1,48 @@
from pathlib import Path
import pytest
from app.main import spa_fallback
@pytest.fixture
def static_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
static = tmp_path / "static"
static.mkdir()
(static / "index.html").write_text("<html>index</html>", encoding="utf-8")
(static / "app.js").write_text("console.log('app')", encoding="utf-8")
secret = tmp_path / "secret.txt"
secret.write_text("TOP SECRET", encoding="utf-8")
monkeypatch.setattr("app.main.static_dir", static)
return static
def static_index(static_dir: Path) -> Path:
return static_dir / "index.html"
def test_returns_existing_file(static_dir: Path) -> None:
response = spa_fallback("app.js")
assert response.path == static_dir / "app.js"
def test_unknown_path_falls_back_to_index(static_dir: Path) -> None:
response = spa_fallback("does/not/exist.js")
assert response.path == static_index(static_dir)
def test_traversal_does_not_leak_outside_static(static_dir: Path) -> None:
response = spa_fallback("../secret.txt")
assert response.path == static_index(static_dir)
response = spa_fallback("%2e%2e/secret.txt")
assert response.path == static_index(static_dir)
def test_symlink_outside_static_is_blocked(static_dir: Path, tmp_path: Path) -> None:
target = tmp_path / "outside.txt"
target.write_text("secret", encoding="utf-8")
link = static_dir / "leak.txt"
link.symlink_to(target)
response = spa_fallback("leak.txt")
assert response.path == static_index(static_dir)
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#080808" />
<title>Userbot · Kubernetes Control</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "userbot-panel",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite --host 0.0.0.0",
"test": "vitest run"
},
"dependencies": {
"lucide-svelte": "^0.468.0",
"svelte": "^5.16.0"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/svelte": "^5.2.6",
"@types/node": "^22.10.5",
"jsdom": "^25.0.1",
"svelte-check": "^4.7.3",
"typescript": "~5.7.2",
"vite": "^6.0.7",
"vitest": "^2.1.8"
}
}
+237
View File
@@ -0,0 +1,237 @@
<script lang="ts">
import {
Menu,
Plus,
RefreshCw,
Search,
Server,
} from 'lucide-svelte';
import { onMount } from 'svelte';
import AddAccountDrawer from './components/AddAccountDrawer.svelte';
import DetailsDrawer from './components/DetailsDrawer.svelte';
import InstanceTable from './components/InstanceTable.svelte';
import Sidebar from './components/Sidebar.svelte';
import { api } from './lib/api';
import { filterInstances } from './lib/instances';
import type { Instance } from './lib/types';
let instances: Instance[] = [];
let selected: Instance | null = null;
let query = '';
let status = '';
let loading = true;
let refreshing = false;
let error = '';
let addOpen = false;
let menuOpen = false;
let busyId = '';
let pollTimer: number | null = null;
$: visibleInstances = filterInstances(instances, query, status);
$: running = instances.filter((item) => item.status === 'running').length;
$: stopped = instances.filter((item) => item.status === 'stopped').length;
$: restarts = instances.reduce((total, item) => total + item.restarts, 0);
onMount(() => {
refresh();
startPolling();
document.addEventListener('visibilitychange', handleVisibility);
return () => {
stopPolling();
document.removeEventListener('visibilitychange', handleVisibility);
};
});
function startPolling() {
stopPolling();
pollTimer = window.setInterval(() => refresh(true), 5000);
}
function stopPolling() {
if (pollTimer !== null) {
window.clearInterval(pollTimer);
pollTimer = null;
}
}
function handleVisibility() {
if (document.visibilityState === 'visible') {
refresh(true);
startPolling();
} else {
stopPolling();
}
}
async function refresh(silent = false) {
if (!silent) refreshing = true;
try {
const next = await api.instances();
instances = next;
if (selected) {
selected =
next.find(
(item) =>
item.instance_id === selected?.instance_id && item.namespace === selected?.namespace,
) || null;
}
error = '';
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Не удалось загрузить инстансы';
} finally {
loading = false;
refreshing = false;
}
}
async function runAction(
instance: Instance,
action: 'start' | 'stop' | 'restart' | 'logs',
) {
if (action === 'logs') {
selected = instance;
return;
}
busyId = instance.instance_id;
try {
const updated = await api.action(instance.instance_id, action);
instances = instances.map((item) =>
item.instance_id === updated.instance_id && item.namespace === updated.namespace
? updated
: item,
);
if (selected?.instance_id === updated.instance_id) selected = updated;
window.setTimeout(() => refresh(true), 900);
} catch (cause) {
error = cause instanceof Error ? cause.message : `Не удалось выполнить ${action}`;
} finally {
busyId = '';
}
}
async function deleteInstance(instance: Instance, deleteData: boolean) {
busyId = instance.instance_id;
try {
await api.delete(instance.instance_id, deleteData);
selected = null;
await refresh();
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Не удалось удалить инстанс';
} finally {
busyId = '';
}
}
function created(instance: Instance) {
addOpen = false;
selected = instance;
refresh();
}
</script>
<main class="app-shell">
<Sidebar
open={menuOpen}
on:add={() => {
addOpen = true;
menuOpen = false;
}}
on:close={() => (menuOpen = false)}
/>
{#if menuOpen}
<button class="mobile-overlay" aria-label="Закрыть меню" on:click={() => (menuOpen = false)}></button>
{/if}
<section class="workspace">
<header class="topbar">
<button class="mobile-menu icon-button" aria-label="Открыть меню" on:click={() => (menuOpen = true)}>
<Menu size={18} />
</button>
<div class="live-state"><span></span>{running} инстанса работают</div>
<div class="topbar-actions">
<span class="namespace"><Server size={14} />userbot</span>
<button class="primary" on:click={() => (addOpen = true)}>
<Plus size={16} />
Добавить аккаунт
</button>
</div>
</header>
<div class="content">
<div class="page-heading">
<div>
<h1>Инстансы</h1>
<p>Telegram-аккаунты и их состояние в Kubernetes</p>
</div>
</div>
<section class="summary-strip" aria-label="Сводка">
<div><span>Всего</span><strong>{instances.length}</strong></div>
<div><span>Работают</span><strong>{running}</strong></div>
<div><span>Остановлены</span><strong>{stopped}</strong></div>
<div><span>Перезапуски</span><strong>{restarts}</strong></div>
</section>
<div class="toolbar">
<label class="search-field">
<Search size={16} />
<input bind:value={query} placeholder="Поиск" aria-label="Поиск инстансов" />
</label>
<select bind:value={status} aria-label="Фильтр статуса">
<option value="">Все статусы</option>
<option value="running">Работают</option>
<option value="stopped">Остановлены</option>
<option value="pending">Запускаются</option>
<option value="error">Ошибки</option>
</select>
<button
class="icon-button refresh"
aria-label="Обновить"
disabled={refreshing}
on:click={() => refresh()}
>
<RefreshCw size={17} class={refreshing ? 'spin' : ''} />
</button>
</div>
{#if error}
<div class="error-banner" role="alert">
<span>{error}</span>
<button on:click={() => refresh()}>Повторить</button>
</div>
{/if}
{#if loading}
<div class="center-state"><RefreshCw class="spin" size={22} />Загружаем инстансы…</div>
{:else if visibleInstances.length}
<InstanceTable
instances={visibleInstances}
{busyId}
on:select={(event) => (selected = event.detail)}
on:action={(event) => runAction(event.detail.instance, event.detail.action)}
/>
{:else}
<div class="empty-state">
<Server size={25} />
<h2>{instances.length ? 'Ничего не найдено' : 'Инстансов пока нет'}</h2>
<p>{instances.length ? 'Измените поиск или фильтр.' : 'Добавьте первый Telegram-аккаунт.'}</p>
{#if !instances.length}
<button class="primary" on:click={() => (addOpen = true)}>
<Plus size={16} />Добавить аккаунт
</button>
{/if}
</div>
{/if}
</div>
</section>
</main>
<AddAccountDrawer open={addOpen} on:close={() => (addOpen = false)} on:created={(event) => created(event.detail)} />
<DetailsDrawer
instance={selected}
busy={busyId === selected?.instance_id}
on:close={() => (selected = null)}
on:action={(event) => runAction(event.detail.instance, event.detail.action)}
on:delete={(event) => deleteInstance(event.detail.instance, event.detail.deleteData)}
/>
@@ -0,0 +1,354 @@
<script lang="ts">
import { ArrowLeft, Check, Eye, EyeOff, LoaderCircle, X } from 'lucide-svelte';
import { createEventDispatcher } from 'svelte';
import { api } from '../lib/api';
import type { AccountDraft, Instance } from '../lib/types';
export let open = false;
type Method = 'phone' | 'session';
type AuthPhase = 'code' | 'password';
const emptyDraft = (): AccountDraft => ({
instance_id: '',
display_name: '',
api_id: '',
api_hash: '',
phone: '',
session_string: '',
resources: {
storage: '1Gi',
cpu_limit: '300m',
memory_limit: '1536Mi',
},
});
let draft = emptyDraft();
let step = 1;
let method: Method = 'phone';
let authPhase: AuthPhase = 'code';
let flowId = '';
let code = '';
let password = '';
let error = '';
let busy = false;
let showHash = false;
let showPassword = false;
const dispatch = createEventDispatcher<{ close: void; created: Instance }>();
function reset() {
draft = emptyDraft();
step = 1;
method = 'phone';
authPhase = 'code';
flowId = '';
code = '';
password = '';
error = '';
busy = false;
showHash = false;
showPassword = false;
}
function close() {
reset();
dispatch('close');
}
function validSettings() {
return (
/^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/.test(draft.instance_id) &&
draft.display_name.trim().length > 0 &&
Number(draft.api_id) > 0 &&
draft.api_hash.trim().length >= 16 &&
(method === 'phone'
? /^\+\d[\d\s()-]{6,}$/.test(draft.phone)
: draft.session_string.trim().length >= 32)
);
}
async function waitUntilReady(initial: Instance) {
let instance = initial;
for (let attempt = 0; attempt < 60 && instance.status === 'pending'; attempt += 1) {
await new Promise((resolve) => window.setTimeout(resolve, 2000));
instance = await api.instance(initial.instance_id);
if (instance.status === 'error') {
throw new Error(`Pod не запустился: ${instance.reason || 'неизвестная ошибка'}`);
}
}
return instance;
}
async function finishProvisioning(instance: Instance) {
step = 3;
const readyInstance = await waitUntilReady(instance);
dispatch('created', readyInstance);
close();
}
async function begin() {
error = '';
if (!validSettings()) {
error = 'Заполните обязательные поля и проверьте формат значений.';
return;
}
busy = true;
try {
if (method === 'session') {
step = 3;
const result = await api.stringSession(draft);
if (result.instance) {
await finishProvisioning(result.instance);
}
return;
}
const result = await api.startPhone(draft);
flowId = result.flow_id || '';
authPhase = 'code';
step = 2;
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Не удалось начать авторизацию';
step = 1;
} finally {
busy = false;
}
}
async function submitCode() {
if (!flowId || !code.trim()) return;
error = '';
busy = true;
try {
const result = await api.submitCode(flowId, code);
if (result.status === 'password_required') {
authPhase = 'password';
} else if (result.instance) {
await finishProvisioning(result.instance);
}
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Не удалось проверить код';
} finally {
busy = false;
}
}
async function submitPassword() {
if (!flowId || !password) return;
error = '';
busy = true;
try {
const result = await api.submitPassword(flowId, password);
if (result.instance) {
await finishProvisioning(result.instance);
}
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Не удалось проверить пароль';
} finally {
busy = false;
}
}
</script>
{#if open}
<div class="drawer-backdrop" on:click={close} aria-hidden="true"></div>
<aside class="drawer add-drawer" aria-label="Добавить аккаунт">
<header class="drawer-header">
<div>
<h2>Добавить аккаунт</h2>
<p>Новый Kubernetes-инстанс userbot</p>
</div>
<button class="icon-button borderless" aria-label="Закрыть" on:click={close}>
<X size={19} />
</button>
</header>
<ol class="steps" aria-label="Шаги создания">
<li class:active={step === 1} class:complete={step > 1}><span>1</span>Настройки</li>
<li class:active={step === 2} class:complete={step > 2}><span>2</span>Авторизация</li>
<li class:active={step === 3}><span>3</span>Создание</li>
</ol>
{#if step === 1}
<form class="drawer-form" on:submit|preventDefault={begin}>
<fieldset>
<legend>Инстанс</legend>
<label>
Имя
<input
bind:value={draft.instance_id}
placeholder="personal"
maxlength="40"
autocomplete="off"
required
/>
<small>Только a-z, 0-9 и дефис</small>
</label>
<label>
Отображаемое имя
<input bind:value={draft.display_name} placeholder="Personal" maxlength="80" required />
</label>
</fieldset>
<fieldset>
<legend>Telegram API</legend>
<div class="two-columns">
<label>
API ID
<input bind:value={draft.api_id} inputmode="numeric" placeholder="12345678" required />
</label>
<label>
API Hash
<span class="input-with-action">
<input
bind:value={draft.api_hash}
type={showHash ? 'text' : 'password'}
placeholder="0123456789abcdef"
required
/>
<button
type="button"
aria-label={showHash ? 'Скрыть API Hash' : 'Показать API Hash'}
on:click={() => (showHash = !showHash)}
>
{#if showHash}<EyeOff size={15} />{:else}<Eye size={15} />{/if}
</button>
</span>
</label>
</div>
</fieldset>
<fieldset>
<legend>Авторизация</legend>
<div class="segmented">
<button type="button" class:active={method === 'phone'} on:click={() => (method = 'phone')}>
По номеру
</button>
<button type="button" class:active={method === 'session'} on:click={() => (method = 'session')}>
StringSession
</button>
</div>
{#if method === 'phone'}
<label>
Номер телефона
<input bind:value={draft.phone} type="tel" placeholder="+421 900 123 456" required />
<small>Код придёт в Telegram. При включённой 2FA потребуется пароль.</small>
</label>
{:else}
<label>
StringSession
<textarea
bind:value={draft.session_string}
rows="3"
placeholder="Вставьте Pyrogram/Pyrofork StringSession"
required
></textarea>
</label>
{/if}
</fieldset>
<fieldset>
<legend>Ресурсы</legend>
<div class="three-columns">
<label>
Хранилище
<select bind:value={draft.resources.storage}>
<option value="1Gi">1 GiB</option>
<option value="2Gi">2 GiB</option>
<option value="5Gi">5 GiB</option>
</select>
</label>
<label>
CPU
<select bind:value={draft.resources.cpu_limit}>
<option value="200m">200m</option>
<option value="300m">300m</option>
<option value="500m">500m</option>
</select>
</label>
<label>
RAM
<select bind:value={draft.resources.memory_limit}>
<option value="1024Mi">1 GiB</option>
<option value="1536Mi">1.5 GiB</option>
<option value="2048Mi">2 GiB</option>
</select>
</label>
</div>
</fieldset>
{#if error}<p class="form-error" role="alert">{error}</p>{/if}
<footer class="form-actions">
<button type="button" on:click={close}>Отмена</button>
<button class="primary" type="submit" disabled={busy}>
{#if busy}<LoaderCircle class="spin" size={16} />{/if}
{method === 'phone' ? 'Запросить код' : 'Создать инстанс'}
</button>
</footer>
</form>
{:else if step === 2}
<div class="auth-step">
<button class="back-link" on:click={() => (step = 1)}>
<ArrowLeft size={15} />Назад к настройкам
</button>
{#if authPhase === 'code'}
<h3>Введите код</h3>
<p>Код отправлен в Telegram на {draft.phone}.</p>
<form on:submit|preventDefault={submitCode}>
<label>
Код Telegram
<input
class="code-input"
bind:value={code}
inputmode="numeric"
autocomplete="one-time-code"
placeholder="12345"
/>
</label>
{#if error}<p class="form-error" role="alert">{error}</p>{/if}
<button class="primary full" type="submit" disabled={busy || !code.trim()}>
{#if busy}<LoaderCircle class="spin" size={16} />{/if}
Авторизовать
</button>
</form>
{:else}
<h3>Пароль 2FA</h3>
<p>Для этого аккаунта включена двухэтапная аутентификация.</p>
<form on:submit|preventDefault={submitPassword}>
<label>
Пароль
<span class="input-with-action">
<input
bind:value={password}
type={showPassword ? 'text' : 'password'}
autocomplete="current-password"
/>
<button
type="button"
aria-label={showPassword ? 'Скрыть пароль' : 'Показать пароль'}
on:click={() => (showPassword = !showPassword)}
>
{#if showPassword}<EyeOff size={15} />{:else}<Eye size={15} />{/if}
</button>
</span>
</label>
{#if error}<p class="form-error" role="alert">{error}</p>{/if}
<button class="primary full" type="submit" disabled={busy || !password}>
{#if busy}<LoaderCircle class="spin" size={16} />{/if}
Продолжить
</button>
</form>
{/if}
</div>
{:else}
<div class="creating-state">
<LoaderCircle class="spin" size={28} />
<h3>Создаём инстанс</h3>
<p>Secret, PVC и Deployment созданы. Ждём Ready в namespace userbot.</p>
{#if error}<p class="form-error" role="alert">{error}</p>{/if}
</div>
{/if}
</aside>
{/if}
@@ -0,0 +1,204 @@
<script lang="ts">
import {
Copy,
FileTerminal,
Play,
RotateCw,
Square,
Trash2,
X,
} from 'lucide-svelte';
import { createEventDispatcher } from 'svelte';
import { api } from '../lib/api';
import type { Instance } from '../lib/types';
import StatusDot from './StatusDot.svelte';
export let instance: Instance | null = null;
export let busy = false;
let logs = '';
let logsLoading = false;
let logsOpen = false;
let deleteOpen = false;
let deleteData = false;
let confirmation = '';
let activeInstance = '';
const dispatch = createEventDispatcher<{
close: void;
action: { instance: Instance; action: 'start' | 'stop' | 'restart' };
delete: { instance: Instance; deleteData: boolean };
}>();
$: instanceKey = instance ? `${instance.namespace}/${instance.instance_id}` : '';
$: if (instanceKey !== activeInstance) {
activeInstance = instanceKey;
logs = '';
logsOpen = false;
deleteOpen = false;
deleteData = false;
confirmation = '';
}
async function loadLogs() {
if (!instance) return;
logsOpen = true;
logsLoading = true;
try {
logs = (await api.logs(instance.instance_id)).logs;
} catch (error) {
logs = error instanceof Error ? error.message : 'Не удалось загрузить логи';
} finally {
logsLoading = false;
}
}
function copy(value: string | null) {
if (value) navigator.clipboard.writeText(value);
}
</script>
{#if instance}
<div class="drawer-backdrop" on:click={() => dispatch('close')} aria-hidden="true"></div>
<aside class="drawer details-drawer" aria-label={`Инстанс ${instance.display_name}`}>
<header class="drawer-header">
<div>
<h2>{instance.display_name}</h2>
<StatusDot status={instance.status} />
</div>
<button class="icon-button borderless" aria-label="Закрыть" on:click={() => dispatch('close')}>
<X size={19} />
</button>
</header>
<div class="drawer-scroll">
<section>
<h3>Ресурсы</h3>
<div class="resource-grid">
<div class="resource">
<span>CPU</span>
<strong>{instance.cpu_usage || '—'} <small>/ {instance.cpu_limit || '—'}</small></strong>
</div>
<div class="resource">
<span>RAM</span>
<strong>{instance.memory_usage || '—'} <small>/ {instance.memory_limit || '—'}</small></strong>
</div>
<div class="resource">
<span>Рестарты</span>
<strong>{instance.restarts}</strong>
</div>
<div class="resource">
<span>Хранилище</span>
<strong>{instance.storage || '—'}</strong>
</div>
</div>
</section>
<section>
<h3>Kubernetes</h3>
<dl class="metadata-list">
<div>
<dt>Namespace</dt>
<dd>{instance.namespace}</dd>
</div>
<div>
<dt>Deployment</dt>
<dd>
<span>{instance.deployment}</span>
<button class="copy-button" aria-label="Копировать Deployment" on:click={() => copy(instance?.deployment)}>
<Copy size={14} />
</button>
</dd>
</div>
<div>
<dt>Pod</dt>
<dd>
<span>{instance.pod || 'Нет pod'}</span>
{#if instance.pod}
<button class="copy-button" aria-label="Копировать Pod" on:click={() => copy(instance?.pod)}>
<Copy size={14} />
</button>
{/if}
</dd>
</div>
<div>
<dt>PVC</dt>
<dd>{instance.pvc || '—'}</dd>
</div>
<div>
<dt>Image</dt>
<dd class="wrap">{instance.image}</dd>
</div>
</dl>
</section>
{#if logsOpen}
<section class="logs-section">
<div class="section-title">
<h3>Логи</h3>
<button class="text-button" on:click={loadLogs}>Обновить</button>
</div>
<pre>{logsLoading ? 'Загрузка…' : logs}</pre>
</section>
{/if}
{#if deleteOpen}
<section class="danger-zone">
<h3>Удалить инстанс</h3>
<p>Deployment и credentials Secret будут удалены. PVC по умолчанию останется.</p>
<label>
Введите <strong>{instance.instance_id}</strong>
<input bind:value={confirmation} autocomplete="off" />
</label>
<label class="checkbox-row">
<input type="checkbox" bind:checked={deleteData} />
Удалить PVC и все данные
</label>
<button
class="danger-button"
disabled={confirmation !== instance.instance_id || busy}
on:click={() => dispatch('delete', { instance, deleteData })}
>
Подтвердить удаление
</button>
</section>
{/if}
</div>
<footer class="drawer-actions">
<button on:click={loadLogs}><FileTerminal size={16} />Логи</button>
<button
disabled={busy || instance.status === 'stopped'}
on:click={() => dispatch('action', { instance, action: 'restart' })}
>
<RotateCw size={16} />Перезапустить
</button>
{#if instance.status === 'stopped'}
<button
class="primary"
disabled={busy}
on:click={() => dispatch('action', { instance, action: 'start' })}
>
<Play size={16} />Запустить
</button>
{:else}
<button
class="primary"
disabled={busy}
on:click={() => dispatch('action', { instance, action: 'stop' })}
>
<Square size={15} />Остановить
</button>
{/if}
<button
class="delete-trigger"
disabled={!instance.deletable}
title={instance.deletable ? 'Удалить инстанс' : 'Legacy-инстансы удаляются из Git'}
on:click={() => (deleteOpen = !deleteOpen)}
>
<Trash2 size={16} />Удалить
</button>
</footer>
</aside>
{/if}
@@ -0,0 +1,114 @@
<script lang="ts">
import { FileTerminal, MoreVertical, Play, RotateCw, Square } from 'lucide-svelte';
import { createEventDispatcher } from 'svelte';
import { relativeTime } from '../lib/instances';
import type { Instance } from '../lib/types';
import StatusDot from './StatusDot.svelte';
export let instances: Instance[] = [];
export let busyId = '';
const dispatch = createEventDispatcher<{
select: Instance;
action: { instance: Instance; action: 'start' | 'stop' | 'restart' | 'logs' };
}>();
function action(
event: MouseEvent,
instance: Instance,
name: 'start' | 'stop' | 'restart' | 'logs',
) {
event.stopPropagation();
dispatch('action', { instance, action: name });
}
</script>
<div class="table-frame">
<div class="table-head table-grid" aria-hidden="true">
<span>ИНСТАНС</span>
<span>СТАТУС</span>
<span>POD</span>
<span>РЕСТАРТЫ</span>
<span>ХРАНИЛИЩЕ</span>
<span>ОБНОВЛЁН</span>
<span class="right">ДЕЙСТВИЯ</span>
</div>
{#each instances as instance (instance.namespace + instance.instance_id)}
<div
class="instance-row table-grid"
class:error-row={instance.status === 'error'}
on:click={() => dispatch('select', instance)}
on:keydown={(event) => {
if (event.key === 'Enter' || event.key === ' ') dispatch('select', instance);
}}
role="button"
tabindex="0"
aria-label={`Открыть ${instance.display_name}`}
>
<span class="instance-name">
<strong>{instance.display_name}</strong>
<small>{instance.instance_id} · {instance.namespace}</small>
</span>
<StatusDot status={instance.status} />
<span class="pod-name" title={instance.pod || instance.reason || ''}>
{instance.pod || instance.reason || 'Нет pod'}
</span>
<span>{instance.restarts}</span>
<span>{instance.storage || '—'}</span>
<span>{relativeTime(instance.updated_at)}</span>
<span class="row-actions">
<button
class="icon-button"
title="Логи"
aria-label={`Логи ${instance.display_name}`}
on:click={(event) => action(event, instance, 'logs')}
>
<FileTerminal size={16} />
</button>
<button
class="icon-button"
title="Перезапустить"
aria-label={`Перезапустить ${instance.display_name}`}
disabled={busyId === instance.instance_id || instance.status === 'stopped'}
on:click={(event) => action(event, instance, 'restart')}
>
<RotateCw size={16} />
</button>
{#if instance.status === 'stopped'}
<button
class="icon-button"
title="Запустить"
aria-label={`Запустить ${instance.display_name}`}
disabled={busyId === instance.instance_id}
on:click={(event) => action(event, instance, 'start')}
>
<Play size={16} />
</button>
{:else}
<button
class="icon-button"
title="Остановить"
aria-label={`Остановить ${instance.display_name}`}
disabled={busyId === instance.instance_id}
on:click={(event) => action(event, instance, 'stop')}
>
<Square size={15} />
</button>
{/if}
<button
class="icon-button"
title="Подробнее"
aria-label={`Подробнее ${instance.display_name}`}
on:click={(event) => {
event.stopPropagation();
dispatch('select', instance);
}}
>
<MoreVertical size={16} />
</button>
</span>
</div>
{/each}
</div>
@@ -0,0 +1,39 @@
<script lang="ts">
import { Bot, Box, Plus, X } from 'lucide-svelte';
import { createEventDispatcher } from 'svelte';
export let open = false;
const dispatch = createEventDispatcher<{ add: void; close: void }>();
</script>
<aside class:open aria-label="Навигация">
<div class="brand">
<Bot size={31} strokeWidth={1.8} />
<div>
<strong>USERBOT</strong>
<span>Kubernetes Control</span>
</div>
<button class="mobile-close" aria-label="Закрыть меню" on:click={() => dispatch('close')}>
<X size={18} />
</button>
</div>
<nav>
<button class="nav-item active" on:click={() => dispatch('close')}>
<Box size={18} />
Инстансы
</button>
<button class="nav-item" on:click={() => dispatch('add')}>
<Plus size={19} />
Добавить аккаунт
</button>
</nav>
<div class="cluster-state">
<span class="cluster-dot"></span>
<div>
<strong>Кластер доступен</strong>
<span>namespace: userbot</span>
</div>
</div>
</aside>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { InstanceStatus } from '../lib/types';
export let status: InstanceStatus;
const labels: Record<InstanceStatus, string> = {
running: 'Работает',
stopped: 'Остановлен',
pending: 'Запускается',
error: 'Ошибка',
};
</script>
<span class="status status-{status}">
<span class="status-dot" aria-hidden="true"></span>
{labels[status]}
</span>
+83
View File
@@ -0,0 +1,83 @@
import type { AccountDraft, AuthResult, Instance } from "./types";
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, init);
if (!response.ok) {
let message = response.statusText || "Request failed";
try {
const payload = await response.json();
if (payload?.detail) message = typeof payload.detail === "string" ? payload.detail : JSON.stringify(payload.detail);
} catch {
const text = await response.text().catch(() => "");
if (text) message = text;
}
throw new Error(message);
}
if (response.status === 204) return undefined as T;
return response.json();
}
const json = (body: unknown): RequestInit => ({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
export const api = {
instances(query = "", status = "") {
const params = new URLSearchParams();
if (query) params.set("query", query);
if (status) params.set("status", status);
const suffix = params.size ? `?${params}` : "";
return request<Instance[]>(`/api/instances${suffix}`);
},
instance(id: string) {
return request<Instance>(`/api/instances/${encodeURIComponent(id)}`);
},
logs(id: string) {
return request<{ logs: string }>(`/api/instances/${encodeURIComponent(id)}/logs`);
},
action(id: string, action: "start" | "stop" | "restart") {
return request<Instance>(`/api/instances/${encodeURIComponent(id)}/${action}`, { method: "POST" });
},
delete(id: string, deleteData: boolean) {
return request<void>(
`/api/instances/${encodeURIComponent(id)}/delete`,
json({ confirmation: id, delete_data: deleteData }),
);
},
startPhone(draft: AccountDraft) {
return request<AuthResult>(
"/api/auth/phone/start",
json({
...basePayload(draft),
phone: draft.phone,
}),
);
},
submitCode(flowId: string, code: string) {
return request<AuthResult>(`/api/auth/phone/${encodeURIComponent(flowId)}/code`, json({ code }));
},
submitPassword(flowId: string, password: string) {
return request<AuthResult>(`/api/auth/phone/${encodeURIComponent(flowId)}/password`, json({ password }));
},
stringSession(draft: AccountDraft) {
return request<AuthResult>(
"/api/auth/string-session",
json({
...basePayload(draft),
session_string: draft.session_string,
}),
);
},
};
function basePayload(draft: AccountDraft) {
return {
instance_id: draft.instance_id,
display_name: draft.display_name,
api_id: Number(draft.api_id),
api_hash: draft.api_hash,
resources: draft.resources,
};
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { filterInstances, relativeTime } from "./instances";
import type { Instance } from "./types";
const instance = (overrides: Partial<Instance>): Instance => ({
instance_id: "forust",
display_name: "Forust",
namespace: "userbot",
deployment: "userbot-forust",
pod: "userbot-forust-abc",
status: "running",
reason: null,
ready: true,
restarts: 0,
pvc: "userbot-forust-data",
storage: "1Gi",
image: "userbot:latest",
cpu_limit: "300m",
memory_limit: "1536Mi",
cpu_usage: null,
memory_usage: null,
updated_at: null,
legacy: false,
deletable: true,
...overrides,
});
describe("filterInstances", () => {
it("filters by status and searchable fields", () => {
const items = [
instance({ instance_id: "forust" }),
instance({ instance_id: "anna", display_name: "Anna", status: "stopped" }),
];
expect(filterInstances(items, "ann", "stopped").map((item) => item.instance_id)).toEqual(["anna"]);
});
});
describe("relativeTime", () => {
it("formats recent timestamps", () => {
expect(relativeTime("2026-01-01T00:00:00Z", Date.parse("2026-01-01T00:00:45Z"))).toBe("45 сек назад");
});
});
@@ -0,0 +1,24 @@
import type { Instance } from "./types";
export function filterInstances(instances: Instance[], query: string, status: string): Instance[] {
const normalized = query.trim().toLowerCase();
return instances.filter((instance) => {
const matchesStatus = !status || instance.status === status;
const matchesQuery =
!normalized ||
instance.instance_id.toLowerCase().includes(normalized) ||
instance.display_name.toLowerCase().includes(normalized) ||
(instance.pod || "").toLowerCase().includes(normalized);
return matchesStatus && matchesQuery;
});
}
export function relativeTime(value: string | null, now = Date.now()): string {
if (!value) return "—";
const seconds = Math.max(0, Math.round((now - new Date(value).getTime()) / 1000));
if (seconds < 60) return `${seconds} сек назад`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} мин назад`;
const hours = Math.floor(minutes / 60);
return `${hours} ч назад`;
}
+45
View File
@@ -0,0 +1,45 @@
export type InstanceStatus = "running" | "stopped" | "pending" | "error";
export interface Instance {
instance_id: string;
display_name: string;
namespace: string;
deployment: string;
pod: string | null;
status: InstanceStatus;
reason: string | null;
ready: boolean;
restarts: number;
pvc: string | null;
storage: string | null;
image: string;
cpu_limit: string | null;
memory_limit: string | null;
cpu_usage: string | null;
memory_usage: string | null;
updated_at: string | null;
legacy: boolean;
deletable: boolean;
}
export interface Resources {
storage: string;
cpu_limit: string;
memory_limit: string;
}
export interface AccountDraft {
instance_id: string;
display_name: string;
api_id: string;
api_hash: string;
phone: string;
session_string: string;
resources: Resources;
}
export interface AuthResult {
status: "code_required" | "password_required" | "ready";
flow_id?: string;
instance?: Instance;
}
+8
View File
@@ -0,0 +1,8 @@
import { mount } from "svelte";
import App from "./App.svelte";
import "./styles.css";
mount(App, {
target: document.getElementById("app")!,
});
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";
+5
View File
@@ -0,0 +1,5 @@
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
export default {
preprocess: vitePreprocess(),
};
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"target": "ES2022",
"module": "ESNext",
"types": ["svelte", "vite/client", "vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
import { svelte } from "@sveltejs/vite-plugin-svelte";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [svelte()],
build: {
outDir: "dist",
emptyOutDir: true,
},
server: {
proxy: {
"/api": "http://127.0.0.1:8080",
},
},
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
},
});
View File
+4
View File
@@ -4,6 +4,8 @@ metadata:
name: vaultwarden-prod name: vaultwarden-prod
namespace: vaultwarden namespace: vaultwarden
spec: spec:
entryPoints:
- websecure
routes: routes:
- match: Host(`vw.forust.xyz`) - match: Host(`vw.forust.xyz`)
kind: Rule kind: Rule
@@ -19,6 +21,8 @@ metadata:
name: vaultwarden-local name: vaultwarden-local
namespace: vaultwarden namespace: vaultwarden
spec: spec:
entryPoints:
- websecure
routes: routes:
- match: Host(`vw.workstation.internal`) || Host(`vw.gigaforust.internal`) - match: Host(`vw.workstation.internal`) || Host(`vw.gigaforust.internal`)
kind: Rule kind: Rule