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:
@@ -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 {
|
||||
def := ""
|
||||
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>`,
|
||||
html.EscapeString(img.Name), img.Kind, img.Version, def, img.CreatedAt.Format("2006-01-02")))
|
||||
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))
|
||||
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(`
|
||||
<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">
|
||||
<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>
|
||||
</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 {
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"provisioning/internal/db"
|
||||
"provisioning/internal/events"
|
||||
"provisioning/internal/httpserver"
|
||||
"provisioning/internal/image"
|
||||
"provisioning/internal/model"
|
||||
"provisioning/internal/orchestrator"
|
||||
"provisioning/internal/pxe"
|
||||
@@ -39,6 +40,10 @@ func newTestServer(t *testing.T) *httptest.Server {
|
||||
hub := events.NewHub()
|
||||
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{
|
||||
Server: config.Server{
|
||||
Bind: "127.0.0.1:0",
|
||||
@@ -68,6 +73,8 @@ func newTestServer(t *testing.T) *httptest.Server {
|
||||
ServerTypes: serverTypes,
|
||||
}
|
||||
|
||||
imageAPI := &api.ImageAPI{Svc: imageSvc}
|
||||
|
||||
hostAPI := &api.HostAPI{
|
||||
Hosts: hosts,
|
||||
Ops: ops,
|
||||
@@ -94,6 +101,7 @@ func newTestServer(t *testing.T) *httptest.Server {
|
||||
Ops: ops,
|
||||
Locks: locks,
|
||||
Images: images,
|
||||
ImageSvc: imageSvc,
|
||||
Runner: runner,
|
||||
Orchestrator: hostOrch,
|
||||
Hub: hub,
|
||||
@@ -103,10 +111,12 @@ func newTestServer(t *testing.T) *httptest.Server {
|
||||
}
|
||||
|
||||
router := httpserver.NewRouter(httpserver.Deps{
|
||||
HostAPI: hostAPI,
|
||||
BootAPI: bootAPI,
|
||||
UI: ui,
|
||||
Hub: hub,
|
||||
HostAPI: hostAPI,
|
||||
BootAPI: bootAPI,
|
||||
ImageAPI: imageAPI,
|
||||
UI: ui,
|
||||
Hub: hub,
|
||||
ImageDir: imageDir,
|
||||
})
|
||||
|
||||
return httptest.NewServer(router)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"provisioning/internal/config"
|
||||
"provisioning/internal/events"
|
||||
"provisioning/internal/image"
|
||||
"provisioning/internal/model"
|
||||
"provisioning/internal/orchestrator"
|
||||
"provisioning/internal/pxe"
|
||||
@@ -23,6 +24,7 @@ type UI struct {
|
||||
Ops *store.Operations
|
||||
Locks *store.Locks
|
||||
Images *store.Images
|
||||
ImageSvc *image.Service
|
||||
Runner *orchestrator.Runner
|
||||
Orchestrator *orchestrator.HostOrchestrator
|
||||
Hub *events.Hub
|
||||
@@ -160,6 +162,57 @@ func (u *UI) ImagesPage(w http.ResponseWriter, r *http.Request) {
|
||||
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}$`)
|
||||
|
||||
func isValidMAC(mac string) bool {
|
||||
|
||||
Reference in New Issue
Block a user