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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> <number>` still works for direct updates.

## Configuration
Expand Down
2 changes: 2 additions & 0 deletions docs/architecture.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/architecture.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 화면 계열
Expand Down
52 changes: 42 additions & 10 deletions internal/auth/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -62,50 +62,60 @@ 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
}
}

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 {
Expand Down Expand Up @@ -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)
Expand Down
103 changes: 100 additions & 3 deletions internal/auth/setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package auth

import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
Expand All @@ -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")
Expand Down Expand Up @@ -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 }

Expand All @@ -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)
}

Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading