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:
+27
@@ -0,0 +1,27 @@
|
|||||||
|
# Binary
|
||||||
|
provisioning
|
||||||
|
provisioning.exe
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
data/
|
||||||
|
var/
|
||||||
|
|
||||||
|
# Config with secrets
|
||||||
|
provisioning.yaml
|
||||||
|
provisioning.production.yaml
|
||||||
|
server-types.yaml
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Go
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Templ generated
|
||||||
|
*_templ.go
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
.PHONY: build dev test clean
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build -o provisioning ./cmd/provisioning
|
||||||
|
|
||||||
|
dev:
|
||||||
|
go run ./cmd/provisioning -config deploy/provisioning.example.yaml
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f provisioning
|
||||||
|
|
||||||
|
docker:
|
||||||
|
docker build -f deploy/Dockerfile -t provisioning .
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
FROM golang:1.23-bookworm AS builder
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 go build -o /provisioning ./cmd/provisioning
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
dnsmasq \
|
||||||
|
openssh-client \
|
||||||
|
ipxe \
|
||||||
|
ca-certificates \
|
||||||
|
dmidecode \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=builder /provisioning /usr/local/bin/provisioning
|
||||||
|
|
||||||
|
RUN mkdir -p /data /etc/provisioning/keys
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
VOLUME ["/data", "/etc/provisioning"]
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/provisioning"]
|
||||||
|
CMD ["-config", "/etc/provisioning/provisioning.yaml"]
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
server:
|
||||||
|
bind: "0.0.0.0:8080"
|
||||||
|
public_url: "http://192.168.1.100:8080"
|
||||||
|
|
||||||
|
database:
|
||||||
|
path: "./data/provisioning.db"
|
||||||
|
|
||||||
|
pxe:
|
||||||
|
enabled: true
|
||||||
|
interface: "eth0"
|
||||||
|
subnet: "192.168.1.0/24"
|
||||||
|
runtime_dir: "./data/pxe"
|
||||||
|
tftp_root: "./data/tftp"
|
||||||
|
dnsmasq_bin: "/usr/sbin/dnsmasq"
|
||||||
|
|
||||||
|
images:
|
||||||
|
dir: "./data/images"
|
||||||
|
|
||||||
|
proxmox:
|
||||||
|
existing_node: "192.168.1.10"
|
||||||
|
cluster_name: "homelab"
|
||||||
|
join_fingerprint: "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"
|
||||||
|
|
||||||
|
credentials:
|
||||||
|
ssh_private_key_path: "/etc/provisioning/keys/id_ed25519"
|
||||||
|
ssh_public_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAEXAMPLE provisioning@homelab"
|
||||||
|
root_password_hash: "$6$rounds=5000$randomsalt$hashedpasswordhere"
|
||||||
|
|
||||||
|
infrastructure:
|
||||||
|
base_url: "http://192.168.1.50:3000"
|
||||||
|
room_id: 1
|
||||||
|
server_type_map:
|
||||||
|
minisforum-ms-01: 1
|
||||||
|
minisforum-um790: 2
|
||||||
|
timeout_seconds: 10
|
||||||
|
|
||||||
|
locks:
|
||||||
|
ttl_minutes: 60
|
||||||
|
|
||||||
|
server_types_path: "./server-types.yaml"
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
server_types:
|
||||||
|
minisforum-ms-01:
|
||||||
|
display_name: "Minisforum MS-01"
|
||||||
|
boot_disk: "/dev/nvme0n1"
|
||||||
|
management_nic: "enp2s0"
|
||||||
|
gpu: false
|
||||||
|
hostname_prefix: "pve-ms"
|
||||||
|
expected_nics:
|
||||||
|
- name: "enp2s0"
|
||||||
|
speed: "2500"
|
||||||
|
- name: "enp3s0"
|
||||||
|
speed: "2500"
|
||||||
|
|
||||||
|
minisforum-um790:
|
||||||
|
display_name: "Minisforum UM790 Pro"
|
||||||
|
boot_disk: "/dev/nvme0n1"
|
||||||
|
management_nic: "enp1s0"
|
||||||
|
gpu: true
|
||||||
|
hostname_prefix: "pve-um"
|
||||||
|
expected_nics:
|
||||||
|
- name: "enp1s0"
|
||||||
|
speed: "2500"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
module provisioning
|
||||||
|
|
||||||
|
go 1.23.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/fsnotify/fsnotify v1.8.0
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0
|
||||||
|
golang.org/x/crypto v0.28.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
modernc.org/sqlite v1.33.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/sys v0.34.0 // indirect
|
||||||
|
golang.org/x/tools v0.35.0 // indirect
|
||||||
|
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||||
|
modernc.org/libc v1.55.3 // indirect
|
||||||
|
modernc.org/mathutil v1.6.0 // indirect
|
||||||
|
modernc.org/memory v1.8.0 // indirect
|
||||||
|
modernc.org/strutil v1.2.0 // indirect
|
||||||
|
modernc.org/token v1.1.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||||
|
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||||
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||||
|
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||||
|
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
|
||||||
|
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
|
||||||
|
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||||
|
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||||
|
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
|
||||||
|
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
|
||||||
|
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
|
||||||
|
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||||
|
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||||
|
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||||
|
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||||
|
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||||
|
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||||
|
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||||
|
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||||
|
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
||||||
|
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||||
|
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||||
|
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||||
|
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||||
|
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||||
|
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||||
|
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||||
|
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||||
|
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||||
|
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||||
|
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||||
|
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
|
||||||
|
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
|
||||||
|
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||||
|
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"provisioning/internal/config"
|
||||||
|
"provisioning/internal/model"
|
||||||
|
"provisioning/internal/orchestrator"
|
||||||
|
"provisioning/internal/pxe"
|
||||||
|
"provisioning/internal/statemachine"
|
||||||
|
"provisioning/internal/store"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BootAPI struct {
|
||||||
|
Hosts *store.Hosts
|
||||||
|
Images *store.Images
|
||||||
|
Runner *orchestrator.Runner
|
||||||
|
Orchestrator *orchestrator.HostOrchestrator
|
||||||
|
Config *config.Config
|
||||||
|
ServerTypes *config.ServerTypeRegistry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *BootAPI) IPXEScript(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mac := normalizeMAC(chi.URLParam(r, "mac"))
|
||||||
|
host, err := a.Hosts.GetByMAC(r.Context(), mac)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
http.Error(w, "#!ipxe\nexit", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
img, err := a.Images.GetDefault(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "#!ipxe\necho No default image configured\nshell", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if host.State == model.StatePXEReady {
|
||||||
|
a.Runner.Transition(r.Context(), host.ID, statemachine.TriggerPXEScriptServed)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
w.Write([]byte(pxe.BuildIPXEScript(a.Config.Server.PublicURL, img, mac)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *BootAPI) AnswerFile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var sysInfo struct {
|
||||||
|
MAC string `json:"mac"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&sysInfo); err != nil {
|
||||||
|
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mac := normalizeMAC(sysInfo.MAC)
|
||||||
|
host, err := a.Hosts.GetByMAC(r.Context(), mac)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
http.Error(w, "unknown host", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
st, ok := a.ServerTypes.Get(host.ServerType)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unknown server type", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if host.State == model.StatePXEBooted {
|
||||||
|
a.Runner.Transition(r.Context(), host.ID, statemachine.TriggerAnswerServed)
|
||||||
|
}
|
||||||
|
|
||||||
|
answer := pxe.GenerateAnswerFile(host, st, a.Config)
|
||||||
|
w.Header().Set("Content-Type", "application/toml")
|
||||||
|
w.Write([]byte(answer))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *BootAPI) InstallComplete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := idFromURL(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
host, err := a.Hosts.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONErr(w, http.StatusNotFound, "host not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if host.State == model.StateInstalling {
|
||||||
|
if _, err := a.Runner.Transition(r.Context(), host.ID, statemachine.TriggerInstallWebhook); err != nil {
|
||||||
|
log.Printf("host %d: install-complete transition failed: %v", host.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *BootAPI) FirstBootScript(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := idFromURL(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
host, err := a.Hosts.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "host not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
st, ok := a.ServerTypes.Get(host.ServerType)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unknown server type", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
script := pxe.GenerateFirstBootScript(host, st, a.Config)
|
||||||
|
w.Header().Set("Content-Type", "text/x-shellscript")
|
||||||
|
w.Write([]byte(script))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *BootAPI) PhoneHome(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := idFromURL(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
HardwareID string `json:"hardware_id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONErr(w, http.StatusBadRequest, "invalid json")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
host, err := a.Hosts.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONErr(w, http.StatusNotFound, "host not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("host %d (%s): phone-home from %s, hwid=%s", host.ID, host.Hostname, req.IP, req.HardwareID)
|
||||||
|
a.Orchestrator.HandlePhoneHome(r.Context(), host.ID, req.IP, req.HardwareID)
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMAC(m string) string {
|
||||||
|
m = strings.ToLower(strings.TrimSpace(m))
|
||||||
|
m = strings.ReplaceAll(m, "-", ":")
|
||||||
|
return m
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"provisioning/internal/config"
|
||||||
|
"provisioning/internal/model"
|
||||||
|
"provisioning/internal/orchestrator"
|
||||||
|
"provisioning/internal/pxe"
|
||||||
|
"provisioning/internal/statemachine"
|
||||||
|
"provisioning/internal/store"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HostAPI struct {
|
||||||
|
Hosts *store.Hosts
|
||||||
|
Ops *store.Operations
|
||||||
|
Locks *store.Locks
|
||||||
|
Images *store.Images
|
||||||
|
Runner *orchestrator.Runner
|
||||||
|
PXE *pxe.Supervisor
|
||||||
|
Config *config.Config
|
||||||
|
ServerTypes *config.ServerTypeRegistry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *HostAPI) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hosts, err := a.Hosts.List(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeJSONErr(w, http.StatusInternalServerError, "failed to list hosts")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, hosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *HostAPI) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
host, ok := a.hostFromURL(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *HostAPI) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
MAC string `json:"mac"`
|
||||||
|
ServerType string `json:"server_type"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONErr(w, http.StatusBadRequest, "invalid json")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Hostname == "" || req.MAC == "" || req.ServerType == "" {
|
||||||
|
writeJSONErr(w, http.StatusBadRequest, "hostname, mac, and server_type are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := a.ServerTypes.Get(req.ServerType); !ok {
|
||||||
|
writeJSONErr(w, http.StatusBadRequest, "unknown server_type")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := a.Hosts.Create(r.Context(), model.Host{
|
||||||
|
Hostname: req.Hostname,
|
||||||
|
MAC: req.MAC,
|
||||||
|
ServerType: req.ServerType,
|
||||||
|
Notes: req.Notes,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeJSONErr(w, http.StatusConflict, "host already exists: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.reloadPXE()
|
||||||
|
|
||||||
|
host, _ := a.Hosts.Get(r.Context(), id)
|
||||||
|
writeJSON(w, http.StatusCreated, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *HostAPI) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := idFromURL(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.Hosts.Delete(r.Context(), id); err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
writeJSONErr(w, http.StatusNotFound, "host not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONErr(w, http.StatusInternalServerError, "failed to delete host")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.reloadPXE()
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *HostAPI) Rebuild(w http.ResponseWriter, r *http.Request) {
|
||||||
|
host, ok := a.hostFromURL(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
locked, _ := a.Locks.IsLocked(r.Context(), host.ID)
|
||||||
|
if locked {
|
||||||
|
writeJSONErr(w, http.StatusConflict, "host is locked by another operation")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
opID, err := a.Ops.Create(r.Context(), model.Operation{
|
||||||
|
HostID: host.ID,
|
||||||
|
Kind: model.OpRebuildProxmox,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeJSONErr(w, http.StatusInternalServerError, "failed to create operation")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.Locks.Acquire(r.Context(), host.ID, opID); err != nil {
|
||||||
|
writeJSONErr(w, http.StatusInternalServerError, "failed to acquire lock")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := a.Runner.Transition(r.Context(), host.ID, statemachine.TriggerRebuildRequested); err != nil {
|
||||||
|
_ = a.Locks.Release(r.Context(), host.ID)
|
||||||
|
writeJSONErr(w, http.StatusConflict, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.reloadPXE()
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "operation_id": opID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *HostAPI) hostFromURL(w http.ResponseWriter, r *http.Request) (*model.Host, bool) {
|
||||||
|
id, ok := idFromURL(w, r)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
host, err := a.Hosts.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
writeJSONErr(w, http.StatusNotFound, "host not found")
|
||||||
|
} else {
|
||||||
|
writeJSONErr(w, http.StatusInternalServerError, "failed to get host")
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return host, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *HostAPI) reloadPXE() {
|
||||||
|
if a.PXE == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hosts, _ := a.Hosts.List(context.Background())
|
||||||
|
_ = a.PXE.Reload(hosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func idFromURL(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||||
|
s := chi.URLParam(r, "id")
|
||||||
|
id, err := strconv.ParseInt(s, 10, 64)
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
writeJSONErr(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSONErr(w http.ResponseWriter, status int, msg string) {
|
||||||
|
writeJSON(w, status, map[string]any{"ok": false, "error": msg})
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"provisioning/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func renderHTML(w http.ResponseWriter, body string) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write([]byte(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
func dashboardPage(hosts []model.Host) string {
|
||||||
|
var tiles strings.Builder
|
||||||
|
for _, h := range hosts {
|
||||||
|
tiles.WriteString(hostTile(h))
|
||||||
|
}
|
||||||
|
if len(hosts) == 0 {
|
||||||
|
tiles.WriteString(`<p class="empty">No hosts registered. <a href="/hosts/new">Register one.</a></p>`)
|
||||||
|
}
|
||||||
|
return layout("Dashboard", fmt.Sprintf(`
|
||||||
|
<div class="actions">
|
||||||
|
<a href="/hosts/new" class="btn">Register Host</a>
|
||||||
|
<span class="count">%d hosts</span>
|
||||||
|
</div>
|
||||||
|
<div class="host-grid">%s</div>
|
||||||
|
`, len(hosts), tiles.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostTile(h model.Host) string {
|
||||||
|
stateClass := stateColor(h.State)
|
||||||
|
return fmt.Sprintf(`
|
||||||
|
<a href="/hosts/%d" class="tile %s" id="tile-%d">
|
||||||
|
<div class="tile-name">%s</div>
|
||||||
|
<div class="tile-type">%s</div>
|
||||||
|
<div class="tile-state">%s</div>
|
||||||
|
<div class="tile-mac">%s</div>
|
||||||
|
</a>
|
||||||
|
`, h.ID, stateClass, h.ID, html.EscapeString(h.Hostname), html.EscapeString(h.ServerType), h.State, h.MAC)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostFormPage(types []string, errMsg string, prefill *model.Host) string {
|
||||||
|
var opts strings.Builder
|
||||||
|
for _, t := range types {
|
||||||
|
selected := ""
|
||||||
|
if prefill != nil && prefill.ServerType == t {
|
||||||
|
selected = " selected"
|
||||||
|
}
|
||||||
|
opts.WriteString(fmt.Sprintf(`<option value="%s"%s>%s</option>`, t, selected, t))
|
||||||
|
}
|
||||||
|
errHTML := ""
|
||||||
|
if errMsg != "" {
|
||||||
|
errHTML = fmt.Sprintf(`<div class="error">%s</div>`, html.EscapeString(errMsg))
|
||||||
|
}
|
||||||
|
hostname, mac, notes := "", "", ""
|
||||||
|
if prefill != nil {
|
||||||
|
hostname = html.EscapeString(prefill.Hostname)
|
||||||
|
mac = html.EscapeString(prefill.MAC)
|
||||||
|
notes = html.EscapeString(prefill.Notes)
|
||||||
|
}
|
||||||
|
return layout("Register Host", fmt.Sprintf(`
|
||||||
|
<h2>Register Host</h2>
|
||||||
|
%s
|
||||||
|
<form method="POST" action="/hosts" class="form">
|
||||||
|
<label>Hostname<input type="text" name="hostname" value="%s" required></label>
|
||||||
|
<label>MAC Address<input type="text" name="mac" value="%s" placeholder="aa:bb:cc:dd:ee:ff" required></label>
|
||||||
|
<label>Server Type<select name="server_type" required>%s</select></label>
|
||||||
|
<label>Notes<textarea name="notes">%s</textarea></label>
|
||||||
|
<button type="submit" class="btn">Register</button>
|
||||||
|
</form>
|
||||||
|
`, errHTML, hostname, mac, opts.String(), notes))
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostDetailPage(h *model.Host, ops []model.Operation) string {
|
||||||
|
stateClass := stateColor(h.State)
|
||||||
|
canRebuild := h.State == model.StateRegistered || h.State == model.StateReady || h.State == model.StateFailed
|
||||||
|
|
||||||
|
var actions strings.Builder
|
||||||
|
if canRebuild {
|
||||||
|
actions.WriteString(fmt.Sprintf(`<form method="POST" action="/hosts/%d/rebuild" class="inline"><button class="btn">Rebuild with Proxmox</button></form>`, h.ID))
|
||||||
|
}
|
||||||
|
actions.WriteString(fmt.Sprintf(`<form method="POST" action="/hosts/%d/delete" class="inline" onsubmit="return confirm('Delete this host?')"><button class="btn btn-danger">Delete</button></form>`, h.ID))
|
||||||
|
|
||||||
|
var opsHTML strings.Builder
|
||||||
|
for _, op := range ops {
|
||||||
|
duration := ""
|
||||||
|
if op.CompletedAt != nil {
|
||||||
|
duration = op.CompletedAt.Sub(op.StartedAt).Truncate(1e9).String()
|
||||||
|
}
|
||||||
|
errCell := ""
|
||||||
|
if op.ErrorMessage != "" {
|
||||||
|
errCell = html.EscapeString(op.ErrorMessage)
|
||||||
|
}
|
||||||
|
opsHTML.WriteString(fmt.Sprintf(`<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>`,
|
||||||
|
op.Kind, op.State, op.StartedAt.Format("2006-01-02 15:04"), duration, errCell))
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := h.IPAddress
|
||||||
|
if ip == "" {
|
||||||
|
ip = "—"
|
||||||
|
}
|
||||||
|
|
||||||
|
return layout(h.Hostname, fmt.Sprintf(`
|
||||||
|
<div class="host-header">
|
||||||
|
<h2>%s</h2>
|
||||||
|
<span class="badge %s">%s</span>
|
||||||
|
</div>
|
||||||
|
<table class="detail-table">
|
||||||
|
<tr><th>MAC</th><td>%s</td></tr>
|
||||||
|
<tr><th>Server Type</th><td>%s</td></tr>
|
||||||
|
<tr><th>IP Address</th><td>%s</td></tr>
|
||||||
|
<tr><th>Notes</th><td>%s</td></tr>
|
||||||
|
</table>
|
||||||
|
<div class="actions">%s</div>
|
||||||
|
<h3>Operations</h3>
|
||||||
|
<table class="ops-table">
|
||||||
|
<thead><tr><th>Kind</th><th>State</th><th>Started</th><th>Duration</th><th>Error</th></tr></thead>
|
||||||
|
<tbody>%s</tbody>
|
||||||
|
</table>
|
||||||
|
`, html.EscapeString(h.Hostname), stateClass, h.State, h.MAC, h.ServerType, ip, html.EscapeString(h.Notes), actions.String(), opsHTML.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func imagesPage(images []model.Image) string {
|
||||||
|
var rows strings.Builder
|
||||||
|
for _, img := range images {
|
||||||
|
def := ""
|
||||||
|
if img.IsDefault {
|
||||||
|
def = "✓"
|
||||||
|
}
|
||||||
|
rows.WriteString(fmt.Sprintf(`<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>`,
|
||||||
|
html.EscapeString(img.Name), img.Kind, img.Version, def, img.CreatedAt.Format("2006-01-02")))
|
||||||
|
}
|
||||||
|
return layout("Images", fmt.Sprintf(`
|
||||||
|
<h2>Boot Images</h2>
|
||||||
|
<table class="ops-table">
|
||||||
|
<thead><tr><th>Name</th><th>Kind</th><th>Version</th><th>Default</th><th>Added</th></tr></thead>
|
||||||
|
<tbody>%s</tbody>
|
||||||
|
</table>
|
||||||
|
`, rows.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func stateColor(s model.HostState) string {
|
||||||
|
switch s {
|
||||||
|
case model.StateRegistered:
|
||||||
|
return "state-grey"
|
||||||
|
case model.StatePXEReady, model.StatePXEBooted, model.StateInstalling:
|
||||||
|
return "state-blue"
|
||||||
|
case model.StateInstalled, model.StateFirstBoot, model.StateJoining:
|
||||||
|
return "state-amber"
|
||||||
|
case model.StateReady:
|
||||||
|
return "state-green"
|
||||||
|
case model.StateFailed:
|
||||||
|
return "state-red"
|
||||||
|
default:
|
||||||
|
return "state-grey"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func layout(title, body string) string {
|
||||||
|
return fmt.Sprintf(`<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>%s — Provisioning</title>
|
||||||
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="topbar">
|
||||||
|
<a href="/" class="brand">Provisioning</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/">Dashboard</a>
|
||||||
|
<a href="/images">Images</a>
|
||||||
|
</div>
|
||||||
|
<span class="sse-indicator" id="sse-dot">●</span>
|
||||||
|
</nav>
|
||||||
|
<main>%s</main>
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>`, html.EscapeString(title), body)
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"provisioning/internal/api"
|
||||||
|
"provisioning/internal/config"
|
||||||
|
"provisioning/internal/db"
|
||||||
|
"provisioning/internal/events"
|
||||||
|
"provisioning/internal/httpserver"
|
||||||
|
"provisioning/internal/model"
|
||||||
|
"provisioning/internal/orchestrator"
|
||||||
|
"provisioning/internal/pxe"
|
||||||
|
"provisioning/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestServer(t *testing.T) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
tmp := t.TempDir()
|
||||||
|
|
||||||
|
database, err := db.Open(filepath.Join(tmp, "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { database.Close() })
|
||||||
|
|
||||||
|
hosts := &store.Hosts{DB: database}
|
||||||
|
ops := &store.Operations{DB: database}
|
||||||
|
locks := &store.Locks{DB: database, TTLMinutes: 60}
|
||||||
|
images := &store.Images{DB: database}
|
||||||
|
hub := events.NewHub()
|
||||||
|
t.Cleanup(func() { hub.Shutdown(context.Background()) })
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Server: config.Server{
|
||||||
|
Bind: "127.0.0.1:0",
|
||||||
|
PublicURL: "http://localhost:8080",
|
||||||
|
},
|
||||||
|
Locks: config.Locks{TTLMinutes: 60},
|
||||||
|
}
|
||||||
|
|
||||||
|
serverTypes := mustLoadServerTypes(t, tmp)
|
||||||
|
|
||||||
|
runner := &orchestrator.Runner{
|
||||||
|
Hosts: hosts,
|
||||||
|
Ops: ops,
|
||||||
|
Locks: locks,
|
||||||
|
Hub: hub,
|
||||||
|
}
|
||||||
|
|
||||||
|
pxeSupervisor := pxe.NewSupervisor(pxe.SupervisorConfig{Enabled: false})
|
||||||
|
|
||||||
|
hostAPI := &api.HostAPI{
|
||||||
|
Hosts: hosts,
|
||||||
|
Ops: ops,
|
||||||
|
Locks: locks,
|
||||||
|
Images: images,
|
||||||
|
Runner: runner,
|
||||||
|
PXE: pxeSupervisor,
|
||||||
|
Config: cfg,
|
||||||
|
ServerTypes: serverTypes,
|
||||||
|
}
|
||||||
|
|
||||||
|
hostOrch := &orchestrator.HostOrchestrator{
|
||||||
|
Runner: runner,
|
||||||
|
Hosts: hosts,
|
||||||
|
Ops: ops,
|
||||||
|
Locks: locks,
|
||||||
|
Cluster: &orchestrator.ClusterJoiner{},
|
||||||
|
Config: cfg,
|
||||||
|
ServerTypes: serverTypes,
|
||||||
|
}
|
||||||
|
|
||||||
|
bootAPI := &api.BootAPI{
|
||||||
|
Hosts: hosts,
|
||||||
|
Images: images,
|
||||||
|
Runner: runner,
|
||||||
|
Orchestrator: hostOrch,
|
||||||
|
Config: cfg,
|
||||||
|
ServerTypes: serverTypes,
|
||||||
|
}
|
||||||
|
|
||||||
|
ui := &api.UI{
|
||||||
|
Hosts: hosts,
|
||||||
|
Ops: ops,
|
||||||
|
Locks: locks,
|
||||||
|
Images: images,
|
||||||
|
Runner: runner,
|
||||||
|
Hub: hub,
|
||||||
|
PXE: pxeSupervisor,
|
||||||
|
Config: cfg,
|
||||||
|
ServerTypes: serverTypes,
|
||||||
|
}
|
||||||
|
|
||||||
|
router := httpserver.NewRouter(httpserver.Deps{
|
||||||
|
HostAPI: hostAPI,
|
||||||
|
BootAPI: bootAPI,
|
||||||
|
UI: ui,
|
||||||
|
Hub: hub,
|
||||||
|
})
|
||||||
|
|
||||||
|
return httptest.NewServer(router)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustLoadServerTypes(t *testing.T, dir string) *config.ServerTypeRegistry {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(dir, "server-types.yaml")
|
||||||
|
content := []byte(`server_types:
|
||||||
|
test-type:
|
||||||
|
display_name: "Test Type"
|
||||||
|
boot_disk: "/dev/sda"
|
||||||
|
management_nic: "eth0"
|
||||||
|
gpu: false
|
||||||
|
hostname_prefix: "pve-test"
|
||||||
|
`)
|
||||||
|
if err := writeTestFile(path, content); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reg, err := config.LoadServerTypes(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return reg
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTestFile(path string, data []byte) error {
|
||||||
|
return os.WriteFile(path, data, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateAndListHosts(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
// Create host via JSON API
|
||||||
|
body := `{"hostname":"pve-test-01","mac":"aa:bb:cc:dd:ee:01","server_type":"test-type"}`
|
||||||
|
resp, err := http.Post(ts.URL+"/api/hosts", "application/json", bytes.NewBufferString(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("create: got %d, want %d", resp.StatusCode, http.StatusCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
var created model.Host
|
||||||
|
json.NewDecoder(resp.Body).Decode(&created)
|
||||||
|
resp.Body.Close()
|
||||||
|
if created.Hostname != "pve-test-01" {
|
||||||
|
t.Fatalf("hostname = %q, want %q", created.Hostname, "pve-test-01")
|
||||||
|
}
|
||||||
|
if created.State != model.StateRegistered {
|
||||||
|
t.Fatalf("state = %q, want %q", created.State, model.StateRegistered)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List hosts
|
||||||
|
resp, err = http.Get(ts.URL + "/api/hosts")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var hosts []model.Host
|
||||||
|
json.NewDecoder(resp.Body).Decode(&hosts)
|
||||||
|
resp.Body.Close()
|
||||||
|
if len(hosts) != 1 {
|
||||||
|
t.Fatalf("list: got %d hosts, want 1", len(hosts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRebuildTransition(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
// Create host
|
||||||
|
body := `{"hostname":"pve-test-02","mac":"aa:bb:cc:dd:ee:02","server_type":"test-type"}`
|
||||||
|
resp, _ := http.Post(ts.URL+"/api/hosts", "application/json", bytes.NewBufferString(body))
|
||||||
|
var created model.Host
|
||||||
|
json.NewDecoder(resp.Body).Decode(&created)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
// Trigger rebuild
|
||||||
|
resp, err := http.Post(ts.URL+"/api/hosts/"+itoa(created.ID)+"/rebuild", "application/json", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("rebuild: got %d, want %d", resp.StatusCode, http.StatusOK)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
// Verify state is pxe_ready
|
||||||
|
resp, _ = http.Get(ts.URL + "/api/hosts/" + itoa(created.ID))
|
||||||
|
var host model.Host
|
||||||
|
json.NewDecoder(resp.Body).Decode(&host)
|
||||||
|
resp.Body.Close()
|
||||||
|
if host.State != model.StatePXEReady {
|
||||||
|
t.Fatalf("state = %q, want %q", host.State, model.StatePXEReady)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDuplicateRebuildConflict(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
body := `{"hostname":"pve-test-03","mac":"aa:bb:cc:dd:ee:03","server_type":"test-type"}`
|
||||||
|
resp, _ := http.Post(ts.URL+"/api/hosts", "application/json", bytes.NewBufferString(body))
|
||||||
|
var created model.Host
|
||||||
|
json.NewDecoder(resp.Body).Decode(&created)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
// First rebuild
|
||||||
|
resp, _ = http.Post(ts.URL+"/api/hosts/"+itoa(created.ID)+"/rebuild", "application/json", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
// Second rebuild should 409
|
||||||
|
resp, _ = http.Post(ts.URL+"/api/hosts/"+itoa(created.ID)+"/rebuild", "application/json", nil)
|
||||||
|
if resp.StatusCode != http.StatusConflict {
|
||||||
|
t.Fatalf("second rebuild: got %d, want %d", resp.StatusCode, http.StatusConflict)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboardHTML(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
resp, err := http.Get(ts.URL + "/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("dashboard: got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if ct := resp.Header.Get("Content-Type"); ct != "text/html; charset=utf-8" {
|
||||||
|
t.Fatalf("content-type = %q", ct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(i int64) string {
|
||||||
|
return fmt.Sprintf("%d", i)
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"provisioning/internal/config"
|
||||||
|
"provisioning/internal/events"
|
||||||
|
"provisioning/internal/model"
|
||||||
|
"provisioning/internal/orchestrator"
|
||||||
|
"provisioning/internal/pxe"
|
||||||
|
"provisioning/internal/statemachine"
|
||||||
|
"provisioning/internal/store"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UI struct {
|
||||||
|
Hosts *store.Hosts
|
||||||
|
Ops *store.Operations
|
||||||
|
Locks *store.Locks
|
||||||
|
Images *store.Images
|
||||||
|
Runner *orchestrator.Runner
|
||||||
|
Hub *events.Hub
|
||||||
|
PXE *pxe.Supervisor
|
||||||
|
Config *config.Config
|
||||||
|
ServerTypes *config.ServerTypeRegistry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UI) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hosts, _ := u.Hosts.List(r.Context())
|
||||||
|
renderHTML(w, dashboardPage(hosts))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UI) NewHostForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
types := u.ServerTypes.Keys()
|
||||||
|
renderHTML(w, hostFormPage(types, "", nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UI) CreateHost(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "bad form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hostname := strings.TrimSpace(r.FormValue("hostname"))
|
||||||
|
mac := strings.TrimSpace(r.FormValue("mac"))
|
||||||
|
serverType := r.FormValue("server_type")
|
||||||
|
notes := r.FormValue("notes")
|
||||||
|
|
||||||
|
var errs []string
|
||||||
|
if hostname == "" {
|
||||||
|
errs = append(errs, "Hostname is required")
|
||||||
|
}
|
||||||
|
if !isValidMAC(mac) {
|
||||||
|
errs = append(errs, "Invalid MAC address format (expected xx:xx:xx:xx:xx:xx)")
|
||||||
|
}
|
||||||
|
if _, ok := u.ServerTypes.Get(serverType); !ok {
|
||||||
|
errs = append(errs, "Invalid server type")
|
||||||
|
}
|
||||||
|
if len(errs) > 0 {
|
||||||
|
types := u.ServerTypes.Keys()
|
||||||
|
renderHTML(w, hostFormPage(types, strings.Join(errs, "; "), &model.Host{
|
||||||
|
Hostname: hostname,
|
||||||
|
MAC: mac,
|
||||||
|
ServerType: serverType,
|
||||||
|
Notes: notes,
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := u.Hosts.Create(r.Context(), model.Host{
|
||||||
|
Hostname: hostname,
|
||||||
|
MAC: mac,
|
||||||
|
ServerType: serverType,
|
||||||
|
Notes: notes,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
types := u.ServerTypes.Keys()
|
||||||
|
renderHTML(w, hostFormPage(types, "Host already exists: "+err.Error(), nil))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hosts, _ := u.Hosts.List(r.Context())
|
||||||
|
_ = u.PXE.Reload(hosts)
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UI) HostDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := chi.URLParam(r, "id")
|
||||||
|
var id int64
|
||||||
|
fmt.Sscanf(idStr, "%d", &id)
|
||||||
|
|
||||||
|
host, err := u.Hosts.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
http.Error(w, "Host not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ops, _ := u.Ops.ListByHost(r.Context(), host.ID)
|
||||||
|
renderHTML(w, hostDetailPage(host, ops))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UI) TriggerRebuild(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := chi.URLParam(r, "id")
|
||||||
|
var id int64
|
||||||
|
fmt.Sscanf(idStr, "%d", &id)
|
||||||
|
|
||||||
|
host, err := u.Hosts.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Host not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
locked, _ := u.Locks.IsLocked(r.Context(), host.ID)
|
||||||
|
if locked {
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/hosts/%d", id), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
opID, _ := u.Ops.Create(r.Context(), model.Operation{
|
||||||
|
HostID: host.ID,
|
||||||
|
Kind: model.OpRebuildProxmox,
|
||||||
|
})
|
||||||
|
_ = u.Locks.Acquire(r.Context(), host.ID, opID)
|
||||||
|
u.Runner.Transition(r.Context(), host.ID, statemachine.TriggerRebuildRequested)
|
||||||
|
|
||||||
|
hosts, _ := u.Hosts.List(r.Context())
|
||||||
|
_ = u.PXE.Reload(hosts)
|
||||||
|
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/hosts/%d", id), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UI) DeleteHost(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := chi.URLParam(r, "id")
|
||||||
|
var id int64
|
||||||
|
fmt.Sscanf(idStr, "%d", &id)
|
||||||
|
|
||||||
|
_ = u.Hosts.Delete(r.Context(), id)
|
||||||
|
hosts, _ := u.Hosts.List(r.Context())
|
||||||
|
_ = u.PXE.Reload(hosts)
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UI) ImagesPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
images, _ := u.Images.List(r.Context())
|
||||||
|
renderHTML(w, imagesPage(images))
|
||||||
|
}
|
||||||
|
|
||||||
|
var macRegex = regexp.MustCompile(`^([0-9a-fA-F]{2}[:\-]){5}[0-9a-fA-F]{2}$`)
|
||||||
|
|
||||||
|
func isValidMAC(mac string) bool {
|
||||||
|
return macRegex.MatchString(strings.TrimSpace(mac))
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Server Server `yaml:"server"`
|
||||||
|
Database Database `yaml:"database"`
|
||||||
|
PXE PXE `yaml:"pxe"`
|
||||||
|
Images Images `yaml:"images"`
|
||||||
|
Proxmox Proxmox `yaml:"proxmox"`
|
||||||
|
Credentials Credentials `yaml:"credentials"`
|
||||||
|
Infrastructure Infrastructure `yaml:"infrastructure"`
|
||||||
|
Locks Locks `yaml:"locks"`
|
||||||
|
ServerTypePath string `yaml:"server_types_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
Bind string `yaml:"bind"`
|
||||||
|
PublicURL string `yaml:"public_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Database struct {
|
||||||
|
Path string `yaml:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PXE struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
Interface string `yaml:"interface"`
|
||||||
|
Subnet string `yaml:"subnet"`
|
||||||
|
RuntimeDir string `yaml:"runtime_dir"`
|
||||||
|
TFTPRoot string `yaml:"tftp_root"`
|
||||||
|
DnsmasqBin string `yaml:"dnsmasq_bin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Images struct {
|
||||||
|
Dir string `yaml:"dir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Proxmox struct {
|
||||||
|
ExistingNode string `yaml:"existing_node"`
|
||||||
|
ClusterName string `yaml:"cluster_name"`
|
||||||
|
JoinFingerprint string `yaml:"join_fingerprint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Credentials struct {
|
||||||
|
SSHPrivateKeyPath string `yaml:"ssh_private_key_path"`
|
||||||
|
SSHPublicKey string `yaml:"ssh_public_key"`
|
||||||
|
RootPasswordHash string `yaml:"root_password_hash"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Infrastructure struct {
|
||||||
|
BaseURL string `yaml:"base_url"`
|
||||||
|
RoomID int `yaml:"room_id"`
|
||||||
|
ServerTypeMap map[string]int `yaml:"server_type_map"`
|
||||||
|
TimeoutSec int `yaml:"timeout_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Locks struct {
|
||||||
|
TTLMinutes int `yaml:"ttl_minutes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load(path string) (*Config, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read config: %w", err)
|
||||||
|
}
|
||||||
|
cfg := &Config{}
|
||||||
|
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse config: %w", err)
|
||||||
|
}
|
||||||
|
applyDefaults(cfg)
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyDefaults(cfg *Config) {
|
||||||
|
if cfg.Server.Bind == "" {
|
||||||
|
cfg.Server.Bind = "0.0.0.0:8080"
|
||||||
|
}
|
||||||
|
if cfg.Database.Path == "" {
|
||||||
|
cfg.Database.Path = "./data/provisioning.db"
|
||||||
|
}
|
||||||
|
if cfg.PXE.RuntimeDir == "" {
|
||||||
|
cfg.PXE.RuntimeDir = "./data/pxe"
|
||||||
|
}
|
||||||
|
if cfg.PXE.TFTPRoot == "" {
|
||||||
|
cfg.PXE.TFTPRoot = "./data/tftp"
|
||||||
|
}
|
||||||
|
if cfg.PXE.DnsmasqBin == "" {
|
||||||
|
cfg.PXE.DnsmasqBin = "/usr/sbin/dnsmasq"
|
||||||
|
}
|
||||||
|
if cfg.Images.Dir == "" {
|
||||||
|
cfg.Images.Dir = "./data/images"
|
||||||
|
}
|
||||||
|
if cfg.Locks.TTLMinutes == 0 {
|
||||||
|
cfg.Locks.TTLMinutes = 60
|
||||||
|
}
|
||||||
|
if cfg.Infrastructure.TimeoutSec == 0 {
|
||||||
|
cfg.Infrastructure.TimeoutSec = 10
|
||||||
|
}
|
||||||
|
if cfg.ServerTypePath == "" {
|
||||||
|
cfg.ServerTypePath = "./server-types.yaml"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"provisioning/internal/model"
|
||||||
|
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type serverTypesFile struct {
|
||||||
|
ServerTypes map[string]model.ServerType `yaml:"server_types"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerTypeRegistry struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
types map[string]model.ServerType
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadServerTypes(path string) (*ServerTypeRegistry, error) {
|
||||||
|
r := &ServerTypeRegistry{path: path}
|
||||||
|
if err := r.load(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ServerTypeRegistry) Get(key string) (model.ServerType, bool) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
st, ok := r.types[key]
|
||||||
|
return st, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ServerTypeRegistry) List() []model.ServerType {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
out := make([]model.ServerType, 0, len(r.types))
|
||||||
|
for _, st := range r.types {
|
||||||
|
out = append(out, st)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ServerTypeRegistry) Keys() []string {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
out := make([]string, 0, len(r.types))
|
||||||
|
for k := range r.types {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ServerTypeRegistry) load() error {
|
||||||
|
data, err := os.ReadFile(r.path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read server types: %w", err)
|
||||||
|
}
|
||||||
|
var f serverTypesFile
|
||||||
|
if err := yaml.Unmarshal(data, &f); err != nil {
|
||||||
|
return fmt.Errorf("parse server types: %w", err)
|
||||||
|
}
|
||||||
|
if len(f.ServerTypes) == 0 {
|
||||||
|
return fmt.Errorf("server types file contains no types")
|
||||||
|
}
|
||||||
|
for k, st := range f.ServerTypes {
|
||||||
|
st.Key = k
|
||||||
|
f.ServerTypes[k] = st
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
r.types = f.ServerTypes
|
||||||
|
r.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ServerTypeRegistry) Watch(stop <-chan struct{}) {
|
||||||
|
watcher, err := fsnotify.NewWatcher()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("server-types: fsnotify unavailable: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := watcher.Add(r.path); err != nil {
|
||||||
|
log.Printf("server-types: watch failed: %v", err)
|
||||||
|
_ = watcher.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
defer watcher.Close()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
case ev, ok := <-watcher.Events:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ev.Op&(fsnotify.Write|fsnotify.Create) != 0 {
|
||||||
|
if err := r.load(); err != nil {
|
||||||
|
log.Printf("server-types: hot-reload failed: %v (keeping previous config)", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("server-types: reloaded %d types", len(r.types))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case err, ok := <-watcher.Errors:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("server-types: watch error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/*.sql
|
||||||
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
|
func Open(path string) (*sql.DB, error) {
|
||||||
|
dsn := fmt.Sprintf("file:%s?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)", filepath.ToSlash(path))
|
||||||
|
db, err := sql.Open("sqlite", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||||
|
}
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, fmt.Errorf("ping sqlite: %w", err)
|
||||||
|
}
|
||||||
|
if err := migrate(db); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrate(db *sql.DB) error {
|
||||||
|
entries, err := fs.ReadDir(migrationsFS, "migrations")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read migrations: %w", err)
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
|
||||||
|
names = append(names, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
|
||||||
|
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)`); err != nil {
|
||||||
|
return fmt.Errorf("ensure schema_migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range names {
|
||||||
|
var applied int
|
||||||
|
if err := db.QueryRow(`SELECT COUNT(1) FROM schema_migrations WHERE name = ?`, name).Scan(&applied); err != nil {
|
||||||
|
return fmt.Errorf("check migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if applied > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content, err := migrationsFS.ReadFile("migrations/" + name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(string(content)); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return fmt.Errorf("apply migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, name); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return fmt.Errorf("record migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("commit migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
CREATE TABLE hosts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
hostname TEXT NOT NULL UNIQUE,
|
||||||
|
mac TEXT NOT NULL UNIQUE,
|
||||||
|
server_type TEXT NOT NULL,
|
||||||
|
state TEXT NOT NULL DEFAULT 'registered',
|
||||||
|
ip_address TEXT,
|
||||||
|
hardware_id TEXT,
|
||||||
|
infra_host_id INTEGER,
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE operations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
state TEXT NOT NULL DEFAULT 'active',
|
||||||
|
image_id INTEGER REFERENCES images(id),
|
||||||
|
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||||
|
completed_at TEXT,
|
||||||
|
error_message TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_operations_host ON operations(host_id);
|
||||||
|
|
||||||
|
CREATE TABLE operation_locks (
|
||||||
|
host_id INTEGER PRIMARY KEY REFERENCES hosts(id) ON DELETE CASCADE,
|
||||||
|
operation_id INTEGER NOT NULL REFERENCES operations(id),
|
||||||
|
locked_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||||
|
expires_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE images (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
version TEXT NOT NULL,
|
||||||
|
kernel_path TEXT NOT NULL,
|
||||||
|
initrd_path TEXT NOT NULL,
|
||||||
|
is_default INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||||
|
);
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package events
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Event struct {
|
||||||
|
Name string
|
||||||
|
Payload string
|
||||||
|
}
|
||||||
|
|
||||||
|
type subscriber struct {
|
||||||
|
id int64
|
||||||
|
ch chan Event
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultBuffer = 32
|
||||||
|
heartbeatInterval = 15 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type Hub struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
nextID int64
|
||||||
|
subs map[int64]*subscriber
|
||||||
|
buffer int
|
||||||
|
heartbeat time.Duration
|
||||||
|
done chan struct{}
|
||||||
|
closeOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHub() *Hub {
|
||||||
|
h := &Hub{
|
||||||
|
subs: map[int64]*subscriber{},
|
||||||
|
buffer: defaultBuffer,
|
||||||
|
heartbeat: heartbeatInterval,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
go h.heartbeatLoop()
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) Publish(ev Event) {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
for _, s := range h.subs {
|
||||||
|
select {
|
||||||
|
case s.ch <- ev:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) Subscribe() (id int64, ch <-chan Event, cancel func()) {
|
||||||
|
id = atomic.AddInt64(&h.nextID, 1)
|
||||||
|
s := &subscriber{id: id, ch: make(chan Event, h.buffer)}
|
||||||
|
h.mu.Lock()
|
||||||
|
h.subs[id] = s
|
||||||
|
h.mu.Unlock()
|
||||||
|
return id, s.ch, func() {
|
||||||
|
h.mu.Lock()
|
||||||
|
delete(h.subs, id)
|
||||||
|
h.mu.Unlock()
|
||||||
|
close(s.ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) heartbeatLoop() {
|
||||||
|
t := time.NewTicker(h.heartbeat)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-h.done:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
h.Publish(Event{
|
||||||
|
Name: "heartbeat",
|
||||||
|
Payload: fmt.Sprintf(`<span data-heartbeat="%d"></span>`, time.Now().Unix()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) ServeSSE(w http.ResponseWriter, r *http.Request) {
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
|
||||||
|
_, eventsCh, cancel := h.Subscribe()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
fmt.Fprintf(w, "event: hello\ndata: ok\n\n")
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case ev, ok := <-eventsCh:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeSSE(w, ev)
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSSE(w http.ResponseWriter, ev Event) {
|
||||||
|
if ev.Name != "" {
|
||||||
|
fmt.Fprintf(w, "event: %s\n", ev.Name)
|
||||||
|
}
|
||||||
|
for _, line := range splitLines(ev.Payload) {
|
||||||
|
fmt.Fprintf(w, "data: %s\n", line)
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitLines(s string) []string {
|
||||||
|
if s == "" {
|
||||||
|
return []string{""}
|
||||||
|
}
|
||||||
|
out := []string{}
|
||||||
|
start := 0
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] == '\n' {
|
||||||
|
out = append(out, s[start:i])
|
||||||
|
start = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start <= len(s) {
|
||||||
|
out = append(out, s[start:])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) Shutdown(_ context.Context) error {
|
||||||
|
h.closeOnce.Do(func() {
|
||||||
|
close(h.done)
|
||||||
|
h.mu.Lock()
|
||||||
|
for id, s := range h.subs {
|
||||||
|
close(s.ch)
|
||||||
|
delete(h.subs, id)
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"provisioning/internal/api"
|
||||||
|
"provisioning/internal/events"
|
||||||
|
"provisioning/internal/web"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Deps struct {
|
||||||
|
HostAPI *api.HostAPI
|
||||||
|
BootAPI *api.BootAPI
|
||||||
|
UI *api.UI
|
||||||
|
Hub *events.Hub
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRouter(d Deps) http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(middleware.RealIP)
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
r.Use(middleware.Logger)
|
||||||
|
|
||||||
|
// Static files
|
||||||
|
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(web.Static))))
|
||||||
|
|
||||||
|
// SSE
|
||||||
|
r.Get("/events", d.Hub.ServeSSE)
|
||||||
|
|
||||||
|
// Dashboard UI
|
||||||
|
r.Get("/", d.UI.Dashboard)
|
||||||
|
r.Get("/hosts/new", d.UI.NewHostForm)
|
||||||
|
r.Post("/hosts", d.UI.CreateHost)
|
||||||
|
r.Get("/hosts/{id}", d.UI.HostDetail)
|
||||||
|
r.Post("/hosts/{id}/rebuild", d.UI.TriggerRebuild)
|
||||||
|
r.Post("/hosts/{id}/delete", d.UI.DeleteHost)
|
||||||
|
r.Get("/images", d.UI.ImagesPage)
|
||||||
|
|
||||||
|
// Host JSON API
|
||||||
|
r.Route("/api/hosts", func(r chi.Router) {
|
||||||
|
r.Get("/", d.HostAPI.List)
|
||||||
|
r.Post("/", d.HostAPI.Create)
|
||||||
|
r.Get("/{id}", d.HostAPI.Get)
|
||||||
|
r.Delete("/{id}", d.HostAPI.Delete)
|
||||||
|
r.Post("/{id}/rebuild", d.HostAPI.Rebuild)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Boot / PXE endpoints
|
||||||
|
r.Get("/ipxe/{mac}", d.BootAPI.IPXEScript)
|
||||||
|
r.Post("/api/boot/answer", d.BootAPI.AnswerFile)
|
||||||
|
r.Post("/api/hosts/{id}/installed", d.BootAPI.InstallComplete)
|
||||||
|
r.Get("/api/hosts/{id}/first-boot-script", d.BootAPI.FirstBootScript)
|
||||||
|
r.Post("/api/hosts/{id}/phone-home", d.BootAPI.PhoneHome)
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type HostState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StateRegistered HostState = "registered"
|
||||||
|
StatePXEReady HostState = "pxe_ready"
|
||||||
|
StatePXEBooted HostState = "pxe_booted"
|
||||||
|
StateInstalling HostState = "installing"
|
||||||
|
StateInstalled HostState = "installed"
|
||||||
|
StateFirstBoot HostState = "first_boot"
|
||||||
|
StateJoining HostState = "joining"
|
||||||
|
StateReady HostState = "ready"
|
||||||
|
StateFailed HostState = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Host struct {
|
||||||
|
ID int64
|
||||||
|
Hostname string
|
||||||
|
MAC string
|
||||||
|
ServerType string
|
||||||
|
State HostState
|
||||||
|
IPAddress string
|
||||||
|
HardwareID string
|
||||||
|
InfraHostID int64
|
||||||
|
Notes string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type OperationKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OpRebuildProxmox OperationKind = "rebuild_proxmox"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OperationState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OpActive OperationState = "active"
|
||||||
|
OpCompleted OperationState = "completed"
|
||||||
|
OpFailed OperationState = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Operation struct {
|
||||||
|
ID int64
|
||||||
|
HostID int64
|
||||||
|
Kind OperationKind
|
||||||
|
State OperationState
|
||||||
|
ImageID int64
|
||||||
|
StartedAt time.Time
|
||||||
|
CompletedAt *time.Time
|
||||||
|
ErrorMessage string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Image struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
Kind string
|
||||||
|
Version string
|
||||||
|
KernelPath string
|
||||||
|
InitrdPath string
|
||||||
|
IsDefault bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerType struct {
|
||||||
|
Key string
|
||||||
|
DisplayName string `yaml:"display_name"`
|
||||||
|
BootDisk string `yaml:"boot_disk"`
|
||||||
|
ManagementNIC string `yaml:"management_nic"`
|
||||||
|
GPU bool `yaml:"gpu"`
|
||||||
|
HostnamePrefix string `yaml:"hostname_prefix"`
|
||||||
|
ExpectedNICs []NICDef `yaml:"expected_nics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NICDef struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Speed string `yaml:"speed"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package orchestrator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ClusterJoiner struct {
|
||||||
|
ExistingNode string
|
||||||
|
ClusterName string
|
||||||
|
JoinFingerprint string
|
||||||
|
SSHKeyPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ClusterJoiner) Join(ctx context.Context, hostIP string) error {
|
||||||
|
client, err := c.connect(hostIP)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ssh connect to %s: %w", hostIP, err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
cmd := fmt.Sprintf("pvecm add %s --force", c.ExistingNode)
|
||||||
|
log.Printf("cluster: running on %s: %s", hostIP, cmd)
|
||||||
|
|
||||||
|
session, err := client.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ssh session: %w", err)
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
output, err := session.CombinedOutput(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("pvecm add failed: %w\noutput: %s", err, string(output))
|
||||||
|
}
|
||||||
|
log.Printf("cluster: %s joined successfully", hostIP)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ClusterJoiner) connect(hostIP string) (*ssh.Client, error) {
|
||||||
|
keyData, err := os.ReadFile(c.SSHKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read ssh key: %w", err)
|
||||||
|
}
|
||||||
|
signer, err := ssh.ParsePrivateKey(keyData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse ssh key: %w", err)
|
||||||
|
}
|
||||||
|
config := &ssh.ClientConfig{
|
||||||
|
User: "root",
|
||||||
|
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
|
||||||
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
return ssh.Dial("tcp", hostIP+":22", config)
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package orchestrator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"provisioning/internal/config"
|
||||||
|
"provisioning/internal/infra"
|
||||||
|
"provisioning/internal/model"
|
||||||
|
"provisioning/internal/statemachine"
|
||||||
|
"provisioning/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HostOrchestrator struct {
|
||||||
|
Runner *Runner
|
||||||
|
Hosts *store.Hosts
|
||||||
|
Ops *store.Operations
|
||||||
|
Locks *store.Locks
|
||||||
|
Cluster *ClusterJoiner
|
||||||
|
InfraClient *infra.Client
|
||||||
|
Config *config.Config
|
||||||
|
ServerTypes *config.ServerTypeRegistry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *HostOrchestrator) HandlePhoneHome(ctx context.Context, hostID int64, ip string, hardwareID string) {
|
||||||
|
if err := o.Hosts.UpdateIP(ctx, hostID, ip, hardwareID); err != nil {
|
||||||
|
log.Printf("host %d: failed to update IP: %v", hostID, err)
|
||||||
|
o.Runner.FailHost(ctx, hostID, "failed to update IP: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := o.Runner.Transition(ctx, hostID, statemachine.TriggerPhoneHome); err != nil {
|
||||||
|
log.Printf("host %d: phone-home transition failed: %v", hostID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go o.postPhoneHome(hostID, ip, hardwareID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *HostOrchestrator) postPhoneHome(hostID int64, ip string, hardwareID string) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
host, err := o.Hosts.Get(ctx, hostID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("host %d: failed to get host for cluster join: %v", hostID, err)
|
||||||
|
o.Runner.FailHost(ctx, hostID, "get host: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := o.Runner.Transition(ctx, hostID, statemachine.TriggerClusterJoinStart); err != nil {
|
||||||
|
log.Printf("host %d: cluster join start transition failed: %v", hostID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := o.Cluster.Join(ctx, ip); err != nil {
|
||||||
|
log.Printf("host %d: cluster join failed: %v", hostID, err)
|
||||||
|
o.Runner.FailHost(ctx, hostID, "cluster join: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := o.registerInfra(ctx, host, ip, hardwareID); err != nil {
|
||||||
|
log.Printf("host %d: infra registration failed: %v", hostID, err)
|
||||||
|
o.Runner.FailHost(ctx, hostID, "infra registration: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := o.Runner.Transition(ctx, hostID, statemachine.TriggerJoinComplete); err != nil {
|
||||||
|
log.Printf("host %d: join complete transition failed: %v", hostID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
op, err := o.Ops.GetActive(ctx, hostID)
|
||||||
|
if err == nil {
|
||||||
|
_ = o.Ops.Complete(ctx, op.ID)
|
||||||
|
}
|
||||||
|
_ = o.Locks.Release(ctx, hostID)
|
||||||
|
log.Printf("host %d (%s): provisioning complete", hostID, host.Hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *HostOrchestrator) registerInfra(ctx context.Context, host *model.Host, ip string, hardwareID string) error {
|
||||||
|
if o.InfraClient == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
st, _ := o.ServerTypes.Get(host.ServerType)
|
||||||
|
serverTypeID := o.Config.Infrastructure.ServerTypeMap[host.ServerType]
|
||||||
|
|
||||||
|
infraID, err := o.InfraClient.CreateHost(ctx, infra.CreateHostRequest{
|
||||||
|
HardwareID: hardwareID,
|
||||||
|
Hostname: host.Hostname,
|
||||||
|
AssetID: host.Hostname,
|
||||||
|
RoomID: o.Config.Infrastructure.RoomID,
|
||||||
|
ServerTypeID: serverTypeID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := o.Hosts.UpdateInfraID(ctx, host.ID, infraID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = o.InfraClient.CreateInterface(ctx, infra.CreateInterfaceRequest{
|
||||||
|
HostID: int(infraID),
|
||||||
|
Name: st.ManagementNIC,
|
||||||
|
MACAddress: host.MAC,
|
||||||
|
IPAddress: ip,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package orchestrator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"provisioning/internal/events"
|
||||||
|
"provisioning/internal/model"
|
||||||
|
"provisioning/internal/statemachine"
|
||||||
|
"provisioning/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Runner struct {
|
||||||
|
Hosts *store.Hosts
|
||||||
|
Ops *store.Operations
|
||||||
|
Locks *store.Locks
|
||||||
|
Hub *events.Hub
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) Transition(ctx context.Context, hostID int64, trigger statemachine.Trigger) (model.HostState, error) {
|
||||||
|
host, err := r.Hosts.Get(ctx, hostID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("transition: get host: %w", err)
|
||||||
|
}
|
||||||
|
next, err := statemachine.Next(host.State, trigger)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("transition: %w", err)
|
||||||
|
}
|
||||||
|
if err := r.Hosts.UpdateState(ctx, hostID, next); err != nil {
|
||||||
|
return "", fmt.Errorf("transition: update state: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("host %d (%s): %s -> %s [%s]", hostID, host.Hostname, host.State, next, trigger)
|
||||||
|
r.Hub.Publish(events.Event{
|
||||||
|
Name: "host.state_changed",
|
||||||
|
Payload: fmt.Sprintf(`{"host_id":%d,"old_state":"%s","new_state":"%s"}`, hostID, host.State, next),
|
||||||
|
})
|
||||||
|
return next, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) FailHost(ctx context.Context, hostID int64, reason string) {
|
||||||
|
if _, err := r.Transition(ctx, hostID, statemachine.TriggerFailed); err != nil {
|
||||||
|
log.Printf("host %d: failed to transition to failed state: %v", hostID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
op, err := r.Ops.GetActive(ctx, hostID)
|
||||||
|
if err == nil {
|
||||||
|
_ = r.Ops.Fail(ctx, op.ID, reason)
|
||||||
|
}
|
||||||
|
_ = r.Locks.Release(ctx, hostID)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package pxe
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"provisioning/internal/config"
|
||||||
|
"provisioning/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GenerateAnswerFile(host *model.Host, serverType model.ServerType, cfg *config.Config) string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
b.WriteString("[global]\n")
|
||||||
|
b.WriteString(`keyboard = "en-us"` + "\n")
|
||||||
|
b.WriteString(`country = "us"` + "\n")
|
||||||
|
b.WriteString(fmt.Sprintf("fqdn = \"%s.thewrightserver.net\"\n", host.Hostname))
|
||||||
|
b.WriteString(`mailto = "admin@thewrightserver.net"` + "\n")
|
||||||
|
b.WriteString(`timezone = "America/Indiana/Indianapolis"` + "\n")
|
||||||
|
b.WriteString(fmt.Sprintf("root-password-hashed = \"%s\"\n", cfg.Credentials.RootPasswordHash))
|
||||||
|
b.WriteString(fmt.Sprintf("root-ssh-keys = [\"%s\"]\n", cfg.Credentials.SSHPublicKey))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
b.WriteString("[network]\n")
|
||||||
|
b.WriteString(`source = "from-dhcp"` + "\n")
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
b.WriteString("[disk-setup]\n")
|
||||||
|
b.WriteString(`filesystem = "zfs"` + "\n")
|
||||||
|
b.WriteString(`zfs.raid = "raid0"` + "\n")
|
||||||
|
b.WriteString(fmt.Sprintf("disk-list = [\"%s\"]\n", serverType.BootDisk))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
b.WriteString("[post-installation-webhook]\n")
|
||||||
|
b.WriteString(fmt.Sprintf("url = \"%s/api/hosts/%d/installed\"\n", cfg.Server.PublicURL, host.ID))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
b.WriteString("[first-boot]\n")
|
||||||
|
b.WriteString(`source = "from-url"` + "\n")
|
||||||
|
b.WriteString(fmt.Sprintf("url = \"%s/api/hosts/%d/first-boot-script\"\n", cfg.Server.PublicURL, host.ID))
|
||||||
|
b.WriteString(`ordering = "after-network"` + "\n")
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package pxe
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"provisioning/internal/config"
|
||||||
|
"provisioning/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GenerateFirstBootScript(host *model.Host, serverType model.ServerType, cfg *config.Config) string {
|
||||||
|
return fmt.Sprintf(`#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROVISIONING_URL="%s"
|
||||||
|
HOST_ID="%d"
|
||||||
|
NIC="%s"
|
||||||
|
|
||||||
|
IP=$(ip -4 addr show dev "$NIC" | grep -oP '(?<=inet\s)\d+(\.\d+){3}')
|
||||||
|
HWID=$(dmidecode -s system-uuid)
|
||||||
|
|
||||||
|
curl -fsSL -X POST "${PROVISIONING_URL}/api/hosts/${HOST_ID}/phone-home" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"ip\": \"${IP}\", \"hardware_id\": \"${HWID}\", \"hostname\": \"$(hostname)\"}"
|
||||||
|
|
||||||
|
systemctl disable provisioning-firstboot.service
|
||||||
|
rm -f /etc/systemd/system/provisioning-firstboot.service
|
||||||
|
`, cfg.Server.PublicURL, host.ID, serverType.ManagementNIC)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package pxe
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"provisioning/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BuildIPXEScript(publicURL string, img *model.Image, mac string) string {
|
||||||
|
kernelURL := fmt.Sprintf("%s/images/boot/%s/%s", publicURL, img.Name, "linux26")
|
||||||
|
initrdURL := fmt.Sprintf("%s/images/boot/%s/%s", publicURL, img.Name, "initrd.img")
|
||||||
|
|
||||||
|
return fmt.Sprintf(`#!ipxe
|
||||||
|
echo Provisioning: booting %s on ${mac}
|
||||||
|
kernel %s vga=791 video=vesafb:lfb:on
|
||||||
|
initrd %s
|
||||||
|
boot
|
||||||
|
`, img.Name, kernelURL, initrdURL)
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package pxe
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func signalReload(p *os.Process) error {
|
||||||
|
return p.Signal(syscall.SIGHUP)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package pxe
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
func signalReload(_ *os.Process) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package pxe
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"provisioning/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SupervisorConfig struct {
|
||||||
|
Enabled bool
|
||||||
|
Interface string
|
||||||
|
Subnet string
|
||||||
|
RuntimeDir string
|
||||||
|
TFTPRoot string
|
||||||
|
DnsmasqBin string
|
||||||
|
PublicURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Supervisor struct {
|
||||||
|
cfg SupervisorConfig
|
||||||
|
cmd *exec.Cmd
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSupervisor(cfg SupervisorConfig) *Supervisor {
|
||||||
|
return &Supervisor{cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Supervisor) Start(ctx context.Context, hosts []model.Host) error {
|
||||||
|
if !s.cfg.Enabled {
|
||||||
|
log.Printf("pxe: dnsmasq disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(s.cfg.RuntimeDir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("pxe: create runtime dir: %w", err)
|
||||||
|
}
|
||||||
|
if err := s.writeConfig(hosts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.startProcess(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Supervisor) Reload(hosts []model.Host) error {
|
||||||
|
if !s.cfg.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := s.writeConfig(hosts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.cmd != nil && s.cmd.Process != nil {
|
||||||
|
return signalReload(s.cmd.Process)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Supervisor) Shutdown() error {
|
||||||
|
if s.cancel != nil {
|
||||||
|
s.cancel()
|
||||||
|
}
|
||||||
|
if s.cmd != nil && s.cmd.Process != nil {
|
||||||
|
return s.cmd.Process.Kill()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Supervisor) writeConfig(hosts []model.Host) error {
|
||||||
|
confPath := filepath.Join(s.cfg.RuntimeDir, "dnsmasq.conf")
|
||||||
|
hostsPath := filepath.Join(s.cfg.RuntimeDir, "dhcp-hostsfile")
|
||||||
|
|
||||||
|
var macs []string
|
||||||
|
for _, h := range hosts {
|
||||||
|
macs = append(macs, h.MAC)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(hostsPath, []byte(strings.Join(macs, "\n")+"\n"), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("pxe: write dhcp-hostsfile: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
conf := dnsmasqConf{
|
||||||
|
Interface: s.cfg.Interface,
|
||||||
|
TFTPRoot: s.cfg.TFTPRoot,
|
||||||
|
HostsFile: hostsPath,
|
||||||
|
PublicURL: s.cfg.PublicURL,
|
||||||
|
RuntimeDir: s.cfg.RuntimeDir,
|
||||||
|
}
|
||||||
|
f, err := os.Create(confPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("pxe: create dnsmasq.conf: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if err := dnsmasqTmpl.Execute(f, conf); err != nil {
|
||||||
|
return fmt.Errorf("pxe: render dnsmasq.conf: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Supervisor) startProcess(ctx context.Context) error {
|
||||||
|
confPath := filepath.Join(s.cfg.RuntimeDir, "dnsmasq.conf")
|
||||||
|
procCtx, cancel := context.WithCancel(ctx)
|
||||||
|
s.cancel = cancel
|
||||||
|
s.cmd = exec.CommandContext(procCtx, s.cfg.DnsmasqBin, "--keep-in-foreground", "--conf-file="+confPath)
|
||||||
|
s.cmd.Stdout = os.Stdout
|
||||||
|
s.cmd.Stderr = os.Stderr
|
||||||
|
if err := s.cmd.Start(); err != nil {
|
||||||
|
cancel()
|
||||||
|
return fmt.Errorf("pxe: start dnsmasq: %w", err)
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
if err := s.cmd.Wait(); err != nil {
|
||||||
|
select {
|
||||||
|
case <-procCtx.Done():
|
||||||
|
default:
|
||||||
|
log.Printf("pxe: dnsmasq exited: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
log.Printf("pxe: dnsmasq started (pid %d)", s.cmd.Process.Pid)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type dnsmasqConf struct {
|
||||||
|
Interface string
|
||||||
|
TFTPRoot string
|
||||||
|
HostsFile string
|
||||||
|
PublicURL string
|
||||||
|
RuntimeDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
var dnsmasqTmpl = template.Must(template.New("dnsmasq.conf").Parse(`
|
||||||
|
port=0
|
||||||
|
interface={{.Interface}}
|
||||||
|
bind-interfaces
|
||||||
|
|
||||||
|
dhcp-range=tag:known,192.168.1.0,proxy
|
||||||
|
dhcp-hostsfile={{.HostsFile}}
|
||||||
|
dhcp-ignore=tag:!known
|
||||||
|
|
||||||
|
enable-tftp
|
||||||
|
tftp-root={{.TFTPRoot}}
|
||||||
|
|
||||||
|
# Legacy BIOS
|
||||||
|
dhcp-match=set:bios,option:client-arch,0
|
||||||
|
dhcp-boot=tag:bios,undionly.kpxe
|
||||||
|
|
||||||
|
# UEFI
|
||||||
|
dhcp-match=set:efi64,option:client-arch,7
|
||||||
|
dhcp-boot=tag:efi64,ipxe.efi
|
||||||
|
|
||||||
|
# iPXE user-class: chain to HTTP script
|
||||||
|
dhcp-match=set:ipxe,option:user-class,iPXE
|
||||||
|
dhcp-boot=tag:ipxe,{{.PublicURL}}/ipxe/${mac:hexhyp}
|
||||||
|
|
||||||
|
log-dhcp
|
||||||
|
log-facility={{.RuntimeDir}}/dnsmasq.log
|
||||||
|
`))
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package statemachine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"provisioning/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Trigger string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TriggerRebuildRequested Trigger = "RebuildRequested"
|
||||||
|
TriggerPXEScriptServed Trigger = "PXEScriptServed"
|
||||||
|
TriggerAnswerServed Trigger = "AnswerServed"
|
||||||
|
TriggerInstallWebhook Trigger = "InstallWebhook"
|
||||||
|
TriggerPhoneHome Trigger = "PhoneHome"
|
||||||
|
TriggerClusterJoinStart Trigger = "ClusterJoinStarted"
|
||||||
|
TriggerJoinComplete Trigger = "JoinComplete"
|
||||||
|
TriggerFailed Trigger = "Failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type transition struct {
|
||||||
|
from []model.HostState
|
||||||
|
to model.HostState
|
||||||
|
}
|
||||||
|
|
||||||
|
var allActiveStates = []model.HostState{
|
||||||
|
model.StatePXEReady,
|
||||||
|
model.StatePXEBooted,
|
||||||
|
model.StateInstalling,
|
||||||
|
model.StateInstalled,
|
||||||
|
model.StateFirstBoot,
|
||||||
|
model.StateJoining,
|
||||||
|
}
|
||||||
|
|
||||||
|
var table = map[Trigger]transition{
|
||||||
|
TriggerRebuildRequested: {
|
||||||
|
from: []model.HostState{model.StateRegistered, model.StateReady, model.StateFailed},
|
||||||
|
to: model.StatePXEReady,
|
||||||
|
},
|
||||||
|
TriggerPXEScriptServed: {
|
||||||
|
from: []model.HostState{model.StatePXEReady},
|
||||||
|
to: model.StatePXEBooted,
|
||||||
|
},
|
||||||
|
TriggerAnswerServed: {
|
||||||
|
from: []model.HostState{model.StatePXEBooted},
|
||||||
|
to: model.StateInstalling,
|
||||||
|
},
|
||||||
|
TriggerInstallWebhook: {
|
||||||
|
from: []model.HostState{model.StateInstalling},
|
||||||
|
to: model.StateInstalled,
|
||||||
|
},
|
||||||
|
TriggerPhoneHome: {
|
||||||
|
from: []model.HostState{model.StateInstalled},
|
||||||
|
to: model.StateFirstBoot,
|
||||||
|
},
|
||||||
|
TriggerClusterJoinStart: {
|
||||||
|
from: []model.HostState{model.StateFirstBoot},
|
||||||
|
to: model.StateJoining,
|
||||||
|
},
|
||||||
|
TriggerJoinComplete: {
|
||||||
|
from: []model.HostState{model.StateJoining},
|
||||||
|
to: model.StateReady,
|
||||||
|
},
|
||||||
|
TriggerFailed: {
|
||||||
|
from: allActiveStates,
|
||||||
|
to: model.StateFailed,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func Next(current model.HostState, t Trigger) (model.HostState, error) {
|
||||||
|
tr, ok := table[t]
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("unknown trigger %q", t)
|
||||||
|
}
|
||||||
|
for _, s := range tr.from {
|
||||||
|
if s == current {
|
||||||
|
return tr.to, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("trigger %q not allowed from state %q", t, current)
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package statemachine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"provisioning/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidTransitions(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
from model.HostState
|
||||||
|
trigger Trigger
|
||||||
|
want model.HostState
|
||||||
|
}{
|
||||||
|
{model.StateRegistered, TriggerRebuildRequested, model.StatePXEReady},
|
||||||
|
{model.StateReady, TriggerRebuildRequested, model.StatePXEReady},
|
||||||
|
{model.StateFailed, TriggerRebuildRequested, model.StatePXEReady},
|
||||||
|
{model.StatePXEReady, TriggerPXEScriptServed, model.StatePXEBooted},
|
||||||
|
{model.StatePXEBooted, TriggerAnswerServed, model.StateInstalling},
|
||||||
|
{model.StateInstalling, TriggerInstallWebhook, model.StateInstalled},
|
||||||
|
{model.StateInstalled, TriggerPhoneHome, model.StateFirstBoot},
|
||||||
|
{model.StateFirstBoot, TriggerClusterJoinStart, model.StateJoining},
|
||||||
|
{model.StateJoining, TriggerJoinComplete, model.StateReady},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got, err := Next(tc.from, tc.trigger)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Next(%q, %q) error: %v", tc.from, tc.trigger, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("Next(%q, %q) = %q, want %q", tc.from, tc.trigger, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFailedFromAllActive(t *testing.T) {
|
||||||
|
for _, state := range allActiveStates {
|
||||||
|
got, err := Next(state, TriggerFailed)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Next(%q, Failed) error: %v", state, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got != model.StateFailed {
|
||||||
|
t.Errorf("Next(%q, Failed) = %q, want %q", state, got, model.StateFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidTransitions(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
from model.HostState
|
||||||
|
trigger Trigger
|
||||||
|
}{
|
||||||
|
{model.StateRegistered, TriggerPXEScriptServed},
|
||||||
|
{model.StateReady, TriggerPhoneHome},
|
||||||
|
{model.StatePXEReady, TriggerInstallWebhook},
|
||||||
|
{model.StateInstalling, TriggerRebuildRequested},
|
||||||
|
{model.StateRegistered, TriggerFailed},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
_, err := Next(tc.from, tc.trigger)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Next(%q, %q) expected error, got nil", tc.from, tc.trigger)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var ErrNotFound = errors.New("not found")
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed static
|
||||||
|
var Static embed.FS
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0f1419;
|
||||||
|
--surface: #1a2027;
|
||||||
|
--border: #2d3748;
|
||||||
|
--text: #e2e8f0;
|
||||||
|
--text-muted: #8892a4;
|
||||||
|
--accent: #60a5fa;
|
||||||
|
--green: #34d399;
|
||||||
|
--amber: #fbbf24;
|
||||||
|
--red: #f87171;
|
||||||
|
--blue: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links { display: flex; gap: 1rem; }
|
||||||
|
.nav-links a { color: var(--text-muted); text-decoration: none; font-size: 0.9rem; }
|
||||||
|
.nav-links a:hover { color: var(--text); }
|
||||||
|
|
||||||
|
.sse-indicator { margin-left: auto; color: var(--green); font-size: 0.8rem; }
|
||||||
|
.sse-indicator.disconnected { color: var(--red); }
|
||||||
|
|
||||||
|
main { padding: 1.5rem; max-width: 1200px; margin: 0 auto; }
|
||||||
|
|
||||||
|
.actions { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
|
||||||
|
.count { color: var(--text-muted); font-size: 0.85rem; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #000;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.btn:hover { opacity: 0.9; }
|
||||||
|
.btn-danger { background: var(--red); }
|
||||||
|
|
||||||
|
.host-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem; }
|
||||||
|
|
||||||
|
.tile {
|
||||||
|
display: block;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text);
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.tile:hover { border-color: var(--accent); }
|
||||||
|
.tile-name { font-weight: 600; margin-bottom: 0.25rem; }
|
||||||
|
.tile-type { font-size: 0.8rem; color: var(--text-muted); }
|
||||||
|
.tile-state { font-size: 0.8rem; margin-top: 0.5rem; padding: 0.15rem 0.5rem; border-radius: 3px; display: inline-block; }
|
||||||
|
.tile-mac { font-size: 0.75rem; color: var(--text-muted); margin-top: 0.5rem; font-family: monospace; }
|
||||||
|
|
||||||
|
.state-grey .tile-state { background: #374151; color: #9ca3af; }
|
||||||
|
.state-blue .tile-state { background: #1e3a5f; color: var(--blue); }
|
||||||
|
.state-amber .tile-state { background: #422006; color: var(--amber); }
|
||||||
|
.state-green .tile-state { background: #064e3b; color: var(--green); }
|
||||||
|
.state-red .tile-state { background: #450a0a; color: var(--red); }
|
||||||
|
|
||||||
|
.host-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
|
||||||
|
.badge { padding: 0.2rem 0.6rem; border-radius: 3px; font-size: 0.8rem; }
|
||||||
|
.state-grey .badge, .badge.state-grey { background: #374151; color: #9ca3af; }
|
||||||
|
.state-blue .badge, .badge.state-blue { background: #1e3a5f; color: var(--blue); }
|
||||||
|
.state-amber .badge, .badge.state-amber { background: #422006; color: var(--amber); }
|
||||||
|
.state-green .badge, .badge.state-green { background: #064e3b; color: var(--green); }
|
||||||
|
.state-red .badge, .badge.state-red { background: #450a0a; color: var(--red); }
|
||||||
|
|
||||||
|
.detail-table { margin-bottom: 1.5rem; }
|
||||||
|
.detail-table th { text-align: left; padding: 0.4rem 1rem 0.4rem 0; color: var(--text-muted); font-weight: 500; }
|
||||||
|
.detail-table td { padding: 0.4rem 0; }
|
||||||
|
|
||||||
|
.ops-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||||
|
.ops-table th { text-align: left; padding: 0.5rem; border-bottom: 1px solid var(--border); color: var(--text-muted); }
|
||||||
|
.ops-table td { padding: 0.5rem; border-bottom: 1px solid var(--border); }
|
||||||
|
|
||||||
|
.form { max-width: 400px; }
|
||||||
|
.form label { display: block; margin-bottom: 1rem; color: var(--text-muted); font-size: 0.85rem; }
|
||||||
|
.form input, .form select, .form textarea {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.form textarea { min-height: 60px; resize: vertical; }
|
||||||
|
.form .btn { margin-top: 0.5rem; }
|
||||||
|
|
||||||
|
.error { background: #450a0a; color: var(--red); padding: 0.75rem; border-radius: 4px; margin-bottom: 1rem; font-size: 0.85rem; }
|
||||||
|
.empty { color: var(--text-muted); padding: 2rem; text-align: center; }
|
||||||
|
.empty a { color: var(--accent); }
|
||||||
|
.inline { display: inline; }
|
||||||
|
h2 { margin-bottom: 1rem; }
|
||||||
|
h3 { margin: 1.5rem 0 0.75rem; color: var(--text-muted); font-size: 0.95rem; }
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
(function() {
|
||||||
|
const dot = document.getElementById('sse-dot');
|
||||||
|
let es;
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
es = new EventSource('/events');
|
||||||
|
es.addEventListener('hello', () => {
|
||||||
|
dot.classList.remove('disconnected');
|
||||||
|
});
|
||||||
|
es.addEventListener('host.state_changed', (e) => {
|
||||||
|
// Reload the page to reflect state changes
|
||||||
|
// Future: HTMX swap individual tiles
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
es.onerror = () => {
|
||||||
|
dot.classList.add('disconnected');
|
||||||
|
es.close();
|
||||||
|
setTimeout(connect, 3000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user