feat: implement NATS-based NixOS deployment system

Implement the complete homelab-deploy system with three operational modes:

- Listener mode: Runs on NixOS hosts as a systemd service, subscribes to
  NATS subjects with configurable templates, executes nixos-rebuild on
  deployment requests with concurrency control

- MCP mode: MCP server exposing deploy, deploy_admin, and list_hosts
  tools for AI assistants with tiered access control

- CLI mode: Manual deployment commands with subject alias support via
  environment variables

Key components:
- internal/messages: Request/response types with validation
- internal/nats: Client wrapper with NKey authentication
- internal/deploy: Executor with timeout and lock for concurrency
- internal/listener: Subject template expansion and request handling
- internal/cli: Deploy logic with alias resolution
- internal/mcp: MCP server with mcp-go integration
- nixos/module.nix: NixOS module with hardened systemd service

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 04:19:47 +01:00
parent ad7d1a650c
commit fa49e9322a
27 changed files with 2929 additions and 26 deletions

61
internal/mcp/server.go Normal file
View File

@@ -0,0 +1,61 @@
package mcp
import (
"time"
"github.com/mark3labs/mcp-go/server"
)
// ServerConfig holds configuration for the MCP server.
type ServerConfig struct {
NATSUrl string
NKeyFile string
EnableAdmin bool
AdminNKeyFile string
DiscoverSubject string
Timeout time.Duration
}
// Server wraps the MCP server.
type Server struct {
cfg ServerConfig
server *server.MCPServer
}
// New creates a new MCP server.
func New(cfg ServerConfig) *Server {
s := server.NewMCPServer(
"homelab-deploy",
"0.1.0",
server.WithToolCapabilities(true),
)
handler := NewToolHandler(ToolConfig{
NATSUrl: cfg.NATSUrl,
NKeyFile: cfg.NKeyFile,
AdminNKeyFile: cfg.AdminNKeyFile,
DiscoverSubject: cfg.DiscoverSubject,
Timeout: cfg.Timeout,
})
// Register deploy tool (test-tier only)
s.AddTool(DeployTool(), handler.HandleDeploy)
// Register list_hosts tool
s.AddTool(ListHostsTool(), handler.HandleListHosts)
// Optionally register admin deploy tool
if cfg.EnableAdmin {
s.AddTool(DeployAdminTool(), handler.HandleDeployAdmin)
}
return &Server{
cfg: cfg,
server: s,
}
}
// Run starts the MCP server and blocks until completed.
func (s *Server) Run() error {
return server.ServeStdio(s.server)
}

View File

@@ -0,0 +1,43 @@
package mcp
import (
"testing"
"time"
)
func TestNew(t *testing.T) {
cfg := ServerConfig{
NATSUrl: "nats://localhost:4222",
NKeyFile: "/path/to/key",
EnableAdmin: false,
AdminNKeyFile: "",
DiscoverSubject: "deploy.discover",
Timeout: 10 * time.Minute,
}
s := New(cfg)
if s == nil {
t.Fatal("New() returned nil")
}
if s.server == nil {
t.Error("server should not be nil")
}
}
func TestNew_WithAdmin(t *testing.T) {
cfg := ServerConfig{
NATSUrl: "nats://localhost:4222",
NKeyFile: "/path/to/key",
EnableAdmin: true,
AdminNKeyFile: "/path/to/admin/key",
DiscoverSubject: "deploy.discover",
Timeout: 10 * time.Minute,
}
s := New(cfg)
if s == nil {
t.Fatal("New() returned nil")
}
}

208
internal/mcp/tools.go Normal file
View File

@@ -0,0 +1,208 @@
// Package mcp provides an MCP server for AI assistants.
package mcp
import (
"context"
"fmt"
"strings"
"time"
"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"
)
// ToolConfig holds configuration for the MCP tools.
type ToolConfig struct {
NATSUrl string
NKeyFile string
AdminNKeyFile string
DiscoverSubject string
Timeout time.Duration
}
// DeployTool creates the test-tier deploy tool definition.
func DeployTool() mcp.Tool {
return mcp.NewTool(
"deploy",
mcp.WithDescription("Deploy NixOS configuration to test-tier hosts"),
mcp.WithString("hostname",
mcp.Description("Target hostname, or omit to use 'all' or 'role' targeting"),
),
mcp.WithBoolean("all",
mcp.Description("Deploy to all test-tier hosts"),
),
mcp.WithString("role",
mcp.Description("Deploy to all test-tier hosts with this role"),
),
mcp.WithString("branch",
mcp.Description("Git branch or commit to deploy (default: master)"),
),
mcp.WithString("action",
mcp.Description("nixos-rebuild action: switch, boot, test, dry-activate (default: switch)"),
mcp.Enum("switch", "boot", "test", "dry-activate"),
),
)
}
// DeployAdminTool creates the admin deploy tool definition (all tiers).
func DeployAdminTool() mcp.Tool {
return mcp.NewTool(
"deploy_admin",
mcp.WithDescription("Deploy NixOS configuration to any host (admin access required)"),
mcp.WithString("tier",
mcp.Required(),
mcp.Description("Target tier: test or prod"),
mcp.Enum("test", "prod"),
),
mcp.WithString("hostname",
mcp.Description("Target hostname, or omit to use 'all' or 'role' targeting"),
),
mcp.WithBoolean("all",
mcp.Description("Deploy to all hosts in tier"),
),
mcp.WithString("role",
mcp.Description("Deploy to all hosts with this role in tier"),
),
mcp.WithString("branch",
mcp.Description("Git branch or commit to deploy (default: master)"),
),
mcp.WithString("action",
mcp.Description("nixos-rebuild action: switch, boot, test, dry-activate (default: switch)"),
mcp.Enum("switch", "boot", "test", "dry-activate"),
),
)
}
// ListHostsTool creates the list_hosts tool definition.
func ListHostsTool() mcp.Tool {
return mcp.NewTool(
"list_hosts",
mcp.WithDescription("List available deployment targets"),
mcp.WithString("tier",
mcp.Description("Filter by tier: test or prod (optional)"),
mcp.Enum("test", "prod"),
),
)
}
// ToolHandler handles tool calls.
type ToolHandler struct {
cfg ToolConfig
}
// NewToolHandler creates a new tool handler.
func NewToolHandler(cfg ToolConfig) *ToolHandler {
return &ToolHandler{cfg: cfg}
}
// HandleDeploy handles the deploy tool (test-tier only).
func (h *ToolHandler) HandleDeploy(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return h.handleDeployWithTier(ctx, request, "test", h.cfg.NKeyFile)
}
// HandleDeployAdmin handles the deploy_admin tool (any tier).
func (h *ToolHandler) HandleDeployAdmin(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
tier, err := request.RequireString("tier")
if err != nil {
return mcp.NewToolResultError("tier is required"), nil
}
if tier != "test" && tier != "prod" {
return mcp.NewToolResultError("tier must be 'test' or 'prod'"), nil
}
return h.handleDeployWithTier(ctx, request, tier, h.cfg.AdminNKeyFile)
}
func (h *ToolHandler) handleDeployWithTier(ctx context.Context, request mcp.CallToolRequest, tier, nkeyFile string) (*mcp.CallToolResult, error) {
// Build subject based on targeting
hostname := request.GetString("hostname", "")
all := request.GetBool("all", false)
role := request.GetString("role", "")
var subject string
if hostname != "" {
subject = fmt.Sprintf("deploy.%s.%s", tier, hostname)
} else if all {
subject = fmt.Sprintf("deploy.%s.all", tier)
} else if role != "" {
subject = fmt.Sprintf("deploy.%s.role.%s", tier, role)
} else {
return mcp.NewToolResultError("must specify hostname, all, or role"), nil
}
// Parse action
actionStr := request.GetString("action", "switch")
action := messages.Action(actionStr)
if !action.Valid() {
return mcp.NewToolResultError(fmt.Sprintf("invalid action: %s", actionStr)), nil
}
// Parse branch
branch := request.GetString("branch", "master")
cfg := deploycli.DeployConfig{
NATSUrl: h.cfg.NATSUrl,
NKeyFile: nkeyFile,
Subject: subject,
Action: action,
Revision: branch,
Timeout: h.cfg.Timeout,
}
var output strings.Builder
output.WriteString(fmt.Sprintf("Deploying to %s (action=%s, revision=%s)\n\n", subject, action, branch))
result, err := deploycli.Deploy(ctx, cfg, func(resp *messages.DeployResponse) {
status := string(resp.Status)
if resp.Error != nil {
status = fmt.Sprintf("%s (%s)", status, *resp.Error)
}
output.WriteString(fmt.Sprintf("[%s] %s: %s\n", resp.Hostname, status, resp.Message))
})
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("deployment failed: %v", err)), nil
}
output.WriteString(fmt.Sprintf("\nDeployment complete: %d hosts responded\n", result.HostCount()))
if !result.AllSucceeded() {
output.WriteString("WARNING: Some deployments failed\n")
}
return mcp.NewToolResultText(output.String()), nil
}
// HandleListHosts handles the list_hosts tool.
func (h *ToolHandler) HandleListHosts(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
tierFilter := request.GetString("tier", "")
responses, err := deploycli.Discover(ctx, h.cfg.NATSUrl, h.cfg.NKeyFile, h.cfg.DiscoverSubject, 5*time.Second)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("discovery failed: %v", err)), nil
}
if len(responses) == 0 {
return mcp.NewToolResultText("No hosts responded to discovery request"), nil
}
var output strings.Builder
output.WriteString("Available deployment targets:\n\n")
for _, resp := range responses {
if tierFilter != "" && resp.Tier != tierFilter {
continue
}
role := resp.Role
if role == "" {
role = "(none)"
}
output.WriteString(fmt.Sprintf("- %s (tier=%s, role=%s)\n", resp.Hostname, resp.Tier, role))
output.WriteString(fmt.Sprintf(" Subjects: %s\n", strings.Join(resp.DeploySubjects, ", ")))
}
return mcp.NewToolResultText(output.String()), nil
}

View File

@@ -0,0 +1,64 @@
package mcp
import (
"testing"
"time"
)
func TestNewToolHandler(t *testing.T) {
cfg := ToolConfig{
NATSUrl: "nats://localhost:4222",
NKeyFile: "/path/to/key",
AdminNKeyFile: "/path/to/admin/key",
DiscoverSubject: "deploy.discover",
Timeout: 10 * time.Minute,
}
h := NewToolHandler(cfg)
if h.cfg.NATSUrl != cfg.NATSUrl {
t.Errorf("NATSUrl = %q, want %q", h.cfg.NATSUrl, cfg.NATSUrl)
}
if h.cfg.NKeyFile != cfg.NKeyFile {
t.Errorf("NKeyFile = %q, want %q", h.cfg.NKeyFile, cfg.NKeyFile)
}
if h.cfg.Timeout != cfg.Timeout {
t.Errorf("Timeout = %v, want %v", h.cfg.Timeout, cfg.Timeout)
}
}
func TestDeployTool(t *testing.T) {
tool := DeployTool()
if tool.Name != "deploy" {
t.Errorf("Name = %q, want %q", tool.Name, "deploy")
}
if tool.Description == "" {
t.Error("Description should not be empty")
}
}
func TestDeployAdminTool(t *testing.T) {
tool := DeployAdminTool()
if tool.Name != "deploy_admin" {
t.Errorf("Name = %q, want %q", tool.Name, "deploy_admin")
}
if tool.Description == "" {
t.Error("Description should not be empty")
}
}
func TestListHostsTool(t *testing.T) {
tool := ListHostsTool()
if tool.Name != "list_hosts" {
t.Errorf("Name = %q, want %q", tool.Name, "list_hosts")
}
if tool.Description == "" {
t.Error("Description should not be empty")
}
}