Skip to content

Commit 2572bff

Browse files
Create a helper token source that requeues (#1135)
The requeues are to avoid spinning during quota exhaustion events. Note that the reconciler (when it directly) hits the quota exhaustion, already uses RequeueAfter with when the quota resets. This aims to detect a class of these where that reset is less clear, and avoid having us spin on the key and DLQ. --------- Signed-off-by: Matt Moore <[email protected]> Co-authored-by: octo-sts[bot] <157150467+octo-sts[bot]@users.noreply.github.com>
1 parent 2653cb0 commit 2572bff

File tree

2 files changed

+365
-0
lines changed

2 files changed

+365
-0
lines changed
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
Copyright 2025 Chainguard, Inc.
3+
SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package githubreconciler
7+
8+
import (
9+
"context"
10+
"time"
11+
12+
"chainguard.dev/sdk/octosts"
13+
"github.com/chainguard-dev/clog"
14+
"github.com/chainguard-dev/terraform-infra-common/pkg/workqueue"
15+
"golang.org/x/oauth2"
16+
"google.golang.org/grpc/codes"
17+
"google.golang.org/grpc/status"
18+
)
19+
20+
// octoTokenFunc is the function used to get tokens from Octo STS.
21+
// This is a variable so tests can override it with a mock.
22+
var octoTokenFunc = octosts.Token
23+
24+
// tokenSource implements oauth2.TokenSource using octosts
25+
type tokenSource struct {
26+
ctx context.Context
27+
identity string
28+
org string
29+
repo string
30+
}
31+
32+
// Token implements oauth2.TokenSource
33+
func (ts *tokenSource) Token() (*oauth2.Token, error) {
34+
ctx, cancel := context.WithTimeout(ts.ctx, 1*time.Minute)
35+
defer cancel()
36+
tok, err := octoTokenFunc(ctx, ts.identity, ts.org, ts.repo)
37+
if err != nil {
38+
// Check if this is a gRPC NotFound error
39+
if status.Code(err) == codes.NotFound {
40+
// A common reason for NotFound from Octo STS is that the org's GitHub App
41+
// installation quota has been exhausted. We log this and requeue with a delay
42+
// to give time for the quota to reset or for manual intervention.
43+
scope := ts.org
44+
if ts.repo != "" {
45+
scope = ts.org + "/" + ts.repo
46+
}
47+
clog.ErrorContextf(ctx, "Got NotFound error from Octo STS for %q: %v", scope, err)
48+
return nil, workqueue.RequeueAfter(10 * time.Minute)
49+
}
50+
return nil, err
51+
}
52+
return &oauth2.Token{
53+
AccessToken: tok,
54+
TokenType: "Bearer",
55+
Expiry: time.Now().Add(55 * time.Minute), // Tokens from Octo STS are valid for 60 minutes
56+
}, nil
57+
}
58+
59+
// NewOrgTokenSource creates a new token source for org-scoped GitHub credentials
60+
func NewOrgTokenSource(ctx context.Context, identity, org string) oauth2.TokenSource {
61+
return oauth2.ReuseTokenSource(nil, &tokenSource{
62+
ctx: ctx,
63+
identity: identity,
64+
org: org,
65+
repo: "",
66+
})
67+
}
68+
69+
// NewRepoTokenSource creates a new token source for repo-scoped GitHub credentials
70+
func NewRepoTokenSource(ctx context.Context, identity, org, repo string) oauth2.TokenSource {
71+
return oauth2.ReuseTokenSource(nil, &tokenSource{
72+
ctx: ctx,
73+
identity: identity,
74+
org: org,
75+
repo: repo,
76+
})
77+
}
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
/*
2+
Copyright 2025 Chainguard, Inc.
3+
SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package githubreconciler
7+
8+
import (
9+
"context"
10+
"errors"
11+
"fmt"
12+
"math/rand/v2"
13+
"testing"
14+
"time"
15+
16+
"github.com/chainguard-dev/terraform-infra-common/pkg/workqueue"
17+
"google.golang.org/grpc/codes"
18+
"google.golang.org/grpc/status"
19+
)
20+
21+
func TestTokenSource_Token_Success(t *testing.T) {
22+
ctx := context.Background()
23+
wantIdentity := fmt.Sprintf("identity-%d", rand.Int64())
24+
wantOrg := fmt.Sprintf("org-%d", rand.Int64())
25+
wantRepo := fmt.Sprintf("repo-%d", rand.Int64())
26+
wantToken := fmt.Sprintf("token-%d", rand.Int64())
27+
28+
// Mock the octoTokenFunc
29+
originalFunc := octoTokenFunc
30+
t.Cleanup(func() { octoTokenFunc = originalFunc })
31+
32+
octoTokenFunc = func(_ context.Context, identity, org, repo string) (string, error) {
33+
if identity != wantIdentity {
34+
t.Errorf("identity: got = %q, wanted = %q", identity, wantIdentity)
35+
}
36+
if org != wantOrg {
37+
t.Errorf("org: got = %q, wanted = %q", org, wantOrg)
38+
}
39+
if repo != wantRepo {
40+
t.Errorf("repo: got = %q, wanted = %q", repo, wantRepo)
41+
}
42+
return wantToken, nil
43+
}
44+
45+
ts := &tokenSource{
46+
ctx: ctx,
47+
identity: wantIdentity,
48+
org: wantOrg,
49+
repo: wantRepo,
50+
}
51+
52+
token, err := ts.Token()
53+
if err != nil {
54+
t.Fatalf("unexpected error: %v", err)
55+
}
56+
57+
if token.AccessToken != wantToken {
58+
t.Errorf("AccessToken: got = %q, wanted = %q", token.AccessToken, wantToken)
59+
}
60+
61+
if token.TokenType != "Bearer" {
62+
t.Errorf("TokenType: got = %q, wanted = %q", token.TokenType, "Bearer")
63+
}
64+
65+
// Check expiry is approximately 55 minutes in the future
66+
expectedExpiry := time.Now().Add(55 * time.Minute)
67+
if token.Expiry.Before(expectedExpiry.Add(-1*time.Second)) || token.Expiry.After(expectedExpiry.Add(1*time.Second)) {
68+
t.Errorf("Expiry: got = %v, wanted approximately = %v", token.Expiry, expectedExpiry)
69+
}
70+
}
71+
72+
func TestTokenSource_Token_NotFoundError(t *testing.T) {
73+
ctx := context.Background()
74+
75+
tests := []struct {
76+
name string
77+
org string
78+
repo string
79+
}{{
80+
name: "org only",
81+
org: fmt.Sprintf("org-%d", rand.Int64()),
82+
repo: "",
83+
}, {
84+
name: "org and repo",
85+
org: fmt.Sprintf("org-%d", rand.Int64()),
86+
repo: fmt.Sprintf("repo-%d", rand.Int64()),
87+
}}
88+
89+
for _, tt := range tests {
90+
t.Run(tt.name, func(t *testing.T) {
91+
// Mock the octoTokenFunc to return NotFound
92+
originalFunc := octoTokenFunc
93+
t.Cleanup(func() { octoTokenFunc = originalFunc })
94+
95+
octoTokenFunc = func(_ context.Context, _, _, _ string) (string, error) {
96+
return "", status.Error(codes.NotFound, "installation not found")
97+
}
98+
99+
ts := &tokenSource{
100+
ctx: ctx,
101+
identity: fmt.Sprintf("identity-%d", rand.Int64()),
102+
org: tt.org,
103+
repo: tt.repo,
104+
}
105+
106+
token, err := ts.Token()
107+
if err == nil {
108+
t.Fatal("expected error but got none")
109+
}
110+
111+
if token != nil {
112+
t.Errorf("token: got = %v, wanted = nil", token)
113+
}
114+
115+
// Check that error is a requeue error with the correct delay
116+
delay, ok := workqueue.GetRequeueDelay(err)
117+
if !ok {
118+
t.Errorf("error type: got non-requeue error, wanted requeue error")
119+
} else if delay != 10*time.Minute {
120+
t.Errorf("requeue duration: got = %v, wanted = %v", delay, 10*time.Minute)
121+
}
122+
})
123+
}
124+
}
125+
126+
func TestTokenSource_Token_OtherError(t *testing.T) {
127+
ctx := context.Background()
128+
wantErr := fmt.Errorf("error-%d", rand.Int64())
129+
130+
// Mock the octoTokenFunc to return a different error
131+
originalFunc := octoTokenFunc
132+
t.Cleanup(func() { octoTokenFunc = originalFunc })
133+
134+
octoTokenFunc = func(_ context.Context, _, _, _ string) (string, error) {
135+
return "", wantErr
136+
}
137+
138+
ts := &tokenSource{
139+
ctx: ctx,
140+
identity: fmt.Sprintf("identity-%d", rand.Int64()),
141+
org: fmt.Sprintf("org-%d", rand.Int64()),
142+
repo: "",
143+
}
144+
145+
token, err := ts.Token()
146+
if err == nil {
147+
t.Fatal("expected error but got none")
148+
}
149+
150+
if token != nil {
151+
t.Errorf("token: got = %v, wanted = nil", token)
152+
}
153+
154+
if !errors.Is(err, wantErr) {
155+
t.Errorf("error: got = %v, wanted = %v", err, wantErr)
156+
}
157+
}
158+
159+
func TestNewOrgTokenSource(t *testing.T) {
160+
ctx := context.Background()
161+
wantIdentity := fmt.Sprintf("identity-%d", rand.Int64())
162+
wantOrg := fmt.Sprintf("org-%d", rand.Int64())
163+
wantToken := fmt.Sprintf("token-%d", rand.Int64())
164+
165+
// Mock the octoTokenFunc
166+
originalFunc := octoTokenFunc
167+
t.Cleanup(func() { octoTokenFunc = originalFunc })
168+
169+
var capturedIdentity, capturedOrg, capturedRepo string
170+
octoTokenFunc = func(_ context.Context, identity, org, repo string) (string, error) {
171+
capturedIdentity = identity
172+
capturedOrg = org
173+
capturedRepo = repo
174+
return wantToken, nil
175+
}
176+
177+
ts := NewOrgTokenSource(ctx, wantIdentity, wantOrg)
178+
if ts == nil {
179+
t.Fatal("expected token source but got nil")
180+
}
181+
182+
// Get a token to verify parameters
183+
token, err := ts.Token()
184+
if err != nil {
185+
t.Fatalf("unexpected error: %v", err)
186+
}
187+
188+
if token.AccessToken != wantToken {
189+
t.Errorf("AccessToken: got = %q, wanted = %q", token.AccessToken, wantToken)
190+
}
191+
192+
if capturedIdentity != wantIdentity {
193+
t.Errorf("identity: got = %q, wanted = %q", capturedIdentity, wantIdentity)
194+
}
195+
196+
if capturedOrg != wantOrg {
197+
t.Errorf("org: got = %q, wanted = %q", capturedOrg, wantOrg)
198+
}
199+
200+
if capturedRepo != "" {
201+
t.Errorf("repo: got = %q, wanted = %q", capturedRepo, "")
202+
}
203+
}
204+
205+
func TestNewRepoTokenSource(t *testing.T) {
206+
ctx := context.Background()
207+
wantIdentity := fmt.Sprintf("identity-%d", rand.Int64())
208+
wantOrg := fmt.Sprintf("org-%d", rand.Int64())
209+
wantRepo := fmt.Sprintf("repo-%d", rand.Int64())
210+
wantToken := fmt.Sprintf("token-%d", rand.Int64())
211+
212+
// Mock the octoTokenFunc
213+
originalFunc := octoTokenFunc
214+
t.Cleanup(func() { octoTokenFunc = originalFunc })
215+
216+
var capturedIdentity, capturedOrg, capturedRepo string
217+
octoTokenFunc = func(_ context.Context, identity, org, repo string) (string, error) {
218+
capturedIdentity = identity
219+
capturedOrg = org
220+
capturedRepo = repo
221+
return wantToken, nil
222+
}
223+
224+
ts := NewRepoTokenSource(ctx, wantIdentity, wantOrg, wantRepo)
225+
if ts == nil {
226+
t.Fatal("expected token source but got nil")
227+
}
228+
229+
// Get a token to verify parameters
230+
token, err := ts.Token()
231+
if err != nil {
232+
t.Fatalf("unexpected error: %v", err)
233+
}
234+
235+
if token.AccessToken != wantToken {
236+
t.Errorf("AccessToken: got = %q, wanted = %q", token.AccessToken, wantToken)
237+
}
238+
239+
if capturedIdentity != wantIdentity {
240+
t.Errorf("identity: got = %q, wanted = %q", capturedIdentity, wantIdentity)
241+
}
242+
243+
if capturedOrg != wantOrg {
244+
t.Errorf("org: got = %q, wanted = %q", capturedOrg, wantOrg)
245+
}
246+
247+
if capturedRepo != wantRepo {
248+
t.Errorf("repo: got = %q, wanted = %q", capturedRepo, wantRepo)
249+
}
250+
}
251+
252+
func TestTokenSource_ReuseToken(t *testing.T) {
253+
ctx := context.Background()
254+
255+
// Mock the octoTokenFunc
256+
originalFunc := octoTokenFunc
257+
t.Cleanup(func() { octoTokenFunc = originalFunc })
258+
259+
callCount := 0
260+
octoTokenFunc = func(_ context.Context, _, _, _ string) (string, error) {
261+
callCount++
262+
return fmt.Sprintf("token-%d", callCount), nil
263+
}
264+
265+
ts := NewOrgTokenSource(ctx, fmt.Sprintf("identity-%d", rand.Int64()), fmt.Sprintf("org-%d", rand.Int64()))
266+
267+
// First call should get token
268+
token1, err := ts.Token()
269+
if err != nil {
270+
t.Fatalf("unexpected error: %v", err)
271+
}
272+
273+
// Second call should reuse token
274+
token2, err := ts.Token()
275+
if err != nil {
276+
t.Fatalf("unexpected error: %v", err)
277+
}
278+
279+
// Should be the same token (oauth2.ReuseTokenSource caches valid tokens)
280+
if token1.AccessToken != token2.AccessToken {
281+
t.Errorf("AccessToken: got = %q, wanted = %q (same token)", token2.AccessToken, token1.AccessToken)
282+
}
283+
284+
// octoTokenFunc should only be called once due to caching
285+
if callCount != 1 {
286+
t.Errorf("call count: got = %d, wanted = 1", callCount)
287+
}
288+
}

0 commit comments

Comments
 (0)