Compare commits

...

17 Commits

Author SHA1 Message Date
Niklas Ye 9669b8f477 Set the chart's placeholder version to 0.9.4
CI / chart (push) Successful in 0s
CI / security (push) Successful in 24s
CI / test (push) Successful in 28s
Release / test (push) Successful in 28s
Release / chart (push) Successful in 1s
Release / binaries (push) Successful in 28s
Release / image (push) Successful in 1m11s
Release / scan-image (push) Successful in 23s
Cosmetic, as in f46e5f5. `make helm-package` passes --version and
--app-version from the tag, so neither field decides anything about what
release.yaml publishes.

Done anyway because a tree heading for v0.9.4 that still says 0.9.3 tells
a reader something false, and the tree is what gets read before the tag
exists. appVersion keeps the v, per APPVERSION_PREFIX in .release.conf.

Claude-Session: https://claude.ai/code/session_014m2pJdpCTv3mvvUUuBM54Y
2026-09-04 18:08:21 +02:00
Niklas Ye a7871ed7c6 Stop the bootstrap hook installing curl at run time
The hook's container was alpine:3 and its first line was
`apk add --no-cache curl`. That writes the binary into the container's
writable upper layer, and every exec of it afterwards is, correctly, a
dropped binary: Falco's `Drop and execute new binary in container`
(PCI_DSS_11.5.1, MITRE TA0003) fired twice at Critical on the upgrade to
chart 0.9.3, 65ms after the container started, with
evt.arg.flags=EXE_WRITABLE|EXE_UPPER_LAYER. Ryuvia/charts#100 has the
event lines.

A true positive of the rule and a false positive of intent, and it is not
a one-off: the hook is post-install,post-upgrade, so it recurred on every
release. The cluster is still in the Falco burn-in with detections routed
to a null receiver, which is the only reason nobody was paged for it.

Fixed here rather than with a Falco exception on purpose. An exception
would have to name this container and would then stay in the rule set
forever, blinding it for the one workload that already runs as root with
create-secret RBAC, and it would leave the second problem untouched: this
runs as a post-upgrade hook, a failed hook fails the release, so every
`helm upgrade` of terdut-server depended on dl-cdn.alpinelinux.org
answering. That dependency is now gone.

alpine/curl is still a full Alpine, so sh, cat, sleep, grep, cut, head and
tail are all present -- verified in-cluster before the swap rather than
assumed, since a missing utility would surface as a failed post-upgrade
hook and not as anything visible here. Digest-pinned, as the wrapper
chart's own sidecar images are. The image declares an ENTRYPOINT, which
the Job's `command:` overrides; a comment says so, because rewriting that
to `args:` would silently run curl's entrypoint instead of the script.

No change to the script's logic, to the RBAC, or to when the hook runs.
Nothing on the terdut-tui side of the API moves, and no terdut-tui version
is required or excluded by this.

Worth recording while it is in view, and deliberately not acted on here:
there is no terdut-server-admin-key secret in the namespace, so the POST
returns 403, the hook logs "Server already bootstrapped, nothing to do"
and exits before the secret-creating branch. On an upgrade this hook
currently achieves nothing at all. Narrowing it to post-install would
remove the detection outright, but that changes what the hook is for and
belongs in its own change.

Claude-Session: https://claude.ai/code/session_014m2pJdpCTv3mvvUUuBM54Y
2026-09-04 18:08:05 +02:00
Niklas Ye 79f5db2636 Scan the source and the working tree too, not just the image
CI / chart (push) Successful in 0s
CI / security (push) Successful in 19s
CI / test (push) Successful in 25s
The image scan added yesterday reads the built artifact. It cannot see a
vulnerable dependency the binary never calls into, and it cannot see a
credential in a file that never reaches the image — this one is FROM scratch
and contains a single binary, so almost nothing in the repo is in it. Those
are two different questions and they need two different tools, which is why
riksdata and rd-web have run govulncheck and gitleaks all along.

Both run on every push and pull request rather than only on a tag, since
neither needs anything published.

Checked by hand before wiring in, as with the image scan. govulncheck
reports no vulnerabilities the code can reach, and gitleaks finds nothing in
the tree.

What govulncheck does report is worth writing down, because it is the
argument for having it. It found three advisories in chi and reports none of
them, all three being IP spoofing in middleware.RealIP, which router.go does
not use — it uses Logger and Recoverer. The analysis is symbol-level rather
than dependency-level, so adding middleware.RealIP would turn this red on
the next push. That is precisely when someone should be made to look, and it
is a plausible thing to reach for here, since the API sits behind a gateway
and real client addresses are exactly what RealIP is for. The fourth finding
is an integer overflow in golang.org/x/sys/windows, which a linux/scratch
image will not be calling.

Both gates were checked for the failure direction as well. gitleaks exits 1
on a private key block. Worth knowing when testing it: it allowlists
well-known example credentials, so the AWS key from Amazon's own
documentation does not trip it and proves nothing.

Neither reads git history. gitleaks runs with --no-git, which scans the
working tree, so it stops a secret on the way in and says nothing about what
is already committed.

Claude-Session: https://claude.ai/code/session_01S7R4gWTz5wh5xCY4nCSJjN
2026-09-02 10:11:13 +02:00
Niklas Ye 84146fc903 Scan the published image for known vulnerabilities
CI / chart (push) Successful in 1s
CI / test (push) Successful in 25s
terdut-server was the only one of the three release-managed repos with no
image scanning at all. riksdata and rd-web have had a scan-image job since
they were set up; everything published here up to and including v0.9.3 went
out without a CVE check.

It scans the pushed image rather than a locally built one, for the same
reason the siblings do: trivy cannot read a local image on this runner,
since Talos has no docker socket and the dind sidecar shares no filesystem
with the job. So it runs after image rather than gating it, and a red scan
unpublishes nothing. What it means is narrower and worth stating plainly: do
not bump the wrapper chart in Ryuvia/charts to that version.

Checked before wiring it in rather than after. v0.9.3 scans clean at
HIGH,CRITICAL with unfixed findings ignored, so this does not turn the
pipeline red on arrival, and the same command exits 1 on an image that does
have findings — a gate that cannot fail is not a gate.

One platform is scanned, not both. The image is FROM scratch, so there are
no OS packages and trivy sees a single target: the Go binary and its module
graph. linux/amd64 and linux/arm64 are that same module set built for a
different GOARCH, so a finding in one is a finding in both. On an image with
a base layer that reasoning would not hold.

Still no govulncheck and no gitleaks here, which riksdata and rd-web run in
a separate CI job. This is the only security scanning terdut-server has.

Claude-Session: https://claude.ai/code/session_01S7R4gWTz5wh5xCY4nCSJjN
2026-09-02 10:04:51 +02:00
Niklas Ye c6f1fe317e Correct the docs that said this repo has no publishing targets
CI / chart (push) Successful in 0s
CI / test (push) Successful in 25s
Both CLAUDE.md and README.md claimed there were deliberately no build or
push targets because the workflow owned publishing. That stopped being true
in 69fcc24, which moved publishing onto the Makefile so release.yaml could
call it — the docs described the arrangement that change replaced.

The claim was wrong in its reasoning too, not just out of date. It was
written on the assumption that riksdata and rd-web duplicated their
pipelines by having those targets. They never did: their workflows call
make and always have, which is what makes a green gate locally and a green
pipeline the same code instead of two descriptions of it. This repo was the
exception, for the single day it had a Makefile that nothing called.

Claude-Session: https://claude.ai/code/session_01S7R4gWTz5wh5xCY4nCSJjN
2026-09-01 22:44:22 +02:00
Niklas Ye f46e5f5729 Set the chart's placeholder version to 0.9.3
CI / chart (push) Successful in 0s
CI / test (push) Successful in 24s
Release / test (push) Successful in 24s
Release / chart (push) Successful in 1s
Release / binaries (push) Successful in 9s
Release / image (push) Successful in 57s
Cosmetic, and done anyway, for the same reason as 477454e: these two lines
decide nothing about what is published — `helm package --version
--app-version` now sets both from the tag, so they are not even read during
a release — but a tree heading for v0.9.3 that says 0.9.2 tells its reader
something false.

The comment above them was rewritten to stop naming a specific version. It
described the tree as "heading for v0.9.2", which is exactly the kind of
line that has to be edited every release and is therefore the kind that goes
stale. It also said nothing that publishes looks at these fields, which only
became literally true in 69fcc24 when the sed was replaced by helm's own
flags.

Claude-Session: https://claude.ai/code/session_01S7R4gWTz5wh5xCY4nCSJjN
2026-09-01 22:38:41 +02:00
Niklas Ye 69fcc24a4d Drive the pipeline through make, the way riksdata and rd-web do
CI / chart (push) Successful in 0s
CI / test (push) Successful in 57s
Both workflows restated the build in YAML: gofmt, go vet and go test inline
in two places, buildx inline in a third, and the chart's version sed'd into
Chart.yaml before packaging. The Makefile added in 1081260 then described
the same checks a second time for local use, which made "green locally means
green in CI" a promise about keeping two files in step rather than a
property of the setup.

riksdata and rd-web never had that problem — their workflows call make and
have done all along. This repo was the odd one out, and only because it had
no Makefile until today. Now ci.yaml runs `make fmt lint test`, release.yaml
runs the same plus `make binaries`, `make push`, `make helm-package` and
`make helm-push`, and the reasoning behind each check lives on the target
rather than in whichever YAML file was edited last.

Three things change rather than just move:

The chart is linted before it is published. release.yaml packaged and pushed
without ever rendering the templates, so a chart that did not compile would
have reached the registry and been found by Flux. ci.yaml gained a chart job
for the same reason.

helm package --version --app-version replaces the sed. The published
metadata is identical, but the tree is no longer mutated mid-build, and it
is what the rest of the release process already assumed happened.

`make push` refuses VERSION=dev. Publishing is one command now, so it is
also one command to run by accident; dev is not a version anyone releases.

Deliberately not moved: uploading the release assets. Compiling them is
`make binaries` and runs anywhere, but the upload needs a token and the
Gitea release API, which is the workflow's business and not something worth
a target.

`push` builds and pushes in one step, unlike riksdata's separate build and
push, because buildx cannot load a multi-platform image into the local store
— it can only push it. `build` stays single-platform and local-only.

Claude-Session: https://claude.ai/code/session_01S7R4gWTz5wh5xCY4nCSJjN
2026-09-01 22:36:54 +02:00
Niklas Ye 6a4f902e38 Declare that this repo's release prose is English
CI / test (push) Successful in 5s
The release skill's house style opened with "everything here is written in
Swedish", stated as a house default. It is not one: Swedish belongs to
riksdata and rd-web because their interface copy and the riksdag data they
present are inseparable from it. Nothing about an on-call tool is — the
labels, the API and the data here are English, and so are the code comments
and the docs.

Stated as a rule that was remembered rather than read, it produced the whole
of v0.9.2 in Swedish: four commits, the tag body and both charts PRs. The
PRs were rewritten; the commits on main and the published tag could not be,
so v0.9.2 stays as the record of the mistake.

PROSE_LANG makes it a declaration each repo carries, which release-preflight
prints on the prose line before any of the four texts get written. See
reference/house-style.md in the skill.

Claude-Session: https://claude.ai/code/session_01S7R4gWTz5wh5xCY4nCSJjN
2026-09-01 22:24:41 +02:00
Niklas Ye 5f9c202d65 Beskriv hur ett släpp går till, och vad som är särskilt här
CI / test (push) Successful in 6s
Repot kom in under den gemensamma släppprocessen i 1081260 utan att någon
dokumentation sa det. README hade Kubernetes och Development men ingenting om
vägen från en commit till något som rullar i klustret, och till skillnad från
riksdata och rd-web fanns ingen CLAUDE.md alls.

Två saker om just det här repot är värda att stå skrivna, eftersom båda
avviker från syskonrepona och båda upptäcktes mitt i ett släpp:

Pipelinen har ingen bildskanning. riksdata och rd-web kör trivy efter
publiceringen; .gitea/workflows/release.yaml här har test, binaries, image och
chart och inget mer. Ett grönt släpp är alltså inget belägg för att bilden är
fri från kända sårbarheter, och en släppnot får inte antyda det.

Wrapperchartets values.yaml har två tag:-rader, appbilden och
python-sidovagnen för säkerhetskopiering, så chart-bump behöver --image för
att veta vilken som flyttas. Utan den vägrar den, vilket är rätt.

Samtidigt: varför det inte finns några build- eller push-mål, varför chartet
publiceras enbart från taggen (766f439), och varför make test kör -race när CI
inte gör det. Helm-exemplet pekade fortfarande på 0.9.0 och går till 0.9.2.

Claude-Session: https://claude.ai/code/session_01S7R4gWTz5wh5xCY4nCSJjN
2026-09-01 22:09:15 +02:00
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
Niklas Ye 6047d1a9f7 ci: do not override HELM_REPOSITORY_CACHE alongside the config
CI / test (push) Successful in 4s
Release / test (push) Successful in 3s
Release / chart (push) Successful in 1s
Release / binaries (push) Successful in 2m48s
Release / image (push) Successful in 2m47s
Helm writes a refreshed repository index to the default cache directory and
then looks for it in the overridden one, so setting both is how the chart
tooling breaks on this machine already.
2026-08-19 20:44:06 +02:00
Niklas Ye 289eca8076 Move to Gitea: git.ryuvia.com/niklas/terdut-server
CI / test (push) Successful in 2m15s
The module path, the container image, the Helm chart and the CI pipeline all
named GitHub. They now name the Gitea instance everything else already runs on.

The workflows are rewritten rather than translated. Gitea's runner image is
ubuntu:22.04, whose nodejs is Node 12, so no JS action runs there at all --
actions/checkout@v4 dies with a SyntaxError before it does anything. Every step
is shell, checkout is a plain clone (this repo is public, so it needs no
credential), and the jobs that need docker or helm run in host mode because the
dind bridge a `container:` job gets cannot reach github.com or get.helm.sh.

Two consequences worth naming:

- upload-artifact/download-artifact are also JS actions, and there is no
  artifact store here, so the job that builds the binaries is the job that
  publishes them. Nothing is passed between jobs.
- setup-qemu-action is gone with the rest, and the runner has no binfmt
  registration. The Dockerfile's builder stage now runs on $BUILDPLATFORM and
  cross-compiles from TARGETARCH instead, which is what keeps the arm64 image
  buildable -- and makes it native rather than emulated.

The chart moves from a GitHub Pages index to an OCI artifact in Gitea's
registry. Publishing stays tag-only for the reason recorded in release.yaml: a
workflow triggered by the branch push cannot know the version it is about to be
tagged with.

The GitHub repository is left in place and untouched. Nothing pushes to it any
more, but its existing release downloads and chart index keep resolving.
2026-08-19 20:39:10 +02:00
Niklas Ye 766f43931c chart: publish from the tag only, not from both workflows
Two workflows published the chart and disagreed about its metadata.
release.yml stamps version and appVersion from the git tag;
chart-release.yml, triggered by any charts/** push to main, took
Chart.yaml verbatim, where appVersion is the hardcoded "latest".
Both fired for the same commit, both tried to publish the same chart
version, and skip_existing turned whichever lost into a no-op — so what
a release said about itself came down to which runner was quicker.

Chart 0.9.0 went out that way, reading appVersion "latest". Every
earlier release got the right answer by accident: Chart.yaml's version
lagged the published set, so chart-release.yml always collided with an
existing version and skipped, leaving release.yml to win uncontested.
Bumping Chart.yaml to match the tag before cutting 0.9.0 removed that
accident and the race showed itself.

Making the two agree is not possible. The tag is pushed after the branch,
so a workflow triggered by the main push cannot know the version it is
about to be tagged with — no amount of deriving from git describe fixes
that ordering. The fix is one publisher, triggered by the tag, so
chart-release.yml is deleted.

The chart now only ships with an app release. Nothing is lost: the sed in
release.yml ties the chart version to the app version, so a chart-only
change never had a version of its own to be released under. Chart fixes
ride the next tag.

Chart.yaml's version and appVersion are documented as the placeholders
they now are, so the next person does not helpfully bump them and
reintroduce this. skip_existing stays, for idempotent re-runs of a failed
release rather than for the race, and a non-version tag now fails the job
instead of silently publishing unstamped metadata.
2026-08-08 21:41:44 +02:00
28 changed files with 831 additions and 249 deletions
+128
View File
@@ -0,0 +1,128 @@
name: CI
# The release workflow gates a tag, which is late: a broken commit sits green until
# somebody decides to publish. This runs the same checks on the way in.
#
# push is scoped to main rather than all branches so that a branch pushed as part of a
# pull request is not checked twice.
#
# No actions/checkout, deliberately -- same as the letsvisit and charts workflows. The
# runner image is ubuntu:22.04 whose `nodejs` package is Node 12, and actions/checkout@v4
# is built with ES2022 static initialiser blocks, so it dies with
# `SyntaxError: Unexpected token '{'` before running. Cloning with git directly avoids JS
# actions entirely. This repo is public, so the clone needs no credential at all.
#
# `${{ }}` values are passed through `env:` and referenced as quoted shell variables: a
# ref name is attacker-influenced by anyone who can push a branch or open a PR, and
# expanding one straight into `run:` is a shell-injection vector.
on:
push:
branches: [main]
pull_request:
# A rapid series of pushes only needs the last one checked.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
REPO_URL: https://git.ryuvia.com/niklas/terdut-server.git
jobs:
test:
runs-on: ubuntu-latest
container:
# Runs inside the toolchain image rather than installing Go per job. Note this puts
# the job on the dind bridge, which cannot reach github.com or get.helm.sh --
# proxy.golang.org and git.ryuvia.com are reachable, which is all this job needs.
image: golang:1.26.6-bookworm
# act_runner destroys a job's own volumes when it finishes, so without these every
# run re-downloads the whole module graph. The names must appear in the runner's
# container.valid_volumes allowlist (charts/act-runner in the k8s repo); unlisted
# volumes are dropped silently, so a workflow that looks correct can still be
# running uncached.
volumes:
- go-mod-cache:/go/pkg/mod
- go-build-cache:/root/.cache/go-build
- gobin-cache:/go/bin
steps:
- name: Checkout
env:
REF_NAME: ${{ github.ref_name }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
if [ -n "$HEAD_SHA" ]; then
# A pull_request ref_name is "<n>/merge", which is not a fetchable branch.
git clone "$REPO_URL" .
git checkout -q "$HEAD_SHA"
else
git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
fi
# The gate is the Makefile's rather than a second copy of it here, the way riksdata
# and rd-web already do it. `make fmt lint test` is exactly what a developer runs, so
# a green pipeline and a green working copy mean the same thing by construction
# instead of by remembering to update two files together.
#
# The reasoning that used to live here moved with the targets: why gofmt is checked
# at all (import order survives `go vet`, and both repos sat unformatted through a
# green run and a release -- 9046f6e), why both of gofmt's failure modes need
# handling, and why `test` adds -race when this job does not have to.
- name: Format, vet and test
run: make fmt lint test
# Runs on every push and pull request, unlike the image scan, which needs something
# published to scan and so lives in release.yaml. Both are needed: govulncheck reads the
# source and its module graph, trivy reads the built artifact, and neither sees what the
# other does.
security:
runs-on: ubuntu-latest
container:
image: golang:1.26.6-bookworm
volumes:
- go-mod-cache:/go/pkg/mod
- go-build-cache:/root/.cache/go-build
- gobin-cache:/go/bin
steps:
- name: Checkout
env:
REF_NAME: ${{ github.ref_name }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
if [ -n "$HEAD_SHA" ]; then
git clone "$REPO_URL" .
git checkout -q "$HEAD_SHA"
else
git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
fi
- name: Go vulnerability scan (govulncheck)
run: make security-go
- name: Secret scan (gitleaks)
run: make security-secrets
# Host mode, no `container:`: helm is baked into the runner image, and a container job
# could not install it -- get.helm.sh is unreachable from the dind bridge. Same reason
# release.yaml's chart job runs on the host.
#
# The chart had no lint step in any workflow until 2026-09-01: release.yaml packaged and
# pushed it without rendering it first, so a template that did not compile would have
# been found by Flux rather than here.
chart:
runs-on: ubuntu-latest
steps:
- name: Checkout
env:
REF_NAME: ${{ github.ref_name }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
if [ -n "$HEAD_SHA" ]; then
git clone "$REPO_URL" .
git checkout -q "$HEAD_SHA"
else
git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
fi
- name: Lint and render the chart
run: make helm-lint
+223
View File
@@ -0,0 +1,223 @@
name: Release
# Checkout, interpolation and caching conventions match ci.yaml -- see the header there
# for why there are no JS actions and why every `${{ }}` goes through `env:`.
#
# There is no upload-artifact/download-artifact equivalent here (both are JS actions, and
# this Gitea has no artifact store wired up), so the job that builds the binaries is also
# the job that publishes them. Nothing is handed between jobs at all.
on:
push:
tags:
- 'v*'
workflow_dispatch:
# A tag is not normally re-pushed, so this mostly matters when one is force-moved during
# a botched release -- the superseded run stops holding runner slots.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: true
env:
REPO_URL: https://git.ryuvia.com/niklas/terdut-server.git
API: https://git.ryuvia.com/api/v1/repos/niklas/terdut-server
REGISTRY: git.ryuvia.com
IMAGE: git.ryuvia.com/niklas/terdut-server
jobs:
# Gates every publishing job below. A tag that fails here publishes nothing: the
# binaries, the image and the chart are all downstream of it.
test:
runs-on: ubuntu-latest
container:
image: golang:1.26.6-bookworm
volumes:
- go-mod-cache:/go/pkg/mod
- go-build-cache:/root/.cache/go-build
- gobin-cache:/go/bin
steps:
- name: Checkout
env:
REF_NAME: ${{ github.ref_name }}
run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
# Same gate as ci.yaml, and the same one a developer runs. See the Makefile for why
# each check is there; restating it here is how the two drift apart.
- name: Format, vet and test
run: make fmt lint test
binaries:
needs: test
runs-on: ubuntu-latest
container:
image: golang:1.26.6-bookworm
volumes:
- go-mod-cache:/go/pkg/mod
- go-build-cache:/root/.cache/go-build
- gobin-cache:/go/bin
steps:
- name: Checkout
env:
REF_NAME: ${{ github.ref_name }}
run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
# Compiling is the Makefile's; uploading is not. `make binaries` is runnable on a
# laptop, while the step below needs a token and the Gitea release API, which is
# this workflow's business and nothing a developer wants a target for.
- name: Build every target
env:
REF_NAME: ${{ github.ref_name }}
run: make binaries VERSION="$REF_NAME"
# Creating the release is made idempotent rather than assumed-new: a re-run of a
# failed release must not die on the release that already exists. Assets are
# replaced the same way, so a re-run repairs a partial upload.
- name: Publish the release
env:
REF_NAME: ${{ github.ref_name }}
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -eu
auth="Authorization: token $TOKEN"
body=$(curl -sf -H "$auth" "$API/releases/tags/$REF_NAME" || true)
if [ -z "$body" ]; then
body=$(curl -sf -X POST -H "$auth" -H 'Content-Type: application/json' \
-d "{\"tag_name\":\"$REF_NAME\",\"name\":\"$REF_NAME\"}" \
"$API/releases")
fi
# The release object serialises `id` first, so the first match is the release's
# own id and not one of the nested author/asset ids.
release_id=$(printf '%s' "$body" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
[ -n "$release_id" ] || { echo "::error::could not determine release id"; exit 1; }
echo "release id $release_id"
for f in dist/*; do
name=$(basename "$f")
# Drop an existing asset of the same name first: Gitea happily stores two
# attachments with one name, and the updater matches by name.
old=$(curl -sf -H "$auth" "$API/releases/$release_id/assets" \
| tr '}' '\n' | grep "\"name\":\"$name\"" \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
if [ -n "$old" ]; then
curl -sf -X DELETE -H "$auth" "$API/releases/$release_id/assets/$old" || true
fi
echo "uploading $name"
curl -sf -X POST -H "$auth" -F "attachment=@$f" \
"$API/releases/$release_id/assets?name=$name" > /dev/null
done
# Host mode on purpose (no `container:`): this is the only context with a Docker CLI
# pointed at the dind daemon. A `container:` job would sit on the dind bridge with no
# docker socket at all.
image:
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout
env:
REF_NAME: ${{ github.ref_name }}
run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
- name: Log in to the registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: echo "$TOKEN" | docker login "$REGISTRY" -u niklas --password-stdin
# buildx setup, the platform list and why there is no QEMU all live on the `push`
# target now, so the same command publishes from a laptop and from here.
- name: Build and push
env:
REF_NAME: ${{ github.ref_name }}
run: make push VERSION="$REF_NAME"
# Also host mode: helm is baked into the runner image, and a `container:` job could not
# install it -- get.helm.sh is unreachable from the dind bridge.
chart:
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout
env:
REF_NAME: ${{ github.ref_name }}
run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
# This job is the only thing that publishes the chart, which is what keeps the
# published metadata honest. There used to be a second publisher on every charts/**
# push to main, and the two raced for the same chart version with different answers:
# this one stamps version and appVersion from the tag, that one took Chart.yaml
# verbatim, where appVersion is the hardcoded "latest". Whichever landed first won,
# so the metadata of a release depended on which runner was quicker -- chart 0.9.0
# went out on 2026-08-08 reading appVersion "latest" that way.
#
# It could not be fixed by making both agree: the tag is pushed after the branch, so
# a workflow triggered by the main push cannot know the version it is about to be
# tagged with. One publisher, triggered by the tag.
#
# The cost is that the chart only ships with an app release. That is no real loss --
# `make helm-package` derives the chart version from the tag, so a chart-only change
# has no version of its own to be released under anyway. Chart fixes ride the next
# tag.
- name: Refuse a non-version tag
env:
REF_NAME: ${{ github.ref_name }}
run: |
set -eu
if ! echo "$REF_NAME" | grep -qE '^v[0-9]'; then
echo "::error::refusing to publish a chart for non-version tag ${REF_NAME}"
exit 1
fi
# Render before publishing. Until 2026-09-01 this job packaged and pushed without
# linting, so a template that did not compile reached the registry and was found by
# Flux instead.
- name: Lint and render the chart
run: make helm-lint
# The version and appVersion are no longer sed'd into Chart.yaml before packaging:
# `helm package --version --app-version` sets both from the tag without mutating the
# tree mid-build, which is what the rest of the release process already assumed
# happened. The isolated helm repo list moved onto the targets with them.
- name: Package and push
env:
REF_NAME: ${{ github.ref_name }}
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -eu
echo "$TOKEN" | helm registry login "$REGISTRY" -u niklas --password-stdin
make helm-package helm-push VERSION="$REF_NAME"
# Host mode, like image and chart: this needs a docker daemon to run trivy in, and a
# `container:` job would sit on the dind bridge with none.
#
# It scans the pushed image rather than a locally built one, because trivy cannot read a
# local image on this runner -- Talos has no docker socket and the dind sidecar shares no
# filesystem with the job -- so it pulls from the registry. That is also why this runs
# after `image` rather than gating it: a red scan does not unpublish anything.
#
# What a red scan means is therefore not "the release failed" but "do not bump the wrapper
# chart in Ryuvia/charts to this version". The image and chart are already published by
# the time this runs, and deliberately so -- this pipeline does not deploy.
#
# riksdata and rd-web have had this since they were set up; terdut-server went without any
# image scanning until 2026-09-02, so every release before v0.9.4 was published with no
# CVE check at all.
scan-image:
needs: image
runs-on: ubuntu-latest
steps:
- name: Checkout
env:
REF_NAME: ${{ github.ref_name }}
run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
# Credentials are passed even though these packages are anonymously pullable -- that
# is a property of the personal namespace this publishes to, not something a release
# should depend on staying true.
- name: Scan the pushed image (trivy)
env:
TRIVY_USERNAME: niklas
TRIVY_PASSWORD: ${{ secrets.REGISTRY_TOKEN }}
REF_NAME: ${{ github.ref_name }}
run: make security-image VERSION="$REF_NAME"
-38
View File
@@ -1,38 +0,0 @@
name: Release Helm Chart
on:
push:
branches:
- main
paths:
- charts/**
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
pages: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure Git
run: |
git config user.name "$GITHUB_ACTOR"
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
- name: Install Helm
uses: azure/setup-helm@v4
- name: Run chart-releaser
uses: helm/chart-releaser-action@v1.6.0
with:
# A charts/** push without a Chart.yaml version bump would otherwise
# fail trying to re-release the current version. Tagged releases also
# publish the chart from release.yml, so the two can race.
skip_existing: true
env:
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
-34
View File
@@ -1,34 +0,0 @@
name: CI
# The release workflow gates a tag, which is late: a broken commit sits green
# until somebody decides to publish. This runs the same checks on the way in.
#
# push is scoped to main rather than all branches for two reasons: a branch
# pushed as part of a pull request would otherwise be checked twice, and
# gh-pages holds the published Helm chart index with no Go code in it, so
# `go vet ./...` there would fail on a missing go.mod.
on:
push:
branches: [main]
pull_request:
# A rapid series of pushes only needs the last one checked.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Vet
run: go vet ./...
- name: Test
run: go test ./...
-143
View File
@@ -1,143 +0,0 @@
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
# Gates every publishing job below. A tag that fails here publishes nothing:
# the binaries, the image and the chart are all downstream of it.
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Vet
run: go vet ./...
- name: Test
run: go test ./...
build:
needs: test
runs-on: ubuntu-latest
strategy:
matrix:
include:
- goos: linux
goarch: amd64
- goos: linux
goarch: arm64
- goos: darwin
goarch: amd64
- goos: darwin
goarch: arm64
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
run: |
go build \
-ldflags "-w -s -X main.version=${{ github.ref_name }}" \
-o terdut-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }} \
./cmd/terdut
- uses: actions/upload-artifact@v4
with:
name: terdut-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }}
path: terdut-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }}
docker:
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
build-args: VERSION=${{ github.ref_name }}
tags: |
ghcr.io/yeniklas/terdut-server:latest
ghcr.io/yeniklas/terdut-server:${{ github.ref_name }}
chart:
needs: test
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure Git
run: |
git config user.name "$GITHUB_ACTOR"
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
- name: Install Helm
uses: azure/setup-helm@v4
- name: Update chart versions
run: |
VERSION="${{ github.ref_name }}"
if [[ "$VERSION" =~ ^v[0-9] ]]; then
CHART_VERSION="${VERSION#v}"
sed -i "s/^version:.*/version: ${CHART_VERSION}/" charts/terdut-server/Chart.yaml
sed -i "s/^appVersion:.*/appVersion: \"${VERSION}\"/" charts/terdut-server/Chart.yaml
fi
- name: Run chart-releaser
uses: helm/chart-releaser-action@v1.6.0
with:
skip_existing: true
env:
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
release:
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
merge-multiple: true
- uses: softprops/action-gh-release@v2
with:
files: 'terdut-*'
+5
View File
@@ -1,6 +1,11 @@
# build output # build output
/terdut /terdut
/terdut-server /terdut-server
# `make binaries` and `make helm-package` write here
/dist/
# isolated helm repo list written by the publishing targets, so the machine-wide
# one (which has an unreachable entry) cannot abort a release
/.helm-repos.yaml
# SQLite database files # SQLite database files
*.db *.db
+24
View File
@@ -0,0 +1,24 @@
# 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=
# PROSE_LANG=en
# 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
# English. The Swedish in riksdata and rd-web follows from their subject matter, not from a
# house style: terdut-server is an on-call tool whose labels, API and data are English, and
# nothing about it is coupled to Swedish. Code comments and docs here were always English;
# from 2026-09-01 the release prose is too.
PROSE_LANG=en
+43
View File
@@ -0,0 +1,43 @@
## Release
Say **"Release"** (or "Release X.Y.Z") and the `release` skill runs it: commit, push, tag,
wait for the pipeline, then open the wrapper-chart PR against `Ryuvia/charts`. It stops
there — merging and the Flux reconcile stay manual, deliberately.
Preconditions and the plan, without side effects:
```sh
~/.claude/skills/release/scripts/release-preflight # state + suggested version
~/.claude/skills/release/scripts/release-preflight vX.Y.Z # validate that release
```
Config is `.release.conf` here plus `make release-vars`. The process itself lives in
`~/.claude/skills/release/`; why it is shaped this way is in README.md §Releasing.
Two things about this repo specifically:
- **The image is scanned after it is published, not before.** `scan-image` runs trivy
against the pushed image, because trivy cannot read a locally built one on this runner.
A red scan therefore unpublishes nothing — it means: do not bump the wrapper chart in
`Ryuvia/charts` to this version. Added 2026-09-02; every release up to and including
v0.9.3 was published with no CVE check at all.
- **The wrapper chart has two `tag:` lines** — the app image and the python backup sidecar —
so `chart-bump` needs `--image "$IMAGE"` to know which one moves.
## Checks
`make fmt lint test helm-lint` **is** what the pipeline runs — `ci.yaml` and `release.yaml`
call these targets rather than restating them, the way riksdata and rd-web do. A green gate
here and a green pipeline are the same code, not two descriptions of it. `test` adds `-race`,
which the workflows do not have to ask for since they call the target; see the comment on it
for why.
`make release` (build + push the multi-arch image, package + push the chart) is what
`release.yaml` invokes. Do not run it by hand — it refuses `VERSION=dev` for that reason, and
publishing happens by pushing a tag.
Three scans, and they see different things: `security-go` (govulncheck) reads the source and
its module graph and reports only vulnerabilities the code can actually reach;
`security-secrets` (gitleaks) reads the working tree, not the history, so it catches a secret
on the way in rather than auditing what is already committed; `security-image` (trivy) reads
the published artifact and therefore only runs on a tag. The first two gate every push.
+11 -2
View File
@@ -1,10 +1,19 @@
FROM golang:1.25-alpine AS builder # --platform=$BUILDPLATFORM pins the builder to the machine doing the building, so a
# multi-arch build compiles both targets natively instead of running an emulated arm64
# toolchain under QEMU. Go cross-compiles from TARGETOS/TARGETARCH, which BuildKit fills
# in per platform. The CI runner has no binfmt registration and no way to get one (the
# JS action that used to install it cannot run there), so this is not just an
# optimisation -- it is what makes the arm64 image buildable at all.
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
ARG VERSION=dev ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s -X main.version=${VERSION}" -o /terdut ./cmd/terdut ARG TARGETOS
ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -ldflags="-w -s -X main.version=${VERSION}" -o /terdut ./cmd/terdut
FROM scratch FROM scratch
COPY --from=builder /terdut /terdut COPY --from=builder /terdut /terdut
+219
View File
@@ -0,0 +1,219 @@
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.
## --- publishing ---
#
# These exist so .gitea/workflows/release.yaml can call `make release` instead of
# restating the build in YAML, the way riksdata and rd-web already do. One definition
# of how this is built and published, runnable locally, reviewable in a diff.
#
# VERSION is the git tag, passed in by the workflow. The guard below is why a stray
# local `make release` cannot publish: dev is not a version anyone releases.
VERSION ?= dev
# Helm requires strict SemVer — strip a leading 'v' if present.
CHART_VERSION := $(shell echo "$(VERSION)" | sed 's/^v//')
PLATFORMS ?= linux/amd64,linux/arm64
BUILDX_BUILDER ?= terdut
TRIVY_VERSION := 0.73.0
GOVULNCHECK_VERSION := v1.1.4
GITLEAKS_VERSION := v8.30.0
# --pull, not --no-cache: refresh the base image without discarding the layer cache.
DOCKER_BUILD_FLAGS ?= --pull
# An isolated repo list. The machine-wide one is not this build's business, and one
# unreachable entry in it aborts otherwise-fine helm commands — there is a dead
# TrueCharts repo on this host that does exactly that. HELM_REPOSITORY_CACHE is
# deliberately NOT overridden alongside it: helm writes a refreshed index to the default
# cache and then looks for it in the overridden one.
HELM_ISOLATED = HELM_REPOSITORY_CONFIG=$(CURDIR)/.helm-repos.yaml
.PHONY: require-version
require-version:
@test "$(VERSION)" != "dev" || \
(echo "VERSION=dev names no release — pass VERSION=vX.Y.Z (the workflow passes the tag)" && exit 1)
.PHONY: build
build: ## Build the image for this host only, without pushing (local check / CI smoke)
docker build $(DOCKER_BUILD_FLAGS) \
--build-arg VERSION=$(VERSION) \
-t $(IMAGE):$(VERSION) .
# Multi-arch, so unlike riksdata and rd-web this cannot be a separate build then push:
# buildx cannot load a multi-platform result into the local image store, it can only
# push it. `build` above stays single-platform and local-only for that reason.
#
# No QEMU: the Dockerfile's builder stage runs on $$BUILDPLATFORM and cross-compiles from
# TARGETARCH, so both platforms build natively. The default "docker" driver cannot build
# more than one platform at a time; the docker-container driver can.
.PHONY: push
push: require-version ## Build and publish the multi-arch image
docker buildx create --name $(BUILDX_BUILDER) --use 2>/dev/null || docker buildx use $(BUILDX_BUILDER)
docker buildx build \
--platform $(PLATFORMS) \
--build-arg "VERSION=$(VERSION)" \
--tag "$(IMAGE):latest" \
--tag "$(IMAGE):$(VERSION)" \
--push .
# --version and --app-version come from the tag, so Chart.yaml's own fields decide nothing
# about what is published. They used to be rewritten in place with sed before packaging;
# the flags do the same job without mutating the tree mid-build.
.PHONY: helm-package
helm-package: require-version ## Package the chart, versioned from the tag
$(HELM_ISOLATED) helm package $(HELM_CHART) \
--version $(CHART_VERSION) \
--app-version $(VERSION) \
--destination dist
.PHONY: helm-push
helm-push: require-version ## Push the packaged chart to the OCI registry
$(HELM_ISOLATED) helm push dist/terdut-server-$(CHART_VERSION).tgz $(HELM_REPO)
.PHONY: binaries
binaries: require-version ## Cross-compile the release binaries into dist/
@mkdir -p dist
@set -eu; for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do \
GOOS="$${target%/*}"; GOARCH="$${target#*/}"; \
out="dist/terdut-$(VERSION)-$${GOOS}-$${GOARCH}"; \
echo "building $$out"; \
GOOS="$$GOOS" GOARCH="$$GOARCH" go build \
-ldflags "-w -s -X main.version=$(VERSION)" \
-o "$$out" ./cmd/terdut; \
done
.PHONY: release
release: push helm-package helm-push ## Publish image + chart (the workflow's one call)
## --- security ---
# Symbol-level, not dependency-level: govulncheck reports a vulnerability only when the
# code can actually reach it. As of 2026-09-02 this repo imports three chi advisories and
# reports none of them, because all three are middleware.RealIP and router.go uses Logger
# and Recoverer. That is the useful property rather than a loophole -- adding
# middleware.RealIP would turn this red, which is exactly when someone should look.
.PHONY: security-go
security-go: ## Scan Go deps for known CVEs (govulncheck)
go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./...
# --no-git scans the working tree rather than the history, so this catches a secret on the
# way in. It is not a history audit and finding nothing here says nothing about what is
# already committed. --redact because the finding is printed into a CI log.
#
# Note when testing it that gitleaks allowlists well-known example credentials -- the AWS
# key from their own documentation does not trip it. A private key block does.
.PHONY: security-secrets
security-secrets: ## Scan the working tree for committed secrets (gitleaks)
go run github.com/zricethezav/gitleaks/v8@$(GITLEAKS_VERSION) detect --no-git \
--source . --redact --no-banner --exit-code 1
# Scans the pushed image, not a local one: trivy cannot read a locally built image on the
# runner -- Talos has no docker socket, and the dind sidecar shares no filesystem with the
# job -- so it pulls from the registry. Same reason riksdata and rd-web scan after pushing.
#
# It cannot gate a deploy, because this pipeline does not deploy. A red scan means: do not
# bump the wrapper chart in Ryuvia/charts to this version.
#
# The image is FROM scratch, so there are no OS packages to scan and trivy sees exactly one
# target -- the Go binary and its module graph. That also makes scanning a single platform
# sufficient here: linux/amd64 and linux/arm64 are the same modules built for a different
# GOARCH, so a CVE in one is a CVE in both. On an image with a base layer that would not
# hold and both platforms would need scanning.
#
# This is the last of the three scans and the only one that needs a published artifact;
# security-go and security-secrets above run on every push.
.PHONY: security-image
security-image: require-version ## Scan the pushed image for CVEs (needs VERSION)
@# The named volume persists trivy's vulnerability DB between runs; without it every
@# scan re-downloads the whole database.
docker run --rm -e TRIVY_USERNAME -e TRIVY_PASSWORD \
-v trivy-cache:/root/.cache/trivy \
docker.io/aquasec/trivy:$(TRIVY_VERSION) image --severity HIGH,CRITICAL \
--ignore-unfixed --exit-code 1 $(IMAGE):$(VERSION)
+66 -4
View File
@@ -17,7 +17,7 @@ Incident management server for teams using Prometheus Alertmanager.
**Prerequisites:** Go 1.21+ **Prerequisites:** Go 1.21+
```bash ```bash
git clone https://github.com/yeniklas/terdut-server git clone https://git.ryuvia.com/niklas/terdut-server
cd terdut-server cd terdut-server
go run ./cmd/terdut go run ./cmd/terdut
``` ```
@@ -52,11 +52,12 @@ docker run -p 8080:8080 -v $(pwd)/data:/data \
### Kubernetes ### Kubernetes
A Helm chart is published from this repository: A Helm chart is published from this repository as an OCI artifact, versioned in lockstep
with the app — chart `x.y.z` is always app `vx.y.z`:
```bash ```bash
helm repo add terdut-server https://yeniklas.github.io/terdut-server helm upgrade --install terdut-server oci://git.ryuvia.com/niklas/terdut-server \
helm upgrade --install terdut-server terdut-server/terdut-server \ --version 0.9.2 \
--namespace terdut-server --create-namespace \ --namespace terdut-server --create-namespace \
--set networking.hostname=terdut.example.com --set networking.hostname=terdut.example.com
``` ```
@@ -660,3 +661,64 @@ go test ./... # run all tests
go build ./... # compile all packages go build ./... # compile all packages
go run ./cmd/terdut # run locally go run ./cmd/terdut # run locally
``` ```
`make fmt lint test helm-lint` is the gate. It mirrors `.gitea/workflows/ci.yaml` step for
step, so a green run here means a green pipeline — with one deliberate exception: `make test`
adds `-race`, which CI does not. The sweeper, the notifier goroutine and the dead man's switch
sweep all touch the same single database connection, and a race between them would surface as
a flaky incident in production rather than as a red build.
## Releasing
```
push or PR → ci.yaml gofmt, go vet, go test -race
govulncheck, gitleaks
helm lint + render
push tag vX.Y.Z → release.yaml the same gate, then publish:
git.ryuvia.com/niklas/terdut-server:vX.Y.Z
oci://git.ryuvia.com/niklas/terdut-server X.Y.Z
then trivy-scan the pushed image
PR to Ryuvia/charts → bump the wrapper chart to X.Y.Z; on merge
Flux reconciles and the release rolls out
```
Both artifacts go to the **personal** Gitea namespace rather than `ryuvia`, because Gitea
scopes package visibility to the owner with no per-package override — so `ryuvia/*` is private
because the org is. Publishing to `niklas` keeps them anonymously pullable, which is why no
pull secret is needed in the cluster. Same reasoning, and the same choice, as riksdata and
rd-web.
Saying **"Release"** runs all three rows: the `release` skill commits, pushes, tags, waits for
the pipeline, and opens the `Ryuvia/charts` PR, stopping before the merge. See
`~/.claude/skills/release/`, or `.release.conf` here for this repo's part of it.
The chart is published **only** from the tag, by the `chart` job. There used to be a second
publisher on every `charts/**` push to main, and the two raced for the same chart version with
different answers — chart 0.9.0 went out reading `appVersion: "latest"` that way. One
publisher, triggered by the tag (`766f439`). The cost is that a chart-only change has no
version of its own and rides the next app tag.
Both workflows are thin drivers over the Makefile: `ci.yaml` runs `make fmt lint test` and
`make helm-lint`, `release.yaml` adds `make binaries`, `make push`, `make helm-package` and
`make helm-push`. That is deliberate — it is what makes a green local gate and a green
pipeline the same code rather than two descriptions of it, and it is how riksdata and rd-web
have always worked.
`make push` builds and pushes in one step, unlike those two, because the image is
`linux/amd64,linux/arm64` and buildx cannot load a multi-platform result into the local image
store. `make build` stays single-platform and local-only. Both refuse `VERSION=dev`:
publishing is one command, so it is also one command to run by accident. Publishing happens
by pushing a tag.
Two things the release process needs to know about this repo:
- **The image scan runs after publishing**, like riksdata's and rd-web's: trivy cannot read
a locally built image on this runner, so it pulls the pushed one. A red `scan-image` means
do not bump the wrapper chart to that version — it cannot unpublish anything. The image is
`FROM scratch`, so trivy sees exactly one target, the Go binary and its module graph.
- **The wrapper chart's `values.yaml` has two `tag:` lines** — the app image and the python
backup sidecar — so `chart-bump` is given `--image` to say which one moves.
The wrapper chart must have **its own `version:` bumped in the same commit**. Flux reconciles
with `reconcileStrategy: ChartVersion`, so a chart whose version did not change produces no
new artifact and the change is never deployed — with no error anywhere.
+15 -2
View File
@@ -2,5 +2,18 @@ apiVersion: v2
name: terdut-server name: terdut-server
description: A Helm chart for Terminal Duty — on-call alert management server description: A Helm chart for Terminal Duty — on-call alert management server
type: application type: application
version: 0.9.0 # These two are placeholders for a local `helm install ./charts/terdut-server`, not the
appVersion: "latest" # released values. .gitea/workflows/release.yaml rewrites both from the git tag when it
# 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 -- `helm package --version --app-version` sets the published values from the tag and
# never consults these -- and a tree heading for a numbered release that states an older
# number tells its reader something false. They said 0.9.0 and "latest" 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.4
appVersion: "v0.9.4"
@@ -23,13 +23,23 @@ spec:
serviceAccountName: {{ include "terdut-server.fullname" . }}-bootstrap serviceAccountName: {{ include "terdut-server.fullname" . }}-bootstrap
containers: containers:
- name: bootstrap - name: bootstrap
image: alpine:3 # alpine/curl, not alpine:3 + `apk add curl`. Installing the binary at run time
# writes it into the container's writable upper layer, which is exactly the
# signature Falco's `Drop and execute new binary in container` (MITRE TA0003)
# exists to catch -- this hook emitted two Critical events on every single
# upgrade. See Ryuvia/charts#100. It also made `helm upgrade` depend on the
# Alpine CDN answering, since this runs as a post-upgrade hook and a failed
# hook fails the release.
#
# Still a full Alpine underneath, so sh, cat, sleep, grep, cut, head and tail
# are all present (verified in-cluster 2026-09-04). The image declares
# ENTRYPOINT ["/entrypoint.sh"], which `command:` below overrides -- do not
# change `command:` to `args:`.
image: alpine/curl:8.21.0@sha256:a1c44bab54d88e18ea9a6a4ecefab7f2d230b968567b78960fcaff8d51b7f067
command: command:
- /bin/sh - /bin/sh
- -c - -c
- | - |
apk add --no-cache curl > /dev/null 2>&1
SERVICE_URL="http://{{ include "terdut-server.fullname" . }}:{{ .Values.service.port }}" SERVICE_URL="http://{{ include "terdut-server.fullname" . }}:{{ .Values.service.port }}"
SECRET_NAME="{{ include "terdut-server.bootstrapSecretName" . }}" SECRET_NAME="{{ include "terdut-server.bootstrapSecretName" . }}"
K8S_API="https://kubernetes.default.svc" K8S_API="https://kubernetes.default.svc"
+1 -1
View File
@@ -7,7 +7,7 @@ networking:
listener: "" listener: ""
image: image:
repository: ghcr.io/yeniklas/terdut-server repository: git.ryuvia.com/niklas/terdut-server
tag: "latest" tag: "latest"
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/yeniklas/terdut-server/internal/api" "git.ryuvia.com/niklas/terdut-server/internal/api"
"github.com/yeniklas/terdut-server/internal/config" "git.ryuvia.com/niklas/terdut-server/internal/config"
"github.com/yeniklas/terdut-server/internal/db" "git.ryuvia.com/niklas/terdut-server/internal/db"
) )
var version = "dev" var version = "dev"
+1 -1
View File
@@ -1,4 +1,4 @@
module github.com/yeniklas/terdut-server module git.ryuvia.com/niklas/terdut-server
go 1.25.9 go 1.25.9
+1 -1
View File
@@ -10,8 +10,8 @@ import (
"strings" "strings"
"time" "time"
"git.ryuvia.com/niklas/terdut-server/internal/models"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/yeniklas/terdut-server/internal/models"
) )
// alertSelectFrom is the shared SELECT … FROM … clause used by all alert queries. // alertSelectFrom is the shared SELECT … FROM … clause used by all alert queries.
+2 -2
View File
@@ -12,8 +12,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/yeniklas/terdut-server/internal/api" "git.ryuvia.com/niklas/terdut-server/internal/api"
"github.com/yeniklas/terdut-server/internal/db" "git.ryuvia.com/niklas/terdut-server/internal/db"
) )
// ts wraps httptest.Server with a pre-bootstrapped API key. db is exposed so // ts wraps httptest.Server with a pre-bootstrapped API key. db is exposed so
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/yeniklas/terdut-server/internal/api" "git.ryuvia.com/niklas/terdut-server/internal/api"
) )
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/yeniklas/terdut-server/internal/models" "git.ryuvia.com/niklas/terdut-server/internal/models"
) )
// Values for incidents.resolution_source, recording who closed the incident: // Values for incidents.resolution_source, recording who closed the incident:
+1 -1
View File
@@ -8,8 +8,8 @@ import (
"strings" "strings"
"time" "time"
"git.ryuvia.com/niklas/terdut-server/internal/models"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/yeniklas/terdut-server/internal/models"
) )
func handleListIncidents(db *sql.DB) http.HandlerFunc { func handleListIncidents(db *sql.DB) http.HandlerFunc {
+57 -2
View File
@@ -10,8 +10,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/yeniklas/terdut-server/internal/api" "git.ryuvia.com/niklas/terdut-server/internal/api"
"github.com/yeniklas/terdut-server/internal/db" "git.ryuvia.com/niklas/terdut-server/internal/db"
) )
// amAlert builds one alert of a webhook payload. // amAlert builds one alert of a webhook payload.
@@ -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. // Nothing acknowledged yet means "no data", which is not the same claim as zero.
func TestStats_IncidentsNullMTTAWhenNothingAcknowledged(t *testing.T) { func TestStats_IncidentsNullMTTAWhenNothingAcknowledged(t *testing.T) {
s := newTS(t) s := newTS(t)
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/yeniklas/terdut-server/internal/models" "git.ryuvia.com/niklas/terdut-server/internal/models"
) )
type contextKey string type contextKey string
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/yeniklas/terdut-server/internal/models" "git.ryuvia.com/niklas/terdut-server/internal/models"
) )
const ( const (
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/yeniklas/terdut-server/internal/api" "git.ryuvia.com/niklas/terdut-server/internal/api"
) )
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+1 -1
View File
@@ -8,8 +8,8 @@ import (
"strings" "strings"
"time" "time"
"git.ryuvia.com/niklas/terdut-server/internal/models"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/yeniklas/terdut-server/internal/models"
) )
func handleCreateSchedule(db *sql.DB) http.HandlerFunc { 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) { return func(w http.ResponseWriter, r *http.Request) {
where, args := statsFilter(r.URL.Query(), "received_at") 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 var total, firing, resolved int64
err := db.QueryRowContext(r.Context(), fmt.Sprintf(` err := db.QueryRowContext(r.Context(), fmt.Sprintf(`
SELECT COUNT(*), SELECT COUNT(*),
SUM(CASE WHEN status = 'firing' THEN 1 ELSE 0 END), COALESCE(SUM(CASE WHEN status = 'firing' THEN 1 ELSE 0 END), 0),
SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END) COALESCE(SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END), 0)
FROM alerts WHERE %s`, where), args..., FROM alerts WHERE %s`, where), args...,
).Scan(&total, &firing, &resolved) ).Scan(&total, &firing, &resolved)
if err != nil { if err != nil {
@@ -167,13 +170,16 @@ func handleStatsIncidents(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
where, args := statsFilter(r.URL.Query(), "triggered_at") 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 total, triggered, acknowledged, resolved int64
var mtta, mttr *float64 var mtta, mttr *float64
err := db.QueryRowContext(r.Context(), fmt.Sprintf(` err := db.QueryRowContext(r.Context(), fmt.Sprintf(`
SELECT COUNT(*), SELECT COUNT(*),
SUM(CASE WHEN status = 'triggered' THEN 1 ELSE 0 END), COALESCE(SUM(CASE WHEN status = 'triggered' THEN 1 ELSE 0 END), 0),
SUM(CASE WHEN status = 'acknowledged' THEN 1 ELSE 0 END), COALESCE(SUM(CASE WHEN status = 'acknowledged' THEN 1 ELSE 0 END), 0),
SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END), COALESCE(SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END), 0),
AVG(CASE WHEN acknowledged_at IS NOT NULL AVG(CASE WHEN acknowledged_at IS NOT NULL
THEN acknowledged_at - triggered_at END), THEN acknowledged_at - triggered_at END),
AVG(CASE WHEN resolved_at IS NOT NULL AVG(CASE WHEN resolved_at IS NOT NULL
+1 -1
View File
@@ -11,8 +11,8 @@ import (
"strings" "strings"
"time" "time"
"git.ryuvia.com/niklas/terdut-server/internal/models"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/yeniklas/terdut-server/internal/models"
) )
func handleBootstrap(db *sql.DB) http.HandlerFunc { func handleBootstrap(db *sql.DB) http.HandlerFunc {