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/deploymententry.go b/coderd/runtimeconfig/deploymententry.go new file mode 100644 index 0000000000000..2b5d835f2624e --- /dev/null +++ b/coderd/runtimeconfig/deploymententry.go @@ -0,0 +1,89 @@ +package runtimeconfig + +import ( + "context" + "errors" + "reflect" + + "github.com/spf13/pflag" +) + +// Ensure serpent values satisfy the ConfigValue interface for easier usage. +var ( + _ pflag.Value = SerpentEntry(nil) + _ pflag.Value = &DeploymentEntry[SerpentEntry]{} +) + +type SerpentEntry interface { + EntryValue + Type() string +} + +// DeploymentEntry extends a runtime entry with a startup value. +// This allows for a single entry to source its value from startup or runtime. +// DeploymentEntry will never return ErrEntryNotFound, as it will always return a value. +type DeploymentEntry[T SerpentEntry] struct { + RuntimeEntry[T] + startupValue T +} + +// Initialize sets the entry's name, and initializes the value. +func (e *DeploymentEntry[T]) Initialize(name string) { + e.n = name + e.val() +} + +// SetStartupValue sets the value of the wrapped field. This ONLY sets the value locally, not in the store. +// See SetRuntimeValue. +func (e *DeploymentEntry[T]) SetStartupValue(s string) error { + return e.val().Set(s) +} + +// StartupValue returns the wrapped type T which represents the state as of the definition of this Entry. +// This function would've been named Value, but this conflicts with a field named Value on some implementations of T in +// the serpent library; plus it's just more clear. +func (e *DeploymentEntry[T]) StartupValue() T { + return e.val() +} + +// Coalesce attempts to resolve the runtime value of this field from the store via the given Manager. Should no runtime +// value be found, the startup value will be used. +func (e *DeploymentEntry[T]) Coalesce(ctx context.Context, r Resolver) (T, error) { + var zero T + + resolved, err := e.Resolve(ctx, r) + if err != nil { + if errors.Is(err, ErrEntryNotFound) { + return e.StartupValue(), nil + } + return zero, err + } + + return resolved, nil +} + +// Functions to implement pflag.Value for serpent usage + +// Set is an alias of SetStartupValue. Implemented to match the serpent interface +// such that we can use this Go type in the OptionSet. +func (e *DeploymentEntry[T]) Set(s string) error { + return e.SetStartupValue(s) +} + +// Type returns the wrapped value's type. +func (e *DeploymentEntry[T]) Type() string { + return e.val().Type() +} + +// String returns the wrapper value's string representation. +func (e *DeploymentEntry[T]) String() string { + return e.val().String() +} + +// val fronts the T value in the struct, and initializes it should the value be nil. +func (e *DeploymentEntry[T]) val() T { + if reflect.ValueOf(e.startupValue).IsNil() { + e.startupValue = create[T]() + } + return e.startupValue +} diff --git a/coderd/runtimeconfig/deploymententry_test.go b/coderd/runtimeconfig/deploymententry_test.go new file mode 100644 index 0000000000000..b1c0f8ac69bd5 --- /dev/null +++ b/coderd/runtimeconfig/deploymententry_test.go @@ -0,0 +1,198 @@ +package runtimeconfig_test + +import ( + "context" + "fmt" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database/dbmem" + "github.com/coder/coder/v2/coderd/database/dbmock" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/runtimeconfig" + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" + "github.com/coder/serpent" +) + +func ExampleDeploymentValues() { + ctx := context.Background() + db := dbmem.New() + st := runtimeconfig.NewManager() + + // Define the field, this will usually live on Deployment Values. + var stringField runtimeconfig.DeploymentEntry[*serpent.String] + // All fields need to be initialized with their "key". This will be used + // to uniquely identify the field in the store. + stringField.Initialize("string-field") + + // The startup value configured by the deployment env vars + // This acts as a default value if no runtime value is set. + // Can be used to support migrating a value from startup to runtime. + _ = stringField.SetStartupValue("default") + + // Runtime values take priority over startup values. + _ = stringField.SetRuntimeValue(ctx, st.Resolver(db), serpent.StringOf(ptr.Ref("hello world"))) + + // Resolve the value of the field. + val, err := stringField.Resolve(ctx, st.Resolver(db)) + if err != nil { + panic(err) + } + _, _ = fmt.Println(val) + // Output: hello world +} + +// TestResolveDBError ensures a db error that is not a sql.ErrNoRows +// will bubble up using Coalesce. The error should not be ignored and replaced +// with the startup value. +func TestResolveDBError(t *testing.T) { + t.Parallel() + + dbErr := xerrors.Errorf("some db error") + ctrl := gomock.NewController(t) + mDB := dbmock.NewMockStore(ctrl) + // Error on fetch + mDB.EXPECT(). + GetRuntimeConfig(gomock.Any(), gomock.Any()). + Times(1). + Return("", dbErr) + + // Error on upsert + mDB.EXPECT(). + UpsertRuntimeConfig(gomock.Any(), gomock.Any()). + Times(1). + Return(dbErr) + + // Error on delete + mDB.EXPECT(). + DeleteRuntimeConfig(gomock.Any(), gomock.Any()). + Times(1). + Return(dbErr) + + st := runtimeconfig.NewManager() + var stringField runtimeconfig.DeploymentEntry[*serpent.String] + stringField.Initialize("string-field") + stringField.SetStartupValue("default") + + ctx := testutil.Context(t, testutil.WaitMedium) + // Resolve + _, err := stringField.Coalesce(ctx, st.Resolver(mDB)) + require.ErrorIs(t, err, dbErr) + // Set + err = stringField.SetRuntimeValue(ctx, st.Resolver(mDB), serpent.StringOf(ptr.Ref("hello world"))) + require.ErrorIs(t, err, dbErr) + // Unset + err = stringField.UnsetRuntimeValue(ctx, st.Resolver(mDB)) + require.ErrorIs(t, err, dbErr) +} + +// TestSerpentDeploymentEntry uses the package as the serpent options will use it. +// Some of the usage might feel awkward, since the serpent package values come from +// the serpent parsing (strings), not manual assignment. +func TestSerpentDeploymentEntry(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitMedium) + db, _ := dbtestutil.NewDB(t) + st := runtimeconfig.NewManager() + + // TestEntries is how entries are defined in deployment values. + type TestEntries struct { + String runtimeconfig.DeploymentEntry[*serpent.String] + Bool runtimeconfig.DeploymentEntry[*serpent.Bool] + // codersdk.Feature is arbitrary, just using an actual struct to test. + Struct runtimeconfig.DeploymentEntry[*serpent.Struct[codersdk.Feature]] + } + + var entries TestEntries + // Init fields + entries.String.Initialize("string-field") + entries.Bool.Initialize("bool-field") + entries.Struct.Initialize("struct-field") + + // Check the Type() methods are unchanged + require.Equal(t, entries.String.Type(), (serpent.String("")).Type()) + require.Equal(t, entries.Bool.Type(), (serpent.Bool(false)).Type()) + require.Equal(t, entries.Struct.Type(), (&serpent.Struct[codersdk.Feature]{}).Type()) + + // When using Coalesce, the default value is the empty value + stringVal, err := entries.String.Coalesce(ctx, st.Resolver(db)) + require.NoError(t, err) + require.Equal(t, "", stringVal.String()) + + // Set some defaults for some + _ = entries.String.SetStartupValue("default") + _ = entries.Struct.SetStartupValue((&serpent.Struct[codersdk.Feature]{ + Value: codersdk.Feature{ + Entitlement: codersdk.EntitlementEntitled, + Enabled: false, + Limit: ptr.Ref(int64(100)), + Actual: nil, + }, + }).String()) + + // Retrieve startup values + stringVal, err = entries.String.Coalesce(ctx, st.Resolver(db)) + require.NoError(t, err) + require.Equal(t, "default", stringVal.String()) + + structVal, err := entries.Struct.Coalesce(ctx, st.Resolver(db)) + require.NoError(t, err) + require.Equal(t, structVal.Value.Entitlement, codersdk.EntitlementEntitled) + require.Equal(t, structVal.Value.Limit, ptr.Ref(int64(100))) + + // Override some defaults + err = entries.String.SetRuntimeValue(ctx, st.Resolver(db), serpent.StringOf(ptr.Ref("hello world"))) + require.NoError(t, err) + + err = entries.Struct.SetRuntimeValue(ctx, st.Resolver(db), &serpent.Struct[codersdk.Feature]{ + Value: codersdk.Feature{ + Entitlement: codersdk.EntitlementGracePeriod, + }, + }) + require.NoError(t, err) + + // Retrieve runtime values + stringVal, err = entries.String.Coalesce(ctx, st.Resolver(db)) + require.NoError(t, err) + require.Equal(t, "hello world", stringVal.String()) + + structVal, err = entries.Struct.Coalesce(ctx, st.Resolver(db)) + require.NoError(t, err) + require.Equal(t, structVal.Value.Entitlement, codersdk.EntitlementGracePeriod) + + // Test unset + err = entries.String.UnsetRuntimeValue(ctx, st.Resolver(db)) + require.NoError(t, err) + stringVal, err = entries.String.Coalesce(ctx, st.Resolver(db)) + require.NoError(t, err) + require.Equal(t, "default", stringVal.String()) + + // Test using org scoped resolver + orgID := uuid.New() + orgResolver := st.OrganizationResolver(db, orgID) + // No org runtime set + stringVal, err = entries.String.Coalesce(ctx, orgResolver) + require.NoError(t, err) + require.Equal(t, "default", stringVal.String()) + // Update org runtime + err = entries.String.SetRuntimeValue(ctx, orgResolver, serpent.StringOf(ptr.Ref("hello organizations"))) + require.NoError(t, err) + // Verify org runtime + stringVal, err = entries.String.Coalesce(ctx, orgResolver) + require.NoError(t, err) + require.Equal(t, "hello organizations", stringVal.String()) + // Unset org runtime + err = entries.String.UnsetRuntimeValue(ctx, orgResolver) + require.NoError(t, err) + // Verify org runtime is back to default + stringVal, err = entries.String.Coalesce(ctx, orgResolver) + require.NoError(t, err) + require.Equal(t, "default", stringVal.String()) +} 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..483172d063417 --- /dev/null +++ b/coderd/runtimeconfig/entry_test.go @@ -0,0 +1,140 @@ +package runtimeconfig_test + +import ( + "context" + "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/coderd/util/ptr" + "github.com/coder/coder/v2/testutil" + "github.com/coder/serpent" +) + +// TestEntry demonstrates creating org-level overrides for deployment-level settings. +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") + }) + + { + var field runtimeconfig.DeploymentEntry[*serpent.Float64] + field.Initialize("my-field") + // "hello" cannot be set on a *serpent.Float64 field. + require.Error(t, field.Set("hello")) + } + }) + + t.Run("zero", func(t *testing.T) { + t.Parallel() + + rlv := runtimeconfig.NewNoopResolver() + + // A zero-value declaration of a runtimeconfig.Entry should behave as a zero value of the generic type. + // NB! A name has not been set for this entry; it is "uninitialized". + var field runtimeconfig.DeploymentEntry[*serpent.Bool] + var zero serpent.Bool + require.Equal(t, field.StartupValue().Value(), zero.Value()) + + // Setting a value will not produce an error. + require.NoError(t, field.SetStartupValue("true")) + + // Attempting to resolve will produce an error. + _, err := field.Resolve(context.Background(), rlv) + require.ErrorIs(t, err, runtimeconfig.ErrNameNotSet) + + // Attempting to unset + err = field.UnsetRuntimeValue(context.Background(), rlv) + require.ErrorIs(t, err, runtimeconfig.ErrNameNotSet) + + // Attempting to set + val := serpent.BoolOf(ptr.Ref(true)) + require.ErrorIs(t, field.SetRuntimeValue(context.Background(), rlv, val), runtimeconfig.ErrNameNotSet) + }) + + t.Run("simple", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + mgr := runtimeconfig.NewManager() + db := dbmem.New() + + var ( + base = serpent.String("system@dev.coder.com") + override = serpent.String("dogfood@dev.coder.com") + ) + + var field runtimeconfig.DeploymentEntry[*serpent.String] + field.Initialize("my-field") + field.SetStartupValue(base.String()) + // Check that default has been set. + require.Equal(t, base.String(), field.StartupValue().String()) + // Validate that it returns that value. + require.Equal(t, base.String(), field.String()) + // Validate that there is no org-level override right now. + _, err := field.Resolve(ctx, mgr.Resolver(db)) + require.ErrorIs(t, err, runtimeconfig.ErrEntryNotFound) + // Coalesce returns the deployment-wide value. + val, err := field.Coalesce(ctx, mgr.Resolver(db)) + require.NoError(t, err) + require.Equal(t, base.String(), val.String()) + // Set an org-level override. + require.NoError(t, field.SetRuntimeValue(ctx, mgr.Resolver(db), &override)) + // Coalesce now returns the org-level value. + val, err = field.Coalesce(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() + + var ( + base = serpent.Struct[map[string]string]{ + Value: map[string]string{"access_type": "offline"}, + } + override = serpent.Struct[map[string]string]{ + Value: map[string]string{ + "a": "b", + "c": "d", + }, + } + ) + + var field runtimeconfig.DeploymentEntry[*serpent.Struct[map[string]string]] + field.Initialize("my-field") + field.SetStartupValue(base.String()) + + // Check that default has been set. + require.Equal(t, base.String(), field.StartupValue().String()) + // Validate that there is no org-level override right now. + _, err := field.Resolve(ctx, mgr.Resolver(db)) + require.ErrorIs(t, err, runtimeconfig.ErrEntryNotFound) + // Coalesce returns the deployment-wide value. + val, err := field.Coalesce(ctx, mgr.Resolver(db)) + require.NoError(t, err) + require.Equal(t, base.Value, val.Value) + // Set an org-level override. + 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) +}