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
+126
View File
@@ -0,0 +1,126 @@
package store
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"provisioning/internal/model"
)
type Hosts struct {
DB *sql.DB
}
const hostColumns = `id, hostname, mac, server_type, state, ip_address, hardware_id, infra_host_id, notes, created_at, updated_at`
func scanHost(row interface{ Scan(dest ...any) error }, h *model.Host) error {
var ip, hwID sql.NullString
var infraID sql.NullInt64
var createdAt, updatedAt string
if err := row.Scan(&h.ID, &h.Hostname, &h.MAC, &h.ServerType, &h.State,
&ip, &hwID, &infraID, &h.Notes, &createdAt, &updatedAt); err != nil {
return err
}
h.IPAddress = ip.String
h.HardwareID = hwID.String
if infraID.Valid {
h.InfraHostID = infraID.Int64
}
h.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
h.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
return nil
}
func (s *Hosts) Create(ctx context.Context, h model.Host) (int64, error) {
h.MAC = normalizeMAC(h.MAC)
res, err := s.DB.ExecContext(ctx, `
INSERT INTO hosts(hostname, mac, server_type, notes)
VALUES(?,?,?,?)
`, h.Hostname, h.MAC, h.ServerType, h.Notes)
if err != nil {
return 0, fmt.Errorf("insert host: %w", err)
}
return res.LastInsertId()
}
func (s *Hosts) List(ctx context.Context) ([]model.Host, error) {
rows, err := s.DB.QueryContext(ctx, `SELECT `+hostColumns+` FROM hosts ORDER BY hostname COLLATE NOCASE`)
if err != nil {
return nil, fmt.Errorf("list hosts: %w", err)
}
defer rows.Close()
var out []model.Host
for rows.Next() {
var h model.Host
if err := scanHost(rows, &h); err != nil {
return nil, fmt.Errorf("scan host: %w", err)
}
out = append(out, h)
}
return out, rows.Err()
}
func (s *Hosts) Get(ctx context.Context, id int64) (*model.Host, error) {
row := s.DB.QueryRowContext(ctx, `SELECT `+hostColumns+` FROM hosts WHERE id = ?`, id)
var h model.Host
if err := scanHost(row, &h); err != nil {
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
return nil, fmt.Errorf("get host: %w", err)
}
return &h, nil
}
func (s *Hosts) GetByMAC(ctx context.Context, mac string) (*model.Host, error) {
row := s.DB.QueryRowContext(ctx, `SELECT `+hostColumns+` FROM hosts WHERE mac = ?`, normalizeMAC(mac))
var h model.Host
if err := scanHost(row, &h); err != nil {
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
return nil, fmt.Errorf("get host by mac: %w", err)
}
return &h, nil
}
func (s *Hosts) UpdateState(ctx context.Context, id int64, state model.HostState) error {
res, err := s.DB.ExecContext(ctx, `UPDATE hosts SET state = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE id = ?`, state, id)
if err != nil {
return fmt.Errorf("update host state: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
func (s *Hosts) UpdateIP(ctx context.Context, id int64, ip string, hardwareID string) error {
_, err := s.DB.ExecContext(ctx, `UPDATE hosts SET ip_address = ?, hardware_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE id = ?`, ip, hardwareID, id)
return err
}
func (s *Hosts) UpdateInfraID(ctx context.Context, id int64, infraHostID int64) error {
_, err := s.DB.ExecContext(ctx, `UPDATE hosts SET infra_host_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE id = ?`, infraHostID, id)
return err
}
func (s *Hosts) Delete(ctx context.Context, id int64) error {
res, err := s.DB.ExecContext(ctx, `DELETE FROM hosts WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete host: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
func normalizeMAC(m string) string {
return strings.ToLower(strings.TrimSpace(m))
}
+101
View File
@@ -0,0 +1,101 @@
package store
import (
"context"
"database/sql"
"fmt"
"time"
"provisioning/internal/model"
)
type Images struct {
DB *sql.DB
}
func (s *Images) Create(ctx context.Context, img model.Image) (int64, error) {
res, err := s.DB.ExecContext(ctx, `
INSERT INTO images(name, kind, version, kernel_path, initrd_path, is_default)
VALUES(?,?,?,?,?,?)
`, img.Name, img.Kind, img.Version, img.KernelPath, img.InitrdPath, boolToInt(img.IsDefault))
if err != nil {
return 0, fmt.Errorf("insert image: %w", err)
}
return res.LastInsertId()
}
func (s *Images) List(ctx context.Context) ([]model.Image, error) {
rows, err := s.DB.QueryContext(ctx, `SELECT id, name, kind, version, kernel_path, initrd_path, is_default, created_at FROM images ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("list images: %w", err)
}
defer rows.Close()
var out []model.Image
for rows.Next() {
var img model.Image
var isDefault int
var createdAt string
if err := rows.Scan(&img.ID, &img.Name, &img.Kind, &img.Version, &img.KernelPath, &img.InitrdPath, &isDefault, &createdAt); err != nil {
return nil, fmt.Errorf("scan image: %w", err)
}
img.IsDefault = isDefault == 1
img.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
out = append(out, img)
}
return out, rows.Err()
}
func (s *Images) GetDefault(ctx context.Context) (*model.Image, error) {
row := s.DB.QueryRowContext(ctx, `SELECT id, name, kind, version, kernel_path, initrd_path, is_default, created_at FROM images WHERE is_default = 1 LIMIT 1`)
var img model.Image
var isDefault int
var createdAt string
if err := row.Scan(&img.ID, &img.Name, &img.Kind, &img.Version, &img.KernelPath, &img.InitrdPath, &isDefault, &createdAt); err != nil {
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
return nil, fmt.Errorf("get default image: %w", err)
}
img.IsDefault = true
img.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
return &img, nil
}
func (s *Images) Get(ctx context.Context, id int64) (*model.Image, error) {
row := s.DB.QueryRowContext(ctx, `SELECT id, name, kind, version, kernel_path, initrd_path, is_default, created_at FROM images WHERE id = ?`, id)
var img model.Image
var isDefault int
var createdAt string
if err := row.Scan(&img.ID, &img.Name, &img.Kind, &img.Version, &img.KernelPath, &img.InitrdPath, &isDefault, &createdAt); err != nil {
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
return nil, fmt.Errorf("get image: %w", err)
}
img.IsDefault = isDefault == 1
img.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
return &img, nil
}
func (s *Images) SetDefault(ctx context.Context, id int64) error {
tx, err := s.DB.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `UPDATE images SET is_default = 0`); err != nil {
_ = tx.Rollback()
return err
}
if _, err := tx.ExecContext(ctx, `UPDATE images SET is_default = 1 WHERE id = ?`, id); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
+46
View File
@@ -0,0 +1,46 @@
package store
import (
"context"
"database/sql"
"fmt"
"time"
)
type Locks struct {
DB *sql.DB
TTLMinutes int
}
func (s *Locks) Acquire(ctx context.Context, hostID, operationID int64) error {
s.cleanExpired(ctx)
expiresAt := time.Now().UTC().Add(time.Duration(s.TTLMinutes) * time.Minute).Format(time.RFC3339)
_, err := s.DB.ExecContext(ctx, `
INSERT INTO operation_locks(host_id, operation_id, expires_at)
VALUES(?,?,?)
`, hostID, operationID, expiresAt)
if err != nil {
return fmt.Errorf("acquire lock: %w", err)
}
return nil
}
func (s *Locks) Release(ctx context.Context, hostID int64) error {
_, err := s.DB.ExecContext(ctx, `DELETE FROM operation_locks WHERE host_id = ?`, hostID)
return err
}
func (s *Locks) IsLocked(ctx context.Context, hostID int64) (bool, error) {
s.cleanExpired(ctx)
var count int
err := s.DB.QueryRowContext(ctx, `SELECT COUNT(1) FROM operation_locks WHERE host_id = ?`, hostID).Scan(&count)
if err != nil {
return false, fmt.Errorf("check lock: %w", err)
}
return count > 0, nil
}
func (s *Locks) cleanExpired(ctx context.Context) {
now := time.Now().UTC().Format(time.RFC3339)
_, _ = s.DB.ExecContext(ctx, `DELETE FROM operation_locks WHERE expires_at < ?`, now)
}
+87
View File
@@ -0,0 +1,87 @@
package store
import (
"context"
"database/sql"
"fmt"
"time"
"provisioning/internal/model"
)
type Operations struct {
DB *sql.DB
}
func (s *Operations) Create(ctx context.Context, op model.Operation) (int64, error) {
res, err := s.DB.ExecContext(ctx, `
INSERT INTO operations(host_id, kind, state, image_id)
VALUES(?,?,?,?)
`, op.HostID, op.Kind, model.OpActive, nullInt64(op.ImageID))
if err != nil {
return 0, fmt.Errorf("insert operation: %w", err)
}
return res.LastInsertId()
}
func (s *Operations) Complete(ctx context.Context, id int64) error {
_, err := s.DB.ExecContext(ctx, `UPDATE operations SET state = ?, completed_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE id = ?`, model.OpCompleted, id)
return err
}
func (s *Operations) Fail(ctx context.Context, id int64, errMsg string) error {
_, err := s.DB.ExecContext(ctx, `UPDATE operations SET state = ?, completed_at = strftime('%Y-%m-%dT%H:%M:%SZ','now'), error_message = ? WHERE id = ?`, model.OpFailed, errMsg, id)
return err
}
func (s *Operations) ListByHost(ctx context.Context, hostID int64) ([]model.Operation, error) {
rows, err := s.DB.QueryContext(ctx, `
SELECT id, host_id, kind, state, COALESCE(image_id, 0), started_at, completed_at, COALESCE(error_message, '')
FROM operations WHERE host_id = ? ORDER BY started_at DESC
`, hostID)
if err != nil {
return nil, fmt.Errorf("list operations: %w", err)
}
defer rows.Close()
var out []model.Operation
for rows.Next() {
var op model.Operation
var startedAt string
var completedAt sql.NullString
if err := rows.Scan(&op.ID, &op.HostID, &op.Kind, &op.State, &op.ImageID, &startedAt, &completedAt, &op.ErrorMessage); err != nil {
return nil, fmt.Errorf("scan operation: %w", err)
}
op.StartedAt, _ = time.Parse(time.RFC3339, startedAt)
if completedAt.Valid {
t, _ := time.Parse(time.RFC3339, completedAt.String)
op.CompletedAt = &t
}
out = append(out, op)
}
return out, rows.Err()
}
func (s *Operations) GetActive(ctx context.Context, hostID int64) (*model.Operation, error) {
row := s.DB.QueryRowContext(ctx, `
SELECT id, host_id, kind, state, COALESCE(image_id, 0), started_at, completed_at, COALESCE(error_message, '')
FROM operations WHERE host_id = ? AND state = ? ORDER BY started_at DESC LIMIT 1
`, hostID, model.OpActive)
var op model.Operation
var startedAt string
var completedAt sql.NullString
if err := row.Scan(&op.ID, &op.HostID, &op.Kind, &op.State, &op.ImageID, &startedAt, &completedAt, &op.ErrorMessage); err != nil {
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
return nil, fmt.Errorf("get active operation: %w", err)
}
op.StartedAt, _ = time.Parse(time.RFC3339, startedAt)
return &op, nil
}
func nullInt64(v int64) any {
if v == 0 {
return nil
}
return v
}
+5
View File
@@ -0,0 +1,5 @@
package store
import "errors"
var ErrNotFound = errors.New("not found")