b23ef64ee1
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>
188 lines
4.7 KiB
Go
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})
|
|
}
|