Skip to content

Commit a2ea95c

Browse files
authored
Add context support (#808)
* feat: add support for managing Docker Model Runner contexts * test: add unit tests for context management and validation * add docs * fix: improve error handling in context store and Docker config directory retrieval * feat: add file locking support for Unix and Windows in context management * fix: enhance error handling for context store access and active context retrieval * fix: improve error handling for random byte generation in temp file creation * fix: enhance validation for --host URL in context creation
1 parent 1304519 commit a2ea95c

22 files changed

Lines changed: 1662 additions & 30 deletions

cmd/cli/commands/context.go

Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
package commands
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"net/url"
7+
"os"
8+
"path/filepath"
9+
"sort"
10+
"time"
11+
12+
"github.com/docker/cli/cli/command"
13+
"github.com/docker/model-runner/cmd/cli/commands/formatter"
14+
"github.com/docker/model-runner/cmd/cli/pkg/modelctx"
15+
"github.com/spf13/cobra"
16+
)
17+
18+
// newContextCmd returns the "docker model context" parent command. Its
19+
// subcommands manage named Model Runner contexts stored on disk, so they do
20+
// not require a running backend and override PersistentPreRunE accordingly.
21+
func newContextCmd(cli *command.DockerCli) *cobra.Command {
22+
c := &cobra.Command{
23+
Use: "context",
24+
Short: "Manage Docker Model Runner contexts",
25+
// Context management commands need only CLI initialisation, not a
26+
// running backend. Override PersistentPreRunE to skip DetectContext.
27+
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
28+
return initDockerCLI(cmd, args, cli, globalOptions)
29+
},
30+
}
31+
32+
c.AddCommand(
33+
newContextCreateCmd(),
34+
newContextUseCmd(),
35+
newContextLsCmd(),
36+
newContextRmCmd(),
37+
newContextInspectCmd(),
38+
)
39+
return c
40+
}
41+
42+
// contextStore opens the context store using the Docker config directory
43+
// derived from the current CLI configuration.
44+
func contextStore() (*modelctx.Store, error) {
45+
dir, err := dockerConfigDir()
46+
if err != nil {
47+
return nil, fmt.Errorf("unable to determine Docker config directory: %w", err)
48+
}
49+
return modelctx.New(dir)
50+
}
51+
52+
// dockerConfigDir returns the Docker configuration directory. It honours the
53+
// DOCKER_CONFIG environment variable and falls back to ~/.docker.
54+
func dockerConfigDir() (string, error) {
55+
if dockerCLI != nil && dockerCLI.ConfigFile() != nil {
56+
return filepath.Dir(dockerCLI.ConfigFile().Filename), nil
57+
}
58+
// Fallback used during testing or when CLI is not yet initialised.
59+
if d := os.Getenv("DOCKER_CONFIG"); d != "" {
60+
return d, nil
61+
}
62+
home, err := os.UserHomeDir()
63+
if err != nil {
64+
return "", fmt.Errorf("unable to determine home directory: %w", err)
65+
}
66+
return filepath.Join(home, ".docker"), nil
67+
}
68+
69+
// newContextCreateCmd returns the "context create" command.
70+
func newContextCreateCmd() *cobra.Command {
71+
var (
72+
host string
73+
tls bool
74+
tlsSkipVerify bool
75+
tlsCACert string
76+
description string
77+
)
78+
79+
c := &cobra.Command{
80+
Use: "create NAME",
81+
Short: "Create a named Model Runner context",
82+
Args: cobra.ExactArgs(1),
83+
RunE: func(cmd *cobra.Command, args []string) error {
84+
name := args[0]
85+
86+
// Validate and normalise the host URL.
87+
if host == "" {
88+
return fmt.Errorf("--host is required")
89+
}
90+
91+
u, err := url.ParseRequestURI(host)
92+
if err != nil {
93+
return fmt.Errorf("invalid --host URL: %w", err)
94+
}
95+
if u.Scheme == "" || u.Host == "" {
96+
return fmt.Errorf("invalid --host URL: must include scheme and host, e.g. http://192.168.1.100:12434")
97+
}
98+
if u.Scheme != "http" && u.Scheme != "https" {
99+
return fmt.Errorf("invalid --host URL: unsupported scheme %q (must be http or https)", u.Scheme)
100+
}
101+
102+
// Normalise the host string.
103+
host = u.String()
104+
105+
// Validate the CA cert path if provided.
106+
tlsCACertAbs := ""
107+
if tlsCACert != "" {
108+
abs, err := filepath.Abs(tlsCACert)
109+
if err != nil {
110+
return fmt.Errorf("invalid --tls-ca-cert path: %w", err)
111+
}
112+
if _, err := os.ReadFile(abs); err != nil {
113+
return fmt.Errorf(
114+
"--tls-ca-cert: cannot read %q: %w", abs, err,
115+
)
116+
}
117+
tlsCACertAbs = abs
118+
}
119+
120+
store, err := contextStore()
121+
if err != nil {
122+
return fmt.Errorf("unable to open context store: %w", err)
123+
}
124+
125+
cfg := modelctx.ContextConfig{
126+
Host: host,
127+
TLS: modelctx.TLSConfig{
128+
Enabled: tls,
129+
SkipVerify: tlsSkipVerify,
130+
CACert: tlsCACertAbs,
131+
},
132+
Description: description,
133+
CreatedAt: time.Now().UTC(),
134+
}
135+
if err := store.Create(name, cfg); err != nil {
136+
return err
137+
}
138+
139+
fmt.Fprintf(cmd.OutOrStdout(), "Context %q created.\n", name)
140+
return nil
141+
},
142+
}
143+
144+
c.Flags().StringVar(&host, "host", "",
145+
"Model Runner API base URL (e.g. http://192.168.1.100:12434)")
146+
c.Flags().BoolVar(&tls, "tls", false,
147+
"Enable TLS for connections to this context")
148+
c.Flags().BoolVar(&tlsSkipVerify, "tls-skip-verify", false,
149+
"Skip TLS server certificate verification")
150+
c.Flags().StringVar(&tlsCACert, "tls-ca-cert", "",
151+
"Path to a custom CA certificate PEM file for TLS verification")
152+
c.Flags().StringVar(&description, "description", "",
153+
"Optional human-readable description for this context")
154+
return c
155+
}
156+
157+
// newContextUseCmd returns the "context use" command.
158+
func newContextUseCmd() *cobra.Command {
159+
return &cobra.Command{
160+
Use: "use NAME",
161+
Short: "Set the active Model Runner context",
162+
Long: `Set the active Model Runner context. Pass "default" to revert to
163+
automatic backend detection.`,
164+
Args: cobra.ExactArgs(1),
165+
RunE: func(cmd *cobra.Command, args []string) error {
166+
name := args[0]
167+
168+
store, err := contextStore()
169+
if err != nil {
170+
return fmt.Errorf("unable to open context store: %w", err)
171+
}
172+
173+
if err := store.SetActive(name); err != nil {
174+
return err
175+
}
176+
177+
fmt.Fprintf(
178+
cmd.OutOrStdout(),
179+
"Current context is now %q.\n", name,
180+
)
181+
return nil
182+
},
183+
}
184+
}
185+
186+
// contextListRow holds the data for one row in the "context ls" table.
187+
type contextListRow struct {
188+
name string
189+
host string
190+
description string
191+
active bool
192+
}
193+
194+
// newContextLsCmd returns the "context ls" command.
195+
func newContextLsCmd() *cobra.Command {
196+
return &cobra.Command{
197+
Use: "ls",
198+
Aliases: []string{"list"},
199+
Short: "List Model Runner contexts",
200+
Args: cobra.NoArgs,
201+
RunE: func(cmd *cobra.Command, args []string) error {
202+
store, err := contextStore()
203+
if err != nil {
204+
return fmt.Errorf("unable to open context store: %w", err)
205+
}
206+
207+
contexts, err := store.List()
208+
if err != nil {
209+
return fmt.Errorf("unable to list contexts: %w", err)
210+
}
211+
212+
activeName, err := store.Active()
213+
if err != nil {
214+
return fmt.Errorf("unable to determine active context: %w", err)
215+
}
216+
217+
// Warn if MODEL_RUNNER_HOST overrides the active context.
218+
if envHost := os.Getenv("MODEL_RUNNER_HOST"); envHost != "" {
219+
fmt.Fprintf(
220+
cmd.ErrOrStderr(),
221+
"Warning: MODEL_RUNNER_HOST=%q overrides the active context.\n",
222+
envHost,
223+
)
224+
}
225+
226+
// Build rows: synthetic "default" first, then named contexts sorted.
227+
rows := []contextListRow{
228+
{
229+
name: modelctx.DefaultContextName,
230+
host: "(auto-detect)",
231+
description: "Auto-detected Docker context",
232+
active: activeName == modelctx.DefaultContextName,
233+
},
234+
}
235+
236+
names := make([]string, 0, len(contexts))
237+
for n := range contexts {
238+
names = append(names, n)
239+
}
240+
sort.Strings(names)
241+
242+
for _, n := range names {
243+
cfg := contexts[n]
244+
rows = append(rows, contextListRow{
245+
name: n,
246+
host: cfg.Host,
247+
description: cfg.Description,
248+
active: activeName == n,
249+
})
250+
}
251+
252+
var buf bytes.Buffer
253+
table := newTable(&buf)
254+
table.Header([]string{"NAME", "HOST", "DESCRIPTION", "CURRENT"})
255+
for _, row := range rows {
256+
current := ""
257+
if row.active {
258+
current = "*"
259+
}
260+
table.Append([]string{
261+
row.name,
262+
row.host,
263+
row.description,
264+
current,
265+
})
266+
}
267+
table.Render()
268+
269+
fmt.Fprint(cmd.OutOrStdout(), buf.String())
270+
return nil
271+
},
272+
}
273+
}
274+
275+
// newContextRmCmd returns the "context rm" command.
276+
func newContextRmCmd() *cobra.Command {
277+
return &cobra.Command{
278+
Use: "rm NAME [NAME...]",
279+
Aliases: []string{"remove"},
280+
Short: "Remove one or more Model Runner contexts",
281+
Args: cobra.MinimumNArgs(1),
282+
RunE: func(cmd *cobra.Command, args []string) error {
283+
store, err := contextStore()
284+
if err != nil {
285+
return fmt.Errorf("unable to open context store: %w", err)
286+
}
287+
288+
// Attempt removal of all named contexts; collect errors.
289+
var errs []error
290+
for _, name := range args {
291+
if err := store.Remove(name); err != nil {
292+
errs = append(errs, fmt.Errorf("%s: %w", name, err))
293+
continue
294+
}
295+
fmt.Fprintf(cmd.OutOrStdout(), "Context %q removed.\n", name)
296+
}
297+
298+
if len(errs) > 0 {
299+
for _, e := range errs {
300+
fmt.Fprintln(cmd.ErrOrStderr(), "Error:", e)
301+
}
302+
return fmt.Errorf("one or more contexts could not be removed")
303+
}
304+
return nil
305+
},
306+
}
307+
}
308+
309+
// namedContextInspect is the JSON-serialisable representation of a named
310+
// context returned by "context inspect".
311+
type namedContextInspect struct {
312+
Name string `json:"name"`
313+
modelctx.ContextConfig
314+
}
315+
316+
// newContextInspectCmd returns the "context inspect" command.
317+
func newContextInspectCmd() *cobra.Command {
318+
return &cobra.Command{
319+
Use: "inspect NAME [NAME...]",
320+
Short: "Display detailed information about one or more contexts",
321+
Args: cobra.MinimumNArgs(1),
322+
RunE: func(cmd *cobra.Command, args []string) error {
323+
store, err := contextStore()
324+
if err != nil {
325+
return fmt.Errorf("unable to open context store: %w", err)
326+
}
327+
328+
results := make([]namedContextInspect, 0, len(args))
329+
for _, name := range args {
330+
if name == modelctx.DefaultContextName {
331+
// Return a synthetic entry for "default".
332+
results = append(results, namedContextInspect{
333+
Name: modelctx.DefaultContextName,
334+
ContextConfig: modelctx.ContextConfig{
335+
Host: "(auto-detect)",
336+
Description: "Auto-detected Docker context",
337+
},
338+
})
339+
continue
340+
}
341+
cfg, err := store.Get(name)
342+
if err != nil {
343+
return err
344+
}
345+
results = append(results, namedContextInspect{
346+
Name: name,
347+
ContextConfig: cfg,
348+
})
349+
}
350+
351+
output, err := formatter.ToStandardJSON(results)
352+
if err != nil {
353+
return err
354+
}
355+
fmt.Fprint(cmd.OutOrStdout(), output)
356+
return nil
357+
},
358+
}
359+
}

0 commit comments

Comments
 (0)