diff --git a/internal/mesh/internal/controllers/apigateways/controller.go b/internal/mesh/internal/controllers/apigateways/controller.go index 28f62d3b2c..d4d8cefcc3 100644 --- a/internal/mesh/internal/controllers/apigateways/controller.go +++ b/internal/mesh/internal/controllers/apigateways/controller.go @@ -5,12 +5,19 @@ package apigateways import ( "context" + "github.com/hashicorp/consul/internal/controller" + "github.com/hashicorp/consul/internal/mesh/internal/controllers/apigateways/fetcher" + "github.com/hashicorp/consul/internal/resource" + pbcatalog "github.com/hashicorp/consul/proto-public/pbcatalog/v2beta1" pbmesh "github.com/hashicorp/consul/proto-public/pbmesh/v2beta1" + "github.com/hashicorp/consul/proto-public/pbresource" + "google.golang.org/protobuf/types/known/anypb" ) const ( ControllerName = "consul.io/api-gateway" + GatewayKind = "api-gateway" ) func Controller() *controller.Controller { @@ -22,13 +29,73 @@ func Controller() *controller.Controller { type reconciler struct{} -// Reconcile is responsible for creating a Service w/ a MeshGateway owner, +// Reconcile is responsible for creating a Service w/ a APIGateway owner, // in addition to other things discussed in the RFC. func (r *reconciler) Reconcile(ctx context.Context, rt controller.Runtime, req controller.Request) error { rt.Logger = rt.Logger.With("resource-id", req.ID) rt.Logger.Trace("reconciling api gateway") - //TODO NET-7378 + dataFetcher := fetcher.New(rt.Client) + + decodedAPIGateway, err := dataFetcher.FetchAPIGateway(ctx, req.ID) + if err != nil { + rt.Logger.Trace("error reading the apigateway", "apigatewayID", req.ID, "error", err) + return err + } else if decodedAPIGateway == nil { + rt.Logger.Trace("apigateway not found", "apigatewayID", req.ID) + return nil + } + + apigw := decodedAPIGateway.Data + + ports := make([]*pbcatalog.ServicePort, 0, len(apigw.Listeners)) + + for _, listener := range apigw.Listeners { + ports = append(ports, &pbcatalog.ServicePort{ + Protocol: listenerProtocolToCatalogProtocol(listener.Protocol), + TargetPort: listener.Name, + VirtualPort: listener.Port, + }) + } + + service := &pbcatalog.Service{ + Workloads: &pbcatalog.WorkloadSelector{ + Prefixes: []string{req.ID.Name}, + }, + Ports: ports, + } + + serviceData, err := anypb.New(service) + if err != nil { + return err + } + + // TODO NET-7378 + _, err = rt.Client.Write(ctx, &pbresource.WriteRequest{ + Resource: &pbresource.Resource{ + Data: serviceData, + Id: resource.ReplaceType(pbcatalog.ServiceType, req.ID), + Metadata: map[string]string{"gateway-kind": GatewayKind}, + Owner: req.ID, + }, + }) + + if err != nil { + return err + } return nil } + +func listenerProtocolToCatalogProtocol(listenerProtocol string) pbcatalog.Protocol { + switch listenerProtocol { + case "http": + return pbcatalog.Protocol_PROTOCOL_HTTP + case "tcp": + return pbcatalog.Protocol_PROTOCOL_TCP + case "grpc": + return pbcatalog.Protocol_PROTOCOL_GRPC + default: + panic("this is a programmer error, the only available protocols are tcp/http/grpc") + } +} diff --git a/internal/mesh/internal/controllers/apigateways/controller_test.go b/internal/mesh/internal/controllers/apigateways/controller_test.go new file mode 100644 index 0000000000..37f570c8b8 --- /dev/null +++ b/internal/mesh/internal/controllers/apigateways/controller_test.go @@ -0,0 +1,156 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package apigateways + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + svctest "github.com/hashicorp/consul/agent/grpc-external/services/resource/testing" + "github.com/hashicorp/consul/internal/catalog" + "github.com/hashicorp/consul/internal/controller" + "github.com/hashicorp/consul/internal/mesh/internal/types" + "github.com/hashicorp/consul/internal/resource" + "github.com/hashicorp/consul/internal/resource/resourcetest" + pbcatalog "github.com/hashicorp/consul/proto-public/pbcatalog/v2beta1" + pbmesh "github.com/hashicorp/consul/proto-public/pbmesh/v2beta1" + "github.com/hashicorp/consul/proto-public/pbresource" + "github.com/hashicorp/consul/sdk/testutil" +) + +type apigatewayControllerSuite struct { + suite.Suite + + ctx context.Context + client pbresource.ResourceServiceClient + resourceClient *resourcetest.Client + rt controller.Runtime + + apiGateway *pbresource.Resource + + tenancies []*pbresource.Tenancy +} + +func (suite *apigatewayControllerSuite) SetupTest() { + suite.ctx = testutil.TestContext(suite.T()) + suite.tenancies = resourcetest.TestTenancies() + suite.client = svctest.NewResourceServiceBuilder(). + WithRegisterFns(types.Register, catalog.RegisterTypes). + WithTenancies(suite.tenancies...). + Run(suite.T()) + suite.resourceClient = resourcetest.NewClient(suite.client) + suite.rt = controller.Runtime{ + Client: suite.client, + Logger: testutil.Logger(suite.T()), + } +} + +func (suite *apigatewayControllerSuite) TestReconciler_Reconcile() { + suite.runTestCaseWithTenancies(func(tenancy *pbresource.Tenancy) { + r := reconciler{} + ctx := context.Background() + + testutil.RunStep(suite.T(), "api-gateway exists", func(t *testing.T) { + id := &pbresource.ID{ + Name: "api-gateway", + Type: &pbresource.Type{ + Group: "mesh", + GroupVersion: "v2beta1", + Kind: "APIGateway", + }, + Tenancy: tenancy, + } + + expectedWrittenService := &pbcatalog.Service{ + Workloads: &pbcatalog.WorkloadSelector{ + Prefixes: []string{"api-gateway"}, + }, + Ports: []*pbcatalog.ServicePort{ + { + VirtualPort: 9090, + TargetPort: "http-listener", + Protocol: pbcatalog.Protocol_PROTOCOL_HTTP, + }, + { + VirtualPort: 8080, + TargetPort: "tcp-listener", + Protocol: pbcatalog.Protocol_PROTOCOL_TCP, + }, + }, + } + req := controller.Request{ID: id} + err := r.Reconcile(ctx, suite.rt, req) + + require.NoError(t, err) + + dec, err := resource.GetDecodedResource[*pbcatalog.Service](ctx, suite.client, resource.ReplaceType(pbcatalog.ServiceType, req.ID)) + require.NoError(t, err) + require.Equal(t, dec.Data.Ports, expectedWrittenService.Ports) + require.Equal(t, dec.Data.Workloads, expectedWrittenService.Workloads) + }) + + testutil.RunStep(suite.T(), "api-gateway does not exist", func(t *testing.T) { + id := &pbresource.ID{ + Name: "does-not-exist", + Type: &pbresource.Type{ + Group: "mesh", + GroupVersion: "v2beta1", + Kind: "APIGateway", + }, + Tenancy: tenancy, + } + + req := controller.Request{ID: id} + err := r.Reconcile(ctx, suite.rt, req) + + require.NoError(t, err) + + dec, err := resource.GetDecodedResource[*pbcatalog.Service](ctx, suite.client, resource.ReplaceType(pbcatalog.ServiceType, req.ID)) + require.NoError(t, err) + require.Nil(t, dec) + }) + }) +} + +func TestAPIGatewayReconciler(t *testing.T) { + suite.Run(t, new(apigatewayControllerSuite)) +} + +func (suite *apigatewayControllerSuite) appendTenancyInfo(tenancy *pbresource.Tenancy) string { + return fmt.Sprintf("%s_Namespace_%s_Partition", tenancy.Namespace, tenancy.Partition) +} + +func (suite *apigatewayControllerSuite) setupSuiteWithTenancy(tenancy *pbresource.Tenancy) { + suite.apiGateway = resourcetest.Resource(pbmesh.APIGatewayType, "api-gateway"). + WithData(suite.T(), &pbmesh.APIGateway{ + GatewayClassName: "consul", + Listeners: []*pbmesh.APIGatewayListener{ + { + Name: "http-listener", + Port: 9090, + Protocol: "http", + }, + { + Name: "tcp-listener", + Port: 8080, + Protocol: "tcp", + }, + }, + }). + WithTenancy(tenancy). + Write(suite.T(), suite.client) +} + +func (suite *apigatewayControllerSuite) runTestCaseWithTenancies(t func(*pbresource.Tenancy)) { + for _, tenancy := range suite.tenancies { + suite.Run(suite.appendTenancyInfo(tenancy), func() { + suite.setupSuiteWithTenancy(tenancy) + t(tenancy) + }) + } +} diff --git a/internal/mesh/internal/controllers/apigateways/fetcher/data_fetcher.go b/internal/mesh/internal/controllers/apigateways/fetcher/data_fetcher.go new file mode 100644 index 0000000000..246adacf20 --- /dev/null +++ b/internal/mesh/internal/controllers/apigateways/fetcher/data_fetcher.go @@ -0,0 +1,44 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package fetcher + +import ( + "context" + "fmt" + + "github.com/hashicorp/consul/internal/mesh/internal/types" + "github.com/hashicorp/consul/internal/resource" + pbmesh "github.com/hashicorp/consul/proto-public/pbmesh/v2beta1" + "github.com/hashicorp/consul/proto-public/pbresource" + "google.golang.org/protobuf/proto" +) + +type Fetcher struct { + client pbresource.ResourceServiceClient +} + +func New(client pbresource.ResourceServiceClient) *Fetcher { + return &Fetcher{ + client: client, + } +} + +// method on fetcher to fetch an apigateway +func (f *Fetcher) FetchAPIGateway(ctx context.Context, id *pbresource.ID) (*types.DecodedAPIGateway, error) { + assertResourceType(pbmesh.APIGatewayType, id.Type) + + dec, err := resource.GetDecodedResource[*pbmesh.APIGateway](ctx, f.client, id) + if err != nil { + return nil, err + } + + return dec, nil +} + +func assertResourceType(expected, actual *pbresource.Type) { + if !proto.Equal(expected, actual) { + // this is always a programmer error so safe to panic + panic(fmt.Sprintf("expected a query for a type of %q, you provided a type of %q", expected, actual)) + } +} diff --git a/internal/mesh/internal/controllers/apigateways/fetcher/data_fetcher_test.go b/internal/mesh/internal/controllers/apigateways/fetcher/data_fetcher_test.go new file mode 100644 index 0000000000..4bf6feab55 --- /dev/null +++ b/internal/mesh/internal/controllers/apigateways/fetcher/data_fetcher_test.go @@ -0,0 +1,113 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package fetcher + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + svctest "github.com/hashicorp/consul/agent/grpc-external/services/resource/testing" + "github.com/hashicorp/consul/internal/catalog" + "github.com/hashicorp/consul/internal/controller" + "github.com/hashicorp/consul/internal/mesh/internal/types" + "github.com/hashicorp/consul/internal/resource/resourcetest" + pbmesh "github.com/hashicorp/consul/proto-public/pbmesh/v2beta1" + "github.com/hashicorp/consul/proto-public/pbresource" + "github.com/hashicorp/consul/sdk/testutil" +) + +type dataFetcherSuite struct { + suite.Suite + + ctx context.Context + client pbresource.ResourceServiceClient + resourceClient *resourcetest.Client + rt controller.Runtime + + apiGateway *pbresource.Resource + + tenancies []*pbresource.Tenancy +} + +func (suite *dataFetcherSuite) SetupTest() { + suite.ctx = testutil.TestContext(suite.T()) + suite.tenancies = resourcetest.TestTenancies() + suite.client = svctest.NewResourceServiceBuilder(). + WithRegisterFns(types.Register, catalog.RegisterTypes). + WithTenancies(suite.tenancies...). + Run(suite.T()) + suite.resourceClient = resourcetest.NewClient(suite.client) + suite.rt = controller.Runtime{ + Client: suite.client, + Logger: testutil.Logger(suite.T()), + } +} + +func (suite *dataFetcherSuite) setupWithTenancy(tenancy *pbresource.Tenancy) { + suite.apiGateway = resourcetest.Resource(pbmesh.APIGatewayType, "apigw"). + WithTenancy(tenancy). + WithData(suite.T(), &pbmesh.APIGateway{ + GatewayClassName: "consul", + Listeners: []*pbmesh.APIGatewayListener{}, + }). + Write(suite.T(), suite.client) +} + +func (suite *dataFetcherSuite) TestFetcher_FetchAPIGateway() { + suite.runTestCaseWithTenancies(func(tenancy *pbresource.Tenancy) { + f := Fetcher{ + client: suite.client, + } + + testutil.RunStep(suite.T(), "gateway does not exist", func(t *testing.T) { + nonExistantID := resourcetest.Resource(pbmesh.APIGatewayType, "not-found").WithTenancy(tenancy).ID() + svc, err := f.FetchAPIGateway(suite.ctx, nonExistantID) + require.NoError(t, err) + require.Nil(t, svc) + }) + + testutil.RunStep(suite.T(), "gateway exists", func(t *testing.T) { + svc, err := f.FetchAPIGateway(suite.ctx, suite.apiGateway.Id) + require.NoError(t, err) + require.NotNil(t, svc) + }) + + testutil.RunStep(suite.T(), "incorrect type is passed", func(t *testing.T) { + incorrectID := resourcetest.Resource(pbmesh.ProxyStateTemplateType, "api-1").ID() + defer func() { + err := recover() + require.NotNil(t, err) + }() + f.FetchAPIGateway(suite.ctx, incorrectID) + }) + }) +} + +func TestDataFetcher(t *testing.T) { + suite.Run(t, new(dataFetcherSuite)) +} + +func (suite *dataFetcherSuite) appendTenancyInfo(tenancy *pbresource.Tenancy) string { + return fmt.Sprintf("%s_Namespace_%s_Partition", tenancy.Namespace, tenancy.Partition) +} + +func (suite *dataFetcherSuite) cleanUpNodes() { + suite.resourceClient.MustDelete(suite.T(), suite.apiGateway.Id) +} + +func (suite *dataFetcherSuite) runTestCaseWithTenancies(t func(*pbresource.Tenancy)) { + for _, tenancy := range suite.tenancies { + suite.Run(suite.appendTenancyInfo(tenancy), func() { + suite.setupWithTenancy(tenancy) + suite.T().Cleanup(func() { + suite.cleanUpNodes() + }) + t(tenancy) + }) + } +} diff --git a/internal/mesh/internal/controllers/gatewayproxy/controller.go b/internal/mesh/internal/controllers/gatewayproxy/controller.go index ef276c22cc..9443c8993e 100644 --- a/internal/mesh/internal/controllers/gatewayproxy/controller.go +++ b/internal/mesh/internal/controllers/gatewayproxy/controller.go @@ -11,8 +11,10 @@ import ( "github.com/hashicorp/consul/internal/controller" "github.com/hashicorp/consul/internal/controller/dependency" + "github.com/hashicorp/consul/internal/mesh/internal/controllers/apigateways" "github.com/hashicorp/consul/internal/mesh/internal/controllers/gatewayproxy/builder" "github.com/hashicorp/consul/internal/mesh/internal/controllers/gatewayproxy/fetcher" + "github.com/hashicorp/consul/internal/mesh/internal/controllers/meshgateways" "github.com/hashicorp/consul/internal/mesh/internal/controllers/sidecarproxy" "github.com/hashicorp/consul/internal/mesh/internal/controllers/sidecarproxy/cache" "github.com/hashicorp/consul/internal/resource" @@ -73,12 +75,21 @@ func (r *reconciler) Reconcile(ctx context.Context, rt controller.Runtime, req c return nil } - // If the workload is not for a xGateway, let the sidecarproxy reconciler handle it - if gatewayKind := workload.Metadata["gateway-kind"]; gatewayKind == "" { - rt.Logger.Trace("workload is not a gateway; skipping reconciliation", "workload", workloadID, "workloadData", workload.Data) + switch workload.Metadata["gateway-type"] { + case meshgateways.GatewayKind: + rt.Logger.Trace("workload is a mesh-gateway; reconciling", "workload", workloadID, "workloadData", workload.Data) + return r.reconcileMeshGatewayProxyState(ctx, dataFetcher, workload, rt, req) + case apigateways.GatewayKind: + rt.Logger.Trace("workload is a api-gateway; reconciling", "workload", workloadID, "workloadData", workload.Data) + // TODO: NET-735 -- implement api-gateway reconciliation + return nil + default: + rt.Logger.Trace("workload is not a gateway; skipping reconciliation", "workload", workloadID) return nil } +} +func (r *reconciler) reconcileMeshGatewayProxyState(ctx context.Context, dataFetcher *fetcher.Fetcher, workload *resource.DecodedResource[*pbcatalog.Workload], rt controller.Runtime, req controller.Request) error { proxyStateTemplate, err := dataFetcher.FetchProxyStateTemplate(ctx, req.ID) if err != nil { rt.Logger.Error("error reading proxy state template", "error", err) diff --git a/internal/mesh/internal/types/api_gateway.go b/internal/mesh/internal/types/api_gateway.go new file mode 100644 index 0000000000..6b9570e2c2 --- /dev/null +++ b/internal/mesh/internal/types/api_gateway.go @@ -0,0 +1,20 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package types + +import ( + "github.com/hashicorp/consul/internal/resource" + pbmesh "github.com/hashicorp/consul/proto-public/pbmesh/v2beta1" +) + +func RegisterAPIGateway(r resource.Registry) { + r.Register(resource.Registration{ + Type: pbmesh.APIGatewayType, + Proto: &pbmesh.APIGateway{}, + Scope: resource.ScopeNamespace, + ACLs: nil, // TODO NET-7289 + Mutate: nil, // TODO NET-7617 + Validate: nil, // TODO NET-7618 + }) +} diff --git a/internal/mesh/internal/types/decoded.go b/internal/mesh/internal/types/decoded.go index 0e3cbc56a5..6fa406f755 100644 --- a/internal/mesh/internal/types/decoded.go +++ b/internal/mesh/internal/types/decoded.go @@ -30,4 +30,5 @@ type ( DecodedProxyStateTemplate = resource.DecodedResource[*pbmesh.ProxyStateTemplate] DecodedMeshGateway = resource.DecodedResource[*pbmesh.MeshGateway] DecodedComputedExportedServices = resource.DecodedResource[*pbmulticluster.ComputedExportedServices] + DecodedAPIGateway = resource.DecodedResource[*pbmesh.APIGateway] ) diff --git a/internal/mesh/internal/types/types.go b/internal/mesh/internal/types/types.go index d98e820e82..5eba4b8e89 100644 --- a/internal/mesh/internal/types/types.go +++ b/internal/mesh/internal/types/types.go @@ -19,7 +19,8 @@ func Register(r resource.Registry) { RegisterDestinationPolicy(r) RegisterComputedRoutes(r) RegisterMeshGateway(r) + RegisterAPIGateway(r) RegisterMeshConfiguration(r) // todo (v2): uncomment once we implement it. - //RegisterDestinationsConfiguration(r) + // RegisterDestinationsConfiguration(r) }