Files
Provisioning/internal/api/hosts.go
T
josh b23ef64ee1
build-and-push / test (push) Successful in 9m57s
build-and-push / build-and-push (push) Has been cancelled
Use ephemeral SSH keys per rebuild instead of static config keys
Generate a fresh ed25519 key pair at rebuild time, inject the public key
into the Proxmox answer file, use the private key for cluster join over
SSH, then remove the key from both the remote host and the database.
This eliminates the need to manage static SSH keys in config/secrets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 21:09:22 -04:00

188 lines
4.7 KiB
Go

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
Orchestrator *orchestrator.HostOrchestrator
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.Orchestrator.PrepareRebuild(r.Context(), host.ID); err != nil {
_ = a.Locks.Release(r.Context(), host.ID)
writeJSONErr(w, http.StatusInternalServerError, "failed to generate SSH key: "+err.Error())
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})
}