Files
mc-gateway/cmd/gateway/plugin_cli_remote.go
tursom f508ecc1b9
Some checks failed
Go / build (.exe, 386, windows, windows-386) (push) Has been cancelled
Go / build (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Go / build (.exe, arm64, windows, windows-arm64) (push) Has been cancelled
Go / build (386, freebsd, freebsd-386) (push) Has been cancelled
Go / build (386, linux, linux-386) (push) Has been cancelled
Go / build (386, netbsd, netbsd-386) (push) Has been cancelled
Go / build (386, openbsd, openbsd-386) (push) Has been cancelled
Go / build (386, plan9, plan9-386) (push) Has been cancelled
Go / build (amd64, darwin, darwin-amd64) (push) Has been cancelled
Go / build (amd64, dragonfly, dragonfly-amd64) (push) Has been cancelled
Go / build (amd64, freebsd, freebsd-amd64) (push) Has been cancelled
Go / build (amd64, illumos, illumos-amd64) (push) Has been cancelled
Go / build (amd64, linux, linux-amd64) (push) Has been cancelled
Go / build (amd64, netbsd, netbsd-amd64) (push) Has been cancelled
Go / build (amd64, openbsd, openbsd-amd64) (push) Has been cancelled
Go / build (amd64, plan9, plan9-amd64) (push) Has been cancelled
Go / build (amd64, solaris, solaris-amd64) (push) Has been cancelled
Go / build (arm, 6, linux, linux-armv6) (push) Has been cancelled
Go / build (arm, 7, linux, linux-armv7) (push) Has been cancelled
Go / build (arm, freebsd, freebsd-arm) (push) Has been cancelled
Go / build (arm, netbsd, netbsd-arm) (push) Has been cancelled
Go / build (arm, openbsd, openbsd-arm) (push) Has been cancelled
Go / build (arm, plan9, plan9-arm) (push) Has been cancelled
Go / build (arm64, darwin, darwin-arm64) (push) Has been cancelled
Go / build (arm64, freebsd, freebsd-arm64) (push) Has been cancelled
Go / build (arm64, linux, linux-arm64) (push) Has been cancelled
Go / build (arm64, netbsd, netbsd-arm64) (push) Has been cancelled
Go / build (arm64, openbsd, openbsd-arm64) (push) Has been cancelled
Go / build (loong64, linux, linux-loong64) (push) Has been cancelled
Go / build (mips, linux, linux-mips) (push) Has been cancelled
Go / build (mips64, linux, linux-mips64) (push) Has been cancelled
Go / build (mips64le, linux, linux-mips64le) (push) Has been cancelled
Go / build (mipsle, linux, linux-mipsle) (push) Has been cancelled
Go / build (ppc64, aix, aix-ppc64) (push) Has been cancelled
Go / build (ppc64, linux, linux-ppc64) (push) Has been cancelled
Go / build (ppc64, openbsd, openbsd-ppc64) (push) Has been cancelled
Go / build (ppc64le, linux, linux-ppc64le) (push) Has been cancelled
Go / build (riscv64, freebsd, freebsd-riscv64) (push) Has been cancelled
Go / build (riscv64, linux, linux-riscv64) (push) Has been cancelled
Go / build (riscv64, openbsd, openbsd-riscv64) (push) Has been cancelled
Go / build (s390x, linux, linux-s390x) (push) Has been cancelled
Docker Image / docker (push) Has been cancelled
Go / merge-artifacts (push) Has been cancelled
docs: 补充中文代码注释
2026-06-27 20:15:29 +08:00

980 lines
26 KiB
Go

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