feat: coordinate Active Profile target panel
This commit is contained in:
+24
-1
@@ -15,7 +15,10 @@ import (
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"proxyctl/internal/app"
|
||||
"proxyctl/internal/apt"
|
||||
"proxyctl/internal/dockerclient"
|
||||
"proxyctl/internal/dockerdaemon"
|
||||
"proxyctl/internal/gnome"
|
||||
"proxyctl/internal/operationlock"
|
||||
"proxyctl/internal/oplog"
|
||||
"proxyctl/internal/profile"
|
||||
@@ -76,7 +79,10 @@ func runWithEnvironmentAndTargets(args []string, input io.Reader, output io.Writ
|
||||
}
|
||||
shellTarget := shell.New(shellConfiguration, files, environment)
|
||||
if systemTargets == nil {
|
||||
systemTargets = []target.Target{dockerdaemon.New(dockerdaemon.DefaultConfiguration(), nil, nil)}
|
||||
systemTargets, err = productionTargets(environment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
targets := append([]target.Target{shellTarget}, systemTargets...)
|
||||
stateDirectory := filepath.Dir(shellConfiguration.RecoveryFile)
|
||||
@@ -136,6 +142,23 @@ func runWithEnvironmentAndTargets(args []string, input io.Reader, output io.Writ
|
||||
return err
|
||||
}
|
||||
|
||||
func productionTargets(environment profile.IdentityEnvironment) ([]target.Target, error) {
|
||||
dockerClientConfiguration, err := dockerclient.ResolveConfiguration(environment)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve Docker containers/builds Target configuration: %w", err)
|
||||
}
|
||||
gnomeConfiguration, err := gnome.ResolveConfiguration(environment)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve GNOME Target configuration: %w", err)
|
||||
}
|
||||
return []target.Target{
|
||||
apt.New(apt.DefaultConfiguration(), nil, nil),
|
||||
dockerclient.New(dockerClientConfiguration, nil),
|
||||
gnome.New(gnomeConfiguration, nil, nil),
|
||||
dockerdaemon.New(dockerdaemon.DefaultConfiguration(), nil, nil),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func runMaintenanceCommand(args []string, input io.Reader, output io.Writer, store *profile.FileStore, targets []target.Target, mutationLock app.MutationLock, logger app.OperationLogger, checks []target.Check) error {
|
||||
if len(args) != 1 {
|
||||
return errors.New("invalid maintenance command")
|
||||
|
||||
@@ -7,9 +7,27 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"proxyctl/internal/profile"
|
||||
"proxyctl/internal/target"
|
||||
)
|
||||
|
||||
func TestProductionCompositionRegistersAPTContainerGNOMEAndDaemonTargets(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
environment := commandEnvironment{identity: profile.UserIdentity{Username: "tester", HomeDir: home, UID: os.Getuid(), GID: os.Getgid()}, env: map[string]string{"SHELL": "/bin/bash"}}
|
||||
targets, err := productionTargets(environment)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ids []string
|
||||
for _, adapter := range targets {
|
||||
ids = append(ids, adapter.ID())
|
||||
}
|
||||
want := []string{"apt", "docker-containers", "gnome", "docker-daemon"}
|
||||
if strings.Join(ids, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("production Target IDs = %#v, want %#v", ids, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultInvocationStartsTUIWithNoProfileState(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
var output bytes.Buffer
|
||||
|
||||
+135
-2
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"proxyctl/internal/profile"
|
||||
@@ -18,6 +19,7 @@ const (
|
||||
DeleteProfile
|
||||
ActivateProfile
|
||||
SetTargetDesired
|
||||
RefreshTargets
|
||||
)
|
||||
|
||||
type Intent struct {
|
||||
@@ -44,10 +46,20 @@ type Snapshot struct {
|
||||
Targets []TargetSnapshot
|
||||
}
|
||||
|
||||
type TargetProgress struct {
|
||||
TargetID string
|
||||
Applying bool
|
||||
State target.State
|
||||
Evidence string
|
||||
Err error
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Snapshot Snapshot
|
||||
Notice string
|
||||
Confirmation string
|
||||
Progress []TargetProgress
|
||||
Complete bool
|
||||
Err error
|
||||
}
|
||||
|
||||
@@ -56,6 +68,13 @@ type Module interface {
|
||||
Dispatch(Intent) Result
|
||||
}
|
||||
|
||||
// ProgressiveModule is implemented by Applications that can expose each
|
||||
// coordinated Target update while an Active Profile transition is running.
|
||||
// The final Result has Complete set and closes the stream.
|
||||
type ProgressiveModule interface {
|
||||
DispatchProgress(Intent) <-chan Result
|
||||
}
|
||||
|
||||
type Application struct {
|
||||
store profile.Store
|
||||
state profile.State
|
||||
@@ -103,6 +122,21 @@ func (a *Application) Snapshot() Snapshot {
|
||||
}
|
||||
|
||||
func (a *Application) Dispatch(intent Intent) Result {
|
||||
return a.dispatch(intent, nil)
|
||||
}
|
||||
|
||||
func (a *Application) DispatchProgress(intent Intent) <-chan Result {
|
||||
results := make(chan Result, 2*len(a.targets)+1)
|
||||
go func() {
|
||||
defer close(results)
|
||||
final := a.dispatch(intent, func(update Result) { results <- update })
|
||||
final.Complete = true
|
||||
results <- final
|
||||
}()
|
||||
return results
|
||||
}
|
||||
|
||||
func (a *Application) dispatch(intent Intent, publish func(Result)) Result {
|
||||
if a.mutationLock != nil {
|
||||
unlock, err := a.mutationLock.Lock(context.Background())
|
||||
if err != nil {
|
||||
@@ -113,14 +147,19 @@ func (a *Application) Dispatch(intent Intent) Result {
|
||||
if intent.Kind == SetTargetDesired {
|
||||
return a.setTargetDesired(intent)
|
||||
}
|
||||
if intent.Kind == RefreshTargets {
|
||||
a.refreshTargets()
|
||||
return Result{Snapshot: a.Snapshot(), Notice: "Refreshed Target availability and state"}
|
||||
}
|
||||
if intent.Kind == ActivateProfile {
|
||||
return a.activateProfile(intent, publish)
|
||||
}
|
||||
var notice string
|
||||
next, err := a.store.Mutate(func(current profile.State) (profile.State, error) {
|
||||
var mutationErr error
|
||||
switch intent.Kind {
|
||||
case CreateProfile:
|
||||
current, notice, mutationErr = create(current, intent.Profile)
|
||||
case ActivateProfile:
|
||||
current, notice, mutationErr = activate(current, intent.Name)
|
||||
case UpdateProfile:
|
||||
current, notice, mutationErr = update(current, intent.Name, intent.Profile)
|
||||
case DeleteProfile:
|
||||
@@ -150,6 +189,100 @@ func (a *Application) Dispatch(intent Intent) Result {
|
||||
return Result{Snapshot: a.Snapshot(), Notice: notice, Err: err}
|
||||
}
|
||||
|
||||
func (a *Application) activateProfile(intent Intent, publish func(Result)) Result {
|
||||
if index(a.state, intent.Name) < 0 {
|
||||
return Result{Snapshot: a.Snapshot(), Err: fmt.Errorf("Profile %q does not exist", intent.Name)}
|
||||
}
|
||||
if a.state.ActiveProfile == intent.Name {
|
||||
return Result{Snapshot: a.Snapshot(), Notice: fmt.Sprintf("Profile %q is already Active", intent.Name)}
|
||||
}
|
||||
|
||||
var enabled []target.Target
|
||||
var warnings []string
|
||||
for _, adapter := range a.targets {
|
||||
if a.inspections[adapter.ID()].State != target.Enabled {
|
||||
continue
|
||||
}
|
||||
enabled = append(enabled, adapter)
|
||||
if warning := adapter.Confirmation(true); warning != "" {
|
||||
warnings = append(warnings, adapter.Description().Name+": "+warning)
|
||||
}
|
||||
}
|
||||
if len(warnings) > 0 && !intent.Confirm {
|
||||
return Result{Snapshot: a.Snapshot(), Confirmation: "Changing the Active Profile requires confirmation:\n" + strings.Join(warnings, "\n")}
|
||||
}
|
||||
|
||||
next, err := a.store.Mutate(func(current profile.State) (profile.State, error) {
|
||||
next, _, mutationErr := activate(current, intent.Name)
|
||||
return next, mutationErr
|
||||
})
|
||||
if err != nil {
|
||||
var committed *profile.CommitError
|
||||
if errors.As(err, &committed) {
|
||||
a.state = next
|
||||
if reloaded, loadErr := a.store.Load(); loadErr != nil {
|
||||
err = errors.Join(err, fmt.Errorf("reload committed Profile configuration: %w", loadErr))
|
||||
} else {
|
||||
a.state = reloaded
|
||||
}
|
||||
}
|
||||
return Result{Snapshot: a.Snapshot(), Err: err}
|
||||
}
|
||||
a.state = next
|
||||
active := a.activeProfile()
|
||||
// The Active Profile has changed before any Target write begins. Re-inspect
|
||||
// immediately so rows not yet updated are truthfully Drifted rather than
|
||||
// appearing Enabled against stale managed values.
|
||||
for _, adapter := range a.targets {
|
||||
a.inspections[adapter.ID()] = adapter.Inspect(context.Background(), active)
|
||||
}
|
||||
var progress []TargetProgress
|
||||
var failures []error
|
||||
for _, adapter := range enabled {
|
||||
if publish != nil {
|
||||
publish(Result{Snapshot: a.Snapshot(), Progress: []TargetProgress{{TargetID: adapter.ID(), Applying: true, State: a.inspections[adapter.ID()].State}}, Notice: "Updating " + adapter.Description().Name + " Target"})
|
||||
}
|
||||
operation := adapter.SetDesired(context.Background(), true, active)
|
||||
a.inspections[adapter.ID()] = operation.Inspection
|
||||
var safeErr error
|
||||
if operation.Err != nil {
|
||||
safeErr = errors.New(profile.RedactText(operation.Err.Error()))
|
||||
}
|
||||
item := TargetProgress{TargetID: adapter.ID(), State: operation.Inspection.State, Evidence: operation.Inspection.Evidence, Err: safeErr}
|
||||
progress = append(progress, item)
|
||||
if publish != nil {
|
||||
publish(Result{Snapshot: a.Snapshot(), Progress: []TargetProgress{item}, Notice: adapter.Description().Name + " Target update completed", Err: safeErr})
|
||||
}
|
||||
if a.logger != nil {
|
||||
outcome, detail := "success", operation.Inspection.Evidence
|
||||
if safeErr != nil {
|
||||
outcome, detail = "failure", safeErr.Error()
|
||||
}
|
||||
_ = a.logger.Write(LogEntry{Time: time.Now(), Operation: "switch-Active-Profile", Target: adapter.ID(), Result: outcome, Detail: RedactLogDetail(detail)})
|
||||
}
|
||||
if safeErr != nil {
|
||||
failures = append(failures, fmt.Errorf("%s Target: %w", adapter.Description().Name, safeErr))
|
||||
}
|
||||
}
|
||||
// Re-inspect Targets which were not part of the transition so any managed
|
||||
// configuration is interpreted against the new Active Profile as well.
|
||||
updated := make(map[string]bool, len(enabled))
|
||||
for _, adapter := range enabled {
|
||||
updated[adapter.ID()] = true
|
||||
}
|
||||
for _, adapter := range a.targets {
|
||||
if !updated[adapter.ID()] {
|
||||
a.inspections[adapter.ID()] = adapter.Inspect(context.Background(), active)
|
||||
}
|
||||
}
|
||||
result := Result{Snapshot: a.Snapshot(), Progress: progress, Notice: fmt.Sprintf("Activated Profile %q", intent.Name)}
|
||||
if len(failures) > 0 {
|
||||
result.Err = errors.Join(failures...)
|
||||
result.Notice = fmt.Sprintf("Activated Profile %q with %d Target update failure(s); successful updates were kept and no global rollback was attempted", intent.Name, len(failures))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *Application) activeProfile() *profile.Profile {
|
||||
for i := range a.state.Profiles {
|
||||
if a.state.Profiles[i].Name == a.state.ActiveProfile {
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
package app_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"proxyctl/internal/app"
|
||||
"proxyctl/internal/dockerclient"
|
||||
"proxyctl/internal/profile"
|
||||
"proxyctl/internal/shell"
|
||||
"proxyctl/internal/target"
|
||||
)
|
||||
|
||||
type coordinatedTarget struct {
|
||||
id string
|
||||
state target.State
|
||||
applied []string
|
||||
appliedName string
|
||||
failName string
|
||||
}
|
||||
|
||||
func (t *coordinatedTarget) ID() string { return t.id }
|
||||
func (t *coordinatedTarget) Description() target.Description {
|
||||
return target.Description{Name: t.id, Summary: "summary", Details: "effects, non-effects, ownership, permissions, and side effects"}
|
||||
}
|
||||
func (t *coordinatedTarget) Availability(context.Context) target.Availability {
|
||||
return target.Availability{Available: true, Evidence: "detected"}
|
||||
}
|
||||
func (t *coordinatedTarget) Inspect(_ context.Context, active *profile.Profile) target.Inspection {
|
||||
if t.state == target.Enabled && (active == nil || (t.appliedName != "" && active.Name != t.appliedName)) {
|
||||
return target.Inspection{State: target.Drifted, Evidence: "managed configuration does not match the Active Profile"}
|
||||
}
|
||||
return target.Inspection{State: t.state, Evidence: "actual configuration"}
|
||||
}
|
||||
func (t *coordinatedTarget) Confirmation(bool) string { return "" }
|
||||
func (t *coordinatedTarget) SetDesired(_ context.Context, enabled bool, active *profile.Profile) target.OperationResult {
|
||||
if !enabled {
|
||||
t.state = target.Disabled
|
||||
return target.OperationResult{Inspection: target.Inspection{State: t.state, Evidence: "disabled"}}
|
||||
}
|
||||
t.applied = append(t.applied, active.Name)
|
||||
if active.Name == t.failName {
|
||||
t.state = target.Drifted
|
||||
return target.OperationResult{Inspection: target.Inspection{State: t.state, Evidence: "re-inspected partial write"}, Err: errors.New("failed using http://alice:secret@proxy.example")}
|
||||
}
|
||||
t.state = target.Enabled
|
||||
t.appliedName = active.Name
|
||||
return target.OperationResult{Inspection: target.Inspection{State: t.state, Evidence: "matches " + active.Name}}
|
||||
}
|
||||
|
||||
func TestActiveProfileSwitchCoordinatesEnabledTargetsWithoutRollback(t *testing.T) {
|
||||
first := &coordinatedTarget{id: "first", state: target.Disabled}
|
||||
second := &coordinatedTarget{id: "second", state: target.Disabled, failName: "new"}
|
||||
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{first, second})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"old", "new"} {
|
||||
result := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: profile.Profile{Name: name, SingleURL: "http://alice:secret@proxy.example:8080"}})
|
||||
if result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
}
|
||||
if result := application.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "old"}); result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
for _, id := range []string{"first", "second"} {
|
||||
if result := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: id, Enabled: true}); result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
}
|
||||
|
||||
result := application.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "new"})
|
||||
if result.Snapshot.ActiveProfile != "new" {
|
||||
t.Fatalf("Active Profile = %q", result.Snapshot.ActiveProfile)
|
||||
}
|
||||
if result.Err == nil || strings.Contains(result.Err.Error(), "secret") || !strings.Contains(result.Err.Error(), "second") {
|
||||
t.Fatalf("redacted partial failure = %v", result.Err)
|
||||
}
|
||||
if got := result.Snapshot.Targets; got[0].State != target.Enabled || got[1].State != target.Drifted {
|
||||
t.Fatalf("truthful partial states = %#v", got)
|
||||
}
|
||||
if len(result.Progress) != 2 || result.Progress[0].TargetID != "first" || result.Progress[0].State != target.Enabled || result.Progress[1].TargetID != "second" || result.Progress[1].State != target.Drifted {
|
||||
t.Fatalf("per-Target progress = %#v", result.Progress)
|
||||
}
|
||||
if len(first.applied) != 2 || first.applied[1] != "new" {
|
||||
t.Fatalf("successful Target was not updated: %#v", first.applied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationCoordinatesMultipleRealAndFakeHermeticAdaptersThroughPartialFailure(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
shellAdapter := shell.New(shell.Configuration{
|
||||
Shell: "/bin/bash", EnvironmentFile: filepath.Join(directory, "shell.env"), RecoveryFile: filepath.Join(directory, "shell.json"), TrustedRoots: []string{directory},
|
||||
}, nil, shell.MapEnvironment{})
|
||||
dockerAdapter := dockerclient.New(dockerclient.Configuration{
|
||||
ConfigFile: filepath.Join(directory, "docker", "config.json"), RecoveryFile: filepath.Join(directory, "docker", "recovery.json"), TrustedRoots: []string{directory},
|
||||
}, nil)
|
||||
failure := &coordinatedTarget{id: "failing-fake", state: target.Disabled, failName: "new"}
|
||||
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(directory, "profiles.json")), []target.Target{shellAdapter, dockerAdapter, failure})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, endpoint := range map[string]string{"old": "http://old-proxy.example:8080", "new": "http://new-proxy.example:9090"} {
|
||||
if result := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: profile.Profile{Name: name, SingleURL: endpoint}}); result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
}
|
||||
if result := application.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "old"}); result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
for _, id := range []string{shellAdapter.ID(), dockerAdapter.ID(), failure.ID()} {
|
||||
if result := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: id, Enabled: true}); result.Err != nil {
|
||||
t.Fatalf("enable %s: %v", id, result.Err)
|
||||
}
|
||||
}
|
||||
|
||||
result := application.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "new"})
|
||||
if result.Err == nil || result.Snapshot.ActiveProfile != "new" {
|
||||
t.Fatalf("partial switch = %#v", result)
|
||||
}
|
||||
states := map[string]target.State{}
|
||||
for _, item := range result.Snapshot.Targets {
|
||||
states[item.ID] = item.State
|
||||
}
|
||||
if states["shell"] != target.Enabled || states["docker-containers"] != target.Enabled || states["failing-fake"] != target.Drifted {
|
||||
t.Fatalf("real/fake Target states = %#v", states)
|
||||
}
|
||||
generated, err := os.ReadFile(filepath.Join(directory, "shell.env"))
|
||||
if err != nil || !strings.Contains(string(generated), "new-proxy.example:9090") {
|
||||
t.Fatalf("real Shell effect = %q, %v", generated, err)
|
||||
}
|
||||
dockerConfig, err := os.ReadFile(filepath.Join(directory, "docker", "config.json"))
|
||||
if err != nil || !strings.Contains(string(dockerConfig), "new-proxy.example:9090") {
|
||||
t.Fatalf("real Docker client effect = %q, %v", dockerConfig, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveProfileSwitchStreamsApplyingAndCompletionForEachTarget(t *testing.T) {
|
||||
adapter := &coordinatedTarget{id: "shell", state: target.Disabled}
|
||||
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"old", "new"} {
|
||||
if result := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: profile.Profile{Name: name, SingleURL: "http://proxy.example:8080"}}); result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
}
|
||||
if result := application.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "old"}); result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
if result := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: "shell", Enabled: true}); result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
|
||||
updates := application.DispatchProgress(app.Intent{Kind: app.ActivateProfile, Name: "new"})
|
||||
applying := <-updates
|
||||
completed := <-updates
|
||||
final := <-updates
|
||||
if len(applying.Progress) != 1 || !applying.Progress[0].Applying || applying.Progress[0].TargetID != "shell" {
|
||||
t.Fatalf("applying update = %#v", applying)
|
||||
}
|
||||
if len(completed.Progress) != 1 || completed.Progress[0].Applying || completed.Progress[0].State != target.Enabled {
|
||||
t.Fatalf("completion update = %#v", completed)
|
||||
}
|
||||
if !final.Complete || final.Snapshot.ActiveProfile != "new" {
|
||||
t.Fatalf("final update = %#v", final)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletingFinalProfileLeavesManagedTargetsDriftedAndDisableStillRestoresExternal(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
configuration := dockerclient.Configuration{
|
||||
ConfigFile: filepath.Join(directory, "docker", "config.json"), RecoveryFile: filepath.Join(directory, "state", "docker.json"), TrustedRoots: []string{directory},
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(configuration.ConfigFile), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const externalConfig = `{"proxies":{"default":{"httpProxy":"http://external.example:3128"}},"auths":{"registry.example":{}}}`
|
||||
if err := os.WriteFile(configuration.ConfigFile, []byte(externalConfig), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adapter := dockerclient.New(configuration, nil)
|
||||
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(directory, "profiles.json")), []target.Target{adapter})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: profile.Profile{Name: "only", SingleURL: "http://proxy.example:8080"}})
|
||||
if created.Err != nil {
|
||||
t.Fatal(created.Err)
|
||||
}
|
||||
if activated := application.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "only"}); activated.Err != nil {
|
||||
t.Fatal(activated.Err)
|
||||
}
|
||||
if enabled := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: adapter.ID(), Enabled: true}); enabled.Err != nil {
|
||||
t.Fatal(enabled.Err)
|
||||
}
|
||||
|
||||
unconfirmed := application.Dispatch(app.Intent{Kind: app.DeleteProfile, Name: "only"})
|
||||
if unconfirmed.Err == nil || unconfirmed.Snapshot.ActiveProfile != "only" {
|
||||
t.Fatalf("unconfirmed final delete = %#v", unconfirmed)
|
||||
}
|
||||
deleted := application.Dispatch(app.Intent{Kind: app.DeleteProfile, Name: "only", Confirm: true})
|
||||
if deleted.Err != nil || deleted.Snapshot.ActiveProfile != "" || deleted.Snapshot.Targets[0].State != target.Drifted {
|
||||
t.Fatalf("confirmed final delete = %#v", deleted)
|
||||
}
|
||||
enable := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: adapter.ID(), Enabled: true})
|
||||
if enable.Err == nil || !strings.Contains(enable.Err.Error(), "Profile management") {
|
||||
t.Fatalf("re-enable without Active Profile = %v", enable.Err)
|
||||
}
|
||||
disabled := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: adapter.ID(), Enabled: false})
|
||||
if disabled.Err != nil || disabled.Snapshot.Targets[0].State != target.External {
|
||||
t.Fatalf("disable after final delete = %#v", disabled)
|
||||
}
|
||||
restored, err := os.ReadFile(configuration.ConfigFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(restored), "external.example:3128") || !strings.Contains(string(restored), "registry.example") {
|
||||
t.Fatalf("External Docker client configuration was not restored: %s", restored)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -110,7 +110,7 @@ func (a *Adapter) Description() target.Description {
|
||||
return target.Description{
|
||||
Name: "APT",
|
||||
Summary: "Persistently configures APT proxying across Shell and privilege boundaries.",
|
||||
Details: "APT supports proxy environment variables; this integration uses an isolated persistent configuration overlay so package operations remain reliable across privilege boundaries. Writing /etc/apt normally requires pre-existing root authority; proxyctl never escalates privileges.",
|
||||
Details: "APT supports proxy environment variables; this Target affects persistent APT package operations across Shell and privilege boundaries; it does not change Shell, desktop, Docker, or other application proxy settings. proxyctl owns only an isolated apt.conf.d overlay, leaving earlier configuration underneath so disabling restores any External setting. Writing /etc/apt normally requires pre-existing root authority; proxyctl never escalates privileges, and no service restart is performed.",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -169,7 +169,7 @@ func (a *Adapter) Description() target.Description {
|
||||
return target.Description{
|
||||
Name: "Docker containers/builds",
|
||||
Summary: "Sets proxy defaults injected into future Docker containers and builds.",
|
||||
Details: "Affects new containers and builds through the owning user's Docker client configuration; it does not affect existing containers and is not Docker daemon registry access. It requires write access to the user's Docker configuration and does not contact the daemon.",
|
||||
Details: "Affects new containers and builds through the owning user's Docker client configuration; it does not affect existing containers or container networking and is not Docker daemon registry access. proxyctl owns selected proxy keys and recovery metadata while preserving other config.json content and restoring prior External values on disable. User-file write permission is required; it does not contact or restart the daemon.",
|
||||
}
|
||||
}
|
||||
func (a *Adapter) Confirmation(bool) string { return "" }
|
||||
|
||||
@@ -96,7 +96,7 @@ func (a *Adapter) Description() target.Description {
|
||||
return target.Description{
|
||||
Name: "Docker daemon",
|
||||
Summary: "Configures proxying for Docker daemon operations such as registry access, not container network traffic or container/build defaults.",
|
||||
Details: "Uses managed Docker daemon configuration. Every change reloads systemd and immediately restarts Docker, which may disrupt running work. System-file and service permissions are required; proxyctl reports permission errors and never initiates privilege escalation.",
|
||||
Details: "Affects Docker daemon network operations such as registry access; it does not change container networking, existing containers, or Docker container/build defaults. proxyctl owns managed proxy keys plus recovery metadata and restores the original daemon.json values on disable. System-file and systemd service permissions are required; every change reloads systemd and immediately restarts Docker, which may disrupt running work. proxyctl reports permission errors and never initiates privilege escalation.",
|
||||
}
|
||||
}
|
||||
func (a *Adapter) Confirmation(bool) string {
|
||||
|
||||
@@ -265,7 +265,7 @@ func (a *Adapter) Description() target.Description {
|
||||
return target.Description{
|
||||
Name: "GNOME Desktop",
|
||||
Summary: "Configures the GNOME desktop system proxy.",
|
||||
Details: "Affects applications that consume GNOME system network settings; not every graphical application consumes these settings. Settings belong to the desktop user and require that user's session bus and schema permissions.",
|
||||
Details: "Affects applications that consume GNOME system network settings; not every graphical application consumes these settings, and it does not affect Shell commands or system services. proxyctl records the complete original desktop proxy values before temporarily owning the managed settings and restores them on disable. The settings belong to the desktop user and require that user's usable session bus and schema permissions; changes take effect immediately without a service restart.",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ func New(configuration Configuration, files FileSystem, environment Environment)
|
||||
|
||||
func (a *Adapter) ID() string { return "shell" }
|
||||
func (a *Adapter) Description() target.Description {
|
||||
return target.Description{Name: "Shell", Summary: "Exports proxy variables for Bash and Zsh commands.", Details: "Affects commands started by the integrated Shell; it does not configure system services. Installation updates the user's Shell startup file and requires no privilege escalation."}
|
||||
return target.Description{Name: "Shell", Summary: "Exports proxy variables for commands started by integrated Bash and Zsh sessions.", Details: "Affects commands started by the integrated Shell; it does not configure system services, desktop settings, or already-running processes. proxyctl owns a generated environment fragment while its one-time startup hook remains in the user's Shell file. User-file write permission is required; after proxyctl exits, the installed wrapper reloads exports and unsets into the parent Shell."}
|
||||
}
|
||||
func (a *Adapter) Availability(context.Context) target.Availability {
|
||||
switch filepath.Base(a.configuration.Shell) {
|
||||
|
||||
+147
-22
@@ -17,6 +17,7 @@ const (
|
||||
mainPage page = iota
|
||||
profilesPage
|
||||
formPage
|
||||
helpPage
|
||||
)
|
||||
|
||||
type formState struct {
|
||||
@@ -26,21 +27,40 @@ type formState struct {
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
application app.Module
|
||||
snapshot app.Snapshot
|
||||
page page
|
||||
selected int
|
||||
form formState
|
||||
confirm bool
|
||||
targetConfirm *app.Intent
|
||||
status string
|
||||
applying bool
|
||||
application app.Module
|
||||
snapshot app.Snapshot
|
||||
page page
|
||||
selected int
|
||||
form formState
|
||||
confirm bool
|
||||
targetConfirm *app.Intent
|
||||
profileConfirm *app.Intent
|
||||
pendingActivation app.Intent
|
||||
status string
|
||||
applying bool
|
||||
profileTransition bool
|
||||
progressTarget string
|
||||
}
|
||||
|
||||
type targetOperationDone struct {
|
||||
result app.Result
|
||||
}
|
||||
|
||||
type profileOperationProgress struct {
|
||||
result app.Result
|
||||
updates <-chan app.Result
|
||||
}
|
||||
|
||||
func waitForProfileProgress(updates <-chan app.Result) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
result, ok := <-updates
|
||||
if !ok {
|
||||
return profileOperationProgress{result: app.Result{Complete: true}, updates: updates}
|
||||
}
|
||||
return profileOperationProgress{result: result, updates: updates}
|
||||
}
|
||||
}
|
||||
|
||||
func NewModel(application app.Module) Model {
|
||||
return Model{application: application, snapshot: application.Snapshot()}
|
||||
}
|
||||
@@ -48,6 +68,38 @@ func NewModel(application app.Module) Model {
|
||||
func (m Model) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if progress, ok := message.(profileOperationProgress); ok {
|
||||
m.snapshot = progress.result.Snapshot
|
||||
m.progressTarget = ""
|
||||
if len(progress.result.Progress) > 0 {
|
||||
item := progress.result.Progress[len(progress.result.Progress)-1]
|
||||
if item.Applying {
|
||||
m.progressTarget = item.TargetID
|
||||
}
|
||||
}
|
||||
if progress.result.Confirmation != "" {
|
||||
m.applying = false
|
||||
m.profileTransition = false
|
||||
m.page = profilesPage
|
||||
intent := m.pendingActivation
|
||||
m.profileConfirm = &intent
|
||||
m.status = progress.result.Confirmation
|
||||
return m, nil
|
||||
}
|
||||
if progress.result.Err != nil {
|
||||
m.status = "\x1b[31mError: " + progress.result.Err.Error() + "\x1b[0m"
|
||||
} else if progress.result.Notice != "" {
|
||||
m.status = progress.result.Notice
|
||||
}
|
||||
if progress.result.Complete {
|
||||
m.applying = false
|
||||
m.profileTransition = false
|
||||
m.progressTarget = ""
|
||||
m.pendingActivation = app.Intent{}
|
||||
return m, nil
|
||||
}
|
||||
return m, waitForProfileProgress(progress.updates)
|
||||
}
|
||||
if completed, ok := message.(targetOperationDone); ok {
|
||||
m.applying = false
|
||||
m.snapshot = completed.result.Snapshot
|
||||
@@ -78,6 +130,11 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m.updateProfiles(key)
|
||||
case formPage:
|
||||
return m.updateForm(key)
|
||||
case helpPage:
|
||||
if key.String() == "q" || key.String() == "esc" {
|
||||
m.page = mainPage
|
||||
}
|
||||
return m, nil
|
||||
default:
|
||||
return m, nil
|
||||
}
|
||||
@@ -103,7 +160,23 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch key.String() {
|
||||
case "p":
|
||||
m.page = profilesPage
|
||||
m.selected = 0
|
||||
m.status = ""
|
||||
case "?":
|
||||
m.page = helpPage
|
||||
m.status = ""
|
||||
case "r":
|
||||
if !m.applying {
|
||||
result := m.application.Dispatch(app.Intent{Kind: app.RefreshTargets})
|
||||
m.snapshot = result.Snapshot
|
||||
m.status = result.Notice
|
||||
if result.Err != nil {
|
||||
m.status = "Error: " + result.Err.Error()
|
||||
}
|
||||
if m.selected >= len(m.availableTargets()) && m.selected > 0 {
|
||||
m.selected--
|
||||
}
|
||||
}
|
||||
case "up", "k":
|
||||
if !m.applying && m.selected > 0 {
|
||||
m.selected--
|
||||
@@ -115,7 +188,7 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
case " ":
|
||||
if !m.applying && m.selected >= 0 && m.selected < len(targets) {
|
||||
selected := targets[m.selected]
|
||||
desired := selected.State != target.Enabled
|
||||
desired := selected.State == target.Disabled || selected.State == target.External
|
||||
m.applying = true
|
||||
m.status = ""
|
||||
intent := app.Intent{Kind: app.SetTargetDesired, TargetID: selected.ID, Enabled: desired}
|
||||
@@ -131,6 +204,19 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (m Model) updateProfiles(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if m.profileConfirm != nil {
|
||||
switch key.String() {
|
||||
case "y":
|
||||
intent := *m.profileConfirm
|
||||
intent.Confirm = true
|
||||
m.profileConfirm = nil
|
||||
return m.beginProfileActivation(intent)
|
||||
case "n", "esc", "q":
|
||||
m.profileConfirm = nil
|
||||
m.status = ""
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if m.confirm {
|
||||
switch key.String() {
|
||||
case "y":
|
||||
@@ -147,6 +233,7 @@ func (m Model) updateProfiles(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch key.String() {
|
||||
case "q", "esc":
|
||||
m.page = mainPage
|
||||
m.selected = 0
|
||||
m.status = ""
|
||||
case "up", "k":
|
||||
if m.selected > 0 {
|
||||
@@ -168,7 +255,7 @@ func (m Model) updateProfiles(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
case "a", "enter":
|
||||
if selected, ok := m.selectedProfile(); ok {
|
||||
m.apply(app.Intent{Kind: app.ActivateProfile, Name: selected.Name})
|
||||
return m.beginProfileActivation(app.Intent{Kind: app.ActivateProfile, Name: selected.Name})
|
||||
}
|
||||
case "d":
|
||||
if _, ok := m.selectedProfile(); ok {
|
||||
@@ -230,9 +317,29 @@ func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) beginProfileActivation(intent app.Intent) (tea.Model, tea.Cmd) {
|
||||
if progressive, ok := m.application.(app.ProgressiveModule); ok {
|
||||
m.applying = true
|
||||
m.profileTransition = true
|
||||
m.pendingActivation = intent
|
||||
m.page = mainPage
|
||||
m.selected = 0
|
||||
m.status = "Changing Active Profile…"
|
||||
updates := progressive.DispatchProgress(intent)
|
||||
return m, waitForProfileProgress(updates)
|
||||
}
|
||||
m.apply(intent)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) apply(intent app.Intent) {
|
||||
result := m.application.Dispatch(intent)
|
||||
m.snapshot = result.Snapshot
|
||||
if result.Confirmation != "" {
|
||||
m.profileConfirm = &intent
|
||||
m.status = result.Confirmation
|
||||
return
|
||||
}
|
||||
if m.selected >= len(m.snapshot.Profiles) && m.selected > 0 {
|
||||
m.selected--
|
||||
}
|
||||
@@ -265,6 +372,8 @@ func (m Model) View() string {
|
||||
return m.profilesView()
|
||||
case formPage:
|
||||
return m.formView()
|
||||
case helpPage:
|
||||
return m.helpView()
|
||||
default:
|
||||
return m.mainView()
|
||||
}
|
||||
@@ -291,7 +400,7 @@ func (m Model) mainView() string {
|
||||
}
|
||||
targets := m.availableTargets()
|
||||
if len(targets) == 0 {
|
||||
b.WriteString("\nNo Targets are available in this build.\n")
|
||||
b.WriteString("\nNo Targets are currently Available. Press ? to review detection reasons.\n")
|
||||
} else {
|
||||
b.WriteString("\nTargets\n")
|
||||
for i, item := range targets {
|
||||
@@ -300,25 +409,20 @@ func (m Model) mainView() string {
|
||||
cursor = "> "
|
||||
}
|
||||
state := string(item.State)
|
||||
if m.applying && i == m.selected {
|
||||
if (m.progressTarget != "" && item.ID == m.progressTarget) || (!m.profileTransition && m.progressTarget == "" && m.applying && i == m.selected) {
|
||||
state = "Applying…"
|
||||
}
|
||||
fmt.Fprintf(&b, "%s[%-9s] %s\n", cursor, state, item.Description.Name)
|
||||
}
|
||||
if m.selected >= 0 && m.selected < len(targets) {
|
||||
selected := targets[m.selected]
|
||||
fmt.Fprintf(&b, "\n%s\n%s\n", selected.Description.Summary, selected.Evidence)
|
||||
}
|
||||
}
|
||||
for _, item := range m.snapshot.Targets {
|
||||
if !item.Available {
|
||||
fmt.Fprintf(&b, "\nUnavailable %s: %s\n", item.Description.Name, item.AvailabilityEvidence)
|
||||
fmt.Fprintf(&b, "\n%s\n%s\n%s\n", selected.Description.Summary, selected.Description.Details, selected.Evidence)
|
||||
}
|
||||
}
|
||||
if m.targetConfirm != nil && !m.applying {
|
||||
b.WriteString("\ny: confirm n/Esc: cancel\n")
|
||||
} else {
|
||||
b.WriteString("\nSpace: toggle ↑/k ↓/j: select p: manage Proxy Profiles q/Esc: quit\n")
|
||||
b.WriteString("\nSpace: toggle ↑/k ↓/j: select p: manage Proxy Profiles r: refresh ?: help q/Esc: quit\n")
|
||||
}
|
||||
if m.status != "" {
|
||||
fmt.Fprintf(&b, "\n%s\n", m.status)
|
||||
@@ -326,6 +430,21 @@ func (m Model) mainView() string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m Model) helpView() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("proxyctl Help — built-in Targets\n\n")
|
||||
for _, item := range m.snapshot.Targets {
|
||||
fmt.Fprintf(&b, "%s\n%s\n%s\n", item.Description.Name, item.Description.Summary, item.Description.Details)
|
||||
if item.Available {
|
||||
fmt.Fprintf(&b, "Detected: %s\n\n", item.AvailabilityEvidence)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "Not detected: %s\n\n", item.AvailabilityEvidence)
|
||||
}
|
||||
}
|
||||
b.WriteString("Keys: ↑/k and ↓/j navigate; Space toggles; p manages Proxy Profiles; r refreshes; ? opens help; q/Escape returns or exits.\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m Model) profilesView() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Proxy Profiles\n\n")
|
||||
@@ -353,8 +472,14 @@ func (m Model) profilesView() string {
|
||||
}
|
||||
fmt.Fprintf(&b, "Bypass: %s\n", strings.Join(selected.Bypass, ", "))
|
||||
}
|
||||
if m.confirm {
|
||||
b.WriteString("\nDelete Profile? y: confirm n/Esc: cancel\n")
|
||||
if m.profileConfirm != nil {
|
||||
b.WriteString("\ny: confirm n/Esc: cancel\n")
|
||||
} else if m.confirm {
|
||||
if len(m.snapshot.Profiles) == 1 {
|
||||
b.WriteString("\nDelete Profile — this is the final Proxy Profile. Managed configuration will remain in place and become Drifted. y: confirm n/Esc: cancel\n")
|
||||
} else {
|
||||
b.WriteString("\nDelete Profile? y: confirm n/Esc: cancel\n")
|
||||
}
|
||||
} else {
|
||||
b.WriteString("\nn: new e: edit d: delete a/Enter: activate\n↑/k ↓/j: select q/Esc: back\n")
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"proxyctl/internal/app"
|
||||
"proxyctl/internal/profile"
|
||||
"proxyctl/internal/target"
|
||||
)
|
||||
|
||||
@@ -32,7 +33,7 @@ func TestTargetPanelShowsOnlyAvailableTargetsAndAppliesAsynchronously(t *testing
|
||||
module.result = app.Result{Snapshot: app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell", Summary: "Exports for Bash/Zsh"}, Available: true, State: target.Enabled, Evidence: "matches"}}}, Notice: "Shell Target is now Enabled"}
|
||||
model := NewModel(module)
|
||||
view := model.View()
|
||||
if !strings.Contains(view, "Shell") || strings.Contains(view, "] APT") || !strings.Contains(view, "Unavailable APT") || !strings.Contains(view, "Exports for Bash/Zsh") {
|
||||
if !strings.Contains(view, "Shell") || strings.Contains(view, "APT") || !strings.Contains(view, "Exports for Bash/Zsh") {
|
||||
t.Fatalf("Target panel:\n%s", view)
|
||||
}
|
||||
|
||||
@@ -85,13 +86,102 @@ func TestTargetRestartConfirmationCancelsWithoutConfirmedMutation(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnavailableShellIsOutsideMainListWithDetectionExplanation(t *testing.T) {
|
||||
func TestHelpListsEveryTargetAndMainListsOnlyAvailableTargets(t *testing.T) {
|
||||
module := &targetModule{snapshot: app.Snapshot{Targets: []app.TargetSnapshot{
|
||||
{ID: "shell", Description: target.Description{Name: "Shell"}, Available: false, AvailabilityEvidence: "Shell Target requires Bash or Zsh; neither was detected"},
|
||||
{ID: "shell", Description: target.Description{Name: "Shell", Summary: "summary", Details: "affects future Shell commands; does not affect daemons; owns a generated fragment; user permission; reload side effect"}, Available: true, State: target.Disabled, Evidence: "integration installed"},
|
||||
{ID: "apt", Description: target.Description{Name: "APT", Summary: "persistent package proxy", Details: "owns an overlay and requires system write permission"}, Available: false, AvailabilityEvidence: "Debian APT tool apt-config was not detected"},
|
||||
}}}
|
||||
view := NewModel(module).View()
|
||||
if strings.Contains(view, "[Disabled ] Shell") || !strings.Contains(view, "Unavailable Shell") || !strings.Contains(view, "requires Bash or Zsh") {
|
||||
t.Fatalf("unavailable Shell explanation:\n%s", view)
|
||||
model := NewModel(module)
|
||||
view := model.View()
|
||||
if strings.Contains(view, "APT") || !strings.Contains(view, "affects future Shell commands") || !strings.Contains(view, "integration installed") {
|
||||
t.Fatalf("main Target panel:\n%s", view)
|
||||
}
|
||||
model = updateKey(model, "?")
|
||||
view = model.View()
|
||||
if !strings.Contains(view, "Shell") || !strings.Contains(view, "APT") || !strings.Contains(view, "Not detected: Debian APT tool apt-config was not detected") || !strings.Contains(view, "owns an overlay") {
|
||||
t.Fatalf("Target help:\n%s", view)
|
||||
}
|
||||
model = updateKey(model, "esc")
|
||||
if !strings.Contains(model.View(), "Target panel") {
|
||||
t.Fatalf("Escape did not return from help:\n%s", model.View())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshKeyDispatchesDetectionRefresh(t *testing.T) {
|
||||
module := &targetModule{snapshot: app.Snapshot{}, result: app.Result{Snapshot: app.Snapshot{Targets: []app.TargetSnapshot{{ID: "apt", Description: target.Description{Name: "APT"}, Available: true, State: target.External}}}}}
|
||||
model := NewModel(module)
|
||||
model = updateKey(model, "r")
|
||||
if len(module.intents) != 1 || module.intents[0].Kind != app.RefreshTargets || !strings.Contains(model.View(), "External") {
|
||||
t.Fatalf("refresh = intents %#v\n%s", module.intents, model.View())
|
||||
}
|
||||
}
|
||||
|
||||
type progressiveTargetModule struct {
|
||||
targetModule
|
||||
updates chan app.Result
|
||||
}
|
||||
|
||||
func (m *progressiveTargetModule) DispatchProgress(intent app.Intent) <-chan app.Result {
|
||||
m.intents = append(m.intents, intent)
|
||||
return m.updates
|
||||
}
|
||||
|
||||
func TestActiveProfileTransitionRendersPerTargetProgress(t *testing.T) {
|
||||
initial := app.Snapshot{ActiveProfile: "old", Profiles: []profile.Profile{{Name: "new"}}, Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Enabled}}}
|
||||
updates := make(chan app.Result, 3)
|
||||
updates <- app.Result{Snapshot: app.Snapshot{ActiveProfile: "new", Targets: initial.Targets}, Progress: []app.TargetProgress{{TargetID: "shell", Applying: true}}}
|
||||
updates <- app.Result{Snapshot: app.Snapshot{ActiveProfile: "new", Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Enabled}}}, Progress: []app.TargetProgress{{TargetID: "shell", State: target.Enabled}}, Notice: "Shell Target update completed"}
|
||||
updates <- app.Result{Snapshot: app.Snapshot{ActiveProfile: "new", Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Enabled}}}, Complete: true, Notice: "Activated Profile new"}
|
||||
close(updates)
|
||||
module := &progressiveTargetModule{targetModule: targetModule{snapshot: initial}, updates: updates}
|
||||
model := updateKey(NewModel(module), "p")
|
||||
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
model = updated.(Model)
|
||||
if command == nil {
|
||||
t.Fatal("activation did not start progressive operation")
|
||||
}
|
||||
updated, command = model.Update(command())
|
||||
model = updated.(Model)
|
||||
if command == nil || !strings.Contains(model.View(), "Applying") || !strings.Contains(model.View(), "Active Profile: new") {
|
||||
t.Fatalf("applying progress:\n%s", model.View())
|
||||
}
|
||||
updated, command = model.Update(command())
|
||||
model = updated.(Model)
|
||||
if command == nil || !strings.Contains(model.View(), "Enabled") || !strings.Contains(model.View(), "update completed") {
|
||||
t.Fatalf("Target completion:\n%s", model.View())
|
||||
}
|
||||
updated, _ = model.Update(command())
|
||||
model = updated.(Model)
|
||||
if !strings.Contains(model.View(), "Activated Profile new") {
|
||||
t.Fatalf("final transition:\n%s", model.View())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetPanelRendersEveryTargetState(t *testing.T) {
|
||||
states := []target.State{target.Enabled, target.Disabled, target.External, target.Drifted}
|
||||
var targets []app.TargetSnapshot
|
||||
for _, state := range states {
|
||||
targets = append(targets, app.TargetSnapshot{ID: string(state), Description: target.Description{Name: string(state)}, Available: true, State: state})
|
||||
}
|
||||
view := NewModel(&targetModule{snapshot: app.Snapshot{Targets: targets}}).View()
|
||||
for _, state := range states {
|
||||
if !strings.Contains(view, string(state)) {
|
||||
t.Fatalf("missing state %s:\n%s", state, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriftedTargetWithoutActiveProfileTogglesTowardDisabled(t *testing.T) {
|
||||
initial := app.Snapshot{Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Drifted}}}
|
||||
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: app.Snapshot{Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Disabled}}}}}
|
||||
model := NewModel(module)
|
||||
_, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||
if command == nil {
|
||||
t.Fatal("Drifted Target did not start disable")
|
||||
}
|
||||
_ = command()
|
||||
if len(module.intents) != 1 || module.intents[0].Enabled {
|
||||
t.Fatalf("Drifted Target intent = %#v", module.intents)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user