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
173 changes: 173 additions & 0 deletions cmd/compose/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
}
}
152 changes: 152 additions & 0 deletions cmd/compose/options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
4 changes: 4 additions & 0 deletions cmd/compose/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading