introduce support for models

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
This commit is contained in:
Nicolas De Loof 2025-06-25 11:43:34 +02:00 committed by Nicolas De loof
parent b6a0df8d3c
commit a9e76943f6
8 changed files with 260 additions and 2 deletions

2
go.mod
View File

@ -212,3 +212,5 @@ exclude (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2
)
replace github.com/compose-spec/compose-go/v2 => github.com/ndeloof/compose-go/v2 v2.0.1-0.20250625082240-b948fe935f02

4
go.sum
View File

@ -80,8 +80,6 @@ github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004 h1:lkAMpLVBDaj17e
github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA=
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE=
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4=
github.com/compose-spec/compose-go/v2 v2.6.5 h1:H7xP5OMKdkN2p0brx01slxIU6dE/q6ybbG+jozPtIqk=
github.com/compose-spec/compose-go/v2 v2.6.5/go.mod h1:TmjkIB9W73fwVxkYY+u2uhMbMUakjiif79DlYgXsyvU=
github.com/containerd/cgroups/v3 v3.0.5 h1:44na7Ud+VwyE7LIoJ8JTNQOa549a8543BmzaJHo6Bzo=
github.com/containerd/cgroups/v3 v3.0.5/go.mod h1:SA5DLYnXO8pTGYiAHXz94qvLQTKfVM5GEVisn4jpins=
github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc=
@ -361,6 +359,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8m
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
github.com/ndeloof/compose-go/v2 v2.0.1-0.20250625082240-b948fe935f02 h1:RPYzx1y7ldfYB8Ba2INxr6FiW2ZxXHLl8it775gz0qE=
github.com/ndeloof/compose-go/v2 v2.0.1-0.20250625082240-b948fe935f02/go.mod h1:TmjkIB9W73fwVxkYY+u2uhMbMUakjiif79DlYgXsyvU=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.0 h1:Iw5WCbBcaAAd0fpRb1c9r5YCylv4XDoCSigm1zLevwU=

View File

@ -83,6 +83,11 @@ func (s *composeService) create(ctx context.Context, project *types.Project, opt
return err
}
err = s.ensureModels(ctx, project, options.QuietPull)
if err != nil {
return err
}
prepareNetworks(project)
networks, err := s.ensureNetworks(ctx, project)

198
pkg/compose/model.go Normal file
View File

@ -0,0 +1,198 @@
/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package compose
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"slices"
"strings"
"github.com/compose-spec/compose-go/v2/types"
"github.com/containerd/errdefs"
"github.com/docker/cli/cli-plugins/manager"
"github.com/docker/compose/v2/pkg/progress"
"github.com/spf13/cobra"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
"golang.org/x/sync/errgroup"
)
func (s *composeService) ensureModels(ctx context.Context, project *types.Project, quietPull bool) error {
if len(project.Models) == 0 {
return nil
}
dockerModel, err := manager.GetPlugin("model", s.dockerCli, &cobra.Command{})
if err != nil {
if errdefs.IsNotFound(err) {
return fmt.Errorf("'models' support requires Docker Model plugin")
}
return err
}
cmd := exec.CommandContext(ctx, dockerModel.Path, "ls", "--json")
s.setupChildProcess(ctx, cmd)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("error checking available models: %w", err)
}
type AvailableModel struct {
Id string `json:"id"`
Tags []string `json:"tags"`
Created int `json:"created"`
}
models := []AvailableModel{}
err = json.Unmarshal(output, &models)
if err != nil {
return fmt.Errorf("error unmarshalling available models: %w", err)
}
eg, gctx := errgroup.WithContext(ctx)
eg.Go(func() error {
return s.setModelEndpointVariable(gctx, dockerModel, project)
})
MODELS:
for name, config := range project.Models {
for _, model := range models {
if slices.Contains(model.Tags, config.Model) {
continue MODELS
}
}
if config.Name == "" {
config.Name = name
}
eg.Go(func() error {
return s.pullModel(gctx, dockerModel, config, quietPull)
})
}
return eg.Wait()
}
func (s *composeService) pullModel(ctx context.Context, dockerModel *manager.Plugin, model types.ModelConfig, quietPull bool) error {
w := progress.ContextWriter(ctx)
w.Event(progress.Event{
ID: model.Name,
Status: progress.Working,
Text: "Pulling",
})
cmd := exec.CommandContext(ctx, dockerModel.Path, "pull", model.Model)
s.setupChildProcess(ctx, cmd)
stream, err := cmd.StdoutPipe()
if err != nil {
return err
}
err = cmd.Start()
if err != nil {
return err
}
scanner := bufio.NewScanner(stream)
for scanner.Scan() {
msg := scanner.Text()
if msg == "" {
continue
}
if !quietPull {
w.Event(progress.Event{
ID: model.Name,
Status: progress.Working,
Text: "Pulling",
StatusText: msg,
})
}
}
err = cmd.Wait()
if err != nil {
w.Event(progress.ErrorMessageEvent(model.Name, err.Error()))
}
w.Event(progress.Event{
ID: model.Name,
Status: progress.Working,
Text: "Pulled",
})
return err
}
func (s *composeService) setModelEndpointVariable(ctx context.Context, dockerModel *manager.Plugin, project *types.Project) error {
cmd := exec.CommandContext(ctx, dockerModel.Path, "status", "--json")
s.setupChildProcess(ctx, cmd)
statusOut, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("error checking docker-model status: %w", err)
}
type Status struct {
Endpoint string `json:"endpoint"`
}
var status Status
err = json.Unmarshal(statusOut, &status)
if err != nil {
return err
}
for _, service := range project.Services {
for model, modelConfig := range service.Models {
var variable string
if modelConfig != nil && modelConfig.Variable != "" {
variable = modelConfig.Variable
} else {
variable = strings.ToUpper(model) + "_URL"
}
service.Environment[variable] = &status.Endpoint
}
}
return nil
}
func (s *composeService) setupChildProcess(gctx context.Context, cmd *exec.Cmd) {
// exec provider command with same environment Compose is running
env := types.NewMapping(os.Environ())
// but remove DOCKER_CLI_PLUGIN... variable so plugin can detect it run standalone
delete(env, manager.ReexecEnvvar)
// propagate opentelemetry context to child process, see https://github.com/open-telemetry/oteps/blob/main/text/0258-env-context-baggage-carriers.md
carrier := propagation.MapCarrier{}
otel.GetTextMapPropagator().Inject(gctx, &carrier)
env.Merge(types.Mapping(carrier))
env["DOCKER_CONTEXT"] = s.dockerCli.CurrentContext()
cmd.Env = env.Values()
}
type Model struct {
Id string `json:"id"`
Tags []string `json:"tags"`
Created int `json:"created"`
Config struct {
Format string `json:"format"`
Quantization string `json:"quantization"`
Parameters string `json:"parameters"`
Architecture string `json:"architecture"`
Size string `json:"size"`
} `json:"config"`
}

View File

@ -123,6 +123,11 @@ func (s *composeService) prepareRun(ctx context.Context, project *types.Project,
return "", err
}
err = s.ensureModels(ctx, project, opts.QuietPull)
if err != nil {
return "", err
}
created, err := s.createContainer(ctx, project, service, service.ContainerName, -1, createOpts)
if err != nil {
return "", err

View File

@ -0,0 +1,9 @@
services:
test:
image: alpine/curl
models:
- foo
models:
foo:
model: ai/smollm2

View File

@ -52,6 +52,9 @@ var (
// DockerBuildxExecutableName is the Os dependent Buildx plugin binary name
DockerBuildxExecutableName = "docker-buildx"
// DockerModelExecutableName is the Os dependent Docker-Model plugin binary name
DockerModelExecutableName = "docker-model"
// WindowsExecutableSuffix is the Windows executable suffix
WindowsExecutableSuffix = ".exe"
)
@ -162,6 +165,13 @@ func initializePlugins(t testing.TB, configDir string) {
}
// We don't need a functional scan plugin, but a valid plugin binary
CopyFile(t, composePlugin, filepath.Join(configDir, "cli-plugins", DockerScanExecutableName))
modelPlugin, err := findPluginExecutable(DockerModelExecutableName)
if err != nil {
t.Logf("WARNING: docker-model cli-plugin not found")
} else {
CopyFile(t, modelPlugin, filepath.Join(configDir, "cli-plugins", DockerModelExecutableName))
}
}
}

29
pkg/e2e/model_test.go Normal file
View File

@ -0,0 +1,29 @@
/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package e2e
import (
"testing"
)
func TestComposeModel(t *testing.T) {
t.Skip("require model-cli on GHA runners")
c := NewParallelCLI(t)
defer c.cleanupWithDown(t, "model-test")
c.RunDockerComposeCmd(t, "-f", "./fixtures/model/compose.yaml", "run", "test", "sh", "-c", "curl ${FOO_URL}")
}