diff --git a/api/product/session_config.go b/api/product/session_config.go index 7b251394..febbcb14 100644 --- a/api/product/session_config.go +++ b/api/product/session_config.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/url" + "regexp" "strings" "github.com/posit-dev/team-operator/api/templates" @@ -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"` @@ -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"). + // 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 +// 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", diff --git a/api/product/session_config_test.go b/api/product/session_config_test.go index 77f4a76d..825b39bd 100644 --- a/api/product/session_config_test.go +++ b/api/product/session_config_test.go @@ -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") } diff --git a/api/product/zz_generated.deepcopy.go b/api/product/zz_generated.deepcopy.go index 04e9a010..1c0c2de4 100644 --- a/api/product/zz_generated.deepcopy.go +++ b/api/product/zz_generated.deepcopy.go @@ -11,6 +11,21 @@ import ( "k8s.io/api/core/v1" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DynamicLabelRule) DeepCopyInto(out *DynamicLabelRule) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DynamicLabelRule. +func (in *DynamicLabelRule) DeepCopy() *DynamicLabelRule { + if in == nil { + return nil + } + out := new(DynamicLabelRule) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *JobConfig) DeepCopyInto(out *JobConfig) { *out = *in @@ -57,6 +72,11 @@ func (in *PodConfig) DeepCopyInto(out *PodConfig) { (*out)[key] = val } } + if in.DynamicLabels != nil { + in, out := &in.DynamicLabels, &out.DynamicLabels + *out = make([]DynamicLabelRule, len(*in)) + copy(*out, *in) + } if in.Volumes != nil { in, out := &in.Volumes, &out.Volumes *out = make([]v1.Volume, len(*in)) diff --git a/api/templates/2.5.0/job.tpl b/api/templates/2.5.0/job.tpl index 865c2980..0726c456 100644 --- a/api/templates/2.5.0/job.tpl +++ b/api/templates/2.5.0/job.tpl @@ -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) }} + {{- $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 }} + {{- end }} + {{- end }} + {{- end }} + {{- end }} + {{- end }} generateName: {{ toYaml .Job.generateName }} spec: {{- if .Job.host }} diff --git a/config/crd/bases/core.posit.team_connects.yaml b/config/crd/bases/core.posit.team_connects.yaml index 94495a6e..3517ddf2 100644 --- a/config/crd/bases/core.posit.team_connects.yaml +++ b/config/crd/bases/core.posit.team_connects.yaml @@ -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 diff --git a/config/crd/bases/core.posit.team_workbenches.yaml b/config/crd/bases/core.posit.team_workbenches.yaml index d047931a..2ae5bb2f 100644 --- a/config/crd/bases/core.posit.team_workbenches.yaml +++ b/config/crd/bases/core.posit.team_workbenches.yaml @@ -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 diff --git a/dist/chart/templates/crd/core.posit.team_connects.yaml b/dist/chart/templates/crd/core.posit.team_connects.yaml index 1a5664de..73ac8982 100755 --- a/dist/chart/templates/crd/core.posit.team_connects.yaml +++ b/dist/chart/templates/crd/core.posit.team_connects.yaml @@ -1878,6 +1878,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 diff --git a/dist/chart/templates/crd/core.posit.team_workbenches.yaml b/dist/chart/templates/crd/core.posit.team_workbenches.yaml index ff0ed92c..76c8536a 100755 --- a/dist/chart/templates/crd/core.posit.team_workbenches.yaml +++ b/dist/chart/templates/crd/core.posit.team_workbenches.yaml @@ -2109,6 +2109,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