From ec8c7e0d646fe0b5d38e987e91c5c8829f80c45e Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 22 Sep 2026 11:51:31 -0400 Subject: [PATCH 1/3] feat: approve or reject manual approval stages from the dashboard (#57) --- CONTEXT.md | 10 +- README.md | 9 +- internal/aggregator/aggregator.go | 9 +- internal/aggregator/aggregator_test.go | 14 +++ internal/aws/approval_test.go | 75 +++++++++++++ internal/aws/client.go | 16 ++- internal/fix/actioner_aws.go | 34 ++++++ internal/fix/actioner_aws_internal_test.go | 28 +++++ internal/fix/approval_test.go | 117 +++++++++++++++++++++ internal/fix/fix.go | 46 ++++++++ internal/poller/poller.go | 5 + internal/poller/stuck_test.go | 17 +++ internal/state/state.go | 39 ++++++- internal/state/state_test.go | 42 ++++++++ internal/ui/approval_test.go | 78 ++++++++++++++ internal/ui/dashboard.go | 46 ++++++-- internal/ui/help.go | 2 + internal/ui/icons.go | 2 + 18 files changed, 570 insertions(+), 19 deletions(-) create mode 100644 internal/aws/approval_test.go create mode 100644 internal/fix/actioner_aws_internal_test.go create mode 100644 internal/fix/approval_test.go create mode 100644 internal/ui/approval_test.go diff --git a/CONTEXT.md b/CONTEXT.md index fee4584..af4033d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -37,13 +37,19 @@ The visual health indicator for a Pipeline. Derived from the latest Execution st | ๐ŸŸข Green | Healthy | `Succeeded` | | ๐Ÿ”ด Red | Broken or blocked | `Failed`, `Stopped` | | ๐ŸŸก Yellow | In progress | `InProgress` | +| โธ Awaiting approval | Waiting on a person | `InProgress` at a manual approval action with an open approval token | | โšช Grey | No signal | `Superseded`, no executions yet | +Awaiting approval ranks between Yellow and Red: it needs a person, not more time, but a failure still outranks it. It is raised in `state.FromData` from the action's approval token, because execution statuses alone can't tell an approval gate from a running build. + _Avoid_: badge, indicator, light +**Approval**: A manual approval action waiting on a decision. `GetPipelineState` returns a token for it only while it is open; approving or rejecting (`a` / `x`) sends that token to `PutApprovalResult`. The token comes from the last poll, so it can already be stale if someone decided in the console; that is reported as "already decided", not as a failure. A pipeline awaiting approval never raises a stuck alert. +_Avoid_: gate (in prose), sign-off + ## Active-first sorting -Pipelines are sorted by Stoplight priority so the most actionable items appear at the top: ๐ŸŸก in-progress โ†’ ๐Ÿ”ด failing โ†’ ๐ŸŸข passing โ†’ โšช no signal. Order is stable within each tier. +Pipelines are sorted by Stoplight priority so the most actionable items appear at the top: โธ awaiting approval โ†’ ๐ŸŸก in-progress โ†’ ๐Ÿ”ด failing โ†’ ๐ŸŸข passing โ†’ โšช no signal. Order is stable within each tier. _Avoid_: bubbling, floating ## Dashboard tree @@ -111,4 +117,4 @@ _Avoid_: task fetch, stopped tasks (use Task detail) ## Flagged ambiguities - "account" vs "profile" โ€” resolved: Account is the logical concept; profile is the credential mechanism -- "manual approval" โ€” open: whether to show approval gates as a special Stoplight state (e.g. โธ) is not yet decided +- "manual approval" โ€” resolved: approval gates get their own โธ Stoplight state (see **Approval**) diff --git a/README.md b/README.md index 5d20ace..600e103 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A terminal dashboard for live AWS resource health across multiple accounts and r ## Features -- **Stoplight-per-project** โ€” ๐ŸŸข ๐Ÿ”ด ๐ŸŸก โšช derived from worst-case across pipeline, CloudFormation stacks, and ECS services +- **Stoplight-per-project** โ€” ๐ŸŸข ๐Ÿ”ด ๐ŸŸก โธ โšช derived from worst-case across pipeline, CloudFormation stacks, and ECS services - **Per-resource-type summary** โ€” collapsed row shows `Pipeline ๐ŸŸก Stacks ๐ŸŸข ECS ๐ŸŸข` at a glance - **Stage-level expand** โ€” expand any project to see pipeline stages, stack statuses with elapsed timers, and ECS running/desired task counts - **Active-first sorting** โ€” in-progress and failing projects surface to the top automatically @@ -17,6 +17,7 @@ A terminal dashboard for live AWS resource health across multiple accounts and r - **CloudFormation monitoring** โ€” maps stack status to stoplight; in-progress stacks show elapsed timer - **ECS service monitoring** โ€” shows running/desired task counts; flags active deployments. Stopped-task detail is fetched only for services that are actually unhealthy, so a green fleet costs one call per cluster - **Throttle-aware** โ€” AWS clients use the SDK's adaptive retry mode, self-throttling before AWS has to reject a burst +- **Manual approvals** โ€” pipelines waiting at an approval gate show โธ and sort to the top; approve or reject from the dashboard with `a` / `x` - **Stuck alerts** โ€” POSTs a signed JSON event to your webhooks when a pipeline, stack, or service stays wedged past a threshold, once per incident - **Multi-account** โ€” per-account AWS profile config with named profiles or environment credentials - **In-TUI project management** โ€” add, edit, delete, and enable/disable projects without leaving the terminal @@ -106,7 +107,7 @@ A dashboard only helps when someone is looking at it. Configure one or more `[[webhooks]]` and aws-green POSTs a JSON event the moment a resource has been wedged for longer than `stuck_threshold_minutes` (default `30`): -- **Pipeline** โ€” a stage of the latest execution is `Failed`/`Stopped`, or still `InProgress` +- **Pipeline** โ€” a stage of the latest execution is `Failed`/`Stopped`, or still `InProgress` (a pipeline waiting on a manual approval is not stuck, and never alerts) - **CloudFormation stack** โ€” any `*_IN_PROGRESS` or `*_FAILED` status - **ECS service** โ€” running task count does not match desired @@ -185,6 +186,9 @@ aws-green uses the standard AWS credential chain via `aws-sdk-go-v2`. Any of the - Environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, etc.) - IAM instance/task roles when running on AWS +Approving or rejecting from the dashboard (`a` / `x`) additionally needs +`codepipeline:PutApprovalResult` on the pipeline for that profile. + ## Keybindings ### Dashboard @@ -196,6 +200,7 @@ aws-green uses the standard AWS credential chain via `aws-sdk-go-v2`. Any of the | `enter` / `space` | Expand / collapse project row | | `r` | Force refresh | | `f` | Smart fix (restart pipeline / force deploy / continue rollback) | +| `a` / `x` | Approve / reject the selected pipeline's pending manual approval (โธ) | | `o` | Open pipeline in AWS Console | | `m` | Open project manager | | `q` | Quit | diff --git a/internal/aggregator/aggregator.go b/internal/aggregator/aggregator.go index 82e305a..7e628d0 100644 --- a/internal/aggregator/aggregator.go +++ b/internal/aggregator/aggregator.go @@ -7,7 +7,12 @@ const ( StoplightGrey Stoplight = iota // no executions, or superseded StoplightGreen // succeeded StoplightYellow // in progress - StoplightRed // failed or stopped + // StoplightAwaitingApproval is a pipeline paused at a manual approval. It + // outranks yellow because it needs a person, not more time. Aggregate never + // returns it: execution statuses can't tell a gate from a build, so + // state.FromData raises it from the action's approval token. + StoplightAwaitingApproval + StoplightRed // failed or stopped ) func (s Stoplight) String() string { @@ -18,6 +23,8 @@ func (s Stoplight) String() string { return "๐Ÿ”ด" case StoplightYellow: return "๐ŸŸก" + case StoplightAwaitingApproval: + return "โธ" default: return "โšช" } diff --git a/internal/aggregator/aggregator_test.go b/internal/aggregator/aggregator_test.go index dc8c995..c3b897f 100644 --- a/internal/aggregator/aggregator_test.go +++ b/internal/aggregator/aggregator_test.go @@ -57,3 +57,17 @@ func TestStoplight_String(t *testing.T) { } } } + +// A pipeline waiting on a person outranks one that is merely running, but a +// failure anywhere still outranks both. +func TestStoplightAwaitingApprovalOrdering(t *testing.T) { + if aggregator.StoplightAwaitingApproval <= aggregator.StoplightYellow { + t.Error("awaiting approval should outrank yellow") + } + if aggregator.StoplightRed <= aggregator.StoplightAwaitingApproval { + t.Error("red should outrank awaiting approval") + } + if got := aggregator.StoplightAwaitingApproval.String(); got != "โธ" { + t.Errorf("String() = %q, want โธ", got) + } +} diff --git a/internal/aws/approval_test.go b/internal/aws/approval_test.go new file mode 100644 index 0000000..b85f348 --- /dev/null +++ b/internal/aws/approval_test.go @@ -0,0 +1,75 @@ +package aws_test + +import ( + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/codepipeline" + "github.com/aws/aws-sdk-go-v2/service/codepipeline/types" + "github.com/ericdahl-dev/aws-green/internal/aggregator" + awsclient "github.com/ericdahl-dev/aws-green/internal/aws" +) + +// GetPipelineState only fills in an action's token while an approval request +// is open. Anything else (a finished approval, a build action) has none. +func TestPipelineDataFromState_capturesOpenApprovalToken(t *testing.T) { + out := &codepipeline.GetPipelineStateOutput{ + StageStates: []types.StageState{ + { + StageName: aws.String("Source"), + LatestExecution: &types.StageExecution{Status: types.StageExecutionStatusSucceeded}, + ActionStates: []types.ActionState{{ + ActionName: aws.String("Checkout"), + LatestExecution: &types.ActionExecution{Status: types.ActionExecutionStatusSucceeded}, + }}, + }, + { + StageName: aws.String("Test"), + LatestExecution: &types.StageExecution{Status: types.StageExecutionStatusInProgress}, + ActionStates: []types.ActionState{{ + ActionName: aws.String("Approve"), + LatestExecution: &types.ActionExecution{ + Status: types.ActionExecutionStatusInProgress, + Token: aws.String("tok-123"), + }, + }}, + }, + }, + } + + d := awsclient.PipelineDataFromState("my-pipeline", "us-east-1", out) + + if got := d.Stages[0].Actions[0].ApprovalToken; got != "" { + t.Errorf("non-approval action token = %q, want empty", got) + } + a := d.Stages[1].Actions[0] + if a.ApprovalToken != "tok-123" { + t.Errorf("approval token = %q, want tok-123", a.ApprovalToken) + } + if a.Status != aggregator.StatusInProgress { + t.Errorf("approval status = %q, want InProgress", a.Status) + } +} + +// A token on an action that is no longer in progress is a decided approval; +// acting on it would only fail. +func TestPipelineDataFromState_ignoresTokenOnFinishedAction(t *testing.T) { + out := &codepipeline.GetPipelineStateOutput{ + StageStates: []types.StageState{{ + StageName: aws.String("Test"), + LatestExecution: &types.StageExecution{Status: types.StageExecutionStatusSucceeded}, + ActionStates: []types.ActionState{{ + ActionName: aws.String("Approve"), + LatestExecution: &types.ActionExecution{ + Status: types.ActionExecutionStatusSucceeded, + Token: aws.String("old-tok"), + }, + }}, + }}, + } + + d := awsclient.PipelineDataFromState("my-pipeline", "us-east-1", out) + if got := d.Stages[0].Actions[0].ApprovalToken; got != "" { + t.Errorf("finished approval token = %q, want empty", got) + } +} diff --git a/internal/aws/client.go b/internal/aws/client.go index d553e02..d7bb2dd 100644 --- a/internal/aws/client.go +++ b/internal/aws/client.go @@ -16,6 +16,10 @@ import ( type ActionData struct { Name string Status aggregator.ExecutionStatus + // ApprovalToken is set only while this is a manual approval waiting on a + // decision. PutApprovalResult needs it, and it goes stale once anyone + // approves, rejects, or the request times out. + ApprovalToken string } // StageState holds the current status of a single Pipeline stage. @@ -71,9 +75,14 @@ func (c *Client) FetchPipeline(ctx context.Context, name string) (PipelineData, return PipelineData{}, fmt.Errorf("GetPipelineState(%q): %w", name, err) } + return PipelineDataFromState(name, c.region, out), nil +} + +// PipelineDataFromState converts a GetPipelineState response into PipelineData. +func PipelineDataFromState(name, region string, out *codepipeline.GetPipelineStateOutput) PipelineData { data := PipelineData{ Name: name, - ConsoleURL: consoleURL(c.region, name), + ConsoleURL: consoleURL(region, name), } for _, stage := range out.StageStates { @@ -86,6 +95,9 @@ func (c *Client) FetchPipeline(ctx context.Context, name string) (PipelineData, ad := ActionData{Name: aws.ToString(action.ActionName)} if action.LatestExecution != nil { ad.Status = mapActionStatus(action.LatestExecution.Status) + if ad.Status == aggregator.StatusInProgress { + ad.ApprovalToken = aws.ToString(action.LatestExecution.Token) + } if action.LatestExecution.LastStatusChange != nil { t := *action.LatestExecution.LastStatusChange if ss.StartedAt == nil || t.Before(*ss.StartedAt) { @@ -103,7 +115,7 @@ func (c *Client) FetchPipeline(ctx context.Context, name string) (PipelineData, data.Stages = append(data.Stages, ss) } - return data, nil + return data } func mapActionStatus(s types.ActionExecutionStatus) aggregator.ExecutionStatus { diff --git a/internal/fix/actioner_aws.go b/internal/fix/actioner_aws.go index fa30193..0b7cba6 100644 --- a/internal/fix/actioner_aws.go +++ b/internal/fix/actioner_aws.go @@ -2,12 +2,14 @@ package fix import ( "context" + "errors" "fmt" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/cloudformation" "github.com/aws/aws-sdk-go-v2/service/codepipeline" + cptypes "github.com/aws/aws-sdk-go-v2/service/codepipeline/types" awsecs "github.com/aws/aws-sdk-go-v2/service/ecs" ) @@ -66,3 +68,35 @@ func (a *AWSActioner) ForceDeployECS(ctx context.Context, cluster, service strin }) return err } + +func (a *AWSActioner) PutApprovalResult(ctx context.Context, pipeline, stage, action, token string, approved bool, summary string) error { + status := cptypes.ApprovalStatusApproved + if !approved { + status = cptypes.ApprovalStatusRejected + } + _, err := a.pipeline.PutApprovalResult(ctx, &codepipeline.PutApprovalResultInput{ + PipelineName: aws.String(pipeline), + StageName: aws.String(stage), + ActionName: aws.String(action), + Token: aws.String(token), + Result: &cptypes.ApprovalResult{ + Status: status, + Summary: aws.String(summary), + }, + }) + return approvalError(err) +} + +// approvalError folds AWS's two "someone already decided" errors into +// ErrApprovalAlreadyDecided so the UI can tell a stale token from a failure. +func approvalError(err error) error { + if err == nil { + return nil + } + var invalid *cptypes.InvalidApprovalTokenException + var completed *cptypes.ApprovalAlreadyCompletedException + if errors.As(err, &invalid) || errors.As(err, &completed) { + return fmt.Errorf("%w: %v", ErrApprovalAlreadyDecided, err) + } + return err +} diff --git a/internal/fix/actioner_aws_internal_test.go b/internal/fix/actioner_aws_internal_test.go new file mode 100644 index 0000000..665fd3d --- /dev/null +++ b/internal/fix/actioner_aws_internal_test.go @@ -0,0 +1,28 @@ +package fix + +import ( + "errors" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/codepipeline/types" +) + +// The token is from the last poll, so someone may have decided in the console +// since. AWS reports that two ways; both mean "already decided". +func TestApprovalError_mapsStaleTokenErrors(t *testing.T) { + for _, err := range []error{ + &types.InvalidApprovalTokenException{}, + &types.ApprovalAlreadyCompletedException{}, + } { + if got := approvalError(err); !errors.Is(got, ErrApprovalAlreadyDecided) { + t.Errorf("approvalError(%T) = %v, want ErrApprovalAlreadyDecided", err, got) + } + } + other := errors.New("AccessDenied") + if got := approvalError(other); !errors.Is(got, other) || errors.Is(got, ErrApprovalAlreadyDecided) { + t.Errorf("approvalError(other) = %v, want it passed through", got) + } + if approvalError(nil) != nil { + t.Error("approvalError(nil) should be nil") + } +} diff --git a/internal/fix/approval_test.go b/internal/fix/approval_test.go new file mode 100644 index 0000000..99e3181 --- /dev/null +++ b/internal/fix/approval_test.go @@ -0,0 +1,117 @@ +package fix_test + +import ( + "context" + "errors" + "testing" + + "github.com/ericdahl-dev/aws-green/internal/aggregator" + "github.com/ericdahl-dev/aws-green/internal/fix" + "github.com/ericdahl-dev/aws-green/internal/state" +) + +func approvalProject(token string) state.ProjectState { + return state.ProjectState{ + Name: "my-app", + Profile: "prod-profile", + Region: "us-east-1", + Pipeline: state.PipelineState{ + Name: "my-pipeline", + Stoplight: aggregator.StoplightAwaitingApproval, + Stages: []state.StageState{ + {Name: "Source", Status: aggregator.StatusSucceeded}, + {Name: "Test", Status: aggregator.StatusInProgress, Actions: []state.ActionState{ + {Name: "Approve", Status: aggregator.StatusInProgress, ApprovalToken: token}, + }}, + }, + }, + } +} + +func TestPlanApproval_nilWithoutPendingApproval(t *testing.T) { + if plan := fix.PlanApproval(approvalProject(""), true); plan != nil { + t.Errorf("expected nil plan, got %+v", plan) + } +} + +func TestPlanApproval_approve(t *testing.T) { + plan := fix.PlanApproval(approvalProject("tok-123"), true) + if plan == nil { + t.Fatal("expected a plan") + } + if plan.Kind != fix.KindApprove { + t.Errorf("kind = %v, want approve", plan.Kind) + } + if plan.PipelineName != "my-pipeline" || plan.StageName != "Test" || plan.ActionName != "Approve" || plan.ApprovalToken != "tok-123" { + t.Errorf("plan = %+v", plan) + } + if plan.Profile != "prod-profile" || plan.Region != "us-east-1" { + t.Errorf("credentials not carried: %+v", plan) + } + if plan.Description == "" { + t.Error("expected a description") + } +} + +func TestPlanApproval_reject(t *testing.T) { + plan := fix.PlanApproval(approvalProject("tok-123"), false) + if plan == nil || plan.Kind != fix.KindReject { + t.Fatalf("expected reject plan, got %+v", plan) + } +} + +// Approvals are driven by their own keys; the smart fix must not approve +// anything on the user's behalf. +func TestPlan_doesNotApprove(t *testing.T) { + if plan := fix.Plan(approvalProject("tok-123")); plan != nil { + t.Errorf("expected no smart fix for a pending approval, got %+v", plan) + } +} + +type fakeActioner struct { + pipeline, stage, action, token string + approved bool + summary string + err error +} + +func (f *fakeActioner) RestartPipeline(context.Context, string) error { return nil } +func (f *fakeActioner) ContinueRollback(context.Context, string) error { return nil } +func (f *fakeActioner) CancelStackUpdate(context.Context, string) error { return nil } +func (f *fakeActioner) ForceDeployECS(context.Context, string, string) error { + return nil +} +func (f *fakeActioner) PutApprovalResult(_ context.Context, pipeline, stage, action, token string, approved bool, summary string) error { + f.pipeline, f.stage, f.action, f.token, f.approved, f.summary = pipeline, stage, action, token, approved, summary + return f.err +} + +func TestExecute_approvalPassesIdentityAndDefaultSummary(t *testing.T) { + for _, tc := range []struct { + approve bool + summary string + }{ + {true, "Approved via aws-green"}, + {false, "Rejected via aws-green"}, + } { + a := &fakeActioner{} + plan := fix.PlanApproval(approvalProject("tok-123"), tc.approve) + if err := fix.Execute(context.Background(), plan, a); err != nil { + t.Fatalf("execute: %v", err) + } + if a.pipeline != "my-pipeline" || a.stage != "Test" || a.action != "Approve" || a.token != "tok-123" { + t.Errorf("wrong identity sent: %+v", a) + } + if a.approved != tc.approve || a.summary != tc.summary { + t.Errorf("approved=%v summary=%q, want %v %q", a.approved, a.summary, tc.approve, tc.summary) + } + } +} + +func TestExecute_approvalAlreadyDecidedPassesThrough(t *testing.T) { + a := &fakeActioner{err: fix.ErrApprovalAlreadyDecided} + err := fix.Execute(context.Background(), fix.PlanApproval(approvalProject("tok"), true), a) + if !errors.Is(err, fix.ErrApprovalAlreadyDecided) { + t.Errorf("err = %v, want ErrApprovalAlreadyDecided", err) + } +} diff --git a/internal/fix/fix.go b/internal/fix/fix.go index 1e9fe1b..dee3bd6 100644 --- a/internal/fix/fix.go +++ b/internal/fix/fix.go @@ -2,6 +2,7 @@ package fix import ( "context" + "errors" "fmt" "strings" "time" @@ -18,8 +19,15 @@ const ( KindContinueRollback // ContinueUpdateRollback on a stack KindCancelStackUpdate // CancelUpdateStack on a stuck stack KindForceDeployECS // UpdateService forceNewDeployment=true + KindApprove // PutApprovalResult Approved + KindReject // PutApprovalResult Rejected ) +// ErrApprovalAlreadyDecided means the approval token went stale between the +// last poll and the decision: someone approved or rejected in the console, or +// the request timed out. It is not a failure of ours. +var ErrApprovalAlreadyDecided = errors.New("approval already decided") + func (k Kind) String() string { switch k { case KindRestartPipeline: @@ -30,6 +38,10 @@ func (k Kind) String() string { return "cancel stack update" case KindForceDeployECS: return "force ECS deployment" + case KindApprove: + return "approve" + case KindReject: + return "reject" default: return "unknown" } @@ -47,6 +59,11 @@ type FixPlan struct { // Pipeline fix PipelineName string + // Approval โ€” stage, action, and the token from the last poll + StageName string + ActionName string + ApprovalToken string + // Stack fix StackName string @@ -115,6 +132,30 @@ func planPipeline(p state.PipelineState) *FixPlan { } } +// PlanApproval returns a plan to approve (or reject) the pipeline's pending +// manual approval, or nil if none is waiting. It is separate from Plan because +// deciding an approval is the user's call, never a "smart fix". +func PlanApproval(proj state.ProjectState, approve bool) *FixPlan { + pa := proj.Pipeline.PendingApproval() + if pa == nil { + return nil + } + kind, verb := KindApprove, "approve" + if !approve { + kind, verb = KindReject, "reject" + } + return &FixPlan{ + Kind: kind, + Description: fmt.Sprintf("%s %s / %s / %s", verb, proj.Pipeline.Name, pa.StageName, pa.ActionName), + Profile: proj.Profile, + Region: proj.Region, + PipelineName: proj.Pipeline.Name, + StageName: pa.StageName, + ActionName: pa.ActionName, + ApprovalToken: pa.Token, + } +} + const stalledStackThreshold = 30 * time.Minute func planStack(s state.StackState) *FixPlan { @@ -163,6 +204,7 @@ type Actioner interface { ContinueRollback(ctx context.Context, stackName string) error CancelStackUpdate(ctx context.Context, stackName string) error ForceDeployECS(ctx context.Context, cluster, service string) error + PutApprovalResult(ctx context.Context, pipeline, stage, action, token string, approved bool, summary string) error } // Execute runs the plan using the provided Actioner. @@ -176,6 +218,10 @@ func Execute(ctx context.Context, plan *FixPlan, a Actioner) error { return a.CancelStackUpdate(ctx, plan.StackName) case KindForceDeployECS: return a.ForceDeployECS(ctx, plan.ECSCluster, plan.ECSService) + case KindApprove: + return a.PutApprovalResult(ctx, plan.PipelineName, plan.StageName, plan.ActionName, plan.ApprovalToken, true, "Approved via aws-green") + case KindReject: + return a.PutApprovalResult(ctx, plan.PipelineName, plan.StageName, plan.ActionName, plan.ApprovalToken, false, "Rejected via aws-green") default: return fmt.Errorf("unknown fix kind: %v", plan.Kind) } diff --git a/internal/poller/poller.go b/internal/poller/poller.go index 0a112e7..9b2be6a 100644 --- a/internal/poller/poller.go +++ b/internal/poller/poller.go @@ -607,6 +607,11 @@ func pipelineStuckReason(ps state.PipelineState) (bool, string, string, string) if ps.Err != nil || ps.Name == "" { return false, "", "", "" } + // Waiting on a person is not a wedged deploy, and the dashboard already + // flags it as awaiting approval. + if ps.PendingApproval() != nil { + return false, "", "", "" + } // A failure anywhere outranks a still-running stage. for _, st := range ps.Stages { if st.Status == aggregator.StatusFailed || st.Status == aggregator.StatusStopped { diff --git a/internal/poller/stuck_test.go b/internal/poller/stuck_test.go index 4c24eb0..ea458f4 100644 --- a/internal/poller/stuck_test.go +++ b/internal/poller/stuck_test.go @@ -502,3 +502,20 @@ func TestStuckSkipsStacksAndServicesWithFailedFetches(t *testing.T) { } } } + +// Waiting on a person is not a wedged deploy; the dashboard already shows it. +func TestStuckSkipsPipelineAwaitingApproval(t *testing.T) { + clock := newStuckClock() + p := newStuckPoller(t, stuckConfig(30), clock) + proj := stuckPipelineProject("my-app", "prod", aggregator.StatusInProgress) + proj.Pipeline.Stages[1].Actions = []state.ActionState{ + {Name: "Approve", Status: aggregator.StatusInProgress, ApprovalToken: "tok"}, + } + projects := []state.ProjectState{proj} + + p.evaluateStuck(projects) + clock.advance(2 * time.Hour) + if events := p.evaluateStuck(projects); len(events) != 0 { + t.Errorf("expected no alert while awaiting approval, got %v", stuckReasons(events)) + } +} diff --git a/internal/state/state.go b/internal/state/state.go index c0f9cb6..b0f55d0 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -11,8 +11,23 @@ import ( // ActionState holds display state for a single pipeline action. type ActionState struct { - Name string - Status aggregator.ExecutionStatus + Name string + Status aggregator.ExecutionStatus + ApprovalToken string // non-empty only while a manual approval is waiting +} + +// AwaitingApproval reports whether this action is a manual approval waiting +// on a decision. +func (a ActionState) AwaitingApproval() bool { + return a.ApprovalToken != "" +} + +// PendingApproval identifies an open approval request: everything +// PutApprovalResult needs besides the pipeline name and the decision. +type PendingApproval struct { + StageName string + ActionName string + Token string } // StageState holds display state for a single Pipeline stage. @@ -45,13 +60,25 @@ func (p PipelineState) IsStale() bool { return p.StaleAt != nil } +// PendingApproval returns the first approval waiting on a decision, or nil. +func (p PipelineState) PendingApproval() *PendingApproval { + for _, s := range p.Stages { + for _, a := range s.Actions { + if a.AwaitingApproval() { + return &PendingApproval{StageName: s.Name, ActionName: a.Name, Token: a.ApprovalToken} + } + } + } + return nil +} + // FromData converts a PipelineData fetch result into a PipelineState. func FromData(account string, d awsclient.PipelineData) PipelineState { stages := make([]StageState, len(d.Stages)) for i, s := range d.Stages { actions := make([]ActionState, len(s.Actions)) for j, a := range s.Actions { - actions[j] = ActionState{Name: a.Name, Status: a.Status} + actions[j] = ActionState{Name: a.Name, Status: a.Status, ApprovalToken: a.ApprovalToken} } stages[i] = StageState{Name: s.Name, Status: s.Status, StartedAt: s.StartedAt, EndedAt: s.EndedAt, Actions: actions} } @@ -61,12 +88,16 @@ func FromData(account string, d awsclient.PipelineData) PipelineState { statuses[i] = s.Status } - return PipelineState{ + ps := PipelineState{ Account: account, Name: d.Name, Stoplight: aggregator.Aggregate(statuses), Stages: stages, } + if ps.PendingApproval() != nil && ps.Stoplight < aggregator.StoplightAwaitingApproval { + ps.Stoplight = aggregator.StoplightAwaitingApproval + } + return ps } // FetchStatus records the outcome of a resource fetch. Without it a failed diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 12c16c6..561981d 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/ericdahl-dev/aws-green/internal/aggregator" + awsclient "github.com/ericdahl-dev/aws-green/internal/aws" "github.com/ericdahl-dev/aws-green/internal/state" ) @@ -84,3 +85,44 @@ func TestProjectState_stoplightAllGreen(t *testing.T) { t.Errorf("expected green, got %v", proj.Stoplight()) } } + +func approvalData(token string) awsclient.PipelineData { + return awsclient.PipelineData{ + Name: "my-pipeline", + Stages: []awsclient.StageState{ + {Name: "Source", Status: aggregator.StatusSucceeded, Actions: []awsclient.ActionData{ + {Name: "Checkout", Status: aggregator.StatusSucceeded}, + }}, + {Name: "Test", Status: aggregator.StatusInProgress, Actions: []awsclient.ActionData{ + {Name: "Approve", Status: aggregator.StatusInProgress, ApprovalToken: token}, + }}, + }, + } +} + +func TestFromData_pendingApprovalSetsStoplight(t *testing.T) { + ps := state.FromData("prod", approvalData("tok-123")) + if ps.Stoplight != aggregator.StoplightAwaitingApproval { + t.Errorf("stoplight = %v, want awaiting approval", ps.Stoplight) + } + pa := ps.PendingApproval() + if pa == nil { + t.Fatal("expected a pending approval") + } + if pa.StageName != "Test" || pa.ActionName != "Approve" || pa.Token != "tok-123" { + t.Errorf("pending approval = %+v", *pa) + } + if !ps.Stages[1].Actions[0].AwaitingApproval() { + t.Error("expected the action to report AwaitingApproval") + } +} + +func TestFromData_noTokenIsPlainInProgress(t *testing.T) { + ps := state.FromData("prod", approvalData("")) + if ps.Stoplight != aggregator.StoplightYellow { + t.Errorf("stoplight = %v, want yellow", ps.Stoplight) + } + if ps.PendingApproval() != nil { + t.Error("expected no pending approval") + } +} diff --git a/internal/ui/approval_test.go b/internal/ui/approval_test.go new file mode 100644 index 0000000..aa4aa74 --- /dev/null +++ b/internal/ui/approval_test.go @@ -0,0 +1,78 @@ +package ui + +import ( + "context" + "fmt" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/ericdahl-dev/aws-green/internal/aggregator" + "github.com/ericdahl-dev/aws-green/internal/fix" + "github.com/ericdahl-dev/aws-green/internal/state" +) + +func approvalSnapshot(token string) state.Snapshot { + return state.NewFromProjects([]state.ProjectState{{ + Name: "annex-ims", + Account: "libnd", + Pipeline: state.PipelineState{ + Account: "libnd", + Name: "annex-pipeline", + Stoplight: aggregator.StoplightAwaitingApproval, + Stages: []state.StageState{ + {Name: "Test", Status: aggregator.StatusInProgress, Actions: []state.ActionState{ + {Name: "Approve", Status: aggregator.StatusInProgress, ApprovalToken: token}, + }}, + }, + }, + }}) +} + +func key(s string) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} } + +func TestApproveKeyStartsConfirm(t *testing.T) { + for _, tc := range []struct { + key string + want fix.Kind + }{{"a", fix.KindApprove}, {"x", fix.KindReject}} { + d := NewDashboard(approvalSnapshot("tok"), nil, context.Background()) + d, _ = d.Update(key(tc.key)) + if d.fixStatus != fixConfirming || d.fixPlan == nil || d.fixPlan.Kind != tc.want { + t.Errorf("%q: status=%v plan=%+v, want confirming %v", tc.key, d.fixStatus, d.fixPlan, tc.want) + } + } +} + +func TestApproveKeyIgnoredWithoutPendingApproval(t *testing.T) { + d := NewDashboard(approvalSnapshot(""), nil, context.Background()) + d, _ = d.Update(key("a")) + if d.fixStatus != fixIdle { + t.Errorf("status = %v, want idle", d.fixStatus) + } +} + +// A stale token means someone got there first. That is not a failure; say so +// and re-poll so the dashboard catches up. +func TestApprovalAlreadyDecidedIsNotAFailure(t *testing.T) { + d := NewDashboard(approvalSnapshot("tok"), nil, context.Background()) + d, _ = d.Update(key("a")) + d, cmd := d.Update(fixDoneMsg{err: fmt.Errorf("put: %w", fix.ErrApprovalAlreadyDecided)}) + if d.fixErr { + t.Error("already-decided should not render as an error") + } + if !strings.Contains(d.fixResultMsg, "already decided") { + t.Errorf("result = %q, want it to say already decided", d.fixResultMsg) + } + if cmd == nil { + t.Fatal("expected a re-poll command") + } +} + +func TestStageRowMarksAwaitingApproval(t *testing.T) { + d := NewDashboard(approvalSnapshot("tok"), nil, context.Background()) + proj := d.snapshot.Projects[0] + if out := d.renderStages(proj, d.buildNavList(), -1); !strings.Contains(out, "โธ") { + t.Errorf("expected โธ on the waiting stage, got %q", out) + } +} diff --git a/internal/ui/dashboard.go b/internal/ui/dashboard.go index b341a41..9eac65b 100644 --- a/internal/ui/dashboard.go +++ b/internal/ui/dashboard.go @@ -95,14 +95,16 @@ func NewDashboard(snap state.Snapshot, actionerFactory ActionerFactory, ctx cont func stoplightPriority(s aggregator.Stoplight) int { switch s { - case aggregator.StoplightYellow: + case aggregator.StoplightAwaitingApproval: return 0 - case aggregator.StoplightRed: + case aggregator.StoplightYellow: return 1 - case aggregator.StoplightGreen: + case aggregator.StoplightRed: return 2 - default: + case aggregator.StoplightGreen: return 3 + default: + return 4 } } @@ -247,6 +249,14 @@ func (d Dashboard) Update(msg tea.Msg) (Dashboard, tea.Cmd) { } } return d, selectionTimeoutCmd() + case "a", "x": + if proj := d.selectedProject(); proj != nil { + if plan := fix.PlanApproval(*proj, msg.String() == "a"); plan != nil { + d.fixStatus = fixConfirming + d.fixPlan = plan + } + } + return d, selectionTimeoutCmd() } return d, selectionTimeoutCmd() @@ -259,6 +269,14 @@ func (d Dashboard) Update(msg tea.Msg) (Dashboard, tea.Cmd) { return d, timerTickCmd() case fixDoneMsg: + // Someone decided in the console since the last poll. Not a failure: + // say so and re-poll so the row catches up. + if errors.Is(msg.err, fix.ErrApprovalAlreadyDecided) { + d.fixStatus = fixShowResult + d.fixResultMsg = "approval already decided elsewhere โ€” refreshing" + d.fixErr = false + return d, tea.Batch(fixStatusExpiredCmd(), func() tea.Msg { return FixAppliedMsg{} }) + } if msg.err != nil { d.fixStatus = fixShowResult d.fixResultMsg = fmt.Sprintf("fix failed: %v", msg.err) @@ -357,7 +375,7 @@ func (d Dashboard) hintLine() string { } return successStyle.Render(d.fixResultMsg) default: - return hintStyle.Render("โ†‘/โ†“ navigate enter/space expand f fix o open r refresh m manage q quit ? help") + return hintStyle.Render("โ†‘/โ†“ navigate enter/space expand f fix a/x approve/reject o open r refresh m manage q quit ? help") } } @@ -605,11 +623,19 @@ func (d Dashboard) renderStages(proj state.ProjectState, navList []navItem, navC } timer := stageTimer(stage) + icon := stageStatusIcon(string(stage.Status)) + for _, a := range stage.Actions { + if a.AwaitingApproval() { + icon = iconApproval.Render("โธ") + timer = strings.TrimSpace(timer + " awaiting approval โ€” a approve / x reject") + break + } + } var stageLine string if timer != "" { - stageLine = fmt.Sprintf("%s%s%s %-20s %s", stageIndent, triangle, stageStatusIcon(string(stage.Status)), stage.Name, staleStyle.Render(timer)) + stageLine = fmt.Sprintf("%s%s%s %-20s %s", stageIndent, triangle, icon, stage.Name, staleStyle.Render(timer)) } else { - stageLine = fmt.Sprintf("%s%s%s %s", stageIndent, triangle, stageStatusIcon(string(stage.Status)), stage.Name) + stageLine = fmt.Sprintf("%s%s%s %s", stageIndent, triangle, icon, stage.Name) } if stageSelected { out += selectedStyle.Render(stageLine) + "\n" @@ -619,7 +645,11 @@ func (d Dashboard) renderStages(proj state.ProjectState, navList []navItem, navC if stageExp { for _, action := range stage.Actions { - out += fmt.Sprintf("%s %s %s\n", stageIndent, stageStatusIcon(string(action.Status)), hintStyle.Render(action.Name)) + icon := stageStatusIcon(string(action.Status)) + if action.AwaitingApproval() { + icon = iconApproval.Render("โธ") + } + out += fmt.Sprintf("%s %s %s\n", stageIndent, icon, hintStyle.Render(action.Name)) } } } diff --git a/internal/ui/help.go b/internal/ui/help.go index b9a506d..aec196e 100644 --- a/internal/ui/help.go +++ b/internal/ui/help.go @@ -21,6 +21,7 @@ const helpMarkdown = `# aws-green ยท keybindings | **โ†“** / **j** | Move down | | **enter** / **space** | Expand or collapse project | | **f** | Smart fix (restart / force deploy / rollback) | +| **a** / **x** | Approve / reject a pending manual approval (โธ) | | **o** | Open pipeline in AWS Console | | **r** | Force refresh | | **m** | Manage projects (add / edit / delete / enable) | @@ -59,6 +60,7 @@ func fallbackHelpText() string { โ†“ / j move down enter / space expand/collapse project f smart fix (restart / force deploy / rollback) + a / x approve / reject a pending manual approval (โธ) o open pipeline in AWS Console r force refresh m manage projects (add / edit / delete / enable) diff --git a/internal/ui/icons.go b/internal/ui/icons.go index 76085e4..ea71bdb 100644 --- a/internal/ui/icons.go +++ b/internal/ui/icons.go @@ -7,6 +7,8 @@ var ( iconRed = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) iconYellow = lipgloss.NewStyle().Foreground(lipgloss.Color("11")) iconFaint = lipgloss.NewStyle().Faint(true) + // Magenta, so a gate waiting on a person doesn't read as "still running". + iconApproval = lipgloss.NewStyle().Foreground(lipgloss.Color("13")) ) func stageStatusIcon(status string) string { From 251edc52685adb69e9535384fdf3ab534c7722c0 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 22 Sep 2026 11:53:10 -0400 Subject: [PATCH 2/3] fix: a failed stage still alerts while an approval is pending --- internal/poller/poller.go | 10 +++++----- internal/poller/stuck_test.go | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/internal/poller/poller.go b/internal/poller/poller.go index 9b2be6a..19636a7 100644 --- a/internal/poller/poller.go +++ b/internal/poller/poller.go @@ -607,17 +607,17 @@ func pipelineStuckReason(ps state.PipelineState) (bool, string, string, string) if ps.Err != nil || ps.Name == "" { return false, "", "", "" } - // Waiting on a person is not a wedged deploy, and the dashboard already - // flags it as awaiting approval. - if ps.PendingApproval() != nil { - return false, "", "", "" - } // A failure anywhere outranks a still-running stage. for _, st := range ps.Stages { if st.Status == aggregator.StatusFailed || st.Status == aggregator.StatusStopped { return true, "pipeline_failed", string(st.Status), "stage " + st.Name } } + // Short of a failure, waiting on a person is not a wedged deploy, and the + // dashboard already flags it as awaiting approval. + if ps.PendingApproval() != nil { + return false, "", "", "" + } for _, st := range ps.Stages { if st.Status == aggregator.StatusInProgress { return true, "pipeline_in_progress", string(st.Status), "stage " + st.Name diff --git a/internal/poller/stuck_test.go b/internal/poller/stuck_test.go index ea458f4..ccf57eb 100644 --- a/internal/poller/stuck_test.go +++ b/internal/poller/stuck_test.go @@ -519,3 +519,23 @@ func TestStuckSkipsPipelineAwaitingApproval(t *testing.T) { t.Errorf("expected no alert while awaiting approval, got %v", stuckReasons(events)) } } + +// Stage statuses can come from different executions, so a newer run can fail +// while an older one still holds an open approval. The failure must alert. +func TestStuckFailureOutranksPendingApproval(t *testing.T) { + clock := newStuckClock() + p := newStuckPoller(t, stuckConfig(30), clock) + proj := stuckPipelineProject("my-app", "prod", aggregator.StatusInProgress) + proj.Pipeline.Stages[0].Status = aggregator.StatusFailed + proj.Pipeline.Stages[1].Actions = []state.ActionState{ + {Name: "Approve", Status: aggregator.StatusInProgress, ApprovalToken: "tok"}, + } + projects := []state.ProjectState{proj} + + p.evaluateStuck(projects) + clock.advance(31 * time.Minute) + events := p.evaluateStuck(projects) + if len(events) != 1 || events[0].Reason != "pipeline_failed" { + t.Errorf("expected pipeline_failed, got %v", stuckReasons(events)) + } +} From b5be59fb3835d5e5b6c634314a51bc698c6429b9 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 22 Sep 2026 11:56:31 -0400 Subject: [PATCH 3/3] feat: name the profile when it can't decide an approval --- internal/fix/actioner_aws.go | 8 +++++++- internal/fix/actioner_aws_internal_test.go | 15 +++++++++++++++ internal/fix/fix.go | 4 ++++ internal/ui/approval_test.go | 15 +++++++++++++++ internal/ui/dashboard.go | 10 ++++++++++ 5 files changed, 51 insertions(+), 1 deletion(-) diff --git a/internal/fix/actioner_aws.go b/internal/fix/actioner_aws.go index 0b7cba6..56216d2 100644 --- a/internal/fix/actioner_aws.go +++ b/internal/fix/actioner_aws.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" @@ -88,7 +89,8 @@ func (a *AWSActioner) PutApprovalResult(ctx context.Context, pipeline, stage, ac } // approvalError folds AWS's two "someone already decided" errors into -// ErrApprovalAlreadyDecided so the UI can tell a stale token from a failure. +// ErrApprovalAlreadyDecided so the UI can tell a stale token from a failure, +// and access denied into ErrApprovalNotPermitted so it can name the profile. func approvalError(err error) error { if err == nil { return nil @@ -98,5 +100,9 @@ func approvalError(err error) error { if errors.As(err, &invalid) || errors.As(err, &completed) { return fmt.Errorf("%w: %v", ErrApprovalAlreadyDecided, err) } + var apiErr interface{ ErrorCode() string } + if errors.As(err, &apiErr) && strings.HasPrefix(apiErr.ErrorCode(), "AccessDenied") { + return fmt.Errorf("%w: %v", ErrApprovalNotPermitted, err) + } return err } diff --git a/internal/fix/actioner_aws_internal_test.go b/internal/fix/actioner_aws_internal_test.go index 665fd3d..6b5275c 100644 --- a/internal/fix/actioner_aws_internal_test.go +++ b/internal/fix/actioner_aws_internal_test.go @@ -26,3 +26,18 @@ func TestApprovalError_mapsStaleTokenErrors(t *testing.T) { t.Error("approvalError(nil) should be nil") } } + +type codeErr string + +func (c codeErr) Error() string { return string(c) } +func (c codeErr) ErrorCode() string { return string(c) } + +// A read-only profile can see the approval but not decide it. That is a +// configuration fact the user can act on, not a mystery AWS error. +func TestApprovalError_mapsAccessDenied(t *testing.T) { + for _, code := range []string{"AccessDeniedException", "AccessDenied"} { + if got := approvalError(codeErr(code)); !errors.Is(got, ErrApprovalNotPermitted) { + t.Errorf("approvalError(%s) = %v, want ErrApprovalNotPermitted", code, got) + } + } +} diff --git a/internal/fix/fix.go b/internal/fix/fix.go index dee3bd6..7f1b217 100644 --- a/internal/fix/fix.go +++ b/internal/fix/fix.go @@ -28,6 +28,10 @@ const ( // the request timed out. It is not a failure of ours. var ErrApprovalAlreadyDecided = errors.New("approval already decided") +// ErrApprovalNotPermitted means the profile can see the approval but not +// decide it โ€” typically a read-only role. +var ErrApprovalNotPermitted = errors.New("profile not permitted to decide approvals") + func (k Kind) String() string { switch k { case KindRestartPipeline: diff --git a/internal/ui/approval_test.go b/internal/ui/approval_test.go index aa4aa74..2dee85f 100644 --- a/internal/ui/approval_test.go +++ b/internal/ui/approval_test.go @@ -76,3 +76,18 @@ func TestStageRowMarksAwaitingApproval(t *testing.T) { t.Errorf("expected โธ on the waiting stage, got %q", out) } } + +func TestApprovalNotPermittedNamesTheProfile(t *testing.T) { + d := NewDashboard(approvalSnapshot("tok"), nil, context.Background()) + d, _ = d.Update(key("a")) + d.fixPlan.Profile = "libnd-view" + d, _ = d.Update(fixDoneMsg{err: fmt.Errorf("put: %w", fix.ErrApprovalNotPermitted)}) + if !d.fixErr { + t.Error("expected an error result") + } + for _, want := range []string{"libnd-view", "codepipeline:PutApprovalResult"} { + if !strings.Contains(d.fixResultMsg, want) { + t.Errorf("result %q missing %q", d.fixResultMsg, want) + } + } +} diff --git a/internal/ui/dashboard.go b/internal/ui/dashboard.go index 9eac65b..aa9a08d 100644 --- a/internal/ui/dashboard.go +++ b/internal/ui/dashboard.go @@ -277,6 +277,16 @@ func (d Dashboard) Update(msg tea.Msg) (Dashboard, tea.Cmd) { d.fixErr = false return d, tea.Batch(fixStatusExpiredCmd(), func() tea.Msg { return FixAppliedMsg{} }) } + if errors.Is(msg.err, fix.ErrApprovalNotPermitted) { + d.fixStatus = fixShowResult + who := "these credentials" + if d.fixPlan.Profile != "" { + who = "profile " + d.fixPlan.Profile + } + d.fixResultMsg = fmt.Sprintf("%s can't %s โ€” it needs codepipeline:PutApprovalResult", who, d.fixPlan.Kind) + d.fixErr = true + return d, fixStatusExpiredCmd() + } if msg.err != nil { d.fixStatus = fixShowResult d.fixResultMsg = fmt.Sprintf("fix failed: %v", msg.err)