From 36f9222a43874fb09f6b006bd4afeedf39eada57 Mon Sep 17 00:00:00 2001 From: Tiago Silva Date: Mon, 14 Oct 2024 17:50:47 +0100 Subject: [PATCH] [gcp] support project discovery (#47434) * [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 +- .../kubernetes/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 8bc3c27a69b3f..e60fc8c73b7ab 100644 --- a/api/types/matchers_gcp.go +++ b/api/types/matchers_gcp.go @@ -102,8 +102,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 46eb18fe248d3..f56c93172b654 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/auto-discovery/kubernetes/google-cloud.mdx b/docs/pages/enroll-resources/auto-discovery/kubernetes/google-cloud.mdx index 97bbf596fb3dc..b26f94f22566c 100644 --- a/docs/pages/enroll-resources/auto-discovery/kubernetes/google-cloud.mdx +++ b/docs/pages/enroll-resources/auto-discovery/kubernetes/google-cloud.mdx @@ -454,8 +454,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 18ddbb29f1c3a..bc21d126b9903 100644 --- a/lib/cloud/clients.go +++ b/lib/cloud/clients.go @@ -107,6 +107,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) } @@ -266,6 +268,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, @@ -324,6 +327,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] } @@ -659,6 +664,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) @@ -1022,6 +1032,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 @@ -1234,6 +1245,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 0000000000000..ee5b178be9f9c --- /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 cb6a35db02912..60a86d8815131 100644 --- a/lib/config/configuration_test.go +++ b/lib/config/configuration_test.go @@ -4345,6 +4345,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 92e46ad3f3abe..6ce416212c789 100644 --- a/lib/srv/discovery/discovery.go +++ b/lib/srv/discovery/discovery.go @@ -572,7 +572,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. @@ -734,19 +739,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 6f0b655e6e34a..9a0488460bc58 100644 --- a/lib/srv/discovery/discovery_test.go +++ b/lib/srv/discovery/discovery_test.go @@ -1162,6 +1162,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 { @@ -1175,6 +1193,7 @@ func TestDiscoveryInCloudKube(t *testing.T) { AzureAKSClient: newPopulatedAKSMock(), EKS: newPopulatedEKSMock(), GCPGKE: newPopulatedGCPMock(), + GCPProjects: newPopulatedGCPProjectsMock(), } ctx := context.Background() @@ -1649,6 +1668,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 { @@ -1664,7 +1705,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) { @@ -2550,12 +2599,21 @@ type mockGCPClient struct { vms []*gcpimds.Instance } -func (m *mockGCPClient) ListInstances(_ context.Context, _, _ string) ([]*gcpimds.Instance, error) { - return m.vms, nil +func (m *mockGCPClient) getVMSForProject(projectID string) []*gcpimds.Instance { + var vms []*gcpimds.Instance + for _, vm := range m.vms { + if vm.ProjectID == projectID { + vms = append(vms, vm) + } + } + return vms +} +func (m *mockGCPClient) ListInstances(_ context.Context, projectID, _ string) ([]*gcpimds.Instance, error) { + return m.getVMSForProject(projectID), nil } -func (m *mockGCPClient) StreamInstances(_ context.Context, _, _ string) stream.Stream[*gcpimds.Instance] { - return stream.Slice(m.vms) +func (m *mockGCPClient) StreamInstances(_ context.Context, projectID, _ string) stream.Stream[*gcpimds.Instance] { + return stream.Slice(m.getVMSForProject(projectID)) } func (m *mockGCPClient) GetInstance(_ context.Context, _ *gcpimds.InstanceRequest) (*gcpimds.Instance, error) { @@ -2648,6 +2706,37 @@ func TestGCPVMDiscovery(t *testing.T) { staticMatchers: defaultStaticMatcher, wantInstalledInstances: []string{"myinstance"}, }, + { + name: "no nodes present, 2 found for different projects", + presentVMs: []types.Server{}, + foundGCPVMs: []*gcpimds.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{ @@ -2733,6 +2822,7 @@ func TestGCPVMDiscovery(t *testing.T) { GCPInstances: &mockGCPClient{ vms: tc.foundGCPVMs, }, + GCPProjects: newPopulatedGCPProjectsMock(), } ctx := context.Background() @@ -3174,3 +3264,27 @@ func rewriteCloudResource(t *testing.T, r types.ResourceWithLabels, discoveryGro require.FailNow(t, "unknown cloud resource type %T", r) } } + +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 4c15136f31462..7ebb2ab37e384 100644 --- a/lib/srv/discovery/fetchers/gke.go +++ b/lib/srv/discovery/fetchers/gke.go @@ -35,8 +35,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. @@ -50,9 +52,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") } @@ -73,7 +78,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) } @@ -82,19 +87,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 @@ -160,3 +177,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 6c14b83c6f70a..53374682b179d 100644 --- a/lib/srv/discovery/fetchers/gke_test.go +++ b/lib/srv/discovery/fetchers/gke_test.go @@ -35,6 +35,7 @@ func TestGKEFetcher(t *testing.T) { type args struct { location string filterLabels types.Labels + projectID string } tests := []struct { name string @@ -48,8 +49,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", @@ -58,6 +60,7 @@ func TestGKEFetcher(t *testing.T) { filterLabels: types.Labels{ "env": []string{"prod"}, }, + projectID: "p1", }, want: gkeClustersToResources(t, gkeMockClusters[:2]...), }, @@ -69,8 +72,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", @@ -79,6 +83,7 @@ func TestGKEFetcher(t *testing.T) { filterLabels: types.Labels{ "env": []string{"none"}, }, + projectID: "p1", }, want: gkeClustersToResources(t), }, @@ -90,6 +95,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...), }, @@ -97,12 +114,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) @@ -118,7 +137,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 { @@ -172,6 +199,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 { @@ -185,3 +234,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 6b68ed0272cd9..466a85e29fe3b 100644 --- a/lib/srv/server/gcp_watcher.go +++ b/lib/srv/server/gcp_watcher.go @@ -91,13 +91,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, })) } @@ -105,8 +106,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 { @@ -117,6 +119,7 @@ type gcpInstanceFetcher struct { ServiceAccounts []string Labels types.Labels Parameters map[string]string + projectsClient gcp.ProjectsClient } func newGCPInstanceFetcher(cfg gcpFetcherConfig) *gcpInstanceFetcher { @@ -126,6 +129,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{ @@ -145,7 +149,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][]*gcpimds.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][]*gcpimds.Instance) for _, zone := range f.Zones { instanceMap[projectID][zone] = make([]*gcpimds.Instance, 0) @@ -185,3 +193,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 +}