From c26bfce8c7597e0fad0564bb274c78d7da404f9d Mon Sep 17 00:00:00 2001 From: Mike Beaumont Date: Tue, 4 Jun 2024 14:58:39 +0200 Subject: [PATCH] feat(HostnameGenerator): apply templates to MeshServices (#10362) Signed-off-by: Mike Beaumont --- app/kuma-cp/cmd/run.go | 5 + ...install-control-plane.defaults.golden.yaml | 72 +++++ ...all-control-plane.gateway-api-present.yaml | 72 +++++ .../install-control-plane.with-helm-set.yaml | 72 +++++ .../testdata/install-crds.all.golden.yaml | 72 +++++ .../kuma/crds/kuma.io_meshservices.yaml | 72 +++++ .../raw/crds/kuma.io_meshservices.yaml | 72 +++++ pkg/config/app/kuma-cp/config.go | 2 +- .../api/v1alpha1/hostnamegenerator.go | 13 + .../api/v1alpha1/v1alpha1_suite_test.go | 11 + .../api/v1alpha1/validate.go | 25 ++ .../api/v1alpha1/validate_test.go | 32 +++ .../hostnamegenerator/hostname/generator.go | 265 ++++++++++++++++++ .../hostname/generator_test.go | 161 +++++++++++ .../hostnamegenerator/hostname/suite_test.go | 11 + .../meshservice/api/v1alpha1/meshservice.go | 81 +++++- .../api/v1alpha1/zz_generated.deepcopy.go | 59 ++++ .../k8s/crd/kuma.io_meshservices.yaml | 72 +++++ pkg/dns/hostname_generator.go | 37 +++ .../resources/builders/meshservice_builder.go | 13 +- 20 files changed, 1213 insertions(+), 6 deletions(-) create mode 100644 pkg/core/resources/apis/hostnamegenerator/api/v1alpha1/v1alpha1_suite_test.go create mode 100644 pkg/core/resources/apis/hostnamegenerator/api/v1alpha1/validate.go create mode 100644 pkg/core/resources/apis/hostnamegenerator/api/v1alpha1/validate_test.go create mode 100644 pkg/core/resources/apis/hostnamegenerator/hostname/generator.go create mode 100644 pkg/core/resources/apis/hostnamegenerator/hostname/generator_test.go create mode 100644 pkg/core/resources/apis/hostnamegenerator/hostname/suite_test.go create mode 100644 pkg/dns/hostname_generator.go diff --git a/app/kuma-cp/cmd/run.go b/app/kuma-cp/cmd/run.go index d79659f2e615..d675974899cd 100644 --- a/app/kuma-cp/cmd/run.go +++ b/app/kuma-cp/cmd/run.go @@ -15,6 +15,7 @@ import ( "github.com/kumahq/kuma/pkg/core/bootstrap" "github.com/kumahq/kuma/pkg/defaults" "github.com/kumahq/kuma/pkg/diagnostics" + "github.com/kumahq/kuma/pkg/dns" dp_server "github.com/kumahq/kuma/pkg/dp-server" "github.com/kumahq/kuma/pkg/gc" "github.com/kumahq/kuma/pkg/hds" @@ -150,6 +151,10 @@ func newRunCmdWithOpts(opts kuma_cmd.RunCmdOpts) *cobra.Command { runLog.Error(err, "unable to set up IPAM") return err } + if err := dns.SetupHostnameGenerator(rt); err != nil { + runLog.Error(err, "unable to set up hostname generator") + return err + } runLog.Info("starting Control Plane", "version", kuma_version.Build.Version) if err := rt.Start(gracefulCtx.Done()); err != nil { diff --git a/app/kumactl/cmd/install/testdata/install-control-plane.defaults.golden.yaml b/app/kumactl/cmd/install/testdata/install-control-plane.defaults.golden.yaml index 7b0b3eb831e6..054d95b96396 100644 --- a/app/kumactl/cmd/install/testdata/install-control-plane.defaults.golden.yaml +++ b/app/kumactl/cmd/install/testdata/install-control-plane.defaults.golden.yaml @@ -5105,6 +5105,78 @@ spec: properties: hostname: type: string + hostnameGeneratorRef: + properties: + name: + type: string + required: + - name + type: object + origin: + type: string + type: object + type: array + hostnameGenerators: + items: + properties: + conditions: + description: Conditions is an array of gateway instance conditions. + items: + properties: + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, + Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: |- + type of condition in CamelCase or in foo.example.com/CamelCase. + --- + Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be + useful (see .node.status.conditions), the ability to deconflict is important. + The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + hostnameGeneratorRef: + properties: + name: + type: string + required: + - name + type: object + required: + - hostnameGeneratorRef type: object type: array tls: diff --git a/app/kumactl/cmd/install/testdata/install-control-plane.gateway-api-present.yaml b/app/kumactl/cmd/install/testdata/install-control-plane.gateway-api-present.yaml index decc651d84d9..a63b6ee2ddee 100644 --- a/app/kumactl/cmd/install/testdata/install-control-plane.gateway-api-present.yaml +++ b/app/kumactl/cmd/install/testdata/install-control-plane.gateway-api-present.yaml @@ -5105,6 +5105,78 @@ spec: properties: hostname: type: string + hostnameGeneratorRef: + properties: + name: + type: string + required: + - name + type: object + origin: + type: string + type: object + type: array + hostnameGenerators: + items: + properties: + conditions: + description: Conditions is an array of gateway instance conditions. + items: + properties: + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, + Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: |- + type of condition in CamelCase or in foo.example.com/CamelCase. + --- + Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be + useful (see .node.status.conditions), the ability to deconflict is important. + The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + hostnameGeneratorRef: + properties: + name: + type: string + required: + - name + type: object + required: + - hostnameGeneratorRef type: object type: array tls: diff --git a/app/kumactl/cmd/install/testdata/install-control-plane.with-helm-set.yaml b/app/kumactl/cmd/install/testdata/install-control-plane.with-helm-set.yaml index 1f2ae10ea1e2..9a6a54c80c93 100644 --- a/app/kumactl/cmd/install/testdata/install-control-plane.with-helm-set.yaml +++ b/app/kumactl/cmd/install/testdata/install-control-plane.with-helm-set.yaml @@ -5125,6 +5125,78 @@ spec: properties: hostname: type: string + hostnameGeneratorRef: + properties: + name: + type: string + required: + - name + type: object + origin: + type: string + type: object + type: array + hostnameGenerators: + items: + properties: + conditions: + description: Conditions is an array of gateway instance conditions. + items: + properties: + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, + Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: |- + type of condition in CamelCase or in foo.example.com/CamelCase. + --- + Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be + useful (see .node.status.conditions), the ability to deconflict is important. + The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + hostnameGeneratorRef: + properties: + name: + type: string + required: + - name + type: object + required: + - hostnameGeneratorRef type: object type: array tls: diff --git a/app/kumactl/cmd/install/testdata/install-crds.all.golden.yaml b/app/kumactl/cmd/install/testdata/install-crds.all.golden.yaml index c3da687945b9..c555e81bf75f 100644 --- a/app/kumactl/cmd/install/testdata/install-crds.all.golden.yaml +++ b/app/kumactl/cmd/install/testdata/install-crds.all.golden.yaml @@ -6537,6 +6537,78 @@ spec: properties: hostname: type: string + hostnameGeneratorRef: + properties: + name: + type: string + required: + - name + type: object + origin: + type: string + type: object + type: array + hostnameGenerators: + items: + properties: + conditions: + description: Conditions is an array of gateway instance conditions. + items: + properties: + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, + Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: |- + type of condition in CamelCase or in foo.example.com/CamelCase. + --- + Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be + useful (see .node.status.conditions), the ability to deconflict is important. + The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + hostnameGeneratorRef: + properties: + name: + type: string + required: + - name + type: object + required: + - hostnameGeneratorRef type: object type: array tls: diff --git a/deployments/charts/kuma/crds/kuma.io_meshservices.yaml b/deployments/charts/kuma/crds/kuma.io_meshservices.yaml index 3e1f5f86c68e..b7d748923186 100644 --- a/deployments/charts/kuma/crds/kuma.io_meshservices.yaml +++ b/deployments/charts/kuma/crds/kuma.io_meshservices.yaml @@ -84,6 +84,78 @@ spec: properties: hostname: type: string + hostnameGeneratorRef: + properties: + name: + type: string + required: + - name + type: object + origin: + type: string + type: object + type: array + hostnameGenerators: + items: + properties: + conditions: + description: Conditions is an array of gateway instance conditions. + items: + properties: + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, + Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: |- + type of condition in CamelCase or in foo.example.com/CamelCase. + --- + Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be + useful (see .node.status.conditions), the ability to deconflict is important. + The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + hostnameGeneratorRef: + properties: + name: + type: string + required: + - name + type: object + required: + - hostnameGeneratorRef type: object type: array tls: diff --git a/docs/generated/raw/crds/kuma.io_meshservices.yaml b/docs/generated/raw/crds/kuma.io_meshservices.yaml index 3e1f5f86c68e..b7d748923186 100644 --- a/docs/generated/raw/crds/kuma.io_meshservices.yaml +++ b/docs/generated/raw/crds/kuma.io_meshservices.yaml @@ -84,6 +84,78 @@ spec: properties: hostname: type: string + hostnameGeneratorRef: + properties: + name: + type: string + required: + - name + type: object + origin: + type: string + type: object + type: array + hostnameGenerators: + items: + properties: + conditions: + description: Conditions is an array of gateway instance conditions. + items: + properties: + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, + Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: |- + type of condition in CamelCase or in foo.example.com/CamelCase. + --- + Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be + useful (see .node.status.conditions), the ability to deconflict is important. + The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + hostnameGeneratorRef: + properties: + name: + type: string + required: + - name + type: object + required: + - hostnameGeneratorRef type: object type: array tls: diff --git a/pkg/config/app/kuma-cp/config.go b/pkg/config/app/kuma-cp/config.go index 4e1a9af04e83..0e53ed968794 100644 --- a/pkg/config/app/kuma-cp/config.go +++ b/pkg/config/app/kuma-cp/config.go @@ -471,7 +471,7 @@ type ExperimentalKDSEventBasedWatchdog struct { type IPAMConfig struct { MeshService MeshServiceIPAM `json:"meshService"` MeshExternalService MeshExternalServiceIPAM `json:"meshExternalService"` - // Interval on which Kuma will allocate new IPs. + // Interval on which Kuma will allocate new IPs and generate hostnames. AllocationInterval config_types.Duration `json:"allocationInterval" envconfig:"KUMA_IPAM_ALLOCATION_INTERVAL"` } diff --git a/pkg/core/resources/apis/hostnamegenerator/api/v1alpha1/hostnamegenerator.go b/pkg/core/resources/apis/hostnamegenerator/api/v1alpha1/hostnamegenerator.go index 1d4c13d3c270..2afd984d278e 100644 --- a/pkg/core/resources/apis/hostnamegenerator/api/v1alpha1/hostnamegenerator.go +++ b/pkg/core/resources/apis/hostnamegenerator/api/v1alpha1/hostnamegenerator.go @@ -9,6 +9,19 @@ type Selector struct { MeshService LabelSelector `json:"meshService,omitempty"` } +func (s LabelSelector) Matches(labels map[string]string) bool { + for tag, matchValue := range s.MatchLabels { + labelValue, exist := labels[tag] + if !exist { + return false + } + if matchValue != labelValue { + return false + } + } + return true +} + // HostnameGenerator // +kuma:policy:is_policy=false type HostnameGenerator struct { diff --git a/pkg/core/resources/apis/hostnamegenerator/api/v1alpha1/v1alpha1_suite_test.go b/pkg/core/resources/apis/hostnamegenerator/api/v1alpha1/v1alpha1_suite_test.go new file mode 100644 index 000000000000..24297b053801 --- /dev/null +++ b/pkg/core/resources/apis/hostnamegenerator/api/v1alpha1/v1alpha1_suite_test.go @@ -0,0 +1,11 @@ +package v1alpha1_test + +import ( + "testing" + + "github.com/kumahq/kuma/pkg/test" +) + +func TestPlugin(t *testing.T) { + test.RunSpecs(t, "HostnameGenerator") +} diff --git a/pkg/core/resources/apis/hostnamegenerator/api/v1alpha1/validate.go b/pkg/core/resources/apis/hostnamegenerator/api/v1alpha1/validate.go new file mode 100644 index 000000000000..3ec78d0ec024 --- /dev/null +++ b/pkg/core/resources/apis/hostnamegenerator/api/v1alpha1/validate.go @@ -0,0 +1,25 @@ +package v1alpha1 + +import ( + "text/template" + + "github.com/pkg/errors" + + "github.com/kumahq/kuma/pkg/core/validators" +) + +func (r *HostnameGeneratorResource) validate() error { + var verr validators.ValidationError + path := validators.RootedAt("spec") + verr.AddErrorAt(path.Field("template"), validateTemplate(r.Spec.Template)) + return verr.OrNil() +} + +func validateTemplate(tmpl string) validators.ValidationError { + var verr validators.ValidationError + _, err := template.New("").Parse(tmpl) + if err != nil { + verr.AddViolationAt(validators.Root(), errors.Wrap(err, "couldn't parse template").Error()) + } + return verr +} diff --git a/pkg/core/resources/apis/hostnamegenerator/api/v1alpha1/validate_test.go b/pkg/core/resources/apis/hostnamegenerator/api/v1alpha1/validate_test.go new file mode 100644 index 000000000000..d982fc9cde9f --- /dev/null +++ b/pkg/core/resources/apis/hostnamegenerator/api/v1alpha1/validate_test.go @@ -0,0 +1,32 @@ +package v1alpha1_test + +import ( + . "github.com/onsi/ginkgo/v2" + + api "github.com/kumahq/kuma/pkg/core/resources/apis/hostnamegenerator/api/v1alpha1" + "github.com/kumahq/kuma/pkg/core/validators" + . "github.com/kumahq/kuma/pkg/test/resources/validators" +) + +var _ = Describe("validation", func() { + DescribeErrorCases( + api.NewHostnameGeneratorResource, + ErrorCase("spec.template error", + validators.Violation{ + Field: `spec.template`, + Message: `couldn't parse template: template: :1: bad character U+005B '['`, + }, ` +type: HostnameGenerator +name: route-1 +template: "{{ .Name[4 }}.mesh" +`), + ) + DescribeValidCases( + api.NewHostnameGeneratorResource, + Entry("accepts valid resource", ` +type: HostnameGenerator +name: route-1 +template: "{{ .Name }}.mesh" +`), + ) +}) diff --git a/pkg/core/resources/apis/hostnamegenerator/hostname/generator.go b/pkg/core/resources/apis/hostnamegenerator/hostname/generator.go new file mode 100644 index 000000000000..de7f2d45da40 --- /dev/null +++ b/pkg/core/resources/apis/hostnamegenerator/hostname/generator.go @@ -0,0 +1,265 @@ +package hostname + +import ( + "context" + "fmt" + "reflect" + "slices" + "strings" + "text/template" + "time" + + "github.com/go-logr/logr" + "github.com/pkg/errors" + "github.com/prometheus/client_golang/prometheus" + kube_meta "k8s.io/apimachinery/pkg/apis/meta/v1" + + mesh_proto "github.com/kumahq/kuma/api/mesh/v1alpha1" + hostnamegenerator_api "github.com/kumahq/kuma/pkg/core/resources/apis/hostnamegenerator/api/v1alpha1" + meshservice_api "github.com/kumahq/kuma/pkg/core/resources/apis/meshservice/api/v1alpha1" + "github.com/kumahq/kuma/pkg/core/resources/manager" + "github.com/kumahq/kuma/pkg/core/resources/model" + "github.com/kumahq/kuma/pkg/core/runtime/component" + "github.com/kumahq/kuma/pkg/core/user" + core_metrics "github.com/kumahq/kuma/pkg/metrics" + "github.com/kumahq/kuma/pkg/util/maps" +) + +type Generator struct { + logger logr.Logger + interval time.Duration + metric prometheus.Summary + resManager manager.ResourceManager +} + +var _ component.Component = &Generator{} + +func NewGenerator( + logger logr.Logger, + metrics core_metrics.Metrics, + resManager manager.ResourceManager, + interval time.Duration, +) (*Generator, error) { + metric := prometheus.NewSummary(prometheus.SummaryOpts{ + Name: "component_hostname_generator_ms", + Help: "Summary of hostname generator interval", + Objectives: core_metrics.DefaultObjectives, + }) + if err := metrics.Register(metric); err != nil { + return nil, err + } + + return &Generator{ + logger: logger, + resManager: resManager, + interval: interval, + metric: metric, + }, nil +} + +func (g *Generator) Start(stop <-chan struct{}) error { + g.logger.Info("starting") + ticker := time.NewTicker(g.interval) + ctx := user.Ctx(context.Background(), user.ControlPlane) + + for { + select { + case <-ticker.C: + start := time.Now() + if err := g.generateHostnames(ctx); err != nil { + return err + } + g.metric.Observe(float64(time.Since(start).Milliseconds())) + case <-stop: + g.logger.Info("stopping") + return nil + } + } +} + +func apply(generator *hostnamegenerator_api.HostnameGeneratorResource, service *meshservice_api.MeshServiceResource) (string, error) { + if !generator.Spec.Selector.MeshService.Matches(service.Meta.GetLabels()) { + return "", nil + } + sb := strings.Builder{} + tmpl := template.New("").Funcs( + map[string]any{ + "label": func(key string) (string, error) { + val, ok := service.GetMeta().GetLabels()[key] + if !ok { + return "", errors.Errorf("label %s not found", key) + } + return val, nil + }, + }, + ) + tmpl, err := tmpl.Parse(generator.Spec.Template) + if err != nil { + return "", fmt.Errorf("failed compiling gotemplate error=%q", err.Error()) + } + type meshedName struct { + Name string + Namespace string + Mesh string + } + err = tmpl.Execute(&sb, meshedName{ + Name: service.GetMeta().GetNameExtensions()[model.K8sNameComponent], + Namespace: service.GetMeta().GetNameExtensions()[model.K8sNamespaceComponent], + Mesh: service.GetMeta().GetMesh(), + }) + if err != nil { + return "", fmt.Errorf("pre evaluation of template with parameters failed with error=%q", err.Error()) + } + return sb.String(), nil +} + +func sortGenerators(generators []*hostnamegenerator_api.HostnameGeneratorResource) []*hostnamegenerator_api.HostnameGeneratorResource { + sorted := slices.Clone(generators) + slices.SortFunc(sorted, func(a, b *hostnamegenerator_api.HostnameGeneratorResource) int { + if a, b := a.Meta.GetLabels()[mesh_proto.ResourceOriginLabel], b.Meta.GetLabels()[mesh_proto.ResourceOriginLabel]; a != b { + if a == string(mesh_proto.ZoneResourceOrigin) { + return -1 + } else if b == string(mesh_proto.ZoneResourceOrigin) { + return 1 + } + } + if a, b := a.Meta.GetCreationTime(), b.Meta.GetCreationTime(); a.Before(b) { + return -1 + } else if a.After(b) { + return 1 + } + return strings.Compare(a.Meta.GetName(), b.Meta.GetName()) + }) + return sorted +} + +func (g *Generator) generateHostnames(ctx context.Context) error { + services := &meshservice_api.MeshServiceResourceList{} + if err := g.resManager.List(ctx, services); err != nil { + return errors.Wrap(err, "could not list MeshServices") + } + + generators := &hostnamegenerator_api.HostnameGeneratorResourceList{} + if err := g.resManager.List(ctx, generators); err != nil { + return errors.Wrap(err, "could not list HostnameGenerators") + } + + type serviceKey struct { + name string + mesh string + } + type status struct { + hostname string + conditions []meshservice_api.Condition + } + type meshName string + type serviceName string + type hostname string + generatedHostnames := map[meshName]map[hostname]serviceName{} + newStatuses := map[serviceKey]map[string]status{} + for _, generator := range sortGenerators(generators.Items) { + for _, service := range services.Items { + serviceKey := serviceKey{ + name: service.GetMeta().GetName(), + mesh: service.GetMeta().GetMesh(), + } + generatorStatuses, ok := newStatuses[serviceKey] + if !ok { + generatorStatuses = map[string]status{} + } + + generated, err := apply(generator, service) + + var conditions []meshservice_api.Condition + if generated != "" || err != nil { + generationConditionStatus := kube_meta.ConditionUnknown + reason := "Pending" + var message string + if err != nil { + generationConditionStatus = kube_meta.ConditionFalse + reason = meshservice_api.TemplateErrorReason + message = err.Error() + } + if generated != "" { + if svcName, ok := generatedHostnames[meshName(serviceKey.mesh)][hostname(generated)]; ok && string(svcName) != serviceKey.name { + generationConditionStatus = kube_meta.ConditionFalse + reason = meshservice_api.CollisionReason + message = fmt.Sprintf("Hostname collision with MeshService %s", serviceKey.name) + generated = "" + } else { + generationConditionStatus = kube_meta.ConditionTrue + reason = meshservice_api.GeneratedReason + meshHostnames, ok := generatedHostnames[meshName(serviceKey.mesh)] + if !ok { + meshHostnames = map[hostname]serviceName{} + } + meshHostnames[hostname(generated)] = serviceName(serviceKey.name) + generatedHostnames[meshName(serviceKey.mesh)] = meshHostnames + } + } + condition := meshservice_api.Condition{ + Type: meshservice_api.GeneratedCondition, + Status: generationConditionStatus, + Reason: reason, + Message: message, + } + conditions = []meshservice_api.Condition{ + condition, + } + } + + generatorStatuses[generator.GetMeta().GetName()] = status{ + hostname: generated, + conditions: conditions, + } + newStatuses[serviceKey] = generatorStatuses + } + } + + for _, service := range services.Items { + statuses := newStatuses[serviceKey{ + name: service.GetMeta().GetName(), + mesh: service.GetMeta().GetMesh(), + }] + var addresses []meshservice_api.Address + var generatorStatuses []meshservice_api.HostnameGeneratorStatus + + for _, generator := range maps.SortedKeys(statuses) { + status := statuses[generator] + ref := meshservice_api.HostnameGeneratorRef{ + CoreName: generator, + } + if status.hostname == "" && len(status.conditions) == 0 { + continue + } + if status.hostname != "" { + addresses = append( + addresses, + meshservice_api.Address{ + Hostname: status.hostname, + Origin: meshservice_api.OriginGenerator, + HostnameGeneratorRef: ref, + }, + ) + } + generatorStatuses = append(generatorStatuses, meshservice_api.HostnameGeneratorStatus{ + HostnameGeneratorRef: ref, + Conditions: status.conditions, + }) + } + if reflect.DeepEqual(addresses, service.Status.Addresses) && reflect.DeepEqual(generatorStatuses, service.Status.HostnameGenerators) { + continue + } + service.Status.Addresses = addresses + service.Status.HostnameGenerators = generatorStatuses + if err := g.resManager.Update(ctx, service); err != nil { + return errors.Wrap(err, "couldn't update MeshService status") + } + } + + return nil +} + +func (g *Generator) NeedLeaderElection() bool { + return true +} diff --git a/pkg/core/resources/apis/hostnamegenerator/hostname/generator_test.go b/pkg/core/resources/apis/hostnamegenerator/hostname/generator_test.go new file mode 100644 index 000000000000..bff9c2951401 --- /dev/null +++ b/pkg/core/resources/apis/hostnamegenerator/hostname/generator_test.go @@ -0,0 +1,161 @@ +package hostname_test + +import ( + "context" + "time" + + "github.com/go-logr/logr" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + kube_meta "k8s.io/apimachinery/pkg/apis/meta/v1" + + hostnamegenerator_api "github.com/kumahq/kuma/pkg/core/resources/apis/hostnamegenerator/api/v1alpha1" + "github.com/kumahq/kuma/pkg/core/resources/apis/hostnamegenerator/hostname" + meshservice_api "github.com/kumahq/kuma/pkg/core/resources/apis/meshservice/api/v1alpha1" + "github.com/kumahq/kuma/pkg/core/resources/manager" + "github.com/kumahq/kuma/pkg/core/resources/model" + core_model "github.com/kumahq/kuma/pkg/core/resources/model" + "github.com/kumahq/kuma/pkg/core/resources/store" + core_metrics "github.com/kumahq/kuma/pkg/metrics" + "github.com/kumahq/kuma/pkg/plugins/resources/memory" + test_model "github.com/kumahq/kuma/pkg/test/resources/model" + "github.com/kumahq/kuma/pkg/test/resources/samples" +) + +var _ = Describe("Hostname Generator", func() { + var stopChSend chan<- struct{} + var resManager manager.ResourceManager + + BeforeEach(func() { + m, err := core_metrics.NewMetrics("") + Expect(err).ToNot(HaveOccurred()) + resManager = manager.NewResourceManager(memory.NewStore()) + allocator, err := hostname.NewGenerator(logr.Discard(), m, resManager, 50*time.Millisecond) + Expect(err).ToNot(HaveOccurred()) + ch := make(chan struct{}) + var stopChRecv <-chan struct{} + stopChSend, stopChRecv = ch, ch + go func() { + defer GinkgoRecover() + Expect(allocator.Start(stopChRecv)).To(Succeed()) + }() + + Expect(samples.MeshDefaultBuilder().Create(resManager)).To(Succeed()) + + generator := hostnamegenerator_api.NewHostnameGeneratorResource() + generator.Meta = &test_model.ResourceMeta{ + Mesh: core_model.DefaultMesh, + Name: "backend", + } + generator.Spec = &hostnamegenerator_api.HostnameGenerator{ + Template: "{{ .Name }}.mesh", + Selector: hostnamegenerator_api.Selector{ + MeshService: hostnamegenerator_api.LabelSelector{ + MatchLabels: map[string]string{ + "label": "value", + }, + }, + }, + } + Expect(resManager.Create(context.Background(), generator, store.CreateBy(core_model.MetaToResourceKey(generator.GetMeta())))).To(Succeed()) + generator = hostnamegenerator_api.NewHostnameGeneratorResource() + generator.Meta = &test_model.ResourceMeta{ + Mesh: core_model.DefaultMesh, + Name: "static", + } + generator.Spec = &hostnamegenerator_api.HostnameGenerator{ + Template: "static.mesh", + Selector: hostnamegenerator_api.Selector{ + MeshService: hostnamegenerator_api.LabelSelector{ + MatchLabels: map[string]string{ + "generate": "static", + }, + }, + }, + } + Expect(resManager.Create(context.Background(), generator, store.CreateBy(core_model.MetaToResourceKey(generator.GetMeta())))).To(Succeed()) + }) + + AfterEach(func() { + close(stopChSend) + Expect(resManager.Delete(context.Background(), &meshservice_api.MeshServiceResource{}, store.DeleteByKey("backend", "default"))).To(Succeed()) + }) + + vipOfMeshService := func(name string) *meshservice_api.MeshServiceStatus { + ms := meshservice_api.NewMeshServiceResource() + err := resManager.Get(context.Background(), ms, store.GetByKey(name, model.DefaultMesh)) + Expect(err).ToNot(HaveOccurred()) + return ms.Status + } + + It("should not generate hostname if no generator selects a given MeshService", func() { + // when + err := samples.MeshServiceBackendBuilder().WithoutVIP().Create(resManager) + Expect(err).ToNot(HaveOccurred()) + + // then + Eventually(func(g Gomega) { + status := vipOfMeshService("backend") + g.Expect(status.Addresses).Should(BeEmpty()) + g.Expect(status.HostnameGenerators).Should(BeEmpty()) + }, "10s", "100ms").Should(Succeed()) + }) + + It("should generate hostname if a generator selects a given MeshService", func() { + // when + err := samples.MeshServiceBackendBuilder().WithoutVIP().WithLabels(map[string]string{ + "label": "value", + }).Create(resManager) + Expect(err).ToNot(HaveOccurred()) + + // then + Eventually(func(g Gomega) { + status := vipOfMeshService("backend") + g.Expect(status.Addresses).Should(Not(BeEmpty())) + g.Expect(status.HostnameGenerators).Should(Not(BeEmpty())) + }, "2s", "100ms").Should(Succeed()) + }) + + It("should set an error if there's a collision", func() { + // when + Expect( + samples.MeshServiceBackendBuilder().WithoutVIP().WithLabels(map[string]string{ + "generate": "static", + }).Create(resManager), + ).To(Succeed()) + Expect( + samples.MeshServiceBackendBuilder().WithoutVIP().WithLabels(map[string]string{ + "generate": "static", + }).WithName("other").Create(resManager), + ).To(Succeed()) + + // then + Eventually(func(g Gomega) { + otherStatus := vipOfMeshService("other") + backendStatus := vipOfMeshService("backend") + g.Expect(otherStatus.Addresses).Should(BeEmpty()) + g.Expect(otherStatus.HostnameGenerators).Should(ConsistOf( + meshservice_api.HostnameGeneratorStatus{ + HostnameGeneratorRef: meshservice_api.HostnameGeneratorRef{CoreName: "static"}, + Conditions: []meshservice_api.Condition{{ + Type: meshservice_api.GeneratedCondition, + Status: kube_meta.ConditionFalse, + Reason: meshservice_api.CollisionReason, + Message: "Hostname collision with MeshService other", + }}, + }, + )) + g.Expect(backendStatus.Addresses).Should(Not(BeEmpty())) + g.Expect(backendStatus.HostnameGenerators).Should(ConsistOf( + meshservice_api.HostnameGeneratorStatus{ + HostnameGeneratorRef: meshservice_api.HostnameGeneratorRef{CoreName: "static"}, + Conditions: []meshservice_api.Condition{{ + Type: meshservice_api.GeneratedCondition, + Status: kube_meta.ConditionTrue, + Reason: meshservice_api.GeneratedReason, + }}, + }, + )) + }, "2s", "100ms").Should(Succeed()) + }) +}) diff --git a/pkg/core/resources/apis/hostnamegenerator/hostname/suite_test.go b/pkg/core/resources/apis/hostnamegenerator/hostname/suite_test.go new file mode 100644 index 000000000000..bf3bf8e2fed0 --- /dev/null +++ b/pkg/core/resources/apis/hostnamegenerator/hostname/suite_test.go @@ -0,0 +1,11 @@ +package hostname_test + +import ( + "testing" + + "github.com/kumahq/kuma/pkg/test" +) + +func TestHostnameGenerator(t *testing.T) { + test.RunSpecs(t, "HostnameGenerator Suite") +} diff --git a/pkg/core/resources/apis/meshservice/api/v1alpha1/meshservice.go b/pkg/core/resources/apis/meshservice/api/v1alpha1/meshservice.go index 4054ce275aa8..f441a20d7967 100644 --- a/pkg/core/resources/apis/meshservice/api/v1alpha1/meshservice.go +++ b/pkg/core/resources/apis/meshservice/api/v1alpha1/meshservice.go @@ -2,6 +2,7 @@ package v1alpha1 import ( + kube_meta "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/intstr" core_mesh "github.com/kumahq/kuma/pkg/core/resources/apis/mesh" @@ -38,8 +39,17 @@ type MeshService struct { Ports []Port `json:"ports,omitempty"` } +type Origin string + +const ( + OriginGenerator Origin = "HostnameGenerator" + OriginKubernetes Origin = "Kubernetes" +) + type Address struct { - Hostname string `json:"hostname,omitempty"` + Hostname string `json:"hostname,omitempty"` + Origin Origin `json:"origin,omitempty"` + HostnameGeneratorRef HostnameGeneratorRef `json:"hostnameGeneratorRef,omitempty"` } type VIP struct { @@ -58,8 +68,71 @@ type TLS struct { Status TLSStatus `json:"status,omitempty"` } +type HostnameGeneratorRef struct { + CoreName string `json:"name"` +} + +const ( + GeneratedCondition string = "Generated" +) + +const ( + GeneratedReason string = "Generated" + TemplateErrorReason string = "TemplateError" + CollisionReason string = "Collision" +) + +type Condition struct { + // type of condition in CamelCase or in foo.example.com/CamelCase. + // --- + // Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be + // useful (see .node.status.conditions), the ability to deconflict is important. + // The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + // +required + // +kubebuilder:validation:Required + // +kubebuilder:validation:Pattern=`^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$` + // +kubebuilder:validation:MaxLength=316 + Type string `json:"type"` + // status of the condition, one of True, False, Unknown. + // +required + // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=True;False;Unknown + Status kube_meta.ConditionStatus `json:"status"` + // reason contains a programmatic identifier indicating the reason for the condition's last transition. + // Producers of specific condition types may define expected values and meanings for this field, + // and whether the values are considered a guaranteed API. + // The value should be a CamelCase string. + // This field may not be empty. + // +required + // +kubebuilder:validation:Required + // +kubebuilder:validation:MaxLength=1024 + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:Pattern=`^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$` + Reason string `json:"reason"` + // message is a human readable message indicating details about the transition. + // This may be an empty string. + // +required + // +kubebuilder:validation:Required + // +kubebuilder:validation:MaxLength=32768 + Message string `json:"message"` +} + +type HostnameGeneratorStatus struct { + HostnameGeneratorRef HostnameGeneratorRef `json:"hostnameGeneratorRef"` + + // Conditions is an array of gateway instance conditions. + // + // +optional + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + Conditions []Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` +} + type MeshServiceStatus struct { - Addresses []Address `json:"addresses,omitempty"` - VIPs []VIP `json:"vips,omitempty"` - TLS TLS `json:"tls,omitempty"` + Addresses []Address `json:"addresses,omitempty"` + VIPs []VIP `json:"vips,omitempty"` + TLS TLS `json:"tls,omitempty"` + HostnameGenerators []HostnameGeneratorStatus `json:"hostnameGenerators,omitempty"` } diff --git a/pkg/core/resources/apis/meshservice/api/v1alpha1/zz_generated.deepcopy.go b/pkg/core/resources/apis/meshservice/api/v1alpha1/zz_generated.deepcopy.go index 30a373975e4a..9bc079231e5e 100644 --- a/pkg/core/resources/apis/meshservice/api/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/core/resources/apis/meshservice/api/v1alpha1/zz_generated.deepcopy.go @@ -9,6 +9,7 @@ import () // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Address) DeepCopyInto(out *Address) { *out = *in + out.HostnameGeneratorRef = in.HostnameGeneratorRef } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Address. @@ -21,6 +22,21 @@ func (in *Address) DeepCopy() *Address { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Condition) DeepCopyInto(out *Condition) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Condition. +func (in *Condition) DeepCopy() *Condition { + if in == nil { + return nil + } + out := new(Condition) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *DataplaneRef) DeepCopyInto(out *DataplaneRef) { *out = *in @@ -57,6 +73,42 @@ func (in DataplaneTags) DeepCopy() DataplaneTags { return *out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HostnameGeneratorRef) DeepCopyInto(out *HostnameGeneratorRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostnameGeneratorRef. +func (in *HostnameGeneratorRef) DeepCopy() *HostnameGeneratorRef { + if in == nil { + return nil + } + out := new(HostnameGeneratorRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HostnameGeneratorStatus) DeepCopyInto(out *HostnameGeneratorStatus) { + *out = *in + out.HostnameGeneratorRef = in.HostnameGeneratorRef + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]Condition, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostnameGeneratorStatus. +func (in *HostnameGeneratorStatus) DeepCopy() *HostnameGeneratorStatus { + if in == nil { + return nil + } + out := new(HostnameGeneratorStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MeshService) DeepCopyInto(out *MeshService) { *out = *in @@ -92,6 +144,13 @@ func (in *MeshServiceStatus) DeepCopyInto(out *MeshServiceStatus) { copy(*out, *in) } out.TLS = in.TLS + if in.HostnameGenerators != nil { + in, out := &in.HostnameGenerators, &out.HostnameGenerators + *out = make([]HostnameGeneratorStatus, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MeshServiceStatus. diff --git a/pkg/core/resources/apis/meshservice/k8s/crd/kuma.io_meshservices.yaml b/pkg/core/resources/apis/meshservice/k8s/crd/kuma.io_meshservices.yaml index 3e1f5f86c68e..b7d748923186 100644 --- a/pkg/core/resources/apis/meshservice/k8s/crd/kuma.io_meshservices.yaml +++ b/pkg/core/resources/apis/meshservice/k8s/crd/kuma.io_meshservices.yaml @@ -84,6 +84,78 @@ spec: properties: hostname: type: string + hostnameGeneratorRef: + properties: + name: + type: string + required: + - name + type: object + origin: + type: string + type: object + type: array + hostnameGenerators: + items: + properties: + conditions: + description: Conditions is an array of gateway instance conditions. + items: + properties: + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, + Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: |- + type of condition in CamelCase or in foo.example.com/CamelCase. + --- + Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be + useful (see .node.status.conditions), the ability to deconflict is important. + The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + hostnameGeneratorRef: + properties: + name: + type: string + required: + - name + type: object + required: + - hostnameGeneratorRef type: object type: array tls: diff --git a/pkg/dns/hostname_generator.go b/pkg/dns/hostname_generator.go new file mode 100644 index 000000000000..58b82e9bad6b --- /dev/null +++ b/pkg/dns/hostname_generator.go @@ -0,0 +1,37 @@ +package dns + +import ( + "slices" + + config_core "github.com/kumahq/kuma/pkg/config/core" + "github.com/kumahq/kuma/pkg/core" + "github.com/kumahq/kuma/pkg/core/resources/apis/hostnamegenerator/hostname" + "github.com/kumahq/kuma/pkg/core/runtime" + "github.com/kumahq/kuma/pkg/core/runtime/component" +) + +func SetupHostnameGenerator(rt runtime.Runtime) error { + if rt.GetMode() == config_core.Global { + return nil + } + logger := core.Log.WithName("hostnamegenerator") + if !slices.Contains(rt.Config().CoreResources.Enabled, "hostnamegenerators") { + logger.Info("HostnameGenerator is not enabled, not starting generator") + return nil + } + generator, err := hostname.NewGenerator( + logger, + rt.Metrics(), + rt.ResourceManager(), + rt.Config().IPAM.AllocationInterval.Duration, + ) + if err != nil { + return err + } + return rt.Add(component.NewResilientComponent( + logger, + generator, + rt.Config().General.ResilientComponentBaseBackoff.Duration, + rt.Config().General.ResilientComponentMaxBackoff.Duration, + )) +} diff --git a/pkg/test/resources/builders/meshservice_builder.go b/pkg/test/resources/builders/meshservice_builder.go index 327913c92e1a..1f9a370be39f 100644 --- a/pkg/test/resources/builders/meshservice_builder.go +++ b/pkg/test/resources/builders/meshservice_builder.go @@ -29,6 +29,11 @@ func MeshService() *MeshServiceBuilder { } } +func (m *MeshServiceBuilder) WithLabels(labels map[string]string) *MeshServiceBuilder { + m.res.Meta.(*test_model.ResourceMeta).Labels = labels + return m +} + func (m *MeshServiceBuilder) WithName(name string) *MeshServiceBuilder { m.res.Meta.(*test_model.ResourceMeta).Name = name return m @@ -80,7 +85,13 @@ func (m *MeshServiceBuilder) Build() *v1alpha1.MeshServiceResource { } func (m *MeshServiceBuilder) Create(s store.ResourceStore) error { - return s.Create(context.Background(), m.Build(), store.CreateBy(m.Key())) + opts := []store.CreateOptionsFunc{ + store.CreateBy(m.Key()), + } + if ls := m.res.GetMeta().GetLabels(); len(ls) > 0 { + opts = append(opts, store.CreateWithLabels(ls)) + } + return s.Create(context.Background(), m.Build(), opts...) } func (m *MeshServiceBuilder) Key() core_model.ResourceKey {