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

View File

@@ -0,0 +1,190 @@
// Package messages defines the message types used for NATS communication
// between deployment clients and listeners.
package messages
import (
"encoding/json"
"fmt"
"regexp"
)
// Action represents a nixos-rebuild action.
type Action string
const (
ActionSwitch Action = "switch"
ActionBoot Action = "boot"
ActionTest Action = "test"
ActionDryActivate Action = "dry-activate"
)
// Valid returns true if the action is a recognized nixos-rebuild action.
func (a Action) Valid() bool {
switch a {
case ActionSwitch, ActionBoot, ActionTest, ActionDryActivate:
return true
default:
return false
}
}
// Status represents the status of a deployment response.
type Status string
const (
StatusAccepted Status = "accepted"
StatusRejected Status = "rejected"
StatusStarted Status = "started"
StatusCompleted Status = "completed"
StatusFailed Status = "failed"
)
// IsFinal returns true if this status indicates a terminal state.
func (s Status) IsFinal() bool {
switch s {
case StatusCompleted, StatusFailed, StatusRejected:
return true
default:
return false
}
}
// ErrorCode represents an error condition.
type ErrorCode string
const (
ErrorInvalidRevision ErrorCode = "invalid_revision"
ErrorInvalidAction ErrorCode = "invalid_action"
ErrorAlreadyRunning ErrorCode = "already_running"
ErrorBuildFailed ErrorCode = "build_failed"
ErrorTimeout ErrorCode = "timeout"
)
// DeployRequest is the message sent to request a deployment.
type DeployRequest struct {
Action Action `json:"action"`
Revision string `json:"revision"`
ReplyTo string `json:"reply_to"`
}
// revisionRegex validates git branch names and commit hashes.
// Allows: alphanumeric, dashes, underscores, dots, slashes (for branch names),
// and hex strings (for commit hashes).
var revisionRegex = regexp.MustCompile(`^[a-zA-Z0-9._/-]+$`)
// Validate checks that the request is valid.
func (r *DeployRequest) Validate() error {
if !r.Action.Valid() {
return fmt.Errorf("invalid action: %q", r.Action)
}
if r.Revision == "" {
return fmt.Errorf("revision is required")
}
if !revisionRegex.MatchString(r.Revision) {
return fmt.Errorf("invalid revision format: %q", r.Revision)
}
if r.ReplyTo == "" {
return fmt.Errorf("reply_to is required")
}
return nil
}
// Marshal serializes the request to JSON.
func (r *DeployRequest) Marshal() ([]byte, error) {
return json.Marshal(r)
}
// UnmarshalDeployRequest deserializes a request from JSON.
func UnmarshalDeployRequest(data []byte) (*DeployRequest, error) {
var r DeployRequest
if err := json.Unmarshal(data, &r); err != nil {
return nil, fmt.Errorf("failed to unmarshal deploy request: %w", err)
}
return &r, nil
}
// DeployResponse is the message sent in response to a deployment request.
type DeployResponse struct {
Hostname string `json:"hostname"`
Status Status `json:"status"`
Error *ErrorCode `json:"error"`
Message string `json:"message"`
}
// NewDeployResponse creates a new response with the given hostname and status.
func NewDeployResponse(hostname string, status Status, message string) *DeployResponse {
return &DeployResponse{
Hostname: hostname,
Status: status,
Message: message,
}
}
// WithError adds an error code to the response.
func (r *DeployResponse) WithError(code ErrorCode) *DeployResponse {
r.Error = &code
return r
}
// Marshal serializes the response to JSON.
func (r *DeployResponse) Marshal() ([]byte, error) {
return json.Marshal(r)
}
// UnmarshalDeployResponse deserializes a response from JSON.
func UnmarshalDeployResponse(data []byte) (*DeployResponse, error) {
var r DeployResponse
if err := json.Unmarshal(data, &r); err != nil {
return nil, fmt.Errorf("failed to unmarshal deploy response: %w", err)
}
return &r, nil
}
// DiscoveryRequest is the message sent to discover available hosts.
type DiscoveryRequest struct {
ReplyTo string `json:"reply_to"`
}
// Validate checks that the request is valid.
func (r *DiscoveryRequest) Validate() error {
if r.ReplyTo == "" {
return fmt.Errorf("reply_to is required")
}
return nil
}
// Marshal serializes the request to JSON.
func (r *DiscoveryRequest) Marshal() ([]byte, error) {
return json.Marshal(r)
}
// UnmarshalDiscoveryRequest deserializes a request from JSON.
func UnmarshalDiscoveryRequest(data []byte) (*DiscoveryRequest, error) {
var r DiscoveryRequest
if err := json.Unmarshal(data, &r); err != nil {
return nil, fmt.Errorf("failed to unmarshal discovery request: %w", err)
}
return &r, nil
}
// DiscoveryResponse is the message sent in response to a discovery request.
type DiscoveryResponse struct {
Hostname string `json:"hostname"`
Tier string `json:"tier"`
Role string `json:"role,omitempty"`
DeploySubjects []string `json:"deploy_subjects"`
}
// Marshal serializes the response to JSON.
func (r *DiscoveryResponse) Marshal() ([]byte, error) {
return json.Marshal(r)
}
// UnmarshalDiscoveryResponse deserializes a response from JSON.
func UnmarshalDiscoveryResponse(data []byte) (*DiscoveryResponse, error) {
var r DiscoveryResponse
if err := json.Unmarshal(data, &r); err != nil {
return nil, fmt.Errorf("failed to unmarshal discovery response: %w", err)
}
return &r, nil
}

View File

@@ -0,0 +1,292 @@
package messages
import (
"encoding/json"
"testing"
)
func TestAction_Valid(t *testing.T) {
tests := []struct {
action Action
valid bool
}{
{ActionSwitch, true},
{ActionBoot, true},
{ActionTest, true},
{ActionDryActivate, true},
{Action("invalid"), false},
{Action(""), false},
}
for _, tc := range tests {
t.Run(string(tc.action), func(t *testing.T) {
if got := tc.action.Valid(); got != tc.valid {
t.Errorf("Action(%q).Valid() = %v, want %v", tc.action, got, tc.valid)
}
})
}
}
func TestStatus_IsFinal(t *testing.T) {
tests := []struct {
status Status
final bool
}{
{StatusAccepted, false},
{StatusStarted, false},
{StatusCompleted, true},
{StatusFailed, true},
{StatusRejected, true},
}
for _, tc := range tests {
t.Run(string(tc.status), func(t *testing.T) {
if got := tc.status.IsFinal(); got != tc.final {
t.Errorf("Status(%q).IsFinal() = %v, want %v", tc.status, got, tc.final)
}
})
}
}
func TestDeployRequest_Validate(t *testing.T) {
tests := []struct {
name string
req DeployRequest
wantErr bool
}{
{
name: "valid request with branch",
req: DeployRequest{
Action: ActionSwitch,
Revision: "master",
ReplyTo: "deploy.responses.abc123",
},
wantErr: false,
},
{
name: "valid request with commit hash",
req: DeployRequest{
Action: ActionBoot,
Revision: "abc123def456",
ReplyTo: "deploy.responses.xyz",
},
wantErr: false,
},
{
name: "valid request with feature branch",
req: DeployRequest{
Action: ActionTest,
Revision: "feature/my-feature",
ReplyTo: "deploy.responses.test",
},
wantErr: false,
},
{
name: "valid request with dotted branch",
req: DeployRequest{
Action: ActionDryActivate,
Revision: "release-1.0.0",
ReplyTo: "deploy.responses.test",
},
wantErr: false,
},
{
name: "invalid action",
req: DeployRequest{
Action: Action("invalid"),
Revision: "master",
ReplyTo: "deploy.responses.abc",
},
wantErr: true,
},
{
name: "empty revision",
req: DeployRequest{
Action: ActionSwitch,
Revision: "",
ReplyTo: "deploy.responses.abc",
},
wantErr: true,
},
{
name: "invalid revision with spaces",
req: DeployRequest{
Action: ActionSwitch,
Revision: "my branch",
ReplyTo: "deploy.responses.abc",
},
wantErr: true,
},
{
name: "invalid revision with special chars",
req: DeployRequest{
Action: ActionSwitch,
Revision: "branch;rm -rf /",
ReplyTo: "deploy.responses.abc",
},
wantErr: true,
},
{
name: "empty reply_to",
req: DeployRequest{
Action: ActionSwitch,
Revision: "master",
ReplyTo: "",
},
wantErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.req.Validate()
if (err != nil) != tc.wantErr {
t.Errorf("Validate() error = %v, wantErr %v", err, tc.wantErr)
}
})
}
}
func TestDeployRequest_Marshal_Unmarshal(t *testing.T) {
req := &DeployRequest{
Action: ActionSwitch,
Revision: "master",
ReplyTo: "deploy.responses.abc123",
}
data, err := req.Marshal()
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
got, err := UnmarshalDeployRequest(data)
if err != nil {
t.Fatalf("UnmarshalDeployRequest() error = %v", err)
}
if got.Action != req.Action || got.Revision != req.Revision || got.ReplyTo != req.ReplyTo {
t.Errorf("roundtrip failed: got %+v, want %+v", got, req)
}
}
func TestDeployResponse_Marshal_Unmarshal(t *testing.T) {
errCode := ErrorBuildFailed
resp := &DeployResponse{
Hostname: "host1",
Status: StatusFailed,
Error: &errCode,
Message: "build failed with exit code 1",
}
data, err := resp.Marshal()
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
got, err := UnmarshalDeployResponse(data)
if err != nil {
t.Fatalf("UnmarshalDeployResponse() error = %v", err)
}
if got.Hostname != resp.Hostname || got.Status != resp.Status || got.Message != resp.Message {
t.Errorf("roundtrip failed: got %+v, want %+v", got, resp)
}
if got.Error == nil || *got.Error != *resp.Error {
t.Errorf("error code mismatch: got %v, want %v", got.Error, resp.Error)
}
}
func TestDeployResponse_NullError(t *testing.T) {
resp := NewDeployResponse("host1", StatusCompleted, "success")
data, err := resp.Marshal()
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
// Verify null error is serialized correctly
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if m["error"] != nil {
t.Errorf("expected null error, got %v", m["error"])
}
}
func TestDiscoveryRequest_Validate(t *testing.T) {
tests := []struct {
name string
req DiscoveryRequest
wantErr bool
}{
{
name: "valid request",
req: DiscoveryRequest{ReplyTo: "deploy.responses.discover-abc"},
wantErr: false,
},
{
name: "empty reply_to",
req: DiscoveryRequest{ReplyTo: ""},
wantErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.req.Validate()
if (err != nil) != tc.wantErr {
t.Errorf("Validate() error = %v, wantErr %v", err, tc.wantErr)
}
})
}
}
func TestDiscoveryResponse_Marshal_Unmarshal(t *testing.T) {
resp := &DiscoveryResponse{
Hostname: "ns1",
Tier: "prod",
Role: "dns",
DeploySubjects: []string{"deploy.prod.ns1", "deploy.prod.all", "deploy.prod.role.dns"},
}
data, err := resp.Marshal()
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
got, err := UnmarshalDiscoveryResponse(data)
if err != nil {
t.Fatalf("UnmarshalDiscoveryResponse() error = %v", err)
}
if got.Hostname != resp.Hostname || got.Tier != resp.Tier || got.Role != resp.Role {
t.Errorf("roundtrip failed: got %+v, want %+v", got, resp)
}
if len(got.DeploySubjects) != len(resp.DeploySubjects) {
t.Errorf("subjects length mismatch: got %d, want %d", len(got.DeploySubjects), len(resp.DeploySubjects))
}
}
func TestDiscoveryResponse_OmitEmptyRole(t *testing.T) {
resp := &DiscoveryResponse{
Hostname: "host1",
Tier: "test",
Role: "",
DeploySubjects: []string{"deploy.test.host1"},
}
data, err := resp.Marshal()
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if _, exists := m["role"]; exists {
t.Error("expected role to be omitted when empty")
}
}