diff --git a/cmd/compose/options.go b/cmd/compose/options.go index 948b66400..c71ee167f 100644 --- a/cmd/compose/options.go +++ b/cmd/compose/options.go @@ -17,9 +17,20 @@ package compose import ( + "context" "fmt" + "io" + "os" + "sort" + "strings" + "text/tabwriter" + "github.com/compose-spec/compose-go/v2/cli" + "github.com/compose-spec/compose-go/v2/template" "github.com/compose-spec/compose-go/v2/types" + "github.com/docker/cli/cli/command" + ui "github.com/docker/compose/v2/pkg/progress" + "github.com/docker/compose/v2/pkg/prompt" "github.com/docker/compose/v2/pkg/utils" ) @@ -72,3 +83,165 @@ func applyPlatforms(project *types.Project, buildForSinglePlatform bool) error { } return nil } + +// isRemoteConfig checks if the main compose file is from a remote source (OCI or Git) +func isRemoteConfig(dockerCli command.Cli, options buildOptions) bool { + if len(options.ConfigPaths) == 0 { + return false + } + remoteLoaders := options.remoteLoaders(dockerCli) + for _, loader := range remoteLoaders { + if loader.Accept(options.ConfigPaths[0]) { + return true + } + } + return false +} + +// checksForRemoteStack handles environment variable prompts for remote configurations +func checksForRemoteStack(ctx context.Context, dockerCli command.Cli, project *types.Project, options buildOptions, assumeYes bool, cmdEnvs []string) error { + if !isRemoteConfig(dockerCli, options) { + return nil + } + displayLocationRemoteStack(dockerCli, project, options) + return promptForInterpolatedVariables(ctx, dockerCli, options.ProjectOptions, assumeYes, cmdEnvs) +} + +// Prepare the values map and collect all variables info +type varInfo struct { + name string + value string + source string + required bool + defaultValue string +} + +// promptForInterpolatedVariables displays all variables and their values at once, +// then prompts for confirmation +func promptForInterpolatedVariables(ctx context.Context, dockerCli command.Cli, projectOptions *ProjectOptions, assumeYes bool, cmdEnvs []string) error { + if assumeYes { + return nil + } + + varsInfo, noVariables, err := extractInterpolationVariablesFromModel(ctx, dockerCli, projectOptions, cmdEnvs) + if err != nil { + return err + } + + if noVariables { + return nil + } + + displayInterpolationVariables(dockerCli.Out(), varsInfo) + + // Prompt for confirmation + userInput := prompt.NewPrompt(dockerCli.In(), dockerCli.Out()) + msg := "\nDo you want to proceed with these variables? [Y/n]: " + confirmed, err := userInput.Confirm(msg, true) + if err != nil { + return err + } + + if !confirmed { + return fmt.Errorf("operation cancelled by user") + } + + return nil +} + +func extractInterpolationVariablesFromModel(ctx context.Context, dockerCli command.Cli, projectOptions *ProjectOptions, cmdEnvs []string) ([]varInfo, bool, error) { + cmdEnvMap := extractEnvCLIDefined(cmdEnvs) + + // Create a model without interpolation to extract variables + opts := configOptions{ + noInterpolate: true, + ProjectOptions: projectOptions, + } + + model, err := opts.ToModel(ctx, dockerCli, nil, cli.WithoutEnvironmentResolution) + if err != nil { + return nil, false, err + } + + // Extract variables that need interpolation + variables := template.ExtractVariables(model, template.DefaultPattern) + if len(variables) == 0 { + return nil, true, nil + } + + var varsInfo []varInfo + proposedValues := make(map[string]string) + + for name, variable := range variables { + info := varInfo{ + name: name, + required: variable.Required, + defaultValue: variable.DefaultValue, + } + + // Determine value and source based on priority + if value, exists := cmdEnvMap[name]; exists { + info.value = value + info.source = "command-line" + proposedValues[name] = value + } else if value, exists := os.LookupEnv(name); exists { + info.value = value + info.source = "environment" + proposedValues[name] = value + } else if variable.DefaultValue != "" { + info.value = variable.DefaultValue + info.source = "compose file" + proposedValues[name] = variable.DefaultValue + } else { + info.value = "" + info.source = "none" + } + + varsInfo = append(varsInfo, info) + } + return varsInfo, false, nil +} + +func extractEnvCLIDefined(cmdEnvs []string) map[string]string { + // Parse command-line environment variables + cmdEnvMap := make(map[string]string) + for _, env := range cmdEnvs { + parts := strings.SplitN(env, "=", 2) + if len(parts) == 2 { + cmdEnvMap[parts[0]] = parts[1] + } + } + return cmdEnvMap +} + +func displayInterpolationVariables(writer io.Writer, varsInfo []varInfo) { + // Display all variables in a table format + _, _ = fmt.Fprintln(writer, "\nFound the following variables in configuration:") + + w := tabwriter.NewWriter(writer, 0, 0, 3, ' ', 0) + _, _ = fmt.Fprintln(w, "VARIABLE\tVALUE\tSOURCE\tREQUIRED\tDEFAULT") + sort.Slice(varsInfo, func(a, b int) bool { + return varsInfo[a].name < varsInfo[b].name + }) + for _, info := range varsInfo { + required := "no" + if info.required { + required = "yes" + } + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", + info.name, + info.value, + info.source, + required, + info.defaultValue, + ) + } + _ = w.Flush() +} + +func displayLocationRemoteStack(dockerCli command.Cli, project *types.Project, options buildOptions) { + mainComposeFile := options.ProjectOptions.ConfigPaths[0] + if ui.Mode != ui.ModeQuiet && ui.Mode != ui.ModeJSON { + _, _ = fmt.Fprintf(dockerCli.Out(), "Your compose stack %q is stored in %q\n", mainComposeFile, project.WorkingDir) + } +} diff --git a/cmd/compose/options_test.go b/cmd/compose/options_test.go index 866306e6c..3e1700b96 100644 --- a/cmd/compose/options_test.go +++ b/cmd/compose/options_test.go @@ -17,10 +17,19 @@ package compose import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "strings" "testing" "github.com/compose-spec/compose-go/v2/types" + "github.com/docker/cli/cli/streams" + "github.com/docker/compose/v2/pkg/mocks" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" ) func TestApplyPlatforms_InferFromRuntime(t *testing.T) { @@ -128,3 +137,146 @@ func TestApplyPlatforms_UnsupportedPlatform(t *testing.T) { `service "test" build.platforms does not support value set by DOCKER_DEFAULT_PLATFORM: commodore/64`) }) } + +func TestIsRemoteConfig(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + cli := mocks.NewMockCli(ctrl) + + tests := []struct { + name string + configPaths []string + want bool + }{ + { + name: "empty config paths", + configPaths: []string{}, + want: false, + }, + { + name: "local file", + configPaths: []string{"docker-compose.yaml"}, + want: false, + }, + { + name: "OCI reference", + configPaths: []string{"oci://registry.example.com/stack:latest"}, + want: true, + }, + { + name: "GIT reference", + configPaths: []string{"git://github.com/user/repo.git"}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := buildOptions{ + ProjectOptions: &ProjectOptions{ + ConfigPaths: tt.configPaths, + }, + } + got := isRemoteConfig(cli, opts) + require.Equal(t, tt.want, got) + }) + } +} + +func TestDisplayLocationRemoteStack(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + cli := mocks.NewMockCli(ctrl) + + buf := new(bytes.Buffer) + cli.EXPECT().Out().Return(streams.NewOut(buf)).AnyTimes() + + project := &types.Project{ + Name: "test-project", + WorkingDir: "/tmp/test", + } + + options := buildOptions{ + ProjectOptions: &ProjectOptions{ + ConfigPaths: []string{"oci://registry.example.com/stack:latest"}, + }, + } + + displayLocationRemoteStack(cli, project, options) + + output := buf.String() + require.Equal(t, output, fmt.Sprintf("Your compose stack %q is stored in %q\n", "oci://registry.example.com/stack:latest", "/tmp/test")) +} + +func TestDisplayInterpolationVariables(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // Create a temporary directory for the test + tmpDir, err := os.MkdirTemp("", "compose-test") + require.NoError(t, err) + defer func() { _ = os.RemoveAll(tmpDir) }() + + // Create a temporary compose file + composeContent := ` +services: + app: + image: nginx + environment: + - TEST_VAR=${TEST_VAR:?required} # required with default + - API_KEY=${API_KEY:?} # required without default + - DEBUG=${DEBUG:-true} # optional with default + - UNSET_VAR # optional without default +` + composePath := filepath.Join(tmpDir, "docker-compose.yml") + err = os.WriteFile(composePath, []byte(composeContent), 0o644) + require.NoError(t, err) + + buf := new(bytes.Buffer) + cli := mocks.NewMockCli(ctrl) + cli.EXPECT().Out().Return(streams.NewOut(buf)).AnyTimes() + + // Create ProjectOptions with the temporary compose file + projectOptions := &ProjectOptions{ + ConfigPaths: []string{composePath}, + } + + // Set up the context with necessary environment variables + ctx := context.Background() + _ = os.Setenv("TEST_VAR", "test-value") + _ = os.Setenv("API_KEY", "123456") + defer func() { + _ = os.Unsetenv("TEST_VAR") + _ = os.Unsetenv("API_KEY") + }() + + // Extract variables from the model + info, noVariables, err := extractInterpolationVariablesFromModel(ctx, cli, projectOptions, []string{}) + require.NoError(t, err) + require.False(t, noVariables) + + // Display the variables + displayInterpolationVariables(cli.Out(), info) + + // Expected output format with proper spacing + expected := "\nFound the following variables in configuration:\n" + + "VARIABLE VALUE SOURCE REQUIRED DEFAULT\n" + + "API_KEY 123456 environment yes \n" + + "DEBUG true compose file no true\n" + + "TEST_VAR test-value environment yes \n" + + // Normalize spaces and newlines for comparison + normalizeSpaces := func(s string) string { + // Replace multiple spaces with a single space + s = strings.Join(strings.Fields(strings.TrimSpace(s)), " ") + return s + } + + actualOutput := buf.String() + + // Compare normalized strings + require.Equal(t, + normalizeSpaces(expected), + normalizeSpaces(actualOutput), + "\nExpected:\n%s\nGot:\n%s", expected, actualOutput) +} diff --git a/cmd/compose/run.go b/cmd/compose/run.go index 01fa93f98..c5df79e02 100644 --- a/cmd/compose/run.go +++ b/cmd/compose/run.go @@ -224,6 +224,10 @@ func runRun(ctx context.Context, backend api.Service, project *types.Project, op return err } + if err := checksForRemoteStack(ctx, dockerCli, project, buildOpts, createOpts.AssumeYes, []string{}); err != nil { + return err + } + err = progress.Run(ctx, func(ctx context.Context) error { var buildForDeps *api.BuildOptions if !createOpts.noBuild { diff --git a/cmd/compose/up.go b/cmd/compose/up.go index 9ec637c95..6eee08a70 100644 --- a/cmd/compose/up.go +++ b/cmd/compose/up.go @@ -224,6 +224,10 @@ func runUp( project *types.Project, services []string, ) error { + if err := checksForRemoteStack(ctx, dockerCli, project, buildOptions, createOptions.AssumeYes, []string{}); err != nil { + return err + } + err := createOptions.Apply(project) if err != nil { return err @@ -301,7 +305,6 @@ func runUp( attachSet.RemoveAll(upOptions.noAttach...) attach = attachSet.Elements() } - displayLocationRemoteStack(dockerCli, project, buildOptions) timeout := time.Duration(upOptions.waitTimeout) * time.Second return backend.Up(ctx, project, api.UpOptions{ @@ -330,18 +333,3 @@ func setServiceScale(project *types.Project, name string, replicas int) error { project.Services[name] = service return nil } - -func displayLocationRemoteStack(dockerCli command.Cli, project *types.Project, options buildOptions) { - if len(options.ProjectOptions.ConfigPaths) == 0 { - return - } - mainComposeFile := options.ProjectOptions.ConfigPaths[0] - if ui.Mode != ui.ModeQuiet && ui.Mode != ui.ModeJSON { - for _, loader := range options.ProjectOptions.remoteLoaders(dockerCli) { - if loader.Accept(mainComposeFile) { - _, _ = fmt.Fprintf(dockerCli.Out(), "Your compose stack %q is stored in %q\n", mainComposeFile, project.WorkingDir) - return - } - } - } -}