mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
co-authored by
Danny Kopping
parent
9da646704b
commit
cb9d40fb8a
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user