Phase 1: local kernel catalog scaffolding

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
<KernelsDir>/<name>/ 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/<name>/
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 <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-16 14:21:10 -03:00
parent ca4865447c
commit 83cc3aee15
No known key found for this signature in database
GPG key ID: 33112E6833C34679
9 changed files with 691 additions and 3 deletions

View file

@ -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 <name>",
Short: "Show kernel catalog entry details",
Args: exactArgsUsage(1, "usage: banger kernel show <name>"),
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 <name>",
Aliases: []string{"remove", "delete"},
Short: "Remove a kernel catalog entry",
Args: exactArgsUsage(1, "usage: banger kernel rm <name>"),
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, " "))

View file

@ -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"})