diff --git a/api/core/v1beta1/site_types.go b/api/core/v1beta1/site_types.go index da24cada..c48dd202 100644 --- a/api/core/v1beta1/site_types.go +++ b/api/core/v1beta1/site_types.go @@ -208,6 +208,11 @@ type InternalPackageManagerSpec struct { // +kubebuilder:default=packagemanager DomainPrefix string `json:"domainPrefix,omitempty"` + // BaseDomain overrides site.Spec.Domain for this product's URL construction. + // When set, the product URL will be: domainPrefix.baseDomain + // +optional + BaseDomain string `json:"baseDomain,omitempty"` + // GitSSHKeys defines SSH key configurations for Git authentication in Package Manager // These SSH keys will be made available to Package Manager for Git Builders // +optional @@ -248,6 +253,11 @@ type InternalConnectSpec struct { // +kubebuilder:default=connect DomainPrefix string `json:"domainPrefix,omitempty"` + // BaseDomain overrides site.Spec.Domain for this product's URL construction. + // When set, the product URL will be: domainPrefix.baseDomain + // +optional + BaseDomain string `json:"baseDomain,omitempty"` + // GPUSettings allows configuring GPU resource requests and limits GPUSettings *GPUSettings `json:"gpuSettings,omitempty"` @@ -366,6 +376,11 @@ type InternalWorkbenchSpec struct { // +kubebuilder:default=workbench DomainPrefix string `json:"domainPrefix,omitempty"` + // BaseDomain overrides site.Spec.Domain for this product's URL construction. + // When set, the product URL will be: domainPrefix.baseDomain + // +optional + BaseDomain string `json:"baseDomain,omitempty"` + // Workbench Auth/Login Landing Page Customization HTML AuthLoginPageHtml string `json:"authLoginPageHtml,omitempty"` diff --git a/client-go/applyconfiguration/core/v1beta1/internalconnectspec.go b/client-go/applyconfiguration/core/v1beta1/internalconnectspec.go index 81dfc18e..79b63ea0 100644 --- a/client-go/applyconfiguration/core/v1beta1/internalconnectspec.go +++ b/client-go/applyconfiguration/core/v1beta1/internalconnectspec.go @@ -27,6 +27,7 @@ type InternalConnectSpecApplyConfiguration struct { Replicas *int `json:"replicas,omitempty"` ExperimentalFeatures *InternalConnectExperimentalFeaturesApplyConfiguration `json:"experimentalFeatures,omitempty"` DomainPrefix *string `json:"domainPrefix,omitempty"` + BaseDomain *string `json:"baseDomain,omitempty"` GPUSettings *GPUSettingsApplyConfiguration `json:"gpuSettings,omitempty"` DatabaseSettings *DatabaseSettingsApplyConfiguration `json:"databaseSettings,omitempty"` ScheduleConcurrency *int `json:"scheduleConcurrency,omitempty"` @@ -162,6 +163,14 @@ func (b *InternalConnectSpecApplyConfiguration) WithDomainPrefix(value string) * return b } +// WithBaseDomain sets the BaseDomain 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 BaseDomain field is set to the value of the last call. +func (b *InternalConnectSpecApplyConfiguration) WithBaseDomain(value string) *InternalConnectSpecApplyConfiguration { + b.BaseDomain = &value + return b +} + // WithGPUSettings sets the GPUSettings 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 GPUSettings field is set to the value of the last call. diff --git a/client-go/applyconfiguration/core/v1beta1/internalpackagemanagerspec.go b/client-go/applyconfiguration/core/v1beta1/internalpackagemanagerspec.go index 1a0a1c37..32cdab2e 100644 --- a/client-go/applyconfiguration/core/v1beta1/internalpackagemanagerspec.go +++ b/client-go/applyconfiguration/core/v1beta1/internalpackagemanagerspec.go @@ -22,6 +22,7 @@ type InternalPackageManagerSpecApplyConfiguration struct { S3Bucket *string `json:"s3Bucket,omitempty"` Replicas *int `json:"replicas,omitempty"` DomainPrefix *string `json:"domainPrefix,omitempty"` + BaseDomain *string `json:"baseDomain,omitempty"` GitSSHKeys []SSHKeyConfigApplyConfiguration `json:"gitSSHKeys,omitempty"` AzureFiles *AzureFilesConfigApplyConfiguration `json:"azureFiles,omitempty"` } @@ -116,6 +117,14 @@ func (b *InternalPackageManagerSpecApplyConfiguration) WithDomainPrefix(value st return b } +// WithBaseDomain sets the BaseDomain 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 BaseDomain field is set to the value of the last call. +func (b *InternalPackageManagerSpecApplyConfiguration) WithBaseDomain(value string) *InternalPackageManagerSpecApplyConfiguration { + b.BaseDomain = &value + return b +} + // WithGitSSHKeys adds the given value to the GitSSHKeys field in the declarative configuration // and returns the receiver, so that objects can be build by chaining "With" function invocations. // If called multiple times, values provided by each call will be appended to the GitSSHKeys field. diff --git a/client-go/applyconfiguration/core/v1beta1/internalworkbenchspec.go b/client-go/applyconfiguration/core/v1beta1/internalworkbenchspec.go index 23ee6ebc..f6b305d8 100644 --- a/client-go/applyconfiguration/core/v1beta1/internalworkbenchspec.go +++ b/client-go/applyconfiguration/core/v1beta1/internalworkbenchspec.go @@ -41,6 +41,7 @@ type InternalWorkbenchSpecApplyConfiguration struct { VSCodeSettings *VSCodeConfigApplyConfiguration `json:"vsCodeConfig,omitempty"` ApiSettings *ApiSettingsConfigApplyConfiguration `json:"apiSettings,omitempty"` DomainPrefix *string `json:"domainPrefix,omitempty"` + BaseDomain *string `json:"baseDomain,omitempty"` AuthLoginPageHtml *string `json:"authLoginPageHtml,omitempty"` JupyterConfig *WorkbenchJupyterConfigApplyConfiguration `json:"jupyterConfig,omitempty"` } @@ -305,6 +306,14 @@ func (b *InternalWorkbenchSpecApplyConfiguration) WithDomainPrefix(value string) return b } +// WithBaseDomain sets the BaseDomain 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 BaseDomain field is set to the value of the last call. +func (b *InternalWorkbenchSpecApplyConfiguration) WithBaseDomain(value string) *InternalWorkbenchSpecApplyConfiguration { + b.BaseDomain = &value + return b +} + // WithAuthLoginPageHtml sets the AuthLoginPageHtml 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 AuthLoginPageHtml field is set to the value of the last call. diff --git a/config/crd/bases/core.posit.team_sites.yaml b/config/crd/bases/core.posit.team_sites.yaml index 0524fcb6..d4ab8ffc 100644 --- a/config/crd/bases/core.posit.team_sites.yaml +++ b/config/crd/bases/core.posit.team_sites.yaml @@ -127,6 +127,11 @@ spec: type: string type: array type: object + baseDomain: + description: |- + BaseDomain overrides site.Spec.Domain for this product's URL construction. + When set, the product URL will be: domainPrefix.baseDomain + type: string databaseSettings: properties: instrumentationSchema: @@ -576,6 +581,11 @@ spec: StorageClass that uses the Azure Files CSI driver type: string type: object + baseDomain: + description: |- + BaseDomain overrides site.Spec.Domain for this product's URL construction. + When set, the product URL will be: domainPrefix.baseDomain + type: string domainPrefix: default: packagemanager type: string @@ -906,6 +916,11 @@ spec: authLoginPageHtml: description: Workbench Auth/Login Landing Page Customization HTML type: string + baseDomain: + description: |- + BaseDomain overrides site.Spec.Domain for this product's URL construction. + When set, the product URL will be: domainPrefix.baseDomain + type: string createUsersAutomatically: type: boolean databricks: diff --git a/dist/chart/templates/crd/core.posit.team_sites.yaml b/dist/chart/templates/crd/core.posit.team_sites.yaml index 31f8dbd9..b406133c 100755 --- a/dist/chart/templates/crd/core.posit.team_sites.yaml +++ b/dist/chart/templates/crd/core.posit.team_sites.yaml @@ -148,6 +148,11 @@ spec: type: string type: array type: object + baseDomain: + description: |- + BaseDomain overrides site.Spec.Domain for this product's URL construction. + When set, the product URL will be: domainPrefix.baseDomain + type: string databaseSettings: properties: instrumentationSchema: @@ -597,6 +602,11 @@ spec: StorageClass that uses the Azure Files CSI driver type: string type: object + baseDomain: + description: |- + BaseDomain overrides site.Spec.Domain for this product's URL construction. + When set, the product URL will be: domainPrefix.baseDomain + type: string domainPrefix: default: packagemanager type: string @@ -927,6 +937,11 @@ spec: authLoginPageHtml: description: Workbench Auth/Login Landing Page Customization HTML type: string + baseDomain: + description: |- + BaseDomain overrides site.Spec.Domain for this product's URL construction. + When set, the product URL will be: domainPrefix.baseDomain + type: string createUsersAutomatically: type: boolean databricks: diff --git a/dist/chart/templates/rbac/auth_proxy_service.yaml b/dist/chart/templates/rbac/auth_proxy_service.yaml new file mode 100755 index 00000000..b665f0dc --- /dev/null +++ b/dist/chart/templates/rbac/auth_proxy_service.yaml @@ -0,0 +1,17 @@ +{{- if .Values.rbac.enable }} +apiVersion: v1 +kind: Service +metadata: + labels: + {{- include "chart.labels" . | nindent 4 }} + name: {{ .Values.controllerManager.serviceAccountName }}-metrics-service + namespace: {{ .Release.Namespace }} +spec: + ports: + - name: https + port: 8443 + protocol: TCP + targetPort: https + selector: + control-plane: controller-manager +{{- end -}} diff --git a/flightdeck/html/home.go b/flightdeck/html/home.go index eda30647..fc8194c4 100644 --- a/flightdeck/html/home.go +++ b/flightdeck/html/home.go @@ -9,8 +9,17 @@ import ( . "maragu.dev/gomponents/html" ) +func getEffectiveBaseDomain(baseDomain, fallbackDomain string) string { + if baseDomain != "" { + return baseDomain + } + return fallbackDomain +} + func HomePage(site positcov1beta1.Site, config *internal.ServerConfig) Node { - baseUrl := site.Spec.Domain + workbenchBaseUrl := getEffectiveBaseDomain(site.Spec.Workbench.BaseDomain, site.Spec.Domain) + connectBaseUrl := getEffectiveBaseDomain(site.Spec.Connect.BaseDomain, site.Spec.Domain) + packageManagerBaseUrl := getEffectiveBaseDomain(site.Spec.PackageManager.BaseDomain, site.Spec.Domain) return page("Home", config, Main( Class("max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8"), @@ -28,17 +37,17 @@ func HomePage(site positcov1beta1.Site, config *internal.ServerConfig) Node { Div( Class("grid grid-cols-1 md:grid-cols-3 gap-6 max-w-6xl mx-auto"), If(!internal.IsEmptyStruct(site.Spec.Workbench), - productCard("/static/logo-workbench.svg", "Posit Workbench", site.Spec.Workbench.DomainPrefix, baseUrl, + productCard("/static/logo-workbench.svg", "Posit Workbench", site.Spec.Workbench.DomainPrefix, workbenchBaseUrl, "Manage your environments with integrated tools like JupyterLab, RStudio, VS Code and Positron. "+ "Self-service workspaces provide a secure solution for both on-premises and cloud deployments"), ), If(!internal.IsEmptyStruct(site.Spec.Connect), - productCard("/static/logo-connect.svg", "Posit Connect", site.Spec.Connect.DomainPrefix, baseUrl, + productCard("/static/logo-connect.svg", "Posit Connect", site.Spec.Connect.DomainPrefix, connectBaseUrl, "Share your interactive applications, dashboards, and reports built with R and Python. "+ "Manage access, and deliver real-time insights to your stakeholders."), ), If(!internal.IsEmptyStruct(site.Spec.PackageManager), - productCard("/static/logo-packagemanager.svg", "Posit Package Manager", site.Spec.PackageManager.DomainPrefix, baseUrl, + productCard("/static/logo-packagemanager.svg", "Posit Package Manager", site.Spec.PackageManager.DomainPrefix, packageManagerBaseUrl, "Securely manage your R and Python packages from public and internal sources, ensuring consistent versions for reproducibility. "+ "Strengthen your security with vulnerability reporting and air-gapped deployments."), ), diff --git a/internal/controller/core/site_controller.go b/internal/controller/core/site_controller.go index 1c8ea043..fdb59c44 100644 --- a/internal/controller/core/site_controller.go +++ b/internal/controller/core/site_controller.go @@ -89,6 +89,13 @@ func prefixDomain(prefix, domain string, domainType positcov1beta1.SiteDomainTyp return fmt.Sprintf("%s.%s", prefix, domain) } +func getEffectiveBaseDomain(baseDomain, fallbackDomain string) string { + if baseDomain != "" { + return baseDomain + } + return fallbackDomain +} + func (r *SiteReconciler) reconcileResources(ctx context.Context, req ctrl.Request, site *positcov1beta1.Site) (ctrl.Result, error) { l := r.GetLogger(ctx).WithValues( @@ -125,9 +132,21 @@ func (r *SiteReconciler) reconcileResources(ctx context.Context, req ctrl.Reques // Default to subdomain type since SiteHome is removed domainType := positcov1beta1.SiteSubDomain - packageManagerUrl := prefixDomain(site.Spec.PackageManager.DomainPrefix, site.Spec.Domain, domainType) - connectUrl := prefixDomain(site.Spec.Connect.DomainPrefix, site.Spec.Domain, domainType) - workbenchUrl := prefixDomain(site.Spec.Workbench.DomainPrefix, site.Spec.Domain, domainType) + packageManagerUrl := prefixDomain( + site.Spec.PackageManager.DomainPrefix, + getEffectiveBaseDomain(site.Spec.PackageManager.BaseDomain, site.Spec.Domain), + domainType, + ) + connectUrl := prefixDomain( + site.Spec.Connect.DomainPrefix, + getEffectiveBaseDomain(site.Spec.Connect.BaseDomain, site.Spec.Domain), + domainType, + ) + workbenchUrl := prefixDomain( + site.Spec.Workbench.DomainPrefix, + getEffectiveBaseDomain(site.Spec.Workbench.BaseDomain, site.Spec.Domain), + domainType, + ) packageManagerRepoUrl := fmt.Sprintf("https://%s/cran/__linux__/jammy/latest", packageManagerUrl) // TODO: don't hardcode OS if site.Spec.PackageManagerUrl != "" { diff --git a/internal/controller/core/site_controller_workbench.go b/internal/controller/core/site_controller_workbench.go index b844aa61..e5ad5b3f 100644 --- a/internal/controller/core/site_controller_workbench.go +++ b/internal/controller/core/site_controller_workbench.go @@ -161,8 +161,12 @@ func (r *SiteReconciler) reconcileWorkbench( WorkbenchSessionIniConfig: v1beta1.WorkbenchSessionIniConfig{ RSession: &v1beta1.WorkbenchRSessionConfig{ // TODO: need TLS to be configurable... for plaintext sites... - DefaultRSConnectServer: "https://" + prefixDomain(site.Spec.Connect.DomainPrefix, site.Spec.Domain, v1beta1.SiteSubDomain), - CopilotEnabled: 1, + DefaultRSConnectServer: "https://" + prefixDomain( + site.Spec.Connect.DomainPrefix, + getEffectiveBaseDomain(site.Spec.Connect.BaseDomain, site.Spec.Domain), + v1beta1.SiteSubDomain, + ), + CopilotEnabled: 1, }, // TODO: configure the expected package manager repositories...? Repos: &v1beta1.WorkbenchRepoConfig{ diff --git a/internal/controller/core/site_test.go b/internal/controller/core/site_test.go index 211e5f54..4db8baf5 100644 --- a/internal/controller/core/site_test.go +++ b/internal/controller/core/site_test.go @@ -939,3 +939,105 @@ func TestSiteReconciler_WorkbenchSessionImagePullPolicyNever(t *testing.T) { testWorkbench := getWorkbench(t, cli, siteNamespace, siteName) assert.Equal(t, corev1.PullNever, testWorkbench.Spec.SessionConfig.Pod.ImagePullPolicy) } + +func TestSiteReconciler_BaseDomainNotSet(t *testing.T) { + siteName := "base-domain-not-set" + siteNamespace := "posit-team" + site := defaultSite(siteName) + site.Spec.Domain = "example.com" + site.Spec.Connect.DomainPrefix = "connect" + site.Spec.Workbench.DomainPrefix = "workbench" + site.Spec.PackageManager.DomainPrefix = "packagemanager" + + cli, _, err := runFakeSiteReconciler(t, siteNamespace, siteName, site) + assert.Nil(t, err) + + // Verify default behavior is preserved when BaseDomain is not set + testConnect := getConnect(t, cli, siteNamespace, siteName) + assert.Equal(t, "connect.example.com", testConnect.Spec.Url) + + testWorkbench := getWorkbench(t, cli, siteNamespace, siteName) + assert.Equal(t, "workbench.example.com", testWorkbench.Spec.Url) + // Verify DefaultRSConnectServer uses site domain when BaseDomain not set + assert.Equal(t, "https://connect.example.com", testWorkbench.Spec.Config.RSession.DefaultRSConnectServer) + + testPackageManager := getPackageManager(t, cli, siteNamespace, siteName) + assert.Equal(t, "packagemanager.example.com", testPackageManager.Spec.Url) +} + +func TestSiteReconciler_BaseDomainConnectOnly(t *testing.T) { + siteName := "base-domain-connect-only" + siteNamespace := "posit-team" + site := defaultSite(siteName) + site.Spec.Domain = "example.com" + site.Spec.Connect.DomainPrefix = "connect" + site.Spec.Connect.BaseDomain = "connect-custom.com" + site.Spec.Workbench.DomainPrefix = "workbench" + site.Spec.PackageManager.DomainPrefix = "packagemanager" + + cli, _, err := runFakeSiteReconciler(t, siteNamespace, siteName, site) + assert.Nil(t, err) + + // Verify Connect uses custom BaseDomain + testConnect := getConnect(t, cli, siteNamespace, siteName) + assert.Equal(t, "connect.connect-custom.com", testConnect.Spec.Url) + + // Verify Workbench uses site domain (not custom) + testWorkbench := getWorkbench(t, cli, siteNamespace, siteName) + assert.Equal(t, "workbench.example.com", testWorkbench.Spec.Url) + // Verify DefaultRSConnectServer uses Connect's BaseDomain + assert.Equal(t, "https://connect.connect-custom.com", testWorkbench.Spec.Config.RSession.DefaultRSConnectServer) + + // Verify PackageManager uses site domain (not custom) + testPackageManager := getPackageManager(t, cli, siteNamespace, siteName) + assert.Equal(t, "packagemanager.example.com", testPackageManager.Spec.Url) +} + +func TestSiteReconciler_BaseDomainAllProducts(t *testing.T) { + siteName := "base-domain-all-products" + siteNamespace := "posit-team" + site := defaultSite(siteName) + site.Spec.Domain = "example.com" + site.Spec.Connect.DomainPrefix = "connect" + site.Spec.Connect.BaseDomain = "connect-domain.com" + site.Spec.Workbench.DomainPrefix = "workbench" + site.Spec.Workbench.BaseDomain = "workbench-domain.com" + site.Spec.PackageManager.DomainPrefix = "packagemanager" + site.Spec.PackageManager.BaseDomain = "pm-domain.com" + + cli, _, err := runFakeSiteReconciler(t, siteNamespace, siteName, site) + assert.Nil(t, err) + + // Verify all products use their custom BaseDomains + testConnect := getConnect(t, cli, siteNamespace, siteName) + assert.Equal(t, "connect.connect-domain.com", testConnect.Spec.Url) + + testWorkbench := getWorkbench(t, cli, siteNamespace, siteName) + assert.Equal(t, "workbench.workbench-domain.com", testWorkbench.Spec.Url) + // Verify DefaultRSConnectServer uses Connect's BaseDomain + assert.Equal(t, "https://connect.connect-domain.com", testWorkbench.Spec.Config.RSession.DefaultRSConnectServer) + + testPackageManager := getPackageManager(t, cli, siteNamespace, siteName) + assert.Equal(t, "packagemanager.pm-domain.com", testPackageManager.Spec.Url) +} + +func TestSiteReconciler_BaseDomainWithCustomPrefix(t *testing.T) { + siteName := "base-domain-custom-prefix" + siteNamespace := "posit-team" + site := defaultSite(siteName) + site.Spec.Domain = "example.com" + site.Spec.Connect.DomainPrefix = "rsc" + site.Spec.Connect.BaseDomain = "custom-domain.com" + site.Spec.Workbench.DomainPrefix = "workbench" + + cli, _, err := runFakeSiteReconciler(t, siteNamespace, siteName, site) + assert.Nil(t, err) + + // Verify custom prefix is preserved with BaseDomain + testConnect := getConnect(t, cli, siteNamespace, siteName) + assert.Equal(t, "rsc.custom-domain.com", testConnect.Spec.Url) + + // Verify Workbench's DefaultRSConnectServer uses custom prefix and BaseDomain + testWorkbench := getWorkbench(t, cli, siteNamespace, siteName) + assert.Equal(t, "https://rsc.custom-domain.com", testWorkbench.Spec.Config.RSession.DefaultRSConnectServer) +}