feat(plugin): add governance release gates
Some checks failed
Go / build (.exe, 386, windows, windows-386) (push) Has been cancelled
Go / build (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Go / build (.exe, arm64, windows, windows-arm64) (push) Has been cancelled
Go / build (386, freebsd, freebsd-386) (push) Has been cancelled
Go / build (386, linux, linux-386) (push) Has been cancelled
Go / build (386, netbsd, netbsd-386) (push) Has been cancelled
Go / build (386, openbsd, openbsd-386) (push) Has been cancelled
Go / build (386, plan9, plan9-386) (push) Has been cancelled
Go / build (amd64, darwin, darwin-amd64) (push) Has been cancelled
Go / build (amd64, dragonfly, dragonfly-amd64) (push) Has been cancelled
Go / build (amd64, freebsd, freebsd-amd64) (push) Has been cancelled
Go / build (amd64, illumos, illumos-amd64) (push) Has been cancelled
Go / build (amd64, linux, linux-amd64) (push) Has been cancelled
Go / build (amd64, netbsd, netbsd-amd64) (push) Has been cancelled
Go / build (amd64, openbsd, openbsd-amd64) (push) Has been cancelled
Go / build (amd64, plan9, plan9-amd64) (push) Has been cancelled
Go / build (amd64, solaris, solaris-amd64) (push) Has been cancelled
Go / build (arm, 6, linux, linux-armv6) (push) Has been cancelled
Go / build (arm, 7, linux, linux-armv7) (push) Has been cancelled
Go / build (arm, freebsd, freebsd-arm) (push) Has been cancelled
Go / build (arm, netbsd, netbsd-arm) (push) Has been cancelled
Go / build (arm, openbsd, openbsd-arm) (push) Has been cancelled
Go / build (arm, plan9, plan9-arm) (push) Has been cancelled
Go / build (arm64, darwin, darwin-arm64) (push) Has been cancelled
Go / build (arm64, freebsd, freebsd-arm64) (push) Has been cancelled
Go / build (arm64, linux, linux-arm64) (push) Has been cancelled
Go / build (arm64, netbsd, netbsd-arm64) (push) Has been cancelled
Go / build (arm64, openbsd, openbsd-arm64) (push) Has been cancelled
Go / build (loong64, linux, linux-loong64) (push) Has been cancelled
Go / build (mips, linux, linux-mips) (push) Has been cancelled
Go / build (mips64, linux, linux-mips64) (push) Has been cancelled
Go / build (mips64le, linux, linux-mips64le) (push) Has been cancelled
Go / build (mipsle, linux, linux-mipsle) (push) Has been cancelled
Go / build (ppc64, aix, aix-ppc64) (push) Has been cancelled
Go / build (ppc64, linux, linux-ppc64) (push) Has been cancelled
Go / build (ppc64, openbsd, openbsd-ppc64) (push) Has been cancelled
Go / build (ppc64le, linux, linux-ppc64le) (push) Has been cancelled
Go / build (riscv64, freebsd, freebsd-riscv64) (push) Has been cancelled
Go / build (riscv64, linux, linux-riscv64) (push) Has been cancelled
Go / build (riscv64, openbsd, openbsd-riscv64) (push) Has been cancelled
Go / build (s390x, linux, linux-s390x) (push) Has been cancelled
Go / merge-artifacts (push) Has been cancelled
Docker Image / docker (push) Has been cancelled
Some checks failed
Go / build (.exe, 386, windows, windows-386) (push) Has been cancelled
Go / build (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Go / build (.exe, arm64, windows, windows-arm64) (push) Has been cancelled
Go / build (386, freebsd, freebsd-386) (push) Has been cancelled
Go / build (386, linux, linux-386) (push) Has been cancelled
Go / build (386, netbsd, netbsd-386) (push) Has been cancelled
Go / build (386, openbsd, openbsd-386) (push) Has been cancelled
Go / build (386, plan9, plan9-386) (push) Has been cancelled
Go / build (amd64, darwin, darwin-amd64) (push) Has been cancelled
Go / build (amd64, dragonfly, dragonfly-amd64) (push) Has been cancelled
Go / build (amd64, freebsd, freebsd-amd64) (push) Has been cancelled
Go / build (amd64, illumos, illumos-amd64) (push) Has been cancelled
Go / build (amd64, linux, linux-amd64) (push) Has been cancelled
Go / build (amd64, netbsd, netbsd-amd64) (push) Has been cancelled
Go / build (amd64, openbsd, openbsd-amd64) (push) Has been cancelled
Go / build (amd64, plan9, plan9-amd64) (push) Has been cancelled
Go / build (amd64, solaris, solaris-amd64) (push) Has been cancelled
Go / build (arm, 6, linux, linux-armv6) (push) Has been cancelled
Go / build (arm, 7, linux, linux-armv7) (push) Has been cancelled
Go / build (arm, freebsd, freebsd-arm) (push) Has been cancelled
Go / build (arm, netbsd, netbsd-arm) (push) Has been cancelled
Go / build (arm, openbsd, openbsd-arm) (push) Has been cancelled
Go / build (arm, plan9, plan9-arm) (push) Has been cancelled
Go / build (arm64, darwin, darwin-arm64) (push) Has been cancelled
Go / build (arm64, freebsd, freebsd-arm64) (push) Has been cancelled
Go / build (arm64, linux, linux-arm64) (push) Has been cancelled
Go / build (arm64, netbsd, netbsd-arm64) (push) Has been cancelled
Go / build (arm64, openbsd, openbsd-arm64) (push) Has been cancelled
Go / build (loong64, linux, linux-loong64) (push) Has been cancelled
Go / build (mips, linux, linux-mips) (push) Has been cancelled
Go / build (mips64, linux, linux-mips64) (push) Has been cancelled
Go / build (mips64le, linux, linux-mips64le) (push) Has been cancelled
Go / build (mipsle, linux, linux-mipsle) (push) Has been cancelled
Go / build (ppc64, aix, aix-ppc64) (push) Has been cancelled
Go / build (ppc64, linux, linux-ppc64) (push) Has been cancelled
Go / build (ppc64, openbsd, openbsd-ppc64) (push) Has been cancelled
Go / build (ppc64le, linux, linux-ppc64le) (push) Has been cancelled
Go / build (riscv64, freebsd, freebsd-riscv64) (push) Has been cancelled
Go / build (riscv64, linux, linux-riscv64) (push) Has been cancelled
Go / build (riscv64, openbsd, openbsd-riscv64) (push) Has been cancelled
Go / build (s390x, linux, linux-s390x) (push) Has been cancelled
Go / merge-artifacts (push) Has been cancelled
Docker Image / docker (push) Has been cancelled
This commit is contained in:
@@ -43,5 +43,7 @@ func newAdminAPIHandler() http.HandlerFunc {
|
||||
PluginRollback: handleAdminPluginRollback,
|
||||
PluginDraining: handleAdminPluginDraining,
|
||||
PluginDispatch: handleAdminPluginDispatchPlan,
|
||||
PluginGovernance: handleAdminPluginGovernance,
|
||||
PluginAdvisories: handleAdminPluginAdvisories,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -314,6 +314,105 @@ func TestAdminPluginPhase4API(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPluginPhase5GovernanceAPI(t *testing.T) {
|
||||
handler := newAdminTestHandlerWithAdmin(t)
|
||||
pluginsManager = pluginmanager.New(pluginmanager.Options{
|
||||
DB: adminDB,
|
||||
ArtifactRoot: filepath.Join(filepath.Dir(adminDBPath), "plugins", "artifacts"),
|
||||
Adapter: gatewayTestPluginAdapter{},
|
||||
})
|
||||
adminToken := adminTestLogin(t, handler, "admin", "secret")
|
||||
resp := adminTestRequest(t, handler, http.MethodPost, "/admin/api/users", adminToken, map[string]any{
|
||||
"username": "member",
|
||||
"role": "member",
|
||||
"password": "member-secret",
|
||||
})
|
||||
if resp.Code != http.StatusCreated {
|
||||
t.Fatalf("create member status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
memberToken := adminTestLogin(t, handler, "member", "member-secret")
|
||||
|
||||
artifact := uploadGatewayPhase5ProtocolProxyArtifact(t, "phase5-proxy")
|
||||
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "phase5-proxy", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/plugins/phase5-proxy/governance?profile=prod", memberToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("member governance status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "review_required") {
|
||||
t.Fatalf("governance status body = %s, want review_required", resp.Body.String())
|
||||
}
|
||||
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase5-proxy/governance/review", memberToken, map[string]any{
|
||||
"artifact_id": artifact.ID,
|
||||
"profile": pluginmanager.PolicyProfileProd,
|
||||
})
|
||||
if resp.Code != http.StatusForbidden {
|
||||
t.Fatalf("member review write status = %d, want forbidden; body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase5-proxy/governance/review", adminToken, map[string]any{
|
||||
"artifact_id": artifact.ID,
|
||||
"profile": pluginmanager.PolicyProfileProd,
|
||||
"decision": pluginmanager.ReviewDecisionApproved,
|
||||
"notes": "phase 5 approval",
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("admin review write status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase5-proxy/governance/preflight", adminToken, map[string]any{
|
||||
"artifact_id": artifact.ID,
|
||||
"profile": pluginmanager.PolicyProfileProd,
|
||||
"action": pluginmanager.GovernanceActionEnable,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("preflight status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase5-proxy/governance/benchmark", adminToken, map[string]any{
|
||||
"artifact_id": artifact.ID,
|
||||
"profile": pluginmanager.PolicyProfileProd,
|
||||
"benchmark_profile": "release",
|
||||
"p95_ms": 10,
|
||||
"p99_ms": 20,
|
||||
"baseline_diff": 0.25,
|
||||
"active_proxy_capacity": 100,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("benchmark status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase5-proxy/governance/override", adminToken, map[string]any{
|
||||
"artifact_id": artifact.ID,
|
||||
"profile": pluginmanager.PolicyProfileProd,
|
||||
"action": pluginmanager.GovernanceActionEnable,
|
||||
"reason": "accepted warning for rollout",
|
||||
"ttl_seconds": 3600,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("override status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugin-advisories", adminToken, map[string]any{
|
||||
"advisory_id": "MCG-2026-ADMIN",
|
||||
"status": pluginmanager.AdvisoryStatusRevoked,
|
||||
"action": pluginmanager.AdvisoryActionRevoke,
|
||||
"artifact_sha256": artifact.SHA256,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("advisory status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/plugin-advisories?plugin_id=phase5-proxy", memberToken, nil)
|
||||
if resp.Code != http.StatusOK || !strings.Contains(resp.Body.String(), "MCG-2026-ADMIN") {
|
||||
t.Fatalf("member advisory read status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/audit-logs", adminToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("audit status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
for _, action := range []string{"plugin_governance_review", "plugin_governance_override", "plugin_governance_advisory"} {
|
||||
if !strings.Contains(resp.Body.String(), action) {
|
||||
t.Fatalf("audit body missing %s: %s", action, resp.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func uploadGatewayPhase4Artifact(t *testing.T, pluginID string) pluginmanager.ArtifactRecord {
|
||||
t.Helper()
|
||||
var manifest pluginmanager.Manifest
|
||||
@@ -347,6 +446,31 @@ func uploadGatewayPhase4Artifact(t *testing.T, pluginID string) pluginmanager.Ar
|
||||
return artifact
|
||||
}
|
||||
|
||||
func uploadGatewayPhase5ProtocolProxyArtifact(t *testing.T, pluginID string) pluginmanager.ArtifactRecord {
|
||||
t.Helper()
|
||||
var manifest pluginmanager.Manifest
|
||||
if err := json.Unmarshal(gatewayTestManifestWithCapabilities(t, pluginID, gatewayProtocolProxyCapabilities()), &manifest); err != nil {
|
||||
t.Fatalf("Unmarshal manifest error = %v", err)
|
||||
}
|
||||
manifest.RuntimeLimits = pluginmanager.RuntimeLimits{HandlerTimeoutMS: 3000}
|
||||
manifestBytes, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal manifest error = %v", err)
|
||||
}
|
||||
artifact, err := pluginsManager.UploadArtifact(context.Background(), pluginmanager.ArtifactUpload{
|
||||
SourcePath: writeGatewayTestMCGPEntries(t, map[string][]byte{
|
||||
"manifest.json": manifestBytes,
|
||||
"plugin.so": []byte("fake plugin bytes " + pluginID),
|
||||
}),
|
||||
FileName: pluginID + ".mcgp",
|
||||
Actor: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UploadArtifact() error = %v", err)
|
||||
}
|
||||
return artifact
|
||||
}
|
||||
|
||||
func newAdminTestHandler(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
t.Cleanup(saveGatewayState(t))
|
||||
|
||||
@@ -117,6 +117,42 @@ export interface PluginProxyConnection {
|
||||
draining: boolean;
|
||||
}
|
||||
|
||||
export interface GovernanceIssue {
|
||||
code: string;
|
||||
severity: string;
|
||||
message: string;
|
||||
plugin_id?: string;
|
||||
artifact_id?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GovernanceDecision {
|
||||
ok: boolean;
|
||||
action: string;
|
||||
profile: string;
|
||||
risk_level: string;
|
||||
policy_hash: string;
|
||||
review_required: boolean;
|
||||
warning_override_used: boolean;
|
||||
issues?: GovernanceIssue[];
|
||||
checks?: GovernanceIssue[];
|
||||
}
|
||||
|
||||
export interface GovernanceStatus {
|
||||
decision?: GovernanceDecision;
|
||||
policy?: Record<string, unknown>;
|
||||
reviews?: Record<string, unknown>[];
|
||||
warning_overrides?: Record<string, unknown>[];
|
||||
preflights?: Record<string, unknown>[];
|
||||
benchmarks?: Record<string, unknown>[];
|
||||
advisories?: Record<string, unknown>[];
|
||||
conflicts?: {
|
||||
ok: boolean;
|
||||
issues?: GovernanceIssue[];
|
||||
plan?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PluginView {
|
||||
id: string;
|
||||
name?: string;
|
||||
@@ -151,6 +187,8 @@ export interface PluginView {
|
||||
manifest?: Record<string, unknown>;
|
||||
active_proxy_connections?: number;
|
||||
proxy_connections?: PluginProxyConnection[];
|
||||
governance?: GovernanceStatus;
|
||||
governance_error?: string;
|
||||
updated_at?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -167,6 +167,10 @@ export function renderPluginDetail(plugin: PluginView | null = selectedPlugin())
|
||||
<dt>Minecraft</dt><dd>${escapeHTML(formatJSON(plugin.minecraft))}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Governance</h3>
|
||||
${governancePanel(plugin, canWrite)}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Config</h3>
|
||||
<textarea id="pluginConfigEditor" ${canWrite ? "" : "readonly"}>${escapeHTML(prettyJSON(plugin.config_json || "{}"))}</textarea>
|
||||
@@ -262,6 +266,12 @@ function bindPluginDetailEvents(plugin: PluginView): void {
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-snapshot-diff]").forEach((button) => {
|
||||
button.addEventListener("click", () => showSnapshotDiff(plugin.id, Number(button.dataset.snapshotDiff || "0")));
|
||||
});
|
||||
document.getElementById("pluginGovernanceReviewBtn")?.addEventListener("click", () => createGovernanceReview(plugin));
|
||||
document.getElementById("pluginGovernanceOverrideBtn")?.addEventListener("click", () => createGovernanceOverride(plugin));
|
||||
document.getElementById("pluginGovernancePreflightBtn")?.addEventListener("click", () => runGovernancePreflight(plugin));
|
||||
document.getElementById("pluginGovernanceSelfTestBtn")?.addEventListener("click", () => runGovernanceSelfTest(plugin));
|
||||
document.getElementById("pluginGovernanceBenchmarkBtn")?.addEventListener("click", () => recordGovernanceBenchmark(plugin));
|
||||
document.getElementById("pluginGovernanceAdvisoryBtn")?.addEventListener("click", () => createArtifactRevokeAdvisory(plugin));
|
||||
}
|
||||
|
||||
async function dryRunConfig(plugin: PluginView): Promise<void> {
|
||||
@@ -413,6 +423,117 @@ async function showSnapshotDiff(pluginID: string, snapshotID: number): Promise<v
|
||||
}
|
||||
}
|
||||
|
||||
async function createGovernanceReview(plugin: PluginView): Promise<void> {
|
||||
try {
|
||||
await api(`/plugins/${encodeURIComponent(plugin.id)}/governance/review`, {
|
||||
method: "POST",
|
||||
body: { artifact_id: plugin.desired_artifact_id, profile: "prod", decision: "approved" },
|
||||
});
|
||||
await loadPluginDetail(plugin.id);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function createGovernanceOverride(plugin: PluginView): Promise<void> {
|
||||
const reason = window.prompt("Reason");
|
||||
if (!reason) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
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 },
|
||||
});
|
||||
await loadPluginDetail(plugin.id);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function runGovernancePreflight(plugin: PluginView): Promise<void> {
|
||||
try {
|
||||
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() },
|
||||
});
|
||||
el("pluginDryRunResult").textContent = formatJSON(data);
|
||||
await loadPluginDetail(plugin.id);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function runGovernanceSelfTest(plugin: PluginView): Promise<void> {
|
||||
try {
|
||||
const data = await api<Record<string, unknown>>(`/plugins/${encodeURIComponent(plugin.id)}/governance/self-test`, {
|
||||
method: "POST",
|
||||
body: { artifact_id: plugin.desired_artifact_id },
|
||||
});
|
||||
el("pluginDryRunResult").textContent = formatJSON(data);
|
||||
await loadPluginDetail(plugin.id);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function recordGovernanceBenchmark(plugin: PluginView): Promise<void> {
|
||||
const diff = Number(window.prompt("Baseline diff, e.g. 0.25", "0.25"));
|
||||
if (!Number.isFinite(diff)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api(`/plugins/${encodeURIComponent(plugin.id)}/governance/benchmark`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
artifact_id: plugin.desired_artifact_id,
|
||||
profile: "prod",
|
||||
benchmark_profile: "manual",
|
||||
p95_ms: 0,
|
||||
p99_ms: 0,
|
||||
error_rate: 0,
|
||||
active_proxy_capacity: 0,
|
||||
baseline_diff: diff,
|
||||
},
|
||||
});
|
||||
await loadPluginDetail(plugin.id);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function createArtifactRevokeAdvisory(plugin: PluginView): Promise<void> {
|
||||
const artifact = plugin.desired_artifact;
|
||||
if (!artifact) {
|
||||
return;
|
||||
}
|
||||
const advisoryID = window.prompt("Advisory ID", `local-${shortID(artifact.sha256)}`);
|
||||
if (!advisoryID) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api("/plugin-advisories", {
|
||||
method: "POST",
|
||||
body: {
|
||||
advisory_id: advisoryID,
|
||||
status: "revoked",
|
||||
action: "revoke",
|
||||
artifact_sha256: artifact.sha256,
|
||||
recommended_action: "rollback or upgrade",
|
||||
},
|
||||
});
|
||||
await loadPluginDetail(plugin.id);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function uploadInventoryDetail(): string {
|
||||
const artifact = selectedArtifact();
|
||||
if (!artifact) {
|
||||
@@ -530,6 +651,50 @@ function pluginActionButtons(plugin: PluginView): string {
|
||||
`;
|
||||
}
|
||||
|
||||
function governancePanel(plugin: PluginView, canWrite: boolean): string {
|
||||
if (plugin.governance_error) {
|
||||
return `<div class="alert inline-alert">${escapeHTML(plugin.governance_error)}</div>`;
|
||||
}
|
||||
const governance = plugin.governance;
|
||||
const decision = governance?.decision;
|
||||
const issues = decision?.issues || [];
|
||||
return `
|
||||
<dl class="kv">
|
||||
<dt>Profile</dt><dd>${escapeHTML(decision?.profile || "")}</dd>
|
||||
<dt>Risk</dt><dd>${escapeHTML(decision?.risk_level || "")}</dd>
|
||||
<dt>Policy</dt><dd>${escapeHTML(shortID(decision?.policy_hash || ""))}</dd>
|
||||
<dt>Decision</dt><dd>${badge(decision?.ok ? "allowed" : "blocked", !decision?.ok)}</dd>
|
||||
<dt>Review</dt><dd>${badge(decision?.review_required ? "required" : "not required", Boolean(decision?.review_required))}</dd>
|
||||
<dt>Override</dt><dd>${badge(decision?.warning_override_used ? "used" : "not used", Boolean(decision?.warning_override_used))}</dd>
|
||||
</dl>
|
||||
${issues.length ? `<div class="mini-list">${issues.map((issue) => `
|
||||
<div class="mini-row">
|
||||
<span>${badge(issue.severity, issue.severity !== "info")}</span>
|
||||
<span>${escapeHTML(issue.code)}</span>
|
||||
<span>${escapeHTML(issue.message)}</span>
|
||||
</div>
|
||||
`).join("")}</div>` : `<div class="empty">No governance issues</div>`}
|
||||
${canWrite ? `
|
||||
<div class="row-actions">
|
||||
<button class="secondary" type="button" id="pluginGovernanceReviewBtn">Review</button>
|
||||
<button class="secondary" type="button" id="pluginGovernanceOverrideBtn">Override</button>
|
||||
<button class="secondary" type="button" id="pluginGovernancePreflightBtn">Preflight</button>
|
||||
<button class="secondary" type="button" id="pluginGovernanceSelfTestBtn">Self-test</button>
|
||||
<button class="secondary" type="button" id="pluginGovernanceBenchmarkBtn">Benchmark</button>
|
||||
<button class="danger" type="button" id="pluginGovernanceAdvisoryBtn">Revoke artifact</button>
|
||||
</div>
|
||||
` : ""}
|
||||
<pre class="log-output">${escapeHTML(formatJSON({
|
||||
conflicts: governance?.conflicts,
|
||||
reviews: governance?.reviews || [],
|
||||
warning_overrides: governance?.warning_overrides || [],
|
||||
preflights: governance?.preflights || [],
|
||||
benchmarks: governance?.benchmarks || [],
|
||||
advisories: governance?.advisories || [],
|
||||
}))}</pre>
|
||||
`;
|
||||
}
|
||||
|
||||
function artifactList(artifacts: PluginArtifact[], plugin: PluginView): string {
|
||||
if (artifacts.length === 0) {
|
||||
return `<div class="empty">No artifacts</div>`;
|
||||
|
||||
@@ -671,6 +671,175 @@ func handleAdminPluginDispatchPlan(w http.ResponseWriter, r *http.Request) {
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"dispatch_plan": pluginsManager.DispatchPlan(r.Context())})
|
||||
}
|
||||
|
||||
func handleAdminPluginGovernance(w http.ResponseWriter, r *http.Request, rawSegment string) {
|
||||
session, ok := requireRole(w, r, adminRoleMember)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if pluginsManager == nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusServiceUnavailable, "plugin manager is not initialized")
|
||||
return
|
||||
}
|
||||
pluginID, action, ok := splitPluginSubresource(w, rawSegment, "governance")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case r.Method == http.MethodGet && action == "":
|
||||
status, err := pluginsManager.GovernanceStatus(r.Context(), pluginID, r.URL.Query().Get("artifact_id"), r.URL.Query().Get("profile"))
|
||||
if err != nil {
|
||||
writePluginManagerError(w, err)
|
||||
return
|
||||
}
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"governance": status})
|
||||
case r.Method == http.MethodPost && action == "review":
|
||||
if session.Role != adminRoleAdmin {
|
||||
adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
var req pluginmanager.GovernanceReviewRequest
|
||||
if !adminhttp.DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
review, err := pluginsManager.CreateReview(r.Context(), session.Username, pluginID, req)
|
||||
if err != nil {
|
||||
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_governance_review", "plugin", pluginID, false, err.Error())
|
||||
writePluginManagerError(w, err)
|
||||
return
|
||||
}
|
||||
recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_governance_review", "plugin", pluginID, true, "governance review recorded", map[string]any{
|
||||
"artifact_id": review.ArtifactID,
|
||||
"profile": review.Profile,
|
||||
"policy_hash": review.PolicyHash,
|
||||
"decision": review.Decision,
|
||||
})
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"review": review})
|
||||
case r.Method == http.MethodPost && action == "override":
|
||||
if session.Role != adminRoleAdmin {
|
||||
adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
var req pluginmanager.WarningOverrideRequest
|
||||
if !adminhttp.DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
override, err := pluginsManager.CreateWarningOverride(r.Context(), session.Username, pluginID, req)
|
||||
if err != nil {
|
||||
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_governance_override", "plugin", pluginID, false, err.Error())
|
||||
writePluginManagerError(w, err)
|
||||
return
|
||||
}
|
||||
recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_governance_override", "plugin", pluginID, true, "governance warning override recorded", map[string]any{
|
||||
"artifact_id": override.ArtifactID,
|
||||
"profile": override.Profile,
|
||||
"action": override.Action,
|
||||
"expires_at": override.ExpiresAt,
|
||||
})
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"override": override})
|
||||
case r.Method == http.MethodPost && action == "preflight":
|
||||
if session.Role != adminRoleAdmin {
|
||||
adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
var req pluginmanager.PreflightRequest
|
||||
if !adminhttp.DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := pluginsManager.RunPreflight(r.Context(), session.Username, pluginID, req)
|
||||
if err != nil {
|
||||
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_governance_preflight", "plugin", pluginID, false, err.Error())
|
||||
writePluginManagerError(w, err)
|
||||
return
|
||||
}
|
||||
recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_governance_preflight", "plugin", pluginID, result.OK, "governance preflight completed", map[string]any{"profile": result.Profile})
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"preflight": result})
|
||||
case r.Method == http.MethodPost && action == "self-test":
|
||||
if session.Role != adminRoleAdmin {
|
||||
adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
var req pluginmanager.SelfTestRequest
|
||||
if !adminhttp.DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := pluginsManager.RunSelfTest(r.Context(), session.Username, pluginID, req)
|
||||
if err != nil {
|
||||
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_governance_self_test", "plugin", pluginID, false, err.Error())
|
||||
writePluginManagerError(w, err)
|
||||
return
|
||||
}
|
||||
recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_governance_self_test", "plugin", pluginID, result.OK, "governance self-test completed", map[string]any{"profile": result.Profile})
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"self_test": result})
|
||||
case r.Method == http.MethodPost && action == "benchmark":
|
||||
if session.Role != adminRoleAdmin {
|
||||
adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
var req pluginmanager.BenchmarkRequest
|
||||
if !adminhttp.DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
benchmark, err := pluginsManager.SaveBenchmark(r.Context(), session.Username, req)
|
||||
if err != nil {
|
||||
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_governance_benchmark", "plugin", pluginID, false, err.Error())
|
||||
writePluginManagerError(w, err)
|
||||
return
|
||||
}
|
||||
recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_governance_benchmark", "plugin", pluginID, true, "governance benchmark recorded", map[string]any{
|
||||
"artifact_id": benchmark.ArtifactID,
|
||||
"profile": benchmark.Profile,
|
||||
"baseline_diff": benchmark.BaselineDiff,
|
||||
})
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"benchmark": benchmark})
|
||||
default:
|
||||
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func handleAdminPluginAdvisories(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := requireRole(w, r, adminRoleMember)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if pluginsManager == nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusServiceUnavailable, "plugin manager is not initialized")
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
advisories, err := pluginsManager.ListAdvisories(r.Context(), r.URL.Query().Get("plugin_id"))
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"advisories": advisories})
|
||||
case http.MethodPost:
|
||||
if session.Role != adminRoleAdmin {
|
||||
adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
var req pluginmanager.AdvisoryRequest
|
||||
if !adminhttp.DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
advisory, err := pluginsManager.UpsertAdvisory(r.Context(), session.Username, req)
|
||||
if err != nil {
|
||||
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_governance_advisory", "plugin_advisory", req.AdvisoryID, false, err.Error())
|
||||
writePluginManagerError(w, err)
|
||||
return
|
||||
}
|
||||
recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_governance_advisory", "plugin_advisory", advisory.AdvisoryID, true, "plugin advisory upserted", map[string]any{
|
||||
"action": advisory.Action,
|
||||
"status": advisory.Status,
|
||||
"artifact_sha256": advisory.ArtifactSHA256,
|
||||
"plugin_id": advisory.PluginID,
|
||||
})
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"advisory": advisory})
|
||||
default:
|
||||
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func receivePluginArtifact(r *http.Request, actor string) (pluginmanager.ArtifactRecord, error) {
|
||||
if err := r.ParseMultipartForm(64 << 20); err != nil {
|
||||
return pluginmanager.ArtifactRecord{}, err
|
||||
@@ -870,6 +1039,12 @@ func pluginView(r *http.Request, plugin pluginmanager.PluginRecord, detail bool)
|
||||
if detail {
|
||||
view["manifest"] = manifest
|
||||
view["operations_path"] = "/plugin-artifacts?plugin_id=" + plugin.ID
|
||||
governance, err := pluginsManager.GovernanceStatus(r.Context(), plugin.ID, plugin.DesiredArtifactID, pluginsManager.PolicyProfile())
|
||||
if err != nil {
|
||||
view["governance_error"] = err.Error()
|
||||
} else {
|
||||
view["governance"] = governance
|
||||
}
|
||||
view["active_proxy_connections"] = activeProxyConnections(plugin)
|
||||
connections, err := pluginsManager.ActiveProxyConnections(r.Context(), plugin.ID)
|
||||
if err != nil {
|
||||
|
||||
@@ -90,10 +90,11 @@ func TestHandleRequestProtocolProxyReplaysInitialDataOnce(t *testing.T) {
|
||||
return gatewayEnd, nil
|
||||
}},
|
||||
})
|
||||
artifact := uploadGatewayTestArtifactWithCapabilities(t, pluginsManager, "proxy-plugin", `{"upstream_connect":{"mode":"protocol-proxy"}}`)
|
||||
artifact := uploadGatewayTestArtifactWithCapabilities(t, pluginsManager, "proxy-plugin", gatewayProtocolProxyCapabilities())
|
||||
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "proxy-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
approveGatewayPluginGovernanceForTest(t, "proxy-plugin", artifact.ID)
|
||||
if _, err := pluginsManager.Enable(context.Background(), "admin", "proxy-plugin"); err != nil {
|
||||
t.Fatalf("Enable() error = %v", err)
|
||||
}
|
||||
@@ -216,10 +217,11 @@ func TestHandleRequestProtocolProxyDisableSkipsNewConnections(t *testing.T) {
|
||||
return gatewayEnd, nil
|
||||
}},
|
||||
})
|
||||
artifact := uploadGatewayTestArtifactWithCapabilities(t, pluginsManager, "proxy-plugin", `{"upstream_connect":{"mode":"protocol-proxy"}}`)
|
||||
artifact := uploadGatewayTestArtifactWithCapabilities(t, pluginsManager, "proxy-plugin", gatewayProtocolProxyCapabilities())
|
||||
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "proxy-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
approveGatewayPluginGovernanceForTest(t, "proxy-plugin", artifact.ID)
|
||||
if _, err := pluginsManager.Enable(context.Background(), "admin", "proxy-plugin"); err != nil {
|
||||
t.Fatalf("Enable() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -279,6 +279,18 @@ func uploadGatewayTestArtifactWithCapabilities(t *testing.T, manager *pluginmana
|
||||
return artifact
|
||||
}
|
||||
|
||||
func approveGatewayPluginGovernanceForTest(t *testing.T, pluginID, artifactID string) {
|
||||
t.Helper()
|
||||
if _, err := pluginsManager.CreateReview(context.Background(), "admin", pluginID, pluginmanager.GovernanceReviewRequest{
|
||||
ArtifactID: artifactID,
|
||||
Profile: pluginmanager.PolicyProfileProd,
|
||||
Decision: pluginmanager.ReviewDecisionApproved,
|
||||
Notes: "test approval",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateReview(%s) error = %v", pluginID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeGatewayTestMCGP(t *testing.T, pluginID string) string {
|
||||
return writeGatewayTestMCGPWithCapabilities(t, pluginID, "")
|
||||
}
|
||||
@@ -322,6 +334,18 @@ func gatewayTestManifest(t *testing.T, pluginID string) []byte {
|
||||
return gatewayTestManifestWithCapabilities(t, pluginID, "")
|
||||
}
|
||||
|
||||
func gatewayProtocolProxyCapabilities() string {
|
||||
return `{
|
||||
"upstream_connect":{"mode":"protocol-proxy"},
|
||||
"scope":{"type":"host","values":["play.example"]},
|
||||
"rollout":{"mode":"canary"},
|
||||
"minecraft":{
|
||||
"protocol_versions":{"tested":[767]},
|
||||
"forwarding":{"supported":["none"],"default":"none"}
|
||||
}
|
||||
}`
|
||||
}
|
||||
|
||||
func gatewayTestManifestWithCapabilities(t *testing.T, pluginID string, capabilities string) []byte {
|
||||
t.Helper()
|
||||
if capabilities == "" {
|
||||
|
||||
@@ -209,6 +209,81 @@ CREATE TABLE IF NOT EXISTS plugin_secrets (
|
||||
PRIMARY KEY(plugin_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_reviews (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id TEXT NOT NULL,
|
||||
artifact_id TEXT NOT NULL,
|
||||
profile TEXT NOT NULL DEFAULT 'dev',
|
||||
risk_level TEXT NOT NULL DEFAULT 'low',
|
||||
config_hash TEXT NOT NULL DEFAULT '',
|
||||
scope_hash TEXT NOT NULL DEFAULT '',
|
||||
rollout_hash TEXT NOT NULL DEFAULT '',
|
||||
runtime_limits_hash TEXT NOT NULL DEFAULT '',
|
||||
features_hash TEXT NOT NULL DEFAULT '',
|
||||
policy_hash TEXT NOT NULL DEFAULT '',
|
||||
decision TEXT NOT NULL DEFAULT 'approved',
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
reviewed_by TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_warning_overrides (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id TEXT NOT NULL,
|
||||
artifact_id TEXT NOT NULL,
|
||||
profile TEXT NOT NULL DEFAULT 'dev',
|
||||
action TEXT NOT NULL DEFAULT '',
|
||||
policy_hash TEXT NOT NULL DEFAULT '',
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_advisories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
advisory_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
action TEXT NOT NULL DEFAULT 'denylist',
|
||||
artifact_sha256 TEXT NOT NULL DEFAULT '',
|
||||
plugin_id TEXT NOT NULL DEFAULT '',
|
||||
version_range TEXT NOT NULL DEFAULT '',
|
||||
dependency_name TEXT NOT NULL DEFAULT '',
|
||||
dependency_range TEXT NOT NULL DEFAULT '',
|
||||
recommended_action TEXT NOT NULL DEFAULT '',
|
||||
fixed_version TEXT NOT NULL DEFAULT '',
|
||||
mitigation TEXT NOT NULL DEFAULT '',
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_preflight_results (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id TEXT NOT NULL,
|
||||
artifact_id TEXT NOT NULL,
|
||||
profile TEXT NOT NULL DEFAULT 'dev',
|
||||
status TEXT NOT NULL DEFAULT '',
|
||||
result_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_benchmarks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id TEXT NOT NULL,
|
||||
artifact_id TEXT NOT NULL,
|
||||
profile TEXT NOT NULL DEFAULT 'dev',
|
||||
benchmark_profile TEXT NOT NULL DEFAULT '',
|
||||
p95_ms REAL NOT NULL DEFAULT 0,
|
||||
p99_ms REAL NOT NULL DEFAULT 0,
|
||||
error_rate REAL NOT NULL DEFAULT 0,
|
||||
active_proxy_capacity INTEGER NOT NULL DEFAULT 0,
|
||||
baseline_diff REAL NOT NULL DEFAULT 0,
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_routes_enabled ON routes(enabled);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_artifacts_plugin_id ON plugin_artifacts(plugin_id, created_at);
|
||||
@@ -218,6 +293,12 @@ CREATE INDEX IF NOT EXISTS idx_plugin_builds_plugin_id ON plugin_builds(plugin_i
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_builds_source_id ON plugin_builds(source_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_config_snapshots_plugin_id ON plugin_config_snapshots(plugin_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_secrets_plugin_id ON plugin_secrets(plugin_id, updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_reviews_lookup ON plugin_reviews(plugin_id, artifact_id, profile, policy_hash, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_warning_overrides_lookup ON plugin_warning_overrides(plugin_id, artifact_id, profile, action, expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_advisories_artifact ON plugin_advisories(artifact_sha256, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_advisories_plugin ON plugin_advisories(plugin_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_preflight_lookup ON plugin_preflight_results(plugin_id, artifact_id, profile, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_benchmarks_lookup ON plugin_benchmarks(plugin_id, artifact_id, profile, created_at);
|
||||
INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (1, strftime('%s','now'));
|
||||
`
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
|
||||
@@ -43,6 +43,8 @@ type APIHandlers struct {
|
||||
PluginRollback SegmentHandlerFunc
|
||||
PluginDraining SegmentHandlerFunc
|
||||
PluginDispatch http.HandlerFunc
|
||||
PluginGovernance SegmentHandlerFunc
|
||||
PluginAdvisories http.HandlerFunc
|
||||
}
|
||||
|
||||
func NewAPIHandler(prefix string, handlers APIHandlers) http.HandlerFunc {
|
||||
@@ -100,8 +102,14 @@ func NewAPIHandler(prefix string, handlers APIHandlers) http.HandlerFunc {
|
||||
callHandler(w, r, handlers.PluginsList)
|
||||
case path == "/plugins/dispatch-plan" && r.Method == http.MethodGet:
|
||||
callHandler(w, r, handlers.PluginDispatch)
|
||||
case path == "/plugin-advisories" && (r.Method == http.MethodGet || r.Method == http.MethodPost):
|
||||
callHandler(w, r, handlers.PluginAdvisories)
|
||||
case strings.HasPrefix(path, "/plugins/"):
|
||||
pluginPath := strings.TrimPrefix(path, "/plugins/")
|
||||
if strings.Contains(pluginPath, "/governance/") || strings.HasSuffix(pluginPath, "/governance") {
|
||||
callSegmentHandler(w, r, handlers.PluginGovernance, pluginPath)
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(pluginPath, "/draining/force-close") {
|
||||
callSegmentHandler(w, r, handlers.PluginDraining, strings.TrimSuffix(pluginPath, "/draining/force-close"))
|
||||
return
|
||||
|
||||
@@ -44,8 +44,11 @@ func TestNewAPIHandlerRoutesRequests(t *testing.T) {
|
||||
{name: "plugin config dry-run", method: http.MethodPost, path: "/admin/api/plugins/upstream-rewrite/config/dry-run", wantCall: "plugin_config", wantSegment: "upstream-rewrite/config/dry-run"},
|
||||
{name: "plugin secrets", method: http.MethodGet, path: "/admin/api/plugins/upstream-rewrite/secrets", wantCall: "plugin_secrets", wantSegment: "upstream-rewrite/secrets"},
|
||||
{name: "plugin rollback artifact", method: http.MethodPost, path: "/admin/api/plugins/upstream-rewrite/rollback/artifact", wantCall: "plugin_rollback", wantSegment: "upstream-rewrite/rollback/artifact"},
|
||||
{name: "plugin governance", method: http.MethodGet, path: "/admin/api/plugins/upstream-rewrite/governance", wantCall: "plugin_governance", wantSegment: "upstream-rewrite/governance"},
|
||||
{name: "plugin governance review", method: http.MethodPost, path: "/admin/api/plugins/upstream-rewrite/governance/review", wantCall: "plugin_governance", wantSegment: "upstream-rewrite/governance/review"},
|
||||
{name: "plugin draining force close", method: http.MethodPost, path: "/admin/api/plugins/mc-auth-proxy/draining/force-close", wantCall: "plugin_draining", wantSegment: "mc-auth-proxy"},
|
||||
{name: "plugin dispatch", method: http.MethodGet, path: "/admin/api/plugins/dispatch-plan", wantCall: "plugin_dispatch"},
|
||||
{name: "plugin advisories", method: http.MethodGet, path: "/admin/api/plugin-advisories", wantCall: "plugin_advisories"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -87,6 +90,8 @@ func TestNewAPIHandlerRoutesRequests(t *testing.T) {
|
||||
PluginRollback: recordSegmentCall(&gotCall, &gotSegment, "plugin_rollback"),
|
||||
PluginDraining: recordSegmentCall(&gotCall, &gotSegment, "plugin_draining"),
|
||||
PluginDispatch: recordCall(&gotCall, "plugin_dispatch"),
|
||||
PluginGovernance: recordSegmentCall(&gotCall, &gotSegment, "plugin_governance"),
|
||||
PluginAdvisories: recordCall(&gotCall, "plugin_advisories"),
|
||||
})
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
1284
internal/pluginmanager/governance.go
Normal file
1284
internal/pluginmanager/governance.go
Normal file
File diff suppressed because it is too large
Load Diff
174
internal/pluginmanager/governance_test.go
Normal file
174
internal/pluginmanager/governance_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package pluginmanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGovernanceHighRiskProtocolProxyRequiresReview(t *testing.T) {
|
||||
manager := newManagerForTest(t, &fakeAdapter{})
|
||||
artifact := uploadTestArtifactWithCapabilities(t, manager, "proxy-review", testProtocolProxyCapabilities())
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "proxy-review", artifact.ID, DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
_, err := manager.Enable(context.Background(), "admin", "proxy-review")
|
||||
if err == nil || !strings.Contains(err.Error(), "review_required") {
|
||||
t.Fatalf("Enable() error = %v, want review_required", err)
|
||||
}
|
||||
if _, err := manager.CreateReview(context.Background(), "admin", "proxy-review", GovernanceReviewRequest{
|
||||
ArtifactID: artifact.ID,
|
||||
Profile: PolicyProfileProd,
|
||||
Decision: ReviewDecisionApproved,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateReview() error = %v", err)
|
||||
}
|
||||
if _, err := manager.Enable(context.Background(), "admin", "proxy-review"); err != nil {
|
||||
t.Fatalf("Enable(after review) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGovernanceBlocksProtocolProxyScopeOverlap(t *testing.T) {
|
||||
manager := newManagerForTest(t, &fakeAdapter{})
|
||||
first := enableProtocolProxyTestPlugin(t, manager, "proxy-a")
|
||||
if first.ID == "" {
|
||||
t.Fatal("first protocol proxy artifact id is empty")
|
||||
}
|
||||
second := uploadTestArtifactWithCapabilities(t, manager, "proxy-b", testProtocolProxyCapabilities())
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "proxy-b", second.ID, DesiredEnabled, `{}`, 20); err != nil {
|
||||
t.Fatalf("SetDesired(second) error = %v", err)
|
||||
}
|
||||
approveGovernanceForTest(t, manager, "proxy-b", second.ID)
|
||||
_, err := manager.Enable(context.Background(), "admin", "proxy-b")
|
||||
if err == nil || !strings.Contains(err.Error(), "scope_overlap") {
|
||||
t.Fatalf("Enable(second) error = %v, want scope_overlap", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGovernanceBlocksMissingFeatureAndSecret(t *testing.T) {
|
||||
manager := newManagerForTest(t, &fakeAdapter{})
|
||||
artifact := uploadTestArtifactWithManifest(t, manager, "feature-secret", func(manifest *Manifest) {
|
||||
manifest.Capabilities = json.RawMessage(`{"required_features":["wasm-sandbox"]}`)
|
||||
manifest.Secrets = []SecretSpec{{Name: "api_token", Required: true}}
|
||||
})
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "feature-secret", artifact.ID, DesiredEnabled, `{}`, 10); err == nil {
|
||||
t.Fatal("SetDesired() error = nil, want missing secret from dry-run")
|
||||
}
|
||||
if _, err := manager.UpsertSecret(context.Background(), "admin", "feature-secret", artifact.ID, "api_token", "secret", true, false); err != nil {
|
||||
t.Fatalf("UpsertSecret() error = %v", err)
|
||||
}
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "feature-secret", artifact.ID, DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired(after secret) error = %v", err)
|
||||
}
|
||||
_, err := manager.Enable(context.Background(), "admin", "feature-secret")
|
||||
if err == nil || !strings.Contains(err.Error(), "feature_missing") {
|
||||
t.Fatalf("Enable() error = %v, want feature_missing", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGovernanceAdvisoryRevokeBlocksRollback(t *testing.T) {
|
||||
manager := newManagerForTest(t, &fakeAdapter{})
|
||||
oldArtifact := uploadTestArtifact(t, manager, "revoke-plugin")
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "revoke-plugin", oldArtifact.ID, DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired(old) error = %v", err)
|
||||
}
|
||||
if _, err := manager.Enable(context.Background(), "admin", "revoke-plugin"); err != nil {
|
||||
t.Fatalf("Enable(old) error = %v", err)
|
||||
}
|
||||
newArtifact := uploadTestArtifactWithManifest(t, manager, "revoke-plugin", func(manifest *Manifest) {
|
||||
manifest.Version = "0.2.0"
|
||||
})
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "revoke-plugin", newArtifact.ID, DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired(new) error = %v", err)
|
||||
}
|
||||
if _, err := manager.Enable(context.Background(), "admin", "revoke-plugin"); err != nil {
|
||||
t.Fatalf("Enable(new) error = %v", err)
|
||||
}
|
||||
if _, err := manager.UpsertAdvisory(context.Background(), "admin", AdvisoryRequest{
|
||||
AdvisoryID: "MCG-2026-0001",
|
||||
Status: AdvisoryStatusRevoked,
|
||||
Action: AdvisoryActionRevoke,
|
||||
ArtifactSHA256: oldArtifact.SHA256,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertAdvisory() error = %v", err)
|
||||
}
|
||||
_, err := manager.RollbackArtifact(context.Background(), "admin", "revoke-plugin", oldArtifact.ID)
|
||||
if err == nil || !strings.Contains(err.Error(), "advisory_revoke") {
|
||||
t.Fatalf("RollbackArtifact() error = %v, want advisory_revoke", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGovernanceWarningOverrideTTL(t *testing.T) {
|
||||
now := time.Unix(1000, 0)
|
||||
db := openPluginManagerTestDB(t)
|
||||
manager := New(Options{
|
||||
DB: db,
|
||||
ArtifactRoot: t.TempDir(),
|
||||
Adapter: &fakeAdapter{},
|
||||
})
|
||||
manager.repo.now = func() time.Time { return now }
|
||||
artifact := uploadTestArtifact(t, manager, "bench-plugin")
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "bench-plugin", artifact.ID, DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
if _, err := manager.SaveBenchmark(context.Background(), "admin", BenchmarkRequest{
|
||||
ArtifactID: artifact.ID,
|
||||
Profile: PolicyProfileProd,
|
||||
BenchmarkProfile: "release",
|
||||
P95MS: 10,
|
||||
P99MS: 20,
|
||||
BaselineDiff: 0.30,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveBenchmark() error = %v", err)
|
||||
}
|
||||
_, err := manager.Enable(context.Background(), "admin", "bench-plugin")
|
||||
if err == nil || !strings.Contains(err.Error(), "benchmark_regression_warning") {
|
||||
t.Fatalf("Enable() error = %v, want benchmark warning", err)
|
||||
}
|
||||
if _, err := manager.CreateWarningOverride(context.Background(), "admin", "bench-plugin", WarningOverrideRequest{
|
||||
ArtifactID: artifact.ID,
|
||||
Profile: PolicyProfileProd,
|
||||
Action: GovernanceActionEnable,
|
||||
Reason: "accepted for canary",
|
||||
TTLSeconds: 60,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateWarningOverride() error = %v", err)
|
||||
}
|
||||
if _, err := manager.Enable(context.Background(), "admin", "bench-plugin"); err != nil {
|
||||
t.Fatalf("Enable(with override) error = %v", err)
|
||||
}
|
||||
if _, err := manager.Disable(context.Background(), "admin", "bench-plugin"); err != nil {
|
||||
t.Fatalf("Disable() error = %v", err)
|
||||
}
|
||||
now = now.Add(2 * time.Minute)
|
||||
_, err = manager.Enable(context.Background(), "admin", "bench-plugin")
|
||||
if err == nil || !strings.Contains(err.Error(), "benchmark_regression_warning") {
|
||||
t.Fatalf("Enable(after override expiry) error = %v, want benchmark warning", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGovernanceBenchmarkBlocking(t *testing.T) {
|
||||
manager := newManagerForTest(t, &fakeAdapter{})
|
||||
artifact := uploadTestArtifactWithManifest(t, manager, "bench-block", func(manifest *Manifest) {
|
||||
manifest.RuntimeLimits.HandlerTimeoutMS = 100
|
||||
})
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "bench-block", artifact.ID, DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
if _, err := manager.SaveBenchmark(context.Background(), "admin", BenchmarkRequest{
|
||||
ArtifactID: artifact.ID,
|
||||
Profile: PolicyProfileProd,
|
||||
BenchmarkProfile: "release",
|
||||
P95MS: 80,
|
||||
P99MS: 200,
|
||||
BaselineDiff: 0.60,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveBenchmark() error = %v", err)
|
||||
}
|
||||
_, err := manager.Enable(context.Background(), "admin", "bench-block")
|
||||
if err == nil || !strings.Contains(err.Error(), "benchmark_regression_blocking") {
|
||||
t.Fatalf("Enable() error = %v, want benchmark_regression_blocking", err)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,52 @@ type ConfigDryRunAdapter interface {
|
||||
type GoPluginAdapter struct{}
|
||||
|
||||
func (a GoPluginAdapter) Load(ctx context.Context, artifact ArtifactRecord, pluginRecord PluginRecord, gateway *Gateway) (api.Plugin, error) {
|
||||
return a.instantiate(ctx, artifact, pluginRecord, gateway, true)
|
||||
}
|
||||
|
||||
func (a GoPluginAdapter) DryRunConfig(ctx context.Context, artifact ArtifactRecord, pluginRecord PluginRecord) error {
|
||||
_ = ctx
|
||||
_, err := a.instantiate(ctx, artifact, pluginRecord, nil, false)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a GoPluginAdapter) RunPreflight(ctx context.Context, artifact ArtifactRecord, pluginRecord PluginRecord, profile, action string) (api.PreflightResult, error) {
|
||||
instance, err := a.instantiate(ctx, artifact, pluginRecord, nil, false)
|
||||
if err != nil {
|
||||
return api.PreflightResult{}, err
|
||||
}
|
||||
checker, ok := instance.(api.PreflightChecker)
|
||||
if !ok {
|
||||
return api.PreflightResult{}, nil
|
||||
}
|
||||
var config map[string]any
|
||||
_ = json.Unmarshal([]byte(defaultJSONObject(pluginRecord.ConfigJSON)), &config)
|
||||
return checker.Preflight(api.PreflightContext{
|
||||
PluginID: pluginRecord.ID,
|
||||
ArtifactID: artifact.ID,
|
||||
Profile: profile,
|
||||
Action: action,
|
||||
Config: config,
|
||||
Scope: jsonObjectFromRaw(manifestCapabilitiesRaw(artifact), "scope"),
|
||||
Rollout: jsonObjectFromRaw(manifestCapabilitiesRaw(artifact), "rollout"),
|
||||
RuntimeLimits: jsonObjectFromRaw(artifact.MetadataJSON, "runtime_limits"),
|
||||
Features: stringSlice(jsonObjectFromRaw(manifestCapabilitiesRaw(artifact), "required_features")),
|
||||
})
|
||||
}
|
||||
|
||||
func (a GoPluginAdapter) RunSelfTest(ctx context.Context, artifact ArtifactRecord, pluginRecord PluginRecord, profile string) (api.SelfTestResult, error) {
|
||||
instance, err := a.instantiate(ctx, artifact, pluginRecord, nil, false)
|
||||
if err != nil {
|
||||
return api.SelfTestResult{}, err
|
||||
}
|
||||
tester, ok := instance.(api.SelfTester)
|
||||
if !ok {
|
||||
return api.SelfTestResult{}, nil
|
||||
}
|
||||
return tester.SelfTest(api.SelfTestProfile{Name: profile})
|
||||
}
|
||||
|
||||
func (a GoPluginAdapter) instantiate(ctx context.Context, artifact ArtifactRecord, pluginRecord PluginRecord, gateway *Gateway, init bool) (api.Plugin, error) {
|
||||
_ = ctx
|
||||
opened, err := stdplugin.Open(artifact.FilePath)
|
||||
if err != nil {
|
||||
@@ -59,44 +105,14 @@ func (a GoPluginAdapter) Load(ctx context.Context, artifact ArtifactRecord, plug
|
||||
if err := instance.ReloadConfig(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if init {
|
||||
if err := instance.Init(gateway); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
func (a GoPluginAdapter) DryRunConfig(ctx context.Context, artifact ArtifactRecord, pluginRecord PluginRecord) error {
|
||||
_ = ctx
|
||||
opened, err := stdplugin.Open(artifact.FilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
symbolName := "Plugin"
|
||||
var manifest Manifest
|
||||
if err := json.Unmarshal([]byte(artifact.MetadataJSON), &manifest); err == nil && manifest.Runtime.EntrySymbol != "" {
|
||||
symbolName = manifest.Runtime.EntrySymbol
|
||||
}
|
||||
symbol, err := opened.Lookup(symbolName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
factory, ok := symbol.(func() api.Plugin)
|
||||
if !ok {
|
||||
return fmt.Errorf("plugin symbol %q has invalid signature", symbolName)
|
||||
}
|
||||
instance := factory()
|
||||
cfg := instance.NewConfigObj()
|
||||
if cfg != nil && pluginRecord.ConfigJSON != "" && canUnmarshalInto(cfg) {
|
||||
if err := json.Unmarshal([]byte(pluginRecord.ConfigJSON), cfg); err != nil {
|
||||
return fmt.Errorf("decode plugin config: %w", err)
|
||||
}
|
||||
}
|
||||
if err := instance.ReloadConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func canUnmarshalInto(value any) bool {
|
||||
if value == nil {
|
||||
return false
|
||||
@@ -112,6 +128,7 @@ type Manager struct {
|
||||
builders map[string]SourceBuilder
|
||||
handleConn func(net.Conn)
|
||||
wg *sync.WaitGroup
|
||||
policyProfile string
|
||||
|
||||
mu sync.Mutex
|
||||
loaded map[string]*loadedPlugin
|
||||
@@ -187,6 +204,7 @@ type Options struct {
|
||||
WaitGroup *sync.WaitGroup
|
||||
Adapter RuntimeAdapter
|
||||
Builders map[string]SourceBuilder
|
||||
PolicyProfile string
|
||||
}
|
||||
|
||||
func New(options Options) *Manager {
|
||||
@@ -201,6 +219,7 @@ func New(options Options) *Manager {
|
||||
builders: options.Builders,
|
||||
handleConn: options.HandleConn,
|
||||
wg: options.WaitGroup,
|
||||
policyProfile: options.PolicyProfile,
|
||||
loaded: make(map[string]*loadedPlugin),
|
||||
proxyConns: make(map[uint64]*proxyConnection),
|
||||
drainingIDs: make(map[string]bool),
|
||||
@@ -539,6 +558,17 @@ func (m *Manager) RollbackArtifact(ctx context.Context, actor, pluginID, artifac
|
||||
if err != nil {
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
decision, err := m.EvaluateGovernance(ctx, pluginID, artifactID, GovernanceActionRollback, m.currentPolicyProfile(), current.ConfigJSON)
|
||||
if err == nil && !decision.OK {
|
||||
err = governanceBlockedError(decision)
|
||||
}
|
||||
if err != nil {
|
||||
_ = m.repo.RecordOperation(ctx, pluginID, artifactID, "artifact_rollback_gate", "failed", actor, err.Error(), map[string]any{
|
||||
"active_changed": false,
|
||||
"governance_decision": decision,
|
||||
})
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
if _, err := m.DryRunConfig(ctx, pluginID, artifactID, current.ConfigJSON); err != nil {
|
||||
_ = m.repo.RecordOperation(ctx, pluginID, artifactID, "artifact_rollback", "failed", actor, err.Error(), map[string]any{
|
||||
"active_changed": false,
|
||||
@@ -555,6 +585,7 @@ func (m *Manager) RollbackArtifact(ctx context.Context, actor, pluginID, artifac
|
||||
_ = m.repo.RecordOperation(ctx, pluginID, artifactID, "artifact_rollback", "succeeded", actor, "artifact rollback desired state updated", map[string]any{
|
||||
"desired_generation": plugin.DesiredGeneration,
|
||||
"active_changed": false,
|
||||
"governance_decision": decision,
|
||||
})
|
||||
return plugin, nil
|
||||
}
|
||||
@@ -576,6 +607,19 @@ func (m *Manager) RollbackConfigSnapshot(ctx context.Context, actor string, snap
|
||||
desiredState = snapshot.DesiredState
|
||||
priority = snapshot.Priority
|
||||
}
|
||||
decision, err := m.EvaluateGovernance(ctx, snapshot.PluginID, artifactID, GovernanceActionRollback, m.currentPolicyProfile(), snapshot.ConfigJSON)
|
||||
if err == nil && !decision.OK {
|
||||
err = governanceBlockedError(decision)
|
||||
}
|
||||
if err != nil {
|
||||
_ = m.repo.RecordOperation(ctx, snapshot.PluginID, artifactID, "config_rollback_gate", "failed", actor, err.Error(), map[string]any{
|
||||
"snapshot_id": snapshot.ID,
|
||||
"full_desired": fullDesired,
|
||||
"active_changed": false,
|
||||
"governance_decision": decision,
|
||||
})
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
if _, err := m.DryRunConfig(ctx, snapshot.PluginID, artifactID, snapshot.ConfigJSON); err != nil {
|
||||
_ = m.repo.RecordOperation(ctx, snapshot.PluginID, artifactID, "config_rollback", "failed", actor, err.Error(), map[string]any{
|
||||
"snapshot_id": snapshot.ID,
|
||||
@@ -598,6 +642,7 @@ func (m *Manager) RollbackConfigSnapshot(ctx context.Context, actor string, snap
|
||||
"full_desired": fullDesired,
|
||||
"desired_generation": next.DesiredGeneration,
|
||||
"active_changed": false,
|
||||
"governance_decision": decision,
|
||||
})
|
||||
return next, nil
|
||||
}
|
||||
@@ -630,6 +675,17 @@ func (m *Manager) Enable(ctx context.Context, actor, pluginID string) (PluginRec
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
}
|
||||
decision, err := m.EvaluateGovernance(ctx, pluginID, pluginRecord.DesiredArtifactID, GovernanceActionEnable, m.currentPolicyProfile(), pluginRecord.ConfigJSON)
|
||||
if err == nil && !decision.OK {
|
||||
err = governanceBlockedError(decision)
|
||||
}
|
||||
if err != nil {
|
||||
_ = m.repo.MarkRuntime(ctx, pluginID, RuntimeFailed, "", "", pluginRecord.AppliedGeneration, err.Error(), map[string]any{"governance": decision}, nil)
|
||||
_ = m.repo.RecordOperation(ctx, pluginID, pluginRecord.DesiredArtifactID, "enable_gate", "failed", actor, err.Error(), map[string]any{
|
||||
"decision": decision,
|
||||
})
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -658,6 +714,7 @@ func (m *Manager) Enable(ctx context.Context, actor, pluginID string) (PluginRec
|
||||
_ = m.repo.RecordOperation(ctx, pluginID, loaded.artifact.ID, "enable", "succeeded", actor, "plugin enabled", map[string]any{
|
||||
"desired_generation": loaded.record.DesiredGeneration,
|
||||
"handler_count": len(loaded.handlers),
|
||||
"governance_decision": decision,
|
||||
})
|
||||
return m.repo.Plugin(ctx, pluginID)
|
||||
}
|
||||
@@ -728,6 +785,15 @@ func (m *Manager) Reconcile(ctx context.Context) error {
|
||||
}
|
||||
nextByPlugin := make(map[string][]*upstreamHandler)
|
||||
for _, pluginRecord := range desired {
|
||||
decision, err := m.EvaluateGovernance(ctx, pluginRecord.ID, pluginRecord.DesiredArtifactID, GovernanceActionEnable, m.currentPolicyProfile(), pluginRecord.ConfigJSON)
|
||||
if err == nil && !decision.OK {
|
||||
err = governanceBlockedError(decision)
|
||||
}
|
||||
if err != nil {
|
||||
_ = m.repo.MarkRuntime(ctx, pluginRecord.ID, RuntimeFailed, "", "", pluginRecord.AppliedGeneration, err.Error(), map[string]any{"governance": decision}, nil)
|
||||
_ = m.repo.RecordOperation(ctx, pluginRecord.ID, pluginRecord.DesiredArtifactID, "reconcile_gate", "failed", "system", err.Error(), map[string]any{"decision": decision})
|
||||
continue
|
||||
}
|
||||
loaded, err := m.loadLocked(ctx, pluginRecord)
|
||||
if err != nil {
|
||||
_ = m.repo.MarkRuntime(ctx, pluginRecord.ID, RuntimeFailed, "", "", pluginRecord.AppliedGeneration, err.Error(), map[string]any{"error": err.Error()}, nil)
|
||||
@@ -901,6 +967,38 @@ func (m *Manager) ListSecrets(ctx context.Context, pluginID string) ([]SecretRec
|
||||
return m.repo.ListSecrets(ctx, pluginID)
|
||||
}
|
||||
|
||||
func (m *Manager) quarantineAffected(ctx context.Context, advisory AdvisoryRecord) {
|
||||
plugins, err := m.repo.ListPlugins(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var manifests = make(map[string]Manifest)
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for _, plugin := range plugins {
|
||||
if plugin.RuntimeState != RuntimeEnabled || plugin.ActiveArtifactID == "" {
|
||||
continue
|
||||
}
|
||||
artifact, err := m.repo.Artifact(ctx, plugin.ActiveArtifactID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
manifest := manifests[artifact.ID]
|
||||
if manifest.ID == "" {
|
||||
_ = json.Unmarshal([]byte(artifact.MetadataJSON), &manifest)
|
||||
manifests[artifact.ID] = manifest
|
||||
}
|
||||
if advisoryMatches(advisory, artifact, manifest) {
|
||||
m.removeFromDispatchLocked(plugin.ID)
|
||||
m.markDrainingLocked(plugin.ID)
|
||||
_ = m.repo.MarkRuntime(ctx, plugin.ID, RuntimeDraining, artifact.ID, artifact.ID, plugin.AppliedGeneration, "plugin quarantined by advisory "+advisory.AdvisoryID, map[string]any{
|
||||
"quarantine": true,
|
||||
"advisory_id": advisory.AdvisoryID,
|
||||
}, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) UpsertSecret(ctx context.Context, actor, pluginID, artifactID, name, value string, reloadRequired, hotReload bool) (SecretRecord, error) {
|
||||
if artifactID == "" {
|
||||
plugin, err := m.repo.Plugin(ctx, pluginID)
|
||||
|
||||
@@ -510,10 +510,11 @@ func TestProtocolProxyTrackDrainAndForceClose(t *testing.T) {
|
||||
},
|
||||
}
|
||||
manager := newManagerForTest(t, adapter)
|
||||
artifact := uploadTestArtifactWithCapabilities(t, manager, "plugin-a", json.RawMessage(`{"upstream_connect":{"mode":"protocol-proxy"}}`))
|
||||
artifact := uploadTestArtifactWithCapabilities(t, manager, "plugin-a", testProtocolProxyCapabilities())
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "plugin-a", artifact.ID, DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
approveGovernanceForTest(t, manager, "plugin-a", artifact.ID)
|
||||
if _, err := manager.Enable(context.Background(), "admin", "plugin-a"); err != nil {
|
||||
t.Fatalf("Enable() error = %v", err)
|
||||
}
|
||||
@@ -616,10 +617,11 @@ func TestProtocolProxyInitialWriteTimeoutClosesUnreadableConn(t *testing.T) {
|
||||
},
|
||||
}
|
||||
manager := newManagerForTest(t, adapter)
|
||||
artifact := uploadTestArtifactWithCapabilities(t, manager, "plugin-a", json.RawMessage(`{"upstream_connect":{"mode":"protocol-proxy"}}`))
|
||||
artifact := uploadTestArtifactWithCapabilities(t, manager, "plugin-a", testProtocolProxyCapabilities())
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "plugin-a", artifact.ID, DesiredEnabled, `{"unused":true}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
approveGovernanceForTest(t, manager, "plugin-a", artifact.ID)
|
||||
if _, err := manager.Enable(context.Background(), "admin", "plugin-a"); err != nil {
|
||||
t.Fatalf("Enable() error = %v", err)
|
||||
}
|
||||
@@ -648,21 +650,46 @@ func TestProtocolProxyInitialWriteTimeoutClosesUnreadableConn(t *testing.T) {
|
||||
|
||||
func enableProtocolProxyTestPlugin(t *testing.T, manager *Manager, pluginID string) ArtifactRecord {
|
||||
t.Helper()
|
||||
artifact := uploadTestArtifactWithCapabilities(t, manager, pluginID, json.RawMessage(`{"upstream_connect":{"mode":"protocol-proxy"}}`))
|
||||
artifact := uploadTestArtifactWithCapabilities(t, manager, pluginID, testProtocolProxyCapabilities())
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", pluginID, artifact.ID, DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
approveGovernanceForTest(t, manager, pluginID, artifact.ID)
|
||||
if _, err := manager.Enable(context.Background(), "admin", pluginID); err != nil {
|
||||
t.Fatalf("Enable() error = %v", err)
|
||||
}
|
||||
return artifact
|
||||
}
|
||||
|
||||
func approveGovernanceForTest(t *testing.T, manager *Manager, pluginID, artifactID string) {
|
||||
t.Helper()
|
||||
if _, err := manager.CreateReview(context.Background(), "admin", pluginID, GovernanceReviewRequest{
|
||||
ArtifactID: artifactID,
|
||||
Profile: PolicyProfileProd,
|
||||
Decision: ReviewDecisionApproved,
|
||||
Notes: "test approval",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateReview(%s) error = %v", pluginID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func newManagerForTest(t *testing.T, adapter RuntimeAdapter) *Manager {
|
||||
t.Helper()
|
||||
return newManagerForTestWithBuilders(t, adapter, nil)
|
||||
}
|
||||
|
||||
func testProtocolProxyCapabilities() json.RawMessage {
|
||||
return json.RawMessage(`{
|
||||
"upstream_connect":{"mode":"protocol-proxy"},
|
||||
"scope":{"type":"host","values":["play.example"]},
|
||||
"rollout":{"mode":"canary"},
|
||||
"minecraft":{
|
||||
"protocol_versions":{"tested":[767]},
|
||||
"forwarding":{"supported":["none"],"default":"none"}
|
||||
}
|
||||
}`)
|
||||
}
|
||||
|
||||
func newManagerForTestWithBuilders(t *testing.T, adapter RuntimeAdapter, builders map[string]SourceBuilder) *Manager {
|
||||
t.Helper()
|
||||
db := openPluginManagerTestDB(t)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -599,6 +600,359 @@ FROM plugin_secrets`
|
||||
return secrets, rows.Err()
|
||||
}
|
||||
|
||||
func (r Repository) SaveReview(ctx context.Context, review ReviewRecord) (ReviewRecord, error) {
|
||||
now := r.now().Unix()
|
||||
if review.CreatedAt == 0 {
|
||||
review.CreatedAt = now
|
||||
}
|
||||
if review.Profile == "" {
|
||||
review.Profile = PolicyProfileDev
|
||||
}
|
||||
if review.RiskLevel == "" {
|
||||
review.RiskLevel = RiskLow
|
||||
}
|
||||
if review.Decision == "" {
|
||||
review.Decision = ReviewDecisionApproved
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO plugin_reviews(
|
||||
plugin_id, artifact_id, profile, risk_level, config_hash, scope_hash, rollout_hash,
|
||||
runtime_limits_hash, features_hash, policy_hash, decision, notes, reviewed_by, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
review.PluginID, review.ArtifactID, review.Profile, review.RiskLevel, review.ConfigHash, review.ScopeHash, review.RolloutHash,
|
||||
review.RuntimeLimitsHash, review.FeaturesHash, review.PolicyHash, review.Decision, review.Notes, review.ReviewedBy, review.CreatedAt)
|
||||
if err != nil {
|
||||
return ReviewRecord{}, err
|
||||
}
|
||||
id, err := lastInsertID(ctx, r.db)
|
||||
if err != nil {
|
||||
return ReviewRecord{}, err
|
||||
}
|
||||
review.ID = id
|
||||
return review, nil
|
||||
}
|
||||
|
||||
func (r Repository) ListReviews(ctx context.Context, pluginID string) ([]ReviewRecord, error) {
|
||||
query := `
|
||||
SELECT id, plugin_id, artifact_id, profile, risk_level, config_hash, scope_hash, rollout_hash,
|
||||
runtime_limits_hash, features_hash, policy_hash, decision, notes, reviewed_by, created_at
|
||||
FROM plugin_reviews`
|
||||
var args []any
|
||||
if pluginID != "" {
|
||||
query += ` WHERE plugin_id = ?`
|
||||
args = append(args, pluginID)
|
||||
}
|
||||
query += ` ORDER BY created_at DESC, id DESC`
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var reviews []ReviewRecord
|
||||
for rows.Next() {
|
||||
review, err := scanReview(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reviews = append(reviews, review)
|
||||
}
|
||||
return reviews, rows.Err()
|
||||
}
|
||||
|
||||
func (r Repository) ApprovedReview(ctx context.Context, pluginID, artifactID, profile, policyHash, configHash, scopeHash, rolloutHash, runtimeHash, featuresHash string) (ReviewRecord, error) {
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, plugin_id, artifact_id, profile, risk_level, config_hash, scope_hash, rollout_hash,
|
||||
runtime_limits_hash, features_hash, policy_hash, decision, notes, reviewed_by, created_at
|
||||
FROM plugin_reviews
|
||||
WHERE plugin_id = ? AND artifact_id = ? AND profile = ? AND policy_hash = ?
|
||||
AND config_hash = ? AND scope_hash = ? AND rollout_hash = ? AND runtime_limits_hash = ? AND features_hash = ?
|
||||
AND decision = ?
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 1`, pluginID, artifactID, profile, policyHash, configHash, scopeHash, rolloutHash, runtimeHash, featuresHash, ReviewDecisionApproved)
|
||||
review, err := scanReview(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ReviewRecord{}, nil
|
||||
}
|
||||
return review, err
|
||||
}
|
||||
|
||||
func (r Repository) SaveWarningOverride(ctx context.Context, override WarningOverrideRecord) (WarningOverrideRecord, error) {
|
||||
now := r.now().Unix()
|
||||
if override.CreatedAt == 0 {
|
||||
override.CreatedAt = now
|
||||
}
|
||||
if override.Profile == "" {
|
||||
override.Profile = PolicyProfileDev
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO plugin_warning_overrides(plugin_id, artifact_id, profile, action, policy_hash, reason, created_by, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
override.PluginID, override.ArtifactID, override.Profile, override.Action, override.PolicyHash, override.Reason, override.CreatedBy, override.ExpiresAt, override.CreatedAt)
|
||||
if err != nil {
|
||||
return WarningOverrideRecord{}, err
|
||||
}
|
||||
id, err := lastInsertID(ctx, r.db)
|
||||
if err != nil {
|
||||
return WarningOverrideRecord{}, err
|
||||
}
|
||||
override.ID = id
|
||||
return override, nil
|
||||
}
|
||||
|
||||
func (r Repository) ActiveWarningOverride(ctx context.Context, pluginID, artifactID, profile, action, policyHash string) (WarningOverrideRecord, bool, error) {
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, plugin_id, artifact_id, profile, action, policy_hash, reason, created_by, expires_at, created_at
|
||||
FROM plugin_warning_overrides
|
||||
WHERE plugin_id = ? AND artifact_id = ? AND profile = ? AND action = ? AND policy_hash = ? AND expires_at > ?
|
||||
ORDER BY expires_at DESC, id DESC
|
||||
LIMIT 1`, pluginID, artifactID, profile, action, policyHash, r.now().Unix())
|
||||
override, err := scanWarningOverride(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return WarningOverrideRecord{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return WarningOverrideRecord{}, false, err
|
||||
}
|
||||
return override, true, nil
|
||||
}
|
||||
|
||||
func (r Repository) ListWarningOverrides(ctx context.Context, pluginID string) ([]WarningOverrideRecord, error) {
|
||||
query := `
|
||||
SELECT id, plugin_id, artifact_id, profile, action, policy_hash, reason, created_by, expires_at, created_at
|
||||
FROM plugin_warning_overrides`
|
||||
var args []any
|
||||
if pluginID != "" {
|
||||
query += ` WHERE plugin_id = ?`
|
||||
args = append(args, pluginID)
|
||||
}
|
||||
query += ` ORDER BY created_at DESC, id DESC`
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var overrides []WarningOverrideRecord
|
||||
for rows.Next() {
|
||||
override, err := scanWarningOverride(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
overrides = append(overrides, override)
|
||||
}
|
||||
return overrides, rows.Err()
|
||||
}
|
||||
|
||||
func (r Repository) UpsertAdvisory(ctx context.Context, actor string, req AdvisoryRequest) (AdvisoryRecord, error) {
|
||||
now := r.now().Unix()
|
||||
if req.Status == "" {
|
||||
req.Status = AdvisoryStatusActive
|
||||
}
|
||||
if req.Action == "" {
|
||||
req.Action = AdvisoryActionDenylist
|
||||
}
|
||||
if strings.TrimSpace(req.AdvisoryID) == "" {
|
||||
return AdvisoryRecord{}, errors.New("advisory_id is required")
|
||||
}
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
UPDATE plugin_advisories
|
||||
SET status = ?, action = ?, artifact_sha256 = ?, plugin_id = ?, version_range = ?,
|
||||
dependency_name = ?, dependency_range = ?, recommended_action = ?, fixed_version = ?,
|
||||
mitigation = ?, created_by = ?, updated_at = ?
|
||||
WHERE advisory_id = ?`,
|
||||
req.Status, req.Action, req.ArtifactSHA256, req.PluginID, req.VersionRange,
|
||||
req.DependencyName, req.DependencyRange, req.RecommendedAction, req.FixedVersion,
|
||||
req.Mitigation, actor, now, req.AdvisoryID)
|
||||
if err != nil {
|
||||
return AdvisoryRecord{}, err
|
||||
}
|
||||
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||
_, err = r.db.ExecContext(ctx, `
|
||||
INSERT INTO plugin_advisories(
|
||||
advisory_id, status, action, artifact_sha256, plugin_id, version_range, dependency_name,
|
||||
dependency_range, recommended_action, fixed_version, mitigation, created_by, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
req.AdvisoryID, req.Status, req.Action, req.ArtifactSHA256, req.PluginID, req.VersionRange, req.DependencyName,
|
||||
req.DependencyRange, req.RecommendedAction, req.FixedVersion, req.Mitigation, actor, now, now)
|
||||
if err != nil {
|
||||
return AdvisoryRecord{}, err
|
||||
}
|
||||
}
|
||||
return r.AdvisoryByID(ctx, req.AdvisoryID)
|
||||
}
|
||||
|
||||
func (r Repository) AdvisoryByID(ctx context.Context, advisoryID string) (AdvisoryRecord, error) {
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, advisory_id, status, action, artifact_sha256, plugin_id, version_range, dependency_name,
|
||||
dependency_range, recommended_action, fixed_version, mitigation, created_by, created_at, updated_at
|
||||
FROM plugin_advisories
|
||||
WHERE advisory_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1`, advisoryID)
|
||||
return scanAdvisory(row)
|
||||
}
|
||||
|
||||
func (r Repository) ListAdvisories(ctx context.Context, pluginID string) ([]AdvisoryRecord, error) {
|
||||
query := `
|
||||
SELECT id, advisory_id, status, action, artifact_sha256, plugin_id, version_range, dependency_name,
|
||||
dependency_range, recommended_action, fixed_version, mitigation, created_by, created_at, updated_at
|
||||
FROM plugin_advisories`
|
||||
var args []any
|
||||
if pluginID != "" {
|
||||
query += ` WHERE plugin_id = ? OR plugin_id = ''`
|
||||
args = append(args, pluginID)
|
||||
}
|
||||
query += ` ORDER BY updated_at DESC, id DESC`
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var advisories []AdvisoryRecord
|
||||
for rows.Next() {
|
||||
advisory, err := scanAdvisory(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
advisories = append(advisories, advisory)
|
||||
}
|
||||
return advisories, rows.Err()
|
||||
}
|
||||
|
||||
func (r Repository) SavePreflight(ctx context.Context, record PreflightRecord) (PreflightRecord, error) {
|
||||
now := r.now().Unix()
|
||||
if record.CreatedAt == 0 {
|
||||
record.CreatedAt = now
|
||||
}
|
||||
if record.Profile == "" {
|
||||
record.Profile = PolicyProfileDev
|
||||
}
|
||||
if record.ResultJSON == "" || !json.Valid([]byte(record.ResultJSON)) {
|
||||
record.ResultJSON = "{}"
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO plugin_preflight_results(plugin_id, artifact_id, profile, status, result_json, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
record.PluginID, record.ArtifactID, record.Profile, record.Status, record.ResultJSON, record.CreatedBy, record.CreatedAt)
|
||||
if err != nil {
|
||||
return PreflightRecord{}, err
|
||||
}
|
||||
id, err := lastInsertID(ctx, r.db)
|
||||
if err != nil {
|
||||
return PreflightRecord{}, err
|
||||
}
|
||||
record.ID = id
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (r Repository) ListPreflights(ctx context.Context, pluginID string) ([]PreflightRecord, error) {
|
||||
query := `
|
||||
SELECT id, plugin_id, artifact_id, profile, status, result_json, created_by, created_at
|
||||
FROM plugin_preflight_results`
|
||||
var args []any
|
||||
if pluginID != "" {
|
||||
query += ` WHERE plugin_id = ?`
|
||||
args = append(args, pluginID)
|
||||
}
|
||||
query += ` ORDER BY created_at DESC, id DESC`
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var records []PreflightRecord
|
||||
for rows.Next() {
|
||||
record, err := scanPreflight(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
func (r Repository) SaveBenchmark(ctx context.Context, actor string, req BenchmarkRequest) (BenchmarkRecord, error) {
|
||||
now := r.now().Unix()
|
||||
if req.Profile == "" {
|
||||
req.Profile = PolicyProfileDev
|
||||
}
|
||||
record := BenchmarkRecord{
|
||||
PluginID: "",
|
||||
ArtifactID: req.ArtifactID,
|
||||
Profile: req.Profile,
|
||||
BenchmarkProfile: req.BenchmarkProfile,
|
||||
P95MS: req.P95MS,
|
||||
P99MS: req.P99MS,
|
||||
ErrorRate: req.ErrorRate,
|
||||
ActiveProxyCapacity: req.ActiveProxyCapacity,
|
||||
BaselineDiff: req.BaselineDiff,
|
||||
CreatedBy: actor,
|
||||
CreatedAt: now,
|
||||
}
|
||||
artifact, err := r.Artifact(ctx, req.ArtifactID)
|
||||
if err != nil {
|
||||
return BenchmarkRecord{}, err
|
||||
}
|
||||
record.PluginID = artifact.PluginID
|
||||
_, err = r.db.ExecContext(ctx, `
|
||||
INSERT INTO plugin_benchmarks(
|
||||
plugin_id, artifact_id, profile, benchmark_profile, p95_ms, p99_ms, error_rate,
|
||||
active_proxy_capacity, baseline_diff, created_by, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
record.PluginID, record.ArtifactID, record.Profile, record.BenchmarkProfile, record.P95MS, record.P99MS, record.ErrorRate,
|
||||
record.ActiveProxyCapacity, record.BaselineDiff, record.CreatedBy, record.CreatedAt)
|
||||
if err != nil {
|
||||
return BenchmarkRecord{}, err
|
||||
}
|
||||
id, err := lastInsertID(ctx, r.db)
|
||||
if err != nil {
|
||||
return BenchmarkRecord{}, err
|
||||
}
|
||||
record.ID = id
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (r Repository) ListBenchmarks(ctx context.Context, pluginID string) ([]BenchmarkRecord, error) {
|
||||
query := `
|
||||
SELECT id, plugin_id, artifact_id, profile, benchmark_profile, p95_ms, p99_ms, error_rate,
|
||||
active_proxy_capacity, baseline_diff, created_by, created_at
|
||||
FROM plugin_benchmarks`
|
||||
var args []any
|
||||
if pluginID != "" {
|
||||
query += ` WHERE plugin_id = ?`
|
||||
args = append(args, pluginID)
|
||||
}
|
||||
query += ` ORDER BY created_at DESC, id DESC`
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var records []BenchmarkRecord
|
||||
for rows.Next() {
|
||||
record, err := scanBenchmark(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
func (r Repository) LatestBenchmark(ctx context.Context, pluginID, artifactID, profile string) (BenchmarkRecord, error) {
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, plugin_id, artifact_id, profile, benchmark_profile, p95_ms, p99_ms, error_rate,
|
||||
active_proxy_capacity, baseline_diff, created_by, created_at
|
||||
FROM plugin_benchmarks
|
||||
WHERE plugin_id = ? AND artifact_id = ? AND profile = ?
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 1`, pluginID, artifactID, profile)
|
||||
benchmark, err := scanBenchmark(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return BenchmarkRecord{}, nil
|
||||
}
|
||||
return benchmark, err
|
||||
}
|
||||
|
||||
func (r Repository) RecordOperation(ctx context.Context, pluginID, artifactID, operation, status, actor, message string, metadata any) error {
|
||||
metadataJSON, err := marshalDefaultObject(metadata)
|
||||
if err != nil {
|
||||
@@ -691,6 +1045,55 @@ func scanBuild(row rowScanner) (BuildRecord, error) {
|
||||
return build, err
|
||||
}
|
||||
|
||||
func scanReview(row rowScanner) (ReviewRecord, error) {
|
||||
var review ReviewRecord
|
||||
err := row.Scan(
|
||||
&review.ID, &review.PluginID, &review.ArtifactID, &review.Profile, &review.RiskLevel,
|
||||
&review.ConfigHash, &review.ScopeHash, &review.RolloutHash, &review.RuntimeLimitsHash,
|
||||
&review.FeaturesHash, &review.PolicyHash, &review.Decision, &review.Notes, &review.ReviewedBy, &review.CreatedAt,
|
||||
)
|
||||
return review, err
|
||||
}
|
||||
|
||||
func scanWarningOverride(row rowScanner) (WarningOverrideRecord, error) {
|
||||
var override WarningOverrideRecord
|
||||
err := row.Scan(
|
||||
&override.ID, &override.PluginID, &override.ArtifactID, &override.Profile, &override.Action,
|
||||
&override.PolicyHash, &override.Reason, &override.CreatedBy, &override.ExpiresAt, &override.CreatedAt,
|
||||
)
|
||||
return override, err
|
||||
}
|
||||
|
||||
func scanAdvisory(row rowScanner) (AdvisoryRecord, error) {
|
||||
var advisory AdvisoryRecord
|
||||
err := row.Scan(
|
||||
&advisory.ID, &advisory.AdvisoryID, &advisory.Status, &advisory.Action, &advisory.ArtifactSHA256,
|
||||
&advisory.PluginID, &advisory.VersionRange, &advisory.DependencyName, &advisory.DependencyRange,
|
||||
&advisory.RecommendedAction, &advisory.FixedVersion, &advisory.Mitigation, &advisory.CreatedBy,
|
||||
&advisory.CreatedAt, &advisory.UpdatedAt,
|
||||
)
|
||||
return advisory, err
|
||||
}
|
||||
|
||||
func scanPreflight(row rowScanner) (PreflightRecord, error) {
|
||||
var record PreflightRecord
|
||||
err := row.Scan(
|
||||
&record.ID, &record.PluginID, &record.ArtifactID, &record.Profile, &record.Status,
|
||||
&record.ResultJSON, &record.CreatedBy, &record.CreatedAt,
|
||||
)
|
||||
return record, err
|
||||
}
|
||||
|
||||
func scanBenchmark(row rowScanner) (BenchmarkRecord, error) {
|
||||
var record BenchmarkRecord
|
||||
err := row.Scan(
|
||||
&record.ID, &record.PluginID, &record.ArtifactID, &record.Profile, &record.BenchmarkProfile,
|
||||
&record.P95MS, &record.P99MS, &record.ErrorRate, &record.ActiveProxyCapacity,
|
||||
&record.BaselineDiff, &record.CreatedBy, &record.CreatedAt,
|
||||
)
|
||||
return record, err
|
||||
}
|
||||
|
||||
func marshalDefaultObject(value any) (string, error) {
|
||||
if value == nil {
|
||||
return "{}", nil
|
||||
|
||||
@@ -53,6 +53,34 @@ const (
|
||||
RuntimeDisabled = "disabled"
|
||||
RuntimeDraining = "draining"
|
||||
|
||||
PolicyProfileDev = "dev"
|
||||
PolicyProfileStaging = "staging"
|
||||
PolicyProfileProd = "prod"
|
||||
|
||||
RiskLow = "low"
|
||||
RiskMedium = "medium"
|
||||
RiskHigh = "high"
|
||||
|
||||
GateSeverityWarning = "warning"
|
||||
GateSeverityBlocking = "blocking"
|
||||
GateSeverityInfo = "info"
|
||||
|
||||
GovernanceActionEnable = "enable"
|
||||
GovernanceActionRollback = "rollback"
|
||||
GovernanceActionPromotion = "promotion_apply"
|
||||
|
||||
AdvisoryActionDenylist = "denylist"
|
||||
AdvisoryActionQuarantine = "quarantine"
|
||||
AdvisoryActionRevoke = "revoke"
|
||||
AdvisoryActionMitigate = "mitigate"
|
||||
|
||||
ReviewDecisionApproved = "approved"
|
||||
ReviewDecisionRejected = "rejected"
|
||||
|
||||
AdvisoryStatusActive = "active"
|
||||
AdvisoryStatusRevoked = "revoked"
|
||||
AdvisoryStatusAcked = "acknowledged"
|
||||
|
||||
DefaultPriority = 100
|
||||
DefaultHandlerTimeout = 3 * time.Second
|
||||
DefaultManifestMaxBytes = 256 * 1024
|
||||
@@ -258,6 +286,196 @@ type SecretRecord struct {
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type PolicySnapshot struct {
|
||||
Profile string `json:"profile"`
|
||||
WarningOverrideTTLSeconds int64 `json:"warning_override_ttl_seconds"`
|
||||
ReviewRequiredRisk string `json:"review_required_risk"`
|
||||
WarnBenchmarkRegression float64 `json:"warn_benchmark_regression"`
|
||||
BlockBenchmarkRegression float64 `json:"block_benchmark_regression"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type GovernanceIssue struct {
|
||||
Code string `json:"code"`
|
||||
Severity string `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
PluginID string `json:"plugin_id,omitempty"`
|
||||
ArtifactID string `json:"artifact_id,omitempty"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
type GovernanceDecision struct {
|
||||
OK bool `json:"ok"`
|
||||
Action string `json:"action"`
|
||||
Profile string `json:"profile"`
|
||||
RiskLevel string `json:"risk_level"`
|
||||
PolicyHash string `json:"policy_hash"`
|
||||
ReviewRequired bool `json:"review_required"`
|
||||
WarningOverrideUsed bool `json:"warning_override_used"`
|
||||
Issues []GovernanceIssue `json:"issues"`
|
||||
Checks []GovernanceIssue `json:"checks"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type ConflictAnalysis struct {
|
||||
OK bool `json:"ok"`
|
||||
Issues []GovernanceIssue `json:"issues"`
|
||||
Plan DispatchPlan `json:"plan"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type PreflightCheck struct {
|
||||
Code string `json:"code"`
|
||||
Severity string `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
type PreflightResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Profile string `json:"profile"`
|
||||
Checks []PreflightCheck `json:"checks"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type GovernanceStatus struct {
|
||||
Decision GovernanceDecision `json:"decision"`
|
||||
Policy PolicySnapshot `json:"policy"`
|
||||
Reviews []ReviewRecord `json:"reviews"`
|
||||
WarningOverrides []WarningOverrideRecord `json:"warning_overrides"`
|
||||
Preflights []PreflightRecord `json:"preflights"`
|
||||
Benchmarks []BenchmarkRecord `json:"benchmarks"`
|
||||
Advisories []AdvisoryRecord `json:"advisories"`
|
||||
Conflicts ConflictAnalysis `json:"conflicts"`
|
||||
}
|
||||
|
||||
type ReviewRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Profile string `json:"profile"`
|
||||
RiskLevel string `json:"risk_level"`
|
||||
ConfigHash string `json:"config_hash"`
|
||||
ScopeHash string `json:"scope_hash"`
|
||||
RolloutHash string `json:"rollout_hash"`
|
||||
RuntimeLimitsHash string `json:"runtime_limits_hash"`
|
||||
FeaturesHash string `json:"features_hash"`
|
||||
PolicyHash string `json:"policy_hash"`
|
||||
Decision string `json:"decision"`
|
||||
Notes string `json:"notes"`
|
||||
ReviewedBy string `json:"reviewed_by"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type WarningOverrideRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Profile string `json:"profile"`
|
||||
Action string `json:"action"`
|
||||
PolicyHash string `json:"policy_hash"`
|
||||
Reason string `json:"reason"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type AdvisoryRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
AdvisoryID string `json:"advisory_id"`
|
||||
Status string `json:"status"`
|
||||
Action string `json:"action"`
|
||||
ArtifactSHA256 string `json:"artifact_sha256"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
VersionRange string `json:"version_range"`
|
||||
DependencyName string `json:"dependency_name"`
|
||||
DependencyRange string `json:"dependency_range"`
|
||||
RecommendedAction string `json:"recommended_action"`
|
||||
FixedVersion string `json:"fixed_version"`
|
||||
Mitigation string `json:"mitigation"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type PreflightRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Profile string `json:"profile"`
|
||||
Status string `json:"status"`
|
||||
ResultJSON string `json:"result_json"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type BenchmarkRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Profile string `json:"profile"`
|
||||
BenchmarkProfile string `json:"benchmark_profile"`
|
||||
P95MS float64 `json:"p95_ms"`
|
||||
P99MS float64 `json:"p99_ms"`
|
||||
ErrorRate float64 `json:"error_rate"`
|
||||
ActiveProxyCapacity int64 `json:"active_proxy_capacity"`
|
||||
BaselineDiff float64 `json:"baseline_diff"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type GovernanceReviewRequest struct {
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Profile string `json:"profile"`
|
||||
Decision string `json:"decision"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
type WarningOverrideRequest struct {
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Profile string `json:"profile"`
|
||||
Action string `json:"action"`
|
||||
Reason string `json:"reason"`
|
||||
TTLSeconds int64 `json:"ttl_seconds"`
|
||||
}
|
||||
|
||||
type PreflightRequest struct {
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Profile string `json:"profile"`
|
||||
Action string `json:"action"`
|
||||
ConfigJSON string `json:"config_json"`
|
||||
}
|
||||
|
||||
type SelfTestRequest struct {
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Profile string `json:"profile"`
|
||||
}
|
||||
|
||||
type AdvisoryRequest struct {
|
||||
AdvisoryID string `json:"advisory_id"`
|
||||
Status string `json:"status"`
|
||||
Action string `json:"action"`
|
||||
ArtifactSHA256 string `json:"artifact_sha256"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
VersionRange string `json:"version_range"`
|
||||
DependencyName string `json:"dependency_name"`
|
||||
DependencyRange string `json:"dependency_range"`
|
||||
RecommendedAction string `json:"recommended_action"`
|
||||
FixedVersion string `json:"fixed_version"`
|
||||
Mitigation string `json:"mitigation"`
|
||||
}
|
||||
|
||||
type BenchmarkRequest struct {
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Profile string `json:"profile"`
|
||||
BenchmarkProfile string `json:"benchmark_profile"`
|
||||
P95MS float64 `json:"p95_ms"`
|
||||
P99MS float64 `json:"p99_ms"`
|
||||
ErrorRate float64 `json:"error_rate"`
|
||||
ActiveProxyCapacity int64 `json:"active_proxy_capacity"`
|
||||
BaselineDiff float64 `json:"baseline_diff"`
|
||||
}
|
||||
|
||||
type ProxyConnectionSummary struct {
|
||||
ID uint64 `json:"id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
|
||||
@@ -23,6 +23,44 @@ type (
|
||||
ReloadConfig(config any) error
|
||||
}
|
||||
|
||||
PreflightCheck struct {
|
||||
Code string `json:"code"`
|
||||
Severity string `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
PreflightContext struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Profile string `json:"profile"`
|
||||
Action string `json:"action"`
|
||||
Config map[string]any `json:"config,omitempty"`
|
||||
Scope any `json:"scope,omitempty"`
|
||||
Rollout any `json:"rollout,omitempty"`
|
||||
RuntimeLimits any `json:"runtime_limits,omitempty"`
|
||||
Features []string `json:"features,omitempty"`
|
||||
}
|
||||
|
||||
PreflightResult struct {
|
||||
Checks []PreflightCheck `json:"checks"`
|
||||
}
|
||||
|
||||
SelfTestProfile struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
SelfTestResult struct {
|
||||
Checks []PreflightCheck `json:"checks"`
|
||||
}
|
||||
|
||||
PreflightChecker interface {
|
||||
Preflight(context any) (PreflightResult, error)
|
||||
}
|
||||
|
||||
SelfTester interface {
|
||||
SelfTest(profile SelfTestProfile) (SelfTestResult, error)
|
||||
}
|
||||
|
||||
Gateway interface {
|
||||
HandleConn(conn net.Conn)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user