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>
191 lines
5.2 KiB
Go
191 lines
5.2 KiB
Go
// 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
|
|
}
|