diff --git a/api/core/v1beta1/site_types.go b/api/core/v1beta1/site_types.go index 1373ffc..9024ce1 100644 --- a/api/core/v1beta1/site_types.go +++ b/api/core/v1beta1/site_types.go @@ -632,6 +632,10 @@ type InternalWorkbenchExperimentalFeatures struct { // ForceAdminUiEnabled forces the configuration manager UI to be enabled even when Workbench has it disabled // by default when running on Kubernetes ForceAdminUiEnabled bool `json:"forceAdminUiEnabled,omitempty"` + + // AuditDatabaseEnabled provisions Workbench's Audit Database: a second Postgres database, + // distinct from the internal database, that stores historical session and usage data. + AuditDatabaseEnabled bool `json:"auditDatabaseEnabled,omitempty"` } type InternalChronicleSpec struct { diff --git a/api/core/v1beta1/workbench_config.go b/api/core/v1beta1/workbench_config.go index 24de445..7dfa0c3 100644 --- a/api/core/v1beta1/workbench_config.go +++ b/api/core/v1beta1/workbench_config.go @@ -30,6 +30,7 @@ type WorkbenchSecretConfig struct { type WorkbenchSecretIniConfig struct { Database *WorkbenchDatabaseConfig `json:"database.conf,omitempty"` + AuditDatabase *WorkbenchAuditDatabaseConfig `json:"audit-database.conf,omitempty"` OpenidClientSecret *WorkbenchOpenidClientSecret `json:"openid-client-secret,omitempty"` Databricks map[string]*WorkbenchDatabricksConfig `json:"databricks.conf,omitempty"` } @@ -909,6 +910,18 @@ type WorkbenchDatabaseConfig struct { Password string `json:"password,omitempty"` } +// WorkbenchAuditDatabaseConfig renders /etc/rstudio/audit-database.conf. Password is +// populated directly here rather than via WORKBENCH_POSTGRES_PASSWORD, since that env +// var is shared with database.conf and can't carry two different roles' passwords. +type WorkbenchAuditDatabaseConfig struct { + Provider WorkbenchDatabaseProvider `json:"provider,omitempty"` + Database string `json:"database,omitempty"` + Port string `json:"port,omitempty"` + Host string `json:"host,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` +} + type WorkbenchVsCodeConfig struct { Enabled int `json:"enabled,omitempty"` Exe string `json:"exe,omitempty"` diff --git a/api/core/v1beta1/workbench_config_test.go b/api/core/v1beta1/workbench_config_test.go index 7f3f638..d190275 100644 --- a/api/core/v1beta1/workbench_config_test.go +++ b/api/core/v1beta1/workbench_config_test.go @@ -22,6 +22,14 @@ func TestWorkbenchSecretConfig_GenerateSecretData(t *testing.T) { Host: "myhost.com", Username: "user", }, + AuditDatabase: &WorkbenchAuditDatabaseConfig{ + Provider: WorkbenchDatabaseProviderPostgres, + Database: "chicken_audit", + Port: "5432", + Host: "myhost.com", + Username: "chicken_audit", + Password: "audit-secret", + }, OpenidClientSecret: &WorkbenchOpenidClientSecret{ ClientId: "your-client-id-test", }, @@ -40,9 +48,15 @@ func TestWorkbenchSecretConfig_GenerateSecretData(t *testing.T) { require.Contains(t, res["database.conf"], "port=5432") require.Contains(t, res["database.conf"], "host=myhost.com") require.Contains(t, res["database.conf"], "username=user") + require.Contains(t, res["audit-database.conf"], "provider=postgresql") + require.Contains(t, res["audit-database.conf"], "database=chicken_audit") + require.Contains(t, res["audit-database.conf"], "port=5432") + require.Contains(t, res["audit-database.conf"], "host=myhost.com") + require.Contains(t, res["audit-database.conf"], "username=chicken_audit") + require.Contains(t, res["audit-database.conf"], "password=audit-secret") require.Contains(t, res["openid-client-secret"], "client-id=your-client-id-test") require.Contains(t, res["databricks.conf"], "name=posit-test") - require.Len(t, res, 3) + require.Len(t, res, 4) } func TestWorkbenchConfig_GenerateConfigmap(t *testing.T) { diff --git a/api/core/v1beta1/workbench_types.go b/api/core/v1beta1/workbench_types.go index 51cb0b0..8426d96 100644 --- a/api/core/v1beta1/workbench_types.go +++ b/api/core/v1beta1/workbench_types.go @@ -110,6 +110,11 @@ type WorkbenchSpec struct { // MainDatabaseCredentialSecret configures the secret used for storing the main database credentials MainDatabaseCredentialSecret SecretConfig `json:"mainDatabaseCredentialSecret,omitempty"` + // AuditDatabaseEnabled provisions a second Postgres database (distinct from the internal + // database) and role, then renders audit-database.conf pointing Workbench at it. Requires + // the Secret referenced by Secret.VaultName to already contain a "dev-audit-db-password" key. + AuditDatabaseEnabled bool `json:"auditDatabaseEnabled,omitempty"` + Replicas int `json:"replicas,omitempty"` // DsnSecret is the name of the secret that contains the DSN to include with all Workbench sessions diff --git a/api/core/v1beta1/zz_generated.deepcopy.go b/api/core/v1beta1/zz_generated.deepcopy.go index 633b0c4..83aeffc 100644 --- a/api/core/v1beta1/zz_generated.deepcopy.go +++ b/api/core/v1beta1/zz_generated.deepcopy.go @@ -2670,6 +2670,21 @@ func (in *Workbench) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkbenchAuditDatabaseConfig) DeepCopyInto(out *WorkbenchAuditDatabaseConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkbenchAuditDatabaseConfig. +func (in *WorkbenchAuditDatabaseConfig) DeepCopy() *WorkbenchAuditDatabaseConfig { + if in == nil { + return nil + } + out := new(WorkbenchAuditDatabaseConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *WorkbenchConfig) DeepCopyInto(out *WorkbenchConfig) { *out = *in @@ -3271,6 +3286,11 @@ func (in *WorkbenchSecretIniConfig) DeepCopyInto(out *WorkbenchSecretIniConfig) *out = new(WorkbenchDatabaseConfig) **out = **in } + if in.AuditDatabase != nil { + in, out := &in.AuditDatabase, &out.AuditDatabase + *out = new(WorkbenchAuditDatabaseConfig) + **out = **in + } if in.OpenidClientSecret != nil { in, out := &in.OpenidClientSecret, &out.OpenidClientSecret *out = new(WorkbenchOpenidClientSecret) diff --git a/client-go/applyconfiguration/core/v1beta1/internalworkbenchexperimentalfeatures.go b/client-go/applyconfiguration/core/v1beta1/internalworkbenchexperimentalfeatures.go index 21d0973..61d6383 100644 --- a/client-go/applyconfiguration/core/v1beta1/internalworkbenchexperimentalfeatures.go +++ b/client-go/applyconfiguration/core/v1beta1/internalworkbenchexperimentalfeatures.go @@ -34,6 +34,7 @@ type InternalWorkbenchExperimentalFeaturesApplyConfiguration struct { LauncherEnvPath *string `json:"launcherEnvPath,omitempty"` ChronicleSidecarProductApiKeyEnabled *bool `json:"chronicleSidecarProductApiKeyEnabled,omitempty"` ForceAdminUiEnabled *bool `json:"forceAdminUiEnabled,omitempty"` + AuditDatabaseEnabled *bool `json:"auditDatabaseEnabled,omitempty"` } // InternalWorkbenchExperimentalFeaturesApplyConfiguration constructs a declarative configuration of the InternalWorkbenchExperimentalFeatures type for use with @@ -217,3 +218,11 @@ func (b *InternalWorkbenchExperimentalFeaturesApplyConfiguration) WithForceAdmin b.ForceAdminUiEnabled = &value return b } + +// WithAuditDatabaseEnabled sets the AuditDatabaseEnabled field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the AuditDatabaseEnabled field is set to the value of the last call. +func (b *InternalWorkbenchExperimentalFeaturesApplyConfiguration) WithAuditDatabaseEnabled(value bool) *InternalWorkbenchExperimentalFeaturesApplyConfiguration { + b.AuditDatabaseEnabled = &value + return b +} diff --git a/client-go/applyconfiguration/core/v1beta1/workbenchauditdatabaseconfig.go b/client-go/applyconfiguration/core/v1beta1/workbenchauditdatabaseconfig.go new file mode 100644 index 0000000..548d671 --- /dev/null +++ b/client-go/applyconfiguration/core/v1beta1/workbenchauditdatabaseconfig.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2023-2026 Posit Software, PBC + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1beta1 + +import ( + corev1beta1 "github.com/posit-dev/team-operator/api/core/v1beta1" +) + +// WorkbenchAuditDatabaseConfigApplyConfiguration represents a declarative configuration of the WorkbenchAuditDatabaseConfig type for use +// with apply. +type WorkbenchAuditDatabaseConfigApplyConfiguration struct { + Provider *corev1beta1.WorkbenchDatabaseProvider `json:"provider,omitempty"` + Database *string `json:"database,omitempty"` + Port *string `json:"port,omitempty"` + Host *string `json:"host,omitempty"` + Username *string `json:"username,omitempty"` + Password *string `json:"password,omitempty"` +} + +// WorkbenchAuditDatabaseConfigApplyConfiguration constructs a declarative configuration of the WorkbenchAuditDatabaseConfig type for use with +// apply. +func WorkbenchAuditDatabaseConfig() *WorkbenchAuditDatabaseConfigApplyConfiguration { + return &WorkbenchAuditDatabaseConfigApplyConfiguration{} +} + +// WithProvider sets the Provider field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Provider field is set to the value of the last call. +func (b *WorkbenchAuditDatabaseConfigApplyConfiguration) WithProvider(value corev1beta1.WorkbenchDatabaseProvider) *WorkbenchAuditDatabaseConfigApplyConfiguration { + b.Provider = &value + return b +} + +// WithDatabase sets the Database field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Database field is set to the value of the last call. +func (b *WorkbenchAuditDatabaseConfigApplyConfiguration) WithDatabase(value string) *WorkbenchAuditDatabaseConfigApplyConfiguration { + b.Database = &value + return b +} + +// WithPort sets the Port field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Port field is set to the value of the last call. +func (b *WorkbenchAuditDatabaseConfigApplyConfiguration) WithPort(value string) *WorkbenchAuditDatabaseConfigApplyConfiguration { + b.Port = &value + return b +} + +// WithHost sets the Host field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Host field is set to the value of the last call. +func (b *WorkbenchAuditDatabaseConfigApplyConfiguration) WithHost(value string) *WorkbenchAuditDatabaseConfigApplyConfiguration { + b.Host = &value + return b +} + +// WithUsername sets the Username field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Username field is set to the value of the last call. +func (b *WorkbenchAuditDatabaseConfigApplyConfiguration) WithUsername(value string) *WorkbenchAuditDatabaseConfigApplyConfiguration { + b.Username = &value + return b +} + +// WithPassword sets the Password field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Password field is set to the value of the last call. +func (b *WorkbenchAuditDatabaseConfigApplyConfiguration) WithPassword(value string) *WorkbenchAuditDatabaseConfigApplyConfiguration { + b.Password = &value + return b +} diff --git a/client-go/applyconfiguration/core/v1beta1/workbenchsecretconfig.go b/client-go/applyconfiguration/core/v1beta1/workbenchsecretconfig.go index 589e881..c72acdf 100644 --- a/client-go/applyconfiguration/core/v1beta1/workbenchsecretconfig.go +++ b/client-go/applyconfiguration/core/v1beta1/workbenchsecretconfig.go @@ -30,6 +30,15 @@ func (b *WorkbenchSecretConfigApplyConfiguration) WithDatabase(value *WorkbenchD return b } +// WithAuditDatabase sets the AuditDatabase field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the AuditDatabase field is set to the value of the last call. +func (b *WorkbenchSecretConfigApplyConfiguration) WithAuditDatabase(value *WorkbenchAuditDatabaseConfigApplyConfiguration) *WorkbenchSecretConfigApplyConfiguration { + b.ensureWorkbenchSecretIniConfigApplyConfigurationExists() + b.WorkbenchSecretIniConfigApplyConfiguration.AuditDatabase = value + return b +} + // WithOpenidClientSecret sets the OpenidClientSecret field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the OpenidClientSecret field is set to the value of the last call. diff --git a/client-go/applyconfiguration/core/v1beta1/workbenchsecretiniconfig.go b/client-go/applyconfiguration/core/v1beta1/workbenchsecretiniconfig.go index 3de039c..62abf02 100644 --- a/client-go/applyconfiguration/core/v1beta1/workbenchsecretiniconfig.go +++ b/client-go/applyconfiguration/core/v1beta1/workbenchsecretiniconfig.go @@ -13,6 +13,7 @@ import ( // with apply. type WorkbenchSecretIniConfigApplyConfiguration struct { Database *WorkbenchDatabaseConfigApplyConfiguration `json:"database.conf,omitempty"` + AuditDatabase *WorkbenchAuditDatabaseConfigApplyConfiguration `json:"audit-database.conf,omitempty"` OpenidClientSecret *WorkbenchOpenidClientSecretApplyConfiguration `json:"openid-client-secret,omitempty"` Databricks map[string]*corev1beta1.WorkbenchDatabricksConfig `json:"databricks.conf,omitempty"` } @@ -31,6 +32,14 @@ func (b *WorkbenchSecretIniConfigApplyConfiguration) WithDatabase(value *Workben return b } +// WithAuditDatabase sets the AuditDatabase field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the AuditDatabase field is set to the value of the last call. +func (b *WorkbenchSecretIniConfigApplyConfiguration) WithAuditDatabase(value *WorkbenchAuditDatabaseConfigApplyConfiguration) *WorkbenchSecretIniConfigApplyConfiguration { + b.AuditDatabase = value + return b +} + // WithOpenidClientSecret sets the OpenidClientSecret field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the OpenidClientSecret field is set to the value of the last call. diff --git a/client-go/applyconfiguration/core/v1beta1/workbenchspec.go b/client-go/applyconfiguration/core/v1beta1/workbenchspec.go index 82c9072..f20af98 100644 --- a/client-go/applyconfiguration/core/v1beta1/workbenchspec.go +++ b/client-go/applyconfiguration/core/v1beta1/workbenchspec.go @@ -46,6 +46,7 @@ type WorkbenchSpecApplyConfiguration struct { Secret *SecretConfigApplyConfiguration `json:"secret,omitempty"` WorkloadSecret *SecretConfigApplyConfiguration `json:"workloadSecret,omitempty"` MainDatabaseCredentialSecret *SecretConfigApplyConfiguration `json:"mainDatabaseCredentialSecret,omitempty"` + AuditDatabaseEnabled *bool `json:"auditDatabaseEnabled,omitempty"` Replicas *int `json:"replicas,omitempty"` DsnSecret *string `json:"dsnSecret,omitempty"` ChronicleSidecarProductApiKeyEnabled *bool `json:"chronicleSidecarProductApiKeyEnabled,omitempty"` @@ -351,6 +352,14 @@ func (b *WorkbenchSpecApplyConfiguration) WithMainDatabaseCredentialSecret(value return b } +// WithAuditDatabaseEnabled sets the AuditDatabaseEnabled field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the AuditDatabaseEnabled field is set to the value of the last call. +func (b *WorkbenchSpecApplyConfiguration) WithAuditDatabaseEnabled(value bool) *WorkbenchSpecApplyConfiguration { + b.AuditDatabaseEnabled = &value + return b +} + // WithReplicas sets the Replicas field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the Replicas field is set to the value of the last call. diff --git a/client-go/applyconfiguration/utils.go b/client-go/applyconfiguration/utils.go index 3618f64..e5a4c3b 100644 --- a/client-go/applyconfiguration/utils.go +++ b/client-go/applyconfiguration/utils.go @@ -201,6 +201,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &corev1beta1.VSCodeConfigApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("Workbench"): return &corev1beta1.WorkbenchApplyConfiguration{} + case v1beta1.SchemeGroupVersion.WithKind("WorkbenchAuditDatabaseConfig"): + return &corev1beta1.WorkbenchAuditDatabaseConfigApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("WorkbenchConfig"): return &corev1beta1.WorkbenchConfigApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("WorkbenchDatabaseConfig"): diff --git a/config/crd/bases/core.posit.team_sites.yaml b/config/crd/bases/core.posit.team_sites.yaml index 5296908..3659032 100644 --- a/config/crd/bases/core.posit.team_sites.yaml +++ b/config/crd/bases/core.posit.team_sites.yaml @@ -1884,6 +1884,11 @@ spec: description: ExperimentalFeatures allows enabling miscellaneous experimental features for workbench properties: + auditDatabaseEnabled: + description: |- + AuditDatabaseEnabled provisions Workbench's Audit Database: a second Postgres database, + distinct from the internal database, that stores historical session and usage data. + type: boolean chronicleSidecarProductApiKeyEnabled: description: |- ChronicleSidecarProductApiKeyEnabled assumes the api key for this product has been added to a secret and diff --git a/config/crd/bases/core.posit.team_workbenches.yaml b/config/crd/bases/core.posit.team_workbenches.yaml index 79839b1..dd49d61 100644 --- a/config/crd/bases/core.posit.team_workbenches.yaml +++ b/config/crd/bases/core.posit.team_workbenches.yaml @@ -106,6 +106,12 @@ spec: type: string type: object type: array + auditDatabaseEnabled: + description: |- + AuditDatabaseEnabled provisions a second Postgres database (distinct from the internal + database) and role, then renders audit-database.conf pointing Workbench at it. Requires + the Secret referenced by Secret.VaultName to already contain a "dev-audit-db-password" key. + type: boolean auth: properties: administratorRoleMapping: @@ -975,6 +981,25 @@ spec: properties: workbench-secret-ini-config: properties: + audit-database.conf: + description: |- + WorkbenchAuditDatabaseConfig renders /etc/rstudio/audit-database.conf. Password is + populated directly here rather than via WORKBENCH_POSTGRES_PASSWORD, since that env + var is shared with database.conf and can't carry two different roles' passwords. + properties: + database: + type: string + host: + type: string + password: + type: string + port: + type: string + provider: + type: string + username: + type: string + type: object database.conf: properties: database: diff --git a/dist/chart/templates/crd/core.posit.team_sites.yaml b/dist/chart/templates/crd/core.posit.team_sites.yaml index 14d65f7..5e85781 100755 --- a/dist/chart/templates/crd/core.posit.team_sites.yaml +++ b/dist/chart/templates/crd/core.posit.team_sites.yaml @@ -1905,6 +1905,11 @@ spec: description: ExperimentalFeatures allows enabling miscellaneous experimental features for workbench properties: + auditDatabaseEnabled: + description: |- + AuditDatabaseEnabled provisions Workbench's Audit Database: a second Postgres database, + distinct from the internal database, that stores historical session and usage data. + type: boolean chronicleSidecarProductApiKeyEnabled: description: |- ChronicleSidecarProductApiKeyEnabled assumes the api key for this product has been added to a secret and diff --git a/dist/chart/templates/crd/core.posit.team_workbenches.yaml b/dist/chart/templates/crd/core.posit.team_workbenches.yaml index 1fd5626..e093678 100755 --- a/dist/chart/templates/crd/core.posit.team_workbenches.yaml +++ b/dist/chart/templates/crd/core.posit.team_workbenches.yaml @@ -127,6 +127,12 @@ spec: type: string type: object type: array + auditDatabaseEnabled: + description: |- + AuditDatabaseEnabled provisions a second Postgres database (distinct from the internal + database) and role, then renders audit-database.conf pointing Workbench at it. Requires + the Secret referenced by Secret.VaultName to already contain a "dev-audit-db-password" key. + type: boolean auth: properties: administratorRoleMapping: @@ -996,6 +1002,25 @@ spec: properties: workbench-secret-ini-config: properties: + audit-database.conf: + description: |- + WorkbenchAuditDatabaseConfig renders /etc/rstudio/audit-database.conf. Password is + populated directly here rather than via WORKBENCH_POSTGRES_PASSWORD, since that env + var is shared with database.conf and can't carry two different roles' passwords. + properties: + database: + type: string + host: + type: string + password: + type: string + port: + type: string + provider: + type: string + username: + type: string + type: object database.conf: properties: database: diff --git a/internal/controller/core/site_controller_workbench.go b/internal/controller/core/site_controller_workbench.go index 892ee13..88eae4d 100644 --- a/internal/controller/core/site_controller_workbench.go +++ b/internal/controller/core/site_controller_workbench.go @@ -388,6 +388,10 @@ func (r *SiteReconciler) reconcileWorkbench( targetWorkbench.Spec.Config.RServer.ForceAdminUiEnabled = 1 } + if site.Spec.Workbench.ExperimentalFeatures.AuditDatabaseEnabled { + targetWorkbench.Spec.AuditDatabaseEnabled = true + } + if site.Spec.Workbench.ExperimentalFeatures.ReadinessProbePath != nil { targetWorkbench.Spec.ReadinessProbePath = site.Spec.Workbench.ExperimentalFeatures.ReadinessProbePath } diff --git a/internal/controller/core/site_test.go b/internal/controller/core/site_test.go index d55f0b4..7931056 100644 --- a/internal/controller/core/site_test.go +++ b/internal/controller/core/site_test.go @@ -562,6 +562,37 @@ func TestSiteAuditedJobsConfiguration(t *testing.T) { assert.Equal(t, intPtr(0), testWorkbench.Spec.Config.RServer.AuditedJobsDetailsUserDefined) } +func TestSiteAuditDatabaseEnabled(t *testing.T) { + siteName := "audit-database-site" + siteNamespace := "posit-team" + + site := defaultSite(siteName) + site.Spec.Workbench.ExperimentalFeatures = &v1beta1.InternalWorkbenchExperimentalFeatures{ + AuditDatabaseEnabled: true, + } + + cli, _, err := runFakeSiteReconciler(t, siteNamespace, siteName, site) + assert.Nil(t, err) + + testWorkbench := getWorkbench(t, cli, siteNamespace, siteName) + + assert.True(t, testWorkbench.Spec.AuditDatabaseEnabled) +} + +func TestSiteAuditDatabaseDisabledByDefault(t *testing.T) { + siteName := "audit-database-default-site" + siteNamespace := "posit-team" + + site := defaultSite(siteName) + + cli, _, err := runFakeSiteReconciler(t, siteNamespace, siteName, site) + assert.Nil(t, err) + + testWorkbench := getWorkbench(t, cli, siteNamespace, siteName) + + assert.False(t, testWorkbench.Spec.AuditDatabaseEnabled) +} + func TestSiteAuditedJobsPartialConfiguration(t *testing.T) { siteName := "audited-jobs-partial-site" siteNamespace := "posit-team" diff --git a/internal/controller/core/workbench.go b/internal/controller/core/workbench.go index b0bca23..2cd08e8 100644 --- a/internal/controller/core/workbench.go +++ b/internal/controller/core/workbench.go @@ -7,6 +7,7 @@ import ( "fmt" "regexp" "strconv" + "strings" "github.com/go-logr/logr" "github.com/pkg/errors" @@ -50,6 +51,18 @@ var invalidCharacters = regexp.MustCompile("[^a-z0-9]") // do not glob, lest we var azureDatabricksRegexp = regexp.MustCompile("azuredatabricks\\.net") +// postgresMaxIdentifierLength is Postgres's NAMEDATALEN-1 limit for database/role names. +const postgresMaxIdentifierLength = 63 + +// truncateForPostgresIdentifier bounds name to postgresMaxIdentifierLength, trimming any +// trailing separator left dangling by the cut. +func truncateForPostgresIdentifier(name string) string { + if len(name) <= postgresMaxIdentifierLength { + return name + } + return strings.TrimRight(name[:postgresMaxIdentifierLength], "-_") +} + const defaultWorkbenchReadinessProbePath = "/health-check" const ( @@ -179,6 +192,39 @@ func (r *WorkbenchReconciler) ReconcileWorkbench(ctx context.Context, req ctrl.R // FYI: Password is set via env var in the CreateSecretVolumeFactory } + // A separate role from dbName, since WORKBENCH_POSTGRES_PASSWORD is shared by + // database.conf and audit-database.conf and can't carry two different passwords. + if w.Spec.AuditDatabaseEnabled { + auditComponentName := truncateForPostgresIdentifier(fmt.Sprintf("%s-audit", w.ComponentName())) + auditSecretKey := "dev-audit-db-password" + if err := db.EnsureDatabaseExists(ctx, r, req, w, w.Spec.DatabaseConfig, auditComponentName, "", []string{}, w.Spec.Secret, w.Spec.WorkloadSecret, w.Spec.MainDatabaseCredentialSecret, auditSecretKey); err != nil { + l.Error(err, "error creating database", "database", auditComponentName) + if patchErr := status.PatchErrorStatus(ctx, r.Status(), w, patchBase, &w.Status.Conditions, w.Generation, err); patchErr != nil { + l.Error(patchErr, "Error patching error status") + } + return ctrl.Result{}, err + } + + auditDbName := invalidCharacters.ReplaceAllString(auditComponentName, "_") + auditPassword, err := product.FetchSecret(ctx, r, req, w.Spec.Secret.Type, w.Spec.Secret.VaultName, auditSecretKey) + if err != nil { + l.Error(err, "error fetching audit database password") + if patchErr := status.PatchErrorStatus(ctx, r.Status(), w, patchBase, &w.Status.Conditions, w.Generation, err); patchErr != nil { + l.Error(patchErr, "Error patching error status") + } + return ctrl.Result{}, err + } + + w.Spec.SecretConfig.AuditDatabase = &positcov1beta1.WorkbenchAuditDatabaseConfig{ + Provider: positcov1beta1.WorkbenchDatabaseProviderPostgres, + Database: auditDbName, + Port: justPort, + Host: justHost, + Username: auditDbName, + Password: auditPassword, + } + } + // fetch azure secret, if databricks is involved if err := r.FetchAndSetClientSecretForAzureDatabricks(ctx, req, w); err != nil { l.Error(err, "error fetching client secret for databricks azure. Not fatal") diff --git a/internal/crdapply/bases/core.posit.team_sites.yaml b/internal/crdapply/bases/core.posit.team_sites.yaml index 5296908..3659032 100644 --- a/internal/crdapply/bases/core.posit.team_sites.yaml +++ b/internal/crdapply/bases/core.posit.team_sites.yaml @@ -1884,6 +1884,11 @@ spec: description: ExperimentalFeatures allows enabling miscellaneous experimental features for workbench properties: + auditDatabaseEnabled: + description: |- + AuditDatabaseEnabled provisions Workbench's Audit Database: a second Postgres database, + distinct from the internal database, that stores historical session and usage data. + type: boolean chronicleSidecarProductApiKeyEnabled: description: |- ChronicleSidecarProductApiKeyEnabled assumes the api key for this product has been added to a secret and diff --git a/internal/crdapply/bases/core.posit.team_workbenches.yaml b/internal/crdapply/bases/core.posit.team_workbenches.yaml index 79839b1..dd49d61 100644 --- a/internal/crdapply/bases/core.posit.team_workbenches.yaml +++ b/internal/crdapply/bases/core.posit.team_workbenches.yaml @@ -106,6 +106,12 @@ spec: type: string type: object type: array + auditDatabaseEnabled: + description: |- + AuditDatabaseEnabled provisions a second Postgres database (distinct from the internal + database) and role, then renders audit-database.conf pointing Workbench at it. Requires + the Secret referenced by Secret.VaultName to already contain a "dev-audit-db-password" key. + type: boolean auth: properties: administratorRoleMapping: @@ -975,6 +981,25 @@ spec: properties: workbench-secret-ini-config: properties: + audit-database.conf: + description: |- + WorkbenchAuditDatabaseConfig renders /etc/rstudio/audit-database.conf. Password is + populated directly here rather than via WORKBENCH_POSTGRES_PASSWORD, since that env + var is shared with database.conf and can't carry two different roles' passwords. + properties: + database: + type: string + host: + type: string + password: + type: string + port: + type: string + provider: + type: string + username: + type: string + type: object database.conf: properties: database: