Compare commits

..

5 Commits

Author SHA1 Message Date
Niklas Ye 477454ec3c Sätt chartets platshållarversion till 0.9.2
CI / test (push) Successful in 39s
Release / test (push) Successful in 5s
Release / chart (push) Successful in 2s
Release / image (push) Successful in 1m38s
Release / binaries (push) Successful in 2m15s
Kosmetiskt, och görs ändå. .gitea/workflows/release.yaml stämplar både
version och appVersion från git-taggen när det publicerar (766f439), så de
här två raderna avgör ingenting om vad som hamnar i registret. Det enda de
gör är att bli lästa, och de sa 0.9.0 och "latest" genom både v0.9.0 och
v0.9.1 — ett träd på väg mot v0.9.2 som säger 0.9.0 påstår något falskt om
sig självt.

Första gången det görs i det här repot, så det finns inga tidigare
tillfällen att hänvisa till. Kommentaren ovanför raderna skrevs om samtidigt:
den hävdade att "latest" var ärligt eftersom det matchade image.tag i
values.yaml, vilket slutade gälla i och med den här ändringen. image.tag
ligger kvar på "latest", som är vad en lokal installation faktiskt drar;
appVersion är metadata och styr ingenting. Att låta kommentaren stå kvar
hade varit sämre än ingen kommentar alls, eftersom den är det en läsare
kontrollerar fälten mot.

Claude-Session: https://claude.ai/code/session_01S7R4gWTz5wh5xCY4nCSJjN
2026-09-01 21:48:48 +02:00
Niklas Ye 10812606bf Lägg terdut-server under den gemensamma släppprocessen
Släppprocessen (~/.claude/skills/release) körde hittills bara riksdata och
rd-web, och vägrade den här katalogen med "not one of the release-managed
repos". Den kräver två saker: en .release.conf och ett release-vars-mål som
skriver ut IMAGE, HELM_CHART och HELM_REPO. Poängen med att fråga make i
stället för att upprepa värdena i processen är att de bara kan ha en
definition, så det som taggas, det som pushas och det som wrappern pinnar
inte kan glida isär.

Makefilen är avsiktligt inte en kopia av riksdatas. Två skillnader:

fmt, lint och test speglar .gitea/workflows/ci.yaml steg för steg, så ett
grönt "make fmt lint test" här betyder samma sak som en grön CI. gofmt-målet
är kopierat ordagrant och inte förenklat, eftersom gofmts två felsätt inte
är lika: en felformaterad fil listas på stdout med exit 0, medan en fil som
inte går att parsa ger tom stdout och exit 2 — och den naiva varianten läser
det andra som framgång (9046f6e). Undantaget är -race, som CI inte kör:
sveparen, notifieraren och deadman-svepet delar en enda databasanslutning,
och en kapplöpning där dyker upp som en flaxig incident i produktion i
stället för som ett rött bygge.

Det finns medvetet inga build-, push- eller helm-push-mål, till skillnad
från riksdata och rd-web. Här äger .gitea/workflows/release.yaml
publiceringen, och den gör två saker en lokal make inte gör: bygger
linux/amd64 och linux/arm64 genom buildx, och stämplar chartets version och
appVersion från taggen. Ett vanligt "docker build && docker push" skulle
lägga en enarkitektursbild över den multiarkitekturella taggen — lätt att
göra av misstag och osynligt efteråt, eftersom taggen fortfarande svarar,
bara inte på arm64. Publicering sker genom att pusha en tagg, inget annat.

Claude-Session: https://claude.ai/code/session_01S7R4gWTz5wh5xCY4nCSJjN
2026-09-01 21:47:59 +02:00
Niklas Ye 94dec19976 Räkna en tom incidentlista som noll i stället för som ett fel
SUM över noll rader är NULL i SQLite, inte 0. handleStatsIncidents läste de
tre statusräknarna rakt in i int64, så i samma stund som filtret inte
matchade någon rad föll skanningen på "converting NULL to int64 is
unsupported" och hela /api/stats/incidents svarade 500. COUNT(*) ger
däremot 0 utan knot, vilket är precis varför felet inte syns förrän
tabellen töms — det är det enda uttrycket i satsen som klarar noll rader.

Filtret är alltid på: statsFilter lägger på archived_at IS NULL (923fc8b,
flyttat hit i 279ef6c). En installation som varit tyst ett tag arkiverar
därmed sig själv in i felet. Det är sluttillståndet för en lugn vecka, inte
ett kantfall, och klustret står i det nu.

Symptomet pekade åt fel håll. terdut-tui hämtar listan och statistiken i
samma uppdatering, så incidentvyn såg trasig ut medan /api/incidents
svarade 200 med []. Loggen i klustret visar de två anropen bredvid
varandra, det ena grönt och det andra rött. Ingen ändring i terdut-tui
behövs: dess ListIncidents är oförändrad sedan 0.7.2 och skickar samma
parametrar som förut.

handleStatsAlerts bar samma fel och rättas likadant, innan någon hittar
det på samma sätt. COALESCE i SQL i stället för sql.NullInt64 i Go,
eftersom jämförelserna redan bor i satserna här (severityRankSQL, 279ef6c).

mtta_seconds och mttr_seconds lämnas medvetet utan COALESCE. null betyder
"inget att mäta ännu" och 0 skulle läsas som "omedelbart" — två olika
påståenden, och testet från 279ef6c låser fast skillnaden.

Claude-Session: https://claude.ai/code/session_01S7R4gWTz5wh5xCY4nCSJjN
2026-09-01 21:47:39 +02:00
Niklas Ye 9046f6e026 ci: fail on code that is not gofmt'd
CI / test (push) Successful in 4s
go vet says nothing about import order, so when the move to git.ryuvia.com
rewrote every import path without re-sorting them -- the new path sorts before
github.com/..., where the old one sorted after -- both repos went through a
green CI run and a release unformatted.

Added to the release workflow as well as CI, so the two keep running the same
checks; ci.yaml's header claims exactly that, and a check in one but not the
other would quietly make it false.

The step handles gofmt's two failure modes separately because they do not look
alike: a misformatted file is listed on stdout with exit 0, so the failure has
to be raised by hand, while a file that does not parse prints nothing to stdout
and exits 2 -- which a plain emptiness test reads as success. Verified against
all three cases (clean, misformatted, unparseable) before committing.
2026-08-19 21:29:35 +02:00
Niklas Ye 03504b61be gofmt: restore import grouping after the module rename
CI / test (push) Successful in 5s
The rename to git.ryuvia.com/niklas/... was a plain string substitution, so it
left the import blocks in their old order. The new path sorts before
github.com/go-chi/..., where the old one sorted after, which gofmt considers
unformatted.

go vet does not look at import order, so CI had nothing to say about it.
2026-08-19 21:21:33 +02:00
11 changed files with 231 additions and 14 deletions
+24
View File
@@ -59,6 +59,30 @@ jobs:
git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
fi
# This exists because `go vet` does not look at import order: the move to
# git.ryuvia.com rewrote every import path without re-sorting, the new path sorts
# before github.com/..., and both repos sat unformatted through a green CI run and
# a release before anyone noticed.
#
# Both of gofmt's failure modes need handling, and they are not alike. A file that
# is merely misformatted is listed on stdout with exit 0 -- so the failure has to
# be raised by hand. A file that does not parse is the opposite: nothing on stdout
# and exit 2, which a naive `[ -n "$unformatted" ]` reads as success. The first
# draft of this step had exactly that hole.
- name: Format
run: |
if ! unformatted=$(gofmt -l .); then
echo "::error::gofmt could not parse the tree"
gofmt -l . # re-run unredirected so the parse errors reach the log
exit 1
fi
if [ -n "$unformatted" ]; then
echo "::error::not gofmt'd:"
echo "$unformatted"
gofmt -d .
exit 1
fi
- name: Vet
run: go vet ./...
+24
View File
@@ -41,6 +41,30 @@ jobs:
REF_NAME: ${{ github.ref_name }}
run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
# This exists because `go vet` does not look at import order: the move to
# git.ryuvia.com rewrote every import path without re-sorting, the new path sorts
# before github.com/..., and both repos sat unformatted through a green CI run and
# a release before anyone noticed.
#
# Both of gofmt's failure modes need handling, and they are not alike. A file that
# is merely misformatted is listed on stdout with exit 0 -- so the failure has to
# be raised by hand. A file that does not parse is the opposite: nothing on stdout
# and exit 2, which a naive `[ -n "$unformatted" ]` reads as success. The first
# draft of this step had exactly that hole.
- name: Format
run: |
if ! unformatted=$(gofmt -l .); then
echo "::error::gofmt could not parse the tree"
gofmt -l . # re-run unredirected so the parse errors reach the log
exit 1
fi
if [ -n "$unformatted" ]; then
echo "::error::not gofmt'd:"
echo "$unformatted"
gofmt -d .
exit 1
fi
- name: Vet
run: go vet ./...
+17
View File
@@ -0,0 +1,17 @@
# Read by the `release` skill (~/.claude/skills/release).
#
# Only what the Makefile cannot already say. IMAGE, HELM_CHART and HELM_REPO come from
# `make release-vars`, so they have one definition and cannot drift from what is built.
#
# Defaults, set here only where this repo differs:
# CHARTS_REPO=$HOME/git/charts CHARTS_DIR=<image basename>
# GITEA_LOGIN=Ryuvia APPVERSION_PREFIX=
# Same as the image basename, so this is only stated to be read rather than derived.
CHARTS_DIR=terdut-server
# riksdata writes appVersion: "v0.3.1", rd-web writes a bare 0.5.0; this repo writes the
# v, like riksdata. Nothing reads the field -- .gitea/workflows/release.yaml stamps both
# version and appVersion from the tag when it publishes -- but people read it, and until
# 2026-09-01 it said "latest" while the tree headed for a numbered release.
APPVERSION_PREFIX=v
+84
View File
@@ -0,0 +1,84 @@
REGISTRY := git.ryuvia.com
# The personal namespace, not ryuvia — deliberately, and for one reason: Gitea
# scopes package visibility to the owner with no per-package override, so
# ryuvia/* is private because the org is. Publishing here keeps the image and
# chart anonymously pullable, so no pull secret is needed in the cluster and
# Flux needs no registry credentials. Same choice riksdata and rd-web made.
OWNER := niklas
IMAGE := $(REGISTRY)/$(OWNER)/terdut-server
HELM_CHART := charts/terdut-server
HELM_REPO := oci://$(REGISTRY)/$(OWNER)
# go.mod pins an exact patch release so nobody builds the shipped binary with a
# toolchain carrying known stdlib CVEs. Fedora's Go package overrides the
# upstream GOTOOLCHAIN default to `local`, which turns that pin into a hard
# failure on a dev box one patch behind, so restore the upstream default here.
export GOTOOLCHAIN ?= auto
.PHONY: help
help: ## Show this help
@grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-22s\033[0m %s\n", $$1, $$2}'
## --- checks ---
#
# These three mirror .gitea/workflows/ci.yaml step for step, so a green `make fmt
# lint test` here means the same thing CI means. The one deliberate difference is
# -race below.
.PHONY: test
test: ## Run the test suite
go test -race ./...
# CI runs a bare `go test ./...`. This is stricter on purpose: the sweeper, the
# notifier goroutine and the deadman sweep all touch the same single-connection
# database, and a race there would surface as a flaky production incident rather
# than a failed build. It passes today; if it ever costs more than it catches,
# the honest fix is to teach CI -race too, not to quietly drop it here.
.PHONY: lint
lint: ## go vet
go vet ./...
# Copied from ci.yaml rather than simplified, because both of gofmt's failure
# modes need handling and they are not alike. A file that is merely misformatted
# is listed on stdout with exit 0 — so the failure has to be raised by hand. A
# file that does not parse is the opposite: nothing on stdout and exit 2, which a
# naive `[ -n "$$out" ]` reads as success. See 9046f6e.
.PHONY: fmt
fmt: ## Report unformatted files
@if ! unformatted=$$(gofmt -l .); then \
echo "gofmt could not parse the tree:"; gofmt -l .; exit 1; \
fi; \
if [ -n "$$unformatted" ]; then \
echo "gofmt needed:"; echo "$$unformatted"; gofmt -d .; exit 1; \
fi
.PHONY: helm-lint
helm-lint: ## Lint and render the chart
helm lint $(HELM_CHART) --set image.tag=v0.0.0
helm template terdut-server $(HELM_CHART) --namespace terdut-server \
--set image.tag=v0.0.0 >/dev/null
@# networking.listener defaults to "", which attaches the route to every
@# matching listener including plaintext HTTP. Production sets it, so the
@# default render proves nothing about the path that actually ships.
helm template terdut-server $(HELM_CHART) --namespace terdut-server \
--set image.tag=v0.0.0 --set networking.listener=https-terdut >/dev/null
## --- release ---
# The release process (~/.claude/skills/release) reads these rather than restating them.
# One definition, so the version that gets tagged, the image that gets pushed and the chart
# the wrapper pins cannot drift apart in a second copy.
.PHONY: release-vars
release-vars: ## Print the variables the release process reads
@printf 'IMAGE=%s\nHELM_CHART=%s\nHELM_REPO=%s\n' '$(IMAGE)' '$(HELM_CHART)' '$(HELM_REPO)'
# There is deliberately no build/push/helm-package/helm-push/release here, unlike
# riksdata and rd-web. .gitea/workflows/release.yaml owns publishing for this repo,
# and it does two things a local make cannot: it builds linux/amd64 and linux/arm64
# through buildx, and it stamps the chart's version and appVersion from the tag. A
# `docker build && docker push` target would push a single-architecture image over
# the multi-arch tag, which is both easy to do by accident and invisible afterwards
# — the tag would still resolve, just not on arm64. Publishing happens by pushing a
# tag; nothing else.
+12 -5
View File
@@ -4,8 +4,15 @@ description: A Helm chart for Terminal Duty — on-call alert management server
type: application
# These two are placeholders for a local `helm install ./charts/terdut-server`, not the
# released values. .gitea/workflows/release.yaml rewrites both from the git tag when it
# publishes, so the chart version always equals the app version. Bumping them by hand
# does nothing for a release and is not needed before tagging.
# "latest" is honest here: it matches image.tag in values.yaml.
version: 0.9.0
appVersion: "latest"
# publishes, so the chart version always equals the app version.
#
# They are kept in step with the tag anyway. Being read is the only thing these two
# lines do -- nothing that publishes looks at them -- and a tree heading for v0.9.2 that
# says 0.9.0 tells its reader something false. That is what they said until 2026-09-01,
# through two releases.
#
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
# metadata and drives nothing.
version: 0.9.2
appVersion: "v0.9.2"
+1 -1
View File
@@ -10,8 +10,8 @@ import (
"strings"
"time"
"github.com/go-chi/chi/v5"
"git.ryuvia.com/niklas/terdut-server/internal/models"
"github.com/go-chi/chi/v5"
)
// alertSelectFrom is the shared SELECT … FROM … clause used by all alert queries.
+1 -1
View File
@@ -8,8 +8,8 @@ import (
"strings"
"time"
"github.com/go-chi/chi/v5"
"git.ryuvia.com/niklas/terdut-server/internal/models"
"github.com/go-chi/chi/v5"
)
func handleListIncidents(db *sql.DB) http.HandlerFunc {
+55
View File
@@ -653,6 +653,61 @@ func TestStats_Incidents(t *testing.T) {
}
}
// An empty window is a report of zero, not a failure. SUM over no rows is NULL
// in SQLite, which used to come back as a 500 the moment every incident was
// archived — the state a quiet installation settles into.
func TestStats_IncidentsEmptyWindowIsZeroNotAnError(t *testing.T) {
s := newTS(t)
// No incidents at all.
resp := s.req(t, http.MethodGet, "/api/stats/incidents", nil)
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
t.Fatalf("expected 200 on an empty database, got %d", resp.StatusCode)
}
var stats map[string]any
decode(t, resp, &stats)
for _, k := range []string{"total", "triggered", "acknowledged", "resolved"} {
if stats[k].(float64) != 0 {
t.Errorf("expected %s 0, got %v", k, stats[k])
}
}
// And with every incident archived out of the window.
postWebhook(t, s, []map[string]any{
amAlert("fp-s4", "Gone", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
})
s.req(t, http.MethodPost, "/api/incidents/1/archive", nil).Body.Close()
resp = s.req(t, http.MethodGet, "/api/stats/incidents", nil)
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
t.Fatalf("expected 200 when every incident is archived, got %d", resp.StatusCode)
}
stats = nil
decode(t, resp, &stats)
if stats["total"].(float64) != 0 {
t.Errorf("expected total 0, got %v", stats["total"])
}
}
// The alert stats share the same aggregate, and the same empty-window trap.
func TestStats_AlertsEmptyWindowIsZeroNotAnError(t *testing.T) {
s := newTS(t)
resp := s.req(t, http.MethodGet, "/api/stats/alerts", nil)
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
t.Fatalf("expected 200 on an empty database, got %d", resp.StatusCode)
}
var stats map[string]any
decode(t, resp, &stats)
for _, k := range []string{"total", "firing", "resolved"} {
if stats[k].(float64) != 0 {
t.Errorf("expected %s 0, got %v", k, stats[k])
}
}
}
// Nothing acknowledged yet means "no data", which is not the same claim as zero.
func TestStats_IncidentsNullMTTAWhenNothingAcknowledged(t *testing.T) {
s := newTS(t)
+1 -1
View File
@@ -8,8 +8,8 @@ import (
"strings"
"time"
"github.com/go-chi/chi/v5"
"git.ryuvia.com/niklas/terdut-server/internal/models"
"github.com/go-chi/chi/v5"
)
func handleCreateSchedule(db *sql.DB) http.HandlerFunc {
+11 -5
View File
@@ -13,11 +13,14 @@ func handleStatsAlerts(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
where, args := statsFilter(r.URL.Query(), "received_at")
// COALESCE because SUM over zero rows is NULL, not 0, and a count of
// nothing is 0 — without it an empty window is a 500 rather than a
// legitimately empty report.
var total, firing, resolved int64
err := db.QueryRowContext(r.Context(), fmt.Sprintf(`
SELECT COUNT(*),
SUM(CASE WHEN status = 'firing' THEN 1 ELSE 0 END),
SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END)
COALESCE(SUM(CASE WHEN status = 'firing' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END), 0)
FROM alerts WHERE %s`, where), args...,
).Scan(&total, &firing, &resolved)
if err != nil {
@@ -167,13 +170,16 @@ func handleStatsIncidents(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
where, args := statsFilter(r.URL.Query(), "triggered_at")
// The counts are COALESCEd because SUM over zero rows is NULL, not 0.
// The averages are not: mtta and mttr stay null on purpose, since zero
// would read as "instant" rather than "nothing to measure yet".
var total, triggered, acknowledged, resolved int64
var mtta, mttr *float64
err := db.QueryRowContext(r.Context(), fmt.Sprintf(`
SELECT COUNT(*),
SUM(CASE WHEN status = 'triggered' THEN 1 ELSE 0 END),
SUM(CASE WHEN status = 'acknowledged' THEN 1 ELSE 0 END),
SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END),
COALESCE(SUM(CASE WHEN status = 'triggered' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status = 'acknowledged' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END), 0),
AVG(CASE WHEN acknowledged_at IS NOT NULL
THEN acknowledged_at - triggered_at END),
AVG(CASE WHEN resolved_at IS NOT NULL
+1 -1
View File
@@ -11,8 +11,8 @@ import (
"strings"
"time"
"github.com/go-chi/chi/v5"
"git.ryuvia.com/niklas/terdut-server/internal/models"
"github.com/go-chi/chi/v5"
)
func handleBootstrap(db *sql.DB) http.HandlerFunc {