diff --git a/cmds/dutagent/dutagent.go b/cmds/dutagent/dutagent.go index 79998c3b..506b01e4 100644 --- a/cmds/dutagent/dutagent.go +++ b/cmds/dutagent/dutagent.go @@ -160,6 +160,7 @@ func printInitErr(err error) { func (agt *agent) startRPCService() error { service := &rpcService{ devices: agt.config.Devices, + locker: dutagent.NewLocker(), } mux := http.NewServeMux() diff --git a/cmds/dutagent/rpc.go b/cmds/dutagent/rpc.go index aa8ba860..6ffacc82 100644 --- a/cmds/dutagent/rpc.go +++ b/cmds/dutagent/rpc.go @@ -9,10 +9,14 @@ import ( "errors" "fmt" "log" + "net/http" + "time" "connectrpc.com/connect" + "github.com/BlindspotSoftware/dutctl/internal/dutagent" "github.com/BlindspotSoftware/dutctl/internal/fsm" "github.com/BlindspotSoftware/dutctl/pkg/dut" + "github.com/BlindspotSoftware/dutctl/pkg/lock" pb "github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1" ) @@ -20,6 +24,17 @@ import ( // rpcService is the service implementation for the RPCs provided by dutagent. type rpcService struct { devices dut.Devlist + locker *dutagent.Locker +} + +// userFromHeader returns the calling user's identity from a request header, +// or a unique anonymous placeholder when the header is missing. +func userFromHeader(h http.Header) string { + if user := h.Get(lock.UserHeader); user != "" { + return user + } + + return lock.AnonymousUser() } // List is the handler for the List RPC. @@ -29,8 +44,27 @@ func (a *rpcService) List( ) (*connect.Response[pb.ListResponse], error) { log.Println("Server received List request") + locks := a.locker.StatusAll() + + names := a.devices.Names() + infos := make([]*pb.DeviceInfo, 0, len(names)) + + for _, name := range names { + info := &pb.DeviceInfo{Name: name} + + if explicit := locks[name].Explicit; explicit != nil { + info.Lock = &pb.LockInfo{ + Owner: explicit.Owner, + LockedAt: explicit.LockedAt.Unix(), + ExpiresAt: explicit.ExpiresAt.Unix(), + } + } + + infos = append(infos, info) + } + res := connect.NewResponse(&pb.ListResponse{ - Devices: a.devices.Names(), + Devices: infos, }) log.Print("List-RPC finished") @@ -121,6 +155,87 @@ func (a *rpcService) Details( return res, nil } +// Lock is the handler for the Lock RPC. +func (a *rpcService) Lock( + _ context.Context, + req *connect.Request[pb.LockRequest], +) (*connect.Response[pb.LockResponse], error) { + log.Println("Server received Lock request") + + device := req.Msg.GetDevice() + user := userFromHeader(req.Header()) + + if _, ok := a.devices[device]; !ok { + return nil, connect.NewError( + connect.CodeInvalidArgument, + fmt.Errorf("device %q: %w", device, dut.ErrDeviceNotFound), + ) + } + + dur := time.Duration(req.Msg.GetDurationSeconds()) * time.Second + + info, lockErr := a.locker.Lock(device, user, dur) + if lockErr != nil { + switch { + case errors.Is(lockErr, dutagent.ErrWrongOwner): + return nil, connect.NewError(connect.CodeFailedPrecondition, lockErr) + case errors.Is(lockErr, dutagent.ErrInvalidDuration): + return nil, connect.NewError(connect.CodeInvalidArgument, lockErr) + default: + return nil, connect.NewError(connect.CodeInternal, lockErr) + } + } + + var expiresAt int64 + if !info.ExpiresAt.IsZero() { + expiresAt = info.ExpiresAt.Unix() + } + + res := connect.NewResponse(&pb.LockResponse{ + Device: device, + Owner: info.Owner, + LockedAt: info.LockedAt.Unix(), + ExpiresAt: expiresAt, + }) + + log.Print("Lock-RPC finished") + + return res, nil +} + +// Unlock is the handler for the Unlock RPC. +func (a *rpcService) Unlock( + _ context.Context, + req *connect.Request[pb.UnlockRequest], +) (*connect.Response[pb.UnlockResponse], error) { + log.Println("Server received Unlock request") + + device := req.Msg.GetDevice() + user := userFromHeader(req.Header()) + + var err error + if req.Msg.GetForce() { + err = a.locker.ForceClearLock(device) + } else { + err = a.locker.ClearLock(device, user) + } + + if err != nil { + switch { + case errors.Is(err, dutagent.ErrWrongOwner): + return nil, connect.NewError(connect.CodePermissionDenied, err) + case errors.Is(err, dutagent.ErrNotLocked): + return nil, connect.NewError(connect.CodeFailedPrecondition, err) + default: + return nil, connect.NewError(connect.CodeInternal, err) + } + } + + log.Print("Unlock-RPC finished") + + return connect.NewResponse(&pb.UnlockResponse{}), nil +} + // streamAdapter decouples a connect.BidiStream to the dutagent.Stream interface. type streamAdapter struct { inner *connect.BidiStream[pb.RunRequest, pb.RunResponse] @@ -139,9 +254,20 @@ func (a *rpcService) Run( fsmArgs := runCmdArgs{ stream: &streamAdapter{inner: stream}, deviceList: a.devices, + locker: a.locker, + user: userFromHeader(stream.RequestHeader()), } - _, err := fsm.Run(ctx, fsmArgs, receiveCommandRPC) + finalArgs, err := fsm.Run(ctx, fsmArgs, receiveCommandRPC) + + // Safety net for error paths that short-circuit the FSM before + // releaseAutoLock runs. Delegating to the state function keeps the + // cleanup logic in one place. The state tolerates ErrNotLocked, so a + // happy-path call (where the FSM already released the auto-lock) is a + // harmless no-op. + if finalArgs.cmdMsg != nil { + releaseAutoLock(ctx, finalArgs) //nolint:errcheck // state never returns an error + } var connectErr *connect.Error if err != nil && !errors.As(err, &connectErr) { diff --git a/cmds/dutagent/rpc_test.go b/cmds/dutagent/rpc_test.go new file mode 100644 index 00000000..7ae3e061 --- /dev/null +++ b/cmds/dutagent/rpc_test.go @@ -0,0 +1,217 @@ +// Copyright 2025 Blindspot Software +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +package main + +import ( + "context" + "strings" + "testing" + "time" + + "connectrpc.com/connect" + "github.com/BlindspotSoftware/dutctl/internal/dutagent" + "github.com/BlindspotSoftware/dutctl/pkg/dut" + "github.com/BlindspotSoftware/dutctl/pkg/lock" + + pb "github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1" +) + +func newTestService() *rpcService { + return &rpcService{ + devices: dut.Devlist{"devA": dut.Device{}, "otherDev": dut.Device{}}, + locker: dutagent.NewLocker(), + } +} + +func lockReq(device, user string, durSeconds int64) *connect.Request[pb.LockRequest] { + req := connect.NewRequest(&pb.LockRequest{Device: device, DurationSeconds: durSeconds}) + if user != "" { + req.Header().Set(lock.UserHeader, user) + } + + return req +} + +func unlockReq(device, user string, force bool) *connect.Request[pb.UnlockRequest] { + req := connect.NewRequest(&pb.UnlockRequest{Device: device, Force: force}) + if user != "" { + req.Header().Set(lock.UserHeader, user) + } + + return req +} + +func TestLockRPC(t *testing.T) { + svc := newTestService() + + res, err := svc.Lock(context.Background(), lockReq("devA", "alice", 60)) + if err != nil { + t.Fatalf("Lock: unexpected error: %v", err) + } + + if res.Msg.GetOwner() != "alice" { + t.Errorf("owner = %q, want alice", res.Msg.GetOwner()) + } + + if res.Msg.GetExpiresAt() == 0 { + t.Error("expires_at = 0, want a timed expiry") + } +} + +func TestLockRPCUnknownDevice(t *testing.T) { + svc := newTestService() + + _, err := svc.Lock(context.Background(), lockReq("ghost", "alice", 60)) + if connect.CodeOf(err) != connect.CodeInvalidArgument { + t.Errorf("code = %v, want InvalidArgument", connect.CodeOf(err)) + } +} + +func TestLockRPCDifferentOwnerRejected(t *testing.T) { + svc := newTestService() + + if _, err := svc.Lock(context.Background(), lockReq("devA", "alice", 60)); err != nil { + t.Fatalf("first Lock: %v", err) + } + + _, err := svc.Lock(context.Background(), lockReq("devA", "bob", 60)) + if connect.CodeOf(err) != connect.CodeFailedPrecondition { + t.Errorf("code = %v, want FailedPrecondition", connect.CodeOf(err)) + } +} + +func TestLockRPCMissingUserHeader(t *testing.T) { + svc := newTestService() + + first, err := svc.Lock(context.Background(), lockReq("devA", "", 60)) + if err != nil { + t.Fatalf("Lock: %v", err) + } + + if !strings.HasPrefix(first.Msg.GetOwner(), "unknown-") { + t.Errorf("owner = %q, want unknown- prefix", first.Msg.GetOwner()) + } + + // A second anonymous caller must get a distinct identity so they cannot + // satisfy CheckAccess against the first caller's lock. + second, err := svc.Lock(context.Background(), lockReq("otherDev", "", 60)) + if err != nil { + t.Fatalf("second Lock: %v", err) + } + + if first.Msg.GetOwner() == second.Msg.GetOwner() { + t.Errorf("two anonymous callers shared identity %q", first.Msg.GetOwner()) + } +} + +func TestUnlockRPC(t *testing.T) { + svc := newTestService() + + if _, err := svc.Lock(context.Background(), lockReq("devA", "alice", 60)); err != nil { + t.Fatalf("Lock: %v", err) + } + + if _, err := svc.Unlock(context.Background(), unlockReq("devA", "alice", false)); err != nil { + t.Errorf("Unlock by owner: %v", err) + } +} + +func TestUnlockRPCWrongOwner(t *testing.T) { + svc := newTestService() + + if _, err := svc.Lock(context.Background(), lockReq("devA", "alice", 60)); err != nil { + t.Fatalf("Lock: %v", err) + } + + _, err := svc.Unlock(context.Background(), unlockReq("devA", "bob", false)) + if connect.CodeOf(err) != connect.CodePermissionDenied { + t.Errorf("code = %v, want PermissionDenied", connect.CodeOf(err)) + } +} + +func TestUnlockRPCNotLocked(t *testing.T) { + svc := newTestService() + + _, err := svc.Unlock(context.Background(), unlockReq("devA", "alice", false)) + if connect.CodeOf(err) != connect.CodeFailedPrecondition { + t.Errorf("code = %v, want FailedPrecondition", connect.CodeOf(err)) + } +} + +func TestUnlockRPCForce(t *testing.T) { + svc := newTestService() + + if _, err := svc.Lock(context.Background(), lockReq("devA", "alice", 60)); err != nil { + t.Fatalf("Lock: %v", err) + } + + if _, err := svc.Unlock(context.Background(), unlockReq("devA", "bob", true)); err != nil { + t.Errorf("forced Unlock by non-owner: %v", err) + } +} + +func TestLockRPCZeroDurationRejected(t *testing.T) { + svc := newTestService() + + for _, dur := range []int64{0, -5} { + _, err := svc.Lock(context.Background(), lockReq("devA", "alice", dur)) + if connect.CodeOf(err) != connect.CodeInvalidArgument { + t.Errorf("dur=%d: code = %v, want InvalidArgument", dur, connect.CodeOf(err)) + } + } +} + +func TestListRPCHidesAutoOnlyLock(t *testing.T) { + svc := newTestService() + + if _, err := svc.locker.AutoLock("devA", "alice"); err != nil { + t.Fatalf("AutoLock: %v", err) + } + + res, err := svc.List(context.Background(), connect.NewRequest(&pb.ListRequest{})) + if err != nil { + t.Fatalf("List: %v", err) + } + + var got *pb.LockInfo + + for _, info := range res.Msg.GetDevices() { + if info.GetName() == "devA" { + got = info.GetLock() + } + } + + if got != nil { + t.Errorf("auto-only lock surfaced in List: %+v, want no lock info", got) + } +} + +func TestListRPCExplicitShadowsAuto(t *testing.T) { + svc := newTestService() + + if _, err := svc.locker.AutoLock("devA", "alice"); err != nil { + t.Fatalf("AutoLock: %v", err) + } + + if _, err := svc.locker.Lock("devA", "alice", time.Minute); err != nil { + t.Fatalf("Lock: %v", err) + } + + res, err := svc.List(context.Background(), connect.NewRequest(&pb.ListRequest{})) + if err != nil { + t.Fatalf("List: %v", err) + } + + var got *pb.LockInfo + + for _, info := range res.Msg.GetDevices() { + if info.GetName() == "devA" { + got = info.GetLock() + } + } + + if got.GetExpiresAt() == 0 { + t.Error("expected explicit-slot expires_at to win, got 0") + } +} diff --git a/cmds/dutagent/states.go b/cmds/dutagent/states.go index 189e10d3..5169a25a 100644 --- a/cmds/dutagent/states.go +++ b/cmds/dutagent/states.go @@ -24,6 +24,8 @@ type runCmdArgs struct { // dependencies of the state machine stream dutagent.Stream deviceList dut.Devlist + locker *dutagent.Locker + user string // fields for the states used during execution cmdMsg *pb.Command @@ -89,9 +91,60 @@ func findDUTCmd(_ context.Context, args runCmdArgs) (runCmdArgs, fsm.State[runCm args.dev = dev args.cmd = cmd + return args, checkDeviceAccess, nil +} + +// checkDeviceAccess is a state of the Run RPC. +// +// It rejects the run if the device is held by a different owner in either +// the explicit or auto lock slot. Otherwise the FSM proceeds to acquire the +// command-scoped auto-lock. +func checkDeviceAccess(_ context.Context, args runCmdArgs) (runCmdArgs, fsm.State[runCmdArgs], error) { + err := args.locker.CheckAccess(args.cmdMsg.GetDevice(), args.user) + if err != nil { + if errors.Is(err, dutagent.ErrWrongOwner) { + return args, nil, connect.NewError(connect.CodeFailedPrecondition, err) + } + + return args, nil, connect.NewError(connect.CodeInternal, err) + } + + return args, acquireAutoLock, nil +} + +// acquireAutoLock is a state of the Run RPC. +// +// It acquires the command-scoped auto-lock for the device. AutoLock is +// idempotent for the same owner, so this is safe even if the same owner +// already holds an auto-lock from a previous race-lost step. +func acquireAutoLock(_ context.Context, args runCmdArgs) (runCmdArgs, fsm.State[runCmdArgs], error) { + _, err := args.locker.AutoLock(args.cmdMsg.GetDevice(), args.user) + if err != nil { + if errors.Is(err, dutagent.ErrWrongOwner) { + return args, nil, connect.NewError(connect.CodeFailedPrecondition, err) + } + + return args, nil, connect.NewError(connect.CodeInternal, err) + } + return args, executeModules, nil } +// releaseAutoLock is the final state of the Run RPC's happy path. +// +// It releases the command-scoped auto-lock acquired by acquireAutoLock. It +// never touches the explicit lock slot, so an explicit Lock the same owner +// holds for the device survives the run. ErrNotLocked is tolerated because +// a forced unlock by an admin may have wiped the slot concurrently. +func releaseAutoLock(_ context.Context, args runCmdArgs) (runCmdArgs, fsm.State[runCmdArgs], error) { + err := args.locker.ClearAutoLock(args.cmdMsg.GetDevice(), args.user) + if err != nil && !errors.Is(err, dutagent.ErrNotLocked) { + log.Printf("Failed to release auto-lock on device %q: %v", args.cmdMsg.GetDevice(), err) + } + + return args, nil, nil +} + // executeModules is a state of the Run RPC. // // It starts the execution the current command's modules. The execution is done @@ -199,5 +252,5 @@ func waitModules(ctx context.Context, args runCmdArgs) (runCmdArgs, fsm.State[ru } } - return args, nil, nil + return args, releaseAutoLock, nil } diff --git a/cmds/dutagent/states_test.go b/cmds/dutagent/states_test.go index 10623e2f..1c9b9d26 100644 --- a/cmds/dutagent/states_test.go +++ b/cmds/dutagent/states_test.go @@ -11,6 +11,7 @@ import ( "time" "connectrpc.com/connect" + "github.com/BlindspotSoftware/dutctl/internal/dutagent" "github.com/BlindspotSoftware/dutctl/internal/fsm" "github.com/BlindspotSoftware/dutctl/internal/test/fakes" "github.com/BlindspotSoftware/dutctl/pkg/dut" @@ -131,7 +132,7 @@ func TestFindDUTCmd(t *testing.T) { name: "success_valid_command", cmdMsg: &validCmd, devs: makeDevlist(true, 1, 1), - wantNext: executeModules, + wantNext: checkDeviceAccess, }, { name: "device_not_found", @@ -187,8 +188,8 @@ func TestFindDUTCmd(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if !stateEqual(next, executeModules) { - t.Fatalf("expected next state executeModules, got %p", next) + if !stateEqual(next, checkDeviceAccess) { + t.Fatalf("expected next state checkDeviceAccess, got %p", next) } if gotArgs.dev.Desc == "" && len(gotArgs.cmd.Modules) == 0 { // simple sanity check device/command captured t.Fatalf("expected device and command to be set") @@ -197,6 +198,148 @@ func TestFindDUTCmd(t *testing.T) { } } +func TestCheckDeviceAccess(t *testing.T) { + const device = "dev1" + + cmdMsg := &pb.Command{Device: device, Command: "echo"} + + t.Run("unlocked_proceeds_to_acquireAutoLock", func(t *testing.T) { + locker := dutagent.NewLocker() + args := runCmdArgs{cmdMsg: cmdMsg, locker: locker, user: "alice"} + + _, next, err := checkDeviceAccess(context.Background(), args) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !stateEqual(next, acquireAutoLock) { + t.Fatalf("next state = %p, want acquireAutoLock", next) + } + }) + + t.Run("same_owner_explicit_lock_passes", func(t *testing.T) { + locker := dutagent.NewLocker() + if _, err := locker.Lock(device, "alice", time.Hour); err != nil { + t.Fatalf("setup Lock: %v", err) + } + + args := runCmdArgs{cmdMsg: cmdMsg, locker: locker, user: "alice"} + + _, next, err := checkDeviceAccess(context.Background(), args) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !stateEqual(next, acquireAutoLock) { + t.Fatalf("next state = %p, want acquireAutoLock", next) + } + }) + + t.Run("different_owner_rejected", func(t *testing.T) { + locker := dutagent.NewLocker() + if _, err := locker.Lock(device, "bob", time.Hour); err != nil { + t.Fatalf("setup Lock: %v", err) + } + + args := runCmdArgs{cmdMsg: cmdMsg, locker: locker, user: "alice"} + + _, next, err := checkDeviceAccess(context.Background(), args) + if connect.CodeOf(err) != connect.CodeFailedPrecondition { + t.Errorf("code = %v, want FailedPrecondition", connect.CodeOf(err)) + } + + if next != nil { + t.Errorf("next state = %p, want nil on error", next) + } + }) +} + +func TestAcquireAutoLock(t *testing.T) { + const device = "dev1" + + cmdMsg := &pb.Command{Device: device, Command: "echo"} + + t.Run("acquires_and_proceeds_to_executeModules", func(t *testing.T) { + locker := dutagent.NewLocker() + args := runCmdArgs{cmdMsg: cmdMsg, locker: locker, user: "alice"} + + _, next, err := acquireAutoLock(context.Background(), args) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !stateEqual(next, executeModules) { + t.Fatalf("next state = %p, want executeModules", next) + } + + state := locker.StatusAll()[device] + if state.Auto == nil { + t.Error("auto-lock not taken") + } + }) + + t.Run("blocked_by_other_owner_returns_FailedPrecondition", func(t *testing.T) { + locker := dutagent.NewLocker() + if _, err := locker.AutoLock(device, "bob"); err != nil { + t.Fatalf("setup AutoLock: %v", err) + } + + args := runCmdArgs{cmdMsg: cmdMsg, locker: locker, user: "alice"} + + _, _, err := acquireAutoLock(context.Background(), args) + if connect.CodeOf(err) != connect.CodeFailedPrecondition { + t.Errorf("code = %v, want FailedPrecondition", connect.CodeOf(err)) + } + }) +} + +func TestReleaseAutoLock(t *testing.T) { + const device = "dev1" + + cmdMsg := &pb.Command{Device: device, Command: "echo"} + + t.Run("clears_auto_slot_only", func(t *testing.T) { + locker := dutagent.NewLocker() + if _, err := locker.Lock(device, "alice", time.Hour); err != nil { + t.Fatalf("setup Lock: %v", err) + } + + if _, err := locker.AutoLock(device, "alice"); err != nil { + t.Fatalf("setup AutoLock: %v", err) + } + + args := runCmdArgs{cmdMsg: cmdMsg, locker: locker, user: "alice"} + + _, next, err := releaseAutoLock(context.Background(), args) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if next != nil { + t.Errorf("next state = %p, want nil (terminal)", next) + } + + state := locker.StatusAll()[device] + if state.Explicit == nil { + t.Error("releaseAutoLock wiped the explicit lock") + } + + if state.Auto != nil { + t.Error("auto lock still present after releaseAutoLock") + } + }) + + t.Run("missing_auto_lock_is_tolerated", func(t *testing.T) { + locker := dutagent.NewLocker() + args := runCmdArgs{cmdMsg: cmdMsg, locker: locker, user: "alice"} + + _, _, err := releaseAutoLock(context.Background(), args) + if err != nil { + t.Errorf("releaseAutoLock on empty slot: %v", err) + } + }) +} + // dummyModule is a lightweight test double implementing module.Module behavior needed for executeModules tests. type dummyModule struct { err error @@ -532,8 +675,8 @@ func TestWaitModules(t *testing.T) { if err != nil { t.Fatalf("expected success, got error: %v", err) } - if next != nil { - t.Fatalf("expected no next state, got %p", next) + if !stateEqual(next, releaseAutoLock) { + t.Fatalf("expected next state releaseAutoLock, got %p", next) } return } diff --git a/cmds/dutctl/dutctl.go b/cmds/dutctl/dutctl.go index 3d397d37..3810743c 100644 --- a/cmds/dutctl/dutctl.go +++ b/cmds/dutctl/dutctl.go @@ -20,6 +20,7 @@ import ( "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" ) @@ -32,6 +33,8 @@ SYNOPSIS: dutctl [options] dutctl [options] [args...] dutctl [options] help + dutctl [options] lock [duration] + dutctl [options] unlock dutctl version ` @@ -42,9 +45,13 @@ 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 +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 ( @@ -52,6 +59,8 @@ const ( 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 @` + forceInfo = `Force unlock a device locked by another user` ) 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 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:]) @@ -106,6 +117,8 @@ type application struct { outputFormat string verbose bool noColor bool + user string + force bool args []string printFlagDefaults func() @@ -177,6 +190,13 @@ func (app *application) start() { 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" { err := app.detailsRPC(device, command, "help") app.exit(err) diff --git a/cmds/dutctl/rpc.go b/cmds/dutctl/rpc.go index 017b4ddc..13e3075c 100644 --- a/cmds/dutctl/rpc.go +++ b/cmds/dutctl/rpc.go @@ -14,9 +14,11 @@ import ( "log" "os" "strings" + "time" "connectrpc.com/connect" "github.com/BlindspotSoftware/dutctl/internal/output" + "github.com/BlindspotSoftware/dutctl/pkg/lock" pb "github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1" ) @@ -30,9 +32,23 @@ func (app *application) listRPC() error { return err } + devices := make([]output.DeviceEntry, 0, len(res.Msg.GetDevices())) + + for _, info := range res.Msg.GetDevices() { + entry := output.DeviceEntry{Name: info.GetName()} + + if lock := info.GetLock(); lock != nil { + entry.Locked = true + entry.Owner = lock.GetOwner() + entry.ExpiresAt = lock.GetExpiresAt() + } + + devices = append(devices, entry) + } + app.formatter.WriteContent(output.Content{ Type: output.TypeDeviceList, - Data: res.Msg.GetDevices(), + Data: devices, Metadata: map[string]string{ "server": app.serverAddr, "msg": "List Response", @@ -42,6 +58,86 @@ func (app *application) listRPC() error { return nil } +// defaultLockDuration is used when the user runs "lock" without a duration. +const defaultLockDuration = 30 * time.Minute + +// parseLockDuration resolves the lock duration from the lock command's +// arguments. An empty argument list yields defaultLockDuration. The duration +// must be positive. +func parseLockDuration(cmdArgs []string) (time.Duration, error) { + if len(cmdArgs) == 0 || cmdArgs[0] == "" { + return defaultLockDuration, nil + } + + parsed, err := time.ParseDuration(cmdArgs[0]) + if err != nil { + return 0, fmt.Errorf("invalid lock duration %q: %w", cmdArgs[0], err) + } + + if parsed <= 0 { + return 0, fmt.Errorf("lock duration must be positive, got %q", cmdArgs[0]) + } + + return parsed, nil +} + +func (app *application) lockRPC(device string, cmdArgs []string) error { + duration, err := parseLockDuration(cmdArgs) + if err != nil { + return err + } + + ctx := context.Background() + req := connect.NewRequest(&pb.LockRequest{ + Device: device, + DurationSeconds: int64(duration.Seconds()), + }) + req.Header().Set(lock.UserHeader, app.user) + + res, err := app.rpcClient.Lock(ctx, req) + if err != nil { + return err + } + + app.formatter.WriteContent(output.Content{ + Type: output.TypeLockResult, + Data: output.DeviceEntry{ + Name: res.Msg.GetDevice(), + Locked: true, + Owner: res.Msg.GetOwner(), + ExpiresAt: res.Msg.GetExpiresAt(), + }, + Metadata: map[string]string{ + "server": app.serverAddr, + "msg": "Lock Response", + }, + }) + + return nil +} + +func (app *application) unlockRPC(device string) error { + ctx := context.Background() + req := connect.NewRequest(&pb.UnlockRequest{Device: device, Force: app.force}) + req.Header().Set(lock.UserHeader, app.user) + + _, err := app.rpcClient.Unlock(ctx, req) + if err != nil { + return err + } + + app.formatter.WriteContent(output.Content{ + Type: output.TypeLockResult, + Data: output.DeviceEntry{Name: device}, + Metadata: map[string]string{ + "server": app.serverAddr, + "msg": "Unlock Response", + }, + }) + + return nil +} + func (app *application) commandsRPC(device string) error { ctx := context.Background() req := connect.NewRequest(&pb.CommandsRequest{Device: device}) @@ -102,6 +198,8 @@ func (app *application) runRPC(device, command string, cmdArgs []string) error { errChan := make(chan error, numWorkers) stream := app.rpcClient.Run(runCtx) + stream.RequestHeader().Set(lock.UserHeader, app.user) + req := &pb.RunRequest{ Msg: &pb.RunRequest_Command{ Command: &pb.Command{ diff --git a/cmds/dutctl/rpc_test.go b/cmds/dutctl/rpc_test.go new file mode 100644 index 00000000..db380666 --- /dev/null +++ b/cmds/dutctl/rpc_test.go @@ -0,0 +1,77 @@ +// Copyright 2025 Blindspot Software +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package main + +import ( + "testing" + "time" +) + +func TestParseLockDuration(t *testing.T) { + tests := []struct { + name string + args []string + want time.Duration + wantErr bool + }{ + { + name: "no args uses default", + args: nil, + want: defaultLockDuration, + }, + { + name: "empty arg uses default", + args: []string{""}, + want: defaultLockDuration, + }, + { + name: "explicit minutes", + args: []string{"5m"}, + want: 5 * time.Minute, + }, + { + name: "explicit compound duration", + args: []string{"1h30m"}, + want: 90 * time.Minute, + }, + { + name: "unparseable duration", + args: []string{"banana"}, + wantErr: true, + }, + { + name: "zero duration rejected", + args: []string{"0s"}, + wantErr: true, + }, + { + name: "negative duration rejected", + args: []string{"-5m"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseLockDuration(tt.args) + + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got duration %v", got) + } + + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got != tt.want { + t.Errorf("duration = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/cmds/exp/dutserver/rpc.go b/cmds/exp/dutserver/rpc.go index 18230602..c13cf901 100644 --- a/cmds/exp/dutserver/rpc.go +++ b/cmds/exp/dutserver/rpc.go @@ -18,6 +18,7 @@ import ( "sync" "connectrpc.com/connect" + "github.com/BlindspotSoftware/dutctl/pkg/lock" "github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1/dutctlv1connect" "golang.org/x/net/http2" @@ -50,7 +51,12 @@ func (a *agent) conn() dutctlv1connect.DeviceServiceClient { // It implements both, the DeviceService used by clients as they would use with dutagents // and the RelayService used by agents to register with the server. type rpcService struct { - sync.RWMutex + // UnimplementedDeviceServiceHandler provides default CodeUnimplemented + // responses for DeviceService RPCs that dutserver does not forward, + // such as Lock and Unlock. + dutctlv1connect.UnimplementedDeviceServiceHandler + + mu sync.RWMutex // agents holds handles of the registered DUT agents. agents map[string]*agent @@ -58,8 +64,8 @@ type rpcService struct { // findAgent returns the handle for the DUT agent, that controls the device with the given name. func (s *rpcService) findAgent(device string) (*agent, error) { - s.RLock() - defer s.RUnlock() + s.mu.RLock() + defer s.mu.RUnlock() if agent, ok := s.agents[device]; ok { return agent, nil @@ -71,8 +77,8 @@ func (s *rpcService) findAgent(device string) (*agent, error) { // addAgent tries to register devices handled by an agent with address. // If one of the provided devices already exists an error is returned and none of the deviced will be stored. func (s *rpcService) addAgent(address string, devices []string) error { - s.Lock() - defer s.Unlock() + s.mu.Lock() + defer s.mu.Unlock() for _, device := range devices { if _, exists := s.agents[device]; exists { @@ -94,8 +100,16 @@ func (s *rpcService) List( ) (*connect.Response[pb.ListResponse], error) { log.Println("Server received List request") + names := slices.Sorted(maps.Keys(s.agents)) + infos := make([]*pb.DeviceInfo, 0, len(names)) + + // dutserver does not track lock state; Lock is left unset. + for _, name := range names { + infos = append(infos, &pb.DeviceInfo{Name: name}) + } + res := connect.NewResponse(&pb.ListResponse{ - Devices: slices.Sorted(maps.Keys(s.agents)), + Devices: infos, }) log.Print("List-RPC finished") @@ -197,6 +211,9 @@ func (s *rpcService) Run( // This is the first message of a new Run RPC from a client. log.Println("Run request has a command message - starting new stream to DUT agent") + // Forward the requesting user's identity to the agent so it can enforce locking. + upstream.RequestHeader().Set(lock.UserHeader, downstream.RequestHeader().Get(lock.UserHeader)) + // Forward the initial request to the DUT agent. err = upstream.Send(donwnStreamRequest) if err != nil { diff --git a/internal/dutagent/locker.go b/internal/dutagent/locker.go new file mode 100644 index 00000000..96614cb5 --- /dev/null +++ b/internal/dutagent/locker.go @@ -0,0 +1,329 @@ +// Copyright 2025 Blindspot Software +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package dutagent + +import ( + "errors" + "fmt" + "log" + "sync" + "time" +) + +// Sentinel errors returned by Locker. +var ( + // ErrNotLocked is returned when releasing a slot that is not held. + ErrNotLocked = errors.New("device is not locked") + // ErrWrongOwner is returned when a non-owner tries to release a slot. + ErrWrongOwner = errors.New("device is locked by another owner") + // ErrInvalidDuration is returned when Lock is called with a non-positive + // duration. Explicit locks always require a positive expiry; the + // no-expiry semantic is reserved for the auto-lock slot. + ErrInvalidDuration = errors.New("lock duration must be positive") +) + +// Slot identifies which of a device's two lock slots a LockInfo refers to. +type Slot string + +const ( + ExplicitSlot Slot = "explicit" + AutoSlot Slot = "auto" +) + +// LockInfo describes the state of a single lock slot. +type LockInfo struct { + Owner string + LockedAt time.Time + // ExpiresAt is the time the lock expires. The zero value means the lock + // never expires by time; only auto-locks may carry a zero ExpiresAt. + ExpiresAt time.Time + // Slot reports which slot this LockInfo was read from. Set by the + // Locker on every value it produces. + Slot Slot +} + +// isExpired reports whether a slot has a time-based expiry that has passed. +// A zero ExpiresAt never expires. +func (li LockInfo) isExpired(now time.Time) bool { + return !li.ExpiresAt.IsZero() && !now.Before(li.ExpiresAt) +} + +// DeviceLockState is a snapshot of both slot states for a single device. +// Each pointer is nil when the corresponding slot is empty. +type DeviceLockState struct { + Explicit *LockInfo + Auto *LockInfo +} + +// LockError is returned when an operation is denied because the device is +// held by a different owner. Holder is the LockInfo of the lock that blocks +// the operation (its owner, when it was taken, its expiry, and which slot +// it lives in via Holder.Slot). LockError unwraps to ErrWrongOwner so +// callers can match the "different owner" case across acquire (Lock/AutoLock) +// and release (ClearLock/ClearAutoLock) APIs with a single errors.Is check. +type LockError struct { + Device string + Holder LockInfo +} + +// humanRemaining renders dur as a compact "1h30m"-style string, rounded to +// the minute. Non-positive durations render as "0m". +func humanRemaining(dur time.Duration) string { + if dur <= 0 { + return "0m" + } + + dur = dur.Round(time.Minute) + + hours := dur / time.Hour + minutes := (dur % time.Hour) / time.Minute + + switch { + case hours > 0 && minutes > 0: + return fmt.Sprintf("%dh%dm", hours, minutes) + case hours > 0: + return fmt.Sprintf("%dh", hours) + default: + return fmt.Sprintf("%dm", minutes) + } +} + +func (e *LockError) Error() string { + if e.Holder.Slot == ExplicitSlot { + remaining := humanRemaining(time.Until(e.Holder.ExpiresAt)) + + return fmt.Sprintf("device %q is locked by %q for %s", e.Device, e.Holder.Owner, remaining) + } + + return fmt.Sprintf("device %q is locked by %q", e.Device, e.Holder.Owner) +} + +func (e *LockError) Unwrap() error { + return ErrWrongOwner +} + +// Locker tracks per-device locks with two independent slots: an explicit +// slot driven by Lock/ClearLock/ForceClearLock and an auto slot driven by +// AutoLock/ClearAutoLock. The two slots are stored separately so a normal +// clear of one never affects the other. ForceClearLock is the one exception: +// it is an admin escape hatch that wipes both slots. Locker is safe for +// concurrent use. Lock state is held in memory only and is lost on agent +// restart. +type Locker struct { + mu sync.Mutex + explicit map[string]LockInfo + auto map[string]LockInfo +} + +// NewLocker returns a ready-to-use Locker. +func NewLocker() *Locker { + return &Locker{ + explicit: make(map[string]LockInfo), + auto: make(map[string]LockInfo), + } +} + +// hasExplicitLock returns the live explicit-slot lock for device, pruning it +// first if it has expired. The caller must hold l.mu. +func (l *Locker) hasExplicitLock(device string) (LockInfo, bool) { + info, ok := l.explicit[device] + if !ok { + return LockInfo{}, false + } + + if info.isExpired(time.Now()) { + delete(l.explicit, device) + + return LockInfo{}, false + } + + return info, true +} + +// checkLocked returns a *LockError describing whichever slot would block +// owner from operating on device, or nil if owner has access. The caller +// must hold l.mu. +func (l *Locker) checkLocked(device, owner string) *LockError { + if info, held := l.hasExplicitLock(device); held && info.Owner != owner { + return &LockError{Device: device, Holder: info} + } + + if info, held := l.auto[device]; held && info.Owner != owner { + return &LockError{Device: device, Holder: info} + } + + return nil +} + +// Lock acquires the explicit-slot lock on device for owner. dur must be +// positive; ErrInvalidDuration is returned otherwise. If the device is +// already explicit-locked by the same owner, the lock is extended: the new +// expiry is the later of the current and now+dur. If either slot is held by +// a different owner, a *LockError is returned. +func (l *Locker) Lock(device, owner string, dur time.Duration) (LockInfo, error) { + if dur <= 0 { + return LockInfo{}, ErrInvalidDuration + } + + l.mu.Lock() + defer l.mu.Unlock() + + blocker := l.checkLocked(device, owner) + if blocker != nil { + return LockInfo{}, blocker + } + + now := time.Now() + newExpiry := now.Add(dur) + + if existing, held := l.hasExplicitLock(device); held { + // Same-owner re-lock extends but never shrinks the expiry. + updated := existing + if newExpiry.After(existing.ExpiresAt) { + updated.ExpiresAt = newExpiry + } + + l.explicit[device] = updated + + return updated, nil + } + + info := LockInfo{Owner: owner, LockedAt: now, ExpiresAt: newExpiry, Slot: ExplicitSlot} + l.explicit[device] = info + + return info, nil +} + +// ClearLock releases the explicit-slot lock on device. Only the owner may +// release it. ErrNotLocked / *LockError as appropriate. The auto slot is +// not touched. +func (l *Locker) ClearLock(device, owner string) error { + l.mu.Lock() + defer l.mu.Unlock() + + info, ok := l.hasExplicitLock(device) + if !ok { + return ErrNotLocked + } + + if info.Owner != owner { + return &LockError{Device: device, Holder: info} + } + + delete(l.explicit, device) + + return nil +} + +// ForceClearLock releases both slots on device regardless of owner. As an +// admin escape hatch, it intentionally wipes any auto-lock as well so a +// stuck command holder cannot survive a forced unlock. Returns ErrNotLocked +// only when neither slot was held. +func (l *Locker) ForceClearLock(device string) error { + l.mu.Lock() + defer l.mu.Unlock() + + explicitInfo, hadExplicit := l.hasExplicitLock(device) + autoInfo, hadAuto := l.auto[device] + + if !hadExplicit && !hadAuto { + return ErrNotLocked + } + + if hadExplicit { + log.Printf("Force-clearing explicit lock on device %q, previously held by %q", device, explicitInfo.Owner) + delete(l.explicit, device) + } + + if hadAuto { + log.Printf("Force-clearing auto lock on device %q, previously held by %q", device, autoInfo.Owner) + delete(l.auto, device) + } + + return nil +} + +// AutoLock acquires the auto-slot lock on device for owner. Auto locks carry +// no expiry. Re-AutoLock by the same owner is a no-op. If either slot is +// held by a different owner, a *LockError is returned. +func (l *Locker) AutoLock(device, owner string) (LockInfo, error) { + l.mu.Lock() + defer l.mu.Unlock() + + blocker := l.checkLocked(device, owner) + if blocker != nil { + return LockInfo{}, blocker + } + + if existing, held := l.auto[device]; held { + return existing, nil + } + + info := LockInfo{Owner: owner, LockedAt: time.Now(), Slot: AutoSlot} + l.auto[device] = info + + return info, nil +} + +// ClearAutoLock releases the auto-slot lock on device. Only the owner may +// release it. ErrNotLocked / *LockError as appropriate. The explicit slot +// is not touched. +func (l *Locker) ClearAutoLock(device, owner string) error { + l.mu.Lock() + defer l.mu.Unlock() + + info, ok := l.auto[device] + if !ok { + return ErrNotLocked + } + + if info.Owner != owner { + return &LockError{Device: device, Holder: info} + } + + delete(l.auto, device) + + return nil +} + +// CheckAccess reports whether owner may operate on device. It returns nil if +// neither slot is held or if every held slot is owned by owner; otherwise it +// returns a *LockError carrying the blocking slot's holder. +func (l *Locker) CheckAccess(device, owner string) error { + l.mu.Lock() + defer l.mu.Unlock() + + blocker := l.checkLocked(device, owner) + if blocker != nil { + return blocker + } + + return nil +} + +// StatusAll returns a snapshot of both slot states for every device that has +// at least one slot held. Expired explicit slots are pruned and not included. +func (l *Locker) StatusAll() map[string]DeviceLockState { + l.mu.Lock() + defer l.mu.Unlock() + + out := make(map[string]DeviceLockState) + + for device := range l.explicit { + if info, ok := l.hasExplicitLock(device); ok { + state := out[device] + state.Explicit = &LockInfo{Owner: info.Owner, LockedAt: info.LockedAt, ExpiresAt: info.ExpiresAt, Slot: ExplicitSlot} + out[device] = state + } + } + + for device, info := range l.auto { + state := out[device] + state.Auto = &LockInfo{Owner: info.Owner, LockedAt: info.LockedAt, ExpiresAt: info.ExpiresAt, Slot: AutoSlot} + out[device] = state + } + + return out +} diff --git a/internal/dutagent/locker_test.go b/internal/dutagent/locker_test.go new file mode 100644 index 00000000..56b4fa84 --- /dev/null +++ b/internal/dutagent/locker_test.go @@ -0,0 +1,323 @@ +// Copyright 2025 Blindspot Software +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package dutagent + +import ( + "errors" + "testing" + "time" +) + +func TestLockHappyPath(t *testing.T) { + l := NewLocker() + + info, err := l.Lock("dev", "alice", time.Minute) + if err != nil { + t.Fatalf("Lock: %v", err) + } + + if info.Owner != "alice" { + t.Errorf("Owner = %q, want alice", info.Owner) + } + + if info.ExpiresAt.IsZero() { + t.Error("ExpiresAt is zero, want a timed expiry") + } + + if err := l.CheckAccess("dev", "alice"); err != nil { + t.Errorf("CheckAccess for owner: %v", err) + } + + if err := l.ClearLock("dev", "alice"); err != nil { + t.Errorf("ClearLock: %v", err) + } +} + +func TestLockRejectsNonPositiveDuration(t *testing.T) { + l := NewLocker() + + for _, dur := range []time.Duration{0, -time.Second, -time.Hour} { + _, err := l.Lock("dev", "alice", dur) + if !errors.Is(err, ErrInvalidDuration) { + t.Errorf("Lock dur=%v: err = %v, want ErrInvalidDuration", dur, err) + } + } +} + +func TestLockSameOwnerExtend(t *testing.T) { + l := NewLocker() + + first, err := l.Lock("dev", "alice", time.Minute) + if err != nil { + t.Fatalf("first Lock: %v", err) + } + + second, err := l.Lock("dev", "alice", time.Hour) + if err != nil { + t.Fatalf("extend Lock: %v", err) + } + + if !second.ExpiresAt.After(first.ExpiresAt) { + t.Errorf("extend did not push expiry out: first=%v second=%v", first.ExpiresAt, second.ExpiresAt) + } + + third, err := l.Lock("dev", "alice", time.Minute) + if err != nil { + t.Fatalf("shorter re-lock: %v", err) + } + + if third.ExpiresAt.Before(second.ExpiresAt) { + t.Errorf("shorter re-lock shrank expiry: second=%v third=%v", second.ExpiresAt, third.ExpiresAt) + } +} + +func TestLockBlockedByDifferentOwnerExplicit(t *testing.T) { + l := NewLocker() + + if _, err := l.Lock("dev", "alice", time.Minute); err != nil { + t.Fatalf("setup Lock: %v", err) + } + + _, err := l.Lock("dev", "bob", time.Minute) + + var le *LockError + if !errors.As(err, &le) { + t.Fatalf("Lock by other owner: err = %v, want *LockError", err) + } + + if le.Holder.Slot != ExplicitSlot || le.Holder.Owner != "alice" { + t.Errorf("LockError = %+v, want slot=explicit owner=alice", le) + } +} + +func TestLockBlockedByDifferentOwnerAuto(t *testing.T) { + l := NewLocker() + + if _, err := l.AutoLock("dev", "alice"); err != nil { + t.Fatalf("setup AutoLock: %v", err) + } + + _, err := l.Lock("dev", "bob", time.Minute) + + var le *LockError + if !errors.As(err, &le) { + t.Fatalf("Lock blocked by auto: err = %v, want *LockError", err) + } + + if le.Holder.Slot != AutoSlot || le.Holder.Owner != "alice" { + t.Errorf("LockError = %+v, want slot=auto owner=alice", le) + } +} + +func TestLockExplicitExpires(t *testing.T) { + l := NewLocker() + + if _, err := l.Lock("dev", "alice", time.Millisecond); err != nil { + t.Fatalf("Lock: %v", err) + } + + time.Sleep(10 * time.Millisecond) + + if _, err := l.Lock("dev", "bob", time.Minute); err != nil { + t.Errorf("Lock after expiry: %v", err) + } +} + +func TestClearLockErrors(t *testing.T) { + l := NewLocker() + + if err := l.ClearLock("dev", "alice"); !errors.Is(err, ErrNotLocked) { + t.Errorf("ClearLock on free slot: err = %v, want ErrNotLocked", err) + } + + if _, err := l.Lock("dev", "alice", time.Minute); err != nil { + t.Fatalf("Lock: %v", err) + } + + if err := l.ClearLock("dev", "bob"); !errors.Is(err, ErrWrongOwner) { + t.Errorf("ClearLock by wrong owner: err = %v, want ErrWrongOwner", err) + } +} + +func TestAutoLockNoExpiry(t *testing.T) { + l := NewLocker() + + info, err := l.AutoLock("dev", "alice") + if err != nil { + t.Fatalf("AutoLock: %v", err) + } + + if !info.ExpiresAt.IsZero() { + t.Errorf("auto-lock ExpiresAt = %v, want zero", info.ExpiresAt) + } + + state, ok := l.StatusAll()["dev"] + if !ok || state.Auto == nil { + t.Fatal("auto-lock missing from StatusAll") + } + + if state.Explicit != nil { + t.Error("AutoLock unexpectedly populated the explicit slot") + } +} + +func TestAutoLockSameOwnerIdempotent(t *testing.T) { + l := NewLocker() + + first, err := l.AutoLock("dev", "alice") + if err != nil { + t.Fatalf("first AutoLock: %v", err) + } + + second, err := l.AutoLock("dev", "alice") + if err != nil { + t.Fatalf("second AutoLock: %v", err) + } + + if !second.LockedAt.Equal(first.LockedAt) { + t.Errorf("re-AutoLock changed LockedAt: first=%v second=%v", first.LockedAt, second.LockedAt) + } +} + +func TestAutoLockBlockedByExplicitOtherOwner(t *testing.T) { + l := NewLocker() + + if _, err := l.Lock("dev", "alice", time.Minute); err != nil { + t.Fatalf("setup Lock: %v", err) + } + + _, err := l.AutoLock("dev", "bob") + + var le *LockError + if !errors.As(err, &le) { + t.Fatalf("AutoLock blocked by explicit: err = %v, want *LockError", err) + } + + if le.Holder.Slot != ExplicitSlot { + t.Errorf("blocking slot = %q, want explicit", le.Holder.Slot) + } +} + +func TestClearAutoLockLeavesExplicitIntact(t *testing.T) { + l := NewLocker() + + if _, err := l.Lock("dev", "alice", time.Hour); err != nil { + t.Fatalf("Lock: %v", err) + } + + if _, err := l.AutoLock("dev", "alice"); err != nil { + t.Fatalf("AutoLock: %v", err) + } + + if err := l.ClearAutoLock("dev", "alice"); err != nil { + t.Fatalf("ClearAutoLock: %v", err) + } + + state, ok := l.StatusAll()["dev"] + if !ok || state.Explicit == nil { + t.Fatal("explicit lock was wiped by ClearAutoLock") + } + + if state.Auto != nil { + t.Error("auto lock still present after ClearAutoLock") + } +} + +func TestClearAutoLockErrors(t *testing.T) { + l := NewLocker() + + if err := l.ClearAutoLock("dev", "alice"); !errors.Is(err, ErrNotLocked) { + t.Errorf("ClearAutoLock on free slot: err = %v, want ErrNotLocked", err) + } + + if _, err := l.AutoLock("dev", "alice"); err != nil { + t.Fatalf("AutoLock: %v", err) + } + + if err := l.ClearAutoLock("dev", "bob"); !errors.Is(err, ErrWrongOwner) { + t.Errorf("ClearAutoLock by wrong owner: err = %v, want ErrWrongOwner", err) + } +} + +func TestForceClearLockWipesBothSlots(t *testing.T) { + l := NewLocker() + + if _, err := l.Lock("dev", "alice", time.Hour); err != nil { + t.Fatalf("Lock: %v", err) + } + + if _, err := l.AutoLock("dev", "alice"); err != nil { + t.Fatalf("AutoLock: %v", err) + } + + if err := l.ForceClearLock("dev"); err != nil { + t.Fatalf("ForceClearLock: %v", err) + } + + if _, ok := l.StatusAll()["dev"]; ok { + t.Error("device still appears in StatusAll after ForceClearLock") + } + + if err := l.ForceClearLock("dev"); !errors.Is(err, ErrNotLocked) { + t.Errorf("ForceClearLock on free device: err = %v, want ErrNotLocked", err) + } +} + +func TestStatusAllReportsBothSlotsIndependently(t *testing.T) { + l := NewLocker() + + if _, err := l.Lock("alpha", "alice", time.Hour); err != nil { + t.Fatalf("Lock alpha: %v", err) + } + + if _, err := l.AutoLock("beta", "bob"); err != nil { + t.Fatalf("AutoLock beta: %v", err) + } + + if _, err := l.Lock("gamma", "carol", time.Hour); err != nil { + t.Fatalf("Lock gamma: %v", err) + } + + if _, err := l.AutoLock("gamma", "carol"); err != nil { + t.Fatalf("AutoLock gamma: %v", err) + } + + status := l.StatusAll() + + if got := status["alpha"]; got.Explicit == nil || got.Auto != nil { + t.Errorf("alpha = %+v, want explicit-only", got) + } + + if got := status["beta"]; got.Auto == nil || got.Explicit != nil { + t.Errorf("beta = %+v, want auto-only", got) + } + + if got := status["gamma"]; got.Explicit == nil || got.Auto == nil { + t.Errorf("gamma = %+v, want both slots populated", got) + } +} + +func TestCheckAccessAllowsSameOwnerOnBothSlots(t *testing.T) { + l := NewLocker() + + if _, err := l.Lock("dev", "alice", time.Hour); err != nil { + t.Fatalf("Lock: %v", err) + } + + if _, err := l.AutoLock("dev", "alice"); err != nil { + t.Fatalf("AutoLock: %v", err) + } + + if err := l.CheckAccess("dev", "alice"); err != nil { + t.Errorf("CheckAccess for same owner: %v", err) + } + + err := l.CheckAccess("dev", "bob") + + var le *LockError + if !errors.As(err, &le) { + t.Fatalf("CheckAccess for other owner: err = %v, want *LockError", err) + } +} diff --git a/internal/output/json_test.go b/internal/output/json_test.go index 47c6b43f..ce65b186 100644 --- a/internal/output/json_test.go +++ b/internal/output/json_test.go @@ -29,7 +29,7 @@ func TestJSONFormatter(t *testing.T) { // Test case 2: Output with metadata formatter.WriteContent(Content{ Type: TypeDeviceList, - Data: []string{"device1", "device2", "device3"}, + Data: []DeviceEntry{{Name: "device1"}, {Name: "device2"}, {Name: "device3"}}, Metadata: map[string]string{ "server": "localhost:1024", "device": "test-device", diff --git a/internal/output/oneline.go b/internal/output/oneline.go index 3729c25b..c97dd4a1 100644 --- a/internal/output/oneline.go +++ b/internal/output/oneline.go @@ -112,12 +112,31 @@ func formatDataValue(data interface{}, separator string) string { return formatQuotedString(joined, separator) case []byte: return formatQuotedString(string(dataValue), separator) + case []DeviceEntry: + entries := make([]string, 0, len(dataValue)) + for _, d := range dataValue { + entries = append(entries, deviceEntryString(d)) + } + + return formatQuotedString(strings.Join(entries, "|"), separator) + case DeviceEntry: + return formatQuotedString(deviceEntryString(dataValue), separator) default: // Convert anything else to string return formatQuotedString(fmt.Sprintf("%v", dataValue), separator) } } +// deviceEntryString renders a DeviceEntry as a compact "name" or +// "name=locked:owner" token for single-line output. +func deviceEntryString(d DeviceEntry) string { + if !d.Locked { + return d.Name + } + + return fmt.Sprintf("%s=locked:%s", d.Name, d.Owner) +} + // output writes the formatted line to the appropriate destination. func (f *OneLineFormatter) output(line string, isError bool) { if f.buffering { diff --git a/internal/output/oneline_test.go b/internal/output/oneline_test.go index 4c15c8aa..0eb82c8f 100644 --- a/internal/output/oneline_test.go +++ b/internal/output/oneline_test.go @@ -28,7 +28,7 @@ func TestOneLineFormatter(t *testing.T) { // Test case 2: Output with metadata formatter.WriteContent(Content{ Type: TypeDeviceList, - Data: []string{"device1", "device2", "device3"}, + Data: []DeviceEntry{{Name: "device1"}, {Name: "device2"}, {Name: "device3"}}, Metadata: map[string]string{ "server": "localhost:1024", "device": "test-device", diff --git a/internal/output/output.go b/internal/output/output.go index 52efbdff..12dd70a8 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -30,8 +30,19 @@ const ( // TypeVersion represents version information. TypeVersion ContentType = "version" + + // TypeLockResult represents the result of a lock or unlock operation. + TypeLockResult ContentType = "lock-result" ) +// DeviceEntry describes a device and its lock state for TypeDeviceList output. +type DeviceEntry struct { + Name string + Locked bool + Owner string + ExpiresAt int64 // Unix seconds, 0 means no expiry. +} + // Content is a structured data unit to be formatted and displayed. type Content struct { // Type identifies the category of this content. diff --git a/internal/output/text.go b/internal/output/text.go index 49732dd6..a49bb94a 100644 --- a/internal/output/text.go +++ b/internal/output/text.go @@ -11,6 +11,7 @@ import ( "os" "slices" "strings" + "time" ) // TextFormatter implements Formatter with plain text formatting capabilities. @@ -53,9 +54,9 @@ func newTextFormatter(config Config) *TextFormatter { } } -// WriteContent formats and outputs structured content. -func (f *TextFormatter) WriteContent(content Content) { - // Get appropriate writer based on buffering mode and error state +// selectWriter returns the writer for content based on buffering mode and +// error state. +func (f *TextFormatter) selectWriter(content Content) io.Writer { var writer io.Writer if f.buffering { @@ -72,6 +73,13 @@ func (f *TextFormatter) WriteContent(content Content) { } } + return writer +} + +// WriteContent formats and outputs structured content. +func (f *TextFormatter) WriteContent(content Content) { + writer := f.selectWriter(content) + // Format and write content based on type, regardless of error state switch content.Type { case TypeDeviceList: @@ -84,6 +92,8 @@ func (f *TextFormatter) WriteContent(content Content) { f.writeDetailTo(content, writer) case TypeModuleOutput: f.writeModuleOutputTo(content, writer) + case TypeLockResult: + f.writeLockResultTo(content, writer) default: // For general text or unrecognized types f.writeGeneralTo(content, writer) @@ -148,17 +158,79 @@ func (f *TextFormatter) Flush() error { // Helper methods for different content types +// humanDuration renders dur as a compact "1h30m"-style string, rounded to the +// minute. A non-positive duration renders as "0m". +func humanDuration(dur time.Duration) string { + dur = dur.Round(time.Minute) + if dur <= 0 { + return "0m" + } + + hours := dur / time.Hour + minutes := (dur % time.Hour) / time.Minute + + switch { + case hours > 0 && minutes > 0: + return fmt.Sprintf("%dh%dm", hours, minutes) + case hours > 0: + return fmt.Sprintf("%dh", hours) + default: + return fmt.Sprintf("%dm", minutes) + } +} + +// lockAnnotation renders the bracketed lock note for a locked device, e.g. +// ` [locked by "alice@host" for 25m]`. ExpiresAt of 0 omits the duration. +func lockAnnotation(entry DeviceEntry) string { + if entry.ExpiresAt == 0 { + return fmt.Sprintf(" [locked by %q]", entry.Owner) + } + + remaining := humanDuration(time.Until(time.Unix(entry.ExpiresAt, 0))) + + return fmt.Sprintf(" [locked by %q for %s]", entry.Owner, remaining) +} + // writeDeviceListTo formats and writes a list of devices with bullet points. func (f *TextFormatter) writeDeviceListTo(content Content, writer io.Writer) { - if devices, ok := content.Data.([]string); ok { - // Print metadata before content - f.writeMetadata(content, writer) + devices, ok := content.Data.([]DeviceEntry) + if !ok { + f.writeGeneralTo(content, writer) + + return + } - for _, device := range devices { - fmt.Fprintf(writer, "- %s\n", device) + // Print metadata before content + f.writeMetadata(content, writer) + + for _, device := range devices { + if device.Locked { + fmt.Fprintf(writer, "- %s%s\n", device.Name, lockAnnotation(device)) + } else { + fmt.Fprintf(writer, "- %s\n", device.Name) } - } else { + } +} + +// writeLockResultTo formats and writes the result of a lock or unlock operation. +func (f *TextFormatter) writeLockResultTo(content Content, writer io.Writer) { + entry, ok := content.Data.(DeviceEntry) + if !ok { f.writeGeneralTo(content, writer) + + return + } + + f.writeMetadata(content, writer) + + switch { + case !entry.Locked: + fmt.Fprintf(writer, "Device %q unlocked\n", entry.Name) + case entry.ExpiresAt == 0: + fmt.Fprintf(writer, "Device %q locked by %q\n", entry.Name, entry.Owner) + default: + remaining := humanDuration(time.Until(time.Unix(entry.ExpiresAt, 0))) + fmt.Fprintf(writer, "Device %q locked by %q for %s\n", entry.Name, entry.Owner, remaining) } } diff --git a/internal/output/text_test.go b/internal/output/text_test.go index f9e7041c..d37ce887 100644 --- a/internal/output/text_test.go +++ b/internal/output/text_test.go @@ -8,6 +8,7 @@ import ( "bytes" "strings" "testing" + "time" ) func TestWriteMetadata(t *testing.T) { @@ -308,3 +309,66 @@ func TestMetadataCaching(t *testing.T) { t.Errorf("Fifth output should include metadata (with # prefix) due to cache clear. Got: %q", fifthOutput) } } + +func TestWriteDeviceList(t *testing.T) { + stdout := &bytes.Buffer{} + formatter := newTextFormatter(Config{Stdout: stdout, Stderr: &bytes.Buffer{}}) + + formatter.WriteContent(Content{ + Type: TypeDeviceList, + Data: []DeviceEntry{ + {Name: "my-board", Locked: true, Owner: "alice@host", ExpiresAt: time.Now().Add(25 * time.Minute).Unix()}, + {Name: "auto-board", Locked: true, Owner: "bob@host"}, + {Name: "free-board"}, + }, + }) + + got := stdout.String() + + for _, want := range []string{ + `- my-board [locked by "alice@host" for 25m]`, + `- auto-board [locked by "bob@host"]`, + "- free-board\n", + } { + if !strings.Contains(got, want) { + t.Errorf("device list output missing %q.\nGot:\n%s", want, got) + } + } +} + +func TestWriteLockResult(t *testing.T) { + tests := []struct { + name string + data DeviceEntry + want string + }{ + { + name: "timed lock", + data: DeviceEntry{Name: "my-board", Locked: true, Owner: "alice@host", ExpiresAt: time.Now().Add(30 * time.Minute).Unix()}, + want: `Device "my-board" locked by "alice@host" for 30m`, + }, + { + name: "auto lock without expiry", + data: DeviceEntry{Name: "my-board", Locked: true, Owner: "alice@host"}, + want: `Device "my-board" locked by "alice@host"` + "\n", + }, + { + name: "unlock", + data: DeviceEntry{Name: "my-board"}, + want: `Device "my-board" unlocked`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stdout := &bytes.Buffer{} + formatter := newTextFormatter(Config{Stdout: stdout, Stderr: &bytes.Buffer{}}) + + formatter.WriteContent(Content{Type: TypeLockResult, Data: tt.data}) + + if got := stdout.String(); !strings.Contains(got, tt.want) { + t.Errorf("lock result output = %q, want substring %q", got, tt.want) + } + }) + } +} diff --git a/internal/output/yaml_test.go b/internal/output/yaml_test.go index 2ee74418..2bb1a036 100644 --- a/internal/output/yaml_test.go +++ b/internal/output/yaml_test.go @@ -30,7 +30,7 @@ func TestYAMLFormatter(t *testing.T) { // Test case 2: Output with metadata formatter.WriteContent(Content{ Type: TypeDeviceList, - Data: []string{"device1", "device2", "device3"}, + Data: []DeviceEntry{{Name: "device1"}, {Name: "device2"}, {Name: "device3"}}, Metadata: map[string]string{ "server": "localhost:1024", "device": "test-device", diff --git a/pkg/dut/config.go b/pkg/dut/config.go index 3d50d9de..e6fc58f7 100644 --- a/pkg/dut/config.go +++ b/pkg/dut/config.go @@ -55,6 +55,7 @@ var ( ErrModuleNotFound = errors.New("module not found") ErrEmptyDevices = errors.New("devices must not be empty") ErrNoCommands = errors.New("device must have at least one command") + ErrReservedCommand = errors.New("command name is reserved") ) // UnmarshalYAML unmarshals a Devlist from a YAML node, wrapping errors @@ -184,13 +185,17 @@ func decodeCmds(node *yaml.Node) (map[string]Command, error) { cmds := make(map[string]Command, len(node.Content)/2) //nolint:mnd // MappingNode stores key/value as alternating pairs // Iterate command entries to capture the command name for errors. - for i := 0; i < len(node.Content); i += 2 { - cmdName := node.Content[i].Value + for idx := 0; idx < len(node.Content); idx += 2 { + cmdName := node.Content[idx].Value + + if cmdName == "lock" || cmdName == "unlock" { + return nil, &ConfigError{Command: cmdName, Err: ErrReservedCommand} + } var cmd Command // Decode triggers Command.UnmarshalYAML. - err := node.Content[i+1].Decode(&cmd) + err := node.Content[idx+1].Decode(&cmd) if err != nil { var configErr *ConfigError if errors.As(err, &configErr) { diff --git a/pkg/dut/config_test.go b/pkg/dut/config_test.go index 9a0a0869..75cd85dd 100644 --- a/pkg/dut/config_test.go +++ b/pkg/dut/config_test.go @@ -140,6 +140,15 @@ func TestInvalidConfig(t *testing.T) { wantLine: 12, }, + // Reserved command names + { + name: "reserved_command_name", + file: "invalid_reserved_command.yaml", + wantSentinel: ErrReservedCommand, + wantDevice: "device1", + wantCommand: "lock", + }, + // Null device value { name: "null_device", diff --git a/pkg/dut/testdata/invalid_reserved_command.yaml b/pkg/dut/testdata/invalid_reserved_command.yaml new file mode 100644 index 00000000..28dec737 --- /dev/null +++ b/pkg/dut/testdata/invalid_reserved_command.yaml @@ -0,0 +1,7 @@ +device1: + desc: "Device 1" + cmds: + lock: + desc: "Report status" + uses: + - module: dummy-status diff --git a/pkg/lock/header.go b/pkg/lock/header.go new file mode 100644 index 00000000..e977d4ae --- /dev/null +++ b/pkg/lock/header.go @@ -0,0 +1,15 @@ +// Copyright 2025 Blindspot Software +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package lock defines constants shared between the dutctl client and the +// dutagent for the per-device locking feature. +package lock + +// UserHeader is the HTTP header used to carry the requesting user's +// identity from the client to the agent. We reuse the standard "From" +// header (RFC 9110 section 10.1.2): its defined purpose is to identify the +// human user controlling the requesting user agent. User identity travels +// in the header rather than in proto messages so that older clients (which +// omit it) remain compatible. +const UserHeader = "From" diff --git a/pkg/lock/user.go b/pkg/lock/user.go new file mode 100644 index 00000000..2c6338b5 --- /dev/null +++ b/pkg/lock/user.go @@ -0,0 +1,48 @@ +// Copyright 2025 Blindspot Software +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package lock + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "os" +) + +// anonymousSuffixBytes is the random-byte length appended to anonymous +// identities; rendered as hex, the visible suffix length is twice this. +const anonymousSuffixBytes = 4 + +// DefaultUser returns the identity used by interactive clients when the user +// did not pass one explicitly: "@". The value is deterministic so +// subsequent invocations from the same shell can release a lock they took. +// When USER or hostname cannot be read, the caller is effectively anonymous +// and AnonymousUser is returned to keep concurrent anonymous callers from +// colliding on a single identity. +func DefaultUser() string { + user := os.Getenv("USER") + host, hostErr := os.Hostname() + + if user == "" || hostErr != nil || host == "" { + return AnonymousUser() + } + + return fmt.Sprintf("%s@%s", user, host) +} + +// AnonymousUser returns the placeholder identity assigned by the agent to a +// caller whose identity could not be determined, e.g. when the request omits +// UserHeader. The random suffix prevents unrelated anonymous callers from +// colliding on a single shared identity. +func AnonymousUser() string { + return "unknown-" + randSuffix(anonymousSuffixBytes) +} + +func randSuffix(n int) string { + buf := make([]byte, n) + _, _ = rand.Read(buf) + + return hex.EncodeToString(buf) +} diff --git a/protobuf/dutctl/v1/dutctl.proto b/protobuf/dutctl/v1/dutctl.proto index ceb9976e..8f04d11f 100644 --- a/protobuf/dutctl/v1/dutctl.proto +++ b/protobuf/dutctl/v1/dutctl.proto @@ -10,6 +10,8 @@ service DeviceService { rpc Commands(CommandsRequest) returns (CommandsResponse) {} rpc Details(DetailsRequest) returns (DetailsResponse) {} rpc Run(stream RunRequest) returns (stream RunResponse) {} + rpc Lock(LockRequest) returns (LockResponse) {} + rpc Unlock(UnlockRequest) returns (UnlockResponse) {} } // ListRequest is sent by the client to request a list of devices connected to the agent. @@ -17,7 +19,22 @@ message ListRequest {} // ListResponse is sent by the agent in response to a ListRequest. message ListResponse { - repeated string devices = 1; + repeated DeviceInfo devices = 1; +} + +// DeviceInfo describes a single device and its current lock state. +message DeviceInfo { + string name = 1; + LockInfo lock = 2; // Unset when the device is not locked. +} + +// LockInfo describes the lock state of a device. The enclosing DeviceInfo +// leaves its lock field unset when the device is not locked, so this message +// does not repeat that signal as a separate boolean. +message LockInfo { + string owner = 1; + int64 locked_at = 2; // Unix seconds. + int64 expires_at = 3; // Unix seconds, 0 means no expiry. } // CommandsRequest is sent by the client to request a list of commands available for @@ -99,6 +116,31 @@ message File { bytes content = 2; } +// LockRequest is sent by the client to acquire or extend a lock on a device. +// The lock owner identity is carried in an HTTP header, not in this message. +message LockRequest { + string device = 1; + int64 duration_seconds = 2; // 0 means a lock with no time-based expiry. +} + +// LockResponse is sent by the agent in response to a successful LockRequest. +message LockResponse { + string device = 1; + string owner = 2; + int64 locked_at = 3; // Unix seconds. + int64 expires_at = 4; // Unix seconds, 0 means no expiry. +} + +// UnlockRequest is sent by the client to release a lock on a device. +// The lock owner identity is carried in an HTTP header, not in this message. +message UnlockRequest { + string device = 1; + bool force = 2; // Release the lock regardless of owner. +} + +// UnlockResponse is sent by the agent in response to a successful UnlockRequest. +message UnlockResponse {} + // RelayService defines the service for forwarding communication via relay server. // NOTE: This is an experimental service and may change in the future. service RelayService { diff --git a/protobuf/gen/dutctl/v1/dutctl.pb.go b/protobuf/gen/dutctl/v1/dutctl.pb.go index 18a63900..8bf030bb 100644 --- a/protobuf/gen/dutctl/v1/dutctl.pb.go +++ b/protobuf/gen/dutctl/v1/dutctl.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.34.2 +// protoc-gen-go v1.36.11 // protoc (unknown) // source: dutctl/v1/dutctl.proto @@ -11,6 +11,7 @@ import ( protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" + unsafe "unsafe" ) const ( @@ -22,18 +23,16 @@ const ( // ListRequest is sent by the client to request a list of devices connected to the agent. type ListRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ListRequest) Reset() { *x = ListRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_dutctl_v1_dutctl_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *ListRequest) String() string { @@ -44,7 +43,7 @@ func (*ListRequest) ProtoMessage() {} func (x *ListRequest) ProtoReflect() protoreflect.Message { mi := &file_dutctl_v1_dutctl_proto_msgTypes[0] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -61,20 +60,17 @@ func (*ListRequest) Descriptor() ([]byte, []int) { // ListResponse is sent by the agent in response to a ListRequest. type ListResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Devices []*DeviceInfo `protobuf:"bytes,1,rep,name=devices,proto3" json:"devices,omitempty"` unknownFields protoimpl.UnknownFields - - Devices []string `protobuf:"bytes,1,rep,name=devices,proto3" json:"devices,omitempty"` + sizeCache protoimpl.SizeCache } func (x *ListResponse) Reset() { *x = ListResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_dutctl_v1_dutctl_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *ListResponse) String() string { @@ -85,7 +81,7 @@ func (*ListResponse) ProtoMessage() {} func (x *ListResponse) ProtoReflect() protoreflect.Message { mi := &file_dutctl_v1_dutctl_proto_msgTypes[1] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -100,30 +96,143 @@ func (*ListResponse) Descriptor() ([]byte, []int) { return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{1} } -func (x *ListResponse) GetDevices() []string { +func (x *ListResponse) GetDevices() []*DeviceInfo { if x != nil { return x.Devices } return nil } +// DeviceInfo describes a single device and its current lock state. +type DeviceInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Lock *LockInfo `protobuf:"bytes,2,opt,name=lock,proto3" json:"lock,omitempty"` // Unset when the device is not locked. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeviceInfo) Reset() { + *x = DeviceInfo{} + mi := &file_dutctl_v1_dutctl_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeviceInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeviceInfo) ProtoMessage() {} + +func (x *DeviceInfo) ProtoReflect() protoreflect.Message { + mi := &file_dutctl_v1_dutctl_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeviceInfo.ProtoReflect.Descriptor instead. +func (*DeviceInfo) Descriptor() ([]byte, []int) { + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{2} +} + +func (x *DeviceInfo) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *DeviceInfo) GetLock() *LockInfo { + if x != nil { + return x.Lock + } + return nil +} + +// LockInfo describes the lock state of a device. The enclosing DeviceInfo +// leaves its lock field unset when the device is not locked, so this message +// does not repeat that signal as a separate boolean. +type LockInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Owner string `protobuf:"bytes,1,opt,name=owner,proto3" json:"owner,omitempty"` + LockedAt int64 `protobuf:"varint,2,opt,name=locked_at,json=lockedAt,proto3" json:"locked_at,omitempty"` // Unix seconds. + ExpiresAt int64 `protobuf:"varint,3,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` // Unix seconds, 0 means no expiry. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LockInfo) Reset() { + *x = LockInfo{} + mi := &file_dutctl_v1_dutctl_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LockInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LockInfo) ProtoMessage() {} + +func (x *LockInfo) ProtoReflect() protoreflect.Message { + mi := &file_dutctl_v1_dutctl_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LockInfo.ProtoReflect.Descriptor instead. +func (*LockInfo) Descriptor() ([]byte, []int) { + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{3} +} + +func (x *LockInfo) GetOwner() string { + if x != nil { + return x.Owner + } + return "" +} + +func (x *LockInfo) GetLockedAt() int64 { + if x != nil { + return x.LockedAt + } + return 0 +} + +func (x *LockInfo) GetExpiresAt() int64 { + if x != nil { + return x.ExpiresAt + } + return 0 +} + // CommandsRequest is sent by the client to request a list of commands available for // a specific device. type CommandsRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Device string `protobuf:"bytes,1,opt,name=device,proto3" json:"device,omitempty"` unknownFields protoimpl.UnknownFields - - Device string `protobuf:"bytes,1,opt,name=device,proto3" json:"device,omitempty"` + sizeCache protoimpl.SizeCache } func (x *CommandsRequest) Reset() { *x = CommandsRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_dutctl_v1_dutctl_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *CommandsRequest) String() string { @@ -133,8 +242,8 @@ func (x *CommandsRequest) String() string { func (*CommandsRequest) ProtoMessage() {} func (x *CommandsRequest) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[2] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_dutctl_v1_dutctl_proto_msgTypes[4] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -146,7 +255,7 @@ func (x *CommandsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CommandsRequest.ProtoReflect.Descriptor instead. func (*CommandsRequest) Descriptor() ([]byte, []int) { - return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{2} + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{4} } func (x *CommandsRequest) GetDevice() string { @@ -158,20 +267,17 @@ func (x *CommandsRequest) GetDevice() string { // CommandsResponse is sent by the agent in response to a CommandsRequest. type CommandsResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Commands []string `protobuf:"bytes,1,rep,name=commands,proto3" json:"commands,omitempty"` unknownFields protoimpl.UnknownFields - - Commands []string `protobuf:"bytes,1,rep,name=commands,proto3" json:"commands,omitempty"` + sizeCache protoimpl.SizeCache } func (x *CommandsResponse) Reset() { *x = CommandsResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_dutctl_v1_dutctl_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *CommandsResponse) String() string { @@ -181,8 +287,8 @@ func (x *CommandsResponse) String() string { func (*CommandsResponse) ProtoMessage() {} func (x *CommandsResponse) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[3] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_dutctl_v1_dutctl_proto_msgTypes[5] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -194,7 +300,7 @@ func (x *CommandsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CommandsResponse.ProtoReflect.Descriptor instead. func (*CommandsResponse) Descriptor() ([]byte, []int) { - return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{3} + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{5} } func (x *CommandsResponse) GetCommands() []string { @@ -207,22 +313,19 @@ func (x *CommandsResponse) GetCommands() []string { // DetailsRequest is sent by the client to request further information for specific // device or a specific command. The type of information is defined by keyword. type DetailsRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Device string `protobuf:"bytes,1,opt,name=device,proto3" json:"device,omitempty"` + Cmd string `protobuf:"bytes,2,opt,name=cmd,proto3" json:"cmd,omitempty"` + Keyword string `protobuf:"bytes,3,opt,name=keyword,proto3" json:"keyword,omitempty"` unknownFields protoimpl.UnknownFields - - Device string `protobuf:"bytes,1,opt,name=device,proto3" json:"device,omitempty"` - Cmd string `protobuf:"bytes,2,opt,name=cmd,proto3" json:"cmd,omitempty"` - Keyword string `protobuf:"bytes,3,opt,name=keyword,proto3" json:"keyword,omitempty"` + sizeCache protoimpl.SizeCache } func (x *DetailsRequest) Reset() { *x = DetailsRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_dutctl_v1_dutctl_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *DetailsRequest) String() string { @@ -232,8 +335,8 @@ func (x *DetailsRequest) String() string { func (*DetailsRequest) ProtoMessage() {} func (x *DetailsRequest) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[4] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_dutctl_v1_dutctl_proto_msgTypes[6] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -245,7 +348,7 @@ func (x *DetailsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DetailsRequest.ProtoReflect.Descriptor instead. func (*DetailsRequest) Descriptor() ([]byte, []int) { - return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{4} + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{6} } func (x *DetailsRequest) GetDevice() string { @@ -271,20 +374,17 @@ func (x *DetailsRequest) GetKeyword() string { // DetailsResponse is sent by the agent in response to a DetailsRequest. type DetailsResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Details string `protobuf:"bytes,1,opt,name=details,proto3" json:"details,omitempty"` unknownFields protoimpl.UnknownFields - - Details string `protobuf:"bytes,1,opt,name=details,proto3" json:"details,omitempty"` + sizeCache protoimpl.SizeCache } func (x *DetailsResponse) Reset() { *x = DetailsResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_dutctl_v1_dutctl_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *DetailsResponse) String() string { @@ -294,8 +394,8 @@ func (x *DetailsResponse) String() string { func (*DetailsResponse) ProtoMessage() {} func (x *DetailsResponse) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[5] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_dutctl_v1_dutctl_proto_msgTypes[7] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -307,7 +407,7 @@ func (x *DetailsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DetailsResponse.ProtoReflect.Descriptor instead. func (*DetailsResponse) Descriptor() ([]byte, []int) { - return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{5} + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{7} } func (x *DetailsResponse) GetDetails() string { @@ -321,25 +421,22 @@ func (x *DetailsResponse) GetDetails() string { // to further interact with the agent during the command execution. // The first RunRequest message sent to a agent must always contain a Command message. type RunRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - // Types that are assignable to Msg: + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Msg: // // *RunRequest_Command // *RunRequest_Console // *RunRequest_File - Msg isRunRequest_Msg `protobuf_oneof:"msg"` + Msg isRunRequest_Msg `protobuf_oneof:"msg"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RunRequest) Reset() { *x = RunRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_dutctl_v1_dutctl_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *RunRequest) String() string { @@ -349,8 +446,8 @@ func (x *RunRequest) String() string { func (*RunRequest) ProtoMessage() {} func (x *RunRequest) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[6] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_dutctl_v1_dutctl_proto_msgTypes[8] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -362,33 +459,39 @@ func (x *RunRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RunRequest.ProtoReflect.Descriptor instead. func (*RunRequest) Descriptor() ([]byte, []int) { - return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{6} + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{8} } -func (m *RunRequest) GetMsg() isRunRequest_Msg { - if m != nil { - return m.Msg +func (x *RunRequest) GetMsg() isRunRequest_Msg { + if x != nil { + return x.Msg } return nil } func (x *RunRequest) GetCommand() *Command { - if x, ok := x.GetMsg().(*RunRequest_Command); ok { - return x.Command + if x != nil { + if x, ok := x.Msg.(*RunRequest_Command); ok { + return x.Command + } } return nil } func (x *RunRequest) GetConsole() *Console { - if x, ok := x.GetMsg().(*RunRequest_Console); ok { - return x.Console + if x != nil { + if x, ok := x.Msg.(*RunRequest_Console); ok { + return x.Console + } } return nil } func (x *RunRequest) GetFile() *File { - if x, ok := x.GetMsg().(*RunRequest_File); ok { - return x.File + if x != nil { + if x, ok := x.Msg.(*RunRequest_File); ok { + return x.File + } } return nil } @@ -418,26 +521,23 @@ func (*RunRequest_File) isRunRequest_Msg() {} // RunResponse is sent by the agent in response to a RunRequest and can either contain // just the output of the command (Print), or trigger further interaction with the client. type RunResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - // Types that are assignable to Msg: + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Msg: // // *RunResponse_Print // *RunResponse_Console // *RunResponse_FileRequest // *RunResponse_File - Msg isRunResponse_Msg `protobuf_oneof:"msg"` + Msg isRunResponse_Msg `protobuf_oneof:"msg"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RunResponse) Reset() { *x = RunResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_dutctl_v1_dutctl_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *RunResponse) String() string { @@ -447,8 +547,8 @@ func (x *RunResponse) String() string { func (*RunResponse) ProtoMessage() {} func (x *RunResponse) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[7] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_dutctl_v1_dutctl_proto_msgTypes[9] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -460,40 +560,48 @@ func (x *RunResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RunResponse.ProtoReflect.Descriptor instead. func (*RunResponse) Descriptor() ([]byte, []int) { - return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{7} + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{9} } -func (m *RunResponse) GetMsg() isRunResponse_Msg { - if m != nil { - return m.Msg +func (x *RunResponse) GetMsg() isRunResponse_Msg { + if x != nil { + return x.Msg } return nil } func (x *RunResponse) GetPrint() *Print { - if x, ok := x.GetMsg().(*RunResponse_Print); ok { - return x.Print + if x != nil { + if x, ok := x.Msg.(*RunResponse_Print); ok { + return x.Print + } } return nil } func (x *RunResponse) GetConsole() *Console { - if x, ok := x.GetMsg().(*RunResponse_Console); ok { - return x.Console + if x != nil { + if x, ok := x.Msg.(*RunResponse_Console); ok { + return x.Console + } } return nil } func (x *RunResponse) GetFileRequest() *FileRequest { - if x, ok := x.GetMsg().(*RunResponse_FileRequest); ok { - return x.FileRequest + if x != nil { + if x, ok := x.Msg.(*RunResponse_FileRequest); ok { + return x.FileRequest + } } return nil } func (x *RunResponse) GetFile() *File { - if x, ok := x.GetMsg().(*RunResponse_File); ok { - return x.File + if x != nil { + if x, ok := x.Msg.(*RunResponse_File); ok { + return x.File + } } return nil } @@ -528,22 +636,19 @@ func (*RunResponse_File) isRunResponse_Msg() {} // Command is used by the client to start a command execution on a device. type Command struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Device string `protobuf:"bytes,1,opt,name=device,proto3" json:"device,omitempty"` + Command string `protobuf:"bytes,2,opt,name=command,proto3" json:"command,omitempty"` + Args []string `protobuf:"bytes,3,rep,name=args,proto3" json:"args,omitempty"` unknownFields protoimpl.UnknownFields - - Device string `protobuf:"bytes,1,opt,name=device,proto3" json:"device,omitempty"` - Command string `protobuf:"bytes,2,opt,name=command,proto3" json:"command,omitempty"` - Args []string `protobuf:"bytes,3,rep,name=args,proto3" json:"args,omitempty"` + sizeCache protoimpl.SizeCache } func (x *Command) Reset() { *x = Command{} - if protoimpl.UnsafeEnabled { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_dutctl_v1_dutctl_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *Command) String() string { @@ -553,8 +658,8 @@ func (x *Command) String() string { func (*Command) ProtoMessage() {} func (x *Command) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[8] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_dutctl_v1_dutctl_proto_msgTypes[10] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -566,7 +671,7 @@ func (x *Command) ProtoReflect() protoreflect.Message { // Deprecated: Use Command.ProtoReflect.Descriptor instead. func (*Command) Descriptor() ([]byte, []int) { - return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{8} + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{10} } func (x *Command) GetDevice() string { @@ -592,20 +697,17 @@ func (x *Command) GetArgs() []string { // Print is used by the agent to send the output of a command execution to the client. type Print struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Text []byte `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` unknownFields protoimpl.UnknownFields - - Text []byte `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` + sizeCache protoimpl.SizeCache } func (x *Print) Reset() { *x = Print{} - if protoimpl.UnsafeEnabled { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_dutctl_v1_dutctl_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *Print) String() string { @@ -615,8 +717,8 @@ func (x *Print) String() string { func (*Print) ProtoMessage() {} func (x *Print) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[9] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_dutctl_v1_dutctl_proto_msgTypes[11] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -628,7 +730,7 @@ func (x *Print) ProtoReflect() protoreflect.Message { // Deprecated: Use Print.ProtoReflect.Descriptor instead. func (*Print) Descriptor() ([]byte, []int) { - return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{9} + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{11} } func (x *Print) GetText() []byte { @@ -641,25 +743,22 @@ func (x *Print) GetText() []byte { // Console is used by the client and agent during an interactive command execution. // An interactive session can only be started by the agent by sending the first Console message. type Console struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - // Types that are assignable to Data: + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Data: // // *Console_Stdin // *Console_Stdout // *Console_Stderr - Data isConsole_Data `protobuf_oneof:"data"` + Data isConsole_Data `protobuf_oneof:"data"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Console) Reset() { *x = Console{} - if protoimpl.UnsafeEnabled { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[10] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_dutctl_v1_dutctl_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *Console) String() string { @@ -669,8 +768,8 @@ func (x *Console) String() string { func (*Console) ProtoMessage() {} func (x *Console) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[10] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_dutctl_v1_dutctl_proto_msgTypes[12] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -682,33 +781,39 @@ func (x *Console) ProtoReflect() protoreflect.Message { // Deprecated: Use Console.ProtoReflect.Descriptor instead. func (*Console) Descriptor() ([]byte, []int) { - return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{10} + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{12} } -func (m *Console) GetData() isConsole_Data { - if m != nil { - return m.Data +func (x *Console) GetData() isConsole_Data { + if x != nil { + return x.Data } return nil } func (x *Console) GetStdin() []byte { - if x, ok := x.GetData().(*Console_Stdin); ok { - return x.Stdin + if x != nil { + if x, ok := x.Data.(*Console_Stdin); ok { + return x.Stdin + } } return nil } func (x *Console) GetStdout() []byte { - if x, ok := x.GetData().(*Console_Stdout); ok { - return x.Stdout + if x != nil { + if x, ok := x.Data.(*Console_Stdout); ok { + return x.Stdout + } } return nil } func (x *Console) GetStderr() []byte { - if x, ok := x.GetData().(*Console_Stderr); ok { - return x.Stderr + if x != nil { + if x, ok := x.Data.(*Console_Stderr); ok { + return x.Stderr + } } return nil } @@ -737,20 +842,17 @@ func (*Console_Stderr) isConsole_Data() {} // FileRequest is used by the agent to request a file from the client. type FileRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` unknownFields protoimpl.UnknownFields - - Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + sizeCache protoimpl.SizeCache } func (x *FileRequest) Reset() { *x = FileRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[11] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_dutctl_v1_dutctl_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *FileRequest) String() string { @@ -760,8 +862,8 @@ func (x *FileRequest) String() string { func (*FileRequest) ProtoMessage() {} func (x *FileRequest) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[11] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_dutctl_v1_dutctl_proto_msgTypes[13] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -773,7 +875,7 @@ func (x *FileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FileRequest.ProtoReflect.Descriptor instead. func (*FileRequest) Descriptor() ([]byte, []int) { - return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{11} + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{13} } func (x *FileRequest) GetPath() string { @@ -785,21 +887,18 @@ func (x *FileRequest) GetPath() string { // File is used by the client and the agent to transfer a file. type File struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Content []byte `protobuf:"bytes,2,opt,name=content,proto3" json:"content,omitempty"` unknownFields protoimpl.UnknownFields - - Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` - Content []byte `protobuf:"bytes,2,opt,name=content,proto3" json:"content,omitempty"` + sizeCache protoimpl.SizeCache } func (x *File) Reset() { *x = File{} - if protoimpl.UnsafeEnabled { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[12] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_dutctl_v1_dutctl_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *File) String() string { @@ -809,8 +908,8 @@ func (x *File) String() string { func (*File) ProtoMessage() {} func (x *File) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[12] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_dutctl_v1_dutctl_proto_msgTypes[14] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -822,7 +921,7 @@ func (x *File) ProtoReflect() protoreflect.Message { // Deprecated: Use File.ProtoReflect.Descriptor instead. func (*File) Descriptor() ([]byte, []int) { - return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{12} + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{14} } func (x *File) GetPath() string { @@ -839,24 +938,235 @@ func (x *File) GetContent() []byte { return nil } +// LockRequest is sent by the client to acquire or extend a lock on a device. +// The lock owner identity is carried in an HTTP header, not in this message. +type LockRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Device string `protobuf:"bytes,1,opt,name=device,proto3" json:"device,omitempty"` + DurationSeconds int64 `protobuf:"varint,2,opt,name=duration_seconds,json=durationSeconds,proto3" json:"duration_seconds,omitempty"` // 0 means a lock with no time-based expiry. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LockRequest) Reset() { + *x = LockRequest{} + mi := &file_dutctl_v1_dutctl_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LockRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LockRequest) ProtoMessage() {} + +func (x *LockRequest) ProtoReflect() protoreflect.Message { + mi := &file_dutctl_v1_dutctl_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LockRequest.ProtoReflect.Descriptor instead. +func (*LockRequest) Descriptor() ([]byte, []int) { + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{15} +} + +func (x *LockRequest) GetDevice() string { + if x != nil { + return x.Device + } + return "" +} + +func (x *LockRequest) GetDurationSeconds() int64 { + if x != nil { + return x.DurationSeconds + } + return 0 +} + +// LockResponse is sent by the agent in response to a successful LockRequest. +type LockResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Device string `protobuf:"bytes,1,opt,name=device,proto3" json:"device,omitempty"` + Owner string `protobuf:"bytes,2,opt,name=owner,proto3" json:"owner,omitempty"` + LockedAt int64 `protobuf:"varint,3,opt,name=locked_at,json=lockedAt,proto3" json:"locked_at,omitempty"` // Unix seconds. + ExpiresAt int64 `protobuf:"varint,4,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` // Unix seconds, 0 means no expiry. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LockResponse) Reset() { + *x = LockResponse{} + mi := &file_dutctl_v1_dutctl_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LockResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LockResponse) ProtoMessage() {} + +func (x *LockResponse) ProtoReflect() protoreflect.Message { + mi := &file_dutctl_v1_dutctl_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LockResponse.ProtoReflect.Descriptor instead. +func (*LockResponse) Descriptor() ([]byte, []int) { + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{16} +} + +func (x *LockResponse) GetDevice() string { + if x != nil { + return x.Device + } + return "" +} + +func (x *LockResponse) GetOwner() string { + if x != nil { + return x.Owner + } + return "" +} + +func (x *LockResponse) GetLockedAt() int64 { + if x != nil { + return x.LockedAt + } + return 0 +} + +func (x *LockResponse) GetExpiresAt() int64 { + if x != nil { + return x.ExpiresAt + } + return 0 +} + +// UnlockRequest is sent by the client to release a lock on a device. +// The lock owner identity is carried in an HTTP header, not in this message. +type UnlockRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Device string `protobuf:"bytes,1,opt,name=device,proto3" json:"device,omitempty"` + Force bool `protobuf:"varint,2,opt,name=force,proto3" json:"force,omitempty"` // Release the lock regardless of owner. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UnlockRequest) Reset() { + *x = UnlockRequest{} + mi := &file_dutctl_v1_dutctl_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UnlockRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UnlockRequest) ProtoMessage() {} + +func (x *UnlockRequest) ProtoReflect() protoreflect.Message { + mi := &file_dutctl_v1_dutctl_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UnlockRequest.ProtoReflect.Descriptor instead. +func (*UnlockRequest) Descriptor() ([]byte, []int) { + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{17} +} + +func (x *UnlockRequest) GetDevice() string { + if x != nil { + return x.Device + } + return "" +} + +func (x *UnlockRequest) GetForce() bool { + if x != nil { + return x.Force + } + return false +} + +// UnlockResponse is sent by the agent in response to a successful UnlockRequest. +type UnlockResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UnlockResponse) Reset() { + *x = UnlockResponse{} + mi := &file_dutctl_v1_dutctl_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UnlockResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UnlockResponse) ProtoMessage() {} + +func (x *UnlockResponse) ProtoReflect() protoreflect.Message { + mi := &file_dutctl_v1_dutctl_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UnlockResponse.ProtoReflect.Descriptor instead. +func (*UnlockResponse) Descriptor() ([]byte, []int) { + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{18} +} + // RegisterRequest is sent by a device agent to register with the relay server. // NOTE: This is an experimental service and may change in the future. type RegisterRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Devices []string `protobuf:"bytes,1,rep,name=devices,proto3" json:"devices,omitempty"` // List of devices the agent is connected to. + Address string `protobuf:"bytes,2,opt,name=address,proto3" json:"address,omitempty"` // Address of the agent sending the request. unknownFields protoimpl.UnknownFields - - Devices []string `protobuf:"bytes,1,rep,name=devices,proto3" json:"devices,omitempty"` // List of devices the agent is connected to. - Address string `protobuf:"bytes,2,opt,name=address,proto3" json:"address,omitempty"` // Address of the agent sending the request. + sizeCache protoimpl.SizeCache } func (x *RegisterRequest) Reset() { *x = RegisterRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[13] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_dutctl_v1_dutctl_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *RegisterRequest) String() string { @@ -866,8 +1176,8 @@ func (x *RegisterRequest) String() string { func (*RegisterRequest) ProtoMessage() {} func (x *RegisterRequest) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[13] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_dutctl_v1_dutctl_proto_msgTypes[19] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -879,7 +1189,7 @@ func (x *RegisterRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RegisterRequest.ProtoReflect.Descriptor instead. func (*RegisterRequest) Descriptor() ([]byte, []int) { - return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{13} + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{19} } func (x *RegisterRequest) GetDevices() []string { @@ -896,21 +1206,19 @@ func (x *RegisterRequest) GetAddress() string { return "" } -// RegisterResponse is sent by the relay server in response to a sucsessful RegisterRequest. +// RegisterResponse is sent by the relay server in response to a successful RegisterRequest. // NOTE: This is an experimental service and may change in the future. type RegisterResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RegisterResponse) Reset() { *x = RegisterResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[14] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_dutctl_v1_dutctl_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *RegisterResponse) String() string { @@ -920,8 +1228,8 @@ func (x *RegisterResponse) String() string { func (*RegisterResponse) ProtoMessage() {} func (x *RegisterResponse) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[14] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_dutctl_v1_dutctl_proto_msgTypes[20] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -933,162 +1241,156 @@ func (x *RegisterResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RegisterResponse.ProtoReflect.Descriptor instead. func (*RegisterResponse) Descriptor() ([]byte, []int) { - return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{14} + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{20} } var File_dutctl_v1_dutctl_proto protoreflect.FileDescriptor -var file_dutctl_v1_dutctl_proto_rawDesc = []byte{ - 0x0a, 0x16, 0x64, 0x75, 0x74, 0x63, 0x74, 0x6c, 0x2f, 0x76, 0x31, 0x2f, 0x64, 0x75, 0x74, 0x63, - 0x74, 0x6c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x09, 0x64, 0x75, 0x74, 0x63, 0x74, 0x6c, - 0x2e, 0x76, 0x31, 0x22, 0x0d, 0x0a, 0x0b, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x22, 0x28, 0x0a, 0x0c, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x73, 0x18, 0x01, 0x20, - 0x03, 0x28, 0x09, 0x52, 0x07, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x73, 0x22, 0x29, 0x0a, 0x0f, - 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, - 0x16, 0x0a, 0x06, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x06, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x22, 0x2e, 0x0a, 0x10, 0x43, 0x6f, 0x6d, 0x6d, 0x61, - 0x6e, 0x64, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x63, - 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x63, - 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x73, 0x22, 0x54, 0x0a, 0x0e, 0x44, 0x65, 0x74, 0x61, 0x69, - 0x6c, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x65, 0x76, - 0x69, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x65, 0x76, 0x69, 0x63, - 0x65, 0x12, 0x10, 0x0a, 0x03, 0x63, 0x6d, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, - 0x63, 0x6d, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x6b, 0x65, 0x79, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6b, 0x65, 0x79, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x2b, 0x0a, - 0x0f, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x18, 0x0a, 0x07, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x07, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x22, 0x9a, 0x01, 0x0a, 0x0a, 0x52, - 0x75, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x07, 0x63, 0x6f, 0x6d, - 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x64, 0x75, 0x74, - 0x63, 0x74, 0x6c, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x48, 0x00, - 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x2e, 0x0a, 0x07, 0x63, 0x6f, 0x6e, - 0x73, 0x6f, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x64, 0x75, 0x74, - 0x63, 0x74, 0x6c, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x48, 0x00, - 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x12, 0x25, 0x0a, 0x04, 0x66, 0x69, 0x6c, - 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x64, 0x75, 0x74, 0x63, 0x74, 0x6c, - 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x48, 0x00, 0x52, 0x04, 0x66, 0x69, 0x6c, 0x65, - 0x42, 0x05, 0x0a, 0x03, 0x6d, 0x73, 0x67, 0x22, 0xd2, 0x01, 0x0a, 0x0b, 0x52, 0x75, 0x6e, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x28, 0x0a, 0x05, 0x70, 0x72, 0x69, 0x6e, 0x74, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x64, 0x75, 0x74, 0x63, 0x74, 0x6c, 0x2e, - 0x76, 0x31, 0x2e, 0x50, 0x72, 0x69, 0x6e, 0x74, 0x48, 0x00, 0x52, 0x05, 0x70, 0x72, 0x69, 0x6e, - 0x74, 0x12, 0x2e, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x64, 0x75, 0x74, 0x63, 0x74, 0x6c, 0x2e, 0x76, 0x31, 0x2e, 0x43, - 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x48, 0x00, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, - 0x65, 0x12, 0x3b, 0x0a, 0x0c, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x64, 0x75, 0x74, 0x63, 0x74, 0x6c, - 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, - 0x00, 0x52, 0x0b, 0x66, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x25, - 0x0a, 0x04, 0x66, 0x69, 0x6c, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x64, - 0x75, 0x74, 0x63, 0x74, 0x6c, 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x48, 0x00, 0x52, - 0x04, 0x66, 0x69, 0x6c, 0x65, 0x42, 0x05, 0x0a, 0x03, 0x6d, 0x73, 0x67, 0x22, 0x4f, 0x0a, 0x07, - 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x65, 0x76, 0x69, 0x63, - 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x12, - 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x61, 0x72, 0x67, - 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x61, 0x72, 0x67, 0x73, 0x22, 0x1b, 0x0a, - 0x05, 0x50, 0x72, 0x69, 0x6e, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x65, 0x78, 0x74, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x74, 0x65, 0x78, 0x74, 0x22, 0x5d, 0x0a, 0x07, 0x43, 0x6f, - 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x12, 0x16, 0x0a, 0x05, 0x73, 0x74, 0x64, 0x69, 0x6e, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0c, 0x48, 0x00, 0x52, 0x05, 0x73, 0x74, 0x64, 0x69, 0x6e, 0x12, 0x18, 0x0a, - 0x06, 0x73, 0x74, 0x64, 0x6f, 0x75, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x00, 0x52, - 0x06, 0x73, 0x74, 0x64, 0x6f, 0x75, 0x74, 0x12, 0x18, 0x0a, 0x06, 0x73, 0x74, 0x64, 0x65, 0x72, - 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x00, 0x52, 0x06, 0x73, 0x74, 0x64, 0x65, 0x72, - 0x72, 0x42, 0x06, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x22, 0x21, 0x0a, 0x0b, 0x46, 0x69, 0x6c, - 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x22, 0x34, 0x0a, 0x04, - 0x46, 0x69, 0x6c, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, - 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, - 0x6e, 0x74, 0x22, 0x45, 0x0a, 0x0f, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x73, - 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x73, 0x12, - 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x22, 0x12, 0x0a, 0x10, 0x52, 0x65, 0x67, - 0x69, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x91, 0x02, - 0x0a, 0x0d, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, - 0x39, 0x0a, 0x04, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x16, 0x2e, 0x64, 0x75, 0x74, 0x63, 0x74, 0x6c, - 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x17, 0x2e, 0x64, 0x75, 0x74, 0x63, 0x74, 0x6c, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x45, 0x0a, 0x08, 0x43, 0x6f, - 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x73, 0x12, 0x1a, 0x2e, 0x64, 0x75, 0x74, 0x63, 0x74, 0x6c, 0x2e, - 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x64, 0x75, 0x74, 0x63, 0x74, 0x6c, 0x2e, 0x76, 0x31, 0x2e, 0x43, - 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, - 0x00, 0x12, 0x42, 0x0a, 0x07, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x12, 0x19, 0x2e, 0x64, - 0x75, 0x74, 0x63, 0x74, 0x6c, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x64, 0x75, 0x74, 0x63, 0x74, 0x6c, - 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x3a, 0x0a, 0x03, 0x52, 0x75, 0x6e, 0x12, 0x15, 0x2e, 0x64, - 0x75, 0x74, 0x63, 0x74, 0x6c, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x75, 0x6e, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x64, 0x75, 0x74, 0x63, 0x74, 0x6c, 0x2e, 0x76, 0x31, 0x2e, - 0x52, 0x75, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x28, 0x01, 0x30, - 0x01, 0x32, 0x55, 0x0a, 0x0c, 0x52, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, - 0x65, 0x12, 0x45, 0x0a, 0x08, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x12, 0x1a, 0x2e, - 0x64, 0x75, 0x74, 0x63, 0x74, 0x6c, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, - 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x64, 0x75, 0x74, 0x63, - 0x74, 0x6c, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x45, 0x5a, 0x43, 0x67, 0x69, 0x74, 0x68, - 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x42, 0x6c, 0x69, 0x6e, 0x64, 0x73, 0x70, 0x6f, 0x74, - 0x53, 0x6f, 0x66, 0x74, 0x77, 0x61, 0x72, 0x65, 0x2f, 0x64, 0x75, 0x74, 0x63, 0x74, 0x6c, 0x2f, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x64, 0x75, 0x74, - 0x63, 0x74, 0x6c, 0x2f, 0x76, 0x31, 0x3b, 0x64, 0x75, 0x74, 0x63, 0x74, 0x6c, 0x76, 0x31, 0x62, - 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, -} +const file_dutctl_v1_dutctl_proto_rawDesc = "" + + "\n" + + "\x16dutctl/v1/dutctl.proto\x12\tdutctl.v1\"\r\n" + + "\vListRequest\"?\n" + + "\fListResponse\x12/\n" + + "\adevices\x18\x01 \x03(\v2\x15.dutctl.v1.DeviceInfoR\adevices\"I\n" + + "\n" + + "DeviceInfo\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12'\n" + + "\x04lock\x18\x02 \x01(\v2\x13.dutctl.v1.LockInfoR\x04lock\"\\\n" + + "\bLockInfo\x12\x14\n" + + "\x05owner\x18\x01 \x01(\tR\x05owner\x12\x1b\n" + + "\tlocked_at\x18\x02 \x01(\x03R\blockedAt\x12\x1d\n" + + "\n" + + "expires_at\x18\x03 \x01(\x03R\texpiresAt\")\n" + + "\x0fCommandsRequest\x12\x16\n" + + "\x06device\x18\x01 \x01(\tR\x06device\".\n" + + "\x10CommandsResponse\x12\x1a\n" + + "\bcommands\x18\x01 \x03(\tR\bcommands\"T\n" + + "\x0eDetailsRequest\x12\x16\n" + + "\x06device\x18\x01 \x01(\tR\x06device\x12\x10\n" + + "\x03cmd\x18\x02 \x01(\tR\x03cmd\x12\x18\n" + + "\akeyword\x18\x03 \x01(\tR\akeyword\"+\n" + + "\x0fDetailsResponse\x12\x18\n" + + "\adetails\x18\x01 \x01(\tR\adetails\"\x9a\x01\n" + + "\n" + + "RunRequest\x12.\n" + + "\acommand\x18\x01 \x01(\v2\x12.dutctl.v1.CommandH\x00R\acommand\x12.\n" + + "\aconsole\x18\x02 \x01(\v2\x12.dutctl.v1.ConsoleH\x00R\aconsole\x12%\n" + + "\x04file\x18\x03 \x01(\v2\x0f.dutctl.v1.FileH\x00R\x04fileB\x05\n" + + "\x03msg\"\xd2\x01\n" + + "\vRunResponse\x12(\n" + + "\x05print\x18\x01 \x01(\v2\x10.dutctl.v1.PrintH\x00R\x05print\x12.\n" + + "\aconsole\x18\x02 \x01(\v2\x12.dutctl.v1.ConsoleH\x00R\aconsole\x12;\n" + + "\ffile_request\x18\x03 \x01(\v2\x16.dutctl.v1.FileRequestH\x00R\vfileRequest\x12%\n" + + "\x04file\x18\x04 \x01(\v2\x0f.dutctl.v1.FileH\x00R\x04fileB\x05\n" + + "\x03msg\"O\n" + + "\aCommand\x12\x16\n" + + "\x06device\x18\x01 \x01(\tR\x06device\x12\x18\n" + + "\acommand\x18\x02 \x01(\tR\acommand\x12\x12\n" + + "\x04args\x18\x03 \x03(\tR\x04args\"\x1b\n" + + "\x05Print\x12\x12\n" + + "\x04text\x18\x01 \x01(\fR\x04text\"]\n" + + "\aConsole\x12\x16\n" + + "\x05stdin\x18\x01 \x01(\fH\x00R\x05stdin\x12\x18\n" + + "\x06stdout\x18\x02 \x01(\fH\x00R\x06stdout\x12\x18\n" + + "\x06stderr\x18\x03 \x01(\fH\x00R\x06stderrB\x06\n" + + "\x04data\"!\n" + + "\vFileRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\"4\n" + + "\x04File\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x18\n" + + "\acontent\x18\x02 \x01(\fR\acontent\"P\n" + + "\vLockRequest\x12\x16\n" + + "\x06device\x18\x01 \x01(\tR\x06device\x12)\n" + + "\x10duration_seconds\x18\x02 \x01(\x03R\x0fdurationSeconds\"x\n" + + "\fLockResponse\x12\x16\n" + + "\x06device\x18\x01 \x01(\tR\x06device\x12\x14\n" + + "\x05owner\x18\x02 \x01(\tR\x05owner\x12\x1b\n" + + "\tlocked_at\x18\x03 \x01(\x03R\blockedAt\x12\x1d\n" + + "\n" + + "expires_at\x18\x04 \x01(\x03R\texpiresAt\"=\n" + + "\rUnlockRequest\x12\x16\n" + + "\x06device\x18\x01 \x01(\tR\x06device\x12\x14\n" + + "\x05force\x18\x02 \x01(\bR\x05force\"\x10\n" + + "\x0eUnlockResponse\"E\n" + + "\x0fRegisterRequest\x12\x18\n" + + "\adevices\x18\x01 \x03(\tR\adevices\x12\x18\n" + + "\aaddress\x18\x02 \x01(\tR\aaddress\"\x12\n" + + "\x10RegisterResponse2\x8d\x03\n" + + "\rDeviceService\x129\n" + + "\x04List\x12\x16.dutctl.v1.ListRequest\x1a\x17.dutctl.v1.ListResponse\"\x00\x12E\n" + + "\bCommands\x12\x1a.dutctl.v1.CommandsRequest\x1a\x1b.dutctl.v1.CommandsResponse\"\x00\x12B\n" + + "\aDetails\x12\x19.dutctl.v1.DetailsRequest\x1a\x1a.dutctl.v1.DetailsResponse\"\x00\x12:\n" + + "\x03Run\x12\x15.dutctl.v1.RunRequest\x1a\x16.dutctl.v1.RunResponse\"\x00(\x010\x01\x129\n" + + "\x04Lock\x12\x16.dutctl.v1.LockRequest\x1a\x17.dutctl.v1.LockResponse\"\x00\x12?\n" + + "\x06Unlock\x12\x18.dutctl.v1.UnlockRequest\x1a\x19.dutctl.v1.UnlockResponse\"\x002U\n" + + "\fRelayService\x12E\n" + + "\bRegister\x12\x1a.dutctl.v1.RegisterRequest\x1a\x1b.dutctl.v1.RegisterResponse\"\x00BEZCgithub.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1;dutctlv1b\x06proto3" var ( file_dutctl_v1_dutctl_proto_rawDescOnce sync.Once - file_dutctl_v1_dutctl_proto_rawDescData = file_dutctl_v1_dutctl_proto_rawDesc + file_dutctl_v1_dutctl_proto_rawDescData []byte ) func file_dutctl_v1_dutctl_proto_rawDescGZIP() []byte { file_dutctl_v1_dutctl_proto_rawDescOnce.Do(func() { - file_dutctl_v1_dutctl_proto_rawDescData = protoimpl.X.CompressGZIP(file_dutctl_v1_dutctl_proto_rawDescData) + file_dutctl_v1_dutctl_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_dutctl_v1_dutctl_proto_rawDesc), len(file_dutctl_v1_dutctl_proto_rawDesc))) }) return file_dutctl_v1_dutctl_proto_rawDescData } -var file_dutctl_v1_dutctl_proto_msgTypes = make([]protoimpl.MessageInfo, 15) +var file_dutctl_v1_dutctl_proto_msgTypes = make([]protoimpl.MessageInfo, 21) var file_dutctl_v1_dutctl_proto_goTypes = []any{ (*ListRequest)(nil), // 0: dutctl.v1.ListRequest (*ListResponse)(nil), // 1: dutctl.v1.ListResponse - (*CommandsRequest)(nil), // 2: dutctl.v1.CommandsRequest - (*CommandsResponse)(nil), // 3: dutctl.v1.CommandsResponse - (*DetailsRequest)(nil), // 4: dutctl.v1.DetailsRequest - (*DetailsResponse)(nil), // 5: dutctl.v1.DetailsResponse - (*RunRequest)(nil), // 6: dutctl.v1.RunRequest - (*RunResponse)(nil), // 7: dutctl.v1.RunResponse - (*Command)(nil), // 8: dutctl.v1.Command - (*Print)(nil), // 9: dutctl.v1.Print - (*Console)(nil), // 10: dutctl.v1.Console - (*FileRequest)(nil), // 11: dutctl.v1.FileRequest - (*File)(nil), // 12: dutctl.v1.File - (*RegisterRequest)(nil), // 13: dutctl.v1.RegisterRequest - (*RegisterResponse)(nil), // 14: dutctl.v1.RegisterResponse + (*DeviceInfo)(nil), // 2: dutctl.v1.DeviceInfo + (*LockInfo)(nil), // 3: dutctl.v1.LockInfo + (*CommandsRequest)(nil), // 4: dutctl.v1.CommandsRequest + (*CommandsResponse)(nil), // 5: dutctl.v1.CommandsResponse + (*DetailsRequest)(nil), // 6: dutctl.v1.DetailsRequest + (*DetailsResponse)(nil), // 7: dutctl.v1.DetailsResponse + (*RunRequest)(nil), // 8: dutctl.v1.RunRequest + (*RunResponse)(nil), // 9: dutctl.v1.RunResponse + (*Command)(nil), // 10: dutctl.v1.Command + (*Print)(nil), // 11: dutctl.v1.Print + (*Console)(nil), // 12: dutctl.v1.Console + (*FileRequest)(nil), // 13: dutctl.v1.FileRequest + (*File)(nil), // 14: dutctl.v1.File + (*LockRequest)(nil), // 15: dutctl.v1.LockRequest + (*LockResponse)(nil), // 16: dutctl.v1.LockResponse + (*UnlockRequest)(nil), // 17: dutctl.v1.UnlockRequest + (*UnlockResponse)(nil), // 18: dutctl.v1.UnlockResponse + (*RegisterRequest)(nil), // 19: dutctl.v1.RegisterRequest + (*RegisterResponse)(nil), // 20: dutctl.v1.RegisterResponse } var file_dutctl_v1_dutctl_proto_depIdxs = []int32{ - 8, // 0: dutctl.v1.RunRequest.command:type_name -> dutctl.v1.Command - 10, // 1: dutctl.v1.RunRequest.console:type_name -> dutctl.v1.Console - 12, // 2: dutctl.v1.RunRequest.file:type_name -> dutctl.v1.File - 9, // 3: dutctl.v1.RunResponse.print:type_name -> dutctl.v1.Print - 10, // 4: dutctl.v1.RunResponse.console:type_name -> dutctl.v1.Console - 11, // 5: dutctl.v1.RunResponse.file_request:type_name -> dutctl.v1.FileRequest - 12, // 6: dutctl.v1.RunResponse.file:type_name -> dutctl.v1.File - 0, // 7: dutctl.v1.DeviceService.List:input_type -> dutctl.v1.ListRequest - 2, // 8: dutctl.v1.DeviceService.Commands:input_type -> dutctl.v1.CommandsRequest - 4, // 9: dutctl.v1.DeviceService.Details:input_type -> dutctl.v1.DetailsRequest - 6, // 10: dutctl.v1.DeviceService.Run:input_type -> dutctl.v1.RunRequest - 13, // 11: dutctl.v1.RelayService.Register:input_type -> dutctl.v1.RegisterRequest - 1, // 12: dutctl.v1.DeviceService.List:output_type -> dutctl.v1.ListResponse - 3, // 13: dutctl.v1.DeviceService.Commands:output_type -> dutctl.v1.CommandsResponse - 5, // 14: dutctl.v1.DeviceService.Details:output_type -> dutctl.v1.DetailsResponse - 7, // 15: dutctl.v1.DeviceService.Run:output_type -> dutctl.v1.RunResponse - 14, // 16: dutctl.v1.RelayService.Register:output_type -> dutctl.v1.RegisterResponse - 12, // [12:17] is the sub-list for method output_type - 7, // [7:12] is the sub-list for method input_type - 7, // [7:7] is the sub-list for extension type_name - 7, // [7:7] is the sub-list for extension extendee - 0, // [0:7] is the sub-list for field type_name + 2, // 0: dutctl.v1.ListResponse.devices:type_name -> dutctl.v1.DeviceInfo + 3, // 1: dutctl.v1.DeviceInfo.lock:type_name -> dutctl.v1.LockInfo + 10, // 2: dutctl.v1.RunRequest.command:type_name -> dutctl.v1.Command + 12, // 3: dutctl.v1.RunRequest.console:type_name -> dutctl.v1.Console + 14, // 4: dutctl.v1.RunRequest.file:type_name -> dutctl.v1.File + 11, // 5: dutctl.v1.RunResponse.print:type_name -> dutctl.v1.Print + 12, // 6: dutctl.v1.RunResponse.console:type_name -> dutctl.v1.Console + 13, // 7: dutctl.v1.RunResponse.file_request:type_name -> dutctl.v1.FileRequest + 14, // 8: dutctl.v1.RunResponse.file:type_name -> dutctl.v1.File + 0, // 9: dutctl.v1.DeviceService.List:input_type -> dutctl.v1.ListRequest + 4, // 10: dutctl.v1.DeviceService.Commands:input_type -> dutctl.v1.CommandsRequest + 6, // 11: dutctl.v1.DeviceService.Details:input_type -> dutctl.v1.DetailsRequest + 8, // 12: dutctl.v1.DeviceService.Run:input_type -> dutctl.v1.RunRequest + 15, // 13: dutctl.v1.DeviceService.Lock:input_type -> dutctl.v1.LockRequest + 17, // 14: dutctl.v1.DeviceService.Unlock:input_type -> dutctl.v1.UnlockRequest + 19, // 15: dutctl.v1.RelayService.Register:input_type -> dutctl.v1.RegisterRequest + 1, // 16: dutctl.v1.DeviceService.List:output_type -> dutctl.v1.ListResponse + 5, // 17: dutctl.v1.DeviceService.Commands:output_type -> dutctl.v1.CommandsResponse + 7, // 18: dutctl.v1.DeviceService.Details:output_type -> dutctl.v1.DetailsResponse + 9, // 19: dutctl.v1.DeviceService.Run:output_type -> dutctl.v1.RunResponse + 16, // 20: dutctl.v1.DeviceService.Lock:output_type -> dutctl.v1.LockResponse + 18, // 21: dutctl.v1.DeviceService.Unlock:output_type -> dutctl.v1.UnlockResponse + 20, // 22: dutctl.v1.RelayService.Register:output_type -> dutctl.v1.RegisterResponse + 16, // [16:23] is the sub-list for method output_type + 9, // [9:16] is the sub-list for method input_type + 9, // [9:9] is the sub-list for extension type_name + 9, // [9:9] is the sub-list for extension extendee + 0, // [0:9] is the sub-list for field type_name } func init() { file_dutctl_v1_dutctl_proto_init() } @@ -1096,200 +1398,18 @@ func file_dutctl_v1_dutctl_proto_init() { if File_dutctl_v1_dutctl_proto != nil { return } - if !protoimpl.UnsafeEnabled { - file_dutctl_v1_dutctl_proto_msgTypes[0].Exporter = func(v any, i int) any { - switch v := v.(*ListRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_dutctl_v1_dutctl_proto_msgTypes[1].Exporter = func(v any, i int) any { - switch v := v.(*ListResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_dutctl_v1_dutctl_proto_msgTypes[2].Exporter = func(v any, i int) any { - switch v := v.(*CommandsRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_dutctl_v1_dutctl_proto_msgTypes[3].Exporter = func(v any, i int) any { - switch v := v.(*CommandsResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_dutctl_v1_dutctl_proto_msgTypes[4].Exporter = func(v any, i int) any { - switch v := v.(*DetailsRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_dutctl_v1_dutctl_proto_msgTypes[5].Exporter = func(v any, i int) any { - switch v := v.(*DetailsResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_dutctl_v1_dutctl_proto_msgTypes[6].Exporter = func(v any, i int) any { - switch v := v.(*RunRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_dutctl_v1_dutctl_proto_msgTypes[7].Exporter = func(v any, i int) any { - switch v := v.(*RunResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_dutctl_v1_dutctl_proto_msgTypes[8].Exporter = func(v any, i int) any { - switch v := v.(*Command); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_dutctl_v1_dutctl_proto_msgTypes[9].Exporter = func(v any, i int) any { - switch v := v.(*Print); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_dutctl_v1_dutctl_proto_msgTypes[10].Exporter = func(v any, i int) any { - switch v := v.(*Console); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_dutctl_v1_dutctl_proto_msgTypes[11].Exporter = func(v any, i int) any { - switch v := v.(*FileRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_dutctl_v1_dutctl_proto_msgTypes[12].Exporter = func(v any, i int) any { - switch v := v.(*File); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_dutctl_v1_dutctl_proto_msgTypes[13].Exporter = func(v any, i int) any { - switch v := v.(*RegisterRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_dutctl_v1_dutctl_proto_msgTypes[14].Exporter = func(v any, i int) any { - switch v := v.(*RegisterResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - } - file_dutctl_v1_dutctl_proto_msgTypes[6].OneofWrappers = []any{ + file_dutctl_v1_dutctl_proto_msgTypes[8].OneofWrappers = []any{ (*RunRequest_Command)(nil), (*RunRequest_Console)(nil), (*RunRequest_File)(nil), } - file_dutctl_v1_dutctl_proto_msgTypes[7].OneofWrappers = []any{ + file_dutctl_v1_dutctl_proto_msgTypes[9].OneofWrappers = []any{ (*RunResponse_Print)(nil), (*RunResponse_Console)(nil), (*RunResponse_FileRequest)(nil), (*RunResponse_File)(nil), } - file_dutctl_v1_dutctl_proto_msgTypes[10].OneofWrappers = []any{ + file_dutctl_v1_dutctl_proto_msgTypes[12].OneofWrappers = []any{ (*Console_Stdin)(nil), (*Console_Stdout)(nil), (*Console_Stderr)(nil), @@ -1298,9 +1418,9 @@ func file_dutctl_v1_dutctl_proto_init() { out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: file_dutctl_v1_dutctl_proto_rawDesc, + RawDescriptor: unsafe.Slice(unsafe.StringData(file_dutctl_v1_dutctl_proto_rawDesc), len(file_dutctl_v1_dutctl_proto_rawDesc)), NumEnums: 0, - NumMessages: 15, + NumMessages: 21, NumExtensions: 0, NumServices: 2, }, @@ -1309,7 +1429,6 @@ func file_dutctl_v1_dutctl_proto_init() { MessageInfos: file_dutctl_v1_dutctl_proto_msgTypes, }.Build() File_dutctl_v1_dutctl_proto = out.File - file_dutctl_v1_dutctl_proto_rawDesc = nil file_dutctl_v1_dutctl_proto_goTypes = nil file_dutctl_v1_dutctl_proto_depIdxs = nil } diff --git a/protobuf/gen/dutctl/v1/dutctlv1connect/dutctl.connect.go b/protobuf/gen/dutctl/v1/dutctlv1connect/dutctl.connect.go index b421ea03..a8245e8f 100644 --- a/protobuf/gen/dutctl/v1/dutctlv1connect/dutctl.connect.go +++ b/protobuf/gen/dutctl/v1/dutctlv1connect/dutctl.connect.go @@ -43,27 +43,22 @@ const ( DeviceServiceDetailsProcedure = "/dutctl.v1.DeviceService/Details" // DeviceServiceRunProcedure is the fully-qualified name of the DeviceService's Run RPC. DeviceServiceRunProcedure = "/dutctl.v1.DeviceService/Run" + // DeviceServiceLockProcedure is the fully-qualified name of the DeviceService's Lock RPC. + DeviceServiceLockProcedure = "/dutctl.v1.DeviceService/Lock" + // DeviceServiceUnlockProcedure is the fully-qualified name of the DeviceService's Unlock RPC. + DeviceServiceUnlockProcedure = "/dutctl.v1.DeviceService/Unlock" // RelayServiceRegisterProcedure is the fully-qualified name of the RelayService's Register RPC. RelayServiceRegisterProcedure = "/dutctl.v1.RelayService/Register" ) -// These variables are the protoreflect.Descriptor objects for the RPCs defined in this package. -var ( - deviceServiceServiceDescriptor = v1.File_dutctl_v1_dutctl_proto.Services().ByName("DeviceService") - deviceServiceListMethodDescriptor = deviceServiceServiceDescriptor.Methods().ByName("List") - deviceServiceCommandsMethodDescriptor = deviceServiceServiceDescriptor.Methods().ByName("Commands") - deviceServiceDetailsMethodDescriptor = deviceServiceServiceDescriptor.Methods().ByName("Details") - deviceServiceRunMethodDescriptor = deviceServiceServiceDescriptor.Methods().ByName("Run") - relayServiceServiceDescriptor = v1.File_dutctl_v1_dutctl_proto.Services().ByName("RelayService") - relayServiceRegisterMethodDescriptor = relayServiceServiceDescriptor.Methods().ByName("Register") -) - // DeviceServiceClient is a client for the dutctl.v1.DeviceService service. type DeviceServiceClient interface { List(context.Context, *connect.Request[v1.ListRequest]) (*connect.Response[v1.ListResponse], error) Commands(context.Context, *connect.Request[v1.CommandsRequest]) (*connect.Response[v1.CommandsResponse], error) Details(context.Context, *connect.Request[v1.DetailsRequest]) (*connect.Response[v1.DetailsResponse], error) Run(context.Context) *connect.BidiStreamForClient[v1.RunRequest, v1.RunResponse] + Lock(context.Context, *connect.Request[v1.LockRequest]) (*connect.Response[v1.LockResponse], error) + Unlock(context.Context, *connect.Request[v1.UnlockRequest]) (*connect.Response[v1.UnlockResponse], error) } // NewDeviceServiceClient constructs a client for the dutctl.v1.DeviceService service. By default, @@ -75,29 +70,42 @@ type DeviceServiceClient interface { // http://api.acme.com or https://acme.com/grpc). func NewDeviceServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) DeviceServiceClient { baseURL = strings.TrimRight(baseURL, "/") + deviceServiceMethods := v1.File_dutctl_v1_dutctl_proto.Services().ByName("DeviceService").Methods() return &deviceServiceClient{ list: connect.NewClient[v1.ListRequest, v1.ListResponse]( httpClient, baseURL+DeviceServiceListProcedure, - connect.WithSchema(deviceServiceListMethodDescriptor), + connect.WithSchema(deviceServiceMethods.ByName("List")), connect.WithClientOptions(opts...), ), commands: connect.NewClient[v1.CommandsRequest, v1.CommandsResponse]( httpClient, baseURL+DeviceServiceCommandsProcedure, - connect.WithSchema(deviceServiceCommandsMethodDescriptor), + connect.WithSchema(deviceServiceMethods.ByName("Commands")), connect.WithClientOptions(opts...), ), details: connect.NewClient[v1.DetailsRequest, v1.DetailsResponse]( httpClient, baseURL+DeviceServiceDetailsProcedure, - connect.WithSchema(deviceServiceDetailsMethodDescriptor), + connect.WithSchema(deviceServiceMethods.ByName("Details")), connect.WithClientOptions(opts...), ), run: connect.NewClient[v1.RunRequest, v1.RunResponse]( httpClient, baseURL+DeviceServiceRunProcedure, - connect.WithSchema(deviceServiceRunMethodDescriptor), + connect.WithSchema(deviceServiceMethods.ByName("Run")), + connect.WithClientOptions(opts...), + ), + lock: connect.NewClient[v1.LockRequest, v1.LockResponse]( + httpClient, + baseURL+DeviceServiceLockProcedure, + connect.WithSchema(deviceServiceMethods.ByName("Lock")), + connect.WithClientOptions(opts...), + ), + unlock: connect.NewClient[v1.UnlockRequest, v1.UnlockResponse]( + httpClient, + baseURL+DeviceServiceUnlockProcedure, + connect.WithSchema(deviceServiceMethods.ByName("Unlock")), connect.WithClientOptions(opts...), ), } @@ -109,6 +117,8 @@ type deviceServiceClient struct { commands *connect.Client[v1.CommandsRequest, v1.CommandsResponse] details *connect.Client[v1.DetailsRequest, v1.DetailsResponse] run *connect.Client[v1.RunRequest, v1.RunResponse] + lock *connect.Client[v1.LockRequest, v1.LockResponse] + unlock *connect.Client[v1.UnlockRequest, v1.UnlockResponse] } // List calls dutctl.v1.DeviceService.List. @@ -131,12 +141,24 @@ func (c *deviceServiceClient) Run(ctx context.Context) *connect.BidiStreamForCli return c.run.CallBidiStream(ctx) } +// Lock calls dutctl.v1.DeviceService.Lock. +func (c *deviceServiceClient) Lock(ctx context.Context, req *connect.Request[v1.LockRequest]) (*connect.Response[v1.LockResponse], error) { + return c.lock.CallUnary(ctx, req) +} + +// Unlock calls dutctl.v1.DeviceService.Unlock. +func (c *deviceServiceClient) Unlock(ctx context.Context, req *connect.Request[v1.UnlockRequest]) (*connect.Response[v1.UnlockResponse], error) { + return c.unlock.CallUnary(ctx, req) +} + // DeviceServiceHandler is an implementation of the dutctl.v1.DeviceService service. type DeviceServiceHandler interface { List(context.Context, *connect.Request[v1.ListRequest]) (*connect.Response[v1.ListResponse], error) Commands(context.Context, *connect.Request[v1.CommandsRequest]) (*connect.Response[v1.CommandsResponse], error) Details(context.Context, *connect.Request[v1.DetailsRequest]) (*connect.Response[v1.DetailsResponse], error) Run(context.Context, *connect.BidiStream[v1.RunRequest, v1.RunResponse]) error + Lock(context.Context, *connect.Request[v1.LockRequest]) (*connect.Response[v1.LockResponse], error) + Unlock(context.Context, *connect.Request[v1.UnlockRequest]) (*connect.Response[v1.UnlockResponse], error) } // NewDeviceServiceHandler builds an HTTP handler from the service implementation. It returns the @@ -145,28 +167,41 @@ type DeviceServiceHandler interface { // By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf // and JSON codecs. They also support gzip compression. func NewDeviceServiceHandler(svc DeviceServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + deviceServiceMethods := v1.File_dutctl_v1_dutctl_proto.Services().ByName("DeviceService").Methods() deviceServiceListHandler := connect.NewUnaryHandler( DeviceServiceListProcedure, svc.List, - connect.WithSchema(deviceServiceListMethodDescriptor), + connect.WithSchema(deviceServiceMethods.ByName("List")), connect.WithHandlerOptions(opts...), ) deviceServiceCommandsHandler := connect.NewUnaryHandler( DeviceServiceCommandsProcedure, svc.Commands, - connect.WithSchema(deviceServiceCommandsMethodDescriptor), + connect.WithSchema(deviceServiceMethods.ByName("Commands")), connect.WithHandlerOptions(opts...), ) deviceServiceDetailsHandler := connect.NewUnaryHandler( DeviceServiceDetailsProcedure, svc.Details, - connect.WithSchema(deviceServiceDetailsMethodDescriptor), + connect.WithSchema(deviceServiceMethods.ByName("Details")), connect.WithHandlerOptions(opts...), ) deviceServiceRunHandler := connect.NewBidiStreamHandler( DeviceServiceRunProcedure, svc.Run, - connect.WithSchema(deviceServiceRunMethodDescriptor), + connect.WithSchema(deviceServiceMethods.ByName("Run")), + connect.WithHandlerOptions(opts...), + ) + deviceServiceLockHandler := connect.NewUnaryHandler( + DeviceServiceLockProcedure, + svc.Lock, + connect.WithSchema(deviceServiceMethods.ByName("Lock")), + connect.WithHandlerOptions(opts...), + ) + deviceServiceUnlockHandler := connect.NewUnaryHandler( + DeviceServiceUnlockProcedure, + svc.Unlock, + connect.WithSchema(deviceServiceMethods.ByName("Unlock")), connect.WithHandlerOptions(opts...), ) return "/dutctl.v1.DeviceService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -179,6 +214,10 @@ func NewDeviceServiceHandler(svc DeviceServiceHandler, opts ...connect.HandlerOp deviceServiceDetailsHandler.ServeHTTP(w, r) case DeviceServiceRunProcedure: deviceServiceRunHandler.ServeHTTP(w, r) + case DeviceServiceLockProcedure: + deviceServiceLockHandler.ServeHTTP(w, r) + case DeviceServiceUnlockProcedure: + deviceServiceUnlockHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -204,6 +243,14 @@ func (UnimplementedDeviceServiceHandler) Run(context.Context, *connect.BidiStrea return connect.NewError(connect.CodeUnimplemented, errors.New("dutctl.v1.DeviceService.Run is not implemented")) } +func (UnimplementedDeviceServiceHandler) Lock(context.Context, *connect.Request[v1.LockRequest]) (*connect.Response[v1.LockResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("dutctl.v1.DeviceService.Lock is not implemented")) +} + +func (UnimplementedDeviceServiceHandler) Unlock(context.Context, *connect.Request[v1.UnlockRequest]) (*connect.Response[v1.UnlockResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("dutctl.v1.DeviceService.Unlock is not implemented")) +} + // RelayServiceClient is a client for the dutctl.v1.RelayService service. type RelayServiceClient interface { Register(context.Context, *connect.Request[v1.RegisterRequest]) (*connect.Response[v1.RegisterResponse], error) @@ -218,11 +265,12 @@ type RelayServiceClient interface { // http://api.acme.com or https://acme.com/grpc). func NewRelayServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) RelayServiceClient { baseURL = strings.TrimRight(baseURL, "/") + relayServiceMethods := v1.File_dutctl_v1_dutctl_proto.Services().ByName("RelayService").Methods() return &relayServiceClient{ register: connect.NewClient[v1.RegisterRequest, v1.RegisterResponse]( httpClient, baseURL+RelayServiceRegisterProcedure, - connect.WithSchema(relayServiceRegisterMethodDescriptor), + connect.WithSchema(relayServiceMethods.ByName("Register")), connect.WithClientOptions(opts...), ), } @@ -249,10 +297,11 @@ type RelayServiceHandler interface { // By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf // and JSON codecs. They also support gzip compression. func NewRelayServiceHandler(svc RelayServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + relayServiceMethods := v1.File_dutctl_v1_dutctl_proto.Services().ByName("RelayService").Methods() relayServiceRegisterHandler := connect.NewUnaryHandler( RelayServiceRegisterProcedure, svc.Register, - connect.WithSchema(relayServiceRegisterMethodDescriptor), + connect.WithSchema(relayServiceMethods.ByName("Register")), connect.WithHandlerOptions(opts...), ) return "/dutctl.v1.RelayService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {