feat(plugin): support multi-format manifest sources
This commit is contained in:
@@ -206,11 +206,7 @@ func runPluginCLI(args []string) (bool, int) {
|
||||
}
|
||||
return true, 0
|
||||
case "validate", "compat":
|
||||
if len(args) < 3 {
|
||||
printPluginCLIUsage()
|
||||
return true, 2
|
||||
}
|
||||
artifact, err := validatePluginPathForCLI(args[2], "")
|
||||
artifact, err := runPluginValidatePathCLI(args[2:], "")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
@@ -219,11 +215,7 @@ func runPluginCLI(args []string) (bool, int) {
|
||||
artifact.PluginID, artifact.Version, artifact.SHA256, artifact.APIVersion, artifact.GoVersion, artifact.GOOS, artifact.GOARCH)
|
||||
return true, 0
|
||||
case "source-validate":
|
||||
if len(args) < 3 {
|
||||
printPluginCLIUsage()
|
||||
return true, 2
|
||||
}
|
||||
source, err := validatePluginPathForCLI(args[2], pluginmanager.ArtifactTypeSource)
|
||||
source, err := runPluginValidatePathCLI(args[2:], pluginmanager.ArtifactTypeSource)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
|
||||
346
cmd/gateway/plugin_cli_manifest.go
Normal file
346
cmd/gateway/plugin_cli_manifest.go
Normal file
@@ -0,0 +1,346 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"github.com/tursom/mc-gateway/internal/pluginmanager"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
manifestFormatJSON = "json"
|
||||
manifestFormatJSONC = "jsonc"
|
||||
manifestFormatYAML = "yaml"
|
||||
manifestFormatTOML = "toml"
|
||||
)
|
||||
|
||||
var manifestSourceNames = []string{
|
||||
"manifest.yaml",
|
||||
"manifest.yml",
|
||||
"manifest.toml",
|
||||
"manifest.jsonc",
|
||||
"manifest.json",
|
||||
}
|
||||
|
||||
type pluginManifestSource struct {
|
||||
Path string
|
||||
Format string
|
||||
Data []byte
|
||||
Manifest pluginmanager.Manifest
|
||||
Raw map[string]any
|
||||
CanonicalJSON []byte
|
||||
}
|
||||
|
||||
func readPluginDirManifest(dir, explicitManifestPath string) (pluginmanager.Manifest, map[string]any, error) {
|
||||
source, err := readPluginManifestSource(dir, explicitManifestPath)
|
||||
if err != nil {
|
||||
return pluginmanager.Manifest{}, nil, err
|
||||
}
|
||||
return source.Manifest, source.Raw, nil
|
||||
}
|
||||
|
||||
func readPluginManifestSource(target, explicitManifestPath string) (pluginManifestSource, error) {
|
||||
manifestPath, err := resolveManifestSourcePath(target, explicitManifestPath)
|
||||
if err != nil {
|
||||
return pluginManifestSource{}, err
|
||||
}
|
||||
return readPluginManifestSourceFile(manifestPath)
|
||||
}
|
||||
|
||||
func resolveManifestSourcePath(target, explicitManifestPath string) (string, error) {
|
||||
if explicitManifestPath != "" {
|
||||
return resolveExplicitManifestPath(target, explicitManifestPath)
|
||||
}
|
||||
info, err := os.Stat(target)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
if !isManifestSourceFile(target) {
|
||||
return "", fmt.Errorf("manifest path must be one of %s, got %q", strings.Join(manifestSourceNames, ", "), target)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
var found []string
|
||||
for _, name := range manifestSourceNames {
|
||||
candidate := filepath.Join(target, name)
|
||||
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||
found = append(found, candidate)
|
||||
}
|
||||
}
|
||||
if len(found) == 0 {
|
||||
return "", fmt.Errorf("plugin manifest is required: expected one of %s in %s", strings.Join(manifestSourceNames, ", "), target)
|
||||
}
|
||||
if len(found) > 1 {
|
||||
sort.Strings(found)
|
||||
return "", fmt.Errorf("multiple plugin manifests found: %s; pass --manifest to select one", strings.Join(found, ", "))
|
||||
}
|
||||
return found[0], nil
|
||||
}
|
||||
|
||||
func resolveExplicitManifestPath(target, explicitManifestPath string) (string, error) {
|
||||
candidates := []string{explicitManifestPath}
|
||||
if info, err := os.Stat(target); err == nil && info.IsDir() && !filepath.IsAbs(explicitManifestPath) {
|
||||
candidates = []string{filepath.Join(target, explicitManifestPath), explicitManifestPath}
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
info, err := os.Stat(candidate)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.IsDir() {
|
||||
return "", fmt.Errorf("manifest path %q is a directory", candidate)
|
||||
}
|
||||
if !isManifestSourceFile(candidate) {
|
||||
return "", fmt.Errorf("manifest path must be one of %s, got %q", strings.Join(manifestSourceNames, ", "), candidate)
|
||||
}
|
||||
return candidate, nil
|
||||
}
|
||||
return "", fmt.Errorf("manifest path %q is not readable", explicitManifestPath)
|
||||
}
|
||||
|
||||
func readPluginManifestSourceFile(manifestPath string) (pluginManifestSource, error) {
|
||||
format, err := manifestFormatForPath(manifestPath)
|
||||
if err != nil {
|
||||
return pluginManifestSource{}, err
|
||||
}
|
||||
data, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
return pluginManifestSource{}, err
|
||||
}
|
||||
raw, err := decodeManifestSource(data, format)
|
||||
if err != nil {
|
||||
return pluginManifestSource{}, fmt.Errorf("invalid %s: %w", filepath.Base(manifestPath), err)
|
||||
}
|
||||
canonical, err := json.MarshalIndent(raw, "", " ")
|
||||
if err != nil {
|
||||
return pluginManifestSource{}, err
|
||||
}
|
||||
canonical = append(canonical, '\n')
|
||||
var manifest pluginmanager.Manifest
|
||||
if err := json.Unmarshal(canonical, &manifest); err != nil {
|
||||
return pluginManifestSource{}, fmt.Errorf("invalid %s object: %w", filepath.Base(manifestPath), err)
|
||||
}
|
||||
if manifest.ID == "" {
|
||||
return pluginManifestSource{}, errors.New("manifest id is required")
|
||||
}
|
||||
return pluginManifestSource{
|
||||
Path: manifestPath,
|
||||
Format: format,
|
||||
Data: data,
|
||||
Manifest: manifest,
|
||||
Raw: raw,
|
||||
CanonicalJSON: canonical,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodeManifestSource(data []byte, format string) (map[string]any, error) {
|
||||
var raw any
|
||||
switch format {
|
||||
case manifestFormatJSON:
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case manifestFormatJSONC:
|
||||
stripped, err := stripJSONC(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(stripped, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case manifestFormatYAML:
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case manifestFormatTOML:
|
||||
var table map[string]any
|
||||
if err := toml.Unmarshal(data, &table); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw = table
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported manifest format %q", format)
|
||||
}
|
||||
normalized, ok := normalizeManifestValue(raw).(map[string]any)
|
||||
if !ok {
|
||||
return nil, errors.New("manifest root must be an object")
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeManifestValue(value any) any {
|
||||
switch v := value.(type) {
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(v))
|
||||
for key, item := range v {
|
||||
out[key] = normalizeManifestValue(item)
|
||||
}
|
||||
return out
|
||||
case map[any]any:
|
||||
out := make(map[string]any, len(v))
|
||||
for key, item := range v {
|
||||
out[fmt.Sprint(key)] = normalizeManifestValue(item)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, len(v))
|
||||
for i, item := range v {
|
||||
out[i] = normalizeManifestValue(item)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func manifestFormatForPath(filePath string) (string, error) {
|
||||
switch strings.ToLower(filepath.Base(filePath)) {
|
||||
case "manifest.json":
|
||||
return manifestFormatJSON, nil
|
||||
case "manifest.jsonc":
|
||||
return manifestFormatJSONC, nil
|
||||
case "manifest.yaml", "manifest.yml":
|
||||
return manifestFormatYAML, nil
|
||||
case "manifest.toml":
|
||||
return manifestFormatTOML, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported manifest file %q", filePath)
|
||||
}
|
||||
}
|
||||
|
||||
func isManifestSourceFile(filePath string) bool {
|
||||
_, err := manifestFormatForPath(filePath)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func stripJSONC(data []byte) ([]byte, error) {
|
||||
withoutComments, err := stripJSONCComments(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return stripJSONCTrailingCommas(withoutComments), nil
|
||||
}
|
||||
|
||||
func stripJSONCComments(data []byte) ([]byte, error) {
|
||||
out := make([]byte, 0, len(data))
|
||||
inString := false
|
||||
escaped := false
|
||||
for i := 0; i < len(data); i++ {
|
||||
ch := data[i]
|
||||
if inString {
|
||||
out = append(out, ch)
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if ch == '\\' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if ch == '"' {
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ch == '"' {
|
||||
inString = true
|
||||
out = append(out, ch)
|
||||
continue
|
||||
}
|
||||
if ch == '/' && i+1 < len(data) {
|
||||
next := data[i+1]
|
||||
if next == '/' {
|
||||
out = append(out, ' ', ' ')
|
||||
i += 2
|
||||
for ; i < len(data); i++ {
|
||||
if data[i] == '\n' || data[i] == '\r' {
|
||||
out = append(out, data[i])
|
||||
break
|
||||
}
|
||||
out = append(out, ' ')
|
||||
}
|
||||
continue
|
||||
}
|
||||
if next == '*' {
|
||||
out = append(out, ' ', ' ')
|
||||
i += 2
|
||||
closed := false
|
||||
for ; i < len(data); i++ {
|
||||
if data[i] == '*' && i+1 < len(data) && data[i+1] == '/' {
|
||||
out = append(out, ' ', ' ')
|
||||
i++
|
||||
closed = true
|
||||
break
|
||||
}
|
||||
if data[i] == '\n' || data[i] == '\r' {
|
||||
out = append(out, data[i])
|
||||
} else {
|
||||
out = append(out, ' ')
|
||||
}
|
||||
}
|
||||
if !closed {
|
||||
return nil, errors.New("unterminated block comment")
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, ch)
|
||||
}
|
||||
if inString {
|
||||
return nil, errors.New("unterminated string")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func stripJSONCTrailingCommas(data []byte) []byte {
|
||||
var out bytes.Buffer
|
||||
inString := false
|
||||
escaped := false
|
||||
for i := 0; i < len(data); i++ {
|
||||
ch := data[i]
|
||||
if inString {
|
||||
out.WriteByte(ch)
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if ch == '\\' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if ch == '"' {
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ch == '"' {
|
||||
inString = true
|
||||
out.WriteByte(ch)
|
||||
continue
|
||||
}
|
||||
if ch == ',' {
|
||||
j := i + 1
|
||||
for j < len(data) && isJSONWhitespace(data[j]) {
|
||||
j++
|
||||
}
|
||||
if j < len(data) && (data[j] == '}' || data[j] == ']') {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out.WriteByte(ch)
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func isJSONWhitespace(ch byte) bool {
|
||||
return ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r'
|
||||
}
|
||||
@@ -30,6 +30,7 @@ type pluginBuildCLIOptions struct {
|
||||
BuildType string
|
||||
Out string
|
||||
FromSource string
|
||||
Manifest string
|
||||
SkipTests bool
|
||||
Vendor bool
|
||||
}
|
||||
@@ -42,10 +43,12 @@ type pluginInitCLIOptions struct {
|
||||
Runtime string
|
||||
Module string
|
||||
Extension string
|
||||
Format string
|
||||
}
|
||||
|
||||
type pluginGovernanceCLIOptions struct {
|
||||
Target string
|
||||
Manifest string
|
||||
ConfigPath string
|
||||
ConfigJSON string
|
||||
Profile string
|
||||
@@ -77,10 +80,11 @@ type pluginBuildRuntimeRequest struct {
|
||||
|
||||
type pluginTestCLIOptions struct {
|
||||
Target string
|
||||
Manifest string
|
||||
Profile string
|
||||
ConfigPath string
|
||||
FixturePath string
|
||||
Manifest pluginmanager.Manifest
|
||||
Source pluginmanager.Manifest
|
||||
}
|
||||
|
||||
type cliStaticRuntimeAdapter struct{}
|
||||
@@ -119,7 +123,7 @@ func (goPluginCLIAdapter) Test(ctx context.Context, opts pluginTestCLIOptions) e
|
||||
return err
|
||||
}
|
||||
case "manifest":
|
||||
if _, err := validatePluginDirectoryForCLI(opts.Target); err != nil {
|
||||
if _, err := validatePluginDirectoryForCLI(opts.Target, opts.Manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
case "harness", "protocol-smoke", "conformance":
|
||||
@@ -129,7 +133,7 @@ func (goPluginCLIAdapter) Test(ctx context.Context, opts pluginTestCLIOptions) e
|
||||
if err := validateTestFileIfSet(opts.FixturePath, "fixture"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := validatePluginDirectoryForCLI(opts.Target); err != nil {
|
||||
if _, err := validatePluginDirectoryForCLI(opts.Target, opts.Manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
@@ -281,12 +285,15 @@ func runPluginManifestCLI(args []string) error {
|
||||
}
|
||||
|
||||
func runPluginManifestFormatCLI(args []string) error {
|
||||
target := "manifest.json"
|
||||
target := "."
|
||||
manifestPath := ""
|
||||
artifactType := ""
|
||||
write := false
|
||||
canonicalJSON := false
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
if !strings.HasPrefix(arg, "--") {
|
||||
if target != "manifest.json" {
|
||||
if target != "." {
|
||||
return fmt.Errorf("unexpected argument %q", arg)
|
||||
}
|
||||
target = arg
|
||||
@@ -300,39 +307,68 @@ func runPluginManifestFormatCLI(args []string) error {
|
||||
switch key {
|
||||
case "write":
|
||||
write = parsePluginBoolFlag(value)
|
||||
case "canonical-json":
|
||||
canonicalJSON = parsePluginBoolFlag(value)
|
||||
case "manifest":
|
||||
manifestPath = value
|
||||
case "type":
|
||||
artifactType = value
|
||||
default:
|
||||
return fmt.Errorf("unknown manifest format flag --%s", key)
|
||||
}
|
||||
}
|
||||
manifestPath, err := resolveManifestPath(target)
|
||||
if write && canonicalJSON {
|
||||
return errors.New("--write and --canonical-json are mutually exclusive")
|
||||
}
|
||||
source, err := readPluginManifestSource(target, manifestPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return fmt.Errorf("invalid manifest.json: %w", err)
|
||||
}
|
||||
formatted, err := json.MarshalIndent(raw, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
formatted = append(formatted, '\n')
|
||||
if write {
|
||||
if bytes.Equal(data, formatted) {
|
||||
fmt.Fprintf(os.Stdout, "ok manifest=%s unchanged\n", manifestPath)
|
||||
return nil
|
||||
if canonicalJSON {
|
||||
if artifactType == "" {
|
||||
artifactType = source.Manifest.ArtifactType
|
||||
}
|
||||
if err := os.WriteFile(manifestPath, formatted, 0644); err != nil {
|
||||
if artifactType == "" {
|
||||
artifactType = pluginmanager.ArtifactTypeBinary
|
||||
}
|
||||
switch artifactType {
|
||||
case pluginmanager.ArtifactTypeBinary, pluginmanager.ArtifactTypeSource:
|
||||
default:
|
||||
return fmt.Errorf("unsupported --type %q", artifactType)
|
||||
}
|
||||
canonical, err := materializedManifestJSON(source.Raw, source.Manifest, artifactType, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "ok manifest=%s formatted\n", manifestPath)
|
||||
_, err = os.Stdout.Write(canonical)
|
||||
return err
|
||||
}
|
||||
if artifactType != "" {
|
||||
return errors.New("--type is only valid with --canonical-json")
|
||||
}
|
||||
if write {
|
||||
if source.Format != manifestFormatJSON {
|
||||
fmt.Fprintf(os.Stdout, "ok manifest=%s validated comments_preserved=true\n", source.Path)
|
||||
return nil
|
||||
}
|
||||
if bytes.Equal(source.Data, source.CanonicalJSON) {
|
||||
fmt.Fprintf(os.Stdout, "ok manifest=%s unchanged\n", source.Path)
|
||||
return nil
|
||||
}
|
||||
if err := os.WriteFile(source.Path, source.CanonicalJSON, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "ok manifest=%s formatted\n", source.Path)
|
||||
return nil
|
||||
}
|
||||
_, err = os.Stdout.Write(formatted)
|
||||
if source.Format == manifestFormatJSON {
|
||||
_, err = os.Stdout.Write(source.CanonicalJSON)
|
||||
return err
|
||||
}
|
||||
_, err = os.Stdout.Write(source.Data)
|
||||
if err == nil && len(source.Data) > 0 && source.Data[len(source.Data)-1] != '\n' {
|
||||
fmt.Fprintln(os.Stdout)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -517,6 +553,7 @@ func runPluginInitCLI(args []string) error {
|
||||
Template: "upstream-dialer",
|
||||
Runtime: pluginmanager.RuntimeGoPlugin,
|
||||
Extension: pluginmanager.ExtensionUpstreamConnect,
|
||||
Format: manifestFormatYAML,
|
||||
}
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
@@ -545,6 +582,8 @@ func runPluginInitCLI(args []string) error {
|
||||
opts.Module = value
|
||||
case "extension":
|
||||
opts.Extension = value
|
||||
case "manifest-format":
|
||||
opts.Format = value
|
||||
default:
|
||||
return fmt.Errorf("unknown init flag --%s", key)
|
||||
}
|
||||
@@ -561,6 +600,9 @@ func runPluginInitCLI(args []string) error {
|
||||
if opts.Module == "" {
|
||||
opts.Module = "example.com/" + opts.ID
|
||||
}
|
||||
if err := validateManifestTemplateFormat(opts.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
adapter, err := pluginCLIAdapterForRuntime(opts.Runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -591,6 +633,8 @@ func runPluginBuildCLI(args []string) error {
|
||||
opts.Out = value
|
||||
case "from-source":
|
||||
opts.FromSource = value
|
||||
case "manifest":
|
||||
opts.Manifest = value
|
||||
case "skip-tests":
|
||||
opts.SkipTests = parsePluginBoolFlag(value)
|
||||
case "vendor":
|
||||
@@ -613,12 +657,7 @@ func runPluginBuildCLI(args []string) error {
|
||||
default:
|
||||
return fmt.Errorf("unsupported build --type %q", opts.BuildType)
|
||||
}
|
||||
if !opts.SkipTests {
|
||||
if err := runGoCommand(context.Background(), opts.Dir, "go", "test", "./..."); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
manifest, raw, err := readPluginDirManifest(opts.Dir)
|
||||
manifest, raw, err := readPluginDirManifest(opts.Dir, opts.Manifest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -626,13 +665,20 @@ func runPluginBuildCLI(args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !opts.SkipTests {
|
||||
if err := runGoCommand(context.Background(), opts.Dir, "go", "test", "./..."); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
outDir := opts.Out
|
||||
if outDir == "" || strings.HasSuffix(outDir, string(os.PathSeparator)) {
|
||||
if opts.BuildType == "both" {
|
||||
if outDir == "" {
|
||||
outDir = filepath.Join(opts.Dir, "dist")
|
||||
}
|
||||
} else if outDir == "" || strings.HasSuffix(outDir, string(os.PathSeparator)) {
|
||||
if outDir == "" {
|
||||
outDir = filepath.Join(opts.Dir, "dist")
|
||||
}
|
||||
} else if opts.BuildType == "both" {
|
||||
return errors.New("--out must be a directory when --type both")
|
||||
}
|
||||
if opts.BuildType == "binary" || opts.BuildType == "both" {
|
||||
outPath := opts.Out
|
||||
@@ -678,6 +724,7 @@ func runPluginTestCLI(args []string) error {
|
||||
profile := "unit,manifest"
|
||||
configPath := ""
|
||||
fixturePath := ""
|
||||
manifestPath := ""
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
if !strings.HasPrefix(arg, "--") {
|
||||
@@ -699,6 +746,8 @@ func runPluginTestCLI(args []string) error {
|
||||
configPath = value
|
||||
case "fixture":
|
||||
fixturePath = value
|
||||
case "manifest":
|
||||
manifestPath = value
|
||||
default:
|
||||
return fmt.Errorf("unknown test flag --%s", key)
|
||||
}
|
||||
@@ -725,7 +774,7 @@ func runPluginTestCLI(args []string) error {
|
||||
fmt.Fprintf(os.Stdout, "ok test target=%s profile=%s\n", target, profile)
|
||||
return nil
|
||||
}
|
||||
manifest, _, err := readPluginDirManifest(target)
|
||||
manifest, _, err := readPluginDirManifest(target, manifestPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -735,10 +784,11 @@ func runPluginTestCLI(args []string) error {
|
||||
}
|
||||
if err := adapter.Test(context.Background(), pluginTestCLIOptions{
|
||||
Target: target,
|
||||
Manifest: manifestPath,
|
||||
Profile: profile,
|
||||
ConfigPath: configPath,
|
||||
FixturePath: fixturePath,
|
||||
Manifest: manifest,
|
||||
Source: manifest,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -767,6 +817,8 @@ func parseGovernanceCLIOptions(args []string) (pluginGovernanceCLIOptions, error
|
||||
}
|
||||
i += consumed
|
||||
switch key {
|
||||
case "manifest":
|
||||
opts.Manifest = value
|
||||
case "config":
|
||||
opts.ConfigPath = value
|
||||
case "config-json":
|
||||
@@ -836,7 +888,7 @@ func prepareLocalGovernanceManager(opts pluginGovernanceCLIOptions) (*pluginmana
|
||||
return nil, func() {}, pluginmanager.ArtifactRecord{}, "", err
|
||||
}
|
||||
if info.IsDir() {
|
||||
manifest, raw, err := readPluginDirManifest(targetPath)
|
||||
manifest, raw, err := readPluginDirManifest(targetPath, opts.Manifest)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, func() {}, pluginmanager.ArtifactRecord{}, "", err
|
||||
@@ -925,13 +977,50 @@ func encodePluginCLIJSON(value any) error {
|
||||
return encoder.Encode(value)
|
||||
}
|
||||
|
||||
func runPluginValidatePathCLI(args []string, expectedArtifactType string) (pluginmanager.ArtifactRecord, error) {
|
||||
if len(args) == 0 {
|
||||
return pluginmanager.ArtifactRecord{}, errors.New("plugin validate requires a plugin directory, manifest source, or .mcgp artifact")
|
||||
}
|
||||
target := ""
|
||||
manifestPath := ""
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
if !strings.HasPrefix(arg, "--") {
|
||||
if target != "" {
|
||||
return pluginmanager.ArtifactRecord{}, fmt.Errorf("unexpected argument %q", arg)
|
||||
}
|
||||
target = arg
|
||||
continue
|
||||
}
|
||||
key, value, consumed, err := parsePluginCLIFlag(args, i)
|
||||
if err != nil {
|
||||
return pluginmanager.ArtifactRecord{}, err
|
||||
}
|
||||
i += consumed
|
||||
switch key {
|
||||
case "manifest":
|
||||
manifestPath = value
|
||||
default:
|
||||
return pluginmanager.ArtifactRecord{}, fmt.Errorf("unknown validate flag --%s", key)
|
||||
}
|
||||
}
|
||||
if target == "" {
|
||||
return pluginmanager.ArtifactRecord{}, errors.New("plugin validate requires a plugin directory, manifest source, or .mcgp artifact")
|
||||
}
|
||||
return validatePluginPathForCLIWithManifest(target, expectedArtifactType, manifestPath)
|
||||
}
|
||||
|
||||
func validatePluginPathForCLI(targetPath, expectedArtifactType string) (pluginmanager.ArtifactRecord, error) {
|
||||
return validatePluginPathForCLIWithManifest(targetPath, expectedArtifactType, "")
|
||||
}
|
||||
|
||||
func validatePluginPathForCLIWithManifest(targetPath, expectedArtifactType, manifestPath string) (pluginmanager.ArtifactRecord, error) {
|
||||
info, err := os.Stat(targetPath)
|
||||
if err != nil {
|
||||
return pluginmanager.ArtifactRecord{}, err
|
||||
}
|
||||
if info.IsDir() {
|
||||
artifact, err := validatePluginDirectoryForCLI(targetPath)
|
||||
artifact, err := validatePluginDirectoryForCLI(targetPath, manifestPath)
|
||||
if err != nil {
|
||||
return pluginmanager.ArtifactRecord{}, err
|
||||
}
|
||||
@@ -940,9 +1029,9 @@ func validatePluginPathForCLI(targetPath, expectedArtifactType string) (pluginma
|
||||
}
|
||||
return artifact, nil
|
||||
}
|
||||
if filepath.Base(targetPath) == "manifest.json" {
|
||||
if isManifestSourceFile(targetPath) {
|
||||
dir := filepath.Dir(targetPath)
|
||||
artifact, err := validatePluginDirectoryForCLI(dir)
|
||||
artifact, err := validatePluginDirectoryForCLI(dir, targetPath)
|
||||
if err != nil {
|
||||
return pluginmanager.ArtifactRecord{}, err
|
||||
}
|
||||
@@ -967,8 +1056,8 @@ func validatePluginPathForCLI(targetPath, expectedArtifactType string) (pluginma
|
||||
return store.ValidateAndStore(upload)
|
||||
}
|
||||
|
||||
func validatePluginDirectoryForCLI(dir string) (pluginmanager.ArtifactRecord, error) {
|
||||
manifest, raw, err := readPluginDirManifest(dir)
|
||||
func validatePluginDirectoryForCLI(dir, manifestPath string) (pluginmanager.ArtifactRecord, error) {
|
||||
manifest, raw, err := readPluginDirManifest(dir, manifestPath)
|
||||
if err != nil {
|
||||
return pluginmanager.ArtifactRecord{}, err
|
||||
}
|
||||
@@ -1041,12 +1130,12 @@ func writeGoPluginTemplate(ctx context.Context, opts pluginInitCLIOptions) error
|
||||
return err
|
||||
}
|
||||
files := map[string]string{
|
||||
"go.mod": goModTemplate(opts.Module, repoRoot),
|
||||
"main.go": goPluginMainTemplate(opts),
|
||||
"main_test.go": goPluginTestTemplate(),
|
||||
"README.md": readmeTemplate(opts),
|
||||
"manifest.json": manifestTemplate(opts),
|
||||
"testdata/config.json": configTemplate(opts.Template),
|
||||
"go.mod": goModTemplate(opts.Module, repoRoot),
|
||||
"main.go": goPluginMainTemplate(opts),
|
||||
"main_test.go": goPluginTestTemplate(),
|
||||
"README.md": readmeTemplate(opts),
|
||||
manifestFileNameForFormat(opts.Format): manifestTemplate(opts),
|
||||
"testdata/config.json": configTemplate(opts.Template),
|
||||
}
|
||||
for name, content := range files {
|
||||
target := filepath.Join(opts.Dir, filepath.FromSlash(name))
|
||||
@@ -1061,39 +1150,6 @@ func writeGoPluginTemplate(ctx context.Context, opts pluginInitCLIOptions) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func readPluginDirManifest(dir string) (pluginmanager.Manifest, map[string]any, error) {
|
||||
data, err := os.ReadFile(filepath.Join(dir, "manifest.json"))
|
||||
if err != nil {
|
||||
return pluginmanager.Manifest{}, nil, err
|
||||
}
|
||||
var manifest pluginmanager.Manifest
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return pluginmanager.Manifest{}, nil, fmt.Errorf("invalid manifest.json: %w", err)
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return pluginmanager.Manifest{}, nil, fmt.Errorf("invalid manifest.json object: %w", err)
|
||||
}
|
||||
if manifest.ID == "" {
|
||||
return pluginmanager.Manifest{}, nil, errors.New("manifest id is required")
|
||||
}
|
||||
return manifest, raw, nil
|
||||
}
|
||||
|
||||
func resolveManifestPath(target string) (string, error) {
|
||||
info, err := os.Stat(target)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return filepath.Join(target, "manifest.json"), nil
|
||||
}
|
||||
if filepath.Base(target) != "manifest.json" {
|
||||
return "", fmt.Errorf("manifest path must be manifest.json or a plugin directory, got %q", target)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func materializedManifestJSON(raw map[string]any, manifest pluginmanager.Manifest, artifactType string, vendor bool) ([]byte, error) {
|
||||
clone := make(map[string]any, len(raw)+4)
|
||||
for key, value := range raw {
|
||||
@@ -1267,7 +1323,7 @@ func collectSourcePackageFiles(dir, vendorDir string) ([]sourcePackageFile, erro
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if rel == "manifest.json" {
|
||||
if isManifestSourceFile(rel) {
|
||||
return nil
|
||||
}
|
||||
if sourcePackageEntryAllowed(rel) {
|
||||
@@ -1421,7 +1477,7 @@ func parsePluginCLIFlag(args []string, index int) (string, string, int, error) {
|
||||
|
||||
func isPluginBoolFlag(name string) bool {
|
||||
switch name {
|
||||
case "skip-tests", "vendor", "json", "quiet", "dry-run", "write", "source", "full-desired":
|
||||
case "skip-tests", "vendor", "json", "quiet", "dry-run", "write", "canonical-json", "source", "full-desired":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -1518,12 +1574,53 @@ replace github.com/tursom/mc-gateway => %s
|
||||
`, module, filepath.ToSlash(repoRoot))
|
||||
}
|
||||
|
||||
func validateManifestTemplateFormat(format string) error {
|
||||
switch format {
|
||||
case manifestFormatYAML, "yml", manifestFormatTOML, manifestFormatJSONC, manifestFormatJSON:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unsupported --manifest-format %q", format)
|
||||
}
|
||||
}
|
||||
|
||||
func manifestFileNameForFormat(format string) string {
|
||||
switch format {
|
||||
case manifestFormatJSON:
|
||||
return "manifest.json"
|
||||
case manifestFormatJSONC:
|
||||
return "manifest.jsonc"
|
||||
case manifestFormatTOML:
|
||||
return "manifest.toml"
|
||||
case "yml":
|
||||
return "manifest.yml"
|
||||
default:
|
||||
return "manifest.yaml"
|
||||
}
|
||||
}
|
||||
|
||||
func manifestTemplate(opts pluginInitCLIOptions) string {
|
||||
switch opts.Format {
|
||||
case manifestFormatJSON:
|
||||
return manifestSourceJSONTemplate(opts, false)
|
||||
case manifestFormatJSONC:
|
||||
return manifestSourceJSONTemplate(opts, true)
|
||||
case manifestFormatTOML:
|
||||
return manifestTOMLTemplate(opts)
|
||||
default:
|
||||
return manifestYAMLTemplate(opts)
|
||||
}
|
||||
}
|
||||
|
||||
func manifestSourceJSONTemplate(opts pluginInitCLIOptions, jsonc bool) string {
|
||||
mode := pluginmanager.UpstreamModeDialer
|
||||
if opts.Template == "protocol-proxy" {
|
||||
mode = pluginmanager.UpstreamModeProtocolProxy
|
||||
}
|
||||
manifest := fmt.Sprintf(`{
|
||||
prefix := ""
|
||||
if jsonc {
|
||||
prefix = "// Human-maintained plugin manifest. Build packages normalize this into manifest.json.\n"
|
||||
}
|
||||
manifest := fmt.Sprintf(`%s{
|
||||
"schema_version": "mc-gateway.plugin/v1",
|
||||
"id": %q,
|
||||
"name": %q,
|
||||
@@ -1563,10 +1660,104 @@ func manifestTemplate(opts pluginInitCLIOptions) string {
|
||||
}
|
||||
}
|
||||
}
|
||||
`, opts.ID, opts.Name, opts.Name+" plugin.", opts.Extension, mode)
|
||||
`, prefix, opts.ID, opts.Name, opts.Name+" plugin.", opts.Extension, mode)
|
||||
return manifest
|
||||
}
|
||||
|
||||
func manifestYAMLTemplate(opts pluginInitCLIOptions) string {
|
||||
mode := pluginmanager.UpstreamModeDialer
|
||||
if opts.Template == "protocol-proxy" {
|
||||
mode = pluginmanager.UpstreamModeProtocolProxy
|
||||
}
|
||||
return fmt.Sprintf(`# Human-maintained plugin manifest. Build packages normalize this into manifest.json.
|
||||
schema_version: mc-gateway.plugin/v1
|
||||
id: %q
|
||||
name: %q
|
||||
version: 0.1.0
|
||||
description: %q
|
||||
artifact_type: source
|
||||
runtime:
|
||||
type: go-plugin
|
||||
entry: plugin.so
|
||||
entry_symbol: Plugin
|
||||
build:
|
||||
type: go
|
||||
entry: "."
|
||||
output: plugin.so
|
||||
tags: []
|
||||
vendor_required: false
|
||||
api_version: plugin-api/v1
|
||||
sdk_module: github.com/tursom/mc-gateway/plugin/api
|
||||
sdk_module_version: v0.1.0
|
||||
extension_points:
|
||||
- type: hook
|
||||
key: %q
|
||||
capabilities:
|
||||
upstream_connect:
|
||||
mode: %q
|
||||
runtime_limits:
|
||||
handler_timeout_ms: 3000
|
||||
initial_write_timeout_ms: 1000
|
||||
config_schema:
|
||||
type: object
|
||||
properties:
|
||||
match_host:
|
||||
type: string
|
||||
upstream:
|
||||
type: string
|
||||
`, opts.ID, opts.Name, opts.Name+" plugin.", opts.Extension, mode)
|
||||
}
|
||||
|
||||
func manifestTOMLTemplate(opts pluginInitCLIOptions) string {
|
||||
mode := pluginmanager.UpstreamModeDialer
|
||||
if opts.Template == "protocol-proxy" {
|
||||
mode = pluginmanager.UpstreamModeProtocolProxy
|
||||
}
|
||||
return fmt.Sprintf(`# Human-maintained plugin manifest. Build packages normalize this into manifest.json.
|
||||
schema_version = "mc-gateway.plugin/v1"
|
||||
id = %q
|
||||
name = %q
|
||||
version = "0.1.0"
|
||||
description = %q
|
||||
artifact_type = "source"
|
||||
api_version = "plugin-api/v1"
|
||||
sdk_module = "github.com/tursom/mc-gateway/plugin/api"
|
||||
sdk_module_version = "v0.1.0"
|
||||
|
||||
[runtime]
|
||||
type = "go-plugin"
|
||||
entry = "plugin.so"
|
||||
entry_symbol = "Plugin"
|
||||
|
||||
[build]
|
||||
type = "go"
|
||||
entry = "."
|
||||
output = "plugin.so"
|
||||
tags = []
|
||||
vendor_required = false
|
||||
|
||||
[[extension_points]]
|
||||
type = "hook"
|
||||
key = %q
|
||||
|
||||
[capabilities.upstream_connect]
|
||||
mode = %q
|
||||
|
||||
[runtime_limits]
|
||||
handler_timeout_ms = 3000
|
||||
initial_write_timeout_ms = 1000
|
||||
|
||||
[config_schema]
|
||||
type = "object"
|
||||
|
||||
[config_schema.properties.match_host]
|
||||
type = "string"
|
||||
|
||||
[config_schema.properties.upstream]
|
||||
type = "string"
|
||||
`, opts.ID, opts.Name, opts.Name+" plugin.", opts.Extension, mode)
|
||||
}
|
||||
|
||||
func goPluginMainTemplate(opts pluginInitCLIOptions) string {
|
||||
if opts.Template == "protocol-proxy" {
|
||||
return protocolProxyMainTemplate()
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -25,12 +26,15 @@ func TestPluginInitCreatesBuildableTemplate(t *testing.T) {
|
||||
if code != 0 {
|
||||
t.Fatalf("runPluginCLI(init) code = %d, want 0", code)
|
||||
}
|
||||
for _, name := range []string{"manifest.json", "go.mod", "main.go", "main_test.go", "README.md", "testdata/config.json"} {
|
||||
for _, name := range []string{"manifest.yaml", "go.mod", "main.go", "main_test.go", "README.md", "testdata/config.json"} {
|
||||
if _, err := os.Stat(filepath.Join(dir, filepath.FromSlash(name))); err != nil {
|
||||
t.Fatalf("generated file %s stat error = %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := validatePluginDirectoryForCLI(dir); err != nil {
|
||||
if _, err := os.Stat(filepath.Join(dir, "manifest.json")); err == nil {
|
||||
t.Fatal("plugin init generated manifest.json by default, want manifest.yaml")
|
||||
}
|
||||
if _, err := validatePluginDirectoryForCLI(dir, ""); err != nil {
|
||||
t.Fatalf("validatePluginDirectoryForCLI() error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -63,6 +67,40 @@ func TestPluginBuildSourcePackagesTemplate(t *testing.T) {
|
||||
t.Fatalf("validatePluginPathForCLI(source) error = %v", err)
|
||||
}
|
||||
assertZipContains(t, out, "manifest.json", "go.mod", "main.go", "main_test.go", "README.md", "testdata/config.json")
|
||||
assertZipNotContains(t, out, "manifest.yaml")
|
||||
}
|
||||
|
||||
func TestPluginBuildBothAcceptsOutDirectory(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "both-plugin")
|
||||
handled, code := runPluginCLI([]string{
|
||||
"plugin", "init", dir,
|
||||
"--id", "both-plugin",
|
||||
"--module", "example.com/both-plugin",
|
||||
})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code)
|
||||
}
|
||||
outDir := filepath.Join(t.TempDir(), "packages")
|
||||
handled, code = runPluginCLI([]string{
|
||||
"plugin", "build", dir,
|
||||
"--type", "both",
|
||||
"--out", outDir,
|
||||
"--skip-tests",
|
||||
"--vendor=false",
|
||||
})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(build both) = (%v, %d), want handled code 0", handled, code)
|
||||
}
|
||||
binaryOut := filepath.Join(outDir, "both-plugin.mcgp")
|
||||
sourceOut := filepath.Join(outDir, "both-plugin-source.mcgp")
|
||||
if _, err := validatePluginPathForCLI(binaryOut, "binary"); err != nil {
|
||||
t.Fatalf("validatePluginPathForCLI(binary) error = %v", err)
|
||||
}
|
||||
if _, err := validatePluginPathForCLI(sourceOut, "source"); err != nil {
|
||||
t.Fatalf("validatePluginPathForCLI(source) error = %v", err)
|
||||
}
|
||||
assertZipNotContains(t, binaryOut, "manifest.yaml")
|
||||
assertZipNotContains(t, sourceOut, "manifest.yaml")
|
||||
}
|
||||
|
||||
func TestPluginTestManifestProfile(t *testing.T) {
|
||||
@@ -107,6 +145,7 @@ func TestPluginManifestFormatWrite(t *testing.T) {
|
||||
"plugin", "init", dir,
|
||||
"--id", "format-plugin",
|
||||
"--module", "example.com/format-plugin",
|
||||
"--manifest-format", "json",
|
||||
})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code)
|
||||
@@ -131,6 +170,157 @@ func TestPluginManifestFormatWrite(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginManifestSourceFormats(t *testing.T) {
|
||||
for _, format := range []string{"yaml", "toml", "jsonc", "json"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "format-"+format)
|
||||
handled, code := runPluginCLI([]string{
|
||||
"plugin", "init", dir,
|
||||
"--id", "format-" + format,
|
||||
"--module", "example.com/format-" + format,
|
||||
"--manifest-format", format,
|
||||
})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(init %s) = (%v, %d), want handled code 0", format, handled, code)
|
||||
}
|
||||
source, err := readPluginManifestSource(dir, "")
|
||||
if err != nil {
|
||||
t.Fatalf("readPluginManifestSource(%s) error = %v", format, err)
|
||||
}
|
||||
if source.Manifest.ID != "format-"+format {
|
||||
t.Fatalf("manifest id = %q, want format-%s", source.Manifest.ID, format)
|
||||
}
|
||||
if !json.Valid(source.CanonicalJSON) {
|
||||
t.Fatalf("canonical JSON for %s is invalid:\n%s", format, source.CanonicalJSON)
|
||||
}
|
||||
packaged, err := materializedManifestJSON(source.Raw, source.Manifest, "binary", false)
|
||||
if err != nil {
|
||||
t.Fatalf("materializedManifestJSON(%s) error = %v", format, err)
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(packaged, &raw); err != nil {
|
||||
t.Fatalf("Unmarshal(materialized %s) error = %v", format, err)
|
||||
}
|
||||
if raw["artifact_type"] != "binary" || raw["go_version"] == "" || raw["go_os"] == "" || raw["go_arch"] == "" {
|
||||
t.Fatalf("materialized %s manifest missing package fields: %s", format, packaged)
|
||||
}
|
||||
handled, code = runPluginCLI([]string{"plugin", "validate", dir})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(validate %s) = (%v, %d), want handled code 0", format, handled, code)
|
||||
}
|
||||
handled, code = runPluginCLI([]string{"plugin", "test", dir, "--profile", "manifest"})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(test %s) = (%v, %d), want handled code 0", format, handled, code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginManifestFormatWritePreservesComments(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
file string
|
||||
content string
|
||||
comment string
|
||||
}{
|
||||
{
|
||||
name: "yaml",
|
||||
file: "manifest.yaml",
|
||||
content: "# keep yaml comment\n" + manifestYAMLTemplate(pluginInitCLIOptions{ID: "comment-yaml", Name: "Comment YAML", Extension: "upstream.connect/v1"}),
|
||||
comment: "# keep yaml comment",
|
||||
},
|
||||
{
|
||||
name: "toml",
|
||||
file: "manifest.toml",
|
||||
content: "# keep toml comment\n" + manifestTOMLTemplate(pluginInitCLIOptions{ID: "comment-toml", Name: "Comment TOML", Extension: "upstream.connect/v1"}),
|
||||
comment: "# keep toml comment",
|
||||
},
|
||||
{
|
||||
name: "jsonc",
|
||||
file: "manifest.jsonc",
|
||||
content: strings.Replace(
|
||||
"// keep jsonc comment\n"+strings.TrimSuffix(manifestSourceJSONTemplate(pluginInitCLIOptions{ID: "comment-jsonc", Name: "Comment JSONC", Extension: "upstream.connect/v1"}, true), "\n"),
|
||||
"\n }\n}",
|
||||
"\n },\n}",
|
||||
1,
|
||||
),
|
||||
comment: "// keep jsonc comment",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
manifestPath := filepath.Join(dir, tc.file)
|
||||
if err := os.WriteFile(manifestPath, []byte(tc.content), 0644); err != nil {
|
||||
t.Fatalf("WriteFile(%s) error = %v", tc.file, err)
|
||||
}
|
||||
before, err := readPluginManifestSource(dir, "")
|
||||
if err != nil {
|
||||
t.Fatalf("readPluginManifestSource(before) error = %v", err)
|
||||
}
|
||||
handled, code := runPluginCLI([]string{"plugin", "manifest", "format", dir, "--write"})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(manifest format %s) = (%v, %d), want handled code 0", tc.name, handled, code)
|
||||
}
|
||||
data, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", tc.file, err)
|
||||
}
|
||||
if !strings.Contains(string(data), tc.comment) {
|
||||
t.Fatalf("formatted %s lost comment:\n%s", tc.file, data)
|
||||
}
|
||||
after, err := readPluginManifestSource(dir, "")
|
||||
if err != nil {
|
||||
t.Fatalf("readPluginManifestSource(after) error = %v", err)
|
||||
}
|
||||
if !bytes.Equal(before.CanonicalJSON, after.CanonicalJSON) {
|
||||
t.Fatalf("canonical JSON changed after format\nbefore=%s\nafter=%s", before.CanonicalJSON, after.CanonicalJSON)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginManifestMultipleSourcesRequireExplicitManifest(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "multi-manifest")
|
||||
handled, code := runPluginCLI([]string{
|
||||
"plugin", "init", dir,
|
||||
"--id", "multi-manifest",
|
||||
"--module", "example.com/multi-manifest",
|
||||
})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifestSourceJSONTemplate(pluginInitCLIOptions{ID: "multi-manifest", Name: "Multi Manifest", Extension: "upstream.connect/v1"}, false)), 0644); err != nil {
|
||||
t.Fatalf("WriteFile(manifest.json) error = %v", err)
|
||||
}
|
||||
handled, code = runPluginCLI([]string{"plugin", "validate", dir})
|
||||
if !handled {
|
||||
t.Fatal("runPluginCLI(validate) handled = false")
|
||||
}
|
||||
if code == 0 {
|
||||
t.Fatal("runPluginCLI(validate) code = 0, want failure for multiple manifests")
|
||||
}
|
||||
handled, code = runPluginCLI([]string{"plugin", "validate", dir, "--manifest", "manifest.yaml"})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(validate --manifest) = (%v, %d), want handled code 0", handled, code)
|
||||
}
|
||||
handled, code = runPluginCLI([]string{"plugin", "test", dir, "--profile", "manifest", "--manifest", "manifest.yaml"})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(test --manifest) = (%v, %d), want handled code 0", handled, code)
|
||||
}
|
||||
out := filepath.Join(t.TempDir(), "multi-manifest-source.mcgp")
|
||||
handled, code = runPluginCLI([]string{"plugin", "build", dir, "--type", "source", "--out", out, "--skip-tests", "--vendor=false", "--manifest", "manifest.yaml"})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(build --manifest) = (%v, %d), want handled code 0", handled, code)
|
||||
}
|
||||
if _, err := validatePluginPathForCLI(out, "source"); err != nil {
|
||||
t.Fatalf("validatePluginPathForCLI(source) error = %v", err)
|
||||
}
|
||||
handled, code = runPluginCLI([]string{"plugin", "manifest", "format", dir, "--manifest", "manifest.yaml", "--canonical-json", "--type", "source"})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(manifest format --manifest --canonical-json) = (%v, %d), want handled code 0", handled, code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginGovernanceCommands(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "governance-plugin")
|
||||
handled, code := runPluginCLI([]string{
|
||||
@@ -155,6 +345,7 @@ func TestPluginGovernanceCommands(t *testing.T) {
|
||||
{"plugin", "preflight", artifact, "--config-json", `{"upstream":"127.0.0.1:25566"}`, "--profile", "dev"},
|
||||
{"plugin", "self-test", artifact, "--profile", "dev"},
|
||||
{"plugin", "benchmark", artifact, "--profile", "dev", "--benchmark-profile", "local-fast", "--p95-ms", "1", "--p99-ms", "2", "--error-rate", "0", "--baseline-diff", "0.1"},
|
||||
{"plugin", "preflight", dir, "--manifest", "manifest.yaml", "--config-json", `{"upstream":"127.0.0.1:25566"}`, "--profile", "dev"},
|
||||
} {
|
||||
handled, code = runPluginCLI(tc)
|
||||
if !handled {
|
||||
@@ -339,3 +530,21 @@ func assertZipContains(t *testing.T, zipPath string, names ...string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertZipNotContains(t *testing.T, zipPath string, names ...string) {
|
||||
t.Helper()
|
||||
reader, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenReader(%s) error = %v", zipPath, err)
|
||||
}
|
||||
defer reader.Close()
|
||||
seen := make(map[string]bool, len(reader.File))
|
||||
for _, file := range reader.File {
|
||||
seen[file.Name] = true
|
||||
}
|
||||
for _, name := range names {
|
||||
if seen[name] {
|
||||
t.Fatalf("zip %s unexpectedly contains entry %s", zipPath, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
本文定义插件开发工具链的功能需求和实现边界。目标是让插件作者从新建、开发、测试、打包到发布前检查都使用同一套 `gateway plugin` CLI,而不是在每个示例插件里维护重复脚本。
|
||||
|
||||
本设计以 [plugin-system-design.md](plugin-system-design.md) 和 [plugin-implementation-plan.md](plugin-implementation-plan.md) 为上游约束。插件元数据只以 `manifest.json` 为准,Go 代码中不再维护 `manifestJSON` 或等价重复元数据。
|
||||
本设计以 [plugin-system-design.md](plugin-system-design.md) 和 [plugin-implementation-plan.md](plugin-implementation-plan.md) 为上游约束。插件作者只维护一个 manifest source 文件,支持 `manifest.yaml`、`manifest.yml`、`manifest.toml`、`manifest.jsonc` 或 `manifest.json`;`.mcgp` 包内仍统一物化为 `manifest.json`。Go 代码中不再维护 `manifestJSON` 或等价重复元数据。
|
||||
|
||||
## 目标
|
||||
|
||||
@@ -27,12 +27,12 @@
|
||||
| 决策 | 结论 |
|
||||
| --- | --- |
|
||||
| CLI 命名 | 直接扩展 `gateway plugin init/build/test`,不新增 `dev` 子命名空间 |
|
||||
| 元数据来源 | `manifest.json` 是唯一人工维护的插件元数据来源 |
|
||||
| 元数据来源 | 插件目录只允许一个人工维护的 manifest source;包内可信元数据统一为 canonical `manifest.json` |
|
||||
| 打包入口 | `gateway plugin build` 同时承担 build 和 package,不再要求插件目录自带 zip 脚本 |
|
||||
| 示例插件 | `upstream-rewrite` 和 `mc-auth-proxy` 迁移到标准 CLI,删除重复 `build.sh` 和 `render-manifest` 逻辑 |
|
||||
| runtime 扩展 | CLI 通过 runtime build/test adapter 分发逻辑,命令名不随 runtime 改变 |
|
||||
| 校验边界 | CLI 校验不能替代 gateway 服务端上传、构建、准入和 enable 校验 |
|
||||
| source manifest | 源码目录中的 `manifest.json` 是作者输入;artifact 包内的 `manifest.json` 是构建时物化结果,不作为第二份人工维护数据 |
|
||||
| source manifest | 源码目录中的 `manifest.yaml/yml/toml/jsonc/json` 是作者输入;artifact 包内的 `manifest.json` 是构建时物化结果,不作为第二份人工维护数据 |
|
||||
|
||||
## 命令总览
|
||||
|
||||
@@ -113,6 +113,7 @@ gateway plugin compat dist/my-plugin.mcgp
|
||||
- 不需要手写 zip 命令。
|
||||
- 不需要手写 `render-manifest`。
|
||||
- 不需要在 Go 代码中声明 manifest 元数据。
|
||||
- 默认模板生成 `manifest.yaml`;如需其它格式可使用 `gateway plugin init --manifest-format yaml|toml|jsonc|json`。
|
||||
|
||||
### 本地调试
|
||||
|
||||
@@ -198,7 +199,7 @@ promotion bundle 默认不包含 secret 明文、secret 密文和 runtime state
|
||||
|
||||
Go plugin 模板应至少生成:
|
||||
|
||||
- `manifest.json`
|
||||
- `manifest.yaml`(默认;也支持 `manifest.yml`、`manifest.toml`、`manifest.jsonc`、`manifest.json`)
|
||||
- `go.mod`
|
||||
- `main.go`
|
||||
- `main_test.go`
|
||||
@@ -206,7 +207,7 @@ Go plugin 模板应至少生成:
|
||||
- `testdata/config.json`
|
||||
- `testdata/fixtures/`,按模板放置 harness 输入
|
||||
|
||||
生成的 `manifest.json` 只包含作者应该维护的字段。`go_version`、`go_os`、`go_arch` 等环境相关字段可以为空或使用文档化占位;`build` 时再物化到 artifact manifest。
|
||||
生成的 manifest source 只包含作者应该维护的字段。`go_version`、`go_os`、`go_arch` 等环境相关字段可以为空或使用文档化占位;`build` 时再物化到 artifact manifest。
|
||||
|
||||
## `gateway plugin build`
|
||||
|
||||
@@ -222,6 +223,8 @@ Go plugin 模板应至少生成:
|
||||
| `gateway plugin build . --type both` | 同时生成 binary 和 source `.mcgp` |
|
||||
| `gateway plugin build --from-source source.mcgp --out built.mcgp` | 使用 gateway builder 从 source 包生成 binary 包 |
|
||||
|
||||
当源码目录内存在多个 `manifest.*` 文件,`build` 必须通过 `--manifest <path>` 显式选择源文件;同一规则也适用于 `test`、`validate`、`preflight`、`self-test`、`benchmark` 和 `manifest format`。
|
||||
|
||||
推荐默认输出:
|
||||
|
||||
- `dist/<plugin-id>.mcgp`
|
||||
@@ -231,21 +234,23 @@ Go plugin 模板应至少生成:
|
||||
|
||||
### Manifest 物化规则
|
||||
|
||||
源码目录中的 `manifest.json` 是唯一人工维护文件。`build` 可以在内存中生成 artifact manifest,并写入 `.mcgp` 包内:
|
||||
源码目录中只能存在一个 manifest source 文件。`build` 读取 `manifest.yaml/yml/toml/jsonc/json` 后在内存中生成 artifact manifest,并写入 `.mcgp` 包内的 canonical `manifest.json`:
|
||||
|
||||
- `artifact_type` 按 `--type` 写为 `binary` 或 `source`。
|
||||
- binary 包写入 `runtime.entry=plugin.so`。
|
||||
- Go plugin binary 包写入实际 `go_version`、`go_os`、`go_arch`。
|
||||
- source 包写入 `build.type=go`、`build.entry`、`build.output`、`build.tags` 和 vendor 策略。
|
||||
- 构建 provenance、module summary、artifact sha256 等写入 build report 或服务端 build record,不要求回写源码目录的 `manifest.json`。
|
||||
- 构建 provenance、module summary、artifact sha256 等写入 build report 或服务端 build record,不要求回写源码目录的 manifest source。
|
||||
|
||||
这保证源码仓库里没有第二份需要维护的 manifest,也避免 `manifest.json` 与 Go 代码常量不一致。
|
||||
这保证源码仓库里没有第二份需要维护的 manifest,也避免 manifest source 与 Go 代码常量不一致。
|
||||
|
||||
如果目录中同时存在多个 `manifest.*` 文件,CLI 必须失败并要求传入 `--manifest <path>` 显式选择,避免不同格式的 manifest 分叉。`gateway plugin manifest format --canonical-json --type binary|source` 可查看最终写入对应 `.mcgp` 的规范 JSON;不传 `--type` 时使用 manifest source 中的 `artifact_type`,缺省按 binary 处理。`--write` 对 YAML/TOML/JSONC 必须保留注释,无法保留时不能覆盖源文件。
|
||||
|
||||
### Go Plugin Adapter
|
||||
|
||||
第一版 `go-plugin` build adapter 负责:
|
||||
|
||||
1. 读取并校验 `manifest.json`。
|
||||
1. 读取并校验唯一 manifest source,或通过 `--manifest` 指定的 manifest source。
|
||||
2. 运行 `go test ./...`,除非传入 `--skip-tests`。
|
||||
3. 用固定命令构建 `plugin.so`:`go build -buildmode=plugin -trimpath -buildvcs=false`。
|
||||
4. 用 `go tool nm` 校验 `Plugin` 符号。
|
||||
@@ -324,7 +329,7 @@ type PluginBuildAdapter interface {
|
||||
|
||||
`validate` 应支持三类输入:
|
||||
|
||||
- `manifest.json`
|
||||
- manifest source 文件:`manifest.yaml`、`manifest.yml`、`manifest.toml`、`manifest.jsonc` 或 `manifest.json`
|
||||
- 插件源码目录
|
||||
- `.mcgp` artifact
|
||||
|
||||
@@ -457,7 +462,7 @@ CI 产物应至少保存:
|
||||
- README 使用 `gateway plugin build . --type both`。
|
||||
- README 使用 `gateway plugin test .`。
|
||||
- 删除或降级 `build.sh` 为兼容包装;最终不再作为主路径。
|
||||
- 删除 `cmd/render-manifest`,由 CLI 根据源码 `manifest.json` 生成 artifact manifest。
|
||||
- 删除 `cmd/render-manifest`,由 CLI 根据源码 manifest source 生成 artifact manifest。
|
||||
- 示例插件的测试 fixture 进入 `testdata/fixtures/`。
|
||||
- 示例插件进入 conformance suite;构建失败视为插件 API 回归。
|
||||
|
||||
@@ -483,6 +488,6 @@ CI 产物应至少保存:
|
||||
- 新建 `protocol-proxy` 模板后,能跑通 Minecraft handshake/login smoke fixture。
|
||||
- `upstream-rewrite` 和 `mc-auth-proxy` 示例插件使用标准 CLI 生成 binary/source `.mcgp`。
|
||||
- 生成的 `.mcgp` 能通过现有上传和服务端校验。
|
||||
- `manifest.json` 与 Go 代码不重复维护插件元数据。
|
||||
- manifest source 与 Go 代码不重复维护插件元数据。
|
||||
- Go plugin adapter 之外的 runtime 可以通过 adapter 注册进入同一套 `init/build/test` 命令。
|
||||
- CLI 失败输出能定位到字段、文件或 fixture,而不是只返回通用错误。
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
- 第一版继续使用 Go `-buildmode=plugin` 的进程内 `.so` 插件。
|
||||
- 尽量支持热加载:兼容且未加载过的插件可以不重启加载并启用。
|
||||
- 明确热卸载限制:Go plugin 不能真正从进程中卸载,只能逻辑禁用。
|
||||
- 通过 `manifest.json` 记录插件 ID、版本、目标平台、构建 Go 版本、SDK/API 版本、声明的 extension point 和配置 schema。
|
||||
- 通过 manifest source 记录插件 ID、版本、目标平台、构建 Go 版本、SDK/API 版本、声明的 extension point 和配置 schema;源码目录只维护一份 `manifest.yaml/yml/toml/jsonc/json`,`.mcgp` 包内统一物化为 canonical `manifest.json`。
|
||||
- 插件能力采用 Extension Point 模型,Hook 是其中一种;第一版先落 `upstream.connect/v1`。
|
||||
- `upstream.connect/v1` 必须支持插件返回自管 `net.Conn`,用于实现完整 stream endpoint/protocol proxy。
|
||||
- MC 正版/三方登录、身份映射、forwarding 和登录后的协议处理属于插件业务逻辑,不由 gateway core 拼装。
|
||||
@@ -33,7 +33,7 @@
|
||||
| 决策/功能点 | 当前设计结论 | 阶段 | 主要章节 |
|
||||
| --- | --- | --- | --- |
|
||||
| 插件信任模型 | 第一版只支持可信 native 插件,不把普通插件当不可信代码运行 | 第一版 | Runtime 与权限声明、安全与运维约束 |
|
||||
| 插件包格式 | 管理页统一上传 `.mcgp` zip 包,源码/二进制由 `manifest.json` 的 `artifact_type` 决定 | 第一版 | 包格式、源码包构建 |
|
||||
| 插件包格式 | 管理页统一上传 `.mcgp` zip 包,源码/二进制由包内 canonical `manifest.json` 的 `artifact_type` 决定 | 第一版 | 包格式、源码包构建 |
|
||||
| 自定义扩展名 | 不新增源码包扩展名;同一 `.mcgp` 格式承载 binary/source | 第一版 | 包格式 |
|
||||
| Go plugin ABI | 仅使用 `.mcgp` 内的 `manifest.json` 表示元数据,并记录 Go/API/SDK/ABI fingerprint | 第一版 | Manifest 元数据、Go Plugin ABI Fingerprint |
|
||||
| Admin 管理 | 上传、构建、加载、启用、禁用、切换版本、删除、回滚和审计都进入 Admin/SQLite | 第一版 | 数据模型、生命周期、Admin API |
|
||||
@@ -67,9 +67,9 @@
|
||||
| 管理页上传、构建、加载、启用/禁用、删除和切换版本 | 全部进入 Admin/SQLite 生命周期,删除已加载 native artifact 后提示重启彻底清理 | 数据模型、生命周期、Admin API、Admin 页面 |
|
||||
| 热加载尽量支持,热卸载承认 Go plugin 限制 | 兼容且未加载过 artifact 可热加载;已加载 Go plugin 只能逻辑禁用,不能真正卸载 | 生命周期、热加载和热卸载、Runbook |
|
||||
| 多进程模型实现进程级热卸载 | 预留 `go-plugin-process` runtime,主进程只做管理和 fd 编排,子进程负责数据面;通过退出子进程回收 Go plugin | Go Plugin Process Runtime、第一版默认策略 |
|
||||
| 插件包不新增源码扩展名 | 统一 `.mcgp` zip,源码/二进制由 `manifest.json.artifact_type` 声明 | 插件包格式 |
|
||||
| 插件包不新增源码扩展名 | 统一 `.mcgp` zip,源码/二进制由包内 `manifest.json.artifact_type` 声明 | 插件包格式 |
|
||||
| 支持源码包和构建环境设计 | source `.mcgp` 经受控 builder 生成 `plugin.so`;开发 local-process,生产推荐 container builder 或外部 CI | 源码包构建环境、供应链元数据 |
|
||||
| Manifest 元数据稳定并记录 Go 构建信息 | `manifest.json` 是唯一元数据来源,记录 Go/API/SDK/ABI fingerprint、builder 和 provenance | Manifest 元数据、Go Plugin ABI Fingerprint |
|
||||
| Manifest 元数据稳定并记录 Go 构建信息 | 作者只维护一个 manifest source;包内 canonical `manifest.json` 是服务端唯一可信元数据来源,记录 Go/API/SDK/ABI fingerprint、builder 和 provenance | Manifest 元数据、Go Plugin ABI Fingerprint |
|
||||
| Hook 之外的插件技术方案 | 统一 Extension Point 模型,覆盖 hook、middleware、provider、event subscriber、rule/policy;mock/mixin/monkey patch 不作为生产机制 | Extension Point 设计、Mock 和 Mixin 的定位 |
|
||||
| 沙箱功能要有未来路线 | 第一版不提供沙箱;预留 sandbox-process、WASM、capability enforcement、stream relay 和 egress 策略 | Sandbox Runtime、Runtime Adapter、第一版默认策略 |
|
||||
| Alibaba 非侵入 Go 注入的参考价值 | 作为官方/组织 build-time instrumentation 未来能力,不作为普通运行时插件或热加载机制 | Build-Time Instrumentation |
|
||||
@@ -285,7 +285,7 @@
|
||||
|
||||
## 插件包格式
|
||||
|
||||
管理页上传的插件包统一使用 `.mcgp`,本质是 zip 包。包内内容由 `manifest.json` 决定,不通过扩展名区分源码包和二进制包。
|
||||
管理页上传的插件包统一使用 `.mcgp`,本质是 zip 包。包内内容由 canonical `manifest.json` 决定,不通过扩展名区分源码包和二进制包。开发目录可以维护 `manifest.yaml`、`manifest.yml`、`manifest.toml`、`manifest.jsonc` 或 `manifest.json`,但构建进入 `.mcgp` 时必须统一物化为根目录 `manifest.json`。
|
||||
|
||||
包类型由两个字段表达:
|
||||
|
||||
@@ -313,7 +313,7 @@ upstream-rewrite.mcgp
|
||||
README.md # 可选
|
||||
```
|
||||
|
||||
`manifest.json` 是加载前可读取的元数据,用于避免必须执行插件代码才能知道基础信息。上传阶段只解析 zip 和 manifest,不执行插件代码。
|
||||
包内 `manifest.json` 是加载前可读取的可信元数据,用于避免必须执行插件代码才能知道基础信息。上传阶段只解析 zip 和 manifest,不执行插件代码。
|
||||
|
||||
二进制包 manifest 示例:
|
||||
|
||||
@@ -459,7 +459,7 @@ upstream-rewrite.mcgp
|
||||
}
|
||||
```
|
||||
|
||||
二进制包上传后可以直接登记为 artifact。源码包上传后必须先进入 builder,构建出 `plugin.so` 后再登记为 artifact。加载阶段始终只加载最终产物 `plugin.so`;元数据以已校验入库的 `manifest.json` 为准。
|
||||
二进制包上传后可以直接登记为 artifact。源码包上传后必须先进入 builder,构建出 `plugin.so` 后再登记为 artifact。加载阶段始终只加载最终产物 `plugin.so`;元数据以已校验入库的包内 `manifest.json` 为准。
|
||||
|
||||
开发环境可以允许直接上传 raw `.so`,但生产推荐只接受 `.mcgp`。直接上传 `.so` 时,加载前只能展示文件名、大小和 sha256;生产路径仍应使用 `.mcgp` 提供 `manifest.json`。
|
||||
|
||||
@@ -2009,7 +2009,7 @@ index 规则:
|
||||
|
||||
## Manifest 元数据
|
||||
|
||||
插件包的元数据只来自 `.mcgp` 根目录的 `manifest.json`。上传、准入、构建、兼容性检查和 Admin 展示都必须使用这份静态 manifest;gateway 不通过执行插件代码读取元数据。
|
||||
插件包的元数据只来自 `.mcgp` 根目录的 canonical `manifest.json`。上传、准入、构建、兼容性检查和 Admin 展示都必须使用这份静态 manifest;gateway 不通过执行插件代码读取元数据。源码目录可以使用 YAML、TOML、JSONC 或 JSON 作为唯一 manifest source,但进入 `.mcgp` 前必须规范化为 `manifest.json`。
|
||||
|
||||
Go plugin 只需要导出一个 factory 符号:
|
||||
|
||||
@@ -6370,10 +6370,10 @@ type Gateway interface {
|
||||
- `plugin/api` 稳定 API 文档。
|
||||
- `examples/plugins/upstream-rewrite` 最小模板。
|
||||
- `examples/plugins/mc-auth-proxy` protocol-proxy 模板。
|
||||
- manifest JSON schema。
|
||||
- manifest source 多格式解析和 canonical JSON schema。
|
||||
- 统一的 `gateway plugin init/build/test` 开发工具链。
|
||||
|
||||
详细工具链设计见 [plugin-development-toolchain-design.md](plugin-development-toolchain-design.md)。工具链必须继续遵守 manifest-only 元数据约束:插件作者只维护 `manifest.json`,Go 代码中不再保存 `manifestJSON` 或等价重复元数据。
|
||||
详细工具链设计见 [plugin-development-toolchain-design.md](plugin-development-toolchain-design.md)。工具链必须继续遵守 manifest-only 元数据约束:插件作者只维护一个 manifest source 文件,Go 代码中不再保存 `manifestJSON` 或等价重复元数据;`.mcgp` 包内仍以 canonical `manifest.json` 作为服务端可信边界。
|
||||
|
||||
### CLI 工具
|
||||
|
||||
@@ -6427,7 +6427,7 @@ CLI 规则:
|
||||
本地开发流程:
|
||||
|
||||
1. 从示例复制插件目录。
|
||||
2. 编写 `manifest.json`。
|
||||
2. 编写唯一 manifest source,默认是 `manifest.yaml`。
|
||||
3. 使用与 gateway 匹配的 Go toolchain。
|
||||
4. 运行 `gateway plugin build` 构建 `.mcgp`。
|
||||
5. 通过 Admin 上传。
|
||||
@@ -7354,7 +7354,7 @@ examples/plugins/mc-status-motd/
|
||||
- 定义 `.mcgp` 静态校验规则、大小限制和 zip slip 防护。
|
||||
- 定义插件 ID、handler ID、task ID、secret name 和 extension point 命名规范。
|
||||
- 支持 `artifact_type=binary/source` 和 `runtime.type=go-plugin`。
|
||||
- 确认插件只需要导出 `Plugin` factory,元数据只来自 `manifest.json`。
|
||||
- 确认插件只需要导出 `Plugin` factory,包内元数据只来自 canonical `manifest.json`。
|
||||
- 定义 Go plugin ABI fingerprint schema、计算规则、compat diff 和开发模式 override 语义。
|
||||
- 在 `plugin/api` 中补齐 `APIVersion`、extension point metadata、`ErrPass` 等。
|
||||
- 把 upstream hook 收敛为 request struct。
|
||||
|
||||
@@ -8,3 +8,5 @@ This example demonstrates phase 7 extension points:
|
||||
- `admin.auth.provider/v1` registers an unavailable external provider while preserving local admin fallback.
|
||||
|
||||
It is intended as a conformance fixture and source example for plugin authors.
|
||||
The source manifest is maintained as `manifest.yaml`; packaged `.mcgp` artifacts
|
||||
still contain canonical `manifest.json`.
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"schema_version": "mc-gateway.plugin/v1",
|
||||
"id": "extension-ecosystem-example",
|
||||
"name": "Extension Ecosystem Example",
|
||||
"version": "0.1.0",
|
||||
"description": "Example fixture for route, status, subscriber and provider extension points.",
|
||||
"artifact_type": "binary",
|
||||
"runtime": {
|
||||
"type": "go-plugin",
|
||||
"entry": "plugin.so",
|
||||
"entry_symbol": "Plugin"
|
||||
},
|
||||
"api_version": "plugin-api/v1",
|
||||
"sdk_module": "github.com/tursom/mc-gateway/plugin/api",
|
||||
"sdk_module_version": "v0.1.0",
|
||||
"go_version": "go1.24.0",
|
||||
"go_os": "linux",
|
||||
"go_arch": "amd64",
|
||||
"extension_points": [
|
||||
{ "type": "provider", "key": "route.resolve/v1" },
|
||||
{ "type": "hook", "key": "status.ping/v1" },
|
||||
{ "type": "event", "key": "event.subscriber/v1" },
|
||||
{ "type": "provider", "key": "admin.auth.provider/v1" }
|
||||
],
|
||||
"capabilities": {
|
||||
"extension_points": ["route.resolve/v1", "status.ping/v1", "event.subscriber/v1", "admin.auth.provider/v1"],
|
||||
"route": { "cache_ttl_ms": 60000 },
|
||||
"status": { "hosts": ["blue.example", "red.example"] },
|
||||
"event_subscriber": { "mode": "at_least_once", "max_retry": 3 },
|
||||
"providers": [{ "type": "admin.auth.provider/v1", "name": "external-identity", "fallback": true }]
|
||||
},
|
||||
"runtime_limits": { "handler_timeout_ms": 1000 },
|
||||
"config_schema": { "type": "object" }
|
||||
}
|
||||
49
examples/plugins/extension-ecosystem/manifest.yaml
Normal file
49
examples/plugins/extension-ecosystem/manifest.yaml
Normal file
@@ -0,0 +1,49 @@
|
||||
# Human-maintained plugin manifest. Build packages normalize this into manifest.json.
|
||||
schema_version: mc-gateway.plugin/v1
|
||||
id: extension-ecosystem-example
|
||||
name: Extension Ecosystem Example
|
||||
version: 0.1.0
|
||||
description: Example fixture for route, status, subscriber and provider extension points.
|
||||
artifact_type: binary
|
||||
runtime:
|
||||
type: go-plugin
|
||||
entry: plugin.so
|
||||
entry_symbol: Plugin
|
||||
api_version: plugin-api/v1
|
||||
sdk_module: github.com/tursom/mc-gateway/plugin/api
|
||||
sdk_module_version: v0.1.0
|
||||
go_version: go1.24.0
|
||||
go_os: linux
|
||||
go_arch: amd64
|
||||
extension_points:
|
||||
- type: provider
|
||||
key: route.resolve/v1
|
||||
- type: hook
|
||||
key: status.ping/v1
|
||||
- type: event
|
||||
key: event.subscriber/v1
|
||||
- type: provider
|
||||
key: admin.auth.provider/v1
|
||||
capabilities:
|
||||
extension_points:
|
||||
- route.resolve/v1
|
||||
- status.ping/v1
|
||||
- event.subscriber/v1
|
||||
- admin.auth.provider/v1
|
||||
route:
|
||||
cache_ttl_ms: 60000
|
||||
status:
|
||||
hosts:
|
||||
- blue.example
|
||||
- red.example
|
||||
event_subscriber:
|
||||
mode: at_least_once
|
||||
max_retry: 3
|
||||
providers:
|
||||
- type: admin.auth.provider/v1
|
||||
name: external-identity
|
||||
fallback: true
|
||||
runtime_limits:
|
||||
handler_timeout_ms: 1000
|
||||
config_schema:
|
||||
type: object
|
||||
@@ -18,6 +18,8 @@ Build and package:
|
||||
|
||||
The binary package is written to `dist/mc-auth-proxy.mcgp`; the source package
|
||||
is written to `dist/mc-auth-proxy-source.mcgp`.
|
||||
The source manifest is maintained as `manifest.yaml`; packaged `.mcgp` artifacts
|
||||
still contain canonical `manifest.json`.
|
||||
|
||||
Build the source package through the gateway builder:
|
||||
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
{
|
||||
"schema_version": "mc-gateway.plugin/v1",
|
||||
"id": "mc-auth-proxy",
|
||||
"name": "Minecraft Auth Proxy",
|
||||
"version": "0.1.0",
|
||||
"description": "Protocol-proxy example that owns Minecraft login handling and returns a stable fixture disconnect.",
|
||||
"artifact_type": "binary",
|
||||
"runtime": {
|
||||
"type": "go-plugin",
|
||||
"entry": "plugin.so",
|
||||
"entry_symbol": "Plugin"
|
||||
},
|
||||
"api_version": "plugin-api/v1",
|
||||
"sdk_module": "github.com/tursom/mc-gateway/plugin/api",
|
||||
"sdk_module_version": "v0.1.0",
|
||||
"go_version": "go1.24.0",
|
||||
"go_os": "linux",
|
||||
"go_arch": "amd64",
|
||||
"extension_points": [
|
||||
{ "type": "hook", "key": "upstream.connect/v1" }
|
||||
],
|
||||
"capabilities": {
|
||||
"upstream_connect": { "mode": "protocol-proxy" },
|
||||
"minecraft": {
|
||||
"protocol_versions": {
|
||||
"min": 47,
|
||||
"max": 767,
|
||||
"tested": [47, 760, 763, 767],
|
||||
"unsupported_policy": "kick"
|
||||
},
|
||||
"states": {
|
||||
"status": "transparent",
|
||||
"login": "handled",
|
||||
"configuration": "transparent",
|
||||
"play": "transparent"
|
||||
},
|
||||
"auth_modes": ["fixture"],
|
||||
"forwarding": {
|
||||
"supported": ["none", "velocity-modern"],
|
||||
"default": "none",
|
||||
"requires_secret": false
|
||||
},
|
||||
"unsupported_policy": "kick",
|
||||
"modded": {
|
||||
"forge": "transparent",
|
||||
"fabric": "transparent",
|
||||
"fml": "unsupported",
|
||||
"unknown": "pass"
|
||||
}
|
||||
}
|
||||
},
|
||||
"runtime_limits": {
|
||||
"handler_timeout_ms": 3000,
|
||||
"initial_write_timeout_ms": 1000
|
||||
},
|
||||
"events": [
|
||||
{ "name": "auth.success", "fields": ["result", "mode"] },
|
||||
{ "name": "auth.failure", "fields": ["result", "mode"] }
|
||||
],
|
||||
"custom_metrics": [
|
||||
{ "name": "auth.attempts", "type": "counter", "labels": ["result", "mode"] }
|
||||
],
|
||||
"external_dependencies": [
|
||||
{
|
||||
"name": "backend",
|
||||
"endpoint": "tcp://",
|
||||
"purpose": "auth",
|
||||
"required": true,
|
||||
"timeout": "3s",
|
||||
"retry": 0,
|
||||
"fail_policy": "fail_closed",
|
||||
"data_classes": ["operational"]
|
||||
}
|
||||
],
|
||||
"background_tasks": [
|
||||
{ "id": "profile-cache-gc", "name": "Profile cache GC", "mode": "manual", "manual": true, "timeout": "1s" }
|
||||
],
|
||||
"data_stores": [
|
||||
{ "name": "profile-cache", "schema_version": 1, "data_class": "profile_cache", "quota_bytes": 1048576, "retention": "24h", "exportable": false }
|
||||
],
|
||||
"file_stores": [
|
||||
{ "namespace": "cache", "data_class": "profile_cache", "quota_bytes": 1048576, "retention": "24h" },
|
||||
{ "namespace": "diagnostic", "data_class": "diagnostic", "quota_bytes": 1048576, "retention": "24h" }
|
||||
],
|
||||
"config_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"match_host": { "type": "string" },
|
||||
"fixture_accept": { "type": "boolean" },
|
||||
"disconnect_message": { "type": "string" },
|
||||
"backend": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
113
examples/plugins/mc-auth-proxy/manifest.yaml
Normal file
113
examples/plugins/mc-auth-proxy/manifest.yaml
Normal file
@@ -0,0 +1,113 @@
|
||||
# Human-maintained plugin manifest. Build packages normalize this into manifest.json.
|
||||
schema_version: mc-gateway.plugin/v1
|
||||
id: mc-auth-proxy
|
||||
name: Minecraft Auth Proxy
|
||||
version: 0.1.0
|
||||
description: Protocol-proxy example that owns Minecraft login handling and returns a stable fixture disconnect.
|
||||
artifact_type: binary
|
||||
runtime:
|
||||
type: go-plugin
|
||||
entry: plugin.so
|
||||
entry_symbol: Plugin
|
||||
api_version: plugin-api/v1
|
||||
sdk_module: github.com/tursom/mc-gateway/plugin/api
|
||||
sdk_module_version: v0.1.0
|
||||
go_version: go1.24.0
|
||||
go_os: linux
|
||||
go_arch: amd64
|
||||
extension_points:
|
||||
- type: hook
|
||||
key: upstream.connect/v1
|
||||
capabilities:
|
||||
upstream_connect:
|
||||
mode: protocol-proxy
|
||||
minecraft:
|
||||
protocol_versions:
|
||||
min: 47
|
||||
max: 767
|
||||
tested:
|
||||
- 47
|
||||
- 760
|
||||
- 763
|
||||
- 767
|
||||
unsupported_policy: kick
|
||||
states:
|
||||
status: transparent
|
||||
login: handled
|
||||
configuration: transparent
|
||||
play: transparent
|
||||
auth_modes:
|
||||
- fixture
|
||||
forwarding:
|
||||
supported:
|
||||
- none
|
||||
- velocity-modern
|
||||
default: none
|
||||
requires_secret: false
|
||||
unsupported_policy: kick
|
||||
modded:
|
||||
forge: transparent
|
||||
fabric: transparent
|
||||
fml: unsupported
|
||||
unknown: pass
|
||||
runtime_limits:
|
||||
handler_timeout_ms: 3000
|
||||
initial_write_timeout_ms: 1000
|
||||
events:
|
||||
- name: auth.success
|
||||
fields:
|
||||
- result
|
||||
- mode
|
||||
- name: auth.failure
|
||||
fields:
|
||||
- result
|
||||
- mode
|
||||
custom_metrics:
|
||||
- name: auth.attempts
|
||||
type: counter
|
||||
labels:
|
||||
- result
|
||||
- mode
|
||||
external_dependencies:
|
||||
- name: backend
|
||||
endpoint: tcp://
|
||||
purpose: auth
|
||||
required: true
|
||||
timeout: 3s
|
||||
retry: 0
|
||||
fail_policy: fail_closed
|
||||
data_classes:
|
||||
- operational
|
||||
background_tasks:
|
||||
- id: profile-cache-gc
|
||||
name: Profile cache GC
|
||||
mode: manual
|
||||
manual: true
|
||||
timeout: 1s
|
||||
data_stores:
|
||||
- name: profile-cache
|
||||
schema_version: 1
|
||||
data_class: profile_cache
|
||||
quota_bytes: 1048576
|
||||
retention: 24h
|
||||
exportable: false
|
||||
file_stores:
|
||||
- namespace: cache
|
||||
data_class: profile_cache
|
||||
quota_bytes: 1048576
|
||||
retention: 24h
|
||||
- namespace: diagnostic
|
||||
data_class: diagnostic
|
||||
quota_bytes: 1048576
|
||||
retention: 24h
|
||||
config_schema:
|
||||
type: object
|
||||
properties:
|
||||
match_host:
|
||||
type: string
|
||||
fixture_accept:
|
||||
type: boolean
|
||||
disconnect_message:
|
||||
type: string
|
||||
backend:
|
||||
type: string
|
||||
@@ -14,6 +14,8 @@ Build and package:
|
||||
|
||||
The binary package is written to `dist/upstream-rewrite.mcgp`; the source
|
||||
package is written to `dist/upstream-rewrite-source.mcgp`.
|
||||
The source manifest is maintained as `manifest.yaml`; packaged `.mcgp` artifacts
|
||||
still contain canonical `manifest.json`.
|
||||
|
||||
Build the source package through the gateway builder:
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"schema_version": "mc-gateway.plugin/v1",
|
||||
"id": "upstream-rewrite",
|
||||
"name": "Upstream Rewrite",
|
||||
"version": "0.1.0",
|
||||
"description": "Rewrite selected upstream targets before dialing.",
|
||||
"artifact_type": "binary",
|
||||
"runtime": {
|
||||
"type": "go-plugin",
|
||||
"entry": "plugin.so",
|
||||
"entry_symbol": "Plugin"
|
||||
},
|
||||
"api_version": "plugin-api/v1",
|
||||
"sdk_module": "github.com/tursom/mc-gateway/plugin/api",
|
||||
"sdk_module_version": "v0.1.0",
|
||||
"go_version": "go1.24.4",
|
||||
"go_os": "linux",
|
||||
"go_arch": "amd64",
|
||||
"extension_points": [
|
||||
{ "type": "hook", "key": "upstream.connect/v1" }
|
||||
],
|
||||
"capabilities": {
|
||||
"extension_points": ["upstream.connect/v1"],
|
||||
"network": { "outbound": ["tcp:*:*"] },
|
||||
"filesystem": { "read": [], "write": [] },
|
||||
"env": []
|
||||
},
|
||||
"runtime_limits": {
|
||||
"handler_timeout_ms": 3000
|
||||
},
|
||||
"config_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"match_host": { "type": "string" },
|
||||
"upstream": { "type": "string" }
|
||||
},
|
||||
"required": ["upstream"]
|
||||
}
|
||||
}
|
||||
41
examples/plugins/upstream-rewrite/manifest.yaml
Normal file
41
examples/plugins/upstream-rewrite/manifest.yaml
Normal file
@@ -0,0 +1,41 @@
|
||||
# Human-maintained plugin manifest. Build packages normalize this into manifest.json.
|
||||
schema_version: mc-gateway.plugin/v1
|
||||
id: upstream-rewrite
|
||||
name: Upstream Rewrite
|
||||
version: 0.1.0
|
||||
description: Rewrite selected upstream targets before dialing.
|
||||
artifact_type: binary
|
||||
runtime:
|
||||
type: go-plugin
|
||||
entry: plugin.so
|
||||
entry_symbol: Plugin
|
||||
api_version: plugin-api/v1
|
||||
sdk_module: github.com/tursom/mc-gateway/plugin/api
|
||||
sdk_module_version: v0.1.0
|
||||
go_version: go1.24.4
|
||||
go_os: linux
|
||||
go_arch: amd64
|
||||
extension_points:
|
||||
- type: hook
|
||||
key: upstream.connect/v1
|
||||
capabilities:
|
||||
extension_points:
|
||||
- upstream.connect/v1
|
||||
network:
|
||||
outbound:
|
||||
- tcp:*:*
|
||||
filesystem:
|
||||
read: []
|
||||
write: []
|
||||
env: []
|
||||
runtime_limits:
|
||||
handler_timeout_ms: 3000
|
||||
config_schema:
|
||||
type: object
|
||||
properties:
|
||||
match_host:
|
||||
type: string
|
||||
upstream:
|
||||
type: string
|
||||
required:
|
||||
- upstream
|
||||
2
go.mod
2
go.mod
@@ -7,11 +7,13 @@ toolchain go1.24.4
|
||||
require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/pelletier/go-toml/v2 v2.4.2
|
||||
github.com/pires/go-proxyproto v0.8.1
|
||||
github.com/quic-go/quic-go v0.52.0
|
||||
github.com/rs/zerolog v1.33.0
|
||||
github.com/xtaci/kcp-go v5.4.20+incompatible
|
||||
golang.org/x/crypto v0.43.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.45.0
|
||||
)
|
||||
|
||||
|
||||
3
go.sum
3
go.sum
@@ -62,6 +62,8 @@ github.com/onsi/ginkgo/v2 v2.9.5 h1:+6Hr4uxzP4XIUyAkg61dWBw8lb/gc4/X5luuxN/EC+Q=
|
||||
github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k=
|
||||
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
|
||||
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
|
||||
github.com/pelletier/go-toml/v2 v2.4.2 h1:M2fKKbmyvI+hGId/D0W64qDBMVhJnNR10O5gIbMc//Q=
|
||||
github.com/pelletier/go-toml/v2 v2.4.2/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0=
|
||||
github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
@@ -157,6 +159,7 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
Reference in New Issue
Block a user