From c247c2ba6587cd75fe4bcca9cfeb93f8e0b11b53 Mon Sep 17 00:00:00 2001 From: Sayan Biswas Date: Wed, 18 Feb 2026 12:36:12 +0530 Subject: [PATCH] Feat: Add corporate certificates to Build APIs Reference: https://github.com/shipwright-io/community/pull/281 # Changes: - Added APIs to allow user to define custom Root CAs in Build and BuildRun APIs - Add unit tests Signed-off-by: Sayan Biswas --- deploy/crds/shipwright.io_buildruns.yaml | 159 ++++++ deploy/crds/shipwright.io_builds.yaml | 53 ++ docs/build.md | 51 ++ docs/buildrun.md | 34 ++ pkg/apis/build/v1beta1/build_types.go | 8 + pkg/apis/build/v1beta1/buildrun_types.go | 4 + pkg/apis/build/v1beta1/cabundle.go | 35 ++ pkg/cabundle/cabundle.go | 166 ++++++ pkg/cabundle/cabundle_test.go | 539 ++++++++++++++++++ pkg/cabundle/suite_test.go | 17 + pkg/reconciler/build/build.go | 1 + pkg/reconciler/buildrun/buildrun.go | 1 + pkg/reconciler/buildrun/resources/cabundle.go | 55 ++ .../resources/pipelinerun_generator.go | 6 + .../buildrun/resources/resource_builders.go | 27 +- pkg/reconciler/buildrun/resources/taskrun.go | 1 + .../buildrun/resources/taskrun_generator.go | 30 +- .../buildrun/resources/taskrun_test.go | 2 + pkg/validate/cabundle.go | 52 ++ pkg/validate/cabundle_test.go | 181 ++++++ pkg/validate/validate.go | 4 + 21 files changed, 1420 insertions(+), 6 deletions(-) create mode 100644 pkg/apis/build/v1beta1/cabundle.go create mode 100644 pkg/cabundle/cabundle.go create mode 100644 pkg/cabundle/cabundle_test.go create mode 100644 pkg/cabundle/suite_test.go create mode 100644 pkg/reconciler/buildrun/resources/cabundle.go create mode 100644 pkg/validate/cabundle.go create mode 100644 pkg/validate/cabundle_test.go diff --git a/deploy/crds/shipwright.io_buildruns.yaml b/deploy/crds/shipwright.io_buildruns.yaml index 0d6067ba22..f88abf3476 100644 --- a/deploy/crds/shipwright.io_buildruns.yaml +++ b/deploy/crds/shipwright.io_buildruns.yaml @@ -7598,6 +7598,59 @@ spec: spec: description: Spec refers to an embedded build specification properties: + caBundle: + description: CABundle specifies the Secret or ConfigMap containing + CA certificates to be loaded in workload containers. + properties: + configMap: + description: configMap is a reference (by name) to a ConfigMap's + `data` key. + properties: + key: + description: Key of the entry in the object's `data` + field to be used. + maxLength: 253 + minLength: 1 + type: string + name: + description: Name is the name of the source object + in the trust namespace. + maxLength: 253 + minLength: 1 + type: string + required: + - key + - name + type: object + x-kubernetes-map-type: atomic + secret: + description: secret is a reference (by name) to a Secret's + `data` key. + properties: + key: + description: Key of the entry in the object's `data` + field to be used. + maxLength: 253 + minLength: 1 + type: string + name: + description: Name is the name of the source object + in the trust namespace. + maxLength: 253 + minLength: 1 + type: string + required: + - key + - name + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: exactly one of the fields in [configMap secret] + must be set + rule: '[has(self.configMap),has(self.secret)].filter(x,x==true).size() + == 1' env: description: Env contains additional environment variables that should be passed to the build container @@ -10244,6 +10297,59 @@ spec: - strategy type: object type: object + caBundle: + description: CABundle specifies the Secret or ConfigMap containing + CA certificates to be loaded in workload containers. + properties: + configMap: + description: configMap is a reference (by name) to a ConfigMap's + `data` key. + properties: + key: + description: Key of the entry in the object's `data` field + to be used. + maxLength: 253 + minLength: 1 + type: string + name: + description: Name is the name of the source object in the + trust namespace. + maxLength: 253 + minLength: 1 + type: string + required: + - key + - name + type: object + x-kubernetes-map-type: atomic + secret: + description: secret is a reference (by name) to a Secret's `data` + key. + properties: + key: + description: Key of the entry in the object's `data` field + to be used. + maxLength: 253 + minLength: 1 + type: string + name: + description: Name is the name of the source object in the + trust namespace. + maxLength: 253 + minLength: 1 + type: string + required: + - key + - name + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: exactly one of the fields in [configMap secret] must be + set + rule: '[has(self.configMap),has(self.secret)].filter(x,x==true).size() + == 1' env: description: Env contains additional environment variables that should be passed to the build container @@ -12713,6 +12819,59 @@ spec: buildSpec: description: BuildSpec is the Build Spec of this BuildRun. properties: + caBundle: + description: CABundle specifies the Secret or ConfigMap containing + CA certificates to be loaded in workload containers. + properties: + configMap: + description: configMap is a reference (by name) to a ConfigMap's + `data` key. + properties: + key: + description: Key of the entry in the object's `data` field + to be used. + maxLength: 253 + minLength: 1 + type: string + name: + description: Name is the name of the source object in + the trust namespace. + maxLength: 253 + minLength: 1 + type: string + required: + - key + - name + type: object + x-kubernetes-map-type: atomic + secret: + description: secret is a reference (by name) to a Secret's + `data` key. + properties: + key: + description: Key of the entry in the object's `data` field + to be used. + maxLength: 253 + minLength: 1 + type: string + name: + description: Name is the name of the source object in + the trust namespace. + maxLength: 253 + minLength: 1 + type: string + required: + - key + - name + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: exactly one of the fields in [configMap secret] must + be set + rule: '[has(self.configMap),has(self.secret)].filter(x,x==true).size() + == 1' env: description: Env contains additional environment variables that should be passed to the build container diff --git a/deploy/crds/shipwright.io_builds.yaml b/deploy/crds/shipwright.io_builds.yaml index 8936710d88..d68354abb1 100644 --- a/deploy/crds/shipwright.io_builds.yaml +++ b/deploy/crds/shipwright.io_builds.yaml @@ -2643,6 +2643,59 @@ spec: spec: description: BuildSpec defines the desired state of Build properties: + caBundle: + description: CABundle specifies the Secret or ConfigMap containing + CA certificates to be loaded in workload containers. + properties: + configMap: + description: configMap is a reference (by name) to a ConfigMap's + `data` key. + properties: + key: + description: Key of the entry in the object's `data` field + to be used. + maxLength: 253 + minLength: 1 + type: string + name: + description: Name is the name of the source object in the + trust namespace. + maxLength: 253 + minLength: 1 + type: string + required: + - key + - name + type: object + x-kubernetes-map-type: atomic + secret: + description: secret is a reference (by name) to a Secret's `data` + key. + properties: + key: + description: Key of the entry in the object's `data` field + to be used. + maxLength: 253 + minLength: 1 + type: string + name: + description: Name is the name of the source object in the + trust namespace. + maxLength: 253 + minLength: 1 + type: string + required: + - key + - name + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: exactly one of the fields in [configMap secret] must be + set + rule: '[has(self.configMap),has(self.secret)].filter(x,x==true).size() + == 1' env: description: Env contains additional environment variables that should be passed to the build container diff --git a/docs/build.md b/docs/build.md index ea11698a3e..3f57eef084 100644 --- a/docs/build.md +++ b/docs/build.md @@ -20,6 +20,7 @@ SPDX-License-Identifier: Apache-2.0 - [Defining the vulnerabilityScan](#defining-the-vulnerabilityscan) - [Defining Retention Parameters](#defining-retention-parameters) - [Defining Volumes](#defining-volumes) + - [Defining CA Bundle](#defining-ca-bundle) - [Defining Step Resources](#defining-step-resources) - [Defining Triggers](#defining-triggers) - [GitHub](#github) @@ -45,6 +46,7 @@ A `Build` resource allows the user to define: - tolerations - schedulerName - runtimeClassName +- caBundle A `Build` is available within a namespace. @@ -60,6 +62,7 @@ When the controller reconciles it: - Validates if the specified `paramValues` exist on the referenced strategy parameters. It also validates if the `paramValues` names collide with the Shipwright reserved names. - Validates if the container `registry` output secret exists. - Validates if the referenced `spec.source.git.url` endpoint exists. +- Validates the Secret/ConfigMap referenced in the CA Bundle exists or not and also validates if the data in the referenced Secret/ConfigMap is a valid certificate authority. ## Build Validations @@ -108,6 +111,12 @@ To prevent users from triggering `BuildRun`s (_execution of a Build_) that will | NodeSelectorPlatformConflict | `spec.output.platforms` is set and `spec.nodeSelector` includes `kubernetes.io/os` or `kubernetes.io/arch`. | | ExecutorNotPipelineRun | *(BuildRun only)* Multi-arch output requires `PipelineRun` executor mode. | | NodePlatformNotFound | *(BuildRun only)* No schedulable node matches a requested platform (`kubernetes.io/os` / `kubernetes.io/arch`). | +| NodeSelectorNotValid | The specified nodeSelector is not valid. | +| TolerationNotValid | The specified tolerations are not valid. | +| SchedulerNameNotValid | The specified schedulerName is not valid. | +| RuntimeClassNameNotValid | The specified runtimeClassName is not valid. | +| CABundleNotFound | Referenced ConfigMap/Secret does not exists. | +| CABundleNotValid | The data in the referenced ConfigMap/Secret is not a valid certificate authority. | ## Configuring a Build @@ -144,6 +153,7 @@ The `Build` definition supports the following fields: - `spec.schedulerName` - Specifies the scheduler name for the build pod. If schedulerName is specified in both a `Build` and `BuildRun`, `BuildRun` values take precedence. - `spec.strategy.stepResources` - Allows overriding resource requirements (CPU, memory) for individual steps defined in the `BuildStrategy` or `ClusterBuildStrategy`. Each entry specifies a step name and the resources to use instead of those defined in the strategy. You can overwrite values in the `BuildRun`. See [Defining Step Resources](#defining-step-resources) for more information. - `spec.runtimeClassName` - Specifies the [RuntimeClass](https://kubernetes.io/docs/concepts/containers/runtime-class/) to be used for the build pod. If runtimeClassName is specified in both a `Build` and `BuildRun`, `BuildRun` values take precedence. + - `spec.caBundle` - Specifies a CA Bundle that is mounted in all the workloads. Either `ConfigMap` or `Secret` can be referenced. The `Key` inside the referenced object also needs to be specified. ### Defining the Source @@ -788,6 +798,47 @@ spec: See the related [BuildRun documentation](buildrun.md#defining-step-resources) for how to override step resources at the BuildRun level. +### Defining CA Bundle + +Using the CA Bundle API, you can mount your own certificate authority, so that any program running in the build workload can use defined CA to establish mutual trust. + +CA Bundle can be referenced from a `ConfigMap` or `Secret` in the build namespace. +The `Key` to be used from referenced `ConfigMap` or `Secret` also needs to be defined. + +The CA will be mounted in all the containers at the following locations +- /etc/ssl/certs/ca-certificates.crt +- /etc/pki/tls/certs/ca-bundle.crt + +The following environment variables will be added in all the containers, with value `/etc/ssl/certs/ca-certificates.crt` +- SSL_CERT_FILE - OS trust store +- NODE_EXTRA_CA_CERTS - Node.js uses this to append additional certificates +- REQUESTS_CA_BUNDLE - Python requests library +- CURL_CA_BUNDLE - curl CA bundle + +**Note**: If an environment is already defined, it will not be overwritten. + +```yaml +apiVersion: shipwright.io/v1beta1 +kind: Build +metadata: + name: buildah-build-with-ca +spec: + output: + image: registry/namespace/image:latest + source: + contextDir: buildah + git: + url: https://gitea.com/admin/samples.git + type: Git + strategy: + kind: ClusterBuildStrategy + name: buildah + caBundle: + secret: + name: ca-bundle + key: ca.crt +``` + ### Defining Triggers Using the triggers, you can submit `BuildRun` instances when certain events happen. The idea is to be able to trigger Shipwright builds in an event driven fashion, for that purpose you can watch certain types of events. diff --git a/docs/buildrun.md b/docs/buildrun.md index bfbceb762c..38d58ed1c0 100644 --- a/docs/buildrun.md +++ b/docs/buildrun.md @@ -18,6 +18,7 @@ SPDX-License-Identifier: Apache-2.0 - [Defining Retention Parameters](#defining-retention-parameters) - [Defining Volumes](#defining-volumes) - [Defining Step Resources](#defining-step-resources) + - [Defining CA Bundle](#defining-ca-bundle) - [Canceling a `BuildRun`](#canceling-a-buildrun) - [Automatic `BuildRun` deletion](#automatic-buildrun-deletion) - [Specifying Environment Variables](#specifying-environment-variables) @@ -81,6 +82,7 @@ The `BuildRun` definition supports the following fields: - `spec.tolerations` - Specifies the tolerations for the build pod. Only `key`, `value`, and `operator` are supported. Only `NoSchedule` taint `effect` is supported. If tolerations are specified in both a `Build` and `BuildRun`, `BuildRun` values take precedence. - `spec.schedulerName` - Specifies the scheduler name for the build pod. If schedulerName is specified in both a `Build` and `BuildRun`, `BuildRun` values take precedence. - `spec.runtimeClassName` - Specifies the [RuntimeClass](https://kubernetes.io/docs/concepts/containers/runtime-class/) to be used for the build pod. If runtimeClassName is specified in both a `Build` and `BuildRun`, `BuildRun` values take precedence. + - `spec.caBundle` - Specifies a CA Bundle that is mounted in all the workloads. Either `ConfigMap` or `Secret` can be referenced. The `Key` inside the referenced object also needs to be specified. **Note**: The `spec.build.name` and `spec.build.spec` are mutually exclusive. Furthermore, the overrides for `timeout`, `paramValues`, `output`, `env`, `stepResources`, `nodeSelector`, `tolerations`, `schedulerName`, and `runtimeClassName` can only be combined with `spec.build.name`, but **not** with `spec.build.spec`. @@ -284,6 +286,38 @@ spec: See the related [Build documentation](./build.md#defining-step-resources) for how to define step resources at the Build level. +### Defining CA Bundle + +Using the CA Bundle API, you can add your own certificate authority, so that any program running in the build workload can use defined CA to establish mutual trust. + +CA Bundle can be referenced from a `ConfigMap` or `Secret` in the build namespace. The `Key` to be used from referenced `ConfigMap` or `Secret` also needs to be defined. + +The CA will be mounted in all the containers at the following locations +- /etc/ssl/certs/ca-certificates.crt +- /etc/pki/tls/certs/ca-bundle.crt + +The following environment variables will be added in all the containers, with value `/etc/ssl/certs/ca-certificates.crt` +- SSL_CERT_FILE - OS trust store +- NODE_EXTRA_CA_CERTS - Node.js uses this to append additional certificates +- REQUESTS_CA_BUNDLE - Python requests library +- CURL_CA_BUNDLE - curl CA bundle + +**Note**: If an environment is already defined, it will not be overwritten. + +```yaml +apiVersion: shipwright.io/v1beta1 +kind: BuildRun +metadata: + generateName: buildah-buildrun-with-ca- +spec: + caBundle: + secret: + name: ca-bundle + key: ca.crt + build: + name: buildah-build +``` + ## Canceling a `BuildRun` To cancel a `BuildRun` that's currently executing, update its status to mark it as canceled. diff --git a/pkg/apis/build/v1beta1/build_types.go b/pkg/apis/build/v1beta1/build_types.go index 5806f37521..7474864582 100644 --- a/pkg/apis/build/v1beta1/build_types.go +++ b/pkg/apis/build/v1beta1/build_types.go @@ -98,6 +98,10 @@ const ( NodePlatformNotFound BuildReason = "NodePlatformNotFound" // AllValidationsSucceeded indicates a Build was successfully validated AllValidationsSucceeded = "all validations succeeded" + // CABundleNotFound indicates the referenced Secret or ConfigMap is missing + CABundleNotFound BuildReason = "CABundleNotFound" + // CABundleNotValid indicates the referenced Secret or ConfigMap is in valid + CABundleNotValid BuildReason = "CABundleNotValid" ) // IgnoredVulnerabilitySeverity is an enum for the possible values for the ignored severity @@ -214,6 +218,10 @@ type BuildSpec struct { // RuntimeClassName specifies the RuntimeClass to be used to run the Pod // +optional RuntimeClassName *string `json:"runtimeClassName,omitempty"` + + // CABundle specifies the Secret or ConfigMap containing CA certificates to be loaded in workload containers. + // +optional + CABundle *CABundle `json:"caBundle,omitempty"` } // BuildVolume is a volume that will be mounted in build pod during build step diff --git a/pkg/apis/build/v1beta1/buildrun_types.go b/pkg/apis/build/v1beta1/buildrun_types.go index b0a411a4b2..fc26a1338f 100644 --- a/pkg/apis/build/v1beta1/buildrun_types.go +++ b/pkg/apis/build/v1beta1/buildrun_types.go @@ -138,6 +138,10 @@ type BuildRunSpec struct { // RuntimeClassName specifies the RuntimeClass to be used to run the Pod // +optional RuntimeClassName *string `json:"runtimeClassName,omitempty"` + + // CABundle specifies the Secret or ConfigMap containing CA certificates to be loaded in workload containers. + // +optional + CABundle *CABundle `json:"caBundle,omitempty"` } // BuildRunRequestedState defines the buildrun state the user can provide to override whatever is the current state. diff --git a/pkg/apis/build/v1beta1/cabundle.go b/pkg/apis/build/v1beta1/cabundle.go new file mode 100644 index 0000000000..a5d2155121 --- /dev/null +++ b/pkg/apis/build/v1beta1/cabundle.go @@ -0,0 +1,35 @@ +// Copyright The Shipwright Contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package v1beta1 + +// CABundle is a set of sources whose data will be added to system trust bundle. +// +structType=atomic +// +kubebuilder:validation:ExactlyOneOf=configMap;secret +type CABundle struct { + // configMap is a reference (by name) to a ConfigMap's `data` key. + // +optional + ConfigMap *SourceObjectKeySelector `json:"configMap,omitempty"` + + // secret is a reference (by name) to a Secret's `data` key. + // +optional + Secret *SourceObjectKeySelector `json:"secret,omitempty"` +} + +// SourceObjectKeySelector is a reference to a source object and its `data` key(s) +// in the trust namespace. +// +structType=atomic +type SourceObjectKeySelector struct { + // Name is the name of the source object in the trust namespace. + // +kubebuilder:validation:required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + Name string `json:"name"` + + // Key of the entry in the object's `data` field to be used. + // +kubebuilder:validation:required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + Key string `json:"key"` +} diff --git a/pkg/cabundle/cabundle.go b/pkg/cabundle/cabundle.go new file mode 100644 index 0000000000..9320f11107 --- /dev/null +++ b/pkg/cabundle/cabundle.go @@ -0,0 +1,166 @@ +// Copyright The Shipwright Contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package cabundle + +import ( + "context" + "crypto/x509" + "encoding/pem" + "fmt" + "hash/fnv" + "regexp" + "strconv" + "strings" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + + buildapi "github.com/shipwright-io/build/pkg/apis/build/v1beta1" +) + +const VolumePath = "ca.crt" +const CACertFile = "/etc/ssl/certs/ca-certificates.crt" + +var dnsLabel1123Forbidden = regexp.MustCompile("[^a-zA-Z0-9-]+") + +// DefaultBundlePaths contains default cert directories for linux distributions to search. Order must be maintained. +// https://go.dev/src/crypto/x509/root_linux.go +var DefaultBundlePaths = []string{ + "/etc/ssl/certs/ca-certificates.crt", // Debian/Ubuntu/Gentoo + "/etc/pki/tls/certs/ca-bundle.crt", // Fedora/RHEL +} + +var EnvVars = []string{ + "SSL_CERT_FILE", // OS trust store + "NODE_EXTRA_CA_CERTS", // Node.js uses this to append additional certificates + "REQUESTS_CA_BUNDLE", // Python requests library + "CURL_CA_BUNDLE", // curl CA bundle +} + +func Validate(ctx context.Context, c client.Client, ca *buildapi.CABundle, namespace string) error { + var object client.Object + var name string + var data []byte + + if ca == nil { + return fmt.Errorf("no CA bundle provided") + } + + switch { + case ca.Secret != nil: + object = &corev1.Secret{} + name = ca.Secret.Name + case ca.ConfigMap != nil: + object = &corev1.ConfigMap{} + name = ca.ConfigMap.Name + } + + // Check if resource exists + if err := c.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: namespace, + }, object); err != nil { + return err + } + + // Check if CA Bundle data is valid + switch o := object.(type) { + case *corev1.Secret: + data = o.Data[ca.Secret.Key] + case *corev1.ConfigMap: + data = []byte(o.Data[ca.ConfigMap.Key]) + } + + // Parse and validate each certificate in the PEM data + block, rest := pem.Decode(data) + if block == nil { + return fmt.Errorf("no certificate present in CA bundle") + } + + for block != nil { + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return fmt.Errorf("unable to parse x509 certificate data: %v", err) + } + if !cert.IsCA { + return fmt.Errorf("invalid certificate data, not CA") + } + block, rest = pem.Decode(rest) + } + + return nil +} + +func NewVolume(ca *buildapi.CABundle) *corev1.Volume { + var name string + v := &corev1.Volume{} + switch { + case ca.Secret != nil: + name = ca.Secret.Name + v.Secret = &corev1.SecretVolumeSource{ + SecretName: ca.Secret.Name, + Items: []corev1.KeyToPath{ + {Key: ca.Secret.Key, Path: VolumePath}, + }, + } + case ca.ConfigMap != nil: + name = ca.ConfigMap.Name + v.ConfigMap = &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: ca.ConfigMap.Name}, + Items: []corev1.KeyToPath{ + {Key: ca.ConfigMap.Key, Path: VolumePath}, + }, + } + } + v.Name = getHashedName(name) + return v +} + +func getHashedName(name string) string { + hasher := fnv.New32a() + _, _ = hasher.Write([]byte(name)) + hash := strconv.FormatUint(uint64(hasher.Sum32()), 10) + + // Convert to lowercase and remove forbidden characters + sanitizedName := strings.ToLower(dnsLabel1123Forbidden.ReplaceAllString(name, "-")) + + // Remove both leading and trailing hyphens + sanitizedName = strings.Trim(sanitizedName, "-") + + // Ensure maximum length, leaving space for the hash + maxLength := 63 - len(hash) - 1 // -1 for the hyphen separator + if len(sanitizedName) > maxLength { + sanitizedName = sanitizedName[:maxLength] + } + + return fmt.Sprintf("%s-%s", sanitizedName, hash) +} + +func NewVolumeMount(volume *corev1.Volume) []corev1.VolumeMount { + var vm []corev1.VolumeMount + for _, bundle := range DefaultBundlePaths { + vm = append(vm, corev1.VolumeMount{ + Name: volume.Name, + MountPath: bundle, + SubPath: VolumePath, + ReadOnly: true, + RecursiveReadOnly: ptr.To(corev1.RecursiveReadOnlyIfPossible), + }) + } + return vm +} + +func NewEnvVar() []corev1.EnvVar { + var ev []corev1.EnvVar + for _, envVar := range EnvVars { + ev = append(ev, corev1.EnvVar{ + Name: envVar, + Value: CACertFile, + }) + } + return ev +} diff --git a/pkg/cabundle/cabundle_test.go b/pkg/cabundle/cabundle_test.go new file mode 100644 index 0000000000..602a568773 --- /dev/null +++ b/pkg/cabundle/cabundle_test.go @@ -0,0 +1,539 @@ +// Copyright The Shipwright Contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package cabundle_test + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "fmt" + "math/big" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + buildapi "github.com/shipwright-io/build/pkg/apis/build/v1beta1" + "github.com/shipwright-io/build/pkg/cabundle" + "github.com/shipwright-io/build/pkg/controller/fakes" +) + +func newCACertPEM() []byte { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + Expect(err).ToNot(HaveOccurred()) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + Expect(err).ToNot(HaveOccurred()) + + return pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: der, + }) +} + +func newNonCACertPEM() []byte { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + Expect(err).ToNot(HaveOccurred()) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + IsCA: false, + BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + Expect(err).ToNot(HaveOccurred()) + + return pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: der, + }) +} + +func newMultipleCACertsPEM() []byte { + var bundle []byte + for i := 0; i < 3; i++ { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + Expect(err).ToNot(HaveOccurred()) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(int64(i + 1)), + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + Expect(err).ToNot(HaveOccurred()) + + bundle = append(bundle, pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: der, + })...) + } + return bundle +} + +func newMixedCertsPEM() []byte { + var bundle []byte + + // First cert: valid CA + bundle = append(bundle, newCACertPEM()...) + + // Second cert: not a CA + bundle = append(bundle, newNonCACertPEM()...) + + return bundle +} + +var _ = Describe("CABundle", func() { + + Describe("Validate", func() { + var ( + ctx context.Context + namespace string + c *fakes.FakeClient + ) + + BeforeEach(func() { + ctx = context.Background() + namespace = "default" + c = &fakes.FakeClient{} + }) + + It("should fail when CABundle is nil", func() { + err := cabundle.Validate(ctx, c, nil, namespace) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(Equal("no CA bundle provided")) + }) + + Context("with a Secret reference", func() { + It("should succeed when the secret exists and contains a valid CA certificate", func() { + certData := newCACertPEM() + c.GetCalls(func(_ context.Context, key types.NamespacedName, obj client.Object, _ ...client.GetOption) error { + s, ok := obj.(*corev1.Secret) + if !ok { + return fmt.Errorf("unexpected object type") + } + s.Name = key.Name + s.Namespace = key.Namespace + s.Data = map[string][]byte{ + cabundle.VolumePath: certData, + } + return nil + }) + + ca := &buildapi.CABundle{ + Secret: &buildapi.SourceObjectKeySelector{ + Name: "my-ca", + Key: cabundle.VolumePath, + }, + } + Expect(cabundle.Validate(ctx, c, ca, namespace)).To(Succeed()) + }) + + It("should fail when the secret does not exist", func() { + c.GetReturns(apierrors.NewNotFound(schema.GroupResource{Resource: "secrets"}, "missing-secret")) + + ca := &buildapi.CABundle{ + Secret: &buildapi.SourceObjectKeySelector{ + Name: "missing-secret", + Key: cabundle.VolumePath, + }, + } + err := cabundle.Validate(ctx, c, ca, namespace) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsNotFound(err)).To(BeTrue()) + }) + + It("should fail when the secret contains invalid certificate data", func() { + c.GetCalls(func(_ context.Context, key types.NamespacedName, obj client.Object, _ ...client.GetOption) error { + s, ok := obj.(*corev1.Secret) + if !ok { + return fmt.Errorf("unexpected object type") + } + s.Name = key.Name + s.Namespace = key.Namespace + s.Data = map[string][]byte{ + cabundle.VolumePath: []byte("not-a-certificate"), + } + return nil + }) + + ca := &buildapi.CABundle{ + Secret: &buildapi.SourceObjectKeySelector{ + Name: "bad-cert", + Key: cabundle.VolumePath, + }, + } + Expect(cabundle.Validate(ctx, c, ca, namespace)).ToNot(Succeed()) + }) + + It("should fail when the certificate is not a CA", func() { + certData := newNonCACertPEM() + c.GetCalls(func(_ context.Context, key types.NamespacedName, obj client.Object, _ ...client.GetOption) error { + s, ok := obj.(*corev1.Secret) + if !ok { + return fmt.Errorf("unexpected object type") + } + s.Name = key.Name + s.Namespace = key.Namespace + s.Data = map[string][]byte{ + cabundle.VolumePath: certData, + } + return nil + }) + + ca := &buildapi.CABundle{ + Secret: &buildapi.SourceObjectKeySelector{ + Name: "non-ca", + Key: cabundle.VolumePath, + }, + } + err := cabundle.Validate(ctx, c, ca, namespace) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not CA")) + }) + + It("should succeed when the secret contains multiple valid CA certificates", func() { + certData := newMultipleCACertsPEM() + c.GetCalls(func(_ context.Context, key types.NamespacedName, obj client.Object, _ ...client.GetOption) error { + s, ok := obj.(*corev1.Secret) + if !ok { + return fmt.Errorf("unexpected object type") + } + s.Name = key.Name + s.Namespace = key.Namespace + s.Data = map[string][]byte{ + cabundle.VolumePath: certData, + } + return nil + }) + + ca := &buildapi.CABundle{ + Secret: &buildapi.SourceObjectKeySelector{ + Name: "multi-ca", + Key: cabundle.VolumePath, + }, + } + Expect(cabundle.Validate(ctx, c, ca, namespace)).To(Succeed()) + }) + + It("should fail when the bundle contains mixed CA and non-CA certificates", func() { + certData := newMixedCertsPEM() + c.GetCalls(func(_ context.Context, key types.NamespacedName, obj client.Object, _ ...client.GetOption) error { + s, ok := obj.(*corev1.Secret) + if !ok { + return fmt.Errorf("unexpected object type") + } + s.Name = key.Name + s.Namespace = key.Namespace + s.Data = map[string][]byte{ + cabundle.VolumePath: certData, + } + return nil + }) + + ca := &buildapi.CABundle{ + Secret: &buildapi.SourceObjectKeySelector{ + Name: "mixed-certs", + Key: cabundle.VolumePath, + }, + } + err := cabundle.Validate(ctx, c, ca, namespace) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not CA")) + }) + + It("should fail when the secret contains empty data", func() { + c.GetCalls(func(_ context.Context, key types.NamespacedName, obj client.Object, _ ...client.GetOption) error { + s, ok := obj.(*corev1.Secret) + if !ok { + return fmt.Errorf("unexpected object type") + } + s.Name = key.Name + s.Namespace = key.Namespace + s.Data = map[string][]byte{ + cabundle.VolumePath: []byte(""), + } + return nil + }) + + ca := &buildapi.CABundle{ + Secret: &buildapi.SourceObjectKeySelector{ + Name: "empty-ca", + Key: cabundle.VolumePath, + }, + } + err := cabundle.Validate(ctx, c, ca, namespace) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no certificate present")) + }) + }) + + Context("with a ConfigMap reference", func() { + It("should succeed when the configmap exists and contains a valid CA certificate", func() { + certData := newCACertPEM() + c.GetCalls(func(_ context.Context, key types.NamespacedName, obj client.Object, _ ...client.GetOption) error { + cm, ok := obj.(*corev1.ConfigMap) + if !ok { + return fmt.Errorf("unexpected object type") + } + cm.Name = key.Name + cm.Namespace = key.Namespace + cm.Data = map[string]string{ + cabundle.VolumePath: string(certData), + } + return nil + }) + + ca := &buildapi.CABundle{ + ConfigMap: &buildapi.SourceObjectKeySelector{ + Name: "my-ca-cm", + Key: cabundle.VolumePath, + }, + } + Expect(cabundle.Validate(ctx, c, ca, namespace)).To(Succeed()) + }) + + It("should fail when the configmap does not exist", func() { + c.GetReturns(apierrors.NewNotFound(schema.GroupResource{Resource: "configmaps"}, "missing-cm")) + + ca := &buildapi.CABundle{ + ConfigMap: &buildapi.SourceObjectKeySelector{ + Name: "missing-cm", + Key: cabundle.VolumePath, + }, + } + err := cabundle.Validate(ctx, c, ca, namespace) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsNotFound(err)).To(BeTrue()) + }) + + It("should fail when the configmap contains invalid certificate data", func() { + c.GetCalls(func(_ context.Context, key types.NamespacedName, obj client.Object, _ ...client.GetOption) error { + cm, ok := obj.(*corev1.ConfigMap) + if !ok { + return fmt.Errorf("unexpected object type") + } + cm.Name = key.Name + cm.Namespace = key.Namespace + cm.Data = map[string]string{ + cabundle.VolumePath: "not-a-certificate", + } + return nil + }) + + ca := &buildapi.CABundle{ + ConfigMap: &buildapi.SourceObjectKeySelector{ + Name: "bad-cert-cm", + Key: cabundle.VolumePath, + }, + } + Expect(cabundle.Validate(ctx, c, ca, namespace)).ToNot(Succeed()) + }) + + It("should fail when the certificate is not a CA", func() { + certData := newNonCACertPEM() + c.GetCalls(func(_ context.Context, key types.NamespacedName, obj client.Object, _ ...client.GetOption) error { + cm, ok := obj.(*corev1.ConfigMap) + if !ok { + return fmt.Errorf("unexpected object type") + } + cm.Name = key.Name + cm.Namespace = key.Namespace + cm.Data = map[string]string{ + cabundle.VolumePath: string(certData), + } + return nil + }) + + ca := &buildapi.CABundle{ + ConfigMap: &buildapi.SourceObjectKeySelector{ + Name: "non-ca-cm", + Key: cabundle.VolumePath, + }, + } + err := cabundle.Validate(ctx, c, ca, namespace) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not CA")) + }) + + It("should succeed when the configmap contains multiple valid CA certificates", func() { + certData := newMultipleCACertsPEM() + c.GetCalls(func(_ context.Context, key types.NamespacedName, obj client.Object, _ ...client.GetOption) error { + cm, ok := obj.(*corev1.ConfigMap) + if !ok { + return fmt.Errorf("unexpected object type") + } + cm.Name = key.Name + cm.Namespace = key.Namespace + cm.Data = map[string]string{ + cabundle.VolumePath: string(certData), + } + return nil + }) + + ca := &buildapi.CABundle{ + ConfigMap: &buildapi.SourceObjectKeySelector{ + Name: "multi-ca-cm", + Key: cabundle.VolumePath, + }, + } + Expect(cabundle.Validate(ctx, c, ca, namespace)).To(Succeed()) + }) + + It("should fail when the bundle contains mixed CA and non-CA certificates", func() { + certData := newMixedCertsPEM() + c.GetCalls(func(_ context.Context, key types.NamespacedName, obj client.Object, _ ...client.GetOption) error { + cm, ok := obj.(*corev1.ConfigMap) + if !ok { + return fmt.Errorf("unexpected object type") + } + cm.Name = key.Name + cm.Namespace = key.Namespace + cm.Data = map[string]string{ + cabundle.VolumePath: string(certData), + } + return nil + }) + + ca := &buildapi.CABundle{ + ConfigMap: &buildapi.SourceObjectKeySelector{ + Name: "mixed-certs-cm", + Key: cabundle.VolumePath, + }, + } + err := cabundle.Validate(ctx, c, ca, namespace) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not CA")) + }) + + It("should fail when the configmap contains empty data", func() { + c.GetCalls(func(_ context.Context, key types.NamespacedName, obj client.Object, _ ...client.GetOption) error { + cm, ok := obj.(*corev1.ConfigMap) + if !ok { + return fmt.Errorf("unexpected object type") + } + cm.Name = key.Name + cm.Namespace = key.Namespace + cm.Data = map[string]string{ + cabundle.VolumePath: "", + } + return nil + }) + + ca := &buildapi.CABundle{ + ConfigMap: &buildapi.SourceObjectKeySelector{ + Name: "empty-ca-cm", + Key: cabundle.VolumePath, + }, + } + err := cabundle.Validate(ctx, c, ca, namespace) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no certificate present")) + }) + }) + }) + + Describe("NewVolume", func() { + Context("with a Secret reference", func() { + It("should create a volume with a SecretVolumeSource", func() { + ca := &buildapi.CABundle{ + Secret: &buildapi.SourceObjectKeySelector{ + Name: "my-secret", + Key: "tls.crt", + }, + } + vol := cabundle.NewVolume(ca) + + Expect(vol).ToNot(BeNil()) + Expect(vol.Name).To(ContainSubstring("my-secret")) + Expect(vol.Secret).ToNot(BeNil()) + Expect(vol.Secret.SecretName).To(Equal("my-secret")) + Expect(vol.Secret.Items).To(HaveLen(1)) + Expect(vol.Secret.Items[0].Key).To(Equal("tls.crt")) + Expect(vol.Secret.Items[0].Path).To(Equal(cabundle.VolumePath)) + }) + }) + + Context("with a ConfigMap reference", func() { + It("should create a volume with a ConfigMapVolumeSource", func() { + ca := &buildapi.CABundle{ + ConfigMap: &buildapi.SourceObjectKeySelector{ + Name: "my-configmap", + Key: "ca.pem", + }, + } + vol := cabundle.NewVolume(ca) + + Expect(vol).ToNot(BeNil()) + Expect(vol.ConfigMap).ToNot(BeNil()) + Expect(vol.ConfigMap.Name).To(Equal("my-configmap")) + Expect(vol.ConfigMap.Items).To(HaveLen(1)) + Expect(vol.ConfigMap.Items[0].Key).To(Equal("ca.pem")) + Expect(vol.ConfigMap.Items[0].Path).To(Equal(cabundle.VolumePath)) + }) + }) + + It("should produce a deterministic hashed volume name", func() { + ca := &buildapi.CABundle{ + Secret: &buildapi.SourceObjectKeySelector{ + Name: "my-secret", + Key: cabundle.VolumePath, + }, + } + vol1 := cabundle.NewVolume(ca) + vol2 := cabundle.NewVolume(ca) + + Expect(vol1.Name).To(Equal(vol2.Name)) + }) + }) + + Describe("NewVolumeMount", func() { + It("should create a volume mount for each bundle path", func() { + vol := &corev1.Volume{ + Name: "test-volume", + } + mounts := cabundle.NewVolumeMount(vol) + + Expect(mounts).To(HaveLen(len(cabundle.DefaultBundlePaths))) + for i, mount := range mounts { + Expect(mount.Name).To(Equal("test-volume")) + Expect(mount.MountPath).To(Equal(cabundle.DefaultBundlePaths[i])) + Expect(mount.SubPath).To(Equal(cabundle.VolumePath)) + Expect(mount.ReadOnly).To(BeTrue()) + } + }) + }) + + Describe("NewEnvVar", func() { + It("should create an env var for each entry in EnvVars", func() { + envVars := cabundle.NewEnvVar() + + Expect(envVars).To(HaveLen(len(cabundle.EnvVars))) + for i, ev := range envVars { + Expect(ev.Name).To(Equal(cabundle.EnvVars[i])) + Expect(ev.Value).To(Equal(cabundle.CACertFile)) + } + }) + }) +}) diff --git a/pkg/cabundle/suite_test.go b/pkg/cabundle/suite_test.go new file mode 100644 index 0000000000..d325d0c616 --- /dev/null +++ b/pkg/cabundle/suite_test.go @@ -0,0 +1,17 @@ +// Copyright The Shipwright Contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package cabundle_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestCABundle(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "CABundle Suite") +} diff --git a/pkg/reconciler/build/build.go b/pkg/reconciler/build/build.go index cd037cb31c..ea15e48da4 100644 --- a/pkg/reconciler/build/build.go +++ b/pkg/reconciler/build/build.go @@ -37,6 +37,7 @@ var validationTypes = [...]string{ validate.Tolerations, validate.SchedulerName, validate.RuntimeClassName, + validate.CABundles, } // ReconcileBuild reconciles a Build object diff --git a/pkg/reconciler/buildrun/buildrun.go b/pkg/reconciler/buildrun/buildrun.go index dea8874a1a..8a03cc1cbb 100644 --- a/pkg/reconciler/buildrun/buildrun.go +++ b/pkg/reconciler/buildrun/buildrun.go @@ -159,6 +159,7 @@ func (r *ReconcileBuildRun) Reconcile(ctx context.Context, request reconcile.Req validate.NewNodeSelector(build), validate.NewTolerations(build), validate.NewSchedulerName(build), + validate.NewCABundle(r.client, build), ) // an internal/technical error during validation happened diff --git a/pkg/reconciler/buildrun/resources/cabundle.go b/pkg/reconciler/buildrun/resources/cabundle.go new file mode 100644 index 0000000000..8f4e1f3682 --- /dev/null +++ b/pkg/reconciler/buildrun/resources/cabundle.go @@ -0,0 +1,55 @@ +// Copyright The Shipwright Contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package resources + +import ( + pipelineapi "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + corev1 "k8s.io/api/core/v1" + + buildapi "github.com/shipwright-io/build/pkg/apis/build/v1beta1" + "github.com/shipwright-io/build/pkg/cabundle" +) + +// hasEnvVarByName checks if an environment variable with the given name already exists +func hasEnvVarByName(envs []corev1.EnvVar, name string) bool { + for _, env := range envs { + if env.Name == name { + return true + } + } + return false +} + +func applyCABundle(taskSpec *pipelineapi.TaskSpec, build *buildapi.Build, buildRun *buildapi.BuildRun) error { + var ca *buildapi.CABundle + + if taskSpec == nil { + return nil + } + + switch { + case buildRun.Spec.CABundle != nil: + ca = buildRun.Spec.CABundle + case build.Spec.CABundle != nil: + ca = build.Spec.CABundle + default: + return nil + } + + envVar := cabundle.NewEnvVar() + volume := cabundle.NewVolume(ca) + + taskSpec.Volumes = append(taskSpec.Volumes, *volume) + for n, step := range taskSpec.Steps { + for _, e := range envVar { + if !hasEnvVarByName(step.Env, e.Name) { + taskSpec.Steps[n].Env = append(step.Env, e) + } + } + taskSpec.Steps[n].VolumeMounts = append(step.VolumeMounts, cabundle.NewVolumeMount(volume)...) + } + + return nil +} diff --git a/pkg/reconciler/buildrun/resources/pipelinerun_generator.go b/pkg/reconciler/buildrun/resources/pipelinerun_generator.go index a5242ba026..7a20fae21b 100644 --- a/pkg/reconciler/buildrun/resources/pipelinerun_generator.go +++ b/pkg/reconciler/buildrun/resources/pipelinerun_generator.go @@ -222,6 +222,12 @@ func (g *PipelineRunGenerator) ApplyInfrastructureConfiguration() error { } } + for n := range g.pipelineTasks { + if err := applyCABundle(&g.pipelineTasks[n].TaskSpec.TaskSpec, g.build, g.buildRun); err != nil { + return err + } + } + return nil } diff --git a/pkg/reconciler/buildrun/resources/resource_builders.go b/pkg/reconciler/buildrun/resources/resource_builders.go index 00ae383d58..d1748d5578 100644 --- a/pkg/reconciler/buildrun/resources/resource_builders.go +++ b/pkg/reconciler/buildrun/resources/resource_builders.go @@ -18,6 +18,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" buildapi "github.com/shipwright-io/build/pkg/apis/build/v1beta1" + "github.com/shipwright-io/build/pkg/cabundle" "github.com/shipwright-io/build/pkg/config" "github.com/shipwright-io/build/pkg/env" "github.com/shipwright-io/build/pkg/reconciler/buildrun/resources/steps" @@ -54,6 +55,11 @@ func generateBaseTaskSpecParams() []pipelineapi.ParamSpec { Description: "The source directory", Type: pipelineapi.ParamTypeString, }, + { + Name: fmt.Sprintf("%s-%s", prefixParamsResultsVolumes, paramCABundle), + Description: "The CA Bundle file used to access secure endpoints", + Type: pipelineapi.ParamTypeString, + }, } } @@ -64,11 +70,12 @@ func generateBaseTaskParamReferences() []pipelineapi.Param { {Name: fmt.Sprintf("%s-%s", prefixParamsResultsVolumes, paramOutputInsecure), Value: pipelineapi.ParamValue{Type: pipelineapi.ParamTypeString, StringVal: fmt.Sprintf("$(params.%s-%s)", prefixParamsResultsVolumes, paramOutputInsecure)}}, {Name: fmt.Sprintf("%s-%s", prefixParamsResultsVolumes, paramSourceRoot), Value: pipelineapi.ParamValue{Type: pipelineapi.ParamTypeString, StringVal: fmt.Sprintf("$(params.%s-%s)", prefixParamsResultsVolumes, paramSourceRoot)}}, {Name: fmt.Sprintf("%s-%s", prefixParamsResultsVolumes, paramSourceContext), Value: pipelineapi.ParamValue{Type: pipelineapi.ParamTypeString, StringVal: fmt.Sprintf("$(params.%s-%s)", prefixParamsResultsVolumes, paramSourceContext)}}, + {Name: fmt.Sprintf("%s-%s", prefixParamsResultsVolumes, paramCABundle), Value: pipelineapi.ParamValue{Type: pipelineapi.ParamTypeString, StringVal: fmt.Sprintf("$(params.%s-%s)", prefixParamsResultsVolumes, paramCABundle)}}, } } func generateBaseParamValues(build *buildapi.Build, buildRun *buildapi.BuildRun) []pipelineapi.Param { - var image string + var image, caBundle string if buildRun.Spec.Output != nil { image = buildRun.Spec.Output.Image } else { @@ -82,6 +89,10 @@ func generateBaseParamValues(build *buildapi.Build, buildRun *buildapi.BuildRun) insecure = *build.Spec.Output.Insecure } + if build.Spec.CABundle != nil || buildRun.Spec.CABundle != nil { + caBundle = cabundle.CACertFile + } + params := []pipelineapi.Param{ { Name: fmt.Sprintf("%s-%s", prefixParamsResultsVolumes, paramOutputImage), @@ -104,6 +115,13 @@ func generateBaseParamValues(build *buildapi.Build, buildRun *buildapi.BuildRun) StringVal: "/workspace/source", }, }, + { + Name: fmt.Sprintf("%s-%s", prefixParamsResultsVolumes, paramCABundle), + Value: pipelineapi.ParamValue{ + Type: pipelineapi.ParamTypeString, + StringVal: caBundle, + }, + }, } if build.Spec.Source != nil && build.Spec.Source.ContextDir != nil { @@ -469,6 +487,13 @@ func applyParameters(taskRun *pipelineapi.TaskRun, build *buildapi.Build, buildR StringVal: "/workspace/source", }, }, + { + Name: fmt.Sprintf("%s-%s", prefixParamsResultsVolumes, paramCABundle), + Value: pipelineapi.ParamValue{ + Type: pipelineapi.ParamTypeString, + StringVal: cabundle.CACertFile, + }, + }, } if build.Spec.Source != nil && build.Spec.Source.ContextDir != nil { diff --git a/pkg/reconciler/buildrun/resources/taskrun.go b/pkg/reconciler/buildrun/resources/taskrun.go index 6ec5b88b77..6a93f6b452 100644 --- a/pkg/reconciler/buildrun/resources/taskrun.go +++ b/pkg/reconciler/buildrun/resources/taskrun.go @@ -21,6 +21,7 @@ const ( paramSourceRoot = "source-root" paramSourceContext = "source-context" paramOutputDirectory = "output-directory" + paramCABundle = "ca-bundle" workspaceSource = "source" ) diff --git a/pkg/reconciler/buildrun/resources/taskrun_generator.go b/pkg/reconciler/buildrun/resources/taskrun_generator.go index a98b272bd2..06179993d7 100644 --- a/pkg/reconciler/buildrun/resources/taskrun_generator.go +++ b/pkg/reconciler/buildrun/resources/taskrun_generator.go @@ -83,13 +83,17 @@ func (g *TaskRunGenerator) GenerateBuildStrategyPhase(execCtx *executionContext) execCtx.volumeMounts = volumeMounts - return generateTaskSpecVolumes( + if err = generateTaskSpecVolumes( g.taskRun.Spec.TaskSpec, execCtx.volumeMounts, execCtx.strategyVolumes, execCtx.buildVolumes, execCtx.buildRunVolumes, - ) + ); err != nil { + return err + } + + return nil } func (g *TaskRunGenerator) GenerateOutputImagePhase(_ *executionContext) error { @@ -98,7 +102,11 @@ func (g *TaskRunGenerator) GenerateOutputImagePhase(_ *executionContext) error { buildRunOutput = &buildapi.Image{} } - return SetupImageProcessing(g.taskRun, g.cfg, g.buildRun.CreationTimestamp.Time, g.build.Spec.Output, *buildRunOutput) + if err := SetupImageProcessing(g.taskRun, g.cfg, g.buildRun.CreationTimestamp.Time, g.build.Spec.Output, *buildRunOutput); err != nil { + return err + } + + return nil } func (g *TaskRunGenerator) ApplyInfrastructureConfiguration() error { @@ -114,7 +122,15 @@ func (g *TaskRunGenerator) ApplyInfrastructureConfiguration() error { return err } - return applyScheduler(g.taskRun, g.build, g.buildRun) + if err := applyScheduler(g.taskRun, g.build, g.buildRun); err != nil { + return err + } + + if err := applyCABundle(g.taskRun.Spec.TaskSpec, g.build, g.buildRun); err != nil { + return err + } + + return nil } func (g *TaskRunGenerator) ApplyMetadataConfiguration() error { @@ -126,7 +142,11 @@ func (g *TaskRunGenerator) ApplyMetadataConfiguration() error { return err } - return applyParameters(g.taskRun, g.build, g.buildRun, g.strategy) + if err := applyParameters(g.taskRun, g.build, g.buildRun, g.strategy); err != nil { + return err + } + + return nil } func (g *TaskRunGenerator) GetExecutor() client.Object { diff --git a/pkg/reconciler/buildrun/resources/taskrun_test.go b/pkg/reconciler/buildrun/resources/taskrun_test.go index cf269d6df2..48edfae972 100644 --- a/pkg/reconciler/buildrun/resources/taskrun_test.go +++ b/pkg/reconciler/buildrun/resources/taskrun_test.go @@ -109,6 +109,7 @@ var _ = Describe("TaskRun Unit Tests", func() { Expect(paramNames).To(HaveKey("shp-output-insecure")) Expect(paramNames).To(HaveKey("shp-source-root")) Expect(paramNames).To(HaveKey("shp-source-context")) + Expect(paramNames).To(HaveKey("shp-ca-bundle")) }) It("should include TaskSpec parameters", func() { @@ -125,6 +126,7 @@ var _ = Describe("TaskRun Unit Tests", func() { Expect(taskSpecParamNames).To(HaveKey("shp-output-insecure")) Expect(taskSpecParamNames).To(HaveKey("shp-source-root")) Expect(taskSpecParamNames).To(HaveKey("shp-source-context")) + Expect(taskSpecParamNames).To(HaveKey("shp-ca-bundle")) }) It("should include workspaces", func() { diff --git a/pkg/validate/cabundle.go b/pkg/validate/cabundle.go new file mode 100644 index 0000000000..9259c1466d --- /dev/null +++ b/pkg/validate/cabundle.go @@ -0,0 +1,52 @@ +// Copyright The Shipwright Contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package validate + +import ( + "context" + "errors" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + + buildapi "github.com/shipwright-io/build/pkg/apis/build/v1beta1" + "github.com/shipwright-io/build/pkg/cabundle" +) + +// CABundle contains all required fields +// to validate a Build spec certificate definitions +type CABundle struct { + Build *buildapi.Build + Client client.Client +} + +func NewCABundle(client client.Client, build *buildapi.Build) *CABundle { + return &CABundle{build, client} +} + +// ValidatePath implements BuildPath interface and validates +// that all referenced secrets or configmaps under certificate exists +func (c *CABundle) ValidatePath(ctx context.Context) error { + // Skip validation if no CA bundle is specified + if c.Build.Spec.CABundle == nil { + return nil + } + var statusError *apierrors.StatusError + if err := cabundle.Validate(ctx, c.Client, c.Build.Spec.CABundle, c.Build.Namespace); err != nil { + switch { + case apierrors.IsNotFound(err): + c.Build.Status.Reason = ptr.To[buildapi.BuildReason](buildapi.CABundleNotFound) + c.Build.Status.Message = ptr.To(err.Error()) + case !errors.As(err, &statusError): + c.Build.Status.Reason = ptr.To[buildapi.BuildReason](buildapi.CABundleNotValid) + c.Build.Status.Message = ptr.To(err.Error()) + default: + return err + } + } + + return nil +} diff --git a/pkg/validate/cabundle_test.go b/pkg/validate/cabundle_test.go new file mode 100644 index 0000000000..fd82248b6f --- /dev/null +++ b/pkg/validate/cabundle_test.go @@ -0,0 +1,181 @@ +// Copyright The Shipwright Contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package validate_test + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "fmt" + "math/big" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + buildapi "github.com/shipwright-io/build/pkg/apis/build/v1beta1" + "github.com/shipwright-io/build/pkg/cabundle" + "github.com/shipwright-io/build/pkg/controller/fakes" + "github.com/shipwright-io/build/pkg/validate" +) + +func newCACertPEM() []byte { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + Expect(err).ToNot(HaveOccurred()) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + Expect(err).ToNot(HaveOccurred()) + + return pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: der, + }) +} + +var _ = Describe("CABundle", func() { + Context("ValidatePath", func() { + var ( + ctx context.Context + c *fakes.FakeClient + build *buildapi.Build + bundle *validate.CABundle + ) + + BeforeEach(func() { + ctx = context.Background() + c = &fakes.FakeClient{} + build = &buildapi.Build{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-build", + Namespace: "default", + }, + } + }) + + It("should successfully validate when no CA bundle is specified", func() { + bundle = validate.NewCABundle(c, build) + Expect(bundle.ValidatePath(ctx)).To(BeNil()) + Expect(build.Status.Reason).To(BeNil()) + Expect(build.Status.Message).To(BeNil()) + }) + + It("should successfully validate a valid CA bundle from Secret", func() { + certData := newCACertPEM() + build.Spec.CABundle = &buildapi.CABundle{ + Secret: &buildapi.SourceObjectKeySelector{ + Name: "my-ca", + Key: cabundle.VolumePath, + }, + } + + c.GetCalls(func(_ context.Context, key types.NamespacedName, obj client.Object, _ ...client.GetOption) error { + s, ok := obj.(*corev1.Secret) + if !ok { + return fmt.Errorf("unexpected object type") + } + s.Name = key.Name + s.Namespace = key.Namespace + s.Data = map[string][]byte{ + cabundle.VolumePath: certData, + } + return nil + }) + + bundle = validate.NewCABundle(c, build) + Expect(bundle.ValidatePath(ctx)).To(BeNil()) + Expect(build.Status.Reason).To(BeNil()) + Expect(build.Status.Message).To(BeNil()) + }) + + It("should set status when CA bundle Secret is not found", func() { + build.Spec.CABundle = &buildapi.CABundle{ + Secret: &buildapi.SourceObjectKeySelector{ + Name: "missing-ca", + Key: cabundle.VolumePath, + }, + } + + c.GetReturns(apierrors.NewNotFound(schema.GroupResource{Resource: "secrets"}, "missing-ca")) + + bundle = validate.NewCABundle(c, build) + Expect(bundle.ValidatePath(ctx)).To(BeNil()) + Expect(build.Status.Reason).ToNot(BeNil()) + Expect(*build.Status.Reason).To(Equal(buildapi.CABundleNotFound)) + Expect(build.Status.Message).ToNot(BeNil()) + }) + + It("should set status when CA bundle contains invalid certificate data", func() { + build.Spec.CABundle = &buildapi.CABundle{ + Secret: &buildapi.SourceObjectKeySelector{ + Name: "bad-ca", + Key: cabundle.VolumePath, + }, + } + + c.GetCalls(func(_ context.Context, key types.NamespacedName, obj client.Object, _ ...client.GetOption) error { + s, ok := obj.(*corev1.Secret) + if !ok { + return fmt.Errorf("unexpected object type") + } + s.Name = key.Name + s.Namespace = key.Namespace + s.Data = map[string][]byte{ + cabundle.VolumePath: []byte("not-a-certificate"), + } + return nil + }) + + bundle = validate.NewCABundle(c, build) + Expect(bundle.ValidatePath(ctx)).To(BeNil()) + Expect(build.Status.Reason).ToNot(BeNil()) + Expect(*build.Status.Reason).To(Equal(buildapi.CABundleNotValid)) + Expect(build.Status.Message).ToNot(BeNil()) + }) + + It("should successfully validate a valid CA bundle from ConfigMap", func() { + certData := newCACertPEM() + build.Spec.CABundle = &buildapi.CABundle{ + ConfigMap: &buildapi.SourceObjectKeySelector{ + Name: "my-ca-cm", + Key: cabundle.VolumePath, + }, + } + + c.GetCalls(func(_ context.Context, key types.NamespacedName, obj client.Object, _ ...client.GetOption) error { + cm, ok := obj.(*corev1.ConfigMap) + if !ok { + return fmt.Errorf("unexpected object type") + } + cm.Name = key.Name + cm.Namespace = key.Namespace + cm.Data = map[string]string{ + cabundle.VolumePath: string(certData), + } + return nil + }) + + bundle = validate.NewCABundle(c, build) + Expect(bundle.ValidatePath(ctx)).To(BeNil()) + Expect(build.Status.Reason).To(BeNil()) + Expect(build.Status.Message).To(BeNil()) + }) + }) +}) diff --git a/pkg/validate/validate.go b/pkg/validate/validate.go index fdc4390383..19763f5abd 100644 --- a/pkg/validate/validate.go +++ b/pkg/validate/validate.go @@ -43,6 +43,8 @@ const ( SchedulerName = "schedulername" // RuntimeClassName for validating `spec.runtimeClassName` entry RuntimeClassName = "runtimeclassname" + // CABundles for validating `spec.caBundle` entry + CABundles = "cabundles" ) const ( @@ -65,6 +67,8 @@ func NewValidation( scheme *runtime.Scheme, ) (BuildPath, error) { switch validationType { + case CABundles: + return &CABundle{Build: build, Client: client}, nil case Secrets: return &Credentials{Build: build, Client: client}, nil case Strategies: