Add mcp build command and deploy auto-build
Extends MCP to own the full build-push-deploy lifecycle. When deploying, the CLI checks whether each component's image tag exists in the registry and builds/pushes automatically if missing and build config is present. - Add Build, Push, ImageExists to runtime.Runtime interface (podman impl) - Add mcp build <service>[/<image>] command - Add [build] section to CLI config (workspace path) - Add path and [build.images] to service definitions - Wire auto-build into mcp deploy before agent RPC - Update ARCHITECTURE.md with runtime interface and deploy auto-build docs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
168
cmd/mcp/build.go
Normal file
168
cmd/mcp/build.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcp/internal/config"
|
||||
"git.wntrmute.dev/kyle/mcp/internal/runtime"
|
||||
"git.wntrmute.dev/kyle/mcp/internal/servicedef"
|
||||
)
|
||||
|
||||
func buildCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "build <service>[/<image>]",
|
||||
Short: "Build and push images for a service",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := config.LoadCLIConfig(cfgPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
serviceName, imageFilter := parseServiceArg(args[0])
|
||||
|
||||
def, err := loadServiceDef(cmd, cfg, serviceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rt := &runtime.Podman{}
|
||||
return buildServiceImages(cmd.Context(), cfg, def, rt, imageFilter)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// buildServiceImages builds and pushes images for a service definition.
|
||||
// If imageFilter is non-empty, only the matching image is built.
|
||||
func buildServiceImages(ctx context.Context, cfg *config.CLIConfig, def *servicedef.ServiceDef, rt *runtime.Podman, imageFilter string) error {
|
||||
if def.Build == nil || len(def.Build.Images) == 0 {
|
||||
return fmt.Errorf("service %q has no [build.images] configuration", def.Name)
|
||||
}
|
||||
if def.Path == "" {
|
||||
return fmt.Errorf("service %q has no path configured", def.Name)
|
||||
}
|
||||
if cfg.Build.Workspace == "" {
|
||||
return fmt.Errorf("build.workspace is not configured in %s", cfgPath)
|
||||
}
|
||||
|
||||
sourceDir := filepath.Join(cfg.Build.Workspace, def.Path)
|
||||
|
||||
for imageName, dockerfile := range def.Build.Images {
|
||||
if imageFilter != "" && imageName != imageFilter {
|
||||
continue
|
||||
}
|
||||
|
||||
imageRef := findImageRef(def, imageName)
|
||||
if imageRef == "" {
|
||||
return fmt.Errorf("no component references image %q in service %q", imageName, def.Name)
|
||||
}
|
||||
|
||||
fmt.Printf("building %s from %s\n", imageRef, dockerfile)
|
||||
if err := rt.Build(ctx, imageRef, sourceDir, dockerfile); err != nil {
|
||||
return fmt.Errorf("build %s: %w", imageRef, err)
|
||||
}
|
||||
|
||||
fmt.Printf("pushing %s\n", imageRef)
|
||||
if err := rt.Push(ctx, imageRef); err != nil {
|
||||
return fmt.Errorf("push %s: %w", imageRef, err)
|
||||
}
|
||||
}
|
||||
|
||||
if imageFilter != "" {
|
||||
if _, ok := def.Build.Images[imageFilter]; !ok {
|
||||
return fmt.Errorf("image %q not found in [build.images] for service %q", imageFilter, def.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// findImageRef finds the full image reference for a build image name by
|
||||
// matching it against component image fields. The image name from
|
||||
// [build.images] matches the repository name in the component's image
|
||||
// reference (the path segment after the last slash, before the tag).
|
||||
func findImageRef(def *servicedef.ServiceDef, imageName string) string {
|
||||
for _, c := range def.Components {
|
||||
repoName := extractRepoName(c.Image)
|
||||
if repoName == imageName {
|
||||
return c.Image
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractRepoName returns the repository name from an image reference.
|
||||
// Examples:
|
||||
//
|
||||
// "mcr.svc.mcp.metacircular.net:8443/mcr:v1.1.0" -> "mcr"
|
||||
// "mcr.svc.mcp.metacircular.net:8443/mcr-web:v1.2.0" -> "mcr-web"
|
||||
// "mcr-web:v1.2.0" -> "mcr-web"
|
||||
// "mcr-web" -> "mcr-web"
|
||||
func extractRepoName(image string) string {
|
||||
// Strip registry prefix (everything up to and including the last slash).
|
||||
name := image
|
||||
if i := strings.LastIndex(image, "/"); i >= 0 {
|
||||
name = image[i+1:]
|
||||
}
|
||||
// Strip tag.
|
||||
if i := strings.LastIndex(name, ":"); i >= 0 {
|
||||
name = name[:i]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// ensureImages checks that all component images exist in the registry.
|
||||
// If an image is missing and the service has build configuration, it
|
||||
// builds and pushes the image. Returns nil if all images are available.
|
||||
func ensureImages(ctx context.Context, cfg *config.CLIConfig, def *servicedef.ServiceDef, rt *runtime.Podman, component string) error {
|
||||
if def.Build == nil || len(def.Build.Images) == 0 {
|
||||
return nil // no build config, skip auto-build
|
||||
}
|
||||
|
||||
for _, c := range def.Components {
|
||||
if component != "" && c.Name != component {
|
||||
continue
|
||||
}
|
||||
|
||||
repoName := extractRepoName(c.Image)
|
||||
dockerfile, ok := def.Build.Images[repoName]
|
||||
if !ok {
|
||||
continue // no Dockerfile for this image, skip
|
||||
}
|
||||
|
||||
exists, err := rt.ImageExists(ctx, c.Image)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check image %s: %w", c.Image, err)
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
|
||||
// Image missing — build and push.
|
||||
if def.Path == "" {
|
||||
return fmt.Errorf("image %s not found in registry and service %q has no path configured", c.Image, def.Name)
|
||||
}
|
||||
if cfg.Build.Workspace == "" {
|
||||
return fmt.Errorf("image %s not found in registry and build.workspace is not configured", c.Image)
|
||||
}
|
||||
|
||||
sourceDir := filepath.Join(cfg.Build.Workspace, def.Path)
|
||||
|
||||
fmt.Printf("image %s not found, building from %s\n", c.Image, dockerfile)
|
||||
if err := rt.Build(ctx, c.Image, sourceDir, dockerfile); err != nil {
|
||||
return fmt.Errorf("auto-build %s: %w", c.Image, err)
|
||||
}
|
||||
|
||||
fmt.Printf("pushing %s\n", c.Image)
|
||||
if err := rt.Push(ctx, c.Image); err != nil {
|
||||
return fmt.Errorf("auto-push %s: %w", c.Image, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
|
||||
"git.wntrmute.dev/kyle/mcp/internal/config"
|
||||
"git.wntrmute.dev/kyle/mcp/internal/runtime"
|
||||
"git.wntrmute.dev/kyle/mcp/internal/servicedef"
|
||||
)
|
||||
|
||||
@@ -31,6 +32,12 @@ func deployCmd() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
// Auto-build missing images if the service has build config.
|
||||
rt := &runtime.Podman{}
|
||||
if err := ensureImages(cmd.Context(), cfg, def, rt, component); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
spec := servicedef.ToProto(def)
|
||||
|
||||
address, err := findNodeAddress(cfg, def.Node)
|
||||
|
||||
@@ -34,6 +34,7 @@ func main() {
|
||||
})
|
||||
|
||||
root.AddCommand(loginCmd())
|
||||
root.AddCommand(buildCmd())
|
||||
root.AddCommand(deployCmd())
|
||||
root.AddCommand(stopCmd())
|
||||
root.AddCommand(startCmd())
|
||||
|
||||
Reference in New Issue
Block a user