Skip to content

Commit 6b9446b

Browse files
committed
Renew Bridge installations
Issue: [sc-16285]
1 parent 6835886 commit 6b9446b

File tree

4 files changed

+417
-27
lines changed

4 files changed

+417
-27
lines changed

internal/bridge/client.go

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"bytes"
2020
"context"
2121
"encoding/json"
22+
"errors"
2223
"fmt"
2324
"io"
2425
"net/http"
@@ -32,6 +33,8 @@ import (
3233

3334
const defaultAPI = "https://api.crunchybridge.com"
3435

36+
var errAuthentication = errors.New("authentication failed")
37+
3538
type Client struct {
3639
http.Client
3740
wait.Backoff
@@ -179,6 +182,38 @@ func (c *Client) doWithRetry(
179182
return response, err
180183
}
181184

185+
func (c *Client) CreateAuthObject(ctx context.Context, authn AuthObject) (AuthObject, error) {
186+
var result AuthObject
187+
188+
response, err := c.doWithRetry(ctx, "POST", "/vendor/operator/auth-objects", nil, http.Header{
189+
"Accept": []string{"application/json"},
190+
"Authorization": []string{"Bearer " + authn.Secret},
191+
})
192+
193+
if err == nil {
194+
defer response.Body.Close()
195+
body, _ := io.ReadAll(response.Body)
196+
197+
switch {
198+
// 2xx, Successful
199+
case response.StatusCode >= 200 && response.StatusCode < 300:
200+
if err = json.Unmarshal(body, &result); err != nil {
201+
err = fmt.Errorf("%w: %s", err, body)
202+
}
203+
204+
// 401, Unauthorized
205+
case response.StatusCode == 401:
206+
err = fmt.Errorf("%w: %s", errAuthentication, body)
207+
208+
default:
209+
//nolint:goerr113 // This is intentionally dynamic.
210+
err = fmt.Errorf("%v: %s", response.Status, body)
211+
}
212+
}
213+
214+
return result, err
215+
}
216+
182217
func (c *Client) CreateInstallation(ctx context.Context) (Installation, error) {
183218
var result Installation
184219

@@ -188,20 +223,18 @@ func (c *Client) CreateInstallation(ctx context.Context) (Installation, error) {
188223

189224
if err == nil {
190225
defer response.Body.Close()
191-
192-
var body bytes.Buffer
193-
_, _ = io.Copy(&body, response.Body)
226+
body, _ := io.ReadAll(response.Body)
194227

195228
switch {
196229
// 2xx, Successful
197-
case 200 <= response.StatusCode && response.StatusCode < 300:
198-
if err = json.Unmarshal(body.Bytes(), &result); err != nil {
199-
err = fmt.Errorf("%w: %v", err, body.String())
230+
case response.StatusCode >= 200 && response.StatusCode < 300:
231+
if err = json.Unmarshal(body, &result); err != nil {
232+
err = fmt.Errorf("%w: %s", err, body)
200233
}
201234

202235
default:
203236
//nolint:goerr113 // This is intentionally dynamic.
204-
err = fmt.Errorf("%v: %v", response.Status, body.String())
237+
err = fmt.Errorf("%v: %s", response.Status, body)
205238
}
206239
}
207240

internal/bridge/client_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,87 @@ func TestClientDoWithRetry(t *testing.T) {
405405
})
406406
}
407407

408+
func TestClientCreateAuthObject(t *testing.T) {
409+
t.Run("Arguments", func(t *testing.T) {
410+
var requests []http.Request
411+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
412+
body, _ := io.ReadAll(r.Body)
413+
assert.Equal(t, len(body), 0)
414+
requests = append(requests, *r)
415+
}))
416+
t.Cleanup(server.Close)
417+
418+
client := NewClient(server.URL, "")
419+
assert.Equal(t, client.BaseURL.String(), server.URL)
420+
421+
ctx := context.Background()
422+
_, _ = client.CreateAuthObject(ctx, AuthObject{Secret: "sesame"})
423+
424+
assert.Equal(t, len(requests), 1)
425+
assert.Equal(t, requests[0].Header.Get("Authorization"), "Bearer sesame")
426+
})
427+
428+
t.Run("Unauthorized", func(t *testing.T) {
429+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
430+
w.WriteHeader(http.StatusUnauthorized)
431+
_, _ = w.Write([]byte(`some info`))
432+
}))
433+
t.Cleanup(server.Close)
434+
435+
client := NewClient(server.URL, "")
436+
assert.Equal(t, client.BaseURL.String(), server.URL)
437+
438+
_, err := client.CreateAuthObject(context.Background(), AuthObject{})
439+
assert.ErrorContains(t, err, "authentication")
440+
assert.ErrorContains(t, err, "some info")
441+
assert.ErrorIs(t, err, errAuthentication)
442+
})
443+
444+
t.Run("ErrorResponse", func(t *testing.T) {
445+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
446+
w.WriteHeader(http.StatusNotFound)
447+
_, _ = w.Write([]byte(`some message`))
448+
}))
449+
t.Cleanup(server.Close)
450+
451+
client := NewClient(server.URL, "")
452+
assert.Equal(t, client.BaseURL.String(), server.URL)
453+
454+
_, err := client.CreateAuthObject(context.Background(), AuthObject{})
455+
assert.ErrorContains(t, err, "404 Not Found")
456+
assert.ErrorContains(t, err, "some message")
457+
})
458+
459+
t.Run("NoResponseBody", func(t *testing.T) {
460+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
461+
w.WriteHeader(http.StatusOK)
462+
}))
463+
t.Cleanup(server.Close)
464+
465+
client := NewClient(server.URL, "")
466+
assert.Equal(t, client.BaseURL.String(), server.URL)
467+
468+
_, err := client.CreateAuthObject(context.Background(), AuthObject{})
469+
assert.ErrorContains(t, err, "unexpected end")
470+
assert.ErrorContains(t, err, "JSON")
471+
})
472+
473+
t.Run("ResponseNotJSON", func(t *testing.T) {
474+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
475+
w.WriteHeader(http.StatusOK)
476+
_, _ = w.Write([]byte(`asdf`))
477+
}))
478+
t.Cleanup(server.Close)
479+
480+
client := NewClient(server.URL, "")
481+
assert.Equal(t, client.BaseURL.String(), server.URL)
482+
483+
_, err := client.CreateAuthObject(context.Background(), AuthObject{})
484+
assert.ErrorContains(t, err, "invalid")
485+
assert.ErrorContains(t, err, "asdf")
486+
})
487+
}
488+
408489
func TestClientCreateInstallation(t *testing.T) {
409490
t.Run("ErrorResponse", func(t *testing.T) {
410491
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

internal/bridge/installation.go

Lines changed: 74 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@ package bridge
1818
import (
1919
"context"
2020
"encoding/json"
21+
"errors"
2122
"sync"
2223
"time"
2324

2425
corev1 "k8s.io/api/core/v1"
2526
apierrors "k8s.io/apimachinery/pkg/api/errors"
2627
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
28+
"k8s.io/apimachinery/pkg/util/wait"
2729
corev1apply "k8s.io/client-go/applyconfigurations/core/v1"
2830
"sigs.k8s.io/controller-runtime/pkg/builder"
2931
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -65,6 +67,9 @@ type InstallationReconciler struct {
6567
Patch(context.Context, client.Object, client.Patch, ...client.PatchOption) error
6668
}
6769

70+
// Refresh is the frequency at which AuthObjects should be renewed.
71+
Refresh time.Duration
72+
6873
// SecretRef is the name of the corev1.Secret in which to store Bridge tokens.
6974
SecretRef client.ObjectKey
7075

@@ -79,6 +84,7 @@ func ManagedInstallationReconciler(m manager.Manager, newClient func() *Client)
7984
Owner: naming.ControllerBridge,
8085
Reader: kubernetes,
8186
Writer: kubernetes,
87+
Refresh: 2 * time.Hour,
8288
SecretRef: naming.AsObjectKey(naming.OperatorConfigurationSecret()),
8389
NewClient: newClient,
8490
}
@@ -119,7 +125,7 @@ func (r *InstallationReconciler) Reconcile(
119125
// make it so.
120126
secret.Namespace, secret.Name = request.Namespace, request.Name
121127

122-
err = r.reconcile(ctx, secret)
128+
result.RequeueAfter, err = r.reconcile(ctx, secret)
123129
}
124130

125131
// TODO: Check for corev1.NamespaceTerminatingCause after
@@ -134,10 +140,14 @@ func (r *InstallationReconciler) Reconcile(
134140
return result, err
135141
}
136142

137-
func (r *InstallationReconciler) reconcile(ctx context.Context, read *corev1.Secret) error {
143+
// reconcile looks for an Installation in read and stores it or another in
144+
// the [self] singleton after a successful response from the Bridge API.
145+
func (r *InstallationReconciler) reconcile(
146+
ctx context.Context, read *corev1.Secret) (next time.Duration, err error,
147+
) {
138148
write, err := corev1apply.ExtractSecret(read, string(r.Owner))
139149
if err != nil {
140-
return err
150+
return 0, err
141151
}
142152

143153
// We GET-extract-PATCH the Secret and do not build it up from scratch.
@@ -157,24 +167,30 @@ func (r *InstallationReconciler) reconcile(ctx context.Context, read *corev1.Sec
157167
// Secret which triggers another reconcile.
158168
if len(installation.ID) == 0 {
159169
if len(self.ID) == 0 {
160-
return r.register(ctx, write)
170+
return 0, r.register(ctx, write)
161171
}
162172

163173
data := map[string][]byte{}
164174
data[KeyBridgeToken], _ = json.Marshal(self.Installation) //nolint:errchkjson
165175

166-
return r.persist(ctx, write.WithData(data))
176+
return 0, r.persist(ctx, write.WithData(data))
167177
}
168178

169-
// When the Secret has an Installation, store it in memory.
170-
// TODO: Validate it first; perhaps refresh the AuthObject.
171-
if len(self.ID) == 0 {
172-
self.Lock()
173-
self.Installation = installation
174-
self.Unlock()
179+
// Read the timestamp from the Secret, if any.
180+
var touched time.Time
181+
if yaml.Unmarshal(read.Data[KeyBridgeLocalTime], &touched) != nil {
182+
touched = time.Time{}
183+
}
184+
185+
// Refresh the AuthObject when there is no Installation in memory,
186+
// there is no timestamp, or the timestamp is far away. This writes to
187+
// the Secret which triggers another reconcile.
188+
if len(self.ID) == 0 || time.Since(touched) > r.Refresh || time.Until(touched) > r.Refresh {
189+
return 0, r.refresh(ctx, installation, write)
175190
}
176191

177-
return nil
192+
// Trigger another reconcile one interval after the stored timestamp.
193+
return wait.Jitter(time.Until(touched.Add(r.Refresh)), 0.1), nil
178194
}
179195

180196
// persist uses Server-Side Apply to write config to Kubernetes. The Name and
@@ -198,6 +214,52 @@ func (r *InstallationReconciler) persist(
198214
return err
199215
}
200216

217+
// refresh calls the Bridge API to refresh the AuthObject of installation. It
218+
// combines the result with installation and stores that in the [self] singleton
219+
// and the write object in Kubernetes. The Name and Namespace fields of the
220+
// latter cannot be nil.
221+
func (r *InstallationReconciler) refresh(
222+
ctx context.Context, installation Installation,
223+
write *corev1apply.SecretApplyConfiguration,
224+
) error {
225+
result, err := r.NewClient().CreateAuthObject(ctx, installation.AuthObject)
226+
227+
// An authentication error means the installation is irrecoverably expired.
228+
// Remove it from the singleton and move it to a dated entry in the Secret.
229+
if err != nil && errors.Is(err, errAuthentication) {
230+
self.Lock()
231+
self.Installation = Installation{}
232+
self.Unlock()
233+
234+
keyExpiration := KeyBridgeToken +
235+
installation.AuthObject.ExpiresAt.UTC().Format("--2006-01-02")
236+
237+
data := make(map[string][]byte, 2)
238+
data[KeyBridgeToken] = nil
239+
data[keyExpiration], _ = json.Marshal(installation) //nolint:errchkjson
240+
241+
return r.persist(ctx, write.WithData(data))
242+
}
243+
244+
if err == nil {
245+
installation.AuthObject = result
246+
247+
// Store the new value in the singleton.
248+
self.Lock()
249+
self.Installation = installation
250+
self.Unlock()
251+
252+
// Store the new value in the Secret along with the current time.
253+
data := make(map[string][]byte, 2)
254+
data[KeyBridgeLocalTime], _ = metav1.Now().MarshalJSON()
255+
data[KeyBridgeToken], _ = json.Marshal(installation) //nolint:errchkjson
256+
257+
err = r.persist(ctx, write.WithData(data))
258+
}
259+
260+
return err
261+
}
262+
201263
// register calls the Bridge API to register a new Installation. It stores the
202264
// result in the [self] singleton and the write object in Kubernetes. The Name
203265
// and Namespace fields of the latter cannot be nil.

0 commit comments

Comments
 (0)