79f5db2636
The image scan added yesterday reads the built artifact. It cannot see a vulnerable dependency the binary never calls into, and it cannot see a credential in a file that never reaches the image — this one is FROM scratch and contains a single binary, so almost nothing in the repo is in it. Those are two different questions and they need two different tools, which is why riksdata and rd-web have run govulncheck and gitleaks all along. Both run on every push and pull request rather than only on a tag, since neither needs anything published. Checked by hand before wiring in, as with the image scan. govulncheck reports no vulnerabilities the code can reach, and gitleaks finds nothing in the tree. What govulncheck does report is worth writing down, because it is the argument for having it. It found three advisories in chi and reports none of them, all three being IP spoofing in middleware.RealIP, which router.go does not use — it uses Logger and Recoverer. The analysis is symbol-level rather than dependency-level, so adding middleware.RealIP would turn this red on the next push. That is precisely when someone should be made to look, and it is a plausible thing to reach for here, since the API sits behind a gateway and real client addresses are exactly what RealIP is for. The fourth finding is an integer overflow in golang.org/x/sys/windows, which a linux/scratch image will not be calling. Both gates were checked for the failure direction as well. gitleaks exits 1 on a private key block. Worth knowing when testing it: it allowlists well-known example credentials, so the AWS key from Amazon's own documentation does not trip it and proves nothing. Neither reads git history. gitleaks runs with --no-git, which scans the working tree, so it stops a secret on the way in and says nothing about what is already committed. Claude-Session: https://claude.ai/code/session_01S7R4gWTz5wh5xCY4nCSJjN
220 lines
10 KiB
Makefile
220 lines
10 KiB
Makefile
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)
|