mirror of
https://github.com/docker/compose.git
synced 2025-07-28 08:04:09 +02:00
display interpolation variables and their values when running a remote stack
Signed-off-by: Guillaume Lours <705411+glours@users.noreply.github.com>
This commit is contained in:
parent
eaf9800948
commit
7b88c5b0ed
@ -17,9 +17,20 @@
|
|||||||
package compose
|
package compose
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"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/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"
|
"github.com/docker/compose/v2/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -72,3 +83,165 @@ func applyPlatforms(project *types.Project, buildForSinglePlatform bool) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -17,10 +17,19 @@
|
|||||||
package compose
|
package compose
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/compose-spec/compose-go/v2/types"
|
"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"
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uber.org/mock/gomock"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestApplyPlatforms_InferFromRuntime(t *testing.T) {
|
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`)
|
`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)
|
||||||
|
}
|
||||||
|
@ -224,6 +224,10 @@ func runRun(ctx context.Context, backend api.Service, project *types.Project, op
|
|||||||
return err
|
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 {
|
err = progress.Run(ctx, func(ctx context.Context) error {
|
||||||
var buildForDeps *api.BuildOptions
|
var buildForDeps *api.BuildOptions
|
||||||
if !createOpts.noBuild {
|
if !createOpts.noBuild {
|
||||||
|
@ -224,6 +224,10 @@ func runUp(
|
|||||||
project *types.Project,
|
project *types.Project,
|
||||||
services []string,
|
services []string,
|
||||||
) error {
|
) error {
|
||||||
|
if err := checksForRemoteStack(ctx, dockerCli, project, buildOptions, createOptions.AssumeYes, []string{}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
err := createOptions.Apply(project)
|
err := createOptions.Apply(project)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -301,7 +305,6 @@ func runUp(
|
|||||||
attachSet.RemoveAll(upOptions.noAttach...)
|
attachSet.RemoveAll(upOptions.noAttach...)
|
||||||
attach = attachSet.Elements()
|
attach = attachSet.Elements()
|
||||||
}
|
}
|
||||||
displayLocationRemoteStack(dockerCli, project, buildOptions)
|
|
||||||
|
|
||||||
timeout := time.Duration(upOptions.waitTimeout) * time.Second
|
timeout := time.Duration(upOptions.waitTimeout) * time.Second
|
||||||
return backend.Up(ctx, project, api.UpOptions{
|
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
|
project.Services[name] = service
|
||||||
return nil
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user