From de9ee1634827d8157b28ae3dfc21ced4163e0576 Mon Sep 17 00:00:00 2001 From: Fabian Wienand Date: Tue, 19 May 2026 12:28:44 +0200 Subject: [PATCH 1/6] feat: add per-device Locker engine Add an in-memory Locker tracking two independent slots per device: an explicit slot (Lock/ClearLock, requires a positive expiry) and an auto slot (AutoLock/ClearAutoLock, command-scoped, no expiry). ClearLock and ClearAutoLock share the same signature and error contract so callers can reason about them as one pattern. ForceClearLock wipes both slots as an admin escape hatch. Lock rejects non-positive durations with ErrInvalidDuration. Add the shared OwnerHeader const used to carry owner identity over HTTP. Signed-off-by: Fabian Wienand --- internal/dutagent/locker.go | 329 +++++++++++++++++++++++++++++++ internal/dutagent/locker_test.go | 323 ++++++++++++++++++++++++++++++ pkg/lock/header.go | 15 ++ 3 files changed, 667 insertions(+) create mode 100644 internal/dutagent/locker.go create mode 100644 internal/dutagent/locker_test.go create mode 100644 pkg/lock/header.go diff --git a/internal/dutagent/locker.go b/internal/dutagent/locker.go new file mode 100644 index 0000000..96614cb --- /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 0000000..56b4fa8 --- /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/pkg/lock/header.go b/pkg/lock/header.go new file mode 100644 index 0000000..e977d4a --- /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" From c301fb18dc9955c2beb599167da6bdd76d97a8b9 Mon Sep 17 00:00:00 2001 From: Fabian Wienand Date: Tue, 19 May 2026 12:31:23 +0200 Subject: [PATCH 2/6] feat: reject reserved command names in device config The "lock" and "unlock" command names are now reserved for the per-device locking RPCs. decodeCmds rejects them with a new ErrReservedCommand sentinel. Signed-off-by: Fabian Wienand --- pkg/dut/config.go | 11 ++++++++--- pkg/dut/config_test.go | 9 +++++++++ pkg/dut/testdata/invalid_reserved_command.yaml | 7 +++++++ 3 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 pkg/dut/testdata/invalid_reserved_command.yaml diff --git a/pkg/dut/config.go b/pkg/dut/config.go index 3d50d9d..e6fc58f 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 9a0a086..75cd85d 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 0000000..28dec73 --- /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 From 78b09fa6e5710958eaee7d12d1308fcb7155c818 Mon Sep 17 00:00:00 2001 From: Fabian Wienand Date: Tue, 19 May 2026 12:42:53 +0200 Subject: [PATCH 3/6] feat: add Lock and Unlock RPCs with agent handlers Add Lock/Unlock to the DeviceService proto and implement them on the dutagent: Lock acquires or extends a per-device lock, Unlock releases it (with a force option that releases regardless of owner). Owner identity is read from the OwnerHeader. Non-positive lock durations are rejected with InvalidArgument. dutserver embeds the Unimplemented handler since it does not forward these RPCs. Signed-off-by: Fabian Wienand --- cmds/dutagent/dutagent.go | 1 + cmds/dutagent/rpc.go | 96 ++ cmds/dutagent/rpc_test.go | 162 +++ cmds/exp/dutserver/rpc.go | 15 +- pkg/lock/user.go | 48 + protobuf/dutctl/v1/dutctl.proto | 27 + protobuf/gen/dutctl/v1/dutctl.pb.go | 966 +++++++++--------- .../v1/dutctlv1connect/dutctl.connect.go | 91 +- 8 files changed, 892 insertions(+), 514 deletions(-) create mode 100644 cmds/dutagent/rpc_test.go create mode 100644 pkg/lock/user.go diff --git a/cmds/dutagent/dutagent.go b/cmds/dutagent/dutagent.go index 79998c3..506b01e 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 aa8ba86..1844f95 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. @@ -121,6 +136,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] diff --git a/cmds/dutagent/rpc_test.go b/cmds/dutagent/rpc_test.go new file mode 100644 index 0000000..3e6ecf6 --- /dev/null +++ b/cmds/dutagent/rpc_test.go @@ -0,0 +1,162 @@ +// 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" + + "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)) + } + } +} diff --git a/cmds/exp/dutserver/rpc.go b/cmds/exp/dutserver/rpc.go index 1823060..81d9e72 100644 --- a/cmds/exp/dutserver/rpc.go +++ b/cmds/exp/dutserver/rpc.go @@ -50,7 +50,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 +63,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 +76,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 { diff --git a/pkg/lock/user.go b/pkg/lock/user.go new file mode 100644 index 0000000..2c6338b --- /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 ceb9976..bd1ffbd 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. @@ -99,6 +101,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 18a6390..3396701 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 []string `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) @@ -110,20 +106,17 @@ func (x *ListResponse) GetDevices() []string { // 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[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *CommandsRequest) String() string { @@ -134,7 +127,7 @@ func (*CommandsRequest) ProtoMessage() {} func (x *CommandsRequest) ProtoReflect() protoreflect.Message { mi := &file_dutctl_v1_dutctl_proto_msgTypes[2] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -158,20 +151,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[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *CommandsResponse) String() string { @@ -182,7 +172,7 @@ func (*CommandsResponse) ProtoMessage() {} func (x *CommandsResponse) ProtoReflect() protoreflect.Message { mi := &file_dutctl_v1_dutctl_proto_msgTypes[3] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -207,22 +197,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[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *DetailsRequest) String() string { @@ -233,7 +220,7 @@ func (*DetailsRequest) ProtoMessage() {} func (x *DetailsRequest) ProtoReflect() protoreflect.Message { mi := &file_dutctl_v1_dutctl_proto_msgTypes[4] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -271,20 +258,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[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *DetailsResponse) String() string { @@ -295,7 +279,7 @@ func (*DetailsResponse) ProtoMessage() {} func (x *DetailsResponse) ProtoReflect() protoreflect.Message { mi := &file_dutctl_v1_dutctl_proto_msgTypes[5] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -321,25 +305,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[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *RunRequest) String() string { @@ -350,7 +331,7 @@ func (*RunRequest) ProtoMessage() {} func (x *RunRequest) ProtoReflect() protoreflect.Message { mi := &file_dutctl_v1_dutctl_proto_msgTypes[6] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -365,30 +346,36 @@ func (*RunRequest) Descriptor() ([]byte, []int) { return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{6} } -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 +405,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[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *RunResponse) String() string { @@ -448,7 +432,7 @@ func (*RunResponse) ProtoMessage() {} func (x *RunResponse) ProtoReflect() protoreflect.Message { mi := &file_dutctl_v1_dutctl_proto_msgTypes[7] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -463,37 +447,45 @@ func (*RunResponse) Descriptor() ([]byte, []int) { return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{7} } -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 +520,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[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *Command) String() string { @@ -554,7 +543,7 @@ func (*Command) ProtoMessage() {} func (x *Command) ProtoReflect() protoreflect.Message { mi := &file_dutctl_v1_dutctl_proto_msgTypes[8] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -592,20 +581,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[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *Print) String() string { @@ -616,7 +602,7 @@ func (*Print) ProtoMessage() {} func (x *Print) ProtoReflect() protoreflect.Message { mi := &file_dutctl_v1_dutctl_proto_msgTypes[9] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -641,25 +627,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[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *Console) String() string { @@ -670,7 +653,7 @@ func (*Console) ProtoMessage() {} func (x *Console) ProtoReflect() protoreflect.Message { mi := &file_dutctl_v1_dutctl_proto_msgTypes[10] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -685,30 +668,36 @@ func (*Console) Descriptor() ([]byte, []int) { return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{10} } -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 +726,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[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *FileRequest) String() string { @@ -761,7 +747,7 @@ func (*FileRequest) ProtoMessage() {} func (x *FileRequest) ProtoReflect() protoreflect.Message { mi := &file_dutctl_v1_dutctl_proto_msgTypes[11] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -785,21 +771,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[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *File) String() string { @@ -810,7 +793,7 @@ func (*File) ProtoMessage() {} func (x *File) ProtoReflect() protoreflect.Message { mi := &file_dutctl_v1_dutctl_proto_msgTypes[12] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -839,24 +822,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[13] + 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[13] + 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{13} +} + +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[14] + 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[14] + 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{14} +} + +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[15] + 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[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 UnlockRequest.ProtoReflect.Descriptor instead. +func (*UnlockRequest) Descriptor() ([]byte, []int) { + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{15} +} + +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[16] + 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[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 UnlockResponse.ProtoReflect.Descriptor instead. +func (*UnlockResponse) Descriptor() ([]byte, []int) { + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{16} +} + // 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[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *RegisterRequest) String() string { @@ -866,8 +1060,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[17] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -879,7 +1073,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{17} } func (x *RegisterRequest) GetDevices() []string { @@ -896,21 +1090,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[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *RegisterResponse) String() string { @@ -920,8 +1112,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[18] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -933,122 +1125,95 @@ 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{18} } 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\x18\n" + + "\adevices\x18\x01 \x03(\tR\adevices\")\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, 19) var file_dutctl_v1_dutctl_proto_goTypes = []any{ (*ListRequest)(nil), // 0: dutctl.v1.ListRequest (*ListResponse)(nil), // 1: dutctl.v1.ListResponse @@ -1063,8 +1228,12 @@ var file_dutctl_v1_dutctl_proto_goTypes = []any{ (*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 + (*LockRequest)(nil), // 13: dutctl.v1.LockRequest + (*LockResponse)(nil), // 14: dutctl.v1.LockResponse + (*UnlockRequest)(nil), // 15: dutctl.v1.UnlockRequest + (*UnlockResponse)(nil), // 16: dutctl.v1.UnlockResponse + (*RegisterRequest)(nil), // 17: dutctl.v1.RegisterRequest + (*RegisterResponse)(nil), // 18: dutctl.v1.RegisterResponse } var file_dutctl_v1_dutctl_proto_depIdxs = []int32{ 8, // 0: dutctl.v1.RunRequest.command:type_name -> dutctl.v1.Command @@ -1078,14 +1247,18 @@ var file_dutctl_v1_dutctl_proto_depIdxs = []int32{ 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 + 13, // 11: dutctl.v1.DeviceService.Lock:input_type -> dutctl.v1.LockRequest + 15, // 12: dutctl.v1.DeviceService.Unlock:input_type -> dutctl.v1.UnlockRequest + 17, // 13: dutctl.v1.RelayService.Register:input_type -> dutctl.v1.RegisterRequest + 1, // 14: dutctl.v1.DeviceService.List:output_type -> dutctl.v1.ListResponse + 3, // 15: dutctl.v1.DeviceService.Commands:output_type -> dutctl.v1.CommandsResponse + 5, // 16: dutctl.v1.DeviceService.Details:output_type -> dutctl.v1.DetailsResponse + 7, // 17: dutctl.v1.DeviceService.Run:output_type -> dutctl.v1.RunResponse + 14, // 18: dutctl.v1.DeviceService.Lock:output_type -> dutctl.v1.LockResponse + 16, // 19: dutctl.v1.DeviceService.Unlock:output_type -> dutctl.v1.UnlockResponse + 18, // 20: dutctl.v1.RelayService.Register:output_type -> dutctl.v1.RegisterResponse + 14, // [14:21] is the sub-list for method output_type + 7, // [7:14] 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 @@ -1096,188 +1269,6 @@ 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{ (*RunRequest_Command)(nil), (*RunRequest_Console)(nil), @@ -1298,9 +1289,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: 19, NumExtensions: 0, NumServices: 2, }, @@ -1309,7 +1300,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 b421ea0..a8245e8 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) { From 6b0f9bb010dcc33b02a6c29e5f0f7abbe3d21247 Mon Sep 17 00:00:00 2001 From: Fabian Wienand Date: Tue, 19 May 2026 12:50:53 +0200 Subject: [PATCH 4/6] feat: enforce per-device locking on Run Add three FSM states for per-device locking on Run: checkDeviceAccess rejects Runs by non-owners with FailedPrecondition; acquireAutoLock takes a command-scoped auto-lock; releaseAutoLock clears it once the command finishes. The Run RPC handler also delegates to releaseAutoLock as a safety net so auto-locks are not leaked on FSM error paths. dutserver forwards the owner header to the upstream agent. Signed-off-by: Fabian Wienand --- cmds/dutagent/rpc.go | 13 ++- cmds/dutagent/states.go | 55 ++++++++++++- cmds/dutagent/states_test.go | 153 +++++++++++++++++++++++++++++++++-- cmds/exp/dutserver/rpc.go | 4 + 4 files changed, 218 insertions(+), 7 deletions(-) diff --git a/cmds/dutagent/rpc.go b/cmds/dutagent/rpc.go index 1844f95..521e752 100644 --- a/cmds/dutagent/rpc.go +++ b/cmds/dutagent/rpc.go @@ -235,9 +235,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/states.go b/cmds/dutagent/states.go index 189e10d..5169a25 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 10623e2..1c9b9d2 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/exp/dutserver/rpc.go b/cmds/exp/dutserver/rpc.go index 81d9e72..001c782 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" @@ -202,6 +203,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 { From b4145db29642637702ffb4f64a2f5e5c24ed4885 Mon Sep 17 00:00:00 2001 From: Fabian Wienand Date: Tue, 19 May 2026 13:03:05 +0200 Subject: [PATCH 5/6] feat: show device lock state in list output ListResponse now carries structured DeviceInfo with per-device lock state instead of bare device names. The dutctl client renders locked devices with a "[locked by ...]" annotation and adds a lock-result output type for the upcoming lock/unlock commands. Signed-off-by: Fabian Wienand --- cmds/dutagent/rpc.go | 21 +- cmds/dutagent/rpc_test.go | 55 +++++ cmds/dutctl/rpc.go | 16 +- cmds/exp/dutserver/rpc.go | 10 +- internal/output/json_test.go | 2 +- internal/output/oneline.go | 19 ++ internal/output/oneline_test.go | 2 +- internal/output/output.go | 11 + internal/output/text.go | 90 +++++++- internal/output/text_test.go | 64 ++++++ internal/output/yaml_test.go | 2 +- protobuf/dutctl/v1/dutctl.proto | 17 +- protobuf/gen/dutctl/v1/dutctl.pb.go | 337 +++++++++++++++++++--------- 13 files changed, 526 insertions(+), 120 deletions(-) diff --git a/cmds/dutagent/rpc.go b/cmds/dutagent/rpc.go index 521e752..6ffacc8 100644 --- a/cmds/dutagent/rpc.go +++ b/cmds/dutagent/rpc.go @@ -44,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") diff --git a/cmds/dutagent/rpc_test.go b/cmds/dutagent/rpc_test.go index 3e6ecf6..7ae3e06 100644 --- a/cmds/dutagent/rpc_test.go +++ b/cmds/dutagent/rpc_test.go @@ -7,6 +7,7 @@ import ( "context" "strings" "testing" + "time" "connectrpc.com/connect" "github.com/BlindspotSoftware/dutctl/internal/dutagent" @@ -160,3 +161,57 @@ func TestLockRPCZeroDurationRejected(t *testing.T) { } } } + +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/dutctl/rpc.go b/cmds/dutctl/rpc.go index 017b4dd..33e30ff 100644 --- a/cmds/dutctl/rpc.go +++ b/cmds/dutctl/rpc.go @@ -30,9 +30,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", diff --git a/cmds/exp/dutserver/rpc.go b/cmds/exp/dutserver/rpc.go index 001c782..c13cf90 100644 --- a/cmds/exp/dutserver/rpc.go +++ b/cmds/exp/dutserver/rpc.go @@ -100,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") diff --git a/internal/output/json_test.go b/internal/output/json_test.go index 47c6b43..ce65b18 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 3729c25..c97dd4a 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 4c15c8a..0eb82c8 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 52efbdf..12dd70a 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 49732dd..a49bb94 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 f9e7041..d37ce88 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 2ee7441..2bb1a03 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/protobuf/dutctl/v1/dutctl.proto b/protobuf/dutctl/v1/dutctl.proto index bd1ffbd..8f04d11 100644 --- a/protobuf/dutctl/v1/dutctl.proto +++ b/protobuf/dutctl/v1/dutctl.proto @@ -19,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 diff --git a/protobuf/gen/dutctl/v1/dutctl.pb.go b/protobuf/gen/dutctl/v1/dutctl.pb.go index 3396701..8bf030b 100644 --- a/protobuf/gen/dutctl/v1/dutctl.pb.go +++ b/protobuf/gen/dutctl/v1/dutctl.pb.go @@ -61,7 +61,7 @@ func (*ListRequest) Descriptor() ([]byte, []int) { // ListResponse is sent by the agent in response to a ListRequest. type ListResponse struct { state protoimpl.MessageState `protogen:"open.v1"` - Devices []string `protobuf:"bytes,1,rep,name=devices,proto3" json:"devices,omitempty"` + Devices []*DeviceInfo `protobuf:"bytes,1,rep,name=devices,proto3" json:"devices,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -96,13 +96,129 @@ 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 { @@ -114,7 +230,7 @@ type CommandsRequest struct { func (x *CommandsRequest) Reset() { *x = CommandsRequest{} - mi := &file_dutctl_v1_dutctl_proto_msgTypes[2] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -126,7 +242,7 @@ func (x *CommandsRequest) String() string { func (*CommandsRequest) ProtoMessage() {} func (x *CommandsRequest) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[2] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -139,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 { @@ -159,7 +275,7 @@ type CommandsResponse struct { func (x *CommandsResponse) Reset() { *x = CommandsResponse{} - mi := &file_dutctl_v1_dutctl_proto_msgTypes[3] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -171,7 +287,7 @@ func (x *CommandsResponse) String() string { func (*CommandsResponse) ProtoMessage() {} func (x *CommandsResponse) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[3] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -184,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,7 +323,7 @@ type DetailsRequest struct { func (x *DetailsRequest) Reset() { *x = DetailsRequest{} - mi := &file_dutctl_v1_dutctl_proto_msgTypes[4] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -219,7 +335,7 @@ func (x *DetailsRequest) String() string { func (*DetailsRequest) ProtoMessage() {} func (x *DetailsRequest) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[4] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -232,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 { @@ -266,7 +382,7 @@ type DetailsResponse struct { func (x *DetailsResponse) Reset() { *x = DetailsResponse{} - mi := &file_dutctl_v1_dutctl_proto_msgTypes[5] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -278,7 +394,7 @@ func (x *DetailsResponse) String() string { func (*DetailsResponse) ProtoMessage() {} func (x *DetailsResponse) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[5] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -291,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 { @@ -318,7 +434,7 @@ type RunRequest struct { func (x *RunRequest) Reset() { *x = RunRequest{} - mi := &file_dutctl_v1_dutctl_proto_msgTypes[6] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -330,7 +446,7 @@ func (x *RunRequest) String() string { func (*RunRequest) ProtoMessage() {} func (x *RunRequest) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[6] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -343,7 +459,7 @@ 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 (x *RunRequest) GetMsg() isRunRequest_Msg { @@ -419,7 +535,7 @@ type RunResponse struct { func (x *RunResponse) Reset() { *x = RunResponse{} - mi := &file_dutctl_v1_dutctl_proto_msgTypes[7] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -431,7 +547,7 @@ func (x *RunResponse) String() string { func (*RunResponse) ProtoMessage() {} func (x *RunResponse) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[7] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -444,7 +560,7 @@ 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 (x *RunResponse) GetMsg() isRunResponse_Msg { @@ -530,7 +646,7 @@ type Command struct { func (x *Command) Reset() { *x = Command{} - mi := &file_dutctl_v1_dutctl_proto_msgTypes[8] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -542,7 +658,7 @@ func (x *Command) String() string { func (*Command) ProtoMessage() {} func (x *Command) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[8] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -555,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 { @@ -589,7 +705,7 @@ type Print struct { func (x *Print) Reset() { *x = Print{} - mi := &file_dutctl_v1_dutctl_proto_msgTypes[9] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -601,7 +717,7 @@ func (x *Print) String() string { func (*Print) ProtoMessage() {} func (x *Print) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[9] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -614,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 { @@ -640,7 +756,7 @@ type Console struct { func (x *Console) Reset() { *x = Console{} - mi := &file_dutctl_v1_dutctl_proto_msgTypes[10] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -652,7 +768,7 @@ func (x *Console) String() string { func (*Console) ProtoMessage() {} func (x *Console) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[10] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -665,7 +781,7 @@ 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 (x *Console) GetData() isConsole_Data { @@ -734,7 +850,7 @@ type FileRequest struct { func (x *FileRequest) Reset() { *x = FileRequest{} - mi := &file_dutctl_v1_dutctl_proto_msgTypes[11] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -746,7 +862,7 @@ func (x *FileRequest) String() string { func (*FileRequest) ProtoMessage() {} func (x *FileRequest) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[11] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -759,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 { @@ -780,7 +896,7 @@ type File struct { func (x *File) Reset() { *x = File{} - mi := &file_dutctl_v1_dutctl_proto_msgTypes[12] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -792,7 +908,7 @@ func (x *File) String() string { func (*File) ProtoMessage() {} func (x *File) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[12] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -805,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 { @@ -834,7 +950,7 @@ type LockRequest struct { func (x *LockRequest) Reset() { *x = LockRequest{} - mi := &file_dutctl_v1_dutctl_proto_msgTypes[13] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -846,7 +962,7 @@ func (x *LockRequest) String() string { func (*LockRequest) ProtoMessage() {} func (x *LockRequest) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[13] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -859,7 +975,7 @@ func (x *LockRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use LockRequest.ProtoReflect.Descriptor instead. func (*LockRequest) Descriptor() ([]byte, []int) { - return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{13} + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{15} } func (x *LockRequest) GetDevice() string { @@ -889,7 +1005,7 @@ type LockResponse struct { func (x *LockResponse) Reset() { *x = LockResponse{} - mi := &file_dutctl_v1_dutctl_proto_msgTypes[14] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -901,7 +1017,7 @@ func (x *LockResponse) String() string { func (*LockResponse) ProtoMessage() {} func (x *LockResponse) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[14] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -914,7 +1030,7 @@ func (x *LockResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use LockResponse.ProtoReflect.Descriptor instead. func (*LockResponse) Descriptor() ([]byte, []int) { - return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{14} + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{16} } func (x *LockResponse) GetDevice() string { @@ -957,7 +1073,7 @@ type UnlockRequest struct { func (x *UnlockRequest) Reset() { *x = UnlockRequest{} - mi := &file_dutctl_v1_dutctl_proto_msgTypes[15] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -969,7 +1085,7 @@ func (x *UnlockRequest) String() string { func (*UnlockRequest) ProtoMessage() {} func (x *UnlockRequest) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[15] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -982,7 +1098,7 @@ func (x *UnlockRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UnlockRequest.ProtoReflect.Descriptor instead. func (*UnlockRequest) Descriptor() ([]byte, []int) { - return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{15} + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{17} } func (x *UnlockRequest) GetDevice() string { @@ -1008,7 +1124,7 @@ type UnlockResponse struct { func (x *UnlockResponse) Reset() { *x = UnlockResponse{} - mi := &file_dutctl_v1_dutctl_proto_msgTypes[16] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1020,7 +1136,7 @@ func (x *UnlockResponse) String() string { func (*UnlockResponse) ProtoMessage() {} func (x *UnlockResponse) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[16] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1033,7 +1149,7 @@ func (x *UnlockResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UnlockResponse.ProtoReflect.Descriptor instead. func (*UnlockResponse) Descriptor() ([]byte, []int) { - return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{16} + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{18} } // RegisterRequest is sent by a device agent to register with the relay server. @@ -1048,7 +1164,7 @@ type RegisterRequest struct { func (x *RegisterRequest) Reset() { *x = RegisterRequest{} - mi := &file_dutctl_v1_dutctl_proto_msgTypes[17] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1060,7 +1176,7 @@ func (x *RegisterRequest) String() string { func (*RegisterRequest) ProtoMessage() {} func (x *RegisterRequest) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[17] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1073,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{17} + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{19} } func (x *RegisterRequest) GetDevices() []string { @@ -1100,7 +1216,7 @@ type RegisterResponse struct { func (x *RegisterResponse) Reset() { *x = RegisterResponse{} - mi := &file_dutctl_v1_dutctl_proto_msgTypes[18] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1112,7 +1228,7 @@ func (x *RegisterResponse) String() string { func (*RegisterResponse) ProtoMessage() {} func (x *RegisterResponse) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[18] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1125,7 +1241,7 @@ 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{18} + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{20} } var File_dutctl_v1_dutctl_proto protoreflect.FileDescriptor @@ -1133,9 +1249,18 @@ var File_dutctl_v1_dutctl_proto protoreflect.FileDescriptor const file_dutctl_v1_dutctl_proto_rawDesc = "" + "\n" + "\x16dutctl/v1/dutctl.proto\x12\tdutctl.v1\"\r\n" + - "\vListRequest\"(\n" + - "\fListResponse\x12\x18\n" + - "\adevices\x18\x01 \x03(\tR\adevices\")\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" + @@ -1213,55 +1338,59 @@ func file_dutctl_v1_dutctl_proto_rawDescGZIP() []byte { return file_dutctl_v1_dutctl_proto_rawDescData } -var file_dutctl_v1_dutctl_proto_msgTypes = make([]protoimpl.MessageInfo, 19) +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 - (*LockRequest)(nil), // 13: dutctl.v1.LockRequest - (*LockResponse)(nil), // 14: dutctl.v1.LockResponse - (*UnlockRequest)(nil), // 15: dutctl.v1.UnlockRequest - (*UnlockResponse)(nil), // 16: dutctl.v1.UnlockResponse - (*RegisterRequest)(nil), // 17: dutctl.v1.RegisterRequest - (*RegisterResponse)(nil), // 18: 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.DeviceService.Lock:input_type -> dutctl.v1.LockRequest - 15, // 12: dutctl.v1.DeviceService.Unlock:input_type -> dutctl.v1.UnlockRequest - 17, // 13: dutctl.v1.RelayService.Register:input_type -> dutctl.v1.RegisterRequest - 1, // 14: dutctl.v1.DeviceService.List:output_type -> dutctl.v1.ListResponse - 3, // 15: dutctl.v1.DeviceService.Commands:output_type -> dutctl.v1.CommandsResponse - 5, // 16: dutctl.v1.DeviceService.Details:output_type -> dutctl.v1.DetailsResponse - 7, // 17: dutctl.v1.DeviceService.Run:output_type -> dutctl.v1.RunResponse - 14, // 18: dutctl.v1.DeviceService.Lock:output_type -> dutctl.v1.LockResponse - 16, // 19: dutctl.v1.DeviceService.Unlock:output_type -> dutctl.v1.UnlockResponse - 18, // 20: dutctl.v1.RelayService.Register:output_type -> dutctl.v1.RegisterResponse - 14, // [14:21] is the sub-list for method output_type - 7, // [7:14] 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() } @@ -1269,18 +1398,18 @@ func file_dutctl_v1_dutctl_proto_init() { if File_dutctl_v1_dutctl_proto != nil { return } - 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), @@ -1291,7 +1420,7 @@ func file_dutctl_v1_dutctl_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_dutctl_v1_dutctl_proto_rawDesc), len(file_dutctl_v1_dutctl_proto_rawDesc)), NumEnums: 0, - NumMessages: 19, + NumMessages: 21, NumExtensions: 0, NumServices: 2, }, From 839d541b6f90d5f78a26325ae37a015ec25bb0be Mon Sep 17 00:00:00 2001 From: Fabian Wienand Date: Tue, 19 May 2026 13:08:27 +0200 Subject: [PATCH 6/6] feat: add lock and unlock client commands Add "dutctl lock [duration]" and "dutctl unlock [--force]" subcommands, plus a -u flag to set the lock owner identity (defaults to user@host). The owner is sent on Run, Lock and Unlock via the OwnerHeader. Lock duration defaults to 30m and must be positive. Signed-off-by: Fabian Wienand --- cmds/dutctl/dutctl.go | 22 ++++++++++- cmds/dutctl/rpc.go | 84 +++++++++++++++++++++++++++++++++++++++++ cmds/dutctl/rpc_test.go | 77 +++++++++++++++++++++++++++++++++++++ 3 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 cmds/dutctl/rpc_test.go diff --git a/cmds/dutctl/dutctl.go b/cmds/dutctl/dutctl.go index 3d397d3..3810743 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 33e30ff..13e3075 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" ) @@ -56,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}) @@ -116,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 0000000..db38066 --- /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) + } + }) + } +}