From 87921c27f63e162ed7d7beaafa3a7c8c2be8ee44 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 29 Oct 2026 23:18:07 +0100 Subject: [PATCH] fix(kiosk): fetch a required plugin module whose replace directory is missing A plugin developed inside an application replaces its required plugins with sibling directories (../user). Checked out on its own, that directory is missing and plugin.yaml lost the minimum (golem15.user: ""), so the manifest differed between the two layouts and TestPluginManifest could not pass in both. Such a module is now fetched at the version go.mod requires, as a consumer would see it. - listedModule.fallback marks a local replace whose directory is absent - fallback modules download separately, so one unfetchable version does not hold up the others - TestDeriveMissingReplace over a file:// proxy (offline) --- docs/plugins/marketplace.md | 2 +- modules/kiosk/README.md | 2 +- modules/kiosk/derive_test.go | 105 +++++++++++++++++++++++++++++++++++ modules/kiosk/identity.go | 40 ++++++++++--- 4 files changed, 140 insertions(+), 9 deletions(-) diff --git a/docs/plugins/marketplace.md b/docs/plugins/marketplace.md index 0869bca6..5c372682 100644 --- a/docs/plugins/marketplace.md +++ b/docs/plugins/marketplace.md @@ -38,7 +38,7 @@ overrides: | `name` | The README's H1, or the `--name` override. | | `description` | The first paragraph after the README's H1 (the one-sentence summary of the standard README structure), or the `--description` override. | | `framework` | The version `go.mod` requires the framework (`git.golem15.com/golem15/summercms`) at: the oldest framework release the plugin works with. | -| `requires` | Each plugin ID `Requires()` returns (literals or constants, as for `id`), with the version `go.mod` requires its module at. A requirement at `v0.0.0` is an error, because nobody can install that version; a pseudo-version is accepted with a warning. The value is empty for a plugin in the same module, and for one whose module is not a `go.mod` require. Left out when `Requires()` returns none. | +| `requires` | Each plugin ID `Requires()` returns (literals or constants, as for `id`), with the version `go.mod` requires its module at. A requirement at `v0.0.0` is an error, because nobody can install that version; a pseudo-version is accepted with a warning. The value is empty for a plugin in the same module, and for one whose module is not a `go.mod` require. A module that `go.mod` replaces with a directory that is not checked out (a plugin developed inside an application, checked out on its own) is read at its required version from the module proxy, so the value is the same in both places. Left out when `Requires()` returns none. | | `overrides` | The `--name` and `--description` values, kept across regenerations. Left out when neither is set. | Versions are minimums in Go's sense (`v0.6.0` means "v0.6.0 or newer"), never ranges: Go's minimal version selection picks the version, and a major version change moves the module path. diff --git a/modules/kiosk/README.md b/modules/kiosk/README.md index a733ee75..2247b9e0 100644 --- a/modules/kiosk/README.md +++ b/modules/kiosk/README.md @@ -17,7 +17,7 @@ WinterCMS keeps this data in a plugin's `Plugin.php` `pluginDetails()` and in `c - One `plugin.yaml` per plugin package directory, so a repository with a nested plugin carries two files. - `kiosk.ReadIdentity` reads `ID()` and `Requires()` statically with `go/parser`: a string literal, a constant of the same package, of another package of the module, or of a package in a module `go.mod` requires. It never builds or runs plugin code, and `summer plugin:add` reads plugin IDs with it too. - A schema version (`kiosk.SchemaVersion`) and a fixed header line (`kiosk.Header`); a manifest with a newer schema is refused with a hint to update summer. -- Go-style minimums: `framework` is the `go.mod` require of `kiosk.FrameworkModule`, and each required plugin gets the version its module is required at. A requirement at `v0.0.0` is an error (not a released version); a pseudo-version is accepted with a warning. +- Go-style minimums: `framework` is the `go.mod` require of `kiosk.FrameworkModule`, and each required plugin gets the version its module is required at. A requirement at `v0.0.0` is an error (not a released version); a pseudo-version is accepted with a warning. A required module replaced by a local directory that is not there (a plugin developed inside an application, checked out on its own) is fetched at its required version, so the manifest is the same in both layouts. - Name and description overrides (`--name`, `--description`) stored in an `overrides:` block that regeneration keeps. - The registry API v1 contract in Go: `kiosk.RegistryAPIPath` and the wire types (`kiosk.ListResponse`, `kiosk.DetailResponse`, `kiosk.VersionsResponse`, `kiosk.ErrorResponse`, `kiosk.IngestRequest`, `kiosk.IngestResponse`). The registry server encodes with them and the summer tool and the admin decode with them, so the JSON shape exists once. - Strict version rules: `kiosk.ValidVersion` (only `vX.Y.Z`), `kiosk.CompareVersions`, `kiosk.BaseVersion` for `git describe` output, `kiosk.Satisfies` for Go-style minimums, `kiosk.NewestCompatible` to pick the release an application can install, and `kiosk.Newer` with `kiosk.Comparable` to tell whether a release is an upgrade for an installed version (a release, a `git describe` string or a pseudo-version), so an update never goes back. diff --git a/modules/kiosk/derive_test.go b/modules/kiosk/derive_test.go index bcf0fe1e..733140f5 100644 --- a/modules/kiosk/derive_test.go +++ b/modules/kiosk/derive_test.go @@ -1,7 +1,10 @@ package kiosk import ( + "archive/zip" + "bytes" "context" + "maps" "os" "path/filepath" "slices" @@ -250,3 +253,105 @@ func TestPluginDirs(t *testing.T) { t.Errorf("pluginDirs = %v, want %v", got, want) } } + +// TestDeriveMissingReplace: a required plugin module replaced by a local +// directory that is not checked out (a plugin developed inside an +// application, checked out on its own) gets the version go.mod requires, +// fetched from the module proxy, so the manifest is the same in both +// layouts. A version the proxy does not have leaves the minimum empty with +// a warning. The proxy is a file:// tree; no network is used. +func TestDeriveMissingReplace(t *testing.T) { + requireGo(t) + ctx := context.Background() + root := t.TempDir() + proxy := filepath.Join(root, "proxy") + userMod := "module example.test/sm-user-plugin\n\ngo 1.27.0\n\nrequire " + FrameworkModule + " v0.6.0\n" + writeProxyModule(t, proxy, "example.test/sm-user-plugin", "v0.1.2", map[string]string{ + "go.mod": userMod, + "plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.user" }`, + }) + modCache := filepath.Join(root, "modcache") + t.Cleanup(func() { + _ = filepath.WalkDir(modCache, func(path string, d os.DirEntry, err error) error { + if err == nil { + _ = os.Chmod(path, 0o755) + } + return nil + }) + }) + t.Setenv("GOPROXY", "file://"+filepath.ToSlash(proxy)) + t.Setenv("GOMODCACHE", modCache) + t.Setenv("GOFLAGS", "-modcacherw") + t.Setenv("GOSUMDB", "off") + t.Setenv("GONOSUMDB", "") + t.Setenv("GOPRIVATE", "") + t.Setenv("GONOPROXY", "") + t.Setenv("GOTOOLCHAIN", "local") + + shopMod := func(userVersion string) string { + return "module example.test/sm-shop-plugin\n\ngo 1.27.0\n\nrequire (\n\t" + FrameworkModule + " v0.6.0\n\texample.test/sm-user-plugin " + userVersion + "\n)\n\n" + + "replace " + FrameworkModule + " => ../framework\n\nreplace example.test/sm-user-plugin => ../user\n" + } + writeTree(t, root, map[string]string{ + "framework/go.mod": "module " + FrameworkModule + "\n\ngo 1.27.0\n", + "shop/go.mod": shopMod("v0.1.2"), + "shop/plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.shop" } +func (*Plugin) Requires() []string { return []string{"acme.user"} }`, + "shop/README.md": "# Shop\n\nA shop.\n", + }) + // ../user is the replace target and does not exist. + m, warnings, err := Derive(ctx, filepath.Join(root, "shop"), DeriveOptions{}) + if err != nil { + t.Fatalf("Derive: %v", err) + } + if got := m.Requires["acme.user"]; got != "v0.1.2" || len(warnings) != 0 { + t.Errorf("requires[acme.user] = %q, warnings %v; want v0.1.2 and none", got, warnings) + } + + // A required version the proxy does not have: no minimum, a warning. + writeTree(t, root, map[string]string{"shop/go.mod": shopMod("v0.1.3")}) + m, warnings, err = Derive(ctx, filepath.Join(root, "shop"), DeriveOptions{}) + if err != nil { + t.Fatalf("Derive v0.1.3: %v", err) + } + if got, ok := m.Requires["acme.user"]; !ok || got != "" || len(warnings) != 1 || !strings.Contains(warnings[0], "not matched") { + t.Errorf("v0.1.3: requires[acme.user] = %q (present %v), warnings %v", got, ok, warnings) + } +} + +// writeProxyModule adds modPath@version to the file:// GOPROXY tree at +// proxyDir: @v/list, .info, .mod (files["go.mod"]) and a .zip whose +// entries are prefixed @/. Module paths are lower case, +// so no escaping is needed. +func writeProxyModule(t *testing.T, proxyDir, modPath, version string, files map[string]string) { + t.Helper() + dir := filepath.Join(proxyDir, filepath.FromSlash(modPath), "@v") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + names := slices.Sorted(maps.Keys(files)) + 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) + } + for name, body := range map[string][]byte{ + "list": []byte(version + "\n"), + version + ".info": []byte(`{"Version":"` + version + `","Time":"2026-10-01T00:00:00Z"}`), + version + ".mod": []byte(files["go.mod"]), + version + ".zip": buf.Bytes(), + } { + if err := os.WriteFile(filepath.Join(dir, name), body, 0o644); err != nil { + t.Fatal(err) + } + } +} diff --git a/modules/kiosk/identity.go b/modules/kiosk/identity.go index c758824c..9008f01c 100644 --- a/modules/kiosk/identity.go +++ b/modules/kiosk/identity.go @@ -78,6 +78,10 @@ type listedModule struct { Dir string GoMod string Error *struct{ Err string } + + // fallback marks a module replaced by a local directory that is not + // there; its Dir and GoMod are cleared so it is fetched at Version. + fallback bool } func newResolver(ctx context.Context, start string) *resolver { @@ -447,6 +451,13 @@ func (r *resolver) list(paths []string) ([]listedModule, error) { if err := dec.Decode(&m); err != nil { return nil, fmt.Errorf("kiosk: go list -m: %w", err) } + // A replace to a directory that is not checked out here (one + // that only exists where the plugin is mounted in an + // application) says nothing about the module: use the version + // go.mod requires, as a consumer of the plugin would. + if m.Dir != "" && m.Error == nil && !isDir(m.Dir) { + m.Dir, m.GoMod, m.fallback = "", "", true + } r.modDirs[m.Path] = m } } @@ -529,19 +540,28 @@ func (r *resolver) minimums(ids []string) (Requires, []string, error) { if err != nil { return nil, nil, err } - // Only a module whose go.mod requires the framework can hold a plugin. - var candidates []listedModule + // Only a module whose go.mod requires the framework can hold a + // plugin. A fallback module is fetched first and checked after, + // in its own download so a version that cannot be fetched does not + // hold up the others. + var candidates, fallbacks []listedModule for _, m := range listed { - if m.Error == nil && requiresFramework(m) { + switch { + case m.Error != nil: + case m.fallback && m.Version != "" && m.Version != "v0.0.0": + fallbacks = append(fallbacks, m) + case requiresFramework(m): candidates = append(candidates, m) } } - if err := r.download(candidates); err != nil { - warnings = append(warnings, fmt.Sprintf("required plugins not matched to modules: %v", err)) + for _, mods := range [][]listedModule{candidates, fallbacks} { + if err := r.download(mods); err != nil { + warnings = append(warnings, fmt.Sprintf("required plugins not matched to modules: %v", err)) + } } - for _, m := range candidates { + for _, m := range append(candidates, fallbacks...) { m = r.modDirs[m.Path] - if m.Dir == "" { + if m.Dir == "" || (m.fallback && !requiresFramework(m)) { continue } for _, d := range pluginDirs(m.Dir) { @@ -576,6 +596,12 @@ func (r *resolver) minimums(ids []string) (Requires, []string, error) { return out, warnings, nil } +// isDir reports whether path is an existing directory. +func isDir(path string) bool { + fi, err := os.Stat(path) + return err == nil && fi.IsDir() +} + // requiresFramework reports whether the module's go.mod names the // framework module. func requiresFramework(m listedModule) bool {