diff --git a/deployments/crds/hephaestus.dominodatalab.com_imagebuilds.yaml b/deployments/crds/hephaestus.dominodatalab.com_imagebuilds.yaml index cc684a67..e094f3ee 100644 --- a/deployments/crds/hephaestus.dominodatalab.com_imagebuilds.yaml +++ b/deployments/crds/hephaestus.dominodatalab.com_imagebuilds.yaml @@ -78,6 +78,11 @@ spec: description: DisableCacheLayerExport will remove the "inline" cache metadata from the image configuration. type: boolean + enableServiceAccountTokenInjection: + description: EnableServiceAccountTokenInjection adds a service account + JWT token as build-arg to the images. This supports use cases like + model building that must access other Domino services + type: boolean images: description: Images is a list of images to build and push. items: diff --git a/deployments/helm/hephaestus/templates/controller/secret.yaml b/deployments/helm/hephaestus/templates/controller/secret.yaml index d56e1ab2..ede2d8b6 100644 --- a/deployments/helm/hephaestus/templates/controller/secret.yaml +++ b/deployments/helm/hephaestus/templates/controller/secret.yaml @@ -15,6 +15,14 @@ stringData: labels: {{- .labels | toYaml | nindent 8 }} {{- end }} + keycloak: + {{- with .Values.keycloak }} + enabled: {{ .enabled }} + server: {{ .server }} + realm: {{ .realm }} + clientId: {{ .clientId }} + clientSecret: {{ .clientSecret }} + {{- end }} buildkit: namespace: {{ .Release.Namespace }} daemonPort: {{ .Values.buildkit.service.port }} diff --git a/deployments/helm/hephaestus/values.yaml b/deployments/helm/hephaestus/values.yaml index 971a6c0e..f6cc6ef6 100644 --- a/deployments/helm/hephaestus/values.yaml +++ b/deployments/helm/hephaestus/values.yaml @@ -36,6 +36,19 @@ istio: # network settings when CNI plugin is NOT installed. cni: false +# Keycloak configuration to optionally provide a service account JWT token to executing builds +keycloak: + # Enable support for acquiring a Keycloak JWT for this service and making it available to builds + enabled: false + # Address of the Keycloak endpoint - auth may or may not be required depending on KeyCloak version and its configuration + server: "http://keycloak-http/auth/" + # Realm where the given client id logs in + realm: "DominoRealm" + # Identifies the client for use in API calls + clientId: "" + # In conjunction clientId, defines the credentials used in Keycloak + clientSecret: "" + # New Relic APM configuration newRelic: # Enable monitoring diff --git a/go.mod b/go.mod index 5ea518b3..cba935a2 100644 --- a/go.mod +++ b/go.mod @@ -45,6 +45,7 @@ require ( require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.6.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.0 + github.com/Nerzal/gocloak/v13 v13.7.0 github.com/dominodatalab/amqp-client v0.1.3 github.com/dominodatalab/controller-util v0.0.2 github.com/hashicorp/go-retryablehttp v0.7.1 @@ -99,6 +100,7 @@ require ( github.com/go-openapi/jsonpointer v0.19.6 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.22.3 // indirect + github.com/go-resty/resty/v2 v2.7.0 // indirect github.com/gogo/googleapis v1.4.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.0 // indirect @@ -131,6 +133,7 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0-rc2.0.20221005185240-3a7f492d3f1b // indirect + github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -139,6 +142,7 @@ require ( github.com/prometheus/common v0.42.0 // indirect github.com/prometheus/procfs v0.9.0 // indirect github.com/rogpeppe/go-internal v1.10.0 // indirect + github.com/segmentio/ksuid v1.0.4 // indirect github.com/sirupsen/logrus v1.9.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/tonistiigi/fsutil v0.0.0-20230105215944-fb433841cbfa // indirect diff --git a/go.sum b/go.sum index edf7814b..e8238b39 100644 --- a/go.sum +++ b/go.sum @@ -80,6 +80,8 @@ github.com/Microsoft/go-winio v0.5.2 h1:a9IhgEQBCUEk6QCdml9CiJGhAws+YwffDHEMp1VM github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/hcsshim v0.9.8 h1:lf7xxK2+Ikbj9sVf2QZsouGjRjEp2STj1yDHgoVtU5k= github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= +github.com/Nerzal/gocloak/v13 v13.7.0 h1:rWZdXtGJarcdTp/XC+cHgAMhLUUYSugm4qnb/qHPyKw= +github.com/Nerzal/gocloak/v13 v13.7.0/go.mod h1:rRBtEdh5N0+JlZZEsrfZcB2sRMZWbgSxI2EIv9jpJp4= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= @@ -238,6 +240,8 @@ github.com/go-openapi/spec v0.0.0-20160808142527-6aced65f8501/go.mod h1:J8+jY1nA github.com/go-openapi/swag v0.0.0-20160704191624-1d0bd113de87/go.mod h1:DXUve3Dpr1UfpPtxFw+EFuQ41HhCWZfha5jSVRG7C7I= github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-resty/resty/v2 v2.7.0 h1:me+K9p3uhSmXtrBZ4k9jcEAfJmuC8IivWHwaLZwPrFY= +github.com/go-resty/resty/v2 v2.7.0/go.mod h1:9PWDzw47qPphMRFfhsyk0NnSgvluHcljSMVIq3w7q0I= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= @@ -433,6 +437,8 @@ github.com/opencontainers/runc v1.1.5 h1:L44KXEpKmfWDcS02aeGm8QNTFXTo2D+8MYGDIJ/ github.com/opencontainers/runtime-spec v1.0.3-0.20210326190908-1c3f411f0417 h1:3snG66yBm59tKhhSPQrQ/0bCrv1LQbKt40LnUPiUxdc= github.com/opencontainers/selinux v1.10.2 h1:NFy2xCsjn7+WspbfZkUd5zyVeisV7VFbPSP96+8/ha4= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= +github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/package-url/packageurl-go v0.1.1-0.20220428063043-89078438f170 h1:DiLBVp4DAcZlBVBEtJpNWZpZVq0AEeCY7Hqk8URVs4o= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= @@ -470,6 +476,8 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/secure-systems-lab/go-securesystemslib v0.4.0 h1:b23VGrQhTA8cN2CbBw7/FulN9fTtqYUdS5+Oxzt+DUE= +github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c= +github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE= github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= @@ -631,6 +639,7 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211029224645-99673261e6eb/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= diff --git a/pkg/api/hephaestus/v1/imagebuild_types.go b/pkg/api/hephaestus/v1/imagebuild_types.go index b96e1d70..272472b0 100644 --- a/pkg/api/hephaestus/v1/imagebuild_types.go +++ b/pkg/api/hephaestus/v1/imagebuild_types.go @@ -32,6 +32,9 @@ type ImageBuildSpec struct { DisableLocalBuildCache bool `json:"disableBuildCache,omitempty"` // DisableCacheLayerExport will remove the "inline" cache metadata from the image configuration. DisableCacheLayerExport bool `json:"disableCacheExport,omitempty"` + // EnableServiceAccountTokenInjection adds a service account JWT token as build-arg to the images. + // This supports use cases like model building that must access other Domino services + EnableServiceAccountTokenInjection bool `json:"enableServiceAccountTokenInjection,omitempty"` } type ImageBuildTransition struct { diff --git a/pkg/api/hephaestus/v1/types.go b/pkg/api/hephaestus/v1/types.go index 456b0592..8455bb7a 100644 --- a/pkg/api/hephaestus/v1/types.go +++ b/pkg/api/hephaestus/v1/types.go @@ -26,6 +26,8 @@ const ( PhaseFailed Phase = "Failed" ) +const ServiceTokenArgName = "SERVICE_TOKEN" + type BasicAuthCredentials struct { Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` diff --git a/pkg/config/config.go b/pkg/config/config.go index d76133d0..bf9200f2 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -18,6 +18,7 @@ type Controller struct { Buildkit Buildkit `json:"buildkit" yaml:"buildkit"` Messaging Messaging `json:"messaging" yaml:"messaging"` NewRelic NewRelic `json:"newRelic" yaml:"newRelic"` + Keycloak Keycloak `json:"keycloak" yaml:"keycloak"` ImageBuildMaxConcurrency int `json:"imageBuildMaxConcurrency" yaml:"imageBuildMaxConcurrency"` } @@ -52,6 +53,21 @@ func (c Controller) Validate() error { errs = append(errs, "newRelic.licenseKey cannot be blank") } + if c.Keycloak.Enabled { + if c.Keycloak.ClientID == "" { + errs = append(errs, "keycloak.clientId cannot be blank") + } + if c.Keycloak.ClientSecret == "" { + errs = append(errs, "keycloak.clientSecret cannot be blank") + } + if c.Keycloak.Realm == "" { + errs = append(errs, "keycloak.realm cannot be blank") + } + if c.Keycloak.Server == "" { + errs = append(errs, "keycloak.server cannot be blank") + } + } + if len(errs) != 0 { return fmt.Errorf("config is invalid: %s", strings.Join(errs, ", ")) } @@ -165,6 +181,14 @@ type NewRelic struct { LicenseKey string `json:"licenseKey" yaml:"licenseKey"` } +type Keycloak struct { + Enabled bool `json:"enabled" yaml:"enabled"` + Server string `json:"server" yaml:"server"` + ClientID string `json:"clientId" yaml:"clientId"` + ClientSecret string `json:"clientSecret" yaml:"clientSecret"` + Realm string `json:"realm" yaml:"realm"` +} + func LoadFromFile(filename string) (Controller, error) { bs, err := os.ReadFile(filename) if err != nil { diff --git a/pkg/controller/imagebuild/component/builddispatcher.go b/pkg/controller/imagebuild/component/builddispatcher.go index 6953e98d..feec9d0c 100644 --- a/pkg/controller/imagebuild/component/builddispatcher.go +++ b/pkg/controller/imagebuild/component/builddispatcher.go @@ -7,6 +7,7 @@ import ( "sync" "time" + "github.com/Nerzal/gocloak/v13" "github.com/dominodatalab/controller-util/core" "github.com/go-logr/logr" "github.com/newrelic/go-agent/v3/newrelic" @@ -22,10 +23,11 @@ import ( ) type BuildDispatcherComponent struct { - cfg config.Buildkit - pool worker.Pool - phase *phase.TransitionHelper - newRelic *newrelic.Application + cfg config.Buildkit + pool worker.Pool + phase *phase.TransitionHelper + newRelic *newrelic.Application + keycloakCfg config.Keycloak delete <-chan client.ObjectKey cancels sync.Map @@ -35,13 +37,15 @@ func BuildDispatcher( cfg config.Buildkit, pool worker.Pool, nr *newrelic.Application, + kc config.Keycloak, ch <-chan client.ObjectKey, ) *BuildDispatcherComponent { return &BuildDispatcherComponent{ - cfg: cfg, - pool: pool, - delete: ch, - newRelic: nr, + cfg: cfg, + pool: pool, + delete: ch, + newRelic: nr, + keycloakCfg: kc, } } @@ -179,6 +183,33 @@ func (c *BuildDispatcherComponent) Reconcile(ctx *core.Context) (ctrl.Result, er }) return ctrl.Result{}, c.phase.SetFailed(ctx, obj, err) } + + if obj.Spec.EnableServiceAccountTokenInjection { + buildLog.Info("Acquiring Keycloak service account token") + if !c.keycloakCfg.Enabled { + buildLog.Error(err, "Keycloak configuration disabled") + txn.NoticeError(newrelic.Error{ + Message: "Keycloak configuration disabled", + Class: "WorkerClientInitError", + }) + return ctrl.Result{}, c.phase.SetFailed(ctx, obj, fmt.Errorf("keycloak configuration disabled")) + } + + kc := gocloak.NewClient(c.keycloakCfg.Server) + jwt, err := kc.LoginClient(buildCtx, c.keycloakCfg.ClientID, c.keycloakCfg.ClientSecret, c.keycloakCfg.Realm) + if err != nil { + buildLog.Error(err, fmt.Sprintf( + "Failed to acquire %s Keycloak creds from %s", c.keycloakCfg.ClientID, c.keycloakCfg.Server)) + txn.NoticeError(newrelic.Error{ + Message: err.Error(), + Class: "WorkerClientInitError", + }) + return ctrl.Result{}, c.phase.SetFailed(ctx, obj, fmt.Errorf("keycloak token acquire failed: %w", err)) + } + + obj.Spec.BuildArgs = append(obj.Spec.BuildArgs, fmt.Sprintf("%s=%s", hephv1.ServiceTokenArgName, jwt.AccessToken)) + log.Info(fmt.Sprintf("Injected %s token as build-arg %s", c.keycloakCfg.ClientID, hephv1.ServiceTokenArgName)) + } clientInitSeg.End() buildOpts := buildkit.BuildOptions{ diff --git a/pkg/controller/imagebuild/imagebuild.go b/pkg/controller/imagebuild/imagebuild.go index d32cf1d3..07c2c1bf 100644 --- a/pkg/controller/imagebuild/imagebuild.go +++ b/pkg/controller/imagebuild/imagebuild.go @@ -20,7 +20,7 @@ var ch = make(chan client.ObjectKey) func Register(mgr ctrl.Manager, cfg config.Controller, pool worker.Pool, nr *newrelic.Application) error { return core.NewReconciler(mgr). For(&hephv1.ImageBuild{}). - Component("build-dispatcher", component.BuildDispatcher(cfg.Buildkit, pool, nr, ch)). + Component("build-dispatcher", component.BuildDispatcher(cfg.Buildkit, pool, nr, cfg.Keycloak, ch)). WithControllerOptions(controller.Options{MaxConcurrentReconciles: cfg.ImageBuildMaxConcurrency}). WithWebhooks(). Complete() diff --git a/test/functional/helpers_test.go b/test/functional/helpers_test.go index d47dc4a5..7d4d101a 100644 --- a/test/functional/helpers_test.go +++ b/test/functional/helpers_test.go @@ -409,6 +409,19 @@ func (suite *GenericImageBuilderTestSuite) TestImageBuilding() { assert.Equalf(t, ib.Status.Phase, hephv1.PhaseSucceeded, "failed build with message %q", ib.Status.Conditions[0].Message) }) + suite.T().Run("token_injection", func(t *testing.T) { + build := newImageBuild( + buildArgBuildContext, + "docker-registry:5000/test-ns/test-repo", + nil, + ) + // TODO: configure the Keycloak client and point it to a fake httptest server + build.Spec.EnableServiceAccountTokenInjection = true + ib := createBuild(t, ctx, suite.hephClient, build) + + assert.Equalf(t, ib.Status.Phase, hephv1.PhaseSucceeded, "failed build with message %q", ib.Status.Conditions[0].Message) + }) + suite.T().Run("build_failure", func(t *testing.T) { build := newImageBuild( errorBuildContext,