962 lines
33 KiB
Go
962 lines
33 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"proxyctl/internal/profile"
|
|
"proxyctl/internal/target"
|
|
)
|
|
|
|
type IntentKind int
|
|
|
|
const (
|
|
CreateProfile IntentKind = iota + 1
|
|
UpdateProfile
|
|
DeleteProfile
|
|
ActivateProfile
|
|
RefreshTargets
|
|
EnableTarget
|
|
DisableTarget
|
|
ApplyProfileToTarget
|
|
ReapplyActiveProfile
|
|
RestoreOriginal
|
|
)
|
|
|
|
type Intent struct {
|
|
Kind IntentKind
|
|
Name string
|
|
Profile profile.Profile
|
|
Confirm bool
|
|
ConfirmationToken string
|
|
TargetID string
|
|
}
|
|
|
|
func confirmationToken(parts ...string) string {
|
|
hash := sha256.New()
|
|
for _, part := range parts {
|
|
_, _ = hash.Write([]byte(part))
|
|
_, _ = hash.Write([]byte{0})
|
|
}
|
|
return hex.EncodeToString(hash.Sum(nil))
|
|
}
|
|
|
|
// TargetActionKind identifies a Target action independently of display labels.
|
|
type TargetActionKind uint8
|
|
|
|
const (
|
|
EnableTargetAction TargetActionKind = iota + 1
|
|
DisableTargetAction
|
|
ApplyProfileTargetAction
|
|
ResolveDriftTargetAction
|
|
ReapplyActiveProfileAction
|
|
RestoreOriginalAction
|
|
)
|
|
|
|
// TargetAction exposes whether an intent is currently available and, when it
|
|
// is not, the reason presentation layers should show instead of ignoring it.
|
|
type TargetAction struct {
|
|
Kind TargetActionKind
|
|
Available bool
|
|
DisabledReason string
|
|
}
|
|
|
|
type targetActionPolicy struct {
|
|
kind TargetActionKind
|
|
intent IntentKind
|
|
state target.State
|
|
desired bool
|
|
primary bool
|
|
requiresProfile bool
|
|
operation string
|
|
}
|
|
|
|
var targetActionPolicies = []targetActionPolicy{
|
|
{kind: EnableTargetAction, intent: EnableTarget, state: target.Disabled, desired: true, primary: true, requiresProfile: true, operation: "enable"},
|
|
{kind: DisableTargetAction, intent: DisableTarget, state: target.Enabled, primary: true, operation: "disable"},
|
|
{kind: ApplyProfileTargetAction, intent: ApplyProfileToTarget, state: target.External, desired: true, primary: true, requiresProfile: true, operation: "apply-Profile"},
|
|
{kind: ResolveDriftTargetAction, state: target.Drifted, primary: true},
|
|
{kind: ReapplyActiveProfileAction, intent: ReapplyActiveProfile, state: target.Drifted, desired: true, requiresProfile: true, operation: "reapply-Active-Profile"},
|
|
{kind: RestoreOriginalAction, intent: RestoreOriginal, state: target.Drifted, operation: "restore-original"},
|
|
}
|
|
|
|
func targetActionPolicyForKind(kind TargetActionKind) (targetActionPolicy, bool) {
|
|
for _, policy := range targetActionPolicies {
|
|
if policy.kind == kind {
|
|
return policy, true
|
|
}
|
|
}
|
|
return targetActionPolicy{}, false
|
|
}
|
|
|
|
// Intent creates the Application intent represented by a structured Target
|
|
// action. Presentation layers use this as the sole action-to-intent boundary.
|
|
func (kind TargetActionKind) Intent(targetID string) (Intent, bool) {
|
|
policy, ok := targetActionPolicyForKind(kind)
|
|
if !ok || policy.intent == 0 {
|
|
return Intent{}, false
|
|
}
|
|
return Intent{Kind: policy.intent, TargetID: targetID}, true
|
|
}
|
|
|
|
type TargetDetectionPhase uint8
|
|
|
|
const (
|
|
// TargetReady is the zero value so snapshots supplied by non-progressive
|
|
// Modules remain compatible with the established dashboard contract.
|
|
TargetReady TargetDetectionPhase = iota
|
|
TargetChecking
|
|
TargetDetectionFailed
|
|
)
|
|
|
|
type TargetSnapshot struct {
|
|
ID string
|
|
Description target.Description
|
|
DetectionPhase TargetDetectionPhase
|
|
DetectionErr error
|
|
Available bool
|
|
AvailabilityEvidence string
|
|
State target.State
|
|
Evidence string
|
|
PrimaryAction TargetAction
|
|
DriftResolutionActions []TargetAction
|
|
}
|
|
|
|
type Snapshot struct {
|
|
Profiles []profile.Profile
|
|
ActiveProfile string
|
|
Targets []TargetSnapshot
|
|
}
|
|
|
|
type TargetProgress struct {
|
|
TargetID string
|
|
Phase TargetProgressPhase
|
|
State target.State
|
|
Evidence string
|
|
Err error
|
|
}
|
|
|
|
type Result struct {
|
|
Snapshot Snapshot
|
|
Notice string
|
|
ConfirmationRequest *Confirmation
|
|
Status OperationStatus
|
|
Progress []TargetProgress
|
|
FirstFailedTargetID string
|
|
Err error
|
|
}
|
|
|
|
type Module interface {
|
|
Snapshot() Snapshot
|
|
Dispatch(Intent) Result
|
|
}
|
|
|
|
type Application struct {
|
|
store profile.Store
|
|
state profile.State
|
|
targets []target.Target
|
|
targetIDs []string
|
|
targetDescriptions []target.Description
|
|
availabilities map[string]target.Availability
|
|
inspections map[string]target.Inspection
|
|
mutationBlockReasons map[string]string
|
|
detectionPhase map[string]TargetDetectionPhase
|
|
detectionErr map[string]error
|
|
detectionGeneration uint64
|
|
stateMu sync.RWMutex
|
|
dispatchMu sync.Mutex
|
|
interactiveMu sync.Mutex
|
|
interactiveOperation bool
|
|
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),
|
|
mutationBlockReasons: make(map[string]string),
|
|
detectionPhase: make(map[string]TargetDetectionPhase),
|
|
detectionErr: make(map[string]error),
|
|
mutationLock: mutationLock,
|
|
logger: logger,
|
|
}
|
|
for _, adapter := range a.targets {
|
|
id := adapter.ID()
|
|
a.targetIDs = append(a.targetIDs, id)
|
|
a.targetDescriptions = append(a.targetDescriptions, adapter.Description())
|
|
a.detectionPhase[id] = TargetChecking
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
func (a *Application) Snapshot() Snapshot {
|
|
a.stateMu.RLock()
|
|
defer a.stateMu.RUnlock()
|
|
return a.snapshotLocked()
|
|
}
|
|
|
|
func (a *Application) snapshotLocked() Snapshot {
|
|
state := cloneState(a.state)
|
|
snapshot := Snapshot{Profiles: state.Profiles, ActiveProfile: state.ActiveProfile}
|
|
for i, id := range a.targetIDs {
|
|
availability := a.availabilities[id]
|
|
inspection := a.inspections[id]
|
|
phase := a.detectionPhase[id]
|
|
primary, resolution := contextualTargetActions(phase, availability.Available, inspection.State, state.ActiveProfile != "", a.mutationBlockReasons[id])
|
|
snapshot.Targets = append(snapshot.Targets, TargetSnapshot{
|
|
ID: id, Description: a.targetDescriptions[i], DetectionPhase: phase, DetectionErr: a.detectionErr[id],
|
|
Available: availability.Available, AvailabilityEvidence: availability.Evidence, State: inspection.State, Evidence: inspection.Evidence,
|
|
PrimaryAction: primary, DriftResolutionActions: resolution,
|
|
})
|
|
}
|
|
return snapshot
|
|
}
|
|
|
|
func contextualTargetActions(phase TargetDetectionPhase, available bool, state target.State, hasActiveProfile bool, mutationBlockReason string) (TargetAction, []TargetAction) {
|
|
primaryKind := primaryTargetActionKind(state)
|
|
primary := TargetAction{Kind: primaryKind, Available: primaryKind != 0}
|
|
if primaryKind == 0 {
|
|
primary.DisabledReason = "Target actions are unavailable because this Target state is unknown"
|
|
}
|
|
if reason := targetActionDisabledReason(phase, available); reason != "" {
|
|
primary.Available = false
|
|
primary.DisabledReason = reason
|
|
}
|
|
if policy, ok := targetActionPolicyForKind(primaryKind); ok && primary.Available && policy.requiresProfile && !hasActiveProfile {
|
|
primary.Available = false
|
|
primary.DisabledReason = "This action is unavailable because there is no Active Profile; open Profile management first"
|
|
}
|
|
if primary.Available && mutationBlockReason != "" {
|
|
primary.Available = false
|
|
primary.DisabledReason = mutationBlockReason
|
|
}
|
|
if state != target.Drifted {
|
|
return primary, nil
|
|
}
|
|
|
|
var resolution []TargetAction
|
|
for _, policy := range targetActionPolicies {
|
|
if policy.state != target.Drifted || policy.primary || policy.intent == 0 {
|
|
continue
|
|
}
|
|
action := TargetAction{Kind: policy.kind, Available: true}
|
|
if policy.requiresProfile && !hasActiveProfile {
|
|
action.Available = false
|
|
action.DisabledReason = "Reapply Active Profile is unavailable because there is no Active Profile; open Profile management first"
|
|
}
|
|
if reason := targetActionDisabledReason(phase, available); reason != "" {
|
|
action.Available = false
|
|
action.DisabledReason = reason
|
|
}
|
|
if action.Available && mutationBlockReason != "" {
|
|
action.Available = false
|
|
action.DisabledReason = mutationBlockReason
|
|
}
|
|
resolution = append(resolution, action)
|
|
}
|
|
return primary, resolution
|
|
}
|
|
|
|
func primaryTargetActionKind(state target.State) TargetActionKind {
|
|
for _, policy := range targetActionPolicies {
|
|
if policy.primary && policy.state == state {
|
|
return policy.kind
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func targetActionDisabledReason(phase TargetDetectionPhase, available bool) string {
|
|
switch phase {
|
|
case TargetChecking:
|
|
return "Target actions are unavailable while this Target is still being checked"
|
|
case TargetDetectionFailed:
|
|
return "Target actions are unavailable because this Target check failed; refresh Targets to retry"
|
|
default:
|
|
if !available {
|
|
return "Target actions are unavailable because this Target is unavailable"
|
|
}
|
|
return ""
|
|
}
|
|
}
|
|
|
|
type targetDetectionOutcome struct {
|
|
targetID string
|
|
detection target.Detection
|
|
mutationBlockReason string
|
|
}
|
|
|
|
type targetDetectionUpdate struct {
|
|
snapshot Snapshot
|
|
targetID string
|
|
complete bool
|
|
}
|
|
|
|
func (a *Application) beginTargetDetection(ctx context.Context) (Snapshot, <-chan targetDetectionUpdate) {
|
|
a.stateMu.Lock()
|
|
a.detectionGeneration++
|
|
generation := a.detectionGeneration
|
|
for _, id := range a.targetIDs {
|
|
a.detectionPhase[id] = TargetChecking
|
|
a.detectionErr[id] = nil
|
|
}
|
|
active := activeProfile(a.state)
|
|
checking := a.snapshotLocked()
|
|
a.stateMu.Unlock()
|
|
|
|
updates := make(chan targetDetectionUpdate, len(a.targets)+1)
|
|
outcomes := make(chan targetDetectionOutcome, len(a.targets))
|
|
for i, adapter := range a.targets {
|
|
adapter, id := adapter, a.targetIDs[i]
|
|
go func() {
|
|
outcomes <- targetDetectionOutcome{
|
|
targetID: id, detection: detectTarget(ctx, adapter, active),
|
|
mutationBlockReason: mutationBlockReason(ctx, adapter),
|
|
}
|
|
}()
|
|
}
|
|
go func() {
|
|
defer close(updates)
|
|
for range a.targets {
|
|
outcome := <-outcomes
|
|
a.stateMu.Lock()
|
|
if generation != a.detectionGeneration {
|
|
a.stateMu.Unlock()
|
|
continue
|
|
}
|
|
phase := TargetReady
|
|
if outcome.detection.Err != nil {
|
|
phase = TargetDetectionFailed
|
|
}
|
|
a.availabilities[outcome.targetID] = outcome.detection.Availability
|
|
a.inspections[outcome.targetID] = outcome.detection.Inspection
|
|
a.mutationBlockReasons[outcome.targetID] = outcome.mutationBlockReason
|
|
a.detectionPhase[outcome.targetID] = phase
|
|
a.detectionErr[outcome.targetID] = outcome.detection.Err
|
|
update := targetDetectionUpdate{snapshot: a.snapshotLocked(), targetID: outcome.targetID}
|
|
// The buffered send cannot wait on a consumer and happens before a
|
|
// newer Begin call can make this generation stale.
|
|
updates <- update
|
|
a.stateMu.Unlock()
|
|
}
|
|
a.stateMu.Lock()
|
|
if generation == a.detectionGeneration {
|
|
updates <- targetDetectionUpdate{snapshot: a.snapshotLocked(), complete: true}
|
|
}
|
|
a.stateMu.Unlock()
|
|
}()
|
|
return checking, updates
|
|
}
|
|
|
|
func detectTarget(ctx context.Context, adapter target.Target, active *profile.Profile) target.Detection {
|
|
if detector, ok := adapter.(target.Detector); ok {
|
|
return detector.Detect(ctx, active)
|
|
}
|
|
return target.Detection{
|
|
Availability: adapter.Availability(ctx),
|
|
Inspection: adapter.Inspect(ctx, active),
|
|
}
|
|
}
|
|
|
|
func mutationBlockReason(ctx context.Context, adapter target.Target) string {
|
|
authorizer, ok := adapter.(target.MutationAuthorizer)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
check := authorizer.MutationAuthority(ctx)
|
|
if check.OK {
|
|
return ""
|
|
}
|
|
if check.Evidence == "" {
|
|
return "The current process does not have authority to change this Target"
|
|
}
|
|
return check.Evidence
|
|
}
|
|
|
|
func (a *Application) Dispatch(intent Intent) Result {
|
|
ctx, cancel := context.WithTimeout(context.Background(), operationTimeout)
|
|
defer cancel()
|
|
return a.dispatch(ctx, intent, nil)
|
|
}
|
|
|
|
func (a *Application) dispatch(ctx context.Context, intent Intent, publish func(Result)) Result {
|
|
// Serialize local mutation workflows even when tests intentionally omit the
|
|
// cross-process MutationLock.
|
|
a.dispatchMu.Lock()
|
|
defer a.dispatchMu.Unlock()
|
|
|
|
if a.mutationLock != nil {
|
|
unlock, err := a.mutationLock.Lock(ctx)
|
|
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.replaceState(fresh)
|
|
if intent.Kind == ActivateProfile {
|
|
a.refreshInspections(ctx)
|
|
}
|
|
if isTargetMutationIntent(intent.Kind) {
|
|
return a.setTargetDesired(ctx, intent, publish)
|
|
}
|
|
if intent.Kind == RefreshTargets {
|
|
return a.refreshTargets(ctx, publish)
|
|
}
|
|
if intent.Kind == ActivateProfile {
|
|
return a.activateProfile(ctx, 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.replaceState(next)
|
|
a.refreshInspections(ctx)
|
|
} 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.replaceState(next)
|
|
reloaded, loadErr := a.store.Load()
|
|
if loadErr != nil {
|
|
err = errors.Join(err, fmt.Errorf("reload committed Profile configuration: %w", loadErr))
|
|
} else {
|
|
a.replaceState(reloaded)
|
|
}
|
|
}
|
|
}
|
|
return Result{Snapshot: a.Snapshot(), Notice: notice, Err: err}
|
|
}
|
|
|
|
func (a *Application) activateProfile(ctx context.Context, intent Intent, publish func(Result)) Result {
|
|
state := a.stateCopy()
|
|
if index(state, intent.Name) < 0 {
|
|
return Result{Snapshot: a.Snapshot(), Err: fmt.Errorf("Profile %q does not exist", intent.Name)}
|
|
}
|
|
if state.ActiveProfile == intent.Name {
|
|
return Result{Snapshot: a.Snapshot(), Notice: fmt.Sprintf("Profile %q is already Active", intent.Name)}
|
|
}
|
|
|
|
inspections := a.inspectionsCopy()
|
|
var enabled []int
|
|
var warnings []string
|
|
confirmationScope := []string{"activate-profile", intent.Name}
|
|
for i, adapter := range a.targets {
|
|
id := a.targetIDs[i]
|
|
if inspections[id].State != target.Enabled {
|
|
continue
|
|
}
|
|
enabled = append(enabled, i)
|
|
confirmationScope = append(confirmationScope, id)
|
|
if warning := adapter.Confirmation(true); warning != "" {
|
|
detail := a.targetDescriptions[i].Name + ": " + warning
|
|
warnings = append(warnings, detail)
|
|
confirmationScope = append(confirmationScope, detail)
|
|
} else {
|
|
confirmationScope = append(confirmationScope, "")
|
|
}
|
|
}
|
|
token := confirmationToken(confirmationScope...)
|
|
if len(warnings) > 0 && (!intent.Confirm || intent.ConfirmationToken != token) {
|
|
confirmation := &Confirmation{
|
|
Title: "Confirm Active Profile change", Summary: "Changing the Active Profile has high-impact Target effects.",
|
|
Details: warnings, ConfirmLabel: "Change Profile", CancelLabel: "Cancel", HighImpact: true, Token: token,
|
|
}
|
|
return Result{Snapshot: a.Snapshot(), ConfirmationRequest: confirmation,
|
|
Status: OperationStatus{Summary: confirmation.Title}}
|
|
}
|
|
|
|
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.replaceState(next)
|
|
if reloaded, loadErr := a.store.Load(); loadErr != nil {
|
|
err = errors.Join(err, fmt.Errorf("reload committed Profile configuration: %w", loadErr))
|
|
} else {
|
|
a.replaceState(reloaded)
|
|
}
|
|
}
|
|
return Result{Snapshot: a.Snapshot(), Err: err}
|
|
}
|
|
a.replaceState(next)
|
|
active := a.activeProfile()
|
|
// The Active Profile has changed before any Target write begins. Re-inspect
|
|
// without the state lock, then publish all observations atomically.
|
|
observed := make(map[string]target.Inspection, len(a.targets))
|
|
for i, adapter := range a.targets {
|
|
observed[a.targetIDs[i]] = adapter.Inspect(ctx, active)
|
|
}
|
|
a.replaceInspections(observed)
|
|
|
|
var progress []TargetProgress
|
|
var failures []error
|
|
updated := make(map[string]bool, len(enabled))
|
|
for _, targetIndex := range enabled {
|
|
adapter := a.targets[targetIndex]
|
|
id := a.targetIDs[targetIndex]
|
|
name := a.targetDescriptions[targetIndex].Name
|
|
updated[id] = true
|
|
if publish != nil {
|
|
publish(Result{Snapshot: a.Snapshot(), Progress: []TargetProgress{{TargetID: id, Phase: TargetProgressRunning, State: a.inspection(id).State}}, Notice: "Updating " + name + " Target",
|
|
Status: OperationStatus{Summary: "Updating " + name + " Target", TargetID: id}})
|
|
}
|
|
operation := adapter.SetDesired(ctx, 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(ctx, active)
|
|
safeErr = fmt.Errorf("%s (fresh inspection: %s — %s)", profile.RedactText(operation.Err.Error()), inspection.State, profile.RedactText(inspection.Evidence))
|
|
}
|
|
a.replaceInspection(id, inspection)
|
|
if a.logger != nil {
|
|
outcome, detail := "success", inspection.Evidence
|
|
if safeErr != nil {
|
|
outcome, detail = "failure", safeErr.Error()
|
|
}
|
|
if logErr := a.logger.Write(LogEntry{Time: time.Now(), Operation: "switch-Active-Profile", Target: id, Result: outcome, Detail: RedactLogDetail(detail)}); logErr != nil {
|
|
safeErr = errors.Join(safeErr, fmt.Errorf("write %s Target operation log: %w", name, logErr))
|
|
}
|
|
}
|
|
progressPhase := TargetProgressSucceeded
|
|
if safeErr != nil {
|
|
progressPhase = TargetProgressFailed
|
|
}
|
|
item := TargetProgress{TargetID: id, Phase: progressPhase, State: inspection.State, Evidence: inspection.Evidence, Err: safeErr}
|
|
progress = append(progress, item)
|
|
if publish != nil {
|
|
status := OperationStatus{Summary: name + " Target update completed", TargetID: id}
|
|
if safeErr != nil {
|
|
status = OperationStatus{Summary: name + " Target update failed", TargetID: id}
|
|
}
|
|
publish(Result{Snapshot: a.Snapshot(), Progress: []TargetProgress{item}, Notice: name + " Target update completed", Status: status, Err: safeErr})
|
|
}
|
|
if safeErr != nil {
|
|
failures = append(failures, fmt.Errorf("%s Target: %w", 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.
|
|
remaining := make(map[string]target.Inspection)
|
|
for i, adapter := range a.targets {
|
|
id := a.targetIDs[i]
|
|
if !updated[id] {
|
|
remaining[id] = adapter.Inspect(ctx, active)
|
|
}
|
|
}
|
|
a.mergeInspections(remaining)
|
|
result := Result{Snapshot: a.Snapshot(), Progress: progress, Notice: fmt.Sprintf("Activated Profile %q", intent.Name),
|
|
Status: OperationStatus{Summary: 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))
|
|
result.Status = OperationStatus{Summary: fmt.Sprintf("Activated Profile %q with %d Target update failure(s)", intent.Name, len(failures))}
|
|
for _, item := range progress {
|
|
if item.Err != nil {
|
|
result.FirstFailedTargetID = item.TargetID
|
|
result.Status.TargetID = item.TargetID
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func activeProfile(state profile.State) *profile.Profile {
|
|
for i := range state.Profiles {
|
|
if state.Profiles[i].Name == state.ActiveProfile {
|
|
value := state.Profiles[i]
|
|
value.Bypass = append([]string(nil), value.Bypass...)
|
|
return &value
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *Application) activeProfile() *profile.Profile {
|
|
a.stateMu.RLock()
|
|
defer a.stateMu.RUnlock()
|
|
return activeProfile(a.state)
|
|
}
|
|
|
|
func (a *Application) stateCopy() profile.State {
|
|
a.stateMu.RLock()
|
|
defer a.stateMu.RUnlock()
|
|
return cloneState(a.state)
|
|
}
|
|
|
|
func (a *Application) replaceState(state profile.State) {
|
|
a.stateMu.Lock()
|
|
defer a.stateMu.Unlock()
|
|
a.detectionGeneration++
|
|
a.state = cloneState(state)
|
|
}
|
|
|
|
func (a *Application) inspectionsCopy() map[string]target.Inspection {
|
|
a.stateMu.RLock()
|
|
defer a.stateMu.RUnlock()
|
|
result := make(map[string]target.Inspection, len(a.inspections))
|
|
for id, inspection := range a.inspections {
|
|
result[id] = inspection
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (a *Application) inspection(id string) target.Inspection {
|
|
a.stateMu.RLock()
|
|
defer a.stateMu.RUnlock()
|
|
return a.inspections[id]
|
|
}
|
|
|
|
func (a *Application) replaceInspection(id string, inspection target.Inspection) {
|
|
a.stateMu.Lock()
|
|
defer a.stateMu.Unlock()
|
|
a.detectionGeneration++
|
|
a.inspections[id] = inspection
|
|
}
|
|
|
|
// publishTargetDetection atomically replaces all availability, inspection, and
|
|
// failure fields from one targeted check. Detect runs before this method, so no
|
|
// Application state mutex is held while adapter code executes.
|
|
func (a *Application) publishTargetDetection(id string, detection target.Detection, mutationBlockReason string) Snapshot {
|
|
a.stateMu.Lock()
|
|
defer a.stateMu.Unlock()
|
|
a.detectionGeneration++
|
|
a.availabilities[id] = detection.Availability
|
|
a.inspections[id] = detection.Inspection
|
|
a.mutationBlockReasons[id] = mutationBlockReason
|
|
a.detectionErr[id] = detection.Err
|
|
a.detectionPhase[id] = TargetReady
|
|
if detection.Err != nil {
|
|
a.detectionPhase[id] = TargetDetectionFailed
|
|
}
|
|
return a.snapshotLocked()
|
|
}
|
|
|
|
func (a *Application) replaceInspections(inspections map[string]target.Inspection) {
|
|
a.stateMu.Lock()
|
|
defer a.stateMu.Unlock()
|
|
a.detectionGeneration++
|
|
for _, id := range a.targetIDs {
|
|
a.inspections[id] = inspections[id]
|
|
}
|
|
}
|
|
|
|
func (a *Application) mergeInspections(inspections map[string]target.Inspection) {
|
|
if len(inspections) == 0 {
|
|
return
|
|
}
|
|
a.stateMu.Lock()
|
|
defer a.stateMu.Unlock()
|
|
a.detectionGeneration++
|
|
for id, inspection := range inspections {
|
|
a.inspections[id] = inspection
|
|
}
|
|
}
|
|
|
|
func targetByID(snapshot Snapshot, id string) (TargetSnapshot, string) {
|
|
for _, item := range snapshot.Targets {
|
|
if item.ID == id {
|
|
name := item.Description.Name
|
|
if name == "" {
|
|
name = id
|
|
}
|
|
return item, name
|
|
}
|
|
}
|
|
return TargetSnapshot{ID: id}, id
|
|
}
|
|
|
|
func (a *Application) refreshTargets(ctx context.Context, publish func(Result)) Result {
|
|
checking, updates := a.beginTargetDetection(ctx)
|
|
if publish != nil {
|
|
progress := make([]TargetProgress, 0, len(checking.Targets))
|
|
for _, item := range checking.Targets {
|
|
progress = append(progress, TargetProgress{TargetID: item.ID, Phase: TargetProgressRunning, State: item.State})
|
|
}
|
|
publish(Result{Snapshot: checking, Status: OperationStatus{Summary: "Checking Target availability and state"}, Progress: progress})
|
|
}
|
|
var failures []error
|
|
firstFailed := ""
|
|
final := checking
|
|
for update := range updates {
|
|
final = update.snapshot
|
|
if update.complete {
|
|
continue
|
|
}
|
|
item, name := targetByID(update.snapshot, update.targetID)
|
|
progress := TargetProgress{TargetID: update.targetID, Phase: TargetProgressSucceeded, State: item.State, Evidence: item.Evidence}
|
|
status := OperationStatus{Summary: "Checked " + name + " Target", TargetID: update.targetID}
|
|
if item.DetectionPhase == TargetDetectionFailed {
|
|
progress.Phase = TargetProgressFailed
|
|
progress.Err = item.DetectionErr
|
|
status.Summary = "Could not check " + name + " Target"
|
|
if item.DetectionErr != nil {
|
|
failures = append(failures, fmt.Errorf("%s Target: %w", name, item.DetectionErr))
|
|
}
|
|
if firstFailed == "" {
|
|
firstFailed = update.targetID
|
|
}
|
|
}
|
|
if publish != nil {
|
|
publish(Result{Snapshot: update.snapshot, Status: status, Progress: []TargetProgress{progress}, Err: progress.Err})
|
|
}
|
|
}
|
|
result := Result{Snapshot: final, Notice: "Target availability and state checks completed", Status: OperationStatus{Summary: "Target availability and state checks completed"}}
|
|
if len(failures) > 0 {
|
|
result.Err = errors.Join(failures...)
|
|
result.FirstFailedTargetID = firstFailed
|
|
result.Status = OperationStatus{Summary: "Some Target checks failed", TargetID: firstFailed}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (a *Application) refreshInspections(ctx context.Context) {
|
|
active := a.activeProfile()
|
|
inspections := make(map[string]target.Inspection, len(a.targets))
|
|
for i, adapter := range a.targets {
|
|
inspections[a.targetIDs[i]] = adapter.Inspect(ctx, active)
|
|
}
|
|
a.replaceInspections(inspections)
|
|
}
|
|
|
|
func isTargetMutationIntent(kind IntentKind) bool {
|
|
for _, policy := range targetActionPolicies {
|
|
if policy.intent == kind {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func requestedTargetAction(intent Intent) (TargetActionKind, bool) {
|
|
for _, policy := range targetActionPolicies {
|
|
if policy.intent == intent.Kind {
|
|
return policy.kind, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func structuredTargetAction(selected TargetSnapshot, kind TargetActionKind) (TargetAction, bool) {
|
|
if selected.PrimaryAction.Kind == kind {
|
|
return selected.PrimaryAction, true
|
|
}
|
|
for _, action := range selected.DriftResolutionActions {
|
|
if action.Kind == kind {
|
|
return action, true
|
|
}
|
|
}
|
|
return TargetAction{}, false
|
|
}
|
|
|
|
func validateTargetAction(intent Intent, selected TargetSnapshot) (targetActionPolicy, error) {
|
|
kind, ok := requestedTargetAction(intent)
|
|
if !ok {
|
|
return targetActionPolicy{}, fmt.Errorf("Target %q action is no longer available because its state is %s", selected.ID, selected.State)
|
|
}
|
|
action, ok := structuredTargetAction(selected, kind)
|
|
if !ok {
|
|
return targetActionPolicy{}, fmt.Errorf("Target %q action is no longer available because its state is %s", selected.ID, selected.State)
|
|
}
|
|
if !action.Available {
|
|
return targetActionPolicy{}, errors.New(action.DisabledReason)
|
|
}
|
|
policy, _ := targetActionPolicyForKind(kind)
|
|
return policy, nil
|
|
}
|
|
|
|
func (a *Application) setTargetDesired(ctx context.Context, intent Intent, publish func(Result)) Result {
|
|
selectedIndex := -1
|
|
for i, id := range a.targetIDs {
|
|
if id == intent.TargetID {
|
|
selectedIndex = i
|
|
break
|
|
}
|
|
}
|
|
if selectedIndex < 0 {
|
|
return Result{Snapshot: a.Snapshot(), Err: fmt.Errorf("Target %q does not exist", intent.TargetID)}
|
|
}
|
|
selected := a.targets[selectedIndex]
|
|
active := a.activeProfile()
|
|
// This targeted check runs inside dispatch serialization and immediately
|
|
// before action validation. Adapter calls happen without stateMu; the full
|
|
// outcome is then published under one lock before policy is derived.
|
|
detection := detectTarget(ctx, selected, active)
|
|
snapshot := a.publishTargetDetection(intent.TargetID, detection, mutationBlockReason(ctx, selected))
|
|
selectedSnapshot := snapshot.Targets[selectedIndex]
|
|
policy, err := validateTargetAction(intent, selectedSnapshot)
|
|
if err != nil {
|
|
return Result{Snapshot: snapshot, Err: err}
|
|
}
|
|
if warning := selected.Confirmation(policy.desired); warning != "" {
|
|
token := confirmationToken("target", intent.TargetID, fmt.Sprint(intent.Kind), fmt.Sprint(selectedSnapshot.State), fmt.Sprint(policy.desired), warning)
|
|
if !intent.Confirm || intent.ConfirmationToken != token {
|
|
confirmation := &Confirmation{Title: "Confirm " + a.targetDescriptions[selectedIndex].Name + " Target change", Summary: warning,
|
|
ConfirmLabel: "Continue", CancelLabel: "Cancel", HighImpact: true, Token: token}
|
|
return Result{Snapshot: a.Snapshot(), ConfirmationRequest: confirmation,
|
|
Status: OperationStatus{Summary: confirmation.Title, TargetID: intent.TargetID}}
|
|
}
|
|
}
|
|
if publish != nil {
|
|
publish(Result{Snapshot: snapshot, Progress: []TargetProgress{{TargetID: intent.TargetID, Phase: TargetProgressRunning, State: selectedSnapshot.State}},
|
|
Status: OperationStatus{Summary: "Updating " + a.targetDescriptions[selectedIndex].Name + " Target", TargetID: intent.TargetID}})
|
|
}
|
|
result := selected.SetDesired(ctx, policy.desired, active)
|
|
inspection := result.Inspection
|
|
if result.Err != nil {
|
|
inspection = selected.Inspect(ctx, active)
|
|
}
|
|
a.replaceInspection(intent.TargetID, inspection)
|
|
outcome, detail := "success", inspection.Evidence
|
|
if result.Err != nil {
|
|
outcome, detail = "failure", result.Err.Error()
|
|
}
|
|
if a.logger != nil {
|
|
if logErr := a.logger.Write(LogEntry{Time: time.Now(), Operation: policy.operation, Target: intent.TargetID, Result: outcome, Detail: RedactLogDetail(detail)}); logErr != nil {
|
|
result.Err = errors.Join(result.Err, fmt.Errorf("write Target operation log: %w", logErr))
|
|
}
|
|
}
|
|
notice := ""
|
|
progressPhase := TargetProgressSucceeded
|
|
status := OperationStatus{TargetID: intent.TargetID}
|
|
if result.Err == nil {
|
|
notice = fmt.Sprintf("%s Target is now %s", a.targetDescriptions[selectedIndex].Name, inspection.State)
|
|
status.Summary = notice
|
|
} else {
|
|
progressPhase = TargetProgressFailed
|
|
status.Summary = a.targetDescriptions[selectedIndex].Name + " Target update failed"
|
|
}
|
|
progress := TargetProgress{TargetID: intent.TargetID, Phase: progressPhase, State: inspection.State, Evidence: inspection.Evidence, Err: result.Err}
|
|
return Result{Snapshot: a.Snapshot(), Notice: notice, Status: status, Progress: []TargetProgress{progress}, FirstFailedTargetID: map[bool]string{true: intent.TargetID}[result.Err != nil], 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...)
|
|
for i := range state.Profiles {
|
|
state.Profiles[i].Bypass = append([]string(nil), state.Profiles[i].Bypass...)
|
|
}
|
|
return state
|
|
}
|