feat(render+node-agent+admin): install fonts on all render nodes + verify
Build backend images / build content-svc (push) Failing after 53s
Build backend images / build file-svc (push) Failing after 47s
Build backend images / build gateway (push) Failing after 52s
Build backend images / build identity-svc (push) Failing after 58s
Build backend images / build notification-svc (push) Failing after 55s
Build backend images / build render-svc (push) Failing after 59s
Build backend images / build studio-svc (push) Failing after 48s
Build backend images / build content-svc (push) Failing after 53s
Build backend images / build file-svc (push) Failing after 47s
Build backend images / build gateway (push) Failing after 52s
Build backend images / build identity-svc (push) Failing after 58s
Build backend images / build notification-svc (push) Failing after 55s
Build backend images / build render-svc (push) Failing after 59s
Build backend images / build studio-svc (push) Failing after 48s
Push a font once → every node installs it → admin sees per-node status. - render-svc: font_requests + node_fonts tables (mig 25); admin GET/POST/DELETE /v1/node-fonts (with per-node status matrix); internal (HMAC) GET pending + POST status for node-agents - node-agent: fontSyncLoop polls pending fonts every 60s, downloads, installs (Windows Fonts dir + registry / macOS / linux fc-cache), reports Installed/Failed - gateway: /v1/node-fonts/* → render - admin /admin/node-fonts: upload a .ttf/.otf → install on all nodes; per-node Installed/Pending/Failed badges + counts + delete Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -67,6 +67,7 @@ func main() {
|
||||
snapH := handlers.NewSnapshotHandler(store)
|
||||
exportH := handlers.NewExportHandler(store, mc, minioBucket)
|
||||
nodeH := handlers.NewNodeHandler(store)
|
||||
fontH := handlers.NewFontHandler(store)
|
||||
internalH := handlers.NewInternalHandler(store, notifyClient, mc, minioTemplatesBucket, minioBucket)
|
||||
|
||||
// ── Router ────────────────────────────────────────────────────────────────
|
||||
@@ -136,12 +137,22 @@ func main() {
|
||||
v1.GET("/node-updates", auth, admin, nodeH.ListUpdates)
|
||||
v1.POST("/node-updates/:update_id/rollout", auth, admin, nodeH.Rollout)
|
||||
|
||||
// ── Node fonts: install a font on all nodes + verify (admin) ──────────────
|
||||
nodeFonts := v1.Group("/node-fonts", auth, admin)
|
||||
{
|
||||
nodeFonts.GET("", fontH.List)
|
||||
nodeFonts.POST("", fontH.Create)
|
||||
nodeFonts.DELETE("/:id", fontH.Delete)
|
||||
}
|
||||
|
||||
// ── Internal (node agents only — HMAC auth) ───────────────────────────────
|
||||
internal := v1.Group("/internal", nodeAuth)
|
||||
{
|
||||
internal.POST("/nodes/:node_id/heartbeat", internalH.Heartbeat)
|
||||
internal.POST("/nodes/:node_id/online", internalH.Online)
|
||||
internal.POST("/nodes/:node_id/cache-update", internalH.CacheUpdate)
|
||||
internal.GET("/nodes/:node_id/fonts/pending", fontH.Pending)
|
||||
internal.POST("/nodes/:node_id/fonts/:request_id/status", fontH.Report)
|
||||
internal.POST("/render/jobs/claim", internalH.Claim)
|
||||
internal.POST("/render/jobs/:job_id/preview", internalH.Preview)
|
||||
internal.POST("/render/jobs/:job_id/output-upload-url", internalH.OutputUploadURL)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/flatrender/render-svc/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (s *Store) CreateFontRequest(ctx context.Context, name, systemName, fileURL string) (*models.FontRequest, error) {
|
||||
var fr models.FontRequest
|
||||
var sys *string
|
||||
if systemName != "" {
|
||||
sys = &systemName
|
||||
}
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`INSERT INTO render.font_requests (name, system_name, file_url)
|
||||
VALUES ($1, $2, $3) RETURNING id, name, system_name, file_url, created_at`,
|
||||
name, sys, fileURL).Scan(&fr.ID, &fr.Name, &fr.SystemName, &fr.FileURL, &fr.CreatedAt)
|
||||
return &fr, err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteFontRequest(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM render.font_requests WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListFontRequestsWithStatus returns every font request with a per-node status matrix
|
||||
// (nodes without an explicit row are reported as Pending).
|
||||
func (s *Store) ListFontRequestsWithStatus(ctx context.Context) ([]models.FontRequestWithStatus, error) {
|
||||
// 1) active nodes
|
||||
type node struct {
|
||||
id uuid.UUID
|
||||
name string
|
||||
}
|
||||
var nodes []node
|
||||
rows, err := s.pool.Query(ctx, `SELECT id, name FROM render.render_nodes ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var n node
|
||||
if err := rows.Scan(&n.id, &n.name); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// 2) requests
|
||||
var reqs []models.FontRequest
|
||||
rrows, err := s.pool.Query(ctx, `SELECT id, name, system_name, file_url, created_at FROM render.font_requests ORDER BY created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rrows.Next() {
|
||||
var fr models.FontRequest
|
||||
if err := rrows.Scan(&fr.ID, &fr.Name, &fr.SystemName, &fr.FileURL, &fr.CreatedAt); err != nil {
|
||||
rrows.Close()
|
||||
return nil, err
|
||||
}
|
||||
reqs = append(reqs, fr)
|
||||
}
|
||||
rrows.Close()
|
||||
|
||||
// 3) explicit per-node statuses
|
||||
type key struct {
|
||||
req uuid.UUID
|
||||
node uuid.UUID
|
||||
}
|
||||
statuses := map[key]models.NodeFontStatus{}
|
||||
srows, err := s.pool.Query(ctx, `SELECT font_request_id, node_id, status, error, updated_at FROM render.node_fonts`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for srows.Next() {
|
||||
var reqID, nodeID uuid.UUID
|
||||
var st models.NodeFontStatus
|
||||
if err := srows.Scan(&reqID, &nodeID, &st.Status, &st.Error, &st.UpdatedAt); err != nil {
|
||||
srows.Close()
|
||||
return nil, err
|
||||
}
|
||||
st.NodeID = nodeID
|
||||
statuses[key{reqID, nodeID}] = st
|
||||
}
|
||||
srows.Close()
|
||||
|
||||
out := make([]models.FontRequestWithStatus, 0, len(reqs))
|
||||
for _, fr := range reqs {
|
||||
item := models.FontRequestWithStatus{FontRequest: fr, TotalNodes: len(nodes)}
|
||||
for _, n := range nodes {
|
||||
st, ok := statuses[key{fr.ID, n.id}]
|
||||
if !ok {
|
||||
st = models.NodeFontStatus{NodeID: n.id, Status: "Pending"}
|
||||
}
|
||||
st.NodeName = n.name
|
||||
if st.Status == "Installed" {
|
||||
item.InstalledCount++
|
||||
}
|
||||
item.Nodes = append(item.Nodes, st)
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PendingFontsForNode returns fonts this node has not yet Installed (Pending or Failed → retry).
|
||||
func (s *Store) PendingFontsForNode(ctx context.Context, nodeID uuid.UUID) ([]models.PendingFont, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT fr.id, fr.name, fr.system_name, fr.file_url
|
||||
FROM render.font_requests fr
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM render.node_fonts nf
|
||||
WHERE nf.font_request_id = fr.id AND nf.node_id = $1 AND nf.status = 'Installed'
|
||||
)`, nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []models.PendingFont
|
||||
for rows.Next() {
|
||||
var f models.PendingFont
|
||||
if err := rows.Scan(&f.ID, &f.Name, &f.SystemName, &f.FileURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ReportFontStatus(ctx context.Context, nodeID, requestID uuid.UUID, status, errMsg string) error {
|
||||
var e *string
|
||||
if errMsg != "" {
|
||||
e = &errMsg
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO render.node_fonts (node_id, font_request_id, status, error, updated_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (node_id, font_request_id)
|
||||
DO UPDATE SET status = EXCLUDED.status, error = EXCLUDED.error, updated_at = NOW()`,
|
||||
nodeID, requestID, status, e)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/flatrender/render-svc/internal/db"
|
||||
"github.com/flatrender/render-svc/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type FontHandler struct {
|
||||
store *db.Store
|
||||
}
|
||||
|
||||
func NewFontHandler(store *db.Store) *FontHandler {
|
||||
return &FontHandler{store: store}
|
||||
}
|
||||
|
||||
// ── Admin ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /v1/node-fonts
|
||||
func (h *FontHandler) List(c *gin.Context) {
|
||||
items, err := h.store.ListFontRequestsWithStatus(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []models.FontRequestWithStatus{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
// POST /v1/node-fonts
|
||||
func (h *FontHandler) Create(c *gin.Context) {
|
||||
var req models.CreateFontRequestBody
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
fr, err := h.store.CreateFontRequest(c.Request.Context(), req.Name, req.SystemName, req.FileURL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, fr)
|
||||
}
|
||||
|
||||
// DELETE /v1/node-fonts/:id
|
||||
func (h *FontHandler) Delete(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid id"})
|
||||
return
|
||||
}
|
||||
if err := h.store.DeleteFontRequest(c.Request.Context(), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ── Internal (node agents, HMAC auth) ────────────────────────────────────────
|
||||
|
||||
// GET /v1/internal/nodes/:node_id/fonts/pending
|
||||
func (h *FontHandler) Pending(c *gin.Context) {
|
||||
nodeID, err := uuid.Parse(c.Param("node_id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid node_id"})
|
||||
return
|
||||
}
|
||||
fonts, err := h.store.PendingFontsForNode(c.Request.Context(), nodeID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if fonts == nil {
|
||||
fonts = []models.PendingFont{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"fonts": fonts})
|
||||
}
|
||||
|
||||
// POST /v1/internal/nodes/:node_id/fonts/:request_id/status
|
||||
func (h *FontHandler) Report(c *gin.Context) {
|
||||
nodeID, err := uuid.Parse(c.Param("node_id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid node_id"})
|
||||
return
|
||||
}
|
||||
reqID, err := uuid.Parse(c.Param("request_id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid request_id"})
|
||||
return
|
||||
}
|
||||
var body models.FontStatusReport
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
status := body.Status
|
||||
if status != "Installed" {
|
||||
status = "Failed"
|
||||
}
|
||||
if err := h.store.ReportFontStatus(c.Request.Context(), nodeID, reqID, status, body.Error); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type FontRequest struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SystemName *string `json:"system_name,omitempty"`
|
||||
FileURL string `json:"file_url"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type NodeFontStatus struct {
|
||||
NodeID uuid.UUID `json:"node_id"`
|
||||
NodeName string `json:"node_name"`
|
||||
Status string `json:"status"` // Pending | Installed | Failed
|
||||
Error *string `json:"error,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type FontRequestWithStatus struct {
|
||||
FontRequest
|
||||
InstalledCount int `json:"installed_count"`
|
||||
TotalNodes int `json:"total_nodes"`
|
||||
Nodes []NodeFontStatus `json:"nodes"`
|
||||
}
|
||||
|
||||
type CreateFontRequestBody struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
SystemName string `json:"system_name"`
|
||||
FileURL string `json:"file_url" binding:"required"`
|
||||
}
|
||||
|
||||
// Sent to node-agents.
|
||||
type PendingFont struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SystemName *string `json:"system_name,omitempty"`
|
||||
FileURL string `json:"file_url"`
|
||||
}
|
||||
|
||||
type FontStatusReport struct {
|
||||
Status string `json:"status"` // Installed | Failed
|
||||
Error string `json:"error"`
|
||||
}
|
||||
Reference in New Issue
Block a user