diff --git a/README.md b/README.md index fd9aafc..bf90ec0 100644 --- a/README.md +++ b/README.md @@ -170,14 +170,19 @@ contexts: sso_account_id: "123456789012" sso_role_name: DeveloperRole - # SSO portal in one region, resources in another - - name: seoul-sso-123456789012-admin - region: ap-northeast-2 # where resources are queried - sso_region: us-east-1 # where the SSO/IAM Identity Center portal lives - auth_type: sso - sso_start_url: https://example.awsapps.com/start - sso_account_id: "123456789012" - sso_role_name: Admin + # Preferred structured format: one identity, several resource regions + - name: production + auth: + type: sso + sso_region: us-east-1 + sso_start_url: https://example.awsapps.com/start + sso_account_id: "123456789012" + sso_role_name: Admin + resources: + default_region: ap-northeast-2 + regions: + - us-east-1 + - eu-west-1 - name: prod-admin order: 20 @@ -205,9 +210,11 @@ contexts: | `credential` | Use shared AWS profile credentials | `profile` | | `console_login` | Run `aws login` during `unic context setup`, then use the resulting profile-backed console credentials | `profile` | | `assume_role` | Assume a role from a base profile | `profile`, `role_arn` | -| `sso` | Use AWS IAM Identity Center / SSO, reusing a valid AWS CLI SSO cache and prompting for login only when needed | `profile`, `sso_start_url`, and for concrete contexts `sso_account_id`, `sso_role_name` | +| `sso` | Use AWS IAM Identity Center / SSO, reusing a valid AWS CLI SSO cache and prompting for login only when needed | `sso_start_url`, and for concrete contexts `sso_account_id`, `sso_role_name`; `profile` is optional | + +The preferred context format separates `auth` from `resources`. `auth.sso_region` controls IAM Identity Center login and role-credential retrieval. `resources.default_region` is selected at startup, and `resources.regions` lists additional regions available from the global `R` region picker. Switching regions reuses the current credentials and recreates only the regional AWS clients. -For `sso` contexts, `region` is the region resources are queried in. When the IAM Identity Center portal lives in a different region than your resources, set `sso_region` to the portal region — SSO login and role-credential retrieval use `sso_region`, while all resource browsing uses `region`. If `sso_region` is omitted, it defaults to `region` (backward compatible). +Legacy flat fields (`auth_type`, `profile`, `region`, `regions`, `sso_region`, and related auth fields) remain supported. A context without a region list behaves as a single-region context, and an omitted SSO region still falls back to its default resource region. TUI startup is passive for SSO contexts: it loads the context picker without launching `aws sso login`. SSO login is prompted when you explicitly select or set up an SSO context, or when an AWS-backed workflow needs credentials. @@ -217,6 +224,7 @@ Optional context fields: |---|---| | `order` | Lower values appear first in the context setup picker. Contexts without `order` fall back after ordered entries in their existing file order. | | `sso_region` | (SSO only) Region of the IAM Identity Center portal, used for SSO login and role-credential retrieval. Defaults to `region` when unset. Use it when the SSO portal and your resources live in different regions. | +| `resources.regions` / `regions` | Additional resource regions available through the global `R` picker. The default resource region is always included automatically. | Resolution priority: @@ -343,6 +351,7 @@ checks: | `H` | Jump to service list | | `i` | Enter Inspector mode from the service list | | `C` | Open context picker | +| `R` | Switch between the active context's configured resource regions | | `S` | Open settings | | `/` | Toggle filter mode on supported screens | | `f` | Favorite/unfavorite the selected service or context on supported lists | diff --git a/docs/architecture.en.md b/docs/architecture.en.md index 8bda60b..8f04610 100644 --- a/docs/architecture.en.md +++ b/docs/architecture.en.md @@ -193,7 +193,9 @@ Two shapes exist: - includes `sso_account_id` and `sso_role_name` - can produce direct environment exports and SDK credentials -`region` is the resource region; `sso_region` (optional) is the IAM Identity Center portal region used for SSO login and `GetRoleCredentials`. When `sso_region` is unset it falls back to `region`, so single-region setups need no change. +Contexts can use the structured `auth` and `resources` sections to keep identity independent from resource location. `auth.sso_region` is used for SSO login and `GetRoleCredentials`; `resources.default_region` is the initial resource region, and `resources.regions` defines the regions available to the runtime picker. Switching regions reuses the credentials provider and only recreates regional SDK clients. + +Legacy flat fields remain supported. `region` maps to `resources.default_region`, `regions` maps to the selectable resource regions, and `sso_region` maps to `auth.sso_region`. A missing region list produces the previous single-region behavior. ## TUI Screen Families diff --git a/docs/architecture.ko.md b/docs/architecture.ko.md index a2c35cc..7e7b238 100644 --- a/docs/architecture.ko.md +++ b/docs/architecture.ko.md @@ -193,7 +193,9 @@ UNIC은 현재 세 가지 인증 모드를 지원한다. - `sso_account_id`, `sso_role_name` 포함 - 직접 env export와 SDK credential 생성 가능 -`region`은 리소스 조회 리전이고, `sso_region`(선택)은 SSO 로그인과 `GetRoleCredentials`에 쓰이는 IAM Identity Center 포털 리전이다. `sso_region`이 없으면 `region`으로 폴백하므로 단일 리전 설정은 변경할 필요가 없다. +컨텍스트는 구조화된 `auth`와 `resources` 섹션을 사용해 인증 정보와 리소스 위치를 분리할 수 있다. `auth.sso_region`은 SSO 로그인과 `GetRoleCredentials`에 사용하고, `resources.default_region`은 최초 리소스 리전, `resources.regions`는 런타임 리전 선택기에 노출할 리전 목록이다. 리전 전환 시 기존 credential provider를 재사용하고 리전별 SDK client만 다시 생성한다. + +기존 flat 필드도 계속 지원한다. `region`은 `resources.default_region`, `regions`는 선택 가능한 리전 목록, `sso_region`은 `auth.sso_region`에 대응한다. 리전 목록이 없으면 이전과 동일한 단일 리전 동작을 유지한다. ## TUI 화면 계열 diff --git a/internal/app/app.go b/internal/app/app.go index e96374a..389ed57 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -106,6 +106,7 @@ const ( screenContextAdd screenContextSSOAccountList screenContextSSORoleList + screenRegionPicker screenSettings screenLoading screenError @@ -184,6 +185,8 @@ type Model struct { contextSSOAccount awsservice.SSOAccount contextSSORoles []awsservice.SSORole contextSSORoleIdx int + regionIdx int + regionPrevScreen screen // Context add wizard addStep int // 0=auth_type select, 1+=field input, -1=confirm @@ -525,6 +528,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ctxPrevScreen = m.screen return m, m.loadContexts() } + // Global resource-region switch. Authentication identity remains unchanged; + // only region-scoped AWS clients are recreated. + if msg.String() == "R" && m.canSwitchResourceRegion() { + m.deactivateFilter() + m.regionPrevScreen = m.screen + m.regionIdx = m.activeRegionIndex() + m.screen = screenRegionPicker + return m, nil + } // Global settings — S opens the settings screen (skip text-entry screens // and the filter input so it never steals a typed character). if msg.String() == "S" && !m.filterTI.Focused() && m.screen != screenSettings && @@ -556,6 +568,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateContextSSOAccountList(msg) case screenContextSSORoleList: return m.updateContextSSORoleList(msg) + case screenRegionPicker: + return m.updateRegionPicker(msg) case screenSettings: return m.updateSettings(msg) case screenError: @@ -714,6 +728,8 @@ func (m Model) View() string { v = m.viewContextSSOAccountList() case screenContextSSORoleList: v = m.viewContextSSORoleList() + case screenRegionPicker: + v = m.viewRegionPicker() case screenSettings: v = m.viewSettings() case screenLoading: diff --git a/internal/app/context_add.go b/internal/app/context_add.go index 831c4ed..6a577ce 100644 --- a/internal/app/context_add.go +++ b/internal/app/context_add.go @@ -21,7 +21,8 @@ var fieldsByAuthType = map[string][]fieldDef{ "sso": { {key: "name", label: "Name", required: true}, {key: "order", label: "Display Order (optional, lower first)", required: false}, - {key: "region", label: "Region (resources)", required: true}, + {key: "region", label: "Default Resource Region", required: true}, + {key: "regions", label: "Other Resource Regions (optional, comma-separated)", required: false}, {key: "sso_region", label: "SSO Login Region (optional, defaults to Region)", required: false}, {key: "sso_start_url", label: "SSO Start URL", required: true}, {key: "sso_account_id", label: "SSO Account ID", required: true}, @@ -31,18 +32,21 @@ var fieldsByAuthType = map[string][]fieldDef{ {key: "name", label: "Name", required: true}, {key: "order", label: "Display Order (optional, lower first)", required: false}, {key: "region", label: "Region", required: true}, + {key: "regions", label: "Other Resource Regions (optional, comma-separated)", required: false}, {key: "profile", label: "Profile", required: true}, }, "console_login": { {key: "name", label: "Name", required: true}, {key: "order", label: "Display Order (optional, lower first)", required: false}, {key: "region", label: "Region", required: true}, + {key: "regions", label: "Other Resource Regions (optional, comma-separated)", required: false}, {key: "profile", label: "Profile", required: true}, }, "assume_role": { {key: "name", label: "Name", required: true}, {key: "order", label: "Display Order (optional, lower first)", required: false}, {key: "region", label: "Region", required: true}, + {key: "regions", label: "Other Resource Regions (optional, comma-separated)", required: false}, {key: "profile", label: "Profile", required: true}, {key: "role_arn", label: "Role ARN", required: true}, {key: "external_id", label: "External ID (optional)", required: false}, @@ -128,17 +132,22 @@ func (m Model) saveContext() tea.Cmd { return errMsg{err: err} } entry := config.ContextEntry{ - Name: m.addValues["name"], - Order: order, - AuthType: m.addValues["auth_type"], - Region: m.addValues["region"], - Profile: m.addValues["profile"], - RoleArn: m.addValues["role_arn"], - ExternalID: m.addValues["external_id"], - SSOStartURL: m.addValues["sso_start_url"], - SSORegion: m.addValues["sso_region"], - SSOAccountID: m.addValues["sso_account_id"], - SSORoleName: m.addValues["sso_role_name"], + Name: m.addValues["name"], + Order: order, + Auth: &config.ContextAuth{ + Type: m.addValues["auth_type"], + Profile: m.addValues["profile"], + RoleArn: m.addValues["role_arn"], + ExternalID: m.addValues["external_id"], + SSOStartURL: m.addValues["sso_start_url"], + SSORegion: m.addValues["sso_region"], + SSOAccountID: m.addValues["sso_account_id"], + SSORoleName: m.addValues["sso_role_name"], + }, + Resources: &config.ContextResources{ + DefaultRegion: m.addValues["region"], + Regions: parseRegionList(m.addValues["regions"]), + }, } if err := config.AddContext(m.configPath, entry); err != nil { return errMsg{err: err} @@ -149,6 +158,17 @@ func (m Model) saveContext() tea.Cmd { } } +func parseRegionList(raw string) []string { + parts := strings.Split(raw, ",") + regions := make([]string, 0, len(parts)) + for _, part := range parts { + if region := strings.TrimSpace(part); region != "" { + regions = append(regions, region) + } + } + return regions +} + func parseOptionalContextOrder(raw string) (int, error) { value := strings.TrimSpace(raw) if value == "" { @@ -188,7 +208,20 @@ func (m Model) viewContextAdd() string { // Show completed fields b.WriteString(dimStyle.Render(fmt.Sprintf(" auth_type: %s", m.addValues["auth_type"]))) b.WriteString("\n") - for i := 0; i < len(m.addFields); i++ { + visibleLines := len(m.addFields) + if m.height > 0 { + visibleLines = max(m.height-8, 5) + } + end := len(m.addFields) + if m.addStep > 0 { + end = min(m.addFieldIdx+1, len(m.addFields)) + } + start := max(end-visibleLines, 0) + if start > 0 { + b.WriteString(dimStyle.Render(fmt.Sprintf(" ... %d earlier fields", start))) + b.WriteString("\n") + } + for i := start; i < end; i++ { field := m.addFields[i] if m.addStep == -1 || i < m.addFieldIdx { // Completed field diff --git a/internal/app/context_add_test.go b/internal/app/context_add_test.go index 6c1af3e..d8d774c 100644 --- a/internal/app/context_add_test.go +++ b/internal/app/context_add_test.go @@ -36,10 +36,32 @@ func TestContextAddSelectsConsoleLoginFields(t *testing.T) { if model.addValues["auth_type"] != "console_login" { t.Fatalf("expected console_login selection, got %q", model.addValues["auth_type"]) } - if len(model.addFields) != 4 { - t.Fatalf("expected 4 fields for console_login, got %d", len(model.addFields)) + if len(model.addFields) != 5 { + t.Fatalf("expected 5 fields for console_login, got %d", len(model.addFields)) } - if model.addFields[3].key != "profile" { - t.Fatalf("expected profile field, got %+v", model.addFields) + if model.addFields[3].key != "regions" || model.addFields[4].key != "profile" { + t.Fatalf("expected regions and profile fields, got %+v", model.addFields) + } +} + +func TestContextAddKeepsCurrentFieldVisibleOnShortTerminal(t *testing.T) { + m := New(testConfig(), "", "dev") + m.screen = screenContextAdd + m.height = 10 + m.addStep = 1 + m.addFields = fieldsByAuthType["sso"] + m.addFieldIdx = len(m.addFields) - 1 + m.addInput = "AdministratorAccess" + m.addValues = map[string]string{"auth_type": "sso"} + for i := 0; i < m.addFieldIdx; i++ { + m.addValues[m.addFields[i].key] = "configured" + } + + view := m.viewContextAdd() + if !strings.Contains(view, "SSO Role Name") || !strings.Contains(view, "AdministratorAccess") { + t.Fatalf("expected focused field to remain visible, got %q", view) + } + if !strings.Contains(view, "earlier fields") { + t.Fatalf("expected windowing indicator on short terminal, got %q", view) } } diff --git a/internal/app/help.go b/internal/app/help.go index 4e305d2..124d69f 100644 --- a/internal/app/help.go +++ b/internal/app/help.go @@ -84,6 +84,9 @@ func (m Model) globalHelpShortcuts() []helpShortcut { m.screen != screenBedrockKeyConfirm { shortcuts = append(shortcuts, helpShortcut{"S", "Open settings"}) } + if m.canSwitchResourceRegion() { + shortcuts = append(shortcuts, helpShortcut{"R", "Switch the active resource region"}) + } return shortcuts } @@ -716,6 +719,12 @@ func (m Model) currentScreenShortcuts() []helpShortcut { {"esc", "Go back to the account list"}, {"q", "Cancel and return to the context picker"}, } + case screenRegionPicker: + return []helpShortcut{ + {"↑/↓, j/k", "Move between configured resource regions"}, + {"enter", "Switch to the highlighted region"}, + {"q / esc", "Cancel the region switch"}, + } case screenLoading: return []helpShortcut{ {"Wait", "The current AWS request is still loading"}, @@ -961,6 +970,8 @@ func (m Model) helpScreenTitle() string { return "Select SSO Account" case screenContextSSORoleList: return "Select SSO Role" + case screenRegionPicker: + return "Resource Region Picker" case screenSettings: return "Settings" case screenLoading: diff --git a/internal/app/messages.go b/internal/app/messages.go index cf456c6..7865d1b 100644 --- a/internal/app/messages.go +++ b/internal/app/messages.go @@ -65,6 +65,11 @@ type contextSwitchedMsg struct { identity *awsservice.CallerIdentity } +type regionSwitchedMsg struct { + region string + repo *awsservice.AwsRepository +} + type ssoLoginDoneMsg struct { err error } diff --git a/internal/app/screen_context.go b/internal/app/screen_context.go index c5f5496..8fe7316 100644 --- a/internal/app/screen_context.go +++ b/internal/app/screen_context.go @@ -63,6 +63,14 @@ func (m Model) handleContextMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) { m.screen = m.ctxPrevScreen return m, tea.ClearScreen, true + case regionSwitchedMsg: + m.cfg.Region = msg.region + m.awsRepo = msg.repo + // Region-scoped feature state may contain resources from the previous + // region, so return to the service catalog after switching. + m.screen = screenServiceList + return m, tea.ClearScreen, true + case contextSSOAccountsLoadedMsg: m.contextSSOBase = msg.base m.contextSSOAccounts = msg.accounts diff --git a/internal/app/screen_region.go b/internal/app/screen_region.go new file mode 100644 index 0000000..f050662 --- /dev/null +++ b/internal/app/screen_region.go @@ -0,0 +1,113 @@ +package app + +import ( + "context" + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + awsservice "unic/internal/services/aws" +) + +var newAwsRepositoryForRegionFn = awsservice.NewAwsRepository + +func (m Model) canSwitchResourceRegion() bool { + if m.cfg == nil || len(m.cfg.Regions) <= 1 || m.filterTI.Focused() || m.isTextEntryScreen() { + return false + } + switch m.screen { + case screenRegionPicker, screenContextPicker, screenLoading, screenInspectorScanning: + return false + default: + return true + } +} + +func (m Model) activeRegionIndex() int { + if m.cfg == nil { + return 0 + } + for i, region := range m.cfg.Regions { + if region == m.cfg.Region { + return i + } + } + return 0 +} + +func (m Model) updateRegionPicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + regions := m.cfg.Regions + switch msg.String() { + case "q", "esc": + m.screen = m.regionPrevScreen + case "up", "k": + m.regionIdx = previousListIndex(m.regionIdx, len(regions)) + case "down", "j": + m.regionIdx = nextListIndex(m.regionIdx, len(regions)) + case "enter": + if m.regionIdx < 0 || m.regionIdx >= len(regions) { + return m, nil + } + region := regions[m.regionIdx] + if region == m.cfg.Region { + m.screen = m.regionPrevScreen + return m, nil + } + return m.startLoading(m.switchResourceRegion(region)) + } + return m, nil +} + +func (m Model) switchResourceRegion(region string) tea.Cmd { + cfg := *m.cfg + repo := m.awsRepo + return func() tea.Msg { + if repo != nil { + return regionSwitchedMsg{region: region, repo: repo.ForRegion(region)} + } + cfg.Region = region + newRepo, err := newAwsRepositoryForRegionFn(context.Background(), &cfg) + if err != nil { + return errMsg{err: fmt.Errorf("failed to switch resource region to %s: %w", region, err)} + } + return regionSwitchedMsg{region: region, repo: newRepo} + } +} + +func (m Model) viewRegionPicker() string { + var b strings.Builder + var panel strings.Builder + b.WriteString(m.renderStatusBar()) + b.WriteString(titleStyle.Render("Select Resource Region")) + b.WriteString("\n\n") + b.WriteString(dimStyle.Render(" Authentication, account, and role remain unchanged.")) + b.WriteString("\n\n") + + visibleLines := max(m.height-10, 5) + start := 0 + if m.regionIdx >= visibleLines { + start = m.regionIdx - visibleLines + 1 + } + end := min(start+visibleLines, len(m.cfg.Regions)) + for i := start; i < end; i++ { + region := m.cfg.Regions[i] + cursor := " " + style := normalStyle + marker := "" + if region == m.cfg.Region { + marker = " (active)" + } + if i == m.regionIdx { + cursor = "> " + style = selectedStyle + } + panel.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, region))) + panel.WriteString(dimStyle.Render(marker)) + panel.WriteString("\n") + } + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar("↑/↓: navigate • enter: switch • esc: cancel")) + return b.String() +} diff --git a/internal/app/screen_region_test.go b/internal/app/screen_region_test.go new file mode 100644 index 0000000..3ea645b --- /dev/null +++ b/internal/app/screen_region_test.go @@ -0,0 +1,158 @@ +package app + +import ( + "context" + "errors" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "unic/internal/config" + awsservice "unic/internal/services/aws" +) + +func multiRegionTestModel() Model { + m := New(&config.Config{ + ContextName: "production", + Region: "ap-northeast-2", + Regions: []string{"ap-northeast-2", "us-east-1"}, + }, "", "dev") + return m +} + +func TestGlobalRegionShortcutOpensPicker(t *testing.T) { + m := multiRegionTestModel() + m.screen = screenServiceList + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'R'}}) + model := updated.(Model) + if model.screen != screenRegionPicker { + t.Fatalf("expected region picker, got %v", model.screen) + } + if model.regionIdx != 0 { + t.Fatalf("expected active region at index 0, got %d", model.regionIdx) + } +} + +func TestRegionShortcutBlockedDuringActiveRequests(t *testing.T) { + for _, activeScreen := range []screen{screenLoading, screenInspectorScanning} { + m := multiRegionTestModel() + m.screen = activeScreen + if m.canSwitchResourceRegion() { + t.Fatalf("expected region switching to be blocked on screen %v", activeScreen) + } + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'R'}}) + if model := updated.(Model); model.screen != activeScreen { + t.Fatalf("expected screen %v to remain active, got %v", activeScreen, model.screen) + } + } +} + +func TestRegionPickerNavigationAndCancel(t *testing.T) { + keys := []struct { + name string + msg tea.KeyMsg + }{ + {name: "q", msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}}, + {name: "esc", msg: tea.KeyMsg{Type: tea.KeyEsc}}, + } + for _, key := range keys { + m := multiRegionTestModel() + m.screen = screenRegionPicker + m.regionPrevScreen = screenFeatureList + updated, _ := m.updateRegionPicker(key.msg) + if model := updated.(Model); model.screen != screenFeatureList { + t.Fatalf("%s should restore previous screen, got %v", key.name, model.screen) + } + } + + m := multiRegionTestModel() + m.screen = screenRegionPicker + updated, _ := m.updateRegionPicker(tea.KeyMsg{Type: tea.KeyDown}) + if model := updated.(Model); model.regionIdx != 1 { + t.Fatalf("expected cursor at region 1, got %d", model.regionIdx) + } +} + +func TestRegionPickerSelectingActiveRegionReturnsWithoutSwitch(t *testing.T) { + m := multiRegionTestModel() + m.screen = screenRegionPicker + m.regionPrevScreen = screenRDSList + m.regionIdx = 0 + + updated, cmd := m.updateRegionPicker(tea.KeyMsg{Type: tea.KeyEnter}) + model := updated.(Model) + if cmd != nil { + t.Fatal("expected no command when selecting the active region") + } + if model.screen != screenRDSList { + t.Fatalf("expected previous screen, got %v", model.screen) + } +} + +func TestRegionPickerRepositoryCreationFailure(t *testing.T) { + original := newAwsRepositoryForRegionFn + t.Cleanup(func() { newAwsRepositoryForRegionFn = original }) + expectedErr := errors.New("credentials unavailable") + newAwsRepositoryForRegionFn = func(context.Context, *config.Config) (*awsservice.AwsRepository, error) { + return nil, expectedErr + } + + m := multiRegionTestModel() + m.screen = screenRegionPicker + m.regionIdx = 1 + updated, cmd := m.updateRegionPicker(tea.KeyMsg{Type: tea.KeyEnter}) + if model := updated.(Model); model.screen != screenLoading { + t.Fatalf("expected loading screen, got %v", model.screen) + } + batch, ok := cmd().(tea.BatchMsg) + if !ok { + t.Fatal("expected loading command batch") + } + var errResult errMsg + found := false + for _, batchedCmd := range batch { + if msg, ok := batchedCmd().(errMsg); ok { + errResult = msg + found = true + break + } + } + if !found { + t.Fatal("expected repository failure errMsg in command batch") + } + if !errors.Is(errResult.err, expectedErr) { + t.Fatalf("unexpected error: %v", errResult.err) + } +} + +func TestRegionSwitchedMessageUpdatesRuntimeRegion(t *testing.T) { + m := multiRegionTestModel() + repo := &awsservice.AwsRepository{Region: "us-east-1"} + + updated, _ := m.Update(regionSwitchedMsg{region: "us-east-1", repo: repo}) + model := updated.(Model) + if model.cfg.Region != "us-east-1" { + t.Fatalf("expected active region us-east-1, got %q", model.cfg.Region) + } + if model.awsRepo != repo { + t.Fatal("expected switched repository to be installed") + } + if model.screen != screenServiceList { + t.Fatalf("expected service list after switch, got %v", model.screen) + } +} + +func TestSingleRegionContextDoesNotOpenPicker(t *testing.T) { + m := New(&config.Config{ + ContextName: "production", + Region: "ap-northeast-2", + Regions: []string{"ap-northeast-2"}, + }, "", "dev") + m.screen = screenServiceList + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'R'}}) + if model := updated.(Model); model.screen != screenServiceList { + t.Fatalf("single-region context should stay on service list, got %v", model.screen) + } +} diff --git a/internal/app/styles.go b/internal/app/styles.go index 3c6d897..d47857e 100644 --- a/internal/app/styles.go +++ b/internal/app/styles.go @@ -109,7 +109,11 @@ func (m Model) renderStatusBar() string { m.screen == screenReachabilityResult { activeRegion = m.activeReachabilityRegion() } - leftParts = append(leftParts, fmt.Sprintf("region:%s", activeRegion)) + regionLabel := fmt.Sprintf("region:%s", activeRegion) + if len(m.cfg.Regions) > 1 { + regionLabel += " [R switch]" + } + leftParts = append(leftParts, regionLabel) if m.cfg.AuthType != "" { leftParts = append(leftParts, fmt.Sprintf("auth:%s", m.cfg.AuthType)) } diff --git a/internal/auth/setup.go b/internal/auth/setup.go index 2ab51f9..4b9575c 100644 --- a/internal/auth/setup.go +++ b/internal/auth/setup.go @@ -318,6 +318,7 @@ func buildSSOContextEntry(configPath string, base config.ContextInfo, account aw } if ctx.Profile == base.Profile && ctx.Region == base.Region && + equalContextRegions(ctx.Region, ctx.Regions, base.Region, base.Regions) && ctx.SSORegion == base.SSORegion && ctx.SSOStartURL == base.SSOStartURL && ctx.SSOAccountID == account.ID && @@ -331,6 +332,7 @@ func buildSSOContextEntry(configPath string, base config.ContextInfo, account aw SSORegion: ctx.SSORegion, SSOAccountID: ctx.SSOAccountID, SSORoleName: ctx.SSORoleName, + Regions: ctx.Regions, }, ctx.Name, nil } } @@ -340,19 +342,56 @@ func buildSSOContextEntry(configPath string, base config.ContextInfo, account aw if region == "" { region = config.DefaultRegion } + resourceRegions := contextRegions(region, base.Regions) entry := config.ContextEntry{ - Name: name, - Profile: base.Profile, - Region: region, - AuthType: string(config.AuthTypeSSO), - SSOStartURL: base.SSOStartURL, - SSORegion: base.SSORegion, - SSOAccountID: account.ID, - SSORoleName: role.Name, + Name: name, + Auth: &config.ContextAuth{ + Type: string(config.AuthTypeSSO), + Profile: base.Profile, + SSOStartURL: base.SSOStartURL, + SSORegion: base.SSORegion, + SSOAccountID: account.ID, + SSORoleName: role.Name, + }, + Resources: &config.ContextResources{ + DefaultRegion: region, + Regions: resourceRegions[1:], + }, } return entry, name, nil } +func equalContextRegions(leftDefault string, left []string, rightDefault string, right []string) bool { + left = contextRegions(leftDefault, left) + right = contextRegions(rightDefault, right) + if len(left) != len(right) { + return false + } + for i := range left { + if left[i] != right[i] { + return false + } + } + return true +} + +func contextRegions(defaultRegion string, regions []string) []string { + result := make([]string, 0, len(regions)+1) + seen := make(map[string]struct{}, len(regions)+1) + for _, region := range append([]string{defaultRegion}, regions...) { + region = strings.TrimSpace(region) + if region == "" { + continue + } + if _, ok := seen[region]; ok { + continue + } + seen[region] = struct{}{} + result = append(result, region) + } + return result +} + func uniqueContextName(existing []config.ContextInfo, base string) string { used := map[string]struct{}{} for _, ctx := range existing { diff --git a/internal/auth/setup_test.go b/internal/auth/setup_test.go index d30ec2a..4978312 100644 --- a/internal/auth/setup_test.go +++ b/internal/auth/setup_test.go @@ -157,6 +157,34 @@ contexts: } } +func TestBuildSSOContextEntryDoesNotDuplicateDefaultRegion(t *testing.T) { + dir := t.TempDir() + path := writeConfig(t, dir, ` +contexts: + - name: base-sso + auth_type: sso + sso_start_url: https://example.awsapps.com/start + region: ap-northeast-2 +`) + + entry, _, err := buildSSOContextEntry(path, config.ContextInfo{ + Name: "base-sso", + AuthType: "sso", + Region: "ap-northeast-2", + Regions: []string{"ap-northeast-2", "us-east-1", "eu-west-1"}, + SSOStartURL: "https://example.awsapps.com/start", + }, awsservice.SSOAccount{ID: "123456789012"}, awsservice.SSORole{Name: "Admin"}) + if err != nil { + t.Fatal(err) + } + if entry.Resources == nil { + t.Fatal("expected structured resources") + } + if got := entry.Resources.Regions; len(got) != 2 || got[0] != "us-east-1" || got[1] != "eu-west-1" { + t.Fatalf("expected only additional regions, got %v", got) + } +} + func TestSetupContextSupportsSearchBeforeSelectingContext(t *testing.T) { origBuild := buildEnvExportsFn defer func() { diff --git a/internal/config/config.go b/internal/config/config.go index a75a6d8..b8ccb2e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -61,11 +61,26 @@ const ( // ContextEntry represents a single context definition in config.yaml. type ContextEntry struct { - Name string `yaml:"name"` - Order int `yaml:"order,omitempty"` + Name string `yaml:"name"` + Order int `yaml:"order,omitempty"` + Profile string `yaml:"profile,omitempty"` + Region string `yaml:"region,omitempty"` + AuthType string `yaml:"auth_type,omitempty"` + RoleArn string `yaml:"role_arn,omitempty"` + ExternalID string `yaml:"external_id,omitempty"` + SSOStartURL string `yaml:"sso_start_url,omitempty"` + SSORegion string `yaml:"sso_region,omitempty"` + SSOAccountID string `yaml:"sso_account_id,omitempty"` + SSORoleName string `yaml:"sso_role_name,omitempty"` + Regions []string `yaml:"regions,omitempty"` + Auth *ContextAuth `yaml:"auth,omitempty"` + Resources *ContextResources `yaml:"resources,omitempty"` +} + +// ContextAuth defines the credentials and identity independently from resource location. +type ContextAuth struct { + Type string `yaml:"type"` Profile string `yaml:"profile,omitempty"` - Region string `yaml:"region"` - AuthType string `yaml:"auth_type"` RoleArn string `yaml:"role_arn,omitempty"` ExternalID string `yaml:"external_id,omitempty"` SSOStartURL string `yaml:"sso_start_url,omitempty"` @@ -74,6 +89,12 @@ type ContextEntry struct { SSORoleName string `yaml:"sso_role_name,omitempty"` } +// ContextResources defines the default and selectable AWS resource regions. +type ContextResources struct { + DefaultRegion string `yaml:"default_region"` + Regions []string `yaml:"regions,omitempty"` +} + // contextEntry is the alias used internally for fileConfig unmarshalling. type contextEntry = ContextEntry @@ -88,6 +109,7 @@ type Config struct { SSORegion string SSOAccountID string SSORoleName string + Regions []string FavoriteServices []string FavoriteContexts []string BootSplash bool @@ -135,13 +157,70 @@ type ContextInfo struct { SSORegion string SSOAccountID string SSORoleName string + Regions []string Current bool Favorite bool } +type resolvedContextEntry struct { + Profile, Region, AuthType, RoleArn, ExternalID string + SSOStartURL, SSORegion, SSOAccountID, SSORoleName string + Regions []string +} + +func (c ContextEntry) resolved(defaultRegion string) resolvedContextEntry { + r := resolvedContextEntry{ + Profile: c.Profile, Region: c.Region, AuthType: c.AuthType, + RoleArn: c.RoleArn, ExternalID: c.ExternalID, + SSOStartURL: c.SSOStartURL, SSORegion: c.SSORegion, + SSOAccountID: c.SSOAccountID, SSORoleName: c.SSORoleName, + Regions: c.Regions, + } + if c.Auth != nil { + r.AuthType = c.Auth.Type + r.Profile = c.Auth.Profile + r.RoleArn = c.Auth.RoleArn + r.ExternalID = c.Auth.ExternalID + r.SSOStartURL = c.Auth.SSOStartURL + r.SSORegion = c.Auth.SSORegion + r.SSOAccountID = c.Auth.SSOAccountID + r.SSORoleName = c.Auth.SSORoleName + } + if c.Resources != nil { + r.Region = c.Resources.DefaultRegion + r.Regions = c.Resources.Regions + } + if r.Region == "" { + r.Region = defaultRegion + } + r.Regions = normalizeRegions(r.Region, r.Regions) + return r +} + +func normalizeRegions(defaultRegion string, regions []string) []string { + seen := make(map[string]struct{}, len(regions)+1) + result := make([]string, 0, len(regions)+1) + add := func(region string) { + region = strings.TrimSpace(region) + if region == "" { + return + } + if _, ok := seen[region]; ok { + return + } + seen[region] = struct{}{} + result = append(result, region) + } + add(defaultRegion) + for _, region := range regions { + add(region) + } + return result +} + // FilterText returns a lowercase string for keyword matching. func (c ContextInfo) FilterText() string { - return strings.ToLower(fmt.Sprintf("%s %s %s", c.Name, c.Profile, c.Region)) + return strings.ToLower(fmt.Sprintf("%s %s %s", c.Name, c.Profile, strings.Join(c.Regions, " "))) } // Load resolves config with priority: CLI flags > context > config file defaults > hardcoded defaults. @@ -173,24 +252,25 @@ func Load(cliProfile, cliRegion *string, configPath string) (*Config, error) { // New format: resolve current context var contextName, roleArn, externalID, ssoStartURL, ssoRegion, ssoAccountID, ssoRoleName string + var regions []string var authType AuthType if fc.Current != "" { for _, ctx := range fc.Contexts { if ctx.Name == fc.Current { + resolved := ctx.resolved(region) contextName = ctx.Name - authType = normalizeAuthType(ctx.AuthType) - if ctx.Profile != "" { - profile = ctx.Profile - } - if ctx.Region != "" { - region = ctx.Region + authType = normalizeAuthType(resolved.AuthType) + if resolved.Profile != "" { + profile = resolved.Profile } - roleArn = ctx.RoleArn - externalID = ctx.ExternalID - ssoStartURL = ctx.SSOStartURL - ssoRegion = ctx.SSORegion - ssoAccountID = ctx.SSOAccountID - ssoRoleName = ctx.SSORoleName + region = resolved.Region + regions = resolved.Regions + roleArn = resolved.RoleArn + externalID = resolved.ExternalID + ssoStartURL = resolved.SSOStartURL + ssoRegion = resolved.SSORegion + ssoAccountID = resolved.SSOAccountID + ssoRoleName = resolved.SSORoleName break } } @@ -202,6 +282,10 @@ func Load(cliProfile, cliRegion *string, configPath string) (*Config, error) { } if cliRegion != nil { region = *cliRegion + regions = normalizeRegions(region, regions) + } + if len(regions) == 0 { + regions = normalizeRegions(region, nil) } uniclog.Debug("config", "config resolved", @@ -223,6 +307,7 @@ func Load(cliProfile, cliRegion *string, configPath string) (*Config, error) { SSORegion: ssoRegion, SSOAccountID: ssoAccountID, SSORoleName: ssoRoleName, + Regions: regions, FavoriteServices: normalizeFavoriteServices(fc.Favorites.Services), FavoriteContexts: normalizeFavoriteContexts(fc.Favorites.Contexts), BootSplash: boolValue(fc.UI.BootSplash, false), @@ -255,22 +340,22 @@ func LoadNamedContext(configPath, name string) (*Config, error) { continue } - profile := ctx.Profile - if ctx.Region != "" { - region = ctx.Region - } + resolved := ctx.resolved(region) + profile := resolved.Profile + region = resolved.Region return &Config{ Profile: profile, Region: region, ContextName: ctx.Name, - AuthType: normalizeAuthType(ctx.AuthType), - RoleArn: ctx.RoleArn, - ExternalID: ctx.ExternalID, - SSOStartURL: ctx.SSOStartURL, - SSORegion: ctx.SSORegion, - SSOAccountID: ctx.SSOAccountID, - SSORoleName: ctx.SSORoleName, + AuthType: normalizeAuthType(resolved.AuthType), + RoleArn: resolved.RoleArn, + ExternalID: resolved.ExternalID, + SSOStartURL: resolved.SSOStartURL, + SSORegion: resolved.SSORegion, + SSOAccountID: resolved.SSOAccountID, + SSORoleName: resolved.SSORoleName, + Regions: resolved.Regions, FavoriteServices: normalizeFavoriteServices(fc.Favorites.Services), FavoriteContexts: normalizeFavoriteContexts(fc.Favorites.Contexts), BootSplash: boolValue(fc.UI.BootSplash, false), @@ -330,19 +415,28 @@ func Contexts(configPath string) ([]ContextInfo, error) { favoriteContexts := favoriteContextSet(fc.Favorites.Contexts) var infos []ContextInfo for _, ctx := range fc.Contexts { + defaultRegion := fc.Defaults.Region + if defaultRegion == "" { + defaultRegion = fc.DefaultRegion + } + if defaultRegion == "" { + defaultRegion = DefaultRegion + } + resolved := ctx.resolved(defaultRegion) _, favorite := favoriteContexts[ctx.Name] infos = append(infos, ContextInfo{ Name: ctx.Name, Order: ctx.Order, - Profile: ctx.Profile, - Region: ctx.Region, - AuthType: ctx.AuthType, - RoleArn: ctx.RoleArn, - ExternalID: ctx.ExternalID, - SSOStartURL: ctx.SSOStartURL, - SSORegion: ctx.SSORegion, - SSOAccountID: ctx.SSOAccountID, - SSORoleName: ctx.SSORoleName, + Profile: resolved.Profile, + Region: resolved.Region, + AuthType: resolved.AuthType, + RoleArn: resolved.RoleArn, + ExternalID: resolved.ExternalID, + SSOStartURL: resolved.SSOStartURL, + SSORegion: resolved.SSORegion, + SSOAccountID: resolved.SSOAccountID, + SSORoleName: resolved.SSORoleName, + Regions: resolved.Regions, Current: ctx.Name == fc.Current, Favorite: favorite, }) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 96ac0fa..cda8e54 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "slices" "strings" "testing" @@ -275,6 +276,63 @@ func TestEffectiveSSORegionFallsBackToRegion(t *testing.T) { } } +func TestLoadReadsStructuredAuthAndResourceScope(t *testing.T) { + dir := t.TempDir() + path := writeUnicConfig(t, dir, ` +current: production +contexts: + - name: production + auth: + type: sso + sso_start_url: https://example.awsapps.com/start + sso_region: us-east-1 + sso_account_id: "123456789012" + sso_role_name: AdministratorAccess + resources: + default_region: ap-northeast-2 + regions: + - us-east-1 + - eu-west-1 +`) + + cfg, err := Load(nil, nil, path) + if err != nil { + t.Fatal(err) + } + if cfg.AuthType != AuthTypeSSO || cfg.SSORegion != "us-east-1" { + t.Fatalf("unexpected structured auth config: %+v", cfg) + } + if cfg.Region != "ap-northeast-2" { + t.Fatalf("expected default resource region ap-northeast-2, got %q", cfg.Region) + } + want := []string{"ap-northeast-2", "us-east-1", "eu-west-1"} + if !slices.Equal(cfg.Regions, want) { + t.Fatalf("expected regions %v, got %v", want, cfg.Regions) + } +} + +func TestLoadNormalizesLegacyResourceRegions(t *testing.T) { + dir := t.TempDir() + path := writeUnicConfig(t, dir, ` +current: production +contexts: + - name: production + auth_type: credential + profile: production + region: ap-northeast-2 + regions: [us-east-1, ap-northeast-2, us-east-1] +`) + + cfg, err := Load(nil, nil, path) + if err != nil { + t.Fatal(err) + } + want := []string{"ap-northeast-2", "us-east-1"} + if !slices.Equal(cfg.Regions, want) { + t.Fatalf("expected normalized regions %v, got %v", want, cfg.Regions) + } +} + func TestSetBootSplashEnabledWritesConfig(t *testing.T) { dir := t.TempDir() path := writeUnicConfig(t, dir, ` diff --git a/internal/services/aws/repository.go b/internal/services/aws/repository.go index 8f897f0..73dc364 100644 --- a/internal/services/aws/repository.go +++ b/internal/services/aws/repository.go @@ -374,6 +374,19 @@ func NewAwsRepository(ctx context.Context, cfg *config.Config) (*AwsRepository, uniclog.Info("aws", "repository created", "region", cfg.Region, "profile", cfg.Profile) + return newRepositoryFromConfig(awsCfg, cfg.Region, cfg.Profile), nil +} + +// ForRegion creates service clients for another region while reusing the +// repository's existing credentials provider. It does not repeat SSO login or +// assume-role authentication. +func (r *AwsRepository) ForRegion(region string) *AwsRepository { + awsCfg := r.awsCfg + awsCfg.Region = region + return newRepositoryFromConfig(awsCfg, region, r.Profile) +} + +func newRepositoryFromConfig(awsCfg aws.Config, region, profile string) *AwsRepository { return &AwsRepository{ EC2Client: ec2.NewFromConfig(awsCfg), SSMClient: ssm.NewFromConfig(awsCfg), @@ -396,10 +409,10 @@ func NewAwsRepository(ctx context.Context, cfg *config.Config) (*AwsRepository, ELBv2Client: elasticloadbalancingv2.NewFromConfig(awsCfg), S3Client: s3.NewFromConfig(awsCfg), LambdaClient: lambda.NewFromConfig(awsCfg), - Region: cfg.Region, - Profile: cfg.Profile, + Region: region, + Profile: profile, awsCfg: awsCfg, - }, nil + } } // ResolveCredentialEnv retrieves the current AWS credentials and returns them diff --git a/internal/services/aws/repository_test.go b/internal/services/aws/repository_test.go index 85bdc49..7e453c0 100644 --- a/internal/services/aws/repository_test.go +++ b/internal/services/aws/repository_test.go @@ -5,6 +5,9 @@ import ( "os" "path/filepath" "testing" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" ) func TestLoadBaseConfig_ExplicitProfileOverridesEnvCredentials(t *testing.T) { @@ -38,6 +41,29 @@ aws_secret_access_key = PROFILESECRET } } +func TestRepositoryForRegionReusesCredentials(t *testing.T) { + provider := credentials.NewStaticCredentialsProvider("KEY", "SECRET", "TOKEN") + repo := newRepositoryFromConfig(awssdk.Config{ + Region: "ap-northeast-2", + Credentials: provider, + }, "ap-northeast-2", "production") + + switched := repo.ForRegion("us-east-1") + if switched.Region != "us-east-1" { + t.Fatalf("expected switched region us-east-1, got %q", switched.Region) + } + if switched.Profile != "production" { + t.Fatalf("expected profile to be preserved, got %q", switched.Profile) + } + creds, err := switched.awsCfg.Credentials.Retrieve(context.Background()) + if err != nil { + t.Fatal(err) + } + if creds.AccessKeyID != "KEY" || creds.SessionToken != "TOKEN" { + t.Fatalf("expected credentials to be reused, got %+v", creds) + } +} + func TestLoadBaseConfig_UsesEnvCredentialsWhenProfileUnset(t *testing.T) { t.Setenv("AWS_ACCESS_KEY_ID", "ENVKEY") t.Setenv("AWS_SECRET_ACCESS_KEY", "ENVSECRET")