diff --git a/_quarto.yml b/_quarto.yml index 89cc79af8..fccaa0d5d 100644 --- a/_quarto.yml +++ b/_quarto.yml @@ -66,6 +66,8 @@ website: file: charts/rstudio-connect/README.md - text: Changelog file: charts/rstudio-connect/NEWS.md + - text: Upgrading to the direct Kubernetes runner + file: examples/connect/upgrade-launcher-to-kubernetes/launcher-to-kubernetes.qmd - text: Examples file: examples/connect/index.qmd - section: "Posit Package Manager" diff --git a/charts/rstudio-connect/Chart.lock b/charts/rstudio-connect/Chart.lock index f605f90cc..f82354682 100644 --- a/charts/rstudio-connect/Chart.lock +++ b/charts/rstudio-connect/Chart.lock @@ -1,6 +1,6 @@ dependencies: - name: rstudio-library repository: https://helm.rstudio.com - version: 0.1.35 -digest: sha256:59fe5e712690610495d2ff32c6c78cc136e9685a785151d4cdba4b914d110f87 -generated: "2025-10-09T13:37:20.639004-05:00" + version: 0.1.36 +digest: sha256:cde061b0b7da43d7adbfcbc8d90e6d466d453dd707d8dad4f702fec6aeea103b +generated: "2026-04-01T12:03:45.783681-05:00" diff --git a/charts/rstudio-connect/Chart.yaml b/charts/rstudio-connect/Chart.yaml index 2416b1efd..92d832613 100644 --- a/charts/rstudio-connect/Chart.yaml +++ b/charts/rstudio-connect/Chart.yaml @@ -1,6 +1,6 @@ name: rstudio-connect description: Official Helm chart for Posit Connect -version: 0.8.38 +version: 0.9.0 apiVersion: v2 appVersion: 2026.03.1 icon: https://raw.githubusercontent.com/rstudio/helm/main/images/posit-icon-fullcolor.svg @@ -13,7 +13,7 @@ maintainers: url: https://github.com/sol-eng dependencies: - name: rstudio-library - version: 0.1.35 + version: 0.1.36 repository: https://helm.rstudio.com annotations: artifacthub.io/images: | diff --git a/charts/rstudio-connect/NEWS.md b/charts/rstudio-connect/NEWS.md index 0d40f47a7..07bd6407f 100644 --- a/charts/rstudio-connect/NEWS.md +++ b/charts/rstudio-connect/NEWS.md @@ -1,5 +1,12 @@ # Changelog +## 0.9.0 + +- Add support for an alternative Kubernetes backend via `backends.kubernetes.enabled`, which replaces the Launcher's template system with standard Kubernetes Job and Service manifests configured through `defaultResourceJobBase` and `defaultResourceServiceBase`. This backend will be available starting with Connect 2026.04.0. +- Add mutual-exclusion validation: `launcher.enabled` and `backends.kubernetes.enabled` cannot both be true. +- Add dedicated RBAC for the direct Kubernetes runner with least-privilege permissions, including automatic ClusterRole creation for NodePort service types. +- Add upgrade guide and examples in `examples/connect/upgrade-launcher-to-kubernetes/`. + ## 0.8.38 - Bump Connect version to 2026.03.1 diff --git a/charts/rstudio-connect/README.md b/charts/rstudio-connect/README.md index bb80a132c..7eff365b9 100644 --- a/charts/rstudio-connect/README.md +++ b/charts/rstudio-connect/README.md @@ -1,6 +1,6 @@ # Posit Connect -![Version: 0.8.38](https://img.shields.io/badge/Version-0.8.38-informational?style=flat-square) ![AppVersion: 2026.03.1](https://img.shields.io/badge/AppVersion-2026.03.1-informational?style=flat-square) +![Version: 0.9.0](https://img.shields.io/badge/Version-0.9.0-informational?style=flat-square) ![AppVersion: 2026.03.1](https://img.shields.io/badge/AppVersion-2026.03.1-informational?style=flat-square) #### _Official Helm chart for Posit Connect_ @@ -30,11 +30,11 @@ To ensure reproducibility in your environment and insulate yourself from future ## Installing the chart -To install the chart with the release name `my-release` at version 0.8.38: +To install the chart with the release name `my-release` at version 0.9.0: ```{.bash} helm repo add rstudio https://helm.rstudio.com -helm upgrade --install my-release rstudio/rstudio-connect --version=0.8.38 +helm upgrade --install my-release rstudio/rstudio-connect --version=0.9.0 ``` To explore other chart versions, look at: @@ -45,10 +45,14 @@ helm search repo rstudio/rstudio-connect -l ## Upgrade guidance +### 0.9.0 + +- Chart version 0.9.0 adds support for the direct Kubernetes runner via `backends.kubernetes.enabled`. See the [upgrade guide](../../examples/connect/upgrade-launcher-to-kubernetes/launcher-to-kubernetes.qmd) for details on transitioning from `launcher.enabled`. + ### 0.8.0 - When upgrading to version 0.8.0 or later, Connect now runs in [Off-Host Execution mode](https://docs.posit.co/connect/admin/getting-started/off-host-install/) by default -- If you desire to run Connect not in Off-Host Execution mode, then set `securityContext.privileged: true` and `launcher.enabled: false` +- If you desire to run Connect in Local Execution mode, then set `securityContext.privileged: true` and `launcher.enabled: false` ## Required configuration @@ -262,6 +266,17 @@ The Helm `config` values are converted into the `rstudio-connect.gcfg` service c |-----|------|---------|-------------| | affinity | object | `{}` | A map used verbatim as the pod's "affinity" definition | | args | list | `[]` | The pod's run arguments. By default, it uses the container's default | +| backends.kubernetes.defaultInitContainer.enabled | bool | `true` | Whether to enable the defaultInitContainer. If disabled, you must ensure that the session components are available another way. Changing the default setting is an advanced option and not recommended. For more information on how Connect uses the session init container refer to https://docs.posit.co/connect/admin/appendix/off-host/arch-overview/#runtime-init-container | +| backends.kubernetes.defaultInitContainer.imagePullPolicy | string | `""` | The imagePullPolicy for the default initContainer | +| backends.kubernetes.defaultInitContainer.repository | string | `"ghcr.io/rstudio/rstudio-connect-content-init"` | The repository to use for the Content InitContainer image | +| backends.kubernetes.defaultInitContainer.resources | object | `{}` | Optional resources for the default initContainer | +| backends.kubernetes.defaultInitContainer.securityContext | object | `{}` | The securityContext for the default initContainer | +| backends.kubernetes.defaultInitContainer.tag | string | `""` | Overrides the image tag whose default is the chart appVersion. | +| backends.kubernetes.defaultInitContainer.tagPrefix | string | `"ubuntu2204-"` | A tag prefix for the Content InitContainer image (common selections: jammy-, ubuntu2204-). Only used if tag is not defined | +| backends.kubernetes.defaultResourceJobBase | object | `{}` | defaultResourceJobBase is an optional Kubernetes Job definition used as the base when launching content jobs. The chart automatically adds the init container and runtime volume when backends.kubernetes.defaultInitContainer.enabled is true. Only set this if you need to customize the job (e.g., add sidecars, node selectors, tolerations). https://kubernetes.io/docs/concepts/workloads/controllers/job/ | +| backends.kubernetes.defaultResourceServiceBase | object | `{}` | defaultResourceServiceBase contains the Kubernetes Service definition which is used as an overlay "base" when creating a content job's Service in Kubernetes. Conceptually this is similar to a Kustomize base. Connect then applies any required Service configuration on-top of the overlay base to produce a final Service definition. https://kubernetes.io/docs/concepts/services-networking/service/ https://kubernetes.io/docs/tasks/manage-kubernetes-objects/kustomization/#bases-and-overlays | +| backends.kubernetes.enabled | bool | `false` | Whether to enable off-host execution for running content-jobs in remote Kubernetes pods. | +| backends.kubernetes.namespace | string | `""` | The namespace to launch connect-content jobs into. Uses the Release namespace by default | | chronicleAgent.agentEnvironment | string | `""` | An environment tag to apply to all metrics reported by this agent ([reference](https://docs.posit.co/chronicle/appendix/library/advanced-agent.html#environment)) | | chronicleAgent.autoDiscovery | bool | `true` | If true, the chart will attempt to lookup the Chronicle Server address and version in the cluster | | chronicleAgent.connectApiKey | object | `{"value":"","valueFrom":{}}` | An Administrator permissions API key generated in Connect for the Chronicle agent to use, API keys can only be created after Connect has been deployed so this value may need to be filled in later if performing an initial deployment ([reference](https://docs.posit.co/connect/user/api-keys/#api-keys-creating)) | @@ -349,13 +364,13 @@ The Helm `config` values are converted into the `rstudio-connect.gcfg` service c | priorityClassName | string | `""` | The pod's priorityClassName | | prometheus.enabled | bool | `true` | The parent setting for whether to enable prometheus metrics. Default is to use the built-in product exporter | | prometheus.port | int | `3232` | The port that prometheus will listen on | -| rbac.clusterRoleCreate | bool | `false` | Whether to create the ClusterRole that grants access to the Kubernetes nodes API. This is used by the Launcher to get all of the IP addresses associated with the node that is running a particular job. In most cases, this can be disabled as the node's internal address is sufficient to allow proper functionality. | -| rbac.create | bool | `true` | Whether to create rbac. (also depends on launcher.enabled = true) | -| rbac.serviceAccount | object | `{"annotations":{},"create":true,"labels":{},"name":""}` | The serviceAccount to be associated with rbac (also depends on launcher.enabled = true) | +| rbac.clusterRoleCreate | bool | `false` | Whether to create the ClusterRole that grants access to the Kubernetes nodes API. This is used by the Launcher or direct Kubernetes runner to get all of the IP addresses associated with the node that is running a particular job. When backends.kubernetes.enabled is true, the ClusterRole is also auto-created if the service base type is NodePort. In most cases, this can be disabled as the node's internal address is sufficient to allow proper functionality. | +| rbac.create | bool | `true` | Whether to create rbac. (also depends on launcher.enabled = true or backends.kubernetes.enabled = true) | +| rbac.serviceAccount | object | `{"annotations":{},"create":true,"labels":{},"name":""}` | The serviceAccount to be associated with rbac (also depends on launcher.enabled = true or backends.kubernetes.enabled = true) | | readinessProbe | object | `{"enabled":true,"failureThreshold":3,"httpGet":{"path":"/__ping__","port":3939},"initialDelaySeconds":3,"periodSeconds":3,"successThreshold":1,"timeoutSeconds":1}` | Used to configure the container's readinessProbe. Only included if enabled = true | | replicas | int | `1` | The number of replica pods to maintain for this service | | resources | object | `{}` | Defines resources for the rstudio-connect container | -| securityContext | object | `{}` | Values to set the `securityContext` for the Connect container. It must include "privileged: true" or "CAP_SYS_ADMIN" when launcher is not enabled. If launcher is enabled, this can be removed with `securityContext: {}` | +| securityContext | object | `{}` | Values to set the `securityContext` for the Connect container. It must include "privileged: true" or "CAP_SYS_ADMIN" when running in local execution mode. If launcher or backends.kubernetes is enabled, this can be removed with `securityContext: {}` | | service.annotations | object | `{}` | Annotations for the service, for example to specify [an internal load balancer](https://kubernetes.io/docs/concepts/services-networking/service/#internal-load-balancer) | | service.clusterIP | string | `""` | The cluster-internal IP to use with `service.type` ClusterIP | | service.loadBalancerIP | string | `""` | The external IP to use with `service.type` LoadBalancer, when supported by the cloud provider | @@ -370,7 +385,7 @@ The Helm `config` values are converted into the `rstudio-connect.gcfg` service c | sharedStorage.annotations | object | `{"helm.sh/resource-policy":"keep"}` | Annotations for the Persistent Volume Claim | | sharedStorage.create | bool | `false` | Whether to create the persistentVolumeClaim for shared storage | | sharedStorage.mount | bool | `false` | Whether the persistentVolumeClaim should be mounted (even if not created) | -| sharedStorage.mountContent | bool | `true` | Whether the persistentVolumeClaim should be mounted to the content pods created by the Launcher | +| sharedStorage.mountContent | bool | `true` | Whether the persistentVolumeClaim should be mounted to content pods. When true, the chart automatically configures DataDirPVCName for both Launcher and backends.kubernetes modes. | | sharedStorage.name | string | `""` | The name of the pvc. By default, computes a value from the release name | | sharedStorage.path | string | `"/var/lib/rstudio-connect"` | The path to mount the sharedStorage claim within the Connect pod | | sharedStorage.requests.storage | string | `"10Gi"` | The volume of storage to request for this persistent volume claim | diff --git a/charts/rstudio-connect/README.md.gotmpl b/charts/rstudio-connect/README.md.gotmpl index d4c15b15e..def6c1acb 100644 --- a/charts/rstudio-connect/README.md.gotmpl +++ b/charts/rstudio-connect/README.md.gotmpl @@ -10,10 +10,14 @@ ## Upgrade guidance +### 0.9.0 + +- Chart version 0.9.0 adds support for the direct Kubernetes runner via `backends.kubernetes.enabled`. See the [upgrade guide](../../examples/connect/upgrade-launcher-to-kubernetes/launcher-to-kubernetes.qmd) for details on transitioning from `launcher.enabled`. + ### 0.8.0 - When upgrading to version 0.8.0 or later, Connect now runs in [Off-Host Execution mode](https://docs.posit.co/connect/admin/getting-started/off-host-install/) by default -- If you desire to run Connect not in Off-Host Execution mode, then set `securityContext.privileged: true` and `launcher.enabled: false` +- If you desire to run Connect in Local Execution mode, then set `securityContext.privileged: true` and `launcher.enabled: false` ## Required configuration diff --git a/charts/rstudio-connect/ci/kubernetes-values.yaml b/charts/rstudio-connect/ci/kubernetes-values.yaml new file mode 100644 index 000000000..614310199 --- /dev/null +++ b/charts/rstudio-connect/ci/kubernetes-values.yaml @@ -0,0 +1,18 @@ +license: + file: + secret: pct-license + secretKey: pct.lic + +launcher: + enabled: false + +backends: + kubernetes: + enabled: true + +# a PVC is required for Connect to run in OHE (now the default) +sharedStorage: + create: true + # normally this should be ReadWriteMany, setting just for CI + accessModes: + - ReadWriteOnce diff --git a/charts/rstudio-connect/templates/NOTES.txt b/charts/rstudio-connect/templates/NOTES.txt index b9de3e760..805b109b9 100644 --- a/charts/rstudio-connect/templates/NOTES.txt +++ b/charts/rstudio-connect/templates/NOTES.txt @@ -34,6 +34,22 @@ Please consider removing this configuration value. {{- /* chart deprecations and misconfiguration warnings */ -}} +{{- if and .Values.launcher.enabled .Values.backends.kubernetes.enabled }} + {{- fail "\n\n`launcher.enabled` and `backends.kubernetes.enabled` cannot both be true"}} +{{- end }} + +{{- if .Values.backends.kubernetes.enabled }} + {{- $configEnabled := dig "Kubernetes" "Enabled" "" .Values.config | printf "%v" | lower }} + {{- if or (eq $configEnabled "false") (eq $configEnabled "0") }} + {{- fail "\n\n`config.Kubernetes.Enabled` is set to false but `backends.kubernetes.enabled` is true. Remove `config.Kubernetes.Enabled` because the chart sets it automatically." }} + {{- end }} + {{- $configNs := dig "Kubernetes" "Namespace" "" .Values.config }} + {{- $chartNs := default $.Release.Namespace .Values.backends.kubernetes.namespace }} + {{- if and $configNs (ne $configNs $chartNs) }} + {{- fail (printf "\n\n`config.Kubernetes.Namespace` (%s) conflicts with `backends.kubernetes.namespace` (%s). Remove `config.Kubernetes.Namespace` because the chart sets it automatically from `backends.kubernetes.namespace`." $configNs $chartNs) }} + {{- end }} +{{- end }} + {{- if and .Values.launcher.useTemplates .Values.launcher.enabled }} {{- range $k,$v := .Values.launcher.launcherKubernetesProfilesConf }} {{- if hasKey $v "job-json-overrides" }} @@ -46,6 +62,10 @@ Please consider removing this configuration value. {{- fail "\n\nWhen launcher is enabled, persistent storage must be provided.\nThis is usually done via a PersistentVolumeClaim (PVC) with `sharedStorage.create=true`, although there are other options."}} {{- end }} +{{- if and .Values.backends.kubernetes.enabled (not .Values.sharedStorage.create) (not .Values.sharedStorage.mount) (not (dig "Kubernetes" "DataDirPVCName" "" .Values.config)) }} + {{- fail "\n\nWhen backends.kubernetes is enabled, persistent storage must be provided.\nThis is usually done via a PersistentVolumeClaim (PVC) with `sharedStorage.create=true`, although there are other options."}} +{{- end }} + {{- if .Values.launcher.contentInitContainer }} {{- fail "\n\n`launcher.contentInitContainer` values are now stored at `launcher.defaultInitContainer`" }} {{- end }} diff --git a/charts/rstudio-connect/templates/_helpers.tpl b/charts/rstudio-connect/templates/_helpers.tpl index 927b333f8..5d1cb8d5c 100644 --- a/charts/rstudio-connect/templates/_helpers.tpl +++ b/charts/rstudio-connect/templates/_helpers.tpl @@ -51,10 +51,19 @@ app.kubernetes.io/name: {{ include "rstudio-connect.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} {{- end }} +{{/* + Whether off-host execution is enabled (via launcher or direct Kubernetes runner). + Returns "true" or empty string (for use in conditionals). +*/}} +{{- define "rstudio-connect.oheEnabled" -}} +{{- if or .Values.launcher.enabled .Values.backends.kubernetes.enabled -}}true{{- end -}} +{{- end -}} + {{/* Generate the configuration - set remote licensing if applicable - set launcher parameters if applicable + - set backends.kubernetes parameters if applicable */}} {{- define "rstudio-connect.config" -}} {{- $configCopy := deepCopy .Values.config }} @@ -81,6 +90,20 @@ app.kubernetes.io/instance: {{ .Release.Name }} {{- $eeDict := dict "ExecutionEnvironments" (dict "ConfigFilePath" "/etc/rstudio-connect/execution-environments/environments.yaml") }} {{- $defaultConfig = merge $defaultConfig $eeDict }} {{- end }} + {{- if .Values.backends.kubernetes.enabled }} + {{- $namespace := default $.Release.Namespace .Values.backends.kubernetes.namespace }} + {{- $kubernetesSettingsDict := dict "Enabled" ("true") "Namespace" ($namespace) }} + {{- if and (or .Values.sharedStorage.create .Values.sharedStorage.mount) .Values.sharedStorage.mountContent }} + {{- $dataDirPVCName := default (print (include "rstudio-connect.fullname" .) "-shared-storage" ) .Values.sharedStorage.name }} + {{- $_ := set $kubernetesSettingsDict "DataDirPVCName" $dataDirPVCName }} + {{- end }} + {{- $_ := set $kubernetesSettingsDict "DefaultResourceJobBase" (default "/etc/rstudio-connect/job.yaml" (dig "Kubernetes" "DefaultResourceJobBase" "" .Values.config)) }} + {{- if .Values.backends.kubernetes.defaultResourceServiceBase }} + {{- $_ := set $kubernetesSettingsDict "DefaultResourceServiceBase" (default "/etc/rstudio-connect/service.yaml" (dig "Kubernetes" "DefaultResourceServiceBase" "" .Values.config)) }} + {{- end }} + {{- $kubernetesDict := dict "Kubernetes" ( $kubernetesSettingsDict ) }} + {{- $defaultConfig = merge $defaultConfig $kubernetesDict }} + {{- end }} {{- /* default licensing configuration */}} {{- if .Values.license.server }} {{- $licenseDict := dict "Licensing" ( dict "LicenseType" ("Remote") ) }} @@ -90,6 +113,15 @@ app.kubernetes.io/instance: {{ .Release.Name }} {{- if .Values.prometheus.enabled }} {{- $defaultConfig = merge $defaultConfig (dict "Metrics" ( dict "PrometheusListen" (print ":" .Values.prometheus.port )))}} {{- end }} + {{- /* remove empty DefaultResource*Base keys so they don't overwrite chart defaults */}} + {{- if hasKey $configCopy "Kubernetes" }} + {{- if not (dig "Kubernetes" "DefaultResourceJobBase" "" $configCopy) }} + {{- $_ := unset (index $configCopy "Kubernetes") "DefaultResourceJobBase" }} + {{- end }} + {{- if not (dig "Kubernetes" "DefaultResourceServiceBase" "" $configCopy) }} + {{- $_ := unset (index $configCopy "Kubernetes") "DefaultResourceServiceBase" }} + {{- end }} + {{- end }} {{- include "rstudio-library.config.gcfg" ( mergeOverwrite $defaultConfig $configCopy ) }} {{- end -}} @@ -155,3 +187,128 @@ app.kubernetes.io/instance: {{ .Release.Name }} {{ $key }}: {{ $value | quote }} {{ end }} {{- end -}} + +{{/* + Build the job resource base for the direct Kubernetes runner. + Starts with the user-provided defaultResourceJobBase (or empty dict), + then adds the init container, content container, and shared volume + when defaultInitContainer.enabled is true. +*/}} +{{- define "rstudio-connect.jobBase" -}} + {{- $jobBase := deepCopy (default (dict) .Values.backends.kubernetes.defaultResourceJobBase) }} + {{- /* ensure top-level fields */}} + {{- if not (hasKey $jobBase "apiVersion") }} + {{- $_ := set $jobBase "apiVersion" "batch/v1" }} + {{- end }} + {{- if not (hasKey $jobBase "kind") }} + {{- $_ := set $jobBase "kind" "Job" }} + {{- else if ne $jobBase.kind "Job" }} + {{- fail (printf "\n\nbackends.kubernetes.defaultResourceJobBase.kind must be \"Job\", got \"%s\"." $jobBase.kind) }} + {{- end }} + {{- if .Values.backends.kubernetes.defaultInitContainer.enabled }} + {{- /* build init container image tag */}} + {{- $defaultVersion := .Values.versionOverride | default $.Chart.AppVersion }} + {{- $tag := .Values.backends.kubernetes.defaultInitContainer.tag | default (printf "%s%s" .Values.backends.kubernetes.defaultInitContainer.tagPrefix $defaultVersion) }} + {{- $image := printf "%s:%s" .Values.backends.kubernetes.defaultInitContainer.repository $tag }} + {{- /* build the init container */}} + {{- $initVolumeMount := dict "name" "rsc-volume" "mountPath" "/mnt/rstudio-connect-runtime/" }} + {{- $initContainer := dict "name" "connect-content-init" "image" $image "volumeMounts" (list $initVolumeMount) }} + {{- if .Values.backends.kubernetes.defaultInitContainer.imagePullPolicy }} + {{- $_ := set $initContainer "imagePullPolicy" .Values.backends.kubernetes.defaultInitContainer.imagePullPolicy }} + {{- end }} + {{- if .Values.backends.kubernetes.defaultInitContainer.resources }} + {{- $_ := set $initContainer "resources" .Values.backends.kubernetes.defaultInitContainer.resources }} + {{- end }} + {{- if .Values.backends.kubernetes.defaultInitContainer.securityContext }} + {{- $_ := set $initContainer "securityContext" .Values.backends.kubernetes.defaultInitContainer.securityContext }} + {{- end }} + {{- /* build the content container */}} + {{- $contentVolumeMount := dict "name" "rsc-volume" "mountPath" "/opt/rstudio-connect" }} + {{- $contentContainer := dict "name" "connect-content" "volumeMounts" (list $contentVolumeMount) }} + {{- /* build the shared volume */}} + {{- $rscVolume := dict "name" "rsc-volume" "emptyDir" (dict) }} + {{- /* ensure spec.template.spec exists */}} + {{- if not (hasKey $jobBase "spec") }} + {{- $_ := set $jobBase "spec" (dict) }} + {{- end }} + {{- if not (hasKey $jobBase.spec "template") }} + {{- $_ := set $jobBase.spec "template" (dict) }} + {{- end }} + {{- if not (hasKey $jobBase.spec.template "spec") }} + {{- $_ := set $jobBase.spec.template "spec" (dict) }} + {{- end }} + {{- $podSpec := $jobBase.spec.template.spec }} + {{- /* add init container if not already present */}} + {{- $existingInits := default list $podSpec.initContainers }} + {{- $hasInit := false }} + {{- range $existingInits }} + {{- if eq .name "connect-content-init" }} + {{- if not (hasKey . "image") }} + {{- $_ := set . "image" $image }} + {{- end }} + {{- $hasInit = true }} + {{- if and (not (hasKey . "imagePullPolicy")) $.Values.backends.kubernetes.defaultInitContainer.imagePullPolicy }} + {{- $_ := set . "imagePullPolicy" $.Values.backends.kubernetes.defaultInitContainer.imagePullPolicy }} + {{- end }} + {{- if and (not (hasKey . "resources")) $.Values.backends.kubernetes.defaultInitContainer.resources }} + {{- $_ := set . "resources" $.Values.backends.kubernetes.defaultInitContainer.resources }} + {{- end }} + {{- if and (not (hasKey . "securityContext")) $.Values.backends.kubernetes.defaultInitContainer.securityContext }} + {{- $_ := set . "securityContext" $.Values.backends.kubernetes.defaultInitContainer.securityContext }} + {{- end }} + {{- $mounts := default list .volumeMounts }} + {{- $hasMount := false }} + {{- range $mounts }} + {{- if and (eq .name "rsc-volume") (eq .mountPath "/mnt/rstudio-connect-runtime/") }} + {{- $hasMount = true }} + {{- else if eq .mountPath "/mnt/rstudio-connect-runtime/" }} + {{- fail "backends.kubernetes.defaultResourceJobBase: connect-content-init container has a volumeMount at /mnt/rstudio-connect-runtime/ using a volume other than rsc-volume. This mountPath is reserved for the runtime volume." }} + {{- end }} + {{- end }} + {{- if not $hasMount }} + {{- $_ := set . "volumeMounts" (append $mounts $initVolumeMount) }} + {{- end }} + {{- end }} + {{- end }} + {{- if not $hasInit }} + {{- $_ := set $podSpec "initContainers" (append $existingInits $initContainer) }} + {{- else }} + {{- $_ := set $podSpec "initContainers" $existingInits }} + {{- end }} + {{- /* add content container if not already present, or ensure volume mount if it is */}} + {{- $existingContainers := default list $podSpec.containers }} + {{- $hasContent := false }} + {{- range $existingContainers }} + {{- if eq .name "connect-content" }} + {{- $hasContent = true }} + {{- $mounts := default list .volumeMounts }} + {{- $hasMount := false }} + {{- range $mounts }} + {{- if and (eq .name "rsc-volume") (eq .mountPath "/opt/rstudio-connect") }} + {{- $hasMount = true }} + {{- else if eq .mountPath "/opt/rstudio-connect" }} + {{- fail "backends.kubernetes.defaultResourceJobBase: connect-content container has a volumeMount at /opt/rstudio-connect using a volume other than rsc-volume. This mountPath is reserved for the runtime volume shared with the init container." }} + {{- end }} + {{- end }} + {{- if not $hasMount }} + {{- $_ := set . "volumeMounts" (append $mounts $contentVolumeMount) }} + {{- end }} + {{- end }} + {{- end }} + {{- if not $hasContent }} + {{- $_ := set $podSpec "containers" (append $existingContainers $contentContainer) }} + {{- end }} + {{- /* add rsc-volume if not already present */}} + {{- $existingVolumes := default list $podSpec.volumes }} + {{- $hasVolume := false }} + {{- range $existingVolumes }} + {{- if eq .name "rsc-volume" }} + {{- $hasVolume = true }} + {{- end }} + {{- end }} + {{- if not $hasVolume }} + {{- $_ := set $podSpec "volumes" (append $existingVolumes $rscVolume) }} + {{- end }} + {{- end }} + {{- toYaml $jobBase }} +{{- end -}} diff --git a/charts/rstudio-connect/templates/configmap-prestart.yaml b/charts/rstudio-connect/templates/configmap-prestart.yaml index 775be6449..87ace3cac 100644 --- a/charts/rstudio-connect/templates/configmap-prestart.yaml +++ b/charts/rstudio-connect/templates/configmap-prestart.yaml @@ -1,4 +1,4 @@ -{{- if .Values.launcher.enabled }} +{{- if include "rstudio-connect.oheEnabled" . }} --- apiVersion: v1 kind: ConfigMap diff --git a/charts/rstudio-connect/templates/configmap.yaml b/charts/rstudio-connect/templates/configmap.yaml index fa672b80f..58bb9e011 100644 --- a/charts/rstudio-connect/templates/configmap.yaml +++ b/charts/rstudio-connect/templates/configmap.yaml @@ -7,6 +7,23 @@ metadata: data: rstudio-connect.gcfg: | {{- include "rstudio-connect.config" . | nindent 4 }} +{{- if .Values.backends.kubernetes.enabled }} + job.yaml: | +{{- include "rstudio-connect.jobBase" . | nindent 4 }} +{{- if .Values.backends.kubernetes.defaultResourceServiceBase }} + {{- $serviceBase := deepCopy .Values.backends.kubernetes.defaultResourceServiceBase }} + {{- if not (hasKey $serviceBase "apiVersion") }} + {{- $_ := set $serviceBase "apiVersion" "v1" }} + {{- end }} + {{- if not (hasKey $serviceBase "kind") }} + {{- $_ := set $serviceBase "kind" "Service" }} + {{- else if ne $serviceBase.kind "Service" }} + {{- fail (printf "\n\nbackends.kubernetes.defaultResourceServiceBase.kind must be \"Service\", got \"%s\"." $serviceBase.kind) }} + {{- end }} + service.yaml: | +{{- $serviceBase | toYaml | nindent 4 }} +{{- end }} +{{- end }} {{- $sessionTemplate := deepCopy .Values.launcher.templateValues }} {{- if .Values.launcher.enabled }} runtime.yaml: | diff --git a/charts/rstudio-connect/templates/deployment.yaml b/charts/rstudio-connect/templates/deployment.yaml index 129a92c52..f6c4dfa3a 100644 --- a/charts/rstudio-connect/templates/deployment.yaml +++ b/charts/rstudio-connect/templates/deployment.yaml @@ -24,7 +24,7 @@ spec: metadata: annotations: checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} - {{- if .Values.launcher.enabled }} + {{- if include "rstudio-connect.oheEnabled" . }} checksum/config-prestart: {{ include (print $.Template.BasePath "/configmap-prestart.yaml") . | sha256sum }} {{- end }} {{- if .Values.prometheus }} @@ -73,7 +73,7 @@ spec: * and also the "Note" callout at the end of this section: * https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/#use-multiple-service-accounts */}} - {{- if and .Values.rbac.create .Values.launcher.enabled }} + {{- if and .Values.rbac.create (include "rstudio-connect.oheEnabled" .) }} {{ $serviceAccountName := default (include "rstudio-connect.fullname" .) .Values.rbac.serviceAccount.name }} serviceAccountName: {{ $serviceAccountName }} {{- else }} @@ -167,7 +167,7 @@ spec: {{- if .Values.command }} command: {{ toYaml .Values.command | indent 10 }} - {{- else if .Values.launcher.enabled }} + {{- else if (include "rstudio-connect.oheEnabled" .) }} command: - tini - -s @@ -176,7 +176,7 @@ spec: {{- if .Values.args }} args: {{ toYaml .Values.args | indent 10 }} - {{- else if .Values.launcher.enabled }} + {{- else if (include "rstudio-connect.oheEnabled" .) }} args: - /scripts/prestart.bash - /usr/local/bin/startup.sh @@ -197,6 +197,16 @@ spec: - name: rstudio-connect-config mountPath: "/etc/rstudio-connect/rstudio-connect.gcfg" subPath: "rstudio-connect.gcfg" + {{- if .Values.backends.kubernetes.enabled }} + - name: rstudio-connect-config + mountPath: {{ default "/etc/rstudio-connect/job.yaml" (dig "Kubernetes" "DefaultResourceJobBase" "" .Values.config) | quote }} + subPath: "job.yaml" + {{- if .Values.backends.kubernetes.defaultResourceServiceBase }} + - name: rstudio-connect-config + mountPath: {{ default "/etc/rstudio-connect/service.yaml" (dig "Kubernetes" "DefaultResourceServiceBase" "" .Values.config) | quote }} + subPath: "service.yaml" + {{- end }} + {{- end }} {{- if .Values.launcher.enabled }} - name: rstudio-connect-config mountPath: "/etc/rstudio-connect/runtime.yaml" @@ -219,6 +229,8 @@ spec: - name: overrides mountPath: "/mnt/job-json-overrides/" {{- end }} + {{- end }} + {{- if include "rstudio-connect.oheEnabled" . }} - name: rstudio-connect-prestart mountPath: "/scripts/" {{- end }} @@ -313,6 +325,8 @@ spec: name: {{ include "rstudio-connect.fullname" . }}-overrides defaultMode: 0644 {{- end }} + {{- end }} + {{- if include "rstudio-connect.oheEnabled" . }} - name: rstudio-connect-prestart configMap: name: {{ include "rstudio-connect.fullname" . }}-prestart diff --git a/charts/rstudio-connect/templates/rbac.yaml b/charts/rstudio-connect/templates/rbac.yaml index bfb253255..7aa2224bb 100644 --- a/charts/rstudio-connect/templates/rbac.yaml +++ b/charts/rstudio-connect/templates/rbac.yaml @@ -1,14 +1,147 @@ -{{- if and (.Values.rbac.create) (.Values.launcher.enabled) }} -{{ $namespace := $.Release.Namespace }} -{{ $targetNamespace := default $.Release.Namespace .Values.launcher.namespace }} -{{ $serviceAccountName := default (include "rstudio-connect.fullname" .) .Values.rbac.serviceAccount.name }} -{{ $serviceAccountCreate := .Values.rbac.serviceAccount.create }} -{{ $serviceAccountAnnotations := .Values.rbac.serviceAccount.annotations }} -{{ $serviceAccountLabels := .Values.rbac.serviceAccount.labels }} -{{ $clusterRoleCreate := .Values.rbac.clusterRoleCreate }} -{{ $rbacValues1 := dict "namespace" $namespace "serviceAccountName" $serviceAccountName "targetNamespace" $targetNamespace }} -{{ $rbacValues2 := dict "serviceAccountCreate" $serviceAccountCreate "serviceAccountAnnotations" $serviceAccountAnnotations "serviceAccountLabels" $serviceAccountLabels }} -{{ $rbacValues3 := dict "clusterRoleCreate" $clusterRoleCreate }} -{{ $rbacValues := merge $rbacValues1 $rbacValues2 $rbacValues3 }} +{{- if and (.Values.rbac.create) (include "rstudio-connect.oheEnabled" .) }} +{{- $namespace := $.Release.Namespace }} +{{- $targetNamespace := $.Release.Namespace }} +{{- if .Values.launcher.enabled }} +{{- $targetNamespace = default $targetNamespace .Values.launcher.namespace }} +{{- else }} +{{- $targetNamespace = default $targetNamespace .Values.backends.kubernetes.namespace }} +{{- end }} +{{- $serviceAccountName := default (include "rstudio-connect.fullname" .) .Values.rbac.serviceAccount.name }} +{{- $serviceAccountCreate := .Values.rbac.serviceAccount.create }} +{{- $serviceAccountAnnotations := .Values.rbac.serviceAccount.annotations }} +{{- $serviceAccountLabels := .Values.rbac.serviceAccount.labels }} +{{- if .Values.launcher.enabled }} +{{- /* Launcher mode: use the shared rstudio-library RBAC template */}} +{{- $clusterRoleCreate := .Values.rbac.clusterRoleCreate }} +{{- $rbacValues1 := dict "namespace" $namespace "serviceAccountName" $serviceAccountName "targetNamespace" $targetNamespace }} +{{- $rbacValues2 := dict "serviceAccountCreate" $serviceAccountCreate "serviceAccountAnnotations" $serviceAccountAnnotations "serviceAccountLabels" $serviceAccountLabels }} +{{- $rbacValues3 := dict "clusterRoleCreate" $clusterRoleCreate }} +{{- $rbacValues := merge $rbacValues1 $rbacValues2 $rbacValues3 }} {{ include "rstudio-library.rbac" $rbacValues }} +{{- else if .Values.backends.kubernetes.enabled }} +{{- /* Direct Kubernetes runner mode: dedicated RBAC with only the permissions Connect needs */}} +{{- $clusterRoleCreate := or .Values.rbac.clusterRoleCreate (eq (dig "spec" "type" "ClusterIP" .Values.backends.kubernetes.defaultResourceServiceBase) "NodePort") }} +{{- if $clusterRoleCreate }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ $serviceAccountName }} +rules: + - apiGroups: + - "" + resources: + - "nodes" + verbs: + - "get" +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ $serviceAccountName }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ $serviceAccountName }} +subjects: + - kind: ServiceAccount + name: {{ $serviceAccountName }} + namespace: {{ $namespace }} +{{- end }} +{{- if $serviceAccountCreate }} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ $serviceAccountName }} + namespace: {{ $namespace }} + {{- with $serviceAccountAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with $serviceAccountLabels }} + labels: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ $serviceAccountName }} + namespace: {{ $targetNamespace }} +rules: + - apiGroups: + - "" + resources: + - "serviceaccounts" + verbs: + - "list" + - apiGroups: + - "" + resources: + - "pods/log" + verbs: + - "get" + - apiGroups: + - "" + resources: + - "pods" + verbs: + - "get" + - "list" + - "watch" + - apiGroups: + - "" + resources: + - "pods/attach" + verbs: + - "create" + - apiGroups: + - "" + resources: + - "pods/exec" + verbs: + - "create" + - apiGroups: + - "" + resources: + - "services" + verbs: + - "create" + - "get" + - "list" + - "watch" + - apiGroups: + - "batch" + resources: + - "jobs" + verbs: + - "create" + - "get" + - "delete" + - "list" + - "watch" + - apiGroups: + - "discovery.k8s.io" + resources: + - "endpointslices" + verbs: + - "list" + - "watch" +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ $serviceAccountName }} + namespace: {{ $targetNamespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ $serviceAccountName }} +subjects: + - kind: ServiceAccount + name: {{ $serviceAccountName }} + namespace: {{ $namespace }} +{{- end }} {{- end }} diff --git a/charts/rstudio-connect/tests/kubernetes-values.yaml b/charts/rstudio-connect/tests/kubernetes-values.yaml new file mode 100644 index 000000000..af97d0a0e --- /dev/null +++ b/charts/rstudio-connect/tests/kubernetes-values.yaml @@ -0,0 +1,10 @@ +launcher: + enabled: false + +backends: + kubernetes: + enabled: true + +sharedStorage: + create: true + mount: true diff --git a/charts/rstudio-connect/tests/kubernetes_test.yaml b/charts/rstudio-connect/tests/kubernetes_test.yaml new file mode 100644 index 000000000..155e6240f --- /dev/null +++ b/charts/rstudio-connect/tests/kubernetes_test.yaml @@ -0,0 +1,1128 @@ +suite: test kubernetes configuration +templates: + - NOTES.txt + - configmap-prestart.yaml + - configmap.yaml + - deployment.yaml + - rbac.yaml +tests: + # Test validation: launcher.enabled and backends.kubernetes.enabled cannot both be true + - it: should fail when both launcher.enabled and backends.kubernetes.enabled are true + template: NOTES.txt + values: + - kubernetes-values.yaml + set: + launcher: + enabled: true + backends: + kubernetes: + enabled: true + asserts: + - failedTemplate: + errorPattern: "launcher.enabled.*and.*backends.kubernetes.enabled.*cannot both be true" + + - it: should fail when config.Kubernetes.Enabled conflicts with backends.kubernetes.enabled + template: NOTES.txt + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + sharedStorage: + create: true + mount: true + config: + Kubernetes: + Enabled: "false" + asserts: + - failedTemplate: + errorPattern: "config.Kubernetes.Enabled.*is set to false.*backends.kubernetes.enabled.*is true" + + - it: should fail when config.Kubernetes.Namespace conflicts with backends.kubernetes.namespace + template: NOTES.txt + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + namespace: correct-ns + sharedStorage: + create: true + mount: true + config: + Kubernetes: + Namespace: wrong-ns + asserts: + - failedTemplate: + errorPattern: "config.Kubernetes.Namespace.*conflicts with.*backends.kubernetes.namespace" + + - it: should fail when backends.kubernetes.enabled without shared storage + template: NOTES.txt + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + sharedStorage: + create: false + mount: false + asserts: + - failedTemplate: + errorPattern: "backends.kubernetes is enabled, persistent storage must be provided" + + - it: should fail when defaultResourceJobBase.kind is not Job + template: deployment.yaml + values: + - kubernetes-values.yaml + set: + backends: + kubernetes: + defaultResourceJobBase: + kind: Service + asserts: + - failedTemplate: + errorPattern: "defaultResourceJobBase.kind must be.*Job.*got.*Service" + + - it: should fail when defaultResourceServiceBase.kind is not Service + template: deployment.yaml + values: + - kubernetes-values.yaml + set: + backends: + kubernetes: + defaultResourceServiceBase: + kind: Job + asserts: + - failedTemplate: + errorPattern: "defaultResourceServiceBase.kind must be.*Service.*got.*Job" + + # Test backends.kubernetes.enabled creates configmap entries for job base + - it: should create job.yaml in configmap when backends.kubernetes.enabled + template: configmap.yaml + values: + - kubernetes-values.yaml + asserts: + - exists: + path: data["job.yaml"] + - matchRegex: + path: data["job.yaml"] + pattern: "apiVersion: batch/v1" + - matchRegex: + path: data["job.yaml"] + pattern: "kind: Job" + + - it: should create service.yaml in configmap when defaultResourceServiceBase is set + template: configmap.yaml + values: + - kubernetes-values.yaml + set: + backends: + kubernetes: + defaultResourceServiceBase: + spec: + type: ClusterIP + asserts: + - exists: + path: data["service.yaml"] + - matchRegex: + path: data["service.yaml"] + pattern: "apiVersion: v1" + - matchRegex: + path: data["service.yaml"] + pattern: "kind: Service" + - matchRegex: + path: data["service.yaml"] + pattern: "type: ClusterIP" + + - it: should not create service.yaml when defaultResourceServiceBase is not set + template: configmap.yaml + values: + - kubernetes-values.yaml + asserts: + - notExists: + path: data["service.yaml"] + + - it: should not create job.yaml when backends.kubernetes.enabled is false + template: configmap.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: false + asserts: + - notExists: + path: data["job.yaml"] + + # Test deployment volumeMounts for kubernetes resource bases + - it: should mount job.yaml when backends.kubernetes.enabled + template: deployment.yaml + values: + - kubernetes-values.yaml + asserts: + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: rstudio-connect-config + mountPath: /etc/rstudio-connect/job.yaml + subPath: job.yaml + + - it: should not mount job.yaml when backends.kubernetes.enabled is false + template: deployment.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: false + asserts: + - notContains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: rstudio-connect-config + mountPath: /etc/rstudio-connect/job.yaml + subPath: job.yaml + + # Test prestart volumeMount is only present when launcher or kubernetes is enabled + - it: should mount prestart scripts when backends.kubernetes.enabled + template: deployment.yaml + values: + - kubernetes-values.yaml + asserts: + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: rstudio-connect-prestart + mountPath: /scripts/ + + - it: should mount prestart scripts when launcher.enabled only + template: deployment.yaml + set: + launcher: + enabled: true + backends: + kubernetes: + enabled: false + sharedStorage: + create: true + mount: true + asserts: + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: rstudio-connect-prestart + mountPath: /scripts/ + + - it: should not mount prestart scripts when neither launcher nor kubernetes is enabled + template: deployment.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: false + asserts: + - notContains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: rstudio-connect-prestart + mountPath: /scripts/ + + # Test custom mount paths for job.yaml and service.yaml + - it: should use custom DefaultResourceJobBase path for job.yaml mount + template: deployment.yaml + values: + - kubernetes-values.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + config: + Kubernetes: + DefaultResourceJobBase: /custom/path/job.yaml + asserts: + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: rstudio-connect-config + mountPath: /custom/path/job.yaml + subPath: job.yaml + + - it: should use custom DefaultResourceServiceBase path for service.yaml mount + template: deployment.yaml + values: + - kubernetes-values.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + defaultResourceServiceBase: + metadata: + labels: + test: value + config: + Kubernetes: + DefaultResourceServiceBase: /custom/path/service.yaml + asserts: + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: rstudio-connect-config + mountPath: /custom/path/service.yaml + subPath: service.yaml + + # Test default mount paths when config.Kubernetes is not set + - it: should use default job.yaml mount path when config.Kubernetes is absent + template: deployment.yaml + values: + - kubernetes-values.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + config: + Kubernetes: null + asserts: + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: rstudio-connect-config + mountPath: /etc/rstudio-connect/job.yaml + subPath: job.yaml + + - it: should fall back to default paths when DefaultResourceJobBase is empty string + template: deployment.yaml + values: + - kubernetes-values.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + config: + Kubernetes: + DefaultResourceJobBase: "" + asserts: + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: rstudio-connect-config + mountPath: /etc/rstudio-connect/job.yaml + subPath: job.yaml + + - it: should write default path in gcfg when DefaultResourceJobBase is empty string + template: configmap.yaml + values: + - kubernetes-values.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + config: + Kubernetes: + DefaultResourceJobBase: "" + asserts: + - matchRegex: + path: data["rstudio-connect.gcfg"] + pattern: "DefaultResourceJobBase = /etc/rstudio-connect/job.yaml" + + - it: should fall back to default paths when DefaultResourceServiceBase is empty string + template: deployment.yaml + values: + - kubernetes-values.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + defaultResourceServiceBase: + metadata: + labels: + test: value + config: + Kubernetes: + DefaultResourceServiceBase: "" + asserts: + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: rstudio-connect-config + mountPath: /etc/rstudio-connect/service.yaml + subPath: service.yaml + + - it: should write default path in gcfg when DefaultResourceServiceBase is empty string + template: configmap.yaml + values: + - kubernetes-values.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + defaultResourceServiceBase: + metadata: + labels: + test: value + config: + Kubernetes: + DefaultResourceServiceBase: "" + asserts: + - matchRegex: + path: data["rstudio-connect.gcfg"] + pattern: "DefaultResourceServiceBase = /etc/rstudio-connect/service.yaml" + + # Test RBAC resources are created when backends.kubernetes.enabled + - it: should create RBAC resources when backends.kubernetes.enabled and rbac.create are true + template: rbac.yaml + values: + - kubernetes-values.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + rbac: + create: true + asserts: + - hasDocuments: + count: 3 # ServiceAccount, Role, RoleBinding + + - it: should render correct Role rules for direct Kubernetes runner + template: rbac.yaml + values: + - kubernetes-values.yaml + set: + rbac: + create: true + documentIndex: 1 # Role + asserts: + - isKind: + of: Role + - contains: + path: rules + content: + apiGroups: [""] + resources: ["serviceaccounts"] + verbs: ["list"] + - contains: + path: rules + content: + apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch"] + - contains: + path: rules + content: + apiGroups: [""] + resources: ["services"] + verbs: ["create", "get", "list", "watch"] + - contains: + path: rules + content: + apiGroups: ["batch"] + resources: ["jobs"] + verbs: ["create", "get", "delete", "list", "watch"] + - contains: + path: rules + content: + apiGroups: ["discovery.k8s.io"] + resources: ["endpointslices"] + verbs: ["list", "watch"] + + - it: should not create RBAC resources when backends.kubernetes.enabled is false and launcher.enabled is false + template: rbac.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: false + rbac: + create: true + asserts: + - hasDocuments: + count: 0 + + # Test kubernetes namespace configuration + - it: should use custom namespace when backends.kubernetes.namespace is specified + template: rbac.yaml + values: + - kubernetes-values.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + namespace: custom-namespace + rbac: + create: true + documentIndex: 1 # Role document + asserts: + - equal: + path: metadata.namespace + value: custom-namespace + + - it: should use release namespace when backends.kubernetes.namespace is not specified + template: rbac.yaml + release: + namespace: test-namespace + values: + - kubernetes-values.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + rbac: + create: true + documentIndex: 1 # Role document + asserts: + - equal: + path: metadata.namespace + value: test-namespace + + # Test serviceAccount configuration with backends.kubernetes.enabled + - it: should set serviceAccountName when backends.kubernetes.enabled and rbac.create are true + template: deployment.yaml + values: + - kubernetes-values.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + rbac: + create: true + serviceAccount: + create: true + name: custom-sa + asserts: + - equal: + path: spec.template.spec.serviceAccountName + value: custom-sa + + # Test that sharedStorage.mountContent propagates to kubernetes config + - it: should configure DataDirPVCName when sharedStorage is enabled and mountContent is true + template: configmap.yaml + release: + name: my-connect + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + sharedStorage: + create: true + mount: true + mountContent: true + documentIndex: 0 + asserts: + - matchRegex: + path: data["rstudio-connect.gcfg"] + pattern: "DataDirPVCName = my-connect-rstudio-connect-shared-storage" + + - it: should not configure DataDirPVCName when sharedStorage.mountContent is false + template: configmap.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + sharedStorage: + create: true + mount: true + mountContent: false + documentIndex: 0 + asserts: + - notMatchRegex: + path: data["rstudio-connect.gcfg"] + pattern: "DataDirPVCName" + + # Test defaultInitContainer fields render on auto-generated init container + - it: should apply imagePullPolicy, resources, and securityContext to init container + template: configmap.yaml + values: + - kubernetes-values.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + defaultInitContainer: + imagePullPolicy: Always + resources: + requests: + cpu: "100m" + memory: "128Mi" + securityContext: + runAsNonRoot: true + asserts: + - matchRegex: + path: data["job.yaml"] + pattern: "imagePullPolicy: Always" + - matchRegex: + path: data["job.yaml"] + pattern: "cpu: 100m" + - matchRegex: + path: data["job.yaml"] + pattern: "runAsNonRoot: true" + + - it: should apply defaultInitContainer fields to user-provided connect-content-init + template: configmap.yaml + values: + - kubernetes-values.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + defaultInitContainer: + imagePullPolicy: Always + resources: + requests: + cpu: "100m" + securityContext: + runAsNonRoot: true + defaultResourceJobBase: + spec: + template: + spec: + initContainers: + - name: connect-content-init + asserts: + - matchRegex: + path: data["job.yaml"] + pattern: "imagePullPolicy: Always" + - matchRegex: + path: data["job.yaml"] + pattern: "cpu: 100m" + - matchRegex: + path: data["job.yaml"] + pattern: "runAsNonRoot: true" + + - it: should preserve user-provided init container fields over defaultInitContainer + template: configmap.yaml + values: + - kubernetes-values.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + defaultInitContainer: + imagePullPolicy: Always + resources: + requests: + cpu: "100m" + securityContext: + runAsNonRoot: true + defaultResourceJobBase: + spec: + template: + spec: + initContainers: + - name: connect-content-init + image: custom-registry/custom-init:v1 + imagePullPolicy: IfNotPresent + resources: + requests: + cpu: "200m" + securityContext: + runAsUser: 1000 + asserts: + - matchRegex: + path: data["job.yaml"] + pattern: "image: custom-registry/custom-init:v1" + - matchRegex: + path: data["job.yaml"] + pattern: "imagePullPolicy: IfNotPresent" + - matchRegex: + path: data["job.yaml"] + pattern: "cpu: 200m" + - matchRegex: + path: data["job.yaml"] + pattern: "runAsUser: 1000" + - notMatchRegex: + path: data["job.yaml"] + pattern: "imagePullPolicy: Always" + - notMatchRegex: + path: data["job.yaml"] + pattern: "image: ghcr.io/rstudio/rstudio-connect-content-init" + + # Test kubernetes config section in rstudio-connect.gcfg + - it: should configure Kubernetes.Enabled in gcfg when backends.kubernetes.enabled is true + template: configmap.yaml + values: + - kubernetes-values.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + namespace: k8s-namespace + asserts: + - matchRegex: + path: data["rstudio-connect.gcfg"] + pattern: "\\[Kubernetes\\]" + - matchRegex: + path: data["rstudio-connect.gcfg"] + pattern: "Enabled = true" + - matchRegex: + path: data["rstudio-connect.gcfg"] + pattern: "Namespace = k8s-namespace" + + - it: should configure DefaultResourceJobBase path when backends.kubernetes.enabled + template: configmap.yaml + values: + - kubernetes-values.yaml + asserts: + - matchRegex: + path: data["rstudio-connect.gcfg"] + pattern: "DefaultResourceJobBase = /etc/rstudio-connect/job.yaml" + + # Test with both launcher disabled and kubernetes enabled + - it: should work correctly with launcher disabled and kubernetes enabled + template: deployment.yaml + values: + - kubernetes-values.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + rbac: + create: true + asserts: + - isKind: + of: Deployment + - equal: + path: spec.template.spec.serviceAccountName + value: RELEASE-NAME-rstudio-connect + + # Test serviceAccount is set correctly when backends.kubernetes.enabled + - it: should generate default serviceAccountName when backends.kubernetes.enabled + template: deployment.yaml + values: + - kubernetes-values.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + rbac: + create: true + serviceAccount: + create: true + asserts: + - equal: + path: spec.template.spec.serviceAccountName + value: RELEASE-NAME-rstudio-connect + + # Test that config includes Kubernetes section + - it: should include Kubernetes config section when enabled + template: configmap.yaml + values: + - kubernetes-values.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + documentIndex: 0 + asserts: + - matchRegex: + path: data["rstudio-connect.gcfg"] + pattern: "\\[Kubernetes\\]" + + # Test auto-generated job base contains init container + - it: should auto-generate job.yaml with init container and correct image + template: configmap.yaml + values: + - kubernetes-values.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + asserts: + - matchRegex: + path: data["job.yaml"] + pattern: "name: connect-content-init" + - matchRegex: + path: data["job.yaml"] + pattern: "image: ghcr.io/rstudio/rstudio-connect-content-init:ubuntu2204-" + - matchRegex: + path: data["job.yaml"] + pattern: "name: connect-content" + - matchRegex: + path: data["job.yaml"] + pattern: "name: rsc-volume" + + - it: should ensure rsc-volume mount on user-provided connect-content-init + template: configmap.yaml + values: + - kubernetes-values.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + defaultResourceJobBase: + spec: + template: + spec: + initContainers: + - name: connect-content-init + image: custom-image:latest + asserts: + - matchRegex: + path: data["job.yaml"] + pattern: "name: connect-content-init" + - matchRegex: + path: data["job.yaml"] + pattern: "/mnt/rstudio-connect-runtime/" + - matchRegex: + path: data["job.yaml"] + pattern: "name: rsc-volume" + + - it: should fail when connect-content has /opt/rstudio-connect mounted from a non-rsc-volume + template: deployment.yaml + values: + - kubernetes-values.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + defaultResourceJobBase: + spec: + template: + spec: + containers: + - name: connect-content + volumeMounts: + - name: other-volume + mountPath: /opt/rstudio-connect + asserts: + - failedTemplate: + errorPattern: "mountPath is reserved for the runtime volume" + + - it: should fail when connect-content-init has /mnt/rstudio-connect-runtime/ mounted from a non-rsc-volume + template: deployment.yaml + values: + - kubernetes-values.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + defaultResourceJobBase: + spec: + template: + spec: + initContainers: + - name: connect-content-init + volumeMounts: + - name: other-volume + mountPath: /mnt/rstudio-connect-runtime/ + asserts: + - failedTemplate: + errorPattern: "mountPath is reserved for the runtime volume" + + - it: should not include init container when defaultInitContainer.enabled is false + template: configmap.yaml + values: + - kubernetes-values.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + defaultInitContainer: + enabled: false + asserts: + - exists: + path: data["job.yaml"] + - notMatchRegex: + path: data["job.yaml"] + pattern: "connect-content-init" + + # Test 1: NodePort triggers ClusterRole auto-create + # Template order: ClusterRole(0), ClusterRoleBinding(1), SA(2), Role(3), RoleBinding(4) + - it: should create ClusterRole with nodes/get when service type is NodePort + template: rbac.yaml + values: + - kubernetes-values.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + defaultResourceServiceBase: + spec: + type: NodePort + rbac: + create: true + clusterRoleCreate: false + asserts: + - isKind: + of: ClusterRole + documentIndex: 0 + - isKind: + of: ClusterRoleBinding + documentIndex: 1 + - equal: + path: rules[0].resources + value: ["nodes"] + documentIndex: 0 + - equal: + path: rules[0].verbs + value: ["get"] + documentIndex: 0 + + # Test 2: Explicit rbac.clusterRoleCreate=true creates ClusterRole even without NodePort + # Template order: ClusterRole(0), ClusterRoleBinding(1), SA(2), Role(3), RoleBinding(4) + - it: should create ClusterRole when rbac.clusterRoleCreate is true regardless of service type + template: rbac.yaml + values: + - kubernetes-values.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + rbac: + create: true + clusterRoleCreate: true + asserts: + - isKind: + of: ClusterRole + documentIndex: 0 + - isKind: + of: ClusterRoleBinding + documentIndex: 1 + + # Test 3: rbac.serviceAccount.create=false skips SA but Role/RoleBinding reference configured name + # Renders: Role(0), RoleBinding(1) + - it: should not create ServiceAccount when rbac.serviceAccount.create is false + template: rbac.yaml + values: + - kubernetes-values.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + rbac: + create: true + clusterRoleCreate: false + serviceAccount: + create: false + name: custom-sa + asserts: + - hasDocuments: + count: 2 + - isKind: + of: Role + documentIndex: 0 + - isKind: + of: RoleBinding + documentIndex: 1 + - equal: + path: subjects[0].name + value: custom-sa + documentIndex: 1 + + # Test 4: rbac.create=false with direct runner enabled renders no RBAC docs + - it: should not create any RBAC resources when rbac.create is false + template: rbac.yaml + values: + - kubernetes-values.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + rbac: + create: false + asserts: + - hasDocuments: + count: 0 + + # Test 5: configmap-prestart gating + - it: should create configmap-prestart when backends.kubernetes.enabled + template: configmap-prestart.yaml + values: + - kubernetes-values.yaml + asserts: + - hasDocuments: + count: 1 + - isKind: + of: ConfigMap + + - it: should create configmap-prestart when launcher.enabled + template: configmap-prestart.yaml + set: + launcher: + enabled: true + backends: + kubernetes: + enabled: false + sharedStorage: + create: true + mount: true + asserts: + - hasDocuments: + count: 1 + + - it: should not create configmap-prestart when neither is enabled + template: configmap-prestart.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: false + asserts: + - hasDocuments: + count: 0 + + # Test 6: checksum/config-prestart annotation gating + - it: should include checksum/config-prestart annotation when backends.kubernetes.enabled + template: deployment.yaml + values: + - kubernetes-values.yaml + asserts: + - exists: + path: spec.template.metadata.annotations["checksum/config-prestart"] + + - it: should include checksum/config-prestart annotation when launcher.enabled only + template: deployment.yaml + set: + launcher: + enabled: true + backends: + kubernetes: + enabled: false + sharedStorage: + create: true + mount: true + asserts: + - exists: + path: spec.template.metadata.annotations["checksum/config-prestart"] + + - it: should not include checksum/config-prestart annotation when neither is enabled + template: deployment.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: false + asserts: + - notExists: + path: spec.template.metadata.annotations["checksum/config-prestart"] + + # Test 7: Init image tag precedence — explicit tag overrides tagPrefix+appVersion + - it: should use explicit defaultInitContainer.tag when set + template: configmap.yaml + values: + - kubernetes-values.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + defaultInitContainer: + tag: "custom-tag-1.2.3" + asserts: + - matchRegex: + path: data["job.yaml"] + pattern: "image: ghcr.io/rstudio/rstudio-connect-content-init:custom-tag-1.2.3" + + - it: should use tagPrefix plus appVersion when tag is not set + template: configmap.yaml + values: + - kubernetes-values.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + defaultInitContainer: + tagPrefix: "jammy-" + asserts: + - matchRegex: + path: data["job.yaml"] + pattern: "image: ghcr.io/rstudio/rstudio-connect-content-init:jammy-\\d+\\.\\d+\\.\\d+" + + - it: should preserve user-provided sidecar and nodeSelector in job base + template: configmap.yaml + values: + - kubernetes-values.yaml + set: + launcher: + enabled: false + backends: + kubernetes: + enabled: true + defaultResourceJobBase: + spec: + template: + spec: + nodeSelector: + workload: content + containers: + - name: logging-sidecar + image: busybox + asserts: + - matchRegex: + path: data["job.yaml"] + pattern: "name: logging-sidecar" + - matchRegex: + path: data["job.yaml"] + pattern: "workload: content" + - matchRegex: + path: data["job.yaml"] + pattern: "name: connect-content-init" + - matchRegex: + path: data["job.yaml"] + pattern: "name: connect-content" diff --git a/charts/rstudio-connect/values.yaml b/charts/rstudio-connect/values.yaml index 3f7a22717..ab957e971 100644 --- a/charts/rstudio-connect/values.yaml +++ b/charts/rstudio-connect/values.yaml @@ -15,7 +15,7 @@ sharedStorage: mount: false # -- The path to mount the sharedStorage claim within the Connect pod path: /var/lib/rstudio-connect - # -- Whether the persistentVolumeClaim should be mounted to the content pods created by the Launcher + # -- Whether the persistentVolumeClaim should be mounted to content pods. When true, the chart automatically configures DataDirPVCName for both Launcher and backends.kubernetes modes. mountContent: true # -- The type of storage to use. Must allow ReadWriteMany storageClassName: false @@ -36,13 +36,14 @@ sharedStorage: subPath: "" rbac: - # -- Whether to create rbac. (also depends on launcher.enabled = true) + # -- Whether to create rbac. (also depends on launcher.enabled = true or backends.kubernetes.enabled = true) create: true # -- Whether to create the ClusterRole that grants access to the Kubernetes nodes API. This is used by the Launcher - # to get all of the IP addresses associated with the node that is running a particular job. In most cases, this can - # be disabled as the node's internal address is sufficient to allow proper functionality. + # or direct Kubernetes runner to get all of the IP addresses associated with the node that is running a particular job. + # When backends.kubernetes.enabled is true, the ClusterRole is also auto-created if the service base type is NodePort. + # In most cases, this can be disabled as the node's internal address is sufficient to allow proper functionality. clusterRoleCreate: false - # -- The serviceAccount to be associated with rbac (also depends on launcher.enabled = true) + # -- The serviceAccount to be associated with rbac (also depends on launcher.enabled = true or backends.kubernetes.enabled = true) serviceAccount: create: true name: "" @@ -161,7 +162,7 @@ license: secret: false # -- Values to set the `securityContext` for the Connect container. It must include "privileged: true" or "CAP_SYS_ADMIN" when -# launcher is not enabled. If launcher is enabled, this can be removed with `securityContext: {}` +# running in local execution mode. If launcher or backends.kubernetes is enabled, this can be removed with `securityContext: {}` securityContext: {} prometheus: @@ -299,6 +300,48 @@ chronicleAgent: # ([reference](https://docs.posit.co/chronicle/appendix/library/advanced-agent.html#environment)) agentEnvironment: "" +backends: + kubernetes: + # -- Whether to enable off-host execution for running content-jobs in remote Kubernetes pods. + enabled: false + # -- The namespace to launch connect-content jobs into. Uses the Release namespace by default + namespace: "" + defaultInitContainer: + # -- Whether to enable the defaultInitContainer. If disabled, you must ensure + # that the session components are available another way. Changing the default + # setting is an advanced option and not recommended. For more information on + # how Connect uses the session init container refer to + # https://docs.posit.co/connect/admin/appendix/off-host/arch-overview/#runtime-init-container + enabled: true + # -- The repository to use for the Content InitContainer image + repository: ghcr.io/rstudio/rstudio-connect-content-init + # -- A tag prefix for the Content InitContainer image (common selections: jammy-, ubuntu2204-). Only used if tag is not defined + tagPrefix: ubuntu2204- + # -- Overrides the image tag whose default is the chart appVersion. + tag: "" + # -- The imagePullPolicy for the default initContainer + imagePullPolicy: "" + # -- Optional resources for the default initContainer + resources: {} + # -- The securityContext for the default initContainer + securityContext: {} + # -- defaultResourceJobBase is an optional Kubernetes Job definition used as the base + # when launching content jobs. The chart automatically adds the init container and + # runtime volume when backends.kubernetes.defaultInitContainer.enabled is true. + # Only set this if you need to customize the job (e.g., add sidecars, node selectors, tolerations). + # https://kubernetes.io/docs/concepts/workloads/controllers/job/ + defaultResourceJobBase: {} + # -- defaultResourceServiceBase contains the Kubernetes Service definition which is used as an overlay "base" when creating a content job's + # Service in Kubernetes. Conceptually this is similar to a Kustomize base. Connect then applies any required Service configuration + # on-top of the overlay base to produce a final Service definition. + # https://kubernetes.io/docs/concepts/services-networking/service/ + # https://kubernetes.io/docs/tasks/manage-kubernetes-objects/kustomization/#bases-and-overlays + defaultResourceServiceBase: {} + # apiVersion: v1 + # kind: Service + # spec: + # type: NodePort + launcher: # -- Whether to enable the launcher enabled: true @@ -471,8 +514,9 @@ config: InitTimeout: 5m Logging: ServiceLog: STDOUT - ServiceLogLevel: INFO # INFO, WARNING or ERROR - ServiceLogFormat: TEXT # TEXT or JSON + ServiceLogLevel: INFO # INFO, WARNING or ERROR + ServiceLogFormat: TEXT # TEXT or JSON AccessLog: STDOUT AccessLogFormat: COMMON # COMMON, COMBINED, or JSON Launcher: {} + Kubernetes: {} diff --git a/examples/connect/upgrade-launcher-to-kubernetes/launcher-to-kubernetes.qmd b/examples/connect/upgrade-launcher-to-kubernetes/launcher-to-kubernetes.qmd new file mode 100644 index 000000000..621576a61 --- /dev/null +++ b/examples/connect/upgrade-launcher-to-kubernetes/launcher-to-kubernetes.qmd @@ -0,0 +1,340 @@ +--- +category: "Upgrade" +--- + +# Upgrading to the direct Kubernetes runner + +This guide covers upgrading Posit Connect helm values from the Launcher (`launcher.enabled: true`) to the direct Kubernetes runner (`backends.kubernetes.enabled: true`). + +The direct Kubernetes runner manages content Jobs and Services using standard Kubernetes manifests, replacing the Launcher's template system. + +:::{.callout-important} +The direct Kubernetes runner requires Posit Connect version 2026.04.0 or later. +::: + +## Minimal upgrade + +If you haven't customized `launcher.templateValues`, the minimal upgrade is a single change: set `launcher.enabled: false` and `backends.kubernetes.enabled: true`. + +| Launcher setting | Action | +| --- | --- | +| `launcher.enabled` | Set to `false` and set `backends.kubernetes.enabled: true` | +| `config.Launcher` | Move to `config.Kubernetes` (optional — only if you customized it) | +| `launcher.namespace` | Move to `backends.kubernetes.namespace` (optional — only if you customized it) | +| `launcher.defaultInitContainer.*` | Move to `backends.kubernetes.defaultInitContainer.*` (optional — same structure) | + +```{.yaml include="rstudio-connect-minimal-upgrade.yaml" filename="values.yaml"} +``` + +## Important notes + +:::{.callout-important} +The direct Kubernetes runner requires content service accounts to have the `connect.posit.co/service-account=true` label. Apply this label to any service account that content should be allowed to use, including the default and any custom service accounts set via `serviceAccountName` in the job base: + +```bash +kubectl label sa connect.posit.co/service-account=true -n +``` +::: + +**Init container** -- The chart auto-generates a `connect-content-init` init container. You do not need to configure it unless you want to customize the init container image or behavior via `backends.kubernetes.defaultInitContainer`. Custom init containers run alongside the auto-generated one. + +**Shared storage** -- `sharedStorage.*` values work identically for both modes. No changes needed. + +## Values with alternatives + +These values do not have a direct equivalent but have alternative approaches in the direct Kubernetes runner. + +| Launcher value | Alternative | +| --- | --- | +| `launcher.customRuntimeYaml` | Use `executionEnvironments` instead. See [Execution environments](/charts/rstudio-connect/README.md#execution-environments). | +| `launcher.additionalRuntimeImages` | Use `executionEnvironments` instead. | +| `launcher.launcherKubernetesProfilesConf` | The direct Kubernetes runner does not use profiles. Similar customization can be achieved using `defaultResourceJobBase`. | + +## Upgrading templateValues customizations + +If you customized `launcher.templateValues`, those settings move into `backends.kubernetes.defaultResourceJobBase` and `backends.kubernetes.defaultResourceServiceBase`. These are standard Kubernetes [Job](https://kubernetes.io/docs/concepts/workloads/controllers/job/) and [Service](https://kubernetes.io/docs/concepts/services-networking/service/) specs, so Kubernetes documentation applies directly. + +Any field supported by the Kubernetes Job or Service spec can be set in the resource base, not just the fields shown below. + +### Volumes and volume mounts + +Note that `volumeMounts` moves from a pod-level setting to the `connect-content` container spec. + +```yaml +# Before +launcher: + templateValues: + pod: + volumes: + - name: extra-config + configMap: + name: content-config + volumeMounts: + - name: extra-config + mountPath: /etc/content-config + readOnly: true + +# After +backends: + kubernetes: + defaultResourceJobBase: + spec: + template: + spec: + volumes: + - name: extra-config + configMap: + name: content-config + containers: + - name: connect-content + volumeMounts: + - name: extra-config + mountPath: /etc/content-config + readOnly: true +``` + +### Init containers and sidecar containers + +Custom `initContainers` run alongside the chart's auto-generated `connect-content-init`. `extraContainers` becomes a named entry in `containers`. + +```yaml +# Before +launcher: + templateValues: + pod: + initContainers: + - name: wait-for-db + image: busybox:latest + command: ["sh", "-c", "echo waiting"] + extraContainers: + - name: log-forwarder + image: fluent/fluent-bit:latest + +# After +backends: + kubernetes: + defaultResourceJobBase: + spec: + template: + spec: + initContainers: + - name: wait-for-db + image: busybox:latest + command: ["sh", "-c", "echo waiting"] + containers: + - name: log-forwarder + image: fluent/fluent-bit:latest +``` + +### Environment variables and resources + +```yaml +# Before +launcher: + templateValues: + pod: + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: app-secrets + key: database-url + resources: + requests: + memory: "1Gi" + cpu: "500m" + limits: + memory: "4Gi" + cpu: "2" + +# After +backends: + kubernetes: + defaultResourceJobBase: + spec: + template: + spec: + containers: + - name: connect-content + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: app-secrets + key: database-url + resources: + requests: + memory: "1Gi" + cpu: "500m" + limits: + memory: "4Gi" + cpu: "2" +``` + +### Node scheduling (nodeSelector, tolerations, affinity) + +```yaml +# Before +launcher: + templateValues: + pod: + nodeSelector: + workload: content + tolerations: + - key: "dedicated" + operator: "Equal" + value: "content" + effect: "NoSchedule" + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchLabels: + app.example.com/content: "true" + topologyKey: kubernetes.io/hostname + +# After +backends: + kubernetes: + defaultResourceJobBase: + spec: + template: + spec: + nodeSelector: + workload: content + tolerations: + - key: "dedicated" + operator: "Equal" + value: "content" + effect: "NoSchedule" + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchLabels: + app.example.com/content: "true" + topologyKey: kubernetes.io/hostname +``` + +### Labels and annotations + +```yaml +# Before +launcher: + templateValues: + job: + labels: + app.example.com/managed-by: connect + annotations: + app.example.com/team: data-science + pod: + labels: + app.example.com/content: "true" + annotations: + app.example.com/tier: compute + +# After +backends: + kubernetes: + defaultResourceJobBase: + metadata: + labels: + app.example.com/managed-by: connect + annotations: + app.example.com/team: data-science + spec: + template: + metadata: + labels: + app.example.com/content: "true" + annotations: + app.example.com/tier: compute +``` + +### Service account and image pull secrets + +```yaml +# Before +launcher: + templateValues: + pod: + serviceAccountName: connect-content-sa + imagePullSecrets: + - name: registry-credentials + +# After +backends: + kubernetes: + defaultResourceJobBase: + spec: + template: + spec: + serviceAccountName: connect-content-sa + imagePullSecrets: + - name: registry-credentials +``` + +### Pod security context + +```yaml +# Before +launcher: + templateValues: + pod: + securityContext: + runAsNonRoot: true + +# After +backends: + kubernetes: + defaultResourceJobBase: + spec: + template: + spec: + securityContext: + runAsNonRoot: true +``` + +### Service configuration + +```yaml +# Before +launcher: + templateValues: + service: + labels: + app.example.com/service: content + annotations: + app.example.com/tier: compute + type: ClusterIP + +# After +backends: + kubernetes: + defaultResourceServiceBase: + metadata: + labels: + app.example.com/service: content + annotations: + app.example.com/tier: compute + spec: + type: ClusterIP +``` + +### Complete before/after reference + +For a complete example with all fields, see the full before/after files: + +**Before** (Launcher-based): + +```{.yaml include="rstudio-connect-customized-launcher.yaml" filename="values.yaml (launcher)"} +``` + +**After** (direct Kubernetes runner): + +```{.yaml include="rstudio-connect-customized-upgrade.yaml" filename="values.yaml (kubernetes)"} +``` diff --git a/examples/connect/upgrade-launcher-to-kubernetes/rstudio-connect-customized-launcher.yaml b/examples/connect/upgrade-launcher-to-kubernetes/rstudio-connect-customized-launcher.yaml new file mode 100644 index 000000000..a949c7597 --- /dev/null +++ b/examples/connect/upgrade-launcher-to-kubernetes/rstudio-connect-customized-launcher.yaml @@ -0,0 +1,102 @@ +# Original Launcher-based values with templateValues customizations. +# Each field is annotated with its equivalent path after upgrading to the direct Kubernetes runner. +# See rstudio-connect-customized-upgrade.yaml for the upgraded version. + +sharedStorage: + create: true + mount: true + storageClassName: nfs-sc-rwx + requests: + storage: 100G + +launcher: + enabled: true + templateValues: + job: + labels: # Moves to: backends.kubernetes.defaultResourceJobBase.metadata.labels + app.example.com/managed-by: connect + annotations: # Moves to: backends.kubernetes.defaultResourceJobBase.metadata.annotations + app.example.com/team: data-science + pod: + labels: # Moves to: backends.kubernetes.defaultResourceJobBase.spec.template.metadata.labels + app.example.com/content: "true" + annotations: # Moves to: backends.kubernetes.defaultResourceJobBase.spec.template.metadata.annotations + app.example.com/tier: compute + nodeSelector: # Moves to: backends.kubernetes.defaultResourceJobBase.spec.template.spec.nodeSelector + workload: content + tolerations: # Moves to: backends.kubernetes.defaultResourceJobBase.spec.template.spec.tolerations + - key: "dedicated" + operator: "Equal" + value: "content" + effect: "NoSchedule" + affinity: # Moves to: backends.kubernetes.defaultResourceJobBase.spec.template.spec.affinity + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchLabels: + app.example.com/content: "true" + topologyKey: kubernetes.io/hostname + securityContext: # Moves to: backends.kubernetes.defaultResourceJobBase.spec.template.spec.securityContext + runAsUser: 999 + runAsGroup: 999 + fsGroup: 999 + # defaultSecurityContext is also supported and maps to the same pod securityContext field in direct mode. + # In the direct runner, runAsUser/runAsGroup/supplementalGroups are managed by Connect and are not user-overridable. + serviceAccountName: connect-content-sa # Moves to: backends.kubernetes.defaultResourceJobBase.spec.template.spec.serviceAccountName + imagePullSecrets: # Moves to: backends.kubernetes.defaultResourceJobBase.spec.template.spec.imagePullSecrets + - name: registry-credentials + priorityClassName: "" # Moves to: backends.kubernetes.defaultResourceJobBase.spec.template.spec.priorityClassName + hostAliases: # Moves to: backends.kubernetes.defaultResourceJobBase.spec.template.spec.hostAliases + - ip: "10.0.0.50" + hostnames: + - "internal-api.example.com" + volumes: # Moves to: backends.kubernetes.defaultResourceJobBase.spec.template.spec.volumes + - name: extra-config + configMap: + name: content-config + initContainers: # Moves to: backends.kubernetes.defaultResourceJobBase.spec.template.spec.initContainers + - name: wait-for-db + image: busybox:latest + command: ["sh", "-c", "echo waiting for dependencies"] + extraContainers: # Moves to: backends.kubernetes.defaultResourceJobBase.spec.template.spec.containers (as a named container) + - name: log-forwarder + image: fluent/fluent-bit:latest + resources: + requests: + cpu: "50m" + memory: "64Mi" + env: # Moves to: backends.kubernetes.defaultResourceJobBase.spec.template.spec.containers[name=connect-content].env + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: app-secrets + key: database-url + resources: # Moves to: backends.kubernetes.defaultResourceJobBase.spec.template.spec.containers[name=connect-content].resources + requests: + memory: "1Gi" + cpu: "500m" + limits: + memory: "4Gi" + cpu: "2" + imagePullPolicy: IfNotPresent # Moves to: backends.kubernetes.defaultResourceJobBase.spec.template.spec.containers[name=connect-content].imagePullPolicy + containerSecurityContext: # Moves to: backends.kubernetes.defaultResourceJobBase.spec.template.spec.containers[name=connect-content].securityContext (except runAsUser/runAsGroup) + allowPrivilegeEscalation: false + command: [] # No direct equivalent override (connect-content command is managed by Connect) + volumeMounts: # Moves to: backends.kubernetes.defaultResourceJobBase.spec.template.spec.containers[name=connect-content].volumeMounts + - name: extra-config + mountPath: /etc/content-config + readOnly: true + service: + labels: # Moves to: backends.kubernetes.defaultResourceServiceBase.metadata.labels + app.example.com/service: content + annotations: # Moves to: backends.kubernetes.defaultResourceServiceBase.metadata.annotations + app.example.com/tier: compute + type: ClusterIP # Moves to: backends.kubernetes.defaultResourceServiceBase.spec.type + +config: + # ... your existing config ... + + Launcher: # Moves to: config.Kubernetes + DataDirPVCName: my-release-rstudio-connect-shared-storage # Your actual PVC name diff --git a/examples/connect/upgrade-launcher-to-kubernetes/rstudio-connect-customized-upgrade.yaml b/examples/connect/upgrade-launcher-to-kubernetes/rstudio-connect-customized-upgrade.yaml new file mode 100644 index 000000000..6f9ce66a0 --- /dev/null +++ b/examples/connect/upgrade-launcher-to-kubernetes/rstudio-connect-customized-upgrade.yaml @@ -0,0 +1,116 @@ +# Upgraded values: direct Kubernetes runner with templateValues customizations. +# Each field is annotated with its original launcher.templateValues path. +# See rstudio-connect-customized-launcher.yaml for the original Launcher-based version. + +sharedStorage: + create: true + mount: true + storageClassName: nfs-sc-rwx # TODO: Change to your RWX StorageClass + requests: + storage: 100G + +launcher: + enabled: false + +backends: + kubernetes: + enabled: true + defaultResourceJobBase: + metadata: + labels: # Was: templateValues.job.labels + app.example.com/managed-by: connect + annotations: # Was: templateValues.job.annotations + app.example.com/team: data-science + spec: + template: + metadata: + labels: # Was: templateValues.pod.labels + app.example.com/content: "true" + annotations: # Was: templateValues.pod.annotations + app.example.com/tier: compute + spec: + nodeSelector: # Was: templateValues.pod.nodeSelector + workload: content + tolerations: # Was: templateValues.pod.tolerations + - key: "dedicated" + operator: "Equal" + value: "content" + effect: "NoSchedule" + affinity: # Was: templateValues.pod.affinity + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchLabels: + app.example.com/content: "true" + topologyKey: kubernetes.io/hostname + securityContext: # Was: templateValues.pod.securityContext and pod.defaultSecurityContext + # Direct runner manages runAsUser/runAsGroup/supplementalGroups from Connect RunAs config. + # Keep only user-overridable pod securityContext fields here. + fsGroup: 999 + serviceAccountName: connect-content-sa # Was: templateValues.pod.serviceAccountName + # NOTE: SA must be labeled: kubectl label sa connect-content-sa connect.posit.co/service-account=true + imagePullSecrets: # Was: templateValues.pod.imagePullSecrets + - name: registry-credentials # TODO: Change to your image pull secret + priorityClassName: "" # Was: templateValues.pod.priorityClassName + hostAliases: # Was: templateValues.pod.hostAliases + - ip: "10.0.0.50" + hostnames: + - "internal-api.example.com" + volumes: # Was: templateValues.pod.volumes + - name: extra-config + configMap: + name: content-config + initContainers: # Was: templateValues.pod.initContainers + # The chart auto-generates connect-content-init; custom init containers run alongside it. + - name: wait-for-db + image: busybox:latest + command: ["sh", "-c", "echo waiting for dependencies"] + containers: + - name: log-forwarder # Was: templateValues.pod.extraContainers + image: fluent/fluent-bit:latest + resources: + requests: + cpu: "50m" + memory: "64Mi" + - name: connect-content # Must use this exact name for the content container + # templateValues.pod.command has no direct equivalent; connect-content command is runner-managed. + env: # Was: templateValues.pod.env + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: app-secrets + key: database-url + resources: # Was: templateValues.pod.resources + requests: + memory: "1Gi" + cpu: "500m" + limits: + memory: "4Gi" + cpu: "2" + imagePullPolicy: IfNotPresent # Was: templateValues.pod.imagePullPolicy + securityContext: # Was: templateValues.pod.containerSecurityContext + # runAsUser/runAsGroup are managed by Connect and cannot be overridden for connect-content. + allowPrivilegeEscalation: false + volumeMounts: # Was: templateValues.pod.volumeMounts + - name: extra-config + mountPath: /etc/content-config + readOnly: true + + defaultResourceServiceBase: + metadata: + labels: # Was: templateValues.service.labels + app.example.com/service: content + annotations: # Was: templateValues.service.annotations + app.example.com/tier: compute + spec: + type: ClusterIP # Was: templateValues.service.type + +config: + # ... your existing config ... + + Kubernetes: # Was: config.Launcher + # DataDirPVCName must match your existing PVC name (typically -rstudio-connect-shared-storage). + # Alternatively, remove this and set sharedStorage.mountContent: true to let the chart set it automatically. + DataDirPVCName: my-release-rstudio-connect-shared-storage # TODO: Change to your actual PVC name diff --git a/examples/connect/upgrade-launcher-to-kubernetes/rstudio-connect-minimal-upgrade.yaml b/examples/connect/upgrade-launcher-to-kubernetes/rstudio-connect-minimal-upgrade.yaml new file mode 100644 index 000000000..e3d7c1357 --- /dev/null +++ b/examples/connect/upgrade-launcher-to-kubernetes/rstudio-connect-minimal-upgrade.yaml @@ -0,0 +1,23 @@ +# Minimal upgrade from Launcher to direct Kubernetes runner. +# One required change if you haven't customized templateValues. + +# sharedStorage values remain unchanged +sharedStorage: + create: true + mount: true + storageClassName: nfs-sc-rwx # TODO: Change to your RWX StorageClass + requests: + storage: 100G + +# Disable the Launcher +launcher: + enabled: false + +# Enable the direct Kubernetes runner +backends: + kubernetes: + enabled: true + +# Optional: if you had custom config.Launcher settings, move them to config.Kubernetes. +# DataDirPVCName is auto-configured when sharedStorage.create or sharedStorage.mount is true +# and sharedStorage.mountContent is true.