diff --git a/cmd/shipwright-build-webhook/main.go b/cmd/shipwright-build-webhook/main.go new file mode 100644 index 0000000000..63c9cbde36 --- /dev/null +++ b/cmd/shipwright-build-webhook/main.go @@ -0,0 +1,109 @@ +// Copyright The Shipwright Contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "crypto/tls" + "flag" + "fmt" + "net/http" + "os" + "path" + "runtime" + "time" + + "github.com/shipwright-io/build/pkg/ctxlog" + "github.com/shipwright-io/build/pkg/webhook/conversion" + "github.com/shipwright-io/build/version" + "github.com/spf13/pflag" + "knative.dev/pkg/signals" +) + +var ( + versionGiven = flag.String("version", "devel", "Version of Shipwright webhook running") +) + +func printVersion(ctx context.Context) { + ctxlog.Info(ctx, fmt.Sprintf("Shipwright Build Webhook Version: %s", version.Version)) + ctxlog.Info(ctx, fmt.Sprintf("Go Version: %s", runtime.Version())) + ctxlog.Info(ctx, fmt.Sprintf("Go OS/Arch: %s/%s", runtime.GOOS, runtime.GOARCH)) +} + +func main() { + // Add the zap logger flag set to the CLI. The flag set must + // be added before calling pflag.Parse(). + pflag.CommandLine.AddGoFlagSet(ctxlog.CustomZapFlagSet()) + + // Add flags registered by imported packages (e.g. glog and + // controller-runtime) + pflag.CommandLine.AddGoFlagSet(flag.CommandLine) + + pflag.Parse() + + if err := Execute(); err != nil { + os.Exit(1) + } + +} + +func Execute() error { + l := ctxlog.NewLogger("shp-build-webhook") + + ctx := ctxlog.NewParentContext(l) + + version.SetVersion(*versionGiven) + printVersion(ctx) + + mux := http.NewServeMux() + mux.HandleFunc("/health", health) + ctxlog.Info(ctx, "adding handlefunc() /health") + + // convert endpoint handles ConversionReview API object serialized to JSON + mux.HandleFunc("/convert", conversion.CRDConvertHandler(ctx)) + ctxlog.Info(ctx, "adding handlefunc() /convert") + + server := &http.Server{ + Addr: ":8443", + Handler: mux, + ReadHeaderTimeout: 32 * time.Second, + TLSConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + CurvePreferences: []tls.CurveID{tls.CurveP256, tls.CurveP384, tls.X25519}, + CipherSuites: []uint16{ + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, + }, + }, + } + + go func() { + ctxlog.Info(ctx, "starting webhook server") + // blocking call, returns on error + if err := server.ListenAndServeTLS(path.Join("/tmp/k8s-webhook-server/serving-certs", "tls.crt"), path.Join("/tmp/k8s-webhook-server/serving-certs", "tls.key")); err != nil { + ctxlog.Error(ctx, err, "webhook server failed to start") + } + }() + + stopCh := signals.SetupSignalHandler() + sig := <-stopCh + + l.Info("Shutting down server.", "signal", sig) + ctxlog.Info(ctx, "shutting down webhook server,", "signal:", sig) + if err := server.Shutdown(context.Background()); err != nil { + l.Error(err, "Failed to gracefully shutdown the server.") + return err + } + return nil + +} + +func health(resp http.ResponseWriter, req *http.Request) { + resp.WriteHeader(http.StatusNoContent) +} diff --git a/pkg/apis/build/v1beta1/build_conversion.go b/pkg/apis/build/v1beta1/build_conversion.go index 4dce84fec8..c68c375715 100644 --- a/pkg/apis/build/v1beta1/build_conversion.go +++ b/pkg/apis/build/v1beta1/build_conversion.go @@ -28,10 +28,12 @@ func (srcSpec *BuildSpec) ConvertTo(bs *v1alpha1.BuildSpec) error { // we only have a single source // BuildSpec Trigger - for _, t := range srcSpec.Trigger.When { - tw := v1alpha1.TriggerWhen{} - t.convertTo(&tw) - bs.Trigger.When = append(bs.Trigger.When, tw) + if srcSpec.Trigger != nil { + for _, t := range srcSpec.Trigger.When { + tw := v1alpha1.TriggerWhen{} + t.convertTo(&tw) + bs.Trigger.When = append(bs.Trigger.When, tw) + } } // BuildSpec Strategy @@ -59,7 +61,9 @@ func (srcSpec *BuildSpec) ConvertTo(bs *v1alpha1.BuildSpec) error { insecure := false bs.Output.Image = srcSpec.Output.Image bs.Output.Insecure = &insecure - bs.Output.Credentials.Name = *srcSpec.Output.PushSecret + if srcSpec.Output.PushSecret != nil { + bs.Output.Credentials.Name = *srcSpec.Output.PushSecret + } bs.Output.Annotations = srcSpec.Output.Annotations bs.Output.Labels = srcSpec.Output.Labels @@ -70,10 +74,19 @@ func (srcSpec *BuildSpec) ConvertTo(bs *v1alpha1.BuildSpec) error { bs.Env = srcSpec.Env // BuildSpec Retention - bs.Retention.FailedLimit = srcSpec.Retention.FailedLimit - bs.Retention.SucceededLimit = srcSpec.Retention.SucceededLimit - bs.Retention.TTLAfterFailed = srcSpec.Retention.TTLAfterFailed - bs.Retention.TTLAfterSucceeded = srcSpec.Retention.TTLAfterSucceeded + if srcSpec.Retention != nil && srcSpec.Retention.FailedLimit != nil { + bs.Retention.FailedLimit = srcSpec.Retention.FailedLimit + } + if srcSpec.Retention != nil && srcSpec.Retention.SucceededLimit != nil { + + bs.Retention.SucceededLimit = srcSpec.Retention.SucceededLimit + } + if srcSpec.Retention != nil && srcSpec.Retention.TTLAfterFailed != nil { + bs.Retention.TTLAfterFailed = srcSpec.Retention.TTLAfterFailed + } + if srcSpec.Retention != nil && srcSpec.Retention.TTLAfterSucceeded != nil { + bs.Retention.TTLAfterSucceeded = srcSpec.Retention.TTLAfterSucceeded + } // BuildSpec Volumes for i, vol := range srcSpec.Volumes { @@ -93,6 +106,9 @@ func (dst *Build) ConvertFrom(srcRaw conversion.Hub) error { // todo: could be placed in its own file func (p ParamValue) convertTo(dest *v1alpha1.ParamValue) { + if p.SingleValue == nil || p.SingleValue.Value == nil { + return + } dest.Value = p.Value dest.ConfigMapValue = (*v1alpha1.ObjectKeyRef)(p.ConfigMapValue) dest.SecretValue = (*v1alpha1.ObjectKeyRef)(p.SecretValue) @@ -137,8 +153,10 @@ func getBuildSource(src BuildSpec) v1alpha1.Source { Prune: (*v1alpha1.PruneOption)(src.Source.OCIArtifact.Prune), } default: - credentials = corev1.LocalObjectReference{ - Name: *src.Source.GitSource.CloneSecret, + if *&src.Source.GitSource.CloneSecret != nil { + credentials = corev1.LocalObjectReference{ + Name: *src.Source.GitSource.CloneSecret, + } } source.URL = src.Source.GitSource.URL revision = src.Source.GitSource.Revision diff --git a/pkg/webhook/conversion/conversion.go b/pkg/webhook/conversion/conversion.go new file mode 100644 index 0000000000..67eea5fe10 --- /dev/null +++ b/pkg/webhook/conversion/conversion.go @@ -0,0 +1,193 @@ +// Copyright The Shipwright Contributors +// +// SPDX-License-Identifier: Apache-2.0 +package conversion + +/* +* This code is influenced by the conversion webhook example +* tested in the Kubernetes E2E(see https://github.com/kubernetes/kubernetes/tree/v1.25.3/test/images/agnhost/crd-conversion-webhook/converter), +* as mentioned in the Kubernetes official documentation: https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definition-versioning/#write-a-conversion-webhook-server + */ + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/munnerz/goautoneg" + "github.com/shipwright-io/build/pkg/ctxlog" + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer/json" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" +) + +var scheme = runtime.NewScheme() + +func init() { + addToScheme(scheme) +} + +func addToScheme(scheme *runtime.Scheme) { + utilruntime.Must(v1.AddToScheme(scheme)) + utilruntime.Must(beta1.AddToScheme(scheme)) + +} + +var serializers = map[mediaType]runtime.Serializer{ + {"application", "json"}: json.NewSerializerWithOptions(json.DefaultMetaFactory, scheme, scheme, json.SerializerOptions{Pretty: false}), + {"application", "yaml"}: json.NewSerializerWithOptions(json.DefaultMetaFactory, scheme, scheme, json.SerializerOptions{Yaml: true}), +} + +type mediaType struct { + Type, SubType string +} + +// convertFunc serves as the Custom Resource conversiob function +type convertFunc func(Object *unstructured.Unstructured, version string, ctx context.Context) (*unstructured.Unstructured, metav1.Status) + +func CRDConvertHandler(ctx context.Context) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + CRDConvert(w, r, ctx) + } +} + +func CRDConvert(w http.ResponseWriter, r *http.Request, ctx context.Context) { + serve(w, r, convertSHPCR, ctx) +} + +// serve handles a ConversionReview object type, it will process a ConversionRequest object +// and convert that into a ConversionResponse one. +func serve(w http.ResponseWriter, r *http.Request, convert convertFunc, ctx context.Context) { + var body []byte + if r.Body != nil { + if data, err := io.ReadAll(r.Body); err == nil { + body = data + } + } + + contentType := r.Header.Get("Content-Type") + serializer := getInputSerializer(contentType) + if serializer == nil { + msg := fmt.Sprintf("invalid Content-Type header `%s`", contentType) + ctxlog.Error(ctx, errors.New(msg), "invalid header") + http.Error(w, msg, http.StatusBadRequest) + return + } + + ctxlog.Info(ctx, "handling request: %v", body) + + obj, gvk, err := serializer.Decode(body, nil, nil) + if err != nil { + msg := fmt.Sprintf("failed to deserialize body (%v) with error %v", string(body), err) + ctxlog.Error(ctx, errors.New(msg), "failed to deserialize") + http.Error(w, msg, http.StatusBadRequest) + return + } + var responseObj runtime.Object + switch *gvk { + case v1.SchemeGroupVersion.WithKind("ConversionReview"): + convertReview, ok := obj.(*v1.ConversionReview) + if !ok { + msg := fmt.Sprintf("Expected v1beta1.ConversionReview but got: %T", obj) + ctxlog.Error(ctx, errors.New(msg), "unexpected kind") + http.Error(w, msg, http.StatusBadRequest) + return + } + convertReview.Response = doConversionToV1alpha1(convertReview.Request, convert, ctx) + convertReview.Response.UID = convertReview.Request.UID + ctxlog.Info(ctx, fmt.Sprintf("sending response: %v", convertReview.Response)) + + convertReview.Request = &v1.ConversionRequest{} + responseObj = convertReview + default: + msg := fmt.Sprintf("Unsupported group version kind: %v", gvk) + ctxlog.Error(ctx, errors.New(msg), "unknown group/version/kind") + http.Error(w, msg, http.StatusBadRequest) + return + } + + accept := r.Header.Get("Accept") + outSerializer := getOutputSerializer(accept) + if outSerializer == nil { + msg := fmt.Sprintf("invalid accept header `%s`", accept) + ctxlog.Error(ctx, errors.New(msg), "invalid header") + http.Error(w, msg, http.StatusBadRequest) + return + } + err = outSerializer.Encode(responseObj, w) + if err != nil { + ctxlog.Error(ctx, err, "outserializer enconding failed") + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +func getInputSerializer(contentType string) runtime.Serializer { + parts := strings.SplitN(contentType, "/", 2) + if len(parts) != 2 { + return nil + } + return serializers[mediaType{parts[0], parts[1]}] +} + +func getOutputSerializer(accept string) runtime.Serializer { + if len(accept) == 0 { + return serializers[mediaType{"application", "json"}] + } + clauses := goautoneg.ParseAccept(accept) + for _, clause := range clauses { + for k, v := range serializers { + switch { + case clause.Type == k.Type && clause.SubType == k.SubType, + clause.Type == k.Type && clause.SubType == "*", + clause.Type == "*" && clause.SubType == "*": + return v + } + } + } + return nil +} + +// doConversionToV1alpha1 takes the v1beta1 CR in the v1 ConversionRequest using the convert function +// and returns a ConversionResponse with a v1alpha1 CR +func doConversionToV1alpha1(convertRequest *v1.ConversionRequest, convert convertFunc, ctx context.Context) *v1.ConversionResponse { + var convertedObjects []runtime.RawExtension + for _, obj := range convertRequest.Objects { + cr := unstructured.Unstructured{} + if err := cr.UnmarshalJSON(obj.Raw); err != nil { + ctxlog.Error(ctx, err, "unmarshalling json on convertrequest") + return &v1.ConversionResponse{ + Result: metav1.Status{ + Message: fmt.Sprintf("failed to unmarshall object (%v) with error: %v", string(obj.Raw), err), + Status: metav1.StatusFailure, + }, + } + } + convertedCR, status := convert(&cr, convertRequest.DesiredAPIVersion, ctx) + if status.Status != metav1.StatusSuccess { + ctxlog.Error(ctx, errors.New(status.String()), "status is not success") + return &v1.ConversionResponse{ + Result: status, + } + } + convertedCR.SetAPIVersion(convertRequest.DesiredAPIVersion) + convertedObjects = append(convertedObjects, runtime.RawExtension{Object: convertedCR}) + } + return &v1.ConversionResponse{ + ConvertedObjects: convertedObjects, + Result: statusSucceed(), + } +} + +func statusSucceed() metav1.Status { + return metav1.Status{ + Status: metav1.StatusSuccess, + } +} diff --git a/pkg/webhook/conversion/converter.go b/pkg/webhook/conversion/converter.go new file mode 100644 index 0000000000..ad8b41bce8 --- /dev/null +++ b/pkg/webhook/conversion/converter.go @@ -0,0 +1,76 @@ +// Copyright The Shipwright Contributors +// +// SPDX-License-Identifier: Apache-2.0 +package conversion + +import ( + "context" + "fmt" + + "github.com/shipwright-io/build/pkg/apis/build/v1alpha1" + "github.com/shipwright-io/build/pkg/apis/build/v1beta1" + "github.com/shipwright-io/build/pkg/ctxlog" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" +) + +// convertSHPCR takes an unstructured object with certain CR apiversion, parse it to a know Object type, +// modify the type to a desired version of that type, and convert it again to unstructured +func convertSHPCR(Object *unstructured.Unstructured, toVersion string, ctx context.Context) (*unstructured.Unstructured, metav1.Status) { + ctxlog.Info(ctx, "converting custom resource") + + convertedObject := Object.DeepCopy() + fromVersion := Object.GetAPIVersion() + + if fromVersion == "shipwright.io/v1alpha1" { + return convertedObject, statusSucceed() + } + + switch Object.GetAPIVersion() { + case "shipwright.io/v1beta1": + switch toVersion { + + case "shipwright.io/v1alpha1": + if convertedObject.Object["kind"] == "Build" { + + // Convert the unstructured object to cluster. + unstructured := convertedObject.UnstructuredContent() + var build v1beta1.Build + err := runtime.DefaultUnstructuredConverter.FromUnstructured(unstructured, &build) + if err != nil { + ctxlog.Error(ctx, err, "failed unstructuring the convertedObject") + } + var buildAlpha v1alpha1.Build + + buildAlpha.TypeMeta = build.TypeMeta + buildAlpha.TypeMeta.APIVersion = "shipwright.io/v1alpha1" + buildAlpha.ObjectMeta = build.ObjectMeta + + build.Spec.ConvertTo(&buildAlpha.Spec) + + mapito, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&buildAlpha) + if err != nil { + ctxlog.Error(ctx, err, "failed structuring the newObject") + } + convertedObject.Object = mapito + + } else { + return nil, statusErrorWithMessage("unsupported Kind") + } + default: + return nil, statusErrorWithMessage("unexpected conversion version to %q", toVersion) + } + default: + return nil, statusErrorWithMessage("unexpected conversion version from %q", fromVersion) + } + return convertedObject, statusSucceed() +} + +func statusErrorWithMessage(msg string, params ...interface{}) metav1.Status { + return metav1.Status{ + Message: fmt.Sprintf(msg, params...), + Status: metav1.StatusFailure, + } +} diff --git a/pkg/webhook/conversion/converter_test.go b/pkg/webhook/conversion/converter_test.go new file mode 100644 index 0000000000..6e6d6f8d30 --- /dev/null +++ b/pkg/webhook/conversion/converter_test.go @@ -0,0 +1,4 @@ +// Copyright The Shipwright Contributors +// +// SPDX-License-Identifier: Apache-2.0 +package conversion