diff --git a/cmd/cli/commands/context.go b/cmd/cli/commands/context.go index 4a1d1489c..2ddd4bef3 100644 --- a/cmd/cli/commands/context.go +++ b/cmd/cli/commands/context.go @@ -2,19 +2,28 @@ package commands import ( "bytes" + "context" "fmt" "net/url" "os" "path/filepath" "sort" + "strconv" "time" "github.com/docker/cli/cli/command" "github.com/docker/model-runner/cmd/cli/commands/formatter" + "github.com/docker/model-runner/cmd/cli/desktop" "github.com/docker/model-runner/cmd/cli/pkg/modelctx" + "github.com/docker/model-runner/cmd/cli/pkg/standalone" + "github.com/docker/model-runner/cmd/cli/pkg/types" "github.com/spf13/cobra" ) +// defaultContextDetectTimeout is the maximum time allowed for detecting the +// engine kind when building the synthetic "default" context row. +const defaultContextDetectTimeout = 2 * time.Second + // newContextCmd returns the "docker model context" parent command. Its // subcommands manage named Model Runner contexts stored on disk, so they do // not require a running backend and override PersistentPreRunE accordingly. @@ -66,6 +75,52 @@ func dockerConfigDir() (string, error) { return filepath.Join(home, ".docker"), nil } +// resolveKindHost maps an engine kind and TLS preference to the host string +// shown in "context ls" / "context inspect". Desktop returns the +// human-readable engine name; other kinds return a localhost URL. +func resolveKindHost(kind types.ModelRunnerEngineKind, useTLS bool) string { + switch kind { + case types.ModelRunnerEngineKindDesktop: + return kind.String() + case types.ModelRunnerEngineKindCloud: + if useTLS { + return "https://localhost:" + strconv.Itoa(standalone.DefaultTLSPortCloud) + } + return "http://localhost:" + strconv.Itoa(standalone.DefaultControllerPortCloud) + case types.ModelRunnerEngineKindMoby, types.ModelRunnerEngineKindMobyManual: + if useTLS { + return "https://localhost:" + strconv.Itoa(standalone.DefaultTLSPortMoby) + } + return "http://localhost:" + strconv.Itoa(standalone.DefaultControllerPortMoby) + default: + return "(auto-detect)" + } +} + +// resolveDefaultContext attempts to detect the Docker engine kind for the +// synthetic "default" context row. When the CLI is available it probes the +// Docker daemon (with a short timeout) and returns a descriptive host and +// description derived from the detected engine kind. If detection fails or +// the CLI is unavailable (nil) it falls back to generic strings. +func resolveDefaultContext(ctx context.Context) (host, description string) { + const fallbackHost = "(auto-detect)" + const fallbackDescription = "Auto-detected Docker context" + + // dockerCLI is nil during tests and when the CLI is not yet initialised. + if dockerCLI == nil { + return fallbackHost, fallbackDescription + } + + detectCtx, cancel := context.WithTimeout(ctx, defaultContextDetectTimeout) + defer cancel() + + kind := desktop.DetectEngineKind(detectCtx, dockerCLI) + // Mirror DetectContext: MODEL_RUNNER_TLS must be explicitly set to "true". + tlsVal, tlsSet := os.LookupEnv("MODEL_RUNNER_TLS") + useTLS := tlsSet && tlsVal == "true" + return resolveKindHost(kind, useTLS), "Model Runner on " + kind.String() +} + // newContextCreateCmd returns the "context create" command. func newContextCreateCmd() *cobra.Command { var ( @@ -223,12 +278,16 @@ func newContextLsCmd() *cobra.Command { ) } + // Resolve the host and description for the synthetic "default" + // row by detecting the engine kind (Desktop, Moby, Cloud). + defaultHost, defaultDescription := resolveDefaultContext(cmd.Context()) + // Build rows: synthetic "default" first, then named contexts sorted. rows := []contextListRow{ { name: modelctx.DefaultContextName, - host: "(auto-detect)", - description: "Auto-detected Docker context", + host: defaultHost, + description: defaultDescription, active: activeName == modelctx.DefaultContextName, }, } @@ -325,15 +384,23 @@ func newContextInspectCmd() *cobra.Command { return fmt.Errorf("unable to open context store: %w", err) } + // Resolve the default context info once (lazily) so that + // repeated "default" args do not trigger multiple probes. + var defaultHost, defaultDescription string + defaultResolved := false + results := make([]namedContextInspect, 0, len(args)) for _, name := range args { if name == modelctx.DefaultContextName { - // Return a synthetic entry for "default". + if !defaultResolved { + defaultHost, defaultDescription = resolveDefaultContext(cmd.Context()) + defaultResolved = true + } results = append(results, namedContextInspect{ Name: modelctx.DefaultContextName, ContextConfig: modelctx.ContextConfig{ - Host: "(auto-detect)", - Description: "Auto-detected Docker context", + Host: defaultHost, + Description: defaultDescription, }, }) continue diff --git a/cmd/cli/commands/context_test.go b/cmd/cli/commands/context_test.go index 7522c5822..e99d37407 100644 --- a/cmd/cli/commands/context_test.go +++ b/cmd/cli/commands/context_test.go @@ -3,10 +3,13 @@ package commands import ( "bytes" "encoding/json" + "strconv" "strings" "testing" "github.com/docker/model-runner/cmd/cli/pkg/modelctx" + "github.com/docker/model-runner/cmd/cli/pkg/standalone" + "github.com/docker/model-runner/cmd/cli/pkg/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -395,3 +398,46 @@ func TestContextInspect_notFound(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "not found") } + +// TestResolveKindHost verifies that every engine kind maps to the expected +// host string, including TLS variants and the unknown-kind fallback. +func TestResolveKindHost(t *testing.T) { + mobyPlain := "http://localhost:" + strconv.Itoa(standalone.DefaultControllerPortMoby) + mobyTLS := "https://localhost:" + strconv.Itoa(standalone.DefaultTLSPortMoby) + cloudPlain := "http://localhost:" + strconv.Itoa(standalone.DefaultControllerPortCloud) + cloudTLS := "https://localhost:" + strconv.Itoa(standalone.DefaultTLSPortCloud) + + tests := []struct { + name string + kind types.ModelRunnerEngineKind + useTLS bool + want string + }{ + {"desktop_plain", types.ModelRunnerEngineKindDesktop, false, "Docker Desktop"}, + {"desktop_tls_ignored", types.ModelRunnerEngineKindDesktop, true, "Docker Desktop"}, + {"cloud_plain", types.ModelRunnerEngineKindCloud, false, cloudPlain}, + {"cloud_tls", types.ModelRunnerEngineKindCloud, true, cloudTLS}, + {"moby_plain", types.ModelRunnerEngineKindMoby, false, mobyPlain}, + {"moby_tls", types.ModelRunnerEngineKindMoby, true, mobyTLS}, + {"moby_manual_plain", types.ModelRunnerEngineKindMobyManual, false, mobyPlain}, + {"moby_manual_tls", types.ModelRunnerEngineKindMobyManual, true, mobyTLS}, + {"unknown_fallback", types.ModelRunnerEngineKind(99), false, "(auto-detect)"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, resolveKindHost(tt.kind, tt.useTLS)) + }) + } +} + +// TestResolveDefaultContext_nilCLI documents the nil-CLI contract: when +// dockerCLI is not set, the fallback strings are returned immediately without +// any network probe. +func TestResolveDefaultContext_nilCLI(t *testing.T) { + prev := dockerCLI + t.Cleanup(func() { dockerCLI = prev }) + dockerCLI = nil + host, desc := resolveDefaultContext(t.Context()) + assert.Equal(t, "(auto-detect)", host) + assert.Equal(t, "Auto-detected Docker context", desc) +} diff --git a/cmd/cli/desktop/context.go b/cmd/cli/desktop/context.go index 7e6546b2c..17a059ffe 100644 --- a/cmd/cli/desktop/context.go +++ b/cmd/cli/desktop/context.go @@ -243,6 +243,32 @@ func namedContextStore(cli *command.DockerCli) (*modelctx.Store, error) { return modelctx.New(configDir) } +// DetectEngineKind determines the Docker engine kind associated with the +// current CLI context without performing any side effects (such as waking +// up Docker Cloud). It is intended for informational commands like +// "context ls" that need to display the resolved engine kind without +// triggering backend initialisation. +func DetectEngineKind(ctx context.Context, cli *command.DockerCli) types.ModelRunnerEngineKind { + if isDesktopContext(ctx, cli) { + // On WSL2, a Moby-based controller container may be running + // alongside Docker Desktop. Mirror the logic in DetectContext + // so that "context ls" reports the same engine kind. + if IsDesktopWSLContext(ctx, cli) { + if dockerClient, err := DockerClientForContext(cli, cli.CurrentContext()); err == nil { + defer dockerClient.Close() + if containerID, _, _, findErr := standalone.FindControllerContainer(ctx, dockerClient); findErr == nil && containerID != "" { + return types.ModelRunnerEngineKindMoby + } + } + } + return types.ModelRunnerEngineKindDesktop + } + if isCloudContext(cli) { + return types.ModelRunnerEngineKindCloud + } + return types.ModelRunnerEngineKindMoby +} + // DetectContext determines the current Docker Model Runner context. func DetectContext(ctx context.Context, cli *command.DockerCli, printer standalone.StatusPrinter) (*ModelRunnerContext, error) { // Check for an explicit endpoint setting.