1
+ package cmd
2
+ import (
3
+ "context"
4
+ "fmt"
5
+ "io"
6
+ "net/http"
7
+ "net/url"
8
+ "os"
9
+ "strconv"
10
+ "strings"
11
+ "time"
12
+ "github.com/spf13/cobra"
13
+ "github.com/uitml/frink/internal/cli"
14
+ "k8s.io/client-go/rest"
15
+ "k8s.io/client-go/tools/clientcmd"
16
+ )
17
+ type gpuContext struct {
18
+ cli.CommandContext
19
+ ServiceURL string // URL to the gpu-viewer service; if empty, use API server proxy
20
+ Format string // "table" or "oneline"
21
+ Color bool // enable ANSI colors
22
+ Limit int // only used for oneline
23
+ Timeout time.Duration // HTTP timeout
24
+ }
25
+ func newGPUCmd () * cobra.Command {
26
+ ctx := & gpuContext {
27
+ Format : "table" ,
28
+ Timeout : 5 * time .Second ,
29
+ }
30
+ cmd := & cobra.Command {
31
+ Use : "gpu" ,
32
+ Short : "Show cluster GPU availability" ,
33
+ PreRunE : func (cmd * cobra.Command , args []string ) error {
34
+ return ctx .Initialize (cmd )
35
+ },
36
+ RunE : ctx .Run ,
37
+ }
38
+ flags := cmd .Flags ()
39
+ flags .StringVar (& ctx .ServiceURL , "service-url" , "" , "GPU viewer service URL (env GPU_VIEWER_URL or API proxy if empty)" )
40
+ flags .StringVar (& ctx .Format , "format" , "table" , "Output format: table|oneline" )
41
+ flags .BoolVar (& ctx .Color , "color" , false , "Enable ANSI colors" )
42
+ flags .IntVar (& ctx .Limit , "limit" , 0 , "Limit number of nodes (only for --format=oneline)" )
43
+ flags .DurationVar (& ctx .Timeout , "timeout" , 5 * time .Second , "HTTP request timeout" )
44
+ return cmd
45
+ }
46
+ func (ctx * gpuContext ) Run (cmd * cobra.Command , args []string ) error {
47
+ // 1) If user provided a direct URL (flag or env), use it (works with port-forward or Ingress)
48
+ if ctx .ServiceURL != "" || os .Getenv ("GPU_VIEWER_URL" ) != "" {
49
+ serviceURL := ctx .ServiceURL
50
+ if serviceURL == "" {
51
+ serviceURL = os .Getenv ("GPU_VIEWER_URL" )
52
+ }
53
+ return fetchDirect (cmd , serviceURL , ctx .Format , ctx .Color , ctx .Limit , ctx .Timeout )
54
+ }
55
+ // 2) Otherwise, go through the API server service proxy using kubeconfig
56
+ return fetchViaAPIServerProxy (cmd , ctx .Format , ctx .Color , ctx .Limit , ctx .Timeout )
57
+ }
58
+ // Direct HTTP call (port-forward or Ingress)
59
+ func fetchDirect (cmd * cobra.Command , baseURL , format string , color bool , limit int , timeout time.Duration ) error {
60
+ u , err := url .Parse (strings .TrimRight (baseURL , "/" ))
61
+ if err != nil {
62
+ return fmt .Errorf ("invalid service-url: %w" , err )
63
+ }
64
+ q := u .Query ()
65
+ switch strings .ToLower (format ) {
66
+ case "oneline" :
67
+ q .Set ("format" , "oneline" )
68
+ if limit > 0 {
69
+ q .Set ("limit" , strconv .Itoa (limit ))
70
+ }
71
+ case "table" , "" :
72
+ // default
73
+ default :
74
+ return fmt .Errorf ("unknown format %q (use table or oneline)" , format )
75
+ }
76
+ if color {
77
+ q .Set ("color" , "1" )
78
+ }
79
+ u .RawQuery = q .Encode ()
80
+ req , _ := http .NewRequestWithContext (context .Background (), http .MethodGet , u .String (), nil )
81
+ resp , err := (& http.Client {Timeout : timeout }).Do (req )
82
+ if err != nil {
83
+ return fmt .Errorf ("request failed: %w" , err )
84
+ }
85
+ defer resp .Body .Close ()
86
+ if resp .StatusCode != http .StatusOK {
87
+ b , _ := io .ReadAll (resp .Body )
88
+ return fmt .Errorf ("service error: %s: %s" , resp .Status , string (b ))
89
+ }
90
+ _ , err = io .Copy (cmd .OutOrStdout (), resp .Body )
91
+ return err
92
+ }
93
+ // API server service proxy (no port-forward; requires RBAC to get services/proxy)
94
+ func fetchViaAPIServerProxy (cmd * cobra.Command , format string , color bool , limit int , timeout time.Duration ) error {
95
+ // Pick up the same --context flag your root command defines
96
+ ctxFlag , _ := cmd .InheritedFlags ().GetString ("context" )
97
+ // Load kubeconfig with overrides (so --context is honored)
98
+ loading := clientcmd .NewDefaultClientConfigLoadingRules ()
99
+ overrides := & clientcmd.ConfigOverrides {}
100
+ if ctxFlag != "" {
101
+ overrides .CurrentContext = ctxFlag
102
+ }
103
+ cfg , err := clientcmd .NewNonInteractiveDeferredLoadingClientConfig (loading , overrides ).ClientConfig ()
104
+ if err != nil {
105
+ return fmt .Errorf ("kubeconfig load failed: %w" , err )
106
+ }
107
+ // Authenticated transport to the API server
108
+ transport , err := rest .TransportFor (cfg )
109
+ if err != nil {
110
+ return fmt .Errorf ("transport build failed: %w" , err )
111
+ }
112
+ host := strings .TrimRight (cfg .Host , "/" )
113
+ ns := "gpu-availability"
114
+ svc := "gpu-viewer"
115
+ scheme := "http"
116
+ portName := "http"
117
+ // proxy URL that WORKS in your cluster:
118
+ proxyURL := fmt .Sprintf ("%s/api/v1/namespaces/%s/services/%s:%s:%s/proxy/" ,
119
+ host , ns , scheme , svc , portName ,
120
+ )
121
+ // Query params
122
+ u , _ := url .Parse (proxyURL )
123
+ q := u .Query ()
124
+ switch strings .ToLower (format ) {
125
+ case "oneline" :
126
+ q .Set ("format" , "oneline" )
127
+ if limit > 0 {
128
+ q .Set ("limit" , strconv .Itoa (limit ))
129
+ }
130
+ case "table" , "" :
131
+ // default
132
+ default :
133
+ return fmt .Errorf ("unknown format %q (use table or oneline)" , format )
134
+ }
135
+ if color {
136
+ q .Set ("color" , "1" )
137
+ }
138
+ u .RawQuery = q .Encode ()
139
+ // Do request via API server proxy
140
+ req , _ := http .NewRequestWithContext (context .Background (), http .MethodGet , u .String (), nil )
141
+ client := & http.Client {Transport : transport , Timeout : timeout }
142
+ resp , err := client .Do (req )
143
+ if err != nil {
144
+ return fmt .Errorf ("proxy request failed: %w" , err )
145
+ }
146
+ defer resp .Body .Close ()
147
+ if resp .StatusCode != http .StatusOK {
148
+ b , _ := io .ReadAll (resp .Body )
149
+ return fmt .Errorf ("service/proxy error: %s: %s" , resp .Status , string (b ))
150
+ }
151
+ _ , err = io .Copy (cmd .OutOrStdout (), resp .Body )
152
+ return err
153
+ }
0 commit comments