diff --git a/README.md b/README.md index bf90ec0..bc1e469 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ unic context unset `unic ecr login` prints a machine-usable container registry login command to `stdout` and can optionally copy it to the clipboard with `--copy`. Both flows now include a `UNIC_CONTEXT` marker in the generated exports so the TUI can show which shell context is currently active. Contexts can be prioritized in the setup picker with an `order` field in config. -In the CLI `unic context setup` flow, the picker now filters contexts, SSO accounts, and SSO roles as you type, with arrow-key navigation and Enter to confirm. +In the CLI `unic context setup` flow, the picker filters contexts, SSO accounts, SSO roles, and configured resource regions as you type, with arrow-key navigation and Enter to confirm. Multi-region contexts prompt for the shell session region after account/role selection; single-region contexts skip that step. The selection changes `AWS_REGION` and `AWS_DEFAULT_REGION` in the generated exports without modifying the context's persisted default region. Use `unic context order` to open reorder mode, choose a context with `↑/↓` or `j/k`, press `Enter` to start moving it, then press `Enter` again to save. `unic context order ` still works for direct updates. ## Configuration diff --git a/docs/architecture.en.md b/docs/architecture.en.md index 8f04610..1ed1bad 100644 --- a/docs/architecture.en.md +++ b/docs/architecture.en.md @@ -195,6 +195,8 @@ Two shapes exist: 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. +`unic context setup` also treats the active resource region as session state. After any required SSO account and role selection, multi-region contexts prompt for a resource region and export it through `AWS_REGION` and `AWS_DEFAULT_REGION`. The persisted default region is unchanged, and single-region contexts skip the picker. + 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 7e7b238..9478f45 100644 --- a/docs/architecture.ko.md +++ b/docs/architecture.ko.md @@ -195,6 +195,8 @@ UNIC은 현재 세 가지 인증 모드를 지원한다. 컨텍스트는 구조화된 `auth`와 `resources` 섹션을 사용해 인증 정보와 리소스 위치를 분리할 수 있다. `auth.sso_region`은 SSO 로그인과 `GetRoleCredentials`에 사용하고, `resources.default_region`은 최초 리소스 리전, `resources.regions`는 런타임 리전 선택기에 노출할 리전 목록이다. 리전 전환 시 기존 credential provider를 재사용하고 리전별 SDK client만 다시 생성한다. +`unic context setup`도 활성 리전을 세션 상태로 취급한다. 필요한 SSO account와 role을 선택한 뒤 다중 리전 컨텍스트라면 리소스 리전을 추가로 선택하고, 선택값을 `AWS_REGION`과 `AWS_DEFAULT_REGION`으로 export한다. 저장된 기본 리전은 변경하지 않으며 단일 리전 컨텍스트는 선택 단계를 생략한다. + 기존 flat 필드도 계속 지원한다. `region`은 `resources.default_region`, `regions`는 선택 가능한 리전 목록, `sso_region`은 `auth.sso_region`에 대응한다. 리전 목록이 없으면 이전과 동일한 단일 리전 동작을 유지한다. ## TUI 화면 계열 diff --git a/internal/auth/setup.go b/internal/auth/setup.go index 4b9575c..1aa581c 100644 --- a/internal/auth/setup.go +++ b/internal/auth/setup.go @@ -49,7 +49,7 @@ func SetupContext(ctx context.Context, configPath string, in io.Reader, errOut i return "", err } - finalName, err := resolveContextSelection(ctx, configPath, selected, in, reader, errOut) + finalName, selectedRegion, err := resolveContextSelection(ctx, configPath, selected, in, reader, errOut) if err != nil { return "", err } @@ -62,6 +62,7 @@ func SetupContext(ctx context.Context, configPath string, in io.Reader, errOut i if err != nil { return "", err } + finalCfg.Region = selectedRegion if finalCfg.AuthType == config.AuthTypeConsoleLogin { if err := runConsoleLoginFn(finalCfg); err != nil { return "", err @@ -69,43 +70,52 @@ func SetupContext(ctx context.Context, configPath string, in io.Reader, errOut i } fmt.Fprintf(errOut, "Selected context: %s\n", finalName) + if len(contextRegions(selected.Region, selected.Regions)) > 1 { + fmt.Fprintf(errOut, "Selected resource region: %s\n", selectedRegion) + } return buildEnvExportsFn(ctx, finalCfg) } -func resolveContextSelection(ctx context.Context, configPath string, selected config.ContextInfo, rawIn io.Reader, in *bufio.Reader, errOut io.Writer) (string, error) { +func resolveContextSelection(ctx context.Context, configPath string, selected config.ContextInfo, rawIn io.Reader, in *bufio.Reader, errOut io.Writer) (string, string, error) { if !IsBaseSSOContext(selected) { - return selected.Name, nil + region, err := chooseResourceRegion(rawIn, in, errOut, selected.Region, selected.Regions) + return selected.Name, region, err } fmt.Fprintf(errOut, "Listing AWS accounts for %s ...\n", selected.Name) accounts, err := ListSSOContextAccounts(ctx, configPath, selected) if err != nil { - return "", err + return "", "", err } if len(accounts) == 0 { - return "", fmt.Errorf("no SSO accounts available for %q", selected.Name) + return "", "", fmt.Errorf("no SSO accounts available for %q", selected.Name) } account, err := chooseSSOAccount(rawIn, in, errOut, accounts) if err != nil { - return "", err + return "", "", err } fmt.Fprintf(errOut, "Listing roles for account %s ...\n", account.ID) roles, err := ListSSOContextRoles(ctx, configPath, selected, account.ID) if err != nil { - return "", err + return "", "", err } if len(roles) == 0 { - return "", fmt.Errorf("no SSO roles available for account %s", account.ID) + return "", "", fmt.Errorf("no SSO roles available for account %s", account.ID) } role, err := chooseSSORole(rawIn, in, errOut, roles) if err != nil { - return "", err + return "", "", err } - return ResolveSSOContextSelection(configPath, selected, account, role) + region, err := chooseResourceRegion(rawIn, in, errOut, selected.Region, selected.Regions) + if err != nil { + return "", "", err + } + name, err := ResolveSSOContextSelection(configPath, selected, account, role) + return name, region, err } func IsBaseSSOContext(selected config.ContextInfo) bool { @@ -185,6 +195,28 @@ func chooseSSORole(rawIn io.Reader, in *bufio.Reader, errOut io.Writer, roles [] return roles[index], nil } +func chooseResourceRegion(rawIn io.Reader, in *bufio.Reader, errOut io.Writer, defaultRegion string, regions []string) (string, error) { + regions = contextRegions(defaultRegion, regions) + if len(regions) == 0 { + return config.DefaultRegion, nil + } + if len(regions) == 1 { + return regions[0], nil + } + index, err := chooseFilteredIndex(rawIn, in, errOut, "resource regions", regions, func(region string) string { + if region == defaultRegion { + return region + " (default)" + } + return region + }, func(region, query string) bool { + return containsFold(region, query) + }) + if err != nil { + return "", err + } + return regions[index], nil +} + func chooseIndex(in *bufio.Reader, errOut io.Writer, label string, count int) (int, error) { for { fmt.Fprintf(errOut, "%s [1-%d]: ", label, count) diff --git a/internal/auth/setup_test.go b/internal/auth/setup_test.go index 4978312..8b79b3b 100644 --- a/internal/auth/setup_test.go +++ b/internal/auth/setup_test.go @@ -2,6 +2,8 @@ package auth import ( "context" + "errors" + "io" "os" "path/filepath" "strings" @@ -11,6 +13,92 @@ import ( awsservice "unic/internal/services/aws" ) +func TestSetupContextSelectsResourceRegionForShellSession(t *testing.T) { + origBuild := buildEnvExportsFn + defer func() { buildEnvExportsFn = origBuild }() + + var exportedRegion string + buildEnvExportsFn = func(_ context.Context, cfg *config.Config) (string, error) { + exportedRegion = cfg.Region + return "export AWS_REGION='" + cfg.Region + "'", nil + } + + dir := t.TempDir() + path := writeConfig(t, dir, ` +current: old +contexts: + - name: old + auth_type: credential + profile: old + region: ap-southeast-1 + - name: production + auth: + type: credential + profile: production + resources: + default_region: ap-northeast-2 + regions: + - us-east-1 +`) + + var stderr strings.Builder + exports, err := SetupContext(context.Background(), path, strings.NewReader("2\n2\n"), &stderr) + if err != nil { + t.Fatal(err) + } + if exportedRegion != "us-east-1" || !strings.Contains(exports, "us-east-1") { + t.Fatalf("expected selected shell region us-east-1, got region=%q exports=%q", exportedRegion, exports) + } + if !strings.Contains(stderr.String(), "Available resource regions") { + t.Fatalf("expected resource region picker, got %q", stderr.String()) + } + + stored, err := config.LoadNamedContext(path, "production") + if err != nil { + t.Fatal(err) + } + if stored.Region != "ap-northeast-2" { + t.Fatalf("expected persisted default region to remain unchanged, got %q", stored.Region) + } + current, err := config.Load(nil, nil, path) + if err != nil { + t.Fatal(err) + } + if current.ContextName != "production" { + t.Fatalf("expected current context production, got %q", current.ContextName) + } +} + +func TestSetupContextRegionCancellationDoesNotChangeCurrentContext(t *testing.T) { + dir := t.TempDir() + path := writeConfig(t, dir, ` +current: old +contexts: + - name: old + auth_type: credential + profile: old + region: ap-southeast-1 + - name: production + auth_type: credential + profile: production + region: ap-northeast-2 + regions: + - us-east-1 +`) + + _, err := SetupContext(context.Background(), path, strings.NewReader("2\n"), &strings.Builder{}) + if !errors.Is(err, io.EOF) { + t.Fatalf("expected region selection cancellation, got %v", err) + } + cfg, loadErr := config.Load(nil, nil, path) + if loadErr != nil { + t.Fatal(loadErr) + } + if cfg.ContextName != "old" { + t.Fatalf("expected current context to remain old, got %q", cfg.ContextName) + } +} + func writeConfig(t *testing.T, dir, content string) string { t.Helper() path := filepath.Join(dir, "config.yaml") @@ -39,7 +127,7 @@ func TestSetupContextUpsertsConcreteSSOContext(t *testing.T) { return []awsservice.SSORole{{Name: "AdministratorAccess"}}, nil } buildEnvExportsFn = func(ctx context.Context, cfg *config.Config) (string, error) { - return "export AWS_REGION='ap-northeast-2'", nil + return "export AWS_REGION='" + cfg.Region + "'", nil } runConsoleLoginFn = func(cfg *config.Config) error { return nil } @@ -52,14 +140,16 @@ contexts: auth_type: sso sso_start_url: https://example.awsapps.com/start region: ap-northeast-2 + regions: + - us-east-1 `) var stderr strings.Builder - exports, err := SetupContext(context.Background(), path, strings.NewReader("1\n1\n1\n"), &stderr) + exports, err := SetupContext(context.Background(), path, strings.NewReader("1\n1\n1\n2\n"), &stderr) if err != nil { t.Fatal(err) } - if !strings.Contains(exports, "export AWS_REGION='ap-northeast-2'") { + if !strings.Contains(exports, "export AWS_REGION='us-east-1'") { t.Fatalf("expected exports, got %q", exports) } @@ -78,6 +168,13 @@ contexts: if len(infos) != 2 { t.Fatalf("expected 2 contexts after upsert, got %d", len(infos)) } + concrete, err := config.LoadNamedContext(path, "base-sso-123456789012-administratoraccess") + if err != nil { + t.Fatal(err) + } + if concrete.Region != "ap-northeast-2" || len(concrete.Regions) != 2 || concrete.Regions[1] != "us-east-1" { + t.Fatalf("expected generated context to preserve default and additional regions, got default=%q regions=%v", concrete.Region, concrete.Regions) + } } func TestSetupContextRunsConsoleLoginForConsoleLoginContext(t *testing.T) { diff --git a/internal/cli/context.go b/internal/cli/context.go index db094bd..2fe36be 100644 --- a/internal/cli/context.go +++ b/internal/cli/context.go @@ -105,7 +105,7 @@ func newContextSetupCmd() *cobra.Command { return &cobra.Command{ Use: "setup", Short: "Interactively select a context and copy shell exports to the clipboard", - Long: "Select a context, resolve any required SSO account/role, set it as current, and copy shell export commands to the clipboard.", + Long: "Select a context, resolve any required SSO account/role and resource region, set it as current, and copy shell export commands to the clipboard.", RunE: func(cmd *cobra.Command, args []string) error { configPath, err := defaultPathFn() if err != nil {