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
+
- | Name | Kind | Version | Default | Added |
+ | Name | Kind | Version | Default | Added | |
%s
- `, 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
+
+ `, 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