feat: add builder mode for centralized Nix builds
Add a new "builder" capability to trigger Nix builds on a dedicated build host via NATS messaging. This allows pre-building NixOS configurations before deployment. New components: - Builder mode: subscribes to build.<repo>.* subjects, executes nix build - Build CLI command: triggers builds with progress tracking - MCP build tool: available with --enable-builds flag - Builder metrics: tracks build success/failure per repo and host - NixOS module: services.homelab-deploy.builder The builder uses a YAML config file to define allowed repositories with their URLs and default branches. Builds can target all hosts or specific hosts, with real-time progress updates. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
109
internal/mcp/build_tools.go
Normal file
109
internal/mcp/build_tools.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
|
||||
deploycli "git.t-juice.club/torjus/homelab-deploy/internal/cli"
|
||||
"git.t-juice.club/torjus/homelab-deploy/internal/messages"
|
||||
)
|
||||
|
||||
// BuildTool creates the build tool definition.
|
||||
func BuildTool() mcp.Tool {
|
||||
return mcp.NewTool(
|
||||
"build",
|
||||
mcp.WithDescription("Trigger a Nix build on the build server"),
|
||||
mcp.WithString("repo",
|
||||
mcp.Required(),
|
||||
mcp.Description("Repository name (must match builder config)"),
|
||||
),
|
||||
mcp.WithString("target",
|
||||
mcp.Description("Target hostname, or omit to build all hosts"),
|
||||
),
|
||||
mcp.WithBoolean("all",
|
||||
mcp.Description("Build all hosts in the repository (default if no target specified)"),
|
||||
),
|
||||
mcp.WithString("branch",
|
||||
mcp.Description("Git branch to build (uses repo default if not specified)"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// HandleBuild handles the build tool.
|
||||
func (h *ToolHandler) HandleBuild(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
repo, err := request.RequireString("repo")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError("repo is required"), nil
|
||||
}
|
||||
|
||||
target := request.GetString("target", "")
|
||||
all := request.GetBool("all", false)
|
||||
branch := request.GetString("branch", "")
|
||||
|
||||
// Default to "all" if no target specified
|
||||
if target == "" {
|
||||
if !all {
|
||||
all = true
|
||||
}
|
||||
target = "all"
|
||||
}
|
||||
if all && target != "all" {
|
||||
return mcp.NewToolResultError("cannot specify both target and all"), nil
|
||||
}
|
||||
|
||||
cfg := deploycli.BuildConfig{
|
||||
NATSUrl: h.cfg.NATSUrl,
|
||||
NKeyFile: h.cfg.NKeyFile,
|
||||
Repo: repo,
|
||||
Target: target,
|
||||
Branch: branch,
|
||||
Timeout: h.cfg.Timeout,
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
branchStr := branch
|
||||
if branchStr == "" {
|
||||
branchStr = "(default)"
|
||||
}
|
||||
output.WriteString(fmt.Sprintf("Building %s target=%s branch=%s\n\n", repo, target, branchStr))
|
||||
|
||||
result, err := deploycli.Build(ctx, cfg, func(resp *messages.BuildResponse) {
|
||||
switch resp.Status {
|
||||
case messages.BuildStatusStarted:
|
||||
output.WriteString(fmt.Sprintf("Started: %s\n", resp.Message))
|
||||
case messages.BuildStatusProgress:
|
||||
successStr := "..."
|
||||
if resp.HostSuccess != nil {
|
||||
if *resp.HostSuccess {
|
||||
successStr = "success"
|
||||
} else {
|
||||
successStr = "failed"
|
||||
}
|
||||
}
|
||||
output.WriteString(fmt.Sprintf("[%d/%d] %s: %s\n", resp.HostsCompleted, resp.HostsTotal, resp.Host, successStr))
|
||||
case messages.BuildStatusCompleted, messages.BuildStatusFailed:
|
||||
output.WriteString(fmt.Sprintf("\n%s\n", resp.Message))
|
||||
case messages.BuildStatusRejected:
|
||||
output.WriteString(fmt.Sprintf("Rejected: %s\n", resp.Message))
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("build failed: %v", err)), nil
|
||||
}
|
||||
|
||||
if result.FinalResponse != nil {
|
||||
output.WriteString(fmt.Sprintf("\nBuild complete: %d succeeded, %d failed (%.1fs)\n",
|
||||
result.FinalResponse.Succeeded,
|
||||
result.FinalResponse.Failed,
|
||||
result.FinalResponse.TotalDurationSeconds))
|
||||
}
|
||||
|
||||
if !result.AllSucceeded() {
|
||||
output.WriteString("WARNING: Some builds failed\n")
|
||||
}
|
||||
|
||||
return mcp.NewToolResultText(output.String()), nil
|
||||
}
|
||||
@@ -12,6 +12,7 @@ type ServerConfig struct {
|
||||
NKeyFile string
|
||||
EnableAdmin bool
|
||||
AdminNKeyFile string
|
||||
EnableBuilds bool
|
||||
DiscoverSubject string
|
||||
Timeout time.Duration
|
||||
}
|
||||
@@ -49,6 +50,11 @@ func New(cfg ServerConfig) *Server {
|
||||
s.AddTool(DeployAdminTool(), handler.HandleDeployAdmin)
|
||||
}
|
||||
|
||||
// Optionally register build tool
|
||||
if cfg.EnableBuilds {
|
||||
s.AddTool(BuildTool(), handler.HandleBuild)
|
||||
}
|
||||
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
server: s,
|
||||
|
||||
Reference in New Issue
Block a user