feat: implement runtime configuration package with multi-org support (#14624)

runtime configuration package
---------

Signed-off-by: Danny Kopping <danny@coder.com>
Co-authored-by: Danny Kopping <danny@coder.com>
This commit is contained in:
Steven Masley
2024-09-09 14:14:52 -05:00
committed by GitHub
co-authored by Danny Kopping
parent 9da646704b
commit cb9d40fb8a
18 changed files with 546 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
// Package runtimeconfig contains logic for managing runtime configuration values
// stored in the database. Each coderd should have a Manager singleton instance
// that can create a Resolver for runtime configuration CRUD.
//
// TODO: Implement a caching layer for the Resolver so that we don't hit the
// database on every request. Configuration values are not expected to change
// frequently, so we should use pubsub to notify for updates.
// When implemented, the runtimeconfig will essentially be an in memory lookup
// with a database for persistence.
package runtimeconfig
+95
View File
@@ -0,0 +1,95 @@
package runtimeconfig
import (
"context"
"fmt"
"golang.org/x/xerrors"
)
// EntryMarshaller requires all entries to marshal to and from a string.
// The final store value is a database `text` column.
// This also is compatible with serpent values.
type EntryMarshaller interface {
fmt.Stringer
}
type EntryValue interface {
EntryMarshaller
Set(string) error
}
// RuntimeEntry are **only** runtime configurable. They are stored in the
// database, and have no startup value or default value.
type RuntimeEntry[T EntryValue] struct {
n string
}
// New creates a new T instance with a defined name and value.
func New[T EntryValue](name string) (out RuntimeEntry[T], err error) {
out.n = name
if name == "" {
return out, ErrNameNotSet
}
return out, nil
}
// MustNew is like New but panics if an error occurs.
func MustNew[T EntryValue](name string) RuntimeEntry[T] {
out, err := New[T](name)
if err != nil {
panic(err)
}
return out
}
// SetRuntimeValue attempts to update the runtime value of this field in the store via the given Mutator.
func (e *RuntimeEntry[T]) SetRuntimeValue(ctx context.Context, m Resolver, val T) error {
name, err := e.name()
if err != nil {
return xerrors.Errorf("set runtime: %w", err)
}
return m.UpsertRuntimeConfig(ctx, name, val.String())
}
// UnsetRuntimeValue removes the runtime value from the store.
func (e *RuntimeEntry[T]) UnsetRuntimeValue(ctx context.Context, m Resolver) error {
name, err := e.name()
if err != nil {
return xerrors.Errorf("unset runtime: %w", err)
}
return m.DeleteRuntimeConfig(ctx, name)
}
// Resolve attempts to resolve the runtime value of this field from the store via the given Resolver.
func (e *RuntimeEntry[T]) Resolve(ctx context.Context, r Resolver) (T, error) {
var zero T
name, err := e.name()
if err != nil {
return zero, xerrors.Errorf("resolve, name issue: %w", err)
}
val, err := r.GetRuntimeConfig(ctx, name)
if err != nil {
return zero, xerrors.Errorf("resolve runtime: %w", err)
}
inst := create[T]()
if err = inst.Set(val); err != nil {
return zero, xerrors.Errorf("instantiate new %T: %w", inst, err)
}
return inst, nil
}
// name returns the configured name, or fails with ErrNameNotSet.
func (e *RuntimeEntry[T]) name() (string, error) {
if e.n == "" {
return "", ErrNameNotSet
}
return e.n, nil
}
+77
View File
@@ -0,0 +1,77 @@
package runtimeconfig_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/database/dbmem"
"github.com/coder/coder/v2/coderd/runtimeconfig"
"github.com/coder/coder/v2/testutil"
"github.com/coder/serpent"
)
func TestEntry(t *testing.T) {
t.Parallel()
t.Run("new", func(t *testing.T) {
t.Parallel()
require.Panics(t, func() {
// No name should panic
runtimeconfig.MustNew[*serpent.Float64]("")
})
require.NotPanics(t, func() {
runtimeconfig.MustNew[*serpent.Float64]("my-field")
})
})
t.Run("simple", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
mgr := runtimeconfig.NewManager()
db := dbmem.New()
override := serpent.String("dogfood@dev.coder.com")
field := runtimeconfig.MustNew[*serpent.String]("string-field")
// No value set yet.
_, err := field.Resolve(ctx, mgr.Resolver(db))
require.ErrorIs(t, err, runtimeconfig.ErrEntryNotFound)
// Set an org-level override.
require.NoError(t, field.SetRuntimeValue(ctx, mgr.Resolver(db), &override))
// Value was updated
val, err := field.Resolve(ctx, mgr.Resolver(db))
require.NoError(t, err)
require.Equal(t, override.String(), val.String())
})
t.Run("complex", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
mgr := runtimeconfig.NewManager()
db := dbmem.New()
override := serpent.Struct[map[string]string]{
Value: map[string]string{
"a": "b",
"c": "d",
},
}
field := runtimeconfig.MustNew[*serpent.Struct[map[string]string]]("string-field")
// Validate that there is no runtime override right now.
_, err := field.Resolve(ctx, mgr.Resolver(db))
require.ErrorIs(t, err, runtimeconfig.ErrEntryNotFound)
// Set a runtime value
require.NoError(t, field.SetRuntimeValue(ctx, mgr.Resolver(db), &override))
// Coalesce now returns the org-level value.
structVal, err := field.Resolve(ctx, mgr.Resolver(db))
require.NoError(t, err)
require.Equal(t, override.Value, structVal.Value)
})
}
+28
View File
@@ -0,0 +1,28 @@
package runtimeconfig
import (
"github.com/google/uuid"
)
// Manager is the singleton that produces resolvers for runtime configuration.
// TODO: Implement caching layer.
type Manager struct{}
func NewManager() *Manager {
return &Manager{}
}
// Resolver is the deployment wide namespace for runtime configuration.
// If you are trying to namespace a configuration, orgs for example, use
// OrganizationResolver.
func (*Manager) Resolver(db Store) Resolver {
return NewStoreResolver(db)
}
// OrganizationResolver will namespace all runtime configuration to the provided
// organization ID. Configuration values stored with a given organization ID require
// that the organization ID be provided to retrieve the value.
// No values set here will ever be returned by the call to 'Resolver()'.
func (*Manager) OrganizationResolver(db Store, orgID uuid.UUID) Resolver {
return OrganizationResolver(orgID, NewStoreResolver(db))
}
+92
View File
@@ -0,0 +1,92 @@
package runtimeconfig
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database"
)
// NoopResolver is a useful test device.
type NoopResolver struct{}
func NewNoopResolver() *NoopResolver {
return &NoopResolver{}
}
func (NoopResolver) GetRuntimeConfig(context.Context, string) (string, error) {
return "", ErrEntryNotFound
}
func (NoopResolver) UpsertRuntimeConfig(context.Context, string, string) error {
return ErrEntryNotFound
}
func (NoopResolver) DeleteRuntimeConfig(context.Context, string) error {
return ErrEntryNotFound
}
// StoreResolver uses the database as the underlying store for runtime settings.
type StoreResolver struct {
db Store
}
func NewStoreResolver(db Store) *StoreResolver {
return &StoreResolver{db: db}
}
func (m StoreResolver) GetRuntimeConfig(ctx context.Context, key string) (string, error) {
val, err := m.db.GetRuntimeConfig(ctx, key)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", xerrors.Errorf("%q: %w", key, ErrEntryNotFound)
}
return "", xerrors.Errorf("fetch %q: %w", key, err)
}
return val, nil
}
func (m StoreResolver) UpsertRuntimeConfig(ctx context.Context, key, val string) error {
err := m.db.UpsertRuntimeConfig(ctx, database.UpsertRuntimeConfigParams{Key: key, Value: val})
if err != nil {
return xerrors.Errorf("update %q: %w", key, err)
}
return nil
}
func (m StoreResolver) DeleteRuntimeConfig(ctx context.Context, key string) error {
return m.db.DeleteRuntimeConfig(ctx, key)
}
// NamespacedResolver prefixes all keys with a namespace.
// Then defers to the underlying resolver for the actual operations.
type NamespacedResolver struct {
ns string
wrapped Resolver
}
func OrganizationResolver(orgID uuid.UUID, wrapped Resolver) NamespacedResolver {
return NamespacedResolver{ns: orgID.String(), wrapped: wrapped}
}
func (m NamespacedResolver) GetRuntimeConfig(ctx context.Context, key string) (string, error) {
return m.wrapped.GetRuntimeConfig(ctx, m.namespacedKey(key))
}
func (m NamespacedResolver) UpsertRuntimeConfig(ctx context.Context, key, val string) error {
return m.wrapped.UpsertRuntimeConfig(ctx, m.namespacedKey(key), val)
}
func (m NamespacedResolver) DeleteRuntimeConfig(ctx context.Context, key string) error {
return m.wrapped.DeleteRuntimeConfig(ctx, m.namespacedKey(key))
}
func (m NamespacedResolver) namespacedKey(k string) string {
return fmt.Sprintf("%s:%s", m.ns, k)
}
+39
View File
@@ -0,0 +1,39 @@
package runtimeconfig
import (
"context"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database"
)
var (
// ErrEntryNotFound is returned when a runtime entry is not saved in the
// store. It is essentially a 'sql.ErrNoRows'.
ErrEntryNotFound = xerrors.New("entry not found")
// ErrNameNotSet is returned when a runtime entry is created without a name.
// This is more likely to happen on DeploymentEntry that has not called
// Initialize().
ErrNameNotSet = xerrors.New("name is not set")
)
type Initializer interface {
Initialize(name string)
}
type Resolver interface {
// GetRuntimeConfig gets a runtime setting by name.
GetRuntimeConfig(ctx context.Context, name string) (string, error)
// UpsertRuntimeConfig upserts a runtime setting by name.
UpsertRuntimeConfig(ctx context.Context, name, val string) error
// DeleteRuntimeConfig deletes a runtime setting by name.
DeleteRuntimeConfig(ctx context.Context, name string) error
}
// Store is a subset of database.Store
type Store interface {
GetRuntimeConfig(ctx context.Context, key string) (string, error)
UpsertRuntimeConfig(ctx context.Context, arg database.UpsertRuntimeConfigParams) error
DeleteRuntimeConfig(ctx context.Context, key string) error
}
+11
View File
@@ -0,0 +1,11 @@
package runtimeconfig
import (
"reflect"
)
func create[T any]() T {
var zero T
//nolint:forcetypeassert
return reflect.New(reflect.TypeOf(zero).Elem()).Interface().(T)
}