Resolve Eve plugin deployments from advertisements
This commit is contained in:
parent
10ba7e5b07
commit
5118387a44
5 changed files with 259 additions and 3 deletions
|
|
@ -23,16 +23,19 @@ namespace GameCult.Eve.Surface
|
|||
internal EveSurfaceSelection(
|
||||
EveProviderAdvertisementDocument provider,
|
||||
EveAdvertisedSurface advertisement,
|
||||
CultMeshDocumentHandle<EveSurfaceDocument> surface)
|
||||
CultMeshDocumentHandle<EveSurfaceDocument> surface,
|
||||
IReadOnlyList<EvePluginAdvertisementDocument> plugins)
|
||||
{
|
||||
Provider = provider;
|
||||
Advertisement = advertisement;
|
||||
Surface = surface;
|
||||
Plugins = plugins;
|
||||
}
|
||||
|
||||
public EveProviderAdvertisementDocument Provider { get; }
|
||||
public EveAdvertisedSurface Advertisement { get; }
|
||||
public CultMeshDocumentHandle<EveSurfaceDocument> Surface { get; }
|
||||
public IReadOnlyList<EvePluginAdvertisementDocument> Plugins { get; }
|
||||
}
|
||||
|
||||
public static class EveCultMeshSurfaceSelection
|
||||
|
|
@ -48,12 +51,54 @@ namespace GameCult.Eve.Surface
|
|||
var providers = await mesh.CollectionAsync<EveProviderAdvertisementDocument>(endpointId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var match = Select(await providers.LatestAsync().ConfigureAwait(false), resolved);
|
||||
IReadOnlyList<EvePluginAdvertisementDocument> plugins = Array.Empty<EvePluginAdvertisementDocument>();
|
||||
if (match.Surface.RequiresPlugins.Count > 0)
|
||||
{
|
||||
var advertisements = await mesh.CollectionAsync<EvePluginAdvertisementDocument>(endpointId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
plugins = ResolvePlugins(match.Surface, await advertisements.LatestAsync().ConfigureAwait(false));
|
||||
}
|
||||
var surface = await mesh.DocumentAsync<EveSurfaceDocument>(
|
||||
endpointId,
|
||||
match.Surface.RecordRef,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return new EveSurfaceSelection(match.Provider, match.Surface, surface);
|
||||
return new EveSurfaceSelection(match.Provider, match.Surface, surface, plugins);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<EvePluginAdvertisementDocument> ResolvePlugins(
|
||||
EveAdvertisedSurface surface,
|
||||
IEnumerable<EvePluginAdvertisementDocument> advertisements)
|
||||
{
|
||||
if (surface == null) throw new ArgumentNullException(nameof(surface));
|
||||
if (advertisements == null) throw new ArgumentNullException(nameof(advertisements));
|
||||
var available = advertisements
|
||||
.GroupBy(plugin => plugin.PluginId, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.OrderByDescending(plugin => plugin.Version, StringComparer.Ordinal).First(), StringComparer.Ordinal);
|
||||
var selected = new List<EvePluginAdvertisementDocument>();
|
||||
foreach (var requirement in surface.RequiresPlugins)
|
||||
{
|
||||
if (!available.TryGetValue(requirement.PluginId, out var plugin))
|
||||
{
|
||||
if (string.Equals(requirement.Availability, "required", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException(
|
||||
$"Eve surface '{surface.SurfaceId}' requires unavailable plugin '{requirement.PluginId}'.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var claims = plugin.Schemas.Concat(plugin.ComponentKinds).Concat(plugin.Commands)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var missing = requirement.RequiredCapabilities.Where(capability => !claims.Contains(capability)).ToArray();
|
||||
if (missing.Length > 0)
|
||||
{
|
||||
if (string.Equals(requirement.Availability, "required", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException(
|
||||
$"Eve plugin '{plugin.PluginId}' does not advertise required capabilities: {string.Join(", ", missing)}.");
|
||||
continue;
|
||||
}
|
||||
selected.Add(plugin);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
public static (EveProviderAdvertisementDocument Provider, EveAdvertisedSurface Surface) Select(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace GameCult.Eve.Surface
|
||||
{
|
||||
[CultDocument("gamecult.eve.plugin_advertisement", SchemaId)]
|
||||
[MessagePackObject]
|
||||
public sealed class EvePluginAdvertisementDocument
|
||||
{
|
||||
public const string SchemaId = "gamecult.eve.plugin_advertisement.v1";
|
||||
|
||||
public EvePluginAdvertisementDocument(
|
||||
string pluginId,
|
||||
string ownerService,
|
||||
string version,
|
||||
string manifestAddress,
|
||||
EvePluginRuntimeAdvertisement runtime,
|
||||
IReadOnlyList<string> schemas,
|
||||
IReadOnlyList<string> componentKinds,
|
||||
IReadOnlyList<string> commands,
|
||||
IReadOnlyList<string> fixtures)
|
||||
: this(SchemaId, pluginId, ownerService, version, manifestAddress, runtime, schemas, componentKinds, commands, fixtures)
|
||||
{
|
||||
}
|
||||
|
||||
[SerializationConstructor]
|
||||
public EvePluginAdvertisementDocument(
|
||||
string schema,
|
||||
string pluginId,
|
||||
string ownerService,
|
||||
string version,
|
||||
string manifestAddress,
|
||||
EvePluginRuntimeAdvertisement runtime,
|
||||
IReadOnlyList<string> schemas,
|
||||
IReadOnlyList<string> componentKinds,
|
||||
IReadOnlyList<string> commands,
|
||||
IReadOnlyList<string> fixtures)
|
||||
{
|
||||
Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema;
|
||||
PluginId = pluginId ?? "";
|
||||
OwnerService = ownerService ?? "";
|
||||
Version = version ?? "";
|
||||
ManifestAddress = manifestAddress ?? "";
|
||||
Runtime = runtime ?? new EvePluginRuntimeAdvertisement("", "", Array.Empty<string>(), Array.Empty<string>(), null);
|
||||
Schemas = schemas ?? Array.Empty<string>();
|
||||
ComponentKinds = componentKinds ?? Array.Empty<string>();
|
||||
Commands = commands ?? Array.Empty<string>();
|
||||
Fixtures = fixtures ?? Array.Empty<string>();
|
||||
}
|
||||
|
||||
[Key(0)] public string Schema { get; }
|
||||
[Key(1), CultName] public string PluginId { get; }
|
||||
[Key(2)] public string OwnerService { get; }
|
||||
[Key(3)] public string Version { get; }
|
||||
[Key(4)] public string ManifestAddress { get; }
|
||||
[Key(5)] public EvePluginRuntimeAdvertisement Runtime { get; }
|
||||
[Key(6)] public IReadOnlyList<string> Schemas { get; }
|
||||
[Key(7)] public IReadOnlyList<string> ComponentKinds { get; }
|
||||
[Key(8)] public IReadOnlyList<string> Commands { get; }
|
||||
[Key(9)] public IReadOnlyList<string> Fixtures { get; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class EvePluginRuntimeAdvertisement
|
||||
{
|
||||
[SerializationConstructor]
|
||||
public EvePluginRuntimeAdvertisement(
|
||||
string invocationModel,
|
||||
string contract,
|
||||
IReadOnlyList<string> transports,
|
||||
IReadOnlyList<string> authority,
|
||||
EvePluginSidecarAdvertisement? sidecar)
|
||||
{
|
||||
InvocationModel = invocationModel ?? "";
|
||||
Contract = contract ?? "";
|
||||
Transports = transports ?? Array.Empty<string>();
|
||||
Authority = authority ?? Array.Empty<string>();
|
||||
Sidecar = sidecar;
|
||||
}
|
||||
|
||||
[Key(0)] public string InvocationModel { get; }
|
||||
[Key(1)] public string Contract { get; }
|
||||
[Key(2)] public IReadOnlyList<string> Transports { get; }
|
||||
[Key(3)] public IReadOnlyList<string> Authority { get; }
|
||||
[Key(4)] public EvePluginSidecarAdvertisement? Sidecar { get; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class EvePluginSidecarAdvertisement
|
||||
{
|
||||
[SerializationConstructor]
|
||||
public EvePluginSidecarAdvertisement(
|
||||
string processKind,
|
||||
string protocol,
|
||||
string requestSchema,
|
||||
string responseSchema,
|
||||
IReadOnlyList<string> operations,
|
||||
string commandEnvelope,
|
||||
string receiptSchema,
|
||||
string stateAuthority)
|
||||
{
|
||||
ProcessKind = processKind ?? "";
|
||||
Protocol = protocol ?? "";
|
||||
RequestSchema = requestSchema ?? "";
|
||||
ResponseSchema = responseSchema ?? "";
|
||||
Operations = operations ?? Array.Empty<string>();
|
||||
CommandEnvelope = commandEnvelope ?? "";
|
||||
ReceiptSchema = receiptSchema ?? "";
|
||||
StateAuthority = stateAuthority ?? "";
|
||||
}
|
||||
|
||||
[Key(0)] public string ProcessKind { get; }
|
||||
[Key(1)] public string Protocol { get; }
|
||||
[Key(2)] public string RequestSchema { get; }
|
||||
[Key(3)] public string ResponseSchema { get; }
|
||||
[Key(4)] public IReadOnlyList<string> Operations { get; }
|
||||
[Key(5)] public string CommandEnvelope { get; }
|
||||
[Key(6)] public string ReceiptSchema { get; }
|
||||
[Key(7)] public string StateAuthority { get; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 7a5b91c7defe47d2a38940a460aa59c5
|
||||
|
|
@ -119,7 +119,8 @@ namespace GameCult.Eve.Surface
|
|||
string transport,
|
||||
string status,
|
||||
string surfaceKind,
|
||||
EveWorldInteractionAdvertisement? worldInteraction = null)
|
||||
EveWorldInteractionAdvertisement? worldInteraction = null,
|
||||
IReadOnlyList<EvePluginRequirement>? requiresPlugins = null)
|
||||
{
|
||||
SurfaceId = surfaceId ?? "";
|
||||
Schema = schema ?? "";
|
||||
|
|
@ -128,6 +129,7 @@ namespace GameCult.Eve.Surface
|
|||
Status = status ?? "";
|
||||
SurfaceKind = surfaceKind ?? "";
|
||||
WorldInteraction = worldInteraction;
|
||||
RequiresPlugins = requiresPlugins ?? Array.Empty<EvePluginRequirement>();
|
||||
}
|
||||
|
||||
[Key(0)] public string SurfaceId { get; }
|
||||
|
|
@ -137,6 +139,32 @@ namespace GameCult.Eve.Surface
|
|||
[Key(4)] public string Status { get; }
|
||||
[Key(5)] public string SurfaceKind { get; }
|
||||
[Key(6)] public EveWorldInteractionAdvertisement? WorldInteraction { get; }
|
||||
[Key(7)] public IReadOnlyList<EvePluginRequirement> RequiresPlugins { get; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class EvePluginRequirement
|
||||
{
|
||||
[SerializationConstructor]
|
||||
public EvePluginRequirement(
|
||||
string pluginId,
|
||||
string versionRange,
|
||||
string availability,
|
||||
IReadOnlyList<string> requiredCapabilities,
|
||||
IReadOnlyList<string> optionalCapabilities)
|
||||
{
|
||||
PluginId = pluginId ?? "";
|
||||
VersionRange = versionRange ?? "";
|
||||
Availability = string.IsNullOrWhiteSpace(availability) ? "required" : availability;
|
||||
RequiredCapabilities = requiredCapabilities ?? Array.Empty<string>();
|
||||
OptionalCapabilities = optionalCapabilities ?? Array.Empty<string>();
|
||||
}
|
||||
|
||||
[Key(0)] public string PluginId { get; }
|
||||
[Key(1)] public string VersionRange { get; }
|
||||
[Key(2)] public string Availability { get; }
|
||||
[Key(3)] public IReadOnlyList<string> RequiredCapabilities { get; }
|
||||
[Key(4)] public IReadOnlyList<string> OptionalCapabilities { get; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
|
|
|
|||
|
|
@ -61,6 +61,41 @@ namespace GameCult.Eve.UnityScene.Tests
|
|||
new EveSurfaceRequest { LoweringTarget = "flutter" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ResolvePlugins_KeepsIndependentRequiredAndOptionalNestedCapabilities()
|
||||
{
|
||||
var surface = new EveAdvertisedSurface(
|
||||
"sai.stage", EveSurfaceDocument.SchemaId, "eve:surface:sai", "cultmesh-record", "active", "interface",
|
||||
requiresPlugins: new[]
|
||||
{
|
||||
new EvePluginRequirement("sai.vn", "^0.1.0", "required", new[] { "vn.stage", "story.choose" }, Array.Empty<string>()),
|
||||
new EvePluginRequirement("norn.graph", "^0.1.0", "optional-nested", Array.Empty<string>(), new[] { "embed.norn" }),
|
||||
new EvePluginRequirement("tex.math", "^0.1.0", "optional-nested", Array.Empty<string>(), new[] { "embed.tex" })
|
||||
});
|
||||
var sai = Plugin("sai.vn", new[] { "vn.stage" }, new[] { "story.choose" });
|
||||
var norn = Plugin("norn.graph", new[] { "embed.norn" }, Array.Empty<string>());
|
||||
|
||||
var selected = EveCultMeshSurfaceSelection.ResolvePlugins(surface, new[] { sai, norn });
|
||||
|
||||
Assert.That(selected, Is.EqualTo(new[] { sai, norn }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ResolvePlugins_RejectsMissingRequiredCapability()
|
||||
{
|
||||
var surface = new EveAdvertisedSurface(
|
||||
"sai.stage", EveSurfaceDocument.SchemaId, "eve:surface:sai", "cultmesh-record", "active", "interface",
|
||||
requiresPlugins: new[]
|
||||
{
|
||||
new EvePluginRequirement("sai.vn", "^0.1.0", "required", new[] { "story.choose" }, Array.Empty<string>())
|
||||
});
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
EveCultMeshSurfaceSelection.ResolvePlugins(
|
||||
surface,
|
||||
new[] { Plugin("sai.vn", new[] { "vn.stage" }, Array.Empty<string>()) }));
|
||||
}
|
||||
|
||||
private static EveProviderAdvertisementDocument Provider(EveAdvertisedSurface surface) =>
|
||||
new EveProviderAdvertisementDocument(
|
||||
"aetheria.daemon",
|
||||
|
|
@ -75,5 +110,26 @@ namespace GameCult.Eve.UnityScene.Tests
|
|||
Array.Empty<EveProviderWitness>(),
|
||||
new[] { surface },
|
||||
Array.Empty<EveAdvertisedCommand>());
|
||||
|
||||
private static EvePluginAdvertisementDocument Plugin(
|
||||
string pluginId,
|
||||
string[] components,
|
||||
string[] commands) =>
|
||||
new EvePluginAdvertisementDocument(
|
||||
pluginId,
|
||||
pluginId.Split('.')[0],
|
||||
"0.1.0",
|
||||
"cultmesh://plugins/" + pluginId,
|
||||
new EvePluginRuntimeAdvertisement(
|
||||
"sidecar",
|
||||
"gamecult.eve.plugin_abi.v1",
|
||||
new[] { "cultmesh" },
|
||||
new[] { "plugin" },
|
||||
new EvePluginSidecarAdvertisement(
|
||||
"daemon", "cultmesh", "request", "response", Array.Empty<string>(), "command", "receipt", "plugin")),
|
||||
Array.Empty<string>(),
|
||||
components,
|
||||
commands,
|
||||
Array.Empty<string>());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Reference in a new issue