Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
66 changes: 64 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,11 @@ 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.
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 +64,70 @@ 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
type DynamicLabelRule struct {
// Field is the name of a top-level .Job field to read (e.g., "user", "args").
Copy link
Collaborator 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.
// +kubebuilder:validation:MaxLength=63
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.
// 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.
TrimPrefix string `json:"trimPrefix,omitempty"`
// LabelPrefix is prepended to the cleaned match to form the label key.
// Required when match is set.
// +kubebuilder:validation:MaxLength=253
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"`
}

// ValidateDynamicLabelRules validates a slice of DynamicLabelRule, checking for
Copy link
Collaborator 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.
func ValidateDynamicLabelRules(rules []DynamicLabelRule) error {
for i, rule := range rules {
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.Match != "" {
if _, err := regexp.Compile(rule.Match); err != nil {
return fmt.Errorf("dynamicLabels[%d]: invalid regex in match: %w", i, err)
}
}
}
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
125 changes: 125 additions & 0 deletions api/product/session_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,131 @@ func TestSessionConfig_GenerateSessionConfigTemplate(t *testing.T) {
require.Contains(t, str, "\"mountPath\":\"/mnt/tmp\"")
}

func TestSessionConfig_DynamicLabels(t *testing.T) {
t.Run("direct mapping rule serializes correctly", func(t *testing.T) {
config := SessionConfig{
Pod: &PodConfig{
DynamicLabels: []DynamicLabelRule{
{
Field: "user",
LabelKey: "session.posit.team/user",
},
},
},
}

str, err := config.GenerateSessionConfigTemplate()
require.Nil(t, err)
require.Contains(t, str, "\"dynamicLabels\"")
require.Contains(t, str, "\"field\":\"user\"")
require.Contains(t, str, "\"labelKey\":\"session.posit.team/user\"")
})

t.Run("pattern extraction rule serializes correctly", func(t *testing.T) {
config := SessionConfig{
Pod: &PodConfig{
DynamicLabels: []DynamicLabelRule{
{
Field: "args",
Match: "--ext-[a-z]+",
TrimPrefix: "--ext-",
LabelPrefix: "session.posit.team/ext.",
LabelValue: "enabled",
},
},
},
}

str, err := config.GenerateSessionConfigTemplate()
require.Nil(t, err)
require.Contains(t, str, "\"dynamicLabels\"")
require.Contains(t, str, "\"field\":\"args\"")
require.Contains(t, str, "\"match\":\"--ext-[a-z]+\"")
require.Contains(t, str, "\"trimPrefix\":\"--ext-\"")
require.Contains(t, str, "\"labelPrefix\":\"session.posit.team/ext.\"")
require.Contains(t, str, "\"labelValue\":\"enabled\"")
})
}

func TestValidateDynamicLabelRules(t *testing.T) {
t.Run("valid direct mapping", func(t *testing.T) {
err := ValidateDynamicLabelRules([]DynamicLabelRule{
{Field: "user", LabelKey: "session.posit.team/user"},
})
require.Nil(t, err)
})

t.Run("valid regex mapping", func(t *testing.T) {
err := ValidateDynamicLabelRules([]DynamicLabelRule{
{Field: "args", Match: "--ext-[a-z]+", LabelPrefix: "session.posit.team/ext."},
})
require.Nil(t, err)
})

t.Run("rejects labelKey and match both set", func(t *testing.T) {
err := ValidateDynamicLabelRules([]DynamicLabelRule{
{Field: "user", LabelKey: "foo", Match: "bar", LabelPrefix: "baz"},
})
require.ErrorContains(t, err, "mutually exclusive")
})

t.Run("rejects neither labelKey nor match", func(t *testing.T) {
err := ValidateDynamicLabelRules([]DynamicLabelRule{
{Field: "user"},
})
require.ErrorContains(t, err, "one of labelKey or match is required")
})

t.Run("rejects match without labelPrefix", func(t *testing.T) {
err := ValidateDynamicLabelRules([]DynamicLabelRule{
{Field: "args", Match: "--ext-[a-z]+"},
})
require.ErrorContains(t, err, "labelPrefix is required")
})

t.Run("rejects invalid regex", func(t *testing.T) {
err := ValidateDynamicLabelRules([]DynamicLabelRule{
{Field: "args", Match: "(unclosed", LabelPrefix: "prefix."},
})
require.ErrorContains(t, err, "invalid regex")
})

t.Run("rejects catastrophic backtracking regex", func(t *testing.T) {
// Go's regexp package uses RE2 which doesn't support backreferences,
// so patterns like (a+)+$ are safe. But invalid patterns still fail.
err := ValidateDynamicLabelRules([]DynamicLabelRule{
{Field: "args", Match: "[a-z]+", LabelPrefix: "prefix."},
})
require.Nil(t, err)
})
}

func TestGenerateSessionConfigTemplate_DynamicLabels_Validation(t *testing.T) {
t.Run("rejects invalid regex at generation time", func(t *testing.T) {
config := SessionConfig{
Pod: &PodConfig{
DynamicLabels: []DynamicLabelRule{
{Field: "args", Match: "(unclosed", LabelPrefix: "prefix."},
},
},
}
_, err := config.GenerateSessionConfigTemplate()
require.ErrorContains(t, err, "invalid regex")
})

t.Run("rejects mutually exclusive fields at generation time", func(t *testing.T) {
config := SessionConfig{
Pod: &PodConfig{
DynamicLabels: []DynamicLabelRule{
{Field: "user", LabelKey: "foo", Match: "bar", LabelPrefix: "baz"},
},
},
}
_, err := config.GenerateSessionConfigTemplate()
require.ErrorContains(t, err, "mutually exclusive")
})
}

func TestSiteSessionVaultName(t *testing.T) {
t.Skip("Need to create a TestProduct struct to test this behavior")
}
20 changes: 20 additions & 0 deletions api/product/zz_generated.deepcopy.go

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

16 changes: 16 additions & 0 deletions api/templates/2.5.0/job.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,22 @@ spec:
{{ $key }}: {{ toYaml $val | indent 8 | trimPrefix (repeat 8 " ") }}
{{- end }}
{{- end }}
{{- with $templateData.pod.dynamicLabels }}
{{- range $rule := . }}
{{- if hasKey $.Job $rule.field }}
{{- $val := index $.Job $rule.field }}
{{- if $rule.labelKey }}
{{ $rule.labelKey }}: {{ $val | toString | quote }}
{{- else if $rule.match }}
{{- $str := (kindIs "slice" $val) | ternary ($val | join " ") ($val | toString) }}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Scalar vs array handling: Uses kindIs "slice" with a ternary to handle both array fields (like args) and scalar fields (like user). Scalars get toString, arrays get join " ". This prevents the runtime template error that would occur if join was called on a string value.

{{- $matches := regexFindAll $rule.match $str -1 }}
{{- range $match := $matches }}
{{ trimPrefix ($rule.trimPrefix | default "") $match | lower | replace " " "_" | trunc 63 | printf "%s%s" $rule.labelPrefix }}: {{ $rule.labelValue | default "true" | quote }}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Design choice: The lower | replace " " "_" | replace "-" "_" normalization is intentionally hardcoded rather than configurable. This covers the common case (Kubernetes label compliance) and avoids complexity. If customers need different sanitization, we can add a transform field later.

{{- end }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
generateName: {{ toYaml .Job.generateName }}
spec:
{{- if .Job.host }}
Expand Down
49 changes: 49 additions & 0 deletions config/crd/bases/core.posit.team_connects.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1857,6 +1857,55 @@ spec:
type: string
type: object
type: object
dynamicLabels:
description: |-
DynamicLabels defines rules for generating pod labels from runtime session data.
Requires template version 2.5.0 or later; ignored by older templates.
items:
description: |-
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).
properties:
field:
description: |-
Field is the name of a top-level .Job field to read (e.g., "user", "args").
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.
minLength: 1
type: string
labelKey:
description: |-
LabelKey is the label key for direct single-value mapping.
Mutually exclusive with match/labelPrefix.
maxLength: 63
type: string
labelPrefix:
description: |-
LabelPrefix is prepended to the cleaned match to form the label key.
Required when match is set.
maxLength: 253
type: string
labelValue:
description: LabelValue is the static value for all
matched labels. Defaults to "true".
maxLength: 63
type: string
match:
description: |-
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.
Mutually exclusive with labelKey.
maxLength: 256
type: string
trimPrefix:
description: TrimPrefix is stripped from each regex
match before forming the label key suffix.
type: string
required:
- field
type: object
type: array
env:
items:
description: EnvVar represents an environment variable present
Expand Down
49 changes: 49 additions & 0 deletions config/crd/bases/core.posit.team_workbenches.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2088,6 +2088,55 @@ spec:
type: string
type: object
type: object
dynamicLabels:
description: |-
DynamicLabels defines rules for generating pod labels from runtime session data.
Requires template version 2.5.0 or later; ignored by older templates.
items:
description: |-
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).
properties:
field:
description: |-
Field is the name of a top-level .Job field to read (e.g., "user", "args").
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.
minLength: 1
type: string
labelKey:
description: |-
LabelKey is the label key for direct single-value mapping.
Mutually exclusive with match/labelPrefix.
maxLength: 63
type: string
labelPrefix:
description: |-
LabelPrefix is prepended to the cleaned match to form the label key.
Required when match is set.
maxLength: 253
type: string
labelValue:
description: LabelValue is the static value for all
matched labels. Defaults to "true".
maxLength: 63
type: string
match:
description: |-
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.
Mutually exclusive with labelKey.
maxLength: 256
type: string
trimPrefix:
description: TrimPrefix is stripped from each regex
match before forming the label key suffix.
type: string
required:
- field
type: object
type: array
env:
items:
description: EnvVar represents an environment variable present
Expand Down
Loading