Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -693,12 +693,6 @@ Version: v88.0.0
License: BSD-3-Clause
License URL: https://github.com/google/go-github/blob/v88.0.0/LICENSE

----------
Module: github.com/google/go-github/v90/github
Version: v90.0.0
License: BSD-3-Clause
License URL: https://github.com/google/go-github/blob/v90.0.0/LICENSE

----------
Module: github.com/google/go-querystring/query
Version: v1.2.0
Expand Down
6 changes: 2 additions & 4 deletions internal/bootstrap/gcp/gce_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import (
"github.com/codesphere-cloud/oms/internal/bootstrap/gcp"
"github.com/codesphere-cloud/oms/internal/github"
"github.com/codesphere-cloud/oms/internal/util"
gh "github.com/google/go-github/v90/github"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/stretchr/testify/mock"
Expand Down Expand Up @@ -678,8 +677,7 @@ var _ = Describe("GCE", func() {
csEnv.GitHubTeamSlug = "dev"
})
It("fetches GitHub team keys", func() {
mockGitHubClient.EXPECT().ListTeamMembersBySlug(mock.Anything, csEnv.GitHubTeamOrg, csEnv.GitHubTeamSlug, mock.Anything).Return([]*gh.User{{Login: gh.Ptr("alice")}}, nil).Maybe()
mockGitHubClient.EXPECT().ListUserKeys(mock.Anything, "alice").Return([]*gh.Key{{Key: gh.Ptr("ssh-rsa AAALICE...")}}, nil).Maybe()
mockGitHubClient.EXPECT().GetTeamMemberSSHKeys(mock.Anything, csEnv.GitHubTeamOrg, csEnv.GitHubTeamSlug).Return([]github.TeamMemberKeys{{Login: "alice", Keys: []string{"ssh-rsa AAALICE..."}}}, nil).Maybe()
ipResp := makeRunningInstance("10.0.0.x", "1.2.3.x")
mockGetInstanceNotFoundThenRunning(gc, csEnv.ProjectID, csEnv.Zone, ipResp, 8)

Expand All @@ -703,7 +701,7 @@ var _ = Describe("GCE", func() {

It("fails when GitHub client fails to list team members", func() {
gc.EXPECT().GetInstance(csEnv.ProjectID, csEnv.Zone, mock.Anything).Return(nil, grpcstatus.Errorf(codes.NotFound, "not found")).Maybe()
mockGitHubClient.EXPECT().ListTeamMembersBySlug(mock.Anything, csEnv.GitHubTeamOrg, csEnv.GitHubTeamSlug, mock.Anything).Return(nil, fmt.Errorf("list members error")).Maybe()
mockGitHubClient.EXPECT().GetTeamMemberSSHKeys(mock.Anything, csEnv.GitHubTeamOrg, csEnv.GitHubTeamSlug).Return(nil, fmt.Errorf("list members error")).Maybe()

err := bs.EnsureComputeInstances()
Expect(err).To(HaveOccurred())
Expand Down
58 changes: 7 additions & 51 deletions internal/github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,71 +6,27 @@ package github
import (
"context"
"fmt"

"github.com/google/go-github/v90/github"
)

// GetSSHKeysFromGitHubTeam fetches the public SSH keys of all members of the specified GitHub team and formats them for inclusion in instance metadata.
func GetSSHKeysFromGitHubTeam(client GitHubClient, org, teamSlug string) (string, error) {
if org == "" || teamSlug == "" {
return "", fmt.Errorf("GitHub team slug and org must be specified to fetch SSH keys from GitHub team")
}
allKeys := ""

allMembers, err := listAllGitHubTeamMembers(client, org, teamSlug)
members, err := client.GetTeamMemberSSHKeys(context.Background(), org, teamSlug)
if err != nil {
return "", fmt.Errorf("failed to list GitHub team members: %w", err)
return "", fmt.Errorf("failed to fetch SSH keys from GitHub team: %w", err)
}

fmt.Printf("Found %d members in team '%s'\n", len(allMembers), teamSlug)
fmt.Printf("Found %d members in team '%s'\n", len(members), teamSlug)

for _, user := range allMembers {
username := user.GetLogin()
keys, err := client.ListUserKeys(context.Background(), username)
if err != nil {
fmt.Printf("Could not fetch keys for %s: %v\n", username, err)
continue
}

for _, key := range keys {
allKeys += fmt.Sprintf("root:%s %sroot\nubuntu:%s %subuntu\n", key.GetKey(), username, key.GetKey(), username)
allKeys := ""
for _, member := range members {
for _, key := range member.Keys {
allKeys += fmt.Sprintf("root:%s %sroot\nubuntu:%s %subuntu\n", key, member.Login, key, member.Login)
}
}

return allKeys, nil
}

// listAllGitHubTeamMembers retrieves all members of the specified GitHub team, handling pagination to ensure all members are fetched.
func listAllGitHubTeamMembers(client GitHubClient, 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 := client.ListTeamMembersBySlug(context.Background(), org, teamSlug, 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
}
166 changes: 147 additions & 19 deletions internal/github/github_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,46 +4,174 @@
package github

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"

"github.com/google/go-github/v90/github"
"golang.org/x/oauth2"
)

// GitHubClient abstracts the GitHub API calls used to fetch team SSH keys.
const githubGraphQLEndpoint = "https://api.github.com/graphql"

// publicKeysPageSize is how many public SSH keys we request per team member. A user is very
// unlikely to have this many keys; totalCount lets us detect and log the rare case where they do.
const publicKeysPageSize = 20

// teamMemberSSHKeysQuery fetches every member of a team together with their public SSH keys in a
// single request. Members are paginated with the $after cursor; publicKeys are fetched in a single
// page of publicKeysPageSize and totalCount is used to detect truncation.
const teamMemberSSHKeysQuery = `query($org: String!, $team: String!, $after: String) {
organization(login: $org) {
team(slug: $team) {
members(first: 100, after: $after) {
pageInfo { hasNextPage endCursor }
nodes {
login
publicKeys(first: 20) { totalCount nodes { key } }
}
}
}
}
}`

// TeamMemberKeys holds a team member's login and their public SSH keys.
type TeamMemberKeys struct {
Login string
Keys []string
}

// GitHubClient abstracts the GitHub API call used to fetch team SSH keys.
//
//mockery:generate: true
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)
GetTeamMemberSSHKeys(ctx context.Context, org, teamSlug string) ([]TeamMemberKeys, error)
}

type RealGitHubClient struct {
client *github.Client
httpClient *http.Client
endpoint string
}

// NewGitHubClient creates a new RealGitHubClient with the provided OAuth token.
func NewGitHubClient(ctx context.Context, token string) (*RealGitHubClient, error) {
ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})
tc := oauth2.NewClient(ctx, ts)
return &RealGitHubClient{
httpClient: oauth2.NewClient(ctx, ts),
endpoint: githubGraphQLEndpoint,
}, nil
}

client, err := github.NewClient(github.WithHTTPClient(tc))
if err != nil {
return nil, fmt.Errorf("creating github client: %w", err)
// graphQLResponse mirrors the shape of the teamMemberSSHKeysQuery response.
type graphQLResponse struct {
Data struct {
Organization struct {
Team struct {
Members struct {
PageInfo struct {
HasNextPage bool `json:"hasNextPage"`
EndCursor string `json:"endCursor"`
} `json:"pageInfo"`
Nodes []struct {
Login string `json:"login"`
PublicKeys struct {
TotalCount int `json:"totalCount"`
Nodes []struct {
Key string `json:"key"`
} `json:"nodes"`
} `json:"publicKeys"`
} `json:"nodes"`
} `json:"members"`
} `json:"team"`
} `json:"organization"`
} `json:"data"`
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
}

// GetTeamMemberSSHKeys fetches all members of the team and their public SSH keys via the GitHub
// GraphQL API, following member pagination until every member has been retrieved.
func (c *RealGitHubClient) GetTeamMemberSSHKeys(ctx context.Context, org, teamSlug string) ([]TeamMemberKeys, error) {
var members []TeamMemberKeys
var after *string

for {
resp, err := c.queryTeamMembers(ctx, org, teamSlug, after)
if err != nil {
return nil, err
}
Comment thread
gnarlex marked this conversation as resolved.

team := resp.Data.Organization.Team
for _, node := range team.Members.Nodes {
if node.PublicKeys.TotalCount > publicKeysPageSize {
fmt.Printf("User %s has %d public keys but only the first %d were fetched\n",
node.Login, node.PublicKeys.TotalCount, publicKeysPageSize)
}
keys := make([]string, 0, len(node.PublicKeys.Nodes))
for _, k := range node.PublicKeys.Nodes {
keys = append(keys, k.Key)
}
members = append(members, TeamMemberKeys{Login: node.Login, Keys: keys})
}

if !team.Members.PageInfo.HasNextPage {
break
}
cursor := team.Members.PageInfo.EndCursor
after = &cursor
}

return &RealGitHubClient{client: client}, nil
return members, nil
Comment thread
gnarlex marked this conversation as resolved.
}

// ListTeamMembersBySlug lists the members of a GitHub team identified by its slug.
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
}
// queryTeamMembers executes a single page of the teamMemberSSHKeysQuery.
func (c *RealGitHubClient) queryTeamMembers(ctx context.Context, org, teamSlug string, after *string) (*graphQLResponse, error) {
variables := map[string]any{"org": org, "team": teamSlug}
if after != nil {
variables["after"] = *after
}

body, err := json.Marshal(map[string]any{"query": teamMemberSSHKeysQuery, "variables": variables})
if err != nil {
return nil, fmt.Errorf("failed to marshal GraphQL request: %w", err)
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create GraphQL request: %w", err)
}
req.Header.Set("Content-Type", "application/json")

httpResp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute GraphQL request: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()

respBody, err := io.ReadAll(httpResp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read GraphQL response: %w", err)
}

if httpResp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GraphQL request failed with status %d: %s", httpResp.StatusCode, string(respBody))
}

var result graphQLResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal GraphQL response: %w", err)
}
if len(result.Errors) > 0 {
msgs := make([]string, len(result.Errors))
for i, e := range result.Errors {
msgs[i] = e.Message
}
return nil, fmt.Errorf("GraphQL query returned errors: %s", strings.Join(msgs, "; "))
}
Comment thread
gnarlex marked this conversation as resolved.

// ListUserKeys lists the public SSH keys of a GitHub user.
func (c *RealGitHubClient) ListUserKeys(ctx context.Context, username string) ([]*github.Key, error) {
keys, _, err := c.client.Users.ListKeys(ctx, username, nil)
return keys, err
return &result, nil
}
Loading