From 93285359f6b51e02f6ae0041c3cf1448e6776dde Mon Sep 17 00:00:00 2001 From: Steve Nolen Date: Tue, 10 Feb 2026 13:47:51 -0500 Subject: [PATCH 01/14] allow completely disabling connect --- api/core/v1beta1/site_types.go | 5 ++ api/core/v1beta1/zz_generated.deepcopy.go | 5 ++ .../core/v1beta1/connectruntimeimagespec.go | 62 +++++++++++++++++++ .../core/v1beta1/internalconnectspec.go | 9 +++ config/crd/bases/core.posit.team_sites.yaml | 5 ++ .../core/site_controller_connect.go | 6 ++ internal/controller/core/site_test.go | 16 +++++ 7 files changed, 108 insertions(+) create mode 100644 client-go/applyconfiguration/core/v1beta1/connectruntimeimagespec.go diff --git a/api/core/v1beta1/site_types.go b/api/core/v1beta1/site_types.go index c14e1cd..f3c1697 100644 --- a/api/core/v1beta1/site_types.go +++ b/api/core/v1beta1/site_types.go @@ -224,6 +224,11 @@ 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. + // +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 b9831e4..23e7741 100644 --- a/api/core/v1beta1/zz_generated.deepcopy.go +++ b/api/core/v1beta1/zz_generated.deepcopy.go @@ -1091,6 +1091,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/connectruntimeimagespec.go b/client-go/applyconfiguration/core/v1beta1/connectruntimeimagespec.go new file mode 100644 index 0000000..29cf31e --- /dev/null +++ b/client-go/applyconfiguration/core/v1beta1/connectruntimeimagespec.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2023-2026 Posit Software, PBC + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1beta1 + +// ConnectRuntimeImageSpecApplyConfiguration represents a declarative configuration of the ConnectRuntimeImageSpec type for use +// with apply. +type ConnectRuntimeImageSpecApplyConfiguration struct { + RVersion *string `json:"rVersion,omitempty"` + PyVersion *string `json:"pyVersion,omitempty"` + OSVersion *string `json:"osVersion,omitempty"` + QuartoVersion *string `json:"quartoVersion,omitempty"` + Repo *string `json:"repo,omitempty"` +} + +// ConnectRuntimeImageSpecApplyConfiguration constructs a declarative configuration of the ConnectRuntimeImageSpec type for use with +// apply. +func ConnectRuntimeImageSpec() *ConnectRuntimeImageSpecApplyConfiguration { + return &ConnectRuntimeImageSpecApplyConfiguration{} +} + +// WithRVersion sets the RVersion 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 RVersion field is set to the value of the last call. +func (b *ConnectRuntimeImageSpecApplyConfiguration) WithRVersion(value string) *ConnectRuntimeImageSpecApplyConfiguration { + b.RVersion = &value + return b +} + +// WithPyVersion sets the PyVersion 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 PyVersion field is set to the value of the last call. +func (b *ConnectRuntimeImageSpecApplyConfiguration) WithPyVersion(value string) *ConnectRuntimeImageSpecApplyConfiguration { + b.PyVersion = &value + return b +} + +// WithOSVersion sets the OSVersion 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 OSVersion field is set to the value of the last call. +func (b *ConnectRuntimeImageSpecApplyConfiguration) WithOSVersion(value string) *ConnectRuntimeImageSpecApplyConfiguration { + b.OSVersion = &value + return b +} + +// WithQuartoVersion sets the QuartoVersion 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 QuartoVersion field is set to the value of the last call. +func (b *ConnectRuntimeImageSpecApplyConfiguration) WithQuartoVersion(value string) *ConnectRuntimeImageSpecApplyConfiguration { + b.QuartoVersion = &value + return b +} + +// WithRepo sets the Repo 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 Repo field is set to the value of the last call. +func (b *ConnectRuntimeImageSpecApplyConfiguration) WithRepo(value string) *ConnectRuntimeImageSpecApplyConfiguration { + b.Repo = &value + return b +} diff --git a/client-go/applyconfiguration/core/v1beta1/internalconnectspec.go b/client-go/applyconfiguration/core/v1beta1/internalconnectspec.go index 3d88320..bd7f308 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"` @@ -40,6 +41,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 701b84c..e820e7a 100644 --- a/config/crd/bases/core.posit.team_sites.yaml +++ b/config/crd/bases/core.posit.team_sites.yaml @@ -190,6 +190,11 @@ 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. + type: boolean experimentalFeatures: properties: chronicleSidecarProductApiKeyEnabled: diff --git a/internal/controller/core/site_controller_connect.go b/internal/controller/core/site_controller_connect.go index 92b2978..f4618b1 100644 --- a/internal/controller/core/site_controller_connect.go +++ b/internal/controller/core/site_controller_connect.go @@ -28,6 +28,12 @@ func (r *SiteReconciler) reconcileConnect( "event", "reconcile-connect", ) + // Skip Connect reconciliation if explicitly disabled + if site.Spec.Connect.Enabled != nil && !*site.Spec.Connect.Enabled { + l.V(1).Info("skipping Connect reconciliation: explicitly disabled via Site.Spec.Connect.Enabled=false") + return nil + } + connectDebugLog := false if site.Spec.Debug { connectDebugLog = true diff --git a/internal/controller/core/site_test.go b/internal/controller/core/site_test.go index 4db8baf..fb3b15c 100644 --- a/internal/controller/core/site_test.go +++ b/internal/controller/core/site_test.go @@ -1041,3 +1041,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 +} From f8845797f23dbd2c8b66dd9154cecf6263d7425d Mon Sep 17 00:00:00 2001 From: Steve Nolen Date: Tue, 10 Feb 2026 14:08:58 -0500 Subject: [PATCH 02/14] run make helm-generate --- dist/chart/templates/crd/core.posit.team_sites.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dist/chart/templates/crd/core.posit.team_sites.yaml b/dist/chart/templates/crd/core.posit.team_sites.yaml index d40af79..5d869f6 100755 --- a/dist/chart/templates/crd/core.posit.team_sites.yaml +++ b/dist/chart/templates/crd/core.posit.team_sites.yaml @@ -211,6 +211,11 @@ 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. + type: boolean experimentalFeatures: properties: chronicleSidecarProductApiKeyEnabled: From 7afa773b6995eca571fc959da9852ebafec5fc04 Mon Sep 17 00:00:00 2001 From: Steve Nolen Date: Tue, 10 Feb 2026 17:21:14 -0500 Subject: [PATCH 03/14] refactor to clean up and handle network policies --- internal/controller/core/site_controller.go | 36 +++++++++++-------- .../core/site_controller_connect.go | 20 +++++++---- .../core/site_controller_networkpolicies.go | 34 +++++++++++++----- 3 files changed, 62 insertions(+), 28 deletions(-) diff --git a/internal/controller/core/site_controller.go b/internal/controller/core/site_controller.go index fdb59c4..4694045 100644 --- a/internal/controller/core/site_controller.go +++ b/internal/controller/core/site_controller.go @@ -296,20 +296,28 @@ 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 + 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 + 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 f4618b1..dd96225 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( @@ -28,12 +30,6 @@ func (r *SiteReconciler) reconcileConnect( "event", "reconcile-connect", ) - // Skip Connect reconciliation if explicitly disabled - if site.Spec.Connect.Enabled != nil && !*site.Spec.Connect.Enabled { - l.V(1).Info("skipping Connect reconciliation: explicitly disabled via Site.Spec.Connect.Enabled=false") - return nil - } - connectDebugLog := false if site.Spec.Debug { connectDebugLog = true @@ -255,3 +251,15 @@ func (r *SiteReconciler) reconcileConnect( } return nil } + +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 +} From c2d266bd69dc2d278aa2e2ded6610d8421be835d Mon Sep 17 00:00:00 2001 From: Steve Nolen Date: Wed, 11 Feb 2026 09:58:53 -0500 Subject: [PATCH 04/14] address more pr feedback --- api/core/v1beta1/site_types.go | 15 +++++ docs/api-reference.md | 1 + docs/guides/connect-configuration.md | 59 +++++++++++++++++++ docs/guides/product-team-site-management.md | 5 ++ flightdeck/html/home.go | 4 +- flightdeck/html/siteconfig.go | 6 +- internal/controller/core/connect.go | 16 +++++ internal/controller/core/site_controller.go | 21 +++++-- .../core/site_controller_connect.go | 15 +++++ .../core/site_controller_workbench.go | 19 ++++-- 10 files changed, 147 insertions(+), 14 deletions(-) diff --git a/api/core/v1beta1/site_types.go b/api/core/v1beta1/site_types.go index f3c1697..8a6e270 100644 --- a/api/core/v1beta1/site_types.go +++ b/api/core/v1beta1/site_types.go @@ -226,6 +226,21 @@ 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"` 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 366a472..dddf190 100644 --- a/internal/controller/core/connect.go +++ b/internal/controller/core/connect.go @@ -831,6 +831,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 4694045..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,6 +302,8 @@ func (r *SiteReconciler) reconcileResources(ctx context.Context, req ctrl.Reques workbenchAdditionalVolumes = append(workbenchAdditionalVolumes, site.Spec.Workbench.AdditionalVolumes...) // CONNECT + // 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, @@ -314,6 +322,9 @@ func (r *SiteReconciler) reconcileResources(ctx context.Context, req ctrl.Reques } } 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 diff --git a/internal/controller/core/site_controller_connect.go b/internal/controller/core/site_controller_connect.go index dd96225..31e543f 100644 --- a/internal/controller/core/site_controller_connect.go +++ b/internal/controller/core/site_controller_connect.go @@ -252,6 +252,21 @@ 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") diff --git a/internal/controller/core/site_controller_workbench.go b/internal/controller/core/site_controller_workbench.go index e5ad5b3..009e238 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...? From 3eef1425513b2c071c48a8642d57ba598d698be2 Mon Sep 17 00:00:00 2001 From: Steve Nolen Date: Wed, 11 Feb 2026 10:16:53 -0500 Subject: [PATCH 05/14] forgot helm-generate! --- config/crd/bases/core.posit.team_sites.yaml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/config/crd/bases/core.posit.team_sites.yaml b/config/crd/bases/core.posit.team_sites.yaml index e820e7a..1775b6d 100644 --- a/config/crd/bases/core.posit.team_sites.yaml +++ b/config/crd/bases/core.posit.team_sites.yaml @@ -194,6 +194,21 @@ spec: 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: From 69de6e1fb44815958948af86241c04a34278a3ae Mon Sep 17 00:00:00 2001 From: Steve Nolen Date: Wed, 11 Feb 2026 10:26:04 -0500 Subject: [PATCH 06/14] forgot helm-generate...and apparently committed before it was finished. --- .../templates/crd/core.posit.team_sites.yaml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/dist/chart/templates/crd/core.posit.team_sites.yaml b/dist/chart/templates/crd/core.posit.team_sites.yaml index 5d869f6..9d4bfe7 100755 --- a/dist/chart/templates/crd/core.posit.team_sites.yaml +++ b/dist/chart/templates/crd/core.posit.team_sites.yaml @@ -215,6 +215,21 @@ spec: 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: From 340fb97785c9310d1632af33c079aca28f3dc59e Mon Sep 17 00:00:00 2001 From: ian-flores Date: Thu, 19 Feb 2026 08:55:18 -0800 Subject: [PATCH 07/14] feat: split Connect disable into non-destructive suspend and explicit teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single enabled=false behavior (which was fully destructive) with two separate fields on InternalConnectSpec: - enabled: false — non-destructive suspend: removes Deployment/Service/Ingress while preserving PVC, database, and secrets; re-enabling restores service without data loss - teardown: true — explicit destructive teardown (only meaningful when enabled=false): deletes the Connect CR, triggering the finalizer that destroys all resources including the database Adds a Suspended field to ConnectSpec (internal, set by the Site controller) that signals the Connect reconciler to remove serving resources without touching data. The disable path is a no-op when Connect was never previously enabled. --- api/core/v1beta1/connect_types.go | 5 ++ api/core/v1beta1/site_types.go | 28 ++++---- api/core/v1beta1/zz_generated.deepcopy.go | 10 +++ .../core/v1beta1/connectspec.go | 9 +++ .../core/v1beta1/internalconnectspec.go | 9 +++ .../crd/bases/core.posit.team_connects.yaml | 5 ++ config/crd/bases/core.posit.team_sites.yaml | 28 ++++---- internal/controller/core/connect.go | 33 +++++++++- internal/controller/core/site_controller.go | 29 ++++++--- .../core/site_controller_connect.go | 46 +++++++++++-- .../core/site_controller_networkpolicies.go | 5 +- internal/controller/core/site_test.go | 64 +++++++++++++++++-- 12 files changed, 211 insertions(+), 60 deletions(-) diff --git a/api/core/v1beta1/connect_types.go b/api/core/v1beta1/connect_types.go index c6b4acf..d6e0264 100644 --- a/api/core/v1beta1/connect_types.go +++ b/api/core/v1beta1/connect_types.go @@ -101,6 +101,11 @@ type ConnectSpec struct { // but can also be useful on occasion Sleep bool `json:"sleep,omitempty"` + // Suspended indicates Connect should not run serving resources (Deployment, Service, Ingress) + // but should preserve data resources (PVC, database, secrets). Set by the Site controller. + // +optional + Suspended *bool `json:"suspended,omitempty"` + SessionImage string `json:"sessionImage,omitempty"` // AwsAccountId is the account Id for this AWS Account. It is used to create EKS-to-IAM annotations diff --git a/api/core/v1beta1/site_types.go b/api/core/v1beta1/site_types.go index 756bb59..fe5d696 100644 --- a/api/core/v1beta1/site_types.go +++ b/api/core/v1beta1/site_types.go @@ -224,26 +224,20 @@ 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. + // Enabled controls whether Connect is running. Defaults to true. + // Setting to false suspends Connect: stops pods and removes ingress/service, + // but preserves PVC, database, and secrets so data is retained. + // Re-enabling restores full service without data loss. // +optional Enabled *bool `json:"enabled,omitempty"` + // Teardown permanently destroys all Connect resources including the database, + // secrets, and persistent volume claim. Only takes effect when Enabled is false. + // Re-enabling after teardown starts fresh with a new empty database. + // Defaults to false. + // +optional + Teardown *bool `json:"teardown,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 9bd852a..0df22e5 100644 --- a/api/core/v1beta1/zz_generated.deepcopy.go +++ b/api/core/v1beta1/zz_generated.deepcopy.go @@ -872,6 +872,11 @@ func (in *ConnectSpec) DeepCopyInto(out *ConnectSpec) { *out = make([]ConnectRuntimeImageSpec, len(*in)) copy(*out, *in) } + if in.Suspended != nil { + in, out := &in.Suspended, &out.Suspended + *out = new(bool) + **out = **in + } if in.AdditionalVolumes != nil { in, out := &in.AdditionalVolumes, &out.AdditionalVolumes *out = make([]product.VolumeSpec, len(*in)) @@ -1151,6 +1156,11 @@ func (in *InternalConnectSpec) DeepCopyInto(out *InternalConnectSpec) { *out = new(bool) **out = **in } + if in.Teardown != nil { + in, out := &in.Teardown, &out.Teardown + *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/connectspec.go b/client-go/applyconfiguration/core/v1beta1/connectspec.go index 82f9cef..6cd407f 100644 --- a/client-go/applyconfiguration/core/v1beta1/connectspec.go +++ b/client-go/applyconfiguration/core/v1beta1/connectspec.go @@ -31,6 +31,7 @@ type ConnectSpecApplyConfiguration struct { Image *string `json:"image,omitempty"` ImagePullPolicy *v1.PullPolicy `json:"imagePullPolicy,omitempty"` Sleep *bool `json:"sleep,omitempty"` + Suspended *bool `json:"suspended,omitempty"` SessionImage *string `json:"sessionImage,omitempty"` AwsAccountId *string `json:"awsAccountId,omitempty"` ClusterDate *string `json:"clusterDate,omitempty"` @@ -222,6 +223,14 @@ func (b *ConnectSpecApplyConfiguration) WithSleep(value bool) *ConnectSpecApplyC return b } +// WithSuspended sets the Suspended 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 Suspended field is set to the value of the last call. +func (b *ConnectSpecApplyConfiguration) WithSuspended(value bool) *ConnectSpecApplyConfiguration { + b.Suspended = &value + return b +} + // WithSessionImage sets the SessionImage 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 SessionImage field is set to the value of the last call. diff --git a/client-go/applyconfiguration/core/v1beta1/internalconnectspec.go b/client-go/applyconfiguration/core/v1beta1/internalconnectspec.go index 3104032..128c1d0 100644 --- a/client-go/applyconfiguration/core/v1beta1/internalconnectspec.go +++ b/client-go/applyconfiguration/core/v1beta1/internalconnectspec.go @@ -14,6 +14,7 @@ import ( // with apply. type InternalConnectSpecApplyConfiguration struct { Enabled *bool `json:"enabled,omitempty"` + Teardown *bool `json:"teardown,omitempty"` License *product.LicenseSpec `json:"license,omitempty"` Volume *product.VolumeSpec `json:"volume,omitempty"` NodeSelector map[string]string `json:"nodeSelector,omitempty"` @@ -50,6 +51,14 @@ func (b *InternalConnectSpecApplyConfiguration) WithEnabled(value bool) *Interna return b } +// WithTeardown sets the Teardown 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 Teardown field is set to the value of the last call. +func (b *InternalConnectSpecApplyConfiguration) WithTeardown(value bool) *InternalConnectSpecApplyConfiguration { + b.Teardown = &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_connects.yaml b/config/crd/bases/core.posit.team_connects.yaml index 95869ae..7d267d0 100644 --- a/config/crd/bases/core.posit.team_connects.yaml +++ b/config/crd/bases/core.posit.team_connects.yaml @@ -7332,6 +7332,11 @@ spec: Sleep puts the service to sleep... so you can debug a crash looping container / etc. It is an ugly escape hatch, but can also be useful on occasion type: boolean + suspended: + description: |- + Suspended indicates Connect should not run serving resources (Deployment, Service, Ingress) + but should preserve data resources (PVC, database, secrets). Set by the Site controller. + type: boolean url: type: string volume: diff --git a/config/crd/bases/core.posit.team_sites.yaml b/config/crd/bases/core.posit.team_sites.yaml index fab3edc..47a30fb 100644 --- a/config/crd/bases/core.posit.team_sites.yaml +++ b/config/crd/bases/core.posit.team_sites.yaml @@ -192,23 +192,10 @@ spec: 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. + Enabled controls whether Connect is running. Defaults to true. + Setting to false suspends Connect: stops pods and removes ingress/service, + but preserves PVC, database, and secrets so data is retained. + Re-enabling restores full service without data loss. type: boolean experimentalFeatures: properties: @@ -453,6 +440,13 @@ spec: type: integer sessionImage: type: string + teardown: + description: |- + Teardown permanently destroys all Connect resources including the database, + secrets, and persistent volume claim. Only takes effect when Enabled is false. + Re-enabling after teardown starts fresh with a new empty database. + Defaults to false. + type: boolean volume: description: VolumeSpec is a specification for a PersistentVolumeClaim to be created (and/or mounted) diff --git a/internal/controller/core/connect.go b/internal/controller/core/connect.go index 3c3f293..ec50761 100644 --- a/internal/controller/core/connect.go +++ b/internal/controller/core/connect.go @@ -37,6 +37,11 @@ func (r *ConnectReconciler) ReconcileConnect(ctx context.Context, req ctrl.Reque "product", "connect", ) + // If suspended, clean up serving resources (Deployment/Service/Ingress) but preserve data + if c.Spec.Suspended != nil && *c.Spec.Suspended { + return r.suspendDeployedService(ctx, req, c) + } + // create database secretKey := "pub-db-password" @@ -849,12 +854,12 @@ func (r *ConnectReconciler) ensureDeployedService(ctx context.Context, req ctrl. // // 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) +// 2. Connect teardown is requested via Site.Spec.Connect.Teardown=true (when Enabled=false) // -// When a user sets Enabled=false, the site controller calls cleanupConnect() which deletes +// When a user sets Teardown=true, 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, +// Re-enabling Connect after teardown 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 { @@ -873,6 +878,28 @@ func (r *ConnectReconciler) CleanupConnect(ctx context.Context, req ctrl.Request return ctrl.Result{}, nil } +func (r *ConnectReconciler) suspendDeployedService(ctx context.Context, req ctrl.Request, c *positcov1beta1.Connect) (ctrl.Result, error) { + l := r.GetLogger(ctx).WithValues( + "event", "suspend-service", + "product", "connect", + ) + + key := client.ObjectKey{Name: c.ComponentName(), Namespace: req.Namespace} + + if err := internal.BasicDelete(ctx, r, l, key, &networkingv1.Ingress{}); err != nil { + return ctrl.Result{}, err + } + if err := internal.BasicDelete(ctx, r, l, key, &corev1.Service{}); err != nil { + return ctrl.Result{}, err + } + if err := internal.BasicDelete(ctx, r, l, key, &v1.Deployment{}); err != nil { + return ctrl.Result{}, err + } + + l.Info("Connect service suspended successfully") + return ctrl.Result{}, nil +} + func (r *ConnectReconciler) cleanupDeployedService(ctx context.Context, req ctrl.Request, c *positcov1beta1.Connect) error { l := r.GetLogger(ctx).WithValues( "event", "cleanup-service", diff --git a/internal/controller/core/site_controller.go b/internal/controller/core/site_controller.go index c75cb50..41bddc2 100644 --- a/internal/controller/core/site_controller.go +++ b/internal/controller/core/site_controller.go @@ -155,6 +155,10 @@ func (r *SiteReconciler) reconcileResources(ctx context.Context, req ctrl.Reques // VOLUMES + // Determine if Connect is enabled (used for volume provisioning and later for reconciliation) + connectEnabled := site.Spec.Connect.Enabled == nil || *site.Spec.Connect.Enabled + connectTeardown := site.Spec.Connect.Teardown != nil && *site.Spec.Connect.Teardown + connectVolumeName := fmt.Sprintf("%s-connect", site.Name) connectStorageClassName := connectVolumeName devVolumeName := fmt.Sprintf("%s-workbench", site.Name) @@ -178,7 +182,7 @@ func (r *SiteReconciler) reconcileResources(ctx context.Context, req ctrl.Reques } // Only provision Connect volume if Connect is enabled - if site.Spec.Connect.Enabled == nil || *site.Spec.Connect.Enabled == true { + if connectEnabled { if err := r.provisionFsxVolume(ctx, site, connectVolumeName, "connect", connectVolumeSize); err != nil { return ctrl.Result{}, err } @@ -213,7 +217,7 @@ func (r *SiteReconciler) reconcileResources(ctx context.Context, req ctrl.Reques } // Only provision Connect volume if Connect is enabled - if site.Spec.Connect.Enabled == nil || *site.Spec.Connect.Enabled == true { + if connectEnabled { connectStorageClassName = fmt.Sprintf("%s-nfs", connectVolumeName) if err := r.provisionNfsVolume(ctx, site, connectVolumeName, "connect", connectStorageClassName, connectVolumeSize); err != nil { @@ -302,9 +306,8 @@ func (r *SiteReconciler) reconcileResources(ctx context.Context, req ctrl.Reques workbenchAdditionalVolumes = append(workbenchAdditionalVolumes, site.Spec.Workbench.AdditionalVolumes...) // CONNECT - // 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 connectEnabled { + // Connect is enabled - reconcile normally if err := r.reconcileConnect( ctx, req, @@ -320,13 +323,19 @@ func (r *SiteReconciler) reconcileResources(ctx context.Context, req ctrl.Reques 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 + } else if connectTeardown { + // Connect is disabled with teardown=true - DESTRUCTIVE cleanup + // 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") + l.Error(err, "error tearing down connect resources") + return ctrl.Result{}, err + } + } else { + // Connect is disabled but teardown=false - non-destructive suspend + // Removes serving resources (Deployment/Service/Ingress) but preserves data + if err := r.disableConnect(ctx, req, l); err != nil { + l.Error(err, "error disabling connect") return ctrl.Result{}, err } } diff --git a/internal/controller/core/site_controller_connect.go b/internal/controller/core/site_controller_connect.go index 527fbb5..befac94 100644 --- a/internal/controller/core/site_controller_connect.go +++ b/internal/controller/core/site_controller_connect.go @@ -9,6 +9,7 @@ import ( "github.com/posit-dev/team-operator/api/product" "github.com/posit-dev/team-operator/internal" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" controllerruntime "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -252,7 +253,42 @@ func (r *SiteReconciler) reconcileConnect( return nil } -// cleanupConnect deletes the Connect CRD when Connect is disabled (Enabled=false). +// disableConnect suspends Connect by marking the existing Connect CR with Suspended=true. +// The Connect controller then removes serving resources (Deployment/Service/Ingress) while +// preserving data resources (PVC, database, secrets). +// +// If no Connect CR exists yet (Connect was never enabled), this is a no-op. +// When Connect is re-enabled, reconcileConnect overwrites Suspended back to nil and +// performs a full reconcile. +func (r *SiteReconciler) disableConnect(ctx context.Context, req controllerruntime.Request, l logr.Logger) error { + l = l.WithValues("event", "disable-connect") + + connect := &v1beta1.Connect{} + if err := r.Get(ctx, client.ObjectKey{Name: req.Name, Namespace: req.Namespace}, connect); err != nil { + if apierrors.IsNotFound(err) { + l.Info("Connect CR not found, nothing to suspend") + return nil + } + return err + } + + if connect.Spec.Suspended != nil && *connect.Spec.Suspended { + l.Info("Connect already suspended") + return nil + } + + suspended := true + connect.Spec.Suspended = &suspended + if err := r.Update(ctx, connect); err != nil { + l.Error(err, "error suspending Connect CR") + return err + } + + l.Info("Connect CR suspended") + return nil +} + +// cleanupConnect deletes the Connect CRD when teardown=true. // // WARNING: This is a DESTRUCTIVE operation. Deleting the Connect CRD triggers the Connect // finalizer (CleanupConnect in connect_controller.go) which permanently destroys: @@ -261,12 +297,8 @@ func (r *SiteReconciler) reconcileConnect( // - 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. +// This is triggered by Site.Spec.Connect.Teardown=true (when Enabled=false). +// Re-enabling Connect after teardown will start fresh with a new database. func (r *SiteReconciler) cleanupConnect(ctx context.Context, req controllerruntime.Request, l logr.Logger) error { l = l.WithValues("event", "cleanup-connect") diff --git a/internal/controller/core/site_controller_networkpolicies.go b/internal/controller/core/site_controller_networkpolicies.go index 36c2020..c98222c 100644 --- a/internal/controller/core/site_controller_networkpolicies.go +++ b/internal/controller/core/site_controller_networkpolicies.go @@ -44,7 +44,8 @@ func (r *SiteReconciler) reconcileNetworkPolicies(ctx context.Context, req ctrl. } // Connect network policies - if site.Spec.Connect.Enabled == nil || *site.Spec.Connect.Enabled == true { + connectEnabled := site.Spec.Connect.Enabled == nil || *site.Spec.Connect.Enabled + if connectEnabled { if err := r.reconcileConnectNetworkPolicy(ctx, req.Namespace, l, site); err != nil { l.Error(err, "error ensuring connect network policy") return err @@ -54,7 +55,7 @@ func (r *SiteReconciler) reconcileNetworkPolicies(ctx context.Context, req ctrl. return err } } else { - // Clean up Connect network policies + // Clean up Connect network policies when disabled (regardless of teardown) if err := r.cleanupConnectNetworkPolicies(ctx, req, l); err != nil { l.Error(err, "error cleaning up connect network policies") return err diff --git a/internal/controller/core/site_test.go b/internal/controller/core/site_test.go index 4d2dc89..8612f22 100644 --- a/internal/controller/core/site_test.go +++ b/internal/controller/core/site_test.go @@ -1151,8 +1151,10 @@ func TestSiteReconciler_BaseDomainWithCustomPrefix(t *testing.T) { assert.Equal(t, "https://rsc.custom-domain.com", testWorkbench.Spec.Config.RSession.DefaultRSConnectServer) } -func TestSiteConnectSkippedWhenDisabled(t *testing.T) { - siteName := "disabled-connect" +// TestSiteConnectDisableNeverEnabled verifies that setting enabled=false when Connect was +// never enabled is a no-op: no Connect CR is created. +func TestSiteConnectDisableNeverEnabled(t *testing.T) { + siteName := "never-enabled-connect" siteNamespace := "posit-team" site := defaultSite(siteName) enabled := false @@ -1161,8 +1163,62 @@ func TestSiteConnectSkippedWhenDisabled(t *testing.T) { cli, _, err := runFakeSiteReconciler(t, siteNamespace, siteName, site) assert.NoError(t, err) - // Connect CRD should NOT be created when explicitly disabled + // Connect CR should NOT exist — disable with no prior enablement is a no-op 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 + assert.Error(t, err, "expected Connect CR to not exist when disabled without ever being enabled") +} + +// TestSiteConnectSuspendAfterEnable verifies that setting enabled=false after Connect was running +// suspends the Connect CR (Suspended=true) rather than deleting it, preserving data. +func TestSiteConnectSuspendAfterEnable(t *testing.T) { + siteName := "suspend-connect" + siteNamespace := "posit-team" + + // Share a single fake environment for both reconcile passes. + fakeClient := localtest.FakeTestEnv{} + cli, scheme, log := fakeClient.Start(loadSchemes) + rec := SiteReconciler{Client: cli, Scheme: scheme, Log: log} + req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: siteNamespace, Name: siteName}} + + // First pass: Connect enabled (default) + site := defaultSite(siteName) + _, err := rec.reconcileResources(context.TODO(), req, site) + assert.NoError(t, err) + + connect := &v1beta1.Connect{} + err = cli.Get(context.TODO(), client.ObjectKey{Name: siteName, Namespace: siteNamespace}, connect) + assert.NoError(t, err, "Connect CR should exist after first reconcile") + + // Second pass: disable Connect without teardown + enabled := false + site.Spec.Connect.Enabled = &enabled + _, err = rec.reconcileResources(context.TODO(), req, site) + assert.NoError(t, err) + + // Connect CR must still exist and be marked suspended + err = cli.Get(context.TODO(), client.ObjectKey{Name: siteName, Namespace: siteNamespace}, connect) + assert.NoError(t, err, "Connect CR should still exist when disabled without teardown") + assert.NotNil(t, connect.Spec.Suspended) + assert.True(t, *connect.Spec.Suspended) +} + +// TestSiteConnectTeardown verifies that setting enabled=false + teardown=true causes the +// Connect CR to be deleted (triggering the destructive finalizer path). +func TestSiteConnectTeardown(t *testing.T) { + siteName := "teardown-connect" + siteNamespace := "posit-team" + site := defaultSite(siteName) + enabled := false + teardown := true + site.Spec.Connect.Enabled = &enabled + site.Spec.Connect.Teardown = &teardown + + cli, _, err := runFakeSiteReconciler(t, siteNamespace, siteName, site) + assert.NoError(t, err) + + // Connect CR should NOT exist when teardown is true + connect := &v1beta1.Connect{} + err = cli.Get(context.TODO(), client.ObjectKey{Name: siteName, Namespace: siteNamespace}, connect) + assert.Error(t, err, "Connect CR should not exist when teardown=true") } From 6f9fd4b0e9ed624cd272af401c04b2734575c4fd Mon Sep 17 00:00:00 2001 From: ian-flores Date: Thu, 19 Feb 2026 09:25:23 -0800 Subject: [PATCH 08/14] fix: address review findings for Connect enable/teardown implementation - Switch disableConnect from Update to MergeFrom Patch to avoid conflict errors when another controller touches the Connect CR concurrently - Add warning log when teardown=true is set while enabled is true/nil - Expand TestSiteConnectSuspendAfterEnable with a third re-enable pass verifying Suspended is cleared by reconcileConnect full spec replace - Rework TestSiteConnectTeardown to pre-create the Connect CR before teardown so the deletion assertion is meaningful --- internal/controller/core/site_controller.go | 3 ++ .../core/site_controller_connect.go | 3 +- internal/controller/core/site_test.go | 45 +++++++++++++++---- 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/internal/controller/core/site_controller.go b/internal/controller/core/site_controller.go index 41bddc2..0c2d40a 100644 --- a/internal/controller/core/site_controller.go +++ b/internal/controller/core/site_controller.go @@ -158,6 +158,9 @@ func (r *SiteReconciler) reconcileResources(ctx context.Context, req ctrl.Reques // Determine if Connect is enabled (used for volume provisioning and later for reconciliation) connectEnabled := site.Spec.Connect.Enabled == nil || *site.Spec.Connect.Enabled connectTeardown := site.Spec.Connect.Teardown != nil && *site.Spec.Connect.Teardown + if connectTeardown && connectEnabled { + l.Info("WARNING: connect.teardown=true has no effect while connect.enabled is true; set enabled=false to trigger teardown") + } connectVolumeName := fmt.Sprintf("%s-connect", site.Name) connectStorageClassName := connectVolumeName diff --git a/internal/controller/core/site_controller_connect.go b/internal/controller/core/site_controller_connect.go index befac94..cfaf2ae 100644 --- a/internal/controller/core/site_controller_connect.go +++ b/internal/controller/core/site_controller_connect.go @@ -277,9 +277,10 @@ func (r *SiteReconciler) disableConnect(ctx context.Context, req controllerrunti return nil } + patch := client.MergeFrom(connect.DeepCopy()) suspended := true connect.Spec.Suspended = &suspended - if err := r.Update(ctx, connect); err != nil { + if err := r.Patch(ctx, connect, patch); err != nil { l.Error(err, "error suspending Connect CR") return err } diff --git a/internal/controller/core/site_test.go b/internal/controller/core/site_test.go index 8612f22..ec91004 100644 --- a/internal/controller/core/site_test.go +++ b/internal/controller/core/site_test.go @@ -1171,17 +1171,20 @@ func TestSiteConnectDisableNeverEnabled(t *testing.T) { // TestSiteConnectSuspendAfterEnable verifies that setting enabled=false after Connect was running // suspends the Connect CR (Suspended=true) rather than deleting it, preserving data. +// It also verifies that re-enabling clears Suspended and restores full reconciliation, +// confirming that reconcileConnect's full spec replace via controllerutil.CreateOrUpdate +// correctly overwrites Suspended=true back to nil. func TestSiteConnectSuspendAfterEnable(t *testing.T) { siteName := "suspend-connect" siteNamespace := "posit-team" - // Share a single fake environment for both reconcile passes. + // Share a single fake environment across all reconcile passes. fakeClient := localtest.FakeTestEnv{} cli, scheme, log := fakeClient.Start(loadSchemes) rec := SiteReconciler{Client: cli, Scheme: scheme, Log: log} req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: siteNamespace, Name: siteName}} - // First pass: Connect enabled (default) + // Pass 1: Connect enabled (default) site := defaultSite(siteName) _, err := rec.reconcileResources(context.TODO(), req, site) assert.NoError(t, err) @@ -1189,36 +1192,60 @@ func TestSiteConnectSuspendAfterEnable(t *testing.T) { connect := &v1beta1.Connect{} err = cli.Get(context.TODO(), client.ObjectKey{Name: siteName, Namespace: siteNamespace}, connect) assert.NoError(t, err, "Connect CR should exist after first reconcile") + assert.Nil(t, connect.Spec.Suspended) - // Second pass: disable Connect without teardown + // Pass 2: disable Connect without teardown enabled := false site.Spec.Connect.Enabled = &enabled _, err = rec.reconcileResources(context.TODO(), req, site) assert.NoError(t, err) - // Connect CR must still exist and be marked suspended err = cli.Get(context.TODO(), client.ObjectKey{Name: siteName, Namespace: siteNamespace}, connect) assert.NoError(t, err, "Connect CR should still exist when disabled without teardown") assert.NotNil(t, connect.Spec.Suspended) assert.True(t, *connect.Spec.Suspended) + + // Pass 3: re-enable Connect — reconcileConnect does a full spec replace via + // controllerutil.CreateOrUpdate, so Suspended must be cleared back to nil. + site.Spec.Connect.Enabled = nil + _, err = rec.reconcileResources(context.TODO(), req, site) + assert.NoError(t, err) + + err = cli.Get(context.TODO(), client.ObjectKey{Name: siteName, Namespace: siteNamespace}, connect) + assert.NoError(t, err, "Connect CR should still exist after re-enable") + assert.Nil(t, connect.Spec.Suspended, "Suspended should be cleared after re-enable") } // TestSiteConnectTeardown verifies that setting enabled=false + teardown=true causes the // Connect CR to be deleted (triggering the destructive finalizer path). +// The CR must be pre-created to confirm it is actually deleted (not just absent from the start). func TestSiteConnectTeardown(t *testing.T) { siteName := "teardown-connect" siteNamespace := "posit-team" + + fakeClient := localtest.FakeTestEnv{} + cli, scheme, log := fakeClient.Start(loadSchemes) + rec := SiteReconciler{Client: cli, Scheme: scheme, Log: log} + req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: siteNamespace, Name: siteName}} + + // Pass 1: establish a running Connect CR site := defaultSite(siteName) + _, err := rec.reconcileResources(context.TODO(), req, site) + assert.NoError(t, err) + + connect := &v1beta1.Connect{} + err = cli.Get(context.TODO(), client.ObjectKey{Name: siteName, Namespace: siteNamespace}, connect) + assert.NoError(t, err, "Connect CR should exist before teardown") + + // Pass 2: teardown enabled := false teardown := true site.Spec.Connect.Enabled = &enabled site.Spec.Connect.Teardown = &teardown - - cli, _, err := runFakeSiteReconciler(t, siteNamespace, siteName, site) + _, err = rec.reconcileResources(context.TODO(), req, site) assert.NoError(t, err) - // Connect CR should NOT exist when teardown is true - connect := &v1beta1.Connect{} + // Connect CR should NOT exist after teardown err = cli.Get(context.TODO(), client.ObjectKey{Name: siteName, Namespace: siteNamespace}, connect) - assert.Error(t, err, "Connect CR should not exist when teardown=true") + assert.Error(t, err, "Connect CR should not exist after teardown=true") } From d62194c3d8dfd34cb611db5b5edceaf5e8a636c7 Mon Sep 17 00:00:00 2001 From: ian-flores Date: Thu, 19 Feb 2026 09:36:26 -0800 Subject: [PATCH 09/14] fix: improve teardown misconfiguration warning log message Remove WARNING: prefix (logr anti-pattern) and correct the message to accurately cover the nil/default case where enabled is unset, not just when it is explicitly true. --- internal/controller/core/site_controller.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/controller/core/site_controller.go b/internal/controller/core/site_controller.go index 0c2d40a..441df34 100644 --- a/internal/controller/core/site_controller.go +++ b/internal/controller/core/site_controller.go @@ -159,7 +159,7 @@ func (r *SiteReconciler) reconcileResources(ctx context.Context, req ctrl.Reques connectEnabled := site.Spec.Connect.Enabled == nil || *site.Spec.Connect.Enabled connectTeardown := site.Spec.Connect.Teardown != nil && *site.Spec.Connect.Teardown if connectTeardown && connectEnabled { - l.Info("WARNING: connect.teardown=true has no effect while connect.enabled is true; set enabled=false to trigger teardown") + l.Info("connect.teardown is set but connect.enabled is not false; teardown has no effect until enabled=false") } connectVolumeName := fmt.Sprintf("%s-connect", site.Name) From 91322a717ca7b650388472813ca05d040aa650a9 Mon Sep 17 00:00:00 2001 From: ian-flores Date: Thu, 19 Feb 2026 09:52:27 -0800 Subject: [PATCH 10/14] docs: update Helm CRD and guides to reflect non-destructive enable/teardown API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Regenerate dist/chart/templates/crd/ — Helm chart CRD was stale and still described enabled=false as destructive; teardown field was missing - Update docs/api-reference.md: correct .enabled description, add .teardown entry - Rewrite connect-configuration.md enabling/disabling section: document enabled=false as a safe suspend, teardown=true as the explicit destructive path --- .../templates/crd/core.posit.team_sites.yaml | 28 ++++---- docs/api-reference.md | 3 +- docs/guides/connect-configuration.md | 64 +++++++++---------- 3 files changed, 43 insertions(+), 52 deletions(-) diff --git a/dist/chart/templates/crd/core.posit.team_sites.yaml b/dist/chart/templates/crd/core.posit.team_sites.yaml index 88481c3..e519633 100755 --- a/dist/chart/templates/crd/core.posit.team_sites.yaml +++ b/dist/chart/templates/crd/core.posit.team_sites.yaml @@ -213,23 +213,10 @@ spec: 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. + Enabled controls whether Connect is running. Defaults to true. + Setting to false suspends Connect: stops pods and removes ingress/service, + but preserves PVC, database, and secrets so data is retained. + Re-enabling restores full service without data loss. type: boolean experimentalFeatures: properties: @@ -474,6 +461,13 @@ spec: type: integer sessionImage: type: string + teardown: + description: |- + Teardown permanently destroys all Connect resources including the database, + secrets, and persistent volume claim. Only takes effect when Enabled is false. + Re-enabling after teardown starts fresh with a new empty database. + Defaults to false. + type: boolean volume: description: VolumeSpec is a specification for a PersistentVolumeClaim to be created (and/or mounted) diff --git a/docs/api-reference.md b/docs/api-reference.md index fbcd45a..5616194 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -743,7 +743,8 @@ 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. | +| `.enabled` | `*bool` | Controls whether Connect is running (default: true). Setting to `false` suspends Connect: stops pods and removes ingress/service, but preserves PVC, database, and secrets. Re-enabling restores full service without data loss. See [Connect Configuration Guide](guides/connect-configuration.md#enablingdisabling-connect). | +| `.teardown` | `*bool` | When `true` and `enabled` is `false`, permanently destroys all Connect resources including the database, secrets, and PVC. Re-enabling after teardown starts fresh with an empty database. Defaults to `false`. | | `.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 47de644..4cf585e 100644 --- a/docs/guides/connect-configuration.md +++ b/docs/guides/connect-configuration.md @@ -62,57 +62,53 @@ When using a Site resource, the Site controller generates and manages the Connec ### Enabling/Disabling Connect -By default, Connect is enabled when specified in a Site configuration. You can explicitly control whether Connect is deployed: +Connect can be suspended or permanently torn down using the `enabled` and `teardown` fields. + +#### Suspending Connect (non-destructive) + +Setting `enabled: false` suspends Connect: the Deployment, Service, and Ingress are removed, but the PVC, database, and secrets are preserved. Re-enabling restores full service with all existing data intact. ```yaml spec: connect: - # Enable Connect deployment (default: true) - enabled: true + enabled: false # suspend — data is preserved ``` -#### 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 +- Customer does not have a Connect license yet — deploy the site without Connect and enable it once a license is purchased +- Temporarily pause Connect during a maintenance window or cost-saving period +- Stop Connect while retaining all content and user data for a possible return -**Never use `enabled: false` for:** +**Re-enabling Connect** after a suspend is as simple as removing the field or setting it back to `true`: -- 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) +```yaml +spec: + connect: + enabled: true # or omit the field entirely — defaults to true +``` + +#### Tearing down Connect (destructive) -**Example of safely scaling down Connect temporarily:** +To permanently destroy all Connect resources — including the database, secrets, and PVC — set both `enabled: false` and `teardown: true`: ```yaml spec: connect: - # Scale down Connect pods without deleting data - replicas: 0 - # Keep enabled: true (or omit it, as true is the default) + enabled: false + teardown: true # DESTRUCTIVE: deletes database, secrets, and PVC ``` +**This is irreversible.** Re-enabling Connect after a teardown starts completely fresh with a new empty database and no prior content or configuration. + +**When to use `teardown: true`:** + +- Permanently decommissioning Connect with no intent to restore data +- Reclaiming cluster storage after migrating to a different Connect instance +- Explicitly wiping Connect to start fresh + +> **Note:** `teardown: true` has no effect while `enabled` is `true` or unset. You must set `enabled: false` first. + --- ### Image Configuration From d316fea5914201a7c922d37a7bbe0f28eaded325 Mon Sep 17 00:00:00 2001 From: ian-flores Date: Thu, 19 Feb 2026 10:18:49 -0800 Subject: [PATCH 11/14] chore: regenerate Helm CRD for Connect with suspended field --- dist/chart/templates/crd/core.posit.team_connects.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dist/chart/templates/crd/core.posit.team_connects.yaml b/dist/chart/templates/crd/core.posit.team_connects.yaml index 8b5d5a2..a8da374 100755 --- a/dist/chart/templates/crd/core.posit.team_connects.yaml +++ b/dist/chart/templates/crd/core.posit.team_connects.yaml @@ -7353,6 +7353,11 @@ spec: Sleep puts the service to sleep... so you can debug a crash looping container / etc. It is an ugly escape hatch, but can also be useful on occasion type: boolean + suspended: + description: |- + Suspended indicates Connect should not run serving resources (Deployment, Service, Ingress) + but should preserve data resources (PVC, database, secrets). Set by the Site controller. + type: boolean url: type: string volume: From 593b5da0c652dbed77f9acfeb0fb730d174d84a6 Mon Sep 17 00:00:00 2001 From: ian-flores Date: Thu, 19 Feb 2026 10:51:09 -0800 Subject: [PATCH 12/14] fix: reorder imports to satisfy gofmt --- internal/controller/core/site_controller_connect.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/controller/core/site_controller_connect.go b/internal/controller/core/site_controller_connect.go index cfaf2ae..7358c14 100644 --- a/internal/controller/core/site_controller_connect.go +++ b/internal/controller/core/site_controller_connect.go @@ -8,8 +8,8 @@ import ( "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" apierrors "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" controllerruntime "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" ) From b2dd1704cb0ae3f6458320b3ac0cd581969ab3d3 Mon Sep 17 00:00:00 2001 From: ian-flores Date: Thu, 19 Feb 2026 11:04:20 -0800 Subject: [PATCH 13/14] docs: fix stale Connect enabled warning in site management guide --- docs/guides/product-team-site-management.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/guides/product-team-site-management.md b/docs/guides/product-team-site-management.md index 7494fbf..1f1d833 100644 --- a/docs/guides/product-team-site-management.md +++ b/docs/guides/product-team-site-management.md @@ -243,8 +243,9 @@ spec: ```yaml spec: connect: - # Enable/disable Connect deployment (default: true) - # WARNING: Setting enabled: false permanently deletes all Connect data! + # Enable/disable Connect deployment (default: true). + # Setting enabled: false suspends Connect (preserves data). + # Use teardown: true to permanently delete all Connect data. # See the Connect Configuration Guide for details. enabled: true From f3e1641a65b5ba02eabbccfc67fa7c81744435b2 Mon Sep 17 00:00:00 2001 From: ian-flores Date: Thu, 19 Feb 2026 11:09:59 -0800 Subject: [PATCH 14/14] feat: add kubebuilder default markers for Connect enabled and teardown fields --- api/core/v1beta1/site_types.go | 3 ++- config/crd/bases/core.posit.team_sites.yaml | 3 ++- dist/chart/templates/crd/core.posit.team_sites.yaml | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/api/core/v1beta1/site_types.go b/api/core/v1beta1/site_types.go index fe5d696..694a1f8 100644 --- a/api/core/v1beta1/site_types.go +++ b/api/core/v1beta1/site_types.go @@ -228,13 +228,14 @@ type InternalConnectSpec struct { // Setting to false suspends Connect: stops pods and removes ingress/service, // but preserves PVC, database, and secrets so data is retained. // Re-enabling restores full service without data loss. + // +kubebuilder:default=true // +optional Enabled *bool `json:"enabled,omitempty"` // Teardown permanently destroys all Connect resources including the database, // secrets, and persistent volume claim. Only takes effect when Enabled is false. // Re-enabling after teardown starts fresh with a new empty database. - // Defaults to false. + // +kubebuilder:default=false // +optional Teardown *bool `json:"teardown,omitempty"` diff --git a/config/crd/bases/core.posit.team_sites.yaml b/config/crd/bases/core.posit.team_sites.yaml index 47a30fb..c3af90c 100644 --- a/config/crd/bases/core.posit.team_sites.yaml +++ b/config/crd/bases/core.posit.team_sites.yaml @@ -191,6 +191,7 @@ spec: default: connect type: string enabled: + default: true description: |- Enabled controls whether Connect is running. Defaults to true. Setting to false suspends Connect: stops pods and removes ingress/service, @@ -441,11 +442,11 @@ spec: sessionImage: type: string teardown: + default: false description: |- Teardown permanently destroys all Connect resources including the database, secrets, and persistent volume claim. Only takes effect when Enabled is false. Re-enabling after teardown starts fresh with a new empty database. - Defaults to false. type: boolean volume: description: VolumeSpec is a specification for a PersistentVolumeClaim diff --git a/dist/chart/templates/crd/core.posit.team_sites.yaml b/dist/chart/templates/crd/core.posit.team_sites.yaml index e519633..f46168c 100755 --- a/dist/chart/templates/crd/core.posit.team_sites.yaml +++ b/dist/chart/templates/crd/core.posit.team_sites.yaml @@ -212,6 +212,7 @@ spec: default: connect type: string enabled: + default: true description: |- Enabled controls whether Connect is running. Defaults to true. Setting to false suspends Connect: stops pods and removes ingress/service, @@ -462,11 +463,11 @@ spec: sessionImage: type: string teardown: + default: false description: |- Teardown permanently destroys all Connect resources including the database, secrets, and persistent volume claim. Only takes effect when Enabled is false. Re-enabling after teardown starts fresh with a new empty database. - Defaults to false. type: boolean volume: description: VolumeSpec is a specification for a PersistentVolumeClaim