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:
292
internal/messages/messages_test.go
Normal file
292
internal/messages/messages_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user