Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9669b8f477 | |||
| a7871ed7c6 | |||
| 79f5db2636 | |||
| 84146fc903 | |||
| c6f1fe317e | |||
| f46e5f5729 | |||
| 69fcc24a4d | |||
| 6a4f902e38 | |||
| 5f9c202d65 | |||
| 477454ec3c | |||
| 10812606bf | |||
| 94dec19976 | |||
| 9046f6e026 | |||
| 03504b61be | |||
| 6047d1a9f7 | |||
| 289eca8076 | |||
| 766f43931c |
@@ -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
|
||||
@@ -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"
|
||||
@@ -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-*'
|
||||
@@ -1,6 +1,11 @@
|
||||
# build output
|
||||
/terdut
|
||||
/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
|
||||
*.db
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
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
|
||||
COPY --from=builder /terdut /terdut
|
||||
|
||||
@@ -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)
|
||||
@@ -17,7 +17,7 @@ Incident management server for teams using Prometheus Alertmanager.
|
||||
**Prerequisites:** Go 1.21+
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yeniklas/terdut-server
|
||||
git clone https://git.ryuvia.com/niklas/terdut-server
|
||||
cd terdut-server
|
||||
go run ./cmd/terdut
|
||||
```
|
||||
@@ -52,11 +52,12 @@ docker run -p 8080:8080 -v $(pwd)/data:/data \
|
||||
|
||||
### 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
|
||||
helm repo add terdut-server https://yeniklas.github.io/terdut-server
|
||||
helm upgrade --install terdut-server terdut-server/terdut-server \
|
||||
helm upgrade --install terdut-server oci://git.ryuvia.com/niklas/terdut-server \
|
||||
--version 0.9.2 \
|
||||
--namespace terdut-server --create-namespace \
|
||||
--set networking.hostname=terdut.example.com
|
||||
```
|
||||
@@ -660,3 +661,64 @@ go test ./... # run all tests
|
||||
go build ./... # compile all packages
|
||||
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.
|
||||
|
||||
@@ -2,5 +2,18 @@ apiVersion: v2
|
||||
name: terdut-server
|
||||
description: A Helm chart for Terminal Duty — on-call alert management server
|
||||
type: application
|
||||
version: 0.9.0
|
||||
appVersion: "latest"
|
||||
# These two are placeholders for a local `helm install ./charts/terdut-server`, not the
|
||||
# released values. .gitea/workflows/release.yaml rewrites both from the git tag when it
|
||||
# publishes, so the chart version always equals the app version.
|
||||
#
|
||||
# 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
|
||||
containers:
|
||||
- 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:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
apk add --no-cache curl > /dev/null 2>&1
|
||||
|
||||
SERVICE_URL="http://{{ include "terdut-server.fullname" . }}:{{ .Values.service.port }}"
|
||||
SECRET_NAME="{{ include "terdut-server.bootstrapSecretName" . }}"
|
||||
K8S_API="https://kubernetes.default.svc"
|
||||
|
||||
@@ -7,7 +7,7 @@ networking:
|
||||
listener: ""
|
||||
|
||||
image:
|
||||
repository: ghcr.io/yeniklas/terdut-server
|
||||
repository: git.ryuvia.com/niklas/terdut-server
|
||||
tag: "latest"
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
|
||||
+3
-3
@@ -8,9 +8,9 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/yeniklas/terdut-server/internal/api"
|
||||
"github.com/yeniklas/terdut-server/internal/config"
|
||||
"github.com/yeniklas/terdut-server/internal/db"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/db"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module github.com/yeniklas/terdut-server
|
||||
module git.ryuvia.com/niklas/terdut-server
|
||||
|
||||
go 1.25.9
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||
"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.
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yeniklas/terdut-server/internal/api"
|
||||
"github.com/yeniklas/terdut-server/internal/db"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/db"
|
||||
)
|
||||
|
||||
// ts wraps httptest.Server with a pre-bootstrapped API key. db is exposed so
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yeniklas/terdut-server/internal/api"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"strings"
|
||||
"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:
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/yeniklas/terdut-server/internal/models"
|
||||
)
|
||||
|
||||
func handleListIncidents(db *sql.DB) http.HandlerFunc {
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yeniklas/terdut-server/internal/api"
|
||||
"github.com/yeniklas/terdut-server/internal/db"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/db"
|
||||
)
|
||||
|
||||
// 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.
|
||||
func TestStats_IncidentsNullMTTAWhenNothingAcknowledged(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yeniklas/terdut-server/internal/models"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yeniklas/terdut-server/internal/models"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yeniklas/terdut-server/internal/api"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/yeniklas/terdut-server/internal/models"
|
||||
)
|
||||
|
||||
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) {
|
||||
where, args := statsFilter(r.URL.Query(), "received_at")
|
||||
|
||||
// COALESCE because SUM over zero rows is NULL, not 0, and a count of
|
||||
// nothing is 0 — without it an empty window is a 500 rather than a
|
||||
// legitimately empty report.
|
||||
var total, firing, resolved int64
|
||||
err := db.QueryRowContext(r.Context(), fmt.Sprintf(`
|
||||
SELECT COUNT(*),
|
||||
SUM(CASE WHEN status = 'firing' THEN 1 ELSE 0 END),
|
||||
SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END)
|
||||
COALESCE(SUM(CASE WHEN status = 'firing' THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END), 0)
|
||||
FROM alerts WHERE %s`, where), args...,
|
||||
).Scan(&total, &firing, &resolved)
|
||||
if err != nil {
|
||||
@@ -167,13 +170,16 @@ func handleStatsIncidents(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
where, args := statsFilter(r.URL.Query(), "triggered_at")
|
||||
|
||||
// The counts are COALESCEd because SUM over zero rows is NULL, not 0.
|
||||
// The averages are not: mtta and mttr stay null on purpose, since zero
|
||||
// would read as "instant" rather than "nothing to measure yet".
|
||||
var total, triggered, acknowledged, resolved int64
|
||||
var mtta, mttr *float64
|
||||
err := db.QueryRowContext(r.Context(), fmt.Sprintf(`
|
||||
SELECT COUNT(*),
|
||||
SUM(CASE WHEN status = 'triggered' THEN 1 ELSE 0 END),
|
||||
SUM(CASE WHEN status = 'acknowledged' THEN 1 ELSE 0 END),
|
||||
SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END),
|
||||
COALESCE(SUM(CASE WHEN status = 'triggered' THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN status = 'acknowledged' THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END), 0),
|
||||
AVG(CASE WHEN acknowledged_at IS NOT NULL
|
||||
THEN acknowledged_at - triggered_at END),
|
||||
AVG(CASE WHEN resolved_at IS NOT NULL
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/yeniklas/terdut-server/internal/models"
|
||||
)
|
||||
|
||||
func handleBootstrap(db *sql.DB) http.HandlerFunc {
|
||||
|
||||
Reference in New Issue
Block a user