feat(plugin): add managed binary plugin MVP
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
Go / merge-artifacts (push) Has been cancelled
Docker Image / docker (push) Has been cancelled
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
Go / merge-artifacts (push) Has been cancelled
Docker Image / docker (push) Has been cancelled
This commit is contained in:
@@ -28,5 +28,12 @@ func newAdminAPIHandler() http.HandlerFunc {
|
||||
UserItem: handleAdminUserItem,
|
||||
|
||||
AuditLogs: handleAdminAuditLogs,
|
||||
|
||||
PluginArtifacts: handleAdminPluginArtifacts,
|
||||
PluginArtifact: handleAdminPluginArtifact,
|
||||
PluginsList: handleAdminPluginsList,
|
||||
PluginItem: handleAdminPluginItem,
|
||||
PluginAction: handleAdminPluginAction,
|
||||
PluginDispatch: handleAdminPluginDispatchPlan,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ func recordAudit(ctx context.Context, actor, sourceIP, action, targetType, targe
|
||||
_ = adminaudit.NewRepository(adminDB).Record(ctx, actor, sourceIP, action, targetType, targetID, success, message)
|
||||
}
|
||||
|
||||
func recordAuditMetadata(ctx context.Context, actor, sourceIP, action, targetType, targetID string, success bool, message string, metadata any) {
|
||||
_ = adminaudit.NewRepository(adminDB).RecordWithMetadata(ctx, actor, sourceIP, action, targetType, targetID, success, message, metadata)
|
||||
}
|
||||
|
||||
func listAuditLogs(ctx context.Context) ([]adminaudit.Record, error) {
|
||||
return adminaudit.NewRepository(adminDB).List(ctx, adminaudit.DefaultListLimit)
|
||||
}
|
||||
|
||||
275
cmd/gateway/admin_plugin_handlers.go
Normal file
275
cmd/gateway/admin_plugin_handlers.go
Normal file
@@ -0,0 +1,275 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/adminhttp"
|
||||
"github.com/tursom/mc-gateway/internal/pluginmanager"
|
||||
)
|
||||
|
||||
func handleAdminPluginArtifacts(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := requireRole(w, r, adminRoleMember)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if pluginsManager == nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusServiceUnavailable, "plugin manager is not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
artifacts, err := pluginsManager.ListArtifacts(r.Context(), r.URL.Query().Get("plugin_id"))
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"artifacts": artifacts})
|
||||
case http.MethodPost:
|
||||
if session.Role != adminRoleAdmin {
|
||||
adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
artifact, err := receivePluginArtifact(r, session.Username)
|
||||
if err != nil {
|
||||
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_artifact_upload", "plugin_artifact", "", false, err.Error())
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_artifact_upload", "plugin_artifact", artifact.ID, true, "artifact uploaded", map[string]any{
|
||||
"plugin_id": artifact.PluginID,
|
||||
"version": artifact.Version,
|
||||
"sha256": artifact.SHA256,
|
||||
"package_sha256": artifact.PackageSHA256,
|
||||
})
|
||||
adminhttp.WriteJSON(w, http.StatusCreated, map[string]any{"artifact": artifact})
|
||||
default:
|
||||
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func handleAdminPluginArtifact(w http.ResponseWriter, r *http.Request, rawArtifactID string) {
|
||||
if _, ok := requireRole(w, r, adminRoleMember); !ok {
|
||||
return
|
||||
}
|
||||
if pluginsManager == nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusServiceUnavailable, "plugin manager is not initialized")
|
||||
return
|
||||
}
|
||||
artifactID, err := adminhttp.PathSegment(rawArtifactID)
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
artifact, err := pluginsManager.Artifact(r.Context(), artifactID)
|
||||
if err != nil {
|
||||
writePluginManagerError(w, err)
|
||||
return
|
||||
}
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"artifact": artifact})
|
||||
}
|
||||
|
||||
func handleAdminPluginsList(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireRole(w, r, adminRoleMember); !ok {
|
||||
return
|
||||
}
|
||||
if pluginsManager == nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusServiceUnavailable, "plugin manager is not initialized")
|
||||
return
|
||||
}
|
||||
plugins, err := pluginsManager.ListPlugins(r.Context())
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"plugins": plugins})
|
||||
}
|
||||
|
||||
func handleAdminPluginItem(w http.ResponseWriter, r *http.Request, rawPluginID string) {
|
||||
session, ok := requireRole(w, r, adminRoleMember)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if pluginsManager == nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusServiceUnavailable, "plugin manager is not initialized")
|
||||
return
|
||||
}
|
||||
pluginID, err := adminhttp.PathSegment(rawPluginID)
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
plugin, err := pluginsManager.Plugin(r.Context(), pluginID)
|
||||
if err != nil {
|
||||
writePluginManagerError(w, err)
|
||||
return
|
||||
}
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"plugin": plugin})
|
||||
case http.MethodPut:
|
||||
if session.Role != adminRoleAdmin {
|
||||
adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
var req adminhttp.PluginDesiredRequest
|
||||
if !adminhttp.DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
configJSON, err := pluginConfigJSON(req)
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
plugin, err := pluginsManager.SetDesired(r.Context(), session.Username, pluginID, req.ArtifactID, req.DesiredState, configJSON, req.Priority)
|
||||
if err != nil {
|
||||
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_desired_update", "plugin", pluginID, false, err.Error())
|
||||
writePluginManagerError(w, err)
|
||||
return
|
||||
}
|
||||
recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_desired_update", "plugin", pluginID, true, "desired state updated", map[string]any{
|
||||
"artifact_id": req.ArtifactID,
|
||||
"desired_state": plugin.DesiredState,
|
||||
"desired_generation": plugin.DesiredGeneration,
|
||||
"priority": plugin.Priority,
|
||||
})
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"plugin": plugin})
|
||||
default:
|
||||
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func handleAdminPluginAction(w http.ResponseWriter, r *http.Request, rawSegment string) {
|
||||
session, ok := requireRole(w, r, adminRoleAdmin)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if pluginsManager == nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusServiceUnavailable, "plugin manager is not initialized")
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
parts := strings.Split(rawSegment, "/")
|
||||
if len(parts) != 2 {
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, "invalid plugin action")
|
||||
return
|
||||
}
|
||||
pluginID, err := adminhttp.PathSegment(parts[0])
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
action, err := adminhttp.PathSegment(parts[1])
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var plugin pluginmanager.PluginRecord
|
||||
switch action {
|
||||
case "load":
|
||||
plugin, err = pluginsManager.Load(r.Context(), session.Username, pluginID)
|
||||
case "enable":
|
||||
plugin, err = pluginsManager.Enable(r.Context(), session.Username, pluginID)
|
||||
case "disable":
|
||||
plugin, err = pluginsManager.Disable(r.Context(), session.Username, pluginID)
|
||||
case "delete":
|
||||
err = pluginsManager.Delete(r.Context(), session.Username, pluginID)
|
||||
default:
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, "unknown plugin action")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_"+action, "plugin", pluginID, false, err.Error())
|
||||
writePluginManagerError(w, err)
|
||||
return
|
||||
}
|
||||
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_"+action, "plugin", pluginID, true, "plugin "+action+" succeeded")
|
||||
if action == "delete" {
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"plugin": plugin})
|
||||
}
|
||||
|
||||
func handleAdminPluginDispatchPlan(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireRole(w, r, adminRoleMember); !ok {
|
||||
return
|
||||
}
|
||||
if pluginsManager == nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusServiceUnavailable, "plugin manager is not initialized")
|
||||
return
|
||||
}
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"dispatch_plan": pluginsManager.DispatchPlan(r.Context())})
|
||||
}
|
||||
|
||||
func receivePluginArtifact(r *http.Request, actor string) (pluginmanager.ArtifactRecord, error) {
|
||||
if err := r.ParseMultipartForm(64 << 20); err != nil {
|
||||
return pluginmanager.ArtifactRecord{}, err
|
||||
}
|
||||
file, header, err := r.FormFile("artifact")
|
||||
if err != nil {
|
||||
return pluginmanager.ArtifactRecord{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
tmp, err := os.CreateTemp("", "mc-gateway-plugin-*.mcgp")
|
||||
if err != nil {
|
||||
return pluginmanager.ArtifactRecord{}, err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
defer tmp.Close()
|
||||
|
||||
if _, err := tmp.ReadFrom(file); err != nil {
|
||||
return pluginmanager.ArtifactRecord{}, err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return pluginmanager.ArtifactRecord{}, err
|
||||
}
|
||||
|
||||
return pluginsManager.UploadArtifact(r.Context(), pluginmanager.ArtifactUpload{
|
||||
SourcePath: tmpPath,
|
||||
FileName: filepath.Base(header.Filename),
|
||||
Actor: actor,
|
||||
})
|
||||
}
|
||||
|
||||
func pluginConfigJSON(req adminhttp.PluginDesiredRequest) (string, error) {
|
||||
if strings.TrimSpace(req.ConfigJSON) != "" {
|
||||
if !json.Valid([]byte(req.ConfigJSON)) {
|
||||
return "", errors.New("config_json must be valid JSON")
|
||||
}
|
||||
return req.ConfigJSON, nil
|
||||
}
|
||||
if req.Config == nil {
|
||||
return "{}", nil
|
||||
}
|
||||
data, err := json.Marshal(req.Config)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func writePluginManagerError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, pluginmanager.ErrArtifactNotFound), errors.Is(err, pluginmanager.ErrPluginNotFound):
|
||||
adminhttp.WriteAPIError(w, http.StatusNotFound, err.Error())
|
||||
default:
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/adminconfig"
|
||||
"github.com/tursom/mc-gateway/internal/admindb"
|
||||
"github.com/tursom/mc-gateway/internal/adminservice"
|
||||
"github.com/tursom/mc-gateway/internal/pluginmanager"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -44,6 +46,7 @@ var (
|
||||
|
||||
adminDB *sql.DB
|
||||
adminDBPath string
|
||||
pluginsManager *pluginmanager.Manager
|
||||
processStartAt = time.Now()
|
||||
)
|
||||
|
||||
@@ -77,7 +80,17 @@ func initializeGatewayRuntime() error {
|
||||
if err := ensureInitialAdminFromEnv(context.Background(), db, os.Getenv(adminEnvInitialPassword)); err != nil {
|
||||
return err
|
||||
}
|
||||
return refreshRouteSnapshot(context.Background())
|
||||
if err := refreshRouteSnapshot(context.Background()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pluginsManager = pluginmanager.New(pluginmanager.Options{
|
||||
DB: db,
|
||||
ArtifactRoot: filepath.Join(filepath.Dir(startup.DBPath), "plugins", "artifacts"),
|
||||
HandleConn: handleRequest,
|
||||
WaitGroup: &exitWaitGroup,
|
||||
})
|
||||
return pluginsManager.Reconcile(context.Background())
|
||||
}
|
||||
|
||||
func closeGatewayRuntime() {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -11,6 +13,10 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
if handled, code := runPluginCLI(os.Args[1:]); handled {
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
if err := loadConfig(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -127,17 +133,39 @@ func mapToHost(conn net.Conn) net.Conn {
|
||||
|
||||
var client net.Conn
|
||||
|
||||
ok, err = invokeFirstHookHandler(api.HookUpstream, Handler2[net.Conn, string, bool](conn, host), func(handler func(net.Conn, string) (net.Conn, error)) error {
|
||||
var err error
|
||||
client, err = handler(conn, host)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to invoke upstream hook")
|
||||
return nil
|
||||
if pluginsManager != nil {
|
||||
result, err := pluginsManager.ConnectUpstream(context.Background(), api.UpstreamConnectRequest{
|
||||
Source: conn,
|
||||
Host: mcHost,
|
||||
Upstream: host,
|
||||
InitialData: append([]byte(nil), buf[:n]...),
|
||||
})
|
||||
if err != nil {
|
||||
log.Err(err).
|
||||
Str("client", conn.RemoteAddr().String()).
|
||||
Str("host", mcHost).
|
||||
Str("mc", host).
|
||||
Msg("failed to invoke managed upstream plugin")
|
||||
return nil
|
||||
}
|
||||
if result.Handled {
|
||||
client = result.Conn
|
||||
}
|
||||
}
|
||||
|
||||
if !ok {
|
||||
if client == nil {
|
||||
ok, err = invokeFirstHookHandler(api.HookUpstream, Handler2[net.Conn, string, bool](conn, host), func(handler func(net.Conn, string) (net.Conn, error)) error {
|
||||
var err error
|
||||
client, err = handler(conn, host)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to invoke upstream hook")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if client == nil {
|
||||
target := upstreamtarget.Parse(host)
|
||||
switch target.Protocol {
|
||||
case upstreamtarget.ProtocolQUIC:
|
||||
|
||||
@@ -1,12 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/admindb"
|
||||
"github.com/tursom/mc-gateway/internal/pluginmanager"
|
||||
"github.com/tursom/mc-gateway/plugin/api"
|
||||
)
|
||||
|
||||
func TestMapToHostUsesManagedPluginBeforeLegacyHook(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
packet := gatewayTestPacket("play.example")
|
||||
source := newGatewayTestConn(packet)
|
||||
managedUpstream := newGatewayTestConn(nil)
|
||||
legacyUpstream := newGatewayTestConn(nil)
|
||||
setGatewayTestRoutes(map[string]string{
|
||||
"play.example": "backend.example:25565",
|
||||
})
|
||||
pluginsManager = pluginmanager.New(pluginmanager.Options{
|
||||
DB: newGatewayTestPluginDB(t),
|
||||
ArtifactRoot: t.TempDir(),
|
||||
Adapter: gatewayTestPluginAdapter{handler: func(req api.UpstreamConnectRequest) (net.Conn, error) {
|
||||
if req.Host != "play.example" || req.Upstream != "backend.example:25565" || !bytes.Equal(req.InitialData, packet) {
|
||||
t.Fatalf("managed request = %+v, initial=%v", req, req.InitialData)
|
||||
}
|
||||
return managedUpstream, nil
|
||||
}},
|
||||
})
|
||||
artifact := uploadGatewayTestArtifact(t, pluginsManager, "managed-upstream")
|
||||
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "managed-upstream", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
if _, err := pluginsManager.Enable(context.Background(), "admin", "managed-upstream"); err != nil {
|
||||
t.Fatalf("Enable() error = %v", err)
|
||||
}
|
||||
registerGatewayUpstreamHook(
|
||||
t,
|
||||
func(net.Conn, string) bool { return true },
|
||||
func(net.Conn, string) (net.Conn, error) { return legacyUpstream, nil },
|
||||
)
|
||||
|
||||
got := mapToHost(source)
|
||||
if got != managedUpstream {
|
||||
t.Fatalf("mapToHost() = %v, want managed upstream", got)
|
||||
}
|
||||
if !bytes.Equal(managedUpstream.writeBuf.Bytes(), packet) {
|
||||
t.Fatalf("managed upstream initial packet = %v, want %v", managedUpstream.writeBuf.Bytes(), packet)
|
||||
}
|
||||
if legacyUpstream.writeBuf.Len() != 0 {
|
||||
t.Fatalf("legacy upstream was used: %v", legacyUpstream.writeBuf.Bytes())
|
||||
}
|
||||
|
||||
if _, err := pluginsManager.Disable(context.Background(), "admin", "managed-upstream"); err != nil {
|
||||
t.Fatalf("Disable() error = %v", err)
|
||||
}
|
||||
nextSource := newGatewayTestConn(packet)
|
||||
if got := mapToHost(nextSource); got != legacyUpstream {
|
||||
t.Fatalf("mapToHost() after disable = %v, want legacy upstream", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapToHostRoutesThroughHookAndForwardsInitialPacket(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
@@ -161,3 +225,110 @@ func TestMapToHostClosesUpstreamWhenInitialWriteFails(t *testing.T) {
|
||||
t.Fatal("upstream was not closed after write failure")
|
||||
}
|
||||
}
|
||||
|
||||
type gatewayTestPluginAdapter struct {
|
||||
handler api.UpstreamConnectHandler
|
||||
}
|
||||
|
||||
func (a gatewayTestPluginAdapter) Load(_ context.Context, _ pluginmanager.ArtifactRecord, _ pluginmanager.PluginRecord, gateway *pluginmanager.Gateway) (api.Plugin, error) {
|
||||
handler := a.handler
|
||||
if handler == nil {
|
||||
handler = func(api.UpstreamConnectRequest) (net.Conn, error) {
|
||||
return nil, api.ErrPass
|
||||
}
|
||||
}
|
||||
if err := api.RegisterHookHandler(
|
||||
gateway,
|
||||
api.HookUpstreamConnect,
|
||||
func(api.UpstreamConnectRequest) bool { return true },
|
||||
handler,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &gatewayPluginStub{}, nil
|
||||
}
|
||||
|
||||
func newGatewayTestPluginDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := admindb.Open(filepath.Join(t.TempDir(), "gateway.sqlite3"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := admindb.Migrate(db); err != nil {
|
||||
t.Fatalf("Migrate() error = %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func uploadGatewayTestArtifact(t *testing.T, manager *pluginmanager.Manager, pluginID string) pluginmanager.ArtifactRecord {
|
||||
t.Helper()
|
||||
artifact, err := manager.UploadArtifact(context.Background(), pluginmanager.ArtifactUpload{
|
||||
SourcePath: writeGatewayTestMCGP(t, pluginID),
|
||||
FileName: pluginID + ".mcgp",
|
||||
Actor: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UploadArtifact() error = %v", err)
|
||||
}
|
||||
return artifact
|
||||
}
|
||||
|
||||
func writeGatewayTestMCGP(t *testing.T, pluginID string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "plugin.mcgp")
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Create zip error = %v", err)
|
||||
}
|
||||
writer := zip.NewWriter(file)
|
||||
entries := map[string][]byte{
|
||||
"manifest.json": gatewayTestManifest(t, pluginID),
|
||||
"plugin.so": []byte("fake plugin bytes " + pluginID),
|
||||
}
|
||||
for name, data := range entries {
|
||||
entry, err := writer.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Create entry error = %v", err)
|
||||
}
|
||||
if _, err := entry.Write(data); err != nil {
|
||||
t.Fatalf("Write entry error = %v", err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("Close zip writer error = %v", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatalf("Close zip file error = %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func gatewayTestManifest(t *testing.T, pluginID string) []byte {
|
||||
t.Helper()
|
||||
manifest := pluginmanager.Manifest{
|
||||
SchemaVersion: pluginmanager.SchemaVersion,
|
||||
ID: pluginID,
|
||||
Name: "Managed Upstream",
|
||||
Version: "0.1.0",
|
||||
ArtifactType: pluginmanager.ArtifactTypeBinary,
|
||||
Runtime: pluginmanager.RuntimeManifest{
|
||||
Type: pluginmanager.RuntimeGoPlugin,
|
||||
Entry: pluginmanager.RuntimeEntry,
|
||||
EntrySymbol: "Plugin",
|
||||
},
|
||||
APIVersion: pluginmanager.APIVersion,
|
||||
GoVersion: runtime.Version(),
|
||||
GOOS: runtime.GOOS,
|
||||
GOARCH: runtime.GOARCH,
|
||||
ExtensionPoints: []pluginmanager.ExtensionPoint{{
|
||||
Type: "hook",
|
||||
Key: pluginmanager.ExtensionUpstreamConnect,
|
||||
}},
|
||||
}
|
||||
data, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal manifest error = %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
94
cmd/gateway/plugin_cli.go
Normal file
94
cmd/gateway/plugin_cli.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/pluginmanager"
|
||||
)
|
||||
|
||||
func runPluginCLI(args []string) (bool, int) {
|
||||
if len(args) < 2 || args[0] != "plugin" {
|
||||
return false, 0
|
||||
}
|
||||
if len(args) < 3 {
|
||||
fmt.Fprintln(os.Stderr, "usage: gateway plugin inspect|validate|compat <artifact.mcgp>")
|
||||
return true, 2
|
||||
}
|
||||
|
||||
command, packagePath := args[1], args[2]
|
||||
switch command {
|
||||
case "inspect":
|
||||
manifest, err := readPackageManifest(packagePath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(manifest); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "validate", "compat":
|
||||
tmpRoot, err := os.MkdirTemp("", "mcgp-cli-*")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
defer os.RemoveAll(tmpRoot)
|
||||
store := pluginmanager.NewArtifactStore(tmpRoot)
|
||||
artifact, err := store.ValidateAndStore(pluginmanager.ArtifactUpload{
|
||||
SourcePath: packagePath,
|
||||
FileName: filepath.Base(packagePath),
|
||||
Actor: "cli",
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "ok plugin=%s version=%s sha256=%s api=%s go=%s %s/%s\n",
|
||||
artifact.PluginID, artifact.Version, artifact.SHA256, artifact.APIVersion, artifact.GoVersion, artifact.GOOS, artifact.GOARCH)
|
||||
return true, 0
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown plugin command %q\n", command)
|
||||
return true, 2
|
||||
}
|
||||
}
|
||||
|
||||
func readPackageManifest(packagePath string) (pluginmanager.Manifest, error) {
|
||||
reader, err := zip.OpenReader(packagePath)
|
||||
if err != nil {
|
||||
return pluginmanager.Manifest{}, err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
for _, file := range reader.File {
|
||||
if file.Name != "manifest.json" {
|
||||
continue
|
||||
}
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
return pluginmanager.Manifest{}, err
|
||||
}
|
||||
defer rc.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(rc, pluginmanager.DefaultManifestMaxBytes+1))
|
||||
if err != nil {
|
||||
return pluginmanager.Manifest{}, err
|
||||
}
|
||||
if len(data) > pluginmanager.DefaultManifestMaxBytes {
|
||||
return pluginmanager.Manifest{}, fmt.Errorf("manifest.json exceeds %d bytes", pluginmanager.DefaultManifestMaxBytes)
|
||||
}
|
||||
var manifest pluginmanager.Manifest
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return pluginmanager.Manifest{}, err
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
return pluginmanager.Manifest{}, fmt.Errorf("manifest.json is required")
|
||||
}
|
||||
@@ -28,6 +28,7 @@ func saveGatewayState(t *testing.T) func() {
|
||||
oldAdminStartup := adminStartup
|
||||
oldAdminDB := adminDB
|
||||
oldAdminDBPath := adminDBPath
|
||||
oldPluginsManager := pluginsManager
|
||||
oldAdminSessionManager := adminSessionManager
|
||||
oldRouteSnapshot := routeSnapshot.Clone()
|
||||
oldGatewayMetrics := gatewayMetrics
|
||||
@@ -51,6 +52,7 @@ func saveGatewayState(t *testing.T) func() {
|
||||
}
|
||||
adminDB = nil
|
||||
adminDBPath = ""
|
||||
pluginsManager = nil
|
||||
adminSessionManager = adminsession.NewManager()
|
||||
publishRouteSnapshot(nil)
|
||||
gatewayMetrics = gatewaymetrics.New()
|
||||
@@ -70,6 +72,7 @@ func saveGatewayState(t *testing.T) func() {
|
||||
adminStartup = oldAdminStartup
|
||||
adminDB = oldAdminDB
|
||||
adminDBPath = oldAdminDBPath
|
||||
pluginsManager = oldPluginsManager
|
||||
adminSessionManager = oldAdminSessionManager
|
||||
publishRouteSnapshot(oldRouteSnapshot)
|
||||
gatewayMetrics = oldGatewayMetrics
|
||||
|
||||
23
examples/plugins/upstream-rewrite/README.md
Normal file
23
examples/plugins/upstream-rewrite/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Upstream Rewrite Plugin
|
||||
|
||||
This example registers `upstream.connect/v1` in dialer mode. When `match_host`
|
||||
matches either the Minecraft hostname or the resolved upstream string, it dials
|
||||
the configured `upstream` and returns that connection. Non-matching connections
|
||||
return `api.ErrPass`.
|
||||
|
||||
Build and package:
|
||||
|
||||
```sh
|
||||
./build.sh
|
||||
```
|
||||
|
||||
The package is written to `dist/upstream-rewrite.mcgp`.
|
||||
|
||||
Example config JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"match_host": "play.example",
|
||||
"upstream": "127.0.0.1:25566"
|
||||
}
|
||||
```
|
||||
12
examples/plugins/upstream-rewrite/build.sh
Executable file
12
examples/plugins/upstream-rewrite/build.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/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
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func main() {
|
||||
manifest := map[string]any{
|
||||
"schema_version": "mc-gateway.plugin/v1",
|
||||
"id": "upstream-rewrite",
|
||||
"name": "Upstream Rewrite",
|
||||
"version": "0.1.0",
|
||||
"description": "Rewrite selected upstream targets before dialing.",
|
||||
"artifact_type": "binary",
|
||||
"runtime": map[string]any{
|
||||
"type": "go-plugin",
|
||||
"entry": "plugin.so",
|
||||
"entry_symbol": "Plugin",
|
||||
"metadata_symbol": "MCGatewayPluginMetadata",
|
||||
},
|
||||
"api_version": "plugin-api/v1",
|
||||
"sdk_module": "github.com/tursom/mc-gateway/plugin/api",
|
||||
"sdk_module_version": "v0.1.0",
|
||||
"go_version": runtime.Version(),
|
||||
"go_os": runtime.GOOS,
|
||||
"go_arch": runtime.GOARCH,
|
||||
"extension_points": []map[string]any{
|
||||
{"type": "hook", "key": "upstream.connect/v1"},
|
||||
},
|
||||
"capabilities": map[string]any{
|
||||
"extension_points": []string{"upstream.connect/v1"},
|
||||
"network": map[string]any{"outbound": []string{"tcp:*:*"}},
|
||||
"filesystem": map[string]any{"read": []string{}, "write": []string{}},
|
||||
"env": []string{},
|
||||
},
|
||||
"runtime_limits": map[string]any{
|
||||
"handler_timeout_ms": 3000,
|
||||
},
|
||||
"config_schema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"match_host": map[string]any{"type": "string"},
|
||||
"upstream": map[string]any{"type": "string"},
|
||||
},
|
||||
"required": []string{"upstream"},
|
||||
},
|
||||
}
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(manifest); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
7
examples/plugins/upstream-rewrite/go.mod
Normal file
7
examples/plugins/upstream-rewrite/go.mod
Normal file
@@ -0,0 +1,7 @@
|
||||
module github.com/tursom/mc-gateway/examples/plugins/upstream-rewrite
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require github.com/tursom/mc-gateway v0.0.0
|
||||
|
||||
replace github.com/tursom/mc-gateway => ../../..
|
||||
99
examples/plugins/upstream-rewrite/main.go
Normal file
99
examples/plugins/upstream-rewrite/main.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"runtime"
|
||||
|
||||
"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 MCGatewayPluginMetadata() string {
|
||||
return manifestJSON
|
||||
}
|
||||
|
||||
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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
var manifestJSON = compactJSON(map[string]any{
|
||||
"schema_version": "mc-gateway.plugin/v1",
|
||||
"id": "upstream-rewrite",
|
||||
"name": "Upstream Rewrite",
|
||||
"version": "0.1.0",
|
||||
"description": "Rewrite selected upstream targets before dialing.",
|
||||
"artifact_type": "binary",
|
||||
"runtime": map[string]any{
|
||||
"type": "go-plugin",
|
||||
"entry": "plugin.so",
|
||||
"entry_symbol": "Plugin",
|
||||
"metadata_symbol": "MCGatewayPluginMetadata",
|
||||
},
|
||||
"api_version": "plugin-api/v1",
|
||||
"sdk_module": "github.com/tursom/mc-gateway/plugin/api",
|
||||
"go_version": runtime.Version(),
|
||||
"go_os": runtime.GOOS,
|
||||
"go_arch": runtime.GOARCH,
|
||||
"extension_points": []map[string]any{
|
||||
{"type": "hook", "key": "upstream.connect/v1"},
|
||||
},
|
||||
"capabilities": map[string]any{
|
||||
"extension_points": []string{"upstream.connect/v1"},
|
||||
"network": map[string]any{"outbound": []string{"tcp:*:*"}},
|
||||
},
|
||||
"runtime_limits": map[string]any{
|
||||
"handler_timeout_ms": 3000,
|
||||
},
|
||||
"config_schema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"match_host": map[string]any{"type": "string"},
|
||||
"upstream": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
func compactJSON(value any) string {
|
||||
data, _ := json.Marshal(value)
|
||||
return string(data)
|
||||
}
|
||||
40
examples/plugins/upstream-rewrite/manifest.json
Normal file
40
examples/plugins/upstream-rewrite/manifest.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"schema_version": "mc-gateway.plugin/v1",
|
||||
"id": "upstream-rewrite",
|
||||
"name": "Upstream Rewrite",
|
||||
"version": "0.1.0",
|
||||
"description": "Rewrite selected upstream targets before dialing.",
|
||||
"artifact_type": "binary",
|
||||
"runtime": {
|
||||
"type": "go-plugin",
|
||||
"entry": "plugin.so",
|
||||
"entry_symbol": "Plugin",
|
||||
"metadata_symbol": "MCGatewayPluginMetadata"
|
||||
},
|
||||
"api_version": "plugin-api/v1",
|
||||
"sdk_module": "github.com/tursom/mc-gateway/plugin/api",
|
||||
"sdk_module_version": "v0.1.0",
|
||||
"go_version": "go1.24.4",
|
||||
"go_os": "linux",
|
||||
"go_arch": "amd64",
|
||||
"extension_points": [
|
||||
{ "type": "hook", "key": "upstream.connect/v1" }
|
||||
],
|
||||
"capabilities": {
|
||||
"extension_points": ["upstream.connect/v1"],
|
||||
"network": { "outbound": ["tcp:*:*"] },
|
||||
"filesystem": { "read": [], "write": [] },
|
||||
"env": []
|
||||
},
|
||||
"runtime_limits": {
|
||||
"handler_timeout_ms": 3000
|
||||
},
|
||||
"config_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"match_host": { "type": "string" },
|
||||
"upstream": { "type": "string" }
|
||||
},
|
||||
"required": ["upstream"]
|
||||
}
|
||||
}
|
||||
@@ -3,21 +3,23 @@ package adminaudit
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
const DefaultListLimit = 200
|
||||
|
||||
type Record struct {
|
||||
ID int64 `json:"id"`
|
||||
Actor string `json:"actor"`
|
||||
SourceIP string `json:"source_ip"`
|
||||
Action string `json:"action"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID string `json:"target_id"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
ID int64 `json:"id"`
|
||||
Actor string `json:"actor"`
|
||||
SourceIP string `json:"source_ip"`
|
||||
Action string `json:"action"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID string `json:"target_id"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
MetadataJSON string `json:"metadata_json"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type Repository struct {
|
||||
@@ -41,13 +43,25 @@ func NewRepositoryWithClock(db *sql.DB, now func() time.Time) Repository {
|
||||
}
|
||||
|
||||
func (r Repository) Record(ctx context.Context, actor, sourceIP, action, targetType, targetID string, success bool, message string) error {
|
||||
return r.RecordWithMetadata(ctx, actor, sourceIP, action, targetType, targetID, success, message, nil)
|
||||
}
|
||||
|
||||
func (r Repository) RecordWithMetadata(ctx context.Context, actor, sourceIP, action, targetType, targetID string, success bool, message string, metadata any) error {
|
||||
if r.db == nil {
|
||||
return nil
|
||||
}
|
||||
metadataJSON := "{}"
|
||||
if metadata != nil {
|
||||
data, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
metadataJSON = string(data)
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO audit_logs(actor, source_ip, action, target_type, target_id, success, message, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
actor, sourceIP, action, targetType, targetID, boolToInt(success), message, r.now().Unix())
|
||||
INSERT INTO audit_logs(actor, source_ip, action, target_type, target_id, success, message, metadata_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
actor, sourceIP, action, targetType, targetID, boolToInt(success), message, metadataJSON, r.now().Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -56,7 +70,7 @@ func (r Repository) List(ctx context.Context, limit int) ([]Record, error) {
|
||||
limit = DefaultListLimit
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, actor, source_ip, action, target_type, target_id, success, message, created_at
|
||||
SELECT id, actor, source_ip, action, target_type, target_id, success, message, metadata_json, created_at
|
||||
FROM audit_logs
|
||||
ORDER BY id DESC
|
||||
LIMIT ?`, limit)
|
||||
@@ -69,7 +83,7 @@ LIMIT ?`, limit)
|
||||
for rows.Next() {
|
||||
var item Record
|
||||
var success int
|
||||
if err := rows.Scan(&item.ID, &item.Actor, &item.SourceIP, &item.Action, &item.TargetType, &item.TargetID, &success, &item.Message, &item.CreatedAt); err != nil {
|
||||
if err := rows.Scan(&item.ID, &item.Actor, &item.SourceIP, &item.Action, &item.TargetType, &item.TargetID, &success, &item.Message, &item.MetadataJSON, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Success = success != 0
|
||||
|
||||
@@ -81,13 +81,119 @@ CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
target_id TEXT NOT NULL,
|
||||
success INTEGER NOT NULL,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_artifacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
plugin_id TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL UNIQUE,
|
||||
package_sha256 TEXT NOT NULL DEFAULT '',
|
||||
size_bytes INTEGER NOT NULL,
|
||||
artifact_type TEXT NOT NULL DEFAULT 'binary',
|
||||
runtime_type TEXT NOT NULL DEFAULT '',
|
||||
runtime_entry TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'uploaded',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
capabilities_summary_json TEXT NOT NULL DEFAULT '{}',
|
||||
extension_points_json TEXT NOT NULL DEFAULT '[]',
|
||||
api_version TEXT NOT NULL DEFAULT '',
|
||||
go_version TEXT NOT NULL DEFAULT '',
|
||||
go_os TEXT NOT NULL DEFAULT '',
|
||||
go_arch TEXT NOT NULL DEFAULT '',
|
||||
uploaded_by TEXT NOT NULL DEFAULT '',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugins (
|
||||
id TEXT PRIMARY KEY,
|
||||
desired_artifact_id TEXT NOT NULL DEFAULT '',
|
||||
active_artifact_id TEXT NOT NULL DEFAULT '',
|
||||
loaded_artifact_id TEXT NOT NULL DEFAULT '',
|
||||
desired_state TEXT NOT NULL DEFAULT 'disabled',
|
||||
runtime_state TEXT NOT NULL DEFAULT 'not_loaded',
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
desired_generation INTEGER NOT NULL DEFAULT 1,
|
||||
applied_generation INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
runtime_summary_json TEXT NOT NULL DEFAULT '{}',
|
||||
dispatch_summary_json TEXT NOT NULL DEFAULT '{}',
|
||||
deleted_at INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
updated_by TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY (desired_artifact_id) REFERENCES plugin_artifacts(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_operations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id TEXT NOT NULL DEFAULT '',
|
||||
artifact_id TEXT NOT NULL DEFAULT '',
|
||||
operation TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
actor TEXT NOT NULL DEFAULT '',
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_config_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id TEXT NOT NULL,
|
||||
artifact_id TEXT NOT NULL DEFAULT '',
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
desired_state TEXT NOT NULL DEFAULT 'disabled',
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
desired_generation INTEGER NOT NULL,
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_routes_enabled ON routes(enabled);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_artifacts_plugin_id ON plugin_artifacts(plugin_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugins_desired_state ON plugins(desired_state, priority);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_operations_plugin_id ON plugin_operations(plugin_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_config_snapshots_plugin_id ON plugin_config_snapshots(plugin_id, created_at);
|
||||
INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (1, strftime('%s','now'));
|
||||
`
|
||||
_, err := db.Exec(schema)
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureColumn(db, "audit_logs", "metadata_json", "TEXT NOT NULL DEFAULT '{}'")
|
||||
}
|
||||
|
||||
func ensureColumn(db *sql.DB, table, column, definition string) error {
|
||||
rows, err := db.Query(`PRAGMA table_info(` + table + `)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, typ string
|
||||
var notNull int
|
||||
var defaultValue any
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬Null, &defaultValue, &pk); err != nil {
|
||||
return err
|
||||
}
|
||||
if name == column {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = db.Exec(`ALTER TABLE ` + table + ` ADD COLUMN ` + column + ` ` + definition)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -28,6 +28,13 @@ type APIHandlers struct {
|
||||
UserItem SegmentHandlerFunc
|
||||
|
||||
AuditLogs http.HandlerFunc
|
||||
|
||||
PluginArtifacts http.HandlerFunc
|
||||
PluginArtifact SegmentHandlerFunc
|
||||
PluginsList http.HandlerFunc
|
||||
PluginItem SegmentHandlerFunc
|
||||
PluginAction SegmentHandlerFunc
|
||||
PluginDispatch http.HandlerFunc
|
||||
}
|
||||
|
||||
func NewAPIHandler(prefix string, handlers APIHandlers) http.HandlerFunc {
|
||||
@@ -69,6 +76,21 @@ func NewAPIHandler(prefix string, handlers APIHandlers) http.HandlerFunc {
|
||||
callSegmentHandler(w, r, handlers.UserItem, strings.TrimPrefix(path, "/users/"))
|
||||
case path == "/audit-logs" && r.Method == http.MethodGet:
|
||||
callHandler(w, r, handlers.AuditLogs)
|
||||
case path == "/plugin-artifacts" && (r.Method == http.MethodGet || r.Method == http.MethodPost):
|
||||
callHandler(w, r, handlers.PluginArtifacts)
|
||||
case strings.HasPrefix(path, "/plugin-artifacts/"):
|
||||
callSegmentHandler(w, r, handlers.PluginArtifact, strings.TrimPrefix(path, "/plugin-artifacts/"))
|
||||
case path == "/plugins" && r.Method == http.MethodGet:
|
||||
callHandler(w, r, handlers.PluginsList)
|
||||
case path == "/plugins/dispatch-plan" && r.Method == http.MethodGet:
|
||||
callHandler(w, r, handlers.PluginDispatch)
|
||||
case strings.HasPrefix(path, "/plugins/"):
|
||||
pluginPath := strings.TrimPrefix(path, "/plugins/")
|
||||
if strings.Count(pluginPath, "/") == 1 {
|
||||
callSegmentHandler(w, r, handlers.PluginAction, pluginPath)
|
||||
return
|
||||
}
|
||||
callSegmentHandler(w, r, handlers.PluginItem, pluginPath)
|
||||
default:
|
||||
WriteAPIError(w, http.StatusNotFound, "not found")
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ func TestNewAPIHandlerRoutesRequests(t *testing.T) {
|
||||
{name: "users create", method: http.MethodPost, path: "/admin/api/users", wantCall: "users_create"},
|
||||
{name: "user item", method: http.MethodPatch, path: "/admin/api/users/member", wantCall: "user_item", wantSegment: "member"},
|
||||
{name: "audit logs", method: http.MethodGet, path: "/admin/api/audit-logs", wantCall: "audit_logs"},
|
||||
{name: "plugin artifacts", method: http.MethodGet, path: "/admin/api/plugin-artifacts", wantCall: "plugin_artifacts"},
|
||||
{name: "plugin artifact", method: http.MethodGet, path: "/admin/api/plugin-artifacts/abc", wantCall: "plugin_artifact", wantSegment: "abc"},
|
||||
{name: "plugins list", method: http.MethodGet, path: "/admin/api/plugins", wantCall: "plugins_list"},
|
||||
{name: "plugin item", method: http.MethodPut, path: "/admin/api/plugins/upstream-rewrite", wantCall: "plugin_item", wantSegment: "upstream-rewrite"},
|
||||
{name: "plugin action", method: http.MethodPost, path: "/admin/api/plugins/upstream-rewrite/enable", wantCall: "plugin_action", wantSegment: "upstream-rewrite/enable"},
|
||||
{name: "plugin dispatch", method: http.MethodGet, path: "/admin/api/plugins/dispatch-plan", wantCall: "plugin_dispatch"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -56,6 +62,13 @@ func TestNewAPIHandlerRoutesRequests(t *testing.T) {
|
||||
UserItem: recordSegmentCall(&gotCall, &gotSegment, "user_item"),
|
||||
|
||||
AuditLogs: recordCall(&gotCall, "audit_logs"),
|
||||
|
||||
PluginArtifacts: recordCall(&gotCall, "plugin_artifacts"),
|
||||
PluginArtifact: recordSegmentCall(&gotCall, &gotSegment, "plugin_artifact"),
|
||||
PluginsList: recordCall(&gotCall, "plugins_list"),
|
||||
PluginItem: recordSegmentCall(&gotCall, &gotSegment, "plugin_item"),
|
||||
PluginAction: recordSegmentCall(&gotCall, &gotSegment, "plugin_action"),
|
||||
PluginDispatch: recordCall(&gotCall, "plugin_dispatch"),
|
||||
})
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
@@ -34,3 +34,11 @@ type PatchUserRequest struct {
|
||||
Password *string `json:"password"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
}
|
||||
|
||||
type PluginDesiredRequest struct {
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
DesiredState string `json:"desired_state"`
|
||||
Priority int `json:"priority"`
|
||||
Config map[string]any `json:"config"`
|
||||
ConfigJSON string `json:"config_json"`
|
||||
}
|
||||
|
||||
311
internal/pluginmanager/artifact.go
Normal file
311
internal/pluginmanager/artifact.go
Normal file
@@ -0,0 +1,311 @@
|
||||
package pluginmanager
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var pluginIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,127}$`)
|
||||
|
||||
type ArtifactStore struct {
|
||||
Root string
|
||||
MaxPackageBytes int64
|
||||
MaxManifestBytes int64
|
||||
MaxEntries int
|
||||
MaxExtractedBytes int64
|
||||
MaxNonRuntimeBytes int64
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type ArtifactUpload struct {
|
||||
SourcePath string
|
||||
FileName string
|
||||
Actor string
|
||||
}
|
||||
|
||||
func NewArtifactStore(root string) ArtifactStore {
|
||||
return ArtifactStore{
|
||||
Root: root,
|
||||
MaxPackageBytes: DefaultPackageMaxBytes,
|
||||
MaxManifestBytes: DefaultManifestMaxBytes,
|
||||
MaxEntries: DefaultPackageMaxEntries,
|
||||
MaxExtractedBytes: DefaultExtractedMaxBytes,
|
||||
MaxNonRuntimeBytes: DefaultNonRuntimeMaxBytes,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
func (s ArtifactStore) ValidateAndStore(upload ArtifactUpload) (ArtifactRecord, error) {
|
||||
if s.Root == "" {
|
||||
return ArtifactRecord{}, errors.New("plugin artifact root is empty")
|
||||
}
|
||||
if s.MaxPackageBytes <= 0 {
|
||||
s.MaxPackageBytes = DefaultPackageMaxBytes
|
||||
}
|
||||
if s.MaxManifestBytes <= 0 {
|
||||
s.MaxManifestBytes = DefaultManifestMaxBytes
|
||||
}
|
||||
if s.MaxEntries <= 0 {
|
||||
s.MaxEntries = DefaultPackageMaxEntries
|
||||
}
|
||||
if s.MaxExtractedBytes <= 0 {
|
||||
s.MaxExtractedBytes = DefaultExtractedMaxBytes
|
||||
}
|
||||
if s.MaxNonRuntimeBytes <= 0 {
|
||||
s.MaxNonRuntimeBytes = DefaultNonRuntimeMaxBytes
|
||||
}
|
||||
if s.now == nil {
|
||||
s.now = time.Now
|
||||
}
|
||||
|
||||
info, err := os.Stat(upload.SourcePath)
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
if info.Size() <= 0 {
|
||||
return ArtifactRecord{}, errors.New("plugin package is empty")
|
||||
}
|
||||
if info.Size() > s.MaxPackageBytes {
|
||||
return ArtifactRecord{}, fmt.Errorf("plugin package size %d exceeds limit %d", info.Size(), s.MaxPackageBytes)
|
||||
}
|
||||
|
||||
packageSHA, err := fileSHA256(upload.SourcePath)
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
|
||||
reader, err := zip.OpenReader(upload.SourcePath)
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
defer reader.Close()
|
||||
if len(reader.File) > s.MaxEntries {
|
||||
return ArtifactRecord{}, fmt.Errorf("plugin package has %d entries, exceeds limit %d", len(reader.File), s.MaxEntries)
|
||||
}
|
||||
|
||||
var manifestFile *zip.File
|
||||
entries := make(map[string]*zip.File)
|
||||
var extractedSize uint64
|
||||
for _, file := range reader.File {
|
||||
clean, err := cleanZipName(file.Name)
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
if file.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
mode := file.FileInfo().Mode()
|
||||
if !mode.IsRegular() || mode&os.ModeType != 0 {
|
||||
return ArtifactRecord{}, fmt.Errorf("unsupported zip entry type %q", file.Name)
|
||||
}
|
||||
if _, exists := entries[clean]; exists {
|
||||
return ArtifactRecord{}, fmt.Errorf("duplicate zip entry %q", clean)
|
||||
}
|
||||
extractedSize += file.UncompressedSize64
|
||||
if extractedSize > uint64(s.MaxExtractedBytes) {
|
||||
return ArtifactRecord{}, fmt.Errorf("plugin package extracted size exceeds limit %d", s.MaxExtractedBytes)
|
||||
}
|
||||
entries[clean] = file
|
||||
if clean == "manifest.json" {
|
||||
manifestFile = file
|
||||
}
|
||||
}
|
||||
if manifestFile == nil {
|
||||
return ArtifactRecord{}, errors.New("manifest.json is required")
|
||||
}
|
||||
if manifestFile.UncompressedSize64 > uint64(s.MaxManifestBytes) {
|
||||
return ArtifactRecord{}, fmt.Errorf("manifest.json size %d exceeds limit %d", manifestFile.UncompressedSize64, s.MaxManifestBytes)
|
||||
}
|
||||
|
||||
manifestBytes, err := readZipFile(manifestFile, s.MaxManifestBytes)
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
var manifest Manifest
|
||||
if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
|
||||
return ArtifactRecord{}, fmt.Errorf("invalid manifest.json: %w", err)
|
||||
}
|
||||
if err := validateManifest(manifest); err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
|
||||
entry := manifest.Runtime.Entry
|
||||
pluginFile, ok := entries[entry]
|
||||
if !ok {
|
||||
return ArtifactRecord{}, fmt.Errorf("runtime entry %q is required", entry)
|
||||
}
|
||||
if pluginFile.UncompressedSize64 == 0 {
|
||||
return ArtifactRecord{}, errors.New("runtime entry is empty")
|
||||
}
|
||||
if pluginFile.UncompressedSize64 > uint64(s.MaxPackageBytes) {
|
||||
return ArtifactRecord{}, fmt.Errorf("runtime entry size %d exceeds limit %d", pluginFile.UncompressedSize64, s.MaxPackageBytes)
|
||||
}
|
||||
for name, file := range entries {
|
||||
if name == "manifest.json" || name == entry {
|
||||
continue
|
||||
}
|
||||
if file.UncompressedSize64 > uint64(s.MaxNonRuntimeBytes) {
|
||||
return ArtifactRecord{}, fmt.Errorf("zip entry %q size %d exceeds limit %d", name, file.UncompressedSize64, s.MaxNonRuntimeBytes)
|
||||
}
|
||||
}
|
||||
|
||||
pluginBytes, err := readZipFile(pluginFile, s.MaxPackageBytes)
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
pluginSum := sha256.Sum256(pluginBytes)
|
||||
artifactID := hex.EncodeToString(pluginSum[:])
|
||||
artifactDir := filepath.Join(s.Root, manifest.ID, artifactID)
|
||||
if err := os.MkdirAll(artifactDir, 0755); err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
pluginPath := filepath.Join(artifactDir, RuntimeEntry)
|
||||
if err := os.WriteFile(pluginPath, pluginBytes, 0644); err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(artifactDir, "manifest.json"), manifestBytes, 0644); err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
|
||||
metadataJSON, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
extensionPoints, err := json.Marshal(extensionPointKeys(manifest))
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
capabilities := manifest.Capabilities
|
||||
if len(capabilities) == 0 {
|
||||
capabilities = json.RawMessage(`{}`)
|
||||
}
|
||||
now := s.now().Unix()
|
||||
return ArtifactRecord{
|
||||
ID: artifactID,
|
||||
PluginID: manifest.ID,
|
||||
Version: manifest.Version,
|
||||
FileName: upload.FileName,
|
||||
FilePath: pluginPath,
|
||||
SHA256: artifactID,
|
||||
PackageSHA256: packageSHA,
|
||||
SizeBytes: int64(len(pluginBytes)),
|
||||
ArtifactType: manifest.ArtifactType,
|
||||
RuntimeType: manifest.Runtime.Type,
|
||||
RuntimeEntry: manifest.Runtime.Entry,
|
||||
Status: ArtifactStatusLoadable,
|
||||
MetadataJSON: string(metadataJSON),
|
||||
CapabilitiesSummaryJSON: string(capabilities),
|
||||
ExtensionPointsJSON: string(extensionPoints),
|
||||
APIVersion: manifest.APIVersion,
|
||||
GoVersion: manifest.GoVersion,
|
||||
GOOS: manifest.GOOS,
|
||||
GOARCH: manifest.GOARCH,
|
||||
UploadedBy: upload.Actor,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateManifest(manifest Manifest) error {
|
||||
switch {
|
||||
case manifest.SchemaVersion != SchemaVersion:
|
||||
return fmt.Errorf("unsupported schema_version %q", manifest.SchemaVersion)
|
||||
case !pluginIDPattern.MatchString(manifest.ID):
|
||||
return fmt.Errorf("invalid plugin id %q", manifest.ID)
|
||||
case strings.TrimSpace(manifest.Version) == "":
|
||||
return errors.New("version is required")
|
||||
case manifest.ArtifactType != ArtifactTypeBinary:
|
||||
return fmt.Errorf("unsupported artifact_type %q", manifest.ArtifactType)
|
||||
case manifest.Runtime.Type != RuntimeGoPlugin:
|
||||
return fmt.Errorf("unsupported runtime.type %q", manifest.Runtime.Type)
|
||||
case manifest.Runtime.Entry != RuntimeEntry:
|
||||
return fmt.Errorf("unsupported runtime.entry %q", manifest.Runtime.Entry)
|
||||
case manifest.APIVersion != APIVersion:
|
||||
return fmt.Errorf("unsupported api_version %q", manifest.APIVersion)
|
||||
case manifest.GoVersion == "":
|
||||
return errors.New("go_version is required")
|
||||
case manifest.GOOS == "":
|
||||
return errors.New("go_os is required")
|
||||
case manifest.GOARCH == "":
|
||||
return errors.New("go_arch is required")
|
||||
}
|
||||
if manifest.GOOS != runtime.GOOS {
|
||||
return fmt.Errorf("go_os %q does not match gateway %q", manifest.GOOS, runtime.GOOS)
|
||||
}
|
||||
if manifest.GOARCH != runtime.GOARCH {
|
||||
return fmt.Errorf("go_arch %q does not match gateway %q", manifest.GOARCH, runtime.GOARCH)
|
||||
}
|
||||
found := false
|
||||
for _, ep := range manifest.ExtensionPoints {
|
||||
if ep.Type == "hook" && ep.Key == ExtensionUpstreamConnect {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("extension point %q is required", ExtensionUpstreamConnect)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extensionPointKeys(manifest Manifest) []string {
|
||||
keys := make([]string, 0, len(manifest.ExtensionPoints))
|
||||
for _, ep := range manifest.ExtensionPoints {
|
||||
keys = append(keys, ep.Key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func cleanZipName(name string) (string, error) {
|
||||
if name == "" || strings.Contains(name, `\`) || strings.HasPrefix(name, "/") {
|
||||
return "", fmt.Errorf("unsafe zip entry %q", name)
|
||||
}
|
||||
clean := path.Clean(name)
|
||||
if clean == "." || clean != name || strings.HasPrefix(clean, "../") || clean == ".." || path.IsAbs(clean) {
|
||||
return "", fmt.Errorf("unsafe zip entry %q", name)
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
func readZipFile(file *zip.File, maxBytes int64) ([]byte, error) {
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
var buf bytes.Buffer
|
||||
if _, err := io.CopyN(&buf, rc, maxBytes+1); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, err
|
||||
}
|
||||
if int64(buf.Len()) > maxBytes {
|
||||
return nil, fmt.Errorf("zip entry %q exceeds limit %d", file.Name, maxBytes)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func fileSHA256(path string) (string, error) {
|
||||
file, err := os.Open(path)
|
||||
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
|
||||
}
|
||||
149
internal/pluginmanager/artifact_test.go
Normal file
149
internal/pluginmanager/artifact_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package pluginmanager
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestArtifactStoreValidateAndStore(t *testing.T) {
|
||||
packagePath := writeTestMCGP(t, map[string][]byte{
|
||||
"manifest.json": testManifestBytes(t, "test-plugin"),
|
||||
"plugin.so": []byte("fake plugin bytes"),
|
||||
})
|
||||
store := NewArtifactStore(t.TempDir())
|
||||
artifact, err := store.ValidateAndStore(ArtifactUpload{
|
||||
SourcePath: packagePath,
|
||||
FileName: "test-plugin.mcgp",
|
||||
Actor: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateAndStore() error = %v", err)
|
||||
}
|
||||
if artifact.PluginID != "test-plugin" || artifact.Status != ArtifactStatusLoadable {
|
||||
t.Fatalf("artifact = %+v, want loadable test-plugin", artifact)
|
||||
}
|
||||
if artifact.SHA256 == "" || artifact.PackageSHA256 == "" {
|
||||
t.Fatalf("artifact hashes not set: %+v", artifact)
|
||||
}
|
||||
if _, err := os.Stat(artifact.FilePath); err != nil {
|
||||
t.Fatalf("stored runtime entry stat error = %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(artifact.FilePath, filepath.Join("test-plugin", artifact.ID, "plugin.so")) {
|
||||
t.Fatalf("artifact file path = %q", artifact.FilePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactStoreRejectsUnsafePackage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
entries map[string][]byte
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "zip slip",
|
||||
entries: map[string][]byte{
|
||||
"manifest.json": testManifestBytes(t, "test-plugin"),
|
||||
"../plugin.so": []byte("fake"),
|
||||
},
|
||||
want: "unsafe zip entry",
|
||||
},
|
||||
{
|
||||
name: "normalized escape",
|
||||
entries: map[string][]byte{
|
||||
"manifest.json": testManifestBytes(t, "test-plugin"),
|
||||
"nested/../plugin.so": []byte("fake"),
|
||||
},
|
||||
want: "unsafe zip entry",
|
||||
},
|
||||
{
|
||||
name: "missing manifest",
|
||||
entries: map[string][]byte{
|
||||
"plugin.so": []byte("fake"),
|
||||
},
|
||||
want: "manifest.json is required",
|
||||
},
|
||||
{
|
||||
name: "missing runtime",
|
||||
entries: map[string][]byte{
|
||||
"manifest.json": testManifestBytes(t, "test-plugin"),
|
||||
},
|
||||
want: `runtime entry "plugin.so" is required`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
store := NewArtifactStore(t.TempDir())
|
||||
_, err := store.ValidateAndStore(ArtifactUpload{
|
||||
SourcePath: writeTestMCGP(t, tt.entries),
|
||||
FileName: "bad.mcgp",
|
||||
Actor: "admin",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("ValidateAndStore() error = %v, want containing %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testManifestBytes(t *testing.T, pluginID string) []byte {
|
||||
t.Helper()
|
||||
manifest := Manifest{
|
||||
SchemaVersion: SchemaVersion,
|
||||
ID: pluginID,
|
||||
Name: "Test Plugin",
|
||||
Version: "0.1.0",
|
||||
ArtifactType: ArtifactTypeBinary,
|
||||
Runtime: RuntimeManifest{
|
||||
Type: RuntimeGoPlugin,
|
||||
Entry: RuntimeEntry,
|
||||
EntrySymbol: "Plugin",
|
||||
},
|
||||
APIVersion: APIVersion,
|
||||
GoVersion: runtime.Version(),
|
||||
GOOS: runtime.GOOS,
|
||||
GOARCH: runtime.GOARCH,
|
||||
ExtensionPoints: []ExtensionPoint{{
|
||||
Type: "hook",
|
||||
Key: ExtensionUpstreamConnect,
|
||||
}},
|
||||
Capabilities: json.RawMessage(`{"extension_points":["upstream.connect/v1"]}`),
|
||||
ConfigSchema: json.RawMessage(`{"type":"object"}`),
|
||||
RuntimeLimits: RuntimeLimits{HandlerTimeoutMS: 3000},
|
||||
}
|
||||
data, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal manifest error = %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func writeTestMCGP(t *testing.T, entries map[string][]byte) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "plugin.mcgp")
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Create package error = %v", err)
|
||||
}
|
||||
zipWriter := zip.NewWriter(file)
|
||||
for name, data := range entries {
|
||||
writer, err := zipWriter.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Create zip entry error = %v", err)
|
||||
}
|
||||
if _, err := writer.Write(data); err != nil {
|
||||
t.Fatalf("Write zip entry error = %v", err)
|
||||
}
|
||||
}
|
||||
if err := zipWriter.Close(); err != nil {
|
||||
t.Fatalf("Close zip error = %v", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatalf("Close package error = %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
555
internal/pluginmanager/manager.go
Normal file
555
internal/pluginmanager/manager.go
Normal file
@@ -0,0 +1,555 @@
|
||||
package pluginmanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
stdplugin "plugin"
|
||||
"reflect"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/tursom/mc-gateway/plugin/api"
|
||||
)
|
||||
|
||||
type RuntimeAdapter interface {
|
||||
Load(ctx context.Context, artifact ArtifactRecord, pluginRecord PluginRecord, gateway *Gateway) (api.Plugin, error)
|
||||
}
|
||||
|
||||
type GoPluginAdapter struct{}
|
||||
|
||||
func (a GoPluginAdapter) Load(ctx context.Context, artifact ArtifactRecord, pluginRecord PluginRecord, gateway *Gateway) (api.Plugin, error) {
|
||||
_ = ctx
|
||||
opened, err := stdplugin.Open(artifact.FilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
symbolName := "Plugin"
|
||||
var manifest Manifest
|
||||
if err := json.Unmarshal([]byte(artifact.MetadataJSON), &manifest); err == nil && manifest.Runtime.EntrySymbol != "" {
|
||||
symbolName = manifest.Runtime.EntrySymbol
|
||||
}
|
||||
symbol, err := opened.Lookup(symbolName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
factory, ok := symbol.(func() api.Plugin)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plugin symbol %q has invalid signature", symbolName)
|
||||
}
|
||||
instance := factory()
|
||||
cfg := instance.NewConfigObj()
|
||||
if cfg != nil && pluginRecord.ConfigJSON != "" && canUnmarshalInto(cfg) {
|
||||
if err := json.Unmarshal([]byte(pluginRecord.ConfigJSON), cfg); err != nil {
|
||||
return nil, fmt.Errorf("decode plugin config: %w", err)
|
||||
}
|
||||
}
|
||||
if err := instance.ReloadConfig(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := instance.Init(gateway); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
func canUnmarshalInto(value any) bool {
|
||||
if value == nil {
|
||||
return false
|
||||
}
|
||||
kind := reflect.TypeOf(value).Kind()
|
||||
return kind == reflect.Pointer || kind == reflect.Map || kind == reflect.Slice
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
repo Repository
|
||||
store ArtifactStore
|
||||
adapter RuntimeAdapter
|
||||
handleConn func(net.Conn)
|
||||
wg *sync.WaitGroup
|
||||
|
||||
mu sync.Mutex
|
||||
loaded map[string]*loadedPlugin
|
||||
snapshot atomic.Value
|
||||
}
|
||||
|
||||
type loadedPlugin struct {
|
||||
record PluginRecord
|
||||
artifact ArtifactRecord
|
||||
instance api.Plugin
|
||||
gateway *Gateway
|
||||
handlers []*upstreamHandler
|
||||
}
|
||||
|
||||
type upstreamHandler struct {
|
||||
pluginID string
|
||||
artifactID string
|
||||
priority int
|
||||
handlerID string
|
||||
timeout time.Duration
|
||||
accept func(api.UpstreamConnectRequest) bool
|
||||
handle func(api.UpstreamConnectRequest) (net.Conn, error)
|
||||
|
||||
calls atomic.Uint64
|
||||
errors atomic.Uint64
|
||||
panics atomic.Uint64
|
||||
timeouts atomic.Uint64
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
DB *sql.DB
|
||||
ArtifactRoot string
|
||||
HandleConn func(net.Conn)
|
||||
WaitGroup *sync.WaitGroup
|
||||
Adapter RuntimeAdapter
|
||||
}
|
||||
|
||||
func New(options Options) *Manager {
|
||||
adapter := options.Adapter
|
||||
if adapter == nil {
|
||||
adapter = GoPluginAdapter{}
|
||||
}
|
||||
manager := &Manager{
|
||||
repo: NewRepository(options.DB),
|
||||
store: NewArtifactStore(options.ArtifactRoot),
|
||||
adapter: adapter,
|
||||
handleConn: options.HandleConn,
|
||||
wg: options.WaitGroup,
|
||||
loaded: make(map[string]*loadedPlugin),
|
||||
}
|
||||
manager.publish(nil)
|
||||
return manager
|
||||
}
|
||||
|
||||
func (m *Manager) UploadArtifact(ctx context.Context, upload ArtifactUpload) (ArtifactRecord, error) {
|
||||
artifact, err := m.store.ValidateAndStore(upload)
|
||||
if err != nil {
|
||||
_ = m.repo.RecordOperation(ctx, "", "", "artifact_upload", "failed", upload.Actor, err.Error(), nil)
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
if err := m.repo.SaveArtifact(ctx, artifact); err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
_ = m.repo.RecordOperation(ctx, artifact.PluginID, artifact.ID, "artifact_upload", "succeeded", upload.Actor, "artifact uploaded", map[string]any{
|
||||
"sha256": artifact.SHA256,
|
||||
"package_sha256": artifact.PackageSHA256,
|
||||
"api_version": artifact.APIVersion,
|
||||
"extension_points": artifact.ExtensionPointsJSON,
|
||||
})
|
||||
return artifact, nil
|
||||
}
|
||||
|
||||
func (m *Manager) SetDesired(ctx context.Context, actor, pluginID, artifactID, desiredState, configJSON string, priority int) (PluginRecord, error) {
|
||||
pluginRecord, err := m.repo.UpsertDesired(ctx, actor, pluginID, artifactID, desiredState, configJSON, priority)
|
||||
if err != nil {
|
||||
_ = m.repo.RecordOperation(ctx, pluginID, artifactID, "desired_update", "failed", actor, err.Error(), nil)
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
_ = m.repo.RecordOperation(ctx, pluginID, artifactID, "desired_update", "succeeded", actor, "desired state updated", map[string]any{
|
||||
"desired_state": desiredState,
|
||||
"desired_generation": pluginRecord.DesiredGeneration,
|
||||
"priority": pluginRecord.Priority,
|
||||
})
|
||||
return pluginRecord, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Load(ctx context.Context, actor, pluginID string) (PluginRecord, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
pluginRecord, err := m.repo.Plugin(ctx, pluginID)
|
||||
if err != nil {
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
loaded, err := m.loadLocked(ctx, pluginRecord)
|
||||
if err != nil {
|
||||
_ = m.repo.RecordOperation(ctx, pluginID, pluginRecord.DesiredArtifactID, "load", "failed", actor, err.Error(), nil)
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
_ = m.repo.RecordOperation(ctx, pluginID, loaded.artifact.ID, "load", "succeeded", actor, "plugin loaded", nil)
|
||||
return m.repo.Plugin(ctx, pluginID)
|
||||
}
|
||||
|
||||
func (m *Manager) Enable(ctx context.Context, actor, pluginID string) (PluginRecord, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
pluginRecord, err := m.repo.Plugin(ctx, pluginID)
|
||||
if err != nil {
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
if pluginRecord.DesiredState != DesiredEnabled {
|
||||
pluginRecord, err = m.repo.UpsertDesired(ctx, actor, pluginRecord.ID, pluginRecord.DesiredArtifactID, DesiredEnabled, pluginRecord.ConfigJSON, pluginRecord.Priority)
|
||||
if err != nil {
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
}
|
||||
loaded, err := m.loadLocked(ctx, pluginRecord)
|
||||
if err != nil {
|
||||
_ = m.repo.RecordOperation(ctx, pluginID, pluginRecord.DesiredArtifactID, "enable", "failed", actor, err.Error(), nil)
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
if len(loaded.handlers) == 0 {
|
||||
err := fmt.Errorf("plugin %q did not register %s", pluginID, ExtensionUpstreamConnect)
|
||||
_ = m.repo.MarkRuntime(ctx, pluginID, RuntimeFailed, "", loaded.artifact.ID, pluginRecord.AppliedGeneration, err.Error(), map[string]any{"error": err.Error()}, nil)
|
||||
_ = m.repo.RecordOperation(ctx, pluginID, pluginRecord.DesiredArtifactID, "enable", "failed", actor, err.Error(), nil)
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
|
||||
current := m.currentHandlersLocked()
|
||||
current[pluginID] = loaded.handlers
|
||||
next := flattenHandlers(current)
|
||||
if err := m.markEnabled(ctx, loaded); err != nil {
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
m.publish(next)
|
||||
_ = m.repo.UpdateArtifactStatus(ctx, loaded.artifact.ID, ArtifactStatusLoaded, "")
|
||||
_ = m.repo.RecordOperation(ctx, pluginID, loaded.artifact.ID, "enable", "succeeded", actor, "plugin enabled", map[string]any{
|
||||
"desired_generation": loaded.record.DesiredGeneration,
|
||||
"handler_count": len(loaded.handlers),
|
||||
})
|
||||
return m.repo.Plugin(ctx, pluginID)
|
||||
}
|
||||
|
||||
func (m *Manager) Disable(ctx context.Context, actor, pluginID string) (PluginRecord, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
pluginRecord, err := m.repo.Plugin(ctx, pluginID)
|
||||
if err != nil {
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
pluginRecord, err = m.repo.UpsertDesired(ctx, actor, pluginRecord.ID, pluginRecord.DesiredArtifactID, DesiredDisabled, pluginRecord.ConfigJSON, pluginRecord.Priority)
|
||||
if err != nil {
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
m.removeFromDispatchLocked(pluginID)
|
||||
if loaded := m.loaded[pluginID]; loaded != nil && loaded.instance != nil {
|
||||
if err := loaded.instance.Destroy(); err != nil {
|
||||
_ = m.repo.RecordOperation(ctx, pluginID, loaded.artifact.ID, "disable", "warning", actor, err.Error(), nil)
|
||||
}
|
||||
}
|
||||
delete(m.loaded, pluginID)
|
||||
if err := m.repo.MarkRuntime(ctx, pluginID, RuntimeDisabled, "", "", pluginRecord.DesiredGeneration, "", nil, nil); err != nil {
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
_ = m.repo.RecordOperation(ctx, pluginID, pluginRecord.DesiredArtifactID, "disable", "succeeded", actor, "plugin disabled", nil)
|
||||
return m.repo.Plugin(ctx, pluginID)
|
||||
}
|
||||
|
||||
func (m *Manager) Delete(ctx context.Context, actor, pluginID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
pluginRecord, err := m.repo.Plugin(ctx, pluginID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.removeFromDispatchLocked(pluginID)
|
||||
if loaded := m.loaded[pluginID]; loaded != nil && loaded.instance != nil {
|
||||
_ = loaded.instance.Destroy()
|
||||
}
|
||||
delete(m.loaded, pluginID)
|
||||
if _, err := m.repo.UpsertDesired(ctx, actor, pluginRecord.ID, pluginRecord.DesiredArtifactID, DesiredDeleted, pluginRecord.ConfigJSON, pluginRecord.Priority); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = m.repo.RecordOperation(ctx, pluginID, pluginRecord.DesiredArtifactID, "delete", "succeeded", actor, "plugin deleted", map[string]any{
|
||||
"cleanup": "pending_restart_for_loaded_go_plugin",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Reconcile(ctx context.Context) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
desired, err := m.repo.DesiredEnabled(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nextByPlugin := make(map[string][]*upstreamHandler)
|
||||
for _, pluginRecord := range desired {
|
||||
loaded, err := m.loadLocked(ctx, pluginRecord)
|
||||
if err != nil {
|
||||
_ = m.repo.MarkRuntime(ctx, pluginRecord.ID, RuntimeFailed, "", "", pluginRecord.AppliedGeneration, err.Error(), map[string]any{"error": err.Error()}, nil)
|
||||
_ = m.repo.RecordOperation(ctx, pluginRecord.ID, pluginRecord.DesiredArtifactID, "reconcile", "failed", "system", err.Error(), nil)
|
||||
continue
|
||||
}
|
||||
if len(loaded.handlers) == 0 {
|
||||
err := fmt.Errorf("plugin %q did not register %s", pluginRecord.ID, ExtensionUpstreamConnect)
|
||||
_ = m.repo.MarkRuntime(ctx, pluginRecord.ID, RuntimeFailed, "", loaded.artifact.ID, pluginRecord.AppliedGeneration, err.Error(), map[string]any{"error": err.Error()}, nil)
|
||||
_ = m.repo.RecordOperation(ctx, pluginRecord.ID, pluginRecord.DesiredArtifactID, "reconcile", "failed", "system", err.Error(), nil)
|
||||
continue
|
||||
}
|
||||
nextByPlugin[pluginRecord.ID] = loaded.handlers
|
||||
_ = m.markEnabled(ctx, loaded)
|
||||
}
|
||||
m.publish(flattenHandlers(nextByPlugin))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) ConnectUpstream(ctx context.Context, req api.UpstreamConnectRequest) (UpstreamResult, error) {
|
||||
value := m.snapshot.Load()
|
||||
if value == nil {
|
||||
return UpstreamResult{}, nil
|
||||
}
|
||||
handlers, ok := value.([]*upstreamHandler)
|
||||
if !ok {
|
||||
return UpstreamResult{}, nil
|
||||
}
|
||||
if req.Context == nil {
|
||||
req.Context = ctx
|
||||
}
|
||||
for _, handler := range handlers {
|
||||
accepted, err := handler.accepts(req)
|
||||
if err != nil {
|
||||
return UpstreamResult{Handled: true}, err
|
||||
}
|
||||
if !accepted {
|
||||
continue
|
||||
}
|
||||
conn, err := handler.invoke(req)
|
||||
if errors.Is(err, api.ErrPass) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return UpstreamResult{Handled: true}, err
|
||||
}
|
||||
if conn != nil {
|
||||
return UpstreamResult{Conn: conn, Handled: true}, nil
|
||||
}
|
||||
}
|
||||
return UpstreamResult{}, nil
|
||||
}
|
||||
|
||||
func (h *upstreamHandler) accepts(req api.UpstreamConnectRequest) (accepted bool, err error) {
|
||||
if h.accept == nil {
|
||||
return true, nil
|
||||
}
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
h.panics.Add(1)
|
||||
accepted = false
|
||||
err = fmt.Errorf("plugin %s acceptor panic: %v", h.pluginID, rec)
|
||||
}
|
||||
}()
|
||||
return h.accept(req), nil
|
||||
}
|
||||
|
||||
func (m *Manager) ListArtifacts(ctx context.Context, pluginID string) ([]ArtifactRecord, error) {
|
||||
return m.repo.ListArtifacts(ctx, pluginID)
|
||||
}
|
||||
|
||||
func (m *Manager) Artifact(ctx context.Context, id string) (ArtifactRecord, error) {
|
||||
return m.repo.Artifact(ctx, id)
|
||||
}
|
||||
|
||||
func (m *Manager) ListPlugins(ctx context.Context) ([]PluginRecord, error) {
|
||||
return m.repo.ListPlugins(ctx)
|
||||
}
|
||||
|
||||
func (m *Manager) Plugin(ctx context.Context, id string) (PluginRecord, error) {
|
||||
return m.repo.Plugin(ctx, id)
|
||||
}
|
||||
|
||||
func (m *Manager) DispatchPlan(ctx context.Context) DispatchPlan {
|
||||
value := m.snapshot.Load()
|
||||
plan := DispatchPlan{UpdatedAt: time.Now().Unix()}
|
||||
if handlers, ok := value.([]*upstreamHandler); ok {
|
||||
plan.Handlers = handlerSummaries(handlers)
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
func (m *Manager) loadLocked(ctx context.Context, pluginRecord PluginRecord) (*loadedPlugin, error) {
|
||||
if loaded := m.loaded[pluginRecord.ID]; loaded != nil &&
|
||||
loaded.artifact.ID == pluginRecord.DesiredArtifactID &&
|
||||
loaded.record.DesiredGeneration == pluginRecord.DesiredGeneration {
|
||||
return loaded, nil
|
||||
}
|
||||
artifact, err := m.repo.Artifact(ctx, pluginRecord.DesiredArtifactID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if artifact.Status == ArtifactStatusDeleted || artifact.Status == ArtifactStatusRejected {
|
||||
return nil, fmt.Errorf("artifact status %q is not loadable", artifact.Status)
|
||||
}
|
||||
|
||||
gateway := NewGateway(pluginRecord.ID, m.handleConn, m.wg)
|
||||
instance, err := m.adapter.Load(ctx, artifact, pluginRecord, gateway)
|
||||
if err != nil {
|
||||
_ = m.repo.MarkRuntime(ctx, pluginRecord.ID, RuntimeFailed, "", "", pluginRecord.AppliedGeneration, err.Error(), map[string]any{"error": err.Error()}, nil)
|
||||
return nil, err
|
||||
}
|
||||
handlers := buildHandlers(pluginRecord, artifact, gateway)
|
||||
loaded := &loadedPlugin{
|
||||
record: pluginRecord,
|
||||
artifact: artifact,
|
||||
instance: instance,
|
||||
gateway: gateway,
|
||||
handlers: handlers,
|
||||
}
|
||||
m.loaded[pluginRecord.ID] = loaded
|
||||
if err := m.repo.MarkRuntime(ctx, pluginRecord.ID, RuntimeLoaded, "", artifact.ID, pluginRecord.AppliedGeneration, "", map[string]any{
|
||||
"handler_count": len(handlers),
|
||||
}, handlerSummaries(handlers)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return loaded, nil
|
||||
}
|
||||
|
||||
func (m *Manager) markEnabled(ctx context.Context, loaded *loadedPlugin) error {
|
||||
return m.repo.MarkRuntime(ctx, loaded.record.ID, RuntimeEnabled, loaded.artifact.ID, loaded.artifact.ID, loaded.record.DesiredGeneration, "", map[string]any{
|
||||
"handler_count": len(loaded.handlers),
|
||||
}, handlerSummaries(loaded.handlers))
|
||||
}
|
||||
|
||||
func (m *Manager) currentHandlersLocked() map[string][]*upstreamHandler {
|
||||
current := make(map[string][]*upstreamHandler)
|
||||
value := m.snapshot.Load()
|
||||
if handlers, ok := value.([]*upstreamHandler); ok {
|
||||
for _, handler := range handlers {
|
||||
current[handler.pluginID] = append(current[handler.pluginID], handler)
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func (m *Manager) removeFromDispatchLocked(pluginID string) {
|
||||
current := m.currentHandlersLocked()
|
||||
delete(current, pluginID)
|
||||
m.publish(flattenHandlers(current))
|
||||
}
|
||||
|
||||
func (m *Manager) publish(handlers []*upstreamHandler) {
|
||||
sort.SliceStable(handlers, func(i, j int) bool {
|
||||
if handlers[i].priority != handlers[j].priority {
|
||||
return handlers[i].priority < handlers[j].priority
|
||||
}
|
||||
if handlers[i].pluginID != handlers[j].pluginID {
|
||||
return handlers[i].pluginID < handlers[j].pluginID
|
||||
}
|
||||
return handlers[i].handlerID < handlers[j].handlerID
|
||||
})
|
||||
m.snapshot.Store(handlers)
|
||||
}
|
||||
|
||||
func buildHandlers(pluginRecord PluginRecord, artifact ArtifactRecord, gateway *Gateway) []*upstreamHandler {
|
||||
timeout := DefaultHandlerTimeout
|
||||
var manifest Manifest
|
||||
if err := json.Unmarshal([]byte(artifact.MetadataJSON), &manifest); err == nil && manifest.RuntimeLimits.HandlerTimeoutMS > 0 {
|
||||
timeout = time.Duration(manifest.RuntimeLimits.HandlerTimeoutMS) * time.Millisecond
|
||||
}
|
||||
var handlers []*upstreamHandler
|
||||
if hook, ok := gateway.UpstreamConnectHandler(); ok {
|
||||
handlers = append(handlers, &upstreamHandler{
|
||||
pluginID: pluginRecord.ID,
|
||||
artifactID: artifact.ID,
|
||||
priority: pluginRecord.Priority,
|
||||
handlerID: "upstream.connect/v1",
|
||||
timeout: timeout,
|
||||
accept: hook.Acceptor(),
|
||||
handle: hook.Handler(),
|
||||
})
|
||||
}
|
||||
if hook, ok := gateway.LegacyUpstreamHandler(); ok {
|
||||
acceptor := hook.Acceptor()
|
||||
handler := hook.Handler()
|
||||
handlers = append(handlers, &upstreamHandler{
|
||||
pluginID: pluginRecord.ID,
|
||||
artifactID: artifact.ID,
|
||||
priority: pluginRecord.Priority,
|
||||
handlerID: "legacy-upstream",
|
||||
timeout: timeout,
|
||||
accept: func(req api.UpstreamConnectRequest) bool {
|
||||
return acceptor(req.Source, req.Upstream)
|
||||
},
|
||||
handle: func(req api.UpstreamConnectRequest) (net.Conn, error) {
|
||||
return handler(req.Source, req.Upstream)
|
||||
},
|
||||
})
|
||||
}
|
||||
return handlers
|
||||
}
|
||||
|
||||
func (h *upstreamHandler) invoke(req api.UpstreamConnectRequest) (conn net.Conn, err error) {
|
||||
h.calls.Add(1)
|
||||
ctx := req.Context
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if h.timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, h.timeout)
|
||||
defer cancel()
|
||||
}
|
||||
req.Context = ctx
|
||||
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
h.panics.Add(1)
|
||||
done <- result{err: fmt.Errorf("plugin %s panic: %v", h.pluginID, rec)}
|
||||
}
|
||||
}()
|
||||
conn, err := h.handle(req)
|
||||
done <- result{conn: conn, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
h.timeouts.Add(1)
|
||||
go closeLateConn(done)
|
||||
return nil, ctx.Err()
|
||||
case result := <-done:
|
||||
if result.err != nil && !errors.Is(result.err, api.ErrPass) {
|
||||
h.errors.Add(1)
|
||||
}
|
||||
return result.conn, result.err
|
||||
}
|
||||
}
|
||||
|
||||
type result struct {
|
||||
conn net.Conn
|
||||
err error
|
||||
}
|
||||
|
||||
func closeLateConn(done <-chan result) {
|
||||
result := <-done
|
||||
if result.conn != nil {
|
||||
_ = result.conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func flattenHandlers(byPlugin map[string][]*upstreamHandler) []*upstreamHandler {
|
||||
var handlers []*upstreamHandler
|
||||
for _, pluginHandlers := range byPlugin {
|
||||
handlers = append(handlers, pluginHandlers...)
|
||||
}
|
||||
return handlers
|
||||
}
|
||||
|
||||
func handlerSummaries(handlers []*upstreamHandler) []DispatchHandlerSummary {
|
||||
summaries := make([]DispatchHandlerSummary, 0, len(handlers))
|
||||
for _, handler := range handlers {
|
||||
summaries = append(summaries, DispatchHandlerSummary{
|
||||
PluginID: handler.pluginID,
|
||||
ArtifactID: handler.artifactID,
|
||||
Priority: handler.priority,
|
||||
HandlerID: handler.handlerID,
|
||||
ExtensionPoint: ExtensionUpstreamConnect,
|
||||
TimeoutMS: handler.timeout.Milliseconds(),
|
||||
Calls: handler.calls.Load(),
|
||||
Errors: handler.errors.Load(),
|
||||
Panics: handler.panics.Load(),
|
||||
Timeouts: handler.timeouts.Load(),
|
||||
})
|
||||
}
|
||||
return summaries
|
||||
}
|
||||
323
internal/pluginmanager/manager_test.go
Normal file
323
internal/pluginmanager/manager_test.go
Normal file
@@ -0,0 +1,323 @@
|
||||
package pluginmanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/admindb"
|
||||
"github.com/tursom/mc-gateway/plugin/api"
|
||||
)
|
||||
|
||||
func TestManagerUploadDoesNotLoadPlugin(t *testing.T) {
|
||||
adapter := &fakeAdapter{}
|
||||
manager := newManagerForTest(t, adapter)
|
||||
|
||||
artifact := uploadTestArtifact(t, manager, "plugin-a")
|
||||
if artifact.PluginID != "plugin-a" {
|
||||
t.Fatalf("artifact plugin = %q, want plugin-a", artifact.PluginID)
|
||||
}
|
||||
if adapter.loads != 0 {
|
||||
t.Fatalf("adapter loads = %d, want 0 for upload-only validation", adapter.loads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerEnableDisableAndDispatch(t *testing.T) {
|
||||
adapter := &fakeAdapter{}
|
||||
manager := newManagerForTest(t, adapter)
|
||||
artifact := uploadTestArtifact(t, manager, "plugin-a")
|
||||
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "plugin-a", artifact.ID, DesiredDisabled, `{"upstream":"override"}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
if _, err := manager.Enable(context.Background(), "admin", "plugin-a"); err != nil {
|
||||
t.Fatalf("Enable() error = %v", err)
|
||||
}
|
||||
if adapter.loads != 1 {
|
||||
t.Fatalf("adapter loads = %d, want 1", adapter.loads)
|
||||
}
|
||||
result, err := manager.ConnectUpstream(context.Background(), api.UpstreamConnectRequest{
|
||||
Host: "play.example",
|
||||
Upstream: "backend.example:25565",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ConnectUpstream() error = %v", err)
|
||||
}
|
||||
if !result.Handled || result.Conn == nil {
|
||||
t.Fatalf("ConnectUpstream() = %+v, want handled conn", result)
|
||||
}
|
||||
|
||||
plugin, err := manager.Disable(context.Background(), "admin", "plugin-a")
|
||||
if err != nil {
|
||||
t.Fatalf("Disable() error = %v", err)
|
||||
}
|
||||
if plugin.RuntimeState != RuntimeDisabled {
|
||||
t.Fatalf("disabled runtime state = %q, want %q", plugin.RuntimeState, RuntimeDisabled)
|
||||
}
|
||||
result, err = manager.ConnectUpstream(context.Background(), api.UpstreamConnectRequest{
|
||||
Host: "play.example",
|
||||
Upstream: "backend.example:25565",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ConnectUpstream(disabled) error = %v", err)
|
||||
}
|
||||
if result.Handled {
|
||||
t.Fatalf("ConnectUpstream(disabled) = %+v, want pass-through", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerErrPassContinuesToNextHandler(t *testing.T) {
|
||||
adapter := &fakeAdapter{
|
||||
handlers: map[string]api.UpstreamConnectHandler{
|
||||
"plugin-a": func(api.UpstreamConnectRequest) (net.Conn, error) {
|
||||
return nil, api.ErrPass
|
||||
},
|
||||
"plugin-b": func(api.UpstreamConnectRequest) (net.Conn, error) {
|
||||
return newMemoryConn(), nil
|
||||
},
|
||||
},
|
||||
}
|
||||
manager := newManagerForTest(t, adapter)
|
||||
artifactA := uploadTestArtifact(t, manager, "plugin-a")
|
||||
artifactB := uploadTestArtifact(t, manager, "plugin-b")
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "plugin-a", artifactA.ID, DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired(a) error = %v", err)
|
||||
}
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "plugin-b", artifactB.ID, DesiredEnabled, `{}`, 20); err != nil {
|
||||
t.Fatalf("SetDesired(b) error = %v", err)
|
||||
}
|
||||
if err := manager.Reconcile(context.Background()); err != nil {
|
||||
t.Fatalf("Reconcile() error = %v", err)
|
||||
}
|
||||
|
||||
result, err := manager.ConnectUpstream(context.Background(), api.UpstreamConnectRequest{Host: "play.example", Upstream: "backend"})
|
||||
if err != nil {
|
||||
t.Fatalf("ConnectUpstream() error = %v", err)
|
||||
}
|
||||
if !result.Handled || result.Conn == nil {
|
||||
t.Fatalf("ConnectUpstream() = %+v, want second handler conn", result)
|
||||
}
|
||||
plan := manager.DispatchPlan(context.Background())
|
||||
if len(plan.Handlers) != 2 {
|
||||
t.Fatalf("dispatch handlers = %d, want 2", len(plan.Handlers))
|
||||
}
|
||||
if plan.Handlers[0].PluginID != "plugin-a" || plan.Handlers[1].PluginID != "plugin-b" {
|
||||
t.Fatalf("dispatch order = %+v, want plugin-a then plugin-b", plan.Handlers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerPanicDoesNotReplaceExistingDispatch(t *testing.T) {
|
||||
adapter := &fakeAdapter{
|
||||
handlers: map[string]api.UpstreamConnectHandler{
|
||||
"plugin-a": func(api.UpstreamConnectRequest) (net.Conn, error) {
|
||||
return newMemoryConn(), nil
|
||||
},
|
||||
"plugin-b": func(api.UpstreamConnectRequest) (net.Conn, error) {
|
||||
panic("boom")
|
||||
},
|
||||
},
|
||||
}
|
||||
manager := newManagerForTest(t, adapter)
|
||||
artifactA := uploadTestArtifact(t, manager, "plugin-a")
|
||||
artifactB := uploadTestArtifact(t, manager, "plugin-b")
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "plugin-a", artifactA.ID, DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired(a) error = %v", err)
|
||||
}
|
||||
if _, err := manager.Enable(context.Background(), "admin", "plugin-a"); err != nil {
|
||||
t.Fatalf("Enable(a) error = %v", err)
|
||||
}
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "plugin-b", artifactB.ID, DesiredEnabled, `{}`, 5); err != nil {
|
||||
t.Fatalf("SetDesired(b) error = %v", err)
|
||||
}
|
||||
if _, err := manager.Enable(context.Background(), "admin", "plugin-b"); err != nil {
|
||||
t.Fatalf("Enable(b) error = %v", err)
|
||||
}
|
||||
_, err := manager.ConnectUpstream(context.Background(), api.UpstreamConnectRequest{Host: "play.example", Upstream: "backend"})
|
||||
if err == nil {
|
||||
t.Fatal("ConnectUpstream() error = nil, want panic converted to error")
|
||||
}
|
||||
plan := manager.DispatchPlan(context.Background())
|
||||
if len(plan.Handlers) != 2 {
|
||||
t.Fatalf("dispatch handlers = %d, want 2", len(plan.Handlers))
|
||||
}
|
||||
if plan.Handlers[0].PluginID != "plugin-b" || plan.Handlers[0].Panics != 1 {
|
||||
t.Fatalf("first handler summary = %+v, want plugin-b panic count", plan.Handlers[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerLoadFailureKeepsExistingDispatch(t *testing.T) {
|
||||
adapter := &fakeAdapter{
|
||||
loadErrs: map[string]error{
|
||||
"plugin-b": errors.New("open failed"),
|
||||
},
|
||||
}
|
||||
manager := newManagerForTest(t, adapter)
|
||||
artifactA := uploadTestArtifact(t, manager, "plugin-a")
|
||||
artifactB := uploadTestArtifact(t, manager, "plugin-b")
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "plugin-a", artifactA.ID, DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired(a) error = %v", err)
|
||||
}
|
||||
if _, err := manager.Enable(context.Background(), "admin", "plugin-a"); err != nil {
|
||||
t.Fatalf("Enable(a) error = %v", err)
|
||||
}
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "plugin-b", artifactB.ID, DesiredEnabled, `{}`, 5); err != nil {
|
||||
t.Fatalf("SetDesired(b) error = %v", err)
|
||||
}
|
||||
if _, err := manager.Enable(context.Background(), "admin", "plugin-b"); err == nil {
|
||||
t.Fatal("Enable(b) error = nil, want load failure")
|
||||
}
|
||||
|
||||
plan := manager.DispatchPlan(context.Background())
|
||||
if len(plan.Handlers) != 1 || plan.Handlers[0].PluginID != "plugin-a" {
|
||||
t.Fatalf("dispatch plan after failed enable = %+v, want only plugin-a", plan)
|
||||
}
|
||||
result, err := manager.ConnectUpstream(context.Background(), api.UpstreamConnectRequest{Host: "play.example", Upstream: "backend"})
|
||||
if err != nil {
|
||||
t.Fatalf("ConnectUpstream() error = %v", err)
|
||||
}
|
||||
if !result.Handled || result.Conn == nil {
|
||||
t.Fatalf("ConnectUpstream() = %+v, want existing plugin-a conn", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerReconcileRestoresEnabledPlugin(t *testing.T) {
|
||||
db := openPluginManagerTestDB(t)
|
||||
root := t.TempDir()
|
||||
firstAdapter := &fakeAdapter{}
|
||||
first := New(Options{
|
||||
DB: db,
|
||||
ArtifactRoot: root,
|
||||
Adapter: firstAdapter,
|
||||
})
|
||||
artifact := uploadTestArtifact(t, first, "plugin-a")
|
||||
if _, err := first.SetDesired(context.Background(), "admin", "plugin-a", artifact.ID, DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
if _, err := first.Enable(context.Background(), "admin", "plugin-a"); err != nil {
|
||||
t.Fatalf("Enable() error = %v", err)
|
||||
}
|
||||
|
||||
secondAdapter := &fakeAdapter{}
|
||||
second := New(Options{
|
||||
DB: db,
|
||||
ArtifactRoot: root,
|
||||
Adapter: secondAdapter,
|
||||
})
|
||||
if err := second.Reconcile(context.Background()); err != nil {
|
||||
t.Fatalf("Reconcile() error = %v", err)
|
||||
}
|
||||
if secondAdapter.loads != 1 {
|
||||
t.Fatalf("reconcile loads = %d, want 1", secondAdapter.loads)
|
||||
}
|
||||
result, err := second.ConnectUpstream(context.Background(), api.UpstreamConnectRequest{Host: "play.example", Upstream: "backend"})
|
||||
if err != nil {
|
||||
t.Fatalf("ConnectUpstream() error = %v", err)
|
||||
}
|
||||
if !result.Handled || result.Conn == nil {
|
||||
t.Fatalf("ConnectUpstream() = %+v, want restored handler conn", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptorPanicIsRecovered(t *testing.T) {
|
||||
handler := &upstreamHandler{
|
||||
pluginID: "acceptor",
|
||||
accept: func(api.UpstreamConnectRequest) bool {
|
||||
panic("boom")
|
||||
},
|
||||
}
|
||||
accepted, err := handler.accepts(api.UpstreamConnectRequest{})
|
||||
if err == nil {
|
||||
t.Fatal("accepts() error = nil, want panic error")
|
||||
}
|
||||
if accepted {
|
||||
t.Fatal("accepts() accepted = true, want false")
|
||||
}
|
||||
if handler.panics.Load() != 1 {
|
||||
t.Fatalf("panics = %d, want 1", handler.panics.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func newManagerForTest(t *testing.T, adapter RuntimeAdapter) *Manager {
|
||||
t.Helper()
|
||||
db := openPluginManagerTestDB(t)
|
||||
return New(Options{
|
||||
DB: db,
|
||||
ArtifactRoot: t.TempDir(),
|
||||
Adapter: adapter,
|
||||
})
|
||||
}
|
||||
|
||||
func openPluginManagerTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := admindb.Open(filepath.Join(t.TempDir(), "gateway.sqlite3"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := admindb.Migrate(db); err != nil {
|
||||
t.Fatalf("Migrate() error = %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func uploadTestArtifact(t *testing.T, manager *Manager, pluginID string) ArtifactRecord {
|
||||
t.Helper()
|
||||
packagePath := writeTestMCGP(t, map[string][]byte{
|
||||
"manifest.json": testManifestBytes(t, pluginID),
|
||||
"plugin.so": []byte("fake plugin bytes " + pluginID),
|
||||
})
|
||||
artifact, err := manager.UploadArtifact(context.Background(), ArtifactUpload{
|
||||
SourcePath: packagePath,
|
||||
FileName: pluginID + ".mcgp",
|
||||
Actor: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UploadArtifact(%s) error = %v", pluginID, err)
|
||||
}
|
||||
return artifact
|
||||
}
|
||||
|
||||
type fakeAdapter struct {
|
||||
loads int
|
||||
handlers map[string]api.UpstreamConnectHandler
|
||||
loadErr error
|
||||
loadErrs map[string]error
|
||||
}
|
||||
|
||||
func (a *fakeAdapter) Load(_ context.Context, artifact ArtifactRecord, _ PluginRecord, gateway *Gateway) (api.Plugin, error) {
|
||||
a.loads++
|
||||
if a.loadErr != nil {
|
||||
return nil, a.loadErr
|
||||
}
|
||||
if a.loadErrs != nil && a.loadErrs[artifact.PluginID] != nil {
|
||||
return nil, a.loadErrs[artifact.PluginID]
|
||||
}
|
||||
handler := api.UpstreamConnectHandler(func(api.UpstreamConnectRequest) (net.Conn, error) {
|
||||
return newMemoryConn(), nil
|
||||
})
|
||||
if a.handlers != nil && a.handlers[artifact.PluginID] != nil {
|
||||
handler = a.handlers[artifact.PluginID]
|
||||
}
|
||||
if err := api.RegisterHookHandler(
|
||||
gateway,
|
||||
api.HookUpstreamConnect,
|
||||
func(api.UpstreamConnectRequest) bool { return true },
|
||||
handler,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &fakePlugin{}, nil
|
||||
}
|
||||
|
||||
type fakePlugin struct {
|
||||
api.AbstractPlugin
|
||||
}
|
||||
|
||||
func newMemoryConn() net.Conn {
|
||||
left, right := net.Pipe()
|
||||
_ = right.Close()
|
||||
return left
|
||||
}
|
||||
337
internal/pluginmanager/repository.go
Normal file
337
internal/pluginmanager/repository.go
Normal file
@@ -0,0 +1,337 @@
|
||||
package pluginmanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Repository struct {
|
||||
db *sql.DB
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewRepository(db *sql.DB) Repository {
|
||||
return Repository{
|
||||
db: db,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
func NewRepositoryWithClock(db *sql.DB, now func() time.Time) Repository {
|
||||
repo := NewRepository(db)
|
||||
if now != nil {
|
||||
repo.now = now
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
func (r Repository) SaveArtifact(ctx context.Context, artifact ArtifactRecord) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO plugin_artifacts(
|
||||
id, plugin_id, version, file_name, file_path, sha256, package_sha256, size_bytes,
|
||||
artifact_type, runtime_type, runtime_entry, status, metadata_json,
|
||||
capabilities_summary_json, extension_points_json, api_version, go_version, go_os, go_arch,
|
||||
uploaded_by, error, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
file_name = excluded.file_name,
|
||||
file_path = excluded.file_path,
|
||||
package_sha256 = excluded.package_sha256,
|
||||
status = excluded.status,
|
||||
metadata_json = excluded.metadata_json,
|
||||
capabilities_summary_json = excluded.capabilities_summary_json,
|
||||
extension_points_json = excluded.extension_points_json,
|
||||
uploaded_by = excluded.uploaded_by,
|
||||
error = excluded.error,
|
||||
updated_at = excluded.updated_at`,
|
||||
artifact.ID, artifact.PluginID, artifact.Version, artifact.FileName, artifact.FilePath, artifact.SHA256, artifact.PackageSHA256, artifact.SizeBytes,
|
||||
artifact.ArtifactType, artifact.RuntimeType, artifact.RuntimeEntry, artifact.Status, artifact.MetadataJSON,
|
||||
artifact.CapabilitiesSummaryJSON, artifact.ExtensionPointsJSON, artifact.APIVersion, artifact.GoVersion, artifact.GOOS, artifact.GOARCH,
|
||||
artifact.UploadedBy, artifact.Error, artifact.CreatedAt, artifact.UpdatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r Repository) Artifact(ctx context.Context, id string) (ArtifactRecord, error) {
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, plugin_id, version, file_name, file_path, sha256, package_sha256, size_bytes,
|
||||
artifact_type, runtime_type, runtime_entry, status, metadata_json,
|
||||
capabilities_summary_json, extension_points_json, api_version, go_version, go_os, go_arch,
|
||||
uploaded_by, error, created_at, updated_at
|
||||
FROM plugin_artifacts
|
||||
WHERE id = ?`, id)
|
||||
return scanArtifact(row)
|
||||
}
|
||||
|
||||
func (r Repository) ListArtifacts(ctx context.Context, pluginID string) ([]ArtifactRecord, error) {
|
||||
query := `
|
||||
SELECT id, plugin_id, version, file_name, file_path, sha256, package_sha256, size_bytes,
|
||||
artifact_type, runtime_type, runtime_entry, status, metadata_json,
|
||||
capabilities_summary_json, extension_points_json, api_version, go_version, go_os, go_arch,
|
||||
uploaded_by, error, created_at, updated_at
|
||||
FROM plugin_artifacts`
|
||||
var args []any
|
||||
if pluginID != "" {
|
||||
query += ` WHERE plugin_id = ?`
|
||||
args = append(args, pluginID)
|
||||
}
|
||||
query += ` ORDER BY created_at DESC, id DESC`
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var artifacts []ArtifactRecord
|
||||
for rows.Next() {
|
||||
artifact, err := scanArtifact(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
artifacts = append(artifacts, artifact)
|
||||
}
|
||||
return artifacts, rows.Err()
|
||||
}
|
||||
|
||||
func (r Repository) UpsertDesired(ctx context.Context, actor, pluginID, artifactID, desiredState, configJSON string, priority int) (PluginRecord, error) {
|
||||
if desiredState == "" {
|
||||
desiredState = DesiredDisabled
|
||||
}
|
||||
if configJSON == "" {
|
||||
configJSON = "{}"
|
||||
}
|
||||
if !json.Valid([]byte(configJSON)) {
|
||||
return PluginRecord{}, errors.New("config_json must be valid JSON")
|
||||
}
|
||||
if priority == 0 {
|
||||
priority = DefaultPriority
|
||||
}
|
||||
switch desiredState {
|
||||
case DesiredEnabled, DesiredDisabled, DesiredDeleted:
|
||||
default:
|
||||
return PluginRecord{}, errors.New("invalid desired_state")
|
||||
}
|
||||
artifact, err := r.Artifact(ctx, artifactID)
|
||||
if err != nil {
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
if artifact.PluginID != pluginID {
|
||||
return PluginRecord{}, errors.New("artifact plugin_id does not match")
|
||||
}
|
||||
|
||||
now := r.now().Unix()
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var existing PluginRecord
|
||||
row := tx.QueryRowContext(ctx, `
|
||||
SELECT id, desired_artifact_id, active_artifact_id, loaded_artifact_id, desired_state, runtime_state,
|
||||
priority, config_json, desired_generation, applied_generation, last_error,
|
||||
runtime_summary_json, dispatch_summary_json, created_at, updated_at, updated_by
|
||||
FROM plugins WHERE id = ?`, pluginID)
|
||||
err = scanPluginRow(row, &existing)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
|
||||
nextGeneration := int64(1)
|
||||
createdAt := now
|
||||
if err == nil {
|
||||
nextGeneration = existing.DesiredGeneration + 1
|
||||
createdAt = existing.CreatedAt
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO plugin_config_snapshots(plugin_id, artifact_id, config_json, desired_state, priority, desired_generation, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
existing.ID, existing.DesiredArtifactID, existing.ConfigJSON, existing.DesiredState, existing.Priority, existing.DesiredGeneration, actor, now); err != nil {
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO plugins(
|
||||
id, desired_artifact_id, active_artifact_id, loaded_artifact_id, desired_state, runtime_state,
|
||||
priority, config_json, desired_generation, applied_generation, last_error,
|
||||
runtime_summary_json, dispatch_summary_json, deleted_at, created_at, updated_at, updated_by
|
||||
) VALUES (?, ?, '', '', ?, ?, ?, ?, ?, 0, '', '{}', '{}', 0, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
desired_artifact_id = excluded.desired_artifact_id,
|
||||
desired_state = excluded.desired_state,
|
||||
priority = excluded.priority,
|
||||
config_json = excluded.config_json,
|
||||
desired_generation = excluded.desired_generation,
|
||||
deleted_at = CASE WHEN excluded.desired_state = 'deleted' THEN excluded.updated_at ELSE 0 END,
|
||||
updated_at = excluded.updated_at,
|
||||
updated_by = excluded.updated_by`,
|
||||
pluginID, artifactID, desiredState, RuntimeDisabled, priority, configJSON, nextGeneration, createdAt, now, actor); err != nil {
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
return r.Plugin(ctx, pluginID)
|
||||
}
|
||||
|
||||
func (r Repository) Plugin(ctx context.Context, id string) (PluginRecord, error) {
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, desired_artifact_id, active_artifact_id, loaded_artifact_id, desired_state, runtime_state,
|
||||
priority, config_json, desired_generation, applied_generation, last_error,
|
||||
runtime_summary_json, dispatch_summary_json, created_at, updated_at, updated_by
|
||||
FROM plugins
|
||||
WHERE id = ? AND desired_state <> 'deleted'`, id)
|
||||
var plugin PluginRecord
|
||||
if err := scanPluginRow(row, &plugin); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return PluginRecord{}, ErrPluginNotFound
|
||||
}
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
return plugin, nil
|
||||
}
|
||||
|
||||
func (r Repository) ListPlugins(ctx context.Context) ([]PluginRecord, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, desired_artifact_id, active_artifact_id, loaded_artifact_id, desired_state, runtime_state,
|
||||
priority, config_json, desired_generation, applied_generation, last_error,
|
||||
runtime_summary_json, dispatch_summary_json, created_at, updated_at, updated_by
|
||||
FROM plugins
|
||||
WHERE desired_state <> 'deleted'
|
||||
ORDER BY priority ASC, id ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var plugins []PluginRecord
|
||||
for rows.Next() {
|
||||
var plugin PluginRecord
|
||||
if err := scanPluginRow(rows, &plugin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plugins = append(plugins, plugin)
|
||||
}
|
||||
return plugins, rows.Err()
|
||||
}
|
||||
|
||||
func (r Repository) DesiredEnabled(ctx context.Context) ([]PluginRecord, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, desired_artifact_id, active_artifact_id, loaded_artifact_id, desired_state, runtime_state,
|
||||
priority, config_json, desired_generation, applied_generation, last_error,
|
||||
runtime_summary_json, dispatch_summary_json, created_at, updated_at, updated_by
|
||||
FROM plugins
|
||||
WHERE desired_state = 'enabled'
|
||||
ORDER BY priority ASC, id ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var plugins []PluginRecord
|
||||
for rows.Next() {
|
||||
var plugin PluginRecord
|
||||
if err := scanPluginRow(rows, &plugin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plugins = append(plugins, plugin)
|
||||
}
|
||||
return plugins, rows.Err()
|
||||
}
|
||||
|
||||
func (r Repository) MarkRuntime(ctx context.Context, pluginID, runtimeState, activeArtifactID, loadedArtifactID string, appliedGeneration int64, lastError string, runtimeSummary, dispatchSummary any) error {
|
||||
now := r.now().Unix()
|
||||
runtimeJSON, err := marshalDefaultObject(runtimeSummary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dispatchJSON, err := marshalDefaultObject(dispatchSummary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = r.db.ExecContext(ctx, `
|
||||
UPDATE plugins
|
||||
SET runtime_state = ?, active_artifact_id = ?, loaded_artifact_id = ?, applied_generation = ?,
|
||||
last_error = ?, runtime_summary_json = ?, dispatch_summary_json = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
runtimeState, activeArtifactID, loadedArtifactID, appliedGeneration, lastError, runtimeJSON, dispatchJSON, now, pluginID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r Repository) UpdateArtifactStatus(ctx context.Context, artifactID, status, message string) error {
|
||||
_, err := r.db.ExecContext(ctx, `UPDATE plugin_artifacts SET status = ?, error = ?, updated_at = ? WHERE id = ?`,
|
||||
status, message, r.now().Unix(), artifactID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r Repository) RecordOperation(ctx context.Context, pluginID, artifactID, operation, status, actor, message string, metadata any) error {
|
||||
metadataJSON, err := marshalDefaultObject(metadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = r.db.ExecContext(ctx, `
|
||||
INSERT INTO plugin_operations(plugin_id, artifact_id, operation, status, actor, message, metadata_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
pluginID, artifactID, operation, status, actor, message, metadataJSON, r.now().Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
func (r Repository) DispatchPlan(ctx context.Context) (DispatchPlan, error) {
|
||||
plugins, err := r.ListPlugins(ctx)
|
||||
if err != nil {
|
||||
return DispatchPlan{}, err
|
||||
}
|
||||
plan := DispatchPlan{UpdatedAt: r.now().Unix()}
|
||||
for _, plugin := range plugins {
|
||||
if plugin.DispatchSummaryJSON == "" || plugin.RuntimeState != RuntimeEnabled {
|
||||
continue
|
||||
}
|
||||
var summaries []DispatchHandlerSummary
|
||||
if err := json.Unmarshal([]byte(plugin.DispatchSummaryJSON), &summaries); err == nil {
|
||||
plan.Handlers = append(plan.Handlers, summaries...)
|
||||
}
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanArtifact(row rowScanner) (ArtifactRecord, error) {
|
||||
var artifact ArtifactRecord
|
||||
err := row.Scan(
|
||||
&artifact.ID, &artifact.PluginID, &artifact.Version, &artifact.FileName, &artifact.FilePath, &artifact.SHA256, &artifact.PackageSHA256, &artifact.SizeBytes,
|
||||
&artifact.ArtifactType, &artifact.RuntimeType, &artifact.RuntimeEntry, &artifact.Status, &artifact.MetadataJSON,
|
||||
&artifact.CapabilitiesSummaryJSON, &artifact.ExtensionPointsJSON, &artifact.APIVersion, &artifact.GoVersion, &artifact.GOOS, &artifact.GOARCH,
|
||||
&artifact.UploadedBy, &artifact.Error, &artifact.CreatedAt, &artifact.UpdatedAt,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ArtifactRecord{}, ErrArtifactNotFound
|
||||
}
|
||||
return artifact, err
|
||||
}
|
||||
|
||||
func scanPluginRow(row rowScanner, plugin *PluginRecord) error {
|
||||
return row.Scan(
|
||||
&plugin.ID, &plugin.DesiredArtifactID, &plugin.ActiveArtifactID, &plugin.LoadedArtifactID, &plugin.DesiredState, &plugin.RuntimeState,
|
||||
&plugin.Priority, &plugin.ConfigJSON, &plugin.DesiredGeneration, &plugin.AppliedGeneration, &plugin.LastError,
|
||||
&plugin.RuntimeSummaryJSON, &plugin.DispatchSummaryJSON, &plugin.CreatedAt, &plugin.UpdatedAt, &plugin.UpdatedBy,
|
||||
)
|
||||
}
|
||||
|
||||
func marshalDefaultObject(value any) (string, error) {
|
||||
if value == nil {
|
||||
return "{}", nil
|
||||
}
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(data) == 0 || string(data) == "null" {
|
||||
return "{}", nil
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
233
internal/pluginmanager/types.go
Normal file
233
internal/pluginmanager/types.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package pluginmanager
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tursom/mc-gateway/plugin/api"
|
||||
)
|
||||
|
||||
const (
|
||||
SchemaVersion = "mc-gateway.plugin/v1"
|
||||
APIVersion = "plugin-api/v1"
|
||||
|
||||
ArtifactTypeBinary = "binary"
|
||||
RuntimeGoPlugin = "go-plugin"
|
||||
RuntimeEntry = "plugin.so"
|
||||
|
||||
ExtensionUpstreamConnect = "upstream.connect/v1"
|
||||
|
||||
ArtifactStatusUploaded = "uploaded"
|
||||
ArtifactStatusValidated = "validated"
|
||||
ArtifactStatusLoadable = "loadable"
|
||||
ArtifactStatusLoaded = "loaded"
|
||||
ArtifactStatusRejected = "rejected"
|
||||
ArtifactStatusDeleted = "deleted"
|
||||
|
||||
DesiredEnabled = "enabled"
|
||||
DesiredDisabled = "disabled"
|
||||
DesiredDeleted = "deleted"
|
||||
|
||||
RuntimeNotLoaded = "not_loaded"
|
||||
RuntimeLoaded = "loaded"
|
||||
RuntimeEnabled = "enabled"
|
||||
RuntimeFailed = "failed"
|
||||
RuntimeDisabled = "disabled"
|
||||
|
||||
DefaultPriority = 100
|
||||
DefaultHandlerTimeout = 3 * time.Second
|
||||
DefaultManifestMaxBytes = 256 * 1024
|
||||
DefaultPackageMaxBytes = 64 * 1024 * 1024
|
||||
DefaultPackageMaxEntries = 2048
|
||||
DefaultExtractedMaxBytes = 256 * 1024 * 1024
|
||||
DefaultNonRuntimeMaxBytes = 16 * 1024 * 1024
|
||||
)
|
||||
|
||||
var (
|
||||
ErrArtifactNotFound = errors.New("plugin artifact not found")
|
||||
ErrPluginNotFound = errors.New("plugin not found")
|
||||
)
|
||||
|
||||
type Manifest struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
ArtifactType string `json:"artifact_type"`
|
||||
Runtime RuntimeManifest `json:"runtime"`
|
||||
APIVersion string `json:"api_version"`
|
||||
SDKModule string `json:"sdk_module"`
|
||||
SDKModuleVersion string `json:"sdk_module_version"`
|
||||
GoVersion string `json:"go_version"`
|
||||
GOOS string `json:"go_os"`
|
||||
GOARCH string `json:"go_arch"`
|
||||
ExtensionPoints []ExtensionPoint `json:"extension_points"`
|
||||
Capabilities json.RawMessage `json:"capabilities"`
|
||||
RuntimeLimits RuntimeLimits `json:"runtime_limits"`
|
||||
ConfigSchema json.RawMessage `json:"config_schema"`
|
||||
SupplyChain json.RawMessage `json:"supply_chain"`
|
||||
}
|
||||
|
||||
type RuntimeManifest struct {
|
||||
Type string `json:"type"`
|
||||
Entry string `json:"entry"`
|
||||
EntrySymbol string `json:"entry_symbol"`
|
||||
MetadataSymbol string `json:"metadata_symbol"`
|
||||
}
|
||||
|
||||
type ExtensionPoint struct {
|
||||
Type string `json:"type"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
type RuntimeLimits struct {
|
||||
HandlerTimeoutMS int `json:"handler_timeout_ms"`
|
||||
}
|
||||
|
||||
type ArtifactRecord struct {
|
||||
ID string `json:"id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
Version string `json:"version"`
|
||||
FileName string `json:"file_name"`
|
||||
FilePath string `json:"file_path"`
|
||||
SHA256 string `json:"sha256"`
|
||||
PackageSHA256 string `json:"package_sha256"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
ArtifactType string `json:"artifact_type"`
|
||||
RuntimeType string `json:"runtime_type"`
|
||||
RuntimeEntry string `json:"runtime_entry"`
|
||||
Status string `json:"status"`
|
||||
MetadataJSON string `json:"metadata_json"`
|
||||
CapabilitiesSummaryJSON string `json:"capabilities_summary_json"`
|
||||
ExtensionPointsJSON string `json:"extension_points_json"`
|
||||
APIVersion string `json:"api_version"`
|
||||
GoVersion string `json:"go_version"`
|
||||
GOOS string `json:"go_os"`
|
||||
GOARCH string `json:"go_arch"`
|
||||
UploadedBy string `json:"uploaded_by"`
|
||||
Error string `json:"error"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type PluginRecord struct {
|
||||
ID string `json:"id"`
|
||||
DesiredArtifactID string `json:"desired_artifact_id"`
|
||||
ActiveArtifactID string `json:"active_artifact_id"`
|
||||
LoadedArtifactID string `json:"loaded_artifact_id"`
|
||||
DesiredState string `json:"desired_state"`
|
||||
RuntimeState string `json:"runtime_state"`
|
||||
Priority int `json:"priority"`
|
||||
ConfigJSON string `json:"config_json"`
|
||||
DesiredGeneration int64 `json:"desired_generation"`
|
||||
AppliedGeneration int64 `json:"applied_generation"`
|
||||
LastError string `json:"last_error"`
|
||||
RuntimeSummaryJSON string `json:"runtime_summary_json"`
|
||||
DispatchSummaryJSON string `json:"dispatch_summary_json"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
UpdatedBy string `json:"updated_by"`
|
||||
}
|
||||
|
||||
type OperationRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Operation string `json:"operation"`
|
||||
Status string `json:"status"`
|
||||
Actor string `json:"actor"`
|
||||
Message string `json:"message"`
|
||||
MetadataJSON string `json:"metadata_json"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type ConfigSnapshot struct {
|
||||
ID int64 `json:"id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
ConfigJSON string `json:"config_json"`
|
||||
DesiredState string `json:"desired_state"`
|
||||
Priority int `json:"priority"`
|
||||
DesiredGeneration int64 `json:"desired_generation"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type DispatchPlan struct {
|
||||
Handlers []DispatchHandlerSummary `json:"handlers"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type DispatchHandlerSummary struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Priority int `json:"priority"`
|
||||
HandlerID string `json:"handler_id"`
|
||||
ExtensionPoint string `json:"extension_point"`
|
||||
TimeoutMS int64 `json:"timeout_ms"`
|
||||
Calls uint64 `json:"calls"`
|
||||
Errors uint64 `json:"errors"`
|
||||
Panics uint64 `json:"panics"`
|
||||
Timeouts uint64 `json:"timeouts"`
|
||||
}
|
||||
|
||||
type UpstreamResult struct {
|
||||
Conn net.Conn
|
||||
Handled bool
|
||||
}
|
||||
|
||||
type Gateway struct {
|
||||
PluginID string
|
||||
handleConn func(net.Conn)
|
||||
wg *sync.WaitGroup
|
||||
hooks map[string]any
|
||||
}
|
||||
|
||||
func NewGateway(pluginID string, handleConn func(net.Conn), wg *sync.WaitGroup) *Gateway {
|
||||
return &Gateway{
|
||||
PluginID: pluginID,
|
||||
handleConn: handleConn,
|
||||
wg: wg,
|
||||
hooks: make(map[string]any),
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) RegisteredHooks() map[string]any {
|
||||
copied := make(map[string]any, len(g.hooks))
|
||||
for key, value := range g.hooks {
|
||||
copied[key] = value
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
func (g *Gateway) Hook(hook string, handler any) error {
|
||||
g.hooks[hook] = handler
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Gateway) HandleConn(conn net.Conn) {
|
||||
if g.handleConn != nil {
|
||||
g.handleConn(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) ExitWaitGroup() *sync.WaitGroup {
|
||||
if g.wg == nil {
|
||||
g.wg = &sync.WaitGroup{}
|
||||
}
|
||||
return g.wg
|
||||
}
|
||||
|
||||
func (g *Gateway) LegacyUpstreamHandler() (api.HookHandler[func(net.Conn, string) bool, func(net.Conn, string) (net.Conn, error)], bool) {
|
||||
handler, ok := g.hooks[api.HookUpstream.Key()].(api.HookHandler[func(net.Conn, string) bool, func(net.Conn, string) (net.Conn, error)])
|
||||
return handler, ok
|
||||
}
|
||||
|
||||
func (g *Gateway) UpstreamConnectHandler() (api.HookHandler[api.UpstreamConnectAcceptor, api.UpstreamConnectHandler], bool) {
|
||||
handler, ok := g.hooks[api.HookUpstreamConnect.Key()].(api.HookHandler[api.UpstreamConnectAcceptor, api.UpstreamConnectHandler])
|
||||
return handler, ok
|
||||
}
|
||||
@@ -24,6 +24,9 @@ func TestAbstractPluginDefaults(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHookTypesAndHandlers(t *testing.T) {
|
||||
if got := HookUpstreamConnect.Key(); got != "upstream.connect/v1" {
|
||||
t.Fatalf("HookUpstreamConnect.Key() = %q, want upstream.connect/v1", got)
|
||||
}
|
||||
if got := HookUpstream.Key(); got != "upstream" {
|
||||
t.Fatalf("HookUpstream.Key() = %q, want upstream", got)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"unsafe"
|
||||
@@ -8,6 +9,8 @@ import (
|
||||
|
||||
var (
|
||||
UnsupportedHookType = errors.New("unsupported hook type")
|
||||
ErrPass = errors.New("plugin handler pass")
|
||||
ErrBlocked = errors.New("plugin handler blocked")
|
||||
)
|
||||
|
||||
type (
|
||||
@@ -19,9 +22,28 @@ type (
|
||||
acceptor Accept
|
||||
handler Handler
|
||||
}
|
||||
|
||||
UpstreamConnectRequest struct {
|
||||
Context context.Context
|
||||
Source net.Conn
|
||||
Host string
|
||||
Upstream string
|
||||
InitialData []byte
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
UpstreamConnectAcceptor func(UpstreamConnectRequest) bool
|
||||
UpstreamConnectHandler func(UpstreamConnectRequest) (net.Conn, error)
|
||||
)
|
||||
|
||||
var (
|
||||
HookUpstreamConnect = HookType[
|
||||
UpstreamConnectAcceptor,
|
||||
UpstreamConnectHandler,
|
||||
]{
|
||||
key: "upstream.connect/v1",
|
||||
}
|
||||
|
||||
HookUpstream = HookType[
|
||||
func(source net.Conn, host string) bool,
|
||||
func(source net.Conn, host string) (net.Conn, error),
|
||||
|
||||
Reference in New Issue
Block a user