- party.Info, party.Installed, party.Describe (additive; Plugin and Activate unchanged) - summer build resolves plugin dirs with one go list, reads plugin.yaml, versions per D-07 (module version, git describe without --dirty, none for in-app plugins) - build.EffectiveFramework: newest tag of a locally replaced or used framework checkout, else the go.mod require - generated main calls party.Describe(app, PluginInfo, FrameworkVersion) before Activate; metadata failures warn and fall back to IDs - summer new goldens and examples/hello regenerated
333 lines
9.8 KiB
Go
333 lines
9.8 KiB
Go
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
|
|
}
|