diff --git a/docs/architecture/go-modules-and-workspaces.md b/docs/architecture/go-modules-and-workspaces.md index fd15e249..2f55a46c 100644 --- a/docs/architecture/go-modules-and-workspaces.md +++ b/docs/architecture/go-modules-and-workspaces.md @@ -91,7 +91,7 @@ build: - `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. +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. An application whose framework predates `party.Describe` (before v0.6.0) still builds with a newer `summer`: the build checks the framework's `party` package as `go list` resolves it in the application, and when it has no `party.Describe` it writes `main.go` and `plugins.gen.go` as before (imports and `PluginIDs` only), with a warning to move the framework requirement to v0.6.0 or newer. 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` and `summer plugin:install` append their entries to the `plugins:` list in place, so the rest of the file, comments and blank lines included, stays as it was. A script that runs its own `go build` must pass the same tags. diff --git a/internal/build/build.go b/internal/build/build.go index d74fb4af..b8a2eda9 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -80,7 +80,7 @@ func goBuildArgs(binRel string, tags []string) []string { } func generate(appDir string, m Manifest, meta buildMeta) error { - mainSrc, err := generateMain(m) + mainSrc, err := generateMainFor(m, !meta.Legacy) if err != nil { return err } @@ -97,7 +97,14 @@ func generate(appDir string, m Manifest, meta buildMeta) error { return nil } +// generateMain writes main.go for a framework that has party.Describe. func generateMain(m Manifest) ([]byte, error) { + return generateMainFor(m, true) +} + +// generateMainFor writes main.go; describe adds the party.Describe call, +// which a framework before v0.6.0 does not have (WR-01). +func generateMainFor(m Manifest, describe bool) ([]byte, error) { var b strings.Builder b.WriteString("// Code generated by summer build. DO NOT EDIT.\n\n") b.WriteString("package main\n\n") @@ -128,7 +135,9 @@ 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") + if describe { + 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") @@ -151,8 +160,13 @@ func generateMain(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. +// baked string goes through strconv.Quote. For a framework without +// party.Info (meta.Legacy) it writes the imports and PluginIDs only, as +// summer build did before v0.6.0. func generatePluginsGen(m Manifest, meta buildMeta) ([]byte, error) { + if meta.Legacy { + return generateLegacyPluginsGen(m) + } var b strings.Builder b.WriteString("// Code generated by summer build. DO NOT EDIT.\n\n") b.WriteString("package main\n\n") @@ -187,6 +201,28 @@ func generatePluginsGen(m Manifest, meta buildMeta) ([]byte, error) { return formatSource([]byte(b.String())) } +// generateLegacyPluginsGen is plugins.gen.go for a framework before +// party.Info: the blank plugin imports and PluginIDs. +func generateLegacyPluginsGen(m Manifest) ([]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("// 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") + 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 { diff --git a/internal/build/plugindirs.go b/internal/build/plugindirs.go index 2d0ceefe..a9da7603 100644 --- a/internal/build/plugindirs.go +++ b/internal/build/plugindirs.go @@ -6,6 +6,9 @@ import ( "encoding/json" "errors" "fmt" + "go/ast" + "go/parser" + "go/token" "os" "os/exec" "path/filepath" @@ -20,8 +23,16 @@ import ( type buildMeta struct { Plugins []party.Info Framework string + // Legacy is set when the framework the application builds against has + // no party.Describe (before v0.6.0): the generated sources then leave + // the metadata out, as they did before, so the application still + // compiles (WR-01). + Legacy bool } +// partyPackage is the framework package that holds party.Describe. +const partyPackage = frameworkModule + "/modules/party" + // listedPackage is the part of `go list -json` output the metadata step // reads for one plugin package. type listedPackage struct { @@ -84,6 +95,12 @@ func idOnlyMeta(m Manifest) buildMeta { // 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) { + if !frameworkDescribes(ctx, appDir) { + fw, _ := EffectiveFramework(ctx, appDir) + return buildMeta{Framework: fw, Legacy: true}, []string{fmt.Sprintf( + "framework %s has no party.Describe (added in v0.6.0); the generated sources carry no plugin metadata, so the admin's Plugins screen lists plugin IDs only. Update the framework requirement to v0.6.0 or newer to get it.", + displayVersion(fw))} + } infos, warnings := resolvePluginInfo(ctx, appDir, m) fw, err := EffectiveFramework(ctx, appDir) if err != nil { @@ -92,6 +109,64 @@ func resolveBuildMeta(ctx context.Context, appDir string, m Manifest) (buildMeta return buildMeta{Plugins: infos, Framework: fw}, warnings } +// frameworkDescribes reports whether the framework the application builds +// against provides party.Describe and party.Info: the party package that +// go list resolves in appDir (go.mod, its replaces and go.work alike) +// declares both. The check is static, not by version, because a local +// framework checkout reports the previous tag until it is tagged. When +// the package cannot be resolved it answers true: the sources stay the +// current ones and go build reports the real problem. +func frameworkDescribes(ctx context.Context, appDir string) bool { + pkgs, err := listPackages(ctx, appDir, []string{partyPackage}) + if err != nil { + return true + } + for _, p := range pkgs { + if p.ImportPath != partyPackage || p.Dir == "" || p.Error != nil { + continue + } + return declaresDescribe(p.Dir) + } + return true +} + +// declaresDescribe reports whether the Go package in dir declares the +// function Describe and the type Info; a directory it cannot read +// answers true. +func declaresDescribe(dir string) bool { + entries, err := os.ReadDir(dir) + if err != nil { + return true + } + describe, info := false, false + fset := token.NewFileSet() + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + f, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, parser.SkipObjectResolution) + if err != nil { + continue + } + for _, d := range f.Decls { + switch d := d.(type) { + case *ast.FuncDecl: + if d.Recv == nil && d.Name.Name == "Describe" { + describe = true + } + case *ast.GenDecl: + for _, spec := range d.Specs { + if ts, ok := spec.(*ast.TypeSpec); ok && ts.Name.Name == "Info" { + info = true + } + } + } + } + } + return describe && info +} + // 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 diff --git a/internal/build/plugindirs_test.go b/internal/build/plugindirs_test.go index d13c7d22..d083e9b3 100644 --- a/internal/build/plugindirs_test.go +++ b/internal/build/plugindirs_test.go @@ -301,3 +301,68 @@ func TestEffectiveFramework(t *testing.T) { } }) } + +// TestLegacyFramework pins WR-01: an application whose framework has no +// party.Describe (before v0.6.0) gets the sources summer build wrote +// before, without the plugin metadata, so it still compiles. +func TestLegacyFramework(t *testing.T) { + ctx := t.Context() + app := func(t *testing.T, partySrc string) string { + t.Helper() + root := t.TempDir() + fw := filepath.Join(root, "framework") + writeAll(t, filepath.Join(fw, "go.mod"), "module "+frameworkModule+"\n\ngo 1.27.0\n") + writeAll(t, filepath.Join(fw, "modules", "party", "party.go"), partySrc) + writeAll(t, filepath.Join(fw, "modules", "party", "party_test.go"), "package party\n\nfunc Describe() {}\n\ntype Info struct{}\n") + dir := filepath.Join(root, "app") + writeAll(t, filepath.Join(dir, "go.mod"), "module example.test/app\n\ngo 1.27.0\n\nrequire "+frameworkModule+" v0.5.3\n\nreplace "+frameworkModule+" => ../framework\n") + return dir + } + m := Manifest{Module: "example.test/app", Binary: "app", Plugins: []Plugin{{ID: "acme.blog", Module: "example.test/blog"}}} + + t.Run("before party.Describe", func(t *testing.T) { + meta, warnings := resolveBuildMeta(ctx, app(t, "package party\n\nfunc Activate() {}\n"), Manifest{Module: "example.test/app", Binary: "app"}) + if !meta.Legacy || len(warnings) != 1 || !strings.Contains(warnings[0], "has no party.Describe") || !strings.Contains(warnings[0], "v0.6.0") { + t.Fatalf("meta %+v, warnings %q", meta, warnings) + } + mainSrc, err := generateMainFor(m, !meta.Legacy) + if err != nil { + t.Fatal(err) + } + gen, err := generatePluginsGen(m, meta) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(mainSrc), "party.Describe") || !strings.Contains(string(mainSrc), "party.Activate(app, PluginIDs)") { + t.Errorf("main.go:\n%s", mainSrc) + } + want := "// Code generated by summer build. DO NOT EDIT.\n\npackage main\n\nimport (\n\t_ \"example.test/blog\"\n)\n\n" + + "// PluginIDs is the ordered plugin list from summer.yaml.\nvar PluginIDs = []string{\n\t\"acme.blog\",\n}\n" + if string(gen) != want { + t.Errorf("plugins.gen.go:\n%s\nwant:\n%s", gen, want) + } + }) + + t.Run("with party.Describe", func(t *testing.T) { + src := "package party\n\ntype Info struct{ ID string }\n\nfunc Describe(any, []Info, string) error { return nil }\n" + meta, warnings := resolveBuildMeta(ctx, app(t, src), Manifest{Module: "example.test/app", Binary: "app"}) + if meta.Legacy { + t.Fatalf("meta %+v, warnings %q", meta, warnings) + } + mainSrc, err := generateMainFor(m, !meta.Legacy) + if err != nil || !strings.Contains(string(mainSrc), "party.Describe(app, PluginInfo, FrameworkVersion)") { + t.Errorf("main.go: %v\n%s", err, mainSrc) + } + }) + + t.Run("unresolvable party package keeps the current sources", func(t *testing.T) { + orig := listPackages + listPackages = func(context.Context, string, []string) ([]listedPackage, error) { + return nil, os.ErrPermission + } + t.Cleanup(func() { listPackages = orig }) + if !frameworkDescribes(ctx, t.TempDir()) { + t.Error("a failing go list switched to the legacy sources") + } + }) +}