Skip to content

Commit 1b5fa3b

Browse files
authored
feat(experiments): add experimental feature state (docker#11633)
Use environment variable for global opt-out and Docker Desktop (if available) to determine specific experiment states. In the future, we'll allow per-feature opt-in/opt-out via env vars as well, but currently there is a single `COMPOSE_EXPERIMENTAL` env var that can be used to opt-out of all experimental features independently of Docker Desktop configuration.
1 parent 86cd523 commit 1b5fa3b

File tree

10 files changed

+230
-90
lines changed

10 files changed

+230
-90
lines changed

cmd/compose/compose.go

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import (
3838
"github.com/docker/cli/cli/command"
3939
"github.com/docker/compose/v2/cmd/formatter"
4040
"github.com/docker/compose/v2/internal/desktop"
41+
"github.com/docker/compose/v2/internal/experimental"
4142
"github.com/docker/compose/v2/internal/tracing"
4243
"github.com/docker/compose/v2/pkg/api"
4344
"github.com/docker/compose/v2/pkg/compose"
@@ -66,6 +67,14 @@ const (
6667
ComposeEnvFiles = "COMPOSE_ENV_FILES"
6768
)
6869

70+
type Backend interface {
71+
api.Service
72+
73+
SetDesktopClient(cli *desktop.Client)
74+
75+
SetExperiments(experiments *experimental.State)
76+
}
77+
6978
// Command defines a compose CLI command as a func with args
7079
type Command func(context.Context, []string) error
7180

@@ -326,7 +335,7 @@ func RunningAsStandalone() bool {
326335
}
327336

328337
// RootCommand returns the compose command with its child commands
329-
func RootCommand(dockerCli command.Cli, backend api.Service) *cobra.Command { //nolint:gocyclo
338+
func RootCommand(dockerCli command.Cli, backend Backend) *cobra.Command { //nolint:gocyclo
330339
// filter out useless commandConn.CloseWrite warning message that can occur
331340
// when using a remote context that is unreachable: "commandConn.CloseWrite: commandconn: failed to wait: signal: killed"
332341
// https://github.com/docker/cli/blob/e1f24d3c93df6752d3c27c8d61d18260f141310c/cli/connhelper/commandconn/commandconn.go#L203-L215
@@ -337,6 +346,7 @@ func RootCommand(dockerCli command.Cli, backend api.Service) *cobra.Command { //
337346
"commandConn.CloseRead:",
338347
))
339348

349+
experiments := experimental.NewState()
340350
opts := ProjectOptions{}
341351
var (
342352
ansi string
@@ -486,20 +496,32 @@ func RootCommand(dockerCli command.Cli, backend api.Service) *cobra.Command { //
486496
cmd.SetContext(ctx)
487497

488498
// (6) Desktop integration
489-
if db, ok := backend.(desktop.IntegrationService); ok {
490-
if err := db.MaybeEnableDesktopIntegration(ctx); err != nil {
499+
var desktopCli *desktop.Client
500+
if !dryRun {
501+
if desktopCli, err = desktop.NewFromDockerClient(ctx, dockerCli); desktopCli != nil {
502+
logrus.Debugf("Enabled Docker Desktop integration (experimental) @ %s", desktopCli.Endpoint())
503+
backend.SetDesktopClient(desktopCli)
504+
} else if err != nil {
491505
// not fatal, Compose will still work but behave as though
492506
// it's not running as part of Docker Desktop
493507
logrus.Debugf("failed to enable Docker Desktop integration: %v", err)
508+
} else {
509+
logrus.Trace("Docker Desktop integration not enabled")
494510
}
495511
}
496512

513+
// (7) experimental features
514+
if err := experiments.Load(ctx, desktopCli); err != nil {
515+
logrus.Debugf("Failed to query feature flags from Desktop: %v", err)
516+
}
517+
backend.SetExperiments(experiments)
518+
497519
return nil
498520
},
499521
}
500522

501523
c.AddCommand(
502-
upCommand(&opts, dockerCli, backend),
524+
upCommand(&opts, dockerCli, backend, experiments),
503525
downCommand(&opts, dockerCli, backend),
504526
startCommand(&opts, dockerCli, backend),
505527
restartCommand(&opts, dockerCli, backend),

cmd/compose/up.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"github.com/compose-spec/compose-go/v2/types"
2828
"github.com/docker/cli/cli/command"
2929
"github.com/docker/compose/v2/cmd/formatter"
30+
"github.com/docker/compose/v2/internal/experimental"
3031
xprogress "github.com/moby/buildkit/util/progress/progressui"
3132
"github.com/spf13/cobra"
3233

@@ -76,7 +77,7 @@ func (opts upOptions) apply(project *types.Project, services []string) (*types.P
7677
return project, nil
7778
}
7879

79-
func upCommand(p *ProjectOptions, dockerCli command.Cli, backend api.Service) *cobra.Command {
80+
func upCommand(p *ProjectOptions, dockerCli command.Cli, backend api.Service, experiments *experimental.State) *cobra.Command {
8081
up := upOptions{}
8182
create := createOptions{}
8283
build := buildOptions{ProjectOptions: p}
@@ -96,7 +97,7 @@ func upCommand(p *ProjectOptions, dockerCli command.Cli, backend api.Service) *c
9697
if len(up.attach) != 0 && up.attachDependencies {
9798
return errors.New("cannot combine --attach and --attach-dependencies")
9899
}
99-
return runUp(ctx, dockerCli, backend, create, up, build, project, services)
100+
return runUp(ctx, dockerCli, backend, experiments, create, up, build, project, services)
100101
}),
101102
ValidArgsFunction: completeServiceNames(dockerCli, p),
102103
}
@@ -160,6 +161,7 @@ func runUp(
160161
ctx context.Context,
161162
dockerCli command.Cli,
162163
backend api.Service,
164+
_ *experimental.State,
163165
createOptions createOptions,
164166
upOptions upOptions,
165167
buildOptions buildOptions,

cmd/main.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ import (
3636

3737
func pluginMain() {
3838
plugin.Run(func(dockerCli command.Cli) *cobra.Command {
39-
backend := compose.NewComposeService(dockerCli)
39+
// TODO(milas): this cast is safe but we should not need to do this,
40+
// we should expose the concrete service type so that we do not need
41+
// to rely on the `api.Service` interface internally
42+
backend := compose.NewComposeService(dockerCli).(commands.Backend)
4043
cmd := commands.RootCommand(dockerCli, backend)
4144
originalPreRunE := cmd.PersistentPreRunE
4245
cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {

internal/desktop/client.go

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ import (
3030

3131
// Client for integration with Docker Desktop features.
3232
type Client struct {
33-
client *http.Client
33+
apiEndpoint string
34+
client *http.Client
3435
}
3536

3637
// NewClient creates a Desktop integration client for the provided in-memory
@@ -45,11 +46,16 @@ func NewClient(apiEndpoint string) *Client {
4546
transport = otelhttp.NewTransport(transport)
4647

4748
c := &Client{
48-
client: &http.Client{Transport: transport},
49+
apiEndpoint: apiEndpoint,
50+
client: &http.Client{Transport: transport},
4951
}
5052
return c
5153
}
5254

55+
func (c *Client) Endpoint() string {
56+
return c.apiEndpoint
57+
}
58+
5359
// Close releases any open connections.
5460
func (c *Client) Close() error {
5561
c.client.CloseIdleConnections()
@@ -84,6 +90,35 @@ func (c *Client) Ping(ctx context.Context) (*PingResponse, error) {
8490
return &ret, nil
8591
}
8692

93+
type FeatureFlagResponse map[string]FeatureFlagValue
94+
95+
type FeatureFlagValue struct {
96+
Enabled bool `json:"enabled"`
97+
}
98+
99+
func (c *Client) FeatureFlags(ctx context.Context) (FeatureFlagResponse, error) {
100+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, backendURL("/features"), http.NoBody)
101+
if err != nil {
102+
return nil, err
103+
}
104+
resp, err := c.client.Do(req)
105+
if err != nil {
106+
return nil, err
107+
}
108+
defer func() {
109+
_ = resp.Body.Close()
110+
}()
111+
if resp.StatusCode != http.StatusOK {
112+
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
113+
}
114+
115+
var ret FeatureFlagResponse
116+
if err := json.NewDecoder(resp.Body).Decode(&ret); err != nil {
117+
return nil, err
118+
}
119+
return ret, nil
120+
}
121+
87122
// backendURL generates a URL for the given API path.
88123
//
89124
// NOTE: Custom transport handles communication. The host is to create a valid

internal/desktop/discovery.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
Copyright 2024 Docker Compose CLI authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package desktop
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"strings"
23+
"time"
24+
25+
"github.com/docker/cli/cli/command"
26+
)
27+
28+
// engineLabelDesktopAddress is used to detect that Compose is running with a
29+
// Docker Desktop context. When this label is present, the value is an endpoint
30+
// address for an in-memory socket (AF_UNIX or named pipe).
31+
const engineLabelDesktopAddress = "com.docker.desktop.address"
32+
33+
// NewFromDockerClient creates a Desktop Client using the Docker CLI client to
34+
// auto-discover the Desktop CLI socket endpoint (if available).
35+
//
36+
// An error is returned if there is a failure communicating with Docker Desktop,
37+
// but even on success, a nil Client can be returned if the active Docker Engine
38+
// is not a Desktop instance.
39+
func NewFromDockerClient(ctx context.Context, dockerCli command.Cli) (*Client, error) {
40+
// safeguard to make sure this doesn't get stuck indefinitely
41+
ctx, cancel := context.WithTimeout(ctx, time.Second)
42+
defer cancel()
43+
44+
info, err := dockerCli.Client().Info(ctx)
45+
if err != nil {
46+
return nil, fmt.Errorf("querying server info: %w", err)
47+
}
48+
for _, l := range info.Labels {
49+
k, v, ok := strings.Cut(l, "=")
50+
if !ok || k != engineLabelDesktopAddress {
51+
continue
52+
}
53+
54+
desktopCli := NewClient(v)
55+
_, err := desktopCli.Ping(ctx)
56+
if err != nil {
57+
return nil, fmt.Errorf("pinging Desktop API: %w", err)
58+
}
59+
return desktopCli, nil
60+
}
61+
return nil, nil
62+
}

internal/desktop/integration.go

Lines changed: 0 additions & 25 deletions
This file was deleted.

internal/experimental/experimental.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/*
2+
Copyright 2024 Docker Compose CLI authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package experimental
18+
19+
import (
20+
"context"
21+
"os"
22+
"strconv"
23+
24+
"github.com/docker/compose/v2/internal/desktop"
25+
)
26+
27+
// envComposeExperimentalGlobal can be set to a falsy value (e.g. 0, false) to
28+
// globally opt-out of any experimental features in Compose.
29+
const envComposeExperimentalGlobal = "COMPOSE_EXPERIMENTAL"
30+
31+
// State of experiments (enabled/disabled) based on environment and local config.
32+
type State struct {
33+
// active is false if experiments have been opted-out of globally.
34+
active bool
35+
desktopValues desktop.FeatureFlagResponse
36+
}
37+
38+
func NewState() *State {
39+
// experimental features have individual controls, but users can opt out
40+
// of ALL experiments easily if desired
41+
experimentsActive := true
42+
if v := os.Getenv(envComposeExperimentalGlobal); v != "" {
43+
experimentsActive, _ = strconv.ParseBool(v)
44+
}
45+
return &State{
46+
active: experimentsActive,
47+
}
48+
}
49+
50+
func (s *State) Load(ctx context.Context, client *desktop.Client) error {
51+
if !s.active {
52+
// user opted out of experiments globally, no need to load state from
53+
// Desktop
54+
return nil
55+
}
56+
57+
if client == nil {
58+
// not running under Docker Desktop
59+
return nil
60+
}
61+
62+
desktopValues, err := client.FeatureFlags(ctx)
63+
if err != nil {
64+
return err
65+
}
66+
s.desktopValues = desktopValues
67+
return nil
68+
}
69+
70+
func (s *State) NavBar() bool {
71+
return s.determineFeatureState("ComposeNav")
72+
}
73+
74+
func (s *State) AutoFileShares() bool {
75+
return s.determineFeatureState("ComposeAutoFileShares")
76+
}
77+
78+
func (s *State) determineFeatureState(name string) bool {
79+
if !s.active || s.desktopValues == nil {
80+
return false
81+
}
82+
// TODO(milas): we should add individual environment variable overrides
83+
// per-experiment in a generic way here
84+
return s.desktopValues[name].Enabled
85+
}

internal/locker/pidfile_windows.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@
1919
package locker
2020

2121
import (
22+
"os"
23+
2224
"github.com/docker/docker/pkg/pidfile"
2325
"github.com/mitchellh/go-ps"
24-
"os"
2526
)
2627

2728
func (f *Pidfile) Lock() error {

pkg/compose/compose.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"sync"
2828

2929
"github.com/docker/compose/v2/internal/desktop"
30+
"github.com/docker/compose/v2/internal/experimental"
3031
"github.com/docker/docker/api/types/volume"
3132
"github.com/jonboulle/clockwork"
3233

@@ -62,8 +63,9 @@ func NewComposeService(dockerCli command.Cli) api.Service {
6263
}
6364

6465
type composeService struct {
65-
dockerCli command.Cli
66-
desktopCli *desktop.Client
66+
dockerCli command.Cli
67+
desktopCli *desktop.Client
68+
experiments *experimental.State
6769

6870
clock clockwork.Clock
6971
maxConcurrency int

0 commit comments

Comments
 (0)