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

142
internal/nats/client.go Normal file
View File

@@ -0,0 +1,142 @@
// Package nats provides a NATS client wrapper with NKey authentication.
package nats
import (
"fmt"
"os"
"strings"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nkeys"
)
// Config holds the configuration for a NATS connection.
type Config struct {
URL string // NATS server URL
NKeyFile string // Path to NKey seed file
Name string // Client name for identification
}
// Client wraps a NATS connection with NKey authentication.
type Client struct {
conn *nats.Conn
}
// Connect establishes a connection to NATS using NKey authentication.
func Connect(cfg Config) (*Client, error) {
seed, err := os.ReadFile(cfg.NKeyFile)
if err != nil {
return nil, fmt.Errorf("failed to read nkey file: %w", err)
}
// Trim any whitespace from the seed
seedStr := strings.TrimSpace(string(seed))
kp, err := nkeys.FromSeed([]byte(seedStr))
if err != nil {
return nil, fmt.Errorf("failed to parse nkey seed: %w", err)
}
pubKey, err := kp.PublicKey()
if err != nil {
return nil, fmt.Errorf("failed to get public key: %w", err)
}
opts := []nats.Option{
nats.Name(cfg.Name),
nats.Nkey(pubKey, func(nonce []byte) ([]byte, error) {
return kp.Sign(nonce)
}),
nats.ReconnectWait(2 * time.Second),
nats.MaxReconnects(-1), // Unlimited reconnects
nats.ReconnectBufSize(8 * 1024 * 1024),
}
nc, err := nats.Connect(cfg.URL, opts...)
if err != nil {
return nil, fmt.Errorf("failed to connect to NATS: %w", err)
}
return &Client{conn: nc}, nil
}
// Subscription represents a NATS subscription.
type Subscription struct {
sub *nats.Subscription
}
// MessageHandler is a callback for received messages.
type MessageHandler func(subject string, data []byte)
// Subscribe subscribes to a subject and calls the handler for each message.
func (c *Client) Subscribe(subject string, handler MessageHandler) (*Subscription, error) {
sub, err := c.conn.Subscribe(subject, func(msg *nats.Msg) {
handler(msg.Subject, msg.Data)
})
if err != nil {
return nil, fmt.Errorf("failed to subscribe to %s: %w", subject, err)
}
return &Subscription{sub: sub}, nil
}
// QueueSubscribe subscribes to a subject with a queue group.
func (c *Client) QueueSubscribe(subject, queue string, handler MessageHandler) (*Subscription, error) {
sub, err := c.conn.QueueSubscribe(subject, queue, func(msg *nats.Msg) {
handler(msg.Subject, msg.Data)
})
if err != nil {
return nil, fmt.Errorf("failed to queue subscribe to %s: %w", subject, err)
}
return &Subscription{sub: sub}, nil
}
// Unsubscribe removes the subscription.
func (s *Subscription) Unsubscribe() error {
if s.sub != nil {
return s.sub.Unsubscribe()
}
return nil
}
// Publish sends a message to a subject.
func (c *Client) Publish(subject string, data []byte) error {
if err := c.conn.Publish(subject, data); err != nil {
return fmt.Errorf("failed to publish to %s: %w", subject, err)
}
return nil
}
// Request sends a request and waits for a response.
func (c *Client) Request(subject string, data []byte, timeout time.Duration) ([]byte, error) {
msg, err := c.conn.Request(subject, data, timeout)
if err != nil {
return nil, fmt.Errorf("request to %s failed: %w", subject, err)
}
return msg.Data, nil
}
// Flush flushes the connection, ensuring all published messages have been sent.
func (c *Client) Flush() error {
return c.conn.Flush()
}
// Close closes the NATS connection.
func (c *Client) Close() {
if c.conn != nil {
c.conn.Close()
}
}
// IsConnected returns true if the client is connected.
func (c *Client) IsConnected() bool {
return c.conn != nil && c.conn.IsConnected()
}
// Status returns the connection status.
func (c *Client) Status() nats.Status {
if c.conn == nil {
return nats.DISCONNECTED
}
return c.conn.Status()
}

View File

@@ -0,0 +1,127 @@
package nats
import (
"os"
"path/filepath"
"testing"
"github.com/nats-io/nkeys"
)
func TestConnect_InvalidNKeyFile(t *testing.T) {
cfg := Config{
URL: "nats://localhost:4222",
NKeyFile: "/nonexistent/file",
Name: "test",
}
_, err := Connect(cfg)
if err == nil {
t.Error("expected error for nonexistent nkey file")
}
}
func TestConnect_InvalidNKeySeed(t *testing.T) {
// Create a temp file with invalid content
tmpDir := t.TempDir()
keyFile := filepath.Join(tmpDir, "invalid.nkey")
if err := os.WriteFile(keyFile, []byte("invalid-seed-content"), 0600); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}
cfg := Config{
URL: "nats://localhost:4222",
NKeyFile: keyFile,
Name: "test",
}
_, err := Connect(cfg)
if err == nil {
t.Error("expected error for invalid nkey seed")
}
}
func TestConnect_ValidSeedParsing(t *testing.T) {
// Generate a valid NKey seed
kp, err := nkeys.CreateUser()
if err != nil {
t.Fatalf("failed to create nkey: %v", err)
}
seed, err := kp.Seed()
if err != nil {
t.Fatalf("failed to get seed: %v", err)
}
// Write seed to temp file
tmpDir := t.TempDir()
keyFile := filepath.Join(tmpDir, "test.nkey")
if err := os.WriteFile(keyFile, seed, 0600); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}
cfg := Config{
URL: "nats://localhost:4222", // Connection will fail, but parsing should work
NKeyFile: keyFile,
Name: "test",
}
// Connection will fail since no NATS server is running, but we're testing
// that the seed parsing works correctly
_, err = Connect(cfg)
if err == nil {
// If it somehow connects (unlikely), that's also fine
return
}
// Error should be about connection, not about nkey parsing
if err != nil && !contains(err.Error(), "connect") && !contains(err.Error(), "connection") {
t.Errorf("expected connection error, got: %v", err)
}
}
func TestConnect_SeedWithWhitespace(t *testing.T) {
// Generate a valid NKey seed
kp, err := nkeys.CreateUser()
if err != nil {
t.Fatalf("failed to create nkey: %v", err)
}
seed, err := kp.Seed()
if err != nil {
t.Fatalf("failed to get seed: %v", err)
}
// Write seed with trailing newline
tmpDir := t.TempDir()
keyFile := filepath.Join(tmpDir, "test.nkey")
seedWithNewline := append(seed, '\n', ' ', '\t', '\n')
if err := os.WriteFile(keyFile, seedWithNewline, 0600); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}
cfg := Config{
URL: "nats://localhost:4222",
NKeyFile: keyFile,
Name: "test",
}
// Should parse the seed correctly despite whitespace
_, err = Connect(cfg)
if err != nil && contains(err.Error(), "parse") {
t.Errorf("seed parsing should handle whitespace: %v", err)
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr))
}
func containsHelper(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}