Skip to content

Commit ad1a40f

Browse files
committed
feat(plugin): setup basic structure
1 parent d687731 commit ad1a40f

File tree

11 files changed

+1085
-1
lines changed

11 files changed

+1085
-1
lines changed

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@
22
.cursor
33
.pytest_cache
44
.idea
5-
**/err.txt
5+
**/err.txt
6+
plugin/bin/
7+
coverage.out

plugin/Makefile

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
SHELL := /bin/bash
2+
3+
BINARY ?= skyhook
4+
BIN_DIR ?= bin
5+
OUTPUT ?= $(BIN_DIR)/$(BINARY)
6+
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
7+
GIT_SHA ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
8+
LDFLAGS ?= -s -w \
9+
-X github.com/NVIDIA/skyhook/plugin/pkg/version.Version=$(VERSION) \
10+
-X github.com/NVIDIA/skyhook/plugin/pkg/version.GitSHA=$(GIT_SHA)
11+
12+
.PHONY: all build install clean test test-unit test-coverage fmt vet help
13+
14+
all: fmt vet test build ## Run all checks and build
15+
16+
help: ## Display this help
17+
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
18+
19+
20+
##@ Testing
21+
22+
test: test-unit ## Run all tests
23+
24+
test-unit: ## Run unit tests
25+
go test -v -race -coverprofile=coverage.out ./...
26+
27+
test-coverage: test-unit ## Run tests and open coverage report
28+
go tool cover -html=coverage.out
29+
30+
##@ Build
31+
32+
build: ## Build binary
33+
@mkdir -p $(BIN_DIR)
34+
go build -ldflags "$(LDFLAGS)" -o $(OUTPUT) .
35+
36+
install: ## Install binary to GOPATH
37+
go install -ldflags "$(LDFLAGS)" .
38+
39+
##@ Cleanup
40+
41+
clean: ## Clean build artifacts
42+
rm -rf $(BIN_DIR)
43+
rm -f coverage.out
44+

plugin/cmd/root.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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 cmd
20+
21+
import (
22+
"fmt"
23+
"strings"
24+
25+
"github.com/spf13/cobra"
26+
"github.com/spf13/pflag"
27+
"k8s.io/cli-runtime/pkg/genericclioptions"
28+
29+
"github.com/NVIDIA/skyhook/plugin/pkg/version"
30+
)
31+
32+
const defaultNamespace = "skyhook"
33+
34+
// GlobalOptions holds persistent CLI options (kubeconfig, namespace, output, etc.).
35+
type GlobalOptions struct {
36+
ConfigFlags *genericclioptions.ConfigFlags
37+
OutputFormat string
38+
Verbose bool
39+
}
40+
41+
// NewGlobalOptions creates a new GlobalOptions with default values.
42+
func NewGlobalOptions() *GlobalOptions {
43+
flags := genericclioptions.NewConfigFlags(true)
44+
if flags.Namespace == nil {
45+
flags.Namespace = new(string)
46+
}
47+
if *flags.Namespace == "" {
48+
*flags.Namespace = defaultNamespace
49+
}
50+
51+
return &GlobalOptions{
52+
ConfigFlags: flags,
53+
OutputFormat: "table",
54+
}
55+
}
56+
57+
func (o *GlobalOptions) addFlags(flagset *pflag.FlagSet) {
58+
o.ConfigFlags.AddFlags(flagset)
59+
flagset.StringVarP(&o.OutputFormat, "output", "o", o.OutputFormat, "Output format. One of: table|json|yaml|wide")
60+
flagset.BoolVarP(&o.Verbose, "verbose", "v", false, "Enable verbose output")
61+
}
62+
63+
func (o *GlobalOptions) validate() error {
64+
validFormats := map[string]struct{}{
65+
"table": {},
66+
"json": {},
67+
"yaml": {},
68+
"wide": {},
69+
}
70+
o.OutputFormat = strings.ToLower(o.OutputFormat)
71+
if _, ok := validFormats[o.OutputFormat]; !ok {
72+
return fmt.Errorf("invalid output format %q", o.OutputFormat)
73+
}
74+
// No validation needed for boolean Verbose flag
75+
return nil
76+
}
77+
78+
// Namespace returns the namespace selected via kubeconfig or flag (default "skyhook").
79+
func (o *GlobalOptions) Namespace() string {
80+
if o.ConfigFlags == nil || o.ConfigFlags.Namespace == nil {
81+
return defaultNamespace
82+
}
83+
ns := strings.TrimSpace(*o.ConfigFlags.Namespace)
84+
if ns == "" {
85+
return defaultNamespace
86+
}
87+
return ns
88+
}
89+
90+
// NewSkyhookCommand creates the root skyhook command with all subcommands.
91+
func NewSkyhookCommand(opts *GlobalOptions) *cobra.Command {
92+
// skyhookCmd represents the root command
93+
skyhookCmd := &cobra.Command{
94+
Use: "skyhook",
95+
Short: "Skyhook SRE plugin",
96+
Long: "kubectl-compatible helper for managing Skyhook deployments.",
97+
Version: version.Summary(),
98+
SilenceErrors: true,
99+
SilenceUsage: true,
100+
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
101+
return opts.validate()
102+
},
103+
}
104+
105+
// Add global flags
106+
opts.addFlags(skyhookCmd.PersistentFlags())
107+
108+
// Customize the version output template
109+
skyhookCmd.SetVersionTemplate("Skyhook plugin: {{.Version}}\n")
110+
111+
// Add subcommands
112+
skyhookCmd.AddCommand(
113+
NewVersionCmd(opts),
114+
)
115+
116+
return skyhookCmd
117+
}
118+
119+
// Execute runs the Skyhook CLI and returns the exit code.
120+
func Execute() int {
121+
opts := NewGlobalOptions()
122+
if err := NewSkyhookCommand(opts).Execute(); err != nil {
123+
return 1
124+
}
125+
return 0
126+
}

plugin/cmd/root_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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 cmd
20+
21+
import (
22+
"testing"
23+
)
24+
25+
// TestNewSkyhookCommand verifies the root command is properly configured
26+
func TestNewSkyhookCommand(t *testing.T) {
27+
opts := NewGlobalOptions()
28+
cmd := NewSkyhookCommand(opts)
29+
30+
if cmd == nil {
31+
t.Fatal("NewSkyhookCommand returned nil")
32+
}
33+
34+
// Verify version command is registered
35+
found := false
36+
for _, c := range cmd.Commands() {
37+
if c.Name() == "version" {
38+
found = true
39+
break
40+
}
41+
}
42+
if !found {
43+
t.Error("version command not registered")
44+
}
45+
}
46+
47+
// TestGlobalOptions_Validate tests the validation logic
48+
func TestGlobalOptions_Validate(t *testing.T) {
49+
tests := []struct {
50+
name string
51+
outputFormat string
52+
wantErr bool
53+
}{
54+
{"valid json", "json", false},
55+
{"valid yaml", "yaml", false},
56+
{"valid table", "table", false},
57+
{"valid wide", "wide", false},
58+
{"case insensitive", "JSON", false},
59+
{"invalid format", "invalid", true},
60+
}
61+
62+
for _, tt := range tests {
63+
t.Run(tt.name, func(t *testing.T) {
64+
opts := NewGlobalOptions()
65+
opts.OutputFormat = tt.outputFormat
66+
67+
err := opts.validate()
68+
if (err != nil) != tt.wantErr {
69+
t.Errorf("validate() error = %v, wantErr %v", err, tt.wantErr)
70+
}
71+
})
72+
}
73+
}

plugin/cmd/version.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
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 cmd
20+
21+
import (
22+
"context"
23+
"fmt"
24+
"strings"
25+
"time"
26+
27+
"github.com/spf13/cobra"
28+
"k8s.io/apimachinery/pkg/api/errors"
29+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
30+
"k8s.io/client-go/kubernetes"
31+
32+
"github.com/NVIDIA/skyhook/plugin/pkg/client"
33+
"github.com/NVIDIA/skyhook/plugin/pkg/version"
34+
)
35+
36+
// NewVersionCmd creates the version command.
37+
func NewVersionCmd(globals *GlobalOptions) *cobra.Command {
38+
var timeout time.Duration
39+
var clientOnly bool
40+
41+
// versionCmd represents the version command
42+
versionCmd := &cobra.Command{
43+
Use: "version",
44+
Short: "Show plugin and Skyhook operator versions",
45+
Long: `Display version information for the Skyhook plugin and the Skyhook operator running in the cluster.
46+
47+
The plugin version is always shown. By default, the command also queries the cluster
48+
to discover the Skyhook operator version. Use --client-only to skip the cluster query.`,
49+
Example: ` # Show both plugin and operator versions
50+
skyhook version
51+
kubectl skyhook version
52+
53+
# Show only the plugin version (no cluster query)
54+
skyhook version --client-only
55+
56+
# Query operator in a specific namespace
57+
skyhook version -n skyhook-system`,
58+
RunE: func(cmd *cobra.Command, args []string) error {
59+
fmt.Fprintf(cmd.OutOrStdout(), "Skyhook plugin:\t%s\n", version.Summary())
60+
61+
if clientOnly {
62+
return nil
63+
}
64+
65+
clientFactory := client.NewFactory(globals.ConfigFlags)
66+
cli, err := clientFactory.Client()
67+
if err != nil {
68+
return fmt.Errorf("initializing kubernetes client: %w", err)
69+
}
70+
71+
ctx, cancel := context.WithTimeout(cmd.Context(), timeout)
72+
defer cancel()
73+
74+
opVersion, err := discoverOperatorVersion(ctx, cli.Kubernetes(), globals.Namespace())
75+
if err != nil {
76+
fmt.Fprintf(cmd.OutOrStdout(), "Skyhook operator:\tunknown (%v)\n", err)
77+
return nil
78+
}
79+
80+
fmt.Fprintf(cmd.OutOrStdout(), "Skyhook operator:\t%s\n", opVersion)
81+
return nil
82+
},
83+
}
84+
85+
versionCmd.Flags().DurationVar(&timeout, "timeout", 10*time.Second, "Time limit for contacting the Kubernetes API")
86+
versionCmd.Flags().BoolVar(&clientOnly, "client-only", false, "Only print the plugin version without querying the cluster")
87+
88+
return versionCmd
89+
}
90+
91+
func discoverOperatorVersion(ctx context.Context, kube kubernetes.Interface, namespace string) (string, error) {
92+
if kube == nil {
93+
return "", fmt.Errorf("nil kubernetes client")
94+
}
95+
if namespace == "" {
96+
namespace = defaultNamespace
97+
}
98+
99+
deploymentName := "skyhook-operator-controller-manager"
100+
deployment, err := kube.AppsV1().Deployments(namespace).Get(ctx, deploymentName, metav1.GetOptions{})
101+
if err != nil {
102+
if errors.IsNotFound(err) {
103+
return "", fmt.Errorf("skyhook operator deployment %q not found in namespace %q", deploymentName, namespace)
104+
}
105+
return "", fmt.Errorf("querying operator deployment: %w", err)
106+
}
107+
108+
// Try to get version from Helm label (preferred for Helm deployments)
109+
if v := deployment.Labels["app.kubernetes.io/version"]; strings.TrimSpace(v) != "" {
110+
return strings.TrimSpace(v), nil
111+
}
112+
113+
// Fallback: parse version from container image tag (works for kustomize deployments)
114+
if len(deployment.Spec.Template.Spec.Containers) > 0 {
115+
image := deployment.Spec.Template.Spec.Containers[0].Image
116+
if tag := extractImageTag(image); tag != "" && tag != "latest" {
117+
return tag, nil
118+
}
119+
}
120+
121+
return "", fmt.Errorf("unable to determine operator version from deployment labels or image tag")
122+
}
123+
124+
// extractImageTag extracts the tag from a container image reference.
125+
// Examples:
126+
// - "ghcr.io/nvidia/skyhook/operator:v1.2.3" -> "v1.2.3"
127+
// - "ghcr.io/nvidia/skyhook/operator:v1.2.3@sha256:..." -> "v1.2.3"
128+
// - "ghcr.io/nvidia/skyhook/operator" -> ""
129+
func extractImageTag(image string) string {
130+
// Remove digest if present (e.g., @sha256:...)
131+
if idx := strings.Index(image, "@"); idx > 0 {
132+
image = image[:idx]
133+
}
134+
135+
// Split on ":" to get tag
136+
parts := strings.Split(image, ":")
137+
if len(parts) < 2 {
138+
return ""
139+
}
140+
141+
return strings.TrimSpace(parts[len(parts)-1])
142+
}

0 commit comments

Comments
 (0)