diff --git a/cmd/lk/agent.go b/cmd/lk/agent.go index fbca6857f..861774ba9 100644 --- a/cmd/lk/agent.go +++ b/cmd/lk/agent.go @@ -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"] @@ -1950,10 +1955,7 @@ 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) @@ -1961,19 +1963,85 @@ func resolveRegion(cmd *cli.Command, settingsMap map[string]string, title string 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(®ion). 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 @@ -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() diff --git a/cmd/lk/agent_test.go b/cmd/lk/agent_test.go index 1bfe40cdc..373e890f8 100644 --- a/cmd/lk/agent_test.go +++ b/cmd/lk/agent_test.go @@ -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)) +} diff --git a/pkg/util/theme.go b/pkg/util/theme.go index 96d7d434d..e45008f00 100644 --- a/pkg/util/theme.go +++ b/pkg/util/theme.go @@ -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 diff --git a/version.go b/version.go index 469ccdb23..2da43e986 100644 --- a/version.go +++ b/version.go @@ -15,5 +15,5 @@ package livekitcli const ( - Version = "2.18.2" + Version = "2.18.3" )