Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Thanks for your interest in contributing.
## Pull Request Checklist

- [ ] The change is scoped and documented.
- [ ] New user-facing AWS service capabilities are TUI-first: they ship with a TUI entry point, and any CLI command is a secondary surface for scripting or automation.
- [ ] Branch name follows `docs/branch-naming-harness.md`.
- [ ] `README.md` and relevant `docs/` pages were reviewed and updated if needed.
- [ ] Existing behavior is not broken.
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ eval "$(unic env prod-admin)"
# Interactively choose/setup a context and copy exports to clipboard
unic context setup

# Print an ECR login command for the current context
# Print an ECR login command for the current context (scripting helper;
# the TUI's ECR Login Helper is the primary workflow)
unic ecr login

# Copy a Podman ECR login command to the clipboard
Expand Down Expand Up @@ -372,7 +373,7 @@ checks:
| Bedrock API Keys | `c` create, choose current IAM user or another user, `r` rotate secret, `d` delete, type the IAM user/key ID to confirm, `c` copy one-time key without printing it, `e` copy `AWS_BEARER_TOKEN_BEDROCK` export |
| CloudWatch Metrics | preset-driven metric list/detail flow, `/` filter, `space` select related series, `g` preset cycle, `t/p/s` range-period-stat controls, `r` refresh, in-terminal single-series and comparison charts |
| CloudWatch Logs | log groups/streams load 10 at a time, `n` load more, `1`-`6` time presets, `t` live tail, `f` filter pattern, `w` wrap toggle, `h/l` horizontal scroll |
| ECR Login | CLI helper: `unic ecr login [--runtime docker|podman] [--copy]` |
| ECR Login Helper | `c` copy Docker login command, `p` copy Podman login command, `r` refresh; CLI helper for scripting: `unic ecr login [--runtime docker|podman] [--copy]` |
| ECS Exec | `r` refresh, `Enter` drill down / exec |
| ECS Rollout / Exec | cluster/service lists support refresh and drill-down, service detail shows deployments/task definition images/events, `Enter` continues into tasks and exec |
| EKS Browser | cluster/node group/add-on lists support `/` filter and `r` refresh, cluster view shows version/status/endpoint visibility/ARN summary, `a` opens managed add-ons, `U` opens current-version upgrade readiness, `u` opens kubeconfig access helper, node group detail shows desired/min/max scaling plus health issues |
Expand All @@ -389,7 +390,7 @@ The service list defaults to favorites first, then alphabetical order. Press `f`

The EKS Browser includes a managed add-on status view for each cluster. Add-on rows show the installed version, status, and health summary, with degraded or unhealthy add-ons highlighted so core components such as CoreDNS, kube-proxy, VPC CNI, and CSI drivers are easy to spot.

The ECR Repository Browser opens image/tag lists from each repository. Image rows include tags, digest, pushed time, and size, and mark untagged images or images older than 90 days as cleanup candidates. Image detail exposes digest and tag values for clipboard copy.
The ECR Login Helper resolves the private registry URI for the active context and shows copyable Docker and Podman login commands without leaving the TUI; `unic ecr login` remains as a secondary CLI helper for scripting. The ECR Repository Browser opens image/tag lists from each repository. Image rows include tags, digest, pushed time, and size, and mark untagged images or images older than 90 days as cleanup candidates. Image detail exposes digest and tag values for clipboard copy.

The FIS Experiment Template Browser lists experiment templates in the active region and opens a detail screen with role ARN, targets, actions, target mappings, parameters, filters, and stop condition summaries without leaving the TUI. Template detail includes a Safe Run Preview that summarizes blast radius, target selection modes, action count, active stop conditions, IAM role, and warnings for missing stop conditions, missing role ARN, broad selection, or unbounded selectors. The preview also states the template ID that any future execution path must type to confirm before a run can start. Press `h` on a selected template or template detail to inspect recent runs for that template, or `H` from the template list to inspect recent experiment history across the active account/region. History rows include run status, timing, and stop/failure summaries, with failed, stopped, stopping, and cancelled runs visually highlighted; `Enter` opens run detail with start/end times, duration, action states, targets, stop conditions, and failure metadata.

Expand All @@ -403,6 +404,10 @@ Bedrock API key management uses the active unic AWS context and IAM service-spec

Reachability Analyzer starts with a region selection step, defaults to the current context region, and now surfaces the AWS-documented source and destination resource types that unic supports: EC2 instances, Internet gateways, Network interfaces, Transit gateways, Transit gateway attachments, Virtual private gateways, VPC endpoint services, VPC endpoints, VPC peering connections, plus IP addresses as destinations. The source and destination pickers support type tabs, keyword filtering, IPv4 destination validation, and automatic cleanup of temporary Network Insights resources after each analysis. During analysis, the loading screen shows a vertical source-to-destination flow and intent summary, and the result view renders path hops and findings in a more readable layout.

## Product Principles

- **TUI-first.** New user-facing AWS service capabilities ship with a TUI entry point first. CLI commands are secondary surfaces for scripting, automation, or copy/paste handoff, and should be documented as such.

## Development

```bash
Expand Down
3 changes: 3 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ const (
screenECRRepositoryDetail
screenECRImageList
screenECRImageDetail
screenECRLoginHelper
screenFISTemplateList
screenFISTemplateDetail
screenFISExperimentList
Expand Down Expand Up @@ -670,6 +671,8 @@ func (m Model) updateFeatureList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.ecs.Start(&m)
case domain.FeatureECRRepositoryBrowser:
return m.ecr.Start(&m)
case domain.FeatureECRLoginHelper:
return m.ecr.StartLogin(&m)
case domain.FeatureEKSBrowser:
return m.eks.Start(&m)
case domain.FeatureFISTemplateBrowser:
Expand Down
9 changes: 9 additions & 0 deletions internal/app/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,13 @@ func (m Model) currentScreenShortcuts() []helpShortcut {
{"q", "Go back to the feature list"},
{"esc", "Go back to the image list"},
}
case screenECRLoginHelper:
return []helpShortcut{
{"c", "Copy the Docker login command"},
{"p", "Copy the Podman login command"},
{"r", "Re-resolve the registry and commands"},
{"q / esc", "Go back to the feature list"},
}
case screenFISTemplateList:
shortcuts := listScreenShortcuts("open the selected experiment template", "go back to the feature list", true, false)
return append(shortcuts[:3], append([]helpShortcut{
Expand Down Expand Up @@ -914,6 +921,8 @@ func (m Model) helpScreenTitle() string {
return "ECR Images"
case screenECRImageDetail:
return "ECR Image Detail"
case screenECRLoginHelper:
return "ECR Login Helper"
case screenFISTemplateList:
return "FIS Experiment Templates"
case screenFISTemplateDetail:
Expand Down
6 changes: 6 additions & 0 deletions internal/app/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,12 @@ type ecrRepositoriesLoadedMsg struct {
repositories []awsservice.ECRRepository
}

type ecrLoginResolvedMsg struct {
registryURI string
dockerCommand string
podmanCommand string
}

type ecrImagesLoadedMsg struct {
repository string
images []awsservice.ECRImage
Expand Down
91 changes: 91 additions & 0 deletions internal/app/screen_ecr.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ type ecrModel struct {
imageIdx int
selectedImage *awsservice.ECRImage
copyMsg string
loginRegistryURI string
loginDockerCommand string
loginPodmanCommand string
}

func newECRModel() ecrModel {
Expand All @@ -32,6 +35,10 @@ func (em *ecrModel) Start(m *Model) (tea.Model, tea.Cmd) {
return m.startLoading(em.loadRepositories(*m))
}

func (em *ecrModel) StartLogin(m *Model) (tea.Model, tea.Cmd) {
return m.startLoading(em.loadLoginCommands(*m))
}

func (em *ecrModel) HandleMessage(m *Model, msg tea.Msg) (tea.Model, tea.Cmd, bool) {
switch msg := msg.(type) {
case ecrRepositoriesLoadedMsg:
Expand All @@ -57,6 +64,13 @@ func (em *ecrModel) HandleMessage(m *Model, msg tea.Msg) (tea.Model, tea.Cmd, bo
m.resetFilter(filterECRImages)
m.screen = screenECRImageList
return *m, nil, true
case ecrLoginResolvedMsg:
em.loginRegistryURI = msg.registryURI
em.loginDockerCommand = msg.dockerCommand
em.loginPodmanCommand = msg.podmanCommand
em.copyMsg = ""
m.screen = screenECRLoginHelper
return *m, nil, true
}
return *m, nil, false
}
Expand All @@ -75,6 +89,9 @@ func (em *ecrModel) HandleKey(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd, boo
case screenECRImageDetail:
newM, cmd := em.updateImageDetail(m, msg)
return newM, cmd, true
case screenECRLoginHelper:
newM, cmd := em.updateLoginHelper(m, msg)
return newM, cmd, true
default:
return *m, nil, false
}
Expand All @@ -90,6 +107,8 @@ func (em ecrModel) View(m Model) (string, bool) {
return em.viewImageList(m), true
case screenECRImageDetail:
return em.viewImageDetail(m), true
case screenECRLoginHelper:
return em.viewLoginHelper(m), true
default:
return "", false
}
Expand Down Expand Up @@ -227,6 +246,78 @@ func (em *ecrModel) updateImageDetail(m *Model, msg tea.KeyMsg) (tea.Model, tea.
return *m, nil
}

func (em *ecrModel) updateLoginHelper(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q", "esc":
em.copyMsg = ""
m.screen = screenFeatureList
case "r":
return m.startLoading(em.loadLoginCommands(*m))
case "c":
em.copyLoginCommand("Docker", em.loginDockerCommand)
case "p":
em.copyLoginCommand("Podman", em.loginPodmanCommand)
}
return *m, nil
}

func (em *ecrModel) copyLoginCommand(runtime, command string) {
if command == "" {
em.copyMsg = fmt.Sprintf("No %s login command available", runtime)
return
}
if err := clipboard.Copy(command); err != nil {
em.copyMsg = fmt.Sprintf("Clipboard error: %s", err)
return
}
em.copyMsg = fmt.Sprintf("Copied %s login command to clipboard", runtime)
}

func (em *ecrModel) loadLoginCommands(m Model) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
registryURI, _, err := awsservice.ResolvePrivateECRRegistryURI(ctx, m.cfg)
if err != nil {
return errMsg{err: err}
}
docker, err := awsservice.BuildECRLoginCommand(registryURI, m.cfg.Region, awsservice.ECRRuntimeDocker)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] registry URI는 m.cfg의 unic context 자격증명으로 해석하지만, 생성되는 명령은 plain aws ecr get-login-password --region ...라서 실행 시 shell의 ambient AWS profile/credentials를 사용합니다. SSO/assume_role/okta_saml context에서는 다른 계정에 로그인하거나 인증 실패할 수 있어 ‘active context login’ 보장이 깨집니다. 복사 명령이 unic context의 exports를 적용하도록 만들거나, 더 단순하게 unic 자체가 ECR authorization token을 받아 login을 실행하는 흐름이 필요합니다.

if err != nil {
return errMsg{err: err}
}
podman, err := awsservice.BuildECRLoginCommand(registryURI, m.cfg.Region, awsservice.ECRRuntimePodman)
if err != nil {
return errMsg{err: err}
}
return ecrLoginResolvedMsg{registryURI: registryURI, dockerCommand: docker, podmanCommand: podman}
}
}

func (em ecrModel) viewLoginHelper(m Model) string {
var b strings.Builder
b.WriteString(m.renderStatusBar())
b.WriteString(titleStyle.Render("ECR Login Helper"))
b.WriteString("\n\n")
b.WriteString(dimStyle.Render(" Registry "))
b.WriteString(normalStyle.Render(em.loginRegistryURI))
b.WriteString("\n\n")
b.WriteString(dimStyle.Render(" Docker"))
b.WriteString("\n")
b.WriteString(normalStyle.Render(" " + em.loginDockerCommand))
b.WriteString("\n\n")
b.WriteString(dimStyle.Render(" Podman"))
b.WriteString("\n")
b.WriteString(normalStyle.Render(" " + em.loginPodmanCommand))
b.WriteString("\n")
if em.copyMsg != "" {
b.WriteString("\n")
b.WriteString(selectedStyle.Render(" " + em.copyMsg))
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(m.renderHelpBar("c: copy docker • p: copy podman • r: refresh • esc: back • H: home"))
return b.String()
}

func (em *ecrModel) loadRepositories(m Model) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
Expand Down
59 changes: 59 additions & 0 deletions internal/app/screen_ecr_login_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package app

import (
"strings"
"testing"

tea "github.com/charmbracelet/bubbletea"

"unic/internal/config"
)

func TestECRLoginResolvedMsgOpensHelperScreen(t *testing.T) {
m := Model{
cfg: &config.Config{Region: "ap-northeast-2"},
screen: screenLoading,
}

msg := ecrLoginResolvedMsg{
registryURI: "123456789012.dkr.ecr.ap-northeast-2.amazonaws.com",
dockerCommand: "aws ecr get-login-password --region ap-northeast-2 | docker login ...",
podmanCommand: "aws ecr get-login-password --region ap-northeast-2 | podman login ...",
}
_, _, handled := m.ecr.HandleMessage(&m, msg)
if !handled {
t.Fatal("expected login-resolved message to be handled")
}
if m.screen != screenECRLoginHelper {
t.Fatalf("expected login helper screen, got %v", m.screen)
}

view, ok := m.ecr.View(m)
if !ok {
t.Fatal("expected login helper view to render")
}
for _, want := range []string{"ECR Login Helper", msg.registryURI, "docker login", "podman login"} {
if !strings.Contains(view, want) {
t.Fatalf("expected view to contain %q, got:\n%s", want, view)
}
}
}

func TestECRLoginHelperEscReturnsToFeatureList(t *testing.T) {
m := Model{
cfg: &config.Config{Region: "ap-northeast-2"},
screen: screenECRLoginHelper,
}
m.ecr.copyMsg = "stale"

_, _, handled := m.ecr.HandleKey(&m, tea.KeyMsg{Type: tea.KeyEsc})
if !handled {
t.Fatal("expected key to be handled on the login helper screen")
}
if m.screen != screenFeatureList {
t.Fatalf("expected feature list screen, got %v", m.screen)
}
if m.ecr.copyMsg != "" {
t.Fatalf("expected copy message to be cleared, got %q", m.ecr.copyMsg)
}
}
4 changes: 4 additions & 0 deletions internal/domain/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ func Catalog() []Service {
Kind: FeatureECRRepositoryBrowser,
Description: "Browse ECR repositories and review image policy settings",
},
{
Kind: FeatureECRLoginHelper,
Description: "Generate and copy a registry login command for the active context",
},
},
},
{
Expand Down
16 changes: 16 additions & 0 deletions internal/domain/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,3 +244,19 @@ func TestCatalogDoesNotContainInspectorPseudoService(t *testing.T) {
}
}
}

func TestCatalogContainsECRLoginHelperFeature(t *testing.T) {
for _, svc := range Catalog() {
if svc.Name != ServiceECR {
continue
}
for _, feat := range svc.Features {
if feat.Kind == FeatureECRLoginHelper {
return
}
}
t.Error("ECR service should have ECR Login Helper feature")
return
}
t.Error("ECR service not found in catalog")
}
1 change: 1 addition & 0 deletions internal/domain/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const (
FeatureCloudWatchLogsBrowser FeatureKind = "CloudWatch Logs Browser"
FeatureECSExec FeatureKind = "ECS Browser & Exec"
FeatureECRRepositoryBrowser FeatureKind = "ECR Repository Browser"
FeatureECRLoginHelper FeatureKind = "ECR Login Helper"
FeatureEKSBrowser FeatureKind = "EKS Cluster Browser"
FeatureFISTemplateBrowser FeatureKind = "FIS Experiment Template Browser"
FeatureS3Browser FeatureKind = "S3 Browser"
Expand Down
Loading