Skip to content
348 changes: 348 additions & 0 deletions cmd/cli/commands/context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,348 @@
package commands

import (
"bytes"
"fmt"
"net/url"
"os"
"path/filepath"
"sort"
"time"

"github.com/docker/cli/cli/command"
"github.com/docker/model-runner/cmd/cli/commands/formatter"
"github.com/docker/model-runner/cmd/cli/pkg/modelctx"
"github.com/spf13/cobra"
)

// 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.
func newContextCmd(cli *command.DockerCli) *cobra.Command {
c := &cobra.Command{
Use: "context",
Short: "Manage Docker Model Runner contexts",
// Context management commands need only CLI initialisation, not a
// running backend. Override PersistentPreRunE to skip DetectContext.
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
return initDockerCLI(cmd, args, cli, globalOptions)
},
}

c.AddCommand(
newContextCreateCmd(),
newContextUseCmd(),
newContextLsCmd(),
newContextRmCmd(),
newContextInspectCmd(),
)
return c
}

// contextStore opens the context store using the Docker config directory
// derived from the current CLI configuration.
func contextStore() (*modelctx.Store, error) {
dir, err := dockerConfigDir()
if err != nil {
return nil, fmt.Errorf("unable to determine Docker config directory: %w", err)
}
return modelctx.New(dir)
}

// dockerConfigDir returns the Docker configuration directory. It honours the
// DOCKER_CONFIG environment variable and falls back to ~/.docker.
func dockerConfigDir() (string, error) {
if dockerCLI != nil && dockerCLI.ConfigFile() != nil {
return filepath.Dir(dockerCLI.ConfigFile().Filename), nil
}
// Fallback used during testing or when CLI is not yet initialised.
if d := os.Getenv("DOCKER_CONFIG"); d != "" {
return d, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("unable to determine home directory: %w", err)
}
return filepath.Join(home, ".docker"), nil
}

// newContextCreateCmd returns the "context create" command.
func newContextCreateCmd() *cobra.Command {
var (
host string
tls bool
tlsSkipVerify bool
tlsCACert string
description string
)

c := &cobra.Command{
Use: "create NAME",
Short: "Create a named Model Runner context",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]

// Validate and normalise the host URL.
if host == "" {
return fmt.Errorf("--host is required")
}
if _, err := url.Parse(host); err != nil {
return fmt.Errorf("invalid --host URL: %w", err)
}
Comment thread
sourcery-ai[bot] marked this conversation as resolved.

// Validate the CA cert path if provided.
tlsCACertAbs := ""
if tlsCACert != "" {
abs, err := filepath.Abs(tlsCACert)
if err != nil {
return fmt.Errorf("invalid --tls-ca-cert path: %w", err)
}
if _, err := os.ReadFile(abs); err != nil {
return fmt.Errorf(
"--tls-ca-cert: cannot read %q: %w", abs, err,
)
}
tlsCACertAbs = abs
}

store, err := contextStore()
if err != nil {
return fmt.Errorf("unable to open context store: %w", err)
}

cfg := modelctx.ContextConfig{
Host: host,
TLS: modelctx.TLSConfig{
Enabled: tls,
SkipVerify: tlsSkipVerify,
CACert: tlsCACertAbs,
},
Description: description,
CreatedAt: time.Now().UTC(),
}
if err := store.Create(name, cfg); err != nil {
return err
}

fmt.Fprintf(cmd.OutOrStdout(), "Context %q created.\n", name)
return nil
},
}

c.Flags().StringVar(&host, "host", "",
"Model Runner API base URL (e.g. http://192.168.1.100:12434)")
c.Flags().BoolVar(&tls, "tls", false,
"Enable TLS for connections to this context")
c.Flags().BoolVar(&tlsSkipVerify, "tls-skip-verify", false,
"Skip TLS server certificate verification")
c.Flags().StringVar(&tlsCACert, "tls-ca-cert", "",
"Path to a custom CA certificate PEM file for TLS verification")
c.Flags().StringVar(&description, "description", "",
"Optional human-readable description for this context")
return c
}

// newContextUseCmd returns the "context use" command.
func newContextUseCmd() *cobra.Command {
return &cobra.Command{
Use: "use NAME",
Short: "Set the active Model Runner context",
Long: `Set the active Model Runner context. Pass "default" to revert to
automatic backend detection.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]

store, err := contextStore()
if err != nil {
return fmt.Errorf("unable to open context store: %w", err)
}

if err := store.SetActive(name); err != nil {
return err
}

fmt.Fprintf(
cmd.OutOrStdout(),
"Current context is now %q.\n", name,
)
return nil
},
}
}

// contextListRow holds the data for one row in the "context ls" table.
type contextListRow struct {
name string
host string
description string
active bool
}

// newContextLsCmd returns the "context ls" command.
func newContextLsCmd() *cobra.Command {
return &cobra.Command{
Use: "ls",
Aliases: []string{"list"},
Short: "List Model Runner contexts",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
store, err := contextStore()
if err != nil {
return fmt.Errorf("unable to open context store: %w", err)
}

contexts, err := store.List()
if err != nil {
return fmt.Errorf("unable to list contexts: %w", err)
}

activeName, err := store.Active()
if err != nil {
return fmt.Errorf("unable to determine active context: %w", err)
}

// Warn if MODEL_RUNNER_HOST overrides the active context.
if envHost := os.Getenv("MODEL_RUNNER_HOST"); envHost != "" {
fmt.Fprintf(
cmd.ErrOrStderr(),
"Warning: MODEL_RUNNER_HOST=%q overrides the active context.\n",
envHost,
)
}

// Build rows: synthetic "default" first, then named contexts sorted.
rows := []contextListRow{
{
name: modelctx.DefaultContextName,
host: "(auto-detect)",
description: "Auto-detected Docker context",
active: activeName == modelctx.DefaultContextName,
},
}

names := make([]string, 0, len(contexts))
for n := range contexts {
names = append(names, n)
}
sort.Strings(names)

for _, n := range names {
cfg := contexts[n]
rows = append(rows, contextListRow{
name: n,
host: cfg.Host,
description: cfg.Description,
active: activeName == n,
})
}

var buf bytes.Buffer
table := newTable(&buf)
table.Header([]string{"NAME", "HOST", "DESCRIPTION", "CURRENT"})
for _, row := range rows {
current := ""
if row.active {
current = "*"
}
table.Append([]string{
row.name,
row.host,
row.description,
current,
})
}
table.Render()

fmt.Fprint(cmd.OutOrStdout(), buf.String())
return nil
},
}
}

// newContextRmCmd returns the "context rm" command.
func newContextRmCmd() *cobra.Command {
return &cobra.Command{
Use: "rm NAME [NAME...]",
Aliases: []string{"remove"},
Short: "Remove one or more Model Runner contexts",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
store, err := contextStore()
if err != nil {
return fmt.Errorf("unable to open context store: %w", err)
}

// Attempt removal of all named contexts; collect errors.
var errs []error
for _, name := range args {
if err := store.Remove(name); err != nil {
errs = append(errs, fmt.Errorf("%s: %w", name, err))
continue
}
fmt.Fprintf(cmd.OutOrStdout(), "Context %q removed.\n", name)
}

if len(errs) > 0 {
for _, e := range errs {
fmt.Fprintln(cmd.ErrOrStderr(), "Error:", e)
}
return fmt.Errorf("one or more contexts could not be removed")
}
return nil
},
}
}

// namedContextInspect is the JSON-serialisable representation of a named
// context returned by "context inspect".
type namedContextInspect struct {
Name string `json:"name"`
modelctx.ContextConfig
}

// newContextInspectCmd returns the "context inspect" command.
func newContextInspectCmd() *cobra.Command {
return &cobra.Command{
Use: "inspect NAME [NAME...]",
Short: "Display detailed information about one or more contexts",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
store, err := contextStore()
if err != nil {
return fmt.Errorf("unable to open context store: %w", err)
}

results := make([]namedContextInspect, 0, len(args))
for _, name := range args {
if name == modelctx.DefaultContextName {
// Return a synthetic entry for "default".
results = append(results, namedContextInspect{
Name: modelctx.DefaultContextName,
ContextConfig: modelctx.ContextConfig{
Host: "(auto-detect)",
Description: "Auto-detected Docker context",
},
})
continue
}
cfg, err := store.Get(name)
if err != nil {
return err
}
results = append(results, namedContextInspect{
Name: name,
ContextConfig: cfg,
})
}

output, err := formatter.ToStandardJSON(results)
if err != nil {
return err
}
fmt.Fprint(cmd.OutOrStdout(), output)
return nil
},
}
}
Loading
Loading