Compare commits
3 Commits
f22005ea37
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f508ecc1b9 | ||
|
|
7a5664ab89 | ||
|
|
e4dfcb62cb |
@@ -1,3 +1,5 @@
|
||||
# Dockerfile 构建网关二进制、编译管理前端,并打包带 SQLite 友好默认值的运行镜像。
|
||||
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM --platform=$BUILDPLATFORM node:24.11.1-alpine AS admin-frontend
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/admin_api.go 组装 Admin API 的共享依赖,并提供嵌入式控制台使用的顶层 HTTP 路由。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -7,6 +9,8 @@ import (
|
||||
)
|
||||
|
||||
func newAdminAPIHandler() http.HandlerFunc {
|
||||
// Admin API 的路径解析放在 internal/adminhttp 中,主包只提供各业务 handler。
|
||||
// 这样测试可以复用同一套路由表,而不会依赖真实监听器。
|
||||
return adminhttp.NewAPIHandler(adminStartup.AdminAPIPrefix, adminhttp.APIHandlers{
|
||||
SetupStatus: handleAdminSetupStatus,
|
||||
Setup: handleAdminSetup,
|
||||
@@ -29,6 +33,8 @@ func newAdminAPIHandler() http.HandlerFunc {
|
||||
|
||||
AuditLogs: handleAdminAuditLogs,
|
||||
|
||||
// 插件相关接口数量较多,统一在这里接入,确保嵌入式 UI 和远程 CLI
|
||||
// 看到的是同一套 Admin API 行为。
|
||||
PluginArtifacts: handleAdminPluginArtifacts,
|
||||
PluginArtifact: handleAdminPluginArtifact,
|
||||
PluginSources: handleAdminPluginSources,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/admin_api_test.go 包含用于约束 admin api 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/admin_audit.go 把 HTTP 请求上下文转换为持久化审计记录,用于追踪管理端变更。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/admin_auth_handlers.go 处理初始化、登录、登出和当前会话查询等嵌入式管理端认证接口。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/admin_frontend/src/alerts.ts 集中处理告警展示,让异步界面流程可以一致地清空或显示错误。
|
||||
|
||||
import { el } from "./dom.js";
|
||||
import { localizeMessage } from "./i18n.js";
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/admin_frontend/src/api.ts 封装 fetch,统一处理 Admin API 前缀、令牌、JSON 编码和错误返回。
|
||||
|
||||
import { state } from "./state.js";
|
||||
|
||||
interface APIOptions {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/admin_frontend/src/config.ts 读取嵌入式管理端 HTML 壳注入的运行时配置。
|
||||
|
||||
import type { RuntimeConfig } from "./types.js";
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/admin_frontend/src/dom.ts 集中 DOM 辅助方法,包括查询、转义、徽标、去抖和表单取值。
|
||||
|
||||
export function el<T extends HTMLElement = HTMLElement>(id: string): T {
|
||||
const node = document.getElementById(id);
|
||||
if (!node) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/admin_frontend/src/i18n.ts 保存嵌入式管理端翻译字典,并提供语言切换辅助方法。
|
||||
|
||||
import { el } from "./dom.js";
|
||||
import { languageStorageKey, state } from "./state.js";
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/admin_frontend/src/main.ts 启动嵌入式管理端,选择初始化/登录/应用视图,并协调按角色加载数据。
|
||||
|
||||
import { api } from "./api.js";
|
||||
import { showAlert } from "./alerts.js";
|
||||
import { runtimeConfig } from "./config.js";
|
||||
@@ -15,12 +17,14 @@ import { loadStatus } from "./views/status.js";
|
||||
import { loadUsers, openUserDialog, renderUsers, saveUser } from "./views/users.js";
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
// API 前缀由后端嵌入到 HTML 中,前端启动时先读取它,避免部署在子路径时写死地址。
|
||||
state.apiBase = runtimeConfig().apiPrefix;
|
||||
initializeLanguage();
|
||||
bindEvents();
|
||||
try {
|
||||
const setup = await api<SetupStatus>("/setup");
|
||||
if (setup.required) {
|
||||
// 没有任何管理账号时只展示初始化界面,不尝试加载其他运行态数据。
|
||||
setView("setupView");
|
||||
setSubtitle("setupSubtitle");
|
||||
return;
|
||||
@@ -30,6 +34,7 @@ async function boot(): Promise<void> {
|
||||
}
|
||||
|
||||
if (!state.token) {
|
||||
// token 保存在本地状态中;没有 token 时直接进入登录视图。
|
||||
setView("loginView");
|
||||
setSubtitle("login");
|
||||
return;
|
||||
@@ -39,6 +44,7 @@ async function boot(): Promise<void> {
|
||||
state.user = await api<User>("/me");
|
||||
await showApp();
|
||||
} catch {
|
||||
// token 失效时清空本地状态,避免后续 API 调用持续带着过期凭证。
|
||||
setToken("");
|
||||
setView("loginView");
|
||||
setSubtitle("login");
|
||||
@@ -46,6 +52,7 @@ async function boot(): Promise<void> {
|
||||
}
|
||||
|
||||
function bindEvents(): void {
|
||||
// 所有顶层事件在启动时绑定一次,视图重渲染只更新内容区域。
|
||||
el<HTMLSelectElement>("languageSelect").addEventListener("change", (event) => {
|
||||
changeLanguage((event.currentTarget as HTMLSelectElement).value, rerenderCurrentView);
|
||||
});
|
||||
@@ -75,6 +82,7 @@ async function submitSetup(event: SubmitEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget as HTMLFormElement);
|
||||
try {
|
||||
// 初始化只创建首个管理员账号,创建成功后仍要求用户走登录流程获取会话 token。
|
||||
await api("/setup", {
|
||||
method: "POST",
|
||||
body: {
|
||||
@@ -94,6 +102,7 @@ async function submitLogin(event: SubmitEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget as HTMLFormElement);
|
||||
try {
|
||||
// 登录成功后立即保存 token 和用户信息,再统一进入应用态加载流程。
|
||||
const data = await api<LoginResponse>("/auth/login", {
|
||||
method: "POST",
|
||||
body: {
|
||||
@@ -114,6 +123,7 @@ async function logout(): Promise<void> {
|
||||
try {
|
||||
await api("/auth/logout", { method: "POST", body: {} });
|
||||
} catch {
|
||||
// 服务端登出失败不阻塞本地清理,避免用户卡在失效会话上。
|
||||
}
|
||||
setToken("");
|
||||
state.user = null;
|
||||
@@ -124,6 +134,7 @@ async function logout(): Promise<void> {
|
||||
}
|
||||
|
||||
async function showApp(): Promise<void> {
|
||||
// 路由列表是成员和管理员都可见的基础视图,因此先加载它。
|
||||
setView("appView");
|
||||
setSubtitle("adminSubtitle");
|
||||
renderSessionUser();
|
||||
@@ -131,12 +142,14 @@ async function showApp(): Promise<void> {
|
||||
applyRoleVisibility();
|
||||
await loadRoutes();
|
||||
if (isMember()) {
|
||||
// 成员权限可以查看运行态、服务、指标和插件,但不能管理用户与审计。
|
||||
await loadStatus();
|
||||
await loadServices();
|
||||
await loadMetrics();
|
||||
await loadPlugins();
|
||||
}
|
||||
if (isAdmin()) {
|
||||
// 管理员专属数据放在最后加载,减少普通成员的无权限请求。
|
||||
await loadUsers();
|
||||
await loadAudit();
|
||||
}
|
||||
@@ -145,6 +158,7 @@ async function showApp(): Promise<void> {
|
||||
function applyRoleVisibility(): void {
|
||||
const member = isMember();
|
||||
const admin = isAdmin();
|
||||
// 角色控制只隐藏入口;服务端仍会按 token 做权限校验。
|
||||
el("statusGrid").classList.toggle("hidden", !member);
|
||||
el("newRouteBtn").classList.toggle("hidden", !member);
|
||||
toggleTab("services", member);
|
||||
@@ -176,6 +190,7 @@ function setView(name: string): void {
|
||||
}
|
||||
|
||||
function rerenderCurrentView(): void {
|
||||
// 切换语言后复用当前内存状态重绘静态文案,再刷新会随语言展示的远端数据。
|
||||
renderSessionUser();
|
||||
renderRoutes();
|
||||
renderServices();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/admin_frontend/src/session.ts 渲染会话用户状态,并提供管理端界面使用的角色判断。
|
||||
|
||||
import { el } from "./dom.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { state } from "./state.js";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/admin_frontend/src/state.ts 保存各视图模块共享的可变客户端状态。
|
||||
|
||||
import type { PluginArtifact, PluginBuild, PluginInstrumentation, PluginServiceStatus, PluginView, RouteRecord, ServiceRecord, User } from "./types.js";
|
||||
|
||||
export const tokenStorageKey = "mcGatewayAdminToken";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/admin_frontend/src/types.ts 声明 Admin API 返回并被各视图消费的 TypeScript 数据结构。
|
||||
|
||||
export type Role = "admin" | "member" | "guest";
|
||||
|
||||
export interface User {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/admin_frontend/src/views/audit.ts 渲染管理员用于复核运行态变更的审计日志。
|
||||
|
||||
import { api } from "../api.js";
|
||||
import { showAlert } from "../alerts.js";
|
||||
import { badge, el, escapeHTML } from "../dom.js";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/admin_frontend/src/views/metrics.ts 渲染管理端成员可见的网关指标计数器。
|
||||
|
||||
import { api } from "../api.js";
|
||||
import { showAlert } from "../alerts.js";
|
||||
import { el, escapeHTML, stat } from "../dom.js";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/admin_frontend/src/views/plugins.ts 渲染插件清单、插件详情、配置/密钥/治理动作和运维工具。
|
||||
|
||||
import { api } from "../api.js";
|
||||
import { showAlert } from "../alerts.js";
|
||||
import { badge, el, escapeAttr, escapeHTML, getFormInput } from "../dom.js";
|
||||
@@ -50,6 +52,8 @@ interface OperationsResponse {
|
||||
|
||||
export async function loadPlugins(): Promise<void> {
|
||||
try {
|
||||
// 插件页首屏依赖插件记录、制品、构建、插件服务模式和观测数据;
|
||||
// 并行请求可以减少进入页面时的等待时间。
|
||||
const [data, artifacts, builds, service, instrumentation] = await Promise.all([
|
||||
api<PluginsResponse>("/plugins"),
|
||||
api<ArtifactsResponse>("/plugin-artifacts"),
|
||||
@@ -64,6 +68,7 @@ export async function loadPlugins(): Promise<void> {
|
||||
state.pluginInstrumentation = instrumentation.instrumentation || [];
|
||||
const firstPlugin = state.plugins[0];
|
||||
if (!state.selectedPluginID && firstPlugin) {
|
||||
// 初次进入时默认选中第一个已纳管插件;未纳管制品会在列表中单独展示。
|
||||
state.selectedPluginID = firstPlugin.id;
|
||||
}
|
||||
renderPlugins();
|
||||
@@ -79,6 +84,7 @@ export async function loadPlugins(): Promise<void> {
|
||||
|
||||
export function renderPlugins(): void {
|
||||
const managed = new Set(state.plugins.map((plugin) => plugin.id));
|
||||
// 未纳管制品还没有 plugins 表记录,但仍要展示,方便管理员创建期望状态。
|
||||
const unmanagedArtifacts = state.pluginArtifacts.filter((artifact) => !managed.has(artifact.plugin_id));
|
||||
renderPluginServicePanel();
|
||||
el("pluginsBody").innerHTML = state.plugins.map((plugin) => `
|
||||
@@ -136,6 +142,7 @@ export async function loadPluginDetail(pluginID: string): Promise<void> {
|
||||
try {
|
||||
const data = await api<PluginResponse>(`/plugins/${encodeURIComponent(pluginID)}`);
|
||||
if (data.plugin) {
|
||||
// 详情接口返回完整插件视图,用它回填列表中的摘要记录。
|
||||
state.plugins = state.plugins.map((plugin) => plugin.id === data.plugin?.id ? data.plugin : plugin);
|
||||
if (!state.plugins.some((plugin) => plugin.id === data.plugin?.id)) {
|
||||
state.plugins.push(data.plugin);
|
||||
@@ -152,11 +159,13 @@ export async function loadPluginDetail(pluginID: string): Promise<void> {
|
||||
export function renderPluginDetail(plugin: PluginView | null = selectedPlugin()): void {
|
||||
const detail = el("pluginDetail");
|
||||
if (!plugin) {
|
||||
// 没有选中纳管插件时展示制品库存和源码构建入口。
|
||||
detail.innerHTML = uploadInventoryDetail();
|
||||
bindInventoryEvents();
|
||||
return;
|
||||
}
|
||||
const canWrite = isAdmin();
|
||||
// 插件详情拆成多个小面板,避免配置、治理、构建和运维信息混成一个长表格。
|
||||
detail.innerHTML = `
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
@@ -252,6 +261,7 @@ export function renderPluginDetail(plugin: PluginView | null = selectedPlugin())
|
||||
}
|
||||
|
||||
export function bindPluginEvents(): void {
|
||||
// 顶层插件页事件只绑定一次;详情区会在每次重绘后重新绑定动态按钮。
|
||||
el<HTMLInputElement>("pluginUploadInput").addEventListener("change", uploadPluginPackage);
|
||||
el<HTMLButtonElement>("refreshPluginsBtn").addEventListener("click", loadPlugins);
|
||||
}
|
||||
@@ -263,6 +273,7 @@ function renderPluginServicePanel(): void {
|
||||
}
|
||||
const service = state.pluginService?.service;
|
||||
const canWrite = isAdmin();
|
||||
// 插件服务模式决定插件在进程内运行还是进入未来的独立/沙箱运行模式。
|
||||
container.innerHTML = `
|
||||
<section class="panel">
|
||||
<div class="detail-header compact">
|
||||
@@ -305,6 +316,7 @@ 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") },
|
||||
@@ -342,6 +354,7 @@ async function uploadPluginPackage(event: Event): Promise<void> {
|
||||
const formData = new FormData();
|
||||
formData.set("artifact", file);
|
||||
try {
|
||||
// 浏览器只负责上传文件;manifest 校验、哈希和制品类型判断由后端完成。
|
||||
await api("/plugin-artifacts", { method: "POST", formData });
|
||||
input.value = "";
|
||||
await loadPlugins();
|
||||
@@ -352,6 +365,7 @@ async function uploadPluginPackage(event: Event): Promise<void> {
|
||||
}
|
||||
|
||||
function bindPluginDetailEvents(plugin: PluginView): void {
|
||||
// 详情区每次重绘都会替换 DOM,因此按钮事件必须在重绘后重新绑定。
|
||||
document.getElementById("pluginDryRunBtn")?.addEventListener("click", () => dryRunConfig(plugin));
|
||||
document.getElementById("pluginSaveConfigBtn")?.addEventListener("click", () => saveConfig(plugin));
|
||||
const secretForm = document.getElementById("pluginSecretForm");
|
||||
@@ -389,6 +403,7 @@ function bindPluginDetailEvents(plugin: PluginView): void {
|
||||
|
||||
async function dryRunConfig(plugin: PluginView): Promise<void> {
|
||||
try {
|
||||
// dry-run 不保存配置,只返回脱敏后的校验结果、diff 和是否需要重启。
|
||||
const data = await api<DryRunResponse>(`/plugins/${encodeURIComponent(plugin.id)}/config/dry-run`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
@@ -405,6 +420,7 @@ async function dryRunConfig(plugin: PluginView): Promise<void> {
|
||||
|
||||
async function saveConfig(plugin: PluginView): Promise<void> {
|
||||
try {
|
||||
// 配置保存写入期望状态;后端会根据当前制品和运行态判断是否可热加载。
|
||||
await api(`/plugins/${encodeURIComponent(plugin.id)}/config`, {
|
||||
method: "PUT",
|
||||
body: {
|
||||
@@ -425,6 +441,7 @@ async function saveSecret(event: SubmitEvent, plugin: PluginView): Promise<void>
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget as HTMLFormElement;
|
||||
try {
|
||||
// 密钥值不回显,保存后通过重新加载详情刷新版本号和 reload 标记。
|
||||
await api(`/plugins/${encodeURIComponent(plugin.id)}/secrets`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
@@ -448,6 +465,7 @@ async function runPluginAction(pluginID: string, action: string): Promise<void>
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// enable/disable/load/delete 等动作都走统一动作接口,后端负责审计和操作日志。
|
||||
await api(`/plugins/${encodeURIComponent(pluginID)}/${action}`, { method: "POST", body: {} });
|
||||
if (action === "delete") {
|
||||
state.selectedPluginID = "";
|
||||
@@ -463,6 +481,7 @@ async function runPluginAction(pluginID: string, action: string): Promise<void>
|
||||
|
||||
async function createDesiredFromArtifact(artifact: PluginArtifact): Promise<void> {
|
||||
try {
|
||||
// 从未纳管制品创建 disabled 期望状态,管理员随后可以编辑配置再启用。
|
||||
await api(`/plugins/${encodeURIComponent(artifact.plugin_id)}`, {
|
||||
method: "PUT",
|
||||
body: {
|
||||
@@ -483,6 +502,7 @@ async function createDesiredFromArtifact(artifact: PluginArtifact): Promise<void
|
||||
|
||||
async function rollbackArtifact(pluginID: string, artifactID: string): Promise<void> {
|
||||
try {
|
||||
// 制品回滚只改期望制品;后端仍会执行治理检查和配置 dry-run。
|
||||
await api(`/plugins/${encodeURIComponent(pluginID)}/rollback/artifact`, {
|
||||
method: "POST",
|
||||
body: { artifact_id: artifactID },
|
||||
@@ -499,6 +519,7 @@ async function runBuildAction(pluginID: string, buildID: number, action: string)
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 构建动作可能耗时,当前界面以刷新详情的方式展示最新构建状态。
|
||||
await api(`/plugin-builds/${buildID}/${encodeURIComponent(action)}`, { method: "POST", body: {} });
|
||||
await loadPluginDetail(pluginID);
|
||||
showAlert("");
|
||||
@@ -512,6 +533,7 @@ async function rollbackSnapshot(pluginID: string, snapshotID: number, fullDesire
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 配置快照回滚可只恢复配置,也可连同 artifact/desired state/priority 一起恢复。
|
||||
await api(`/plugins/${encodeURIComponent(pluginID)}/rollback/config`, {
|
||||
method: "POST",
|
||||
body: { snapshot_id: snapshotID, full_desired: fullDesired },
|
||||
@@ -528,6 +550,7 @@ async function showSnapshotDiff(pluginID: string, snapshotID: number): Promise<v
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// diff 已由后端脱敏,前端只负责展示结果给管理员确认。
|
||||
const data = await api<SnapshotDiffResponse>(`/plugins/${encodeURIComponent(pluginID)}/config/snapshots/${snapshotID}/diff`);
|
||||
el("pluginDryRunResult").textContent = formatJSON(data.diff || {});
|
||||
showAlert("");
|
||||
@@ -538,6 +561,7 @@ async function showSnapshotDiff(pluginID: string, snapshotID: number): Promise<v
|
||||
|
||||
async function createGovernanceReview(plugin: PluginView): Promise<void> {
|
||||
try {
|
||||
// 评审记录绑定当前 desired artifact 和配置哈希,用于后续启用或回滚门禁。
|
||||
await api(`/plugins/${encodeURIComponent(plugin.id)}/governance/review`, {
|
||||
method: "POST",
|
||||
body: { artifact_id: plugin.desired_artifact_id, profile: "prod", decision: "approved" },
|
||||
@@ -555,6 +579,7 @@ async function createGovernanceOverride(plugin: PluginView): Promise<void> {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// override 是带 TTL 的临时治理豁免,必须记录人工原因。
|
||||
await api(`/plugins/${encodeURIComponent(plugin.id)}/governance/override`, {
|
||||
method: "POST",
|
||||
body: { artifact_id: plugin.desired_artifact_id, profile: "prod", action: "enable", reason, ttl_seconds: 3600 },
|
||||
@@ -568,6 +593,7 @@ async function createGovernanceOverride(plugin: PluginView): Promise<void> {
|
||||
|
||||
async function runGovernancePreflight(plugin: PluginView): Promise<void> {
|
||||
try {
|
||||
// preflight 由插件或宿主返回检查项,结果会持久化到治理面板。
|
||||
const data = await api<Record<string, unknown>>(`/plugins/${encodeURIComponent(plugin.id)}/governance/preflight`, {
|
||||
method: "POST",
|
||||
body: { artifact_id: plugin.desired_artifact_id, config_json: configEditorValue() },
|
||||
@@ -582,6 +608,7 @@ async function runGovernancePreflight(plugin: PluginView): Promise<void> {
|
||||
|
||||
async function runGovernanceSelfTest(plugin: PluginView): Promise<void> {
|
||||
try {
|
||||
// self-test 用于验证制品自身能力,不直接修改 desired state。
|
||||
const data = await api<Record<string, unknown>>(`/plugins/${encodeURIComponent(plugin.id)}/governance/self-test`, {
|
||||
method: "POST",
|
||||
body: { artifact_id: plugin.desired_artifact_id },
|
||||
@@ -600,6 +627,7 @@ async function recordGovernanceBenchmark(plugin: PluginView): Promise<void> {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 手动录入基准差异用于治理门禁判断,避免高风险性能回退直接启用。
|
||||
await api(`/plugins/${encodeURIComponent(plugin.id)}/governance/benchmark`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
@@ -630,6 +658,7 @@ async function createArtifactRevokeAdvisory(plugin: PluginView): Promise<void> {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 撤销公告会让命中的制品进入隔离/阻断路径,详情刷新后展示最新治理状态。
|
||||
await api("/plugin-advisories", {
|
||||
method: "POST",
|
||||
body: {
|
||||
@@ -649,6 +678,7 @@ async function createArtifactRevokeAdvisory(plugin: PluginView): Promise<void> {
|
||||
|
||||
async function loadPluginOperations(plugin: PluginView): Promise<void> {
|
||||
try {
|
||||
// 运维快照包含事件、日志、trace、任务、外部依赖和 GC 候选项,按需刷新即可。
|
||||
const data = await api<OperationsResponse>(`/plugins/${encodeURIComponent(plugin.id)}/operations`);
|
||||
el("pluginOperationsOutput").textContent = formatJSON(data.operations || {});
|
||||
showAlert("");
|
||||
@@ -659,6 +689,7 @@ async function loadPluginOperations(plugin: PluginView): Promise<void> {
|
||||
|
||||
async function dryRunOperationsGC(plugin: PluginView): Promise<void> {
|
||||
try {
|
||||
// GC dry-run 不删除文件,只展示哪些运行态数据会被保护或清理。
|
||||
const data = await api<OperationsResponse>(`/plugins/${encodeURIComponent(plugin.id)}/operations/gc`);
|
||||
el("pluginOperationsOutput").textContent = formatJSON(data);
|
||||
showAlert("");
|
||||
@@ -669,6 +700,7 @@ async function dryRunOperationsGC(plugin: PluginView): Promise<void> {
|
||||
|
||||
async function loadDiagnosticPackage(plugin: PluginView): Promise<void> {
|
||||
try {
|
||||
// 诊断包由后端生成并脱敏,前端以 JSON 文本形式展示给管理员。
|
||||
const data = await api<OperationsResponse>(`/plugins/${encodeURIComponent(plugin.id)}/operations/diagnostic`);
|
||||
el("pluginOperationsOutput").textContent = formatJSON(data);
|
||||
showAlert("");
|
||||
@@ -678,6 +710,7 @@ async function loadDiagnosticPackage(plugin: PluginView): Promise<void> {
|
||||
}
|
||||
|
||||
function uploadInventoryDetail(): string {
|
||||
// 库存视图聚合未纳管制品和构建记录,支撑上传、构建、纳管的完整流程。
|
||||
const artifact = selectedArtifact();
|
||||
if (!artifact) {
|
||||
return `
|
||||
@@ -732,6 +765,7 @@ function uploadInventoryDetail(): string {
|
||||
}
|
||||
|
||||
function bindInventoryEvents(): void {
|
||||
// 库存视图也是动态渲染,制品详情和纳管表单事件需要在渲染后绑定。
|
||||
const artifact = selectedArtifact();
|
||||
const form = document.getElementById("artifactDesiredForm");
|
||||
if (artifact && form instanceof HTMLFormElement) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/admin_frontend/src/views/routes.ts 渲染路由列表,并通过 Admin API 保存主机到上游的变更。
|
||||
|
||||
import { api } from "../api.js";
|
||||
import { showAlert } from "../alerts.js";
|
||||
import { badge, el, escapeAttr, escapeHTML, getFormInput } from "../dom.js";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/admin_frontend/src/views/services.ts 渲染监听服务设置,并持久化启停、端口和选项更新。
|
||||
|
||||
import { api } from "../api.js";
|
||||
import { showAlert } from "../alerts.js";
|
||||
import { el, escapeAttr, escapeHTML, getFormInput } from "../dom.js";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/admin_frontend/src/views/status.ts 渲染管理面板上的网关健康摘要。
|
||||
|
||||
import { api } from "../api.js";
|
||||
import { showAlert } from "../alerts.js";
|
||||
import { el, stat } from "../dom.js";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/admin_frontend/src/views/users.ts 渲染管理用户列表,并保存账号、角色、密码和禁用状态变更。
|
||||
|
||||
import { api } from "../api.js";
|
||||
import { showAlert } from "../alerts.js";
|
||||
import { badge, el, escapeAttr, escapeHTML, getFormInput, getFormSelect } from "../dom.js";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/admin_metric_handlers.go 返回管理面板状态卡片使用的轻量运行时计数器。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/admin_plugin_handlers.go 承载插件制品、期望状态、配置、密钥、运维操作和治理检查相关的 Admin API。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/admin_route_handlers.go 提供修改路由记录的 HTTP 接口,并在提交后立刻刷新内存路由快照。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/admin_routes.go 让内存中的主机到上游映射快照与 SQLite 路由记录保持同步。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -8,10 +10,14 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
routeSnapshot = adminroute.NewSnapshot()
|
||||
// routeSnapshot 是连接热路径读取的不可变快照;写路径通过 Store 整体替换它。
|
||||
routeSnapshot = adminroute.NewSnapshot()
|
||||
// routeWriteLock 串行化路由写入和快照刷新,避免并发写导致后写库、先发布的顺序错乱。
|
||||
routeWriteLock sync.Mutex
|
||||
)
|
||||
|
||||
// refreshRouteSnapshot 从 SQLite 读取启用路由并发布到热路径。数据库尚未初始化时
|
||||
// 发布空快照,方便测试和早期启动路径调用。
|
||||
func refreshRouteSnapshot(ctx context.Context) error {
|
||||
if adminDB == nil {
|
||||
publishRouteSnapshot(map[string]string{})
|
||||
@@ -26,10 +32,12 @@ func refreshRouteSnapshot(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// publishRouteSnapshot 原子替换当前路由快照;调用方应传入新 map,避免发布后继续修改。
|
||||
func publishRouteSnapshot(routes map[string]string) {
|
||||
routeSnapshot.Store(routes)
|
||||
}
|
||||
|
||||
// lookupRoute 是连接热路径使用的只读查找函数,不访问 SQLite。
|
||||
func lookupRoute(host string) (string, bool) {
|
||||
return routeSnapshot.Lookup(host)
|
||||
}
|
||||
@@ -42,6 +50,7 @@ func upsertRoute(ctx context.Context, actor, host, upstream string, enabled bool
|
||||
routeWriteLock.Lock()
|
||||
defer routeWriteLock.Unlock()
|
||||
|
||||
// 路由写入成功后必须立即刷新内存快照,否则管理端保存的配置不会影响新连接。
|
||||
if err := adminroute.NewRepository(adminDB).Upsert(ctx, actor, host, upstream, enabled, note); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -52,6 +61,7 @@ func deleteRoute(ctx context.Context, actor, host string) error {
|
||||
routeWriteLock.Lock()
|
||||
defer routeWriteLock.Unlock()
|
||||
|
||||
// 删除也走同一把锁,确保快照刷新顺序与数据库提交顺序一致。
|
||||
if err := adminroute.NewRepository(adminDB).Delete(ctx, host); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/admin_runtime.go 打开 SQLite 运行态数据库、写入默认数据,并为在线流量发布首个路由快照。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -36,6 +38,7 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
// adminStartup 是启动时解析出的管理端配置;后续 HTTP handler 和静态资源注入都会读取它。
|
||||
adminStartup = adminconfig.Config{
|
||||
DBPath: defaultAdminDBPath,
|
||||
TCPAdminPort: defaultTCPPort,
|
||||
@@ -50,6 +53,8 @@ var (
|
||||
processStartAt = time.Now()
|
||||
)
|
||||
|
||||
// initializeGatewayRuntime 按固定顺序准备运行态:解析配置、打开数据库、迁移 schema、
|
||||
// 写入默认服务、应用服务配置、创建初始管理员、发布路由快照、最后启动插件管理器。
|
||||
func initializeGatewayRuntime() error {
|
||||
startup, err := parseStartupConfig(os.Getenv)
|
||||
if err != nil {
|
||||
@@ -71,6 +76,7 @@ func initializeGatewayRuntime() error {
|
||||
if err := admindb.Migrate(db); err != nil {
|
||||
return err
|
||||
}
|
||||
// 默认服务必须先存在,applyServiceConfig 才能把 SQLite 中的运行态端口写回 config。
|
||||
if err := ensureDefaultServices(context.Background(), db, startup.TCPAdminPort); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -84,6 +90,7 @@ func initializeGatewayRuntime() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// 插件制品放在数据库同级目录下,便于容器挂载一个 data volume 即可保留全部运行态。
|
||||
pluginsManager = pluginmanager.New(pluginmanager.Options{
|
||||
DB: db,
|
||||
ArtifactRoot: filepath.Join(filepath.Dir(startup.DBPath), "plugins", "artifacts"),
|
||||
@@ -93,6 +100,7 @@ func initializeGatewayRuntime() error {
|
||||
return pluginsManager.Reconcile(context.Background())
|
||||
}
|
||||
|
||||
// closeGatewayRuntime 只关闭当前进程持有的数据库连接;SQLite 文件和插件制品都保留在数据目录中。
|
||||
func closeGatewayRuntime() {
|
||||
if adminDB != nil {
|
||||
_ = adminDB.Close()
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/admin_service_handlers.go 提供监听服务配置接口,用于维护端口、启停状态和是否需要重启。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/admin_services.go 从 SQLite 加载监听服务配置,并暴露规范化后的运行时服务选项。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/admin_sessions.go 提供 Admin API 认证中间件使用的内存会话管理器。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/admin_static.go 嵌入构建后的管理前端,并通过网关 HTTP 处理器对外提供。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* cmd/gateway/admin_static/app.css 定义嵌入式管理端仪表盘、表格、表单、对话框和响应式布局样式。 */
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f6f7f4;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<!-- cmd/gateway/admin_static/index.html 提供嵌入式管理端 HTML 外壳,包含对话框、标签页和运行时 API 前缀注入。 -->
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/admin_user_handlers.go 提供管理员维护管理账号的接口,包括创建、更新、禁用和列表查询。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/admin_users.go 初始化管理用户仓库,并在没有账号时创建首次初始化用户。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/config.go 加载静态网关配置,并与管理数据库提供的运行态状态组合使用。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/config_test.go 包含用于约束 config 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/err.go 集中放置网关请求路径使用的少量哨兵错误。
|
||||
|
||||
package main
|
||||
|
||||
import "errors"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/handle_request_test.go 包含用于约束 handle request 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/haproxy.go 为需要 HAProxy PROXY 头的上游 TCP 连接先写入代理头,再回放 Minecraft 流量。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -45,7 +47,7 @@ func haProxyUpstream(source net.Conn, host string) net.Conn {
|
||||
SourceAddr: sourceAddr,
|
||||
DestinationAddr: target,
|
||||
}
|
||||
// After the connection was created write the proxy headers first
|
||||
// 连接建立后先写入 PROXY 头,再转发 Minecraft 首包。
|
||||
_, err = header.WriteTo(conn)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to write proxy header")
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/haproxy_test.go 包含用于约束 haproxy 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/kcp.go 启动可选的 KCP 监听器,并把接收到的会话转入统一网关请求处理流程。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -14,6 +16,7 @@ func runKcp(wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
}
|
||||
|
||||
// KCP 监听使用运行态服务配置中的分片参数,和上游拨号保持一致。
|
||||
listener, err := kcp.ListenWithOptions(fmt.Sprintf(":%d", config.Kcp.Port), nil, config.Kcp.DataShards, config.Kcp.ParityShards)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).
|
||||
@@ -37,11 +40,13 @@ func runKcp(wg *sync.WaitGroup) {
|
||||
|
||||
tuneKcpConn(conn)
|
||||
|
||||
// KCP session 实现 net.Conn,可以直接进入统一网关请求流程。
|
||||
go handleRequest(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func upstreamKcp(host string) net.Conn {
|
||||
// KCP 上游使用与入口相同的 data/parity shards,确保两端编码参数匹配。
|
||||
conn, err := kcp.DialWithOptions(host, nil, config.Kcp.DataShards, config.Kcp.ParityShards)
|
||||
if err != nil {
|
||||
gatewayMetrics.UpstreamDialError()
|
||||
@@ -55,6 +60,7 @@ func upstreamKcp(host string) net.Conn {
|
||||
}
|
||||
|
||||
func tuneKcpConn(conn *kcp.UDPSession) {
|
||||
// 这里偏向低延迟交互:stream mode 模拟 TCP 字节流,禁用写延迟并打开快速 ACK。
|
||||
conn.SetStreamMode(true)
|
||||
conn.SetWriteDelay(false)
|
||||
conn.SetNoDelay(1, 10, 2, 1)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/log.go 配置网关日志、日志文件、日志级别和日志轮转钩子。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/log_notunix.go 在没有 Unix 信号的平台上提供空的日志轮转信号钩子。
|
||||
|
||||
// pid_unix.go
|
||||
//go:build !unix && !plan9
|
||||
|
||||
@@ -8,8 +10,8 @@ import (
|
||||
)
|
||||
|
||||
func handleLogRotate() {
|
||||
// No-op for non-unix platforms
|
||||
// Log rotation is not supported on this platform
|
||||
// This function can be left empty or removed if not needed
|
||||
// 非 Unix 平台不执行日志轮转信号处理。
|
||||
// 该平台不支持通过信号触发日志轮转。
|
||||
// 保留空实现是为了让跨平台调用点保持一致。
|
||||
log.Info().Msg("Log rotation is not supported on this platform")
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/log_pid_test.go 包含用于约束 log pid 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/log_unix.go 注册 Unix 信号处理,让进程无需完整重启即可重新打开日志文件。
|
||||
|
||||
// pid_unix.go
|
||||
//go:build unix || plan9
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/main.go 负责网关进程启动、监听器选择、Minecraft 握手路由、插件钩子分发以及上游转发交接。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -17,6 +19,8 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 插件子命令复用网关二进制。这里先于运行态配置加载处理它们,
|
||||
// 这样本地构建和清单命令不需要一份可用的网关部署配置。
|
||||
if handled, code := runPluginCLI(os.Args[1:]); handled {
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -39,8 +43,13 @@ func main() {
|
||||
}
|
||||
|
||||
func startEnabledServices() {
|
||||
// TCP 和 Admin HTTP 始终通过共享监听器启动。共享监听器按每条连接
|
||||
// 的首包判断它是 HTTP 还是 Minecraft 协议数据,因此不需要额外维护
|
||||
// 一个手动模式开关。
|
||||
startService(runTcpWebPortReuse)
|
||||
|
||||
// 可选传输最终仍进入 handleRequest,这让插件过滤、路由解析和上游拨号
|
||||
// 在 TCP、KCP、QUIC 和 WebSocket 入口之间保持一致。
|
||||
if config.Kcp.Enable {
|
||||
startService(runKcp)
|
||||
}
|
||||
@@ -61,6 +70,8 @@ func handleRequest(conn net.Conn) {
|
||||
gatewayMetrics.ConnectionStarted()
|
||||
defer gatewayMetrics.ConnectionFinished()
|
||||
|
||||
// 插件或协议解析器的 panic 不能杀掉监听协程;当前连接会被放弃,
|
||||
// 进程继续服务其他客户端。
|
||||
defer func() {
|
||||
rec := recover()
|
||||
if rec == nil {
|
||||
@@ -91,6 +102,8 @@ func handleRequest(conn net.Conn) {
|
||||
}
|
||||
|
||||
func mapToHost(conn net.Conn) net.Conn {
|
||||
// 连接过滤器在读取 Minecraft 握手前执行,因此可以按来源地址或传输类型
|
||||
// 拒绝连接,同时不消耗客户端发送的协议字节。
|
||||
if pluginsManager != nil {
|
||||
transport, _, _ := connectionIngress(conn)
|
||||
filter, err := pluginsManager.FilterConnection(context.Background(), api.ConnectionFilterRequest{
|
||||
@@ -124,6 +137,8 @@ func mapToHost(conn net.Conn) net.Conn {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 第一次读取包含 Minecraft 握手数据。所有过滤器和路由决策完成后,
|
||||
// 这段数据必须原样或按插件改写后回放给选中的上游。
|
||||
initialData := append([]byte(nil), buf[:n]...)
|
||||
handshake := protocol.ParseHandshake(initialData)
|
||||
if handshake.ServerHost == "" {
|
||||
@@ -133,6 +148,8 @@ func mapToHost(conn net.Conn) net.Conn {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 握手过滤器可以改写目标主机名。发生改写时要立刻重建首包,
|
||||
// 确保上游看到的是改写后的 Minecraft 主机名,而不是客户端原始值。
|
||||
if pluginsManager != nil {
|
||||
filter, err := pluginsManager.FilterHandshake(context.Background(), api.HandshakeFilterRequest{
|
||||
SourceAddr: conn.RemoteAddr().String(),
|
||||
@@ -155,6 +172,8 @@ func mapToHost(conn net.Conn) net.Conn {
|
||||
}
|
||||
}
|
||||
|
||||
// 状态查询使用 NextState=1,并且可以由插件直接完整响应。
|
||||
// 如果这里已经处理,就不会再为该查询打开上游连接。
|
||||
if handshake.NextState == 1 {
|
||||
if handled := handleStatusPing(conn, handshake); handled {
|
||||
return nil
|
||||
@@ -226,6 +245,8 @@ func mapToHost(conn net.Conn) net.Conn {
|
||||
|
||||
if client == nil {
|
||||
target := upstreamtarget.Parse(host)
|
||||
// 路由值可以通过前缀选择非 TCP 传输;普通地址仍按 TCP 处理,
|
||||
// 以保持旧配置的行为不变。
|
||||
switch target.Protocol {
|
||||
case upstreamtarget.ProtocolQUIC:
|
||||
client = upstreamQuic(target.Address)
|
||||
@@ -241,6 +262,8 @@ func mapToHost(conn net.Conn) net.Conn {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 只有在上游路径确定后才回放握手数据。这样插件在任何上游字节发出前,
|
||||
// 都还有机会阻断、代理或改写连接。
|
||||
if err := writeAll(client, initialData); err != nil {
|
||||
log.Err(err).
|
||||
Str("client", conn.RemoteAddr().String()).
|
||||
@@ -256,6 +279,8 @@ func mapToHost(conn net.Conn) net.Conn {
|
||||
|
||||
func resolveGatewayRoute(conn net.Conn, handshake protocol.Handshake) pluginmanager.RouteResolveResult {
|
||||
upstream, hit := lookupRoute(handshake.ServerHost)
|
||||
// SQLite 快照始终作为本地兜底。插件会同时拿到兜底决策和刷新回调,
|
||||
// 因此可以选择性覆盖路由,而不必在插件里复制一套路由仓库逻辑。
|
||||
req := api.RouteResolveRequest{
|
||||
Host: handshake.ServerHost,
|
||||
RawServerHost: handshake.RawServerHost,
|
||||
@@ -283,6 +308,7 @@ func resolveGatewayRoute(conn net.Conn, handshake protocol.Handshake) pluginmana
|
||||
action := api.RouteDecisionFallback
|
||||
source := "sqlite_fallback"
|
||||
if upstream == "" {
|
||||
// 没有命中兜底路由时统一表示为拒绝决策,便于热路径记录一致的失败形态。
|
||||
action = api.RouteDecisionReject
|
||||
source = "fallback_miss"
|
||||
}
|
||||
@@ -296,6 +322,8 @@ func handleStatusPing(conn net.Conn, handshake protocol.Handshake) bool {
|
||||
if pluginsManager == nil {
|
||||
return false
|
||||
}
|
||||
// Minecraft 状态响应是带长度前缀的 JSON 数据包。插件只提供高层字段,
|
||||
// Minecraft 协议封包由 protocol.StatusResponsePacket 统一完成。
|
||||
result, err := pluginsManager.StatusPing(context.Background(), api.StatusPingRequest{
|
||||
Host: handshake.ServerHost,
|
||||
RawServerHost: handshake.RawServerHost,
|
||||
@@ -339,6 +367,8 @@ func handleStatusPing(conn net.Conn, handshake protocol.Handshake) bool {
|
||||
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)
|
||||
// InitialData 使用副本,避免上游插件在其他处理器或日志路径仍引用回放缓冲区时
|
||||
// 意外修改调用方持有的数据。
|
||||
req := api.UpstreamConnectRequest{
|
||||
Source: conn,
|
||||
Host: handshake.ServerHost,
|
||||
@@ -367,6 +397,8 @@ func newUpstreamConnectRequest(conn net.Conn, upstream string, handshake protoco
|
||||
func connectionIngress(conn net.Conn) (transport string, serviceName string, listenerPort int) {
|
||||
transport = "tcp"
|
||||
serviceName = serviceNameTCPAdmin
|
||||
// 具体连接包装类型记录了客户端来自哪个监听器。该元数据会传给插件,
|
||||
// 并出现在运维诊断中,同时不需要改变 net.Conn 接口。
|
||||
switch conn.(type) {
|
||||
case *webSocketConn:
|
||||
transport = "websocket"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/main_test.go 包含用于约束 gateway 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/pid.go 维护 pid 文件的写入和清理,供进程管理器按文件追踪网关进程。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/pid_unix.go 实现 Unix 平台的 pid 文件占用检查,避免覆盖仍在运行的进程记录。
|
||||
|
||||
// pid_unix.go
|
||||
//go:build unix || plan9
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//go:build unix || plan9
|
||||
|
||||
// cmd/gateway/pid_unix_test.go 包含用于约束 pid unix 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/pid_windows.go 在 Windows 上提供可移植的 pid 文件占用检查替代实现。
|
||||
|
||||
// pid_windows.go
|
||||
//go:build windows
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/plugin.go 把网关运行时接入 pluginmanager,负责钩子分发和插件生命周期加载。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -96,12 +98,12 @@ func loadPlugins() {
|
||||
}
|
||||
}
|
||||
|
||||
// HandleConn implements api.Gateway.
|
||||
// HandleConn 实现 api.Gateway,用于让插件把连接交回网关主流程。
|
||||
func (g *Gateway) HandleConn(conn net.Conn) {
|
||||
go handleRequest(conn)
|
||||
}
|
||||
|
||||
// Hook implements api.Gateway.
|
||||
// Hook 实现 api.Gateway,用于注册旧版内存钩子处理器。
|
||||
func (g *Gateway) Hook(hook string, handler any) error {
|
||||
pluginLock.Lock()
|
||||
defer pluginLock.Unlock()
|
||||
@@ -110,7 +112,7 @@ func (g *Gateway) Hook(hook string, handler any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExitWaitGroup implements api.Gateway.
|
||||
// ExitWaitGroup 实现 api.Gateway,用于把插件后台任务纳入进程退出等待。
|
||||
func (g *Gateway) ExitWaitGroup() *sync.WaitGroup {
|
||||
return &exitWaitGroup
|
||||
}
|
||||
@@ -152,7 +154,7 @@ func (g *Gateway) RegisterBackgroundTask(task api.BackgroundTask) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestOp implements api.Gateway.
|
||||
// TestOp 实现 api.Gateway,保留给测试或调试插件能力探测。
|
||||
func (g *Gateway) TestOp() {
|
||||
panic("unimplemented")
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/plugin_cli.go 分发插件相关子命令,包括本地脚手架、构建、清单和远程管理操作。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -15,17 +17,184 @@ import (
|
||||
)
|
||||
|
||||
func runPluginCLI(args []string) (bool, int) {
|
||||
if len(args) < 2 || args[0] != "plugin" {
|
||||
if len(args) < 1 || args[0] != "plugin" {
|
||||
return false, 0
|
||||
}
|
||||
if len(args) < 3 {
|
||||
fmt.Fprintln(os.Stderr, "usage: gateway plugin inspect|validate|compat|source-validate <artifact.mcgp> | source-build <source.mcgp> [out.mcgp]")
|
||||
if len(args) < 2 {
|
||||
printPluginCLIUsage()
|
||||
return true, 2
|
||||
}
|
||||
|
||||
command, packagePath := args[1], args[2]
|
||||
command := args[1]
|
||||
switch command {
|
||||
case "init":
|
||||
if err := runPluginInitCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "features":
|
||||
if err := runPluginFeaturesCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "manifest":
|
||||
if err := runPluginManifestCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "preflight":
|
||||
if err := runPluginPreflightCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "self-test":
|
||||
if err := runPluginSelfTestCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "benchmark":
|
||||
if err := runPluginBenchmarkCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "status":
|
||||
if err := runPluginRemoteStatusCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "upload":
|
||||
if err := runPluginRemoteUploadCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "enable":
|
||||
if err := runPluginRemoteDesiredCLI(args[2:], pluginmanager.DesiredEnabled); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "disable":
|
||||
if err := runPluginRemoteActionCLI(args[2:], "disable"); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "delete":
|
||||
if err := runPluginRemoteActionCLI(args[2:], "delete"); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "rollback":
|
||||
if err := runPluginRemoteRollbackCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "config":
|
||||
if err := runPluginRemoteConfigCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "secret":
|
||||
if err := runPluginRemoteSecretCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "logs", "events", "metrics":
|
||||
if err := runPluginRemoteOperationsSectionCLI(command, args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "diagnose":
|
||||
if err := runPluginRemoteDiagnoseCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "task":
|
||||
if err := runPluginRemoteTaskCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "data", "files":
|
||||
if err := runPluginRemoteResourceCLI(command, args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "gc":
|
||||
if err := runPluginRemoteGCCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "review":
|
||||
if err := runPluginRemoteReviewCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "advisory":
|
||||
if err := runPluginRemoteAdvisoryCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "repo":
|
||||
if err := runPluginRemoteRepoCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "sbom", "verify":
|
||||
if err := runPluginRemoteSupplyChainCLI(command, args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "runtime":
|
||||
if err := runPluginRuntimeCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "schema", "contract", "conformance", "export", "import", "diff", "drift", "dr-drill", "sign":
|
||||
if err := runPluginReservedCLI(command, args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "build":
|
||||
if err := runPluginBuildCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "test":
|
||||
if err := runPluginTestCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "inspect":
|
||||
if len(args) < 3 {
|
||||
printPluginCLIUsage()
|
||||
return true, 2
|
||||
}
|
||||
packagePath := args[2]
|
||||
manifest, err := readPackageManifest(packagePath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
@@ -39,18 +208,7 @@ func runPluginCLI(args []string) (bool, int) {
|
||||
}
|
||||
return true, 0
|
||||
case "validate", "compat":
|
||||
tmpRoot, err := os.MkdirTemp("", "mcgp-cli-*")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
defer os.RemoveAll(tmpRoot)
|
||||
store := pluginmanager.NewArtifactStore(tmpRoot)
|
||||
artifact, err := store.ValidateAndStore(pluginmanager.ArtifactUpload{
|
||||
SourcePath: packagePath,
|
||||
FileName: filepath.Base(packagePath),
|
||||
Actor: "cli",
|
||||
})
|
||||
artifact, err := runPluginValidatePathCLI(args[2:], "")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
@@ -59,18 +217,7 @@ func runPluginCLI(args []string) (bool, int) {
|
||||
artifact.PluginID, artifact.Version, artifact.SHA256, artifact.APIVersion, artifact.GoVersion, artifact.GOOS, artifact.GOARCH)
|
||||
return true, 0
|
||||
case "source-validate":
|
||||
tmpRoot, err := os.MkdirTemp("", "mcgp-source-cli-*")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
defer os.RemoveAll(tmpRoot)
|
||||
store := pluginmanager.NewArtifactStore(tmpRoot)
|
||||
source, err := store.ValidateAndStoreSource(pluginmanager.ArtifactUpload{
|
||||
SourcePath: packagePath,
|
||||
FileName: filepath.Base(packagePath),
|
||||
Actor: "cli",
|
||||
})
|
||||
source, err := runPluginValidatePathCLI(args[2:], pluginmanager.ArtifactTypeSource)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
@@ -79,6 +226,11 @@ func runPluginCLI(args []string) (bool, int) {
|
||||
source.PluginID, source.Version, source.SHA256, source.APIVersion, source.GoVersion, source.GOOS, source.GOARCH)
|
||||
return true, 0
|
||||
case "source-build":
|
||||
if len(args) < 3 {
|
||||
printPluginCLIUsage()
|
||||
return true, 2
|
||||
}
|
||||
packagePath := args[2]
|
||||
outPath := ""
|
||||
if len(args) >= 4 {
|
||||
outPath = args[3]
|
||||
@@ -97,6 +249,10 @@ func runPluginCLI(args []string) (bool, int) {
|
||||
}
|
||||
}
|
||||
|
||||
func printPluginCLIUsage() {
|
||||
fmt.Fprintln(os.Stderr, "usage: gateway plugin init|features|manifest|build|test|preflight|self-test|benchmark|status|upload|enable|disable|delete|rollback|config|secret|logs|events|metrics|diagnose|task|data|files|gc|review|advisory|repo|sbom|verify|runtime|inspect|validate|compat|source-validate|source-build ...")
|
||||
}
|
||||
|
||||
func buildSourcePackageForCLI(packagePath, outPath string) (pluginmanager.BuildRecord, string, error) {
|
||||
tmpRoot, err := os.MkdirTemp("", "mcgp-source-build-cli-*")
|
||||
if err != nil {
|
||||
|
||||
348
cmd/gateway/plugin_cli_manifest.go
Normal file
348
cmd/gateway/plugin_cli_manifest.go
Normal file
@@ -0,0 +1,348 @@
|
||||
// cmd/gateway/plugin_cli_manifest.go 实现插件包清单的查看、校验、特性列表和格式化命令。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"github.com/tursom/mc-gateway/internal/pluginmanager"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
manifestFormatJSON = "json"
|
||||
manifestFormatJSONC = "jsonc"
|
||||
manifestFormatYAML = "yaml"
|
||||
manifestFormatTOML = "toml"
|
||||
)
|
||||
|
||||
var manifestSourceNames = []string{
|
||||
"manifest.yaml",
|
||||
"manifest.yml",
|
||||
"manifest.toml",
|
||||
"manifest.jsonc",
|
||||
"manifest.json",
|
||||
}
|
||||
|
||||
type pluginManifestSource struct {
|
||||
Path string
|
||||
Format string
|
||||
Data []byte
|
||||
Manifest pluginmanager.Manifest
|
||||
Raw map[string]any
|
||||
CanonicalJSON []byte
|
||||
}
|
||||
|
||||
func readPluginDirManifest(dir, explicitManifestPath string) (pluginmanager.Manifest, map[string]any, error) {
|
||||
source, err := readPluginManifestSource(dir, explicitManifestPath)
|
||||
if err != nil {
|
||||
return pluginmanager.Manifest{}, nil, err
|
||||
}
|
||||
return source.Manifest, source.Raw, nil
|
||||
}
|
||||
|
||||
func readPluginManifestSource(target, explicitManifestPath string) (pluginManifestSource, error) {
|
||||
manifestPath, err := resolveManifestSourcePath(target, explicitManifestPath)
|
||||
if err != nil {
|
||||
return pluginManifestSource{}, err
|
||||
}
|
||||
return readPluginManifestSourceFile(manifestPath)
|
||||
}
|
||||
|
||||
func resolveManifestSourcePath(target, explicitManifestPath string) (string, error) {
|
||||
if explicitManifestPath != "" {
|
||||
return resolveExplicitManifestPath(target, explicitManifestPath)
|
||||
}
|
||||
info, err := os.Stat(target)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
if !isManifestSourceFile(target) {
|
||||
return "", fmt.Errorf("manifest path must be one of %s, got %q", strings.Join(manifestSourceNames, ", "), target)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
var found []string
|
||||
for _, name := range manifestSourceNames {
|
||||
candidate := filepath.Join(target, name)
|
||||
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||
found = append(found, candidate)
|
||||
}
|
||||
}
|
||||
if len(found) == 0 {
|
||||
return "", fmt.Errorf("plugin manifest is required: expected one of %s in %s", strings.Join(manifestSourceNames, ", "), target)
|
||||
}
|
||||
if len(found) > 1 {
|
||||
sort.Strings(found)
|
||||
return "", fmt.Errorf("multiple plugin manifests found: %s; pass --manifest to select one", strings.Join(found, ", "))
|
||||
}
|
||||
return found[0], nil
|
||||
}
|
||||
|
||||
func resolveExplicitManifestPath(target, explicitManifestPath string) (string, error) {
|
||||
candidates := []string{explicitManifestPath}
|
||||
if info, err := os.Stat(target); err == nil && info.IsDir() && !filepath.IsAbs(explicitManifestPath) {
|
||||
candidates = []string{filepath.Join(target, explicitManifestPath), explicitManifestPath}
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
info, err := os.Stat(candidate)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.IsDir() {
|
||||
return "", fmt.Errorf("manifest path %q is a directory", candidate)
|
||||
}
|
||||
if !isManifestSourceFile(candidate) {
|
||||
return "", fmt.Errorf("manifest path must be one of %s, got %q", strings.Join(manifestSourceNames, ", "), candidate)
|
||||
}
|
||||
return candidate, nil
|
||||
}
|
||||
return "", fmt.Errorf("manifest path %q is not readable", explicitManifestPath)
|
||||
}
|
||||
|
||||
func readPluginManifestSourceFile(manifestPath string) (pluginManifestSource, error) {
|
||||
format, err := manifestFormatForPath(manifestPath)
|
||||
if err != nil {
|
||||
return pluginManifestSource{}, err
|
||||
}
|
||||
data, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
return pluginManifestSource{}, err
|
||||
}
|
||||
raw, err := decodeManifestSource(data, format)
|
||||
if err != nil {
|
||||
return pluginManifestSource{}, fmt.Errorf("invalid %s: %w", filepath.Base(manifestPath), err)
|
||||
}
|
||||
canonical, err := json.MarshalIndent(raw, "", " ")
|
||||
if err != nil {
|
||||
return pluginManifestSource{}, err
|
||||
}
|
||||
canonical = append(canonical, '\n')
|
||||
var manifest pluginmanager.Manifest
|
||||
if err := json.Unmarshal(canonical, &manifest); err != nil {
|
||||
return pluginManifestSource{}, fmt.Errorf("invalid %s object: %w", filepath.Base(manifestPath), err)
|
||||
}
|
||||
if manifest.ID == "" {
|
||||
return pluginManifestSource{}, errors.New("manifest id is required")
|
||||
}
|
||||
return pluginManifestSource{
|
||||
Path: manifestPath,
|
||||
Format: format,
|
||||
Data: data,
|
||||
Manifest: manifest,
|
||||
Raw: raw,
|
||||
CanonicalJSON: canonical,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodeManifestSource(data []byte, format string) (map[string]any, error) {
|
||||
var raw any
|
||||
switch format {
|
||||
case manifestFormatJSON:
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case manifestFormatJSONC:
|
||||
stripped, err := stripJSONC(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(stripped, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case manifestFormatYAML:
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case manifestFormatTOML:
|
||||
var table map[string]any
|
||||
if err := toml.Unmarshal(data, &table); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw = table
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported manifest format %q", format)
|
||||
}
|
||||
normalized, ok := normalizeManifestValue(raw).(map[string]any)
|
||||
if !ok {
|
||||
return nil, errors.New("manifest root must be an object")
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeManifestValue(value any) any {
|
||||
switch v := value.(type) {
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(v))
|
||||
for key, item := range v {
|
||||
out[key] = normalizeManifestValue(item)
|
||||
}
|
||||
return out
|
||||
case map[any]any:
|
||||
out := make(map[string]any, len(v))
|
||||
for key, item := range v {
|
||||
out[fmt.Sprint(key)] = normalizeManifestValue(item)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, len(v))
|
||||
for i, item := range v {
|
||||
out[i] = normalizeManifestValue(item)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func manifestFormatForPath(filePath string) (string, error) {
|
||||
switch strings.ToLower(filepath.Base(filePath)) {
|
||||
case "manifest.json":
|
||||
return manifestFormatJSON, nil
|
||||
case "manifest.jsonc":
|
||||
return manifestFormatJSONC, nil
|
||||
case "manifest.yaml", "manifest.yml":
|
||||
return manifestFormatYAML, nil
|
||||
case "manifest.toml":
|
||||
return manifestFormatTOML, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported manifest file %q", filePath)
|
||||
}
|
||||
}
|
||||
|
||||
func isManifestSourceFile(filePath string) bool {
|
||||
_, err := manifestFormatForPath(filePath)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func stripJSONC(data []byte) ([]byte, error) {
|
||||
withoutComments, err := stripJSONCComments(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return stripJSONCTrailingCommas(withoutComments), nil
|
||||
}
|
||||
|
||||
func stripJSONCComments(data []byte) ([]byte, error) {
|
||||
out := make([]byte, 0, len(data))
|
||||
inString := false
|
||||
escaped := false
|
||||
for i := 0; i < len(data); i++ {
|
||||
ch := data[i]
|
||||
if inString {
|
||||
out = append(out, ch)
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if ch == '\\' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if ch == '"' {
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ch == '"' {
|
||||
inString = true
|
||||
out = append(out, ch)
|
||||
continue
|
||||
}
|
||||
if ch == '/' && i+1 < len(data) {
|
||||
next := data[i+1]
|
||||
if next == '/' {
|
||||
out = append(out, ' ', ' ')
|
||||
i += 2
|
||||
for ; i < len(data); i++ {
|
||||
if data[i] == '\n' || data[i] == '\r' {
|
||||
out = append(out, data[i])
|
||||
break
|
||||
}
|
||||
out = append(out, ' ')
|
||||
}
|
||||
continue
|
||||
}
|
||||
if next == '*' {
|
||||
out = append(out, ' ', ' ')
|
||||
i += 2
|
||||
closed := false
|
||||
for ; i < len(data); i++ {
|
||||
if data[i] == '*' && i+1 < len(data) && data[i+1] == '/' {
|
||||
out = append(out, ' ', ' ')
|
||||
i++
|
||||
closed = true
|
||||
break
|
||||
}
|
||||
if data[i] == '\n' || data[i] == '\r' {
|
||||
out = append(out, data[i])
|
||||
} else {
|
||||
out = append(out, ' ')
|
||||
}
|
||||
}
|
||||
if !closed {
|
||||
return nil, errors.New("unterminated block comment")
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, ch)
|
||||
}
|
||||
if inString {
|
||||
return nil, errors.New("unterminated string")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func stripJSONCTrailingCommas(data []byte) []byte {
|
||||
var out bytes.Buffer
|
||||
inString := false
|
||||
escaped := false
|
||||
for i := 0; i < len(data); i++ {
|
||||
ch := data[i]
|
||||
if inString {
|
||||
out.WriteByte(ch)
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if ch == '\\' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if ch == '"' {
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ch == '"' {
|
||||
inString = true
|
||||
out.WriteByte(ch)
|
||||
continue
|
||||
}
|
||||
if ch == ',' {
|
||||
j := i + 1
|
||||
for j < len(data) && isJSONWhitespace(data[j]) {
|
||||
j++
|
||||
}
|
||||
if j < len(data) && (data[j] == '}' || data[j] == ']') {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out.WriteByte(ch)
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func isJSONWhitespace(ch byte) bool {
|
||||
return ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r'
|
||||
}
|
||||
979
cmd/gateway/plugin_cli_remote.go
Normal file
979
cmd/gateway/plugin_cli_remote.go
Normal file
@@ -0,0 +1,979 @@
|
||||
// cmd/gateway/plugin_cli_remote.go 实现通过 Admin API 驱动插件管理操作的命令行客户端。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/pluginmanager"
|
||||
)
|
||||
|
||||
type pluginRemoteOptions struct {
|
||||
Gateway string
|
||||
Token string
|
||||
Target string
|
||||
Extra []string
|
||||
ArtifactID string
|
||||
ConfigPath string
|
||||
ConfigJSON string
|
||||
Priority int
|
||||
Source bool
|
||||
SnapshotID int64
|
||||
FullDesired bool
|
||||
Profile string
|
||||
Action string
|
||||
Decision string
|
||||
Notes string
|
||||
Reason string
|
||||
TTLSeconds int64
|
||||
ConfirmToken string
|
||||
DryRun bool
|
||||
RepositoryType string
|
||||
IndexPath string
|
||||
Version string
|
||||
TrustPolicy string
|
||||
MetadataPath string
|
||||
MetadataJSON string
|
||||
BenchmarkProfile string
|
||||
P95MS float64
|
||||
P99MS float64
|
||||
ErrorRate float64
|
||||
ActiveProxyCapacity int64
|
||||
BaselineDiff float64
|
||||
Mode string
|
||||
}
|
||||
|
||||
type pluginRemoteClient struct {
|
||||
baseURL string
|
||||
token string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func runPluginRemoteStatusCLI(args []string) error {
|
||||
opts, err := parsePluginRemoteOptions(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
endpoint := "/plugins"
|
||||
if opts.Target != "" {
|
||||
endpoint = "/plugins/" + url.PathEscape(opts.Target)
|
||||
}
|
||||
return client.doToStdout(http.MethodGet, endpoint, nil)
|
||||
}
|
||||
|
||||
func runPluginRemoteUploadCLI(args []string) error {
|
||||
opts, err := parsePluginRemoteOptions(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" {
|
||||
return errors.New("upload requires an artifact path")
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
endpoint := "/plugin-artifacts"
|
||||
if opts.Source {
|
||||
endpoint = "/plugin-sources"
|
||||
}
|
||||
return client.uploadArtifact(endpoint, opts.Target)
|
||||
}
|
||||
|
||||
func runPluginRemoteDesiredCLI(args []string, desiredState string) error {
|
||||
opts, err := parsePluginRemoteOptions(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" {
|
||||
return errors.New("enable requires a plugin id")
|
||||
}
|
||||
if opts.ArtifactID == "" {
|
||||
return errors.New("enable requires --artifact")
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
configJSON, err := remoteConfigJSON(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]any{
|
||||
"artifact_id": opts.ArtifactID,
|
||||
"desired_state": desiredState,
|
||||
"config_json": configJSON,
|
||||
"priority": opts.Priority,
|
||||
"source": "cli",
|
||||
"requested_mode": "desired",
|
||||
}
|
||||
return client.doToStdout(http.MethodPut, "/plugins/"+url.PathEscape(opts.Target), body)
|
||||
}
|
||||
|
||||
func runPluginRemoteActionCLI(args []string, action string) error {
|
||||
opts, err := parsePluginRemoteOptions(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" {
|
||||
return fmt.Errorf("%s requires a plugin id", action)
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/"+action, nil)
|
||||
}
|
||||
|
||||
func runPluginRemoteRollbackCLI(args []string) error {
|
||||
opts, err := parsePluginRemoteOptions(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" {
|
||||
return errors.New("rollback requires a plugin id")
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.SnapshotID > 0 {
|
||||
body := map[string]any{"snapshot_id": opts.SnapshotID, "full_desired": opts.FullDesired}
|
||||
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/rollback/config", body)
|
||||
}
|
||||
if opts.ArtifactID == "" {
|
||||
return errors.New("rollback requires --artifact or --snapshot")
|
||||
}
|
||||
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/rollback/artifact", map[string]any{"artifact_id": opts.ArtifactID})
|
||||
}
|
||||
|
||||
func runPluginRemoteConfigCLI(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("usage: gateway plugin config validate <plugin-id> ...")
|
||||
}
|
||||
switch args[0] {
|
||||
case "validate":
|
||||
opts, err := parsePluginRemoteOptions(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" {
|
||||
return errors.New("config validate requires a plugin id")
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
configJSON, err := remoteConfigJSON(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]any{"artifact_id": opts.ArtifactID, "config_json": configJSON}
|
||||
if opts.Priority != 0 {
|
||||
body["priority"] = opts.Priority
|
||||
}
|
||||
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/config/dry-run", body)
|
||||
default:
|
||||
return fmt.Errorf("unknown config command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runPluginRemoteSecretCLI(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("usage: gateway plugin secret check <plugin-id> ...")
|
||||
}
|
||||
switch args[0] {
|
||||
case "check":
|
||||
opts, err := parsePluginRemoteOptions(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" {
|
||||
return errors.New("secret check requires a plugin id")
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.doToStdout(http.MethodGet, "/plugins/"+url.PathEscape(opts.Target)+"/secrets", nil)
|
||||
default:
|
||||
return fmt.Errorf("unknown secret command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runPluginRemoteOperationsSectionCLI(command string, args []string) error {
|
||||
opts, err := parsePluginRemoteOptions(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" {
|
||||
return fmt.Errorf("%s requires a plugin id", command)
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := client.doJSON(http.MethodGet, "/plugins/"+url.PathEscape(opts.Target)+"/operations", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
operations, _ := body["operations"].(map[string]any)
|
||||
result := map[string]any{"plugin_id": opts.Target}
|
||||
switch command {
|
||||
case "logs":
|
||||
result["logs"] = operations["logs"]
|
||||
result["traces"] = operations["traces"]
|
||||
case "events":
|
||||
result["events"] = operations["events"]
|
||||
result["event_queue"] = operations["event_queue"]
|
||||
case "metrics":
|
||||
result["handlers"] = operations["handlers"]
|
||||
result["custom_metrics"] = operations["custom_metrics"]
|
||||
default:
|
||||
return fmt.Errorf("unknown operations section %q", command)
|
||||
}
|
||||
return encodePluginCLIJSON(result)
|
||||
}
|
||||
|
||||
func runPluginRemoteDiagnoseCLI(args []string) error {
|
||||
opts, err := parsePluginRemoteOptions(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" {
|
||||
return errors.New("diagnose requires a plugin id")
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.doToStdout(http.MethodGet, "/plugins/"+url.PathEscape(opts.Target)+"/diagnostics", nil)
|
||||
}
|
||||
|
||||
func runPluginRemoteTaskCLI(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("usage: gateway plugin task list|run|cancel ...")
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
opts, err := parsePluginRemoteOptions(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" {
|
||||
return errors.New("task list requires a plugin id")
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := client.doJSON(http.MethodGet, "/plugins/"+url.PathEscape(opts.Target)+"/operations", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
operations, _ := body["operations"].(map[string]any)
|
||||
return encodePluginCLIJSON(map[string]any{
|
||||
"plugin_id": opts.Target,
|
||||
"background_tasks": operations["background_tasks"],
|
||||
})
|
||||
case "run":
|
||||
opts, err := parsePluginRemoteOptionsWithPositionals(args[1:], 2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" || len(opts.Extra) == 0 {
|
||||
return errors.New("task run requires a plugin id and task id")
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
taskID := opts.Extra[0]
|
||||
body := map[string]any{"confirm_token": opts.ConfirmToken}
|
||||
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/operations/tasks/"+url.PathEscape(taskID)+"/trigger", body)
|
||||
case "cancel":
|
||||
return runPluginReservedCLI("task cancel", args[1:])
|
||||
default:
|
||||
return fmt.Errorf("unknown task command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runPluginRemoteResourceCLI(command string, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("usage: gateway plugin %s inspect|gc ...", command)
|
||||
}
|
||||
switch args[0] {
|
||||
case "inspect":
|
||||
opts, err := parsePluginRemoteOptions(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" {
|
||||
return fmt.Errorf("%s inspect requires a plugin id", command)
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := client.doJSON(http.MethodGet, "/plugins/"+url.PathEscape(opts.Target)+"/operations", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
operations, _ := body["operations"].(map[string]any)
|
||||
field := "plugin_data"
|
||||
if command == "files" {
|
||||
field = "plugin_files"
|
||||
}
|
||||
return encodePluginCLIJSON(map[string]any{
|
||||
"plugin_id": opts.Target,
|
||||
field: operations[field],
|
||||
"gc": operations["gc"],
|
||||
})
|
||||
case "gc":
|
||||
opts, err := parsePluginRemoteOptions(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" {
|
||||
return fmt.Errorf("%s gc requires a plugin id", command)
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
method := http.MethodGet
|
||||
if !opts.DryRun {
|
||||
method = http.MethodPost
|
||||
}
|
||||
return client.doToStdout(method, "/plugins/"+url.PathEscape(opts.Target)+"/operations/gc", nil)
|
||||
case "export":
|
||||
return runPluginReservedCLI(command+" export", args[1:])
|
||||
default:
|
||||
return fmt.Errorf("unknown %s command %q", command, args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runPluginRemoteGCCLI(args []string) error {
|
||||
opts, err := parsePluginRemoteOptions(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
method := http.MethodGet
|
||||
if !opts.DryRun {
|
||||
method = http.MethodPost
|
||||
}
|
||||
endpoint := "/plugin-gc"
|
||||
if opts.Target != "" {
|
||||
endpoint = "/plugin-operations-gc?plugin_id=" + url.QueryEscape(opts.Target)
|
||||
}
|
||||
return client.doToStdout(method, endpoint, nil)
|
||||
}
|
||||
|
||||
func runPluginRemoteReviewCLI(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("usage: gateway plugin review status|approve|reject|override ...")
|
||||
}
|
||||
switch args[0] {
|
||||
case "status":
|
||||
opts, err := parsePluginRemoteOptions(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" {
|
||||
return errors.New("review status requires a plugin id")
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
values := url.Values{}
|
||||
if opts.ArtifactID != "" {
|
||||
values.Set("artifact_id", opts.ArtifactID)
|
||||
}
|
||||
if opts.Profile != "" {
|
||||
values.Set("profile", opts.Profile)
|
||||
}
|
||||
endpoint := "/plugins/" + url.PathEscape(opts.Target) + "/governance"
|
||||
if query := values.Encode(); query != "" {
|
||||
endpoint += "?" + query
|
||||
}
|
||||
return client.doToStdout(http.MethodGet, endpoint, nil)
|
||||
case "approve", "reject":
|
||||
opts, err := parsePluginRemoteOptions(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" {
|
||||
return fmt.Errorf("review %s requires a plugin id", args[0])
|
||||
}
|
||||
if opts.ArtifactID == "" {
|
||||
return fmt.Errorf("review %s requires --artifact", args[0])
|
||||
}
|
||||
decision := pluginmanager.ReviewDecisionApproved
|
||||
if args[0] == "reject" {
|
||||
decision = pluginmanager.ReviewDecisionRejected
|
||||
}
|
||||
if opts.Decision != "" {
|
||||
decision = opts.Decision
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]any{
|
||||
"artifact_id": opts.ArtifactID,
|
||||
"profile": opts.Profile,
|
||||
"decision": decision,
|
||||
"notes": opts.Notes,
|
||||
}
|
||||
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/governance/review", body)
|
||||
case "override":
|
||||
opts, err := parsePluginRemoteOptions(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" || opts.ArtifactID == "" {
|
||||
return errors.New("review override requires a plugin id and --artifact")
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]any{
|
||||
"artifact_id": opts.ArtifactID,
|
||||
"profile": opts.Profile,
|
||||
"action": opts.Action,
|
||||
"reason": opts.Reason,
|
||||
"ttl_seconds": opts.TTLSeconds,
|
||||
}
|
||||
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/governance/override", body)
|
||||
default:
|
||||
return fmt.Errorf("unknown review command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runPluginRemoteAdvisoryCLI(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("usage: gateway plugin advisory scan|import ...")
|
||||
}
|
||||
switch args[0] {
|
||||
case "scan":
|
||||
opts, err := parsePluginRemoteOptions(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
endpoint := "/plugin-advisories"
|
||||
if opts.Target != "" {
|
||||
endpoint += "?plugin_id=" + url.QueryEscape(opts.Target)
|
||||
}
|
||||
return client.doToStdout(http.MethodGet, endpoint, nil)
|
||||
case "import":
|
||||
opts, err := parsePluginRemoteOptions(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := remoteMetadataJSON(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.doToStdout(http.MethodPost, "/plugin-advisories", body)
|
||||
default:
|
||||
return fmt.Errorf("unknown advisory command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runPluginRemoteRepoCLI(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("usage: gateway plugin repo list|import|search|show ...")
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
opts, err := parsePluginRemoteOptions(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.doToStdout(http.MethodGet, "/plugin-repositories/imports", nil)
|
||||
case "import":
|
||||
opts, err := parsePluginRemoteOptions(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]any{
|
||||
"repository_type": opts.RepositoryType,
|
||||
"index_path": opts.IndexPath,
|
||||
"artifact_id": opts.ArtifactID,
|
||||
"plugin_id": opts.Target,
|
||||
"version": opts.Version,
|
||||
"trust_policy": opts.TrustPolicy,
|
||||
}
|
||||
return client.doToStdout(http.MethodPost, "/plugin-repositories/imports", body)
|
||||
case "search", "show":
|
||||
return runPluginReservedCLI("repo "+args[0], args[1:])
|
||||
default:
|
||||
return fmt.Errorf("unknown repo command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runPluginRemoteSupplyChainCLI(command string, args []string) error {
|
||||
if command == "sbom" {
|
||||
if len(args) == 0 {
|
||||
return errors.New("usage: gateway plugin sbom verify|generate ...")
|
||||
}
|
||||
switch args[0] {
|
||||
case "verify":
|
||||
return runPluginRemoteSupplyChainAssessCLI(args[1:])
|
||||
case "generate":
|
||||
return runPluginReservedCLI("sbom generate", args[1:])
|
||||
default:
|
||||
return fmt.Errorf("unknown sbom command %q", args[0])
|
||||
}
|
||||
}
|
||||
return runPluginRemoteSupplyChainAssessCLI(args)
|
||||
}
|
||||
|
||||
func runPluginRemoteSupplyChainAssessCLI(args []string) error {
|
||||
opts, err := parsePluginRemoteOptions(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" || opts.ArtifactID == "" {
|
||||
return errors.New("supply-chain verification requires a plugin id and --artifact")
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
metadata, err := remoteMetadataJSON(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]any{
|
||||
"plugin_id": opts.Target,
|
||||
"artifact_id": opts.ArtifactID,
|
||||
"metadata": metadata,
|
||||
}
|
||||
return client.doToStdout(http.MethodPost, "/plugin-supply-chain", body)
|
||||
}
|
||||
|
||||
func runPluginRuntimeCLI(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("usage: gateway plugin runtime features|status|mode|apply ...")
|
||||
}
|
||||
switch args[0] {
|
||||
case "features":
|
||||
return runPluginFeaturesCLI(nil)
|
||||
case "status":
|
||||
opts, err := parsePluginRemoteOptions(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.doToStdout(http.MethodGet, "/plugin-service", nil)
|
||||
case "mode":
|
||||
opts, err := parsePluginRemoteOptions(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Mode == "" {
|
||||
return errors.New("runtime mode requires --mode")
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.doToStdout(http.MethodPut, "/plugin-service", map[string]any{"desired_mode": opts.Mode})
|
||||
case "apply":
|
||||
opts, err := parsePluginRemoteOptions(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.doToStdout(http.MethodPost, "/plugin-service", nil)
|
||||
default:
|
||||
return fmt.Errorf("unknown runtime command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runPluginReservedCLI(command string, args []string) error {
|
||||
_ = args
|
||||
return encodePluginCLIJSON(map[string]any{
|
||||
"command": command,
|
||||
"status": "reserved",
|
||||
"message": "command is reserved by the plugin toolchain design but is not implemented in this gateway yet",
|
||||
})
|
||||
}
|
||||
|
||||
func parsePluginRemoteOptions(args []string) (pluginRemoteOptions, error) {
|
||||
return parsePluginRemoteOptionsWithPositionals(args, 1)
|
||||
}
|
||||
|
||||
func parsePluginRemoteOptionsWithPositionals(args []string, maxPositionals int) (pluginRemoteOptions, error) {
|
||||
opts := pluginRemoteOptions{
|
||||
Gateway: os.Getenv("MC_GATEWAY_ADMIN_URL"),
|
||||
Token: os.Getenv("MC_GATEWAY_ADMIN_TOKEN"),
|
||||
Priority: pluginmanager.DefaultPriority,
|
||||
DryRun: true,
|
||||
}
|
||||
var positionals []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
if !strings.HasPrefix(arg, "--") {
|
||||
if len(positionals) >= maxPositionals {
|
||||
return pluginRemoteOptions{}, fmt.Errorf("unexpected argument %q", arg)
|
||||
}
|
||||
positionals = append(positionals, arg)
|
||||
continue
|
||||
}
|
||||
key, value, consumed, err := parsePluginCLIFlag(args, i)
|
||||
if err != nil {
|
||||
return pluginRemoteOptions{}, err
|
||||
}
|
||||
i += consumed
|
||||
switch key {
|
||||
case "gateway":
|
||||
opts.Gateway = value
|
||||
case "token":
|
||||
opts.Token = value
|
||||
case "artifact":
|
||||
opts.ArtifactID = value
|
||||
case "config":
|
||||
opts.ConfigPath = value
|
||||
case "config-json":
|
||||
opts.ConfigJSON = value
|
||||
case "priority":
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return pluginRemoteOptions{}, fmt.Errorf("invalid --priority %q: %w", value, err)
|
||||
}
|
||||
opts.Priority = parsed
|
||||
case "source":
|
||||
opts.Source = parsePluginBoolFlag(value)
|
||||
case "snapshot":
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return pluginRemoteOptions{}, fmt.Errorf("invalid --snapshot %q: %w", value, err)
|
||||
}
|
||||
opts.SnapshotID = parsed
|
||||
case "full-desired":
|
||||
opts.FullDesired = parsePluginBoolFlag(value)
|
||||
case "profile":
|
||||
opts.Profile = value
|
||||
case "action":
|
||||
opts.Action = value
|
||||
case "decision":
|
||||
opts.Decision = value
|
||||
case "notes":
|
||||
opts.Notes = value
|
||||
case "reason":
|
||||
opts.Reason = value
|
||||
case "ttl":
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return pluginRemoteOptions{}, fmt.Errorf("invalid --ttl %q: %w", value, err)
|
||||
}
|
||||
opts.TTLSeconds = parsed
|
||||
case "confirm-token":
|
||||
opts.ConfirmToken = value
|
||||
case "dry-run":
|
||||
opts.DryRun = parsePluginBoolFlag(value)
|
||||
case "repository-type":
|
||||
opts.RepositoryType = value
|
||||
case "index":
|
||||
opts.IndexPath = value
|
||||
case "version":
|
||||
opts.Version = value
|
||||
case "trust-policy":
|
||||
opts.TrustPolicy = value
|
||||
case "metadata":
|
||||
opts.MetadataPath = value
|
||||
case "metadata-json":
|
||||
opts.MetadataJSON = value
|
||||
case "benchmark-profile":
|
||||
opts.BenchmarkProfile = value
|
||||
case "p95-ms":
|
||||
parsed, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return pluginRemoteOptions{}, fmt.Errorf("invalid --p95-ms %q: %w", value, err)
|
||||
}
|
||||
opts.P95MS = parsed
|
||||
case "p99-ms":
|
||||
parsed, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return pluginRemoteOptions{}, fmt.Errorf("invalid --p99-ms %q: %w", value, err)
|
||||
}
|
||||
opts.P99MS = parsed
|
||||
case "error-rate":
|
||||
parsed, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return pluginRemoteOptions{}, fmt.Errorf("invalid --error-rate %q: %w", value, err)
|
||||
}
|
||||
opts.ErrorRate = parsed
|
||||
case "active-proxy-capacity":
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return pluginRemoteOptions{}, fmt.Errorf("invalid --active-proxy-capacity %q: %w", value, err)
|
||||
}
|
||||
opts.ActiveProxyCapacity = parsed
|
||||
case "baseline-diff":
|
||||
parsed, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return pluginRemoteOptions{}, fmt.Errorf("invalid --baseline-diff %q: %w", value, err)
|
||||
}
|
||||
opts.BaselineDiff = parsed
|
||||
case "mode":
|
||||
opts.Mode = value
|
||||
default:
|
||||
return pluginRemoteOptions{}, fmt.Errorf("unknown remote flag --%s", key)
|
||||
}
|
||||
}
|
||||
if len(positionals) > 0 {
|
||||
opts.Target = positionals[0]
|
||||
}
|
||||
if len(positionals) > 1 {
|
||||
opts.Extra = append(opts.Extra, positionals[1:]...)
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func newPluginRemoteClient(opts pluginRemoteOptions) (pluginRemoteClient, error) {
|
||||
if strings.TrimSpace(opts.Gateway) == "" {
|
||||
return pluginRemoteClient{}, errors.New("--gateway or MC_GATEWAY_ADMIN_URL is required")
|
||||
}
|
||||
if strings.TrimSpace(opts.Token) == "" {
|
||||
return pluginRemoteClient{}, errors.New("--token or MC_GATEWAY_ADMIN_TOKEN is required")
|
||||
}
|
||||
base, err := normalizeAdminAPIBase(opts.Gateway)
|
||||
if err != nil {
|
||||
return pluginRemoteClient{}, err
|
||||
}
|
||||
return pluginRemoteClient{baseURL: base, token: opts.Token, client: http.DefaultClient}, nil
|
||||
}
|
||||
|
||||
func normalizeAdminAPIBase(raw string) (string, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if parsed.Scheme == "" || parsed.Host == "" {
|
||||
return "", fmt.Errorf("invalid gateway URL %q", raw)
|
||||
}
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||
switch {
|
||||
case parsed.Path == "":
|
||||
parsed.Path = "/admin/api"
|
||||
case strings.HasSuffix(parsed.Path, "/admin/api"):
|
||||
case strings.HasSuffix(parsed.Path, "/admin"):
|
||||
parsed.Path = parsed.Path + "/api"
|
||||
default:
|
||||
parsed.Path = path.Join(parsed.Path, "admin/api")
|
||||
}
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func (c pluginRemoteClient) doToStdout(method, endpoint string, body any) error {
|
||||
data, err := c.doBytes(method, endpoint, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writePluginRemoteData(data)
|
||||
}
|
||||
|
||||
func (c pluginRemoteClient) doJSON(method, endpoint string, body any) (map[string]any, error) {
|
||||
data, err := c.doBytes(method, endpoint, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
return nil, fmt.Errorf("admin API response is not a JSON object: %w", err)
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func (c pluginRemoteClient) doBytes(method, endpoint string, body any) ([]byte, error) {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reader = bytes.NewReader(data)
|
||||
}
|
||||
req, err := http.NewRequest(method, c.baseURL+endpoint, reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return readPluginRemoteResponse(resp)
|
||||
}
|
||||
|
||||
func (c pluginRemoteClient) uploadArtifact(endpoint, filePath string) error {
|
||||
var payload bytes.Buffer
|
||||
writer := multipart.NewWriter(&payload)
|
||||
part, err := writer.CreateFormFile("artifact", filepath.Base(filePath))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(part, file); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, c.baseURL+endpoint, &payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := readPluginRemoteResponse(resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writePluginRemoteData(data)
|
||||
}
|
||||
|
||||
func readPluginRemoteResponse(resp *http.Response) ([]byte, error) {
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
message := strings.TrimSpace(string(data))
|
||||
if message == "" {
|
||||
message = resp.Status
|
||||
}
|
||||
return nil, fmt.Errorf("admin API %s: %s", resp.Status, message)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func writePluginRemoteData(data []byte) error {
|
||||
if len(data) == 0 {
|
||||
fmt.Fprintln(os.Stdout, "{}")
|
||||
return nil
|
||||
}
|
||||
var pretty bytes.Buffer
|
||||
if json.Indent(&pretty, data, "", " ") == nil {
|
||||
pretty.WriteByte('\n')
|
||||
_, err := pretty.WriteTo(os.Stdout)
|
||||
return err
|
||||
}
|
||||
_, err := os.Stdout.Write(data)
|
||||
if err == nil && len(data) > 0 && data[len(data)-1] != '\n' {
|
||||
fmt.Fprintln(os.Stdout)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func remoteConfigJSON(opts pluginRemoteOptions) (string, error) {
|
||||
if opts.ConfigJSON != "" {
|
||||
if !json.Valid([]byte(opts.ConfigJSON)) {
|
||||
return "", errors.New("--config-json must be valid JSON")
|
||||
}
|
||||
return opts.ConfigJSON, nil
|
||||
}
|
||||
if opts.ConfigPath != "" {
|
||||
data, err := os.ReadFile(opts.ConfigPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !json.Valid(data) {
|
||||
return "", fmt.Errorf("config file %q must contain valid JSON", opts.ConfigPath)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
return "{}", nil
|
||||
}
|
||||
|
||||
func remoteMetadataJSON(opts pluginRemoteOptions) (map[string]any, error) {
|
||||
if opts.MetadataJSON != "" {
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal([]byte(opts.MetadataJSON), &decoded); err != nil {
|
||||
return nil, fmt.Errorf("--metadata-json must be a JSON object: %w", err)
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
if opts.MetadataPath != "" {
|
||||
data, err := os.ReadFile(opts.MetadataPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
return nil, fmt.Errorf("metadata file %q must contain a JSON object: %w", opts.MetadataPath, err)
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
1909
cmd/gateway/plugin_cli_toolchain.go
Normal file
1909
cmd/gateway/plugin_cli_toolchain.go
Normal file
File diff suppressed because it is too large
Load Diff
552
cmd/gateway/plugin_cli_toolchain_test.go
Normal file
552
cmd/gateway/plugin_cli_toolchain_test.go
Normal file
@@ -0,0 +1,552 @@
|
||||
// cmd/gateway/plugin_cli_toolchain_test.go 包含用于约束 plugin cli toolchain 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPluginInitCreatesBuildableTemplate(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "sample-plugin")
|
||||
handled, code := runPluginCLI([]string{
|
||||
"plugin", "init", dir,
|
||||
"--id", "sample-plugin",
|
||||
"--module", "example.com/sample-plugin",
|
||||
})
|
||||
if !handled {
|
||||
t.Fatal("runPluginCLI() handled = false")
|
||||
}
|
||||
if code != 0 {
|
||||
t.Fatalf("runPluginCLI(init) code = %d, want 0", code)
|
||||
}
|
||||
for _, name := range []string{"manifest.yaml", "go.mod", "main.go", "main_test.go", "README.md", "testdata/config.json"} {
|
||||
if _, err := os.Stat(filepath.Join(dir, filepath.FromSlash(name))); err != nil {
|
||||
t.Fatalf("generated file %s stat error = %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "manifest.json")); err == nil {
|
||||
t.Fatal("plugin init generated manifest.json by default, want manifest.yaml")
|
||||
}
|
||||
if _, err := validatePluginDirectoryForCLI(dir, ""); err != nil {
|
||||
t.Fatalf("validatePluginDirectoryForCLI() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginBuildSourcePackagesTemplate(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "source-plugin")
|
||||
handled, code := runPluginCLI([]string{
|
||||
"plugin", "init", dir,
|
||||
"--id", "source-plugin",
|
||||
"--module", "example.com/source-plugin",
|
||||
})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code)
|
||||
}
|
||||
out := filepath.Join(t.TempDir(), "source-plugin.mcgp")
|
||||
handled, code = runPluginCLI([]string{
|
||||
"plugin", "build", dir,
|
||||
"--type", "source",
|
||||
"--out", out,
|
||||
"--skip-tests",
|
||||
"--vendor=false",
|
||||
})
|
||||
if !handled {
|
||||
t.Fatal("runPluginCLI() handled = false")
|
||||
}
|
||||
if code != 0 {
|
||||
t.Fatalf("runPluginCLI(build source) code = %d, want 0", code)
|
||||
}
|
||||
if _, err := validatePluginPathForCLI(out, "source"); err != nil {
|
||||
t.Fatalf("validatePluginPathForCLI(source) error = %v", err)
|
||||
}
|
||||
assertZipContains(t, out, "manifest.json", "go.mod", "main.go", "main_test.go", "README.md", "testdata/config.json")
|
||||
assertZipNotContains(t, out, "manifest.yaml")
|
||||
}
|
||||
|
||||
func TestPluginBuildBothAcceptsOutDirectory(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "both-plugin")
|
||||
handled, code := runPluginCLI([]string{
|
||||
"plugin", "init", dir,
|
||||
"--id", "both-plugin",
|
||||
"--module", "example.com/both-plugin",
|
||||
})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code)
|
||||
}
|
||||
outDir := filepath.Join(t.TempDir(), "packages")
|
||||
handled, code = runPluginCLI([]string{
|
||||
"plugin", "build", dir,
|
||||
"--type", "both",
|
||||
"--out", outDir,
|
||||
"--skip-tests",
|
||||
"--vendor=false",
|
||||
})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(build both) = (%v, %d), want handled code 0", handled, code)
|
||||
}
|
||||
binaryOut := filepath.Join(outDir, "both-plugin.mcgp")
|
||||
sourceOut := filepath.Join(outDir, "both-plugin-source.mcgp")
|
||||
if _, err := validatePluginPathForCLI(binaryOut, "binary"); err != nil {
|
||||
t.Fatalf("validatePluginPathForCLI(binary) error = %v", err)
|
||||
}
|
||||
if _, err := validatePluginPathForCLI(sourceOut, "source"); err != nil {
|
||||
t.Fatalf("validatePluginPathForCLI(source) error = %v", err)
|
||||
}
|
||||
assertZipNotContains(t, binaryOut, "manifest.yaml")
|
||||
assertZipNotContains(t, sourceOut, "manifest.yaml")
|
||||
}
|
||||
|
||||
func TestPluginTestManifestProfile(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "test-plugin")
|
||||
handled, code := runPluginCLI([]string{
|
||||
"plugin", "init", dir,
|
||||
"--id", "test-plugin",
|
||||
"--module", "example.com/test-plugin",
|
||||
})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code)
|
||||
}
|
||||
handled, code = runPluginCLI([]string{"plugin", "test", dir, "--profile", "manifest"})
|
||||
if !handled {
|
||||
t.Fatal("runPluginCLI() handled = false")
|
||||
}
|
||||
if code != 0 {
|
||||
t.Fatalf("runPluginCLI(test manifest) code = %d, want 0", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginFeaturesAndManifestCommands(t *testing.T) {
|
||||
handled, code := runPluginCLI([]string{"plugin", "features"})
|
||||
if !handled {
|
||||
t.Fatal("runPluginCLI() handled = false")
|
||||
}
|
||||
if code != 0 {
|
||||
t.Fatalf("runPluginCLI(features) code = %d, want 0", code)
|
||||
}
|
||||
handled, code = runPluginCLI([]string{"plugin", "manifest", "explain", "upstream.connect/v1"})
|
||||
if !handled {
|
||||
t.Fatal("runPluginCLI() handled = false")
|
||||
}
|
||||
if code != 0 {
|
||||
t.Fatalf("runPluginCLI(manifest explain) code = %d, want 0", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginManifestFormatWrite(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "format-plugin")
|
||||
handled, code := runPluginCLI([]string{
|
||||
"plugin", "init", dir,
|
||||
"--id", "format-plugin",
|
||||
"--module", "example.com/format-plugin",
|
||||
"--manifest-format", "json",
|
||||
})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code)
|
||||
}
|
||||
manifestPath := filepath.Join(dir, "manifest.json")
|
||||
if err := os.WriteFile(manifestPath, []byte(`{"schema_version":"mc-gateway.plugin/v1","id":"format-plugin","name":"Format Plugin","version":"0.1.0","artifact_type":"source","runtime":{"type":"go-plugin","entry":"plugin.so","entry_symbol":"Plugin"},"build":{"type":"go","entry":".","output":"plugin.so"},"api_version":"plugin-api/v1","sdk_module":"github.com/tursom/mc-gateway/plugin/api","sdk_module_version":"v0.1.0","extension_points":[{"type":"hook","key":"upstream.connect/v1"}],"capabilities":{"upstream_connect":{"mode":"dialer"}},"runtime_limits":{"handler_timeout_ms":3000},"config_schema":{"type":"object"}}`), 0644); err != nil {
|
||||
t.Fatalf("WriteFile(manifest) error = %v", err)
|
||||
}
|
||||
handled, code = runPluginCLI([]string{"plugin", "manifest", "format", dir, "--write"})
|
||||
if !handled {
|
||||
t.Fatal("runPluginCLI() handled = false")
|
||||
}
|
||||
if code != 0 {
|
||||
t.Fatalf("runPluginCLI(manifest format) code = %d, want 0", code)
|
||||
}
|
||||
data, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(manifest) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "\n \"schema_version\"") {
|
||||
t.Fatalf("manifest was not formatted:\n%s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginManifestSourceFormats(t *testing.T) {
|
||||
for _, format := range []string{"yaml", "toml", "jsonc", "json"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "format-"+format)
|
||||
handled, code := runPluginCLI([]string{
|
||||
"plugin", "init", dir,
|
||||
"--id", "format-" + format,
|
||||
"--module", "example.com/format-" + format,
|
||||
"--manifest-format", format,
|
||||
})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(init %s) = (%v, %d), want handled code 0", format, handled, code)
|
||||
}
|
||||
source, err := readPluginManifestSource(dir, "")
|
||||
if err != nil {
|
||||
t.Fatalf("readPluginManifestSource(%s) error = %v", format, err)
|
||||
}
|
||||
if source.Manifest.ID != "format-"+format {
|
||||
t.Fatalf("manifest id = %q, want format-%s", source.Manifest.ID, format)
|
||||
}
|
||||
if !json.Valid(source.CanonicalJSON) {
|
||||
t.Fatalf("canonical JSON for %s is invalid:\n%s", format, source.CanonicalJSON)
|
||||
}
|
||||
packaged, err := materializedManifestJSON(source.Raw, source.Manifest, "binary", false)
|
||||
if err != nil {
|
||||
t.Fatalf("materializedManifestJSON(%s) error = %v", format, err)
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(packaged, &raw); err != nil {
|
||||
t.Fatalf("Unmarshal(materialized %s) error = %v", format, err)
|
||||
}
|
||||
if raw["artifact_type"] != "binary" || raw["go_version"] == "" || raw["go_os"] == "" || raw["go_arch"] == "" {
|
||||
t.Fatalf("materialized %s manifest missing package fields: %s", format, packaged)
|
||||
}
|
||||
handled, code = runPluginCLI([]string{"plugin", "validate", dir})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(validate %s) = (%v, %d), want handled code 0", format, handled, code)
|
||||
}
|
||||
handled, code = runPluginCLI([]string{"plugin", "test", dir, "--profile", "manifest"})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(test %s) = (%v, %d), want handled code 0", format, handled, code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginManifestFormatWritePreservesComments(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
file string
|
||||
content string
|
||||
comment string
|
||||
}{
|
||||
{
|
||||
name: "yaml",
|
||||
file: "manifest.yaml",
|
||||
content: "# keep yaml comment\n" + manifestYAMLTemplate(pluginInitCLIOptions{ID: "comment-yaml", Name: "Comment YAML", Extension: "upstream.connect/v1"}),
|
||||
comment: "# keep yaml comment",
|
||||
},
|
||||
{
|
||||
name: "toml",
|
||||
file: "manifest.toml",
|
||||
content: "# keep toml comment\n" + manifestTOMLTemplate(pluginInitCLIOptions{ID: "comment-toml", Name: "Comment TOML", Extension: "upstream.connect/v1"}),
|
||||
comment: "# keep toml comment",
|
||||
},
|
||||
{
|
||||
name: "jsonc",
|
||||
file: "manifest.jsonc",
|
||||
content: strings.Replace(
|
||||
"// keep jsonc comment\n"+strings.TrimSuffix(manifestSourceJSONTemplate(pluginInitCLIOptions{ID: "comment-jsonc", Name: "Comment JSONC", Extension: "upstream.connect/v1"}, true), "\n"),
|
||||
"\n }\n}",
|
||||
"\n },\n}",
|
||||
1,
|
||||
),
|
||||
comment: "// keep jsonc comment",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
manifestPath := filepath.Join(dir, tc.file)
|
||||
if err := os.WriteFile(manifestPath, []byte(tc.content), 0644); err != nil {
|
||||
t.Fatalf("WriteFile(%s) error = %v", tc.file, err)
|
||||
}
|
||||
before, err := readPluginManifestSource(dir, "")
|
||||
if err != nil {
|
||||
t.Fatalf("readPluginManifestSource(before) error = %v", err)
|
||||
}
|
||||
handled, code := runPluginCLI([]string{"plugin", "manifest", "format", dir, "--write"})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(manifest format %s) = (%v, %d), want handled code 0", tc.name, handled, code)
|
||||
}
|
||||
data, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", tc.file, err)
|
||||
}
|
||||
if !strings.Contains(string(data), tc.comment) {
|
||||
t.Fatalf("formatted %s lost comment:\n%s", tc.file, data)
|
||||
}
|
||||
after, err := readPluginManifestSource(dir, "")
|
||||
if err != nil {
|
||||
t.Fatalf("readPluginManifestSource(after) error = %v", err)
|
||||
}
|
||||
if !bytes.Equal(before.CanonicalJSON, after.CanonicalJSON) {
|
||||
t.Fatalf("canonical JSON changed after format\nbefore=%s\nafter=%s", before.CanonicalJSON, after.CanonicalJSON)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginManifestMultipleSourcesRequireExplicitManifest(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "multi-manifest")
|
||||
handled, code := runPluginCLI([]string{
|
||||
"plugin", "init", dir,
|
||||
"--id", "multi-manifest",
|
||||
"--module", "example.com/multi-manifest",
|
||||
})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifestSourceJSONTemplate(pluginInitCLIOptions{ID: "multi-manifest", Name: "Multi Manifest", Extension: "upstream.connect/v1"}, false)), 0644); err != nil {
|
||||
t.Fatalf("WriteFile(manifest.json) error = %v", err)
|
||||
}
|
||||
handled, code = runPluginCLI([]string{"plugin", "validate", dir})
|
||||
if !handled {
|
||||
t.Fatal("runPluginCLI(validate) handled = false")
|
||||
}
|
||||
if code == 0 {
|
||||
t.Fatal("runPluginCLI(validate) code = 0, want failure for multiple manifests")
|
||||
}
|
||||
handled, code = runPluginCLI([]string{"plugin", "validate", dir, "--manifest", "manifest.yaml"})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(validate --manifest) = (%v, %d), want handled code 0", handled, code)
|
||||
}
|
||||
handled, code = runPluginCLI([]string{"plugin", "test", dir, "--profile", "manifest", "--manifest", "manifest.yaml"})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(test --manifest) = (%v, %d), want handled code 0", handled, code)
|
||||
}
|
||||
out := filepath.Join(t.TempDir(), "multi-manifest-source.mcgp")
|
||||
handled, code = runPluginCLI([]string{"plugin", "build", dir, "--type", "source", "--out", out, "--skip-tests", "--vendor=false", "--manifest", "manifest.yaml"})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(build --manifest) = (%v, %d), want handled code 0", handled, code)
|
||||
}
|
||||
if _, err := validatePluginPathForCLI(out, "source"); err != nil {
|
||||
t.Fatalf("validatePluginPathForCLI(source) error = %v", err)
|
||||
}
|
||||
handled, code = runPluginCLI([]string{"plugin", "manifest", "format", dir, "--manifest", "manifest.yaml", "--canonical-json", "--type", "source"})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(manifest format --manifest --canonical-json) = (%v, %d), want handled code 0", handled, code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginGovernanceCommands(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "governance-plugin")
|
||||
handled, code := runPluginCLI([]string{
|
||||
"plugin", "init", dir,
|
||||
"--id", "governance-plugin",
|
||||
"--module", "example.com/governance-plugin",
|
||||
})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code)
|
||||
}
|
||||
artifact := filepath.Join(t.TempDir(), "governance-plugin.mcgp")
|
||||
handled, code = runPluginCLI([]string{
|
||||
"plugin", "build", dir,
|
||||
"--type", "binary",
|
||||
"--out", artifact,
|
||||
"--skip-tests",
|
||||
})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(build binary) = (%v, %d), want handled code 0", handled, code)
|
||||
}
|
||||
for _, tc := range [][]string{
|
||||
{"plugin", "preflight", artifact, "--config-json", `{"upstream":"127.0.0.1:25566"}`, "--profile", "dev"},
|
||||
{"plugin", "self-test", artifact, "--profile", "dev"},
|
||||
{"plugin", "benchmark", artifact, "--profile", "dev", "--benchmark-profile", "local-fast", "--p95-ms", "1", "--p99-ms", "2", "--error-rate", "0", "--baseline-diff", "0.1"},
|
||||
{"plugin", "preflight", dir, "--manifest", "manifest.yaml", "--config-json", `{"upstream":"127.0.0.1:25566"}`, "--profile", "dev"},
|
||||
} {
|
||||
handled, code = runPluginCLI(tc)
|
||||
if !handled {
|
||||
t.Fatalf("runPluginCLI(%v) handled = false", tc)
|
||||
}
|
||||
if code != 0 {
|
||||
t.Fatalf("runPluginCLI(%v) code = %d, want 0", tc, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginRemoteCLIRequests(t *testing.T) {
|
||||
type observedRequest struct {
|
||||
Method string
|
||||
RequestURI string
|
||||
ContentType string
|
||||
Body map[string]any
|
||||
FileName string
|
||||
}
|
||||
requests := make(chan observedRequest, 16)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
|
||||
t.Errorf("Authorization = %q, want bearer token", got)
|
||||
}
|
||||
observed := observedRequest{
|
||||
Method: r.Method,
|
||||
RequestURI: r.URL.RequestURI(),
|
||||
ContentType: r.Header.Get("Content-Type"),
|
||||
Body: map[string]any{},
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(observed.ContentType, "application/json"):
|
||||
if err := json.NewDecoder(r.Body).Decode(&observed.Body); err != nil {
|
||||
t.Errorf("Decode JSON body error = %v", err)
|
||||
}
|
||||
case strings.HasPrefix(observed.ContentType, "multipart/form-data"):
|
||||
if err := r.ParseMultipartForm(64 << 20); err != nil {
|
||||
t.Errorf("ParseMultipartForm error = %v", err)
|
||||
} else {
|
||||
file, header, err := r.FormFile("artifact")
|
||||
if err != nil {
|
||||
t.Errorf("FormFile(artifact) error = %v", err)
|
||||
} else {
|
||||
observed.FileName = header.Filename
|
||||
_, _ = io.Copy(io.Discard, file)
|
||||
_ = file.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
requests <- observed
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if strings.HasSuffix(r.URL.Path, "/operations") {
|
||||
_, _ = io.WriteString(w, `{"operations":{"logs":[{"message":"ok"}],"traces":[],"events":[{"name":"evt"}],"event_queue":{"queued":1},"handlers":[{"plugin_id":"demo"}],"custom_metrics":[],"background_tasks":[{"id":"sync"}],"plugin_data":[{"key":"k"}],"plugin_files":[{"name":"f"}],"gc":[]}}`)
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"ok":true}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv("MC_GATEWAY_ADMIN_URL", server.URL)
|
||||
t.Setenv("MC_GATEWAY_ADMIN_TOKEN", "test-token")
|
||||
|
||||
runRemotePluginCLI(t, "plugin", "status", "demo")
|
||||
assertRemoteRequest(t, <-requests, http.MethodGet, "/admin/api/plugins/demo")
|
||||
|
||||
artifactPath := filepath.Join(t.TempDir(), "demo.mcgp")
|
||||
if err := os.WriteFile(artifactPath, []byte("artifact"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile(artifact) error = %v", err)
|
||||
}
|
||||
runRemotePluginCLI(t, "plugin", "upload", artifactPath)
|
||||
uploadReq := <-requests
|
||||
assertRemoteRequest(t, uploadReq, http.MethodPost, "/admin/api/plugin-artifacts")
|
||||
if uploadReq.FileName != "demo.mcgp" {
|
||||
t.Fatalf("upload file name = %q, want demo.mcgp", uploadReq.FileName)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"upstream":"127.0.0.1:25565"}`), 0644); err != nil {
|
||||
t.Fatalf("WriteFile(config) error = %v", err)
|
||||
}
|
||||
runRemotePluginCLI(t, "plugin", "enable", "demo", "--artifact", "art-1", "--config", configPath, "--priority", "7")
|
||||
enableReq := <-requests
|
||||
assertRemoteRequest(t, enableReq, http.MethodPut, "/admin/api/plugins/demo")
|
||||
if enableReq.Body["artifact_id"] != "art-1" || enableReq.Body["desired_state"] != "enabled" || enableReq.Body["priority"].(float64) != 7 {
|
||||
t.Fatalf("enable body = %#v", enableReq.Body)
|
||||
}
|
||||
|
||||
runRemotePluginCLI(t, "plugin", "logs", "demo")
|
||||
assertRemoteRequest(t, <-requests, http.MethodGet, "/admin/api/plugins/demo/operations")
|
||||
|
||||
runRemotePluginCLI(t, "plugin", "task", "run", "demo", "sync", "--confirm-token", "confirm")
|
||||
taskReq := <-requests
|
||||
assertRemoteRequest(t, taskReq, http.MethodPost, "/admin/api/plugins/demo/operations/tasks/sync/trigger")
|
||||
if taskReq.Body["confirm_token"] != "confirm" {
|
||||
t.Fatalf("task body = %#v", taskReq.Body)
|
||||
}
|
||||
|
||||
runRemotePluginCLI(t, "plugin", "repo", "import", "demo", "--repository-type", "file", "--index", "repo.json", "--artifact", "candidate-1", "--version", "1.2.3")
|
||||
repoReq := <-requests
|
||||
assertRemoteRequest(t, repoReq, http.MethodPost, "/admin/api/plugin-repositories/imports")
|
||||
if repoReq.Body["plugin_id"] != "demo" || repoReq.Body["repository_type"] != "file" || repoReq.Body["artifact_id"] != "candidate-1" {
|
||||
t.Fatalf("repo body = %#v", repoReq.Body)
|
||||
}
|
||||
|
||||
runRemotePluginCLI(t, "plugin", "review", "status", "demo", "--artifact", "art-1", "--profile", "prod")
|
||||
assertRemoteRequest(t, <-requests, http.MethodGet, "/admin/api/plugins/demo/governance?artifact_id=art-1&profile=prod")
|
||||
|
||||
runRemotePluginCLI(t, "plugin", "sbom", "verify", "demo", "--artifact", "art-1", "--metadata-json", `{"sbom":{"format":"spdx"}}`)
|
||||
supplyReq := <-requests
|
||||
assertRemoteRequest(t, supplyReq, http.MethodPost, "/admin/api/plugin-supply-chain")
|
||||
if supplyReq.Body["plugin_id"] != "demo" || supplyReq.Body["artifact_id"] != "art-1" {
|
||||
t.Fatalf("supply-chain body = %#v", supplyReq.Body)
|
||||
}
|
||||
|
||||
runRemotePluginCLI(t, "plugin", "runtime", "mode", "--mode", "go-plugin-process")
|
||||
runtimeReq := <-requests
|
||||
assertRemoteRequest(t, runtimeReq, http.MethodPut, "/admin/api/plugin-service")
|
||||
if runtimeReq.Body["desired_mode"] != "go-plugin-process" {
|
||||
t.Fatalf("runtime body = %#v", runtimeReq.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAdminAPIBase(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{raw: "http://127.0.0.1:8080", want: "http://127.0.0.1:8080/admin/api"},
|
||||
{raw: "http://127.0.0.1:8080/admin", want: "http://127.0.0.1:8080/admin/api"},
|
||||
{raw: "http://127.0.0.1:8080/admin/api/", want: "http://127.0.0.1:8080/admin/api"},
|
||||
} {
|
||||
got, err := normalizeAdminAPIBase(tc.raw)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeAdminAPIBase(%q) error = %v", tc.raw, err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("normalizeAdminAPIBase(%q) = %q, want %q", tc.raw, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runRemotePluginCLI(t *testing.T, args ...string) {
|
||||
t.Helper()
|
||||
handled, code := runPluginCLI(args)
|
||||
if !handled {
|
||||
t.Fatalf("runPluginCLI(%v) handled = false", args)
|
||||
}
|
||||
if code != 0 {
|
||||
t.Fatalf("runPluginCLI(%v) code = %d, want 0", args, code)
|
||||
}
|
||||
}
|
||||
|
||||
func assertRemoteRequest(t *testing.T, got struct {
|
||||
Method string
|
||||
RequestURI string
|
||||
ContentType string
|
||||
Body map[string]any
|
||||
FileName string
|
||||
}, wantMethod, wantURI string) {
|
||||
t.Helper()
|
||||
if got.Method != wantMethod || got.RequestURI != wantURI {
|
||||
t.Fatalf("request = %s %s, want %s %s", got.Method, got.RequestURI, wantMethod, wantURI)
|
||||
}
|
||||
}
|
||||
|
||||
func assertZipContains(t *testing.T, zipPath string, names ...string) {
|
||||
t.Helper()
|
||||
reader, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenReader(%s) error = %v", zipPath, err)
|
||||
}
|
||||
defer reader.Close()
|
||||
seen := make(map[string]bool, len(reader.File))
|
||||
for _, file := range reader.File {
|
||||
seen[file.Name] = true
|
||||
if strings.Contains(file.Name, `\`) {
|
||||
t.Fatalf("zip entry %q uses backslash", file.Name)
|
||||
}
|
||||
}
|
||||
for _, name := range names {
|
||||
if !seen[name] {
|
||||
t.Fatalf("zip %s missing entry %s; entries=%v", zipPath, name, seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertZipNotContains(t *testing.T, zipPath string, names ...string) {
|
||||
t.Helper()
|
||||
reader, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenReader(%s) error = %v", zipPath, err)
|
||||
}
|
||||
defer reader.Close()
|
||||
seen := make(map[string]bool, len(reader.File))
|
||||
for _, file := range reader.File {
|
||||
seen[file.Name] = true
|
||||
}
|
||||
for _, name := range names {
|
||||
if seen[name] {
|
||||
t.Fatalf("zip %s unexpectedly contains entry %s", zipPath, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/plugin_test.go 包含用于约束 plugin 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/quic.go 启动可选的 QUIC 监听器,并把 QUIC 流适配到普通网关连接流程。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -19,6 +21,8 @@ import (
|
||||
)
|
||||
|
||||
type (
|
||||
// quicConn 把 QUIC connection 和单条 stream 组合成 net.Conn 风格对象,
|
||||
// 使后续转发逻辑不用区分 TCP 与 QUIC。
|
||||
quicConn struct {
|
||||
quic.Connection
|
||||
quic.Stream
|
||||
@@ -30,6 +34,7 @@ func runQuic(wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
}
|
||||
|
||||
// QUIC 基于 UDP 监听,端口来自运行态服务配置。
|
||||
udpConn, err := net.ListenUDP("udp4", &net.UDPAddr{Port: config.Quic.Port})
|
||||
if err != nil {
|
||||
log.Panic().Err(err).Msg("Failed to listen UDP")
|
||||
@@ -41,6 +46,7 @@ func runQuic(wg *sync.WaitGroup) {
|
||||
log.Panic().Err(err).Msg("Failed to generate TLS config")
|
||||
}
|
||||
|
||||
// quic-go 的 listener 接收 connection,真正的字节流在 stream 中。
|
||||
ln, err := quic.Listen(udpConn, tlsConf, nil)
|
||||
if err != nil {
|
||||
log.Panic().Err(err).Msg("Failed to listen QUIC")
|
||||
@@ -63,11 +69,13 @@ func runQuic(wg *sync.WaitGroup) {
|
||||
|
||||
func upstreamQuic(host string) net.Conn {
|
||||
tlsConf := &tls.Config{
|
||||
// 网关自管的 QUIC 上游默认使用临时证书,当前先跳过证书校验。
|
||||
InsecureSkipVerify: true, // 跳过证书检查
|
||||
NextProtos: getQuicNextProtos(),
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) // 3s handshake timeout
|
||||
// 上游握手使用短超时,避免连接协程在不可达上游上长期等待。
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
conn, err := quic.DialAddr(ctx, host, tlsConf, nil)
|
||||
@@ -86,6 +94,7 @@ func upstreamQuic(host string) net.Conn {
|
||||
}
|
||||
log.Debug().Str("host", host).Msg("QUIC stream opened")
|
||||
|
||||
// 返回的 quicConn 后续会收到 Minecraft 首包回放并进入普通双向转发。
|
||||
return quicConn{
|
||||
Connection: conn,
|
||||
Stream: stream,
|
||||
@@ -95,6 +104,7 @@ func upstreamQuic(host string) net.Conn {
|
||||
func handleQuicRequest(conn quic.Connection) {
|
||||
defer conn.CloseWithError(0, "Closing connection")
|
||||
|
||||
// 入口连接只等待第一条 stream;该 stream 承载完整 Minecraft 字节流。
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||
defer cancel()
|
||||
|
||||
@@ -111,33 +121,33 @@ func handleQuicRequest(conn quic.Connection) {
|
||||
}
|
||||
|
||||
func generateTLSConfig() (*tls.Config, error) {
|
||||
// 生成私钥
|
||||
// 生成临时私钥;当前 QUIC 入口不依赖磁盘证书文件。
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 创建证书模板
|
||||
// 创建自签证书模板,满足 QUIC TLS 握手要求。
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"Example Org"},
|
||||
},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(365 * 24 * time.Hour), // 有效期 1 年
|
||||
NotAfter: time.Now().Add(365 * 24 * time.Hour), // 有效期 1 年。
|
||||
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
// 自签名证书
|
||||
// 自签名证书用于当前进程生命周期内的 QUIC 监听。
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 编码证书和私钥
|
||||
// 编码证书和私钥,再交给 tls.X509KeyPair 解析为标准证书结构。
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
keyPEM, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
@@ -145,13 +155,13 @@ func generateTLSConfig() (*tls.Config, error) {
|
||||
}
|
||||
keyPEMBlock := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyPEM})
|
||||
|
||||
// 加载到 tls.Certificate
|
||||
// 加载到 tls.Certificate。
|
||||
cert, err := tls.X509KeyPair(certPEM, keyPEMBlock)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 返回 tls.Config
|
||||
// 返回 QUIC listener 使用的 TLS 配置。
|
||||
return &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
NextProtos: getQuicNextProtos(),
|
||||
@@ -161,7 +171,7 @@ func generateTLSConfig() (*tls.Config, error) {
|
||||
func getQuicNextProtos() []string {
|
||||
nextProtos := config.Quic.ApplicationProtocols
|
||||
if len(nextProtos) == 0 {
|
||||
return []string{"minecraft", "quic", "raw", "h3"} // 默认协议
|
||||
return []string{"minecraft", "quic", "raw", "h3"} // 默认协议列表。
|
||||
}
|
||||
return nextProtos
|
||||
}
|
||||
@@ -172,10 +182,12 @@ func (c quicConn) Close() error {
|
||||
}
|
||||
|
||||
func (c quicConn) CloseWrite() error {
|
||||
// QUIC stream 关闭写方向即可通知对端没有更多数据。
|
||||
return c.Stream.Close()
|
||||
}
|
||||
|
||||
func (c quicConn) CloseRead() error {
|
||||
// CancelRead 用于停止接收方向,匹配 relay.go 中的半关闭调用。
|
||||
c.Stream.CancelRead(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/quic_test.go 包含用于约束 quic 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/relay.go 实现客户端与上游之间的双向复制循环和转发缓冲池。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -10,6 +12,7 @@ import (
|
||||
|
||||
const proxyBufferSize = 64 * 1024
|
||||
|
||||
// proxyBufferPool 为普通 io.CopyBuffer 路径复用 64KiB 缓冲区,降低长连接转发时的分配压力。
|
||||
var proxyBufferPool = sync.Pool{
|
||||
New: func() any {
|
||||
buf := make([]byte, proxyBufferSize)
|
||||
@@ -30,6 +33,7 @@ type (
|
||||
func proxyConnections(a, b io.ReadWriter) {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// 两个方向独立复制,任意一侧读到 EOF 后通过半关闭通知对端。
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
@@ -44,6 +48,7 @@ func proxyConnections(a, b io.ReadWriter) {
|
||||
}
|
||||
|
||||
func proxyCopy(dst io.Writer, src io.Reader) {
|
||||
// 转发协程不能把 panic 带出到连接处理主协程;记录后关闭对应方向即可。
|
||||
defer recoverProxyCopy()
|
||||
defer closeRead(src)
|
||||
defer closeWrite(dst)
|
||||
@@ -55,6 +60,8 @@ func proxyCopy(dst io.Writer, src io.Reader) {
|
||||
}
|
||||
|
||||
func copyForward(dst io.Writer, src io.Reader) (int64, error) {
|
||||
// 优先使用标准库为具体类型提供的零拷贝/优化路径,只有普通 reader/writer
|
||||
// 才落到共享缓冲区。
|
||||
if _, ok := src.(io.WriterTo); ok {
|
||||
return io.Copy(dst, src)
|
||||
}
|
||||
@@ -81,6 +88,7 @@ func putProxyBuffer(buf []byte) {
|
||||
}
|
||||
|
||||
func writeAll(w io.Writer, buf []byte) error {
|
||||
// net.Conn.Write 允许短写;首包回放和 PROXY 头写入必须循环直到写完。
|
||||
for len(buf) > 0 {
|
||||
n, err := w.Write(buf)
|
||||
if n > 0 {
|
||||
@@ -98,6 +106,7 @@ func writeAll(w io.Writer, buf []byte) error {
|
||||
}
|
||||
|
||||
func closeWrite(conn any) {
|
||||
// TCP 支持半关闭时只关闭写方向,让反向复制还有机会读完剩余数据。
|
||||
if closer, ok := conn.(closeWriter); ok {
|
||||
if err := closer.CloseWrite(); err != nil {
|
||||
log.Debug().Err(err).Msg("failed to close write side")
|
||||
@@ -113,6 +122,7 @@ func closeWrite(conn any) {
|
||||
}
|
||||
|
||||
func closeRead(conn any) {
|
||||
// 支持 CloseRead 的连接可以显式停止读方向,帮助对端更快感知转发结束。
|
||||
if closer, ok := conn.(closeReader); ok {
|
||||
if err := closer.CloseRead(); err != nil {
|
||||
log.Debug().Err(err).Msg("failed to close read side")
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/relay_benchmark_test.go 包含用于约束 relay benchmark 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/relay_test.go 包含用于约束 relay 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/tcp.go 在未与 Admin HTTP 共用端口时启动普通 TCP Minecraft 监听器。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -34,13 +36,14 @@ func runTcp(wg *sync.WaitGroup) {
|
||||
continue
|
||||
}
|
||||
setSocketOptions(conn)
|
||||
// 处理连接
|
||||
// 处理连接;后续握手解析、插件过滤和路由解析都在 handleRequest 中完成。
|
||||
gatewayMetrics.TCPConnectionStarted()
|
||||
go handleRequest(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func upstreamTcp(host string) net.Conn {
|
||||
// TCP 是默认上游传输,路由值没有协议前缀时都会走这里。
|
||||
conn, err := tcpDialer.Dial("tcp", host)
|
||||
if err != nil {
|
||||
gatewayMetrics.UpstreamDialError()
|
||||
@@ -53,13 +56,14 @@ func upstreamTcp(host string) net.Conn {
|
||||
}
|
||||
|
||||
var tcpDialer = net.Dialer{
|
||||
// 上游拨号失败应尽快返回给客户端连接处理流程,避免连接协程长期堆积。
|
||||
Timeout: 3 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}
|
||||
|
||||
func setSocketOptions(conn net.Conn) {
|
||||
if tcpConn, ok := conn.(*net.TCPConn); ok {
|
||||
tcpConn.SetNoDelay(true) // 禁用 Nagle 算法
|
||||
tcpConn.SetNoDelay(true) // 禁用 Nagle 算法,降低 Minecraft 交互延迟。
|
||||
tcpConn.SetKeepAlive(true)
|
||||
tcpConn.SetKeepAlivePeriod(30 * time.Second)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/tcp_test.go 包含用于约束 tcp 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/tcp_web_port_reuse.go 启动共享 TCP/Admin 监听器,按连接首包自动区分 HTTP 流量和 Minecraft 流量。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -12,11 +14,13 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTCPPort = 25565
|
||||
defaultTCPPort = 25565
|
||||
// 首包超时沿用 tcphttpmux 默认值,保持同端口分流逻辑的单一来源。
|
||||
tcpWebInitialPacketTimeout = tcphttpmux.DefaultInitialPacketTimeout
|
||||
)
|
||||
|
||||
func normalizedTCPPort() int {
|
||||
// 静态配置未指定端口时保持 Minecraft 默认端口。
|
||||
if config.Tcp.Port == 0 {
|
||||
return defaultTCPPort
|
||||
}
|
||||
@@ -31,6 +35,7 @@ func normalizedWebSocketPort() int {
|
||||
}
|
||||
|
||||
func normalizedWebSocketPath() string {
|
||||
// WebSocket 路径为空时回退到根路径,避免生成空的 HTTP 路由。
|
||||
if config.WebSocket.Path == "" {
|
||||
return "/"
|
||||
}
|
||||
@@ -38,6 +43,7 @@ func normalizedWebSocketPath() string {
|
||||
}
|
||||
|
||||
func tcpWebPortReuseEnabled() bool {
|
||||
// 是否共用端口完全由启用状态和端口相等推导,不引入额外配置开关。
|
||||
return config.Tcp.Enable &&
|
||||
config.WebSocket.Enable &&
|
||||
normalizedTCPPort() == normalizedWebSocketPort()
|
||||
@@ -49,6 +55,7 @@ func runTcpWebPortReuse(wg *sync.WaitGroup) {
|
||||
}
|
||||
|
||||
port := normalizedTCPPort()
|
||||
// 同一个 listener 同时承载 Minecraft TCP 和 Admin HTTP,由 serveTcpWebPortReuse 分流。
|
||||
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).
|
||||
@@ -69,6 +76,7 @@ func runTcpWebPortReuse(wg *sync.WaitGroup) {
|
||||
}
|
||||
|
||||
func serveTcpWebPortReuse(listener net.Listener, handler http.Handler, tcpHandler func(net.Conn)) error {
|
||||
// tcphttpmux 只负责协议分流;指标、socket 选项和日志通过回调接回主包。
|
||||
return tcphttpmux.Serve(listener, handler, tcpHandler, tcphttpmux.Options{
|
||||
InitialPacketTimeout: tcpWebInitialPacketTimeout,
|
||||
SetSocketOptions: setSocketOptions,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/tcp_web_port_reuse_test.go 包含用于约束 tcp web port reuse 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/test_helpers_test.go 包含用于约束 test helpers 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/websocket.go 把 WebSocket 会话适配为 net.Conn,让浏览器客户端复用网关请求路径。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -14,7 +16,7 @@ import (
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
// 允许所有来源的连接(生产环境中应该更严格)
|
||||
// 当前网关把 WebSocket 当作传输层入口,先允许所有来源;生产暴露时应在反向代理层收紧来源。
|
||||
return true
|
||||
},
|
||||
}
|
||||
@@ -38,12 +40,14 @@ func handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
defer conn.Close()
|
||||
|
||||
gatewayMetrics.WebSocketConnectionStarted()
|
||||
// WebSocket 连接包装为 net.Conn 后进入同一个 handleRequest,复用插件、路由和转发逻辑。
|
||||
handleRequest(&webSocketConn{Conn: conn})
|
||||
}
|
||||
|
||||
func (w *webSocketConn) Read(b []byte) (n int, err error) {
|
||||
for {
|
||||
if w.reader != nil {
|
||||
// 当前消息帧没读完前持续从同一个 reader 读取,模拟流式 net.Conn。
|
||||
n, err = w.reader.Read(b)
|
||||
if errors.Is(err, io.EOF) {
|
||||
w.reader = nil
|
||||
@@ -60,6 +64,7 @@ func (w *webSocketConn) Read(b []byte) (n int, err error) {
|
||||
return 0, err
|
||||
}
|
||||
if messageType != websocket.BinaryMessage && messageType != websocket.TextMessage {
|
||||
// 控制帧不进入 Minecraft 协议流。
|
||||
continue
|
||||
}
|
||||
w.reader = reader
|
||||
@@ -67,6 +72,7 @@ func (w *webSocketConn) Read(b []byte) (n int, err error) {
|
||||
}
|
||||
|
||||
func (w *webSocketConn) Write(b []byte) (n int, err error) {
|
||||
// 每次 Write 输出一个二进制 WebSocket 消息,保持与 Minecraft packet 边界无关的字节流语义。
|
||||
writer, err := w.NextWriter(websocket.BinaryMessage)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -96,6 +102,7 @@ func (w *webSocketConn) SetDeadline(t time.Time) error {
|
||||
|
||||
func newWebSocketHandler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
// 路径来自运行态服务配置,允许管理端把 WebSocket 入口挂到子路径。
|
||||
mux.HandleFunc(normalizedWebSocketPath(), handleWebSocket)
|
||||
return mux
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/gateway/websocket_test.go 包含用于约束 websocket 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/kcp/main.go 提供独立的 KCP 到 TCP 代理工具,用于测试或演示 KCP 传输行为。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/kcp/main_test.go 包含用于约束 kcp 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/quic/main.go 提供独立的 QUIC 到 TCP 代理工具,用于测试或演示 QUIC 传输行为。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -84,7 +86,7 @@ func handlerConn(conn net.Conn) {
|
||||
log.Info().
|
||||
Msg("QUIC stream opened")
|
||||
|
||||
// read and write stream data
|
||||
// 读写 QUIC 流数据。
|
||||
|
||||
buf := make([]byte, 1024)
|
||||
n, err := conn.Read(buf)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// cmd/quic/main_test.go 包含用于约束 quic 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# compose.override.yaml 把本地 Compose 覆盖项与偏生产形态的基础服务定义分开维护。
|
||||
|
||||
services:
|
||||
mc-gateway:
|
||||
build:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# compose.yaml 定义偏生产形态的网关容器、数据卷、端口和避开代理干扰的健康检查。
|
||||
|
||||
services:
|
||||
mc-gateway:
|
||||
image: ${MC_GATEWAY_IMAGE:-ghcr.io/tursom/mc-gateway:latest}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# config.example.toml 说明启动监听器和运行态默认值所需的静态网关配置字段。
|
||||
|
||||
# pid 文件
|
||||
pid_file = "gateway.pid"
|
||||
|
||||
|
||||
493
docs/plugin-development-toolchain-design.md
Normal file
493
docs/plugin-development-toolchain-design.md
Normal file
@@ -0,0 +1,493 @@
|
||||
# 插件开发工具链设计
|
||||
|
||||
本文定义插件开发工具链的功能需求和实现边界。目标是让插件作者从新建、开发、测试、打包到发布前检查都使用同一套 `gateway plugin` CLI,而不是在每个示例插件里维护重复脚本。
|
||||
|
||||
本设计以 [plugin-system-design.md](plugin-system-design.md) 和 [plugin-implementation-plan.md](plugin-implementation-plan.md) 为上游约束。插件作者只维护一个 manifest source 文件,支持 `manifest.yaml`、`manifest.yml`、`manifest.toml`、`manifest.jsonc` 或 `manifest.json`;`.mcgp` 包内仍统一物化为 `manifest.json`。Go 代码中不再维护 `manifestJSON` 或等价重复元数据。
|
||||
|
||||
## 目标
|
||||
|
||||
- 提供 `gateway plugin init/build/test` 三个核心开发入口。
|
||||
- 让示例插件和第三方插件使用同一套构建、打包、校验和测试流程。
|
||||
- 支持 binary `.mcgp` 和 source `.mcgp`,并逐步替代示例插件内的 `build.sh`、`cmd/render-manifest` 等重复逻辑。
|
||||
- 保持工具链 runtime-neutral:Go plugin 是第一批实现目标,后续 `go-plugin-process`、`sandbox-process`、WASM 和 ingress service 通过 runtime adapter 扩展。
|
||||
- 保证 CLI 产物可被 Admin/API 的服务端校验重复验证;CLI 只是开发体验和预检工具,不是信任边界。
|
||||
- 产物尽量稳定可复现:相同输入、相同 builder 和相同环境生成相同 zip 排序、权限和摘要。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不引入 `gateway plugin dev ...` 命名空间;开发命令直接扩展在 `gateway plugin` 下。
|
||||
- 不恢复代码内 manifest 元数据。
|
||||
- 不支持插件自定义构建脚本作为默认路径。
|
||||
- 不把 source build 当成 runtime sandbox。
|
||||
- 不在第一版支持远程插件市场、签名分发或自动升级。
|
||||
- 不承诺 Go plugin 真正热卸载;本地调试仍遵守运行时限制。
|
||||
|
||||
## 设计决策
|
||||
|
||||
| 决策 | 结论 |
|
||||
| --- | --- |
|
||||
| CLI 命名 | 直接扩展 `gateway plugin init/build/test`,不新增 `dev` 子命名空间 |
|
||||
| 元数据来源 | 插件目录只允许一个人工维护的 manifest source;包内可信元数据统一为 canonical `manifest.json` |
|
||||
| 打包入口 | `gateway plugin build` 同时承担 build 和 package,不再要求插件目录自带 zip 脚本 |
|
||||
| 示例插件 | `upstream-rewrite` 和 `mc-auth-proxy` 迁移到标准 CLI,删除重复 `build.sh` 和 `render-manifest` 逻辑 |
|
||||
| runtime 扩展 | CLI 通过 runtime build/test adapter 分发逻辑,命令名不随 runtime 改变 |
|
||||
| 校验边界 | CLI 校验不能替代 gateway 服务端上传、构建、准入和 enable 校验 |
|
||||
| source manifest | 源码目录中的 `manifest.yaml/yml/toml/jsonc/json` 是作者输入;artifact 包内的 `manifest.json` 是构建时物化结果,不作为第二份人工维护数据 |
|
||||
|
||||
## 命令总览
|
||||
|
||||
第一版重点实现:
|
||||
|
||||
| 命令 | 用途 |
|
||||
| --- | --- |
|
||||
| `gateway plugin init <dir>` | 生成插件模板 |
|
||||
| `gateway plugin build [dir]` | 构建并打包 binary/source `.mcgp` |
|
||||
| `gateway plugin test [dir]` | 运行插件单元测试和 harness 测试 |
|
||||
| `gateway plugin validate <path>` | 校验 manifest、源码目录或 `.mcgp` 包 |
|
||||
| `gateway plugin inspect <artifact.mcgp>` | 查看包内 manifest 和摘要 |
|
||||
| `gateway plugin compat <artifact.mcgp>` | 检查当前 gateway 对 artifact 的兼容性 |
|
||||
|
||||
现有 `gateway plugin source-build <source.mcgp> [out.mcgp]` 保留为兼容命令。后续可以由 `gateway plugin build --from-source <source.mcgp> --out <out.mcgp>` 覆盖同等能力,再把 `source-build` 标记为兼容别名。
|
||||
|
||||
所有面向 CI 的命令都应支持:
|
||||
|
||||
- `--json`:输出机器可读结果。
|
||||
- `--quiet`:只输出错误或关键产物路径。
|
||||
- `--out <path>`:指定产物或报告位置。
|
||||
- 稳定退出码:参数错误、校验失败、构建失败和测试失败应可区分。
|
||||
|
||||
## 完整功能域
|
||||
|
||||
工具链最终需要覆盖从插件作者到生产运维的完整闭环。下表是功能需求清单,阶段表示推荐落地顺序,不代表命令只能在该阶段出现。
|
||||
|
||||
| 功能域 | 需要解决的问题 | 关键命令 |
|
||||
| --- | --- | --- |
|
||||
| 项目脚手架 | 快速生成可构建、可测试、manifest 正确的插件目录 | `init` |
|
||||
| Manifest 编辑 | 发现字段错误、解释支持能力、避免人工维护环境字段 | `validate`、`manifest format`、`manifest explain`、`features` |
|
||||
| 构建和打包 | 统一 binary/source `.mcgp` 产物,替代示例脚本 | `build`、`clean` |
|
||||
| Source 构建复现 | 在本地或 CI 复现 gateway builder 行为 | `build --from-source` |
|
||||
| 单元和契约测试 | 在真实上传前验证 SDK、extension point 和 fixture | `test`、`conformance` |
|
||||
| 本地安装调试 | 把产物上传到开发 gateway,启用、禁用、回滚和查看状态 | `upload`、`enable`、`disable`、`rollback`、`status` |
|
||||
| 配置和 secret 预检 | 在启用前验证 config schema、secret ref、reload 兼容性 | `config validate`、`secret check`、`preflight` |
|
||||
| 发布门禁 | 生成能进入 review/CI 的证据 | `preflight`、`self-test`、`benchmark` |
|
||||
| 观测诊断 | 收集插件日志、事件、指标、trace 和诊断包 | `logs`、`events`、`metrics`、`diagnose` |
|
||||
| 后台任务 | 开发和运维手动触发任务、查看执行状态 | `task list`、`task run`、`task cancel` |
|
||||
| 数据和文件 | 查看 plugin_data/runtime files 配额、导出可迁移数据、GC | `data inspect/export/gc`、`files inspect/export/gc` |
|
||||
| Promotion | 跨环境导入导出、diff、drift 和灾备演练 | `export`、`import`、`diff`、`drift`、`dr-drill` |
|
||||
| 仓库和供应链 | 导入仓库候选、验证 SBOM/license/signature/advisory | `repo`、`sbom`、`sign`、`verify`、`advisory` |
|
||||
| SDK 和契约治理 | 发布前检查 SDK/API/manifest/错误码兼容性 | `contract check`、`schema export`、`conformance` |
|
||||
| Runtime 扩展 | 让新 runtime 复用同一套 init/build/test/validate 命令 | runtime adapter、`runtime features` |
|
||||
|
||||
### 命令分层
|
||||
|
||||
为了避免第一版实现过大,命令按层交付:
|
||||
|
||||
| 层级 | 阶段 | 命令 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 | 已有能力 | `inspect`、`validate`、`compat`、`source-validate`、`source-build` | 当前 CLI 基线,后续保持兼容 |
|
||||
| 1 | 阶段 1-3 | `init`、`build`、`test`、`features`、`manifest format/explain` | 插件作者日常开发闭环 |
|
||||
| 2 | 阶段 4 | `upload`、`enable`、`disable`、`rollback`、`status`、`config validate`、`secret check` | 本地开发 gateway 和 Admin API 操作闭环 |
|
||||
| 3 | 阶段 5 | `preflight`、`self-test`、`benchmark`、`review status`、`advisory scan` | 发布治理和准入证据 |
|
||||
| 4 | 阶段 6 | `logs`、`events`、`metrics`、`diagnose`、`task`、`data`、`files`、`gc` | 运行诊断、后台任务、数据和资源治理 |
|
||||
| 5 | 阶段 7-8 | `repo`、`sbom`、`sign`、`verify`、`contract`、`conformance`、`export/import/diff/drift/dr-drill` | 生态、供应链、跨环境发布和未来 runtime |
|
||||
|
||||
第一版不必一次实现所有命令,但设计上要避免把能力做进一次性脚本。每个命令都应能输出 JSON 报告,方便 CI 和 Admin API 复用。
|
||||
|
||||
## 开发工作流
|
||||
|
||||
工具链需要支持这些端到端流程。
|
||||
|
||||
### 新插件开发
|
||||
|
||||
```sh
|
||||
gateway plugin init ./my-plugin --id my-plugin --template upstream-dialer --module example.com/my-plugin
|
||||
cd ./my-plugin
|
||||
gateway plugin validate .
|
||||
gateway plugin test .
|
||||
gateway plugin build . --type both
|
||||
gateway plugin compat dist/my-plugin.mcgp
|
||||
```
|
||||
|
||||
完成标准:
|
||||
|
||||
- 不需要手写 zip 命令。
|
||||
- 不需要手写 `render-manifest`。
|
||||
- 不需要在 Go 代码中声明 manifest 元数据。
|
||||
- 默认模板生成 `manifest.yaml`;如需其它格式可使用 `gateway plugin init --manifest-format yaml|toml|jsonc|json`。
|
||||
|
||||
### 本地调试
|
||||
|
||||
```sh
|
||||
gateway plugin build . --type binary
|
||||
gateway plugin upload dist/my-plugin.mcgp --gateway http://127.0.0.1:8080
|
||||
gateway plugin enable my-plugin --config testdata/config.json --profile dev
|
||||
gateway plugin status my-plugin
|
||||
gateway plugin logs my-plugin --tail 100
|
||||
gateway plugin disable my-plugin
|
||||
```
|
||||
|
||||
本地调试命令通过 Admin API 工作,不绕过服务端校验。需要认证时使用现有 Admin session/token 机制;CLI 不保存 secret 明文。
|
||||
|
||||
### CI 发布检查
|
||||
|
||||
```sh
|
||||
gateway plugin validate .
|
||||
gateway plugin test . --profile unit,manifest,harness,protocol-smoke
|
||||
gateway plugin build . --type both --json --out dist/build-report.json
|
||||
gateway plugin compat dist/my-plugin.mcgp --json --out dist/compat-report.json
|
||||
gateway plugin preflight dist/my-plugin.mcgp --config config/prod.json --profile prod --json
|
||||
gateway plugin benchmark dist/my-plugin.mcgp --profile ci-contract --json
|
||||
```
|
||||
|
||||
CI 报告必须能作为 review 证据保存,并包含 artifact sha256、source sha256、SDK/API 版本、runtime、extension points、config hash、测试 profile 和失败原因。
|
||||
|
||||
### Source 包复现
|
||||
|
||||
```sh
|
||||
gateway plugin build . --type source
|
||||
gateway plugin build --from-source dist/my-plugin-source.mcgp --out dist/my-plugin-rebuilt.mcgp
|
||||
gateway plugin compat dist/my-plugin-rebuilt.mcgp
|
||||
```
|
||||
|
||||
该流程用于验证源码包能被受控 builder 重建,且构建失败不会影响 active artifact。
|
||||
|
||||
### 跨环境发布
|
||||
|
||||
```sh
|
||||
gateway plugin export my-plugin --profile staging --out promotion.json
|
||||
gateway plugin diff promotion.json --target prod
|
||||
gateway plugin import promotion.json --target prod --dry-run
|
||||
gateway plugin drift --baseline promotion.json --target prod
|
||||
```
|
||||
|
||||
promotion bundle 默认不包含 secret 明文、secret 密文和 runtime state。缺失 secret mapping、runtime 不兼容、advisory 命中或策略阻断时必须失败。
|
||||
|
||||
## `gateway plugin init`
|
||||
|
||||
`init` 负责生成一个可直接构建和测试的插件目录。
|
||||
|
||||
### 输入
|
||||
|
||||
推荐参数:
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `--id <id>` | 插件 ID,必须满足 manifest 命名规则 |
|
||||
| `--name <name>` | 展示名,默认由 ID 派生 |
|
||||
| `--template <name>` | 模板名 |
|
||||
| `--runtime <type>` | runtime 类型,默认 `go-plugin` |
|
||||
| `--module <module>` | Go module path,Go runtime 模板必填或由目录推导 |
|
||||
| `--extension <key>` | 目标 extension point |
|
||||
|
||||
第一批模板:
|
||||
|
||||
| 模板 | runtime | extension point | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `upstream-dialer` | `go-plugin` | `upstream.connect/v1` | 最小 dialer mode 模板 |
|
||||
| `protocol-proxy` | `go-plugin` | `upstream.connect/v1` | 最小 Minecraft protocol-proxy 模板 |
|
||||
| `empty-go` | `go-plugin` | 无默认 handler | 用于自定义实验 |
|
||||
|
||||
预留模板:
|
||||
|
||||
| 模板 | runtime | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `wasm-rule` | `wasm` | 未来 rule/config validate 类轻量插件 |
|
||||
| `sandbox-process` | `sandbox-process` | 未来隔离进程插件 |
|
||||
| `ingress-service` | `sandbox-process` 或专用 runtime | 未来入口服务插件 |
|
||||
|
||||
### 输出目录
|
||||
|
||||
Go plugin 模板应至少生成:
|
||||
|
||||
- `manifest.yaml`(默认;也支持 `manifest.yml`、`manifest.toml`、`manifest.jsonc`、`manifest.json`)
|
||||
- `go.mod`
|
||||
- `main.go`
|
||||
- `main_test.go`
|
||||
- `README.md`
|
||||
- `testdata/config.json`
|
||||
- `testdata/fixtures/`,按模板放置 harness 输入
|
||||
|
||||
生成的 manifest source 只包含作者应该维护的字段。`go_version`、`go_os`、`go_arch` 等环境相关字段可以为空或使用文档化占位;`build` 时再物化到 artifact manifest。
|
||||
|
||||
## `gateway plugin build`
|
||||
|
||||
`build` 是统一构建和打包入口。
|
||||
|
||||
### 常用模式
|
||||
|
||||
| 命令 | 结果 |
|
||||
| --- | --- |
|
||||
| `gateway plugin build .` | 默认生成 binary `.mcgp` |
|
||||
| `gateway plugin build . --type binary` | 生成 binary `.mcgp` |
|
||||
| `gateway plugin build . --type source` | 生成 source `.mcgp` |
|
||||
| `gateway plugin build . --type both` | 同时生成 binary 和 source `.mcgp` |
|
||||
| `gateway plugin build --from-source source.mcgp --out built.mcgp` | 使用 gateway builder 从 source 包生成 binary 包 |
|
||||
|
||||
当源码目录内存在多个 `manifest.*` 文件,`build` 必须通过 `--manifest <path>` 显式选择源文件;同一规则也适用于 `test`、`validate`、`preflight`、`self-test`、`benchmark` 和 `manifest format`。
|
||||
|
||||
推荐默认输出:
|
||||
|
||||
- `dist/<plugin-id>.mcgp`
|
||||
- `dist/<plugin-id>-source.mcgp`
|
||||
- `dist/<plugin-id>-built.mcgp`
|
||||
- `dist/build-report.json`
|
||||
|
||||
### Manifest 物化规则
|
||||
|
||||
源码目录中只能存在一个 manifest source 文件。`build` 读取 `manifest.yaml/yml/toml/jsonc/json` 后在内存中生成 artifact manifest,并写入 `.mcgp` 包内的 canonical `manifest.json`:
|
||||
|
||||
- `artifact_type` 按 `--type` 写为 `binary` 或 `source`。
|
||||
- binary 包写入 `runtime.entry=plugin.so`。
|
||||
- Go plugin binary 包写入实际 `go_version`、`go_os`、`go_arch`。
|
||||
- source 包写入 `build.type=go`、`build.entry`、`build.output`、`build.tags` 和 vendor 策略。
|
||||
- 构建 provenance、module summary、artifact sha256 等写入 build report 或服务端 build record,不要求回写源码目录的 manifest source。
|
||||
|
||||
这保证源码仓库里没有第二份需要维护的 manifest,也避免 manifest source 与 Go 代码常量不一致。
|
||||
|
||||
如果目录中同时存在多个 `manifest.*` 文件,CLI 必须失败并要求传入 `--manifest <path>` 显式选择,避免不同格式的 manifest 分叉。`gateway plugin manifest format --canonical-json --type binary|source` 可查看最终写入对应 `.mcgp` 的规范 JSON;不传 `--type` 时使用 manifest source 中的 `artifact_type`,缺省按 binary 处理。`--write` 对 YAML/TOML/JSONC 必须保留注释,无法保留时不能覆盖源文件。
|
||||
|
||||
### Go Plugin Adapter
|
||||
|
||||
第一版 `go-plugin` build adapter 负责:
|
||||
|
||||
1. 读取并校验唯一 manifest source,或通过 `--manifest` 指定的 manifest source。
|
||||
2. 运行 `go test ./...`,除非传入 `--skip-tests`。
|
||||
3. 用固定命令构建 `plugin.so`:`go build -buildmode=plugin -trimpath -buildvcs=false`。
|
||||
4. 用 `go tool nm` 校验 `Plugin` 符号。
|
||||
5. 生成稳定 zip:固定 entry 排序、权限、时间戳策略和路径分隔符。
|
||||
6. 生成 source `.mcgp` 时只包含允许的源码、`go.mod`、可选 `go.sum/vendor`、README、LICENSE、SBOM 和测试 fixture。
|
||||
7. 输出 artifact sha256、source sha256、Go/API/SDK 版本和 ABI fingerprint。
|
||||
|
||||
第一版不执行包内脚本。未来如果需要复杂构建,应通过受控 builder profile 或外部 CI,而不是让插件包携带任意 shell 脚本。
|
||||
|
||||
### Runtime Adapter 预留
|
||||
|
||||
CLI 内部应抽象 build adapter:
|
||||
|
||||
```go
|
||||
type PluginBuildAdapter interface {
|
||||
RuntimeType() string
|
||||
ValidateSource(ctx context.Context, req BuildCLIRequest) error
|
||||
Build(ctx context.Context, req BuildCLIRequest) (BuildCLIResult, error)
|
||||
PackageSource(ctx context.Context, req BuildCLIRequest) (BuildCLIResult, error)
|
||||
}
|
||||
```
|
||||
|
||||
预留 runtime 行为:
|
||||
|
||||
| runtime | build 产物 | source 包 | 测试方式 |
|
||||
| --- | --- | --- | --- |
|
||||
| `go-plugin` | `plugin.so` | Go module source | Go test + extension harness |
|
||||
| `go-plugin-process` | `plugin.so` 或 host bundle | Go module source | 子进程 host harness |
|
||||
| `sandbox-process` | executable 或 bundle | 受控源码/二进制 bundle | control RPC harness |
|
||||
| `wasm` | `plugin.wasm` | WASM source/bundle | WASM host ABI harness |
|
||||
| `builtin` | 无外部 artifact | 不适用 | gateway 内部测试 |
|
||||
|
||||
命令层不应写死 Go plugin 细节。新增 runtime 时只新增 adapter、manifest 校验和 harness,不新增一套用户命令。
|
||||
|
||||
## `gateway plugin test`
|
||||
|
||||
`test` 负责把插件作者的本地测试和 gateway extension contract 连接起来。
|
||||
|
||||
### 测试 profile
|
||||
|
||||
| Profile | 说明 |
|
||||
| --- | --- |
|
||||
| `unit` | 运行插件目录原生测试,例如 `go test ./...` |
|
||||
| `manifest` | 校验 manifest schema、命名、runtime、extension point 和 config schema |
|
||||
| `harness` | 运行 extension point fixture |
|
||||
| `protocol-smoke` | 运行 Minecraft handshake/login smoke fixture |
|
||||
| `conformance` | 运行当前 gateway 公开契约兼容测试 |
|
||||
|
||||
常用命令:
|
||||
|
||||
| 命令 | 结果 |
|
||||
| --- | --- |
|
||||
| `gateway plugin test .` | 运行模板默认 profile |
|
||||
| `gateway plugin test . --profile unit,harness` | 运行指定 profile |
|
||||
| `gateway plugin test . --config testdata/config.json` | 使用指定配置测试 |
|
||||
| `gateway plugin test . --fixture testdata/fixtures/login-reject.json` | 使用指定 fixture |
|
||||
| `gateway plugin test dist/plugin.mcgp --profile compat` | 对已打包 artifact 做兼容测试 |
|
||||
|
||||
### Harness 范围
|
||||
|
||||
第一版 harness 覆盖:
|
||||
|
||||
- `upstream.connect/v1` dialer mode:匹配 host、返回 `api.ErrPass`、返回自管 conn、错误传播。
|
||||
- `upstream.connect/v1` protocol-proxy mode:initial data replay、handshake/login packet fixture、disconnect/kick 响应、读写关闭。
|
||||
- config:`ReloadConfig()` 成功、失败、默认值和 schema 校验。
|
||||
- lifecycle:`Init()`、`Destroy()` 幂等、handler timeout、panic recover。
|
||||
|
||||
未来 runtime harness:
|
||||
|
||||
- `go-plugin-process`:通过 plugin-host 启动插件,验证 drain-only、crash loop 和 control channel。
|
||||
- `sandbox-process`:验证 capability enforcement、secret handle、filesystem/network policy。
|
||||
- `wasm`:验证 host ABI、memory/time limit、无授权文件和网络访问。
|
||||
- `ingress.service/v1`:验证 listener 由 gateway 创建、端口冲突和 disable drain。
|
||||
|
||||
## `gateway plugin validate`
|
||||
|
||||
`validate` 应支持三类输入:
|
||||
|
||||
- manifest source 文件:`manifest.yaml`、`manifest.yml`、`manifest.toml`、`manifest.jsonc` 或 `manifest.json`
|
||||
- 插件源码目录
|
||||
- `.mcgp` artifact
|
||||
|
||||
校验内容:
|
||||
|
||||
- manifest schema 和必填字段。
|
||||
- runtime type、runtime entry、build entry。
|
||||
- extension point key、type 和 mode。
|
||||
- config schema JSON。
|
||||
- secret、event、metric、background task、external dependency、data store 和 file store 命名。
|
||||
- binary/source 包结构、zip slip、大小限制和允许文件。
|
||||
- 当前 gateway feature support。
|
||||
|
||||
对于源码目录,`validate` 不能执行插件代码;最多做静态文件、manifest 和包结构检查。需要运行代码的检查放在 `test` 或 `build`。
|
||||
|
||||
## 本地 Admin 操作命令
|
||||
|
||||
阶段 4 后,CLI 应能操作开发或测试环境的 Admin API,形成不依赖页面的调试闭环。
|
||||
|
||||
| 命令 | 职责 |
|
||||
| --- | --- |
|
||||
| `gateway plugin upload <artifact.mcgp>` | 上传 artifact/source package,返回 artifact ID、sha256 和校验摘要 |
|
||||
| `gateway plugin status [plugin-id]` | 展示 desired/runtime state、active/desired/loaded artifact、recent error 和 restart required |
|
||||
| `gateway plugin enable <plugin-id>` | 设置 desired enabled,支持 `--artifact`、`--config`、`--profile`、`--priority` |
|
||||
| `gateway plugin disable <plugin-id>` | 设置 desired disabled,protocol-proxy 连接按策略 drain 或 force close |
|
||||
| `gateway plugin delete <plugin-id>` | 删除 desired state 或 artifact,支持保留/删除数据选项 |
|
||||
| `gateway plugin rollback <plugin-id>` | 回滚 artifact 或 config snapshot,并重新执行当前基础门禁 |
|
||||
| `gateway plugin config validate <plugin-id>` | 校验 config JSON、schema、secret ref 和 `ReloadConfig()` dry-run |
|
||||
| `gateway plugin secret check <plugin-id>` | 检查 manifest 必需 secret、secret ref、版本和 reload/rotation 状态 |
|
||||
|
||||
这些命令必须通过 Admin API 执行,并复用服务端权限、审计和错误码。CLI 不直接写 SQLite,不直接操作 artifact store,也不能绕过上传时的 zip/manifest 校验。
|
||||
|
||||
## 发布治理命令
|
||||
|
||||
阶段 5 后,CLI 需要生成和读取生产准入证据。
|
||||
|
||||
| 命令 | 职责 |
|
||||
| --- | --- |
|
||||
| `gateway plugin preflight` | 运行 config、secret、feature、runtime limits、scope/rollout、conflict 和 Minecraft capability 检查 |
|
||||
| `gateway plugin self-test` | 运行插件实现的 quick/protocol-smoke/integration profile,保存脱敏证据 |
|
||||
| `gateway plugin benchmark` | 记录或执行 benchmark profile,输出 P95/P99、error rate、capacity 和 baseline diff |
|
||||
| `gateway plugin review status` | 查看当前 artifact/config/scope/risk/policy hash 是否已有有效 review |
|
||||
| `gateway plugin advisory scan` | 按 artifact sha256、plugin/version、SBOM dependency 或 source metadata 扫描安全公告 |
|
||||
|
||||
发布治理命令的 JSON 报告必须包含稳定 `code`、`severity`、`message`、`evidence_id` 和相关 hash,不能要求 CI 解析人类可读文本。
|
||||
|
||||
## 观测和运维命令
|
||||
|
||||
阶段 6 后,CLI 应覆盖插件出问题时的定位、证据导出和资源清理。
|
||||
|
||||
| 命令 | 职责 |
|
||||
| --- | --- |
|
||||
| `gateway plugin logs <plugin-id>` | 查看插件日志摘要,支持 tail、时间范围、trace ID 和脱敏 |
|
||||
| `gateway plugin events <plugin-id>` | 查看插件业务事件、drop/dead-letter 摘要和 replay/drop 操作 |
|
||||
| `gateway plugin metrics <plugin-id>` | 查看 handler calls、duration、panic、timeout、active proxy connections 和 custom metrics |
|
||||
| `gateway plugin diagnose <plugin-id>` | 生成诊断包,包含 manifest、state、recent logs/events/metrics/build summary,不含 secret 明文 |
|
||||
| `gateway plugin task list/run/cancel <plugin-id>` | 查看、手动触发或取消 background task |
|
||||
| `gateway plugin data inspect/export/gc <plugin-id>` | 查看 plugin_data schema/data class/quota,导出可迁移数据,执行 dry-run 或清理 |
|
||||
| `gateway plugin files inspect/export/gc <plugin-id>` | 查看 runtime files/resources/cache/tmp/log/diagnostic 用量和 GC candidate |
|
||||
| `gateway plugin gc --dry-run` | 汇总 artifact、build log、diagnostic、plugin_data 和 runtime files 的可清理对象 |
|
||||
|
||||
所有清理命令默认 dry-run;实际删除必须显式传入确认参数,并写审计。数据导出只允许 manifest 声明 `exportable=true` 且调用者有权限的数据。
|
||||
|
||||
## 仓库、供应链和签名命令
|
||||
|
||||
阶段 8 的分发能力不能绕过本地 review 和 enable 流程。
|
||||
|
||||
| 命令 | 职责 |
|
||||
| --- | --- |
|
||||
| `gateway plugin repo list/search/show` | 查看 official/internal/file/url repository 中的候选版本 |
|
||||
| `gateway plugin repo import` | 下载或导入候选 artifact 到本地 store,只生成 local artifact,不自动启用 |
|
||||
| `gateway plugin sbom generate/verify` | 生成或验证 SBOM,供 advisory/license 策略使用 |
|
||||
| `gateway plugin sign` | 对 artifact 或 promotion bundle 签名,未来能力 |
|
||||
| `gateway plugin verify` | 验证 signature、sha256、SBOM、license 和 provenance |
|
||||
| `gateway plugin advisory import/scan/ack` | 导入安全公告、重新扫描本地 artifact、记录 mitigation/ack |
|
||||
|
||||
仓库删除、远端更新或签名失败都不能自动改变本地 active artifact。repository import 之后仍要走 validate、compat、preflight、review 和 enable。
|
||||
|
||||
## 契约和 SDK 命令
|
||||
|
||||
插件系统公开 API 后,CLI 还要服务 gateway release 过程。
|
||||
|
||||
| 命令 | 职责 |
|
||||
| --- | --- |
|
||||
| `gateway plugin features` | 输出当前 gateway 支持的 runtime、extension point、manifest field、feature key 和版本 |
|
||||
| `gateway plugin schema export` | 导出 manifest JSON schema、config UI hint schema 和 extension fixture schema |
|
||||
| `gateway plugin contract check` | 对比上一 release 的 SDK/API/manifest/error code/CLI JSON 输出兼容性 |
|
||||
| `gateway plugin conformance` | 构建示例插件,运行 source/binary fixture 和 Admin/CLI golden test |
|
||||
|
||||
`features` 输出必须和 Admin API 使用同一契约。`contract check` 和 `conformance` 失败应被视为 gateway release 风险,不是普通文档错误。
|
||||
|
||||
## Runtime 扩展命令
|
||||
|
||||
新增 runtime 不应增加一套平行 CLI。`init/build/test/validate/compat/preflight` 必须根据 `manifest.runtime.type` 选择 adapter。
|
||||
|
||||
| runtime | 额外 CLI 需求 |
|
||||
| --- | --- |
|
||||
| `go-plugin-process` | `test` 能启动 plugin-host harness;`preflight` 检查 migration mode、safe point、drain-only/fd-live 声明 |
|
||||
| `sandbox-process` | `validate/preflight` 检查 capability、secret handle、filesystem/network/env/cpu/memory policy;`test` 验证 control RPC 和 crash loop |
|
||||
| `wasm` | `build` 生成 `plugin.wasm`;`test` 使用 WASM host ABI;`preflight` 检查 memory/time/no file/no network |
|
||||
| `ingress.service/v1` | `preflight` 检查 listener ownership、port conflict、TLS/secret refs 和 disable drain |
|
||||
| build-time instrumentation | 不进入 runtime plugin enable/disable;CLI 只提供 manifest/provenance/conformance/benchmark/smoke 证据 |
|
||||
|
||||
如果目标 gateway 不支持某 runtime,`compat` 和 `preflight` 必须返回明确的 blocking code,而不是降级为 Go plugin 尝试加载。
|
||||
|
||||
## 发布前检查
|
||||
|
||||
发布前推荐流程:
|
||||
|
||||
1. `gateway plugin validate .`
|
||||
2. `gateway plugin test . --profile unit,manifest,harness`
|
||||
3. `gateway plugin build . --type both`
|
||||
4. `gateway plugin validate dist/<plugin-id>.mcgp`
|
||||
5. `gateway plugin compat dist/<plugin-id>.mcgp`
|
||||
6. 可选:`gateway plugin build --from-source dist/<plugin-id>-source.mcgp --out dist/<plugin-id>-rebuilt.mcgp`
|
||||
7. 可选:`gateway plugin test dist/<plugin-id>.mcgp --profile conformance`
|
||||
|
||||
CI 产物应至少保存:
|
||||
|
||||
- binary `.mcgp`
|
||||
- source `.mcgp`
|
||||
- build report JSON
|
||||
- test report JSON
|
||||
- artifact sha256 和 source sha256
|
||||
|
||||
## 示例插件迁移
|
||||
|
||||
`examples/plugins/upstream-rewrite` 和 `examples/plugins/mc-auth-proxy` 迁移目标:
|
||||
|
||||
- README 使用 `gateway plugin build . --type both`。
|
||||
- README 使用 `gateway plugin test .`。
|
||||
- 删除或降级 `build.sh` 为兼容包装;最终不再作为主路径。
|
||||
- 删除 `cmd/render-manifest`,由 CLI 根据源码 manifest source 生成 artifact manifest。
|
||||
- 示例插件的测试 fixture 进入 `testdata/fixtures/`。
|
||||
- 示例插件进入 conformance suite;构建失败视为插件 API 回归。
|
||||
|
||||
迁移时必须保留现有 `.mcgp` 格式:binary 包仍包含 `manifest.json` 和 `plugin.so`;source 包仍包含 `manifest.json`、`go.mod`、build entry 和源码。
|
||||
|
||||
## 实现顺序
|
||||
|
||||
建议按以下顺序实现:
|
||||
|
||||
1. 增加 `gateway plugin init`,生成 `upstream-dialer` 和 `protocol-proxy` Go 模板。
|
||||
2. 增加 `gateway plugin build` 的 Go plugin binary/source 打包能力,复用现有 artifact 校验逻辑。
|
||||
3. 用 `gateway plugin build` 替换示例插件 `build.sh` 和 `cmd/render-manifest` 主路径。
|
||||
4. 增加 `gateway plugin test` 的 unit、manifest 和 upstream harness profile。
|
||||
5. 将 `source-build` 能力收敛为 `build --from-source`,保留兼容别名。
|
||||
6. 增加 runtime build/test adapter 接口,为 `go-plugin-process`、`sandbox-process` 和 WASM 实现预留扩展点。
|
||||
7. 增加 JSON report、conformance profile 和 CI golden 输出。
|
||||
|
||||
每一步结束时,现有 `inspect/validate/compat/source-validate/source-build` 不能回归。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 新建 `upstream-dialer` 模板后,不手写额外脚本即可 build/test/validate。
|
||||
- 新建 `protocol-proxy` 模板后,能跑通 Minecraft handshake/login smoke fixture。
|
||||
- `upstream-rewrite` 和 `mc-auth-proxy` 示例插件使用标准 CLI 生成 binary/source `.mcgp`。
|
||||
- 生成的 `.mcgp` 能通过现有上传和服务端校验。
|
||||
- manifest source 与 Go 代码不重复维护插件元数据。
|
||||
- Go plugin adapter 之外的 runtime 可以通过 adapter 注册进入同一套 `init/build/test` 命令。
|
||||
- CLI 失败输出能定位到字段、文件或 fixture,而不是只返回通用错误。
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
本文以 [plugin-system-design.md](plugin-system-design.md) 作为最终目标设计文档,把插件系统拆分成多个可上线的实现阶段。每个阶段都必须在结束时保持 gateway 当前可用:可以启动、可以回滚、可以排障,且不会要求后续阶段补齐后才能恢复基本能力。
|
||||
|
||||
插件开发工具链作为跨阶段交付项单独设计,见 [plugin-development-toolchain-design.md](plugin-development-toolchain-design.md)。工具链主入口为 `gateway plugin init/build/test`,并需要从第一批 Go plugin 示例开始预留未来 runtime adapter。
|
||||
|
||||
## 拆分原则
|
||||
|
||||
- 以可用的纵向切片拆分,而不是按数据库、API、UI、SDK 等横向模块拆分。
|
||||
@@ -37,6 +39,7 @@
|
||||
| Minecraft capability manifest、protocol smoke fixture | 2 | 支撑管理页展示和后续发布门禁 |
|
||||
| source `.mcgp`、builder、构建 provenance | 3 | 源码包构建成 `plugin.so` 后复用阶段 1/2 加载路径 |
|
||||
| builder 隔离、Go/module/ABI 记录、source/build log GC | 3 | 构建失败不影响 active artifact |
|
||||
| `gateway plugin init/build/test` 开发工具链 | 1-3,后续扩展 | 阶段 1/2 提供 Go plugin 模板和 harness,阶段 3 收敛 source/binary 打包;后续 runtime 通过 adapter 接入 |
|
||||
| Admin 页面基础管理闭环 | 4 | 上传、构建状态、加载、启用、禁用、删除、回滚 |
|
||||
| 配置 schema、配置快照、配置迁移入口 | 4 | 错误配置不切换 active artifact |
|
||||
| SecretStore、secret version、reload/rotation 基础 | 4 | secret 不在页面、日志、审计中明文展示 |
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
- 第一版继续使用 Go `-buildmode=plugin` 的进程内 `.so` 插件。
|
||||
- 尽量支持热加载:兼容且未加载过的插件可以不重启加载并启用。
|
||||
- 明确热卸载限制:Go plugin 不能真正从进程中卸载,只能逻辑禁用。
|
||||
- 通过 `manifest.json` 记录插件 ID、版本、目标平台、构建 Go 版本、SDK/API 版本、声明的 extension point 和配置 schema。
|
||||
- 通过 manifest source 记录插件 ID、版本、目标平台、构建 Go 版本、SDK/API 版本、声明的 extension point 和配置 schema;源码目录只维护一份 `manifest.yaml/yml/toml/jsonc/json`,`.mcgp` 包内统一物化为 canonical `manifest.json`。
|
||||
- 插件能力采用 Extension Point 模型,Hook 是其中一种;第一版先落 `upstream.connect/v1`。
|
||||
- `upstream.connect/v1` 必须支持插件返回自管 `net.Conn`,用于实现完整 stream endpoint/protocol proxy。
|
||||
- MC 正版/三方登录、身份映射、forwarding 和登录后的协议处理属于插件业务逻辑,不由 gateway core 拼装。
|
||||
@@ -33,7 +33,7 @@
|
||||
| 决策/功能点 | 当前设计结论 | 阶段 | 主要章节 |
|
||||
| --- | --- | --- | --- |
|
||||
| 插件信任模型 | 第一版只支持可信 native 插件,不把普通插件当不可信代码运行 | 第一版 | Runtime 与权限声明、安全与运维约束 |
|
||||
| 插件包格式 | 管理页统一上传 `.mcgp` zip 包,源码/二进制由 `manifest.json` 的 `artifact_type` 决定 | 第一版 | 包格式、源码包构建 |
|
||||
| 插件包格式 | 管理页统一上传 `.mcgp` zip 包,源码/二进制由包内 canonical `manifest.json` 的 `artifact_type` 决定 | 第一版 | 包格式、源码包构建 |
|
||||
| 自定义扩展名 | 不新增源码包扩展名;同一 `.mcgp` 格式承载 binary/source | 第一版 | 包格式 |
|
||||
| Go plugin ABI | 仅使用 `.mcgp` 内的 `manifest.json` 表示元数据,并记录 Go/API/SDK/ABI fingerprint | 第一版 | Manifest 元数据、Go Plugin ABI Fingerprint |
|
||||
| Admin 管理 | 上传、构建、加载、启用、禁用、切换版本、删除、回滚和审计都进入 Admin/SQLite | 第一版 | 数据模型、生命周期、Admin API |
|
||||
@@ -67,9 +67,9 @@
|
||||
| 管理页上传、构建、加载、启用/禁用、删除和切换版本 | 全部进入 Admin/SQLite 生命周期,删除已加载 native artifact 后提示重启彻底清理 | 数据模型、生命周期、Admin API、Admin 页面 |
|
||||
| 热加载尽量支持,热卸载承认 Go plugin 限制 | 兼容且未加载过 artifact 可热加载;已加载 Go plugin 只能逻辑禁用,不能真正卸载 | 生命周期、热加载和热卸载、Runbook |
|
||||
| 多进程模型实现进程级热卸载 | 预留 `go-plugin-process` runtime,主进程只做管理和 fd 编排,子进程负责数据面;通过退出子进程回收 Go plugin | Go Plugin Process Runtime、第一版默认策略 |
|
||||
| 插件包不新增源码扩展名 | 统一 `.mcgp` zip,源码/二进制由 `manifest.json.artifact_type` 声明 | 插件包格式 |
|
||||
| 插件包不新增源码扩展名 | 统一 `.mcgp` zip,源码/二进制由包内 `manifest.json.artifact_type` 声明 | 插件包格式 |
|
||||
| 支持源码包和构建环境设计 | source `.mcgp` 经受控 builder 生成 `plugin.so`;开发 local-process,生产推荐 container builder 或外部 CI | 源码包构建环境、供应链元数据 |
|
||||
| Manifest 元数据稳定并记录 Go 构建信息 | `manifest.json` 是唯一元数据来源,记录 Go/API/SDK/ABI fingerprint、builder 和 provenance | Manifest 元数据、Go Plugin ABI Fingerprint |
|
||||
| Manifest 元数据稳定并记录 Go 构建信息 | 作者只维护一个 manifest source;包内 canonical `manifest.json` 是服务端唯一可信元数据来源,记录 Go/API/SDK/ABI fingerprint、builder 和 provenance | Manifest 元数据、Go Plugin ABI Fingerprint |
|
||||
| Hook 之外的插件技术方案 | 统一 Extension Point 模型,覆盖 hook、middleware、provider、event subscriber、rule/policy;mock/mixin/monkey patch 不作为生产机制 | Extension Point 设计、Mock 和 Mixin 的定位 |
|
||||
| 沙箱功能要有未来路线 | 第一版不提供沙箱;预留 sandbox-process、WASM、capability enforcement、stream relay 和 egress 策略 | Sandbox Runtime、Runtime Adapter、第一版默认策略 |
|
||||
| Alibaba 非侵入 Go 注入的参考价值 | 作为官方/组织 build-time instrumentation 未来能力,不作为普通运行时插件或热加载机制 | Build-Time Instrumentation |
|
||||
@@ -285,7 +285,7 @@
|
||||
|
||||
## 插件包格式
|
||||
|
||||
管理页上传的插件包统一使用 `.mcgp`,本质是 zip 包。包内内容由 `manifest.json` 决定,不通过扩展名区分源码包和二进制包。
|
||||
管理页上传的插件包统一使用 `.mcgp`,本质是 zip 包。包内内容由 canonical `manifest.json` 决定,不通过扩展名区分源码包和二进制包。开发目录可以维护 `manifest.yaml`、`manifest.yml`、`manifest.toml`、`manifest.jsonc` 或 `manifest.json`,但构建进入 `.mcgp` 时必须统一物化为根目录 `manifest.json`。
|
||||
|
||||
包类型由两个字段表达:
|
||||
|
||||
@@ -313,7 +313,7 @@ upstream-rewrite.mcgp
|
||||
README.md # 可选
|
||||
```
|
||||
|
||||
`manifest.json` 是加载前可读取的元数据,用于避免必须执行插件代码才能知道基础信息。上传阶段只解析 zip 和 manifest,不执行插件代码。
|
||||
包内 `manifest.json` 是加载前可读取的可信元数据,用于避免必须执行插件代码才能知道基础信息。上传阶段只解析 zip 和 manifest,不执行插件代码。
|
||||
|
||||
二进制包 manifest 示例:
|
||||
|
||||
@@ -459,7 +459,7 @@ upstream-rewrite.mcgp
|
||||
}
|
||||
```
|
||||
|
||||
二进制包上传后可以直接登记为 artifact。源码包上传后必须先进入 builder,构建出 `plugin.so` 后再登记为 artifact。加载阶段始终只加载最终产物 `plugin.so`;元数据以已校验入库的 `manifest.json` 为准。
|
||||
二进制包上传后可以直接登记为 artifact。源码包上传后必须先进入 builder,构建出 `plugin.so` 后再登记为 artifact。加载阶段始终只加载最终产物 `plugin.so`;元数据以已校验入库的包内 `manifest.json` 为准。
|
||||
|
||||
开发环境可以允许直接上传 raw `.so`,但生产推荐只接受 `.mcgp`。直接上传 `.so` 时,加载前只能展示文件名、大小和 sha256;生产路径仍应使用 `.mcgp` 提供 `manifest.json`。
|
||||
|
||||
@@ -2009,7 +2009,7 @@ index 规则:
|
||||
|
||||
## Manifest 元数据
|
||||
|
||||
插件包的元数据只来自 `.mcgp` 根目录的 `manifest.json`。上传、准入、构建、兼容性检查和 Admin 展示都必须使用这份静态 manifest;gateway 不通过执行插件代码读取元数据。
|
||||
插件包的元数据只来自 `.mcgp` 根目录的 canonical `manifest.json`。上传、准入、构建、兼容性检查和 Admin 展示都必须使用这份静态 manifest;gateway 不通过执行插件代码读取元数据。源码目录可以使用 YAML、TOML、JSONC 或 JSON 作为唯一 manifest source,但进入 `.mcgp` 前必须规范化为 `manifest.json`。
|
||||
|
||||
Go plugin 只需要导出一个 factory 符号:
|
||||
|
||||
@@ -6370,52 +6370,54 @@ type Gateway interface {
|
||||
- `plugin/api` 稳定 API 文档。
|
||||
- `examples/plugins/upstream-rewrite` 最小模板。
|
||||
- `examples/plugins/mc-auth-proxy` protocol-proxy 模板。
|
||||
- manifest JSON schema。
|
||||
- 构建脚本模板。
|
||||
- `.mcgp` 打包脚本。
|
||||
- manifest source 多格式解析和 canonical JSON schema。
|
||||
- 统一的 `gateway plugin init/build/test` 开发工具链。
|
||||
|
||||
详细工具链设计见 [plugin-development-toolchain-design.md](plugin-development-toolchain-design.md)。工具链必须继续遵守 manifest-only 元数据约束:插件作者只维护一个 manifest source 文件,Go 代码中不再保存 `manifestJSON` 或等价重复元数据;`.mcgp` 包内仍以 canonical `manifest.json` 作为服务端可信边界。
|
||||
|
||||
### CLI 工具
|
||||
|
||||
建议提供 `mc-gateway plugin` 子命令,降低插件开发和运维成本。
|
||||
建议提供 `gateway plugin` 子命令,降低插件开发和运维成本。开发入口直接扩展在 `gateway plugin init/build/test` 下,不新增 `dev` 子命名空间。
|
||||
|
||||
候选命令:
|
||||
|
||||
| 命令 | 说明 |
|
||||
| --- | --- |
|
||||
| `mc-gateway plugin init` | 生成插件模板 |
|
||||
| `mc-gateway plugin validate manifest.json` | 校验 manifest schema、命名、capabilities 和 extension point |
|
||||
| `mc-gateway plugin package --type source` | 打包 source `.mcgp` |
|
||||
| `mc-gateway plugin package --type binary` | 打包 binary `.mcgp` |
|
||||
| `mc-gateway plugin inspect plugin.mcgp` | 查看 manifest、supply chain、sha256、Go/API 版本 |
|
||||
| `mc-gateway plugin compat plugin.mcgp` | 检查当前 gateway 是否可能加载该插件 |
|
||||
| `mc-gateway plugin features` | 查看当前 gateway 支持的 feature key 和版本 |
|
||||
| `mc-gateway plugin build` | 使用匹配 builder 本地构建 plugin.so |
|
||||
| `mc-gateway plugin test` | 运行插件 harness 测试 |
|
||||
| `mc-gateway plugin preflight` | 对插件包或已安装插件执行通用预检和插件 Preflight |
|
||||
| `mc-gateway plugin self-test` | 运行 quick/protocol-smoke/integration 自测 profile |
|
||||
| `mc-gateway plugin benchmark` | 运行插件 benchmark、soak 或 regression profile |
|
||||
| `mc-gateway plugin contract check` | 校验契约文件和上一 release 的兼容性 |
|
||||
| `mc-gateway plugin conformance` | 运行插件契约 conformance suite |
|
||||
| `mc-gateway plugin export` | 从 Admin API 导出 promotion bundle |
|
||||
| `mc-gateway plugin import` | 上传并校验 promotion bundle |
|
||||
| `mc-gateway plugin diff` | 对比 bundle、目标环境和当前 desired state |
|
||||
| `mc-gateway plugin drift` | 查看当前环境相对基线的漂移状态 |
|
||||
| `mc-gateway plugin dr-drill` | 触发或查看灾备演练 |
|
||||
| `mc-gateway plugin data inspect <plugin>` | 查看 plugin_data schema、data class、大小、配额和 GC candidate |
|
||||
| `mc-gateway plugin data export <plugin>` | 导出允许迁移的数据,受 data_class 和权限控制 |
|
||||
| `mc-gateway plugin data gc <plugin>` | 按 retention 清理过期或可丢弃 plugin_data |
|
||||
| `mc-gateway plugin sbom` | 生成或校验 SBOM,未来能力 |
|
||||
| `mc-gateway plugin sign` | 签名插件包,未来能力 |
|
||||
| `gateway plugin init` | 生成插件模板 |
|
||||
| `gateway plugin validate <path>` | 校验 manifest、源码目录或 `.mcgp` 包 |
|
||||
| `gateway plugin build --type source` | 打包 source `.mcgp` |
|
||||
| `gateway plugin build --type binary` | 构建并打包 binary `.mcgp` |
|
||||
| `gateway plugin build --type both` | 同时生成 source/binary `.mcgp` |
|
||||
| `gateway plugin build --from-source` | 从 source `.mcgp` 生成 binary `.mcgp`,逐步替代 `source-build` 主路径 |
|
||||
| `gateway plugin test` | 运行插件 unit、manifest、harness 或 conformance profile |
|
||||
| `gateway plugin inspect plugin.mcgp` | 查看 manifest、supply chain、sha256、Go/API 版本 |
|
||||
| `gateway plugin compat plugin.mcgp` | 检查当前 gateway 是否可能加载该插件 |
|
||||
| `gateway plugin features` | 查看当前 gateway 支持的 feature key 和版本 |
|
||||
| `gateway plugin preflight` | 对插件包或已安装插件执行通用预检和插件 Preflight |
|
||||
| `gateway plugin self-test` | 运行 quick/protocol-smoke/integration 自测 profile |
|
||||
| `gateway plugin benchmark` | 运行插件 benchmark、soak 或 regression profile |
|
||||
| `gateway plugin contract check` | 校验契约文件和上一 release 的兼容性 |
|
||||
| `gateway plugin conformance` | 运行插件契约 conformance suite |
|
||||
| `gateway plugin export` | 从 Admin API 导出 promotion bundle |
|
||||
| `gateway plugin import` | 上传并校验 promotion bundle |
|
||||
| `gateway plugin diff` | 对比 bundle、目标环境和当前 desired state |
|
||||
| `gateway plugin drift` | 查看当前环境相对基线的漂移状态 |
|
||||
| `gateway plugin dr-drill` | 触发或查看灾备演练 |
|
||||
| `gateway plugin data inspect <plugin>` | 查看 plugin_data schema、data class、大小、配额和 GC candidate |
|
||||
| `gateway plugin data export <plugin>` | 导出允许迁移的数据,受 data_class 和权限控制 |
|
||||
| `gateway plugin data gc <plugin>` | 按 retention 清理过期或可丢弃 plugin_data |
|
||||
| `gateway plugin sbom` | 生成或校验 SBOM,未来能力 |
|
||||
| `gateway plugin sign` | 签名插件包,未来能力 |
|
||||
|
||||
CLI 规则:
|
||||
|
||||
- CLI 校验不能替代服务端校验,服务端必须重复做安全校验。
|
||||
- package 命令必须生成稳定 zip,避免无意义 sha256 变化。
|
||||
- build 命令必须生成稳定 zip,避免无意义 sha256 变化。
|
||||
- inspect 命令不能执行插件代码。
|
||||
- compat 命令只能做 preflight,必须检查 required/optional features,但不能保证 `plugin.Open` 一定成功。
|
||||
- features 命令输出必须和 Admin `/plugins/features` API 使用同一契约。
|
||||
- diff、drift、export 和 import 必须使用同一 canonical hash 与脱敏 diff 实现。
|
||||
- build 命令应默认使用与 gateway release 匹配的 builder image。
|
||||
- build 命令应默认使用与 gateway release 匹配的 builder image;本地 Go plugin adapter 可以先使用当前 Go toolchain。
|
||||
- data inspect 默认只显示摘要,不导出 value。
|
||||
- data export 必须经过 Admin API 权限检查,且只能导出 manifest 声明 `exportable=true` 的数据。
|
||||
- data gc 必须支持 dry-run,先展示将清理的 data_class、key 数量和总大小。
|
||||
@@ -6425,9 +6427,9 @@ CLI 规则:
|
||||
本地开发流程:
|
||||
|
||||
1. 从示例复制插件目录。
|
||||
2. 编写 `manifest.json`。
|
||||
2. 编写唯一 manifest source,默认是 `manifest.yaml`。
|
||||
3. 使用与 gateway 匹配的 Go toolchain。
|
||||
4. 运行示例脚本构建 `.mcgp`。
|
||||
4. 运行 `gateway plugin build` 构建 `.mcgp`。
|
||||
5. 通过 Admin 上传。
|
||||
6. 查看 ABI 校验结果、构建日志、加载状态和运行错误。
|
||||
|
||||
@@ -7130,7 +7132,7 @@ API 错误响应应包含稳定错误码,便于管理页和 CLI 处理:
|
||||
1. 查看 build log excerpt 和 builder image。
|
||||
2. 确认 Go version、GOOS/GOARCH、CGO 和 build tags。
|
||||
3. 检查 GOPROXY/vendor/private dependency 配置。
|
||||
4. 使用 CLI 在本地或 CI 复现 `mc-gateway plugin build`。
|
||||
4. 使用 CLI 在本地或 CI 复现 `gateway plugin build`。
|
||||
5. 修正源码包后重新上传,或 retry 同一 build job。
|
||||
|
||||
构建失败不应改变 active artifact。
|
||||
@@ -7202,10 +7204,12 @@ API 错误响应应包含稳定错误码,便于管理页和 CLI 处理:
|
||||
examples/plugins/upstream-rewrite/
|
||||
go.mod
|
||||
main.go
|
||||
main_test.go
|
||||
manifest.json
|
||||
README.md
|
||||
build.sh
|
||||
package-source.sh
|
||||
testdata/
|
||||
config.json
|
||||
fixtures/
|
||||
```
|
||||
|
||||
示例能力:
|
||||
@@ -7231,10 +7235,12 @@ examples/plugins/upstream-rewrite/
|
||||
examples/plugins/mc-auth-proxy/
|
||||
go.mod
|
||||
main.go
|
||||
main_test.go
|
||||
manifest.json
|
||||
README.md
|
||||
build.sh
|
||||
package-source.sh
|
||||
testdata/
|
||||
config.json
|
||||
fixtures/
|
||||
```
|
||||
|
||||
示例能力:
|
||||
@@ -7263,10 +7269,12 @@ examples/plugins/mc-auth-proxy/
|
||||
examples/plugins/mc-status-motd/
|
||||
go.mod
|
||||
main.go
|
||||
main_test.go
|
||||
manifest.json
|
||||
README.md
|
||||
build.sh
|
||||
package-source.sh
|
||||
testdata/
|
||||
config.json
|
||||
fixtures/
|
||||
```
|
||||
|
||||
示例能力:
|
||||
@@ -7346,7 +7354,7 @@ examples/plugins/mc-status-motd/
|
||||
- 定义 `.mcgp` 静态校验规则、大小限制和 zip slip 防护。
|
||||
- 定义插件 ID、handler ID、task ID、secret name 和 extension point 命名规范。
|
||||
- 支持 `artifact_type=binary/source` 和 `runtime.type=go-plugin`。
|
||||
- 确认插件只需要导出 `Plugin` factory,元数据只来自 `manifest.json`。
|
||||
- 确认插件只需要导出 `Plugin` factory,包内元数据只来自 canonical `manifest.json`。
|
||||
- 定义 Go plugin ABI fingerprint schema、计算规则、compat diff 和开发模式 override 语义。
|
||||
- 在 `plugin/api` 中补齐 `APIVersion`、extension point metadata、`ErrPass` 等。
|
||||
- 把 upstream hook 收敛为 request struct。
|
||||
@@ -7662,7 +7670,7 @@ examples/plugins/mc-status-motd/
|
||||
- 插件测试 harness 能覆盖 dialer mode 和 protocol-proxy mode。
|
||||
- 测试矩阵覆盖包格式、ABI、生命周期、配置、extension point、入口传输、上游协议、protocol-proxy、治理、secret、artifact 和 Admin 权限。
|
||||
- 运维 Runbook 覆盖连接失败、启用失败、构建失败、secret 泄漏怀疑、磁盘占用过高和多实例部分失败。
|
||||
- CLI 工具至少覆盖 manifest validate、package、inspect、compat、test、promotion export/import/diff、drift 和 dr-drill 的设计。
|
||||
- CLI 工具至少覆盖 manifest validate、build/package、inspect、compat、test、promotion export/import/diff、drift 和 dr-drill 的设计。
|
||||
- CLI 工具覆盖 plugin_data inspect/export/gc,且 data gc 支持 dry-run。
|
||||
- Admin API 错误响应有稳定 code,管理页和 CLI 不依赖错误字符串解析。
|
||||
- 文档能明确区分第一版能力、预留 extension point 和未来 runtime。
|
||||
@@ -7696,7 +7704,7 @@ examples/plugins/mc-status-motd/
|
||||
| 契约文件 | 第一版手写维护 JSON schema/contract;后续可从 Go 类型和 manifest schema 生成并做 diff 校验 |
|
||||
| SDK 发布节奏 | gateway release 与 plugin SDK release 默认绑定;SDK 使用 SemVer,gateway 记录支持范围 |
|
||||
| conformance suite | release 前必须运行并产出报告;第一版可先作为 release gate,CI 阻断按模块成熟度逐步打开 |
|
||||
| CLI 形态 | 第一版作为 gateway 二进制的 `mc-gateway plugin` 子命令;独立 `mc-gateway-plugin` 作为未来分发形态 |
|
||||
| CLI 形态 | 第一版作为 gateway 二进制的 `gateway plugin` 子命令;独立 `gateway-plugin` 作为未来分发形态 |
|
||||
| 插件服务启动模式 | 第一版固定 `in-process`;Admin 可预留 desired mode 配置,`go-plugin-process`/`sandbox-process` 未来生效且切换需要重启 |
|
||||
|
||||
### 准入和权限
|
||||
|
||||
@@ -8,3 +8,5 @@ This example demonstrates phase 7 extension points:
|
||||
- `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.
|
||||
The source manifest is maintained as `manifest.yaml`; packaged `.mcgp` artifacts
|
||||
still contain canonical `manifest.json`.
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"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" }
|
||||
}
|
||||
51
examples/plugins/extension-ecosystem/manifest.yaml
Normal file
51
examples/plugins/extension-ecosystem/manifest.yaml
Normal file
@@ -0,0 +1,51 @@
|
||||
# examples/plugins/extension-ecosystem/manifest.yaml 是示例插件代码,用于演示托管插件接入方式。
|
||||
|
||||
# 人工维护的插件清单;构建插件包时会规范化为 manifest.json。
|
||||
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
|
||||
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
|
||||
@@ -12,16 +12,19 @@ belong inside a protocol-proxy plugin.
|
||||
Build and package:
|
||||
|
||||
```sh
|
||||
./build.sh
|
||||
(cd ../../.. && go run ./cmd/gateway plugin test examples/plugins/mc-auth-proxy --profile manifest)
|
||||
(cd ../../.. && go run ./cmd/gateway plugin build examples/plugins/mc-auth-proxy --type both)
|
||||
```
|
||||
|
||||
The binary package is written to `dist/mc-auth-proxy.mcgp`; the source package
|
||||
is written to `dist/mc-auth-proxy-source.mcgp`.
|
||||
The source manifest is maintained as `manifest.yaml`; packaged `.mcgp` artifacts
|
||||
still contain canonical `manifest.json`.
|
||||
|
||||
Build the source package through the gateway builder:
|
||||
|
||||
```sh
|
||||
go run ../../../cmd/gateway plugin source-build dist/mc-auth-proxy-source.mcgp dist/mc-auth-proxy-built.mcgp
|
||||
(cd ../../.. && go run ./cmd/gateway plugin build --from-source examples/plugins/mc-auth-proxy/dist/mc-auth-proxy-source.mcgp --out examples/plugins/mc-auth-proxy/dist/mc-auth-proxy-built.mcgp)
|
||||
```
|
||||
|
||||
Example config JSON:
|
||||
|
||||
@@ -1,23 +1,8 @@
|
||||
#!/usr/bin/env sh
|
||||
# examples/plugins/mc-auth-proxy/build.sh 是示例插件代码,用于演示托管插件接入方式。
|
||||
|
||||
set -eu
|
||||
|
||||
mkdir -p dist
|
||||
go build -buildmode=plugin -o dist/plugin.so .
|
||||
go run ./cmd/render-manifest > dist/manifest.json
|
||||
cp README.md dist/README.md
|
||||
(
|
||||
cd dist
|
||||
rm -f mc-auth-proxy.mcgp
|
||||
zip -q mc-auth-proxy.mcgp manifest.json plugin.so README.md
|
||||
)
|
||||
rm -rf dist/source-package
|
||||
mkdir -p dist/source-package/cmd/render-manifest
|
||||
ARTIFACT_TYPE=source go run ./cmd/render-manifest > dist/source-package/manifest.json
|
||||
cp main.go main_test.go go.mod README.md dist/source-package/
|
||||
cp cmd/render-manifest/main.go dist/source-package/cmd/render-manifest/main.go
|
||||
go mod vendor -o dist/source-package/vendor
|
||||
(
|
||||
cd dist/source-package
|
||||
rm -f ../mc-auth-proxy-source.mcgp
|
||||
zip -qr ../mc-auth-proxy-source.mcgp manifest.json main.go main_test.go go.mod README.md cmd/render-manifest/main.go vendor
|
||||
)
|
||||
repo_root=$(cd ../../.. && pwd)
|
||||
cd "$repo_root"
|
||||
go run ./cmd/gateway plugin build examples/plugins/mc-auth-proxy --type both --skip-tests
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := renderManifest(os.Stdout, os.Getenv("ARTIFACT_TYPE")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func renderManifest(out *os.File, artifactType string) error {
|
||||
data, err := os.ReadFile("manifest.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var manifest map[string]any
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
if artifactType != "" {
|
||||
manifest["artifact_type"] = artifactType
|
||||
}
|
||||
manifest["go_version"] = runtime.Version()
|
||||
manifest["go_os"] = runtime.GOOS
|
||||
manifest["go_arch"] = runtime.GOARCH
|
||||
if manifest["artifact_type"] == "source" {
|
||||
manifest["build"] = map[string]any{
|
||||
"type": "go",
|
||||
"entry": ".",
|
||||
"go_version": runtime.Version(),
|
||||
"cgo_enabled": true,
|
||||
"tags": []string{},
|
||||
"vendor_required": false,
|
||||
"output": "plugin.so",
|
||||
}
|
||||
}
|
||||
encoder := json.NewEncoder(out)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(manifest)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// examples/plugins/mc-auth-proxy/main.go 演示托管插件如何拦截登录流量、发出认证事件并按条件拒绝客户端。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// examples/plugins/mc-auth-proxy/main_test.go 包含用于约束 mc auth proxy 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
{
|
||||
"schema_version": "mc-gateway.plugin/v1",
|
||||
"id": "mc-auth-proxy",
|
||||
"name": "Minecraft Auth Proxy",
|
||||
"version": "0.1.0",
|
||||
"description": "Protocol-proxy example that owns Minecraft login handling and returns a stable fixture disconnect.",
|
||||
"artifact_type": "binary",
|
||||
"runtime": {
|
||||
"type": "go-plugin",
|
||||
"entry": "plugin.so",
|
||||
"entry_symbol": "Plugin"
|
||||
},
|
||||
"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": "hook", "key": "upstream.connect/v1" }
|
||||
],
|
||||
"capabilities": {
|
||||
"upstream_connect": { "mode": "protocol-proxy" },
|
||||
"minecraft": {
|
||||
"protocol_versions": {
|
||||
"min": 47,
|
||||
"max": 767,
|
||||
"tested": [47, 760, 763, 767],
|
||||
"unsupported_policy": "kick"
|
||||
},
|
||||
"states": {
|
||||
"status": "transparent",
|
||||
"login": "handled",
|
||||
"configuration": "transparent",
|
||||
"play": "transparent"
|
||||
},
|
||||
"auth_modes": ["fixture"],
|
||||
"forwarding": {
|
||||
"supported": ["none", "velocity-modern"],
|
||||
"default": "none",
|
||||
"requires_secret": false
|
||||
},
|
||||
"unsupported_policy": "kick",
|
||||
"modded": {
|
||||
"forge": "transparent",
|
||||
"fabric": "transparent",
|
||||
"fml": "unsupported",
|
||||
"unknown": "pass"
|
||||
}
|
||||
}
|
||||
},
|
||||
"runtime_limits": {
|
||||
"handler_timeout_ms": 3000,
|
||||
"initial_write_timeout_ms": 1000
|
||||
},
|
||||
"events": [
|
||||
{ "name": "auth.success", "fields": ["result", "mode"] },
|
||||
{ "name": "auth.failure", "fields": ["result", "mode"] }
|
||||
],
|
||||
"custom_metrics": [
|
||||
{ "name": "auth.attempts", "type": "counter", "labels": ["result", "mode"] }
|
||||
],
|
||||
"external_dependencies": [
|
||||
{
|
||||
"name": "backend",
|
||||
"endpoint": "tcp://",
|
||||
"purpose": "auth",
|
||||
"required": true,
|
||||
"timeout": "3s",
|
||||
"retry": 0,
|
||||
"fail_policy": "fail_closed",
|
||||
"data_classes": ["operational"]
|
||||
}
|
||||
],
|
||||
"background_tasks": [
|
||||
{ "id": "profile-cache-gc", "name": "Profile cache GC", "mode": "manual", "manual": true, "timeout": "1s" }
|
||||
],
|
||||
"data_stores": [
|
||||
{ "name": "profile-cache", "schema_version": 1, "data_class": "profile_cache", "quota_bytes": 1048576, "retention": "24h", "exportable": false }
|
||||
],
|
||||
"file_stores": [
|
||||
{ "namespace": "cache", "data_class": "profile_cache", "quota_bytes": 1048576, "retention": "24h" },
|
||||
{ "namespace": "diagnostic", "data_class": "diagnostic", "quota_bytes": 1048576, "retention": "24h" }
|
||||
],
|
||||
"config_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"match_host": { "type": "string" },
|
||||
"fixture_accept": { "type": "boolean" },
|
||||
"disconnect_message": { "type": "string" },
|
||||
"backend": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
115
examples/plugins/mc-auth-proxy/manifest.yaml
Normal file
115
examples/plugins/mc-auth-proxy/manifest.yaml
Normal file
@@ -0,0 +1,115 @@
|
||||
# examples/plugins/mc-auth-proxy/manifest.yaml 是示例插件代码,用于演示托管插件接入方式。
|
||||
|
||||
# 人工维护的插件清单;构建插件包时会规范化为 manifest.json。
|
||||
schema_version: mc-gateway.plugin/v1
|
||||
id: mc-auth-proxy
|
||||
name: Minecraft Auth Proxy
|
||||
version: 0.1.0
|
||||
description: Protocol-proxy example that owns Minecraft login handling and returns a stable fixture disconnect.
|
||||
artifact_type: binary
|
||||
runtime:
|
||||
type: go-plugin
|
||||
entry: plugin.so
|
||||
entry_symbol: Plugin
|
||||
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: hook
|
||||
key: upstream.connect/v1
|
||||
capabilities:
|
||||
upstream_connect:
|
||||
mode: protocol-proxy
|
||||
minecraft:
|
||||
protocol_versions:
|
||||
min: 47
|
||||
max: 767
|
||||
tested:
|
||||
- 47
|
||||
- 760
|
||||
- 763
|
||||
- 767
|
||||
unsupported_policy: kick
|
||||
states:
|
||||
status: transparent
|
||||
login: handled
|
||||
configuration: transparent
|
||||
play: transparent
|
||||
auth_modes:
|
||||
- fixture
|
||||
forwarding:
|
||||
supported:
|
||||
- none
|
||||
- velocity-modern
|
||||
default: none
|
||||
requires_secret: false
|
||||
unsupported_policy: kick
|
||||
modded:
|
||||
forge: transparent
|
||||
fabric: transparent
|
||||
fml: unsupported
|
||||
unknown: pass
|
||||
runtime_limits:
|
||||
handler_timeout_ms: 3000
|
||||
initial_write_timeout_ms: 1000
|
||||
events:
|
||||
- name: auth.success
|
||||
fields:
|
||||
- result
|
||||
- mode
|
||||
- name: auth.failure
|
||||
fields:
|
||||
- result
|
||||
- mode
|
||||
custom_metrics:
|
||||
- name: auth.attempts
|
||||
type: counter
|
||||
labels:
|
||||
- result
|
||||
- mode
|
||||
external_dependencies:
|
||||
- name: backend
|
||||
endpoint: tcp://
|
||||
purpose: auth
|
||||
required: true
|
||||
timeout: 3s
|
||||
retry: 0
|
||||
fail_policy: fail_closed
|
||||
data_classes:
|
||||
- operational
|
||||
background_tasks:
|
||||
- id: profile-cache-gc
|
||||
name: Profile cache GC
|
||||
mode: manual
|
||||
manual: true
|
||||
timeout: 1s
|
||||
data_stores:
|
||||
- name: profile-cache
|
||||
schema_version: 1
|
||||
data_class: profile_cache
|
||||
quota_bytes: 1048576
|
||||
retention: 24h
|
||||
exportable: false
|
||||
file_stores:
|
||||
- namespace: cache
|
||||
data_class: profile_cache
|
||||
quota_bytes: 1048576
|
||||
retention: 24h
|
||||
- namespace: diagnostic
|
||||
data_class: diagnostic
|
||||
quota_bytes: 1048576
|
||||
retention: 24h
|
||||
config_schema:
|
||||
type: object
|
||||
properties:
|
||||
match_host:
|
||||
type: string
|
||||
fixture_accept:
|
||||
type: boolean
|
||||
disconnect_message:
|
||||
type: string
|
||||
backend:
|
||||
type: string
|
||||
5
examples/plugins/mc-auth-proxy/testdata/config.json
vendored
Normal file
5
examples/plugins/mc-auth-proxy/testdata/config.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"match_host": "play.example",
|
||||
"fixture_accept": false,
|
||||
"disconnect_message": "Authentication fixture rejected the login"
|
||||
}
|
||||
@@ -8,16 +8,19 @@ return `api.ErrPass`.
|
||||
Build and package:
|
||||
|
||||
```sh
|
||||
./build.sh
|
||||
(cd ../../.. && go run ./cmd/gateway plugin test examples/plugins/upstream-rewrite --profile manifest)
|
||||
(cd ../../.. && go run ./cmd/gateway plugin build examples/plugins/upstream-rewrite --type both)
|
||||
```
|
||||
|
||||
The binary package is written to `dist/upstream-rewrite.mcgp`; the source
|
||||
package is written to `dist/upstream-rewrite-source.mcgp`.
|
||||
The source manifest is maintained as `manifest.yaml`; packaged `.mcgp` artifacts
|
||||
still contain canonical `manifest.json`.
|
||||
|
||||
Build the source package through the gateway builder:
|
||||
|
||||
```sh
|
||||
go run ../../../cmd/gateway plugin source-build dist/upstream-rewrite-source.mcgp dist/upstream-rewrite-built.mcgp
|
||||
(cd ../../.. && go run ./cmd/gateway plugin build --from-source examples/plugins/upstream-rewrite/dist/upstream-rewrite-source.mcgp --out examples/plugins/upstream-rewrite/dist/upstream-rewrite-built.mcgp)
|
||||
```
|
||||
|
||||
Example config JSON:
|
||||
|
||||
@@ -1,23 +1,8 @@
|
||||
#!/usr/bin/env sh
|
||||
# examples/plugins/upstream-rewrite/build.sh 是示例插件代码,用于演示托管插件接入方式。
|
||||
|
||||
set -eu
|
||||
|
||||
mkdir -p dist
|
||||
go build -buildmode=plugin -o dist/plugin.so .
|
||||
go run ./cmd/render-manifest > dist/manifest.json
|
||||
cp README.md dist/README.md
|
||||
(
|
||||
cd dist
|
||||
rm -f upstream-rewrite.mcgp
|
||||
zip -q upstream-rewrite.mcgp manifest.json plugin.so README.md
|
||||
)
|
||||
rm -rf dist/source-package
|
||||
mkdir -p dist/source-package/cmd/render-manifest
|
||||
ARTIFACT_TYPE=source go run ./cmd/render-manifest > dist/source-package/manifest.json
|
||||
cp main.go go.mod README.md dist/source-package/
|
||||
cp cmd/render-manifest/main.go dist/source-package/cmd/render-manifest/main.go
|
||||
go mod vendor -o dist/source-package/vendor
|
||||
(
|
||||
cd dist/source-package
|
||||
rm -f ../upstream-rewrite-source.mcgp
|
||||
zip -qr ../upstream-rewrite-source.mcgp manifest.json main.go go.mod README.md cmd/render-manifest/main.go vendor
|
||||
)
|
||||
repo_root=$(cd ../../.. && pwd)
|
||||
cd "$repo_root"
|
||||
go run ./cmd/gateway plugin build examples/plugins/upstream-rewrite --type both --skip-tests
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := renderManifest(os.Stdout, os.Getenv("ARTIFACT_TYPE")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func renderManifest(out *os.File, artifactType string) error {
|
||||
data, err := os.ReadFile("manifest.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var manifest map[string]any
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
if artifactType != "" {
|
||||
manifest["artifact_type"] = artifactType
|
||||
}
|
||||
manifest["go_version"] = runtime.Version()
|
||||
manifest["go_os"] = runtime.GOOS
|
||||
manifest["go_arch"] = runtime.GOARCH
|
||||
if manifest["artifact_type"] == "source" {
|
||||
manifest["build"] = map[string]any{
|
||||
"type": "go",
|
||||
"entry": ".",
|
||||
"go_version": runtime.Version(),
|
||||
"cgo_enabled": true,
|
||||
"tags": []string{},
|
||||
"vendor_required": false,
|
||||
"output": "plugin.so",
|
||||
}
|
||||
}
|
||||
encoder := json.NewEncoder(out)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(manifest)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// examples/plugins/upstream-rewrite/main.go 演示托管插件如何在网关拨号上游前改写路由决策。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"schema_version": "mc-gateway.plugin/v1",
|
||||
"id": "upstream-rewrite",
|
||||
"name": "Upstream Rewrite",
|
||||
"version": "0.1.0",
|
||||
"description": "Rewrite selected upstream targets before dialing.",
|
||||
"artifact_type": "binary",
|
||||
"runtime": {
|
||||
"type": "go-plugin",
|
||||
"entry": "plugin.so",
|
||||
"entry_symbol": "Plugin"
|
||||
},
|
||||
"api_version": "plugin-api/v1",
|
||||
"sdk_module": "github.com/tursom/mc-gateway/plugin/api",
|
||||
"sdk_module_version": "v0.1.0",
|
||||
"go_version": "go1.24.4",
|
||||
"go_os": "linux",
|
||||
"go_arch": "amd64",
|
||||
"extension_points": [
|
||||
{ "type": "hook", "key": "upstream.connect/v1" }
|
||||
],
|
||||
"capabilities": {
|
||||
"extension_points": ["upstream.connect/v1"],
|
||||
"network": { "outbound": ["tcp:*:*"] },
|
||||
"filesystem": { "read": [], "write": [] },
|
||||
"env": []
|
||||
},
|
||||
"runtime_limits": {
|
||||
"handler_timeout_ms": 3000
|
||||
},
|
||||
"config_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"match_host": { "type": "string" },
|
||||
"upstream": { "type": "string" }
|
||||
},
|
||||
"required": ["upstream"]
|
||||
}
|
||||
}
|
||||
43
examples/plugins/upstream-rewrite/manifest.yaml
Normal file
43
examples/plugins/upstream-rewrite/manifest.yaml
Normal file
@@ -0,0 +1,43 @@
|
||||
# examples/plugins/upstream-rewrite/manifest.yaml 是示例插件代码,用于演示托管插件接入方式。
|
||||
|
||||
# 人工维护的插件清单;构建插件包时会规范化为 manifest.json。
|
||||
schema_version: mc-gateway.plugin/v1
|
||||
id: upstream-rewrite
|
||||
name: Upstream Rewrite
|
||||
version: 0.1.0
|
||||
description: Rewrite selected upstream targets before dialing.
|
||||
artifact_type: binary
|
||||
runtime:
|
||||
type: go-plugin
|
||||
entry: plugin.so
|
||||
entry_symbol: Plugin
|
||||
api_version: plugin-api/v1
|
||||
sdk_module: github.com/tursom/mc-gateway/plugin/api
|
||||
sdk_module_version: v0.1.0
|
||||
go_version: go1.24.4
|
||||
go_os: linux
|
||||
go_arch: amd64
|
||||
extension_points:
|
||||
- type: hook
|
||||
key: upstream.connect/v1
|
||||
capabilities:
|
||||
extension_points:
|
||||
- upstream.connect/v1
|
||||
network:
|
||||
outbound:
|
||||
- tcp:*:*
|
||||
filesystem:
|
||||
read: []
|
||||
write: []
|
||||
env: []
|
||||
runtime_limits:
|
||||
handler_timeout_ms: 3000
|
||||
config_schema:
|
||||
type: object
|
||||
properties:
|
||||
match_host:
|
||||
type: string
|
||||
upstream:
|
||||
type: string
|
||||
required:
|
||||
- upstream
|
||||
4
examples/plugins/upstream-rewrite/testdata/config.json
vendored
Normal file
4
examples/plugins/upstream-rewrite/testdata/config.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"match_host": "play.example",
|
||||
"upstream": "127.0.0.1:25566"
|
||||
}
|
||||
2
go.mod
2
go.mod
@@ -7,11 +7,13 @@ toolchain go1.24.4
|
||||
require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/pelletier/go-toml/v2 v2.4.2
|
||||
github.com/pires/go-proxyproto v0.8.1
|
||||
github.com/quic-go/quic-go v0.52.0
|
||||
github.com/rs/zerolog v1.33.0
|
||||
github.com/xtaci/kcp-go v5.4.20+incompatible
|
||||
golang.org/x/crypto v0.43.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.45.0
|
||||
)
|
||||
|
||||
|
||||
3
go.sum
3
go.sum
@@ -62,6 +62,8 @@ github.com/onsi/ginkgo/v2 v2.9.5 h1:+6Hr4uxzP4XIUyAkg61dWBw8lb/gc4/X5luuxN/EC+Q=
|
||||
github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k=
|
||||
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
|
||||
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
|
||||
github.com/pelletier/go-toml/v2 v2.4.2 h1:M2fKKbmyvI+hGId/D0W64qDBMVhJnNR10O5gIbMc//Q=
|
||||
github.com/pelletier/go-toml/v2 v2.4.2/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0=
|
||||
github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
@@ -157,6 +159,7 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user