Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 118 additions & 17 deletions memex/Memex.Portal.Shared/Api/PluginBundleEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,20 +119,55 @@ private static Task<IResult> Index(HttpContext http, IMessageHub rootHub, Cancel
var baseUrl = $"{http.Request.Scheme}://{http.Request.Host}{RoutePrefix}";

return InstalledPackages(rootHub, ct)
.Select(packages => Results.Json(new
{
frameworkMvid = FrameworkMvid,
bundles = packages.Select(p => new
.SelectMany(packages => ServableModules(rootHub, packages)
.Select(modules => Results.Json(new
{
plugin = p.PluginId,
version = p.Version,
url = $"{baseUrl}/{Uri.EscapeDataString(p.PluginId)}/{Uri.EscapeDataString(p.Version)}",
}).ToArray(),
}))
frameworkMvid = FrameworkMvid,
bundles = packages.Select(p => new
{
plugin = p.PluginId,
version = p.Version,
url = $"{baseUrl}/{Uri.EscapeDataString(p.PluginId)}/{Uri.EscapeDataString(p.Version)}",
// The compiled module this bundle carries (#1664) — stamped ONLY when this
// instance can actually serve its bytes, so a consumer never downloads for
// a module section that will not be there. Additive: an older client's
// BundleRef simply ignores it.
module = modules.TryGetValue(p.PluginId, out var moduleName) ? moduleName : null,
// The module's declared platform FLOOR — the consumer's gate (a semver
// floor, never MVID equality; the index-level frameworkMvid above stays
// the NodeType lane's strict gate and, for modules, diagnostics).
minMeshVersion = modules.ContainsKey(p.PluginId) ? p.MinMeshVersion : null,
}).ToArray(),
})))
.FirstAsync()
.ToTask(ct);
}

/// <summary>
/// Which of the installed packages' declared modules this instance can serve right now:
/// plugin id → module assembly name, for exactly the entries whose bytes exist under
/// <c>modules/&lt;name&gt;/</c> and pass the platform-floor gate (<see cref="ModuleBundleSource"/>
/// — the same <see cref="ModulePlatformFloor"/> check boot applies, so a registry never serves
/// a landing its own boot skips). One activation-sidecar read for the whole index.
/// </summary>
private static IObservable<IReadOnlyDictionary<string, string>> ServableModules(
IMessageHub rootHub, IReadOnlyList<BundleEntry> packages)
{
var landing = rootHub.ServiceProvider.GetService<ModuleLandingService>();
var declaring = packages.Where(p => !string.IsNullOrWhiteSpace(p.Module)).ToArray();
if (landing is null || declaring.Length == 0)
return Observable.Return<IReadOnlyDictionary<string, string>>(
new Dictionary<string, string>());

return landing.GetActivation().Take(1)
.Select(activation => (IReadOnlyDictionary<string, string>)declaring
.Where(p => ModuleBundleSource.Collect(
landing.BaseDirectory, p.Module!, activation,
ModulePlatformFloor.DeclineReason)
.DeclineReason is null)
.ToDictionary(p => p.PluginId, p => p.Module!, StringComparer.OrdinalIgnoreCase));
}

/// <summary>
/// One plugin's bundle: each of its NodeTypes' compiled assemblies, plus the manifest saying
/// which node each belongs to and what framework they were built against.
Expand Down Expand Up @@ -199,7 +234,39 @@ private static IObservable<IResult> Assemble(IMessageHub rootHub, BundleEntry pa
? Observable.Return(Array.Empty<(string NodePath, string? Path)>())
: lookups.CombineLatest().Select(x => x.ToArray());

return assemblies.Select(found => BuildResult(package, found));
return assemblies.SelectMany(found => ModuleFiles(rootHub, package)
.Select(moduleFiles => BuildResult(package, found, moduleFiles)));
});
}

/// <summary>
/// The MODULE closure files this bundle carries (#1664) — the instance's own
/// <c>modules/&lt;name&gt;/</c> bytes, resolved through <see cref="ModuleBundleSource"/> (which
/// refuses uninstalled and framework-stale landings). Empty for a package that declares no
/// module or whose bytes this instance cannot serve — the bundle then simply has no module
/// section, which a consumer reads as "nothing to land".
/// </summary>
private static IObservable<IReadOnlyList<string>> ModuleFiles(
IMessageHub rootHub, BundleEntry package)
{
var landing = rootHub.ServiceProvider.GetService<ModuleLandingService>();
if (landing is null || string.IsNullOrWhiteSpace(package.Module))
return Observable.Return<IReadOnlyList<string>>([]);

var logger = rootHub.ServiceProvider.GetService<ILoggerFactory>()
?.CreateLogger(typeof(PluginBundleEndpoints));

return landing.GetActivation().Take(1)
.Select(activation =>
{
var (files, decline) = ModuleBundleSource.Collect(
landing.BaseDirectory, package.Module!, activation,
ModulePlatformFloor.DeclineReason);
if (decline is not null)
logger?.LogInformation(
"Plugin bundles: {Plugin} declares module '{Module}' but it is not served: {Reason}",
package.PluginId, package.Module, decline);
return files;
});
}

Expand All @@ -225,7 +292,8 @@ private static IObservable<IResult> Assemble(IMessageHub rootHub, BundleEntry pa
/// </summary>
private static IResult BuildResult(
BundleEntry package,
IReadOnlyList<(string NodePath, string? Path)> assemblies)
IReadOnlyList<(string NodePath, string? Path)> assemblies,
IReadOnlyList<string> moduleFiles)
{
var entries = new List<NuGetPackageWriter.Entry>();
var assemblyRecords = new List<object>();
Expand All @@ -241,6 +309,17 @@ private static IResult BuildResult(
assemblyRecords.Add(new { nodePath, assembly = $"{nodePath}.dll" });
}

// The module closure, under its own folder (#1664): these bytes land beside the consumer's
// app via ModuleLandingService, a different lane than the assembly store above — the two
// must never mix (a module DLL seeded as a NodeType assembly fails only at activation).
foreach (var moduleFile in moduleFiles)
{
var local = moduleFile;
entries.Add(new NuGetPackageWriter.Entry(
NuGetPackageWriter.ModuleEntryPathFor(Path.GetFileName(local)),
() => File.OpenRead(local)));
}

var manifest = new PackagingManifest(
package.PluginId, package.PackageId, package.Version, package.PluginId, null, []);

Expand All @@ -249,12 +328,29 @@ private static IResult BuildResult(
{
plugin = package.PluginId,
version = package.Version,
// The framework this instance RUNS — these assemblies came out of its bake, so that
// is the only framework they are known good against.
// The framework this instance RUNS — these assemblies came out of its bake (and its
// own modules/ tree), so that is the only framework they are known good against.
frameworkMvid = FrameworkMvid,
assemblies = assemblyRecords,
// The module section — the manifest names every closure file; a consumer reads
// this list, never the folder (BundleReader.ReadModule). Null (omitted) when the
// bundle carries no module. minMeshVersion is the consumer's landing gate (a
// semver floor — MVID equality is the NodeType lane's gate, and the frameworkMvid
// above is, for the module, diagnostics).
module = moduleFiles.Count == 0
? null
: new
{
assemblyName = package.Module,
assemblies = moduleFiles.Select(Path.GetFileName).ToArray(),
minMeshVersion = package.MinMeshVersion,
},
},
new JsonSerializerOptions { WriteIndented = true });
new JsonSerializerOptions
{
WriteIndented = true,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
});

var buffer = new MemoryStream();
NuGetPackageWriter.Write(buffer, manifest, "3.0.0", entries, manifestJson);
Expand Down Expand Up @@ -285,10 +381,15 @@ private static IObservable<IReadOnlyList<BundleEntry>> InstalledPackages(
rootHub.JsonSerializerOptions))
.Where(m => m is not null && !string.IsNullOrWhiteSpace(m.ReleasedVersion))
.Select(m => new BundleEntry(
PackagingManifest.IdPrefix + m!.Id, m.ReleasedVersion!, m.Id))
PackagingManifest.IdPrefix + m!.Id, m.ReleasedVersion!, m.Id, m.Module,
m.MinMeshVersion))
.OrderBy(e => e.PluginId, StringComparer.OrdinalIgnoreCase)
.ToArray());

/// <summary>One servable bundle: its package id, its released version, and the plugin it is.</summary>
private sealed record BundleEntry(string PackageId, string Version, string PluginId);
/// <summary>One servable bundle: its package id, its released version, the plugin it is, the
/// compiled module the plugin's install record declares (null for content-only plugins), and
/// that module's declared platform floor.</summary>
private sealed record BundleEntry(
string PackageId, string Version, string PluginId, string? Module = null,
string? MinMeshVersion = null);
}
19 changes: 11 additions & 8 deletions memex/Memex.Portal.Shared/MemexConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -527,20 +527,23 @@ public TBuilder ConfigureMemexMesh(IConfiguration configuration, bool isDevelopm
//
// #1664 step 9 — the effective set is the appsettings baseline ∪ the ENABLED entries
// of the modules/activation.json sidecar (store-installed modules landed by
// ModuleLandingService), deduped by name. Sidecar entries are guarded: a recorded
// framework MVID that mismatches the running framework (an image roll happened since
// the install) or a missing DLL SKIPS the entry with a loud stderr line — never a
// crash, the deployment must boot; the entry stays for the post-roll re-install.
// Pre-DI, so diagnostics go to stderr (pod stdout/stderr ship to Loki regardless).
// ModuleLandingService), deduped by name. Sidecar entries are guarded: a declared
// minMeshVersion FLOOR the running platform no longer satisfies (a rollback below the
// module's requirement) or a missing DLL SKIPS the entry with a loud stderr line —
// never a crash, the deployment must boot; the entry stays for when the platform
// moves forward again. A landed module's built-against MVID is diagnostic only:
// modules bind by simple name across platform builds (the strict MVID gate is the
// NodeType bake lane's). Pre-DI, so diagnostics go to stderr (pod stdout/stderr ship
// to Loki regardless).
var moduleAssemblies = configuration.GetSection("Modules:Assemblies").Get<string[]>();
var persistedActivation = ModuleActivationSidecar.Read(AppContext.BaseDirectory,
msg => Console.Error.WriteLine($"[ModuleActivation] {msg}"));
var effectiveModules = ModuleActivationBoot.ComputeEffectiveModuleEntries(
moduleAssemblies,
persistedActivation,
// The ONE framework-identity gate (PrebuiltAssemblySeeder) — never a second
// notion of framework version.
PrebuiltAssemblySeeder.DeclineReason,
// The ONE module platform gate (ModulePlatformFloor) — never a second notion of
// the module platform requirement.
ModulePlatformFloor.DeclineReason,
// 🚨 modules/<name>/<name>.dll SPECIFICALLY — never ResolveModulePath, whose
// BaseDirectory fallback would let a sidecar entry with a lost modules/ folder
// silently bind a same-named app-closure DLL instead of being skipped. Baseline
Expand Down
61 changes: 61 additions & 0 deletions memex/Memex.Portal.Shared/SelfUpdate/PlatformModuleUpdatePolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using System.Reactive.Linq;
using MeshWeaver.Mesh.Services;
using MeshWeaver.Messaging;
using MeshWeaver.PluginCatalog;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace Memex.Portal.Shared.SelfUpdate;

/// <summary>
/// Wires the module lane's unattended-landing gate (<see cref="IModuleUpdatePolicy"/>, #1664) to
/// the platform's EXISTING update-policy surface — the admin-editable <c>Admin/UpdatePolicy</c>
/// node that already governs the image roll. There is deliberately NO module-specific knob:
///
/// <list type="bullet">
/// <item><b>Continuous</b> (the platform default, and the value an ABSENT node reads as) —
/// unattended module landing is allowed: store-installed modules track their registry the same
/// way the platform tracks its image.</item>
/// <item><b>Stable</b> / <b>None</b> — declined: a deployment that pins its image takes updates
/// deliberately, and its modules must not run ahead of that choice. The catalog card's manual
/// Update (and any explicit install) still lands the module — the gate covers only the
/// background reconcile.</item>
/// </list>
///
/// <para>Read through the storage adapter (authoritative point read — the same primitive the
/// package reconcile uses for install records); an unreadable policy DECLINES with the error as the
/// reason, because "could not check the operator's choice" must fail toward not acting.</para>
/// </summary>
public sealed class PlatformModuleUpdatePolicy(IMessageHub hub, ILogger<PlatformModuleUpdatePolicy> logger)
: IModuleUpdatePolicy
{
/// <inheritdoc />
public IObservable<string?> DeclineUnattendedLanding()
{
var storage = hub.ServiceProvider.GetService<IStorageAdapter>();
if (storage is null)
// No storage = no persisted policy = the platform default (Continuous): allowed.
return Observable.Return<string?>(null);

return storage.Read(UpdatePolicyNodeType.NodePath, hub.JsonSerializerOptions)
.Take(1)
.Select(node =>
{
var policy = UpdatePolicyNodeType.Parse(node, hub.JsonSerializerOptions).Policy;
return policy == UpdatePolicyKind.Continuous
? null
: $"the deployment's update policy is {policy} "
+ $"({UpdatePolicyNodeType.NodePath}) — unattended module updates ride the "
+ "Continuous channel only; use the catalog's manual Update instead";
})
.Catch((Exception ex) =>
{
logger.LogWarning(ex,
"[SelfUpdate] could not read {Path} — declining unattended module landing "
+ "this pass.", UpdatePolicyNodeType.NodePath);
return Observable.Return<string?>(
$"the update policy at {UpdatePolicyNodeType.NodePath} could not be read "
+ $"({ex.GetType().Name})");
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ public static TBuilder AddSelfUpdate<TBuilder>(this TBuilder builder, SelfUpdate
builder.AddUpdatePolicyType();
builder.ConfigureServices(services =>
{
// The module lane's unattended-landing gate (#1664) rides the SAME Admin/UpdatePolicy
// node this feature owns — Continuous (default) lands store-installed modules
// unattended, Stable/None decline. Registered here, beside the node type, so a host
// without self-update simply has no policy provider and the PluginCatalog default
// (allowed) applies. Platform-neutral (a storage read), so no browser guard.
services.AddSingleton<MeshWeaver.PluginCatalog.IModuleUpdatePolicy, PlatformModuleUpdatePolicy>();
// Bind from configuration when the caller passes nothing. Without this the defaults were
// baked into the image: PollInterval sat at 6h with NO way for an environment to change
// it, and a SelfUpdate__PollInterval in the configmap silently did nothing — the failure
Expand Down
Loading
Loading