From 0cdcd2394a2464d07646dafe3dd0c1a75853854f Mon Sep 17 00:00:00 2001 From: Valentin Knabel Date: Mon, 6 Jul 2026 11:10:04 +0200 Subject: [PATCH 01/12] feat: initial implementation --- .github/workflows/metal-stack-component.yaml | 36 +++++ .gitignore | 1 + CODEWONERS | 1 + Dockerfile | 5 + Makefile | 37 ++++++ cmd/token-refresher/main.go | 67 ++++++++++ go.mod | 56 ++++++++ go.sum | 131 +++++++++++++++++++ refresher/refresher.go | 82 ++++++++++++ spec/annotations.go | 8 ++ spec/config.go | 48 +++++++ 11 files changed, 472 insertions(+) create mode 100644 .github/workflows/metal-stack-component.yaml create mode 100644 CODEWONERS create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 cmd/token-refresher/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 refresher/refresher.go create mode 100644 spec/annotations.go create mode 100644 spec/config.go diff --git a/.github/workflows/metal-stack-component.yaml b/.github/workflows/metal-stack-component.yaml new file mode 100644 index 0000000..10b1037 --- /dev/null +++ b/.github/workflows/metal-stack-component.yaml @@ -0,0 +1,36 @@ +--- +name: metal-stack component release + +on: + pull_request: + branches: + - main + release: + types: + - published + push: + branches: + - main + +jobs: + draft: + uses: metal-stack/actions-common/.github/workflows/release-drafter.yaml@v1 + + spell-check: + uses: metal-stack/actions-common/.github/workflows/spell-check.yaml@v1 + + go-build: + uses: metal-stack/actions-common/.github/workflows/go-build.yaml@v1 + secrets: inherit + with: + lint: true + test: true + build: true + test-command: make test + build-command: make token-refresher + registry: ghcr.io + registry-username: ${{ github.actor }} + image-name: ${{ github.repository }} + context: . + dockerfile: Dockerfile + artifact-files: "" diff --git a/.gitignore b/.gitignore index aaadf73..df48589 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ *.dll *.so *.dylib +bin # Test binary, built with `go test -c` *.test diff --git a/CODEWONERS b/CODEWONERS new file mode 100644 index 0000000..df6734d --- /dev/null +++ b/CODEWONERS @@ -0,0 +1 @@ +* @metal-stack/metal-token-refresher-maintainers diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..006fcc3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +# nonroot cannot be chosen because the lack of permissions +# to write the kubeconfig and token file into the desired place in the fs +FROM gcr.io/distroless/static-debian13:latest +COPY bin/token-refresher /token-refresher +CMD ["/token-refresher"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8dcdad9 --- /dev/null +++ b/Makefile @@ -0,0 +1,37 @@ +SHA := $(shell git rev-parse --short=8 HEAD) +GITVERSION := $(shell git describe --long --all) +BUILDDATE := $(shell date -Iseconds) +VERSION := $(or ${VERSION},$(shell git describe --tags --exact-match 2> /dev/null || git symbolic-ref -q --short HEAD || git rev-parse --short HEAD)) + +CGO_ENABLED := 1 +LINKMODE := -extldflags '-static -s -w' + +ifeq ($(CI),true) + GO_TEST_ARGS=-p 1 -count=1 +else + GO_TEST_ARGS= +endif + +all: fmt test token-refresher + +.PHONY: token-refresher +token-refresher: fmt + go build -tags netgo,osusergo,urfave_cli_no_docs \ + -ldflags "$(LINKMODE) -X 'github.com/metal-stack/v.Version=$(VERSION)' \ + -X 'github.com/metal-stack/v.Revision=$(GITVERSION)' \ + -X 'github.com/metal-stack/v.GitSHA1=$(SHA)' \ + -X 'github.com/metal-stack/v.BuildDate=$(BUILDDATE)'" \ + -o bin/token-refresher github.com/metal-stack/metal-token-refresher/cmd/token-refresher + strip bin/token-refresher + +.PHONY: test +test: + go test ./... -race -coverpkg=./... -coverprofile=coverage.out -covermode=atomic $(GO_TEST_ARGS) -timeout=300s && go tool cover -func=coverage.out + +.PHONY: fmt +fmt: + go fmt ./... + +.PHONY: golint +golint: + golangci-lint run -p bugs -p unused -D protogetter diff --git a/cmd/token-refresher/main.go b/cmd/token-refresher/main.go new file mode 100644 index 0000000..39c3908 --- /dev/null +++ b/cmd/token-refresher/main.go @@ -0,0 +1,67 @@ +package main + +import ( + "context" + "log/slog" + "os" + + apiclient "github.com/metal-stack/api/go/client" + "github.com/metal-stack/metal-token-refresher/refresher" + "github.com/metal-stack/metal-token-refresher/spec" + "github.com/metal-stack/v" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +func main() { + jsonHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{}) + log := slog.New(jsonHandler) + + log.Info("starting metal-token-refresher", + "version", v.Version, + "revision", v.Revision, + "git-sha1", v.GitSHA1, + "build-date", v.BuildDate, + ) + + cfg, err := spec.LoadConfig() + if err != nil { + log.Error("configuration error", "error", err) + panic(err) + } + + restCfg, err := rest.InClusterConfig() + if err != nil { + log.Error("failed to fetch in cluster rest config", "error", err) + panic(err) + } + cs, err := kubernetes.NewForConfig(restCfg) + if err != nil { + log.Error("failed to create in cluster client", "error", err) + panic(err) + } + + log.Info("refreshing metal-apiserver token", "metal-apiserver-url", cfg.MetalAPIServerURL) + + dial := &apiclient.DialConfig{ + BaseURL: cfg.MetalAPIServerURL, + Log: log, + Token: "", // handled by refresher + } + + client, err := apiclient.New(dial) + if err != nil { + log.Error("failed to create metal-apiserver client", "error", err) + panic(err) + } + + refresh := refresher.New(log, cs, dial, client) + err = refresh.RefreshSecret(context.Background(), refresher.TokenSecretKeyRef{ + Namespace: cfg.SecretNamespace, + Name: cfg.SecretName, + Key: cfg.SecretKey, + }) + if err != nil { + panic(err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..56b6322 --- /dev/null +++ b/go.mod @@ -0,0 +1,56 @@ +module github.com/metal-stack/metal-token-refresher + +go 1.26.4 + +require ( + github.com/metal-stack/api v0.2.3 + github.com/metal-stack/v v1.0.3 + k8s.io/apimachinery v0.36.2 + k8s.io/client-go v0.36.2 +) + +require ( + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 // indirect + connectrpc.com/connect v1.20.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/klauspost/connect-compress/v2 v2.1.1 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/minio/minlz v1.1.1 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/term v0.39.0 // indirect + golang.org/x/text v0.38.0 // indirect + golang.org/x/time v0.14.0 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/api v0.36.2 // indirect + k8s.io/klog/v2 v2.140.0 // indirect + k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect + k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..20d3e7a --- /dev/null +++ b/go.sum @@ -0,0 +1,131 @@ +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 h1:s6hzCXtND/ICdGPTMGk7C+/BFlr2Jg5GyH0NKf4XGXg= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= +connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= +connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/connect-compress/v2 v2.1.1 h1:ycZNp4rWOZBodVE2Ls5AzK4aHkyK+GteEfzRZgKNs+c= +github.com/klauspost/connect-compress/v2 v2.1.1/go.mod h1:9oilsPHJMzGKkjafSBk9J7iVo4mO+dw0G0KSdVpnlVE= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/metal-stack/api v0.2.3 h1:+WxXfcsd3R9Hr8BzOa0ybXv8rlarDNJDSlVgetwAYns= +github.com/metal-stack/api v0.2.3/go.mod h1:+WrGqA7QpQ2O60vakm3tjDbo70Q4c92pdco4R5MRcL8= +github.com/metal-stack/v v1.0.3 h1:Sh2oBlnxrCUD+mVpzfC8HiqL045YWkxs0gpTvkjppqs= +github.com/metal-stack/v v1.0.3/go.mod h1:YTahEu7/ishwpYKnp/VaW/7nf8+PInogkfGwLcGPdXg= +github.com/minio/minlz v1.1.1 h1:OGmft1V6AnI/Wme332U6bhG54nxEan+VFgkD7lat4KM= +github.com/minio/minlz v1.1.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.36.2 h1:TF6YDLIzKfccK7cq9YpTcGX8TJmEkHVRv78DM51fRYY= +k8s.io/api v0.36.2/go.mod h1:F4LbMO4brjZYh7yFkXWhynSvtB7YauxV4c+HHkNRGNg= +k8s.io/apimachinery v0.36.2 h1:0PE/W/WNy1UX61NLbXY5TMbJ6UwLL6E6lAPkYrKFxbQ= +k8s.io/apimachinery v0.36.2/go.mod h1:fvf/HOLXq9RId0rnDIbN1OEBvHXdQbLMM8nu0LcBUf4= +k8s.io/client-go v0.36.2 h1:bfgxmFKc9CgqsgX4xKLAAdmTQlWee7Ob/HlDOrJ5TBI= +k8s.io/client-go v0.36.2/go.mod h1:1vgO4OAlfPnoLcb+Rze2GF5rAr14w8qjrYMoyXJzQj0= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/refresher/refresher.go b/refresher/refresher.go new file mode 100644 index 0000000..ef93d93 --- /dev/null +++ b/refresher/refresher.go @@ -0,0 +1,82 @@ +package refresher + +import ( + "context" + "log/slog" + "time" + + apiclient "github.com/metal-stack/api/go/client" + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/metal-token-refresher/spec" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +type ( + Refresher struct { + log *slog.Logger + clientset *kubernetes.Clientset + dial *apiclient.DialConfig + client apiclient.Client + } + TokenSecretKeyRef struct { + Namespace string + Name string + Key string + } +) + +func New(log *slog.Logger, clientset *kubernetes.Clientset, dial *apiclient.DialConfig, client apiclient.Client) *Refresher { + return &Refresher{ + log: log, + clientset: clientset, + dial: dial, + client: client, + } +} + +func (r *Refresher) RefreshSecret(ctx context.Context, ref TokenSecretKeyRef) error { + r.log.With("namespace", ref.Namespace, "name", ref.Name) + + tokSec, err := r.clientset.CoreV1().Secrets(ref.Namespace).Get(ctx, ref.Name, metav1.GetOptions{}) + if err != nil { + r.log.Error("failed to fetch token secret", "error", err) + return err + } + + tok, ok := tokSec.Data[ref.Key] + if !ok { + r.log.Error("missing token in secret", "key", ref.Key) + return err + } + + r.dial.Token = string(tok) + + r.log.Info("refreshing token...") + + tokResp, err := r.client.Apiv2().Token().Refresh(ctx, &apiv2.TokenServiceRefreshRequest{}) + if err != nil { + r.log.Error("failed to refresh token", "error", err) + return err + } + + tokSec.Data[ref.Key] = []byte(tokResp.Secret) + + annos := tokSec.Annotations + if annos == nil { + annos = map[string]string{} + } + + annos[spec.AnnotationTokenUser] = tokResp.Token.User + annos[spec.AnnotationTokenDescription] = tokResp.Token.Description + annos[spec.AnnotationTokenExpires] = tokResp.Token.Expires.AsTime().Format(time.RFC3339) + annos[spec.AnnotationTokenIssuedAt] = tokResp.Token.IssuedAt.AsTime().Format(time.RFC3339) + + _, err = r.clientset.CoreV1().Secrets(ref.Namespace).Update(ctx, tokSec, metav1.UpdateOptions{}) + if err != nil { + r.log.Error("failed to update token secret", "error", err) + return err + } + + return nil +} diff --git a/spec/annotations.go b/spec/annotations.go new file mode 100644 index 0000000..094adda --- /dev/null +++ b/spec/annotations.go @@ -0,0 +1,8 @@ +package spec + +const ( + AnnotationTokenExpires = "token-refresher.metal-stack.io/expires" + AnnotationTokenIssuedAt = "token-refresher.metal-stack.io/issued-at" + AnnotationTokenDescription = "token-refresher.metal-stack.io/description" + AnnotationTokenUser = "token-refresher.metal-stack.io/user" +) diff --git a/spec/config.go b/spec/config.go new file mode 100644 index 0000000..8ec0791 --- /dev/null +++ b/spec/config.go @@ -0,0 +1,48 @@ +package spec + +import ( + "errors" + "fmt" + "os" +) + +type Config struct { + MetalAPIServerURL string + SecretNamespace string + SecretName string + SecretKey string +} + +func LoadConfig() (*Config, error) { + var ( + cfg = &Config{} + errs []error + ok bool + ) + + cfg.MetalAPIServerURL, ok = os.LookupEnv("METAL_APISERVER_URL") + if !ok { + errs = append(errs, fmt.Errorf("missing required METAL_APISERVER_URL")) + } + + cfg.SecretNamespace, ok = os.LookupEnv("TOKEN_SECRET_NAMESPACE") + if !ok { + errs = append(errs, fmt.Errorf("missing required TOKEN_SECRET_NAMESPACE")) + } + + cfg.SecretName, ok = os.LookupEnv("TOKEN_SECRET_NAME") + if !ok { + errs = append(errs, fmt.Errorf("missing required TOKEN_SECRET_NAME")) + } + + cfg.SecretKey, ok = os.LookupEnv("TOKEN_SECRET_KEY") + if !ok { + cfg.SecretKey = "token" + } + + if len(errs) > 0 { + return nil, errors.Join(errs...) + } + + return cfg, nil +} From 5202a0409e830cb420561813dc8cf541d789afb5 Mon Sep 17 00:00:00 2001 From: Valentin Knabel Date: Mon, 6 Jul 2026 13:51:00 +0200 Subject: [PATCH 02/12] test: token refresher --- cmd/token-refresher/main.go | 19 +-- go.mod | 7 +- go.sum | 2 + refresher/refresher.go | 32 ++-- refresher/refresher_test.go | 303 ++++++++++++++++++++++++++++++++++++ 5 files changed, 338 insertions(+), 25 deletions(-) create mode 100644 refresher/refresher_test.go diff --git a/cmd/token-refresher/main.go b/cmd/token-refresher/main.go index 39c3908..7153d9d 100644 --- a/cmd/token-refresher/main.go +++ b/cmd/token-refresher/main.go @@ -43,19 +43,16 @@ func main() { log.Info("refreshing metal-apiserver token", "metal-apiserver-url", cfg.MetalAPIServerURL) - dial := &apiclient.DialConfig{ - BaseURL: cfg.MetalAPIServerURL, - Log: log, - Token: "", // handled by refresher - } + refresh := refresher.New(log, cs, func(token string) (apiclient.Client, error) { + dial := &apiclient.DialConfig{ + BaseURL: cfg.MetalAPIServerURL, + Log: log, + Token: "", // handled by refresher + } - client, err := apiclient.New(dial) - if err != nil { - log.Error("failed to create metal-apiserver client", "error", err) - panic(err) - } + return apiclient.New(dial) + }) - refresh := refresher.New(log, cs, dial, client) err = refresh.RefreshSecret(context.Background(), refresher.TokenSecretKeyRef{ Namespace: cfg.SecretNamespace, Name: cfg.SecretName, diff --git a/go.mod b/go.mod index 56b6322..5d2d215 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,9 @@ go 1.26.4 require ( github.com/metal-stack/api v0.2.3 github.com/metal-stack/v v1.0.3 + github.com/stretchr/testify v1.11.1 + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af + k8s.io/api v0.36.2 k8s.io/apimachinery v0.36.2 k8s.io/client-go v0.36.2 ) @@ -32,6 +35,8 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/stretchr/objx v0.5.3 // indirect github.com/x448/float16 v0.8.4 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect @@ -41,11 +46,9 @@ require ( golang.org/x/term v0.39.0 // indirect golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.14.0 // indirect - google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.36.2 // indirect k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect diff --git a/go.sum b/go.sum index 20d3e7a..e7ac14a 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-202604152011 buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/refresher/refresher.go b/refresher/refresher.go index ef93d93..e3d1cfc 100644 --- a/refresher/refresher.go +++ b/refresher/refresher.go @@ -2,6 +2,7 @@ package refresher import ( "context" + "fmt" "log/slog" "time" @@ -13,12 +14,14 @@ import ( ) type ( + ClientForToken func(token string) (apiclient.Client, error) + Refresher struct { - log *slog.Logger - clientset *kubernetes.Clientset - dial *apiclient.DialConfig - client apiclient.Client + log *slog.Logger + clientset kubernetes.Interface + clientForToken ClientForToken } + TokenSecretKeyRef struct { Namespace string Name string @@ -26,12 +29,11 @@ type ( } ) -func New(log *slog.Logger, clientset *kubernetes.Clientset, dial *apiclient.DialConfig, client apiclient.Client) *Refresher { +func New(log *slog.Logger, clientset kubernetes.Interface, clientForToken ClientForToken) *Refresher { return &Refresher{ - log: log, - clientset: clientset, - dial: dial, - client: client, + log: log, + clientset: clientset, + clientForToken: clientForToken, } } @@ -47,14 +49,18 @@ func (r *Refresher) RefreshSecret(ctx context.Context, ref TokenSecretKeyRef) er tok, ok := tokSec.Data[ref.Key] if !ok { r.log.Error("missing token in secret", "key", ref.Key) - return err + return fmt.Errorf("key %q not found in secret %s/%s", ref.Key, ref.Namespace, ref.Name) } - r.dial.Token = string(tok) + client, err := r.clientForToken(string(tok)) + if err != nil { + r.log.Error("failed to create a metal client for token", "error", err) + return err + } r.log.Info("refreshing token...") - tokResp, err := r.client.Apiv2().Token().Refresh(ctx, &apiv2.TokenServiceRefreshRequest{}) + tokResp, err := client.Apiv2().Token().Refresh(ctx, &apiv2.TokenServiceRefreshRequest{}) if err != nil { r.log.Error("failed to refresh token", "error", err) return err @@ -72,6 +78,8 @@ func (r *Refresher) RefreshSecret(ctx context.Context, ref TokenSecretKeyRef) er annos[spec.AnnotationTokenExpires] = tokResp.Token.Expires.AsTime().Format(time.RFC3339) annos[spec.AnnotationTokenIssuedAt] = tokResp.Token.IssuedAt.AsTime().Format(time.RFC3339) + tokSec.SetAnnotations(annos) + _, err = r.clientset.CoreV1().Secrets(ref.Namespace).Update(ctx, tokSec, metav1.UpdateOptions{}) if err != nil { r.log.Error("failed to update token secret", "error", err) diff --git a/refresher/refresher_test.go b/refresher/refresher_test.go new file mode 100644 index 0000000..86d49f5 --- /dev/null +++ b/refresher/refresher_test.go @@ -0,0 +1,303 @@ +package refresher + +import ( + "context" + "fmt" + "log/slog" + "os" + "testing" + "time" + + "google.golang.org/protobuf/types/known/timestamppb" + + apiclient "github.com/metal-stack/api/go/client" + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + apitests "github.com/metal-stack/api/go/tests" + "github.com/metal-stack/metal-token-refresher/spec" + "github.com/stretchr/testify/mock" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + fake "k8s.io/client-go/kubernetes/fake" +) + +func mustTimestamp(date string) *timestamppb.Timestamp { + parsed, err := time.Parse(time.RFC3339, date) + if err != nil { + panic(fmt.Errorf("failed to parse date %q", err)) + } + + return timestamppb.New(parsed) +} + +func testLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(os.Stdout, nil)) +} + +func TestRefreshSecret(t *testing.T) { + tests := []struct { + name string + ref TokenSecretKeyRef + responseOk *apiv2.TokenServiceRefreshResponse + responseErr error + beforeSecret *v1.Secret + wantSecret *v1.Secret + wantError string + }{ + { + name: "refreshes token and patches existing one", + ref: TokenSecretKeyRef{ + Name: "some-secret", + Namespace: "my-namespace", + Key: "token", + }, + beforeSecret: &v1.Secret{ + Data: map[string][]byte{ + "token": []byte("old-token"), + }, + }, + responseOk: &apiv2.TokenServiceRefreshResponse{ + Secret: "new-token", + Token: &apiv2.Token{ + User: "some-user", + Description: "some description", + Expires: mustTimestamp("2006-01-02T16:04:05Z"), + IssuedAt: mustTimestamp("2006-01-02T14:04:05Z"), + }, + }, + wantSecret: &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + spec.AnnotationTokenUser: "some-user", + spec.AnnotationTokenDescription: "some description", + spec.AnnotationTokenExpires: "2006-01-02T16:04:05Z", + spec.AnnotationTokenIssuedAt: "2006-01-02T14:04:05Z", + }, + }, + Data: map[string][]byte{ + "token": []byte("new-token"), + }, + }, + }, + { + name: "overrides only conflicting annotations", + ref: TokenSecretKeyRef{ + Name: "some-secret", + Namespace: "my-namespace", + Key: "token", + }, + beforeSecret: &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + spec.AnnotationTokenUser: "outdated-user", + spec.AnnotationTokenDescription: "outdated description", + spec.AnnotationTokenExpires: "2006-01-01T16:04:05Z", + spec.AnnotationTokenIssuedAt: "2006-01-01T14:04:05Z", + "another-annotation": "keep-it", + }, + }, + Data: map[string][]byte{ + "token": []byte("old-token"), + }, + }, + responseOk: &apiv2.TokenServiceRefreshResponse{ + Secret: "new-token", + Token: &apiv2.Token{ + User: "some-user", + Description: "some description", + Expires: mustTimestamp("2006-01-02T16:04:05Z"), + IssuedAt: mustTimestamp("2006-01-02T14:04:05Z"), + }, + }, + wantSecret: &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + spec.AnnotationTokenUser: "some-user", + spec.AnnotationTokenDescription: "some description", + spec.AnnotationTokenExpires: "2006-01-02T16:04:05Z", + spec.AnnotationTokenIssuedAt: "2006-01-02T14:04:05Z", + "another-annotation": "keep-it", + }, + }, + Data: map[string][]byte{ + "token": []byte("new-token"), + }, + }, + }, + { + name: "keeps other data as is", + ref: TokenSecretKeyRef{ + Name: "some-secret", + Namespace: "my-namespace", + Key: "token", + }, + beforeSecret: &v1.Secret{ + Data: map[string][]byte{ + "token": []byte("old-token"), + "something": []byte("keep it"), + }, + }, + responseOk: &apiv2.TokenServiceRefreshResponse{ + Secret: "new-token", + Token: &apiv2.Token{ + User: "some-user", + Description: "some description", + Expires: mustTimestamp("2006-01-02T16:04:05Z"), + IssuedAt: mustTimestamp("2006-01-02T14:04:05Z"), + }, + }, + wantSecret: &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + spec.AnnotationTokenUser: "some-user", + spec.AnnotationTokenDescription: "some description", + spec.AnnotationTokenExpires: "2006-01-02T16:04:05Z", + spec.AnnotationTokenIssuedAt: "2006-01-02T14:04:05Z", + }, + }, + Data: map[string][]byte{ + "token": []byte("new-token"), + "something": []byte("keep it"), + }, + }, + }, + { + name: "respects token key", + ref: TokenSecretKeyRef{ + Name: "some-secret", + Namespace: "my-namespace", + Key: "secret-token", + }, + beforeSecret: &v1.Secret{ + Data: map[string][]byte{ + "secret-token": []byte("old-token"), + }, + }, + responseOk: &apiv2.TokenServiceRefreshResponse{ + Secret: "new-token", + Token: &apiv2.Token{ + User: "some-user", + Description: "some description", + Expires: mustTimestamp("2006-01-02T16:04:05Z"), + IssuedAt: mustTimestamp("2006-01-02T14:04:05Z"), + }, + }, + wantSecret: &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + spec.AnnotationTokenUser: "some-user", + spec.AnnotationTokenDescription: "some description", + spec.AnnotationTokenExpires: "2006-01-02T16:04:05Z", + spec.AnnotationTokenIssuedAt: "2006-01-02T14:04:05Z", + }, + }, + Data: map[string][]byte{ + "secret-token": []byte("new-token"), + }, + }, + }, + // error cases + { + name: "fails when refresh fails", + ref: TokenSecretKeyRef{ + Name: "some-secret", + Namespace: "my-namespace", + Key: "token", + }, + beforeSecret: &v1.Secret{ + Data: map[string][]byte{ + "token": []byte("old-token"), + }, + }, + responseErr: fmt.Errorf("internal server error"), + wantSecret: &v1.Secret{ + Data: map[string][]byte{ + "token": []byte("old-token"), + }, + }, + wantError: "internal server error", + }, + { + name: "fails when secret not found", + ref: TokenSecretKeyRef{ + Name: "some-secret", + Namespace: "my-namespace", + Key: "token", + }, + beforeSecret: nil, + responseOk: &apiv2.TokenServiceRefreshResponse{ + Secret: "new-token", + Token: &apiv2.Token{ + User: "some-user", + Description: "some description", + Expires: mustTimestamp("2006-01-02T16:04:05Z"), + IssuedAt: mustTimestamp("2006-01-02T14:04:05Z"), + }, + }, + wantSecret: &v1.Secret{ + Data: map[string][]byte{ + "token": []byte("old-token"), + }, + }, + wantError: "secrets \"some-secret\" not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + objs := []runtime.Object{} + if tt.beforeSecret != nil { + tt.beforeSecret.Name = tt.ref.Name + tt.beforeSecret.Namespace = tt.ref.Namespace + objs = append(objs, tt.beforeSecret) + } + cs := fake.NewSimpleClientset(objs...) + + ref := New(testLogger(), cs, func(token string) (apiclient.Client, error) { + mocks := apitests.New(t) + return mocks.Client(&apitests.ClientMockFns{ + Apiv2Mocks: &apitests.Apiv2MockFns{ + Token: func(m *mock.Mock) { + m.On("Refresh", mock.IsType(context.Background()), &apiv2.TokenServiceRefreshRequest{}). + Return(tt.responseOk, tt.responseErr) + }, + }, + }), nil + }) + + err := ref.RefreshSecret(context.Background(), tt.ref) + + if (tt.wantError == "") != (err == nil) { + t.Errorf("want error %q, got %q", tt.wantError, err) + } else if err != nil && err.Error() != tt.wantError { + t.Errorf("want error %q, got %q", tt.wantError, err) + } + + if tt.wantError != "" { + return + } + + gotSecret, err := cs.CoreV1().Secrets(tt.ref.Namespace).Get(context.Background(), tt.ref.Name, metav1.GetOptions{}) + if err != nil { + t.Fatalf("failed to get updated secret: %v", err) + } + + if tt.wantSecret != nil { + for k, wantv := range tt.wantSecret.Annotations { + gotv := gotSecret.Annotations[k] + if gotv != wantv { + t.Errorf("want annotation %q=%q, got value %q", k, wantv, gotv) + } + } + + for k, wantv := range tt.wantSecret.Data { + gotv := gotSecret.Data[k] + if string(gotv) != string(wantv) { + t.Errorf("want data %q=%q, got %q", k, wantv, gotv) + } + } + } + }) + } +} From 66ed7b76efe8b246d77f5ea462eeab6e2795e21d Mon Sep 17 00:00:00 2001 From: Valentin Knabel Date: Fri, 10 Jul 2026 14:32:46 +0200 Subject: [PATCH 03/12] fix: missing token :) --- cmd/token-refresher/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/token-refresher/main.go b/cmd/token-refresher/main.go index 7153d9d..3631f79 100644 --- a/cmd/token-refresher/main.go +++ b/cmd/token-refresher/main.go @@ -47,7 +47,7 @@ func main() { dial := &apiclient.DialConfig{ BaseURL: cfg.MetalAPIServerURL, Log: log, - Token: "", // handled by refresher + Token: token, } return apiclient.New(dial) From 19d6ebfb20180510462a78671a6d1201399759bb Mon Sep 17 00:00:00 2001 From: Valentin Knabel Date: Fri, 10 Jul 2026 14:39:50 +0200 Subject: [PATCH 04/12] feat: success message --- refresher/refresher.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/refresher/refresher.go b/refresher/refresher.go index e3d1cfc..aecb005 100644 --- a/refresher/refresher.go +++ b/refresher/refresher.go @@ -86,5 +86,7 @@ func (r *Refresher) RefreshSecret(ctx context.Context, ref TokenSecretKeyRef) er return err } + r.log.Info("updated token secret") + return nil } From 20c4f6e64adde4adcabaf574810ff1f7820dd7fb Mon Sep 17 00:00:00 2001 From: Valentin Knabel Date: Mon, 13 Jul 2026 08:44:25 +0200 Subject: [PATCH 05/12] chore: docs --- README.md | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7e09100..89a8de6 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,81 @@ # metal-token-refresher -Refreshes metal-apiserver tokens + +Refreshes metal-apiserver-tokens stored in Kubernetes secrets as a CronJob. + +## Configuration + +To configure the metal-token-refresher to refresh a secret, set the following environment variables accordingly. + +- `METAL_APISERVER_URL` +- `TOKEN_SECRET_NAMESPACE` +- `TOKEN_SECRET_NAME` +- `TOKEN_SECRET_KEY` + +```yaml +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + name: metal-token-refresher +spec: + schedule: "@hourly" + jobTemplate: + metadata: + name: metal-token-refresher + spec: + template: + spec: + serviceAccountName: metal-token-refresher + restartPolicy: OnFailure + containers: + - name: token-refresher + image: ghcr.io/metal-stack/metal-token-refresher + env: + - name: METAL_APISERVER_URL + value: http://metal-apiserver:8080 + - name: TOKEN_SECRET_NAMESPACE + value: "metal-control-plane" + - name: TOKEN_SECRET_NAME + value: "token-secret" + - name: TOKEN_SECRET_KEY + value: "token" + +``` + +Also make sure to configure the service account accordingly. + +```yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: metal-token-renewal + namespace: metal-stack +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: metal-console-token + namespace: metal-stack +rules: +- apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "update", "patch"] +- apiGroups: [""] + resources: ["secrets"] + resourceNames: ["token-secret"] + verbs: ["get", "update", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: metal-token-renewal + namespace: metal-stack +subjects: +- kind: ServiceAccount + name: metal-token-renewal + namespace: metal-stack +roleRef: + kind: Role + name: metal-token-renewal + apiGroup: rbac.authorization.k8s.io +``` From 0fce8f743131486a38c15c971a276421e52ad5fc Mon Sep 17 00:00:00 2001 From: Valentin Knabel Date: Mon, 13 Jul 2026 09:15:34 +0200 Subject: [PATCH 06/12] docs: secret example --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 89a8de6..1000ccf 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,13 @@ spec: value: "token-secret" - name: TOKEN_SECRET_KEY value: "token" - +--- +apiVersion: v1 +kind: Secret +metadata: + name: token-secret +data: + token: "..." ``` Also make sure to configure the service account accordingly. From 2352eb30df17c475973ff81e69c0bfa4db0fbf15 Mon Sep 17 00:00:00 2001 From: Valentin Knabel Date: Wed, 15 Jul 2026 10:58:00 +0200 Subject: [PATCH 07/12] chore(deps): dependabot config --- .github/dependabot.yml | 56 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..0d99d41 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,56 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + groups: + security: + applies-to: security-updates + patterns: + - "*" + + actions: + applies-to: version-updates + patterns: + - "*" + + - package-ecosystem: gomod + directory: "/" + schedule: + interval: weekly + groups: + security: + applies-to: security-updates + patterns: + - "*" + + metal-stack: + applies-to: version-updates + patterns: + - "github.com/metal-stack/*" + - "google.golang.org/protobuf" + - "connectrpc.com/*" + + k8s: + applies-to: version-updates + patterns: + - "k8s.io/*" + + other: + applies-to: version-updates + update-types: + - minor + - patch + patterns: + - "*" + exclude-patterns: + - "github.com/metal-stack/*" + - "google.golang.org/protobuf" + - "connectrpc.com/*" + - "k8s.io/*" From 8a83176cc30bc426ce4371200cf5b13f5225272f Mon Sep 17 00:00:00 2001 From: Valentin Knabel Date: Wed, 15 Jul 2026 11:57:24 +0200 Subject: [PATCH 08/12] chore(deps): k8s and other in same group --- .github/dependabot.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0d99d41..ad561b5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -37,11 +37,6 @@ updates: - "google.golang.org/protobuf" - "connectrpc.com/*" - k8s: - applies-to: version-updates - patterns: - - "k8s.io/*" - other: applies-to: version-updates update-types: @@ -53,4 +48,3 @@ updates: - "github.com/metal-stack/*" - "google.golang.org/protobuf" - "connectrpc.com/*" - - "k8s.io/*" From ce10858f801a18c5af8e402ca31a336984ff53da Mon Sep 17 00:00:00 2001 From: Valentin Knabel Date: Wed, 15 Jul 2026 11:57:40 +0200 Subject: [PATCH 09/12] chore(ci): drop defaults --- .github/workflows/metal-stack-component.yaml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/.github/workflows/metal-stack-component.yaml b/.github/workflows/metal-stack-component.yaml index 10b1037..d3e2c6d 100644 --- a/.github/workflows/metal-stack-component.yaml +++ b/.github/workflows/metal-stack-component.yaml @@ -23,14 +23,5 @@ jobs: uses: metal-stack/actions-common/.github/workflows/go-build.yaml@v1 secrets: inherit with: - lint: true - test: true - build: true test-command: make test build-command: make token-refresher - registry: ghcr.io - registry-username: ${{ github.actor }} - image-name: ${{ github.repository }} - context: . - dockerfile: Dockerfile - artifact-files: "" From 934b1f4ebb21caf009efd999092585189cadfeeb Mon Sep 17 00:00:00 2001 From: Valentin Knabel Date: Wed, 15 Jul 2026 12:04:25 +0200 Subject: [PATCH 10/12] refactor: prefer os.Exit over panics --- cmd/token-refresher/main.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cmd/token-refresher/main.go b/cmd/token-refresher/main.go index 3631f79..4f59763 100644 --- a/cmd/token-refresher/main.go +++ b/cmd/token-refresher/main.go @@ -27,18 +27,18 @@ func main() { cfg, err := spec.LoadConfig() if err != nil { log.Error("configuration error", "error", err) - panic(err) + os.Exit(1) } restCfg, err := rest.InClusterConfig() if err != nil { log.Error("failed to fetch in cluster rest config", "error", err) - panic(err) + os.Exit(1) } cs, err := kubernetes.NewForConfig(restCfg) if err != nil { log.Error("failed to create in cluster client", "error", err) - panic(err) + os.Exit(1) } log.Info("refreshing metal-apiserver token", "metal-apiserver-url", cfg.MetalAPIServerURL) @@ -59,6 +59,7 @@ func main() { Key: cfg.SecretKey, }) if err != nil { - panic(err) + // error will be logged by refresh.RefreshSecret + os.Exit(1) } } From 9bd3bc957e5b81f6304362ead3fa1ba888ed103b Mon Sep 17 00:00:00 2001 From: Valentin Knabel Date: Wed, 15 Jul 2026 13:00:31 +0200 Subject: [PATCH 11/12] fix: build date Co-authored-by: Gerrit --- Makefile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 8dcdad9..41e0ec3 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ SHA := $(shell git rev-parse --short=8 HEAD) GITVERSION := $(shell git describe --long --all) -BUILDDATE := $(shell date -Iseconds) +# gnu date format iso-8601 is parsable with Go RFC3339 +BUILDDATE := $(shell date --iso-8601=seconds) VERSION := $(or ${VERSION},$(shell git describe --tags --exact-match 2> /dev/null || git symbolic-ref -q --short HEAD || git rev-parse --short HEAD)) CGO_ENABLED := 1 From 3066782754273fe7b7e91a765ff05b6c1806842f Mon Sep 17 00:00:00 2001 From: Valentin Knabel Date: Wed, 15 Jul 2026 13:12:02 +0200 Subject: [PATCH 12/12] fix: nil data exception and test --- refresher/refresher.go | 5 +++++ refresher/refresher_test.go | 26 +++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/refresher/refresher.go b/refresher/refresher.go index aecb005..20d6a64 100644 --- a/refresher/refresher.go +++ b/refresher/refresher.go @@ -46,6 +46,11 @@ func (r *Refresher) RefreshSecret(ctx context.Context, ref TokenSecretKeyRef) er return err } + if tokSec.Data == nil { + r.log.Error("missing token in secret", "key", ref.Key) + return fmt.Errorf("key %q not found in secret %s/%s", ref.Key, ref.Namespace, ref.Name) + } + tok, ok := tokSec.Data[ref.Key] if !ok { r.log.Error("missing token in secret", "key", ref.Key) diff --git a/refresher/refresher_test.go b/refresher/refresher_test.go index 86d49f5..6d47c59 100644 --- a/refresher/refresher_test.go +++ b/refresher/refresher_test.go @@ -197,6 +197,30 @@ func TestRefreshSecret(t *testing.T) { }, }, // error cases + { + name: "fails when secret is empty", + ref: TokenSecretKeyRef{ + Name: "some-secret", + Namespace: "my-namespace", + Key: "token", + }, + beforeSecret: &v1.Secret{ + Data: nil, + }, + responseOk: &apiv2.TokenServiceRefreshResponse{ + Secret: "new-token", + Token: &apiv2.Token{ + User: "some-user", + Description: "some description", + Expires: mustTimestamp("2006-01-02T16:04:05Z"), + IssuedAt: mustTimestamp("2006-01-02T14:04:05Z"), + }, + }, + wantSecret: &v1.Secret{ + Data: nil, + }, + wantError: `key "token" not found in secret my-namespace/some-secret`, + }, { name: "fails when refresh fails", ref: TokenSecretKeyRef{ @@ -239,7 +263,7 @@ func TestRefreshSecret(t *testing.T) { "token": []byte("old-token"), }, }, - wantError: "secrets \"some-secret\" not found", + wantError: `secrets "some-secret" not found`, }, }