From 83cc3aee153fcbd2759d6a5d1e9a685f0362a5a8 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Thu, 16 Apr 2026 14:21:10 -0300 Subject: [PATCH] Phase 1: local kernel catalog scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a read/write kernel catalog on disk without any network dependency, so later phases (image register --kernel-ref, import, pull) can build on a working foundation. Layout: adds KernelsDir to paths.Layout, ensured under ~/.local/state/banger/kernels/. Each cataloged kernel lives at // with a manifest.json alongside vmlinux and optional initrd.img / modules/. New internal/kernelcat package owns the disk format: - Entry (Name, Distro, Arch, KernelVersion, SHA256, Source, ImportedAt) - ValidateName (alphanumeric + dots/hyphens/underscores, no traversal) - ReadLocal / ListLocal / WriteLocal / DeleteLocal - SumFile helper The daemon exposes three RPC methods dispatched in daemon.go: kernel.list, kernel.show, kernel.delete. Implementations live in a new internal/daemon/kernels.go and are thin wrappers over kernelcat using d.layout.KernelsDir. CLI: new top-level `banger kernel` with list / show / rm subcommands mirroring the image-command pattern (ensureDaemon, RPC call, table or JSON output). No sudo required — kernel ops are user-space only. Users can now manually populate ~/.local/state/banger/kernels// and see it via `banger kernel list`. Phase 2 wires --kernel-ref into image register; Phase 3 adds `banger kernel import`; Phase 4 adds remote pulls. Co-Authored-By: Claude Sonnet 4.6 --- internal/api/types.go | 25 ++++ internal/cli/banger.go | 102 ++++++++++++++- internal/cli/cli_test.go | 26 +++- internal/daemon/daemon.go | 16 +++ internal/daemon/kernels.go | 67 ++++++++++ internal/daemon/kernels_test.go | 99 ++++++++++++++ internal/kernelcat/kernelcat.go | 184 +++++++++++++++++++++++++++ internal/kernelcat/kernelcat_test.go | 171 +++++++++++++++++++++++++ internal/paths/paths.go | 4 +- 9 files changed, 691 insertions(+), 3 deletions(-) create mode 100644 internal/daemon/kernels.go create mode 100644 internal/daemon/kernels_test.go create mode 100644 internal/kernelcat/kernelcat.go create mode 100644 internal/kernelcat/kernelcat_test.go diff --git a/internal/api/types.go b/internal/api/types.go index e1c89d8..8d6c1aa 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -274,6 +274,31 @@ type ImageShowResult struct { Image model.Image `json:"image"` } +type KernelEntry struct { + Name string `json:"name"` + Distro string `json:"distro,omitempty"` + Arch string `json:"arch,omitempty"` + KernelVersion string `json:"kernel_version,omitempty"` + SHA256 string `json:"sha256,omitempty"` + Source string `json:"source,omitempty"` + ImportedAt string `json:"imported_at,omitempty"` + KernelPath string `json:"kernel_path,omitempty"` + InitrdPath string `json:"initrd_path,omitempty"` + ModulesDir string `json:"modules_dir,omitempty"` +} + +type KernelListResult struct { + Entries []KernelEntry `json:"entries"` +} + +type KernelRefParams struct { + Name string `json:"name"` +} + +type KernelShowResult struct { + Entry KernelEntry `json:"entry"` +} + type SudoStatus struct { Available bool `json:"available"` Command string `json:"command,omitempty"` diff --git a/internal/cli/banger.go b/internal/cli/banger.go index 996041f..7f93a2d 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -163,7 +163,7 @@ func NewBangerCommand() *cobra.Command { RunE: helpNoArgs, } root.CompletionOptions.DisableDefaultCmd = true - root.AddCommand(newDaemonCommand(), newDoctorCommand(), newImageCommand(), newInternalCommand(), newVersionCommand(), newPSCommand(), newVMCommand()) + root.AddCommand(newDaemonCommand(), newDoctorCommand(), newImageCommand(), newInternalCommand(), newKernelCommand(), newVersionCommand(), newPSCommand(), newVMCommand()) return root } @@ -1585,6 +1585,106 @@ func newImageDeleteCommand() *cobra.Command { } } +func newKernelCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "kernel", + Short: "Manage the local kernel catalog", + RunE: helpNoArgs, + } + cmd.AddCommand( + newKernelListCommand(), + newKernelShowCommand(), + newKernelRmCommand(), + ) + return cmd +} + +func newKernelListCommand() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List locally available kernels", + Args: noArgsUsage("usage: banger kernel list"), + RunE: func(cmd *cobra.Command, args []string) error { + layout, _, err := ensureDaemon(cmd.Context()) + if err != nil { + return err + } + result, err := rpc.Call[api.KernelListResult](cmd.Context(), layout.SocketPath, "kernel.list", api.Empty{}) + if err != nil { + return err + } + return printKernelListTable(cmd.OutOrStdout(), result.Entries) + }, + } +} + +func newKernelShowCommand() *cobra.Command { + return &cobra.Command{ + Use: "show ", + Short: "Show kernel catalog entry details", + Args: exactArgsUsage(1, "usage: banger kernel show "), + RunE: func(cmd *cobra.Command, args []string) error { + layout, _, err := ensureDaemon(cmd.Context()) + if err != nil { + return err + } + result, err := rpc.Call[api.KernelShowResult](cmd.Context(), layout.SocketPath, "kernel.show", api.KernelRefParams{Name: args[0]}) + if err != nil { + return err + } + return printJSON(cmd.OutOrStdout(), result.Entry) + }, + } +} + +func newKernelRmCommand() *cobra.Command { + return &cobra.Command{ + Use: "rm ", + Aliases: []string{"remove", "delete"}, + Short: "Remove a kernel catalog entry", + Args: exactArgsUsage(1, "usage: banger kernel rm "), + RunE: func(cmd *cobra.Command, args []string) error { + layout, _, err := ensureDaemon(cmd.Context()) + if err != nil { + return err + } + if _, err := rpc.Call[api.Empty](cmd.Context(), layout.SocketPath, "kernel.delete", api.KernelRefParams{Name: args[0]}); err != nil { + return err + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "removed %s\n", args[0]) + return err + }, + } +} + +func printKernelListTable(out anyWriter, entries []api.KernelEntry) error { + w := tabwriter.NewWriter(out, 0, 8, 2, ' ', 0) + if _, err := fmt.Fprintln(w, "NAME\tDISTRO\tARCH\tKERNEL\tIMPORTED"); err != nil { + return err + } + for _, entry := range entries { + if _, err := fmt.Fprintf( + w, + "%s\t%s\t%s\t%s\t%s\n", + entry.Name, + dashIfEmpty(entry.Distro), + dashIfEmpty(entry.Arch), + dashIfEmpty(entry.KernelVersion), + dashIfEmpty(entry.ImportedAt), + ); err != nil { + return err + } + } + return w.Flush() +} + +func dashIfEmpty(s string) string { + if strings.TrimSpace(s) == "" { + return "-" + } + return s +} + func helpNoArgs(cmd *cobra.Command, args []string) error { if len(args) != 0 { return fmt.Errorf("unknown arguments: %s", strings.Join(args, " ")) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 2fa6efc..6a53b07 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -19,6 +19,8 @@ import ( "banger/internal/model" "banger/internal/system" "banger/internal/toolingplan" + + "github.com/spf13/cobra" ) func TestNewBangerCommandHasExpectedSubcommands(t *testing.T) { @@ -27,7 +29,7 @@ func TestNewBangerCommandHasExpectedSubcommands(t *testing.T) { for _, sub := range cmd.Commands() { names = append(names, sub.Name()) } - want := []string{"daemon", "doctor", "image", "internal", "ps", "version", "vm"} + want := []string{"daemon", "doctor", "image", "internal", "kernel", "ps", "version", "vm"} if !reflect.DeepEqual(names, want) { t.Fatalf("subcommands = %v, want %v", names, want) } @@ -57,6 +59,28 @@ func TestVersionCommandPrintsBuildInfo(t *testing.T) { } } +func TestKernelCommandExposesSubcommands(t *testing.T) { + cmd := NewBangerCommand() + var kernel *cobra.Command + for _, sub := range cmd.Commands() { + if sub.Name() == "kernel" { + kernel = sub + break + } + } + if kernel == nil { + t.Fatalf("kernel command missing from root") + } + names := []string{} + for _, sub := range kernel.Commands() { + names = append(names, sub.Name()) + } + want := []string{"list", "rm", "show"} + if !reflect.DeepEqual(names, want) { + t.Fatalf("kernel subcommands = %v, want %v", names, want) + } +} + func TestLegacyRemovedCommandIsRejected(t *testing.T) { cmd := NewBangerCommand() cmd.SetArgs([]string{"tui"}) diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 14cd19e..5c6ca6f 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -527,6 +527,22 @@ func (d *Daemon) dispatch(ctx context.Context, req rpc.Request) rpc.Response { } image, err := d.DeleteImage(ctx, params.IDOrName) return marshalResultOrError(api.ImageShowResult{Image: image}, err) + case "kernel.list": + return marshalResultOrError(d.KernelList(ctx)) + case "kernel.show": + params, err := rpc.DecodeParams[api.KernelRefParams](req) + if err != nil { + return rpc.NewError("bad_request", err.Error()) + } + entry, err := d.KernelShow(ctx, params.Name) + return marshalResultOrError(api.KernelShowResult{Entry: entry}, err) + case "kernel.delete": + params, err := rpc.DecodeParams[api.KernelRefParams](req) + if err != nil { + return rpc.NewError("bad_request", err.Error()) + } + err = d.KernelDelete(ctx, params.Name) + return marshalResultOrError(api.Empty{}, err) default: return rpc.NewError("unknown_method", req.Method) } diff --git a/internal/daemon/kernels.go b/internal/daemon/kernels.go new file mode 100644 index 0000000..1436fd0 --- /dev/null +++ b/internal/daemon/kernels.go @@ -0,0 +1,67 @@ +package daemon + +import ( + "context" + "fmt" + "os" + "time" + + "banger/internal/api" + "banger/internal/kernelcat" +) + +func (d *Daemon) KernelList(_ context.Context) (api.KernelListResult, error) { + entries, err := kernelcat.ListLocal(d.layout.KernelsDir) + if err != nil { + return api.KernelListResult{}, err + } + result := api.KernelListResult{Entries: make([]api.KernelEntry, 0, len(entries))} + for _, entry := range entries { + result.Entries = append(result.Entries, kernelEntryToAPI(entry)) + } + return result, nil +} + +func (d *Daemon) KernelShow(_ context.Context, name string) (api.KernelEntry, error) { + entry, err := kernelcat.ReadLocal(d.layout.KernelsDir, name) + if err != nil { + return api.KernelEntry{}, kernelNotFoundIfMissing(name, err) + } + return kernelEntryToAPI(entry), nil +} + +func (d *Daemon) KernelDelete(_ context.Context, name string) error { + if err := kernelcat.ValidateName(name); err != nil { + return err + } + return kernelcat.DeleteLocal(d.layout.KernelsDir, name) +} + +func kernelEntryToAPI(entry kernelcat.Entry) api.KernelEntry { + importedAt := "" + if !entry.ImportedAt.IsZero() { + importedAt = entry.ImportedAt.UTC().Format(time.RFC3339) + } + return api.KernelEntry{ + Name: entry.Name, + Distro: entry.Distro, + Arch: entry.Arch, + KernelVersion: entry.KernelVersion, + SHA256: entry.SHA256, + Source: entry.Source, + ImportedAt: importedAt, + KernelPath: entry.KernelPath, + InitrdPath: entry.InitrdPath, + ModulesDir: entry.ModulesDir, + } +} + +func kernelNotFoundIfMissing(name string, err error) error { + if err == nil { + return nil + } + if os.IsNotExist(err) { + return fmt.Errorf("kernel %q not found", name) + } + return err +} diff --git a/internal/daemon/kernels_test.go b/internal/daemon/kernels_test.go new file mode 100644 index 0000000..2ab9f03 --- /dev/null +++ b/internal/daemon/kernels_test.go @@ -0,0 +1,99 @@ +package daemon + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "banger/internal/api" + "banger/internal/kernelcat" + "banger/internal/paths" + "banger/internal/rpc" +) + +func seedKernelEntry(t *testing.T, kernelsDir, name string) { + t.Helper() + entry := kernelcat.Entry{ + Name: name, + Distro: "void", + Arch: "x86_64", + KernelVersion: "6.12.0", + Source: "test", + } + if err := kernelcat.WriteLocal(kernelsDir, entry); err != nil { + t.Fatalf("seed WriteLocal: %v", err) + } + if err := os.WriteFile(filepath.Join(kernelsDir, name, "vmlinux"), []byte("kernel-bytes"), 0o644); err != nil { + t.Fatalf("seed vmlinux: %v", err) + } +} + +func TestKernelListReturnsSeededEntries(t *testing.T) { + kernelsDir := t.TempDir() + seedKernelEntry(t, kernelsDir, "void-6.12") + seedKernelEntry(t, kernelsDir, "alpine-3.23") + + d := &Daemon{layout: paths.Layout{KernelsDir: kernelsDir}} + result, err := d.KernelList(context.Background()) + if err != nil { + t.Fatalf("KernelList: %v", err) + } + if len(result.Entries) != 2 { + t.Fatalf("entries = %d, want 2", len(result.Entries)) + } + // sorted alphabetically by kernelcat + if result.Entries[0].Name != "alpine-3.23" || result.Entries[1].Name != "void-6.12" { + t.Fatalf("entries order = %+v", result.Entries) + } + if result.Entries[0].KernelPath == "" || !strings.HasSuffix(result.Entries[0].KernelPath, "vmlinux") { + t.Fatalf("KernelPath not populated: %+v", result.Entries[0]) + } +} + +func TestKernelShowAndDeleteThroughDispatch(t *testing.T) { + kernelsDir := t.TempDir() + seedKernelEntry(t, kernelsDir, "void-6.12") + + d := &Daemon{layout: paths.Layout{KernelsDir: kernelsDir}} + + showParams, _ := json.Marshal(api.KernelRefParams{Name: "void-6.12"}) + resp := d.dispatch(context.Background(), rpc.Request{Version: rpc.Version, Method: "kernel.show", Params: showParams}) + if !resp.OK { + t.Fatalf("kernel.show dispatch failed: %+v", resp) + } + var show api.KernelShowResult + if err := json.Unmarshal(resp.Result, &show); err != nil { + t.Fatalf("unmarshal show: %v", err) + } + if show.Entry.Name != "void-6.12" || show.Entry.Distro != "void" { + t.Fatalf("show.Entry = %+v", show.Entry) + } + + delParams, _ := json.Marshal(api.KernelRefParams{Name: "void-6.12"}) + del := d.dispatch(context.Background(), rpc.Request{Version: rpc.Version, Method: "kernel.delete", Params: delParams}) + if !del.OK { + t.Fatalf("kernel.delete dispatch failed: %+v", del) + } + + if _, err := kernelcat.ReadLocal(kernelsDir, "void-6.12"); !os.IsNotExist(err) { + t.Fatalf("entry still present after delete: err=%v", err) + } +} + +func TestKernelShowMissingEntry(t *testing.T) { + d := &Daemon{layout: paths.Layout{KernelsDir: t.TempDir()}} + _, err := d.KernelShow(context.Background(), "nope") + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("KernelShow missing: err=%v", err) + } +} + +func TestKernelDeleteRejectsInvalidName(t *testing.T) { + d := &Daemon{layout: paths.Layout{KernelsDir: t.TempDir()}} + if err := d.KernelDelete(context.Background(), "../escape"); err == nil { + t.Fatalf("KernelDelete should reject traversal") + } +} diff --git a/internal/kernelcat/kernelcat.go b/internal/kernelcat/kernelcat.go new file mode 100644 index 0000000..d203134 --- /dev/null +++ b/internal/kernelcat/kernelcat.go @@ -0,0 +1,184 @@ +// Package kernelcat is the on-disk catalog of Firecracker-ready kernel +// bundles. Each entry lives at // and contains a +// manifest.json alongside the vmlinux, optional initrd.img, and optional +// modules/ tree. The package owns the layout, manifest read/write, and +// validation; it does not talk to the network (remote pulls are layered on +// later). +package kernelcat + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" +) + +// Filenames used inside an entry directory. +const ( + manifestFilename = "manifest.json" + kernelFilename = "vmlinux" + initrdFilename = "initrd.img" + modulesDirName = "modules" +) + +// Entry describes a cataloged kernel bundle. Paths are absolute and +// populated from the entry's on-disk layout when read via ReadLocal / +// ListLocal; they are never written into the manifest itself. +type Entry struct { + Name string `json:"name"` + Distro string `json:"distro,omitempty"` + Arch string `json:"arch,omitempty"` + KernelVersion string `json:"kernel_version,omitempty"` + SHA256 string `json:"sha256,omitempty"` + Source string `json:"source,omitempty"` + ImportedAt time.Time `json:"imported_at"` + + // Populated on read, not persisted: + KernelPath string `json:"-"` + InitrdPath string `json:"-"` + ModulesDir string `json:"-"` +} + +// namePattern matches names that are safe as single filesystem components. +// Intentionally strict so entry names stay short and script-friendly. +var namePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`) + +// ValidateName returns an error unless name is a non-empty identifier made +// of alphanumerics, dots, hyphens, and underscores, starting with an +// alphanumeric and at most 64 characters long. +func ValidateName(name string) error { + if strings.TrimSpace(name) == "" { + return errors.New("kernel name is required") + } + if !namePattern.MatchString(name) { + return fmt.Errorf("invalid kernel name %q: use alphanumerics, dots, hyphens, underscores (<=64 chars, starts with alphanumeric)", name) + } + return nil +} + +// EntryDir returns the absolute directory path for name under kernelsDir. +func EntryDir(kernelsDir, name string) string { + return filepath.Join(kernelsDir, name) +} + +// ReadLocal reads the manifest for name and resolves per-artifact paths. +// Returns os.ErrNotExist-compatible error if the entry is missing. +func ReadLocal(kernelsDir, name string) (Entry, error) { + if err := ValidateName(name); err != nil { + return Entry{}, err + } + dir := EntryDir(kernelsDir, name) + data, err := os.ReadFile(filepath.Join(dir, manifestFilename)) + if err != nil { + return Entry{}, err + } + var entry Entry + if err := json.Unmarshal(data, &entry); err != nil { + return Entry{}, fmt.Errorf("parse manifest for %q: %w", name, err) + } + if entry.Name == "" { + entry.Name = name + } + if entry.Name != name { + return Entry{}, fmt.Errorf("manifest name %q does not match directory %q", entry.Name, name) + } + entry.KernelPath = filepath.Join(dir, kernelFilename) + if fi, err := os.Stat(filepath.Join(dir, initrdFilename)); err == nil && !fi.IsDir() { + entry.InitrdPath = filepath.Join(dir, initrdFilename) + } + if fi, err := os.Stat(filepath.Join(dir, modulesDirName)); err == nil && fi.IsDir() { + entry.ModulesDir = filepath.Join(dir, modulesDirName) + } + return entry, nil +} + +// ListLocal returns every entry under kernelsDir with a readable manifest, +// sorted by name. Directories without a manifest are skipped silently so +// partial imports don't break the list. +func ListLocal(kernelsDir string) ([]Entry, error) { + dirEntries, err := os.ReadDir(kernelsDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + entries := make([]Entry, 0, len(dirEntries)) + for _, de := range dirEntries { + if !de.IsDir() { + continue + } + name := de.Name() + if err := ValidateName(name); err != nil { + continue + } + entry, err := ReadLocal(kernelsDir, name) + if err != nil { + if os.IsNotExist(err) { + continue + } + return nil, err + } + entries = append(entries, entry) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name }) + return entries, nil +} + +// WriteLocal persists entry's manifest.json. The caller is responsible for +// placing vmlinux / initrd.img / modules/ under the entry dir first. +func WriteLocal(kernelsDir string, entry Entry) error { + if err := ValidateName(entry.Name); err != nil { + return err + } + dir := EntryDir(kernelsDir, entry.Name) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + if entry.ImportedAt.IsZero() { + entry.ImportedAt = time.Now().UTC() + } + data, err := json.MarshalIndent(entry, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, manifestFilename), append(data, '\n'), 0o644) +} + +// DeleteLocal removes the entry directory entirely. Missing entries are a +// no-op so callers can idempotently clean up. +func DeleteLocal(kernelsDir, name string) error { + if err := ValidateName(name); err != nil { + return err + } + dir := EntryDir(kernelsDir, name) + if _, err := os.Stat(dir); err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + return os.RemoveAll(dir) +} + +// SumFile returns the hex-encoded SHA256 of the file at path. +func SumFile(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + hasher := sha256.New() + if _, err := io.Copy(hasher, f); err != nil { + return "", err + } + return hex.EncodeToString(hasher.Sum(nil)), nil +} diff --git a/internal/kernelcat/kernelcat_test.go b/internal/kernelcat/kernelcat_test.go new file mode 100644 index 0000000..ee935d5 --- /dev/null +++ b/internal/kernelcat/kernelcat_test.go @@ -0,0 +1,171 @@ +package kernelcat + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +func TestValidateName(t *testing.T) { + t.Parallel() + cases := []struct { + name string + wantErr bool + }{ + {"void-6.12", false}, + {"alpine_3.20", false}, + {"a", false}, + {"Void-6.12", false}, + {"", true}, + {"-leading-dash", true}, + {".leading-dot", true}, + {"has space", true}, + {"has/slash", true}, + {"../escape", true}, + } + for _, tc := range cases { + err := ValidateName(tc.name) + if tc.wantErr && err == nil { + t.Errorf("ValidateName(%q) err=nil, want error", tc.name) + } + if !tc.wantErr && err != nil { + t.Errorf("ValidateName(%q) err=%v, want nil", tc.name, err) + } + } +} + +func TestWriteAndReadLocalRoundTrip(t *testing.T) { + t.Parallel() + dir := t.TempDir() + entry := Entry{ + Name: "void-6.12", + Distro: "void", + Arch: "x86_64", + KernelVersion: "6.12.79_1", + SHA256: "deadbeef", + Source: "import:testdata", + } + if err := WriteLocal(dir, entry); err != nil { + t.Fatalf("WriteLocal: %v", err) + } + + kernelPath := filepath.Join(dir, entry.Name, "vmlinux") + if err := os.WriteFile(kernelPath, []byte("kernel-bytes"), 0o644); err != nil { + t.Fatalf("write kernel: %v", err) + } + modulesPath := filepath.Join(dir, entry.Name, "modules", "6.12.79_1", "modules.dep") + if err := os.MkdirAll(filepath.Dir(modulesPath), 0o755); err != nil { + t.Fatalf("mkdir modules: %v", err) + } + if err := os.WriteFile(modulesPath, []byte(""), 0o644); err != nil { + t.Fatalf("write modules stub: %v", err) + } + + got, err := ReadLocal(dir, entry.Name) + if err != nil { + t.Fatalf("ReadLocal: %v", err) + } + if got.Name != entry.Name || got.Distro != "void" || got.KernelVersion != "6.12.79_1" { + t.Fatalf("ReadLocal round-trip mismatch: %+v", got) + } + if got.KernelPath != kernelPath { + t.Errorf("KernelPath = %q, want %q", got.KernelPath, kernelPath) + } + if got.InitrdPath != "" { + t.Errorf("InitrdPath = %q, want empty (no initrd on disk)", got.InitrdPath) + } + if got.ModulesDir != filepath.Join(dir, entry.Name, "modules") { + t.Errorf("ModulesDir = %q", got.ModulesDir) + } + if got.ImportedAt.IsZero() { + t.Errorf("ImportedAt not populated by WriteLocal") + } + if time.Since(got.ImportedAt) > time.Minute { + t.Errorf("ImportedAt too far in the past: %v", got.ImportedAt) + } +} + +func TestReadLocalRejectsMismatchedName(t *testing.T) { + t.Parallel() + dir := t.TempDir() + entryDir := filepath.Join(dir, "void-6.12") + if err := os.MkdirAll(entryDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(entryDir, manifestFilename), []byte(`{"name":"other"}`), 0o644); err != nil { + t.Fatal(err) + } + if _, err := ReadLocal(dir, "void-6.12"); err == nil { + t.Fatal("ReadLocal should reject manifest with mismatched name") + } +} + +func TestListLocalSkipsManifestless(t *testing.T) { + t.Parallel() + dir := t.TempDir() + if err := WriteLocal(dir, Entry{Name: "alpine-3.20"}); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "orphan"), 0o755); err != nil { + t.Fatal(err) + } + entries, err := ListLocal(dir) + if err != nil { + t.Fatalf("ListLocal: %v", err) + } + if len(entries) != 1 || entries[0].Name != "alpine-3.20" { + t.Fatalf("ListLocal = %+v, want one alpine-3.20", entries) + } +} + +func TestListLocalReturnsEmptyForMissingDir(t *testing.T) { + t.Parallel() + entries, err := ListLocal(filepath.Join(t.TempDir(), "nope")) + if err != nil { + t.Fatalf("ListLocal: %v", err) + } + if len(entries) != 0 { + t.Fatalf("ListLocal = %v, want empty", entries) + } +} + +func TestDeleteLocalRemovesEntry(t *testing.T) { + t.Parallel() + dir := t.TempDir() + if err := WriteLocal(dir, Entry{Name: "void-6.12"}); err != nil { + t.Fatal(err) + } + if err := DeleteLocal(dir, "void-6.12"); err != nil { + t.Fatalf("DeleteLocal: %v", err) + } + if _, err := os.Stat(EntryDir(dir, "void-6.12")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected entry dir removed, stat err=%v", err) + } +} + +func TestDeleteLocalIdempotent(t *testing.T) { + t.Parallel() + if err := DeleteLocal(t.TempDir(), "never-existed"); err != nil { + t.Fatalf("DeleteLocal on missing entry: %v", err) + } +} + +func TestSumFileMatchesSHA256(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "data") + if err := os.WriteFile(path, []byte("banger"), 0o644); err != nil { + t.Fatal(err) + } + sum, err := SumFile(path) + if err != nil { + t.Fatalf("SumFile: %v", err) + } + // precomputed sha256("banger") + const want = "e0c69eae8afb38872fa425c2cdba794176f3b9d97e8eefb7b0e7c831f566458f" + if sum != want { + t.Fatalf("SumFile = %q, want %q", sum, want) + } +} diff --git a/internal/paths/paths.go b/internal/paths/paths.go index 0eeacba..4ae0b46 100644 --- a/internal/paths/paths.go +++ b/internal/paths/paths.go @@ -22,6 +22,7 @@ type Layout struct { DaemonLog string VMsDir string ImagesDir string + KernelsDir string } func Resolve() (Layout, error) { @@ -52,11 +53,12 @@ func Resolve() (Layout, error) { layout.DaemonLog = filepath.Join(layout.StateDir, "bangerd.log") layout.VMsDir = filepath.Join(layout.StateDir, "vms") layout.ImagesDir = filepath.Join(layout.StateDir, "images") + layout.KernelsDir = filepath.Join(layout.StateDir, "kernels") return layout, nil } func Ensure(layout Layout) error { - for _, dir := range []string{layout.ConfigDir, layout.StateDir, layout.CacheDir, layout.RuntimeDir, layout.VMsDir, layout.ImagesDir} { + for _, dir := range []string{layout.ConfigDir, layout.StateDir, layout.CacheDir, layout.RuntimeDir, layout.VMsDir, layout.ImagesDir, layout.KernelsDir} { if err := os.MkdirAll(dir, 0o755); err != nil { return err }