summer build always emitted party.Describe and []party.Info, so an application whose framework requirement predates v0.6.0 stopped compiling once the CLI was updated. The build now checks the party package go list resolves in the application (a static look for Describe and Info, not a version compare, since a local checkout reports the previous tag until it is tagged) and, when they are missing, writes main.go and plugins.gen.go exactly as before, with a warning to move the framework to v0.6.0. An unresolvable party package keeps the current sources.
413 lines
13 KiB
Go
413 lines
13 KiB
Go
package build
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"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
|
|
// 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 {
|
|
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) {
|
|
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 {
|
|
warnings = append(warnings, err.Error())
|
|
}
|
|
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
|
|
// 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, appDir, 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. A
|
|
// package of the application's own module is in-app even though the
|
|
// application directory is the top level of a repository.
|
|
func pluginVersion(ctx context.Context, appDir string, 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 abs, err := filepath.Abs(appDir); err == nil {
|
|
appDir = abs
|
|
}
|
|
if !samePath(root, appDir) && 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
|
|
}
|