From 97dc48e9177fec245435ab4f67fbd19e68c818d4 Mon Sep 17 00:00:00 2001 From: Tiago Silva Date: Wed, 16 Oct 2024 19:14:44 +0100 Subject: [PATCH] [v14] [gcp] support project discovery (#47566) * [gcp] support project discovery This PR extends Teleport discovery service to be able to support find all GKE and VM servers in every project a user has access to. ``` discovery_service: enabled: true discovery_group: "test" gcp: - types: ["gke"] locations: ["*"] project_ids: ["*"] tags: '*': '*' ``` * simplify docs by adding examples --- api/types/matchers_gcp.go | 4 +- api/types/matchers_gcp_test.go | 4 +- .../discovery/google-cloud.mdx | 14 +- lib/cloud/clients.go | 16 +++ lib/cloud/gcp/projects.go | 105 +++++++++++++++ lib/config/configuration_test.go | 47 +++++++ lib/srv/discovery/discovery.go | 28 ++-- lib/srv/discovery/discovery_test.go | 124 +++++++++++++++++- lib/srv/discovery/fetchers/gke.go | 51 ++++++- lib/srv/discovery/fetchers/gke_test.go | 89 +++++++++++-- lib/srv/server/gcp_watcher.go | 40 +++++- 11 files changed, 482 insertions(+), 40 deletions(-) create mode 100644 lib/cloud/gcp/projects.go diff --git a/api/types/matchers_gcp.go b/api/types/matchers_gcp.go index 095aef938644..20eec0d6d89c 100644 --- a/api/types/matchers_gcp.go +++ b/api/types/matchers_gcp.go @@ -101,8 +101,8 @@ func (m *GCPMatcher) CheckAndSetDefaults() error { m.Locations = []string{Wildcard} } - if slices.Contains(m.ProjectIDs, Wildcard) { - return trace.BadParameter("GCP discovery service project_ids does not support wildcards; please specify at least one value in project_ids.") + if slices.Contains(m.ProjectIDs, Wildcard) && len(m.ProjectIDs) > 1 { + return trace.BadParameter("GCP discovery service either supports wildcard project_ids or multiple values, but not both.") } if len(m.ProjectIDs) == 0 { return trace.BadParameter("GCP discovery service project_ids does cannot be empty; please specify at least one value in project_ids.") diff --git a/api/types/matchers_gcp_test.go b/api/types/matchers_gcp_test.go index 46eb18fe248d..f56c93172b65 100644 --- a/api/types/matchers_gcp_test.go +++ b/api/types/matchers_gcp_test.go @@ -92,12 +92,12 @@ func TestGCPMatcherCheckAndSetDefaults(t *testing.T) { errCheck: isBadParameterErr, }, { - name: "wildcard is invalid for project ids", + name: "wildcard is valid for project ids", in: &GCPMatcher{ Types: []string{"gce"}, ProjectIDs: []string{"*"}, }, - errCheck: isBadParameterErr, + errCheck: require.NoError, }, { name: "invalid type", diff --git a/docs/pages/enroll-resources/kubernetes-access/discovery/google-cloud.mdx b/docs/pages/enroll-resources/kubernetes-access/discovery/google-cloud.mdx index 1e54ead88ae0..83f892516dd7 100644 --- a/docs/pages/enroll-resources/kubernetes-access/discovery/google-cloud.mdx +++ b/docs/pages/enroll-resources/kubernetes-access/discovery/google-cloud.mdx @@ -452,8 +452,18 @@ value, `gke`. #### `discovery_service.gcp[0].project_ids` In your matcher, replace `myproject` with the ID of your Google Cloud project. -The `project_ids` field must include at least one value, and it must not be the -wildcard character (`*`). + +Ensure that the `project_ids` field follows these rules: +- It must include at least one value. +- It must not combine the wildcard character (`*`) with other values. + +##### Examples of valid configurations +- `["p1", "p2"]` +- `["*"]` +- `["p1"]` + +##### Example of an invalid configuration +- `["p1", "*"]` #### `discovery_service.gcp[0].locations` diff --git a/lib/cloud/clients.go b/lib/cloud/clients.go index 48ccacbca296..a01ce81692a1 100644 --- a/lib/cloud/clients.go +++ b/lib/cloud/clients.go @@ -98,6 +98,8 @@ type GCPClients interface { GetGCPSQLAdminClient(context.Context) (gcp.SQLAdminClient, error) // GetGCPGKEClient returns GKE client. GetGCPGKEClient(context.Context) (gcp.GKEClient, error) + // GetGCPProjectsClient returns Projects client. + GetGCPProjectsClient(context.Context) (gcp.ProjectsClient, error) // GetGCPInstancesClient returns instances client. GetGCPInstancesClient(context.Context) (gcp.InstancesClient, error) } @@ -255,6 +257,7 @@ func NewClients(opts ...ClientsOption) (Clients, error) { gcpClients: gcpClients{ gcpSQLAdmin: newClientCache[gcp.SQLAdminClient](gcp.NewSQLAdminClient), gcpGKE: newClientCache[gcp.GKEClient](gcp.NewGKEClient), + gcpProjects: newClientCache[gcp.ProjectsClient](gcp.NewProjectsClient), gcpInstances: newClientCache[gcp.InstancesClient](gcp.NewInstancesClient), }, azureClients: azClients, @@ -313,6 +316,8 @@ type gcpClients struct { gcpSQLAdmin *clientCache[gcp.SQLAdminClient] // gcpGKE is the cached GCP Cloud GKE client. gcpGKE *clientCache[gcp.GKEClient] + // gcpProjects is the cached GCP Cloud Projects client. + gcpProjects *clientCache[gcp.ProjectsClient] // gcpInstances is the cached GCP instances client. gcpInstances *clientCache[gcp.InstancesClient] } @@ -639,6 +644,11 @@ func (c *cloudClients) GetGCPGKEClient(ctx context.Context) (gcp.GKEClient, erro return c.gcpGKE.GetClient(ctx) } +// GetGCPProjectsClient returns Project client. +func (c *cloudClients) GetGCPProjectsClient(ctx context.Context) (gcp.ProjectsClient, error) { + return c.gcpProjects.GetClient(ctx) +} + // GetGCPInstancesClient returns instances client. func (c *cloudClients) GetGCPInstancesClient(ctx context.Context) (gcp.InstancesClient, error) { return c.gcpInstances.GetClient(ctx) @@ -982,6 +992,7 @@ type TestCloudClients struct { STS stsiface.STSAPI GCPSQL gcp.SQLAdminClient GCPGKE gcp.GKEClient + GCPProjects gcp.ProjectsClient GCPInstances gcp.InstancesClient EC2 ec2iface.EC2API SSM ssmiface.SSMAPI @@ -1178,6 +1189,11 @@ func (c *TestCloudClients) GetGCPGKEClient(ctx context.Context) (gcp.GKEClient, return c.GCPGKE, nil } +// GetGCPGKEClient returns GKE client. +func (c *TestCloudClients) GetGCPProjectsClient(ctx context.Context) (gcp.ProjectsClient, error) { + return c.GCPProjects, nil +} + // GetGCPInstancesClient returns instances client. func (c *TestCloudClients) GetGCPInstancesClient(ctx context.Context) (gcp.InstancesClient, error) { return c.GCPInstances, nil diff --git a/lib/cloud/gcp/projects.go b/lib/cloud/gcp/projects.go new file mode 100644 index 000000000000..ee5b178be9f9 --- /dev/null +++ b/lib/cloud/gcp/projects.go @@ -0,0 +1,105 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package gcp + +import ( + "context" + + "github.com/gravitational/trace" + "google.golang.org/api/cloudresourcemanager/v1" +) + +// Project is a GCP project. +type Project struct { + // ID is the project ID. + ID string + // Name is the project name. + Name string +} + +// ProjectsClient is an interface to interact with GCP Projects API. +type ProjectsClient interface { + // ListProjects lists the GCP projects that the authenticated user has access to. + ListProjects(ctx context.Context) ([]Project, error) +} + +// ProjectsClientConfig is the client configuration for ProjectsClient. +type ProjectsClientConfig struct { + // Client is the GCP client for resourcemanager service. + Client *cloudresourcemanager.Service +} + +// CheckAndSetDefaults check and set defaults for ProjectsClientConfig. +func (c *ProjectsClientConfig) CheckAndSetDefaults(ctx context.Context) (err error) { + if c.Client == nil { + c.Client, err = cloudresourcemanager.NewService(ctx) + if err != nil { + return trace.Wrap(err) + } + } + return nil +} + +// NewProjectsClient returns a ProjectsClient interface wrapping resourcemanager.ProjectsClient +// for interacting with GCP Projects API. +func NewProjectsClient(ctx context.Context) (ProjectsClient, error) { + var cfg ProjectsClientConfig + client, err := NewProjectsClientWithConfig(ctx, cfg) + return client, trace.Wrap(err) +} + +// NewProjectsClientWithConfig returns a ProjectsClient interface wrapping resourcemanager.ProjectsClient +// for interacting with GCP Projects API. +func NewProjectsClientWithConfig(ctx context.Context, cfg ProjectsClientConfig) (ProjectsClient, error) { + if err := cfg.CheckAndSetDefaults(ctx); err != nil { + return nil, trace.Wrap(err) + } + return &projectsClient{cfg}, nil +} + +type projectsClient struct { + ProjectsClientConfig +} + +// ListProjects lists the GCP Projects that the authenticated user has access to. +func (g *projectsClient) ListProjects(ctx context.Context) ([]Project, error) { + + var pageToken string + var projects []Project + for { + projectsCall, err := g.Client.Projects.List().PageToken(pageToken).Do() + if err != nil { + return nil, trace.Wrap(err) + } + for _, project := range projectsCall.Projects { + projects = append(projects, + Project{ + ID: project.ProjectId, + Name: project.Name, + }, + ) + } + if projectsCall.NextPageToken == "" { + break + } + pageToken = projectsCall.NextPageToken + } + + return projects, nil +} diff --git a/lib/config/configuration_test.go b/lib/config/configuration_test.go index 835f02a09313..23f3e0152b0f 100644 --- a/lib/config/configuration_test.go +++ b/lib/config/configuration_test.go @@ -4346,6 +4346,53 @@ func TestDiscoveryConfig(t *testing.T) { ProjectIDs: []string{"p1", "p2"}, }}, }, + { + desc: "GCP section is filled with wildcard project ids", + expectError: require.NoError, + expectEnabled: require.True, + mutate: func(cfg cfgMap) { + cfg["discovery_service"].(cfgMap)["enabled"] = "yes" + cfg["discovery_service"].(cfgMap)["gcp"] = []cfgMap{ + { + "types": []string{"gke"}, + "locations": []string{"eucentral1"}, + "tags": cfgMap{ + "discover_teleport": "yes", + }, + "project_ids": []string{"*"}, + }, + } + }, + expectedGCPMatchers: []types.GCPMatcher{{ + Types: []string{"gke"}, + Locations: []string{"eucentral1"}, + Labels: map[string]apiutils.Strings{ + "discover_teleport": []string{"yes"}, + }, + Tags: map[string]apiutils.Strings{ + "discover_teleport": []string{"yes"}, + }, + ProjectIDs: []string{"*"}, + }}, + }, + { + desc: "GCP section mixes wildcard and specific project ids", + expectError: require.Error, + expectEnabled: require.True, + mutate: func(cfg cfgMap) { + cfg["discovery_service"].(cfgMap)["enabled"] = "yes" + cfg["discovery_service"].(cfgMap)["gcp"] = []cfgMap{ + { + "types": []string{"gke"}, + "locations": []string{"eucentral1"}, + "tags": cfgMap{ + "discover_teleport": "yes", + }, + "project_ids": []string{"p1", "*"}, + }, + } + }, + }, { desc: "GCP section is filled with installer", expectError: require.NoError, diff --git a/lib/srv/discovery/discovery.go b/lib/srv/discovery/discovery.go index 6f612c763b24..78fc1937e92b 100644 --- a/lib/srv/discovery/discovery.go +++ b/lib/srv/discovery/discovery.go @@ -562,7 +562,12 @@ func (s *Server) gcpServerFetchersFromMatchers(ctx context.Context, matchers []t return nil, trace.Wrap(err) } - return server.MatchersToGCPInstanceFetchers(serverMatchers, client), nil + projectsClient, err := s.CloudClients.GetGCPProjectsClient(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + + return server.MatchersToGCPInstanceFetchers(serverMatchers, client, projectsClient), nil } // databaseFetchersFromMatchers converts Matchers into a set of Database Fetchers. @@ -704,19 +709,26 @@ func (s *Server) initGCPWatchers(ctx context.Context, matchers []types.GCPMatche if err != nil { return trace.Wrap(err) } + projectClient, err := s.CloudClients.GetGCPProjectsClient(ctx) + if err != nil { + return trace.Wrap(err, "unable to create gcp project client") + } for _, matcher := range otherMatchers { for _, projectID := range matcher.ProjectIDs { for _, location := range matcher.Locations { for _, t := range matcher.Types { switch t { case types.GCPMatcherKubernetes: - fetcher, err := fetchers.NewGKEFetcher(fetchers.GKEFetcherConfig{ - Client: kubeClient, - Location: location, - FilterLabels: matcher.GetLabels(), - ProjectID: projectID, - Log: s.Log, - }) + fetcher, err := fetchers.NewGKEFetcher( + ctx, + fetchers.GKEFetcherConfig{ + GKEClient: kubeClient, + ProjectClient: projectClient, + Location: location, + FilterLabels: matcher.GetLabels(), + ProjectID: projectID, + Log: s.Log, + }) if err != nil { return trace.Wrap(err) } diff --git a/lib/srv/discovery/discovery_test.go b/lib/srv/discovery/discovery_test.go index f8997975aa57..dcbefc5e0290 100644 --- a/lib/srv/discovery/discovery_test.go +++ b/lib/srv/discovery/discovery_test.go @@ -950,6 +950,24 @@ func TestDiscoveryInCloudKube(t *testing.T) { }, wantEvents: 2, }, + { + name: "no clusters in auth server, import 3 prod clusters from GKE across multiple projects", + existingKubeClusters: []types.KubeCluster{}, + gcpMatchers: []types.GCPMatcher{ + { + Types: []string{"gke"}, + Locations: []string{"*"}, + ProjectIDs: []string{"*"}, + Tags: map[string]utils.Strings{"env": {"prod"}}, + }, + }, + expectedClustersToExistInAuth: []types.KubeCluster{ + mustConvertGKEToKubeCluster(t, gkeMockClusters[0], mainDiscoveryGroup), + mustConvertGKEToKubeCluster(t, gkeMockClusters[1], mainDiscoveryGroup), + mustConvertGKEToKubeCluster(t, gkeMockClusters[4], mainDiscoveryGroup), + }, + wantEvents: 3, + }, } for _, tc := range tcs { @@ -963,6 +981,7 @@ func TestDiscoveryInCloudKube(t *testing.T) { AzureAKSClient: newPopulatedAKSMock(), EKS: newPopulatedEKSMock(), GCPGKE: newPopulatedGCPMock(), + GCPProjects: newPopulatedGCPProjectsMock(), } ctx := context.Background() @@ -1447,6 +1466,28 @@ var gkeMockClusters = []gcp.GKECluster{ Location: "central-1", Description: "desc1", }, + { + Name: "cluster5", + Status: containerpb.Cluster_RUNNING, + Labels: map[string]string{ + "env": "prod", + "location": "central-1", + }, + ProjectID: "p2", + Location: "central-1", + Description: "desc1", + }, + { + Name: "cluster6", + Status: containerpb.Cluster_RUNNING, + Labels: map[string]string{ + "env": "stg", + "location": "central-1", + }, + ProjectID: "p2", + Location: "central-1", + Description: "desc1", + }, } func mustConvertGKEToKubeCluster(t *testing.T, gkeCluster gcp.GKECluster, discoveryGroup string) types.KubeCluster { @@ -1464,7 +1505,15 @@ type mockGKEAPI struct { } func (m *mockGKEAPI) ListClusters(ctx context.Context, projectID string, location string) ([]gcp.GKECluster, error) { - return m.clusters, nil + var clusters []gcp.GKECluster + for _, cluster := range m.clusters { + if cluster.ProjectID != projectID { + continue + } + clusters = append(clusters, cluster) + } + + return clusters, nil } func TestDiscoveryDatabase(t *testing.T) { @@ -2377,12 +2426,21 @@ type mockGCPClient struct { vms []*gcp.Instance } -func (m *mockGCPClient) ListInstances(_ context.Context, _, _ string) ([]*gcp.Instance, error) { - return m.vms, nil +func (m *mockGCPClient) getVMSForProject(projectID string) []*gcp.Instance { + var vms []*gcp.Instance + for _, vm := range m.vms { + if vm.ProjectID == projectID { + vms = append(vms, vm) + } + } + return vms +} +func (m *mockGCPClient) ListInstances(_ context.Context, projectID, _ string) ([]*gcp.Instance, error) { + return m.getVMSForProject(projectID), nil } -func (m *mockGCPClient) StreamInstances(_ context.Context, _, _ string) stream.Stream[*gcp.Instance] { - return stream.Slice(m.vms) +func (m *mockGCPClient) StreamInstances(_ context.Context, projectID, _ string) stream.Stream[*gcp.Instance] { + return stream.Slice(m.getVMSForProject(projectID)) } func (m *mockGCPClient) GetInstance(_ context.Context, _ *gcp.InstanceRequest) (*gcp.Instance, error) { @@ -2471,6 +2529,37 @@ func TestGCPVMDiscovery(t *testing.T) { staticMatchers: defaultStaticMatcher, wantInstalledInstances: []string{"myinstance"}, }, + { + name: "no nodes present, 2 found for different projects", + presentVMs: []types.Server{}, + foundGCPVMs: []*gcp.Instance{ + { + ProjectID: "p1", + Zone: "myzone", + Name: "myinstance1", + Labels: map[string]string{ + "teleport": "yes", + }, + }, + { + ProjectID: "p2", + Zone: "myzone", + Name: "myinstance2", + Labels: map[string]string{ + "teleport": "yes", + }, + }, + }, + staticMatchers: Matchers{ + GCP: []types.GCPMatcher{{ + Types: []string{"gce"}, + ProjectIDs: []string{"*"}, + Locations: []string{"myzone"}, + Labels: types.Labels{"teleport": {"yes"}}, + }}, + }, + wantInstalledInstances: []string{"myinstance1", "myinstance2"}, + }, { name: "nodes present, instance filtered", presentVMs: []types.Server{ @@ -2556,6 +2645,7 @@ func TestGCPVMDiscovery(t *testing.T) { GCPInstances: &mockGCPClient{ vms: tc.foundGCPVMs, }, + GCPProjects: newPopulatedGCPProjectsMock(), } ctx := context.Background() @@ -2800,3 +2890,27 @@ func (m fakeWatcher) Close() error { func (m fakeWatcher) Error() error { return nil } + +type mockProjectsAPI struct { + gcp.ProjectsClient + projects []gcp.Project +} + +func (m *mockProjectsAPI) ListProjects(ctx context.Context) ([]gcp.Project, error) { + return m.projects, nil +} + +func newPopulatedGCPProjectsMock() *mockProjectsAPI { + return &mockProjectsAPI{ + projects: []gcp.Project{ + { + ID: "p1", + Name: "project1", + }, + { + ID: "p2", + Name: "project2", + }, + }, + } +} diff --git a/lib/srv/discovery/fetchers/gke.go b/lib/srv/discovery/fetchers/gke.go index 402d4233ccdb..e4988a9ba456 100644 --- a/lib/srv/discovery/fetchers/gke.go +++ b/lib/srv/discovery/fetchers/gke.go @@ -32,8 +32,10 @@ import ( // GKEFetcherConfig configures the GKE fetcher. type GKEFetcherConfig struct { - // Client is the GCP GKE client. - Client gcp.GKEClient + // GKEClient is the GCP GKE client. + GKEClient gcp.GKEClient + // ProjectClient is the GCP project client. + ProjectClient gcp.ProjectsClient // ProjectID is the projectID the cluster should belong to. ProjectID string // Location is the GCP's location where the clusters should be located. @@ -47,9 +49,12 @@ type GKEFetcherConfig struct { // CheckAndSetDefaults validates and sets the defaults values. func (c *GKEFetcherConfig) CheckAndSetDefaults() error { - if c.Client == nil { + if c.GKEClient == nil { return trace.BadParameter("missing Client field") } + if c.ProjectClient == nil { + return trace.BadParameter("missing ProjectClient field") + } if len(c.Location) == 0 { return trace.BadParameter("missing Location field") } @@ -70,7 +75,7 @@ type gkeFetcher struct { } // NewGKEFetcher creates a new GKE fetcher configuration. -func NewGKEFetcher(cfg GKEFetcherConfig) (common.Fetcher, error) { +func NewGKEFetcher(ctx context.Context, cfg GKEFetcherConfig) (common.Fetcher, error) { if err := cfg.CheckAndSetDefaults(); err != nil { return nil, trace.Wrap(err) } @@ -79,19 +84,31 @@ func NewGKEFetcher(cfg GKEFetcherConfig) (common.Fetcher, error) { } func (a *gkeFetcher) Get(ctx context.Context) (types.ResourcesWithLabels, error) { - clusters, err := a.getGKEClusters(ctx) + + // Get the project IDs that this fetcher is configured to query. + projectIDs, err := a.getProjectIDs(ctx) if err != nil { return nil, trace.Wrap(err) } + a.Log.Debugf("Fetching GKE clusters for project IDs: %v", projectIDs) + var clusters types.KubeClusters + for _, projectID := range projectIDs { + lClusters, err := a.getGKEClusters(ctx, projectID) + if err != nil { + return nil, trace.Wrap(err) + } + clusters = append(clusters, lClusters...) + } + a.rewriteKubeClusters(clusters) return clusters.AsResources(), nil } -func (a *gkeFetcher) getGKEClusters(ctx context.Context) (types.KubeClusters, error) { +func (a *gkeFetcher) getGKEClusters(ctx context.Context, projectID string) (types.KubeClusters, error) { var clusters types.KubeClusters - gkeClusters, err := a.Client.ListClusters(ctx, a.ProjectID, a.Location) + gkeClusters, err := a.GKEClient.ListClusters(ctx, projectID, a.Location) for _, gkeCluster := range gkeClusters { cluster, err := a.getMatchingKubeCluster(gkeCluster) // trace.CompareFailed is returned if the cluster did not match the matcher filtering labels @@ -157,3 +174,23 @@ func (a *gkeFetcher) getMatchingKubeCluster(gkeCluster gcp.GKECluster) (types.Ku return cluster, nil } + +// getProjectIDs returns the project ids that this fetcher is configured to query. +// This will make an API call to list project IDs when the fetcher is configured to match "*" projectID, +// in order to discover and query new projectID. +// Otherwise, a list containing the fetcher's non-wildcard project is returned. +func (a *gkeFetcher) getProjectIDs(ctx context.Context) ([]string, error) { + if a.ProjectID != types.Wildcard { + return []string{a.ProjectID}, nil + } + + gcpProjects, err := a.ProjectClient.ListProjects(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + var projectIDs []string + for _, prj := range gcpProjects { + projectIDs = append(projectIDs, prj.ID) + } + return projectIDs, nil +} diff --git a/lib/srv/discovery/fetchers/gke_test.go b/lib/srv/discovery/fetchers/gke_test.go index ee5dbfaa43c3..c85f08ba982b 100644 --- a/lib/srv/discovery/fetchers/gke_test.go +++ b/lib/srv/discovery/fetchers/gke_test.go @@ -33,6 +33,7 @@ func TestGKEFetcher(t *testing.T) { type args struct { location string filterLabels types.Labels + projectID string } tests := []struct { name string @@ -46,8 +47,9 @@ func TestGKEFetcher(t *testing.T) { filterLabels: types.Labels{ types.Wildcard: []string{types.Wildcard}, }, + projectID: "p1", }, - want: gkeClustersToResources(t, gkeMockClusters...), + want: gkeClustersToResources(t, gkeMockClusters[:4]...), }, { name: "list prod clusters", @@ -56,6 +58,7 @@ func TestGKEFetcher(t *testing.T) { filterLabels: types.Labels{ "env": []string{"prod"}, }, + projectID: "p1", }, want: gkeClustersToResources(t, gkeMockClusters[:2]...), }, @@ -67,8 +70,9 @@ func TestGKEFetcher(t *testing.T) { "env": []string{"stg"}, "location": []string{"central-1"}, }, + projectID: "p1", }, - want: gkeClustersToResources(t, gkeMockClusters[2:]...), + want: gkeClustersToResources(t, gkeMockClusters[2:4]...), }, { name: "filter not found", @@ -77,6 +81,7 @@ func TestGKEFetcher(t *testing.T) { filterLabels: types.Labels{ "env": []string{"none"}, }, + projectID: "p1", }, want: gkeClustersToResources(t), }, @@ -88,6 +93,18 @@ func TestGKEFetcher(t *testing.T) { filterLabels: types.Labels{ "env": []string{"prod", "stg"}, }, + projectID: "p1", + }, + want: gkeClustersToResources(t, gkeMockClusters[:4]...), + }, + { + name: "list everything with wildcard project", + args: args{ + location: "uswest2", + filterLabels: types.Labels{ + "env": []string{"prod", "stg"}, + }, + projectID: "*", }, want: gkeClustersToResources(t, gkeMockClusters...), }, @@ -95,12 +112,14 @@ func TestGKEFetcher(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := GKEFetcherConfig{ - Client: newPopulatedGCPMock(), - FilterLabels: tt.args.filterLabels, - Location: tt.args.location, - Log: logrus.New(), + GKEClient: newPopulatedGCPMock(), + ProjectClient: newPopulatedGCPProjectsMock(), + FilterLabels: tt.args.filterLabels, + Location: tt.args.location, + ProjectID: tt.args.projectID, + Log: logrus.New(), } - fetcher, err := NewGKEFetcher(cfg) + fetcher, err := NewGKEFetcher(context.Background(), cfg) require.NoError(t, err) resources, err := fetcher.Get(context.Background()) require.NoError(t, err) @@ -116,7 +135,15 @@ type mockGKEAPI struct { } func (m *mockGKEAPI) ListClusters(ctx context.Context, projectID string, location string) ([]gcp.GKECluster, error) { - return m.clusters, nil + var clusters []gcp.GKECluster + for _, cluster := range m.clusters { + if cluster.ProjectID != projectID { + continue + } + clusters = append(clusters, cluster) + } + + return clusters, nil } func newPopulatedGCPMock() *mockGKEAPI { @@ -170,6 +197,28 @@ var gkeMockClusters = []gcp.GKECluster{ Location: "central-1", Description: "desc1", }, + { + Name: "cluster5", + Status: containerpb.Cluster_RUNNING, + Labels: map[string]string{ + "env": "stg", + "location": "central-1", + }, + ProjectID: "p2", + Location: "central-1", + Description: "desc1", + }, + { + Name: "cluster6", + Status: containerpb.Cluster_RUNNING, + Labels: map[string]string{ + "env": "stg", + "location": "central-1", + }, + ProjectID: "p2", + Location: "central-1", + Description: "desc1", + }, } func gkeClustersToResources(t *testing.T, clusters ...gcp.GKECluster) types.ResourcesWithLabels { @@ -183,3 +232,27 @@ func gkeClustersToResources(t *testing.T, clusters ...gcp.GKECluster) types.Reso } return kubeClusters.AsResources() } + +type mockProjectsAPI struct { + gcp.ProjectsClient + projects []gcp.Project +} + +func (m *mockProjectsAPI) ListProjects(ctx context.Context) ([]gcp.Project, error) { + return m.projects, nil +} + +func newPopulatedGCPProjectsMock() *mockProjectsAPI { + return &mockProjectsAPI{ + projects: []gcp.Project{ + { + ID: "p1", + Name: "project1", + }, + { + ID: "p2", + Name: "project2", + }, + }, + } +} diff --git a/lib/srv/server/gcp_watcher.go b/lib/srv/server/gcp_watcher.go index 7e3aa35ba360..2ad34aea4922 100644 --- a/lib/srv/server/gcp_watcher.go +++ b/lib/srv/server/gcp_watcher.go @@ -88,13 +88,14 @@ func NewGCPWatcher(ctx context.Context, fetchersFn func() []Fetcher, opts ...Opt } // MatchersToGCPInstanceFetchers converts a list of GCP GCE Matchers into a list of GCP GCE Fetchers. -func MatchersToGCPInstanceFetchers(matchers []types.GCPMatcher, gcpClient gcp.InstancesClient) []Fetcher { +func MatchersToGCPInstanceFetchers(matchers []types.GCPMatcher, gcpClient gcp.InstancesClient, projectsClient gcp.ProjectsClient) []Fetcher { fetchers := make([]Fetcher, 0, len(matchers)) for _, matcher := range matchers { fetchers = append(fetchers, newGCPInstanceFetcher(gcpFetcherConfig{ - Matcher: matcher, - GCPClient: gcpClient, + Matcher: matcher, + GCPClient: gcpClient, + projectsClient: projectsClient, })) } @@ -102,8 +103,9 @@ func MatchersToGCPInstanceFetchers(matchers []types.GCPMatcher, gcpClient gcp.In } type gcpFetcherConfig struct { - Matcher types.GCPMatcher - GCPClient gcp.InstancesClient + Matcher types.GCPMatcher + GCPClient gcp.InstancesClient + projectsClient gcp.ProjectsClient } type gcpInstanceFetcher struct { @@ -114,6 +116,7 @@ type gcpInstanceFetcher struct { ServiceAccounts []string Labels types.Labels Parameters map[string]string + projectsClient gcp.ProjectsClient } func newGCPInstanceFetcher(cfg gcpFetcherConfig) *gcpInstanceFetcher { @@ -123,6 +126,7 @@ func newGCPInstanceFetcher(cfg gcpFetcherConfig) *gcpInstanceFetcher { ProjectIDs: cfg.Matcher.ProjectIDs, ServiceAccounts: cfg.Matcher.ServiceAccounts, Labels: cfg.Matcher.GetLabels(), + projectsClient: cfg.projectsClient, } if cfg.Matcher.Params != nil { fetcher.Parameters = map[string]string{ @@ -142,7 +146,11 @@ func (*gcpInstanceFetcher) GetMatchingInstances(_ []types.Server, _ bool) ([]Ins func (f *gcpInstanceFetcher) GetInstances(ctx context.Context, _ bool) ([]Instances, error) { // Key by project ID, then by zone. instanceMap := make(map[string]map[string][]*gcp.Instance) - for _, projectID := range f.ProjectIDs { + projectIDs, err := f.getProjectIDs(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + for _, projectID := range projectIDs { instanceMap[projectID] = make(map[string][]*gcp.Instance) for _, zone := range f.Zones { instanceMap[projectID][zone] = make([]*gcp.Instance, 0) @@ -182,3 +190,23 @@ func (f *gcpInstanceFetcher) GetInstances(ctx context.Context, _ bool) ([]Instan return instances, nil } + +// getProjectIDs returns the project ids that this fetcher is configured to query. +// This will make an API call to list project IDs when the fetcher is configured to match "*" projectID, +// in order to discover and query new projectID. +// Otherwise, a list containing the fetcher's non-wildcard project is returned. +func (f *gcpInstanceFetcher) getProjectIDs(ctx context.Context) ([]string, error) { + if len(f.ProjectIDs) != 1 || len(f.ProjectIDs) == 1 && f.ProjectIDs[0] != types.Wildcard { + return f.ProjectIDs, nil + } + + gcpProjects, err := f.projectsClient.ListProjects(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + var projectIDs []string + for _, prj := range gcpProjects { + projectIDs = append(projectIDs, prj.ID) + } + return projectIDs, nil +}