Skip to content

Commit

Permalink
Adds endpoint to list installations, support org_name lookup with mor…
Browse files Browse the repository at this point in the history
…e than 30 installations (#141)

* Add plugin version to Vault backend

Signed-off-by: Martin Baillie <[email protected]>

* Bump codecov/codecov-action from 4.1.0 to 4.5.0

Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.1.0 to 4.5.0.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](codecov/codecov-action@v4.1.0...v4.5.0)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>

* Fix formatting

Signed-off-by: Martin Baillie <[email protected]>

* Bump codecov/codecov-action from 4.5.0 to 5.1.2

Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.5.0 to 5.1.2.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](codecov/codecov-action@v4.5.0...v5.1.2)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>

* installations endpoint

* test

* tests pass

* add pagination

* Improve GitHub Installations API documentation

Signed-off-by: Martin Baillie <[email protected]>

* Pre-allocate map

Signed-off-by: Martin Baillie <[email protected]>

* Add pagination tests for installation path handling

Signed-off-by: Martin Baillie <[email protected]>

---------

Signed-off-by: Martin Baillie <[email protected]>
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: Martin Baillie <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
  • Loading branch information
3 people authored Jan 21, 2025
1 parent c493250 commit 75580a4
Show file tree
Hide file tree
Showing 5 changed files with 410 additions and 23 deletions.
1 change: 1 addition & 0 deletions github/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ func Factory(ctx context.Context, conf *logical.BackendConfig) (logical.Backend,
},
Paths: []*framework.Path{
b.pathInfo(),
b.pathInstallations(),
b.pathMetrics(),
b.pathConfig(),
b.pathToken(),
Expand Down
108 changes: 85 additions & 23 deletions github/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -272,48 +272,110 @@ func (c *Client) accessTokenURLForInstallationID(installationID int) (*url.URL,
return url.ParseRequestURI(fmt.Sprintf(c.accessTokenURLTemplate, installationID))
}

// installationID makes a round trip to the configured GitHub API in an attempt to get the
// installation ID of the App.
func (c *Client) installationID(ctx context.Context, orgName string) (int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.installationsURL.String(), nil)
// ListInstallations retrieves a list of App installations associated with the
// client. It returns a logical.Response containing a map where the keys are
// account names and the values are corresponding installation IDs. In case of
// an error during the fetch operation, it returns nil and the error.
func (c *Client) ListInstallations(ctx context.Context) (*logical.Response, error) {
instResult, err := c.fetchInstallations(ctx)
if err != nil {
return 0, err
return nil, err
}

req.Header.Set("User-Agent", projectName)
installations := make(map[string]any, len(instResult))
for _, v := range instResult {
installations[v.Account.Login] = v.ID
}

// Perform the request, re-using the client's shared transport.
res, err := c.installationsClient.Do(req)
return &logical.Response{Data: installations}, nil
}

// installationID makes a round trip to the configured GitHub API in an attempt
// to get the installation ID of the App.
func (c *Client) installationID(ctx context.Context, orgName string) (int, error) {
instResult, err := c.fetchInstallations(ctx)
if err != nil {
return 0, fmt.Errorf("%s: %w", errUnableToGetInstallations, err)
return 0, err
}

defer res.Body.Close()
for _, v := range instResult {
if v.Account.Login == orgName {
return v.ID, nil
}
}

if statusCode(res.StatusCode).Unsuccessful() {
var bodyBytes []byte
return 0, errAppNotInstalled
}

if bodyBytes, err = io.ReadAll(res.Body); err != nil {
return 0, fmt.Errorf("%s: %w", errUnableToGetInstallations, err)
// fetchInstallations makes a request to the GitHub API to fetch the installations.
func (c *Client) fetchInstallations(ctx context.Context) ([]installation, error) {
var allInstallations []installation
url := c.installationsURL.String()

for url != "" {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}

bodyErr := fmt.Errorf("%s: %s", res.Status, string(bodyBytes))
req.Header.Set("User-Agent", projectName)

// Perform the request, re-using the client's shared transport.
res, err := c.installationsClient.Do(req)
if err != nil {
return nil, fmt.Errorf("%s: %w", errUnableToGetInstallations, err)
}

defer res.Body.Close()

if statusCode(res.StatusCode).Unsuccessful() {
var bodyBytes []byte

return 0, fmt.Errorf("%s: %w", errUnableToGetInstallations, bodyErr)
if bodyBytes, err = io.ReadAll(res.Body); err != nil {
return nil, fmt.Errorf("%s: %w", errUnableToGetInstallations, err)
}

bodyErr := fmt.Errorf("%s: %s", res.Status, string(bodyBytes))

return nil, fmt.Errorf("%s: %w", errUnableToGetInstallations, bodyErr)
}

var instResult []installation
if err = json.NewDecoder(res.Body).Decode(&instResult); err != nil {
return nil, fmt.Errorf("%s: %w", errUnableToDecodeInstallationsRes, err)
}

allInstallations = append(allInstallations, instResult...)

// Check for pagination
url = getNextPageURL(res.Header.Get("Link"))
}

var instResult []installation
if err = json.NewDecoder(res.Body).Decode(&instResult); err != nil {
return 0, fmt.Errorf("%s: %w", errUnableToDecodeInstallationsRes, err)
return allInstallations, nil
}

// getNextPageURL parses the Link header to find the URL for the next page.
func getNextPageURL(linkHeader string) string {
if linkHeader == "" {
return ""
}

for _, v := range instResult {
if v.Account.Login == orgName {
return v.ID, nil
links := strings.Split(linkHeader, ",")
for _, link := range links {
parts := strings.Split(strings.TrimSpace(link), ";")
if len(parts) < 2 {
continue
}

urlPart := strings.Trim(parts[0], "<>")
relPart := strings.TrimSpace(parts[1])

if relPart == `rel="next"` {
return urlPart
}
}

return 0, errAppNotInstalled
return ""
}

// Model the parts of a installations list response that we care about.
Expand Down
86 changes: 86 additions & 0 deletions github/path_installations.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package github

import (
"context"
"strconv"
"time"

"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
"github.com/prometheus/client_golang/prometheus"
)

// pathPatternInstallation is the string used to define the base path of the
// installations endpoint.
const pathPatternInstallations = "installations"

const (
pathInstallationsHelpSyn = `
List GitHub App installations associated with this plugin's configuration.
`
pathInstallationsHelpDesc = `
This endpoint returns a mapping of GitHub organization names to their
corresponding installation IDs for the App associated with this plugin's
configuration. It automatically handles GitHub API pagination, combining results
from all pages into a single response. This ensures complete results even for
GitHub Apps installed on many organizations.
`
)

func (b *backend) pathInstallations() *framework.Path {
return &framework.Path{
Pattern: pathPatternInstallations,
Fields: map[string]*framework.FieldSchema{},
ExistenceCheck: b.pathInstallationsExistenceCheck,
Operations: map[logical.Operation]framework.OperationHandler{
// As per the issue request in https://git.io/JUhRk, allow Vault
// Reads (i.e. HTTP GET) to also write the GitHub installations.
logical.ReadOperation: &framework.PathOperation{
Callback: withFieldValidator(b.pathInstallationsWrite),
},
},
HelpSynopsis: pathInstallationsHelpSyn,
HelpDescription: pathInstallationsHelpDesc,
}
}

// pathInstallationsWrite corresponds to READ, CREATE and UPDATE on /github/installations.
func (b *backend) pathInstallationsWrite(
ctx context.Context,
req *logical.Request,
_ *framework.FieldData,
) (res *logical.Response, err error) {
client, done, err := b.Client(ctx, req.Storage)
if err != nil {
return nil, err
}

defer done()

// Instrument and log the installations API call, recording status, duration and
// whether any constraints (permissions, repository IDs) were requested.
defer func(begin time.Time) {
duration := time.Since(begin)
b.Logger().Debug("attempted to fetch installations",
"took", duration.String(),
"err", err,
)
installationsDuration.With(prometheus.Labels{
"success": strconv.FormatBool(err == nil),
}).Observe(duration.Seconds())
}(time.Now())

// Perform the installations request.
return client.ListInstallations(ctx)
}

// pathInstallationsExistenceCheck always returns false to force the Create path. This
// plugin predates the framework's 'ExistenceCheck' features and we wish to
// avoid changing any contracts with the user at this stage. Installations are created
// regardless of whether the request is a CREATE, UPDATE or even READ (per a
// user's request (https://git.io/JUhRk).
func (b *backend) pathInstallationsExistenceCheck(
context.Context, *logical.Request, *framework.FieldData,
) (bool, error) {
return false, nil
}
Loading

0 comments on commit 75580a4

Please sign in to comment.