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) +} pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy