diff --git a/api/core/v1beta1/site_types.go b/api/core/v1beta1/site_types.go index 3e7a3b0..756bb59 100644 --- a/api/core/v1beta1/site_types.go +++ b/api/core/v1beta1/site_types.go @@ -224,6 +224,26 @@ type InternalPackageManagerSpec struct { } type InternalConnectSpec struct { + // Enabled controls whether Connect is deployed. Defaults to true if not specified. + // Set to false to explicitly disable Connect deployment. + // + // WARNING: Disabling Connect (setting Enabled=false) is a DESTRUCTIVE operation that + // permanently deletes all Connect resources including: + // - The Connect database and all its data + // - All secrets (database credentials, provisioning keys, etc.) + // - Persistent volumes and claims + // - All deployed Kubernetes resources (deployments, services, ingress, etc.) + // + // Re-enabling Connect after disabling it will start completely fresh with: + // - A new, empty database + // - New secrets and credentials + // - No content or configuration from the previous deployment + // + // This is intentional behavior, not a bug. Only disable Connect if you intend to + // permanently destroy the Connect instance and all its data. + // +optional + Enabled *bool `json:"enabled,omitempty"` + License product.LicenseSpec `json:"license,omitempty"` Volume *product.VolumeSpec `json:"volume,omitempty"` diff --git a/api/core/v1beta1/zz_generated.deepcopy.go b/api/core/v1beta1/zz_generated.deepcopy.go index a8d7c25..9bd852a 100644 --- a/api/core/v1beta1/zz_generated.deepcopy.go +++ b/api/core/v1beta1/zz_generated.deepcopy.go @@ -1146,6 +1146,11 @@ func (in *InternalConnectExperimentalFeatures) DeepCopy() *InternalConnectExperi // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *InternalConnectSpec) DeepCopyInto(out *InternalConnectSpec) { *out = *in + if in.Enabled != nil { + in, out := &in.Enabled, &out.Enabled + *out = new(bool) + **out = **in + } out.License = in.License if in.Volume != nil { in, out := &in.Volume, &out.Volume diff --git a/client-go/applyconfiguration/core/v1beta1/internalconnectspec.go b/client-go/applyconfiguration/core/v1beta1/internalconnectspec.go index 84e69b2..3104032 100644 --- a/client-go/applyconfiguration/core/v1beta1/internalconnectspec.go +++ b/client-go/applyconfiguration/core/v1beta1/internalconnectspec.go @@ -13,6 +13,7 @@ import ( // InternalConnectSpecApplyConfiguration represents a declarative configuration of the InternalConnectSpec type for use // with apply. type InternalConnectSpecApplyConfiguration struct { + Enabled *bool `json:"enabled,omitempty"` License *product.LicenseSpec `json:"license,omitempty"` Volume *product.VolumeSpec `json:"volume,omitempty"` NodeSelector map[string]string `json:"nodeSelector,omitempty"` @@ -41,6 +42,14 @@ func InternalConnectSpec() *InternalConnectSpecApplyConfiguration { return &InternalConnectSpecApplyConfiguration{} } +// WithEnabled sets the Enabled 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 Enabled field is set to the value of the last call. +func (b *InternalConnectSpecApplyConfiguration) WithEnabled(value bool) *InternalConnectSpecApplyConfiguration { + b.Enabled = &value + return b +} + // WithLicense sets the License 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 License 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 bb2ebf3..fab3edc 100644 --- a/config/crd/bases/core.posit.team_sites.yaml +++ b/config/crd/bases/core.posit.team_sites.yaml @@ -190,6 +190,26 @@ spec: domainPrefix: default: connect type: string + enabled: + description: |- + Enabled controls whether Connect is deployed. Defaults to true if not specified. + Set to false to explicitly disable Connect deployment. + + WARNING: Disabling Connect (setting Enabled=false) is a DESTRUCTIVE operation that + permanently deletes all Connect resources including: + - The Connect database and all its data + - All secrets (database credentials, provisioning keys, etc.) + - Persistent volumes and claims + - All deployed Kubernetes resources (deployments, services, ingress, etc.) + + Re-enabling Connect after disabling it will start completely fresh with: + - A new, empty database + - New secrets and credentials + - No content or configuration from the previous deployment + + This is intentional behavior, not a bug. Only disable Connect if you intend to + permanently destroy the Connect instance and all its data. + type: boolean experimentalFeatures: properties: chronicleSidecarProductApiKeyEnabled: diff --git a/dist/chart/templates/crd/core.posit.team_sites.yaml b/dist/chart/templates/crd/core.posit.team_sites.yaml index e002d1b..88481c3 100755 --- a/dist/chart/templates/crd/core.posit.team_sites.yaml +++ b/dist/chart/templates/crd/core.posit.team_sites.yaml @@ -211,6 +211,26 @@ spec: domainPrefix: default: connect type: string + enabled: + description: |- + Enabled controls whether Connect is deployed. Defaults to true if not specified. + Set to false to explicitly disable Connect deployment. + + WARNING: Disabling Connect (setting Enabled=false) is a DESTRUCTIVE operation that + permanently deletes all Connect resources including: + - The Connect database and all its data + - All secrets (database credentials, provisioning keys, etc.) + - Persistent volumes and claims + - All deployed Kubernetes resources (deployments, services, ingress, etc.) + + Re-enabling Connect after disabling it will start completely fresh with: + - A new, empty database + - New secrets and credentials + - No content or configuration from the previous deployment + + This is intentional behavior, not a bug. Only disable Connect if you intend to + permanently destroy the Connect instance and all its data. + type: boolean experimentalFeatures: properties: chronicleSidecarProductApiKeyEnabled: diff --git a/docs/api-reference.md b/docs/api-reference.md index 72547d7..fbcd45a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -743,6 +743,7 @@ These types are used within the Site CRD for product configuration. | Field | Type | Description | |-------|------|-------------| +| `.enabled` | `*bool` | Enable/disable Connect deployment (default: true). **WARNING:** Setting to `false` permanently deletes all Connect data including the database, secrets, and volumes. Cannot be reversed. See [Connect Configuration Guide](guides/connect-configuration.md#enablingdisabling-connect) for details. | | `.license` | `LicenseSpec` | License configuration | | `.volume` | `*VolumeSpec` | Data volume | | `.nodeSelector` | `map[string]string` | Node selector | diff --git a/docs/guides/connect-configuration.md b/docs/guides/connect-configuration.md index bbef819..47de644 100644 --- a/docs/guides/connect-configuration.md +++ b/docs/guides/connect-configuration.md @@ -6,6 +6,10 @@ This comprehensive guide covers all configuration options for Posit Connect when 1. [Overview](#overview) 2. [Basic Configuration](#basic-configuration) + - [Enabling/Disabling Connect](#enablingdisabling-connect) + - [Image Configuration](#image-configuration) + - [Resource Scaling](#resource-scaling) + - [Domain and Ingress](#domain-and-ingress) 3. [Authentication Configuration](#authentication-configuration) 4. [Database Configuration](#database-configuration) 5. [Off-Host Execution / Kubernetes Launcher](#off-host-execution--kubernetes-launcher) @@ -56,6 +60,61 @@ When using a Site resource, the Site controller generates and manages the Connec ## Basic Configuration +### Enabling/Disabling Connect + +By default, Connect is enabled when specified in a Site configuration. You can explicitly control whether Connect is deployed: + +```yaml +spec: + connect: + # Enable Connect deployment (default: true) + enabled: true +``` + +#### WARNING: Disabling Connect is Destructive + +**Setting `enabled: false` is a PERMANENT, DESTRUCTIVE operation that cannot be reversed without complete data loss.** + +When you disable Connect by setting `enabled: false`, the Team Operator: + +1. **Deletes the Connect Custom Resource (CR)** from Kubernetes +2. **Triggers finalizers** that destroy all Connect resources, including: + - The Connect PostgreSQL database and **all its data** (published content, users, settings, schedules, etc.) + - All Kubernetes secrets (database credentials, provisioning keys, OAuth secrets) + - Persistent volumes and all stored content + - All Kubernetes deployments, services, ingress routes, and jobs + +**This means:** + +- All published content, applications, reports, and APIs are **permanently deleted** +- All user accounts, permissions, and settings are **permanently deleted** +- All content schedules, email subscriptions, and integrations are **permanently deleted** +- **Re-enabling Connect later will start with a completely fresh, empty instance** with no data from the previous deployment + +**When to use `enabled: false`:** + +- During initial cluster setup when you don't want Connect deployed yet +- When permanently decommissioning Connect and migrating to a different instance +- When you explicitly want to destroy all Connect data and start fresh + +**Never use `enabled: false` for:** + +- Temporarily stopping Connect (use `replicas: 0` instead to scale down without data loss) +- Maintenance windows (scale down replicas instead) +- Troubleshooting (use debug mode or check logs instead) + +**Example of safely scaling down Connect temporarily:** + +```yaml +spec: + connect: + # Scale down Connect pods without deleting data + replicas: 0 + # Keep enabled: true (or omit it, as true is the default) +``` + +--- + ### Image Configuration ```yaml diff --git a/docs/guides/product-team-site-management.md b/docs/guides/product-team-site-management.md index 0c408f3..7494fbf 100644 --- a/docs/guides/product-team-site-management.md +++ b/docs/guides/product-team-site-management.md @@ -243,6 +243,11 @@ spec: ```yaml spec: connect: + # Enable/disable Connect deployment (default: true) + # WARNING: Setting enabled: false permanently deletes all Connect data! + # See the Connect Configuration Guide for details. + enabled: true + image: "ghcr.io/posit-dev/connect:ubuntu22-2024.10.0" imagePullPolicy: IfNotPresent replicas: 1 diff --git a/flightdeck/html/home.go b/flightdeck/html/home.go index fc8194c..c4296bc 100644 --- a/flightdeck/html/home.go +++ b/flightdeck/html/home.go @@ -41,7 +41,9 @@ func HomePage(site positcov1beta1.Site, config *internal.ServerConfig) Node { "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), + // Check Enabled field explicitly for Connect - when Enabled=false, Connect should not appear + // even if other fields like License or Image are set + If((site.Spec.Connect.Enabled == nil || *site.Spec.Connect.Enabled == true) && !internal.IsEmptyStruct(site.Spec.Connect), 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."), diff --git a/flightdeck/html/siteconfig.go b/flightdeck/html/siteconfig.go index fac5d9a..8fc9d15 100644 --- a/flightdeck/html/siteconfig.go +++ b/flightdeck/html/siteconfig.go @@ -21,7 +21,8 @@ func SiteConfigTable(site positcov1beta1.Site) Node { TBody( If(!internal.IsEmptyStruct(site.Spec.Workbench), SiteConfigTableRow("Workbench Image", site.Spec.Workbench.Image)), - If(!internal.IsEmptyStruct(site.Spec.Connect), + // Check Enabled field explicitly for Connect - when Enabled=false, Connect should not appear + If((site.Spec.Connect.Enabled == nil || *site.Spec.Connect.Enabled == true) && !internal.IsEmptyStruct(site.Spec.Connect), SiteConfigTableRow("Connect Image", site.Spec.Connect.Image)), If(!internal.IsEmptyStruct(site.Spec.PackageManager), SiteConfigTableRow("Package Manager Image", site.Spec.PackageManager.Image)), @@ -45,7 +46,8 @@ func SiteConfigBlock(site positcov1beta1.Site) Node { if !internal.IsEmptyStruct(site.Spec.Workbench) { productConfigs["Workbench"] = site.Spec.Workbench } - if !internal.IsEmptyStruct(site.Spec.Connect) { + // Check Enabled field explicitly for Connect - when Enabled=false, Connect config should not appear + if (site.Spec.Connect.Enabled == nil || *site.Spec.Connect.Enabled == true) && !internal.IsEmptyStruct(site.Spec.Connect) { productConfigs["Connect"] = site.Spec.Connect } if !internal.IsEmptyStruct(site.Spec.PackageManager) { diff --git a/internal/controller/core/connect.go b/internal/controller/core/connect.go index 992994f..3c3f293 100644 --- a/internal/controller/core/connect.go +++ b/internal/controller/core/connect.go @@ -840,6 +840,22 @@ func (r *ConnectReconciler) ensureDeployedService(ctx context.Context, req ctrl. return ctrl.Result{}, nil } +// CleanupConnect is the finalizer that runs when a Connect CRD is deleted. +// +// WARNING: This function performs DESTRUCTIVE cleanup operations that permanently destroy: +// - The Connect database via db.CleanupDatabase (drops the database if configured to do so) +// - All secrets: provisioning keys, database password secrets, etc. +// - All Kubernetes resources: deployments, services, ingress, PVCs, configmaps, etc. +// +// This finalizer is automatically triggered when: +// 1. The Site CR is deleted (complete teardown) +// 2. Connect is disabled via Site.Spec.Connect.Enabled=false (user disables the product) +// +// When a user sets Enabled=false, the site controller calls cleanupConnect() which deletes +// the Connect CRD, triggering this finalizer. This results in complete data loss. +// +// Re-enabling Connect after disabling it will start fresh with a new database, new secrets, +// and no previous content. This is intentional behavior to ensure clean resource teardown. func (r *ConnectReconciler) CleanupConnect(ctx context.Context, req ctrl.Request, c *positcov1beta1.Connect) (ctrl.Result, error) { if err := r.cleanupDeployedService(ctx, req, c); err != nil { return ctrl.Result{}, err diff --git a/internal/controller/core/site_controller.go b/internal/controller/core/site_controller.go index fdb59c4..c75cb50 100644 --- a/internal/controller/core/site_controller.go +++ b/internal/controller/core/site_controller.go @@ -177,8 +177,11 @@ func (r *SiteReconciler) reconcileResources(ctx context.Context, req ctrl.Reques return ctrl.Result{}, err } - if err := r.provisionFsxVolume(ctx, site, connectVolumeName, "connect", connectVolumeSize); err != nil { - return ctrl.Result{}, err + // Only provision Connect volume if Connect is enabled + if site.Spec.Connect.Enabled == nil || *site.Spec.Connect.Enabled == true { + if err := r.provisionFsxVolume(ctx, site, connectVolumeName, "connect", connectVolumeSize); err != nil { + return ctrl.Result{}, err + } } if err := r.provisionFsxVolume(ctx, site, devVolumeName, "workbench", connectVolumeSize); err != nil { @@ -209,10 +212,13 @@ func (r *SiteReconciler) reconcileResources(ctx context.Context, req ctrl.Reques return ctrl.Result{}, err } - connectStorageClassName = fmt.Sprintf("%s-nfs", connectVolumeName) + // Only provision Connect volume if Connect is enabled + if site.Spec.Connect.Enabled == nil || *site.Spec.Connect.Enabled == true { + connectStorageClassName = fmt.Sprintf("%s-nfs", connectVolumeName) - if err := r.provisionNfsVolume(ctx, site, connectVolumeName, "connect", connectStorageClassName, connectVolumeSize); err != nil { - return ctrl.Result{}, err + if err := r.provisionNfsVolume(ctx, site, connectVolumeName, "connect", connectStorageClassName, connectVolumeSize); err != nil { + return ctrl.Result{}, err + } } devStorageClassName = fmt.Sprintf("%s-nfs", devVolumeName) @@ -296,20 +302,33 @@ func (r *SiteReconciler) reconcileResources(ctx context.Context, req ctrl.Reques workbenchAdditionalVolumes = append(workbenchAdditionalVolumes, site.Spec.Workbench.AdditionalVolumes...) // CONNECT - if err := r.reconcileConnect( - ctx, - req, - site, - dbUrl.Host, - sslMode, - connectVolumeName, - connectStorageClassName, - additionalVolumes, - packageManagerRepoUrl, - connectUrl, - ); err != nil { - l.Error(err, "error reconciling connect") - return ctrl.Result{}, err + // When Enabled is nil or true, reconcile Connect (create/update resources) + // When Enabled is false, cleanup Connect (DESTRUCTIVE: deletes database, secrets, and all resources) + if site.Spec.Connect.Enabled == nil || *site.Spec.Connect.Enabled == true { + if err := r.reconcileConnect( + ctx, + req, + site, + dbUrl.Host, + sslMode, + connectVolumeName, + connectStorageClassName, + additionalVolumes, + packageManagerRepoUrl, + connectUrl, + ); err != nil { + l.Error(err, "error reconciling connect") + return ctrl.Result{}, err + } + } else { + // Connect is disabled - clean up any existing Connect resources + // WARNING: This triggers permanent deletion of the Connect CRD, which causes + // the Connect finalizer to destroy the database, secrets, and all resources. + // See cleanupConnect() and CleanupConnect() for details. + if err := r.cleanupConnect(ctx, req, l); err != nil { + l.Error(err, "error cleaning up connect resources") + return ctrl.Result{}, err + } } // PACKAGE MANAGER diff --git a/internal/controller/core/site_controller_connect.go b/internal/controller/core/site_controller_connect.go index 2462486..527fbb5 100644 --- a/internal/controller/core/site_controller_connect.go +++ b/internal/controller/core/site_controller_connect.go @@ -4,11 +4,13 @@ import ( "context" "fmt" + "github.com/go-logr/logr" "github.com/posit-dev/team-operator/api/core/v1beta1" "github.com/posit-dev/team-operator/api/product" "github.com/posit-dev/team-operator/internal" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" controllerruntime "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" ) func (r *SiteReconciler) reconcileConnect( @@ -249,3 +251,30 @@ func (r *SiteReconciler) reconcileConnect( } return nil } + +// cleanupConnect deletes the Connect CRD when Connect is disabled (Enabled=false). +// +// WARNING: This is a DESTRUCTIVE operation. Deleting the Connect CRD triggers the Connect +// finalizer (CleanupConnect in connect_controller.go) which permanently destroys: +// - The Connect database and all its data +// - All secrets (database credentials, provisioning keys, etc.) +// - Persistent volumes and claims +// - All deployed Kubernetes resources +// +// This means that disabling Connect via Site.Spec.Connect.Enabled=false is a one-way +// operation that results in complete data loss. Re-enabling Connect will start fresh +// with a new database and no previous content or configuration. +// +// This behavior is intentional to ensure clean teardown of Connect resources when +// a user explicitly disables the product. +func (r *SiteReconciler) cleanupConnect(ctx context.Context, req controllerruntime.Request, l logr.Logger) error { + l = l.WithValues("event", "cleanup-connect") + + // Delete Connect CRD if it exists + connectKey := client.ObjectKey{Name: req.Name, Namespace: req.Namespace} + if err := internal.BasicDelete(ctx, r, l, connectKey, &v1beta1.Connect{}); err != nil { + return err + } + + return nil +} diff --git a/internal/controller/core/site_controller_networkpolicies.go b/internal/controller/core/site_controller_networkpolicies.go index 455bd3f..36c2020 100644 --- a/internal/controller/core/site_controller_networkpolicies.go +++ b/internal/controller/core/site_controller_networkpolicies.go @@ -43,14 +43,22 @@ func (r *SiteReconciler) reconcileNetworkPolicies(ctx context.Context, req ctrl. return err } - if err := r.reconcileConnectNetworkPolicy(ctx, req.Namespace, l, site); err != nil { - l.Error(err, "error ensuring connect network policy") - return err - } - - if err := r.reconcileConnectSessionNetworkPolicy(ctx, req.Namespace, l, site); err != nil { - l.Error(err, "error ensuring connect session network policy") - return err + // Connect network policies + if site.Spec.Connect.Enabled == nil || *site.Spec.Connect.Enabled == true { + if err := r.reconcileConnectNetworkPolicy(ctx, req.Namespace, l, site); err != nil { + l.Error(err, "error ensuring connect network policy") + return err + } + if err := r.reconcileConnectSessionNetworkPolicy(ctx, req.Namespace, l, site); err != nil { + l.Error(err, "error ensuring connect session network policy") + return err + } + } else { + // Clean up Connect network policies + if err := r.cleanupConnectNetworkPolicies(ctx, req, l); err != nil { + l.Error(err, "error cleaning up connect network policies") + return err + } } if err := r.reconcileHomeNetworkPolicy(ctx, req.Namespace, l, site); err != nil { @@ -783,3 +791,13 @@ func (r *SiteReconciler) reconcileFlightdeckNetworkPolicy(ctx context.Context, n }) return err } + +func (r *SiteReconciler) cleanupConnectNetworkPolicies(ctx context.Context, req ctrl.Request, l logr.Logger) error { + for _, suffix := range []string{"connect", "connect-session"} { + key := client.ObjectKey{Name: req.Name + "-" + suffix, Namespace: req.Namespace} + if err := internal.BasicDelete(ctx, r, l, key, &networkingv1.NetworkPolicy{}); err != nil { + return err + } + } + return nil +} diff --git a/internal/controller/core/site_controller_workbench.go b/internal/controller/core/site_controller_workbench.go index 8b489ab..1b058d9 100644 --- a/internal/controller/core/site_controller_workbench.go +++ b/internal/controller/core/site_controller_workbench.go @@ -160,12 +160,19 @@ 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, - getEffectiveBaseDomain(site.Spec.Connect.BaseDomain, site.Spec.Domain), - v1beta1.SiteSubDomain, - ), + // Only set DefaultRSConnectServer if Connect is enabled + // When Connect is disabled (Enabled=false), leave this empty so Workbench + // doesn't have a config entry pointing to a non-existent service + DefaultRSConnectServer: func() string { + if site.Spec.Connect.Enabled != nil && *site.Spec.Connect.Enabled == false { + return "" + } + return "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...? diff --git a/internal/controller/core/site_test.go b/internal/controller/core/site_test.go index 4c88deb..4d2dc89 100644 --- a/internal/controller/core/site_test.go +++ b/internal/controller/core/site_test.go @@ -1150,3 +1150,19 @@ func TestSiteReconciler_BaseDomainWithCustomPrefix(t *testing.T) { testWorkbench := getWorkbench(t, cli, siteNamespace, siteName) assert.Equal(t, "https://rsc.custom-domain.com", testWorkbench.Spec.Config.RSession.DefaultRSConnectServer) } + +func TestSiteConnectSkippedWhenDisabled(t *testing.T) { + siteName := "disabled-connect" + siteNamespace := "posit-team" + site := defaultSite(siteName) + enabled := false + site.Spec.Connect.Enabled = &enabled + + cli, _, err := runFakeSiteReconciler(t, siteNamespace, siteName, site) + assert.NoError(t, err) + + // Connect CRD should NOT be created when explicitly disabled + connect := &v1beta1.Connect{} + err = cli.Get(context.TODO(), client.ObjectKey{Name: siteName, Namespace: siteNamespace}, connect) + assert.Error(t, err) // Should error because it doesn't exist +}