Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50b08cba4b | ||
|
|
489cf11416 | ||
|
|
02f71dab49 |
No files matched your search
@@ -105,7 +105,7 @@ approve decisions, or supervise work. The specification does all of this.
|
|||||||
| Service | Location | Access |
|
| Service | Location | Access |
|
||||||
|---------|----------|--------|
|
|---------|----------|--------|
|
||||||
| Gitea | `http://185.19.28.170:3000` (SSH :22) — behind the shared ingress NLB | User: heaviness510 |
|
| Gitea | `http://185.19.28.170:3000` (SSH :22) — behind the shared ingress NLB | User: heaviness510 |
|
||||||
| Bikoret | `http://185.19.28.170` — behind the same ingress (catch-all; `shorashim.ch` once DNS points there) | keys: KOHEN.md "Issuing a key" |
|
| Bikoret | `https://bikoret.ch` (also `http://185.19.28.170`, the catch-all; `shorashim.ch` retired 2026-10-20) | keys: KOHEN.md "Issuing a key" |
|
||||||
| PostgreSQL | Exoscale DBaaS ch-gva-2 | `temple-council-db` |
|
| PostgreSQL | Exoscale DBaaS ch-gva-2 | `temple-council-db` |
|
||||||
| Kubernetes | Exoscale SKS ch-gva-2 | `~/.kube/exoscale-temple-council.yaml` |
|
| Kubernetes | Exoscale SKS ch-gva-2 | `~/.kube/exoscale-temple-council.yaml` |
|
||||||
|
|
||||||
|
|||||||
@@ -481,11 +481,34 @@ If any day failed: record what broke, fix the cause, restart from Day 1.
|
|||||||
```bash
|
```bash
|
||||||
# 1. Write today's manna provision (same format as Day 4, new date)
|
# 1. Write today's manna provision (same format as Day 4, new date)
|
||||||
# 2. Health check
|
# 2. Health check
|
||||||
curl https://shorashim.ch/kodesh/health | jq .
|
curl https://bikoret.ch/kodesh/health | jq .
|
||||||
# 3. Halt status
|
# 3. Halt status
|
||||||
curl https://shorashim.ch/kodesh/status | jq .halt_status
|
curl https://bikoret.ch/kodesh/status | jq .halt_status
|
||||||
# 4. Refuge list
|
# 4. Refuge list
|
||||||
curl https://shorashim.ch/kodesh/status | jq '.refuge_components'
|
curl https://bikoret.ch/kodesh/status | jq '.refuge_components'
|
||||||
|
```
|
||||||
|
|
||||||
|
**The watchdog — *shomer*.** `scripts/bikoret-watchdog` (installed at
|
||||||
|
`~/.local/bin/`, cron every 5 minutes on the dev VM) probes
|
||||||
|
`/kodesh/health` from outside the cluster. Two consecutive failures —
|
||||||
|
ten minutes, so a rollout's own blip does not alert — open ONE Gitea
|
||||||
|
issue in this repo titled `WATCHDOG: bikoret.ch down since …`, naming
|
||||||
|
the failed checks and what to read first; while down it comments only
|
||||||
|
when the failed set changes; on recovery it comments the duration and
|
||||||
|
closes the issue. Every transition goes to
|
||||||
|
`~/shorashim-operator-notes/watchdog.log`. It needs a Gitea token with
|
||||||
|
`write:issue` at `~/.config/bikoret/gitea_watchdog_token` (0600) — the
|
||||||
|
clone token in `~/.git-credentials` has only the code scopes and Gitea
|
||||||
|
answers 403 on an issue. The kohen issues it (Gitea → Settings →
|
||||||
|
Applications → token, scopes `read:issue` + `write:issue`); the builder
|
||||||
|
does not write credentials. Until it exists the watchdog still logs and
|
||||||
|
tracks recovery; it cannot open the issue. 2026-10-15: the site was 503
|
||||||
|
for 13.5 h and the Doorpost outage was found by losing Claude — nothing
|
||||||
|
watched.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tail ~/shorashim-operator-notes/watchdog.log # transitions only; silence is health
|
||||||
|
BIKORET_WATCHDOG_DRY=1 bikoret-watchdog # a probe that opens nothing
|
||||||
```
|
```
|
||||||
|
|
||||||
### Issuing a key — *Mishmeret HaSha'ar*
|
### Issuing a key — *Mishmeret HaSha'ar*
|
||||||
@@ -730,14 +753,19 @@ run on Saturday.
|
|||||||
### The manna, each morning — *Dvar yom b'yomo*
|
### The manna, each morning — *Dvar yom b'yomo*
|
||||||
|
|
||||||
Since 2026-10-15 the day's provision is written by the system's own hand:
|
Since 2026-10-15 the day's provision is written by the system's own hand:
|
||||||
CronJob `manna` (`deploy/k8s/manna-cronjob.yaml`) runs at 00:01Z Sunday
|
CronJob `manna` (`deploy/k8s/manna-cronjob.yaml`) runs at 00:00Z Sunday
|
||||||
to Friday, mounts the manna PVC and writes `today.json` with the amounts
|
to Friday, mounts the manna PVC and writes `today.json` with the amounts
|
||||||
from `shorashim-config` (`MANNA_COMPUTE_UNITS` …). Before it, every night
|
from `shorashim-config` (`MANNA_COMPUTE_UNITS` …). Before it, every night
|
||||||
at 00:00Z the old provision went stale and the readiness probe failed
|
at 00:00Z the old provision went stale and the readiness probe failed
|
||||||
until someone woke (13.5 hours on 2026-10-15). The kohen's acts now:
|
until someone woke (13.5 hours on 2026-10-15). With the job at 00:01Z the
|
||||||
|
site was still 503 for one to five minutes every night (the watchdog saw
|
||||||
|
it, 2026-10-21): the provision is stale from 00:00:00 and readiness fell
|
||||||
|
before the job wrote. Now the job runs at 00:00Z and readiness waits 60 s
|
||||||
|
(`failureThreshold: 6`) before the Service stops routing — the manna
|
||||||
|
falls at the boundary, and the pod is carried across it. The kohen's acts now:
|
||||||
|
|
||||||
- set the amounts in the ConfigMap (`kubectl apply -f deploy/k8s/configmap.yaml`);
|
- set the amounts in the ConfigMap (`kubectl apply -f deploy/k8s/configmap.yaml`);
|
||||||
- read `kubectl -n shorashim get jobs` after 00:01Z — a failed job is the
|
- read `kubectl -n shorashim get jobs` after 00:00Z — a failed job is the
|
||||||
Day-4 alarm; write by hand with `kubectl cp` (Day 4) if it failed;
|
Day-4 alarm; write by hand with `kubectl cp` (Day 4) if it failed;
|
||||||
- run it at once when needed: `kubectl -n shorashim create job --from=cronjob/manna manna-now`.
|
- run it at once when needed: `kubectl -n shorashim create job --from=cronjob/manna manna-now`.
|
||||||
|
|
||||||
@@ -750,7 +778,7 @@ The halt monitor detected one of three cardinal sins:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Inspect the halt
|
# Inspect the halt
|
||||||
curl https://shorashim.ch/kodesh/status | jq .halt_status
|
curl https://bikoret.ch/kodesh/status | jq .halt_status
|
||||||
kubectl logs -n shorashim deploy/bikoret -c vav-pipeline | grep HALT
|
kubectl logs -n shorashim deploy/bikoret -c vav-pipeline | grep HALT
|
||||||
|
|
||||||
# Investigate. Fix the root cause. Then resolve via the operator API.
|
# Investigate. Fix the root cause. Then resolve via the operator API.
|
||||||
|
|||||||
@@ -40,8 +40,9 @@ Kept in `deploy/k8s/`:
|
|||||||
- `gitea-mcp-ingress.yaml` — TLS ingress `mcp.bikoret.ch` for the MCP server
|
- `gitea-mcp-ingress.yaml` — TLS ingress `mcp.bikoret.ch` for the MCP server
|
||||||
- `gitea-mcp-secret.yaml` — template for a Gitea PAT (static-token mode only; kept for rollback)
|
- `gitea-mcp-secret.yaml` — template for a Gitea PAT (static-token mode only; kept for rollback)
|
||||||
- `gitea-ingress.yaml` — TLS ingress `git.bikoret.ch` for Gitea itself (namespace temple-council), required by OAuth mode
|
- `gitea-ingress.yaml` — TLS ingress `git.bikoret.ch` for Gitea itself (namespace temple-council), required by OAuth mode
|
||||||
- `gitea-public-patch.yaml` — patch that sets Gitea's ROOT_URL/DOMAIN/SSH_DOMAIN to `git.bikoret.ch`
|
- `gitea-public-patch.yaml` — patch that sets Gitea's ROOT_URL/DOMAIN/SSH_DOMAIN to `git.bikoret.ch` and APP_NAME to `Bikoret`
|
||||||
- `gitea-public-apply.sh` / `gitea-public-verify.sh` — the operator's apply and read-only check for the four above
|
- `gitea-public-apply.sh` / `gitea-public-verify.sh` — the operator's apply and read-only check for the four above
|
||||||
|
- `shorashim-ch-takedown.sh` — 2026-10-20: removes the shorashim.ch ingress rule and the orphan mcp.shorashim.ch secret, applies the Gitea brand patch
|
||||||
- `cert-manager-issuer.yaml` — Let's Encrypt ClusterIssuer
|
- `cert-manager-issuer.yaml` — Let's Encrypt ClusterIssuer
|
||||||
|
|
||||||
## Containers
|
## Containers
|
||||||
|
|||||||
@@ -266,6 +266,13 @@ spec:
|
|||||||
port: 4000
|
port: 4000
|
||||||
initialDelaySeconds: 10
|
initialDelaySeconds: 10
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
|
# 60 s of failures before the Service stops routing here (the
|
||||||
|
# default is 30 s). The manna goes stale at 00:00:00 and the
|
||||||
|
# CronJob writes the new day's within seconds; the cushion
|
||||||
|
# carries the pod across that boundary without a 503. A real
|
||||||
|
# outage still shows within a minute, and the watchdog's own
|
||||||
|
# threshold is ten.
|
||||||
|
failureThreshold: 6
|
||||||
|
|
||||||
# === SHORESH ENGINE ===
|
# === SHORESH ENGINE ===
|
||||||
# Not a container. It runs as an Erlang Port inside
|
# Not a container. It runs as an Erlang Port inside
|
||||||
|
|||||||
@@ -26,3 +26,7 @@ spec:
|
|||||||
value: "git.bikoret.ch"
|
value: "git.bikoret.ch"
|
||||||
- name: GITEA__server__SSH_DOMAIN
|
- name: GITEA__server__SSH_DOMAIN
|
||||||
value: "git.bikoret.ch"
|
value: "git.bikoret.ch"
|
||||||
|
# [DEFAULT] APP_NAME — the title and brand on every Gitea page.
|
||||||
|
# Was "Shorashim"; the system is named Bikoret (PRD §0).
|
||||||
|
- name: GITEA____APP_NAME
|
||||||
|
value: "Bikoret"
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
# Until 2026-10-15 the day's provision was written by the kohen with
|
# Until 2026-10-15 the day's provision was written by the kohen with
|
||||||
# `kubectl cp`, and every night at 00:00Z the old one went stale and the
|
# `kubectl cp`, and every night at 00:00Z the old one went stale and the
|
||||||
# readiness probe failed until someone woke (13.5 hours on 2026-10-15).
|
# readiness probe failed until someone woke (13.5 hours on 2026-10-15).
|
||||||
# This CronJob writes today.json onto the manna PVC at 00:01Z, Sunday to
|
# This CronJob writes today.json onto the manna PVC at 00:00Z, Sunday to
|
||||||
# Friday — a double portion on Friday that is valid through Shabbat, and
|
# Friday — a double portion on Friday that is valid through Shabbat, and
|
||||||
# nothing on Shabbat (Shemot 16:22–26: "on the sixth day they gathered
|
# nothing on Shabbat (Shemot 16:22–26: "on the sixth day they gathered
|
||||||
# double … on the seventh day there shall be none"). The kohen still sets
|
# double … on the seventh day there shall be none"). The kohen still sets
|
||||||
@@ -23,10 +23,14 @@ metadata:
|
|||||||
app.kubernetes.io/name: shorashim
|
app.kubernetes.io/name: shorashim
|
||||||
app.kubernetes.io/component: manna
|
app.kubernetes.io/component: manna
|
||||||
spec:
|
spec:
|
||||||
schedule: "1 0 * * 0-5" # 00:01Z, Sunday–Friday; none on Shabbat
|
schedule: "0 0 * * 0-5" # 00:00Z, Sunday–Friday; none on Shabbat. At the boundary, not after it:
|
||||||
|
# the provision goes stale at 00:00:00 and readiness has a 60 s cushion
|
||||||
|
# (deployment.yaml failureThreshold 6) — a job that writes within it never
|
||||||
|
# flips the pod. At 00:01 the site was 503 for 1–5 min every night
|
||||||
|
# (watchdog, 2026-10-21).
|
||||||
timeZone: "Etc/UTC"
|
timeZone: "Etc/UTC"
|
||||||
concurrencyPolicy: Forbid
|
concurrencyPolicy: Forbid
|
||||||
startingDeadlineSeconds: 3600 # a missed 00:01 still runs within the hour
|
startingDeadlineSeconds: 3600 # a missed 00:00 still runs within the hour
|
||||||
successfulJobsHistoryLimit: 3
|
successfulJobsHistoryLimit: 3
|
||||||
failedJobsHistoryLimit: 3
|
failedJobsHistoryLimit: 3
|
||||||
jobTemplate:
|
jobTemplate:
|
||||||
|
|||||||
+3
-13
@@ -38,8 +38,9 @@ spec:
|
|||||||
# believes that header only from TRUSTED_PROXIES (configmap.yaml).
|
# believes that header only from TRUSTED_PROXIES (configmap.yaml).
|
||||||
#
|
#
|
||||||
# Hosts (2026-10-13, the name): bikoret.ch is the place; www.bikoret.ch
|
# Hosts (2026-10-13, the name): bikoret.ch is the place; www.bikoret.ch
|
||||||
# and the old shorashim.ch are served too; bikoret.org (second Ingress
|
# is served too; bikoret.org (second Ingress
|
||||||
# below) only redirects. TLS from Let's Encrypt through cert-manager's
|
# below) only redirects. The old shorashim.ch rule was taken down on
|
||||||
|
# 2026-10-20 (the domain is retired). TLS from Let's Encrypt through cert-manager's
|
||||||
# letsencrypt-prod ClusterIssuer, HTTP-01 — so apply this ONLY after
|
# letsencrypt-prod ClusterIssuer, HTTP-01 — so apply this ONLY after
|
||||||
# `dig +short A bikoret.ch` answers 185.19.28.170: a certificate asked
|
# `dig +short A bikoret.ch` answers 185.19.28.170: a certificate asked
|
||||||
# for before the name resolves only burns the failed-validation limit.
|
# for before the name resolves only burns the failed-validation limit.
|
||||||
@@ -98,17 +99,6 @@ spec:
|
|||||||
name: shorashim
|
name: shorashim
|
||||||
port:
|
port:
|
||||||
number: 80
|
number: 80
|
||||||
# The old name, kept until its callers have moved; no certificate asked for it.
|
|
||||||
- host: shorashim.ch
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: shorashim
|
|
||||||
port:
|
|
||||||
number: 80
|
|
||||||
---
|
---
|
||||||
# bikoret.org: the stranger's guess, redirected to the place. No service
|
# bikoret.org: the stranger's guess, redirected to the place. No service
|
||||||
# of its own; nginx answers the redirect itself. Add its A records too.
|
# of its own; nginx answers the redirect itself. Add its A records too.
|
||||||
|
|||||||
Executable
+29
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Take shorashim.ch down from the cluster and rename Gitea's brand
|
||||||
|
# (2026-10-20). Run by the transmitter:
|
||||||
|
# ! bash ~/.local/bin/shorashim-ch-takedown
|
||||||
|
# Idempotent.
|
||||||
|
# 1. Ingress `shorashim`: the shorashim.ch host rule is gone (service.yaml).
|
||||||
|
# The no-host catch-all stays, so http://185.19.28.170/ still answers —
|
||||||
|
# the name itself dies when its A record is removed at Infomaniak.
|
||||||
|
# 2. The orphan TLS secret for mcp.shorashim.ch is deleted.
|
||||||
|
# 3. Gitea APP_NAME "Shorashim" → "Bikoret" (gitea-public-patch.yaml; one restart).
|
||||||
|
set -euo pipefail
|
||||||
|
REPO="${REPO:-$HOME/bikoret}"
|
||||||
|
cd "$REPO"
|
||||||
|
echo "== 1/3 ingress without the shorashim.ch rule"
|
||||||
|
kubectl apply -f deploy/k8s/service.yaml
|
||||||
|
echo "== 2/3 orphan certificate secret for mcp.shorashim.ch"
|
||||||
|
kubectl -n shorashim delete secret gitea-mcp-tls --ignore-not-found
|
||||||
|
echo "== 3/3 Gitea brand → Bikoret"
|
||||||
|
kubectl -n temple-council patch deploy gitea --patch-file deploy/k8s/gitea-public-patch.yaml
|
||||||
|
kubectl -n temple-council rollout status deploy/gitea --timeout=180s
|
||||||
|
echo "== verify"
|
||||||
|
fail=0
|
||||||
|
hosts=$(kubectl get ingress -A -o jsonpath='{range .items[*]}{range .spec.rules[*]}{.host}{" "}{end}{end}')
|
||||||
|
case " $hosts " in *" shorashim.ch "*) echo "FAIL ingress still lists shorashim.ch"; fail=1;; *) echo "ok no ingress rule for shorashim.ch";; esac
|
||||||
|
case "$(kubectl get secret -A -o name | grep -c 'gitea-mcp-tls$' || true)" in 0) echo "ok gitea-mcp-tls secret gone";; *) echo "FAIL gitea-mcp-tls secret remains"; fail=1;; esac
|
||||||
|
title=$(curl -sS -m 15 https://git.bikoret.ch/ | grep -o -m1 '<title>[^<]*</title>' || echo none)
|
||||||
|
case "$title" in *Bikoret*) echo "ok gitea title: $title";; *) echo "FAIL gitea title: $title"; fail=1;; esac
|
||||||
|
case "$(curl -sS -m 15 -o /dev/null -w '%{http_code}' https://bikoret.ch/kodesh/health || echo err)" in 200) echo "ok bikoret.ch still healthy";; *) echo "FAIL bikoret.ch health"; fail=1;; esac
|
||||||
|
exit $fail
|
||||||
Executable
+167
@@ -0,0 +1,167 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""bikoret-watchdog — the kohen's eye on /kodesh/health, from outside the cluster.
|
||||||
|
|
||||||
|
Every run (cron, every 5 minutes) probes the health endpoint. A failure is
|
||||||
|
anything but 200 with overall "healthy" (a 503 at the day boundary, a
|
||||||
|
Doorpost drift, a hung rollout, the gate dark). After two consecutive
|
||||||
|
failures (10 minutes — a rollout's own blip does not alert) it opens ONE
|
||||||
|
Gitea issue in the repo naming the failed checks; while down it comments
|
||||||
|
only when the failed set changes; on recovery it comments the duration and
|
||||||
|
closes the issue. Every transition goes to the log. 2026-10-15: the site
|
||||||
|
was 503 for 13.5 h at a day boundary and the transmitter learned of the
|
||||||
|
Doorpost outage by losing Claude — nothing watched.
|
||||||
|
|
||||||
|
Paths:
|
||||||
|
state ~/.local/state/bikoret-watchdog/state.json
|
||||||
|
log ~/shorashim-operator-notes/watchdog.log
|
||||||
|
token ~/.git-credentials (the Gitea token; read, never printed)
|
||||||
|
|
||||||
|
Env overrides (for a test run): BIKORET_HEALTH_URL, BIKORET_WATCHDOG_STATE,
|
||||||
|
BIKORET_WATCHDOG_LOG, BIKORET_GITEA_API (default https://git.bikoret.ch/api/v1),
|
||||||
|
BIKORET_GITEA_REPO (default heaviness510/bikoret), BIKORET_WATCHDOG_DRY=1 (no issue).
|
||||||
|
"""
|
||||||
|
import json, os, re, sys, time, urllib.request, urllib.error
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
HOME = os.path.expanduser("~")
|
||||||
|
URL = os.environ.get("BIKORET_HEALTH_URL", "https://bikoret.ch/kodesh/health")
|
||||||
|
STATE = os.environ.get("BIKORET_WATCHDOG_STATE", f"{HOME}/.local/state/bikoret-watchdog/state.json")
|
||||||
|
LOG = os.environ.get("BIKORET_WATCHDOG_LOG", f"{HOME}/shorashim-operator-notes/watchdog.log")
|
||||||
|
API = os.environ.get("BIKORET_GITEA_API", "https://git.bikoret.ch/api/v1")
|
||||||
|
REPO = os.environ.get("BIKORET_GITEA_REPO", "heaviness510/bikoret")
|
||||||
|
DRY = os.environ.get("BIKORET_WATCHDOG_DRY") == "1"
|
||||||
|
THRESHOLD = 2
|
||||||
|
|
||||||
|
|
||||||
|
def now():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
os.makedirs(os.path.dirname(LOG), exist_ok=True)
|
||||||
|
with open(LOG, "a") as f:
|
||||||
|
f.write(f"{now().strftime('%Y-%m-%dT%H:%M:%SZ')} {msg}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def probe():
|
||||||
|
"""(ok, summary, failed_checks)"""
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(urllib.request.Request(URL, headers={"user-agent": "bikoret-watchdog"}), timeout=15) as r:
|
||||||
|
code, body = r.status, r.read()
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
code, body = e.code, e.read()
|
||||||
|
except Exception as e: # DNS, TLS, timeout, refused
|
||||||
|
return False, f"unreachable: {type(e).__name__}: {e}", ["unreachable"]
|
||||||
|
try:
|
||||||
|
d = json.loads(body)
|
||||||
|
checks = d.get("checks") or {}
|
||||||
|
failed = sorted(k for k, v in checks.items() if v != "pass")
|
||||||
|
overall = d.get("overall")
|
||||||
|
ok = code == 200 and overall == "healthy"
|
||||||
|
return ok, f"HTTP {code} overall={overall} failed={failed or 'none'}", failed or ([f"HTTP {code}"] if not ok else [])
|
||||||
|
except Exception:
|
||||||
|
return False, f"HTTP {code} non-JSON body ({len(body)} bytes)", [f"HTTP {code}"]
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
"""The Gitea token: ~/.config/bikoret/gitea_watchdog_token first (a token
|
||||||
|
the kohen issued with write:issue — the clone token in ~/.git-credentials
|
||||||
|
has only the code scopes and Gitea answers 403 on an issue), else that."""
|
||||||
|
try:
|
||||||
|
t = open(f"{HOME}/.config/bikoret/gitea_watchdog_token").read().strip()
|
||||||
|
if t:
|
||||||
|
return t
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
for line in open(f"{HOME}/.git-credentials"):
|
||||||
|
m = re.match(r"https?://([^:]+):([^@]+)@", line.strip())
|
||||||
|
if m:
|
||||||
|
return m.group(2)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def gitea(method, path, payload=None):
|
||||||
|
if DRY:
|
||||||
|
log(f"DRY {method} {path} {json.dumps(payload)[:120] if payload else ''}")
|
||||||
|
return {"number": -1, "html_url": "(dry)"}
|
||||||
|
tok = token()
|
||||||
|
if not tok:
|
||||||
|
log("no Gitea token in ~/.git-credentials — cannot open an issue")
|
||||||
|
return None
|
||||||
|
data = json.dumps(payload).encode() if payload is not None else None
|
||||||
|
req = urllib.request.Request(f"{API}/repos/{REPO}{path}", data=data, method=method,
|
||||||
|
headers={"authorization": f"token {tok}", "content-type": "application/json", "accept": "application/json"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=20) as r:
|
||||||
|
return json.loads(r.read() or b"{}")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
log(f"Gitea {method} {path} -> HTTP {e.code}: {e.read()[:120]!r}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
log(f"Gitea {method} {path} failed: {type(e).__name__}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_state():
|
||||||
|
try:
|
||||||
|
return json.load(open(STATE))
|
||||||
|
except Exception:
|
||||||
|
return {"fails": 0, "down_since": None, "issue": None, "failed": []}
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(s):
|
||||||
|
os.makedirs(os.path.dirname(STATE), exist_ok=True)
|
||||||
|
tmp = STATE + ".tmp"
|
||||||
|
json.dump(s, open(tmp, "w"))
|
||||||
|
os.replace(tmp, STATE)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
s = load_state()
|
||||||
|
ok, summary, failed = probe()
|
||||||
|
t = now().strftime("%Y-%m-%d %H:%M:%SZ")
|
||||||
|
|
||||||
|
if ok:
|
||||||
|
if s["issue"] is not None or s["down_since"]:
|
||||||
|
since = s["down_since"] or t
|
||||||
|
mins = int((now() - datetime.fromisoformat(since.replace("Z", "+00:00").replace(" ", "T"))).total_seconds() // 60)
|
||||||
|
log(f"RECOVERED after {mins} min ({summary})")
|
||||||
|
if s["issue"] is not None:
|
||||||
|
gitea("POST", f"/issues/{s['issue']}/comments", {"body": f"Recovered at {t} after {mins} min. `{summary}`"})
|
||||||
|
gitea("PATCH", f"/issues/{s['issue']}", {"state": "closed"})
|
||||||
|
elif s["fails"]:
|
||||||
|
log(f"ok again after {s['fails']} failed probe(s), no alert raised ({summary})")
|
||||||
|
save_state({"fails": 0, "down_since": None, "issue": None, "failed": []})
|
||||||
|
return 0
|
||||||
|
|
||||||
|
s["fails"] += 1
|
||||||
|
if not s["down_since"]:
|
||||||
|
s["down_since"] = t
|
||||||
|
log(f"FAIL #{s['fails']}: {summary}")
|
||||||
|
|
||||||
|
if s["fails"] >= THRESHOLD and s["issue"] is None:
|
||||||
|
body = (f"bikoret.ch health has failed {s['fails']} consecutive probes since {s['down_since']}.\n\n"
|
||||||
|
f"Last probe: `{summary}`\n\nFailed checks: {', '.join(failed) or 'none named'}\n\n"
|
||||||
|
"What to look at first: the vav-pipeline startup log (`kubectl -n shorashim logs <pod> -c vav-pipeline | grep -v health`) — "
|
||||||
|
"a Doorpost drift, stale manna at the day boundary, and a missing anchor all look the same from outside. "
|
||||||
|
"Claude Code behind the gate is down with the site: `claude-direct` or `claude-key` (KOHEN.md, Fallback).\n\n"
|
||||||
|
"Opened by bikoret-watchdog on the dev VM; it will comment and close this issue on recovery.")
|
||||||
|
r = gitea("POST", "/issues", {"title": f"WATCHDOG: bikoret.ch down since {s['down_since']} ({', '.join(failed)[:60]})", "body": body, "labels": []})
|
||||||
|
if r:
|
||||||
|
s["issue"] = r.get("number")
|
||||||
|
s["failed"] = failed
|
||||||
|
log(f"ALERT issue #{s['issue']} opened: {r.get('html_url')}")
|
||||||
|
elif s["issue"] is not None and failed != s.get("failed"):
|
||||||
|
gitea("POST", f"/issues/{s['issue']}/comments", {"body": f"{t}: failed checks now {', '.join(failed)} — `{summary}`"})
|
||||||
|
s["failed"] = failed
|
||||||
|
log(f"issue #{s['issue']} updated: failed={failed}")
|
||||||
|
|
||||||
|
save_state(s)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in new issue
Block a user