-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add dynamicLabels to session pod config #113
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a83d555
7c9de7c
c70f317
f8c244d
588b8e6
044378c
f5a19e0
f430aa6
2ba4c20
74dde7e
ebdccff
fddb987
5d75dbc
71e1f44
c269982
dbbcc13
f555a50
34f5f2f
fd690b3
24c2a48
f23a762
cefbeb1
8ffad35
a2aa1db
bbee88e
1d318c6
6153844
f71de02
01985cd
877a062
a9194fc
ddae143
d9d69a4
4c4e639
f60a851
b90fe2d
1b1b0a8
5945237
ac85502
bab9bc2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ import ( | |
| "context" | ||
| "fmt" | ||
| "net/url" | ||
| "regexp" | ||
| "strings" | ||
|
|
||
| "github.com/posit-dev/team-operator/api/templates" | ||
|
|
@@ -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"` | ||
|
|
@@ -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"). | ||
| // 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 | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validation strategy: Mutual exclusivity ( |
||
| // 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", | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Trust model:
fieldaccepts any top-level.Jobkey, 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.