diff --git a/.gitignore b/.gitignore index 2b01037..06db909 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ vendor/ # Templ generated *_templ.go +.claude/settings.json diff --git a/cmd/provisioning/main.go b/cmd/provisioning/main.go index 195debf..7c2cad4 100644 --- a/cmd/provisioning/main.go +++ b/cmd/provisioning/main.go @@ -16,6 +16,7 @@ import ( "provisioning/internal/db" "provisioning/internal/events" "provisioning/internal/httpserver" + "provisioning/internal/image" "provisioning/internal/infra" "provisioning/internal/orchestrator" "provisioning/internal/pxe" @@ -45,11 +46,17 @@ func main() { } 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} ops := &store.Operations{DB: database} locks := &store.Locks{DB: database, TTLMinutes: cfg.Locks.TTLMinutes} images := &store.Images{DB: database} + imageSvc := &image.Service{Store: images, ImageDir: cfg.Images.Dir} + hub := events.NewHub() runner := &orchestrator.Runner{ @@ -91,6 +98,8 @@ func main() { ServerTypes: serverTypes, } + imageAPI := &api.ImageAPI{Svc: imageSvc} + hostAPI := &api.HostAPI{ Hosts: hosts, Ops: ops, @@ -117,6 +126,7 @@ func main() { Ops: ops, Locks: locks, Images: images, + ImageSvc: imageSvc, Runner: runner, Orchestrator: hostOrch, Hub: hub, @@ -126,10 +136,12 @@ func main() { } router := httpserver.NewRouter(httpserver.Deps{ - HostAPI: hostAPI, - BootAPI: bootAPI, - UI: ui, - Hub: hub, + HostAPI: hostAPI, + BootAPI: bootAPI, + ImageAPI: imageAPI, + UI: ui, + Hub: hub, + ImageDir: cfg.Images.Dir, }) srv := &http.Server{ diff --git a/go.mod b/go.mod index b3bfa71..a501e97 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ 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/kdomanski/iso9660 v0.4.0 // 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 diff --git a/go.sum b/go.sum index a4a6297..d16d360 100644 --- a/go.sum +++ b/go.sum @@ -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/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/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/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= diff --git a/internal/api/images_api.go b/internal/api/images_api.go new file mode 100644 index 0000000..ed90cda --- /dev/null +++ b/internal/api/images_api.go @@ -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}) +} diff --git a/internal/api/render.go b/internal/api/render.go index fdc9326..66d133f 100644 --- a/internal/api/render.go +++ b/internal/api/render.go @@ -129,18 +129,50 @@ func imagesPage(images []model.Image) string { for _, img := range images { def := "" if img.IsDefault { - def = "✓" + def = `default` + } else { + def = fmt.Sprintf(`
`, img.ID) } - rows.WriteString(fmt.Sprintf(`%s%s%s%s%s`, - html.EscapeString(img.Name), img.Kind, img.Version, def, img.CreatedAt.Format("2006-01-02"))) + deleteBtn := fmt.Sprintf(`
`, img.ID, html.EscapeString(img.Name)) + rows.WriteString(fmt.Sprintf(`%s%s%s%s%s%s`, + html.EscapeString(img.Name), img.Kind, img.Version, def, img.CreatedAt.Format("2006-01-02"), deleteBtn)) + } + if len(images) == 0 { + rows.WriteString(`No images uploaded yet.`) } return layout("Images", fmt.Sprintf(` -

Boot Images

+
+ Upload Image + %d images +
- + %s
NameKindVersionDefaultAdded
NameKindVersionDefaultAdded
- `, rows.String())) + `, len(images), rows.String())) +} + +func imageUploadForm(errMsg string) string { + errHTML := "" + if errMsg != "" { + errHTML = fmt.Sprintf(`
%s
`, html.EscapeString(errMsg)) + } + return layout("Upload Image", fmt.Sprintf(` +

Upload Boot Image

+ %s +
+ + + + +

Upload may take several minutes for large ISOs. The kernel and initrd will be extracted automatically.

+ +
+ `, errHTML)) } func stateColor(s model.HostState) string { diff --git a/internal/api/smoke_test.go b/internal/api/smoke_test.go index 77a109b..4fb172b 100644 --- a/internal/api/smoke_test.go +++ b/internal/api/smoke_test.go @@ -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) diff --git a/internal/api/ui.go b/internal/api/ui.go index 19e5f72..50086b0 100644 --- a/internal/api/ui.go +++ b/internal/api/ui.go @@ -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 { diff --git a/internal/httpserver/router.go b/internal/httpserver/router.go index 9b658f1..452d149 100644 --- a/internal/httpserver/router.go +++ b/internal/httpserver/router.go @@ -13,10 +13,12 @@ import ( ) type Deps struct { - HostAPI *api.HostAPI - BootAPI *api.BootAPI - UI *api.UI - Hub *events.Hub + HostAPI *api.HostAPI + BootAPI *api.BootAPI + ImageAPI *api.ImageAPI + UI *api.UI + Hub *events.Hub + ImageDir string } func NewRouter(d Deps) http.Handler { @@ -29,6 +31,10 @@ func NewRouter(d Deps) http.Handler { staticFS, _ := fs.Sub(web.Static, "static") 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 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}/delete", d.UI.DeleteHost) 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 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) }) + // 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 r.Get("/ipxe/{mac}", d.BootAPI.IPXEScript) r.Post("/api/boot/answer", d.BootAPI.AnswerFile) diff --git a/internal/image/extract.go b/internal/image/extract.go new file mode 100644 index 0000000..e48ef7d --- /dev/null +++ b/internal/image/extract.go @@ -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 +} diff --git a/internal/image/service.go b/internal/image/service.go new file mode 100644 index 0000000..5677ea0 --- /dev/null +++ b/internal/image/service.go @@ -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) +} diff --git a/internal/pxe/ipxe.go b/internal/pxe/ipxe.go index 57f511f..8cfbe0a 100644 --- a/internal/pxe/ipxe.go +++ b/internal/pxe/ipxe.go @@ -7,8 +7,8 @@ import ( ) 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") + kernelURL := fmt.Sprintf("%s/images/boot/%s", publicURL, img.KernelPath) + initrdURL := fmt.Sprintf("%s/images/boot/%s", publicURL, img.InitrdPath) return fmt.Sprintf(`#!ipxe echo Provisioning: booting %s on ${mac} diff --git a/internal/store/images.go b/internal/store/images.go index 886610b..9664fac 100644 --- a/internal/store/images.go +++ b/internal/store/images.go @@ -93,6 +93,34 @@ func (s *Images) SetDefault(ctx context.Context, id int64) error { 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 { if b { return 1