diff --git a/api/v1beta1/clusterclass_types.go b/api/v1beta1/clusterclass_types.go index 6b0d37aae40f..a2dcab4c9cec 100644 --- a/api/v1beta1/clusterclass_types.go +++ b/api/v1beta1/clusterclass_types.go @@ -338,7 +338,13 @@ type ClusterClassPatch struct { // Definitions define the patches inline. // Note: Patches will be applied in the order of the array. - Definitions []PatchDefinition `json:"definitions"` + // +optional + Definitions []PatchDefinition `json:"definitions,omitempty"` + + // External defines an external patch. + // FIXME(sbueringer): internal or external only => validate + // +optional + External *ExternalPatchDefinition `json:"external,omitempty"` } // PatchDefinition defines a patch which is applied to customize the referenced templates. @@ -441,6 +447,18 @@ type JSONPatchValue struct { Template *string `json:"template,omitempty"` } +// ExternalPatchDefinition defines an external patch. +type ExternalPatchDefinition struct { + // GenerateExtension references an extension which is called to generate patches. + // FIXME(sbueringer): Q: Fine that GenerateExtension is optional as well? + // +optional + GenerateExtension *string `json:"generateExtension,omitempty"` + + // GenerateExtension references an extension which is called to validate the topology. + // +optional + ValidateExtension *string `json:"validateExtension,omitempty"` +} + // LocalObjectTemplate defines a template for a topology Class. type LocalObjectTemplate struct { // Ref is a required reference to a custom resource diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index a35644d945a2..db145ed1c65e 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -170,6 +170,11 @@ func (in *ClusterClassPatch) DeepCopyInto(out *ClusterClassPatch) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.External != nil { + in, out := &in.External, &out.External + *out = new(ExternalPatchDefinition) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterClassPatch. @@ -473,6 +478,31 @@ func (in *ControlPlaneTopology) DeepCopy() *ControlPlaneTopology { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExternalPatchDefinition) DeepCopyInto(out *ExternalPatchDefinition) { + *out = *in + if in.GenerateExtension != nil { + in, out := &in.GenerateExtension, &out.GenerateExtension + *out = new(string) + **out = **in + } + if in.ValidateExtension != nil { + in, out := &in.ValidateExtension, &out.ValidateExtension + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalPatchDefinition. +func (in *ExternalPatchDefinition) DeepCopy() *ExternalPatchDefinition { + if in == nil { + return nil + } + out := new(ExternalPatchDefinition) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FailureDomainSpec) DeepCopyInto(out *FailureDomainSpec) { *out = *in diff --git a/api/v1beta1/zz_generated.openapi.go b/api/v1beta1/zz_generated.openapi.go index 7a9e1fea8d40..7cd3969b28c9 100644 --- a/api/v1beta1/zz_generated.openapi.go +++ b/api/v1beta1/zz_generated.openapi.go @@ -46,6 +46,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "sigs.k8s.io/cluster-api/api/v1beta1.Condition": schema_sigsk8sio_cluster_api_api_v1beta1_Condition(ref), "sigs.k8s.io/cluster-api/api/v1beta1.ControlPlaneClass": schema_sigsk8sio_cluster_api_api_v1beta1_ControlPlaneClass(ref), "sigs.k8s.io/cluster-api/api/v1beta1.ControlPlaneTopology": schema_sigsk8sio_cluster_api_api_v1beta1_ControlPlaneTopology(ref), + "sigs.k8s.io/cluster-api/api/v1beta1.ExternalPatchDefinition": schema_sigsk8sio_cluster_api_api_v1beta1_ExternalPatchDefinition(ref), "sigs.k8s.io/cluster-api/api/v1beta1.FailureDomainSpec": schema_sigsk8sio_cluster_api_api_v1beta1_FailureDomainSpec(ref), "sigs.k8s.io/cluster-api/api/v1beta1.JSONPatch": schema_sigsk8sio_cluster_api_api_v1beta1_JSONPatch(ref), "sigs.k8s.io/cluster-api/api/v1beta1.JSONPatchValue": schema_sigsk8sio_cluster_api_api_v1beta1_JSONPatchValue(ref), @@ -328,12 +329,18 @@ func schema_sigsk8sio_cluster_api_api_v1beta1_ClusterClassPatch(ref common.Refer }, }, }, + "external": { + SchemaProps: spec.SchemaProps{ + Description: "External defines an external patch. FIXME(sbueringer): internal or external only => validate", + Ref: ref("sigs.k8s.io/cluster-api/api/v1beta1.ExternalPatchDefinition"), + }, + }, }, - Required: []string{"name", "definitions"}, + Required: []string{"name"}, }, }, Dependencies: []string{ - "sigs.k8s.io/cluster-api/api/v1beta1.PatchDefinition"}, + "sigs.k8s.io/cluster-api/api/v1beta1.ExternalPatchDefinition", "sigs.k8s.io/cluster-api/api/v1beta1.PatchDefinition"}, } } @@ -838,6 +845,33 @@ func schema_sigsk8sio_cluster_api_api_v1beta1_ControlPlaneTopology(ref common.Re } } +func schema_sigsk8sio_cluster_api_api_v1beta1_ExternalPatchDefinition(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "ExternalPatchDefinition defines an external patch.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "generateExtension": { + SchemaProps: spec.SchemaProps{ + Description: "GenerateExtension references an extension which is called to generate patches. FIXME(sbueringer): Q: Fine that GenerateExtension is optional as well?", + Type: []string{"string"}, + Format: "", + }, + }, + "validateExtension": { + SchemaProps: spec.SchemaProps{ + Description: "GenerateExtension references an extension which is called to validate the topology.", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + } +} + func schema_sigsk8sio_cluster_api_api_v1beta1_FailureDomainSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ diff --git a/config/crd/bases/cluster.x-k8s.io_clusterclasses.yaml b/config/crd/bases/cluster.x-k8s.io_clusterclasses.yaml index faa7273dcd06..bac03994c247 100644 --- a/config/crd/bases/cluster.x-k8s.io_clusterclasses.yaml +++ b/config/crd/bases/cluster.x-k8s.io_clusterclasses.yaml @@ -794,11 +794,24 @@ spec: will be disabled. If EnabledIf is not set, the patch will be enabled per default. type: string + external: + description: 'External defines an external patch. FIXME(sbueringer): + internal or external only => validate' + properties: + generateExtension: + description: 'GenerateExtension references an extension + which is called to generate patches. FIXME(sbueringer): + Q: Fine that GenerateExtension is optional as well?' + type: string + validateExtension: + description: GenerateExtension references an extension which + is called to validate the topology. + type: string + type: object name: description: Name of the patch. type: string required: - - definitions - name type: object type: array diff --git a/controllers/alias.go b/controllers/alias.go index 337440ae8507..dc0a9454be5f 100644 --- a/controllers/alias.go +++ b/controllers/alias.go @@ -32,6 +32,7 @@ import ( clustertopologycontroller "sigs.k8s.io/cluster-api/internal/controllers/topology/cluster" machinedeploymenttopologycontroller "sigs.k8s.io/cluster-api/internal/controllers/topology/machinedeployment" machinesettopologycontroller "sigs.k8s.io/cluster-api/internal/controllers/topology/machineset" + runtimeclient "sigs.k8s.io/cluster-api/internal/runtime/client" ) // Following types provides access to reconcilers implemented in internal/controllers, thus @@ -133,6 +134,8 @@ type ClusterTopologyReconciler struct { // race conditions caused by an outdated cache. APIReader client.Reader + RuntimeClient runtimeclient.Client + // WatchFilterValue is the label value used to filter events prior to reconciliation. WatchFilterValue string @@ -146,6 +149,7 @@ func (r *ClusterTopologyReconciler) SetupWithManager(ctx context.Context, mgr ct Client: r.Client, APIReader: r.APIReader, UnstructuredCachingClient: r.UnstructuredCachingClient, + RuntimeClient: r.RuntimeClient, WatchFilterValue: r.WatchFilterValue, }).SetupWithManager(ctx, mgr, options) } diff --git a/exp/runtime/hooks/api/v1alpha1/common_types.go b/exp/runtime/hooks/api/v1alpha1/common_types.go index c7ace37dc81a..2567a32df1b4 100644 --- a/exp/runtime/hooks/api/v1alpha1/common_types.go +++ b/exp/runtime/hooks/api/v1alpha1/common_types.go @@ -41,11 +41,12 @@ type RetryResponseObject interface { // CommonResponse is the data structure common to all response types. type CommonResponse struct { - // Status of the call. One of "Success" or "Failure". + // Status of the call. + // One of: "Success" or "Failure". Status ResponseStatus `json:"status"` // A human-readable description of the status of the call. - Message string `json:"message"` + Message string `json:"message,omitempty"` } // SetMessage sets the message field for the CommonResponse. diff --git a/exp/runtime/hooks/api/v1alpha1/topologymutation_types.go b/exp/runtime/hooks/api/v1alpha1/topologymutation_types.go index 13071cf188b0..ea90e6eab809 100644 --- a/exp/runtime/hooks/api/v1alpha1/topologymutation_types.go +++ b/exp/runtime/hooks/api/v1alpha1/topologymutation_types.go @@ -171,7 +171,7 @@ type HolderReference struct { } // ValidateTopology validates the Cluster topology after all patches have been applied. -func ValidateTopology(*GeneratePatchesRequest, *GeneratePatchesResponse) {} +func ValidateTopology(*ValidateTopologyRequest, *ValidateTopologyResponse) {} func init() { catalogBuilder.RegisterHook(GeneratePatches, &runtimecatalog.HookMeta{ diff --git a/exp/runtime/hooks/api/v1alpha1/zz_generated.openapi.go b/exp/runtime/hooks/api/v1alpha1/zz_generated.openapi.go index 46e9a40de661..e152747c6f62 100644 --- a/exp/runtime/hooks/api/v1alpha1/zz_generated.openapi.go +++ b/exp/runtime/hooks/api/v1alpha1/zz_generated.openapi.go @@ -128,7 +128,7 @@ func schema_runtime_hooks_api_v1alpha1_AfterClusterUpgradeResponse(ref common.Re }, "status": { SchemaProps: spec.SchemaProps{ - Description: "Status of the call. One of \"Success\" or \"Failure\".\n\nPossible enum values:\n - `\"Failure\"` represents a failure response.\n - `\"Success\"` represents the success response.", + Description: "Status of the call. One of: \"Success\" or \"Failure\".\n\nPossible enum values:\n - `\"Failure\"` represents a failure response.\n - `\"Success\"` represents the success response.", Default: "", Type: []string{"string"}, Format: "", @@ -137,13 +137,12 @@ func schema_runtime_hooks_api_v1alpha1_AfterClusterUpgradeResponse(ref common.Re "message": { SchemaProps: spec.SchemaProps{ Description: "A human-readable description of the status of the call.", - Default: "", Type: []string{"string"}, Format: "", }, }, }, - Required: []string{"status", "message"}, + Required: []string{"status"}, }, }, } @@ -209,7 +208,7 @@ func schema_runtime_hooks_api_v1alpha1_AfterControlPlaneInitializedResponse(ref }, "status": { SchemaProps: spec.SchemaProps{ - Description: "Status of the call. One of \"Success\" or \"Failure\".\n\nPossible enum values:\n - `\"Failure\"` represents a failure response.\n - `\"Success\"` represents the success response.", + Description: "Status of the call. One of: \"Success\" or \"Failure\".\n\nPossible enum values:\n - `\"Failure\"` represents a failure response.\n - `\"Success\"` represents the success response.", Default: "", Type: []string{"string"}, Format: "", @@ -218,13 +217,12 @@ func schema_runtime_hooks_api_v1alpha1_AfterControlPlaneInitializedResponse(ref "message": { SchemaProps: spec.SchemaProps{ Description: "A human-readable description of the status of the call.", - Default: "", Type: []string{"string"}, Format: "", }, }, }, - Required: []string{"status", "message"}, + Required: []string{"status"}, }, }, } @@ -298,7 +296,7 @@ func schema_runtime_hooks_api_v1alpha1_AfterControlPlaneUpgradeResponse(ref comm }, "status": { SchemaProps: spec.SchemaProps{ - Description: "Status of the call. One of \"Success\" or \"Failure\".\n\nPossible enum values:\n - `\"Failure\"` represents a failure response.\n - `\"Success\"` represents the success response.", + Description: "Status of the call. One of: \"Success\" or \"Failure\".\n\nPossible enum values:\n - `\"Failure\"` represents a failure response.\n - `\"Success\"` represents the success response.", Default: "", Type: []string{"string"}, Format: "", @@ -307,7 +305,6 @@ func schema_runtime_hooks_api_v1alpha1_AfterControlPlaneUpgradeResponse(ref comm "message": { SchemaProps: spec.SchemaProps{ Description: "A human-readable description of the status of the call.", - Default: "", Type: []string{"string"}, Format: "", }, @@ -321,7 +318,7 @@ func schema_runtime_hooks_api_v1alpha1_AfterControlPlaneUpgradeResponse(ref comm }, }, }, - Required: []string{"status", "message", "retryAfterSeconds"}, + Required: []string{"status", "retryAfterSeconds"}, }, }, } @@ -387,7 +384,7 @@ func schema_runtime_hooks_api_v1alpha1_BeforeClusterCreateResponse(ref common.Re }, "status": { SchemaProps: spec.SchemaProps{ - Description: "Status of the call. One of \"Success\" or \"Failure\".\n\nPossible enum values:\n - `\"Failure\"` represents a failure response.\n - `\"Success\"` represents the success response.", + Description: "Status of the call. One of: \"Success\" or \"Failure\".\n\nPossible enum values:\n - `\"Failure\"` represents a failure response.\n - `\"Success\"` represents the success response.", Default: "", Type: []string{"string"}, Format: "", @@ -396,7 +393,6 @@ func schema_runtime_hooks_api_v1alpha1_BeforeClusterCreateResponse(ref common.Re "message": { SchemaProps: spec.SchemaProps{ Description: "A human-readable description of the status of the call.", - Default: "", Type: []string{"string"}, Format: "", }, @@ -410,7 +406,7 @@ func schema_runtime_hooks_api_v1alpha1_BeforeClusterCreateResponse(ref common.Re }, }, }, - Required: []string{"status", "message", "retryAfterSeconds"}, + Required: []string{"status", "retryAfterSeconds"}, }, }, } @@ -476,7 +472,7 @@ func schema_runtime_hooks_api_v1alpha1_BeforeClusterDeleteResponse(ref common.Re }, "status": { SchemaProps: spec.SchemaProps{ - Description: "Status of the call. One of \"Success\" or \"Failure\".\n\nPossible enum values:\n - `\"Failure\"` represents a failure response.\n - `\"Success\"` represents the success response.", + Description: "Status of the call. One of: \"Success\" or \"Failure\".\n\nPossible enum values:\n - `\"Failure\"` represents a failure response.\n - `\"Success\"` represents the success response.", Default: "", Type: []string{"string"}, Format: "", @@ -485,7 +481,6 @@ func schema_runtime_hooks_api_v1alpha1_BeforeClusterDeleteResponse(ref common.Re "message": { SchemaProps: spec.SchemaProps{ Description: "A human-readable description of the status of the call.", - Default: "", Type: []string{"string"}, Format: "", }, @@ -499,7 +494,7 @@ func schema_runtime_hooks_api_v1alpha1_BeforeClusterDeleteResponse(ref common.Re }, }, }, - Required: []string{"status", "message", "retryAfterSeconds"}, + Required: []string{"status", "retryAfterSeconds"}, }, }, } @@ -581,7 +576,7 @@ func schema_runtime_hooks_api_v1alpha1_BeforeClusterUpgradeResponse(ref common.R }, "status": { SchemaProps: spec.SchemaProps{ - Description: "Status of the call. One of \"Success\" or \"Failure\".\n\nPossible enum values:\n - `\"Failure\"` represents a failure response.\n - `\"Success\"` represents the success response.", + Description: "Status of the call. One of: \"Success\" or \"Failure\".\n\nPossible enum values:\n - `\"Failure\"` represents a failure response.\n - `\"Success\"` represents the success response.", Default: "", Type: []string{"string"}, Format: "", @@ -590,7 +585,6 @@ func schema_runtime_hooks_api_v1alpha1_BeforeClusterUpgradeResponse(ref common.R "message": { SchemaProps: spec.SchemaProps{ Description: "A human-readable description of the status of the call.", - Default: "", Type: []string{"string"}, Format: "", }, @@ -604,7 +598,7 @@ func schema_runtime_hooks_api_v1alpha1_BeforeClusterUpgradeResponse(ref common.R }, }, }, - Required: []string{"status", "message", "retryAfterSeconds"}, + Required: []string{"status", "retryAfterSeconds"}, }, }, } @@ -619,7 +613,7 @@ func schema_runtime_hooks_api_v1alpha1_CommonResponse(ref common.ReferenceCallba Properties: map[string]spec.Schema{ "status": { SchemaProps: spec.SchemaProps{ - Description: "Status of the call. One of \"Success\" or \"Failure\".\n\nPossible enum values:\n - `\"Failure\"` represents a failure response.\n - `\"Success\"` represents the success response.", + Description: "Status of the call. One of: \"Success\" or \"Failure\".\n\nPossible enum values:\n - `\"Failure\"` represents a failure response.\n - `\"Success\"` represents the success response.", Default: "", Type: []string{"string"}, Format: "", @@ -628,13 +622,12 @@ func schema_runtime_hooks_api_v1alpha1_CommonResponse(ref common.ReferenceCallba "message": { SchemaProps: spec.SchemaProps{ Description: "A human-readable description of the status of the call.", - Default: "", Type: []string{"string"}, Format: "", }, }, }, - Required: []string{"status", "message"}, + Required: []string{"status"}, }, }, } @@ -649,7 +642,7 @@ func schema_runtime_hooks_api_v1alpha1_CommonRetryResponse(ref common.ReferenceC Properties: map[string]spec.Schema{ "status": { SchemaProps: spec.SchemaProps{ - Description: "Status of the call. One of \"Success\" or \"Failure\".\n\nPossible enum values:\n - `\"Failure\"` represents a failure response.\n - `\"Success\"` represents the success response.", + Description: "Status of the call. One of: \"Success\" or \"Failure\".\n\nPossible enum values:\n - `\"Failure\"` represents a failure response.\n - `\"Success\"` represents the success response.", Default: "", Type: []string{"string"}, Format: "", @@ -658,7 +651,6 @@ func schema_runtime_hooks_api_v1alpha1_CommonRetryResponse(ref common.ReferenceC "message": { SchemaProps: spec.SchemaProps{ Description: "A human-readable description of the status of the call.", - Default: "", Type: []string{"string"}, Format: "", }, @@ -672,7 +664,7 @@ func schema_runtime_hooks_api_v1alpha1_CommonRetryResponse(ref common.ReferenceC }, }, }, - Required: []string{"status", "message", "retryAfterSeconds"}, + Required: []string{"status", "retryAfterSeconds"}, }, }, } @@ -728,7 +720,7 @@ func schema_runtime_hooks_api_v1alpha1_DiscoveryResponse(ref common.ReferenceCal }, "status": { SchemaProps: spec.SchemaProps{ - Description: "Status of the call. One of \"Success\" or \"Failure\".\n\nPossible enum values:\n - `\"Failure\"` represents a failure response.\n - `\"Success\"` represents the success response.", + Description: "Status of the call. One of: \"Success\" or \"Failure\".\n\nPossible enum values:\n - `\"Failure\"` represents a failure response.\n - `\"Success\"` represents the success response.", Default: "", Type: []string{"string"}, Format: "", @@ -737,7 +729,6 @@ func schema_runtime_hooks_api_v1alpha1_DiscoveryResponse(ref common.ReferenceCal "message": { SchemaProps: spec.SchemaProps{ Description: "A human-readable description of the status of the call.", - Default: "", Type: []string{"string"}, Format: "", }, @@ -765,7 +756,7 @@ func schema_runtime_hooks_api_v1alpha1_DiscoveryResponse(ref common.ReferenceCal }, }, }, - Required: []string{"status", "message", "handlers"}, + Required: []string{"status", "handlers"}, }, }, Dependencies: []string{ @@ -951,7 +942,7 @@ func schema_runtime_hooks_api_v1alpha1_GeneratePatchesResponse(ref common.Refere }, "status": { SchemaProps: spec.SchemaProps{ - Description: "Status of the call. One of \"Success\" or \"Failure\".\n\nPossible enum values:\n - `\"Failure\"` represents a failure response.\n - `\"Success\"` represents the success response.", + Description: "Status of the call. One of: \"Success\" or \"Failure\".\n\nPossible enum values:\n - `\"Failure\"` represents a failure response.\n - `\"Success\"` represents the success response.", Default: "", Type: []string{"string"}, Format: "", @@ -960,7 +951,6 @@ func schema_runtime_hooks_api_v1alpha1_GeneratePatchesResponse(ref common.Refere "message": { SchemaProps: spec.SchemaProps{ Description: "A human-readable description of the status of the call.", - Default: "", Type: []string{"string"}, Format: "", }, @@ -980,7 +970,7 @@ func schema_runtime_hooks_api_v1alpha1_GeneratePatchesResponse(ref common.Refere }, }, }, - Required: []string{"status", "message", "items"}, + Required: []string{"status", "items"}, }, }, Dependencies: []string{ @@ -1233,7 +1223,7 @@ func schema_runtime_hooks_api_v1alpha1_ValidateTopologyResponse(ref common.Refer }, "status": { SchemaProps: spec.SchemaProps{ - Description: "Status of the call. One of \"Success\" or \"Failure\".\n\nPossible enum values:\n - `\"Failure\"` represents a failure response.\n - `\"Success\"` represents the success response.", + Description: "Status of the call. One of: \"Success\" or \"Failure\".\n\nPossible enum values:\n - `\"Failure\"` represents a failure response.\n - `\"Success\"` represents the success response.", Default: "", Type: []string{"string"}, Format: "", @@ -1242,13 +1232,12 @@ func schema_runtime_hooks_api_v1alpha1_ValidateTopologyResponse(ref common.Refer "message": { SchemaProps: spec.SchemaProps{ Description: "A human-readable description of the status of the call.", - Default: "", Type: []string{"string"}, Format: "", }, }, }, - Required: []string{"status", "message"}, + Required: []string{"status"}, }, }, } diff --git a/internal/controllers/topology/cluster/cluster_controller.go b/internal/controllers/topology/cluster/cluster_controller.go index 2d0857d26b43..d63ad9dc2c5e 100644 --- a/internal/controllers/topology/cluster/cluster_controller.go +++ b/internal/controllers/topology/cluster/cluster_controller.go @@ -38,6 +38,7 @@ import ( "sigs.k8s.io/cluster-api/internal/controllers/topology/cluster/patches" "sigs.k8s.io/cluster-api/internal/controllers/topology/cluster/scope" "sigs.k8s.io/cluster-api/internal/controllers/topology/cluster/structuredmerge" + runtimeclient "sigs.k8s.io/cluster-api/internal/runtime/client" "sigs.k8s.io/cluster-api/util" "sigs.k8s.io/cluster-api/util/annotations" "sigs.k8s.io/cluster-api/util/patch" @@ -59,6 +60,8 @@ type Reconciler struct { // race conditions caused by an outdated cache. APIReader client.Reader + RuntimeClient runtimeclient.Client + // WatchFilterValue is the label value used to filter events prior to reconciliation. WatchFilterValue string @@ -103,7 +106,7 @@ func (r *Reconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager, opt r.externalTracker = external.ObjectTracker{ Controller: c, } - r.patchEngine = patches.NewEngine() + r.patchEngine = patches.NewEngine(r.RuntimeClient) r.recorder = mgr.GetEventRecorderFor("topology/cluster") if r.patchHelperFactory == nil { r.patchHelperFactory = serverSideApplyPatchHelperFactory(r.Client) @@ -113,7 +116,8 @@ func (r *Reconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager, opt // SetupForDryRun prepares the Reconciler for a dry run execution. func (r *Reconciler) SetupForDryRun(recorder record.EventRecorder) { - r.patchEngine = patches.NewEngine() + // FIXME(sbueringer) this won't work without a real registry. + r.patchEngine = patches.NewEngine(r.RuntimeClient) r.recorder = recorder r.patchHelperFactory = dryRunPatchHelperFactory(r.Client) } diff --git a/internal/controllers/topology/cluster/patches/api/interface.go b/internal/controllers/topology/cluster/patches/api/interface.go index 5fd5aef3b091..7d5e795f86c2 100644 --- a/internal/controllers/topology/cluster/patches/api/interface.go +++ b/internal/controllers/topology/cluster/patches/api/interface.go @@ -32,5 +32,13 @@ type Generator interface { // Generate generates patches for templates. // GeneratePatchesRequest contains templates and the corresponding variables. // GeneratePatchesResponse contains the patches which should be applied to the templates of the GenerateRequest. - Generate(context.Context, *runtimehooksv1.GeneratePatchesRequest) *runtimehooksv1.GeneratePatchesResponse + Generate(context.Context, *runtimehooksv1.GeneratePatchesRequest) (*runtimehooksv1.GeneratePatchesResponse, error) +} + +// Validator defines a component that can validate ClusterClass templates. +type Validator interface { + // Validate validates templates.. + // ValidateTopologyRequest contains templates and the corresponding variables. + // ValidateTopologyResponse contains the validation response. + Validate(context.Context, *runtimehooksv1.ValidateTopologyRequest) (*runtimehooksv1.ValidateTopologyResponse, error) } diff --git a/internal/controllers/topology/cluster/patches/engine.go b/internal/controllers/topology/cluster/patches/engine.go index cfb6066eaf1e..b20885719d11 100644 --- a/internal/controllers/topology/cluster/patches/engine.go +++ b/internal/controllers/topology/cluster/patches/engine.go @@ -28,10 +28,12 @@ import ( runtimehooksv1 "sigs.k8s.io/cluster-api/exp/runtime/hooks/api/v1alpha1" "sigs.k8s.io/cluster-api/internal/contract" "sigs.k8s.io/cluster-api/internal/controllers/topology/cluster/patches/api" + "sigs.k8s.io/cluster-api/internal/controllers/topology/cluster/patches/external" "sigs.k8s.io/cluster-api/internal/controllers/topology/cluster/patches/inline" "sigs.k8s.io/cluster-api/internal/controllers/topology/cluster/patches/variables" "sigs.k8s.io/cluster-api/internal/controllers/topology/cluster/scope" tlog "sigs.k8s.io/cluster-api/internal/log" + runtimeclient "sigs.k8s.io/cluster-api/internal/runtime/client" ) // Engine is a patch engine which applies patches defined in a ClusterBlueprint to a ClusterState. @@ -40,18 +42,15 @@ type Engine interface { } // NewEngine creates a new patch engine. -func NewEngine() Engine { +func NewEngine(runtimeClient runtimeclient.Client) Engine { return &engine{ - createPatchGenerator: createPatchGenerator, + runtimeClient: runtimeClient, } } // engine implements the Engine interface. type engine struct { - // createPatchGenerator is the func which returns a patch generator - // based on a ClusterClassPatch. - // Note: This field is also used to inject patches in unit tests. - createPatchGenerator func(patch *clusterv1.ClusterClassPatch) (api.Generator, error) + runtimeClient runtimeclient.Client } // Apply applies patches to the desired state according to the patches from the ClusterClass, variables from the Cluster @@ -83,18 +82,24 @@ func (e *engine) Apply(ctx context.Context, blueprint *scope.ClusterBlueprint, d log.V(5).Infof("Applying patch to templates") // Create patch generator for the current patch. - generator, err := e.createPatchGenerator(&clusterClassPatch) + generator, err := createPatchGenerator(e.runtimeClient, &clusterClassPatch) if err != nil { return err } + // Continue if no generator has been created. This can happen if an external config + // does not contain a generate extension. + if generator == nil { + continue + } + // Generate patches. // NOTE: All the partial patches accumulate on top of the request, so the // patch generator in the next iteration of the loop will get the modified // version of the request (including the patched version of the templates). - resp := generator.Generate(ctx, req) - if resp.Status == runtimehooksv1.ResponseStatusFailure { - return errors.Errorf("failed to generate patches for patch %q: %v", clusterClassPatch.Name, resp.Message) + resp, err := generator.Generate(ctx, req) + if err != nil { + return errors.Wrapf(err, "failed to generate patches for patch %q", clusterClassPatch.Name) } // Apply patches to the request. @@ -103,6 +108,30 @@ func (e *engine) Apply(ctx context.Context, blueprint *scope.ClusterBlueprint, d } } + // Convert request to validation request. + validationRequest := convertToValidationRequest(req) + + // Loop over patches in ClusterClass and validate topology, + // respecting the order in which they are defined. + for i := range blueprint.ClusterClass.Spec.Patches { + clusterClassPatch := blueprint.ClusterClass.Spec.Patches[i] + + if clusterClassPatch.External == nil || clusterClassPatch.External.ValidateExtension == nil { + continue + } + + ctx, log = log.WithValues("patch", clusterClassPatch.Name).Into(ctx) + + log.V(5).Infof("Validating topology") + + validator := external.NewValidator(e.runtimeClient, &clusterClassPatch) + + _, err := validator.Validate(ctx, validationRequest) + if err != nil { + return errors.Wrapf(err, "template validation %q failed", clusterClassPatch.Name) + } + } + // Use patched templates to update the desired state objects. log.V(5).Infof("Applying patched templates to desired state") if err := updateDesiredState(ctx, req, blueprint, desired); err != nil { @@ -234,11 +263,18 @@ func lookupMDTopology(topology *clusterv1.Topology, mdTopologyName string) (*clu // createPatchGenerator creates a patch generator for the given patch. // NOTE: Currently only inline JSON patches are supported; in the future we will add // external patches as well. -func createPatchGenerator(patch *clusterv1.ClusterClassPatch) (api.Generator, error) { +func createPatchGenerator(runtimeClient runtimeclient.Client, patch *clusterv1.ClusterClassPatch) (api.Generator, error) { // Return a jsonPatchGenerator if there are PatchDefinitions in the patch. if len(patch.Definitions) > 0 { return inline.New(patch), nil } + // Return an externalPatchGenerator if there is an external configuration in the patch. + if patch.External != nil { + if patch.External.GenerateExtension == nil { + return nil, nil + } + return external.New(runtimeClient, patch), nil + } return nil, errors.Errorf("failed to create patch generator for patch %q", patch.Name) } @@ -296,6 +332,24 @@ func applyPatchesToRequest(ctx context.Context, req *runtimehooksv1.GeneratePatc return nil } +// convertToValidationRequest converts a GeneratePatchesRequest to a ValidateTopologyRequest. +func convertToValidationRequest(generateRequest *runtimehooksv1.GeneratePatchesRequest) *runtimehooksv1.ValidateTopologyRequest { + validationRequest := &runtimehooksv1.ValidateTopologyRequest{} + validationRequest.Variables = generateRequest.Variables + + for i := range generateRequest.Items { + item := generateRequest.Items[i] + + validationRequest.Items = append(validationRequest.Items, &runtimehooksv1.ValidateTopologyRequestItem{ + HolderReference: item.HolderReference, + Object: item.Object, + Variables: item.Variables, + }) + } + + return validationRequest +} + // updateDesiredState uses the patched templates of a GeneratePatchesRequest to update the desired state. // NOTE: This func should be called after all the patches have been applied to the GeneratePatchesRequest. func updateDesiredState(ctx context.Context, req *runtimehooksv1.GeneratePatchesRequest, blueprint *scope.ClusterBlueprint, desired *scope.ClusterState) error { diff --git a/internal/controllers/topology/cluster/patches/engine_test.go b/internal/controllers/topology/cluster/patches/engine_test.go index f64d0f7204bf..6151b40168cf 100644 --- a/internal/controllers/topology/cluster/patches/engine_test.go +++ b/internal/controllers/topology/cluster/patches/engine_test.go @@ -361,7 +361,7 @@ func TestApply(t *testing.T) { blueprint, desired := setupTestObjects() // If there are patches, set up patch generators. - patchEngine := NewEngine() + patchEngine := NewEngine(nil) if len(tt.patches) > 0 { // Add the patches. blueprint.ClusterClass.Spec.Patches = tt.patches diff --git a/internal/controllers/topology/cluster/patches/external/external_patch_generator.go b/internal/controllers/topology/cluster/patches/external/external_patch_generator.go new file mode 100644 index 000000000000..ad58c35d577f --- /dev/null +++ b/internal/controllers/topology/cluster/patches/external/external_patch_generator.go @@ -0,0 +1,51 @@ +/* +Copyright 2022 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package external implements the external patch generator. +package external + +import ( + "context" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + runtimehooksv1 "sigs.k8s.io/cluster-api/exp/runtime/hooks/api/v1alpha1" + "sigs.k8s.io/cluster-api/internal/controllers/topology/cluster/patches/api" + runtimeclient "sigs.k8s.io/cluster-api/internal/runtime/client" +) + +// externalPatchGenerator generates JSON patches for a GeneratePatchesRequest based on a ClusterClassPatch. +type externalPatchGenerator struct { + runtimeClient runtimeclient.Client + patch *clusterv1.ClusterClassPatch +} + +// New returns a new external Generator from a given ClusterClassPatch object. +func New(runtimeClient runtimeclient.Client, patch *clusterv1.ClusterClassPatch) api.Generator { + return &externalPatchGenerator{ + runtimeClient: runtimeClient, + patch: patch, + } +} + +func (e externalPatchGenerator) Generate(ctx context.Context, req *runtimehooksv1.GeneratePatchesRequest) (*runtimehooksv1.GeneratePatchesResponse, error) { + resp := &runtimehooksv1.GeneratePatchesResponse{} + // FIXME(sbueringer): CallExtension and CallAllExtensions should check if registry is ready internally. + err := e.runtimeClient.CallExtension(ctx, runtimehooksv1.GeneratePatches, *e.patch.External.GenerateExtension, req, resp) + if err != nil { + return nil, err + } + return resp, nil +} diff --git a/internal/controllers/topology/cluster/patches/external/external_validator.go b/internal/controllers/topology/cluster/patches/external/external_validator.go new file mode 100644 index 000000000000..e3866128c764 --- /dev/null +++ b/internal/controllers/topology/cluster/patches/external/external_validator.go @@ -0,0 +1,50 @@ +/* +Copyright 2022 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package external implements the external patch generator. +package external + +import ( + "context" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + runtimehooksv1 "sigs.k8s.io/cluster-api/exp/runtime/hooks/api/v1alpha1" + "sigs.k8s.io/cluster-api/internal/controllers/topology/cluster/patches/api" + runtimeclient "sigs.k8s.io/cluster-api/internal/runtime/client" +) + +// externalValidator validates templates. +type externalValidator struct { + runtimeClient runtimeclient.Client + patch *clusterv1.ClusterClassPatch +} + +// NewValidator returns a new external Validator from a given ClusterClassPatch object. +func NewValidator(runtimeClient runtimeclient.Client, patch *clusterv1.ClusterClassPatch) api.Validator { + return &externalValidator{ + runtimeClient: runtimeClient, + patch: patch, + } +} + +func (e externalValidator) Validate(ctx context.Context, req *runtimehooksv1.ValidateTopologyRequest) (*runtimehooksv1.ValidateTopologyResponse, error) { + resp := &runtimehooksv1.ValidateTopologyResponse{} + err := e.runtimeClient.CallExtension(ctx, runtimehooksv1.ValidateTopology, *e.patch.External.ValidateExtension, req, resp) + if err != nil { + return nil, err + } + return resp, nil +} diff --git a/internal/controllers/topology/cluster/patches/inline/json_patch_generator.go b/internal/controllers/topology/cluster/patches/inline/json_patch_generator.go index 628863be8bee..50ec85c8bcc1 100644 --- a/internal/controllers/topology/cluster/patches/inline/json_patch_generator.go +++ b/internal/controllers/topology/cluster/patches/inline/json_patch_generator.go @@ -51,17 +51,17 @@ func New(patch *clusterv1.ClusterClassPatch) api.Generator { } // Generate generates JSON patches for the given GeneratePatchesRequest based on a ClusterClassPatch. -func (j *jsonPatchGenerator) Generate(_ context.Context, req *runtimehooksv1.GeneratePatchesRequest) *runtimehooksv1.GeneratePatchesResponse { +func (j *jsonPatchGenerator) Generate(_ context.Context, req *runtimehooksv1.GeneratePatchesRequest) (*runtimehooksv1.GeneratePatchesResponse, error) { resp := &runtimehooksv1.GeneratePatchesResponse{} - globalVariables := toMap(req.Variables) + globalVariables := patchvariables.ToMap(req.Variables) // Loop over all templates. errs := []error{} for i := range req.Items { item := &req.Items[i] - templateVariables := toMap(item.Variables) + templateVariables := patchvariables.ToMap(item.Variables) // Calculate the list of patches which match the current template. matchingPatches := []clusterv1.PatchDefinition{} @@ -78,7 +78,7 @@ func (j *jsonPatchGenerator) Generate(_ context.Context, req *runtimehooksv1.Gen } // Merge template-specific and global variables. - variables, err := mergeVariableMaps(globalVariables, templateVariables) + variables, err := patchvariables.MergeVariableMaps(globalVariables, templateVariables) if err != nil { errs = append(errs, errors.Wrapf(err, "failed to merge global and template-specific variables for item with uid %q", item.UID)) continue @@ -113,24 +113,10 @@ func (j *jsonPatchGenerator) Generate(_ context.Context, req *runtimehooksv1.Gen } if err := kerrors.NewAggregate(errs); err != nil { - return &runtimehooksv1.GeneratePatchesResponse{ - CommonResponse: runtimehooksv1.CommonResponse{ - Status: runtimehooksv1.ResponseStatusFailure, - Message: err.Error(), - }, - } + return nil, err } - return resp -} - -// toMap converts a list of Variables to a map of JSON (name is the map key). -func toMap(variables []runtimehooksv1.Variable) map[string]apiextensionsv1.JSON { - variablesMap := map[string]apiextensionsv1.JSON{} - for i := range variables { - variablesMap[variables[i].Name] = variables[i].Value - } - return variablesMap + return resp, nil } // matchesSelector returns true if the GeneratePatchesRequestItem matches the selector. @@ -357,53 +343,3 @@ func calculateTemplateData(variables map[string]apiextensionsv1.JSON) (map[strin return res, nil } - -// mergeVariableMaps merges variables. -// NOTE: In case a variable exists in multiple maps, the variable from the latter map is preserved. -// NOTE: The builtin variable object is merged instead of simply overwritten. -func mergeVariableMaps(variableMaps ...map[string]apiextensionsv1.JSON) (map[string]apiextensionsv1.JSON, error) { - res := make(map[string]apiextensionsv1.JSON) - - for _, variableMap := range variableMaps { - for variableName, variableValue := range variableMap { - // If the variable already exits and is the builtin variable, merge it. - if _, ok := res[variableName]; ok && variableName == patchvariables.BuiltinsName { - mergedV, err := mergeBuiltinVariables(res[variableName], variableValue) - if err != nil { - return nil, errors.Wrapf(err, "failed to merge builtin variables") - } - res[variableName] = *mergedV - continue - } - res[variableName] = variableValue - } - } - - return res, nil -} - -// mergeBuiltinVariables merges builtin variable objects. -// NOTE: In case a variable exists in multiple builtin variables, the variable from the latter map is preserved. -func mergeBuiltinVariables(variableList ...apiextensionsv1.JSON) (*apiextensionsv1.JSON, error) { - builtins := &patchvariables.Builtins{} - - // Unmarshal all variables into builtins. - // NOTE: This accumulates the fields on the builtins. - // Fields will be overwritten by later Unmarshals if fields are - // set on multiple variables. - for _, variable := range variableList { - if err := json.Unmarshal(variable.Raw, builtins); err != nil { - return nil, errors.Wrapf(err, "failed to unmarshal builtin variable") - } - } - - // Marshal builtins to JSON. - builtinVariableJSON, err := json.Marshal(builtins) - if err != nil { - return nil, errors.Wrapf(err, "failed to marshal builtin variable") - } - - return &apiextensionsv1.JSON{ - Raw: builtinVariableJSON, - }, nil -} diff --git a/internal/controllers/topology/cluster/patches/inline/json_patch_generator_test.go b/internal/controllers/topology/cluster/patches/inline/json_patch_generator_test.go index 3f31ee4856bc..da67895c9c51 100644 --- a/internal/controllers/topology/cluster/patches/inline/json_patch_generator_test.go +++ b/internal/controllers/topology/cluster/patches/inline/json_patch_generator_test.go @@ -339,9 +339,10 @@ func TestGenerate(t *testing.T) { t.Run(tt.name, func(t *testing.T) { g := NewWithT(t) - got := New(tt.patch).Generate(context.Background(), tt.req) + got, err := New(tt.patch).Generate(context.Background(), tt.req) g.Expect(got).To(Equal(tt.want)) + g.Expect(err).ToNot(HaveOccurred()) }) } } @@ -1759,33 +1760,6 @@ func TestCalculateTemplateData(t *testing.T) { } } -func TestMergeVariables(t *testing.T) { - t.Run("Merge variables", func(t *testing.T) { - g := NewWithT(t) - - m, err := mergeVariableMaps( - map[string]apiextensionsv1.JSON{ - patchvariables.BuiltinsName: {Raw: []byte(`{"cluster":{"name":"cluster-name","namespace":"default","topology":{"class":"clusterClass1","version":"v1.21.1"}}}`)}, - "a": {Raw: []byte("a-different")}, - "c": {Raw: []byte("c")}, - }, - map[string]apiextensionsv1.JSON{ - // Verify that builtin variables are merged correctly and - // the latter variables take precedent ("cluster-name-overwrite"). - patchvariables.BuiltinsName: {Raw: []byte(`{"controlPlane":{"replicas":3},"cluster":{"name":"cluster-name-overwrite"}}`)}, - "a": {Raw: []byte("a")}, - "b": {Raw: []byte("b")}, - }, - ) - g.Expect(err).To(BeNil()) - - g.Expect(m).To(HaveKeyWithValue(patchvariables.BuiltinsName, apiextensionsv1.JSON{Raw: []byte(`{"cluster":{"name":"cluster-name-overwrite","namespace":"default","topology":{"version":"v1.21.1","class":"clusterClass1"}},"controlPlane":{"replicas":3}}`)})) - g.Expect(m).To(HaveKeyWithValue("a", apiextensionsv1.JSON{Raw: []byte("a")})) - g.Expect(m).To(HaveKeyWithValue("b", apiextensionsv1.JSON{Raw: []byte("b")})) - g.Expect(m).To(HaveKeyWithValue("c", apiextensionsv1.JSON{Raw: []byte("c")})) - }) -} - // toJSONCompact is used to be able to write JSON values in a readable manner. func toJSONCompact(value string) []byte { var compactValue bytes.Buffer diff --git a/internal/controllers/topology/cluster/patches/variables/merge.go b/internal/controllers/topology/cluster/patches/variables/merge.go new file mode 100644 index 000000000000..3c1259635f82 --- /dev/null +++ b/internal/controllers/topology/cluster/patches/variables/merge.go @@ -0,0 +1,75 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package variables + +import ( + "encoding/json" + + "github.com/pkg/errors" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +// MergeVariableMaps merges variables. +// This func is useful when merging global and template-specific variables. +// NOTE: In case a variable exists in multiple maps, the variable from the latter map is preserved. +// NOTE: The builtin variable object is merged instead of simply overwritten. +func MergeVariableMaps(variableMaps ...map[string]apiextensionsv1.JSON) (map[string]apiextensionsv1.JSON, error) { + res := make(map[string]apiextensionsv1.JSON) + + for _, variableMap := range variableMaps { + for variableName, variableValue := range variableMap { + // If the variable already exits and is the builtin variable, merge it. + if _, ok := res[variableName]; ok && variableName == BuiltinsName { + mergedV, err := mergeBuiltinVariables(res[variableName], variableValue) + if err != nil { + return nil, errors.Wrapf(err, "failed to merge builtin variables") + } + res[variableName] = *mergedV + continue + } + res[variableName] = variableValue + } + } + + return res, nil +} + +// mergeBuiltinVariables merges builtin variable objects. +// NOTE: In case a variable exists in multiple builtin variables, the variable from the latter map is preserved. +func mergeBuiltinVariables(variableList ...apiextensionsv1.JSON) (*apiextensionsv1.JSON, error) { + builtins := &Builtins{} + + // Unmarshal all variables into builtins. + // NOTE: This accumulates the fields on the builtins. + // Fields will be overwritten by later Unmarshals if fields are + // set on multiple variables. + for _, variable := range variableList { + if err := json.Unmarshal(variable.Raw, builtins); err != nil { + return nil, errors.Wrapf(err, "failed to unmarshal builtin variable") + } + } + + // Marshal builtins to JSON. + builtinVariableJSON, err := json.Marshal(builtins) + if err != nil { + return nil, errors.Wrapf(err, "failed to marshal builtin variable") + } + + return &apiextensionsv1.JSON{ + Raw: builtinVariableJSON, + }, nil +} diff --git a/internal/controllers/topology/cluster/patches/variables/merge_test.go b/internal/controllers/topology/cluster/patches/variables/merge_test.go new file mode 100644 index 000000000000..dc2a4b5793a9 --- /dev/null +++ b/internal/controllers/topology/cluster/patches/variables/merge_test.go @@ -0,0 +1,51 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package variables + +import ( + "testing" + + . "github.com/onsi/gomega" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +func TestMergeVariables(t *testing.T) { + t.Run("Merge variables", func(t *testing.T) { + g := NewWithT(t) + + m, err := MergeVariableMaps( + map[string]apiextensionsv1.JSON{ + BuiltinsName: {Raw: []byte(`{"cluster":{"name":"cluster-name","namespace":"default","topology":{"class":"clusterClass1","version":"v1.21.1"}}}`)}, + "a": {Raw: []byte("a-different")}, + "c": {Raw: []byte("c")}, + }, + map[string]apiextensionsv1.JSON{ + // Verify that builtin variables are merged correctly and + // the latter variables take precedent ("cluster-name-overwrite"). + BuiltinsName: {Raw: []byte(`{"controlPlane":{"replicas":3},"cluster":{"name":"cluster-name-overwrite"}}`)}, + "a": {Raw: []byte("a")}, + "b": {Raw: []byte("b")}, + }, + ) + g.Expect(err).To(BeNil()) + + g.Expect(m).To(HaveKeyWithValue(BuiltinsName, apiextensionsv1.JSON{Raw: []byte(`{"cluster":{"name":"cluster-name-overwrite","namespace":"default","topology":{"version":"v1.21.1","class":"clusterClass1"}},"controlPlane":{"replicas":3}}`)})) + g.Expect(m).To(HaveKeyWithValue("a", apiextensionsv1.JSON{Raw: []byte("a")})) + g.Expect(m).To(HaveKeyWithValue("b", apiextensionsv1.JSON{Raw: []byte("b")})) + g.Expect(m).To(HaveKeyWithValue("c", apiextensionsv1.JSON{Raw: []byte("c")})) + }) +} diff --git a/internal/controllers/topology/cluster/patches/variables/variables.go b/internal/controllers/topology/cluster/patches/variables/variables.go index 4090bc88dae4..fe618e1475f3 100644 --- a/internal/controllers/topology/cluster/patches/variables/variables.go +++ b/internal/controllers/topology/cluster/patches/variables/variables.go @@ -346,3 +346,12 @@ func ipFamilyToString(ipFamily clusterv1.ClusterIPFamily) string { return "Invalid" } } + +// ToMap converts a list of Variables to a map of JSON (name is the map key). +func ToMap(variables []runtimehooksv1.Variable) map[string]apiextensionsv1.JSON { + variablesMap := map[string]apiextensionsv1.JSON{} + for i := range variables { + variablesMap[variables[i].Name] = variables[i].Value + } + return variablesMap +} diff --git a/internal/runtime/catalog/catalog.go b/internal/runtime/catalog/catalog.go index 1112d18802ad..431aa5ffbfcd 100644 --- a/internal/runtime/catalog/catalog.go +++ b/internal/runtime/catalog/catalog.go @@ -145,8 +145,7 @@ func (c *Catalog) AddHook(gv schema.GroupVersion, hookFunc Hook, hookMeta *HookM } // Calculate the hook name based on the func name. - hookFuncName := goruntime.FuncForPC(reflect.ValueOf(hookFunc).Pointer()).Name() - hookName := hookFuncName[strings.LastIndex(hookFuncName, ".")+1:] + hookName := HookName(hookFunc) gvh := GroupVersionHook{ Group: gv.Group, @@ -347,6 +346,13 @@ func (gvh GroupVersionHook) String() string { return strings.Join([]string{gvh.Group, "/", gvh.Version, ", Hook=", gvh.Hook}, "") } +// HookName returns the name of the runtime hook. +func HookName(hook Hook) string { + hookFuncName := goruntime.FuncForPC(reflect.ValueOf(hook).Pointer()).Name() + hookName := hookFuncName[strings.LastIndex(hookFuncName, ".")+1:] + return hookName +} + var emptyGroupVersionHook = GroupVersionHook{} var emptyGroupVersionKind = schema.GroupVersionKind{} diff --git a/main.go b/main.go index 6f5bfb391fea..926f59221ad5 100644 --- a/main.go +++ b/main.go @@ -313,6 +313,18 @@ func setupReconcilers(ctx context.Context, mgr ctrl.Manager) { os.Exit(1) } + var runtimeRegistry runtimeregistry.ExtensionRegistry + var runtimeClient runtimeclient.Client + if feature.Gates.Enabled(feature.RuntimeSDK) { + // This is the creation of the single RuntimeExtension registry for the controller. + runtimeRegistry = runtimeregistry.New() + runtimeClient = runtimeclient.New(runtimeclient.Options{ + Catalog: catalog, + Registry: runtimeRegistry, + Client: mgr.GetClient(), + }) + } + if feature.Gates.Enabled(feature.ClusterTopology) { unstructuredCachingClient, err := client.NewDelegatingClient( client.NewDelegatingClientInput{ @@ -343,6 +355,7 @@ func setupReconcilers(ctx context.Context, mgr ctrl.Manager) { Client: mgr.GetClient(), APIReader: mgr.GetAPIReader(), UnstructuredCachingClient: unstructuredCachingClient, + RuntimeClient: runtimeClient, WatchFilterValue: watchFilterValue, }).SetupWithManager(ctx, mgr, concurrency(clusterTopologyConcurrency)); err != nil { setupLog.Error(err, "unable to create controller", "controller", "ClusterTopology") @@ -369,16 +382,10 @@ func setupReconcilers(ctx context.Context, mgr ctrl.Manager) { } if feature.Gates.Enabled(feature.RuntimeSDK) { - // This is the creation of the single RuntimeExtension registry for the controller. - registry := runtimeregistry.New() if err = (&runtimecontrollers.ExtensionConfigReconciler{ - Client: mgr.GetClient(), - APIReader: mgr.GetAPIReader(), - RuntimeClient: runtimeclient.New(runtimeclient.Options{ - Catalog: catalog, - Registry: registry, - Client: mgr.GetClient(), - }), + Client: mgr.GetClient(), + APIReader: mgr.GetAPIReader(), + RuntimeClient: runtimeClient, WatchFilterValue: watchFilterValue, }).SetupWithManager(ctx, mgr, concurrency(extensionConfigConcurrency)); err != nil { setupLog.Error(err, "unable to create controller", "controller", "ExtensionConfig") diff --git a/poc/local/README.md b/poc/local/README.md new file mode 100644 index 000000000000..7aa36c2a77b0 --- /dev/null +++ b/poc/local/README.md @@ -0,0 +1,71 @@ + +# POC + +Start dev-env + controller: + +```bash +./hack/kind-install-for-capd.sh +tilt up + +controller=capi +mkdir -p /tmp/k8s-webhook-server-${controller} +k -n ${controller}-system get secret ${controller}-webhook-service-cert -o json | jq '.data."tls.crt"' -r | base64 -d > /tmp/k8s-webhook-server-${controller}/tls.crt +k -n ${controller}-system get secret ${controller}-webhook-service-cert -o json | jq '.data."tls.key"' -r | base64 -d > /tmp/k8s-webhook-server-${controller}/tls.key + +# Start controller with: +# --webhook-cert-dir=/tmp/k8s-webhook-server-capi/ +# --feature-gates=MachinePool=true,ClusterResourceSet=true,ClusterTopology=true,RuntimeSDK=true +# --metrics-bind-addr=localhost:8080 +# --metrics-bind-addr=0.0.0.0:8080 +# --logging-format=json +# --v=2 +``` + +# Deploy secure + +```sh +# To create service and certificate +k apply -f ./poc/test/local/secure-infra.yaml + +# fetch certificate +mkdir -p /tmp/k8s-webhook-server/serving-certs +for f in $(kubectl get secret my-local-extension-cert -o json | jq '.data | keys | .[]' -r); do + kubectl get secret my-local-extension-cert -o json | jq '.data["'$f'"]' -r | base64 -d > "/tmp/k8s-webhook-server/serving-certs/$f" +done + +export CA_BUNDLE="$(cat /tmp/k8s-webhook-server/serving-certs/ca.crt | base64)" +envsubst < ./poc/test/local/secure-extension.yaml | k apply -f - +# replace caBundle in `secure-extension.yaml` with base64 encoded content of /tmp/k8s-webhook-server/serving-certs/ca.crt + +# start webserver now (IDE?) rte-implementation-v1alpha1-secure + +# Deploy Extension +kubectl apply -f secure-extension.yaml +``` + + +# Deploy secure (part of e2e test) + +```sh +# fetch certificate +mkdir -p /tmp/k8s-webhook-server/serving-certs +for f in $(k -n test-extension-system get secret webhook-service-cert -o json | jq '.data | keys | .[]' -r); do + k -n test-extension-system get secret webhook-service-cert -o json | jq '.data["'$f'"]' -r | base64 -d > "/tmp/k8s-webhook-server/serving-certs/$f" +done + +# Change ExtensionConfig to url: https://localhost + +# start webserver now (IDE?) rte-implementation-v1alpha1-secure + +# Deploy Extension +kubectl apply -f secure-extension.yaml +``` + +# Deploy insecure + +```bash +# Start poc/test/runtime-extension + +# Deploy Extension +kubectl apply -f ./extension.yaml +``` diff --git a/poc/local/extension.yaml b/poc/local/extension.yaml new file mode 100644 index 000000000000..8e4a30a4e1aa --- /dev/null +++ b/poc/local/extension.yaml @@ -0,0 +1,9 @@ +apiVersion: runtime.cluster.x-k8s.io/v1beta1 +kind: Extension +metadata: + name: "my-local-extensions" +spec: + clientConfig: + # TODO: test docker.for.mac.localhost / linux: some ip. + url: "http://localhost:8082" + namespaceSelector: {} diff --git a/poc/local/secure-extension.yaml b/poc/local/secure-extension.yaml new file mode 100644 index 000000000000..9989a14a8aeb --- /dev/null +++ b/poc/local/secure-extension.yaml @@ -0,0 +1,14 @@ +apiVersion: runtime.cluster.x-k8s.io/v1alpha1 +kind: ExtensionConfig +metadata: + name: "my-local-extensions" +spec: + clientConfig: + caBundle: $CA_BUNDLE +# service: +# name: my-local-extension +# namespace: default +# port: 8083 +# url: "https://my-local-extension.default.svc.cluster.local:8083" + url: "https://localhost:8083" + namespaceSelector: {} diff --git a/poc/local/secure-infra.yaml b/poc/local/secure-infra.yaml new file mode 100644 index 000000000000..bc0aef6af7d1 --- /dev/null +++ b/poc/local/secure-infra.yaml @@ -0,0 +1,34 @@ +apiVersion: v1 +kind: Service +metadata: + name: my-local-extension + namespace: default +spec: + type: ExternalName + externalName: docker.for.mac.localhost +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: my-local-extension-serving-cert + namespace: default +spec: + dnsNames: + - localhost + - my-local-extension.default.svc + - my-local-extension.default.svc.cluster.local + issuerRef: + kind: Issuer + name: my-local-extension-selfsigned-issuer + secretName: my-local-extension-cert + subject: + organizations: + - k8s-sig-cluster-lifecycle +--- +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: my-local-extension-selfsigned-issuer + namespace: default +spec: + selfSigned: {} diff --git a/test/e2e/cluster_upgrade_runtimesdk.go b/test/e2e/cluster_upgrade_runtimesdk.go index 98a02a11425d..3444644e0ddc 100644 --- a/test/e2e/cluster_upgrade_runtimesdk.go +++ b/test/e2e/cluster_upgrade_runtimesdk.go @@ -214,7 +214,10 @@ func clusterUpgradeWithRuntimeSDKSpec(ctx context.Context, inputGetter func() cl func extensionConfig(specName string, namespace *corev1.Namespace) *runtimev1.ExtensionConfig { return &runtimev1.ExtensionConfig{ ObjectMeta: metav1.ObjectMeta{ - Name: fmt.Sprintf("%s-%s", specName, util.RandomString(6)), + // FIXME(sbueringer): use constant name for now as we have to be able to reference it in the ClusterClass. + // Random generate later on again when Yuvaraj's PR has split up the cluster lifecycle. + //Name: fmt.Sprintf("%s-%s", specName, util.RandomString(6)), + Name: specName, Annotations: map[string]string{ runtimev1.InjectCAFromSecretAnnotation: fmt.Sprintf("%s/webhook-service-cert", namespace.Name), }, diff --git a/test/e2e/data/infrastructure-docker/v1beta1/clusterclass-quick-start-runtimesdk.yaml b/test/e2e/data/infrastructure-docker/v1beta1/clusterclass-quick-start-runtimesdk.yaml index f110589eb84f..d880f4cfa10c 100644 --- a/test/e2e/data/infrastructure-docker/v1beta1/clusterclass-quick-start-runtimesdk.yaml +++ b/test/e2e/data/infrastructure-docker/v1beta1/clusterclass-quick-start-runtimesdk.yaml @@ -72,11 +72,10 @@ spec: example: "0" description: "kubeadmControlPlaneMaxSurge is the maximum number of control planes that can be scheduled above or under the desired number of control plane machines." patches: -# TODO: enable external patches once topology mutation is implemented -# - name: lbImageRepository -# external: -# generateExtension: generate-patches.test-extension-config -# validateExtension: validate-topology.test-extension-config + - name: lbImageRepository + external: + generateExtension: generate-patches.k8s-upgrade-with-runtimesdk + validateExtension: validate-topology.k8s-upgrade-with-runtimesdk - name: imageRepository description: "Sets the imageRepository used for the KubeadmControlPlane." definitions: diff --git a/test/extension/handlers/topologymutation/handler.go b/test/extension/handlers/topologymutation/handler.go new file mode 100644 index 000000000000..c73a9c13ad72 --- /dev/null +++ b/test/extension/handlers/topologymutation/handler.go @@ -0,0 +1,80 @@ +/* +Copyright 2022 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package topologymutation contains the handlers for the topologymutation webhook. +package topologymutation + +import ( + "context" + "strconv" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer" + ctrl "sigs.k8s.io/controller-runtime" + + runtimehooksv1 "sigs.k8s.io/cluster-api/exp/runtime/hooks/api/v1alpha1" + patchvariables "sigs.k8s.io/cluster-api/internal/controllers/topology/cluster/patches/variables" + infrav1 "sigs.k8s.io/cluster-api/test/infrastructure/docker/api/v1beta1" +) + +// GeneratePatches returns a function that generates patches for the given request. +func GeneratePatches(scheme *runtime.Scheme) func(context.Context, *runtimehooksv1.GeneratePatchesRequest, *runtimehooksv1.GeneratePatchesResponse) { + decoder := serializer.NewCodecFactory(scheme).UniversalDecoder(infrav1.GroupVersion) + + return func(ctx context.Context, req *runtimehooksv1.GeneratePatchesRequest, resp *runtimehooksv1.GeneratePatchesResponse) { + log := ctrl.LoggerFrom(ctx) + log.Info("GeneratePatches called") + + walk(decoder, req, resp, func(obj runtime.Object, variables map[string]apiextensionsv1.JSON) error { + if dockerClusterTemplate, ok := obj.(*infrav1.DockerClusterTemplate); ok { + if err := patchDockerClusterTemplate(dockerClusterTemplate, variables); err != nil { + return err + } + } + + return nil + }) + } +} + +// patchDockerClusterTemplate patches the DockerClusterTepmlate. +func patchDockerClusterTemplate(dockerClusterTemplate *infrav1.DockerClusterTemplate, templateVariables map[string]apiextensionsv1.JSON) error { + // Get the variable value as JSON string. + value, err := patchvariables.GetVariableValue(templateVariables, "lbImageRepository") + if err != nil { + return err + } + + // Unquote the JSON string. + stringValue, err := strconv.Unquote(string(value.Raw)) + if err != nil { + return err + } + + dockerClusterTemplate.Spec.Template.Spec.LoadBalancer.ImageRepository = stringValue + return nil +} + +// ValidateTopology returns a function that validates the given request. +func ValidateTopology(_ *runtime.Scheme) func(context.Context, *runtimehooksv1.ValidateTopologyRequest, *runtimehooksv1.ValidateTopologyResponse) { + return func(ctx context.Context, req *runtimehooksv1.ValidateTopologyRequest, resp *runtimehooksv1.ValidateTopologyResponse) { + log := ctrl.LoggerFrom(ctx) + log.Info("ValidateTopology called") + + resp.Status = runtimehooksv1.ResponseStatusSuccess + } +} diff --git a/test/extension/handlers/topologymutation/lib.go b/test/extension/handlers/topologymutation/lib.go new file mode 100644 index 000000000000..ed0e0907d77a --- /dev/null +++ b/test/extension/handlers/topologymutation/lib.go @@ -0,0 +1,100 @@ +/* +Copyright 2022 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package topologymutation + +import ( + "encoding/json" + "fmt" + + "github.com/pkg/errors" + "gomodules.xyz/jsonpatch/v2" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/runtime" + + runtimehooksv1 "sigs.k8s.io/cluster-api/exp/runtime/hooks/api/v1alpha1" + patchvariables "sigs.k8s.io/cluster-api/internal/controllers/topology/cluster/patches/variables" +) + +// walk walks through all templates of a GeneratePatchesRequest and calls the mutateFunc. +func walk(decoder runtime.Decoder, req *runtimehooksv1.GeneratePatchesRequest, resp *runtimehooksv1.GeneratePatchesResponse, mutateFunc func(obj runtime.Object, variables map[string]apiextensionsv1.JSON) error) { + globalVariables := patchvariables.ToMap(req.Variables) + + for _, requestItem := range req.Items { + templateVariables, err := patchvariables.MergeVariableMaps(globalVariables, patchvariables.ToMap(requestItem.Variables)) + if err != nil { + resp.Status = runtimehooksv1.ResponseStatusFailure + resp.Message = err.Error() + return + } + + obj, _, err := decoder.Decode(requestItem.Object.Raw, nil, requestItem.Object.Object) + if err != nil { + // Continue, object has a type which hasn't been registered with the scheme. + continue + } + + original := obj.DeepCopyObject() + + if err := mutateFunc(obj, templateVariables); err != nil { + resp.Status = runtimehooksv1.ResponseStatusFailure + resp.Message = err.Error() + return + } + + patch, err := createPatch(original, obj) + if err != nil { + resp.Status = runtimehooksv1.ResponseStatusFailure + resp.Message = err.Error() + return + } + + resp.Items = append(resp.Items, runtimehooksv1.GeneratePatchesResponseItem{ + UID: requestItem.UID, + PatchType: runtimehooksv1.JSONPatchType, + Patch: patch, + }) + + fmt.Printf("Generated patch (uid: %q): %q\n", requestItem.UID, string(patch)) + } + + resp.Status = runtimehooksv1.ResponseStatusSuccess +} + +// createPatch creates a JSON patch from the original and the modified object. +func createPatch(original, modified runtime.Object) ([]byte, error) { + marshalledOriginal, err := json.Marshal(original) + if err != nil { + return nil, errors.Errorf("failed to marshal original object: %v", err) + } + + marshalledModified, err := json.Marshal(modified) + if err != nil { + return nil, errors.Errorf("failed to marshal modified object: %v", err) + } + + patch, err := jsonpatch.CreatePatch(marshalledOriginal, marshalledModified) + if err != nil { + return nil, errors.Errorf("failed to create patch: %v", err) + } + + patchBytes, err := json.Marshal(patch) + if err != nil { + return nil, errors.Errorf("failed to marshal patch: %v", err) + } + + return patchBytes, nil +} diff --git a/test/extension/main.go b/test/extension/main.go index 3e57c48f5b14..9624bde072da 100644 --- a/test/extension/main.go +++ b/test/extension/main.go @@ -26,11 +26,13 @@ import ( cliflag "k8s.io/component-base/cli/flag" "k8s.io/component-base/logs" "k8s.io/klog/v2" + "k8s.io/utils/pointer" ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/webhook" runtimehooksv1 "sigs.k8s.io/cluster-api/exp/runtime/hooks/api/v1alpha1" runtimecatalog "sigs.k8s.io/cluster-api/internal/runtime/catalog" + "sigs.k8s.io/cluster-api/test/extension/handlers/topologymutation" + "sigs.k8s.io/cluster-api/test/extension/server" infrav1 "sigs.k8s.io/cluster-api/test/infrastructure/docker/api/v1beta1" "sigs.k8s.io/cluster-api/version" ) @@ -92,22 +94,45 @@ func main() { ctx := ctrl.SetupSignalHandler() - srv := webhook.Server{ - Host: "", - Port: webhookPort, - CertDir: webhookCertDir, - CertName: "tls.crt", - KeyName: "tls.key", - WebhookMux: http.NewServeMux(), - TLSMinVersion: "1.2", + webhookServer, err := server.NewServer(server.Options{ + Catalog: catalog, + Port: webhookPort, + CertDir: webhookCertDir, + }) + if err != nil { + setupLog.Error(err, "error creating webhook server") + os.Exit(1) + } + + if err := webhookServer.AddExtensionHandler(server.ExtensionHandler{ + Hook: runtimehooksv1.GeneratePatches, + Name: "generate-patches", + HandlerFunc: topologymutation.GeneratePatches(scheme), + TimeoutSeconds: pointer.Int32(5), + FailurePolicy: toPtr(runtimehooksv1.FailurePolicyFail), + }); err != nil { + setupLog.Error(err, "error adding handler") + os.Exit(1) } - // TODO: next PRs - // srv.WebhookMux.Handle("/", operation1Handler) + if err := webhookServer.AddExtensionHandler(server.ExtensionHandler{ + Hook: runtimehooksv1.ValidateTopology, + Name: "validate-topology", + HandlerFunc: topologymutation.ValidateTopology(scheme), + TimeoutSeconds: pointer.Int32(5), + FailurePolicy: toPtr(runtimehooksv1.FailurePolicyFail), + }); err != nil { + setupLog.Error(err, "error adding handler") + os.Exit(1) + } setupLog.Info("starting RuntimeExtension", "version", version.Get().String()) - if err := srv.StartStandalone(ctx, nil); err != nil { - setupLog.Error(err, "problem running webhook") + if err := webhookServer.Start(ctx); err != nil { + setupLog.Error(err, "error running webhook server") os.Exit(1) } } + +func toPtr(f runtimehooksv1.FailurePolicy) *runtimehooksv1.FailurePolicy { + return &f +} diff --git a/test/extension/server/server.go b/test/extension/server/server.go new file mode 100644 index 000000000000..58e836242c7c --- /dev/null +++ b/test/extension/server/server.go @@ -0,0 +1,292 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package server contains the implementation of a RuntimeSDK webhook server. +package server + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "reflect" + + "github.com/gorilla/mux" + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook" + + runtimehooksv1 "sigs.k8s.io/cluster-api/exp/runtime/hooks/api/v1alpha1" + runtimecatalog "sigs.k8s.io/cluster-api/internal/runtime/catalog" +) + +// DefaultPort is the default port that the webhook server serves. +var DefaultPort = 9443 + +// Server is a runtime webhook server. +type Server struct { + catalog *runtimecatalog.Catalog + server *webhook.Server + handlers []ExtensionHandler +} + +// Options are the options for the Server. +type Options struct { + // Catalog is the catalog used to handle requests. + Catalog *runtimecatalog.Catalog + + // Port is the port that the webhook server serves at. + // It is used to set webhook.Server.Port. + Port int + + // Host is the hostname that the webhook server binds to. + // It is used to set webhook.Server.Host. + Host string + + // CertDir is the directory that contains the server key and certificate. + // If not set, webhook server would look up the server key and certificate in + // {TempDir}/k8s-webhook-server/serving-certs. The server key and certificate + // must be named tls.key and tls.crt, respectively. + // It is used to set webhook.Server.CertDir. + CertDir string +} + +// NewServer creates a new runtime webhook server based on the given Options. +func NewServer(options Options) (*Server, error) { + if options.Catalog == nil { + return nil, errors.Errorf("catalog is required") + } + if options.Port <= 0 { + options.Port = DefaultPort + } + if options.CertDir == "" { + options.CertDir = filepath.Join(os.TempDir(), "k8s-webhook-server", "serving-certs") + } + + webhookServer := &webhook.Server{ + Port: options.Port, + Host: options.Host, + CertDir: options.CertDir, + CertName: "tls.crt", + KeyName: "tls.key", + WebhookMux: http.NewServeMux(), + TLSMinVersion: "1.2", + } + + return &Server{ + catalog: options.Catalog, + server: webhookServer, + }, nil +} + +// ExtensionHandler represents an extension handler. +type ExtensionHandler struct { + // gvh is the gvh of the hook corresponding to the extension handler. + gvh runtimecatalog.GroupVersionHook + // requestObject is a runtime object that the handler expects to receive. + requestObject runtime.Object + // responseObject is a runtime object that the handler expects to return. + responseObject runtime.Object + + // Hook is the corresponding hook of the handler. + Hook runtimecatalog.Hook + + // Name is the name of the extension handler. + Name string + + // HandlerFunc is the handler function. + HandlerFunc runtimecatalog.Hook + + // TimeoutSeconds is the timeout of the extension handler. + TimeoutSeconds *int32 + + // FailurePolicy is the failure policy of the extension handler + FailurePolicy *runtimehooksv1.FailurePolicy +} + +// AddExtensionHandler adds an extension handler to the server. +func (s *Server) AddExtensionHandler(handler ExtensionHandler) error { + gvh, err := s.catalog.GroupVersionHook(handler.Hook) + if err != nil { + return errors.Wrapf(err, "hook %q does not exist in catalog", runtimecatalog.HookName(handler.Hook)) + } + handler.gvh = gvh + + requestObject, err := s.catalog.NewRequest(handler.gvh) + if err != nil { + return err + } + handler.requestObject = requestObject + + responseObject, err := s.catalog.NewResponse(handler.gvh) + if err != nil { + return err + } + handler.responseObject = responseObject + + if err := s.validateHandler(handler); err != nil { + return err + } + + s.handlers = append(s.handlers, handler) + return nil +} + +// validateHandler validates a handler. +func (s *Server) validateHandler(handler ExtensionHandler) error { + // Get hook and handler type. + hookFuncType := reflect.TypeOf(handler.Hook) + handlerFuncType := reflect.TypeOf(handler.HandlerFunc) + + // Validate handler function signature. + if handlerFuncType.Kind() != reflect.Func { + return errors.Errorf("HandlerFunc must be a func") + } + if handlerFuncType.NumIn() != 3 { + return errors.Errorf("HandlerFunc must have three input parameter") + } + if handlerFuncType.NumOut() != 0 { + return errors.Errorf("HandlerFunc must have no output parameter") + } + + // Get hook and handler request and response types. + hookRequestType := hookFuncType.In(0) //nolint:ifshort + hookResponseType := hookFuncType.In(1) //nolint:ifshort + handlerContextType := handlerFuncType.In(0) + handlerRequestType := handlerFuncType.In(1) + handlerResponseType := handlerFuncType.In(2) + + // Validate handler request and response are pointers. + if handlerRequestType.Kind() != reflect.Ptr { + return errors.Errorf("HandlerFunc request type must be a pointer") + } + if handlerResponseType.Kind() != reflect.Ptr { + return errors.Errorf("HandlerFunc response type must be a pointer") + } + + // Validate first handler parameter is a context + // TODO: improve check, how to check if param is a specific interface? + if handlerContextType.Name() != "Context" { + return errors.Errorf("HandlerFunc first parameter must be Context but is %s", handlerContextType.Name()) + } + + // Validate hook and handler request and response types are equal. + if hookRequestType != handlerRequestType { + return errors.Errorf("HandlerFunc request type must be *%s but is *%s", hookRequestType.Elem().Name(), handlerRequestType.Elem().Name()) + } + if hookResponseType != handlerResponseType { + return errors.Errorf("HandlerFunc response type must be *%s but is *%s", hookResponseType.Elem().Name(), handlerResponseType.Elem().Name()) + } + + return nil +} + +// Start starts the server. +func (s *Server) Start(ctx context.Context) error { + r := mux.NewRouter() + + // Add discovery handler. + err := s.AddExtensionHandler(ExtensionHandler{ + Hook: runtimehooksv1.Discovery, + HandlerFunc: discoveryHandler(s.handlers), + }) + if err != nil { + return err + } + + // Add handlers to router. + for _, h := range s.handlers { + handler := h + + wrappedHandler := s.wrapHandler(handler) + handlerPath := runtimecatalog.GVHToPath(handler.gvh, handler.Name) + r.HandleFunc(handlerPath, wrappedHandler).Methods("POST") + } + + s.server.WebhookMux.Handle("/", r) + + return s.server.StartStandalone(ctx, nil) +} + +// discoveryHandler generates a discovery handler based on a list of handlers. +func discoveryHandler(handlers []ExtensionHandler) func(context.Context, *runtimehooksv1.DiscoveryRequest, *runtimehooksv1.DiscoveryResponse) { + return func(_ context.Context, request *runtimehooksv1.DiscoveryRequest, response *runtimehooksv1.DiscoveryResponse) { + response.Status = runtimehooksv1.ResponseStatusSuccess + + for _, handler := range handlers { + response.Handlers = append(response.Handlers, runtimehooksv1.ExtensionHandler{ + Name: handler.Name, + RequestHook: runtimehooksv1.GroupVersionHook{ + APIVersion: handler.gvh.GroupVersion().String(), + Hook: handler.gvh.Hook, + }, + TimeoutSeconds: handler.TimeoutSeconds, + FailurePolicy: handler.FailurePolicy, + }) + } + } +} + +func (s *Server) wrapHandler(handler ExtensionHandler) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + response := s.callHandler(handler, r) + + responseBody, err := json.Marshal(response) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _, _ = fmt.Fprintf(w, "unable to marshal response: %v", err) + return + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write(responseBody) + } +} + +func (s *Server) callHandler(handler ExtensionHandler, r *http.Request) runtimehooksv1.ResponseObject { + request := handler.requestObject.DeepCopyObject() + response := handler.responseObject.DeepCopyObject().(runtimehooksv1.ResponseObject) + + requestBody, err := io.ReadAll(r.Body) + if err != nil { + response.SetStatus(runtimehooksv1.ResponseStatusFailure) + response.SetMessage(fmt.Sprintf("error reading request: %v", err)) + return response + } + + if err := json.Unmarshal(requestBody, request); err != nil { + response.SetStatus(runtimehooksv1.ResponseStatusFailure) + response.SetMessage(fmt.Sprintf("error unmarshalling request: %v", err)) + return response + } + + // log.Log is the logger previously set via ctrl.SetLogger. + // This implemented analog to the logger in the controller-runtime manager. + ctx := ctrl.LoggerInto(r.Context(), log.Log) + + reflect.ValueOf(handler.HandlerFunc).Call([]reflect.Value{ + reflect.ValueOf(ctx), + reflect.ValueOf(request), + reflect.ValueOf(response), + }) + + return response +} diff --git a/test/go.mod b/test/go.mod index 52673760f28a..74d9e6dc6c13 100644 --- a/test/go.mod +++ b/test/go.mod @@ -10,11 +10,13 @@ require ( github.com/docker/go-connections v0.4.0 github.com/flatcar-linux/ignition v0.36.1 github.com/go-logr/logr v1.2.0 + github.com/gorilla/mux v1.8.0 github.com/onsi/ginkgo v1.16.5 github.com/onsi/gomega v1.18.1 github.com/pkg/errors v0.9.1 github.com/spf13/pflag v1.0.5 github.com/vincent-petithory/dataurl v1.0.0 + gomodules.xyz/jsonpatch/v2 v2.2.0 k8s.io/api v0.24.0 k8s.io/apiextensions-apiserver v0.24.0 k8s.io/apimachinery v0.24.0 @@ -115,7 +117,6 @@ require ( golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect golang.org/x/text v0.3.7 // indirect golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 // indirect - gomodules.xyz/jsonpatch/v2 v2.2.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto v0.0.0-20220107163113-42d7afdf6368 // indirect google.golang.org/protobuf v1.27.1 // indirect diff --git a/test/go.sum b/test/go.sum index 522c45e9da84..2fa6c775486d 100644 --- a/test/go.sum +++ b/test/go.sum @@ -358,6 +358,7 @@ github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0 github.com/googleapis/gnostic v0.5.1/go.mod h1:6U4PtQXGIEt/Z3h5MAT7FNofLnw9vXk2cUuW7uA/OeU= github.com/googleapis/gnostic v0.5.5/go.mod h1:7+EbHbldMins07ALC74bsA81Ovc97DwqyJO1AENw9kA= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=