mirror of
https://github.com/anand34577/ferrum.git
synced 2026-09-12 05:48:58 +00:00
22c1dd382c
- User-scoped API keys (Profile > API Keys) for 3rd-party REST API access
and MCP clients, each locked to one scope at creation, with expiry,
revocation, and last-used tracking.
- A hand-rolled MCP (Model Context Protocol) server exposing the fleet
(connections, nodes, guests, storage, pools, alerts, cluster status) as
read tools plus one admin-gated power-action tool, so Claude Code/Desktop
or any other MCP client can query and operate the fleet directly.
- Both the REST API and MCP are off by default and toggleable instance-wide
from Settings > API & MCP, enforced live on every request.
- Admin-managed AI providers (any OpenAI-chat-completions-compatible
endpoint) backing the AI Assistant's tool-calling loop, replacing the
single hardcoded provider.
- A built-in, zero-config, no-API-key local provider backed by Needle 2
(internal/needle) for fully offline tool-calling, wired in as a one-click
preset. Requires the operator to separately download the Needle 2 binary
and point FERRUM_NEEDLE_BIN at it -- Ferrum never fetches executable
content from the network itself; see README "Built-in LLM (Needle 2)".
- System settings (CORS allow-list, instance-wide toggles) moved to the
admin Settings UI; environment variables are now scoped to true
bootstrap-level config only (listen address, TLS, DB connection, secret,
optional Needle binary path).
- Fixed: node Journal tab 502'ing with "unexpected end of JSON input" on an
empty response, and separately with a decode error on PVE versions that
return a bare-string journal line instead of the documented {n,t} object.
- Fixed: bottom content padding disappearing on every page except the AI
Assistant (an unconditional h-full on the content wrapper let overflowing
content bleed through where the padding should render).
- Fixed: Profile page felt cramped despite a wide viewport (stray max-w-2xl
cap not present on the equivalent Settings page).
- Test coverage added for the previously-untested MCP package and the new
Needle adapter (20 new Go tests), plus a regression test for the journal
decode fix.
140 lines
4.1 KiB
Go
140 lines
4.1 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net/http"
|
|
"sync"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"ferrum/internal/pve"
|
|
)
|
|
|
|
type connectionInventory struct {
|
|
ConnectionID string `json:"connectionId"`
|
|
Name string `json:"name"`
|
|
Online bool `json:"online"`
|
|
Error string `json:"error,omitempty"`
|
|
Resources []pve.ClusterResource `json:"resources,omitempty"`
|
|
}
|
|
|
|
// inventoryOverview fetches /cluster/resources for every configured
|
|
// connection and returns them grouped by connection. A single unreachable
|
|
// connection does not fail the whole request — it's reported inline so the
|
|
// UI can show it as offline instead of blanking the page.
|
|
func (s *Server) inventoryOverview(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := s.db.QueryContext(r.Context(), `SELECT id, name FROM connections ORDER BY name`)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
type conn struct{ ID, Name string }
|
|
var conns []conn
|
|
for rows.Next() {
|
|
var c conn
|
|
if err := rows.Scan(&c.ID, &c.Name); err != nil {
|
|
rows.Close()
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
conns = append(conns, c)
|
|
}
|
|
rows.Close()
|
|
|
|
// Fan out concurrently — see buildFleetEntry in overview.go for why a
|
|
// sequential per-connection loop doesn't scale to a real fleet.
|
|
out := make([]connectionInventory, len(conns))
|
|
var wg sync.WaitGroup
|
|
sem := make(chan struct{}, fleetFanoutLimit)
|
|
for i, c := range conns {
|
|
wg.Add(1)
|
|
sem <- struct{}{}
|
|
go func(i int, c conn) {
|
|
defer wg.Done()
|
|
defer func() { <-sem }()
|
|
out[i] = s.buildInventoryEntry(r.Context(), c.ID, c.Name)
|
|
}(i, c)
|
|
}
|
|
wg.Wait()
|
|
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (s *Server) buildInventoryEntry(ctx context.Context, id, name string) connectionInventory {
|
|
entry := connectionInventory{ConnectionID: id, Name: name}
|
|
client, err := s.clientFor(ctx, id)
|
|
if err != nil {
|
|
slog.Warn("connection unreachable", "connectionId", id, "name", name, "error", err)
|
|
entry.Error = err.Error()
|
|
return entry
|
|
}
|
|
resources, err := client.ClusterResources(ctx)
|
|
if err != nil {
|
|
slog.Warn("cluster/resources call failed", "connectionId", id, "name", name, "error", err)
|
|
entry.Error = err.Error()
|
|
return entry
|
|
}
|
|
entry.Online = true
|
|
entry.Resources = resources
|
|
return entry
|
|
}
|
|
|
|
func (s *Server) guestPowerAction(w http.ResponseWriter, r *http.Request) {
|
|
connID := chi.URLParam(r, "id")
|
|
guestType := chi.URLParam(r, "type")
|
|
node := chi.URLParam(r, "node")
|
|
vmid, err := vmidParam(r)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
action := chi.URLParam(r, "action")
|
|
if !allowedPowerActions[action] {
|
|
writeErrorMsg(w, http.StatusBadRequest, "unsupported action")
|
|
return
|
|
}
|
|
// LXC suspend/resume (criu checkpoint) is best-effort and frequently
|
|
// unsupported by the container's kernel/rootfs — PVE reports a generic
|
|
// 5xx for it, not a helpful message. Qemu supports both reliably.
|
|
if guestType == "lxc" && (action == "suspend" || action == "resume") {
|
|
writeErrorMsg(w, http.StatusBadRequest, "suspend/resume is not supported for containers")
|
|
return
|
|
}
|
|
|
|
client, err := s.clientFor(r.Context(), connID)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
upid, err := client.GuestPowerAction(r.Context(), guestType, node, vmid, action)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
|
|
s.audit(r, "vm."+action, "vm", node+"/"+guestType+"/"+chi.URLParam(r, "vmid"))
|
|
writeJSON(w, http.StatusOK, map[string]string{"upid": upid})
|
|
}
|
|
|
|
var allowedPowerActions = map[string]bool{
|
|
"start": true, "stop": true, "shutdown": true, "reboot": true, "reset": true, "suspend": true, "resume": true,
|
|
}
|
|
|
|
func (s *Server) nodeTasks(w http.ResponseWriter, r *http.Request) {
|
|
connID := chi.URLParam(r, "id")
|
|
node := chi.URLParam(r, "node")
|
|
|
|
client, err := s.clientFor(r.Context(), connID)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
tasks, err := client.NodeTasks(r.Context(), node, 100)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, tasks)
|
|
}
|