diff --git a/CONTEXT.md b/CONTEXT.md index 5f2b06e..161a2de 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -52,6 +52,11 @@ _Avoid_: hung, wedged (in prose) **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 +## Discovery + +Finding the stacks and ECS services that belong with a Pipeline when a project is added from the manage screen. The pipeline definition can't answer it — CDK pipelines deploy through CodeBuild, so no action names a stack — so discovery links them through the `ProjectName` tag the CDK blueprints put on the pipeline and every stack, minus the pipeline's own stack; ECS services come from those stacks' `AWS::ECS::Service` resources. A shared tag also matches sibling projects (`dec` covers honeycomb, beehive and honeypot), so the result is shown for the user to confirm, with the ones named after the project pre-selected. Nothing discovered is saved unconfirmed. +_Avoid_: auto-detect, import + ## Active-first sorting 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. diff --git a/README.md b/README.md index 1ee5a07..5c3c821 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ A terminal dashboard for live AWS resource health across multiple accounts and r - **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 +- **In-TUI project management** — add, edit, delete, and enable/disable projects without leaving the terminal; adding a pipeline finds its CloudFormation stacks and ECS services for you - **Interactive init** — `aws-green init` writes a starter config via a terminal form - **Single binary** — no runtime, no dependencies @@ -221,3 +221,5 @@ Approving or rejecting from the dashboard (`a` / `x`) additionally needs | `esc` | Back to dashboard | Changes are written to `config.toml` immediately and the poller reloads automatically. Disabled projects make no AWS API calls. + +When you add a project, or edit one that has a pipeline but no stacks or ECS services yet, aws-green looks up the stacks that share the pipeline's `ProjectName` tag (the tag CDK blueprints set) and the ECS services those stacks created. It lists them with the ones named after the project pre-selected: `space` toggles an item, `enter` saves the selection, and `esc` saves the project without them. A pipeline with no `ProjectName` tag is saved as-is, and you add its stacks and services to `config.toml` by hand. diff --git a/internal/discover/aws.go b/internal/discover/aws.go new file mode 100644 index 0000000..c96ebd5 --- /dev/null +++ b/internal/discover/aws.go @@ -0,0 +1,112 @@ +package discover + +import ( + "context" + "errors" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/cloudformation" + "github.com/aws/aws-sdk-go-v2/service/codepipeline" + "github.com/aws/smithy-go" + "github.com/ericdahl-dev/aws-green/internal/awscfg" +) + +// Run discovers pipeline's stacks and ECS services in the given profile and +// region. +func Run(ctx context.Context, profile, region, pipeline string) (Result, error) { + cfg, err := awscfg.Load(ctx, profile, region) + if err != nil { + return Result{}, err + } + return Discover(ctx, sdkAWS{ + pipelines: codepipeline.NewFromConfig(cfg), + stacks: cloudformation.NewFromConfig(cfg), + }, pipeline) +} + +// sdkAWS answers discovery's questions with the AWS SDK. Every call is +// read-only. +type sdkAWS struct { + pipelines *codepipeline.Client + stacks *cloudformation.Client +} + +func (a sdkAWS) PipelineTags(ctx context.Context, pipeline string) (map[string]string, error) { + out, err := a.pipelines.GetPipeline(ctx, &codepipeline.GetPipelineInput{Name: aws.String(pipeline)}) + if err != nil { + return nil, err + } + tags := map[string]string{} + pages := codepipeline.NewListTagsForResourcePaginator(a.pipelines, &codepipeline.ListTagsForResourceInput{ + ResourceArn: out.Metadata.PipelineArn, + }) + for pages.HasMorePages() { + page, err := pages.NextPage(ctx) + if err != nil { + return nil, err + } + for _, t := range page.Tags { + tags[aws.ToString(t.Key)] = aws.ToString(t.Value) + } + } + return tags, nil +} + +func (a sdkAWS) PipelineStack(ctx context.Context, pipeline string) (string, error) { + out, err := a.stacks.DescribeStackResources(ctx, &cloudformation.DescribeStackResourcesInput{ + PhysicalResourceId: aws.String(pipeline), + }) + if err != nil { + // A pipeline created outside CloudFormation has no stack; that is + // an answer, not a failure. + var apiErr smithy.APIError + if errors.As(err, &apiErr) && apiErr.ErrorCode() == "ValidationError" && + strings.Contains(apiErr.ErrorMessage(), "does not exist") { + return "", nil + } + return "", err + } + if len(out.StackResources) == 0 { + return "", nil + } + return aws.ToString(out.StackResources[0].StackName), nil +} + +func (a sdkAWS) Stacks(ctx context.Context) ([]Stack, error) { + var out []Stack + pages := cloudformation.NewDescribeStacksPaginator(a.stacks, &cloudformation.DescribeStacksInput{}) + for pages.HasMorePages() { + page, err := pages.NextPage(ctx) + if err != nil { + return nil, err + } + for _, s := range page.Stacks { + tags := make(map[string]string, len(s.Tags)) + for _, t := range s.Tags { + tags[aws.ToString(t.Key)] = aws.ToString(t.Value) + } + out = append(out, Stack{Name: aws.ToString(s.StackName), Tags: tags}) + } + } + return out, nil +} + +func (a sdkAWS) ECSServiceARNs(ctx context.Context, stack string) ([]string, error) { + var arns []string + pages := cloudformation.NewListStackResourcesPaginator(a.stacks, &cloudformation.ListStackResourcesInput{ + StackName: aws.String(stack), + }) + for pages.HasMorePages() { + page, err := pages.NextPage(ctx) + if err != nil { + return nil, err + } + for _, r := range page.StackResourceSummaries { + if aws.ToString(r.ResourceType) == "AWS::ECS::Service" && r.PhysicalResourceId != nil { + arns = append(arns, aws.ToString(r.PhysicalResourceId)) + } + } + } + return arns, nil +} diff --git a/internal/discover/discover.go b/internal/discover/discover.go new file mode 100644 index 0000000..7c1c9d1 --- /dev/null +++ b/internal/discover/discover.go @@ -0,0 +1,140 @@ +// Package discover finds the CloudFormation stacks and ECS services that +// belong with a Pipeline, so adding a project doesn't mean hand-editing them +// into the Config file. +// +// The pipeline definition can't answer this: CDK pipelines deploy through +// CodeBuild (`cdk deploy`), so no action names a stack. What links them is +// the ProjectName tag the CDK blueprints put on the pipeline and on every +// stack. That is a heuristic — a shared tag also matches shared stacks — so +// callers show the result for the user to confirm, never save it blindly. +package discover + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/ericdahl-dev/aws-green/internal/config" +) + +// ProjectTag is the tag that links a pipeline to its stacks. +const ProjectTag = "ProjectName" + +// Stack is an active CloudFormation stack and its tags. +type Stack struct { + Name string + Tags map[string]string +} + +// AWS is what discovery needs to ask AWS. +type AWS interface { + // PipelineTags returns the pipeline's tags. + PipelineTags(ctx context.Context, pipeline string) (map[string]string, error) + // PipelineStack returns the stack that defines the pipeline, or "" if + // it wasn't created by CloudFormation. + PipelineStack(ctx context.Context, pipeline string) (string, error) + // Stacks returns every active stack in the account and region. + Stacks(ctx context.Context) ([]Stack, error) + // ECSServiceARNs returns the ARNs of the ECS services a stack created. + ECSServiceARNs(ctx context.Context, stack string) ([]string, error) +} + +// Result is what discovery found for one pipeline. +type Result struct { + Stacks []string + ECS []config.ECSConfig +} + +// Discover finds the stacks and ECS services that belong with pipeline. +func Discover(ctx context.Context, aws AWS, pipeline string) (Result, error) { + tags, err := aws.PipelineTags(ctx, pipeline) + if err != nil { + return Result{}, fmt.Errorf("pipeline tags: %w", err) + } + project := tags[ProjectTag] + if project == "" { + return Result{}, nil + } + own, err := aws.PipelineStack(ctx, pipeline) + if err != nil { + return Result{}, fmt.Errorf("pipeline stack: %w", err) + } + stacks, err := aws.Stacks(ctx) + if err != nil { + return Result{}, fmt.Errorf("list stacks: %w", err) + } + + var r Result + for _, s := range stacks { + if s.Name != own && s.Tags[ProjectTag] == project { + r.Stacks = append(r.Stacks, s.Name) + } + } + sort.Strings(r.Stacks) + + byCluster := map[string]int{} + for _, stack := range r.Stacks { + arns, err := aws.ECSServiceARNs(ctx, stack) + if err != nil { + return Result{}, fmt.Errorf("resources of %s: %w", stack, err) + } + for _, arn := range arns { + cluster, service, ok := parseServiceARN(arn) + if !ok { + continue + } + i, seen := byCluster[cluster] + if !seen { + i = len(r.ECS) + byCluster[cluster] = i + r.ECS = append(r.ECS, config.ECSConfig{Cluster: cluster}) + } + r.ECS[i].Services = append(r.ECS[i].Services, service) + } + } + return r, nil +} + +// parseServiceARN splits an ECS service ARN of the long form +// arn:aws:ecs:::service//. +func parseServiceARN(arn string) (cluster, service string, ok bool) { + _, resource, found := strings.Cut(arn, ":service/") + if !found { + return "", "", false + } + cluster, service, found = strings.Cut(resource, "/") + return cluster, service, found && cluster != "" && service != "" +} + +// Likely narrows the result to the stacks and services named after the +// project. A shared tag can also match sibling projects — ProjectName=dec +// covers honeycomb, beehive and honeypot alike — and the project's own +// resources are the ones carrying its name. When nothing carries it, the tag +// match is all there is to go on, so everything is kept. +func (r Result) Likely(project string) Result { + named := func(s string) bool { + return project != "" && strings.Contains(strings.ToLower(s), strings.ToLower(project)) + } + var out Result + for _, s := range r.Stacks { + if named(s) { + out.Stacks = append(out.Stacks, s) + } + } + for _, e := range r.ECS { + var services []string + for _, sv := range e.Services { + if named(sv) { + services = append(services, sv) + } + } + if len(services) > 0 { + out.ECS = append(out.ECS, config.ECSConfig{Cluster: e.Cluster, Services: services}) + } + } + if len(out.Stacks) == 0 && len(out.ECS) == 0 { + return r + } + return out +} diff --git a/internal/discover/discover_test.go b/internal/discover/discover_test.go new file mode 100644 index 0000000..3ce69fa --- /dev/null +++ b/internal/discover/discover_test.go @@ -0,0 +1,143 @@ +package discover_test + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/ericdahl-dev/aws-green/internal/config" + "github.com/ericdahl-dev/aws-green/internal/discover" +) + +// fakeAWS answers discovery's questions from canned data. +type fakeAWS struct { + pipelineTags map[string]string + pipelineStack string + stacks []discover.Stack + services map[string][]string // stack name → ECS service ARNs + err error +} + +func (f fakeAWS) PipelineTags(context.Context, string) (map[string]string, error) { + return f.pipelineTags, f.err +} + +func (f fakeAWS) PipelineStack(context.Context, string) (string, error) { + return f.pipelineStack, nil +} + +func (f fakeAWS) Stacks(context.Context) ([]discover.Stack, error) { + return f.stacks, nil +} + +func (f fakeAWS) ECSServiceARNs(_ context.Context, stack string) ([]string, error) { + return f.services[stack], nil +} + +func reservesAWS() fakeAWS { + reserves := map[string]string{"ProjectName": "Reserves"} + return fakeAWS{ + pipelineTags: reserves, + pipelineStack: "reserves-deployment", + stacks: []discover.Stack{ + {Name: "reserves-deployment", Tags: reserves}, + {Name: "reserves-service", Tags: reserves}, + {Name: "api-service", Tags: map[string]string{"ProjectName": "API"}}, + {Name: "reserves-cluster", Tags: reserves}, + {Name: "untagged"}, + {Name: "reserves-test-service", Tags: reserves}, + }, + services: map[string][]string{ + "reserves-service": {"arn:aws:ecs:us-east-1:123:service/reserves-cluster-A/reserves-app"}, + "reserves-test-service": {"arn:aws:ecs:us-east-1:123:service/reserves-test-cluster-B/reserves-test-app"}, + }, + } +} + +// A pipeline's stacks are the ones sharing its ProjectName tag, minus the +// stack that defines the pipeline itself. +func TestDiscoverFindsStacksByProjectTag(t *testing.T) { + got, err := discover.Discover(context.Background(), reservesAWS(), "reserves-pipeline") + if err != nil { + t.Fatal(err) + } + want := []string{"reserves-cluster", "reserves-service", "reserves-test-service"} + if !reflect.DeepEqual(got.Stacks, want) { + t.Errorf("Stacks = %v, want %v", got.Stacks, want) + } +} + +// Without a ProjectName tag there is nothing to link on — an untagged +// pipeline must not claim every untagged stack in the account. +func TestDiscoverUntaggedPipelineFindsNothing(t *testing.T) { + aws := reservesAWS() + aws.pipelineTags = nil + got, err := discover.Discover(context.Background(), aws, "reserves-pipeline") + if err != nil { + t.Fatal(err) + } + if len(got.Stacks) != 0 { + t.Errorf("Stacks = %v, want none", got.Stacks) + } +} + +// ECS services come from the discovered stacks' resources, grouped by the +// cluster named in each service ARN. +func TestDiscoverFindsECSServicesByCluster(t *testing.T) { + aws := reservesAWS() + aws.services["reserves-service"] = append(aws.services["reserves-service"], + "arn:aws:ecs:us-east-1:123:service/reserves-cluster-A/reserves-worker") + got, err := discover.Discover(context.Background(), aws, "reserves-pipeline") + if err != nil { + t.Fatal(err) + } + want := []config.ECSConfig{ + {Cluster: "reserves-cluster-A", Services: []string{"reserves-app", "reserves-worker"}}, + {Cluster: "reserves-test-cluster-B", Services: []string{"reserves-test-app"}}, + } + if !reflect.DeepEqual(got.ECS, want) { + t.Errorf("ECS = %+v, want %+v", got.ECS, want) + } +} + +// A failed call is reported, not mistaken for "nothing found". +func TestDiscoverReportsAWSErrors(t *testing.T) { + aws := reservesAWS() + aws.err = errors.New("AccessDenied") + if _, err := discover.Discover(context.Background(), aws, "reserves-pipeline"); err == nil { + t.Error("expected the AWS error to be returned") + } +} + +// A shared tag like ProjectName=dec also matches sibling projects, so the +// likely picks are the ones named after the project. +func TestLikelyKeepsWhatIsNamedAfterTheProject(t *testing.T) { + r := discover.Result{ + Stacks: []string{"dec-prod-beehive", "dec-prod-foundation", "dec-prod-honeycomb", "dec-test-honeycomb"}, + ECS: []config.ECSConfig{ + {Cluster: "dec-prod-foundation-Cluster", Services: []string{"dec-prod-honeycomb-App", "dec-prod-honeypot-App"}}, + {Cluster: "dec-other-Cluster", Services: []string{"dec-prod-beehive-App"}}, + }, + } + got := r.Likely("Honeycomb") + want := discover.Result{ + Stacks: []string{"dec-prod-honeycomb", "dec-test-honeycomb"}, + ECS: []config.ECSConfig{{Cluster: "dec-prod-foundation-Cluster", Services: []string{"dec-prod-honeycomb-App"}}}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("Likely = %+v, want %+v", got, want) + } +} + +// When nothing is named after the project, the tag match is all there is to +// go on, so everything stays likely. +func TestLikelyKeepsEverythingWhenNothingIsNamedAfterTheProject(t *testing.T) { + r := discover.Result{ + Stacks: []string{"svc-a", "svc-b"}, + ECS: []config.ECSConfig{{Cluster: "c", Services: []string{"app"}}}, + } + if got := r.Likely("reserves"); !reflect.DeepEqual(got, r) { + t.Errorf("Likely = %+v, want everything %+v", got, r) + } +} diff --git a/internal/ui/manage.go b/internal/ui/manage.go index 33d92eb..a5eee1f 100644 --- a/internal/ui/manage.go +++ b/internal/ui/manage.go @@ -1,15 +1,28 @@ package ui import ( + "context" "errors" "fmt" "strings" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "github.com/ericdahl-dev/aws-green/internal/config" + "github.com/ericdahl-dev/aws-green/internal/discover" ) +// Discoverer finds a pipeline's stacks and ECS services in an AWS profile and +// region; see discover.Run. +type Discoverer func(ctx context.Context, profile, region, pipeline string) (discover.Result, error) + +// discoveredMsg carries a finished discovery back to the manage screen. +type discoveredMsg struct { + result discover.Result + err error +} + // BackMsg is sent when the user exits the manage screen. type BackMsg struct{} @@ -24,6 +37,8 @@ const ( manageModeList manageMode = iota manageModeForm manageModeConfirmDelete + manageModeDiscovering + manageModeConfirmDiscovered ) // Manage is a Bubble Tea component for CRUD management of projects. @@ -35,6 +50,12 @@ type Manage struct { editIdx int // -1 = add, >=0 = edit index err string + discover Discoverer + // pending is the project from the form, held while its stacks and + // services are discovered and confirmed. + pending config.Project + found discover.Result + // fields is a pointer because Manage is copied on every Update, and the // huh form binds to these values by address. fields *manageFields @@ -44,10 +65,16 @@ type manageFields struct { name string account string pipeline string + // stacks and services are the discovered resources the user keeps; + // services are "cluster/service". + stacks []string + services []string } -func NewManage(cfg *config.Config) Manage { - return Manage{cfg: cfg, cursor: 0, editIdx: -1, fields: &manageFields{}} +// NewManage builds the manage screen. A nil discoverer skips looking up a new +// project's stacks and ECS services. +func NewManage(cfg *config.Config, discoverer Discoverer) Manage { + return Manage{cfg: cfg, cursor: 0, editIdx: -1, fields: &manageFields{}, discover: discoverer} } func (m Manage) Init() tea.Cmd { return nil } @@ -58,6 +85,10 @@ func (m Manage) Update(msg tea.Msg) (Manage, tea.Cmd) { return m.updateForm(msg) case manageModeConfirmDelete: return m.updateConfirm(msg) + case manageModeDiscovering: + return m.updateDiscovering(msg) + case manageModeConfirmDiscovered: + return m.updateConfirmDiscovered(msg) default: return m.updateList(msg) } @@ -137,23 +168,15 @@ func (m Manage) updateForm(msg tea.Msg) (Manage, tea.Cmd) { proj.Name = name proj.Account = account proj.Pipeline.Name = pipeline - if m.editIdx >= 0 { - if err := m.cfg.UpdateProject(m.editIdx, proj); err != nil { - m.err = err.Error() - } else { - m.err = "" - } - } else { - if err := m.cfg.AddProject(proj); err != nil { - m.err = err.Error() - } else { - m.err = "" - m.cursor = len(m.cfg.Projects) - 1 - } + // A project with a pipeline but nothing else to watch is worth a + // lookup; one whose stacks and services are already set is left alone. + if m.discover != nil && proj.Pipeline.Name != "" && len(proj.Stacks) == 0 && len(proj.ECS) == 0 { + m.pending = proj + m.mode = manageModeDiscovering + m.form = nil + return m, m.discoverCmd(proj) } - m.mode = manageModeList - m.form = nil - return m, configChangedCmd(m.cfg) + return m.save(proj) } if m.form.State == huh.StateAborted { @@ -164,6 +187,145 @@ func (m Manage) updateForm(msg tea.Msg) (Manage, tea.Cmd) { return m, cmd } +// save adds or updates proj, returns to the list and announces the change. +func (m Manage) save(proj config.Project) (Manage, tea.Cmd) { + var err error + if m.editIdx >= 0 { + err = m.cfg.UpdateProject(m.editIdx, proj) + } else if err = m.cfg.AddProject(proj); err == nil { + m.cursor = len(m.cfg.Projects) - 1 + } + m.err = "" + if err != nil { + m.err = err.Error() + } + m.mode = manageModeList + m.form = nil + return m, configChangedCmd(m.cfg) +} + +func (m Manage) discoverCmd(proj config.Project) tea.Cmd { + var profile, region string + if acct, ok := m.cfg.AccountFor(proj); ok { + profile, region = acct.Profile, acct.Region + } + discoverer := m.discover + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + r, err := discoverer(ctx, profile, region, proj.Pipeline.Name) + return discoveredMsg{result: r, err: err} + } +} + +func (m Manage) updateDiscovering(msg tea.Msg) (Manage, tea.Cmd) { + done, ok := msg.(discoveredMsg) + if !ok { + return m, nil + } + m.err = "" + switch { + case done.err != nil: + m, cmd := m.save(m.pending) + m.err = "couldn't look up stacks and ECS services: " + done.err.Error() + return m, cmd + case len(done.result.Stacks) == 0 && len(done.result.ECS) == 0: + m, cmd := m.save(m.pending) + m.err = fmt.Sprintf("no stacks or ECS services share a %s tag with this pipeline — add them to the config by hand", discover.ProjectTag) + return m, cmd + } + m.found = done.result + likely := done.result.Likely(m.pending.Name) + m.fields.stacks = likely.Stacks + m.fields.services = serviceKeys(likely.ECS) + m.form = m.buildDiscoveredForm() + m.mode = manageModeConfirmDiscovered + return m, m.form.Init() +} + +func (m Manage) updateConfirmDiscovered(msg tea.Msg) (Manage, tea.Cmd) { + proj := m.pending + if msg, ok := msg.(tea.KeyMsg); ok && msg.String() == "esc" { + return m.save(proj) + } + form, cmd := m.form.Update(msg) + if f, ok := form.(*huh.Form); ok { + m.form = f + } + switch m.form.State { + case huh.StateCompleted: + keep := map[string]bool{} + for _, k := range m.fields.stacks { + keep[k] = true + } + for _, s := range m.found.Stacks { + if keep[s] { + proj.Stacks = append(proj.Stacks, config.Stack{Name: s}) + } + } + keep = map[string]bool{} + for _, k := range m.fields.services { + keep[k] = true + } + for _, e := range m.found.ECS { + var services []string + for _, sv := range e.Services { + if keep[e.Cluster+"/"+sv] { + services = append(services, sv) + } + } + if len(services) > 0 { + proj.ECS = append(proj.ECS, config.ECSConfig{Cluster: e.Cluster, Services: services}) + } + } + return m.save(proj) + case huh.StateAborted: + return m.save(proj) + } + return m, cmd +} + +// serviceKeys flattens ECS config into "cluster/service" keys. +func serviceKeys(ecs []config.ECSConfig) []string { + var keys []string + for _, e := range ecs { + for _, sv := range e.Services { + keys = append(keys, e.Cluster+"/"+sv) + } + } + return keys +} + +// buildDiscoveredForm lists what discovery found, with the likely picks +// selected, for the user to confirm before any of it is saved. +func (m Manage) buildDiscoveredForm() *huh.Form { + var fields []huh.Field + if len(m.found.Stacks) > 0 { + opts := make([]huh.Option[string], len(m.found.Stacks)) + for i, s := range m.found.Stacks { + opts[i] = huh.NewOption(s, s) + } + fields = append(fields, huh.NewMultiSelect[string](). + Title("CloudFormation stacks"). + Description("Found by the pipeline's "+discover.ProjectTag+" tag. space toggles, enter confirms."). + Options(opts...). + Value(&m.fields.stacks)) + } + if keys := serviceKeys(m.found.ECS); len(keys) > 0 { + opts := make([]huh.Option[string], len(keys)) + for i, k := range keys { + cluster, service, _ := strings.Cut(k, "/") + opts[i] = huh.NewOption(service+" ("+cluster+")", k) + } + fields = append(fields, huh.NewMultiSelect[string](). + Title("ECS services"). + Description("From those stacks' resources. space toggles, enter confirms."). + Options(opts...). + Value(&m.fields.services)) + } + return huh.NewForm(huh.NewGroup(fields...).Title("Watch these for " + m.pending.Name + "?")) +} + func (m Manage) updateConfirm(msg tea.Msg) (Manage, tea.Cmd) { if msg, ok := msg.(tea.KeyMsg); ok { switch msg.String() { @@ -239,6 +401,12 @@ func (m Manage) View() string { if m.form != nil { return m.form.View() } + case manageModeDiscovering: + return fmt.Sprintf("\n Looking for stacks and ECS services that belong with %s…\n", m.pending.Pipeline.Name) + case manageModeConfirmDiscovered: + if m.form != nil { + return m.form.View() + } case manageModeConfirmDelete: if m.cursor < len(m.cfg.Projects) { p := m.cfg.Projects[m.cursor] diff --git a/internal/ui/manage_test.go b/internal/ui/manage_test.go index 91e3940..0599985 100644 --- a/internal/ui/manage_test.go +++ b/internal/ui/manage_test.go @@ -1,14 +1,18 @@ package ui import ( + "context" + "errors" "os" "path/filepath" "reflect" + "strings" "testing" "time" tea "github.com/charmbracelet/bubbletea" "github.com/ericdahl-dev/aws-green/internal/config" + "github.com/ericdahl-dev/aws-green/internal/discover" ) func loadManageConfig(t *testing.T) *config.Config { @@ -99,7 +103,7 @@ func enter(m Manage) Manage { return send(m, tea.KeyMsg{Type: tea.KeyEnter}) } func TestAddProjectSavesEnteredValues(t *testing.T) { cfg := loadManageConfig(t) - m := NewManage(cfg) + m := NewManage(cfg, nil) m = send(m, key("a")) m = enter(typeText(m, "annex-ims")) m = enter(typeText(m, "libnd")) @@ -128,7 +132,7 @@ func TestAddProjectSavesEnteredValues(t *testing.T) { func TestEditProjectSavesChangedValues(t *testing.T) { cfg := loadManageConfig(t) cfg.Projects[0].Stacks = []config.Stack{{Name: "honeycomb-stack"}} - m := NewManage(cfg) + m := NewManage(cfg, nil) m = send(m, key("e")) m = enter(typeText(m, "-prod")) m = enter(m) @@ -149,7 +153,7 @@ func TestEditProjectKeepsItDisabled(t *testing.T) { cfg := loadManageConfig(t) disabled := false cfg.Projects[0].Enabled = &disabled - m := NewManage(cfg) + m := NewManage(cfg, nil) m = send(m, key("e")) m = enter(typeText(m, "-prod")) m = enter(m) @@ -159,3 +163,109 @@ func TestEditProjectKeepsItDisabled(t *testing.T) { t.Errorf("edited project = %+v (enabled=%v), want honeycomb-prod still disabled", got, got.IsEnabled()) } } + +// fakeDiscoverer records what it was asked and answers with a canned result. +type fakeDiscoverer struct { + calls int + profile, region, pipeline string + result discover.Result + err error +} + +func (f *fakeDiscoverer) discover(_ context.Context, profile, region, pipeline string) (discover.Result, error) { + f.calls++ + f.profile, f.region, f.pipeline = profile, region, pipeline + return f.result, f.err +} + +func addReserves(m Manage) Manage { + m = send(m, key("a")) + m = enter(typeText(m, "reserves")) + m = enter(typeText(m, "libnd")) + return enter(typeText(m, "reserves-pipeline")) +} + +// Adding a project looks up its stacks and ECS services in its Account and +// saves the ones the user confirms. +func TestAddProjectSavesDiscoveredResources(t *testing.T) { + cfg := loadManageConfig(t) + d := &fakeDiscoverer{result: discover.Result{ + Stacks: []string{"reserves-cluster", "reserves-service"}, + ECS: []config.ECSConfig{{Cluster: "reserves-cluster-A", Services: []string{"reserves-app"}}}, + }} + m := addReserves(NewManage(cfg, d.discover)) + if m.mode != manageModeConfirmDiscovered { + t.Fatalf("mode = %v, want the discovery confirmation", m.mode) + } + m = enter(m) // stacks, as pre-selected + m = enter(m) // ECS services, as pre-selected + + if d.calls != 1 || d.profile != "libnd-view" || d.region != "us-east-1" || d.pipeline != "reserves-pipeline" { + t.Errorf("discovery asked for %+v, want libnd-view / us-east-1 / reserves-pipeline", d) + } + if m.mode != manageModeList { + t.Fatalf("mode = %v, want list after confirming", m.mode) + } + got := cfg.Projects[len(cfg.Projects)-1] + if !reflect.DeepEqual(got.Stacks, []config.Stack{{Name: "reserves-cluster"}, {Name: "reserves-service"}}) { + t.Errorf("Stacks = %+v", got.Stacks) + } + if !reflect.DeepEqual(got.ECS, d.result.ECS) { + t.Errorf("ECS = %+v, want %+v", got.ECS, d.result.ECS) + } +} + +// A shared tag also finds sibling projects; only the likely picks are +// pre-selected, so accepting the defaults leaves the siblings out. +func TestAddProjectPreselectsOnlyLikelyResources(t *testing.T) { + cfg := loadManageConfig(t) + d := &fakeDiscoverer{result: discover.Result{ + Stacks: []string{"dec-prod-beehive", "reserves-service"}, + ECS: []config.ECSConfig{{Cluster: "shared", Services: []string{"dec-beehive-app", "reserves-app"}}}, + }} + m := enter(enter(addReserves(NewManage(cfg, d.discover)))) + + got := cfg.Projects[len(cfg.Projects)-1] + if !reflect.DeepEqual(got.Stacks, []config.Stack{{Name: "reserves-service"}}) { + t.Errorf("Stacks = %+v, want only reserves-service", got.Stacks) + } + if want := []config.ECSConfig{{Cluster: "shared", Services: []string{"reserves-app"}}}; !reflect.DeepEqual(got.ECS, want) { + t.Errorf("ECS = %+v, want %+v", got.ECS, want) + } + _ = m +} + +// Discovery is a convenience: when it fails the project still saves, and the +// user is told why nothing was found. +func TestAddProjectSavesWhenDiscoveryFails(t *testing.T) { + cfg := loadManageConfig(t) + d := &fakeDiscoverer{err: errors.New("AccessDenied")} + m := addReserves(NewManage(cfg, d.discover)) + + if m.mode != manageModeList { + t.Fatalf("mode = %v, want list", m.mode) + } + if got := cfg.Projects[len(cfg.Projects)-1]; got.Name != "reserves" || len(got.Stacks) != 0 { + t.Errorf("saved %+v, want reserves with no stacks", got) + } + if !strings.Contains(m.err, "AccessDenied") { + t.Errorf("err = %q, want the discovery error shown", m.err) + } +} + +// Editing a project whose stacks and services are already configured leaves +// them alone — no lookup, no confirmation. +func TestEditProjectWithResourcesSkipsDiscovery(t *testing.T) { + cfg := loadManageConfig(t) + cfg.Projects[0].Stacks = []config.Stack{{Name: "honeycomb-stack"}} + d := &fakeDiscoverer{result: discover.Result{Stacks: []string{"other"}}} + m := send(NewManage(cfg, d.discover), key("e")) + m = enter(enter(enter(m))) + + if d.calls != 0 { + t.Errorf("discovery ran %d times, want 0", d.calls) + } + if m.mode != manageModeList { + t.Errorf("mode = %v, want list", m.mode) + } +} diff --git a/main.go b/main.go index 313e83c..dd71124 100644 --- a/main.go +++ b/main.go @@ -17,6 +17,7 @@ import ( awsclient "github.com/ericdahl-dev/aws-green/internal/aws" "github.com/ericdahl-dev/aws-green/internal/cfn" "github.com/ericdahl-dev/aws-green/internal/config" + "github.com/ericdahl-dev/aws-green/internal/discover" "github.com/ericdahl-dev/aws-green/internal/ecs" "github.com/ericdahl-dev/aws-green/internal/fix" "github.com/ericdahl-dev/aws-green/internal/poller" @@ -119,7 +120,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case "m": m.screen = screenManage - m.manage = ui.NewManage(m.cfg) + m.manage = ui.NewManage(m.cfg, discover.Run) return m, nil case "r": m.fetching = true @@ -297,7 +298,7 @@ func main() { m := model{ screen: screenDashboard, dashboard: ui.NewDashboard(p.Snapshot(), actionerFactory, ctx), - manage: ui.NewManage(cfg), + manage: ui.NewManage(cfg, discover.Run), cfg: cfg, pollCh: writeCh, pollCancel: func() { cancel(); stopPoller() },