feat: add contextual Target actions
This commit is contained in:
+149
-76
@@ -57,6 +57,44 @@ type TargetAction struct {
|
||||
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 (
|
||||
@@ -193,13 +231,16 @@ func (a *Application) snapshotLocked() Snapshot {
|
||||
}
|
||||
|
||||
func contextualTargetActions(phase TargetDetectionPhase, available bool, state target.State, hasActiveProfile bool) (TargetAction, []TargetAction) {
|
||||
primary := TargetAction{Kind: primaryTargetActionKind(state), Available: true}
|
||||
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
|
||||
}
|
||||
needsProfile := state == target.Disabled || state == target.External
|
||||
if primary.Available && needsProfile && !hasActiveProfile {
|
||||
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"
|
||||
}
|
||||
@@ -207,36 +248,32 @@ func contextualTargetActions(phase TargetDetectionPhase, available bool, state t
|
||||
return primary, nil
|
||||
}
|
||||
|
||||
resolution := []TargetAction{
|
||||
{Kind: ReapplyActiveProfileAction, Available: true},
|
||||
{Kind: RestoreOriginalAction, Available: true},
|
||||
}
|
||||
if !hasActiveProfile {
|
||||
resolution[0].Available = false
|
||||
resolution[0].DisabledReason = "Reapply Active Profile is unavailable because there is no Active Profile; open Profile management first"
|
||||
}
|
||||
if reason := targetActionDisabledReason(phase, available); reason != "" {
|
||||
for i := range resolution {
|
||||
resolution[i].Available = false
|
||||
resolution[i].DisabledReason = reason
|
||||
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
|
||||
}
|
||||
resolution = append(resolution, action)
|
||||
}
|
||||
return primary, resolution
|
||||
}
|
||||
|
||||
func primaryTargetActionKind(state target.State) TargetActionKind {
|
||||
switch state {
|
||||
case target.Disabled:
|
||||
return EnableTargetAction
|
||||
case target.Enabled:
|
||||
return DisableTargetAction
|
||||
case target.External:
|
||||
return ApplyProfileTargetAction
|
||||
case target.Drifted:
|
||||
return ResolveDriftTargetAction
|
||||
default:
|
||||
return 0
|
||||
for _, policy := range targetActionPolicies {
|
||||
if policy.primary && policy.state == state {
|
||||
return policy.kind
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func targetActionDisabledReason(phase TargetDetectionPhase, available bool) string {
|
||||
@@ -352,7 +389,7 @@ func (a *Application) dispatch(intent Intent, publish func(Result)) Result {
|
||||
return Result{Snapshot: a.Snapshot(), Err: fmt.Errorf("reload Profile state for mutation: %w", err)}
|
||||
}
|
||||
a.replaceState(fresh)
|
||||
if isTargetMutationIntent(intent.Kind) || intent.Kind == ActivateProfile {
|
||||
if intent.Kind == ActivateProfile {
|
||||
a.refreshInspections()
|
||||
}
|
||||
if isTargetMutationIntent(intent.Kind) {
|
||||
@@ -584,6 +621,23 @@ func (a *Application) replaceInspection(id string, inspection target.Inspection)
|
||||
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) Snapshot {
|
||||
a.stateMu.Lock()
|
||||
defer a.stateMu.Unlock()
|
||||
a.detectionGeneration++
|
||||
a.availabilities[id] = detection.Availability
|
||||
a.inspections[id] = detection.Inspection
|
||||
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()
|
||||
@@ -672,29 +726,66 @@ func (a *Application) refreshInspections() {
|
||||
}
|
||||
|
||||
func isTargetMutationIntent(kind IntentKind) bool {
|
||||
switch kind {
|
||||
case SetTargetDesired, EnableTarget, DisableTarget, ApplyProfileToTarget, ReapplyActiveProfile, RestoreOriginal:
|
||||
if kind == SetTargetDesired {
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
for _, policy := range targetActionPolicies {
|
||||
if policy.intent == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func contextualIntentSemantics(kind IntentKind) (expected target.State, enabled bool, ok bool) {
|
||||
switch kind {
|
||||
case EnableTarget:
|
||||
return target.Disabled, true, true
|
||||
case DisableTarget:
|
||||
return target.Enabled, false, true
|
||||
case ApplyProfileToTarget:
|
||||
return target.External, true, true
|
||||
case ReapplyActiveProfile:
|
||||
return target.Drifted, true, true
|
||||
case RestoreOriginal:
|
||||
return target.Drifted, false, true
|
||||
default:
|
||||
return "", false, false
|
||||
func requestedTargetAction(intent Intent, selected TargetSnapshot) (TargetActionKind, bool) {
|
||||
if intent.Kind == SetTargetDesired {
|
||||
return legacyTargetAction(intent.Enabled, selected.State)
|
||||
}
|
||||
for _, policy := range targetActionPolicies {
|
||||
if policy.intent == intent.Kind {
|
||||
return policy.kind, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// legacyTargetAction is the only compatibility boundary for the former
|
||||
// SetTargetDesired boolean contract. All current callers use TargetActionKind.
|
||||
func legacyTargetAction(enabled bool, state target.State) (TargetActionKind, bool) {
|
||||
for _, policy := range targetActionPolicies {
|
||||
if policy.intent != 0 && policy.state == state && policy.desired == enabled {
|
||||
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, selected)
|
||||
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(intent Intent, publish func(Result)) Result {
|
||||
@@ -709,29 +800,28 @@ func (a *Application) setTargetDesired(intent Intent, publish func(Result)) Resu
|
||||
return Result{Snapshot: a.Snapshot(), Err: fmt.Errorf("Target %q does not exist", intent.TargetID)}
|
||||
}
|
||||
selected := a.targets[selectedIndex]
|
||||
enabled := intent.Enabled
|
||||
if expected, contextualEnabled, contextual := contextualIntentSemantics(intent.Kind); contextual {
|
||||
actual := a.inspection(intent.TargetID).State
|
||||
if actual != expected {
|
||||
return Result{Snapshot: a.Snapshot(), Err: fmt.Errorf("Target %q action is no longer available because its state is %s", intent.TargetID, actual)}
|
||||
}
|
||||
enabled = contextualEnabled
|
||||
}
|
||||
active := a.activeProfile()
|
||||
if enabled && active == nil {
|
||||
return Result{Snapshot: a.Snapshot(), Err: errors.New("no Active Profile; open Profile management before enabling a Target")}
|
||||
// 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(selected, active)
|
||||
snapshot := a.publishTargetDetection(intent.TargetID, detection)
|
||||
selectedSnapshot := snapshot.Targets[selectedIndex]
|
||||
policy, err := validateTargetAction(intent, selectedSnapshot)
|
||||
if err != nil {
|
||||
return Result{Snapshot: snapshot, Err: err}
|
||||
}
|
||||
if warning := selected.Confirmation(enabled); warning != "" && !intent.Confirm {
|
||||
if warning := selected.Confirmation(policy.desired); warning != "" && !intent.Confirm {
|
||||
confirmation := &Confirmation{Title: "Confirm " + a.targetDescriptions[selectedIndex].Name + " Target change", Summary: warning,
|
||||
ConfirmLabel: "Continue", CancelLabel: "Cancel", HighImpact: true}
|
||||
return Result{Snapshot: a.Snapshot(), ConfirmationRequest: confirmation,
|
||||
Status: OperationStatus{Summary: confirmation.Title, TargetID: intent.TargetID}}
|
||||
}
|
||||
if publish != nil {
|
||||
publish(Result{Snapshot: a.Snapshot(), Progress: []TargetProgress{{TargetID: intent.TargetID, Phase: TargetProgressRunning, State: a.inspection(intent.TargetID).State}},
|
||||
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(context.Background(), enabled, active)
|
||||
result := selected.SetDesired(context.Background(), policy.desired, active)
|
||||
inspection := result.Inspection
|
||||
if result.Err != nil {
|
||||
inspection = selected.Inspect(context.Background(), active)
|
||||
@@ -742,8 +832,7 @@ func (a *Application) setTargetDesired(intent Intent, publish func(Result)) Resu
|
||||
outcome, detail = "failure", result.Err.Error()
|
||||
}
|
||||
if a.logger != nil {
|
||||
operation := targetOperationName(intent.Kind, enabled)
|
||||
_ = a.logger.Write(LogEntry{Time: time.Now(), Operation: operation, Target: intent.TargetID, Result: outcome, Detail: RedactLogDetail(detail)})
|
||||
_ = a.logger.Write(LogEntry{Time: time.Now(), Operation: policy.operation, Target: intent.TargetID, Result: outcome, Detail: RedactLogDetail(detail)})
|
||||
}
|
||||
notice := ""
|
||||
progressPhase := TargetProgressSucceeded
|
||||
@@ -759,22 +848,6 @@ func (a *Application) setTargetDesired(intent Intent, publish func(Result)) Resu
|
||||
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 targetOperationName(kind IntentKind, enabled bool) string {
|
||||
switch kind {
|
||||
case ApplyProfileToTarget:
|
||||
return "apply-Profile"
|
||||
case ReapplyActiveProfile:
|
||||
return "reapply-Active-Profile"
|
||||
case RestoreOriginal:
|
||||
return "restore-original"
|
||||
default:
|
||||
if enabled {
|
||||
return "enable"
|
||||
}
|
||||
return "disable"
|
||||
}
|
||||
}
|
||||
|
||||
func create(state profile.State, input profile.Profile) (profile.State, string, error) {
|
||||
p, err := profile.ValidateAndNormalize(input)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user