Compare commits

..
Author SHA1 Message Date
renovate-bot f0e0fba125 chore(deps): update container patch updates
renovate-ci / validate-renovate (push) Skipped
ci / lint-compose (push) Successful in 7s
ci / lint-shellcheck (push) Successful in 8s
ci / lint-actionlint (push) Successful in 6s
ci / lint-prettier (push) Successful in 2m38s
ci / lint-ruff (push) Successful in 3s
ci / lint-yaml (push) Successful in 3s
ci / lint-dockerfiles (push) Successful in 5s
ci / scan-deps (push) Successful in 17s
ci / test-backend (push) Successful in 8s
ci / test-frontend (push) Successful in 13s
ci / validate (push) Successful in 6s
ci / build (push) Skipped
ci / lint-compose (pull_request) Successful in 3s
ci / lint-actionlint (pull_request) Successful in 2s
ci / lint-shellcheck (pull_request) Successful in 2s
ci / lint-prettier (pull_request) Successful in 3s
ci / lint-ruff (pull_request) Successful in 2s
ci / lint-yaml (pull_request) Successful in 3s
ci / lint-dockerfiles (pull_request) Successful in 2s
ci / scan-deps (pull_request) Successful in 14s
ci / test-backend (pull_request) Successful in 7s
ci / test-frontend (pull_request) Successful in 12s
ci / validate (pull_request) Successful in 4s
ci / build (pull_request) Skipped
renovate-ci / validate-renovate (pull_request) Successful in 5m2s
2026-09-27 22:22:41 +00:00
349 changed files with 25901 additions and 4077 deletions

No files matched your search

-88
View File
@@ -1,88 +0,0 @@
# Build and deployment workflows
Gitea Actions checks this repository, builds its custom images, and deploys
selected services to the workstation. Workflows use the self-hosted runner labels
`linux`, `arch`, and `homelab`; deployment jobs also require `prod`.
## Checks
`ci.yaml` runs Compose validation, actionlint, ShellCheck, Prettier, Ruff,
yamllint, hadolint, and kubeconform. Tool versions are pinned in
`workflows/tool-versions.env` and installed by `install-ci-tools.sh`.
Compose CI checks structure without resolving local environment files or paths.
On the reviewed main commit it only discovers standard filenames; the
`fix/deploy-validation` branch adds the manual Compose entry points too.
Kubeconform validates known resource schemas. Unknown CRDs are skipped. On main,
CI also attempts server-side dry-runs for marked services; these require an
existing namespace and contact the cluster's admission webhooks. A cluster that
is unreachable produces a warning and skips that CI pass. Deploy validation has
its own dry-run stage.
`renovate-ci.yaml` validates Renovate settings and checks that its generated
ConfigMap matches `renovate/renovate.json`.
## Image builds
CI builds changed custom images for `errorpages`, both `homepages` variants, and
the two `edu_master` Python services. Main builds publish `main`, `prod`, and a
commit tag. Dev builds publish `dev`. Build jobs wait for the lint and manifest
checks.
Kubernetes deployment resolves the lab's own registry images to digests, preferring
commit-specific tags. Third-party image versions remain declared in the manifests.
## Deploy selection
`workflows/deploy-lib.sh` owns the stage logic; `ssh-run.sh` invokes it on the
workstation through SSH. Kubernetes selection uses `k8s/active`; Compose selection
uses an `active` file beside a standard `compose.yaml` or `compose.yml`.
Kustomize overlays are supported, although the current tree primarily contains
plain manifests.
Secret files, examples, Helm values, and patch files are excluded from plain
manifest selection. Create local Kubernetes Secrets separately in their target
namespaces. The Helm table lists Prometheus, Loki, Alloy, and Reloader, with each
release controlled by its configured marker. Other charts need separate setup.
## Trigger and required settings
Automatic deployment follows a successful main CI run when the repository Actions
variable `AUTODEPLOY` is `true`. The manual deploy workflow bypasses that switch
and targets the fetched main branch when no validated commit SHA is provided.
A manual dispatch does not prove that this commit passed CI.
Configure the Actions secrets `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY`, and,
where needed, `DEPLOY_PORT` and `DEPLOY_PATH`. Registry publishing uses
`REGISTRY_USERNAME` and `REGISTRY_PASSWORD`. The remote user needs access to Git,
Docker, kubectl, Helm, jq, and the state directory used for snapshots.
Keep `APPLY_PRUNE` false on the reviewed implementation: its per-file prune loop
is unsafe. `fix/deploy-prune-guard` rejects that option before changes are applied.
Preflight fetches and resets the remote checkout. It refuses when tracked files
have local changes; ignored local env and Secret files stay in place. Do not use
a development checkout with uncommitted tracked changes as the deployment target.
## Stages and recovery
1. Preflight fetches the target commit and checks the remote working tree.
2. Validate selects services, parses Compose, performs Kubernetes dry-runs, and
checks referenced Secrets.
3. Apply Kubernetes records a workload snapshot, upgrades selected Helm releases,
applies resources, and refreshes owned custom images.
4. Apply Compose recreates marked stacks and checks container state.
5. Verify Kubernetes checks changed workloads and attempts rollback for failures.
6. Smoke probes public routes after verification.
The two apply jobs share a remote lock. Workflow concurrency queues deployments
rather than interrupting an older apply. Snapshots live under
`$XDG_STATE_HOME/homelab-deploy`, or `~/.local/state/homelab-deploy` by default.
They contain the pre-apply workload data and commit identifier.
Rollback uses workload revisions. It does not restore ConfigMaps, Secrets,
database schemas, or data. Helm-owned workloads are handled through the Helm
upgrade's rollback path; the generic rollback skips them. Compose has no automatic
rollback. See the [review](../docs/repository-review.md) for remaining recovery
limitations, including SSH retries and serial rollback timing.
+248 -99
View File
@@ -90,8 +90,11 @@ jobs:
set -euo pipefail
tools_dir="$(bash .gitea/workflows/install-ci-tools.sh shellcheck)"
export PATH="$tools_dir:$PATH"
# userbot/ is a git subtree synced from forust/userbot, so its shell
# scripts are upstream's to maintain, not ours. Linting them would let a
# routine subtree pull turn the deploy gate red on code we do not own.
mapfile -t scripts < <(
git ls-files '*.sh' ':(glob)**/*.bash'
git ls-files '*.sh' ':(glob)**/*.bash' ':!userbot/**'
)
if [ "${#scripts[@]}" -eq 0 ]; then
echo "No shell scripts found."
@@ -194,6 +197,155 @@ jobs:
hadolint -c .hadolint.yaml "${dockerfiles[@]}"
# Known, accepted, and recorded. Each line is a real advisory against a
# package we build into the panel image, kept in this workflow rather than in
# the package manifest so that a subtree sync from forust/userbot cannot
# silently widen the exemption.
#
# starlette is the reason this job is not simply "fail on everything":
# fastapi 0.115.12 pins `starlette<0.47.0`, and the fixes for the last four
# below need 0.49.1 through 1.3.1, so clearing them means a jump from fastapi
# 0.115.12 to 0.141.x. That is upstream's call, not a drive-by in a lint
# commit. Of the seven, four are reachable here in principle: 1942 is a
# crafted Range header hitting FileResponse, and the panel serves its built
# SPA through exactly that; 249 is request.form() ignoring max_fields for
# x-www-form-urlencoded, which is the login form; 1941 is a large multipart
# body blocking the event loop; 161 and 248 are unvalidated Host and request
# path reaching request.url. 2280 needs HTTPEndpoint, which the panel does
# not use, and 2281 is Windows-only, and this deploys on Linux.
#
# The panel answers on userbot.workstation.internal and has no public
# forust.xyz route, which is what keeps the four reachable ones from being
# an internet-facing DoS. It still manages Telegram credentials.
#
# Deleting an entry here is how you accept a new advisory, so the diff says
# so out loud.
scan-deps:
runs-on: [self-hosted, linux, arch, homelab]
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Audit the Python dependencies that ship in the image
shell: bash
run: |
set -euo pipefail
tools_dir="$(bash .gitea/workflows/install-ci-tools.sh pip-audit)"
export PATH="$tools_dir:$PATH"
# requirements.txt, not requirements-dev.txt: this is what the image
# installs, and the test tooling is not a shipped attack surface.
pip-audit -r userbot/panel/backend/requirements.txt --strict \
--ignore-vuln CVE-2025-67720 \
--ignore-vuln PYSEC-2026-161 \
--ignore-vuln PYSEC-2026-1941 \
--ignore-vuln PYSEC-2026-1942 \
--ignore-vuln PYSEC-2026-2280 \
--ignore-vuln PYSEC-2026-2281 \
--ignore-vuln PYSEC-2026-248 \
--ignore-vuln PYSEC-2026-249
# devDependencies are excluded on purpose. `npm audit` on the full tree
# reports 7 findings, and every one of them is a build- or test-time
# package: the esbuild CORS advisory needs a vite dev server serving to
# the internet, and nanoid's infinite loop needs a custom generator
# called with size 0, which postcss does not do. None of them are in the
# 91 kB bundle the panel serves. The one production finding, devalue
# via svelte, is moderate, which is where --audit-level draws the line;
# this fails on the next high or critical one.
- name: Audit the production npm dependencies
shell: bash
run: |
set -euo pipefail
# The pinned node, not whatever the runner has. Its system node is a
# rolling Arch package: during this very push its npm was missing
# entirely, and an hour later it was npm 12 on node 26. Both are the
# wrong major anyway — the panel image is node:22-alpine.
tools_dir="$(bash .gitea/workflows/install-ci-tools.sh node)"
export PATH="$tools_dir:$PATH"
cd userbot/panel/frontend
npm ci
npm audit --omit=dev --audit-level=high
test-backend:
runs-on: [self-hosted, linux, arch, homelab]
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
# 25 tests over the panel's pydantic models, its auth flow, the SPA
# fallback and the Kubernetes client it shells out with. They existed and
# had never been executed by anything.
#
# Note that userbot/ is a subtree synced from forust/userbot, so a routine
# sync can turn this red on upstream's code. Unlike the shellcheck job,
# which skips that tree because style disagreements there are ours to
# lose, a failing test here is a real defect in a service we deploy.
- name: Run the panel backend test suite
shell: bash
run: |
set -euo pipefail
tools_dir="$(bash .gitea/workflows/install-ci-tools.sh uv)"
export PATH="$tools_dir:$PATH"
# A venv in a temp dir rather than a checked-out one: the runner is
# shared, and a leftover .venv would let a dependency the
# requirements no longer pin still satisfy an import.
#
# --python is not optional. uv otherwise takes whatever interpreter it
# finds first, and which one that is depends on the machine: this
# runner runs jobs on the host, where the only interpreter is 3.14,
# and pyrogram's sync.py calls the bare asyncio.get_event_loop() that
# 3.14 no longer auto-creates, so three tests fail at collection. The
# image is python:3.13-slim, so 3.13 is also the version worth
# testing: uv fetches a managed build of it when the host has none,
# which is what makes this job independent of the runner.
venv="$(mktemp -d)/venv"
uv venv --python 3.13 --quiet "$venv"
uv pip install --quiet --python "$venv/bin/python" \
-r userbot/panel/backend/requirements-dev.txt
# `python -m`, not bare `pytest`: the tests import `app.*` relative to
# the backend directory, which only works if the cwd is on sys.path,
# and only `python -m` puts it there.
cd userbot/panel/backend
"$venv/bin/python" -m pytest tests/ -q
test-frontend:
runs-on: [self-hosted, linux, arch, homelab]
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
# One `npm ci` for both checks below: it is by far the slowest part of
# this job, and a second one would learn nothing the first did not.
#
# `npm ci`, not `npm install`, for the same reason the Dockerfile uses it:
# the lockfile is what makes the tree that gets checked the tree that
# gets shipped.
- name: Type-check and test the panel frontend
shell: bash
run: |
set -euo pipefail
# The pinned node, not whatever the runner has. Its system node is a
# rolling Arch package: during this very push its npm was missing
# entirely, and an hour later it was npm 12 on node 26. Both are the
# wrong major anyway — the panel image is node:22-alpine.
tools_dir="$(bash .gitea/workflows/install-ci-tools.sh node)"
export PATH="$tools_dir:$PATH"
cd userbot/panel/frontend
npm ci
# svelte-check has been a devDependency all along with no script
# pointing at it, so the type errors it reports had nowhere to
# surface. It is clean today, which is the only reason it can be a
# gate: it stops at whatever upstream introduces rather than
# reporting a backlog we inherited.
npm run check
npm test
validate:
runs-on: [self-hosted, linux, arch, homelab]
timeout-minutes: 20
@@ -312,12 +464,8 @@ jobs:
build:
needs:
# The panel's scan-deps/test-backend/test-frontend jobs gated here until
# userbot moved to its own repo; upstream's code is upstream's gate now.
# The rule is unchanged: publishing and passing the checks are the same
# gate, so a commit that fails any of these still cannot move :prod.
[lint-actionlint, lint-shellcheck, lint-compose, lint-prettier, lint-ruff, lint-yaml, lint-dockerfiles, validate]
if: github.event_name != 'pull_request' && (github.ref_name == 'main' || github.ref_name == 'dev') && !startsWith(github.ref_name, 'renovate/')
if: github.event_name != 'pull_request' && (github.ref_name == 'main' || github.ref_name == 'dev')
runs-on: [self-hosted, linux, arch, homelab]
timeout-minutes: 60
outputs:
@@ -332,20 +480,12 @@ jobs:
id: services
shell: bash
run: |
set -euo pipefail
base="${{ github.event.before }}"
if [ -z "$base" ] || [ "$base" = "0000000000000000000000000000000000000000" ]; then
base="$(git rev-list --max-parents=0 HEAD)"
fi
# A failed diff used to leave changed_files empty, which reads exactly
# like "nothing to build": the job went green having built nothing and
# the tag never moved. The status is checked, not assumed.
if ! changed="$(git diff --name-only "$base" "${GITHUB_SHA}")"; then
echo "::error::cannot diff ${base}..${GITHUB_SHA}"
exit 1
fi
mapfile -t changed_files <<<"$changed"
mapfile -t changed_files < <(git diff --name-only "$base" "${GITHUB_SHA}")
services=()
@@ -365,9 +505,15 @@ jobs:
for file in "${changed_files[@]}"; do
case "$file" in
dtek_notif/*)
add_service dtek_notif
;;
errorpages/*)
add_service errorpages
;;
userbot/*)
add_service userbot
;;
homepages/*)
add_service homepages
;;
@@ -387,63 +533,55 @@ jobs:
echo "services=$(paste -sd, /tmp/services.txt)" >> "$GITHUB_OUTPUT"
- name: Log in to registry
# The pin step below also writes (manifest PUTs), and it runs on every
# main push — including manifest-only ones where services is empty. A
# stale persistent login on the old runner used to mask this; a clean
# runner pushes anonymously and gets 401.
if: steps.services.outputs.services != '' || github.ref_name == 'main'
if: steps.services.outputs.services != ''
shell: bash
# Through env, not by substitution into the script. A secret written
# into a run: block is pasted into the shell source before bash parses
# it, so a password containing a quote, a backtick or $(...) becomes
# code that runs. Masking the value in the log does not prevent that.
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
set -euo pipefail
printf '%s' "$REGISTRY_PASSWORD" | docker login "${REGISTRY}" \
-u "$REGISTRY_USERNAME" \
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${REGISTRY}" \
-u "${{ secrets.REGISTRY_USERNAME }}" \
--password-stdin
- name: Build and push changed images
if: steps.services.outputs.services != ''
shell: bash
run: |
# This step was the one run: block in the workflow without it, and it
# is the one that cannot afford it: a docker push that failed partway
# through the loop used to be followed by more pushes, the loop's exit
# status came from the last one, and the job went green with half the
# images missing from the registry.
set -euo pipefail
IFS=, read -r -a services <<< "${{ steps.services.outputs.services }}"
# Tags for this push. The commit-pinned name is the point of this
# step: the deploy resolves it in preference to :prod, so a deploy
# that sat in the queue behind a later push still gets the build of
# the commit CI validated, instead of whatever :prod points at by the
# time it runs. See render_pinned in deploy-lib.sh.
commit_tag=""
if [ "${GITHUB_REF_NAME}" = "main" ]; then
commit_tag="sha-${GITHUB_SHA:0:12}"
fi
set_tags() {
tags=()
case "${GITHUB_REF_NAME}" in
main) tags+=("main" "prod") ;;
dev) tags+=("dev") ;;
esac
if [ -n "$commit_tag" ]; then
tags+=("$commit_tag")
fi
}
for service in "${services[@]}"; do
case "$service" in
dtek_notif)
image="${REGISTRY}/forust/dtek-notif"
tags=()
case "${GITHUB_REF_NAME}" in
main)
tags+=("main" "prod")
;;
dev)
tags+=("dev")
;;
esac
build_args=()
for tag in "${tags[@]}"; do
build_args+=(-t "${image}:${tag}")
done
docker build \
--cache-from "type=registry,ref=${image}:buildcache" \
--cache-to "type=registry,ref=${image}:buildcache,mode=max" \
"${build_args[@]}" dtek_notif
for tag in "${tags[@]}"; do
docker push "${image}:${tag}"
done
;;
errorpages)
image="${REGISTRY}/forust/error-pages"
set_tags
tags=()
case "${GITHUB_REF_NAME}" in
main)
tags+=("main" "prod")
;;
dev)
tags+=("dev")
;;
esac
build_args=()
for tag in "${tags[@]}"; do
build_args+=(-t "${image}:${tag}")
@@ -456,6 +594,40 @@ jobs:
docker push "${image}:${tag}"
done
;;
userbot)
tags=()
case "${GITHUB_REF_NAME}" in
main)
tags+=("main" "prod")
;;
dev)
tags+=("dev")
;;
esac
for target in runtime panel; do
case "$target" in
runtime)
context="userbot"
image="${REGISTRY}/forust/userbot"
;;
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 \
--cache-from "type=registry,ref=${image}:buildcache" \
--cache-to "type=registry,ref=${image}:buildcache,mode=max" \
"${build_args[@]}" "$context"
for tag in "${tags[@]}"; do
docker push "${image}:${tag}"
done
done
;;
homepages)
for variant in forust xdfnx; do
case "$variant" in
@@ -466,7 +638,15 @@ jobs:
image="${REGISTRY}/forust/xdfnx-homepage"
;;
esac
set_tags
tags=()
case "${GITHUB_REF_NAME}" in
main)
tags+=("main" "prod")
;;
dev)
tags+=("dev")
;;
esac
build_args=()
for tag in "${tags[@]}"; do
build_args+=(-t "${image}:${tag}")
@@ -492,7 +672,15 @@ jobs:
image="${REGISTRY}/forust/webinar-checker"
;;
esac
set_tags
tags=()
case "${GITHUB_REF_NAME}" in
main)
tags+=("main" "prod")
;;
dev)
tags+=("dev")
;;
esac
build_args=()
for tag in "${tags[@]}"; do
build_args+=(-t "${image}:${tag}")
@@ -508,42 +696,3 @@ jobs:
;;
esac
done
# Every image the tree names has to carry the commit-pinned name, not only
# the ones this push rebuilt. A push that touches nothing but manifests
# builds nothing, and its deploy would then find no commit-pinned tag to
# resolve and quietly fall back to the moving :prod - which is the whole
# failure the commit-pinned name exists to remove.
#
# Re-tagging copies the manifest list and transfers no layers, so pinning
# six images that already exist costs six registry writes.
#
# The list is derived from the tree rather than written out here, so an
# image added to a manifest is covered without a second place to update.
- name: Pin the commit name on the images this push did not rebuild
if: github.ref_name == 'main'
shell: bash
run: |
set -euo pipefail
commit_tag="sha-${GITHUB_SHA:0:12}"
mapfile -t repos < <(
git grep -hoE 'gcr\.forust\.xyz/forust/[A-Za-z0-9._-]+' -- '*.yaml' '*.yml' \
| sort -u
)
if [ "${#repos[@]}" -eq 0 ]; then
echo "No own images referenced by the tree."
exit 0
fi
echo "pinning ${#repos[@]} image(s) to $commit_tag"
for repo in "${repos[@]}"; do
if docker buildx imagetools inspect "$repo:$commit_tag" >/dev/null 2>&1; then
echo " already built by this push: ${repo##*/}"
continue
fi
if ! docker buildx imagetools inspect "$repo:prod" >/dev/null 2>&1; then
echo " WARNING: ${repo##*/} has no :prod to pin and no build produced it"
continue
fi
docker buildx imagetools create --tag "$repo:$commit_tag" "$repo:prod"
echo " pinned ${repo##*/}"
done
+24 -226
View File
@@ -233,73 +233,20 @@ registry_digest() {
# pipefail reports the rightmost non-zero stage, so a ref the registry does not
# have would abort the caller at the assignment instead of yielding an empty
# string. The callers check for empty themselves and report it by name.
#
# Retried with a hard timeout because the registry has a known hang mode (and
# a known blink mode: a single failed lookup aborts the whole apply file in
# render_pinned). A short sleep between attempts lets a restarting registry
# come back instead of failing the deploy on one bad second.
local attempt=0 digest=""
while [ "$attempt" -lt 3 ]; do
digest="$(timeout 25s docker manifest inspect "$1" 2>/dev/null \
docker manifest inspect "$1" 2>/dev/null \
| jq -r --arg arch "$arch" '
.manifests[]?
| select(.platform.os == "linux" and .platform.architecture == $arch)
| .digest
' 2>/dev/null \
| head -1 || true)"
[ -n "$digest" ] && break
attempt=$((attempt + 1))
if [ "$attempt" -lt 3 ]; then
echo "WARNING: registry lookup of $1 failed (attempt $attempt/3), retrying in 5s" >&2
sleep 5
fi
done
printf '%s' "$digest"
}
# The commit this deploy is for: what CI validated, or - on a manual dispatch,
# whatever stage_preflight just checked out.
deploy_commit() {
local c="${DEPLOY_SHA:-}"
[ -n "$c" ] || c="$(git -C "$REPO" rev-parse HEAD 2>/dev/null || true)"
printf '%.12s' "${c:-}"
}
# Resolves one of our image refs to the digest THIS commit's build produced.
#
# A manifest naming `:prod` names a pointer, not a version, and the deploy
# resolves it when the apply runs - which is not when CI ran it. Deploy runs are
# queued rather than cancelled (see deploy.yaml), so two pushes in a row leave
# the first deploy resolving the second push's build: the right manifests with
# the wrong code, and nothing anywhere reports it. ci therefore publishes every
# image it ships under `sha-<commit12>`, a name that cannot move, and that is
# the name resolved here.
#
# The fallback to the plain tag is for an image this pipeline never built. It
# reports itself, because a fallback nobody sees is the failure this removes.
pinned_digest() {
local ref="$1" commit pinned
commit="$(deploy_commit)"
if [ -n "$commit" ]; then
pinned="$(registry_digest "${ref%:*}:sha-$commit")"
if [ -n "$pinned" ]; then
printf '%s' "$pinned"
return 0
fi
fi
pinned="$(registry_digest "$ref")"
if [ -n "$pinned" ]; then
echo "WARNING: ${ref} carries no sha-${commit:-<unknown>} tag; resolved the moving tag instead" >&2
fi
printf '%s' "$pinned"
| head -1 || true
}
# Rewrites our own images to immutable digests on the way into the cluster.
# Reads a manifest stream on stdin, writes the pinned stream to stdout.
#
# A digest is not knowable when a manifest is written, so it is never committed:
# git keeps a readable `:prod` tag and the exact bytes are chosen here, at apply
# time, from the tag ci published for the commit being deployed. That is what
# A digest is not knowable when a manifest is written, so it is resolved here, at
# apply time, and never committed: git keeps a readable `:prod` tag. That is what
# makes rollback mean something. `kubectl rollout undo` restores the previous
# ReplicaSet's pod template verbatim, and a template naming a digest restores the
# exact bytes that were serving before. A template naming a moving tag does not —
@@ -324,7 +271,7 @@ render_pinned() {
while read -r ref; do
[ -n "$ref" ] || continue
digest="$(pinned_digest "$ref")"
digest="$(registry_digest "$ref")"
if [ -z "$digest" ]; then
echo "ERROR: cannot resolve ${ref} in the registry; applying nothing." >&2
echo " The build job has to push that tag before the deploy resolves it." >&2
@@ -392,7 +339,7 @@ restart_stale_images() {
while read -r ns target image; do
[ -n "${target:-}" ] || continue
if [ -z "${digests[$image]:-}" ]; then
digests[$image]="$(pinned_digest "$image")"
digests[$image]="$(registry_digest "$image")"
fi
want="${digests[$image]}"
if [ -z "$want" ]; then
@@ -504,14 +451,6 @@ rollback_workloads() {
local -a recovered=()
while read -r kind ns name; do
[ -n "${kind:-}" ] || continue
# Helm-owned workloads are already rolled back by the release's --rollback-on-failure
# upgrade. `rollout undo` here would step back to the revision Helm just
# escaped (the failed one), so leave them for the operator instead.
if kubectl get "${kind}/${name}" -n "$ns" -o jsonpath='{.metadata.annotations}' 2>/dev/null | grep -q 'meta.helm.sh/release-name'; then
echo " skip (helm-managed, needs manual check): ${kind}/${ns}/${name}"
unrecovered+=("${kind}/${ns}/${name} (helm-managed)")
continue
fi
if kubectl rollout undo "${kind}/${name}" -n "$ns" >/dev/null 2>&1 \
&& kubectl rollout status "${kind}/${name}" -n "$ns" --timeout="${ROLLOUT_TIMEOUT}s" >/dev/null 2>&1; then
echo " rolled back: ${kind}/${ns}/${name}"
@@ -550,66 +489,6 @@ helm_repo_for() {
esac
}
# helm_release_status <release> <namespace>
# Prints the release status in lowercase (deployed, failed, pending-rollback,
# ...) or "not-found" when the release does not exist yet.
helm_release_status() {
local out
if ! out="$(helm status "$1" -n "$2" 2>&1)"; then
echo "not-found"
return 0
fi
awk '/^STATUS:/{print $2}' <<<"$out" | tr '[:upper:]' '[:lower:]'
}
# recover_pending_release <release> <namespace>
# Rolls a release out of a pending-* state left by a failed upgrade with --rollback-on-failure
# whose own rollback never completed. Without this every future upgrade errors
# out until a human runs `helm rollback`. Passes through releases that are not
# pending (deployed, failed, not-found). Returns non-zero when the release is
# still not recoverable, so the pipeline fails loud instead of wedging.
recover_pending_release() {
local release="$1" namespace="$2" status
status="$(helm_release_status "$release" "$namespace")"
case "$status" in
pending-upgrade|pending-rollback|pending-install)
log "Release $release is $status, rolling back to the last deployed revision"
if ! helm rollback "$release" -n "$namespace" --wait --timeout 10m >/dev/null 2>&1; then
echo "WARN: helm rollback of $release did not complete"
return 1
fi
status="$(helm_release_status "$release" "$namespace")"
if [ "$status" != "deployed" ]; then
echo "WARN: $release is $status after rollback"
return 1
fi
;;
esac
return 0
}
# wait_for_calm <stage>
# The deploy itself is heavy enough to melt this single node (helm churn plus
# apply churn drove load past 40, killed netbird/ssh, left helm pending-*).
# Never pile a heavy step onto an already-hot node: wait up to 10 minutes for
# the 1-minute load average to drop below the ceiling, then proceed anyway
# with a warning so a permanently busy node cannot wedge the pipeline forever.
wait_for_calm() {
local load waited=0
while [ "$waited" -lt 600 ]; do
load="$(cut -d' ' -f1 /proc/loadavg | cut -d. -f1)"
if [ "$load" -lt 28 ]; then
return 0
fi
if [ "$((waited % 60))" -eq 0 ]; then
log "$1: load $load, waiting for calm (<28)..."
fi
sleep 15
waited=$((waited + 15))
done
echo "WARN: $1: node still loaded ($load) after 10m, proceeding anyway"
}
upgrade_helm_releases() {
local entry release chart namespace version values marker repo
for entry in ${HELM_RELEASES[@]+"${HELM_RELEASES[@]}"}; do
@@ -630,33 +509,13 @@ upgrade_helm_releases() {
helm repo add "${repo%% *}" "${repo#* }" >/dev/null 2>&1 || true
helm repo update "${repo%% *}" >/dev/null 2>&1 || true
log "Upgrading $release ($chart $version)"
wait_for_calm "helm $release"
# A previous run with --rollback-on-failure whose own rollback never finished leaves the
# release in pending-*, which blocks every future upgrade. Recover first
# so one wedged revision cannot wedge the pipeline forever.
if ! recover_pending_release "$release" "$namespace"; then
echo "ERROR: $release is stuck and automatic rollback did not recover it, run 'helm rollback $release -n $namespace' by hand."
return 1
fi
# --rollback-on-failure (+ --wait) rolls the release back when the upgrade
# times out or the workloads it touches never become ready, so a bad chart
# bump is not left half applied. (--atomic was this combo; deprecated.)
if ! helm upgrade --install "$release" "$chart" \
# --atomic rolls the release back when the upgrade times out or the workloads
# it touches never become ready, so a bad chart bump is not left half applied.
helm upgrade --install "$release" "$chart" \
--namespace "$namespace" \
--version "$version" \
--values "$REPO/$values" \
--wait --rollback-on-failure --cleanup-on-fail --timeout 10m; then
echo "WARN: upgrade of $release failed, checking release state"
# --rollback-on-failure already attempted its own rollback; finish the job when that
# rollback never completed, otherwise the release stays pending-* and
# blocks every future run.
if ! recover_pending_release "$release" "$namespace"; then
echo "ERROR: upgrade of $release failed and the release did not recover, run 'helm rollback $release -n $namespace' by hand."
else
echo "ERROR: upgrade of $release failed (release is back on its previous revision)."
fi
return 1
fi
--atomic --cleanup-on-fail --timeout 10m
done
}
@@ -786,7 +645,6 @@ stage_apply_k8s() {
fi
fi
upgrade_helm_releases
wait_for_calm "apply resources"
if [ "${#other_files[@]}" -gt 0 ]; then
log "Applying resources (${#other_files[@]} files, our images pinned to digests)"
for m in "${other_files[@]}"; do
@@ -803,6 +661,19 @@ stage_apply_k8s() {
exit 1
fi
done
if [ -f "$REPO/userbot/k8s/active" ]; then
log "userbot panel hook"
if kubectl get secret userbot-common-secrets -n userbot >/dev/null 2>&1; then
echo " userbot-common-secrets already present in userbot ns, not touching"
elif kubectl get secret userbot-common-secrets -n default >/dev/null 2>&1; then
echo " bootstrapping userbot-common-secrets into userbot ns"
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 -
else
echo " WARNING: userbot-common-secrets missing in both default and userbot ns; create it manually from the laptop"
fi
fi
restart_stale_images
# No verification here on purpose. This stage may be killed at any point by
@@ -954,32 +825,6 @@ smoke_hosts() {
| sort -u
}
# Traefik's own list of the routes it actually built. The Kubernetes CRs are the
# wrong source for this: when a middleware fails to load, Traefik drops the
# router that referenced it and leaves the CR behind looking perfectly healthy.
#
# api.insecure is already on for the internal `traefik` entrypoint, but the pod
# IP is not routable from the node, so read it through kubectl exec rather than
# standing up a port-forward. HTTP only: the TCP routers match on HostSNI(`*`)
# and the UDP ones carry no rule at all, both selected by entrypoint and port,
# so neither can answer whether a given host has a route.
traefik_http_routes() {
kubectl -n traefik exec deploy/traefik -- \
wget -qO- --timeout=10 http://127.0.0.1:8080/api/http/routers 2>/dev/null \
| jq -c '[.[] | {status, rule: (.rule // "")}]'
}
# The hosts Traefik currently routes to, one per line. Every backticked token of
# an enabled rule counts, which is a superset of the hosts -- PathPrefix values
# land here too, harmlessly -- but it keeps the host syntax in one place instead
# of a matcher per host. The scan keeps the delimiters, so strip them: what
# belongs in a comparison against a hostname is the bare name.
traefik_routed_hosts() {
jq -r '[.[] | select(.status == "enabled") | (.rule // "")
| scan("`[^`]+`") | ltrimstr("`") | rtrimstr("`")]
| unique | .[]' <<<"$1"
}
# stage_verify_k8s watches the rollout, which reports that the pods converged.
# It cannot tell a converged pod from a serving one: a route pointing at the
# wrong port, a Service selector that matches nothing the app listens on, a 500
@@ -991,12 +836,6 @@ traefik_routed_hosts() {
# or a 404 from a path the service does not serve still means the chain is
# intact. Only a transport failure (no DNS, refused, timeout) or a 5xx means
# the service is not serving, and only those fail the run.
#
# Except that a 404 is not evidence on its own. A router Traefik refused to
# build answers with the same 404 and nothing behind it, so a middleware that
# fails to load takes down every route that referenced it while
# this stage reports `ok` for all of them. No status code separates those two
# cases, so ask Traefik which routes it built and fail on the difference.
stage_smoke() {
cd "$REPO"
select_manifests >/dev/null
@@ -1043,52 +882,11 @@ stage_smoke() {
esac
done
# Second gate. The probe above only means something if a router matched the
# host in the first place, so compare the hosts we expect against the hosts
# Traefik reports and fail on the difference.
local routes routed
if ! routes="$(traefik_http_routes)"; then
echo "ERROR: could not read Traefik's router list, refusing to report success"
return 1
fi
routed="$(traefik_routed_hosts "$routes")"
local -a unrouted=()
local tries=3
while :; do
unrouted=()
for h in "${hosts[@]}"; do
grep -qxF "$h" <<<"$routed" || unrouted+=("$h")
done
if [ "${#unrouted[@]}" -eq 0 ]; then
break
fi
# A router mid-rollout is legitimately absent for a moment. A middleware
# that failed to load stays absent, so waiting cannot paper over it.
if [ "$tries" -le 1 ]; then
break
fi
tries=$((tries - 1))
warn "${#unrouted[@]} host(s) have no enabled route yet, re-checking in 10s"
sleep 10
if ! routes="$(traefik_http_routes)"; then
break
fi
routed="$(traefik_routed_hosts "$routes")"
done
if [ "${#unrouted[@]}" -ne 0 ]; then
for h in "${unrouted[@]}"; do
echo " NO ROUTE $h (Traefik has no enabled router for this host)"
done
bad=1
fi
if [ "$bad" -ne 0 ]; then
echo "ERROR: at least one active service is not serving over its public route"
return 1
fi
echo "all ${#hosts[@]} route(s) answered and have a router"
echo "all ${#hosts[@]} route(s) answered"
}
stage_apply_compose() {
+5 -51
View File
@@ -39,15 +39,10 @@ env:
jobs:
preflight:
# Autodeploy defaults to OFF: pushes deploy only when the AUTODEPLOY repo
# variable is set to 'true' (Settings -> Actions -> Variables). A manual
# Run workflow always bypasses the switch: dispatching it is the explicit
# intent to deploy.
if: >-
(vars.AUTODEPLOY == 'true' || github.event_name == 'workflow_dispatch') &&
(github.event_name != 'workflow_run' ||
github.event_name != 'workflow_run' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'main'))
github.event.workflow_run.head_branch == 'main')
runs-on: [self-hosted, linux, arch, homelab, prod]
timeout-minutes: 10
steps:
@@ -78,34 +73,8 @@ jobs:
needs: [validate]
runs-on: [self-hosted, linux, arch, homelab, prod]
# Apply only, no verification, so this is just the work itself: snapshot,
# then sequential `helm upgrade --install --wait --rollback-on-failure --timeout 10m`, then the apply loop.
# Verification has its own job and its own budget.
#
# 45 is roughly four times the measured cost of the stage, which is
# deliberately not raised on a theory:
#
# helm, healthy 3 no-op upgrades ~3-5 min
# helm, one release bad rollback-on-failure spends its 10m, ~10-15 min
# then rolls that one back
# apply loop ~40 manifests, 4 of which ~1 min
# resolve an image digest
# restart_stale_images 7.6s to find 8 workloads, ~0.5 min
# 9.8s to resolve their digests
#
# The helm figure is one release, not three: `set -e` aborts
# upgrade_helm_releases on the first failure, so a broken release costs
# 10m and the other two are never attempted. Multiplying 10m by three
# overstates the worst case by 20 minutes.
#
# The 45 minutes this was last raised to 45 were still not enough, and the
# job logs for those runs no longer exist, so what actually consumed the
# budget is not known - the two measurable candidates above account for
# ~15 of it. The unbounded `docker manifest inspect` against the registry's
# known hang mode is now bounded inside registry_digest (25s timeout, 3
# attempts): a dead registry fails each owned image after ~85s instead of
# hanging the stage, and a blinking one is retried instead of failing the
# whole apply file. Still open: make the stage announce which manifest it
# is working on, so a killed run leaves a diagnosable last line.
# then up to three sequential `helm upgrade --atomic --timeout 10m`, then the
# apply loop. Verification has its own job and its own budget.
timeout-minutes: 45
steps:
- name: Checkout repository
@@ -143,22 +112,7 @@ jobs:
needs.apply-k8s.result != 'skipped' &&
needs.apply-compose.result != 'skipped'
runs-on: [self-hosted, linux, arch, homelab, prod]
# Not raised, because the arithmetic does not close.
#
# 32 workloads are under management and the wave width is 8, so the verify
# itself is 4 waves of ROLLOUT_TIMEOUT (300s) = 20 minutes worst case, when
# every rollout times out rather than converging. That is already 20 of 30.
#
# The other 10 would have to absorb rollback, and rollback_workloads is a
# serial `while read` loop at 300s per failed workload. 10 minutes buys two.
# Any larger number is buying a bigger multiple of an unbounded term rather
# than covering a known cost: 60 minutes buys eight, and 60 minutes is
# therefore not a bound, it is a guess with two digits.
#
# The number becomes derivable the moment rollback uses the same wave width
# as the verify: 32 failures then cost 4 waves = 20 minutes instead of 160,
# and 45 covers verify plus rollback at full width. That change is to the
# recovery path and is not folded into a timeout edit.
# ceil(changed_workloads / 8) waves of ROLLOUT_TIMEOUT each, plus rollback.
timeout-minutes: 30
steps:
- name: Checkout repository
+4 -25
View File
@@ -16,10 +16,6 @@ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TOOLS_DIR="${TOOLS_DIR:-${RUNNER_TEMP:-/tmp}/homelab-tools}"
BIN_DIR="$TOOLS_DIR/bin"
mkdir -p "$BIN_DIR"
# The just-installed tools must resolve inside this script too: callers only
# prepend BIN_DIR to PATH after the script exits, so a bare `uv` below would
# miss the binary install_uv just placed (exit 127 on a clean runner).
export PATH="$BIN_DIR:$PATH"
arch="$(uname -m)"
# Upstream projects disagree on arch spelling: kubeconform and actionlint use
@@ -57,31 +53,14 @@ fetch() {
fi
}
# resolve <command>
# Absolute path to use for invoking a tool: the copy in BIN_DIR when present,
# otherwise the name for PATH lookup. Every version check and every in-script
# invocation goes through this, so a tool missing from both places reads as
# "not installed" instead of dying with 127 under `set -e`.
resolve() {
if [ -x "$BIN_DIR/$1" ]; then
printf '%s' "$BIN_DIR/$1"
else
printf '%s' "$1"
fi
}
# installed_version <command>
# Prints the version of an already-installed tool, or nothing. Each tool spells
# its version flag differently, hence the case.
installed_version() {
local bin out
bin="$(resolve "$1")"
if ! command -v "$bin" >/dev/null 2>&1; then
return 0
fi
local out
case "$1" in
kubeconform) out="$("$bin" -v 2>/dev/null | head -1 || true)" ;;
*) out="$("$bin" --version 2>/dev/null | head -1 || true)" ;;
kubeconform) out="$("$1" -v 2>/dev/null | head -1 || true)" ;;
*) out="$("$1" --version 2>/dev/null | head -1 || true)" ;;
esac
printf '%s' "$out"
}
@@ -151,7 +130,7 @@ install_uv_tool() {
return 0
fi
install_uv
UV_TOOL_BIN_DIR="$BIN_DIR" "$BIN_DIR/uv" tool install --force "$1==$2" >/dev/null
UV_TOOL_BIN_DIR="$BIN_DIR" uv tool install --force "$1==$2" >/dev/null
}
install_ruff() {
+1 -1
View File
@@ -81,7 +81,7 @@ jobs:
docker run --rm \
-v "$PWD/renovate/renovate.json:/opt/renovate/renovate.json:ro" \
-e RENOVATE_PLATFORM=gitea \
-e RENOVATE_ENDPOINT=https://git.forust.xyz/api/v1 \
-e RENOVATE_ENDPOINT=https://gitea.forust.xyz/api/v1 \
-e RENOVATE_TOKEN="$RENOVATE_TOKEN" \
-e RENOVATE_GITHUB_COM_TOKEN="${RENOVATE_GITHUB_COM_TOKEN:-}" \
-e RENOVATE_REPOSITORIES="${RENOVATE_REPOSITORIES:-forust/homelab}" \
+4 -45
View File
@@ -21,51 +21,10 @@ ssh_key="$key_dir/deploy_key"
printf '%s\n' "$DEPLOY_KEY" > "$ssh_key"
chmod 600 "$ssh_key"
# A connection that died silently used to hang until the job timeout, and the
# stage was never re-run: one flaky TCP session cost a whole 45-minute apply.
# ServerAlive* bounds how long a dead peer goes unnoticed, ConnectTimeout bounds
# setup. Only exit 255 - ssh's own transport failures - is retried. A stage that
# fails on its own merits exits with the remote's status, so a real failure
# still surfaces its own log instead of burning three attempts. The stages are
# declarative applies, so re-running one that had already committed is harmless.
ssh_opts=(
-i "$ssh_key" -p "$deploy_port"
-o BatchMode=yes -o StrictHostKeyChecking=accept-new
-o ConnectTimeout=15
-o ServerAliveInterval=15 -o ServerAliveCountMax=4
)
rc=0
# apply-k8s and apply-compose are separate workflow jobs so the graph stays
# intact for the verify job, but on a single node they must not run at once:
# host docker churn on top of cluster churn is what melts the node (load 40+,
# netbird/ssh die, helm is left pending-*). Serialize them on the workstation
# with a shared lock; whoever arrives second waits.
remote_cmd=(bash -se)
case "$1" in
apply-k8s | apply-compose)
remote_cmd=(flock -w 5400 /tmp/homelab-apply.lock bash -se)
;;
esac
for attempt in 1 2 3; do
if [ "$attempt" -gt 1 ]; then
echo ":: warning::ssh transport failed, retrying (${attempt}/3)"
sleep $((attempt * 5))
fi
rc=0
# shellcheck disable=SC2029 # remote_cmd/ssh_opts expand on the client on purpose: they select the local ssh invocation, only the heredoc runs remotely.
ssh "${ssh_opts[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" \
env "REPO=$deploy_path" "APPLY_PRUNE=${APPLY_PRUNE:-false}" \
"DEPLOY_SHA=${DEPLOY_SHA:-}" "DEPLOY_SNAPSHOT_DIR=${DEPLOY_SNAPSHOT_DIR:-}" \
"STAGE=$1" "${remote_cmd[@]}" <<'EOF' || rc=$?
ssh -i "$ssh_key" -p "$deploy_port" \
-o BatchMode=yes -o StrictHostKeyChecking=accept-new \
"${DEPLOY_USER}@${DEPLOY_HOST}" \
"REPO=$deploy_path APPLY_PRUNE=${APPLY_PRUNE:-false} DEPLOY_SHA=${DEPLOY_SHA:-} DEPLOY_SNAPSHOT_DIR=${DEPLOY_SNAPSHOT_DIR:-} STAGE=$1 bash -se" <<'EOF'
source "$REPO/.gitea/workflows/deploy-lib.sh"
run_stage "$STAGE"
EOF
[ "$rc" -eq 0 ] && break
[ "$rc" -ne 255 ] && break
done
if [ "$rc" -ne 0 ]; then
echo ":: error::stage $1 failed over ssh (exit $rc)"
fi
exit "$rc"
-163
View File
@@ -1,163 +0,0 @@
# Homelab
Configuration for my homelab: Kubernetes manifests, Docker Compose stacks, and the
Gitea Actions that build and deploy them. Most applications have both deployment
formats. Headscale, Nextcloud AIO, and the media stack run on Docker; Kubernetes
provides their ingress through Services and EndpointSlices.
These files contain this lab's domains, IP addresses, storage paths, and private
registry names. Running them on another machine takes some editing.
## Start here
- [Service list](#services) — what each directory contains.
- [Deployment workflow](.gitea/README.md) — selection, validation, and recovery.
- [Repository review](docs/repository-review.md) — confirmed problems and fix branches.
- [Shared PostgreSQL](postgres/README.md), [Traefik](traefik/README.md), and
[cert-manager](cert-manager/README.md) — common dependencies.
## What gets deployed
The `active` files are switches for the deploy workflow, not health indicators.
| File | Effect |
| ---------------------- | ----------------------------------------------------------- |
| `<service>/active` | Include that directory's `compose.yaml` or `compose.yml`. |
| `<service>/k8s/active` | Include its Kubernetes manifests or Kustomize overlay. |
| Both | Run the Compose stack and apply the Kubernetes resources. |
| Neither | Keep the configuration in Git without automatic deployment. |
`shared-compose.yaml`, `client.compose.yaml`, and `renovate-compose.yaml` are
manual entry points. The deploy script does not discover them.
Kubernetes selection excludes secret files, examples, Helm values, and patches.
Helm releases listed in `deploy-lib.sh` are upgraded separately. Traefik,
cert-manager, and CrowdSec have additional bootstrap steps; an `active` marker
does not install their charts.
The table below describes committed configuration. It does not claim that a
service is currently healthy or running.
## Services
| Service | Configuration | Selected by markers |
| ----------------------------------------------------------- | ---------------------------- | ------------------- |
| [AdGuard Home](adguardhome/README.md) | Kubernetes + Compose | Kubernetes |
| [Authentik](authentik/README.md) | Kubernetes + Compose | Kubernetes |
| [cert-manager](cert-manager/README.md) | Kubernetes / Helm | Manual |
| [Cloudflare DDNS](cfddns/README.md) | Kubernetes + Compose | Kubernetes |
| [Checkmk](checkmk/README.md) | Kubernetes + Compose | Manual |
| [Cloudflare Tunnel](cloudflared/README.md) | Kubernetes / Helm | Manual |
| [File converters](converters/README.md) | Kubernetes + Compose | Kubernetes |
| [CrowdSec](crowdsec/README.md) | Kubernetes / Helm | Manual |
| [Dockmon](dockmon/README.md) | Kubernetes + Compose | Manual |
| [Downtify](downtify/README.md) | Kubernetes + Compose | Manual |
| [EDU session keeper and Telegram bot](edu_master/README.md) | Kubernetes + Compose | Kubernetes |
| [Error pages](errorpages/README.md) | Kubernetes + Compose | Kubernetes |
| [Gitea](gitea/README.md) | Kubernetes + Compose | Kubernetes |
| [Glance](glance/README.md) | Kubernetes + Compose | Manual |
| [Headscale](headscale/README.md) | Compose + Kubernetes routing | Compose, Kubernetes |
| [Homarr](homarr/README.md) | Kubernetes + Compose | Manual |
| [Homepages](homepages/README.md) | Kubernetes + Compose | Kubernetes |
| [Immich](immich/README.md) | Kubernetes + Compose | Kubernetes |
| [Kener](kener/README.md) | Kubernetes + Compose | Manual |
| [Loki and Alloy](loki/README.md) | Kubernetes / Helm | Kubernetes |
| [MeTube](metube/README.md) | Kubernetes + Compose | Kubernetes |
| [n8n](n8n/README.md) | Kubernetes + Compose | Manual |
| [NetBird](netbird/README.md) | Kubernetes + Compose | Kubernetes |
| [NetBox](netbox/README.md) | Kubernetes + Compose | Kubernetes |
| [Netronome](netronome/README.md) | Kubernetes + Compose | Kubernetes |
| [Nextcloud AIO](nextcloud/README.md) | Compose + Kubernetes routing | Compose, Kubernetes |
| [Penpot](penpot/README.md) | Compose | Manual |
| [Portainer](portainer/README.md) | Kubernetes + Compose | Manual |
| [Shared PostgreSQL](postgres/README.md) | Kubernetes + Compose | Kubernetes |
| [Monitoring stack](prometheus-stack/README.md) | Kubernetes + Compose | Kubernetes |
| [RackPeek](rackpeek/README.md) | Kubernetes + Compose | Kubernetes |
| [Reloader](reloader/README.md) | Kubernetes / Helm | Kubernetes |
| [Renovate](renovate/README.md) | Kubernetes + Compose | Kubernetes |
| [SearXNG](searxng/README.md) | Kubernetes + Compose | Manual |
| [Media stack](streaming/README.md) | Compose + Kubernetes routing | Compose, Kubernetes |
| [Termix](termix/README.md) | Kubernetes + Compose | Manual |
| [Traefik](traefik/README.md) | Kubernetes + Compose | Kubernetes |
| [Uptime Kuma](uptime-kuma/README.md) | Kubernetes + Compose | Kubernetes |
| [Vaultwarden](vaultwarden/README.md) | Kubernetes + Compose | Kubernetes |
| [3x-ui](vpn/xui/README.md) | Kubernetes | Kubernetes |
## Running a Compose stack
Use the service README first. Where a service has an env example, copy it inside
that service's directory and replace the placeholders. The root `.env.example`
is an older collection of variables, not a complete configuration for every stack.
For example, from the repository root:
```sh
cd netbox
cp .env.example .env
$EDITOR .env
docker compose config --quiet
docker compose up -d
docker compose ps
```
Stacks that attach to `proxy` require an existing Docker network of that name and
an appropriate reverse proxy. Published host ports still work independently of
Traefik. Check port conflicts before starting an alternative to a Kubernetes
service: DNS, STUN, and HTTP listeners can share the same host.
`docker compose down` keeps named volumes. Adding `-v` removes them.
## Preparing Kubernetes
The manifests assume Traefik CRDs, cert-manager, and a working storage provisioner.
PrometheusRule and ServiceMonitor resources also need the Prometheus Operator.
Replace the lab's hosts and addresses before using the configuration elsewhere.
Create a service's namespace, then prepare its ignored Secret from the example.
For example:
```sh
kubectl apply -f netbox/k8s/namespace.yaml
cp netbox/k8s/secrets.yaml.example netbox/k8s/secrets.yaml
$EDITOR netbox/k8s/secrets.yaml
kubectl apply -f netbox/k8s/secrets.yaml
```
The deploy workflow applies the tracked resources for marked services. Avoid
applying an entire `k8s/` directory blindly: some directories contain Helm values,
examples, and alternative routes. For a manual change, apply the selected manifest
explicitly and check the resulting rollout.
Shared database passwords must agree between the `database` namespace and each
application's Secret. Updating the PostgreSQL Secret does not change an existing
role's password; see the database README.
## Local checks
CI pins its tools in `.gitea/workflows/tool-versions.env`. Use the same versions:
```sh
tools_dir="$(bash .gitea/workflows/install-ci-tools.sh)"
export PATH="$tools_dir:$PATH"
ruff check .
ruff format --check .
actionlint -config-file .gitea/actionlint.yaml .gitea/workflows/*.yaml
.gitea/workflows/sync-renovate-configmap.sh --check
```
The [workflow README](.gitea/README.md#checks) lists the rest of the checks.
Structure checks do not establish that local Secrets, mounted files, storage,
or external services are ready.
## Data and recovery
State lives outside Git: PVCs, Docker volumes, bind mounts, databases, and ignored
configuration. Keep backups of application data and the keys needed to read it.
An image rollback does not roll back database migrations or ConfigMap contents.
Many PVCs use the cluster's default StorageClass; monitoring explicitly uses
`local-path`. Check the PV reclaim policy before deleting a PVC or namespace.
The manifests do not provide a repository-wide backup schedule.
`incident-archive/` contains past incident notes. `.docs/storage-audit-instruction.md`
is a planning document, not evidence that NFS has been installed.
-22
View File
@@ -1,22 +0,0 @@
# AdGuard Home
DNS filtering with a web UI, DNS-over-TLS, and certificates from cert-manager.
The Kubernetes namespace is `adguard`. The workload uses `adguard-pvc` for
configuration and working data, and mounts the `adguard-certs` TLS Secret.
The LoadBalancer Service exposes DNS separately from the web ingress.
The Compose stack publishes TCP/UDP 53 and TCP 853 on the host. Prepare `conf/`
and `certs/` before starting it. Starting both DNS deployments on the same address
can cause a port conflict.
## Inspect
From the repository root:
```sh
kubectl get pods,svc,pvc -n adguard
kubectl get events -n adguard --sort-by=.metadata.creationTimestamp
```
See the [repository README](../README.md) for deployment selection.
+1 -10
View File
@@ -58,8 +58,6 @@ spec:
selector:
matchLabels:
app: adguard
strategy:
type: Recreate
template:
metadata:
labels:
@@ -75,7 +73,7 @@ spec:
memory: "1.5Gi"
cpu: "300m"
requests:
memory: "512Mi"
memory: "500Mi"
cpu: "50m"
ports:
- containerPort: 3000
@@ -84,13 +82,6 @@ spec:
name: dns
- containerPort: 853
name: dot
readinessProbe:
tcpSocket:
port: dns
initialDelaySeconds: 5
periodSeconds: 5
successThreshold: 1
failureThreshold: 3
volumeMounts:
- name: adguard-data
mountPath: /opt/adguardhome/work
+3
View File
@@ -9,6 +9,9 @@ spec:
routes:
- match: Host(`dns.forust.xyz`)
kind: Rule
middlewares:
- name: crowdsec-bouncer
namespace: crowdsec
services:
- name: adguard-service
port: 3000
-22
View File
@@ -1,22 +0,0 @@
# Authentik
Identity provider with separate server and worker deployments.
Kubernetes connects to the shared PostgreSQL service in `database`. Set
`AUTHENTIK_DB_PASSWORD` to the same value in both database and application Secrets.
Keep `AUTHENTIK_SECRET_KEY` with the backups.
Compose uses its own PostgreSQL 15 container and bind-mounted media and templates.
Its image defaults differ from Kubernetes; check both before an upgrade.
The worker mounts the Docker socket for Docker outpost management.
## Inspect
From the repository root:
```sh
kubectl get pods,svc,pvc -n authentik
kubectl get events -n authentik --sort-by=.metadata.creationTimestamp
```
See the [repository README](../README.md) for deployment selection.
+5 -9
View File
@@ -34,8 +34,6 @@ spec:
selector:
matchLabels:
app: authentik-server
strategy:
type: Recreate
template:
metadata:
labels:
@@ -54,8 +52,8 @@ spec:
- containerPort: 9000
resources:
requests:
memory: "768Mi"
cpu: "100m"
memory: "700Mi"
cpu: "300m"
limits:
memory: "1.5Gi"
cpu: "1000m"
@@ -70,8 +68,6 @@ spec:
selector:
matchLabels:
app: authentik-worker
strategy:
type: Recreate
template:
metadata:
labels:
@@ -90,8 +86,8 @@ spec:
name: authentik-secrets
resources:
requests:
memory: "320Mi"
cpu: "100m"
memory: "512Mi"
cpu: "300m"
limits:
memory: "768Mi"
memory: "1Gi"
cpu: "700m"
+3
View File
@@ -9,6 +9,9 @@ spec:
routes:
- match: Host(`auth.forust.xyz`)
kind: Rule
middlewares:
- name: crowdsec-bouncer
namespace: crowdsec
services:
- name: authentik-server-service
port: 9000
-18
View File
@@ -1,18 +0,0 @@
# cert-manager
Public ACME issuers and an internal certificate authority.
This directory contains chart values and issuer resources, not the controller
installation. Install the cert-manager chart with CRDs and the settings in
`k8s/cert-manager-values.yaml` before applying the issuers.
`clusterissuer.yaml` defines staging and production Let's Encrypt issuers.
They use HTTP-01 through the Traefik ingress class. Public DNS and inbound HTTP
reachability must work for the requested names before issuance.
`internal-ca.yaml` bootstraps the internal CA. Keep its private-key Secret backed
up; the tracked `.crt` is only a public certificate.
This directory has no `k8s/active` marker. Apply the issuer files deliberately;
`kubectl apply` does not interpret the Helm values file.
See the [repository README](../README.md) for deployment selection.
-22
View File
@@ -1,22 +0,0 @@
# Cloudflare DDNS
Updates the lab DNS records when the public address changes.
Kubernetes runs in `default` with host networking and reads `cfddns-secrets`.
The Compose stack also uses host networking. Configure the API token and domain
list from the relevant example; keep DNS names consistent with the ingress rules.
`config.json.example` is a separate configuration example. The current Compose
file does not mount a config.json file. Check configuration against the pinned
DDNS image when changing between environment and file-based settings.
## Inspect
From the repository root:
```sh
kubectl get pods,svc,pvc -n default
kubectl get events -n default --sort-by=.metadata.creationTimestamp
```
See the [repository README](../README.md) for deployment selection.
+2 -4
View File
@@ -9,8 +9,6 @@ spec:
selector:
matchLabels:
app: cfddns
strategy:
type: Recreate
template:
metadata:
labels:
@@ -24,10 +22,10 @@ spec:
imagePullPolicy: Always
resources:
requests:
memory: "32Mi"
memory: "20Mi"
cpu: "30m"
limits:
memory: "128Mi"
memory: "64Mi"
cpu: "50m"
envFrom:
- secretRef:
-21
View File
@@ -1,21 +0,0 @@
# Checkmk
Checkmk Raw monitoring site with web and agent-receiver ingress.
The site data lives in `checkmk-sites-pvc` on Kubernetes and the `sites` named
volume on Compose. The agent receiver has a separate TCP route; enabling the
web route alone does not expose it.
Prepare the password in the service env or Secret example. Inspect the Checkmk
container logs during the first site creation.
## Inspect
From the repository root:
```sh
kubectl get pods,svc,pvc -n checkmk
kubectl get events -n checkmk --sort-by=.metadata.creationTimestamp
```
See the [repository README](../README.md) for deployment selection.
-2
View File
@@ -24,8 +24,6 @@ spec:
selector:
matchLabels:
app: checkmk
strategy:
type: Recreate
template:
metadata:
labels:
+3
View File
@@ -9,6 +9,9 @@ spec:
routes:
- match: Host(`cmk.forust.xyz`)
kind: Rule
middlewares:
- name: crowdsec-bouncer
namespace: crowdsec
services:
- name: checkmk-service
port: 5000
-21
View File
@@ -1,21 +0,0 @@
# Cloudflare Tunnel
A Kubernetes connector for an existing Cloudflare tunnel.
The Deployment runs in `default` and reads its token from the ignored Secret
created from `k8s/secret.yaml.example`. Create the tunnel and its hostname rules
in Cloudflare before starting the connector.
There is no Compose file or `k8s/active` marker. Apply the Secret first, then
`k8s/deployment.yaml` when this tunnel is needed.
## Inspect
From the repository root:
```sh
kubectl get pods,svc,pvc -n default
kubectl get events -n default --sort-by=.metadata.creationTimestamp
```
See the [repository README](../README.md) for deployment selection.
File renamed without changes.
+2 -4
View File
@@ -9,8 +9,6 @@ spec:
selector:
matchLabels:
app: cloudflared
strategy:
type: Recreate
template:
metadata:
labels:
@@ -32,8 +30,8 @@ spec:
key: TUNNEL_TOKEN
resources:
requests:
memory: "128Mi"
memory: "32Mi"
cpu: "30m"
limits:
memory: "256Mi"
memory: "128Mi"
cpu: "200m"
-22
View File
@@ -1,22 +0,0 @@
# File converters
ConvertX for server-side conversion and BentoPDF for PDF tools.
ConvertX persists files in `convertx-pvc`; BentoPDF has no persistent volume.
Kubernetes configuration includes a local `config.yaml.example`, excluded from
normal deployment. Copy and apply the real ConfigMap separately where required.
Compose publishes ConvertX on host port 9992 as well as attaching it to the
proxy network. Replace the authentication settings from `.env.example` before
exposing it outside the lab.
## Inspect
From the repository root:
```sh
kubectl get pods,svc,pvc -n converters
kubectl get events -n converters --sort-by=.metadata.creationTimestamp
```
See the [repository README](../README.md) for deployment selection.
+1 -1
View File
@@ -1,7 +1,7 @@
services:
convertx:
container_name: convertx
image: ghcr.io/c4illin/convertx:v0.19.0
image: ghcr.io/c4illin/convertx:v0.18.0
restart: unless-stopped
ports:
- "9992:3000"
+2 -3
View File
@@ -31,13 +31,12 @@ spec:
name: bentopdf
ports:
- containerPort: 8080
# p95 4M, max 11M over 7 days. Was 50Mi/700Mi.
resources:
requests:
memory: "32Mi"
memory: "50Mi"
cpu: "50m"
ephemeral-storage: "100Mi"
limits:
memory: "128Mi"
memory: "700Mi"
cpu: "700m"
ephemeral-storage: "5Gi"
+3 -6
View File
@@ -20,15 +20,13 @@ spec:
selector:
matchLabels:
app: convertx
strategy:
type: Recreate
template:
metadata:
labels:
app: convertx
spec:
containers:
- image: ghcr.io/c4illin/convertx:v0.19.0
- image: ghcr.io/c4illin/convertx:v0.18.0
name: convertx
envFrom:
- configMapRef:
@@ -40,14 +38,13 @@ spec:
volumeMounts:
- mountPath: /data
name: data
# p95 85M, max 136M over 7 days, spikes while converting. Was 250Mi/1.5Gi.
resources:
requests:
memory: "128Mi"
memory: "250Mi"
cpu: "100m"
limits:
cpu: "1500m"
memory: "512Mi"
memory: "1.5Gi"
volumes:
- name: data
persistentVolumeClaim:
-25
View File
@@ -1,25 +0,0 @@
# CrowdSec
Helm values, dashboards, network policy, and a maintenance CronJob.
Install CrowdSec separately using `k8s/crowdsec-values.yaml`; the deploy
workflow does not have a CrowdSec Helm release entry. There is no `k8s/active`
marker in this directory.
The LAPI policy and janitor run in `crowdsec`. The dashboard ConfigMaps are in
`prometheus` for Grafana's sidecar. The janitor has its own ServiceAccount and
namespace Role. Review its script and schedule before enabling cleanup.
Traefik's values state that enforcement moved to a host firewall bouncer. This
repository does not install that host component.
## Inspect
From the repository root:
```sh
kubectl get pods,svc,pvc -n crowdsec
kubectl get events -n crowdsec --sort-by=.metadata.creationTimestamp
```
See the [repository README](../README.md) for deployment selection.
+24
View File
@@ -0,0 +1,24 @@
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: crowdsec-bouncer
namespace: crowdsec
spec:
plugin:
crowdsec-bouncer:
enabled: true
LogLevel: INFO
CrowdsecMode: live
CrowdsecLapiScheme: http
CrowdsecLapiHost: crowdsec-service.crowdsec.svc.cluster.local:8080
CrowdsecLapiKeyFile: "/etc/traefik/secrets/traefik-api-key"
# LAPI lookup is SYNCHRONOUS and per-request: the plugin blocks on
# `GET /v1/decisions?ip=...&banned=true` before the request reaches
# the backend, and fails CLOSED (403) if the lookup exceeds the
# timeout. Unset, the fork defaults to 10s, which is an eternity for
# a request path: a single slow LAPI (idle 1.3-7.4s here) turned
# every request into a 10s hang and then a self-inflicted 403.
# 2s keeps the fail-closed path fast and bounded; with the LAPI
# resourced properly (see crowdsec-values.yaml) the lookup is
# sub-100ms and this budget is never hit.
CrowdsecLapiTimeout: "2s"
+15 -92
View File
@@ -16,12 +16,6 @@ agent:
value: crowdsecurity/traefik crowdsecurity/base-http-scenarios
- name: DISABLE_COLLECTIONS
value: crowdsecurity/sshd
# Bans on 401/403 bursts hurt more than they protect: with L3 enforcement
# a false positive cuts the IP off everything (SSH included), and past
# incidents show legit automation (deploy runner, mesh peers, registry
# pulls) tripping this probe. Probing/XSS/SQLi/CVE scenarios stay.
- name: DISABLE_SCENARIOS
value: crowdsecurity/http-generic-bf
metrics:
enabled: true
serviceMonitor:
@@ -64,44 +58,9 @@ config:
reason: "Mobile IP whitelist"
cidr:
- "84.245.64.0/18"
# CrowdSec's own guidance: CIDR allowlisting belongs at the parser stage.
# A parser whitelist discards the event before it reaches a bucket, so
# these addresses never produce an overflow and never become a decision.
# A postoverflow whitelist is checked only *after* the ban exists, and
# the bouncer answers 403 for as long as it does - which is a window we
# do not want the deploy sitting in.
local-network.yaml: |
name: forust/local-network
description: "Whitelist loopback, private and VPN networks"
whitelist:
reason: "Local network"
cidr:
- "127.0.0.0/8"
- "10.0.0.0/8"
- "172.16.0.0/12"
- "192.168.0.0/16"
# CGNAT range (RFC 6598). The workstation and the k0s node live
# here on WireGuard, and 100.64.0.0/10 is not covered by the
# RFC 1918 blocks above.
- "100.64.0.0/10"
- "169.254.0.0/16"
- "fc00::/7"
- "fe80::/10"
vps-whitelist.yaml: |
name: forust/vps-whitelist
description: "Whitelist static VPS"
whitelist:
reason: "VPS"
ip:
- "193.181.211.79"
postoverflows:
s01-whitelist:
# The one whitelist that has to stay here: resolving a hostname is a
# network call, and the docs put expensive lookups in postoverflows on
# purpose - it runs only when a bucket actually overflows.
# ddns.forust.xyz is the public home address, not a private one, so
# forust/local-network does not cover it.
home-dynamic-ip.yaml: |
name: forust/home-dynamic-ip
description: "Whitelist home dynamic IP"
@@ -109,59 +68,23 @@ config:
reason: "Home dynamic IP"
expression:
- evt.Overflow.Alert.Source.IP in LookupHost("ddns.forust.xyz")
# LAPI-only main config override, merged over config.yaml. NOTE: the
# chart's own default for this key is REPLACED, not merged, so its
# auto_registration block is repeated verbatim below - drop it and the
# agent can no longer register itself.
config.yaml.local: |
api:
server:
auto_registration: # Activate if not using TLS for authentication
enabled: true
token: "${REGISTRATION_TOKEN}" # /!\ Do not modify this variable (auto-generated and handled by the chart)
allowed_ranges: # /!\ Make sure to adapt to the pod IP ranges used by your cluster
- "127.0.0.1/32"
- "192.168.0.0/16"
# The hairpin-NAT address of the router (192.168.88.1) is what the
# Gitea Actions runner presents to Traefik - it is NOT the home
# dynamic IP, so the whitelist above did not cover it. During a
# deploy the runner POSTs to the Actions API many times a second;
# a single 403 storm was enough to earn it a 4h ban and break every
# later job. Whitelisting the whole LAN also covers phones and
# tablets browsing over 192.168.88.0/24.
lan.yaml: |
name: forust/lan
description: "Whitelist local network"
whitelist:
reason: "Local network"
cidr:
- "127.0.0.0/8"
- "10.0.0.0/8"
- "172.16.0.0/12"
# This homelab has no egress to console.crowdsec.cloud: DNS does
# not resolve. The LAPI kept trying anyway ("Signal push: N
# signals to push", "capi metrics: sending" every 10s) and each
# attempt sat on a resolver timeout WHILE HOLDING A WRITE
# TRANSACTION, which is what kept stalling per-request decision
# lookups even with WAL enabled. Nothing to share and nothing to
# pull - turn the Central API off instead of letting it block the
# only database writer we have.
online_client:
sharing: false
pull:
community: false
blocklists: false
disable_usage_metrics_export: true
db_config:
# SQLite without WAL serialises every reader behind the writer's
# rollback journal, and the LAPI writes constantly: the agent pushes
# Traefik alerts read from Loki, the metrics collector counts
# decisions, the bouncer touches "last pull" on every request.
# Symptom: decision lookups taking 10-30s (and a second connection
# that could not even open the database) while the LAPI sat at 28m
# CPU - the process was blocked in fsync, not computing. Every
# bouncer-protected request then blew through the plugin timeout and
# fail-closed with 403, on every site at once.
# The PVC is local-path-retain (hostPath), not a network share, so
# WAL is safe here; the crowdsec docs recommend it for exactly this
# ("allowing more concurrency in SQLite that will improve
# performances in most scenarios").
use_wal: true
# Keeps the alert table bounded. At the 5000/7d default the file
# reached 54MB in 15 days off the Traefik access log alone, and the
# metrics collector counts decisions on a timer; a smaller working
# set means fewer full scans. Crowdsec only prunes - SQLite never
# shrinks the file, so the size stays until a manual VACUUM.
flush:
max_items: 1000
max_age: 24h
- "192.168.0.0/16"
lapi:
env:
+20 -8
View File
@@ -30,14 +30,10 @@
# 3. ensure the static machine exists, recreating it with the
# Secret password if missing (agent retry loops reconnect
# on their own - same name + same password);
# 4. prune bouncer entries idle for 30d.
#
# It used to also delete LePresidente/http-generic-403-bf decisions hourly.
# That was a workaround for the bouncer failing closed on a slow LAPI and
# 403-ing the deploy runner into a 4h ban. The bouncer now polls decisions
# into a cache and never blocks on an unreachable LAPI, so it cannot
# manufacture those 403s any more, and the scenario only fires against real
# scanners - deleting their decisions hourly was undoing a working ban.
# 4. prune bouncer entries idle for 30d;
# 5. delete decisions from LePresidente/http-generic-403-bf, a hub
# scenario that bans an IP for 4h after 5 POST-403s in 10s and
# therefore bans us for our own bouncer's fail-closed 403s.
#
# Manual apply (crowdsec/k8s is NOT managed by deploy.yaml):
# kubectl apply -f crowdsec/k8s/janitor-cronjob.yaml
@@ -200,3 +196,19 @@ spec:
fi
echo "== 4. prune stale bouncers (no pull for 30d) =="
$LAPI_EXEC cscli bouncers prune -d 720h --force
echo "== 5. drop http-403-bf decisions (4h self-bans) =="
# `LePresidente/http-generic-403-bf` (hub item
# crowdsecurity/http-generic-bf v0.9) bans any source IP
# after 5 POSTs answered 403 within 10s, for 4h. That
# includes 403s this homelab generates ITSELF (any
# bouncer fail-closed, any app CSRF/rate-limit 403), and a
# 4h ban on the runner/home IP silently breaks deploys and
# browsing. The scenario cannot be removed per-scenario -
# it is baked into a hub item, and disabling the whole
# base-http-scenarios collection would drop ~40 useful
# detections. Instead we keep the detection and drop its
# decisions hourly; the LAN/home whitelists in
# crowdsec-values.yaml handle the legit sources, so this
# only ever hits real scanners (who are re-banned anyway).
$LAPI_EXEC cscli decisions delete \
--scenario LePresidente/http-generic-403-bf --all || true
-21
View File
@@ -1,21 +0,0 @@
# Dockmon
Docker management UI that talks to the host Docker daemon.
Both runtimes mount `/var/run/docker.sock`. On Kubernetes the socket belongs
to the node hosting the pod, so this is not a cluster-wide container manager.
Compose stores application data in a named volume. Kubernetes uses a StatefulSet
with a volume claim template. Its ServersTransport is specific to the upstream
connection; keep it with the ingress resources.
## Inspect
From the repository root:
```sh
kubectl get pods,svc,pvc -n dockmon
kubectl get events -n dockmon --sort-by=.metadata.creationTimestamp
```
See the [repository README](../README.md) for deployment selection.
+2
View File
@@ -18,6 +18,8 @@ spec:
- match: Host(`dockmon.forust.xyz`)
kind: Rule
middlewares:
- name: crowdsec-bouncer
namespace: crowdsec
- name: security-headers@file
services:
- name: dockmon-service
-151
View File
@@ -1,151 +0,0 @@
# Repository review
Reviewed the tracked tree at `cc9c3de` and read the live workstation state on
6 October 2026. Changes are split into documentation and individual fix branches,
all based on that main commit. The original local checkout and its uncommitted
monitoring changes were preserved. No deployment was performed.
## Confirmed problems with prepared fixes
| Priority | Problem and consequence | Fix branch |
| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
| High | `APPLY_PRUNE=true` is passed to each individual manifest apply. Each invocation sees only that file's desired objects and can delete other resources selected by the shared label. | `fix/deploy-prune-guard` |
| High | Deploy validates Compose with interpolation and env/path resolution disabled. Required settings can pass validation and then fail during apply after other workloads have changed. | `fix/deploy-validation` |
| Medium | Secret validation is text-based and compares names across all namespaces. A Secret elsewhere can hide a missing local Secret; mounted Secrets are also missed. | `fix/deploy-validation` |
| Medium | Compose CI misses `postgres/shared-compose.yaml`, `netbird/client.compose.yaml`, and `renovate/renovate-compose.yaml`. | `fix/deploy-validation` |
| Medium | NetBird Compose mounts `entrypoint.sh`, but it is absent. Its README also calls a missing `setup.sh`; a fresh checkout cannot start this stack as documented. | `fix/netbird-compose-runtime` |
| Medium | Glance's CSS mount uses `glance-config`, whose keys do not include `user.css`. That key is in `glance-assets`; the pod's subPath mount cannot be prepared correctly. | `fix/glance-assets` |
| Medium | The shared PostgreSQL initializer requires `NETBOX_DB_PASSWORD`, but the Compose env example omits it. Following the example leaves first initialization incomplete. | `fix/postgres-env-example` |
| Medium | EDU's Compose env example uses old credential names and full URL variables, while the code reads `KEEPER_*` and paths under `EDU_URL_BASE`. | `fix/session-keeper-reliability` |
| Medium | Session keeper HTTP calls have no timeouts. Its Redis cookie never expires, probes only check existence, and its logs include cookies. A hung or failed refresh can leave a stale session appearing ready. | `fix/session-keeper-reliability` |
| Medium | AdGuard's DoH and SearXNG's Compose rules put Boolean expressions inside `Host(...)`. They are invalid router expressions despite valid YAML. | `fix/compose-router-rules` |
Traefik matchers should be combined as `Host(a) || Host(b)`; the rule syntax is
described in the [Traefik rules documentation](https://doc.traefik.io/traefik/reference/routing-configuration/http/routing/rules-and-priority/).
The fix retains the DoH path constraint for both hostnames.
The prune fix deliberately rejects the unsafe option. It does not introduce
automatic deletion under a different implementation. Prune defaults to false,
and no tracked resource currently carries the selector label, so this is a
latent defect rather than evidence of a live deletion incident.
The session fix bounds HTTP and Redis calls, validates required credentials,
sets a cookie lifetime of two refresh intervals, and marks success only after
publishing the verified cookie. With the default ten-minute interval, an outage
longer than twenty minutes will make the existing Redis-key readiness checks fail.
That is an intentional change from indefinite apparent readiness.
The deployment fix extracts required pod Secret references from rendered JSON,
checks their namespaces, includes init containers, image-pull credentials, and
mounted/projected Secrets, and honors optional references. Ingress TLS Secrets
issued by cert-manager are not treated as pre-existing pod prerequisites.
It checks existence/access, not every key's contents or application validity.
## Live workstation observations
The SSH alias `workstation` is reachable. It has one Ready control-plane node,
Kubernetes `v1.35.4+k0s`, and a Docker daemon alongside containerd. At inspection,
no pods were Pending or in another non-running, non-completed phase. This is a
point-in-time observation, not a complete application health test.
The deployment checkout at `/srv/homelab` is on main commit `2adf17c`, behind the
reviewed local commit. It has untracked host configuration and a separate
`userbot/` directory. It was not reset or cleaned.
| Observed difference | Implication |
| ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| VictoriaMetrics and vmalert are running; the Prometheus StatefulSet has zero replicas. | A monitoring migration is already in progress outside committed main. Deploying the old Helm values can overwrite those settings. |
| Homarr, Cloudflared, and Reloader are installed without their current Git active markers. | Installed services and marker-selected services are different inventories. Missing markers do not establish that a service is stopped. |
| Cloudflare DDNS is running in both Docker and Kubernetes. | Confirm which instance should own DNS updates and whether their domain lists overlap before retiring either one. Secret values were not inspected. |
| Traefik's LoadBalancer exposes port 8080 at `192.168.80.2`. | The direct API listener is deployed; its external reachability was not tested. |
| Default `local-path` has reclaim policy Delete, while many existing PVs have been changed to Retain. | Current retention is partly live state. Recreating a claim can get a different policy from the old PV. |
| NetBird, NetBox media/reports/scripts, EDU Redis, Homarr, and VictoriaMetrics have Delete-policy PVs. | Deleting their claims can delete important state. Plan backup and retention changes before namespace cleanup. |
The monitoring files already modified in the user's local tree correspond to the
live migration. They are excluded from these branches. Reconcile that work before
using this review's baseline to deploy monitoring.
## Remaining work
These need recovery design or infrastructure decisions rather than a small
configuration correction:
- **SSH apply retries can replace the rollback baseline.** `ssh-run.sh` retries
exit 255, including `apply-k8s`; every new invocation publishes a fresh snapshot.
If the first attempt already changed workloads, the retry snapshots that partial
state. Preserve a run-specific original baseline and verify it across retries.
- **Rollback can exceed the job budget.** Verification is parallel, but
`rollback_workloads` is serial with a five-minute limit per workload. The
thirty-minute job budget can expire before recovery finishes. Bound recovery
concurrency and account for both phases before choosing a new timeout.
- **Snapshot collection is allowed to fail.** Generation and workload snapshot
errors are warnings; verify can fall back to all workloads. A snapshot failure
must not permit unrelated workloads to be selected for automatic undo.
- **Rollback uses the previous revision, not the captured revision.** `rollout undo`
without an explicit revision cannot guarantee restoration to the snapshot after
retries or intervening rollouts. First deployments also have no previous revision.
- **Manual deploy dispatch bypasses the CI-success trigger.** Either validate the
target commit's successful CI run or document manual dispatch as an operator
override with its own required checks.
- **Direct Traefik API exposure is unauthenticated.** The latest local commit
explicitly added it for Homarr. Preserve that integration while choosing a
cluster-internal authenticated path or a verified network restriction; do not
simply disable an integration that is already in use.
- **Storage retention and backup are not reproducible as a whole.** Defaults and
several important PV policies are Delete. There is no repository-wide backup
schedule. Existing PVC StorageClass changes require migration rather than an
in-place YAML edit.
- **MeTube downloads are temporary on Kubernetes.** `/downloads` is a 20 GiB
emptyDir. Decide whether pod replacement should discard files or whether it
should use persistent storage. Compose uses a host directory instead.
- **First-time activation needs a bootstrap path.** Deploy validation dry-runs
namespaced resources before the apply stage creates namespaces and installs
selected charts. On a fresh cluster, missing namespaces and CRDs need separate
preparation; activation is not a complete installer.
## Validation
Baseline lint checks passed for Python, shell, workflows, YAML, standard Compose
files, and Kubernetes resources with available schemas. Kubeconform found 347
resources in 174 files: 201 valid, 146 skipped CRDs, zero invalid resources.
That skip count matters: passing schema validation does not validate Traefik rule
strings or other controller-specific behavior.
Fix validation covers:
- Compose discovery of manual entry points, rejection of required-variable gaps,
namespace-scoped and optional Secret references, and API/render failures.
- NetBird setup idempotence, preservation of existing keys, file permissions,
runtime rendering, and rejection of invalid trusted proxy CIDRs.
- Session refresh success and failure paths, timeouts, cookie expiry, log redaction,
missing credentials, and nonpositive refresh intervals.
- Correct Glance ConfigMap key selection and PostgreSQL initializer/env alignment.
- YAML and Compose structure for the corrected router rules, compared with the
documented Traefik grammar. They were not exercised on the live proxy.
- Prune rejection before any cluster invocation.
All seven fix branches and the documentation branch merged together without
conflicts in a disposable validation worktree. The combined tree passed the
CI-equivalent local checks, Markdown formatting/lint and link checks, all 35
Compose structure checks, and 11 Python regression tests plus the shell
validation regressions. CRD server-side validation and live rollout tests were
not run.
Runtime tests use fixtures and mocks, not production credentials. Live checks read
workload metadata, storage policies, chart versions, and container state only.
They did not read Secret contents or change services.
## Reloader follow-up
`fix/reloader-integration` adds the active marker and opt-in annotations to 28
application Deployments/StatefulSets that consume runtime ConfigMaps or Secrets.
It corrects AdGuard's misplaced pod-template annotation. The Helm settings use
annotation-based reloads, keep global auto-reload disabled, and ignore Jobs and
CronJobs. PostgreSQL workloads are excluded because their credential variables
and init scripts are only effective on an empty data directory.
The controller was already running on workstation when inspected. Its live
configuration is unchanged by the branch: merge and deploy the integration to
apply the new policy and application annotations. Configuration reload behavior
was checked against the pinned chart, with Helm rendering and manifest validation;
no production configuration was changed to provoke a test restart.
-20
View File
@@ -1,20 +0,0 @@
# Downtify
Download UI with a persistent downloads directory.
Compose stores downloads under `Downtify_downloads/`; Kubernetes uses
`downtify-downloads-pvc`. The ingress manifests reference shared infrastructure,
so check certificate and middleware availability before enabling them.
Back up downloads separately if they need to survive storage replacement.
## Inspect
From the repository root:
```sh
kubectl get pods,svc,pvc -n downtify
kubectl get events -n downtify --sort-by=.metadata.creationTimestamp
```
See the [repository README](../README.md) for deployment selection.
+1 -1
View File
@@ -1,7 +1,7 @@
services:
downtify:
container_name: downtify
image: ghcr.io/henriquesebastiao/downtify:3.4.0
image: ghcr.io/henriquesebastiao/downtify:3.1.0
restart: unless-stopped
# ports:
# - '7077:8000'
+1 -3
View File
@@ -20,8 +20,6 @@ spec:
selector:
matchLabels:
app: downtify
strategy:
type: Recreate
template:
metadata:
labels:
@@ -29,7 +27,7 @@ spec:
spec:
containers:
- name: downtify
image: ghcr.io/henriquesebastiao/downtify:3.4.0
image: ghcr.io/henriquesebastiao/downtify:3.1.0
ports:
- containerPort: 8000
volumeMounts:
+2
View File
@@ -10,6 +10,8 @@ spec:
- match: Host(`downtify.forust.xyz`)
kind: Rule
middlewares:
- name: crowdsec-bouncer
namespace: crowdsec
- name: security-chain@file
services:
- name: downtify-service
+13
View File
@@ -0,0 +1,13 @@
FROM python:3.9-alpine
WORKDIR /app
# Установка зависимостей
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Копирование кода
COPY main.py .
COPY .env .
# Запуск бота
CMD ["python", "-u", "main.py"]
+373
View File
@@ -0,0 +1,373 @@
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at https://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.
+15
View File
@@ -0,0 +1,15 @@
services:
dtek_notif:
build:
context: .
dockerfile: Dockerfile
image: gcr.forust.xyz/forust/dtek-notif:latest
pull_policy: build
restart: unless-stopped
environment:
- TZ=Europe/Kyiv
dns:
- 1.1.1.1
- 8.8.8.8
networks:
- default
+748
View File
@@ -0,0 +1,748 @@
import asyncio
import contextlib
import logging
import os
from datetime import datetime, timedelta
import requests
from aiogram import Bot, Dispatcher
from aiogram.filters import Command
from aiogram.types import KeyboardButton, Message
from aiogram.utils.keyboard import ReplyKeyboardBuilder
from bs4 import BeautifulSoup
from dotenv import load_dotenv
# Загрузка переменных окружения
load_dotenv()
# Настройки
TELEGRAM_TOKEN = os.getenv('TELEGRAM_TOKEN', 'YOUR_TOKEN_HERE')
ALLOWED_CHAT_IDS = list(map(int, os.getenv('ALLOWED_CHAT_IDS', '').split(','))) if os.getenv('ALLOWED_CHAT_IDS') else []
CHECK_INTERVAL = int(os.getenv('CHECK_INTERVAL', '120'))
# Параметры для запроса
VOE_CITY_ID = int(os.getenv('VOE_CITY_ID', 'VOE_CITY_ID'))
VOE_STREET_ID = int(os.getenv('VOE_STREET_ID', 'VOE_STREET_ID'))
VOE_HOUSE_ID = int(os.getenv('VOE_HOUSE_ID', 'VOE_HOUSE_ID'))
# Настройка логирования
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# Глобальные переменные
bot = Bot(token=TELEGRAM_TOKEN)
dp = Dispatcher()
last_schedule: list[dict] | None = None
last_notification_time: dict[str, datetime] = {}
# ============================================================================
# УТИЛИТЫ
# ============================================================================
def format_time_duration(minutes: int) -> str:
"""Форматирует время из минут в часы и минуты"""
hours = minutes // 60
mins = minutes % 60
if hours == 0:
return f'{mins}м'
elif mins == 0:
return f'{hours}ч'
return f'{hours}ч {mins}м'
def get_day_statistics(day_blocks: list[dict]) -> dict[str, int]:
"""Получает статистику по дню"""
total_minutes = 0
confirmed_minutes = 0
possible_minutes = 0
for block in day_blocks:
for half in [block['first_half'], block['second_half']]:
if half['status'] == 'off':
total_minutes += 30
if half['confirmed']:
confirmed_minutes += 30
else:
possible_minutes += 30
return {'total': total_minutes, 'confirmed': confirmed_minutes, 'possible': possible_minutes}
# ============================================================================
# ПАРСИНГ ДАННЫХ
# ============================================================================
def parse_html(html: str) -> list[dict]:
"""Парсит HTML с графиком отключений (логика от 15.11.2024)"""
soup = BeautifulSoup(html, 'html.parser')
cells = soup.select('.disconnection-detailed-table-cell.cell')
schedule = []
current_hour = 0
current_day = 0
for cell in cells:
if 'legend' in cell.get('class', []) or 'head' in cell.get('class', []):
continue
cell_classes = cell.get('class', [])
# ПРоверка статуса отключения на весь час
full_hour_off = 'has_disconnection' in cell_classes and 'full_hour' in cell_classes
hour_block = cell.select_one('.hour_block')
if not hour_block:
continue
# Проверка подтверждённости отключения для всего часа
cell_confirmed = None
if 'confirm_1' in cell_classes:
cell_confirmed = True
elif 'confirm_0' in cell_classes:
cell_confirmed = False
# Проверка половин часа
left = hour_block.select_one('.half.left')
right = hour_block.select_one('.half.right')
def parse_half(half, is_full_hour_off: bool, cell_confirmed: bool | None = None) -> dict:
"""Парсит половину часа"""
if not half:
return {'status': 'on', 'queue': None, 'confirmed': None}
half_classes = half.get('class', [])
# Если вся ячейка full_hour - используем статус ячейки
if is_full_hour_off:
return {'status': 'off', 'queue': None, 'confirmed': cell_confirmed}
# Определяем статус половины
if 'has_disconnection' in half_classes:
status = 'off'
elif 'no_disconnection' in half_classes:
status = 'on'
else:
status = 'on' # По умолчанию считаем включенным
# Если выключено - ищем подробности
queue = None
confirmed = None
if status == 'off':
disconnection_div = half.select_one('.disconnection')
if disconnection_div:
# Ищем номер черги в title
if disconnection_div.has_attr('title'):
title = disconnection_div['title']
if 'Номер черги' in title or 'Номер черги:' in title:
with contextlib.suppress(BaseException):
queue = title.split(':')[-1].strip()
# Определяем подтверждение
disc_classes = disconnection_div.get('class', [])
if 'disconnection_confirm_1' in disc_classes:
confirmed = True
elif 'disconnection_confirm_0' in disc_classes:
confirmed = False
return {'status': status, 'queue': queue, 'confirmed': confirmed}
first_half_data = parse_half(left, full_hour_off, cell_confirmed)
second_half_data = parse_half(right, full_hour_off, cell_confirmed)
schedule.append(
{
'hour': current_hour,
'day': current_day,
'first_half': first_half_data,
'second_half': second_half_data,
}
)
current_hour += 1
if current_hour >= 24:
current_hour = 0
current_day += 1
return schedule
def get_voe_html(city_id: int, street_id: int, house_id: int) -> str:
"""Получает HTML с сайта VOE"""
url = 'https://www.voe.com.ua/disconnection/detailed?ajax_form=1&_wrapper_format=drupal_ajax'
headers = {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
}
data = {
'search_type': 0,
'city_id': city_id,
'street_id': street_id,
'house_id': house_id,
'form_build_id': 'form-Irv5aHw1R2FT_Ik2apyHOZ47hTH5xPNH_LQnBrmpSTc',
'form_id': 'disconnection_detailed_search_form',
'_triggering_element_name': 'search',
'_triggering_element_value': 'Показати',
'_drupal_ajax': 1,
}
try:
response = requests.post(url, headers=headers, data=data, timeout=10)
response.raise_for_status()
resp_json = response.json()
insert_html = next((item['data'] for item in resp_json if item.get('command') == 'insert'), None)
if not insert_html:
raise ValueError('HTML не найден в ответе')
return insert_html
except requests.exceptions.RequestException as e:
logger.error(f'Ошибка запроса VOE: {e}')
raise
# ============================================================================
# ФОРМАТИРОВАНИЕ СООБЩЕНИЙ
# ============================================================================
def get_main_keyboard():
"""Создает главную клавиатуру"""
builder = ReplyKeyboardBuilder()
builder.row(KeyboardButton(text='📊 Графік'), KeyboardButton(text='🔄 Оновити'))
builder.row(KeyboardButton(text='📅 Сьогодні'), KeyboardButton(text='📅 Завтра'))
builder.row(KeyboardButton(text='ℹ️ Про бота'))
return builder.as_markup(resize_keyboard=True)
def format_schedule_message(schedule: list[dict], days_to_show: int = 2) -> str:
"""Форматирует полный график на несколько дней"""
lines = [
'⚡️ <b>Графік відключень світла</b>',
f'🕐 Оновлено: {datetime.now().strftime("%d.%m.%Y %H:%M:%S")}',
'─' * 30,
'',
]
start_date = datetime.now()
for day in range(min(days_to_show, 2)):
day_blocks = [b for b in schedule if b['day'] == day]
if not day_blocks:
continue
date_str = (start_date + timedelta(days=day)).strftime('%d.%m.%Y')
day_name = '🌅 <b>Сьогодні</b>' if day == 0 else '🌄 <b>Завтра</b>'
lines.append(f'{day_name} ({date_str})')
# Статистика
stats = get_day_statistics(day_blocks)
if stats['total'] > 0:
lines.append(f'⏱ Всього: <code>{format_time_duration(stats["total"])}</code>')
if stats['confirmed'] > 0:
lines.append(f'🔴 Підтверджено: <code>{format_time_duration(stats["confirmed"])}</code>')
if stats['possible'] > 0:
lines.append(f'🟠 Можливо: <code>{format_time_duration(stats["possible"])}</code>')
else:
lines.append('🟢 <b>Відключень немає!</b>')
lines.append('')
# Детальный список отключений
disconnections = []
current_status = None
start_time = None
current_confirmed = None
current_queue = None
for block in day_blocks:
hour = block['hour']
for half_idx, half in enumerate([block['first_half'], block['second_half']]):
time_str = f'{hour:02d}:00' if half_idx == 0 else f'{hour:02d}:30'
if half['status'] == 'off':
if current_status != 'off':
start_time = time_str
current_confirmed = half['confirmed']
current_queue = half['queue']
current_status = 'off'
else:
if current_status == 'off':
icon = '🔴' if current_confirmed else '🟠'
queue_text = f' (Ч{current_queue})' if current_queue else ''
disconnections.append(f'{icon} <code>{start_time} - {time_str}</code>{queue_text}')
current_status = half['status']
# Если день закончился на отключении
if current_status == 'off':
icon = '🔴' if current_confirmed else '🟠'
queue_text = f' (Ч{current_queue})' if current_queue else ''
next_hour = (day_blocks[-1]['hour'] + 1) % 24
end_time = f'{next_hour:02d}:00'
disconnections.append(f'{icon} <code>{start_time} - {end_time}</code>{queue_text}')
if disconnections:
for idx, disc in enumerate(disconnections, 1):
lines.append(f'{idx}. {disc}')
lines.append('')
lines.append('<i>🔴 = підтверджено • 🟠 = можливо • 🟢 = світло</i>')
return '\n'.join(lines)
def format_single_day_schedule(schedule: list[dict], day: int) -> str:
"""Форматирует график на один день"""
day_blocks = [b for b in schedule if b['day'] == day]
if not day_blocks:
return '❌ Немає даних для цього дня'
start_date = datetime.now()
date_str = (start_date + timedelta(days=day)).strftime('%d.%m.%Y')
day_name = '🟠 <b>Сьогодні</b>' if day == 0 else '🔶 <b>Завтра</b>'
lines = [f'{day_name} • {date_str}', '']
# Статистика
lines.append('<b>📊 Статистика</b>')
stats = get_day_statistics(day_blocks)
if stats['total'] == 0:
lines.append('└ 🟢 <b>Відключень немає!</b>')
else:
total_time = format_time_duration(stats['total'])
lines.append(f'├ ⏱ Всього: <code>{total_time}</code>')
if stats['confirmed'] > 0:
confirmed_time = format_time_duration(stats['confirmed'])
lines.append(f'├ 🔴 Підтверджено: <code>{confirmed_time}</code>')
if stats['possible'] > 0:
possible_time = format_time_duration(stats['possible'])
lines.append(f'└ 🟠 Можливо: <code>{possible_time}</code>')
else:
lines.append('└ 🟢 Решта часу світло')
lines.append('')
# Детальный список отключений
disconnections = []
current_status = None
start_time = None
current_confirmed = None
current_queue = None
for block in day_blocks:
hour = block['hour']
for half_idx, half in enumerate([block['first_half'], block['second_half']]):
time_str = f'{hour:02d}:00' if half_idx == 0 else f'{hour:02d}:30'
if half['status'] == 'off':
if current_status != 'off':
start_time = time_str
current_confirmed = half['confirmed']
current_queue = half['queue']
current_status = 'off'
else:
if current_status == 'off':
icon = '🔴' if current_confirmed else '🟠'
queue_text = f' (Ч.{current_queue})' if current_queue else ''
disconnections.append(f'{icon} <code>{start_time} - {time_str}</code>{queue_text}')
current_status = half['status']
# Если день закончился на отключении
if current_status == 'off':
icon = '🔴' if current_confirmed else '🟠'
queue_text = f' (Ч.{current_queue})' if current_queue else ''
next_hour = (day_blocks[-1]['hour'] + 1) % 24
end_time = f'{next_hour:02d}:00'
disconnections.append(f'{icon} <code>{start_time} - {end_time}</code>{queue_text}')
if disconnections:
lines.append('<b>⚡️ Розклад відключень</b>')
for idx, disc in enumerate(disconnections, 1):
lines.append(f'{idx}. {disc}')
lines.append('')
lines.append('<i>🔴 підтверджено • 🟠 можливо • 🟢 світло</i>')
return '\n'.join(lines)
def schedules_differ(old_schedule: list[dict] | None, new_schedule: list[dict] | None) -> bool:
"""Проверяет отличия между графиками"""
if old_schedule is None or new_schedule is None:
return True
if len(old_schedule) != len(new_schedule):
return True
for old, new in zip(old_schedule, new_schedule, strict=False):
if old['day'] >= 2:
break
if old['first_half'] != new['first_half'] or old['second_half'] != new['second_half']:
return True
return False
# ============================================================================
# УВЕДОМЛЕНИЯ
# ============================================================================
async def send_to_all_users(message_text: str, parse_mode: str = 'HTML'):
"""Отправляет сообщение всем пользователям"""
if not ALLOWED_CHAT_IDS:
logger.warning('Нет допущенных ID чатов для отправки уведомлений')
return
for chat_id in ALLOWED_CHAT_IDS:
try:
await bot.send_message(chat_id, message_text, parse_mode=parse_mode)
logger.info(f'✅ Сообщение отправлено пользователю {chat_id}')
except Exception as e:
logger.error(f'❌ Ошибка отправки пользователю {chat_id}: {e}')
await asyncio.sleep(0.5)
async def check_schedule():
"""Проверяет график и отправляет уведомления"""
global last_schedule
try:
logger.info('🔍 Проверка графика...')
html = get_voe_html(VOE_CITY_ID, VOE_STREET_ID, VOE_HOUSE_ID)
new_schedule = parse_html(html)
if schedules_differ(last_schedule, new_schedule):
logger.info('✨ Обнаружены изменения!')
message = format_schedule_message(new_schedule, days_to_show=2)
if last_schedule is not None:
await send_to_all_users(f'🔄 <b>Графік оновлено!</b>\n\n{message}')
last_schedule = new_schedule
else:
logger.info('✓ Графік без змін')
except Exception as e:
logger.error(f'❌ Ошибка при проверке графика: {e}')
async def check_upcoming_disconnections():
"""Проверяет предстоящие события и отправляет предупреждения за 5 минут"""
global last_notification_time
if last_schedule is None:
return
now = datetime.now()
today_blocks = [b for b in last_schedule if b['day'] == 0]
# Создаем список всех переходов (off -> on или on -> off)
transitions = []
prev_status = None
for block in today_blocks:
hour = block['hour']
for half_idx, half in enumerate([block['first_half'], block['second_half']]):
minute = 0 if half_idx == 0 else 30
time_str = f'{hour:02d}:{minute:02d}'
current_status = half['status']
# Если статус изменился - это переход
if prev_status is not None and prev_status != current_status:
transitions.append(
{
'hour': hour,
'minute': minute,
'time_str': time_str,
'from_status': prev_status,
'to_status': current_status,
'confirmed': half.get('confirmed'),
'queue': half.get('queue'),
}
)
prev_status = current_status
# Проверяем переходы
for transition in transitions:
event_time = now.replace(hour=transition['hour'], minute=transition['minute'], second=0, microsecond=0)
time_until = (event_time - now).total_seconds() / 60
notification_key = f'{transition["hour"]}:{transition["minute"]}_{transition["to_status"]}'
# Если за 5 минут до события (±1 минута) и еще не отправляли
if 4 <= time_until <= 6:
# Проверяем, не отправляли ли уже уведомление сегодня
if notification_key in last_notification_time:
last_notif_time = last_notification_time[notification_key]
if last_notif_time.date() == now.date():
continue # Уже отправляли сегодня
# Переход на ОТКЛЮЧЕНИЕ (on -> off)
if transition['from_status'] == 'on' and transition['to_status'] == 'off':
icon = '🔴' if transition['confirmed'] else '🟠'
status = 'підтверджено' if transition['confirmed'] else 'можливе'
queue_info = f' (Черга {transition["queue"]})' if transition['queue'] else ''
warning = (
f'⚠️ <b>УВАГА! ВІДКЛЮЧЕННЯ</b>\n\n'
f'Через ~5 хвилин\n'
f'Час: <code>{transition["time_str"]}</code>\n'
f'Статус: {icon} {status}{queue_info}'
)
await send_to_all_users(warning)
last_notification_time[notification_key] = now
logger.info(f'📢 Відправлено попередження про ВІДКЛЮЧЕННЯ в {transition["time_str"]}')
# Переход на ВКЛЮЧЕНИЕ (off -> on)
elif transition['from_status'] == 'off' and transition['to_status'] == 'on':
warning = (
f'✅ <b>УВАГА! ВКЛЮЧЕННЯ</b>\n\n'
f'Через ~5 хвилин буде світло\n'
f'Час: <code>{transition["time_str"]}</code>'
)
await send_to_all_users(warning)
last_notification_time[notification_key] = now
logger.info(f'📢 Відправлено попередження про ВКЛЮЧЕННЯ в {transition["time_str"]}')
async def monitoring_loop():
"""Основной цикл мониторинга"""
await check_schedule()
while True:
try:
await asyncio.sleep(CHECK_INTERVAL)
await check_schedule()
await check_upcoming_disconnections()
except Exception as e:
logger.error(f'Ошибка в цикле мониторинга: {e}')
await asyncio.sleep(5)
# ============================================================================
# ОБРАБОТЧИКИ КОМАНД
# ============================================================================
@dp.message(Command('start'))
async def cmd_start(message: Message):
"""Обработчик /start"""
if message.chat.id not in ALLOWED_CHAT_IDS:
await message.answer('❌ У вас немає доступу до цього бота.')
return
await message.answer(
'👋 <b>Ласкаво просимо!</b>\n\n'
'🤖 <b>Бот для моніторингу графіку відключень світла</b>\n\n'
'✨ <b>Можливості:</b>\n'
'• 📊 Перегляд графіку на сьогодні і завтра\n'
'• 🔔 Автоматичні сповіщення за 5 хвилин до подій\n'
'• 🔄 Моніторинг змін графіку\n\n'
'Використовуйте кнопки нижче 👇',
parse_mode='HTML',
reply_markup=get_main_keyboard(),
)
@dp.message(lambda msg: msg.text == 'ℹ️ Про бота')
async def cmd_info(message: Message):
"""Показывает информацию о боте"""
if message.chat.id not in ALLOWED_CHAT_IDS:
return
await message.answer(
'<b>ℹ️ Про бота</b>\n\n'
'🚀 <b>Версія:</b> 2.2 (Стабільна)\n\n'
'📝 <b>Реліз-ноути:</b>\n'
'├ 15.11.2024: Адаптація під оновлену логіку сайту VOE\n'
'├ Виправлено парсинг half.left та half.right\n'
'├ Покращено визначення підтвердження відключень\n'
'└ Оптимізовано обробку статусу для всієї години\n\n'
'⚡ <b>Функціональність:</b>\n'
'├ Моніторинг графіку 24/7\n'
'├ Сповіщення за 5 хвилин\n'
'├ Детальна статистика дня\n'
'└ Красива візуалізація\n\n'
'🔐 <b>Безпека:</b> Використовуються .env файли\n'
'💾 <b>Джерело:</b> voe.com.ua',
parse_mode='HTML',
reply_markup=get_main_keyboard(),
)
@dp.message(Command('schedule'))
async def cmd_schedule(message: Message):
"""Показывает полный график"""
if message.chat.id not in ALLOWED_CHAT_IDS:
await message.answer('❌ У вас немає доступу.')
return
try:
await message.answer('⏳ Завантаження графіку...')
html = get_voe_html(VOE_CITY_ID, VOE_STREET_ID, VOE_HOUSE_ID)
schedule = parse_html(html)
text = format_schedule_message(schedule, days_to_show=2)
await message.answer(text, parse_mode='HTML', reply_markup=get_main_keyboard())
except Exception as e:
await message.answer(f'❌ <b>Помилка:</b> {str(e)}', parse_mode='HTML', reply_markup=get_main_keyboard())
@dp.message(Command('today'))
async def cmd_today(message: Message):
"""Показывает график на сегодня"""
if message.chat.id not in ALLOWED_CHAT_IDS:
await message.answer('❌ У вас немає доступу.')
return
try:
await message.answer('⏳ Завантаження графіку сьогодні...')
html = get_voe_html(VOE_CITY_ID, VOE_STREET_ID, VOE_HOUSE_ID)
schedule = parse_html(html)
text = format_single_day_schedule(schedule, 0)
await message.answer(text, parse_mode='HTML', reply_markup=get_main_keyboard())
except Exception as e:
await message.answer(f'❌ <b>Помилка:</b> {str(e)}', parse_mode='HTML', reply_markup=get_main_keyboard())
@dp.message(Command('tomorrow'))
async def cmd_tomorrow(message: Message):
"""Показывает график на завтра"""
if message.chat.id not in ALLOWED_CHAT_IDS:
await message.answer('❌ У вас немає доступу.')
return
try:
await message.answer('⏳ Завантаження графіку завтра...')
html = get_voe_html(VOE_CITY_ID, VOE_STREET_ID, VOE_HOUSE_ID)
schedule = parse_html(html)
text = format_single_day_schedule(schedule, 1)
await message.answer(text, parse_mode='HTML', reply_markup=get_main_keyboard())
# await message.answer("❌ Функція тимчасово недоступна. Чекаємо на оновлення сайту", parse_mode="HTML", reply_markup=get_main_keyboard())
except Exception as e:
await message.answer(f'❌ <b>Помилка:</b> {str(e)}', parse_mode='HTML', reply_markup=get_main_keyboard())
@dp.message(Command('check'))
async def cmd_check(message: Message):
"""Принудительная проверка графика"""
if message.chat.id not in ALLOWED_CHAT_IDS:
await message.answer('❌ У вас немає доступу.')
return
try:
await message.answer('🔄 <b>Перевіряю графік...</b>', parse_mode='HTML')
html = get_voe_html(VOE_CITY_ID, VOE_STREET_ID, VOE_HOUSE_ID)
new_schedule = parse_html(html)
prefix = (
'✅ <b>Знайдено зміни!</b>\n\n'
if schedules_differ(last_schedule, new_schedule)
else '✓ <b>Графік без змін</b>\n\n'
)
result = prefix + format_schedule_message(new_schedule, days_to_show=2)
await message.answer(result, parse_mode='HTML', reply_markup=get_main_keyboard())
except Exception as e:
await message.answer(f'❌ <b>Помилка:</b> {str(e)}', parse_mode='HTML', reply_markup=get_main_keyboard())
@dp.message()
async def handle_text(message: Message):
"""Обработчик текстовых сообщений и кнопок"""
if message.chat.id not in ALLOWED_CHAT_IDS:
return
text = message.text
# Кнопка "Графік"
if text == '📊 Графік':
await cmd_schedule(message)
# Кнопка "Сьогодні"
elif text == '📅 Сьогодні':
await cmd_today(message)
# Кнопка "Завтра"
elif text == '📅 Завтра':
await cmd_tomorrow(message)
# Кнопка "Оновити"
elif text == '🔄 Оновити':
await cmd_check(message)
# Кнопка "Про бота"
elif text == 'ℹ️ Про бота':
await cmd_info(message)
# Неизвестная команда
else:
await message.answer(
'❓ <b>Команда не розпізнана</b>\n\n'
'Використовуйте кнопки на клавіатурі або команди:\n'
'/start • /today • /tomorrow • /schedule • /check',
parse_mode='HTML',
reply_markup=get_main_keyboard(),
)
# ============================================================================
# ГЛАВНАЯ ФУНКЦИЯ
# ============================================================================
async def main():
"""Главная функция"""
logger.info('=' * 50)
logger.info('ЗАПУСК БОТА V2.2 (stable 2.2, 15.11.2025)')
logger.info('=' * 50)
if not TELEGRAM_TOKEN or os.getenv('TELEGRAM_TOKEN', 'YOUR_TOKEN_HERE') == TELEGRAM_TOKEN:
logger.error('❌ TELEGRAM_TOKEN не конфігурований! Напишіть токен в .env файл')
return
if not ALLOWED_CHAT_IDS:
logger.error('❌ ALLOWED_CHAT_IDS не конфігуровані! Напишіть ID в .env файл')
return
logger.info(f'📌 Allowed chat ids: {ALLOWED_CHAT_IDS}')
logger.info(f'⏱ Інтервал перевірки: {CHECK_INTERVAL} сек')
logger.info('=' * 50)
# Запускаем мониторинг
monitoring_task = asyncio.create_task(monitoring_loop())
try:
await dp.start_polling(bot)
except KeyboardInterrupt:
logger.info('⏹ Бот зупинений користувачем')
finally:
monitoring_task.cancel()
await bot.session.close()
logger.info('✓ Підключення закрито')
if __name__ == '__main__':
try:
asyncio.run(main())
except KeyboardInterrupt:
logger.info('⏹ Завершено')
+7
View File
@@ -0,0 +1,7 @@
[project]
name = "dtek-notif"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = []
+5
View File
@@ -0,0 +1,5 @@
requests>=2.31.0
beautifulsoup4>=4.12.0
aiogram>=3.3.0
python-dotenv>=1.0.0
aiohttp>=3.9.0
-52
View File
@@ -1,52 +0,0 @@
# EDU session keeper and Telegram bot
Keeps an EDU login session in Redis and sends Telegram notifications for new webinars. The bot also serves diary and schedule commands.
`phpsessid-bot/` logs into EDU and publishes `EDU_PHPSESSID` in Redis.
`webinar-checker/` uses that cookie through a remote Playwright browser and stores
subscribers, language preferences, and webinar history in Redis.
Kubernetes runs in `edu-master`, with Redis data in `redis-data-pvc`.
`service.yaml`, `servicemonitor.yaml`, and `alerts.yaml` expose and monitor the
checker's metrics on port 8000. Its `/health` endpoint reflects recent checks.
## Configuration
Use the keys in `k8s/secrets.yaml.example` as the reference. The committed Compose
`.env.example` has stale names until `fix/session-keeper-reliability` is merged.
The code reads:
| Variable | Purpose |
| ----------------------------------------------------- | ------------------------------------------------ |
| `KEEPER_LOGIN`, `KEEPER_PASSWORD` | EDU login credentials. |
| `KEEPER_INTERVAL` | Session refresh interval in minutes; default 10. |
| `EDU_URL_BASE` | EDU site origin. |
| `EDU_URL_LOGIN`, `EDU_URL_COURSES`, `EDU_URL_WEBINAR` | Paths under that origin. |
| `WEBINAR_TELEGRAM_TOKEN`, `WEBINAR_ADMIN_ID` | Telegram bot and administrator. |
| `WEBINAR_CHECK_INTERVAL` | Checker interval in seconds; default 60. |
| `REDIS_HOST`, `REDIS_PORT` | Redis connection. |
| `PLAYWRIGHT_WS` | Remote browser WebSocket endpoint. |
Set the keeper keys explicitly in the Compose `.env`. Keep the Playwright Python
package, browser image, server command, and `PLAYWRIGHT_VERSION` file on matching
versions. The two Python images are built and published by CI.
## Bot use
Start a private chat with `/start` to subscribe. `/stop`, `/language`, `/diary`,
`/schedule`, and `/setclass` manage subscriptions and school views. The
administrator can manage the whitelist with `/adduser` and `/removeuser`.
Back up Redis if subscriber settings and notification history matter. Session
cookies and Telegram tokens are credentials; keep them out of shared logs.
## Inspect
From the repository root:
```sh
kubectl get pods,svc,pvc -n edu-master
kubectl get events -n edu-master --sort-by=.metadata.creationTimestamp
```
See the [repository README](../README.md) for deployment selection.
+2 -2
View File
@@ -17,7 +17,7 @@ services:
session-keeper:
build: ./phpsessid-bot
image: gcr.forust.xyz/forust/session-keeper:prod
image: gcr.forust.xyz/forust/session-keeper:latest
pull_policy: build
env_file: .env
restart: unless-stopped
@@ -33,7 +33,7 @@ services:
webinar-checker:
build: ./webinar-checker
image: gcr.forust.xyz/forust/webinar-checker:prod
image: gcr.forust.xyz/forust/webinar-checker:latest
pull_policy: build
env_file: .env
restart: unless-stopped
-11
View File
@@ -10,8 +10,6 @@ spec:
selector:
matchLabels:
app: edu-master-playwright
strategy:
type: Recreate
template:
metadata:
labels:
@@ -22,15 +20,6 @@ spec:
# renovate: datasource=docker depName=mcr.microsoft.com/playwright versioning=docker
image: mcr.microsoft.com/playwright:v1.56.0-jammy
imagePullPolicy: IfNotPresent
# p95 412M, max 478M over 7 days, no limit before. Request is set at p95
# so the pod is not an eviction candidate; the limit stays above 2x the
# request because browser page lifetimes are unpredictable.
resources:
requests:
cpu: "200m"
memory: "416Mi"
limits:
memory: "1Gi"
command:
- npx
- -y
+2 -2
View File
@@ -28,10 +28,10 @@ spec:
resources:
requests:
cpu: 25m
memory: 32Mi
memory: 64Mi
limits:
cpu: 250m
memory: 128Mi
memory: 256Mi
readinessProbe:
exec:
command: ["redis-cli", "ping"]
+2 -4
View File
@@ -10,8 +10,6 @@ spec:
selector:
matchLabels:
app: edu-master-session-keeper
strategy:
type: Recreate
template:
metadata:
labels:
@@ -40,10 +38,10 @@ spec:
resources:
requests:
cpu: 25m
memory: 32Mi
memory: 96Mi
limits:
cpu: 250m
memory: 128Mi
memory: 256Mi
readinessProbe:
exec:
command: ["/bin/sh", "-ec", "redis-cli -h redis EXISTS EDU_PHPSESSID | grep -q 1"]
+2 -4
View File
@@ -10,8 +10,6 @@ spec:
selector:
matchLabels:
app: edu-master-webinar-checker
strategy:
type: Recreate
template:
metadata:
labels:
@@ -69,7 +67,7 @@ spec:
resources:
requests:
cpu: "50m"
memory: "192Mi"
memory: "128Mi"
limits:
cpu: "600m"
memory: "384Mi"
memory: "512Mi"
-21
View File
@@ -1,21 +0,0 @@
# Error pages
Static HTTP error pages served by an Nginx image built in CI.
Edit the HTML in `html/`; the Dockerfile copies it into the image.
Kubernetes exposes `error-pages-service` in `error-pages` for Traefik's error
middleware. Keep the middleware's namespace and port aligned with that Service.
For a local build, run `docker build -t homelab-error-pages .` from this directory.
Compose references the private registry image rather than a build context.
## Inspect
From the repository root:
```sh
kubectl get pods,svc,pvc -n error-pages
kubectl get events -n error-pages --sort-by=.metadata.creationTimestamp
```
See the [repository README](../README.md) for deployment selection.
+1 -1
View File
@@ -1,7 +1,7 @@
services:
errorpage:
build: .
image: gcr.forust.xyz/forust/error-pages:prod
image: gcr.forust.xyz/forust/error-pages:latest
pull_policy: build
container_name: error-pages
restart: unless-stopped
-9
View File
@@ -20,8 +20,6 @@ spec:
selector:
matchLabels:
app: error-pages
strategy:
type: Recreate
template:
metadata:
labels:
@@ -30,13 +28,6 @@ spec:
containers:
- name: error-pages
image: gcr.forust.xyz/forust/error-pages:prod
# p95 6M, max 10M, no limit before.
resources:
requests:
cpu: "10m"
memory: "32Mi"
limits:
memory: "128Mi"
ports:
- containerPort: 80
readinessProbe:
-25
View File
@@ -1,25 +0,0 @@
# Gitea
Git hosting with HTTP and a separate SSH route.
Kubernetes uses the shared PostgreSQL service and `gitea-pvc` for repositories
and application data. Match the Gitea database password with the shared database
Secret. SSH is routed through Traefik's TCP entrypoint on 2221.
Compose uses a separate PostgreSQL 14 database, bind mounts `gitea-data/` and
`gitea-db/`, and publishes host port 2221. It is an alternative deployment with
its own database, not a second frontend for the Kubernetes instance.
Back up repositories, application configuration, and a consistent database dump
together. Gitea Actions definitions for this repository live in `../.gitea/`.
## Inspect
From the repository root:
```sh
kubectl get pods,svc,pvc -n gitea
kubectl get events -n gitea --sort-by=.metadata.creationTimestamp
```
See the [repository README](../README.md) for deployment selection.
+3 -6
View File
@@ -1,6 +1,6 @@
services:
server:
image: docker.gitea.com/gitea:28.0.0
image: docker.gitea.com/gitea:1.27.3
container_name: gitea
restart: always
environment:
@@ -13,12 +13,9 @@ services:
- GITEA__database__PASSWD=gitea
- GITEA__database__NAME=gitea
# Server
- GITEA__server__ROOT_URL=https://git.forust.xyz
- GITEA__server__ROOT_URL=https://gitea.forust.xyz
- GITEA__server__SSH_DOMAIN=gitssh.forust.xyz
- GITEA__server__SSH_PORT=2221
# Pin 28.0 defaults explicitly (see k8s/config.yaml for rationale)
- GITEA__service__DISABLE_REGISTRATION=true
- GITEA__actions__RUN_RETENTION_DAYS=90
# Mailer
- GITEA__mailer__ENABLED=true
- GITEA__mailer__FROM=${SERVICE_EMAIL}
@@ -37,7 +34,7 @@ services:
- "traefik.http.services.gitea.loadbalancer.server.port=3000"
# Prod Router
- "traefik.http.routers.gitea.rule=Host(`git.forust.xyz`) || Host(`gitea.forust.xyz`)"
- "traefik.http.routers.gitea.rule=Host(`gitea.forust.xyz`)"
- "traefik.http.routers.gitea.entrypoints=websecure"
- "traefik.http.routers.gitea.tls.certresolver"
# Local Router
-1
View File
@@ -8,7 +8,6 @@ spec:
dnsNames:
- gcr.forust.xyz
- gitea.forust.xyz
- git.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
+2 -11
View File
@@ -4,14 +4,11 @@ metadata:
name: gitea-config
namespace: gitea
data:
GITEA__server__ROOT_URL: "https://git.forust.xyz"
GITEA__server__DOMAIN: "gitea.forust.xyz"
GITEA__server__ROOT_URL: "https://gitea.forust.xyz"
GITEA__server__SSH_DOMAIN: "gitssh.forust.xyz"
GITEA__server__SSH_PORT: "2221"
GITEA__service__DISABLE_REGISTRATION: "true"
GITEA__actions__RUN_RETENTION_DAYS: "90"
GITEA__database__DB_TYPE: "postgres"
GITEA__database__HOST: "postgres.database.svc.cluster.local:5432"
GITEA__database__NAME: "gitea"
@@ -20,12 +17,6 @@ data:
GITEA__mailer__ENABLED: "false"
# No code/issue search needed: bleve reindexes the whole issue index on
# every pod restart (cron.rebuild_issue_indexer RUN_AT_START) and hammers
# the rotational disk for an hour. "db" serves issue search from postgres.
GITEA__indexer__ISSUE_INDEXER_TYPE: "db"
GITEA__indexer__REPO_INDEXER_ENABLED: "false"
GITEA__log__logger.access.MODE: "console, file"
USER_UID: "1000"
USER_GID: "1000"
+4 -6
View File
@@ -24,8 +24,6 @@ spec:
selector:
matchLabels:
app: gitea
strategy:
type: Recreate
template:
metadata:
labels:
@@ -33,7 +31,7 @@ spec:
spec:
containers:
- name: gitea
image: gitea/gitea:28.0.0
image: gitea/gitea:1.27.3
envFrom:
- configMapRef:
name: gitea-config
@@ -49,10 +47,10 @@ spec:
mountPath: /data
resources:
requests:
memory: "320Mi"
cpu: "100m"
memory: "512Mi"
cpu: "300m"
limits:
memory: "1Gi"
memory: "1.5Gi"
cpu: "1300m"
volumes:
- name: gitea-data
+15 -2
View File
@@ -7,11 +7,24 @@ spec:
entryPoints:
- websecure
routes:
- match: Host(`gitea.forust.xyz`) || Host(`git.forust.xyz`)
- match: Host(`gitea.forust.xyz`)
kind: Rule
middlewares:
- name: crowdsec-bouncer
namespace: crowdsec
services:
- name: gitea-service
port: 3000
# Registry route: NO crowdsec-bouncer.
# The bouncer plugin does a blocking `GET /v1/decisions` to the LAPI on
# *every* request. A deploy burst (runner Action API polls, `docker
# manifest inspect` per own image, containerd pulls, smoke probes) fires
# hundreds of parallel registry calls; LAPI saturation pushed the lookup
# past the plugin timeout, and the bouncer fail-closed with 403 - which
# containerd surfaces as ErrImagePull/ImagePullBackOff on the next pod.
# This route only serves authenticated OCI traffic (registry tokens,
# basic-auth already handled by gitea) and scanners get nothing useful
# from /v2, so there is no bruteforce surface to protect here.
- match: Host(`gcr.forust.xyz`) && PathPrefix(`/v2`)
kind: Rule
services:
@@ -29,7 +42,7 @@ spec:
entryPoints:
- websecure
routes:
- match: (Host(`gitea.workstation.internal`) || Host(`gitea.gigaforust.internal`)) || (Host(`git.workstation.internal`) || Host(`git.gigaforust.internal`))
- match: Host(`gitea.workstation.internal`) || Host(`gitea.gigaforust.internal`)
kind: Rule
services:
- name: gitea-service
-26
View File
@@ -1,26 +0,0 @@
# Glance
Dashboard pages for links, service checks, and Docker containers.
Compose mounts `config/` and `assets/`. The Kubernetes equivalents are embedded
in `k8s/glance-config.yaml`: `glance-config` holds pages and `glance-assets` holds
`user.css`. Update both copies when changing shared content.
Kubernetes serves the dashboard under `/glance`. Its pod also mounts the node's
Docker socket. It references `glance-secrets` for `ADGUARD_PASSWORD`, but there is
no tracked Secret example; create that Secret in `glance` before starting it.
Compose expects a local `.env` with the same password.
The CSS mount points at the wrong ConfigMap on the reviewed main commit;
`fix/glance-assets` corrects it.
## Inspect
From the repository root:
```sh
kubectl get pods,svc,pvc -n glance
kubectl get events -n glance --sort-by=.metadata.creationTimestamp
```
See the [repository README](../README.md) for deployment selection.
File renamed without changes.
+2 -2
View File
@@ -177,8 +177,8 @@ data:
# url: https://gitssh.forust.xyz
# - title: gcr.forust.xyz
# url: https://gcr.forust.xyz/v2/
- title: git.forust.xyz
url: https://git.forust.xyz
- title: gitea.forust.xyz
url: https://gitea.forust.xyz
- title: nextcloud.forust.xyz
url: https://nextcloud.forust.xyz
- title: mc.forust.xyz
+2 -4
View File
@@ -20,8 +20,6 @@ spec:
selector:
matchLabels:
app: glance
strategy:
type: Recreate
template:
metadata:
labels:
@@ -59,10 +57,10 @@ spec:
resources:
requests:
cpu: "50m"
memory: "32Mi"
memory: "64Mi"
limits:
cpu: "200m"
memory: "128Mi"
memory: "256Mi"
volumes:
- name: glance-config
configMap:
-18
View File
@@ -1,18 +0,0 @@
# Headscale
Headscale, Headplane, and a separate web administration UI on Docker.
Kubernetes only provides routes to the Docker host. Update the addresses in
`k8s/routing/external-service.yaml` if the host moves.
Copy `config/headscale.yaml.example`, `config/headplane.yaml.example`, and
`config/policy.json.example` to their names without `.example`. Set the public
server URL, DNS settings, Headplane cookie secret, and Headscale public URL.
The example URLs are placeholders.
Compose publishes Headscale on 18080, its metrics port on 19090, Headplane on
13000, and the other UI on 10080. The data volumes store the Headscale database,
keys, and Headplane state. The embedded DERP configuration needs reachable
addresses; Compose does not publish its UDP 3478 listener.
See the [repository README](../README.md) for deployment selection.
+8
View File
@@ -23,11 +23,17 @@ spec:
port: 8080
- match: Host(`hs.forust.xyz`) && PathPrefix(`/admin`)
kind: Rule
middlewares:
- name: crowdsec-bouncer
namespace: crowdsec
services:
- name: headscale-ui-external
port: 80
- match: Host(`hs.forust.xyz`) && PathPrefix(`/metrics`)
kind: Rule
middlewares:
- name: crowdsec-bouncer
namespace: crowdsec
services:
- name: headscale-server-external
port: 9090
@@ -47,6 +53,8 @@ spec:
kind: Rule
middlewares:
- name: headplane-prefix
- name: crowdsec-bouncer
namespace: crowdsec
services:
- name: headplane-external
port: 3000
-4
View File
@@ -1,4 +0,0 @@
SECRET_ENCRYPTION_KEY="REPLACE_ME"
TZ="Europe/Bratislava"
PUID="1000"
PGID="1000"
-24
View File
@@ -1,24 +0,0 @@
# Homarr
Dashboard with Kubernetes integration and persistent application state.
Kubernetes uses the `homarr` ServiceAccount and the read-only ClusterRole in
`k8s/rbac.yaml`. Application data lives in `homarr-pvc`; supply the encryption key
from `k8s/secrets.yaml.example` before the first start and retain it with backups.
The committed ingress is internal. There is no `k8s/active` marker even though
manifests exist, so the workflow does not select Homarr automatically.
Compose publishes ports 80 and 81, mounts appdata and the Docker socket, and
expects a local kubeconfig. Check these host ports against Traefik before use.
## Inspect
From the repository root:
```sh
kubectl get pods,svc,pvc -n homarr
kubectl get events -n homarr --sort-by=.metadata.creationTimestamp
```
See the [repository README](../README.md) for deployment selection.
-38
View File
@@ -1,38 +0,0 @@
services:
homarr:
container_name: homarr
image: ghcr.io/homarr-labs/homarr:v2.1.2
restart: unless-stopped
volumes:
- ./appdata:/appdata
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./kubeconfig:/app/config/kubeconfig:ro
env_file: .env
ports:
- 80:7575
- 81:3000
environment:
- TZ=${TZ:-Europe/Bratislava}
- TURBO_TELEMETRY_DISABLED=1
- KUBECONFIG=/app/config/kubeconfig
labels:
- "traefik.enable=true"
- "traefik.http.services.homarr.loadbalancer.server.port=7575"
# Prod Router
- "traefik.http.routers.homarr.rule=Host(`homarr.forust.xyz`)"
- "traefik.http.routers.homarr.entrypoints=websecure"
- "traefik.http.routers.homarr.tls.certresolver=letsencrypt"
# Local Router
- "traefik.http.routers.homarr-local.rule=Host(`homarr.workstation.internal`)"
- "traefik.http.routers.homarr-local.entrypoints=websecure"
- "traefik.http.routers.homarr-local.tls=true"
# Dev Router
- "traefik.http.routers.homarr-dev.rule=Host(`homarr.gigaforust.internal`)"
- "traefik.http.routers.homarr-dev.entrypoints=websecure"
- "traefik.http.routers.homarr-dev.tls=true"
networks:
- proxy
networks:
proxy:
external: true
-28
View File
@@ -1,28 +0,0 @@
# apiVersion: cert-manager.io/v1
# kind: Certificate
# metadata:
# name: home-prod-tls
# namespace: homarr
# spec:
# secretName: home-prod-tls
# dnsNames:
# - home.forust.xyz
# issuerRef:
# name: letsencrypt-prod
# kind: ClusterIssuer
# ---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: homarr
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
-9
View File
@@ -1,9 +0,0 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: homarr-config
namespace: homarr
data:
TZ: "Europe/Bratislava"
TURBO_TELEMETRY_DISABLED: "1"
ENABLE_KUBERNETES: "true"
-81
View File
@@ -1,81 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: homarr-service
namespace: homarr
spec:
selector:
app: homarr
ports:
- port: 7575
targetPort: 7575
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: homarr-deployment
namespace: homarr
spec:
replicas: 1
selector:
matchLabels:
app: homarr
strategy:
type: Recreate
template:
metadata:
labels:
app: homarr
spec:
serviceAccountName: homarr
containers:
- name: homarr
image: ghcr.io/homarr-labs/homarr:v2.1.2
envFrom:
- configMapRef:
name: homarr-config
- secretRef:
name: homarr-secrets
ports:
- containerPort: 7575
readinessProbe:
httpGet:
path: /
port: 7575
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 6
livenessProbe:
httpGet:
path: /
port: 7575
initialDelaySeconds: 60
periodSeconds: 30
failureThreshold: 3
volumeMounts:
- name: homarr-data
mountPath: /appdata
resources:
requests:
cpu: "250m"
memory: "350Mi"
limits:
cpu: "500m"
memory: "700Mi"
volumes:
- name: homarr-data
persistentVolumeClaim:
claimName: homarr-pvc
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: homarr-pvc
namespace: homarr
spec:
resources:
requests:
storage: 2Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
-33
View File
@@ -1,33 +0,0 @@
# apiVersion: traefik.io/v1alpha1
# kind: IngressRoute
# metadata:
# name: homarr-prod
# namespace: homarr
# spec:
# entryPoints:
# - websecure
# routes:
# - match: Host(`home.forust.xyz`)
# kind: Rule
# services:
# - name: homarr-service
# port: 7575
# tls:
# secretName: home-prod-tls
# ---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: homarr-local
namespace: homarr
spec:
entryPoints:
- websecure
routes:
- match: Host(`home.workstation.internal`) || Host(`home.gigaforust.internal`)
kind: Rule
services:
- name: homarr-service
port: 7575
tls:
secretName: internal-wildcard-tls
-4
View File
@@ -1,4 +0,0 @@
apiVersion: v1
kind: Namespace
metadata:
name: homarr
-58
View File
@@ -1,58 +0,0 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: homarr
namespace: homarr
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: homarr-readonly
rules:
- apiGroups: [""]
resources:
- pods
- services
- endpoints
- namespaces
- nodes
- configmaps
- persistentvolumeclaims
- events
verbs: ["get", "list", "watch"]
- apiGroups: ["apps"]
resources:
- deployments
- statefulsets
- daemonsets
- replicasets
verbs: ["get", "list", "watch"]
- apiGroups: ["networking.k8s.io"]
resources:
- ingresses
verbs: ["get", "list", "watch"]
- apiGroups: ["traefik.io"]
resources:
- ingressroutes
- ingressroutetcps
- ingressrouteudps
- middlewares
verbs: ["get", "list", "watch"]
- apiGroups: ["metrics.k8s.io"]
resources:
- pods
- nodes
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: homarr-readonly
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: homarr-readonly
subjects:
- kind: ServiceAccount
name: homarr
namespace: homarr
-9
View File
@@ -1,9 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: homarr-secrets
namespace: homarr
type: Opaque
stringData:
# openssl rand -hex 32
SECRET_ENCRYPTION_KEY: "REPLACE_ME"
-25
View File
@@ -1,25 +0,0 @@
# Homepages
Two static sites: Forust and xdfnx.
The site sources are in `forust_files/` and `xdfnx_files/`. CI builds each with
its own Dockerfile and publishes it to the private registry. Kubernetes serves
the image contents; Compose overlays the source directories as bind mounts.
Both Traefik IngressRoute and Gateway API route manifests are committed.
Keep their hostnames and backend Services aligned when changing routes.
Certificate resources cover public and internal hostnames.
Build either site locally with `docker build -f Dockerfile.forust .` or
`docker build -f Dockerfile.xdfnx .` from this directory.
## Inspect
From the repository root:
```sh
kubectl get pods,svc,pvc -n homepages
kubectl get events -n homepages --sort-by=.metadata.creationTimestamp
```
See the [repository README](../README.md) for deployment selection.
+2 -2
View File
@@ -3,7 +3,7 @@ services:
build:
context: .
dockerfile: Dockerfile.forust
image: gcr.forust.xyz/forust/forust-homepage:prod
image: gcr.forust.xyz/forust/forust-homepage:latest
pull_policy: build
# ports:
# - "8085:80"
@@ -35,7 +35,7 @@ services:
build:
context: .
dockerfile: Dockerfile.xdfnx
image: gcr.forust.xyz/forust/xdfnx-homepage:prod
image: gcr.forust.xyz/forust/xdfnx-homepage:latest
pull_policy: build
restart: unless-stopped
# ports:
+1 -1
View File
@@ -174,7 +174,7 @@
<h2>./projects</h2>
<ul class="repo-list">
<li>
<a href="https://git.forust.xyz/forust/gosleep" target="_blank">forust/gosleep</a>
<a href="https://gitea.forust.xyz/forust/gosleep" target="_blank">forust/gosleep</a>
<span class="comment">// linux sleep timer written in rust (originally in go)</span>
</li>
</ul>
-31
View File
@@ -1,31 +0,0 @@
# Gateway API PoC for homepages. Lives next to the TLS secrets so
# certificateRefs stay same-namespace and no ReferenceGrant is needed.
# Listener ports must match the Traefik entryPoints (80/443),
# otherwise Traefik marks the listener Invalid.
# Local .internal hosts are deliberately left on IngressRoute,
# only prod is migrated here.
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: homepages
namespace: homepages
spec:
gatewayClassName: traefik
listeners:
- name: http
protocol: HTTP
port: 80
allowedRoutes:
namespaces:
from: Same
- name: https
protocol: HTTPS
port: 443
tls:
mode: Terminate
certificateRefs:
- name: forust-homepage-prod-tls
- name: xdfnx-homepage-prod-tls
allowedRoutes:
namespaces:
from: Same
+4 -8
View File
@@ -20,8 +20,6 @@ spec:
selector:
matchLabels:
app: forust-homepage
strategy:
type: Recreate
template:
metadata:
labels:
@@ -41,10 +39,10 @@ spec:
failureThreshold: 3
resources:
requests:
memory: "32Mi"
memory: "10Mi"
cpu: "20m"
limits:
memory: "128Mi"
memory: "100Mi"
cpu: "50m"
---
apiVersion: v1
@@ -69,8 +67,6 @@ spec:
selector:
matchLabels:
app: xdfnx-homepage
strategy:
type: Recreate
template:
metadata:
labels:
@@ -90,8 +86,8 @@ spec:
failureThreshold: 3
resources:
requests:
memory: "32Mi"
memory: "10Mi"
cpu: "20m"
limits:
memory: "128Mi"
memory: "100Mi"
cpu: "50m"
-69
View File
@@ -1,69 +0,0 @@
# PoC: homepages prod hosts via Gateway API.
# Runs alongside k8s/ingress.yaml - delete the prod IngressRoutes only after verification.
# There are no local .internal hosts here, they stay on the local IngressRoute.
# No per-route security middlewares: L3 enforcement moved to the host
# firewall bouncer, so HTTPRoutes stay clean.
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: homepages-http-redirect
namespace: homepages
spec:
parentRefs:
- name: homepages
kind: Gateway
sectionName: http
hostnames:
- forust.xyz
- www.forust.xyz
- xdfnx.cfd
rules:
- filters:
- type: RequestRedirect
requestRedirect:
scheme: https
statusCode: 301
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: forust-homepage-https
namespace: homepages
spec:
parentRefs:
- name: homepages
kind: Gateway
sectionName: https
hostnames:
- forust.xyz
- www.forust.xyz
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: forust-homepage-service
port: 80
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: xdfnx-homepage-https
namespace: homepages
spec:
parentRefs:
- name: homepages
kind: Gateway
sectionName: https
hostnames:
- xdfnx.cfd
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: xdfnx-homepage-service
port: 80
+6
View File
@@ -9,6 +9,9 @@ spec:
routes:
- match: Host(`forust.xyz`) || Host(`www.forust.xyz`)
kind: Rule
middlewares:
- name: crowdsec-bouncer
namespace: crowdsec
priority: 10
services:
- name: forust-homepage-service
@@ -46,6 +49,9 @@ spec:
routes:
- match: Host(`xdfnx.cfd`)
kind: Rule
middlewares:
- name: crowdsec-bouncer
namespace: crowdsec
services:
- name: xdfnx-homepage-service
port: 80
-24
View File
@@ -1,24 +0,0 @@
# You can find documentation for all the supported env variables at https://docs.immich.app/install/environment-variables
# The location where your uploaded files are stored. The k8s manifests bind
# mount /mnt/immich/library, which is the sdc9 partition - the same place, so
# the two deployment paths look at one library.
UPLOAD_LOCATION=/mnt/immich/library
# The location where your database files are stored. Network shares are not supported for the database
DB_DATA_LOCATION=./postgres
# To set a timezone, uncomment the next line and change Etc/UTC to a TZ identifier from this list: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List
# TZ=Etc/UTC
# The Immich version to use. You can pin this to a specific version like "v2.1.0"
IMMICH_VERSION=v3
# Connection secret for postgres. You should change it to a random password
# Please use only the characters `A-Za-z0-9`, without special characters or spaces
DB_PASSWORD=postgres
# The values below this line do not need to be changed
###################################################################################
DB_USERNAME=postgres
DB_DATABASE_NAME=immich
-29
View File
@@ -1,29 +0,0 @@
# Immich
Photo library with its own vector-enabled PostgreSQL and machine-learning service.
This database is separate from the shared PostgreSQL instance. Keep the server
and machine-learning versions aligned when upgrading.
Kubernetes bind-mounts `/mnt/immich/library` from the node. That directory must
already exist and contain the intended library; moving the pod to a different
node does not move the files. PostgreSQL and Valkey use StatefulSet storage, and
the model cache has its own PVC.
Compose reads `UPLOAD_LOCATION` and `DB_DATA_LOCATION` from `.env`. The example
uses the same library path as Kubernetes. Run one writer against that library;
do not start both deployments as independent instances over the same files.
Back up the library and a consistent database dump together. The model cache
can be rebuilt; the photo database cannot.
## Inspect
From the repository root:
```sh
kubectl get pods,svc,pvc -n immich
kubectl get events -n immich --sort-by=.metadata.creationTimestamp
```
See the [repository README](../README.md) for deployment selection.
-63
View File
@@ -1,63 +0,0 @@
name: immich
services:
immich-server:
container_name: immich_server
image: ghcr.io/immich-app/immich-server:v3
volumes:
- ${UPLOAD_LOCATION}:/data
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
ports:
- "2283:2283"
depends_on:
- redis
- database
restart: always
healthcheck:
disable: false
immich-machine-learning:
container_name: immich_machine_learning
# For hardware acceleration, add one of -[armnn, cuda, rocm, openvino, rknn] to the image tag.
# Example tag: ${IMMICH_VERSION:-release}-cuda
image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}
# extends: # uncomment this section for hardware acceleration - see https://docs.immich.app/features/ml-hardware-acceleration
# file: hwaccel.ml.yml
# service: cpu # set to one of [armnn, cuda, rocm, openvino, openvino-wsl, rknn] for accelerated inference - use the `-wsl` version for WSL2 where applicable
volumes:
- model-cache:/cache
env_file:
- .env
restart: always
healthcheck:
disable: false
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:418652cfb58ef879d4978c33553735d7147016032d5aefaa14c828e611eb9dfd
healthcheck:
test: redis-cli ping | grep -q PONG || exit 1
restart: always
database:
container_name: immich_postgres
image: ghcr.io/immich-app/postgres:16-vectorchord0.4.3-pgvectors0.2.0@sha256:1a078b237c1d9b420b0ee59147386b4aa60d3a07a8e6a402fc84a57e41b043a4
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME}
POSTGRES_INITDB_ARGS: "--data-checksums"
# Uncomment the DB_STORAGE_TYPE: 'HDD' var if your database isn't stored on SSDs
# DB_STORAGE_TYPE: 'HDD'
volumes:
# Do not edit the next line. If you want to change the database storage location on your system, edit the value of DB_DATA_LOCATION in the .env file
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
shm_size: 128mb
restart: always
healthcheck:
disable: false
volumes:
model-cache:
-24
View File
@@ -1,24 +0,0 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: immich-config
namespace: immich
data:
TZ: "Europe/Bratislava"
# The database in this namespace, not the shared one in the database
# namespace: v3 needs VectorChord, and only the dedicated image carries it.
DB_HOSTNAME: "immich-postgres"
DB_PORT: "5432"
DB_USERNAME: "immich"
DB_DATABASE_NAME: "immich"
DB_SSL_MODE: "disable"
DB_VECTOR_EXTENSION: "vectorchord"
REDIS_HOSTNAME: "immich-valkey"
REDIS_PORT: "6379"
# Traefik is the only client of the server, and it is a pod: the address immich
# sees is inside the node's pod CIDR. Without this the server does not trust
# X-Forwarded-For and every request looks like it came from Traefik itself.
IMMICH_TRUSTED_PROXIES: "10.244.0.0/24"
-95
View File
@@ -1,95 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: immich-service
namespace: immich
spec:
selector:
app: immich
ports:
- name: http
port: 2283
targetPort: 2283
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: immich-deployment
namespace: immich
labels:
app: immich
spec:
replicas: 2
selector:
matchLabels:
app: immich
template:
metadata:
labels:
app: immich
spec:
containers:
- name: immich
image: ghcr.io/immich-app/immich-server:v3
envFrom:
- configMapRef:
name: immich-config
- secretRef:
name: immich-secrets
ports:
- name: http
containerPort: 2283
volumeMounts:
- name: immich-data
mountPath: /data
# The first boot runs migrations and warms the transcoder, which can
# take minutes, so liveness has to wait on the startup probe.
startupProbe:
httpGet:
path: /api/server/ping
port: http
failureThreshold: 60
periodSeconds: 10
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /api/server/ping
port: http
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
httpGet:
path: /api/server/ping
port: http
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
# Only the request is scheduled against, and the node is already
# oversubscribed (5.58 of 6 cores requested) while actually running
# at about 1.5. So the request states what this sits at while idle -
# tens of millicores - and the limit leaves room for the burst that
# matters: thumbnails, transcodes and metadata extraction.
#
# The limit used to be 2Gi, but the server OOMKilled on boot while
# chewing through a backlog of unprocessed assets (API +Workers in
# one container spike well past idle).
resources:
requests:
cpu: "100m"
memory: "512Mi"
limits:
cpu: "1500m"
memory: "4Gi"
volumes:
- name: immich-data
# The library lives on the node's own disk, not in a PVC. A PVC here
# meant declaring a size up front for data that does not exist yet,
# on a provisioner that cannot grow it, and the only copy of the
# photos was one `kubectl delete namespace` away.
#
# Directory, not DirectoryOrCreate, on purpose: if sdc9 is not
# mounted, this must fail loudly instead of quietly writing the
# library onto the root filesystem.
hostPath:
path: /mnt/immich/library
type: Directory
-33
View File
@@ -1,33 +0,0 @@
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: immich-prod
namespace: immich
spec:
entryPoints:
- websecure
routes:
- match: Host(`immich.forust.xyz`)
kind: Rule
services:
- name: immich-service
port: 2283
tls:
secretName: immich-prod-tls
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: immich-local
namespace: immich
spec:
entryPoints:
- websecure
routes:
- match: Host(`immich.workstation.internal`) || Host(`immich.gigaforust.internal`)
kind: Rule
services:
- name: immich-service
port: 2283
tls:
secretName: internal-wildcard-tls
-94
View File
@@ -1,94 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: immich-machine-learning
namespace: immich
spec:
selector:
app: immich-machine-learning
ports:
- name: http
port: 3003
targetPort: 3003
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: immich-machine-learning-deployment
namespace: immich
labels:
app: immich-machine-learning
spec:
replicas: 1
selector:
matchLabels:
app: immich-machine-learning
strategy:
type: Recreate
template:
metadata:
labels:
app: immich-machine-learning
spec:
containers:
- name: immich-machine-learning
image: ghcr.io/immich-app/immich-machine-learning:v3
envFrom:
- configMapRef:
name: immich-config
- secretRef:
name: immich-secrets
ports:
- name: http
containerPort: 3003
volumeMounts:
- name: model-cache
mountPath: /cache
# The first request pulls a model over the internet, so a cold start
# is slower than a container start.
startupProbe:
httpGet:
path: /ping
port: http
failureThreshold: 60
periodSeconds: 5
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /ping
port: http
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
httpGet:
path: /ping
port: http
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
# Same reasoning as the server: the request covers the idle cost
# only, because the node has no spare cores to schedule against.
# Recognition is the burst - a busy import wants both cores.
resources:
requests:
cpu: "100m"
memory: "1Gi"
limits:
cpu: "2000m"
memory: "3Gi"
volumes:
- name: model-cache
persistentVolumeClaim:
claimName: immich-model-cache-pvc
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: immich-model-cache-pvc
namespace: immich
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Gi
-5
View File
@@ -1,5 +0,0 @@
# yaml-language-server: $schema=kubernetes
apiVersion: v1
kind: Namespace
metadata:
name: immich
-145
View File
@@ -1,145 +0,0 @@
# Immich's own database, separate from the shared postgres in the database
# namespace. It has to be separate: v3 checks the VectorChord version at startup
# and refuses to boot without it, VectorChord needs its .so in
# shared_preload_libraries, and that can only be read when postmaster starts.
# So the shared instance would have to be rebuilt on a custom image carrying
# vchord and restarted - for every consumer of it (authentik, gitea, netbox,
# netronome, penpot, statuspage). Not worth it for one photo library.
apiVersion: v1
kind: Service
metadata:
name: immich-postgres
namespace: immich
labels:
app: immich-postgres
spec:
selector:
app: immich-postgres
ports:
- name: postgres
port: 5432
targetPort: postgres
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: immich-postgres
namespace: immich
labels:
app: immich-postgres
spec:
serviceName: immich-postgres
replicas: 1
selector:
matchLabels:
app: immich-postgres
template:
metadata:
labels:
app: immich-postgres
spec:
containers:
- name: postgres
# v3.x expects vchord for its vector work and vectors (pgvecto.rs)
# for some index types. This image ships both and preloads them, plus
# its own shared_buffers and wal settings, through
# /etc/postgresql/postgresql.conf - which its entrypoint reaches via
# `postgres -c config_file=...` in the image CMD.
#
# So there is deliberately no `command:` here. Overriding it replaces
# that config_file, and it also loses the step where the entrypoint
# drops from root to the postgres user: postmaster then starts as
# root and refuses to run.
image: ghcr.io/immich-app/postgres:16-vectorchord0.4.3-pgvectors0.2.0@sha256:1a078b237c1d9b420b0ee59147386b4aa60d3a07a8e6a402fc84a57e41b043a4
env:
- name: POSTGRES_USER
value: immich
- name: POSTGRES_DB
value: immich
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: immich-secrets
key: DB_PASSWORD
# Only read when the data directory is empty, so the checksums are
# decided here and never again.
- name: POSTGRES_INITDB_ARGS
value: --data-checksums
# The postgres-data volume lives on sdc, which is rotational. The
# SSD template is the default; HDD only changes the planner costs
# (effective_io_concurrency, random_page_cost), nothing structural.
- name: DB_STORAGE_TYPE
value: HDD
- name: TZ
valueFrom:
configMapKeyRef:
name: immich-config
key: TZ
ports:
- name: postgres
containerPort: 5432
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
# The upstream compose file asks docker for 128mb of shm. Kubernetes
# gives every container 64mb, which is not what postmaster expects
# for parallel query workers and the WAL writer.
- name: shm
mountPath: /dev/shm
# Probes use a generous timeout on purpose: the data lives on a
# rotational disk on a loaded single node, and pg_isready can take
# seconds during WAL recovery. A 1s timeout kills the container
# mid-recovery and restarts the spiral.
#
# Budgets are sized for HDD stalls, not for a healthy disk: fsync of
# a single file was observed taking 70s under node IO pressure, so
# the startup budget is 15 minutes and liveness tolerates 5 minutes
# of unresponsiveness. Killing a stalled-but-healthy postmaster only
# buys another full WAL replay, which is more IO, not less.
startupProbe:
exec:
command: ["sh", "-c", "pg_isready -U immich -d immich"]
failureThreshold: 180
periodSeconds: 5
timeoutSeconds: 5
readinessProbe:
exec:
command: ["sh", "-c", "pg_isready -U immich -d immich"]
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
exec:
command: ["sh", "-c", "pg_isready -U immich -d immich"]
initialDelaySeconds: 30
periodSeconds: 60
timeoutSeconds: 10
failureThreshold: 5
# The image template sets shared_buffers to 512MB, and the vchord and
# vectors workers are Rust binaries with a real RSS footprint on top
# of postmaster, checkpointer and friends. 1Gi was enough to start
# the server but the vectors worker kept dying in it, so the limit
# sits at 2Gi. The request stays at the idle cost.
resources:
requests:
cpu: "50m"
memory: "256Mi"
limits:
cpu: "1000m"
memory: "2Gi"
volumes:
- name: shm
emptyDir:
medium: Memory
sizeLimit: 128Mi
volumeClaimTemplates:
- metadata:
name: postgres-data
spec:
accessModes: ["ReadWriteOnce"]
# Retain: this is the metadata for a library that only exists in one
# place, and local-path cannot expand a bound volume, so this size has
# to hold until the library is rebuilt or dumped elsewhere.
storageClassName: local-path-retain
resources:
requests:
storage: 32Gi
-13
View File
@@ -1,13 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: immich-secrets
namespace: immich
type: Opaque
stringData:
# Creates the immich superuser in this namespace's own postgres on first
# boot, and is the same value the server connects with. Nothing outside the
# immich namespace needs it. Letters and digits only: immich reads this into
# a connection string.
DB_PASSWORD: "changeme"
REDIS_PASSWORD: "changeme"
-84
View File
@@ -1,84 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: immich-valkey
namespace: immich
labels:
app: immich-valkey
spec:
clusterIP: None
selector:
app: immich-valkey
ports:
- name: valkey
port: 6379
targetPort: valkey
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: immich-valkey
namespace: immich
labels:
app: immich-valkey
spec:
serviceName: immich-valkey
replicas: 1
selector:
matchLabels:
app: immich-valkey
template:
metadata:
labels:
app: immich-valkey
spec:
containers:
- name: valkey
image: docker.io/valkey/valkey:9.1.2-alpine
command:
- sh
- -c
- valkey-server --appendonly yes --save 30 1 --loglevel warning --requirepass "$REDIS_PASSWORD"
envFrom:
- secretRef:
name: immich-secrets
ports:
- name: valkey
containerPort: 6379
volumeMounts:
- name: valkey-data
mountPath: /data
# Same reasoning as postgres: 1s probe timeouts flap on a loaded
# single node with rotational storage.
startupProbe:
exec:
command: ["sh", "-c", 'valkey-cli --pass "$REDIS_PASSWORD" ping | grep -q PONG']
failureThreshold: 20
periodSeconds: 5
timeoutSeconds: 5
readinessProbe:
exec:
command: ["sh", "-c", 'valkey-cli --pass "$REDIS_PASSWORD" ping | grep -q PONG']
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
exec:
command: ["sh", "-c", 'valkey-cli --pass "$REDIS_PASSWORD" ping | grep -q PONG']
initialDelaySeconds: 20
periodSeconds: 20
timeoutSeconds: 5
resources:
requests:
cpu: "25m"
memory: "64Mi"
limits:
cpu: "250m"
memory: "256Mi"
volumeClaimTemplates:
- metadata:
name: valkey-data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi
-21
View File
@@ -1,21 +0,0 @@
# Kener
Status page with Redis and persistent database and upload directories.
Kubernetes uses `kener-db-pvc`, `kener-uploads-pvc`, and a Redis StatefulSet.
Compose keeps the corresponding directories in named volumes. Set the signing
and other credentials from the env or Secret example.
The monitors and route settings live in `k8s/config.yaml` and `k8s/ingress.yaml`.
There is no active marker for either runtime.
## Inspect
From the repository root:
```sh
kubectl get pods,svc,pvc -n kener
kubectl get events -n kener --sort-by=.metadata.creationTimestamp
```
See the [repository README](../README.md) for deployment selection.
+3
View File
@@ -9,6 +9,9 @@ spec:
routes:
- match: Host(`status.forust.xyz`)
kind: Rule
middlewares:
- name: crowdsec-bouncer
namespace: crowdsec
services:
- name: kener-service
port: 3000
-2
View File
@@ -20,8 +20,6 @@ spec:
selector:
matchLabels:
app: kener
strategy:
type: Recreate
template:
metadata:
labels:
-16
View File
@@ -1,16 +0,0 @@
# Loki and Alloy
Loki log storage and Alloy collection, both deployed through Helm.
The deploy library lists separate `loki` and `alloy` releases in `prometheus`,
controlled by this directory's `k8s/active` marker. Chart versions are pinned in
`deploy-lib.sh`; settings live in `loki-values.yaml` and `alloy-values.yaml`.
Alloy collects Kubernetes logs. Grafana's Loki datasource is configured in the
monitoring stack. Review Loki retention and storage settings before enabling
collection on a new cluster.
Check releases with `helm list -n prometheus` and inspect collector logs before
assuming that an empty Grafana query means there were no events.
See the [repository README](../README.md) for deployment selection.
+4 -18
View File
@@ -7,32 +7,18 @@
controller:
type: daemonset
# config-reloader sidecar: p95 33M, max 43M. The chart keeps it at the top level,
# not under `alloy:`.
configReloader:
resources:
requests:
memory: "32Mi"
cpu: "10m"
limits:
memory: "128Mi"
cpu: "50m"
limits:
memory: "512Mi"
cpu: "500m"
image:
tag: "v1.19.2"
alloy:
# p95 275M, max 287M. Alloy tails every pod log and ships it to Loki, so it sits
# on the same IronWolf read path the node is I/O bound on. Request is set at p95.
# The chart key is `alloy.resources`. `controller.resources` is ignored silently,
# which is why this pod shipped with no limits at all.
resources:
requests:
memory: "288Mi"
cpu: "50m"
limits:
memory: "512Mi"
configMap:
create: true
content: |
+4 -27
View File
@@ -39,16 +39,6 @@ loki:
local:
directory: /var/loki/rules
# p95 84M, max 85M for the rules sidecar that shares the singleBinary pod.
# The chart exposes it as `sidecar.resources`, shared with any other sidecar.
sidecar:
resources:
requests:
memory: "96Mi"
cpu: "10m"
limits:
memory: "192Mi"
singleBinary:
replicas: 1
persistence:
@@ -57,10 +47,10 @@ singleBinary:
storageClass: local-path-retain
resources:
requests:
memory: "256Mi"
memory: "512Mi"
cpu: "200m"
limits:
memory: "1Gi"
memory: "2Gi"
cpu: "1000m"
# Zeroed: unused in SingleBinary mode (chart validation requires it).
@@ -73,25 +63,12 @@ backend:
gateway:
replicas: 1
# Single node: chart default is required podAntiAffinity on hostname +
# RollingUpdate 25%/25% (effective maxUnavailable=0 at replicas=1).
# That deadlocks the rollout: the new pod stays Unschedulable while the
# old one lives, and the old one never leaves while the new one is not
# Ready. Null clears the default (an empty map would deep-merge with it
# and keep the required rule); maxUnavailable=1 allows a brief gateway
# outage during rollouts instead of a stuck deploy.
affinity: null
deploymentStrategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 1
resources:
requests:
memory: "32Mi"
memory: "64Mi"
cpu: "50m"
limits:
memory: "128Mi"
memory: "256Mi"
cpu: "300m"
monitoring:
-21
View File
@@ -1,21 +0,0 @@
# MeTube
Web downloader behind Traefik.
Compose bind-mounts `MeTube_downloads/` on the host. Kubernetes uses a 20 GiB
`emptyDir` for `/downloads`: completed downloads disappear when the pod is
replaced. Download files from the UI promptly if this temporary storage is intended.
Application settings are in `k8s/config.yaml`. Persisting downloads in Kubernetes
would require changing the volume to a PVC and choosing a storage policy.
## Inspect
From the repository root:
```sh
kubectl get pods,svc,pvc -n metube
kubectl get events -n metube --sort-by=.metadata.creationTimestamp
```
See the [repository README](../README.md) for deployment selection.
Loaded 100 of 349 files, more files were not shown because too many files have changed in this diff. Show more