From e4dfcb62cbd6554d036756a75db65bd5168a76e9 Mon Sep 17 00:00:00 2001 From: tursom Date: Sat, 27 Jun 2026 10:19:57 +0800 Subject: [PATCH] feat(plugin): add development toolchain --- cmd/gateway/plugin_cli.go | 214 +- cmd/gateway/plugin_cli_remote.go | 977 ++++++++++ cmd/gateway/plugin_cli_toolchain.go | 1716 +++++++++++++++++ cmd/gateway/plugin_cli_toolchain_test.go | 341 ++++ docs/plugin-development-toolchain-design.md | 488 +++++ docs/plugin-implementation-plan.md | 3 + docs/plugin-system-design.md | 86 +- examples/plugins/mc-auth-proxy/README.md | 5 +- examples/plugins/mc-auth-proxy/build.sh | 23 +- .../mc-auth-proxy/cmd/render-manifest/main.go | 44 - .../mc-auth-proxy/testdata/config.json | 5 + examples/plugins/upstream-rewrite/README.md | 5 +- examples/plugins/upstream-rewrite/build.sh | 23 +- .../cmd/render-manifest/main.go | 44 - .../upstream-rewrite/testdata/config.json | 4 + internal/pluginmanager/artifact.go | 1 + 16 files changed, 3782 insertions(+), 197 deletions(-) create mode 100644 cmd/gateway/plugin_cli_remote.go create mode 100644 cmd/gateway/plugin_cli_toolchain.go create mode 100644 cmd/gateway/plugin_cli_toolchain_test.go create mode 100644 docs/plugin-development-toolchain-design.md delete mode 100644 examples/plugins/mc-auth-proxy/cmd/render-manifest/main.go create mode 100644 examples/plugins/mc-auth-proxy/testdata/config.json delete mode 100644 examples/plugins/upstream-rewrite/cmd/render-manifest/main.go create mode 100644 examples/plugins/upstream-rewrite/testdata/config.json diff --git a/cmd/gateway/plugin_cli.go b/cmd/gateway/plugin_cli.go index 018afa0..98e040b 100644 --- a/cmd/gateway/plugin_cli.go +++ b/cmd/gateway/plugin_cli.go @@ -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 | source-build [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 { diff --git a/cmd/gateway/plugin_cli_remote.go b/cmd/gateway/plugin_cli_remote.go new file mode 100644 index 0000000..bf5571a --- /dev/null +++ b/cmd/gateway/plugin_cli_remote.go @@ -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 ...") + } + 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 ...") + } + 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 +} diff --git a/cmd/gateway/plugin_cli_toolchain.go b/cmd/gateway/plugin_cli_toolchain.go new file mode 100644 index 0000000..bffaa02 --- /dev/null +++ b/cmd/gateway/plugin_cli_toolchain.go @@ -0,0 +1,1716 @@ +package main + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path" + "path/filepath" + "runtime" + "sort" + "strconv" + "strings" + "time" + + "github.com/tursom/mc-gateway/internal/pluginmanager" + "github.com/tursom/mc-gateway/plugin/api" +) + +type pluginBuildCLIOptions struct { + Dir string + BuildType string + Out string + FromSource string + SkipTests bool + Vendor bool +} + +type pluginInitCLIOptions struct { + Dir string + ID string + Name string + Template string + Runtime string + Module string + Extension string +} + +type pluginGovernanceCLIOptions struct { + Target string + ConfigPath string + ConfigJSON string + Profile string + Action string + Priority int + BenchmarkProfile string + P95MS float64 + P99MS float64 + ErrorRate float64 + ActiveProxyCapacity int64 + BaselineDiff float64 +} + +type pluginRuntimeCLIAdapter interface { + RuntimeType() string + Init(context.Context, pluginInitCLIOptions) error + BuildBinary(context.Context, pluginBuildRuntimeRequest) (pluginmanager.ArtifactRecord, error) + BuildSource(context.Context, pluginBuildRuntimeRequest) (pluginmanager.ArtifactRecord, error) + Test(context.Context, pluginTestCLIOptions) error +} + +type pluginBuildRuntimeRequest struct { + Dir string + Manifest pluginmanager.Manifest + Raw map[string]any + OutPath string + Vendor bool +} + +type pluginTestCLIOptions struct { + Target string + Profile string + ConfigPath string + FixturePath string + Manifest pluginmanager.Manifest +} + +type cliStaticRuntimeAdapter struct{} + +func (cliStaticRuntimeAdapter) Load(context.Context, pluginmanager.ArtifactRecord, pluginmanager.PluginRecord, *pluginmanager.Gateway) (api.Plugin, error) { + return nil, errors.New("local CLI governance adapter does not load plugin code") +} + +type goPluginCLIAdapter struct{} + +func (goPluginCLIAdapter) RuntimeType() string { + return pluginmanager.RuntimeGoPlugin +} + +func (goPluginCLIAdapter) Init(ctx context.Context, opts pluginInitCLIOptions) error { + return writeGoPluginTemplate(ctx, opts) +} + +func (goPluginCLIAdapter) BuildBinary(ctx context.Context, req pluginBuildRuntimeRequest) (pluginmanager.ArtifactRecord, error) { + return buildBinaryPluginPackageContext(ctx, req.Dir, req.Manifest, req.Raw, req.OutPath) +} + +func (goPluginCLIAdapter) BuildSource(ctx context.Context, req pluginBuildRuntimeRequest) (pluginmanager.ArtifactRecord, error) { + return buildSourcePluginPackageContext(ctx, req.Dir, req.Manifest, req.Raw, req.OutPath, req.Vendor) +} + +func (goPluginCLIAdapter) Test(ctx context.Context, opts pluginTestCLIOptions) error { + for _, item := range strings.Split(opts.Profile, ",") { + item = strings.TrimSpace(item) + if item == "" { + continue + } + switch item { + case "unit": + if err := runGoCommand(ctx, opts.Target, "go", "test", "./..."); err != nil { + return err + } + case "manifest": + if _, err := validatePluginDirectoryForCLI(opts.Target); err != nil { + return err + } + case "harness", "protocol-smoke", "conformance": + if err := validateTestFileIfSet(opts.ConfigPath, "config"); err != nil { + return err + } + if err := validateTestFileIfSet(opts.FixturePath, "fixture"); err != nil { + return err + } + if _, err := validatePluginDirectoryForCLI(opts.Target); err != nil { + return err + } + default: + return fmt.Errorf("unsupported test profile %q", item) + } + } + return nil +} + +func pluginCLIAdapterForRuntime(runtimeType string) (pluginRuntimeCLIAdapter, error) { + if runtimeType == "" { + runtimeType = pluginmanager.RuntimeGoPlugin + } + switch runtimeType { + case pluginmanager.RuntimeGoPlugin: + return goPluginCLIAdapter{}, nil + case pluginmanager.RuntimeBuiltin, pluginmanager.RuntimeSandbox, pluginmanager.RuntimeWASM: + return nil, fmt.Errorf("runtime %q is reserved; no CLI build/test adapter is implemented yet", runtimeType) + default: + return nil, fmt.Errorf("unsupported runtime %q", runtimeType) + } +} + +func runPluginFeaturesCLI(args []string) error { + if len(args) > 0 { + return fmt.Errorf("features does not accept positional arguments") + } + features := map[string]any{ + "schema_version": pluginmanager.SchemaVersion, + "api_version": pluginmanager.APIVersion, + "artifact_types": []string{ + pluginmanager.ArtifactTypeBinary, + pluginmanager.ArtifactTypeSource, + }, + "runtime_types": []map[string]any{ + {"type": pluginmanager.RuntimeGoPlugin, "implemented": true, "entry": pluginmanager.RuntimeEntry}, + {"type": pluginmanager.RuntimeBuiltin, "implemented": false}, + {"type": pluginmanager.RuntimeSandbox, "implemented": false}, + {"type": pluginmanager.RuntimeWASM, "implemented": false, "entry": pluginmanager.RuntimeWASMEntry}, + }, + "service_modes": []map[string]any{ + {"mode": pluginmanager.PluginServiceModeInProcess, "implemented": true}, + {"mode": pluginmanager.PluginServiceModeGoPluginProcess, "implemented": false}, + {"mode": pluginmanager.PluginServiceModeSandboxProcess, "implemented": false}, + }, + "extension_points": []map[string]string{ + {"type": "hook", "key": pluginmanager.ExtensionUpstreamConnect}, + {"type": "hook", "key": pluginmanager.ExtensionRouteResolve}, + {"type": "provider", "key": pluginmanager.ExtensionRouteResolver}, + {"type": "hook", "key": pluginmanager.ExtensionRuleEvaluate}, + {"type": "hook", "key": pluginmanager.ExtensionConfigValidate}, + {"type": "hook", "key": pluginmanager.ExtensionStatusPing}, + {"type": "hook", "key": pluginmanager.ExtensionConnectionFilter}, + {"type": "hook", "key": pluginmanager.ExtensionHandshakeFilter}, + {"type": "subscriber", "key": pluginmanager.ExtensionEventSubscriber}, + {"type": "provider", "key": pluginmanager.ExtensionProvider}, + {"type": "provider", "key": pluginmanager.ExtensionAuthProvider}, + {"type": "provider", "key": pluginmanager.ExtensionAdminAuthProvider}, + }, + "build": map[string]any{ + "implemented_runtimes": []string{pluginmanager.RuntimeGoPlugin}, + "builder_types": []string{ + pluginmanager.BuilderTypeLocalProcess, + pluginmanager.BuilderTypeContainer, + }, + "default_runtime_entry": pluginmanager.RuntimeEntry, + "default_build_entry": pluginmanager.SourceBuildEntry, + }, + "cli": map[string]any{ + "implemented_commands": []string{ + "init", + "features", + "manifest format", + "manifest explain", + "build", + "test", + "preflight", + "self-test", + "benchmark", + "status", + "upload", + "enable", + "disable", + "delete", + "rollback", + "config validate", + "secret check", + "logs", + "events", + "metrics", + "diagnose", + "task list", + "task run", + "data inspect", + "data gc", + "files inspect", + "files gc", + "gc", + "review status", + "review approve", + "review reject", + "review override", + "advisory scan", + "advisory import", + "repo list", + "repo import", + "sbom verify", + "verify", + "runtime features", + "runtime status", + "runtime mode", + "runtime apply", + "inspect", + "validate", + "compat", + "source-validate", + "source-build", + }, + "reserved_commands": []string{ + "contract", + "conformance", + "schema export", + "repo search", + "repo show", + "sbom generate", + "sign", + "promotion export/import/diff/drift/dr-drill", + "task cancel", + }, + }, + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(features) +} + +func runPluginManifestCLI(args []string) error { + if len(args) == 0 { + return errors.New("usage: gateway plugin manifest format|explain ...") + } + switch args[0] { + case "format": + return runPluginManifestFormatCLI(args[1:]) + case "explain": + return runPluginManifestExplainCLI(args[1:]) + default: + return fmt.Errorf("unknown manifest command %q", args[0]) + } +} + +func runPluginManifestFormatCLI(args []string) error { + target := "manifest.json" + write := false + for i := 0; i < len(args); i++ { + arg := args[i] + if !strings.HasPrefix(arg, "--") { + if target != "manifest.json" { + return fmt.Errorf("unexpected argument %q", arg) + } + target = arg + continue + } + key, value, consumed, err := parsePluginCLIFlag(args, i) + if err != nil { + return err + } + i += consumed + switch key { + case "write": + write = parsePluginBoolFlag(value) + default: + return fmt.Errorf("unknown manifest format flag --%s", key) + } + } + manifestPath, err := resolveManifestPath(target) + if err != nil { + return err + } + data, err := os.ReadFile(manifestPath) + if err != nil { + return err + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("invalid manifest.json: %w", err) + } + formatted, err := json.MarshalIndent(raw, "", " ") + if err != nil { + return err + } + formatted = append(formatted, '\n') + if write { + if bytes.Equal(data, formatted) { + fmt.Fprintf(os.Stdout, "ok manifest=%s unchanged\n", manifestPath) + return nil + } + if err := os.WriteFile(manifestPath, formatted, 0644); err != nil { + return err + } + fmt.Fprintf(os.Stdout, "ok manifest=%s formatted\n", manifestPath) + return nil + } + _, err = os.Stdout.Write(formatted) + return err +} + +func runPluginManifestExplainCLI(args []string) error { + if len(args) != 1 { + return errors.New("usage: gateway plugin manifest explain ") + } + key := strings.TrimSpace(args[0]) + explanation, ok := manifestExplanation(key) + if !ok { + return fmt.Errorf("no manifest explanation for %q", key) + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(explanation) +} + +func manifestExplanation(key string) (map[string]any, bool) { + explanations := map[string]map[string]any{ + "schema_version": { + "key": "schema_version", + "required": true, + "value": pluginmanager.SchemaVersion, + "summary": "Manifest schema contract version supported by this gateway.", + }, + "artifact_type": { + "key": "artifact_type", + "required": true, + "values": []string{pluginmanager.ArtifactTypeBinary, pluginmanager.ArtifactTypeSource}, + "summary": "Declares whether the .mcgp package carries a built runtime artifact or source package.", + }, + "runtime.type": { + "key": "runtime.type", + "required": true, + "values": []string{pluginmanager.RuntimeGoPlugin, pluginmanager.RuntimeBuiltin, pluginmanager.RuntimeSandbox, pluginmanager.RuntimeWASM}, + "summary": "Selects the runtime adapter. Only go-plugin build/test is implemented by this CLI slice.", + }, + "runtime.entry": { + "key": "runtime.entry", + "required": true, + "default": pluginmanager.RuntimeEntry, + "summary": "Path of the runtime entry inside a binary .mcgp package.", + }, + "runtime.entry_symbol": { + "key": "runtime.entry_symbol", + "default": "Plugin", + "summary": "Go plugin factory symbol used to create a plugin instance.", + }, + "build.entry": { + "key": "build.entry", + "default": pluginmanager.SourceBuildEntry, + "summary": "Go package path built from a source .mcgp package.", + }, + "api_version": { + "key": "api_version", + "required": true, + "value": pluginmanager.APIVersion, + "summary": "Plugin API contract version supported by this gateway.", + }, + "extension_points": { + "key": "extension_points", + "required": true, + "summary": "Declared extension points used for static validation, governance, conflict analysis, and Admin display.", + }, + "upstream.connect/v1": { + "key": pluginmanager.ExtensionUpstreamConnect, + "type": "hook", + "summary": "Extension point for replacing upstream connection creation or returning a protocol-proxy stream endpoint.", + }, + "config_schema": { + "key": "config_schema", + "summary": "JSON schema used by Admin UI and preflight to validate plugin configuration before enable/reload.", + }, + "capabilities": { + "key": "capabilities", + "summary": "Runtime, network, filesystem, Minecraft, and feature declarations used for review and policy gates.", + }, + } + explanation, ok := explanations[key] + return explanation, ok +} + +func runPluginPreflightCLI(args []string) error { + opts, err := parseGovernanceCLIOptions(args) + if err != nil { + return err + } + if opts.Target == "" { + return errors.New("preflight requires a plugin directory or binary .mcgp") + } + manager, cleanup, artifact, configJSON, err := prepareLocalGovernanceManager(opts) + if err != nil { + return err + } + defer cleanup() + result, err := manager.RunPreflight(context.Background(), "cli", artifact.PluginID, pluginmanager.PreflightRequest{ + ArtifactID: artifact.ID, + Profile: opts.Profile, + Action: opts.Action, + ConfigJSON: configJSON, + }) + if err != nil { + return err + } + return encodePluginCLIJSON(map[string]any{ + "plugin_id": artifact.PluginID, + "artifact_id": artifact.ID, + "preflight": result, + }) +} + +func runPluginSelfTestCLI(args []string) error { + opts, err := parseGovernanceCLIOptions(args) + if err != nil { + return err + } + if opts.Target == "" { + return errors.New("self-test requires a plugin directory or binary .mcgp") + } + manager, cleanup, artifact, _, err := prepareLocalGovernanceManager(opts) + if err != nil { + return err + } + defer cleanup() + result, err := manager.RunSelfTest(context.Background(), "cli", artifact.PluginID, pluginmanager.SelfTestRequest{ + ArtifactID: artifact.ID, + Profile: opts.Profile, + }) + if err != nil { + return err + } + return encodePluginCLIJSON(map[string]any{ + "plugin_id": artifact.PluginID, + "artifact_id": artifact.ID, + "self_test": result, + }) +} + +func runPluginBenchmarkCLI(args []string) error { + opts, err := parseGovernanceCLIOptions(args) + if err != nil { + return err + } + if opts.Target == "" { + return errors.New("benchmark requires a plugin directory or binary .mcgp") + } + if opts.BenchmarkProfile == "" { + opts.BenchmarkProfile = "local-fast" + } + manager, cleanup, artifact, configJSON, err := prepareLocalGovernanceManager(opts) + if err != nil { + return err + } + defer cleanup() + record, err := manager.SaveBenchmark(context.Background(), "cli", pluginmanager.BenchmarkRequest{ + ArtifactID: artifact.ID, + Profile: opts.Profile, + BenchmarkProfile: opts.BenchmarkProfile, + P95MS: opts.P95MS, + P99MS: opts.P99MS, + ErrorRate: opts.ErrorRate, + ActiveProxyCapacity: opts.ActiveProxyCapacity, + BaselineDiff: opts.BaselineDiff, + }) + if err != nil { + return err + } + decision, err := manager.EvaluateGovernance(context.Background(), artifact.PluginID, artifact.ID, pluginmanager.GovernanceActionEnable, opts.Profile, configJSON) + if err != nil { + return err + } + return encodePluginCLIJSON(map[string]any{ + "plugin_id": artifact.PluginID, + "artifact_id": artifact.ID, + "benchmark": record, + "decision": decision, + }) +} + +func runPluginInitCLI(args []string) error { + opts := pluginInitCLIOptions{ + Template: "upstream-dialer", + Runtime: pluginmanager.RuntimeGoPlugin, + Extension: pluginmanager.ExtensionUpstreamConnect, + } + for i := 0; i < len(args); i++ { + arg := args[i] + if !strings.HasPrefix(arg, "--") { + if opts.Dir != "" { + return fmt.Errorf("unexpected argument %q", arg) + } + opts.Dir = arg + continue + } + key, value, consumed, err := parsePluginCLIFlag(args, i) + if err != nil { + return err + } + i += consumed + switch key { + case "id": + opts.ID = value + case "name": + opts.Name = value + case "template": + opts.Template = value + case "runtime": + opts.Runtime = value + case "module": + opts.Module = value + case "extension": + opts.Extension = value + default: + return fmt.Errorf("unknown init flag --%s", key) + } + } + if opts.Dir == "" { + return errors.New("plugin init requires a target directory") + } + if opts.ID == "" { + opts.ID = filepath.Base(opts.Dir) + } + if opts.Name == "" { + opts.Name = titleFromPluginID(opts.ID) + } + if opts.Module == "" { + opts.Module = "example.com/" + opts.ID + } + adapter, err := pluginCLIAdapterForRuntime(opts.Runtime) + if err != nil { + return err + } + return adapter.Init(context.Background(), opts) +} + +func runPluginBuildCLI(args []string) error { + opts := pluginBuildCLIOptions{Dir: ".", BuildType: "binary", Vendor: true} + for i := 0; i < len(args); i++ { + arg := args[i] + if !strings.HasPrefix(arg, "--") { + if opts.Dir != "." { + return fmt.Errorf("unexpected argument %q", arg) + } + opts.Dir = arg + continue + } + key, value, consumed, err := parsePluginCLIFlag(args, i) + if err != nil { + return err + } + i += consumed + switch key { + case "type": + opts.BuildType = value + case "out": + opts.Out = value + case "from-source": + opts.FromSource = value + case "skip-tests": + opts.SkipTests = parsePluginBoolFlag(value) + case "vendor": + opts.Vendor = parsePluginBoolFlag(value) + default: + return fmt.Errorf("unknown build flag --%s", key) + } + } + if opts.FromSource != "" { + build, out, err := buildSourcePackageForCLI(opts.FromSource, opts.Out) + if err != nil { + return err + } + fmt.Fprintf(os.Stdout, "ok build=%d plugin=%s source_sha256=%s artifact_sha256=%s builder=%s go=%s status=%s out=%s\n", + build.ID, build.PluginID, build.SourceSHA256, build.ArtifactSHA256, build.BuilderType, build.GoVersion, build.Status, out) + return nil + } + switch opts.BuildType { + case "binary", "source", "both": + default: + return fmt.Errorf("unsupported build --type %q", opts.BuildType) + } + if !opts.SkipTests { + if err := runGoCommand(context.Background(), opts.Dir, "go", "test", "./..."); err != nil { + return err + } + } + manifest, raw, err := readPluginDirManifest(opts.Dir) + if err != nil { + return err + } + adapter, err := pluginCLIAdapterForRuntime(manifest.Runtime.Type) + if err != nil { + return err + } + outDir := opts.Out + if outDir == "" || strings.HasSuffix(outDir, string(os.PathSeparator)) { + if outDir == "" { + outDir = filepath.Join(opts.Dir, "dist") + } + } else if opts.BuildType == "both" { + return errors.New("--out must be a directory when --type both") + } + if opts.BuildType == "binary" || opts.BuildType == "both" { + outPath := opts.Out + if outPath == "" || opts.BuildType == "both" || strings.HasSuffix(outPath, string(os.PathSeparator)) { + outPath = filepath.Join(outDir, manifest.ID+".mcgp") + } + artifact, err := adapter.BuildBinary(context.Background(), pluginBuildRuntimeRequest{ + Dir: opts.Dir, + Manifest: manifest, + Raw: raw, + OutPath: outPath, + Vendor: opts.Vendor, + }) + if err != nil { + return err + } + fmt.Fprintf(os.Stdout, "ok plugin=%s version=%s sha256=%s api=%s go=%s %s/%s out=%s\n", + artifact.PluginID, artifact.Version, artifact.SHA256, artifact.APIVersion, artifact.GoVersion, artifact.GOOS, artifact.GOARCH, outPath) + } + if opts.BuildType == "source" || opts.BuildType == "both" { + outPath := opts.Out + if outPath == "" || opts.BuildType == "both" || strings.HasSuffix(outPath, string(os.PathSeparator)) { + outPath = filepath.Join(outDir, manifest.ID+"-source.mcgp") + } + source, err := adapter.BuildSource(context.Background(), pluginBuildRuntimeRequest{ + Dir: opts.Dir, + Manifest: manifest, + Raw: raw, + OutPath: outPath, + Vendor: opts.Vendor, + }) + if err != nil { + return err + } + fmt.Fprintf(os.Stdout, "ok source plugin=%s version=%s source_sha256=%s api=%s go=%s %s/%s out=%s\n", + source.PluginID, source.Version, source.SHA256, source.APIVersion, source.GoVersion, source.GOOS, source.GOARCH, outPath) + } + return nil +} + +func runPluginTestCLI(args []string) error { + target := "." + profile := "unit,manifest" + configPath := "" + fixturePath := "" + for i := 0; i < len(args); i++ { + arg := args[i] + if !strings.HasPrefix(arg, "--") { + if target != "." { + return fmt.Errorf("unexpected argument %q", arg) + } + target = arg + continue + } + key, value, consumed, err := parsePluginCLIFlag(args, i) + if err != nil { + return err + } + i += consumed + switch key { + case "profile": + profile = value + case "config": + configPath = value + case "fixture": + fixturePath = value + default: + return fmt.Errorf("unknown test flag --%s", key) + } + } + info, err := os.Stat(target) + if err != nil { + return err + } + if !info.IsDir() { + for _, item := range strings.Split(profile, ",") { + item = strings.TrimSpace(item) + if item == "" { + continue + } + switch item { + case "manifest", "compat", "conformance": + if _, err := validatePluginPathForCLI(target, ""); err != nil { + return err + } + default: + return fmt.Errorf("test profile %q requires a plugin source directory", item) + } + } + fmt.Fprintf(os.Stdout, "ok test target=%s profile=%s\n", target, profile) + return nil + } + manifest, _, err := readPluginDirManifest(target) + if err != nil { + return err + } + adapter, err := pluginCLIAdapterForRuntime(manifest.Runtime.Type) + if err != nil { + return err + } + if err := adapter.Test(context.Background(), pluginTestCLIOptions{ + Target: target, + Profile: profile, + ConfigPath: configPath, + FixturePath: fixturePath, + Manifest: manifest, + }); err != nil { + return err + } + fmt.Fprintf(os.Stdout, "ok test target=%s profile=%s\n", target, profile) + return nil +} + +func parseGovernanceCLIOptions(args []string) (pluginGovernanceCLIOptions, error) { + opts := pluginGovernanceCLIOptions{ + Profile: pluginmanager.PolicyProfileDev, + Action: pluginmanager.GovernanceActionEnable, + Priority: pluginmanager.DefaultPriority, + } + for i := 0; i < len(args); i++ { + arg := args[i] + if !strings.HasPrefix(arg, "--") { + if opts.Target != "" { + return pluginGovernanceCLIOptions{}, fmt.Errorf("unexpected argument %q", arg) + } + opts.Target = arg + continue + } + key, value, consumed, err := parsePluginCLIFlag(args, i) + if err != nil { + return pluginGovernanceCLIOptions{}, err + } + i += consumed + switch key { + case "config": + opts.ConfigPath = value + case "config-json": + opts.ConfigJSON = value + case "profile": + opts.Profile = value + case "action": + opts.Action = value + case "priority": + parsed, err := strconv.Atoi(value) + if err != nil { + return pluginGovernanceCLIOptions{}, fmt.Errorf("invalid --priority %q: %w", value, err) + } + opts.Priority = parsed + case "benchmark-profile": + opts.BenchmarkProfile = value + case "p95-ms": + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return pluginGovernanceCLIOptions{}, 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 pluginGovernanceCLIOptions{}, 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 pluginGovernanceCLIOptions{}, 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 pluginGovernanceCLIOptions{}, 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 pluginGovernanceCLIOptions{}, fmt.Errorf("invalid --baseline-diff %q: %w", value, err) + } + opts.BaselineDiff = parsed + default: + return pluginGovernanceCLIOptions{}, fmt.Errorf("unknown governance flag --%s", key) + } + } + if opts.ConfigPath != "" && opts.ConfigJSON != "" { + return pluginGovernanceCLIOptions{}, errors.New("--config and --config-json are mutually exclusive") + } + return opts, nil +} + +func prepareLocalGovernanceManager(opts pluginGovernanceCLIOptions) (*pluginmanager.Manager, func(), pluginmanager.ArtifactRecord, string, error) { + tmpRoot, err := os.MkdirTemp("", "mcgp-governance-cli-*") + if err != nil { + return nil, func() {}, pluginmanager.ArtifactRecord{}, "", err + } + cleanup := func() { _ = os.RemoveAll(tmpRoot) } + targetPath := opts.Target + info, err := os.Stat(targetPath) + if err != nil { + cleanup() + return nil, func() {}, pluginmanager.ArtifactRecord{}, "", err + } + if info.IsDir() { + manifest, raw, err := readPluginDirManifest(targetPath) + if err != nil { + cleanup() + return nil, func() {}, pluginmanager.ArtifactRecord{}, "", err + } + targetPath = filepath.Join(tmpRoot, manifest.ID+".mcgp") + if _, err := buildBinaryPluginPackage(opts.Target, manifest, raw, targetPath); err != nil { + cleanup() + return nil, func() {}, pluginmanager.ArtifactRecord{}, "", err + } + } + db, err := openPluginCLIDB(filepath.Join(tmpRoot, "plugins.db")) + if err != nil { + cleanup() + return nil, func() {}, pluginmanager.ArtifactRecord{}, "", err + } + manager := pluginmanager.New(pluginmanager.Options{ + DB: db, + ArtifactRoot: filepath.Join(tmpRoot, "artifacts"), + Adapter: cliStaticRuntimeAdapter{}, + PolicyProfile: opts.Profile, + }) + artifact, err := manager.UploadArtifact(context.Background(), pluginmanager.ArtifactUpload{ + SourcePath: targetPath, + FileName: filepath.Base(targetPath), + Actor: "cli", + }) + if err != nil { + _ = db.Close() + cleanup() + return nil, func() {}, pluginmanager.ArtifactRecord{}, "", err + } + if artifact.ArtifactType != pluginmanager.ArtifactTypeBinary { + _ = db.Close() + cleanup() + return nil, func() {}, pluginmanager.ArtifactRecord{}, "", fmt.Errorf("governance target must be a binary artifact, got %q", artifact.ArtifactType) + } + configJSON, err := governanceConfigJSON(opts) + if err != nil { + _ = db.Close() + cleanup() + return nil, func() {}, pluginmanager.ArtifactRecord{}, "", err + } + if _, err := manager.SetDesired(context.Background(), "cli", artifact.PluginID, artifact.ID, pluginmanager.DesiredDisabled, configJSON, opts.Priority); err != nil { + _ = db.Close() + cleanup() + return nil, func() {}, pluginmanager.ArtifactRecord{}, "", err + } + return manager, func() { + _ = db.Close() + cleanup() + }, artifact, configJSON, nil +} + +func governanceConfigJSON(opts pluginGovernanceCLIOptions) (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 + } + if info, err := os.Stat(opts.Target); err == nil && info.IsDir() { + defaultConfig := filepath.Join(opts.Target, "testdata", "config.json") + if data, err := os.ReadFile(defaultConfig); err == nil { + if !json.Valid(data) { + return "", fmt.Errorf("default config file %q must contain valid JSON", defaultConfig) + } + return string(data), nil + } + } + return "{}", nil +} + +func encodePluginCLIJSON(value any) error { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(value) +} + +func validatePluginPathForCLI(targetPath, expectedArtifactType string) (pluginmanager.ArtifactRecord, error) { + info, err := os.Stat(targetPath) + if err != nil { + return pluginmanager.ArtifactRecord{}, err + } + if info.IsDir() { + artifact, err := validatePluginDirectoryForCLI(targetPath) + if err != nil { + return pluginmanager.ArtifactRecord{}, err + } + if expectedArtifactType != "" && artifact.ArtifactType != expectedArtifactType { + return pluginmanager.ArtifactRecord{}, fmt.Errorf("artifact_type %q does not match expected %q", artifact.ArtifactType, expectedArtifactType) + } + return artifact, nil + } + if filepath.Base(targetPath) == "manifest.json" { + dir := filepath.Dir(targetPath) + artifact, err := validatePluginDirectoryForCLI(dir) + if err != nil { + return pluginmanager.ArtifactRecord{}, err + } + if expectedArtifactType != "" && artifact.ArtifactType != expectedArtifactType { + return pluginmanager.ArtifactRecord{}, fmt.Errorf("artifact_type %q does not match expected %q", artifact.ArtifactType, expectedArtifactType) + } + return artifact, nil + } + tmpRoot, err := os.MkdirTemp("", "mcgp-cli-*") + if err != nil { + return pluginmanager.ArtifactRecord{}, err + } + defer os.RemoveAll(tmpRoot) + store := pluginmanager.NewArtifactStore(tmpRoot) + upload := pluginmanager.ArtifactUpload{SourcePath: targetPath, FileName: filepath.Base(targetPath), Actor: "cli"} + if expectedArtifactType == pluginmanager.ArtifactTypeSource { + return store.ValidateAndStoreSource(upload) + } + if expectedArtifactType == pluginmanager.ArtifactTypeBinary { + return store.ValidateAndStoreBinary(upload) + } + return store.ValidateAndStore(upload) +} + +func validatePluginDirectoryForCLI(dir string) (pluginmanager.ArtifactRecord, error) { + manifest, raw, err := readPluginDirManifest(dir) + if err != nil { + return pluginmanager.ArtifactRecord{}, err + } + tmpRoot, err := os.MkdirTemp("", "mcgp-dir-validate-*") + if err != nil { + return pluginmanager.ArtifactRecord{}, err + } + defer os.RemoveAll(tmpRoot) + packagePath := filepath.Join(tmpRoot, manifest.ID+"-source.mcgp") + if _, err := writeSourcePackage(dir, manifest, raw, packagePath, false); err != nil { + return pluginmanager.ArtifactRecord{}, err + } + store := pluginmanager.NewArtifactStore(filepath.Join(tmpRoot, "store")) + return store.ValidateAndStoreSource(pluginmanager.ArtifactUpload{SourcePath: packagePath, FileName: filepath.Base(packagePath), Actor: "cli"}) +} + +func buildBinaryPluginPackage(dir string, manifest pluginmanager.Manifest, raw map[string]any, outPath string) (pluginmanager.ArtifactRecord, error) { + return buildBinaryPluginPackageContext(context.Background(), dir, manifest, raw, outPath) +} + +func buildBinaryPluginPackageContext(ctx context.Context, dir string, manifest pluginmanager.Manifest, raw map[string]any, outPath string) (pluginmanager.ArtifactRecord, error) { + tmpRoot, err := os.MkdirTemp("", "mcgp-build-binary-*") + if err != nil { + return pluginmanager.ArtifactRecord{}, err + } + defer os.RemoveAll(tmpRoot) + pluginPath := filepath.Join(tmpRoot, pluginmanager.RuntimeEntry) + buildEntry := sourceBuildEntryForCLI(manifest) + if err := runGoCommand(ctx, dir, "go", "build", "-buildmode=plugin", "-trimpath", "-buildvcs=false", "-o", pluginPath, buildEntry); err != nil { + return pluginmanager.ArtifactRecord{}, err + } + if err := validatePluginSymbol(ctx, dir, pluginPath, manifest); err != nil { + return pluginmanager.ArtifactRecord{}, err + } + manifestBytes, err := materializedManifestJSON(raw, manifest, pluginmanager.ArtifactTypeBinary, false) + if err != nil { + return pluginmanager.ArtifactRecord{}, err + } + if err := writeBinaryPackage(pluginPath, manifestBytes, dir, outPath); err != nil { + return pluginmanager.ArtifactRecord{}, err + } + return validatePluginPathForCLI(outPath, pluginmanager.ArtifactTypeBinary) +} + +func buildSourcePluginPackage(dir string, manifest pluginmanager.Manifest, raw map[string]any, outPath string, vendor bool) (pluginmanager.ArtifactRecord, error) { + return buildSourcePluginPackageContext(context.Background(), dir, manifest, raw, outPath, vendor) +} + +func buildSourcePluginPackageContext(ctx context.Context, dir string, manifest pluginmanager.Manifest, raw map[string]any, outPath string, vendor bool) (pluginmanager.ArtifactRecord, error) { + if _, err := writeSourcePackageContext(ctx, dir, manifest, raw, outPath, vendor); err != nil { + return pluginmanager.ArtifactRecord{}, err + } + return validatePluginPathForCLI(outPath, pluginmanager.ArtifactTypeSource) +} + +func writeGoPluginTemplate(ctx context.Context, opts pluginInitCLIOptions) error { + _ = ctx + if err := os.MkdirAll(opts.Dir, 0755); err != nil { + return err + } + existing, err := os.ReadDir(opts.Dir) + if err != nil { + return err + } + if len(existing) > 0 { + return fmt.Errorf("target directory %q is not empty", opts.Dir) + } + repoRoot, err := findRepoRoot() + if err != nil { + return err + } + files := map[string]string{ + "go.mod": goModTemplate(opts.Module, repoRoot), + "main.go": goPluginMainTemplate(opts), + "main_test.go": goPluginTestTemplate(), + "README.md": readmeTemplate(opts), + "manifest.json": manifestTemplate(opts), + "testdata/config.json": configTemplate(opts.Template), + } + for name, content := range files { + target := filepath.Join(opts.Dir, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + if err := os.WriteFile(target, []byte(content), 0644); err != nil { + return err + } + } + fmt.Fprintf(os.Stdout, "ok init plugin=%s template=%s dir=%s\n", opts.ID, opts.Template, opts.Dir) + return nil +} + +func readPluginDirManifest(dir string) (pluginmanager.Manifest, map[string]any, error) { + data, err := os.ReadFile(filepath.Join(dir, "manifest.json")) + if err != nil { + return pluginmanager.Manifest{}, nil, err + } + var manifest pluginmanager.Manifest + if err := json.Unmarshal(data, &manifest); err != nil { + return pluginmanager.Manifest{}, nil, fmt.Errorf("invalid manifest.json: %w", err) + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return pluginmanager.Manifest{}, nil, fmt.Errorf("invalid manifest.json object: %w", err) + } + if manifest.ID == "" { + return pluginmanager.Manifest{}, nil, errors.New("manifest id is required") + } + return manifest, raw, nil +} + +func resolveManifestPath(target string) (string, error) { + info, err := os.Stat(target) + if err != nil { + return "", err + } + if info.IsDir() { + return filepath.Join(target, "manifest.json"), nil + } + if filepath.Base(target) != "manifest.json" { + return "", fmt.Errorf("manifest path must be manifest.json or a plugin directory, got %q", target) + } + return target, nil +} + +func materializedManifestJSON(raw map[string]any, manifest pluginmanager.Manifest, artifactType string, vendor bool) ([]byte, error) { + clone := make(map[string]any, len(raw)+4) + for key, value := range raw { + clone[key] = value + } + clone["artifact_type"] = artifactType + clone["go_version"] = runtime.Version() + clone["go_os"] = runtime.GOOS + clone["go_arch"] = runtime.GOARCH + runtimeMap, _ := clone["runtime"].(map[string]any) + if runtimeMap == nil { + runtimeMap = make(map[string]any) + } + if runtimeMap["type"] == nil || runtimeMap["type"] == "" { + runtimeMap["type"] = pluginmanager.RuntimeGoPlugin + } + if runtimeMap["entry_symbol"] == nil || runtimeMap["entry_symbol"] == "" { + runtimeMap["entry_symbol"] = "Plugin" + } + if artifactType == pluginmanager.ArtifactTypeBinary { + runtimeMap["entry"] = pluginmanager.RuntimeEntry + } else if runtimeMap["entry"] == nil || runtimeMap["entry"] == "" { + runtimeMap["entry"] = pluginmanager.RuntimeEntry + } + clone["runtime"] = runtimeMap + if artifactType == pluginmanager.ArtifactTypeSource { + buildMap, _ := clone["build"].(map[string]any) + if buildMap == nil { + buildMap = make(map[string]any) + } + if buildMap["type"] == nil || buildMap["type"] == "" { + buildMap["type"] = pluginmanager.BuildTypeGo + } + if buildMap["entry"] == nil || buildMap["entry"] == "" { + buildMap["entry"] = sourceBuildEntryForCLI(manifest) + } + if buildMap["output"] == nil || buildMap["output"] == "" { + buildMap["output"] = pluginmanager.RuntimeEntry + } + if buildMap["go_version"] == nil || buildMap["go_version"] == "" { + buildMap["go_version"] = runtime.Version() + } + if buildMap["tags"] == nil { + buildMap["tags"] = []string{} + } + if buildMap["vendor_required"] == nil { + buildMap["vendor_required"] = false + } + clone["build"] = buildMap + } + data, err := json.MarshalIndent(clone, "", " ") + if err != nil { + return nil, err + } + return append(data, '\n'), nil +} + +func writeBinaryPackage(pluginPath string, manifestBytes []byte, sourceDir, outPath string) error { + if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { + return err + } + out, err := os.Create(outPath) + if err != nil { + return err + } + defer out.Close() + zw := zip.NewWriter(out) + if err := addZipBytes(zw, "manifest.json", manifestBytes); err != nil { + zw.Close() + return err + } + if err := addZipFileStable(zw, pluginmanager.RuntimeEntry, pluginPath); err != nil { + zw.Close() + return err + } + for _, name := range optionalPackageDocs(sourceDir) { + if err := addZipFileStable(zw, filepath.ToSlash(name), filepath.Join(sourceDir, name)); err != nil { + zw.Close() + return err + } + } + if err := zw.Close(); err != nil { + return err + } + return out.Close() +} + +func writeSourcePackage(dir string, manifest pluginmanager.Manifest, raw map[string]any, outPath string, vendor bool) (string, error) { + return writeSourcePackageContext(context.Background(), dir, manifest, raw, outPath, vendor) +} + +func writeSourcePackageContext(ctx context.Context, dir string, manifest pluginmanager.Manifest, raw map[string]any, outPath string, vendor bool) (string, error) { + manifestBytes, err := materializedManifestJSON(raw, manifest, pluginmanager.ArtifactTypeSource, vendor) + if err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { + return "", err + } + tmpRoot, err := os.MkdirTemp("", "mcgp-source-package-*") + if err != nil { + return "", err + } + defer os.RemoveAll(tmpRoot) + var vendorDir string + if vendor { + vendorDir = filepath.Join(tmpRoot, "vendor") + if err := runGoCommand(ctx, dir, "go", "mod", "vendor", "-o", vendorDir); err != nil { + return "", err + } + } + files, err := collectSourcePackageFiles(dir, vendorDir) + if err != nil { + return "", err + } + out, err := os.Create(outPath) + if err != nil { + return "", err + } + defer out.Close() + zw := zip.NewWriter(out) + if err := addZipBytes(zw, "manifest.json", manifestBytes); err != nil { + zw.Close() + return "", err + } + for _, file := range files { + if err := addZipFileStable(zw, file.Name, file.Path); err != nil { + zw.Close() + return "", err + } + } + if err := zw.Close(); err != nil { + return "", err + } + if err := out.Close(); err != nil { + return "", err + } + sum, err := fileSHA256ForCLI(outPath) + if err != nil { + return "", err + } + return sum, nil +} + +type sourcePackageFile struct { + Name string + Path string +} + +func collectSourcePackageFiles(dir, vendorDir string) ([]sourcePackageFile, error) { + var files []sourcePackageFile + err := filepath.WalkDir(dir, func(filePath string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(dir, filePath) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + if rel == "." { + return nil + } + if d.IsDir() { + switch rel { + case ".git", "dist", "vendor": + return filepath.SkipDir + } + if strings.HasPrefix(path.Base(rel), ".") { + return filepath.SkipDir + } + return nil + } + if rel == "manifest.json" { + return nil + } + if sourcePackageEntryAllowed(rel) { + files = append(files, sourcePackageFile{Name: rel, Path: filePath}) + } + return nil + }) + if err != nil { + return nil, err + } + if vendorDir != "" { + err = filepath.WalkDir(vendorDir, func(filePath string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + rel, err := filepath.Rel(vendorDir, filePath) + if err != nil { + return err + } + files = append(files, sourcePackageFile{Name: path.Join("vendor", filepath.ToSlash(rel)), Path: filePath}) + return nil + }) + if err != nil { + return nil, err + } + } + sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name }) + return files, nil +} + +func sourcePackageEntryAllowed(name string) bool { + base := path.Base(name) + lower := strings.ToLower(base) + switch { + case name == "go.mod", name == "go.sum": + return true + case strings.HasSuffix(name, ".go"): + return true + case strings.EqualFold(base, "README.md"), strings.EqualFold(base, "LICENSE"): + return true + case strings.Contains(lower, "sbom"): + return true + case strings.HasPrefix(name, "testdata/"): + return true + default: + return false + } +} + +func optionalPackageDocs(sourceDir string) []string { + var docs []string + for _, name := range []string{"README.md", "LICENSE"} { + if info, err := os.Stat(filepath.Join(sourceDir, name)); err == nil && !info.IsDir() { + docs = append(docs, name) + } + } + return docs +} + +func validatePluginSymbol(ctx context.Context, dir, pluginPath string, manifest pluginmanager.Manifest) error { + output, err := commandOutputForCLI(ctx, dir, "go", "tool", "nm", pluginPath) + if err != nil { + return fmt.Errorf("inspect built plugin symbols: %w\n%s", err, output) + } + entrySymbol := manifest.Runtime.EntrySymbol + if entrySymbol == "" { + entrySymbol = "Plugin" + } + if !strings.Contains(output, entrySymbol) { + return fmt.Errorf("built plugin is missing entry symbol %q", entrySymbol) + } + return nil +} + +func runGoCommand(ctx context.Context, dir string, name string, args ...string) error { + output, err := commandOutputForCLI(ctx, dir, name, args...) + if err != nil { + if strings.TrimSpace(output) == "" { + return err + } + return fmt.Errorf("%s %s failed: %w\n%s", name, strings.Join(args, " "), err, output) + } + return nil +} + +func commandOutputForCLI(ctx context.Context, dir string, name string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Dir = dir + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + err := cmd.Run() + return strings.TrimSpace(out.String()), err +} + +func addZipBytes(zw *zip.Writer, name string, data []byte) error { + header := &zip.FileHeader{Name: name, Method: zip.Deflate} + header.SetMode(0644) + header.Modified = time.Unix(0, 0).UTC() + writer, err := zw.CreateHeader(header) + if err != nil { + return err + } + _, err = writer.Write(data) + return err +} + +func addZipFileStable(zw *zip.Writer, name, filePath string) error { + data, err := os.ReadFile(filePath) + if err != nil { + return err + } + mode := fs.FileMode(0644) + if info, err := os.Stat(filePath); err == nil { + mode = info.Mode().Perm() + } + header := &zip.FileHeader{Name: filepath.ToSlash(name), Method: zip.Deflate} + header.SetMode(mode) + header.Modified = time.Unix(0, 0).UTC() + writer, err := zw.CreateHeader(header) + if err != nil { + return err + } + _, err = writer.Write(data) + return err +} + +func parsePluginCLIFlag(args []string, index int) (string, string, int, error) { + arg := args[index] + if !strings.HasPrefix(arg, "--") { + return "", "", 0, fmt.Errorf("expected flag, got %q", arg) + } + body := strings.TrimPrefix(arg, "--") + if body == "" { + return "", "", 0, fmt.Errorf("invalid empty flag %q", arg) + } + if key, value, ok := strings.Cut(body, "="); ok { + return key, value, 0, nil + } + if isPluginBoolFlag(body) { + return body, "true", 0, nil + } + if index+1 >= len(args) || strings.HasPrefix(args[index+1], "--") { + return "", "", 0, fmt.Errorf("flag --%s requires a value", body) + } + return body, args[index+1], 1, nil +} + +func isPluginBoolFlag(name string) bool { + switch name { + case "skip-tests", "vendor", "json", "quiet", "dry-run", "write", "source", "full-desired": + return true + default: + return false + } +} + +func parsePluginBoolFlag(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "1", "t", "true", "yes", "y", "on": + return true + default: + return false + } +} + +func sourceBuildEntryForCLI(manifest pluginmanager.Manifest) string { + entry := strings.TrimSpace(manifest.Build.Entry) + if entry == "" { + entry = strings.TrimSpace(manifest.Runtime.BuildEntry) + } + if entry == "" { + entry = pluginmanager.SourceBuildEntry + } + return entry +} + +func validateTestFileIfSet(filePath, label string) error { + if filePath == "" { + return nil + } + info, err := os.Stat(filePath) + if err != nil { + return fmt.Errorf("%s %q is not readable: %w", label, filePath, err) + } + if info.IsDir() { + return fmt.Errorf("%s %q is a directory", label, filePath) + } + return nil +} + +func fileSHA256ForCLI(filePath string) (string, error) { + file, err := os.Open(filePath) + if err != nil { + return "", err + } + defer file.Close() + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + +func titleFromPluginID(id string) string { + parts := strings.FieldsFunc(id, func(r rune) bool { return r == '-' || r == '_' || r == '.' }) + for i, part := range parts { + if part == "" { + continue + } + parts[i] = strings.ToUpper(part[:1]) + part[1:] + } + if len(parts) == 0 { + return id + } + return strings.Join(parts, " ") +} + +func findRepoRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + for { + data, err := os.ReadFile(filepath.Join(dir, "go.mod")) + if err == nil && strings.Contains(string(data), "module github.com/tursom/mc-gateway") { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", errors.New("could not locate mc-gateway repository root") + } + dir = parent + } +} + +func goModTemplate(module, repoRoot string) string { + return fmt.Sprintf(`module %s + +go 1.24.0 + +require github.com/tursom/mc-gateway v0.0.0 + +replace github.com/tursom/mc-gateway => %s +`, module, filepath.ToSlash(repoRoot)) +} + +func manifestTemplate(opts pluginInitCLIOptions) string { + mode := pluginmanager.UpstreamModeDialer + if opts.Template == "protocol-proxy" { + mode = pluginmanager.UpstreamModeProtocolProxy + } + manifest := fmt.Sprintf(`{ + "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) + return manifest +} + +func goPluginMainTemplate(opts pluginInitCLIOptions) string { + if opts.Template == "protocol-proxy" { + return protocolProxyMainTemplate() + } + return upstreamDialerMainTemplate() +} + +func upstreamDialerMainTemplate() string { + return `package main + +import ( + "net" + + "github.com/tursom/mc-gateway/plugin/api" +) + +type PluginImpl struct { + api.AbstractPlugin + config Config +} + +type Config struct { + MatchHost string ` + "`json:\"match_host\"`" + ` + Upstream string ` + "`json:\"upstream\"`" + ` +} + +func Plugin() api.Plugin { + return &PluginImpl{} +} + +func (p *PluginImpl) NewConfigObj() any { + return &Config{} +} + +func (p *PluginImpl) ReloadConfig(config any) error { + if cfg, ok := config.(*Config); ok { + p.config = *cfg + } + return nil +} + +func (p *PluginImpl) Init(gateway api.Gateway) error { + return api.RegisterHookHandler( + gateway, + api.HookUpstreamConnect, + func(req api.UpstreamConnectRequest) bool { + return p.config.MatchHost == "" || req.Host == p.config.MatchHost || req.Upstream == p.config.MatchHost + }, + func(req api.UpstreamConnectRequest) (net.Conn, error) { + if p.config.Upstream == "" { + return nil, api.ErrPass + } + if p.config.MatchHost != "" && req.Host != p.config.MatchHost && req.Upstream != p.config.MatchHost { + return nil, api.ErrPass + } + return net.Dial("tcp", p.config.Upstream) + }, + ) +} +` +} + +func protocolProxyMainTemplate() string { + return `package main + +import ( + "net" + + "github.com/tursom/mc-gateway/plugin/api" +) + +type PluginImpl struct { + api.AbstractPlugin + config Config +} + +type Config struct { + MatchHost string ` + "`json:\"match_host\"`" + ` +} + +func Plugin() api.Plugin { + return &PluginImpl{} +} + +func (p *PluginImpl) NewConfigObj() any { + return &Config{} +} + +func (p *PluginImpl) ReloadConfig(config any) error { + if cfg, ok := config.(*Config); ok { + p.config = *cfg + } + return nil +} + +func (p *PluginImpl) Init(gateway api.Gateway) error { + return api.RegisterHookHandler( + gateway, + api.HookUpstreamConnect, + func(req api.UpstreamConnectRequest) bool { + return p.config.MatchHost == "" || req.Host == p.config.MatchHost + }, + func(req api.UpstreamConnectRequest) (net.Conn, error) { + return nil, api.ErrPass + }, + ) +} +` +} + +func goPluginTestTemplate() string { + return `package main + +import ( + "testing" + + "github.com/tursom/mc-gateway/plugin/api" +) + +func TestPluginFactory(t *testing.T) { + if plugin := Plugin(); plugin == nil { + t.Fatal("Plugin() returned nil") + } else if _, ok := plugin.(api.Plugin); !ok { + t.Fatal("Plugin() did not return api.Plugin") + } +} +` +} + +func readmeTemplate(opts pluginInitCLIOptions) string { + return fmt.Sprintf(`# %s + +Build and test: + +`+"```sh"+` +gateway plugin test . +gateway plugin build . --type both +`+"```"+` +`, opts.Name) +} + +func configTemplate(template string) string { + if template == "protocol-proxy" { + return "{\n \"match_host\": \"play.example\"\n}\n" + } + return "{\n \"match_host\": \"play.example\",\n \"upstream\": \"127.0.0.1:25566\"\n}\n" +} diff --git a/cmd/gateway/plugin_cli_toolchain_test.go b/cmd/gateway/plugin_cli_toolchain_test.go new file mode 100644 index 0000000..371d0f1 --- /dev/null +++ b/cmd/gateway/plugin_cli_toolchain_test.go @@ -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) + } + } +} diff --git a/docs/plugin-development-toolchain-design.md b/docs/plugin-development-toolchain-design.md new file mode 100644 index 0000000..49c3e08 --- /dev/null +++ b/docs/plugin-development-toolchain-design.md @@ -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-neutral:Go 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 ` | 生成插件模板 | +| `gateway plugin build [dir]` | 构建并打包 binary/source `.mcgp` | +| `gateway plugin test [dir]` | 运行插件单元测试和 harness 测试 | +| `gateway plugin validate ` | 校验 manifest、源码目录或 `.mcgp` 包 | +| `gateway plugin inspect ` | 查看包内 manifest 和摘要 | +| `gateway plugin compat ` | 检查当前 gateway 对 artifact 的兼容性 | + +现有 `gateway plugin source-build [out.mcgp]` 保留为兼容命令。后续可以由 `gateway plugin build --from-source --out ` 覆盖同等能力,再把 `source-build` 标记为兼容别名。 + +所有面向 CI 的命令都应支持: + +- `--json`:输出机器可读结果。 +- `--quiet`:只输出错误或关键产物路径。 +- `--out `:指定产物或报告位置。 +- 稳定退出码:参数错误、校验失败、构建失败和测试失败应可区分。 + +## 完整功能域 + +工具链最终需要覆盖从插件作者到生产运维的完整闭环。下表是功能需求清单,阶段表示推荐落地顺序,不代表命令只能在该阶段出现。 + +| 功能域 | 需要解决的问题 | 关键命令 | +| --- | --- | --- | +| 项目脚手架 | 快速生成可构建、可测试、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,必须满足 manifest 命名规则 | +| `--name ` | 展示名,默认由 ID 派生 | +| `--template ` | 模板名 | +| `--runtime ` | runtime 类型,默认 `go-plugin` | +| `--module ` | Go module path,Go runtime 模板必填或由目录推导 | +| `--extension ` | 目标 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/.mcgp` +- `dist/-source.mcgp` +- `dist/-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 mode:initial 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/source package,返回 artifact ID、sha256 和校验摘要 | +| `gateway plugin status [plugin-id]` | 展示 desired/runtime state、active/desired/loaded artifact、recent error 和 restart required | +| `gateway plugin enable ` | 设置 desired enabled,支持 `--artifact`、`--config`、`--profile`、`--priority` | +| `gateway plugin disable ` | 设置 desired disabled,protocol-proxy 连接按策略 drain 或 force close | +| `gateway plugin delete ` | 删除 desired state 或 artifact,支持保留/删除数据选项 | +| `gateway plugin rollback ` | 回滚 artifact 或 config snapshot,并重新执行当前基础门禁 | +| `gateway plugin config validate ` | 校验 config JSON、schema、secret ref 和 `ReloadConfig()` dry-run | +| `gateway plugin secret check ` | 检查 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 ` | 查看插件日志摘要,支持 tail、时间范围、trace ID 和脱敏 | +| `gateway plugin events ` | 查看插件业务事件、drop/dead-letter 摘要和 replay/drop 操作 | +| `gateway plugin metrics ` | 查看 handler calls、duration、panic、timeout、active proxy connections 和 custom metrics | +| `gateway plugin diagnose ` | 生成诊断包,包含 manifest、state、recent logs/events/metrics/build summary,不含 secret 明文 | +| `gateway plugin task list/run/cancel ` | 查看、手动触发或取消 background task | +| `gateway plugin data inspect/export/gc ` | 查看 plugin_data schema/data class/quota,导出可迁移数据,执行 dry-run 或清理 | +| `gateway plugin files inspect/export/gc ` | 查看 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/disable;CLI 只提供 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/.mcgp` +5. `gateway plugin compat dist/.mcgp` +6. 可选:`gateway plugin build --from-source dist/-source.mcgp --out dist/-rebuilt.mcgp` +7. 可选:`gateway plugin test dist/.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,而不是只返回通用错误。 diff --git a/docs/plugin-implementation-plan.md b/docs/plugin-implementation-plan.md index 1f35721..053d907 100644 --- a/docs/plugin-implementation-plan.md +++ b/docs/plugin-implementation-plan.md @@ -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 不在页面、日志、审计中明文展示 | diff --git a/docs/plugin-system-design.md b/docs/plugin-system-design.md index 6c68dbd..7cada2e 100644 --- a/docs/plugin-system-design.md +++ b/docs/plugin-system-design.md @@ -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_data schema、data class、大小、配额和 GC candidate | -| `mc-gateway plugin data export ` | 导出允许迁移的数据,受 data_class 和权限控制 | -| `mc-gateway plugin data gc ` | 按 retention 清理过期或可丢弃 plugin_data | -| `mc-gateway plugin sbom` | 生成或校验 SBOM,未来能力 | -| `mc-gateway plugin sign` | 签名插件包,未来能力 | +| `gateway plugin init` | 生成插件模板 | +| `gateway plugin validate ` | 校验 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_data schema、data class、大小、配额和 GC candidate | +| `gateway plugin data export ` | 导出允许迁移的数据,受 data_class 和权限控制 | +| `gateway plugin data gc ` | 按 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 使用 SemVer,gateway 记录支持范围 | | conformance suite | release 前必须运行并产出报告;第一版可先作为 release gate,CI 阻断按模块成熟度逐步打开 | -| CLI 形态 | 第一版作为 gateway 二进制的 `mc-gateway plugin` 子命令;独立 `mc-gateway-plugin` 作为未来分发形态 | +| CLI 形态 | 第一版作为 gateway 二进制的 `gateway plugin` 子命令;独立 `gateway-plugin` 作为未来分发形态 | | 插件服务启动模式 | 第一版固定 `in-process`;Admin 可预留 desired mode 配置,`go-plugin-process`/`sandbox-process` 未来生效且切换需要重启 | ### 准入和权限 diff --git a/examples/plugins/mc-auth-proxy/README.md b/examples/plugins/mc-auth-proxy/README.md index a2cb1b5..0492033 100644 --- a/examples/plugins/mc-auth-proxy/README.md +++ b/examples/plugins/mc-auth-proxy/README.md @@ -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: diff --git a/examples/plugins/mc-auth-proxy/build.sh b/examples/plugins/mc-auth-proxy/build.sh index 7c9168e..19abfa4 100755 --- a/examples/plugins/mc-auth-proxy/build.sh +++ b/examples/plugins/mc-auth-proxy/build.sh @@ -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 diff --git a/examples/plugins/mc-auth-proxy/cmd/render-manifest/main.go b/examples/plugins/mc-auth-proxy/cmd/render-manifest/main.go deleted file mode 100644 index 90de166..0000000 --- a/examples/plugins/mc-auth-proxy/cmd/render-manifest/main.go +++ /dev/null @@ -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) -} diff --git a/examples/plugins/mc-auth-proxy/testdata/config.json b/examples/plugins/mc-auth-proxy/testdata/config.json new file mode 100644 index 0000000..5a15e5a --- /dev/null +++ b/examples/plugins/mc-auth-proxy/testdata/config.json @@ -0,0 +1,5 @@ +{ + "match_host": "play.example", + "fixture_accept": false, + "disconnect_message": "Authentication fixture rejected the login" +} diff --git a/examples/plugins/upstream-rewrite/README.md b/examples/plugins/upstream-rewrite/README.md index efe1b07..53ab273 100644 --- a/examples/plugins/upstream-rewrite/README.md +++ b/examples/plugins/upstream-rewrite/README.md @@ -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: diff --git a/examples/plugins/upstream-rewrite/build.sh b/examples/plugins/upstream-rewrite/build.sh index a8fed58..80a1846 100755 --- a/examples/plugins/upstream-rewrite/build.sh +++ b/examples/plugins/upstream-rewrite/build.sh @@ -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 diff --git a/examples/plugins/upstream-rewrite/cmd/render-manifest/main.go b/examples/plugins/upstream-rewrite/cmd/render-manifest/main.go deleted file mode 100644 index 90de166..0000000 --- a/examples/plugins/upstream-rewrite/cmd/render-manifest/main.go +++ /dev/null @@ -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) -} diff --git a/examples/plugins/upstream-rewrite/testdata/config.json b/examples/plugins/upstream-rewrite/testdata/config.json new file mode 100644 index 0000000..940b7bd --- /dev/null +++ b/examples/plugins/upstream-rewrite/testdata/config.json @@ -0,0 +1,4 @@ +{ + "match_host": "play.example", + "upstream": "127.0.0.1:25566" +} diff --git a/internal/pluginmanager/artifact.go b/internal/pluginmanager/artifact.go index dc1fbb5..0d33b25 100644 --- a/internal/pluginmanager/artifact.go +++ b/internal/pluginmanager/artifact.go @@ -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: