From cc5af3765665347e3eeed72809055fe77c05e794 Mon Sep 17 00:00:00 2001
From: duanhongyi <duanhongyi@doopai.com>
Date: Tue, 12 May 2026 10:35:37 +0800
Subject: [PATCH 1/2] feat(oauth): use oauth to unify service-to-service
 authentication.

---
 .gitignore                                    |   1 +
 boot.go                                       |  18 +
 charts/builder/templates/_helpers.tpl         |  12 +
 .../templates/builder-cronjob-daily.yaml      |  32 ++
 charts/builder/values.yaml                    |   4 +
 go.mod                                        |  15 +-
 go.sum                                        |  42 +-
 pkg/controller/token/token.go                 | 409 ++++++++++++++++++
 pkg/controller/token/token_test.go            | 381 ++++++++++++++++
 pkg/controller/utils.go                       | 131 ++++--
 pkg/controller/utils_test.go                  | 229 ++++++++--
 pkg/gitreceive/build.go                       |   2 +-
 pkg/healthsrv/server.go                       |   3 +-
 pkg/sshd/server.go                            |   3 +-
 14 files changed, 1174 insertions(+), 108 deletions(-)
 create mode 100644 charts/builder/templates/builder-cronjob-daily.yaml
 create mode 100644 pkg/controller/token/token.go
 create mode 100644 pkg/controller/token/token_test.go

diff --git a/.gitignore b/.gitignore
index aeac0f9..43b6ff3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ coverage.txt
 testdata/hooks/pre-receive
 .idea/
 .vscode/
+.sisyphus/
\ No newline at end of file
diff --git a/boot.go b/boot.go
index b890dd0..42bb25e 100644
--- a/boot.go
+++ b/boot.go
@@ -14,6 +14,7 @@ import (
 	"github.com/drycc/builder/pkg"
 	"github.com/drycc/builder/pkg/cleaner"
 	"github.com/drycc/builder/pkg/conf"
+	"github.com/drycc/builder/pkg/controller/token"
 	"github.com/drycc/builder/pkg/gitreceive"
 	"github.com/drycc/builder/pkg/healthsrv"
 	"github.com/drycc/builder/pkg/k8s"
@@ -129,6 +130,23 @@ func main() {
 				return nil
 			},
 		},
+		{
+			Name:  "refresh-token",
+			Usage: "Refresh the OAuth m2m access token in Valkey (CronJob entry point)",
+			Flags: []cli.Flag{
+				&cli.BoolFlag{
+					Name:  "force",
+					Usage: "Refresh regardless of current token lifetime",
+				},
+			},
+			Action: func(ctx context.Context, c *cli.Command) error {
+				if err := token.Refresh(ctx, c.Bool("force")); err != nil {
+					return fmt.Errorf("token refresh failed: %w", err)
+				}
+				log.Printf("Token refresh completed successfully")
+				return nil
+			},
+		},
 	}
 
 	if err := app.Run(context.Background(), os.Args); err != nil {
diff --git a/charts/builder/templates/_helpers.tpl b/charts/builder/templates/_helpers.tpl
index 91efff7..93300ab 100644
--- a/charts/builder/templates/_helpers.tpl
+++ b/charts/builder/templates/_helpers.tpl
@@ -6,6 +6,18 @@ env:
   value: "2223"
 - name: "TTL_SECONDS_AFTER_FINISHED"
   value: "{{ .Values.global.ttlSecondsAfterFinished }}"
+{{- if (.Values.valkeyUrl) }}
+- name: DRYCC_VALKEY_URL
+  value: "{{ .Values.valkeyUrl }}"
+{{- else }}
+- name: DRYCC_VALKEY_PASSWORD
+  valueFrom:
+    secretKeyRef:
+      name: valkey-creds
+      key: password
+- name: DRYCC_VALKEY_URL
+  value: "redis://:$(DRYCC_VALKEY_PASSWORD)@drycc-valkey:16379/3"
+{{- end }}
 # Set GIT_LOCK_TIMEOUT to number of minutes you want to wait to git push again to the same repository
 - name: "GIT_LOCK_TIMEOUT"
   value: "30"
diff --git a/charts/builder/templates/builder-cronjob-daily.yaml b/charts/builder/templates/builder-cronjob-daily.yaml
new file mode 100644
index 0000000..694c3c0
--- /dev/null
+++ b/charts/builder/templates/builder-cronjob-daily.yaml
@@ -0,0 +1,32 @@
+apiVersion: batch/v1
+kind: CronJob
+metadata:
+  name: drycc-builder-token-refresher
+  labels:
+    heritage: drycc
+    app: drycc-builder
+    component: token-refresher
+spec:
+  schedule: "0 2 * * *"
+  concurrencyPolicy: Forbid
+  successfulJobsHistoryLimit: 3
+  failedJobsHistoryLimit: 3
+  jobTemplate:
+    spec:
+      backoffLimit: 3
+      template:
+        metadata:
+          labels: {{- include "common.labels.standard" . | nindent 12 }}
+            app: drycc-builder
+            component: token-refresher
+        spec:
+          affinity:
+            nodeAffinity: {{- include "common.affinities.nodes" (dict "type" .Values.nodeAffinityPreset.type "key" .Values.nodeAffinityPreset.key "values" .Values.nodeAffinityPreset.values ) | nindent 14 }}
+          serviceAccount: drycc-builder
+          restartPolicy: OnFailure
+          containers:
+          - name: token-refresher
+            image: {{ .Values.imageRegistry }}/{{ .Values.imageOrg }}/builder:{{ .Values.imageTag }}
+            imagePullPolicy: {{ .Values.imagePullPolicy }}
+            args: ["refresh-token"]
+            {{- include "builder.envs" . | indent 12 }}
diff --git a/charts/builder/values.yaml b/charts/builder/values.yaml
index f62ea33..f995123 100644
--- a/charts/builder/values.yaml
+++ b/charts/builder/values.yaml
@@ -77,3 +77,7 @@ registry:
   enabled: true
   proxy:
     port: 5555
+
+# Override DRYCC_VALKEY_URL when running against an external Valkey/Redis.
+# When empty, the chart wires the in-cluster drycc-valkey service.
+valkeyUrl: ""
diff --git a/go.mod b/go.mod
index f79bb12..1a37ef3 100644
--- a/go.mod
+++ b/go.mod
@@ -3,18 +3,20 @@ module github.com/drycc/builder
 go 1.26
 
 require (
+	github.com/alicebob/miniredis/v2 v2.37.0
 	github.com/aws/aws-sdk-go-v2 v1.36.3
 	github.com/aws/aws-sdk-go-v2/config v1.29.14
 	github.com/aws/aws-sdk-go-v2/credentials v1.17.67
 	github.com/aws/aws-sdk-go-v2/service/ecr v1.44.1
 	github.com/distribution/distribution/v3 v3.1.0
-	github.com/drycc/controller-sdk-go v0.0.0-20260416093543-28d3a22ab999
+	github.com/drycc/controller-sdk-go v0.0.0-20260511051139-2b7986fe96fd
 	github.com/drycc/pkg v0.0.0-20250917064731-345368da3dbf
 	github.com/google/uuid v1.6.0
 	github.com/kelseyhightower/envconfig v1.4.0
 	github.com/stretchr/testify v1.11.1
 	github.com/urfave/cli/v3 v3.3.3
-	golang.org/x/crypto v0.50.0
+	github.com/valkey-io/valkey-go v1.0.74
+	golang.org/x/crypto v0.51.0
 	gopkg.in/yaml.v3 v3.0.1
 	k8s.io/api v0.35.4
 	k8s.io/apimachinery v0.35.4
@@ -64,6 +66,7 @@ require (
 	github.com/prometheus/procfs v0.20.1 // indirect
 	github.com/sirupsen/logrus v1.9.4 // indirect
 	github.com/x448/float16 v0.8.4 // indirect
+	github.com/yuin/gopher-lua v1.1.1 // indirect
 	go.opentelemetry.io/auto/sdk v1.2.1 // indirect
 	go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 // indirect
 	go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 // indirect
@@ -88,11 +91,11 @@ require (
 	go.opentelemetry.io/proto/otlp v1.10.0 // indirect
 	go.yaml.in/yaml/v2 v2.4.3 // indirect
 	go.yaml.in/yaml/v3 v3.0.4 // indirect
-	golang.org/x/net v0.52.0 // indirect
+	golang.org/x/net v0.54.0 // indirect
 	golang.org/x/oauth2 v0.35.0 // indirect
-	golang.org/x/sys v0.43.0 // indirect
-	golang.org/x/term v0.42.0 // indirect
-	golang.org/x/text v0.36.0 // indirect
+	golang.org/x/sys v0.44.0 // indirect
+	golang.org/x/term v0.43.0 // indirect
+	golang.org/x/text v0.37.0 // indirect
 	golang.org/x/time v0.14.0 // indirect
 	google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
diff --git a/go.sum b/go.sum
index 955bad4..8692f2d 100644
--- a/go.sum
+++ b/go.sum
@@ -2,6 +2,8 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1
 github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
 github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
 github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
+github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
+github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
 github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
 github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
 github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
@@ -48,8 +50,8 @@ github.com/distribution/distribution/v3 v3.1.0 h1:u1v788HreKTLGdNY6s7px8Exgrs9mZ
 github.com/distribution/distribution/v3 v3.1.0/go.mod h1:73BuF5/ziMHNVt7nnL1roYpH4Eg/FgUlKZm3WryIx/o=
 github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8=
 github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw=
-github.com/drycc/controller-sdk-go v0.0.0-20260416093543-28d3a22ab999 h1:yHGZInF3xoLRHDgIPQFnfXF8EGaPZZXaRXf0I4pxnWI=
-github.com/drycc/controller-sdk-go v0.0.0-20260416093543-28d3a22ab999/go.mod h1:eHcmYwg81ASlP55/U587xnBZnZoeZnPHXGeQ8nYWnsg=
+github.com/drycc/controller-sdk-go v0.0.0-20260511051139-2b7986fe96fd h1:zeDC7WbB3yGjuviC0u4eHaiylt7ixVbfL8Ope+FwEtM=
+github.com/drycc/controller-sdk-go v0.0.0-20260511051139-2b7986fe96fd/go.mod h1:jV1AUDHtY8aPMF95evHQGXZOX6tUXaf7wgqzUEnD5SM=
 github.com/drycc/pkg v0.0.0-20250917064731-345368da3dbf h1:CYy3NoPhfFhkGAbEppTOQfY/HC2s0FJDcBgbtRKeweg=
 github.com/drycc/pkg v0.0.0-20250917064731-345368da3dbf/go.mod h1:BrrNrNskHKm+nJYhXfGuI114w8nupi0AMo8QZHID7CM=
 github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
@@ -132,8 +134,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8m
 github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
 github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
 github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
-github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
-github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
+github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM=
+github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
 github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 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=
@@ -175,8 +177,12 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
 github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I=
 github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
+github.com/valkey-io/valkey-go v1.0.74 h1:NqtBHzjybz+is+c71hsyZP7hoE5lwCHQX026me0Vb08=
+github.com/valkey-io/valkey-go v1.0.74/go.mod h1:VGhZ6fs68Qrn2+OhH+6waZH27bjpgQOiLyUQyXuYK5k=
 github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
 github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
+github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
+github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
 go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
 go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
 go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 h1:dkBzNEAIKADEaFnuESzcXvpd09vxvDZsOjx11gjUqLk=
@@ -231,14 +237,14 @@ 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/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
-golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
-golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
-golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
+golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
+golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
+golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
+golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
 golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
-golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
+golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
+golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
 golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
 golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -249,17 +255,17 @@ golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5h
 golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
-golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
-golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
+golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
+golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
+golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
-golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
+golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
+golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
 golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
 golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
-golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
-golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
+golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
+golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
 gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
 gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
 google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
diff --git a/pkg/controller/token/token.go b/pkg/controller/token/token.go
new file mode 100644
index 0000000..942118c
--- /dev/null
+++ b/pkg/controller/token/token.go
@@ -0,0 +1,409 @@
+// Package token manages OAuth m2m access tokens for the Drycc builder.
+//
+// It is a Go port of grafana/rootfs/usr/share/grafana/oauth2/token.py, the
+// reference implementation used by drycc/grafana. The token is persisted in
+// Valkey so that all builder replicas share a single cached credential, and a
+// Kubernetes CronJob refreshes it well before expiry. The runtime (sshd,
+// healthsrv, gitreceive) only reads from Valkey on the fast path; a
+// distributed lock guards the rare cache-miss / cold-start path so that the
+// passport service is hit at most once.
+package token
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"os"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/google/uuid"
+	"github.com/valkey-io/valkey-go"
+)
+
+// Tunables mirror the grafana token.py constants. Exported so tests can
+// override them without poking package internals.
+const (
+	// TokenKey is the Valkey key under which the JSON-encoded token lives.
+	TokenKey = "builder:oauth2:token"
+	// InitLockKey guards concurrent refreshes during cold start.
+	InitLockKey = "builder:oauth2:init_lock"
+	// InitLockTTL bounds how long the refresh critical section may run.
+	InitLockTTL = 30 * time.Second
+	// RefreshThreshold tells the CronJob to refresh when less than this
+	// much lifetime remains.
+	RefreshThreshold = 7 * 24 * time.Hour
+	// ReadBuffer is the safety margin used by Get(): a token whose expiry
+	// is within this window is treated as already expired.
+	ReadBuffer = 60 * time.Second
+	// DefaultExpiresIn is the fallback when passport omits expires_in.
+	DefaultExpiresIn = 30 * 24 * time.Hour
+	// ValkeyTTLBuffer extends the Valkey TTL past the OAuth expiry so the
+	// CronJob has a chance to refresh before the key disappears.
+	ValkeyTTLBuffer = 7 * 24 * time.Hour
+)
+
+// Lock-loop timings. Vars (not consts) so tests can shorten them; not part
+// of the public API contract.
+var (
+	lockPollInterval    = 200 * time.Millisecond
+	lockBlockingTimeout = 30 * time.Second
+)
+
+// payload is the JSON shape stored in Valkey. Identical to grafana token.py.
+type payload struct {
+	AccessToken string `json:"access_token"`
+	ExpiresAt   int64  `json:"expires_at"` // unix seconds
+}
+
+// tokenResponse models the passport /oauth/token/ response.
+type tokenResponse struct {
+	AccessToken string `json:"access_token"`
+	TokenType   string `json:"token_type"`
+	ExpiresIn   int64  `json:"expires_in"`
+}
+
+// Manager owns the Valkey client and the configuration needed to refresh
+// tokens. It is safe for concurrent use.
+type Manager struct {
+	client         valkey.Client
+	passportURL    string
+	passportKey    string
+	passportSecret string
+	httpClient     *http.Client
+	now            func() time.Time
+}
+
+// Option customises a Manager. Used mostly by tests.
+type Option func(*Manager)
+
+// WithHTTPClient overrides the http.Client used to talk to passport.
+func WithHTTPClient(c *http.Client) Option {
+	return func(m *Manager) { m.httpClient = c }
+}
+
+// WithClock overrides the time source. Tests use this to make TTL maths
+// deterministic.
+func WithClock(now func() time.Time) Option {
+	return func(m *Manager) { m.now = now }
+}
+
+// NewManager builds a Manager from environment variables and an explicit
+// valkey-go client. The caller owns the client and must Close() it.
+//
+// Required env vars: DRYCC_PASSPORT_URL, DRYCC_PASSPORT_KEY,
+// DRYCC_PASSPORT_SECRET. They match the existing builder convention.
+func NewManager(client valkey.Client, opts ...Option) (*Manager, error) {
+	passportURL := os.Getenv("DRYCC_PASSPORT_URL")
+	passportKey := os.Getenv("DRYCC_PASSPORT_KEY")
+	passportSecret := os.Getenv("DRYCC_PASSPORT_SECRET")
+	if passportURL == "" || passportKey == "" || passportSecret == "" {
+		return nil, errors.New("passport credentials not configured")
+	}
+	m := &Manager{
+		client:         client,
+		passportURL:    passportURL,
+		passportKey:    passportKey,
+		passportSecret: passportSecret,
+		httpClient:     http.DefaultClient,
+		now:            time.Now,
+	}
+	for _, opt := range opts {
+		opt(m)
+	}
+	return m, nil
+}
+
+// NewClientFromEnv constructs a valkey-go client from DRYCC_VALKEY_URL. The
+// URL follows the Drycc convention, e.g.
+//
+//	redis://:password@drycc-valkey:16379/2
+//
+// Both redis:// and valkey:// schemes are accepted.
+func NewClientFromEnv() (valkey.Client, error) {
+	raw := os.Getenv("DRYCC_VALKEY_URL")
+	if raw == "" {
+		return nil, errors.New("DRYCC_VALKEY_URL not set")
+	}
+	return NewClientFromURL(raw)
+}
+
+// NewClientFromURL parses a redis-style URL into a valkey-go client.
+// Client-side caching is disabled because the runtime keeps no per-process
+// cache of its own and not every Valkey/Redis flavour ships RESP3 tracking.
+func NewClientFromURL(raw string) (valkey.Client, error) {
+	opt, err := valkey.ParseURL(raw)
+	if err != nil {
+		u, perr := url.Parse(raw)
+		if perr != nil {
+			return nil, fmt.Errorf("invalid DRYCC_VALKEY_URL: %w", err)
+		}
+		opt = valkey.ClientOption{InitAddress: []string{u.Host}}
+		if u.User != nil {
+			if pw, ok := u.User.Password(); ok {
+				opt.Password = pw
+			}
+			opt.Username = u.User.Username()
+		}
+	}
+	opt.DisableCache = true
+	return valkey.NewClient(opt)
+}
+
+// Get returns a currently-valid access token, performing a synchronous
+// refresh through the distributed lock if Valkey has nothing usable. This is
+// the runtime fast path used by sshd/healthsrv/gitreceive. Mirrors
+// grafana token.py::get_token().
+func (m *Manager) Get(ctx context.Context) (string, error) {
+	// 1. Fast path: try to read a valid token directly.
+	if p, err := m.readValid(ctx, ReadBuffer); err == nil && p != nil {
+		return p.AccessToken, nil
+	}
+
+	// 2. Acquire the distributed lock with bounded wait.
+	owner, err := m.acquireLock(ctx)
+	if err != nil {
+		return "", err
+	}
+	defer m.releaseLock(context.Background(), owner)
+
+	// 3. Double-check: another caller may have refreshed while we waited.
+	if p, err := m.readValid(ctx, ReadBuffer); err == nil && p != nil {
+		return p.AccessToken, nil
+	}
+
+	// 4. Cold path: fetch from passport and persist.
+	p, err := m.fetchAndSave(ctx)
+	if err != nil {
+		return "", err
+	}
+	return p.AccessToken, nil
+}
+
+// Refresh is the CronJob entry point. When force is true the token is
+// refreshed unconditionally; otherwise the token is left alone unless less
+// than RefreshThreshold of lifetime remains. Mirrors token.py::async_main().
+func (m *Manager) Refresh(ctx context.Context, force bool) error {
+	if !force {
+		p, err := m.readValid(ctx, 0)
+		if err != nil {
+			return err
+		}
+		if p != nil {
+			remaining := time.Until(time.Unix(p.ExpiresAt, 0))
+			if remaining > RefreshThreshold {
+				return nil
+			}
+		}
+	}
+	_, err := m.fetchAndSave(ctx)
+	return err
+}
+
+// Invalidate deletes the cached token so the next Get() forces a refresh.
+// Used by the 401 self-heal path.
+func (m *Manager) Invalidate(ctx context.Context) error {
+	return m.client.Do(ctx, m.client.B().Del().Key(TokenKey).Build()).Error()
+}
+
+// ---- internals -----------------------------------------------------------
+
+func (m *Manager) readValid(ctx context.Context, buffer time.Duration) (*payload, error) {
+	resp := m.client.Do(ctx, m.client.B().Get().Key(TokenKey).Build())
+	if err := resp.Error(); err != nil {
+		if valkey.IsValkeyNil(err) {
+			return nil, nil
+		}
+		return nil, err
+	}
+	raw, err := resp.ToString()
+	if err != nil {
+		return nil, err
+	}
+	var p payload
+	if err := json.Unmarshal([]byte(raw), &p); err != nil {
+		// Corrupted entry: treat as miss; CronJob/Get will rewrite it.
+		return nil, nil
+	}
+	cutoff := m.now().Add(buffer).Unix()
+	if p.ExpiresAt <= cutoff {
+		return nil, nil
+	}
+	return &p, nil
+}
+
+func (m *Manager) fetchAndSave(ctx context.Context) (*payload, error) {
+	tr, err := m.requestToken(ctx)
+	if err != nil {
+		return nil, err
+	}
+	expiresIn := time.Duration(tr.ExpiresIn) * time.Second
+	if expiresIn <= 0 {
+		expiresIn = DefaultExpiresIn
+	}
+	now := m.now()
+	p := payload{
+		AccessToken: tr.AccessToken,
+		ExpiresAt:   now.Add(expiresIn).Unix(),
+	}
+	raw, err := json.Marshal(p)
+	if err != nil {
+		return nil, err
+	}
+	ttl := expiresIn + ValkeyTTLBuffer
+	err = m.client.Do(ctx,
+		m.client.B().Set().Key(TokenKey).Value(string(raw)).
+			ExSeconds(int64(ttl.Seconds())).Build(),
+	).Error()
+	if err != nil {
+		return nil, err
+	}
+	return &p, nil
+}
+
+func (m *Manager) requestToken(ctx context.Context) (*tokenResponse, error) {
+	endpoint := strings.TrimRight(m.passportURL, "/") + "/oauth/token/"
+	form := url.Values{}
+	form.Set("grant_type", "client_credentials")
+	form.Set("client_id", m.passportKey)
+	form.Set("client_secret", m.passportSecret)
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
+	if err != nil {
+		return nil, fmt.Errorf("create token request: %w", err)
+	}
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+	resp, err := m.httpClient.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("request token: %w", err)
+	}
+	defer resp.Body.Close()
+	body, _ := io.ReadAll(resp.Body)
+	if resp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("passport returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
+	}
+	var tr tokenResponse
+	if err := json.Unmarshal(body, &tr); err != nil {
+		return nil, fmt.Errorf("decode token response: %w", err)
+	}
+	if tr.AccessToken == "" {
+		return nil, errors.New("passport returned empty access_token")
+	}
+	return &tr, nil
+}
+
+// acquireLock takes InitLockKey with SET NX EX, retrying up to
+// lockBlockingTimeout. The returned string is the owner token; pass it to
+// releaseLock so we never delete a lock that someone else now holds.
+func (m *Manager) acquireLock(ctx context.Context) (string, error) {
+	owner := uuid.NewString()
+	deadline := time.Now().Add(lockBlockingTimeout)
+	for {
+		err := m.client.Do(ctx,
+			m.client.B().Set().Key(InitLockKey).Value(owner).
+				Nx().ExSeconds(int64(InitLockTTL.Seconds())).Build(),
+		).Error()
+		if err == nil {
+			return owner, nil
+		}
+		// valkey-go signals "NX rejected" as a Nil reply, not an error
+		// string. Anything else is fatal.
+		if !valkey.IsValkeyNil(err) {
+			return "", fmt.Errorf("acquire init lock: %w", err)
+		}
+		if time.Now().After(deadline) {
+			return "", errors.New("timeout waiting for token refresh lock")
+		}
+		select {
+		case <-ctx.Done():
+			return "", ctx.Err()
+		case <-time.After(lockPollInterval):
+		}
+	}
+}
+
+// releaseLock deletes InitLockKey only if we still own it. Uses a Lua check
+// to avoid a TOCTOU where our lock expired and someone else grabbed it.
+func (m *Manager) releaseLock(ctx context.Context, owner string) {
+	const script = `if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end`
+	_ = m.client.Do(ctx,
+		m.client.B().Eval().Script(script).Numkeys(1).
+			Key(InitLockKey).Arg(owner).Build(),
+	).Error()
+}
+
+// ---- package-level singleton --------------------------------------------
+//
+// The runtime call sites (sshd/healthsrv/gitreceive) want a tiny API:
+// token.Get(ctx) / token.Invalidate(ctx). We lazily construct a Manager from
+// the standard env vars on first use.
+
+var (
+	defaultOnce sync.Once
+	defaultMgr  *Manager
+	defaultErr  error
+)
+
+func getDefault() (*Manager, error) {
+	defaultOnce.Do(func() {
+		client, err := NewClientFromEnv()
+		if err != nil {
+			defaultErr = err
+			return
+		}
+		mgr, err := NewManager(client)
+		if err != nil {
+			client.Close()
+			defaultErr = err
+			return
+		}
+		defaultMgr = mgr
+	})
+	return defaultMgr, defaultErr
+}
+
+// Get is a convenience wrapper around the package-level Manager.
+func Get(ctx context.Context) (string, error) {
+	mgr, err := getDefault()
+	if err != nil {
+		return "", err
+	}
+	return mgr.Get(ctx)
+}
+
+// Refresh is a convenience wrapper around the package-level Manager.
+func Refresh(ctx context.Context, force bool) error {
+	mgr, err := getDefault()
+	if err != nil {
+		return err
+	}
+	return mgr.Refresh(ctx, force)
+}
+
+// Invalidate is a convenience wrapper around the package-level Manager.
+func Invalidate(ctx context.Context) error {
+	mgr, err := getDefault()
+	if err != nil {
+		return err
+	}
+	return mgr.Invalidate(ctx)
+}
+
+// ResetForTest replaces the package-level Manager with one bound to the
+// supplied Valkey client. It re-reads passport env vars so tests can swap
+// them via t.Setenv. Test-only; not part of the public API contract.
+func ResetForTest(t interface{ Helper() }, client valkey.Client) {
+	t.Helper()
+	defaultOnce = sync.Once{}
+	defaultMgr = nil
+	defaultErr = nil
+	mgr, err := NewManager(client)
+	defaultMgr, defaultErr = mgr, err
+	defaultOnce.Do(func() {})
+}
diff --git a/pkg/controller/token/token_test.go b/pkg/controller/token/token_test.go
new file mode 100644
index 0000000..71aa1b7
--- /dev/null
+++ b/pkg/controller/token/token_test.go
@@ -0,0 +1,381 @@
+package token
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"sync"
+	"sync/atomic"
+	"testing"
+	"time"
+
+	"github.com/alicebob/miniredis/v2"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/valkey-io/valkey-go"
+)
+
+func newTestManager(t *testing.T, passportHandler http.HandlerFunc) (*Manager, *miniredis.Miniredis, *httptest.Server) {
+	t.Helper()
+
+	mr := miniredis.RunT(t)
+
+	client, err := valkey.NewClient(valkey.ClientOption{
+		InitAddress:  []string{mr.Addr()},
+		DisableCache: true,
+	})
+	require.NoError(t, err)
+	t.Cleanup(client.Close)
+
+	ts := httptest.NewServer(passportHandler)
+	t.Cleanup(ts.Close)
+
+	t.Setenv("DRYCC_PASSPORT_URL", ts.URL)
+	t.Setenv("DRYCC_PASSPORT_KEY", "test-key")
+	t.Setenv("DRYCC_PASSPORT_SECRET", "test-secret")
+
+	mgr, err := NewManager(client)
+	require.NoError(t, err)
+	return mgr, mr, ts
+}
+
+func passportJSON(t *testing.T, accessToken string, expiresIn int64) http.HandlerFunc {
+	t.Helper()
+	return func(w http.ResponseWriter, _ *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		_ = json.NewEncoder(w).Encode(map[string]any{
+			"access_token": accessToken,
+			"token_type":   "Bearer",
+			"expires_in":   expiresIn,
+			"scope":        "passport:message",
+		})
+	}
+}
+
+func TestGet_FastPathReturnsCachedToken(t *testing.T) {
+	mgr, mr, _ := newTestManager(t, passportJSON(t, "should-not-be-fetched", 2592000))
+
+	// Pre-populate Valkey with a still-valid token.
+	p := payload{AccessToken: "cached-token", ExpiresAt: time.Now().Add(24 * time.Hour).Unix()}
+	raw, _ := json.Marshal(p)
+	require.NoError(t, mr.Set(TokenKey, string(raw)))
+
+	got, err := mgr.Get(context.Background())
+	require.NoError(t, err)
+	assert.Equal(t, "cached-token", got)
+}
+
+func TestGet_ColdStartFetchesFromPassport(t *testing.T) {
+	var calls int32
+	handler := func(w http.ResponseWriter, _ *http.Request) {
+		atomic.AddInt32(&calls, 1)
+		w.Header().Set("Content-Type", "application/json")
+		_, _ = w.Write([]byte(`{"access_token":"fresh","token_type":"Bearer","expires_in":2592000,"scope":"passport:message"}`))
+	}
+	mgr, mr, _ := newTestManager(t, handler)
+
+	got, err := mgr.Get(context.Background())
+	require.NoError(t, err)
+	assert.Equal(t, "fresh", got)
+	assert.Equal(t, int32(1), atomic.LoadInt32(&calls))
+
+	stored, err := mr.Get(TokenKey)
+	require.NoError(t, err)
+	var p payload
+	require.NoError(t, json.Unmarshal([]byte(stored), &p))
+	assert.Equal(t, "fresh", p.AccessToken)
+	assert.InDelta(t, time.Now().Add(30*24*time.Hour).Unix(), p.ExpiresAt, 5)
+
+	ttl := mr.TTL(TokenKey)
+	expected := 30*24*time.Hour + ValkeyTTLBuffer
+	assert.InDelta(t, expected.Seconds(), ttl.Seconds(), 5)
+}
+
+func TestGet_ConcurrentCallsHitPassportOnce(t *testing.T) {
+	var calls int32
+	handler := func(w http.ResponseWriter, _ *http.Request) {
+		atomic.AddInt32(&calls, 1)
+		// Slow handler to widen the race window.
+		time.Sleep(50 * time.Millisecond)
+		w.Header().Set("Content-Type", "application/json")
+		_, _ = w.Write([]byte(`{"access_token":"only-one","token_type":"Bearer","expires_in":2592000}`))
+	}
+	mgr, _, _ := newTestManager(t, handler)
+
+	var wg sync.WaitGroup
+	const n = 20
+	results := make([]string, n)
+	errs := make([]error, n)
+	for i := 0; i < n; i++ {
+		wg.Add(1)
+		go func(i int) {
+			defer wg.Done()
+			results[i], errs[i] = mgr.Get(context.Background())
+		}(i)
+	}
+	wg.Wait()
+
+	for i := 0; i < n; i++ {
+		require.NoErrorf(t, errs[i], "goroutine %d", i)
+		assert.Equal(t, "only-one", results[i])
+	}
+	assert.Equal(t, int32(1), atomic.LoadInt32(&calls), "passport should be called exactly once")
+}
+
+func TestRefresh_SkipsWhenPlentyOfLifetimeRemains(t *testing.T) {
+	var calls int32
+	handler := func(w http.ResponseWriter, _ *http.Request) {
+		atomic.AddInt32(&calls, 1)
+		w.WriteHeader(http.StatusOK)
+		_, _ = w.Write([]byte(`{"access_token":"new","expires_in":2592000}`))
+	}
+	mgr, mr, _ := newTestManager(t, handler)
+
+	// Token with 20 days remaining (> RefreshThreshold of 7d).
+	p := payload{AccessToken: "still-good", ExpiresAt: time.Now().Add(20 * 24 * time.Hour).Unix()}
+	raw, _ := json.Marshal(p)
+	require.NoError(t, mr.Set(TokenKey, string(raw)))
+
+	require.NoError(t, mgr.Refresh(context.Background(), false))
+	assert.Equal(t, int32(0), atomic.LoadInt32(&calls))
+
+	got, _ := mr.Get(TokenKey)
+	assert.Equal(t, string(raw), got, "token must be untouched")
+}
+
+func TestRefresh_RefreshesWhenCloseToExpiry(t *testing.T) {
+	mgr, mr, _ := newTestManager(t, passportJSON(t, "renewed", 2592000))
+
+	// Less than RefreshThreshold remaining.
+	p := payload{AccessToken: "old", ExpiresAt: time.Now().Add(2 * 24 * time.Hour).Unix()}
+	raw, _ := json.Marshal(p)
+	require.NoError(t, mr.Set(TokenKey, string(raw)))
+
+	require.NoError(t, mgr.Refresh(context.Background(), false))
+
+	stored, _ := mr.Get(TokenKey)
+	var got payload
+	require.NoError(t, json.Unmarshal([]byte(stored), &got))
+	assert.Equal(t, "renewed", got.AccessToken)
+}
+
+func TestRefresh_ForceAlwaysRefreshes(t *testing.T) {
+	var calls int32
+	handler := func(w http.ResponseWriter, _ *http.Request) {
+		atomic.AddInt32(&calls, 1)
+		w.WriteHeader(http.StatusOK)
+		_, _ = w.Write([]byte(`{"access_token":"forced","expires_in":2592000}`))
+	}
+	mgr, mr, _ := newTestManager(t, handler)
+
+	p := payload{AccessToken: "still-fresh", ExpiresAt: time.Now().Add(29 * 24 * time.Hour).Unix()}
+	raw, _ := json.Marshal(p)
+	require.NoError(t, mr.Set(TokenKey, string(raw)))
+
+	require.NoError(t, mgr.Refresh(context.Background(), true))
+	assert.Equal(t, int32(1), atomic.LoadInt32(&calls))
+
+	stored, _ := mr.Get(TokenKey)
+	var got payload
+	require.NoError(t, json.Unmarshal([]byte(stored), &got))
+	assert.Equal(t, "forced", got.AccessToken)
+}
+
+func TestRefresh_NoCachedTokenFetchesAnyway(t *testing.T) {
+	var calls int32
+	handler := func(w http.ResponseWriter, _ *http.Request) {
+		atomic.AddInt32(&calls, 1)
+		_, _ = w.Write([]byte(`{"access_token":"bootstrap","expires_in":2592000}`))
+	}
+	mgr, _, _ := newTestManager(t, handler)
+
+	require.NoError(t, mgr.Refresh(context.Background(), false))
+	assert.Equal(t, int32(1), atomic.LoadInt32(&calls))
+}
+
+func TestRefresh_MissingExpiresInUsesDefault(t *testing.T) {
+	handler := func(w http.ResponseWriter, _ *http.Request) {
+		_, _ = w.Write([]byte(`{"access_token":"no-expiry","token_type":"Bearer"}`))
+	}
+	mgr, mr, _ := newTestManager(t, handler)
+
+	require.NoError(t, mgr.Refresh(context.Background(), true))
+
+	stored, _ := mr.Get(TokenKey)
+	var p payload
+	require.NoError(t, json.Unmarshal([]byte(stored), &p))
+	assert.InDelta(t, time.Now().Add(DefaultExpiresIn).Unix(), p.ExpiresAt, 5)
+}
+
+func TestInvalidate_ClearsToken(t *testing.T) {
+	mgr, mr, _ := newTestManager(t, passportJSON(t, "irrelevant", 2592000))
+
+	p := payload{AccessToken: "doomed", ExpiresAt: time.Now().Add(24 * time.Hour).Unix()}
+	raw, _ := json.Marshal(p)
+	require.NoError(t, mr.Set(TokenKey, string(raw)))
+
+	require.NoError(t, mgr.Invalidate(context.Background()))
+	assert.False(t, mr.Exists(TokenKey))
+}
+
+func TestGet_ExpiredTokenTriggersRefresh(t *testing.T) {
+	mgr, mr, _ := newTestManager(t, passportJSON(t, "after-expiry", 2592000))
+
+	// Already expired.
+	p := payload{AccessToken: "stale", ExpiresAt: time.Now().Add(-time.Hour).Unix()}
+	raw, _ := json.Marshal(p)
+	require.NoError(t, mr.Set(TokenKey, string(raw)))
+
+	got, err := mgr.Get(context.Background())
+	require.NoError(t, err)
+	assert.Equal(t, "after-expiry", got)
+}
+
+func TestGet_PassportFailurePropagates(t *testing.T) {
+	handler := func(w http.ResponseWriter, _ *http.Request) {
+		w.WriteHeader(http.StatusUnauthorized)
+		_, _ = w.Write([]byte(`{"error":"invalid_client"}`))
+	}
+	mgr, _, _ := newTestManager(t, handler)
+
+	_, err := mgr.Get(context.Background())
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "HTTP 401")
+}
+
+func TestNewManager_MissingEnvFails(t *testing.T) {
+	t.Setenv("DRYCC_PASSPORT_URL", "")
+	t.Setenv("DRYCC_PASSPORT_KEY", "")
+	t.Setenv("DRYCC_PASSPORT_SECRET", "")
+
+	mr := miniredis.RunT(t)
+	client, err := valkey.NewClient(valkey.ClientOption{
+		InitAddress:  []string{mr.Addr()},
+		DisableCache: true,
+	})
+	require.NoError(t, err)
+	t.Cleanup(client.Close)
+
+	_, err = NewManager(client)
+	require.Error(t, err)
+}
+
+func TestGet_CorruptedJSONIsTreatedAsMiss(t *testing.T) {
+	mgr, mr, _ := newTestManager(t, passportJSON(t, "recovered", 2592000))
+
+	require.NoError(t, mr.Set(TokenKey, "{not valid json"))
+
+	got, err := mgr.Get(context.Background())
+	require.NoError(t, err)
+	assert.Equal(t, "recovered", got, "corrupted entry must trigger refresh, not propagate")
+
+	stored, _ := mr.Get(TokenKey)
+	var p payload
+	require.NoError(t, json.Unmarshal([]byte(stored), &p), "corrupted blob must be overwritten with valid JSON")
+}
+
+func TestRefresh_CorruptedJSONForcesRefresh(t *testing.T) {
+	var calls int32
+	handler := func(w http.ResponseWriter, _ *http.Request) {
+		atomic.AddInt32(&calls, 1)
+		_, _ = w.Write([]byte(`{"access_token":"after-corrupt","expires_in":2592000}`))
+	}
+	mgr, mr, _ := newTestManager(t, handler)
+
+	require.NoError(t, mr.Set(TokenKey, "garbage"))
+
+	require.NoError(t, mgr.Refresh(context.Background(), false))
+	assert.Equal(t, int32(1), atomic.LoadInt32(&calls))
+}
+
+func TestAcquireLock_TimesOutWhenHeldByAnother(t *testing.T) {
+	prevTimeout := lockBlockingTimeout
+	prevPoll := lockPollInterval
+	lockBlockingTimeout = 200 * time.Millisecond
+	lockPollInterval = 30 * time.Millisecond
+	t.Cleanup(func() {
+		lockBlockingTimeout = prevTimeout
+		lockPollInterval = prevPoll
+	})
+
+	mgr, mr, _ := newTestManager(t, passportJSON(t, "never-fetched", 2592000))
+
+	require.NoError(t, mr.Set(InitLockKey, "someone-else"))
+	mr.SetTTL(InitLockKey, InitLockTTL)
+
+	start := time.Now()
+	_, err := mgr.Get(context.Background())
+	elapsed := time.Since(start)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "timeout waiting for token refresh lock")
+	assert.GreaterOrEqual(t, elapsed, 200*time.Millisecond)
+	assert.Less(t, elapsed, 2*time.Second, "should not wait the full production timeout")
+}
+
+func TestReleaseLock_DoesNotDeleteWhenOwnerMismatches(t *testing.T) {
+	mgr, mr, _ := newTestManager(t, passportJSON(t, "x", 2592000))
+
+	require.NoError(t, mr.Set(InitLockKey, "another-owner"))
+	mr.SetTTL(InitLockKey, InitLockTTL)
+
+	mgr.releaseLock(context.Background(), "our-owner")
+
+	val, err := mr.Get(InitLockKey)
+	require.NoError(t, err)
+	assert.Equal(t, "another-owner", val, "Lua owner-check must protect foreign locks")
+}
+
+func TestReleaseLock_DeletesWhenOwnerMatches(t *testing.T) {
+	mgr, mr, _ := newTestManager(t, passportJSON(t, "x", 2592000))
+
+	require.NoError(t, mr.Set(InitLockKey, "us"))
+	mr.SetTTL(InitLockKey, InitLockTTL)
+
+	mgr.releaseLock(context.Background(), "us")
+
+	assert.False(t, mr.Exists(InitLockKey))
+}
+
+func TestGet_ContextCancellationPropagates(t *testing.T) {
+	prevTimeout := lockBlockingTimeout
+	prevPoll := lockPollInterval
+	lockBlockingTimeout = 5 * time.Second
+	lockPollInterval = 50 * time.Millisecond
+	t.Cleanup(func() {
+		lockBlockingTimeout = prevTimeout
+		lockPollInterval = prevPoll
+	})
+
+	mgr, mr, _ := newTestManager(t, passportJSON(t, "x", 2592000))
+
+	require.NoError(t, mr.Set(InitLockKey, "blocker"))
+	mr.SetTTL(InitLockKey, InitLockTTL)
+
+	ctx, cancel := context.WithCancel(context.Background())
+	go func() {
+		time.Sleep(100 * time.Millisecond)
+		cancel()
+	}()
+
+	start := time.Now()
+	_, err := mgr.Get(ctx)
+	elapsed := time.Since(start)
+	require.Error(t, err)
+	assert.ErrorIs(t, err, context.Canceled)
+	assert.Less(t, elapsed, 1*time.Second, "ctx cancel must abort the lock loop promptly")
+}
+
+func TestNewClientFromURL(t *testing.T) {
+	mr := miniredis.RunT(t)
+	c, err := NewClientFromURL("redis://" + mr.Addr())
+	require.NoError(t, err)
+	require.NoError(t, c.Do(context.Background(), c.B().Ping().Build()).Error())
+	c.Close()
+}
+
+func TestNewClientFromEnv_MissingURL(t *testing.T) {
+	t.Setenv("DRYCC_VALKEY_URL", "")
+	_, err := NewClientFromEnv()
+	require.Error(t, err)
+}
diff --git a/pkg/controller/utils.go b/pkg/controller/utils.go
index 2ac0f0f..558b797 100644
--- a/pkg/controller/utils.go
+++ b/pkg/controller/utils.go
@@ -2,77 +2,116 @@
 package controller
 
 import (
-	"encoding/json"
-	"fmt"
+	"bytes"
+	"context"
+	"crypto/tls"
+	"errors"
+	"io"
 	"net/http"
-	"net/url"
-	"os"
-	"strings"
+	"sync/atomic"
 
+	"github.com/drycc/builder/pkg/controller/token"
 	drycc "github.com/drycc/controller-sdk-go"
 	"github.com/drycc/pkg/log"
 )
 
-type tokenResponse struct {
-	AccessToken string `json:"access_token"`
-	TokenType   string `json:"token_type"`
-}
-
-// New creates a new SDK client configured as the builder.
-func New(controllerURL string) (*drycc.Client, error) {
+// New creates a new SDK client configured as the builder. The OAuth m2m
+// access token is sourced from Valkey via the token package; the HTTP
+// transport transparently re-fetches on 401 (self-heal).
+func New(ctx context.Context, controllerURL string) (*drycc.Client, error) {
 	client, err := drycc.New(true, controllerURL, "")
 	if err != nil {
 		return client, err
 	}
 	client.UserAgent = "drycc-builder"
 
-	passportURL := os.Getenv("DRYCC_PASSPORT_URL")
-	passportKey := os.Getenv("DRYCC_PASSPORT_KEY")
-	passportSecret := os.Getenv("DRYCC_PASSPORT_SECRET")
-	if passportURL == "" || passportKey == "" || passportSecret == "" {
-		return client, fmt.Errorf("passport credentials not configured")
-	}
-
-	data := url.Values{}
-	data.Set("grant_type", "client_credentials")
-	data.Set("client_id", passportKey)
-	data.Set("client_secret", passportSecret)
-
-	req, err := http.NewRequest("POST", fmt.Sprintf("%s/oauth/token/", passportURL), strings.NewReader(data.Encode()))
+	tok, err := token.Get(ctx)
 	if err != nil {
-		return client, fmt.Errorf("failed to create token request: %v", err)
-	}
-	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
-
-	resp, err := http.DefaultClient.Do(req)
-	if err != nil {
-		return client, fmt.Errorf("failed to request token: %v", err)
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode != http.StatusOK {
-		return client, fmt.Errorf("failed to get token: HTTP %d", resp.StatusCode)
+		return client, err
 	}
 
-	var tr tokenResponse
-	if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
-		return client, fmt.Errorf("failed to decode token response: %v", err)
+	base := &http.Transport{
+		TLSClientConfig:   &tls.Config{InsecureSkipVerify: !client.VerifySSL},
+		DisableKeepAlives: true,
+		Proxy:             http.ProxyFromEnvironment,
 	}
+	at := &authTransport{base: base}
+	at.token.Store(tok)
+	client.HTTPClient = &http.Client{Transport: at}
 
-	client.Token = tr.TokenType + " " + tr.AccessToken
-
+	// Clear Token so the SDK does not append its own Authorization header;
+	// authTransport is the sole owner of Authorization.
+	client.Token = ""
 	return client, nil
 }
 
 // CheckAPICompat checks for API compatibility errors and warns about them.
 func CheckAPICompat(c *drycc.Client, err error) error {
-	if err == drycc.ErrAPIMismatch {
+	if errors.Is(err, drycc.ErrAPIMismatch) {
 		log.Info("WARNING: SDK and Controller API versions do not match. SDK: %s Controller: %s",
 			drycc.APIVersion, c.ControllerAPIVersion)
-
-		// API mismatch isn't fatal, so after warning continue on.
 		return nil
 	}
-
 	return err
 }
+
+// authTransport injects the cached bearer token on every request and, on
+// 401, invalidates the cache and replays the request exactly once with a
+// freshly fetched token. This is the runtime self-heal path: if the CronJob
+// is late or the cached token was rotated out-of-band, in-flight requests
+// recover without the caller noticing.
+type authTransport struct {
+	base  http.RoundTripper
+	token atomic.Value
+}
+
+func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+	body, err := copyBody(req)
+	if err != nil {
+		return nil, err
+	}
+	t.setAuth(req)
+	resp, err := t.base.RoundTrip(req)
+	if err != nil || resp.StatusCode != http.StatusUnauthorized {
+		return resp, err
+	}
+	_ = resp.Body.Close()
+	if err := token.Invalidate(req.Context()); err != nil {
+		log.Info("token: failed to invalidate after 401: %s", err)
+	}
+	fresh, err := token.Get(req.Context())
+	if err != nil {
+		return nil, err
+	}
+	t.token.Store(fresh)
+	retry := req.Clone(req.Context())
+	if body != nil {
+		retry.Body = io.NopCloser(bytes.NewReader(body))
+		retry.GetBody = func() (io.ReadCloser, error) {
+			return io.NopCloser(bytes.NewReader(body)), nil
+		}
+	}
+	t.setAuth(retry)
+	return t.base.RoundTrip(retry)
+}
+
+func (t *authTransport) setAuth(req *http.Request) {
+	if tok, ok := t.token.Load().(string); ok && tok != "" {
+		req.Header.Set("Authorization", "Bearer "+tok)
+	}
+}
+
+// copyBody buffers a request body so the same payload can be replayed after
+// a 401. Returns nil for bodyless requests.
+func copyBody(req *http.Request) ([]byte, error) {
+	if req.Body == nil || req.Body == http.NoBody {
+		return nil, nil
+	}
+	buf, err := io.ReadAll(req.Body)
+	if err != nil {
+		return nil, err
+	}
+	_ = req.Body.Close()
+	req.Body = io.NopCloser(bytes.NewReader(buf))
+	return buf, nil
+}
diff --git a/pkg/controller/utils_test.go b/pkg/controller/utils_test.go
index 4404f9d..cf93276 100644
--- a/pkg/controller/utils_test.go
+++ b/pkg/controller/utils_test.go
@@ -1,60 +1,219 @@
 package controller
 
 import (
+	"context"
+	"encoding/json"
 	"errors"
+	"io"
 	"net/http"
 	"net/http/httptest"
-	"os"
+	"sync/atomic"
 	"testing"
 
+	"github.com/alicebob/miniredis/v2"
+	tokenpkg "github.com/drycc/builder/pkg/controller/token"
 	drycc "github.com/drycc/controller-sdk-go"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/valkey-io/valkey-go"
 )
 
-func TestNew(t *testing.T) {
-	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+// installTokenForTest replaces the package-level token Manager with one
+// backed by miniredis + a stub passport server, isolating tests from each
+// other and from any real DRYCC_VALKEY_URL in the environment.
+func installTokenForTest(t *testing.T, passport http.HandlerFunc) (*miniredis.Miniredis, *httptest.Server) {
+	t.Helper()
+	mr := miniredis.RunT(t)
+	client, err := valkey.NewClient(valkey.ClientOption{
+		InitAddress:  []string{mr.Addr()},
+		DisableCache: true,
+	})
+	require.NoError(t, err)
+	t.Cleanup(client.Close)
+
+	ts := httptest.NewServer(passport)
+	t.Cleanup(ts.Close)
+	t.Setenv("DRYCC_PASSPORT_URL", ts.URL)
+	t.Setenv("DRYCC_PASSPORT_KEY", "k")
+	t.Setenv("DRYCC_PASSPORT_SECRET", "s")
+	t.Setenv("DRYCC_VALKEY_URL", "redis://"+mr.Addr())
+
+	tokenpkg.ResetForTest(t, client)
+	return mr, ts
+}
+
+func TestNew_PullsTokenFromValkeyAndSetsBearer(t *testing.T) {
+	installTokenForTest(t, func(w http.ResponseWriter, _ *http.Request) {
 		w.Header().Set("Content-Type", "application/json")
-		w.Write([]byte(`{"access_token": "testing_token", "token_type": "Bearer"}`))
-	}))
-	defer ts.Close()
-
-	os.Setenv("DRYCC_PASSPORT_URL", ts.URL)
-	os.Setenv("DRYCC_PASSPORT_KEY", "testing_key")
-	os.Setenv("DRYCC_PASSPORT_SECRET", "testing_secret")
-	defer os.Unsetenv("DRYCC_PASSPORT_URL")
-	defer os.Unsetenv("DRYCC_PASSPORT_KEY")
-	defer os.Unsetenv("DRYCC_PASSPORT_SECRET")
-
-	url := "http://127.0.0.1:80"
-	cli, err := New(url)
-	assert.Equal(t, err, nil)
-	assert.Equal(t, cli.ControllerURL.String(), url, "data")
-	assert.Equal(t, cli.Token, "Bearer testing_token", "data")
-	assert.Equal(t, cli.UserAgent, "drycc-builder", "user-agent")
-
-	url = "http://127.0.0.1:invalid-port-number"
-	if _, err = New(url); err == nil {
-		t.Errorf("expected error with invalid port number, got nil")
+		_, _ = w.Write([]byte(`{"access_token":"abc","token_type":"Bearer","expires_in":2592000}`))
+	})
+
+	cli, err := New(context.Background(), "http://controller.example/")
+	require.NoError(t, err)
+	assert.Equal(t, "drycc-builder", cli.UserAgent)
+	assert.Empty(t, cli.Token, "Token must be empty; authTransport owns Authorization")
+}
+
+func TestNew_RetriesOnceOn401(t *testing.T) {
+	tokens := []string{"first", "second"}
+	var passportCalls int32
+	passport := func(w http.ResponseWriter, _ *http.Request) {
+		idx := int(atomic.AddInt32(&passportCalls, 1) - 1)
+		if idx >= len(tokens) {
+			idx = len(tokens) - 1
+		}
+		w.Header().Set("Content-Type", "application/json")
+		_ = json.NewEncoder(w).Encode(map[string]any{
+			"access_token": tokens[idx],
+			"token_type":   "Bearer",
+			"expires_in":   2592000,
+		})
 	}
+	installTokenForTest(t, passport)
+
+	var controllerCalls int32
+	var seenAuth []string
+	controllerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		seenAuth = append(seenAuth, r.Header.Get("Authorization"))
+		if atomic.AddInt32(&controllerCalls, 1) == 1 {
+			w.WriteHeader(http.StatusUnauthorized)
+			return
+		}
+		w.Header().Set("DRYCC_API_VERSION", drycc.APIVersion)
+		w.WriteHeader(http.StatusOK)
+		_, _ = w.Write([]byte(`ok`))
+	}))
+	defer controllerSrv.Close()
+
+	cli, err := New(context.Background(), controllerSrv.URL)
+	require.NoError(t, err)
+
+	resp, err := cli.Request(http.MethodGet, "/v2/ping", nil)
+	require.NoError(t, err)
+	defer resp.Body.Close()
+	assert.Equal(t, http.StatusOK, resp.StatusCode)
+	assert.Equal(t, int32(2), atomic.LoadInt32(&controllerCalls), "expected exactly one retry")
+	require.Len(t, seenAuth, 2)
+	assert.Equal(t, "Bearer first", seenAuth[0])
+	assert.Equal(t, "Bearer second", seenAuth[1])
+	assert.Equal(t, int32(2), atomic.LoadInt32(&passportCalls), "passport hit on cold start + 401 refresh")
 }
 
-func TestNewWithInvalidCredentials(t *testing.T) {
-	os.Unsetenv("DRYCC_PASSPORT_URL")
-	url := "http://127.0.0.1:80"
-	_, err := New(url)
-	assert.True(t, err != nil, "no error received when there should have been")
+func TestNew_PropagatesUnauthorizedAfterRetry(t *testing.T) {
+	installTokenForTest(t, func(w http.ResponseWriter, _ *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		_, _ = w.Write([]byte(`{"access_token":"always-bad","token_type":"Bearer","expires_in":2592000}`))
+	})
+
+	controllerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.WriteHeader(http.StatusUnauthorized)
+	}))
+	defer controllerSrv.Close()
+
+	cli, err := New(context.Background(), controllerSrv.URL)
+	require.NoError(t, err)
+
+	_, err = cli.Request(http.MethodGet, "/v2/ping", nil)
+	require.Error(t, err)
+	assert.ErrorIs(t, err, drycc.ErrUnauthorized)
 }
 
 func TestCheckAPICompat(t *testing.T) {
 	client := &drycc.Client{ControllerAPIVersion: drycc.APIVersion}
-	err := drycc.ErrAPIMismatch
-
-	if apiErr := CheckAPICompat(client, err); apiErr != nil {
+	if apiErr := CheckAPICompat(client, drycc.ErrAPIMismatch); apiErr != nil {
 		t.Errorf("api errors are non-fatal and should return nil, got '%v'", apiErr)
 	}
-
-	err = errors.New("random error")
-	if apiErr := CheckAPICompat(client, err); apiErr == nil {
+	if apiErr := CheckAPICompat(client, errors.New("random error")); apiErr == nil {
 		t.Error("expected error to be returned, got nil")
 	}
 }
+
+func TestNew_PostBodyIsReplayedOn401(t *testing.T) {
+	tokens := []string{"first", "second"}
+	var passportCalls int32
+	installTokenForTest(t, func(w http.ResponseWriter, _ *http.Request) {
+		idx := int(atomic.AddInt32(&passportCalls, 1) - 1)
+		if idx >= len(tokens) {
+			idx = len(tokens) - 1
+		}
+		w.Header().Set("Content-Type", "application/json")
+		_ = json.NewEncoder(w).Encode(map[string]any{
+			"access_token": tokens[idx],
+			"token_type":   "Bearer",
+			"expires_in":   2592000,
+		})
+	})
+
+	const wantBody = `{"app":"hello","build":"sha256:abc"}`
+	var seenBodies []string
+	controllerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		body, _ := io.ReadAll(r.Body)
+		seenBodies = append(seenBodies, string(body))
+		if len(seenBodies) == 1 {
+			w.WriteHeader(http.StatusUnauthorized)
+			return
+		}
+		w.Header().Set("DRYCC_API_VERSION", drycc.APIVersion)
+		w.WriteHeader(http.StatusOK)
+		_, _ = w.Write([]byte(`{}`))
+	}))
+	defer controllerSrv.Close()
+
+	cli, err := New(context.Background(), controllerSrv.URL)
+	require.NoError(t, err)
+
+	resp, err := cli.Request(http.MethodPost, "/v2/builds/", []byte(wantBody))
+	require.NoError(t, err)
+	defer resp.Body.Close()
+	assert.Equal(t, http.StatusOK, resp.StatusCode)
+
+	require.Len(t, seenBodies, 2, "expected initial request + one retry")
+	assert.Equal(t, wantBody, seenBodies[0], "first attempt body must arrive intact")
+	assert.Equal(t, wantBody, seenBodies[1], "replayed body must be byte-identical")
+}
+
+func TestNew_NetworkErrorDoesNotTriggerRetry(t *testing.T) {
+	installTokenForTest(t, func(w http.ResponseWriter, _ *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		_, _ = w.Write([]byte(`{"access_token":"t","token_type":"Bearer","expires_in":2592000}`))
+	})
+
+	cli, err := New(context.Background(), "http://127.0.0.1:1")
+	require.NoError(t, err)
+
+	_, err = cli.Request(http.MethodGet, "/v2/ping", nil)
+	require.Error(t, err)
+	assert.NotErrorIs(t, err, drycc.ErrUnauthorized)
+}
+
+func TestNew_SuccessfulRequestReusesToken(t *testing.T) {
+	var passportCalls int32
+	installTokenForTest(t, func(w http.ResponseWriter, _ *http.Request) {
+		atomic.AddInt32(&passportCalls, 1)
+		w.Header().Set("Content-Type", "application/json")
+		_, _ = w.Write([]byte(`{"access_token":"reuse-me","token_type":"Bearer","expires_in":2592000}`))
+	})
+
+	var authHeaders []string
+	controllerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		authHeaders = append(authHeaders, r.Header.Get("Authorization"))
+		w.Header().Set("DRYCC_API_VERSION", drycc.APIVersion)
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer controllerSrv.Close()
+
+	cli, err := New(context.Background(), controllerSrv.URL)
+	require.NoError(t, err)
+
+	for i := 0; i < 3; i++ {
+		resp, err := cli.Request(http.MethodGet, "/v2/ping", nil)
+		require.NoError(t, err)
+		_ = resp.Body.Close()
+	}
+
+	assert.Equal(t, int32(1), atomic.LoadInt32(&passportCalls), "passport must only be hit on the cold start")
+	require.Len(t, authHeaders, 3)
+	for _, h := range authHeaders {
+		assert.Equal(t, "Bearer reuse-me", h)
+	}
+}
diff --git a/pkg/gitreceive/build.go b/pkg/gitreceive/build.go
index 86b9f9a..0942a78 100644
--- a/pkg/gitreceive/build.go
+++ b/pkg/gitreceive/build.go
@@ -91,7 +91,7 @@ func build(
 		}
 	}()
 
-	client, err := controller.New(conf.ControllerURL)
+	client, err := controller.New(context.Background(), conf.ControllerURL)
 	if err != nil {
 		return err
 	}
diff --git a/pkg/healthsrv/server.go b/pkg/healthsrv/server.go
index 3697ff4..5fddb7a 100644
--- a/pkg/healthsrv/server.go
+++ b/pkg/healthsrv/server.go
@@ -1,6 +1,7 @@
 package healthsrv
 
 import (
+	"context"
 	"fmt"
 	"net/http"
 
@@ -12,7 +13,7 @@ import (
 // with the indicative error.
 func Start(cnf *sshd.Config, nsLister NamespaceLister, bLister BucketLister, sshServerCircuit *sshd.Circuit) error {
 	mux := http.NewServeMux()
-	client, err := controller.New(cnf.ControllerURL)
+	client, err := controller.New(context.Background(), cnf.ControllerURL)
 	if err != nil {
 		return err
 	}
diff --git a/pkg/sshd/server.go b/pkg/sshd/server.go
index 67e45cf..63fe36a 100644
--- a/pkg/sshd/server.go
+++ b/pkg/sshd/server.go
@@ -4,6 +4,7 @@
 package sshd
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"io"
@@ -38,7 +39,7 @@ var (
 // AuthKey authenticates based on a public key.
 func AuthKey(key ssh.PublicKey, cnf *Config) (*ssh.Permissions, error) {
 	log.Info("Starting ssh authentication")
-	client, err := controller.New(cnf.ControllerURL)
+	client, err := controller.New(context.Background(), cnf.ControllerURL)
 	if err != nil {
 		return nil, err
 	}

From 92c98b591d493410e723d5e545e282936bb8d870 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 12 May 2026 02:36:55 +0000
Subject: [PATCH 2/2] chore(deps): bump github.com/distribution/distribution/v3

Bumps [github.com/distribution/distribution/v3](https://github.com/distribution/distribution) from 3.1.0 to 3.1.1.
- [Release notes](https://github.com/distribution/distribution/releases)
- [Commits](https://github.com/distribution/distribution/compare/v3.1.0...v3.1.1)

---
updated-dependencies:
- dependency-name: github.com/distribution/distribution/v3
  dependency-version: 3.1.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
---
 go.mod | 2 +-
 go.sum | 4 ++--
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/go.mod b/go.mod
index 1a37ef3..cb4084f 100644
--- a/go.mod
+++ b/go.mod
@@ -8,7 +8,7 @@ require (
 	github.com/aws/aws-sdk-go-v2/config v1.29.14
 	github.com/aws/aws-sdk-go-v2/credentials v1.17.67
 	github.com/aws/aws-sdk-go-v2/service/ecr v1.44.1
-	github.com/distribution/distribution/v3 v3.1.0
+	github.com/distribution/distribution/v3 v3.1.1
 	github.com/drycc/controller-sdk-go v0.0.0-20260511051139-2b7986fe96fd
 	github.com/drycc/pkg v0.0.0-20250917064731-345368da3dbf
 	github.com/google/uuid v1.6.0
diff --git a/go.sum b/go.sum
index 8692f2d..315188b 100644
--- a/go.sum
+++ b/go.sum
@@ -46,8 +46,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
 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/distribution/distribution/v3 v3.1.0 h1:u1v788HreKTLGdNY6s7px8Exgrs9mZ9UrCDjSrpCM8g=
-github.com/distribution/distribution/v3 v3.1.0/go.mod h1:73BuF5/ziMHNVt7nnL1roYpH4Eg/FgUlKZm3WryIx/o=
+github.com/distribution/distribution/v3 v3.1.1 h1:KUbk7C8CfaLXy8kbf/hGq9cad/wCoLB6dbWH6DMbmX0=
+github.com/distribution/distribution/v3 v3.1.1/go.mod h1:d7lXwZpph0bVcOj4Aqn0nMrWHIwRQGdiV5TLeI+/w6Y=
 github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8=
 github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw=
 github.com/drycc/controller-sdk-go v0.0.0-20260511051139-2b7986fe96fd h1:zeDC7WbB3yGjuviC0u4eHaiylt7ixVbfL8Ope+FwEtM=
