Add boot image management with ISO extraction and serving
Upload Proxmox ISOs via API or dashboard UI, extract kernel+initrd using pure-Go iso9660 library, store on disk, and serve over HTTP for PXE booting. Dynamic kernel/initrd filenames per image replace the previous hardcoded paths. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -25,3 +25,4 @@ vendor/
|
|||||||
|
|
||||||
# Templ generated
|
# Templ generated
|
||||||
*_templ.go
|
*_templ.go
|
||||||
|
.claude/settings.json
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"provisioning/internal/db"
|
"provisioning/internal/db"
|
||||||
"provisioning/internal/events"
|
"provisioning/internal/events"
|
||||||
"provisioning/internal/httpserver"
|
"provisioning/internal/httpserver"
|
||||||
|
"provisioning/internal/image"
|
||||||
"provisioning/internal/infra"
|
"provisioning/internal/infra"
|
||||||
"provisioning/internal/orchestrator"
|
"provisioning/internal/orchestrator"
|
||||||
"provisioning/internal/pxe"
|
"provisioning/internal/pxe"
|
||||||
@@ -45,11 +46,17 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer database.Close()
|
defer database.Close()
|
||||||
|
|
||||||
|
if err := os.MkdirAll(cfg.Images.Dir, 0o755); err != nil {
|
||||||
|
log.Fatalf("create images dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
hosts := &store.Hosts{DB: database}
|
hosts := &store.Hosts{DB: database}
|
||||||
ops := &store.Operations{DB: database}
|
ops := &store.Operations{DB: database}
|
||||||
locks := &store.Locks{DB: database, TTLMinutes: cfg.Locks.TTLMinutes}
|
locks := &store.Locks{DB: database, TTLMinutes: cfg.Locks.TTLMinutes}
|
||||||
images := &store.Images{DB: database}
|
images := &store.Images{DB: database}
|
||||||
|
|
||||||
|
imageSvc := &image.Service{Store: images, ImageDir: cfg.Images.Dir}
|
||||||
|
|
||||||
hub := events.NewHub()
|
hub := events.NewHub()
|
||||||
|
|
||||||
runner := &orchestrator.Runner{
|
runner := &orchestrator.Runner{
|
||||||
@@ -91,6 +98,8 @@ func main() {
|
|||||||
ServerTypes: serverTypes,
|
ServerTypes: serverTypes,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
imageAPI := &api.ImageAPI{Svc: imageSvc}
|
||||||
|
|
||||||
hostAPI := &api.HostAPI{
|
hostAPI := &api.HostAPI{
|
||||||
Hosts: hosts,
|
Hosts: hosts,
|
||||||
Ops: ops,
|
Ops: ops,
|
||||||
@@ -117,6 +126,7 @@ func main() {
|
|||||||
Ops: ops,
|
Ops: ops,
|
||||||
Locks: locks,
|
Locks: locks,
|
||||||
Images: images,
|
Images: images,
|
||||||
|
ImageSvc: imageSvc,
|
||||||
Runner: runner,
|
Runner: runner,
|
||||||
Orchestrator: hostOrch,
|
Orchestrator: hostOrch,
|
||||||
Hub: hub,
|
Hub: hub,
|
||||||
@@ -128,8 +138,10 @@ func main() {
|
|||||||
router := httpserver.NewRouter(httpserver.Deps{
|
router := httpserver.NewRouter(httpserver.Deps{
|
||||||
HostAPI: hostAPI,
|
HostAPI: hostAPI,
|
||||||
BootAPI: bootAPI,
|
BootAPI: bootAPI,
|
||||||
|
ImageAPI: imageAPI,
|
||||||
UI: ui,
|
UI: ui,
|
||||||
Hub: hub,
|
Hub: hub,
|
||||||
|
ImageDir: cfg.Images.Dir,
|
||||||
})
|
})
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ require (
|
|||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
|
github.com/kdomanski/iso9660 v0.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ 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/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 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/kdomanski/iso9660 v0.4.0 h1:BPKKdcINz3m0MdjIMwS0wx1nofsOjxOq8TOr45WGHFg=
|
||||||
|
github.com/kdomanski/iso9660 v0.4.0/go.mod h1:OxUSupHsO9ceI8lBLPJKWBTphLemjrCQY8LPXM7qSzU=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
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/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 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"provisioning/internal/image"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ImageAPI struct {
|
||||||
|
Svc *image.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ImageAPI) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
images, err := a.Svc.Store.List(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeJSONErr(w, http.StatusInternalServerError, "failed to list images")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, images)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ImageAPI) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := idFromURL(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
img, err := a.Svc.Store.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONErr(w, http.StatusNotFound, "image not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, img)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ImageAPI) Upload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseMultipartForm(0); err != nil {
|
||||||
|
writeJSONErr(w, http.StatusBadRequest, "invalid multipart form")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
|
version := strings.TrimSpace(r.FormValue("version"))
|
||||||
|
kind := strings.TrimSpace(r.FormValue("kind"))
|
||||||
|
|
||||||
|
file, _, err := r.FormFile("iso")
|
||||||
|
if err != nil {
|
||||||
|
writeJSONErr(w, http.StatusBadRequest, "iso file is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
img, err := a.Svc.Upload(r.Context(), image.UploadParams{
|
||||||
|
Name: name,
|
||||||
|
Kind: kind,
|
||||||
|
Version: version,
|
||||||
|
ISO: file,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
status := http.StatusInternalServerError
|
||||||
|
msg := err.Error()
|
||||||
|
if strings.Contains(msg, "already exists") {
|
||||||
|
status = http.StatusConflict
|
||||||
|
} else if strings.Contains(msg, "invalid name") || strings.Contains(msg, "version is required") {
|
||||||
|
status = http.StatusBadRequest
|
||||||
|
}
|
||||||
|
writeJSONErr(w, status, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, img)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ImageAPI) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := idFromURL(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.Svc.Delete(r.Context(), id); err != nil {
|
||||||
|
writeJSONErr(w, http.StatusNotFound, "image not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ImageAPI) SetDefault(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := idFromURL(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.Svc.Store.SetDefault(r.Context(), id); err != nil {
|
||||||
|
writeJSONErr(w, http.StatusInternalServerError, "failed to set default")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
+38
-6
@@ -129,18 +129,50 @@ func imagesPage(images []model.Image) string {
|
|||||||
for _, img := range images {
|
for _, img := range images {
|
||||||
def := ""
|
def := ""
|
||||||
if img.IsDefault {
|
if img.IsDefault {
|
||||||
def = "✓"
|
def = `<span class="badge state-green">default</span>`
|
||||||
|
} else {
|
||||||
|
def = fmt.Sprintf(`<form method="POST" action="/images/%d/default" class="inline"><button class="btn" style="font-size:0.75rem;padding:0.2rem 0.5rem">Set Default</button></form>`, img.ID)
|
||||||
}
|
}
|
||||||
rows.WriteString(fmt.Sprintf(`<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>`,
|
deleteBtn := fmt.Sprintf(`<form method="POST" action="/images/%d/delete" class="inline" onsubmit="return confirm('Delete image %s?')"><button class="btn btn-danger" style="font-size:0.75rem;padding:0.2rem 0.5rem">Delete</button></form>`, img.ID, html.EscapeString(img.Name))
|
||||||
html.EscapeString(img.Name), img.Kind, img.Version, def, img.CreatedAt.Format("2006-01-02")))
|
rows.WriteString(fmt.Sprintf(`<tr><td>%s</td><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"), deleteBtn))
|
||||||
|
}
|
||||||
|
if len(images) == 0 {
|
||||||
|
rows.WriteString(`<tr><td colspan="6" style="text-align:center;color:var(--text-muted)">No images uploaded yet.</td></tr>`)
|
||||||
}
|
}
|
||||||
return layout("Images", fmt.Sprintf(`
|
return layout("Images", fmt.Sprintf(`
|
||||||
<h2>Boot Images</h2>
|
<div class="actions">
|
||||||
|
<a href="/images/new" class="btn">Upload Image</a>
|
||||||
|
<span class="count">%d images</span>
|
||||||
|
</div>
|
||||||
<table class="ops-table">
|
<table class="ops-table">
|
||||||
<thead><tr><th>Name</th><th>Kind</th><th>Version</th><th>Default</th><th>Added</th></tr></thead>
|
<thead><tr><th>Name</th><th>Kind</th><th>Version</th><th>Default</th><th>Added</th><th></th></tr></thead>
|
||||||
<tbody>%s</tbody>
|
<tbody>%s</tbody>
|
||||||
</table>
|
</table>
|
||||||
`, rows.String()))
|
`, len(images), rows.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func imageUploadForm(errMsg string) string {
|
||||||
|
errHTML := ""
|
||||||
|
if errMsg != "" {
|
||||||
|
errHTML = fmt.Sprintf(`<div class="error">%s</div>`, html.EscapeString(errMsg))
|
||||||
|
}
|
||||||
|
return layout("Upload Image", fmt.Sprintf(`
|
||||||
|
<h2>Upload Boot Image</h2>
|
||||||
|
%s
|
||||||
|
<form method="POST" action="/images/upload" enctype="multipart/form-data" class="form">
|
||||||
|
<label>Name<input type="text" name="name" placeholder="proxmox-8.2" required pattern="[a-z0-9][a-z0-9.\-]*"></label>
|
||||||
|
<label>Version<input type="text" name="version" placeholder="8.2-1" required></label>
|
||||||
|
<label>Kind
|
||||||
|
<select name="kind">
|
||||||
|
<option value="proxmox" selected>Proxmox VE</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>ISO File<input type="file" name="iso" accept=".iso" required></label>
|
||||||
|
<p style="font-size:0.8rem;color:var(--text-muted);margin-bottom:0.5rem">Upload may take several minutes for large ISOs. The kernel and initrd will be extracted automatically.</p>
|
||||||
|
<button type="submit" class="btn">Upload & Extract</button>
|
||||||
|
</form>
|
||||||
|
`, errHTML))
|
||||||
}
|
}
|
||||||
|
|
||||||
func stateColor(s model.HostState) string {
|
func stateColor(s model.HostState) string {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"provisioning/internal/db"
|
"provisioning/internal/db"
|
||||||
"provisioning/internal/events"
|
"provisioning/internal/events"
|
||||||
"provisioning/internal/httpserver"
|
"provisioning/internal/httpserver"
|
||||||
|
"provisioning/internal/image"
|
||||||
"provisioning/internal/model"
|
"provisioning/internal/model"
|
||||||
"provisioning/internal/orchestrator"
|
"provisioning/internal/orchestrator"
|
||||||
"provisioning/internal/pxe"
|
"provisioning/internal/pxe"
|
||||||
@@ -39,6 +40,10 @@ func newTestServer(t *testing.T) *httptest.Server {
|
|||||||
hub := events.NewHub()
|
hub := events.NewHub()
|
||||||
t.Cleanup(func() { hub.Shutdown(context.Background()) })
|
t.Cleanup(func() { hub.Shutdown(context.Background()) })
|
||||||
|
|
||||||
|
imageDir := filepath.Join(tmp, "images")
|
||||||
|
os.MkdirAll(imageDir, 0o755)
|
||||||
|
imageSvc := &image.Service{Store: images, ImageDir: imageDir}
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
Server: config.Server{
|
Server: config.Server{
|
||||||
Bind: "127.0.0.1:0",
|
Bind: "127.0.0.1:0",
|
||||||
@@ -68,6 +73,8 @@ func newTestServer(t *testing.T) *httptest.Server {
|
|||||||
ServerTypes: serverTypes,
|
ServerTypes: serverTypes,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
imageAPI := &api.ImageAPI{Svc: imageSvc}
|
||||||
|
|
||||||
hostAPI := &api.HostAPI{
|
hostAPI := &api.HostAPI{
|
||||||
Hosts: hosts,
|
Hosts: hosts,
|
||||||
Ops: ops,
|
Ops: ops,
|
||||||
@@ -94,6 +101,7 @@ func newTestServer(t *testing.T) *httptest.Server {
|
|||||||
Ops: ops,
|
Ops: ops,
|
||||||
Locks: locks,
|
Locks: locks,
|
||||||
Images: images,
|
Images: images,
|
||||||
|
ImageSvc: imageSvc,
|
||||||
Runner: runner,
|
Runner: runner,
|
||||||
Orchestrator: hostOrch,
|
Orchestrator: hostOrch,
|
||||||
Hub: hub,
|
Hub: hub,
|
||||||
@@ -105,8 +113,10 @@ func newTestServer(t *testing.T) *httptest.Server {
|
|||||||
router := httpserver.NewRouter(httpserver.Deps{
|
router := httpserver.NewRouter(httpserver.Deps{
|
||||||
HostAPI: hostAPI,
|
HostAPI: hostAPI,
|
||||||
BootAPI: bootAPI,
|
BootAPI: bootAPI,
|
||||||
|
ImageAPI: imageAPI,
|
||||||
UI: ui,
|
UI: ui,
|
||||||
Hub: hub,
|
Hub: hub,
|
||||||
|
ImageDir: imageDir,
|
||||||
})
|
})
|
||||||
|
|
||||||
return httptest.NewServer(router)
|
return httptest.NewServer(router)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"provisioning/internal/config"
|
"provisioning/internal/config"
|
||||||
"provisioning/internal/events"
|
"provisioning/internal/events"
|
||||||
|
"provisioning/internal/image"
|
||||||
"provisioning/internal/model"
|
"provisioning/internal/model"
|
||||||
"provisioning/internal/orchestrator"
|
"provisioning/internal/orchestrator"
|
||||||
"provisioning/internal/pxe"
|
"provisioning/internal/pxe"
|
||||||
@@ -23,6 +24,7 @@ type UI struct {
|
|||||||
Ops *store.Operations
|
Ops *store.Operations
|
||||||
Locks *store.Locks
|
Locks *store.Locks
|
||||||
Images *store.Images
|
Images *store.Images
|
||||||
|
ImageSvc *image.Service
|
||||||
Runner *orchestrator.Runner
|
Runner *orchestrator.Runner
|
||||||
Orchestrator *orchestrator.HostOrchestrator
|
Orchestrator *orchestrator.HostOrchestrator
|
||||||
Hub *events.Hub
|
Hub *events.Hub
|
||||||
@@ -160,6 +162,57 @@ func (u *UI) ImagesPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
renderHTML(w, imagesPage(images))
|
renderHTML(w, imagesPage(images))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *UI) NewImageForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
renderHTML(w, imageUploadForm(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UI) UploadImage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseMultipartForm(0); err != nil {
|
||||||
|
renderHTML(w, imageUploadForm("Invalid form submission"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
|
version := strings.TrimSpace(r.FormValue("version"))
|
||||||
|
kind := strings.TrimSpace(r.FormValue("kind"))
|
||||||
|
|
||||||
|
file, _, err := r.FormFile("iso")
|
||||||
|
if err != nil {
|
||||||
|
renderHTML(w, imageUploadForm("ISO file is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
_, err = u.ImageSvc.Upload(r.Context(), image.UploadParams{
|
||||||
|
Name: name,
|
||||||
|
Kind: kind,
|
||||||
|
Version: version,
|
||||||
|
ISO: file,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
renderHTML(w, imageUploadForm(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/images", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UI) SetDefaultImage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := chi.URLParam(r, "id")
|
||||||
|
var id int64
|
||||||
|
fmt.Sscanf(idStr, "%d", &id)
|
||||||
|
_ = u.Images.SetDefault(r.Context(), id)
|
||||||
|
http.Redirect(w, r, "/images", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UI) DeleteImage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := chi.URLParam(r, "id")
|
||||||
|
var id int64
|
||||||
|
fmt.Sscanf(idStr, "%d", &id)
|
||||||
|
_ = u.ImageSvc.Delete(r.Context(), id)
|
||||||
|
http.Redirect(w, r, "/images", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
var macRegex = regexp.MustCompile(`^([0-9a-fA-F]{2}[:\-]){5}[0-9a-fA-F]{2}$`)
|
var macRegex = regexp.MustCompile(`^([0-9a-fA-F]{2}[:\-]){5}[0-9a-fA-F]{2}$`)
|
||||||
|
|
||||||
func isValidMAC(mac string) bool {
|
func isValidMAC(mac string) bool {
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ import (
|
|||||||
type Deps struct {
|
type Deps struct {
|
||||||
HostAPI *api.HostAPI
|
HostAPI *api.HostAPI
|
||||||
BootAPI *api.BootAPI
|
BootAPI *api.BootAPI
|
||||||
|
ImageAPI *api.ImageAPI
|
||||||
UI *api.UI
|
UI *api.UI
|
||||||
Hub *events.Hub
|
Hub *events.Hub
|
||||||
|
ImageDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRouter(d Deps) http.Handler {
|
func NewRouter(d Deps) http.Handler {
|
||||||
@@ -29,6 +31,10 @@ func NewRouter(d Deps) http.Handler {
|
|||||||
staticFS, _ := fs.Sub(web.Static, "static")
|
staticFS, _ := fs.Sub(web.Static, "static")
|
||||||
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
||||||
|
|
||||||
|
// Boot image files (kernel/initrd served from disk)
|
||||||
|
r.Handle("/images/boot/*", http.StripPrefix("/images/boot/",
|
||||||
|
http.FileServer(http.Dir(d.ImageDir))))
|
||||||
|
|
||||||
// SSE
|
// SSE
|
||||||
r.Get("/events", d.Hub.ServeSSE)
|
r.Get("/events", d.Hub.ServeSSE)
|
||||||
|
|
||||||
@@ -40,6 +46,10 @@ func NewRouter(d Deps) http.Handler {
|
|||||||
r.Post("/hosts/{id}/rebuild", d.UI.TriggerRebuild)
|
r.Post("/hosts/{id}/rebuild", d.UI.TriggerRebuild)
|
||||||
r.Post("/hosts/{id}/delete", d.UI.DeleteHost)
|
r.Post("/hosts/{id}/delete", d.UI.DeleteHost)
|
||||||
r.Get("/images", d.UI.ImagesPage)
|
r.Get("/images", d.UI.ImagesPage)
|
||||||
|
r.Get("/images/new", d.UI.NewImageForm)
|
||||||
|
r.Post("/images/upload", d.UI.UploadImage)
|
||||||
|
r.Post("/images/{id}/default", d.UI.SetDefaultImage)
|
||||||
|
r.Post("/images/{id}/delete", d.UI.DeleteImage)
|
||||||
|
|
||||||
// Host JSON API
|
// Host JSON API
|
||||||
r.Route("/api/hosts", func(r chi.Router) {
|
r.Route("/api/hosts", func(r chi.Router) {
|
||||||
@@ -50,6 +60,15 @@ func NewRouter(d Deps) http.Handler {
|
|||||||
r.Post("/{id}/rebuild", d.HostAPI.Rebuild)
|
r.Post("/{id}/rebuild", d.HostAPI.Rebuild)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Image JSON API
|
||||||
|
r.Route("/api/images", func(r chi.Router) {
|
||||||
|
r.Get("/", d.ImageAPI.List)
|
||||||
|
r.Post("/", d.ImageAPI.Upload)
|
||||||
|
r.Get("/{id}", d.ImageAPI.Get)
|
||||||
|
r.Delete("/{id}", d.ImageAPI.Delete)
|
||||||
|
r.Post("/{id}/default", d.ImageAPI.SetDefault)
|
||||||
|
})
|
||||||
|
|
||||||
// Boot / PXE endpoints
|
// Boot / PXE endpoints
|
||||||
r.Get("/ipxe/{mac}", d.BootAPI.IPXEScript)
|
r.Get("/ipxe/{mac}", d.BootAPI.IPXEScript)
|
||||||
r.Post("/api/boot/answer", d.BootAPI.AnswerFile)
|
r.Post("/api/boot/answer", d.BootAPI.AnswerFile)
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package image
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kdomanski/iso9660"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExtractResult struct {
|
||||||
|
KernelFilename string
|
||||||
|
InitrdFilename string
|
||||||
|
}
|
||||||
|
|
||||||
|
var kernelCandidates = []string{"linux26", "vmlinuz", "bzImage"}
|
||||||
|
var initrdCandidates = []string{"initrd.img", "initrd", "initrd.gz"}
|
||||||
|
|
||||||
|
func ExtractFromISO(r io.Reader, destDir string) (*ExtractResult, error) {
|
||||||
|
tmp, err := os.CreateTemp(filepath.Dir(destDir), "iso-upload-*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create temp file: %w", err)
|
||||||
|
}
|
||||||
|
tmpPath := tmp.Name()
|
||||||
|
defer os.Remove(tmpPath)
|
||||||
|
|
||||||
|
if _, err := io.Copy(tmp, r); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return nil, fmt.Errorf("write ISO to temp file: %w", err)
|
||||||
|
}
|
||||||
|
tmp.Close()
|
||||||
|
|
||||||
|
f, err := os.Open(tmpPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open temp ISO: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
img, err := iso9660.OpenImage(f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse ISO: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
root, err := img.RootDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read ISO root: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
candidateSet := make(map[string]bool)
|
||||||
|
for _, c := range kernelCandidates {
|
||||||
|
candidateSet[strings.ToLower(c)] = true
|
||||||
|
}
|
||||||
|
for _, c := range initrdCandidates {
|
||||||
|
candidateSet[strings.ToLower(c)] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
found := make(map[string]*iso9660.File)
|
||||||
|
walkDir(root, candidateSet, found)
|
||||||
|
|
||||||
|
kernelFile, kernelName := matchFirst(found, kernelCandidates)
|
||||||
|
initrdFile, initrdName := matchFirst(found, initrdCandidates)
|
||||||
|
|
||||||
|
if kernelFile == nil {
|
||||||
|
return nil, fmt.Errorf("no kernel found in ISO (looked for %s)", strings.Join(kernelCandidates, ", "))
|
||||||
|
}
|
||||||
|
if initrdFile == nil {
|
||||||
|
return nil, fmt.Errorf("no initrd found in ISO (looked for %s)", strings.Join(initrdCandidates, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := extractFile(kernelFile, filepath.Join(destDir, kernelName)); err != nil {
|
||||||
|
return nil, fmt.Errorf("extract kernel: %w", err)
|
||||||
|
}
|
||||||
|
if err := extractFile(initrdFile, filepath.Join(destDir, initrdName)); err != nil {
|
||||||
|
return nil, fmt.Errorf("extract initrd: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ExtractResult{
|
||||||
|
KernelFilename: kernelName,
|
||||||
|
InitrdFilename: initrdName,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func walkDir(dir *iso9660.File, candidates map[string]bool, found map[string]*iso9660.File) {
|
||||||
|
children, err := dir.GetChildren()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, child := range children {
|
||||||
|
name := strings.ToLower(child.Name())
|
||||||
|
if child.IsDir() {
|
||||||
|
walkDir(child, candidates, found)
|
||||||
|
} else if candidates[name] {
|
||||||
|
if _, exists := found[name]; !exists {
|
||||||
|
found[name] = child
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchFirst(found map[string]*iso9660.File, candidates []string) (*iso9660.File, string) {
|
||||||
|
for _, c := range candidates {
|
||||||
|
lower := strings.ToLower(c)
|
||||||
|
if f, ok := found[lower]; ok {
|
||||||
|
return f, f.Name()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractFile(isoFile *iso9660.File, destPath string) error {
|
||||||
|
out, err := os.Create(destPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
reader := isoFile.Reader()
|
||||||
|
if reader == nil {
|
||||||
|
return fmt.Errorf("cannot read %s from ISO", isoFile.Name())
|
||||||
|
}
|
||||||
|
_, err = io.Copy(out, reader)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package image
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"provisioning/internal/model"
|
||||||
|
"provisioning/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
Store *store.Images
|
||||||
|
ImageDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UploadParams struct {
|
||||||
|
Name string
|
||||||
|
Kind string
|
||||||
|
Version string
|
||||||
|
ISO io.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
var slugRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9.-]*$`)
|
||||||
|
|
||||||
|
func (s *Service) Upload(ctx context.Context, p UploadParams) (*model.Image, error) {
|
||||||
|
if !slugRegex.MatchString(p.Name) {
|
||||||
|
return nil, fmt.Errorf("invalid name %q: must be lowercase alphanumeric with hyphens/dots", p.Name)
|
||||||
|
}
|
||||||
|
if p.Kind == "" {
|
||||||
|
p.Kind = "proxmox"
|
||||||
|
}
|
||||||
|
if p.Version == "" {
|
||||||
|
return nil, fmt.Errorf("version is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.Store.GetByName(ctx, p.Name); err == nil {
|
||||||
|
return nil, fmt.Errorf("image %q already exists", p.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
destDir := filepath.Join(s.ImageDir, p.Name)
|
||||||
|
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("create image dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ExtractFromISO(p.ISO, destDir)
|
||||||
|
if err != nil {
|
||||||
|
os.RemoveAll(destDir)
|
||||||
|
return nil, fmt.Errorf("extract ISO: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
kernelPath := filepath.Join(p.Name, result.KernelFilename)
|
||||||
|
initrdPath := filepath.Join(p.Name, result.InitrdFilename)
|
||||||
|
|
||||||
|
id, err := s.Store.Create(ctx, model.Image{
|
||||||
|
Name: p.Name,
|
||||||
|
Kind: p.Kind,
|
||||||
|
Version: p.Version,
|
||||||
|
KernelPath: kernelPath,
|
||||||
|
InitrdPath: initrdPath,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
os.RemoveAll(destDir)
|
||||||
|
return nil, fmt.Errorf("save image record: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
img, err := s.Store.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return img, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Delete(ctx context.Context, id int64) error {
|
||||||
|
img, err := s.Store.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
destDir := filepath.Join(s.ImageDir, img.Name)
|
||||||
|
os.RemoveAll(destDir)
|
||||||
|
|
||||||
|
return s.Store.Delete(ctx, id)
|
||||||
|
}
|
||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func BuildIPXEScript(publicURL string, img *model.Image, mac string) string {
|
func BuildIPXEScript(publicURL string, img *model.Image, mac string) string {
|
||||||
kernelURL := fmt.Sprintf("%s/images/boot/%s/%s", publicURL, img.Name, "linux26")
|
kernelURL := fmt.Sprintf("%s/images/boot/%s", publicURL, img.KernelPath)
|
||||||
initrdURL := fmt.Sprintf("%s/images/boot/%s/%s", publicURL, img.Name, "initrd.img")
|
initrdURL := fmt.Sprintf("%s/images/boot/%s", publicURL, img.InitrdPath)
|
||||||
|
|
||||||
return fmt.Sprintf(`#!ipxe
|
return fmt.Sprintf(`#!ipxe
|
||||||
echo Provisioning: booting %s on ${mac}
|
echo Provisioning: booting %s on ${mac}
|
||||||
|
|||||||
@@ -93,6 +93,34 @@ func (s *Images) SetDefault(ctx context.Context, id int64) error {
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Images) GetByName(ctx context.Context, name string) (*model.Image, error) {
|
||||||
|
row := s.DB.QueryRowContext(ctx, `SELECT id, name, kind, version, kernel_path, initrd_path, is_default, created_at FROM images WHERE name = ?`, name)
|
||||||
|
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 by name: %w", err)
|
||||||
|
}
|
||||||
|
img.IsDefault = isDefault == 1
|
||||||
|
img.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||||
|
return &img, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Images) Delete(ctx context.Context, id int64) error {
|
||||||
|
res, err := s.DB.ExecContext(ctx, `DELETE FROM images WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete image: %w", err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func boolToInt(b bool) int {
|
func boolToInt(b bool) int {
|
||||||
if b {
|
if b {
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
Reference in New Issue
Block a user