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 a379d63..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" @@ -47,7 +48,7 @@ func main() { Name: "server", Aliases: []string{"srv"}, Usage: "Run the git server", - Action: func(ctx context.Context, cmd *cli.Command) error { + Action: func(_ context.Context, _ *cli.Command) error { cnf := new(sshd.Config) if err := envconfig.Process(serverConfAppName, cnf); err != nil { return fmt.Errorf("getting config for %s [%s]", serverConfAppName, err) @@ -106,7 +107,7 @@ func main() { Name: "git-receive", Aliases: []string{"gr"}, Usage: "Run the git-receive hook", - Action: func(ctx context.Context, cmd *cli.Command) error { + Action: func(_ context.Context, _ *cli.Command) error { cnf := new(gitreceive.Config) if err := envconfig.Process(gitReceiveConfAppName, cnf); err != nil { return fmt.Errorf("error getting config for %s [%s]", gitReceiveConfAppName, err) @@ -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 914c773..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" @@ -22,6 +34,32 @@ env: fieldPath: metadata.namespace - name: "DRYCC_CONTROLLER_URL" value: http://drycc-controller-api +{{- if .Values.passport.enabled }} +- name: "DRYCC_PASSPORT_URL" +{{- if .Values.global.certManagerEnabled }} + value: https://drycc-passport.{{ .Values.global.platformDomain }} +{{- else }} + value: http://drycc-passport.{{ .Values.global.platformDomain }} +{{- end }} +- name: DRYCC_PASSPORT_KEY + valueFrom: + secretKeyRef: + name: passport-creds + key: drycc-passport-builder-key +- name: DRYCC_PASSPORT_SECRET + valueFrom: + secretKeyRef: + name: passport-creds + key: drycc-passport-builder-secret +{{- else }} +- name: DRYCC_PASSPORT_URL + valueFrom: + secretKeyRef: + name: builder-secret + key: passport-url +- name: DRYCC_PASSPORT_KEY + valueFrom: + secretKeyRef: {{- if (.Values.storageEndpoint) }} - name: "DRYCC_STORAGE_BUCKET" valueFrom: 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/templates/builder-secret.yaml b/charts/builder/templates/builder-secret.yaml index 501beb1..e62b516 100644 --- a/charts/builder/templates/builder-secret.yaml +++ b/charts/builder/templates/builder-secret.yaml @@ -7,6 +7,15 @@ metadata: type: Opaque data: {{- if (.Values.registryHost) }} + {{- if (.Values.passportUrl) }} + passport-url: {{ .Values.passportUrl | b64enc }} + {{- end }} + {{- if (.Values.passportKey) }} + passport-key: {{ .Values.passportKey | b64enc }} + {{- end }} + {{- if (.Values.passportSecret) }} + passport-secret: {{ .Values.passportSecret | b64enc }} + {{- end }} registry-host: {{ .Values.registryHost | b64enc }} registry-username: {{ .Values.registryUsername | b64enc }} registry-password: {{ .Values.registryPassword | b64enc }} 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..cb4084f 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/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 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..315188b 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= @@ -44,12 +46,12 @@ 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-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/conf/config.go b/pkg/conf/config.go index 464aee9..55ca217 100644 --- a/pkg/conf/config.go +++ b/pkg/conf/config.go @@ -2,10 +2,8 @@ package conf import ( - "fmt" "net" "net/url" - "os" "strings" "github.com/drycc/builder/pkg/sys" @@ -19,22 +17,9 @@ const ( storagePathStyleEnvVar = "DRYCC_STORAGE_PATH_STYLE" ) -// ServiceKeyLocation holds the path of the service key secret. -var ServiceKeyLocation = "/var/run/secrets/drycc/controller/service-key" - // Parameters is map which contains storage params type Parameters map[string]any -// GetServiceKey returns the key to be used as token to interact with drycc-controller -func GetServiceKey() (string, error) { - serviceKeyBytes, err := os.ReadFile(ServiceKeyLocation) - if err != nil { - return "", fmt.Errorf("couldn't get builder key from %s (%s)", ServiceKeyLocation, err) - } - serviceKey := strings.TrimSuffix(string(serviceKeyBytes), "\n") - return serviceKey, nil -} - // GetStorageParams returns the credentials required for connecting to object storage func GetStorageParams(env sys.Env) (Parameters, error) { params := make(map[string]any) diff --git a/pkg/conf/config_test.go b/pkg/conf/config_test.go index 42460e7..e0d565a 100644 --- a/pkg/conf/config_test.go +++ b/pkg/conf/config_test.go @@ -1,8 +1,6 @@ package conf import ( - "os" - "path/filepath" "testing" "github.com/drycc/builder/pkg/sys" @@ -29,31 +27,3 @@ func TestGetStorageParams(t *testing.T) { assert.Equal(t, params["accesskey"], "admin", "accesskey") assert.Equal(t, params["secretkey"], "adminpass", "secretkey") } - -func TestGetControllerClient(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "tmpdir") - if err != nil { - t.Fatalf("error creating temp directory (%s)", err) - } - - defer func() { - if err := os.RemoveAll(tmpDir); err != nil { - t.Fatalf("failed to remove service-key from %s (%s)", tmpDir, err) - } - }() - - ServiceKeyLocation = filepath.Join(tmpDir, "service-key") - data := []byte("testbuilderkey") - if err := os.WriteFile(ServiceKeyLocation, data, 0o644); err != nil { - t.Fatalf("error creating %s (%s)", ServiceKeyLocation, err) - } - - key, err := GetServiceKey() - assert.Equal(t, err, nil) - assert.Equal(t, key, string(data), "data") -} - -func TestGetServiceKeyError(t *testing.T) { - _, err := GetServiceKey() - assert.True(t, err != nil, "no error received when there should have been") -} 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 2edb12b..558b797 100644 --- a/pkg/controller/utils.go +++ b/pkg/controller/utils.go @@ -2,37 +2,116 @@ package controller import ( - "github.com/drycc/builder/pkg/conf" + "bytes" + "context" + "crypto/tls" + "errors" + "io" + "net/http" + "sync/atomic" + + "github.com/drycc/builder/pkg/controller/token" drycc "github.com/drycc/controller-sdk-go" "github.com/drycc/pkg/log" ) -// New creates a new SDK client configured as the builder. -func New(url string) (*drycc.Client, error) { - client, err := drycc.New(true, url, "") +// 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" - serviceKey, err := conf.GetServiceKey() + tok, err := token.Get(ctx) if err != nil { return client, err } - client.ServiceKey = serviceKey + 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} + + // 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 63c8494..cf93276 100644 --- a/pkg/controller/utils_test.go +++ b/pkg/controller/utils_test.go @@ -1,63 +1,219 @@ package controller import ( + "context" + "encoding/json" "errors" - "os" - "path/filepath" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" "testing" - builderconf "github.com/drycc/builder/pkg/conf" + "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) { - tmpDir, err := os.MkdirTemp("", "tmpdir") - if err != nil { - t.Fatalf("error creating temp directory (%s)", err) - } +// 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) - defer func() { - if err := os.RemoveAll(tmpDir); err != nil { - t.Fatalf("failed to remove service-key from %s (%s)", tmpDir, err) - } - }() + 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()) - builderconf.ServiceKeyLocation = filepath.Join(tmpDir, "service-key") - data := []byte("testbuilderkey") - if err := os.WriteFile(builderconf.ServiceKeyLocation, data, 0o644); err != nil { - t.Fatalf("error creating %s (%s)", builderconf.ServiceKeyLocation, err) - } + tokenpkg.ResetForTest(t, client) + return mr, ts +} - 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.ServiceKey, string(data), "data") - assert.Equal(t, cli.UserAgent, "drycc-builder", "user-agent") +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":"abc","token_type":"Bearer","expires_in":2592000}`)) + }) - 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") + 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 TestNewWithInvalidBuilderKeyPath(t *testing.T) { - 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/gitreceive/build_test.go b/pkg/gitreceive/build_test.go index 450eaa2..3da1814 100644 --- a/pkg/gitreceive/build_test.go +++ b/pkg/gitreceive/build_test.go @@ -5,12 +5,10 @@ import ( "context" "os" "os/exec" - "path/filepath" "testing" "github.com/distribution/distribution/v3/registry/storage/driver/factory" _ "github.com/distribution/distribution/v3/registry/storage/driver/inmemory" - builderconf "github.com/drycc/builder/pkg/conf" "github.com/drycc/builder/pkg/sys" "github.com/drycc/controller-sdk-go/api" "github.com/drycc/pkg/log" @@ -73,13 +71,7 @@ func TestBuild(t *testing.T) { config.ControllerURL = "http://localhost:1234" if err := build(config, storageDriver, nil, env, sha); err == nil { - t.Error("expected running build() without a valid builder key to fail") - } - - builderconf.ServiceKeyLocation = filepath.Join(tmpDir, "service-key") - data := []byte("testbuilderkey") - if err := os.WriteFile(builderconf.ServiceKeyLocation, data, 0o644); err != nil { - t.Fatalf("error creating %s (%s)", builderconf.ServiceKeyLocation, err) + t.Error("expected running build() without valid credentials to fail") } if err := build(config, storageDriver, nil, env, sha); err == nil { 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 }