mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: implement runtime configuration package with multi-org support (#14624)
runtime configuration package --------- Signed-off-by: Danny Kopping <danny@coder.com> Co-authored-by: Danny Kopping <danny@coder.com>
This commit is contained in:
@@ -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):")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -84,6 +84,7 @@ func New() database.Store {
|
||||
workspaceProxies: make([]database.WorkspaceProxy, 0),
|
||||
customRoles: make([]database.CustomRole, 0),
|
||||
locks: map[int64]struct{}{},
|
||||
runtimeConfig: map[string]string{},
|
||||
},
|
||||
}
|
||||
// Always start with a default org. Matching migration 198.
|
||||
@@ -194,6 +195,7 @@ type data struct {
|
||||
workspaces []database.Workspace
|
||||
workspaceProxies []database.WorkspaceProxy
|
||||
customRoles []database.CustomRole
|
||||
runtimeConfig map[string]string
|
||||
// Locks is a map of lock names. Any keys within the map are currently
|
||||
// locked.
|
||||
locks map[int64]struct{}
|
||||
@@ -1928,6 +1930,14 @@ func (q *FakeQuerier) DeleteReplicasUpdatedBefore(_ context.Context, before time
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) DeleteRuntimeConfig(_ context.Context, key string) error {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
||||
delete(q.runtimeConfig, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*FakeQuerier) DeleteTailnetAgent(context.Context, database.DeleteTailnetAgentParams) (database.DeleteTailnetAgentRow, error) {
|
||||
return database.DeleteTailnetAgentRow{}, ErrUnimplemented
|
||||
}
|
||||
@@ -3505,6 +3515,18 @@ func (q *FakeQuerier) GetReplicasUpdatedAfter(_ context.Context, updatedAt time.
|
||||
return replicas, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) GetRuntimeConfig(_ context.Context, key string) (string, error) {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
||||
val, ok := q.runtimeConfig[key]
|
||||
if !ok {
|
||||
return "", sql.ErrNoRows
|
||||
}
|
||||
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func (*FakeQuerier) GetTailnetAgents(context.Context, uuid.UUID) ([]database.TailnetAgent, error) {
|
||||
return nil, ErrUnimplemented
|
||||
}
|
||||
@@ -9187,6 +9209,19 @@ func (q *FakeQuerier) UpsertProvisionerDaemon(_ context.Context, arg database.Up
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) UpsertRuntimeConfig(_ context.Context, arg database.UpsertRuntimeConfigParams) error {
|
||||
err := validateDatabaseType(arg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
||||
q.runtimeConfig[arg.Key] = arg.Value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*FakeQuerier) UpsertTailnetAgent(context.Context, database.UpsertTailnetAgentParams) (database.TailnetAgent, error) {
|
||||
return database.TailnetAgent{}, ErrUnimplemented
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// Package runtimeconfig contains logic for managing runtime configuration values
|
||||
// stored in the database. Each coderd should have a Manager singleton instance
|
||||
// that can create a Resolver for runtime configuration CRUD.
|
||||
//
|
||||
// TODO: Implement a caching layer for the Resolver so that we don't hit the
|
||||
// database on every request. Configuration values are not expected to change
|
||||
// frequently, so we should use pubsub to notify for updates.
|
||||
// When implemented, the runtimeconfig will essentially be an in memory lookup
|
||||
// with a database for persistence.
|
||||
package runtimeconfig
|
||||
@@ -0,0 +1,95 @@
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
// EntryMarshaller requires all entries to marshal to and from a string.
|
||||
// The final store value is a database `text` column.
|
||||
// This also is compatible with serpent values.
|
||||
type EntryMarshaller interface {
|
||||
fmt.Stringer
|
||||
}
|
||||
|
||||
type EntryValue interface {
|
||||
EntryMarshaller
|
||||
Set(string) error
|
||||
}
|
||||
|
||||
// RuntimeEntry are **only** runtime configurable. They are stored in the
|
||||
// database, and have no startup value or default value.
|
||||
type RuntimeEntry[T EntryValue] struct {
|
||||
n string
|
||||
}
|
||||
|
||||
// New creates a new T instance with a defined name and value.
|
||||
func New[T EntryValue](name string) (out RuntimeEntry[T], err error) {
|
||||
out.n = name
|
||||
if name == "" {
|
||||
return out, ErrNameNotSet
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MustNew is like New but panics if an error occurs.
|
||||
func MustNew[T EntryValue](name string) RuntimeEntry[T] {
|
||||
out, err := New[T](name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SetRuntimeValue attempts to update the runtime value of this field in the store via the given Mutator.
|
||||
func (e *RuntimeEntry[T]) SetRuntimeValue(ctx context.Context, m Resolver, val T) error {
|
||||
name, err := e.name()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("set runtime: %w", err)
|
||||
}
|
||||
|
||||
return m.UpsertRuntimeConfig(ctx, name, val.String())
|
||||
}
|
||||
|
||||
// UnsetRuntimeValue removes the runtime value from the store.
|
||||
func (e *RuntimeEntry[T]) UnsetRuntimeValue(ctx context.Context, m Resolver) error {
|
||||
name, err := e.name()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("unset runtime: %w", err)
|
||||
}
|
||||
|
||||
return m.DeleteRuntimeConfig(ctx, name)
|
||||
}
|
||||
|
||||
// Resolve attempts to resolve the runtime value of this field from the store via the given Resolver.
|
||||
func (e *RuntimeEntry[T]) Resolve(ctx context.Context, r Resolver) (T, error) {
|
||||
var zero T
|
||||
|
||||
name, err := e.name()
|
||||
if err != nil {
|
||||
return zero, xerrors.Errorf("resolve, name issue: %w", err)
|
||||
}
|
||||
|
||||
val, err := r.GetRuntimeConfig(ctx, name)
|
||||
if err != nil {
|
||||
return zero, xerrors.Errorf("resolve runtime: %w", err)
|
||||
}
|
||||
|
||||
inst := create[T]()
|
||||
if err = inst.Set(val); err != nil {
|
||||
return zero, xerrors.Errorf("instantiate new %T: %w", inst, err)
|
||||
}
|
||||
return inst, nil
|
||||
}
|
||||
|
||||
// name returns the configured name, or fails with ErrNameNotSet.
|
||||
func (e *RuntimeEntry[T]) name() (string, error) {
|
||||
if e.n == "" {
|
||||
return "", ErrNameNotSet
|
||||
}
|
||||
|
||||
return e.n, nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package runtimeconfig_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database/dbmem"
|
||||
"github.com/coder/coder/v2/coderd/runtimeconfig"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func TestEntry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("new", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Panics(t, func() {
|
||||
// No name should panic
|
||||
runtimeconfig.MustNew[*serpent.Float64]("")
|
||||
})
|
||||
|
||||
require.NotPanics(t, func() {
|
||||
runtimeconfig.MustNew[*serpent.Float64]("my-field")
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("simple", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
mgr := runtimeconfig.NewManager()
|
||||
db := dbmem.New()
|
||||
|
||||
override := serpent.String("dogfood@dev.coder.com")
|
||||
|
||||
field := runtimeconfig.MustNew[*serpent.String]("string-field")
|
||||
|
||||
// No value set yet.
|
||||
_, err := field.Resolve(ctx, mgr.Resolver(db))
|
||||
require.ErrorIs(t, err, runtimeconfig.ErrEntryNotFound)
|
||||
// Set an org-level override.
|
||||
require.NoError(t, field.SetRuntimeValue(ctx, mgr.Resolver(db), &override))
|
||||
// Value was updated
|
||||
val, err := field.Resolve(ctx, mgr.Resolver(db))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, override.String(), val.String())
|
||||
})
|
||||
|
||||
t.Run("complex", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
mgr := runtimeconfig.NewManager()
|
||||
db := dbmem.New()
|
||||
|
||||
override := serpent.Struct[map[string]string]{
|
||||
Value: map[string]string{
|
||||
"a": "b",
|
||||
"c": "d",
|
||||
},
|
||||
}
|
||||
|
||||
field := runtimeconfig.MustNew[*serpent.Struct[map[string]string]]("string-field")
|
||||
// Validate that there is no runtime override right now.
|
||||
_, err := field.Resolve(ctx, mgr.Resolver(db))
|
||||
require.ErrorIs(t, err, runtimeconfig.ErrEntryNotFound)
|
||||
// Set a runtime value
|
||||
require.NoError(t, field.SetRuntimeValue(ctx, mgr.Resolver(db), &override))
|
||||
// Coalesce now returns the org-level value.
|
||||
structVal, err := field.Resolve(ctx, mgr.Resolver(db))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, override.Value, structVal.Value)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Manager is the singleton that produces resolvers for runtime configuration.
|
||||
// TODO: Implement caching layer.
|
||||
type Manager struct{}
|
||||
|
||||
func NewManager() *Manager {
|
||||
return &Manager{}
|
||||
}
|
||||
|
||||
// Resolver is the deployment wide namespace for runtime configuration.
|
||||
// If you are trying to namespace a configuration, orgs for example, use
|
||||
// OrganizationResolver.
|
||||
func (*Manager) Resolver(db Store) Resolver {
|
||||
return NewStoreResolver(db)
|
||||
}
|
||||
|
||||
// OrganizationResolver will namespace all runtime configuration to the provided
|
||||
// organization ID. Configuration values stored with a given organization ID require
|
||||
// that the organization ID be provided to retrieve the value.
|
||||
// No values set here will ever be returned by the call to 'Resolver()'.
|
||||
func (*Manager) OrganizationResolver(db Store, orgID uuid.UUID) Resolver {
|
||||
return OrganizationResolver(orgID, NewStoreResolver(db))
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
)
|
||||
|
||||
// NoopResolver is a useful test device.
|
||||
type NoopResolver struct{}
|
||||
|
||||
func NewNoopResolver() *NoopResolver {
|
||||
return &NoopResolver{}
|
||||
}
|
||||
|
||||
func (NoopResolver) GetRuntimeConfig(context.Context, string) (string, error) {
|
||||
return "", ErrEntryNotFound
|
||||
}
|
||||
|
||||
func (NoopResolver) UpsertRuntimeConfig(context.Context, string, string) error {
|
||||
return ErrEntryNotFound
|
||||
}
|
||||
|
||||
func (NoopResolver) DeleteRuntimeConfig(context.Context, string) error {
|
||||
return ErrEntryNotFound
|
||||
}
|
||||
|
||||
// StoreResolver uses the database as the underlying store for runtime settings.
|
||||
type StoreResolver struct {
|
||||
db Store
|
||||
}
|
||||
|
||||
func NewStoreResolver(db Store) *StoreResolver {
|
||||
return &StoreResolver{db: db}
|
||||
}
|
||||
|
||||
func (m StoreResolver) GetRuntimeConfig(ctx context.Context, key string) (string, error) {
|
||||
val, err := m.db.GetRuntimeConfig(ctx, key)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", xerrors.Errorf("%q: %w", key, ErrEntryNotFound)
|
||||
}
|
||||
return "", xerrors.Errorf("fetch %q: %w", key, err)
|
||||
}
|
||||
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func (m StoreResolver) UpsertRuntimeConfig(ctx context.Context, key, val string) error {
|
||||
err := m.db.UpsertRuntimeConfig(ctx, database.UpsertRuntimeConfigParams{Key: key, Value: val})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update %q: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m StoreResolver) DeleteRuntimeConfig(ctx context.Context, key string) error {
|
||||
return m.db.DeleteRuntimeConfig(ctx, key)
|
||||
}
|
||||
|
||||
// NamespacedResolver prefixes all keys with a namespace.
|
||||
// Then defers to the underlying resolver for the actual operations.
|
||||
type NamespacedResolver struct {
|
||||
ns string
|
||||
wrapped Resolver
|
||||
}
|
||||
|
||||
func OrganizationResolver(orgID uuid.UUID, wrapped Resolver) NamespacedResolver {
|
||||
return NamespacedResolver{ns: orgID.String(), wrapped: wrapped}
|
||||
}
|
||||
|
||||
func (m NamespacedResolver) GetRuntimeConfig(ctx context.Context, key string) (string, error) {
|
||||
return m.wrapped.GetRuntimeConfig(ctx, m.namespacedKey(key))
|
||||
}
|
||||
|
||||
func (m NamespacedResolver) UpsertRuntimeConfig(ctx context.Context, key, val string) error {
|
||||
return m.wrapped.UpsertRuntimeConfig(ctx, m.namespacedKey(key), val)
|
||||
}
|
||||
|
||||
func (m NamespacedResolver) DeleteRuntimeConfig(ctx context.Context, key string) error {
|
||||
return m.wrapped.DeleteRuntimeConfig(ctx, m.namespacedKey(key))
|
||||
}
|
||||
|
||||
func (m NamespacedResolver) namespacedKey(k string) string {
|
||||
return fmt.Sprintf("%s:%s", m.ns, k)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrEntryNotFound is returned when a runtime entry is not saved in the
|
||||
// store. It is essentially a 'sql.ErrNoRows'.
|
||||
ErrEntryNotFound = xerrors.New("entry not found")
|
||||
// ErrNameNotSet is returned when a runtime entry is created without a name.
|
||||
// This is more likely to happen on DeploymentEntry that has not called
|
||||
// Initialize().
|
||||
ErrNameNotSet = xerrors.New("name is not set")
|
||||
)
|
||||
|
||||
type Initializer interface {
|
||||
Initialize(name string)
|
||||
}
|
||||
|
||||
type Resolver interface {
|
||||
// GetRuntimeConfig gets a runtime setting by name.
|
||||
GetRuntimeConfig(ctx context.Context, name string) (string, error)
|
||||
// UpsertRuntimeConfig upserts a runtime setting by name.
|
||||
UpsertRuntimeConfig(ctx context.Context, name, val string) error
|
||||
// DeleteRuntimeConfig deletes a runtime setting by name.
|
||||
DeleteRuntimeConfig(ctx context.Context, name string) error
|
||||
}
|
||||
|
||||
// Store is a subset of database.Store
|
||||
type Store interface {
|
||||
GetRuntimeConfig(ctx context.Context, key string) (string, error)
|
||||
UpsertRuntimeConfig(ctx context.Context, arg database.UpsertRuntimeConfigParams) error
|
||||
DeleteRuntimeConfig(ctx context.Context, key string) error
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
)
|
||||
|
||||
func create[T any]() T {
|
||||
var zero T
|
||||
//nolint:forcetypeassert
|
||||
return reflect.New(reflect.TypeOf(zero).Elem()).Interface().(T)
|
||||
}
|
||||
Reference in New Issue
Block a user