Phase 4: remote catalog + banger kernel pull
Introduces the headline feature of the kernel catalog: pulling a kernel
bundle over HTTP without any local build step.
Catalog format (internal/kernelcat/catalog.go):
- Catalog { Version, Entries } + CatEntry { Name, Distro, Arch,
KernelVersion, TarballURL, TarballSHA256, SizeBytes, Description }.
- catalog.json is embedded via go:embed and ships with each banger
binary. It starts empty (Phase 5's CI pipeline will populate it).
- Lookup(name) returns the matching entry or os.ErrNotExist.
Fetch (internal/kernelcat/fetch.go):
- HTTP GET with streaming SHA256 over the response body.
- zstd-decode (github.com/klauspost/compress/zstd) -> tar extract into
<kernelsDir>/<name>/.
- Hardens against path-traversal tarball entries (members whose
normalised path escapes the target dir, and unsafe symlink
targets) and sha256-mismatch downloads; any failure removes the
partially-populated target dir.
- Regular files, directories, and safe symlinks are supported; other
tar types (hardlinks, devices, fifos) are silently skipped.
- After extraction, recomputes sha256 over the on-disk vmlinux and
writes the manifest with Source="pull:<url>".
Daemon methods (internal/daemon/kernels.go):
- KernelPull(ctx, {Name, Force}) - lookup in embedded catalog, refuse
overwrite unless Force, delegate to kernelcat.Fetch.
- KernelCatalog(ctx) - return the embedded catalog annotated per-entry
with whether it has been pulled locally.
RPC: kernel.pull, kernel.catalog dispatch cases.
CLI:
- `banger kernel pull <name> [--force]`.
- `banger kernel list --available` prints the catalog with a
pulled/available STATE column and a human-readable size.
Tests: fetch round-trip (extract + manifest + sha256), sha256 mismatch
rejection with cleanup, missing-vmlinux rejection, path-traversal
rejection, HTTP error propagation, catalog parsing, lookup,
pulled-status reconciliation. All 20 packages green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7192ba24ae
commit
f0668ee598
13 changed files with 711 additions and 4 deletions
|
|
@ -550,6 +550,15 @@ func (d *Daemon) dispatch(ctx context.Context, req rpc.Request) rpc.Response {
|
|||
}
|
||||
entry, err := d.KernelImport(ctx, params)
|
||||
return marshalResultOrError(api.KernelShowResult{Entry: entry}, err)
|
||||
case "kernel.pull":
|
||||
params, err := rpc.DecodeParams[api.KernelPullParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
entry, err := d.KernelPull(ctx, params)
|
||||
return marshalResultOrError(api.KernelShowResult{Entry: entry}, err)
|
||||
case "kernel.catalog":
|
||||
return marshalResultOrError(d.KernelCatalog(ctx))
|
||||
default:
|
||||
return rpc.NewError("unknown_method", req.Method)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,6 +114,65 @@ func (d *Daemon) KernelImport(ctx context.Context, params api.KernelImportParams
|
|||
return kernelEntryToAPI(stored), nil
|
||||
}
|
||||
|
||||
// KernelPull downloads a catalog entry by name into the local catalog. It
|
||||
// refuses to overwrite an existing entry unless params.Force is set.
|
||||
func (d *Daemon) KernelPull(ctx context.Context, params api.KernelPullParams) (api.KernelEntry, error) {
|
||||
name := strings.TrimSpace(params.Name)
|
||||
if err := kernelcat.ValidateName(name); err != nil {
|
||||
return api.KernelEntry{}, err
|
||||
}
|
||||
|
||||
if !params.Force {
|
||||
if _, err := kernelcat.ReadLocal(d.layout.KernelsDir, name); err == nil {
|
||||
return api.KernelEntry{}, fmt.Errorf("kernel %q already pulled; pass --force to re-pull", name)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return api.KernelEntry{}, err
|
||||
}
|
||||
}
|
||||
|
||||
catalog, err := kernelcat.LoadEmbedded()
|
||||
if err != nil {
|
||||
return api.KernelEntry{}, err
|
||||
}
|
||||
catEntry, err := catalog.Lookup(name)
|
||||
if err != nil {
|
||||
return api.KernelEntry{}, fmt.Errorf("kernel %q not in catalog (run 'banger kernel list --available' to browse)", name)
|
||||
}
|
||||
|
||||
stored, err := kernelcat.Fetch(ctx, nil, d.layout.KernelsDir, catEntry)
|
||||
if err != nil {
|
||||
return api.KernelEntry{}, err
|
||||
}
|
||||
return kernelEntryToAPI(stored), nil
|
||||
}
|
||||
|
||||
// KernelCatalog returns every entry from the embedded catalog annotated
|
||||
// with whether it has already been pulled locally.
|
||||
func (d *Daemon) KernelCatalog(_ context.Context) (api.KernelCatalogResult, error) {
|
||||
catalog, err := kernelcat.LoadEmbedded()
|
||||
if err != nil {
|
||||
return api.KernelCatalogResult{}, err
|
||||
}
|
||||
local, _ := kernelcat.ListLocal(d.layout.KernelsDir)
|
||||
pulled := make(map[string]bool, len(local))
|
||||
for _, entry := range local {
|
||||
pulled[entry.Name] = true
|
||||
}
|
||||
result := api.KernelCatalogResult{Entries: make([]api.KernelCatalogEntry, 0, len(catalog.Entries))}
|
||||
for _, entry := range catalog.Entries {
|
||||
result.Entries = append(result.Entries, api.KernelCatalogEntry{
|
||||
Name: entry.Name,
|
||||
Distro: entry.Distro,
|
||||
Arch: entry.Arch,
|
||||
KernelVersion: entry.KernelVersion,
|
||||
SizeBytes: entry.SizeBytes,
|
||||
Description: entry.Description,
|
||||
Pulled: pulled[entry.Name],
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// inferKernelVersion makes a best-effort guess at the kernel version from
|
||||
// the source filename (e.g. "vmlinux-6.12.79_1") or falls back to the
|
||||
// modules directory basename. Returns "" if nothing looks useful.
|
||||
|
|
|
|||
|
|
@ -205,6 +205,43 @@ func TestKernelImportCopiesArtifactsAndWritesManifest(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestKernelPullRejectsUnknownCatalogEntry(t *testing.T) {
|
||||
d := &Daemon{
|
||||
layout: paths.Layout{KernelsDir: t.TempDir()},
|
||||
runner: system.NewRunner(),
|
||||
}
|
||||
_, err := d.KernelPull(context.Background(), api.KernelPullParams{Name: "unknown"})
|
||||
if err == nil || !strings.Contains(err.Error(), "not in catalog") {
|
||||
t.Fatalf("KernelPull unknown: err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKernelPullRefusesOverwriteWithoutForce(t *testing.T) {
|
||||
kernelsDir := t.TempDir()
|
||||
seedKernelEntry(t, kernelsDir, "void-6.12")
|
||||
|
||||
d := &Daemon{
|
||||
layout: paths.Layout{KernelsDir: kernelsDir},
|
||||
runner: system.NewRunner(),
|
||||
}
|
||||
_, err := d.KernelPull(context.Background(), api.KernelPullParams{Name: "void-6.12"})
|
||||
if err == nil || !strings.Contains(err.Error(), "already pulled") {
|
||||
t.Fatalf("KernelPull without --force: err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKernelCatalogReportsPulledStatus(t *testing.T) {
|
||||
d := &Daemon{layout: paths.Layout{KernelsDir: t.TempDir()}}
|
||||
result, err := d.KernelCatalog(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("KernelCatalog: %v", err)
|
||||
}
|
||||
// Embedded catalog ships empty; CI (phase 5) populates it.
|
||||
if result.Entries == nil {
|
||||
t.Fatalf("Entries should be non-nil even when catalog is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKernelImportRejectsMissingFromDir(t *testing.T) {
|
||||
d := &Daemon{
|
||||
layout: paths.Layout{KernelsDir: t.TempDir()},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue