A failure after the first change left the application half-modified: a checkout moved into plugins/<vendor>/<name> but not registered, go.mod edited, submodules without summer.yaml entries (and the next install refused the leftover directory), or some submodules updated and others not. Both commands now snapshot go.mod, go.sum, go.work, go.work.sum, summer.yaml and .gitmodules (with its index entry) before applying; a failure restores them, removes the submodules the run added (index entry, submodule config, absorbed git dir, checkout; registered right after the move) and checks moved submodules out where they were. What cannot be undone is named in the error.
541 lines
16 KiB
Go
541 lines
16 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 go.mod module version (module: a release or a
|
|
// pseudo-version, see kiosk.Newer), 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 == "" {
|
|
// The version stays as go.mod has it: a pseudo-version is a commit
|
|
// after a release, and only kiosk.Newer may compare it (CR-01).
|
|
src.Kind = ModeModule
|
|
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
|
|
}
|
|
|
|
// releaseOrEmpty returns v when it is a vX.Y.Z release and "" otherwise:
|
|
// a requirement check accepts a version it cannot read (a pseudo-version)
|
|
// rather than refuse it.
|
|
func releaseOrEmpty(v string) string {
|
|
if kiosk.ValidVersion(v) {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// 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 (kiosk.Newer: never at or
|
|
// below the release a pseudo-version or describe string builds on) whose
|
|
// framework minimum the app satisfies, with a note naming a still newer
|
|
// release that needs a newer framework. An empty have (a submodule with no
|
|
// release tag behind its commit) takes any release. 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) {
|
|
var newer []kiosk.RegistryVersion
|
|
for _, v := range versions {
|
|
if kiosk.ValidVersion(v.Version) && (have == "" || kiosk.Newer(v.Version, have)) {
|
|
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
|
|
}
|
|
if !kiosk.Comparable(src.Version) {
|
|
// A commit with no release tag before it, or a pre-release: no
|
|
// release is known to be newer, and guessing could go back.
|
|
u.note(p.ID, fmt.Sprintf("installed at %s, which is not after a release; plugin:update does not guess, so pin a release with go get %s@<tag>", displayVersion(src.Version), src.Module))
|
|
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] = releaseOrEmpty(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 = releaseOrEmpty(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. A failure checks the submodules out where they were
|
|
// and restores go.mod, go.sum and go.work.
|
|
func (u *updater) apply() error {
|
|
snap, err := snapshotApp(u.ctx, u.appDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// A failure puts every checkout and file back (WR-02).
|
|
fail := func(err error) error { return snap.undo(u.ctx, u.out, err) }
|
|
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 := snap.movingCheckout(u.ctx, s.Src.Dir); err != nil {
|
|
return fail(err)
|
|
}
|
|
if err := checkout(u.ctx, s.Src.Dir, s.To.Version); err != nil {
|
|
return fail(err)
|
|
}
|
|
if err := runGo(u.ctx, u.appDir, "mod", "edit", "-require="+s.Src.Module+"@"+s.To.Version); err != nil {
|
|
return fail(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 fail(err)
|
|
}
|
|
}
|
|
if err := refreshWorkOverrides(u.ctx, u.appDir, u.out); err != nil {
|
|
return fail(err)
|
|
}
|
|
fmt.Fprintln(u.out, "Updated; run summer build")
|
|
return nil
|
|
}
|