diff --git a/cmd/summer/main.go b/cmd/summer/main.go index 0fb8287c..f2939a5a 100644 --- a/cmd/summer/main.go +++ b/cmd/summer/main.go @@ -85,6 +85,10 @@ files: every build rewrites them.`, if line == "" { continue } + if rest, ok := strings.CutPrefix(line, "warning: "); ok { + out.Warning(strings.TrimSuffix(rest, "\n")) + continue + } if rest, ok := strings.CutPrefix(line, "built "); ok && err == nil { out.Success("Built bin/" + strings.TrimSuffix(rest, "\n")) continue diff --git a/docs/architecture/go-modules-and-workspaces.md b/docs/architecture/go-modules-and-workspaces.md index 79a2cc76..2aeb1dde 100644 --- a/docs/architecture/go-modules-and-workspaces.md +++ b/docs/architecture/go-modules-and-workspaces.md @@ -86,6 +86,13 @@ build: `summer build` turns this list into `plugins.gen.go`. The order is the manifest order, adjusted so that each plugin comes after the plugins it requires. Plugins with no dependency between them keep the order you wrote. +`plugins.gen.go` also carries what the build knew about each plugin and the framework: + +- `PluginInfo`, one `party.Info` per plugin in manifest order: the ID, the Go module and package, the name and description from the plugin's `plugin.yaml` (see [Publishing plugins](../plugins/marketplace.md)), the version and the source. A plugin from the module cache (`go get`) reports its module version and the source `module`. A plugin directory that is its own git repository, such as a submodule, reports `git describe --tags` output like `v0.3.1-4-gabc1234` and the source `submodule`; an uncommitted change does not alter it. A plugin inside the application's own repository has no version and the source `local`. A plugin without `plugin.yaml` has no name or description. +- `FrameworkVersion`, the framework version the application was built against. When `go.mod` or `go.work` points the framework at a local checkout, it is the newest tag reachable in that checkout; otherwise it is the version `go.mod` requires. + +The generated `main` publishes both with `party.Describe` before activating the plugins, so the admin can show the installed plugins with `app.Lookup[party.Installed]()`. When the metadata cannot be resolved, `summer build` prints a warning and bakes the IDs only; it never fails a build. A submodule bump or a new framework tag therefore changes `plugins.gen.go`: commit the regenerated file with the bump. + `build.tags` lists Go build tags that `summer build` (and `summer dev`) passes to `go build` as `-tags=,`, printing them as `build tags: …`. Each tag must consist of lower-case letters, digits and underscores; a duplicate is ignored. Framework features that pull in large dependencies sit behind a tag, such as `summercms_s3` for S3 uploads buckets (see [Storage](../services/storage.md#s3-and-s3-compatible-buckets)). `summer plugin:add` keeps the list when it rewrites the manifest. A script that runs its own `go build` must pass the same tags. ## Local plugins in a workspace diff --git a/docs/plugins/marketplace.md b/docs/plugins/marketplace.md index 7dcf2f36..9ded688f 100644 --- a/docs/plugins/marketplace.md +++ b/docs/plugins/marketplace.md @@ -88,6 +88,10 @@ func TestPluginManifest(t *testing.T) { `kiosk.CheckT` runs the same check as `summer plugin:manifest --check` and names the command to rerun. A nested plugin gets its own test in its own directory. +## Installed plugin metadata + +`summer build` reads the `plugin.yaml` of every plugin the application lists and bakes the name and description into the generated `plugins.gen.go` as `PluginInfo`, next to each plugin's version and the framework version (`FrameworkVersion`). At start-up the application publishes them as `party.Installed`, which the admin's Plugins screen reads. [Go modules and workspaces](../architecture/go-modules-and-workspaces.md#the-summeryaml-manifest) explains where the versions come from. + ## Registry API v1 summercms.io publishes the plugins it lists through a read-only JSON API. The marketplace pages, `summer` and the admin's Plugins screen read it. Its base path is `/api/registry/v1` (`kiosk.RegistryAPIPath`), and the `kiosk` module holds its wire types, so every client and the server share one definition of the shape. diff --git a/examples/hello/main.go b/examples/hello/main.go index ee78a1be..09822f3e 100644 --- a/examples/hello/main.go +++ b/examples/hello/main.go @@ -34,6 +34,9 @@ func run(args []string, out io.Writer) int { return bonfire.Fail(os.Stderr, "hello", err, g) } app := backpack.New(cfg) + if err := party.Describe(app, PluginInfo, FrameworkVersion); err != nil { + return bonfire.Fail(os.Stderr, "hello", err, g) + } plugins, err := party.Activate(app, PluginIDs) if err != nil { return bonfire.Fail(os.Stderr, "hello", err, g) diff --git a/examples/hello/plugins.gen.go b/examples/hello/plugins.gen.go index f90d6aed..d85a0641 100644 --- a/examples/hello/plugins.gen.go +++ b/examples/hello/plugins.gen.go @@ -6,6 +6,8 @@ import ( _ "git.golem15.com/golem15/summercms/examples/hello/plugins/base" _ "git.golem15.com/golem15/summercms/examples/hello/plugins/greeter" _ "git.golem15.com/golem15/summercms/examples/hello/plugins/optional" + + "git.golem15.com/golem15/summercms/modules/party" ) // PluginIDs is the ordered plugin list from summer.yaml. @@ -14,3 +16,14 @@ var PluginIDs = []string{ "golem15.greeter", "golem15.optional", } + +// PluginInfo is each plugin's metadata in summer.yaml order: name and +// description from its plugin.yaml, version and source from summer build. +var PluginInfo = []party.Info{ + {ID: "golem15.hello", Module: "git.golem15.com/golem15/summercms/examples/hello/plugins/base", Package: "git.golem15.com/golem15/summercms/examples/hello/plugins/base", Source: party.SourceLocal}, + {ID: "golem15.greeter", Module: "git.golem15.com/golem15/summercms/examples/hello/plugins/greeter", Package: "git.golem15.com/golem15/summercms/examples/hello/plugins/greeter", Source: party.SourceLocal}, + {ID: "golem15.optional", Module: "git.golem15.com/golem15/summercms/examples/hello/plugins/optional", Package: "git.golem15.com/golem15/summercms/examples/hello/plugins/optional", Source: party.SourceLocal}, +} + +// FrameworkVersion is the framework version the app was built against. +const FrameworkVersion = "v0.5.3" diff --git a/internal/build/build.go b/internal/build/build.go index 935bcbf8..d74fb4af 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -13,6 +13,8 @@ import ( "strconv" "strings" "time" + + "git.golem15.com/golem15/summercms/modules/party" ) // App generates app sources from summer.yaml and runs go build in appDir. @@ -34,7 +36,11 @@ func App(ctx context.Context, appDir string, out io.Writer) error { if err != nil { return err } - if err := generate(appDir, m); err != nil { + meta, warnings := resolveBuildMeta(ctx, appDir, m) + for _, w := range warnings { + fmt.Fprintf(out, "warning: %s\n", w) + } + if err := generate(appDir, m, meta); err != nil { return err } if err := checkModelsLeaf(appDir, m); err != nil { @@ -73,12 +79,12 @@ func goBuildArgs(binRel string, tags []string) []string { return append(args, "-o", binRel, ".") } -func generate(appDir string, m Manifest) error { +func generate(appDir string, m Manifest, meta buildMeta) error { mainSrc, err := generateMain(m) if err != nil { return err } - pluginsSrc, err := generatePluginsGen(m) + pluginsSrc, err := generatePluginsGen(m, meta) if err != nil { return err } @@ -122,6 +128,7 @@ func generateMain(m Manifest) ([]byte, error) { b.WriteString("\tif err != nil {\n" + fail + "\t}\n") b.WriteString("\tif err := diary.Setup(cfg, diary.FromGlobals(g)); err != nil {\n" + fail + "\t}\n") b.WriteString("\tapp := backpack.New(cfg)\n") + b.WriteString("\tif err := party.Describe(app, PluginInfo, FrameworkVersion); err != nil {\n" + fail + "\t}\n") b.WriteString("\tplugins, err := party.Activate(app, PluginIDs)\n") b.WriteString("\tif err != nil {\n" + fail + "\t}\n") b.WriteString("\tcommands := lagoon.RuntimeCommands(app, plugins)\n") @@ -142,26 +149,72 @@ func generateMain(m Manifest) ([]byte, error) { return formatSource([]byte(b.String())) } -func generatePluginsGen(m Manifest) ([]byte, error) { +// generatePluginsGen writes plugins.gen.go: the blank plugin imports, the +// ordered PluginIDs, the PluginInfo metadata and FrameworkVersion. Every +// baked string goes through strconv.Quote. +func generatePluginsGen(m Manifest, meta buildMeta) ([]byte, error) { var b strings.Builder b.WriteString("// Code generated by summer build. DO NOT EDIT.\n\n") b.WriteString("package main\n\n") - if len(m.Plugins) > 0 { - b.WriteString("import (\n") - for _, p := range m.Plugins { - fmt.Fprintf(&b, "\t_ %s\n", strconv.Quote(p.Module)) - } - b.WriteString(")\n\n") + b.WriteString("import (\n") + for _, p := range m.Plugins { + fmt.Fprintf(&b, "\t_ %s\n", strconv.Quote(p.Module)) } + if len(m.Plugins) > 0 { + b.WriteString("\n") + } + b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/party\"\n") + b.WriteString(")\n\n") b.WriteString("// PluginIDs is the ordered plugin list from summer.yaml.\n") b.WriteString("var PluginIDs = []string{\n") for _, p := range m.Plugins { fmt.Fprintf(&b, "\t%s,\n", strconv.Quote(p.ID)) } - b.WriteString("}\n") + b.WriteString("}\n\n") + b.WriteString("// PluginInfo is each plugin's metadata in summer.yaml order: name and\n") + b.WriteString("// description from its plugin.yaml, version and source from summer build.\n") + b.WriteString("var PluginInfo = []party.Info{\n") + for i, p := range m.Plugins { + info := party.Info{ID: p.ID} + if i < len(meta.Plugins) && meta.Plugins[i].ID == p.ID { + info = meta.Plugins[i] + } + fmt.Fprintf(&b, "\t{%s},\n", infoLiteral(info)) + } + b.WriteString("}\n\n") + b.WriteString("// FrameworkVersion is the framework version the app was built against.\n") + fmt.Fprintf(&b, "const FrameworkVersion = %s\n", strconv.Quote(meta.Framework)) return formatSource([]byte(b.String())) } +// infoLiteral is the field list of a party.Info composite literal with the +// empty fields left out. +func infoLiteral(info party.Info) string { + fields := []string{"ID: " + strconv.Quote(info.ID)} + add := func(name, v string) { + if v != "" { + fields = append(fields, name+": "+strconv.Quote(v)) + } + } + add("Module", info.Module) + add("Package", info.Package) + add("Name", info.Name) + add("Description", info.Description) + add("Version", info.Version) + switch info.Source { + case "": + case party.SourceModule: + fields = append(fields, "Source: party.SourceModule") + case party.SourceSubmodule: + fields = append(fields, "Source: party.SourceSubmodule") + case party.SourceLocal: + fields = append(fields, "Source: party.SourceLocal") + default: + fields = append(fields, "Source: "+strconv.Quote(info.Source)) + } + return strings.Join(fields, ", ") +} + func formatSource(src []byte) ([]byte, error) { formatted, err := format.Source(src) if err != nil { diff --git a/internal/build/build_test.go b/internal/build/build_test.go index a68ed58d..dce373c1 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -66,11 +66,11 @@ func TestGenerateStableQuotedImportsInManifestOrder(t *testing.T) { {ID: "golem15.greeter", Module: "example.com/app/plugins/greeter"}, }, } - first, err := generatePluginsGen(m) + first, err := generatePluginsGen(m, buildMeta{}) if err != nil { t.Fatal(err) } - second, err := generatePluginsGen(m) + second, err := generatePluginsGen(m, buildMeta{}) if err != nil { t.Fatal(err) } diff --git a/internal/build/new.go b/internal/build/new.go index 36a2bdb5..59f9fb89 100644 --- a/internal/build/new.go +++ b/internal/build/new.go @@ -450,7 +450,9 @@ func populateApp(ctx context.Context, opts NewOptions, target string, files []ge if err != nil { return err } - return generate(target, m) + // summer new bakes IDs only and an empty FrameworkVersion; the first + // summer build fills both in. + return generate(target, m, idOnlyMeta(m)) } // addStarterPlugin creates golem15. through the make:plugin and diff --git a/internal/build/plugindirs.go b/internal/build/plugindirs.go new file mode 100644 index 00000000..9cd687b4 --- /dev/null +++ b/internal/build/plugindirs.go @@ -0,0 +1,332 @@ +package build + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "git.golem15.com/golem15/summercms/modules/kiosk" + "git.golem15.com/golem15/summercms/modules/party" +) + +// buildMeta is the plugin metadata and framework version summer build +// bakes into plugins.gen.go. +type buildMeta struct { + Plugins []party.Info + Framework string +} + +// listedPackage is the part of `go list -json` output the metadata step +// reads for one plugin package. +type listedPackage struct { + ImportPath string + Dir string + Module *listedPackageModule + Error *struct{ Err string } +} + +type listedPackageModule struct { + Path string + Version string + Dir string + Main bool + Replace *struct { + Path string + Version string + Dir string + } +} + +// listPackages runs `go list` for the plugin packages; tests replace it. +var listPackages = goListPackages + +// goListPackages resolves import paths to directories and modules with one +// `go list -e -json` in appDir, honouring the app's go.work. +func goListPackages(ctx context.Context, appDir string, paths []string) ([]listedPackage, error) { + args := append([]string{"list", "-e", "-json=ImportPath,Dir,Module,Error", "--"}, paths...) + cmd := exec.CommandContext(ctx, "go", args...) + cmd.Dir = appDir + applyGoWork(cmd, appDir) + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("go list: %w: %s", err, bytes.TrimSpace(stderr.Bytes())) + } + var pkgs []listedPackage + dec := json.NewDecoder(bytes.NewReader(out)) + for dec.More() { + var p listedPackage + if err := dec.Decode(&p); err != nil { + return nil, fmt.Errorf("go list: %w", err) + } + pkgs = append(pkgs, p) + } + return pkgs, nil +} + +// idOnlyMeta is the metadata of a build that knows nothing but the IDs. +func idOnlyMeta(m Manifest) buildMeta { + infos := make([]party.Info, len(m.Plugins)) + for i, p := range m.Plugins { + infos[i] = party.Info{ID: p.ID} + } + return buildMeta{Plugins: infos} +} + +// resolveBuildMeta resolves the plugin metadata and the effective +// framework version. It never fails: what it cannot resolve stays empty +// and comes back as a warning, so metadata never breaks a build. +func resolveBuildMeta(ctx context.Context, appDir string, m Manifest) (buildMeta, []string) { + infos, warnings := resolvePluginInfo(ctx, appDir, m) + fw, err := EffectiveFramework(ctx, appDir) + if err != nil { + warnings = append(warnings, err.Error()) + } + return buildMeta{Plugins: infos, Framework: fw}, warnings +} + +// resolvePluginInfo returns one party.Info per summer.yaml plugin, in +// manifest order: module and package from go list, name and description +// from the plugin's plugin.yaml, and the version per D-07. No filesystem +// path is ever stored. +func resolvePluginInfo(ctx context.Context, appDir string, m Manifest) ([]party.Info, []string) { + infos := idOnlyMeta(m).Plugins + if len(m.Plugins) == 0 { + return infos, nil + } + paths := make([]string, len(m.Plugins)) + for i, p := range m.Plugins { + paths[i] = p.Module + } + pkgs, err := listPackages(ctx, appDir, paths) + if err != nil { + return infos, []string{fmt.Sprintf("plugin metadata: %v; PluginInfo carries plugin IDs only", err)} + } + byPath := make(map[string]listedPackage, len(pkgs)) + for _, p := range pkgs { + byPath[p.ImportPath] = p + } + var warnings []string + for i, p := range m.Plugins { + pkg, ok := byPath[p.Module] + if !ok || pkg.Dir == "" { + reason := "package not found" + if ok && pkg.Error != nil { + reason = pkg.Error.Err + } + warnings = append(warnings, fmt.Sprintf("plugin metadata: %s: %s", p.ID, reason)) + continue + } + info := &infos[i] + info.Package = pkg.ImportPath + if pkg.Module != nil { + info.Module = pkg.Module.Path + } + if w := readPluginYAML(pkg.Dir, info); w != "" { + warnings = append(warnings, fmt.Sprintf("plugin metadata: %s: %s", p.ID, w)) + } + pluginVersion(ctx, pkg, info) + } + return infos, warnings +} + +// readPluginYAML copies name and description from dir/plugin.yaml into +// info. It returns a warning when the file exists but cannot be used. +func readPluginYAML(dir string, info *party.Info) string { + raw, err := os.ReadFile(filepath.Join(dir, kiosk.FileName)) + if errors.Is(err, os.ErrNotExist) { + return "" + } + if err != nil { + return fmt.Sprintf("%s: %v; ignored", kiosk.FileName, err) + } + pm, err := kiosk.Parse(raw) + if err != nil { + return fmt.Sprintf("%s: %v; ignored", kiosk.FileName, err) + } + if pm.ID != info.ID { + return fmt.Sprintf("%s describes %s, not %s; ignored", kiosk.FileName, pm.ID, info.ID) + } + info.Name, info.Description = pm.Name, pm.Description + if info.Module == "" { + info.Module = pm.Module + } + return "" +} + +// pluginVersion sets info.Source and info.Version (D-07): the module +// version for a plugin from the module cache, git describe for a plugin +// directory that is its own repository, nothing for an in-app plugin. +func pluginVersion(ctx context.Context, pkg listedPackage, info *party.Info) { + mod := pkg.Module + switch { + case mod == nil: + info.Source = party.SourceLocal + case mod.Replace != nil && mod.Replace.Version != "": + info.Source, info.Version = party.SourceModule, mod.Replace.Version + case !mod.Main && mod.Replace == nil: + info.Source, info.Version = party.SourceModule, mod.Version + default: + root := mod.Dir + if root == "" { + root = pkg.Dir + } + if isOwnRepository(ctx, root) { + info.Source = party.SourceSubmodule + info.Version = gitDescribe(ctx, root) + } else { + info.Source = party.SourceLocal + } + } +} + +// isOwnRepository reports whether dir is the top level of a git work tree +// (its own repository or a submodule), not a directory inside the app's. +func isOwnRepository(ctx context.Context, dir string) bool { + out, err := gitOutput(ctx, dir, "rev-parse", "--show-toplevel") + if err != nil { + return false + } + return samePath(strings.TrimSpace(string(out)), dir) +} + +// gitDescribe is `git describe --tags --match v[0-9]* --always` in dir, +// without --dirty: an uncommitted change in a plugin must not rewrite the +// app's tracked plugins.gen.go (research Pitfall 6). +func gitDescribe(ctx context.Context, dir string) string { + out, err := gitOutput(ctx, dir, "describe", "--tags", "--match", "v[0-9]*", "--always") + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func gitOutput(ctx context.Context, dir string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, "git", append([]string{"-C", dir}, args...)...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + return nil, errors.New(msg) + } + return out, nil +} + +func samePath(a, b string) bool { + ra, err := filepath.EvalSymlinks(a) + if err != nil { + ra = a + } + rb, err := filepath.EvalSymlinks(b) + if err != nil { + rb = b + } + return filepath.Clean(ra) == filepath.Clean(rb) +} + +// goModJSON is the part of `go mod edit -json` and `go work edit -json` +// output EffectiveFramework reads. +type goModJSON struct { + Require []struct { + Path string + Version string + } + Replace []struct { + Old struct{ Path string } + New struct{ Path, Version string } + } + Use []struct { + DiskPath string + } +} + +func editJSON(ctx context.Context, dir string, args ...string) (goModJSON, error) { + cmd := exec.CommandContext(ctx, "go", args...) + cmd.Dir = dir + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + return goModJSON{}, fmt.Errorf("go %s: %w: %s", strings.Join(args, " "), err, bytes.TrimSpace(stderr.Bytes())) + } + var j goModJSON + if err := json.Unmarshal(out, &j); err != nil { + return goModJSON{}, fmt.Errorf("go %s: %w", strings.Join(args, " "), err) + } + return j, nil +} + +// EffectiveFramework returns the framework version an app in appDir builds +// against (research F3). When go.work or go.mod replaces the framework with +// a local directory, or go.work uses one, it is the newest tag reachable +// from that checkout (`git describe --tags --abbrev=0`); a replace with a +// module version gives that version; otherwise it is the go.mod require. +// When git fails it returns the require version together with a non-nil +// error the caller prints as a warning. +func EffectiveFramework(ctx context.Context, appDir string) (string, error) { + gomod, err := editJSON(ctx, appDir, "mod", "edit", "-json", filepath.Join(appDir, "go.mod")) + if err != nil { + return "", fmt.Errorf("framework version: %w", err) + } + required := "" + for _, r := range gomod.Require { + if r.Path == frameworkModule { + required = r.Version + } + } + localDir, version := "", "" + for _, r := range gomod.Replace { + if r.Old.Path == frameworkModule { + localDir, version = r.New.Path, r.New.Version + if version == "" && !filepath.IsAbs(localDir) { + localDir = filepath.Join(appDir, localDir) + } + } + } + if gw := findGoWork(appDir); gw != "" { + work, err := editJSON(ctx, appDir, "work", "edit", "-json", gw) + if err != nil { + return required, fmt.Errorf("framework version: %w; using the go.mod require %s", err, required) + } + base := filepath.Dir(gw) + for _, u := range work.Use { + dir := u.DiskPath + if !filepath.IsAbs(dir) { + dir = filepath.Join(base, dir) + } + if mod, err := readModulePath(filepath.Join(dir, "go.mod")); err == nil && mod == frameworkModule { + localDir, version = dir, "" + } + } + for _, r := range work.Replace { + if r.Old.Path == frameworkModule { + localDir, version = r.New.Path, r.New.Version + if version == "" && !filepath.IsAbs(localDir) { + localDir = filepath.Join(base, localDir) + } + } + } + } + switch { + case version != "": + return version, nil + case localDir == "": + return required, nil + } + out, err := gitOutput(ctx, localDir, "describe", "--tags", "--abbrev=0", "--match", "v[0-9]*") + if err != nil { + return required, fmt.Errorf("framework version: git describe in %s: %v; using the go.mod require %s", localDir, err, required) + } + return strings.TrimSpace(string(out)), nil +} diff --git a/internal/build/plugininfo_smoke_test.go b/internal/build/plugininfo_smoke_test.go new file mode 100644 index 00000000..a8346ee6 --- /dev/null +++ b/internal/build/plugininfo_smoke_test.go @@ -0,0 +1,242 @@ +package build + +import ( + "bytes" + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/party" +) + +// TestPluginInfoSmoke covers the build-time plugin metadata (D-06, D-07): +// generation, plugin directory resolution, versions, the effective +// framework version, the fallback and the runtime publication. +func TestPluginInfoSmoke(t *testing.T) { + m := Manifest{ + Module: "example.com/app", + Binary: "app", + Plugins: []Plugin{ + {ID: "acme.blog", Module: "example.com/sm-blog-plugin"}, + {ID: "acme.local", Module: "example.com/app/plugins/acme/local"}, + }, + } + + t.Run("generate", func(t *testing.T) { + meta := buildMeta{ + Framework: "v0.6.0", + Plugins: []party.Info{ + {ID: "acme.blog", Module: "example.com/sm-blog-plugin", Package: "example.com/sm-blog-plugin", + Name: "Blog", Description: "Posts with \"quotes\",\nnewlines and `ticks`.", Version: "v0.2.5", Source: party.SourceModule}, + {ID: "acme.local", Package: "example.com/app/plugins/acme/local", Source: party.SourceLocal}, + }, + } + first, err := generatePluginsGen(m, meta) + if err != nil { + t.Fatal(err) + } + second, err := generatePluginsGen(m, meta) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(first, second) { + t.Fatal("plugins.gen.go is not byte-stable") + } + src := string(first) + for _, want := range []string{ + `"git.golem15.com/golem15/summercms/modules/party"`, + "var PluginInfo = []party.Info{", + `{ID: "acme.blog", Module: "example.com/sm-blog-plugin", Package: "example.com/sm-blog-plugin", Name: "Blog", Description: "Posts with \"quotes\",\nnewlines and ` + "`ticks`" + `.", Version: "v0.2.5", Source: party.SourceModule},`, + `{ID: "acme.local", Package: "example.com/app/plugins/acme/local", Source: party.SourceLocal},`, + `const FrameworkVersion = "v0.6.0"`, + } { + if !strings.Contains(src, want) { + t.Errorf("plugins.gen.go lacks %s\n%s", want, src) + } + } + if strings.Index(src, `ID: "acme.blog"`) > strings.Index(src, `ID: "acme.local"`) { + t.Error("PluginInfo is not in summer.yaml order") + } + if strings.Index(src, "var PluginIDs") > strings.Index(src, "var PluginInfo") { + t.Error("PluginInfo comes before PluginIDs") + } + + // Missing metadata degrades to ID-only entries. + idOnly, err := generatePluginsGen(m, buildMeta{}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(idOnly), `{ID: "acme.blog"},`) || !strings.Contains(string(idOnly), `const FrameworkVersion = ""`) { + t.Errorf("ID-only plugins.gen.go:\n%s", idOnly) + } + }) + + t.Run("main", func(t *testing.T) { + src, err := generateMain(m) + if err != nil { + t.Fatal(err) + } + describe := strings.Index(string(src), "if err := party.Describe(app, PluginInfo, FrameworkVersion); err != nil {") + activate := strings.Index(string(src), "party.Activate(app, PluginIDs)") + if describe < 0 || activate < 0 || describe > activate { + t.Fatalf("Describe must come before Activate:\n%s", src) + } + }) + + t.Run("dirs", func(t *testing.T) { + requireGit(t) + app := t.TempDir() + write(t, filepath.Join(app, "go.mod"), "module example.test/app\n\ngo 1.27.0\n\n"+ + "require (\n\texample.test/acme/inapp v0.0.0\n\texample.test/acme/sub v0.0.0\n)\n\n"+ + "replace example.test/acme/inapp => ./plugins/acme/inapp\n\nreplace example.test/acme/sub => ./plugins/acme/sub\n") + write(t, filepath.Join(app, "main.go"), "package main\n\nimport (\n\t_ \"example.test/acme/inapp\"\n\t_ \"example.test/acme/sub\"\n)\n\nfunc main() {}\n") + for _, name := range []string{"inapp", "sub"} { + dir := filepath.Join(app, "plugins", "acme", name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + write(t, filepath.Join(dir, "go.mod"), "module example.test/acme/"+name+"\n\ngo 1.27.0\n") + write(t, filepath.Join(dir, "plugin.go"), "package "+name+"\n") + } + inapp := filepath.Join(app, "plugins", "acme", "inapp") + sub := filepath.Join(app, "plugins", "acme", "sub") + write(t, filepath.Join(inapp, "plugin.yaml"), "schema: [not, a, manifest\n") + write(t, filepath.Join(sub, "plugin.yaml"), "# Generated by summer plugin:manifest. Do not edit; rerun the command.\n"+ + "schema: 1\nid: acme.sub\nmodule: example.test/acme/sub\npackage: example.test/acme/sub\n"+ + "name: Sub\ndescription: A plugin in its own repository.\nframework: v0.6.0\n") + runTestGit(t, app, "init", "-q") + runTestGit(t, sub, "init", "-q") + runTestGit(t, sub, "add", ".") + runTestGit(t, sub, "commit", "-q", "-m", "first") + runTestGit(t, sub, "tag", "v0.1.0") + write(t, filepath.Join(sub, "extra.go"), "package sub\n") + runTestGit(t, sub, "add", ".") + runTestGit(t, sub, "commit", "-q", "-m", "second") + // Uncommitted change: the version must not carry -dirty. + write(t, filepath.Join(sub, "plugin.go"), "package sub\n\n// changed\n") + + manifest := Manifest{Module: "example.test/app", Binary: "app", Plugins: []Plugin{ + {ID: "acme.inapp", Module: "example.test/acme/inapp"}, + {ID: "acme.sub", Module: "example.test/acme/sub"}, + }} + infos, warnings := resolvePluginInfo(t.Context(), app, manifest) + if len(infos) != 2 { + t.Fatalf("infos = %+v", infos) + } + in, sb := infos[0], infos[1] + if in.ID != "acme.inapp" || in.Source != party.SourceLocal || in.Version != "" || in.Name != "" || + in.Module != "example.test/acme/inapp" || in.Package != "example.test/acme/inapp" { + t.Errorf("in-app plugin = %+v", in) + } + if sb.ID != "acme.sub" || sb.Source != party.SourceSubmodule || !strings.HasPrefix(sb.Version, "v0.1.0-1-g") || + strings.Contains(sb.Version, "dirty") || sb.Name != "Sub" || sb.Description != "A plugin in its own repository." { + t.Errorf("own-repository plugin = %+v", sb) + } + if len(warnings) != 1 || !strings.Contains(warnings[0], "plugin.yaml") { + t.Errorf("warnings = %q, want one about the malformed plugin.yaml", warnings) + } + for _, info := range infos { + for _, s := range []string{info.Module, info.Package, info.Version, info.Name, info.Description} { + if strings.Contains(s, app) { + t.Errorf("a filesystem path leaked into %+v", info) + } + } + } + }) + + t.Run("framework", func(t *testing.T) { + requireGit(t) + ctx := t.Context() + + fw := t.TempDir() + write(t, filepath.Join(fw, "go.mod"), "module "+frameworkModule+"\n\ngo 1.27.0\n") + runTestGit(t, fw, "init", "-q") + runTestGit(t, fw, "add", ".") + runTestGit(t, fw, "commit", "-q", "-m", "one") + runTestGit(t, fw, "tag", "v0.5.3") + write(t, filepath.Join(fw, "a.go"), "package summercms\n") + runTestGit(t, fw, "add", ".") + runTestGit(t, fw, "commit", "-q", "-m", "two") + runTestGit(t, fw, "tag", "v0.6.0") + write(t, filepath.Join(fw, "b.go"), "package summercms\n") + runTestGit(t, fw, "add", ".") + runTestGit(t, fw, "commit", "-q", "-m", "three") + + replaced := t.TempDir() + write(t, filepath.Join(replaced, "go.mod"), "module example.test/app\n\ngo 1.27.0\n\nrequire "+frameworkModule+" v0.0.0\n\nreplace "+frameworkModule+" => "+fw+"\n") + if v, err := EffectiveFramework(ctx, replaced); err != nil || v != "v0.6.0" { + t.Errorf("replaced framework = %q, %v; want v0.6.0", v, err) + } + + required := t.TempDir() + write(t, filepath.Join(required, "go.mod"), "module example.test/app\n\ngo 1.27.0\n\nrequire "+frameworkModule+" v0.5.3\n") + if v, err := EffectiveFramework(ctx, required); err != nil || v != "v0.5.3" { + t.Errorf("required framework = %q, %v; want v0.5.3", v, err) + } + + notGit := t.TempDir() + noRepo := t.TempDir() + write(t, filepath.Join(noRepo, "go.mod"), "module "+frameworkModule+"\n\ngo 1.27.0\n") + write(t, filepath.Join(notGit, "go.mod"), "module example.test/app\n\ngo 1.27.0\n\nrequire "+frameworkModule+" v0.5.1\n\nreplace "+frameworkModule+" => "+noRepo+"\n") + if v, err := EffectiveFramework(ctx, notGit); err == nil || v != "v0.5.1" { + t.Errorf("framework without git = %q, %v; want v0.5.1 and a warning", v, err) + } + }) + + t.Run("fallback", func(t *testing.T) { + dir := copyHelloApp(t) + orig := listPackages + listPackages = func(context.Context, string, []string) ([]listedPackage, error) { + return nil, errors.New("go list failed") + } + t.Cleanup(func() { listPackages = orig }) + var out bytes.Buffer + if err := App(t.Context(), dir, &out); err != nil { + t.Fatalf("build with failing metadata: %v\n%s", err, out.String()) + } + if n := strings.Count(out.String(), "warning:"); n != 1 { + t.Errorf("warnings = %d, want 1:\n%s", n, out.String()) + } + gen, err := os.ReadFile(filepath.Join(dir, "plugins.gen.go")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(gen), `{ID: "golem15.hello"},`) || !strings.Contains(string(gen), "const FrameworkVersion = ") { + t.Errorf("plugins.gen.go after a metadata failure:\n%s", gen) + } + }) + + t.Run("describe", func(t *testing.T) { + app := backpack.New(&compass.Config{}) + plugins := []party.Info{{ID: "acme.blog", Name: "Blog", Version: "v0.2.5", Source: party.SourceModule}} + if err := party.Describe(app, plugins, "v0.6.0"); err != nil { + t.Fatal(err) + } + got, ok := app.Lookup[party.Installed]() + if !ok || got.Framework != "v0.6.0" || len(got.Plugins) != 1 || got.Plugins[0] != plugins[0] { + t.Fatalf("Installed = %+v, %v", got, ok) + } + if err := party.Describe(nil, plugins, "v0.6.0"); err == nil { + t.Fatal("Describe(nil app) succeeded") + } + }) +} + +// git runs git in dir with a test-local identity and no signing. +func runTestGit(t *testing.T, dir string, args ...string) { + t.Helper() + full := append([]string{"-c", "user.name=Test", "-c", "user.email=test@example.test", + "-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false", "-c", "init.defaultBranch=master"}, args...) + cmd := exec.CommandContext(t.Context(), "git", full...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } +} diff --git a/internal/build/testdata/new-no-plugin/plugins.gen.go.golden b/internal/build/testdata/new-no-plugin/plugins.gen.go.golden index e4fafd90..34e113bf 100644 --- a/internal/build/testdata/new-no-plugin/plugins.gen.go.golden +++ b/internal/build/testdata/new-no-plugin/plugins.gen.go.golden @@ -2,5 +2,16 @@ package main +import ( + "git.golem15.com/golem15/summercms/modules/party" +) + // PluginIDs is the ordered plugin list from summer.yaml. var PluginIDs = []string{} + +// PluginInfo is each plugin's metadata in summer.yaml order: name and +// description from its plugin.yaml, version and source from summer build. +var PluginInfo = []party.Info{} + +// FrameworkVersion is the framework version the app was built against. +const FrameworkVersion = "" diff --git a/internal/build/testdata/new/main.go.golden b/internal/build/testdata/new/main.go.golden index ba4013aa..7870ccf8 100644 --- a/internal/build/testdata/new/main.go.golden +++ b/internal/build/testdata/new/main.go.golden @@ -34,6 +34,9 @@ func run(args []string, out io.Writer) int { return bonfire.Fail(os.Stderr, "demo", err, g) } app := backpack.New(cfg) + if err := party.Describe(app, PluginInfo, FrameworkVersion); err != nil { + return bonfire.Fail(os.Stderr, "demo", err, g) + } plugins, err := party.Activate(app, PluginIDs) if err != nil { return bonfire.Fail(os.Stderr, "demo", err, g) diff --git a/internal/build/testdata/new/plugins.gen.go.golden b/internal/build/testdata/new/plugins.gen.go.golden index 5a4a2300..c038239b 100644 --- a/internal/build/testdata/new/plugins.gen.go.golden +++ b/internal/build/testdata/new/plugins.gen.go.golden @@ -4,9 +4,20 @@ package main import ( _ "git.golem15.com/golem15/sm-demo-app/plugins/golem15/demo" + + "git.golem15.com/golem15/summercms/modules/party" ) // PluginIDs is the ordered plugin list from summer.yaml. var PluginIDs = []string{ "golem15.demo", } + +// PluginInfo is each plugin's metadata in summer.yaml order: name and +// description from its plugin.yaml, version and source from summer build. +var PluginInfo = []party.Info{ + {ID: "golem15.demo"}, +} + +// FrameworkVersion is the framework version the app was built against. +const FrameworkVersion = "" diff --git a/modules/party/README.md b/modules/party/README.md index 86b55136..2e12df9f 100644 --- a/modules/party/README.md +++ b/modules/party/README.md @@ -26,6 +26,7 @@ Use cases: - A process-wide, concurrency-safe registry filled by `party.Register` (nil plugins are ignored). - `party.Activate` selects plugins by manifest ID and fails on an empty ID, a duplicate ID, an unregistered plugin, a missing requirement or a dependency cycle. - Stable topological ordering: plugins without a dependency relation keep their manifest order. +- Build-time metadata: `party.Describe` publishes `party.Installed`, the plugin metadata (`party.Info`: ID, module, package, name, description, version and source) and the framework version that `summer build` baked into the application's `plugins.gen.go`. The generated `main` calls it before `party.Activate`. - Activation sequence: records the ordered IDs on the `backpack.App`, publishes `party.Activated` (the ordered plugin list for commands that need plugin scopes at Run time), merges each `pact.HasConfig` tree into the app config under the plugin ID, runs every Register, publishes the translator ([phrasebook](../phrasebook/README.md)), the mailer ([postcard](../postcard/README.md)) and the menu item type registry ([signpost](../signpost/README.md), collected from every `pact.HasMenuItemTypes` plugin; a duplicate type key fails the activation), then registers each plugin's mail templates and runs its Boot. ## Usage @@ -52,7 +53,7 @@ func init() { } ``` -The application activates the plugins it lists, in dependency order: +The application publishes its build-time metadata and activates the plugins it lists, in dependency order (`summer build` generates both lists): ```go cfg, err := compass.Load("config") @@ -60,6 +61,13 @@ if err != nil { return err } app := backpack.New(cfg) +info := []party.Info{ + {ID: "acme.user", Source: party.SourceLocal}, + {ID: "acme.blog", Version: "v0.2.5", Source: party.SourceModule}, +} +if err := party.Describe(app, info, "v0.6.0"); err != nil { + return err +} plugins, err := party.Activate(app, []string{"acme.user", "acme.blog"}) if err != nil { return err @@ -73,6 +81,12 @@ if err != nil { | `party.Plugin` | Interface every compiled plugin implements: ID, required plugin IDs, Register and Boot. | | `party.Register` | Adds a plugin to the process-wide registry; called from the plugin's `init`. | | `party.Activate` | Selects registered plugins by ID, orders them by `party.Plugin.Requires` and runs the config, Register, translation, mail, menu item type and Boot steps; returns the ordered plugins. | +| `party.Info` | One plugin's build-time metadata: `ID`, `Module`, `Package`, `Name`, `Description` (from its `plugin.yaml`), `Version` and `Source`. | +| `party.SourceModule` | Source of a plugin built from the Go module cache; its version is the module version. | +| `party.SourceSubmodule` | Source of a plugin directory that is its own git repository; its version is `git describe --tags` output. | +| `party.SourceLocal` | Source of a plugin inside the application repository; it has no version. | +| `party.Installed` | What the application was built with: `Framework` and `Plugins` in manifest order; look it up with `app.Lookup[party.Installed]()`. | +| `party.Describe` | Publishes `party.Installed` on the app; a nil app is an error. | | `party.Activated` | The ordered `[]party.Plugin` published by `party.Activate`; commands look it up at Run time with `app.Lookup[party.Activated]()`. | ## Dependencies diff --git a/modules/party/info.go b/modules/party/info.go new file mode 100644 index 00000000..b71b1228 --- /dev/null +++ b/modules/party/info.go @@ -0,0 +1,56 @@ +package party + +import ( + "fmt" + + "git.golem15.com/golem15/summercms/modules/backpack" +) + +// Where an installed plugin's source came from at build time. +const ( + // SourceModule is a plugin built from the Go module cache (go get). + SourceModule = "module" + // SourceSubmodule is a plugin directory that is its own git repository, + // such as a git submodule; its version is git describe output. + SourceSubmodule = "submodule" + // SourceLocal is a plugin directory inside the application repository; + // it has no version of its own. + SourceLocal = "local" +) + +// Info is the metadata summer build bakes into plugins.gen.go for one +// plugin: its ID, Go module and package, the name and description from its +// plugin.yaml, its version and where it came from (SourceModule, +// SourceSubmodule or SourceLocal). A plugin without plugin.yaml has no +// name or description. +type Info struct { + ID string + Module string + Package string + Name string + Description string + Version string + Source string +} + +// Installed is what the application was built with: the effective +// framework version and the plugin metadata in summer.yaml order. Describe +// publishes it; look it up with app.Lookup[party.Installed](). +type Installed struct { + Framework string + Plugins []Info +} + +// Describe publishes the build-time plugin metadata and framework version +// as Installed. The generated main calls it before Activate. +func Describe(app *backpack.App, plugins []Info, framework string) error { + if app == nil { + return fmt.Errorf("party: app is nil") + } + list := make([]Info, len(plugins)) + copy(list, plugins) + if err := app.Publish(Installed{Framework: framework, Plugins: list}); err != nil { + return fmt.Errorf("party: %w", err) + } + return nil +}