-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathdutctl.go
More file actions
309 lines (251 loc) · 7.94 KB
/
Copy pathdutctl.go
File metadata and controls
309 lines (251 loc) · 7.94 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
// Copyright 2025 Blindspot Software
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// dutctl is the client application of the DUT Control system.
// It provides a command line interface to issue task on remote devices (DUTs).
package main
import (
"crypto/sha256"
"crypto/tls"
"errors"
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"strconv"
"strings"
"connectrpc.com/connect"
"github.com/BlindspotSoftware/dutctl/internal/buildinfo"
"github.com/BlindspotSoftware/dutctl/internal/output"
"github.com/BlindspotSoftware/dutctl/pkg/lock"
"github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1/dutctlv1connect"
"golang.org/x/net/http2"
)
const usageAbstract = `dutctl - The client application of the DUT Control system.
`
const usageSynopsis = `
SYNOPSIS:
dutctl [options] list
dutctl [options] <device>
dutctl [options] <device> <command> [args...]
dutctl [options] <device> <command> help
dutctl [options] <device> lock [duration]
dutctl [options] <device> unlock
dutctl version
`
const usageDescription = `
If a device and a command are provided, dutctl will execute the command on the device.
The optional args are passed to the command.
To list all available devices, use the list command. If only a device is provided,
dutctl list all available commands for the device.
If a device, a command and the keyword help are provided, dutctl will show usage
information for the command.
The lock command reserves a device for the current user; the optional duration
(e.g. 30m, 2h) defaults to 30m. The unlock command releases it; pass the -force
option to release a lock held by another user.
`
const (
serverAddrInfo = `Address and port of the dutagent to connect to in the format: address:port`
outputFormatInfo = `Output format, text|json|yaml|oneline, default is text`
verboseInfo = `Verbose output`
noColorInfo = `Disable colored output`
userInfo = `User Identity of the user of the device, defaults to <user>@<host>`
forceInfo = `Force unlock a device locked by another user`
)
func newApp(stdin io.Reader, stdout, stderr io.Writer, exitFunc func(int), args []string) *application {
var app application
app.stdout = stdout
app.stderr = stderr
app.stdin = stdin
app.exitFunc = exitFunc
fs := flag.NewFlagSet(args[0], flag.ExitOnError)
fs.SetOutput(stderr)
app.printFlagDefaults = func() {
fmt.Fprint(stderr, "OPTIONS:\n")
fs.PrintDefaults()
}
fs.Usage = func() {
fmt.Fprint(stderr, usageAbstract, usageSynopsis, usageDescription)
app.printFlagDefaults()
}
// Flags
fs.StringVar(&app.serverAddr, "s", "localhost:1024", serverAddrInfo)
fs.StringVar(&app.outputFormat, "f", "", outputFormatInfo)
fs.BoolVar(&app.verbose, "v", false, verboseInfo)
fs.BoolVar(&app.noColor, "no-color", false, noColorInfo)
fs.StringVar(&app.user, "u", lock.DefaultUser(), userInfo)
fs.BoolVar(&app.force, "force", false, forceInfo)
//nolint:errcheck // flag.Parse always returns no error because of flag.ExitOnError
fs.Parse(args[1:])
app.args = fs.Args()
// Setup output formatter
app.formatter = output.New(output.Config{
Stdout: stdout,
Stderr: stderr,
Format: app.outputFormat,
Verbose: app.verbose,
NoColor: app.noColor,
})
return &app
}
type application struct {
stdin io.Reader
stdout io.Writer
stderr io.Writer
exitFunc func(int)
// flags
serverAddr string
outputFormat string
verbose bool
noColor bool
user string
force bool
args []string
printFlagDefaults func()
rpcClient dutctlv1connect.DeviceServiceClient
formatter output.Formatter
}
func (app *application) setupRPCClient() {
client := dutctlv1connect.NewDeviceServiceClient(
// Instead of http.DefaultClient, use the HTTP/2 protocol without TLS
newInsecureClient(),
fmt.Sprintf("http://%s", app.serverAddr),
connect.WithGRPC(),
)
app.rpcClient = client
}
func newInsecureClient() *http.Client {
return &http.Client{
Transport: &http2.Transport{
AllowHTTP: true,
DialTLS: func(network, addr string, _ *tls.Config) (net.Conn, error) {
// If you're also using this client for non-h2c traffic, you may want
// to delegate to tls.Dial if the network isn't TCP or the addr isn't
// in an allowlist.
//nolint:noctx
return net.Dial(network, addr)
},
// Don't forget timeouts!
},
}
}
var errInvalidCmdline = fmt.Errorf("invalid command line")
// start is the entry point of the application.
func (app *application) start() {
log.SetOutput(app.stdout)
if len(app.args) == 0 {
app.exit(errInvalidCmdline)
}
if app.args[0] == "version" {
app.printVersion()
app.exit(nil)
}
app.setupRPCClient()
if app.args[0] == "list" {
if len(app.args) > 1 {
app.exit(errInvalidCmdline)
}
app.exit(app.listRPC())
}
if len(app.args) == 1 {
app.exit(app.commandsRPC(app.args[0]))
}
app.dispatchDeviceCommand()
}
// dispatchDeviceCommand handles the "device command [args...]" invocation form.
func (app *application) dispatchDeviceCommand() {
device := app.args[0]
command := app.args[1]
cmdArgs := app.args[2:]
switch command {
case "lock":
app.exit(app.lockRPC(device, cmdArgs))
case "unlock":
app.exit(app.unlockRPC(device))
}
if len(cmdArgs) > 0 && cmdArgs[0] == "help" {
app.exit(app.detailsRPC(device, command, "help"))
}
// Preprocess arguments for optimization (e.g., calculate hashes)
cmdArgs, err := app.preprocessArgs(command, cmdArgs)
if err != nil {
app.exit(err)
}
app.exit(app.runRPC(device, command, cmdArgs))
}
// exit terminates the application. If the provided error is not nil, it is printed to
// the standard error output. If printUsage is true, the usage information is printed additionally.
func (app *application) exit(err error) {
if err == nil {
// Flush any buffered output before exiting
if app.formatter != nil {
_ = app.formatter.Flush()
}
app.exitFunc(0)
}
if err != nil {
log.Print(err)
}
if errors.Is(err, errInvalidCmdline) {
fmt.Fprint(app.stderr, usageSynopsis)
app.printFlagDefaults()
}
// Flush any buffered output before exiting with error
if app.formatter != nil {
_ = app.formatter.Flush()
}
app.exitFunc(1)
}
// preprocessArgs preprocesses command arguments for optimization.
// For example, it calculates file hashes for PiKVM media mount commands.
func (app *application) preprocessArgs(command string, args []string) ([]string, error) {
// Argument counts for "media mount <path> [hash] [size]".
const (
mediaMountMinArgs = 2 // mount + path
mediaMountWithHash = 3 // mount + path + hash
)
// Optimize PiKVM media mount: calculate hash locally to avoid unnecessary transfers
if strings.ToLower(command) == "media" && len(args) >= mediaMountMinArgs && strings.ToLower(args[0]) == "mount" {
imagePath := args[1]
// If hash is already provided (args[2]), skip preprocessing
if len(args) >= mediaMountWithHash {
return args, nil
}
// Check if file exists
fileInfo, err := os.Stat(imagePath)
if err != nil {
// If file doesn't exist locally, let the agent handle the error
return args, nil
}
// Calculate SHA256 hash
log.Printf("Calculating SHA256 hash of %s...", imagePath)
file, err := os.Open(imagePath)
if err != nil {
return args, fmt.Errorf("failed to open file for hashing: %w", err)
}
defer file.Close()
hash := sha256.New()
_, err = io.Copy(hash, file)
if err != nil {
return args, fmt.Errorf("failed to calculate hash: %w", err)
}
hashSum := fmt.Sprintf("%x", hash.Sum(nil))
fileSize := fileInfo.Size()
log.Printf("Hash: %s, Size: %d bytes", hashSum, fileSize)
// Append hash and size to arguments
return append(args, hashSum, strconv.FormatInt(fileSize, 10)), nil
}
return args, nil
}
func (app *application) printVersion() {
app.formatter.WriteContent(output.Content{
Type: output.TypeVersion,
Data: "DUT Control Client\n" + buildinfo.VersionString(),
})
}
func main() {
newApp(os.Stdin, os.Stdout, os.Stderr, os.Exit, os.Args).start()
}