diff --git a/cli/cmd/bootstrap_gcp.go b/cli/cmd/bootstrap_gcp.go index f522419..84ca8cf 100644 --- a/cli/cmd/bootstrap_gcp.go +++ b/cli/cmd/bootstrap_gcp.go @@ -61,10 +61,12 @@ func AddBootstrapGcpCmd(parent *cobra.Command, opts *GlobalOptions) { flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.ProjectTTL, "project-ttl", "2h", "Time to live for the GCP project. Cleanup workflows will remove it afterwards. (default: 2 hours)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.BillingAccount, "billing-account", "", "GCP Billing Account ID (required)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.BaseDomain, "base-domain", "", "Base domain for Codesphere (required)") - flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.GithubAppClientID, "github-app-client-id", "", "Github App Client ID (required)") - flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.GithubAppClientSecret, "github-app-client-secret", "", "Github App Client Secret (required)") - flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.GitHubPAT, "github-pat", "", "GitHub Personal Access Token to use for direct image access. Scope required: package read (optional)") - flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.GitHubAppName, "github-app-name", "", "Github App Name (optional)") + flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.GitHubAppClientID, "github-app-client-id", "", "GitHub App Client ID (required)") + flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.GitHubAppClientSecret, "github-app-client-secret", "", "GitHub App Client Secret (required)") + flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.GitHubPAT, "github-pat", "", "GitHub Personal Access Token used for direct image access and fetching team SSH keys. Required when using --github-team-org/--github-team-slug. Required scopes: read:packages, read:org.") + flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.GitHubAppName, "github-app-name", "", "GitHub App Name (optional)") + flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.GitHubTeamOrg, "github-team-org", "", "GitHub organization used to fetch team SSH keys (optional, used with --github-team-slug). Requires --github-pat with at least the read:org scope.") + flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.GitHubTeamSlug, "github-team-slug", "", "GitHub team slug used to fetch team SSH keys (optional, used with --github-team-org). Requires --github-pat with at least the read:org scope.") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.SecretsDir, "secrets-dir", "/etc/codesphere/secrets", "Directory for secrets (default: /etc/codesphere/secrets)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.FolderID, "folder-id", "", "GCP Folder ID (optional)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.SSHPublicKeyPath, "ssh-public-key-path", "~/.ssh/id_rsa.pub", "SSH Public Key Path (default: ~/.ssh/id_rsa.pub)") @@ -110,8 +112,10 @@ func (c *BootstrapGcpCmd) BootstrapGcp() error { gcpClient := gcp.NewGCPClient(ctx, stlog, os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")) fw := util.NewFilesystemWriter() portalClient := portal.NewPortalClient() + githubClient := gcp.NewGitHubClient(ctx, c.CodesphereEnv.GitHubPAT) - bs, err := gcp.NewGCPBootstrapper(ctx, + bs, err := gcp.NewGCPBootstrapper( + ctx, c.Env, stlog, c.CodesphereEnv, @@ -121,6 +125,7 @@ func (c *BootstrapGcpCmd) BootstrapGcp() error { node.NewSSHNodeClient(c.SSHQuiet), portalClient, util.NewTime(), + githubClient, ) if err != nil { return err diff --git a/docs/oms_beta_bootstrap-gcp.md b/docs/oms_beta_bootstrap-gcp.md index 93003eb..d9e1244 100644 --- a/docs/oms_beta_bootstrap-gcp.md +++ b/docs/oms_beta_bootstrap-gcp.md @@ -26,10 +26,12 @@ oms beta bootstrap-gcp [flags] --experiments stringArray Experiments to enable in Codesphere installation (optional) (default [managed-services,vcluster,custom-service-image,ms-in-ls,secret-management,sub-path-mount]) --feature-flags stringArray Feature flags to enable in Codesphere installation (optional) --folder-id string GCP Folder ID (optional) - --github-app-client-id string Github App Client ID (required) - --github-app-client-secret string Github App Client Secret (required) - --github-app-name string Github App Name (optional) - --github-pat string GitHub Personal Access Token to use for direct image access. Scope required: package read (optional) + --github-app-client-id string GitHub App Client ID (required) + --github-app-client-secret string GitHub App Client Secret (required) + --github-app-name string GitHub App Name (optional) + --github-pat string GitHub Personal Access Token used for direct image access and fetching team SSH keys. Required when using --github-team-org/--github-team-slug. Required scopes: read:packages, read:org. + --github-team-org string GitHub organization used to fetch team SSH keys (optional, used with --github-team-slug). Requires --github-pat with at least the read:org scope. + --github-team-slug string GitHub team slug used to fetch team SSH keys (optional, used with --github-team-org). Requires --github-pat with at least the read:org scope. -h, --help help for bootstrap-gcp --install-config string Path to install config file (optional) (default "config.yaml") --install-hash string Codesphere package hash to install (default: none) diff --git a/internal/bootstrap/gcp/gcp.go b/internal/bootstrap/gcp/gcp.go index 11a2ba5..dadeeb9 100644 --- a/internal/bootstrap/gcp/gcp.go +++ b/internal/bootstrap/gcp/gcp.go @@ -22,6 +22,7 @@ import ( "github.com/codesphere-cloud/oms/internal/installer/node" "github.com/codesphere-cloud/oms/internal/portal" "github.com/codesphere-cloud/oms/internal/util" + "github.com/google/go-github/v74/github" "github.com/lithammer/shortuuid" "google.golang.org/api/dns/v1" "google.golang.org/api/googleapi" @@ -122,6 +123,7 @@ type GCPBootstrapper struct { // SSH command runner NodeClient node.NodeClient PortalClient portal.Portal + GitHubClient GitHubClient } type CodesphereEnvironment struct { @@ -146,6 +148,8 @@ type CodesphereEnvironment struct { RegistryType RegistryType `json:"registry_type"` GitHubPAT string `json:"-"` GitHubAppName string `json:"-"` + GitHubTeamOrg string `json:"github_team_org"` + GitHubTeamSlug string `json:"github_team_slug"` RegistryUser string `json:"-"` Experiments []string `json:"experiments"` FeatureFlags []string `json:"feature_flags"` @@ -166,8 +170,8 @@ type CodesphereEnvironment struct { ProjectDisplayName string `json:"project_display_name"` BillingAccount string `json:"billing_account"` BaseDomain string `json:"base_domain"` - GithubAppClientID string `json:"-"` - GithubAppClientSecret string `json:"-"` + GitHubAppClientID string `json:"-"` + GitHubAppClientSecret string `json:"-"` SecretsDir string `json:"secrets_dir"` FolderID string `json:"folder_id"` SSHPublicKeyPath string `json:"-"` @@ -190,6 +194,7 @@ func NewGCPBootstrapper( sshRunner node.NodeClient, portalClient portal.Portal, time util.Time, + gitHubClient GitHubClient, ) (*GCPBootstrapper, error) { return &GCPBootstrapper{ ctx: ctx, @@ -201,6 +206,7 @@ func NewGCPBootstrapper( NodeClient: sshRunner, PortalClient: portalClient, Time: time, + GitHubClient: gitHubClient, }, nil } @@ -375,7 +381,7 @@ func (b *GCPBootstrapper) ValidateInput() error { return err } - return b.validateGithubParams() + return b.validateGitHubParams() } // validateInstallVersion checks if the specified install version exists and contains the required installer artifact @@ -417,11 +423,20 @@ func (b *GCPBootstrapper) validateInstallVersion() error { return fmt.Errorf("specified package does not contain required installer artifact %s. Existing artifacts: %s", requiredFilename, strings.Join(filenames, ", ")) } -// validateGithubParams checks if the GitHub credentials are fully specified if GitHub registry is selected -func (b *GCPBootstrapper) validateGithubParams() error { - ghParams := []string{b.Env.GitHubAppName, b.Env.GithubAppClientID, b.Env.GithubAppClientSecret} - if slices.Contains(ghParams, "") && strings.Join(ghParams, "") != "" { - return fmt.Errorf("GitHub app credentials are not fully specified (all or none of GitHubAppName, GithubAppClientID, GithubAppClientSecret must be set)") +// validateGitHubParams checks if the GitHub credentials are fully specified if GitHub registry is selected +func (b *GCPBootstrapper) validateGitHubParams() error { + if b.Env.GitHubTeamSlug != "" && b.Env.GitHubTeamOrg != "" && b.Env.GitHubPAT == "" { + return fmt.Errorf("GitHub PAT is required to extract public keys of GitHub team members") + } + + ghTeamParams := []string{b.Env.GitHubTeamSlug, b.Env.GitHubTeamOrg} + if slices.Contains(ghTeamParams, "") && strings.Join(ghTeamParams, "") != "" { + return fmt.Errorf("GitHub team parameters are not fully specified (all or none of GitHubTeamSlug, GitHubTeamOrg must be set)") + } + + ghAppParams := []string{b.Env.GitHubAppName, b.Env.GitHubAppClientID, b.Env.GitHubAppClientSecret} + if slices.Contains(ghAppParams, "") && strings.Join(ghAppParams, "") != "" { + return fmt.Errorf("GitHub app credentials are not fully specified (all or none of GitHubAppName, GitHubAppClientID, GitHubAppClientSecret must be set)") } return nil @@ -757,14 +772,30 @@ func (b *GCPBootstrapper) EnsureComputeInstances() error { subnetwork := fmt.Sprintf("projects/%s/regions/%s/subnetworks/%s-%s-subnet", projectID, region, projectID, region) diskType := fmt.Sprintf("projects/%s/zones/%s/diskTypes/pd-ssd", projectID, zone) - // Create VMs in parallel - wg := sync.WaitGroup{} - errCh := make(chan error, len(vmDefs)) - resultCh := make(chan vmResult, len(vmDefs)) rootDiskSize := int64(200) if b.Env.RegistryType == RegistryTypeGitHub { rootDiskSize = 50 } + sshKeys := "" + var err error + if b.Env.GitHubPAT != "" && b.Env.GitHubTeamOrg != "" && b.Env.GitHubTeamSlug != "" { + sshKeys, err = b.getSSHKeysFromGitHubTeam() + if err != nil { + return fmt.Errorf("failed to get SSH keys from GitHub team: %w", err) + } + } + + pubKey, err := b.readSSHKey(b.Env.SSHPublicKeyPath) + if err != nil { + return err + } + + sshKeys += fmt.Sprintf("root:%s\nubuntu:%s", pubKey+"root", pubKey+"ubuntu") + + // Create VMs in parallel + wg := sync.WaitGroup{} + errCh := make(chan error, len(vmDefs)) + resultCh := make(chan vmResult, len(vmDefs)) for _, vm := range vmDefs { wg.Add(1) go func(vm VMDef) { @@ -793,12 +824,6 @@ func (b *GCPBootstrapper) EnsureComputeInstances() error { }) } - pubKey, err := b.readSSHKey(b.Env.SSHPublicKeyPath) - if err != nil { - errCh <- fmt.Errorf("failed to read SSH public key: %w", err) - return - } - serviceAccount := fmt.Sprintf("cloud-controller@%s.iam.gserviceaccount.com", projectID) instance := &computepb.Instance{ Name: protoString(vm.Name), @@ -826,7 +851,7 @@ func (b *GCPBootstrapper) EnsureComputeInstances() error { Items: []*computepb.Items{ { Key: protoString("ssh-keys"), - Value: protoString(fmt.Sprintf("root:%s\nubuntu:%s", pubKey+"root", pubKey+"ubuntu")), + Value: protoString(sshKeys), }, }, }, @@ -918,6 +943,71 @@ func (b *GCPBootstrapper) EnsureComputeInstances() error { return nil } +// getSSHKeysFromGitHubTeam fetches the public SSH keys of all members of the specified GitHub team and formats them for inclusion in instance metadata. +func (b *GCPBootstrapper) getSSHKeysFromGitHubTeam() (string, error) { + if b.Env.GitHubTeamSlug == "" || b.Env.GitHubTeamOrg == "" || b.Env.GitHubPAT == "" { + return "", fmt.Errorf("GitHub team slug, org, and PAT must be specified to fetch SSH keys from GitHub team") + } + allKeys := "" + + allMembers, err := b.listAllGitHubTeamMembers(b.ctx, b.Env, b.Env.GitHubTeamOrg, b.Env.GitHubTeamSlug) + if err != nil { + return "", fmt.Errorf("failed to list GitHub team members: %w", err) + } + + b.stlog.Logf("Found %d members in team '%s'", len(allMembers), b.Env.GitHubTeamSlug) + + for _, user := range allMembers { + username := user.GetLogin() + keys, err := b.GitHubClient.ListUserKeys(b.ctx, username) + if err != nil { + b.stlog.Logf("Could not fetch keys for %s: %v", username, err) + continue + } + + for _, key := range keys { + allKeys += fmt.Sprintf("root:%s %sroot\nubuntu:%s %subuntu\n", key.GetKey(), username, key.GetKey(), username) + } + } + + return allKeys, nil +} + +// listAllGitHubTeamMembers retrieves all members of the specified GitHub team, handling pagination to ensure all members are fetched. +func (b *GCPBootstrapper) listAllGitHubTeamMembers(ctx context.Context, env *CodesphereEnvironment, org string, teamSlug string) ([]*github.User, error) { + perPage := 100 + page := 1 + var allMembers []*github.User + + for { + opts := &github.TeamListTeamMembersOptions{ + ListOptions: github.ListOptions{ + Page: page, + PerPage: perPage, + }, + } + + members, err := b.GitHubClient.ListTeamMembersBySlug(b.ctx, b.Env.GitHubTeamOrg, b.Env.GitHubTeamSlug, opts) + if err != nil { + return nil, fmt.Errorf("failed to fetch team members from GitHub: %w", err) + } + + if len(members) == 0 { + break + } + + allMembers = append(allMembers, members...) + + if len(members) < perPage { + break + } + + page++ + } + + return allMembers, nil +} + // EnsureGatewayIPAddresses reserves 2 static external IP addresses for the ingress // controllers of the cluster. func (b *GCPBootstrapper) EnsureGatewayIPAddresses() error { @@ -930,6 +1020,7 @@ func (b *GCPBootstrapper) EnsureGatewayIPAddresses() error { if err != nil { return fmt.Errorf("failed to ensure public gateway IP: %w", err) } + return nil } @@ -961,6 +1052,7 @@ func (b *GCPBootstrapper) EnsureExternalIP(name string) (string, error) { if err == nil && address != nil { return address.GetAddress(), nil } + return "", fmt.Errorf("failed to get address %s after creation", name) } @@ -1332,7 +1424,7 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { } b.Env.InstallConfig.Codesphere.GitProviders = &files.GitProvidersConfig{} - if b.Env.GitHubAppName != "" && b.Env.GithubAppClientID != "" && b.Env.GithubAppClientSecret != "" { + if b.Env.GitHubAppName != "" && b.Env.GitHubAppClientID != "" && b.Env.GitHubAppClientSecret != "" { b.Env.InstallConfig.Codesphere.GitProviders.GitHub = &files.GitProviderConfig{ Enabled: true, URL: "https://github.com", @@ -1347,8 +1439,8 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { RedirectURI: "https://cs." + b.Env.BaseDomain + "/ide/auth/github/callback", InstallationURI: "https://github.com/apps/" + b.Env.GitHubAppName + "/installations/new", - ClientID: b.Env.GithubAppClientID, - ClientSecret: b.Env.GithubAppClientSecret, + ClientID: b.Env.GitHubAppClientID, + ClientSecret: b.Env.GitHubAppClientSecret, }, } } diff --git a/internal/bootstrap/gcp/gcp_test.go b/internal/bootstrap/gcp/gcp_test.go index fecdcff..cb07265 100644 --- a/internal/bootstrap/gcp/gcp_test.go +++ b/internal/bootstrap/gcp/gcp_test.go @@ -22,6 +22,7 @@ import ( "github.com/codesphere-cloud/oms/internal/installer/node" "github.com/codesphere-cloud/oms/internal/portal" "github.com/codesphere-cloud/oms/internal/util" + "github.com/google/go-github/v74/github" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/stretchr/testify/mock" @@ -47,6 +48,7 @@ var _ = Describe("GCP Bootstrapper", func() { fw *util.MockFileIO stlog *bootstrap.StepLogger mockPortalClient *portal.MockPortal + mockGitHubClient *gcp.MockGitHubClient bs *gcp.GCPBootstrapper ) @@ -64,6 +66,7 @@ var _ = Describe("GCP Bootstrapper", func() { nodeClient, mockPortalClient, util.NewFakeTime(), + mockGitHubClient, ) Expect(err).NotTo(HaveOccurred()) }) @@ -76,12 +79,13 @@ var _ = Describe("GCP Bootstrapper", func() { gc = gcp.NewMockGCPClientManager(GinkgoT()) fw = util.NewMockFileIO(GinkgoT()) mockPortalClient = portal.NewMockPortal(GinkgoT()) + mockGitHubClient = gcp.NewMockGitHubClient(GinkgoT()) stlog = bootstrap.NewStepLogger(false) csEnv = &gcp.CodesphereEnvironment{ GitHubAppName: "fake-app", - GithubAppClientID: "fake-client-id", - GithubAppClientSecret: "fake-secret", + GitHubAppClientID: "fake-client-id", + GitHubAppClientSecret: "fake-secret", InstallConfigPath: "fake-config-file", SecretsFilePath: "fake-secret", ProjectName: "test-project", @@ -135,11 +139,11 @@ var _ = Describe("GCP Bootstrapper", func() { // 1. EnsureInstallConfig fw.EXPECT().Exists("fake-config-file").Return(false) - icg.EXPECT().ApplyProfile("dev").Return(nil) + icg.EXPECT().ApplyProfile("minimal").Return(nil) // Returning a real install config to avoid nil pointer dereferences later icg.EXPECT().GetInstallConfig().RunAndReturn(func() *files.RootConfig { realIcm := installer.NewInstallConfigManager() - _ = realIcm.ApplyProfile("dev") + _ = realIcm.ApplyProfile("minimal") return realIcm.GetInstallConfig() }) @@ -196,7 +200,7 @@ var _ = Describe("GCP Bootstrapper", func() { } gc.EXPECT().GetInstance(projectId, "us-central1-a", mock.Anything).Return(ipResp, nil).Times(9) - fw.EXPECT().ReadFile(mock.Anything).Return([]byte("fake-key"), nil).Times(9) + fw.EXPECT().ReadFile(mock.Anything).Return([]byte("fake-key"), nil).Times(1) // 12. EnsureGatewayIPAddresses gc.EXPECT().GetAddress(projectId, "us-central1", "gateway").Return(nil, fmt.Errorf("not found")) @@ -278,6 +282,41 @@ var _ = Describe("GCP Bootstrapper", func() { var ( artifacts []portal.Artifact ) + Context("When GitHub team and org is set", func() { + BeforeEach(func() { + csEnv.GitHubTeamOrg = "codesphere-cloud" + csEnv.GitHubTeamSlug = "dev" + }) + Context("when github PAT is set", func() { + BeforeEach(func() { + csEnv.GitHubPAT = "pat" + }) + It("passes validation", func() { + err := bs.ValidateInput() + Expect(err).NotTo(HaveOccurred()) + }) + + Context("when GitHub arguments are partially set", func() { + BeforeEach(func() { + csEnv.GitHubTeamOrg = "" + }) + It("fails", func() { + err := bs.ValidateInput() + Expect(err).To(MatchError(MatchRegexp("GitHub team parameters are not fully specified"))) + }) + }) + }) + + Context("when github PAT is not set", func() { + BeforeEach(func() { + csEnv.GitHubPAT = "" + }) + It("fails", func() { + err := bs.ValidateInput() + Expect(err).To(MatchError(MatchRegexp("GitHub PAT is required to extract public keys of GitHub team members"))) + }) + }) + }) Context("When a version and hash are specified", func() { BeforeEach(func() { mockPortalClient = portal.NewMockPortal(GinkgoT()) @@ -416,7 +455,7 @@ var _ = Describe("GCP Bootstrapper", func() { It("creates install config when missing", func() { fw.EXPECT().Exists(csEnv.InstallConfigPath).Return(false) - icg.EXPECT().ApplyProfile("dev").Return(nil) + icg.EXPECT().ApplyProfile("minimal").Return(nil) icg.EXPECT().GetInstallConfig().Return(&files.RootConfig{}) err := bs.EnsureInstallConfig() @@ -438,7 +477,7 @@ var _ = Describe("GCP Bootstrapper", func() { It("returns error when config file missing and applying profile fails", func() { fw.EXPECT().Exists(csEnv.InstallConfigPath).Return(false) - icg.EXPECT().ApplyProfile("dev").Return(fmt.Errorf("profile error")) + icg.EXPECT().ApplyProfile("minimal").Return(fmt.Errorf("profile error")) err := bs.EnsureInstallConfig() Expect(err).To(HaveOccurred()) @@ -960,8 +999,8 @@ var _ = Describe("GCP Bootstrapper", func() { }) Describe("Valid EnsureComputeInstances", func() { It("creates all instances", func() { - // Mock ReadFile for SSH key (called 9 times in parallel) - fw.EXPECT().ReadFile(csEnv.SSHPublicKeyPath).Return([]byte("ssh-rsa AAA..."), nil).Times(9) + // Mock ReadFile for SSH key + fw.EXPECT().ReadFile(csEnv.SSHPublicKeyPath).Return([]byte("ssh-rsa AAA..."), nil).Times(1) // Mock CreateInstance (9 times) gc.EXPECT().CreateInstance(csEnv.ProjectID, csEnv.Zone, mock.Anything).Return(nil).Times(9) @@ -986,6 +1025,99 @@ var _ = Describe("GCP Bootstrapper", func() { Expect(bs.Env.PostgreSQLNode).NotTo(BeNil()) Expect(bs.Env.Jumpbox).NotTo(BeNil()) }) + + Context("When github team is set", func() { + BeforeEach(func() { + csEnv.GitHubTeamOrg = "someorg" + csEnv.GitHubTeamSlug = "" + csEnv.GitHubPAT = "pat" + }) + It("does not fetch GitHub org keys when GitHub team org is set without slug", func() { + fw.EXPECT().ReadFile(csEnv.SSHPublicKeyPath).Return([]byte("ssh-rsa AAA..."), nil).Times(1) + gc.EXPECT().CreateInstance(csEnv.ProjectID, csEnv.Zone, mock.Anything).Return(nil).Times(9) + ipResp := &computepb.Instance{ + NetworkInterfaces: []*computepb.NetworkInterface{{ + NetworkIP: protoString("10.0.0.x"), + AccessConfigs: []*computepb.AccessConfig{{NatIP: protoString("1.2.3.x")}}, + }}, + } + gc.EXPECT().GetInstance(csEnv.ProjectID, csEnv.Zone, mock.Anything).Return(ipResp, nil).Times(9) + + err := bs.EnsureComputeInstances() + Expect(err).NotTo(HaveOccurred()) + }) + Context("When GitHub team org and slug are set", func() { + BeforeEach(func() { + csEnv.GitHubTeamSlug = "dev" + }) + It("fetches GitHub team keys when org and team are set", func() { + mockGitHubClient.EXPECT().ListTeamMembersBySlug(mock.Anything, csEnv.GitHubTeamOrg, csEnv.GitHubTeamSlug, mock.Anything).Return([]*github.User{{Login: github.Ptr("alice")}}, nil).Once() + mockGitHubClient.EXPECT().ListUserKeys(mock.Anything, "alice").Return([]*github.Key{{Key: github.Ptr("ssh-rsa AAALICE...")}}, nil).Once() + + fw.EXPECT().ReadFile(csEnv.SSHPublicKeyPath).Return([]byte("ssh-rsa AAA..."), nil).Times(1) + gc.EXPECT().CreateInstance(csEnv.ProjectID, csEnv.Zone, mock.Anything).RunAndReturn(func(projectID, zone string, instance *computepb.Instance) error { + sshMetadata := "" + for _, item := range instance.GetMetadata().GetItems() { + if item.GetKey() == "ssh-keys" { + sshMetadata = item.GetValue() + } + } + if !strings.Contains(sshMetadata, "AAALICE...") { + return fmt.Errorf("expected ssh metadata to include team user key") + } + return nil + }).Times(9) + + ipResp := &computepb.Instance{ + NetworkInterfaces: []*computepb.NetworkInterface{{ + NetworkIP: protoString("10.0.0.x"), + AccessConfigs: []*computepb.AccessConfig{{NatIP: protoString("1.2.3.x")}}, + }}, + } + gc.EXPECT().GetInstance(csEnv.ProjectID, csEnv.Zone, mock.Anything).Return(ipResp, nil).Times(9) + + err := bs.EnsureComputeInstances() + Expect(err).NotTo(HaveOccurred()) + }) + + Context("When more than 100 team members exist", func() { + It("handles pagination when listing team members", func() { + // Simulate 150 team members to trigger pagination + var allMembers []*github.User + for i := 0; i < 150; i++ { + allMembers = append(allMembers, &github.User{Login: github.Ptr(fmt.Sprintf("user%d", i))}) + } + mockGitHubClient.EXPECT().ListTeamMembersBySlug(mock.Anything, csEnv.GitHubTeamOrg, csEnv.GitHubTeamSlug, mock.Anything).Return(allMembers[:100], nil).Once() + mockGitHubClient.EXPECT().ListTeamMembersBySlug(mock.Anything, csEnv.GitHubTeamOrg, csEnv.GitHubTeamSlug, mock.Anything).Return(allMembers[100:], nil).Once() + + // Expect ListUserKeys to be called 150 times + mockGitHubClient.EXPECT().ListUserKeys(mock.Anything, mock.Anything).Return([]*github.Key{{Key: github.Ptr("ssh-rsa AAASOMEONE...")}}, nil).Times(150) + + fw.EXPECT().ReadFile(csEnv.SSHPublicKeyPath).Return([]byte("ssh-rsa AAA..."), nil).Times(1) + gc.EXPECT().CreateInstance(csEnv.ProjectID, csEnv.Zone, mock.Anything).Return(nil).Times(9) + + ipResp := &computepb.Instance{ + NetworkInterfaces: []*computepb.NetworkInterface{{ + NetworkIP: protoString("10.0.0.x"), + AccessConfigs: []*computepb.AccessConfig{{NatIP: protoString("1.2.3.x")}}, + }}, + } + gc.EXPECT().GetInstance(csEnv.ProjectID, csEnv.Zone, mock.Anything).Return(ipResp, nil).Times(9) + + err := bs.EnsureComputeInstances() + Expect(err).NotTo(HaveOccurred()) + }) + }) + + It("fails when wrapped GitHub client fails to list team members", func() { + mockGitHubClient.EXPECT().ListTeamMembersBySlug(mock.Anything, csEnv.GitHubTeamOrg, csEnv.GitHubTeamSlug, mock.Anything).Return(nil, fmt.Errorf("list members error")).Once() + + err := bs.EnsureComputeInstances() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to get SSH keys from GitHub team")) + }) + }) + }) }) Describe("Invalid cases", func() { @@ -994,7 +1126,7 @@ var _ = Describe("GCP Bootstrapper", func() { err := bs.EnsureComputeInstances() Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("error ensuring compute instances")) + Expect(err.Error()).To(ContainSubstring("error reading SSH key from key.pub")) }) It("fails when CreateInstance fails", func() { diff --git a/internal/bootstrap/gcp/github_client.go b/internal/bootstrap/gcp/github_client.go new file mode 100644 index 0000000..e9c28a3 --- /dev/null +++ b/internal/bootstrap/gcp/github_client.go @@ -0,0 +1,37 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package gcp + +import ( + "context" + + "github.com/google/go-github/v74/github" + "golang.org/x/oauth2" +) + +// GitHubClient abstracts the GitHub API calls used to fetch team SSH keys. +type GitHubClient interface { + ListTeamMembersBySlug(ctx context.Context, org, teamSlug string, opts *github.TeamListTeamMembersOptions) ([]*github.User, error) + ListUserKeys(ctx context.Context, username string) ([]*github.Key, error) +} + +type RealGitHubClient struct { + client *github.Client +} + +func NewGitHubClient(ctx context.Context, token string) *RealGitHubClient { + ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) + tc := oauth2.NewClient(ctx, ts) + return &RealGitHubClient{client: github.NewClient(tc)} +} + +func (c *RealGitHubClient) ListTeamMembersBySlug(ctx context.Context, org, teamSlug string, opts *github.TeamListTeamMembersOptions) ([]*github.User, error) { + members, _, err := c.client.Teams.ListTeamMembersBySlug(ctx, org, teamSlug, opts) + return members, err +} + +func (c *RealGitHubClient) ListUserKeys(ctx context.Context, username string) ([]*github.Key, error) { + keys, _, err := c.client.Users.ListKeys(ctx, username, nil) + return keys, err +} diff --git a/internal/bootstrap/gcp/mocks.go b/internal/bootstrap/gcp/mocks.go index 4fd00cb..ae29bdb 100644 --- a/internal/bootstrap/gcp/mocks.go +++ b/internal/bootstrap/gcp/mocks.go @@ -8,6 +8,8 @@ import ( "cloud.google.com/go/artifactregistry/apiv1/artifactregistrypb" "cloud.google.com/go/compute/apiv1/computepb" "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb" + "context" + "github.com/google/go-github/v74/github" mock "github.com/stretchr/testify/mock" "google.golang.org/api/cloudbilling/v1" "google.golang.org/api/dns/v1" @@ -1570,3 +1572,178 @@ func (_c *MockGCPClientManager_RemoveIAMRoleBinding_Call) RunAndReturn(run func( _c.Call.Return(run) return _c } + +// NewMockGitHubClient creates a new instance of MockGitHubClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockGitHubClient(t interface { + mock.TestingT + Cleanup(func()) +}) *MockGitHubClient { + mock := &MockGitHubClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockGitHubClient is an autogenerated mock type for the GitHubClient type +type MockGitHubClient struct { + mock.Mock +} + +type MockGitHubClient_Expecter struct { + mock *mock.Mock +} + +func (_m *MockGitHubClient) EXPECT() *MockGitHubClient_Expecter { + return &MockGitHubClient_Expecter{mock: &_m.Mock} +} + +// ListTeamMembersBySlug provides a mock function for the type MockGitHubClient +func (_mock *MockGitHubClient) ListTeamMembersBySlug(ctx context.Context, org string, teamSlug string, opts *github.TeamListTeamMembersOptions) ([]*github.User, error) { + ret := _mock.Called(ctx, org, teamSlug, opts) + + if len(ret) == 0 { + panic("no return value specified for ListTeamMembersBySlug") + } + + var r0 []*github.User + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, *github.TeamListTeamMembersOptions) ([]*github.User, error)); ok { + return returnFunc(ctx, org, teamSlug, opts) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, *github.TeamListTeamMembersOptions) []*github.User); ok { + r0 = returnFunc(ctx, org, teamSlug, opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*github.User) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, *github.TeamListTeamMembersOptions) error); ok { + r1 = returnFunc(ctx, org, teamSlug, opts) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockGitHubClient_ListTeamMembersBySlug_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListTeamMembersBySlug' +type MockGitHubClient_ListTeamMembersBySlug_Call struct { + *mock.Call +} + +// ListTeamMembersBySlug is a helper method to define mock.On call +// - ctx context.Context +// - org string +// - teamSlug string +// - opts *github.TeamListTeamMembersOptions +func (_e *MockGitHubClient_Expecter) ListTeamMembersBySlug(ctx interface{}, org interface{}, teamSlug interface{}, opts interface{}) *MockGitHubClient_ListTeamMembersBySlug_Call { + return &MockGitHubClient_ListTeamMembersBySlug_Call{Call: _e.mock.On("ListTeamMembersBySlug", ctx, org, teamSlug, opts)} +} + +func (_c *MockGitHubClient_ListTeamMembersBySlug_Call) Run(run func(ctx context.Context, org string, teamSlug string, opts *github.TeamListTeamMembersOptions)) *MockGitHubClient_ListTeamMembersBySlug_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 *github.TeamListTeamMembersOptions + if args[3] != nil { + arg3 = args[3].(*github.TeamListTeamMembersOptions) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *MockGitHubClient_ListTeamMembersBySlug_Call) Return(users []*github.User, err error) *MockGitHubClient_ListTeamMembersBySlug_Call { + _c.Call.Return(users, err) + return _c +} + +func (_c *MockGitHubClient_ListTeamMembersBySlug_Call) RunAndReturn(run func(ctx context.Context, org string, teamSlug string, opts *github.TeamListTeamMembersOptions) ([]*github.User, error)) *MockGitHubClient_ListTeamMembersBySlug_Call { + _c.Call.Return(run) + return _c +} + +// ListUserKeys provides a mock function for the type MockGitHubClient +func (_mock *MockGitHubClient) ListUserKeys(ctx context.Context, username string) ([]*github.Key, error) { + ret := _mock.Called(ctx, username) + + if len(ret) == 0 { + panic("no return value specified for ListUserKeys") + } + + var r0 []*github.Key + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]*github.Key, error)); ok { + return returnFunc(ctx, username) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) []*github.Key); ok { + r0 = returnFunc(ctx, username) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*github.Key) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, username) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockGitHubClient_ListUserKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListUserKeys' +type MockGitHubClient_ListUserKeys_Call struct { + *mock.Call +} + +// ListUserKeys is a helper method to define mock.On call +// - ctx context.Context +// - username string +func (_e *MockGitHubClient_Expecter) ListUserKeys(ctx interface{}, username interface{}) *MockGitHubClient_ListUserKeys_Call { + return &MockGitHubClient_ListUserKeys_Call{Call: _e.mock.On("ListUserKeys", ctx, username)} +} + +func (_c *MockGitHubClient_ListUserKeys_Call) Run(run func(ctx context.Context, username string)) *MockGitHubClient_ListUserKeys_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockGitHubClient_ListUserKeys_Call) Return(keys []*github.Key, err error) *MockGitHubClient_ListUserKeys_Call { + _c.Call.Return(keys, err) + return _c +} + +func (_c *MockGitHubClient_ListUserKeys_Call) RunAndReturn(run func(ctx context.Context, username string) ([]*github.Key, error)) *MockGitHubClient_ListUserKeys_Call { + _c.Call.Return(run) + return _c +}