Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

client/setec: prototype a secret-dependent value updater #79

Merged
merged 1 commit into from
Nov 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions client/setec/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,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