feat(worker): one-command packaging and --check install verification
Deploy / deploy (push) Failing after 15m27s

Adds scripts/publish.ps1, which produces a self-contained single-file exe plus
the headless AE build script, optionally zipped for copying to a render machine.

Adds `VFXReviewWorker.exe --check`: resolves every configured path, confirms
aerender/AfterFX/the build script exist, verifies each pathMappings target is
reachable from that machine, and pings the server to confirm the API key is
accepted — exiting non-zero if anything would stop the worker running. Catches
a wrong path at install time rather than in the logs later.

Verified from the published binary: the single-file exe resolves its scripts
folder correctly, so the preview build script ships alongside the exe rather
than embedded and can be patched without a rebuild.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-08-02 15:17:35 +02:00
parent ae58dc0366
commit 43c717bb85
4 changed files with 214 additions and 6 deletions
+37 -6
View File
@@ -63,16 +63,47 @@ the artist's AEP (then it is pure `aerender`), or the ffmpeg engine.
Preview generation (Phase 4) and validation (Phase 3) plug into this same service later. Preview generation (Phase 4) and validation (Phase 3) plug into this same service later.
## Build ## Packaging for another machine
Requires the .NET 8+ SDK. On a machine with the .NET 8+ SDK (only the packaging machine needs it):
```bash ```powershell
cd RenderWorker/VFXReviewWorker .\scripts\publish.ps1 -Zip
dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -o publish
``` ```
Produces a single `publish/VFXReviewWorker.exe` — no runtime install needed on render nodes. Produces `publish\win-x64\` containing exactly two things: a self-contained
`VFXReviewWorker.exe` (~67 MB, no .NET runtime needed on the target) and
`scripts\vfxr_build_preview.jsx`, which the exe loads from its own folder at
runtime. **Keep them together** — the script is deliberately not embedded in the
exe so the slate/preview build can be patched without a rebuild. `-Zip` also
writes `VFXReviewWorker_<date>.zip` for copying.
### Installing on the target
1. Copy the folder to the render machine, e.g. `C:\pipeline\VFXReviewWorker`.
2. Create `C:\ProgramData\VFXReviewWorker\config.json` (next section).
3. **Verify before installing the service:**
```powershell
.\VFXReviewWorker.exe --check
```
This resolves every path, confirms `aerender.exe` / `AfterFX.com` / the build
script exist, checks each `pathMappings` target is reachable *from that
machine*, and pings the server to confirm the API key is accepted. It exits
non-zero if anything would stop the worker running, so a wrong path is caught
here instead of in the logs an hour later.
4. Install the service (below), then confirm the machine appears on the web
**Pipeline → Machines** page.
Run `--check` again under the *service account* (`runas /user:STUDIO\svc-render`)
if the service will run as someone other than the logged-in artist — mapped
drives and share permissions differ per account, and that is the most common
cause of a worker that registers fine but cannot find footage.
Upgrading later: stop the service, replace the folder, start it again. Config
lives in ProgramData and is untouched.
## Configure ## Configure
+115
View File
@@ -0,0 +1,115 @@
using System.Net.Http.Headers;
namespace VFXReviewWorker;
/// <summary>
/// `VFXReviewWorker.exe --check` — install verification for a new machine.
/// Resolves every path and dependency the worker needs, reports what is
/// missing, and exits non-zero if anything would stop it running. Cheaper than
/// installing the service and reading logs to find a typo'd path.
/// </summary>
public static class Preflight
{
public static async Task<int> RunAsync(string? configPath)
{
var ok = true;
Console.WriteLine("VFXReview RenderWorker preflight");
Console.WriteLine();
var resolvedConfigPath = configPath ?? WorkerOptions.DefaultConfigPath;
WorkerOptions? options = null;
try
{
options = WorkerOptions.Load(configPath);
ok &= Report("config file", resolvedConfigPath, true);
}
catch (Exception ex)
{
Report("config file", resolvedConfigPath, false, ex.Message);
Console.WriteLine();
Console.WriteLine("Cannot continue without a config file. See RenderWorker/README.md.");
return 1;
}
Console.WriteLine($" machine name : {options.MachineName}");
Console.WriteLine($" server : {options.ServerUrl}");
Console.WriteLine($" api key : {(string.IsNullOrWhiteSpace(options.ApiKey) ? "(missing)" : "set")}");
Console.WriteLine();
ok &= Report("aerender.exe", options.AerenderPath, File.Exists(options.AerenderPath));
var afterFx = options.ResolveAfterFxPath();
ok &= Report("AfterFX.com", afterFx, File.Exists(afterFx),
"needed for preview builds only");
var script = options.ResolvePreviewScriptPath();
ok &= Report("preview script", script, File.Exists(script),
"needed for preview builds only");
if (!string.IsNullOrWhiteSpace(options.FfmpegPath))
{
Report("ffmpeg", options.FfmpegPath!, File.Exists(options.FfmpegPath!),
"optional — thumbnails are skipped without it");
}
else
{
Console.WriteLine(" [ - ] ffmpeg not configured (thumbnails will be skipped)");
}
// Path mappings: prove the mapped roots are actually reachable here.
Console.WriteLine();
if (options.PathMappings.Count == 0)
{
Console.WriteLine(" [ ! ] no pathMappings configured — manifest UNC paths will be used as-is");
}
foreach (var m in options.PathMappings)
{
var reachable = Directory.Exists(m.To);
Report($"path map {m.From}", m.To, reachable, "target must be reachable from this machine");
ok &= reachable;
}
// Server reachability + API key validity in one call.
Console.WriteLine();
try
{
using var http = new HttpClient { BaseAddress = new Uri(options.ServerUrl.TrimEnd('/') + "/"), Timeout = TimeSpan.FromSeconds(15) };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", options.ApiKey);
var res = await http.GetAsync("api/ext/projects");
if (res.IsSuccessStatusCode)
{
Console.WriteLine(" [ok ] server reachable and API key accepted");
}
else if ((int)res.StatusCode == 401)
{
Console.WriteLine(" [FAIL] server reachable but API key was rejected (401)");
ok = false;
}
else
{
Console.WriteLine($" [FAIL] server responded {(int)res.StatusCode}");
ok = false;
}
}
catch (Exception ex)
{
Console.WriteLine($" [FAIL] cannot reach {options.ServerUrl}: {ex.Message}");
ok = false;
}
Console.WriteLine();
Console.WriteLine(ok
? "All checks passed — safe to install the service."
: "One or more checks FAILED — fix the above before installing.");
return ok ? 0 : 1;
}
private static bool Report(string label, string path, bool exists, string? note = null)
{
var status = exists ? "ok " : "FAIL";
Console.WriteLine($" [{status}] {label,-16} {path}");
if (!exists && note is not null)
{
Console.WriteLine($" ({note})");
}
return exists;
}
}
+9
View File
@@ -11,6 +11,14 @@ using VFXReviewWorker;
// Install as a service: see RenderWorker/README.md // Install as a service: see RenderWorker/README.md
var configPath = args.FirstOrDefault(a => !a.StartsWith('-')); var configPath = args.FirstOrDefault(a => !a.StartsWith('-'));
// Install verification on a new machine — resolves paths, checks tools and
// pings the server, then exits. Never starts the service loop.
if (args.Any(a => a.Equals("--check", StringComparison.OrdinalIgnoreCase)))
{
return await Preflight.RunAsync(configPath);
}
var options = WorkerOptions.Load(configPath); var options = WorkerOptions.Load(configPath);
// Positional args (config path) are consumed above and deliberately not // Positional args (config path) are consumed above and deliberately not
@@ -28,3 +36,4 @@ builder.Services.AddSingleton<JobExecutor>();
builder.Services.AddHostedService<WorkerService>(); builder.Services.AddHostedService<WorkerService>();
await builder.Build().RunAsync(); await builder.Build().RunAsync();
return 0;
+53
View File
@@ -0,0 +1,53 @@
<#
.SYNOPSIS
Packages the RenderWorker for installation on another machine.
.DESCRIPTION
Produces a self-contained single-file build (no .NET runtime needed on the
target) plus the headless AE build script, and zips it for copying.
.EXAMPLE
.\publish.ps1
.\publish.ps1 -Zip
#>
[CmdletBinding()]
param(
[string]$OutDir = "",
[switch]$Zip
)
$ErrorActionPreference = "Stop"
$root = Split-Path $PSScriptRoot -Parent
if (-not $OutDir) { $OutDir = Join-Path $root "publish\win-x64" }
Write-Host "Publishing self-contained worker..." -ForegroundColor Cyan
& dotnet publish (Join-Path $root "VFXReviewWorker\VFXReviewWorker.csproj") `
-c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o $OutDir
if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed" }
# The exe reads scripts\vfxr_build_preview.jsx from its own folder at runtime.
$scriptOut = Join-Path $OutDir "scripts\vfxr_build_preview.jsx"
if (-not (Test-Path $scriptOut)) { throw "Preview build script missing from output: $scriptOut" }
Remove-Item (Join-Path $OutDir "*.pdb") -ErrorAction SilentlyContinue
Write-Host ""
Write-Host "Package contents:" -ForegroundColor Green
Get-ChildItem $OutDir -Recurse -File | ForEach-Object {
Write-Host (" {0,-40} {1,8:N0} KB" -f $_.FullName.Substring($OutDir.Length).TrimStart('\'), ($_.Length / 1KB))
}
if ($Zip) {
$zipPath = Join-Path $root ("VFXReviewWorker_{0}.zip" -f (Get-Date -Format "yyyyMMdd"))
if (Test-Path $zipPath) { Remove-Item $zipPath }
Compress-Archive -Path (Join-Path $OutDir "*") -DestinationPath $zipPath
Write-Host ""
Write-Host "Zipped to: $zipPath" -ForegroundColor Green
}
Write-Host ""
Write-Host "Next on the target machine:" -ForegroundColor Cyan
Write-Host " 1. Copy this folder to e.g. C:\pipeline\VFXReviewWorker"
Write-Host " 2. Create C:\ProgramData\VFXReviewWorker\config.json (see README)"
Write-Host " 3. Verify: .\VFXReviewWorker.exe --check"
Write-Host " 4. Install: sc.exe create VFXReviewRenderWorker binPath= ... start= auto obj= ... password= ..."