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