feat: coordinate Active Profile target panel
This commit is contained in:
+135
-2
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"proxyctl/internal/profile"
|
||||
@@ -18,6 +19,7 @@ const (
|
||||
DeleteProfile
|
||||
ActivateProfile
|
||||
SetTargetDesired
|
||||
RefreshTargets
|
||||
)
|
||||
|
||||
type Intent struct {
|
||||
@@ -44,10 +46,20 @@ type Snapshot struct {
|
||||
Targets []TargetSnapshot
|
||||
}
|
||||
|
||||
type TargetProgress struct {
|
||||
TargetID string
|
||||
Applying bool
|
||||
State target.State
|
||||
Evidence string
|
||||
Err error
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Snapshot Snapshot
|
||||
Notice string
|
||||
Confirmation string
|
||||
Progress []TargetProgress
|
||||
Complete bool
|
||||
Err error
|
||||
}
|
||||
|
||||
@@ -56,6 +68,13 @@ type Module interface {
|
||||
Dispatch(Intent) Result
|
||||
}
|
||||
|
||||
// ProgressiveModule is implemented by Applications that can expose each
|
||||
// coordinated Target update while an Active Profile transition is running.
|
||||
// The final Result has Complete set and closes the stream.
|
||||
type ProgressiveModule interface {
|
||||
DispatchProgress(Intent) <-chan Result
|
||||
}
|
||||
|
||||
type Application struct {
|
||||
store profile.Store
|
||||
state profile.State
|
||||
@@ -103,6 +122,21 @@ func (a *Application) Snapshot() Snapshot {
|
||||
}
|
||||
|
||||
func (a *Application) Dispatch(intent Intent) Result {
|
||||
return a.dispatch(intent, nil)
|
||||
}
|
||||
|
||||
func (a *Application) DispatchProgress(intent Intent) <-chan Result {
|
||||
results := make(chan Result, 2*len(a.targets)+1)
|
||||
go func() {
|
||||
defer close(results)
|
||||
final := a.dispatch(intent, func(update Result) { results <- update })
|
||||
final.Complete = true
|
||||
results <- final
|
||||
}()
|
||||
return results
|
||||
}
|
||||
|
||||
func (a *Application) dispatch(intent Intent, publish func(Result)) Result {
|
||||
if a.mutationLock != nil {
|
||||
unlock, err := a.mutationLock.Lock(context.Background())
|
||||
if err != nil {
|
||||
@@ -113,14 +147,19 @@ func (a *Application) Dispatch(intent Intent) Result {
|
||||
if intent.Kind == SetTargetDesired {
|
||||
return a.setTargetDesired(intent)
|
||||
}
|
||||
if intent.Kind == RefreshTargets {
|
||||
a.refreshTargets()
|
||||
return Result{Snapshot: a.Snapshot(), Notice: "Refreshed Target availability and state"}
|
||||
}
|
||||
if intent.Kind == ActivateProfile {
|
||||
return a.activateProfile(intent, publish)
|
||||
}
|
||||
var notice string
|
||||
next, err := a.store.Mutate(func(current profile.State) (profile.State, error) {
|
||||
var mutationErr error
|
||||
switch intent.Kind {
|
||||
case CreateProfile:
|
||||
current, notice, mutationErr = create(current, intent.Profile)
|
||||
case ActivateProfile:
|
||||
current, notice, mutationErr = activate(current, intent.Name)
|
||||
case UpdateProfile:
|
||||
current, notice, mutationErr = update(current, intent.Name, intent.Profile)
|
||||
case DeleteProfile:
|
||||
@@ -150,6 +189,100 @@ func (a *Application) Dispatch(intent Intent) Result {
|
||||
return Result{Snapshot: a.Snapshot(), Notice: notice, Err: err}
|
||||
}
|
||||
|
||||
func (a *Application) activateProfile(intent Intent, publish func(Result)) Result {
|
||||
if index(a.state, intent.Name) < 0 {
|
||||
return Result{Snapshot: a.Snapshot(), Err: fmt.Errorf("Profile %q does not exist", intent.Name)}
|
||||
}
|
||||
if a.state.ActiveProfile == intent.Name {
|
||||
return Result{Snapshot: a.Snapshot(), Notice: fmt.Sprintf("Profile %q is already Active", intent.Name)}
|
||||
}
|
||||
|
||||
var enabled []target.Target
|
||||
var warnings []string
|
||||
for _, adapter := range a.targets {
|
||||
if a.inspections[adapter.ID()].State != target.Enabled {
|
||||
continue
|
||||
}
|
||||
enabled = append(enabled, adapter)
|
||||
if warning := adapter.Confirmation(true); warning != "" {
|
||||
warnings = append(warnings, adapter.Description().Name+": "+warning)
|
||||
}
|
||||
}
|
||||
if len(warnings) > 0 && !intent.Confirm {
|
||||
return Result{Snapshot: a.Snapshot(), Confirmation: "Changing the Active Profile requires confirmation:\n" + strings.Join(warnings, "\n")}
|
||||
}
|
||||
|
||||
next, err := a.store.Mutate(func(current profile.State) (profile.State, error) {
|
||||
next, _, mutationErr := activate(current, intent.Name)
|
||||
return next, mutationErr
|
||||
})
|
||||
if err != nil {
|
||||
var committed *profile.CommitError
|
||||
if errors.As(err, &committed) {
|
||||
a.state = next
|
||||
if reloaded, loadErr := a.store.Load(); loadErr != nil {
|
||||
err = errors.Join(err, fmt.Errorf("reload committed Profile configuration: %w", loadErr))
|
||||
} else {
|
||||
a.state = reloaded
|
||||
}
|
||||
}
|
||||
return Result{Snapshot: a.Snapshot(), Err: err}
|
||||
}
|
||||
a.state = next
|
||||
active := a.activeProfile()
|
||||
// The Active Profile has changed before any Target write begins. Re-inspect
|
||||
// immediately so rows not yet updated are truthfully Drifted rather than
|
||||
// appearing Enabled against stale managed values.
|
||||
for _, adapter := range a.targets {
|
||||
a.inspections[adapter.ID()] = adapter.Inspect(context.Background(), active)
|
||||
}
|
||||
var progress []TargetProgress
|
||||
var failures []error
|
||||
for _, adapter := range enabled {
|
||||
if publish != nil {
|
||||
publish(Result{Snapshot: a.Snapshot(), Progress: []TargetProgress{{TargetID: adapter.ID(), Applying: true, State: a.inspections[adapter.ID()].State}}, Notice: "Updating " + adapter.Description().Name + " Target"})
|
||||
}
|
||||
operation := adapter.SetDesired(context.Background(), true, active)
|
||||
a.inspections[adapter.ID()] = operation.Inspection
|
||||
var safeErr error
|
||||
if operation.Err != nil {
|
||||
safeErr = errors.New(profile.RedactText(operation.Err.Error()))
|
||||
}
|
||||
item := TargetProgress{TargetID: adapter.ID(), State: operation.Inspection.State, Evidence: operation.Inspection.Evidence, Err: safeErr}
|
||||
progress = append(progress, item)
|
||||
if publish != nil {
|
||||
publish(Result{Snapshot: a.Snapshot(), Progress: []TargetProgress{item}, Notice: adapter.Description().Name + " Target update completed", Err: safeErr})
|
||||
}
|
||||
if a.logger != nil {
|
||||
outcome, detail := "success", operation.Inspection.Evidence
|
||||
if safeErr != nil {
|
||||
outcome, detail = "failure", safeErr.Error()
|
||||
}
|
||||
_ = a.logger.Write(LogEntry{Time: time.Now(), Operation: "switch-Active-Profile", Target: adapter.ID(), Result: outcome, Detail: RedactLogDetail(detail)})
|
||||
}
|
||||
if safeErr != nil {
|
||||
failures = append(failures, fmt.Errorf("%s Target: %w", adapter.Description().Name, safeErr))
|
||||
}
|
||||
}
|
||||
// Re-inspect Targets which were not part of the transition so any managed
|
||||
// configuration is interpreted against the new Active Profile as well.
|
||||
updated := make(map[string]bool, len(enabled))
|
||||
for _, adapter := range enabled {
|
||||
updated[adapter.ID()] = true
|
||||
}
|
||||
for _, adapter := range a.targets {
|
||||
if !updated[adapter.ID()] {
|
||||
a.inspections[adapter.ID()] = adapter.Inspect(context.Background(), active)
|
||||
}
|
||||
}
|
||||
result := Result{Snapshot: a.Snapshot(), Progress: progress, Notice: fmt.Sprintf("Activated Profile %q", intent.Name)}
|
||||
if len(failures) > 0 {
|
||||
result.Err = errors.Join(failures...)
|
||||
result.Notice = fmt.Sprintf("Activated Profile %q with %d Target update failure(s); successful updates were kept and no global rollback was attempted", intent.Name, len(failures))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *Application) activeProfile() *profile.Profile {
|
||||
for i := range a.state.Profiles {
|
||||
if a.state.Profiles[i].Name == a.state.ActiveProfile {
|
||||
|
||||
Reference in New Issue
Block a user