Repository: docker/compose
Pull request #12604 (2025-03-05 11:07:49)

display interpolation variables and their values when running a remote stack
**What I did**
Display a message showing the interpolation variables and their values before running a OCI or GIT Compose stack 

```
$ docker compose -e REGISTRY=myregistry.com up

Found the following variables in configuration:
VARIABLE     VALUE                SOURCE        REQUIRED    DEFAULT
REGISTRY     myregistry.com      command-line  yes         
TAG          v1.0                environment    no          latest
DOCKERFILE   Dockerfile          default        no          Dockerfile
API_KEY      <unset>            none           no          

Do you want to proceed with these variables? [Y/n]:
```

**Related issue**
https://docker.atlassian.net/browse/APCLI-874

**(not mandatory) A picture of a cute animal, if possible in relation to what you did**
![image](https://github.com/user-attachments/assets/2a8b4fde-2565-4a78-a63b-0cbd84968e02)



Patch:
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 = "<unset>"
+			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/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
-			}
-		}
-	}
-}


Test patch:
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)
+}