From 3dc5a4ffc21a837dd2b128aaf8abf46cd3bf0230 Mon Sep 17 00:00:00 2001 From: Marco Dinis Date: Fri, 22 Nov 2024 14:59:58 +0000 Subject: [PATCH] AWS OIDC: List Deployed Database Services HTTP API This PR adds a new endpoint which returns the deployed database services. Calling the ECS APIs requires a region, so we had to iterate over the following resources to collect the relevant regions: - databases - database services - discovery configs --- .../deployservice_config.go | 23 ++ .../deployservice_config_test.go | 44 +++ lib/web/apiserver.go | 1 + lib/web/integrations_awsoidc.go | 198 ++++++++++++ lib/web/integrations_awsoidc_test.go | 295 ++++++++++++++++++ lib/web/ui/integration.go | 20 ++ 6 files changed, 581 insertions(+) diff --git a/lib/integrations/awsoidc/deployserviceconfig/deployservice_config.go b/lib/integrations/awsoidc/deployserviceconfig/deployservice_config.go index 1f2624b94d6c7..54c83f5e11c3c 100644 --- a/lib/integrations/awsoidc/deployserviceconfig/deployservice_config.go +++ b/lib/integrations/awsoidc/deployserviceconfig/deployservice_config.go @@ -89,3 +89,26 @@ func GenerateTeleportConfigString(proxyHostPort, iamTokenName string, resourceMa return teleportConfigString, nil } + +// ParseResourceLabelMatchers receives a teleport config string and returns the Resource Matcher Label. +// The expected input is a base64 encoded yaml string containing a teleport configuration, +// the same format that GenerateTeleportConfigString returns. +func ParseResourceLabelMatchers(teleportConfigStringBase64 string) (types.Labels, error) { + teleportConfigString, err := base64.StdEncoding.DecodeString(teleportConfigStringBase64) + if err != nil { + return nil, trace.BadParameter("invalid base64 value, error=%v", err) + } + + var teleportConfig *config.FileConfig + if err := yaml.Unmarshal(teleportConfigString, &teleportConfig); err != nil { + return nil, trace.BadParameter("invalid teleport config, error=%v", err) + } + + if len(teleportConfig.Databases.ResourceMatchers) == 0 { + return nil, trace.BadParameter("valid yaml configuration but db_service.resources has 0 items") + } + + resourceMatchers := teleportConfig.Databases.ResourceMatchers[0] + + return resourceMatchers.Labels, nil +} diff --git a/lib/integrations/awsoidc/deployserviceconfig/deployservice_config_test.go b/lib/integrations/awsoidc/deployserviceconfig/deployservice_config_test.go index 1f47d96e2dac4..3b40912ac9160 100644 --- a/lib/integrations/awsoidc/deployserviceconfig/deployservice_config_test.go +++ b/lib/integrations/awsoidc/deployserviceconfig/deployservice_config_test.go @@ -23,8 +23,10 @@ import ( "testing" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/utils" ) func TestDeployServiceConfig(t *testing.T) { @@ -39,3 +41,45 @@ func TestDeployServiceConfig(t *testing.T) { require.Contains(t, base64Config, base64SeverityDebug) }) } + +func TestParseResourceLabelMatchers(t *testing.T) { + labels := types.Labels{ + "vpc": utils.Strings{"vpc-1", "vpc-2"}, + "region": utils.Strings{"us-west-2"}, + "xyz": utils.Strings{}, + } + base64Config, err := GenerateTeleportConfigString("host:port", "iam-token", labels) + require.NoError(t, err) + + t.Run("recover matching labels", func(t *testing.T) { + gotLabels, err := ParseResourceLabelMatchers(base64Config) + require.NoError(t, err) + + require.Equal(t, labels, gotLabels) + }) + + t.Run("fails if invalid base64 string", func(t *testing.T) { + _, err := ParseResourceLabelMatchers("invalid base 64") + require.ErrorContains(t, err, "base64") + }) + + t.Run("invalid yaml", func(t *testing.T) { + input := base64.StdEncoding.EncodeToString([]byte("invalid yaml")) + _, err := ParseResourceLabelMatchers(input) + require.ErrorContains(t, err, "yaml") + }) + + t.Run("valid yaml but not a teleport config", func(t *testing.T) { + yamlInput := struct { + DBService string `yaml:"db_service"` + }{ + DBService: "not a valid teleport config", + } + yamlBS, err := yaml.Marshal(yamlInput) + require.NoError(t, err) + input := base64.StdEncoding.EncodeToString(yamlBS) + + _, err = ParseResourceLabelMatchers(input) + require.ErrorContains(t, err, "invalid teleport config") + }) +} diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index 221e104b3b2db..533bc467c1d4a 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -991,6 +991,7 @@ func (h *Handler) bindDefaultEndpoints() { h.GET("/webapi/scripts/integrations/configure/listdatabases-iam.sh", h.WithLimiter(h.awsOIDCConfigureListDatabasesIAM)) h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/deployservice", h.WithClusterAuth(h.awsOIDCDeployService)) h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/deploydatabaseservices", h.WithClusterAuth(h.awsOIDCDeployDatabaseServices)) + h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/listdeployeddatabaseservices", h.WithClusterAuth(h.awsOIDCListDeployedDatabaseService)) h.GET("/webapi/scripts/integrations/configure/deployservice-iam.sh", h.WithLimiter(h.awsOIDCConfigureDeployServiceIAM)) h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/ec2", h.WithClusterAuth(h.awsOIDCListEC2)) h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/eksclusters", h.WithClusterAuth(h.awsOIDCListEKSClusters)) diff --git a/lib/web/integrations_awsoidc.go b/lib/web/integrations_awsoidc.go index 39ebb250a9452..e3afc66fc7579 100644 --- a/lib/web/integrations_awsoidc.go +++ b/lib/web/integrations_awsoidc.go @@ -23,6 +23,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "log/slog" "net/http" "net/url" "slices" @@ -31,6 +32,7 @@ import ( "github.com/google/safetext/shsprintf" "github.com/gravitational/trace" "github.com/julienschmidt/httprouter" + "google.golang.org/grpc" "github.com/gravitational/teleport" "github.com/gravitational/teleport/api/client" @@ -38,6 +40,7 @@ import ( apidefaults "github.com/gravitational/teleport/api/defaults" integrationv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/integration/v1" "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/types/discoveryconfig" "github.com/gravitational/teleport/api/utils" "github.com/gravitational/teleport/api/utils/aws" "github.com/gravitational/teleport/lib/auth/authclient" @@ -48,6 +51,7 @@ import ( kubeutils "github.com/gravitational/teleport/lib/kube/utils" "github.com/gravitational/teleport/lib/reversetunnelclient" "github.com/gravitational/teleport/lib/services" + libui "github.com/gravitational/teleport/lib/ui" libutils "github.com/gravitational/teleport/lib/utils" "github.com/gravitational/teleport/lib/utils/oidc" "github.com/gravitational/teleport/lib/web/scripts/oneoff" @@ -253,6 +257,200 @@ func (h *Handler) awsOIDCDeployDatabaseServices(w http.ResponseWriter, r *http.R }, nil } +// awsOIDCListDeployedDatabaseService lists the deployed Database Services in Amazon ECS. +func (h *Handler) awsOIDCListDeployedDatabaseService(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (any, error) { + ctx := r.Context() + clt, err := sctx.GetUserClient(ctx, site) + if err != nil { + return nil, trace.Wrap(err) + } + + integrationName := p.ByName("name") + if integrationName == "" { + return nil, trace.BadParameter("an integration name is required") + } + + regions, err := fetchRelevantAWSRegions(ctx, clt, clt.DiscoveryConfigClient()) + if err != nil { + return nil, trace.Wrap(err) + } + + services, err := listDeployedDatabaseServices(ctx, h.logger, integrationName, regions, clt.IntegrationAWSOIDCClient()) + if err != nil { + return nil, trace.Wrap(err) + } + + return ui.AWSOIDCListDeployedDatabaseServiceResponse{ + Services: services, + }, nil +} + +func fetchRelevantAWSRegions(ctx context.Context, + authClient interface { + GetResources(ctx context.Context, req *proto.ListResourcesRequest) (*proto.ListResourcesResponse, error) + GetDatabases(context.Context) ([]types.Database, error) + }, + discoveryConfigsClient interface { + ListDiscoveryConfigs(ctx context.Context, pageSize int, nextToken string) ([]*discoveryconfig.DiscoveryConfig, string, error) + }, +) ([]string, error) { + regionsSet := make(map[string]struct{}) + + // Collect Regions from Database resources. + databases, err := authClient.GetDatabases(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + + for _, resource := range databases { + regionsSet[resource.GetAWS().Region] = struct{}{} + regionsSet[resource.GetAllLabels()[types.DiscoveryLabelRegion]] = struct{}{} + } + + // Iterate over all DatabaseServices and fetch their AWS Region in the matchers. + var nextPageKey string + for { + req := &proto.ListResourcesRequest{ + ResourceType: types.KindDatabaseService, + Limit: defaults.MaxIterationLimit, + StartKey: nextPageKey, + } + page, err := client.GetResourcePage[types.DatabaseService](ctx, authClient, req) + if err != nil { + return nil, trace.Wrap(err) + } + for _, resource := range page.Resources { + for _, matcher := range resource.GetResourceMatchers() { + if matcher.Labels == nil { + continue + } + for labelKey, labelValues := range *matcher.Labels { + if labelKey != types.DiscoveryLabelRegion { + continue + } + for _, labelValue := range labelValues { + regionsSet[labelValue] = struct{}{} + } + } + } + } + if page.NextKey == "" { + break + } + nextPageKey = page.NextKey + } + + // Iterate over all DiscoveryConfigs and fetch their AWS Region in AWS Matchers. + nextPageKey = "" + for { + resp, respNextPageKey, err := discoveryConfigsClient.ListDiscoveryConfigs(ctx, defaults.MaxIterationLimit, nextPageKey) + if err != nil { + return nil, trace.Wrap(err) + } + + for _, dc := range resp { + for _, awsMatcher := range dc.Spec.AWS { + for _, region := range awsMatcher.Regions { + regionsSet[region] = struct{}{} + } + } + } + + if respNextPageKey == "" { + break + } + nextPageKey = respNextPageKey + } + + // Drop any invalid region. + ret := make([]string, 0, len(regionsSet)) + for region := range regionsSet { + if aws.IsValidRegion(region) == nil { + ret = append(ret, region) + } + } + + return ret, nil +} + +func listDeployedDatabaseServices(ctx context.Context, + logger *slog.Logger, + integrationName string, + regions []string, + awsoidcClient interface { + ListDeployedDatabaseServices(ctx context.Context, in *integrationv1.ListDeployedDatabaseServicesRequest, opts ...grpc.CallOption) (*integrationv1.ListDeployedDatabaseServicesResponse, error) + }, +) ([]ui.AWSOIDCDeployedDatabaseService, error) { + var services []ui.AWSOIDCDeployedDatabaseService + for _, region := range regions { + var nextToken string + for { + resp, err := awsoidcClient.ListDeployedDatabaseServices(ctx, &integrationv1.ListDeployedDatabaseServicesRequest{ + Integration: integrationName, + Region: region, + NextToken: nextToken, + }) + if err != nil { + return nil, trace.Wrap(err) + } + + for _, deployedDatabaseService := range resp.DeployedDatabaseServices { + matchingLabels, err := matchingLabelsFromDeployedService(deployedDatabaseService) + if err != nil { + logger.WarnContext(ctx, "Failed to obtain teleport config string from ECS Service", + "ecs_service", deployedDatabaseService.ServiceDashboardUrl, + "error", err, + ) + } + validTeleportConfigFound := err == nil + + services = append(services, ui.AWSOIDCDeployedDatabaseService{ + Name: deployedDatabaseService.Name, + DashboardURL: deployedDatabaseService.ServiceDashboardUrl, + MatchingLabels: matchingLabels, + ValidTeleportConfig: validTeleportConfigFound, + }) + } + + if resp.NextToken == "" { + break + } + nextToken = resp.NextToken + } + } + return services, nil +} + +func matchingLabelsFromDeployedService(deployedDatabaseService *integrationv1.DeployedDatabaseService) ([]libui.Label, error) { + commandArgs := deployedDatabaseService.ContainerCommand + // This command is what starts the teleport agent in the ECS Service Fargate container. + // See deployservice.go/upsertTask for details. + // It is expected to have at least 3 values, even if dumb-init is removed in the future. + if len(commandArgs) < 3 { + return nil, trace.BadParameter("unexpected command size, expected at least 3 args, got %d", len(commandArgs)) + } + + // The --config-string flag's value is the last argument. + teleportConfigString := commandArgs[len(commandArgs)-1] + + labelMatchers, err := deployserviceconfig.ParseResourceLabelMatchers(teleportConfigString) + if err != nil { + return nil, trace.Wrap(err) + } + + var matchingLabels []libui.Label + for labelKey, labelValues := range labelMatchers { + for _, labelValue := range labelValues { + matchingLabels = append(matchingLabels, libui.Label{ + Name: labelKey, + Value: labelValue, + }) + } + } + + return matchingLabels, nil +} + // awsOIDCConfigureDeployServiceIAM returns a script that configures the required IAM permissions to enable the usage of DeployService action. func (h *Handler) awsOIDCConfigureDeployServiceIAM(w http.ResponseWriter, r *http.Request, p httprouter.Params) (any, error) { ctx := r.Context() diff --git a/lib/web/integrations_awsoidc_test.go b/lib/web/integrations_awsoidc_test.go index 200d68ef7d9a0..866c1e88ba253 100644 --- a/lib/web/integrations_awsoidc_test.go +++ b/lib/web/integrations_awsoidc_test.go @@ -23,6 +23,7 @@ import ( "encoding/base64" "fmt" "net/url" + "strconv" "strings" "testing" @@ -31,13 +32,18 @@ import ( "github.com/google/uuid" "github.com/gravitational/trace" "github.com/stretchr/testify/require" + "google.golang.org/grpc" "github.com/gravitational/teleport/api" "github.com/gravitational/teleport/api/client/proto" integrationv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/integration/v1" "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/types/discoveryconfig" "github.com/gravitational/teleport/lib/integrations/awsoidc" + "github.com/gravitational/teleport/lib/integrations/awsoidc/deployserviceconfig" "github.com/gravitational/teleport/lib/services" + libui "github.com/gravitational/teleport/lib/ui" + "github.com/gravitational/teleport/lib/utils" "github.com/gravitational/teleport/lib/web/ui" ) @@ -1144,3 +1150,292 @@ func TestAWSOIDCAppAccessAppServerCreationDeletion(t *testing.T) { require.NoError(t, err) }) } + +type mockDeployedDatabaseServices struct { + integration string + servicesPerRegion map[string][]*integrationv1.DeployedDatabaseService +} + +func (m *mockDeployedDatabaseServices) ListDeployedDatabaseServices(ctx context.Context, in *integrationv1.ListDeployedDatabaseServicesRequest, opts ...grpc.CallOption) (*integrationv1.ListDeployedDatabaseServicesResponse, error) { + const pageSize = 10 + ret := &integrationv1.ListDeployedDatabaseServicesResponse{} + if in.Integration != m.integration { + return ret, nil + } + + services := m.servicesPerRegion[in.Region] + if len(services) == 0 { + return ret, nil + } + + requestedPage := 1 + totalResources := len(services) + + if in.NextToken != "" { + currentMarker, err := strconv.Atoi(in.NextToken) + if err != nil { + return nil, trace.Wrap(err) + } + requestedPage = currentMarker + } + + sliceStart := pageSize * (requestedPage - 1) + sliceEnd := pageSize * requestedPage + if sliceEnd > totalResources { + sliceEnd = totalResources + } + + ret.DeployedDatabaseServices = services[sliceStart:sliceEnd] + if sliceEnd < totalResources { + ret.NextToken = strconv.Itoa(requestedPage + 1) + } + + return ret, nil +} + +func TestAWSOIDCListDeployedDatabaseServices(t *testing.T) { + ctx := context.Background() + logger := utils.NewSlogLoggerForTests() + + for _, tt := range []struct { + name string + integration string + regions []string + servicesPerRegion func(t *testing.T) map[string][]*integrationv1.DeployedDatabaseService + expectedServices func(t *testing.T) []ui.AWSOIDCDeployedDatabaseService + }{ + { + name: "valid", + integration: "my-integration", + regions: []string{"us-west-2"}, + servicesPerRegion: func(t *testing.T) map[string][]*integrationv1.DeployedDatabaseService { + command := buildCommandDeployedDatabaseService(t, true, types.Labels{"vpc": []string{"vpc1", "vpc2"}}) + return map[string][]*integrationv1.DeployedDatabaseService{ + "us-west-2": dummyDeployedDatabaseServices(1, command), + } + }, + expectedServices: func(t *testing.T) []ui.AWSOIDCDeployedDatabaseService { + return []ui.AWSOIDCDeployedDatabaseService{{ + Name: "database-service-vpc-0", + DashboardURL: "url", + ValidTeleportConfig: true, + MatchingLabels: []libui.Label{ + {Name: "vpc", Value: "vpc1"}, + {Name: "vpc", Value: "vpc2"}, + }, + }} + }, + }, + { + name: "no regions", + integration: "my-integration", + regions: []string{}, + servicesPerRegion: func(t *testing.T) map[string][]*integrationv1.DeployedDatabaseService { + return make(map[string][]*integrationv1.DeployedDatabaseService) + }, + expectedServices: func(t *testing.T) []ui.AWSOIDCDeployedDatabaseService { return nil }, + }, + { + name: "no services", + integration: "my-integration", + regions: []string{"us-west-2"}, + servicesPerRegion: func(t *testing.T) map[string][]*integrationv1.DeployedDatabaseService { + return make(map[string][]*integrationv1.DeployedDatabaseService) + }, + expectedServices: func(t *testing.T) []ui.AWSOIDCDeployedDatabaseService { return nil }, + }, + { + name: "services exist but for another region", + integration: "my-integration", + regions: []string{"us-west-2"}, + servicesPerRegion: func(t *testing.T) map[string][]*integrationv1.DeployedDatabaseService { + return map[string][]*integrationv1.DeployedDatabaseService{ + "us-west-1": dummyDeployedDatabaseServices(1, []string{}), + } + }, + expectedServices: func(t *testing.T) []ui.AWSOIDCDeployedDatabaseService { return nil }, + }, + { + name: "services exist for multiple regions", + integration: "my-integration", + regions: []string{"us-west-2"}, + servicesPerRegion: func(t *testing.T) map[string][]*integrationv1.DeployedDatabaseService { + command := buildCommandDeployedDatabaseService(t, true, types.Labels{"vpc": []string{"vpc1", "vpc2"}}) + return map[string][]*integrationv1.DeployedDatabaseService{ + "us-west-1": dummyDeployedDatabaseServices(1, command), + "us-west-2": dummyDeployedDatabaseServices(1, command), + } + }, + expectedServices: func(t *testing.T) []ui.AWSOIDCDeployedDatabaseService { + return []ui.AWSOIDCDeployedDatabaseService{{ + Name: "database-service-vpc-0", + DashboardURL: "url", + ValidTeleportConfig: true, + MatchingLabels: []libui.Label{ + {Name: "vpc", Value: "vpc1"}, + {Name: "vpc", Value: "vpc2"}, + }, + }} + }, + }, + { + name: "service exist but has invalid configuration", + integration: "my-integration", + regions: []string{"us-west-2"}, + servicesPerRegion: func(t *testing.T) map[string][]*integrationv1.DeployedDatabaseService { + command := buildCommandDeployedDatabaseService(t, false, nil) + return map[string][]*integrationv1.DeployedDatabaseService{ + "us-west-2": dummyDeployedDatabaseServices(1, command), + } + }, + expectedServices: func(t *testing.T) []ui.AWSOIDCDeployedDatabaseService { + return []ui.AWSOIDCDeployedDatabaseService{{ + Name: "database-service-vpc-0", + DashboardURL: "url", + ValidTeleportConfig: false, + }} + }, + }, + { + name: "supports pagination", + integration: "my-integration", + regions: []string{"us-west-2"}, + servicesPerRegion: func(t *testing.T) map[string][]*integrationv1.DeployedDatabaseService { + command := buildCommandDeployedDatabaseService(t, true, types.Labels{"vpc": []string{"vpc1", "vpc2"}}) + return map[string][]*integrationv1.DeployedDatabaseService{ + "us-west-2": dummyDeployedDatabaseServices(1_024, command), + } + }, + expectedServices: func(t *testing.T) []ui.AWSOIDCDeployedDatabaseService { + var ret []ui.AWSOIDCDeployedDatabaseService + for i := 0; i < 1_024; i++ { + ret = append(ret, ui.AWSOIDCDeployedDatabaseService{ + Name: fmt.Sprintf("database-service-vpc-%d", i), + DashboardURL: "url", + ValidTeleportConfig: true, + MatchingLabels: []libui.Label{ + {Name: "vpc", Value: "vpc1"}, + {Name: "vpc", Value: "vpc2"}, + }, + }) + } + return ret + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + clt := &mockDeployedDatabaseServices{ + integration: tt.integration, + servicesPerRegion: tt.servicesPerRegion(t), + } + got, err := listDeployedDatabaseServices(ctx, logger, tt.integration, tt.regions, clt) + require.NoError(t, err) + require.Equal(t, tt.expectedServices(t), got) + }) + } +} + +func buildCommandDeployedDatabaseService(t *testing.T, valid bool, matchingLabels types.Labels) []string { + t.Helper() + if !valid { + return []string{"not valid"} + } + + ret, err := deployserviceconfig.GenerateTeleportConfigString("host", "token", matchingLabels) + require.NoError(t, err) + + return []string{"start", "--config-string", ret} +} + +func dummyDeployedDatabaseServices(count int, command []string) []*integrationv1.DeployedDatabaseService { + var ret []*integrationv1.DeployedDatabaseService + for i := 0; i < count; i++ { + ret = append(ret, &integrationv1.DeployedDatabaseService{ + Name: fmt.Sprintf("database-service-vpc-%d", i), + ServiceDashboardUrl: "url", + ContainerEntryPoint: []string{"teleport"}, + ContainerCommand: command, + }) + } + return ret +} + +func TestFetchRelevantAWSRegions(t *testing.T) { + ctx := context.Background() + + t.Run("resources do not provide any region", func(t *testing.T) { + clt := &mockRelevantAWSRegionsClient{ + databaseServices: &proto.ListResourcesResponse{ + Resources: []*proto.PaginatedResource{}, + }, + databases: make([]types.Database, 0), + discoveryConfigs: make([]*discoveryconfig.DiscoveryConfig, 0), + } + gotRegions, err := fetchRelevantAWSRegions(ctx, clt, clt) + require.NoError(t, err) + require.Empty(t, gotRegions) + }) + + t.Run("resources provide multiple regions", func(t *testing.T) { + clt := &mockRelevantAWSRegionsClient{ + databaseServices: &proto.ListResourcesResponse{ + Resources: []*proto.PaginatedResource{{Resource: &proto.PaginatedResource_DatabaseService{ + DatabaseService: &types.DatabaseServiceV1{Spec: types.DatabaseServiceSpecV1{ + ResourceMatchers: []*types.DatabaseResourceMatcher{ + {Labels: &types.Labels{"region": []string{"us-east-1"}}}, + {Labels: &types.Labels{"region": []string{"us-east-2"}}}, + }, + }}, + }}}, + }, + databases: []types.Database{ + &types.DatabaseV3{Spec: types.DatabaseSpecV3{AWS: types.AWS{Region: "us-west-1"}}}, + &types.DatabaseV3{Metadata: types.Metadata{Labels: map[string]string{"region": "us-west-2"}}}, + }, + discoveryConfigs: []*discoveryconfig.DiscoveryConfig{{ + Spec: discoveryconfig.Spec{AWS: []types.AWSMatcher{{ + Regions: []string{"eu-west-1", "eu-west-2"}, + }}}, + }}, + } + gotRegions, err := fetchRelevantAWSRegions(ctx, clt, clt) + require.NoError(t, err) + expectedRegions := []string{"us-east-1", "us-east-2", "us-west-1", "us-west-2", "eu-west-1", "eu-west-2"} + require.ElementsMatch(t, expectedRegions, gotRegions) + }) + + t.Run("invalid regions are ignored", func(t *testing.T) { + clt := &mockRelevantAWSRegionsClient{ + databaseServices: &proto.ListResourcesResponse{ + Resources: []*proto.PaginatedResource{}, + }, + databases: []types.Database{ + &types.DatabaseV3{Spec: types.DatabaseSpecV3{AWS: types.AWS{Region: "us-west-1"}}}, + &types.DatabaseV3{Metadata: types.Metadata{Labels: map[string]string{"region": "bad-region"}}}, + }, + discoveryConfigs: make([]*discoveryconfig.DiscoveryConfig, 0), + } + gotRegions, err := fetchRelevantAWSRegions(ctx, clt, clt) + require.NoError(t, err) + expectedRegions := []string{"us-west-1"} + require.ElementsMatch(t, expectedRegions, gotRegions) + }) +} + +type mockRelevantAWSRegionsClient struct { + databaseServices *proto.ListResourcesResponse + databases []types.Database + discoveryConfigs []*discoveryconfig.DiscoveryConfig +} + +func (m *mockRelevantAWSRegionsClient) GetResources(ctx context.Context, req *proto.ListResourcesRequest) (*proto.ListResourcesResponse, error) { + return m.databaseServices, nil +} + +func (m *mockRelevantAWSRegionsClient) GetDatabases(context.Context) ([]types.Database, error) { + return m.databases, nil +} + +func (m *mockRelevantAWSRegionsClient) ListDiscoveryConfigs(ctx context.Context, pageSize int, nextToken string) ([]*discoveryconfig.DiscoveryConfig, string, error) { + return m.discoveryConfigs, "", nil +} diff --git a/lib/web/ui/integration.go b/lib/web/ui/integration.go index f1eaf46188164..0a56348f8c91d 100644 --- a/lib/web/ui/integration.go +++ b/lib/web/ui/integration.go @@ -287,6 +287,26 @@ type AWSOIDCDeployDatabaseServiceResponse struct { ClusterDashboardURL string `json:"clusterDashboardUrl"` } +// AWSOIDCDeployedDatabaseService represents an Teleport Database Service that is deployed in Amazon ECS. +type AWSOIDCDeployedDatabaseService struct { + // Name is the ECS Service name. + Name string `json:"name,omitempty"` + // DashboardURL is the link to the ECS Service in Amazon Web Console. + DashboardURL string `json:"dashboardUrl,omitempty"` + // ValidTeleportConfig returns whether this ECS Service has a valid Teleport Configuration for a deployed Database Service. + // ECS Services with non-valid configuration require the user to take action on them. + // No MatchingLabels are returned with an invalid configuration. + ValidTeleportConfig bool `json:"validTeleportConfig,omitempty"` + // MatchingLabels are the labels that are used by the Teleport Database Service to know which databases it should proxy. + MatchingLabels []ui.Label `json:"matchingLabels,omitempty"` +} + +// AWSOIDCListDeployedDatabaseServiceResponse is a list of Teleport Database Services that are deployed as ECS Services. +type AWSOIDCListDeployedDatabaseServiceResponse struct { + // Services are the ECS Services. + Services []AWSOIDCDeployedDatabaseService `json:"services"` +} + // AWSOIDCEnrollEKSClustersRequest is a request to ListEKSClusters using the AWS OIDC Integration. type AWSOIDCEnrollEKSClustersRequest struct { // Region is the AWS Region.