feat(plugin): add development toolchain

This commit is contained in:
2026-06-27 10:19:57 +08:00
parent f22005ea37
commit e4dfcb62cb
16 changed files with 3782 additions and 197 deletions

View File

@@ -15,17 +15,184 @@ import (
)
func runPluginCLI(args []string) (bool, int) {
if len(args) < 2 || args[0] != "plugin" {
if len(args) < 1 || args[0] != "plugin" {
return false, 0
}
if len(args) < 3 {
fmt.Fprintln(os.Stderr, "usage: gateway plugin inspect|validate|compat|source-validate <artifact.mcgp> | source-build <source.mcgp> [out.mcgp]")
if len(args) < 2 {
printPluginCLIUsage()
return true, 2
}
command, packagePath := args[1], args[2]
command := args[1]
switch command {
case "init":
if err := runPluginInitCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "features":
if err := runPluginFeaturesCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "manifest":
if err := runPluginManifestCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "preflight":
if err := runPluginPreflightCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "self-test":
if err := runPluginSelfTestCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "benchmark":
if err := runPluginBenchmarkCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "status":
if err := runPluginRemoteStatusCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "upload":
if err := runPluginRemoteUploadCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "enable":
if err := runPluginRemoteDesiredCLI(args[2:], pluginmanager.DesiredEnabled); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "disable":
if err := runPluginRemoteActionCLI(args[2:], "disable"); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "delete":
if err := runPluginRemoteActionCLI(args[2:], "delete"); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "rollback":
if err := runPluginRemoteRollbackCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "config":
if err := runPluginRemoteConfigCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "secret":
if err := runPluginRemoteSecretCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "logs", "events", "metrics":
if err := runPluginRemoteOperationsSectionCLI(command, args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "diagnose":
if err := runPluginRemoteDiagnoseCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "task":
if err := runPluginRemoteTaskCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "data", "files":
if err := runPluginRemoteResourceCLI(command, args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "gc":
if err := runPluginRemoteGCCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "review":
if err := runPluginRemoteReviewCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "advisory":
if err := runPluginRemoteAdvisoryCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "repo":
if err := runPluginRemoteRepoCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "sbom", "verify":
if err := runPluginRemoteSupplyChainCLI(command, args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "runtime":
if err := runPluginRuntimeCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "schema", "contract", "conformance", "export", "import", "diff", "drift", "dr-drill", "sign":
if err := runPluginReservedCLI(command, args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "build":
if err := runPluginBuildCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "test":
if err := runPluginTestCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "inspect":
if len(args) < 3 {
printPluginCLIUsage()
return true, 2
}
packagePath := args[2]
manifest, err := readPackageManifest(packagePath)
if err != nil {
fmt.Fprintln(os.Stderr, err)
@@ -39,18 +206,11 @@ func runPluginCLI(args []string) (bool, int) {
}
return true, 0
case "validate", "compat":
tmpRoot, err := os.MkdirTemp("", "mcgp-cli-*")
if err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
if len(args) < 3 {
printPluginCLIUsage()
return true, 2
}
defer os.RemoveAll(tmpRoot)
store := pluginmanager.NewArtifactStore(tmpRoot)
artifact, err := store.ValidateAndStore(pluginmanager.ArtifactUpload{
SourcePath: packagePath,
FileName: filepath.Base(packagePath),
Actor: "cli",
})
artifact, err := validatePluginPathForCLI(args[2], "")
if err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
@@ -59,18 +219,11 @@ 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":
tmpRoot, err := os.MkdirTemp("", "mcgp-source-cli-*")
if err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
if len(args) < 3 {
printPluginCLIUsage()
return true, 2
}
defer os.RemoveAll(tmpRoot)
store := pluginmanager.NewArtifactStore(tmpRoot)
source, err := store.ValidateAndStoreSource(pluginmanager.ArtifactUpload{
SourcePath: packagePath,
FileName: filepath.Base(packagePath),
Actor: "cli",
})
source, err := validatePluginPathForCLI(args[2], pluginmanager.ArtifactTypeSource)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
@@ -79,6 +232,11 @@ func runPluginCLI(args []string) (bool, int) {
source.PluginID, source.Version, source.SHA256, source.APIVersion, source.GoVersion, source.GOOS, source.GOARCH)
return true, 0
case "source-build":
if len(args) < 3 {
printPluginCLIUsage()
return true, 2
}
packagePath := args[2]
outPath := ""
if len(args) >= 4 {
outPath = args[3]
@@ -97,6 +255,10 @@ func runPluginCLI(args []string) (bool, int) {
}
}
func printPluginCLIUsage() {
fmt.Fprintln(os.Stderr, "usage: gateway plugin init|features|manifest|build|test|preflight|self-test|benchmark|status|upload|enable|disable|delete|rollback|config|secret|logs|events|metrics|diagnose|task|data|files|gc|review|advisory|repo|sbom|verify|runtime|inspect|validate|compat|source-validate|source-build ...")
}
func buildSourcePackageForCLI(packagePath, outPath string) (pluginmanager.BuildRecord, string, error) {
tmpRoot, err := os.MkdirTemp("", "mcgp-source-build-cli-*")
if err != nil {

View File

@@ -0,0 +1,977 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"github.com/tursom/mc-gateway/internal/pluginmanager"
)
type pluginRemoteOptions struct {
Gateway string
Token string
Target string
Extra []string
ArtifactID string
ConfigPath string
ConfigJSON string
Priority int
Source bool
SnapshotID int64
FullDesired bool
Profile string
Action string
Decision string
Notes string
Reason string
TTLSeconds int64
ConfirmToken string
DryRun bool
RepositoryType string
IndexPath string
Version string
TrustPolicy string
MetadataPath string
MetadataJSON string
BenchmarkProfile string
P95MS float64
P99MS float64
ErrorRate float64
ActiveProxyCapacity int64
BaselineDiff float64
Mode string
}
type pluginRemoteClient struct {
baseURL string
token string
client *http.Client
}
func runPluginRemoteStatusCLI(args []string) error {
opts, err := parsePluginRemoteOptions(args)
if err != nil {
return err
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
endpoint := "/plugins"
if opts.Target != "" {
endpoint = "/plugins/" + url.PathEscape(opts.Target)
}
return client.doToStdout(http.MethodGet, endpoint, nil)
}
func runPluginRemoteUploadCLI(args []string) error {
opts, err := parsePluginRemoteOptions(args)
if err != nil {
return err
}
if opts.Target == "" {
return errors.New("upload requires an artifact path")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
endpoint := "/plugin-artifacts"
if opts.Source {
endpoint = "/plugin-sources"
}
return client.uploadArtifact(endpoint, opts.Target)
}
func runPluginRemoteDesiredCLI(args []string, desiredState string) error {
opts, err := parsePluginRemoteOptions(args)
if err != nil {
return err
}
if opts.Target == "" {
return errors.New("enable requires a plugin id")
}
if opts.ArtifactID == "" {
return errors.New("enable requires --artifact")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
configJSON, err := remoteConfigJSON(opts)
if err != nil {
return err
}
body := map[string]any{
"artifact_id": opts.ArtifactID,
"desired_state": desiredState,
"config_json": configJSON,
"priority": opts.Priority,
"source": "cli",
"requested_mode": "desired",
}
return client.doToStdout(http.MethodPut, "/plugins/"+url.PathEscape(opts.Target), body)
}
func runPluginRemoteActionCLI(args []string, action string) error {
opts, err := parsePluginRemoteOptions(args)
if err != nil {
return err
}
if opts.Target == "" {
return fmt.Errorf("%s requires a plugin id", action)
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/"+action, nil)
}
func runPluginRemoteRollbackCLI(args []string) error {
opts, err := parsePluginRemoteOptions(args)
if err != nil {
return err
}
if opts.Target == "" {
return errors.New("rollback requires a plugin id")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
if opts.SnapshotID > 0 {
body := map[string]any{"snapshot_id": opts.SnapshotID, "full_desired": opts.FullDesired}
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/rollback/config", body)
}
if opts.ArtifactID == "" {
return errors.New("rollback requires --artifact or --snapshot")
}
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/rollback/artifact", map[string]any{"artifact_id": opts.ArtifactID})
}
func runPluginRemoteConfigCLI(args []string) error {
if len(args) == 0 {
return errors.New("usage: gateway plugin config validate <plugin-id> ...")
}
switch args[0] {
case "validate":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
if opts.Target == "" {
return errors.New("config validate requires a plugin id")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
configJSON, err := remoteConfigJSON(opts)
if err != nil {
return err
}
body := map[string]any{"artifact_id": opts.ArtifactID, "config_json": configJSON}
if opts.Priority != 0 {
body["priority"] = opts.Priority
}
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/config/dry-run", body)
default:
return fmt.Errorf("unknown config command %q", args[0])
}
}
func runPluginRemoteSecretCLI(args []string) error {
if len(args) == 0 {
return errors.New("usage: gateway plugin secret check <plugin-id> ...")
}
switch args[0] {
case "check":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
if opts.Target == "" {
return errors.New("secret check requires a plugin id")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
return client.doToStdout(http.MethodGet, "/plugins/"+url.PathEscape(opts.Target)+"/secrets", nil)
default:
return fmt.Errorf("unknown secret command %q", args[0])
}
}
func runPluginRemoteOperationsSectionCLI(command string, args []string) error {
opts, err := parsePluginRemoteOptions(args)
if err != nil {
return err
}
if opts.Target == "" {
return fmt.Errorf("%s requires a plugin id", command)
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
body, err := client.doJSON(http.MethodGet, "/plugins/"+url.PathEscape(opts.Target)+"/operations", nil)
if err != nil {
return err
}
operations, _ := body["operations"].(map[string]any)
result := map[string]any{"plugin_id": opts.Target}
switch command {
case "logs":
result["logs"] = operations["logs"]
result["traces"] = operations["traces"]
case "events":
result["events"] = operations["events"]
result["event_queue"] = operations["event_queue"]
case "metrics":
result["handlers"] = operations["handlers"]
result["custom_metrics"] = operations["custom_metrics"]
default:
return fmt.Errorf("unknown operations section %q", command)
}
return encodePluginCLIJSON(result)
}
func runPluginRemoteDiagnoseCLI(args []string) error {
opts, err := parsePluginRemoteOptions(args)
if err != nil {
return err
}
if opts.Target == "" {
return errors.New("diagnose requires a plugin id")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
return client.doToStdout(http.MethodGet, "/plugins/"+url.PathEscape(opts.Target)+"/diagnostics", nil)
}
func runPluginRemoteTaskCLI(args []string) error {
if len(args) == 0 {
return errors.New("usage: gateway plugin task list|run|cancel ...")
}
switch args[0] {
case "list":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
if opts.Target == "" {
return errors.New("task list requires a plugin id")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
body, err := client.doJSON(http.MethodGet, "/plugins/"+url.PathEscape(opts.Target)+"/operations", nil)
if err != nil {
return err
}
operations, _ := body["operations"].(map[string]any)
return encodePluginCLIJSON(map[string]any{
"plugin_id": opts.Target,
"background_tasks": operations["background_tasks"],
})
case "run":
opts, err := parsePluginRemoteOptionsWithPositionals(args[1:], 2)
if err != nil {
return err
}
if opts.Target == "" || len(opts.Extra) == 0 {
return errors.New("task run requires a plugin id and task id")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
taskID := opts.Extra[0]
body := map[string]any{"confirm_token": opts.ConfirmToken}
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/operations/tasks/"+url.PathEscape(taskID)+"/trigger", body)
case "cancel":
return runPluginReservedCLI("task cancel", args[1:])
default:
return fmt.Errorf("unknown task command %q", args[0])
}
}
func runPluginRemoteResourceCLI(command string, args []string) error {
if len(args) == 0 {
return fmt.Errorf("usage: gateway plugin %s inspect|gc ...", command)
}
switch args[0] {
case "inspect":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
if opts.Target == "" {
return fmt.Errorf("%s inspect requires a plugin id", command)
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
body, err := client.doJSON(http.MethodGet, "/plugins/"+url.PathEscape(opts.Target)+"/operations", nil)
if err != nil {
return err
}
operations, _ := body["operations"].(map[string]any)
field := "plugin_data"
if command == "files" {
field = "plugin_files"
}
return encodePluginCLIJSON(map[string]any{
"plugin_id": opts.Target,
field: operations[field],
"gc": operations["gc"],
})
case "gc":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
if opts.Target == "" {
return fmt.Errorf("%s gc requires a plugin id", command)
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
method := http.MethodGet
if !opts.DryRun {
method = http.MethodPost
}
return client.doToStdout(method, "/plugins/"+url.PathEscape(opts.Target)+"/operations/gc", nil)
case "export":
return runPluginReservedCLI(command+" export", args[1:])
default:
return fmt.Errorf("unknown %s command %q", command, args[0])
}
}
func runPluginRemoteGCCLI(args []string) error {
opts, err := parsePluginRemoteOptions(args)
if err != nil {
return err
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
method := http.MethodGet
if !opts.DryRun {
method = http.MethodPost
}
endpoint := "/plugin-gc"
if opts.Target != "" {
endpoint = "/plugin-operations-gc?plugin_id=" + url.QueryEscape(opts.Target)
}
return client.doToStdout(method, endpoint, nil)
}
func runPluginRemoteReviewCLI(args []string) error {
if len(args) == 0 {
return errors.New("usage: gateway plugin review status|approve|reject|override ...")
}
switch args[0] {
case "status":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
if opts.Target == "" {
return errors.New("review status requires a plugin id")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
values := url.Values{}
if opts.ArtifactID != "" {
values.Set("artifact_id", opts.ArtifactID)
}
if opts.Profile != "" {
values.Set("profile", opts.Profile)
}
endpoint := "/plugins/" + url.PathEscape(opts.Target) + "/governance"
if query := values.Encode(); query != "" {
endpoint += "?" + query
}
return client.doToStdout(http.MethodGet, endpoint, nil)
case "approve", "reject":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
if opts.Target == "" {
return fmt.Errorf("review %s requires a plugin id", args[0])
}
if opts.ArtifactID == "" {
return fmt.Errorf("review %s requires --artifact", args[0])
}
decision := pluginmanager.ReviewDecisionApproved
if args[0] == "reject" {
decision = pluginmanager.ReviewDecisionRejected
}
if opts.Decision != "" {
decision = opts.Decision
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
body := map[string]any{
"artifact_id": opts.ArtifactID,
"profile": opts.Profile,
"decision": decision,
"notes": opts.Notes,
}
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/governance/review", body)
case "override":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
if opts.Target == "" || opts.ArtifactID == "" {
return errors.New("review override requires a plugin id and --artifact")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
body := map[string]any{
"artifact_id": opts.ArtifactID,
"profile": opts.Profile,
"action": opts.Action,
"reason": opts.Reason,
"ttl_seconds": opts.TTLSeconds,
}
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/governance/override", body)
default:
return fmt.Errorf("unknown review command %q", args[0])
}
}
func runPluginRemoteAdvisoryCLI(args []string) error {
if len(args) == 0 {
return errors.New("usage: gateway plugin advisory scan|import ...")
}
switch args[0] {
case "scan":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
endpoint := "/plugin-advisories"
if opts.Target != "" {
endpoint += "?plugin_id=" + url.QueryEscape(opts.Target)
}
return client.doToStdout(http.MethodGet, endpoint, nil)
case "import":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
body, err := remoteMetadataJSON(opts)
if err != nil {
return err
}
return client.doToStdout(http.MethodPost, "/plugin-advisories", body)
default:
return fmt.Errorf("unknown advisory command %q", args[0])
}
}
func runPluginRemoteRepoCLI(args []string) error {
if len(args) == 0 {
return errors.New("usage: gateway plugin repo list|import|search|show ...")
}
switch args[0] {
case "list":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
return client.doToStdout(http.MethodGet, "/plugin-repositories/imports", nil)
case "import":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
body := map[string]any{
"repository_type": opts.RepositoryType,
"index_path": opts.IndexPath,
"artifact_id": opts.ArtifactID,
"plugin_id": opts.Target,
"version": opts.Version,
"trust_policy": opts.TrustPolicy,
}
return client.doToStdout(http.MethodPost, "/plugin-repositories/imports", body)
case "search", "show":
return runPluginReservedCLI("repo "+args[0], args[1:])
default:
return fmt.Errorf("unknown repo command %q", args[0])
}
}
func runPluginRemoteSupplyChainCLI(command string, args []string) error {
if command == "sbom" {
if len(args) == 0 {
return errors.New("usage: gateway plugin sbom verify|generate ...")
}
switch args[0] {
case "verify":
return runPluginRemoteSupplyChainAssessCLI(args[1:])
case "generate":
return runPluginReservedCLI("sbom generate", args[1:])
default:
return fmt.Errorf("unknown sbom command %q", args[0])
}
}
return runPluginRemoteSupplyChainAssessCLI(args)
}
func runPluginRemoteSupplyChainAssessCLI(args []string) error {
opts, err := parsePluginRemoteOptions(args)
if err != nil {
return err
}
if opts.Target == "" || opts.ArtifactID == "" {
return errors.New("supply-chain verification requires a plugin id and --artifact")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
metadata, err := remoteMetadataJSON(opts)
if err != nil {
return err
}
body := map[string]any{
"plugin_id": opts.Target,
"artifact_id": opts.ArtifactID,
"metadata": metadata,
}
return client.doToStdout(http.MethodPost, "/plugin-supply-chain", body)
}
func runPluginRuntimeCLI(args []string) error {
if len(args) == 0 {
return errors.New("usage: gateway plugin runtime features|status|mode|apply ...")
}
switch args[0] {
case "features":
return runPluginFeaturesCLI(nil)
case "status":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
return client.doToStdout(http.MethodGet, "/plugin-service", nil)
case "mode":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
if opts.Mode == "" {
return errors.New("runtime mode requires --mode")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
return client.doToStdout(http.MethodPut, "/plugin-service", map[string]any{"desired_mode": opts.Mode})
case "apply":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
return client.doToStdout(http.MethodPost, "/plugin-service", nil)
default:
return fmt.Errorf("unknown runtime command %q", args[0])
}
}
func runPluginReservedCLI(command string, args []string) error {
_ = args
return encodePluginCLIJSON(map[string]any{
"command": command,
"status": "reserved",
"message": "command is reserved by the plugin toolchain design but is not implemented in this gateway yet",
})
}
func parsePluginRemoteOptions(args []string) (pluginRemoteOptions, error) {
return parsePluginRemoteOptionsWithPositionals(args, 1)
}
func parsePluginRemoteOptionsWithPositionals(args []string, maxPositionals int) (pluginRemoteOptions, error) {
opts := pluginRemoteOptions{
Gateway: os.Getenv("MC_GATEWAY_ADMIN_URL"),
Token: os.Getenv("MC_GATEWAY_ADMIN_TOKEN"),
Priority: pluginmanager.DefaultPriority,
DryRun: true,
}
var positionals []string
for i := 0; i < len(args); i++ {
arg := args[i]
if !strings.HasPrefix(arg, "--") {
if len(positionals) >= maxPositionals {
return pluginRemoteOptions{}, fmt.Errorf("unexpected argument %q", arg)
}
positionals = append(positionals, arg)
continue
}
key, value, consumed, err := parsePluginCLIFlag(args, i)
if err != nil {
return pluginRemoteOptions{}, err
}
i += consumed
switch key {
case "gateway":
opts.Gateway = value
case "token":
opts.Token = value
case "artifact":
opts.ArtifactID = value
case "config":
opts.ConfigPath = value
case "config-json":
opts.ConfigJSON = value
case "priority":
parsed, err := strconv.Atoi(value)
if err != nil {
return pluginRemoteOptions{}, fmt.Errorf("invalid --priority %q: %w", value, err)
}
opts.Priority = parsed
case "source":
opts.Source = parsePluginBoolFlag(value)
case "snapshot":
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return pluginRemoteOptions{}, fmt.Errorf("invalid --snapshot %q: %w", value, err)
}
opts.SnapshotID = parsed
case "full-desired":
opts.FullDesired = parsePluginBoolFlag(value)
case "profile":
opts.Profile = value
case "action":
opts.Action = value
case "decision":
opts.Decision = value
case "notes":
opts.Notes = value
case "reason":
opts.Reason = value
case "ttl":
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return pluginRemoteOptions{}, fmt.Errorf("invalid --ttl %q: %w", value, err)
}
opts.TTLSeconds = parsed
case "confirm-token":
opts.ConfirmToken = value
case "dry-run":
opts.DryRun = parsePluginBoolFlag(value)
case "repository-type":
opts.RepositoryType = value
case "index":
opts.IndexPath = value
case "version":
opts.Version = value
case "trust-policy":
opts.TrustPolicy = value
case "metadata":
opts.MetadataPath = value
case "metadata-json":
opts.MetadataJSON = value
case "benchmark-profile":
opts.BenchmarkProfile = value
case "p95-ms":
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return pluginRemoteOptions{}, fmt.Errorf("invalid --p95-ms %q: %w", value, err)
}
opts.P95MS = parsed
case "p99-ms":
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return pluginRemoteOptions{}, fmt.Errorf("invalid --p99-ms %q: %w", value, err)
}
opts.P99MS = parsed
case "error-rate":
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return pluginRemoteOptions{}, fmt.Errorf("invalid --error-rate %q: %w", value, err)
}
opts.ErrorRate = parsed
case "active-proxy-capacity":
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return pluginRemoteOptions{}, fmt.Errorf("invalid --active-proxy-capacity %q: %w", value, err)
}
opts.ActiveProxyCapacity = parsed
case "baseline-diff":
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return pluginRemoteOptions{}, fmt.Errorf("invalid --baseline-diff %q: %w", value, err)
}
opts.BaselineDiff = parsed
case "mode":
opts.Mode = value
default:
return pluginRemoteOptions{}, fmt.Errorf("unknown remote flag --%s", key)
}
}
if len(positionals) > 0 {
opts.Target = positionals[0]
}
if len(positionals) > 1 {
opts.Extra = append(opts.Extra, positionals[1:]...)
}
return opts, nil
}
func newPluginRemoteClient(opts pluginRemoteOptions) (pluginRemoteClient, error) {
if strings.TrimSpace(opts.Gateway) == "" {
return pluginRemoteClient{}, errors.New("--gateway or MC_GATEWAY_ADMIN_URL is required")
}
if strings.TrimSpace(opts.Token) == "" {
return pluginRemoteClient{}, errors.New("--token or MC_GATEWAY_ADMIN_TOKEN is required")
}
base, err := normalizeAdminAPIBase(opts.Gateway)
if err != nil {
return pluginRemoteClient{}, err
}
return pluginRemoteClient{baseURL: base, token: opts.Token, client: http.DefaultClient}, nil
}
func normalizeAdminAPIBase(raw string) (string, error) {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return "", err
}
if parsed.Scheme == "" || parsed.Host == "" {
return "", fmt.Errorf("invalid gateway URL %q", raw)
}
parsed.RawQuery = ""
parsed.Fragment = ""
parsed.Path = strings.TrimRight(parsed.Path, "/")
switch {
case parsed.Path == "":
parsed.Path = "/admin/api"
case strings.HasSuffix(parsed.Path, "/admin/api"):
case strings.HasSuffix(parsed.Path, "/admin"):
parsed.Path = parsed.Path + "/api"
default:
parsed.Path = path.Join(parsed.Path, "admin/api")
}
return parsed.String(), nil
}
func (c pluginRemoteClient) doToStdout(method, endpoint string, body any) error {
data, err := c.doBytes(method, endpoint, body)
if err != nil {
return err
}
return writePluginRemoteData(data)
}
func (c pluginRemoteClient) doJSON(method, endpoint string, body any) (map[string]any, error) {
data, err := c.doBytes(method, endpoint, body)
if err != nil {
return nil, err
}
if len(data) == 0 {
return map[string]any{}, nil
}
var decoded map[string]any
if err := json.Unmarshal(data, &decoded); err != nil {
return nil, fmt.Errorf("admin API response is not a JSON object: %w", err)
}
return decoded, nil
}
func (c pluginRemoteClient) doBytes(method, endpoint string, body any) ([]byte, error) {
var reader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, err
}
reader = bytes.NewReader(data)
}
req, err := http.NewRequest(method, c.baseURL+endpoint, reader)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+c.token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return readPluginRemoteResponse(resp)
}
func (c pluginRemoteClient) uploadArtifact(endpoint, filePath string) error {
var payload bytes.Buffer
writer := multipart.NewWriter(&payload)
part, err := writer.CreateFormFile("artifact", filepath.Base(filePath))
if err != nil {
return err
}
file, err := os.Open(filePath)
if err != nil {
return err
}
if _, err := io.Copy(part, file); err != nil {
_ = file.Close()
return err
}
if err := file.Close(); err != nil {
return err
}
if err := writer.Close(); err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, c.baseURL+endpoint, &payload)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, err := readPluginRemoteResponse(resp)
if err != nil {
return err
}
return writePluginRemoteData(data)
}
func readPluginRemoteResponse(resp *http.Response) ([]byte, error) {
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
message := strings.TrimSpace(string(data))
if message == "" {
message = resp.Status
}
return nil, fmt.Errorf("admin API %s: %s", resp.Status, message)
}
return data, nil
}
func writePluginRemoteData(data []byte) error {
if len(data) == 0 {
fmt.Fprintln(os.Stdout, "{}")
return nil
}
var pretty bytes.Buffer
if json.Indent(&pretty, data, "", " ") == nil {
pretty.WriteByte('\n')
_, err := pretty.WriteTo(os.Stdout)
return err
}
_, err := os.Stdout.Write(data)
if err == nil && len(data) > 0 && data[len(data)-1] != '\n' {
fmt.Fprintln(os.Stdout)
}
return err
}
func remoteConfigJSON(opts pluginRemoteOptions) (string, error) {
if opts.ConfigJSON != "" {
if !json.Valid([]byte(opts.ConfigJSON)) {
return "", errors.New("--config-json must be valid JSON")
}
return opts.ConfigJSON, nil
}
if opts.ConfigPath != "" {
data, err := os.ReadFile(opts.ConfigPath)
if err != nil {
return "", err
}
if !json.Valid(data) {
return "", fmt.Errorf("config file %q must contain valid JSON", opts.ConfigPath)
}
return string(data), nil
}
return "{}", nil
}
func remoteMetadataJSON(opts pluginRemoteOptions) (map[string]any, error) {
if opts.MetadataJSON != "" {
var decoded map[string]any
if err := json.Unmarshal([]byte(opts.MetadataJSON), &decoded); err != nil {
return nil, fmt.Errorf("--metadata-json must be a JSON object: %w", err)
}
return decoded, nil
}
if opts.MetadataPath != "" {
data, err := os.ReadFile(opts.MetadataPath)
if err != nil {
return nil, err
}
var decoded map[string]any
if err := json.Unmarshal(data, &decoded); err != nil {
return nil, fmt.Errorf("metadata file %q must contain a JSON object: %w", opts.MetadataPath, err)
}
return decoded, nil
}
return map[string]any{}, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,341 @@
package main
import (
"archive/zip"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestPluginInitCreatesBuildableTemplate(t *testing.T) {
dir := filepath.Join(t.TempDir(), "sample-plugin")
handled, code := runPluginCLI([]string{
"plugin", "init", dir,
"--id", "sample-plugin",
"--module", "example.com/sample-plugin",
})
if !handled {
t.Fatal("runPluginCLI() handled = false")
}
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"} {
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 {
t.Fatalf("validatePluginDirectoryForCLI() error = %v", err)
}
}
func TestPluginBuildSourcePackagesTemplate(t *testing.T) {
dir := filepath.Join(t.TempDir(), "source-plugin")
handled, code := runPluginCLI([]string{
"plugin", "init", dir,
"--id", "source-plugin",
"--module", "example.com/source-plugin",
})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code)
}
out := filepath.Join(t.TempDir(), "source-plugin.mcgp")
handled, code = runPluginCLI([]string{
"plugin", "build", dir,
"--type", "source",
"--out", out,
"--skip-tests",
"--vendor=false",
})
if !handled {
t.Fatal("runPluginCLI() handled = false")
}
if code != 0 {
t.Fatalf("runPluginCLI(build source) code = %d, want 0", code)
}
if _, err := validatePluginPathForCLI(out, "source"); err != nil {
t.Fatalf("validatePluginPathForCLI(source) error = %v", err)
}
assertZipContains(t, out, "manifest.json", "go.mod", "main.go", "main_test.go", "README.md", "testdata/config.json")
}
func TestPluginTestManifestProfile(t *testing.T) {
dir := filepath.Join(t.TempDir(), "test-plugin")
handled, code := runPluginCLI([]string{
"plugin", "init", dir,
"--id", "test-plugin",
"--module", "example.com/test-plugin",
})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code)
}
handled, code = runPluginCLI([]string{"plugin", "test", dir, "--profile", "manifest"})
if !handled {
t.Fatal("runPluginCLI() handled = false")
}
if code != 0 {
t.Fatalf("runPluginCLI(test manifest) code = %d, want 0", code)
}
}
func TestPluginFeaturesAndManifestCommands(t *testing.T) {
handled, code := runPluginCLI([]string{"plugin", "features"})
if !handled {
t.Fatal("runPluginCLI() handled = false")
}
if code != 0 {
t.Fatalf("runPluginCLI(features) code = %d, want 0", code)
}
handled, code = runPluginCLI([]string{"plugin", "manifest", "explain", "upstream.connect/v1"})
if !handled {
t.Fatal("runPluginCLI() handled = false")
}
if code != 0 {
t.Fatalf("runPluginCLI(manifest explain) code = %d, want 0", code)
}
}
func TestPluginManifestFormatWrite(t *testing.T) {
dir := filepath.Join(t.TempDir(), "format-plugin")
handled, code := runPluginCLI([]string{
"plugin", "init", dir,
"--id", "format-plugin",
"--module", "example.com/format-plugin",
})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code)
}
manifestPath := filepath.Join(dir, "manifest.json")
if err := os.WriteFile(manifestPath, []byte(`{"schema_version":"mc-gateway.plugin/v1","id":"format-plugin","name":"Format Plugin","version":"0.1.0","artifact_type":"source","runtime":{"type":"go-plugin","entry":"plugin.so","entry_symbol":"Plugin"},"build":{"type":"go","entry":".","output":"plugin.so"},"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":"upstream.connect/v1"}],"capabilities":{"upstream_connect":{"mode":"dialer"}},"runtime_limits":{"handler_timeout_ms":3000},"config_schema":{"type":"object"}}`), 0644); err != nil {
t.Fatalf("WriteFile(manifest) error = %v", err)
}
handled, code = runPluginCLI([]string{"plugin", "manifest", "format", dir, "--write"})
if !handled {
t.Fatal("runPluginCLI() handled = false")
}
if code != 0 {
t.Fatalf("runPluginCLI(manifest format) code = %d, want 0", code)
}
data, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatalf("ReadFile(manifest) error = %v", err)
}
if !strings.Contains(string(data), "\n \"schema_version\"") {
t.Fatalf("manifest was not formatted:\n%s", data)
}
}
func TestPluginGovernanceCommands(t *testing.T) {
dir := filepath.Join(t.TempDir(), "governance-plugin")
handled, code := runPluginCLI([]string{
"plugin", "init", dir,
"--id", "governance-plugin",
"--module", "example.com/governance-plugin",
})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code)
}
artifact := filepath.Join(t.TempDir(), "governance-plugin.mcgp")
handled, code = runPluginCLI([]string{
"plugin", "build", dir,
"--type", "binary",
"--out", artifact,
"--skip-tests",
})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(build binary) = (%v, %d), want handled code 0", handled, code)
}
for _, tc := range [][]string{
{"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"},
} {
handled, code = runPluginCLI(tc)
if !handled {
t.Fatalf("runPluginCLI(%v) handled = false", tc)
}
if code != 0 {
t.Fatalf("runPluginCLI(%v) code = %d, want 0", tc, code)
}
}
}
func TestPluginRemoteCLIRequests(t *testing.T) {
type observedRequest struct {
Method string
RequestURI string
ContentType string
Body map[string]any
FileName string
}
requests := make(chan observedRequest, 16)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
t.Errorf("Authorization = %q, want bearer token", got)
}
observed := observedRequest{
Method: r.Method,
RequestURI: r.URL.RequestURI(),
ContentType: r.Header.Get("Content-Type"),
Body: map[string]any{},
}
switch {
case strings.HasPrefix(observed.ContentType, "application/json"):
if err := json.NewDecoder(r.Body).Decode(&observed.Body); err != nil {
t.Errorf("Decode JSON body error = %v", err)
}
case strings.HasPrefix(observed.ContentType, "multipart/form-data"):
if err := r.ParseMultipartForm(64 << 20); err != nil {
t.Errorf("ParseMultipartForm error = %v", err)
} else {
file, header, err := r.FormFile("artifact")
if err != nil {
t.Errorf("FormFile(artifact) error = %v", err)
} else {
observed.FileName = header.Filename
_, _ = io.Copy(io.Discard, file)
_ = file.Close()
}
}
}
requests <- observed
w.Header().Set("Content-Type", "application/json")
if strings.HasSuffix(r.URL.Path, "/operations") {
_, _ = io.WriteString(w, `{"operations":{"logs":[{"message":"ok"}],"traces":[],"events":[{"name":"evt"}],"event_queue":{"queued":1},"handlers":[{"plugin_id":"demo"}],"custom_metrics":[],"background_tasks":[{"id":"sync"}],"plugin_data":[{"key":"k"}],"plugin_files":[{"name":"f"}],"gc":[]}}`)
return
}
_, _ = io.WriteString(w, `{"ok":true}`)
}))
defer server.Close()
t.Setenv("MC_GATEWAY_ADMIN_URL", server.URL)
t.Setenv("MC_GATEWAY_ADMIN_TOKEN", "test-token")
runRemotePluginCLI(t, "plugin", "status", "demo")
assertRemoteRequest(t, <-requests, http.MethodGet, "/admin/api/plugins/demo")
artifactPath := filepath.Join(t.TempDir(), "demo.mcgp")
if err := os.WriteFile(artifactPath, []byte("artifact"), 0644); err != nil {
t.Fatalf("WriteFile(artifact) error = %v", err)
}
runRemotePluginCLI(t, "plugin", "upload", artifactPath)
uploadReq := <-requests
assertRemoteRequest(t, uploadReq, http.MethodPost, "/admin/api/plugin-artifacts")
if uploadReq.FileName != "demo.mcgp" {
t.Fatalf("upload file name = %q, want demo.mcgp", uploadReq.FileName)
}
configPath := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(configPath, []byte(`{"upstream":"127.0.0.1:25565"}`), 0644); err != nil {
t.Fatalf("WriteFile(config) error = %v", err)
}
runRemotePluginCLI(t, "plugin", "enable", "demo", "--artifact", "art-1", "--config", configPath, "--priority", "7")
enableReq := <-requests
assertRemoteRequest(t, enableReq, http.MethodPut, "/admin/api/plugins/demo")
if enableReq.Body["artifact_id"] != "art-1" || enableReq.Body["desired_state"] != "enabled" || enableReq.Body["priority"].(float64) != 7 {
t.Fatalf("enable body = %#v", enableReq.Body)
}
runRemotePluginCLI(t, "plugin", "logs", "demo")
assertRemoteRequest(t, <-requests, http.MethodGet, "/admin/api/plugins/demo/operations")
runRemotePluginCLI(t, "plugin", "task", "run", "demo", "sync", "--confirm-token", "confirm")
taskReq := <-requests
assertRemoteRequest(t, taskReq, http.MethodPost, "/admin/api/plugins/demo/operations/tasks/sync/trigger")
if taskReq.Body["confirm_token"] != "confirm" {
t.Fatalf("task body = %#v", taskReq.Body)
}
runRemotePluginCLI(t, "plugin", "repo", "import", "demo", "--repository-type", "file", "--index", "repo.json", "--artifact", "candidate-1", "--version", "1.2.3")
repoReq := <-requests
assertRemoteRequest(t, repoReq, http.MethodPost, "/admin/api/plugin-repositories/imports")
if repoReq.Body["plugin_id"] != "demo" || repoReq.Body["repository_type"] != "file" || repoReq.Body["artifact_id"] != "candidate-1" {
t.Fatalf("repo body = %#v", repoReq.Body)
}
runRemotePluginCLI(t, "plugin", "review", "status", "demo", "--artifact", "art-1", "--profile", "prod")
assertRemoteRequest(t, <-requests, http.MethodGet, "/admin/api/plugins/demo/governance?artifact_id=art-1&profile=prod")
runRemotePluginCLI(t, "plugin", "sbom", "verify", "demo", "--artifact", "art-1", "--metadata-json", `{"sbom":{"format":"spdx"}}`)
supplyReq := <-requests
assertRemoteRequest(t, supplyReq, http.MethodPost, "/admin/api/plugin-supply-chain")
if supplyReq.Body["plugin_id"] != "demo" || supplyReq.Body["artifact_id"] != "art-1" {
t.Fatalf("supply-chain body = %#v", supplyReq.Body)
}
runRemotePluginCLI(t, "plugin", "runtime", "mode", "--mode", "go-plugin-process")
runtimeReq := <-requests
assertRemoteRequest(t, runtimeReq, http.MethodPut, "/admin/api/plugin-service")
if runtimeReq.Body["desired_mode"] != "go-plugin-process" {
t.Fatalf("runtime body = %#v", runtimeReq.Body)
}
}
func TestNormalizeAdminAPIBase(t *testing.T) {
for _, tc := range []struct {
raw string
want string
}{
{raw: "http://127.0.0.1:8080", want: "http://127.0.0.1:8080/admin/api"},
{raw: "http://127.0.0.1:8080/admin", want: "http://127.0.0.1:8080/admin/api"},
{raw: "http://127.0.0.1:8080/admin/api/", want: "http://127.0.0.1:8080/admin/api"},
} {
got, err := normalizeAdminAPIBase(tc.raw)
if err != nil {
t.Fatalf("normalizeAdminAPIBase(%q) error = %v", tc.raw, err)
}
if got != tc.want {
t.Fatalf("normalizeAdminAPIBase(%q) = %q, want %q", tc.raw, got, tc.want)
}
}
}
func runRemotePluginCLI(t *testing.T, args ...string) {
t.Helper()
handled, code := runPluginCLI(args)
if !handled {
t.Fatalf("runPluginCLI(%v) handled = false", args)
}
if code != 0 {
t.Fatalf("runPluginCLI(%v) code = %d, want 0", args, code)
}
}
func assertRemoteRequest(t *testing.T, got struct {
Method string
RequestURI string
ContentType string
Body map[string]any
FileName string
}, wantMethod, wantURI string) {
t.Helper()
if got.Method != wantMethod || got.RequestURI != wantURI {
t.Fatalf("request = %s %s, want %s %s", got.Method, got.RequestURI, wantMethod, wantURI)
}
}
func assertZipContains(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
if strings.Contains(file.Name, `\`) {
t.Fatalf("zip entry %q uses backslash", file.Name)
}
}
for _, name := range names {
if !seen[name] {
t.Fatalf("zip %s missing entry %s; entries=%v", zipPath, name, seen)
}
}
}

View File

@@ -0,0 +1,488 @@
# 插件开发工具链设计
本文定义插件开发工具链的功能需求和实现边界。目标是让插件作者从新建、开发、测试、打包到发布前检查都使用同一套 `gateway plugin` CLI而不是在每个示例插件里维护重复脚本。
本设计以 [plugin-system-design.md](plugin-system-design.md) 和 [plugin-implementation-plan.md](plugin-implementation-plan.md) 为上游约束。插件元数据只以 `manifest.json` 为准Go 代码中不再维护 `manifestJSON` 或等价重复元数据。
## 目标
- 提供 `gateway plugin init/build/test` 三个核心开发入口。
- 让示例插件和第三方插件使用同一套构建、打包、校验和测试流程。
- 支持 binary `.mcgp` 和 source `.mcgp`,并逐步替代示例插件内的 `build.sh``cmd/render-manifest` 等重复逻辑。
- 保持工具链 runtime-neutralGo plugin 是第一批实现目标,后续 `go-plugin-process``sandbox-process`、WASM 和 ingress service 通过 runtime adapter 扩展。
- 保证 CLI 产物可被 Admin/API 的服务端校验重复验证CLI 只是开发体验和预检工具,不是信任边界。
- 产物尽量稳定可复现:相同输入、相同 builder 和相同环境生成相同 zip 排序、权限和摘要。
## 非目标
- 不引入 `gateway plugin dev ...` 命名空间;开发命令直接扩展在 `gateway plugin` 下。
- 不恢复代码内 manifest 元数据。
- 不支持插件自定义构建脚本作为默认路径。
- 不把 source build 当成 runtime sandbox。
- 不在第一版支持远程插件市场、签名分发或自动升级。
- 不承诺 Go plugin 真正热卸载;本地调试仍遵守运行时限制。
## 设计决策
| 决策 | 结论 |
| --- | --- |
| CLI 命名 | 直接扩展 `gateway plugin init/build/test`,不新增 `dev` 子命名空间 |
| 元数据来源 | `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` 是构建时物化结果,不作为第二份人工维护数据 |
## 命令总览
第一版重点实现:
| 命令 | 用途 |
| --- | --- |
| `gateway plugin init <dir>` | 生成插件模板 |
| `gateway plugin build [dir]` | 构建并打包 binary/source `.mcgp` |
| `gateway plugin test [dir]` | 运行插件单元测试和 harness 测试 |
| `gateway plugin validate <path>` | 校验 manifest、源码目录或 `.mcgp` 包 |
| `gateway plugin inspect <artifact.mcgp>` | 查看包内 manifest 和摘要 |
| `gateway plugin compat <artifact.mcgp>` | 检查当前 gateway 对 artifact 的兼容性 |
现有 `gateway plugin source-build <source.mcgp> [out.mcgp]` 保留为兼容命令。后续可以由 `gateway plugin build --from-source <source.mcgp> --out <out.mcgp>` 覆盖同等能力,再把 `source-build` 标记为兼容别名。
所有面向 CI 的命令都应支持:
- `--json`:输出机器可读结果。
- `--quiet`:只输出错误或关键产物路径。
- `--out <path>`:指定产物或报告位置。
- 稳定退出码:参数错误、校验失败、构建失败和测试失败应可区分。
## 完整功能域
工具链最终需要覆盖从插件作者到生产运维的完整闭环。下表是功能需求清单,阶段表示推荐落地顺序,不代表命令只能在该阶段出现。
| 功能域 | 需要解决的问题 | 关键命令 |
| --- | --- | --- |
| 项目脚手架 | 快速生成可构建、可测试、manifest 正确的插件目录 | `init` |
| Manifest 编辑 | 发现字段错误、解释支持能力、避免人工维护环境字段 | `validate``manifest format``manifest explain``features` |
| 构建和打包 | 统一 binary/source `.mcgp` 产物,替代示例脚本 | `build``clean` |
| Source 构建复现 | 在本地或 CI 复现 gateway builder 行为 | `build --from-source` |
| 单元和契约测试 | 在真实上传前验证 SDK、extension point 和 fixture | `test``conformance` |
| 本地安装调试 | 把产物上传到开发 gateway启用、禁用、回滚和查看状态 | `upload``enable``disable``rollback``status` |
| 配置和 secret 预检 | 在启用前验证 config schema、secret ref、reload 兼容性 | `config validate``secret check``preflight` |
| 发布门禁 | 生成能进入 review/CI 的证据 | `preflight``self-test``benchmark` |
| 观测诊断 | 收集插件日志、事件、指标、trace 和诊断包 | `logs``events``metrics``diagnose` |
| 后台任务 | 开发和运维手动触发任务、查看执行状态 | `task list``task run``task cancel` |
| 数据和文件 | 查看 plugin_data/runtime files 配额、导出可迁移数据、GC | `data inspect/export/gc``files inspect/export/gc` |
| Promotion | 跨环境导入导出、diff、drift 和灾备演练 | `export``import``diff``drift``dr-drill` |
| 仓库和供应链 | 导入仓库候选、验证 SBOM/license/signature/advisory | `repo``sbom``sign``verify``advisory` |
| SDK 和契约治理 | 发布前检查 SDK/API/manifest/错误码兼容性 | `contract check``schema export``conformance` |
| Runtime 扩展 | 让新 runtime 复用同一套 init/build/test/validate 命令 | runtime adapter、`runtime features` |
### 命令分层
为了避免第一版实现过大,命令按层交付:
| 层级 | 阶段 | 命令 | 说明 |
| --- | --- | --- | --- |
| 0 | 已有能力 | `inspect``validate``compat``source-validate``source-build` | 当前 CLI 基线,后续保持兼容 |
| 1 | 阶段 1-3 | `init``build``test``features``manifest format/explain` | 插件作者日常开发闭环 |
| 2 | 阶段 4 | `upload``enable``disable``rollback``status``config validate``secret check` | 本地开发 gateway 和 Admin API 操作闭环 |
| 3 | 阶段 5 | `preflight``self-test``benchmark``review status``advisory scan` | 发布治理和准入证据 |
| 4 | 阶段 6 | `logs``events``metrics``diagnose``task``data``files``gc` | 运行诊断、后台任务、数据和资源治理 |
| 5 | 阶段 7-8 | `repo``sbom``sign``verify``contract``conformance``export/import/diff/drift/dr-drill` | 生态、供应链、跨环境发布和未来 runtime |
第一版不必一次实现所有命令,但设计上要避免把能力做进一次性脚本。每个命令都应能输出 JSON 报告,方便 CI 和 Admin API 复用。
## 开发工作流
工具链需要支持这些端到端流程。
### 新插件开发
```sh
gateway plugin init ./my-plugin --id my-plugin --template upstream-dialer --module example.com/my-plugin
cd ./my-plugin
gateway plugin validate .
gateway plugin test .
gateway plugin build . --type both
gateway plugin compat dist/my-plugin.mcgp
```
完成标准:
- 不需要手写 zip 命令。
- 不需要手写 `render-manifest`
- 不需要在 Go 代码中声明 manifest 元数据。
### 本地调试
```sh
gateway plugin build . --type binary
gateway plugin upload dist/my-plugin.mcgp --gateway http://127.0.0.1:8080
gateway plugin enable my-plugin --config testdata/config.json --profile dev
gateway plugin status my-plugin
gateway plugin logs my-plugin --tail 100
gateway plugin disable my-plugin
```
本地调试命令通过 Admin API 工作,不绕过服务端校验。需要认证时使用现有 Admin session/token 机制CLI 不保存 secret 明文。
### CI 发布检查
```sh
gateway plugin validate .
gateway plugin test . --profile unit,manifest,harness,protocol-smoke
gateway plugin build . --type both --json --out dist/build-report.json
gateway plugin compat dist/my-plugin.mcgp --json --out dist/compat-report.json
gateway plugin preflight dist/my-plugin.mcgp --config config/prod.json --profile prod --json
gateway plugin benchmark dist/my-plugin.mcgp --profile ci-contract --json
```
CI 报告必须能作为 review 证据保存,并包含 artifact sha256、source sha256、SDK/API 版本、runtime、extension points、config hash、测试 profile 和失败原因。
### Source 包复现
```sh
gateway plugin build . --type source
gateway plugin build --from-source dist/my-plugin-source.mcgp --out dist/my-plugin-rebuilt.mcgp
gateway plugin compat dist/my-plugin-rebuilt.mcgp
```
该流程用于验证源码包能被受控 builder 重建,且构建失败不会影响 active artifact。
### 跨环境发布
```sh
gateway plugin export my-plugin --profile staging --out promotion.json
gateway plugin diff promotion.json --target prod
gateway plugin import promotion.json --target prod --dry-run
gateway plugin drift --baseline promotion.json --target prod
```
promotion bundle 默认不包含 secret 明文、secret 密文和 runtime state。缺失 secret mapping、runtime 不兼容、advisory 命中或策略阻断时必须失败。
## `gateway plugin init`
`init` 负责生成一个可直接构建和测试的插件目录。
### 输入
推荐参数:
| 参数 | 说明 |
| --- | --- |
| `--id <id>` | 插件 ID必须满足 manifest 命名规则 |
| `--name <name>` | 展示名,默认由 ID 派生 |
| `--template <name>` | 模板名 |
| `--runtime <type>` | runtime 类型,默认 `go-plugin` |
| `--module <module>` | Go module pathGo runtime 模板必填或由目录推导 |
| `--extension <key>` | 目标 extension point |
第一批模板:
| 模板 | runtime | extension point | 说明 |
| --- | --- | --- | --- |
| `upstream-dialer` | `go-plugin` | `upstream.connect/v1` | 最小 dialer mode 模板 |
| `protocol-proxy` | `go-plugin` | `upstream.connect/v1` | 最小 Minecraft protocol-proxy 模板 |
| `empty-go` | `go-plugin` | 无默认 handler | 用于自定义实验 |
预留模板:
| 模板 | runtime | 说明 |
| --- | --- | --- |
| `wasm-rule` | `wasm` | 未来 rule/config validate 类轻量插件 |
| `sandbox-process` | `sandbox-process` | 未来隔离进程插件 |
| `ingress-service` | `sandbox-process` 或专用 runtime | 未来入口服务插件 |
### 输出目录
Go plugin 模板应至少生成:
- `manifest.json`
- `go.mod`
- `main.go`
- `main_test.go`
- `README.md`
- `testdata/config.json`
- `testdata/fixtures/`,按模板放置 harness 输入
生成的 `manifest.json` 只包含作者应该维护的字段。`go_version``go_os``go_arch` 等环境相关字段可以为空或使用文档化占位;`build` 时再物化到 artifact manifest。
## `gateway plugin build`
`build` 是统一构建和打包入口。
### 常用模式
| 命令 | 结果 |
| --- | --- |
| `gateway plugin build .` | 默认生成 binary `.mcgp` |
| `gateway plugin build . --type binary` | 生成 binary `.mcgp` |
| `gateway plugin build . --type source` | 生成 source `.mcgp` |
| `gateway plugin build . --type both` | 同时生成 binary 和 source `.mcgp` |
| `gateway plugin build --from-source source.mcgp --out built.mcgp` | 使用 gateway builder 从 source 包生成 binary 包 |
推荐默认输出:
- `dist/<plugin-id>.mcgp`
- `dist/<plugin-id>-source.mcgp`
- `dist/<plugin-id>-built.mcgp`
- `dist/build-report.json`
### Manifest 物化规则
源码目录中的 `manifest.json` 是唯一人工维护文件。`build` 可以在内存中生成 artifact manifest并写入 `.mcgp` 包内:
- `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`
这保证源码仓库里没有第二份需要维护的 manifest也避免 `manifest.json` 与 Go 代码常量不一致。
### Go Plugin Adapter
第一版 `go-plugin` build adapter 负责:
1. 读取并校验 `manifest.json`
2. 运行 `go test ./...`,除非传入 `--skip-tests`
3. 用固定命令构建 `plugin.so``go build -buildmode=plugin -trimpath -buildvcs=false`
4.`go tool nm` 校验 `Plugin` 符号。
5. 生成稳定 zip固定 entry 排序、权限、时间戳策略和路径分隔符。
6. 生成 source `.mcgp` 时只包含允许的源码、`go.mod`、可选 `go.sum/vendor`、README、LICENSE、SBOM 和测试 fixture。
7. 输出 artifact sha256、source sha256、Go/API/SDK 版本和 ABI fingerprint。
第一版不执行包内脚本。未来如果需要复杂构建,应通过受控 builder profile 或外部 CI而不是让插件包携带任意 shell 脚本。
### Runtime Adapter 预留
CLI 内部应抽象 build adapter
```go
type PluginBuildAdapter interface {
RuntimeType() string
ValidateSource(ctx context.Context, req BuildCLIRequest) error
Build(ctx context.Context, req BuildCLIRequest) (BuildCLIResult, error)
PackageSource(ctx context.Context, req BuildCLIRequest) (BuildCLIResult, error)
}
```
预留 runtime 行为:
| runtime | build 产物 | source 包 | 测试方式 |
| --- | --- | --- | --- |
| `go-plugin` | `plugin.so` | Go module source | Go test + extension harness |
| `go-plugin-process` | `plugin.so` 或 host bundle | Go module source | 子进程 host harness |
| `sandbox-process` | executable 或 bundle | 受控源码/二进制 bundle | control RPC harness |
| `wasm` | `plugin.wasm` | WASM source/bundle | WASM host ABI harness |
| `builtin` | 无外部 artifact | 不适用 | gateway 内部测试 |
命令层不应写死 Go plugin 细节。新增 runtime 时只新增 adapter、manifest 校验和 harness不新增一套用户命令。
## `gateway plugin test`
`test` 负责把插件作者的本地测试和 gateway extension contract 连接起来。
### 测试 profile
| Profile | 说明 |
| --- | --- |
| `unit` | 运行插件目录原生测试,例如 `go test ./...` |
| `manifest` | 校验 manifest schema、命名、runtime、extension point 和 config schema |
| `harness` | 运行 extension point fixture |
| `protocol-smoke` | 运行 Minecraft handshake/login smoke fixture |
| `conformance` | 运行当前 gateway 公开契约兼容测试 |
常用命令:
| 命令 | 结果 |
| --- | --- |
| `gateway plugin test .` | 运行模板默认 profile |
| `gateway plugin test . --profile unit,harness` | 运行指定 profile |
| `gateway plugin test . --config testdata/config.json` | 使用指定配置测试 |
| `gateway plugin test . --fixture testdata/fixtures/login-reject.json` | 使用指定 fixture |
| `gateway plugin test dist/plugin.mcgp --profile compat` | 对已打包 artifact 做兼容测试 |
### Harness 范围
第一版 harness 覆盖:
- `upstream.connect/v1` dialer mode匹配 host、返回 `api.ErrPass`、返回自管 conn、错误传播。
- `upstream.connect/v1` protocol-proxy modeinitial data replay、handshake/login packet fixture、disconnect/kick 响应、读写关闭。
- config`ReloadConfig()` 成功、失败、默认值和 schema 校验。
- lifecycle`Init()``Destroy()` 幂等、handler timeout、panic recover。
未来 runtime harness
- `go-plugin-process`:通过 plugin-host 启动插件,验证 drain-only、crash loop 和 control channel。
- `sandbox-process`:验证 capability enforcement、secret handle、filesystem/network policy。
- `wasm`:验证 host ABI、memory/time limit、无授权文件和网络访问。
- `ingress.service/v1`:验证 listener 由 gateway 创建、端口冲突和 disable drain。
## `gateway plugin validate`
`validate` 应支持三类输入:
- `manifest.json`
- 插件源码目录
- `.mcgp` artifact
校验内容:
- manifest schema 和必填字段。
- runtime type、runtime entry、build entry。
- extension point key、type 和 mode。
- config schema JSON。
- secret、event、metric、background task、external dependency、data store 和 file store 命名。
- binary/source 包结构、zip slip、大小限制和允许文件。
- 当前 gateway feature support。
对于源码目录,`validate` 不能执行插件代码最多做静态文件、manifest 和包结构检查。需要运行代码的检查放在 `test``build`
## 本地 Admin 操作命令
阶段 4 后CLI 应能操作开发或测试环境的 Admin API形成不依赖页面的调试闭环。
| 命令 | 职责 |
| --- | --- |
| `gateway plugin upload <artifact.mcgp>` | 上传 artifact/source package返回 artifact ID、sha256 和校验摘要 |
| `gateway plugin status [plugin-id]` | 展示 desired/runtime state、active/desired/loaded artifact、recent error 和 restart required |
| `gateway plugin enable <plugin-id>` | 设置 desired enabled支持 `--artifact``--config``--profile``--priority` |
| `gateway plugin disable <plugin-id>` | 设置 desired disabledprotocol-proxy 连接按策略 drain 或 force close |
| `gateway plugin delete <plugin-id>` | 删除 desired state 或 artifact支持保留/删除数据选项 |
| `gateway plugin rollback <plugin-id>` | 回滚 artifact 或 config snapshot并重新执行当前基础门禁 |
| `gateway plugin config validate <plugin-id>` | 校验 config JSON、schema、secret ref 和 `ReloadConfig()` dry-run |
| `gateway plugin secret check <plugin-id>` | 检查 manifest 必需 secret、secret ref、版本和 reload/rotation 状态 |
这些命令必须通过 Admin API 执行并复用服务端权限、审计和错误码。CLI 不直接写 SQLite不直接操作 artifact store也不能绕过上传时的 zip/manifest 校验。
## 发布治理命令
阶段 5 后CLI 需要生成和读取生产准入证据。
| 命令 | 职责 |
| --- | --- |
| `gateway plugin preflight` | 运行 config、secret、feature、runtime limits、scope/rollout、conflict 和 Minecraft capability 检查 |
| `gateway plugin self-test` | 运行插件实现的 quick/protocol-smoke/integration profile保存脱敏证据 |
| `gateway plugin benchmark` | 记录或执行 benchmark profile输出 P95/P99、error rate、capacity 和 baseline diff |
| `gateway plugin review status` | 查看当前 artifact/config/scope/risk/policy hash 是否已有有效 review |
| `gateway plugin advisory scan` | 按 artifact sha256、plugin/version、SBOM dependency 或 source metadata 扫描安全公告 |
发布治理命令的 JSON 报告必须包含稳定 `code``severity``message``evidence_id` 和相关 hash不能要求 CI 解析人类可读文本。
## 观测和运维命令
阶段 6 后CLI 应覆盖插件出问题时的定位、证据导出和资源清理。
| 命令 | 职责 |
| --- | --- |
| `gateway plugin logs <plugin-id>` | 查看插件日志摘要,支持 tail、时间范围、trace ID 和脱敏 |
| `gateway plugin events <plugin-id>` | 查看插件业务事件、drop/dead-letter 摘要和 replay/drop 操作 |
| `gateway plugin metrics <plugin-id>` | 查看 handler calls、duration、panic、timeout、active proxy connections 和 custom metrics |
| `gateway plugin diagnose <plugin-id>` | 生成诊断包,包含 manifest、state、recent logs/events/metrics/build summary不含 secret 明文 |
| `gateway plugin task list/run/cancel <plugin-id>` | 查看、手动触发或取消 background task |
| `gateway plugin data inspect/export/gc <plugin-id>` | 查看 plugin_data schema/data class/quota导出可迁移数据执行 dry-run 或清理 |
| `gateway plugin files inspect/export/gc <plugin-id>` | 查看 runtime files/resources/cache/tmp/log/diagnostic 用量和 GC candidate |
| `gateway plugin gc --dry-run` | 汇总 artifact、build log、diagnostic、plugin_data 和 runtime files 的可清理对象 |
所有清理命令默认 dry-run实际删除必须显式传入确认参数并写审计。数据导出只允许 manifest 声明 `exportable=true` 且调用者有权限的数据。
## 仓库、供应链和签名命令
阶段 8 的分发能力不能绕过本地 review 和 enable 流程。
| 命令 | 职责 |
| --- | --- |
| `gateway plugin repo list/search/show` | 查看 official/internal/file/url repository 中的候选版本 |
| `gateway plugin repo import` | 下载或导入候选 artifact 到本地 store只生成 local artifact不自动启用 |
| `gateway plugin sbom generate/verify` | 生成或验证 SBOM供 advisory/license 策略使用 |
| `gateway plugin sign` | 对 artifact 或 promotion bundle 签名,未来能力 |
| `gateway plugin verify` | 验证 signature、sha256、SBOM、license 和 provenance |
| `gateway plugin advisory import/scan/ack` | 导入安全公告、重新扫描本地 artifact、记录 mitigation/ack |
仓库删除、远端更新或签名失败都不能自动改变本地 active artifact。repository import 之后仍要走 validate、compat、preflight、review 和 enable。
## 契约和 SDK 命令
插件系统公开 API 后CLI 还要服务 gateway release 过程。
| 命令 | 职责 |
| --- | --- |
| `gateway plugin features` | 输出当前 gateway 支持的 runtime、extension point、manifest field、feature key 和版本 |
| `gateway plugin schema export` | 导出 manifest JSON schema、config UI hint schema 和 extension fixture schema |
| `gateway plugin contract check` | 对比上一 release 的 SDK/API/manifest/error code/CLI JSON 输出兼容性 |
| `gateway plugin conformance` | 构建示例插件,运行 source/binary fixture 和 Admin/CLI golden test |
`features` 输出必须和 Admin API 使用同一契约。`contract check``conformance` 失败应被视为 gateway release 风险,不是普通文档错误。
## Runtime 扩展命令
新增 runtime 不应增加一套平行 CLI。`init/build/test/validate/compat/preflight` 必须根据 `manifest.runtime.type` 选择 adapter。
| runtime | 额外 CLI 需求 |
| --- | --- |
| `go-plugin-process` | `test` 能启动 plugin-host harness`preflight` 检查 migration mode、safe point、drain-only/fd-live 声明 |
| `sandbox-process` | `validate/preflight` 检查 capability、secret handle、filesystem/network/env/cpu/memory policy`test` 验证 control RPC 和 crash loop |
| `wasm` | `build` 生成 `plugin.wasm``test` 使用 WASM host ABI`preflight` 检查 memory/time/no file/no network |
| `ingress.service/v1` | `preflight` 检查 listener ownership、port conflict、TLS/secret refs 和 disable drain |
| build-time instrumentation | 不进入 runtime plugin enable/disableCLI 只提供 manifest/provenance/conformance/benchmark/smoke 证据 |
如果目标 gateway 不支持某 runtime`compat``preflight` 必须返回明确的 blocking code而不是降级为 Go plugin 尝试加载。
## 发布前检查
发布前推荐流程:
1. `gateway plugin validate .`
2. `gateway plugin test . --profile unit,manifest,harness`
3. `gateway plugin build . --type both`
4. `gateway plugin validate dist/<plugin-id>.mcgp`
5. `gateway plugin compat dist/<plugin-id>.mcgp`
6. 可选:`gateway plugin build --from-source dist/<plugin-id>-source.mcgp --out dist/<plugin-id>-rebuilt.mcgp`
7. 可选:`gateway plugin test dist/<plugin-id>.mcgp --profile conformance`
CI 产物应至少保存:
- binary `.mcgp`
- source `.mcgp`
- build report JSON
- test report JSON
- artifact sha256 和 source sha256
## 示例插件迁移
`examples/plugins/upstream-rewrite``examples/plugins/mc-auth-proxy` 迁移目标:
- README 使用 `gateway plugin build . --type both`
- README 使用 `gateway plugin test .`
- 删除或降级 `build.sh` 为兼容包装;最终不再作为主路径。
- 删除 `cmd/render-manifest`,由 CLI 根据源码 `manifest.json` 生成 artifact manifest。
- 示例插件的测试 fixture 进入 `testdata/fixtures/`
- 示例插件进入 conformance suite构建失败视为插件 API 回归。
迁移时必须保留现有 `.mcgp` 格式binary 包仍包含 `manifest.json``plugin.so`source 包仍包含 `manifest.json``go.mod`、build entry 和源码。
## 实现顺序
建议按以下顺序实现:
1. 增加 `gateway plugin init`,生成 `upstream-dialer``protocol-proxy` Go 模板。
2. 增加 `gateway plugin build` 的 Go plugin binary/source 打包能力,复用现有 artifact 校验逻辑。
3.`gateway plugin build` 替换示例插件 `build.sh``cmd/render-manifest` 主路径。
4. 增加 `gateway plugin test` 的 unit、manifest 和 upstream harness profile。
5.`source-build` 能力收敛为 `build --from-source`,保留兼容别名。
6. 增加 runtime build/test adapter 接口,为 `go-plugin-process``sandbox-process` 和 WASM 实现预留扩展点。
7. 增加 JSON report、conformance profile 和 CI golden 输出。
每一步结束时,现有 `inspect/validate/compat/source-validate/source-build` 不能回归。
## 验收标准
- 新建 `upstream-dialer` 模板后,不手写额外脚本即可 build/test/validate。
- 新建 `protocol-proxy` 模板后,能跑通 Minecraft handshake/login smoke fixture。
- `upstream-rewrite``mc-auth-proxy` 示例插件使用标准 CLI 生成 binary/source `.mcgp`
- 生成的 `.mcgp` 能通过现有上传和服务端校验。
- `manifest.json` 与 Go 代码不重复维护插件元数据。
- Go plugin adapter 之外的 runtime 可以通过 adapter 注册进入同一套 `init/build/test` 命令。
- CLI 失败输出能定位到字段、文件或 fixture而不是只返回通用错误。

View File

@@ -2,6 +2,8 @@
本文以 [plugin-system-design.md](plugin-system-design.md) 作为最终目标设计文档,把插件系统拆分成多个可上线的实现阶段。每个阶段都必须在结束时保持 gateway 当前可用:可以启动、可以回滚、可以排障,且不会要求后续阶段补齐后才能恢复基本能力。
插件开发工具链作为跨阶段交付项单独设计,见 [plugin-development-toolchain-design.md](plugin-development-toolchain-design.md)。工具链主入口为 `gateway plugin init/build/test`,并需要从第一批 Go plugin 示例开始预留未来 runtime adapter。
## 拆分原则
- 以可用的纵向切片拆分而不是按数据库、API、UI、SDK 等横向模块拆分。
@@ -37,6 +39,7 @@
| Minecraft capability manifest、protocol smoke fixture | 2 | 支撑管理页展示和后续发布门禁 |
| source `.mcgp`、builder、构建 provenance | 3 | 源码包构建成 `plugin.so` 后复用阶段 1/2 加载路径 |
| builder 隔离、Go/module/ABI 记录、source/build log GC | 3 | 构建失败不影响 active artifact |
| `gateway plugin init/build/test` 开发工具链 | 1-3后续扩展 | 阶段 1/2 提供 Go plugin 模板和 harness阶段 3 收敛 source/binary 打包;后续 runtime 通过 adapter 接入 |
| Admin 页面基础管理闭环 | 4 | 上传、构建状态、加载、启用、禁用、删除、回滚 |
| 配置 schema、配置快照、配置迁移入口 | 4 | 错误配置不切换 active artifact |
| SecretStore、secret version、reload/rotation 基础 | 4 | secret 不在页面、日志、审计中明文展示 |

View File

@@ -6371,51 +6371,53 @@ type Gateway interface {
- `examples/plugins/upstream-rewrite` 最小模板。
- `examples/plugins/mc-auth-proxy` protocol-proxy 模板。
- manifest JSON schema。
- 构建脚本模板
- `.mcgp` 打包脚本。
- 统一的 `gateway plugin init/build/test` 开发工具链
详细工具链设计见 [plugin-development-toolchain-design.md](plugin-development-toolchain-design.md)。工具链必须继续遵守 manifest-only 元数据约束:插件作者只维护 `manifest.json`Go 代码中不再保存 `manifestJSON` 或等价重复元数据。
### CLI 工具
建议提供 `mc-gateway plugin` 子命令,降低插件开发和运维成本。
建议提供 `gateway plugin` 子命令,降低插件开发和运维成本。开发入口直接扩展在 `gateway plugin init/build/test` 下,不新增 `dev` 子命名空间。
候选命令:
| 命令 | 说明 |
| --- | --- |
| `mc-gateway plugin init` | 生成插件模板 |
| `mc-gateway plugin validate manifest.json` | 校验 manifest schema、命名、capabilities 和 extension point |
| `mc-gateway plugin package --type source` | 打包 source `.mcgp` |
| `mc-gateway plugin package --type binary` | 打包 binary `.mcgp` |
| `mc-gateway plugin inspect plugin.mcgp` | 查看 manifest、supply chain、sha256、Go/API 版本 |
| `mc-gateway plugin compat plugin.mcgp` | 检查当前 gateway 是否可能加载该插件 |
| `mc-gateway plugin features` | 查看当前 gateway 支持的 feature key 和版本 |
| `mc-gateway plugin build` | 使用匹配 builder 本地构建 plugin.so |
| `mc-gateway plugin test` | 运行插件 harness 测试 |
| `mc-gateway plugin preflight` | 对插件包或已安装插件执行通用预检和插件 Preflight |
| `mc-gateway plugin self-test` | 运行 quick/protocol-smoke/integration 自测 profile |
| `mc-gateway plugin benchmark` | 运行插件 benchmark、soak 或 regression profile |
| `mc-gateway plugin contract check` | 校验契约文件和上一 release 的兼容性 |
| `mc-gateway plugin conformance` | 运行插件契约 conformance suite |
| `mc-gateway plugin export` | 从 Admin API 导出 promotion bundle |
| `mc-gateway plugin import` | 上传并校验 promotion bundle |
| `mc-gateway plugin diff` | 对比 bundle、目标环境和当前 desired state |
| `mc-gateway plugin drift` | 查看当前环境相对基线的漂移状态 |
| `mc-gateway plugin dr-drill` | 触发或查看灾备演练 |
| `mc-gateway plugin data inspect <plugin>` | 查看 plugin_data schema、data class、大小、配额和 GC candidate |
| `mc-gateway plugin data export <plugin>` | 导出允许迁移的数据,受 data_class 和权限控制 |
| `mc-gateway plugin data gc <plugin>` | 按 retention 清理过期或可丢弃 plugin_data |
| `mc-gateway plugin sbom` | 生成或校验 SBOM未来能力 |
| `mc-gateway plugin sign` | 签名插件包,未来能力 |
| `gateway plugin init` | 生成插件模板 |
| `gateway plugin validate <path>` | 校验 manifest、源码目录或 `.mcgp` |
| `gateway plugin build --type source` | 打包 source `.mcgp` |
| `gateway plugin build --type binary` | 构建并打包 binary `.mcgp` |
| `gateway plugin build --type both` | 同时生成 source/binary `.mcgp` |
| `gateway plugin build --from-source` | 从 source `.mcgp` 生成 binary `.mcgp`,逐步替代 `source-build` 主路径 |
| `gateway plugin test` | 运行插件 unit、manifest、harness 或 conformance profile |
| `gateway plugin inspect plugin.mcgp` | 查看 manifest、supply chain、sha256、Go/API 版本 |
| `gateway plugin compat plugin.mcgp` | 检查当前 gateway 是否可能加载该插件 |
| `gateway plugin features` | 查看当前 gateway 支持的 feature key 和版本 |
| `gateway plugin preflight` | 对插件包或已安装插件执行通用预检和插件 Preflight |
| `gateway plugin self-test` | 运行 quick/protocol-smoke/integration 自测 profile |
| `gateway plugin benchmark` | 运行插件 benchmark、soak 或 regression profile |
| `gateway plugin contract check` | 校验契约文件和上一 release 的兼容性 |
| `gateway plugin conformance` | 运行插件契约 conformance suite |
| `gateway plugin export` | 从 Admin API 导出 promotion bundle |
| `gateway plugin import` | 上传并校验 promotion bundle |
| `gateway plugin diff` | 对比 bundle、目标环境和当前 desired state |
| `gateway plugin drift` | 查看当前环境相对基线的漂移状态 |
| `gateway plugin dr-drill` | 触发或查看灾备演练 |
| `gateway plugin data inspect <plugin>` | 查看 plugin_data schema、data class、大小、配额和 GC candidate |
| `gateway plugin data export <plugin>` | 导出允许迁移的数据,受 data_class 和权限控制 |
| `gateway plugin data gc <plugin>` | 按 retention 清理过期或可丢弃 plugin_data |
| `gateway plugin sbom` | 生成或校验 SBOM,未来能力 |
| `gateway plugin sign` | 签名插件包,未来能力 |
CLI 规则:
- CLI 校验不能替代服务端校验,服务端必须重复做安全校验。
- package 命令必须生成稳定 zip避免无意义 sha256 变化。
- build 命令必须生成稳定 zip避免无意义 sha256 变化。
- inspect 命令不能执行插件代码。
- compat 命令只能做 preflight必须检查 required/optional features但不能保证 `plugin.Open` 一定成功。
- features 命令输出必须和 Admin `/plugins/features` API 使用同一契约。
- diff、drift、export 和 import 必须使用同一 canonical hash 与脱敏 diff 实现。
- build 命令应默认使用与 gateway release 匹配的 builder image。
- build 命令应默认使用与 gateway release 匹配的 builder image;本地 Go plugin adapter 可以先使用当前 Go toolchain
- data inspect 默认只显示摘要,不导出 value。
- data export 必须经过 Admin API 权限检查,且只能导出 manifest 声明 `exportable=true` 的数据。
- data gc 必须支持 dry-run先展示将清理的 data_class、key 数量和总大小。
@@ -6427,7 +6429,7 @@ CLI 规则:
1. 从示例复制插件目录。
2. 编写 `manifest.json`
3. 使用与 gateway 匹配的 Go toolchain。
4. 运行示例脚本构建 `.mcgp`
4. 运行 `gateway plugin build` 构建 `.mcgp`
5. 通过 Admin 上传。
6. 查看 ABI 校验结果、构建日志、加载状态和运行错误。
@@ -7130,7 +7132,7 @@ API 错误响应应包含稳定错误码,便于管理页和 CLI 处理:
1. 查看 build log excerpt 和 builder image。
2. 确认 Go version、GOOS/GOARCH、CGO 和 build tags。
3. 检查 GOPROXY/vendor/private dependency 配置。
4. 使用 CLI 在本地或 CI 复现 `mc-gateway plugin build`
4. 使用 CLI 在本地或 CI 复现 `gateway plugin build`
5. 修正源码包后重新上传,或 retry 同一 build job。
构建失败不应改变 active artifact。
@@ -7202,10 +7204,12 @@ API 错误响应应包含稳定错误码,便于管理页和 CLI 处理:
examples/plugins/upstream-rewrite/
go.mod
main.go
main_test.go
manifest.json
README.md
build.sh
package-source.sh
testdata/
config.json
fixtures/
```
示例能力:
@@ -7231,10 +7235,12 @@ examples/plugins/upstream-rewrite/
examples/plugins/mc-auth-proxy/
go.mod
main.go
main_test.go
manifest.json
README.md
build.sh
package-source.sh
testdata/
config.json
fixtures/
```
示例能力:
@@ -7263,10 +7269,12 @@ examples/plugins/mc-auth-proxy/
examples/plugins/mc-status-motd/
go.mod
main.go
main_test.go
manifest.json
README.md
build.sh
package-source.sh
testdata/
config.json
fixtures/
```
示例能力:
@@ -7662,7 +7670,7 @@ examples/plugins/mc-status-motd/
- 插件测试 harness 能覆盖 dialer mode 和 protocol-proxy mode。
- 测试矩阵覆盖包格式、ABI、生命周期、配置、extension point、入口传输、上游协议、protocol-proxy、治理、secret、artifact 和 Admin 权限。
- 运维 Runbook 覆盖连接失败、启用失败、构建失败、secret 泄漏怀疑、磁盘占用过高和多实例部分失败。
- CLI 工具至少覆盖 manifest validate、package、inspect、compat、test、promotion export/import/diff、drift 和 dr-drill 的设计。
- CLI 工具至少覆盖 manifest validate、build/package、inspect、compat、test、promotion export/import/diff、drift 和 dr-drill 的设计。
- CLI 工具覆盖 plugin_data inspect/export/gc且 data gc 支持 dry-run。
- Admin API 错误响应有稳定 code管理页和 CLI 不依赖错误字符串解析。
- 文档能明确区分第一版能力、预留 extension point 和未来 runtime。
@@ -7696,7 +7704,7 @@ examples/plugins/mc-status-motd/
| 契约文件 | 第一版手写维护 JSON schema/contract后续可从 Go 类型和 manifest schema 生成并做 diff 校验 |
| SDK 发布节奏 | gateway release 与 plugin SDK release 默认绑定SDK 使用 SemVergateway 记录支持范围 |
| conformance suite | release 前必须运行并产出报告;第一版可先作为 release gateCI 阻断按模块成熟度逐步打开 |
| CLI 形态 | 第一版作为 gateway 二进制的 `mc-gateway plugin` 子命令;独立 `mc-gateway-plugin` 作为未来分发形态 |
| CLI 形态 | 第一版作为 gateway 二进制的 `gateway plugin` 子命令;独立 `gateway-plugin` 作为未来分发形态 |
| 插件服务启动模式 | 第一版固定 `in-process`Admin 可预留 desired mode 配置,`go-plugin-process`/`sandbox-process` 未来生效且切换需要重启 |
### 准入和权限

View File

@@ -12,7 +12,8 @@ belong inside a protocol-proxy plugin.
Build and package:
```sh
./build.sh
(cd ../../.. && go run ./cmd/gateway plugin test examples/plugins/mc-auth-proxy --profile manifest)
(cd ../../.. && go run ./cmd/gateway plugin build examples/plugins/mc-auth-proxy --type both)
```
The binary package is written to `dist/mc-auth-proxy.mcgp`; the source package
@@ -21,7 +22,7 @@ is written to `dist/mc-auth-proxy-source.mcgp`.
Build the source package through the gateway builder:
```sh
go run ../../../cmd/gateway plugin source-build dist/mc-auth-proxy-source.mcgp dist/mc-auth-proxy-built.mcgp
(cd ../../.. && go run ./cmd/gateway plugin build --from-source examples/plugins/mc-auth-proxy/dist/mc-auth-proxy-source.mcgp --out examples/plugins/mc-auth-proxy/dist/mc-auth-proxy-built.mcgp)
```
Example config JSON:

View File

@@ -1,23 +1,6 @@
#!/usr/bin/env sh
set -eu
mkdir -p dist
go build -buildmode=plugin -o dist/plugin.so .
go run ./cmd/render-manifest > dist/manifest.json
cp README.md dist/README.md
(
cd dist
rm -f mc-auth-proxy.mcgp
zip -q mc-auth-proxy.mcgp manifest.json plugin.so README.md
)
rm -rf dist/source-package
mkdir -p dist/source-package/cmd/render-manifest
ARTIFACT_TYPE=source go run ./cmd/render-manifest > dist/source-package/manifest.json
cp main.go main_test.go go.mod README.md dist/source-package/
cp cmd/render-manifest/main.go dist/source-package/cmd/render-manifest/main.go
go mod vendor -o dist/source-package/vendor
(
cd dist/source-package
rm -f ../mc-auth-proxy-source.mcgp
zip -qr ../mc-auth-proxy-source.mcgp manifest.json main.go main_test.go go.mod README.md cmd/render-manifest/main.go vendor
)
repo_root=$(cd ../../.. && pwd)
cd "$repo_root"
go run ./cmd/gateway plugin build examples/plugins/mc-auth-proxy --type both --skip-tests

View File

@@ -1,44 +0,0 @@
package main
import (
"encoding/json"
"os"
"runtime"
)
func main() {
if err := renderManifest(os.Stdout, os.Getenv("ARTIFACT_TYPE")); err != nil {
panic(err)
}
}
func renderManifest(out *os.File, artifactType string) error {
data, err := os.ReadFile("manifest.json")
if err != nil {
return err
}
var manifest map[string]any
if err := json.Unmarshal(data, &manifest); err != nil {
return err
}
if artifactType != "" {
manifest["artifact_type"] = artifactType
}
manifest["go_version"] = runtime.Version()
manifest["go_os"] = runtime.GOOS
manifest["go_arch"] = runtime.GOARCH
if manifest["artifact_type"] == "source" {
manifest["build"] = map[string]any{
"type": "go",
"entry": ".",
"go_version": runtime.Version(),
"cgo_enabled": true,
"tags": []string{},
"vendor_required": false,
"output": "plugin.so",
}
}
encoder := json.NewEncoder(out)
encoder.SetIndent("", " ")
return encoder.Encode(manifest)
}

View File

@@ -0,0 +1,5 @@
{
"match_host": "play.example",
"fixture_accept": false,
"disconnect_message": "Authentication fixture rejected the login"
}

View File

@@ -8,7 +8,8 @@ return `api.ErrPass`.
Build and package:
```sh
./build.sh
(cd ../../.. && go run ./cmd/gateway plugin test examples/plugins/upstream-rewrite --profile manifest)
(cd ../../.. && go run ./cmd/gateway plugin build examples/plugins/upstream-rewrite --type both)
```
The binary package is written to `dist/upstream-rewrite.mcgp`; the source
@@ -17,7 +18,7 @@ package is written to `dist/upstream-rewrite-source.mcgp`.
Build the source package through the gateway builder:
```sh
go run ../../../cmd/gateway plugin source-build dist/upstream-rewrite-source.mcgp dist/upstream-rewrite-built.mcgp
(cd ../../.. && go run ./cmd/gateway plugin build --from-source examples/plugins/upstream-rewrite/dist/upstream-rewrite-source.mcgp --out examples/plugins/upstream-rewrite/dist/upstream-rewrite-built.mcgp)
```
Example config JSON:

View File

@@ -1,23 +1,6 @@
#!/usr/bin/env sh
set -eu
mkdir -p dist
go build -buildmode=plugin -o dist/plugin.so .
go run ./cmd/render-manifest > dist/manifest.json
cp README.md dist/README.md
(
cd dist
rm -f upstream-rewrite.mcgp
zip -q upstream-rewrite.mcgp manifest.json plugin.so README.md
)
rm -rf dist/source-package
mkdir -p dist/source-package/cmd/render-manifest
ARTIFACT_TYPE=source go run ./cmd/render-manifest > dist/source-package/manifest.json
cp main.go go.mod README.md dist/source-package/
cp cmd/render-manifest/main.go dist/source-package/cmd/render-manifest/main.go
go mod vendor -o dist/source-package/vendor
(
cd dist/source-package
rm -f ../upstream-rewrite-source.mcgp
zip -qr ../upstream-rewrite-source.mcgp manifest.json main.go go.mod README.md cmd/render-manifest/main.go vendor
)
repo_root=$(cd ../../.. && pwd)
cd "$repo_root"
go run ./cmd/gateway plugin build examples/plugins/upstream-rewrite --type both --skip-tests

View File

@@ -1,44 +0,0 @@
package main
import (
"encoding/json"
"os"
"runtime"
)
func main() {
if err := renderManifest(os.Stdout, os.Getenv("ARTIFACT_TYPE")); err != nil {
panic(err)
}
}
func renderManifest(out *os.File, artifactType string) error {
data, err := os.ReadFile("manifest.json")
if err != nil {
return err
}
var manifest map[string]any
if err := json.Unmarshal(data, &manifest); err != nil {
return err
}
if artifactType != "" {
manifest["artifact_type"] = artifactType
}
manifest["go_version"] = runtime.Version()
manifest["go_os"] = runtime.GOOS
manifest["go_arch"] = runtime.GOARCH
if manifest["artifact_type"] == "source" {
manifest["build"] = map[string]any{
"type": "go",
"entry": ".",
"go_version": runtime.Version(),
"cgo_enabled": true,
"tags": []string{},
"vendor_required": false,
"output": "plugin.so",
}
}
encoder := json.NewEncoder(out)
encoder.SetIndent("", " ")
return encoder.Encode(manifest)
}

View File

@@ -0,0 +1,4 @@
{
"match_host": "play.example",
"upstream": "127.0.0.1:25566"
}

View File

@@ -615,6 +615,7 @@ func validateSourceEntries(manifest Manifest, entries map[string]*zip.File) erro
switch {
case name == "manifest.json" || name == "go.mod" || name == "go.sum":
case strings.HasPrefix(name, "vendor/"):
case strings.HasPrefix(name, "testdata/"):
case strings.EqualFold(path.Base(name), "README.md"), strings.EqualFold(path.Base(name), "LICENSE"), strings.Contains(strings.ToLower(path.Base(name)), "sbom"):
case strings.HasSuffix(name, ".go"):
default: