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"
"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")
+18
View File
@@ -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
View File
@@ -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 {
+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{
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.",
}
}
+1 -1
View File
@@ -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 "" }
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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.",
}
}
+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) 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
View File
@@ -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")
}
+96 -6
View File
@@ -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)
}
}