Compare commits

..
Author SHA1 Message Date
renovate-bot b835b5ce97 chore(deps): update renovate/renovate docker tag to v44.104.2
ci / lint-prettier (push) Successful in 3s
ci / lint-ruff (push) Successful in 2s
ci / lint-yaml (push) Successful in 3s
ci / lint-dockerfiles (push) Successful in 1s
ci / validate (push) Successful in 2s
ci / lint-prettier (pull_request) Successful in 3s
ci / lint-ruff (pull_request) Successful in 1s
ci / lint-yaml (pull_request) Successful in 2s
ci / lint-dockerfiles (pull_request) Successful in 2s
ci / validate (pull_request) Successful in 1s
renovate-ci / validate-renovate (pull_request) Successful in 12s
ci / build (push) Has been skipped
ci / build (pull_request) Has been skipped
ci / deploy-userbot-panel (push) Has been skipped
ci / deploy-userbot-panel (pull_request) Has been skipped
2026-09-21 04:18:00 +00:00
79 changed files with 296 additions and 1538 deletions

No files matched your search

+20
View File
@@ -347,3 +347,23 @@ jobs:
;; ;;
esac esac
done done
deploy-userbot-panel:
needs: build
if: github.ref_name == 'main' && contains(needs.build.outputs.services, 'userbot')
runs-on: [self-hosted, linux, arch, homelab, prod]
steps:
- name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Apply and roll out userbot panel
shell: bash
run: |
kubectl apply -f userbot/k8s/base/panel.yaml
kubectl get secret userbot-common-secrets -n default -o json \
| jq 'del(.metadata.annotations,.metadata.creationTimestamp,.metadata.resourceVersion,.metadata.uid,.metadata.managedFields) | .metadata.namespace = "userbot"' \
| kubectl apply -f -
# Keep legacy deployments (forust/anna) in sync with manifests; they have no replicas field, so apply leaves scaling to the user manager only.
kubectl apply -f userbot/k8s/base/userbots.yaml
kubectl rollout restart deployment/userbot-panel -n userbot
kubectl rollout status deployment/userbot-panel -n userbot --timeout=180s
+42 -173
View File
@@ -1,9 +1,6 @@
name: deploy name: deploy
on: on:
push:
branches:
- main
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
@@ -22,6 +19,9 @@ jobs:
DEPLOY_USER: ${{ secrets.DEPLOY_USER }} DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }} DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }} DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
# Set APPLY_PRUNE=true to enable kubectl apply --prune. Requires every
# manifest to carry label app.kubernetes.io/managed-by=homelab-deploy,
# otherwise previously applied resources get deleted on the next run.
APPLY_PRUNE: ${{ vars.APPLY_PRUNE }} APPLY_PRUNE: ${{ vars.APPLY_PRUNE }}
run: | run: |
set -euo pipefail set -euo pipefail
@@ -57,170 +57,59 @@ jobs:
fi fi
git -C "$repo" fetch origin main git -C "$repo" fetch origin main
echo "== Workstation state =="
echo " local: $(git -C "$repo" rev-parse --short HEAD)"
echo " remote: $(git -C "$repo" rev-parse --short origin/main)"
if [ -n "$(git -C "$repo" status --porcelain --untracked-files=no)" ]; then
echo "ERROR: workstation has local tracked modifications, refusing reset:"
git -C "$repo" status --porcelain --untracked-files=no
git -C "$repo" diff --stat
exit 1
fi
git -C "$repo" reset --hard origin/main git -C "$repo" reset --hard origin/main
cd "$repo"
is_disabled() {
local target="$1"
if [ -f "$target" ]; then
target="$(dirname "$target")"
fi
while true; do
if [ -f "$target/DISABLED" ]; then
return 0
fi
if [ "$target" = "$repo" ]; then
break
fi
target="$(dirname "$target")"
case "$target" in
"$repo"/*) ;;
*) break ;;
esac
done
return 1
}
# Runtime selection: a service is k8s-managed when $SERVICE/k8s/active
# exists. Otherwise it is compose-managed, and only k8s/routing/*
# manifests (external Services / EndpointSlices / ServersTransport /
# Ingresses that route to docker backends) are applied.
# migrate: touch SERVICE/k8s/active (+ move routing files up)
# rollback: rm SERVICE/k8s/active
collect_k8s() { collect_k8s() {
git ls-files -- "$1" \ find "$1" -type f \( -name '*.yaml' -o -name '*.yml' \) \
| grep -E '\.ya?ml$' \ ! -path '*/routing/*' ! -path '*/overlays/*' \
| grep -Ev '/routing/|/overlays/' \ ! -name 'kustomization.y*ml' ! -name '*.example.y*ml' \
| grep -Ev '(^|/)(kustomization\.ya?ml|.*\.example\.ya?ml|.*values\.ya?ml|patch-.*\.ya?ml)$' \ ! -name '*values.y*ml' ! -name 'patch-*.y*ml' \
| grep -Ev '(^|/)[^/]*secret[^/]*\.ya?ml$' \
| sort | sort
} }
collect_k8s_inactive() { collect_k8s_inactive() {
collect_k8s "$1" \ find "$1" -type f \( -name '*.yaml' -o -name '*.yml' \) \
| grep -E '(^|/)namespace\.ya?ml$|/routing/' \( -name 'namespace.y*ml' -o -path '*/routing/*' \) \
! -path '*/overlays/*' ! -name '*.example.y*ml' \
| sort
} }
kustomize_overlay() { mapfile -t compose_stacks < <(
if [ -f "$1/overlays/prod/kustomization.yaml" ]; then find "$repo" -type f \( -name 'compose.yaml' -o -name 'compose.yml' \) | sort
echo "$1/overlays/prod"
elif [ -f "$1/base/kustomization.yaml" ]; then
echo "$1/base"
fi
}
mapfile -t k8s_dirs < <(
git ls-files '*.yaml' '*.yml' \
| grep -E '(^|/)k8s/' \
| sed -E 's#((^|.*/)k8s)/.*#\1#' \
| sort -u
) )
k8s_manifests=() mapfile -t k8s_manifests < <(
kustomize_apps=() for kd in $(find "$repo" -type d -name k8s ! -path '*/.git/*' | sort); do
for kd_rel in "${k8s_dirs[@]}"; do if [ -f "$kd/active" ]; then
kd="$repo/$kd_rel" collect_k8s "$kd"
if is_disabled "$kd"; then
echo "skip (DISABLED): $kd_rel"
continue
fi
if [ -f "$kd/active" ]; then
overlay="$(kustomize_overlay "$kd" || true)"
if [ -n "${overlay:-}" ]; then
echo "kustomize app: ${overlay#$repo/}"
kustomize_apps+=("$overlay")
else else
while IFS= read -r f; do collect_k8s_inactive "$kd"
[ -n "$f" ] && k8s_manifests+=("$repo/$f")
done < <(collect_k8s "$kd_rel" || true)
fi fi
else done
while IFS= read -r f; do
[ -n "$f" ] && k8s_manifests+=("$repo/$f")
done < <(collect_k8s_inactive "$kd_rel" || true)
fi
done
mapfile -t compose_rel < <(
git ls-files '*/compose.yaml' '*/compose.yml' compose.yaml compose.yml | sort
) )
compose_stacks=()
for cf_rel in "${compose_rel[@]}"; do
cf="$repo/$cf_rel"
if is_disabled "$cf"; then
echo "skip (DISABLED): $cf_rel"
continue
fi
if [ -f "$(dirname "$cf")/k8s/active" ]; then
echo "skip (k8s-managed): $cf_rel"
continue
fi
compose_stacks+=("$cf")
done
echo "== Validate compose stacks ==" echo "== Validate compose stacks =="
for cf in "${compose_stacks[@]}"; do for cf in "${compose_stacks[@]}"; do
dir=$(dirname "$cf")
if [ -f "$dir/k8s/active" ]; then
echo " skip (k8s-managed): $dir"
continue
fi
echo " config: $cf" echo " config: $cf"
docker compose -f "$cf" config --quiet docker compose -f "$cf" config --quiet
done done
echo "== Validate k8s manifests (kubectl dry-run=client) ==" echo "== Validate k8s manifests (kubectl dry-run) =="
for m in "${k8s_manifests[@]}"; do for m in "${k8s_manifests[@]}"; do
echo " apply --dry-run=client $m" echo " apply --dry-run=client $m"
kubectl apply --dry-run=client -f "$m" >/dev/null kubectl apply --dry-run=client -f "$m" >/dev/null
done done
for k in "${kustomize_apps[@]}"; do
echo " apply -k --dry-run=client $k"
kubectl apply -k "$k" --dry-run=client >/dev/null
done
echo "== Validate k8s manifests (kubectl dry-run=server) =="
for m in "${k8s_manifests[@]}"; do
echo " apply --dry-run=server $m"
kubectl apply --dry-run=server -f "$m" >/dev/null
done
for k in "${kustomize_apps[@]}"; do
echo " apply -k --dry-run=server $k"
kubectl apply -k "$k" --dry-run=server >/dev/null
done
echo "== Checking referenced Secrets exist =="
echo " (deploy never applies *secret*.yaml; create missing ones from the laptop)"
ref_secrets=()
if [ "${#k8s_manifests[@]}" -gt 0 ]; then
while IFS= read -r s; do
[ -n "$s" ] && ref_secrets+=("$s")
done < <(
{
grep -h -A1 -E 'secretRef:|secretKeyRef:' "${k8s_manifests[@]}" 2>/dev/null || true
grep -h -E 'secretName:' "${k8s_manifests[@]}" 2>/dev/null || true
} | grep -E 'name:' | sed -E 's/.*name:[[:space:]]*//' | tr -d '"'"'"' "'"'" | sed -E 's/[[:space:]]*#.*//' | awk 'NF' | sort -u || true
)
fi
missing_secrets=()
all_secrets="$(kubectl get secrets -A --no-headers -o custom-columns=:metadata.name 2>/dev/null || true)"
for s in "${ref_secrets[@]}"; do
if printf '%s\n' "$all_secrets" | grep -qx "$s"; then
echo " ok: $s"
else
echo " MISSING: $s"
missing_secrets+=("$s")
fi
done
if [ "${#missing_secrets[@]}" -gt 0 ]; then
echo "ERROR: ${#missing_secrets[@]} referenced Secret(s) not found in the cluster:"
printf ' - %s\n' "${missing_secrets[@]}"
echo "Create them manually from the laptop, e.g.:"
echo " kubectl apply -f SERVICE/k8s/secrets.yaml # see SERVICE/k8s/secrets.yaml.example"
exit 1
fi
echo "== Applying Kubernetes manifests ==" echo "== Applying Kubernetes manifests =="
ns_files=() ns_files=()
@@ -241,19 +130,15 @@ jobs:
echo " namespaces first: ${ns_files[*]}" echo " namespaces first: ${ns_files[*]}"
kubectl apply -f "${ns_files[@]}" kubectl apply -f "${ns_files[@]}"
fi fi
if [ -f "$repo/prometheus-stack/k8s/active" ] && ! is_disabled "$repo/prometheus-stack/k8s"; then if [ -f "$repo/prometheus-stack/k8s/active" ]; then
if [ ! -f "$repo/prometheus-stack/k8s/grafana-values.yaml" ]; then
echo "ERROR: prometheus-stack/k8s/grafana-values.yaml (gitignored) missing on workstation, restore it first."
exit 1
fi
echo "== Upgrading kube-prometheus-stack ==" echo "== Upgrading kube-prometheus-stack =="
helm upgrade --install prometheus-stack prometheus-community/kube-prometheus-stack \ helm upgrade --install prometheus-stack prometheus-community/kube-prometheus-stack \
--namespace prometheus \ --namespace prometheus \
--version 86.2.3 \ --version 86.2.3 \
--values "$repo/prometheus-stack/k8s/grafana-values.yaml" \ --values "$repo/prometheus-stack/k8s/grafana-values.yaml" \
--wait --timeout 10m --wait
fi fi
if [ -f "$repo/loki/k8s/active" ] && ! is_disabled "$repo/loki/k8s"; then if [ -f "$repo/loki/k8s/active" ]; then
echo "== Upgrading loki/alloy ==" echo "== Upgrading loki/alloy =="
helm repo add grafana https://grafana.github.io/helm-charts >/dev/null 2>&1 || true helm repo add grafana https://grafana.github.io/helm-charts >/dev/null 2>&1 || true
helm repo update grafana >/dev/null 2>&1 || true helm repo update grafana >/dev/null 2>&1 || true
@@ -261,12 +146,12 @@ jobs:
--version 7.3.0 \ --version 7.3.0 \
--namespace prometheus \ --namespace prometheus \
--values "$repo/loki/k8s/loki-values.yaml" \ --values "$repo/loki/k8s/loki-values.yaml" \
--wait --timeout 10m --wait
helm upgrade --install alloy grafana/alloy \ helm upgrade --install alloy grafana/alloy \
--version 1.12.1 \ --version 1.12.1 \
--namespace prometheus \ --namespace prometheus \
--values "$repo/loki/k8s/alloy-values.yaml" \ --values "$repo/loki/k8s/alloy-values.yaml" \
--wait --timeout 10m --wait
fi fi
if [ "${#other_files[@]}" -gt 0 ]; then if [ "${#other_files[@]}" -gt 0 ]; then
@@ -274,30 +159,14 @@ jobs:
kubectl apply "${prune_opts[@]}" -f "${other_files[@]}" kubectl apply "${prune_opts[@]}" -f "${other_files[@]}"
fi fi
for k in "${kustomize_apps[@]}"; do
echo "== Applying kustomize app: ${k#$repo/} =="
kubectl apply -k "$k"
done
if [ -f "$repo/userbot/k8s/active" ] && ! is_disabled "$repo/userbot"; then
echo "== 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
kubectl rollout restart deployment/userbot-panel -n userbot
kubectl rollout status deployment/userbot-panel -n userbot --timeout=180s
fi
echo "== Redeploying docker compose stacks ==" echo "== Redeploying docker compose stacks =="
for cf in "${compose_stacks[@]}"; do for cf in "${compose_stacks[@]}"; do
echo " compose: $cf" dir=$(dirname "$cf")
if [ -f "$dir/k8s/active" ]; then
echo " skip (k8s-managed): $dir"
continue
fi
echo " compose: $dir"
if grep -Eq '^\s+pull_policy:\s*build\b' "$cf"; then if grep -Eq '^\s+pull_policy:\s*build\b' "$cf"; then
docker compose -f "$cf" build docker compose -f "$cf" build
docker compose -f "$cf" push docker compose -f "$cf" push
-2
View File
@@ -62,8 +62,6 @@ spec:
metadata: metadata:
labels: labels:
app: adguard app: adguard
annotations:
reloader.stakater.com/auto: "true"
spec: spec:
containers: containers:
- name: adguard - name: adguard
-12
View File
@@ -1,12 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: adguard-certs
namespace: adguard
spec:
secretName: adguard-certs
dnsNames:
- dns.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
+3 -5
View File
@@ -7,7 +7,7 @@ spec:
entryPoints: entryPoints:
- websecure - websecure
routes: routes:
- match: Host(`dns.forust.xyz`) - match: Host(`adguard.forust.xyz`) || Host(`dns.forust.xyz`)
kind: Rule kind: Rule
middlewares: middlewares:
- name: crowdsec-bouncer - name: crowdsec-bouncer
@@ -15,13 +15,13 @@ spec:
services: services:
- name: adguard-service - name: adguard-service
port: 3000 port: 3000
- match: (Host(`dns.forust.xyz`)) && PathPrefix(`/dns-query`) - match: (Host(`adguard.forust.xyz`) || Host(`dns.forust.xyz`)) && PathPrefix(`/dns-query`)
kind: Rule kind: Rule
services: services:
- name: adguard-service - name: adguard-service
port: 3000 port: 3000
tls: tls:
secretName: adguard-certs certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -42,5 +42,3 @@ spec:
services: services:
- name: adguard-service - name: adguard-service
port: 3000 port: 3000
tls:
secretName: internal-wildcard-tls
-15
View File
@@ -1,15 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: adguard
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
-28
View File
@@ -1,28 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: authentik-prod-tls
namespace: authentik
spec:
secretName: authentik-prod-tls
dnsNames:
- auth.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: authentik
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+1 -3
View File
@@ -16,7 +16,7 @@ spec:
- name: authentik-server-service - name: authentik-server-service
port: 9000 port: 9000
tls: tls:
secretName: authentik-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -32,5 +32,3 @@ spec:
services: services:
- name: authentik-server-service - name: authentik-server-service
port: 9000 port: 9000
tls:
secretName: internal-wildcard-tls
@@ -1,9 +0,0 @@
crds:
enabled: true
prometheus:
servicemonitor:
enabled: true
interval: 60s
scrapeTimeout: 30s
labels:
release: prometheus-stack
-29
View File
@@ -1,29 +0,0 @@
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
email: bobrovod@national.shitposting.agency
server: https://acme-staging-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-staging-account-key
solvers:
- http01:
ingress:
class: traefik
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
email: bobrovod@national.shitposting.agency
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-prod-account-key
solvers:
- http01:
ingress:
class: traefik
@@ -1,30 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIFFjCCAv6gAwIBAgIUetKpTfEDOn2985FFMu6G26itT+wwDQYJKoZIhvcNAQEN
BQAwIzEhMB8GA1UEAxMYaG9tZWxhYiBpbnRlcm5hbCByb290IENBMB4XDTI2MDky
MzEyNDA0N1oXDTM2MDkyMDEyNDA0N1owIzEhMB8GA1UEAxMYaG9tZWxhYiBpbnRl
cm5hbCByb290IENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAvmNP
ZCOoD8NtNuYJKVXBlTPjX7D7sJCSK5neH7ZbYV5+lmUlEErY8Mik7j37V5k5NfpF
Ig85pOjP7RckTPz5V6ek3yaN40s4AL053sN5ZPauDVYjalaEHTgj5sEMqlLACQWI
yZmJOZspZykae8dIpQnqCoFpRT4FurJ78v4a0ylnFVLMQn/lyCHedwTjkEdtYWYr
ccJy8vQwqkzs/rWvEH1lDqZhennLOrmcCjfonG7D/pruMn4z+6E28p4+ejkRrI6x
luak3KnpT1XMeHtgU21hiRGaMDBchHMFgAhnY1qosymKenXvfTZItwgjZbwa1hJI
GAiDm+jQDKMjzRZ3rH6Xfc0auUcykNz73PpNu1NGm78nndXwCXcXn1LFKNQJ+r1U
sJiyAmUZmXVn4aM4OMf2F38k7wTYIKg7nRGaUkNeKDlNkjA4HvgWw+jwO1KmdHQ/
mOem1rosDWHRK01wg+Gga9mQCnhNhxglg3t/UeSic6uOaRsvaz4qkzHq8MbCujVz
DpKQjqdikYOAXZOs4KlBLWrS7NaK4NzfSD02pBUErh54ruJfY/bWz9KyXzBD/lQZ
VUTKyvUVB0bkVHEdf1jJmX3H4IZRQSF5JPqOBotW6bJI5fEGNBvj9Zxy4nm2WWGz
yyP3uWsQz8U/Wdx9nXZLHInTZBsvgLYtKUAWA30CAwEAAaNCMEAwDgYDVR0PAQH/
BAQDAgKkMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFEkKm2rxPaK6+O9WD80z
BLC6F9QsMA0GCSqGSIb3DQEBDQUAA4ICAQAnFyHz97Umf5VIu+dKTJid7C73VugJ
TIar/xJBs/4CxP+znBxhJjXygRoyIfzoVGWcB2ZSL//vL78Qlts79K/Imc9a4RFF
wMvCxsRXAEQ4TpeWi3ophPNcs4rhsP+gQKQFtnyKP9519bqpfxp0bTqwOV2o18fn
za7rlQViiEnNV58j7CVoM9+mJvVVfBEX1Km+GyJL9GadzbIQ7FxClVJZefCbft93
zHVk9gDOw8ys1XGSR2OUCyCLinXO6mqS16CmBb2MAKXq/YyH7E0N8iotAPGtfA8V
M/0ddy947rY0xCrtECfWwvGQpJS7NRv/Z9b2jCfXrI5LXmL2nfQRg0y9GE4Vjwr+
WxtGU5jOeFt0jQ+xRzcgG0Op+qK3x55l5LSo2hOcOVYbiHxcHEJFgwNi1ADeBFwb
q/HdysfURSOghqjIpMMAUabBp+DBUg2EUF7pIaUqbdqExFYcr9EYisEMiNsmKmN+
8ZbcOeerFKDQj+t/R0bFXa7UBn2UWsjI8zlR74aa2kLDXwtyz/XlO/FlYm66eBFo
2/eYUSeU+S4ej+wUAs/dvjF7f190/DUQGuwOTlLTahqWDztmhCk7qzbECu56CwKT
E5Ect2P72UleYwdblkVOVd352AmiwEzdOaziIRrPh8uenEknH6JBYPO3mjk7cCg+
GPGcNIBctjdXhg==
-----END CERTIFICATE-----
-33
View File
@@ -1,33 +0,0 @@
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: selfsigned
spec:
selfSigned: {}
---
# Homelab internal root CA (10y). Install the .crt on clients (see below).
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-ca-root
namespace: cert-manager
spec:
isCA: true
commonName: homelab internal root CA
duration: 87600h
renewBefore: 7200h
secretName: internal-ca-root
privateKey:
algorithm: RSA
size: 4096
issuerRef:
name: selfsigned
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: internal-ca
spec:
ca:
secretName: internal-ca-root
-4
View File
@@ -1,4 +0,0 @@
apiVersion: v1
kind: Namespace
metadata:
name: cert-manager
-28
View File
@@ -1,28 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: checkmk-prod-tls
namespace: checkmk
spec:
secretName: checkmk-prod-tls
dnsNames:
- cmk.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: checkmk
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+1 -3
View File
@@ -16,7 +16,7 @@ spec:
- name: checkmk-service - name: checkmk-service
port: 5000 port: 5000
tls: tls:
secretName: checkmk-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRouteTCP kind: IngressRouteTCP
@@ -48,5 +48,3 @@ spec:
services: services:
- name: checkmk-service - name: checkmk-service
port: 5000 port: 5000
tls:
secretName: internal-wildcard-tls
-42
View File
@@ -1,42 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: convertx-prod-tls
namespace: converters
spec:
secretName: convertx-prod-tls
dnsNames:
- forust.xyz
- www.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: bentopdf-prod-tls
namespace: converters
spec:
secretName: bentopdf-prod-tls
dnsNames:
- pdf.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: converters
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+2 -7
View File
@@ -14,7 +14,7 @@ spec:
- name: convertx-service - name: convertx-service
port: 3000 port: 3000
tls: tls:
secretName: convertx-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -31,9 +31,6 @@ spec:
services: services:
- name: convertx-service - name: convertx-service
port: 3000 port: 3000
tls:
secretName: internal-wildcard-tls
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -50,7 +47,7 @@ spec:
- name: bentopdf-service - name: bentopdf-service
port: 8080 port: 8080
tls: tls:
secretName: bentopdf-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -66,5 +63,3 @@ spec:
services: services:
- name: bentopdf-service - name: bentopdf-service
port: 8080 port: 8080
tls:
secretName: internal-wildcard-tls
-28
View File
@@ -1,28 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: dockmon-prod-tls
namespace: dockmon
spec:
secretName: dockmon-prod-tls
dnsNames:
- dockmon.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: dockmon
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+1 -3
View File
@@ -26,7 +26,7 @@ spec:
port: 443 port: 443
serversTransport: dockmon-transport serversTransport: dockmon-transport
tls: tls:
secretName: dockmon-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -43,5 +43,3 @@ spec:
- name: dockmon-service - name: dockmon-service
port: 443 port: 443
serversTransport: dockmon-transport serversTransport: dockmon-transport
tls:
secretName: internal-wildcard-tls
+1 -3
View File
@@ -17,7 +17,7 @@ spec:
- name: downtify-service - name: downtify-service
port: 8000 port: 8000
tls: tls:
secretName: downtify-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -33,5 +33,3 @@ spec:
services: services:
- name: downtify-service - name: downtify-service
port: 8000 port: 8000
tls:
secretName: internal-wildcard-tls
-1
View File
@@ -1 +0,0 @@
1.56.0
+1 -1
View File
@@ -11,7 +11,7 @@ services:
retries: 5 retries: 5
playwright-service: playwright-service:
image: mcr.microsoft.com/playwright:v1.56.0-jammy image: mcr.microsoft.com/playwright:v1.63.0-jammy
restart: unless-stopped restart: unless-stopped
command: npx -y playwright@1.56.0 run-server --port 3000 --path /ws command: npx -y playwright@1.56.0 run-server --port 3000 --path /ws
-77
View File
@@ -1,77 +0,0 @@
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: edu-master-webinar
namespace: edu-master
labels:
release: prometheus-stack
spec:
groups:
- name: edu_master.webinar
rules:
# No successful webinar check for 5m (~2-3 missed 2-min checks).
# Catches: playwright hangs/timeouts, version skew, site changes, hung job.
- alert: WebinarCheckerNoSuccessfulCheck
expr: |
(time() - webinar_check_last_success_timestamp_seconds > 300)
and (webinar_check_last_run_timestamp_seconds > 0)
for: 2m
labels:
severity: critical
annotations:
summary: "Webinar checker has no successful check for 5m"
description: "edu-master/webinar-checker: last successful webinar check was {{ $value | humanizeDuration }} ago. Checks are failing or hanging (see consecutive failures alert). Notifications about new webinars are NOT being sent."
# Fast path: 3 consecutive failures (~6+ min at 2-min interval).
- alert: WebinarCheckerConsecutiveFailures
expr: |
webinar_check_consecutive_failures >= 3
for: 5m
labels:
severity: critical
annotations:
summary: "Webinar checker failing consecutively"
description: 'edu-master/webinar-checker: {{ $value }} consecutive webinar check failures (timeout / playwright error / page error). Check pod logs (Loki: {namespace="edu-master", container="webinar-checker"}).'
# Metrics endpoint not scraped for 10m: pod down, metrics server dead, or ServiceMonitor broken.
- alert: WebinarCheckerScrapeDown
expr: |
absent(webinar_check_last_run_timestamp_seconds) == 1
for: 10m
labels:
severity: critical
annotations:
summary: "Webinar checker metrics missing"
description: "edu-master/webinar-checker: no metrics series for 10m. Pod may be down, metrics server dead, or ServiceMonitor/Service broken. Webinar checks are unobserved."
# EDU session lost: session-keeper down or credentials expired. Without PHPSESSID every check is skipped.
- alert: EduPhpsessidMissing
expr: |
edu_phpsessid_present == 0
for: 10m
labels:
severity: critical
annotations:
summary: "EDU_PHPSESSID missing"
description: "edu-master: EDU_PHPSESSID absent from redis for 10m. Webinar/diari/schedule checks are all skipped. Check session-keeper logs and EDU credentials."
# Hard deps: checker and playwright deployments unavailable.
- alert: WebinarCheckerDeploymentDown
expr: |
kube_deployment_status_replicas_unavailable{deployment="webinar-checker", namespace="edu-master"} > 0
for: 10m
labels:
severity: critical
annotations:
summary: "Webinar checker deployment unavailable"
description: "edu-master/webinar-checker deployment has {{ $value }} unavailable replica(s) for 10m."
- alert: PlaywrightServiceDown
expr: |
kube_deployment_status_replicas_unavailable{deployment="playwright-service", namespace="edu-master"} > 0
for: 10m
labels:
severity: critical
annotations:
summary: "Playwright service unavailable"
description: "edu-master/playwright-service deployment has {{ $value }} unavailable replica(s) for 10m. All webinar/diari/schedule checks fail without it."
+1 -2
View File
@@ -17,8 +17,7 @@ spec:
spec: spec:
containers: containers:
- name: playwright - name: playwright
# renovate: datasource=docker depName=mcr.microsoft.com/playwright versioning=docker image: mcr.microsoft.com/playwright:v1.63.0-jammy
image: mcr.microsoft.com/playwright:v1.56.0-jammy
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: command:
- npx - npx
-2
View File
@@ -21,8 +21,6 @@ stringData:
WEBINAR_TELEGRAM_TOKEN: "" WEBINAR_TELEGRAM_TOKEN: ""
WEBINAR_ADMIN_ID: "" WEBINAR_ADMIN_ID: ""
WEBINAR_CHECK_INTERVAL: "60" WEBINAR_CHECK_INTERVAL: "60"
# Prometheus metrics endpoint (scraped via ServiceMonitor, alerts in k8s/alerts.yaml)
METRICS_PORT: "8000"
# Database # Database
REDIS_HOST: "redis" REDIS_HOST: "redis"
REDIS_PORT: "6379" REDIS_PORT: "6379"
-15
View File
@@ -1,15 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: webinar-checker
namespace: edu-master
labels:
app: edu-master-webinar-checker
spec:
selector:
app: edu-master-webinar-checker
ports:
- name: metrics
port: 8000
targetPort: metrics
protocol: TCP
-16
View File
@@ -1,16 +0,0 @@
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: webinar-checker
namespace: edu-master
labels:
release: prometheus-stack
spec:
selector:
matchLabels:
app: edu-master-webinar-checker
endpoints:
- port: metrics
path: /metrics
interval: 30s
scrapeTimeout: 10s
-4
View File
@@ -47,10 +47,6 @@ spec:
- name: webinar-checker - name: webinar-checker
image: gcr.forust.xyz/forust/webinar-checker:latest image: gcr.forust.xyz/forust/webinar-checker:latest
imagePullPolicy: Always imagePullPolicy: Always
ports:
- name: metrics
containerPort: 8000
protocol: TCP
envFrom: envFrom:
- secretRef: - secretRef:
name: edu-master-secrets name: edu-master-secrets
+2 -5
View File
@@ -2,11 +2,8 @@ FROM python:3.11-slim
WORKDIR /app WORKDIR /app
# renovate: datasource=pypi depName=playwright versioning=pep440 # Install dependencies
ARG PLAYWRIGHT_VERSION=1.56.0 RUN pip install --no-cache-dir pip==25.0.1 && pip install --no-cache-dir playwright==1.56.0 redis==5.2.1 requests==2.32.3 "python-telegram-bot[job-queue]==21.10"
# Install dependencies - PLAYWRIGHT_VERSION is single-source, renovate updates ARG above and all other places via regexManagers
RUN pip install --no-cache-dir pip==25.0.1 && pip install --no-cache-dir playwright==${PLAYWRIGHT_VERSION} redis==5.2.1 requests==2.32.3 "python-telegram-bot[job-queue]==21.10"
COPY checker.py . COPY checker.py .
+144 -277
View File
@@ -1,15 +1,12 @@
import asyncio
import contextlib import contextlib
import json import json
import logging import logging
import os import os
import re import re
import tempfile import tempfile
import threading
import time import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
from html import escape from html import escape
from http.server import BaseHTTPRequestHandler, HTTPServer
import redis import redis
from playwright.async_api import async_playwright from playwright.async_api import async_playwright
@@ -51,106 +48,6 @@ USER_AGENT = _env(
) )
WEBINAR_TELEGRAM_TOKEN = _env('WEBINAR_TELEGRAM_TOKEN') WEBINAR_TELEGRAM_TOKEN = _env('WEBINAR_TELEGRAM_TOKEN')
ADMIN_ID = int(_env('WEBINAR_ADMIN_ID', '0')) ADMIN_ID = int(_env('WEBINAR_ADMIN_ID', '0'))
METRICS_PORT = int(_env('METRICS_PORT', '8000'))
# --- Prometheus metrics (stdlib only, no extra deps) ---
# Scraped by prometheus-stack via ServiceMonitor (edu_master/k8s/servicemonitor.yaml).
# Critical alerts in edu_master/k8s/alerts.yaml fire to Telegram via Alertmanager.
_METRICS_LOCK = threading.Lock()
_METRICS = {
'last_run': 0.0, # Unix ts of last check start
'last_success': 0.0, # Unix ts of last successful check
'last_duration': 0.0, # Duration of last check in seconds
'success_total': 0,
'failure_total': 0,
'consecutive_failures': 0,
'phpsessid_present': 1, # 1 if EDU_PHPSESSID found in redis, else 0
}
def _metric_check_start():
with _METRICS_LOCK:
_METRICS['last_run'] = time.time()
def _metric_check_ok(duration: float):
now = time.time()
with _METRICS_LOCK:
_METRICS['last_success'] = now
_METRICS['last_duration'] = duration
_METRICS['success_total'] += 1
_METRICS['consecutive_failures'] = 0
_METRICS['phpsessid_present'] = 1
def _metric_check_fail(duration: float, phpsessid_missing: bool = False):
with _METRICS_LOCK:
_METRICS['last_duration'] = duration
_METRICS['failure_total'] += 1
_METRICS['consecutive_failures'] += 1
_METRICS['phpsessid_present'] = 0 if phpsessid_missing else 1
def _metrics_render() -> bytes:
with _METRICS_LOCK:
m = dict(_METRICS)
lines = [
'# HELP webinar_check_last_run_timestamp_seconds Unix timestamp of last webinar check start.',
'# TYPE webinar_check_last_run_timestamp_seconds gauge',
f'webinar_check_last_run_timestamp_seconds {m["last_run"]}',
'# HELP webinar_check_last_success_timestamp_seconds Unix timestamp of last successful webinar check.',
'# TYPE webinar_check_last_success_timestamp_seconds gauge',
f'webinar_check_last_success_timestamp_seconds {m["last_success"]}',
'# HELP webinar_check_last_duration_seconds Duration of last webinar check in seconds.',
'# TYPE webinar_check_last_duration_seconds gauge',
f'webinar_check_last_duration_seconds {m["last_duration"]}',
'# HELP webinar_check_success_total Total successful webinar checks.',
'# TYPE webinar_check_success_total counter',
f'webinar_check_success_total {m["success_total"]}',
'# HELP webinar_check_failure_total Total failed webinar checks (timeout, playwright error, page error).',
'# TYPE webinar_check_failure_total counter',
f'webinar_check_failure_total {m["failure_total"]}',
'# HELP webinar_check_consecutive_failures Consecutive failed webinar checks (reset on success).',
'# TYPE webinar_check_consecutive_failures gauge',
f'webinar_check_consecutive_failures {m["consecutive_failures"]}',
'# HELP edu_phpsessid_present 1 if EDU_PHPSESSID exists in redis, 0 otherwise.',
'# TYPE edu_phpsessid_present gauge',
f'edu_phpsessid_present {m["phpsessid_present"]}',
]
return ('\n'.join(lines) + '\n').encode()
class _MetricsHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == '/metrics':
body = _metrics_render()
self.send_response(200)
self.send_header('Content-Type', 'text/plain; version=0.0.4')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
elif self.path in ('/healthz', '/health'):
body = b'ok\n'
self.send_response(200)
self.send_header('Content-Type', 'text/plain')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
else:
self.send_response(404)
self.end_headers()
def log_message(self, *args):
pass # keep bot logs clean
def start_metrics_server(port: int = METRICS_PORT):
server = HTTPServer(('0.0.0.0', port), _MetricsHandler) # noqa: S104 - k8s ServiceMonitor scrapes pod IP
thread = threading.Thread(target=server.serve_forever, name='metrics-server', daemon=True)
thread.start()
logger.info(f'Metrics server listening on :{port}/metrics')
return server
# Redis Keys # Redis Keys
KEY_WHITELIST = 'bot:whitelist' KEY_WHITELIST = 'bot:whitelist'
@@ -700,66 +597,59 @@ async def _collect_event_times(page) -> dict:
async def fetch_diary_data(phpsessid: str) -> dict | None: async def fetch_diary_data(phpsessid: str) -> dict | None:
logger.info('Fetching diary data via Playwright...') logger.info('Fetching diary data via Playwright...')
try: try:
async with asyncio.timeout(60): async with async_playwright() as p:
async with async_playwright() as p: browser = await p.chromium.connect(PLAYWRIGHT_WS)
browser = await asyncio.wait_for(p.chromium.connect(PLAYWRIGHT_WS), timeout=15) try:
context_browser = await browser.new_context(user_agent=USER_AGENT)
await context_browser.add_cookies(
[{'name': 'PHPSESSID', 'value': phpsessid, 'domain': 'edu.edu.vn.ua', 'path': '/'}]
)
page = await context_browser.new_page()
try: try:
context_browser = await browser.new_context(user_agent=USER_AGENT) await page.goto(DIARY_URL, wait_until='domcontentloaded')
await context_browser.add_cookies( await page.wait_for_selector('table.calendar', timeout=10000)
[{'name': 'PHPSESSID', 'value': phpsessid, 'domain': 'edu.edu.vn.ua', 'path': '/'}] await page.wait_for_timeout(1500)
)
page = await context_browser.new_page()
try: table_html = await page.evaluate("""
await asyncio.wait_for(page.goto(DIARY_URL, wait_until='domcontentloaded'), timeout=30) () => {
await page.wait_for_selector('table.calendar', timeout=10000) const t = document.querySelector('table.calendar');
await page.wait_for_timeout(1500) return t ? t.outerHTML : null;
}
table_html = await page.evaluate(""" """)
() => { if not table_html:
const t = document.querySelector('table.calendar'); logger.error('table.calendar not found in DOM')
return t ? t.outerHTML : null;
}
""")
if not table_html:
logger.error('table.calendar not found in DOM')
return None
# Debug: save HTML for troubleshooting
with contextlib.suppress(Exception), open('/tmp/diary_debug.html', 'w', encoding='utf-8') as f: # noqa: S108
f.write(table_html)
month_text, days = _parse_calendar_html(table_html)
# Read event times by opening each event's AJAX popup.
times_by_id = await _collect_event_times(page)
if times_by_id:
for day_data in days.values():
for ev in day_data.get('events', []):
eid = ev.get('id')
if eid and eid in times_by_id:
ev['time'] = times_by_id[eid]
logger.info(
f'Diary parsed: month={month_text!r}, days_with_events={sum(1 for d in days.values() if d["events"])}/{len(days)}'
)
return {'monthFullText': month_text, 'days': days}
except Exception as e:
logger.error(f'Error parsing diary: {e}')
return None return None
finally:
with contextlib.suppress(Exception): # Debug: save HTML for troubleshooting
await asyncio.wait_for(page.close(), timeout=5) with contextlib.suppress(Exception), open('/tmp/diary_debug.html', 'w', encoding='utf-8') as f: # noqa: S108
with contextlib.suppress(Exception): f.write(table_html)
await asyncio.wait_for(context_browser.close(), timeout=5)
month_text, days = _parse_calendar_html(table_html)
# Read event times by opening each event's AJAX popup.
times_by_id = await _collect_event_times(page)
if times_by_id:
for day_data in days.values():
for ev in day_data.get('events', []):
eid = ev.get('id')
if eid and eid in times_by_id:
ev['time'] = times_by_id[eid]
logger.info(
f'Diary parsed: month={month_text!r}, days_with_events={sum(1 for d in days.values() if d["events"])}/{len(days)}'
)
return {'monthFullText': month_text, 'days': days}
except Exception as e:
logger.error(f'Error parsing diary: {e}')
return None
finally: finally:
with contextlib.suppress(Exception): await page.close()
await asyncio.wait_for(browser.close(), timeout=5) await context_browser.close()
except TimeoutError: finally:
logger.error('Diary fetch timed out (60s)') await browser.close()
return None
except Exception as e: except Exception as e:
logger.error(f'Playwright error in diary fetch: {e}') logger.error(f'Playwright error in diary fetch: {e}')
return None return None
@@ -1041,55 +931,48 @@ def _parse_schedule_html(table_html: str) -> dict:
async def fetch_schedule_data(phpsessid: str) -> dict | None: async def fetch_schedule_data(phpsessid: str) -> dict | None:
logger.info('Fetching schedule data via Playwright...') logger.info('Fetching schedule data via Playwright...')
try: try:
async with asyncio.timeout(60): async with async_playwright() as p:
async with async_playwright() as p: browser = await p.chromium.connect(PLAYWRIGHT_WS)
browser = await asyncio.wait_for(p.chromium.connect(PLAYWRIGHT_WS), timeout=15) try:
context_browser = await browser.new_context(user_agent=USER_AGENT)
await context_browser.add_cookies(
[{'name': 'PHPSESSID', 'value': phpsessid, 'domain': 'edu.edu.vn.ua', 'path': '/'}]
)
page = await context_browser.new_page()
try: try:
context_browser = await browser.new_context(user_agent=USER_AGENT) await page.goto(SCHEDULE_URL, wait_until='domcontentloaded')
await context_browser.add_cookies( await page.wait_for_selector('table.schedule-table', timeout=10000)
[{'name': 'PHPSESSID', 'value': phpsessid, 'domain': 'edu.edu.vn.ua', 'path': '/'}] await page.wait_for_timeout(1500)
)
page = await context_browser.new_page()
try: table_html = await page.evaluate("""
await asyncio.wait_for(page.goto(SCHEDULE_URL, wait_until='domcontentloaded'), timeout=30) () => {
await page.wait_for_selector('table.schedule-table', timeout=10000) const t = document.querySelector('table.schedule-table');
await page.wait_for_timeout(1500) return t ? t.outerHTML : null;
}
table_html = await page.evaluate(""" """)
() => { if not table_html:
const t = document.querySelector('table.schedule-table'); logger.error('table.schedule-table not found in DOM')
return t ? t.outerHTML : null;
}
""")
if not table_html:
logger.error('table.schedule-table not found in DOM')
return None
debug_path = os.path.join(tempfile.gettempdir(), 'schedule_debug.html')
with contextlib.suppress(Exception), open(debug_path, 'w', encoding='utf-8') as f:
f.write(table_html)
data = _parse_schedule_html(table_html)
logger.info(list(data['weekdays'].keys()))
logger.info(f'Schedule parsed: {len(data["weekdays"])} days, classes={data["classes"]}')
return data
except Exception as e:
logger.error(f'Error parsing schedule: {e}')
return None return None
finally:
with contextlib.suppress(Exception): debug_path = os.path.join(tempfile.gettempdir(), 'schedule_debug.html')
await asyncio.wait_for(page.close(), timeout=5) with contextlib.suppress(Exception), open(debug_path, 'w', encoding='utf-8') as f:
with contextlib.suppress(Exception): f.write(table_html)
await asyncio.wait_for(context_browser.close(), timeout=5)
data = _parse_schedule_html(table_html)
logger.info(list(data['weekdays'].keys()))
logger.info(f'Schedule parsed: {len(data["weekdays"])} days, classes={data["classes"]}')
return data
except Exception as e:
logger.error(f'Error parsing schedule: {e}')
return None
finally: finally:
with contextlib.suppress(Exception): await page.close()
await asyncio.wait_for(browser.close(), timeout=5) await context_browser.close()
except TimeoutError: finally:
logger.error('Schedule fetch timed out (60s)') await browser.close()
return None
except Exception as e: except Exception as e:
logger.error(f'Playwright error in schedule fetch: {e}') logger.error(f'Playwright error in schedule fetch: {e}')
return None return None
@@ -1602,13 +1485,10 @@ async def check_webinars_job(context: ContextTypes.DEFAULT_TYPE):
int: Number of webinars found, or None if check failed int: Number of webinars found, or None if check failed
""" """
logger.info('Running webinar check...') logger.info('Running webinar check...')
_t0 = time.time()
_metric_check_start()
phpsessid = redis_client.get(KEY_PHPSESSID) phpsessid = redis_client.get(KEY_PHPSESSID)
if not phpsessid: if not phpsessid:
logger.warning('PHPSESSID missing. Skipping check.') logger.warning('PHPSESSID missing. Skipping check.')
_metric_check_fail(time.time() - _t0, phpsessid_missing=True)
# --- DEBUG LOGGING --- # --- DEBUG LOGGING ---
try: try:
with open('phpsessid_missing.log', 'a') as f: with open('phpsessid_missing.log', 'a') as f:
@@ -1622,88 +1502,78 @@ async def check_webinars_job(context: ContextTypes.DEFAULT_TYPE):
content = '' content = ''
try: try:
async with asyncio.timeout(90): async with async_playwright() as p:
async with async_playwright() as p: # Connect to remote Playwright service
# Connect to remote Playwright service browser = await p.chromium.connect(PLAYWRIGHT_WS)
browser = await asyncio.wait_for(p.chromium.connect(PLAYWRIGHT_WS), timeout=15)
try:
# Create browser context with user agent
context_browser = await browser.new_context(user_agent=USER_AGENT)
# Add PHPSESSID cookie
await context_browser.add_cookies(
[{'name': 'PHPSESSID', 'value': phpsessid, 'domain': 'edu.edu.vn.ua', 'path': '/'}]
)
# Create new page
page = await context_browser.new_page()
try: try:
# Create browser context with user agent # Navigate to webinar page
context_browser = await browser.new_context(user_agent=USER_AGENT) await page.goto(WEBINAR_URL, wait_until='domcontentloaded')
# Add PHPSESSID cookie # Wait for the table to load
await context_browser.add_cookies( await page.wait_for_selector('#meetings table', timeout=10000)
[{'name': 'PHPSESSID', 'value': phpsessid, 'domain': 'edu.edu.vn.ua', 'path': '/'}] await page.wait_for_timeout(2000)
)
# Create new page # Get page content
page = await context_browser.new_page() content = await page.content()
try: # Check if "no webinar" message is present
# Navigate to webinar page if NO_WEBINAR_MARKER not in content:
await asyncio.wait_for(page.goto(WEBINAR_URL, wait_until='domcontentloaded'), timeout=30) logger.info('!!! WEBINAR FOUND !!!')
# Wait for the table to load # Extract webinar details from table rows
await page.wait_for_selector('#meetings table', timeout=10000) rows = page.locator('#meetings table tbody tr')
await page.wait_for_timeout(2000) count = await rows.count()
# Get page content for i in range(count):
content = await page.content() row = rows.nth(i)
text = await row.inner_text()
# Check if "no webinar" message is present if NO_WEBINAR_MARKER not in text:
if NO_WEBINAR_MARKER not in content: # Extract name (topic) from first column
logger.info('!!! WEBINAR FOUND !!!') name_elem = row.locator('td').nth(0)
name = await name_elem.inner_text()
name = name.strip()
# Extract webinar details from table rows # Extract join URL from fourth column
rows = page.locator('#meetings table tbody tr') url_elem = row.locator('td').nth(3).locator('a[href*="/webinar/join/"]').first
count = await rows.count() url = await url_elem.get_attribute('href')
for i in range(count): if name and url:
row = rows.nth(i) current_webinars.append({'name': name, 'url': url, 'text': text.strip()})
text = await row.inner_text() logger.info(f'Found webinar: {name} -> {url}')
else:
logger.info('No webinars found (expected message present)')
if NO_WEBINAR_MARKER not in text: except Exception as e:
# Extract name (topic) from first column logger.error(f'Error checking page: {e}. Saving content for debug.')
name_elem = row.locator('td').nth(0) # If page content is available, save it on error
name = await name_elem.inner_text()
name = name.strip()
# Extract join URL from fourth column
url_elem = row.locator('td').nth(3).locator('a[href*="/webinar/join/"]').first
url = await url_elem.get_attribute('href')
if name and url:
current_webinars.append({'name': name, 'url': url, 'text': text.strip()})
logger.info(f'Found webinar: {name} -> {url}')
else:
logger.info('No webinars found (expected message present)')
except Exception as e:
logger.error(f'Error checking page: {e}. Saving content for debug.')
# If page content is available, save it on error
with contextlib.suppress(Exception):
if page and not content:
content = await page.content()
_metric_check_fail(time.time() - _t0)
return None
finally:
with contextlib.suppress(Exception):
await asyncio.wait_for(page.close(), timeout=5)
with contextlib.suppress(Exception):
await asyncio.wait_for(context_browser.close(), timeout=5)
finally:
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
await asyncio.wait_for(browser.close(), timeout=5) if page and not content:
content = await page.content()
return None
finally:
await page.close()
await context_browser.close()
finally:
await browser.close()
except TimeoutError:
logger.error('Webinar check timed out after 90s (playwright hang)')
_metric_check_fail(time.time() - _t0)
return None
except Exception as e: except Exception as e:
logger.error(f'Playwright error: {e}') logger.error(f'Playwright error: {e}')
_metric_check_fail(time.time() - _t0)
return None return None
# --- DEBUG LOGGING (Saving last response content) --- # --- DEBUG LOGGING (Saving last response content) ---
@@ -1767,7 +1637,6 @@ async def check_webinars_job(context: ContextTypes.DEFAULT_TYPE):
else: else:
logger.info(f'Found {len(current_webinars)} webinar(s), but all are already known') logger.info(f'Found {len(current_webinars)} webinar(s), but all are already known')
_metric_check_ok(time.time() - _t0)
return len(current_webinars) return len(current_webinars)
@@ -1811,8 +1680,6 @@ def main():
job_queue = app.job_queue job_queue = app.job_queue
job_queue.run_repeating(check_webinars_job, interval=WEBINAR_CHECK_INTERVAL, first=10) job_queue.run_repeating(check_webinars_job, interval=WEBINAR_CHECK_INTERVAL, first=10)
start_metrics_server()
logger.info('Bot started polling...') logger.info('Bot started polling...')
app.run_polling() app.run_polling()
-29
View File
@@ -1,29 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: gitea-prod-tls
namespace: gitea
spec:
secretName: gitea-prod-tls
dnsNames:
- gcr.forust.xyz
- gitea.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: gitea
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+1 -1
View File
@@ -31,7 +31,7 @@ spec:
spec: spec:
containers: containers:
- name: gitea - name: gitea
image: gitea/gitea:1.27.3 image: docker.gitea.com/gitea:1.27.3
envFrom: envFrom:
- configMapRef: - configMapRef:
name: gitea-config name: gitea-config
+1 -4
View File
@@ -24,7 +24,7 @@ spec:
- name: gitea-service - name: gitea-service
port: 3000 port: 3000
tls: tls:
secretName: gitea-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -45,9 +45,6 @@ spec:
services: services:
- name: gitea-service - name: gitea-service
port: 3000 port: 3000
tls:
secretName: internal-wildcard-tls
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRouteTCP kind: IngressRouteTCP
-29
View File
@@ -1,29 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: glance-prod-tls
namespace: glance
spec:
secretName: glance-prod-tls
dnsNames:
- forust.xyz
- www.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: glance
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+1 -4
View File
@@ -16,7 +16,7 @@ spec:
- name: glance-service - name: glance-service
port: 8080 port: 8080
tls: tls:
secretName: glance-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -35,9 +35,6 @@ spec:
services: services:
- name: glance-service - name: glance-service
port: 8080 port: 8080
tls:
secretName: internal-wildcard-tls
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: Middleware kind: Middleware
-41
View File
@@ -1,41 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: headscale-prod-tls
namespace: headscale
spec:
secretName: headscale-prod-tls
dnsNames:
- hs.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: headplane-prod-tls
namespace: headscale
spec:
secretName: headplane-prod-tls
dnsNames:
- hp.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: headscale
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+2 -7
View File
@@ -38,7 +38,7 @@ spec:
- name: headscale-server-external - name: headscale-server-external
port: 9090 port: 9090
tls: tls:
secretName: headscale-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -64,7 +64,7 @@ spec:
- name: headplane-external - name: headplane-external
port: 3000 port: 3000
tls: tls:
secretName: headplane-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -90,9 +90,6 @@ spec:
services: services:
- name: headscale-server-external - name: headscale-server-external
port: 9090 port: 9090
tls:
secretName: internal-wildcard-tls
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -113,5 +110,3 @@ spec:
services: services:
- name: headplane-external - name: headplane-external
port: 3000 port: 3000
tls:
secretName: internal-wildcard-tls
-42
View File
@@ -1,42 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: forust-homepage-prod-tls
namespace: homepages
spec:
secretName: forust-homepage-prod-tls
dnsNames:
- forust.xyz
- www.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: xdfnx-homepage-prod-tls
namespace: homepages
spec:
secretName: xdfnx-homepage-prod-tls
dnsNames:
- xdfnx.cfd
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: homepages
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+2 -7
View File
@@ -17,7 +17,7 @@ spec:
- name: forust-homepage-service - name: forust-homepage-service
port: 80 port: 80
tls: tls:
secretName: forust-homepage-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -34,9 +34,6 @@ spec:
services: services:
- name: forust-homepage-service - name: forust-homepage-service
port: 80 port: 80
tls:
secretName: internal-wildcard-tls
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -56,7 +53,7 @@ spec:
- name: xdfnx-homepage-service - name: xdfnx-homepage-service
port: 80 port: 80
tls: tls:
secretName: xdfnx-homepage-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -72,5 +69,3 @@ spec:
services: services:
- name: xdfnx-homepage-service - name: xdfnx-homepage-service
port: 80 port: 80
tls:
secretName: internal-wildcard-tls
+1 -3
View File
@@ -16,7 +16,7 @@ spec:
- name: kener-service - name: kener-service
port: 3000 port: 3000
tls: tls:
secretName: kener-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -32,5 +32,3 @@ spec:
services: services:
- name: kener-service - name: kener-service
port: 3000 port: 3000
tls:
secretName: internal-wildcard-tls
-28
View File
@@ -1,28 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: metube-prod-tls
namespace: metube
spec:
secretName: metube-prod-tls
dnsNames:
- metube.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: metube
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+1 -3
View File
@@ -15,7 +15,7 @@ spec:
- name: metube-service - name: metube-service
port: 8081 port: 8081
tls: tls:
secretName: metube-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -31,5 +31,3 @@ spec:
services: services:
- name: metube-service - name: metube-service
port: 8081 port: 8081
tls:
secretName: internal-wildcard-tls
-2
View File
@@ -32,5 +32,3 @@ spec:
services: services:
- name: n8n-service - name: n8n-service
port: 5678 port: 5678
tls:
secretName: internal-wildcard-tls
-15
View File
@@ -1,15 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: n8n
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
-28
View File
@@ -1,28 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: netronome-prod-tls
namespace: netronome
spec:
secretName: netronome-prod-tls
dnsNames:
- nm.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: netronome
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+1 -3
View File
@@ -16,7 +16,7 @@ spec:
- name: netronome-service - name: netronome-service
port: 7575 port: 7575
tls: tls:
secretName: netronome-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -32,5 +32,3 @@ spec:
services: services:
- name: netronome-service - name: netronome-service
port: 7575 port: 7575
tls:
secretName: internal-wildcard-tls
-28
View File
@@ -1,28 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: nextcloud-prod-tls
namespace: nextcloud
spec:
secretName: nextcloud-prod-tls
dnsNames:
- nextcloud.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: nextcloud
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+21 -26
View File
@@ -18,7 +18,7 @@ spec:
- name: nextcloud-apache - name: nextcloud-apache
port: 11000 port: 11000
tls: tls:
secretName: nextcloud-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -38,29 +38,26 @@ spec:
services: services:
- name: nextcloud-apache - name: nextcloud-apache
port: 11000 port: 11000
# --- # ---
# Nextcloud AIO # Nextcloud AIO
# apiVersion: traefik.io/v1alpha1 # apiVersion: traefik.io/v1alpha1
# kind: IngressRoute # kind: IngressRoute
# metadata: # metadata:
# name: naio-prod # name: naio-prod
# namespace: nextcloud # namespace: nextcloud
# spec: # spec:
# entryPoints: # entryPoints:
# - websecure # - websecure
# routes: # routes:
# - match: Host(`naio.forust.xyz`) # - match: Host(`naio.forust.xyz`)
# kind: Rule # kind: Rule
# services: # services:
# - name: nextcloud-aio # - name: nextcloud-aio
# port: 8888 # port: 8888
# scheme: https # scheme: https
# serversTransport: insecure-transport # serversTransport: insecure-transport
# tls: # tls:
# certResolver: letsencrypt # certResolver: letsencrypt
tls:
secretName: internal-wildcard-tls
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -78,5 +75,3 @@ spec:
port: 8888 port: 8888
scheme: https scheme: https
serversTransport: insecure-transport serversTransport: insecure-transport
tls:
secretName: internal-wildcard-tls
-28
View File
@@ -1,28 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: portainer-prod-tls
namespace: portainer
spec:
secretName: portainer-prod-tls
dnsNames:
- portainer.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: portainer
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+1 -3
View File
@@ -16,7 +16,7 @@ spec:
- name: portainer-service - name: portainer-service
port: 9000 port: 9000
tls: tls:
secretName: portainer-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -32,5 +32,3 @@ spec:
services: services:
- name: portainer-service - name: portainer-service
port: 9000 port: 9000
tls:
secretName: internal-wildcard-tls
-1
View File
@@ -3,4 +3,3 @@ AUTHENTIK_DB_PASSWORD=
GITEA_DB_PASSWORD= GITEA_DB_PASSWORD=
NETRONOME_DB_PASSWORD= NETRONOME_DB_PASSWORD=
PENPOT_DB_PASSWORD= PENPOT_DB_PASSWORD=
STATUSPAGE_DB_PASSWORD=
+14 -13
View File
@@ -1,22 +1,23 @@
# Shared PostgreSQL # Shared PostgreSQL
This directory contains the shared PostgreSQL 17 deployment for Authentik, This directory contains a PostgreSQL 15 deployment draft for Authentik, Gitea,
Gitea, Netronome, and Statuspage. It creates one database and one login role Netronome, and Penpot. It creates one database and one login role per service;
per service. Per-service standalone databases were removed after the it does not migrate existing data or change application connection settings.
migration (Sep 2026); Penpot stays on its own compose PostgreSQL (archived,
not part of the shared instance).
## Compatibility baseline ## Compatibility baseline
| Service | Current application | Shared PostgreSQL 17 | | Service | Current application | Current standalone PostgreSQL | Common PostgreSQL 15 |
| ---------- | ------------------- | -------------------------------------- | | --------- | ------------------- | ----------------------------: | ------------------------------------------------------------------------------------ |
| Authentik | 2025.10.x | Supported (Authentik requires 14+) | | Authentik | 2025.10.2 | 15 | Supported (Authentik requires 14+) |
| Gitea | 1.27.3 | Supported (Gitea requires 12+) | | Gitea | 1.27.3 | 14 | Supported (Gitea requires 12+) |
| Netronome | 0.14.0 | Supported (upstream's example uses 17) | | Netronome | 0.14.0 | 17 | Validate in staging; upstream's example uses 17 but no 17-only feature is documented |
| Statuspage | custom | Supported | | Penpot | 2.17.2 | 15 | Supported by the official deployment |
A major-version change must use a logical dump/restore; changing only the PostgreSQL 15 is the conservative common major. A major-version downgrade or
image tag while keeping a data directory is not supported. change must use a logical dump/restore; changing only the image tag while
keeping a data directory is not supported. Back up and migrate one application
at a time, starting with Netronome because its current standalone deployment
uses PostgreSQL 17.
For Compose, copy `.env.example` to `.env`, set all passwords, and start it with For Compose, copy `.env.example` to `.env`, set all passwords, and start it with
`docker compose -f shared-compose.yaml up -d`. This file is intentionally not `docker compose -f shared-compose.yaml up -d`. This file is intentionally not
+1 -2
View File
@@ -5,12 +5,12 @@ set -euo pipefail
: "${GITEA_DB_PASSWORD:?GITEA_DB_PASSWORD is required}" : "${GITEA_DB_PASSWORD:?GITEA_DB_PASSWORD is required}"
: "${NETRONOME_DB_PASSWORD:?NETRONOME_DB_PASSWORD is required}" : "${NETRONOME_DB_PASSWORD:?NETRONOME_DB_PASSWORD is required}"
: "${PENPOT_DB_PASSWORD:?PENPOT_DB_PASSWORD is required}" : "${PENPOT_DB_PASSWORD:?PENPOT_DB_PASSWORD is required}"
: "${STATUSPAGE_DB_PASSWORD:?STATUSPAGE_DB_PASSWORD is required}"
create_role_and_database() { create_role_and_database() {
local role="$1" local role="$1"
local database="$2" local database="$2"
local password="$3" local password="$3"
psql --username "$POSTGRES_USER" --dbname postgres \ psql --username "$POSTGRES_USER" --dbname postgres \
-v role="$role" -v database="$database" -v password="$password" \ -v role="$role" -v database="$database" -v password="$password" \
<<'SQL' <<'SQL'
@@ -25,4 +25,3 @@ create_role_and_database authentik authentik "$AUTHENTIK_DB_PASSWORD"
create_role_and_database gitea gitea "$GITEA_DB_PASSWORD" create_role_and_database gitea gitea "$GITEA_DB_PASSWORD"
create_role_and_database netronome netronome "$NETRONOME_DB_PASSWORD" create_role_and_database netronome netronome "$NETRONOME_DB_PASSWORD"
create_role_and_database penpot penpot "$PENPOT_DB_PASSWORD" create_role_and_database penpot penpot "$PENPOT_DB_PASSWORD"
create_role_and_database statuspage statuspage "$STATUSPAGE_DB_PASSWORD"
-12
View File
@@ -60,23 +60,11 @@ spec:
command: ["pg_isready", "-U", "postgres", "-d", "postgres"] command: ["pg_isready", "-U", "postgres", "-d", "postgres"]
initialDelaySeconds: 10 initialDelaySeconds: 10
periodSeconds: 10 periodSeconds: 10
startupProbe:
exec:
command: ["pg_isready", "-U", "postgres", "-d", "postgres"]
failureThreshold: 30
periodSeconds: 10
livenessProbe: livenessProbe:
exec: exec:
command: ["pg_isready", "-U", "postgres", "-d", "postgres"] command: ["pg_isready", "-U", "postgres", "-d", "postgres"]
initialDelaySeconds: 30 initialDelaySeconds: 30
periodSeconds: 20 periodSeconds: 20
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "2Gi"
cpu: "2000m"
volumes: volumes:
- name: postgres-data - name: postgres-data
persistentVolumeClaim: persistentVolumeClaim:
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
postgres: postgres:
image: postgres:17.11-alpine image: postgres:15.19-alpine
container_name: homelab-postgres container_name: homelab-postgres
restart: unless-stopped restart: unless-stopped
env_file: env_file:
@@ -1,43 +0,0 @@
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: cert-manager
namespace: prometheus
labels:
release: prometheus-stack
spec:
groups:
- name: cert-manager
rules:
- alert: CertManagerCertNotReady
expr: certmanager_certificate_ready_status{condition="False"} == 1
for: 10m
labels:
severity: critical
annotations:
summary: "Certificate {{ $labels.exported_namespace }}/{{ $labels.name }} is not ready"
description: "Certificate has been failing for more than 10 minutes. Check Challenges/Orders in that namespace."
- alert: CertManagerCertExpirySoon
expr: certmanager_certificate_expiration_timestamp_seconds - time() < 86400 * 14
for: 1h
labels:
severity: warning
annotations:
summary: "Certificate {{ $labels.exported_namespace }}/{{ $labels.name }} expires in less than 14 days"
description: "Renewal should have happened automatically. Check cert-manager logs if it persists."
- alert: CertManagerCertExpiryCritical
expr: certmanager_certificate_expiration_timestamp_seconds - time() < 86400 * 7
for: 1h
labels:
severity: critical
annotations:
summary: "Certificate {{ $labels.exported_namespace }}/{{ $labels.name }} expires in less than 7 days"
description: "Manual intervention likely needed: cmctl status certificate {{ $labels.name }} -n {{ $labels.exported_namespace }}."
- alert: CertManagerHittingRateLimits
expr: sum by (exported_namespace) (rate(certmanager_http_acme_client_request_count{status=~"429.*"}[10m])) > 0
for: 15m
labels:
severity: warning
annotations:
summary: "cert-manager is hitting ACME rate limits"
description: "Back off manual issuance retries and check failed Orders/Challenges."
-28
View File
@@ -1,28 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: grafana-prod-tls
namespace: prometheus
spec:
secretName: grafana-prod-tls
dnsNames:
- grafana.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: prometheus
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+3 -3
View File
@@ -16,7 +16,9 @@ spec:
- name: prometheus-stack-grafana - name: prometheus-stack-grafana
port: 80 port: 80
tls: tls:
secretName: grafana-prod-tls certResolver: letsencrypt
domains:
- main: grafana.forust.xyz
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -32,5 +34,3 @@ spec:
services: services:
- name: prometheus-stack-grafana - name: prometheus-stack-grafana
port: 80 port: 80
tls:
secretName: internal-wildcard-tls
-4
View File
@@ -1,4 +0,0 @@
apiVersion: v1
kind: Namespace
metadata:
name: reloader
+1 -30
View File
@@ -1,43 +1,14 @@
{ {
"$schema": "https://docs.renovatebot.com/renovate-schema.json", "$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"], "extends": ["config:recommended"],
"enabledManagers": ["dockerfile", "docker-compose", "kubernetes", "helm-values", "custom.regex"], "enabledManagers": ["docker-compose", "kubernetes", "helm-values"],
"helm-values": { "helm-values": {
"managerFilePatterns": ["/k8s/.+values\\.ya?ml$/"] "managerFilePatterns": ["/k8s/.+values\\.ya?ml$/"]
}, },
"kubernetes": { "kubernetes": {
"managerFilePatterns": ["/k8s/.+\\.ya?ml$/"] "managerFilePatterns": ["/k8s/.+\\.ya?ml$/"]
}, },
"customManagers": [
{
"customType": "regex",
"description": "singlesource: playwright npm version pinned in npx command (k8s + compose)",
"fileMatch": ["^edu_master/k8s/playwright\\.yaml$", "^edu_master/compose\\.yaml$"],
"matchStrings": ["playwright@(?<currentValue>\\d+\\.\\d+\\.\\d+)"],
"datasourceTemplate": "npm",
"depNameTemplate": "playwright"
},
{
"customType": "regex",
"description": "singlesource: PLAYWRIGHT_VERSION file",
"fileMatch": ["^edu_master/PLAYWRIGHT_VERSION$"],
"matchStrings": ["^(?<currentValue>\\d+\\.\\d+\\.\\d+)$"],
"datasourceTemplate": "pypi",
"depNameTemplate": "playwright"
}
],
"packageRules": [ "packageRules": [
{
"description": "singlesource playwright - use whichever version is found, keep docker+pypi+npm in sync",
"matchPackageNames": ["playwright", "mcr.microsoft.com/playwright"],
"groupName": "playwright singlesource",
"groupSlug": "playwright"
},
{
"description": "playwright must not automerge - version skew breaks WS handshake (checker.py:1523 vs playwright.yaml:20)",
"matchPackageNames": ["playwright", "mcr.microsoft.com/playwright"],
"automerge": false
},
{ {
"description": "Keep private homelab images unchanged", "description": "Keep private homelab images unchanged",
"matchDatasources": ["docker"], "matchDatasources": ["docker"],
+1 -1
View File
@@ -1,6 +1,6 @@
module.exports = { module.exports = {
platform: 'gitea', platform: 'gitea',
endpoint: process.env.RENOVATE_ENDPOINT || 'https://gitea.forust.xyz/api/v1', endpoint: process.env.RENOVATE_ENDPOINT,
enabledManagers: ['docker-compose', 'kubernetes', 'helm-values'], enabledManagers: ['docker-compose', 'kubernetes', 'helm-values'],
'helm-values': { 'helm-values': {
managerFilePatterns: ['/k8s/.+values\\.ya?ml$/'], managerFilePatterns: ['/k8s/.+values\\.ya?ml$/'],
+1 -1
View File
@@ -7,7 +7,7 @@ data:
config.js: | config.js: |
module.exports = { module.exports = {
platform: 'gitea', platform: 'gitea',
endpoint: process.env.RENOVATE_ENDPOINT || 'https://gitea.forust.xyz/api/v1', endpoint: process.env.RENOVATE_ENDPOINT,
enabledManagers: ['docker-compose', 'kubernetes', 'helm-values'], enabledManagers: ['docker-compose', 'kubernetes', 'helm-values'],
'helm-values': { 'helm-values': {
managerFilePatterns: ['/k8s/.+values\\.ya?ml$/'], managerFilePatterns: ['/k8s/.+values\\.ya?ml$/'],
+1 -1
View File
@@ -16,7 +16,7 @@ spec:
restartPolicy: Never restartPolicy: Never
containers: containers:
- name: renovate - name: renovate
image: renovate/renovate:44.106.0 image: renovate/renovate:44.104.2
env: env:
- name: RENOVATE_PLATFORM - name: RENOVATE_PLATFORM
value: gitea value: gitea
-29
View File
@@ -1,29 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: searxng-prod-tls
namespace: searxng
spec:
secretName: searxng-prod-tls
dnsNames:
- s.forust.xyz
- search.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: searxng
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+1 -3
View File
@@ -16,7 +16,7 @@ spec:
- name: searxng-service - name: searxng-service
port: 8080 port: 8080
tls: tls:
secretName: searxng-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -32,5 +32,3 @@ spec:
services: services:
- name: searxng-service - name: searxng-service
port: 8080 port: 8080
tls:
secretName: internal-wildcard-tls
-28
View File
@@ -1,28 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: termix-prod-tls
namespace: termix
spec:
secretName: termix-prod-tls
dnsNames:
- termix.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: termix
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+1 -3
View File
@@ -16,7 +16,7 @@ spec:
- name: termix-service - name: termix-service
port: 8080 port: 8080
tls: tls:
secretName: termix-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -32,5 +32,3 @@ spec:
services: services:
- name: termix-service - name: termix-service
port: 8080 port: 8080
tls:
secretName: internal-wildcard-tls
-28
View File
@@ -1,28 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: dashboard-prod-tls
namespace: traefik
spec:
secretName: dashboard-prod-tls
dnsNames:
- traefik.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: traefik
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+1 -3
View File
@@ -17,7 +17,7 @@ spec:
- name: api@internal - name: api@internal
kind: TraefikService kind: TraefikService
tls: tls:
secretName: dashboard-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -33,5 +33,3 @@ spec:
services: services:
- name: api@internal - name: api@internal
kind: TraefikService kind: TraefikService
tls:
secretName: internal-wildcard-tls
+16 -2
View File
@@ -31,7 +31,7 @@ deployment:
providers: providers:
kubernetesIngress: kubernetesIngress:
enabled: true enabled: false
kubernetesCRD: kubernetesCRD:
enabled: true enabled: true
kubernetesGateway: kubernetesGateway:
@@ -121,7 +121,21 @@ persistence:
size: 100Mi size: 100Mi
path: /data path: /data
# No certificatesResolvers: public TLS comes from cert-manager. certificatesResolvers:
letsencrypt-staging:
acme:
email: bobrovod@national.shitposting.agency
storage: /data/letsencrypt/acme.json
caServer: https://acme-staging-v02.api.letsencrypt.org/directory
httpChallenge:
entryPoint: web
letsencrypt:
acme:
email: bobrovod@national.shitposting.agency
storage: /data/letsencrypt/acme.json
caServer: https://acme-v02.api.letsencrypt.org/directory
httpChallenge:
entryPoint: web
volumes: volumes:
- name: traefik-dynamic - name: traefik-dynamic
-28
View File
@@ -1,28 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: uptime-kuma-prod-tls
namespace: uptime-kuma
spec:
secretName: uptime-kuma-prod-tls
dnsNames:
- uptime.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: uptime-kuma
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+1 -3
View File
@@ -16,7 +16,7 @@ spec:
- name: uptime-kuma-service - name: uptime-kuma-service
port: 3001 port: 3001
tls: tls:
secretName: uptime-kuma-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -32,5 +32,3 @@ spec:
services: services:
- name: uptime-kuma-service - name: uptime-kuma-service
port: 3001 port: 3001
tls:
secretName: internal-wildcard-tls
@@ -1,15 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: userbot
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
-1
View File
@@ -4,5 +4,4 @@ kind: Kustomization
resources: resources:
- userbots.yaml - userbots.yaml
- panel.yaml - panel.yaml
- internal-certificate.yaml
- common-config.yaml - common-config.yaml
-2
View File
@@ -262,5 +262,3 @@ spec:
services: services:
- name: userbot-panel - name: userbot-panel
port: 8080 port: 8080
tls:
secretName: internal-wildcard-tls
-28
View File
@@ -1,28 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: vaultwarden-prod-tls
namespace: vaultwarden
spec:
secretName: vaultwarden-prod-tls
dnsNames:
- vw.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: vaultwarden
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+1 -3
View File
@@ -13,7 +13,7 @@ spec:
- name: vaultwarden-service - name: vaultwarden-service
port: 80 port: 80
tls: tls:
secretName: vaultwarden-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -29,5 +29,3 @@ spec:
services: services:
- name: vaultwarden-service - name: vaultwarden-service
port: 80 port: 80
tls:
secretName: internal-wildcard-tls
-28
View File
@@ -1,28 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: xray-prod-tls
namespace: xui
spec:
secretName: xray-prod-tls
dnsNames:
- xray.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: xui
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+1 -6
View File
@@ -12,9 +12,6 @@ spec:
services: services:
- name: xui-service - name: xui-service
port: 30379 port: 30379
tls:
secretName: internal-wildcard-tls
--- ---
# Public panel access (optional). # Public panel access (optional).
# Realistic, but intentionally disabled: the panel has its own login, # Realistic, but intentionally disabled: the panel has its own login,
@@ -61,7 +58,7 @@ spec:
- name: xui-service - name: xui-service
port: 10000 port: 10000
tls: tls:
secretName: xray-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -82,5 +79,3 @@ spec:
services: services:
- name: xui-service - name: xui-service
port: 10000 port: 10000
tls:
secretName: internal-wildcard-tls