feat: coordinate Active Profile target panel

This commit is contained in:
proxyctl coordinator
2026-07-17 04:05:14 +08:00
parent 34b45c807f
commit fd113fb316
11 changed files with 652 additions and 36 deletions
+24 -1
View File
@@ -15,7 +15,10 @@ import (
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
"proxyctl/internal/app" "proxyctl/internal/app"
"proxyctl/internal/apt"
"proxyctl/internal/dockerclient"
"proxyctl/internal/dockerdaemon" "proxyctl/internal/dockerdaemon"
"proxyctl/internal/gnome"
"proxyctl/internal/operationlock" "proxyctl/internal/operationlock"
"proxyctl/internal/oplog" "proxyctl/internal/oplog"
"proxyctl/internal/profile" "proxyctl/internal/profile"
@@ -76,7 +79,10 @@ func runWithEnvironmentAndTargets(args []string, input io.Reader, output io.Writ
} }
shellTarget := shell.New(shellConfiguration, files, environment) shellTarget := shell.New(shellConfiguration, files, environment)
if systemTargets == nil { 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...) targets := append([]target.Target{shellTarget}, systemTargets...)
stateDirectory := filepath.Dir(shellConfiguration.RecoveryFile) stateDirectory := filepath.Dir(shellConfiguration.RecoveryFile)
@@ -136,6 +142,23 @@ func runWithEnvironmentAndTargets(args []string, input io.Reader, output io.Writ
return err 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 { 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 { if len(args) != 1 {
return errors.New("invalid maintenance command") return errors.New("invalid maintenance command")
+18
View File
@@ -7,9 +7,27 @@ import (
"strings" "strings"
"testing" "testing"
"proxyctl/internal/profile"
"proxyctl/internal/target" "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) { func TestDefaultInvocationStartsTUIWithNoProfileState(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir()) t.Setenv("XDG_CONFIG_HOME", t.TempDir())
var output bytes.Buffer var output bytes.Buffer
+135 -2
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"strings"
"time" "time"
"proxyctl/internal/profile" "proxyctl/internal/profile"
@@ -18,6 +19,7 @@ const (
DeleteProfile DeleteProfile
ActivateProfile ActivateProfile
SetTargetDesired SetTargetDesired
RefreshTargets
) )
type Intent struct { type Intent struct {
@@ -44,10 +46,20 @@ type Snapshot struct {
Targets []TargetSnapshot Targets []TargetSnapshot
} }
type TargetProgress struct {
TargetID string
Applying bool
State target.State
Evidence string
Err error
}
type Result struct { type Result struct {
Snapshot Snapshot Snapshot Snapshot
Notice string Notice string
Confirmation string Confirmation string
Progress []TargetProgress
Complete bool
Err error Err error
} }
@@ -56,6 +68,13 @@ type Module interface {
Dispatch(Intent) Result 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 { type Application struct {
store profile.Store store profile.Store
state profile.State state profile.State
@@ -103,6 +122,21 @@ func (a *Application) Snapshot() Snapshot {
} }
func (a *Application) Dispatch(intent Intent) Result { 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 { if a.mutationLock != nil {
unlock, err := a.mutationLock.Lock(context.Background()) unlock, err := a.mutationLock.Lock(context.Background())
if err != nil { if err != nil {
@@ -113,14 +147,19 @@ func (a *Application) Dispatch(intent Intent) Result {
if intent.Kind == SetTargetDesired { if intent.Kind == SetTargetDesired {
return a.setTargetDesired(intent) 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 var notice string
next, err := a.store.Mutate(func(current profile.State) (profile.State, error) { next, err := a.store.Mutate(func(current profile.State) (profile.State, error) {
var mutationErr error var mutationErr error
switch intent.Kind { switch intent.Kind {
case CreateProfile: case CreateProfile:
current, notice, mutationErr = create(current, intent.Profile) current, notice, mutationErr = create(current, intent.Profile)
case ActivateProfile:
current, notice, mutationErr = activate(current, intent.Name)
case UpdateProfile: case UpdateProfile:
current, notice, mutationErr = update(current, intent.Name, intent.Profile) current, notice, mutationErr = update(current, intent.Name, intent.Profile)
case DeleteProfile: case DeleteProfile:
@@ -150,6 +189,100 @@ func (a *Application) Dispatch(intent Intent) Result {
return Result{Snapshot: a.Snapshot(), Notice: notice, Err: err} 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 { func (a *Application) activeProfile() *profile.Profile {
for i := range a.state.Profiles { for i := range a.state.Profiles {
if a.state.Profiles[i].Name == a.state.ActiveProfile { if a.state.Profiles[i].Name == a.state.ActiveProfile {
+227
View File
@@ -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
View File
@@ -110,7 +110,7 @@ func (a *Adapter) Description() target.Description {
return target.Description{ return target.Description{
Name: "APT", Name: "APT",
Summary: "Persistently configures APT proxying across Shell and privilege boundaries.", 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.",
} }
} }
+1 -1
View File
@@ -169,7 +169,7 @@ func (a *Adapter) Description() target.Description {
return target.Description{ return target.Description{
Name: "Docker containers/builds", Name: "Docker containers/builds",
Summary: "Sets proxy defaults injected into future Docker containers and 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 "" } func (a *Adapter) Confirmation(bool) string { return "" }
+1 -1
View File
@@ -96,7 +96,7 @@ func (a *Adapter) Description() target.Description {
return target.Description{ return target.Description{
Name: "Docker daemon", Name: "Docker daemon",
Summary: "Configures proxying for Docker daemon operations such as registry access, not container network traffic or container/build defaults.", 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 { func (a *Adapter) Confirmation(bool) string {
+1 -1
View File
@@ -265,7 +265,7 @@ func (a *Adapter) Description() target.Description {
return target.Description{ return target.Description{
Name: "GNOME Desktop", Name: "GNOME Desktop",
Summary: "Configures the GNOME desktop system proxy.", 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.",
} }
} }
+1 -1
View File
@@ -173,7 +173,7 @@ func New(configuration Configuration, files FileSystem, environment Environment)
func (a *Adapter) ID() string { return "shell" } func (a *Adapter) ID() string { return "shell" }
func (a *Adapter) Description() target.Description { 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 { func (a *Adapter) Availability(context.Context) target.Availability {
switch filepath.Base(a.configuration.Shell) { switch filepath.Base(a.configuration.Shell) {
+147 -22
View File
@@ -17,6 +17,7 @@ const (
mainPage page = iota mainPage page = iota
profilesPage profilesPage
formPage formPage
helpPage
) )
type formState struct { type formState struct {
@@ -26,21 +27,40 @@ type formState struct {
} }
type Model struct { type Model struct {
application app.Module application app.Module
snapshot app.Snapshot snapshot app.Snapshot
page page page page
selected int selected int
form formState form formState
confirm bool confirm bool
targetConfirm *app.Intent targetConfirm *app.Intent
status string profileConfirm *app.Intent
applying bool pendingActivation app.Intent
status string
applying bool
profileTransition bool
progressTarget string
} }
type targetOperationDone struct { type targetOperationDone struct {
result app.Result 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 { func NewModel(application app.Module) Model {
return Model{application: application, snapshot: application.Snapshot()} 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) Init() tea.Cmd { return nil }
func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { 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 { if completed, ok := message.(targetOperationDone); ok {
m.applying = false m.applying = false
m.snapshot = completed.result.Snapshot m.snapshot = completed.result.Snapshot
@@ -78,6 +130,11 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
return m.updateProfiles(key) return m.updateProfiles(key)
case formPage: case formPage:
return m.updateForm(key) return m.updateForm(key)
case helpPage:
if key.String() == "q" || key.String() == "esc" {
m.page = mainPage
}
return m, nil
default: default:
return m, nil return m, nil
} }
@@ -103,7 +160,23 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch key.String() { switch key.String() {
case "p": case "p":
m.page = profilesPage m.page = profilesPage
m.selected = 0
m.status = "" 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": case "up", "k":
if !m.applying && m.selected > 0 { if !m.applying && m.selected > 0 {
m.selected-- m.selected--
@@ -115,7 +188,7 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
case " ": case " ":
if !m.applying && m.selected >= 0 && m.selected < len(targets) { if !m.applying && m.selected >= 0 && m.selected < len(targets) {
selected := targets[m.selected] selected := targets[m.selected]
desired := selected.State != target.Enabled desired := selected.State == target.Disabled || selected.State == target.External
m.applying = true m.applying = true
m.status = "" m.status = ""
intent := app.Intent{Kind: app.SetTargetDesired, TargetID: selected.ID, Enabled: desired} 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) { 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 { if m.confirm {
switch key.String() { switch key.String() {
case "y": case "y":
@@ -147,6 +233,7 @@ func (m Model) updateProfiles(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch key.String() { switch key.String() {
case "q", "esc": case "q", "esc":
m.page = mainPage m.page = mainPage
m.selected = 0
m.status = "" m.status = ""
case "up", "k": case "up", "k":
if m.selected > 0 { if m.selected > 0 {
@@ -168,7 +255,7 @@ func (m Model) updateProfiles(key tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
case "a", "enter": case "a", "enter":
if selected, ok := m.selectedProfile(); ok { 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": case "d":
if _, ok := m.selectedProfile(); ok { if _, ok := m.selectedProfile(); ok {
@@ -230,9 +317,29 @@ func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil 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) { func (m *Model) apply(intent app.Intent) {
result := m.application.Dispatch(intent) result := m.application.Dispatch(intent)
m.snapshot = result.Snapshot 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 { if m.selected >= len(m.snapshot.Profiles) && m.selected > 0 {
m.selected-- m.selected--
} }
@@ -265,6 +372,8 @@ func (m Model) View() string {
return m.profilesView() return m.profilesView()
case formPage: case formPage:
return m.formView() return m.formView()
case helpPage:
return m.helpView()
default: default:
return m.mainView() return m.mainView()
} }
@@ -291,7 +400,7 @@ func (m Model) mainView() string {
} }
targets := m.availableTargets() targets := m.availableTargets()
if len(targets) == 0 { 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 { } else {
b.WriteString("\nTargets\n") b.WriteString("\nTargets\n")
for i, item := range targets { for i, item := range targets {
@@ -300,25 +409,20 @@ func (m Model) mainView() string {
cursor = "> " cursor = "> "
} }
state := string(item.State) 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…" state = "Applying…"
} }
fmt.Fprintf(&b, "%s[%-9s] %s\n", cursor, state, item.Description.Name) fmt.Fprintf(&b, "%s[%-9s] %s\n", cursor, state, item.Description.Name)
} }
if m.selected >= 0 && m.selected < len(targets) { if m.selected >= 0 && m.selected < len(targets) {
selected := targets[m.selected] selected := targets[m.selected]
fmt.Fprintf(&b, "\n%s\n%s\n", selected.Description.Summary, selected.Evidence) fmt.Fprintf(&b, "\n%s\n%s\n%s\n", selected.Description.Summary, selected.Description.Details, selected.Evidence)
}
}
for _, item := range m.snapshot.Targets {
if !item.Available {
fmt.Fprintf(&b, "\nUnavailable %s: %s\n", item.Description.Name, item.AvailabilityEvidence)
} }
} }
if m.targetConfirm != nil && !m.applying { if m.targetConfirm != nil && !m.applying {
b.WriteString("\ny: confirm n/Esc: cancel\n") b.WriteString("\ny: confirm n/Esc: cancel\n")
} else { } 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 != "" { if m.status != "" {
fmt.Fprintf(&b, "\n%s\n", m.status) fmt.Fprintf(&b, "\n%s\n", m.status)
@@ -326,6 +430,21 @@ func (m Model) mainView() string {
return b.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 { func (m Model) profilesView() string {
var b strings.Builder var b strings.Builder
b.WriteString("Proxy Profiles\n\n") 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, ", ")) fmt.Fprintf(&b, "Bypass: %s\n", strings.Join(selected.Bypass, ", "))
} }
if m.confirm { if m.profileConfirm != nil {
b.WriteString("\nDelete Profile? y: confirm n/Esc: cancel\n") 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 { } else {
b.WriteString("\nn: new e: edit d: delete a/Enter: activate\n↑/k ↓/j: select q/Esc: back\n") b.WriteString("\nn: new e: edit d: delete a/Enter: activate\n↑/k ↓/j: select q/Esc: back\n")
} }
+96 -6
View File
@@ -8,6 +8,7 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/app" "proxyctl/internal/app"
"proxyctl/internal/profile"
"proxyctl/internal/target" "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"} 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) model := NewModel(module)
view := model.View() 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) 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{ 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() model := NewModel(module)
if strings.Contains(view, "[Disabled ] Shell") || !strings.Contains(view, "Unavailable Shell") || !strings.Contains(view, "requires Bash or Zsh") { view := model.View()
t.Fatalf("unavailable Shell explanation:\n%s", 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)
} }
} }