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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user