Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
a83d555
feat: add dynamicLabels to session pod config
ian-flores Mar 4, 2026
7c9de7c
Address review findings (job 704)
ian-flores Mar 4, 2026
c70f317
fix: add doc comments and regenerate CRDs with validation markers
ian-flores Mar 4, 2026
f8c244d
Merge remote-tracking branch 'origin/main' into dynamic-labels
ian-flores Mar 5, 2026
588b8e6
chore: sync Helm chart with CRDs and remove tsc-cache files
ian-flores Mar 5, 2026
044378c
Merge remote-tracking branch 'origin/main' into dynamic-labels
ian-flores Mar 11, 2026
f5a19e0
chore: sync crdapply bases with dynamicLabels schema changes
ian-flores Mar 11, 2026
f430aa6
Address review findings (job 705)
ian-flores Mar 11, 2026
2ba4c20
Address review findings (job 706)
ian-flores Mar 11, 2026
74dde7e
Address review findings (job 869)
ian-flores Mar 11, 2026
ebdccff
Address review findings (job 725)
ian-flores Mar 11, 2026
fddb987
Address review findings (job 871)
ian-flores Mar 11, 2026
5d75dbc
Address review findings (job 867)
ian-flores Mar 11, 2026
71e1f44
Address review findings (job 873)
ian-flores Mar 11, 2026
c269982
Address review findings (job 868)
ian-flores Mar 11, 2026
dbbcc13
Address review findings (job 875)
ian-flores Mar 11, 2026
f555a50
Address review findings (job 876)
ian-flores Mar 11, 2026
34f5f2f
Address review findings (job 878)
ian-flores Mar 11, 2026
fd690b3
Address review findings (job 879)
ian-flores Mar 11, 2026
24c2a48
Address review findings (job 880)
ian-flores Mar 11, 2026
f23a762
Address review findings (job 882)
ian-flores Mar 11, 2026
cefbeb1
Address review findings (job 883)
ian-flores Mar 11, 2026
8ffad35
Address review findings (job 885)
ian-flores Mar 11, 2026
a2aa1db
Address review findings (job 886)
ian-flores Mar 11, 2026
bbee88e
feat: expose sessionConfig on Site CRD for dynamic labels support
ian-flores Mar 11, 2026
1d318c6
Address review findings (job 887)
ian-flores Mar 11, 2026
6153844
Address review findings (job 889)
ian-flores Mar 11, 2026
f71de02
chore: sync Helm chart CRDs after sessionConfig addition
ian-flores Mar 11, 2026
01985cd
Address review findings (job 888)
ian-flores Mar 11, 2026
877a062
Address review findings (job 890)
ian-flores Mar 11, 2026
a9194fc
Address review findings (job 892)
ian-flores Mar 11, 2026
ddae143
Address review findings (job 895)
ian-flores Mar 11, 2026
d9d69a4
Address review findings (job 897)
ian-flores Mar 11, 2026
4c4e639
Address review findings (job 899)
ian-flores Mar 11, 2026
f60a851
Address review findings (job 898)
ian-flores Mar 11, 2026
b90fe2d
Address review findings (job 900)
ian-flores Mar 11, 2026
1b1b0a8
Address review findings (job 904)
ian-flores Mar 11, 2026
5945237
Address review findings (job 905)
ian-flores Mar 11, 2026
ac85502
Address review findings (job 907)
ian-flores Mar 11, 2026
bab9bc2
Address review findings (job 908)
ian-flores Mar 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions api/core/v1beta1/site_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,11 @@ type InternalWorkbenchSpec struct {
// Keys are config file names (e.g., "rsession.conf", "repos.conf").
// +optional
AdditionalSessionConfigs map[string]string `json:"additionalSessionConfigs,omitempty"`

// SessionConfig allows configuring Workbench session pods, including dynamic labels,
// annotations, tolerations, and other pod-level settings.
// +optional
SessionConfig *product.SessionConfig `json:"sessionConfig,omitempty"`
}

type InternalWorkbenchExperimentalFeatures struct {
Expand Down
5 changes: 5 additions & 0 deletions api/core/v1beta1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

185 changes: 183 additions & 2 deletions api/product/session_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/url"
"regexp"
"strings"

"github.com/posit-dev/team-operator/api/templates"
Expand Down Expand Up @@ -32,8 +33,12 @@ type ServiceConfig struct {
// PodConfig is the configuration for session pods
// +kubebuilder:object:generate=true
type PodConfig struct {
Annotations map[string]string `json:"annotations,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
// DynamicLabels defines rules for generating pod labels from runtime session data.
// Requires template version 2.5.0 or later; ignored by older templates.
// +kubebuilder:validation:MaxItems=20
DynamicLabels []DynamicLabelRule `json:"dynamicLabels,omitempty"`
ServiceAccountName string `json:"serviceAccountName,omitempty"`
Volumes []corev1.Volume `json:"volumes,omitempty"`
VolumeMounts []corev1.VolumeMount `json:"volumeMounts,omitempty"`
Expand All @@ -60,12 +65,188 @@ type JobConfig struct {
Labels map[string]string `json:"labels,omitempty"`
}

// DynamicLabelRule defines a rule for generating pod labels from runtime session data.
// Each rule references a field from the .Job template object and either maps it directly
// to a label (using labelKey) or extracts multiple labels via regex (using match).
// +kubebuilder:object:generate=true
// +kubebuilder:validation:XValidation:rule="!(has(self.labelKey) && has(self.match))",message="labelKey and match are mutually exclusive"
// +kubebuilder:validation:XValidation:rule="has(self.labelKey) || has(self.match)",message="one of labelKey or match is required"
// +kubebuilder:validation:XValidation:rule="!has(self.match) || has(self.labelPrefix)",message="labelPrefix is required when match is set"
type DynamicLabelRule struct {
// Field is the name of a top-level .Job field to read (e.g., "user", "args").
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trust model: field accepts any top-level .Job key, which means CRD authors can surface any launcher job field as a pod label. This is acceptable because CRD write access is already a privileged operation (cluster admin or namespace admin). Documented this explicitly in the field comment.

// Any .Job field is addressable — this relies on CRD write access being a privileged
// operation. Field values may appear as pod labels visible to anyone with pod read access.
// +kubebuilder:validation:MinLength=1
Field string `json:"field"`
// LabelKey is the label key for direct single-value mapping.
// Mutually exclusive with match/labelPrefix.
// Field values are sanitized for use as label values: non-alphanumeric characters
// (except . - _) are replaced with underscores, then truncated to 63 characters with
// leading/trailing non-alphanumeric characters stripped. Long values with special
// characters near the truncation boundary may lose trailing segments.
// MaxLength = 253 (optional DNS prefix) + 1 (/) + 63 (name) = 317
// +kubebuilder:validation:MaxLength=317
LabelKey string `json:"labelKey,omitempty"`
// Match is a regex pattern applied to the field value. Each match produces a label.
// For array fields (like "args"), elements are joined with spaces before matching.
// At runtime, at most 50 matches are applied per rule; excess matches are dropped and a
// posit.team/label-cap-reached annotation is set on the pod.
// Mutually exclusive with labelKey.
// +kubebuilder:validation:MaxLength=256
Match string `json:"match,omitempty"`
// TrimPrefix is stripped from each regex match before forming the label key suffix.
// +kubebuilder:validation:MaxLength=256
TrimPrefix string `json:"trimPrefix,omitempty"`
// LabelPrefix is prepended to the cleaned match to form the label key.
// Required when match is set.
// MaxLength = 253 (DNS subdomain) + 1 (/) + 52 (name prefix, must be < 53 to leave ≥11 chars for suffix)
// +kubebuilder:validation:MaxLength=306
LabelPrefix string `json:"labelPrefix,omitempty"`
// LabelValue is the static value for all matched labels. Defaults to "true".
// +kubebuilder:validation:MaxLength=63
LabelValue string `json:"labelValue,omitempty"`
}

// labelNameRegex validates the name segment of a Kubernetes label key.
var labelNameRegex = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$`)

// dnsSubdomainRegex validates a DNS subdomain per RFC 1123.
var dnsSubdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$`)

// labelNamePrefixRegex validates the name prefix portion of a label key prefix.
// Trailing -, _, or . are allowed because the suffix (appended at runtime) always
// starts with an alphanumeric character, producing a valid final label name.
var labelNamePrefixRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)

// ValidateDynamicLabelRules validates a slice of DynamicLabelRule, checking for
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validation strategy: Mutual exclusivity (labelKey vs match) and regex compilation are validated programmatically in ValidateDynamicLabelRules(), called at template generation time. Kubebuilder markers handle length constraints at admission. This catches errors before they reach the Go template engine at session launch.

// regex compilation errors and mutual exclusivity of labelKey vs match/labelPrefix.
// NOTE: This validation runs at reconciliation time (via GenerateSessionConfigTemplate), not at
// admission time. The CRD XValidation markers only enforce structural rules. A validating webhook
// would be needed to surface these errors (e.g., invalid regex) at CRD write time.
func ValidateDynamicLabelRules(rules []DynamicLabelRule) error {
seenKeys := map[string]bool{}
seenPrefixes := map[string]bool{}
for i, rule := range rules {
if rule.LabelKey != "" {
if seenKeys[rule.LabelKey] {
return fmt.Errorf("dynamicLabels[%d]: duplicate labelKey %q", i, rule.LabelKey)
}
seenKeys[rule.LabelKey] = true
}
if rule.Match != "" && rule.LabelPrefix != "" {
if seenPrefixes[rule.LabelPrefix] {
return fmt.Errorf("dynamicLabels[%d]: duplicate labelPrefix %q across regex rules (overlapping matches would produce duplicate label keys)", i, rule.LabelPrefix)
}
seenPrefixes[rule.LabelPrefix] = true
}
if rule.LabelKey != "" && rule.Match != "" {
return fmt.Errorf("dynamicLabels[%d]: labelKey and match are mutually exclusive", i)
}
if rule.LabelKey == "" && rule.Match == "" {
return fmt.Errorf("dynamicLabels[%d]: one of labelKey or match is required", i)
}
if rule.Match != "" && rule.LabelPrefix == "" {
return fmt.Errorf("dynamicLabels[%d]: labelPrefix is required when match is set", i)
}
if rule.LabelValue != "" {
if rule.LabelKey != "" {
return fmt.Errorf("dynamicLabels[%d]: labelValue must not be set with labelKey (the field value is used as the label value in direct-mapping mode)", i)
}
if len(rule.LabelValue) > 63 {
return fmt.Errorf("dynamicLabels[%d]: labelValue must not exceed 63 characters", i)
}
if !labelNameRegex.MatchString(rule.LabelValue) {
return fmt.Errorf("dynamicLabels[%d]: labelValue must be a valid Kubernetes label value (alphanumeric, -, _, . characters)", i)
}
}
if rule.LabelKey != "" && rule.TrimPrefix != "" {
return fmt.Errorf("dynamicLabels[%d]: trimPrefix must not be set with labelKey (trimPrefix only applies to regex match mode)", i)
}
if rule.LabelKey != "" && rule.LabelPrefix != "" {
return fmt.Errorf("dynamicLabels[%d]: labelPrefix must not be set with labelKey (labelPrefix only applies to regex match mode)", i)
}
if rule.LabelKey != "" {
if strings.Count(rule.LabelKey, "/") > 1 {
return fmt.Errorf("dynamicLabels[%d]: labelKey must contain at most one '/'", i)
}
name := rule.LabelKey
if idx := strings.Index(rule.LabelKey, "/"); idx >= 0 {
prefix := rule.LabelKey[:idx]
if len(prefix) == 0 {
return fmt.Errorf("dynamicLabels[%d]: labelKey DNS prefix (before '/') must not be empty", i)
}
if len(prefix) > 253 {
return fmt.Errorf("dynamicLabels[%d]: labelKey DNS prefix (before '/') must not exceed 253 characters", i)
}
if !dnsSubdomainRegex.MatchString(prefix) {
return fmt.Errorf("dynamicLabels[%d]: labelKey DNS prefix must be a valid DNS subdomain (RFC 1123)", i)
}
if prefix == "kubernetes.io" || strings.HasSuffix(prefix, ".kubernetes.io") ||
prefix == "k8s.io" || strings.HasSuffix(prefix, ".k8s.io") {
return fmt.Errorf("dynamicLabels[%d]: labelKey must not use reserved Kubernetes label prefix %q", i, prefix)
}
name = rule.LabelKey[idx+1:]
}
if len(name) == 0 || len(name) > 63 {
return fmt.Errorf("dynamicLabels[%d]: labelKey name segment must be between 1 and 63 characters", i)
}
if !labelNameRegex.MatchString(name) {
return fmt.Errorf("dynamicLabels[%d]: labelKey name segment must match [a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?", i)
}
}
if rule.Match != "" {
if _, err := regexp.Compile(rule.Match); err != nil {
return fmt.Errorf("dynamicLabels[%d]: invalid regex in match: %w", i, err)
}
// Validate labelPrefix conforms to Kubernetes label key structure:
// [dns-prefix/]name-prefix
// - dns-prefix (before '/') must be ≤ 253 chars
// - name-prefix (after '/', or entire string) must be < 63 chars
// to leave room for at least one suffix character
if strings.Count(rule.LabelPrefix, "/") > 1 {
return fmt.Errorf("dynamicLabels[%d]: labelPrefix must contain at most one '/'", i)
}
namePrefix := rule.LabelPrefix
if idx := strings.Index(rule.LabelPrefix, "/"); idx >= 0 {
dnsPrefix := rule.LabelPrefix[:idx]
if len(dnsPrefix) == 0 {
return fmt.Errorf("dynamicLabels[%d]: labelPrefix DNS prefix (before '/') must not be empty", i)
}
if len(dnsPrefix) > 253 {
return fmt.Errorf("dynamicLabels[%d]: labelPrefix DNS prefix (before '/') must not exceed 253 characters", i)
}
if !dnsSubdomainRegex.MatchString(dnsPrefix) {
return fmt.Errorf("dynamicLabels[%d]: labelPrefix DNS prefix must be a valid DNS subdomain (RFC 1123)", i)
}
if dnsPrefix == "kubernetes.io" || strings.HasSuffix(dnsPrefix, ".kubernetes.io") ||
dnsPrefix == "k8s.io" || strings.HasSuffix(dnsPrefix, ".k8s.io") {
return fmt.Errorf("dynamicLabels[%d]: labelPrefix must not use reserved Kubernetes label prefix %q", i, dnsPrefix)
}
namePrefix = rule.LabelPrefix[idx+1:]
}
if len(namePrefix) >= 53 {
return fmt.Errorf("dynamicLabels[%d]: labelPrefix name segment (after '/') must be shorter than 53 characters to leave room for suffix", i)
}
if len(namePrefix) > 0 && !labelNamePrefixRegex.MatchString(namePrefix) {
return fmt.Errorf("dynamicLabels[%d]: labelPrefix name segment must start with alphanumeric and contain only [a-zA-Z0-9._-]", i)
}
}
}
return nil
}

type wrapperTemplateData struct {
Name string `json:"name"`
Value *SessionConfig `json:"value"`
}

func (s *SessionConfig) GenerateSessionConfigTemplate() (string, error) {
if s.Pod != nil && len(s.Pod.DynamicLabels) > 0 {
if err := ValidateDynamicLabelRules(s.Pod.DynamicLabels); err != nil {
return "", err
}
}

// build wrapper struct
w := wrapperTemplateData{
Name: "rstudio-library.templates.data",
Expand Down
Loading
Loading