diff --git a/.changelog/17577.txt b/.changelog/17577.txt new file mode 100644 index 000000000000..3699d5261122 --- /dev/null +++ b/.changelog/17577.txt @@ -0,0 +1,3 @@ +```release-note:improvement +fix metric names in /docs/agent/telemetry +``` \ No newline at end of file diff --git a/.changelog/17581.txt b/.changelog/17581.txt new file mode 100644 index 000000000000..9277dbcd3ef1 --- /dev/null +++ b/.changelog/17581.txt @@ -0,0 +1,3 @@ +```release-note:bug +gateways: **(Enterprise only)** Fixed a bug in API gateways where gateway configuration objects in non-default partitions did not reconcile properly. +``` diff --git a/agent/consul/gateways/controller_gateways.go b/agent/consul/gateways/controller_gateways.go index 46651f004370..cf4f25aa5d46 100644 --- a/agent/consul/gateways/controller_gateways.go +++ b/agent/consul/gateways/controller_gateways.go @@ -93,7 +93,7 @@ func (r *apiGatewayReconciler) enqueueCertificateReferencedGateways(store *state logger.Trace("certificate changed, enqueueing dependent gateways") defer logger.Trace("finished enqueuing gateways") - _, entries, err := store.ConfigEntriesByKind(nil, structs.APIGateway, acl.WildcardEnterpriseMeta()) + _, entries, err := store.ConfigEntriesByKind(nil, structs.APIGateway, wildcardMeta()) if err != nil { logger.Warn("error retrieving api gateways", "error", err) return err @@ -564,11 +564,11 @@ type gatewayMeta struct { // tuples based on the state coming from the store. Any gateway that does not have // a corresponding bound-api-gateway config entry will be filtered out. func getAllGatewayMeta(store *state.Store) ([]*gatewayMeta, error) { - _, gateways, err := store.ConfigEntriesByKind(nil, structs.APIGateway, acl.WildcardEnterpriseMeta()) + _, gateways, err := store.ConfigEntriesByKind(nil, structs.APIGateway, wildcardMeta()) if err != nil { return nil, err } - _, boundGateways, err := store.ConfigEntriesByKind(nil, structs.BoundAPIGateway, acl.WildcardEnterpriseMeta()) + _, boundGateways, err := store.ConfigEntriesByKind(nil, structs.BoundAPIGateway, wildcardMeta()) if err != nil { return nil, err } @@ -1074,12 +1074,12 @@ func requestToResourceRef(req controller.Request) structs.ResourceReference { // retrieveAllRoutesFromStore retrieves all HTTP and TCP routes from the given store func retrieveAllRoutesFromStore(store *state.Store) ([]structs.BoundRoute, error) { - _, httpRoutes, err := store.ConfigEntriesByKind(nil, structs.HTTPRoute, acl.WildcardEnterpriseMeta()) + _, httpRoutes, err := store.ConfigEntriesByKind(nil, structs.HTTPRoute, wildcardMeta()) if err != nil { return nil, err } - _, tcpRoutes, err := store.ConfigEntriesByKind(nil, structs.TCPRoute, acl.WildcardEnterpriseMeta()) + _, tcpRoutes, err := store.ConfigEntriesByKind(nil, structs.TCPRoute, wildcardMeta()) if err != nil { return nil, err } @@ -1141,3 +1141,9 @@ func routeLogger(logger hclog.Logger, route structs.ConfigEntry) hclog.Logger { meta := route.GetEnterpriseMeta() return logger.With("route.kind", route.GetKind(), "route.name", route.GetName(), "route.namespace", meta.NamespaceOrDefault(), "route.partition", meta.PartitionOrDefault()) } + +func wildcardMeta() *acl.EnterpriseMeta { + meta := acl.WildcardEnterpriseMeta() + meta.OverridePartition(acl.WildcardPartitionName) + return meta +} diff --git a/agent/envoyextensions/builtin/property-override/property_override.go b/agent/envoyextensions/builtin/property-override/property_override.go index 6bdf9e7a39f5..c018d334a5c3 100644 --- a/agent/envoyextensions/builtin/property-override/property_override.go +++ b/agent/envoyextensions/builtin/property-override/property_override.go @@ -7,6 +7,7 @@ import ( envoy_endpoint_v3 "github.com/envoyproxy/go-control-plane/envoy/config/endpoint/v3" envoy_listener_v3 "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3" envoy_route_v3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" + "github.com/hashicorp/consul/lib/decode" "github.com/hashicorp/go-multierror" "github.com/mitchellh/mapstructure" "google.golang.org/protobuf/proto" @@ -73,7 +74,7 @@ func matchesResourceFilter[K proto.Message](rf ResourceFilter, resourceType Reso } type ServiceName struct { - api.CompoundServiceName + api.CompoundServiceName `mapstructure:",squash"` } // ResourceType is the type of Envoy resource being patched. @@ -275,7 +276,21 @@ func Constructor(ext api.EnvoyExtension) (extensioncommon.EnvoyExtender, error) if name := ext.Name; name != api.BuiltinPropertyOverrideExtension { return nil, fmt.Errorf("expected extension name %q but got %q", api.BuiltinPropertyOverrideExtension, name) } - if err := mapstructure.WeakDecode(ext.Arguments, &p); err != nil { + // This avoids issues with decoding nested slices, which are error-prone + // due to slice<->map coercion by mapstructure. See HookWeakDecodeFromSlice + // and WeaklyTypedInput docs for more details. + d, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + DecodeHook: mapstructure.ComposeDecodeHookFunc( + decode.HookWeakDecodeFromSlice, + decode.HookTranslateKeys, + ), + WeaklyTypedInput: true, + Result: &p, + }) + if err != nil { + return nil, fmt.Errorf("error configuring decoder: %v", err) + } + if err := d.Decode(ext.Arguments); err != nil { return nil, fmt.Errorf("error decoding extension arguments: %v", err) } if err := p.validate(); err != nil { diff --git a/agent/envoyextensions/builtin/property-override/property_override_test.go b/agent/envoyextensions/builtin/property-override/property_override_test.go index 7faa3795c24b..68a895edb846 100644 --- a/agent/envoyextensions/builtin/property-override/property_override_test.go +++ b/agent/envoyextensions/builtin/property-override/property_override_test.go @@ -220,8 +220,8 @@ func TestConstructor(t *testing.T) { arguments: makeArguments(map[string]any{"Patches": []map[string]any{ makePatch(map[string]any{ "ResourceFilter": makeResourceFilter(map[string]any{ - "Services": []ServiceName{ - {CompoundServiceName: api.CompoundServiceName{}}, + "Services": []map[string]any{ + {}, }, }), }), @@ -240,6 +240,42 @@ func TestConstructor(t *testing.T) { expected: validTestCase(OpAdd, extensioncommon.TrafficDirectionOutbound, ResourceTypeRoute).expected, ok: true, }, + // Ensure that embedded api struct used for Services is parsed correctly. + // See also above comment on decode.HookWeakDecodeFromSlice. + "single value Services decoded as map construction succeeds": { + arguments: makeArguments(map[string]any{"Patches": []map[string]any{ + makePatch(map[string]any{ + "ResourceFilter": makeResourceFilter(map[string]any{ + "Services": []map[string]any{ + {"Name": "foo"}, + }, + }), + }), + }}), + expected: propertyOverride{ + Patches: []Patch{ + { + ResourceFilter: ResourceFilter{ + ResourceType: ResourceTypeRoute, + TrafficDirection: extensioncommon.TrafficDirectionOutbound, + Services: []*ServiceName{ + {CompoundServiceName: api.CompoundServiceName{ + Name: "foo", + Namespace: "default", + Partition: "default", + }}, + }, + }, + Op: OpAdd, + Path: "/name", + Value: "foo", + }, + }, + Debug: true, + ProxyType: api.ServiceKindConnectProxy, + }, + ok: true, + }, "invalid ProxyType": { arguments: makeArguments(map[string]any{ "Patches": []map[string]any{ diff --git a/go.mod b/go.mod index 4c421c465305..cd4ed5a89014 100644 --- a/go.mod +++ b/go.mod @@ -103,6 +103,7 @@ require ( go.opentelemetry.io/proto/otlp v0.19.0 go.uber.org/goleak v1.1.10 golang.org/x/crypto v0.1.0 + golang.org/x/exp v0.0.0-20230321023759-10a507213a29 golang.org/x/net v0.10.0 golang.org/x/oauth2 v0.6.0 golang.org/x/sync v0.2.0 @@ -240,7 +241,6 @@ require ( go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/otel/trace v1.16.0 // indirect go.uber.org/atomic v1.9.0 // indirect - golang.org/x/exp v0.0.0-20230321023759-10a507213a29 // indirect golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 // indirect golang.org/x/mod v0.10.0 // indirect golang.org/x/term v0.8.0 // indirect diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-service.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-service.json new file mode 100644 index 000000000000..866c384b1d97 --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-service.json @@ -0,0 +1,37 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Service" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.Service", + "workloads": { + "prefixes": [ + "api-" + ] + }, + "ports": [ + { + "target_port": "http", + "protocol": "PROTOCOL_HTTP" + }, + { + "target_port": "grpc", + "protocol": "PROTOCOL_GRPC" + }, + { + "target_port": "mesh", + "protocol": "PROTOCOL_MESH" + } + ] + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-1-health.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-1-health.json new file mode 100644 index 000000000000..a1dd1609f297 --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-1-health.json @@ -0,0 +1,33 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "HealthStatus" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-1-health" + }, + "owner": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-1" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.HealthStatus", + "type": "synthetic", + "status": "HEALTH_PASSING" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-1.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-1.json new file mode 100644 index 000000000000..1935a767a19f --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-1.json @@ -0,0 +1,46 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-1" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.Workload", + "addresses": [ + { + "host": "172.16.1.1" + }, + { + "host": "198.18.2.1", + "external": true, + "ports": [ + "mesh" + ] + } + ], + "ports": { + "http": { + "port": 8080, + "protocol": "PROTOCOL_HTTP" + }, + "grpc": { + "port": 9090, + "protocol": "PROTOCOL_GRPC" + }, + "mesh": { + "port": 10000, + "protocol": "PROTOCOL_MESH" + } + }, + "identity": "api", + "node_name": "node-1" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-10-health.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-10-health.json new file mode 100644 index 000000000000..9c60ce7b0913 --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-10-health.json @@ -0,0 +1,33 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "HealthStatus" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-10-health" + }, + "owner": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-10" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.HealthStatus", + "type": "synthetic", + "status": "HEALTH_WARNING" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-10.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-10.json new file mode 100644 index 000000000000..362baa4099ec --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-10.json @@ -0,0 +1,46 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-10" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.Workload", + "addresses": [ + { + "host": "172.16.1.10" + }, + { + "host": "198.18.2.10", + "external": true, + "ports": [ + "mesh" + ] + } + ], + "ports": { + "http": { + "port": 8080, + "protocol": "PROTOCOL_HTTP" + }, + "grpc": { + "port": 9090, + "protocol": "PROTOCOL_GRPC" + }, + "mesh": { + "port": 10000, + "protocol": "PROTOCOL_MESH" + } + }, + "identity": "api", + "node_name": "node-3" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-11-health.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-11-health.json new file mode 100644 index 000000000000..2f8025258075 --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-11-health.json @@ -0,0 +1,33 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "HealthStatus" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-11-health" + }, + "owner": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-11" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.HealthStatus", + "type": "synthetic", + "status": "HEALTH_CRITICAL" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-11.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-11.json new file mode 100644 index 000000000000..be9c1898835e --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-11.json @@ -0,0 +1,46 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-11" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.Workload", + "addresses": [ + { + "host": "172.16.1.11" + }, + { + "host": "198.18.2.11", + "external": true, + "ports": [ + "mesh" + ] + } + ], + "ports": { + "http": { + "port": 8080, + "protocol": "PROTOCOL_HTTP" + }, + "grpc": { + "port": 9090, + "protocol": "PROTOCOL_GRPC" + }, + "mesh": { + "port": 10000, + "protocol": "PROTOCOL_MESH" + } + }, + "identity": "api", + "node_name": "node-3" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-12-health.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-12-health.json new file mode 100644 index 000000000000..dd1026eed11a --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-12-health.json @@ -0,0 +1,33 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "HealthStatus" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-12-health" + }, + "owner": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-12" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.HealthStatus", + "type": "synthetic", + "status": "HEALTH_MAINTENANCE" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-12.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-12.json new file mode 100644 index 000000000000..34f34d534239 --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-12.json @@ -0,0 +1,46 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-12" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.Workload", + "addresses": [ + { + "host": "172.16.1.12" + }, + { + "host": "198.18.2.12", + "external": true, + "ports": [ + "mesh" + ] + } + ], + "ports": { + "http": { + "port": 8080, + "protocol": "PROTOCOL_HTTP" + }, + "grpc": { + "port": 9090, + "protocol": "PROTOCOL_GRPC" + }, + "mesh": { + "port": 10000, + "protocol": "PROTOCOL_MESH" + } + }, + "identity": "api", + "node_name": "node-3" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-13-health.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-13-health.json new file mode 100644 index 000000000000..2a5084afa14c --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-13-health.json @@ -0,0 +1,33 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "HealthStatus" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-13-health" + }, + "owner": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-13" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.HealthStatus", + "type": "synthetic", + "status": "HEALTH_PASSING" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-13.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-13.json new file mode 100644 index 000000000000..3d896e036590 --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-13.json @@ -0,0 +1,46 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-13" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.Workload", + "addresses": [ + { + "host": "172.16.1.13" + }, + { + "host": "198.18.2.13", + "external": true, + "ports": [ + "mesh" + ] + } + ], + "ports": { + "http": { + "port": 8080, + "protocol": "PROTOCOL_HTTP" + }, + "grpc": { + "port": 9090, + "protocol": "PROTOCOL_GRPC" + }, + "mesh": { + "port": 10000, + "protocol": "PROTOCOL_MESH" + } + }, + "identity": "api", + "node_name": "node-4" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-14-health.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-14-health.json new file mode 100644 index 000000000000..991b66ee526d --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-14-health.json @@ -0,0 +1,33 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "HealthStatus" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-14-health" + }, + "owner": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-14" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.HealthStatus", + "type": "synthetic", + "status": "HEALTH_WARNING" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-14.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-14.json new file mode 100644 index 000000000000..e8712a6c6520 --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-14.json @@ -0,0 +1,46 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-14" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.Workload", + "addresses": [ + { + "host": "172.16.1.14" + }, + { + "host": "198.18.2.14", + "external": true, + "ports": [ + "mesh" + ] + } + ], + "ports": { + "http": { + "port": 8080, + "protocol": "PROTOCOL_HTTP" + }, + "grpc": { + "port": 9090, + "protocol": "PROTOCOL_GRPC" + }, + "mesh": { + "port": 10000, + "protocol": "PROTOCOL_MESH" + } + }, + "identity": "api", + "node_name": "node-4" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-15-health.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-15-health.json new file mode 100644 index 000000000000..22b2ff83340d --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-15-health.json @@ -0,0 +1,33 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "HealthStatus" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-15-health" + }, + "owner": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-15" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.HealthStatus", + "type": "synthetic", + "status": "HEALTH_CRITICAL" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-15.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-15.json new file mode 100644 index 000000000000..dbe0f0a44af7 --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-15.json @@ -0,0 +1,46 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-15" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.Workload", + "addresses": [ + { + "host": "172.16.1.15" + }, + { + "host": "198.18.2.15", + "external": true, + "ports": [ + "mesh" + ] + } + ], + "ports": { + "http": { + "port": 8080, + "protocol": "PROTOCOL_HTTP" + }, + "grpc": { + "port": 9090, + "protocol": "PROTOCOL_GRPC" + }, + "mesh": { + "port": 10000, + "protocol": "PROTOCOL_MESH" + } + }, + "identity": "api", + "node_name": "node-4" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-16-health.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-16-health.json new file mode 100644 index 000000000000..06e36994387e --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-16-health.json @@ -0,0 +1,33 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "HealthStatus" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-16-health" + }, + "owner": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-16" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.HealthStatus", + "type": "synthetic", + "status": "HEALTH_MAINTENANCE" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-16.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-16.json new file mode 100644 index 000000000000..3db40493b1d3 --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-16.json @@ -0,0 +1,46 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-16" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.Workload", + "addresses": [ + { + "host": "172.16.1.16" + }, + { + "host": "198.18.2.16", + "external": true, + "ports": [ + "mesh" + ] + } + ], + "ports": { + "http": { + "port": 8080, + "protocol": "PROTOCOL_HTTP" + }, + "grpc": { + "port": 9090, + "protocol": "PROTOCOL_GRPC" + }, + "mesh": { + "port": 10000, + "protocol": "PROTOCOL_MESH" + } + }, + "identity": "api", + "node_name": "node-4" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-17-health.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-17-health.json new file mode 100644 index 000000000000..3b4614cd6f70 --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-17-health.json @@ -0,0 +1,33 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "HealthStatus" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-17-health" + }, + "owner": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-17" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.HealthStatus", + "type": "synthetic", + "status": "HEALTH_PASSING" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-17.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-17.json new file mode 100644 index 000000000000..79d26a2b3381 --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-17.json @@ -0,0 +1,45 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-17" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.Workload", + "addresses": [ + { + "host": "172.16.1.17" + }, + { + "host": "198.18.2.17", + "external": true, + "ports": [ + "mesh" + ] + } + ], + "ports": { + "http": { + "port": 8080, + "protocol": "PROTOCOL_HTTP" + }, + "grpc": { + "port": 9090, + "protocol": "PROTOCOL_GRPC" + }, + "mesh": { + "port": 10000, + "protocol": "PROTOCOL_MESH" + } + }, + "identity": "api" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-18-health.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-18-health.json new file mode 100644 index 000000000000..7764016a9859 --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-18-health.json @@ -0,0 +1,33 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "HealthStatus" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-18-health" + }, + "owner": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-18" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.HealthStatus", + "type": "synthetic", + "status": "HEALTH_WARNING" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-18.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-18.json new file mode 100644 index 000000000000..04df2611c867 --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-18.json @@ -0,0 +1,45 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-18" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.Workload", + "addresses": [ + { + "host": "172.16.1.18" + }, + { + "host": "198.18.2.18", + "external": true, + "ports": [ + "mesh" + ] + } + ], + "ports": { + "http": { + "port": 8080, + "protocol": "PROTOCOL_HTTP" + }, + "grpc": { + "port": 9090, + "protocol": "PROTOCOL_GRPC" + }, + "mesh": { + "port": 10000, + "protocol": "PROTOCOL_MESH" + } + }, + "identity": "api" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-19-health.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-19-health.json new file mode 100644 index 000000000000..468cddfbcbab --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-19-health.json @@ -0,0 +1,33 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "HealthStatus" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-19-health" + }, + "owner": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-19" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.HealthStatus", + "type": "synthetic", + "status": "HEALTH_CRITICAL" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-19.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-19.json new file mode 100644 index 000000000000..bf9c8fe25a7f --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-19.json @@ -0,0 +1,45 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-19" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.Workload", + "addresses": [ + { + "host": "172.16.1.19" + }, + { + "host": "198.18.2.19", + "external": true, + "ports": [ + "mesh" + ] + } + ], + "ports": { + "http": { + "port": 8080, + "protocol": "PROTOCOL_HTTP" + }, + "grpc": { + "port": 9090, + "protocol": "PROTOCOL_GRPC" + }, + "mesh": { + "port": 10000, + "protocol": "PROTOCOL_MESH" + } + }, + "identity": "api" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-2-health.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-2-health.json new file mode 100644 index 000000000000..d9bf96b62b52 --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-2-health.json @@ -0,0 +1,33 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "HealthStatus" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-2-health" + }, + "owner": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-2" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.HealthStatus", + "type": "synthetic", + "status": "HEALTH_WARNING" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-2.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-2.json new file mode 100644 index 000000000000..b80c0ccb8532 --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-2.json @@ -0,0 +1,46 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-2" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.Workload", + "addresses": [ + { + "host": "172.16.1.2" + }, + { + "host": "198.18.2.2", + "external": true, + "ports": [ + "mesh" + ] + } + ], + "ports": { + "http": { + "port": 8080, + "protocol": "PROTOCOL_HTTP" + }, + "grpc": { + "port": 9090, + "protocol": "PROTOCOL_GRPC" + }, + "mesh": { + "port": 10000, + "protocol": "PROTOCOL_MESH" + } + }, + "identity": "api", + "node_name": "node-1" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-20-health.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-20-health.json new file mode 100644 index 000000000000..98af32bd00fd --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-20-health.json @@ -0,0 +1,33 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "HealthStatus" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-20-health" + }, + "owner": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-20" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.HealthStatus", + "type": "synthetic", + "status": "HEALTH_MAINTENANCE" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-20.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-20.json new file mode 100644 index 000000000000..89723f54fe00 --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-20.json @@ -0,0 +1,45 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-20" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.Workload", + "addresses": [ + { + "host": "172.16.1.20" + }, + { + "host": "198.18.2.20", + "external": true, + "ports": [ + "mesh" + ] + } + ], + "ports": { + "http": { + "port": 8080, + "protocol": "PROTOCOL_HTTP" + }, + "grpc": { + "port": 9090, + "protocol": "PROTOCOL_GRPC" + }, + "mesh": { + "port": 10000, + "protocol": "PROTOCOL_MESH" + } + }, + "identity": "api" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-3-health.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-3-health.json new file mode 100644 index 000000000000..28670cc33728 --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-3-health.json @@ -0,0 +1,33 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "HealthStatus" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-3-health" + }, + "owner": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-3" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.HealthStatus", + "type": "synthetic", + "status": "HEALTH_CRITICAL" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-3.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-3.json new file mode 100644 index 000000000000..c5ea7d82a5df --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-3.json @@ -0,0 +1,46 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-3" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.Workload", + "addresses": [ + { + "host": "172.16.1.3" + }, + { + "host": "198.18.2.3", + "external": true, + "ports": [ + "mesh" + ] + } + ], + "ports": { + "http": { + "port": 8080, + "protocol": "PROTOCOL_HTTP" + }, + "grpc": { + "port": 9090, + "protocol": "PROTOCOL_GRPC" + }, + "mesh": { + "port": 10000, + "protocol": "PROTOCOL_MESH" + } + }, + "identity": "api", + "node_name": "node-1" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-4-health.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-4-health.json new file mode 100644 index 000000000000..041d6eed5afd --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-4-health.json @@ -0,0 +1,33 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "HealthStatus" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-4-health" + }, + "owner": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-4" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.HealthStatus", + "type": "synthetic", + "status": "HEALTH_MAINTENANCE" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-4.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-4.json new file mode 100644 index 000000000000..53934b9d5eea --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-4.json @@ -0,0 +1,46 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-4" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.Workload", + "addresses": [ + { + "host": "172.16.1.4" + }, + { + "host": "198.18.2.4", + "external": true, + "ports": [ + "mesh" + ] + } + ], + "ports": { + "http": { + "port": 8080, + "protocol": "PROTOCOL_HTTP" + }, + "grpc": { + "port": 9090, + "protocol": "PROTOCOL_GRPC" + }, + "mesh": { + "port": 10000, + "protocol": "PROTOCOL_MESH" + } + }, + "identity": "api", + "node_name": "node-1" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-5-health.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-5-health.json new file mode 100644 index 000000000000..1773cb44ca84 --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-5-health.json @@ -0,0 +1,33 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "HealthStatus" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-5-health" + }, + "owner": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-5" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.HealthStatus", + "type": "synthetic", + "status": "HEALTH_PASSING" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-5.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-5.json new file mode 100644 index 000000000000..cbed9843adb5 --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-5.json @@ -0,0 +1,46 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-5" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.Workload", + "addresses": [ + { + "host": "172.16.1.5" + }, + { + "host": "198.18.2.5", + "external": true, + "ports": [ + "mesh" + ] + } + ], + "ports": { + "http": { + "port": 8080, + "protocol": "PROTOCOL_HTTP" + }, + "grpc": { + "port": 9090, + "protocol": "PROTOCOL_GRPC" + }, + "mesh": { + "port": 10000, + "protocol": "PROTOCOL_MESH" + } + }, + "identity": "api", + "node_name": "node-2" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-6-health.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-6-health.json new file mode 100644 index 000000000000..3cf2104ef4fc --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-6-health.json @@ -0,0 +1,33 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "HealthStatus" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-6-health" + }, + "owner": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-6" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.HealthStatus", + "type": "synthetic", + "status": "HEALTH_WARNING" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-6.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-6.json new file mode 100644 index 000000000000..1646e7c0291c --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-6.json @@ -0,0 +1,46 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-6" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.Workload", + "addresses": [ + { + "host": "172.16.1.6" + }, + { + "host": "198.18.2.6", + "external": true, + "ports": [ + "mesh" + ] + } + ], + "ports": { + "http": { + "port": 8080, + "protocol": "PROTOCOL_HTTP" + }, + "grpc": { + "port": 9090, + "protocol": "PROTOCOL_GRPC" + }, + "mesh": { + "port": 10000, + "protocol": "PROTOCOL_MESH" + } + }, + "identity": "api", + "node_name": "node-2" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-7-health.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-7-health.json new file mode 100644 index 000000000000..d1dbc895fd3a --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-7-health.json @@ -0,0 +1,33 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "HealthStatus" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-7-health" + }, + "owner": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-7" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.HealthStatus", + "type": "synthetic", + "status": "HEALTH_CRITICAL" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-7.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-7.json new file mode 100644 index 000000000000..240f1e29a693 --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-7.json @@ -0,0 +1,46 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-7" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.Workload", + "addresses": [ + { + "host": "172.16.1.7" + }, + { + "host": "198.18.2.7", + "external": true, + "ports": [ + "mesh" + ] + } + ], + "ports": { + "http": { + "port": 8080, + "protocol": "PROTOCOL_HTTP" + }, + "grpc": { + "port": 9090, + "protocol": "PROTOCOL_GRPC" + }, + "mesh": { + "port": 10000, + "protocol": "PROTOCOL_MESH" + } + }, + "identity": "api", + "node_name": "node-2" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-8-health.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-8-health.json new file mode 100644 index 000000000000..cdbb06270375 --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-8-health.json @@ -0,0 +1,33 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "HealthStatus" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-8-health" + }, + "owner": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-8" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.HealthStatus", + "type": "synthetic", + "status": "HEALTH_MAINTENANCE" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-8.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-8.json new file mode 100644 index 000000000000..16e686c4b409 --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-8.json @@ -0,0 +1,46 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-8" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.Workload", + "addresses": [ + { + "host": "172.16.1.8" + }, + { + "host": "198.18.2.8", + "external": true, + "ports": [ + "mesh" + ] + } + ], + "ports": { + "http": { + "port": 8080, + "protocol": "PROTOCOL_HTTP" + }, + "grpc": { + "port": 9090, + "protocol": "PROTOCOL_GRPC" + }, + "mesh": { + "port": 10000, + "protocol": "PROTOCOL_MESH" + } + }, + "identity": "api", + "node_name": "node-2" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-9-health.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-9-health.json new file mode 100644 index 000000000000..6d221b07bd96 --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-9-health.json @@ -0,0 +1,33 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "HealthStatus" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-9-health" + }, + "owner": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-9" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.HealthStatus", + "type": "synthetic", + "status": "HEALTH_PASSING" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-9.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-9.json new file mode 100644 index 000000000000..9081ff2e9cad --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/api-workload-9.json @@ -0,0 +1,46 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Workload" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "api-9" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.Workload", + "addresses": [ + { + "host": "172.16.1.9" + }, + { + "host": "198.18.2.9", + "external": true, + "ports": [ + "mesh" + ] + } + ], + "ports": { + "http": { + "port": 8080, + "protocol": "PROTOCOL_HTTP" + }, + "grpc": { + "port": 9090, + "protocol": "PROTOCOL_GRPC" + }, + "mesh": { + "port": 10000, + "protocol": "PROTOCOL_MESH" + } + }, + "identity": "api", + "node_name": "node-3" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/foo-service-endpoints.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/foo-service-endpoints.json new file mode 100644 index 000000000000..b96f8805b151 --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/foo-service-endpoints.json @@ -0,0 +1,47 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "ServiceEndpoints" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "foo" + }, + "owner": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Service" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "foo" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.ServiceEndpoints", + "endpoints": [ + { + "addresses": [ + { + "host": "198.18.0.1" + } + ], + "ports": { + "external-service-port": { + "port": 9876, + "protocol": "PROTOCOL_HTTP2" + } + }, + "health_status": "HEALTH_PASSING" + } + ] + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/foo-service.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/foo-service.json new file mode 100644 index 000000000000..a79cdb233166 --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/foo-service.json @@ -0,0 +1,24 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Service" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "foo" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.Service", + "ports": [ + { + "target_port": "external-service-port", + "protocol": "PROTOCOL_HTTP2" + } + ] + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/grpc-api-service.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/grpc-api-service.json new file mode 100644 index 000000000000..6aa39628bf0f --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/grpc-api-service.json @@ -0,0 +1,42 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Service" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "grpc-api" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.Service", + "workloads": { + "names": [ + "api-1", + "api-2", + "api-3", + "api-4", + "api-5", + "api-6", + "api-7", + "api-8", + "api-9", + "api-20" + ] + }, + "ports": [ + { + "target_port": "grpc", + "protocol": "PROTOCOL_GRPC" + }, + { + "target_port": "mesh", + "protocol": "PROTOCOL_MESH" + } + ] + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/http-api-service.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/http-api-service.json new file mode 100644 index 000000000000..989851e72564 --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/http-api-service.json @@ -0,0 +1,29 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Service" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "http-api" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.Service", + "workloads": { + "prefixes": [ + "api-1" + ] + }, + "ports": [ + { + "target_port": "http", + "protocol": "PROTOCOL_HTTP" + } + ] + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/node-1-health.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/node-1-health.json new file mode 100644 index 000000000000..5f58cda6ae88 --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/node-1-health.json @@ -0,0 +1,33 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "HealthStatus" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "node-1-health" + }, + "owner": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Node" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "node-1" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.HealthStatus", + "type": "synthetic", + "status": "HEALTH_PASSING" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/node-1.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/node-1.json new file mode 100644 index 000000000000..c0f618279dcb --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/node-1.json @@ -0,0 +1,27 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Node" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "node-1" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.Node", + "addresses": [ + { + "host": "198.18.1.1", + "external": true + }, + { + "host": "172.16.0.1" + } + ] + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/node-2-health.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/node-2-health.json new file mode 100644 index 000000000000..6c67a1c1d1fc --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/node-2-health.json @@ -0,0 +1,33 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "HealthStatus" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "node-2-health" + }, + "owner": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Node" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "node-2" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.HealthStatus", + "type": "synthetic", + "status": "HEALTH_WARNING" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/node-2.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/node-2.json new file mode 100644 index 000000000000..f68fd86ded71 --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/node-2.json @@ -0,0 +1,27 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Node" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "node-2" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.Node", + "addresses": [ + { + "host": "198.18.1.2", + "external": true + }, + { + "host": "172.16.0.2" + } + ] + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/node-3-health.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/node-3-health.json new file mode 100644 index 000000000000..2efa143afa2c --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/node-3-health.json @@ -0,0 +1,33 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "HealthStatus" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "node-3-health" + }, + "owner": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Node" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "node-3" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.HealthStatus", + "type": "synthetic", + "status": "HEALTH_CRITICAL" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/node-3.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/node-3.json new file mode 100644 index 000000000000..b8d9a4e1651b --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/node-3.json @@ -0,0 +1,27 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Node" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "node-3" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.Node", + "addresses": [ + { + "host": "198.18.1.3", + "external": true + }, + { + "host": "172.16.0.3" + } + ] + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/node-4-health.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/node-4-health.json new file mode 100644 index 000000000000..a4f425e3cdc3 --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/node-4-health.json @@ -0,0 +1,33 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "HealthStatus" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "node-4-health" + }, + "owner": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Node" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "node-4" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.HealthStatus", + "type": "synthetic", + "status": "HEALTH_MAINTENANCE" + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/integration_test_data/v1alpha1/node-4.json b/internal/catalog/catalogtest/integration_test_data/v1alpha1/node-4.json new file mode 100644 index 000000000000..17c98cd98dd1 --- /dev/null +++ b/internal/catalog/catalogtest/integration_test_data/v1alpha1/node-4.json @@ -0,0 +1,27 @@ +{ + "id": { + "type": { + "group": "catalog", + "group_version": "v1alpha1", + "kind": "Node" + }, + "tenancy": { + "partition": "default", + "namespace": "default", + "peer_name": "local" + }, + "name": "node-4" + }, + "data": { + "@type": "hashicorp.consul.catalog.v1alpha1.Node", + "addresses": [ + { + "host": "198.18.1.4", + "external": true + }, + { + "host": "172.16.0.4" + } + ] + } +} \ No newline at end of file diff --git a/internal/catalog/catalogtest/run_test.go b/internal/catalog/catalogtest/run_test.go new file mode 100644 index 000000000000..7c17052d8246 --- /dev/null +++ b/internal/catalog/catalogtest/run_test.go @@ -0,0 +1,39 @@ +package catalogtest + +import ( + "testing" + + svctest "github.com/hashicorp/consul/agent/grpc-external/services/resource/testing" + "github.com/hashicorp/consul/internal/catalog" + "github.com/hashicorp/consul/internal/catalog/internal/controllers" + "github.com/hashicorp/consul/internal/controller" + "github.com/hashicorp/consul/internal/resource/reaper" + "github.com/hashicorp/consul/proto-public/pbresource" + "github.com/hashicorp/consul/sdk/testutil" +) + +func runInMemResourceServiceAndControllers(t *testing.T, deps controllers.Dependencies) pbresource.ResourceServiceClient { + t.Helper() + + ctx := testutil.TestContext(t) + + // Create the in-mem resource service + client := svctest.RunResourceService(t, catalog.RegisterTypes) + + // Setup/Run the controller manager + mgr := controller.NewManager(client, testutil.Logger(t)) + catalog.RegisterControllers(mgr, deps) + + // We also depend on the reaper to take care of cleaning up owned health statuses and + // service endpoints so we must enable that controller as well + reaper.RegisterControllers(mgr) + mgr.SetRaftLeader(true) + go mgr.Run(ctx) + + return client +} + +func TestControllers_Integration(t *testing.T) { + client := runInMemResourceServiceAndControllers(t, catalog.DefaultControllerDependencies()) + RunCatalogV1Alpha1IntegrationTest(t, client) +} diff --git a/internal/catalog/catalogtest/test_integration_v1alpha1.go b/internal/catalog/catalogtest/test_integration_v1alpha1.go new file mode 100644 index 000000000000..8a7f4cd9a248 --- /dev/null +++ b/internal/catalog/catalogtest/test_integration_v1alpha1.go @@ -0,0 +1,707 @@ +package catalogtest + +import ( + "embed" + "fmt" + "testing" + + "github.com/hashicorp/consul/internal/catalog" + "github.com/hashicorp/consul/internal/catalog/internal/controllers/endpoints" + "github.com/hashicorp/consul/internal/catalog/internal/controllers/nodehealth" + "github.com/hashicorp/consul/internal/catalog/internal/controllers/workloadhealth" + "github.com/hashicorp/consul/internal/catalog/internal/types" + rtest "github.com/hashicorp/consul/internal/resource/resourcetest" + pbcatalog "github.com/hashicorp/consul/proto-public/pbcatalog/v1alpha1" + "github.com/hashicorp/consul/proto-public/pbresource" + "github.com/hashicorp/consul/proto/private/prototest" + "github.com/hashicorp/consul/sdk/testutil" + "github.com/stretchr/testify/require" +) + +var ( + //go:embed integration_test_data + testData embed.FS +) + +// RunCatalogV1Alpha1IntegrationTest will push up a bunch of catalog related data and then +// verify that all the expected reconciliations happened correctly. This test is +// intended to exercise a large swathe of behavior of the overall catalog package. +// Besides just controller reconciliation behavior, the intent is also to verify +// that integrations with the resource service are also working (i.e. the various +// validation, mutation and ACL hooks get invoked and are working properly) +// +// This test specifically is not doing any sort of lifecycle related tests to ensure +// that modification to values results in re-reconciliation as expected. Instead there +// is another RunCatalogIntegrationTestLifeCycle function that can be used for those +// purposes. The two are distinct so that the data being published and the assertions +// made against the system can be reused in upgrade tests. +func RunCatalogV1Alpha1IntegrationTest(t *testing.T, client pbresource.ResourceServiceClient) { + t.Helper() + + PublishCatalogV1Alpha1IntegrationTestData(t, client) + VerifyCatalogV1Alpha1IntegrationTestResults(t, client) +} + +// PublishCatalogV1Alpha1IntegrationTestData will perform a whole bunch of resource writes +// for Service, ServiceEndpoints, Workload, Node and HealthStatus objects +func PublishCatalogV1Alpha1IntegrationTestData(t *testing.T, client pbresource.ResourceServiceClient) { + t.Helper() + + c := rtest.NewClient(client) + + resources := rtest.ParseResourcesFromFilesystem(t, testData, "integration_test_data/v1alpha1") + c.PublishResources(t, resources) +} + +func VerifyCatalogV1Alpha1IntegrationTestResults(t *testing.T, client pbresource.ResourceServiceClient) { + t.Helper() + + c := rtest.NewClient(client) + + testutil.RunStep(t, "resources-exist", func(t *testing.T) { + c.RequireResourceExists(t, rtest.Resource(catalog.ServiceV1Alpha1Type, "api").ID()) + c.RequireResourceExists(t, rtest.Resource(catalog.ServiceV1Alpha1Type, "http-api").ID()) + c.RequireResourceExists(t, rtest.Resource(catalog.ServiceV1Alpha1Type, "grpc-api").ID()) + c.RequireResourceExists(t, rtest.Resource(catalog.ServiceV1Alpha1Type, "foo").ID()) + + for i := 1; i < 5; i++ { + nodeId := rtest.Resource(catalog.NodeV1Alpha1Type, fmt.Sprintf("node-%d", i)).ID() + c.RequireResourceExists(t, nodeId) + + res := c.RequireResourceExists(t, rtest.Resource(catalog.HealthStatusV1Alpha1Type, fmt.Sprintf("node-%d-health", i)).ID()) + rtest.RequireOwner(t, res, nodeId, true) + } + + for i := 1; i < 21; i++ { + workloadId := rtest.Resource(catalog.WorkloadV1Alpha1Type, fmt.Sprintf("api-%d", i)).ID() + c.RequireResourceExists(t, workloadId) + + res := c.RequireResourceExists(t, rtest.Resource(catalog.HealthStatusV1Alpha1Type, fmt.Sprintf("api-%d-health", i)).ID()) + rtest.RequireOwner(t, res, workloadId, true) + } + }) + + testutil.RunStep(t, "node-health-reconciliation", func(t *testing.T) { + c.WaitForStatusCondition(t, rtest.Resource(catalog.NodeV1Alpha1Type, "node-1").ID(), nodehealth.StatusKey, nodehealth.ConditionPassing) + c.WaitForStatusCondition(t, rtest.Resource(catalog.NodeV1Alpha1Type, "node-2").ID(), nodehealth.StatusKey, nodehealth.ConditionWarning) + c.WaitForStatusCondition(t, rtest.Resource(catalog.NodeV1Alpha1Type, "node-3").ID(), nodehealth.StatusKey, nodehealth.ConditionCritical) + c.WaitForStatusCondition(t, rtest.Resource(catalog.NodeV1Alpha1Type, "node-4").ID(), nodehealth.StatusKey, nodehealth.ConditionMaintenance) + }) + + testutil.RunStep(t, "workload-health-reconciliation", func(t *testing.T) { + c.WaitForStatusCondition(t, rtest.Resource(catalog.WorkloadV1Alpha1Type, "api-1").ID(), workloadhealth.StatusKey, workloadhealth.ConditionNodeAndWorkloadPassing) + c.WaitForStatusCondition(t, rtest.Resource(catalog.WorkloadV1Alpha1Type, "api-2").ID(), workloadhealth.StatusKey, workloadhealth.ConditionWorkloadWarning) + c.WaitForStatusCondition(t, rtest.Resource(catalog.WorkloadV1Alpha1Type, "api-3").ID(), workloadhealth.StatusKey, workloadhealth.ConditionWorkloadCritical) + c.WaitForStatusCondition(t, rtest.Resource(catalog.WorkloadV1Alpha1Type, "api-4").ID(), workloadhealth.StatusKey, workloadhealth.ConditionWorkloadMaintenance) + c.WaitForStatusCondition(t, rtest.Resource(catalog.WorkloadV1Alpha1Type, "api-5").ID(), workloadhealth.StatusKey, workloadhealth.ConditionNodeWarning) + c.WaitForStatusCondition(t, rtest.Resource(catalog.WorkloadV1Alpha1Type, "api-6").ID(), workloadhealth.StatusKey, workloadhealth.ConditionNodeAndWorkloadWarning) + c.WaitForStatusCondition(t, rtest.Resource(catalog.WorkloadV1Alpha1Type, "api-7").ID(), workloadhealth.StatusKey, workloadhealth.ConditionNodeAndWorkloadCritical) + c.WaitForStatusCondition(t, rtest.Resource(catalog.WorkloadV1Alpha1Type, "api-8").ID(), workloadhealth.StatusKey, workloadhealth.ConditionNodeAndWorkloadMaintenance) + c.WaitForStatusCondition(t, rtest.Resource(catalog.WorkloadV1Alpha1Type, "api-9").ID(), workloadhealth.StatusKey, workloadhealth.ConditionNodeCritical) + c.WaitForStatusCondition(t, rtest.Resource(catalog.WorkloadV1Alpha1Type, "api-10").ID(), workloadhealth.StatusKey, workloadhealth.ConditionNodeAndWorkloadCritical) + c.WaitForStatusCondition(t, rtest.Resource(catalog.WorkloadV1Alpha1Type, "api-11").ID(), workloadhealth.StatusKey, workloadhealth.ConditionNodeAndWorkloadCritical) + c.WaitForStatusCondition(t, rtest.Resource(catalog.WorkloadV1Alpha1Type, "api-12").ID(), workloadhealth.StatusKey, workloadhealth.ConditionNodeAndWorkloadMaintenance) + c.WaitForStatusCondition(t, rtest.Resource(catalog.WorkloadV1Alpha1Type, "api-13").ID(), workloadhealth.StatusKey, workloadhealth.ConditionNodeMaintenance) + c.WaitForStatusCondition(t, rtest.Resource(catalog.WorkloadV1Alpha1Type, "api-14").ID(), workloadhealth.StatusKey, workloadhealth.ConditionNodeAndWorkloadMaintenance) + c.WaitForStatusCondition(t, rtest.Resource(catalog.WorkloadV1Alpha1Type, "api-15").ID(), workloadhealth.StatusKey, workloadhealth.ConditionNodeAndWorkloadMaintenance) + c.WaitForStatusCondition(t, rtest.Resource(catalog.WorkloadV1Alpha1Type, "api-16").ID(), workloadhealth.StatusKey, workloadhealth.ConditionNodeAndWorkloadMaintenance) + c.WaitForStatusCondition(t, rtest.Resource(catalog.WorkloadV1Alpha1Type, "api-17").ID(), workloadhealth.StatusKey, workloadhealth.ConditionWorkloadPassing) + c.WaitForStatusCondition(t, rtest.Resource(catalog.WorkloadV1Alpha1Type, "api-18").ID(), workloadhealth.StatusKey, workloadhealth.ConditionWorkloadWarning) + c.WaitForStatusCondition(t, rtest.Resource(catalog.WorkloadV1Alpha1Type, "api-19").ID(), workloadhealth.StatusKey, workloadhealth.ConditionWorkloadCritical) + c.WaitForStatusCondition(t, rtest.Resource(catalog.WorkloadV1Alpha1Type, "api-20").ID(), workloadhealth.StatusKey, workloadhealth.ConditionWorkloadMaintenance) + }) + + testutil.RunStep(t, "service-reconciliation", func(t *testing.T) { + c.WaitForStatusCondition(t, rtest.Resource(catalog.ServiceV1Alpha1Type, "foo").ID(), endpoints.StatusKey, endpoints.ConditionUnmanaged) + c.WaitForStatusCondition(t, rtest.Resource(catalog.ServiceV1Alpha1Type, "api").ID(), endpoints.StatusKey, endpoints.ConditionManaged) + c.WaitForStatusCondition(t, rtest.Resource(catalog.ServiceV1Alpha1Type, "http-api").ID(), endpoints.StatusKey, endpoints.ConditionManaged) + c.WaitForStatusCondition(t, rtest.Resource(catalog.ServiceV1Alpha1Type, "grpc-api").ID(), endpoints.StatusKey, endpoints.ConditionManaged) + }) + + testutil.RunStep(t, "service-endpoints-generation", func(t *testing.T) { + verifyServiceEndpoints(t, c, rtest.Resource(catalog.ServiceEndpointsV1Alpha1Type, "foo").ID(), expectedFooServiceEndpoints()) + verifyServiceEndpoints(t, c, rtest.Resource(catalog.ServiceEndpointsV1Alpha1Type, "api").ID(), expectedApiServiceEndpoints(t, c)) + verifyServiceEndpoints(t, c, rtest.Resource(catalog.ServiceEndpointsV1Alpha1Type, "http-api").ID(), expectedHTTPApiServiceEndpoints(t, c)) + verifyServiceEndpoints(t, c, rtest.Resource(catalog.ServiceEndpointsV1Alpha1Type, "grpc-api").ID(), expectedGRPCApiServiceEndpoints(t, c)) + }) +} + +func expectedFooServiceEndpoints() *pbcatalog.ServiceEndpoints { + return &pbcatalog.ServiceEndpoints{ + Endpoints: []*pbcatalog.Endpoint{ + { + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "198.18.0.1"}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "external-service-port": { + Port: 9876, + Protocol: pbcatalog.Protocol_PROTOCOL_HTTP2, + }, + }, + HealthStatus: pbcatalog.Health_HEALTH_PASSING, + }, + }, + } +} + +func expectedApiServiceEndpoints(t *testing.T, c *rtest.Client) *pbcatalog.ServiceEndpoints { + return &pbcatalog.ServiceEndpoints{ + Endpoints: []*pbcatalog.Endpoint{ + // api-1 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-1").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.1", Ports: []string{"grpc", "http", "mesh"}}, + {Host: "198.18.2.1", External: true, Ports: []string{"mesh"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + "grpc": {Port: 9090, Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + "mesh": {Port: 10000, Protocol: pbcatalog.Protocol_PROTOCOL_MESH}, + }, + HealthStatus: pbcatalog.Health_HEALTH_PASSING, + }, + // api-2 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-2").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.2", Ports: []string{"grpc", "http", "mesh"}}, + {Host: "198.18.2.2", External: true, Ports: []string{"mesh"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + "grpc": {Port: 9090, Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + "mesh": {Port: 10000, Protocol: pbcatalog.Protocol_PROTOCOL_MESH}, + }, + HealthStatus: pbcatalog.Health_HEALTH_WARNING, + }, + // api-3 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-3").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.3", Ports: []string{"grpc", "http", "mesh"}}, + {Host: "198.18.2.3", External: true, Ports: []string{"mesh"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + "grpc": {Port: 9090, Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + "mesh": {Port: 10000, Protocol: pbcatalog.Protocol_PROTOCOL_MESH}, + }, + HealthStatus: pbcatalog.Health_HEALTH_CRITICAL, + }, + // api-4 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-4").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.4", Ports: []string{"grpc", "http", "mesh"}}, + {Host: "198.18.2.4", External: true, Ports: []string{"mesh"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + "grpc": {Port: 9090, Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + "mesh": {Port: 10000, Protocol: pbcatalog.Protocol_PROTOCOL_MESH}, + }, + HealthStatus: pbcatalog.Health_HEALTH_MAINTENANCE, + }, + // api-5 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-5").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.5", Ports: []string{"grpc", "http", "mesh"}}, + {Host: "198.18.2.5", External: true, Ports: []string{"mesh"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + "grpc": {Port: 9090, Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + "mesh": {Port: 10000, Protocol: pbcatalog.Protocol_PROTOCOL_MESH}, + }, + HealthStatus: pbcatalog.Health_HEALTH_WARNING, + }, + // api-6 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-6").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.6", Ports: []string{"grpc", "http", "mesh"}}, + {Host: "198.18.2.6", External: true, Ports: []string{"mesh"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + "grpc": {Port: 9090, Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + "mesh": {Port: 10000, Protocol: pbcatalog.Protocol_PROTOCOL_MESH}, + }, + HealthStatus: pbcatalog.Health_HEALTH_WARNING, + }, + // api-7 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-7").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.7", Ports: []string{"grpc", "http", "mesh"}}, + {Host: "198.18.2.7", External: true, Ports: []string{"mesh"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + "grpc": {Port: 9090, Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + "mesh": {Port: 10000, Protocol: pbcatalog.Protocol_PROTOCOL_MESH}, + }, + HealthStatus: pbcatalog.Health_HEALTH_CRITICAL, + }, + // api-8 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-8").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.8", Ports: []string{"grpc", "http", "mesh"}}, + {Host: "198.18.2.8", External: true, Ports: []string{"mesh"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + "grpc": {Port: 9090, Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + "mesh": {Port: 10000, Protocol: pbcatalog.Protocol_PROTOCOL_MESH}, + }, + HealthStatus: pbcatalog.Health_HEALTH_MAINTENANCE, + }, + // api-9 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-9").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.9", Ports: []string{"grpc", "http", "mesh"}}, + {Host: "198.18.2.9", External: true, Ports: []string{"mesh"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + "grpc": {Port: 9090, Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + "mesh": {Port: 10000, Protocol: pbcatalog.Protocol_PROTOCOL_MESH}, + }, + HealthStatus: pbcatalog.Health_HEALTH_CRITICAL, + }, + // api-10 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-10").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.10", Ports: []string{"grpc", "http", "mesh"}}, + {Host: "198.18.2.10", External: true, Ports: []string{"mesh"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + "grpc": {Port: 9090, Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + "mesh": {Port: 10000, Protocol: pbcatalog.Protocol_PROTOCOL_MESH}, + }, + HealthStatus: pbcatalog.Health_HEALTH_CRITICAL, + }, + // api-11 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-11").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.11", Ports: []string{"grpc", "http", "mesh"}}, + {Host: "198.18.2.11", External: true, Ports: []string{"mesh"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + "grpc": {Port: 9090, Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + "mesh": {Port: 10000, Protocol: pbcatalog.Protocol_PROTOCOL_MESH}, + }, + HealthStatus: pbcatalog.Health_HEALTH_CRITICAL, + }, + // api-12 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-12").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.12", Ports: []string{"grpc", "http", "mesh"}}, + {Host: "198.18.2.12", External: true, Ports: []string{"mesh"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + "grpc": {Port: 9090, Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + "mesh": {Port: 10000, Protocol: pbcatalog.Protocol_PROTOCOL_MESH}, + }, + HealthStatus: pbcatalog.Health_HEALTH_MAINTENANCE, + }, + // api-13 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-13").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.13", Ports: []string{"grpc", "http", "mesh"}}, + {Host: "198.18.2.13", External: true, Ports: []string{"mesh"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + "grpc": {Port: 9090, Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + "mesh": {Port: 10000, Protocol: pbcatalog.Protocol_PROTOCOL_MESH}, + }, + HealthStatus: pbcatalog.Health_HEALTH_MAINTENANCE, + }, + // api-14 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-14").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.14", Ports: []string{"grpc", "http", "mesh"}}, + {Host: "198.18.2.14", External: true, Ports: []string{"mesh"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + "grpc": {Port: 9090, Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + "mesh": {Port: 10000, Protocol: pbcatalog.Protocol_PROTOCOL_MESH}, + }, + HealthStatus: pbcatalog.Health_HEALTH_MAINTENANCE, + }, + // api-15 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-15").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.15", Ports: []string{"grpc", "http", "mesh"}}, + {Host: "198.18.2.15", External: true, Ports: []string{"mesh"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + "grpc": {Port: 9090, Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + "mesh": {Port: 10000, Protocol: pbcatalog.Protocol_PROTOCOL_MESH}, + }, + HealthStatus: pbcatalog.Health_HEALTH_MAINTENANCE, + }, + // api-16 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-16").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.16", Ports: []string{"grpc", "http", "mesh"}}, + {Host: "198.18.2.16", External: true, Ports: []string{"mesh"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + "grpc": {Port: 9090, Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + "mesh": {Port: 10000, Protocol: pbcatalog.Protocol_PROTOCOL_MESH}, + }, + HealthStatus: pbcatalog.Health_HEALTH_MAINTENANCE, + }, + // api-17 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-17").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.17", Ports: []string{"grpc", "http", "mesh"}}, + {Host: "198.18.2.17", External: true, Ports: []string{"mesh"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + "grpc": {Port: 9090, Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + "mesh": {Port: 10000, Protocol: pbcatalog.Protocol_PROTOCOL_MESH}, + }, + HealthStatus: pbcatalog.Health_HEALTH_PASSING, + }, + // api-18 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-18").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.18", Ports: []string{"grpc", "http", "mesh"}}, + {Host: "198.18.2.18", External: true, Ports: []string{"mesh"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + "grpc": {Port: 9090, Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + "mesh": {Port: 10000, Protocol: pbcatalog.Protocol_PROTOCOL_MESH}, + }, + HealthStatus: pbcatalog.Health_HEALTH_WARNING, + }, + // api-19 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-19").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.19", Ports: []string{"grpc", "http", "mesh"}}, + {Host: "198.18.2.19", External: true, Ports: []string{"mesh"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + "grpc": {Port: 9090, Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + "mesh": {Port: 10000, Protocol: pbcatalog.Protocol_PROTOCOL_MESH}, + }, + HealthStatus: pbcatalog.Health_HEALTH_CRITICAL, + }, + // api-20 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-20").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.20", Ports: []string{"grpc", "http", "mesh"}}, + {Host: "198.18.2.20", External: true, Ports: []string{"mesh"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + "grpc": {Port: 9090, Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + "mesh": {Port: 10000, Protocol: pbcatalog.Protocol_PROTOCOL_MESH}, + }, + HealthStatus: pbcatalog.Health_HEALTH_MAINTENANCE, + }, + }, + } +} + +func expectedHTTPApiServiceEndpoints(t *testing.T, c *rtest.Client) *pbcatalog.ServiceEndpoints { + return &pbcatalog.ServiceEndpoints{ + Endpoints: []*pbcatalog.Endpoint{ + // api-1 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-1").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.1", Ports: []string{"http"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + HealthStatus: pbcatalog.Health_HEALTH_PASSING, + }, + // api-10 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-10").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.10", Ports: []string{"http"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + HealthStatus: pbcatalog.Health_HEALTH_CRITICAL, + }, + // api-11 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-11").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.11", Ports: []string{"http"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + HealthStatus: pbcatalog.Health_HEALTH_CRITICAL, + }, + // api-12 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-12").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.12", Ports: []string{"http"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + HealthStatus: pbcatalog.Health_HEALTH_MAINTENANCE, + }, + // api-13 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-13").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.13", Ports: []string{"http"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + HealthStatus: pbcatalog.Health_HEALTH_MAINTENANCE, + }, + // api-14 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-14").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.14", Ports: []string{"http"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + HealthStatus: pbcatalog.Health_HEALTH_MAINTENANCE, + }, + // api-15 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-15").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.15", Ports: []string{"http"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + HealthStatus: pbcatalog.Health_HEALTH_MAINTENANCE, + }, + // api-16 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-16").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.16", Ports: []string{"http"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + HealthStatus: pbcatalog.Health_HEALTH_MAINTENANCE, + }, + // api-17 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-17").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.17", Ports: []string{"http"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + HealthStatus: pbcatalog.Health_HEALTH_PASSING, + }, + // api-18 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-18").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.18", Ports: []string{"http"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + HealthStatus: pbcatalog.Health_HEALTH_WARNING, + }, + // api-19 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-19").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.19", Ports: []string{"http"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + HealthStatus: pbcatalog.Health_HEALTH_CRITICAL, + }, + }, + } +} + +func expectedGRPCApiServiceEndpoints(t *testing.T, c *rtest.Client) *pbcatalog.ServiceEndpoints { + return &pbcatalog.ServiceEndpoints{ + Endpoints: []*pbcatalog.Endpoint{ + // api-1 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-1").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.1", Ports: []string{"grpc", "mesh"}}, + {Host: "198.18.2.1", External: true, Ports: []string{"mesh"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "grpc": {Port: 9090, Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + "mesh": {Port: 10000, Protocol: pbcatalog.Protocol_PROTOCOL_MESH}, + }, + HealthStatus: pbcatalog.Health_HEALTH_PASSING, + }, + // api-2 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-2").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.2", Ports: []string{"grpc", "mesh"}}, + {Host: "198.18.2.2", External: true, Ports: []string{"mesh"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "grpc": {Port: 9090, Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + "mesh": {Port: 10000, Protocol: pbcatalog.Protocol_PROTOCOL_MESH}, + }, + HealthStatus: pbcatalog.Health_HEALTH_WARNING, + }, + // api-3 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-3").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.3", Ports: []string{"grpc", "mesh"}}, + {Host: "198.18.2.3", External: true, Ports: []string{"mesh"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "grpc": {Port: 9090, Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + "mesh": {Port: 10000, Protocol: pbcatalog.Protocol_PROTOCOL_MESH}, + }, + HealthStatus: pbcatalog.Health_HEALTH_CRITICAL, + }, + // api-4 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-4").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.4", Ports: []string{"grpc", "mesh"}}, + {Host: "198.18.2.4", External: true, Ports: []string{"mesh"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "grpc": {Port: 9090, Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + "mesh": {Port: 10000, Protocol: pbcatalog.Protocol_PROTOCOL_MESH}, + }, + HealthStatus: pbcatalog.Health_HEALTH_MAINTENANCE, + }, + // api-5 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-5").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.5", Ports: []string{"grpc", "mesh"}}, + {Host: "198.18.2.5", External: true, Ports: []string{"mesh"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "grpc": {Port: 9090, Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + "mesh": {Port: 10000, Protocol: pbcatalog.Protocol_PROTOCOL_MESH}, + }, + HealthStatus: pbcatalog.Health_HEALTH_WARNING, + }, + // api-6 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-6").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.6", Ports: []string{"grpc", "mesh"}}, + {Host: "198.18.2.6", External: true, Ports: []string{"mesh"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "grpc": {Port: 9090, Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + "mesh": {Port: 10000, Protocol: pbcatalog.Protocol_PROTOCOL_MESH}, + }, + HealthStatus: pbcatalog.Health_HEALTH_WARNING, + }, + // api-7 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-7").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.7", Ports: []string{"grpc", "mesh"}}, + {Host: "198.18.2.7", External: true, Ports: []string{"mesh"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "grpc": {Port: 9090, Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + "mesh": {Port: 10000, Protocol: pbcatalog.Protocol_PROTOCOL_MESH}, + }, + HealthStatus: pbcatalog.Health_HEALTH_CRITICAL, + }, + // api-8 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-8").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.8", Ports: []string{"grpc", "mesh"}}, + {Host: "198.18.2.8", External: true, Ports: []string{"mesh"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "grpc": {Port: 9090, Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + "mesh": {Port: 10000, Protocol: pbcatalog.Protocol_PROTOCOL_MESH}, + }, + HealthStatus: pbcatalog.Health_HEALTH_MAINTENANCE, + }, + // api-9 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-9").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.9", Ports: []string{"grpc", "mesh"}}, + {Host: "198.18.2.9", External: true, Ports: []string{"mesh"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "grpc": {Port: 9090, Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + "mesh": {Port: 10000, Protocol: pbcatalog.Protocol_PROTOCOL_MESH}, + }, + HealthStatus: pbcatalog.Health_HEALTH_CRITICAL, + }, + // api-20 + { + TargetRef: c.ResolveResourceID(t, rtest.Resource(types.WorkloadV1Alpha1Type, "api-20").ID()), + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "172.16.1.20", Ports: []string{"grpc", "mesh"}}, + {Host: "198.18.2.20", External: true, Ports: []string{"mesh"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "grpc": {Port: 9090, Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + "mesh": {Port: 10000, Protocol: pbcatalog.Protocol_PROTOCOL_MESH}, + }, + HealthStatus: pbcatalog.Health_HEALTH_MAINTENANCE, + }, + }, + } +} + +func verifyServiceEndpoints(t *testing.T, c *rtest.Client, id *pbresource.ID, expected *pbcatalog.ServiceEndpoints) { + c.WaitForResourceState(t, id, func(t rtest.T, res *pbresource.Resource) { + var actual pbcatalog.ServiceEndpoints + err := res.Data.UnmarshalTo(&actual) + require.NoError(t, err) + prototest.AssertElementsMatch(t, expected.Endpoints, actual.Endpoints) + }) +} diff --git a/internal/catalog/exports.go b/internal/catalog/exports.go index 10f2f9be0fd5..61247091be1c 100644 --- a/internal/catalog/exports.go +++ b/internal/catalog/exports.go @@ -6,6 +6,7 @@ package catalog import ( "github.com/hashicorp/consul/internal/catalog/internal/controllers" "github.com/hashicorp/consul/internal/catalog/internal/mappers/nodemapper" + "github.com/hashicorp/consul/internal/catalog/internal/mappers/selectiontracker" "github.com/hashicorp/consul/internal/catalog/internal/types" "github.com/hashicorp/consul/internal/controller" "github.com/hashicorp/consul/internal/resource" @@ -52,6 +53,7 @@ type ControllerDependencies = controllers.Dependencies func DefaultControllerDependencies() ControllerDependencies { return ControllerDependencies{ WorkloadHealthNodeMapper: nodemapper.New(), + EndpointsWorkloadMapper: selectiontracker.New(), } } diff --git a/internal/catalog/internal/controllers/endpoints/controller.go b/internal/catalog/internal/controllers/endpoints/controller.go new file mode 100644 index 000000000000..1be2a3fd2fad --- /dev/null +++ b/internal/catalog/internal/controllers/endpoints/controller.go @@ -0,0 +1,384 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package endpoints + +import ( + "context" + "sort" + + "github.com/hashicorp/consul/internal/catalog/internal/controllers/workloadhealth" + "github.com/hashicorp/consul/internal/catalog/internal/types" + "github.com/hashicorp/consul/internal/controller" + "github.com/hashicorp/consul/internal/resource" + pbcatalog "github.com/hashicorp/consul/proto-public/pbcatalog/v1alpha1" + "github.com/hashicorp/consul/proto-public/pbresource" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/anypb" +) + +const ( + endpointsMetaManagedBy = "managed-by-controller" +) + +// The WorkloadMapper interface is used to provide an implementation around being able +// to map a watch even for a Workload resource and translate it to reconciliation requests +type WorkloadMapper interface { + // MapWorkload conforms to the controller.DependencyMapper signature. Given a Workload + // resource it should report the resource IDs that have selected the workload. + MapWorkload(context.Context, controller.Runtime, *pbresource.Resource) ([]controller.Request, error) + + // TrackIDForSelector should be used to associate the specified WorkloadSelector with + // the given resource ID. Future calls to MapWorkload + TrackIDForSelector(*pbresource.ID, *pbcatalog.WorkloadSelector) + + // UntrackID should be used to inform the tracker to forget about the specified ID + UntrackID(*pbresource.ID) +} + +// ServiceEndpointsController creates a controller to perform automatic endpoint management for +// services. +func ServiceEndpointsController(workloadMap WorkloadMapper) controller.Controller { + if workloadMap == nil { + panic("No WorkloadMapper was provided to the ServiceEndpointsController constructor") + } + + return controller.ForType(types.ServiceEndpointsType). + WithWatch(types.ServiceType, controller.ReplaceType(types.ServiceEndpointsType)). + WithWatch(types.WorkloadType, workloadMap.MapWorkload). + WithReconciler(newServiceEndpointsReconciler(workloadMap)) +} + +type serviceEndpointsReconciler struct { + workloadMap WorkloadMapper +} + +func newServiceEndpointsReconciler(workloadMap WorkloadMapper) *serviceEndpointsReconciler { + return &serviceEndpointsReconciler{ + workloadMap: workloadMap, + } +} + +// Reconcile will reconcile one ServiceEndpoints resource in response to some event. +func (r *serviceEndpointsReconciler) Reconcile(ctx context.Context, rt controller.Runtime, req controller.Request) error { + // The runtime is passed by value so replacing it here for the remainder of this + // reconciliation request processing will not affect future invocations. + rt.Logger = rt.Logger.With("resource-id", req.ID, "controller", StatusKey) + + rt.Logger.Trace("reconciling service endpoints") + + endpointsID := req.ID + serviceID := &pbresource.ID{ + Type: types.ServiceType, + Tenancy: endpointsID.Tenancy, + Name: endpointsID.Name, + } + + // First we read and unmarshal the service + + serviceData, err := getServiceData(ctx, rt, serviceID) + if err != nil { + rt.Logger.Error("error retrieving corresponding Service", "error", err) + return err + } + + // Check if the service exists. If it doesn't we can avoid a bunch of other work. + if serviceData == nil { + rt.Logger.Trace("service has been deleted") + + // The service was deleted so we need to update the WorkloadMapper to tell it to + // stop tracking this service + r.workloadMap.UntrackID(req.ID) + + // Note that because we configured ServiceEndpoints to be owned by the service, + // the service endpoints object should eventually be automatically deleted. + // There is no reason to attempt deletion here. + return nil + } + + // Now read and unmarshal the endpoints. We don't need this data just yet but all + // code paths from this point on will need this regardless of branching so we pull + // it now. + endpointsData, err := getEndpointsData(ctx, rt, endpointsID) + if err != nil { + rt.Logger.Error("error retrieving existing endpoints", "error", err) + return err + } + + var status *pbresource.Condition + + if serviceUnderManagement(serviceData.service) { + rt.Logger.Trace("service is enabled for automatic endpoint management") + // This service should have its endpoints automatically managed + status = ConditionManaged + + // Inform the WorkloadMapper to track this service and its selectors. So + // future workload updates that would be matched by the services selectors + // cause this service to be rereconciled. + r.workloadMap.TrackIDForSelector(req.ID, serviceData.service.GetWorkloads()) + + // Now read and umarshal all workloads selected by the service. It is imperative + // that this happens after we notify the selection tracker to be tracking that + // selection criteria. If the order were reversed we could potentially miss + // workload creations that should be selected if they happen after gathering + // the workloads but before tracking the selector. Tracking first ensures that + // any event that happens after that would get mapped to an event for these + // endpoints. + workloadData, err := getWorkloadData(ctx, rt, serviceData) + if err != nil { + rt.Logger.Trace("error retrieving selected workloads", "error", err) + return err + } + + // Calculate the latest endpoints from the already gathered workloads + latestEndpoints := workloadsToEndpoints(serviceData.service, workloadData) + + // Before writing the endpoints actually check to see if they are changed + if endpointsData == nil || !proto.Equal(endpointsData.endpoints, latestEndpoints) { + rt.Logger.Trace("endpoints have changed") + + // First encode the endpoints data as an Any type. + endpointData, err := anypb.New(latestEndpoints) + if err != nil { + rt.Logger.Error("error marshalling latest endpoints", "error", err) + return err + } + + // Now perform the write. The endpoints resource should be owned by the service + // so that it will automatically be deleted upon service deletion. We are using + // a special metadata entry to track that this controller is responsible for + // the management of this resource. + _, err = rt.Client.Write(ctx, &pbresource.WriteRequest{ + Resource: &pbresource.Resource{ + Id: req.ID, + Owner: serviceData.resource.Id, + Metadata: map[string]string{ + endpointsMetaManagedBy: StatusKey, + }, + Data: endpointData, + }, + }) + if err != nil { + rt.Logger.Error("error writing generated endpoints", "error", err) + return err + } else { + rt.Logger.Trace("updated endpoints were successfully written") + } + } + } else { + rt.Logger.Trace("endpoints are not being automatically managed") + // This service is not having its endpoints automatically managed + status = ConditionUnmanaged + + // Inform the WorkloadMapper that it no longer needs to track this service + // as it is no longer under endpoint management + r.workloadMap.UntrackID(req.ID) + + // Delete the managed ServiceEndpoints if necessary if the metadata would + // indicate that they were previously managed by this controller + if endpointsData != nil && endpointsData.resource.Metadata[endpointsMetaManagedBy] == StatusKey { + rt.Logger.Trace("removing previous managed endpoints") + + // This performs a CAS deletion to protect against the case where the user + // has overwritten the endpoints since we fetched them. + _, err := rt.Client.Delete(ctx, &pbresource.DeleteRequest{ + Id: endpointsData.resource.Id, + Version: endpointsData.resource.Version, + }) + + // Potentially we could look for CAS failures by checking if the gRPC + // status code is Aborted. However its an edge case and there could + // possibly be other reasons why the gRPC status code would be aborted + // besides CAS version mismatches. The simplest thing to do is to just + // propagate the error and retry reconciliation later. + if err != nil { + rt.Logger.Error("error deleting previously managed endpoints", "error", err) + return err + } + } + } + + // Update the Service status if necessary. Mainly we want to inform the user + // whether we are automatically managing the endpoints to set expectations + // for that object existing or not. + newStatus := &pbresource.Status{ + ObservedGeneration: serviceData.resource.Generation, + Conditions: []*pbresource.Condition{ + status, + }, + } + // If the status is unchanged then we should return and avoid the unnecessary write + if resource.EqualStatus(serviceData.resource.Status[StatusKey], newStatus, false) { + return nil + } + + _, err = rt.Client.WriteStatus(ctx, &pbresource.WriteStatusRequest{ + Id: serviceData.resource.Id, + Key: StatusKey, + Status: newStatus, + }) + + if err != nil { + rt.Logger.Error("error updating the service's status", "error", err, "service", serviceID) + } + return err +} + +// determineWorkloadHealth will find the workload-health controller's status +// within the resource status and parse the workloads health out of it. If +// the workload-health controller has yet to reconcile the workload health +// or the status isn't in the expected form then this function will return +// HEALTH_CRITICAL. +func determineWorkloadHealth(workload *pbresource.Resource) pbcatalog.Health { + status, found := workload.Status[workloadhealth.StatusKey] + if !found { + return pbcatalog.Health_HEALTH_CRITICAL + } + + for _, condition := range status.Conditions { + if condition.Type == workloadhealth.StatusConditionHealthy { + raw, found := pbcatalog.Health_value[condition.Reason] + if found { + return pbcatalog.Health(raw) + } + return pbcatalog.Health_HEALTH_CRITICAL + } + } + return pbcatalog.Health_HEALTH_CRITICAL +} + +// serviceUnderManagement detects whether this service should have its +// endpoints automatically managed by the controller +func serviceUnderManagement(svc *pbcatalog.Service) bool { + sel := svc.GetWorkloads() + if sel == nil { + // The selector wasn't present at all. Therefore this service is not under + // automatic endpoint management. + return false + } + + if len(sel.Names) < 1 && len(sel.Prefixes) < 1 { + // The selector was set in the request but the list of workload names + // and prefixes were both empty. Therefore this service is not under + // automatic endpoint management + return false + } + + // Some workload selection criteria exists, so this service is considered + // under automatic endpoint management. + return true +} + +// workloadsToEndpoints will translate the Workload resources into a ServiceEndpoints resource +func workloadsToEndpoints(svc *pbcatalog.Service, workloads []*workloadData) *pbcatalog.ServiceEndpoints { + var endpoints []*pbcatalog.Endpoint + + for _, workload := range workloads { + endpoint := workloadToEndpoint(svc, workload) + if endpoint != nil { + endpoints = append(endpoints, endpoint) + } + } + + return &pbcatalog.ServiceEndpoints{ + Endpoints: endpoints, + } +} + +// workloadToEndpoint will convert a workload resource into a singular Endpoint to be +// put within a ServiceEndpoints resource. +// +// The conversion process involves parsing the workloads health and filtering its +// addresses and ports down to just what the service wants to consume. +// +// Determining the workloads health requires the workload-health controller to already +// have reconciled the workloads health and stored it within the resources Status field. +// Any unreconciled workload health will be represented in the ServiceEndpoints with +// the ANY health status. +func workloadToEndpoint(svc *pbcatalog.Service, data *workloadData) *pbcatalog.Endpoint { + health := determineWorkloadHealth(data.resource) + + endpointPorts := make(map[string]*pbcatalog.WorkloadPort) + + // Create the endpoints filtered ports map. Only workload ports specified in + // one of the services ports are included. Ports with a protocol mismatch + // between the service and workload will be excluded as well. + for _, svcPort := range svc.Ports { + workloadPort, found := data.workload.Ports[svcPort.TargetPort] + if !found { + // this workload doesn't have this port so ignore it + continue + } + + if workloadPort.Protocol != svcPort.Protocol { + // workload port mismatch - ignore it + continue + } + + endpointPorts[svcPort.TargetPort] = workloadPort + } + + var workloadAddrs []*pbcatalog.WorkloadAddress + + // Now we filter down the addresses and their corresponding port usage to just + // what the service needs to consume. If the address isn't being used to serve + // any of the services target ports, it will be entirely excluded from the + // address list. If some but not all of its ports are served, then the list + // of ports will be reduced to just the intersection of the service ports + // and the workload addresses ports + for _, addr := range data.workload.Addresses { + var ports []string + + if len(addr.Ports) > 0 { + // The workload address has defined ports, filter these as necessary + + for _, portName := range addr.Ports { + // check if the workload port has been selected by the service + _, found := endpointPorts[portName] + if !found { + // this port isn't selected by the service so drop this port + continue + } + + ports = append(ports, portName) + } + } else { + // The workload address doesn't specify ports. This lack of port specification + // means that all ports are exposed on the interface so here we create a list + // of all the port names exposed by the service. + for portName := range endpointPorts { + ports = append(ports, portName) + } + } + + // sort the ports to keep them stable and prevent unnecessary rewrites when the endpoints + // get diffed + sort.Slice(ports, func(i, j int) bool { + return ports[i] < ports[j] + }) + + // Only record this workload address if one or more of its ports were consumed + // by the service. + if len(ports) > 0 { + workloadAddrs = append(workloadAddrs, &pbcatalog.WorkloadAddress{ + Host: addr.Host, + External: addr.External, + Ports: ports, + }) + } + } + + // If all the workload addresses were filtered out then we should completely ignore + // the workload. While the name matched nothing else did so it isn't useable as + // far as the service is concerned. + if len(workloadAddrs) < 1 { + return nil + } + + return &pbcatalog.Endpoint{ + TargetRef: data.resource.Id, + HealthStatus: health, + Addresses: workloadAddrs, + Ports: endpointPorts, + } +} diff --git a/internal/catalog/internal/controllers/endpoints/controller_test.go b/internal/catalog/internal/controllers/endpoints/controller_test.go new file mode 100644 index 000000000000..ff53509057a2 --- /dev/null +++ b/internal/catalog/internal/controllers/endpoints/controller_test.go @@ -0,0 +1,709 @@ +package endpoints + +import ( + "context" + "testing" + + svctest "github.com/hashicorp/consul/agent/grpc-external/services/resource/testing" + "github.com/hashicorp/consul/internal/catalog/internal/controllers/workloadhealth" + "github.com/hashicorp/consul/internal/catalog/internal/mappers/selectiontracker" + "github.com/hashicorp/consul/internal/catalog/internal/types" + "github.com/hashicorp/consul/internal/controller" + rtest "github.com/hashicorp/consul/internal/resource/resourcetest" + pbcatalog "github.com/hashicorp/consul/proto-public/pbcatalog/v1alpha1" + "github.com/hashicorp/consul/proto-public/pbresource" + "github.com/hashicorp/consul/proto/private/prototest" + "github.com/hashicorp/consul/sdk/testutil" + "github.com/hashicorp/consul/sdk/testutil/retry" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +var ( + badId = rtest.Resource(&pbresource.Type{Group: "not", Kind: "found", GroupVersion: "vfake"}, "foo").ID() +) + +func TestWorkloadsToEndpoints(t *testing.T) { + // This test's purpose is to ensure that converting multiple workloads to endpoints + // happens as expected. It is not concerned with the data in each endpoint but rather + // the removal of unconvertable workloads (nil endpoints returned by workloadToEndpoint). + + // The workload to endpoint conversion only cares about the service ports + service := &pbcatalog.Service{ + Ports: []*pbcatalog.ServicePort{ + {TargetPort: "http2", Protocol: pbcatalog.Protocol_PROTOCOL_HTTP2}, + }, + } + + workloadAddresses := []*pbcatalog.WorkloadAddress{ + {Host: "127.0.0.1"}, + } + + // This workload is port-matched with the service and should show up as an + // endpoint in the final set. + workloadData1 := &pbcatalog.Workload{ + Addresses: workloadAddresses, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http2": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP2}, + }, + } + + // This workload is NOT port-matched with the service and should be omitted. + workloadData2 := &pbcatalog.Workload{ + Addresses: workloadAddresses, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + } + + // Build out the workloads. + workloads := []*workloadData{ + { + // this workload should result in an endpoints + resource: rtest.Resource(types.WorkloadType, "foo"). + WithData(t, workloadData1). + Build(), + workload: workloadData1, + }, + { + // this workload should be filtered out + resource: rtest.Resource(types.WorkloadType, "bar"). + WithData(t, workloadData2). + Build(), + workload: workloadData2, + }, + } + + endpoints := workloadsToEndpoints(service, workloads) + require.Len(t, endpoints.Endpoints, 1) + prototest.AssertDeepEqual(t, workloads[0].resource.Id, endpoints.Endpoints[0].TargetRef) +} + +func TestWorkloadToEndpoint(t *testing.T) { + // This test handles ensuring that the bulk of the functionality of + // the workloadToEndpoint function works correctly. + // + // * WorkloadPorts that are not selected by one service port are ignored + // and not present in the resulting Endpoint + // * WorkloadPorts that have a protocol mismatch with the service port + // are ignored and not present in the resulting Endpoint + // * WorkloadAddresses with 0 non-ignored ports are omitted from the + // resulting Endpoint. + // * Specifying no ports for a WorkloadAddress will use all the non-ignored + // ports. These are explicitly set but that is intended to be an + // implementation detail at this point. + + service := &pbcatalog.Service{ + Ports: []*pbcatalog.ServicePort{ + // the workload will not have this port so it should be ignored + {TargetPort: "not-found", Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + {TargetPort: "http", Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + // the workload will have a different protocol for this port and so it + // will be ignored. + {TargetPort: "grpc", Protocol: pbcatalog.Protocol_PROTOCOL_GRPC}, + }, + } + + workload := &pbcatalog.Workload{ + Addresses: []*pbcatalog.WorkloadAddress{ + // this address will be in the endpoint with all the ports that are + // not filtered out - so just http + {Host: "127.0.0.1"}, + // this address will be in the endpoint but with a filtered ports list + {Host: "198.18.1.1", Ports: []string{"http", "grpc"}}, + // this address should not show up in the endpoint because the port it + // uses is filtered out + {Host: "198.8.0.1", Ports: []string{"grpc"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + // the protocol is wrong here so it will not show up in the endpoints. + "grpc": {Port: 9090, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP2}, + }, + } + + data := &workloadData{ + resource: rtest.Resource(types.WorkloadType, "foo"). + WithData(t, workload). + Build(), + workload: workload, + } + + expected := &pbcatalog.Endpoint{ + TargetRef: data.resource.Id, + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "127.0.0.1", Ports: []string{"http"}}, + {Host: "198.18.1.1", Ports: []string{"http"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": workload.Ports["http"], + }, + // The health is critical because we are not setting the workload's + // health status. The tests for determineWorkloadHealth will ensure + // that we can properly determine the health status and the overall + // controller tests will prove that the integration works as expected. + HealthStatus: pbcatalog.Health_HEALTH_CRITICAL, + } + + prototest.AssertDeepEqual(t, expected, workloadToEndpoint(service, data)) +} + +func TestWorkloadToEndpoint_AllAddressesFiltered(t *testing.T) { + // This test checks the specific case where the workload has no + // address/port combinations that remain unfiltered. In this + // case we want to ensure nil is returned instead of an Endpoint + // with no addresses. + + service := &pbcatalog.Service{ + Ports: []*pbcatalog.ServicePort{ + {TargetPort: "not-found", Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + } + + workload := &pbcatalog.Workload{ + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "127.0.0.1"}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + } + + data := &workloadData{ + resource: rtest.Resource(types.WorkloadType, "foo"). + WithData(t, workload). + Build(), + workload: workload, + } + + require.Nil(t, workloadToEndpoint(service, data)) +} + +func TestServiceUnderManagement(t *testing.T) { + // This test ensures that we can properly detect when a service + // should have endpoints generated for it vs when those endpoints + // are not being automatically managed. + + type testCase struct { + svc *pbcatalog.Service + managed bool + } + + cases := map[string]testCase{ + "nil": { + svc: nil, + managed: false, + }, + "nil-selector": { + svc: &pbcatalog.Service{Workloads: nil}, + managed: false, + }, + "empty-selector": { + svc: &pbcatalog.Service{Workloads: &pbcatalog.WorkloadSelector{}}, + managed: false, + }, + "exact-match": { + svc: &pbcatalog.Service{Workloads: &pbcatalog.WorkloadSelector{ + Names: []string{"foo"}, + }}, + managed: true, + }, + "prefix-match": { + svc: &pbcatalog.Service{Workloads: &pbcatalog.WorkloadSelector{ + Prefixes: []string{"foo"}, + }}, + managed: true, + }, + "multiple": { + svc: &pbcatalog.Service{Workloads: &pbcatalog.WorkloadSelector{ + Names: []string{"foo"}, + Prefixes: []string{"api-"}, + }}, + managed: true, + }, + } + + for name, tcase := range cases { + t.Run(name, func(t *testing.T) { + require.Equal(t, tcase.managed, serviceUnderManagement(tcase.svc)) + }) + } +} + +func TestDetermineWorkloadHealth(t *testing.T) { + // This test ensures that parsing workload health out of the + // resource status works as expected. + + type testCase struct { + res *pbresource.Resource + expected pbcatalog.Health + } + + cases := map[string]testCase{ + "no-status": { + res: rtest.Resource(types.WorkloadType, "foo").Build(), + expected: pbcatalog.Health_HEALTH_CRITICAL, + }, + "condition-not-found": { + res: rtest.Resource(types.WorkloadType, "foo"). + WithStatus(workloadhealth.StatusKey, &pbresource.Status{ + Conditions: []*pbresource.Condition{ + { + Type: "other", + State: pbresource.Condition_STATE_TRUE, + Reason: "NOT_RELEVANT", + }, + }, + }). + Build(), + expected: pbcatalog.Health_HEALTH_CRITICAL, + }, + "invalid-reason": { + res: rtest.Resource(types.WorkloadType, "foo"). + WithStatus(workloadhealth.StatusKey, &pbresource.Status{ + Conditions: []*pbresource.Condition{ + { + Type: workloadhealth.StatusConditionHealthy, + State: pbresource.Condition_STATE_TRUE, + Reason: "INVALID_HEALTH_STATUS_REASON", + }, + }, + }). + Build(), + expected: pbcatalog.Health_HEALTH_CRITICAL, + }, + "passing": { + res: rtest.Resource(types.WorkloadType, "foo"). + WithStatus(workloadhealth.StatusKey, &pbresource.Status{ + Conditions: []*pbresource.Condition{ + { + Type: workloadhealth.StatusConditionHealthy, + State: pbresource.Condition_STATE_TRUE, + Reason: pbcatalog.Health_HEALTH_PASSING.String(), + }, + }, + }). + Build(), + expected: pbcatalog.Health_HEALTH_PASSING, + }, + "warning": { + res: rtest.Resource(types.WorkloadType, "foo"). + WithStatus(workloadhealth.StatusKey, &pbresource.Status{ + Conditions: []*pbresource.Condition{ + { + Type: workloadhealth.StatusConditionHealthy, + State: pbresource.Condition_STATE_TRUE, + Reason: pbcatalog.Health_HEALTH_WARNING.String(), + }, + }, + }). + Build(), + expected: pbcatalog.Health_HEALTH_WARNING, + }, + "critical": { + res: rtest.Resource(types.WorkloadType, "foo"). + WithStatus(workloadhealth.StatusKey, &pbresource.Status{ + Conditions: []*pbresource.Condition{ + { + Type: workloadhealth.StatusConditionHealthy, + State: pbresource.Condition_STATE_TRUE, + Reason: pbcatalog.Health_HEALTH_CRITICAL.String(), + }, + }, + }). + Build(), + expected: pbcatalog.Health_HEALTH_CRITICAL, + }, + "maintenance": { + res: rtest.Resource(types.WorkloadType, "foo"). + WithStatus(workloadhealth.StatusKey, &pbresource.Status{ + Conditions: []*pbresource.Condition{ + { + Type: workloadhealth.StatusConditionHealthy, + State: pbresource.Condition_STATE_TRUE, + Reason: pbcatalog.Health_HEALTH_MAINTENANCE.String(), + }, + }, + }). + Build(), + expected: pbcatalog.Health_HEALTH_MAINTENANCE, + }, + } + + for name, tcase := range cases { + t.Run(name, func(t *testing.T) { + require.Equal(t, tcase.expected, determineWorkloadHealth(tcase.res)) + }) + } +} + +type controllerSuite struct { + suite.Suite + + ctx context.Context + client *rtest.Client + rt controller.Runtime + + tracker *selectiontracker.WorkloadSelectionTracker + reconciler *serviceEndpointsReconciler +} + +func (suite *controllerSuite) SetupTest() { + suite.ctx = testutil.TestContext(suite.T()) + client := svctest.RunResourceService(suite.T(), types.Register) + suite.rt = controller.Runtime{ + Client: client, + Logger: testutil.Logger(suite.T()), + } + suite.client = rtest.NewClient(client) + suite.tracker = selectiontracker.New() + suite.reconciler = newServiceEndpointsReconciler(suite.tracker) +} + +func (suite *controllerSuite) requireTracking(workload *pbresource.Resource, ids ...*pbresource.ID) { + reqs, err := suite.tracker.MapWorkload(suite.ctx, suite.rt, workload) + require.NoError(suite.T(), err) + require.Len(suite.T(), reqs, len(ids)) + for _, id := range ids { + prototest.AssertContainsElement(suite.T(), reqs, controller.Request{ID: id}) + } +} + +func (suite *controllerSuite) requireEndpoints(resource *pbresource.Resource, expected ...*pbcatalog.Endpoint) { + var svcEndpoints pbcatalog.ServiceEndpoints + require.NoError(suite.T(), resource.Data.UnmarshalTo(&svcEndpoints)) + require.Len(suite.T(), svcEndpoints.Endpoints, len(expected)) + prototest.AssertElementsMatch(suite.T(), expected, svcEndpoints.Endpoints) +} + +func (suite *controllerSuite) TestReconcile_ServiceNotFound() { + // This test's purpose is to ensure that when we are reconciling + // endpoints for a service that no longer exists, we stop + // tracking the endpoints resource ID in the selection tracker. + + // generate a workload resource to use for checking if it maps + // to a service endpoints object + workload := rtest.Resource(types.WorkloadType, "foo").Build() + + // ensure that the tracker knows about the service prior to + // calling reconcile so that we can ensure it removes tracking + id := rtest.Resource(types.ServiceEndpointsType, "not-found").ID() + suite.tracker.TrackIDForSelector(id, &pbcatalog.WorkloadSelector{Prefixes: []string{""}}) + + // verify that mapping the workload to service endpoints returns a + // non-empty list prior to reconciliation which should remove the + // tracking. + suite.requireTracking(workload, id) + + // Because the endpoints don't exist, this reconcile call should + // cause tracking of the endpoints to be removed + err := suite.reconciler.Reconcile(suite.ctx, suite.rt, controller.Request{ID: id}) + require.NoError(suite.T(), err) + + // Now ensure that the tracking was removed + suite.requireTracking(workload) +} + +func (suite *controllerSuite) TestReconcile_NoSelector_NoEndpoints() { + // This test's purpose is to ensure that the service's status is + // updated to record that its endpoints are not being automatically + // managed. Additionally, with no endpoints pre-existing it will + // not attempt to delete them. + + service := rtest.Resource(types.ServiceType, "test"). + WithData(suite.T(), &pbcatalog.Service{ + Ports: []*pbcatalog.ServicePort{ + {TargetPort: "http", Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + }). + Write(suite.T(), suite.client) + + endpointsID := rtest.Resource(types.ServiceEndpointsType, "test").ID() + + err := suite.reconciler.Reconcile(suite.ctx, suite.rt, controller.Request{ID: endpointsID}) + require.NoError(suite.T(), err) + + suite.client.RequireStatusCondition(suite.T(), service.Id, StatusKey, ConditionUnmanaged) +} + +func (suite *controllerSuite) TestReconcile_NoSelector_ManagedEndpoints() { + // This test's purpose is to ensure that when moving from managed endpoints + // to unmanaged endpoints for a service, any already generated managed endpoints + // get deleted. + + service := rtest.Resource(types.ServiceType, "test"). + WithData(suite.T(), &pbcatalog.Service{ + Ports: []*pbcatalog.ServicePort{ + {TargetPort: "http", Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + }). + Write(suite.T(), suite.client) + + endpoints := rtest.Resource(types.ServiceEndpointsType, "test"). + WithData(suite.T(), &pbcatalog.ServiceEndpoints{}). + // this marks these endpoints as under management + WithMeta(endpointsMetaManagedBy, StatusKey). + Write(suite.T(), suite.client) + + err := suite.reconciler.Reconcile(suite.ctx, suite.rt, controller.Request{ID: endpoints.Id}) + require.NoError(suite.T(), err) + // the status should indicate the services endpoints are not being managed + suite.client.RequireStatusCondition(suite.T(), service.Id, StatusKey, ConditionUnmanaged) + // endpoints under management should be deleted + suite.client.RequireResourceNotFound(suite.T(), endpoints.Id) +} + +func (suite *controllerSuite) TestReconcile_NoSelector_UnmanagedEndpoints() { + // This test's purpose is to ensure that when re-reconciling a service that + // doesn't have its endpoints managed, that we do not delete any unmanaged + // ServiceEndpoints resource that the user would have manually written. + + service := rtest.Resource(types.ServiceType, "test"). + WithData(suite.T(), &pbcatalog.Service{ + Ports: []*pbcatalog.ServicePort{ + {TargetPort: "http", Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + }). + Write(suite.T(), suite.client) + + endpoints := rtest.Resource(types.ServiceEndpointsType, "test"). + WithData(suite.T(), &pbcatalog.ServiceEndpoints{}). + Write(suite.T(), suite.client) + + err := suite.reconciler.Reconcile(suite.ctx, suite.rt, controller.Request{ID: endpoints.Id}) + require.NoError(suite.T(), err) + // the status should indicate the services endpoints are not being managed + suite.client.RequireStatusCondition(suite.T(), service.Id, StatusKey, ConditionUnmanaged) + // unmanaged endpoints should not be deleted when the service is unmanaged + suite.client.RequireResourceExists(suite.T(), endpoints.Id) +} + +func (suite *controllerSuite) TestReconcile_Managed_NoPreviousEndpoints() { + // This test's purpose is to ensure the managed endpoint generation occurs + // as expected when there are no pre-existing endpoints. + + service := rtest.Resource(types.ServiceType, "test"). + WithData(suite.T(), &pbcatalog.Service{ + Workloads: &pbcatalog.WorkloadSelector{ + Prefixes: []string{""}, + }, + Ports: []*pbcatalog.ServicePort{ + {TargetPort: "http", Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + }). + Write(suite.T(), suite.client) + + endpointsID := rtest.Resource(types.ServiceEndpointsType, "test").ID() + + rtest.Resource(types.WorkloadType, "test-workload"). + WithData(suite.T(), &pbcatalog.Workload{ + Addresses: []*pbcatalog.WorkloadAddress{{Host: "127.0.0.1"}}, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + }). + Write(suite.T(), suite.client) + + err := suite.reconciler.Reconcile(suite.ctx, suite.rt, controller.Request{ID: endpointsID}) + require.NoError(suite.T(), err) + + // Verify that the services status has been set to indicate endpoints are automatically managed. + suite.client.RequireStatusCondition(suite.T(), service.Id, StatusKey, ConditionManaged) + + // The service endpoints metadata should include our tag to indcate it was generated by this controller + res := suite.client.RequireResourceMeta(suite.T(), endpointsID, endpointsMetaManagedBy, StatusKey) + + var endpoints pbcatalog.ServiceEndpoints + err = res.Data.UnmarshalTo(&endpoints) + require.NoError(suite.T(), err) + require.Len(suite.T(), endpoints.Endpoints, 1) + // We are not going to retest that the workloads to endpoints conversion process + // The length check should be sufficient to prove the endpoints are being + // converted. The unit tests for the workloadsToEndpoints functions prove that + // the process works correctly in all cases. +} + +func (suite *controllerSuite) TestReconcile_Managed_ExistingEndpoints() { + // This test's purpose is to ensure that when the current set of endpoints + // differs from any prior set of endpoints that the resource gets rewritten. + + service := rtest.Resource(types.ServiceType, "test"). + WithData(suite.T(), &pbcatalog.Service{ + Workloads: &pbcatalog.WorkloadSelector{ + Prefixes: []string{""}, + }, + Ports: []*pbcatalog.ServicePort{ + {TargetPort: "http", Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + }). + Write(suite.T(), suite.client) + + endpoints := rtest.Resource(types.ServiceEndpointsType, "test"). + WithData(suite.T(), &pbcatalog.ServiceEndpoints{}). + WithOwner(service.Id). + Write(suite.T(), suite.client) + + rtest.Resource(types.WorkloadType, "test-workload"). + WithData(suite.T(), &pbcatalog.Workload{ + Addresses: []*pbcatalog.WorkloadAddress{{Host: "127.0.0.1"}}, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + }). + Write(suite.T(), suite.client) + + err := suite.reconciler.Reconcile(suite.ctx, suite.rt, controller.Request{ID: endpoints.Id}) + require.NoError(suite.T(), err) + + suite.client.RequireStatusCondition(suite.T(), service.Id, StatusKey, ConditionManaged) + res := suite.client.RequireResourceMeta(suite.T(), endpoints.Id, endpointsMetaManagedBy, StatusKey) + + var newEndpoints pbcatalog.ServiceEndpoints + err = res.Data.UnmarshalTo(&newEndpoints) + require.NoError(suite.T(), err) + require.Len(suite.T(), newEndpoints.Endpoints, 1) +} + +func (suite *controllerSuite) TestController() { + // This test's purpose is to exercise the controller in a halfway realistic way. + // Generally we are trying to go through the whole lifecycle of creating services, + // adding workloads, modifying workload health and modifying the service selection + // criteria. This isn't a full integration test as that would require also + // executing the workload health controller. Instead workload health status is + // synthesized as necessary. + + // Run the controller manager + mgr := controller.NewManager(suite.client, suite.rt.Logger) + mgr.Register(ServiceEndpointsController(suite.tracker)) + mgr.SetRaftLeader(true) + go mgr.Run(suite.ctx) + + // Add a service - there are no workloads so an empty endpoints + // object should be created. + service := rtest.Resource(types.ServiceType, "api"). + WithData(suite.T(), &pbcatalog.Service{ + Workloads: &pbcatalog.WorkloadSelector{ + Prefixes: []string{"api-"}, + }, + Ports: []*pbcatalog.ServicePort{ + {TargetPort: "http", Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + }). + Write(suite.T(), suite.client) + + // Wait for the controller to record that the endpoints are being managed + res := suite.client.WaitForReconciliation(suite.T(), service.Id, StatusKey) + // Check that the services status was updated accordingly + rtest.RequireStatusCondition(suite.T(), res, StatusKey, ConditionManaged) + + // Check that the endpoints resource exists and contains 0 endpoints + endpointsID := rtest.Resource(types.ServiceEndpointsType, "api").ID() + endpoints := suite.client.RequireResourceExists(suite.T(), endpointsID) + suite.requireEndpoints(endpoints) + + // Now add a workload that would be selected by the service. Leave + // the workload in a state where its health has not been reconciled + workload := rtest.Resource(types.WorkloadType, "api-1"). + WithData(suite.T(), &pbcatalog.Workload{ + Addresses: []*pbcatalog.WorkloadAddress{{Host: "127.0.0.1"}}, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + Identity: "api", + }). + Write(suite.T(), suite.client) + + // Wait for the endpoints to be regenerated + endpoints = suite.client.WaitForNewVersion(suite.T(), endpointsID, endpoints.Version) + + // Verify that the generated endpoints now contain the workload + suite.requireEndpoints(endpoints, &pbcatalog.Endpoint{ + TargetRef: workload.Id, + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "127.0.0.1", Ports: []string{"http"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + HealthStatus: pbcatalog.Health_HEALTH_CRITICAL, + }) + + // Update the health status of the workload + suite.client.WriteStatus(suite.ctx, &pbresource.WriteStatusRequest{ + Id: workload.Id, + Key: workloadhealth.StatusKey, + Status: &pbresource.Status{ + ObservedGeneration: workload.Generation, + Conditions: []*pbresource.Condition{ + { + Type: workloadhealth.StatusConditionHealthy, + State: pbresource.Condition_STATE_TRUE, + Reason: "HEALTH_PASSING", + }, + }, + }, + }) + + // Wait for the endpoints to be regenerated + endpoints = suite.client.WaitForNewVersion(suite.T(), endpointsID, endpoints.Version) + + // ensure the endpoint was put into the passing state + suite.requireEndpoints(endpoints, &pbcatalog.Endpoint{ + TargetRef: workload.Id, + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "127.0.0.1", Ports: []string{"http"}}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + HealthStatus: pbcatalog.Health_HEALTH_PASSING, + }) + + // rewrite the service to add more selection criteria. This should trigger + // reconciliation but shouldn't result in updating the endpoints because + // the actual list of currently selected workloads has not changed + rtest.Resource(types.ServiceType, "api"). + WithData(suite.T(), &pbcatalog.Service{ + Workloads: &pbcatalog.WorkloadSelector{ + Prefixes: []string{"api-"}, + Names: []string{"doesnt-matter"}, + }, + Ports: []*pbcatalog.ServicePort{ + {TargetPort: "http", Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + }). + Write(suite.T(), suite.client) + + // Wait for the service status' observed generation to get bumped + service = suite.client.WaitForReconciliation(suite.T(), service.Id, StatusKey) + + // Verify that the endpoints were not regenerated + suite.client.RequireVersionUnchanged(suite.T(), endpointsID, endpoints.Version) + + // Delete the endpoints. The controller should bring these back momentarily + suite.client.Delete(suite.ctx, &pbresource.DeleteRequest{Id: endpointsID}) + + // Wait for controller to recreate the endpoints + retry.Run(suite.T(), func(r *retry.R) { + suite.client.RequireResourceExists(r, endpointsID) + }) + + // Move the service to having unmanaged endpoints + rtest.Resource(types.ServiceType, "api"). + WithData(suite.T(), &pbcatalog.Service{ + Ports: []*pbcatalog.ServicePort{ + {TargetPort: "http", Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + }). + Write(suite.T(), suite.client) + + res = suite.client.WaitForReconciliation(suite.T(), service.Id, StatusKey) + rtest.RequireStatusCondition(suite.T(), res, StatusKey, ConditionUnmanaged) + + // Verify that the endpoints were deleted + suite.client.RequireResourceNotFound(suite.T(), endpointsID) +} + +func TestController(t *testing.T) { + suite.Run(t, new(controllerSuite)) +} diff --git a/internal/catalog/internal/controllers/endpoints/reconciliation_data.go b/internal/catalog/internal/controllers/endpoints/reconciliation_data.go new file mode 100644 index 000000000000..f00ac1595a21 --- /dev/null +++ b/internal/catalog/internal/controllers/endpoints/reconciliation_data.go @@ -0,0 +1,177 @@ +package endpoints + +import ( + "context" + "sort" + + "github.com/hashicorp/consul/internal/catalog/internal/types" + "github.com/hashicorp/consul/internal/controller" + "github.com/hashicorp/consul/internal/resource" + pbcatalog "github.com/hashicorp/consul/proto-public/pbcatalog/v1alpha1" + "github.com/hashicorp/consul/proto-public/pbresource" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type serviceData struct { + resource *pbresource.Resource + service *pbcatalog.Service +} + +type endpointsData struct { + resource *pbresource.Resource + endpoints *pbcatalog.ServiceEndpoints +} + +type workloadData struct { + resource *pbresource.Resource + workload *pbcatalog.Workload +} + +// getServiceData will read the service with the given ID and unmarshal the +// Data field. The return value is a struct that contains the retrieved +// resource as well as the unmsashalled form. If the resource doesn't +// exist, nil will be returned. Any other error either with retrieving +// the resource or unmarshalling it will cause the error to be returned +// to the caller +func getServiceData(ctx context.Context, rt controller.Runtime, id *pbresource.ID) (*serviceData, error) { + rsp, err := rt.Client.Read(ctx, &pbresource.ReadRequest{Id: id}) + switch { + case status.Code(err) == codes.NotFound: + return nil, nil + case err != nil: + return nil, err + } + + var service pbcatalog.Service + err = rsp.Resource.Data.UnmarshalTo(&service) + if err != nil { + return nil, resource.NewErrDataParse(&service, err) + } + + return &serviceData{resource: rsp.Resource, service: &service}, nil +} + +// getEndpointsData will read the endpoints with the given ID and unmarshal the +// Data field. The return value is a struct that contains the retrieved +// resource as well as the unmsashalled form. If the resource doesn't +// exist, nil will be returned. Any other error either with retrieving +// the resource or unmarshalling it will cause the error to be returned +// to the caller +func getEndpointsData(ctx context.Context, rt controller.Runtime, id *pbresource.ID) (*endpointsData, error) { + rsp, err := rt.Client.Read(ctx, &pbresource.ReadRequest{Id: id}) + switch { + case status.Code(err) == codes.NotFound: + return nil, nil + case err != nil: + return nil, err + } + + var endpoints pbcatalog.ServiceEndpoints + err = rsp.Resource.Data.UnmarshalTo(&endpoints) + if err != nil { + return nil, resource.NewErrDataParse(&endpoints, err) + } + + return &endpointsData{resource: rsp.Resource, endpoints: &endpoints}, nil +} + +// getWorkloadData will retrieve all workloads for the given services selector +// and unmarhshal them, returning a slic of objects hold both the resource and +// unmarshaled forms. Unmarshalling errors, or other resource service errors +// will be returned to the caller. +func getWorkloadData(ctx context.Context, rt controller.Runtime, svc *serviceData) ([]*workloadData, error) { + workloadResources, err := gatherWorkloadsForService(ctx, rt, svc) + if err != nil { + return nil, err + } + + var results []*workloadData + for _, res := range workloadResources { + var workload pbcatalog.Workload + err = res.Data.UnmarshalTo(&workload) + if err != nil { + return nil, resource.NewErrDataParse(&workload, err) + } + + results = append(results, &workloadData{resource: res, workload: &workload}) + } + + return results, nil +} + +// gatherWorkloadsForService will retrieve all the unique workloads for a given selector. +// NotFound errors for workloads selected by Name will be ignored. Any other +// resource service errors will be returned to the caller. Prior to returning +// the slice of resources, they will be sorted by name. The consistent ordering +// will allow callers to diff two versions of the data to determine if anything +// has changed but it also will make testing a little easier. +func gatherWorkloadsForService(ctx context.Context, rt controller.Runtime, svc *serviceData) ([]*pbresource.Resource, error) { + var workloads []*pbresource.Resource + + sel := svc.service.GetWorkloads() + + // this map will track all the gathered workloads by name, this is mainly to deduplicate workloads if they + // are specified multiple times throughout the list of selection criteria + workloadNames := make(map[string]struct{}) + + // First gather all the prefix matched workloads. We could do this second but by doing + // it first its possible we can avoid some resource service calls to read individual + // workloads selected by name if they are also matched by a prefix. + for _, prefix := range sel.GetPrefixes() { + rsp, err := rt.Client.List(ctx, &pbresource.ListRequest{ + Type: types.WorkloadType, + Tenancy: svc.resource.Id.Tenancy, + NamePrefix: prefix, + }) + if err != nil { + return nil, err + } + + // append all workloads in the list response to our list of all selected workloads + for _, workload := range rsp.Resources { + // ignore duplicate workloads + if _, found := workloadNames[workload.Id.Name]; !found { + workloads = append(workloads, workload) + workloadNames[workload.Id.Name] = struct{}{} + } + } + } + + // Now gather the exact match selections + for _, name := range sel.GetNames() { + // ignore names we have already fetched + if _, found := workloadNames[name]; found { + continue + } + + workloadID := &pbresource.ID{ + Type: types.WorkloadType, + Tenancy: svc.resource.Id.Tenancy, + Name: name, + } + + rsp, err := rt.Client.Read(ctx, &pbresource.ReadRequest{Id: workloadID}) + switch { + case status.Code(err) == codes.NotFound: + // Ignore not found errors as services may select workloads that do not + // yet exist. This is not considered an error state or mis-configuration + // as the user could be getting ready to add the workloads. + continue + case err != nil: + return nil, err + } + + workloads = append(workloads, rsp.Resource) + workloadNames[rsp.Resource.Id.Name] = struct{}{} + } + + // Sorting ensures deterministic output. This will help for testing but + // the real reason to do this is so we will be able to diff the set of + // workloads endpoints to determine if we need to update them. + sort.Slice(workloads, func(i, j int) bool { + return workloads[i].Id.Name < workloads[j].Id.Name + }) + + return workloads, nil +} diff --git a/internal/catalog/internal/controllers/endpoints/reconciliation_data_test.go b/internal/catalog/internal/controllers/endpoints/reconciliation_data_test.go new file mode 100644 index 000000000000..c0c83deda98d --- /dev/null +++ b/internal/catalog/internal/controllers/endpoints/reconciliation_data_test.go @@ -0,0 +1,260 @@ +package endpoints + +import ( + "context" + "testing" + + svctest "github.com/hashicorp/consul/agent/grpc-external/services/resource/testing" + "github.com/hashicorp/consul/internal/catalog/internal/types" + "github.com/hashicorp/consul/internal/controller" + "github.com/hashicorp/consul/internal/resource" + rtest "github.com/hashicorp/consul/internal/resource/resourcetest" + pbcatalog "github.com/hashicorp/consul/proto-public/pbcatalog/v1alpha1" + "github.com/hashicorp/consul/proto-public/pbresource" + "github.com/hashicorp/consul/proto/private/prototest" + "github.com/hashicorp/consul/sdk/testutil" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type reconciliationDataSuite struct { + suite.Suite + + ctx context.Context + client pbresource.ResourceServiceClient + rt controller.Runtime + + apiServiceData *pbcatalog.Service + apiService *pbresource.Resource + apiEndpoints *pbresource.Resource + api1Workload *pbresource.Resource + api2Workload *pbresource.Resource + api123Workload *pbresource.Resource + web1Workload *pbresource.Resource + web2Workload *pbresource.Resource +} + +func (suite *reconciliationDataSuite) SetupTest() { + suite.ctx = testutil.TestContext(suite.T()) + suite.client = svctest.RunResourceService(suite.T(), types.Register) + suite.rt = controller.Runtime{ + Client: suite.client, + Logger: testutil.Logger(suite.T()), + } + + suite.apiServiceData = &pbcatalog.Service{ + Workloads: &pbcatalog.WorkloadSelector{ + // This services selectors are specially crafted to exercise both the + // dedeuplication and sorting behaviors of gatherWorkloadsForService + Prefixes: []string{"api-"}, + Names: []string{"api-1", "web-2", "web-1", "api-1", "not-found"}, + }, + Ports: []*pbcatalog.ServicePort{ + { + TargetPort: "http", + Protocol: pbcatalog.Protocol_PROTOCOL_HTTP, + }, + }, + } + + suite.apiService = rtest.Resource(types.ServiceType, "api"). + WithData(suite.T(), suite.apiServiceData). + Write(suite.T(), suite.client) + + suite.api1Workload = rtest.Resource(types.WorkloadType, "api-1"). + WithData(suite.T(), &pbcatalog.Workload{ + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "127.0.0.1"}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + Identity: "api", + }). + Write(suite.T(), suite.client) + + suite.api2Workload = rtest.Resource(types.WorkloadType, "api-2"). + WithData(suite.T(), &pbcatalog.Workload{ + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "127.0.0.1"}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + Identity: "api", + }). + Write(suite.T(), suite.client) + + suite.api123Workload = rtest.Resource(types.WorkloadType, "api-123"). + WithData(suite.T(), &pbcatalog.Workload{ + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "127.0.0.1"}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + Identity: "api", + }). + Write(suite.T(), suite.client) + + suite.web1Workload = rtest.Resource(types.WorkloadType, "web-1"). + WithData(suite.T(), &pbcatalog.Workload{ + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "127.0.0.1"}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + Identity: "web", + }). + Write(suite.T(), suite.client) + + suite.web2Workload = rtest.Resource(types.WorkloadType, "web-2"). + WithData(suite.T(), &pbcatalog.Workload{ + Addresses: []*pbcatalog.WorkloadAddress{ + {Host: "127.0.0.1"}, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + Identity: "web", + }). + Write(suite.T(), suite.client) + + suite.apiEndpoints = rtest.Resource(types.ServiceEndpointsType, "api"). + WithData(suite.T(), &pbcatalog.ServiceEndpoints{ + Endpoints: []*pbcatalog.Endpoint{ + { + TargetRef: rtest.Resource(types.WorkloadType, "api-1").ID(), + Addresses: []*pbcatalog.WorkloadAddress{ + { + Host: "127.0.0.1", + Ports: []string{"http"}, + }, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP}, + }, + HealthStatus: pbcatalog.Health_HEALTH_PASSING, + }, + }, + }). + Write(suite.T(), suite.client) +} + +func (suite *reconciliationDataSuite) TestGetServiceData_NotFound() { + // This test's purposes is to ensure that NotFound errors when retrieving + // the service data are ignored properly. + data, err := getServiceData(suite.ctx, suite.rt, rtest.Resource(types.ServiceType, "not-found").ID()) + require.NoError(suite.T(), err) + require.Nil(suite.T(), data) +} + +func (suite *reconciliationDataSuite) TestGetServiceData_ReadError() { + // This test's purpose is to ensure that Read errors other than NotFound + // are propagated back to the caller. Specifying a resource ID with an + // unregistered type is the easiest way to force a resource service error. + badType := &pbresource.Type{ + Group: "not", + Kind: "found", + GroupVersion: "vfake", + } + data, err := getServiceData(suite.ctx, suite.rt, rtest.Resource(badType, "foo").ID()) + require.Error(suite.T(), err) + require.Equal(suite.T(), codes.InvalidArgument, status.Code(err)) + require.Nil(suite.T(), data) +} + +func (suite *reconciliationDataSuite) TestGetServiceData_UnmarshalError() { + // This test's purpose is to ensure that unmarshlling errors are returned + // to the caller. We are using a resource id that points to an endpoints + // object instead of a service to ensure that the data will be unmarshallable. + data, err := getServiceData(suite.ctx, suite.rt, rtest.Resource(types.ServiceEndpointsType, "api").ID()) + require.Error(suite.T(), err) + var parseErr resource.ErrDataParse + require.ErrorAs(suite.T(), err, &parseErr) + require.Nil(suite.T(), data) +} + +func (suite *reconciliationDataSuite) TestGetServiceData_Ok() { + // This test's purpose is to ensure that the happy path for + // retrieving a service works as expected. + data, err := getServiceData(suite.ctx, suite.rt, suite.apiService.Id) + require.NoError(suite.T(), err) + require.NotNil(suite.T(), data) + require.NotNil(suite.T(), data.resource) + prototest.AssertDeepEqual(suite.T(), suite.apiService.Id, data.resource.Id) + require.Len(suite.T(), data.service.Ports, 1) +} + +func (suite *reconciliationDataSuite) TestGetEndpointsData_NotFound() { + // This test's purposes is to ensure that NotFound errors when retrieving + // the endpoint data are ignored properly. + data, err := getEndpointsData(suite.ctx, suite.rt, rtest.Resource(types.ServiceEndpointsType, "not-found").ID()) + require.NoError(suite.T(), err) + require.Nil(suite.T(), data) +} + +func (suite *reconciliationDataSuite) TestGetEndpointsData_ReadError() { + // This test's purpose is to ensure that Read errors other than NotFound + // are propagated back to the caller. Specifying a resource ID with an + // unregistered type is the easiest way to force a resource service error. + badType := &pbresource.Type{ + Group: "not", + Kind: "found", + GroupVersion: "vfake", + } + data, err := getEndpointsData(suite.ctx, suite.rt, rtest.Resource(badType, "foo").ID()) + require.Error(suite.T(), err) + require.Equal(suite.T(), codes.InvalidArgument, status.Code(err)) + require.Nil(suite.T(), data) +} + +func (suite *reconciliationDataSuite) TestGetEndpointsData_UnmarshalError() { + // This test's purpose is to ensure that unmarshlling errors are returned + // to the caller. We are using a resource id that points to a service object + // instead of an endpoints object to ensure that the data will be unmarshallable. + data, err := getEndpointsData(suite.ctx, suite.rt, rtest.Resource(types.ServiceType, "api").ID()) + require.Error(suite.T(), err) + var parseErr resource.ErrDataParse + require.ErrorAs(suite.T(), err, &parseErr) + require.Nil(suite.T(), data) +} + +func (suite *reconciliationDataSuite) TestGetEndpointsData_Ok() { + // This test's purpose is to ensure that the happy path for + // retrieving an endpoints object works as expected. + data, err := getEndpointsData(suite.ctx, suite.rt, suite.apiEndpoints.Id) + require.NoError(suite.T(), err) + require.NotNil(suite.T(), data) + require.NotNil(suite.T(), data.resource) + prototest.AssertDeepEqual(suite.T(), suite.apiEndpoints.Id, data.resource.Id) + require.Len(suite.T(), data.endpoints.Endpoints, 1) +} + +func (suite *reconciliationDataSuite) TestGetWorkloadData() { + // This test's purpose is to ensure that gather workloads for + // a service work as expected. The services selector was crafted + // to exercise the deduplication behavior as well as the sorting + // behavior. The assertions in this test will verify that only + // unique workloads are returned and that they are ordered. + + data, err := getWorkloadData(suite.ctx, suite.rt, &serviceData{ + resource: suite.apiService, + service: suite.apiServiceData, + }) + + require.NoError(suite.T(), err) + require.Len(suite.T(), data, 5) + prototest.AssertDeepEqual(suite.T(), suite.api1Workload, data[0].resource) + prototest.AssertDeepEqual(suite.T(), suite.api123Workload, data[1].resource) + prototest.AssertDeepEqual(suite.T(), suite.api2Workload, data[2].resource) + prototest.AssertDeepEqual(suite.T(), suite.web1Workload, data[3].resource) + prototest.AssertDeepEqual(suite.T(), suite.web2Workload, data[4].resource) +} + +func TestReconciliationData(t *testing.T) { + suite.Run(t, new(reconciliationDataSuite)) +} diff --git a/internal/catalog/internal/controllers/endpoints/status.go b/internal/catalog/internal/controllers/endpoints/status.go new file mode 100644 index 000000000000..5388a0fda912 --- /dev/null +++ b/internal/catalog/internal/controllers/endpoints/status.go @@ -0,0 +1,33 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package endpoints + +import "github.com/hashicorp/consul/proto-public/pbresource" + +const ( + StatusKey = "consul.io/endpoint-manager" + StatusConditionEndpointsManaged = "EndpointsManaged" + + StatusReasonSelectorNotFound = "SelectorNotFound" + StatusReasonSelectorFound = "SelectorFound" + + SelectorFoundMessage = "A valid workload selector is present within the service." + SelectorNotFoundMessage = "Either the workload selector was not present or contained no selection criteria." +) + +var ( + ConditionManaged = &pbresource.Condition{ + Type: StatusConditionEndpointsManaged, + State: pbresource.Condition_STATE_TRUE, + Reason: StatusReasonSelectorFound, + Message: SelectorFoundMessage, + } + + ConditionUnmanaged = &pbresource.Condition{ + Type: StatusConditionEndpointsManaged, + State: pbresource.Condition_STATE_FALSE, + Reason: StatusReasonSelectorNotFound, + Message: SelectorNotFoundMessage, + } +) diff --git a/internal/catalog/internal/controllers/nodehealth/controller.go b/internal/catalog/internal/controllers/nodehealth/controller.go index d7586f6121cb..4e3aff9993ba 100644 --- a/internal/catalog/internal/controllers/nodehealth/controller.go +++ b/internal/catalog/internal/controllers/nodehealth/controller.go @@ -50,22 +50,10 @@ func (r *nodeHealthReconciler) Reconcile(ctx context.Context, rt controller.Runt return err } - message := NodeHealthyMessage - statusState := pbresource.Condition_STATE_TRUE - if health != pbcatalog.Health_HEALTH_PASSING { - statusState = pbresource.Condition_STATE_FALSE - message = NodeUnhealthyMessage - } - newStatus := &pbresource.Status{ ObservedGeneration: res.Generation, Conditions: []*pbresource.Condition{ - { - Type: StatusConditionHealthy, - State: statusState, - Reason: health.String(), - Message: message, - }, + Conditions[health], }, } diff --git a/internal/catalog/internal/controllers/nodehealth/status.go b/internal/catalog/internal/controllers/nodehealth/status.go index 8cb989101dad..14a3151484b5 100644 --- a/internal/catalog/internal/controllers/nodehealth/status.go +++ b/internal/catalog/internal/controllers/nodehealth/status.go @@ -3,6 +3,11 @@ package nodehealth +import ( + pbcatalog "github.com/hashicorp/consul/proto-public/pbcatalog/v1alpha1" + "github.com/hashicorp/consul/proto-public/pbresource" +) + const ( StatusKey = "consul.io/node-health" StatusConditionHealthy = "healthy" @@ -10,3 +15,40 @@ const ( NodeHealthyMessage = "All node health checks are passing" NodeUnhealthyMessage = "One or more node health checks are not passing" ) + +var ( + ConditionPassing = &pbresource.Condition{ + Type: StatusConditionHealthy, + State: pbresource.Condition_STATE_TRUE, + Reason: pbcatalog.Health_HEALTH_PASSING.String(), + Message: NodeHealthyMessage, + } + + ConditionWarning = &pbresource.Condition{ + Type: StatusConditionHealthy, + State: pbresource.Condition_STATE_FALSE, + Reason: pbcatalog.Health_HEALTH_WARNING.String(), + Message: NodeUnhealthyMessage, + } + + ConditionCritical = &pbresource.Condition{ + Type: StatusConditionHealthy, + State: pbresource.Condition_STATE_FALSE, + Reason: pbcatalog.Health_HEALTH_CRITICAL.String(), + Message: NodeUnhealthyMessage, + } + + ConditionMaintenance = &pbresource.Condition{ + Type: StatusConditionHealthy, + State: pbresource.Condition_STATE_FALSE, + Reason: pbcatalog.Health_HEALTH_MAINTENANCE.String(), + Message: NodeUnhealthyMessage, + } + + Conditions = map[pbcatalog.Health]*pbresource.Condition{ + pbcatalog.Health_HEALTH_PASSING: ConditionPassing, + pbcatalog.Health_HEALTH_WARNING: ConditionWarning, + pbcatalog.Health_HEALTH_CRITICAL: ConditionCritical, + pbcatalog.Health_HEALTH_MAINTENANCE: ConditionMaintenance, + } +) diff --git a/internal/catalog/internal/controllers/register.go b/internal/catalog/internal/controllers/register.go index 1a7987d12d82..5f7fc631a543 100644 --- a/internal/catalog/internal/controllers/register.go +++ b/internal/catalog/internal/controllers/register.go @@ -4,6 +4,7 @@ package controllers import ( + "github.com/hashicorp/consul/internal/catalog/internal/controllers/endpoints" "github.com/hashicorp/consul/internal/catalog/internal/controllers/nodehealth" "github.com/hashicorp/consul/internal/catalog/internal/controllers/workloadhealth" "github.com/hashicorp/consul/internal/controller" @@ -11,9 +12,11 @@ import ( type Dependencies struct { WorkloadHealthNodeMapper workloadhealth.NodeMapper + EndpointsWorkloadMapper endpoints.WorkloadMapper } func Register(mgr *controller.Manager, deps Dependencies) { mgr.Register(nodehealth.NodeHealthController()) mgr.Register(workloadhealth.WorkloadHealthController(deps.WorkloadHealthNodeMapper)) + mgr.Register(endpoints.ServiceEndpointsController(deps.EndpointsWorkloadMapper)) } diff --git a/internal/catalog/internal/controllers/workloadhealth/controller.go b/internal/catalog/internal/controllers/workloadhealth/controller.go index b9c19b54148f..77009697bcd7 100644 --- a/internal/catalog/internal/controllers/workloadhealth/controller.go +++ b/internal/catalog/internal/controllers/workloadhealth/controller.go @@ -58,7 +58,7 @@ type workloadHealthReconciler struct { } func (r *workloadHealthReconciler) Reconcile(ctx context.Context, rt controller.Runtime, req controller.Request) error { - // The runtime is passed by value so replacing it here for the remaineder of this + // The runtime is passed by value so replacing it here for the remainder of this // reconciliation request processing will not affect future invocations. rt.Logger = rt.Logger.With("resource-id", req.ID, "controller", StatusKey) @@ -90,6 +90,12 @@ func (r *workloadHealthReconciler) Reconcile(ctx context.Context, rt controller. if workload.NodeName != "" { nodeID := r.nodeMap.NodeIDFromWorkload(res, &workload) r.nodeMap.TrackWorkload(res.Id, nodeID) + + // It is important that getting the nodes health happens after tracking the + // Workload with the node mapper. If the order were reversed we could + // potentially miss events for data that changes after we read the node but + // before we configured the node mapper to map subsequent events to this + // workload. nodeHealth, err = getNodeHealth(ctx, rt, nodeID) if err != nil { rt.Logger.Error("error looking up node health", "error", err, "node-id", nodeID) @@ -114,33 +120,15 @@ func (r *workloadHealthReconciler) Reconcile(ctx context.Context, rt controller. health = workloadHealth } - statusState := pbresource.Condition_STATE_TRUE - if health != pbcatalog.Health_HEALTH_PASSING { - statusState = pbresource.Condition_STATE_FALSE - } - - message := WorkloadHealthyMessage + condition := WorkloadConditions[workloadHealth] if workload.NodeName != "" { - message = NodeAndWorkloadHealthyMessage - } - switch { - case workloadHealth != pbcatalog.Health_HEALTH_PASSING && nodeHealth != pbcatalog.Health_HEALTH_PASSING: - message = NodeAndWorkloadUnhealthyMessage - case workloadHealth != pbcatalog.Health_HEALTH_PASSING: - message = WorkloadUnhealthyMessage - case nodeHealth != pbcatalog.Health_HEALTH_PASSING: - message = nodehealth.NodeUnhealthyMessage + condition = NodeAndWorkloadConditions[workloadHealth][nodeHealth] } newStatus := &pbresource.Status{ ObservedGeneration: res.Generation, Conditions: []*pbresource.Condition{ - { - Type: StatusConditionHealthy, - State: statusState, - Reason: health.String(), - Message: message, - }, + condition, }, } diff --git a/internal/catalog/internal/controllers/workloadhealth/status.go b/internal/catalog/internal/controllers/workloadhealth/status.go index a7da3fad6b3c..05cc989ddd93 100644 --- a/internal/catalog/internal/controllers/workloadhealth/status.go +++ b/internal/catalog/internal/controllers/workloadhealth/status.go @@ -1,5 +1,11 @@ package workloadhealth +import ( + "github.com/hashicorp/consul/internal/catalog/internal/controllers/nodehealth" + pbcatalog "github.com/hashicorp/consul/proto-public/pbcatalog/v1alpha1" + "github.com/hashicorp/consul/proto-public/pbresource" +) + const ( StatusKey = "consul.io/workload-health" StatusConditionHealthy = "healthy" @@ -9,3 +15,122 @@ const ( NodeAndWorkloadUnhealthyMessage = "One or more workload and node health checks are not passing" WorkloadUnhealthyMessage = "One or more workload health checks are not passing" ) + +var ( + ConditionWorkloadPassing = &pbresource.Condition{ + Type: StatusConditionHealthy, + State: pbresource.Condition_STATE_TRUE, + Reason: pbcatalog.Health_HEALTH_PASSING.String(), + Message: WorkloadHealthyMessage, + } + + ConditionWorkloadWarning = &pbresource.Condition{ + Type: StatusConditionHealthy, + State: pbresource.Condition_STATE_FALSE, + Reason: pbcatalog.Health_HEALTH_WARNING.String(), + Message: WorkloadUnhealthyMessage, + } + + ConditionWorkloadCritical = &pbresource.Condition{ + Type: StatusConditionHealthy, + State: pbresource.Condition_STATE_FALSE, + Reason: pbcatalog.Health_HEALTH_CRITICAL.String(), + Message: WorkloadUnhealthyMessage, + } + + ConditionWorkloadMaintenance = &pbresource.Condition{ + Type: StatusConditionHealthy, + State: pbresource.Condition_STATE_FALSE, + Reason: pbcatalog.Health_HEALTH_MAINTENANCE.String(), + Message: WorkloadUnhealthyMessage, + } + + ConditionNodeAndWorkloadPassing = &pbresource.Condition{ + Type: StatusConditionHealthy, + State: pbresource.Condition_STATE_TRUE, + Reason: pbcatalog.Health_HEALTH_PASSING.String(), + Message: NodeAndWorkloadHealthyMessage, + } + + ConditionNodeAndWorkloadWarning = &pbresource.Condition{ + Type: StatusConditionHealthy, + State: pbresource.Condition_STATE_FALSE, + Reason: pbcatalog.Health_HEALTH_WARNING.String(), + Message: NodeAndWorkloadUnhealthyMessage, + } + + ConditionNodeAndWorkloadCritical = &pbresource.Condition{ + Type: StatusConditionHealthy, + State: pbresource.Condition_STATE_FALSE, + Reason: pbcatalog.Health_HEALTH_CRITICAL.String(), + Message: NodeAndWorkloadUnhealthyMessage, + } + + ConditionNodeAndWorkloadMaintenance = &pbresource.Condition{ + Type: StatusConditionHealthy, + State: pbresource.Condition_STATE_FALSE, + Reason: pbcatalog.Health_HEALTH_MAINTENANCE.String(), + Message: NodeAndWorkloadUnhealthyMessage, + } + + ConditionNodeWarning = &pbresource.Condition{ + Type: StatusConditionHealthy, + State: pbresource.Condition_STATE_FALSE, + Reason: pbcatalog.Health_HEALTH_WARNING.String(), + Message: nodehealth.NodeUnhealthyMessage, + } + + ConditionNodeCritical = &pbresource.Condition{ + Type: StatusConditionHealthy, + State: pbresource.Condition_STATE_FALSE, + Reason: pbcatalog.Health_HEALTH_CRITICAL.String(), + Message: nodehealth.NodeUnhealthyMessage, + } + + ConditionNodeMaintenance = &pbresource.Condition{ + Type: StatusConditionHealthy, + State: pbresource.Condition_STATE_FALSE, + Reason: pbcatalog.Health_HEALTH_MAINTENANCE.String(), + Message: nodehealth.NodeUnhealthyMessage, + } + + // WorkloadConditions is a map of the workloadhealth to the status condition + // used to represent that health. + WorkloadConditions = map[pbcatalog.Health]*pbresource.Condition{ + pbcatalog.Health_HEALTH_PASSING: ConditionWorkloadPassing, + pbcatalog.Health_HEALTH_WARNING: ConditionWorkloadWarning, + pbcatalog.Health_HEALTH_CRITICAL: ConditionWorkloadCritical, + pbcatalog.Health_HEALTH_MAINTENANCE: ConditionWorkloadMaintenance, + } + + // NodeAndWorkloadConditions is a map whose ultimate values are the status conditions + // used to represent the combined health of a workload and its associated node. + // The outer map's keys are the workloads health and the inner maps keys are the nodes + // health + NodeAndWorkloadConditions = map[pbcatalog.Health]map[pbcatalog.Health]*pbresource.Condition{ + pbcatalog.Health_HEALTH_PASSING: { + pbcatalog.Health_HEALTH_PASSING: ConditionNodeAndWorkloadPassing, + pbcatalog.Health_HEALTH_WARNING: ConditionNodeWarning, + pbcatalog.Health_HEALTH_CRITICAL: ConditionNodeCritical, + pbcatalog.Health_HEALTH_MAINTENANCE: ConditionNodeMaintenance, + }, + pbcatalog.Health_HEALTH_WARNING: { + pbcatalog.Health_HEALTH_PASSING: ConditionWorkloadWarning, + pbcatalog.Health_HEALTH_WARNING: ConditionNodeAndWorkloadWarning, + pbcatalog.Health_HEALTH_CRITICAL: ConditionNodeAndWorkloadCritical, + pbcatalog.Health_HEALTH_MAINTENANCE: ConditionNodeAndWorkloadMaintenance, + }, + pbcatalog.Health_HEALTH_CRITICAL: { + pbcatalog.Health_HEALTH_PASSING: ConditionWorkloadCritical, + pbcatalog.Health_HEALTH_WARNING: ConditionNodeAndWorkloadCritical, + pbcatalog.Health_HEALTH_CRITICAL: ConditionNodeAndWorkloadCritical, + pbcatalog.Health_HEALTH_MAINTENANCE: ConditionNodeAndWorkloadMaintenance, + }, + pbcatalog.Health_HEALTH_MAINTENANCE: { + pbcatalog.Health_HEALTH_PASSING: ConditionWorkloadMaintenance, + pbcatalog.Health_HEALTH_WARNING: ConditionNodeAndWorkloadMaintenance, + pbcatalog.Health_HEALTH_CRITICAL: ConditionNodeAndWorkloadMaintenance, + pbcatalog.Health_HEALTH_MAINTENANCE: ConditionNodeAndWorkloadMaintenance, + }, + } +) diff --git a/internal/catalog/internal/mappers/selectiontracker/selection_tracker.go b/internal/catalog/internal/mappers/selectiontracker/selection_tracker.go new file mode 100644 index 000000000000..3bf229bff65e --- /dev/null +++ b/internal/catalog/internal/mappers/selectiontracker/selection_tracker.go @@ -0,0 +1,163 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package selectiontracker + +import ( + "context" + "sync" + + "github.com/hashicorp/consul/internal/controller" + "github.com/hashicorp/consul/internal/radix" + "github.com/hashicorp/consul/internal/resource" + "github.com/hashicorp/consul/lib/stringslice" + pbcatalog "github.com/hashicorp/consul/proto-public/pbcatalog/v1alpha1" + "github.com/hashicorp/consul/proto-public/pbresource" +) + +type WorkloadSelectionTracker struct { + lock sync.Mutex + prefixes *radix.Tree[[]controller.Request] + exact *radix.Tree[[]controller.Request] + + // workloadSelectors contains a map keyed on resource names with values + // being the selector that resource is currently associated with. This map + // is kept mainly to make tracking removal operations more efficient. + // Generally any operation that could take advantage of knowing where + // in the trees the resource id is referenced can use this to prevent + // needing to search the whole tree. + workloadSelectors map[string]*pbcatalog.WorkloadSelector +} + +func New() *WorkloadSelectionTracker { + return &WorkloadSelectionTracker{ + prefixes: radix.New[[]controller.Request](), + exact: radix.New[[]controller.Request](), + workloadSelectors: make(map[string]*pbcatalog.WorkloadSelector), + } +} + +// MapWorkload will return a slice of controller.Requests with 1 resource for +// each resource that selects the specified Workload resource. +func (t *WorkloadSelectionTracker) MapWorkload(_ context.Context, _ controller.Runtime, res *pbresource.Resource) ([]controller.Request, error) { + t.lock.Lock() + defer t.lock.Unlock() + + var reqs []controller.Request + + // gather the list of all resources that select the specified workload using a prefix match + t.prefixes.WalkPath(res.Id.Name, func(path string, requests []controller.Request) bool { + reqs = append(reqs, requests...) + return false + }) + + // gather the list of all resources that select the specified workload using an exact match + exactReqs, _ := t.exact.Get(res.Id.Name) + + // return the combined list of all resources that select the specified workload + return append(reqs, exactReqs...), nil +} + +// TrackIDForSelector will associate workloads matching the specified workload +// selector with the given resource id. +func (t *WorkloadSelectionTracker) TrackIDForSelector(id *pbresource.ID, selector *pbcatalog.WorkloadSelector) { + t.lock.Lock() + defer t.lock.Unlock() + + if previousSelector, found := t.workloadSelectors[id.Name]; found { + if stringslice.Equal(previousSelector.Names, selector.Names) && + stringslice.Equal(previousSelector.Prefixes, selector.Prefixes) { + // the selector is unchanged so do nothing + return + } + + // Potentially we could detect differences and do more minimal work. However + // users are not expected to alter workload selectors often and therefore + // not optimizing this further is probably fine. Therefore we are going + // to wipe all tracking of the id and reinsert things. + t.untrackID(id) + } + + // loop over all the exact matching rules and associate those workload names + // with the given resource id + for _, name := range selector.GetNames() { + // lookup any resource id associations for the given workload name + leaf, _ := t.exact.Get(name) + + // append the ID to the existing request list + t.exact.Insert(name, append(leaf, controller.Request{ID: id})) + } + + // loop over all the prefix matching rules and associate those prefixes + // with the given resource id. + for _, prefix := range selector.GetPrefixes() { + // lookup any resource id associations for the given workload name prefix + leaf, _ := t.prefixes.Get(prefix) + + // append the new resource ID to the existing request list + t.prefixes.Insert(prefix, append(leaf, controller.Request{ID: id})) + } + + t.workloadSelectors[id.Name] = selector +} + +// UntrackID causes the tracker to stop tracking the given resource ID +func (t *WorkloadSelectionTracker) UntrackID(id *pbresource.ID) { + t.lock.Lock() + defer t.lock.Unlock() + t.untrackID(id) +} + +// untrackID should be called to stop tracking a resource ID. +// This method assumes the lock is already held. Besides modifying +// the prefix & name trees to not reference this ID, it will also +// delete any corresponding entry within the workloadSelectors map +func (t *WorkloadSelectionTracker) untrackID(id *pbresource.ID) { + selector, found := t.workloadSelectors[id.Name] + if !found { + return + } + + removeIDFromTreeAtPaths(t.exact, id, selector.Names) + removeIDFromTreeAtPaths(t.prefixes, id, selector.Prefixes) + + // If we don't do this deletion then reinsertion of the id for + // tracking in the future could prevent selection criteria from + // being properly inserted into the radix trees. + delete(t.workloadSelectors, id.Name) +} + +// removeIDFromTree will remove the given resource ID from all leaf nodes in the radix tree. +func removeIDFromTreeAtPaths(t *radix.Tree[[]controller.Request], id *pbresource.ID, paths []string) { + for _, path := range paths { + requests, _ := t.Get(path) + + foundIdx := -1 + for idx, req := range requests { + if resource.EqualID(req.ID, id) { + foundIdx = idx + break + } + } + + if foundIdx != -1 { + l := len(requests) + + if l == 1 { + requests = nil + } else if foundIdx == l-1 { + requests = requests[:foundIdx] + } else if foundIdx == 0 { + requests = requests[1:] + } else { + requests = append(requests[:foundIdx], requests[foundIdx+1:]...) + } + + if len(requests) > 1 { + t.Insert(path, requests) + } else { + t.Delete(path) + } + } + } +} diff --git a/internal/catalog/internal/mappers/selectiontracker/selection_tracker_test.go b/internal/catalog/internal/mappers/selectiontracker/selection_tracker_test.go new file mode 100644 index 000000000000..75fa0967c94b --- /dev/null +++ b/internal/catalog/internal/mappers/selectiontracker/selection_tracker_test.go @@ -0,0 +1,275 @@ +package selectiontracker + +import ( + "context" + "testing" + + "github.com/hashicorp/consul/internal/catalog/internal/types" + "github.com/hashicorp/consul/internal/controller" + "github.com/hashicorp/consul/internal/radix" + rtest "github.com/hashicorp/consul/internal/resource/resourcetest" + pbcatalog "github.com/hashicorp/consul/proto-public/pbcatalog/v1alpha1" + "github.com/hashicorp/consul/proto-public/pbresource" + "github.com/hashicorp/consul/proto/private/prototest" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +var ( + workloadData = &pbcatalog.Workload{ + Addresses: []*pbcatalog.WorkloadAddress{ + { + Host: "198.18.0.1", + }, + }, + Ports: map[string]*pbcatalog.WorkloadPort{ + "http": { + Port: 8080, + Protocol: pbcatalog.Protocol_PROTOCOL_HTTP, + }, + }, + } +) + +func TestRemoveIDFromTreeAtPaths(t *testing.T) { + tree := radix.New[[]controller.Request]() + + toRemove := rtest.Resource(types.ServiceEndpointsType, "blah").ID() + other1 := rtest.Resource(types.ServiceEndpointsType, "other1").ID() + other2 := rtest.Resource(types.ServiceEndpointsType, "other1").ID() + + // we are trying to create a tree such that removal of the toRemove id causes a + // few things to happen. + // + // * All the slice modification conditions are executed + // - removal from beginning of the list + // - removal from the end of the list + // - removal of only element in the list + // - removal from middle of the list + // * Paths without matching ids are ignored + + notMatching := []controller.Request{ + {ID: other1}, + {ID: other2}, + } + + matchAtBeginning := []controller.Request{ + {ID: toRemove}, + {ID: other1}, + {ID: other2}, + } + + matchAtEnd := []controller.Request{ + {ID: other1}, + {ID: other2}, + {ID: toRemove}, + } + + matchInMiddle := []controller.Request{ + {ID: other1}, + {ID: toRemove}, + {ID: other2}, + } + + matchOnly := []controller.Request{ + {ID: toRemove}, + } + + tree.Insert("no-match", notMatching) + tree.Insert("match-beginning", matchAtBeginning) + tree.Insert("match-end", matchAtEnd) + tree.Insert("match-middle", matchInMiddle) + tree.Insert("match-only", matchOnly) + + removeIDFromTreeAtPaths(tree, toRemove, []string{ + "no-match", + "match-beginning", + "match-end", + "match-middle", + "match-only", + }) + + reqs, found := tree.Get("no-match") + require.True(t, found) + require.Equal(t, notMatching, reqs) + + reqs, found = tree.Get("match-beginning") + require.True(t, found) + require.Equal(t, notMatching, reqs) + + reqs, found = tree.Get("match-end") + require.True(t, found) + require.Equal(t, notMatching, reqs) + + reqs, found = tree.Get("match-middle") + require.True(t, found) + require.Equal(t, notMatching, reqs) + + // The last tracked request should cause removal from the tree + _, found = tree.Get("match-only") + require.False(t, found) +} + +type selectionTrackerSuite struct { + suite.Suite + + rt controller.Runtime + tracker *WorkloadSelectionTracker + + workloadAPI1 *pbresource.Resource + workloadWeb1 *pbresource.Resource + endpointsFoo *pbresource.ID + endpointsBar *pbresource.ID +} + +func (suite *selectionTrackerSuite) SetupTest() { + suite.tracker = New() + + suite.workloadAPI1 = rtest.Resource(types.WorkloadType, "api-1").WithData(suite.T(), workloadData).Build() + suite.workloadWeb1 = rtest.Resource(types.WorkloadType, "web-1").WithData(suite.T(), workloadData).Build() + suite.endpointsFoo = rtest.Resource(types.ServiceEndpointsType, "foo").ID() + suite.endpointsBar = rtest.Resource(types.ServiceEndpointsType, "bar").ID() +} + +func (suite *selectionTrackerSuite) requireMappedIDs(workload *pbresource.Resource, ids ...*pbresource.ID) { + suite.T().Helper() + + reqs, err := suite.tracker.MapWorkload(context.Background(), suite.rt, workload) + require.NoError(suite.T(), err) + require.Len(suite.T(), reqs, len(ids)) + for _, id := range ids { + prototest.AssertContainsElement(suite.T(), reqs, controller.Request{ID: id}) + } +} + +func (suite *selectionTrackerSuite) TestMapWorkload_Empty() { + // If we aren't tracking anything than the default mapping behavior + // should be to return an empty list of requests. + suite.requireMappedIDs(suite.workloadAPI1) +} + +func (suite *selectionTrackerSuite) TestUntrackID_Empty() { + // this test has no assertions but mainly is here to prove that things + // dont explode if this is attempted. + suite.tracker.UntrackID(suite.endpointsFoo) +} + +func (suite *selectionTrackerSuite) TestTrackAndMap_SingleResource_MultipleWorkloadMappings() { + // This test aims to prove that tracking a resources workload selector and + // then mapping a workload back to that resource works as expected when the + // result set is a single resource. This test will ensure that both prefix + // and exact match criteria are handle correctly and that one resource + // can be mapped from multiple distinct workloads. + + // associate the foo endpoints with some workloads + suite.tracker.TrackIDForSelector(suite.endpointsFoo, &pbcatalog.WorkloadSelector{ + Names: []string{"bar", "api", "web-1"}, + Prefixes: []string{"api-"}, + }) + + // Ensure that mappings tracked by prefix work. + suite.requireMappedIDs(suite.workloadAPI1, suite.endpointsFoo) + + // Ensure that mappings tracked by exact match work. + suite.requireMappedIDs(suite.workloadWeb1, suite.endpointsFoo) +} + +func (suite *selectionTrackerSuite) TestTrackAndMap_MultiResource_SingleWorkloadMapping() { + // This test aims to prove that multiple resources selecting of a workload + // will result in multiple requests when mapping that workload. + + // associate the foo endpoints with some workloads + suite.tracker.TrackIDForSelector(suite.endpointsFoo, &pbcatalog.WorkloadSelector{ + Prefixes: []string{"api-"}, + }) + + // associate the bar endpoints with some workloads + suite.tracker.TrackIDForSelector(suite.endpointsBar, &pbcatalog.WorkloadSelector{ + Names: []string{"api-1"}, + }) + + // now the mapping should return both endpoints resource ids + suite.requireMappedIDs(suite.workloadAPI1, suite.endpointsFoo, suite.endpointsBar) +} + +func (suite *selectionTrackerSuite) TestDuplicateTracking() { + // This test aims to prove that tracking some ID multiple times doesn't + // result in multiple requests for the same ID + + // associate the foo endpoints with some workloads 3 times without changing + // the selection criteria. The second two times should be no-ops + suite.tracker.TrackIDForSelector(suite.endpointsFoo, &pbcatalog.WorkloadSelector{ + Prefixes: []string{"api-"}, + }) + + suite.tracker.TrackIDForSelector(suite.endpointsFoo, &pbcatalog.WorkloadSelector{ + Prefixes: []string{"api-"}, + }) + + suite.tracker.TrackIDForSelector(suite.endpointsFoo, &pbcatalog.WorkloadSelector{ + Prefixes: []string{"api-"}, + }) + + // regardless of the number of times tracked we should only see a single request + suite.requireMappedIDs(suite.workloadAPI1, suite.endpointsFoo) +} + +func (suite *selectionTrackerSuite) TestModifyTracking() { + // This test aims to prove that modifying selection criteria for a resource + // works as expected. Adding new criteria results in all being tracked. + // Removal of some criteria does't result in removal of all etc. More or + // less we want to ensure that updating selection criteria leaves the + // tracker in a consistent/expected state. + + // track the web-1 workload + suite.tracker.TrackIDForSelector(suite.endpointsFoo, &pbcatalog.WorkloadSelector{ + Names: []string{"web-1"}, + }) + + // ensure that api-1 isn't mapped but web-1 is + suite.requireMappedIDs(suite.workloadAPI1) + suite.requireMappedIDs(suite.workloadWeb1, suite.endpointsFoo) + + // now also track the api- prefix + suite.tracker.TrackIDForSelector(suite.endpointsFoo, &pbcatalog.WorkloadSelector{ + Names: []string{"web-1"}, + Prefixes: []string{"api-"}, + }) + + // ensure that both workloads are mapped appropriately + suite.requireMappedIDs(suite.workloadAPI1, suite.endpointsFoo) + suite.requireMappedIDs(suite.workloadWeb1, suite.endpointsFoo) + + // now remove the web tracking + suite.tracker.TrackIDForSelector(suite.endpointsFoo, &pbcatalog.WorkloadSelector{ + Prefixes: []string{"api-"}, + }) + + // ensure that only api-1 is mapped + suite.requireMappedIDs(suite.workloadAPI1, suite.endpointsFoo) + suite.requireMappedIDs(suite.workloadWeb1) +} + +func (suite *selectionTrackerSuite) TestRemove() { + // This test aims to prove that removal of a resource from tracking + // actually prevents subsequent mapping calls from returning the + // workload. + + // track the web-1 workload + suite.tracker.TrackIDForSelector(suite.endpointsFoo, &pbcatalog.WorkloadSelector{ + Names: []string{"web-1"}, + }) + + // ensure that api-1 isn't mapped but web-1 is + suite.requireMappedIDs(suite.workloadWeb1, suite.endpointsFoo) + + // untrack the resource + suite.tracker.UntrackID(suite.endpointsFoo) + + // ensure that we no longer map the previous workload to the resource + suite.requireMappedIDs(suite.workloadWeb1) +} + +func TestWorkloadSelectionSuite(t *testing.T) { + suite.Run(t, new(selectionTrackerSuite)) +} diff --git a/internal/catalog/internal/types/errors.go b/internal/catalog/internal/types/errors.go index 39a7131ae0ba..6f0b44c1b378 100644 --- a/internal/catalog/internal/types/errors.go +++ b/internal/catalog/internal/types/errors.go @@ -60,3 +60,12 @@ type errTooMuchMesh struct { func (err errTooMuchMesh) Error() string { return fmt.Sprintf("protocol \"mesh\" was specified in more than 1 port: %+v", err.Ports) } + +type errInvalidEndpointsOwnerName struct { + Name string + OwnerName string +} + +func (err errInvalidEndpointsOwnerName) Error() string { + return fmt.Sprintf("invalid owner name %q. ServiceEndpoints objects must be owned by a Service with the same name: %q", err.OwnerName, err.Name) +} diff --git a/internal/catalog/internal/types/errors_test.go b/internal/catalog/internal/types/errors_test.go index d8249549d530..7a8157727350 100644 --- a/internal/catalog/internal/types/errors_test.go +++ b/internal/catalog/internal/types/errors_test.go @@ -52,6 +52,10 @@ func TestErrorStrings(t *testing.T) { "errTooMuchMesh": errTooMuchMesh{ Ports: []string{"http", "grpc"}, }, + "errInvalidEndpointsOwnerName": errInvalidEndpointsOwnerName{ + Name: "foo", + OwnerName: "bar", + }, "errNotDNSLabel": errNotDNSLabel, "errNotIPAddress": errNotIPAddress, "errUnixSocketMultiport": errUnixSocketMultiport, diff --git a/internal/catalog/internal/types/health_status.go b/internal/catalog/internal/types/health_status.go index 0dc73f632080..5e5a659475d8 100644 --- a/internal/catalog/internal/types/health_status.go +++ b/internal/catalog/internal/types/health_status.go @@ -73,7 +73,7 @@ func ValidateHealthStatus(res *pbresource.Resource) error { Wrapped: resource.ErrMissing, }) } else if !resource.EqualType(res.Owner.Type, WorkloadType) && !resource.EqualType(res.Owner.Type, NodeType) { - err = multierror.Append(err, resource.ErrOwnerInvalid{ResourceType: res.Id.Type, OwnerType: res.Owner.Type}) + err = multierror.Append(err, resource.ErrOwnerTypeInvalid{ResourceType: res.Id.Type, OwnerType: res.Owner.Type}) } return err diff --git a/internal/catalog/internal/types/health_status_test.go b/internal/catalog/internal/types/health_status_test.go index 5fa9eaa5af1e..be9d30d50115 100644 --- a/internal/catalog/internal/types/health_status_test.go +++ b/internal/catalog/internal/types/health_status_test.go @@ -203,11 +203,11 @@ func TestValidateHealthStatus_InvalidOwner(t *testing.T) { res := createHealthStatusResource(t, data, tcase.owner) err := ValidateHealthStatus(res) require.Error(t, err) - expected := resource.ErrOwnerInvalid{ + expected := resource.ErrOwnerTypeInvalid{ ResourceType: HealthStatusType, OwnerType: tcase.owner.Type, } - var actual resource.ErrOwnerInvalid + var actual resource.ErrOwnerTypeInvalid require.ErrorAs(t, err, &actual) require.Equal(t, expected, actual) }) diff --git a/internal/catalog/internal/types/service.go b/internal/catalog/internal/types/service.go index f4da3cd8e4b6..27e13530e5f3 100644 --- a/internal/catalog/internal/types/service.go +++ b/internal/catalog/internal/types/service.go @@ -71,7 +71,7 @@ func ValidateService(res *pbresource.Resource) error { }, }, }) - } else { + } else if port.VirtualPort != 0 { usedVirtualPorts[port.VirtualPort] = idx } diff --git a/internal/catalog/internal/types/service_endpoints.go b/internal/catalog/internal/types/service_endpoints.go index 9a2ce37230c1..8c3d1cf10956 100644 --- a/internal/catalog/internal/types/service_endpoints.go +++ b/internal/catalog/internal/types/service_endpoints.go @@ -30,10 +30,48 @@ func RegisterServiceEndpoints(r resource.Registry) { r.Register(resource.Registration{ Type: ServiceEndpointsV1Alpha1Type, Proto: &pbcatalog.ServiceEndpoints{}, - Validate: nil, + Validate: ValidateServiceEndpoints, + Mutate: MutateServiceEndpoints, }) } +func MutateServiceEndpoints(res *pbresource.Resource) error { + if res.Owner == nil { + res.Owner = &pbresource.ID{ + Type: ServiceV1Alpha1Type, + Tenancy: res.Id.Tenancy, + Name: res.Id.Name, + } + } + + var err error + if !resource.EqualType(res.Owner.Type, ServiceV1Alpha1Type) { + err = multierror.Append(err, resource.ErrOwnerTypeInvalid{ + ResourceType: ServiceEndpointsV1Alpha1Type, + OwnerType: res.Owner.Type, + }) + } + + if !resource.EqualTenancy(res.Owner.Tenancy, res.Id.Tenancy) { + err = multierror.Append(err, resource.ErrOwnerTenantInvalid{ + ResourceTenancy: res.Id.Tenancy, + OwnerTenancy: res.Owner.Tenancy, + }) + } + + if res.Owner.Name != res.Id.Name { + err = multierror.Append(err, resource.ErrInvalidField{ + Name: "name", + Wrapped: errInvalidEndpointsOwnerName{ + Name: res.Id.Name, + OwnerName: res.Owner.Name, + }, + }) + } + + return err +} + func ValidateServiceEndpoints(res *pbresource.Resource) error { var svcEndpoints pbcatalog.ServiceEndpoints diff --git a/internal/catalog/internal/types/service_test.go b/internal/catalog/internal/types/service_test.go index 241a49939dee..1a0a035d9161 100644 --- a/internal/catalog/internal/types/service_test.go +++ b/internal/catalog/internal/types/service_test.go @@ -45,6 +45,18 @@ func TestValidateService_Ok(t *testing.T) { VirtualPort: 42, Protocol: pbcatalog.Protocol_PROTOCOL_HTTP, }, + { + TargetPort: "other", + // leaving VirtualPort unset to verify that seeing + // a zero virtual port multiple times is fine. + Protocol: pbcatalog.Protocol_PROTOCOL_HTTP2, + }, + { + TargetPort: "other2", + // leaving VirtualPort unset to verify that seeing + // a zero virtual port multiple times is fine. + Protocol: pbcatalog.Protocol_PROTOCOL_GRPC, + }, }, VirtualIps: []string{"198.18.0.1"}, } diff --git a/internal/catalog/internal/types/testdata/errInvalidEndpointsOwnerName.golden b/internal/catalog/internal/types/testdata/errInvalidEndpointsOwnerName.golden new file mode 100644 index 000000000000..38fc896f57e3 --- /dev/null +++ b/internal/catalog/internal/types/testdata/errInvalidEndpointsOwnerName.golden @@ -0,0 +1 @@ +invalid owner name "bar". ServiceEndpoints objects must be owned by a Service with the same name: "foo" \ No newline at end of file diff --git a/internal/controller/api.go b/internal/controller/api.go index c2cc0925da1b..8f5d873368b5 100644 --- a/internal/controller/api.go +++ b/internal/controller/api.go @@ -152,46 +152,6 @@ type Reconciler interface { Reconcile(ctx context.Context, rt Runtime, req Request) error } -// DependencyMapper is called when a dependency watched via WithWatch is changed -// to determine which of the controller's managed resources need to be reconciled. -type DependencyMapper func( - ctx context.Context, - rt Runtime, - res *pbresource.Resource, -) ([]Request, error) - -// MapOwner implements a DependencyMapper that returns the updated resource's owner. -func MapOwner(_ context.Context, _ Runtime, res *pbresource.Resource) ([]Request, error) { - var reqs []Request - if res.Owner != nil { - reqs = append(reqs, Request{ID: res.Owner}) - } - return reqs, nil -} - -func MapOwnerFiltered(filter *pbresource.Type) DependencyMapper { - return func(_ context.Context, _ Runtime, res *pbresource.Resource) ([]Request, error) { - if res.Owner == nil { - return nil, nil - } - - ownerType := res.Owner.GetType() - if ownerType.Group != filter.Group { - return nil, nil - } - - if ownerType.GroupVersion != filter.GroupVersion { - return nil, nil - } - - if ownerType.Kind != filter.Kind { - return nil, nil - } - - return []Request{{ID: res.Owner}}, nil - } -} - // Placement determines where and how many replicas of the controller will run. type Placement int diff --git a/internal/controller/api_test.go b/internal/controller/api_test.go index 51bb62843f7d..2006664b20fb 100644 --- a/internal/controller/api_test.go +++ b/internal/controller/api_test.go @@ -9,7 +9,6 @@ import ( "testing" "time" - "github.com/google/go-cmp/cmp/cmpopts" "github.com/stretchr/testify/require" svctest "github.com/hashicorp/consul/agent/grpc-external/services/resource/testing" @@ -267,76 +266,3 @@ func testContext(t *testing.T) context.Context { return ctx } - -func resourceID(group string, version string, kind string, name string) *pbresource.ID { - return &pbresource.ID{ - Type: &pbresource.Type{ - Group: group, - GroupVersion: version, - Kind: kind, - }, - Tenancy: &pbresource.Tenancy{ - Partition: "default", - Namespace: "default", - PeerName: "local", - }, - Name: name, - } -} - -func TestMapOwnerFiltered(t *testing.T) { - mapper := controller.MapOwnerFiltered(&pbresource.Type{ - Group: "foo", - GroupVersion: "v1", - Kind: "bar", - }) - - type testCase struct { - owner *pbresource.ID - matches bool - } - - cases := map[string]testCase{ - "nil-owner": { - owner: nil, - matches: false, - }, - "group-mismatch": { - owner: resourceID("other", "v1", "bar", "irrelevant"), - matches: false, - }, - "group-version-mismatch": { - owner: resourceID("foo", "v2", "bar", "irrelevant"), - matches: false, - }, - "kind-mismatch": { - owner: resourceID("foo", "v1", "baz", "irrelevant"), - matches: false, - }, - "match": { - owner: resourceID("foo", "v1", "bar", "irrelevant"), - matches: true, - }, - } - - for name, tcase := range cases { - t.Run(name, func(t *testing.T) { - // the runtime is not used by the mapper so its fine to pass an empty struct - req, err := mapper(context.Background(), controller.Runtime{}, &pbresource.Resource{ - Id: resourceID("foo", "v1", "other", "x"), - Owner: tcase.owner, - }) - - // The mapper has no error paths at present - require.NoError(t, err) - - if tcase.matches { - require.NotNil(t, req) - require.Len(t, req, 1) - prototest.AssertDeepEqual(t, req[0].ID, tcase.owner, cmpopts.EquateEmpty()) - } else { - require.Nil(t, req) - } - }) - } -} diff --git a/internal/controller/dependency_mappers.go b/internal/controller/dependency_mappers.go new file mode 100644 index 000000000000..c054c4369e94 --- /dev/null +++ b/internal/controller/dependency_mappers.go @@ -0,0 +1,58 @@ +package controller + +import ( + "context" + + "github.com/hashicorp/consul/internal/resource" + "github.com/hashicorp/consul/proto-public/pbresource" +) + +// DependencyMapper is called when a dependency watched via WithWatch is changed +// to determine which of the controller's managed resources need to be reconciled. +type DependencyMapper func( + ctx context.Context, + rt Runtime, + res *pbresource.Resource, +) ([]Request, error) + +// MapOwner implements a DependencyMapper that returns the updated resource's owner. +func MapOwner(_ context.Context, _ Runtime, res *pbresource.Resource) ([]Request, error) { + var reqs []Request + if res.Owner != nil { + reqs = append(reqs, Request{ID: res.Owner}) + } + return reqs, nil +} + +// MapOwnerFiltered creates a DependencyMapper that returns owner IDs as Requests +// if the type of the owner ID matches the given filter type. +func MapOwnerFiltered(filter *pbresource.Type) DependencyMapper { + return func(_ context.Context, _ Runtime, res *pbresource.Resource) ([]Request, error) { + if res.Owner == nil { + return nil, nil + } + + if !resource.EqualType(res.Owner.GetType(), filter) { + return nil, nil + } + + return []Request{{ID: res.Owner}}, nil + } +} + +// ReplaceType creates a DependencyMapper that returns request IDs with the same +// name and tenancy as the original resource but with the type replaced with +// the type specified as this functions parameter. +func ReplaceType(desiredType *pbresource.Type) DependencyMapper { + return func(_ context.Context, _ Runtime, res *pbresource.Resource) ([]Request, error) { + return []Request{ + { + ID: &pbresource.ID{ + Type: desiredType, + Tenancy: res.Id.Tenancy, + Name: res.Id.Name, + }, + }, + }, nil + } +} diff --git a/internal/controller/dependency_mappers_test.go b/internal/controller/dependency_mappers_test.go new file mode 100644 index 000000000000..b5300bfb9ac9 --- /dev/null +++ b/internal/controller/dependency_mappers_test.go @@ -0,0 +1,137 @@ +package controller + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/consul/proto-public/pbresource" + "github.com/hashicorp/consul/proto/private/prototest" + "github.com/stretchr/testify/require" +) + +func resourceID(group string, version string, kind string, name string) *pbresource.ID { + return &pbresource.ID{ + Type: &pbresource.Type{ + Group: group, + GroupVersion: version, + Kind: kind, + }, + Tenancy: &pbresource.Tenancy{ + Partition: "default", + Namespace: "default", + PeerName: "local", + }, + Name: name, + } +} + +func TestMapOwner(t *testing.T) { + owner := resourceID("foo", "v99", "bar", "object") + + res := &pbresource.Resource{ + Id: resourceID("something", "v1", "else", "x"), + Owner: owner, + } + + reqs, err := MapOwner(context.Background(), Runtime{}, res) + require.NoError(t, err) + require.Len(t, reqs, 1) + prototest.AssertDeepEqual(t, owner, reqs[0].ID) +} + +func TestMapOwnerFiltered(t *testing.T) { + mapper := MapOwnerFiltered(&pbresource.Type{ + Group: "foo", + GroupVersion: "v1", + Kind: "bar", + }) + + type testCase struct { + owner *pbresource.ID + matches bool + } + + cases := map[string]testCase{ + "nil-owner": { + owner: nil, + matches: false, + }, + "group-mismatch": { + owner: resourceID("other", "v1", "bar", "irrelevant"), + matches: false, + }, + "group-version-mismatch": { + owner: resourceID("foo", "v2", "bar", "irrelevant"), + matches: false, + }, + "kind-mismatch": { + owner: resourceID("foo", "v1", "baz", "irrelevant"), + matches: false, + }, + "match": { + owner: resourceID("foo", "v1", "bar", "irrelevant"), + matches: true, + }, + } + + for name, tcase := range cases { + t.Run(name, func(t *testing.T) { + // the runtime is not used by the mapper so its fine to pass an empty struct + req, err := mapper(context.Background(), Runtime{}, &pbresource.Resource{ + Id: resourceID("foo", "v1", "other", "x"), + Owner: tcase.owner, + }) + + // The mapper has no error paths at present + require.NoError(t, err) + + if tcase.matches { + require.NotNil(t, req) + require.Len(t, req, 1) + prototest.AssertDeepEqual(t, req[0].ID, tcase.owner, cmpopts.EquateEmpty()) + } else { + require.Nil(t, req) + } + }) + } +} + +func TestReplaceType(t *testing.T) { + rtype := &pbresource.Type{ + Group: "foo", + GroupVersion: "v1", + Kind: "bar", + } + + tenant := &pbresource.Tenancy{ + Partition: "not", + Namespace: "using", + PeerName: "the-defaults", + } + + in := &pbresource.Resource{ + Id: &pbresource.ID{ + Type: &pbresource.Type{ + Group: "other", + GroupVersion: "v2", + Kind: "baz", + }, + Tenancy: tenant, + Name: "arr-matey", + }, + } + + mapper := ReplaceType(rtype) + + reqs, err := mapper(nil, Runtime{}, in) + require.NoError(t, err) + require.Len(t, reqs, 1) + + expected := &pbresource.ID{ + Type: rtype, + Tenancy: tenant, + Name: "arr-matey", + } + prototest.AssertDeepEqual(t, expected, reqs[0].ID) +} diff --git a/internal/radix/doc.go b/internal/radix/doc.go new file mode 100644 index 000000000000..47e8961d0d0a --- /dev/null +++ b/internal/radix/doc.go @@ -0,0 +1,4 @@ +// This packages contents were originally copied from github.com/armon/go-radix. +// After the intial copy all the data structures were made to use Go 1.18 generics +// instead of relying on the use of interface{} or the any type. +package radix diff --git a/internal/radix/radix.go b/internal/radix/radix.go new file mode 100644 index 000000000000..0f1d8d772ac7 --- /dev/null +++ b/internal/radix/radix.go @@ -0,0 +1,568 @@ +package radix + +import ( + "sort" + "strings" +) + +// WalkFn is used when walking the tree. Takes a +// key and value, returning if iteration should +// be terminated. +type WalkFn[T any] func(s string, v T) bool + +// leafNode is used to represent a value +type leafNode[T any] struct { + key string + val T +} + +// edge is used to represent an edge node +type edge[T any] struct { + label byte + node *node[T] +} + +type node[T any] struct { + // leaf is used to store possible leaf + leaf *leafNode[T] + + // prefix is the common prefix we ignore + prefix string + + // Edges should be stored in-order for iteration. + // We avoid a fully materialized slice to save memory, + // since in most cases we expect to be sparse + edges edges[T] +} + +func (n *node[T]) isLeaf() bool { + return n.leaf != nil +} + +func (n *node[T]) addEdge(e edge[T]) { + num := len(n.edges) + idx := sort.Search(num, func(i int) bool { + return n.edges[i].label >= e.label + }) + + n.edges = append(n.edges, edge[T]{}) + copy(n.edges[idx+1:], n.edges[idx:]) + n.edges[idx] = e +} + +func (n *node[T]) updateEdge(label byte, node *node[T]) { + num := len(n.edges) + idx := sort.Search(num, func(i int) bool { + return n.edges[i].label >= label + }) + if idx < num && n.edges[idx].label == label { + n.edges[idx].node = node + return + } + panic("replacing missing edge") +} + +func (n *node[T]) getEdge(label byte) *node[T] { + num := len(n.edges) + idx := sort.Search(num, func(i int) bool { + return n.edges[i].label >= label + }) + if idx < num && n.edges[idx].label == label { + return n.edges[idx].node + } + return nil +} + +func (n *node[T]) delEdge(label byte) { + num := len(n.edges) + idx := sort.Search(num, func(i int) bool { + return n.edges[i].label >= label + }) + if idx < num && n.edges[idx].label == label { + copy(n.edges[idx:], n.edges[idx+1:]) + n.edges[len(n.edges)-1] = edge[T]{} + n.edges = n.edges[:len(n.edges)-1] + } +} + +type edges[T any] []edge[T] + +func (e edges[T]) Len() int { + return len(e) +} + +func (e edges[T]) Less(i, j int) bool { + return e[i].label < e[j].label +} + +func (e edges[T]) Swap(i, j int) { + e[i], e[j] = e[j], e[i] +} + +func (e edges[T]) Sort() { + sort.Sort(e) +} + +// Tree implements a radix tree. This can be treated as a +// Dictionary abstract data type. The main advantage over +// a standard hash map is prefix-based lookups and +// ordered iteration, +type Tree[T any] struct { + root *node[T] + size int +} + +// New returns an empty Tree +func New[T any]() *Tree[T] { + return NewFromMap[T](nil) +} + +// NewFromMap returns a new tree containing the keys +// from an existing map +func NewFromMap[T any](m map[string]T) *Tree[T] { + t := &Tree[T]{root: &node[T]{}} + for k, v := range m { + t.Insert(k, v) + } + return t +} + +// Len is used to return the number of elements in the tree +func (t *Tree[T]) Len() int { + return t.size +} + +// longestPrefix finds the length of the shared prefix +// of two strings +func longestPrefix(k1, k2 string) int { + max := len(k1) + if l := len(k2); l < max { + max = l + } + var i int + for i = 0; i < max; i++ { + if k1[i] != k2[i] { + break + } + } + return i +} + +// Insert is used to add a newentry or update +// an existing entry. Returns true if an existing record is updated. +func (t *Tree[T]) Insert(s string, v T) (T, bool) { + var zeroVal T + var parent *node[T] + n := t.root + search := s + for { + // Handle key exhaution + if len(search) == 0 { + if n.isLeaf() { + old := n.leaf.val + n.leaf.val = v + return old, true + } + + n.leaf = &leafNode[T]{ + key: s, + val: v, + } + t.size++ + return zeroVal, false + } + + // Look for the edge + parent = n + n = n.getEdge(search[0]) + + // No edge, create one + if n == nil { + e := edge[T]{ + label: search[0], + node: &node[T]{ + leaf: &leafNode[T]{ + key: s, + val: v, + }, + prefix: search, + }, + } + parent.addEdge(e) + t.size++ + return zeroVal, false + } + + // Determine longest prefix of the search key on match + commonPrefix := longestPrefix(search, n.prefix) + if commonPrefix == len(n.prefix) { + search = search[commonPrefix:] + continue + } + + // Split the node + t.size++ + child := &node[T]{ + prefix: search[:commonPrefix], + } + parent.updateEdge(search[0], child) + + // Restore the existing node + child.addEdge(edge[T]{ + label: n.prefix[commonPrefix], + node: n, + }) + n.prefix = n.prefix[commonPrefix:] + + // Create a new leaf node + leaf := &leafNode[T]{ + key: s, + val: v, + } + + // If the new key is a subset, add to this node + search = search[commonPrefix:] + if len(search) == 0 { + child.leaf = leaf + return zeroVal, false + } + + // Create a new edge for the node + child.addEdge(edge[T]{ + label: search[0], + node: &node[T]{ + leaf: leaf, + prefix: search, + }, + }) + return zeroVal, false + } +} + +// Delete is used to delete a key, returning the previous +// value and if it was deleted +func (t *Tree[T]) Delete(s string) (T, bool) { + var zeroVal T + + var parent *node[T] + var label byte + n := t.root + search := s + for { + // Check for key exhaution + if len(search) == 0 { + if !n.isLeaf() { + break + } + goto DELETE + } + + // Look for an edge + parent = n + label = search[0] + n = n.getEdge(label) + if n == nil { + break + } + + // Consume the search prefix + if strings.HasPrefix(search, n.prefix) { + search = search[len(n.prefix):] + } else { + break + } + } + return zeroVal, false + +DELETE: + // Delete the leaf + leaf := n.leaf + n.leaf = nil + t.size-- + + // Check if we should delete this node from the parent + if parent != nil && len(n.edges) == 0 { + parent.delEdge(label) + } + + // Check if we should merge this node + if n != t.root && len(n.edges) == 1 { + n.mergeChild() + } + + // Check if we should merge the parent's other child + if parent != nil && parent != t.root && len(parent.edges) == 1 && !parent.isLeaf() { + parent.mergeChild() + } + + return leaf.val, true +} + +// DeletePrefix is used to delete the subtree under a prefix +// Returns how many nodes were deleted +// Use this to delete large subtrees efficiently +func (t *Tree[T]) DeletePrefix(s string) int { + return t.deletePrefix(nil, t.root, s) +} + +// delete does a recursive deletion +func (t *Tree[T]) deletePrefix(parent, n *node[T], prefix string) int { + // Check for key exhaustion + if len(prefix) == 0 { + // Remove the leaf node + subTreeSize := 0 + //recursively walk from all edges of the node to be deleted + recursiveWalk(n, func(s string, v T) bool { + subTreeSize++ + return false + }) + if n.isLeaf() { + n.leaf = nil + } + n.edges = nil // deletes the entire subtree + + // Check if we should merge the parent's other child + if parent != nil && parent != t.root && len(parent.edges) == 1 && !parent.isLeaf() { + parent.mergeChild() + } + t.size -= subTreeSize + return subTreeSize + } + + // Look for an edge + label := prefix[0] + child := n.getEdge(label) + if child == nil || (!strings.HasPrefix(child.prefix, prefix) && !strings.HasPrefix(prefix, child.prefix)) { + return 0 + } + + // Consume the search prefix + if len(child.prefix) > len(prefix) { + prefix = prefix[len(prefix):] + } else { + prefix = prefix[len(child.prefix):] + } + return t.deletePrefix(n, child, prefix) +} + +func (n *node[T]) mergeChild() { + e := n.edges[0] + child := e.node + n.prefix = n.prefix + child.prefix + n.leaf = child.leaf + n.edges = child.edges +} + +// Get is used to lookup a specific key, returning +// the value and if it was found +func (t *Tree[T]) Get(s string) (T, bool) { + var zeroVal T + n := t.root + search := s + for { + // Check for key exhaution + if len(search) == 0 { + if n.isLeaf() { + return n.leaf.val, true + } + break + } + + // Look for an edge + n = n.getEdge(search[0]) + if n == nil { + break + } + + // Consume the search prefix + if strings.HasPrefix(search, n.prefix) { + search = search[len(n.prefix):] + } else { + break + } + } + return zeroVal, false +} + +// LongestPrefix is like Get, but instead of an +// exact match, it will return the longest prefix match. +func (t *Tree[T]) LongestPrefix(s string) (string, T, bool) { + var zeroVal T + var last *leafNode[T] + n := t.root + search := s + for { + // Look for a leaf node + if n.isLeaf() { + last = n.leaf + } + + // Check for key exhaution + if len(search) == 0 { + break + } + + // Look for an edge + n = n.getEdge(search[0]) + if n == nil { + break + } + + // Consume the search prefix + if strings.HasPrefix(search, n.prefix) { + search = search[len(n.prefix):] + } else { + break + } + } + if last != nil { + return last.key, last.val, true + } + return "", zeroVal, false +} + +// Minimum is used to return the minimum value in the tree +func (t *Tree[T]) Minimum() (string, T, bool) { + var zeroVal T + n := t.root + for { + if n.isLeaf() { + return n.leaf.key, n.leaf.val, true + } + if len(n.edges) > 0 { + n = n.edges[0].node + } else { + break + } + } + return "", zeroVal, false +} + +// Maximum is used to return the maximum value in the tree +func (t *Tree[T]) Maximum() (string, T, bool) { + var zeroVal T + n := t.root + for { + if num := len(n.edges); num > 0 { + n = n.edges[num-1].node + continue + } + if n.isLeaf() { + return n.leaf.key, n.leaf.val, true + } + break + } + return "", zeroVal, false +} + +// Walk is used to walk the tree +func (t *Tree[T]) Walk(fn WalkFn[T]) { + recursiveWalk(t.root, fn) +} + +// WalkPrefix is used to walk the tree under a prefix +func (t *Tree[T]) WalkPrefix(prefix string, fn WalkFn[T]) { + n := t.root + search := prefix + for { + // Check for key exhaustion + if len(search) == 0 { + recursiveWalk(n, fn) + return + } + + // Look for an edge + n = n.getEdge(search[0]) + if n == nil { + return + } + + // Consume the search prefix + if strings.HasPrefix(search, n.prefix) { + search = search[len(n.prefix):] + continue + } + if strings.HasPrefix(n.prefix, search) { + // Child may be under our search prefix + recursiveWalk(n, fn) + } + return + } +} + +// WalkPath is used to walk the tree, but only visiting nodes +// from the root down to a given leaf. Where WalkPrefix walks +// all the entries *under* the given prefix, this walks the +// entries *above* the given prefix. +func (t *Tree[T]) WalkPath(path string, fn WalkFn[T]) { + n := t.root + search := path + for { + // Visit the leaf values if any + if n.leaf != nil && fn(n.leaf.key, n.leaf.val) { + return + } + + // Check for key exhaution + if len(search) == 0 { + return + } + + // Look for an edge + n = n.getEdge(search[0]) + if n == nil { + return + } + + // Consume the search prefix + if strings.HasPrefix(search, n.prefix) { + search = search[len(n.prefix):] + } else { + break + } + } +} + +// recursiveWalk is used to do a pre-order walk of a node +// recursively. Returns true if the walk should be aborted +func recursiveWalk[T any](n *node[T], fn WalkFn[T]) bool { + // Visit the leaf values if any + if n.leaf != nil && fn(n.leaf.key, n.leaf.val) { + return true + } + + // Recurse on the children + i := 0 + k := len(n.edges) // keeps track of number of edges in previous iteration + for i < k { + e := n.edges[i] + if recursiveWalk(e.node, fn) { + return true + } + // It is a possibility that the WalkFn modified the node we are + // iterating on. If there are no more edges, mergeChild happened, + // so the last edge became the current node n, on which we'll + // iterate one last time. + if len(n.edges) == 0 { + return recursiveWalk(n, fn) + } + // If there are now less edges than in the previous iteration, + // then do not increment the loop index, since the current index + // points to a new edge. Otherwise, get to the next index. + if len(n.edges) >= k { + i++ + } + k = len(n.edges) + } + return false +} + +// ToMap is used to walk the tree and convert it into a map +func (t *Tree[T]) ToMap() map[string]T { + out := make(map[string]T, t.size) + t.Walk(func(k string, v T) bool { + out[k] = v + return false + }) + return out +} diff --git a/internal/radix/radix_test.go b/internal/radix/radix_test.go new file mode 100644 index 000000000000..9f616b73f438 --- /dev/null +++ b/internal/radix/radix_test.go @@ -0,0 +1,408 @@ +package radix + +import ( + crand "crypto/rand" + "fmt" + "reflect" + "sort" + "strconv" + "testing" +) + +func TestRadix(t *testing.T) { + var min, max string + inp := make(map[string]interface{}) + for i := 0; i < 1000; i++ { + gen := generateUUID() + inp[gen] = i + if gen < min || i == 0 { + min = gen + } + if gen > max || i == 0 { + max = gen + } + } + + r := NewFromMap(inp) + if r.Len() != len(inp) { + t.Fatalf("bad length: %v %v", r.Len(), len(inp)) + } + + r.Walk(func(k string, v interface{}) bool { + println(k) + return false + }) + + for k, v := range inp { + out, ok := r.Get(k) + if !ok { + t.Fatalf("missing key: %v", k) + } + if out != v { + t.Fatalf("value mis-match: %v %v", out, v) + } + } + + // Check min and max + outMin, _, _ := r.Minimum() + if outMin != min { + t.Fatalf("bad minimum: %v %v", outMin, min) + } + outMax, _, _ := r.Maximum() + if outMax != max { + t.Fatalf("bad maximum: %v %v", outMax, max) + } + + for k, v := range inp { + out, ok := r.Delete(k) + if !ok { + t.Fatalf("missing key: %v", k) + } + if out != v { + t.Fatalf("value mis-match: %v %v", out, v) + } + } + if r.Len() != 0 { + t.Fatalf("bad length: %v", r.Len()) + } +} + +func TestRoot(t *testing.T) { + r := New[bool]() + _, ok := r.Delete("") + if ok { + t.Fatalf("bad") + } + _, ok = r.Insert("", true) + if ok { + t.Fatalf("bad") + } + val, ok := r.Get("") + if !ok || val != true { + t.Fatalf("bad: %v", val) + } + val, ok = r.Delete("") + if !ok || val != true { + t.Fatalf("bad: %v", val) + } +} + +func TestDelete(t *testing.T) { + + r := New[bool]() + + s := []string{"", "A", "AB"} + + for _, ss := range s { + r.Insert(ss, true) + } + + for _, ss := range s { + _, ok := r.Delete(ss) + if !ok { + t.Fatalf("bad %q", ss) + } + } +} + +func TestDeletePrefix(t *testing.T) { + type exp struct { + inp []string + prefix string + out []string + numDeleted int + } + + cases := []exp{ + {[]string{"", "A", "AB", "ABC", "R", "S"}, "A", []string{"", "R", "S"}, 3}, + {[]string{"", "A", "AB", "ABC", "R", "S"}, "ABC", []string{"", "A", "AB", "R", "S"}, 1}, + {[]string{"", "A", "AB", "ABC", "R", "S"}, "", []string{}, 6}, + {[]string{"", "A", "AB", "ABC", "R", "S"}, "S", []string{"", "A", "AB", "ABC", "R"}, 1}, + {[]string{"", "A", "AB", "ABC", "R", "S"}, "SS", []string{"", "A", "AB", "ABC", "R", "S"}, 0}, + } + + for _, test := range cases { + r := New[bool]() + for _, ss := range test.inp { + r.Insert(ss, true) + } + + deleted := r.DeletePrefix(test.prefix) + if deleted != test.numDeleted { + t.Fatalf("Bad delete, expected %v to be deleted but got %v", test.numDeleted, deleted) + } + + out := []string{} + fn := func(s string, v bool) bool { + out = append(out, s) + return false + } + r.Walk(fn) + + if !reflect.DeepEqual(out, test.out) { + t.Fatalf("mis-match: %v %v", out, test.out) + } + } +} + +func TestLongestPrefix(t *testing.T) { + r := New[interface{}]() + + keys := []string{ + "", + "foo", + "foobar", + "foobarbaz", + "foobarbazzip", + "foozip", + } + for _, k := range keys { + r.Insert(k, nil) + } + if r.Len() != len(keys) { + t.Fatalf("bad len: %v %v", r.Len(), len(keys)) + } + + type exp struct { + inp string + out string + } + cases := []exp{ + {"a", ""}, + {"abc", ""}, + {"fo", ""}, + {"foo", "foo"}, + {"foob", "foo"}, + {"foobar", "foobar"}, + {"foobarba", "foobar"}, + {"foobarbaz", "foobarbaz"}, + {"foobarbazzi", "foobarbaz"}, + {"foobarbazzip", "foobarbazzip"}, + {"foozi", "foo"}, + {"foozip", "foozip"}, + {"foozipzap", "foozip"}, + } + for _, test := range cases { + m, _, ok := r.LongestPrefix(test.inp) + if !ok { + t.Fatalf("no match: %v", test) + } + if m != test.out { + t.Fatalf("mis-match: %v %v", m, test) + } + } +} + +func TestWalkPrefix(t *testing.T) { + r := New[interface{}]() + + keys := []string{ + "foobar", + "foo/bar/baz", + "foo/baz/bar", + "foo/zip/zap", + "zipzap", + } + for _, k := range keys { + r.Insert(k, nil) + } + if r.Len() != len(keys) { + t.Fatalf("bad len: %v %v", r.Len(), len(keys)) + } + + type exp struct { + inp string + out []string + } + cases := []exp{ + { + "f", + []string{"foobar", "foo/bar/baz", "foo/baz/bar", "foo/zip/zap"}, + }, + { + "foo", + []string{"foobar", "foo/bar/baz", "foo/baz/bar", "foo/zip/zap"}, + }, + { + "foob", + []string{"foobar"}, + }, + { + "foo/", + []string{"foo/bar/baz", "foo/baz/bar", "foo/zip/zap"}, + }, + { + "foo/b", + []string{"foo/bar/baz", "foo/baz/bar"}, + }, + { + "foo/ba", + []string{"foo/bar/baz", "foo/baz/bar"}, + }, + { + "foo/bar", + []string{"foo/bar/baz"}, + }, + { + "foo/bar/baz", + []string{"foo/bar/baz"}, + }, + { + "foo/bar/bazoo", + []string{}, + }, + { + "z", + []string{"zipzap"}, + }, + } + + for _, test := range cases { + out := []string{} + fn := func(s string, v interface{}) bool { + out = append(out, s) + return false + } + r.WalkPrefix(test.inp, fn) + sort.Strings(out) + sort.Strings(test.out) + if !reflect.DeepEqual(out, test.out) { + t.Fatalf("mis-match: %v %v", out, test.out) + } + } +} + +func TestWalkPath(t *testing.T) { + r := New[interface{}]() + + keys := []string{ + "foo", + "foo/bar", + "foo/bar/baz", + "foo/baz/bar", + "foo/zip/zap", + "zipzap", + } + for _, k := range keys { + r.Insert(k, nil) + } + if r.Len() != len(keys) { + t.Fatalf("bad len: %v %v", r.Len(), len(keys)) + } + + type exp struct { + inp string + out []string + } + cases := []exp{ + { + "f", + []string{}, + }, + { + "foo", + []string{"foo"}, + }, + { + "foo/", + []string{"foo"}, + }, + { + "foo/ba", + []string{"foo"}, + }, + { + "foo/bar", + []string{"foo", "foo/bar"}, + }, + { + "foo/bar/baz", + []string{"foo", "foo/bar", "foo/bar/baz"}, + }, + { + "foo/bar/bazoo", + []string{"foo", "foo/bar", "foo/bar/baz"}, + }, + { + "z", + []string{}, + }, + } + + for _, test := range cases { + out := []string{} + fn := func(s string, v interface{}) bool { + out = append(out, s) + return false + } + r.WalkPath(test.inp, fn) + sort.Strings(out) + sort.Strings(test.out) + if !reflect.DeepEqual(out, test.out) { + t.Fatalf("mis-match: %v %v", out, test.out) + } + } +} + +func TestWalkDelete(t *testing.T) { + r := New[interface{}]() + r.Insert("init0/0", nil) + r.Insert("init0/1", nil) + r.Insert("init0/2", nil) + r.Insert("init0/3", nil) + r.Insert("init1/0", nil) + r.Insert("init1/1", nil) + r.Insert("init1/2", nil) + r.Insert("init1/3", nil) + r.Insert("init2", nil) + + deleteFn := func(s string, v interface{}) bool { + r.Delete(s) + return false + } + + r.WalkPrefix("init1", deleteFn) + + for _, s := range []string{"init0/0", "init0/1", "init0/2", "init0/3", "init2"} { + if _, ok := r.Get(s); !ok { + t.Fatalf("expecting to still find %q", s) + } + } + if n := r.Len(); n != 5 { + t.Fatalf("expected to find exactly 5 nodes, instead found %d: %v", n, r.ToMap()) + } + + r.Walk(deleteFn) + if n := r.Len(); n != 0 { + t.Fatalf("expected to find exactly 0 nodes, instead found %d: %v", n, r.ToMap()) + } +} + +// generateUUID is used to generate a random UUID +func generateUUID() string { + buf := make([]byte, 16) + if _, err := crand.Read(buf); err != nil { + panic(fmt.Errorf("failed to read random bytes: %v", err)) + } + + return fmt.Sprintf("%08x-%04x-%04x-%04x-%12x", + buf[0:4], + buf[4:6], + buf[6:8], + buf[8:10], + buf[10:16]) +} + +func BenchmarkInsert(b *testing.B) { + r := New[bool]() + for i := 0; i < 10000; i++ { + r.Insert(fmt.Sprintf("init%d", i), true) + } + b.ResetTimer() + for n := 0; n < b.N; n++ { + _, updated := r.Insert(strconv.Itoa(n), true) + if updated { + b.Fatal("bad") + } + } +} diff --git a/internal/resource/errors.go b/internal/resource/errors.go index 55fbb95d6ae9..c258f9ad35b1 100644 --- a/internal/resource/errors.go +++ b/internal/resource/errors.go @@ -92,12 +92,12 @@ func (err ErrInvalidMapKey) Unwrap() error { return err.Wrapped } -type ErrOwnerInvalid struct { +type ErrOwnerTypeInvalid struct { ResourceType *pbresource.Type OwnerType *pbresource.Type } -func (err ErrOwnerInvalid) Error() string { +func (err ErrOwnerTypeInvalid) Error() string { return fmt.Sprintf( "resources of type %s cannot be owned by resources with type %s", ToGVK(err.ResourceType), @@ -105,6 +105,20 @@ func (err ErrOwnerInvalid) Error() string { ) } +type ErrOwnerTenantInvalid struct { + ResourceType *pbresource.Type + ResourceTenancy *pbresource.Tenancy + OwnerTenancy *pbresource.Tenancy +} + +func (err ErrOwnerTenantInvalid) Error() string { + return fmt.Sprintf( + "resource in partition %s, namespace %s and peer %s cannot be owned by a resource in partition %s, namespace %s and peer %s", + err.ResourceTenancy.Partition, err.ResourceTenancy.Namespace, err.ResourceTenancy.PeerName, + err.OwnerTenancy.Partition, err.OwnerTenancy.Namespace, err.OwnerTenancy.PeerName, + ) +} + type ErrInvalidReferenceType struct { AllowedType *pbresource.Type } diff --git a/internal/resource/errors_test.go b/internal/resource/errors_test.go index 5705457d5992..990a91607723 100644 --- a/internal/resource/errors_test.go +++ b/internal/resource/errors_test.go @@ -63,7 +63,7 @@ func TestErrorStrings(t *testing.T) { Key: "http", Wrapped: fakeWrappedErr, }, - "ErrOwnerInvalid": ErrOwnerInvalid{ + "ErrOwnerInvalid": ErrOwnerTypeInvalid{ ResourceType: &pbresource.Type{Group: "foo", GroupVersion: "v1", Kind: "bar"}, OwnerType: &pbresource.Type{Group: "other", GroupVersion: "v2", Kind: "something"}, }, diff --git a/internal/resource/resourcetest/builder.go b/internal/resource/resourcetest/builder.go index 46039e209a70..7355f38824ec 100644 --- a/internal/resource/resourcetest/builder.go +++ b/internal/resource/resourcetest/builder.go @@ -2,7 +2,6 @@ package resourcetest import ( "context" - "testing" "github.com/hashicorp/consul/proto-public/pbresource" "github.com/oklog/ulid/v2" @@ -38,13 +37,24 @@ func Resource(rtype *pbresource.Type, name string) *resourceBuilder { } } -func (b *resourceBuilder) WithData(t *testing.T, data protoreflect.ProtoMessage) *resourceBuilder { +func (b *resourceBuilder) WithData(t T, data protoreflect.ProtoMessage) *resourceBuilder { + t.Helper() + anyData, err := anypb.New(data) require.NoError(t, err) b.resource.Data = anyData return b } +func (b *resourceBuilder) WithMeta(key string, value string) *resourceBuilder { + if b.resource.Metadata == nil { + b.resource.Metadata = make(map[string]string) + } + + b.resource.Metadata[key] = value + return b +} + func (b *resourceBuilder) WithOwner(id *pbresource.ID) *resourceBuilder { b.resource.Owner = id return b @@ -91,7 +101,13 @@ func (b *resourceBuilder) Build() *pbresource.Resource { return res } -func (b *resourceBuilder) Write(t *testing.T, client pbresource.ResourceServiceClient) *pbresource.Resource { +func (b *resourceBuilder) ID() *pbresource.ID { + return b.resource.Id +} + +func (b *resourceBuilder) Write(t T, client pbresource.ResourceServiceClient) *pbresource.Resource { + t.Helper() + res := b.resource rsp, err := client.Write(context.Background(), &pbresource.WriteRequest{ @@ -101,7 +117,9 @@ func (b *resourceBuilder) Write(t *testing.T, client pbresource.ResourceServiceC require.NoError(t, err) if !b.dontCleanup { - t.Cleanup(func() { + cleaner, ok := t.(CleanupT) + require.True(t, ok, "T does not implement a Cleanup method and cannot be used with automatic resource cleanup") + cleaner.Cleanup(func() { _, err := client.Delete(context.Background(), &pbresource.DeleteRequest{ Id: rsp.Resource.Id, }) diff --git a/internal/resource/resourcetest/client.go b/internal/resource/resourcetest/client.go new file mode 100644 index 000000000000..dab5b03c3adb --- /dev/null +++ b/internal/resource/resourcetest/client.go @@ -0,0 +1,218 @@ +package resourcetest + +import ( + "context" + "math/rand" + "time" + + "github.com/hashicorp/consul/internal/resource" + "github.com/hashicorp/consul/proto-public/pbresource" + "github.com/hashicorp/consul/sdk/testutil/retry" + "github.com/stretchr/testify/require" + "golang.org/x/exp/slices" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type Client struct { + pbresource.ResourceServiceClient + + timeout time.Duration + wait time.Duration +} + +func NewClient(client pbresource.ResourceServiceClient) *Client { + return &Client{ + ResourceServiceClient: client, + timeout: 7 * time.Second, + wait: 25 * time.Millisecond, + } +} + +func (client *Client) SetRetryerConfig(timeout time.Duration, wait time.Duration) { + client.timeout = timeout + client.wait = wait +} + +func (client *Client) retry(t T, fn func(r *retry.R)) { + retryer := &retry.Timer{Timeout: client.timeout, Wait: client.wait} + retry.RunWith(retryer, t, fn) +} + +func (client *Client) PublishResources(t T, resources []*pbresource.Resource) { + // Randomize the order of insertion. Generally insertion order shouldn't matter as the + // controllers should eventually converge on the desired state. The exception to this + // is that you cannot insert resources with owner refs before the resource they are + // owned by or insert a resource into a non-default tenant before that tenant exists. + rand.Shuffle(len(resources), func(i, j int) { + temp := resources[i] + resources[i] = resources[j] + resources[j] = temp + }) + + // This slice will be used to track the resources actually published each round. When + // a resource with an owner ID is encountered we will not attempt to write but defer it + // to the next round of publishing + var written []*pbresource.ID + + for len(resources) > 0 { + var left []*pbresource.Resource + published := 0 + for _, res := range resources { + + // check that any owner references would be satisfied + if res.Owner != nil { + found := slices.ContainsFunc(written, func(id *pbresource.ID) bool { + return resource.EqualID(res.Owner, id) + }) + + // the owner hasn't yet been published then we cannot publish this resource + if !found { + left = append(left, res) + continue + } + } + + t.Logf("Writing resource %s with type %s", res.Id.Name, resource.ToGVK(res.Id.Type)) + _, err := client.Write(context.Background(), &pbresource.WriteRequest{ + Resource: res, + }) + require.NoError(t, err) + + // track the number o + published += 1 + written = append(written, res.Id) + } + + // the next round only has this subset of resources to attempt writing + resources = left + + // if we didn't publish any resources this round then nothing would + // enable us to do so by iterating again so we break to prevent infinite + // loooping. + if published == 0 { + break + } + } + + require.Empty(t, resources, "Could not publish all resources - some resources have invalid owner references") +} + +func (client *Client) RequireResourceNotFound(t T, id *pbresource.ID) { + t.Helper() + + rsp, err := client.Read(context.Background(), &pbresource.ReadRequest{Id: id}) + require.Error(t, err) + require.Equal(t, codes.NotFound, status.Code(err)) + require.Nil(t, rsp) +} + +func (client *Client) RequireResourceExists(t T, id *pbresource.ID) *pbresource.Resource { + t.Helper() + + rsp, err := client.Read(context.Background(), &pbresource.ReadRequest{Id: id}) + require.NoError(t, err, "error reading %s with type %s", id.Name, resource.ToGVK(id.Type)) + require.NotNil(t, rsp) + return rsp.Resource +} + +func (client *Client) RequireVersionUnchanged(t T, id *pbresource.ID, version string) *pbresource.Resource { + t.Helper() + + res := client.RequireResourceExists(t, id) + RequireVersionUnchanged(t, res, version) + return res +} + +func (client *Client) RequireVersionChanged(t T, id *pbresource.ID, version string) *pbresource.Resource { + t.Helper() + + res := client.RequireResourceExists(t, id) + RequireVersionChanged(t, res, version) + return res +} + +func (client *Client) RequireStatusCondition(t T, id *pbresource.ID, statusKey string, condition *pbresource.Condition) *pbresource.Resource { + t.Helper() + + res := client.RequireResourceExists(t, id) + RequireStatusCondition(t, res, statusKey, condition) + return res +} + +func (client *Client) RequireStatusConditionForCurrentGen(t T, id *pbresource.ID, statusKey string, condition *pbresource.Condition) *pbresource.Resource { + t.Helper() + + res := client.RequireResourceExists(t, id) + RequireStatusConditionForCurrentGen(t, res, statusKey, condition) + return res +} + +func (client *Client) RequireResourceMeta(t T, id *pbresource.ID, key string, value string) *pbresource.Resource { + t.Helper() + + res := client.RequireResourceExists(t, id) + RequireResourceMeta(t, res, key, value) + return res +} + +func (client *Client) RequireReconciledCurrentGen(t T, id *pbresource.ID, statusKey string) *pbresource.Resource { + t.Helper() + + res := client.RequireResourceExists(t, id) + RequireReconciledCurrentGen(t, res, statusKey) + return res +} + +func (client *Client) WaitForReconciliation(t T, id *pbresource.ID, statusKey string) *pbresource.Resource { + t.Helper() + + var res *pbresource.Resource + + client.retry(t, func(r *retry.R) { + res = client.RequireReconciledCurrentGen(r, id, statusKey) + }) + + return res +} + +func (client *Client) WaitForStatusCondition(t T, id *pbresource.ID, statusKey string, condition *pbresource.Condition) *pbresource.Resource { + t.Helper() + + var res *pbresource.Resource + client.retry(t, func(r *retry.R) { + res = client.RequireStatusConditionForCurrentGen(t, id, statusKey, condition) + }) + + return res +} + +func (client *Client) WaitForNewVersion(t T, id *pbresource.ID, version string) *pbresource.Resource { + t.Helper() + + var res *pbresource.Resource + client.retry(t, func(r *retry.R) { + res = client.RequireVersionChanged(r, id, version) + }) + return res +} + +func (client *Client) WaitForResourceState(t T, id *pbresource.ID, verify func(T, *pbresource.Resource)) *pbresource.Resource { + t.Helper() + + var res *pbresource.Resource + client.retry(t, func(r *retry.R) { + res = client.RequireResourceExists(r, id) + verify(r, res) + }) + + return res +} + +// ResolveResourceID will read the specified resource and returns its full ID. +// This is mainly useful to get the ID with the Uid filled out. +func (client *Client) ResolveResourceID(t T, id *pbresource.ID) *pbresource.ID { + t.Helper() + + return client.RequireResourceExists(t, id).Id +} diff --git a/internal/resource/resourcetest/fs.go b/internal/resource/resourcetest/fs.go new file mode 100644 index 000000000000..e7a1417a5908 --- /dev/null +++ b/internal/resource/resourcetest/fs.go @@ -0,0 +1,40 @@ +package resourcetest + +import ( + "fmt" + "io/fs" + + "github.com/hashicorp/consul/proto-public/pbresource" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/encoding/protojson" +) + +// ParseResourcesFromFilesystem will walk the filesystem at the given path +// and parse all files as protobuf/JSON resources. +func ParseResourcesFromFilesystem(t T, files fs.FS, path string) []*pbresource.Resource { + t.Helper() + + var resources []*pbresource.Resource + err := fs.WalkDir(files, path, func(fpath string, dent fs.DirEntry, _ error) error { + if dent.IsDir() { + return nil + } + + data, err := fs.ReadFile(files, fpath) + if err != nil { + return err + } + + var res pbresource.Resource + err = protojson.Unmarshal(data, &res) + if err != nil { + return fmt.Errorf("error decoding data from %s: %w", fpath, err) + } + + resources = append(resources, &res) + return nil + }) + + require.NoError(t, err) + return resources +} diff --git a/internal/resource/resourcetest/require.go b/internal/resource/resourcetest/require.go new file mode 100644 index 000000000000..fff8cb2aebf2 --- /dev/null +++ b/internal/resource/resourcetest/require.go @@ -0,0 +1,62 @@ +package resourcetest + +import ( + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/consul/proto-public/pbresource" + "github.com/hashicorp/consul/proto/private/prototest" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/testing/protocmp" +) + +func RequireVersionUnchanged(t T, res *pbresource.Resource, version string) { + t.Helper() + require.Equal(t, version, res.Version) +} + +func RequireVersionChanged(t T, res *pbresource.Resource, version string) { + t.Helper() + require.NotEqual(t, version, res.Version) +} + +func RequireStatusCondition(t T, res *pbresource.Resource, statusKey string, condition *pbresource.Condition) { + t.Helper() + require.NotNil(t, res.Status) + status, found := res.Status[statusKey] + require.True(t, found) + prototest.AssertContainsElement(t, status.Conditions, condition) +} + +func RequireStatusConditionForCurrentGen(t T, res *pbresource.Resource, statusKey string, condition *pbresource.Condition) { + t.Helper() + require.NotNil(t, res.Status) + status, found := res.Status[statusKey] + require.True(t, found) + require.Equal(t, res.Generation, status.ObservedGeneration) + prototest.AssertContainsElement(t, status.Conditions, condition) +} + +func RequireResourceMeta(t T, res *pbresource.Resource, key string, value string) { + t.Helper() + require.NotNil(t, res.Metadata) + require.Contains(t, res.Metadata, key) + require.Equal(t, value, res.Metadata[key]) +} + +func RequireReconciledCurrentGen(t T, res *pbresource.Resource, statusKey string) { + t.Helper() + require.NotNil(t, res.Status) + status, found := res.Status[statusKey] + require.True(t, found) + require.Equal(t, res.Generation, status.ObservedGeneration) +} + +func RequireOwner(t T, res *pbresource.Resource, owner *pbresource.ID, ignoreUid bool) { + t.Helper() + + var opts []cmp.Option + if ignoreUid { + opts = append(opts, protocmp.IgnoreFields(owner, "uid")) + } + + prototest.AssertDeepEqual(t, res.Owner, owner, opts...) +} diff --git a/internal/resource/resourcetest/testing.go b/internal/resource/resourcetest/testing.go new file mode 100644 index 000000000000..d02b70da9d03 --- /dev/null +++ b/internal/resource/resourcetest/testing.go @@ -0,0 +1,17 @@ +package resourcetest + +// T represents the subset of testing.T methods that will be used +// by the various functionality in this package +type T interface { + Helper() + Log(args ...interface{}) + Logf(format string, args ...interface{}) + Errorf(format string, args ...interface{}) + Fatalf(format string, args ...interface{}) + FailNow() +} + +type CleanupT interface { + T + Cleanup(func()) +} diff --git a/sdk/testutil/retry/retry.go b/sdk/testutil/retry/retry.go index 8817b6e8fb9e..30045f0c629d 100644 --- a/sdk/testutil/retry/retry.go +++ b/sdk/testutil/retry/retry.go @@ -59,6 +59,10 @@ func (r *R) Logf(format string, args ...interface{}) { r.log(fmt.Sprintf(format, args...)) } +func (r *R) Log(args ...interface{}) { + r.log(fmt.Sprintln(args...)) +} + func (r *R) Helper() {} // runFailed is a sentinel value to indicate that the func itself diff --git a/test/integration/connect/envoy/case-property-override/capture.sh b/test/integration/connect/envoy/case-property-override/capture.sh new file mode 100644 index 000000000000..88f1dd6465f2 --- /dev/null +++ b/test/integration/connect/envoy/case-property-override/capture.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + + +snapshot_envoy_admin localhost:19000 s1 primary || true +snapshot_envoy_admin localhost:19001 s2 primary || true +snapshot_envoy_admin localhost:19002 s3 primary || true diff --git a/test/integration/connect/envoy/case-property-override/service_s1.hcl b/test/integration/connect/envoy/case-property-override/service_s1.hcl new file mode 100644 index 000000000000..6bacecf91617 --- /dev/null +++ b/test/integration/connect/envoy/case-property-override/service_s1.hcl @@ -0,0 +1,23 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +services { + name = "s1" + port = 8080 + connect { + sidecar_service { + proxy { + upstreams = [ + { + destination_name = "s2" + local_bind_port = 5000 + }, + { + destination_name = "s3" + local_bind_port = 5001 + } + ] + } + } + } +} diff --git a/test/integration/connect/envoy/case-property-override/service_s2.hcl b/test/integration/connect/envoy/case-property-override/service_s2.hcl new file mode 100644 index 000000000000..c01f4b2fefe5 --- /dev/null +++ b/test/integration/connect/envoy/case-property-override/service_s2.hcl @@ -0,0 +1,8 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +services { + name = "s2" + port = 8181 + connect { sidecar_service {} } +} diff --git a/test/integration/connect/envoy/case-property-override/service_s3.hcl b/test/integration/connect/envoy/case-property-override/service_s3.hcl new file mode 100644 index 000000000000..d616906e4c67 --- /dev/null +++ b/test/integration/connect/envoy/case-property-override/service_s3.hcl @@ -0,0 +1,8 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +services { + name = "s3" + port = 8181 + connect { sidecar_service {} } +} diff --git a/test/integration/connect/envoy/case-property-override/setup.sh b/test/integration/connect/envoy/case-property-override/setup.sh new file mode 100644 index 000000000000..1bf2021c0c9a --- /dev/null +++ b/test/integration/connect/envoy/case-property-override/setup.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + + +set -eEuo pipefail + +upsert_config_entry primary ' +Kind = "service-defaults" +Name = "s2" +Protocol = "http" +EnvoyExtensions = [ + { + Name = "builtin/property-override" + Arguments = { + ProxyType = "connect-proxy" + Patches = [{ + ResourceFilter = { + ResourceType = "listener" + TrafficDirection = "inbound" + } + Op = "add" + Path = "/stat_prefix" + Value = "custom.stats.s2" + }] + } + } +] +' + +upsert_config_entry primary ' +Kind = "service-defaults" +Name = "s3" +Protocol = "http" +' + +upsert_config_entry primary ' +Kind = "service-defaults" +Name = "s1" +Protocol = "http" +EnvoyExtensions = [ + { + Name = "builtin/property-override" + Arguments = { + ProxyType = "connect-proxy" + Patches = [ + { + ResourceFilter = { + ResourceType = "cluster" + TrafficDirection = "outbound" + } + Op = "add" + Path = "/upstream_connection_options/tcp_keepalive/keepalive_probes" + Value = 1234 + }, + { + ResourceFilter = { + ResourceType = "cluster" + TrafficDirection = "outbound" + Services = [{ + Name = "s2" + }] + } + Op = "remove" + Path = "/outlier_detection" + } + ] + } + } +] +' + +register_services primary + +gen_envoy_bootstrap s1 19000 primary +gen_envoy_bootstrap s2 19001 primary diff --git a/test/integration/connect/envoy/case-property-override/vars.sh b/test/integration/connect/envoy/case-property-override/vars.sh new file mode 100644 index 000000000000..358866872a13 --- /dev/null +++ b/test/integration/connect/envoy/case-property-override/vars.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + + +export REQUIRED_SERVICES="s1 s1-sidecar-proxy s2 s2-sidecar-proxy s3 s3-sidecar-proxy" diff --git a/test/integration/connect/envoy/case-property-override/verify.bats b/test/integration/connect/envoy/case-property-override/verify.bats new file mode 100644 index 000000000000..4453409eede5 --- /dev/null +++ b/test/integration/connect/envoy/case-property-override/verify.bats @@ -0,0 +1,36 @@ +#!/usr/bin/env bats + +load helpers + +@test "s1 proxy admin is up on :19000" { + retry_default curl -f -s localhost:19000/stats -o /dev/null +} + +@test "s2 proxy admin is up on :19001" { + retry_default curl -f -s localhost:19001/stats -o /dev/null +} + +@test "s1 proxy listener should be up and have right cert" { + assert_proxy_presents_cert_uri localhost:21000 s1 +} + +@test "s1 proxy is configured with the expected envoy patches" { + run get_envoy_cluster_config localhost:19000 s2 + [ "$status" == 0 ] + + [ "$(echo "$output" | jq -r '.upstream_connection_options.tcp_keepalive.keepalive_probes')" == "1234" ] + [ "$(echo "$output" | jq -r '.outlier_detection')" == "null" ] + + run get_envoy_cluster_config localhost:19000 s3 + [ "$status" == 0 ] + + [ "$(echo "$output" | jq -r '.upstream_connection_options.tcp_keepalive.keepalive_probes')" == "1234" ] + [ "$(echo "$output" | jq -r '.outlier_detection')" == "{}" ] +} + +@test "s2 proxy is configured with the expected envoy patches" { + run get_envoy_public_listener_once localhost:19001 + [ "$status" == 0 ] + + [ "$(echo "$output" | jq -r '.active_state.listener.stat_prefix')" == "custom.stats.s2" ] +} diff --git a/test/integration/connect/envoy/helpers.bash b/test/integration/connect/envoy/helpers.bash index 81ee390128a6..009c88cd90d8 100755 --- a/test/integration/connect/envoy/helpers.bash +++ b/test/integration/connect/envoy/helpers.bash @@ -228,6 +228,13 @@ function get_envoy_expose_checks_listener_once { echo "$output" | jq --raw-output '.configs[] | select(.["@type"] == "type.googleapis.com/envoy.admin.v3.ListenersConfigDump") | .dynamic_listeners[] | select(.name | startswith("exposed_path_"))' } +function get_envoy_public_listener_once { + local HOSTPORT=$1 + run curl -s -f $HOSTPORT/config_dump + [ "$status" -eq 0 ] + echo "$output" | jq --raw-output '.configs[] | select(.["@type"] == "type.googleapis.com/envoy.admin.v3.ListenersConfigDump") | .dynamic_listeners[] | select(.name | startswith("public_listener:"))' +} + function assert_envoy_http_rbac_policy_count { local HOSTPORT=$1 local EXPECT_COUNT=$2 diff --git a/website/content/docs/agent/telemetry.mdx b/website/content/docs/agent/telemetry.mdx index ebb73a303ad4..59e78e27e073 100644 --- a/website/content/docs/agent/telemetry.mdx +++ b/website/content/docs/agent/telemetry.mdx @@ -381,34 +381,34 @@ This is a full list of metrics emitted by Consul. | `consul.client.rpc` | Increments whenever a Consul agent in client mode makes an RPC request to a Consul server. This gives a measure of how much a given agent is loading the Consul servers. Currently, this is only generated by agents in client mode, not Consul servers. | requests | counter | | `consul.client.rpc.exceeded` | Increments whenever a Consul agent in client mode makes an RPC request to a Consul server gets rate limited by that agent's [`limits`](/consul/docs/agent/config/config-files#limits) configuration. This gives an indication that there's an abusive application making too many requests on the agent, or that the rate limit needs to be increased. Currently, this only applies to agents in client mode, not Consul servers. | rejected requests | counter | | `consul.client.rpc.failed` | Increments whenever a Consul agent in client mode makes an RPC request to a Consul server and fails. | requests | counter | -| `consul.client.api.catalog_register.` | Increments whenever a Consul agent receives a catalog register request. | requests | counter | -| `consul.client.api.success.catalog_register.` | Increments whenever a Consul agent successfully responds to a catalog register request. | requests | counter | -| `consul.client.rpc.error.catalog_register.` | Increments whenever a Consul agent receives an RPC error for a catalog register request. | errors | counter | -| `consul.client.api.catalog_deregister.` | Increments whenever a Consul agent receives a catalog deregister request. | requests | counter | -| `consul.client.api.success.catalog_deregister.` | Increments whenever a Consul agent successfully responds to a catalog deregister request. | requests | counter | -| `consul.client.rpc.error.catalog_deregister.` | Increments whenever a Consul agent receives an RPC error for a catalog deregister request. | errors | counter | -| `consul.client.api.catalog_datacenters.` | Increments whenever a Consul agent receives a request to list datacenters in the catalog. | requests | counter | -| `consul.client.api.success.catalog_datacenters.` | Increments whenever a Consul agent successfully responds to a request to list datacenters. | requests | counter | -| `consul.client.rpc.error.catalog_datacenters.` | Increments whenever a Consul agent receives an RPC error for a request to list datacenters. | errors | counter | -| `consul.client.api.catalog_nodes.` | Increments whenever a Consul agent receives a request to list nodes from the catalog. | requests | counter | -| `consul.client.api.success.catalog_nodes.` | Increments whenever a Consul agent successfully responds to a request to list nodes. | requests | counter | -| `consul.client.rpc.error.catalog_nodes.` | Increments whenever a Consul agent receives an RPC error for a request to list nodes. | errors | counter | -| `consul.client.api.catalog_services.` | Increments whenever a Consul agent receives a request to list services from the catalog. | requests | counter | -| `consul.client.api.success.catalog_services.` | Increments whenever a Consul agent successfully responds to a request to list services. | requests | counter | -| `consul.client.rpc.error.catalog_services.` | Increments whenever a Consul agent receives an RPC error for a request to list services. | errors | counter | -| `consul.client.api.catalog_service_nodes.` | Increments whenever a Consul agent receives a request to list nodes offering a service. | requests | counter | -| `consul.client.api.success.catalog_service_nodes.` | Increments whenever a Consul agent successfully responds to a request to list nodes offering a service. | requests | counter | -| `consul.client.api.error.catalog_service_nodes.` | Increments whenever a Consul agent receives an RPC error for request to list nodes offering a service. | requests | counter | -| `consul.client.rpc.error.catalog_service_nodes.` | Increments whenever a Consul agent receives an RPC error for a request to list nodes offering a service.   | errors | counter | -| `consul.client.api.catalog_node_services.` | Increments whenever a Consul agent receives a request to list services registered in a node.   | requests | counter | -| `consul.client.api.success.catalog_node_services.` | Increments whenever a Consul agent successfully responds to a request to list services in a node.   | requests | counter | -| `consul.client.rpc.error.catalog_node_services.` | Increments whenever a Consul agent receives an RPC error for a request to list services in a node.   | errors | counter | +| `consul.client.api.catalog_register` | Increments whenever a Consul agent receives a catalog register request. | requests | counter | +| `consul.client.api.success.catalog_register` | Increments whenever a Consul agent successfully responds to a catalog register request. | requests | counter | +| `consul.client.rpc.error.catalog_register` | Increments whenever a Consul agent receives an RPC error for a catalog register request. | errors | counter | +| `consul.client.api.catalog_deregister` | Increments whenever a Consul agent receives a catalog deregister request. | requests | counter | +| `consul.client.api.success.catalog_deregister` | Increments whenever a Consul agent successfully responds to a catalog deregister request. | requests | counter | +| `consul.client.rpc.error.catalog_deregister` | Increments whenever a Consul agent receives an RPC error for a catalog deregister request. | errors | counter | +| `consul.client.api.catalog_datacenters` | Increments whenever a Consul agent receives a request to list datacenters in the catalog. | requests | counter | +| `consul.client.api.success.catalog_datacenters` | Increments whenever a Consul agent successfully responds to a request to list datacenters. | requests | counter | +| `consul.client.rpc.error.catalog_datacenters` | Increments whenever a Consul agent receives an RPC error for a request to list datacenters. | errors | counter | +| `consul.client.api.catalog_nodes` | Increments whenever a Consul agent receives a request to list nodes from the catalog. | requests | counter | +| `consul.client.api.success.catalog_nodes` | Increments whenever a Consul agent successfully responds to a request to list nodes. | requests | counter | +| `consul.client.rpc.error.catalog_nodes` | Increments whenever a Consul agent receives an RPC error for a request to list nodes. | errors | counter | +| `consul.client.api.catalog_services` | Increments whenever a Consul agent receives a request to list services from the catalog. | requests | counter | +| `consul.client.api.success.catalog_services` | Increments whenever a Consul agent successfully responds to a request to list services. | requests | counter | +| `consul.client.rpc.error.catalog_services` | Increments whenever a Consul agent receives an RPC error for a request to list services. | errors | counter | +| `consul.client.api.catalog_service_nodes` | Increments whenever a Consul agent receives a request to list nodes offering a service. | requests | counter | +| `consul.client.api.success.catalog_service_nodes` | Increments whenever a Consul agent successfully responds to a request to list nodes offering a service. | requests | counter | +| `consul.client.api.error.catalog_service_nodes` | Increments whenever a Consul agent receives an RPC error for request to list nodes offering a service. | requests | counter | +| `consul.client.rpc.error.catalog_service_nodes` | Increments whenever a Consul agent receives an RPC error for a request to list nodes offering a service.   | errors | counter | +| `consul.client.api.catalog_node_services` | Increments whenever a Consul agent receives a request to list services registered in a node.   | requests | counter | +| `consul.client.api.success.catalog_node_services` | Increments whenever a Consul agent successfully responds to a request to list services in a node.   | requests | counter | +| `consul.client.rpc.error.catalog_node_services` | Increments whenever a Consul agent receives an RPC error for a request to list services in a node.   | errors | counter | | `consul.client.api.catalog_node_service_list` | Increments whenever a Consul agent receives a request to list a node's registered services. | requests | counter | | `consul.client.rpc.error.catalog_node_service_list` | Increments whenever a Consul agent receives an RPC error for request to list a node's registered services. | errors | counter | | `consul.client.api.success.catalog_node_service_list` | Increments whenever a Consul agent successfully responds to a request to list a node's registered services. | requests | counter | -| `consul.client.api.catalog_gateway_services.` | Increments whenever a Consul agent receives a request to list services associated with a gateway. | requests | counter | -| `consul.client.api.success.catalog_gateway_services.` | Increments whenever a Consul agent successfully responds to a request to list services associated with a gateway. | requests | counter | -| `consul.client.rpc.error.catalog_gateway_services.` | Increments whenever a Consul agent receives an RPC error for a request to list services associated with a gateway. | errors | counter | +| `consul.client.api.catalog_gateway_services` | Increments whenever a Consul agent receives a request to list services associated with a gateway. | requests | counter | +| `consul.client.api.success.catalog_gateway_services` | Increments whenever a Consul agent successfully responds to a request to list services associated with a gateway. | requests | counter | +| `consul.client.rpc.error.catalog_gateway_services` | Increments whenever a Consul agent receives an RPC error for a request to list services associated with a gateway. | errors | counter | | `consul.runtime.num_goroutines` | Tracks the number of running goroutines and is a general load pressure indicator. This may burst from time to time but should return to a steady state value. | number of goroutines | gauge | | `consul.runtime.alloc_bytes` | Measures the number of bytes allocated by the Consul process. This may burst from time to time but should return to a steady state value. | bytes | gauge | | `consul.runtime.heap_objects` | Measures the number of objects allocated on the heap and is a general memory pressure indicator. This may burst from time to time but should return to a steady state value. | number of objects | gauge | @@ -422,8 +422,8 @@ This is a full list of metrics emitted by Consul. | `consul.members.clients` | Measures the current number of client agents registered with Consul. It is only emitted by Consul servers. Added in v1.9.6. | number of clients | gauge | | `consul.members.servers` | Measures the current number of server agents registered with Consul. It is only emitted by Consul servers. Added in v1.9.6. | number of servers | gauge | | `consul.dns.stale_queries` | Increments when an agent serves a query within the allowed stale threshold. | queries | counter | -| `consul.dns.ptr_query.` | Measures the time spent handling a reverse DNS query for the given node. | ms | timer | -| `consul.dns.domain_query.` | Measures the time spent handling a domain query for the given node. | ms | timer | +| `consul.dns.ptr_query` | Measures the time spent handling a reverse DNS query for the given node. | ms | timer | +| `consul.dns.domain_query` | Measures the time spent handling a domain query for the given node. | ms | timer | | `consul.system.licenseExpiration` | This measures the number of hours remaining on the agents license. | hours | gauge | | `consul.version` | Represents the Consul version. | agents | gauge |