Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 477454ec3c | |||
| 10812606bf | |||
| 94dec19976 | |||
| 9046f6e026 | |||
| 03504b61be | |||
| 6047d1a9f7 | |||
| 289eca8076 | |||
| 766f43931c |
@@ -0,0 +1,90 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
# 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 ./...
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: go test ./...
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
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" .
|
||||||
|
|
||||||
|
# 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 ./...
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: go 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" .
|
||||||
|
|
||||||
|
- name: Build every target
|
||||||
|
env:
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
mkdir -p dist
|
||||||
|
for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do
|
||||||
|
GOOS="${target%/*}"
|
||||||
|
GOARCH="${target#*/}"
|
||||||
|
out="dist/terdut-${REF_NAME}-${GOOS}-${GOARCH}"
|
||||||
|
echo "building $out"
|
||||||
|
GOOS="$GOOS" GOARCH="$GOARCH" go build \
|
||||||
|
-ldflags "-w -s -X main.version=${REF_NAME}" \
|
||||||
|
-o "$out" ./cmd/terdut
|
||||||
|
done
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# The default "docker" driver cannot build more than one platform at a time; the
|
||||||
|
# docker-container driver can. Reused across runs if it survived the last one.
|
||||||
|
- name: Prepare buildx
|
||||||
|
run: docker buildx create --name terdut --use 2>/dev/null || docker buildx use terdut
|
||||||
|
|
||||||
|
# No QEMU: the Dockerfile's builder stage runs on $BUILDPLATFORM and cross-compiles
|
||||||
|
# from TARGETARCH, so both platforms build natively. See the comment in Dockerfile.
|
||||||
|
- name: Build and push
|
||||||
|
env:
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
--build-arg "VERSION=${REF_NAME}" \
|
||||||
|
--tag "${IMAGE}:latest" \
|
||||||
|
--tag "${IMAGE}:${REF_NAME}" \
|
||||||
|
--push .
|
||||||
|
|
||||||
|
# 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 --
|
||||||
|
# the sed below ties the chart version to the app version, so a chart-only change
|
||||||
|
# has no version of its own to be released under anyway. Chart fixes ride the next
|
||||||
|
# tag.
|
||||||
|
- name: Stamp the chart version from the 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
|
||||||
|
CHART_VERSION="${REF_NAME#v}"
|
||||||
|
sed -i "s/^version:.*/version: ${CHART_VERSION}/" charts/terdut-server/Chart.yaml
|
||||||
|
sed -i "s/^appVersion:.*/appVersion: \"${REF_NAME}\"/" charts/terdut-server/Chart.yaml
|
||||||
|
cat charts/terdut-server/Chart.yaml
|
||||||
|
|
||||||
|
- 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
|
||||||
|
# Isolated repo config: the machine-wide helm repo list is not this job's
|
||||||
|
# business, and one unreachable entry in it aborts otherwise-fine commands.
|
||||||
|
# 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.
|
||||||
|
export HELM_REPOSITORY_CONFIG="$PWD/.helm-repos.yaml"
|
||||||
|
: > "$HELM_REPOSITORY_CONFIG"
|
||||||
|
helm package charts/terdut-server -d dist
|
||||||
|
helm push "dist/terdut-server-${REF_NAME#v}.tgz" "oci://${REGISTRY}/niklas"
|
||||||
@@ -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 }}"
|
|
||||||
@@ -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 ./...
|
|
||||||
@@ -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-*'
|
|
||||||
@@ -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
|
||||||
+11
-2
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.0 \
|
||||||
--namespace terdut-server --create-namespace \
|
--namespace terdut-server --create-namespace \
|
||||||
--set networking.hostname=terdut.example.com
|
--set networking.hostname=terdut.example.com
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -2,5 +2,17 @@ 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 -- 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"
|
||||||
|
|||||||
@@ -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
@@ -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,4 +1,4 @@
|
|||||||
module github.com/yeniklas/terdut-server
|
module git.ryuvia.com/niklas/terdut-server
|
||||||
|
|
||||||
go 1.25.9
|
go 1.25.9
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/yeniklas/terdut-server/internal/api"
|
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/yeniklas/terdut-server/internal/api"
|
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user