Skip to content

Commit

Permalink
client/setec: prototype a secret-dependent value updater
Browse files Browse the repository at this point in the history
  • Loading branch information
creachadair committed Oct 6, 2023
1 parent 5851f1e commit 75a33aa
Show file tree
Hide file tree
Showing 3 changed files with 138 additions and 6 deletions.
43 changes: 43 additions & 0 deletions client/setec/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,49 @@ func (w Watcher) notify() {
}
}

// NewUpdater creates a new Updater that tracks updates to a value based on new
// secret versions delivered to w. The newValue function returns a new value
// of the type based on its argument, a secret value.
//
// The initial value is constructed by calling newValue with the current secret
// version in w at the time NewUpdater is called. Calls to the Get method
// update the value as needed when w changes.
//
// The updater synchronizes calls to Get and newValue, so the callback can
// safely interact with shared state without additional locking.
func NewUpdater[T any](w Watcher, newValue func([]byte) T) *Updater[T] {
return &Updater[T]{
newValue: newValue,
w: w,
value: newValue(w.Get()),
}
}

// An Updater tracks a value whose state depends on a secret, together with a
// watcher for updates to the secret. The caller provides a function to update
// the value when a new version of the secret is delivered, and the Updater
// manages access and updates to the value.
type Updater[T any] struct {
newValue func([]byte) T
w Watcher
mu sync.Mutex
value T
}

// Get fetches the current value of u, first updating it if the secret has
// changed. It is safe to call Get concurrently from multiple goroutines.
func (u *Updater[T]) Get() T {
u.mu.Lock()
defer u.mu.Unlock()
select {
case <-u.w.Ready():
u.value = u.newValue(u.w.Get())
default:
// no change, use the existing value
}
return u.value
}

type cachedSecret struct {
Secret *api.SecretValue `json:"secret"`
LastAccess int64 `json:"lastAccess,string"`
Expand Down
51 changes: 51 additions & 0 deletions client/setec/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package setec_test
import (
"context"
"errors"
"fmt"
"net/http/httptest"
"os"
"path/filepath"
Expand Down Expand Up @@ -344,6 +345,56 @@ func TestWatcher(t *testing.T) {
}
}

func TestUpdater(t *testing.T) {
d := setectest.NewDB(t, nil)
d.MustPut(d.Superuser, "label", "malarkey") // active
v2 := d.MustPut(d.Superuser, "label", "dog-faced pony soldier")

ts := setectest.NewServer(t, d, nil)
hs := httptest.NewServer(ts.Mux)
defer hs.Close()

ctx := context.Background()
cli := setec.Client{Server: hs.URL, DoHTTP: hs.Client().Do}

pollTicker := newFakeTicker()
st, err := setec.NewStore(ctx, setec.StoreConfig{
Client: cli,
Secrets: []string{"label"},
PollTicker: pollTicker,
})
if err != nil {
t.Fatalf("NewStore: unexpected error: %v", err)
}
defer st.Close()

// Set up an updater that tracks a string against the secret named "label".
u := setec.NewUpdater(st.Watcher("label"), func(secret []byte) string {
return fmt.Sprintf("value: %q", secret)
})
checkValue := func(label, want string) {
if got := u.Get(); got != want {
t.Errorf("%s: got %q, want %q", label, got, want)
}
}

checkValue("Initial value", `value: "malarkey"`)

// The secret gets updated...
if err := cli.Activate(ctx, "label", v2); err != nil {
t.Fatalf("Activate to %v: unexpected error: %v", v2, err)
}
pollTicker.Poll()

// The next get should see the updated value.
checkValue("Updated value", `value: "dog-faced pony soldier"`)

pollTicker.Poll()

// The next get should not see a change.
checkValue("Updated value", `value: "dog-faced pony soldier"`)
}

func TestLookup(t *testing.T) {
d := setectest.NewDB(t, nil)
d.MustPut(d.Superuser, "red", "badge of courage") // active
Expand Down
50 changes: 44 additions & 6 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,13 +315,23 @@ a secret with a channel that notifies when a new secret value is available:
```go
// Get the handle, as before.
apiKey := st.Watcher("prod/my-program/secret-1")
```

A `Watcher` contains a level-triggered channel that receives a value when a new
version of the secret has been applied. You can use this to update API clients
and other values that depend on the current secret value, but which might be
expensive to re-construct every time they're used.

For example, here is one way to update a client value based on a watcher:

```go
// Create a client with the current value.
cli := someservice.NewClient("username", apiKey.Get())

// Make a helper that will refresh the client when the secret updates.
// This example assumes no concurrency; you may need a lock if multiple
// goroutines will request a client at the same time.
// See below for a simple way to handle that.
getClient := func() *someservice.Client {
select {
case <-apiKey.Ready():
Expand All @@ -336,12 +346,39 @@ rsp, err := getClient().Method(ctx, args)
// ...
```

In addition, if the program needs to explicitly refresh the values of secrets
at a specific time (for example, in response to an operator signal or other
event) it may explicitly call the `Store` value's [`Refresh`][strefresh]
method, which effects a poll of all known secrets synchronously. It is safe for
the client to do this concurrently with a background poll; the store will
coalesce the operations.
As noted in the comments, the above example does not work if multiple
goroutines may access the client concurrently, since they will race on reading
and updating the `cli` variable. You could add a lock, but the client library
also includes a [`setec.Updater`][setecupdater] type that will manage updates
for you even with concurrent use:

```go
// Construct an updater, given a callback that takes a secret value and returns
// a new someservice client using that secret.
client := setec.NewUpdater(apiKey, func(secret []byte) *someservice.Client {
return someservice.NewClient("username", secret)
})

// Now, when you need a client, use the updater:
rsp, err := client.Get().Method(ctx, args)
// ...
```

The updater constructs the initial client by invoking the callback with the
value of `w` when `NewUpdater` is called. Thereafter, calls to `u.Get()` will
return the same client until the secret in `w` changes. When that happens, the
updater invokes the callback again with the new secret value, to get a fresh
client.

#### Explicit Refresh

Ordinarily a `Store` will automatically update secret values in the background.
If a program needs to explicitly refresh the values of secrets at a specific
time (for example, in response to an operator signal or other event) it may
explicitly call the `Store` value's [`Refresh`][strefresh] method, which
effects a poll of all known secrets synchronously. It is safe for the client to
do this concurrently with a background poll; the store will coalesce the
operations.

### Bootstrapping and Availability

Expand Down Expand Up @@ -444,6 +481,7 @@ if err != nil {
[setecclient]: https://godoc.org/github.com/tailscale/setec/client/setec#Client
[setecstore]: https://godoc.org/github.com/tailscale/setec/client/setec#Store
[setectest]: https://godoc.org/github.com/tailscale/setec/setectest
[setecupdater]: https://godoc.org/github.com/tailscale/setec/client/setec#Updater
[setecwatcher]: https://godoc.org/github.com/tailscale/setec/client/setec#Watcher
[stserver]: https://godoc.org/github.com/tailscale/setec/setectest#Server
[strefresh]: https://godoc.org/github.com/tailscale/setec/client/setec#Store.Refresh

0 comments on commit 75a33aa

Please sign in to comment.