Initial implementation: host lifecycle + PXE + admin dashboard

Go service for Proxmox homelab cluster provisioning. Handles PXE boot,
Proxmox autoinstall (answer file generation), cluster join via SSH,
and Infrastructure API registration.

- Host state machine (registered → pxe_ready → installing → ready)
- dnsmasq supervisor with MAC-based allowlist
- iPXE script and Proxmox answer file generation
- First-boot phone-home → cluster join → infra registration
- Operation locking with expiry (409 on conflict)
- SSE event hub for real-time dashboard updates
- Admin dashboard (host grid, detail, registration form)
- Config-driven server types with hot-reload
- Docker deployment (multi-stage fat image)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-03 20:55:14 -04:00
commit bda568b25c
39 changed files with 3067 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
package infra
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
type Client struct {
BaseURL string
HTTPClient *http.Client
}
func NewClient(baseURL string, timeout time.Duration) *Client {
return &Client{
BaseURL: baseURL,
HTTPClient: &http.Client{Timeout: timeout},
}
}
type CreateHostRequest struct {
HardwareID string `json:"hardware_id"`
Hostname string `json:"hostname"`
AssetID string `json:"asset_id"`
RoomID int `json:"room_id"`
ServerTypeID int `json:"server_type_id"`
}
type CreateHostResponse struct {
ID int64 `json:"id"`
}
func (c *Client) CreateHost(ctx context.Context, req CreateHostRequest) (int64, error) {
body, err := json.Marshal(req)
if err != nil {
return 0, err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/api/hosts", bytes.NewReader(body))
if err != nil {
return 0, err
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(httpReq)
if err != nil {
return 0, fmt.Errorf("infra: create host: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
return 0, fmt.Errorf("infra: create host: status %d", resp.StatusCode)
}
var result CreateHostResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return 0, fmt.Errorf("infra: decode response: %w", err)
}
return result.ID, nil
}
type CreateInterfaceRequest struct {
HostID int `json:"host_id"`
Name string `json:"name"`
MACAddress string `json:"mac_address"`
IPAddress string `json:"ip_address"`
}
func (c *Client) CreateInterface(ctx context.Context, req CreateInterfaceRequest) error {
body, err := json.Marshal(req)
if err != nil {
return err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/api/interfaces", bytes.NewReader(body))
if err != nil {
return err
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(httpReq)
if err != nil {
return fmt.Errorf("infra: create interface: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
return fmt.Errorf("infra: create interface: status %d", resp.StatusCode)
}
return nil
}