From 2754fa493097ef0af1f248eefb41ae50b6f6a537 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 29 Sep 2026 13:58:41 -0400 Subject: [PATCH] feat: discover a pipeline's stacks and ECS services when adding a project Adding a project from the manage screen saved only its pipeline; stacks and ECS services had to be hand-edited into config.toml. CDK pipelines deploy through CodeBuild, so the pipeline definition never names a stack. New internal/discover links them through the ProjectName tag the CDK blueprints put on the pipeline and every stack (minus the pipeline's own stack), and reads ECS services from those stacks' resources. The manage screen runs it after the form for new projects and for ones with a pipeline but nothing else, and shows a confirmation with the resources named after the project pre-selected, since a shared tag also matches sibling projects. Discovery failure never blocks the save. Closes #67 --- CONTEXT.md | 5 + README.md | 4 +- internal/discover/aws.go | 112 ++++++++++++++++ internal/discover/discover.go | 140 ++++++++++++++++++++ internal/discover/discover_test.go | 143 ++++++++++++++++++++ internal/ui/manage.go | 204 ++++++++++++++++++++++++++--- internal/ui/manage_test.go | 116 +++++++++++++++- main.go | 5 +- 8 files changed, 705 insertions(+), 24 deletions(-) create mode 100644 internal/discover/aws.go create mode 100644 internal/discover/discover.go create mode 100644 internal/discover/discover_test.go 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() },