Repository navigation
Expand file tree
/
Copy pathBuildPluginCommand.cs
More file actions
872 lines (831 loc) · 49.2 KB
/
Copy pathBuildPluginCommand.cs
File metadata and controls
872 lines (831 loc) · 49.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
namespace MeshWeaver.Cli;
/// <summary>
/// <c>memex build plugin <path> --image <image></c> — the whole CI contract for building a
/// plugin, in one verb (<c>Doc/Architecture/InMeshBuildAndTest</c>).
///
/// <para>A plugin repo's CI job becomes three lines: check out, install this tool, run this. The
/// tool pulls the image, pins it by DIGEST, and runs the platform's own builder and tester inside
/// it — so nothing in the job knows about docker, mounts, seeds, allow-files or registries.</para>
///
/// <para><b>Two stages, and the split is deliberate (#1763).</b> The same tool produces and
/// consumes: <c>compile … --output /bake</c> emits one bundle per package plus the bake identity,
/// then the gate runs with <c>--seed /bake</c> and stands a mesh up on <b>those bytes</b>. Testing
/// the baked bytes is a strictly stronger claim than a fused pass, because the bytes judged are the
/// bytes that ship.</para>
///
/// <para><b>Two IMAGES, and that split is deliberate too (#3022 for the lanes, #3113 here).</b> The
/// TESTER image executes; the PLATFORM (portal) image supplies the reference set, the framework
/// identity and the runtime. Both stages run from a host composed of the portal's <c>/app</c> with
/// the tester CLI beside it — see <see cref="GateHost"/> for what the tester's subset <c>/app</c>
/// cannot compile and why there is no fallback to it.</para>
/// </summary>
public sealed class BuildPluginCommand(TextWriter output, TextWriter error)
{
/// <summary>The docker plumbing this verb shares with <c>build project</c>: the pull
/// retry, the digest pin, and the process launch. One copy, so the pin cannot drift.</summary>
private readonly ImageRunner _runner = new(output, error);
/// <summary>
/// The file the compile stage writes to record which framework the bundles were built against.
/// Its presence is the POSITIVE signal that a bake actually happened: a compile that emitted no
/// bundles still exits 0, so "the command returned" proves nothing.
/// </summary>
private const string BakeIdentityFile = "framework-mvid.txt";
public async Task<int> RunAsync(BuildPluginOptions options, CancellationToken ct)
{
// Two /app extractions plus the composed host is roughly a gigabyte per invocation, and
// the path carries this process's id — so a later run never reuses it and stale copies
// accumulate until the disk is full. The bake is deliberately NOT in here: the caller asked
// for it and may well read it after this returns.
var hostRoot = Path.Combine(Path.GetTempPath(), $"memex-host-{Environment.ProcessId}");
try
{
return await RunCoreAsync(options, hostRoot, ct);
}
finally
{
await GateHost.DiscardTree(hostRoot, output);
}
}
private async Task<int> RunCoreAsync(
BuildPluginOptions options, string hostRoot, CancellationToken ct)
{
var repo = Path.GetFullPath(options.PluginPath);
if (!Directory.Exists(repo))
{
await error.WriteLineAsync($"error: plugin path '{repo}' does not exist.");
return 2;
}
// Argument validation happens BEFORE any docker work, so a misconfigured job fails in
// seconds naming the input — not minutes later inside a container. (The first version
// checked --upstream-seed after stage 1, and the 'proof' of its refusal path turned out
// to have died at the container entrypoint before ever reaching the check.)
if ((options.RegistryModules is { Length: > 0 } || options.UpstreamSeed is { Length: > 0 })
&& (options.RegistryUrl is not { Length: > 0 } || options.RegistryKey is not { Length: > 0 }))
{
await error.WriteLineAsync(
"error: --registry-modules / --upstream-seed need --registry-url and a key "
+ "(MW_REGISTRY_KEY or --registry-key). A dependency install or seed that silently "
+ "skips is a gate that tests nothing, so this refuses rather than proceeding.");
return 6;
}
// ── the PLATFORM image is REQUIRED, and there is no fallback to the tester's /app ────────
// 🚨 #3113. Composing only the tester's /app is not "backwards compatible", it is the
// defect: content that binds a portal-shipped assembly cannot compile against a strict
// subset of the portal's reference set, and the failure reads as a CONTENT error on source
// nobody changed. node-repo-gate.yml refuses the same way (`platform-image-digest is empty
// and allow-unpinned is not set … There is no fallback to the tester's /app`), so this
// refuses rather than silently compiling against the wrong reference set.
if (options.PlatformImage is not { Length: > 0 })
{
await error.WriteLineAsync(
"error: --platform-image is required — pass the PORTAL image "
+ "(e.g. meshweaver.azurecr.io/memex-portal-ai@sha256:…, pinned from the SAME CD wave "
+ "as --image). The tester image only EXECUTES; the portal supplies the reference "
+ "set, the framework identity and the runtime. There is no fallback to the tester's "
+ "/app: it is a strict subset of the portal's (88 vs 219 assemblies on "
+ "3.0.0-rc9.ci.7534), so content binding a portal-only assembly fails to "
+ "compile against it (MeshWeaver#3022 / #3113).");
return 9;
}
if (GateHost.NamesTheTesterImage(options.PlatformImage))
{
await error.WriteLineAsync(
$"error: --platform-image names the TESTER image ('{options.PlatformImage}') — pass "
+ "the PORTAL image (meshweaver.azurecr.io/memex-portal-ai); the tester only executes. "
+ "Accepting it would silently restore the reference-set gap this argument closes.");
return 9;
}
// Pin by DIGEST once, and use it for both stages. The workflow this replaces already did
// this (`${IMAGE%%:*}@${DIGEST}`) for a reason: a tag can move between the compile and the
// gate, and then the bytes tested are not the bytes baked — which is the one claim the
// two-stage split exists to make.
var pinned = await _runner.PullImage(options.Image, ct);
if (pinned is null) return 4;
await output.WriteLineAsync($"tester image: {pinned}");
var platform = await _runner.PullImage(options.PlatformImage, ct);
if (platform is null) return 4;
await output.WriteLineAsync($"platform image: {platform}");
// ── the composed GATE HOST: the portal's /app with the tester CLI beside it ──────────────
// (hostRoot is created by RunAsync, which also removes it however this returns.)
var testerApp = Path.Combine(hostRoot, "tester-app");
var portalApp = Path.Combine(hostRoot, "portal-app");
var gateHost = Path.Combine(hostRoot, "gate-host");
if (!await _runner.ExtractApp(pinned, testerApp, ct)) return 10;
if (!await _runner.ExtractApp(platform, portalApp, ct)) return 10;
if (!File.Exists(Path.Combine(testerApp, GateHost.TesterCli)))
{
await error.WriteLineAsync(
$"error: {pinned} ships no /app/{GateHost.TesterCli} — --image must be the "
+ "mw-plugin-test image.");
return 10;
}
var manifest = new FileInfo(Path.Combine(portalApp, GateHost.SurfaceManifest));
if (!manifest.Exists || manifest.Length == 0)
{
await error.WriteLineAsync(
$"error: {platform} ships no /app/{GateHost.SurfaceManifest} — --platform-image must "
+ "be the PORTAL image; a host without one resolves the fallback identity no bake may "
+ "be keyed to.");
return 10;
}
await output.WriteLineAsync(
$"tester /app: {Directory.GetFiles(testerApp, "*.dll").Length} assemblies; "
+ $"platform /app: {Directory.GetFiles(portalApp, "*.dll").Length} assemblies");
// 🚨 ONE BUILD, ASSERTED. The bake is keyed to the PORTAL's identity and executed by the
// TESTER's toolchain, which is honest only when the two images are one build. The tester's
// own `framework-identity` verb reads the portal's /app as FILES and compares — naming the
// canonical assemblies each side lacks on a mismatch (#1814) — and it never degrades to a
// fallback identity, so this comparison cannot pass on a manifest-less directory.
var (testerRc, testerLine) = await _runner.CaptureVerbose("docker",
["run", "--rm", "--init", "--entrypoint", "/app/mw-plugin-test", pinned,
"--print-framework-identity"], ct);
var idIdx = testerLine.IndexOf("identity=", StringComparison.Ordinal);
var testerIdentity = testerRc == 0 && idIdx >= 0
? testerLine[(idIdx + "identity=".Length)..].Split(' ', '\n', '\r')[0].Trim()
: "";
if (testerIdentity.Length == 0)
{
await error.WriteLineAsync(
$"error: could not resolve the tester image's framework identity (got: '{testerLine.Trim()}').");
return 10;
}
var (identityRc, identityOut) = await _runner.CaptureVerbose("docker",
["run", "--rm", "--init", "-v", $"{portalApp}:/portal:ro",
"--entrypoint", "/app/mw-plugin-test", pinned,
"framework-identity", "/portal", "--expect", testerIdentity], ct);
var frameworkIdentity = identityOut
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.LastOrDefault() ?? "";
if (identityRc != 0 || frameworkIdentity.Length == 0)
{
await error.WriteLineAsync(
"error: the tester image and the platform image do NOT resolve one framework identity "
+ "— they are not one build (the verb's verdict above names the canonical assemblies "
+ "each side lacks). Pin --image and --platform-image from the SAME CD wave.");
return 10;
}
await output.WriteLineAsync(
$"framework identity: {frameworkIdentity} (the platform's; the tester resolves the same)");
// The composition rules are the LANES' rules — one implementation, extracted and run, never
// reimplemented here. It fails closed and names the file it is missing.
var script = GateHost.ExtractComposeScript(hostRoot);
var composed = await _runner.Exec("bash", [script, portalApp, testerApp, gateHost], ct);
if (composed != 0)
{
await error.WriteLineAsync(
composed == 127
? $"error: could not run {GateHost.ComposeScriptName} — 'bash' is not on PATH. "
+ "Composing the gate host is not optional: without it the build has no portal "
+ "reference set."
: $"error: composing the gate host failed (exit {composed}) — the refusal above "
+ "names the file that was missing.");
return 10;
}
// The two extractions are CONSUMED by the composition above (and by the identity check
// before it); only the composed host is mounted from here on. Dropping them now keeps peak
// disk at one copy instead of three across the compile and the gate, which are the long
// stages — the finally in RunAsync is the backstop, not the whole answer.
await GateHost.DiscardTree(portalApp, output);
await GateHost.DiscardTree(testerApp, output);
var invokingUser = await _runner.ResolveInvokingUser(ct);
var bake = options.BakeOutput is { Length: > 0 }
? Path.GetFullPath(options.BakeOutput)
: Path.Combine(Path.GetTempPath(), $"memex-bake-{Environment.ProcessId}");
Directory.CreateDirectory(bake);
// 🚨 The container may run as a user this host never provisioned, so the bake mount must be
// writable by it — the `chmod 777 "$OWN_BAKE"` every docker-run gate performs. Proven the
// hard way on the verb's first production run (Manufacturing #37): 15/15 NodeTypes compiled, then
// `UnauthorizedAccessException: Access to the path '/bake/Manufacturing.zip' is denied`
// writing the first bundle. Applied to a caller-supplied directory too: the caller asked
// for a bake THERE, and a mount the container cannot write is a promise this command
// cannot keep. Windows has no unix mode and no such non-root docker convention; skip.
ImageRunner.MakeContainerWritable(bake);
// ── the upstream seed comes FIRST, because it is also where the modules come from ───────
// Round 4's lesson: sourcing module DLLs from the package index takes the LATEST version,
// while the sealed publication's types were built against the SEALED module mvids — 10
// upstream types were DECLINED ('MeshWeaver.AI built against mvid:699963b6…, live is
// 813bec7e…') because the composed module and the publication disagreed. The reusable gate
// never has this skew: with an upstream seed, compose-sealed-modules takes the modules FROM
// the publication, identity-matched. Same here — the seed is fetched before stage 1, and the
// requested modules come from the publication's SEALED MODULE SET
// (`…/prebuilt/{identity}/{source}/modules`, MeshWeaver#2698/#2707). Round 5's lesson: the
// seed's own zips are the NODE-REPO content packs — no module bundle is among them, so
// scanning them for 'AI'/'Essentials' refuses on every run. Only a build with NO upstream
// seed falls back to the package index's latest.
//
// 🚨 The address is the PLATFORM's identity, resolved above from the portal's /app — not the
// tester's. The bake this run produces is keyed to the host it compiles on, and a seed
// fetched under the tester's identity would address a publication this gate never adopts.
var seedDir = bake; // the gate consumes one dir: own bake + upstream bundles
if (options.UpstreamSeed is { Length: > 0 } upstreams)
{
var seeded = await FetchUpstreamSeed(
options.RegistryUrl!, options.RegistryKey!, upstreams, frameworkIdentity, seedDir, ct);
if (!seeded) return 8;
}
// ── dependencies: INSTALL, never build (PluginBuildContract step 2) ─────────────────────
// Module DLLs register the types a Skill/Agent subtree needs; without them the gate
// installs files and registers ZERO NodeTypes (round 1's lesson).
var moduleArgs = new List<string>();
var extDir = options.ExternalModulesDir is { Length: > 0 } e ? Path.GetFullPath(e) : null;
if (options is { RegistryModules.Length: > 0 })
{
extDir ??= Path.Combine(Path.GetTempPath(), $"memex-ext-{Environment.ProcessId}");
var fetched = options.UpstreamSeed is { Length: > 0 } sources
? await ComposeSealedModules(
options.RegistryUrl!, options.RegistryKey!, sources, frameworkIdentity,
options.RegistryModules!, extDir, ct)
: await FetchRegistryModules(
options.RegistryUrl!, options.RegistryKey!, options.RegistryModules!, extDir, ct);
if (fetched is null) return 7;
moduleArgs.AddRange(fetched);
}
else if (extDir is not null)
{
foreach (var dir in Directory.EnumerateDirectories(extDir))
{
var name = Path.GetFileName(dir);
if (File.Exists(Path.Combine(dir, $"{name}.dll")))
{ moduleArgs.Add("--module"); moduleArgs.Add($"/ext/{name}/{name}.dll"); }
}
}
if (extDir is not null && Directory.Exists(extDir))
ImageRunner.MakeContainerWritable(extDir);
// ── stage 1: PRODUCE ───────────────────────────────────────────────────────────────────
// Module args go to BOTH stages: the compile needs the externals to RESOLVE their types,
// the gate needs them to REGISTER them (the node-repo gate passes them to both for exactly
// this reason, and dropping either half reintroduces one of the two failures).
//
// 🚨 `--app /app --shared-frameworks …` makes the reference set the PLATFORM's /app plus its
// IMPLEMENTATION frameworks — what a portal's runtime compile sees — and keys the bake to the
// portal's identity. Same shape as node-repo-gate.yml, on purpose: the CLI and the lanes must
// not disagree about what a portal contains.
var compile = await RunOnHost(platform, gateHost, invokingUser, repo, extDir,
mounts: [$"{bake}:/bake"],
env: [],
args: ["compile", "/repo", "--app", "/app",
"--shared-frameworks", GateHost.SharedFrameworks,
..AllowArgs(repo, options), ..moduleArgs, ..options.ExtraArgs,
"--output", "/bake", ..SourceShaArgs(options)],
ct);
if (compile != 0) return compile;
var identity = Path.Combine(bake, BakeIdentityFile);
if (!File.Exists(identity) || new FileInfo(identity).Length == 0)
{
await error.WriteLineAsync(
$"error: compile ran but wrote no bake identity ({BakeIdentityFile} missing or empty) "
+ "— the bake stage regressed. Refusing to test bytes that were never produced.");
return 5;
}
var baked = File.ReadAllText(identity).Trim();
if (!string.Equals(baked, frameworkIdentity, StringComparison.Ordinal))
{
await error.WriteLineAsync(
$"error: the run's own bake is keyed to '{baked}' but the platform's /app resolves "
+ $"'{frameworkIdentity}' — the compile did not run against the platform host. Bundles "
+ "published under an identity no portal asks for are INERT.");
return 5;
}
// ── stage 2: CONSUME — stand a mesh up on the bytes stage 1 produced ───────────────────
// `--app /app`: the gate must RUN AS the platform host (it resolves the portal's identity),
// and is refused before a mesh boots otherwise — a gate running as another host would decline
// every bundle and compile the tree itself, passing without judging the bytes that ship.
return await RunOnHost(platform, gateHost, invokingUser, repo, extDir,
mounts: [$"{bake}:/seed:ro"],
env: ["MW_INSTALL_DIFF=1"],
args: ["/repo", "--app", "/app", ..AllowArgs(repo, options), ..moduleArgs,
..options.ExtraArgs, "--seed", "/seed"],
ct);
}
/// <summary>
/// Fetches each named package's sealed bundle from the plugin registry and composes it under
/// <paramref name="extDir"/> in the layout the tester consumes (<c>/ext/<name>/<name>.dll</c>).
/// The client half of the contract <c>PluginBundleEndpoints</c> serves, and byte-for-byte the
/// composition the node-repo gate performs in shell: index → advertised version → bundle zip →
/// <c>meshweaver/manifest.json</c> names the entry assembly (single-DLL fallback) →
/// <c>meshweaver/modules/</c> copied whole. Returns the <c>--module</c> args, or null on failure
/// (each failure already reported, naming the package).
/// </summary>
private async Task<List<string>?> FetchRegistryModules(
string registryUrl, string key, string modules, string extDir, CancellationToken ct)
{
var args = new List<string>();
Directory.CreateDirectory(extDir);
using var http = new HttpClient { BaseAddress = new Uri(registryUrl.TrimEnd('/') + "/") };
http.DefaultRequestHeaders.Authorization = new("Bearer", key);
System.Text.Json.JsonDocument index;
try
{
await using var s = await http.GetStreamAsync("api/plugins/bundles/index.json", ct);
index = await System.Text.Json.JsonDocument.ParseAsync(s, cancellationToken: ct);
}
catch (Exception ex)
{
await error.WriteLineAsync($"error: registry index unreadable at {registryUrl}: {ex.Message}");
return null;
}
using (index)
foreach (var pkg in modules.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
string? version = null;
var root = index.RootElement;
var bundles = root.TryGetProperty("bundles", out var b) ? b
: root.TryGetProperty("Bundles", out var b2) ? b2 : default;
if (bundles.ValueKind == System.Text.Json.JsonValueKind.Array)
foreach (var entry in bundles.EnumerateArray())
{
var plugin = entry.TryGetProperty("plugin", out var pl) ? pl.GetString()
: entry.TryGetProperty("Plugin", out var pl2) ? pl2.GetString() : null;
if (!string.Equals(plugin, pkg, StringComparison.OrdinalIgnoreCase)) continue;
version = entry.TryGetProperty("version", out var ve) ? ve.GetString()
: entry.TryGetProperty("Version", out var ve2) ? ve2.GetString() : null;
break;
}
if (version is not { Length: > 0 })
{
await error.WriteLineAsync(
$"error: the registry at {registryUrl} does not advertise package '{pkg}' to this "
+ "key — check the key's grants and the package name.");
return null;
}
var zipPath = Path.Combine(extDir, $"{pkg}.bundle.zip");
try
{
await using var body = await http.GetStreamAsync($"api/plugins/bundles/{pkg}/{version}", ct);
await using var file = File.Create(zipPath);
await body.CopyToAsync(file, ct);
}
catch (Exception ex)
{
await error.WriteLineAsync($"error: download failed for {pkg}@{version}: {ex.Message}");
return null;
}
var name = await StageModuleBundle(zipPath, extDir, pkg);
if (name is null) return null;
args.Add("--module"); args.Add($"/ext/{name}/{name}.dll");
await output.WriteLineAsync($"external module composed: {name} ({pkg}@{version})");
}
return args;
}
/// <summary>
/// Unpacks one module bundle zip into <paramref name="extDir"/> in the tester's layout
/// (<c>/ext/<name>/<name>.dll</c>): <c>meshweaver/manifest.json</c>'s
/// <c>module.assemblyName</c> names the entry assembly (single-DLL fallback), the whole
/// <c>meshweaver/modules/</c> directory moves under the assembly's name. Deletes the zip.
/// Returns the assembly name, or null with the failure already reported against
/// <paramref name="label"/>.
/// </summary>
private async Task<string?> StageModuleBundle(string zipPath, string extDir, string label)
{
var unpack = Path.Combine(extDir, $"unpack-{label}");
if (Directory.Exists(unpack)) Directory.Delete(unpack, true);
System.IO.Compression.ZipFile.ExtractToDirectory(zipPath, unpack);
File.Delete(zipPath);
var manifest = Path.Combine(unpack, "meshweaver", "manifest.json");
string? name = null;
if (File.Exists(manifest))
using (var doc = System.Text.Json.JsonDocument.Parse(File.ReadAllText(manifest)))
if (doc.RootElement.TryGetProperty("module", out var m)
&& m.TryGetProperty("assemblyName", out var an))
name = an.GetString();
var modulesDir = Path.Combine(unpack, "meshweaver", "modules");
if (name is not { Length: > 0 })
{
var dlls = Directory.Exists(modulesDir) ? Directory.GetFiles(modulesDir, "*.dll") : [];
if (dlls.Length != 1)
{
await error.WriteLineAsync(
$"error: {label}: cannot identify the entry assembly (no manifest "
+ $"module.assemblyName; meshweaver/modules holds {dlls.Length} dll(s)).");
return null;
}
name = Path.GetFileNameWithoutExtension(dlls[0]);
}
if (!File.Exists(Path.Combine(modulesDir, $"{name}.dll")))
{
await error.WriteLineAsync($"error: {label}: meshweaver/modules/{name}.dll is missing from the bundle.");
return null;
}
var target = Path.Combine(extDir, name);
if (Directory.Exists(target)) Directory.Delete(target, true);
Directory.Move(modulesDir, target);
Directory.Delete(unpack, true);
return name;
}
private static IEnumerable<string> AllowArgs(string repo, BuildPluginOptions o)
{
var allow = o.AllowFile is { Length: > 0 } ? o.AllowFile : "plugin-gate.allow";
return File.Exists(Path.Combine(repo, allow)) ? ["--allow", $"/repo/{allow}"] : [];
}
private static IEnumerable<string> SourceShaArgs(BuildPluginOptions o) =>
o.SourceSha is { Length: > 0 } sha ? ["--source-sha", sha] : [];
/// <summary>
/// Composes the requested module packages from the upstream publication's SEALED MODULE SET —
/// <c>…/api/plugins/bundles/prebuilt/{identity}/{source}/modules</c> — the client half of the
/// contract <c>.github/scripts/compose-sealed-modules.sh</c> speaks for the reusable gate
/// (MeshWeaver#2698: the publication is the unit of consistency; #2707 seals the module set
/// beside the bundles). The seed's own zips are the node-repo CONTENT packs; no module bundle
/// is among them — round 5 proved that by refusing on every run — so module bytes must come
/// from the module set the same seal carries, never from the registry's latest index (round
/// 4's mvid declines) and never from a scan of the seed. A wanted package matches the bundle
/// named <c>{package}.module.nupkg</c> case-insensitively; the first upstream listing it wins.
/// Returns the <c>--module</c> args, or null with every failure already reported RED and named.
/// </summary>
private async Task<List<string>?> ComposeSealedModules(
string registryUrl, string key, string upstreamSources, string identity,
string modules, string extDir, CancellationToken ct)
{
using var http = new HttpClient { BaseAddress = new Uri(registryUrl.TrimEnd('/') + "/") };
http.DefaultRequestHeaders.Authorization = new("Bearer", key);
return await ComposeSealedModules(http, upstreamSources, identity, modules, extDir, ct);
}
/// <summary>
/// How many times a consumer re-reads a publication that moved under its N+1 reads before it
/// refuses (MeshWeaver#3401). The bound is what makes this a READ rather than a poll: a
/// publisher resealing faster than a composition completes is a real condition, and it must
/// go RED naming itself instead of spinning. Mirrored as <c>RESTARTS</c> in
/// <c>.github/scripts/compose-sealed-modules.sh</c> and <c>node-repo-gate.yml</c>.
/// </summary>
public const int MaxPublicationRestarts = 2;
/// <summary>
/// 🚨 <b>A composition reads ONE publication, or none</b> (MeshWeaver#3401). The module-set
/// index and the N bundle fetches are N+1 reads of a directory the publisher unseals, rewrites
/// and re-seals — and both core CD and the Plugins satellite publish the <c>plugins</c>
/// prefix, so the two writers can land inside each other's window. Each fetch therefore pins
/// the generation its index came from; a publication that moves answers 412, and the answer to
/// that is to read the publication that NOW applies — never to keep the half already staged,
/// and never to re-ask the read the server refused.
///
/// <para>Takes the <see cref="HttpClient"/> so the composition can be driven against a real
/// registry in a test: this loop is the thing that has to be right, and a copy of it in a test
/// harness would be free to agree with itself.</para>
/// </summary>
public async Task<List<string>?> ComposeSealedModules(
HttpClient http, string upstreamSources, string identity, string modules, string extDir,
CancellationToken ct)
{
Directory.CreateDirectory(extDir);
for (var attempt = 0; ; attempt++)
{
var composed = await ComposeOnce(http, upstreamSources, identity, modules, extDir, ct);
// Composed, or refused for a reason already reported — either answer is final.
if (!composed.PublicationMoved) return composed.Args;
if (attempt >= MaxPublicationRestarts)
{
await error.WriteLineAsync(
$"error: the upstream publication(s) ({upstreamSources}) for identity {identity} "
+ $"were resealed under every one of {MaxPublicationRestarts + 1} composition "
+ "attempts — the publisher is republishing faster than a module set can be "
+ "read (MeshWeaver#3401). This is not a fault of this repo's content or its "
+ "platform pin: re-run once the upstream's publish-bake has settled.");
return null;
}
}
}
/// <summary>One composition attempt against one publication instance per upstream. Carries
/// <see cref="ComposeAttempt.PublicationMoved"/> when a 412 says the seal moved mid-fetch —
/// distinct from a refusal, which comes back as a null <see cref="ComposeAttempt.Args"/>.
/// </summary>
private readonly record struct ComposeAttempt(List<string>? Args, bool PublicationMoved);
private async Task<ComposeAttempt> ComposeOnce(
HttpClient http, string upstreamSources, string identity, string modules, string extDir,
CancellationToken ct)
{
var args = new List<string>();
// What THIS attempt staged, so a 412 can discard it: a module bundle from a publication
// that has since been superseded must not survive into the compile surface — mixing two
// publications' module bytes is the mvid mismatch that DECLINES every NodeType assembly
// at adoption, silently.
var staged = new List<string>();
var sources = upstreamSources.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var sealedSets = new List<(string Source, List<string> Bundles, string? Generation)>();
foreach (var src in sources)
{
var url = $"api/plugins/bundles/prebuilt/{identity}/{src}/modules";
using var response = await RegistryGet(http, url, null, ct);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
await error.WriteLineAsync(
$"error: upstream '{src}' answers 404 for the module set of identity {identity} — either "
+ "it has no SEALED publication for this identity, or its publication predates module "
+ $"sealing (no modules/_index). Republish '{src}' under a core that carries "
+ "MeshWeaver#2707. Not composing from the registry instead: that is the decline this "
+ "exists to end.");
return new ComposeAttempt(null, false);
}
if (response.StatusCode is System.Net.HttpStatusCode.Unauthorized or System.Net.HttpStatusCode.Forbidden)
{
await error.WriteLineAsync(
$"error: the registry refused the instance key for '{src}' ({(int)response.StatusCode}) — "
+ $"the instance needs a whole-source grant '{src}/*'.");
return new ComposeAttempt(null, false);
}
if (!response.IsSuccessStatusCode)
{
await error.WriteLineAsync(
$"error: registry answered {(int)response.StatusCode} for {url} — refusing.");
return new ComposeAttempt(null, false);
}
List<string> bundles;
string? generation;
try
{
using var doc = await System.Text.Json.JsonDocument.ParseAsync(
await response.Content.ReadAsStreamAsync(ct), cancellationToken: ct);
if (!doc.RootElement.TryGetProperty("modules", out var arr)
|| arr.ValueKind != System.Text.Json.JsonValueKind.Array)
{
await error.WriteLineAsync(
$"error: {url} answered 200 but not a module-set index — refusing to guess.");
return new ComposeAttempt(null, false);
}
bundles = arr.EnumerateArray()
.Select(e => e.GetString())
.Where(n => n is { Length: > 0 })
.Select(n => n!)
.ToList();
// The publication INSTANCE this listing describes (#3401). Absent on a registry
// that predates it — no If-Match is then sent and the behaviour is as before.
generation = doc.RootElement.TryGetProperty("generation", out var gen)
? gen.GetString()
: null;
}
catch (System.Text.Json.JsonException ex)
{
await error.WriteLineAsync($"error: {url} answered 200 but not JSON: {ex.Message}");
return new ComposeAttempt(null, false);
}
sealedSets.Add((src, bundles, generation));
await output.WriteLineAsync(
$"upstream '{src}' sealed {bundles.Count} module bundle(s) for identity {identity}");
}
foreach (var pkg in modules.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var wanted = $"{pkg.ToLowerInvariant()}.module.nupkg";
var hit = sealedSets
.Select(set => (set.Source, set.Generation, Name: set.Bundles.FirstOrDefault(
b => string.Equals(b, wanted, StringComparison.OrdinalIgnoreCase))))
.FirstOrDefault(x => x.Name is not null);
if (hit.Name is null)
{
await error.WriteLineAsync(
$"error: no sealed upstream publication ({upstreamSources}) for identity {identity} carries "
+ $"module package '{pkg}' (looked for {wanted}). The upstream that owns it must compose it "
+ "in its bake (module-artifacts / registry-modules) so its seal carries it; composing it "
+ "from anywhere else would carry the wrong mvid and every publication type built against "
+ "it would be DECLINED.");
return new ComposeAttempt(null, false);
}
var zipPath = Path.Combine(extDir, hit.Name);
var moduleUrl = $"api/plugins/bundles/prebuilt/{identity}/{hit.Source}/modules/{hit.Name}";
try
{
// 🚨 PINNED to the generation the listing above came from (#3401). Without it this
// fetch silently returns bytes from whatever publication is current, which need not
// be the one whose index named this bundle.
using var moduleResponse = await RegistryGet(http, moduleUrl, hit.Generation, ct);
if (moduleResponse.StatusCode == System.Net.HttpStatusCode.PreconditionFailed)
{
await output.WriteLineAsync(
$"the '{hit.Source}' publication was resealed while its module set was being "
+ $"composed (we held generation {hit.Generation}) — discarding what this "
+ "attempt staged and re-reading the publication that now applies.");
Discard(staged, zipPath);
return new ComposeAttempt(null, true);
}
if (moduleResponse.StatusCode == System.Net.HttpStatusCode.NotFound)
{
await error.WriteLineAsync(
$"error: '{hit.Name}' is listed in the '{hit.Source}' sealed module set for "
+ $"identity {identity} but the registry will not serve it — the seal and the "
+ "store disagree, and that needs a republish. This is NOT the republish "
+ "window: that answers 503 and is waited out.");
return new ComposeAttempt(null, false);
}
moduleResponse.EnsureSuccessStatusCode();
await using var body = await moduleResponse.Content.ReadAsStreamAsync(ct);
await using var file = File.Create(zipPath);
await body.CopyToAsync(file, ct);
}
catch (Exception ex)
{
await error.WriteLineAsync(
$"error: could not fetch sealed module {hit.Name} of '{hit.Source}' for identity "
+ $"{identity}: {ex.GetType().Name}: {ex.Message}");
return new ComposeAttempt(null, false);
}
var name = await StageModuleBundle(zipPath, extDir, pkg);
if (name is null) return new ComposeAttempt(null, false);
staged.Add(Path.Combine(extDir, name));
args.Add("--module"); args.Add($"/ext/{name}/{name}.dll");
await output.WriteLineAsync(
$"external module composed from the SEALED publication of '{hit.Source}': {name} ({pkg}, {hit.Name})");
}
return new ComposeAttempt(args, false);
}
/// <summary>Removes what one rejected attempt staged, so nothing from a superseded publication
/// reaches the compile surface (#3401).</summary>
private static void Discard(List<string> staged, string zipPath)
{
File.Delete(zipPath); // a no-op when the fetch never landed
foreach (var dir in staged)
if (Directory.Exists(dir)) Directory.Delete(dir, true);
}
/// <summary>
/// One registry GET that survives a REPUBLISH WINDOW (MeshWeaver#3401).
///
/// <para>The publisher unseals a publication before replacing it and re-seals it LAST, on
/// purpose, so nobody can read a mix of two. A request landing in that window now answers
/// <c>503 + Retry-After</c> — "this exists and is being replaced" — and waiting it out is the
/// correct response, not a retry papering over a fault. The two shell lanes that read the same
/// endpoint (<c>node-repo-gate.yml</c>, <c>compose-sealed-modules.sh</c>) already back off on
/// the same codes with the same delays; this verb did not, and would have died on the window
/// the moment it opened.</para>
///
/// <para><paramref name="generation"/> pins the publication INSTANCE the caller read its index
/// from, so the server can refuse a fetch that would mix two publications (412) rather than
/// silently serving half of each. It is opt-in: a registry that predates #3401 publishes no
/// generation, the header is then absent, and the behaviour is exactly as before.</para>
/// </summary>
private async Task<HttpResponseMessage> RegistryGet(
HttpClient http, string url, string? generation, CancellationToken ct)
{
int[] delays = [15, 30, 60, 90];
for (var attempt = 0; ; attempt++)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
if (generation is { Length: > 0 })
request.Headers.TryAddWithoutValidation("If-Match", generation);
var response = await http.SendAsync(
request, HttpCompletionOption.ResponseHeadersRead, ct);
var code = (int)response.StatusCode;
if (!(code is 408 or 429 || code >= 500) || attempt >= delays.Length)
return response;
response.Dispose();
await error.WriteLineAsync(
$"registry answered {code} for {url} — transient (a republish window looks like "
+ $"this); re-asking in {delays[attempt]}s, attempt {attempt + 2}");
await Task.Delay(TimeSpan.FromSeconds(delays[attempt]), ct);
}
}
/// <summary>
/// Fetches each upstream source's SEALED publication for <paramref name="frameworkIdentity"/>
/// into <paramref name="seedDir"/> — the client half of the registry's
/// <c>prebuilt/{identity}/{source}</c> route, byte-for-byte the node-repo gate's shell:
/// index (<c>{"bundles":[…]}</c>) → each file → refuse a 404 (no sealed publication), a
/// 401/403 (the key needs a whole-source grant '<c>src/*</c>'), and an EMPTY seed — sealed
/// but empty is refused, never treated as "nothing to install".
/// </summary>
private async Task<bool> FetchUpstreamSeed(
string registryUrl, string key, string upstreams, string frameworkIdentity,
string seedDir, CancellationToken ct)
{
using var http = new HttpClient { BaseAddress = new Uri(registryUrl.TrimEnd('/') + "/") };
http.DefaultRequestHeaders.Authorization = new("Bearer", key);
var fetched = 0;
foreach (var src in upstreams.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var basePath = $"api/plugins/bundles/prebuilt/{frameworkIdentity}/{src}";
using var resp = await RegistryGet(http, basePath, null, ct);
if ((int)resp.StatusCode == 404)
{
await error.WriteLineAsync(
$"error: upstream '{src}' has no SEALED publication on {registryUrl} for identity "
+ $"{frameworkIdentity}. Not compiling it instead.");
return false;
}
if ((int)resp.StatusCode is 401 or 403)
{
await error.WriteLineAsync(
$"error: the registry refused the key for '{src}' ({(int)resp.StatusCode}) — the "
+ $"instance needs a whole-source grant '{src}/*'.");
return false;
}
if (!resp.IsSuccessStatusCode)
{
await error.WriteLineAsync($"error: registry answered {(int)resp.StatusCode} for {basePath} — refusing.");
return false;
}
System.Text.Json.JsonDocument doc;
try { doc = System.Text.Json.JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)); }
catch
{
await error.WriteLineAsync($"error: {basePath} answered 200 but not a publication index.");
return false;
}
using (doc)
{
if (!doc.RootElement.TryGetProperty("bundles", out var bundles)
|| bundles.ValueKind != System.Text.Json.JsonValueKind.Array)
{
await error.WriteLineAsync($"error: {basePath} answered 200 but not a publication index.");
return false;
}
// The publication INSTANCE this index describes (#3401). Reading the index and then
// each bundle it names is N+1 reads of a directory the publisher may reseal
// underneath us; carrying it lets the server refuse a mixed read instead of
// serving half of two publications. Absent on a registry that predates #3401.
var generation = doc.RootElement.TryGetProperty("generation", out var gen)
? gen.GetString()
: null;
foreach (var nameEl in bundles.EnumerateArray())
{
var name = nameEl.GetString();
if (name is not { Length: > 0 }) continue;
// Path-safety: the index names files, never paths — a traversal here would let
// a registry write outside the seed.
if (name.Contains('/') || name.Contains('\\') || name.Contains(".."))
{
await error.WriteLineAsync($"error: publication index for '{src}' names an unsafe path '{name}' — refusing.");
return false;
}
using var bundleResponse =
await RegistryGet(http, $"{basePath}/{name}", generation, ct);
if ((int)bundleResponse.StatusCode == 412)
{
await error.WriteLineAsync(
$"error: the '{src}' publication was resealed while this fetch was in "
+ $"flight (we held generation {generation}). One publication or none — "
+ "never half of two. Re-run once the publisher has settled.");
return false;
}
if ((int)bundleResponse.StatusCode == 404)
{
await error.WriteLineAsync(
$"error: '{name}' is listed in the '{src}' publication index but the "
+ "registry will not serve it — the seal and the store disagree, and "
+ "that needs a republish. This is NOT the republish window: that "
+ "answers 503 and is waited out.");
return false;
}
if (!bundleResponse.IsSuccessStatusCode)
{
await error.WriteLineAsync(
$"error: registry answered {(int)bundleResponse.StatusCode} for "
+ $"{basePath}/{name} — refusing.");
return false;
}
await using var body = await bundleResponse.Content.ReadAsStreamAsync(ct);
await using var file = File.Create(Path.Combine(seedDir, name));
await body.CopyToAsync(file, ct);
fetched++;
}
}
await output.WriteLineAsync($"upstream '{src}': fetched from {registryUrl}");
}
if (fetched == 0)
{
await error.WriteLineAsync("error: sealed but empty — refusing an empty seed.");
return false;
}
return true;
}
/// <summary>
/// Runs the tester CLI out of the composed gate host, inside the PLATFORM image, with this
/// verb's standing mounts (<c>/repo</c> and, when there are external modules, <c>/ext</c>).
/// </summary>
private Task<int> RunOnHost(
string platformImage, string hostDirectory, string? user, string repo,
string? extDir,
IEnumerable<string> mounts, IEnumerable<string> env, IEnumerable<string> args,
CancellationToken ct)
{
var all = new List<string> { $"{repo}:/repo" };
all.AddRange(mounts);
if (extDir is { Length: > 0 } ext)
all.Add($"{ext}:/ext");
return _runner.RunOnComposedHost(platformImage, hostDirectory, user, all, env, args, ct);
}
}
/// <summary>Arguments for <see cref="BuildPluginCommand"/>. A record so a caller cannot half-set it.</summary>
public sealed record BuildPluginOptions(
string PluginPath,
string Image,
string? BakeOutput = null,
string? ExternalModulesDir = null,
string? SourceSha = null,
string? AllowFile = null,
IReadOnlyList<string>? Extra = null)
{
// 🚨 init-properties, NOT primary-constructor parameters: adding a parameter to a record's
// primary constructor changes the synthesized constructor's ARITY — a binary break the
// public-surface gate rejects even though nothing was removed (the "adding is source-compatible
// everywhere the compiler looks" trap, third sighting today). Same resolution as Seed's
// overload in #2821: extend without touching the existing shape.
/// <summary>Registry base URL for the dependency install (PluginBuildContract step 2).</summary>
public string? RegistryUrl { get; init; }
/// <summary>Space-separated packages to install as built artifacts before building.</summary>
public string? RegistryModules { get; init; }
/// <summary>The mwi_ instance key; prefer sourcing from $MW_REGISTRY_KEY.</summary>
public string? RegistryKey { get; init; }
/// <summary>Space-separated upstream SOURCES whose sealed publication seeds the gate's mesh
/// (e.g. "plugins") — the packages a `requires` chain reaches, not merely their DLLs.</summary>
public string? UpstreamSeed { get; init; }
/// <summary>
/// The PORTAL image — REQUIRED (#3113). It supplies the reference set, the framework identity
/// and the runtime; <see cref="Image"/> only executes. There is deliberately no default and no
/// fallback to the tester's <c>/app</c>: see <see cref="GateHost"/>.
/// </summary>
public string? PlatformImage { get; init; }
public IReadOnlyList<string> ExtraArgs => Extra ?? [];
}