Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 9 additions & 133 deletions cmd/root/build.go
Original file line number Diff line number Diff line change
@@ -1,26 +1,19 @@
package root

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"

"github.com/spf13/cobra"

"github.com/docker/cagent/internal/telemetry"
"github.com/docker/cagent/pkg/config"
latest "github.com/docker/cagent/pkg/config/v2"
"github.com/docker/cagent/pkg/model/provider"
"github.com/docker/cagent/pkg/oci"
)

var push bool

func NewBuildCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "build <agent-file> <image-name>",
Args: cobra.ExactArgs(2),
Use: "build <agent-file> [docker-image-name]",
Short: "Build a Docker image for the agent",
Args: cobra.MinimumNArgs(1),
RunE: runBuildCommand,
Hidden: true,
}
Expand All @@ -33,128 +26,11 @@ func NewBuildCmd() *cobra.Command {
func runBuildCommand(cmd *cobra.Command, args []string) error {
telemetry.TrackCommand("build", args)

fileName := filepath.Base(args[0])
parentDir := filepath.Dir(args[0])

cfg, err := config.LoadConfigSecure(fileName, parentDir)
if err != nil {
return err
}

secrets := gatherRequiredEnv(cfg)
mcpServers := gatherMCPServers(cfg)

tmp, err := os.MkdirTemp("", "build")
if err != nil {
return err
}
defer os.RemoveAll(tmp)

// TODO(dga): set the right entrypoint.
err = os.WriteFile(filepath.Join(tmp, "Dockerfile"), fmt.Appendf(nil, `# syntax=docker/dockerfile:1
FROM alpine:3.22@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1

RUN adduser -D cagent
ADD https://github.com/docker/cagent/releases/download/v1.0.9/cagent-linux-arm64 /cagent
RUN chmod +x /cagent
COPY agent.yaml /
RUN chmod 666 /agent.yaml
USER cagent
ENTRYPOINT ["/cagent", "run", "--debug", "--tui=false", "/agent.yaml", "get my username on github"]

LABEL com.docker.agent.packaging.version="v0.0.1"
LABEL com.docker.agent.runtime="cagent"
LABEL org.opencontainers.image.description="%s"
LABEL org.opencontainers.image.licenses="%s"
LABEL com.docker.agent.mcp-servers="%s"
LABEL com.docker.agent.secrets="%s"
`, cfg.Agents["root"].Description, cfg.Metadata.License, strings.Join(mcpServers, ","), strings.Join(secrets, ",")), 0o700)
if err != nil {
return err
}

agentYaml, err := os.ReadFile(args[0])
if err != nil {
return err
}

err = os.WriteFile(filepath.Join(tmp, "agent.yaml"), agentYaml, 0o700)
if err != nil {
return err
}

buildArgs := []string{"build", "-t", args[1]}
if push {
buildArgs = append(buildArgs, "--push")
}
buildArgs = append(buildArgs, tmp)
buildCmd := exec.CommandContext(cmd.Context(), "docker", buildArgs...)
buildCmd.Stdout = os.Stdout
buildCmd.Stderr = os.Stderr

return buildCmd.Run()
}

func gatherRequiredEnv(cfg *latest.Config) []string {
requiredEnv := map[string]bool{}

for name := range cfg.Models {
model := cfg.Models[name]
// Use the token environment variable from the alias if available
if alias, exists := provider.ProviderAliases[model.Provider]; exists {
if alias.TokenEnvVar != "" {
requiredEnv[alias.TokenEnvVar] = true
}
} else {
// Fallback to hardcoded mappings for unknown providers
switch model.Provider {
case "openai":
requiredEnv["OPENAI_API_KEY"] = true
case "anthropic":
requiredEnv["ANTHROPIC_API_KEY"] = true
case "google":
requiredEnv["GOOGLE_API_KEY"] = true
}
}
}

for _, agent := range cfg.Agents {
model := agent.Model
switch {
case strings.HasPrefix(model, "openai/"):
requiredEnv["OPENAI_API_KEY"] = true
case strings.HasPrefix(model, "anthropic/"):
requiredEnv["ANTHROPIC_API_KEY"] = true
case strings.HasPrefix(model, "google/"):
requiredEnv["GOOGLE_API_KEY"] = true
}
}

var requiredEnvList []string
for e := range requiredEnv {
requiredEnvList = append(requiredEnvList, e)
}

return requiredEnvList
}

func gatherMCPServers(cfg *latest.Config) []string {
requiredServers := map[string]bool{}

for _, agent := range cfg.Agents {
for i := range agent.Toolsets {
toolSet := agent.Toolsets[i]

if toolSet.Type == "mcp" && toolSet.Ref != "" {
requiredServers[toolSet.Ref] = true
}
}
}

var requiredServersList []string
for e := range requiredServers {
requiredServersList = append(requiredServersList, e)
agentFilePath := args[0]
dockerImageName := ""
if len(args) > 1 {
dockerImageName = args[1]
}

return requiredServersList
return oci.BuildDockerImage(cmd.Context(), agentFilePath, dockerImageName, push)
}
29 changes: 29 additions & 0 deletions pkg/config/mcp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package config

import (
"sort"

latest "github.com/docker/cagent/pkg/config/v2"
)

func GatherMCPServerReferences(cfg *latest.Config) []string {
servers := map[string]bool{}

for _, agent := range cfg.Agents {
for i := range agent.Toolsets {
toolSet := agent.Toolsets[i]

if toolSet.Type == "mcp" && toolSet.Ref != "" {
servers[toolSet.Ref] = true
}
}
}

var list []string
for e := range servers {
list = append(list, e)
}
sort.Strings(list)

return list
}
1 change: 1 addition & 0 deletions pkg/gateway/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ func RequiredEnvVars(ctx context.Context, serverName, catalogURL string) ([]Secr
return server.Secrets, nil
}

// TODO(dga): cache the catalog.
func readCatalog(ctx context.Context, url string) (Catalog, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
if err != nil {
Expand Down
20 changes: 20 additions & 0 deletions pkg/oci/Dockerfile.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# syntax=docker/dockerfile:1
FROM alpine:3.22@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1

RUN adduser -D cagent
ADD https://github.com/docker/cagent/releases/download/v1.0.9/cagent-linux-arm64 /cagent
RUN chmod +x /cagent
RUN cat <<EOF > /agent.yaml
{{ .AgentConfig }}
EOF
RUN chmod +r /agent.yaml && mkdir /data && chmod 777 -R /data
USER cagent
ENTRYPOINT ["/cagent"]
CMD ["api", "--session-db", "/data/session.db", "/agent.yaml"]

LABEL com.docker.agent.packaging.version="v0.0.1"
LABEL com.docker.agent.runtime="cagent"
LABEL org.opencontainers.image.description="{{ .Description }}"
LABEL org.opencontainers.image.licenses="{{ .Licenses }}"
LABEL com.docker.agent.mcp-servers="{{ .McpServers }}"
LABEL com.docker.agent.secrets="{{ .Secrets }}"
72 changes: 72 additions & 0 deletions pkg/oci/build.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package oci

import (
"bytes"
"context"
_ "embed"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"text/template"

"github.com/docker/cagent/pkg/config"
"github.com/docker/cagent/pkg/secrets"
)

//go:embed Dockerfile.template
var dockerfileTemplate string

func BuildDockerImage(ctx context.Context, agentFilePath, dockerImageName string, push bool) error {
agentYaml, err := os.ReadFile(agentFilePath)
if err != nil {
return err
}

fileName := filepath.Base(agentFilePath)
parentDir := filepath.Dir(agentFilePath)
cfg, err := config.LoadConfigSecure(fileName, parentDir)
if err != nil {
return err
}

// Analyze the config to find which secrets are needed
modelSecrets := secrets.GatherEnvVarsForModels(cfg)
mcpServers := config.GatherMCPServerReferences(cfg)

// Generate the Dockerfile
var dockerfileBuf bytes.Buffer

tpl := template.Must(template.New("Dockerfile").Parse(dockerfileTemplate))
if err := tpl.Execute(&dockerfileBuf, map[string]any{
"AgentConfig": string(agentYaml),
"Description": cfg.Agents["root"].Description,
"Licenses": cfg.Metadata.License,
"McpServers": strings.Join(mcpServers, ","),
"Secrets": strings.Join(modelSecrets, ","),
}); err != nil {
return err
}

dockerfile := dockerfileBuf.String()
slog.Debug("Generated Dockerfile", "dockerfile", dockerfile)

// Run docker build
buildArgs := []string{"build"}
if dockerImageName != "" {
buildArgs = append(buildArgs, "-t", dockerImageName)
if push {
buildArgs = append(buildArgs, "--push")
}
}
buildArgs = append(buildArgs, "-")

buildCmd := exec.CommandContext(ctx, "docker", buildArgs...)
buildCmd.Stdin = strings.NewReader(dockerfile)
buildCmd.Stdout = os.Stdout
buildCmd.Stderr = os.Stderr
slog.Debug("running docker build", "args", buildArgs)

return buildCmd.Run()
}
File renamed without changes.
Loading