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 3e7a3b0..694a1f8 100644 --- a/api/core/v1beta1/site_types.go +++ b/api/core/v1beta1/site_types.go @@ -224,6 +224,21 @@ type InternalPackageManagerSpec struct { } type InternalConnectSpec struct { + // 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. + // +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. + // +kubebuilder:default=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 a8d7c25..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)) @@ -1146,6 +1151,16 @@ 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 + } + 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 84e69b2..128c1d0 100644 --- a/client-go/applyconfiguration/core/v1beta1/internalconnectspec.go +++ b/client-go/applyconfiguration/core/v1beta1/internalconnectspec.go @@ -13,6 +13,8 @@ import ( // InternalConnectSpecApplyConfiguration represents a declarative configuration of the InternalConnectSpec type for use // 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"` @@ -41,6 +43,22 @@ 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 +} + +// 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 bb2ebf3..c3af90c 100644 --- a/config/crd/bases/core.posit.team_sites.yaml +++ b/config/crd/bases/core.posit.team_sites.yaml @@ -190,6 +190,14 @@ spec: domainPrefix: 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, + but preserves PVC, database, and secrets so data is retained. + Re-enabling restores full service without data loss. + type: boolean experimentalFeatures: properties: chronicleSidecarProductApiKeyEnabled: @@ -433,6 +441,13 @@ spec: type: integer 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. + type: boolean volume: description: VolumeSpec is a specification for a PersistentVolumeClaim to be created (and/or mounted) 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: diff --git a/dist/chart/templates/crd/core.posit.team_sites.yaml b/dist/chart/templates/crd/core.posit.team_sites.yaml index e002d1b..f46168c 100755 --- a/dist/chart/templates/crd/core.posit.team_sites.yaml +++ b/dist/chart/templates/crd/core.posit.team_sites.yaml @@ -211,6 +211,14 @@ spec: domainPrefix: 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, + but preserves PVC, database, and secrets so data is retained. + Re-enabling restores full service without data loss. + type: boolean experimentalFeatures: properties: chronicleSidecarProductApiKeyEnabled: @@ -454,6 +462,13 @@ spec: type: integer 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. + 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 72547d7..5616194 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -743,6 +743,8 @@ These types are used within the Site CRD for product configuration. | Field | Type | Description | |-------|------|-------------| +| `.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 bbef819..4cf585e 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,57 @@ When using a Site resource, the Site controller generates and manages the Connec ## Basic Configuration +### Enabling/Disabling Connect + +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: + enabled: false # suspend — data is preserved +``` + +**When to use `enabled: false`:** + +- 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 + +**Re-enabling Connect** after a suspend is as simple as removing the field or setting it back to `true`: + +```yaml +spec: + connect: + enabled: true # or omit the field entirely — defaults to true +``` + +#### Tearing down Connect (destructive) + +To permanently destroy all Connect resources — including the database, secrets, and PVC — set both `enabled: false` and `teardown: true`: + +```yaml +spec: + connect: + 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 ```yaml diff --git a/docs/guides/product-team-site-management.md b/docs/guides/product-team-site-management.md index 0c408f3..1f1d833 100644 --- a/docs/guides/product-team-site-management.md +++ b/docs/guides/product-team-site-management.md @@ -243,6 +243,12 @@ spec: ```yaml spec: connect: + # 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 + 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..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" @@ -840,6 +845,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 teardown is requested via Site.Spec.Connect.Teardown=true (when Enabled=false) +// +// 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 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 { return ctrl.Result{}, err @@ -857,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 fdb59c4..441df34 100644 --- a/internal/controller/core/site_controller.go +++ b/internal/controller/core/site_controller.go @@ -155,6 +155,13 @@ 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 + if connectTeardown && connectEnabled { + 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) connectStorageClassName := connectVolumeName devVolumeName := fmt.Sprintf("%s-workbench", site.Name) @@ -177,8 +184,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 connectEnabled { + 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 +219,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 connectEnabled { + 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 +309,38 @@ 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 connectEnabled { + // Connect is enabled - reconcile normally + 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 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. + if err := r.cleanupConnect(ctx, req, l); err != nil { + 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 + } } // PACKAGE MANAGER diff --git a/internal/controller/core/site_controller_connect.go b/internal/controller/core/site_controller_connect.go index 2462486..7358c14 100644 --- a/internal/controller/core/site_controller_connect.go +++ b/internal/controller/core/site_controller_connect.go @@ -4,11 +4,14 @@ 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" + 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" ) func (r *SiteReconciler) reconcileConnect( @@ -249,3 +252,62 @@ func (r *SiteReconciler) reconcileConnect( } return nil } + +// 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 + } + + patch := client.MergeFrom(connect.DeepCopy()) + suspended := true + connect.Spec.Suspended = &suspended + if err := r.Patch(ctx, connect, patch); 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: +// - The Connect database and all its data +// - All secrets (database credentials, provisioning keys, etc.) +// - Persistent volumes and claims +// - All deployed Kubernetes resources +// +// 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") + + // 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..c98222c 100644 --- a/internal/controller/core/site_controller_networkpolicies.go +++ b/internal/controller/core/site_controller_networkpolicies.go @@ -43,14 +43,23 @@ 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 + 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 + } + 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 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 + } } if err := r.reconcileHomeNetworkPolicy(ctx, req.Namespace, l, site); err != nil { @@ -783,3 +792,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..ec91004 100644 --- a/internal/controller/core/site_test.go +++ b/internal/controller/core/site_test.go @@ -1150,3 +1150,102 @@ 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) } + +// 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 + site.Spec.Connect.Enabled = &enabled + + cli, _, err := runFakeSiteReconciler(t, siteNamespace, siteName, site) + assert.NoError(t, err) + + // 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, "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. +// 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 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}} + + // Pass 1: 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") + assert.Nil(t, connect.Spec.Suspended) + + // Pass 2: disable Connect without teardown + enabled := false + site.Spec.Connect.Enabled = &enabled + _, 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 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 + _, err = rec.reconcileResources(context.TODO(), req, site) + assert.NoError(t, err) + + // 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 after teardown=true") +}