feat(kiosk): registry v1 wire types and version rules
- RegistryAPIPath, RegistryPlugin, RegistryPluginDetail, RegistryVersion, envelopes, errors, ingest types - ValidVersion, CompareVersions, BaseVersion, Satisfies, NewestCompatible - docs: Registry API v1 reference in plugins/marketplace.md
This commit is contained in:
@@ -19,6 +19,8 @@ WinterCMS keeps this data in a plugin's `Plugin.php` `pluginDetails()` and in `c
|
||||
- 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.
|
||||
- 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 and `kiosk.NewestCompatible` to pick the release an application can install.
|
||||
- `kiosk.Check` regenerates in memory and returns a `kiosk.StaleError` when the committed file is missing or differs, which `summer plugin:manifest --check` turns into exit status 1, and `kiosk.CheckT` turns into a failing test in the plugin's own `go test`.
|
||||
|
||||
## Usage
|
||||
@@ -42,6 +44,20 @@ if err := kiosk.Check(ctx, "plugins/acme/blog"); err != nil {
|
||||
}
|
||||
```
|
||||
|
||||
Picking the newest release of a plugin that an application on framework v0.6.2 can install:
|
||||
|
||||
```go
|
||||
var versions kiosk.VersionsResponse
|
||||
if err := json.Unmarshal(body, &versions); err != nil {
|
||||
return err
|
||||
}
|
||||
v, ok := kiosk.NewestCompatible(versions.Data, "v0.6.2")
|
||||
if !ok {
|
||||
return errors.New("every release needs a newer framework")
|
||||
}
|
||||
fmt.Println("go get", v.Module+"@"+v.Version)
|
||||
```
|
||||
|
||||
A plugin repository keeps its manifest current with one test:
|
||||
|
||||
```go
|
||||
@@ -73,6 +89,28 @@ func TestPluginManifest(t *testing.T) {
|
||||
| `kiosk.TB` | The part of `testing.TB` that `kiosk.CheckT` calls (`Helper`, `Fatalf`); kiosk does not import `testing`. |
|
||||
| `kiosk.CheckT` | Fails the test with the `kiosk.Check` error and the command to rerun when the manifest is missing or stale. |
|
||||
| `kiosk.Validate` | Checks a manifest: schema, ID and required IDs, module and package paths (no leading `-`), name, description, and `vX.Y.Z` or pseudo-version minimums. `kiosk.Parse` and `kiosk.Generate` call it. |
|
||||
| `kiosk.Requires.MarshalJSON` | Encodes the map as a JSON object, `{}` when empty, so the registry never sends `null` for requirements. |
|
||||
| `kiosk.RegistryAPIPath` | `/api/registry/v1`, the base path of the registry API. |
|
||||
| `kiosk.RegistryPlugin` | One listed plugin: `id`, `name`, `description`, `module`, `package`, `repository`, `category`, `featured`, `icon_url`, `latest_version`, `latest_framework`, `updated_at` (RFC 3339). |
|
||||
| `kiosk.RegistryPluginDetail` | `kiosk.RegistryPlugin` plus `readme_markdown`, `readme_html`, `screenshots` and the `requires` of the latest version. |
|
||||
| `kiosk.RegistryScreenshot` | One screenshot: `url`, `caption`. |
|
||||
| `kiosk.RegistryVersion` | One tagged release: `version`, `framework`, `requires`, `module`, `package`, `commit`, `tagged_at`. |
|
||||
| `kiosk.ListResponse` | `{"data": [...], "meta": {"total": N}}` for the plugin list. |
|
||||
| `kiosk.ListMeta` | The `meta` object of a list: `total`. |
|
||||
| `kiosk.DetailResponse` | `{"data": {...}}` for one plugin. |
|
||||
| `kiosk.VersionsResponse` | `{"data": [...]}` for a plugin's releases, newest first. |
|
||||
| `kiosk.ErrorResponse` | `{"error": {"code", "message"}}`, the body of every error. |
|
||||
| `kiosk.RegistryError` | A stable `code` and a `message`. |
|
||||
| `kiosk.RegistryErrorNotFound` | The `not_found` code of a 404. |
|
||||
| `kiosk.RegistryErrorInvalidQuery` | The `invalid_query` code of a 400. |
|
||||
| `kiosk.IngestRequest` | The body of the authenticated ingest request: `repository`. |
|
||||
| `kiosk.IngestResponse` | The 202 answer to an ingest request: `{"data": {"repository", "queued"}}`. |
|
||||
| `kiosk.IngestResult` | The `data` of an ingest answer. |
|
||||
| `kiosk.ValidVersion` | Reports whether a version is a strict `vX.Y.Z` release (no pre-release, build metadata or leading zeros). |
|
||||
| `kiosk.CompareVersions` | Compares two valid versions numerically per component: `v0.10.0` sorts after `v0.9.9`. |
|
||||
| `kiosk.BaseVersion` | The `vX.Y.Z` tag a `git describe --tags` string starts from (`v0.3.1` for `v0.3.1-4-gabc1234`), or `""`. |
|
||||
| `kiosk.Satisfies` | Whether a release meets a minimum; an empty minimum is always met, and a pseudo-version minimum is met by its base release and later. |
|
||||
| `kiosk.NewestCompatible` | The newest release whose framework minimum a framework version satisfies. |
|
||||
| `kiosk.Identity` | The plugin ID and the required plugin IDs. |
|
||||
| `kiosk.ReadIdentity` | Reads what `ID()` and `Requires()` return from the sources of a directory, resolving constants across packages and modules without running code. Any other expression is an error naming the method. |
|
||||
| `kiosk.StaleError` | `Path`, `Missing`, and the generated (`Want`) and committed (`Got`) bytes; its message names the first differing line. |
|
||||
@@ -87,7 +125,7 @@ func TestPluginManifest(t *testing.T) {
|
||||
|
||||
- SummerCMS modules: none.
|
||||
- Third-party: `github.com/goccy/go-yaml` (manifest YAML), `github.com/yuin/goldmark` (README headings and paragraphs).
|
||||
- Standard library: `bytes`, `context`, `encoding/json`, `errors`, `fmt`, `go/ast`, `go/parser`, `go/token`, `go/types` (expression printing only), `os`, `os/exec`, `path/filepath`, `regexp`, `slices`, `sort`, `strconv`, `strings`.
|
||||
- Standard library: `bytes`, `context`, `encoding/json`, `errors`, `fmt`, `go/ast`, `go/parser`, `go/token`, `go/types` (expression printing only), `os`, `os/exec`, `path/filepath`, `regexp`, `slices`, `sort`, `strconv`, `strings`, `time`.
|
||||
- Tools: the `go` command (`go mod edit -json`, `go list -m -json`, and `go mod download -json` for a required module not in the module cache), run without a shell and with workspaces off.
|
||||
|
||||
## Testing
|
||||
@@ -96,4 +134,4 @@ func TestPluginManifest(t *testing.T) {
|
||||
go test ./modules/kiosk/...
|
||||
```
|
||||
|
||||
`TestManifestSmoke` copies the fixture plugins in `testdata/` to a temporary directory and generates, checks and edits their manifests: a literal-ID plugin, a plugin whose identity is spread over constants of three packages and two modules, a nested plugin, the error cases, overrides, `kiosk.CheckT` and `kiosk.Validate`. Every fixture module replaces the framework with the stub in `testdata/framework`, so the tests run the local `go` command but need no network and no database.
|
||||
`TestManifestSmoke` copies the fixture plugins in `testdata/` to a temporary directory and generates, checks and edits their manifests: a literal-ID plugin, a plugin whose identity is spread over constants of three packages and two modules, a nested plugin, the error cases, overrides, `kiosk.CheckT` and `kiosk.Validate`, the registry JSON (golden bytes, unknown fields ignored) and the version rules. Every fixture module replaces the framework with the stub in `testdata/framework`, so the tests run the local `go` command but need no network and no database.
|
||||
|
||||
@@ -94,10 +94,7 @@ func Parse(data []byte) (Manifest, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
var (
|
||||
pluginIDRe = regexp.MustCompile(`^[a-z][a-z0-9]*\.[a-z][a-z0-9]*$`)
|
||||
releaseVersion = regexp.MustCompile(`^v\d+\.\d+\.\d+$`)
|
||||
)
|
||||
var pluginIDRe = regexp.MustCompile(`^[a-z][a-z0-9]*\.[a-z][a-z0-9]*$`)
|
||||
|
||||
// Validate checks a manifest: the schema is between 1 and SchemaVersion,
|
||||
// the ID and every required ID are lowercase vendor.plugin, module and
|
||||
@@ -141,7 +138,7 @@ func Validate(m Manifest) error {
|
||||
}
|
||||
|
||||
func releaseOrPseudo(v string) bool {
|
||||
return releaseVersion.MatchString(v) || isPseudoVersion(v)
|
||||
return ValidVersion(v) || isPseudoVersion(v)
|
||||
}
|
||||
|
||||
// validPath accepts a slash-separated module or import path.
|
||||
|
||||
@@ -2,6 +2,7 @@ package kiosk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// copyTestdata copies testdata/<names...> into a fresh temp dir, keeping
|
||||
@@ -348,8 +350,140 @@ func (*Plugin) Requires() []string { return []string{dep} }`, "Requires()"},
|
||||
t.Errorf("Parse of a newer schema: err = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("registry", func(t *testing.T) {
|
||||
at := time.Date(2026, 10, 29, 10, 0, 0, 0, time.UTC)
|
||||
plugin := RegistryPlugin{
|
||||
ID: "acme.blog", Name: "Blog", Description: "A blog.", Module: "example.test/sm-blog-plugin",
|
||||
Package: "example.test/sm-blog-plugin", Repository: "https://example.test/sm-blog-plugin.git",
|
||||
Category: "content", Featured: true, IconURL: "https://example.test/icon.png",
|
||||
LatestVersion: "v0.2.5", LatestFramework: "v0.6.0", UpdatedAt: at,
|
||||
}
|
||||
wantPlugin := `{"id":"acme.blog","name":"Blog","description":"A blog.","module":"example.test/sm-blog-plugin",` +
|
||||
`"package":"example.test/sm-blog-plugin","repository":"https://example.test/sm-blog-plugin.git",` +
|
||||
`"category":"content","featured":true,"icon_url":"https://example.test/icon.png",` +
|
||||
`"latest_version":"v0.2.5","latest_framework":"v0.6.0","updated_at":"2026-10-29T10:00:00Z"}`
|
||||
assertJSON(t, ListResponse{Data: []RegistryPlugin{plugin}, Meta: ListMeta{Total: 1}},
|
||||
`{"data":[`+wantPlugin+`],"meta":{"total":1}}`)
|
||||
|
||||
detail := RegistryPluginDetail{
|
||||
RegistryPlugin: plugin, ReadmeMarkdown: "# Blog", ReadmeHTML: "<h1>Blog</h1>",
|
||||
Screenshots: []RegistryScreenshot{{URL: "https://example.test/1.png", Caption: "List"}},
|
||||
Requires: Requires{"acme.translate": "v0.1.0"},
|
||||
}
|
||||
assertJSON(t, DetailResponse{Data: detail}, `{"data":`+wantPlugin[:len(wantPlugin)-1]+
|
||||
`,"readme_markdown":"# Blog","readme_html":"\u003ch1\u003eBlog\u003c/h1\u003e",`+
|
||||
`"screenshots":[{"url":"https://example.test/1.png","caption":"List"}],"requires":{"acme.translate":"v0.1.0"}}}`)
|
||||
|
||||
version := RegistryVersion{Version: "v0.2.5", Framework: "v0.6.0", Requires: Requires{"acme.translate": "v0.1.0"},
|
||||
Module: "example.test/sm-blog-plugin", Package: "example.test/sm-blog-plugin", Commit: "abc123", TaggedAt: at}
|
||||
assertJSON(t, VersionsResponse{Data: []RegistryVersion{version}},
|
||||
`{"data":[{"version":"v0.2.5","framework":"v0.6.0","requires":{"acme.translate":"v0.1.0"},`+
|
||||
`"module":"example.test/sm-blog-plugin","package":"example.test/sm-blog-plugin","commit":"abc123","tagged_at":"2026-10-29T10:00:00Z"}]}`)
|
||||
assertJSON(t, RegistryVersion{Version: "v0.1.0", TaggedAt: at},
|
||||
`{"version":"v0.1.0","framework":"","requires":{},"module":"","package":"","commit":"","tagged_at":"2026-10-29T10:00:00Z"}`)
|
||||
assertJSON(t, ErrorResponse{Error: RegistryError{Code: RegistryErrorNotFound, Message: "no plugin acme.x"}},
|
||||
`{"error":{"code":"not_found","message":"no plugin acme.x"}}`)
|
||||
assertJSON(t, IngestRequest{Repository: "https://example.test/sm-blog-plugin.git"},
|
||||
`{"repository":"https://example.test/sm-blog-plugin.git"}`)
|
||||
assertJSON(t, IngestResponse{Data: IngestResult{Repository: "https://example.test/sm-blog-plugin.git", Queued: true}},
|
||||
`{"data":{"repository":"https://example.test/sm-blog-plugin.git","queued":true}}`)
|
||||
if RegistryAPIPath != "/api/registry/v1" || RegistryErrorInvalidQuery != "invalid_query" {
|
||||
t.Errorf("RegistryAPIPath = %q, RegistryErrorInvalidQuery = %q", RegistryAPIPath, RegistryErrorInvalidQuery)
|
||||
}
|
||||
|
||||
// Clients ignore fields a later v1 server adds.
|
||||
var got DetailResponse
|
||||
extra := `{"data":{"id":"acme.blog","latest_version":"v0.2.5","downloads":12,"requires":{"acme.x":""},"new":{"a":[1]}},"links":{}}`
|
||||
if err := json.Unmarshal([]byte(extra), &got); err != nil {
|
||||
t.Fatalf("unknown fields: %v", err)
|
||||
}
|
||||
if got.Data.ID != "acme.blog" || got.Data.LatestVersion != "v0.2.5" || len(got.Data.Requires) != 1 {
|
||||
t.Errorf("decoded = %+v", got.Data)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("versions", func(t *testing.T) {
|
||||
for _, v := range []string{"v0.6.0", "v10.2.33", "v0.0.0", "v1.0.0"} {
|
||||
if !ValidVersion(v) {
|
||||
t.Errorf("ValidVersion(%q) = false", v)
|
||||
}
|
||||
}
|
||||
for _, v := range []string{"0.6.0", "v0.6", "v0.6.0-rc1", "v0.6.0+meta", "-v0.6.0", "", "v01.2.3", "v0.3.1-4-gabc1234"} {
|
||||
if ValidVersion(v) {
|
||||
t.Errorf("ValidVersion(%q) = true", v)
|
||||
}
|
||||
}
|
||||
cmp := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"v0.10.0", "v0.9.9", 1}, {"v0.9.9", "v0.10.0", -1}, {"v1.2.3", "v1.2.3", 0},
|
||||
{"v2.0.0", "v1.99.99", 1}, {"v0.5.3", "v0.6.0", -1}, {"v0.0.10", "v0.0.9", 1},
|
||||
}
|
||||
for _, c := range cmp {
|
||||
if got := CompareVersions(c.a, c.b); got != c.want {
|
||||
t.Errorf("CompareVersions(%s, %s) = %d, want %d", c.a, c.b, got, c.want)
|
||||
}
|
||||
}
|
||||
for in, want := range map[string]string{
|
||||
"v0.3.1-4-gabc1234": "v0.3.1", "v0.3.1": "v0.3.1", "abc1234": "", "": "",
|
||||
"v0.3.1-rc1": "", "v0.3": "",
|
||||
} {
|
||||
if got := BaseVersion(in); got != want {
|
||||
t.Errorf("BaseVersion(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
pseudo := "v0.5.4-0.20261029101500-abcdefabcdef"
|
||||
sat := []struct {
|
||||
have, min string
|
||||
want bool
|
||||
}{
|
||||
{"v0.6.0", "", true}, {"", "", true}, {"v0.6.0", "v0.6.0", true}, {"v0.7.1", "v0.6.0", true},
|
||||
{"v0.5.3", "v0.6.0", false}, {"", "v0.6.0", false}, {"v0.3.1-4-gabc1234", "v0.3.0", false},
|
||||
{"v0.5.4", pseudo, true}, {"v0.6.0", pseudo, true}, {"v0.5.3", pseudo, false},
|
||||
{"v0.6.0", "latest", false},
|
||||
}
|
||||
for _, c := range sat {
|
||||
if got := Satisfies(c.have, c.min); got != c.want {
|
||||
t.Errorf("Satisfies(%q, %q) = %v, want %v", c.have, c.min, got, c.want)
|
||||
}
|
||||
}
|
||||
versions := []RegistryVersion{
|
||||
{Version: "v0.3.0", Framework: "v0.7.0"},
|
||||
{Version: "v0.2.5", Framework: "v0.6.0"},
|
||||
{Version: "v0.10.0", Framework: "v0.8.0"},
|
||||
{Version: "v0.2.4", Framework: "v0.5.0"},
|
||||
{Version: "v0.2.6-rc1", Framework: "v0.5.0"},
|
||||
}
|
||||
if got, ok := NewestCompatible(versions, "v0.6.2"); !ok || got.Version != "v0.2.5" {
|
||||
t.Errorf("NewestCompatible(v0.6.2) = %+v, %v", got, ok)
|
||||
}
|
||||
if got, ok := NewestCompatible(versions, "v0.9.0"); !ok || got.Version != "v0.10.0" {
|
||||
t.Errorf("NewestCompatible(v0.9.0) = %+v, %v", got, ok)
|
||||
}
|
||||
if _, ok := NewestCompatible(versions, "v0.4.0"); ok {
|
||||
t.Error("NewestCompatible(v0.4.0) found a version")
|
||||
}
|
||||
if _, ok := NewestCompatible(nil, "v1.0.0"); ok {
|
||||
t.Error("NewestCompatible(nil) found a version")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// assertJSON marshals v and compares it with want byte for byte.
|
||||
func assertJSON(t *testing.T, v any, want string) {
|
||||
t.Helper()
|
||||
got, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal %T: %v", v, err)
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Errorf("%T JSON\n got %s\nwant %s", v, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// fakeTB records CheckT's calls.
|
||||
type fakeTB struct {
|
||||
helpers int
|
||||
|
||||
133
modules/kiosk/registry.go
Normal file
133
modules/kiosk/registry.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package kiosk
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The registry API v1 contract. The summercms.io registry encodes its
|
||||
// responses with these types and the summer tool and the admin decode
|
||||
// them, so no other copy of the shape exists. Rules: every response is an
|
||||
// envelope object (data, meta or error); clients ignore unknown fields;
|
||||
// changes within v1 are additive only. The API is GET-only for clients,
|
||||
// needs no credentials and answers with open CORS and
|
||||
// Cache-Control: public, max-age=300 plus an ETag.
|
||||
|
||||
// RegistryAPIPath is the base path of the registry API v1:
|
||||
//
|
||||
// GET {base}/plugins?q=&category= ListResponse
|
||||
// GET {base}/plugins/{id} DetailResponse
|
||||
// GET {base}/plugins/{id}/versions VersionsResponse, newest first
|
||||
// POST {base}/ingest IngestRequest -> 202 IngestResponse (authenticated)
|
||||
//
|
||||
// Errors answer with an ErrorResponse: 404 not_found, 400 invalid_query.
|
||||
const RegistryAPIPath = "/api/registry/v1"
|
||||
|
||||
// Error codes of a RegistryError.
|
||||
const (
|
||||
// RegistryErrorNotFound is the code of a 404: no such plugin.
|
||||
RegistryErrorNotFound = "not_found"
|
||||
// RegistryErrorInvalidQuery is the code of a 400: a bad query parameter.
|
||||
RegistryErrorInvalidQuery = "invalid_query"
|
||||
)
|
||||
|
||||
// RegistryPlugin is one plugin in the registry list.
|
||||
type RegistryPlugin struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Module string `json:"module"`
|
||||
Package string `json:"package"`
|
||||
Repository string `json:"repository"`
|
||||
Category string `json:"category"`
|
||||
Featured bool `json:"featured"`
|
||||
IconURL string `json:"icon_url"`
|
||||
LatestVersion string `json:"latest_version"`
|
||||
LatestFramework string `json:"latest_framework"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// RegistryPluginDetail is one plugin with its README, screenshots and the
|
||||
// requirements of its latest version.
|
||||
type RegistryPluginDetail struct {
|
||||
RegistryPlugin
|
||||
ReadmeMarkdown string `json:"readme_markdown"`
|
||||
ReadmeHTML string `json:"readme_html"`
|
||||
Screenshots []RegistryScreenshot `json:"screenshots"`
|
||||
Requires Requires `json:"requires"`
|
||||
}
|
||||
|
||||
// RegistryScreenshot is one screenshot of a plugin listing.
|
||||
type RegistryScreenshot struct {
|
||||
URL string `json:"url"`
|
||||
Caption string `json:"caption"`
|
||||
}
|
||||
|
||||
// RegistryVersion is one tagged release of a plugin, with the minimums
|
||||
// its plugin.yaml declared at that tag.
|
||||
type RegistryVersion struct {
|
||||
Version string `json:"version"`
|
||||
Framework string `json:"framework"`
|
||||
Requires Requires `json:"requires"`
|
||||
Module string `json:"module"`
|
||||
Package string `json:"package"`
|
||||
Commit string `json:"commit"`
|
||||
TaggedAt time.Time `json:"tagged_at"`
|
||||
}
|
||||
|
||||
// ListResponse answers GET {base}/plugins.
|
||||
type ListResponse struct {
|
||||
Data []RegistryPlugin `json:"data"`
|
||||
Meta ListMeta `json:"meta"`
|
||||
}
|
||||
|
||||
// ListMeta carries the number of plugins that matched.
|
||||
type ListMeta struct {
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// DetailResponse answers GET {base}/plugins/{id}.
|
||||
type DetailResponse struct {
|
||||
Data RegistryPluginDetail `json:"data"`
|
||||
}
|
||||
|
||||
// VersionsResponse answers GET {base}/plugins/{id}/versions, newest first.
|
||||
type VersionsResponse struct {
|
||||
Data []RegistryVersion `json:"data"`
|
||||
}
|
||||
|
||||
// ErrorResponse is the body of every registry error.
|
||||
type ErrorResponse struct {
|
||||
Error RegistryError `json:"error"`
|
||||
}
|
||||
|
||||
// RegistryError is a stable code and a human-readable message.
|
||||
type RegistryError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// IngestRequest is the body of POST {base}/ingest.
|
||||
type IngestRequest struct {
|
||||
Repository string `json:"repository"`
|
||||
}
|
||||
|
||||
// IngestResponse answers an accepted ingest request (202).
|
||||
type IngestResponse struct {
|
||||
Data IngestResult `json:"data"`
|
||||
}
|
||||
|
||||
// IngestResult names the repository queued for ingestion.
|
||||
type IngestResult struct {
|
||||
Repository string `json:"repository"`
|
||||
Queued bool `json:"queued"`
|
||||
}
|
||||
|
||||
// MarshalJSON encodes the map as an object, {} when it is empty, so the
|
||||
// registry never sends null for requires.
|
||||
func (r Requires) MarshalJSON() ([]byte, error) {
|
||||
if r == nil {
|
||||
return []byte("{}"), nil
|
||||
}
|
||||
return json.Marshal(map[string]string(r))
|
||||
}
|
||||
105
modules/kiosk/version.go
Normal file
105
modules/kiosk/version.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package kiosk
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
validVersionRe = regexp.MustCompile(`^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`)
|
||||
describeRe = regexp.MustCompile(`^(v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*))(?:-[0-9]+-g[0-9a-f]+)?$`)
|
||||
pseudoBaseRe = regexp.MustCompile(`^(v[0-9]+\.[0-9]+\.[0-9]+)-`)
|
||||
)
|
||||
|
||||
// ValidVersion reports whether v is a release version in the strict form
|
||||
// vX.Y.Z: no pre-release, no build metadata, no leading zeros.
|
||||
func ValidVersion(v string) bool {
|
||||
return validVersionRe.MatchString(v)
|
||||
}
|
||||
|
||||
// CompareVersions compares two valid versions numerically per component
|
||||
// and returns -1, 0 or +1. It is only defined for versions ValidVersion
|
||||
// accepts; never pass a git describe string (use BaseVersion first).
|
||||
func CompareVersions(a, b string) int {
|
||||
pa := strings.Split(strings.TrimPrefix(a, "v"), ".")
|
||||
pb := strings.Split(strings.TrimPrefix(b, "v"), ".")
|
||||
for i := 0; i < len(pa) && i < len(pb); i++ {
|
||||
if c := compareNumeric(pa[i], pb[i]); c != 0 {
|
||||
return c
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(pa) < len(pb):
|
||||
return -1
|
||||
case len(pa) > len(pb):
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// compareNumeric compares two decimal strings without leading zeros, of
|
||||
// any length.
|
||||
func compareNumeric(a, b string) int {
|
||||
switch {
|
||||
case len(a) != len(b):
|
||||
if len(a) < len(b) {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
case a < b:
|
||||
return -1
|
||||
case a > b:
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// BaseVersion returns the vX.Y.Z tag a `git describe --tags` string starts
|
||||
// from: "v0.3.1" for "v0.3.1" and for "v0.3.1-4-gabc1234". Anything else
|
||||
// (a bare commit hash, an empty string, a pre-release tag) gives "".
|
||||
func BaseVersion(describe string) string {
|
||||
m := describeRe.FindStringSubmatch(describe)
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
// Satisfies reports whether the release have meets the minimum min. An
|
||||
// empty minimum is always met. A pseudo-version minimum such as
|
||||
// vX.Y.Z-0.<timestamp>-<hash> sorts just below vX.Y.Z, so it is met by
|
||||
// vX.Y.Z and later. have must be a valid release; anything else meets
|
||||
// only the empty minimum.
|
||||
func Satisfies(have, min string) bool {
|
||||
if min == "" {
|
||||
return true
|
||||
}
|
||||
if !ValidVersion(have) {
|
||||
return false
|
||||
}
|
||||
if ValidVersion(min) {
|
||||
return CompareVersions(have, min) >= 0
|
||||
}
|
||||
if isPseudoVersion(min) {
|
||||
base := pseudoBaseRe.FindStringSubmatch(min)
|
||||
return base != nil && ValidVersion(base[1]) && CompareVersions(have, base[1]) >= 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NewestCompatible returns the newest release in versions whose framework
|
||||
// minimum the given framework version satisfies, and false when there is
|
||||
// none. Entries whose Version is not a valid release are skipped.
|
||||
func NewestCompatible(versions []RegistryVersion, framework string) (RegistryVersion, bool) {
|
||||
var best RegistryVersion
|
||||
found := false
|
||||
for _, v := range versions {
|
||||
if !ValidVersion(v.Version) || !Satisfies(framework, v.Framework) {
|
||||
continue
|
||||
}
|
||||
if !found || CompareVersions(v.Version, best.Version) > 0 {
|
||||
best, found = v, true
|
||||
}
|
||||
}
|
||||
return best, found
|
||||
}
|
||||
Reference in New Issue
Block a user