Skip to content

Commit f2b5ea8

Browse files
committed
feat: add lock and unlock client commands
Add "dutctl <device> lock [duration]" and "dutctl <device> unlock [--force]" subcommands, plus a -u flag to set the lock owner identity (defaults to user@host). The owner is sent on Run, Lock and Unlock via the OwnerHeader. Lock duration defaults to 30m and must be positive. Signed-off-by: Fabian Wienand <fabian.wienand@blindspot.software>
1 parent 0d56523 commit f2b5ea8

3 files changed

Lines changed: 182 additions & 1 deletion

File tree

cmds/dutctl/dutctl.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"connectrpc.com/connect"
2121
"github.com/BlindspotSoftware/dutctl/internal/buildinfo"
2222
"github.com/BlindspotSoftware/dutctl/internal/output"
23+
"github.com/BlindspotSoftware/dutctl/pkg/lock"
2324
"github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1/dutctlv1connect"
2425
"golang.org/x/net/http2"
2526
)
@@ -32,6 +33,8 @@ SYNOPSIS:
3233
dutctl [options] <device>
3334
dutctl [options] <device> <command> [args...]
3435
dutctl [options] <device> <command> help
36+
dutctl [options] <device> lock [duration]
37+
dutctl [options] <device> unlock
3538
dutctl version
3639
3740
`
@@ -42,16 +45,22 @@ The optional args are passed to the command.
4245
To list all available devices, use the list command. If only a device is provided,
4346
dutctl list all available commands for the device.
4447
45-
If a device, a command and the keyword help are provided, dutctl will show usage
48+
If a device, a command and the keyword help are provided, dutctl will show usage
4649
information for the command.
4750
51+
The lock command reserves a device for the current user; the optional duration
52+
(e.g. 30m, 2h) defaults to 30m. The unlock command releases it; pass the -force
53+
option to release a lock held by another user.
54+
4855
`
4956

5057
const (
5158
serverAddrInfo = `Address and port of the dutagent to connect to in the format: address:port`
5259
outputFormatInfo = `Output format, text|json|yaml|oneline, default is text`
5360
verboseInfo = `Verbose output`
5461
noColorInfo = `Disable colored output`
62+
userInfo = `User Identity of the user of the device, defaults to <user>@<host>`
63+
forceInfo = `Force unlock a device locked by another user`
5564
)
5665

5766
func newApp(stdin io.Reader, stdout, stderr io.Writer, exitFunc func(int), args []string) *application {
@@ -78,6 +87,8 @@ func newApp(stdin io.Reader, stdout, stderr io.Writer, exitFunc func(int), args
7887
fs.StringVar(&app.outputFormat, "f", "", outputFormatInfo)
7988
fs.BoolVar(&app.verbose, "v", false, verboseInfo)
8089
fs.BoolVar(&app.noColor, "no-color", false, noColorInfo)
90+
fs.StringVar(&app.user, "u", lock.DefaultUser(), userInfo)
91+
fs.BoolVar(&app.force, "force", false, forceInfo)
8192

8293
//nolint:errcheck // flag.Parse always returns no error because of flag.ExitOnError
8394
fs.Parse(args[1:])
@@ -106,6 +117,8 @@ type application struct {
106117
outputFormat string
107118
verbose bool
108119
noColor bool
120+
user string
121+
force bool
109122
args []string
110123
printFlagDefaults func()
111124

@@ -177,6 +190,13 @@ func (app *application) start() {
177190
command := app.args[1]
178191
cmdArgs := app.args[2:]
179192

193+
switch command {
194+
case "lock":
195+
app.exit(app.lockRPC(device, cmdArgs))
196+
case "unlock":
197+
app.exit(app.unlockRPC(device))
198+
}
199+
180200
if len(cmdArgs) > 0 && cmdArgs[0] == "help" {
181201
err := app.detailsRPC(device, command, "help")
182202
app.exit(err)

cmds/dutctl/rpc.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@ import (
1414
"log"
1515
"os"
1616
"strings"
17+
"time"
1718

1819
"connectrpc.com/connect"
1920
"github.com/BlindspotSoftware/dutctl/internal/output"
21+
"github.com/BlindspotSoftware/dutctl/pkg/lock"
2022

2123
pb "github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1"
2224
)
@@ -56,6 +58,86 @@ func (app *application) listRPC() error {
5658
return nil
5759
}
5860

61+
// defaultLockDuration is used when the user runs "lock" without a duration.
62+
const defaultLockDuration = 30 * time.Minute
63+
64+
// parseLockDuration resolves the lock duration from the lock command's
65+
// arguments. An empty argument list yields defaultLockDuration. The duration
66+
// must be positive.
67+
func parseLockDuration(cmdArgs []string) (time.Duration, error) {
68+
if len(cmdArgs) == 0 || cmdArgs[0] == "" {
69+
return defaultLockDuration, nil
70+
}
71+
72+
parsed, err := time.ParseDuration(cmdArgs[0])
73+
if err != nil {
74+
return 0, fmt.Errorf("invalid lock duration %q: %w", cmdArgs[0], err)
75+
}
76+
77+
if parsed <= 0 {
78+
return 0, fmt.Errorf("lock duration must be positive, got %q", cmdArgs[0])
79+
}
80+
81+
return parsed, nil
82+
}
83+
84+
func (app *application) lockRPC(device string, cmdArgs []string) error {
85+
duration, err := parseLockDuration(cmdArgs)
86+
if err != nil {
87+
return err
88+
}
89+
90+
ctx := context.Background()
91+
req := connect.NewRequest(&pb.LockRequest{
92+
Device: device,
93+
DurationSeconds: int64(duration.Seconds()),
94+
})
95+
req.Header().Set(lock.UserHeader, app.user)
96+
97+
res, err := app.rpcClient.Lock(ctx, req)
98+
if err != nil {
99+
return err
100+
}
101+
102+
app.formatter.WriteContent(output.Content{
103+
Type: output.TypeLockResult,
104+
Data: output.DeviceEntry{
105+
Name: res.Msg.GetDevice(),
106+
Locked: true,
107+
Owner: res.Msg.GetOwner(),
108+
ExpiresAt: res.Msg.GetExpiresAt(),
109+
},
110+
Metadata: map[string]string{
111+
"server": app.serverAddr,
112+
"msg": "Lock Response",
113+
},
114+
})
115+
116+
return nil
117+
}
118+
119+
func (app *application) unlockRPC(device string) error {
120+
ctx := context.Background()
121+
req := connect.NewRequest(&pb.UnlockRequest{Device: device, Force: app.force})
122+
req.Header().Set(lock.UserHeader, app.user)
123+
124+
_, err := app.rpcClient.Unlock(ctx, req)
125+
if err != nil {
126+
return err
127+
}
128+
129+
app.formatter.WriteContent(output.Content{
130+
Type: output.TypeLockResult,
131+
Data: output.DeviceEntry{Name: device},
132+
Metadata: map[string]string{
133+
"server": app.serverAddr,
134+
"msg": "Unlock Response",
135+
},
136+
})
137+
138+
return nil
139+
}
140+
59141
func (app *application) commandsRPC(device string) error {
60142
ctx := context.Background()
61143
req := connect.NewRequest(&pb.CommandsRequest{Device: device})
@@ -116,6 +198,8 @@ func (app *application) runRPC(device, command string, cmdArgs []string) error {
116198
errChan := make(chan error, numWorkers)
117199

118200
stream := app.rpcClient.Run(runCtx)
201+
stream.RequestHeader().Set(lock.UserHeader, app.user)
202+
119203
req := &pb.RunRequest{
120204
Msg: &pb.RunRequest_Command{
121205
Command: &pb.Command{

cmds/dutctl/rpc_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Copyright 2025 Blindspot Software
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package main
6+
7+
import (
8+
"testing"
9+
"time"
10+
)
11+
12+
func TestParseLockDuration(t *testing.T) {
13+
tests := []struct {
14+
name string
15+
args []string
16+
want time.Duration
17+
wantErr bool
18+
}{
19+
{
20+
name: "no args uses default",
21+
args: nil,
22+
want: defaultLockDuration,
23+
},
24+
{
25+
name: "empty arg uses default",
26+
args: []string{""},
27+
want: defaultLockDuration,
28+
},
29+
{
30+
name: "explicit minutes",
31+
args: []string{"5m"},
32+
want: 5 * time.Minute,
33+
},
34+
{
35+
name: "explicit compound duration",
36+
args: []string{"1h30m"},
37+
want: 90 * time.Minute,
38+
},
39+
{
40+
name: "unparseable duration",
41+
args: []string{"banana"},
42+
wantErr: true,
43+
},
44+
{
45+
name: "zero duration rejected",
46+
args: []string{"0s"},
47+
wantErr: true,
48+
},
49+
{
50+
name: "negative duration rejected",
51+
args: []string{"-5m"},
52+
wantErr: true,
53+
},
54+
}
55+
56+
for _, tt := range tests {
57+
t.Run(tt.name, func(t *testing.T) {
58+
got, err := parseLockDuration(tt.args)
59+
60+
if tt.wantErr {
61+
if err == nil {
62+
t.Fatalf("expected error, got duration %v", got)
63+
}
64+
65+
return
66+
}
67+
68+
if err != nil {
69+
t.Fatalf("unexpected error: %v", err)
70+
}
71+
72+
if got != tt.want {
73+
t.Errorf("duration = %v, want %v", got, tt.want)
74+
}
75+
})
76+
}
77+
}

0 commit comments

Comments
 (0)