Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
112 changes: 112 additions & 0 deletions internal/discover/aws.go
Original file line number Diff line number Diff line change
@@ -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
}
140 changes: 140 additions & 0 deletions internal/discover/discover.go
Original file line number Diff line number Diff line change
@@ -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:<region>:<account>:service/<cluster>/<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
}
Loading
Loading