cli/internal/bnp/build.go
Alex Dunmow 71d005f1fb feat(bnp): marshal public_routes + sitemap into built manifests (ADR 0026)
pluginsdk v0.2.7. The packer validates public_routes (reserved
prefixes, traversal, duplicates), requires an HTTP handler for routes
or sitemap, and stamps both into manifest.pb; codeless builds reject
them; writeMod round-trips the new plugin.mod keys so a version bump
cannot strip live route claims.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 02:45:58 +08:00

338 lines
11 KiB
Go

package bnp
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
core "git.dev.alexdunmow.com/block/pluginsdk/plugin"
"git.dev.alexdunmow.com/block/pluginsdk/egress"
"google.golang.org/protobuf/proto"
)
// Required top-level artifact members (mirrors the CMS reader).
const (
fileWasm = "plugin.wasm"
fileMod = "plugin.mod"
fileManifest = "manifest.pb"
)
// minGoMajor/minGoMinor is the lowest Go toolchain that can emit reactor-mode
// (`-buildmode=c-shared`) wasip1 modules the guest shim relies on.
const (
minGoMajor = 1
minGoMinor = 24
)
// BuildOptions configures Build.
type BuildOptions struct {
// Dir is the plugin repo root (holds plugin.mod and the main Go package).
Dir string
// Output is the destination .bnp path. Empty → "<name>-<version>.bnp" in
// the current working directory.
Output string
}
// BuildResult summarizes a produced artifact for the CLI summary table.
type BuildResult struct {
OutputPath string
Name string
Version string
// Codeless marks a declarative artifact (no plugin.wasm, WO-WZ-020).
Codeless bool
WasmBytes int64
ManifestBytes int64
ArtifactBytes int64 // on-disk .bnp size
UncompBytes int64 // sum of packed file sizes
BlockCount int
TemplateCount int
AdminPages int
JobTypes int
Hooks []string
DataDir bool
IncludedDirs []string
// PreviewIncluded/ScreenshotCount report the packed presentation members
// (root preview.png + screenshots/); the registry ingests these into the
// plugin's gallery at publish.
PreviewIncluded bool
ScreenshotCount int
}
// Build compiles the plugin in opts.Dir to wasm, extracts its manifest, and
// packs a .bnp. No Docker/podman: the whole pipeline is the local Go
// toolchain + wazero + tar.zst.
func Build(ctx context.Context, opts BuildOptions) (*BuildResult, error) {
dir := opts.Dir
if dir == "" {
dir = "."
}
dir, err := filepath.Abs(dir)
if err != nil {
return nil, fmt.Errorf("resolve dir: %w", err)
}
modPath := filepath.Join(dir, fileMod)
modBytes, err := os.ReadFile(modPath)
if err != nil {
return nil, fmt.Errorf("read plugin.mod (is %s a plugin repo?): %w", dir, err)
}
mod, err := core.ParseModFull(modBytes)
if err != nil {
return nil, fmt.Errorf("parse plugin.mod: %w", err)
}
if mod.Plugin.Name == "" || mod.Plugin.Version == "" {
return nil, fmt.Errorf("plugin.mod must set both name and version")
}
if err := checkGoVersion(ctx); err != nil {
return nil, err
}
// 1. Compile to reactor-mode wasip1 c-shared.
tmp, err := os.MkdirTemp("", "ninja-build-*")
if err != nil {
return nil, fmt.Errorf("temp dir: %w", err)
}
defer func() { _ = os.RemoveAll(tmp) }()
wasmPath := filepath.Join(tmp, fileWasm)
if err := buildWasm(ctx, dir, wasmPath); err != nil {
return nil, err
}
// 2. Extract the manifest by driving DESCRIBE.
wasm, err := readWasm(wasmPath)
if err != nil {
return nil, err
}
manifest, err := ExtractManifest(ctx, wasm)
if err != nil {
return nil, fmt.Errorf("extract manifest: %w", err)
}
// 3. Validate against plugin.mod before packing an incoherent artifact.
if manifest.GetName() != mod.Plugin.Name {
return nil, fmt.Errorf("manifest name %q != plugin.mod name %q", manifest.GetName(), mod.Plugin.Name)
}
if v := manifest.GetAbiVersion(); v != hostAbiVersion {
return nil, fmt.Errorf("manifest abi_version %d unsupported (packer speaks %d)", v, hostAbiVersion)
}
// 4. Stamp the data_dir grant from plugin.mod. DESCRIBE cannot see
// plugin.mod (it lives outside guest code), so the packer is where the
// grant crosses from mod → manifest; the loader then reads one source.
manifest.DataDir = mod.Plugin.DataDir
// 4a. Stamp the egress declaration (ADR 0023). Validate the host-pattern
// grammar here so a malformed allowed_hosts fails the build, not the
// install; the platform denylist is enforced host-side at publish and
// fetch (it needs the instance's own domains, which the packer lacks).
if _, err := egress.ParseAllowlist(mod.Plugin.AllowedHosts); err != nil {
return nil, fmt.Errorf("allowed_hosts: %w", err)
}
manifest.AllowedHosts = mod.Plugin.AllowedHosts
manifest.MaxResponseMb = mod.Plugin.MaxResponseMB
// 4b. Stamp the public-route claims (ADR 0026, cms repo). Same
// mod-is-the-source rule as data_dir/allowed_hosts; validate here so a
// bad claim fails the build, not the install. Conflict resolution
// (core wins, first-installed plugin wins) is host-side: it needs the
// instance's install set, which the packer lacks.
if err := stampPublicRoutes(manifest, mod); err != nil {
return nil, err
}
// 4c. Mixed-form artifacts (WO-WZ-021): a reduced wasm plugin may keep a
// minimal guest for genuine logic (a contact route, Stripe) while its
// declarative surfaces (theme presets, bundled fonts, master pages,
// system/page templates, template overrides, email wrappers, css,
// required icon packs) live in an optional root manifest.yaml — exactly
// the codeless declarative set. Fold it into the DESCRIBE-derived
// manifest just as BuildCodeless does: the declarative keys
// supplement/override the guest's, and the referenced root JSON
// (presets.json / master_pages.json / fonts.json) is embedded into
// manifest.pb (not packed separately, same as codeless). The templates/
// dir carrying the .ninjatpl sources already rides the optional-dir
// pack below. A repo with NO manifest.yaml is a no-op here
// (applyManifestYAML returns nil on os.IsNotExist), so an existing
// pure-wasm plugin builds byte-for-byte as before.
if err := applyManifestYAML(dir, manifest); err != nil {
return nil, err
}
manifestBytes, err := proto.Marshal(manifest)
if err != nil {
return nil, fmt.Errorf("marshal manifest.pb: %w", err)
}
// 5. Assemble artifact entries.
entries := []packEntry{
{ArtifactPath: fileWasm, Source: wasmPath},
{ArtifactPath: fileMod, Source: modPath},
{ArtifactPath: fileManifest, Data: manifestBytes},
}
var includedDirs []string
// dir-name → source subpath. web ships the Module Federation build output
// (web/dist) flattened under web/ so the reader's dirExists("web") fires.
optional := []struct{ artifact, src string }{
{dirBlocks, dirBlocks},
{dirTemplates, dirTemplates},
{dirSeed, dirSeed},
{"migrations", "migrations"},
{"schemas", "schemas"},
{"assets", "assets"},
{"web", filepath.Join("web", "dist")},
}
// Declarative dirs ride along on wasm artifacts too (a reduced plugin may
// mix definition-backed blocks with logic); validate them identically.
if _, err := validateBlocksDir(dir); err != nil {
return nil, err
}
if err := validateSeedDir(dir); err != nil {
return nil, err
}
for _, o := range optional {
dirEntries, has, dErr := collectDir(filepath.Join(dir, o.src), o.artifact)
if dErr != nil {
return nil, dErr
}
if has {
entries = append(entries, dirEntries...)
includedDirs = append(includedDirs, o.artifact)
}
}
presEntries, previewIncluded, screenshotCount, err := presentationEntries(dir)
if err != nil {
return nil, err
}
entries = append(entries, presEntries...)
if screenshotCount > 0 {
includedDirs = append(includedDirs, dirScreenshots)
}
// 6. Pack.
outPath := opts.Output
if outPath == "" {
outPath = fmt.Sprintf("%s-%s.bnp", mod.Plugin.Name, mod.Plugin.Version)
}
uncomp, err := packArtifact(outPath, entries)
if err != nil {
return nil, err
}
artInfo, err := os.Stat(outPath)
if err != nil {
return nil, fmt.Errorf("stat artifact: %w", err)
}
wasmInfo, _ := os.Stat(wasmPath)
return &BuildResult{
OutputPath: outPath,
Name: manifest.GetName(),
Version: manifest.GetVersion(),
WasmBytes: wasmInfo.Size(),
ManifestBytes: int64(len(manifestBytes)),
ArtifactBytes: artInfo.Size(),
UncompBytes: uncomp,
BlockCount: len(manifest.GetBlocks()),
TemplateCount: len(manifest.GetTemplateKeys()),
AdminPages: len(manifest.GetAdminPages()),
JobTypes: len(manifest.GetJobTypes()),
Hooks: hooksPresent(manifest),
DataDir: manifest.GetDataDir(),
IncludedDirs: includedDirs,
PreviewIncluded: previewIncluded,
ScreenshotCount: screenshotCount,
}, nil
}
// stampPublicRoutes validates the plugin.mod public-route declaration and
// copies it into the manifest (ADR 0026, cms repo). Routes and the sitemap
// flag are served by the guest HTTP handler, so declaring either without one
// fails the build.
func stampPublicRoutes(manifest *abiv1.PluginManifest, mod *core.ModFile) error {
if err := core.ValidatePublicRoutes(mod.Plugin.PublicRoutes); err != nil {
return fmt.Errorf("public_routes: %w", err)
}
if !manifest.GetHasHttpHandler() {
if len(mod.Plugin.PublicRoutes) > 0 {
return fmt.Errorf("public_routes declared but the plugin registers no HTTP handler to serve them")
}
if mod.Plugin.Sitemap {
return fmt.Errorf("sitemap = true but the plugin registers no HTTP handler to serve the sitemap endpoint")
}
}
for _, r := range mod.Plugin.PublicRoutes {
manifest.PublicRoutes = append(manifest.PublicRoutes, &abiv1.PublicRoute{Path: r.Path, Prefix: r.Prefix})
}
manifest.Sitemap = mod.Plugin.Sitemap
return nil
}
// buildWasm runs the reactor-mode wasip1 c-shared build in dir.
func buildWasm(ctx context.Context, dir, outPath string) error {
cmd := exec.CommandContext(ctx, "go", "build", "-buildmode=c-shared", "-o", outPath, ".")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm")
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("go build (GOOS=wasip1 GOARCH=wasm -buildmode=c-shared) failed: %w\n%s", err, stderr.String())
}
return nil
}
var goVersionRe = regexp.MustCompile(`go(\d+)\.(\d+)(?:\.\d+)?`)
// checkGoVersion enforces the minimum toolchain (Go 1.24) with a clear error.
func checkGoVersion(ctx context.Context) error {
out, err := exec.CommandContext(ctx, "go", "version").Output()
if err != nil {
return fmt.Errorf("`go version` failed (is the Go toolchain on PATH?): %w", err)
}
m := goVersionRe.FindStringSubmatch(string(out))
if m == nil {
return fmt.Errorf("could not parse Go version from %q", string(out))
}
major, _ := strconv.Atoi(m[1])
minor, _ := strconv.Atoi(m[2])
if major < minGoMajor || (major == minGoMajor && minor < minGoMinor) {
return fmt.Errorf(
"building reactor-mode wasip1 plugins requires Go %d.%d+; found %d.%d — upgrade your toolchain",
minGoMajor, minGoMinor, major, minor)
}
return nil
}
// hooksPresent returns the sorted set of runtime hooks the manifest declares,
// for the summary table.
func hooksPresent(m *abiv1.PluginManifest) []string {
var hooks []string
if m.GetHasLoadHook() {
hooks = append(hooks, "load")
}
if m.GetHasUnloadHook() {
hooks = append(hooks, "unload")
}
if m.GetHasHttpHandler() {
hooks = append(hooks, "http")
}
if m.GetHasMediaHooks() {
hooks = append(hooks, "media")
}
if len(m.GetJobTypes()) > 0 {
hooks = append(hooks, "job")
}
if len(m.GetRagContentFetcherTypes()) > 0 {
hooks = append(hooks, "rag")
}
sort.Strings(hooks)
return hooks
}