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
parent 9da646704b
commit cb9d40fb8a
18 changed files with 546 additions and 0 deletions
+3
View File
@@ -56,6 +56,7 @@ import (
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/v2/coderd/entitlements"
"github.com/coder/coder/v2/coderd/runtimeconfig"
"github.com/coder/pretty"
"github.com/coder/quartz"
"github.com/coder/retry"
@@ -820,6 +821,8 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
return err
}
options.RuntimeConfig = runtimeconfig.NewManager()
// This should be output before the logs start streaming.
cliui.Infof(inv.Stdout, "\n==> Logs will stream in below (press ctrl+c to gracefully exit):")
+2
View File
@@ -39,6 +39,7 @@ import (
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/entitlements"
"github.com/coder/coder/v2/coderd/idpsync"
"github.com/coder/coder/v2/coderd/runtimeconfig"
"github.com/coder/quartz"
"github.com/coder/serpent"
@@ -135,6 +136,7 @@ type Options struct {
Logger slog.Logger
Database database.Store
Pubsub pubsub.Pubsub
RuntimeConfig *runtimeconfig.Manager
// CacheDir is used for caching files served by the API.
CacheDir string
+3
View File
@@ -67,6 +67,7 @@ import (
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/runtimeconfig"
"github.com/coder/coder/v2/coderd/schedule"
"github.com/coder/coder/v2/coderd/telemetry"
"github.com/coder/coder/v2/coderd/unhanger"
@@ -254,6 +255,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can
var acs dbauthz.AccessControlStore = dbauthz.AGPLTemplateAccessControlStore{}
accessControlStore.Store(&acs)
runtimeManager := runtimeconfig.NewManager()
options.Database = dbauthz.New(options.Database, options.Authorizer, *options.Logger, accessControlStore)
// Some routes expect a deployment ID, so just make sure one exists.
@@ -482,6 +484,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can
AppHostnameRegex: appHostnameRegex,
Logger: *options.Logger,
CacheDir: t.TempDir(),
RuntimeConfig: runtimeManager,
Database: options.Database,
Pubsub: options.Pubsub,
ExternalAuthConfigs: options.ExternalAuthConfigs,
+21
View File
@@ -1183,6 +1183,13 @@ func (q *querier) DeleteReplicasUpdatedBefore(ctx context.Context, updatedAt tim
return q.db.DeleteReplicasUpdatedBefore(ctx, updatedAt)
}
func (q *querier) DeleteRuntimeConfig(ctx context.Context, key string) error {
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil {
return err
}
return q.db.DeleteRuntimeConfig(ctx, key)
}
func (q *querier) DeleteTailnetAgent(ctx context.Context, arg database.DeleteTailnetAgentParams) (database.DeleteTailnetAgentRow, error) {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceTailnetCoordinator); err != nil {
return database.DeleteTailnetAgentRow{}, err
@@ -1856,6 +1863,13 @@ func (q *querier) GetReplicasUpdatedAfter(ctx context.Context, updatedAt time.Ti
return q.db.GetReplicasUpdatedAfter(ctx, updatedAt)
}
func (q *querier) GetRuntimeConfig(ctx context.Context, key string) (string, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
return "", err
}
return q.db.GetRuntimeConfig(ctx, key)
}
func (q *querier) GetTailnetAgents(ctx context.Context, id uuid.UUID) ([]database.TailnetAgent, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceTailnetCoordinator); err != nil {
return nil, err
@@ -3906,6 +3920,13 @@ func (q *querier) UpsertProvisionerDaemon(ctx context.Context, arg database.Upse
return q.db.UpsertProvisionerDaemon(ctx, arg)
}
func (q *querier) UpsertRuntimeConfig(ctx context.Context, arg database.UpsertRuntimeConfigParams) error {
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceSystem); err != nil {
return err
}
return q.db.UpsertRuntimeConfig(ctx, arg)
}
func (q *querier) UpsertTailnetAgent(ctx context.Context, arg database.UpsertTailnetAgentParams) (database.TailnetAgent, error) {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceTailnetCoordinator); err != nil {
return database.TailnetAgent{}, err
+16
View File
@@ -2696,6 +2696,22 @@ func (s *MethodTestSuite) TestSystemFunctions() {
AgentID: uuid.New(),
}).Asserts(tpl, policy.ActionCreate)
}))
s.Run("DeleteRuntimeConfig", s.Subtest(func(db database.Store, check *expects) {
check.Args("test").Asserts(rbac.ResourceSystem, policy.ActionDelete)
}))
s.Run("GetRuntimeConfig", s.Subtest(func(db database.Store, check *expects) {
_ = db.UpsertRuntimeConfig(context.Background(), database.UpsertRuntimeConfigParams{
Key: "test",
Value: "value",
})
check.Args("test").Asserts(rbac.ResourceSystem, policy.ActionRead)
}))
s.Run("UpsertRuntimeConfig", s.Subtest(func(db database.Store, check *expects) {
check.Args(database.UpsertRuntimeConfigParams{
Key: "test",
Value: "value",
}).Asserts(rbac.ResourceSystem, policy.ActionCreate)
}))
}
func (s *MethodTestSuite) TestNotifications() {
+35
View File
@@ -84,6 +84,7 @@ func New() database.Store {
workspaceProxies: make([]database.WorkspaceProxy, 0),
customRoles: make([]database.CustomRole, 0),
locks: map[int64]struct{}{},
runtimeConfig: map[string]string{},
},
}
// Always start with a default org. Matching migration 198.
@@ -194,6 +195,7 @@ type data struct {
workspaces []database.Workspace
workspaceProxies []database.WorkspaceProxy
customRoles []database.CustomRole
runtimeConfig map[string]string
// Locks is a map of lock names. Any keys within the map are currently
// locked.
locks map[int64]struct{}
@@ -1928,6 +1930,14 @@ func (q *FakeQuerier) DeleteReplicasUpdatedBefore(_ context.Context, before time
return nil
}
func (q *FakeQuerier) DeleteRuntimeConfig(_ context.Context, key string) error {
q.mutex.Lock()
defer q.mutex.Unlock()
delete(q.runtimeConfig, key)
return nil
}
func (*FakeQuerier) DeleteTailnetAgent(context.Context, database.DeleteTailnetAgentParams) (database.DeleteTailnetAgentRow, error) {
return database.DeleteTailnetAgentRow{}, ErrUnimplemented
}
@@ -3505,6 +3515,18 @@ func (q *FakeQuerier) GetReplicasUpdatedAfter(_ context.Context, updatedAt time.
return replicas, nil
}
func (q *FakeQuerier) GetRuntimeConfig(_ context.Context, key string) (string, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
val, ok := q.runtimeConfig[key]
if !ok {
return "", sql.ErrNoRows
}
return val, nil
}
func (*FakeQuerier) GetTailnetAgents(context.Context, uuid.UUID) ([]database.TailnetAgent, error) {
return nil, ErrUnimplemented
}
@@ -9187,6 +9209,19 @@ func (q *FakeQuerier) UpsertProvisionerDaemon(_ context.Context, arg database.Up
return d, nil
}
func (q *FakeQuerier) UpsertRuntimeConfig(_ context.Context, arg database.UpsertRuntimeConfigParams) error {
err := validateDatabaseType(arg)
if err != nil {
return err
}
q.mutex.Lock()
defer q.mutex.Unlock()
q.runtimeConfig[arg.Key] = arg.Value
return nil
}
func (*FakeQuerier) UpsertTailnetAgent(context.Context, database.UpsertTailnetAgentParams) (database.TailnetAgent, error) {
return database.TailnetAgent{}, ErrUnimplemented
}
+21
View File
@@ -347,6 +347,13 @@ func (m metricsStore) DeleteReplicasUpdatedBefore(ctx context.Context, updatedAt
return err
}
func (m metricsStore) DeleteRuntimeConfig(ctx context.Context, key string) error {
start := time.Now()
r0 := m.s.DeleteRuntimeConfig(ctx, key)
m.queryLatencies.WithLabelValues("DeleteRuntimeConfig").Observe(time.Since(start).Seconds())
return r0
}
func (m metricsStore) DeleteTailnetAgent(ctx context.Context, arg database.DeleteTailnetAgentParams) (database.DeleteTailnetAgentRow, error) {
start := time.Now()
r0, r1 := m.s.DeleteTailnetAgent(ctx, arg)
@@ -991,6 +998,13 @@ func (m metricsStore) GetReplicasUpdatedAfter(ctx context.Context, updatedAt tim
return replicas, err
}
func (m metricsStore) GetRuntimeConfig(ctx context.Context, key string) (string, error) {
start := time.Now()
r0, r1 := m.s.GetRuntimeConfig(ctx, key)
m.queryLatencies.WithLabelValues("GetRuntimeConfig").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m metricsStore) GetTailnetAgents(ctx context.Context, id uuid.UUID) ([]database.TailnetAgent, error) {
start := time.Now()
r0, r1 := m.s.GetTailnetAgents(ctx, id)
@@ -2454,6 +2468,13 @@ func (m metricsStore) UpsertProvisionerDaemon(ctx context.Context, arg database.
return r0, r1
}
func (m metricsStore) UpsertRuntimeConfig(ctx context.Context, arg database.UpsertRuntimeConfigParams) error {
start := time.Now()
r0 := m.s.UpsertRuntimeConfig(ctx, arg)
m.queryLatencies.WithLabelValues("UpsertRuntimeConfig").Observe(time.Since(start).Seconds())
return r0
}
func (m metricsStore) UpsertTailnetAgent(ctx context.Context, arg database.UpsertTailnetAgentParams) (database.TailnetAgent, error) {
start := time.Now()
r0, r1 := m.s.UpsertTailnetAgent(ctx, arg)
+43
View File
@@ -584,6 +584,20 @@ func (mr *MockStoreMockRecorder) DeleteReplicasUpdatedBefore(arg0, arg1 any) *go
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteReplicasUpdatedBefore", reflect.TypeOf((*MockStore)(nil).DeleteReplicasUpdatedBefore), arg0, arg1)
}
// DeleteRuntimeConfig mocks base method.
func (m *MockStore) DeleteRuntimeConfig(arg0 context.Context, arg1 string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteRuntimeConfig", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// DeleteRuntimeConfig indicates an expected call of DeleteRuntimeConfig.
func (mr *MockStoreMockRecorder) DeleteRuntimeConfig(arg0, arg1 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteRuntimeConfig", reflect.TypeOf((*MockStore)(nil).DeleteRuntimeConfig), arg0, arg1)
}
// DeleteTailnetAgent mocks base method.
func (m *MockStore) DeleteTailnetAgent(arg0 context.Context, arg1 database.DeleteTailnetAgentParams) (database.DeleteTailnetAgentRow, error) {
m.ctrl.T.Helper()
@@ -2019,6 +2033,21 @@ func (mr *MockStoreMockRecorder) GetReplicasUpdatedAfter(arg0, arg1 any) *gomock
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetReplicasUpdatedAfter", reflect.TypeOf((*MockStore)(nil).GetReplicasUpdatedAfter), arg0, arg1)
}
// GetRuntimeConfig mocks base method.
func (m *MockStore) GetRuntimeConfig(arg0 context.Context, arg1 string) (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetRuntimeConfig", arg0, arg1)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetRuntimeConfig indicates an expected call of GetRuntimeConfig.
func (mr *MockStoreMockRecorder) GetRuntimeConfig(arg0, arg1 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRuntimeConfig", reflect.TypeOf((*MockStore)(nil).GetRuntimeConfig), arg0, arg1)
}
// GetTailnetAgents mocks base method.
func (m *MockStore) GetTailnetAgents(arg0 context.Context, arg1 uuid.UUID) ([]database.TailnetAgent, error) {
m.ctrl.T.Helper()
@@ -5151,6 +5180,20 @@ func (mr *MockStoreMockRecorder) UpsertProvisionerDaemon(arg0, arg1 any) *gomock
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertProvisionerDaemon", reflect.TypeOf((*MockStore)(nil).UpsertProvisionerDaemon), arg0, arg1)
}
// UpsertRuntimeConfig mocks base method.
func (m *MockStore) UpsertRuntimeConfig(arg0 context.Context, arg1 database.UpsertRuntimeConfigParams) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpsertRuntimeConfig", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// UpsertRuntimeConfig indicates an expected call of UpsertRuntimeConfig.
func (mr *MockStoreMockRecorder) UpsertRuntimeConfig(arg0, arg1 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertRuntimeConfig", reflect.TypeOf((*MockStore)(nil).UpsertRuntimeConfig), arg0, arg1)
}
// UpsertTailnetAgent mocks base method.
func (m *MockStore) UpsertTailnetAgent(arg0 context.Context, arg1 database.UpsertTailnetAgentParams) (database.TailnetAgent, error) {
m.ctrl.T.Helper()
+3
View File
@@ -96,6 +96,7 @@ type sqlcQuerier interface {
DeleteOrganizationMember(ctx context.Context, arg DeleteOrganizationMemberParams) error
DeleteProvisionerKey(ctx context.Context, id uuid.UUID) error
DeleteReplicasUpdatedBefore(ctx context.Context, updatedAt time.Time) error
DeleteRuntimeConfig(ctx context.Context, key string) error
DeleteTailnetAgent(ctx context.Context, arg DeleteTailnetAgentParams) (DeleteTailnetAgentRow, error)
DeleteTailnetClient(ctx context.Context, arg DeleteTailnetClientParams) (DeleteTailnetClientRow, error)
DeleteTailnetClientSubscription(ctx context.Context, arg DeleteTailnetClientSubscriptionParams) error
@@ -199,6 +200,7 @@ type sqlcQuerier interface {
GetQuotaConsumedForUser(ctx context.Context, arg GetQuotaConsumedForUserParams) (int64, error)
GetReplicaByID(ctx context.Context, id uuid.UUID) (Replica, error)
GetReplicasUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]Replica, error)
GetRuntimeConfig(ctx context.Context, key string) (string, error)
GetTailnetAgents(ctx context.Context, id uuid.UUID) ([]TailnetAgent, error)
GetTailnetClientsForAgent(ctx context.Context, agentID uuid.UUID) ([]TailnetClient, error)
GetTailnetPeers(ctx context.Context, id uuid.UUID) ([]TailnetPeer, error)
@@ -478,6 +480,7 @@ type sqlcQuerier interface {
UpsertNotificationsSettings(ctx context.Context, value string) error
UpsertOAuthSigningKey(ctx context.Context, value string) error
UpsertProvisionerDaemon(ctx context.Context, arg UpsertProvisionerDaemonParams) (ProvisionerDaemon, error)
UpsertRuntimeConfig(ctx context.Context, arg UpsertRuntimeConfigParams) error
UpsertTailnetAgent(ctx context.Context, arg UpsertTailnetAgentParams) (TailnetAgent, error)
UpsertTailnetClient(ctx context.Context, arg UpsertTailnetClientParams) (TailnetClient, error)
UpsertTailnetClientSubscription(ctx context.Context, arg UpsertTailnetClientSubscriptionParams) error
+36
View File
@@ -6703,6 +6703,16 @@ func (q *sqlQuerier) UpdateCustomRole(ctx context.Context, arg UpdateCustomRoleP
return i, err
}
const deleteRuntimeConfig = `-- name: DeleteRuntimeConfig :exec
DELETE FROM site_configs
WHERE site_configs.key = $1
`
func (q *sqlQuerier) DeleteRuntimeConfig(ctx context.Context, key string) error {
_, err := q.db.ExecContext(ctx, deleteRuntimeConfig, key)
return err
}
const getAnnouncementBanners = `-- name: GetAnnouncementBanners :one
SELECT value FROM site_configs WHERE key = 'announcement_banners'
`
@@ -6844,6 +6854,17 @@ func (q *sqlQuerier) GetOAuthSigningKey(ctx context.Context) (string, error) {
return value, err
}
const getRuntimeConfig = `-- name: GetRuntimeConfig :one
SELECT value FROM site_configs WHERE site_configs.key = $1
`
func (q *sqlQuerier) GetRuntimeConfig(ctx context.Context, key string) (string, error) {
row := q.db.QueryRowContext(ctx, getRuntimeConfig, key)
var value string
err := row.Scan(&value)
return value, err
}
const insertDERPMeshKey = `-- name: InsertDERPMeshKey :exec
INSERT INTO site_configs (key, value) VALUES ('derp_mesh_key', $1)
`
@@ -6975,6 +6996,21 @@ func (q *sqlQuerier) UpsertOAuthSigningKey(ctx context.Context, value string) er
return err
}
const upsertRuntimeConfig = `-- name: UpsertRuntimeConfig :exec
INSERT INTO site_configs (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = $2 WHERE site_configs.key = $1
`
type UpsertRuntimeConfigParams struct {
Key string `db:"key" json:"key"`
Value string `db:"value" json:"value"`
}
func (q *sqlQuerier) UpsertRuntimeConfig(ctx context.Context, arg UpsertRuntimeConfigParams) error {
_, err := q.db.ExecContext(ctx, upsertRuntimeConfig, arg.Key, arg.Value)
return err
}
const cleanTailnetCoordinators = `-- name: CleanTailnetCoordinators :exec
DELETE
FROM tailnet_coordinators
+11
View File
@@ -96,3 +96,14 @@ SELECT
INSERT INTO site_configs (key, value) VALUES ('notifications_settings', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'notifications_settings';
-- name: GetRuntimeConfig :one
SELECT value FROM site_configs WHERE site_configs.key = $1;
-- name: UpsertRuntimeConfig :exec
INSERT INTO site_configs (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = $2 WHERE site_configs.key = $1;
-- name: DeleteRuntimeConfig :exec
DELETE FROM site_configs
WHERE site_configs.key = $1;
+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)
}