feat(build): summer plugin:install for registry plugins as Go modules
- kiosk.Client reads the registry API v1 over fetchguard (trusted mode, 4 MiB, 10 s, no redirects); kiosk.RegistryURL reads plugins.registry_url (default https://summercms.io, empty disables) - build.PlanInstall/ApplyInstall pick the newest release the effective framework satisfies (or a pin), validate registry strings, go get it and append it to summer.yaml - summer.yaml edits (plugin:add and plugin:install) keep comments and blank lines - TestInstallSmoke/module installs from a file:// GOPROXY and builds
This commit is contained in:
266
internal/build/fixtures_test.go
Normal file
266
internal/build/fixtures_test.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package build
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/kiosk"
|
||||
)
|
||||
|
||||
// writeProxyModule adds modPath@version to the file:// GOPROXY tree at
|
||||
// proxyDir: @v/list, .info, .mod (files["go.mod"]) and .zip, whose
|
||||
// entries are prefixed <module>@<version>/ as the module zip format
|
||||
// requires. Module paths in fixtures are lower case, so no escaping is
|
||||
// needed.
|
||||
func writeProxyModule(t *testing.T, proxyDir, modPath, version string, files map[string]string) {
|
||||
t.Helper()
|
||||
if strings.ToLower(modPath) != modPath {
|
||||
t.Fatalf("fixture module path %q must be lower case", modPath)
|
||||
}
|
||||
gomod, ok := files["go.mod"]
|
||||
if !ok {
|
||||
t.Fatalf("fixture %s@%s has no go.mod", modPath, version)
|
||||
}
|
||||
dir := filepath.Join(proxyDir, filepath.FromSlash(modPath), "@v")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, _ := os.ReadFile(filepath.Join(dir, "list"))
|
||||
if !strings.Contains("\n"+string(list), "\n"+version+"\n") {
|
||||
list = append(list, []byte(version+"\n")...)
|
||||
}
|
||||
write(t, filepath.Join(dir, "list"), string(list))
|
||||
info, _ := json.Marshal(map[string]string{"Version": version, "Time": "2026-10-01T00:00:00Z"})
|
||||
write(t, filepath.Join(dir, version+".info"), string(info))
|
||||
write(t, filepath.Join(dir, version+".mod"), gomod)
|
||||
|
||||
f, err := os.Create(filepath.Join(dir, version+".zip"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zw := zip.NewWriter(f)
|
||||
names := make([]string, 0, len(files))
|
||||
for name := range files {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
w, err := zw.Create(modPath + "@" + version + "/" + name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write([]byte(files[name])); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// fixturePluginFiles is a compiling plugin package with the given ID in
|
||||
// a module that requires the framework at framework.
|
||||
func fixturePluginFiles(modPath, pkg, id, framework string, extraRequires ...string) map[string]string {
|
||||
var req strings.Builder
|
||||
req.WriteString("require (\n\t" + frameworkModule + " " + framework + "\n")
|
||||
for _, r := range extraRequires {
|
||||
req.WriteString("\t" + r + "\n")
|
||||
}
|
||||
req.WriteString(")\n")
|
||||
gomod := "module " + modPath + "\n\ngo 1.27.0\n\n" + req.String()
|
||||
src := fmt.Sprintf(`package %s
|
||||
|
||||
import (
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
)
|
||||
|
||||
// Plugin is the %s fixture plugin.
|
||||
type Plugin struct{}
|
||||
|
||||
func (p *Plugin) ID() string { return %q }
|
||||
func (p *Plugin) Requires() []string { return nil }
|
||||
func (p *Plugin) Register(*backpack.App) error { return nil }
|
||||
func (p *Plugin) Boot(*backpack.App) error { return nil }
|
||||
|
||||
func init() { party.Register(&Plugin{}) }
|
||||
`, pkg, id, id)
|
||||
return map[string]string{"go.mod": gomod, "plugin.go": src}
|
||||
}
|
||||
|
||||
// offlineGoEnv points the go commands of the test at the file:// proxy
|
||||
// tree proxyDir, falling back to the developer's module download cache
|
||||
// for every other module, with a fresh module cache and no checksum
|
||||
// database: no network is used. The fresh cache is made writable again
|
||||
// before the temp directory is removed, because summer's go runner drops
|
||||
// GOFLAGS (and with it -modcacherw).
|
||||
func offlineGoEnv(t *testing.T, proxyDir string) {
|
||||
t.Helper()
|
||||
out, err := exec.CommandContext(t.Context(), "go", "env", "GOMODCACHE").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("go env GOMODCACHE: %v", err)
|
||||
}
|
||||
realCache := strings.TrimSpace(string(out))
|
||||
modCache := t.TempDir()
|
||||
t.Cleanup(func() { makeWritable(modCache) })
|
||||
t.Setenv("GOPROXY", "file://"+filepath.ToSlash(proxyDir)+",file://"+filepath.ToSlash(filepath.Join(realCache, "cache", "download")))
|
||||
t.Setenv("GOMODCACHE", modCache)
|
||||
t.Setenv("GOSUMDB", "off")
|
||||
t.Setenv("GOFLAGS", "-modcacherw")
|
||||
t.Setenv("GOTOOLCHAIN", "local")
|
||||
t.Setenv("GONOSUMDB", "")
|
||||
t.Setenv("GOPRIVATE", "")
|
||||
t.Setenv("GONOPROXY", "")
|
||||
}
|
||||
|
||||
func makeWritable(root string) {
|
||||
_ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err == nil {
|
||||
_ = os.Chmod(path, 0o755)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// fakePlugin is one plugin a fakeRegistry serves.
|
||||
type fakePlugin struct {
|
||||
Detail kiosk.RegistryPluginDetail
|
||||
Versions []kiosk.RegistryVersion
|
||||
}
|
||||
|
||||
// fakeReg is an httptest registry speaking the kiosk v1 wire types.
|
||||
type fakeReg struct {
|
||||
Client *kiosk.Client
|
||||
URL string
|
||||
|
||||
mu sync.Mutex
|
||||
plugins map[string]fakePlugin
|
||||
requests []string
|
||||
}
|
||||
|
||||
// fakeRegistry serves plugins through httptest and returns a real
|
||||
// kiosk.Client for it, so tests exercise the HTTP client too.
|
||||
func fakeRegistry(t *testing.T, plugins map[string]fakePlugin) *fakeReg {
|
||||
t.Helper()
|
||||
r := &fakeReg{plugins: plugins}
|
||||
mux := http.NewServeMux()
|
||||
notFound := func(w http.ResponseWriter, id string) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_ = json.NewEncoder(w).Encode(kiosk.ErrorResponse{Error: kiosk.RegistryError{Code: kiosk.RegistryErrorNotFound, Message: id + " not found"}})
|
||||
}
|
||||
mux.HandleFunc("GET "+kiosk.RegistryAPIPath+"/plugins", func(w http.ResponseWriter, req *http.Request) {
|
||||
r.record(req)
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
var resp kiosk.ListResponse
|
||||
for _, p := range r.plugins {
|
||||
resp.Data = append(resp.Data, p.Detail.RegistryPlugin)
|
||||
}
|
||||
resp.Meta.Total = len(resp.Data)
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
mux.HandleFunc("GET "+kiosk.RegistryAPIPath+"/plugins/{id}", func(w http.ResponseWriter, req *http.Request) {
|
||||
r.record(req)
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
id := req.PathValue("id")
|
||||
p, ok := r.plugins[id]
|
||||
if !ok {
|
||||
notFound(w, id)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(kiosk.DetailResponse{Data: p.Detail})
|
||||
})
|
||||
mux.HandleFunc("GET "+kiosk.RegistryAPIPath+"/plugins/{id}/versions", func(w http.ResponseWriter, req *http.Request) {
|
||||
r.record(req)
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
id := req.PathValue("id")
|
||||
p, ok := r.plugins[id]
|
||||
if !ok {
|
||||
notFound(w, id)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(kiosk.VersionsResponse{Data: p.Versions})
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
client, err := kiosk.NewClient(srv.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Client, r.URL = client, srv.URL
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *fakeReg) record(req *http.Request) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.requests = append(r.requests, req.URL.Path)
|
||||
}
|
||||
|
||||
// Requests returns the request paths served so far.
|
||||
func (r *fakeReg) Requests() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]string(nil), r.requests...)
|
||||
}
|
||||
|
||||
// set replaces (or adds) one plugin.
|
||||
func (r *fakeReg) set(id string, p fakePlugin) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.plugins[id] = p
|
||||
}
|
||||
|
||||
// regVersion is a registry release of a module plugin.
|
||||
func regVersion(version, framework, module, pkg string, requires kiosk.Requires) kiosk.RegistryVersion {
|
||||
return kiosk.RegistryVersion{
|
||||
Version: version,
|
||||
Framework: framework,
|
||||
Requires: requires,
|
||||
Module: module,
|
||||
Package: pkg,
|
||||
Commit: strings.Repeat("a", 40),
|
||||
TaggedAt: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
}
|
||||
|
||||
// regPlugin is a registry listing whose versions are given newest first.
|
||||
func regPlugin(id, repo string, versions ...kiosk.RegistryVersion) fakePlugin {
|
||||
p := fakePlugin{Versions: versions}
|
||||
p.Detail.ID = id
|
||||
p.Detail.Name = id
|
||||
p.Detail.Repository = repo
|
||||
if len(versions) > 0 {
|
||||
p.Detail.Module = versions[0].Module
|
||||
p.Detail.Package = versions[0].Package
|
||||
p.Detail.LatestVersion = versions[0].Version
|
||||
p.Detail.LatestFramework = versions[0].Framework
|
||||
p.Detail.Requires = versions[0].Requires
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// helloCopy is a copy of examples/hello in a temp directory whose go.mod
|
||||
// replaces the framework with this checkout (absolute path), as
|
||||
// TestMakeAndAddPluginOnHelloCopy uses it.
|
||||
func helloCopy(t *testing.T) string {
|
||||
t.Helper()
|
||||
return copyHelloApp(t)
|
||||
}
|
||||
374
internal/build/install.go
Normal file
374
internal/build/install.go
Normal file
@@ -0,0 +1,374 @@
|
||||
package build
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"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
|
||||
}
|
||||
|
||||
// InstallStep is one plugin to add.
|
||||
type InstallStep struct {
|
||||
ID string
|
||||
Version string
|
||||
Module string
|
||||
Package string
|
||||
Repository string
|
||||
Mode string
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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), 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}
|
||||
|
||||
id, pin, err := parseInstallArg(opts.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
|
||||
}
|
||||
step, err := in.choose(id, pin, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in.plan.Steps = append(in.plan.Steps, step)
|
||||
|
||||
if err := in.confirm(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return in.plan, 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>")
|
||||
}
|
||||
|
||||
// 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, error) {
|
||||
versions, err := in.opts.Registry.Versions(in.ctx, id)
|
||||
if err != nil {
|
||||
return InstallStep{}, 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{}, fmt.Errorf("build: %s has no release %s; available: %s", id, pin, list)
|
||||
}
|
||||
if !kiosk.Satisfies(in.fw, chosen.Framework) {
|
||||
return InstallStep{}, in.frameworkError(id, chosen)
|
||||
}
|
||||
if !kiosk.Satisfies(pin, min) {
|
||||
return InstallStep{}, fmt.Errorf("build: %s %s is older than the required minimum %s", id, pin, min)
|
||||
}
|
||||
} else {
|
||||
var eligible []kiosk.RegistryVersion
|
||||
for _, v := range versions {
|
||||
if kiosk.ValidVersion(v.Version) && kiosk.Satisfies(v.Version, min) {
|
||||
eligible = append(eligible, v)
|
||||
}
|
||||
}
|
||||
best, ok := kiosk.NewestCompatible(eligible, in.fw)
|
||||
if !ok {
|
||||
if len(eligible) == 0 {
|
||||
if min != "" {
|
||||
return InstallStep{}, fmt.Errorf("build: %s has no release at or above the required minimum %s", id, min)
|
||||
}
|
||||
return InstallStep{}, fmt.Errorf("build: %s has no release in the registry", id)
|
||||
}
|
||||
return InstallStep{}, in.frameworkError(id, newestOf(eligible))
|
||||
}
|
||||
chosen = best
|
||||
}
|
||||
step := InstallStep{
|
||||
ID: id,
|
||||
Version: chosen.Version,
|
||||
Module: chosen.Module,
|
||||
Package: chosen.Package,
|
||||
Mode: ModeModule,
|
||||
}
|
||||
if err := validateStep(step); err != nil {
|
||||
return InstallStep{}, err
|
||||
}
|
||||
return step, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if !kiosk.ValidVersion(s.Version) {
|
||||
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)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// summary renders one line per step.
|
||||
func (p *InstallPlan) summary() string {
|
||||
var b strings.Builder
|
||||
for _, s := range p.Steps {
|
||||
fmt.Fprintf(&b, " %s@%s (%s)\n", s.ID, s.Version, s.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 displayVersion(v string) string {
|
||||
if v == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ApplyInstall carries out a plan from PlanInstall: one go get for every
|
||||
// module step, so minimal version selection sees the whole set, then the
|
||||
// summer.yaml entries in plan order. It does not run go mod tidy: until
|
||||
// summer build imports the new packages, tidy would drop them.
|
||||
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")
|
||||
}
|
||||
if out == nil {
|
||||
out = io.Discard
|
||||
}
|
||||
var getArgs []string
|
||||
var entries []Plugin
|
||||
for _, s := range plan.Steps {
|
||||
if err := validateStep(s); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.Mode == ModeModule {
|
||||
getArgs = append(getArgs, s.Module+"@"+s.Version)
|
||||
}
|
||||
entries = append(entries, Plugin{ID: s.ID, Module: s.Package})
|
||||
}
|
||||
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 err
|
||||
}
|
||||
}
|
||||
if err := appendManifestPlugins(filepath.Join(plan.AppDir, manifestName), entries); err != nil {
|
||||
return err
|
||||
}
|
||||
ids := make([]string, len(plan.Steps))
|
||||
for i, s := range plan.Steps {
|
||||
ids[i] = s.ID + "@" + s.Version
|
||||
}
|
||||
fmt.Fprintf(out, "Installed %s; run summer build\n", strings.Join(ids, ", "))
|
||||
return nil
|
||||
}
|
||||
213
internal/build/install_smoke_test.go
Normal file
213
internal/build/install_smoke_test.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package build
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/kiosk"
|
||||
)
|
||||
|
||||
const (
|
||||
blogModule = "example.com/acme/blog"
|
||||
// plantedComment is written into summer.yaml before an install; the
|
||||
// install must keep it.
|
||||
plantedComment = "# planted: plugin:install keeps this comment"
|
||||
)
|
||||
|
||||
// plantComment puts a comment line and a blank line above the plugins
|
||||
// list of the app's summer.yaml.
|
||||
func plantComment(t *testing.T, appDir string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(appDir, manifestName)
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := strings.Replace(string(raw), "plugins:\n", "\n"+plantedComment+"\nplugins:\n", 1)
|
||||
write(t, path, s)
|
||||
}
|
||||
|
||||
func assertManifestKeepsComment(t *testing.T, appDir string) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join(appDir, manifestName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(raw, []byte(plantedComment)) {
|
||||
t.Fatalf("summer.yaml lost the planted comment:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func manifestIDs(t *testing.T, appDir string) []string {
|
||||
t.Helper()
|
||||
m, err := LoadManifest(filepath.Join(appDir, manifestName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := make([]string, len(m.Plugins))
|
||||
for i, p := range m.Plugins {
|
||||
ids[i] = p.ID + "=" + p.Module
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func TestInstallSmoke(t *testing.T) {
|
||||
blogV1 := regVersion("v0.1.0", "v0.5.0", blogModule, blogModule, nil)
|
||||
blogV2 := regVersion("v0.2.0", "v9.0.0", blogModule, blogModule, nil)
|
||||
|
||||
t.Run("module", func(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("builds an app against a file:// module proxy")
|
||||
}
|
||||
app := helloCopy(t)
|
||||
plantComment(t, app)
|
||||
proxy := t.TempDir()
|
||||
writeProxyModule(t, proxy, blogModule, "v0.1.0", fixturePluginFiles(blogModule, "blog", "acme.blog", "v0.5.0"))
|
||||
offlineGoEnv(t, proxy)
|
||||
reg := fakeRegistry(t, map[string]fakePlugin{
|
||||
"acme.blog": regPlugin("acme.blog", "https://example.com/acme/blog.git", blogV2, blogV1),
|
||||
})
|
||||
|
||||
var out bytes.Buffer
|
||||
plan, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.blog", Yes: true, Registry: reg.Client, Out: &out})
|
||||
if err != nil {
|
||||
t.Fatalf("plan: %v\n%s", err, out.String())
|
||||
}
|
||||
if len(plan.Steps) != 1 || plan.Steps[0].Version != "v0.1.0" || plan.Steps[0].Mode != ModeModule {
|
||||
t.Fatalf("plan steps = %+v", plan.Steps)
|
||||
}
|
||||
if !strings.Contains(out.String(), "acme.blog@v0.1.0 (module)") {
|
||||
t.Fatalf("plan output lacks the step:\n%s", out.String())
|
||||
}
|
||||
if err := ApplyInstall(t.Context(), plan, &out); err != nil {
|
||||
t.Fatalf("apply: %v\n%s", err, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "run summer build") {
|
||||
t.Fatalf("apply output:\n%s", out.String())
|
||||
}
|
||||
gomod, err := os.ReadFile(filepath.Join(app, "go.mod"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(gomod, []byte(blogModule+" v0.1.0")) {
|
||||
t.Fatalf("go.mod does not require %s v0.1.0:\n%s", blogModule, gomod)
|
||||
}
|
||||
ids := manifestIDs(t, app)
|
||||
if last := ids[len(ids)-1]; last != "acme.blog="+blogModule {
|
||||
t.Fatalf("summer.yaml plugins = %v", ids)
|
||||
}
|
||||
assertManifestKeepsComment(t, app)
|
||||
|
||||
var log bytes.Buffer
|
||||
if err := App(t.Context(), app, &log); err != nil {
|
||||
t.Fatalf("build: %v\n%s", err, log.String())
|
||||
}
|
||||
gen, err := os.ReadFile(filepath.Join(app, "plugins.gen.go"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(gen, []byte(`_ "`+blogModule+`"`)) || !bytes.Contains(gen, []byte(`Version: "v0.1.0"`)) {
|
||||
t.Fatalf("plugins.gen.go does not import %s at v0.1.0:\n%s", blogModule, gen)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("module_pin_framework", func(t *testing.T) {
|
||||
app := helloCopy(t)
|
||||
reg := fakeRegistry(t, map[string]fakePlugin{
|
||||
"acme.blog": regPlugin("acme.blog", "https://example.com/acme/blog.git", blogV2, blogV1),
|
||||
})
|
||||
before := manifestIDs(t, app)
|
||||
_, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.blog@v0.2.0", Yes: true, Registry: reg.Client})
|
||||
if err == nil || !strings.Contains(err.Error(), "v9.0.0") {
|
||||
t.Fatalf("pin to v0.2.0: err = %v, want one naming framework v9.0.0", err)
|
||||
}
|
||||
if got := manifestIDs(t, app); strings.Join(got, ",") != strings.Join(before, ",") {
|
||||
t.Fatalf("summer.yaml changed: %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("append_manifest", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, manifestName)
|
||||
cases := []struct {
|
||||
name, in, want string
|
||||
}{
|
||||
{
|
||||
name: "indented",
|
||||
in: "# app\nmodule: example.com/app\nbinary: app\n\n# the plugins\nplugins:\n - id: a.one\n module: example.com/one # first\n\n # trailing note\nbuild:\n tags: [x]\n",
|
||||
want: "# app\nmodule: example.com/app\nbinary: app\n\n# the plugins\nplugins:\n - id: a.one\n module: example.com/one # first\n - id: acme.blog\n module: example.com/acme/blog\n\n # trailing note\nbuild:\n tags: [x]\n",
|
||||
},
|
||||
{
|
||||
name: "flush",
|
||||
in: "module: example.com/app\nbinary: app\nplugins:\n- id: a.one\n module: example.com/one\n",
|
||||
want: "module: example.com/app\nbinary: app\nplugins:\n- id: a.one\n module: example.com/one\n- id: acme.blog\n module: example.com/acme/blog\n",
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
in: "module: example.com/app\nbinary: app\nplugins: []\n# end\n",
|
||||
want: "module: example.com/app\nbinary: app\nplugins:\n - id: acme.blog\n module: example.com/acme/blog\n# end\n",
|
||||
},
|
||||
{
|
||||
name: "absent",
|
||||
in: "module: example.com/app\nbinary: app",
|
||||
want: "module: example.com/app\nbinary: app\nplugins:\n - id: acme.blog\n module: example.com/acme/blog\n",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
write(t, path, c.in)
|
||||
if err := appendManifestPlugins(path, []Plugin{{ID: "acme.blog", Module: blogModule}}); err != nil {
|
||||
t.Fatalf("%s: %v", c.name, err)
|
||||
}
|
||||
got, _ := os.ReadFile(path)
|
||||
if string(got) != c.want {
|
||||
t.Fatalf("%s:\n got %q\nwant %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
inline := "module: example.com/app\nbinary: app\nplugins: [{id: a.one, module: example.com/one}]\n"
|
||||
write(t, path, inline)
|
||||
if err := appendManifestPlugins(path, []Plugin{{ID: "acme.blog", Module: blogModule}}); err == nil {
|
||||
t.Fatal("inline plugins list: want an error")
|
||||
}
|
||||
if got, _ := os.ReadFile(path); string(got) != inline {
|
||||
t.Fatalf("inline list was changed: %q", got)
|
||||
}
|
||||
dup := "module: example.com/app\nbinary: app\nplugins:\n - id: acme.blog\n module: example.com/other\n"
|
||||
write(t, path, dup)
|
||||
if err := appendManifestPlugins(path, []Plugin{{ID: "acme.blog", Module: blogModule}}); err == nil {
|
||||
t.Fatal("duplicate ID: want an error")
|
||||
}
|
||||
if got, _ := os.ReadFile(path); string(got) != dup {
|
||||
t.Fatalf("summer.yaml not restored: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("registry_client", func(t *testing.T) {
|
||||
reg := fakeRegistry(t, map[string]fakePlugin{
|
||||
"acme.blog": regPlugin("acme.blog", "https://example.com/acme/blog.git", blogV2, blogV1),
|
||||
})
|
||||
vs, err := reg.Client.Versions(t.Context(), "acme.blog")
|
||||
if err != nil || len(vs) != 2 || vs[0].Version != "v0.2.0" {
|
||||
t.Fatalf("versions = %+v, %v", vs, err)
|
||||
}
|
||||
_, err = reg.Client.Versions(t.Context(), "acme.nope")
|
||||
if !errors.Is(err, kiosk.ErrNotFound) || !strings.Contains(err.Error(), reg.URL) {
|
||||
t.Fatalf("unknown id: err = %v", err)
|
||||
}
|
||||
list, err := reg.Client.List(t.Context(), "blog", "")
|
||||
if err != nil || list.Meta.Total != 1 {
|
||||
t.Fatalf("list = %+v, %v", list, err)
|
||||
}
|
||||
if _, err := kiosk.NewClient("", nil); !errors.Is(err, kiosk.ErrRegistryDisabled) {
|
||||
t.Fatalf("empty URL: err = %v", err)
|
||||
}
|
||||
for _, bad := range []string{"ftp://x", "https://user:pw@x", "https://x/?q=1", "x"} {
|
||||
if _, err := kiosk.NewClient(bad, nil); err == nil {
|
||||
t.Fatalf("NewClient(%q) accepted", bad)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -180,6 +180,143 @@ func SaveManifest(path string, m Manifest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// appendManifestPlugins adds plugins to the end of the plugins list in the
|
||||
// summer.yaml at path by inserting text, so comments, blank lines and the
|
||||
// order of the other keys stay as they are. The entries take the
|
||||
// indentation of the existing items; a file without a plugins key gets a
|
||||
// new block at its end. The result is parsed again with LoadManifest, and
|
||||
// any error restores the original bytes.
|
||||
func appendManifestPlugins(path string, plugins []Plugin) error {
|
||||
if len(plugins) == 0 {
|
||||
return nil
|
||||
}
|
||||
for i, p := range plugins {
|
||||
if err := ValidatePluginID(p.ID); err != nil {
|
||||
return fmt.Errorf("build: plugins[%d]: %w", i, err)
|
||||
}
|
||||
if err := validateModulePath(p.Module); err != nil {
|
||||
return fmt.Errorf("build: plugins[%d] module: %w", i, err)
|
||||
}
|
||||
}
|
||||
orig, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build: read %s: %w", path, err)
|
||||
}
|
||||
before, err := LoadManifest(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updated, err := insertManifestPlugins(string(orig), plugins)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build: edit %s: %w", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
|
||||
return fmt.Errorf("build: write %s: %w", path, err)
|
||||
}
|
||||
restore := func(cause error) error {
|
||||
if werr := os.WriteFile(path, orig, 0o644); werr != nil {
|
||||
return fmt.Errorf("%w (restoring %s also failed: %v)", cause, path, werr)
|
||||
}
|
||||
return cause
|
||||
}
|
||||
after, err := LoadManifest(path)
|
||||
if err != nil {
|
||||
return restore(fmt.Errorf("build: summer.yaml edit did not parse, left unchanged: %w", err))
|
||||
}
|
||||
want := append(append([]Plugin(nil), before.Plugins...), plugins...)
|
||||
if len(after.Plugins) != len(want) {
|
||||
return restore(fmt.Errorf("build: summer.yaml edit gave %d plugins instead of %d, left unchanged", len(after.Plugins), len(want)))
|
||||
}
|
||||
for i := range want {
|
||||
if after.Plugins[i] != want[i] {
|
||||
return restore(fmt.Errorf("build: summer.yaml edit changed plugins[%d], left unchanged", i))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// insertManifestPlugins returns src with plugins appended to its
|
||||
// top-level plugins block.
|
||||
func insertManifestPlugins(src string, plugins []Plugin) (string, error) {
|
||||
lines := strings.SplitAfter(src, "\n")
|
||||
if n := len(lines); n > 0 && lines[n-1] == "" {
|
||||
lines = lines[:n-1]
|
||||
}
|
||||
key := -1
|
||||
for i, l := range lines {
|
||||
if strings.HasPrefix(l, "plugins:") {
|
||||
key = i
|
||||
break
|
||||
}
|
||||
}
|
||||
render := func(dash, keyIndent string) string {
|
||||
var b strings.Builder
|
||||
for _, p := range plugins {
|
||||
fmt.Fprintf(&b, "%s- id: %s\n%smodule: %s\n", dash, p.ID, keyIndent, p.Module)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
if key < 0 {
|
||||
out := strings.Join(lines, "")
|
||||
if out != "" && !strings.HasSuffix(out, "\n") {
|
||||
out += "\n"
|
||||
}
|
||||
return out + "plugins:\n" + render(" ", " "), nil
|
||||
}
|
||||
rest := strings.TrimSpace(stripYAMLComment(strings.TrimPrefix(lines[key], "plugins:")))
|
||||
switch rest {
|
||||
case "", "[]", "~", "null":
|
||||
default:
|
||||
return "", fmt.Errorf("the plugins list is written inline; write it as a block list (one \"- id:\" item per plugin) and run the command again")
|
||||
}
|
||||
// The block runs until the next line that starts a top-level key.
|
||||
end := key + 1
|
||||
last := key
|
||||
dash, keyIndent := "", ""
|
||||
for ; end < len(lines); end++ {
|
||||
l := strings.TrimRight(lines[end], "\r\n")
|
||||
trimmed := strings.TrimSpace(l)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
indented := l[0] == ' ' || l[0] == '\t'
|
||||
if !indented && !strings.HasPrefix(l, "-") {
|
||||
break
|
||||
}
|
||||
last = end
|
||||
if dash == "" && strings.HasPrefix(trimmed, "-") {
|
||||
dash = l[:len(l)-len(strings.TrimLeft(l, " \t"))]
|
||||
afterDash := strings.TrimLeft(trimmed[1:], " \t")
|
||||
keyIndent = strings.Repeat(" ", len(dash)+len(trimmed)-len(afterDash))
|
||||
}
|
||||
}
|
||||
if dash == "" && keyIndent == "" {
|
||||
dash, keyIndent = " ", " "
|
||||
}
|
||||
head := lines[:last+1]
|
||||
if rest != "" {
|
||||
head = append(append([]string(nil), lines[:key]...), "plugins:\n")
|
||||
head = append(head, lines[key+1:last+1]...)
|
||||
}
|
||||
tail := lines[last+1:]
|
||||
out := strings.Join(head, "")
|
||||
if !strings.HasSuffix(out, "\n") {
|
||||
out += "\n"
|
||||
}
|
||||
return out + render(dash, keyIndent) + strings.Join(tail, ""), nil
|
||||
}
|
||||
|
||||
// stripYAMLComment drops a trailing " # comment" from a YAML value.
|
||||
func stripYAMLComment(s string) string {
|
||||
if i := strings.Index(s, " #"); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(s), "#") {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ValidatePluginID requires a lowercase vendor.plugin identifier.
|
||||
func ValidatePluginID(id string) error {
|
||||
if id == "" {
|
||||
|
||||
@@ -204,7 +204,7 @@ func AddPlugin(ctx context.Context, startDir, pluginDir string) error {
|
||||
if already {
|
||||
return nil
|
||||
}
|
||||
return SaveManifest(manifestPath, m)
|
||||
return appendManifestPlugins(manifestPath, []Plugin{{ID: id, Module: modPath}})
|
||||
}
|
||||
|
||||
func pluginName(id string) string {
|
||||
|
||||
Reference in New Issue
Block a user