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 }