diff --git a/cmd/root/build.go b/cmd/root/build.go index 22b607f88..c8d9f933f 100644 --- a/cmd/root/build.go +++ b/cmd/root/build.go @@ -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 ", - Args: cobra.ExactArgs(2), + Use: "build [docker-image-name]", + Short: "Build a Docker image for the agent", + Args: cobra.MinimumNArgs(1), RunE: runBuildCommand, Hidden: true, } @@ -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) } diff --git a/pkg/config/mcp.go b/pkg/config/mcp.go new file mode 100644 index 000000000..981bbd4d1 --- /dev/null +++ b/pkg/config/mcp.go @@ -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 +} diff --git a/pkg/gateway/catalog.go b/pkg/gateway/catalog.go index 361792b12..a262390a8 100644 --- a/pkg/gateway/catalog.go +++ b/pkg/gateway/catalog.go @@ -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 { diff --git a/pkg/oci/Dockerfile.template b/pkg/oci/Dockerfile.template new file mode 100644 index 000000000..181d4e3f0 --- /dev/null +++ b/pkg/oci/Dockerfile.template @@ -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 < /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 }}" \ No newline at end of file diff --git a/pkg/oci/build.go b/pkg/oci/build.go new file mode 100644 index 000000000..62fa0a0c7 --- /dev/null +++ b/pkg/oci/build.go @@ -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() +} diff --git a/pkg/oci/build_test.go b/pkg/oci/package_test.go similarity index 100% rename from pkg/oci/build_test.go rename to pkg/oci/package_test.go diff --git a/pkg/secrets/gather.go b/pkg/secrets/gather.go new file mode 100644 index 000000000..cbf7c20e1 --- /dev/null +++ b/pkg/secrets/gather.go @@ -0,0 +1,126 @@ +package secrets + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/docker/cagent/pkg/config" + latest "github.com/docker/cagent/pkg/config/v2" + "github.com/docker/cagent/pkg/environment" + "github.com/docker/cagent/pkg/gateway" + "github.com/docker/cagent/pkg/model/provider" +) + +// GatherMissingEnvVars finds out which environment variables are required by the models and tools. +// This allows exiting early with a proper error message instead of failing later when trying to use a model or tool. +// TODO(dga): This code contains lots of duplication and ought to be refactored. +func GatherMissingEnvVars(ctx context.Context, cfg *latest.Config, env environment.Provider, runtimeConfig config.RuntimeConfig) ([]string, error) { + requiredEnv := map[string]bool{} + + // Models + if runtimeConfig.ModelsGateway == "" { + names := GatherEnvVarsForModels(cfg) + for _, e := range names { + requiredEnv[e] = true + } + } + + // Tools + if runtimeConfig.ToolsGateway == "" && !environment.IsInContainer() { + names, err := GatherEnvVarsForTools(ctx, cfg) + if err != nil { + return nil, err + } + for _, e := range names { + requiredEnv[e] = true + } + } + + // Check for missing + var missing []string + for _, e := range mcpToSortedList(requiredEnv) { + if env.Get(ctx, e) == "" { + missing = append(missing, e) + } + } + + return missing, nil +} + +func GatherEnvVarsForModels(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 + + for prefix, alias := range provider.ProviderAliases { + if strings.HasPrefix(model, prefix+"/") && alias.TokenEnvVar != "" { + requiredEnv[alias.TokenEnvVar] = true + } + } + + 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 + } + } + + return mcpToSortedList(requiredEnv) +} + +func GatherEnvVarsForTools(ctx context.Context, cfg *latest.Config) ([]string, error) { + requiredEnv := map[string]bool{} + + for _, ref := range config.GatherMCPServerReferences(cfg) { + mcpServerName := gateway.ParseServerRef(ref) + + secrets, err := gateway.RequiredEnvVars(ctx, mcpServerName, gateway.DockerCatalogURL) + if err != nil { + return nil, fmt.Errorf("reading which secrets the MCP server needs: %w", err) + } + + for _, secret := range secrets { + requiredEnv[secret.Env] = true + } + } + + return mcpToSortedList(requiredEnv), nil +} + +func mcpToSortedList(requiredEnv map[string]bool) []string { + var requiredEnvList []string + + for e := range requiredEnv { + requiredEnvList = append(requiredEnvList, e) + } + sort.Strings(requiredEnvList) + + return requiredEnvList +} diff --git a/pkg/teamloader/teamloader.go b/pkg/teamloader/teamloader.go index 88ec9bacc..f58f595a3 100644 --- a/pkg/teamloader/teamloader.go +++ b/pkg/teamloader/teamloader.go @@ -7,18 +7,17 @@ import ( "log/slog" "os" "path/filepath" - "sort" "strings" "github.com/docker/cagent/pkg/agent" "github.com/docker/cagent/pkg/config" latest "github.com/docker/cagent/pkg/config/v2" "github.com/docker/cagent/pkg/environment" - "github.com/docker/cagent/pkg/gateway" "github.com/docker/cagent/pkg/memory" "github.com/docker/cagent/pkg/memory/database/sqlite" "github.com/docker/cagent/pkg/model/provider" "github.com/docker/cagent/pkg/model/provider/options" + "github.com/docker/cagent/pkg/secrets" "github.com/docker/cagent/pkg/team" "github.com/docker/cagent/pkg/tools" "github.com/docker/cagent/pkg/tools/builtin" @@ -79,84 +78,18 @@ func FindAgentPaths(agentsPathOrDirectory string) ([]string, error) { // checkRequiredEnvVars checks which environment variables are required by the models and tools. // This allows exiting early with a proper error message instead of failing later when trying to use a model or tool. -// TODO(dga): This code contains lots of duplication and ought to be refactored. func checkRequiredEnvVars(ctx context.Context, cfg *latest.Config, env environment.Provider, runtimeConfig config.RuntimeConfig) error { - requiredEnv := map[string]bool{} - - // Models - if runtimeConfig.ModelsGateway == "" { - 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 - } - } - } - - // Tools - if runtimeConfig.ToolsGateway == "" && !environment.IsInContainer() { - for _, agent := range cfg.Agents { - for i := range agent.Toolsets { - toolSet := agent.Toolsets[i] - - if toolSet.Type == "mcp" && toolSet.Ref != "" { - mcpServerName := gateway.ParseServerRef(toolSet.Ref) - - secrets, err := gateway.RequiredEnvVars(ctx, mcpServerName, gateway.DockerCatalogURL) - if err != nil { - return fmt.Errorf("reading which secrets the MCP server needs: %w", err) - } - for _, secret := range secrets { - requiredEnv[secret.Env] = true - } - } - } - } + requiredEnv, err := secrets.GatherMissingEnvVars(ctx, cfg, env, runtimeConfig) + if err != nil { + return fmt.Errorf("gathering required environment variables: %w", err) } if len(requiredEnv) == 0 { return nil } - var requiredEnvList []string - for e := range requiredEnv { - if env.Get(ctx, e) == "" { - requiredEnvList = append(requiredEnvList, e) - } - } - - if len(requiredEnvList) == 0 { - return nil - } - - sort.Strings(requiredEnvList) return &environment.RequiredEnvError{ - Missing: requiredEnvList, + Missing: requiredEnv, } }