Skip to content

Commit 75580a4

Browse files
ajvpotmartinbailliedependabot[bot]
authored
Adds endpoint to list installations, support org_name lookup with more 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>
1 parent c493250 commit 75580a4

File tree

5 files changed

+410
-23
lines changed

5 files changed

+410
-23
lines changed

github/backend.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ func Factory(ctx context.Context, conf *logical.BackendConfig) (logical.Backend,
5050
},
5151
Paths: []*framework.Path{
5252
b.pathInfo(),
53+
b.pathInstallations(),
5354
b.pathMetrics(),
5455
b.pathConfig(),
5556
b.pathToken(),

github/client.go

Lines changed: 85 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -272,48 +272,110 @@ func (c *Client) accessTokenURLForInstallationID(installationID int) (*url.URL,
272272
return url.ParseRequestURI(fmt.Sprintf(c.accessTokenURLTemplate, installationID))
273273
}
274274

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

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

285-
// Perform the request, re-using the client's shared transport.
286-
res, err := c.installationsClient.Do(req)
290+
return &logical.Response{Data: installations}, nil
291+
}
292+
293+
// installationID makes a round trip to the configured GitHub API in an attempt
294+
// to get the installation ID of the App.
295+
func (c *Client) installationID(ctx context.Context, orgName string) (int, error) {
296+
instResult, err := c.fetchInstallations(ctx)
287297
if err != nil {
288-
return 0, fmt.Errorf("%s: %w", errUnableToGetInstallations, err)
298+
return 0, err
289299
}
290300

291-
defer res.Body.Close()
301+
for _, v := range instResult {
302+
if v.Account.Login == orgName {
303+
return v.ID, nil
304+
}
305+
}
292306

293-
if statusCode(res.StatusCode).Unsuccessful() {
294-
var bodyBytes []byte
307+
return 0, errAppNotInstalled
308+
}
295309

296-
if bodyBytes, err = io.ReadAll(res.Body); err != nil {
297-
return 0, fmt.Errorf("%s: %w", errUnableToGetInstallations, err)
310+
// fetchInstallations makes a request to the GitHub API to fetch the installations.
311+
func (c *Client) fetchInstallations(ctx context.Context) ([]installation, error) {
312+
var allInstallations []installation
313+
url := c.installationsURL.String()
314+
315+
for url != "" {
316+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
317+
if err != nil {
318+
return nil, err
298319
}
299320

300-
bodyErr := fmt.Errorf("%s: %s", res.Status, string(bodyBytes))
321+
req.Header.Set("User-Agent", projectName)
322+
323+
// Perform the request, re-using the client's shared transport.
324+
res, err := c.installationsClient.Do(req)
325+
if err != nil {
326+
return nil, fmt.Errorf("%s: %w", errUnableToGetInstallations, err)
327+
}
328+
329+
defer res.Body.Close()
330+
331+
if statusCode(res.StatusCode).Unsuccessful() {
332+
var bodyBytes []byte
301333

302-
return 0, fmt.Errorf("%s: %w", errUnableToGetInstallations, bodyErr)
334+
if bodyBytes, err = io.ReadAll(res.Body); err != nil {
335+
return nil, fmt.Errorf("%s: %w", errUnableToGetInstallations, err)
336+
}
337+
338+
bodyErr := fmt.Errorf("%s: %s", res.Status, string(bodyBytes))
339+
340+
return nil, fmt.Errorf("%s: %w", errUnableToGetInstallations, bodyErr)
341+
}
342+
343+
var instResult []installation
344+
if err = json.NewDecoder(res.Body).Decode(&instResult); err != nil {
345+
return nil, fmt.Errorf("%s: %w", errUnableToDecodeInstallationsRes, err)
346+
}
347+
348+
allInstallations = append(allInstallations, instResult...)
349+
350+
// Check for pagination
351+
url = getNextPageURL(res.Header.Get("Link"))
303352
}
304353

305-
var instResult []installation
306-
if err = json.NewDecoder(res.Body).Decode(&instResult); err != nil {
307-
return 0, fmt.Errorf("%s: %w", errUnableToDecodeInstallationsRes, err)
354+
return allInstallations, nil
355+
}
356+
357+
// getNextPageURL parses the Link header to find the URL for the next page.
358+
func getNextPageURL(linkHeader string) string {
359+
if linkHeader == "" {
360+
return ""
308361
}
309362

310-
for _, v := range instResult {
311-
if v.Account.Login == orgName {
312-
return v.ID, nil
363+
links := strings.Split(linkHeader, ",")
364+
for _, link := range links {
365+
parts := strings.Split(strings.TrimSpace(link), ";")
366+
if len(parts) < 2 {
367+
continue
368+
}
369+
370+
urlPart := strings.Trim(parts[0], "<>")
371+
relPart := strings.TrimSpace(parts[1])
372+
373+
if relPart == `rel="next"` {
374+
return urlPart
313375
}
314376
}
315377

316-
return 0, errAppNotInstalled
378+
return ""
317379
}
318380

319381
// Model the parts of a installations list response that we care about.

github/path_installations.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"strconv"
6+
"time"
7+
8+
"github.com/hashicorp/vault/sdk/framework"
9+
"github.com/hashicorp/vault/sdk/logical"
10+
"github.com/prometheus/client_golang/prometheus"
11+
)
12+
13+
// pathPatternInstallation is the string used to define the base path of the
14+
// installations endpoint.
15+
const pathPatternInstallations = "installations"
16+
17+
const (
18+
pathInstallationsHelpSyn = `
19+
List GitHub App installations associated with this plugin's configuration.
20+
`
21+
pathInstallationsHelpDesc = `
22+
This endpoint returns a mapping of GitHub organization names to their
23+
corresponding installation IDs for the App associated with this plugin's
24+
configuration. It automatically handles GitHub API pagination, combining results
25+
from all pages into a single response. This ensures complete results even for
26+
GitHub Apps installed on many organizations.
27+
`
28+
)
29+
30+
func (b *backend) pathInstallations() *framework.Path {
31+
return &framework.Path{
32+
Pattern: pathPatternInstallations,
33+
Fields: map[string]*framework.FieldSchema{},
34+
ExistenceCheck: b.pathInstallationsExistenceCheck,
35+
Operations: map[logical.Operation]framework.OperationHandler{
36+
// As per the issue request in https://git.io/JUhRk, allow Vault
37+
// Reads (i.e. HTTP GET) to also write the GitHub installations.
38+
logical.ReadOperation: &framework.PathOperation{
39+
Callback: withFieldValidator(b.pathInstallationsWrite),
40+
},
41+
},
42+
HelpSynopsis: pathInstallationsHelpSyn,
43+
HelpDescription: pathInstallationsHelpDesc,
44+
}
45+
}
46+
47+
// pathInstallationsWrite corresponds to READ, CREATE and UPDATE on /github/installations.
48+
func (b *backend) pathInstallationsWrite(
49+
ctx context.Context,
50+
req *logical.Request,
51+
_ *framework.FieldData,
52+
) (res *logical.Response, err error) {
53+
client, done, err := b.Client(ctx, req.Storage)
54+
if err != nil {
55+
return nil, err
56+
}
57+
58+
defer done()
59+
60+
// Instrument and log the installations API call, recording status, duration and
61+
// whether any constraints (permissions, repository IDs) were requested.
62+
defer func(begin time.Time) {
63+
duration := time.Since(begin)
64+
b.Logger().Debug("attempted to fetch installations",
65+
"took", duration.String(),
66+
"err", err,
67+
)
68+
installationsDuration.With(prometheus.Labels{
69+
"success": strconv.FormatBool(err == nil),
70+
}).Observe(duration.Seconds())
71+
}(time.Now())
72+
73+
// Perform the installations request.
74+
return client.ListInstallations(ctx)
75+
}
76+
77+
// pathInstallationsExistenceCheck always returns false to force the Create path. This
78+
// plugin predates the framework's 'ExistenceCheck' features and we wish to
79+
// avoid changing any contracts with the user at this stage. Installations are created
80+
// regardless of whether the request is a CREATE, UPDATE or even READ (per a
81+
// user's request (https://git.io/JUhRk).
82+
func (b *backend) pathInstallationsExistenceCheck(
83+
context.Context, *logical.Request, *framework.FieldData,
84+
) (bool, error) {
85+
return false, nil
86+
}

0 commit comments

Comments
 (0)