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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -53,6 +54,24 @@ func TestApplicationSnapshotStructuresContextualTargetActions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationSnapshotMakesUnknownTargetStateStructurallyUnavailable(t *testing.T) {
|
||||
adapter := &fakeTarget{available: true}
|
||||
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
createAndActivate(t, application)
|
||||
if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
|
||||
got := application.Snapshot().Targets[0].PrimaryAction
|
||||
const reason = "Target actions are unavailable because this Target state is unknown"
|
||||
if got.Kind != 0 || got.Available || got.DisabledReason != reason {
|
||||
t.Fatalf("unknown-state action = %#v, want structurally unavailable with reason %q", got, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationDispatchesExplicitContextualTargetIntents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -132,6 +151,57 @@ func TestApplicationReapplyPublishesTruthfulDriftWhenAdapterCannotConverge(t *te
|
||||
}
|
||||
}
|
||||
|
||||
type changingAvailabilityTarget struct {
|
||||
*fakeTarget
|
||||
detections int
|
||||
}
|
||||
|
||||
func (f *changingAvailabilityTarget) Detect(context.Context, *profile.Profile) target.Detection {
|
||||
f.detections++
|
||||
available := f.detections == 1
|
||||
return target.Detection{
|
||||
Availability: target.Availability{Available: available, Evidence: map[bool]string{true: "available", false: "no longer available"}[available]},
|
||||
Inspection: target.Inspection{State: target.External, Evidence: "actual state"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationRechecksAvailabilityImmediatelyBeforeTargetMutation(t *testing.T) {
|
||||
base := &fakeTarget{available: true, state: target.External}
|
||||
adapter := &changingAvailabilityTarget{fakeTarget: base}
|
||||
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
createAndActivate(t, application)
|
||||
if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
if before := application.Snapshot().Targets[0]; !before.Available || !before.PrimaryAction.Available {
|
||||
t.Fatalf("initial detection = %#v, want available action", before)
|
||||
}
|
||||
|
||||
result := application.Dispatch(app.Intent{Kind: app.ApplyProfileToTarget, TargetID: adapter.ID()})
|
||||
const reason = "Target actions are unavailable because this Target is unavailable"
|
||||
if result.Err == nil || result.Err.Error() != reason || len(base.calls) != 0 {
|
||||
t.Fatalf("available-to-unavailable result = %#v, SetDesired calls = %#v", result, base.calls)
|
||||
}
|
||||
if got := result.Snapshot.Targets[0]; got.Available || got.AvailabilityEvidence != "no longer available" || got.PrimaryAction.Available || got.PrimaryAction.DisabledReason != reason {
|
||||
t.Fatalf("published targeted detection = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
type failedDetectionTarget struct {
|
||||
*fakeTarget
|
||||
}
|
||||
|
||||
func (f failedDetectionTarget) Detect(context.Context, *profile.Profile) target.Detection {
|
||||
return target.Detection{
|
||||
Availability: target.Availability{Available: true, Evidence: "detected"},
|
||||
Inspection: target.Inspection{State: f.state, Evidence: "actual state"},
|
||||
Err: errors.New("detection failed"),
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationRejectsStructuredUnavailableActionWithoutAdapterMutation(t *testing.T) {
|
||||
adapter := &fakeTarget{available: false, state: target.External}
|
||||
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter})
|
||||
@@ -153,18 +223,88 @@ func TestApplicationRejectsStructuredUnavailableActionWithoutAdapterMutation(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationRejectsProfileDependentIntentWithoutActiveProfile(t *testing.T) {
|
||||
adapter := &fakeTarget{available: true, state: target.External}
|
||||
func TestApplicationValidatesEveryStructuredDisabledReasonBeforeAdapterMutation(t *testing.T) {
|
||||
assertRejected := func(t *testing.T, application *app.Application, adapter *fakeTarget, intent app.Intent, reason string) {
|
||||
t.Helper()
|
||||
adapter.calls = nil
|
||||
result := application.Dispatch(intent)
|
||||
if result.Err == nil || !strings.Contains(result.Err.Error(), reason) || len(adapter.calls) != 0 {
|
||||
t.Fatalf("disabled action result = %#v, calls = %#v, want reason containing %q", result, adapter.calls, reason)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("failed check", func(t *testing.T) {
|
||||
adapter := &fakeTarget{available: true, state: target.Disabled}
|
||||
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{failedDetectionTarget{fakeTarget: adapter}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
createAndActivate(t, application)
|
||||
if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err == nil {
|
||||
t.Fatal("Target check unexpectedly succeeded")
|
||||
}
|
||||
assertRejected(t, application, adapter, app.Intent{Kind: app.EnableTarget, TargetID: adapter.ID()}, "check failed")
|
||||
})
|
||||
|
||||
t.Run("unavailable Drift Resolution", func(t *testing.T) {
|
||||
adapter := &fakeTarget{available: false, state: target.Drifted}
|
||||
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
createAndActivate(t, application)
|
||||
if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
assertRejected(t, application, adapter, app.Intent{Kind: app.RestoreOriginal, TargetID: adapter.ID()}, "unavailable")
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplicationRejectsProfileDependentIntentsWithoutActiveProfile(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
state target.State
|
||||
intent app.IntentKind
|
||||
}{
|
||||
{name: "Apply Profile", state: target.External, intent: app.ApplyProfileToTarget},
|
||||
{name: "Reapply Active Profile", state: target.Drifted, intent: app.ReapplyActiveProfile},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
adapter := &fakeTarget{available: true, state: test.state}
|
||||
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
result := application.Dispatch(app.Intent{Kind: test.intent, TargetID: adapter.ID()})
|
||||
if result.Err == nil || !strings.Contains(result.Err.Error(), "no Active Profile") || len(adapter.calls) != 0 {
|
||||
t.Fatalf("missing Active Profile result = %#v, calls=%#v", result, adapter.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationRevalidatesRestoreAfterOwnershipSensitiveStateChange(t *testing.T) {
|
||||
adapter := &fakeTarget{available: true, state: target.Drifted}
|
||||
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
createAndActivate(t, application)
|
||||
if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
result := application.Dispatch(app.Intent{Kind: app.ApplyProfileToTarget, TargetID: adapter.ID()})
|
||||
if result.Err == nil || !strings.Contains(result.Err.Error(), "no Active Profile") || len(adapter.calls) != 0 {
|
||||
t.Fatalf("missing Active Profile result = %#v, calls=%#v", result, adapter.calls)
|
||||
|
||||
// Model ownership loss as a fresh inspection becoming External between the
|
||||
// displayed Drift Resolution and dispatch. The stale Restore intent must not
|
||||
// reach SetDesired, where it could otherwise overwrite external state.
|
||||
adapter.state = target.External
|
||||
adapter.calls = nil
|
||||
result := application.Dispatch(app.Intent{Kind: app.RestoreOriginal, TargetID: adapter.ID()})
|
||||
if result.Err == nil || !strings.Contains(result.Err.Error(), "External") || len(adapter.calls) != 0 || result.Snapshot.Targets[0].State != target.External {
|
||||
t.Fatalf("stale Restore result = %#v, calls = %#v", result, adapter.calls)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ func TestSecondApplicationUsesPersistedActiveProfileChangedByFirstApplication(t
|
||||
if got := second.Snapshot().ActiveProfile; got != "old" {
|
||||
t.Fatalf("second Application starting Active Profile = %q, want old", got)
|
||||
}
|
||||
refreshApplicationTargets(t, second)
|
||||
|
||||
if result := first.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "new"}); result.Err != nil {
|
||||
t.Fatalf("first Application activates new Profile: %v", result.Err)
|
||||
@@ -119,6 +120,7 @@ func TestActiveProfileSwitchCoordinatesEnabledTargetsWithoutRollback(t *testing.
|
||||
if result := application.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "old"}); result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
refreshApplicationTargets(t, application)
|
||||
for _, id := range []string{"first", "second"} {
|
||||
if result := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: id, Enabled: true}); result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
@@ -188,6 +190,7 @@ func TestApplicationCoordinatesMultipleRealAndFakeHermeticAdaptersThroughPartial
|
||||
if result := application.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "old"}); result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
refreshApplicationTargets(t, application)
|
||||
for _, id := range []string{shellAdapter.ID(), dockerAdapter.ID(), failure.ID()} {
|
||||
if result := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: id, Enabled: true}); result.Err != nil {
|
||||
t.Fatalf("enable %s: %v", id, result.Err)
|
||||
@@ -229,6 +232,7 @@ func TestActiveProfileProgressReusesAvailabilityCache(t *testing.T) {
|
||||
if result := application.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "old"}); result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
refreshApplicationTargets(t, application)
|
||||
if result := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: "shell", Enabled: true}); result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
@@ -264,6 +268,7 @@ func TestActiveProfileSwitchStreamsApplyingAndCompletionForEachTarget(t *testing
|
||||
if result := application.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "old"}); result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
refreshApplicationTargets(t, application)
|
||||
if result := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: "shell", Enabled: true}); result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
@@ -309,6 +314,7 @@ func TestDeletingFinalProfileLeavesManagedTargetsDriftedAndDisableStillRestoresE
|
||||
if activated := application.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "only"}); activated.Err != nil {
|
||||
t.Fatal(activated.Err)
|
||||
}
|
||||
refreshApplicationTargets(t, application)
|
||||
if enabled := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: adapter.ID(), Enabled: true}); enabled.Err != nil {
|
||||
t.Fatal(enabled.Err)
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ func TestDoctorReportsReadOnlyChecksEvidenceAndRedactsCredentials(t *testing.T)
|
||||
}
|
||||
|
||||
func TestApplicationSerializesAndLogsTargetMutations(t *testing.T) {
|
||||
adapter := &maintenanceTarget{id: "fake", available: target.Availability{Available: true}, inspection: target.Inspection{State: target.External}}
|
||||
adapter := &maintenanceTarget{id: "fake", available: target.Availability{Available: true}, inspection: target.Inspection{State: target.Disabled}}
|
||||
lock := &recordingLock{}
|
||||
log := &recordingLog{}
|
||||
application, err := app.NewWithTargetsAndRuntime(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter}, lock, log)
|
||||
@@ -124,12 +124,14 @@ func TestApplicationSerializesAndLogsTargetMutations(t *testing.T) {
|
||||
if result := application.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "office"}); result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
adapter.inspection = target.Inspection{State: target.Enabled}
|
||||
refreshApplicationTargets(t, application)
|
||||
adapter.disableErr = errors.New("failed http://alice:secret@proxy.test/private")
|
||||
result := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: "fake", Enabled: false})
|
||||
if result.Err == nil {
|
||||
t.Fatal("Target failure reported success")
|
||||
}
|
||||
if lock.acquired != 3 || lock.held != 0 {
|
||||
if lock.acquired != 4 || lock.held != 0 {
|
||||
t.Fatalf("all mutation lock acquired=%d held=%d", lock.acquired, lock.held)
|
||||
}
|
||||
if len(log.entries) != 1 || log.entries[0].Operation != "disable" || strings.Contains(log.entries[0].Detail, "secret") || strings.Contains(log.entries[0].Detail, "proxy.test/private") {
|
||||
|
||||
@@ -44,7 +44,11 @@ func (t *coordinatedOperationTarget) Detect(ctx context.Context, active *profile
|
||||
t.inDetection = true
|
||||
t.mu.Unlock()
|
||||
if t.detectionStarted != nil {
|
||||
close(t.detectionStarted)
|
||||
select {
|
||||
case <-t.detectionStarted:
|
||||
default:
|
||||
close(t.detectionStarted)
|
||||
}
|
||||
}
|
||||
if t.detectionRelease != nil {
|
||||
<-t.detectionRelease
|
||||
@@ -91,6 +95,7 @@ func TestInteractiveOperationStreamSerializesMutationAndPublishesStructuredProgr
|
||||
if result := application.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "office"}); result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
refreshApplicationTargets(t, application)
|
||||
|
||||
operation := application.StartOperation(app.Intent{Kind: app.SetTargetDesired, TargetID: "shell", Enabled: true})
|
||||
running := <-operation.Updates
|
||||
@@ -162,7 +167,6 @@ func TestStartOperationReturnsBeforeDispatchSerializationIsAvailable(t *testing.
|
||||
adapter := &coordinatedOperationTarget{
|
||||
id: "shell", state: target.Disabled,
|
||||
started: make(chan struct{}), release: make(chan struct{}),
|
||||
detectionStarted: make(chan struct{}), detectionRelease: make(chan struct{}),
|
||||
}
|
||||
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter})
|
||||
if err != nil {
|
||||
@@ -174,12 +178,15 @@ func TestStartOperationReturnsBeforeDispatchSerializationIsAvailable(t *testing.
|
||||
if result := application.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "office"}); result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
refreshApplicationTargets(t, application)
|
||||
|
||||
mutationDone := make(chan app.Result, 1)
|
||||
go func() {
|
||||
mutationDone <- application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: "shell", Enabled: true})
|
||||
}()
|
||||
<-adapter.started // barrier: synchronous Dispatch owns dispatch serialization
|
||||
adapter.detectionStarted = make(chan struct{})
|
||||
adapter.detectionRelease = make(chan struct{})
|
||||
|
||||
returned := make(chan app.OperationStream, 1)
|
||||
go func() { returned <- application.StartOperation(app.Intent{Kind: app.RefreshTargets}) }()
|
||||
@@ -213,6 +220,7 @@ func TestInteractiveOperationStreamUsesStructuredConfirmation(t *testing.T) {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
|
||||
refreshApplicationTargets(t, application)
|
||||
operation := application.StartOperation(app.Intent{Kind: app.SetTargetDesired, TargetID: adapter.ID(), Enabled: true})
|
||||
update := <-operation.Updates
|
||||
confirmation := update.Confirmation()
|
||||
@@ -239,6 +247,7 @@ func TestInteractiveProfileTransitionReportsFirstFailureWithoutRollback(t *testi
|
||||
if result := application.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "old"}); result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
refreshApplicationTargets(t, application)
|
||||
for _, id := range []string{"first", "second"} {
|
||||
if result := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: id, Enabled: true}); result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
|
||||
@@ -190,7 +190,11 @@ func (t *mutationInteractionTarget) SetDesired(context.Context, bool, *profile.P
|
||||
return target.OperationResult{Inspection: target.Inspection{State: target.Enabled, Evidence: "mutation completed"}}
|
||||
}
|
||||
func (t *mutationInteractionTarget) Detect(context.Context, *profile.Profile) target.Detection {
|
||||
close(t.started)
|
||||
select {
|
||||
case <-t.started:
|
||||
default:
|
||||
close(t.started)
|
||||
}
|
||||
<-t.release
|
||||
return target.Detection{
|
||||
Availability: target.Availability{Available: true, Evidence: "stale detection"},
|
||||
|
||||
@@ -86,6 +86,7 @@ func TestApplicationRequiresTargetConfirmationBeforeMutation(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
createAndActivate(t, application)
|
||||
refreshApplicationTargets(t, application)
|
||||
|
||||
warning := application.Dispatch(app.Intent{Kind: app.EnableTarget, TargetID: "docker-daemon"})
|
||||
if warning.ConfirmationRequest == nil || warning.Err != nil || len(adapter.calls) != 0 || warning.Snapshot.Targets[0].State != target.Disabled {
|
||||
@@ -119,6 +120,13 @@ func TestApplicationHidesUnavailableTargetButKeepsExplanation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func refreshApplicationTargets(t *testing.T, application *app.Application) {
|
||||
t.Helper()
|
||||
if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func createAndActivate(t *testing.T, application *app.Application) {
|
||||
t.Helper()
|
||||
if result := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: profile.Profile{Name: "office", SingleURL: "http://proxy.example:8080"}}); result.Err != nil {
|
||||
|
||||
Reference in New Issue
Block a user