Skip to content

Commit e4a207c

Browse files
JAORMXjhrozek
andauthored
feat: add -l/--label flag to thv run and thv list commands (#1084)
Signed-off-by: Juan Antonio Osorio <[email protected]> Co-authored-by: Jakub Hrozek <[email protected]>
1 parent 4acf537 commit e4a207c

File tree

14 files changed

+643
-10
lines changed

14 files changed

+643
-10
lines changed

cmd/thv/app/list.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,15 @@ var listCmd = &cobra.Command{
2020
}
2121

2222
var (
23-
listAll bool
24-
listFormat string
23+
listAll bool
24+
listFormat string
25+
listLabelFilter []string
2526
)
2627

2728
func init() {
2829
listCmd.Flags().BoolVarP(&listAll, "all", "a", false, "Show all workloads (default shows just running)")
2930
listCmd.Flags().StringVar(&listFormat, "format", FormatText, "Output format (json, text, or mcpservers)")
31+
listCmd.Flags().StringArrayVarP(&listLabelFilter, "label", "l", []string{}, "Filter workloads by labels (format: key=value)")
3032
}
3133

3234
func listCmdFunc(cmd *cobra.Command, _ []string) error {
@@ -38,7 +40,7 @@ func listCmdFunc(cmd *cobra.Command, _ []string) error {
3840
return fmt.Errorf("failed to create status manager: %v", err)
3941
}
4042

41-
workloadList, err := manager.ListWorkloads(ctx, listAll)
43+
workloadList, err := manager.ListWorkloads(ctx, listAll, listLabelFilter...)
4244
if err != nil {
4345
return fmt.Errorf("failed to list workloads: %v", err)
4446
}

cmd/thv/app/run.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ var (
9191

9292
// Network isolation flag
9393
runIsolateNetwork bool
94+
95+
// Labels flag
96+
runLabels []string
9497
)
9598

9699
func init() {
@@ -223,6 +226,13 @@ func init() {
223226
"(comma-separated: ENV1,ENV2)")
224227
runCmd.Flags().BoolVar(&runIsolateNetwork, "isolate-network", false,
225228
"Isolate the container network from the host (default: false)")
229+
runCmd.Flags().StringArrayVarP(
230+
&runLabels,
231+
"label",
232+
"l",
233+
[]string{},
234+
"Set labels on the container (format: key=value)",
235+
)
226236

227237
}
228238

@@ -365,6 +375,7 @@ func runCmdFunc(cmd *cobra.Command, args []string) error {
365375
effectivePort,
366376
runTargetPort,
367377
runEnv,
378+
runLabels,
368379
oidcIssuer,
369380
oidcAudience,
370381
oidcJwksURL,

docs/cli/thv_list.md

Lines changed: 4 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/cli/thv_run.md

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/server/docs.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/server/swagger.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/server/swagger.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/labels/labels.go

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,186 @@ func GetPort(labels map[string]string) (int, error) {
115115
func GetToolType(labels map[string]string) string {
116116
return labels[LabelToolType]
117117
}
118+
119+
// IsStandardToolHiveLabel checks if a label key is a standard ToolHive label
120+
// that should not be passed through from user input or displayed to users
121+
func IsStandardToolHiveLabel(key string) bool {
122+
standardLabels := []string{
123+
LabelToolHive,
124+
LabelName,
125+
LabelBaseName,
126+
LabelTransport,
127+
LabelPort,
128+
LabelToolType,
129+
LabelNetworkIsolation,
130+
}
131+
132+
for _, standardLabel := range standardLabels {
133+
if key == standardLabel {
134+
return true
135+
}
136+
}
137+
138+
return false
139+
}
140+
141+
// ParseLabel parses a label string in the format "key=value" and validates it
142+
// according to Kubernetes label naming conventions
143+
func ParseLabel(label string) (string, string, error) {
144+
parts := strings.SplitN(label, "=", 2)
145+
if len(parts) != 2 {
146+
return "", "", fmt.Errorf("invalid label format, expected key=value")
147+
}
148+
149+
key := strings.TrimSpace(parts[0])
150+
value := strings.TrimSpace(parts[1])
151+
152+
if key == "" {
153+
return "", "", fmt.Errorf("label key cannot be empty")
154+
}
155+
156+
return key, value, nil
157+
}
158+
159+
// validateLabelKey validates a label key according to Kubernetes naming conventions
160+
func validateLabelKey(key string) error {
161+
if len(key) == 0 {
162+
return fmt.Errorf("key cannot be empty")
163+
}
164+
if len(key) > 253 {
165+
return fmt.Errorf("key cannot be longer than 253 characters")
166+
}
167+
168+
// Check for valid prefix (optional)
169+
parts := strings.Split(key, "/")
170+
if len(parts) > 2 {
171+
return fmt.Errorf("key can have at most one '/' separator")
172+
}
173+
174+
var name string
175+
if len(parts) == 2 {
176+
prefix := parts[0]
177+
name = parts[1]
178+
179+
// Validate prefix (should be a valid DNS subdomain)
180+
if len(prefix) > 253 {
181+
return fmt.Errorf("prefix cannot be longer than 253 characters")
182+
}
183+
if !isValidDNSSubdomain(prefix) {
184+
return fmt.Errorf("prefix must be a valid DNS subdomain")
185+
}
186+
} else {
187+
name = parts[0]
188+
}
189+
190+
// Validate name part
191+
if len(name) == 0 {
192+
return fmt.Errorf("name part cannot be empty")
193+
}
194+
if len(name) > 63 {
195+
return fmt.Errorf("name part cannot be longer than 63 characters")
196+
}
197+
if !isValidLabelName(name) {
198+
return fmt.Errorf("name part must consist of alphanumeric characters, '-', '_' or '.', " +
199+
"and must start and end with an alphanumeric character")
200+
}
201+
202+
return nil
203+
}
204+
205+
// validateLabelValue validates a label value according to Kubernetes naming conventions
206+
func validateLabelValue(value string) error {
207+
if len(value) > 63 {
208+
return fmt.Errorf("value cannot be longer than 63 characters")
209+
}
210+
if value != "" && !isValidLabelName(value) {
211+
return fmt.Errorf("value must consist of alphanumeric characters, '-', '_' or '.', " +
212+
"and must start and end with an alphanumeric character")
213+
}
214+
return nil
215+
}
216+
217+
// isValidDNSSubdomain checks if a string is a valid DNS subdomain
218+
func isValidDNSSubdomain(s string) bool {
219+
if len(s) == 0 || len(s) > 253 {
220+
return false
221+
}
222+
223+
parts := strings.Split(s, ".")
224+
for _, part := range parts {
225+
if len(part) == 0 || len(part) > 63 {
226+
return false
227+
}
228+
if !isValidDNSLabel(part) {
229+
return false
230+
}
231+
}
232+
return true
233+
}
234+
235+
// isValidDNSLabel checks if a string is a valid DNS label
236+
func isValidDNSLabel(s string) bool {
237+
if len(s) == 0 || len(s) > 63 {
238+
return false
239+
}
240+
241+
// Must start and end with alphanumeric
242+
if !isAlphaNumeric(s[0]) || !isAlphaNumeric(s[len(s)-1]) {
243+
return false
244+
}
245+
246+
// Middle characters can be alphanumeric or hyphen
247+
for i := 1; i < len(s)-1; i++ {
248+
if !isAlphaNumeric(s[i]) && s[i] != '-' {
249+
return false
250+
}
251+
}
252+
253+
return true
254+
}
255+
256+
// isValidLabelName checks if a string is a valid label name
257+
func isValidLabelName(s string) bool {
258+
if len(s) == 0 {
259+
return false
260+
}
261+
262+
// Must start and end with alphanumeric
263+
if !isAlphaNumeric(s[0]) || !isAlphaNumeric(s[len(s)-1]) {
264+
return false
265+
}
266+
267+
// Middle characters can be alphanumeric, hyphen, underscore, or dot
268+
for i := 1; i < len(s)-1; i++ {
269+
if !isAlphaNumeric(s[i]) && s[i] != '-' && s[i] != '_' && s[i] != '.' {
270+
return false
271+
}
272+
}
273+
274+
return true
275+
}
276+
277+
// isAlphaNumeric checks if a character is alphanumeric
278+
func isAlphaNumeric(c byte) bool {
279+
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
280+
}
281+
282+
// ParseLabelWithValidation parses and validates a label according to Kubernetes naming conventions
283+
func ParseLabelWithValidation(label string) (string, string, error) {
284+
key, value, err := ParseLabel(label)
285+
if err != nil {
286+
return "", "", err
287+
}
288+
289+
// Validate key according to Kubernetes label naming conventions
290+
if err := validateLabelKey(key); err != nil {
291+
return "", "", fmt.Errorf("invalid label key: %v", err)
292+
}
293+
294+
// Validate value according to Kubernetes label naming conventions
295+
if err := validateLabelValue(value); err != nil {
296+
return "", "", fmt.Errorf("invalid label value: %v", err)
297+
}
298+
299+
return key, value, nil
300+
}

0 commit comments

Comments
 (0)