feat(plugin): support multi-format manifest sources

This commit is contained in:
2026-06-27 18:28:03 +08:00
parent e4dfcb62cb
commit 7a5664ab89
17 changed files with 1079 additions and 289 deletions

View File

@@ -206,11 +206,7 @@ func runPluginCLI(args []string) (bool, int) {
} }
return true, 0 return true, 0
case "validate", "compat": case "validate", "compat":
if len(args) < 3 { artifact, err := runPluginValidatePathCLI(args[2:], "")
printPluginCLIUsage()
return true, 2
}
artifact, err := validatePluginPathForCLI(args[2], "")
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
return true, 1 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) artifact.PluginID, artifact.Version, artifact.SHA256, artifact.APIVersion, artifact.GoVersion, artifact.GOOS, artifact.GOARCH)
return true, 0 return true, 0
case "source-validate": case "source-validate":
if len(args) < 3 { source, err := runPluginValidatePathCLI(args[2:], pluginmanager.ArtifactTypeSource)
printPluginCLIUsage()
return true, 2
}
source, err := validatePluginPathForCLI(args[2], pluginmanager.ArtifactTypeSource)
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
return true, 1 return true, 1

View 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'
}

View File

@@ -30,6 +30,7 @@ type pluginBuildCLIOptions struct {
BuildType string BuildType string
Out string Out string
FromSource string FromSource string
Manifest string
SkipTests bool SkipTests bool
Vendor bool Vendor bool
} }
@@ -42,10 +43,12 @@ type pluginInitCLIOptions struct {
Runtime string Runtime string
Module string Module string
Extension string Extension string
Format string
} }
type pluginGovernanceCLIOptions struct { type pluginGovernanceCLIOptions struct {
Target string Target string
Manifest string
ConfigPath string ConfigPath string
ConfigJSON string ConfigJSON string
Profile string Profile string
@@ -77,10 +80,11 @@ type pluginBuildRuntimeRequest struct {
type pluginTestCLIOptions struct { type pluginTestCLIOptions struct {
Target string Target string
Manifest string
Profile string Profile string
ConfigPath string ConfigPath string
FixturePath string FixturePath string
Manifest pluginmanager.Manifest Source pluginmanager.Manifest
} }
type cliStaticRuntimeAdapter struct{} type cliStaticRuntimeAdapter struct{}
@@ -119,7 +123,7 @@ func (goPluginCLIAdapter) Test(ctx context.Context, opts pluginTestCLIOptions) e
return err return err
} }
case "manifest": case "manifest":
if _, err := validatePluginDirectoryForCLI(opts.Target); err != nil { if _, err := validatePluginDirectoryForCLI(opts.Target, opts.Manifest); err != nil {
return err return err
} }
case "harness", "protocol-smoke", "conformance": 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 { if err := validateTestFileIfSet(opts.FixturePath, "fixture"); err != nil {
return err return err
} }
if _, err := validatePluginDirectoryForCLI(opts.Target); err != nil { if _, err := validatePluginDirectoryForCLI(opts.Target, opts.Manifest); err != nil {
return err return err
} }
default: default:
@@ -281,12 +285,15 @@ func runPluginManifestCLI(args []string) error {
} }
func runPluginManifestFormatCLI(args []string) error { func runPluginManifestFormatCLI(args []string) error {
target := "manifest.json" target := "."
manifestPath := ""
artifactType := ""
write := false write := false
canonicalJSON := false
for i := 0; i < len(args); i++ { for i := 0; i < len(args); i++ {
arg := args[i] arg := args[i]
if !strings.HasPrefix(arg, "--") { if !strings.HasPrefix(arg, "--") {
if target != "manifest.json" { if target != "." {
return fmt.Errorf("unexpected argument %q", arg) return fmt.Errorf("unexpected argument %q", arg)
} }
target = arg target = arg
@@ -300,39 +307,68 @@ func runPluginManifestFormatCLI(args []string) error {
switch key { switch key {
case "write": case "write":
write = parsePluginBoolFlag(value) write = parsePluginBoolFlag(value)
case "canonical-json":
canonicalJSON = parsePluginBoolFlag(value)
case "manifest":
manifestPath = value
case "type":
artifactType = value
default: default:
return fmt.Errorf("unknown manifest format flag --%s", key) 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 { if err != nil {
return err return err
} }
data, err := os.ReadFile(manifestPath) if canonicalJSON {
if err != nil { if artifactType == "" {
return err artifactType = source.Manifest.ArtifactType
}
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 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 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 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 return err
} }
@@ -517,6 +553,7 @@ func runPluginInitCLI(args []string) error {
Template: "upstream-dialer", Template: "upstream-dialer",
Runtime: pluginmanager.RuntimeGoPlugin, Runtime: pluginmanager.RuntimeGoPlugin,
Extension: pluginmanager.ExtensionUpstreamConnect, Extension: pluginmanager.ExtensionUpstreamConnect,
Format: manifestFormatYAML,
} }
for i := 0; i < len(args); i++ { for i := 0; i < len(args); i++ {
arg := args[i] arg := args[i]
@@ -545,6 +582,8 @@ func runPluginInitCLI(args []string) error {
opts.Module = value opts.Module = value
case "extension": case "extension":
opts.Extension = value opts.Extension = value
case "manifest-format":
opts.Format = value
default: default:
return fmt.Errorf("unknown init flag --%s", key) return fmt.Errorf("unknown init flag --%s", key)
} }
@@ -561,6 +600,9 @@ func runPluginInitCLI(args []string) error {
if opts.Module == "" { if opts.Module == "" {
opts.Module = "example.com/" + opts.ID opts.Module = "example.com/" + opts.ID
} }
if err := validateManifestTemplateFormat(opts.Format); err != nil {
return err
}
adapter, err := pluginCLIAdapterForRuntime(opts.Runtime) adapter, err := pluginCLIAdapterForRuntime(opts.Runtime)
if err != nil { if err != nil {
return err return err
@@ -591,6 +633,8 @@ func runPluginBuildCLI(args []string) error {
opts.Out = value opts.Out = value
case "from-source": case "from-source":
opts.FromSource = value opts.FromSource = value
case "manifest":
opts.Manifest = value
case "skip-tests": case "skip-tests":
opts.SkipTests = parsePluginBoolFlag(value) opts.SkipTests = parsePluginBoolFlag(value)
case "vendor": case "vendor":
@@ -613,12 +657,7 @@ func runPluginBuildCLI(args []string) error {
default: default:
return fmt.Errorf("unsupported build --type %q", opts.BuildType) return fmt.Errorf("unsupported build --type %q", opts.BuildType)
} }
if !opts.SkipTests { manifest, raw, err := readPluginDirManifest(opts.Dir, opts.Manifest)
if err := runGoCommand(context.Background(), opts.Dir, "go", "test", "./..."); err != nil {
return err
}
}
manifest, raw, err := readPluginDirManifest(opts.Dir)
if err != nil { if err != nil {
return err return err
} }
@@ -626,13 +665,20 @@ func runPluginBuildCLI(args []string) error {
if err != nil { if err != nil {
return err return err
} }
if !opts.SkipTests {
if err := runGoCommand(context.Background(), opts.Dir, "go", "test", "./..."); err != nil {
return err
}
}
outDir := opts.Out 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 == "" { if outDir == "" {
outDir = filepath.Join(opts.Dir, "dist") 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" { if opts.BuildType == "binary" || opts.BuildType == "both" {
outPath := opts.Out outPath := opts.Out
@@ -678,6 +724,7 @@ func runPluginTestCLI(args []string) error {
profile := "unit,manifest" profile := "unit,manifest"
configPath := "" configPath := ""
fixturePath := "" fixturePath := ""
manifestPath := ""
for i := 0; i < len(args); i++ { for i := 0; i < len(args); i++ {
arg := args[i] arg := args[i]
if !strings.HasPrefix(arg, "--") { if !strings.HasPrefix(arg, "--") {
@@ -699,6 +746,8 @@ func runPluginTestCLI(args []string) error {
configPath = value configPath = value
case "fixture": case "fixture":
fixturePath = value fixturePath = value
case "manifest":
manifestPath = value
default: default:
return fmt.Errorf("unknown test flag --%s", key) 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) fmt.Fprintf(os.Stdout, "ok test target=%s profile=%s\n", target, profile)
return nil return nil
} }
manifest, _, err := readPluginDirManifest(target) manifest, _, err := readPluginDirManifest(target, manifestPath)
if err != nil { if err != nil {
return err return err
} }
@@ -735,10 +784,11 @@ func runPluginTestCLI(args []string) error {
} }
if err := adapter.Test(context.Background(), pluginTestCLIOptions{ if err := adapter.Test(context.Background(), pluginTestCLIOptions{
Target: target, Target: target,
Manifest: manifestPath,
Profile: profile, Profile: profile,
ConfigPath: configPath, ConfigPath: configPath,
FixturePath: fixturePath, FixturePath: fixturePath,
Manifest: manifest, Source: manifest,
}); err != nil { }); err != nil {
return err return err
} }
@@ -767,6 +817,8 @@ func parseGovernanceCLIOptions(args []string) (pluginGovernanceCLIOptions, error
} }
i += consumed i += consumed
switch key { switch key {
case "manifest":
opts.Manifest = value
case "config": case "config":
opts.ConfigPath = value opts.ConfigPath = value
case "config-json": case "config-json":
@@ -836,7 +888,7 @@ func prepareLocalGovernanceManager(opts pluginGovernanceCLIOptions) (*pluginmana
return nil, func() {}, pluginmanager.ArtifactRecord{}, "", err return nil, func() {}, pluginmanager.ArtifactRecord{}, "", err
} }
if info.IsDir() { if info.IsDir() {
manifest, raw, err := readPluginDirManifest(targetPath) manifest, raw, err := readPluginDirManifest(targetPath, opts.Manifest)
if err != nil { if err != nil {
cleanup() cleanup()
return nil, func() {}, pluginmanager.ArtifactRecord{}, "", err return nil, func() {}, pluginmanager.ArtifactRecord{}, "", err
@@ -925,13 +977,50 @@ func encodePluginCLIJSON(value any) error {
return encoder.Encode(value) 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) { 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) info, err := os.Stat(targetPath)
if err != nil { if err != nil {
return pluginmanager.ArtifactRecord{}, err return pluginmanager.ArtifactRecord{}, err
} }
if info.IsDir() { if info.IsDir() {
artifact, err := validatePluginDirectoryForCLI(targetPath) artifact, err := validatePluginDirectoryForCLI(targetPath, manifestPath)
if err != nil { if err != nil {
return pluginmanager.ArtifactRecord{}, err return pluginmanager.ArtifactRecord{}, err
} }
@@ -940,9 +1029,9 @@ func validatePluginPathForCLI(targetPath, expectedArtifactType string) (pluginma
} }
return artifact, nil return artifact, nil
} }
if filepath.Base(targetPath) == "manifest.json" { if isManifestSourceFile(targetPath) {
dir := filepath.Dir(targetPath) dir := filepath.Dir(targetPath)
artifact, err := validatePluginDirectoryForCLI(dir) artifact, err := validatePluginDirectoryForCLI(dir, targetPath)
if err != nil { if err != nil {
return pluginmanager.ArtifactRecord{}, err return pluginmanager.ArtifactRecord{}, err
} }
@@ -967,8 +1056,8 @@ func validatePluginPathForCLI(targetPath, expectedArtifactType string) (pluginma
return store.ValidateAndStore(upload) return store.ValidateAndStore(upload)
} }
func validatePluginDirectoryForCLI(dir string) (pluginmanager.ArtifactRecord, error) { func validatePluginDirectoryForCLI(dir, manifestPath string) (pluginmanager.ArtifactRecord, error) {
manifest, raw, err := readPluginDirManifest(dir) manifest, raw, err := readPluginDirManifest(dir, manifestPath)
if err != nil { if err != nil {
return pluginmanager.ArtifactRecord{}, err return pluginmanager.ArtifactRecord{}, err
} }
@@ -1041,12 +1130,12 @@ func writeGoPluginTemplate(ctx context.Context, opts pluginInitCLIOptions) error
return err return err
} }
files := map[string]string{ files := map[string]string{
"go.mod": goModTemplate(opts.Module, repoRoot), "go.mod": goModTemplate(opts.Module, repoRoot),
"main.go": goPluginMainTemplate(opts), "main.go": goPluginMainTemplate(opts),
"main_test.go": goPluginTestTemplate(), "main_test.go": goPluginTestTemplate(),
"README.md": readmeTemplate(opts), "README.md": readmeTemplate(opts),
"manifest.json": manifestTemplate(opts), manifestFileNameForFormat(opts.Format): manifestTemplate(opts),
"testdata/config.json": configTemplate(opts.Template), "testdata/config.json": configTemplate(opts.Template),
} }
for name, content := range files { for name, content := range files {
target := filepath.Join(opts.Dir, filepath.FromSlash(name)) target := filepath.Join(opts.Dir, filepath.FromSlash(name))
@@ -1061,39 +1150,6 @@ func writeGoPluginTemplate(ctx context.Context, opts pluginInitCLIOptions) error
return nil 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) { func materializedManifestJSON(raw map[string]any, manifest pluginmanager.Manifest, artifactType string, vendor bool) ([]byte, error) {
clone := make(map[string]any, len(raw)+4) clone := make(map[string]any, len(raw)+4)
for key, value := range raw { for key, value := range raw {
@@ -1267,7 +1323,7 @@ func collectSourcePackageFiles(dir, vendorDir string) ([]sourcePackageFile, erro
} }
return nil return nil
} }
if rel == "manifest.json" { if isManifestSourceFile(rel) {
return nil return nil
} }
if sourcePackageEntryAllowed(rel) { if sourcePackageEntryAllowed(rel) {
@@ -1421,7 +1477,7 @@ func parsePluginCLIFlag(args []string, index int) (string, string, int, error) {
func isPluginBoolFlag(name string) bool { func isPluginBoolFlag(name string) bool {
switch name { 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 return true
default: default:
return false return false
@@ -1518,12 +1574,53 @@ replace github.com/tursom/mc-gateway => %s
`, module, filepath.ToSlash(repoRoot)) `, 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 { 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 mode := pluginmanager.UpstreamModeDialer
if opts.Template == "protocol-proxy" { if opts.Template == "protocol-proxy" {
mode = pluginmanager.UpstreamModeProtocolProxy 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", "schema_version": "mc-gateway.plugin/v1",
"id": %q, "id": %q,
"name": %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 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 { func goPluginMainTemplate(opts pluginInitCLIOptions) string {
if opts.Template == "protocol-proxy" { if opts.Template == "protocol-proxy" {
return protocolProxyMainTemplate() return protocolProxyMainTemplate()

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"archive/zip" "archive/zip"
"bytes"
"encoding/json" "encoding/json"
"io" "io"
"net/http" "net/http"
@@ -25,12 +26,15 @@ func TestPluginInitCreatesBuildableTemplate(t *testing.T) {
if code != 0 { if code != 0 {
t.Fatalf("runPluginCLI(init) code = %d, want 0", code) 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 { if _, err := os.Stat(filepath.Join(dir, filepath.FromSlash(name))); err != nil {
t.Fatalf("generated file %s stat error = %v", name, err) 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) t.Fatalf("validatePluginDirectoryForCLI() error = %v", err)
} }
} }
@@ -63,6 +67,40 @@ func TestPluginBuildSourcePackagesTemplate(t *testing.T) {
t.Fatalf("validatePluginPathForCLI(source) error = %v", err) t.Fatalf("validatePluginPathForCLI(source) error = %v", err)
} }
assertZipContains(t, out, "manifest.json", "go.mod", "main.go", "main_test.go", "README.md", "testdata/config.json") 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) { func TestPluginTestManifestProfile(t *testing.T) {
@@ -107,6 +145,7 @@ func TestPluginManifestFormatWrite(t *testing.T) {
"plugin", "init", dir, "plugin", "init", dir,
"--id", "format-plugin", "--id", "format-plugin",
"--module", "example.com/format-plugin", "--module", "example.com/format-plugin",
"--manifest-format", "json",
}) })
if !handled || code != 0 { if !handled || code != 0 {
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code) 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) { func TestPluginGovernanceCommands(t *testing.T) {
dir := filepath.Join(t.TempDir(), "governance-plugin") dir := filepath.Join(t.TempDir(), "governance-plugin")
handled, code := runPluginCLI([]string{ 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", "preflight", artifact, "--config-json", `{"upstream":"127.0.0.1:25566"}`, "--profile", "dev"},
{"plugin", "self-test", artifact, "--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", "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) handled, code = runPluginCLI(tc)
if !handled { 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)
}
}
}

View File

@@ -2,7 +2,7 @@
本文定义插件开发工具链的功能需求和实现边界。目标是让插件作者从新建、开发、测试、打包到发布前检查都使用同一套 `gateway plugin` CLI而不是在每个示例插件里维护重复脚本。 本文定义插件开发工具链的功能需求和实现边界。目标是让插件作者从新建、开发、测试、打包到发布前检查都使用同一套 `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` 子命名空间 | | CLI 命名 | 直接扩展 `gateway plugin init/build/test`,不新增 `dev` 子命名空间 |
| 元数据来源 | `manifest.json` 是唯一人工维护的插件元数据来源 | | 元数据来源 | 插件目录只允许一个人工维护的 manifest source包内可信元数据统一为 canonical `manifest.json` |
| 打包入口 | `gateway plugin build` 同时承担 build 和 package不再要求插件目录自带 zip 脚本 | | 打包入口 | `gateway plugin build` 同时承担 build 和 package不再要求插件目录自带 zip 脚本 |
| 示例插件 | `upstream-rewrite``mc-auth-proxy` 迁移到标准 CLI删除重复 `build.sh``render-manifest` 逻辑 | | 示例插件 | `upstream-rewrite``mc-auth-proxy` 迁移到标准 CLI删除重复 `build.sh``render-manifest` 逻辑 |
| runtime 扩展 | CLI 通过 runtime build/test adapter 分发逻辑,命令名不随 runtime 改变 | | runtime 扩展 | CLI 通过 runtime build/test adapter 分发逻辑,命令名不随 runtime 改变 |
| 校验边界 | CLI 校验不能替代 gateway 服务端上传、构建、准入和 enable 校验 | | 校验边界 | 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 命令。 - 不需要手写 zip 命令。
- 不需要手写 `render-manifest` - 不需要手写 `render-manifest`
- 不需要在 Go 代码中声明 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 模板应至少生成: Go plugin 模板应至少生成:
- `manifest.json` - `manifest.yaml`(默认;也支持 `manifest.yml``manifest.toml``manifest.jsonc``manifest.json`
- `go.mod` - `go.mod`
- `main.go` - `main.go`
- `main_test.go` - `main_test.go`
@@ -206,7 +207,7 @@ Go plugin 模板应至少生成:
- `testdata/config.json` - `testdata/config.json`
- `testdata/fixtures/`,按模板放置 harness 输入 - `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` ## `gateway plugin build`
@@ -222,6 +223,8 @@ Go plugin 模板应至少生成:
| `gateway plugin build . --type both` | 同时生成 binary 和 source `.mcgp` | | `gateway plugin build . --type both` | 同时生成 binary 和 source `.mcgp` |
| `gateway plugin build --from-source source.mcgp --out built.mcgp` | 使用 gateway builder 从 source 包生成 binary 包 | | `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` - `dist/<plugin-id>.mcgp`
@@ -231,21 +234,23 @@ Go plugin 模板应至少生成:
### Manifest 物化规则 ### 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` - `artifact_type``--type` 写为 `binary``source`
- binary 包写入 `runtime.entry=plugin.so` - binary 包写入 `runtime.entry=plugin.so`
- Go plugin binary 包写入实际 `go_version``go_os``go_arch` - Go plugin binary 包写入实际 `go_version``go_os``go_arch`
- source 包写入 `build.type=go``build.entry``build.output``build.tags` 和 vendor 策略。 - 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 Adapter
第一版 `go-plugin` build adapter 负责: 第一版 `go-plugin` build adapter 负责:
1. 读取并校验 `manifest.json` 1. 读取并校验唯一 manifest source或通过 `--manifest` 指定的 manifest source
2. 运行 `go test ./...`,除非传入 `--skip-tests` 2. 运行 `go test ./...`,除非传入 `--skip-tests`
3. 用固定命令构建 `plugin.so``go build -buildmode=plugin -trimpath -buildvcs=false` 3. 用固定命令构建 `plugin.so``go build -buildmode=plugin -trimpath -buildvcs=false`
4.`go tool nm` 校验 `Plugin` 符号。 4.`go tool nm` 校验 `Plugin` 符号。
@@ -324,7 +329,7 @@ type PluginBuildAdapter interface {
`validate` 应支持三类输入: `validate` 应支持三类输入:
- `manifest.json` - manifest source 文件:`manifest.yaml``manifest.yml``manifest.toml``manifest.jsonc` `manifest.json`
- 插件源码目录 - 插件源码目录
- `.mcgp` artifact - `.mcgp` artifact
@@ -457,7 +462,7 @@ CI 产物应至少保存:
- README 使用 `gateway plugin build . --type both` - README 使用 `gateway plugin build . --type both`
- README 使用 `gateway plugin test .` - README 使用 `gateway plugin test .`
- 删除或降级 `build.sh` 为兼容包装;最终不再作为主路径。 - 删除或降级 `build.sh` 为兼容包装;最终不再作为主路径。
- 删除 `cmd/render-manifest`,由 CLI 根据源码 `manifest.json` 生成 artifact manifest。 - 删除 `cmd/render-manifest`,由 CLI 根据源码 manifest source 生成 artifact manifest。
- 示例插件的测试 fixture 进入 `testdata/fixtures/` - 示例插件的测试 fixture 进入 `testdata/fixtures/`
- 示例插件进入 conformance suite构建失败视为插件 API 回归。 - 示例插件进入 conformance suite构建失败视为插件 API 回归。
@@ -483,6 +488,6 @@ CI 产物应至少保存:
- 新建 `protocol-proxy` 模板后,能跑通 Minecraft handshake/login smoke fixture。 - 新建 `protocol-proxy` 模板后,能跑通 Minecraft handshake/login smoke fixture。
- `upstream-rewrite``mc-auth-proxy` 示例插件使用标准 CLI 生成 binary/source `.mcgp` - `upstream-rewrite``mc-auth-proxy` 示例插件使用标准 CLI 生成 binary/source `.mcgp`
- 生成的 `.mcgp` 能通过现有上传和服务端校验。 - 生成的 `.mcgp` 能通过现有上传和服务端校验。
- `manifest.json` 与 Go 代码不重复维护插件元数据。 - manifest source 与 Go 代码不重复维护插件元数据。
- Go plugin adapter 之外的 runtime 可以通过 adapter 注册进入同一套 `init/build/test` 命令。 - Go plugin adapter 之外的 runtime 可以通过 adapter 注册进入同一套 `init/build/test` 命令。
- CLI 失败输出能定位到字段、文件或 fixture而不是只返回通用错误。 - CLI 失败输出能定位到字段、文件或 fixture而不是只返回通用错误。

View File

@@ -20,7 +20,7 @@
- 第一版继续使用 Go `-buildmode=plugin` 的进程内 `.so` 插件。 - 第一版继续使用 Go `-buildmode=plugin` 的进程内 `.so` 插件。
- 尽量支持热加载:兼容且未加载过的插件可以不重启加载并启用。 - 尽量支持热加载:兼容且未加载过的插件可以不重启加载并启用。
- 明确热卸载限制Go plugin 不能真正从进程中卸载,只能逻辑禁用。 - 明确热卸载限制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` - 插件能力采用 Extension Point 模型Hook 是其中一种;第一版先落 `upstream.connect/v1`
- `upstream.connect/v1` 必须支持插件返回自管 `net.Conn`,用于实现完整 stream endpoint/protocol proxy。 - `upstream.connect/v1` 必须支持插件返回自管 `net.Conn`,用于实现完整 stream endpoint/protocol proxy。
- MC 正版/三方登录、身份映射、forwarding 和登录后的协议处理属于插件业务逻辑,不由 gateway core 拼装。 - MC 正版/三方登录、身份映射、forwarding 和登录后的协议处理属于插件业务逻辑,不由 gateway core 拼装。
@@ -33,7 +33,7 @@
| 决策/功能点 | 当前设计结论 | 阶段 | 主要章节 | | 决策/功能点 | 当前设计结论 | 阶段 | 主要章节 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 插件信任模型 | 第一版只支持可信 native 插件,不把普通插件当不可信代码运行 | 第一版 | Runtime 与权限声明、安全与运维约束 | | 插件信任模型 | 第一版只支持可信 native 插件,不把普通插件当不可信代码运行 | 第一版 | Runtime 与权限声明、安全与运维约束 |
| 插件包格式 | 管理页统一上传 `.mcgp` zip 包,源码/二进制由 `manifest.json``artifact_type` 决定 | 第一版 | 包格式、源码包构建 | | 插件包格式 | 管理页统一上传 `.mcgp` zip 包,源码/二进制由包内 canonical `manifest.json``artifact_type` 决定 | 第一版 | 包格式、源码包构建 |
| 自定义扩展名 | 不新增源码包扩展名;同一 `.mcgp` 格式承载 binary/source | 第一版 | 包格式 | | 自定义扩展名 | 不新增源码包扩展名;同一 `.mcgp` 格式承载 binary/source | 第一版 | 包格式 |
| Go plugin ABI | 仅使用 `.mcgp` 内的 `manifest.json` 表示元数据,并记录 Go/API/SDK/ABI fingerprint | 第一版 | Manifest 元数据、Go Plugin ABI Fingerprint | | Go plugin ABI | 仅使用 `.mcgp` 内的 `manifest.json` 表示元数据,并记录 Go/API/SDK/ABI fingerprint | 第一版 | Manifest 元数据、Go Plugin ABI Fingerprint |
| Admin 管理 | 上传、构建、加载、启用、禁用、切换版本、删除、回滚和审计都进入 Admin/SQLite | 第一版 | 数据模型、生命周期、Admin API | | Admin 管理 | 上传、构建、加载、启用、禁用、切换版本、删除、回滚和审计都进入 Admin/SQLite | 第一版 | 数据模型、生命周期、Admin API |
@@ -67,9 +67,9 @@
| 管理页上传、构建、加载、启用/禁用、删除和切换版本 | 全部进入 Admin/SQLite 生命周期,删除已加载 native artifact 后提示重启彻底清理 | 数据模型、生命周期、Admin API、Admin 页面 | | 管理页上传、构建、加载、启用/禁用、删除和切换版本 | 全部进入 Admin/SQLite 生命周期,删除已加载 native artifact 后提示重启彻底清理 | 数据模型、生命周期、Admin API、Admin 页面 |
| 热加载尽量支持,热卸载承认 Go plugin 限制 | 兼容且未加载过 artifact 可热加载;已加载 Go plugin 只能逻辑禁用,不能真正卸载 | 生命周期、热加载和热卸载、Runbook | | 热加载尽量支持,热卸载承认 Go plugin 限制 | 兼容且未加载过 artifact 可热加载;已加载 Go plugin 只能逻辑禁用,不能真正卸载 | 生命周期、热加载和热卸载、Runbook |
| 多进程模型实现进程级热卸载 | 预留 `go-plugin-process` runtime主进程只做管理和 fd 编排,子进程负责数据面;通过退出子进程回收 Go plugin | Go Plugin Process Runtime、第一版默认策略 | | 多进程模型实现进程级热卸载 | 预留 `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 | 源码包构建环境、供应链元数据 | | 支持源码包和构建环境设计 | 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/policymock/mixin/monkey patch 不作为生产机制 | Extension Point 设计、Mock 和 Mixin 的定位 | | Hook 之外的插件技术方案 | 统一 Extension Point 模型,覆盖 hook、middleware、provider、event subscriber、rule/policymock/mixin/monkey patch 不作为生产机制 | Extension Point 设计、Mock 和 Mixin 的定位 |
| 沙箱功能要有未来路线 | 第一版不提供沙箱;预留 sandbox-process、WASM、capability enforcement、stream relay 和 egress 策略 | Sandbox Runtime、Runtime Adapter、第一版默认策略 | | 沙箱功能要有未来路线 | 第一版不提供沙箱;预留 sandbox-process、WASM、capability enforcement、stream relay 和 egress 策略 | Sandbox Runtime、Runtime Adapter、第一版默认策略 |
| Alibaba 非侵入 Go 注入的参考价值 | 作为官方/组织 build-time instrumentation 未来能力,不作为普通运行时插件或热加载机制 | Build-Time Instrumentation | | 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 # 可选 README.md # 可选
``` ```
`manifest.json` 是加载前可读取的元数据,用于避免必须执行插件代码才能知道基础信息。上传阶段只解析 zip 和 manifest不执行插件代码。 包内 `manifest.json` 是加载前可读取的可信元数据,用于避免必须执行插件代码才能知道基础信息。上传阶段只解析 zip 和 manifest不执行插件代码。
二进制包 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` 开发环境可以允许直接上传 raw `.so`,但生产推荐只接受 `.mcgp`。直接上传 `.so` 时,加载前只能展示文件名、大小和 sha256生产路径仍应使用 `.mcgp` 提供 `manifest.json`
@@ -2009,7 +2009,7 @@ index 规则:
## Manifest 元数据 ## Manifest 元数据
插件包的元数据只来自 `.mcgp` 根目录的 `manifest.json`。上传、准入、构建、兼容性检查和 Admin 展示都必须使用这份静态 manifestgateway 不通过执行插件代码读取元数据。 插件包的元数据只来自 `.mcgp` 根目录的 canonical `manifest.json`。上传、准入、构建、兼容性检查和 Admin 展示都必须使用这份静态 manifestgateway 不通过执行插件代码读取元数据。源码目录可以使用 YAML、TOML、JSONC 或 JSON 作为唯一 manifest source但进入 `.mcgp` 前必须规范化为 `manifest.json`
Go plugin 只需要导出一个 factory 符号: Go plugin 只需要导出一个 factory 符号:
@@ -6370,10 +6370,10 @@ type Gateway interface {
- `plugin/api` 稳定 API 文档。 - `plugin/api` 稳定 API 文档。
- `examples/plugins/upstream-rewrite` 最小模板。 - `examples/plugins/upstream-rewrite` 最小模板。
- `examples/plugins/mc-auth-proxy` protocol-proxy 模板。 - `examples/plugins/mc-auth-proxy` protocol-proxy 模板。
- manifest JSON schema。 - manifest source 多格式解析和 canonical JSON schema。
- 统一的 `gateway plugin init/build/test` 开发工具链。 - 统一的 `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 工具 ### CLI 工具
@@ -6427,7 +6427,7 @@ CLI 规则:
本地开发流程: 本地开发流程:
1. 从示例复制插件目录。 1. 从示例复制插件目录。
2. 编写 `manifest.json` 2. 编写唯一 manifest source默认是 `manifest.yaml`
3. 使用与 gateway 匹配的 Go toolchain。 3. 使用与 gateway 匹配的 Go toolchain。
4. 运行 `gateway plugin build` 构建 `.mcgp` 4. 运行 `gateway plugin build` 构建 `.mcgp`
5. 通过 Admin 上传。 5. 通过 Admin 上传。
@@ -7354,7 +7354,7 @@ examples/plugins/mc-status-motd/
- 定义 `.mcgp` 静态校验规则、大小限制和 zip slip 防护。 - 定义 `.mcgp` 静态校验规则、大小限制和 zip slip 防护。
- 定义插件 ID、handler ID、task ID、secret name 和 extension point 命名规范。 - 定义插件 ID、handler ID、task ID、secret name 和 extension point 命名规范。
- 支持 `artifact_type=binary/source``runtime.type=go-plugin` - 支持 `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 语义。 - 定义 Go plugin ABI fingerprint schema、计算规则、compat diff 和开发模式 override 语义。
-`plugin/api` 中补齐 `APIVersion`、extension point metadata、`ErrPass` 等。 -`plugin/api` 中补齐 `APIVersion`、extension point metadata、`ErrPass` 等。
- 把 upstream hook 收敛为 request struct。 - 把 upstream hook 收敛为 request struct。

View File

@@ -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. - `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. 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`.

View File

@@ -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" }
}

View 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

View File

@@ -18,6 +18,8 @@ Build and package:
The binary package is written to `dist/mc-auth-proxy.mcgp`; the source package The binary package is written to `dist/mc-auth-proxy.mcgp`; the source package
is written to `dist/mc-auth-proxy-source.mcgp`. 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: Build the source package through the gateway builder:

View File

@@ -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" }
}
}
}

View 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

View File

@@ -14,6 +14,8 @@ Build and package:
The binary package is written to `dist/upstream-rewrite.mcgp`; the source The binary package is written to `dist/upstream-rewrite.mcgp`; the source
package is written to `dist/upstream-rewrite-source.mcgp`. 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: Build the source package through the gateway builder:

View File

@@ -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"]
}
}

View 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
View File

@@ -7,11 +7,13 @@ toolchain go1.24.4
require ( require (
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/mitchellh/mapstructure v1.5.0 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/pires/go-proxyproto v0.8.1
github.com/quic-go/quic-go v0.52.0 github.com/quic-go/quic-go v0.52.0
github.com/rs/zerolog v1.33.0 github.com/rs/zerolog v1.33.0
github.com/xtaci/kcp-go v5.4.20+incompatible github.com/xtaci/kcp-go v5.4.20+incompatible
golang.org/x/crypto v0.43.0 golang.org/x/crypto v0.43.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.45.0 modernc.org/sqlite v1.45.0
) )

3
go.sum
View File

@@ -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/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 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= 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 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0=
github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 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.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 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 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/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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=