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

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:
soroush.asadi
2026-06-03 06:33:48 +03:30
parent ca0c05db10
commit 7f2f65dd8a
14 changed files with 648 additions and 3 deletions
+1
View File
@@ -135,6 +135,7 @@ func main() {
v1.Any("/snapshots/*path", heavyRL, auth, render.Handler())
v1.Any("/exports/*path", apiRL, auth, render.Handler())
v1.Any("/nodes/*path", apiRL, auth, render.Handler())
v1.Any("/node-fonts/*path", apiRL, auth, render.Handler())
v1.Any("/node-updates/*path", apiRL, auth, render.Handler())
// ── Notification Service ──────────────────────────────────────────────────
+40 -1
View File
@@ -121,9 +121,10 @@ func main() {
// Main loops
var wg sync.WaitGroup
wg.Add(2)
wg.Add(3)
go func() { defer wg.Done(); agent.heartbeatLoop(ctx) }()
go func() { defer wg.Done(); agent.pollLoop(ctx) }()
go func() { defer wg.Done(); agent.fontSyncLoop(ctx) }()
wg.Wait()
log.Printf("shutdown complete")
}
@@ -144,6 +145,44 @@ func (a *Agent) registerOnline(ctx context.Context) error {
return nil
}
// ── Font sync loop ────────────────────────────────────────────────────────────
// Periodically installs any fonts the orchestrator wants on this node and reports
// per-font status, so the admin can verify installation.
func (a *Agent) fontSyncLoop(ctx context.Context) {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
a.syncFonts(ctx) // run once on startup
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
a.syncFonts(ctx)
}
}
}
func (a *Agent) syncFonts(ctx context.Context) {
fonts, err := a.orch.PendingFonts(ctx, a.cfg.NodeID)
if err != nil {
return // transient; try again next tick
}
for _, f := range fonts {
name := f.SystemName
if name == "" {
name = f.Name
}
if err := runner.InstallFont(ctx, f.FileURL, name); err != nil {
log.Printf("font install failed (%s): %v", f.Name, err)
_ = a.orch.ReportFont(ctx, a.cfg.NodeID, f.ID, "Failed", err.Error())
continue
}
log.Printf("font installed: %s", f.Name)
_ = a.orch.ReportFont(ctx, a.cfg.NodeID, f.ID, "Installed", "")
}
}
// ── Heartbeat loop ────────────────────────────────────────────────────────────
func (a *Agent) heartbeatLoop(ctx context.Context) {
@@ -61,6 +61,48 @@ func decodeJSON(resp *http.Response, out any) error {
return json.NewDecoder(resp.Body).Decode(out)
}
// ── Fonts ───────────────────────────────────────────────────────────────────
type PendingFont struct {
ID string `json:"id"`
Name string `json:"name"`
SystemName string `json:"system_name"`
FileURL string `json:"file_url"`
}
type pendingFontsResp struct {
Fonts []PendingFont `json:"fonts"`
}
// PendingFonts returns fonts this node still needs to install.
func (c *Client) PendingFonts(ctx context.Context, nodeID string) ([]PendingFont, error) {
resp, err := c.do(ctx, http.MethodGet, fmt.Sprintf("/v1/internal/nodes/%s/fonts/pending", nodeID), nil)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("pending fonts status %d", resp.StatusCode)
}
var out pendingFontsResp
if err := decodeJSON(resp, &out); err != nil {
return nil, err
}
return out.Fonts, nil
}
// ReportFont reports a font install result (Installed | Failed).
func (c *Client) ReportFont(ctx context.Context, nodeID, requestID, status, errMsg string) error {
resp, err := c.do(ctx, http.MethodPost,
fmt.Sprintf("/v1/internal/nodes/%s/fonts/%s/status", nodeID, requestID),
map[string]string{"status": status, "error": errMsg})
if err != nil {
return err
}
resp.Body.Close()
return nil
}
// ── Domain types ──────────────────────────────────────────────────────────────
// OnlineRequest is sent once on startup to mark the node Ready.
@@ -0,0 +1,92 @@
package runner
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
)
// InstallFont downloads a font file and installs it into the OS so After Effects
// can use it. Returns nil on success. Best-effort per platform.
func InstallFont(ctx context.Context, fileURL, name string) error {
ext := filepath.Ext(strings.Split(fileURL, "?")[0])
if ext == "" {
ext = ".ttf"
}
fileName := sanitizeFontName(name) + ext
tmpDir, err := os.MkdirTemp("", "fr-font-")
if err != nil {
return fmt.Errorf("tmp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
tmp := filepath.Join(tmpDir, fileName)
if _, err := DownloadFile(ctx, fileURL, tmp); err != nil {
return fmt.Errorf("download: %w", err)
}
switch runtime.GOOS {
case "windows":
return installFontWindows(ctx, tmp, fileName)
case "darwin":
return copyFile(tmp, filepath.Join(os.Getenv("HOME"), "Library", "Fonts", fileName))
default: // linux
dir := filepath.Join(os.Getenv("HOME"), ".local", "share", "fonts")
_ = os.MkdirAll(dir, 0o755)
if err := copyFile(tmp, filepath.Join(dir, fileName)); err != nil {
return err
}
_ = exec.CommandContext(ctx, "fc-cache", "-f").Run()
return nil
}
}
func installFontWindows(ctx context.Context, src, fileName string) error {
// Prefer the machine-wide Fonts dir; fall back to the per-user dir.
dst := filepath.Join(os.Getenv("WINDIR"), "Fonts", fileName)
if err := copyFile(src, dst); err != nil {
userFonts := filepath.Join(os.Getenv("LOCALAPPDATA"), "Microsoft", "Windows", "Fonts")
_ = os.MkdirAll(userFonts, 0o755)
dst = filepath.Join(userFonts, fileName)
if err := copyFile(src, dst); err != nil {
return fmt.Errorf("copy font: %w", err)
}
}
// Register so Windows/AE pick it up without a reboot (best-effort; needs admin).
_ = exec.CommandContext(ctx, "reg", "add",
`HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts`,
"/v", fileName, "/t", "REG_SZ", "/d", dst, "/f").Run()
return nil
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Sync()
}
func sanitizeFontName(s string) string {
s = strings.TrimSpace(s)
repl := strings.NewReplacer("/", "_", "\\", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_", " ", "_")
s = repl.Replace(s)
if s == "" {
return "font"
}
return s
}
+11
View File
@@ -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)
+144
View File
@@ -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
}
+107
View File
@@ -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)
}
+49
View File
@@ -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"`
}