~ogham/doom

2cd1da636a0701955d23e6f36c5ebe0deecd4cf0 — Benjamin Sago 1 year, 10 months ago 543c2a0
Hot-swappable secrets
1 files changed, 131 insertions(+), 0 deletions(-)

A msecrets/hotswap.go
A msecrets/hotswap.go => msecrets/hotswap.go +131 -0
@@ 0,0 1,131 @@
package msecrets

import (
    "log"
    "sync"
    "time"
)


// A hot-swap value is one that can be reloaded, but requires spinning up
// (connecting) and spinning down (closing the connection) around the times where
// that happens. It’s intended to be used as a secrets applier, converting values
// read from the text file into stored entities.
type HotSwap[T any] struct {

    // The “name” of this value that gets printed out during logging, to
    // distinguish values from other ones.
    name string

    // A pointer to the current value (if any), having been connected.
    currentValue *T

    // The function that gets called with the line read from the secrets text
    // file, and “connects” to the value given in the string. This should be
    // called from within the watcher goroutine, so this function does not need
    // to worry about thread-safety itself.
    connector func(string) (*T, error)

    // The function that gets called to close down an entity after a new one
    // comes in.
    closer func(*T)

    // The last-good string that was used to successfully connect. This is used
    // to determine whether we need to cycle the entity because the input
    // changed, or whether we can keep it because it stayed the same.
    lastString string

    // A queue of input strings that gets passed to the watcher goroutine.
    queue chan string

    // Whether we have successfully connected with the current value. This is
    // used to determine whether, even if the lastString is the same, we should
    // attempt to re-connect anyway.
    ready bool

    // A mutex that synchronises access to the mutable fields (currentValue,
    // lastString, and ready).
    lock sync.RWMutex
}

// HotSwappable creates a new hot-swappable value with the given name and pair of
// up/down functions. It does not _call_ any of those functions, so is safe to
// assign to a top-level variable without weird stuff happening.
func HotSwappable[T any](name string, connector func(string) (*T, error), closer func(*T)) HotSwap[T] {
    return HotSwap[T]{
        name:         name,
        currentValue: nil,
        connector:    connector,
        closer:       closer,
        lastString:   "",
        ready:        false,
        queue:        make(chan string),
        lock:         sync.RWMutex{},
    }
}

// Roll starts the process to connect using the given string, resulting in a new
// entity if successful. Hook this function up as a secrets applier.
func (hs *HotSwap[T]) Roll(newStr string) {
    hs.queue <- newStr
}

// Watch should be called with ‘go’, as it loops forever. It receives values
// added by Roll, tests whether the value has been updated, and runs the
// connector if so.
func (hs *HotSwap[T]) Watch() {
    for newStr := range hs.queue {
        hs.lock.RLock()
        updated := hs.lastString != newStr || ! hs.ready
        hs.lock.RUnlock()

        if updated {
            log.Println("Updated value for hot-swap", hs.name)
            hs.update(newStr)
        } else {
            log.Println("No change in value for hot-swap", hs.name)
        }
    }
}

// update runs the connector function to create a new entity using the secret
// string we have just read from the queue. If it succeeds, updates currentValue;
// in any case, it updates ready and lastString. If the old value is to be
// closed, closes it in a separate goroutine.
func (hs *HotSwap[T]) update(newStr string) {
    newValue, err := hs.connector(newStr)
    if err != nil {
        log.Printf("Failed to run connector for hot-swap %s: %v", hs.name, err)

        hs.lock.Lock()
        hs.ready = false
        hs.lastString = ""
        hs.lock.Unlock()

        return
    }

    log.Printf("Connected for %s", hs.name)
    oldValue := hs.currentValue

    hs.lock.Lock()
    hs.currentValue = newValue
    hs.lastString = newStr
    hs.ready = true
    hs.lock.Unlock()

    if oldValue != nil {
        started := time.Now()
        log.Printf("Closing old connection for hot-swap %s...", hs.name)
        hs.closer(oldValue)
        log.Printf("Closed old connection for hot-swap %s (took %v)", hs.name, time.Since(started))
    }
}

// Get returns a pointer to the current value in the HotSwap. This will be nil if
// there is no active one.
func (hs *HotSwap[T]) Get() *T {
    hs.lock.RLock()
    defer hs.lock.RUnlock()
    return hs.currentValue
}