diff --git a/agent/consul/state/config_entry.go b/agent/consul/state/config_entry.go index 7ab78ac6ce..adc6d4e354 100644 --- a/agent/consul/state/config_entry.go +++ b/agent/consul/state/config_entry.go @@ -360,6 +360,7 @@ func validateProposedConfigEntryInGraph( } case structs.ServiceIntentions: case structs.MeshConfig: + case structs.ServiceExports: default: return fmt.Errorf("unhandled kind %q during validation of %q", kindName.Kind, kindName.Name) } diff --git a/agent/consul/usagemetrics/usagemetrics_oss_test.go b/agent/consul/usagemetrics/usagemetrics_oss_test.go index 50e41c88b1..80fe3057f1 100644 --- a/agent/consul/usagemetrics/usagemetrics_oss_test.go +++ b/agent/consul/usagemetrics/usagemetrics_oss_test.go @@ -177,6 +177,14 @@ func TestUsageReporter_emitNodeUsage_OSS(t *testing.T) { {Name: "kind", Value: "terminating-gateway"}, }, }, + "consul.usage.test.consul.state.config_entries;datacenter=dc1;kind=service-exports": { + Name: "consul.usage.test.consul.state.config_entries", + Value: 0, + Labels: []metrics.Label{ + {Name: "datacenter", Value: "dc1"}, + {Name: "kind", Value: "service-exports"}, + }, + }, }, getMembersFunc: func() []serf.Member { return []serf.Member{} }, }, @@ -354,6 +362,14 @@ func TestUsageReporter_emitNodeUsage_OSS(t *testing.T) { {Name: "kind", Value: "terminating-gateway"}, }, }, + "consul.usage.test.consul.state.config_entries;datacenter=dc1;kind=service-exports": { + Name: "consul.usage.test.consul.state.config_entries", + Value: 0, + Labels: []metrics.Label{ + {Name: "datacenter", Value: "dc1"}, + {Name: "kind", Value: "service-exports"}, + }, + }, }, }, } @@ -559,6 +575,14 @@ func TestUsageReporter_emitServiceUsage_OSS(t *testing.T) { {Name: "kind", Value: "terminating-gateway"}, }, }, + "consul.usage.test.consul.state.config_entries;datacenter=dc1;kind=service-exports": { + Name: "consul.usage.test.consul.state.config_entries", + Value: 0, + Labels: []metrics.Label{ + {Name: "datacenter", Value: "dc1"}, + {Name: "kind", Value: "service-exports"}, + }, + }, }, getMembersFunc: func() []serf.Member { return []serf.Member{} }, }, @@ -778,6 +802,14 @@ func TestUsageReporter_emitServiceUsage_OSS(t *testing.T) { {Name: "kind", Value: "terminating-gateway"}, }, }, + "consul.usage.test.consul.state.config_entries;datacenter=dc1;kind=service-exports": { + Name: "consul.usage.test.consul.state.config_entries", + Value: 0, + Labels: []metrics.Label{ + {Name: "datacenter", Value: "dc1"}, + {Name: "kind", Value: "service-exports"}, + }, + }, }, }, } @@ -974,6 +1006,14 @@ func TestUsageReporter_emitKVUsage_OSS(t *testing.T) { {Name: "kind", Value: "terminating-gateway"}, }, }, + "consul.usage.test.consul.state.config_entries;datacenter=dc1;kind=service-exports": { + Name: "consul.usage.test.consul.state.config_entries", + Value: 0, + Labels: []metrics.Label{ + {Name: "datacenter", Value: "dc1"}, + {Name: "kind", Value: "service-exports"}, + }, + }, }, getMembersFunc: func() []serf.Member { return []serf.Member{} }, }, @@ -1160,6 +1200,14 @@ func TestUsageReporter_emitKVUsage_OSS(t *testing.T) { {Name: "kind", Value: "terminating-gateway"}, }, }, + "consul.usage.test.consul.state.config_entries;datacenter=dc1;kind=service-exports": { + Name: "consul.usage.test.consul.state.config_entries", + Value: 0, + Labels: []metrics.Label{ + {Name: "datacenter", Value: "dc1"}, + {Name: "kind", Value: "service-exports"}, + }, + }, }, }, } diff --git a/agent/structs/config_entry.go b/agent/structs/config_entry.go index bcb7c59593..0bf603eead 100644 --- a/agent/structs/config_entry.go +++ b/agent/structs/config_entry.go @@ -27,6 +27,7 @@ const ( TerminatingGateway string = "terminating-gateway" ServiceIntentions string = "service-intentions" MeshConfig string = "mesh" + ServiceExports string = "service-exports" ProxyConfigGlobal string = "global" MeshConfigMesh string = "mesh" @@ -44,6 +45,7 @@ var AllConfigEntryKinds = []string{ TerminatingGateway, ServiceIntentions, MeshConfig, + ServiceExports, } // ConfigEntry is the interface for centralized configuration stored in Raft. @@ -530,6 +532,8 @@ func MakeConfigEntry(kind, name string) (ConfigEntry, error) { return &ServiceIntentionsConfigEntry{Name: name}, nil case MeshConfig: return &MeshConfigEntry{}, nil + case ServiceExports: + return &ServiceExportsConfigEntry{Partition: name}, nil default: return nil, fmt.Errorf("invalid config entry kind: %s", kind) } diff --git a/agent/structs/config_entry_exports.go b/agent/structs/config_entry_exports.go new file mode 100644 index 0000000000..5b06983009 --- /dev/null +++ b/agent/structs/config_entry_exports.go @@ -0,0 +1,145 @@ +package structs + +import ( + "fmt" + + "github.com/hashicorp/consul/acl" +) + +// ServiceExportsConfigEntry is the top-level struct for exporting a service to be exposed +// across other admin partitions. +type ServiceExportsConfigEntry struct { + Partition string + + // Services is a list of services to be exported and the list of partitions + // to expose them to. + Services []ExportedService + + Meta map[string]string `json:",omitempty"` + EnterpriseMeta `hcl:",squash" mapstructure:",squash"` + RaftIndex +} + +// ExportedService manages the exporting of a service in the local partition to +// other partitions. +type ExportedService struct { + // Name is the name of the service to be exported. + Name string + + // Namespace is the namespace to export the service from. + Namespace string `json:",omitempty"` + + // Consumers is a list of downstream consumers of the service to be exported. + Consumers []ServiceConsumer +} + +// ServiceConsumer represents a downstream consumer of the service to be exported. +type ServiceConsumer struct { + // Partition is the admin partition to export the service to. + Partition string +} + +func (e *ServiceExportsConfigEntry) Clone() *ServiceExportsConfigEntry { + e2 := *e + e2.Services = make([]ExportedService, len(e.Services)) + for _, svc := range e.Services { + exportedSvc := svc + exportedSvc.Consumers = make([]ServiceConsumer, len(svc.Consumers)) + for _, consumer := range svc.Consumers { + exportedSvc.Consumers = append(exportedSvc.Consumers, consumer) + } + e2.Services = append(e2.Services, exportedSvc) + } + + return &e2 +} + +func (e *ServiceExportsConfigEntry) GetKind() string { + return ServiceExports +} + +func (e *ServiceExportsConfigEntry) GetName() string { + if e == nil { + return "" + } + + return e.Partition +} + +func (e *ServiceExportsConfigEntry) GetMeta() map[string]string { + if e == nil { + return nil + } + return e.Meta +} + +func (e *ServiceExportsConfigEntry) Normalize() error { + if e == nil { + return fmt.Errorf("config entry is nil") + } + + meta := DefaultEnterpriseMetaInPartition(e.Partition) + e.EnterpriseMeta.Merge(meta) + e.EnterpriseMeta.Normalize() + + for i := range e.Services { + e.Services[i].Namespace = NamespaceOrDefault(e.Services[i].Namespace) + } + + return nil +} + +func (e *ServiceExportsConfigEntry) Validate() error { + if e.Partition == "" { + return fmt.Errorf("Partition is required") + } + if e.Partition == WildcardSpecifier { + return fmt.Errorf("service-exports Partition must be the name of a partition, and not a wildcard") + } + + validationErr := validateConfigEntryMeta(e.Meta) + + for _, svc := range e.Services { + if svc.Name == "" { + return fmt.Errorf("service name cannot be empty") + } + if len(svc.Consumers) == 0 { + return fmt.Errorf("service %q must have at least one consumer", svc.Name) + } + for _, consumer := range svc.Consumers { + if consumer.Partition == WildcardSpecifier { + return fmt.Errorf("exporting to all partitions (wildcard) is not yet supported") + } + } + } + + return validationErr +} + +func (e *ServiceExportsConfigEntry) CanRead(authz acl.Authorizer) bool { + var authzContext acl.AuthorizerContext + e.FillAuthzContext(&authzContext) + return authz.MeshRead(&authzContext) == acl.Allow +} + +func (e *ServiceExportsConfigEntry) CanWrite(authz acl.Authorizer) bool { + var authzContext acl.AuthorizerContext + e.FillAuthzContext(&authzContext) + return authz.MeshWrite(&authzContext) == acl.Allow +} + +func (e *ServiceExportsConfigEntry) GetRaftIndex() *RaftIndex { + if e == nil { + return &RaftIndex{} + } + + return &e.RaftIndex +} + +func (e *ServiceExportsConfigEntry) GetEnterpriseMeta() *EnterpriseMeta { + if e == nil { + return nil + } + + return &e.EnterpriseMeta +} diff --git a/agent/structs/config_entry_test.go b/agent/structs/config_entry_test.go index 7b82c2dd10..92202d5cc0 100644 --- a/agent/structs/config_entry_test.go +++ b/agent/structs/config_entry_test.go @@ -1664,6 +1664,102 @@ func TestDecodeConfigEntry(t *testing.T) { }, }, }, + { + name: "service-exports", + snake: ` + kind = "service-exports" + partition = "foo" + meta { + "foo" = "bar" + "gir" = "zim" + } + services = [ + { + name = "web" + namespace = "foo" + consumers = [ + { + partition = "bar" + }, + { + partition = "baz" + } + ] + }, + { + name = "db" + namespace = "bar" + consumers = [ + { + partition = "zoo" + } + ] + } + ] + `, + camel: ` + Kind = "service-exports" + Partition = "foo" + Meta { + "foo" = "bar" + "gir" = "zim" + } + Services = [ + { + Name = "web" + Namespace = "foo" + Consumers = [ + { + Partition = "bar" + }, + { + Partition = "baz" + } + ] + }, + { + Name = "db" + Namespace = "bar" + Consumers = [ + { + Partition = "zoo" + } + ] + } + ] + `, + expect: &ServiceExportsConfigEntry{ + Partition: "foo", + Meta: map[string]string{ + "foo": "bar", + "gir": "zim", + }, + Services: []ExportedService{ + { + Name: "web", + Namespace: "foo", + Consumers: []ServiceConsumer{ + { + Partition: "bar", + }, + { + Partition: "baz", + }, + }, + }, + { + Name: "db", + Namespace: "bar", + Consumers: []ServiceConsumer{ + { + Partition: "zoo", + }, + }, + }, + }, + EnterpriseMeta: NewEnterpriseMetaWithPartition("foo", ""), + }, + }, } { tc := tc diff --git a/api/config_entry.go b/api/config_entry.go index f71b248396..db231735c2 100644 --- a/api/config_entry.go +++ b/api/config_entry.go @@ -22,6 +22,7 @@ const ( TerminatingGateway string = "terminating-gateway" ServiceIntentions string = "service-intentions" MeshConfig string = "mesh" + ServiceExports string = "service-exports" ProxyConfigGlobal string = "global" MeshConfigMesh string = "mesh" @@ -276,6 +277,8 @@ func makeConfigEntry(kind, name string) (ConfigEntry, error) { return &ServiceIntentionsConfigEntry{Kind: kind, Name: name}, nil case MeshConfig: return &MeshConfigEntry{}, nil + case ServiceExports: + return &ServiceExportsConfigEntry{Partition: name}, nil default: return nil, fmt.Errorf("invalid config entry kind: %s", kind) } diff --git a/api/config_entry_exports.go b/api/config_entry_exports.go new file mode 100644 index 0000000000..d2b68eeae5 --- /dev/null +++ b/api/config_entry_exports.go @@ -0,0 +1,67 @@ +package api + +import "encoding/json" + +// ServiceExportsConfigEntry manages the exported services for a single admin partition. +// Admin Partitions are a Consul Enterprise feature. +type ServiceExportsConfigEntry struct { + // Partition is the partition the ServiceExportsConfigEntry applies to. + // Partitioning is a Consul Enterprise feature. + Partition string `json:",omitempty"` + + // Services is a list of services to be exported and the list of partitions + // to expose them to. + Services []ExportedService + + Meta map[string]string `json:",omitempty"` + + // CreateIndex is the Raft index this entry was created at. This is a + // read-only field. + CreateIndex uint64 + + // ModifyIndex is used for the Check-And-Set operations and can also be fed + // back into the WaitIndex of the QueryOptions in order to perform blocking + // queries. + ModifyIndex uint64 +} + +// ExportedService manages the exporting of a service in the local partition to +// other partitions. +type ExportedService struct { + // Name is the name of the service to be exported. + Name string + + // Namespace is the namespace to export the service from. + Namespace string `json:",omitempty"` + + // Consumers is a list of downstream consumers of the service to be exported. + Consumers []ServiceConsumer +} + +// ServiceConsumer represents a downstream consumer of the service to be exported. +type ServiceConsumer struct { + // Partition is the admin partition to export the service to. + Partition string +} + +func (e *ServiceExportsConfigEntry) GetKind() string { return ServiceExports } +func (e *ServiceExportsConfigEntry) GetName() string { return e.Partition } +func (e *ServiceExportsConfigEntry) GetPartition() string { return e.Partition } +func (e *ServiceExportsConfigEntry) GetNamespace() string { return IntentionDefaultNamespace } +func (e *ServiceExportsConfigEntry) GetMeta() map[string]string { return e.Meta } +func (e *ServiceExportsConfigEntry) GetCreateIndex() uint64 { return e.CreateIndex } +func (e *ServiceExportsConfigEntry) GetModifyIndex() uint64 { return e.ModifyIndex } + +// MarshalJSON adds the Kind field so that the JSON can be decoded back into the +// correct type. +func (e *ServiceExportsConfigEntry) MarshalJSON() ([]byte, error) { + type Alias ServiceExportsConfigEntry + source := &struct { + Kind string + *Alias + }{ + Kind: ServiceExports, + Alias: (*Alias)(e), + } + return json.Marshal(source) +} diff --git a/command/config/write/config_write_test.go b/command/config/write/config_write_test.go index 8856acf833..6496f837ae 100644 --- a/command/config/write/config_write_test.go +++ b/command/config/write/config_write_test.go @@ -2721,6 +2721,167 @@ func TestParseConfigEntry(t *testing.T) { }, }, }, + { + name: "service-exports", + snake: ` + kind = "service-exports" + partition = "foo" + meta { + "foo" = "bar" + "gir" = "zim" + } + services = [ + { + name = "web" + namespace = "foo" + consumers = [ + { + partition = "bar" + }, + { + partition = "baz" + } + ] + }, + { + name = "db" + namespace = "bar" + consumers = [ + { + partition = "zoo" + } + ] + } + ] + `, + camel: ` + Kind = "service-exports" + Partition = "foo" + Meta { + "foo" = "bar" + "gir" = "zim" + } + Services = [ + { + Name = "web" + Namespace = "foo" + Consumers = [ + { + Partition = "bar" + }, + { + Partition = "baz" + } + ] + }, + { + Name = "db" + Namespace = "bar" + Consumers = [ + { + Partition = "zoo" + } + ] + } + ] + `, + snakeJSON: ` + { + "kind": "service-exports", + "partition": "foo", + "meta": { + "foo": "bar", + "gir": "zim" + }, + "services": [ + { + "name": "web", + "namespace": "foo", + "consumers": [ + { + "partition": "bar" + }, + { + "partition": "baz" + } + ] + }, + { + "name": "db", + "namespace": "bar", + "consumers": [ + { + "partition": "zoo" + } + ] + } + ] + } + `, + camelJSON: ` + { + "Kind": "service-exports", + "Partition": "foo", + "Meta": { + "foo": "bar", + "gir": "zim" + }, + "Services": [ + { + "Name": "web", + "Namespace": "foo", + "Consumers": [ + { + "Partition": "bar" + }, + { + "Partition": "baz" + } + ] + }, + { + "Name": "db", + "Namespace": "bar", + "Consumers": [ + { + "Partition": "zoo" + } + ] + } + ] + } + `, + expect: &api.ServiceExportsConfigEntry{ + Partition: "foo", + Meta: map[string]string{ + "foo": "bar", + "gir": "zim", + }, + Services: []api.ExportedService{ + { + Name: "web", + Namespace: "foo", + Consumers: []api.ServiceConsumer{ + { + Partition: "bar", + }, + { + Partition: "baz", + }, + }, + }, + { + Name: "db", + Namespace: "bar", + Consumers: []api.ServiceConsumer{ + { + Partition: "zoo", + }, + }, + }, + }, + }, + }, } { tc := tc