diff --git a/cli/server.go b/cli/server.go index 94f1518fa13a1..4e3b1e16a1482 100644 --- a/cli/server.go +++ b/cli/server.go @@ -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):") diff --git a/coderd/coderd.go b/coderd/coderd.go index 20ce616eab5ba..51b6780e4dc47 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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 diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 57d2a876de125..bd7945541556f 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -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, diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index f6bd03cc50e8b..5782bdc8e7155 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -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 diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index e76ea5a3ef28d..d23bb48184b61 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -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() { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index b1d2178e66a29..445c0c9b4e58d 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -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 } @@ -9186,6 +9208,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 } diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 38289c143bfd9..5aa3a0c8d8cfb 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -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) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 1771807f26b2f..6d881cfe6fc1b 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -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() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index c614a03834a9b..3432bac7dada1 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -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 diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index fc388e55247d0..1267449cf3d98 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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 diff --git a/coderd/database/queries/siteconfig.sql b/coderd/database/queries/siteconfig.sql index 877f5ee237122..e8d02372e5a4f 100644 --- a/coderd/database/queries/siteconfig.sql +++ b/coderd/database/queries/siteconfig.sql @@ -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; + diff --git a/coderd/runtimeconfig/doc.go b/coderd/runtimeconfig/doc.go new file mode 100644 index 0000000000000..a0e42b1390ddf --- /dev/null +++ b/coderd/runtimeconfig/doc.go @@ -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 diff --git a/coderd/runtimeconfig/entry.go b/coderd/runtimeconfig/entry.go new file mode 100644 index 0000000000000..780138a89d03b --- /dev/null +++ b/coderd/runtimeconfig/entry.go @@ -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 +} diff --git a/coderd/runtimeconfig/entry_test.go b/coderd/runtimeconfig/entry_test.go new file mode 100644 index 0000000000000..3092dae88c4cd --- /dev/null +++ b/coderd/runtimeconfig/entry_test.go @@ -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) + }) +} diff --git a/coderd/runtimeconfig/manager.go b/coderd/runtimeconfig/manager.go new file mode 100644 index 0000000000000..f7861b34bd8cd --- /dev/null +++ b/coderd/runtimeconfig/manager.go @@ -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)) +} diff --git a/coderd/runtimeconfig/resolver.go b/coderd/runtimeconfig/resolver.go new file mode 100644 index 0000000000000..d899680f034a4 --- /dev/null +++ b/coderd/runtimeconfig/resolver.go @@ -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) +} diff --git a/coderd/runtimeconfig/spec.go b/coderd/runtimeconfig/spec.go new file mode 100644 index 0000000000000..04451131c252a --- /dev/null +++ b/coderd/runtimeconfig/spec.go @@ -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 +} diff --git a/coderd/runtimeconfig/util.go b/coderd/runtimeconfig/util.go new file mode 100644 index 0000000000000..73af53cb8aeee --- /dev/null +++ b/coderd/runtimeconfig/util.go @@ -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) +}