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>
293 lines
6.5 KiB
Go
293 lines
6.5 KiB
Go
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")
|
|
}
|
|
}
|