Skip to content
Open
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
81 changes: 74 additions & 7 deletions cmd/lk/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -1938,9 +1938,14 @@ func getClientSettings(ctx context.Context) (map[string]string, error) {
// picker populated from server-reported available_regions when --region is
// unset and the CLI is interactive. In non-interactive mode an unset --region
// is an error so invocations fail loudly instead of silently defaulting.
//
// Regions the server flags in residency_warning_regions are annotated in the
// picker and confirmed before use; see confirmRegionResidency.
func resolveRegion(cmd *cli.Command, settingsMap map[string]string, title string) (string, error) {
warnRegions := splitSetting(settingsMap["residency_warning_regions"])

if region := cmd.String("region"); region != "" {
return region, nil
return region, confirmRegionResidency(cmd, region, settingsMap["project_data_region"], warnRegions)
}

availableRegionsStr, ok := settingsMap["available_regions"]
Expand All @@ -1950,30 +1955,93 @@ func resolveRegion(cmd *cli.Command, settingsMap map[string]string, title string
return "us-east", nil
}

regionOptions := strings.Split(availableRegionsStr, ",")
for i, r := range regionOptions {
regionOptions[i] = strings.TrimSpace(r)
}
regionOptions := splitSetting(availableRegionsStr)
slices.Sort(regionOptions)
slices.Reverse(regionOptions)

if SkipPrompts(cmd) {
return "", fmt.Errorf("non-interactive mode: --region flag must be specified, available regions: %v", regionOptions)
}

options := make([]huh.Option[string], 0, len(regionOptions))
for _, r := range regionOptions {
label := r
if slices.Contains(warnRegions, r) {
label = r + " " + util.Warn("⚠︎ GDPR compliance required")
}
options = append(options, huh.NewOption(label, r))
}

var region string
if err := huh.NewSelect[string]().
Title(title).
Options(huh.NewOptions(regionOptions...)...).
Options(options...).
Value(&region).
WithTheme(util.Theme).
Run(); err != nil {
return "", err
}
if err := confirmRegionResidency(cmd, region, settingsMap["project_data_region"], warnRegions); err != nil {
return "", err
}
out.Statusf("Using region [%s]", util.Accented(region))
return region, nil
}

// confirmRegionResidency asks the user to confirm deploying into a region the
// server flagged as a data-residency risk for this project — an EU region while
// the project stores its data elsewhere, meaning an agent that may serve EU end
// users records their data outside the EU.
//
// Deploying there is permitted, so this only prompts; in non-interactive mode it
// warns and proceeds rather than failing, since the region was named explicitly
// and blocking would break existing automation. An older server sends no warning
// regions, in which case nothing here fires.
func confirmRegionResidency(cmd *cli.Command, region, dataRegion string, warnRegions []string) error {
if !slices.Contains(warnRegions, region) {
return nil
}

detail := fmt.Sprintf(
"Data residency warning: This agent will run in [%s], but your project observability region is [%s]. Its recordings, transcripts, and traces will be stored outside the EU, which may breach GDPR. To keep this data in-region, create a new project with with an EU observability region.",
region,
dataRegion,
)
if SkipPrompts(cmd) {
out.Warnf("Deploying to [%s]. %s", region, detail)
return nil
}

confirmed := false
if err := huh.NewForm(huh.NewGroup(util.Confirm().
Title(fmt.Sprintf("Are you sure you want to deploy to [%s]?", region)).
Description(detail).
Affirmative("Deploy").
Negative("Cancel").
Value(&confirmed))).
WithTheme(util.Theme).
Run(); err != nil {
return err
}
if !confirmed {
return fmt.Errorf("deployment cancelled")
}
return nil
}

// splitSetting parses a comma-separated client setting into trimmed values,
// returning nil for an absent or empty setting.
func splitSetting(value string) []string {
if strings.TrimSpace(value) == "" {
return nil
}
parts := strings.Split(value, ",")
for i, p := range parts {
parts[i] = strings.TrimSpace(p)
}
return parts
}

func requireConfig(workingDir, tomlFilename string) (bool, error) {
if lkConfig != nil {
return true, nil
Expand All @@ -1998,7 +2066,6 @@ func generateAgentDockerfile(ctx context.Context, cmd *cli.Command) error {
if err != nil {
return err
}

projectType, err := agentfs.DetectProjectType(os.DirFS(workingDir))
if err != nil {
return noAgentError()
Expand Down
28 changes: 28 additions & 0 deletions cmd/lk/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -602,3 +602,31 @@ func TestResolveAttributes(t *testing.T) {
})
}
}

// An older server sends neither residency_warning_regions nor project_data_region.
// The advisory must then be inert rather than erroring or prompting, so a new CLI
// keeps working against a server that predates it.
func TestConfirmRegionResidencyWithoutServerSettings(t *testing.T) {
settingsMap := map[string]string{"available_regions": "us-east,eu-central"}
warnRegions := splitSetting(settingsMap["residency_warning_regions"])
require.Empty(t, warnRegions)

err := confirmRegionResidency(&cli.Command{}, "eu-central",
settingsMap["project_data_region"], warnRegions)
require.NoError(t, err)
}

// An EU project gets the param with an empty value; it must behave like absent.
func TestConfirmRegionResidencyWithEmptyWarningRegions(t *testing.T) {
warnRegions := splitSetting("")
require.Empty(t, warnRegions)
require.NoError(t, confirmRegionResidency(&cli.Command{}, "eu-central", "eu", warnRegions))
}

// A flagged region without a TTY (or with --yes) warns and proceeds rather than
// blocking, so existing automation deploying into the EU keeps working.
func TestConfirmRegionResidencyProceedsWhenPromptsSkipped(t *testing.T) {
warnRegions := splitSetting("eu-central")
require.Equal(t, []string{"eu-central"}, warnRegions)
require.NoError(t, confirmRegionResidency(&cli.Command{}, "eu-central", "us", warnRegions))
}
10 changes: 10 additions & 0 deletions pkg/util/theme.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,16 @@ func Dimmed(text string) string {
return Theme.Focused.Description.Render(text)
}

// Warn renders text in the active theme's Warning style
func Warn(text string) string {
return lipgloss.NewStyle().Foreground(activePalette.Warning).Render(text)
}

// Warn renders text in the active theme's Error style
func Err(text string) string {
return lipgloss.NewStyle().Foreground(activePalette.Error).Render(text)
}

// Hyperlink wraps label in an OSC 8 terminal hyperlink pointing at url. Terminals
// that support OSC 8 render label as a clickable link; others ignore the escape
// and show label unchanged. Gate calls on an interactive terminal (see
Expand Down
2 changes: 1 addition & 1 deletion version.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@
package livekitcli

const (
Version = "2.18.2"
Version = "2.18.3"
)
Loading