- module installs move to the newest compatible registry release with one go get; submodules fetch tags, check out the release tag and require it in go.mod; go.work overrides and version pins are refreshed - unlisted submodules (git URL installs) update from their own tags and plugin.yaml; --offline restricts to that - without an ID every plugin that resolves is updated; in-app, unlisted and incompatible ones are reported; failure only when a resolved update cannot be carried out - same prompt rule as plugin:install
516 lines
15 KiB
Go
516 lines
15 KiB
Go
package build
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
|
"git.golem15.com/golem15/summercms/modules/kiosk"
|
|
)
|
|
|
|
// sourceLocal marks a plugin that is part of the application's own
|
|
// repository: it has no release to update.
|
|
const sourceLocal = "local"
|
|
|
|
// UpdateOptions are the inputs of summer plugin:update.
|
|
type UpdateOptions struct {
|
|
// StartDir is a directory inside the application.
|
|
StartDir string
|
|
// ID names one plugin; empty updates every plugin that resolves.
|
|
ID string
|
|
// Yes skips the confirmation prompt.
|
|
Yes bool
|
|
// Offline makes no registry call: only submodule installs are
|
|
// updated, from their own tags.
|
|
Offline bool
|
|
// Interactive reports that Confirm can ask a person.
|
|
Interactive bool
|
|
// Registry answers release lookups; nil when registry calls are
|
|
// disabled.
|
|
Registry Registry
|
|
// Confirm asks the question and returns the answer.
|
|
Confirm func(question string) (bool, error)
|
|
// Out receives the plan and progress lines; nil discards them.
|
|
Out io.Writer
|
|
}
|
|
|
|
// pluginSource is how a summer.yaml plugin is installed.
|
|
type pluginSource struct {
|
|
// Kind is ModeModule, ModeSubmodule or sourceLocal.
|
|
Kind string
|
|
// Module is the module providing the package ("" for a package of the
|
|
// application module).
|
|
Module string
|
|
// Version is the module version (module), the base release tag of
|
|
// git describe (submodule) or "".
|
|
Version string
|
|
// Dir is the module root of a submodule or local plugin.
|
|
Dir string
|
|
}
|
|
|
|
// installedSource tells how the plugin package pkg is installed, from the
|
|
// app's go.mod and go.work without any network: the module that provides
|
|
// it (the longest go.mod require that prefixes it, or a go.work use
|
|
// module), whether a replace points it at a directory, and whether that
|
|
// directory is its own git repository (a submodule).
|
|
func installedSource(ctx context.Context, appDir, pkg string) (pluginSource, error) {
|
|
gomod, err := editJSON(ctx, appDir, "mod", "edit", "-json", filepath.Join(appDir, "go.mod"))
|
|
if err != nil {
|
|
return pluginSource{}, fmt.Errorf("build: %w", err)
|
|
}
|
|
var src pluginSource
|
|
for _, r := range gomod.Require {
|
|
if (pkg == r.Path || strings.HasPrefix(pkg, r.Path+"/")) && len(r.Path) > len(src.Module) {
|
|
src.Module, src.Version = r.Path, r.Version
|
|
}
|
|
}
|
|
var work goModJSON
|
|
workDir := ""
|
|
if gw := findGoWork(appDir); gw != "" {
|
|
if w, err := editJSON(ctx, appDir, "work", "edit", "-json", gw); err == nil {
|
|
work, workDir = w, filepath.Dir(gw)
|
|
}
|
|
}
|
|
if src.Module == "" {
|
|
// A module only go.work uses, or a package of the app itself.
|
|
for _, u := range work.Use {
|
|
dir := u.DiskPath
|
|
if !filepath.IsAbs(dir) {
|
|
dir = filepath.Join(workDir, dir)
|
|
}
|
|
mod, err := readModulePath(filepath.Join(dir, "go.mod"))
|
|
if err != nil || samePath(dir, appDir) {
|
|
continue
|
|
}
|
|
if (pkg == mod || strings.HasPrefix(pkg, mod+"/")) && len(mod) > len(src.Module) {
|
|
src.Module, src.Dir = mod, filepath.Clean(dir)
|
|
}
|
|
}
|
|
if src.Module == "" {
|
|
return pluginSource{Kind: sourceLocal, Dir: appDir}, nil
|
|
}
|
|
}
|
|
apply := func(replaces []struct {
|
|
Old struct{ Path string }
|
|
New struct{ Path, Version string }
|
|
}, base string) {
|
|
for _, r := range replaces {
|
|
if r.Old.Path != src.Module {
|
|
continue
|
|
}
|
|
if r.New.Version != "" {
|
|
src.Version, src.Dir = r.New.Version, ""
|
|
continue
|
|
}
|
|
src.Dir = r.New.Path
|
|
if !filepath.IsAbs(src.Dir) {
|
|
src.Dir = filepath.Join(base, src.Dir)
|
|
}
|
|
src.Dir = filepath.Clean(src.Dir)
|
|
}
|
|
}
|
|
apply(gomod.Replace, appDir)
|
|
apply(work.Replace, workDir)
|
|
if src.Dir == "" {
|
|
src.Kind = ModeModule
|
|
if !kiosk.ValidVersion(src.Version) {
|
|
src.Version = ""
|
|
}
|
|
return src, nil
|
|
}
|
|
if !isOwnRepository(ctx, src.Dir) {
|
|
src.Kind, src.Version = sourceLocal, ""
|
|
return src, nil
|
|
}
|
|
src.Kind = ModeSubmodule
|
|
src.Version = kiosk.BaseVersion(gitDescribe(ctx, src.Dir))
|
|
return src, nil
|
|
}
|
|
|
|
// updateStep is one plugin plugin:update moves.
|
|
type updateStep struct {
|
|
ID, Package string
|
|
Src pluginSource
|
|
To kiosk.RegistryVersion
|
|
// Newer names a release above To that needs a newer framework.
|
|
Newer string
|
|
}
|
|
|
|
// updater carries the state of one Update run.
|
|
type updater struct {
|
|
ctx context.Context
|
|
opts UpdateOptions
|
|
out io.Writer
|
|
appDir string
|
|
fw string
|
|
|
|
steps []updateStep
|
|
notes []string // "id: why it is not updated"
|
|
failed []string // IDs whose update could not be resolved or applied
|
|
reasons map[string]string
|
|
}
|
|
|
|
// Update moves installed plugins to the newest release the application's
|
|
// framework version allows (D-10): a module install with go get, a
|
|
// submodule install by checking its repository out at the release tag and
|
|
// requiring that tag in go.mod, followed by the go.work overrides. A
|
|
// listed plugin's releases come from the registry; a submodule the
|
|
// registry does not list (a git URL install) is updated from its own tags
|
|
// and their plugin.yaml. Without opts.ID every plugin in summer.yaml that
|
|
// resolves is updated; in-app plugins, unlisted module installs and
|
|
// plugins whose newer releases need a newer framework are reported and
|
|
// skipped. The plan is printed and confirmed like plugin:install. The run
|
|
// fails when an update that resolved could not be carried out, or when
|
|
// the one plugin asked for cannot be updated.
|
|
func Update(ctx context.Context, opts UpdateOptions) error {
|
|
appDir, err := findAppDir(opts.StartDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m, err := LoadManifest(filepath.Join(appDir, manifestName))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
u := &updater{ctx: ctx, opts: opts, out: opts.Out, appDir: appDir, reasons: map[string]string{}}
|
|
if u.out == nil {
|
|
u.out = io.Discard
|
|
}
|
|
fw, err := EffectiveFramework(ctx, appDir)
|
|
if err != nil {
|
|
if fw == "" {
|
|
return err
|
|
}
|
|
fmt.Fprintf(u.out, "warning: %v\n", err)
|
|
}
|
|
u.fw = fw
|
|
|
|
targets := m.Plugins
|
|
if opts.ID != "" {
|
|
targets = nil
|
|
for _, p := range m.Plugins {
|
|
if p.ID == opts.ID {
|
|
targets = []Plugin{p}
|
|
}
|
|
}
|
|
if targets == nil {
|
|
return bonfire.WithHint(fmt.Errorf("build: %s is not in summer.yaml", opts.ID),
|
|
"plugin:update moves plugins the application already has; add a new one with plugin:install.",
|
|
"{bin} plugin:install "+opts.ID)
|
|
}
|
|
}
|
|
installed := map[string]pluginSource{}
|
|
seenDir := map[string]string{}
|
|
for _, p := range targets {
|
|
src, err := installedSource(ctx, appDir, p.Module)
|
|
if err != nil {
|
|
u.fail(p.ID, err.Error())
|
|
continue
|
|
}
|
|
installed[p.ID] = src
|
|
switch src.Kind {
|
|
case sourceLocal:
|
|
u.note(p.ID, "part of the application's repository; it has no releases")
|
|
case ModeModule:
|
|
u.planModule(p, src)
|
|
case ModeSubmodule:
|
|
if first, ok := seenDir[src.Dir]; ok {
|
|
u.note(p.ID, "ships in the same repository as "+first+" and moves with it")
|
|
continue
|
|
}
|
|
seenDir[src.Dir] = p.ID
|
|
u.planSubmodule(p, src)
|
|
}
|
|
}
|
|
u.checkRequires(m, installed)
|
|
u.report()
|
|
|
|
if opts.ID != "" && len(u.steps) == 0 {
|
|
if why, ok := u.reasons[opts.ID]; ok && len(u.failed) > 0 {
|
|
return fmt.Errorf("build: cannot update %s: %s", opts.ID, why)
|
|
}
|
|
return nil
|
|
}
|
|
if len(u.steps) == 0 {
|
|
return u.failure()
|
|
}
|
|
n := len(u.steps)
|
|
noun := "plugin"
|
|
if n != 1 {
|
|
noun = "plugins"
|
|
}
|
|
if err := askToProceed(opts.Yes, opts.Interactive, opts.Confirm, fmt.Sprintf("Update %d %s?", n, noun)); err != nil {
|
|
return err
|
|
}
|
|
if err := u.apply(); err != nil {
|
|
return err
|
|
}
|
|
return u.failure()
|
|
}
|
|
|
|
func (u *updater) note(id, why string) {
|
|
u.notes = append(u.notes, id+": "+why)
|
|
u.reasons[id] = why
|
|
}
|
|
|
|
func (u *updater) fail(id, why string) {
|
|
u.note(id, "failed: "+why)
|
|
u.failed = append(u.failed, id)
|
|
}
|
|
|
|
func (u *updater) failure() error {
|
|
if len(u.failed) == 0 {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("build: plugin:update could not update %s", strings.Join(u.failed, ", "))
|
|
}
|
|
|
|
// registryReason says why the registry cannot be asked, or "".
|
|
func (u *updater) registryReason() string {
|
|
switch {
|
|
case u.opts.Offline:
|
|
return "--offline makes no registry calls"
|
|
case u.opts.Registry == nil:
|
|
return "the plugin registry is disabled (plugins.registry_url is empty)"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// pick chooses the newest release above have whose framework minimum the
|
|
// app satisfies, with a note naming a still newer release that needs a
|
|
// newer framework. When there is none it returns only a note: the newer
|
|
// release that needs a newer framework, or that have is the newest.
|
|
func (u *updater) pick(versions []kiosk.RegistryVersion, have string) (kiosk.RegistryVersion, string) {
|
|
base := have
|
|
if base == "" {
|
|
base = "v0.0.0"
|
|
}
|
|
var newer []kiosk.RegistryVersion
|
|
for _, v := range versions {
|
|
if kiosk.ValidVersion(v.Version) && kiosk.CompareVersions(v.Version, base) > 0 {
|
|
newer = append(newer, v)
|
|
}
|
|
}
|
|
if best, ok := kiosk.NewestCompatible(newer, u.fw); ok {
|
|
if n := newestOf(newer); kiosk.CompareVersions(n.Version, best.Version) > 0 {
|
|
return best, fmt.Sprintf("%s needs framework %s", n.Version, n.Framework)
|
|
}
|
|
return best, ""
|
|
}
|
|
if len(newer) > 0 {
|
|
n := newestOf(newer)
|
|
return kiosk.RegistryVersion{}, fmt.Sprintf("stays at %s; %s needs framework %s (this application builds against %s)", displayRef(have), n.Version, n.Framework, displayVersion(u.fw))
|
|
}
|
|
return kiosk.RegistryVersion{}, "up to date at " + displayRef(have)
|
|
}
|
|
|
|
// planModule plans a module install's update from the registry.
|
|
func (u *updater) planModule(p Plugin, src pluginSource) {
|
|
if why := u.registryReason(); why != "" {
|
|
u.note(p.ID, "installed as a Go module, and "+why)
|
|
return
|
|
}
|
|
versions, err := u.opts.Registry.Versions(u.ctx, p.ID)
|
|
if errors.Is(err, kiosk.ErrNotFound) {
|
|
u.note(p.ID, "not in the registry")
|
|
return
|
|
}
|
|
if err != nil {
|
|
u.fail(p.ID, err.Error())
|
|
return
|
|
}
|
|
var same []kiosk.RegistryVersion
|
|
for _, v := range versions {
|
|
if v.Module == src.Module {
|
|
same = append(same, v)
|
|
}
|
|
}
|
|
to, why := u.pick(same, src.Version)
|
|
if to.Version == "" {
|
|
u.note(p.ID, why)
|
|
return
|
|
}
|
|
step := InstallStep{ID: p.ID, Version: to.Version, Module: to.Module, Package: to.Package, Mode: ModeModule}
|
|
if err := validateStep(step); err != nil {
|
|
u.fail(p.ID, err.Error())
|
|
return
|
|
}
|
|
u.steps = append(u.steps, updateStep{ID: p.ID, Package: p.Module, Src: src, To: to, Newer: why})
|
|
}
|
|
|
|
// planSubmodule plans a submodule install's update: the registry's
|
|
// releases when it lists the plugin, otherwise the repository's own tags.
|
|
func (u *updater) planSubmodule(p Plugin, src pluginSource) {
|
|
if _, err := runCLIGit(u.ctx, src.Dir, u.opts.Interactive, "fetch", "--quiet", "--tags"); err != nil {
|
|
u.fail(p.ID, err.Error())
|
|
return
|
|
}
|
|
rel := strings.TrimPrefix(strings.TrimPrefix(p.Module, src.Module), "/")
|
|
var to kiosk.RegistryVersion
|
|
why := ""
|
|
fromRegistry := false
|
|
if u.registryReason() == "" {
|
|
versions, err := u.opts.Registry.Versions(u.ctx, p.ID)
|
|
switch {
|
|
case errors.Is(err, kiosk.ErrNotFound):
|
|
case err != nil:
|
|
u.fail(p.ID, err.Error())
|
|
return
|
|
default:
|
|
fromRegistry = true
|
|
var same []kiosk.RegistryVersion
|
|
for _, v := range versions {
|
|
if v.Module == src.Module {
|
|
same = append(same, v)
|
|
}
|
|
}
|
|
to, why = u.pick(same, src.Version)
|
|
}
|
|
}
|
|
if !fromRegistry {
|
|
tags, err := releaseTags(u.ctx, src.Dir)
|
|
if err != nil {
|
|
u.fail(p.ID, err.Error())
|
|
return
|
|
}
|
|
var own []kiosk.RegistryVersion
|
|
for _, tag := range tags {
|
|
if src.Version != "" && kiosk.CompareVersions(tag, src.Version) <= 0 {
|
|
continue
|
|
}
|
|
pm, err := manifestAt(u.ctx, src.Dir, tag, rel)
|
|
if err != nil || pm == nil || pm.ID != p.ID {
|
|
continue
|
|
}
|
|
own = append(own, kiosk.RegistryVersion{Version: tag, Framework: pm.Framework, Module: pm.Module, Package: pm.Package, Requires: pm.Requires})
|
|
}
|
|
to, why = u.pick(own, src.Version)
|
|
}
|
|
if to.Version == "" {
|
|
u.note(p.ID, why)
|
|
return
|
|
}
|
|
if _, err := runCLIGit(u.ctx, src.Dir, false, "rev-parse", "--verify", "--quiet", "refs/tags/"+to.Version+"^{commit}"); err != nil {
|
|
u.fail(p.ID, fmt.Sprintf("the registry lists %s, but its repository has no tag %s", to.Version, to.Version))
|
|
return
|
|
}
|
|
pm, err := manifestAt(u.ctx, src.Dir, to.Version, rel)
|
|
if err != nil {
|
|
u.fail(p.ID, err.Error())
|
|
return
|
|
}
|
|
if pm != nil && (pm.ID != p.ID || pm.Module != src.Module) {
|
|
u.fail(p.ID, fmt.Sprintf("%s at %s describes %s (%s); refused", kiosk.FileName, to.Version, pm.ID, pm.Module))
|
|
return
|
|
}
|
|
if pm != nil && to.Requires == nil {
|
|
to.Requires = pm.Requires
|
|
}
|
|
u.steps = append(u.steps, updateStep{ID: p.ID, Package: p.Module, Src: src, To: to, Newer: why})
|
|
}
|
|
|
|
// checkRequires drops planned updates whose new release requires a plugin
|
|
// the application lacks, or at a version it will not have after this run.
|
|
func (u *updater) checkRequires(m Manifest, installed map[string]pluginSource) {
|
|
final := map[string]string{}
|
|
listed := map[string]string{}
|
|
for _, p := range m.Plugins {
|
|
listed[p.ID] = p.Module
|
|
}
|
|
for id, src := range installed {
|
|
final[id] = src.Version
|
|
}
|
|
for _, s := range u.steps {
|
|
final[s.ID] = s.To.Version
|
|
}
|
|
kept := u.steps[:0]
|
|
for _, s := range u.steps {
|
|
why := ""
|
|
for _, dep := range sortedKeys(s.To.Requires) {
|
|
min := s.To.Requires[dep]
|
|
if _, ok := listed[dep]; !ok {
|
|
why = fmt.Sprintf("%s requires %s, which is not installed; run summer plugin:install %s first", s.To.Version, dep, dep)
|
|
break
|
|
}
|
|
have, known := final[dep]
|
|
if !known {
|
|
src, err := installedSource(u.ctx, u.appDir, listed[dep])
|
|
if err == nil {
|
|
have = src.Version
|
|
}
|
|
}
|
|
if have != "" && !kiosk.Satisfies(have, min) {
|
|
why = fmt.Sprintf("%s needs %s %s or newer, and the application has %s", s.To.Version, dep, min, have)
|
|
break
|
|
}
|
|
}
|
|
if why != "" {
|
|
u.note(s.ID, "stays at "+displayRef(s.Src.Version)+"; "+why)
|
|
continue
|
|
}
|
|
kept = append(kept, s)
|
|
}
|
|
u.steps = kept
|
|
}
|
|
|
|
// report prints the planned updates and the plugins left as they are.
|
|
func (u *updater) report() {
|
|
if n := len(u.steps); n > 0 {
|
|
noun := "plugin"
|
|
if n != 1 {
|
|
noun = "plugins"
|
|
}
|
|
fmt.Fprintf(u.out, "Updating %d %s in %s (framework %s):\n", n, noun, u.appDir, displayVersion(u.fw))
|
|
for _, s := range u.steps {
|
|
line := fmt.Sprintf(" %s %s -> %s (%s)", s.ID, displayRef(s.Src.Version), s.To.Version, s.Src.Kind)
|
|
if s.Newer != "" {
|
|
line += "; " + s.Newer
|
|
}
|
|
fmt.Fprintln(u.out, line)
|
|
}
|
|
} else {
|
|
fmt.Fprintln(u.out, "Nothing to update.")
|
|
}
|
|
if len(u.notes) > 0 {
|
|
fmt.Fprintln(u.out, "Not updated:")
|
|
for _, n := range u.notes {
|
|
fmt.Fprintf(u.out, " %s\n", n)
|
|
}
|
|
}
|
|
}
|
|
|
|
// apply checks submodules out at their new tags, requires the tags in
|
|
// go.mod, runs one go get for the module updates and refreshes the
|
|
// go.work overrides.
|
|
func (u *updater) apply() error {
|
|
var getArgs []string
|
|
for _, s := range u.steps {
|
|
switch s.Src.Kind {
|
|
case ModeModule:
|
|
getArgs = append(getArgs, s.Src.Module+"@"+s.To.Version)
|
|
case ModeSubmodule:
|
|
if err := checkout(u.ctx, s.Src.Dir, s.To.Version); err != nil {
|
|
return err
|
|
}
|
|
if err := runGo(u.ctx, u.appDir, "mod", "edit", "-require="+s.Src.Module+"@"+s.To.Version); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(u.out, "Checked out %s at %s\n", s.ID, s.To.Version)
|
|
}
|
|
}
|
|
if len(getArgs) > 0 {
|
|
fmt.Fprintf(u.out, "go get %s\n", strings.Join(getArgs, " "))
|
|
if err := runGo(u.ctx, u.appDir, append([]string{"get", "--"}, getArgs...)...); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := refreshWorkOverrides(u.ctx, u.appDir, u.out); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintln(u.out, "Updated; run summer build")
|
|
return nil
|
|
}
|