Files
summercms/internal/build/install.go
Jakub Zych 9977e31ac9 fix(34): WR-02 undo a failed plugin:install or plugin:update
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.
2026-10-29 22:08:54 +01:00

746 lines
24 KiB
Go

package build
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/kiosk"
)
// Install modes of an InstallStep.
const (
// ModeModule installs the plugin as a Go module with go get.
ModeModule = "module"
// ModeSubmodule installs the plugin as a git submodule under
// plugins/<vendor>/<name>.
ModeSubmodule = "submodule"
)
// ErrCancelled is returned by PlanInstall and Update when the user
// declined the confirmation prompt; nothing was changed.
var ErrCancelled = errors.New("cancelled; nothing was changed")
// Registry is the part of the registry API v1 the installer uses.
// kiosk.Client implements it; a nil Registry means registry calls are
// disabled.
type Registry interface {
List(ctx context.Context, q, category string) (kiosk.ListResponse, error)
Plugin(ctx context.Context, id string) (kiosk.RegistryPluginDetail, error)
Versions(ctx context.Context, id string) ([]kiosk.RegistryVersion, error)
}
// InstallOptions are the inputs of summer plugin:install.
type InstallOptions struct {
// StartDir is a directory inside the application.
StartDir string
// Arg is vendor.name, vendor.name@vX.Y.Z or a git URL.
Arg string
// Source installs registry plugins as git submodules.
Source bool
// Yes skips the confirmation prompt.
Yes bool
// Offline makes no registry call.
Offline bool
// Registry answers ID lookups; nil when registry calls are disabled.
Registry Registry
// Interactive reports that Confirm can ask a person.
Interactive bool
// 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
}
// InstallPlan is what PlanInstall resolved and ApplyInstall carries out.
type InstallPlan struct {
AppDir string
Framework string
// Steps are in dependency order: a plugin's requirements come first.
Steps []InstallStep
// temps are checkouts under plugins/ made while planning or applying;
// whatever is left of them is removed at the end.
temps []string
interactive bool
// snap is what ApplyInstall restores when it fails.
snap *appSnapshot
}
// InstallStep is one plugin to add.
type InstallStep struct {
ID string
Version string
Module string
Package string
Repository string
Mode string
// clone is the checkout a git URL step was planned from.
clone string
// existing marks a plugin in a module the application already has (a
// go.mod require or a submodule checkout): it is installed at that
// version and ApplyInstall fetches nothing for it, only adds its
// summer.yaml entry (CR-02).
existing bool
}
// installer carries the state of one PlanInstall run.
type installer struct {
ctx context.Context
opts InstallOptions
out io.Writer
appDir string
manifest Manifest
fw string
plan *InstallPlan
// planned holds the steps chosen so far by ID; visiting is the chain
// of IDs being resolved, for cycle reports; moduleVersion pins the
// version of a module once one of its plugins is chosen, so plugins
// that share a module install at one version.
planned map[string]InstallStep
visiting []string
moduleVersion map[string]string
// installed holds the modules the application already has, by module
// path: a plugin in one of them installs at the version the
// application has, never moving it.
installed map[string]installedModule
// source installs registry plugins as submodules: --source, or the
// requirements of a git URL install.
source bool
}
// PlanInstall resolves what plugin:install would add: the requested
// plugin at the newest release whose framework minimum the application's
// effective framework version satisfies (or the pinned release), and,
// before it, every required plugin the application lacks, each at the
// newest compatible release at or above its minimum. It checks every
// registry string, prints the additions and asks for confirmation
// unless opts.Yes. A non-interactive run without opts.Yes is an error
// naming --yes; a declined prompt returns ErrCancelled. It changes
// nothing in the application.
func PlanInstall(ctx context.Context, opts InstallOptions) (*InstallPlan, error) {
appDir, err := findAppDir(opts.StartDir)
if err != nil {
return nil, err
}
m, err := LoadManifest(filepath.Join(appDir, manifestName))
if err != nil {
return nil, err
}
in := &installer{ctx: ctx, opts: opts, out: opts.Out, appDir: appDir, manifest: m}
if in.out == nil {
in.out = io.Discard
}
fw, err := EffectiveFramework(ctx, appDir)
if err != nil {
if fw == "" {
return nil, err
}
fmt.Fprintf(in.out, "warning: %v\n", err)
}
in.fw = fw
in.plan = &InstallPlan{AppDir: appDir, Framework: fw}
in.planned = map[string]InstallStep{}
in.moduleVersion = map[string]string{}
if in.installed, err = installedModules(ctx, appDir, m); err != nil {
return nil, err
}
in.plan.interactive = opts.Interactive
in.source = opts.Source
ok := false
defer func() {
if !ok {
in.plan.removeTemps()
}
}()
arg := strings.TrimSpace(opts.Arg)
if isGitURL(arg) {
in.source = true
if err := in.planGitURL(arg); err != nil {
return nil, err
}
} else {
id, pin, err := parseInstallArg(arg)
if err != nil {
return nil, err
}
if err := in.requireAbsent(id); err != nil {
return nil, err
}
if err := in.requireRegistry(id); err != nil {
return nil, err
}
if in.source {
if err := requireGitRepo(ctx, appDir); err != nil {
return nil, err
}
}
if err := in.resolve(id, pin, ""); err != nil {
return nil, err
}
}
if err := in.confirm(); err != nil {
return nil, err
}
ok = true
return in.plan, nil
}
// installedModule is a module the application already has, with the
// summer.yaml plugin that brought it.
type installedModule struct {
src pluginSource
by string
}
// release is the version a plugin of the module installs at: the go.mod
// release of a module install, the base tag of a submodule checkout; ""
// when there is none to pin (a pseudo-version, an untagged checkout, a
// local directory).
func (im installedModule) release() string {
switch im.src.Kind {
case ModeModule, ModeSubmodule:
return releaseOrEmpty(im.src.Version)
}
return ""
}
// installedModules maps every module that provides a summer.yaml plugin
// to how it is installed (see installedSource). Packages of the
// application module itself are left out.
func installedModules(ctx context.Context, appDir string, m Manifest) (map[string]installedModule, error) {
out := map[string]installedModule{}
for _, p := range m.Plugins {
src, err := installedSource(ctx, appDir, p.Module)
if err != nil {
return nil, err
}
if src.Module == "" {
continue
}
if _, ok := out[src.Module]; !ok {
out[src.Module] = installedModule{src: src, by: p.ID}
}
}
return out, nil
}
// removeTemps deletes the leftover temporary checkouts of the plan.
func (p *InstallPlan) removeTemps() {
for _, dir := range p.temps {
_ = os.RemoveAll(dir)
}
p.temps = nil
}
// parseInstallArg splits vendor.name[@vX.Y.Z].
func parseInstallArg(arg string) (id, pin string, err error) {
arg = strings.TrimSpace(arg)
id, pin, _ = strings.Cut(arg, "@")
if err := ValidatePluginID(id); err != nil {
return "", "", bonfire.WithHint(fmt.Errorf("build: %w", err),
"plugin:install takes a plugin ID (vendor.name), optionally pinned to a release with @vX.Y.Z.",
"{bin} plugin:install acme.blog@v0.1.0")
}
if strings.Contains(arg, "@") && !kiosk.ValidVersion(pin) {
return "", "", fmt.Errorf("build: %q is not a release version; pin with @vX.Y.Z", pin)
}
return id, pin, nil
}
// requireAbsent fails when summer.yaml already lists id.
func (in *installer) requireAbsent(id string) error {
for _, p := range in.manifest.Plugins {
if p.ID == id {
return bonfire.WithHint(fmt.Errorf("build: %s is already in summer.yaml (%s)", id, p.Module),
"plugin:install adds plugins the application does not have yet; move an installed plugin to a newer release with plugin:update.",
"{bin} plugin:update "+id)
}
}
return nil
}
// requireRegistry fails when an ID cannot be looked up.
func (in *installer) requireRegistry(id string) error {
if in.opts.Registry != nil && !in.opts.Offline {
return nil
}
reason := "the plugin registry is disabled (plugins.registry_url is empty)"
if in.opts.Offline {
reason = "--offline makes no registry calls"
}
return bonfire.WithHint(fmt.Errorf("build: cannot look up %s: %s", id, reason),
"Installing by plugin ID asks the registry for the plugin's releases. Install from the plugin's git repository instead, or enable the registry.",
"{bin} plugin:install <git-url>")
}
// resolve adds id and, first, every plugin it requires that the
// application does not have: the depth-first post-order puts each
// requirement before the plugins that need it. An installed requirement
// must be at least its minimum. A cycle in the registry data is an error.
func (in *installer) resolve(id, pin, min string) error {
for i, v := range in.visiting {
if v == id {
chain := append(append([]string(nil), in.visiting[i:]...), id)
return fmt.Errorf("build: dependency cycle in the registry data: %s", strings.Join(chain, " -> "))
}
}
if step, ok := in.planned[id]; ok {
if !kiosk.Satisfies(step.Version, min) {
return fmt.Errorf("build: %s needs %s %s or newer, but %s was chosen for an earlier requirement", in.parent(), id, min, step.Version)
}
return nil
}
if len(in.visiting) > 0 {
if done, err := in.installedSatisfies(id, min); done || err != nil {
return err
}
if in.opts.Registry == nil || in.opts.Offline {
return in.missingOffline(id)
}
}
step, requires, err := in.choose(id, pin, min)
if err != nil {
return err
}
in.moduleVersion[step.Module] = step.Version
in.visiting = append(in.visiting, id)
if err := in.resolveRequires(id, step.Version, requires); err != nil {
return err
}
in.visiting = in.visiting[:len(in.visiting)-1]
in.planned[id] = step
in.plan.Steps = append(in.plan.Steps, step)
return nil
}
// resolveRequires resolves the requirements of id (at version), in ID
// order.
func (in *installer) resolveRequires(id, version string, requires kiosk.Requires) error {
for _, dep := range sortedKeys(requires) {
if err := ValidatePluginID(dep); err != nil {
return fmt.Errorf("build: %s %s requires %w; refused", id, displayRef(version), err)
}
if err := in.resolve(dep, "", requires[dep]); err != nil {
return err
}
}
return nil
}
// parent names the plugin whose requirement is being resolved.
func (in *installer) parent() string {
if n := len(in.visiting); n > 0 {
return in.visiting[n-1]
}
return "the application"
}
// missingOffline is the error for a requirement that is not installed
// when the registry cannot be asked.
func (in *installer) missingOffline(id string) error {
reason := "the plugin registry is disabled (plugins.registry_url is empty)"
if in.opts.Offline {
reason = "--offline makes no registry calls"
}
return bonfire.WithHint(fmt.Errorf("build: %s requires %s, which is not installed, and %s", in.parent(), id, reason),
"Install the required plugin first, from its repository or with the registry enabled, then run this install again.",
"{bin} plugin:install "+id)
}
// installedSatisfies reports whether summer.yaml already lists id; it
// returns an error when the installed version is below min. An installed
// plugin whose version cannot be read (an in-app plugin) is accepted with
// a warning.
func (in *installer) installedSatisfies(id, min string) (bool, error) {
var pkg string
for _, p := range in.manifest.Plugins {
if p.ID == id {
pkg = p.Module
}
}
if pkg == "" {
return false, nil
}
if min == "" {
return true, nil
}
have, err := installedVersion(in.ctx, in.appDir, pkg)
if err != nil {
return true, err
}
if have == "" {
fmt.Fprintf(in.out, "warning: %s needs %s %s or newer; the installed version cannot be read, so make sure it is\n", in.parent(), id, min)
return true, nil
}
if !kiosk.Satisfies(have, min) {
return true, bonfire.WithHint(fmt.Errorf("build: %s needs %s %s or newer, but the application has %s; run summer plugin:update %s first", in.parent(), id, min, have, id),
"plugin:install does not move plugins the application already has.",
"{bin} plugin:update "+id)
}
return true, nil
}
// installedVersion returns the installed release of the plugin package
// pkg (see installedSource): the module version of a module install, the
// base tag of git describe for a submodule, "" for an in-app plugin or a
// module at a pseudo-version.
func installedVersion(ctx context.Context, appDir, pkg string) (string, error) {
src, err := installedSource(ctx, appDir, pkg)
if err != nil {
return "", err
}
return releaseOrEmpty(src.Version), nil
}
func sortedKeys(m kiosk.Requires) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// choose picks the release of id to install: the pinned one, or the
// newest whose framework minimum the app satisfies and which is at least
// min. It validates every string ApplyInstall will pass to go or git.
func (in *installer) choose(id, pin, min string) (InstallStep, kiosk.Requires, error) {
versions, err := in.opts.Registry.Versions(in.ctx, id)
if err != nil {
return InstallStep{}, nil, fmt.Errorf("build: %w", err)
}
var chosen kiosk.RegistryVersion
if pin != "" {
found := false
var available []string
for _, v := range versions {
available = append(available, v.Version)
if v.Version == pin {
chosen, found = v, true
}
}
if !found {
list := strings.Join(available, ", ")
if list == "" {
list = "none"
}
return InstallStep{}, nil, fmt.Errorf("build: %s has no release %s; available: %s", id, pin, list)
}
if !kiosk.Satisfies(in.fw, chosen.Framework) {
return InstallStep{}, nil, in.frameworkError(id, chosen)
}
if !kiosk.Satisfies(pin, min) {
return InstallStep{}, nil, fmt.Errorf("build: %s %s is older than the required minimum %s", id, pin, min)
}
if im, ok := in.installed[chosen.Module]; ok && im.release() != pin {
return InstallStep{}, nil, in.installedModuleError(id, im)
}
} else {
var eligible []kiosk.RegistryVersion
var held *installedModule
for _, v := range versions {
if !kiosk.ValidVersion(v.Version) || !kiosk.Satisfies(v.Version, min) {
continue
}
// A plugin in a module the application already has installs
// at the version it has: installing never moves the plugins
// already there.
if im, ok := in.installed[v.Module]; ok && v.Version != im.release() {
held = &im
continue
}
// A plugin in a module another chosen plugin lives in must
// come from the same release of that module.
if want, ok := in.moduleVersion[v.Module]; ok && v.Version != want {
continue
}
eligible = append(eligible, v)
}
best, ok := kiosk.NewestCompatible(eligible, in.fw)
if !ok && held != nil {
return InstallStep{}, nil, in.installedModuleError(id, *held)
}
if !ok {
if len(eligible) == 0 {
if min != "" {
return InstallStep{}, nil, fmt.Errorf("build: %s has no release at or above the required minimum %s", id, min)
}
return InstallStep{}, nil, fmt.Errorf("build: %s has no release in the registry", id)
}
return InstallStep{}, nil, in.frameworkError(id, newestOf(eligible))
}
chosen = best
}
step := InstallStep{
ID: id,
Version: chosen.Version,
Module: chosen.Module,
Package: chosen.Package,
Mode: ModeModule,
}
if im, ok := in.installed[chosen.Module]; ok {
// The module is there already, as a go.mod require or a
// checkout: nothing is fetched or cloned, whatever --source says.
step.existing, step.Mode = true, im.src.Kind
if im.src.Kind == ModeSubmodule {
rel := strings.TrimPrefix(strings.TrimPrefix(chosen.Package, chosen.Module), "/")
if !dirExists(filepath.Join(im.src.Dir, filepath.FromSlash(rel))) {
return InstallStep{}, nil, bonfire.WithHint(fmt.Errorf("build: %s lives in %s, which the application has checked out at %s (for %s), and that checkout has no package %s", id, chosen.Module, displayRef(im.src.Version), im.by, chosen.Package),
"plugin:install does not move plugins the application already has. Update the checkout with plugin:update first.",
"{bin} plugin:update "+im.by)
}
}
if err := validateStep(step); err != nil {
return InstallStep{}, nil, err
}
return step, chosen.Requires, nil
}
if in.source {
detail, err := in.opts.Registry.Plugin(in.ctx, id)
if err != nil {
return InstallStep{}, nil, fmt.Errorf("build: %w", err)
}
if err := validateRepositoryURL(id, detail.Repository); err != nil {
return InstallStep{}, nil, err
}
step.Mode, step.Repository = ModeSubmodule, detail.Repository
if dir := filepath.Join(in.appDir, "plugins", pluginVendor(id), pluginName(id)); dirExists(dir) {
return InstallStep{}, nil, fmt.Errorf("build: %s already exists; remove it or register it with plugin:add", dir)
}
}
if err := validateStep(step); err != nil {
return InstallStep{}, nil, err
}
return step, chosen.Requires, nil
}
// installedModuleError is the error for a plugin whose module the
// application already has, when no release of the plugin matches the
// version the application has.
func (in *installer) installedModuleError(id string, im installedModule) error {
have := im.release()
why := "the registry has no release of " + id + " at " + have
if have == "" {
why = "that version is not a release plugin:install can pin"
have = displayVersion(im.src.Version)
if im.src.Kind == sourceLocal {
have = "a local directory"
}
}
return bonfire.WithHint(fmt.Errorf("build: %s lives in %s, which the application already has at %s (for %s), and %s", id, im.src.Module, have, im.by, why),
"plugin:install does not move plugins the application already has. Move "+im.by+" to a release that ships "+id+" with plugin:update, then install it.",
"{bin} plugin:update "+im.by)
}
// frameworkError names the framework release v needs.
func (in *installer) frameworkError(id string, v kiosk.RegistryVersion) error {
have := in.fw
if have == "" {
have = "unknown"
}
return bonfire.WithHint(fmt.Errorf("build: %s %s needs framework %s or newer; this application builds against %s", id, v.Version, v.Framework, have),
"plugin:install never moves the framework. Update the framework requirement in go.mod (or the local framework checkout) to "+v.Framework+" first, or pin an older release of the plugin.",
"go get "+frameworkModule+"@"+v.Framework)
}
// newestOf returns the newest valid release in versions.
func newestOf(versions []kiosk.RegistryVersion) kiosk.RegistryVersion {
var best kiosk.RegistryVersion
for _, v := range versions {
if !kiosk.ValidVersion(v.Version) {
continue
}
if best.Version == "" || kiosk.CompareVersions(v.Version, best.Version) > 0 {
best = v
}
}
return best
}
// validateStep refuses registry strings that are not safe to hand to go:
// the version must be a strict vX.Y.Z and the module and package must be
// plain module paths, the package inside the module.
func validateStep(s InstallStep) error {
// A git URL install without a fitting release tag takes the default
// branch head, which has no version.
headOfClone := s.Version == "" && s.clone != ""
if !kiosk.ValidVersion(s.Version) && !headOfClone {
return fmt.Errorf("build: registry entry %s: version %q is not a vX.Y.Z release; refused", s.ID, s.Version)
}
if err := validateModulePath(s.Module); err != nil {
return fmt.Errorf("build: registry entry %s: module %w; refused", s.ID, err)
}
if err := validateModulePath(s.Package); err != nil {
return fmt.Errorf("build: registry entry %s: package %w; refused", s.ID, err)
}
if s.Package != s.Module && !strings.HasPrefix(s.Package, s.Module+"/") {
return fmt.Errorf("build: registry entry %s: package %s is not inside module %s; refused", s.ID, s.Package, s.Module)
}
switch s.Mode {
case ModeModule:
case ModeSubmodule:
if s.existing {
break
}
if err := validateGitURL(s.Repository); err != nil {
return fmt.Errorf("build: %s: %w; refused", s.ID, err)
}
default:
return fmt.Errorf("build: %s: unknown install mode %q", s.ID, s.Mode)
}
return nil
}
// summary renders one line per step.
func (p *InstallPlan) summary() string {
var b strings.Builder
for _, s := range p.Steps {
mode := s.Mode
if s.existing {
mode += ", module already in the application"
}
fmt.Fprintf(&b, " %s@%s (%s)\n", s.ID, displayRef(s.Version), mode)
}
return b.String()
}
// confirm prints the plan and asks for confirmation unless --yes.
func (in *installer) confirm() error {
n := len(in.plan.Steps)
noun := "plugin"
if n != 1 {
noun = "plugins"
}
fmt.Fprintf(in.out, "Installing %d %s into %s (framework %s):\n%s", n, noun, in.appDir, displayVersion(in.fw), in.plan.summary())
return askToProceed(in.opts.Yes, in.opts.Interactive, in.opts.Confirm, fmt.Sprintf("Install %d %s?", n, noun))
}
// askToProceed is the prompt rule of plugin:install and plugin:update
// (research F4): --yes proceeds, an interactive run asks with "no" as the
// default, and a non-interactive run without --yes is an error.
func askToProceed(yes, interactive bool, confirm func(string) (bool, error), question string) error {
if yes {
return nil
}
if !interactive || confirm == nil {
return bonfire.WithHint(errors.New("build: this run cannot ask for confirmation; pass --yes to proceed"),
"Every addition is printed above. A run without a terminal (or with -n or -q) needs --yes to go ahead.",
"{bin} "+cliInvocation()+" --yes")
}
ok, err := confirm(question)
if err != nil {
return err
}
if !ok {
return ErrCancelled
}
return nil
}
func dirExists(dir string) bool {
_, err := os.Stat(dir)
return err == nil
}
func displayVersion(v string) string {
if v == "" {
return "unknown"
}
return v
}
// ApplyInstall carries out a plan from PlanInstall. Submodule steps are
// cloned at their tag, added as git submodules under
// plugins/<vendor>/<name> and registered in go.mod (require at the tag
// plus a local replace) and go.work, followed by the go.work replace
// overrides the workspace needs. Module steps go through one go get, so
// minimal version selection sees the whole set. The summer.yaml entries
// are appended last, in plan order. It does not run go mod tidy: until
// summer build imports the new packages, tidy would drop them. When a
// step fails, the files it edits (go.mod, go.sum, go.work, summer.yaml,
// .gitmodules) are restored and the submodules this run added are
// removed again; what cannot be undone is named in the error.
func ApplyInstall(ctx context.Context, plan *InstallPlan, out io.Writer) error {
if plan == nil || len(plan.Steps) == 0 {
return fmt.Errorf("build: nothing to install")
}
defer plan.removeTemps()
if out == nil {
out = io.Discard
}
var getArgs []string
var entries []Plugin
submodules := false
for _, s := range plan.Steps {
if err := validateStep(s); err != nil {
return err
}
entries = append(entries, Plugin{ID: s.ID, Module: s.Package})
if s.existing {
continue
}
switch s.Mode {
case ModeModule:
if !slices.Contains(getArgs, s.Module+"@"+s.Version) {
getArgs = append(getArgs, s.Module+"@"+s.Version)
}
case ModeSubmodule:
submodules = true
}
}
snap, err := snapshotApp(ctx, plan.AppDir)
if err != nil {
return err
}
plan.snap = snap
// A failure from here on puts the application back (WR-02).
fail := func(err error) error { return snap.undo(ctx, out, err) }
if submodules {
if err := requireGitRepo(ctx, plan.AppDir); err != nil {
return err
}
done := map[string]bool{}
for i := range plan.Steps {
s := &plan.Steps[i]
if s.Mode != ModeSubmodule || s.existing || done[s.Module] {
continue
}
if err := applySubmodule(ctx, plan, s, plan.interactive, out); err != nil {
return fail(err)
}
done[s.Module] = true
}
if err := refreshWorkOverrides(ctx, plan.AppDir, out); err != nil {
return fail(err)
}
}
if len(getArgs) > 0 {
fmt.Fprintf(out, "go get %s\n", strings.Join(getArgs, " "))
if err := runGo(ctx, plan.AppDir, append([]string{"get", "--"}, getArgs...)...); err != nil {
return fail(err)
}
}
if err := appendManifestPlugins(filepath.Join(plan.AppDir, manifestName), entries); err != nil {
return fail(err)
}
ids := make([]string, len(plan.Steps))
for i, s := range plan.Steps {
ids[i] = s.ID + "@" + displayRef(s.Version)
}
fmt.Fprintf(out, "Installed %s; run summer build\n", strings.Join(ids, ", "))
return nil
}