package app import ( "context" "errors" "fmt" "strings" "time" "proxyctl/internal/profile" "proxyctl/internal/target" ) type IntentKind int const ( CreateProfile IntentKind = iota + 1 UpdateProfile DeleteProfile ActivateProfile SetTargetDesired RefreshTargets ) type Intent struct { Kind IntentKind Name string Profile profile.Profile Confirm bool TargetID string Enabled bool } type TargetSnapshot struct { ID string Description target.Description Available bool AvailabilityEvidence string State target.State Evidence string } type Snapshot struct { Profiles []profile.Profile ActiveProfile string 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 } type Module interface { Snapshot() Snapshot 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 targets []target.Target availabilities map[string]target.Availability inspections map[string]target.Inspection mutationLock MutationLock logger OperationLogger } func New(store profile.Store) (*Application, error) { return NewWithTargets(store, nil) } func NewWithTargets(store profile.Store, targets []target.Target) (*Application, error) { return NewWithTargetsAndLock(store, targets, nil) } // NewWithTargetsAndLock configures the cross-process lock used by every // Profile or Target mutation. A nil lock is intended only for isolated tests. func NewWithTargetsAndLock(store profile.Store, targets []target.Target, mutationLock MutationLock) (*Application, error) { return NewWithTargetsAndRuntime(store, targets, mutationLock, nil) } // NewWithTargetsAndRuntime configures shared mutation serialization and local // operation diagnostics for production callers. func NewWithTargetsAndRuntime(store profile.Store, targets []target.Target, mutationLock MutationLock, logger OperationLogger) (*Application, error) { state, err := store.Load() if err != nil { return nil, err } a := &Application{ store: store, state: state, targets: append([]target.Target(nil), targets...), availabilities: make(map[string]target.Availability), inspections: make(map[string]target.Inspection), mutationLock: mutationLock, logger: logger, } a.refreshTargets() return a, nil } func (a *Application) Snapshot() Snapshot { profiles := append([]profile.Profile(nil), a.state.Profiles...) snapshot := Snapshot{Profiles: profiles, ActiveProfile: a.state.ActiveProfile} for _, adapter := range a.targets { availability := a.availabilities[adapter.ID()] inspection := a.inspections[adapter.ID()] snapshot.Targets = append(snapshot.Targets, TargetSnapshot{ID: adapter.ID(), Description: adapter.Description(), Available: availability.Available, AvailabilityEvidence: availability.Evidence, State: inspection.State, Evidence: inspection.Evidence}) } return 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 { return Result{Snapshot: a.Snapshot(), Err: fmt.Errorf("lock proxyctl mutation: %w", err)} } defer unlock() } 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 UpdateProfile: current, notice, mutationErr = update(current, intent.Name, intent.Profile) case DeleteProfile: current, notice, mutationErr = deleteProfile(current, intent.Name, intent.Confirm) default: mutationErr = errors.New("unsupported Profile action") } return current, mutationErr }) if err == nil { a.state = next a.refreshInspections() } else { var committed *profile.CommitError if errors.As(err, &committed) { // Rename committed next before directory durability reporting failed. // Prefer that state even if the required verification reload also fails. a.state = next reloaded, loadErr := a.store.Load() if loadErr != nil { err = errors.Join(err, fmt.Errorf("reload committed Profile configuration: %w", loadErr)) } else { a.state = reloaded } } } 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) inspection := operation.Inspection var safeErr error if operation.Err != nil { // A failure-returned inspection may predate a partial write. Always // inspect explicitly against the new Active Profile before publishing. inspection = adapter.Inspect(context.Background(), active) safeErr = fmt.Errorf("%s (fresh inspection: %s — %s)", profile.RedactText(operation.Err.Error()), inspection.State, profile.RedactText(inspection.Evidence)) } a.inspections[adapter.ID()] = inspection item := TargetProgress{TargetID: adapter.ID(), State: inspection.State, Evidence: 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", 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 { value := a.state.Profiles[i] return &value } } return nil } func (a *Application) refreshTargets() { active := a.activeProfile() for _, adapter := range a.targets { a.availabilities[adapter.ID()] = adapter.Availability(context.Background()) a.inspections[adapter.ID()] = adapter.Inspect(context.Background(), active) } } func (a *Application) refreshInspections() { active := a.activeProfile() for _, adapter := range a.targets { a.inspections[adapter.ID()] = adapter.Inspect(context.Background(), active) } } func (a *Application) setTargetDesired(intent Intent) Result { var selected target.Target for _, adapter := range a.targets { if adapter.ID() == intent.TargetID { selected = adapter break } } if selected == nil { return Result{Snapshot: a.Snapshot(), Err: fmt.Errorf("Target %q does not exist", intent.TargetID)} } active := a.activeProfile() if intent.Enabled && active == nil { return Result{Snapshot: a.Snapshot(), Err: errors.New("no Active Profile; open Profile management before enabling a Target")} } if warning := selected.Confirmation(intent.Enabled); warning != "" && !intent.Confirm { return Result{Snapshot: a.Snapshot(), Confirmation: warning} } result := selected.SetDesired(context.Background(), intent.Enabled, active) inspection := result.Inspection if result.Err != nil { inspection = selected.Inspect(context.Background(), active) } a.inspections[selected.ID()] = inspection outcome, detail := "success", inspection.Evidence if result.Err != nil { outcome, detail = "failure", result.Err.Error() } if a.logger != nil { operation := "disable" if intent.Enabled { operation = "enable" } _ = a.logger.Write(LogEntry{Time: time.Now(), Operation: operation, Target: selected.ID(), Result: outcome, Detail: RedactLogDetail(detail)}) } notice := "" if result.Err == nil { notice = fmt.Sprintf("%s Target is now %s", selected.Description().Name, inspection.State) } return Result{Snapshot: a.Snapshot(), Notice: notice, Err: result.Err} } func create(state profile.State, input profile.Profile) (profile.State, string, error) { p, err := profile.ValidateAndNormalize(input) if err != nil { return state, "", err } if index(state, p.Name) >= 0 { return state, "", fmt.Errorf("a Profile named %q already exists", p.Name) } next := cloneState(state) next.Profiles = append(next.Profiles, p) return next, fmt.Sprintf("Created Profile %q", p.Name), nil } func activate(state profile.State, name string) (profile.State, string, error) { if index(state, name) < 0 { return state, "", fmt.Errorf("Profile %q does not exist", name) } next := cloneState(state) next.ActiveProfile = name return next, fmt.Sprintf("Activated Profile %q", name), nil } func update(state profile.State, originalName string, input profile.Profile) (profile.State, string, error) { profileIndex := index(state, originalName) if profileIndex < 0 { return state, "", fmt.Errorf("Profile %q does not exist", originalName) } p, err := profile.ValidateAndNormalize(input) if err != nil { return state, "", err } if other := index(state, p.Name); other >= 0 && other != profileIndex { return state, "", fmt.Errorf("a Profile named %q already exists", p.Name) } next := cloneState(state) next.Profiles[profileIndex] = p if next.ActiveProfile == originalName { next.ActiveProfile = p.Name } return next, fmt.Sprintf("Updated Profile %q", p.Name), nil } func deleteProfile(state profile.State, name string, confirm bool) (profile.State, string, error) { profileIndex := index(state, name) if profileIndex < 0 { return state, "", fmt.Errorf("Profile %q does not exist", name) } if len(state.Profiles) == 1 && !confirm { return state, "", errors.New("deleting the final Profile requires confirmation") } next := cloneState(state) next.Profiles = append(next.Profiles[:profileIndex], next.Profiles[profileIndex+1:]...) if next.ActiveProfile == name { next.ActiveProfile = "" } return next, fmt.Sprintf("Deleted Profile %q", name), nil } func index(state profile.State, name string) int { for i := range state.Profiles { if state.Profiles[i].Name == name { return i } } return -1 } func cloneState(state profile.State) profile.State { state.Profiles = append([]profile.Profile(nil), state.Profiles...) return state }