From 6b4635a8800b0bed805e8dccd68f446c7a9b74ef Mon Sep 17 00:00:00 2001 From: Gary Bowers Date: Fri, 31 Jul 2026 08:15:04 +0100 Subject: [PATCH 1/4] Resolve issue with soft deleted clients, resolve issue with kali --- internal/admin/handlers.go | 109 +++++++++++++++++ internal/auth/admin_authz_test.go | 8 +- internal/extractor/detect_generic.go | 8 ++ internal/extractor/detect_unified.go | 95 +++++++++++++++ internal/extractor/extractor.go | 29 +++-- internal/extractor/live_integration_test.go | 71 +++++++++++ internal/models/models.go | 2 + internal/server/menu.go | 14 ++- internal/server/menu_override_test.go | 59 +++++++++ internal/server/server.go | 1 + internal/storage/clients.go | 49 ++++++++ internal/storage/clients_test.go | 125 ++++++++++++++++++++ internal/storage/postgres.go | 35 +++--- internal/storage/sqlite.go | 35 +++--- web/static/app.js | 45 ++++++- web/static/i18n.js | 20 ++++ web/static/index.html | 18 +++ 17 files changed, 665 insertions(+), 58 deletions(-) create mode 100644 internal/extractor/live_integration_test.go create mode 100644 internal/server/menu_override_test.go create mode 100644 internal/storage/clients.go create mode 100644 internal/storage/clients_test.go diff --git a/internal/admin/handlers.go b/internal/admin/handlers.go index 303dd41..ff376d8 100644 --- a/internal/admin/handlers.go +++ b/internal/admin/handlers.go @@ -16,6 +16,7 @@ import ( "net/http" "os" "path/filepath" + "sort" "strconv" "strings" "sync" @@ -858,6 +859,22 @@ func (h *Handler) UpdateImage(w http.ResponseWriter, r *http.Request) { image.AutoInstallFile = aiFile image.AutoInstallEnabled = aiFile != "" || image.AutoInstallScript != "" } + if kernelOverride, ok := updates["kernel_override"].(string); ok { + resolved, err := h.resolveBootFileOverride(filename, kernelOverride) + if err != nil { + h.sendJSON(w, http.StatusBadRequest, Response{Success: false, Error: fmt.Sprintf("Invalid kernel override: %v", err)}) + return + } + image.KernelOverride = resolved + } + if initrdOverride, ok := updates["initrd_override"].(string); ok { + resolved, err := h.resolveBootFileOverride(filename, initrdOverride) + if err != nil { + h.sendJSON(w, http.StatusBadRequest, Response{Success: false, Error: fmt.Sprintf("Invalid initrd override: %v", err)}) + return + } + image.InitrdOverride = resolved + } if err := h.storage.UpdateImage(filename, image); err != nil { h.sendJSON(w, http.StatusInternalServerError, Response{Success: false, Error: err.Error()}) @@ -868,6 +885,98 @@ func (h *Handler) UpdateImage(w http.ResponseWriter, r *http.Request) { h.sendJSON(w, http.StatusOK, Response{Success: true, Message: "Image updated", Data: image}) } +func (h *Handler) resolveBootFileOverride(filename, rel string) (string, error) { + if rel == "" { + return "", nil + } + rel = strings.Trim(filepath.ToSlash(rel), "/") + if rel == "" || strings.Contains(rel, "..") { + return "", fmt.Errorf("invalid path") + } + isoBase := strings.TrimSuffix(filename, filepath.Ext(filename)) + baseDir := filepath.Clean(filepath.Join(h.isoDir, isoBase)) + fullPath := filepath.Clean(filepath.Join(baseDir, filepath.FromSlash(rel))) + if !strings.HasPrefix(fullPath, baseDir+string(filepath.Separator)) { + return "", fmt.Errorf("invalid path") + } + info, err := os.Stat(fullPath) + if err != nil { + return "", fmt.Errorf("file not found: %s", rel) + } + if info.IsDir() { + return "", fmt.Errorf("not a file: %s", rel) + } + return rel, nil +} + +func (h *Handler) BootFileCandidates(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + h.sendJSON(w, http.StatusMethodNotAllowed, Response{Success: false, Error: "Method not allowed"}) + return + } + + filename := r.URL.Query().Get("filename") + if filename == "" { + h.sendJSON(w, http.StatusBadRequest, Response{Success: false, Error: "Missing filename parameter"}) + return + } + + image, err := h.storage.GetImage(filename) + if err != nil { + h.sendJSON(w, http.StatusNotFound, Response{Success: false, Error: "Image not found"}) + return + } + + type bootFileCandidate struct { + Path string `json:"path"` + Size int64 `json:"size"` + } + + isoBase := strings.TrimSuffix(filename, filepath.Ext(filename)) + baseDir := filepath.Join(h.isoDir, isoBase) + extractedDir := filepath.Join(baseDir, "iso") + + excludedExts := map[string]bool{".deb": true, ".udeb": true, ".rpm": true, ".mod": true, ".txt": true, ".sig": true} + + kernels := []bootFileCandidate{} + initrds := []bootFileCandidate{} + filepath.Walk(extractedDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if info.IsDir() { + if strings.EqualFold(info.Name(), "pool") || strings.EqualFold(info.Name(), "pool_udeb") { + return filepath.SkipDir + } + return nil + } + if excludedExts[strings.ToLower(filepath.Ext(info.Name()))] { + return nil + } + rel, relErr := filepath.Rel(baseDir, path) + if relErr != nil { + return nil + } + candidate := bootFileCandidate{Path: filepath.ToSlash(rel), Size: info.Size()} + if extractor.IsKernelFileName(info.Name()) { + kernels = append(kernels, candidate) + } else if extractor.IsInitrdFileName(info.Name()) { + initrds = append(initrds, candidate) + } + return nil + }) + + sort.Slice(kernels, func(i, j int) bool { return kernels[i].Path < kernels[j].Path }) + sort.Slice(initrds, func(i, j int) bool { return initrds[i].Path < initrds[j].Path }) + + h.sendJSON(w, http.StatusOK, Response{Success: true, Data: map[string]any{ + "kernels": kernels, + "initrds": initrds, + "kernel_override": image.KernelOverride, + "initrd_override": image.InitrdOverride, + }}) +} + func (h *Handler) DeleteImage(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { h.sendJSON(w, http.StatusMethodNotAllowed, Response{Success: false, Error: "Method not allowed"}) diff --git a/internal/auth/admin_authz_test.go b/internal/auth/admin_authz_test.go index 5554cd5..3783351 100644 --- a/internal/auth/admin_authz_test.go +++ b/internal/auth/admin_authz_test.go @@ -12,9 +12,11 @@ type fakeUserStore struct { users map[string]*models.User } -func (f *fakeUserStore) EnsureAdminUser() (string, string, bool, error) { return "admin", "", false, nil } -func (f *fakeUserStore) ResetAdminPassword() (string, error) { return "", nil } -func (f *fakeUserStore) UpdateUserLastLogin(string) error { return nil } +func (f *fakeUserStore) EnsureAdminUser() (string, string, bool, error) { + return "admin", "", false, nil +} +func (f *fakeUserStore) ResetAdminPassword() (string, error) { return "", nil } +func (f *fakeUserStore) UpdateUserLastLogin(string) error { return nil } func (f *fakeUserStore) GetUser(username string) (*models.User, error) { u, ok := f.users[username] if !ok { diff --git a/internal/extractor/detect_generic.go b/internal/extractor/detect_generic.go index ecfc44b..4da0a2b 100644 --- a/internal/extractor/detect_generic.go +++ b/internal/extractor/detect_generic.go @@ -55,6 +55,14 @@ func (e *Extractor) detectGenericUnified(reader FileSystemReader) (*BootFiles, e }, nil } +func IsKernelFileName(name string) bool { + return isKernelFile(strings.ToLower(name)) +} + +func IsInitrdFileName(name string) bool { + return isInitrdFile(strings.ToLower(name)) +} + func isKernelFile(name string) bool { kernelPatterns := []string{ "vmlinuz", diff --git a/internal/extractor/detect_unified.go b/internal/extractor/detect_unified.go index e0d36e7..8098b80 100644 --- a/internal/extractor/detect_unified.go +++ b/internal/extractor/detect_unified.go @@ -109,7 +109,59 @@ func detectDistroNameUnified(reader FileSystemReader, isoPath string) string { return "" } +func (e *Extractor) detectLiveDebianUnified(reader FileSystemReader) *BootFiles { + entries, err := reader.ListDirectory("/live") + if err != nil || entries == nil { + return nil + } + + var kernel, versionedKernel, initrd, versionedInitrd, squashfs string + for _, entry := range entries { + if entry.IsDir { + continue + } + name := strings.ToLower(entry.Name) + path := "/live/" + entry.Name + switch { + case name == "vmlinuz": + kernel = path + case strings.HasPrefix(name, "vmlinuz-") && versionedKernel == "": + versionedKernel = path + case name == "initrd.img" || name == "initrd": + initrd = path + case (strings.HasPrefix(name, "initrd.img-") || strings.HasPrefix(name, "initrd-")) && versionedInitrd == "": + versionedInitrd = path + case strings.HasPrefix(name, "filesystem.squashfs"): + squashfs = path + case strings.HasSuffix(name, ".squashfs") && squashfs == "": + squashfs = path + } + } + + if versionedKernel != "" { + kernel = versionedKernel + } + if versionedInitrd != "" { + initrd = versionedInitrd + } + if kernel == "" || initrd == "" || squashfs == "" { + return nil + } + + log.Printf("Detected Debian live system: kernel=%s initrd=%s squashfs=%s", kernel, initrd, squashfs) + return &BootFiles{ + Kernel: kernel, + Initrd: initrd, + Distro: "debian", + SquashfsPath: squashfs, + } +} + func (e *Extractor) detectUbuntuDebianUnified(reader FileSystemReader) (*BootFiles, error) { + if files := e.detectLiveDebianUnified(reader); files != nil { + return files, nil + } + paths := []struct { kernel string initrd string @@ -142,6 +194,10 @@ func (e *Extractor) detectUbuntuDebianUnified(reader FileSystemReader) (*BootFil NetbootRequired: p.netboot, NetbootURL: p.netbootURL, } + squashfs := parentDir(p.kernel) + "/filesystem.squashfs" + if reader.FileExists(squashfs) { + bootFiles.SquashfsPath = squashfs + } return bootFiles, nil } } @@ -524,5 +580,44 @@ func (e *Extractor) cacheBootFilesUnified(files *BootFiles, reader FileSystemRea files.ExtractedDir = extractedDir + if files.SquashfsPath != "" { + if rel := resolveExtractedRelPath(bootFilesDir, files.SquashfsPath); rel != "" { + files.SquashfsPath = rel + } else { + log.Printf("Warning: squashfs %s not found in extracted ISO contents", files.SquashfsPath) + files.SquashfsPath = "" + } + } + return nil } + +func resolveExtractedRelPath(bootFilesDir, isoPath string) string { + rel := "iso" + cur := filepath.Join(bootFilesDir, "iso") + for _, part := range strings.Split(strings.TrimPrefix(isoPath, "/"), "/") { + if part == "" { + continue + } + entries, err := os.ReadDir(cur) + if err != nil { + return "" + } + match := "" + for _, entry := range entries { + if entry.Name() == part { + match = part + break + } + if match == "" && strings.EqualFold(entry.Name(), part) { + match = entry.Name() + } + } + if match == "" { + return "" + } + rel = rel + "/" + match + cur = filepath.Join(cur, match) + } + return rel +} diff --git a/internal/extractor/extractor.go b/internal/extractor/extractor.go index 45cd469..6c1fcbd 100644 --- a/internal/extractor/extractor.go +++ b/internal/extractor/extractor.go @@ -881,7 +881,8 @@ func (e *Extractor) extractDirectory(dir *iso9660.File, destPath, isoPath string return nil } - for _, child := range children { + for i := 0; i < len(children); i++ { + child := children[i] name := child.Name() if name == "" || name == "." || name == ".." { continue @@ -906,7 +907,15 @@ func (e *Extractor) extractDirectory(dir *iso9660.File, destPath, isoPath string log.Printf("Warning: error extracting directory %s: %v (continuing)", childISOPath, err) } } else { - if err := e.extractFile(child, childDestPath, childISOPath); err != nil { + extents := []*iso9660.File{child} + for i+1 < len(children) && !children[i+1].IsDir() && children[i+1].Name() == name { + i++ + extents = append(extents, children[i]) + } + if len(extents) > 1 { + log.Printf("Extracting multi-extent file %s (%d extents)", childISOPath, len(extents)) + } + if err := e.extractFile(extents, childDestPath, childISOPath); err != nil { log.Printf("Warning: failed to extract file %s: %v (skipping)", childISOPath, err) continue } @@ -916,21 +925,21 @@ func (e *Extractor) extractDirectory(dir *iso9660.File, destPath, isoPath string return nil } -func (e *Extractor) extractFile(file *iso9660.File, destPath, isoPath string) error { - reader := file.Reader() - +func (e *Extractor) extractFile(extents []*iso9660.File, destPath, isoPath string) error { outFile, err := os.Create(destPath) if err != nil { return fmt.Errorf("failed to create file: %w", err) } defer outFile.Close() - n, err := io.Copy(outFile, reader) - if err != nil { - os.Remove(destPath) - return fmt.Errorf("failed to copy file contents: %w", err) + for _, extent := range extents { + n, err := io.Copy(outFile, extent.Reader()) + if err != nil { + os.Remove(destPath) + return fmt.Errorf("failed to copy file contents: %w", err) + } + e.progress.AddBytes(n) } - e.progress.AddBytes(n) return nil } diff --git a/internal/extractor/live_integration_test.go b/internal/extractor/live_integration_test.go new file mode 100644 index 0000000..a7e8701 --- /dev/null +++ b/internal/extractor/live_integration_test.go @@ -0,0 +1,71 @@ +package extractor + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestExtractLiveISO(t *testing.T) { + isoPath := os.Getenv("BOOTIMUS_TEST_ISO") + if isoPath == "" { + t.Skip("BOOTIMUS_TEST_ISO not set") + } + + dataDir := os.Getenv("BOOTIMUS_TEST_DATADIR") + if dataDir == "" { + dataDir = t.TempDir() + } + + ext, err := New(dataDir) + if err != nil { + t.Fatal(err) + } + ext.SetProgress(NewProgressReporter()) + + files, err := ext.Extract(isoPath) + if err != nil { + t.Fatalf("Extract failed: %v", err) + } + + t.Logf("Kernel: %s", files.Kernel) + t.Logf("Initrd: %s", files.Initrd) + t.Logf("Distro: %s", files.Distro) + t.Logf("SquashfsPath: %s", files.SquashfsPath) + t.Logf("NetbootRequired: %v", files.NetbootRequired) + + if files.NetbootRequired { + t.Error("live ISO should not require netboot files") + } + + if !strings.Contains(strings.ToLower(filepath.Base(isoPath)), "live") { + return + } + + if files.SquashfsPath == "" { + t.Fatal("live ISO should have a squashfs path") + } + + squashfsOnDisk := filepath.Join(dataDir, relativeISOBase(dataDir, isoPath), filepath.FromSlash(files.SquashfsPath)) + info, err := os.Stat(squashfsOnDisk) + if err != nil { + t.Fatalf("squashfs not on disk at %s: %v", squashfsOnDisk, err) + } + t.Logf("squashfs size on disk: %d", info.Size()) + + kinfo, err := os.Stat(files.Kernel) + if err != nil { + t.Fatalf("kernel not on disk: %v", err) + } + if kinfo.Size() == 0 { + t.Error("extracted kernel is empty") + } + iinfo, err := os.Stat(files.Initrd) + if err != nil { + t.Fatalf("initrd not on disk: %v", err) + } + if iinfo.Size() == 0 { + t.Error("extracted initrd is empty") + } +} diff --git a/internal/models/models.go b/internal/models/models.go index cd03337..7ea7b66 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -184,6 +184,8 @@ type Image struct { InitrdPath string `json:"initrd_path,omitempty"` BootParams string `json:"boot_params,omitempty"` SquashfsPath string `json:"squashfs_path,omitempty"` + KernelOverride string `json:"kernel_override,omitempty"` + InitrdOverride string `json:"initrd_override,omitempty"` ExtractionError string `json:"extraction_error,omitempty"` ExtractedAt *time.Time `json:"extracted_at,omitempty"` SanbootCompatible bool `gorm:"default:true" json:"sanboot_compatible"` diff --git a/internal/server/menu.go b/internal/server/menu.go index 504e403..fc4230a 100644 --- a/internal/server/menu.go +++ b/internal/server/menu.go @@ -324,8 +324,18 @@ func (mb *MenuBuilder) buildKernelBootSection(img *models.Image, encodedFilename sb.WriteString("boot || goto failed\n") default: - sb.WriteString(fmt.Sprintf("kernel %s/boot/%s/vmlinuz%s%s\n", baseURL, cacheDir, autoInstallParam, bootParams)) - sb.WriteString(fmt.Sprintf("initrd %s/boot/%s/initrd\n", baseURL, cacheDir)) + kernelPath := "vmlinuz" + if img.KernelOverride != "" { + kernelPath = encodePathSegments(img.KernelOverride) + } + initrdPath := "initrd" + initrdName := "" + if img.InitrdOverride != "" { + initrdPath = encodePathSegments(img.InitrdOverride) + initrdName = " initrd" + } + sb.WriteString(fmt.Sprintf("kernel %s/boot/%s/%s%s%s\n", baseURL, cacheDir, kernelPath, autoInstallParam, bootParams)) + sb.WriteString(fmt.Sprintf("initrd %s/boot/%s/%s%s\n", baseURL, cacheDir, initrdPath, initrdName)) sb.WriteString("boot || goto failed\n") } diff --git a/internal/server/menu_override_test.go b/internal/server/menu_override_test.go new file mode 100644 index 0000000..b075413 --- /dev/null +++ b/internal/server/menu_override_test.go @@ -0,0 +1,59 @@ +package server + +import ( + "strings" + "testing" + + "bootimus/internal/models" +) + +func TestBuildKernelBootSectionDefault(t *testing.T) { + mb := &MenuBuilder{serverAddr: "10.0.0.1", httpPort: 8080} + img := models.Image{ + ID: 1, + Name: "Kali", + Filename: "kali-linux-2026.2-live-amd64.iso", + Enabled: true, + BootMethod: "kernel", + Distro: "kali", + BootParams: "initrd=initrd boot=live fetch={{SQUASHFS}}", + SquashfsPath: "iso/LIVE/filesystem.squashfs", + } + + section := mb.buildKernelBootSection(&img, "kali-linux-2026.2-live-amd64.iso", "kali-linux-2026.2-live-amd64") + + if !strings.Contains(section, "kernel http://10.0.0.1:8080/boot/kali-linux-2026.2-live-amd64/vmlinuz ") { + t.Errorf("expected default kernel path, got:\n%s", section) + } + if !strings.Contains(section, "initrd http://10.0.0.1:8080/boot/kali-linux-2026.2-live-amd64/initrd\n") { + t.Errorf("expected default initrd path, got:\n%s", section) + } + if !strings.Contains(section, "fetch=http://10.0.0.1:8080/boot/kali-linux-2026.2-live-amd64/iso/LIVE/filesystem.squashfs") { + t.Errorf("expected squashfs fetch URL, got:\n%s", section) + } +} + +func TestBuildKernelBootSectionOverrides(t *testing.T) { + mb := &MenuBuilder{serverAddr: "10.0.0.1", httpPort: 8080} + img := models.Image{ + ID: 1, + Name: "Kali", + Filename: "kali-linux-2026.2-live-amd64.iso", + Enabled: true, + BootMethod: "kernel", + Distro: "kali", + BootParams: "initrd=initrd boot=live fetch={{SQUASHFS}}", + SquashfsPath: "iso/LIVE/filesystem.squashfs", + KernelOverride: "iso/LIVE/vmlinuz-6.19.14+kali-amd64", + InitrdOverride: "iso/LIVE/initrd.img-6.19.14+kali-amd64", + } + + section := mb.buildKernelBootSection(&img, "kali-linux-2026.2-live-amd64.iso", "kali-linux-2026.2-live-amd64") + + if !strings.Contains(section, "kernel http://10.0.0.1:8080/boot/kali-linux-2026.2-live-amd64/iso/LIVE/vmlinuz-6.19.14+kali-amd64 ") { + t.Errorf("expected overridden kernel path, got:\n%s", section) + } + if !strings.Contains(section, "initrd http://10.0.0.1:8080/boot/kali-linux-2026.2-live-amd64/iso/LIVE/initrd.img-6.19.14+kali-amd64 initrd\n") { + t.Errorf("expected overridden initrd path with explicit initrd name, got:\n%s", section) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index ee96a8e..f5097e7 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1242,6 +1242,7 @@ func (s *Server) setupAdminInterface(mux *http.ServeMux) { mux.HandleFunc("/api/images/extract", adminWrap(adminHandler.ExtractImage)) mux.HandleFunc("/api/images/extract-progress", adminWrap(adminHandler.ExtractProgress)) mux.HandleFunc("/api/images/redetect", adminWrap(adminHandler.RedetectImage)) + mux.HandleFunc("/api/images/boot-candidates", adminWrap(adminHandler.BootFileCandidates)) mux.HandleFunc("/api/images/patch-smb", adminWrap(adminHandler.PatchImageSMB)) mux.HandleFunc("/api/autoinstall-files", adminWrap(adminHandler.ListAutoInstallFiles)) mux.HandleFunc("/api/autoinstall-files/get", adminWrap(adminHandler.GetAutoInstallFile)) diff --git a/internal/storage/clients.go b/internal/storage/clients.go new file mode 100644 index 0000000..d14107a --- /dev/null +++ b/internal/storage/clients.go @@ -0,0 +1,49 @@ +package storage + +import ( + "log" + + "bootimus/internal/models" + + "gorm.io/gorm" +) + +func hardDeleteClient(db *gorm.DB, client *models.Client) error { + if err := db.Exec("DELETE FROM client_images WHERE client_id = ?", client.ID).Error; err != nil { + return err + } + if err := db.Model(&models.BootLog{}).Where("client_id = ?", client.ID).Update("client_id", nil).Error; err != nil { + return err + } + if err := db.Model(&models.HardwareInventory{}).Where("client_id = ?", client.ID).Update("client_id", nil).Error; err != nil { + return err + } + return db.Unscoped().Delete(client).Error +} + +func deleteClientByMAC(db *gorm.DB, mac string) error { + var client models.Client + if err := db.Where("mac_address = ?", mac).First(&client).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil + } + return err + } + return hardDeleteClient(db, &client) +} + +func cleanupSoftDeletedClients(db *gorm.DB) error { + var clients []models.Client + if err := db.Unscoped().Where("deleted_at IS NOT NULL").Find(&clients).Error; err != nil { + return err + } + for i := range clients { + if err := hardDeleteClient(db, &clients[i]); err != nil { + return err + } + } + if len(clients) > 0 { + log.Printf("Cleaned up %d soft-deleted clients from database", len(clients)) + } + return nil +} diff --git a/internal/storage/clients_test.go b/internal/storage/clients_test.go new file mode 100644 index 0000000..e4d6122 --- /dev/null +++ b/internal/storage/clients_test.go @@ -0,0 +1,125 @@ +package storage + +import ( + "testing" + + "bootimus/internal/models" +) + +func newTestStore(t *testing.T) *SQLiteStore { + t.Helper() + store, err := NewSQLiteStore(t.TempDir()) + if err != nil { + t.Fatalf("NewSQLiteStore: %v", err) + } + if err := store.AutoMigrate(); err != nil { + t.Fatalf("AutoMigrate: %v", err) + } + return store +} + +func TestDeleteClientHardDeletesAndAllowsRecreate(t *testing.T) { + store := newTestStore(t) + mac := "aa:bb:cc:dd:ee:ff" + + if err := store.CreateClient(&models.Client{MACAddress: mac, Name: "first"}); err != nil { + t.Fatalf("CreateClient: %v", err) + } + if err := store.DeleteClient(mac); err != nil { + t.Fatalf("DeleteClient: %v", err) + } + + var count int64 + store.db.Unscoped().Model(&models.Client{}).Where("mac_address = ?", mac).Count(&count) + if count != 0 { + t.Fatalf("expected client row to be gone, found %d rows", count) + } + + if err := store.CreateClient(&models.Client{MACAddress: mac, Name: "second"}); err != nil { + t.Fatalf("CreateClient after delete: %v", err) + } + client, err := store.GetClient(mac) + if err != nil { + t.Fatalf("GetClient after recreate: %v", err) + } + if client.Name != "second" { + t.Fatalf("expected recreated client, got %q", client.Name) + } +} + +func TestDeleteClientMissingMACIsNoop(t *testing.T) { + store := newTestStore(t) + if err := store.DeleteClient("00:00:00:00:00:00"); err != nil { + t.Fatalf("DeleteClient on missing MAC: %v", err) + } +} + +func TestDeleteClientDetachesBootLogsAndInventory(t *testing.T) { + store := newTestStore(t) + mac := "aa:bb:cc:dd:ee:01" + + if err := store.CreateClient(&models.Client{MACAddress: mac}); err != nil { + t.Fatalf("CreateClient: %v", err) + } + client, err := store.GetClient(mac) + if err != nil { + t.Fatalf("GetClient: %v", err) + } + if err := store.db.Create(&models.BootLog{ClientID: &client.ID, MACAddress: mac}).Error; err != nil { + t.Fatalf("create boot log: %v", err) + } + if err := store.db.Create(&models.HardwareInventory{ClientID: &client.ID, MACAddress: mac}).Error; err != nil { + t.Fatalf("create inventory: %v", err) + } + + if err := store.DeleteClient(mac); err != nil { + t.Fatalf("DeleteClient: %v", err) + } + + var bootLog models.BootLog + if err := store.db.Where("mac_address = ?", mac).First(&bootLog).Error; err != nil { + t.Fatalf("boot log should survive client deletion: %v", err) + } + if bootLog.ClientID != nil { + t.Fatalf("expected boot log client_id to be nulled, got %d", *bootLog.ClientID) + } + + var inv models.HardwareInventory + if err := store.db.Where("mac_address = ?", mac).First(&inv).Error; err != nil { + t.Fatalf("inventory should survive client deletion: %v", err) + } + if inv.ClientID != nil { + t.Fatalf("expected inventory client_id to be nulled, got %d", *inv.ClientID) + } +} + +func TestAutoMigratePurgesLegacySoftDeletedClients(t *testing.T) { + store := newTestStore(t) + mac := "aa:bb:cc:dd:ee:02" + + if err := store.CreateClient(&models.Client{MACAddress: mac}); err != nil { + t.Fatalf("CreateClient: %v", err) + } + if err := store.db.Where("mac_address = ?", mac).Delete(&models.Client{}).Error; err != nil { + t.Fatalf("soft delete: %v", err) + } + + var count int64 + store.db.Unscoped().Model(&models.Client{}).Where("mac_address = ?", mac).Count(&count) + if count != 1 { + t.Fatalf("expected soft-deleted row to exist before migration, found %d", count) + } + + if err := store.AutoMigrate(); err != nil { + t.Fatalf("AutoMigrate: %v", err) + } + + store.db.Unscoped().Model(&models.Client{}).Where("mac_address = ?", mac).Count(&count) + if count != 0 { + t.Fatalf("expected soft-deleted row to be purged, found %d", count) + } + + if err := store.CreateClient(&models.Client{MACAddress: mac}); err != nil { + t.Fatalf("CreateClient after purge: %v", err) + } +} diff --git a/internal/storage/postgres.go b/internal/storage/postgres.go index 964360d..eb26432 100644 --- a/internal/storage/postgres.go +++ b/internal/storage/postgres.go @@ -107,6 +107,10 @@ func (s *PostgresStore) AutoMigrate() error { log.Printf("Warning: Failed to cleanup soft-deleted files: %v", err) } + if err := cleanupSoftDeletedClients(s.db); err != nil { + log.Printf("Warning: Failed to cleanup soft-deleted clients: %v", err) + } + return nil } @@ -236,7 +240,7 @@ func (s *PostgresStore) UpdateClient(mac string, client *models.Client) error { } func (s *PostgresStore) DeleteClient(mac string) error { - return s.db.Where("mac_address = ?", mac).Delete(&models.Client{}).Error + return deleteClientByMAC(s.db, mac) } func (s *PostgresStore) ListImages() ([]*models.Image, error) { @@ -776,26 +780,15 @@ func (s *PostgresStore) SaveHardwareInventory(inv *models.HardwareInventory) err if err := s.db.Where("mac_address = ?", inv.MACAddress).First(&client).Error; err == nil { inv.ClientID = &client.ID } else { - var deleted models.Client - if err := s.db.Unscoped().Where("mac_address = ? AND deleted_at IS NOT NULL", inv.MACAddress).First(&deleted).Error; err == nil { - deleted.DeletedAt = gorm.DeletedAt{} - deleted.Enabled = true - deleted.ShowPublicImages = true - deleted.Static = false - s.db.Unscoped().Save(&deleted) - inv.ClientID = &deleted.ID - log.Printf("Storage: Restored soft-deleted client for MAC %s", inv.MACAddress) - } else { - client = models.Client{ - MACAddress: inv.MACAddress, - Enabled: true, - ShowPublicImages: true, - Static: false, - } - if err := s.db.Create(&client).Error; err == nil { - inv.ClientID = &client.ID - log.Printf("Storage: Auto-created dynamic client for MAC %s", inv.MACAddress) - } + client = models.Client{ + MACAddress: inv.MACAddress, + Enabled: true, + ShowPublicImages: true, + Static: false, + } + if err := s.db.Create(&client).Error; err == nil { + inv.ClientID = &client.ID + log.Printf("Storage: Auto-created dynamic client for MAC %s", inv.MACAddress) } } } diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index b69f76a..ccbba53 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -42,6 +42,10 @@ func (s *SQLiteStore) AutoMigrate() error { return fmt.Errorf("failed to cleanup soft-deleted files: %w", err) } + if err := cleanupSoftDeletedClients(s.db); err != nil { + return fmt.Errorf("failed to cleanup soft-deleted clients: %w", err) + } + return nil } @@ -84,7 +88,7 @@ func (s *SQLiteStore) UpdateClient(mac string, client *models.Client) error { } func (s *SQLiteStore) DeleteClient(mac string) error { - return s.db.Where("mac_address = ?", mac).Delete(&models.Client{}).Error + return deleteClientByMAC(s.db, mac) } func (s *SQLiteStore) ListImages() ([]*models.Image, error) { @@ -158,26 +162,15 @@ func (s *SQLiteStore) SaveHardwareInventory(inv *models.HardwareInventory) error if err := s.db.Where("mac_address = ?", inv.MACAddress).First(&client).Error; err == nil { inv.ClientID = &client.ID } else { - var deleted models.Client - if err := s.db.Unscoped().Where("mac_address = ? AND deleted_at IS NOT NULL", inv.MACAddress).First(&deleted).Error; err == nil { - deleted.DeletedAt = gorm.DeletedAt{} - deleted.Enabled = true - deleted.ShowPublicImages = true - deleted.Static = false - s.db.Unscoped().Save(&deleted) - inv.ClientID = &deleted.ID - log.Printf("Storage: Restored soft-deleted client for MAC %s", inv.MACAddress) - } else { - client = models.Client{ - MACAddress: inv.MACAddress, - Enabled: true, - ShowPublicImages: true, - Static: false, - } - if err := s.db.Create(&client).Error; err == nil { - inv.ClientID = &client.ID - log.Printf("Storage: Auto-created dynamic client for MAC %s", inv.MACAddress) - } + client = models.Client{ + MACAddress: inv.MACAddress, + Enabled: true, + ShowPublicImages: true, + Static: false, + } + if err := s.db.Create(&client).Error; err == nil { + inv.ClientID = &client.ID + log.Printf("Storage: Auto-created dynamic client for MAC %s", inv.MACAddress) } } } diff --git a/web/static/app.js b/web/static/app.js index dcafc3c..c1ebec9 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -2769,13 +2769,14 @@ const API_REFERENCE = [ ]}, { category: 'Images', endpoints: [ { method: 'GET', path: '/api/images', desc: 'List all images. Add ?filename={fn} for one.' }, - { method: 'PUT', path: '/api/images?filename={fn}', desc: 'Partial update. Fields: name, description, enabled, public, group_id, order, boot_method, distro, boot_params, auto_install_file.' }, + { method: 'PUT', path: '/api/images?filename={fn}', desc: 'Partial update. Fields: name, description, enabled, public, group_id, order, boot_method, distro, boot_params, auto_install_file, kernel_override, initrd_override.' }, { method: 'DELETE', path: '/api/images?filename={fn}', desc: 'Delete image. Add &delete_file=true to also remove the ISO.' }, { method: 'POST', path: '/api/images/upload', desc: 'Multipart: file, public, description.' }, { method: 'POST', path: '/api/images/download', desc: 'Body: {url, filename, description}. filename is optional. Async download.' }, { method: 'POST', path: '/api/images/extract?filename={fn}', desc: 'Extract kernel/initrd from ISO.' }, { method: 'GET', path: '/api/images/extract-progress?filename={fn}', desc: 'Extraction progress.' }, { method: 'POST', path: '/api/images/redetect?filename={fn}', desc: 'Re-run distro detection and boot-param resolution.' }, + { method: 'GET', path: '/api/images/boot-candidates?filename={fn}', desc: 'List kernel/initrd files found in the extracted ISO for override selection.' }, { method: 'POST', path: '/api/images/patch-smb?filename={fn}', desc: 'Patch boot.wim for Windows SMB install.' }, { method: 'POST', path: '/api/images/boot-method?filename={fn}', desc: 'Body: {method} (sanboot/kernel/nbd/nfs).' }, { method: 'POST', path: '/api/images/netboot/download?filename={fn}', desc: 'Fetch netboot kernel/initrd from distro mirror.' }, @@ -5147,6 +5148,8 @@ async function showImagePropertiesModal(filename, opts) { document.getElementById('image-props-enabled').checked = img.enabled; document.getElementById('image-props-public').checked = img.public; + await populateBootFileOverrides(img); + applyBootParamsWindowsLock(distroSelect.value); distroSelect.onchange = () => applyBootParamsWindowsLock(distroSelect.value); @@ -5210,6 +5213,40 @@ async function showImagePropertiesModal(filename, opts) { let _imagePropsState = null; +async function populateBootFileOverrides(img) { + const row = document.getElementById('image-props-bootfiles-row'); + const kernelSelect = document.getElementById('image-props-kernel-override'); + const initrdSelect = document.getElementById('image-props-initrd-override'); + const autoOption = ``; + kernelSelect.innerHTML = autoOption; + initrdSelect.innerHTML = autoOption; + + if (!img.extracted || img.distro === 'windows') { + row.style.display = 'none'; + return; + } + + try { + const res = await authFetch(`${API_BASE}/images/boot-candidates?filename=${encodeURIComponent(img.filename)}`); + const data = await res.json(); + if (!data.success || !data.data) throw new Error(data.error || 'failed'); + + const addOptions = (select, candidates, current) => { + for (const c of candidates || []) { + const selected = current === c.path ? 'selected' : ''; + select.innerHTML += ``; + } + }; + addOptions(kernelSelect, data.data.kernels, data.data.kernel_override); + addOptions(initrdSelect, data.data.initrds, data.data.initrd_override); + + const hasCandidates = (data.data.kernels || []).length > 0 || (data.data.initrds || []).length > 0; + row.style.display = hasCandidates ? '' : 'none'; + } catch (e) { + row.style.display = 'none'; + } +} + function updateImagePropsWarnings() { if (!_imagePropsState) return; const img = _imagePropsState.img; @@ -5512,6 +5549,12 @@ async function saveImageProperties(opts) { auto_install_file: autoInstallFile, }; + const bootFilesRow = document.getElementById('image-props-bootfiles-row'); + if (bootFilesRow && bootFilesRow.style.display !== 'none') { + updates.kernel_override = document.getElementById('image-props-kernel-override').value; + updates.initrd_override = document.getElementById('image-props-initrd-override').value; + } + try { // Update general image properties const res = await authFetch(`${API_BASE}/images?filename=${encodeURIComponent(filename)}`, { diff --git a/web/static/i18n.js b/web/static/i18n.js index 158001e..0162572 100644 --- a/web/static/i18n.js +++ b/web/static/i18n.js @@ -126,6 +126,10 @@ const TRANSLATIONS = { 'props.field.boot_params': 'Boot Parameters', 'props.field.boot_params_placeholder': 'Optional kernel parameters', 'props.field.boot_params_hint': 'Leave empty for distro defaults.', + 'props.field.kernel_file': 'Kernel File', + 'props.field.initrd_file': 'Initrd File', + 'props.field.bootfile_auto': 'Auto-detected', + 'props.field.bootfile_hint': 'Serve a specific kernel/initrd from the extracted ISO instead of the auto-detected ones.', 'props.field.placeholders_label': 'Placeholders:', 'props.field.public': 'Public (available to all clients)', 'props.field.default_autoinstall': 'Default Auto-Install File', @@ -291,6 +295,10 @@ const TRANSLATIONS = { 'props.field.boot_params': 'Boot-Parameter', 'props.field.boot_params_placeholder': 'Optionale Kernel-Parameter', 'props.field.boot_params_hint': 'Leer lassen für Distro-Standardwerte.', + 'props.field.kernel_file': 'Kernel-Datei', + 'props.field.initrd_file': 'Initrd-Datei', + 'props.field.bootfile_auto': 'Automatisch erkannt', + 'props.field.bootfile_hint': 'Einen bestimmten Kernel/Initrd aus dem entpackten ISO ausliefern statt der automatisch erkannten Dateien.', 'props.field.placeholders_label': 'Platzhalter:', 'props.field.public': 'Öffentlich (für alle Clients verfügbar)', 'props.field.default_autoinstall': 'Standard-Auto-Installations-Datei', @@ -456,6 +464,10 @@ const TRANSLATIONS = { 'props.field.boot_params': 'Paramètres de démarrage', 'props.field.boot_params_placeholder': 'Paramètres noyau optionnels', 'props.field.boot_params_hint': 'Laissez vide pour utiliser les valeurs par défaut de la distribution.', + 'props.field.kernel_file': 'Fichier noyau', + 'props.field.initrd_file': 'Fichier initrd', + 'props.field.bootfile_auto': 'Détection automatique', + 'props.field.bootfile_hint': 'Servir un noyau/initrd spécifique depuis l\'ISO extrait au lieu de ceux détectés automatiquement.', 'props.field.placeholders_label': 'Espaces réservés :', 'props.field.public': 'Public (disponible pour tous les clients)', 'props.field.default_autoinstall': "Fichier d'installation auto par défaut", @@ -625,6 +637,10 @@ const TRANSLATIONS = { 'props.field.boot_params': 'Параметры загрузки', 'props.field.boot_params_placeholder': 'Дополнительные параметры ядра', 'props.field.boot_params_hint': 'Оставьте пустым, чтобы использовать значения дистрибутива по умолчанию.', + 'props.field.kernel_file': 'Файл ядра', + 'props.field.initrd_file': 'Файл initrd', + 'props.field.bootfile_auto': 'Автоопределение', + 'props.field.bootfile_hint': 'Использовать определённые файлы ядра/initrd из извлечённого ISO вместо автоматически определённых.', 'props.field.placeholders_label': 'Подстановки:', 'props.field.public': 'Общий (доступен всем клиентам)', 'props.field.default_autoinstall': 'Файл автоустановки по умолчанию', @@ -790,6 +806,10 @@ const TRANSLATIONS = { 'props.field.boot_params': '引导参数', 'props.field.boot_params_placeholder': '额外的内核参数', 'props.field.boot_params_hint': '留空以使用发行版默认值。', + 'props.field.kernel_file': '内核文件', + 'props.field.initrd_file': 'Initrd 文件', + 'props.field.bootfile_auto': '自动检测', + 'props.field.bootfile_hint': '从解压的 ISO 中提供指定的内核/initrd,而不是自动检测的文件。', 'props.field.placeholders_label': '占位符:', 'props.field.public': '公开(所有客户端可见)', 'props.field.default_autoinstall': '默认自动安装文件', diff --git a/web/static/index.html b/web/static/index.html index f6dfb04..a7ead52 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -1540,6 +1540,24 @@

Leave empty for distro defaults. Placeholders: {{BASE_URL}} {{CACHE_DIR}} {{FILENAME}} {{SQUASHFS}} + +
From ede31a58bf36fbdca1836f894f3522d94d34efe0 Mon Sep 17 00:00:00 2001 From: Gary Bowers Date: Fri, 31 Jul 2026 08:21:48 +0100 Subject: [PATCH 2/4] ci pipeline for non main --- .github/workflows/ci.yml | 33 +++++++++++++++++++++++++++++++ internal/profiles/manager_test.go | 4 ++-- 2 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d9cfce5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +# Runs on every push to a non-main branch. Tag pushes are handled by +# release.yml; main only receives merges that have already passed here. + +on: + push: + branches-ignore: + - main + +permissions: + contents: read + +jobs: + test: + name: Vet, test & build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Vet + run: go vet ./... + + - name: Test + run: go test ./... + + - name: Build + run: make build diff --git a/internal/profiles/manager_test.go b/internal/profiles/manager_test.go index 8bdd158..90b8c2b 100644 --- a/internal/profiles/manager_test.go +++ b/internal/profiles/manager_test.go @@ -24,8 +24,8 @@ func TestMatchProfile_RealISOFilenames(t *testing.T) { {"cachyos-desktop-linux-250101.iso", "arch"}, {"manjaro-kde-24.0.0-240416-linux69.iso", "manjaro"}, {"Fedora-Workstation-Live-x86_64-41-1.4.iso", "fedora"}, - {"Rocky-9.4-x86_64-minimal.iso", "centos"}, - {"AlmaLinux-9.4-x86_64-minimal.iso", "centos"}, + {"Rocky-9.4-x86_64-minimal.iso", "rocky"}, + {"AlmaLinux-9.4-x86_64-minimal.iso", "alma"}, {"openSUSE-Leap-15.6-DVD-x86_64-Media.iso", "opensuse"}, {"alpine-standard-3.20.3-x86_64.iso", "alpine"}, {"kali-linux-2024.3-installer-amd64.iso", "kali"}, From 589339e7e4f4baceb30aeb89cff9ffddfb39676a Mon Sep 17 00:00:00 2001 From: Gary Bowers Date: Fri, 31 Jul 2026 08:33:45 +0100 Subject: [PATCH 3/4] Adding CI tests --- .github/workflows/ci.yml | 27 ++- internal/extractor/extract_synthetic_test.go | 166 +++++++++++++++++++ 2 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 internal/extractor/extract_synthetic_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9cfce5..989afd3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,11 +1,15 @@ name: CI -# Runs on every push to a non-main branch. Tag pushes are handled by -# release.yml; main only receives merges that have already passed here. +# Runs on every branch push and on pull requests into main (which test the +# merged result). Tag pushes are handled by release.yml, which stamps the +# version and pushes the container image. on: push: - branches-ignore: + branches: + - "**" + pull_request: + branches: - main permissions: @@ -31,3 +35,20 @@ jobs: - name: Build run: make build + + docker: + name: Docker build (no push) + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Build image + uses: docker/build-push-action@v6 + with: + context: . + push: false + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/internal/extractor/extract_synthetic_test.go b/internal/extractor/extract_synthetic_test.go new file mode 100644 index 0000000..156ae30 --- /dev/null +++ b/internal/extractor/extract_synthetic_test.go @@ -0,0 +1,166 @@ +package extractor + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/kdomanski/iso9660" +) + +func writeTestISO(t *testing.T, isoPath string, files map[string]string) { + t.Helper() + + w, err := iso9660.NewWriter() + if err != nil { + t.Fatal(err) + } + defer w.Cleanup() + + for path, content := range files { + if err := w.AddFile(bytes.NewReader([]byte(content)), path); err != nil { + t.Fatalf("AddFile %s: %v", path, err) + } + } + + f, err := os.Create(isoPath) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + if err := w.WriteTo(f, "TESTISO"); err != nil { + t.Fatal(err) + } +} + +func extractTestISO(t *testing.T, isoName string, files map[string]string) (*BootFiles, string) { + t.Helper() + + dataDir := t.TempDir() + isoPath := filepath.Join(dataDir, isoName) + writeTestISO(t, isoPath, files) + + ext, err := New(dataDir) + if err != nil { + t.Fatal(err) + } + ext.SetProgress(NewProgressReporter()) + + bootFiles, err := ext.Extract(isoPath) + if err != nil { + t.Fatalf("Extract failed: %v", err) + } + return bootFiles, dataDir +} + +func readFileString(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(data) +} + +func TestExtractKaliLiveLayout(t *testing.T) { + files := map[string]string{ + "/live/vmlinuz": "live-kernel", + "/live/initrd.img": "live-initrd", + "/live/filesystem.squashfs": "live-squashfs", + "/install.amd/vmlinuz": "installer-kernel", + "/install.amd/initrd.gz": "installer-initrd", + "/.disk/info": "Kali GNU/Linux Rolling", + "/isolinux/isolinux.cfg": "default live", + } + + bootFiles, dataDir := extractTestISO(t, "kali-linux-2099.1-live-amd64.iso", files) + + if bootFiles.Distro != "kali" { + t.Errorf("distro = %q, want kali", bootFiles.Distro) + } + if bootFiles.NetbootRequired { + t.Error("live image must not require netboot files") + } + if got := readFileString(t, bootFiles.Kernel); got != "live-kernel" { + t.Errorf("extracted kernel content = %q, want live kernel (installer kernel won)", got) + } + if got := readFileString(t, bootFiles.Initrd); got != "live-initrd" { + t.Errorf("extracted initrd content = %q, want live initrd (installer initrd won)", got) + } + if !strings.EqualFold(bootFiles.SquashfsPath, "iso/live/filesystem.squashfs") { + t.Errorf("squashfs path = %q, want iso/live/filesystem.squashfs", bootFiles.SquashfsPath) + } + + cacheDir := filepath.Join(dataDir, "kali-linux-2099.1-live-amd64") + squashfsOnDisk := filepath.Join(cacheDir, filepath.FromSlash(bootFiles.SquashfsPath)) + if got := readFileString(t, squashfsOnDisk); got != "live-squashfs" { + t.Errorf("squashfs content = %q, want live-squashfs", got) + } +} + +func TestExtractDebianNetinstLayout(t *testing.T) { + files := map[string]string{ + "/install.amd/vmlinuz": "installer-kernel", + "/install.amd/initrd.gz": "installer-initrd", + "/.disk/info": "Debian GNU/Linux 13", + } + + bootFiles, _ := extractTestISO(t, "debian-13.0.0-amd64-netinst.iso", files) + + if bootFiles.Distro != "debian" { + t.Errorf("distro = %q, want debian", bootFiles.Distro) + } + if !bootFiles.NetbootRequired { + t.Error("installer-only image should require netboot files") + } + if got := readFileString(t, bootFiles.Kernel); got != "installer-kernel" { + t.Errorf("extracted kernel content = %q, want installer kernel", got) + } + if bootFiles.SquashfsPath != "" { + t.Errorf("squashfs path = %q, want empty", bootFiles.SquashfsPath) + } +} + +func TestExtractUbuntuCasperLayout(t *testing.T) { + files := map[string]string{ + "/casper/vmlinuz": "casper-kernel", + "/casper/initrd": "casper-initrd", + "/casper/filesystem.squashfs": "casper-squashfs", + "/.disk/info": "Ubuntu 26.04 LTS", + } + + bootFiles, _ := extractTestISO(t, "ubuntu-26.04-desktop-amd64.iso", files) + + if bootFiles.Distro != "ubuntu" { + t.Errorf("distro = %q, want ubuntu", bootFiles.Distro) + } + if bootFiles.NetbootRequired { + t.Error("casper live image must not require netboot files") + } + if got := readFileString(t, bootFiles.Kernel); got != "casper-kernel" { + t.Errorf("extracted kernel content = %q, want casper kernel", got) + } + if !strings.EqualFold(bootFiles.SquashfsPath, "iso/casper/filesystem.squashfs") { + t.Errorf("squashfs path = %q, want iso/casper/filesystem.squashfs", bootFiles.SquashfsPath) + } +} + +func TestExtractFedoraLayout(t *testing.T) { + files := map[string]string{ + "/images/pxeboot/vmlinuz": "fedora-kernel", + "/images/pxeboot/initrd.img": "fedora-initrd", + "/.disk/info": "Fedora 43", + } + + bootFiles, _ := extractTestISO(t, "Fedora-Workstation-Live-x86_64-43.iso", files) + + if bootFiles.Distro != "fedora" { + t.Errorf("distro = %q, want fedora", bootFiles.Distro) + } + if got := readFileString(t, bootFiles.Kernel); got != "fedora-kernel" { + t.Errorf("extracted kernel content = %q, want fedora kernel", got) + } +} From 6a9a74fb4e5e87c1646e1efd25e2a17288a7e57f Mon Sep 17 00:00:00 2001 From: Gary Bowers Date: Fri, 31 Jul 2026 08:35:21 +0100 Subject: [PATCH 4/4] Adding CI tests --- .github/workflows/ci.yml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 989afd3..218f3d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,16 +1,15 @@ name: CI -# Runs on every branch push and on pull requests into main (which test the -# merged result). Tag pushes are handled by release.yml, which stamps the -# version and pushes the container image. +# Runs once per change: pull requests test the result of merging into the +# target branch; pushes to main verify the merge itself (and build the +# container image). Branches without an open PR don't run CI. Tag pushes are +# handled by release.yml, which stamps the version and pushes the image. on: push: - branches: - - "**" - pull_request: branches: - main + pull_request: permissions: contents: read