Skip to content

Commit 7b88c5b

Browse files
committed
display interpolation variables and their values when running a remote stack
Signed-off-by: Guillaume Lours <[email protected]>
1 parent eaf9800 commit 7b88c5b

File tree

4 files changed

+333
-16
lines changed

4 files changed

+333
-16
lines changed

cmd/compose/options.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,20 @@
1717
package compose
1818

1919
import (
20+
"context"
2021
"fmt"
22+
"io"
23+
"os"
24+
"sort"
25+
"strings"
26+
"text/tabwriter"
2127

28+
"github.com/compose-spec/compose-go/v2/cli"
29+
"github.com/compose-spec/compose-go/v2/template"
2230
"github.com/compose-spec/compose-go/v2/types"
31+
"github.com/docker/cli/cli/command"
32+
ui "github.com/docker/compose/v2/pkg/progress"
33+
"github.com/docker/compose/v2/pkg/prompt"
2334
"github.com/docker/compose/v2/pkg/utils"
2435
)
2536

@@ -72,3 +83,165 @@ func applyPlatforms(project *types.Project, buildForSinglePlatform bool) error {
7283
}
7384
return nil
7485
}
86+
87+
// isRemoteConfig checks if the main compose file is from a remote source (OCI or Git)
88+
func isRemoteConfig(dockerCli command.Cli, options buildOptions) bool {
89+
if len(options.ConfigPaths) == 0 {
90+
return false
91+
}
92+
remoteLoaders := options.remoteLoaders(dockerCli)
93+
for _, loader := range remoteLoaders {
94+
if loader.Accept(options.ConfigPaths[0]) {
95+
return true
96+
}
97+
}
98+
return false
99+
}
100+
101+
// checksForRemoteStack handles environment variable prompts for remote configurations
102+
func checksForRemoteStack(ctx context.Context, dockerCli command.Cli, project *types.Project, options buildOptions, assumeYes bool, cmdEnvs []string) error {
103+
if !isRemoteConfig(dockerCli, options) {
104+
return nil
105+
}
106+
displayLocationRemoteStack(dockerCli, project, options)
107+
return promptForInterpolatedVariables(ctx, dockerCli, options.ProjectOptions, assumeYes, cmdEnvs)
108+
}
109+
110+
// Prepare the values map and collect all variables info
111+
type varInfo struct {
112+
name string
113+
value string
114+
source string
115+
required bool
116+
defaultValue string
117+
}
118+
119+
// promptForInterpolatedVariables displays all variables and their values at once,
120+
// then prompts for confirmation
121+
func promptForInterpolatedVariables(ctx context.Context, dockerCli command.Cli, projectOptions *ProjectOptions, assumeYes bool, cmdEnvs []string) error {
122+
if assumeYes {
123+
return nil
124+
}
125+
126+
varsInfo, noVariables, err := extractInterpolationVariablesFromModel(ctx, dockerCli, projectOptions, cmdEnvs)
127+
if err != nil {
128+
return err
129+
}
130+
131+
if noVariables {
132+
return nil
133+
}
134+
135+
displayInterpolationVariables(dockerCli.Out(), varsInfo)
136+
137+
// Prompt for confirmation
138+
userInput := prompt.NewPrompt(dockerCli.In(), dockerCli.Out())
139+
msg := "\nDo you want to proceed with these variables? [Y/n]: "
140+
confirmed, err := userInput.Confirm(msg, true)
141+
if err != nil {
142+
return err
143+
}
144+
145+
if !confirmed {
146+
return fmt.Errorf("operation cancelled by user")
147+
}
148+
149+
return nil
150+
}
151+
152+
func extractInterpolationVariablesFromModel(ctx context.Context, dockerCli command.Cli, projectOptions *ProjectOptions, cmdEnvs []string) ([]varInfo, bool, error) {
153+
cmdEnvMap := extractEnvCLIDefined(cmdEnvs)
154+
155+
// Create a model without interpolation to extract variables
156+
opts := configOptions{
157+
noInterpolate: true,
158+
ProjectOptions: projectOptions,
159+
}
160+
161+
model, err := opts.ToModel(ctx, dockerCli, nil, cli.WithoutEnvironmentResolution)
162+
if err != nil {
163+
return nil, false, err
164+
}
165+
166+
// Extract variables that need interpolation
167+
variables := template.ExtractVariables(model, template.DefaultPattern)
168+
if len(variables) == 0 {
169+
return nil, true, nil
170+
}
171+
172+
var varsInfo []varInfo
173+
proposedValues := make(map[string]string)
174+
175+
for name, variable := range variables {
176+
info := varInfo{
177+
name: name,
178+
required: variable.Required,
179+
defaultValue: variable.DefaultValue,
180+
}
181+
182+
// Determine value and source based on priority
183+
if value, exists := cmdEnvMap[name]; exists {
184+
info.value = value
185+
info.source = "command-line"
186+
proposedValues[name] = value
187+
} else if value, exists := os.LookupEnv(name); exists {
188+
info.value = value
189+
info.source = "environment"
190+
proposedValues[name] = value
191+
} else if variable.DefaultValue != "" {
192+
info.value = variable.DefaultValue
193+
info.source = "compose file"
194+
proposedValues[name] = variable.DefaultValue
195+
} else {
196+
info.value = "<unset>"
197+
info.source = "none"
198+
}
199+
200+
varsInfo = append(varsInfo, info)
201+
}
202+
return varsInfo, false, nil
203+
}
204+
205+
func extractEnvCLIDefined(cmdEnvs []string) map[string]string {
206+
// Parse command-line environment variables
207+
cmdEnvMap := make(map[string]string)
208+
for _, env := range cmdEnvs {
209+
parts := strings.SplitN(env, "=", 2)
210+
if len(parts) == 2 {
211+
cmdEnvMap[parts[0]] = parts[1]
212+
}
213+
}
214+
return cmdEnvMap
215+
}
216+
217+
func displayInterpolationVariables(writer io.Writer, varsInfo []varInfo) {
218+
// Display all variables in a table format
219+
_, _ = fmt.Fprintln(writer, "\nFound the following variables in configuration:")
220+
221+
w := tabwriter.NewWriter(writer, 0, 0, 3, ' ', 0)
222+
_, _ = fmt.Fprintln(w, "VARIABLE\tVALUE\tSOURCE\tREQUIRED\tDEFAULT")
223+
sort.Slice(varsInfo, func(a, b int) bool {
224+
return varsInfo[a].name < varsInfo[b].name
225+
})
226+
for _, info := range varsInfo {
227+
required := "no"
228+
if info.required {
229+
required = "yes"
230+
}
231+
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
232+
info.name,
233+
info.value,
234+
info.source,
235+
required,
236+
info.defaultValue,
237+
)
238+
}
239+
_ = w.Flush()
240+
}
241+
242+
func displayLocationRemoteStack(dockerCli command.Cli, project *types.Project, options buildOptions) {
243+
mainComposeFile := options.ProjectOptions.ConfigPaths[0]
244+
if ui.Mode != ui.ModeQuiet && ui.Mode != ui.ModeJSON {
245+
_, _ = fmt.Fprintf(dockerCli.Out(), "Your compose stack %q is stored in %q\n", mainComposeFile, project.WorkingDir)
246+
}
247+
}

cmd/compose/options_test.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,19 @@
1717
package compose
1818

1919
import (
20+
"bytes"
21+
"context"
22+
"fmt"
23+
"os"
24+
"path/filepath"
25+
"strings"
2026
"testing"
2127

2228
"github.com/compose-spec/compose-go/v2/types"
29+
"github.com/docker/cli/cli/streams"
30+
"github.com/docker/compose/v2/pkg/mocks"
2331
"github.com/stretchr/testify/require"
32+
"go.uber.org/mock/gomock"
2433
)
2534

2635
func TestApplyPlatforms_InferFromRuntime(t *testing.T) {
@@ -128,3 +137,146 @@ func TestApplyPlatforms_UnsupportedPlatform(t *testing.T) {
128137
`service "test" build.platforms does not support value set by DOCKER_DEFAULT_PLATFORM: commodore/64`)
129138
})
130139
}
140+
141+
func TestIsRemoteConfig(t *testing.T) {
142+
ctrl := gomock.NewController(t)
143+
defer ctrl.Finish()
144+
cli := mocks.NewMockCli(ctrl)
145+
146+
tests := []struct {
147+
name string
148+
configPaths []string
149+
want bool
150+
}{
151+
{
152+
name: "empty config paths",
153+
configPaths: []string{},
154+
want: false,
155+
},
156+
{
157+
name: "local file",
158+
configPaths: []string{"docker-compose.yaml"},
159+
want: false,
160+
},
161+
{
162+
name: "OCI reference",
163+
configPaths: []string{"oci://registry.example.com/stack:latest"},
164+
want: true,
165+
},
166+
{
167+
name: "GIT reference",
168+
configPaths: []string{"git://github.com/user/repo.git"},
169+
want: true,
170+
},
171+
}
172+
173+
for _, tt := range tests {
174+
t.Run(tt.name, func(t *testing.T) {
175+
opts := buildOptions{
176+
ProjectOptions: &ProjectOptions{
177+
ConfigPaths: tt.configPaths,
178+
},
179+
}
180+
got := isRemoteConfig(cli, opts)
181+
require.Equal(t, tt.want, got)
182+
})
183+
}
184+
}
185+
186+
func TestDisplayLocationRemoteStack(t *testing.T) {
187+
ctrl := gomock.NewController(t)
188+
defer ctrl.Finish()
189+
cli := mocks.NewMockCli(ctrl)
190+
191+
buf := new(bytes.Buffer)
192+
cli.EXPECT().Out().Return(streams.NewOut(buf)).AnyTimes()
193+
194+
project := &types.Project{
195+
Name: "test-project",
196+
WorkingDir: "/tmp/test",
197+
}
198+
199+
options := buildOptions{
200+
ProjectOptions: &ProjectOptions{
201+
ConfigPaths: []string{"oci://registry.example.com/stack:latest"},
202+
},
203+
}
204+
205+
displayLocationRemoteStack(cli, project, options)
206+
207+
output := buf.String()
208+
require.Equal(t, output, fmt.Sprintf("Your compose stack %q is stored in %q\n", "oci://registry.example.com/stack:latest", "/tmp/test"))
209+
}
210+
211+
func TestDisplayInterpolationVariables(t *testing.T) {
212+
ctrl := gomock.NewController(t)
213+
defer ctrl.Finish()
214+
215+
// Create a temporary directory for the test
216+
tmpDir, err := os.MkdirTemp("", "compose-test")
217+
require.NoError(t, err)
218+
defer func() { _ = os.RemoveAll(tmpDir) }()
219+
220+
// Create a temporary compose file
221+
composeContent := `
222+
services:
223+
app:
224+
image: nginx
225+
environment:
226+
- TEST_VAR=${TEST_VAR:?required} # required with default
227+
- API_KEY=${API_KEY:?} # required without default
228+
- DEBUG=${DEBUG:-true} # optional with default
229+
- UNSET_VAR # optional without default
230+
`
231+
composePath := filepath.Join(tmpDir, "docker-compose.yml")
232+
err = os.WriteFile(composePath, []byte(composeContent), 0o644)
233+
require.NoError(t, err)
234+
235+
buf := new(bytes.Buffer)
236+
cli := mocks.NewMockCli(ctrl)
237+
cli.EXPECT().Out().Return(streams.NewOut(buf)).AnyTimes()
238+
239+
// Create ProjectOptions with the temporary compose file
240+
projectOptions := &ProjectOptions{
241+
ConfigPaths: []string{composePath},
242+
}
243+
244+
// Set up the context with necessary environment variables
245+
ctx := context.Background()
246+
_ = os.Setenv("TEST_VAR", "test-value")
247+
_ = os.Setenv("API_KEY", "123456")
248+
defer func() {
249+
_ = os.Unsetenv("TEST_VAR")
250+
_ = os.Unsetenv("API_KEY")
251+
}()
252+
253+
// Extract variables from the model
254+
info, noVariables, err := extractInterpolationVariablesFromModel(ctx, cli, projectOptions, []string{})
255+
require.NoError(t, err)
256+
require.False(t, noVariables)
257+
258+
// Display the variables
259+
displayInterpolationVariables(cli.Out(), info)
260+
261+
// Expected output format with proper spacing
262+
expected := "\nFound the following variables in configuration:\n" +
263+
"VARIABLE VALUE SOURCE REQUIRED DEFAULT\n" +
264+
"API_KEY 123456 environment yes \n" +
265+
"DEBUG true compose file no true\n" +
266+
"TEST_VAR test-value environment yes \n"
267+
268+
// Normalize spaces and newlines for comparison
269+
normalizeSpaces := func(s string) string {
270+
// Replace multiple spaces with a single space
271+
s = strings.Join(strings.Fields(strings.TrimSpace(s)), " ")
272+
return s
273+
}
274+
275+
actualOutput := buf.String()
276+
277+
// Compare normalized strings
278+
require.Equal(t,
279+
normalizeSpaces(expected),
280+
normalizeSpaces(actualOutput),
281+
"\nExpected:\n%s\nGot:\n%s", expected, actualOutput)
282+
}

cmd/compose/run.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,10 @@ func runRun(ctx context.Context, backend api.Service, project *types.Project, op
224224
return err
225225
}
226226

227+
if err := checksForRemoteStack(ctx, dockerCli, project, buildOpts, createOpts.AssumeYes, []string{}); err != nil {
228+
return err
229+
}
230+
227231
err = progress.Run(ctx, func(ctx context.Context) error {
228232
var buildForDeps *api.BuildOptions
229233
if !createOpts.noBuild {

0 commit comments

Comments
 (0)