feat(privilege): acquire sudo and keep timestamp fresh via goroutine
This commit is contained in:
77
internal/privilege/sudo.go
Normal file
77
internal/privilege/sudo.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
// Package privilege manages sudo timestamp acquisition and keepalive so
|
||||||
|
// a long-running installer only prompts once.
|
||||||
|
package privilege
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Keepalive holds the lifetime of a background sudo-refresh goroutine.
|
||||||
|
type Keepalive struct {
|
||||||
|
cancel context.CancelFunc
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop terminates the keepalive goroutine. Safe to call multiple times.
|
||||||
|
func (k *Keepalive) Stop() {
|
||||||
|
if k == nil || k.cancel == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
k.cancel()
|
||||||
|
<-k.done
|
||||||
|
k.cancel = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquirer abstracts the `sudo` binary so tests can inject a fake.
|
||||||
|
type Acquirer interface {
|
||||||
|
Validate(ctx context.Context) error // sudo -v (may prompt)
|
||||||
|
ValidateNonInteractive(ctx context.Context) error // sudo -n -v
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecSudo is the production Acquirer.
|
||||||
|
type ExecSudo struct{}
|
||||||
|
|
||||||
|
func (ExecSudo) Validate(ctx context.Context) error {
|
||||||
|
cmd := exec.CommandContext(ctx, "sudo", "-v")
|
||||||
|
cmd.Stdin = nil // inherit terminal stdin
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ExecSudo) ValidateNonInteractive(ctx context.Context) error {
|
||||||
|
return exec.CommandContext(ctx, "sudo", "-n", "-v").Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquire runs `sudo -v` once (prompting if needed) and returns a Keepalive
|
||||||
|
// that refreshes the timestamp every `interval` using ValidateNonInteractive.
|
||||||
|
// If the initial Validate fails, no goroutine is started and the error is returned.
|
||||||
|
func Acquire(ctx context.Context, a Acquirer, interval time.Duration) (*Keepalive, error) {
|
||||||
|
if a == nil {
|
||||||
|
a = ExecSudo{}
|
||||||
|
}
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 50 * time.Second
|
||||||
|
}
|
||||||
|
if err := a.Validate(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("sudo -v failed: %w", err)
|
||||||
|
}
|
||||||
|
kCtx, cancel := context.WithCancel(ctx)
|
||||||
|
k := &Keepalive{cancel: cancel, done: make(chan struct{})}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(k.done)
|
||||||
|
t := time.NewTicker(interval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-kCtx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
_ = a.ValidateNonInteractive(kCtx) // best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
65
internal/privilege/sudo_test.go
Normal file
65
internal/privilege/sudo_test.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package privilege
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeAcquirer struct {
|
||||||
|
validateErr error
|
||||||
|
validates int32
|
||||||
|
refreshes int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAcquirer) Validate(ctx context.Context) error {
|
||||||
|
atomic.AddInt32(&f.validates, 1)
|
||||||
|
return f.validateErr
|
||||||
|
}
|
||||||
|
func (f *fakeAcquirer) ValidateNonInteractive(ctx context.Context) error {
|
||||||
|
atomic.AddInt32(&f.refreshes, 1)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAcquireFailsIfValidateFails(t *testing.T) {
|
||||||
|
a := &fakeAcquirer{validateErr: errors.New("wrong password")}
|
||||||
|
_, err := Acquire(context.Background(), a, 10*time.Millisecond)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if atomic.LoadInt32(&a.refreshes) != 0 {
|
||||||
|
t.Fatal("keepalive ran despite Validate failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAcquireStartsKeepaliveAndStops(t *testing.T) {
|
||||||
|
a := &fakeAcquirer{}
|
||||||
|
k, err := Acquire(context.Background(), a, 5*time.Millisecond)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
time.Sleep(25 * time.Millisecond)
|
||||||
|
k.Stop()
|
||||||
|
got := atomic.LoadInt32(&a.refreshes)
|
||||||
|
if got < 2 {
|
||||||
|
t.Fatalf("refreshes = %d, want >= 2", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// After Stop, no additional refreshes should occur.
|
||||||
|
snap := atomic.LoadInt32(&a.refreshes)
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
if after := atomic.LoadInt32(&a.refreshes); after != snap {
|
||||||
|
t.Fatalf("refreshes continued after Stop: %d → %d", snap, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStopIsIdempotent(t *testing.T) {
|
||||||
|
k, err := Acquire(context.Background(), &fakeAcquirer{}, 5*time.Millisecond)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
k.Stop()
|
||||||
|
k.Stop() // must not panic or hang
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user