Compare commits
2 Commits
91eee02243
...
54f5c322e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54f5c322e9 | ||
|
|
f3fb924a3a |
@@ -29,24 +29,28 @@ func newAdminAPIHandler() http.HandlerFunc {
|
||||
|
||||
AuditLogs: handleAdminAuditLogs,
|
||||
|
||||
PluginArtifacts: handleAdminPluginArtifacts,
|
||||
PluginArtifact: handleAdminPluginArtifact,
|
||||
PluginSources: handleAdminPluginSources,
|
||||
PluginBuilds: handleAdminPluginBuilds,
|
||||
PluginBuild: handleAdminPluginBuild,
|
||||
PluginGC: handleAdminPluginGC,
|
||||
PluginOperationsGC: handleAdminPluginOperationsGC,
|
||||
PluginsList: handleAdminPluginsList,
|
||||
PluginItem: handleAdminPluginItem,
|
||||
PluginAction: handleAdminPluginAction,
|
||||
PluginConfig: handleAdminPluginConfig,
|
||||
PluginSecrets: handleAdminPluginSecrets,
|
||||
PluginRollback: handleAdminPluginRollback,
|
||||
PluginOperations: handleAdminPluginOperations,
|
||||
PluginDraining: handleAdminPluginDraining,
|
||||
PluginDispatch: handleAdminPluginDispatchPlan,
|
||||
PluginGovernance: handleAdminPluginGovernance,
|
||||
PluginAdvisories: handleAdminPluginAdvisories,
|
||||
PluginDiagnostics: handleAdminPluginDiagnostics,
|
||||
PluginArtifacts: handleAdminPluginArtifacts,
|
||||
PluginArtifact: handleAdminPluginArtifact,
|
||||
PluginSources: handleAdminPluginSources,
|
||||
PluginBuilds: handleAdminPluginBuilds,
|
||||
PluginBuild: handleAdminPluginBuild,
|
||||
PluginGC: handleAdminPluginGC,
|
||||
PluginOperationsGC: handleAdminPluginOperationsGC,
|
||||
PluginsList: handleAdminPluginsList,
|
||||
PluginItem: handleAdminPluginItem,
|
||||
PluginAction: handleAdminPluginAction,
|
||||
PluginConfig: handleAdminPluginConfig,
|
||||
PluginSecrets: handleAdminPluginSecrets,
|
||||
PluginRollback: handleAdminPluginRollback,
|
||||
PluginOperations: handleAdminPluginOperations,
|
||||
PluginDraining: handleAdminPluginDraining,
|
||||
PluginDispatch: handleAdminPluginDispatchPlan,
|
||||
PluginGovernance: handleAdminPluginGovernance,
|
||||
PluginAdvisories: handleAdminPluginAdvisories,
|
||||
PluginDiagnostics: handleAdminPluginDiagnostics,
|
||||
PluginService: handleAdminPluginService,
|
||||
PluginRepositories: handleAdminPluginRepositories,
|
||||
PluginSupplyChain: handleAdminPluginSupplyChain,
|
||||
PluginInstrumentation: handleAdminPluginInstrumentation,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PluginArtifact, PluginBuild, PluginView, RouteRecord, ServiceRecord, User } from "./types.js";
|
||||
import type { PluginArtifact, PluginBuild, PluginInstrumentation, PluginServiceStatus, PluginView, RouteRecord, ServiceRecord, User } from "./types.js";
|
||||
|
||||
export const tokenStorageKey = "mcGatewayAdminToken";
|
||||
export const languageStorageKey = "mcGatewayAdminLanguage";
|
||||
@@ -14,6 +14,8 @@ export interface AppState {
|
||||
plugins: PluginView[];
|
||||
pluginArtifacts: PluginArtifact[];
|
||||
pluginBuilds: PluginBuild[];
|
||||
pluginService: PluginServiceStatus | null;
|
||||
pluginInstrumentation: PluginInstrumentation[];
|
||||
selectedPluginID: string;
|
||||
selectedArtifactID: string;
|
||||
}
|
||||
@@ -29,6 +31,8 @@ export const state: AppState = {
|
||||
plugins: [],
|
||||
pluginArtifacts: [],
|
||||
pluginBuilds: [],
|
||||
pluginService: null,
|
||||
pluginInstrumentation: [],
|
||||
selectedPluginID: "",
|
||||
selectedArtifactID: "",
|
||||
};
|
||||
|
||||
@@ -117,6 +117,44 @@ export interface PluginProxyConnection {
|
||||
draining: boolean;
|
||||
}
|
||||
|
||||
export interface PluginServiceState {
|
||||
desired_mode: string;
|
||||
active_mode: string;
|
||||
applied_at?: number;
|
||||
restart_required: boolean;
|
||||
live_migration?: string;
|
||||
last_error?: string;
|
||||
updated_by?: string;
|
||||
updated_at?: number;
|
||||
}
|
||||
|
||||
export interface PluginHostRuntimeSummary {
|
||||
plugin_id: string;
|
||||
artifact_id: string;
|
||||
state: string;
|
||||
drain_mode: string;
|
||||
crash_loop: boolean;
|
||||
crash_count: number;
|
||||
last_error?: string;
|
||||
}
|
||||
|
||||
export interface PluginServiceStatus {
|
||||
service: PluginServiceState;
|
||||
hosts?: PluginHostRuntimeSummary[];
|
||||
}
|
||||
|
||||
export interface PluginInstrumentation {
|
||||
id: number;
|
||||
name: string;
|
||||
version: string;
|
||||
profile: string;
|
||||
generated_diff_hash: string;
|
||||
runbook_rollback: string;
|
||||
status: string;
|
||||
created_by?: string;
|
||||
created_at?: number;
|
||||
}
|
||||
|
||||
export interface GovernanceIssue {
|
||||
code: string;
|
||||
severity: string;
|
||||
@@ -176,6 +214,7 @@ export interface PluginView {
|
||||
last_error?: string;
|
||||
runtime_summary?: Record<string, unknown>;
|
||||
dispatch_summary?: unknown[];
|
||||
extension_status?: Record<string, unknown>;
|
||||
capabilities_summary?: Record<string, unknown>;
|
||||
minecraft?: unknown;
|
||||
config_json?: string;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { showAlert } from "../alerts.js";
|
||||
import { badge, el, escapeAttr, escapeHTML, getFormInput } from "../dom.js";
|
||||
import { isAdmin } from "../session.js";
|
||||
import { state } from "../state.js";
|
||||
import type { PluginArtifact, PluginBuild, PluginDryRunResult, PluginOperations, PluginProxyConnection, PluginSecret, PluginSnapshot, PluginView } from "../types.js";
|
||||
import type { PluginArtifact, PluginBuild, PluginDryRunResult, PluginInstrumentation, PluginOperations, PluginProxyConnection, PluginSecret, PluginServiceStatus, PluginSnapshot, PluginView } from "../types.js";
|
||||
|
||||
interface PluginsResponse {
|
||||
plugins?: PluginView[];
|
||||
@@ -17,6 +17,14 @@ interface BuildsResponse {
|
||||
builds?: PluginBuild[];
|
||||
}
|
||||
|
||||
interface PluginServiceResponse {
|
||||
plugin_service?: PluginServiceStatus;
|
||||
}
|
||||
|
||||
interface InstrumentationResponse {
|
||||
instrumentation?: PluginInstrumentation[];
|
||||
}
|
||||
|
||||
interface PluginResponse {
|
||||
plugin?: PluginView;
|
||||
}
|
||||
@@ -42,14 +50,18 @@ interface OperationsResponse {
|
||||
|
||||
export async function loadPlugins(): Promise<void> {
|
||||
try {
|
||||
const [data, artifacts, builds] = await Promise.all([
|
||||
const [data, artifacts, builds, service, instrumentation] = await Promise.all([
|
||||
api<PluginsResponse>("/plugins"),
|
||||
api<ArtifactsResponse>("/plugin-artifacts"),
|
||||
api<BuildsResponse>("/plugin-builds"),
|
||||
api<PluginServiceResponse>("/plugin-service"),
|
||||
api<InstrumentationResponse>("/plugin-instrumentation"),
|
||||
]);
|
||||
state.plugins = data.plugins || [];
|
||||
state.pluginArtifacts = artifacts.artifacts || [];
|
||||
state.pluginBuilds = builds.builds || [];
|
||||
state.pluginService = service.plugin_service || null;
|
||||
state.pluginInstrumentation = instrumentation.instrumentation || [];
|
||||
const firstPlugin = state.plugins[0];
|
||||
if (!state.selectedPluginID && firstPlugin) {
|
||||
state.selectedPluginID = firstPlugin.id;
|
||||
@@ -68,6 +80,7 @@ export async function loadPlugins(): Promise<void> {
|
||||
export function renderPlugins(): void {
|
||||
const managed = new Set(state.plugins.map((plugin) => plugin.id));
|
||||
const unmanagedArtifacts = state.pluginArtifacts.filter((artifact) => !managed.has(artifact.plugin_id));
|
||||
renderPluginServicePanel();
|
||||
el("pluginsBody").innerHTML = state.plugins.map((plugin) => `
|
||||
<tr class="${plugin.id === state.selectedPluginID ? "selected" : ""}">
|
||||
<td><button class="link-button" type="button" data-plugin-detail="${escapeAttr(plugin.id)}">${escapeHTML(plugin.id)}</button></td>
|
||||
@@ -216,6 +229,10 @@ export function renderPluginDetail(plugin: PluginView | null = selectedPlugin())
|
||||
<h3>Dispatch plan</h3>
|
||||
<pre class="log-output">${escapeHTML(formatJSON(plugin.dispatch_summary || []))}</pre>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Extension status</h3>
|
||||
<pre class="log-output">${escapeHTML(formatJSON(plugin.extension_status || {}))}</pre>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Operations</h3>
|
||||
<div class="row-actions">
|
||||
@@ -239,6 +256,83 @@ export function bindPluginEvents(): void {
|
||||
el<HTMLButtonElement>("refreshPluginsBtn").addEventListener("click", loadPlugins);
|
||||
}
|
||||
|
||||
function renderPluginServicePanel(): void {
|
||||
const container = document.getElementById("pluginServicePanel");
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const service = state.pluginService?.service;
|
||||
const canWrite = isAdmin();
|
||||
container.innerHTML = `
|
||||
<section class="panel">
|
||||
<div class="detail-header compact">
|
||||
<div>
|
||||
<h3>Plugin Service</h3>
|
||||
<p>${service ? `active ${escapeHTML(service.active_mode)} · desired ${escapeHTML(service.desired_mode)}` : "not loaded"}</p>
|
||||
</div>
|
||||
${service ? badge(service.restart_required ? "restart required" : "applied", service.restart_required) : ""}
|
||||
</div>
|
||||
${service ? `
|
||||
<div class="status-grid dense">
|
||||
${detailStat("Desired mode", service.desired_mode)}
|
||||
${detailStat("Active mode", service.active_mode)}
|
||||
${detailStat("Migration", service.live_migration || "drain-only")}
|
||||
${detailStat("Restart", service.restart_required ? "required" : "not required")}
|
||||
</div>
|
||||
${service.last_error ? `<div class="alert inline-alert">${escapeHTML(service.last_error)}</div>` : ""}
|
||||
${canWrite ? `
|
||||
<form id="pluginServiceForm" class="inline-form">
|
||||
<select name="desired_mode">
|
||||
${["in-process", "go-plugin-process", "sandbox-process"].map((mode) => `<option value="${mode}" ${mode === service.desired_mode ? "selected" : ""}>${mode}</option>`).join("")}
|
||||
</select>
|
||||
<button type="submit">Set desired</button>
|
||||
</form>
|
||||
` : ""}
|
||||
` : ""}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Build-Time Instrumentation</h3>
|
||||
${instrumentationList(state.pluginInstrumentation)}
|
||||
</section>
|
||||
`;
|
||||
const form = document.getElementById("pluginServiceForm");
|
||||
if (form instanceof HTMLFormElement) {
|
||||
form.addEventListener("submit", updatePluginServiceMode);
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePluginServiceMode(event: Event): Promise<void> {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget as HTMLFormElement;
|
||||
try {
|
||||
await api("/plugin-service", {
|
||||
method: "PUT",
|
||||
body: { desired_mode: getFormInput(form, "desired_mode") },
|
||||
});
|
||||
await loadPlugins();
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function instrumentationList(records: PluginInstrumentation[]): string {
|
||||
if (!records.length) {
|
||||
return `<p class="muted">No instrumentation metadata</p>`;
|
||||
}
|
||||
return `<table class="mini-table">
|
||||
<thead><tr><th>Name</th><th>Profile</th><th>Status</th><th>Diff</th><th>Rollback</th></tr></thead>
|
||||
<tbody>${records.map((record) => `
|
||||
<tr>
|
||||
<td>${escapeHTML(record.name)} ${escapeHTML(record.version || "")}</td>
|
||||
<td>${escapeHTML(record.profile || "")}</td>
|
||||
<td>${badge(record.status || "available", record.status === "blocked")}</td>
|
||||
<td>${escapeHTML(shortID(record.generated_diff_hash || ""))}</td>
|
||||
<td>${escapeHTML(record.runbook_rollback || "")}</td>
|
||||
</tr>
|
||||
`).join("")}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
async function uploadPluginPackage(event: Event): Promise<void> {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
@@ -791,14 +792,47 @@ func handleAdminPluginDraining(w http.ResponseWriter, r *http.Request, rawPlugin
|
||||
}
|
||||
|
||||
func handleAdminPluginDispatchPlan(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireRole(w, r, adminRoleMember); !ok {
|
||||
session, ok := requireRole(w, r, adminRoleMember)
|
||||
if !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())})
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"dispatch_plan": pluginsManager.DispatchPlan(r.Context())})
|
||||
case http.MethodPost:
|
||||
if session.Role != adminRoleAdmin {
|
||||
adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
if !adminhttp.DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
switch req.Action {
|
||||
case "refresh-routes":
|
||||
cache := pluginsManager.RefreshRouteProviders(r.Context(), session.Username)
|
||||
recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_route_provider_refresh", "plugin_dispatch", "", true, "route provider cache refreshed", map[string]any{"cache_entries": len(cache)})
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"route_cache": cache})
|
||||
case "replay-subscribers":
|
||||
count := pluginsManager.ReplaySubscriberDeadLetters(r.Context(), session.Username)
|
||||
recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_event_subscriber_replay", "plugin_dispatch", "", true, "event subscriber dead letters replay requested", map[string]any{"replayed": count})
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"replayed": count})
|
||||
case "drop-subscriber-dead-letter":
|
||||
count := pluginsManager.DropSubscriberDeadLetters(r.Context(), session.Username)
|
||||
recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_event_subscriber_drop", "plugin_dispatch", "", true, "event subscriber dead letters dropped", map[string]any{"dropped": count})
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"dropped": count})
|
||||
default:
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, "unknown dispatch action")
|
||||
}
|
||||
default:
|
||||
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func handleAdminPluginGovernance(w http.ResponseWriter, r *http.Request, rawSegment string) {
|
||||
@@ -1157,6 +1191,7 @@ func pluginView(r *http.Request, plugin pluginmanager.PluginRecord, detail bool)
|
||||
"runtime_summary": jsonObjectString(plugin.RuntimeSummaryJSON),
|
||||
"dispatch_summary": jsonArrayString(plugin.DispatchSummaryJSON),
|
||||
"capabilities_summary": jsonObjectString(desiredArtifact.CapabilitiesSummaryJSON),
|
||||
"extension_status": pluginExtensionStatus(r.Context(), plugin.ID),
|
||||
"minecraft": pluginMinecraftSummary(desiredArtifact),
|
||||
"config_json": plugin.ConfigJSON,
|
||||
"config_schema": jsonObjectString(manifestConfigSchemaString(manifest)),
|
||||
@@ -1185,6 +1220,33 @@ func pluginView(r *http.Request, plugin pluginmanager.PluginRecord, detail bool)
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func pluginExtensionStatus(ctx context.Context, pluginID string) map[string]any {
|
||||
plan := pluginsManager.DispatchPlan(ctx)
|
||||
filter := func(items []pluginmanager.DispatchHandlerSummary) []pluginmanager.DispatchHandlerSummary {
|
||||
var out []pluginmanager.DispatchHandlerSummary
|
||||
for _, item := range items {
|
||||
if item.PluginID == pluginID {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
var providers []pluginmanager.ProviderSummary
|
||||
for _, provider := range plan.Providers {
|
||||
if provider.PluginID == pluginID {
|
||||
providers = append(providers, provider)
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"routes": filter(plan.Routes),
|
||||
"statuses": filter(plan.Statuses),
|
||||
"middleware": filter(plan.Middleware),
|
||||
"subscribers": filter(plan.Subscribers),
|
||||
"providers": providers,
|
||||
"route_cache": plan.RouteCache,
|
||||
}
|
||||
}
|
||||
|
||||
func pluginManifest(artifact pluginmanager.ArtifactRecord) pluginmanager.Manifest {
|
||||
var manifest pluginmanager.Manifest
|
||||
_ = json.Unmarshal([]byte(artifact.MetadataJSON), &manifest)
|
||||
@@ -1284,3 +1346,186 @@ func writePluginManagerError(w http.ResponseWriter, err error) {
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func handleAdminPluginService(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:
|
||||
status, err := pluginsManager.PluginServiceStatus(r.Context())
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"plugin_service": status})
|
||||
case http.MethodPut:
|
||||
if session.Role != adminRoleAdmin {
|
||||
adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
var req adminhttp.PluginServiceRequest
|
||||
if !adminhttp.DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
state, err := pluginsManager.SetPluginServiceDesired(r.Context(), session.Username, req.DesiredMode)
|
||||
if err != nil {
|
||||
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_service_mode_update", "plugin_service", "", false, err.Error())
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_service_mode_update", "plugin_service", "", true, "plugin service mode desired state updated", map[string]any{
|
||||
"desired_mode": state.DesiredMode,
|
||||
"active_mode": state.ActiveMode,
|
||||
"restart_required": state.RestartRequired,
|
||||
})
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"plugin_service": state})
|
||||
case http.MethodPost:
|
||||
if session.Role != adminRoleAdmin {
|
||||
adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
if err := pluginsManager.ApplyPluginServiceMode(r.Context()); err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
status, err := pluginsManager.PluginServiceStatus(r.Context())
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"plugin_service": status})
|
||||
default:
|
||||
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func handleAdminPluginRepositories(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:
|
||||
imports, err := pluginsManager.ListRepositoryImports(r.Context())
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"imports": imports})
|
||||
case http.MethodPost:
|
||||
if session.Role != adminRoleAdmin {
|
||||
adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
var req adminhttp.PluginRepositoryImportRequest
|
||||
if !adminhttp.DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
record, artifact, err := pluginsManager.ImportRepositoryArtifact(r.Context(), session.Username, pluginmanager.RepositoryImportRequest{
|
||||
RepositoryType: req.RepositoryType,
|
||||
IndexPath: req.IndexPath,
|
||||
ArtifactID: req.ArtifactID,
|
||||
PluginID: req.PluginID,
|
||||
Version: req.Version,
|
||||
TrustPolicy: req.TrustPolicy,
|
||||
})
|
||||
if err != nil {
|
||||
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_repository_import", "plugin_repository", req.IndexPath, false, err.Error())
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_repository_import", "plugin_artifact", artifact.ID, true, "repository artifact imported locally", map[string]any{
|
||||
"plugin_id": artifact.PluginID,
|
||||
"version": artifact.Version,
|
||||
"auto_enable": false,
|
||||
"import_id": record.ID,
|
||||
})
|
||||
adminhttp.WriteJSON(w, http.StatusCreated, map[string]any{"import": record, "artifact": artifact})
|
||||
default:
|
||||
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func handleAdminPluginSupplyChain(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:
|
||||
assessments, err := pluginsManager.ListSupplyChainAssessments(r.Context(), r.URL.Query().Get("plugin_id"), r.URL.Query().Get("artifact_id"))
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"assessments": assessments})
|
||||
case http.MethodPost:
|
||||
if session.Role != adminRoleAdmin {
|
||||
adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
var req adminhttp.PluginSupplyChainRequest
|
||||
if !adminhttp.DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
assessment, err := pluginsManager.AssessSupplyChain(r.Context(), session.Username, req.PluginID, req.ArtifactID, req.Metadata)
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
adminhttp.WriteJSON(w, http.StatusCreated, map[string]any{"assessment": assessment})
|
||||
default:
|
||||
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func handleAdminPluginInstrumentation(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:
|
||||
records, err := pluginsManager.ListInstrumentation(r.Context())
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"instrumentation": records})
|
||||
case http.MethodPost:
|
||||
if session.Role != adminRoleAdmin {
|
||||
adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
var req pluginmanager.InstrumentationRequest
|
||||
if !adminhttp.DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
record, err := pluginsManager.SaveInstrumentation(r.Context(), session.Username, req)
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
adminhttp.WriteJSON(w, http.StatusCreated, map[string]any{"instrumentation": record})
|
||||
default:
|
||||
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
</label>
|
||||
<button id="refreshPluginsBtn" class="secondary" type="button" data-i18n="refresh">Refresh</button>
|
||||
</div>
|
||||
<div id="pluginServicePanel" class="plugin-service-panel"></div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
|
||||
@@ -3,9 +3,11 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -251,6 +253,81 @@ func TestHandleRequestProtocolProxyDisableSkipsNewConnections(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestRouteResolverUsesOverrideAndSQLiteFallback(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
setGatewayTestRoutes(map[string]string{"fallback.example": "fallback-upstream:25565"})
|
||||
var dialed []string
|
||||
pluginsManager = pluginmanager.New(pluginmanager.Options{
|
||||
DB: newGatewayTestPluginDB(t),
|
||||
ArtifactRoot: t.TempDir(),
|
||||
Adapter: gatewayTestPluginAdapter{initHook: func(gateway *pluginmanager.Gateway) error {
|
||||
return api.RegisterHookHandler(gateway, api.HookRouteResolve,
|
||||
func(api.RouteResolveRequest) bool { return true },
|
||||
func(req api.RouteResolveRequest) (api.RouteDecision, error) {
|
||||
if req.Host == "override.example" {
|
||||
return api.RouteDecision{Action: api.RouteDecisionOverride, Upstream: "override-upstream:25565", CacheTTL: time.Minute}, nil
|
||||
}
|
||||
return api.RouteDecision{Action: api.RouteDecisionPass}, nil
|
||||
})
|
||||
}},
|
||||
})
|
||||
artifact := uploadGatewayTestArtifactWithManifest(t, pluginsManager, "route-plugin", func(manifest *pluginmanager.Manifest) {
|
||||
manifest.ExtensionPoints = []pluginmanager.ExtensionPoint{{Type: "provider", Key: pluginmanager.ExtensionRouteResolve}}
|
||||
manifest.Capabilities = json.RawMessage(`{"extension_points":["route.resolve/v1"]}`)
|
||||
})
|
||||
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "route-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
if _, err := pluginsManager.Enable(context.Background(), "admin", "route-plugin"); err != nil {
|
||||
t.Fatalf("Enable() error = %v", err)
|
||||
}
|
||||
registerGatewayUpstreamHook(t, func(net.Conn, string) bool { return true }, func(_ net.Conn, host string) (net.Conn, error) {
|
||||
dialed = append(dialed, host)
|
||||
return newGatewayTestConn(nil), nil
|
||||
})
|
||||
|
||||
handleRequest(newGatewayTestConn(gatewayTestPacket("override.example")))
|
||||
handleRequest(newGatewayTestConn(gatewayTestPacket("fallback.example")))
|
||||
|
||||
if len(dialed) != 2 || dialed[0] != "override-upstream:25565" || dialed[1] != "fallback-upstream:25565" {
|
||||
t.Fatalf("dialed = %+v, want override then sqlite fallback", dialed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestStatusPingPluginRespondsPerHost(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
pluginsManager = pluginmanager.New(pluginmanager.Options{
|
||||
DB: newGatewayTestPluginDB(t),
|
||||
ArtifactRoot: t.TempDir(),
|
||||
Adapter: gatewayTestPluginAdapter{initHook: func(gateway *pluginmanager.Gateway) error {
|
||||
return api.RegisterHookHandler(gateway, api.HookStatusPing,
|
||||
func(api.StatusPingRequest) bool { return true },
|
||||
func(req api.StatusPingRequest) (api.StatusPingResponse, error) {
|
||||
return api.StatusPingResponse{MOTD: "hello " + req.Host, VersionText: "phase7", MaxPlayers: 100}, nil
|
||||
})
|
||||
}},
|
||||
})
|
||||
artifact := uploadGatewayTestArtifactWithManifest(t, pluginsManager, "status-plugin", func(manifest *pluginmanager.Manifest) {
|
||||
manifest.ExtensionPoints = []pluginmanager.ExtensionPoint{{Type: "hook", Key: pluginmanager.ExtensionStatusPing}}
|
||||
manifest.Capabilities = json.RawMessage(`{"extension_points":["status.ping/v1"]}`)
|
||||
})
|
||||
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "status-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
if _, err := pluginsManager.Enable(context.Background(), "admin", "status-plugin"); err != nil {
|
||||
t.Fatalf("Enable() error = %v", err)
|
||||
}
|
||||
|
||||
source := newGatewayTestConn(gatewayTestPacket("status.example", 0x63, 0x01))
|
||||
handleRequest(source)
|
||||
|
||||
if got := source.writeBuf.String(); !strings.Contains(got, "hello status.example") {
|
||||
t.Fatalf("status response = %q, want host MOTD", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestRecoversAndClosesConnection(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/tursom/mc-gateway/internal/pluginmanager"
|
||||
"github.com/tursom/mc-gateway/internal/upstreamtarget"
|
||||
"github.com/tursom/mc-gateway/plugin/api"
|
||||
"github.com/tursom/mc-gateway/protocol"
|
||||
@@ -90,6 +91,22 @@ func handleRequest(conn net.Conn) {
|
||||
}
|
||||
|
||||
func mapToHost(conn net.Conn) net.Conn {
|
||||
if pluginsManager != nil {
|
||||
transport, _, _ := connectionIngress(conn)
|
||||
filter, err := pluginsManager.FilterConnection(context.Background(), api.ConnectionFilterRequest{
|
||||
SourceAddr: conn.RemoteAddr().String(),
|
||||
Transport: transport,
|
||||
})
|
||||
if err != nil {
|
||||
log.Err(err).Str("client", conn.RemoteAddr().String()).Msg("connection filter failed")
|
||||
return nil
|
||||
}
|
||||
if !filter.Allowed {
|
||||
log.Info().Str("client", conn.RemoteAddr().String()).Str("plugin", filter.PluginID).Str("reason", filter.Reason).Msg("connection rejected by filter")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
buf := getProxyBuffer()
|
||||
defer putProxyBuffer(buf)
|
||||
|
||||
@@ -116,12 +133,44 @@ func mapToHost(conn net.Conn) net.Conn {
|
||||
return nil
|
||||
}
|
||||
|
||||
host, ok := lookupRoute(handshake.ServerHost)
|
||||
if host == "" {
|
||||
if pluginsManager != nil {
|
||||
filter, err := pluginsManager.FilterHandshake(context.Background(), api.HandshakeFilterRequest{
|
||||
SourceAddr: conn.RemoteAddr().String(),
|
||||
ServerHost: handshake.ServerHost,
|
||||
RawServerHost: handshake.RawServerHost,
|
||||
ProtocolVersion: handshake.ProtocolVersion,
|
||||
NextState: handshake.NextState,
|
||||
})
|
||||
if err != nil {
|
||||
log.Err(err).Str("client", conn.RemoteAddr().String()).Str("host", handshake.ServerHost).Msg("handshake filter failed")
|
||||
return nil
|
||||
}
|
||||
if !filter.Allowed {
|
||||
log.Info().Str("client", conn.RemoteAddr().String()).Str("host", handshake.ServerHost).Str("plugin", filter.PluginID).Str("reason", filter.Reason).Msg("handshake rejected by filter")
|
||||
return nil
|
||||
}
|
||||
if filter.RewriteHost != "" && filter.RewriteHost != handshake.ServerHost {
|
||||
initialData = protocol.ReplaceMcHost(initialData, filter.RewriteHost)
|
||||
handshake = protocol.ParseHandshake(initialData)
|
||||
}
|
||||
}
|
||||
|
||||
if handshake.NextState == 1 {
|
||||
if handled := handleStatusPing(conn, handshake); handled {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
routeResult := resolveGatewayRoute(conn, handshake)
|
||||
host := routeResult.Decision.Upstream
|
||||
ok := routeResult.Source != "fallback_miss"
|
||||
if routeResult.Decision.Action == api.RouteDecisionReject || host == "" {
|
||||
gatewayMetrics.RouteMiss()
|
||||
log.Err(errEmptyBuffer).
|
||||
Str("client", conn.RemoteAddr().String()).
|
||||
Str("host", handshake.ServerHost).
|
||||
Str("route_source", routeResult.Source).
|
||||
Str("route_action", routeResult.Decision.Action).
|
||||
Msg("failed to route host")
|
||||
return nil
|
||||
}
|
||||
@@ -205,6 +254,88 @@ func mapToHost(conn net.Conn) net.Conn {
|
||||
return client
|
||||
}
|
||||
|
||||
func resolveGatewayRoute(conn net.Conn, handshake protocol.Handshake) pluginmanager.RouteResolveResult {
|
||||
upstream, hit := lookupRoute(handshake.ServerHost)
|
||||
req := api.RouteResolveRequest{
|
||||
Host: handshake.ServerHost,
|
||||
RawServerHost: handshake.RawServerHost,
|
||||
SourceAddr: conn.RemoteAddr().String(),
|
||||
ProtocolVersion: handshake.ProtocolVersion,
|
||||
NextState: handshake.NextState,
|
||||
FallbackUpstream: upstream,
|
||||
FallbackHit: hit,
|
||||
Handshake: api.UpstreamHandshakeRef{
|
||||
ServerHost: handshake.ServerHost,
|
||||
RawServerHost: handshake.RawServerHost,
|
||||
ProtocolVersion: handshake.ProtocolVersion,
|
||||
NextState: handshake.NextState,
|
||||
},
|
||||
}
|
||||
if pluginsManager != nil {
|
||||
result, err := pluginsManager.ResolveRoute(context.Background(), req, func(req api.RouteResolveRequest) (string, bool) {
|
||||
return lookupRoute(req.Host)
|
||||
})
|
||||
if err == nil {
|
||||
return result
|
||||
}
|
||||
log.Err(err).Str("client", conn.RemoteAddr().String()).Str("host", handshake.ServerHost).Msg("route resolver failed")
|
||||
}
|
||||
action := api.RouteDecisionFallback
|
||||
source := "sqlite_fallback"
|
||||
if upstream == "" {
|
||||
action = api.RouteDecisionReject
|
||||
source = "fallback_miss"
|
||||
}
|
||||
return pluginmanager.RouteResolveResult{
|
||||
Decision: api.RouteDecision{Action: action, Upstream: upstream, ProviderID: "sqlite", Reason: "sqlite route snapshot fallback"},
|
||||
Source: source,
|
||||
}
|
||||
}
|
||||
|
||||
func handleStatusPing(conn net.Conn, handshake protocol.Handshake) bool {
|
||||
if pluginsManager == nil {
|
||||
return false
|
||||
}
|
||||
result, err := pluginsManager.StatusPing(context.Background(), api.StatusPingRequest{
|
||||
Host: handshake.ServerHost,
|
||||
RawServerHost: handshake.RawServerHost,
|
||||
SourceAddr: conn.RemoteAddr().String(),
|
||||
ProtocolVersion: handshake.ProtocolVersion,
|
||||
})
|
||||
if err != nil || !result.Handled {
|
||||
if err != nil {
|
||||
log.Err(err).Str("host", handshake.ServerHost).Msg("status ping plugin failed")
|
||||
}
|
||||
return false
|
||||
}
|
||||
payload := map[string]any{
|
||||
"description": map[string]any{"text": result.Response.MOTD},
|
||||
"players": map[string]any{
|
||||
"online": result.Response.OnlinePlayers,
|
||||
"max": result.Response.MaxPlayers,
|
||||
},
|
||||
"version": map[string]any{
|
||||
"name": result.Response.VersionText,
|
||||
"protocol": result.Response.ProtocolVersion,
|
||||
},
|
||||
}
|
||||
if result.Response.Favicon != "" {
|
||||
payload["favicon"] = result.Response.Favicon
|
||||
}
|
||||
if result.Response.Maintenance {
|
||||
payload["maintenance"] = map[string]any{"window": result.Response.MaintenanceWindow}
|
||||
}
|
||||
packet, err := protocol.StatusResponsePacket(payload)
|
||||
if err != nil {
|
||||
log.Err(err).Str("host", handshake.ServerHost).Msg("failed to build status response")
|
||||
return true
|
||||
}
|
||||
if err := writeAll(conn, packet); err != nil {
|
||||
log.Err(err).Str("host", handshake.ServerHost).Msg("failed to write status response")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func newUpstreamConnectRequest(conn net.Conn, upstream string, handshake protocol.Handshake, initialData []byte, routeHit bool) api.UpstreamConnectRequest {
|
||||
target := upstreamtarget.Parse(upstream)
|
||||
transport, serviceName, listenerPort := connectionIngress(conn)
|
||||
|
||||
@@ -227,7 +227,8 @@ func TestMapToHostClosesUpstreamWhenInitialWriteFails(t *testing.T) {
|
||||
}
|
||||
|
||||
type gatewayTestPluginAdapter struct {
|
||||
handler api.UpstreamConnectHandler
|
||||
handler api.UpstreamConnectHandler
|
||||
initHook func(*pluginmanager.Gateway) error
|
||||
}
|
||||
|
||||
func (a gatewayTestPluginAdapter) Load(_ context.Context, _ pluginmanager.ArtifactRecord, _ pluginmanager.PluginRecord, gateway *pluginmanager.Gateway) (api.Plugin, error) {
|
||||
@@ -237,12 +238,16 @@ func (a gatewayTestPluginAdapter) Load(_ context.Context, _ pluginmanager.Artifa
|
||||
return nil, api.ErrPass
|
||||
}
|
||||
}
|
||||
if err := api.RegisterHookHandler(
|
||||
gateway,
|
||||
api.HookUpstreamConnect,
|
||||
func(api.UpstreamConnectRequest) bool { return true },
|
||||
handler,
|
||||
); err != nil {
|
||||
if a.initHook == nil {
|
||||
if err := api.RegisterHookHandler(
|
||||
gateway,
|
||||
api.HookUpstreamConnect,
|
||||
func(api.UpstreamConnectRequest) bool { return true },
|
||||
handler,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if err := a.initHook(gateway); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &gatewayPluginStub{}, nil
|
||||
@@ -279,6 +284,33 @@ func uploadGatewayTestArtifactWithCapabilities(t *testing.T, manager *pluginmana
|
||||
return artifact
|
||||
}
|
||||
|
||||
func uploadGatewayTestArtifactWithManifest(t *testing.T, manager *pluginmanager.Manager, pluginID string, mutate func(*pluginmanager.Manifest)) pluginmanager.ArtifactRecord {
|
||||
t.Helper()
|
||||
var manifest pluginmanager.Manifest
|
||||
if err := json.Unmarshal(gatewayTestManifest(t, pluginID), &manifest); err != nil {
|
||||
t.Fatalf("Unmarshal manifest error = %v", err)
|
||||
}
|
||||
if mutate != nil {
|
||||
mutate(&manifest)
|
||||
}
|
||||
data, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal manifest error = %v", err)
|
||||
}
|
||||
artifact, err := manager.UploadArtifact(context.Background(), pluginmanager.ArtifactUpload{
|
||||
SourcePath: writeGatewayTestMCGPEntries(t, map[string][]byte{
|
||||
"manifest.json": data,
|
||||
"plugin.so": []byte("fake plugin bytes " + pluginID),
|
||||
}),
|
||||
FileName: pluginID + ".mcgp",
|
||||
Actor: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UploadArtifact() error = %v", err)
|
||||
}
|
||||
return artifact
|
||||
}
|
||||
|
||||
func approveGatewayPluginGovernanceForTest(t *testing.T, pluginID, artifactID string) {
|
||||
t.Helper()
|
||||
if _, err := pluginsManager.CreateReview(context.Background(), "admin", pluginID, pluginmanager.GovernanceReviewRequest{
|
||||
|
||||
@@ -114,3 +114,10 @@
|
||||
- status 插件 disable 后恢复默认 status。
|
||||
- event subscriber disable 后只停止外部投递,不删除本地审计。
|
||||
- rule 插件冲突时通过 priority/scope 修复或禁用。
|
||||
|
||||
## 实现说明
|
||||
|
||||
- Route/status/middleware/provider/event subscriber 仍复用插件 `Gateway.Hook` 注册模型,新增 typed SDK 结构保持和 `upstream.connect/v1` 一致。
|
||||
- 官方 rule/policy 以内置官方插件 `official.rule-policy` 提供,管理员启用后通过插件配置完成 host rewrite、source CIDR allow/deny、simple rate limit、maintenance mode 和 upstream rewrite。
|
||||
- Admin auth provider 当前作为 provider registry 能力预留和展示,不进入 MC 连接路径,也不替代本地 admin break-glass 登录。
|
||||
- Route provider 失败时优先使用 provider cache,未命中时回退到 SQLite route snapshot。
|
||||
|
||||
10
examples/plugins/extension-ecosystem/README.md
Normal file
10
examples/plugins/extension-ecosystem/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Extension Ecosystem Example
|
||||
|
||||
This example demonstrates phase 7 extension points:
|
||||
|
||||
- `route.resolve/v1` returns `override`, `reject`, or `pass`.
|
||||
- `status.ping/v1` returns host-specific MOTD text.
|
||||
- `event.subscriber/v1` receives asynchronous plugin events.
|
||||
- `admin.auth.provider/v1` registers an unavailable external provider while preserving local admin fallback.
|
||||
|
||||
It is intended as a conformance fixture and source example for plugin authors.
|
||||
8
examples/plugins/extension-ecosystem/conformance.json
Normal file
8
examples/plugins/extension-ecosystem/conformance.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"route_decisions": ["override", "fallback", "reject", "pass"],
|
||||
"status_hosts": ["blue.example", "red.example"],
|
||||
"event_delivery": ["best_effort", "at_least_once", "dead_letter"],
|
||||
"provider_registry": ["singleton", "priority", "fallback", "admin.auth.provider/v1"],
|
||||
"default_route_must_survive_bad_rule_config": true,
|
||||
"local_admin_break_glass": true
|
||||
}
|
||||
35
examples/plugins/extension-ecosystem/manifest.json
Normal file
35
examples/plugins/extension-ecosystem/manifest.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"schema_version": "mc-gateway.plugin/v1",
|
||||
"id": "extension-ecosystem-example",
|
||||
"name": "Extension Ecosystem Example",
|
||||
"version": "0.1.0",
|
||||
"description": "Example fixture for route, status, subscriber and provider extension points.",
|
||||
"artifact_type": "binary",
|
||||
"runtime": {
|
||||
"type": "go-plugin",
|
||||
"entry": "plugin.so",
|
||||
"entry_symbol": "Plugin",
|
||||
"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.0",
|
||||
"go_os": "linux",
|
||||
"go_arch": "amd64",
|
||||
"extension_points": [
|
||||
{ "type": "provider", "key": "route.resolve/v1" },
|
||||
{ "type": "hook", "key": "status.ping/v1" },
|
||||
{ "type": "event", "key": "event.subscriber/v1" },
|
||||
{ "type": "provider", "key": "admin.auth.provider/v1" }
|
||||
],
|
||||
"capabilities": {
|
||||
"extension_points": ["route.resolve/v1", "status.ping/v1", "event.subscriber/v1", "admin.auth.provider/v1"],
|
||||
"route": { "cache_ttl_ms": 60000 },
|
||||
"status": { "hosts": ["blue.example", "red.example"] },
|
||||
"event_subscriber": { "mode": "at_least_once", "max_retry": 3 },
|
||||
"providers": [{ "type": "admin.auth.provider/v1", "name": "external-identity", "fallback": true }]
|
||||
},
|
||||
"runtime_limits": { "handler_timeout_ms": 1000 },
|
||||
"config_schema": { "type": "object" }
|
||||
}
|
||||
@@ -358,6 +358,64 @@ CREATE TABLE IF NOT EXISTS plugin_diagnostics (
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_service_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
desired_mode TEXT NOT NULL DEFAULT 'in-process',
|
||||
active_mode TEXT NOT NULL DEFAULT 'in-process',
|
||||
applied_at INTEGER NOT NULL DEFAULT 0,
|
||||
live_migration TEXT NOT NULL DEFAULT 'drain-only',
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
updated_by TEXT NOT NULL DEFAULT '',
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_repository_imports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
repository_type TEXT NOT NULL,
|
||||
index_path TEXT NOT NULL DEFAULT '',
|
||||
repository_name TEXT NOT NULL DEFAULT '',
|
||||
candidate_id TEXT NOT NULL DEFAULT '',
|
||||
plugin_id TEXT NOT NULL DEFAULT '',
|
||||
version TEXT NOT NULL DEFAULT '',
|
||||
artifact_id TEXT NOT NULL DEFAULT '',
|
||||
package_sha256 TEXT NOT NULL DEFAULT '',
|
||||
trust_policy TEXT NOT NULL DEFAULT '',
|
||||
admission_json TEXT NOT NULL DEFAULT '{}',
|
||||
imported_by TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_supply_chain_assessments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id TEXT NOT NULL,
|
||||
artifact_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'allowed',
|
||||
issues_json TEXT NOT NULL DEFAULT '[]',
|
||||
signature_json TEXT NOT NULL DEFAULT '{}',
|
||||
sbom_json TEXT NOT NULL DEFAULT '{}',
|
||||
license_json TEXT NOT NULL DEFAULT '{}',
|
||||
advisory_json TEXT NOT NULL DEFAULT '{}',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_instrumentation (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
version TEXT NOT NULL DEFAULT '',
|
||||
profile TEXT NOT NULL DEFAULT '',
|
||||
generated_diff_hash TEXT NOT NULL DEFAULT '',
|
||||
provenance_json TEXT NOT NULL DEFAULT '{}',
|
||||
conformance_json TEXT NOT NULL DEFAULT '{}',
|
||||
benchmark_json TEXT NOT NULL DEFAULT '{}',
|
||||
smoke_json TEXT NOT NULL DEFAULT '{}',
|
||||
runbook_rollback TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'available',
|
||||
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);
|
||||
@@ -379,6 +437,11 @@ CREATE INDEX IF NOT EXISTS idx_plugin_traces_lookup ON plugin_traces(plugin_id,
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_data_expires ON plugin_data(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_files_expires ON plugin_files(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_diagnostics_lookup ON plugin_diagnostics(plugin_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_repository_imports_artifact ON plugin_repository_imports(artifact_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_supply_chain_lookup ON plugin_supply_chain_assessments(plugin_id, artifact_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_instrumentation_lookup ON plugin_instrumentation(name, created_at);
|
||||
INSERT OR IGNORE INTO plugin_service_state(id, desired_mode, active_mode, applied_at, live_migration, updated_by, updated_at)
|
||||
VALUES (1, 'in-process', 'in-process', strftime('%s','now'), 'drain-only', 'system', strftime('%s','now'));
|
||||
INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (1, strftime('%s','now'));
|
||||
`
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
@@ -393,6 +456,12 @@ INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (1, strftime
|
||||
if err := ensureColumn(db, "plugin_secrets", "hot_reload", "INTEGER NOT NULL DEFAULT 0"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureColumn(db, "plugin_service_state", "live_migration", "TEXT NOT NULL DEFAULT 'drain-only'"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureColumn(db, "plugin_service_state", "last_error", "TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -29,25 +29,29 @@ type APIHandlers struct {
|
||||
|
||||
AuditLogs http.HandlerFunc
|
||||
|
||||
PluginArtifacts http.HandlerFunc
|
||||
PluginArtifact SegmentHandlerFunc
|
||||
PluginSources http.HandlerFunc
|
||||
PluginBuilds http.HandlerFunc
|
||||
PluginBuild SegmentHandlerFunc
|
||||
PluginGC http.HandlerFunc
|
||||
PluginsList http.HandlerFunc
|
||||
PluginItem SegmentHandlerFunc
|
||||
PluginAction SegmentHandlerFunc
|
||||
PluginConfig SegmentHandlerFunc
|
||||
PluginSecrets SegmentHandlerFunc
|
||||
PluginRollback SegmentHandlerFunc
|
||||
PluginOperations SegmentHandlerFunc
|
||||
PluginOperationsGC http.HandlerFunc
|
||||
PluginDraining SegmentHandlerFunc
|
||||
PluginDispatch http.HandlerFunc
|
||||
PluginGovernance SegmentHandlerFunc
|
||||
PluginAdvisories http.HandlerFunc
|
||||
PluginDiagnostics SegmentHandlerFunc
|
||||
PluginArtifacts http.HandlerFunc
|
||||
PluginArtifact SegmentHandlerFunc
|
||||
PluginSources http.HandlerFunc
|
||||
PluginBuilds http.HandlerFunc
|
||||
PluginBuild SegmentHandlerFunc
|
||||
PluginGC http.HandlerFunc
|
||||
PluginsList http.HandlerFunc
|
||||
PluginItem SegmentHandlerFunc
|
||||
PluginAction SegmentHandlerFunc
|
||||
PluginConfig SegmentHandlerFunc
|
||||
PluginSecrets SegmentHandlerFunc
|
||||
PluginRollback SegmentHandlerFunc
|
||||
PluginOperations SegmentHandlerFunc
|
||||
PluginOperationsGC http.HandlerFunc
|
||||
PluginDraining SegmentHandlerFunc
|
||||
PluginDispatch http.HandlerFunc
|
||||
PluginGovernance SegmentHandlerFunc
|
||||
PluginAdvisories http.HandlerFunc
|
||||
PluginDiagnostics SegmentHandlerFunc
|
||||
PluginService http.HandlerFunc
|
||||
PluginRepositories http.HandlerFunc
|
||||
PluginSupplyChain http.HandlerFunc
|
||||
PluginInstrumentation http.HandlerFunc
|
||||
}
|
||||
|
||||
func NewAPIHandler(prefix string, handlers APIHandlers) http.HandlerFunc {
|
||||
@@ -105,10 +109,18 @@ func NewAPIHandler(prefix string, handlers APIHandlers) http.HandlerFunc {
|
||||
callHandler(w, r, handlers.PluginOperationsGC)
|
||||
case path == "/plugins" && r.Method == http.MethodGet:
|
||||
callHandler(w, r, handlers.PluginsList)
|
||||
case path == "/plugins/dispatch-plan" && r.Method == http.MethodGet:
|
||||
case path == "/plugins/dispatch-plan" && (r.Method == http.MethodGet || r.Method == http.MethodPost):
|
||||
callHandler(w, r, handlers.PluginDispatch)
|
||||
case path == "/plugin-advisories" && (r.Method == http.MethodGet || r.Method == http.MethodPost):
|
||||
callHandler(w, r, handlers.PluginAdvisories)
|
||||
case path == "/plugin-service" && (r.Method == http.MethodGet || r.Method == http.MethodPut || r.Method == http.MethodPost):
|
||||
callHandler(w, r, handlers.PluginService)
|
||||
case path == "/plugin-repositories/imports" && (r.Method == http.MethodGet || r.Method == http.MethodPost):
|
||||
callHandler(w, r, handlers.PluginRepositories)
|
||||
case path == "/plugin-supply-chain" && (r.Method == http.MethodGet || r.Method == http.MethodPost):
|
||||
callHandler(w, r, handlers.PluginSupplyChain)
|
||||
case path == "/plugin-instrumentation" && (r.Method == http.MethodGet || r.Method == http.MethodPost):
|
||||
callHandler(w, r, handlers.PluginInstrumentation)
|
||||
case strings.HasPrefix(path, "/plugins/"):
|
||||
pluginPath := strings.TrimPrefix(path, "/plugins/")
|
||||
if strings.Contains(pluginPath, "/governance/") || strings.HasSuffix(pluginPath, "/governance") {
|
||||
|
||||
@@ -53,6 +53,10 @@ func TestNewAPIHandlerRoutesRequests(t *testing.T) {
|
||||
{name: "plugin draining force close", method: http.MethodPost, path: "/admin/api/plugins/mc-auth-proxy/draining/force-close", wantCall: "plugin_draining", wantSegment: "mc-auth-proxy"},
|
||||
{name: "plugin dispatch", method: http.MethodGet, path: "/admin/api/plugins/dispatch-plan", wantCall: "plugin_dispatch"},
|
||||
{name: "plugin advisories", method: http.MethodGet, path: "/admin/api/plugin-advisories", wantCall: "plugin_advisories"},
|
||||
{name: "plugin service", method: http.MethodGet, path: "/admin/api/plugin-service", wantCall: "plugin_service"},
|
||||
{name: "plugin repositories", method: http.MethodGet, path: "/admin/api/plugin-repositories/imports", wantCall: "plugin_repositories"},
|
||||
{name: "plugin supply chain", method: http.MethodGet, path: "/admin/api/plugin-supply-chain", wantCall: "plugin_supply_chain"},
|
||||
{name: "plugin instrumentation", method: http.MethodGet, path: "/admin/api/plugin-instrumentation", wantCall: "plugin_instrumentation"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -80,25 +84,29 @@ func TestNewAPIHandlerRoutesRequests(t *testing.T) {
|
||||
|
||||
AuditLogs: recordCall(&gotCall, "audit_logs"),
|
||||
|
||||
PluginArtifacts: recordCall(&gotCall, "plugin_artifacts"),
|
||||
PluginArtifact: recordSegmentCall(&gotCall, &gotSegment, "plugin_artifact"),
|
||||
PluginSources: recordCall(&gotCall, "plugin_sources"),
|
||||
PluginBuilds: recordCall(&gotCall, "plugin_builds"),
|
||||
PluginBuild: recordSegmentCall(&gotCall, &gotSegment, "plugin_build"),
|
||||
PluginGC: recordCall(&gotCall, "plugin_gc"),
|
||||
PluginOperationsGC: recordCall(&gotCall, "plugin_operations_gc"),
|
||||
PluginsList: recordCall(&gotCall, "plugins_list"),
|
||||
PluginItem: recordSegmentCall(&gotCall, &gotSegment, "plugin_item"),
|
||||
PluginAction: recordSegmentCall(&gotCall, &gotSegment, "plugin_action"),
|
||||
PluginConfig: recordSegmentCall(&gotCall, &gotSegment, "plugin_config"),
|
||||
PluginSecrets: recordSegmentCall(&gotCall, &gotSegment, "plugin_secrets"),
|
||||
PluginRollback: recordSegmentCall(&gotCall, &gotSegment, "plugin_rollback"),
|
||||
PluginOperations: recordSegmentCall(&gotCall, &gotSegment, "plugin_operations"),
|
||||
PluginDraining: recordSegmentCall(&gotCall, &gotSegment, "plugin_draining"),
|
||||
PluginDispatch: recordCall(&gotCall, "plugin_dispatch"),
|
||||
PluginGovernance: recordSegmentCall(&gotCall, &gotSegment, "plugin_governance"),
|
||||
PluginAdvisories: recordCall(&gotCall, "plugin_advisories"),
|
||||
PluginDiagnostics: recordSegmentCall(&gotCall, &gotSegment, "plugin_diagnostics"),
|
||||
PluginArtifacts: recordCall(&gotCall, "plugin_artifacts"),
|
||||
PluginArtifact: recordSegmentCall(&gotCall, &gotSegment, "plugin_artifact"),
|
||||
PluginSources: recordCall(&gotCall, "plugin_sources"),
|
||||
PluginBuilds: recordCall(&gotCall, "plugin_builds"),
|
||||
PluginBuild: recordSegmentCall(&gotCall, &gotSegment, "plugin_build"),
|
||||
PluginGC: recordCall(&gotCall, "plugin_gc"),
|
||||
PluginOperationsGC: recordCall(&gotCall, "plugin_operations_gc"),
|
||||
PluginsList: recordCall(&gotCall, "plugins_list"),
|
||||
PluginItem: recordSegmentCall(&gotCall, &gotSegment, "plugin_item"),
|
||||
PluginAction: recordSegmentCall(&gotCall, &gotSegment, "plugin_action"),
|
||||
PluginConfig: recordSegmentCall(&gotCall, &gotSegment, "plugin_config"),
|
||||
PluginSecrets: recordSegmentCall(&gotCall, &gotSegment, "plugin_secrets"),
|
||||
PluginRollback: recordSegmentCall(&gotCall, &gotSegment, "plugin_rollback"),
|
||||
PluginOperations: recordSegmentCall(&gotCall, &gotSegment, "plugin_operations"),
|
||||
PluginDraining: recordSegmentCall(&gotCall, &gotSegment, "plugin_draining"),
|
||||
PluginDispatch: recordCall(&gotCall, "plugin_dispatch"),
|
||||
PluginGovernance: recordSegmentCall(&gotCall, &gotSegment, "plugin_governance"),
|
||||
PluginAdvisories: recordCall(&gotCall, "plugin_advisories"),
|
||||
PluginDiagnostics: recordSegmentCall(&gotCall, &gotSegment, "plugin_diagnostics"),
|
||||
PluginService: recordCall(&gotCall, "plugin_service"),
|
||||
PluginRepositories: recordCall(&gotCall, "plugin_repositories"),
|
||||
PluginSupplyChain: recordCall(&gotCall, "plugin_supply_chain"),
|
||||
PluginInstrumentation: recordCall(&gotCall, "plugin_instrumentation"),
|
||||
})
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
@@ -64,3 +64,22 @@ type PluginRollbackRequest struct {
|
||||
SnapshotID int64 `json:"snapshot_id"`
|
||||
FullDesired bool `json:"full_desired"`
|
||||
}
|
||||
|
||||
type PluginServiceRequest struct {
|
||||
DesiredMode string `json:"desired_mode"`
|
||||
}
|
||||
|
||||
type PluginRepositoryImportRequest struct {
|
||||
RepositoryType string `json:"repository_type"`
|
||||
IndexPath string `json:"index_path"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
Version string `json:"version"`
|
||||
TrustPolicy string `json:"trust_policy"`
|
||||
}
|
||||
|
||||
type PluginSupplyChainRequest struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
}
|
||||
|
||||
@@ -406,7 +406,13 @@ func capabilitiesSummaryJSON(raw json.RawMessage) ([]byte, error) {
|
||||
summary.Raw = append(json.RawMessage(nil), raw...)
|
||||
var caps struct {
|
||||
UpstreamConnect UpstreamConnectCapability `json:"upstream_connect"`
|
||||
Route RouteCapability `json:"route"`
|
||||
Status StatusCapability `json:"status"`
|
||||
Middleware MiddlewareCapability `json:"middleware"`
|
||||
Providers []ProviderCapability `json:"providers"`
|
||||
EventSubscriber EventSubscriberCapability `json:"event_subscriber"`
|
||||
Minecraft *MinecraftCapability `json:"minecraft"`
|
||||
Runtime RuntimeCapability `json:"runtime"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &caps); err != nil {
|
||||
return nil, fmt.Errorf("invalid capabilities: %w", err)
|
||||
@@ -414,6 +420,13 @@ func capabilitiesSummaryJSON(raw json.RawMessage) ([]byte, error) {
|
||||
if caps.UpstreamConnect.Mode != "" {
|
||||
summary.UpstreamConnect.Mode = caps.UpstreamConnect.Mode
|
||||
}
|
||||
summary.Route = caps.Route
|
||||
summary.Status = caps.Status
|
||||
summary.Middleware = caps.Middleware
|
||||
summary.Providers = append([]ProviderCapability(nil), caps.Providers...)
|
||||
summary.EventSubscriber = caps.EventSubscriber
|
||||
summary.Runtime = caps.Runtime
|
||||
summary.Runtime.RequiredFeatures = append(summary.Runtime.RequiredFeatures, stringSlice(jsonObjectFromRaw(string(raw), "required_features"))...)
|
||||
if caps.Minecraft != nil {
|
||||
summary.Minecraft = caps.Minecraft
|
||||
if summary.Minecraft.UnsupportedPolicy == "" {
|
||||
@@ -455,35 +468,37 @@ func validateManifest(manifest Manifest) error {
|
||||
return errors.New("version is required")
|
||||
case manifest.ArtifactType != ArtifactTypeBinary && manifest.ArtifactType != ArtifactTypeSource:
|
||||
return fmt.Errorf("unsupported artifact_type %q", manifest.ArtifactType)
|
||||
case manifest.Runtime.Type != RuntimeGoPlugin:
|
||||
case manifest.Runtime.Type != RuntimeGoPlugin && manifest.Runtime.Type != RuntimeBuiltin && manifest.Runtime.Type != RuntimeSandbox && manifest.Runtime.Type != RuntimeWASM:
|
||||
return fmt.Errorf("unsupported runtime.type %q", manifest.Runtime.Type)
|
||||
case manifest.ArtifactType == ArtifactTypeBinary && manifest.Runtime.Entry != RuntimeEntry:
|
||||
case manifest.ArtifactType == ArtifactTypeBinary && manifest.Runtime.Type == RuntimeGoPlugin && manifest.Runtime.Entry != RuntimeEntry:
|
||||
return fmt.Errorf("unsupported runtime.entry %q", manifest.Runtime.Entry)
|
||||
case manifest.ArtifactType == ArtifactTypeBinary && manifest.Runtime.Type == RuntimeWASM && manifest.Runtime.Entry != RuntimeWASMEntry:
|
||||
return fmt.Errorf("unsupported runtime.entry %q", manifest.Runtime.Entry)
|
||||
case manifest.ArtifactType == ArtifactTypeSource && rawSourceBuildEntry(manifest) == "":
|
||||
return errors.New("build.entry is required for source artifacts")
|
||||
case manifest.APIVersion != APIVersion:
|
||||
return fmt.Errorf("unsupported api_version %q", manifest.APIVersion)
|
||||
case manifest.ArtifactType == ArtifactTypeBinary && manifest.GoVersion == "":
|
||||
case manifest.ArtifactType == ArtifactTypeBinary && manifest.Runtime.Type == RuntimeGoPlugin && manifest.GoVersion == "":
|
||||
return errors.New("go_version is required")
|
||||
case manifest.ArtifactType == ArtifactTypeBinary && manifest.GOOS == "":
|
||||
case manifest.ArtifactType == ArtifactTypeBinary && manifest.Runtime.Type == RuntimeGoPlugin && manifest.GOOS == "":
|
||||
return errors.New("go_os is required")
|
||||
case manifest.ArtifactType == ArtifactTypeBinary && manifest.GOARCH == "":
|
||||
case manifest.ArtifactType == ArtifactTypeBinary && manifest.Runtime.Type == RuntimeGoPlugin && manifest.GOARCH == "":
|
||||
return errors.New("go_arch is required")
|
||||
}
|
||||
if manifest.ArtifactType == ArtifactTypeBinary && manifest.GOOS != "" && manifest.GOOS != runtime.GOOS {
|
||||
if manifest.ArtifactType == ArtifactTypeBinary && manifest.Runtime.Type == RuntimeGoPlugin && manifest.GOOS != "" && manifest.GOOS != runtime.GOOS {
|
||||
return fmt.Errorf("go_os %q does not match gateway %q", manifest.GOOS, runtime.GOOS)
|
||||
}
|
||||
if manifest.ArtifactType == ArtifactTypeBinary && manifest.GOARCH != "" && manifest.GOARCH != runtime.GOARCH {
|
||||
if manifest.ArtifactType == ArtifactTypeBinary && manifest.Runtime.Type == RuntimeGoPlugin && manifest.GOARCH != "" && 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 {
|
||||
if supportedExtensionPoint(ep.Key) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("extension point %q is required", ExtensionUpstreamConnect)
|
||||
return errors.New("at least one supported extension point is required")
|
||||
}
|
||||
seenSecrets := make(map[string]bool, len(manifest.Secrets))
|
||||
for _, secret := range manifest.Secrets {
|
||||
@@ -510,6 +525,18 @@ func validateManifest(manifest Manifest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func supportedExtensionPoint(key string) bool {
|
||||
switch key {
|
||||
case ExtensionUpstreamConnect, ExtensionRouteResolve, ExtensionRouteResolver, ExtensionStatusPing,
|
||||
ExtensionConnectionFilter, ExtensionHandshakeFilter, ExtensionEventSubscriber,
|
||||
ExtensionProvider, ExtensionAuthProvider, ExtensionAdminAuthProvider,
|
||||
ExtensionRuleEvaluate, ExtensionConfigValidate:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validateNamedSpecs(kind string, names []string) error {
|
||||
seen := make(map[string]bool, len(names))
|
||||
for _, name := range names {
|
||||
|
||||
861
internal/pluginmanager/extensions.go
Normal file
861
internal/pluginmanager/extensions.go
Normal file
@@ -0,0 +1,861 @@
|
||||
package pluginmanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/tursom/mc-gateway/plugin/api"
|
||||
)
|
||||
|
||||
type dispatchState struct {
|
||||
upstreams []*upstreamHandler
|
||||
routes []*routeHandler
|
||||
statuses []*statusHandler
|
||||
middleware []*middlewareHandler
|
||||
subscribers []*subscriberHandler
|
||||
providers []ProviderSummary
|
||||
}
|
||||
|
||||
type routeHandler struct {
|
||||
pluginID string
|
||||
artifactID string
|
||||
priority int
|
||||
handlerID string
|
||||
extensionPoint string
|
||||
timeout time.Duration
|
||||
accept api.RouteResolveAcceptor
|
||||
handle api.RouteResolveHandler
|
||||
calls atomic.Uint64
|
||||
errors atomic.Uint64
|
||||
panics atomic.Uint64
|
||||
timeouts atomic.Uint64
|
||||
blocked atomic.Uint64
|
||||
durationCount atomic.Uint64
|
||||
durationSumMS atomic.Uint64
|
||||
durationMaxMS atomic.Uint64
|
||||
}
|
||||
|
||||
type statusHandler struct {
|
||||
pluginID string
|
||||
artifactID string
|
||||
priority int
|
||||
handlerID string
|
||||
extensionPoint string
|
||||
timeout time.Duration
|
||||
accept api.StatusPingAcceptor
|
||||
handle api.StatusPingHandler
|
||||
calls atomic.Uint64
|
||||
errors atomic.Uint64
|
||||
panics atomic.Uint64
|
||||
timeouts atomic.Uint64
|
||||
blocked atomic.Uint64
|
||||
durationCount atomic.Uint64
|
||||
durationSumMS atomic.Uint64
|
||||
durationMaxMS atomic.Uint64
|
||||
}
|
||||
|
||||
type middlewareHandler struct {
|
||||
pluginID string
|
||||
artifactID string
|
||||
priority int
|
||||
handlerID string
|
||||
extensionPoint string
|
||||
timeout time.Duration
|
||||
failPolicy string
|
||||
connectionAccept api.ConnectionFilterAcceptor
|
||||
connectionHandle api.ConnectionFilterHandler
|
||||
handshakeAccept api.HandshakeFilterAcceptor
|
||||
handshakeHandle api.HandshakeFilterHandler
|
||||
calls atomic.Uint64
|
||||
errors atomic.Uint64
|
||||
panics atomic.Uint64
|
||||
timeouts atomic.Uint64
|
||||
blocked atomic.Uint64
|
||||
durationCount atomic.Uint64
|
||||
durationSumMS atomic.Uint64
|
||||
durationMaxMS atomic.Uint64
|
||||
}
|
||||
|
||||
type subscriberHandler struct {
|
||||
pluginID string
|
||||
artifactID string
|
||||
priority int
|
||||
handlerID string
|
||||
extensionPoint string
|
||||
timeout time.Duration
|
||||
mode string
|
||||
maxRetry int
|
||||
accept api.EventSubscriberAcceptor
|
||||
handle api.EventSubscriberHandler
|
||||
calls atomic.Uint64
|
||||
errors atomic.Uint64
|
||||
panics atomic.Uint64
|
||||
timeouts atomic.Uint64
|
||||
blocked atomic.Uint64
|
||||
durationCount atomic.Uint64
|
||||
durationSumMS atomic.Uint64
|
||||
durationMaxMS atomic.Uint64
|
||||
}
|
||||
|
||||
type routeCacheEntry struct {
|
||||
summary RouteDecisionSummary
|
||||
decision api.RouteDecision
|
||||
}
|
||||
|
||||
type RouteResolveResult struct {
|
||||
Decision api.RouteDecision `json:"decision"`
|
||||
Source string `json:"source"`
|
||||
Summary RouteDecisionSummary `json:"summary"`
|
||||
}
|
||||
|
||||
type StatusPingResult struct {
|
||||
Handled bool `json:"handled"`
|
||||
Response api.StatusPingResponse `json:"response"`
|
||||
PluginID string `json:"plugin_id,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
type ConnectionFilterResult struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
PluginID string `json:"plugin_id,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type HandshakeFilterResult struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
PluginID string `json:"plugin_id,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
RewriteHost string `json:"rewrite_host,omitempty"`
|
||||
}
|
||||
|
||||
func (e pluginExtensions) empty() bool {
|
||||
return e.count() == 0
|
||||
}
|
||||
|
||||
func (e pluginExtensions) count() int {
|
||||
return len(e.routes) + len(e.statuses) + len(e.middleware) + len(e.subscribers) + len(e.providers)
|
||||
}
|
||||
|
||||
func (loaded *loadedPlugin) dispatchSummaries() []DispatchHandlerSummary {
|
||||
out := handlerSummaries(loaded.handlers)
|
||||
out = append(out, routeHandlerSummaries(loaded.extensions.routes)...)
|
||||
out = append(out, statusHandlerSummaries(loaded.extensions.statuses)...)
|
||||
out = append(out, middlewareHandlerSummaries(loaded.extensions.middleware)...)
|
||||
out = append(out, subscriberHandlerSummaries(loaded.extensions.subscribers)...)
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *Manager) ResolveRoute(ctx context.Context, req api.RouteResolveRequest, fallback func(api.RouteResolveRequest) (string, bool)) (RouteResolveResult, error) {
|
||||
if req.Context == nil {
|
||||
req.Context = ctx
|
||||
}
|
||||
if fallback != nil && req.FallbackUpstream == "" {
|
||||
upstream, ok := fallback(req)
|
||||
req.FallbackUpstream = upstream
|
||||
req.FallbackHit = ok
|
||||
}
|
||||
state := m.extensionState()
|
||||
for _, handler := range state.routes {
|
||||
accepted, err := handler.accepts(req)
|
||||
if err != nil {
|
||||
return RouteResolveResult{}, err
|
||||
}
|
||||
if !accepted {
|
||||
continue
|
||||
}
|
||||
decision, err := handler.invoke(req)
|
||||
if err != nil {
|
||||
if errors.Is(err, api.ErrPass) {
|
||||
continue
|
||||
}
|
||||
if cached, ok := m.cachedRoute(req.Host); ok {
|
||||
cached.Source = "cache"
|
||||
return RouteResolveResult{Decision: cachedDecision(cached), Source: "cache", Summary: cached}, nil
|
||||
}
|
||||
break
|
||||
}
|
||||
decision = normalizeRouteDecision(decision, handler.pluginID)
|
||||
if decision.Action == api.RouteDecisionPass || decision.Action == "" {
|
||||
continue
|
||||
}
|
||||
summary := routeDecisionSummary(req.Host, decision, "provider")
|
||||
m.cacheRoute(req.Host, decision, summary)
|
||||
return RouteResolveResult{Decision: decision, Source: "provider", Summary: summary}, nil
|
||||
}
|
||||
if cached, ok := m.cachedRoute(req.Host); ok && !req.Refresh {
|
||||
cached.Source = "cache"
|
||||
return RouteResolveResult{Decision: cachedDecision(cached), Source: "cache", Summary: cached}, nil
|
||||
}
|
||||
upstream := req.FallbackUpstream
|
||||
source := "sqlite_fallback"
|
||||
if upstream == "" {
|
||||
source = "fallback_miss"
|
||||
}
|
||||
decision := api.RouteDecision{
|
||||
Action: api.RouteDecisionFallback,
|
||||
Upstream: upstream,
|
||||
ProviderID: "sqlite",
|
||||
Reason: "sqlite route snapshot fallback",
|
||||
}
|
||||
if upstream == "" {
|
||||
decision.Action = api.RouteDecisionReject
|
||||
decision.Reason = "no route provider decision and no sqlite fallback"
|
||||
}
|
||||
summary := routeDecisionSummary(req.Host, decision, source)
|
||||
return RouteResolveResult{Decision: decision, Source: source, Summary: summary}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) RefreshRouteProviders(ctx context.Context, actor string) []RouteDecisionSummary {
|
||||
hosts := make([]string, 0)
|
||||
m.routeCacheMu.Lock()
|
||||
for host := range m.routeCache {
|
||||
hosts = append(hosts, host)
|
||||
}
|
||||
m.routeCache = make(map[string]routeCacheEntry)
|
||||
m.routeCacheMu.Unlock()
|
||||
sort.Strings(hosts)
|
||||
_ = m.repo.RecordOperation(ctx, "", "", "route_provider_refresh", "succeeded", actor, "route provider cache refreshed", map[string]any{
|
||||
"cleared_hosts": hosts,
|
||||
})
|
||||
return m.RouteCacheSnapshot()
|
||||
}
|
||||
|
||||
func (m *Manager) ReplaySubscriberDeadLetters(ctx context.Context, actor string) uint64 {
|
||||
count := m.operations.SubscriberDeadLetters()
|
||||
_ = m.repo.RecordOperation(ctx, "", "", "event_subscriber_replay", "succeeded", actor, "event subscriber dead letter replay requested", map[string]any{
|
||||
"dead_letters": count,
|
||||
})
|
||||
return count
|
||||
}
|
||||
|
||||
func (m *Manager) DropSubscriberDeadLetters(ctx context.Context, actor string) uint64 {
|
||||
count := m.operations.DropSubscriberDeadLetters()
|
||||
_ = m.repo.RecordOperation(ctx, "", "", "event_subscriber_drop", "succeeded", actor, "event subscriber dead letters dropped", map[string]any{
|
||||
"dead_letters": count,
|
||||
})
|
||||
return count
|
||||
}
|
||||
|
||||
func (m *Manager) RouteCacheSnapshot() []RouteDecisionSummary {
|
||||
m.routeCacheMu.Lock()
|
||||
defer m.routeCacheMu.Unlock()
|
||||
out := make([]RouteDecisionSummary, 0, len(m.routeCache))
|
||||
now := time.Now().Unix()
|
||||
for _, entry := range m.routeCache {
|
||||
if entry.summary.ExpiresAt > 0 && entry.summary.ExpiresAt <= now {
|
||||
continue
|
||||
}
|
||||
out = append(out, entry.summary)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host })
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *Manager) StatusPing(ctx context.Context, req api.StatusPingRequest) (StatusPingResult, error) {
|
||||
if req.Context == nil {
|
||||
req.Context = ctx
|
||||
}
|
||||
for _, handler := range m.extensionState().statuses {
|
||||
accepted, err := handler.accepts(req)
|
||||
if err != nil {
|
||||
return StatusPingResult{}, err
|
||||
}
|
||||
if !accepted {
|
||||
continue
|
||||
}
|
||||
resp, err := handler.invoke(req)
|
||||
if err != nil {
|
||||
if errors.Is(err, api.ErrPass) {
|
||||
continue
|
||||
}
|
||||
return StatusPingResult{}, err
|
||||
}
|
||||
return StatusPingResult{Handled: true, Response: resp, PluginID: handler.pluginID, Source: "plugin"}, nil
|
||||
}
|
||||
return StatusPingResult{}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) FilterConnection(ctx context.Context, req api.ConnectionFilterRequest) (ConnectionFilterResult, error) {
|
||||
if req.Context == nil {
|
||||
req.Context = ctx
|
||||
}
|
||||
for _, handler := range m.extensionState().middleware {
|
||||
if handler.connectionHandle == nil {
|
||||
continue
|
||||
}
|
||||
accepted, err := handler.acceptsConnection(req)
|
||||
if err != nil {
|
||||
if handler.failPolicy == api.FailPolicyClose {
|
||||
return ConnectionFilterResult{Allowed: false, PluginID: handler.pluginID, Reason: err.Error()}, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !accepted {
|
||||
continue
|
||||
}
|
||||
decision, err := handler.invokeConnection(req)
|
||||
if err != nil {
|
||||
if handler.failPolicy == api.FailPolicyClose {
|
||||
return ConnectionFilterResult{Allowed: false, PluginID: handler.pluginID, Reason: err.Error()}, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if decision.Reject || !decision.Allow {
|
||||
handler.blocked.Add(1)
|
||||
return ConnectionFilterResult{Allowed: false, PluginID: handler.pluginID, Reason: decision.Reason}, nil
|
||||
}
|
||||
}
|
||||
return ConnectionFilterResult{Allowed: true}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) FilterHandshake(ctx context.Context, req api.HandshakeFilterRequest) (HandshakeFilterResult, error) {
|
||||
if req.Context == nil {
|
||||
req.Context = ctx
|
||||
}
|
||||
result := HandshakeFilterResult{Allowed: true}
|
||||
for _, handler := range m.extensionState().middleware {
|
||||
if handler.handshakeHandle == nil {
|
||||
continue
|
||||
}
|
||||
accepted, err := handler.acceptsHandshake(req)
|
||||
if err != nil {
|
||||
if handler.failPolicy == api.FailPolicyClose {
|
||||
return HandshakeFilterResult{Allowed: false, PluginID: handler.pluginID, Reason: err.Error()}, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !accepted {
|
||||
continue
|
||||
}
|
||||
decision, err := handler.invokeHandshake(req)
|
||||
if err != nil {
|
||||
if handler.failPolicy == api.FailPolicyClose {
|
||||
return HandshakeFilterResult{Allowed: false, PluginID: handler.pluginID, Reason: err.Error()}, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if decision.Reject || !decision.Allow {
|
||||
handler.blocked.Add(1)
|
||||
return HandshakeFilterResult{Allowed: false, PluginID: handler.pluginID, Reason: decision.Reason}, nil
|
||||
}
|
||||
if decision.RewriteHost != "" {
|
||||
result.RewriteHost = decision.RewriteHost
|
||||
result.PluginID = handler.pluginID
|
||||
req.ServerHost = decision.RewriteHost
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *Manager) extensionState() dispatchState {
|
||||
value := m.extensionSnapshot.Load()
|
||||
if state, ok := value.(dispatchState); ok {
|
||||
return state
|
||||
}
|
||||
return dispatchState{}
|
||||
}
|
||||
|
||||
func (m *Manager) currentExtensionsLocked() map[string]pluginExtensions {
|
||||
current := make(map[string]pluginExtensions)
|
||||
state := m.extensionState()
|
||||
for _, handler := range state.routes {
|
||||
ext := current[handler.pluginID]
|
||||
ext.routes = append(ext.routes, handler)
|
||||
current[handler.pluginID] = ext
|
||||
}
|
||||
for _, handler := range state.statuses {
|
||||
ext := current[handler.pluginID]
|
||||
ext.statuses = append(ext.statuses, handler)
|
||||
current[handler.pluginID] = ext
|
||||
}
|
||||
for _, handler := range state.middleware {
|
||||
ext := current[handler.pluginID]
|
||||
ext.middleware = append(ext.middleware, handler)
|
||||
current[handler.pluginID] = ext
|
||||
}
|
||||
for _, handler := range state.subscribers {
|
||||
ext := current[handler.pluginID]
|
||||
ext.subscribers = append(ext.subscribers, handler)
|
||||
current[handler.pluginID] = ext
|
||||
}
|
||||
for _, provider := range state.providers {
|
||||
ext := current[provider.PluginID]
|
||||
ext.providers = append(ext.providers, provider)
|
||||
current[provider.PluginID] = ext
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func (m *Manager) publishExtensionsLocked(byPlugin map[string]pluginExtensions) {
|
||||
var state dispatchState
|
||||
for _, ext := range byPlugin {
|
||||
state.routes = append(state.routes, ext.routes...)
|
||||
state.statuses = append(state.statuses, ext.statuses...)
|
||||
state.middleware = append(state.middleware, ext.middleware...)
|
||||
state.subscribers = append(state.subscribers, ext.subscribers...)
|
||||
state.providers = append(state.providers, ext.providers...)
|
||||
}
|
||||
sortRouteHandlers(state.routes)
|
||||
sortStatusHandlers(state.statuses)
|
||||
sortMiddlewareHandlers(state.middleware)
|
||||
sortSubscriberHandlers(state.subscribers)
|
||||
sort.SliceStable(state.providers, func(i, j int) bool {
|
||||
if state.providers[i].Type != state.providers[j].Type {
|
||||
return state.providers[i].Type < state.providers[j].Type
|
||||
}
|
||||
if state.providers[i].Priority != state.providers[j].Priority {
|
||||
return state.providers[i].Priority < state.providers[j].Priority
|
||||
}
|
||||
return state.providers[i].Name < state.providers[j].Name
|
||||
})
|
||||
m.extensionSnapshot.Store(state)
|
||||
m.operations.SetSubscribers(state.subscribers)
|
||||
}
|
||||
|
||||
func (m *Manager) removeExtensionsLocked(pluginID string) {
|
||||
current := m.currentExtensionsLocked()
|
||||
delete(current, pluginID)
|
||||
m.publishExtensionsLocked(current)
|
||||
}
|
||||
|
||||
func buildExtensions(pluginRecord PluginRecord, artifact ArtifactRecord, gateway *Gateway) pluginExtensions {
|
||||
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
|
||||
}
|
||||
failPolicy := api.FailPolicyOpen
|
||||
var summary CapabilitySummary
|
||||
if err := json.Unmarshal([]byte(artifact.CapabilitiesSummaryJSON), &summary); err == nil && summary.Middleware.FailPolicy != "" {
|
||||
failPolicy = summary.Middleware.FailPolicy
|
||||
}
|
||||
ext := pluginExtensions{}
|
||||
if hook, ok := gateway.RouteResolveHandler(); ok {
|
||||
ext.routes = append(ext.routes, &routeHandler{
|
||||
pluginID: pluginRecord.ID, artifactID: artifact.ID, priority: pluginRecord.Priority,
|
||||
handlerID: ExtensionRouteResolve, extensionPoint: ExtensionRouteResolve, timeout: timeout,
|
||||
accept: hook.Acceptor(), handle: hook.Handler(),
|
||||
})
|
||||
}
|
||||
if hook, ok := gateway.RouteResolverHandler(); ok {
|
||||
ext.routes = append(ext.routes, &routeHandler{
|
||||
pluginID: pluginRecord.ID, artifactID: artifact.ID, priority: pluginRecord.Priority,
|
||||
handlerID: ExtensionRouteResolver, extensionPoint: ExtensionRouteResolver, timeout: timeout,
|
||||
accept: hook.Acceptor(), handle: hook.Handler(),
|
||||
})
|
||||
}
|
||||
if hook, ok := gateway.StatusPingHandler(); ok {
|
||||
ext.statuses = append(ext.statuses, &statusHandler{
|
||||
pluginID: pluginRecord.ID, artifactID: artifact.ID, priority: pluginRecord.Priority,
|
||||
handlerID: ExtensionStatusPing, extensionPoint: ExtensionStatusPing, timeout: timeout,
|
||||
accept: hook.Acceptor(), handle: hook.Handler(),
|
||||
})
|
||||
}
|
||||
if hook, ok := gateway.ConnectionFilterHandler(); ok {
|
||||
ext.middleware = append(ext.middleware, &middlewareHandler{
|
||||
pluginID: pluginRecord.ID, artifactID: artifact.ID, priority: pluginRecord.Priority,
|
||||
handlerID: ExtensionConnectionFilter, extensionPoint: ExtensionConnectionFilter, timeout: timeout, failPolicy: failPolicy,
|
||||
connectionAccept: hook.Acceptor(), connectionHandle: hook.Handler(),
|
||||
})
|
||||
}
|
||||
if hook, ok := gateway.HandshakeFilterHandler(); ok {
|
||||
ext.middleware = append(ext.middleware, &middlewareHandler{
|
||||
pluginID: pluginRecord.ID, artifactID: artifact.ID, priority: pluginRecord.Priority,
|
||||
handlerID: ExtensionHandshakeFilter, extensionPoint: ExtensionHandshakeFilter, timeout: timeout, failPolicy: failPolicy,
|
||||
handshakeAccept: hook.Acceptor(), handshakeHandle: hook.Handler(),
|
||||
})
|
||||
}
|
||||
if hook, ok := gateway.EventSubscriberHandler(); ok {
|
||||
mode := summary.EventSubscriber.Mode
|
||||
if mode == "" {
|
||||
mode = api.DeliveryBestEffort
|
||||
}
|
||||
maxRetry := summary.EventSubscriber.MaxRetry
|
||||
if maxRetry <= 0 {
|
||||
maxRetry = DefaultSubscriberMaxRetry
|
||||
}
|
||||
ext.subscribers = append(ext.subscribers, &subscriberHandler{
|
||||
pluginID: pluginRecord.ID, artifactID: artifact.ID, priority: pluginRecord.Priority,
|
||||
handlerID: ExtensionEventSubscriber, extensionPoint: ExtensionEventSubscriber, timeout: timeout,
|
||||
mode: mode, maxRetry: maxRetry, accept: hook.Acceptor(), handle: hook.Handler(),
|
||||
})
|
||||
}
|
||||
ext.providers = append(ext.providers, buildProviderSummaries(pluginRecord, artifact, gateway)...)
|
||||
return ext
|
||||
}
|
||||
|
||||
func buildProviderSummaries(pluginRecord PluginRecord, artifact ArtifactRecord, gateway *Gateway) []ProviderSummary {
|
||||
var out []ProviderSummary
|
||||
providerHook, providerOK := gateway.ProviderHandler()
|
||||
authHook, authOK := gateway.AuthProviderHandler()
|
||||
adminAuthHook, adminAuthOK := gateway.AdminAuthProviderHandler()
|
||||
items := []struct {
|
||||
key string
|
||||
hook api.HookHandler[api.ProviderAcceptor, api.ProviderHandler]
|
||||
ok bool
|
||||
}{
|
||||
{ExtensionProvider, providerHook, providerOK},
|
||||
{ExtensionAuthProvider, authHook, authOK},
|
||||
{ExtensionAdminAuthProvider, adminAuthHook, adminAuthOK},
|
||||
}
|
||||
for _, item := range items {
|
||||
if !item.ok {
|
||||
continue
|
||||
}
|
||||
reg, err := item.hook.Handler()()
|
||||
status := "ready"
|
||||
errText := ""
|
||||
if err != nil {
|
||||
status = "unavailable"
|
||||
errText = err.Error()
|
||||
}
|
||||
if reg.Type == "" {
|
||||
reg.Type = item.key
|
||||
}
|
||||
if reg.Name == "" {
|
||||
reg.Name = pluginRecord.ID
|
||||
}
|
||||
if reg.Priority == 0 {
|
||||
reg.Priority = pluginRecord.Priority
|
||||
}
|
||||
out = append(out, ProviderSummary{
|
||||
PluginID: pluginRecord.ID, ArtifactID: artifact.ID, ExtensionPoint: item.key,
|
||||
Type: reg.Type, Name: reg.Name, Priority: reg.Priority, Fallback: reg.Fallback,
|
||||
Dependencies: append([]string(nil), reg.Dependencies...), Metadata: copyStringMap(reg.Metadata),
|
||||
Status: status, Error: errText,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *routeHandler) accepts(req api.RouteResolveRequest) (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 route acceptor panic: %v", h.pluginID, rec)
|
||||
}
|
||||
}()
|
||||
return h.accept(req), nil
|
||||
}
|
||||
|
||||
func (h *routeHandler) invoke(req api.RouteResolveRequest) (decision api.RouteDecision, err error) {
|
||||
h.calls.Add(1)
|
||||
start := time.Now()
|
||||
defer recordExtensionDuration(&h.durationCount, &h.durationSumMS, &h.durationMaxMS, start)
|
||||
if req.Context == nil {
|
||||
req.Context = context.Background()
|
||||
}
|
||||
return invokeWithTimeout(req.Context, h.timeout, &h.timeouts, &h.panics, &h.errors, h.pluginID, "route resolver", func(ctx context.Context) (api.RouteDecision, error) {
|
||||
req.Context = ctx
|
||||
return h.handle(req)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *statusHandler) accepts(req api.StatusPingRequest) (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 status acceptor panic: %v", h.pluginID, rec)
|
||||
}
|
||||
}()
|
||||
return h.accept(req), nil
|
||||
}
|
||||
|
||||
func (h *statusHandler) invoke(req api.StatusPingRequest) (response api.StatusPingResponse, err error) {
|
||||
h.calls.Add(1)
|
||||
start := time.Now()
|
||||
defer recordExtensionDuration(&h.durationCount, &h.durationSumMS, &h.durationMaxMS, start)
|
||||
if req.Context == nil {
|
||||
req.Context = context.Background()
|
||||
}
|
||||
return invokeWithTimeout(req.Context, h.timeout, &h.timeouts, &h.panics, &h.errors, h.pluginID, "status ping", func(ctx context.Context) (api.StatusPingResponse, error) {
|
||||
req.Context = ctx
|
||||
return h.handle(req)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *middlewareHandler) acceptsConnection(req api.ConnectionFilterRequest) (accepted bool, err error) {
|
||||
if h.connectionAccept == nil {
|
||||
return true, nil
|
||||
}
|
||||
defer recoverBool(&accepted, &err, h.pluginID, "connection filter acceptor", &h.panics)
|
||||
return h.connectionAccept(req), nil
|
||||
}
|
||||
|
||||
func (h *middlewareHandler) acceptsHandshake(req api.HandshakeFilterRequest) (accepted bool, err error) {
|
||||
if h.handshakeAccept == nil {
|
||||
return true, nil
|
||||
}
|
||||
defer recoverBool(&accepted, &err, h.pluginID, "handshake filter acceptor", &h.panics)
|
||||
return h.handshakeAccept(req), nil
|
||||
}
|
||||
|
||||
func (h *middlewareHandler) invokeConnection(req api.ConnectionFilterRequest) (api.FilterDecision, error) {
|
||||
h.calls.Add(1)
|
||||
start := time.Now()
|
||||
defer recordExtensionDuration(&h.durationCount, &h.durationSumMS, &h.durationMaxMS, start)
|
||||
if req.Context == nil {
|
||||
req.Context = context.Background()
|
||||
}
|
||||
return invokeWithTimeout(req.Context, h.timeout, &h.timeouts, &h.panics, &h.errors, h.pluginID, "connection filter", func(ctx context.Context) (api.FilterDecision, error) {
|
||||
req.Context = ctx
|
||||
return h.connectionHandle(req)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *middlewareHandler) invokeHandshake(req api.HandshakeFilterRequest) (api.HandshakeFilterDecision, error) {
|
||||
h.calls.Add(1)
|
||||
start := time.Now()
|
||||
defer recordExtensionDuration(&h.durationCount, &h.durationSumMS, &h.durationMaxMS, start)
|
||||
if req.Context == nil {
|
||||
req.Context = context.Background()
|
||||
}
|
||||
return invokeWithTimeout(req.Context, h.timeout, &h.timeouts, &h.panics, &h.errors, h.pluginID, "handshake filter", func(ctx context.Context) (api.HandshakeFilterDecision, error) {
|
||||
req.Context = ctx
|
||||
return h.handshakeHandle(req)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *subscriberHandler) accepts(req api.EventDeliveryRequest) (accepted bool, err error) {
|
||||
if h.accept == nil {
|
||||
return true, nil
|
||||
}
|
||||
defer recoverBool(&accepted, &err, h.pluginID, "event subscriber acceptor", &h.panics)
|
||||
return h.accept(req), nil
|
||||
}
|
||||
|
||||
func (h *subscriberHandler) invoke(req api.EventDeliveryRequest) (api.EventDeliveryResult, error) {
|
||||
h.calls.Add(1)
|
||||
start := time.Now()
|
||||
defer recordExtensionDuration(&h.durationCount, &h.durationSumMS, &h.durationMaxMS, start)
|
||||
if req.Context == nil {
|
||||
req.Context = context.Background()
|
||||
}
|
||||
return invokeWithTimeout(req.Context, h.timeout, &h.timeouts, &h.panics, &h.errors, h.pluginID, "event subscriber", func(ctx context.Context) (api.EventDeliveryResult, error) {
|
||||
req.Context = ctx
|
||||
return h.handle(req)
|
||||
})
|
||||
}
|
||||
|
||||
func invokeWithTimeout[T any](ctx context.Context, timeout time.Duration, timeouts, panics, errorsCounter *atomic.Uint64, pluginID, name string, fn func(context.Context) (T, error)) (zero T, err error) {
|
||||
if timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
}
|
||||
done := make(chan struct {
|
||||
value T
|
||||
err error
|
||||
}, 1)
|
||||
go func() {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
panics.Add(1)
|
||||
done <- struct {
|
||||
value T
|
||||
err error
|
||||
}{err: fmt.Errorf("plugin %s %s panic: %v", pluginID, name, rec)}
|
||||
}
|
||||
}()
|
||||
value, err := fn(ctx)
|
||||
done <- struct {
|
||||
value T
|
||||
err error
|
||||
}{value: value, err: err}
|
||||
}()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timeouts.Add(1)
|
||||
return zero, ctx.Err()
|
||||
case result := <-done:
|
||||
if result.err != nil && !errors.Is(result.err, api.ErrPass) {
|
||||
errorsCounter.Add(1)
|
||||
}
|
||||
return result.value, result.err
|
||||
}
|
||||
}
|
||||
|
||||
func recordExtensionDuration(count, sum, max *atomic.Uint64, start time.Time) {
|
||||
durationMS := uint64(time.Since(start).Milliseconds())
|
||||
count.Add(1)
|
||||
sum.Add(durationMS)
|
||||
for {
|
||||
current := max.Load()
|
||||
if durationMS <= current || max.CompareAndSwap(current, durationMS) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func recoverBool(accepted *bool, err *error, pluginID, name string, panics *atomic.Uint64) {
|
||||
if rec := recover(); rec != nil {
|
||||
panics.Add(1)
|
||||
*accepted = false
|
||||
*err = fmt.Errorf("plugin %s %s panic: %v", pluginID, name, rec)
|
||||
}
|
||||
}
|
||||
|
||||
func sortRouteHandlers(handlers []*routeHandler) {
|
||||
sort.SliceStable(handlers, func(i, j int) bool {
|
||||
return extensionLess(handlers[i].priority, handlers[i].pluginID, handlers[i].handlerID, handlers[j].priority, handlers[j].pluginID, handlers[j].handlerID)
|
||||
})
|
||||
}
|
||||
|
||||
func sortStatusHandlers(handlers []*statusHandler) {
|
||||
sort.SliceStable(handlers, func(i, j int) bool {
|
||||
return extensionLess(handlers[i].priority, handlers[i].pluginID, handlers[i].handlerID, handlers[j].priority, handlers[j].pluginID, handlers[j].handlerID)
|
||||
})
|
||||
}
|
||||
|
||||
func sortMiddlewareHandlers(handlers []*middlewareHandler) {
|
||||
sort.SliceStable(handlers, func(i, j int) bool {
|
||||
return extensionLess(handlers[i].priority, handlers[i].pluginID, handlers[i].handlerID, handlers[j].priority, handlers[j].pluginID, handlers[j].handlerID)
|
||||
})
|
||||
}
|
||||
|
||||
func sortSubscriberHandlers(handlers []*subscriberHandler) {
|
||||
sort.SliceStable(handlers, func(i, j int) bool {
|
||||
return extensionLess(handlers[i].priority, handlers[i].pluginID, handlers[i].handlerID, handlers[j].priority, handlers[j].pluginID, handlers[j].handlerID)
|
||||
})
|
||||
}
|
||||
|
||||
func extensionLess(aPriority int, aPlugin, aHandler string, bPriority int, bPlugin, bHandler string) bool {
|
||||
if aPriority != bPriority {
|
||||
return aPriority < bPriority
|
||||
}
|
||||
if aPlugin != bPlugin {
|
||||
return aPlugin < bPlugin
|
||||
}
|
||||
return aHandler < bHandler
|
||||
}
|
||||
|
||||
func extensionSummary(pluginID, artifactID string, priority int, handlerID, extensionPoint, mode string, timeout time.Duration, calls, errors, panics, timeouts, blocked, durationCount, durationSum, durationMax *atomic.Uint64) DispatchHandlerSummary {
|
||||
return DispatchHandlerSummary{
|
||||
PluginID: pluginID, ArtifactID: artifactID, Priority: priority, HandlerID: handlerID,
|
||||
ExtensionPoint: extensionPoint, Mode: mode, TimeoutMS: timeout.Milliseconds(),
|
||||
Calls: calls.Load(), Errors: errors.Load(), Panics: panics.Load(), Timeouts: timeouts.Load(), Blocked: blocked.Load(),
|
||||
DurationCount: durationCount.Load(), DurationSumMS: durationSum.Load(), DurationMaxMS: durationMax.Load(),
|
||||
}
|
||||
}
|
||||
|
||||
func routeHandlerSummaries(handlers []*routeHandler) []DispatchHandlerSummary {
|
||||
out := make([]DispatchHandlerSummary, 0, len(handlers))
|
||||
for _, h := range handlers {
|
||||
out = append(out, extensionSummary(h.pluginID, h.artifactID, h.priority, h.handlerID, h.extensionPoint, "route", h.timeout, &h.calls, &h.errors, &h.panics, &h.timeouts, &h.blocked, &h.durationCount, &h.durationSumMS, &h.durationMaxMS))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func statusHandlerSummaries(handlers []*statusHandler) []DispatchHandlerSummary {
|
||||
out := make([]DispatchHandlerSummary, 0, len(handlers))
|
||||
for _, h := range handlers {
|
||||
out = append(out, extensionSummary(h.pluginID, h.artifactID, h.priority, h.handlerID, h.extensionPoint, "status", h.timeout, &h.calls, &h.errors, &h.panics, &h.timeouts, &h.blocked, &h.durationCount, &h.durationSumMS, &h.durationMaxMS))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func middlewareHandlerSummaries(handlers []*middlewareHandler) []DispatchHandlerSummary {
|
||||
out := make([]DispatchHandlerSummary, 0, len(handlers))
|
||||
for _, h := range handlers {
|
||||
out = append(out, extensionSummary(h.pluginID, h.artifactID, h.priority, h.handlerID, h.extensionPoint, h.failPolicy, h.timeout, &h.calls, &h.errors, &h.panics, &h.timeouts, &h.blocked, &h.durationCount, &h.durationSumMS, &h.durationMaxMS))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func subscriberHandlerSummaries(handlers []*subscriberHandler) []DispatchHandlerSummary {
|
||||
out := make([]DispatchHandlerSummary, 0, len(handlers))
|
||||
for _, h := range handlers {
|
||||
out = append(out, extensionSummary(h.pluginID, h.artifactID, h.priority, h.handlerID, h.extensionPoint, h.mode, h.timeout, &h.calls, &h.errors, &h.panics, &h.timeouts, &h.blocked, &h.durationCount, &h.durationSumMS, &h.durationMaxMS))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeRouteDecision(decision api.RouteDecision, providerID string) api.RouteDecision {
|
||||
if decision.ProviderID == "" {
|
||||
decision.ProviderID = providerID
|
||||
}
|
||||
switch decision.Action {
|
||||
case api.RouteDecisionOverride, api.RouteDecisionFallback, api.RouteDecisionReject, api.RouteDecisionPass:
|
||||
default:
|
||||
if decision.Upstream != "" {
|
||||
decision.Action = api.RouteDecisionOverride
|
||||
} else {
|
||||
decision.Action = api.RouteDecisionPass
|
||||
}
|
||||
}
|
||||
if decision.CacheTTL == 0 {
|
||||
decision.CacheTTL = time.Minute
|
||||
}
|
||||
return decision
|
||||
}
|
||||
|
||||
func routeDecisionSummary(host string, decision api.RouteDecision, source string) RouteDecisionSummary {
|
||||
now := time.Now()
|
||||
expiresAt := int64(0)
|
||||
if decision.CacheTTL > 0 {
|
||||
expiresAt = now.Add(decision.CacheTTL).Unix()
|
||||
}
|
||||
targetHost := host
|
||||
if decision.Host != "" {
|
||||
targetHost = decision.Host
|
||||
}
|
||||
return RouteDecisionSummary{
|
||||
Host: targetHost, Action: decision.Action, Upstream: decision.Upstream, ProviderID: decision.ProviderID,
|
||||
Source: source, Reason: decision.Reason, Metadata: copyStringMap(decision.Metadata),
|
||||
CreatedAt: now.Unix(), ExpiresAt: expiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) cacheRoute(host string, decision api.RouteDecision, summary RouteDecisionSummary) {
|
||||
if host == "" || decision.Action == api.RouteDecisionPass {
|
||||
return
|
||||
}
|
||||
m.routeCacheMu.Lock()
|
||||
defer m.routeCacheMu.Unlock()
|
||||
m.routeCache[host] = routeCacheEntry{summary: summary, decision: decision}
|
||||
}
|
||||
|
||||
func (m *Manager) cachedRoute(host string) (RouteDecisionSummary, bool) {
|
||||
m.routeCacheMu.Lock()
|
||||
defer m.routeCacheMu.Unlock()
|
||||
entry, ok := m.routeCache[host]
|
||||
if !ok {
|
||||
return RouteDecisionSummary{}, false
|
||||
}
|
||||
if entry.summary.ExpiresAt > 0 && entry.summary.ExpiresAt <= time.Now().Unix() {
|
||||
delete(m.routeCache, host)
|
||||
return RouteDecisionSummary{}, false
|
||||
}
|
||||
return entry.summary, true
|
||||
}
|
||||
|
||||
func cachedDecision(summary RouteDecisionSummary) api.RouteDecision {
|
||||
return api.RouteDecision{
|
||||
Action: summary.Action, Upstream: summary.Upstream, Host: summary.Host, Reason: summary.Reason,
|
||||
ProviderID: summary.ProviderID, Metadata: copyStringMap(summary.Metadata),
|
||||
}
|
||||
}
|
||||
|
||||
func copyStringMap(input map[string]string) map[string]string {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(input))
|
||||
for key, value := range input {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
433
internal/pluginmanager/future.go
Normal file
433
internal/pluginmanager/future.go
Normal file
@@ -0,0 +1,433 @@
|
||||
package pluginmanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type pluginHostProcess struct {
|
||||
PluginID string
|
||||
ArtifactID string
|
||||
State string
|
||||
DrainMode string
|
||||
CrashLoop bool
|
||||
CrashCount int
|
||||
LastError string
|
||||
StartedAt int64
|
||||
DrainingAt int64
|
||||
ExitedAt int64
|
||||
LastCrashAt int64
|
||||
}
|
||||
|
||||
func (m *Manager) PluginServiceState(ctx context.Context) (PluginServiceState, error) {
|
||||
state, err := m.repo.PluginServiceState(ctx)
|
||||
if err != nil {
|
||||
return PluginServiceState{}, err
|
||||
}
|
||||
if m.serviceMode != "" {
|
||||
state.ActiveMode = m.serviceMode
|
||||
state.RestartRequired = state.DesiredMode != state.ActiveMode
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (m *Manager) PluginServiceStatus(ctx context.Context) (PluginServiceStatus, error) {
|
||||
state, err := m.PluginServiceState(ctx)
|
||||
if err != nil {
|
||||
return PluginServiceStatus{}, err
|
||||
}
|
||||
return PluginServiceStatus{Service: state, Hosts: m.PluginHostSummaries()}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) SetPluginServiceDesired(ctx context.Context, actor, mode string) (PluginServiceState, error) {
|
||||
mode = strings.TrimSpace(mode)
|
||||
if err := validatePluginServiceMode(mode); err != nil {
|
||||
return PluginServiceState{}, err
|
||||
}
|
||||
state, err := m.repo.SetPluginServiceDesired(ctx, actor, mode)
|
||||
if err != nil {
|
||||
return PluginServiceState{}, err
|
||||
}
|
||||
_ = m.repo.RecordOperation(ctx, "", "", "plugin_service_mode_desired", "succeeded", actor, "plugin service desired mode updated", map[string]any{
|
||||
"desired_mode": state.DesiredMode,
|
||||
"active_mode": state.ActiveMode,
|
||||
"restart_required": state.RestartRequired,
|
||||
})
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (m *Manager) ApplyPluginServiceMode(ctx context.Context) error {
|
||||
state, err := m.repo.PluginServiceState(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePluginServiceMode(state.DesiredMode); err != nil {
|
||||
_ = m.repo.SetPluginServiceError(ctx, err.Error())
|
||||
state.DesiredMode = PluginServiceModeInProcess
|
||||
}
|
||||
applied, err := m.repo.ApplyPluginServiceActive(ctx, state.DesiredMode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.serviceMode = applied.ActiveMode
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePluginServiceMode(mode string) error {
|
||||
switch mode {
|
||||
case PluginServiceModeInProcess, PluginServiceModeGoPluginProcess, PluginServiceModeSandboxProcess:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid plugin service mode %q", mode)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) markHostStarted(pluginID, artifactID string) {
|
||||
if m.serviceMode != PluginServiceModeGoPluginProcess {
|
||||
return
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
m.hostMu.Lock()
|
||||
defer m.hostMu.Unlock()
|
||||
host := m.hosts[pluginID]
|
||||
if host == nil {
|
||||
host = &pluginHostProcess{PluginID: pluginID}
|
||||
m.hosts[pluginID] = host
|
||||
}
|
||||
host.ArtifactID = artifactID
|
||||
host.State = RuntimeEnabled
|
||||
host.DrainMode = PluginMigrationDrainOnly
|
||||
host.StartedAt = now
|
||||
host.DrainingAt = 0
|
||||
host.ExitedAt = 0
|
||||
host.LastError = ""
|
||||
}
|
||||
|
||||
func (m *Manager) markHostDraining(pluginID string) {
|
||||
if m.serviceMode != PluginServiceModeGoPluginProcess {
|
||||
return
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
m.hostMu.Lock()
|
||||
defer m.hostMu.Unlock()
|
||||
host := m.hosts[pluginID]
|
||||
if host == nil {
|
||||
return
|
||||
}
|
||||
host.State = RuntimeDraining
|
||||
host.DrainMode = PluginMigrationDrainOnly
|
||||
host.DrainingAt = now
|
||||
host.ExitedAt = now
|
||||
host.State = RuntimeDisabled
|
||||
}
|
||||
|
||||
func (m *Manager) SimulatePluginHostCrash(ctx context.Context, pluginID, message string) error {
|
||||
plugin, err := m.repo.Plugin(ctx, pluginID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
m.hostMu.Lock()
|
||||
host := m.hosts[pluginID]
|
||||
if host == nil {
|
||||
host = &pluginHostProcess{PluginID: pluginID, ArtifactID: plugin.ActiveArtifactID}
|
||||
m.hosts[pluginID] = host
|
||||
}
|
||||
host.State = RuntimeFailed
|
||||
host.CrashCount++
|
||||
host.CrashLoop = host.CrashCount >= 1
|
||||
host.LastError = message
|
||||
host.LastCrashAt = now
|
||||
m.hostMu.Unlock()
|
||||
_ = m.repo.MarkRuntime(ctx, pluginID, RuntimeFailed, plugin.ActiveArtifactID, plugin.LoadedArtifactID, plugin.AppliedGeneration, message, map[string]any{
|
||||
"plugin_host": m.hostSummary(pluginID),
|
||||
}, nil)
|
||||
_ = m.repo.RecordOperation(ctx, pluginID, plugin.ActiveArtifactID, "plugin_host_crash", "failed", "system", message, map[string]any{"crash_loop": true})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) PluginHostSummaries() []PluginHostRuntimeSummary {
|
||||
m.hostMu.Lock()
|
||||
defer m.hostMu.Unlock()
|
||||
out := make([]PluginHostRuntimeSummary, 0, len(m.hosts))
|
||||
for _, host := range m.hosts {
|
||||
out = append(out, host.summary())
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return out[i].PluginID < out[j].PluginID
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *Manager) hostSummary(pluginID string) PluginHostRuntimeSummary {
|
||||
m.hostMu.Lock()
|
||||
defer m.hostMu.Unlock()
|
||||
if host := m.hosts[pluginID]; host != nil {
|
||||
return host.summary()
|
||||
}
|
||||
return PluginHostRuntimeSummary{PluginID: pluginID, State: RuntimeNotLoaded, DrainMode: PluginMigrationDrainOnly}
|
||||
}
|
||||
|
||||
func (h *pluginHostProcess) summary() PluginHostRuntimeSummary {
|
||||
return PluginHostRuntimeSummary{
|
||||
PluginID: h.PluginID, ArtifactID: h.ArtifactID, State: h.State, DrainMode: h.DrainMode,
|
||||
CrashLoop: h.CrashLoop, CrashCount: h.CrashCount, LastError: h.LastError,
|
||||
StartedAt: h.StartedAt, DrainingAt: h.DrainingAt, ExitedAt: h.ExitedAt, LastCrashAt: h.LastCrashAt,
|
||||
}
|
||||
}
|
||||
|
||||
type WASMRunner struct{}
|
||||
|
||||
func (WASMRunner) Validate(ctx context.Context, manifest Manifest, behavior string) error {
|
||||
timeout := DefaultHandlerTimeout
|
||||
if manifest.RuntimeLimits.HandlerTimeoutMS > 0 {
|
||||
timeout = time.Duration(manifest.RuntimeLimits.HandlerTimeoutMS) * time.Millisecond
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = DefaultHandlerTimeout
|
||||
}
|
||||
callCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
done <- fmt.Errorf("wasm plugin panic: %v", rec)
|
||||
}
|
||||
}()
|
||||
switch behavior {
|
||||
case "panic":
|
||||
panic("simulated wasm panic")
|
||||
case "timeout":
|
||||
<-callCtx.Done()
|
||||
done <- callCtx.Err()
|
||||
case "memory":
|
||||
done <- errors.New("wasm memory limit exceeded")
|
||||
default:
|
||||
done <- nil
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-callCtx.Done():
|
||||
return callCtx.Err()
|
||||
case err := <-done:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) RunWASMValidation(ctx context.Context, pluginID, artifactID, behavior string) error {
|
||||
_, manifest, err := m.artifactManifest(ctx, pluginID, artifactID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return (WASMRunner{}).Validate(ctx, manifest, behavior)
|
||||
}
|
||||
|
||||
func (m *Manager) ImportRepositoryArtifact(ctx context.Context, actor string, req RepositoryImportRequest) (RepositoryImportRecord, ArtifactRecord, error) {
|
||||
if req.RepositoryType == "" {
|
||||
req.RepositoryType = RepositoryTypeFile
|
||||
}
|
||||
if req.RepositoryType != RepositoryTypeFile {
|
||||
return RepositoryImportRecord{}, ArtifactRecord{}, fmt.Errorf("repository type %q is reserved; only file is enabled", req.RepositoryType)
|
||||
}
|
||||
index, err := readRepositoryIndex(req.IndexPath)
|
||||
if err != nil {
|
||||
return RepositoryImportRecord{}, ArtifactRecord{}, err
|
||||
}
|
||||
candidate, err := selectRepositoryCandidate(index, req)
|
||||
if err != nil {
|
||||
return RepositoryImportRecord{}, ArtifactRecord{}, err
|
||||
}
|
||||
artifactPath := candidate.ArtifactPath
|
||||
if !filepath.IsAbs(artifactPath) {
|
||||
artifactPath = filepath.Join(filepath.Dir(req.IndexPath), artifactPath)
|
||||
}
|
||||
artifact, err := m.UploadArtifact(ctx, ArtifactUpload{SourcePath: artifactPath, FileName: filepath.Base(artifactPath), Actor: actor})
|
||||
if err != nil {
|
||||
return RepositoryImportRecord{}, ArtifactRecord{}, err
|
||||
}
|
||||
admission, _ := m.EvaluateGovernance(ctx, artifact.PluginID, artifact.ID, GovernanceActionPromotion, m.currentPolicyProfile(), "{}")
|
||||
admissionJSON, _ := json.Marshal(admission)
|
||||
record, err := m.repo.SaveRepositoryImport(ctx, RepositoryImportRecord{
|
||||
RepositoryType: req.RepositoryType,
|
||||
IndexPath: req.IndexPath,
|
||||
RepositoryName: index.Name,
|
||||
CandidateID: candidate.ID,
|
||||
PluginID: artifact.PluginID,
|
||||
Version: artifact.Version,
|
||||
ArtifactID: artifact.ID,
|
||||
PackageSHA256: artifact.PackageSHA256,
|
||||
TrustPolicy: req.TrustPolicy,
|
||||
AdmissionJSON: string(admissionJSON),
|
||||
ImportedBy: actor,
|
||||
})
|
||||
if err != nil {
|
||||
return RepositoryImportRecord{}, ArtifactRecord{}, err
|
||||
}
|
||||
_ = m.repo.RecordOperation(ctx, artifact.PluginID, artifact.ID, "repository_import", "succeeded", actor, "repository artifact imported to local store", map[string]any{
|
||||
"repository": index.Name,
|
||||
"candidate_id": candidate.ID,
|
||||
"auto_enable": false,
|
||||
"admission_result": admission.OK,
|
||||
})
|
||||
return record, artifact, nil
|
||||
}
|
||||
|
||||
func (m *Manager) ListRepositoryImports(ctx context.Context) ([]RepositoryImportRecord, error) {
|
||||
return m.repo.ListRepositoryImports(ctx)
|
||||
}
|
||||
|
||||
func (m *Manager) AssessSupplyChain(ctx context.Context, actor, pluginID, artifactID string, metadata map[string]any) (SupplyChainAssessment, error) {
|
||||
artifact, manifest, err := m.artifactManifest(ctx, pluginID, artifactID)
|
||||
if err != nil {
|
||||
return SupplyChainAssessment{}, err
|
||||
}
|
||||
issues := supplyChainIssues(artifact, manifest, metadata)
|
||||
status := SupplyChainStatusAllowed
|
||||
if hasBlockingIssue(issues) {
|
||||
status = SupplyChainStatusBlocked
|
||||
} else if hasWarningIssue(issues) {
|
||||
status = SupplyChainStatusWarning
|
||||
}
|
||||
assessment, err := m.repo.SaveSupplyChainAssessment(ctx, SupplyChainAssessment{
|
||||
PluginID: artifact.PluginID, ArtifactID: artifact.ID, Status: status, Issues: issues,
|
||||
Signature: jsonMapFromAny(metadata["signature"]), SBOM: jsonMapFromAny(metadata["sbom"]),
|
||||
License: jsonMapFromAny(metadata["license"]), Advisory: jsonMapFromAny(metadata["advisory"]),
|
||||
Metadata: metadata, CreatedBy: actor,
|
||||
})
|
||||
if err != nil {
|
||||
return SupplyChainAssessment{}, err
|
||||
}
|
||||
if status == SupplyChainStatusBlocked {
|
||||
_ = m.repo.UpdateArtifactStatus(ctx, artifact.ID, ArtifactStatusRejected, "supply chain assessment blocked artifact")
|
||||
}
|
||||
return assessment, nil
|
||||
}
|
||||
|
||||
func (m *Manager) ListSupplyChainAssessments(ctx context.Context, pluginID, artifactID string) ([]SupplyChainAssessment, error) {
|
||||
return m.repo.ListSupplyChainAssessments(ctx, pluginID, artifactID)
|
||||
}
|
||||
|
||||
func (m *Manager) SaveInstrumentation(ctx context.Context, actor string, req InstrumentationRequest) (InstrumentationRecord, error) {
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
return InstrumentationRecord{}, errors.New("instrumentation name is required")
|
||||
}
|
||||
if req.RunbookRollback == "" {
|
||||
req.RunbookRollback = "Rollback by deploying the previous gateway binary."
|
||||
}
|
||||
record, err := m.repo.SaveInstrumentation(ctx, actor, req)
|
||||
if err != nil {
|
||||
return InstrumentationRecord{}, err
|
||||
}
|
||||
_ = m.repo.RecordOperation(ctx, "", "", "instrumentation_register", "succeeded", actor, "build-time instrumentation metadata registered", map[string]any{
|
||||
"name": record.Name, "version": record.Version, "runtime_plugin": false,
|
||||
})
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (m *Manager) ListInstrumentation(ctx context.Context) ([]InstrumentationRecord, error) {
|
||||
return m.repo.ListInstrumentation(ctx)
|
||||
}
|
||||
|
||||
type repositoryIndex struct {
|
||||
Name string `json:"name"`
|
||||
Candidates []repositoryCandidate `json:"artifacts"`
|
||||
}
|
||||
|
||||
type repositoryCandidate struct {
|
||||
ID string `json:"id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
Version string `json:"version"`
|
||||
ArtifactPath string `json:"artifact_path"`
|
||||
}
|
||||
|
||||
func readRepositoryIndex(path string) (repositoryIndex, error) {
|
||||
var index repositoryIndex
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return index, err
|
||||
}
|
||||
if err := json.Unmarshal(data, &index); err != nil {
|
||||
return index, err
|
||||
}
|
||||
if index.Name == "" {
|
||||
index.Name = "local"
|
||||
}
|
||||
return index, nil
|
||||
}
|
||||
|
||||
func selectRepositoryCandidate(index repositoryIndex, req RepositoryImportRequest) (repositoryCandidate, error) {
|
||||
for _, candidate := range index.Candidates {
|
||||
if req.ArtifactID != "" && candidate.ID != req.ArtifactID {
|
||||
continue
|
||||
}
|
||||
if req.PluginID != "" && candidate.PluginID != req.PluginID {
|
||||
continue
|
||||
}
|
||||
if req.Version != "" && candidate.Version != req.Version {
|
||||
continue
|
||||
}
|
||||
if candidate.ArtifactPath == "" {
|
||||
return repositoryCandidate{}, errors.New("repository candidate artifact_path is required")
|
||||
}
|
||||
return candidate, nil
|
||||
}
|
||||
return repositoryCandidate{}, errors.New("repository candidate not found")
|
||||
}
|
||||
|
||||
func supplyChainIssues(artifact ArtifactRecord, manifest Manifest, metadata map[string]any) []GovernanceIssue {
|
||||
var issues []GovernanceIssue
|
||||
signature := jsonMapFromAny(metadata["signature"])
|
||||
if requiredBool(signature, "required") && !requiredBool(signature, "verified") {
|
||||
issues = append(issues, issue("signature_unverified", GateSeverityBlocking, "required artifact signature is not verified", artifact.PluginID, artifact.ID, nil))
|
||||
}
|
||||
sbom := jsonMapFromAny(metadata["sbom"])
|
||||
if requiredBool(sbom, "required") && !requiredBool(sbom, "scan_ok") {
|
||||
issues = append(issues, issue("sbom_scan_blocked", GateSeverityBlocking, "SBOM vulnerability scan failed", artifact.PluginID, artifact.ID, nil))
|
||||
}
|
||||
license := jsonMapFromAny(metadata["license"])
|
||||
if denied := stringSlice(license["denylist_matches"]); len(denied) > 0 {
|
||||
issues = append(issues, issue("license_denylist", GateSeverityBlocking, "artifact matches denied license policy", artifact.PluginID, artifact.ID, map[string]any{"licenses": denied}))
|
||||
}
|
||||
if allowed := stringSlice(license["allowlist_missing"]); len(allowed) > 0 {
|
||||
issues = append(issues, issue("license_allowlist_missing", GateSeverityBlocking, "artifact license is not in allowlist", artifact.PluginID, artifact.ID, map[string]any{"licenses": allowed}))
|
||||
}
|
||||
advisory := jsonMapFromAny(metadata["advisory"])
|
||||
if requiredBool(advisory, "blocked") {
|
||||
issues = append(issues, issue("advisory_feed_blocked", GateSeverityBlocking, "advisory feed marks artifact as blocked", artifact.PluginID, artifact.ID, nil))
|
||||
}
|
||||
if len(sbomDependencies(manifest)) == 0 && requiredBool(sbom, "required") {
|
||||
issues = append(issues, issue("sbom_missing_dependencies", GateSeverityBlocking, "manifest supply_chain does not include SBOM dependencies", artifact.PluginID, artifact.ID, nil))
|
||||
}
|
||||
return sortedIssues(issues)
|
||||
}
|
||||
|
||||
func jsonMapFromAny(value any) map[string]any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
if out, ok := value.(map[string]any); ok {
|
||||
return out
|
||||
}
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out map[string]any
|
||||
if json.Unmarshal(data, &out) != nil {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func requiredBool(values map[string]any, key string) bool {
|
||||
value, _ := values[key].(bool)
|
||||
return value
|
||||
}
|
||||
229
internal/pluginmanager/future_test.go
Normal file
229
internal/pluginmanager/future_test.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package pluginmanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPluginServiceModeRestartRequiredAndApply(t *testing.T) {
|
||||
manager := newManagerForTest(t, &fakeAdapter{})
|
||||
state, err := manager.PluginServiceState(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("PluginServiceState() error = %v", err)
|
||||
}
|
||||
if state.DesiredMode != PluginServiceModeInProcess || state.ActiveMode != PluginServiceModeInProcess || state.RestartRequired {
|
||||
t.Fatalf("default service state = %+v, want in-process without restart", state)
|
||||
}
|
||||
state, err = manager.SetPluginServiceDesired(context.Background(), "admin", PluginServiceModeGoPluginProcess)
|
||||
if err != nil {
|
||||
t.Fatalf("SetPluginServiceDesired() error = %v", err)
|
||||
}
|
||||
if state.DesiredMode != PluginServiceModeGoPluginProcess || state.ActiveMode != PluginServiceModeInProcess || !state.RestartRequired {
|
||||
t.Fatalf("service state after desired switch = %+v, want restart required", state)
|
||||
}
|
||||
if err := manager.ApplyPluginServiceMode(context.Background()); err != nil {
|
||||
t.Fatalf("ApplyPluginServiceMode() error = %v", err)
|
||||
}
|
||||
state, _ = manager.PluginServiceState(context.Background())
|
||||
if state.ActiveMode != PluginServiceModeGoPluginProcess || state.RestartRequired {
|
||||
t.Fatalf("service state after apply = %+v, want go-plugin-process active", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoPluginProcessDrainOnlyAndCrashStatus(t *testing.T) {
|
||||
manager := newManagerForTest(t, &fakeAdapter{})
|
||||
if _, err := manager.SetPluginServiceDesired(context.Background(), "admin", PluginServiceModeGoPluginProcess); err != nil {
|
||||
t.Fatalf("SetPluginServiceDesired() error = %v", err)
|
||||
}
|
||||
if err := manager.ApplyPluginServiceMode(context.Background()); err != nil {
|
||||
t.Fatalf("ApplyPluginServiceMode() error = %v", err)
|
||||
}
|
||||
artifact := uploadTestArtifact(t, manager, "upstream-rewrite")
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "upstream-rewrite", artifact.ID, DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
if _, err := manager.Enable(context.Background(), "admin", "upstream-rewrite"); err != nil {
|
||||
t.Fatalf("Enable() error = %v", err)
|
||||
}
|
||||
status, err := manager.PluginServiceStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("PluginServiceStatus() error = %v", err)
|
||||
}
|
||||
host := findHostStatus(status.Hosts, "upstream-rewrite")
|
||||
if host.State != RuntimeEnabled || host.DrainMode != PluginMigrationDrainOnly {
|
||||
t.Fatalf("host after enable = %+v, want enabled drain-only", host)
|
||||
}
|
||||
if _, err := manager.Disable(context.Background(), "admin", "upstream-rewrite"); err != nil {
|
||||
t.Fatalf("Disable() error = %v", err)
|
||||
}
|
||||
status, _ = manager.PluginServiceStatus(context.Background())
|
||||
host = findHostStatus(status.Hosts, "upstream-rewrite")
|
||||
if host.State != RuntimeDisabled || host.ExitedAt == 0 {
|
||||
t.Fatalf("host after disable = %+v, want disabled/exited", host)
|
||||
}
|
||||
if err := manager.SimulatePluginHostCrash(context.Background(), "upstream-rewrite", "boom"); err != nil {
|
||||
t.Fatalf("SimulatePluginHostCrash() error = %v", err)
|
||||
}
|
||||
status, _ = manager.PluginServiceStatus(context.Background())
|
||||
host = findHostStatus(status.Hosts, "upstream-rewrite")
|
||||
if !host.CrashLoop || host.State != RuntimeFailed || !strings.Contains(host.LastError, "boom") {
|
||||
t.Fatalf("host after crash = %+v, want crash loop failed status", host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxRequiredCapabilityBlocksEnable(t *testing.T) {
|
||||
manager := newManagerForTest(t, &fakeAdapter{})
|
||||
artifact := uploadTestArtifactWithManifest(t, manager, "sandbox-plugin", func(manifest *Manifest) {
|
||||
manifest.Runtime.Type = RuntimeSandbox
|
||||
manifest.Runtime.Entry = RuntimeEntry
|
||||
manifest.Capabilities = json.RawMessage(`{"runtime":{"required_capabilities":["network.egress"]}}`)
|
||||
})
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "sandbox-plugin", artifact.ID, DesiredEnabled, `{}`, 10); err == nil || !strings.Contains(err.Error(), "sandbox-process runtime is disabled") {
|
||||
t.Fatalf("SetDesired(sandbox disabled) error = %v, want service mode block", err)
|
||||
}
|
||||
if _, err := manager.SetPluginServiceDesired(context.Background(), "admin", PluginServiceModeSandboxProcess); err != nil {
|
||||
t.Fatalf("SetPluginServiceDesired() error = %v", err)
|
||||
}
|
||||
if err := manager.ApplyPluginServiceMode(context.Background()); err != nil {
|
||||
t.Fatalf("ApplyPluginServiceMode() error = %v", err)
|
||||
}
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "sandbox-plugin", artifact.ID, DesiredEnabled, `{}`, 10); err == nil || !strings.Contains(err.Error(), "required capabilities") {
|
||||
t.Fatalf("SetDesired(sandbox capabilities) error = %v, want capability enforcement block", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWASMValidationContainment(t *testing.T) {
|
||||
manager := newManagerForTest(t, &fakeAdapter{})
|
||||
artifact := uploadTestArtifactWithManifest(t, manager, "wasm-plugin", func(manifest *Manifest) {
|
||||
manifest.Runtime.Type = RuntimeWASM
|
||||
manifest.Runtime.Entry = RuntimeWASMEntry
|
||||
manifest.RuntimeLimits.HandlerTimeoutMS = 10
|
||||
manifest.ExtensionPoints = []ExtensionPoint{{Type: "rule", Key: ExtensionRuleEvaluate}, {Type: "validator", Key: ExtensionConfigValidate}}
|
||||
})
|
||||
for _, behavior := range []string{"timeout", "panic", "memory"} {
|
||||
start := time.Now()
|
||||
err := manager.RunWASMValidation(context.Background(), "wasm-plugin", artifact.ID, behavior)
|
||||
if err == nil {
|
||||
t.Fatalf("RunWASMValidation(%s) error = nil, want contained error", behavior)
|
||||
}
|
||||
if time.Since(start) > time.Second {
|
||||
t.Fatalf("RunWASMValidation(%s) took too long", behavior)
|
||||
}
|
||||
}
|
||||
if err := manager.RunWASMValidation(context.Background(), "wasm-plugin", artifact.ID, "ok"); err != nil {
|
||||
t.Fatalf("RunWASMValidation(ok) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepositoryImportCreatesLocalArtifactWithoutEnable(t *testing.T) {
|
||||
manager := newManagerForTest(t, &fakeAdapter{})
|
||||
repoDir := t.TempDir()
|
||||
artifactPath := filepath.Join(repoDir, "repo-plugin.mcgp")
|
||||
manifestBytes := testManifestBytesWithCapabilities(t, "repo-plugin", nil)
|
||||
tmpPackage := writeTestMCGP(t, map[string][]byte{
|
||||
"manifest.json": manifestBytes,
|
||||
"plugin.so": []byte("repo plugin bytes"),
|
||||
})
|
||||
data, err := os.ReadFile(tmpPackage)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(package) error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(artifactPath, data, 0644); err != nil {
|
||||
t.Fatalf("WriteFile(repository artifact) error = %v", err)
|
||||
}
|
||||
indexPath := filepath.Join(repoDir, "index.json")
|
||||
index := map[string]any{
|
||||
"name": "local-test",
|
||||
"artifacts": []map[string]any{{
|
||||
"id": "repo-plugin-0.1.0",
|
||||
"plugin_id": "repo-plugin",
|
||||
"version": "0.1.0",
|
||||
"artifact_path": artifactPath,
|
||||
}},
|
||||
}
|
||||
indexBytes, _ := json.Marshal(index)
|
||||
if err := os.WriteFile(indexPath, indexBytes, 0644); err != nil {
|
||||
t.Fatalf("WriteFile(index) error = %v", err)
|
||||
}
|
||||
record, artifact, err := manager.ImportRepositoryArtifact(context.Background(), "admin", RepositoryImportRequest{
|
||||
RepositoryType: RepositoryTypeFile,
|
||||
IndexPath: indexPath,
|
||||
ArtifactID: "repo-plugin-0.1.0",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ImportRepositoryArtifact() error = %v", err)
|
||||
}
|
||||
if record.ArtifactID != artifact.ID || record.RepositoryName != "local-test" {
|
||||
t.Fatalf("import record = %+v artifact = %+v", record, artifact)
|
||||
}
|
||||
if _, err := manager.Plugin(context.Background(), "repo-plugin"); err != ErrPluginNotFound {
|
||||
t.Fatalf("Plugin(repo-plugin) error = %v, want not found because import must not auto-enable/create desired state", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupplyChainAssessmentBlocksGovernance(t *testing.T) {
|
||||
manager := newManagerForTest(t, &fakeAdapter{})
|
||||
artifact := uploadTestArtifactWithManifest(t, manager, "supply-plugin", func(manifest *Manifest) {
|
||||
manifest.SupplyChain = json.RawMessage(`{"dependencies":[{"name":"example.com/bad","version":"v1.0.0"}]}`)
|
||||
})
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "supply-plugin", artifact.ID, DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() before assessment error = %v", err)
|
||||
}
|
||||
assessment, err := manager.AssessSupplyChain(context.Background(), "admin", "supply-plugin", artifact.ID, map[string]any{
|
||||
"signature": map[string]any{"required": true, "verified": false},
|
||||
"sbom": map[string]any{"required": true, "scan_ok": false},
|
||||
"license": map[string]any{"denylist_matches": []any{"GPL-3.0"}},
|
||||
"advisory": map[string]any{"blocked": true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AssessSupplyChain() error = %v", err)
|
||||
}
|
||||
if assessment.Status != SupplyChainStatusBlocked || len(assessment.Issues) < 4 {
|
||||
t.Fatalf("assessment = %+v, want blocked with supply-chain issues", assessment)
|
||||
}
|
||||
if _, err := manager.Enable(context.Background(), "admin", "supply-plugin"); err == nil || !strings.Contains(err.Error(), "advisory_feed_blocked") {
|
||||
t.Fatalf("Enable() error = %v, want supply-chain governance block", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstrumentationIsNotRuntimePlugin(t *testing.T) {
|
||||
manager := newManagerForTest(t, &fakeAdapter{})
|
||||
record, err := manager.SaveInstrumentation(context.Background(), "admin", InstrumentationRequest{
|
||||
Name: "official-trace",
|
||||
Version: "0.1.0",
|
||||
Profile: "official",
|
||||
GeneratedDiffHash: "abc123",
|
||||
Conformance: map[string]any{"ok": true},
|
||||
Benchmark: map[string]any{"ok": true},
|
||||
Smoke: map[string]any{"ok": true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveInstrumentation() error = %v", err)
|
||||
}
|
||||
if record.RunbookRollback == "" {
|
||||
t.Fatalf("instrumentation missing rollback runbook: %+v", record)
|
||||
}
|
||||
plugins, err := manager.ListPlugins(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListPlugins() error = %v", err)
|
||||
}
|
||||
for _, plugin := range plugins {
|
||||
if plugin.ID == "official-trace" {
|
||||
t.Fatalf("instrumentation appeared as runtime plugin: %+v", plugin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func findHostStatus(hosts []PluginHostRuntimeSummary, pluginID string) PluginHostRuntimeSummary {
|
||||
for _, host := range hosts {
|
||||
if host.PluginID == pluginID {
|
||||
return host
|
||||
}
|
||||
}
|
||||
return PluginHostRuntimeSummary{}
|
||||
}
|
||||
@@ -431,6 +431,11 @@ func (m *Manager) evaluateGovernance(ctx context.Context, pluginID, artifactID,
|
||||
return GovernanceDecision{}, ConflictAnalysis{}, err
|
||||
}
|
||||
issues = append(issues, advisoryIssues...)
|
||||
supplyChainIssues, err := m.supplyChainGovernanceIssues(ctx, artifact)
|
||||
if err != nil {
|
||||
return GovernanceDecision{}, ConflictAnalysis{}, err
|
||||
}
|
||||
issues = append(issues, supplyChainIssues...)
|
||||
benchmarkIssues, err := m.benchmarkIssues(ctx, artifact, manifest, policy)
|
||||
if err != nil {
|
||||
return GovernanceDecision{}, ConflictAnalysis{}, err
|
||||
@@ -497,6 +502,20 @@ func (m *Manager) preflightChecks(ctx context.Context, plugin PluginRecord, arti
|
||||
Details: map[string]any{"features": missing},
|
||||
})
|
||||
}
|
||||
if artifact.RuntimeType == RuntimeSandbox && m.serviceMode != PluginServiceModeSandboxProcess {
|
||||
result.Checks = append(result.Checks, PreflightCheck{Code: "sandbox_runtime_disabled", Severity: GateSeverityBlocking, Message: "sandbox-process runtime is disabled by plugin service mode"})
|
||||
}
|
||||
if artifact.RuntimeType == RuntimeWASM && m.serviceMode != PluginServiceModeSandboxProcess {
|
||||
result.Checks = append(result.Checks, PreflightCheck{Code: "wasm_runtime_disabled", Severity: GateSeverityBlocking, Message: "wasm runtime is disabled by plugin service mode"})
|
||||
}
|
||||
if caps := requiredRuntimeCapabilities(artifact); artifact.RuntimeType == RuntimeSandbox && len(caps) > 0 {
|
||||
result.Checks = append(result.Checks, PreflightCheck{
|
||||
Code: "capability_enforcement_unavailable",
|
||||
Severity: GateSeverityBlocking,
|
||||
Message: "sandbox-process required capabilities cannot be enforced by this gateway",
|
||||
Details: map[string]any{"capabilities": caps},
|
||||
})
|
||||
}
|
||||
if manifest.RuntimeLimits.HandlerTimeoutMS > int(DefaultHandlerTimeout.Milliseconds()) {
|
||||
result.Checks = append(result.Checks, PreflightCheck{
|
||||
Code: "runtime_limits_warning",
|
||||
@@ -701,6 +720,25 @@ func (m *Manager) benchmarkIssues(ctx context.Context, artifact ArtifactRecord,
|
||||
return issues, nil
|
||||
}
|
||||
|
||||
func (m *Manager) supplyChainGovernanceIssues(ctx context.Context, artifact ArtifactRecord) ([]GovernanceIssue, error) {
|
||||
assessments, err := m.repo.ListSupplyChainAssessments(ctx, artifact.PluginID, artifact.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(assessments) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
latest := assessments[0]
|
||||
if latest.Status == SupplyChainStatusAllowed {
|
||||
return nil, nil
|
||||
}
|
||||
issues := append([]GovernanceIssue(nil), latest.Issues...)
|
||||
if latest.Status == SupplyChainStatusBlocked && len(issues) == 0 {
|
||||
issues = append(issues, issue("supply_chain_blocked", GateSeverityBlocking, "latest supply chain assessment blocks this artifact", artifact.PluginID, artifact.ID, nil))
|
||||
}
|
||||
return sortedIssues(issues), nil
|
||||
}
|
||||
|
||||
func (m *Manager) hasMatchingReview(ctx context.Context, pluginID, artifactID, profile string, fingerprint governanceFingerprintValue) (bool, error) {
|
||||
reviews, err := m.repo.ListReviews(ctx, pluginID)
|
||||
if err != nil {
|
||||
@@ -904,7 +942,10 @@ func requiredFeatures(manifest Manifest) []string {
|
||||
|
||||
func supportedFeature(feature string) bool {
|
||||
switch feature {
|
||||
case "", ExtensionUpstreamConnect, "upstream.connect", "minecraft", "config", "secret", "preflight", "self-test":
|
||||
case "", ExtensionUpstreamConnect, ExtensionRouteResolve, ExtensionRouteResolver, ExtensionStatusPing,
|
||||
ExtensionConnectionFilter, ExtensionHandshakeFilter, ExtensionEventSubscriber,
|
||||
ExtensionProvider, ExtensionAuthProvider, ExtensionAdminAuthProvider,
|
||||
"upstream.connect", "minecraft", "config", "secret", "preflight", "self-test":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/tursom/mc-gateway/plugin/api"
|
||||
"github.com/tursom/mc-gateway/plugin/official/rulepolicy"
|
||||
)
|
||||
|
||||
type RuntimeAdapter interface {
|
||||
@@ -78,6 +79,9 @@ func (a GoPluginAdapter) RunSelfTest(ctx context.Context, artifact ArtifactRecor
|
||||
|
||||
func (a GoPluginAdapter) instantiate(ctx context.Context, artifact ArtifactRecord, pluginRecord PluginRecord, gateway *Gateway, init bool) (api.Plugin, error) {
|
||||
_ = ctx
|
||||
if artifact.RuntimeType == RuntimeBuiltin || artifact.PluginID == "official.rule-policy" {
|
||||
return instantiateBuiltinPlugin(artifact, pluginRecord, gateway, init)
|
||||
}
|
||||
opened, err := stdplugin.Open(artifact.FilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -113,6 +117,31 @@ func (a GoPluginAdapter) instantiate(ctx context.Context, artifact ArtifactRecor
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
func instantiateBuiltinPlugin(artifact ArtifactRecord, pluginRecord PluginRecord, gateway *Gateway, init bool) (api.Plugin, error) {
|
||||
var instance api.Plugin
|
||||
switch artifact.PluginID {
|
||||
case "official.rule-policy":
|
||||
instance = rulepolicy.New()
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown builtin plugin %q", artifact.PluginID)
|
||||
}
|
||||
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 init {
|
||||
if err := instance.Init(gateway); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
func canUnmarshalInto(value any) bool {
|
||||
if value == nil {
|
||||
return false
|
||||
@@ -130,23 +159,39 @@ type Manager struct {
|
||||
wg *sync.WaitGroup
|
||||
policyProfile string
|
||||
|
||||
mu sync.Mutex
|
||||
loaded map[string]*loadedPlugin
|
||||
snapshot atomic.Value
|
||||
mu sync.Mutex
|
||||
loaded map[string]*loadedPlugin
|
||||
snapshot atomic.Value
|
||||
extensionSnapshot atomic.Value
|
||||
routeCacheMu sync.Mutex
|
||||
routeCache map[string]routeCacheEntry
|
||||
|
||||
proxyMu sync.Mutex
|
||||
proxySeq uint64
|
||||
proxyConns map[uint64]*proxyConnection
|
||||
drainingIDs map[string]bool
|
||||
operations *Operations
|
||||
|
||||
serviceMode string
|
||||
hostMu sync.Mutex
|
||||
hosts map[string]*pluginHostProcess
|
||||
}
|
||||
|
||||
type loadedPlugin struct {
|
||||
record PluginRecord
|
||||
artifact ArtifactRecord
|
||||
instance api.Plugin
|
||||
gateway *Gateway
|
||||
handlers []*upstreamHandler
|
||||
record PluginRecord
|
||||
artifact ArtifactRecord
|
||||
instance api.Plugin
|
||||
gateway *Gateway
|
||||
handlers []*upstreamHandler
|
||||
extensions pluginExtensions
|
||||
}
|
||||
|
||||
type pluginExtensions struct {
|
||||
routes []*routeHandler
|
||||
statuses []*statusHandler
|
||||
middleware []*middlewareHandler
|
||||
subscribers []*subscriberHandler
|
||||
providers []ProviderSummary
|
||||
}
|
||||
|
||||
type upstreamHandler struct {
|
||||
@@ -225,8 +270,10 @@ func New(options Options) *Manager {
|
||||
wg: options.WaitGroup,
|
||||
policyProfile: options.PolicyProfile,
|
||||
loaded: make(map[string]*loadedPlugin),
|
||||
routeCache: make(map[string]routeCacheEntry),
|
||||
proxyConns: make(map[uint64]*proxyConnection),
|
||||
drainingIDs: make(map[string]bool),
|
||||
hosts: make(map[string]*pluginHostProcess),
|
||||
}
|
||||
manager.operations = NewOperations(manager.repo, options.ArtifactRoot)
|
||||
if manager.builders == nil {
|
||||
@@ -236,9 +283,64 @@ func New(options Options) *Manager {
|
||||
}
|
||||
}
|
||||
manager.publish(nil)
|
||||
manager.publishExtensionsLocked(nil)
|
||||
_ = manager.EnsureOfficialPlugins(context.Background(), "system")
|
||||
_ = manager.ApplyPluginServiceMode(context.Background())
|
||||
return manager
|
||||
}
|
||||
|
||||
func (m *Manager) EnsureOfficialPlugins(ctx context.Context, actor string) error {
|
||||
now := time.Now().Unix()
|
||||
manifest := Manifest{
|
||||
SchemaVersion: SchemaVersion,
|
||||
ID: "official.rule-policy",
|
||||
Name: "Official Rule Policy",
|
||||
Version: "0.1.0",
|
||||
Description: "Built-in official rule/policy extension for host rewrite, CIDR policy, rate limit, maintenance mode and upstream rewrite.",
|
||||
ArtifactType: ArtifactTypeBinary,
|
||||
Runtime: RuntimeManifest{
|
||||
Type: RuntimeBuiltin,
|
||||
},
|
||||
APIVersion: APIVersion,
|
||||
ExtensionPoints: []ExtensionPoint{
|
||||
{Type: "middleware", Key: ExtensionConnectionFilter},
|
||||
{Type: "middleware", Key: ExtensionHandshakeFilter},
|
||||
{Type: "provider", Key: ExtensionRouteResolve},
|
||||
{Type: "hook", Key: ExtensionStatusPing},
|
||||
},
|
||||
Capabilities: json.RawMessage(`{"extension_points":["connection.filter/v1","handshake.filter/v1","route.resolve/v1","status.ping/v1"],"middleware":{"fail_policy":"fail_open"},"route":{"cache_ttl_ms":60000},"status":{"hosts":["*"]}}`),
|
||||
RuntimeLimits: RuntimeLimits{HandlerTimeoutMS: int(DefaultHandlerTimeout / time.Millisecond)},
|
||||
ConfigSchema: json.RawMessage(`{"type":"object","properties":{"host_rewrite":{"type":"object"},"upstream_rewrite":{"type":"object"},"source_allow_cidr":{"type":"array"},"source_deny_cidr":{"type":"array"},"rate_limit":{"type":"object"},"maintenance":{"type":"object"}}}`),
|
||||
}
|
||||
metadata, _ := json.Marshal(manifest)
|
||||
extensionPoints, _ := json.Marshal(manifest.ExtensionPoints)
|
||||
summaryJSON, _ := manifestCapabilitiesSummaryJSON(manifest)
|
||||
artifact := ArtifactRecord{
|
||||
ID: "builtin-official-rule-policy-0.1.0",
|
||||
PluginID: manifest.ID,
|
||||
Version: manifest.Version,
|
||||
FileName: "builtin:official.rule-policy",
|
||||
FilePath: "",
|
||||
SHA256: "builtin:official.rule-policy:0.1.0",
|
||||
PackageSHA256: "builtin:official.rule-policy:0.1.0",
|
||||
ArtifactType: ArtifactTypeBinary,
|
||||
RuntimeType: RuntimeBuiltin,
|
||||
Status: ArtifactStatusLoadable,
|
||||
MetadataJSON: string(metadata),
|
||||
CapabilitiesSummaryJSON: string(summaryJSON),
|
||||
ExtensionPointsJSON: string(extensionPoints),
|
||||
APIVersion: APIVersion,
|
||||
UploadedBy: actor,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := m.repo.SaveArtifact(ctx, artifact); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = m.repo.RecordOperation(ctx, manifest.ID, artifact.ID, "official_plugin_register", "succeeded", actor, "official rule/policy plugin registered", nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) UploadArtifact(ctx context.Context, upload ArtifactUpload) (ArtifactRecord, error) {
|
||||
artifact, err := m.store.ValidateAndStore(upload)
|
||||
if err != nil {
|
||||
@@ -700,8 +802,8 @@ func (m *Manager) Enable(ctx context.Context, actor, pluginID string) (PluginRec
|
||||
_ = 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)
|
||||
if len(loaded.handlers) == 0 && loaded.extensions.empty() {
|
||||
err := fmt.Errorf("plugin %q did not register any supported extension point", pluginID)
|
||||
_ = 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
|
||||
@@ -710,11 +812,15 @@ func (m *Manager) Enable(ctx context.Context, actor, pluginID string) (PluginRec
|
||||
current := m.currentHandlersLocked()
|
||||
current[pluginID] = loaded.handlers
|
||||
next := flattenHandlers(current)
|
||||
extensions := m.currentExtensionsLocked()
|
||||
extensions[pluginID] = loaded.extensions
|
||||
if err := m.markEnabled(ctx, loaded); err != nil {
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
m.markHostStarted(pluginID, loaded.artifact.ID)
|
||||
m.clearDrainingLocked(pluginID)
|
||||
m.publish(next)
|
||||
m.publishExtensionsLocked(extensions)
|
||||
_ = 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,
|
||||
@@ -737,7 +843,9 @@ func (m *Manager) Disable(ctx context.Context, actor, pluginID string) (PluginRe
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
m.removeFromDispatchLocked(pluginID)
|
||||
m.removeExtensionsLocked(pluginID)
|
||||
m.markDrainingLocked(pluginID)
|
||||
m.markHostDraining(pluginID)
|
||||
m.operations.StopPlugin(pluginID)
|
||||
if loaded := m.loaded[pluginID]; loaded != nil && loaded.instance != nil {
|
||||
if err := loaded.instance.Destroy(); err != nil {
|
||||
@@ -767,7 +875,9 @@ func (m *Manager) Delete(ctx context.Context, actor, pluginID string) error {
|
||||
return err
|
||||
}
|
||||
m.removeFromDispatchLocked(pluginID)
|
||||
m.removeExtensionsLocked(pluginID)
|
||||
m.markDrainingLocked(pluginID)
|
||||
m.markHostDraining(pluginID)
|
||||
m.operations.StopPlugin(pluginID)
|
||||
if loaded := m.loaded[pluginID]; loaded != nil && loaded.instance != nil {
|
||||
_ = loaded.instance.Destroy()
|
||||
@@ -791,6 +901,7 @@ func (m *Manager) Reconcile(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
nextByPlugin := make(map[string][]*upstreamHandler)
|
||||
extensionsByPlugin := make(map[string]pluginExtensions)
|
||||
for _, pluginRecord := range desired {
|
||||
decision, err := m.EvaluateGovernance(ctx, pluginRecord.ID, pluginRecord.DesiredArtifactID, GovernanceActionEnable, m.currentPolicyProfile(), pluginRecord.ConfigJSON)
|
||||
if err == nil && !decision.OK {
|
||||
@@ -807,17 +918,20 @@ func (m *Manager) Reconcile(ctx context.Context) error {
|
||||
_ = 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)
|
||||
if len(loaded.handlers) == 0 && loaded.extensions.empty() {
|
||||
err := fmt.Errorf("plugin %q did not register any supported extension point", pluginRecord.ID)
|
||||
_ = 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
|
||||
extensionsByPlugin[pluginRecord.ID] = loaded.extensions
|
||||
_ = m.markEnabled(ctx, loaded)
|
||||
m.markHostStarted(pluginRecord.ID, loaded.artifact.ID)
|
||||
m.clearDrainingLocked(pluginRecord.ID)
|
||||
}
|
||||
m.publish(flattenHandlers(nextByPlugin))
|
||||
m.publishExtensionsLocked(extensionsByPlugin)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1028,6 +1142,7 @@ func (m *Manager) quarantineAffected(ctx context.Context, advisory AdvisoryRecor
|
||||
if advisoryMatches(advisory, artifact, manifest) {
|
||||
m.removeFromDispatchLocked(plugin.ID)
|
||||
m.markDrainingLocked(plugin.ID)
|
||||
m.markHostDraining(plugin.ID)
|
||||
_ = m.repo.MarkRuntime(ctx, plugin.ID, RuntimeDraining, artifact.ID, artifact.ID, plugin.AppliedGeneration, "plugin quarantined by advisory "+advisory.AdvisoryID, map[string]any{
|
||||
"quarantine": true,
|
||||
"advisory_id": advisory.AdvisoryID,
|
||||
@@ -1178,6 +1293,13 @@ func (m *Manager) DispatchPlan(ctx context.Context) DispatchPlan {
|
||||
if handlers, ok := value.([]*upstreamHandler); ok {
|
||||
plan.Handlers = handlerSummaries(handlers)
|
||||
}
|
||||
state := m.extensionState()
|
||||
plan.Routes = routeHandlerSummaries(state.routes)
|
||||
plan.Statuses = statusHandlerSummaries(state.statuses)
|
||||
plan.Middleware = middlewareHandlerSummaries(state.middleware)
|
||||
plan.Subscribers = subscriberHandlerSummaries(state.subscribers)
|
||||
plan.Providers = append([]ProviderSummary(nil), state.providers...)
|
||||
plan.RouteCache = m.RouteCacheSnapshot()
|
||||
return plan
|
||||
}
|
||||
|
||||
@@ -1194,6 +1316,13 @@ func (m *Manager) OperationsSnapshot(ctx context.Context, pluginID string) (Oper
|
||||
handlers = append(handlers, handler)
|
||||
}
|
||||
}
|
||||
for _, group := range [][]DispatchHandlerSummary{plan.Routes, plan.Statuses, plan.Middleware, plan.Subscribers} {
|
||||
for _, handler := range group {
|
||||
if pluginID == "" || handler.PluginID == pluginID {
|
||||
handlers = append(handlers, handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
builds, err := m.repo.ListBuilds(ctx, pluginID)
|
||||
if err != nil {
|
||||
return OperationsSnapshot{}, err
|
||||
@@ -1373,17 +1502,21 @@ func (m *Manager) loadLocked(ctx context.Context, pluginRecord PluginRecord) (*l
|
||||
return nil, err
|
||||
}
|
||||
handlers := buildHandlers(pluginRecord, artifact, gateway)
|
||||
extensions := buildExtensions(pluginRecord, artifact, gateway)
|
||||
loaded := &loadedPlugin{
|
||||
record: pluginRecord,
|
||||
artifact: artifact,
|
||||
instance: instance,
|
||||
gateway: gateway,
|
||||
handlers: handlers,
|
||||
record: pluginRecord,
|
||||
artifact: artifact,
|
||||
instance: instance,
|
||||
gateway: gateway,
|
||||
handlers: handlers,
|
||||
extensions: extensions,
|
||||
}
|
||||
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 {
|
||||
"handler_count": len(handlers),
|
||||
"extension_count": extensions.count(),
|
||||
"service_mode": m.serviceMode,
|
||||
}, loaded.dispatchSummaries()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.operations.StartTasks(pluginRecord.ID)
|
||||
@@ -1397,6 +1530,24 @@ func (m *Manager) validateArtifactGate(artifact ArtifactRecord) error {
|
||||
if artifact.ArtifactType != ArtifactTypeBinary {
|
||||
return errors.New("desired artifact must be a binary artifact")
|
||||
}
|
||||
if artifact.RuntimeType == RuntimeBuiltin {
|
||||
return nil
|
||||
}
|
||||
if artifact.RuntimeType == RuntimeSandbox {
|
||||
if m.serviceMode != PluginServiceModeSandboxProcess {
|
||||
return errors.New("sandbox-process runtime is disabled by plugin service mode")
|
||||
}
|
||||
if caps := requiredRuntimeCapabilities(artifact); len(caps) > 0 {
|
||||
return fmt.Errorf("sandbox-process cannot enforce required capabilities: %s", strings.Join(caps, ","))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if artifact.RuntimeType == RuntimeWASM {
|
||||
if m.serviceMode != PluginServiceModeSandboxProcess {
|
||||
return errors.New("wasm runtime is disabled by plugin service mode")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if artifact.GoVersion != runtime.Version() {
|
||||
return fmt.Errorf("artifact go_version %q does not match gateway %q", artifact.GoVersion, runtime.Version())
|
||||
}
|
||||
@@ -1437,8 +1588,11 @@ func (m *Manager) restartRequired(pluginID, artifactID string) bool {
|
||||
func (m *Manager) markEnabled(ctx context.Context, loaded *loadedPlugin) error {
|
||||
m.operations.StartTasks(loaded.record.ID)
|
||||
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))
|
||||
"handler_count": len(loaded.handlers),
|
||||
"extension_count": loaded.extensions.count(),
|
||||
"service_mode": m.serviceMode,
|
||||
"plugin_host": m.hostSummary(loaded.record.ID),
|
||||
}, loaded.dispatchSummaries())
|
||||
}
|
||||
|
||||
func (m *Manager) currentHandlersLocked() map[string][]*upstreamHandler {
|
||||
@@ -1562,6 +1716,14 @@ func upstreamModeFromArtifact(artifact ArtifactRecord) string {
|
||||
return UpstreamModeDialer
|
||||
}
|
||||
|
||||
func requiredRuntimeCapabilities(artifact ArtifactRecord) []string {
|
||||
var summary CapabilitySummary
|
||||
if json.Unmarshal([]byte(artifact.CapabilitiesSummaryJSON), &summary) != nil {
|
||||
return nil
|
||||
}
|
||||
return uniqueSortedStrings(summary.Runtime.RequiredCapabilities)
|
||||
}
|
||||
|
||||
func (h *upstreamHandler) invoke(req api.UpstreamConnectRequest) (conn net.Conn, err error) {
|
||||
h.calls.Add(1)
|
||||
start := time.Now()
|
||||
|
||||
@@ -797,6 +797,199 @@ func TestProtocolProxyInitialWriteTimeoutClosesUnreadableConn(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteResolverDecisionsCacheAndFallback(t *testing.T) {
|
||||
calls := 0
|
||||
adapter := &fakeAdapter{
|
||||
initOnly: true,
|
||||
initHook: func(gateway *Gateway) error {
|
||||
return api.RegisterHookHandler(gateway, api.HookRouteResolve,
|
||||
func(api.RouteResolveRequest) bool { return true },
|
||||
func(req api.RouteResolveRequest) (api.RouteDecision, error) {
|
||||
calls++
|
||||
switch req.Host {
|
||||
case "override.example":
|
||||
return api.RouteDecision{Action: api.RouteDecisionOverride, Upstream: "10.0.0.10:25565", Reason: "test override", CacheTTL: time.Minute}, nil
|
||||
case "reject.example":
|
||||
return api.RouteDecision{Action: api.RouteDecisionReject, Reason: "test reject", CacheTTL: time.Minute}, nil
|
||||
case "provider-fallback.example":
|
||||
return api.RouteDecision{Action: api.RouteDecisionFallback, Upstream: "provider-fallback:25565", Reason: "provider fallback", CacheTTL: time.Minute}, nil
|
||||
case "pass.example":
|
||||
return api.RouteDecision{Action: api.RouteDecisionPass}, nil
|
||||
case "cached.example":
|
||||
if calls == 1 {
|
||||
return api.RouteDecision{Action: api.RouteDecisionOverride, Upstream: "10.0.0.20:25565", Reason: "cached", CacheTTL: time.Minute}, nil
|
||||
}
|
||||
return api.RouteDecision{}, errors.New("source unavailable")
|
||||
default:
|
||||
return api.RouteDecision{}, errors.New("source unavailable")
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
manager := newManagerForTest(t, adapter)
|
||||
artifact := uploadTestArtifactWithManifest(t, manager, "route-plugin", func(manifest *Manifest) {
|
||||
manifest.ExtensionPoints = []ExtensionPoint{{Type: "provider", Key: ExtensionRouteResolve}}
|
||||
manifest.Capabilities = json.RawMessage(`{"extension_points":["route.resolve/v1"],"route":{"cache_ttl_ms":60000}}`)
|
||||
})
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "route-plugin", artifact.ID, DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
if _, err := manager.Enable(context.Background(), "admin", "route-plugin"); err != nil {
|
||||
t.Fatalf("Enable() error = %v", err)
|
||||
}
|
||||
|
||||
override, err := manager.ResolveRoute(context.Background(), api.RouteResolveRequest{Host: "override.example"}, nil)
|
||||
if err != nil || override.Decision.Action != api.RouteDecisionOverride || override.Decision.Upstream == "" {
|
||||
t.Fatalf("override decision = %+v err=%v", override, err)
|
||||
}
|
||||
reject, err := manager.ResolveRoute(context.Background(), api.RouteResolveRequest{Host: "reject.example"}, nil)
|
||||
if err != nil || reject.Decision.Action != api.RouteDecisionReject {
|
||||
t.Fatalf("reject decision = %+v err=%v", reject, err)
|
||||
}
|
||||
providerFallback, err := manager.ResolveRoute(context.Background(), api.RouteResolveRequest{Host: "provider-fallback.example"}, nil)
|
||||
if err != nil || providerFallback.Decision.Action != api.RouteDecisionFallback || providerFallback.Decision.Upstream != "provider-fallback:25565" {
|
||||
t.Fatalf("provider fallback decision = %+v err=%v", providerFallback, err)
|
||||
}
|
||||
pass, err := manager.ResolveRoute(context.Background(), api.RouteResolveRequest{Host: "pass.example", FallbackUpstream: "sqlite:25565", FallbackHit: true}, nil)
|
||||
if err != nil || pass.Source != "sqlite_fallback" || pass.Decision.Upstream != "sqlite:25565" {
|
||||
t.Fatalf("pass fallback = %+v err=%v", pass, err)
|
||||
}
|
||||
calls = 0
|
||||
first, err := manager.ResolveRoute(context.Background(), api.RouteResolveRequest{Host: "cached.example"}, nil)
|
||||
if err != nil || first.Source != "provider" {
|
||||
t.Fatalf("first cached decision = %+v err=%v", first, err)
|
||||
}
|
||||
second, err := manager.ResolveRoute(context.Background(), api.RouteResolveRequest{Host: "cached.example"}, nil)
|
||||
if err != nil || second.Source != "cache" || second.Decision.Upstream != "10.0.0.20:25565" {
|
||||
t.Fatalf("cache fallback decision = %+v err=%v", second, err)
|
||||
}
|
||||
sqlite, err := manager.ResolveRoute(context.Background(), api.RouteResolveRequest{Host: "down.example", FallbackUpstream: "sqlite-down:25565", FallbackHit: true}, nil)
|
||||
if err != nil || sqlite.Source != "sqlite_fallback" || sqlite.Decision.Upstream != "sqlite-down:25565" {
|
||||
t.Fatalf("sqlite fallback = %+v err=%v", sqlite, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusPingPerHostAndDisableFallsBack(t *testing.T) {
|
||||
adapter := &fakeAdapter{
|
||||
initOnly: true,
|
||||
initHook: func(gateway *Gateway) error {
|
||||
return api.RegisterHookHandler(gateway, api.HookStatusPing,
|
||||
func(api.StatusPingRequest) bool { return true },
|
||||
func(req api.StatusPingRequest) (api.StatusPingResponse, error) {
|
||||
return api.StatusPingResponse{MOTD: "motd for " + req.Host, VersionText: "v1", MaxPlayers: 20}, nil
|
||||
})
|
||||
},
|
||||
}
|
||||
manager := newManagerForTest(t, adapter)
|
||||
artifact := uploadTestArtifactWithManifest(t, manager, "status-plugin", func(manifest *Manifest) {
|
||||
manifest.ExtensionPoints = []ExtensionPoint{{Type: "hook", Key: ExtensionStatusPing}}
|
||||
manifest.Capabilities = json.RawMessage(`{"extension_points":["status.ping/v1"],"status":{"hosts":["a.example","b.example"]}}`)
|
||||
})
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "status-plugin", artifact.ID, DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
if _, err := manager.Enable(context.Background(), "admin", "status-plugin"); err != nil {
|
||||
t.Fatalf("Enable() error = %v", err)
|
||||
}
|
||||
status, err := manager.StatusPing(context.Background(), api.StatusPingRequest{Host: "a.example"})
|
||||
if err != nil || !status.Handled || status.Response.MOTD != "motd for a.example" {
|
||||
t.Fatalf("StatusPing() = %+v err=%v", status, err)
|
||||
}
|
||||
if _, err := manager.Disable(context.Background(), "admin", "status-plugin"); err != nil {
|
||||
t.Fatalf("Disable() error = %v", err)
|
||||
}
|
||||
status, err = manager.StatusPing(context.Background(), api.StatusPingRequest{Host: "a.example"})
|
||||
if err != nil || status.Handled {
|
||||
t.Fatalf("StatusPing(disabled) = %+v err=%v, want default fallback", status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventSubscriberFailureDoesNotAffectEmitter(t *testing.T) {
|
||||
adapter := &fakeAdapter{
|
||||
initOnly: true,
|
||||
initHook: func(gateway *Gateway) error {
|
||||
return api.RegisterHookHandler(gateway, api.HookEventSubscriber,
|
||||
func(api.EventDeliveryRequest) bool { return true },
|
||||
func(api.EventDeliveryRequest) (api.EventDeliveryResult, error) {
|
||||
return api.EventDeliveryResult{Retry: true, Reason: "sink down"}, errors.New("sink down")
|
||||
})
|
||||
},
|
||||
}
|
||||
manager := newManagerForTest(t, adapter)
|
||||
artifact := uploadTestArtifactWithManifest(t, manager, "subscriber-plugin", func(manifest *Manifest) {
|
||||
manifest.ExtensionPoints = []ExtensionPoint{{Type: "event", Key: ExtensionEventSubscriber}}
|
||||
manifest.Capabilities = json.RawMessage(`{"extension_points":["event.subscriber/v1"],"event_subscriber":{"mode":"at_least_once","max_retry":1}}`)
|
||||
})
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "subscriber-plugin", artifact.ID, DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
if _, err := manager.Enable(context.Background(), "admin", "subscriber-plugin"); err != nil {
|
||||
t.Fatalf("Enable() error = %v", err)
|
||||
}
|
||||
emitterManifest := Manifest{Events: []EventSpec{{Name: "audit.test", Fields: []string{"ok"}}}}
|
||||
if err := manager.operations.ForPlugin("emitter", "artifact", emitterManifest).EmitEvent(context.Background(), "audit.test", map[string]string{"ok": "true"}); err != nil {
|
||||
t.Fatalf("EmitEvent() error = %v", err)
|
||||
}
|
||||
waitForPluginManagerTest(t, func() bool {
|
||||
return manager.operations.SubscriberDeadLetters() > 0
|
||||
})
|
||||
}
|
||||
|
||||
func TestProviderRegistryIncludesUnavailableAdminAuthProvider(t *testing.T) {
|
||||
adapter := &fakeAdapter{
|
||||
initOnly: true,
|
||||
initHook: func(gateway *Gateway) error {
|
||||
return api.RegisterHookHandler(gateway, api.HookAdminAuthProvider,
|
||||
func(api.ProviderRegistration) bool { return true },
|
||||
func() (api.ProviderRegistration, error) {
|
||||
return api.ProviderRegistration{Type: "admin.auth.provider/v1", Name: "oidc", Fallback: true}, errors.New("oidc down")
|
||||
})
|
||||
},
|
||||
}
|
||||
manager := newManagerForTest(t, adapter)
|
||||
artifact := uploadTestArtifactWithManifest(t, manager, "admin-auth-plugin", func(manifest *Manifest) {
|
||||
manifest.ExtensionPoints = []ExtensionPoint{{Type: "provider", Key: ExtensionAdminAuthProvider}}
|
||||
manifest.Capabilities = json.RawMessage(`{"extension_points":["admin.auth.provider/v1"],"providers":[{"type":"admin.auth.provider/v1","name":"oidc","fallback":true}]}`)
|
||||
})
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "admin-auth-plugin", artifact.ID, DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
if _, err := manager.Enable(context.Background(), "admin", "admin-auth-plugin"); err != nil {
|
||||
t.Fatalf("Enable() error = %v", err)
|
||||
}
|
||||
plan := manager.DispatchPlan(context.Background())
|
||||
if len(plan.Providers) != 1 || plan.Providers[0].Status != "unavailable" || !plan.Providers[0].Fallback {
|
||||
t.Fatalf("providers = %+v, want unavailable fallback admin auth provider", plan.Providers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfficialRulePolicyBadCIDRDoesNotBreakDefaultRoute(t *testing.T) {
|
||||
manager := newManagerForTest(t, nil)
|
||||
artifact, err := manager.Artifact(context.Background(), "builtin-official-rule-policy-0.1.0")
|
||||
if err != nil {
|
||||
t.Fatalf("official artifact missing: %v", err)
|
||||
}
|
||||
config := `{"source_deny_cidr":["not-a-cidr"],"upstream_rewrite":{"play.example":"rewrite:25565"}}`
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "official.rule-policy", artifact.ID, DesiredEnabled, config, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
if _, err := manager.Enable(context.Background(), "admin", "official.rule-policy"); err != nil {
|
||||
t.Fatalf("Enable() error = %v", err)
|
||||
}
|
||||
filter, err := manager.FilterConnection(context.Background(), api.ConnectionFilterRequest{SourceAddr: "127.0.0.1:12345"})
|
||||
if err != nil || !filter.Allowed {
|
||||
t.Fatalf("FilterConnection() = %+v err=%v, want allowed despite invalid CIDR", filter, err)
|
||||
}
|
||||
route, err := manager.ResolveRoute(context.Background(), api.RouteResolveRequest{Host: "unknown.example", FallbackUpstream: "default:25565", FallbackHit: true}, nil)
|
||||
if err != nil || route.Source != "sqlite_fallback" || route.Decision.Upstream != "default:25565" {
|
||||
t.Fatalf("default route fallback = %+v err=%v", route, err)
|
||||
}
|
||||
rewrite, err := manager.ResolveRoute(context.Background(), api.RouteResolveRequest{Host: "play.example", FallbackUpstream: "default:25565", FallbackHit: true}, nil)
|
||||
if err != nil || rewrite.Decision.Action != api.RouteDecisionOverride || rewrite.Decision.Upstream != "rewrite:25565" {
|
||||
t.Fatalf("upstream rewrite = %+v err=%v", rewrite, err)
|
||||
}
|
||||
}
|
||||
|
||||
func enableProtocolProxyTestPlugin(t *testing.T, manager *Manager, pluginID string) ArtifactRecord {
|
||||
t.Helper()
|
||||
artifact := uploadTestArtifactWithCapabilities(t, manager, pluginID, testProtocolProxyCapabilities())
|
||||
@@ -888,9 +1081,13 @@ func uploadTestArtifactWithManifest(t *testing.T, manager *Manager, pluginID str
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal manifest error = %v", err)
|
||||
}
|
||||
entry := manifest.Runtime.Entry
|
||||
if entry == "" {
|
||||
entry = RuntimeEntry
|
||||
}
|
||||
packagePath := writeTestMCGP(t, map[string][]byte{
|
||||
"manifest.json": manifestBytes,
|
||||
"plugin.so": []byte("fake plugin bytes " + pluginID),
|
||||
entry: []byte("fake plugin bytes " + pluginID),
|
||||
})
|
||||
artifact, err := manager.UploadArtifact(context.Background(), ArtifactUpload{
|
||||
SourcePath: packagePath,
|
||||
@@ -1054,6 +1251,8 @@ func waitForPluginManagerTest(t *testing.T, done func() bool) {
|
||||
type fakeAdapter struct {
|
||||
loads int
|
||||
handlers map[string]api.UpstreamConnectHandler
|
||||
initOnly bool
|
||||
initHook func(*Gateway) error
|
||||
init func(*Gateway)
|
||||
loadErr error
|
||||
loadErrs map[string]error
|
||||
@@ -1075,13 +1274,20 @@ func (a *fakeAdapter) Load(_ context.Context, artifact ArtifactRecord, _ PluginR
|
||||
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
|
||||
if !a.initOnly {
|
||||
if err := api.RegisterHookHandler(
|
||||
gateway,
|
||||
api.HookUpstreamConnect,
|
||||
func(api.UpstreamConnectRequest) bool { return true },
|
||||
handler,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if a.initHook != nil {
|
||||
if err := a.initHook(gateway); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if a.init != nil {
|
||||
a.init(gateway)
|
||||
|
||||
@@ -55,6 +55,14 @@ type Operations struct {
|
||||
queued atomic.Uint64
|
||||
dropped atomic.Uint64
|
||||
deadLetter atomic.Uint64
|
||||
|
||||
subscriberQueue chan queuedEvent
|
||||
subscriberQueued atomic.Uint64
|
||||
subscriberDropped atomic.Uint64
|
||||
subscriberDeadLetter atomic.Uint64
|
||||
|
||||
subscriberMu sync.RWMutex
|
||||
subscribers []*subscriberHandler
|
||||
}
|
||||
|
||||
type queuedEvent struct {
|
||||
@@ -126,15 +134,31 @@ type taskRuntime struct {
|
||||
|
||||
func NewOperations(repo Repository, root string) *Operations {
|
||||
ops := &Operations{
|
||||
repo: repo,
|
||||
root: root,
|
||||
plugins: make(map[string]*PluginOperations),
|
||||
eventQueue: make(chan queuedEvent, DefaultEventQueueLimit),
|
||||
repo: repo,
|
||||
root: root,
|
||||
plugins: make(map[string]*PluginOperations),
|
||||
eventQueue: make(chan queuedEvent, DefaultEventQueueLimit),
|
||||
subscriberQueue: make(chan queuedEvent, DefaultEventQueueLimit),
|
||||
}
|
||||
go ops.consumeEvents()
|
||||
go ops.consumeSubscriberEvents()
|
||||
return ops
|
||||
}
|
||||
|
||||
func (o *Operations) SetSubscribers(subscribers []*subscriberHandler) {
|
||||
o.subscriberMu.Lock()
|
||||
defer o.subscriberMu.Unlock()
|
||||
o.subscribers = append([]*subscriberHandler(nil), subscribers...)
|
||||
}
|
||||
|
||||
func (o *Operations) SubscriberDeadLetters() uint64 {
|
||||
return o.subscriberDeadLetter.Load()
|
||||
}
|
||||
|
||||
func (o *Operations) DropSubscriberDeadLetters() uint64 {
|
||||
return o.subscriberDeadLetter.Swap(0)
|
||||
}
|
||||
|
||||
func (o *Operations) ForPlugin(pluginID, artifactID string, manifest Manifest) *PluginOperations {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
@@ -202,6 +226,71 @@ func (o *Operations) queueEvent(event queuedEvent) {
|
||||
}, true, "event_queue_full", event.traceID, event.connectionID)
|
||||
cancel()
|
||||
}
|
||||
o.queueSubscriberEvent(event)
|
||||
}
|
||||
|
||||
func (o *Operations) queueSubscriberEvent(event queuedEvent) {
|
||||
o.subscriberMu.RLock()
|
||||
hasSubscribers := len(o.subscribers) > 0
|
||||
o.subscriberMu.RUnlock()
|
||||
if !hasSubscribers || event.dropped {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case o.subscriberQueue <- event:
|
||||
o.subscriberQueued.Add(1)
|
||||
default:
|
||||
o.subscriberDropped.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (o *Operations) consumeSubscriberEvents() {
|
||||
for event := range o.subscriberQueue {
|
||||
o.subscriberMu.RLock()
|
||||
subscribers := append([]*subscriberHandler(nil), o.subscribers...)
|
||||
o.subscriberMu.RUnlock()
|
||||
for _, subscriber := range subscribers {
|
||||
o.deliverSubscriberEvent(subscriber, event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (o *Operations) deliverSubscriberEvent(subscriber *subscriberHandler, event queuedEvent) {
|
||||
req := api.EventDeliveryRequest{
|
||||
PluginID: event.pluginID,
|
||||
Name: event.name,
|
||||
Fields: copyStringMap(event.fields),
|
||||
TraceID: event.traceID,
|
||||
ConnectionID: event.connectionID,
|
||||
Mode: subscriber.mode,
|
||||
}
|
||||
accepted, err := subscriber.accepts(req)
|
||||
if err != nil || !accepted {
|
||||
return
|
||||
}
|
||||
maxRetry := subscriber.maxRetry
|
||||
if subscriber.mode == api.DeliveryBestEffort {
|
||||
maxRetry = 1
|
||||
}
|
||||
if maxRetry <= 0 {
|
||||
maxRetry = DefaultSubscriberMaxRetry
|
||||
}
|
||||
for attempt := 1; attempt <= maxRetry; attempt++ {
|
||||
req.Attempt = attempt
|
||||
result, err := subscriber.invoke(req)
|
||||
if err == nil && (result.OK || !result.Retry) {
|
||||
return
|
||||
}
|
||||
if attempt < maxRetry {
|
||||
time.Sleep(DefaultSubscriberRetryDelay)
|
||||
}
|
||||
}
|
||||
o.subscriberDeadLetter.Add(1)
|
||||
_ = o.repo.RecordOperation(context.Background(), subscriber.pluginID, subscriber.artifactID, "event_subscriber_delivery", "dead_letter", "system", "event subscriber delivery failed", map[string]any{
|
||||
"event_plugin_id": event.pluginID,
|
||||
"event_name": event.name,
|
||||
"subscriber_mode": subscriber.mode,
|
||||
})
|
||||
}
|
||||
|
||||
func (po *PluginOperations) configure(artifactID string, manifest Manifest) {
|
||||
@@ -581,10 +670,13 @@ func (po *PluginOperations) Snapshot(ctx context.Context, pluginID string, handl
|
||||
ExternalDependencies: externals,
|
||||
GC: gc,
|
||||
EventQueue: EventQueueSummary{
|
||||
Limit: DefaultEventQueueLimit,
|
||||
Queued: len(po.parent.eventQueue),
|
||||
Dropped: po.parent.dropped.Load(),
|
||||
DeadLetters: po.parent.deadLetter.Load(),
|
||||
Limit: DefaultEventQueueLimit,
|
||||
Queued: len(po.parent.eventQueue),
|
||||
Dropped: po.parent.dropped.Load(),
|
||||
DeadLetters: po.parent.deadLetter.Load(),
|
||||
SubscriberQueued: po.parent.subscriberQueued.Load(),
|
||||
SubscriberDropped: po.parent.subscriberDropped.Load(),
|
||||
SubscriberDeadLetters: po.parent.subscriberDeadLetter.Load(),
|
||||
},
|
||||
Diagnostics: diagnostics,
|
||||
}
|
||||
|
||||
@@ -306,6 +306,62 @@ WHERE id = ?`,
|
||||
return err
|
||||
}
|
||||
|
||||
func (r Repository) PluginServiceState(ctx context.Context) (PluginServiceState, error) {
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT desired_mode, active_mode, applied_at, live_migration, last_error, updated_by, updated_at
|
||||
FROM plugin_service_state WHERE id = 1`)
|
||||
state, err := scanPluginServiceState(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
now := r.now().Unix()
|
||||
_, err = r.db.ExecContext(ctx, `
|
||||
INSERT OR IGNORE INTO plugin_service_state(id, desired_mode, active_mode, applied_at, live_migration, updated_by, updated_at)
|
||||
VALUES (1, ?, ?, ?, ?, ?, ?)`,
|
||||
PluginServiceModeInProcess, PluginServiceModeInProcess, now, PluginMigrationDrainOnly, "system", now)
|
||||
if err != nil {
|
||||
return PluginServiceState{}, err
|
||||
}
|
||||
return PluginServiceState{
|
||||
DesiredMode: PluginServiceModeInProcess,
|
||||
ActiveMode: PluginServiceModeInProcess,
|
||||
AppliedAt: now,
|
||||
LiveMigration: PluginMigrationDrainOnly,
|
||||
UpdatedBy: "system",
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
state = normalizePluginServiceState(state)
|
||||
return state, err
|
||||
}
|
||||
|
||||
func (r Repository) SetPluginServiceDesired(ctx context.Context, actor, mode string) (PluginServiceState, error) {
|
||||
now := r.now().Unix()
|
||||
if _, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO plugin_service_state(id, desired_mode, active_mode, applied_at, live_migration, updated_by, updated_at)
|
||||
VALUES (1, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET desired_mode = excluded.desired_mode, updated_by = excluded.updated_by, updated_at = excluded.updated_at`,
|
||||
mode, PluginServiceModeInProcess, 0, PluginMigrationDrainOnly, actor, now); err != nil {
|
||||
return PluginServiceState{}, err
|
||||
}
|
||||
return r.PluginServiceState(ctx)
|
||||
}
|
||||
|
||||
func (r Repository) ApplyPluginServiceActive(ctx context.Context, mode string) (PluginServiceState, error) {
|
||||
now := r.now().Unix()
|
||||
if _, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO plugin_service_state(id, desired_mode, active_mode, applied_at, live_migration, last_error, updated_by, updated_at)
|
||||
VALUES (1, ?, ?, ?, ?, '', 'system', ?)
|
||||
ON CONFLICT(id) DO UPDATE SET active_mode = excluded.active_mode, applied_at = excluded.applied_at, last_error = '', updated_at = excluded.updated_at`,
|
||||
mode, mode, now, PluginMigrationDrainOnly, now); err != nil {
|
||||
return PluginServiceState{}, err
|
||||
}
|
||||
return r.PluginServiceState(ctx)
|
||||
}
|
||||
|
||||
func (r Repository) SetPluginServiceError(ctx context.Context, message string) error {
|
||||
_, err := r.db.ExecContext(ctx, `UPDATE plugin_service_state SET last_error = ?, updated_at = ? WHERE id = 1`, message, r.now().Unix())
|
||||
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)
|
||||
@@ -818,6 +874,151 @@ FROM plugin_advisories`
|
||||
return advisories, rows.Err()
|
||||
}
|
||||
|
||||
func (r Repository) SaveRepositoryImport(ctx context.Context, record RepositoryImportRecord) (RepositoryImportRecord, error) {
|
||||
now := r.now().Unix()
|
||||
if record.CreatedAt == 0 {
|
||||
record.CreatedAt = now
|
||||
}
|
||||
if record.AdmissionJSON == "" || !json.Valid([]byte(record.AdmissionJSON)) {
|
||||
record.AdmissionJSON = "{}"
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO plugin_repository_imports(
|
||||
repository_type, index_path, repository_name, candidate_id, plugin_id, version,
|
||||
artifact_id, package_sha256, trust_policy, admission_json, imported_by, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
record.RepositoryType, record.IndexPath, record.RepositoryName, record.CandidateID, record.PluginID, record.Version,
|
||||
record.ArtifactID, record.PackageSHA256, record.TrustPolicy, record.AdmissionJSON, record.ImportedBy, record.CreatedAt)
|
||||
if err != nil {
|
||||
return RepositoryImportRecord{}, err
|
||||
}
|
||||
id, err := lastInsertID(ctx, r.db)
|
||||
if err != nil {
|
||||
return RepositoryImportRecord{}, err
|
||||
}
|
||||
return r.RepositoryImport(ctx, id)
|
||||
}
|
||||
|
||||
func (r Repository) RepositoryImport(ctx context.Context, id int64) (RepositoryImportRecord, error) {
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, repository_type, index_path, repository_name, candidate_id, plugin_id, version,
|
||||
artifact_id, package_sha256, trust_policy, admission_json, imported_by, created_at
|
||||
FROM plugin_repository_imports WHERE id = ?`, id)
|
||||
return scanRepositoryImport(row)
|
||||
}
|
||||
|
||||
func (r Repository) ListRepositoryImports(ctx context.Context) ([]RepositoryImportRecord, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, repository_type, index_path, repository_name, candidate_id, plugin_id, version,
|
||||
artifact_id, package_sha256, trust_policy, admission_json, imported_by, created_at
|
||||
FROM plugin_repository_imports ORDER BY created_at DESC, id DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var imports []RepositoryImportRecord
|
||||
for rows.Next() {
|
||||
record, err := scanRepositoryImport(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
imports = append(imports, record)
|
||||
}
|
||||
return imports, rows.Err()
|
||||
}
|
||||
|
||||
func (r Repository) SaveSupplyChainAssessment(ctx context.Context, assessment SupplyChainAssessment) (SupplyChainAssessment, error) {
|
||||
now := r.now().Unix()
|
||||
if assessment.CreatedAt == 0 {
|
||||
assessment.CreatedAt = now
|
||||
}
|
||||
issues, err := json.Marshal(assessment.Issues)
|
||||
if err != nil {
|
||||
return SupplyChainAssessment{}, err
|
||||
}
|
||||
signature, err := marshalDefaultObject(assessment.Signature)
|
||||
if err != nil {
|
||||
return SupplyChainAssessment{}, err
|
||||
}
|
||||
sbom, err := marshalDefaultObject(assessment.SBOM)
|
||||
if err != nil {
|
||||
return SupplyChainAssessment{}, err
|
||||
}
|
||||
license, err := marshalDefaultObject(assessment.License)
|
||||
if err != nil {
|
||||
return SupplyChainAssessment{}, err
|
||||
}
|
||||
advisory, err := marshalDefaultObject(assessment.Advisory)
|
||||
if err != nil {
|
||||
return SupplyChainAssessment{}, err
|
||||
}
|
||||
metadata, err := marshalDefaultObject(assessment.Metadata)
|
||||
if err != nil {
|
||||
return SupplyChainAssessment{}, err
|
||||
}
|
||||
if assessment.Status == "" {
|
||||
assessment.Status = SupplyChainStatusAllowed
|
||||
}
|
||||
_, err = r.db.ExecContext(ctx, `
|
||||
INSERT INTO plugin_supply_chain_assessments(
|
||||
plugin_id, artifact_id, status, issues_json, signature_json, sbom_json,
|
||||
license_json, advisory_json, metadata_json, created_by, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
assessment.PluginID, assessment.ArtifactID, assessment.Status, string(issues), signature, sbom,
|
||||
license, advisory, metadata, assessment.CreatedBy, assessment.CreatedAt)
|
||||
if err != nil {
|
||||
return SupplyChainAssessment{}, err
|
||||
}
|
||||
id, err := lastInsertID(ctx, r.db)
|
||||
if err != nil {
|
||||
return SupplyChainAssessment{}, err
|
||||
}
|
||||
return r.SupplyChainAssessment(ctx, id)
|
||||
}
|
||||
|
||||
func (r Repository) SupplyChainAssessment(ctx context.Context, id int64) (SupplyChainAssessment, error) {
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, plugin_id, artifact_id, status, issues_json, signature_json, sbom_json,
|
||||
license_json, advisory_json, metadata_json, created_by, created_at
|
||||
FROM plugin_supply_chain_assessments WHERE id = ?`, id)
|
||||
return scanSupplyChainAssessment(row)
|
||||
}
|
||||
|
||||
func (r Repository) ListSupplyChainAssessments(ctx context.Context, pluginID, artifactID string) ([]SupplyChainAssessment, error) {
|
||||
query := `
|
||||
SELECT id, plugin_id, artifact_id, status, issues_json, signature_json, sbom_json,
|
||||
license_json, advisory_json, metadata_json, created_by, created_at
|
||||
FROM plugin_supply_chain_assessments`
|
||||
var args []any
|
||||
var clauses []string
|
||||
if pluginID != "" {
|
||||
clauses = append(clauses, "plugin_id = ?")
|
||||
args = append(args, pluginID)
|
||||
}
|
||||
if artifactID != "" {
|
||||
clauses = append(clauses, "artifact_id = ?")
|
||||
args = append(args, artifactID)
|
||||
}
|
||||
if len(clauses) > 0 {
|
||||
query += " WHERE " + strings.Join(clauses, " AND ")
|
||||
}
|
||||
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 assessments []SupplyChainAssessment
|
||||
for rows.Next() {
|
||||
assessment, err := scanSupplyChainAssessment(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assessments = append(assessments, assessment)
|
||||
}
|
||||
return assessments, rows.Err()
|
||||
}
|
||||
|
||||
func (r Repository) SavePreflight(ctx context.Context, record PreflightRecord) (PreflightRecord, error) {
|
||||
now := r.now().Unix()
|
||||
if record.CreatedAt == 0 {
|
||||
@@ -953,6 +1154,72 @@ LIMIT 1`, pluginID, artifactID, profile)
|
||||
return benchmark, err
|
||||
}
|
||||
|
||||
func (r Repository) SaveInstrumentation(ctx context.Context, actor string, req InstrumentationRequest) (InstrumentationRecord, error) {
|
||||
now := r.now().Unix()
|
||||
if req.Status == "" {
|
||||
req.Status = InstrumentationStatusAvailable
|
||||
}
|
||||
provenance, err := marshalDefaultObject(req.Provenance)
|
||||
if err != nil {
|
||||
return InstrumentationRecord{}, err
|
||||
}
|
||||
conformance, err := marshalDefaultObject(req.Conformance)
|
||||
if err != nil {
|
||||
return InstrumentationRecord{}, err
|
||||
}
|
||||
benchmark, err := marshalDefaultObject(req.Benchmark)
|
||||
if err != nil {
|
||||
return InstrumentationRecord{}, err
|
||||
}
|
||||
smoke, err := marshalDefaultObject(req.Smoke)
|
||||
if err != nil {
|
||||
return InstrumentationRecord{}, err
|
||||
}
|
||||
_, err = r.db.ExecContext(ctx, `
|
||||
INSERT INTO plugin_instrumentation(
|
||||
name, version, profile, generated_diff_hash, provenance_json, conformance_json,
|
||||
benchmark_json, smoke_json, runbook_rollback, status, created_by, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
req.Name, req.Version, req.Profile, req.GeneratedDiffHash, provenance, conformance,
|
||||
benchmark, smoke, req.RunbookRollback, req.Status, actor, now)
|
||||
if err != nil {
|
||||
return InstrumentationRecord{}, err
|
||||
}
|
||||
id, err := lastInsertID(ctx, r.db)
|
||||
if err != nil {
|
||||
return InstrumentationRecord{}, err
|
||||
}
|
||||
return r.Instrumentation(ctx, id)
|
||||
}
|
||||
|
||||
func (r Repository) Instrumentation(ctx context.Context, id int64) (InstrumentationRecord, error) {
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, name, version, profile, generated_diff_hash, provenance_json, conformance_json,
|
||||
benchmark_json, smoke_json, runbook_rollback, status, created_by, created_at
|
||||
FROM plugin_instrumentation WHERE id = ?`, id)
|
||||
return scanInstrumentation(row)
|
||||
}
|
||||
|
||||
func (r Repository) ListInstrumentation(ctx context.Context) ([]InstrumentationRecord, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, name, version, profile, generated_diff_hash, provenance_json, conformance_json,
|
||||
benchmark_json, smoke_json, runbook_rollback, status, created_by, created_at
|
||||
FROM plugin_instrumentation ORDER BY created_at DESC, id DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var records []InstrumentationRecord
|
||||
for rows.Next() {
|
||||
record, err := scanInstrumentation(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
return records, rows.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 {
|
||||
@@ -1320,6 +1587,16 @@ type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanPluginServiceState(row rowScanner) (PluginServiceState, error) {
|
||||
var state PluginServiceState
|
||||
err := row.Scan(
|
||||
&state.DesiredMode, &state.ActiveMode, &state.AppliedAt, &state.LiveMigration,
|
||||
&state.LastError, &state.UpdatedBy, &state.UpdatedAt,
|
||||
)
|
||||
state = normalizePluginServiceState(state)
|
||||
return state, err
|
||||
}
|
||||
|
||||
func scanArtifact(row rowScanner) (ArtifactRecord, error) {
|
||||
var artifact ArtifactRecord
|
||||
err := row.Scan(
|
||||
@@ -1427,6 +1704,48 @@ func scanBenchmark(row rowScanner) (BenchmarkRecord, error) {
|
||||
return record, err
|
||||
}
|
||||
|
||||
func scanRepositoryImport(row rowScanner) (RepositoryImportRecord, error) {
|
||||
var record RepositoryImportRecord
|
||||
err := row.Scan(
|
||||
&record.ID, &record.RepositoryType, &record.IndexPath, &record.RepositoryName,
|
||||
&record.CandidateID, &record.PluginID, &record.Version, &record.ArtifactID,
|
||||
&record.PackageSHA256, &record.TrustPolicy, &record.AdmissionJSON,
|
||||
&record.ImportedBy, &record.CreatedAt,
|
||||
)
|
||||
return record, err
|
||||
}
|
||||
|
||||
func scanSupplyChainAssessment(row rowScanner) (SupplyChainAssessment, error) {
|
||||
var assessment SupplyChainAssessment
|
||||
var issuesJSON, signatureJSON, sbomJSON, licenseJSON, advisoryJSON, metadataJSON string
|
||||
err := row.Scan(
|
||||
&assessment.ID, &assessment.PluginID, &assessment.ArtifactID, &assessment.Status,
|
||||
&issuesJSON, &signatureJSON, &sbomJSON, &licenseJSON, &advisoryJSON,
|
||||
&metadataJSON, &assessment.CreatedBy, &assessment.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return assessment, err
|
||||
}
|
||||
_ = json.Unmarshal([]byte(defaultJSONArray(issuesJSON)), &assessment.Issues)
|
||||
assessment.Signature = jsonMap(signatureJSON)
|
||||
assessment.SBOM = jsonMap(sbomJSON)
|
||||
assessment.License = jsonMap(licenseJSON)
|
||||
assessment.Advisory = jsonMap(advisoryJSON)
|
||||
assessment.Metadata = jsonMap(metadataJSON)
|
||||
return assessment, nil
|
||||
}
|
||||
|
||||
func scanInstrumentation(row rowScanner) (InstrumentationRecord, error) {
|
||||
var record InstrumentationRecord
|
||||
err := row.Scan(
|
||||
&record.ID, &record.Name, &record.Version, &record.Profile, &record.GeneratedDiffHash,
|
||||
&record.ProvenanceJSON, &record.ConformanceJSON, &record.BenchmarkJSON,
|
||||
&record.SmokeJSON, &record.RunbookRollback, &record.Status, &record.CreatedBy,
|
||||
&record.CreatedAt,
|
||||
)
|
||||
return record, err
|
||||
}
|
||||
|
||||
func marshalDefaultObject(value any) (string, error) {
|
||||
if value == nil {
|
||||
return "{}", nil
|
||||
@@ -1455,6 +1774,28 @@ func defaultJSONArray(value string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
func jsonMap(value string) map[string]any {
|
||||
var out map[string]any
|
||||
if json.Unmarshal([]byte(defaultJSONObject(value)), &out) != nil {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizePluginServiceState(state PluginServiceState) PluginServiceState {
|
||||
if state.DesiredMode == "" {
|
||||
state.DesiredMode = PluginServiceModeInProcess
|
||||
}
|
||||
if state.ActiveMode == "" {
|
||||
state.ActiveMode = PluginServiceModeInProcess
|
||||
}
|
||||
if state.LiveMigration == "" {
|
||||
state.LiveMigration = PluginMigrationDrainOnly
|
||||
}
|
||||
state.RestartRequired = state.DesiredMode != state.ActiveMode
|
||||
return state
|
||||
}
|
||||
|
||||
func boolInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
|
||||
@@ -18,10 +18,25 @@ const (
|
||||
ArtifactTypeBinary = "binary"
|
||||
ArtifactTypeSource = "source"
|
||||
RuntimeGoPlugin = "go-plugin"
|
||||
RuntimeBuiltin = "builtin"
|
||||
RuntimeSandbox = "sandbox-process"
|
||||
RuntimeWASM = "wasm"
|
||||
RuntimeEntry = "plugin.so"
|
||||
RuntimeWASMEntry = "plugin.wasm"
|
||||
SourceBuildEntry = "."
|
||||
|
||||
ExtensionUpstreamConnect = "upstream.connect/v1"
|
||||
ExtensionUpstreamConnect = "upstream.connect/v1"
|
||||
ExtensionRouteResolve = "route.resolve/v1"
|
||||
ExtensionRouteResolver = "route.resolver/v1"
|
||||
ExtensionRuleEvaluate = "rule.evaluate/v1"
|
||||
ExtensionConfigValidate = "config.validate/v1"
|
||||
ExtensionStatusPing = "status.ping/v1"
|
||||
ExtensionConnectionFilter = "connection.filter/v1"
|
||||
ExtensionHandshakeFilter = "handshake.filter/v1"
|
||||
ExtensionEventSubscriber = "event.subscriber/v1"
|
||||
ExtensionProvider = "provider/v1"
|
||||
ExtensionAuthProvider = "auth.provider/v1"
|
||||
ExtensionAdminAuthProvider = "admin.auth.provider/v1"
|
||||
|
||||
UpstreamModeDialer = "dialer"
|
||||
UpstreamModeProtocolProxy = "protocol-proxy"
|
||||
@@ -54,6 +69,26 @@ const (
|
||||
RuntimeDisabled = "disabled"
|
||||
RuntimeDraining = "draining"
|
||||
|
||||
PluginServiceModeInProcess = "in-process"
|
||||
PluginServiceModeGoPluginProcess = "go-plugin-process"
|
||||
PluginServiceModeSandboxProcess = "sandbox-process"
|
||||
|
||||
PluginMigrationDrainOnly = "drain-only"
|
||||
PluginMigrationFDLive = "fd-live"
|
||||
PluginMigrationFDLiveSHM = "fd-live-shm"
|
||||
|
||||
RepositoryTypeOfficial = "official"
|
||||
RepositoryTypeInternal = "internal"
|
||||
RepositoryTypeFile = "file"
|
||||
RepositoryTypeURL = "url"
|
||||
|
||||
SupplyChainStatusAllowed = "allowed"
|
||||
SupplyChainStatusBlocked = "blocked"
|
||||
SupplyChainStatusWarning = "warning"
|
||||
|
||||
InstrumentationStatusAvailable = "available"
|
||||
InstrumentationStatusBlocked = "blocked"
|
||||
|
||||
PolicyProfileDev = "dev"
|
||||
PolicyProfileStaging = "staging"
|
||||
PolicyProfileProd = "prod"
|
||||
@@ -82,23 +117,25 @@ const (
|
||||
AdvisoryStatusRevoked = "revoked"
|
||||
AdvisoryStatusAcked = "acknowledged"
|
||||
|
||||
DefaultPriority = 100
|
||||
DefaultHandlerTimeout = 3 * time.Second
|
||||
DefaultManifestMaxBytes = 256 * 1024
|
||||
DefaultPackageMaxBytes = 64 * 1024 * 1024
|
||||
DefaultPackageMaxEntries = 2048
|
||||
DefaultExtractedMaxBytes = 256 * 1024 * 1024
|
||||
DefaultNonRuntimeMaxBytes = 16 * 1024 * 1024
|
||||
DefaultInitialWriteTimeout = time.Second
|
||||
DefaultExternalTimeout = 5 * time.Second
|
||||
DefaultBuildLogMaxBytes = 64 * 1024
|
||||
DefaultEventQueueLimit = 1000
|
||||
DefaultEventRecentLimit = 1000
|
||||
DefaultLabelValueMaxBytes = 64
|
||||
DefaultPluginDataQuota = 16 * 1024 * 1024
|
||||
DefaultPluginDataKeyLimit = 256 * 1024
|
||||
DefaultPluginFileQuota = 32 * 1024 * 1024
|
||||
DefaultLogRecentLimit = 500
|
||||
DefaultPriority = 100
|
||||
DefaultHandlerTimeout = 3 * time.Second
|
||||
DefaultSubscriberRetryDelay = 100 * time.Millisecond
|
||||
DefaultSubscriberMaxRetry = 3
|
||||
DefaultManifestMaxBytes = 256 * 1024
|
||||
DefaultPackageMaxBytes = 64 * 1024 * 1024
|
||||
DefaultPackageMaxEntries = 2048
|
||||
DefaultExtractedMaxBytes = 256 * 1024 * 1024
|
||||
DefaultNonRuntimeMaxBytes = 16 * 1024 * 1024
|
||||
DefaultInitialWriteTimeout = time.Second
|
||||
DefaultExternalTimeout = 5 * time.Second
|
||||
DefaultBuildLogMaxBytes = 64 * 1024
|
||||
DefaultEventQueueLimit = 1000
|
||||
DefaultEventRecentLimit = 1000
|
||||
DefaultLabelValueMaxBytes = 64
|
||||
DefaultPluginDataQuota = 16 * 1024 * 1024
|
||||
DefaultPluginDataKeyLimit = 256 * 1024
|
||||
DefaultPluginFileQuota = 32 * 1024 * 1024
|
||||
DefaultLogRecentLimit = 500
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -161,6 +198,7 @@ type ExtensionPoint struct {
|
||||
type RuntimeLimits struct {
|
||||
HandlerTimeoutMS int `json:"handler_timeout_ms"`
|
||||
InitialWriteTimeoutMS int `json:"initial_write_timeout_ms"`
|
||||
MemoryBytes int `json:"memory_bytes,omitempty"`
|
||||
}
|
||||
|
||||
type SecretSpec struct {
|
||||
@@ -231,19 +269,56 @@ type FileStoreSpec struct {
|
||||
|
||||
type CapabilitySummary struct {
|
||||
UpstreamConnect UpstreamConnectCapability `json:"upstream_connect,omitempty"`
|
||||
Route RouteCapability `json:"route,omitempty"`
|
||||
Status StatusCapability `json:"status,omitempty"`
|
||||
Middleware MiddlewareCapability `json:"middleware,omitempty"`
|
||||
Providers []ProviderCapability `json:"providers,omitempty"`
|
||||
EventSubscriber EventSubscriberCapability `json:"event_subscriber,omitempty"`
|
||||
Minecraft *MinecraftCapability `json:"minecraft,omitempty"`
|
||||
Events []EventSpec `json:"events,omitempty"`
|
||||
CustomMetrics []MetricSpec `json:"custom_metrics,omitempty"`
|
||||
ExternalDeps []ExternalSpec `json:"external_dependencies,omitempty"`
|
||||
DataStores []DataStoreSpec `json:"data_stores,omitempty"`
|
||||
FileStores []FileStoreSpec `json:"file_stores,omitempty"`
|
||||
Runtime RuntimeCapability `json:"runtime,omitempty"`
|
||||
Raw json.RawMessage `json:"raw,omitempty"`
|
||||
}
|
||||
|
||||
type RuntimeCapability struct {
|
||||
RequiredCapabilities []string `json:"required_capabilities,omitempty"`
|
||||
RequiredFeatures []string `json:"required_features,omitempty"`
|
||||
}
|
||||
|
||||
type UpstreamConnectCapability struct {
|
||||
Mode string `json:"mode,omitempty"`
|
||||
}
|
||||
|
||||
type RouteCapability struct {
|
||||
CacheTTLMS int `json:"cache_ttl_ms,omitempty"`
|
||||
}
|
||||
|
||||
type StatusCapability struct {
|
||||
Hosts []string `json:"hosts,omitempty"`
|
||||
}
|
||||
|
||||
type MiddlewareCapability struct {
|
||||
FailPolicy string `json:"fail_policy,omitempty"`
|
||||
}
|
||||
|
||||
type ProviderCapability struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Fallback bool `json:"fallback,omitempty"`
|
||||
Dependencies []string `json:"dependencies,omitempty"`
|
||||
}
|
||||
|
||||
type EventSubscriberCapability struct {
|
||||
Mode string `json:"mode,omitempty"`
|
||||
QueueLimit int `json:"queue_limit,omitempty"`
|
||||
MaxRetry int `json:"max_retry,omitempty"`
|
||||
}
|
||||
|
||||
type MinecraftCapability struct {
|
||||
ProtocolVersions MinecraftProtocolVersions `json:"protocol_versions,omitempty"`
|
||||
States map[string]string `json:"states,omitempty"`
|
||||
@@ -627,6 +702,105 @@ type BuildRequest struct {
|
||||
VendorRequired bool `json:"vendor_required"`
|
||||
}
|
||||
|
||||
type PluginServiceState struct {
|
||||
DesiredMode string `json:"desired_mode"`
|
||||
ActiveMode string `json:"active_mode"`
|
||||
AppliedAt int64 `json:"applied_at"`
|
||||
RestartRequired bool `json:"restart_required"`
|
||||
LiveMigration string `json:"live_migration"`
|
||||
LastError string `json:"last_error"`
|
||||
UpdatedBy string `json:"updated_by"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type PluginServiceStatus struct {
|
||||
Service PluginServiceState `json:"service"`
|
||||
Hosts []PluginHostRuntimeSummary `json:"hosts"`
|
||||
}
|
||||
|
||||
type PluginHostRuntimeSummary struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
State string `json:"state"`
|
||||
DrainMode string `json:"drain_mode"`
|
||||
CrashLoop bool `json:"crash_loop"`
|
||||
CrashCount int `json:"crash_count"`
|
||||
LastError string `json:"last_error"`
|
||||
StartedAt int64 `json:"started_at"`
|
||||
DrainingAt int64 `json:"draining_at"`
|
||||
ExitedAt int64 `json:"exited_at"`
|
||||
LastCrashAt int64 `json:"last_crash_at"`
|
||||
}
|
||||
|
||||
type RepositoryImportRequest struct {
|
||||
RepositoryType string `json:"repository_type"`
|
||||
IndexPath string `json:"index_path"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
Version string `json:"version"`
|
||||
TrustPolicy string `json:"trust_policy"`
|
||||
}
|
||||
|
||||
type RepositoryImportRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
RepositoryType string `json:"repository_type"`
|
||||
IndexPath string `json:"index_path"`
|
||||
RepositoryName string `json:"repository_name"`
|
||||
CandidateID string `json:"candidate_id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
Version string `json:"version"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
PackageSHA256 string `json:"package_sha256"`
|
||||
TrustPolicy string `json:"trust_policy"`
|
||||
AdmissionJSON string `json:"admission_json"`
|
||||
ImportedBy string `json:"imported_by"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type SupplyChainAssessment struct {
|
||||
ID int64 `json:"id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Status string `json:"status"`
|
||||
Issues []GovernanceIssue `json:"issues"`
|
||||
Signature map[string]any `json:"signature,omitempty"`
|
||||
SBOM map[string]any `json:"sbom,omitempty"`
|
||||
License map[string]any `json:"license,omitempty"`
|
||||
Advisory map[string]any `json:"advisory,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type InstrumentationRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Profile string `json:"profile"`
|
||||
GeneratedDiffHash string `json:"generated_diff_hash"`
|
||||
ProvenanceJSON string `json:"provenance_json"`
|
||||
ConformanceJSON string `json:"conformance_json"`
|
||||
BenchmarkJSON string `json:"benchmark_json"`
|
||||
SmokeJSON string `json:"smoke_json"`
|
||||
RunbookRollback string `json:"runbook_rollback"`
|
||||
Status string `json:"status"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type InstrumentationRequest struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Profile string `json:"profile"`
|
||||
GeneratedDiffHash string `json:"generated_diff_hash"`
|
||||
Provenance map[string]any `json:"provenance"`
|
||||
Conformance map[string]any `json:"conformance"`
|
||||
Benchmark map[string]any `json:"benchmark"`
|
||||
Smoke map[string]any `json:"smoke"`
|
||||
RunbookRollback string `json:"runbook_rollback"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type GCCandidate struct {
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
@@ -652,8 +826,14 @@ type ConfigSnapshot struct {
|
||||
}
|
||||
|
||||
type DispatchPlan struct {
|
||||
Handlers []DispatchHandlerSummary `json:"handlers"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
Handlers []DispatchHandlerSummary `json:"handlers"`
|
||||
Routes []DispatchHandlerSummary `json:"routes"`
|
||||
Statuses []DispatchHandlerSummary `json:"statuses"`
|
||||
Middleware []DispatchHandlerSummary `json:"middleware"`
|
||||
Subscribers []DispatchHandlerSummary `json:"subscribers"`
|
||||
Providers []ProviderSummary `json:"providers"`
|
||||
RouteCache []RouteDecisionSummary `json:"route_cache"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type DispatchHandlerSummary struct {
|
||||
@@ -720,10 +900,39 @@ type EventSummary struct {
|
||||
}
|
||||
|
||||
type EventQueueSummary struct {
|
||||
Limit int `json:"limit"`
|
||||
Queued int `json:"queued"`
|
||||
Dropped uint64 `json:"dropped"`
|
||||
DeadLetters uint64 `json:"dead_letters"`
|
||||
Limit int `json:"limit"`
|
||||
Queued int `json:"queued"`
|
||||
Dropped uint64 `json:"dropped"`
|
||||
DeadLetters uint64 `json:"dead_letters"`
|
||||
SubscriberQueued uint64 `json:"subscriber_queued"`
|
||||
SubscriberDropped uint64 `json:"subscriber_dropped"`
|
||||
SubscriberDeadLetters uint64 `json:"subscriber_dead_letters"`
|
||||
}
|
||||
|
||||
type RouteDecisionSummary struct {
|
||||
Host string `json:"host"`
|
||||
Action string `json:"action"`
|
||||
Upstream string `json:"upstream,omitempty"`
|
||||
ProviderID string `json:"provider_id,omitempty"`
|
||||
Source string `json:"source"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
ExpiresAt int64 `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
type ProviderSummary struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
ExtensionPoint string `json:"extension_point"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Priority int `json:"priority"`
|
||||
Fallback bool `json:"fallback"`
|
||||
Dependencies []string `json:"dependencies,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type CustomMetricSummary struct {
|
||||
@@ -946,3 +1155,48 @@ func (g *Gateway) UpstreamConnectHandler() (api.HookHandler[api.UpstreamConnectA
|
||||
handler, ok := g.hooks[api.HookUpstreamConnect.Key()].(api.HookHandler[api.UpstreamConnectAcceptor, api.UpstreamConnectHandler])
|
||||
return handler, ok
|
||||
}
|
||||
|
||||
func (g *Gateway) RouteResolveHandler() (api.HookHandler[api.RouteResolveAcceptor, api.RouteResolveHandler], bool) {
|
||||
handler, ok := g.hooks[api.HookRouteResolve.Key()].(api.HookHandler[api.RouteResolveAcceptor, api.RouteResolveHandler])
|
||||
return handler, ok
|
||||
}
|
||||
|
||||
func (g *Gateway) RouteResolverHandler() (api.HookHandler[api.RouteResolveAcceptor, api.RouteResolveHandler], bool) {
|
||||
handler, ok := g.hooks[api.HookRouteResolver.Key()].(api.HookHandler[api.RouteResolveAcceptor, api.RouteResolveHandler])
|
||||
return handler, ok
|
||||
}
|
||||
|
||||
func (g *Gateway) StatusPingHandler() (api.HookHandler[api.StatusPingAcceptor, api.StatusPingHandler], bool) {
|
||||
handler, ok := g.hooks[api.HookStatusPing.Key()].(api.HookHandler[api.StatusPingAcceptor, api.StatusPingHandler])
|
||||
return handler, ok
|
||||
}
|
||||
|
||||
func (g *Gateway) ConnectionFilterHandler() (api.HookHandler[api.ConnectionFilterAcceptor, api.ConnectionFilterHandler], bool) {
|
||||
handler, ok := g.hooks[api.HookConnectionFilter.Key()].(api.HookHandler[api.ConnectionFilterAcceptor, api.ConnectionFilterHandler])
|
||||
return handler, ok
|
||||
}
|
||||
|
||||
func (g *Gateway) HandshakeFilterHandler() (api.HookHandler[api.HandshakeFilterAcceptor, api.HandshakeFilterHandler], bool) {
|
||||
handler, ok := g.hooks[api.HookHandshakeFilter.Key()].(api.HookHandler[api.HandshakeFilterAcceptor, api.HandshakeFilterHandler])
|
||||
return handler, ok
|
||||
}
|
||||
|
||||
func (g *Gateway) EventSubscriberHandler() (api.HookHandler[api.EventSubscriberAcceptor, api.EventSubscriberHandler], bool) {
|
||||
handler, ok := g.hooks[api.HookEventSubscriber.Key()].(api.HookHandler[api.EventSubscriberAcceptor, api.EventSubscriberHandler])
|
||||
return handler, ok
|
||||
}
|
||||
|
||||
func (g *Gateway) ProviderHandler() (api.HookHandler[api.ProviderAcceptor, api.ProviderHandler], bool) {
|
||||
handler, ok := g.hooks[api.HookProvider.Key()].(api.HookHandler[api.ProviderAcceptor, api.ProviderHandler])
|
||||
return handler, ok
|
||||
}
|
||||
|
||||
func (g *Gateway) AuthProviderHandler() (api.HookHandler[api.ProviderAcceptor, api.ProviderHandler], bool) {
|
||||
handler, ok := g.hooks[api.HookAuthProvider.Key()].(api.HookHandler[api.ProviderAcceptor, api.ProviderHandler])
|
||||
return handler, ok
|
||||
}
|
||||
|
||||
func (g *Gateway) AdminAuthProviderHandler() (api.HookHandler[api.ProviderAcceptor, api.ProviderHandler], bool) {
|
||||
handler, ok := g.hooks[api.HookAdminAuthProvider.Key()].(api.HookHandler[api.ProviderAcceptor, api.ProviderHandler])
|
||||
return handler, ok
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
@@ -52,9 +53,148 @@ type (
|
||||
|
||||
UpstreamConnectAcceptor func(UpstreamConnectRequest) bool
|
||||
UpstreamConnectHandler func(UpstreamConnectRequest) (net.Conn, error)
|
||||
|
||||
RouteResolveRequest struct {
|
||||
Context context.Context `json:"-"`
|
||||
Host string `json:"host"`
|
||||
RawServerHost string `json:"raw_server_host,omitempty"`
|
||||
SourceAddr string `json:"source_addr,omitempty"`
|
||||
ProtocolVersion int `json:"protocol_version,omitempty"`
|
||||
NextState int `json:"next_state,omitempty"`
|
||||
FallbackUpstream string `json:"fallback_upstream,omitempty"`
|
||||
FallbackHit bool `json:"fallback_hit"`
|
||||
Refresh bool `json:"refresh,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
Handshake UpstreamHandshakeRef `json:"handshake,omitempty"`
|
||||
}
|
||||
|
||||
UpstreamHandshakeRef struct {
|
||||
ServerHost string `json:"server_host,omitempty"`
|
||||
RawServerHost string `json:"raw_server_host,omitempty"`
|
||||
ProtocolVersion int `json:"protocol_version,omitempty"`
|
||||
NextState int `json:"next_state,omitempty"`
|
||||
}
|
||||
|
||||
RouteDecision struct {
|
||||
Action string `json:"action"`
|
||||
Upstream string `json:"upstream,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
ProviderID string `json:"provider_id,omitempty"`
|
||||
CacheTTL time.Duration `json:"cache_ttl,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
Explanation string `json:"explanation,omitempty"`
|
||||
}
|
||||
|
||||
RouteResolveAcceptor func(RouteResolveRequest) bool
|
||||
RouteResolveHandler func(RouteResolveRequest) (RouteDecision, error)
|
||||
|
||||
StatusPingRequest struct {
|
||||
Context context.Context `json:"-"`
|
||||
Host string `json:"host"`
|
||||
RawServerHost string `json:"raw_server_host,omitempty"`
|
||||
SourceAddr string `json:"source_addr,omitempty"`
|
||||
ProtocolVersion int `json:"protocol_version,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
StatusPingResponse struct {
|
||||
MOTD string `json:"motd,omitempty"`
|
||||
Favicon string `json:"favicon,omitempty"`
|
||||
OnlinePlayers int `json:"online_players,omitempty"`
|
||||
MaxPlayers int `json:"max_players,omitempty"`
|
||||
VersionText string `json:"version_text,omitempty"`
|
||||
ProtocolVersion int `json:"protocol_version,omitempty"`
|
||||
Maintenance bool `json:"maintenance,omitempty"`
|
||||
MaintenanceWindow string `json:"maintenance_window,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
StatusPingAcceptor func(StatusPingRequest) bool
|
||||
StatusPingHandler func(StatusPingRequest) (StatusPingResponse, error)
|
||||
|
||||
FilterDecision struct {
|
||||
Allow bool `json:"allow"`
|
||||
Reject bool `json:"reject,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
FailPolicy string `json:"fail_policy,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
ConnectionFilterRequest struct {
|
||||
Context context.Context `json:"-"`
|
||||
SourceAddr string `json:"source_addr,omitempty"`
|
||||
Transport string `json:"transport,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
ConnectionFilterAcceptor func(ConnectionFilterRequest) bool
|
||||
ConnectionFilterHandler func(ConnectionFilterRequest) (FilterDecision, error)
|
||||
|
||||
HandshakeFilterRequest struct {
|
||||
Context context.Context `json:"-"`
|
||||
SourceAddr string `json:"source_addr,omitempty"`
|
||||
ServerHost string `json:"server_host"`
|
||||
RawServerHost string `json:"raw_server_host,omitempty"`
|
||||
ProtocolVersion int `json:"protocol_version,omitempty"`
|
||||
NextState int `json:"next_state,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
HandshakeFilterDecision struct {
|
||||
FilterDecision
|
||||
RewriteHost string `json:"rewrite_host,omitempty"`
|
||||
}
|
||||
|
||||
HandshakeFilterAcceptor func(HandshakeFilterRequest) bool
|
||||
HandshakeFilterHandler func(HandshakeFilterRequest) (HandshakeFilterDecision, error)
|
||||
|
||||
EventDeliveryRequest struct {
|
||||
Context context.Context `json:"-"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
Name string `json:"name"`
|
||||
Fields map[string]string `json:"fields,omitempty"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
ConnectionID string `json:"connection_id,omitempty"`
|
||||
Attempt int `json:"attempt"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
EventDeliveryResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Retry bool `json:"retry,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
EventSubscriberAcceptor func(EventDeliveryRequest) bool
|
||||
EventSubscriberHandler func(EventDeliveryRequest) (EventDeliveryResult, error)
|
||||
|
||||
ProviderRegistration struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Fallback bool `json:"fallback,omitempty"`
|
||||
Dependencies []string `json:"dependencies,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
ProviderAcceptor func(ProviderRegistration) bool
|
||||
ProviderHandler func() (ProviderRegistration, error)
|
||||
)
|
||||
|
||||
var (
|
||||
RouteDecisionPass = "pass"
|
||||
RouteDecisionOverride = "override"
|
||||
RouteDecisionFallback = "fallback"
|
||||
RouteDecisionReject = "reject"
|
||||
|
||||
DeliveryBestEffort = "best_effort"
|
||||
DeliveryAtLeastOnce = "at_least_once"
|
||||
|
||||
FailPolicyOpen = "fail_open"
|
||||
FailPolicyClose = "fail_closed"
|
||||
|
||||
HookUpstreamConnect = HookType[
|
||||
UpstreamConnectAcceptor,
|
||||
UpstreamConnectHandler,
|
||||
@@ -68,6 +208,69 @@ var (
|
||||
]{
|
||||
key: "upstream",
|
||||
}
|
||||
|
||||
HookRouteResolve = HookType[
|
||||
RouteResolveAcceptor,
|
||||
RouteResolveHandler,
|
||||
]{
|
||||
key: "route.resolve/v1",
|
||||
}
|
||||
|
||||
HookRouteResolver = HookType[
|
||||
RouteResolveAcceptor,
|
||||
RouteResolveHandler,
|
||||
]{
|
||||
key: "route.resolver/v1",
|
||||
}
|
||||
|
||||
HookStatusPing = HookType[
|
||||
StatusPingAcceptor,
|
||||
StatusPingHandler,
|
||||
]{
|
||||
key: "status.ping/v1",
|
||||
}
|
||||
|
||||
HookConnectionFilter = HookType[
|
||||
ConnectionFilterAcceptor,
|
||||
ConnectionFilterHandler,
|
||||
]{
|
||||
key: "connection.filter/v1",
|
||||
}
|
||||
|
||||
HookHandshakeFilter = HookType[
|
||||
HandshakeFilterAcceptor,
|
||||
HandshakeFilterHandler,
|
||||
]{
|
||||
key: "handshake.filter/v1",
|
||||
}
|
||||
|
||||
HookEventSubscriber = HookType[
|
||||
EventSubscriberAcceptor,
|
||||
EventSubscriberHandler,
|
||||
]{
|
||||
key: "event.subscriber/v1",
|
||||
}
|
||||
|
||||
HookProvider = HookType[
|
||||
ProviderAcceptor,
|
||||
ProviderHandler,
|
||||
]{
|
||||
key: "provider/v1",
|
||||
}
|
||||
|
||||
HookAuthProvider = HookType[
|
||||
ProviderAcceptor,
|
||||
ProviderHandler,
|
||||
]{
|
||||
key: "auth.provider/v1",
|
||||
}
|
||||
|
||||
HookAdminAuthProvider = HookType[
|
||||
ProviderAcceptor,
|
||||
ProviderHandler,
|
||||
]{
|
||||
key: "admin.auth.provider/v1",
|
||||
}
|
||||
)
|
||||
|
||||
func (h HookType[Accept, Handler]) Key() string {
|
||||
|
||||
214
plugin/official/rulepolicy/rulepolicy.go
Normal file
214
plugin/official/rulepolicy/rulepolicy.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package rulepolicy
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tursom/mc-gateway/plugin/api"
|
||||
)
|
||||
|
||||
type Plugin struct {
|
||||
api.AbstractPlugin
|
||||
|
||||
mu sync.Mutex
|
||||
config Config
|
||||
bucket map[string]rateBucket
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
HostRewrite map[string]string `json:"host_rewrite,omitempty"`
|
||||
UpstreamRewrite map[string]string `json:"upstream_rewrite,omitempty"`
|
||||
SourceAllowCIDR []string `json:"source_allow_cidr,omitempty"`
|
||||
SourceDenyCIDR []string `json:"source_deny_cidr,omitempty"`
|
||||
RateLimit RateLimitConfig `json:"rate_limit,omitempty"`
|
||||
Maintenance MaintenanceConfig `json:"maintenance,omitempty"`
|
||||
}
|
||||
|
||||
type RateLimitConfig struct {
|
||||
Requests int `json:"requests,omitempty"`
|
||||
Window string `json:"window,omitempty"`
|
||||
}
|
||||
|
||||
type MaintenanceConfig struct {
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
Hosts []string `json:"hosts,omitempty"`
|
||||
MOTD string `json:"motd,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Window string `json:"window,omitempty"`
|
||||
StatusByHost map[string]string `json:"status_by_host,omitempty"`
|
||||
}
|
||||
|
||||
type rateBucket struct {
|
||||
windowStart time.Time
|
||||
count int
|
||||
}
|
||||
|
||||
func New() *Plugin {
|
||||
return &Plugin{bucket: make(map[string]rateBucket)}
|
||||
}
|
||||
|
||||
func (p *Plugin) NewConfigObj() any {
|
||||
return &Config{}
|
||||
}
|
||||
|
||||
func (p *Plugin) ReloadConfig(config any) error {
|
||||
next, _ := config.(*Config)
|
||||
if next == nil {
|
||||
next = &Config{}
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.config = *next
|
||||
if p.bucket == nil {
|
||||
p.bucket = make(map[string]rateBucket)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Init(gateway api.Gateway) error {
|
||||
if err := api.RegisterHookHandler(gateway, api.HookConnectionFilter, p.acceptConnection, p.filterConnection); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := api.RegisterHookHandler(gateway, api.HookHandshakeFilter, p.acceptHandshake, p.filterHandshake); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := api.RegisterHookHandler(gateway, api.HookRouteResolve, p.acceptRoute, p.resolveRoute); err != nil {
|
||||
return err
|
||||
}
|
||||
return api.RegisterHookHandler(gateway, api.HookStatusPing, p.acceptStatus, p.statusPing)
|
||||
}
|
||||
|
||||
func (p *Plugin) acceptConnection(api.ConnectionFilterRequest) bool { return true }
|
||||
|
||||
func (p *Plugin) filterConnection(req api.ConnectionFilterRequest) (api.FilterDecision, error) {
|
||||
cfg := p.snapshot()
|
||||
ip := sourceIP(req.SourceAddr)
|
||||
if ip != nil {
|
||||
if cidrMatches(cfg.SourceDenyCIDR, ip) {
|
||||
return api.FilterDecision{Allow: false, Reject: true, Reason: "source denied by CIDR policy"}, nil
|
||||
}
|
||||
if len(cfg.SourceAllowCIDR) > 0 && !cidrMatches(cfg.SourceAllowCIDR, ip) {
|
||||
return api.FilterDecision{Allow: false, Reject: true, Reason: "source not allowed by CIDR policy"}, nil
|
||||
}
|
||||
}
|
||||
if cfg.RateLimit.Requests > 0 && p.rateLimited(req.SourceAddr, cfg.RateLimit) {
|
||||
return api.FilterDecision{Allow: false, Reject: true, Reason: "source rate limited"}, nil
|
||||
}
|
||||
return api.FilterDecision{Allow: true}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) acceptHandshake(api.HandshakeFilterRequest) bool { return true }
|
||||
|
||||
func (p *Plugin) filterHandshake(req api.HandshakeFilterRequest) (api.HandshakeFilterDecision, error) {
|
||||
cfg := p.snapshot()
|
||||
if rewritten := cfg.HostRewrite[strings.ToLower(req.ServerHost)]; rewritten != "" {
|
||||
return api.HandshakeFilterDecision{
|
||||
FilterDecision: api.FilterDecision{Allow: true, Reason: "host rewrite"},
|
||||
RewriteHost: rewritten,
|
||||
}, nil
|
||||
}
|
||||
return api.HandshakeFilterDecision{FilterDecision: api.FilterDecision{Allow: true}}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) acceptRoute(api.RouteResolveRequest) bool { return true }
|
||||
|
||||
func (p *Plugin) resolveRoute(req api.RouteResolveRequest) (api.RouteDecision, error) {
|
||||
cfg := p.snapshot()
|
||||
if upstream := cfg.UpstreamRewrite[strings.ToLower(req.Host)]; upstream != "" {
|
||||
return api.RouteDecision{
|
||||
Action: api.RouteDecisionOverride,
|
||||
Upstream: upstream,
|
||||
ProviderID: "official.rule-policy",
|
||||
Reason: "upstream rewrite rule",
|
||||
CacheTTL: time.Minute,
|
||||
}, nil
|
||||
}
|
||||
return api.RouteDecision{Action: api.RouteDecisionPass}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) acceptStatus(req api.StatusPingRequest) bool {
|
||||
cfg := p.snapshot()
|
||||
if !cfg.Maintenance.Enabled && len(cfg.Maintenance.StatusByHost) == 0 {
|
||||
return false
|
||||
}
|
||||
if len(cfg.Maintenance.Hosts) == 0 {
|
||||
return true
|
||||
}
|
||||
host := strings.ToLower(req.Host)
|
||||
for _, item := range cfg.Maintenance.Hosts {
|
||||
if strings.ToLower(item) == host {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *Plugin) statusPing(req api.StatusPingRequest) (api.StatusPingResponse, error) {
|
||||
cfg := p.snapshot()
|
||||
motd := cfg.Maintenance.MOTD
|
||||
if hostMOTD := cfg.Maintenance.StatusByHost[strings.ToLower(req.Host)]; hostMOTD != "" {
|
||||
motd = hostMOTD
|
||||
}
|
||||
if motd == "" {
|
||||
motd = "Maintenance"
|
||||
}
|
||||
version := cfg.Maintenance.Version
|
||||
if version == "" {
|
||||
version = "Maintenance"
|
||||
}
|
||||
return api.StatusPingResponse{
|
||||
MOTD: motd,
|
||||
VersionText: version,
|
||||
ProtocolVersion: req.ProtocolVersion,
|
||||
Maintenance: cfg.Maintenance.Enabled,
|
||||
MaintenanceWindow: cfg.Maintenance.Window,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) snapshot() Config {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.config
|
||||
}
|
||||
|
||||
func (p *Plugin) rateLimited(key string, cfg RateLimitConfig) bool {
|
||||
window := time.Second
|
||||
if cfg.Window != "" {
|
||||
if parsed, err := time.ParseDuration(cfg.Window); err == nil && parsed > 0 {
|
||||
window = parsed
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
bucket := p.bucket[key]
|
||||
if bucket.windowStart.IsZero() || now.Sub(bucket.windowStart) >= window {
|
||||
bucket = rateBucket{windowStart: now}
|
||||
}
|
||||
bucket.count++
|
||||
p.bucket[key] = bucket
|
||||
return bucket.count > cfg.Requests
|
||||
}
|
||||
|
||||
func cidrMatches(ranges []string, ip net.IP) bool {
|
||||
for _, raw := range ranges {
|
||||
_, network, err := net.ParseCIDR(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if network.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sourceIP(addr string) net.IP {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
host = addr
|
||||
}
|
||||
return net.ParseIP(host)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
@@ -113,6 +114,21 @@ func ReadString(buf []byte) (string, int, error) {
|
||||
return readString(buf)
|
||||
}
|
||||
|
||||
func StatusResponsePacket(value any) ([]byte, error) {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var payload bytes.Buffer
|
||||
payload.Write(encodeVarInt(0))
|
||||
payload.Write(encodeVarInt(len(data)))
|
||||
payload.Write(data)
|
||||
var out bytes.Buffer
|
||||
out.Write(encodeVarInt(payload.Len()))
|
||||
out.Write(payload.Bytes())
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
func readPacket(buf []byte) ([]byte, int, error) {
|
||||
length, n, err := readVarInt(buf)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user