449 lines
15 KiB
Go
449 lines
15 KiB
Go
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()
|
|
}
|
|
// A different process may have changed the persisted Active Profile since
|
|
// this Application was constructed. Reload only after the global mutation
|
|
// lock is held, before selecting a Profile or invoking a Target. Load is
|
|
// deliberately separate from Store.Mutate so no Profile-store lock is nested.
|
|
fresh, err := a.store.Load()
|
|
if err != nil {
|
|
return Result{Snapshot: a.Snapshot(), Err: fmt.Errorf("reload Profile state for mutation: %w", err)}
|
|
}
|
|
a.state = fresh
|
|
if intent.Kind == SetTargetDesired || intent.Kind == ActivateProfile {
|
|
a.refreshInspections()
|
|
}
|
|
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
|
|
}
|