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@", 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. 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 }