• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

NVIDIA / skyhook / 20974453035

13 Jan 2026 10:12PM UTC coverage: 80.806% (-0.4%) from 81.233%
20974453035

push

github

lockwobr
feat: add cli doc for backwards compatibly and warnings

77 of 121 new or added lines in 3 files covered. (63.64%)

20 existing lines in 3 files now uncovered.

6517 of 8065 relevant lines covered (80.81%)

4.3 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

67.94
/operator/internal/cli/utils/utils.go
1
/*
2
 * SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3
 * SPDX-License-Identifier: Apache-2.0
4
 *
5
 *
6
 * Licensed under the Apache License, Version 2.0 (the "License");
7
 * you may not use this file except in compliance with the License.
8
 * You may obtain a copy of the License at
9
 *
10
 * http://www.apache.org/licenses/LICENSE-2.0
11
 *
12
 * Unless required by applicable law or agreed to in writing, software
13
 * distributed under the License is distributed on an "AS IS" BASIS,
14
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
 * See the License for the specific language governing permissions and
16
 * limitations under the License.
17
 */
18

19
package utils
20

21
import (
22
        "context"
23
        "encoding/json"
24
        "fmt"
25
        "io"
26
        "regexp"
27
        "strings"
28
        "text/tabwriter"
29

30
        "golang.org/x/mod/semver"
31
        appsv1 "k8s.io/api/apps/v1"
32
        metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
33
        "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
34
        "k8s.io/apimachinery/pkg/types"
35
        "k8s.io/client-go/dynamic"
36
        "k8s.io/client-go/kubernetes"
37
        "sigs.k8s.io/yaml"
38

39
        "github.com/NVIDIA/skyhook/operator/api/v1alpha1"
40
)
41

42
// Output format constants
43
const (
44
        OutputFormatTable = "table"
45
        OutputFormatJSON  = "json"
46
        OutputFormatYAML  = "yaml"
47
        OutputFormatWide  = "wide"
48
)
49

50
// MatchNodes matches node patterns against a list of available nodes.
51
// Patterns can be exact node names or regex patterns.
52
func MatchNodes(patterns []string, availableNodes []string) ([]string, error) {
2✔
53
        matched := make(map[string]bool)
2✔
54

2✔
55
        for _, pattern := range patterns {
4✔
56
                // Check if it's a regex pattern (contains regex metacharacters)
2✔
57
                isRegex := strings.ContainsAny(pattern, ".*+?^${}[]|()\\")
2✔
58

2✔
59
                if isRegex {
3✔
60
                        re, err := regexp.Compile("^" + pattern + "$")
1✔
61
                        if err != nil {
2✔
62
                                return nil, fmt.Errorf("invalid regex pattern %q: %w", pattern, err)
1✔
63
                        }
1✔
64

65
                        for _, node := range availableNodes {
2✔
66
                                if re.MatchString(node) {
2✔
67
                                        matched[node] = true
1✔
68
                                }
1✔
69
                        }
70
                } else {
2✔
71
                        // Exact match
2✔
72
                        for _, node := range availableNodes {
4✔
73
                                if node == pattern {
4✔
74
                                        matched[node] = true
2✔
75
                                }
2✔
76
                        }
77
                }
78
        }
79

80
        result := make([]string, 0, len(matched))
2✔
81
        for node := range matched {
4✔
82
                result = append(result, node)
2✔
83
        }
2✔
84

85
        return result, nil
2✔
86
}
87

88
// UnstructuredToSkyhook converts an unstructured object to a Skyhook.
89
func UnstructuredToSkyhook(u *unstructured.Unstructured) (*v1alpha1.Skyhook, error) {
2✔
90
        data, err := u.MarshalJSON()
2✔
91
        if err != nil {
2✔
92
                return nil, fmt.Errorf("marshaling unstructured: %w", err)
×
93
        }
×
94

95
        var skyhook v1alpha1.Skyhook
2✔
96
        if err := json.Unmarshal(data, &skyhook); err != nil {
2✔
97
                return nil, fmt.Errorf("unmarshaling to skyhook: %w", err)
×
98
        }
×
99

100
        return &skyhook, nil
2✔
101
}
102

103
// Skyhook annotation and label keys
104
const (
105
        PauseAnnotation   = v1alpha1.METADATA_PREFIX + "/pause"
106
        DisableAnnotation = v1alpha1.METADATA_PREFIX + "/disable"
107
        NodeIgnoreLabel   = v1alpha1.METADATA_PREFIX + "/ignore"
108
        VersionAnnotation = v1alpha1.METADATA_PREFIX + "/version"
109
)
110

111
// SetSkyhookAnnotation sets an annotation on a Skyhook CR using dynamic client
112
// Note: Skyhook is a cluster-scoped resource (not namespaced)
113
func SetSkyhookAnnotation(ctx context.Context, dynamicClient dynamic.Interface, skyhookName, annotation, value string) error {
1✔
114
        patch := fmt.Sprintf(`{"metadata":{"annotations":{%q:%q}}}`, annotation, value)
1✔
115

1✔
116
        gvr := v1alpha1.GroupVersion.WithResource("skyhooks")
1✔
117
        _, err := dynamicClient.Resource(gvr).Patch(
1✔
118
                ctx,
1✔
119
                skyhookName,
1✔
120
                types.MergePatchType,
1✔
121
                []byte(patch),
1✔
122
                metav1.PatchOptions{},
1✔
123
        )
1✔
124
        if err != nil {
1✔
125
                return fmt.Errorf("patching skyhook %q: %w", skyhookName, err)
×
126
        }
×
127

128
        return nil
1✔
129
}
130

131
// RemoveSkyhookAnnotation removes an annotation from a Skyhook CR using dynamic client
132
// Note: Skyhook is a cluster-scoped resource (not namespaced)
UNCOV
133
func RemoveSkyhookAnnotation(ctx context.Context, dynamicClient dynamic.Interface, skyhookName, annotation string) error {
×
UNCOV
134
        patch := fmt.Sprintf(`{"metadata":{"annotations":{%q:null}}}`, annotation)
×
UNCOV
135

×
UNCOV
136
        gvr := v1alpha1.GroupVersion.WithResource("skyhooks")
×
UNCOV
137
        _, err := dynamicClient.Resource(gvr).Patch(
×
UNCOV
138
                ctx,
×
UNCOV
139
                skyhookName,
×
UNCOV
140
                types.MergePatchType,
×
UNCOV
141
                []byte(patch),
×
UNCOV
142
                metav1.PatchOptions{},
×
UNCOV
143
        )
×
UNCOV
144
        if err != nil {
×
145
                return fmt.Errorf("patching skyhook %q: %w", skyhookName, err)
×
146
        }
×
147

UNCOV
148
        return nil
×
149
}
150

151
// SetNodeAnnotation sets an annotation on a Node using merge patch
152
func SetNodeAnnotation(ctx context.Context, kubeClient kubernetes.Interface, nodeName, key, value string) error {
1✔
153
        patch := fmt.Sprintf(`{"metadata":{"annotations":{%q:%q}}}`, key, value)
1✔
154

1✔
155
        _, err := kubeClient.CoreV1().Nodes().Patch(
1✔
156
                ctx,
1✔
157
                nodeName,
1✔
158
                types.MergePatchType,
1✔
159
                []byte(patch),
1✔
160
                metav1.PatchOptions{},
1✔
161
        )
1✔
162
        if err != nil {
1✔
163
                return fmt.Errorf("patching node %q: %w", nodeName, err)
×
164
        }
×
165

166
        return nil
1✔
167
}
168

169
// RemoveNodeAnnotation removes an annotation from a Node using merge patch
170
func RemoveNodeAnnotation(ctx context.Context, kubeClient kubernetes.Interface, nodeName, key string) error {
1✔
171
        patch := fmt.Sprintf(`{"metadata":{"annotations":{%q:null}}}`, key)
1✔
172

1✔
173
        _, err := kubeClient.CoreV1().Nodes().Patch(
1✔
174
                ctx,
1✔
175
                nodeName,
1✔
176
                types.MergePatchType,
1✔
177
                []byte(patch),
1✔
178
                metav1.PatchOptions{},
1✔
179
        )
1✔
180
        if err != nil {
1✔
181
                return fmt.Errorf("patching node %q: %w", nodeName, err)
×
182
        }
×
183

184
        return nil
1✔
185
}
186

187
// SetNodeLabel sets a label on a Node using merge patch
188
func SetNodeLabel(ctx context.Context, kubeClient kubernetes.Interface, nodeName, key, value string) error {
1✔
189
        patch := fmt.Sprintf(`{"metadata":{"labels":{%q:%q}}}`, key, value)
1✔
190

1✔
191
        _, err := kubeClient.CoreV1().Nodes().Patch(
1✔
192
                ctx,
1✔
193
                nodeName,
1✔
194
                types.MergePatchType,
1✔
195
                []byte(patch),
1✔
196
                metav1.PatchOptions{},
1✔
197
        )
1✔
198
        if err != nil {
1✔
199
                return fmt.Errorf("patching node %q: %w", nodeName, err)
×
200
        }
×
201

202
        return nil
1✔
203
}
204

205
// RemoveNodeLabel removes a label from a Node using merge patch
206
func RemoveNodeLabel(ctx context.Context, kubeClient kubernetes.Interface, nodeName, key string) error {
1✔
207
        patch := fmt.Sprintf(`{"metadata":{"labels":{%q:null}}}`, key)
1✔
208

1✔
209
        _, err := kubeClient.CoreV1().Nodes().Patch(
1✔
210
                ctx,
1✔
211
                nodeName,
1✔
212
                types.MergePatchType,
1✔
213
                []byte(patch),
1✔
214
                metav1.PatchOptions{},
1✔
215
        )
1✔
216
        if err != nil {
1✔
217
                return fmt.Errorf("patching node %q: %w", nodeName, err)
×
218
        }
×
219

220
        return nil
1✔
221
}
222

223
// OutputJSON writes data as indented JSON to the writer
224
func OutputJSON(out io.Writer, data any) error {
1✔
225
        jsonData, err := json.MarshalIndent(data, "", "  ")
1✔
226
        if err != nil {
1✔
227
                return fmt.Errorf("marshaling json: %w", err)
×
228
        }
×
229
        _, _ = fmt.Fprintln(out, string(jsonData))
1✔
230
        return nil
1✔
231
}
232

233
// OutputYAML writes data as YAML to the writer
234
func OutputYAML(out io.Writer, data any) error {
×
235
        yamlData, err := yaml.Marshal(data)
×
236
        if err != nil {
×
237
                return fmt.Errorf("marshaling yaml: %w", err)
×
238
        }
×
239
        _, _ = fmt.Fprint(out, string(yamlData))
×
240
        return nil
×
241
}
242

243
// TableConfig defines the column configuration for table/wide output
244
// T is the type of items being displayed
245
type TableConfig[T any] struct {
246
        // Headers for table mode (always shown)
247
        Headers []string
248
        // Extract returns column values for table mode
249
        Extract func(T) []string
250
        // WideHeaders are additional headers appended in wide mode (optional)
251
        WideHeaders []string
252
        // WideExtract returns additional column values for wide mode (optional)
253
        WideExtract func(T) []string
254
}
255

256
// OutputTable writes items as a table using the provided config
257
func OutputTable[T any](out io.Writer, cfg TableConfig[T], items []T) error {
1✔
258
        return outputTableInternal(out, cfg, items, false)
1✔
259
}
1✔
260

261
// OutputWide writes items as a wide table (table columns + wide columns)
262
func OutputWide[T any](out io.Writer, cfg TableConfig[T], items []T) error {
×
263
        return outputTableInternal(out, cfg, items, true)
×
264
}
×
265

266
// OutputTableWithHeader writes items as a table with a header line above
267
func OutputTableWithHeader[T any](out io.Writer, headerLine string, cfg TableConfig[T], items []T) error {
×
268
        _, _ = fmt.Fprintln(out, headerLine)
×
269
        _, _ = fmt.Fprintln(out)
×
270
        return outputTableInternal(out, cfg, items, false)
×
271
}
×
272

273
// OutputWideWithHeader writes items as a wide table with a header line above
274
func OutputWideWithHeader[T any](out io.Writer, headerLine string, cfg TableConfig[T], items []T) error {
×
275
        _, _ = fmt.Fprintln(out, headerLine)
×
276
        _, _ = fmt.Fprintln(out)
×
277
        return outputTableInternal(out, cfg, items, true)
×
278
}
×
279

280
func outputTableInternal[T any](out io.Writer, cfg TableConfig[T], items []T, wide bool) error {
1✔
281
        tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
1✔
282

1✔
283
        // Build headers
1✔
284
        headers := cfg.Headers
1✔
285
        if wide && len(cfg.WideHeaders) > 0 {
1✔
286
                headers = append(headers, cfg.WideHeaders...)
×
287
        }
×
288

289
        // Write headers
290
        _, _ = fmt.Fprintln(tw, strings.Join(headers, "\t"))
1✔
291

1✔
292
        // Write separator
1✔
293
        seps := make([]string, len(headers))
1✔
294
        for i, h := range headers {
2✔
295
                seps[i] = strings.Repeat("-", len(h))
1✔
296
        }
1✔
297
        _, _ = fmt.Fprintln(tw, strings.Join(seps, "\t"))
1✔
298

1✔
299
        // Write rows
1✔
300
        for _, item := range items {
2✔
301
                row := cfg.Extract(item)
1✔
302
                if wide && cfg.WideExtract != nil {
1✔
303
                        row = append(row, cfg.WideExtract(item)...)
×
304
                }
×
305
                _, _ = fmt.Fprintln(tw, strings.Join(row, "\t"))
1✔
306
        }
307

308
        return tw.Flush()
1✔
309
}
310

311
// Operator version discovery constants
312
const (
313
        DefaultNamespace = "skyhook"
314
        // MinAnnotationSupportVersion is the minimum operator version that supports annotation-based pause/disable
315
        MinAnnotationSupportVersion = "v0.8.0"
316
)
317

318
// CompareVersions compares two semver versions.
319
// Returns -1 if v1 < v2, 0 if equal, 1 if v1 > v2.
320
// Handles "v" prefix automatically.
321
// Returns 0 if either version is invalid (non-semver like "dev").
322
func CompareVersions(v1, v2 string) int {
2✔
323
        // Handle empty versions
2✔
324
        if v1 == "" {
3✔
325
                if v2 == "" {
2✔
326
                        return 0
1✔
327
                }
1✔
328
                return -1
1✔
329
        }
330
        if v2 == "" {
3✔
331
                return 1
1✔
332
        }
1✔
333

334
        // Ensure "v" prefix
335
        if v1[0] != 'v' {
3✔
336
                v1 = "v" + v1
1✔
337
        }
1✔
338
        if v2[0] != 'v' {
3✔
339
                v2 = "v" + v2
1✔
340
        }
1✔
341

342
        // If either version is invalid semver, return 0 (treat as equal/unknown)
343
        if !semver.IsValid(v1) || !semver.IsValid(v2) {
3✔
344
                return 0
1✔
345
        }
1✔
346

347
        return semver.Compare(v1, v2)
2✔
348
}
349

350
// IsValidVersion checks if a version string is a valid semver.
351
func IsValidVersion(v string) bool {
2✔
352
        if v == "" {
4✔
353
                return false
2✔
354
        }
2✔
355
        if v[0] != 'v' {
3✔
356
                v = "v" + v
1✔
357
        }
1✔
358
        return semver.IsValid(v)
2✔
359
}
360

361
// GetSkyhook fetches a Skyhook CR by name using the dynamic client.
362
func GetSkyhook(ctx context.Context, dynamicClient dynamic.Interface, name string) (*v1alpha1.Skyhook, error) {
1✔
363
        gvr := v1alpha1.GroupVersion.WithResource("skyhooks")
1✔
364
        obj, err := dynamicClient.Resource(gvr).Get(ctx, name, metav1.GetOptions{})
1✔
365
        if err != nil {
1✔
NEW
366
                return nil, fmt.Errorf("getting skyhook %q: %w", name, err)
×
NEW
367
        }
×
368
        return UnstructuredToSkyhook(obj)
1✔
369
}
370

371
// GetSkyhookVersion extracts the operator version from a Skyhook's annotation.
372
// Returns empty string if the annotation is not present.
373
func GetSkyhookVersion(skyhook *v1alpha1.Skyhook) string {
1✔
374
        if skyhook == nil || skyhook.Annotations == nil {
2✔
375
                return ""
1✔
376
        }
1✔
377
        return skyhook.Annotations[VersionAnnotation]
1✔
378
}
379

380
// DiscoverOperatorVersion queries the cluster to find the Skyhook operator version.
381
// It searches for deployments that look like the Skyhook operator (by checking labels
382
// or container images for "skyhook") and extracts the version.
383
func DiscoverOperatorVersion(ctx context.Context, kube kubernetes.Interface, namespace string) (string, error) {
1✔
384
        if kube == nil {
1✔
NEW
385
                return "", fmt.Errorf("nil kubernetes client")
×
NEW
386
        }
×
387
        if namespace == "" {
1✔
NEW
388
                namespace = DefaultNamespace
×
NEW
389
        }
×
390

391
        // List all deployments in the namespace and find the Skyhook operator
392
        deployments, err := kube.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{})
1✔
393
        if err != nil {
1✔
NEW
394
                return "", fmt.Errorf("listing deployments in namespace %q: %w", namespace, err)
×
NEW
395
        }
×
396

397
        for _, deployment := range deployments.Items {
1✔
NEW
398
                if !isSkyhookOperatorDeployment(&deployment) {
×
NEW
399
                        continue
×
400
                }
401

402
                // Try to get version from Helm label (preferred for Helm deployments)
NEW
403
                if v := deployment.Labels["app.kubernetes.io/version"]; strings.TrimSpace(v) != "" {
×
NEW
404
                        return strings.TrimSpace(v), nil
×
NEW
405
                }
×
406

407
                // Fallback: parse version from container image tag (works for kustomize deployments)
NEW
408
                for _, container := range deployment.Spec.Template.Spec.Containers {
×
NEW
409
                        if tag := ExtractImageTag(container.Image); tag != "" && tag != "latest" {
×
NEW
410
                                return tag, nil
×
NEW
411
                        }
×
412
                }
413
        }
414

415
        return "", fmt.Errorf("unable to determine operator version; no skyhook operator deployment found in namespace %q", namespace)
1✔
416
}
417

418
// isSkyhookOperatorDeployment checks if a deployment looks like the Skyhook operator
419
// by examining container images for "skyhook" (most reliable), then labels as fallback.
NEW
420
func isSkyhookOperatorDeployment(deployment *appsv1.Deployment) bool {
×
NEW
421
        // Check container images for "skyhook" (most reliable - image name won't change)
×
NEW
422
        for _, container := range deployment.Spec.Template.Spec.Containers {
×
NEW
423
                if strings.Contains(strings.ToLower(container.Image), "skyhook") {
×
NEW
424
                        return true
×
NEW
425
                }
×
426
        }
427

428
        // Fallback: check labels for "skyhook"
NEW
429
        for key, value := range deployment.Labels {
×
NEW
430
                if strings.Contains(strings.ToLower(key), "skyhook") ||
×
NEW
431
                        strings.Contains(strings.ToLower(value), "skyhook") {
×
NEW
432
                        return true
×
NEW
433
                }
×
434
        }
435

NEW
436
        return false
×
437
}
438

439
// ExtractImageTag extracts the tag from a container image reference.
440
// Examples:
441
//   - "ghcr.io/nvidia/skyhook/operator:v1.2.3" -> "v1.2.3"
442
//   - "ghcr.io/nvidia/skyhook/operator:v1.2.3@sha256:..." -> "v1.2.3"
443
//   - "ghcr.io/nvidia/skyhook/operator" -> ""
444
func ExtractImageTag(image string) string {
1✔
445
        // Remove digest if present (e.g., @sha256:...)
1✔
446
        if idx := strings.Index(image, "@"); idx > 0 {
2✔
447
                image = image[:idx]
1✔
448
        }
1✔
449

450
        // Split on ":" to get tag
451
        parts := strings.Split(image, ":")
1✔
452
        if len(parts) < 2 {
2✔
453
                return ""
1✔
454
        }
1✔
455

456
        return strings.TrimSpace(parts[len(parts)-1])
1✔
457
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc