Explorer source
This page is part of the documentation for Orleans.Lattice 9.9.0 (release line 9.9), built 2026-10-04. It is also published as markdown, with every table and list, at source.md, and llms.txt lists every page.The source of the Explorer sample.
Program.cs
using System.Diagnostics;
using Orleans.Lattice.Samples.Explorer;
// Orleans.Lattice.Explorer sample: one process that shows every area of the
// Explorer console with live data, with no cloud dependency.
//
// By default it runs a two-region estate: the east and west regions are two
// single-silo Orleans clusters in this process, each serving every control
// plane the Explorer has an area for on its own gRPC endpoint, replicating with
// each other over loopback gRPC, and sharing one in-memory backup sink. East
// also serves the console, which connects to east (or to west, with
// --explorer-region west). Tenancy is on, with two seeded tenants. --minimal
// keeps the single-region experience with no tenancy and no peer.
//
// The console signs in automatically as the bootstrap administrator, so every
// administrator-gated area lights up. Telemetry is the one area that stays
// hidden: it reads a Prometheus-compatible metrics backend, which this sample
// does not run. README.md walks each area.
if (!ExplorerSampleOptions.TryParse(args, Environment.GetEnvironmentVariable, out var options, out var error))
{
Console.WriteLine(error);
return 2;
}
var clock = Stopwatch.StartNew();
Console.WriteLine(options.Minimal ? "Starting one region..." : "Starting the east and west regions...");
await using var sample = ExplorerSample.Create(options);
await sample.StartAsync();
SampleBanner.Write(Console.Out, sample, clock.Elapsed);
sample.PeerLink.Changed += paused => Console.WriteLine(paused
? "Peer link PAUSED: replication between east and west is refused. Watch Replication go Lagging, then Stalled."
: "Peer link RESUMED: the regions catch up.");
using var stop = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
stop.Cancel();
};
if (sample.West is not null)
{
_ = Task.Run(() => ReadPeerToggles(sample.PeerLink, stop.Token));
}
try
{
await Task.Delay(Timeout.Infinite, stop.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Stopping...");
}
return 0;
// P (or a line starting with p, when input is redirected) pauses or resumes the
// link between the regions.
static void ReadPeerToggles(PeerLink link, CancellationToken stop)
{
while (!stop.IsCancellationRequested)
{
if (Console.IsInputRedirected)
{
var line = Console.In.ReadLine();
if (line is null)
{
return;
}
if (line.Trim().StartsWith('p') || line.Trim().StartsWith('P'))
{
link.Toggle();
}
}
else if (Console.ReadKey(intercept: true).Key == ConsoleKey.P)
{
link.Toggle();
}
}
}
Apps/TaskBoard/src/manifest.json
{
"identity": {
"slug": "task-board",
"version": "1.0.0",
"provenance": {
"source": "in-image",
"publisher": "Orleans.Lattice samples",
"reference": "embedded:Orleans.Lattice.Samples.Explorer.TaskBoard.manifest.json"
}
},
"presentation": {
"displayName": "Task board",
"summary": "A three-column task board whose cards live as JSON values in the app's own tree.",
"description": "Task board is the Explorer's pilot app with an untrusted UI.\nIt lists, adds, moves and deletes task cards stored under tasks/{id} in its one tree, and deep-links the selected card into the Explorer's address line.\nViewers can read the board. Editors can also change it.",
"icon": { "path": "icon.svg", "digest": "c367e956895304c28f0936bd8f80db36308cfd837e8827c71ebe87940b4403a6" },
"categories": ["productivity", "sample"],
"publisherDisplayName": "Orleans.Lattice samples"
},
"trees": [{ "name": "tasks" }],
"roles": [
{
"name": "viewer",
"operations": ["Read", "RangeRead"],
"scopes": [{ "tree": "tasks" }]
},
{
"name": "editor",
"operations": ["Read", "RangeRead", "Write", "Delete"],
"scopes": [{ "tree": "tasks" }]
}
],
"replication": [{ "tree": "tasks", "mergeMode": "LwwRegister" }],
"schema": [],
"subscriptions": [],
"mcpTools": [],
"ui": {
"entry": "index.html",
"styles": ["app.css"],
"scripts": [{ "path": "app.mjs", "module": true }],
"assets": [
{ "path": "index.html", "mediaType": "text/html", "digest": "48e16d67c058dd9f19f64f7b27bd03fc1dfb33d39d9774be88acd988df2b8bf2" },
{ "path": "app.css", "mediaType": "text/css", "digest": "a2ad7151751a9261c28b05c7a7888bc3e6033eee044666d923a750fe22e2873b" },
{ "path": "app.mjs", "mediaType": "text/javascript", "digest": "935cc197b6148e8d984e9d7adafccbf7ced3a6f45984487bbdd04b44f867775f" },
{ "path": "icon.svg", "mediaType": "image/svg+xml", "digest": "c367e956895304c28f0936bd8f80db36308cfd837e8827c71ebe87940b4403a6" }
],
"bundleDigest": "753d0f676b30113bd014d54b8dc0508bed711a2c904ac13ba88a8a2a10555c36",
"bridge": [
{ "operation": "context.read" },
{ "operation": "data.read", "trees": ["tasks"] },
{ "operation": "data.write", "trees": ["tasks"] },
{ "operation": "data.delete", "trees": ["tasks"] },
{ "operation": "nav.sync" },
{ "operation": "ui.notify" }
],
"minProtocol": 1
}
}
Apps/TaskBoard/src/TaskBoard.csproj
<Project Sdk="Microsoft.NET.Sdk">
<!--
The task-board sample app: a manifest and a UI bundle embedded in a small
class library, with no build toolchain. The digests in manifest.json are
checked (not produced) by TaskBoard.Tests, which fails with the correct
values when a bundle file changes.
-->
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Orleans.Lattice.Samples.Explorer.TaskBoard</RootNamespace>
<AssemblyName>Orleans.Lattice.Samples.Explorer.TaskBoard</AssemblyName>
<IsPackable>false</IsPackable>
</PropertyGroup>
<!--
InImageAppSource reads the manifest by name and each bundle asset from
"{manifest namespace}.ui.{path with '/' as '.'}", so these logical names
let the three-argument AddLatticeApp overload find the bundle unaided.
-->
<ItemGroup>
<EmbeddedResource Include="manifest.json" LogicalName="Orleans.Lattice.Samples.Explorer.TaskBoard.manifest.json" />
<EmbeddedResource Include="ui/index.html" LogicalName="Orleans.Lattice.Samples.Explorer.TaskBoard.ui.index.html" />
<EmbeddedResource Include="ui/app.css" LogicalName="Orleans.Lattice.Samples.Explorer.TaskBoard.ui.app.css" />
<EmbeddedResource Include="ui/app.mjs" LogicalName="Orleans.Lattice.Samples.Explorer.TaskBoard.ui.app.mjs" />
<EmbeddedResource Include="ui/icon.svg" LogicalName="Orleans.Lattice.Samples.Explorer.TaskBoard.ui.icon.svg" />
</ItemGroup>
</Project>
Apps/TaskBoard/src/TaskBoardApp.cs
using System.Reflection;
namespace Orleans.Lattice.Samples.Explorer.TaskBoard;
/// <summary>
/// Where the task-board sample app lives in this assembly. A silo registers it with the in-image
/// app source through the three-argument <c>AddLatticeApp</c> overload:
/// <c>siloBuilder.AddLatticeApp(TaskBoardApp.Slug, TaskBoardApp.Assembly, TaskBoardApp.ManifestResourceName)</c>.
/// </summary>
public static class TaskBoardApp
{
/// <summary>The app slug the manifest declares.</summary>
public const string Slug = "task-board";
/// <summary>The manifest's embedded-resource name.</summary>
public const string ManifestResourceName = "Orleans.Lattice.Samples.Explorer.TaskBoard.manifest.json";
/// <summary>
/// The embedded-resource name prefix of the UI bundle assets; it is the in-image source's default
/// for <see cref="ManifestResourceName"/>, so no explicit prefix need be passed.
/// </summary>
public const string AssetResourcePrefix = "Orleans.Lattice.Samples.Explorer.TaskBoard.ui.";
/// <summary>The assembly that carries the manifest and the bundle.</summary>
public static Assembly Assembly => typeof(TaskBoardApp).Assembly;
}
Apps/TaskBoard/test/TaskBoard.Tests.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Orleans.Lattice.Samples.Explorer.TaskBoard.Tests</RootNamespace>
<AssemblyName>Orleans.Lattice.Samples.Explorer.TaskBoard.Tests</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
<PackageReference Include="NUnit" Version="4.6.1" />
<PackageReference Include="NUnit3TestAdapter" Version="6.2.0" />
</ItemGroup>
<ItemGroup>
<Using Include="NUnit.Framework" />
</ItemGroup>
<!-- The tests read the bundle from disk as well as from the embedded resources. -->
<ItemGroup>
<AssemblyMetadata Include="TaskBoardSourceDirectory" Value="$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../src/'))" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\src\TaskBoard.csproj" />
<ProjectReference Include="..\..\..\..\..\src\lattice.apps\Orleans.Lattice.Apps.csproj" />
</ItemGroup>
</Project>
Apps/TaskBoard/test/TaskBoardBundleTests.cs
using System.Text.RegularExpressions;
namespace Orleans.Lattice.Samples.Explorer.TaskBoard.Tests;
/// <summary>
/// The bundle is plain HTML, CSS and one self-contained ES module that reaches the Explorer only
/// through <c>globalThis.lattice</c>, needs no toolchain, and hides every write control until
/// <c>context.read</c> reports a role that may write.
/// </summary>
[TestFixture]
public sealed class TaskBoardBundleTests
{
private static readonly string[] TextFiles =
[
"manifest.json", "ui/index.html", "ui/app.css", "ui/app.mjs", "ui/icon.svg", "ui/.gitattributes", ".gitattributes",
];
[Test]
public void Every_file_is_plain_ascii_with_lf_line_endings()
{
Assert.Multiple(() =>
{
foreach (var file in TextFiles)
{
var bytes = TaskBoardFiles.ReadBytes(file);
Assert.That(bytes.All(b => b is 0x09 or 0x0a or (>= 0x20 and < 0x7f)), Is.True, file + " is ASCII without CR");
}
});
}
[Test]
public void The_line_endings_are_pinned()
{
Assert.That(TaskBoardFiles.ReadText("ui/.gitattributes"), Does.Contain("* text eol=lf"));
Assert.That(TaskBoardFiles.ReadText(".gitattributes"), Does.Contain("manifest.json text eol=lf"));
}
[TestCase(@"^\s*import\b", "a static import")]
[TestCase(@"\bimport\s*\(", "a dynamic import")]
[TestCase(@"^\s*export\b", "an export")]
[TestCase(@"\bfetch\s*\(", "fetch")]
[TestCase(@"\bXMLHttpRequest\b", "XHR")]
[TestCase(@"\bWebSocket\b", "a WebSocket")]
[TestCase(@"\bEventSource\b", "an EventSource")]
[TestCase(@"\b(localStorage|sessionStorage|indexedDB)\b", "storage")]
[TestCase(@"\bdocument\.cookie\b", "cookies")]
[TestCase(@"\b(window\.)?parent\b|\btop\b\.|\bpostMessage\b", "the parent window")]
[TestCase(@"\b(innerHTML|outerHTML|insertAdjacentHTML)\b|document\.write", "HTML injection")]
[TestCase(@"\beval\s*\(|\bnew\s+Function\b", "code generation")]
[TestCase(@"\bsetTimeout\s*\(\s*[""'`]", "string timers")]
public void The_module_is_self_contained_and_uses_only_the_lattice_api(string pattern, string what)
{
var module = TaskBoardFiles.ReadText("ui/app.mjs");
Assert.That(Regex.IsMatch(module, pattern, RegexOptions.Multiline), Is.False, "app.mjs must not use " + what);
}
[Test]
public void The_module_reaches_the_host_through_the_lattice_global()
{
var module = TaskBoardFiles.ReadText("ui/app.mjs");
Assert.Multiple(() =>
{
Assert.That(module, Does.Contain("await lattice.ready"));
Assert.That(module, Does.Contain("lattice.on(\"context.changed\""));
Assert.That(module, Does.Contain("lattice.on(\"nav.changed\""));
Assert.That(module, Does.Contain("lattice.on(\"lattice.revoked\""));
Assert.That(module, Does.Contain("lattice.assetUrl(\"icon.svg\")"));
});
}
[Test]
public void The_stylesheet_needs_no_url_import_or_hover()
{
var css = TaskBoardFiles.ReadText("ui/app.css");
Assert.Multiple(() =>
{
Assert.That(css, Does.Not.Contain("url("), "a blob: stylesheet has no base URL");
Assert.That(css, Does.Not.Contain("@import"));
Assert.That(css, Does.Not.Contain(":hover"), "no hover-only affordance");
});
}
[Test]
public void Custom_controls_keep_the_kit_touch_target()
{
var css = TaskBoardFiles.ReadText("ui/app.css");
var card = Regex.Match(css, @"\.tb-card\s*\{(?<body>[^}]*)\}").Groups["body"].Value;
Assert.That(card, Does.Contain("min-height: var(--lt-app-control-height)"));
}
[Test]
public void The_columns_reflow_to_one_at_a_phone_width_without_width_queries()
{
var css = TaskBoardFiles.ReadText("ui/app.css");
Assert.That(css, Does.Contain("repeat(auto-fit, minmax(min(100%, 15rem), 1fr))"));
Assert.That(css, Does.Not.Contain("@media (min-width").And.Not.Contain("@media (max-width"));
}
[TestCase("tb-add")]
[TestCase("tb-detail-actions")]
public void Write_controls_start_hidden(string id)
{
var html = TaskBoardFiles.ReadText("ui/index.html");
Assert.That(Regex.IsMatch(html, "id=\"" + id + "\"[^>]*\\shidden\\b"), Is.True, id + " is hidden until context.read reports a writer role");
}
[Test]
public void The_entry_has_no_form_because_the_sandbox_blocks_submission()
{
Assert.That(TaskBoardFiles.ReadText("ui/index.html"), Does.Not.Contain("<form"));
}
[Test]
public void The_icon_is_a_static_svg()
{
var svg = TaskBoardFiles.ReadText("ui/icon.svg");
Assert.Multiple(() =>
{
Assert.That(svg, Does.StartWith("<svg"));
Assert.That(svg, Does.Not.Contain("<script").IgnoreCase);
Assert.That(svg, Does.Not.Contain("href").IgnoreCase);
Assert.That(Regex.IsMatch(svg, @"\son[a-z]+\s*=", RegexOptions.IgnoreCase), Is.False);
});
}
}
Apps/TaskBoard/test/TaskBoardDigestTests.cs
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Orleans.Lattice.Apps;
namespace Orleans.Lattice.Samples.Explorer.TaskBoard.Tests;
/// <summary>
/// The manifest pins the exact bytes of every bundle asset. There is no build step: these tests
/// compute the digests and, on drift, fail with the values to paste into manifest.json.
/// </summary>
[TestFixture]
public sealed class TaskBoardDigestTests
{
private static string Sha256(byte[] bytes) => Convert.ToHexStringLower(SHA256.HashData(bytes));
private static IReadOnlyList<AppUiAsset> ExpectedAssets()
{
using var document = JsonDocument.Parse(TaskBoardFiles.ReadBytes("manifest.json"));
return document.RootElement.GetProperty("ui").GetProperty("assets").EnumerateArray()
.Select(a =>
{
var path = a.GetProperty("path").GetString()!;
return new AppUiAsset
{
Path = path,
MediaType = a.GetProperty("mediaType").GetString()!,
Digest = Sha256(TaskBoardFiles.ReadAsset(path)),
};
})
.ToArray();
}
[Test]
public void The_manifest_digests_match_the_bundle_files()
{
using var document = JsonDocument.Parse(TaskBoardFiles.ReadBytes("manifest.json"));
var root = document.RootElement;
var ui = root.GetProperty("ui");
var expected = ExpectedAssets();
var expectedBundle = AppUiBundle.ComputeBundleDigest(expected.ToArray());
var expectedIcon = expected.Single(a => a.Path == "icon.svg").Digest;
var drift = new StringBuilder();
foreach (var (declared, asset) in ui.GetProperty("assets").EnumerateArray().Zip(expected))
{
if (declared.GetProperty("digest").GetString() != asset.Digest)
drift.AppendLine($" ui.assets[{asset.Path}].digest = \"{asset.Digest}\"");
}
if (ui.GetProperty("bundleDigest").GetString() != expectedBundle)
drift.AppendLine($" ui.bundleDigest = \"{expectedBundle}\"");
if (root.GetProperty("presentation").GetProperty("icon").GetProperty("digest").GetString() != expectedIcon)
drift.AppendLine($" presentation.icon.digest = \"{expectedIcon}\"");
Assert.That(drift.ToString(), Is.Empty, "manifest.json has drifted from the bundle files. Set:" + Environment.NewLine + drift);
}
[Test]
public void The_manifest_lists_exactly_the_bundle_files()
{
var onDisk = Directory.EnumerateFiles(Path.Combine(TaskBoardFiles.SourceDirectory, "ui"))
.Select(Path.GetFileName)
.Where(name => name != ".gitattributes");
Assert.That(TaskBoardFiles.Manifest().Ui!.Assets.Select(a => a.Path), Is.EquivalentTo(onDisk));
Assert.That(TaskBoardFiles.Manifest().Ui!.Assets.Select(a => a.Path), Is.EqualTo(TaskBoardFiles.AssetPaths));
}
[Test]
public void The_bundle_digest_is_the_f1_digest_of_the_assets()
{
var ui = TaskBoardFiles.Manifest().Ui!;
Assert.That(AppUiBundle.ComputeBundleDigest(ui.Assets), Is.EqualTo(ui.BundleDigest));
}
[Test]
public void The_icon_digest_is_its_asset_digest()
{
var manifest = TaskBoardFiles.Manifest();
Assert.That(manifest.Presentation!.Icon!.Digest, Is.EqualTo(manifest.Ui!.Assets.Single(a => a.Path == "icon.svg").Digest));
}
[Test]
public void Every_embedded_resource_is_the_file_on_disk()
{
Assert.Multiple(() =>
{
Assert.That(TaskBoardFiles.ReadEmbedded(TaskBoardApp.ManifestResourceName), Is.EqualTo(TaskBoardFiles.ReadBytes("manifest.json")), "manifest.json");
foreach (var path in TaskBoardFiles.AssetPaths)
{
Assert.That(TaskBoardFiles.ReadEmbedded(TaskBoardApp.AssetResourcePrefix + path), Is.EqualTo(TaskBoardFiles.ReadAsset(path)), path);
}
});
}
}
Apps/TaskBoard/test/TaskBoardFiles.cs
using System.Reflection;
using Orleans.Lattice.Apps;
namespace Orleans.Lattice.Samples.Explorer.TaskBoard.Tests;
/// <summary>Reads the task board's manifest and bundle, from disk and from the embedded resources.</summary>
internal static class TaskBoardFiles
{
/// <summary>The bundle assets the manifest lists, in manifest order.</summary>
public static readonly string[] AssetPaths = ["index.html", "app.css", "app.mjs", "icon.svg"];
/// <summary>The app library's source directory, stamped into this assembly at build time.</summary>
public static string SourceDirectory { get; } = typeof(TaskBoardFiles).Assembly
.GetCustomAttributes<AssemblyMetadataAttribute>()
.Single(a => a.Key == "TaskBoardSourceDirectory").Value!;
/// <summary>Reads the bytes of a file under the app's source directory.</summary>
public static byte[] ReadBytes(string relative) =>
File.ReadAllBytes(Path.Combine(SourceDirectory, relative.Replace('/', Path.DirectorySeparatorChar)));
/// <summary>Reads a file under the app's source directory as text.</summary>
public static string ReadText(string relative) =>
File.ReadAllText(Path.Combine(SourceDirectory, relative.Replace('/', Path.DirectorySeparatorChar)));
/// <summary>Reads a bundle asset's bytes from disk.</summary>
public static byte[] ReadAsset(string path) => ReadBytes("ui/" + path);
/// <summary>Reads an embedded resource of the app assembly.</summary>
public static byte[] ReadEmbedded(string resourceName)
{
using var stream = TaskBoardApp.Assembly.GetManifestResourceStream(resourceName)
?? throw new AssertionException($"The app assembly has no embedded resource '{resourceName}'.");
using var buffer = new MemoryStream();
stream.CopyTo(buffer);
return buffer.ToArray();
}
/// <summary>Parses the embedded manifest, failing with every diagnostic when it does not validate.</summary>
public static AppManifest Manifest()
{
using var stream = new MemoryStream(ReadEmbedded(TaskBoardApp.ManifestResourceName));
var result = AppManifestParser.Parse(stream);
Assert.That(result.Errors, Is.Empty, string.Join("; ", result.Errors.Select(e => e.Path + ": " + e.Message)));
return result.Manifest!;
}
}
Apps/TaskBoard/test/TaskBoardInImageSourceTests.cs
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Orleans.Lattice.Apps;
using Orleans.Lattice.Apps.Sources;
namespace Orleans.Lattice.Samples.Explorer.TaskBoard.Tests;
/// <summary>
/// The task board registers through the same <c>AddLatticeApp</c> line the Explorer sample uses,
/// and the in-image source then enumerates, resolves and serves it.
/// </summary>
[TestFixture]
public sealed class TaskBoardInImageSourceTests
{
private static InImageAppSource Source()
{
var services = new ServiceCollection();
services.AddLatticeApp(TaskBoardApp.Slug, TaskBoardApp.Assembly, TaskBoardApp.ManifestResourceName);
using var provider = services.BuildServiceProvider();
return new InImageAppSource(provider.GetRequiredService<IOptions<InImageAppSourceOptions>>());
}
[Test]
public void The_default_asset_prefix_is_the_one_the_app_embeds_under()
{
var registration = new InImageAppRegistration(AppSlug.Parse(TaskBoardApp.Slug), TaskBoardApp.Assembly, TaskBoardApp.ManifestResourceName);
Assert.That(registration.AssetResourcePrefix, Is.EqualTo(TaskBoardApp.AssetResourcePrefix));
}
[Test]
public async Task The_app_enumerates_from_the_in_image_source()
{
var source = Source();
var page = await source.ListAsync(AppSourceQuery.Default);
Assert.Multiple(() =>
{
Assert.That(source.Descriptor.Key, Is.EqualTo("in-image"));
Assert.That(page.Entries, Has.Count.EqualTo(1));
var entry = page.Entries[0];
Assert.That(entry.Slug.Value, Is.EqualTo(TaskBoardApp.Slug));
Assert.That(entry.IsAvailable, Is.True, string.Join("; ", entry.Errors.Select(e => e.Path + ": " + e.Message)));
Assert.That(entry.Versions, Is.EqualTo(new[] { AppVersion.Parse("1.0.0") }));
Assert.That(entry.Provenance!.Source, Is.EqualTo(InImageAppSource.SourceKey));
Assert.That(entry.Manifest!.Presentation!.DisplayName, Is.EqualTo("Task board"));
});
}
[Test]
public async Task The_app_resolves_from_the_composed_source_set()
{
var set = new AppSourceSet([Source()]);
var result = await set.ResolveAsync(AppSlug.Parse(TaskBoardApp.Slug), sourceKey: InImageAppSource.SourceKey);
Assert.That(result.IsResolved, Is.True, string.Join("; ", result.Errors.Select(e => e.Path + ": " + e.Message)));
Assert.That(result.Manifest!.Ui, Is.Not.Null);
}
[Test]
public async Task Every_bundle_asset_opens_verified_from_the_in_image_source()
{
var source = Source();
var manifest = TaskBoardFiles.Manifest();
foreach (var asset in manifest.Ui!.Assets)
{
var opened = await source.OpenAssetAsync(manifest.Identity.Slug, manifest.Identity.Version, asset.Path, asset.Digest);
Assert.That(opened.IsOpened, Is.True, asset.Path + ": " + opened.Status);
Assert.That(opened.MediaType, Is.EqualTo(asset.MediaType), asset.Path);
Assert.That(opened.Content.ToArray(), Is.EqualTo(TaskBoardFiles.ReadAsset(asset.Path)), asset.Path);
}
}
}
Apps/TaskBoard/test/TaskBoardManifestTests.cs
using System.Text.RegularExpressions;
using Orleans.Lattice.Apps;
using Orleans.Lattice.Auth;
namespace Orleans.Lattice.Samples.Explorer.TaskBoard.Tests;
/// <summary>The task board's manifest: one tree, two roles, a presentation and a UI that asks only for what it uses.</summary>
[TestFixture]
public sealed class TaskBoardManifestTests
{
[Test]
public void The_manifest_validates()
{
using var stream = new MemoryStream(TaskBoardFiles.ReadEmbedded(TaskBoardApp.ManifestResourceName));
var result = AppManifestParser.Parse(stream);
Assert.That(result.IsValid, Is.True, string.Join("; ", result.Errors.Select(e => e.Path + ": " + e.Message)));
}
[Test]
public void The_identity_is_the_task_board_slug()
{
var identity = TaskBoardFiles.Manifest().Identity;
Assert.That(identity.Slug.Value, Is.EqualTo(TaskBoardApp.Slug));
Assert.That(identity.Provenance.Source, Is.EqualTo(InImageAppSource.SourceKey));
}
[Test]
public void The_app_declares_one_tree_named_tasks()
{
Assert.That(TaskBoardFiles.Manifest().Trees.Select(t => t.Name), Is.EqualTo(new[] { "tasks" }));
}
[Test]
public void The_viewer_reads_and_the_editor_reads_and_writes_only_its_own_tree()
{
var roles = TaskBoardFiles.Manifest().Roles.ToDictionary(r => r.Name);
Assert.Multiple(() =>
{
Assert.That(roles.Keys, Is.EquivalentTo(new[] { "viewer", "editor" }));
Assert.That(roles["viewer"].Operations, Is.EqualTo(LatticeOperation.Read | LatticeOperation.RangeRead));
Assert.That(roles["editor"].Operations,
Is.EqualTo(LatticeOperation.Read | LatticeOperation.RangeRead | LatticeOperation.Write | LatticeOperation.Delete));
foreach (var role in roles.Values)
{
Assert.That(role.Scopes, Has.Length.EqualTo(1), role.Name);
Assert.That(role.Scopes[0].Tree, Is.EqualTo("tasks"), role.Name);
Assert.That(role.Scopes[0].App, Is.Null, role.Name + " stays in the app's own namespace");
Assert.That(role.Scopes[0].Kind, Is.EqualTo(LatticeScopeKind.Tree), role.Name);
}
});
}
[Test]
public void The_presentation_has_a_name_summary_description_and_svg_icon()
{
var presentation = TaskBoardFiles.Manifest().Presentation!;
Assert.Multiple(() =>
{
Assert.That(presentation.DisplayName, Is.EqualTo("Task board"));
Assert.That(presentation.Summary, Is.Not.Null.And.Not.Empty);
Assert.That(presentation.Description, Is.Not.Null.And.Not.Empty);
Assert.That(presentation.Icon!.Path, Is.EqualTo("icon.svg"));
});
}
[Test]
public void The_ui_is_a_fragment_one_stylesheet_and_one_module()
{
var ui = TaskBoardFiles.Manifest().Ui!;
Assert.Multiple(() =>
{
Assert.That(ui.Entry, Is.EqualTo("index.html"));
Assert.That(ui.Styles, Is.EqualTo(new[] { "app.css" }));
Assert.That(ui.Scripts, Has.Length.EqualTo(1));
Assert.That(ui.Scripts![0].Path, Is.EqualTo("app.mjs"));
Assert.That(ui.Scripts[0].Module, Is.True);
Assert.That(ui.MinProtocol, Is.EqualTo(AppUiProtocol.Current));
});
}
[Test]
public void The_ui_requests_exactly_the_pilot_bridge_operations()
{
var bridge = TaskBoardFiles.Manifest().Ui!.Bridge!;
Assert.That(bridge.Select(b => b.Operation), Is.EqualTo(new[]
{
AppUiBridgeOperations.ContextRead,
AppUiBridgeOperations.DataRead,
AppUiBridgeOperations.DataWrite,
AppUiBridgeOperations.DataDelete,
AppUiBridgeOperations.NavSync,
AppUiBridgeOperations.UiNotify,
}));
Assert.That(bridge.Where(b => AppUiBridgeOperations.IsDataOperation(b.Operation)).Select(b => b.Trees),
Is.All.EqualTo(new[] { "tasks" }));
}
[Test]
public void The_writer_roles_in_the_module_are_the_manifest_roles_that_can_write()
{
var module = TaskBoardFiles.ReadText("ui/app.mjs");
var declared = Regex.Match(module, @"const WRITER_ROLES = \[(?<list>[^\]]*)\];");
Assert.That(declared.Success, Is.True, "app.mjs declares WRITER_ROLES");
var inModule = Regex.Matches(declared.Groups["list"].Value, "\"(?<name>[a-z][a-z0-9_-]*)\"").Select(m => m.Groups["name"].Value);
var writers = TaskBoardFiles.Manifest().Roles
.Where(r => r.Operations.HasFlag(LatticeOperation.Write) && r.Operations.HasFlag(LatticeOperation.Delete))
.Select(r => r.Name);
Assert.That(inModule, Is.EquivalentTo(writers));
Assert.That(writers, Is.EquivalentTo(new[] { "editor" }));
}
[Test]
public void The_board_decides_write_access_from_context_read_roles_not_a_probe()
{
var module = TaskBoardFiles.ReadText("ui/app.mjs");
Assert.Multiple(() =>
{
Assert.That(module, Does.Contain("context.roles"));
Assert.That(module, Does.Contain("Array.isArray(context.roles)"), "an absent roles member infers no role");
Assert.That(module, Does.Not.Contain("probe").IgnoreCase, "no write probe");
Assert.That(Regex.Matches(module, "request\\(\"data\\.delete\"").Count, Is.EqualTo(1), "the only delete is the user's own delete");
Assert.That(Regex.IsMatch(module, @"canEdit:\s*false"), Is.True, "the board starts read-only");
Assert.That(module, Does.Contain("code === \"denied\""), "a denied write drops to read-only");
});
}
[Test]
public void The_module_uses_every_operation_it_requests_and_no_other()
{
var bridge = TaskBoardFiles.Manifest().Ui!.Bridge!.Select(b => b.Operation).ToHashSet();
var module = TaskBoardFiles.ReadText("ui/app.mjs");
Assert.Multiple(() =>
{
foreach (var operation in AppUiBridgeOperations.All)
{
var used = module.Contains("request(\"" + operation + "\"", StringComparison.Ordinal);
Assert.That(used, Is.EqualTo(bridge.Contains(operation)), operation);
}
});
}
[Test]
public void The_entry_is_a_valid_fragment()
{
Assert.That(AppManifestValidator.ValidateUiEntryFragment(TaskBoardFiles.ReadAsset("index.html")), Is.Empty);
}
}
DemoBasicAuthenticator.cs
using System.Text;
using Orleans.Lattice;
using Orleans.Lattice.Membership;
namespace Orleans.Lattice.Samples.Explorer;
/// <summary>
/// A minimal demo <see cref="ILatticeCredentialAuthenticator"/> that trusts the
/// username in the console's auto-applied Basic sign-in as the caller subject id.
/// The Explorer web console signs in with
/// <c>authorization: Basic base64(username:password)</c>; the auth / schema gRPC
/// bindings (configured with a <c>Basic</c> credential scheme) strip the scheme
/// and hand this authenticator the base64 token, which it decodes to recover the
/// username. That username is the caller subject, and the sample registers it as
/// a bootstrap administrator so the Access and Schema admin areas light up.
///
/// A real deployment resolves the subject from a validated JWT or Entra token
/// (see the JWT / Entra authenticators shipped with the Membership package); this
/// sample uses a trivial trusted-token authenticator so the whole flow runs on
/// one silo with no identity provider and the password is never checked.
/// </summary>
internal sealed class DemoBasicAuthenticator : ILatticeCredentialAuthenticator
{
/// <summary>The credential scheme this authenticator claims.</summary>
public const string Scheme = "Basic";
/// <summary>The issuer stamped on the resolved principal.</summary>
public const string Issuer = "https://issuer.explorer.sample/";
/// <inheritdoc />
public bool CanHandle(in LatticeCredential credential) =>
string.Equals(credential.Scheme, Scheme, StringComparison.OrdinalIgnoreCase);
/// <inheritdoc />
public ValueTask<LatticePrincipal?> AuthenticateAsync(
LatticeCredential credential,
CancellationToken cancellationToken = default)
{
// The token is base64(username:password); the caller subject is the
// username. A malformed token resolves to no principal (anonymous).
string username;
try
{
var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(credential.Token));
var separator = decoded.IndexOf(':');
username = separator >= 0 ? decoded[..separator] : decoded;
}
catch (FormatException)
{
return new ValueTask<LatticePrincipal?>((LatticePrincipal?)null);
}
return string.IsNullOrEmpty(username)
? new ValueTask<LatticePrincipal?>((LatticePrincipal?)null)
: new ValueTask<LatticePrincipal?>(new LatticePrincipal(username, Issuer));
}
}
Explorer.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Orleans.Lattice.Samples.Explorer</RootNamespace>
<AssemblyName>Orleans.Lattice.Samples.Explorer</AssemblyName>
<IsPackable>false</IsPackable>
<!--
The Explorer's Razor components (including the App root and interactive
server render mode) live in the referenced RCLs, not this host project, so
the SDK does not auto-detect that this app hosts Blazor and omits the
framework's _framework/blazor.web.js script - leaving the console rendered
but non-interactive. Opting in explicitly pulls the Blazor Web script into
this app's static web assets so the interactive circuit starts.
-->
<RequiresAspNetWebAssets>true</RequiresAspNetWebAssets>
</PropertyGroup>
<!--
Apps/ holds sample apps as their own projects and test/ holds this sample's
smoke tests; keep this host's default globs out of both.
-->
<ItemGroup>
<Compile Remove="Apps/**;test/**" />
<Content Remove="Apps/**;test/**" />
<None Remove="Apps/**;test/**" />
<EmbeddedResource Remove="Apps/**;test/**" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Orleans.Lattice.Samples.Explorer.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Orleans.Server" Version="10.2.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\lattice\Orleans.Lattice.csproj" />
<ProjectReference Include="..\..\src\lattice.api.state\Orleans.Lattice.Api.State.csproj" />
<ProjectReference Include="..\..\src\lattice.api.state.grpc\Orleans.Lattice.Api.State.Grpc.csproj" />
<ProjectReference Include="..\..\src\lattice.membership\Orleans.Lattice.Membership.csproj" />
<ProjectReference Include="..\..\src\lattice.membership.entra\Orleans.Lattice.Membership.Entra.csproj" />
<ProjectReference Include="..\..\src\lattice.membership.entra.graph\Orleans.Lattice.Membership.Entra.Graph.csproj" />
<ProjectReference Include="..\..\src\lattice.auth\Orleans.Lattice.Auth.csproj" />
<ProjectReference Include="..\..\src\lattice.api.auth\Orleans.Lattice.Api.Auth.csproj" />
<ProjectReference Include="..\..\src\lattice.api.auth.grpc\Orleans.Lattice.Api.Auth.Grpc.csproj" />
<ProjectReference Include="..\..\src\lattice.schema\Orleans.Lattice.Schema.csproj" />
<ProjectReference Include="..\..\src\lattice.api.schema\Orleans.Lattice.Api.Schema.csproj" />
<ProjectReference Include="..\..\src\lattice.api.schema.grpc\Orleans.Lattice.Api.Schema.Grpc.csproj" />
<ProjectReference Include="..\..\src\lattice.explorer\WebHosting\Orleans.Lattice.Explorer.Web.csproj" />
<ProjectReference Include="..\..\src\lattice.apps\Orleans.Lattice.Apps.csproj" />
<ProjectReference Include="..\..\src\lattice.api.apps\Orleans.Lattice.Api.Apps.csproj" />
<ProjectReference Include="..\..\src\lattice.api.apps.grpc\Orleans.Lattice.Api.Apps.Grpc.csproj" />
<ProjectReference Include="..\..\src\lattice.api.treeadmin\Orleans.Lattice.Api.TreeAdmin.csproj" />
<ProjectReference Include="..\..\src\lattice.api.treeadmin.grpc\Orleans.Lattice.Api.TreeAdmin.Grpc.csproj" />
<ProjectReference Include="..\..\src\lattice.backup\Orleans.Lattice.Backup.csproj" />
<ProjectReference Include="..\..\src\lattice.api.backup\Orleans.Lattice.Api.Backup.csproj" />
<ProjectReference Include="..\..\src\lattice.api.backup.grpc\Orleans.Lattice.Api.Backup.Grpc.csproj" />
<ProjectReference Include="..\..\src\lattice.replication\Orleans.Lattice.Replication.csproj" />
<ProjectReference Include="..\..\src\lattice.api.replication\Orleans.Lattice.Api.Replication.csproj" />
<ProjectReference Include="..\..\src\lattice.api.replication.grpc\Orleans.Lattice.Api.Replication.Grpc.csproj" />
<ProjectReference Include="..\..\src\lattice.replication.grpc\Orleans.Lattice.Replication.Grpc.csproj" />
<ProjectReference Include="..\..\src\lattice.tenancy\Orleans.Lattice.Tenancy.csproj" />
<ProjectReference Include="..\..\src\lattice.api.tenantadmin\Orleans.Lattice.Api.TenantAdmin.csproj" />
<ProjectReference Include="..\..\src\lattice.api.tenantadmin.grpc\Orleans.Lattice.Api.TenantAdmin.Grpc.csproj" />
<!-- The task-board pilot app (P1): a manifest and UI bundle embedded in its own class library. -->
<ProjectReference Include="Apps\TaskBoard\src\TaskBoard.csproj" />
</ItemGroup>
</Project>
ExplorerSample.cs
namespace Orleans.Lattice.Samples.Explorer;
/// <summary>
/// The whole sample: the east region, which serves the Explorer console, and -
/// unless it runs <c>--minimal</c> - the west region it replicates with, the
/// backup sink they share, the switch that pauses the link between them and
/// the background writer that keeps the link busy.
/// </summary>
internal sealed class ExplorerSample : IAsyncDisposable
{
private readonly List<string> _seedLog = [];
private readonly Lock _seedLogLock = new();
private ExplorerSample(
ExplorerSampleOptions options,
SampleRegion east,
SampleRegion? west,
SampleSharedBackupSink? sink,
PeerLink peerLink,
SampleConsolePlan console)
{
Options = options;
East = east;
West = west;
Sink = sink;
PeerLink = peerLink;
Console = console;
}
/// <summary>The options the sample was built with.</summary>
public ExplorerSampleOptions Options { get; }
/// <summary>The primary region, which serves the Explorer console.</summary>
public SampleRegion East { get; }
/// <summary>The peer region, or <see langword="null"/> when the sample runs <c>--minimal</c>.</summary>
public SampleRegion? West { get; }
/// <summary>The backup sink the two regions share, or <see langword="null"/> when the sample runs <c>--minimal</c>.</summary>
public SampleSharedBackupSink? Sink { get; }
/// <summary>The switch that pauses replication between the regions.</summary>
public PeerLink PeerLink { get; }
/// <summary>Where the console is served and which region it connects to.</summary>
public SampleConsolePlan Console { get; }
/// <summary>The background writer, once started; <see langword="null"/> when the sample runs <c>--minimal</c>.</summary>
public ReplicationWriter? Writer { get; private set; }
/// <summary>The region the console connects to.</summary>
public SampleRegion ConsoleRegion => West is { } west && Options.ExplorerRegion == west.Id ? west : East;
/// <summary>Every region, primary first.</summary>
public IReadOnlyList<SampleRegion> Regions => West is null ? [East] : [East, West];
/// <summary>One line per item seeded, once <see cref="StartAsync"/> has completed.</summary>
public IReadOnlyList<string> SeedLog
{
get
{
lock (_seedLogLock)
{
return [.. _seedLog];
}
}
}
/// <summary>Builds the sample's regions; nothing starts until <see cref="StartAsync"/>.</summary>
/// <param name="options">How the sample runs.</param>
/// <returns>The sample.</returns>
public static ExplorerSample Create(ExplorerSampleOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var ports = options.Ports;
var peerLink = new PeerLink();
var eastEndpoint = new Uri($"http://localhost:{ports.EastGrpc}");
if (options.Minimal)
{
var console = new SampleConsolePlan(ports.EastWeb, eastEndpoint, options.ExplorerConfigPath, options.SignInAs);
var single = SampleRegion.Build(
new SampleRegionPlan(SampleIdentities.EastRegion, ports.EastGrpc, ports.EastSilo, ports.EastGateway, Peer: null, console),
options,
sink: null,
peerLink);
return new ExplorerSample(options, single, west: null, sink: null, peerLink, console);
}
var westEndpoint = new Uri($"http://localhost:{ports.WestGrpc}");
var estateConsole = new SampleConsolePlan(
ports.EastWeb,
options.ExplorerRegion == SampleIdentities.WestRegion ? westEndpoint : eastEndpoint,
options.ExplorerConfigPath,
options.SignInAs);
var sink = new SampleSharedBackupSink();
var east = SampleRegion.Build(
new SampleRegionPlan(
SampleIdentities.EastRegion,
ports.EastGrpc,
ports.EastSilo,
ports.EastGateway,
new SampleRegionPeer(SampleIdentities.WestRegion, ports.WestGrpc),
estateConsole),
options,
sink,
peerLink);
var west = SampleRegion.Build(
new SampleRegionPlan(
SampleIdentities.WestRegion,
ports.WestGrpc,
ports.WestSilo,
ports.WestGateway,
new SampleRegionPeer(SampleIdentities.EastRegion, ports.EastGrpc),
Console: null),
options,
sink,
peerLink);
return new ExplorerSample(options, east, west, sink, peerLink, estateConsole);
}
/// <summary>
/// Starts every region, seeds them and, on the estate, starts the background
/// writer. The console's persisted configuration is cleared first, so it
/// connects to the region this run was asked for.
/// </summary>
/// <param name="cancellationToken">Cancels the start.</param>
public async Task StartAsync(CancellationToken cancellationToken = default)
{
if (File.Exists(Console.ConfigPath))
{
File.Delete(Console.ConfigPath);
}
await Task.WhenAll(Regions.Select(region => region.StartAsync(cancellationToken))).ConfigureAwait(false);
// Every region is seeded, and the demo tree enrolled and known to the
// peer, before the primary writes the data replication then carries.
var staticDirectory = Options.Entra is null;
await Task.WhenAll(Regions.Select(region => SampleSeeder.SeedRegionAsync(region, staticDirectory, Log, cancellationToken)))
.ConfigureAwait(false);
if (West is { } peer)
{
await SampleSeeder.EnrolAsync(East, peer, Log, cancellationToken).ConfigureAwait(false);
}
await SampleSeeder.SeedPrimaryAsync(East, West, Log, cancellationToken).ConfigureAwait(false);
if (West is { } west)
{
var grainsEast = East.Services.GetRequiredService<IGrainFactory>();
var grainsWest = west.Services.GetRequiredService<IGrainFactory>();
Writer = new ReplicationWriter(
[
new ReplicationWriterTarget(East.Id, grainsEast.GetGrain<ILattice>(SampleIdentities.FactoryFloorTree), "machine-", SampleIdentities.MachineCount),
new ReplicationWriterTarget(west.Id, grainsWest.GetGrain<ILattice>(SampleIdentities.FactoryFloorTree), "west-sensor-", 4),
],
Options.WriterInterval);
Writer.Start();
if (Options.StartPeerPaused)
{
// Paused only once the seeded data has reached the peer, so every
// link has made contact: from there the links age into Lagging
// and then Stalled, rather than reading as never contacted.
var arrived = await WaitForSeedOnPeerAsync(west, SeedArrivalBudget, cancellationToken).ConfigureAwait(false);
PeerLink.Pause();
Log(arrived
? $"[{East.Id}] Peer link paused once the seeded data reached '{west.Id}'."
: $"[{East.Id}] Peer link paused; the seeded data had not reached '{west.Id}' within {SeedArrivalBudget.TotalSeconds:0}s.");
}
}
}
/// <summary>How long <c>--peer-paused</c> waits for the seeded data to reach the peer before pausing regardless.</summary>
public static TimeSpan SeedArrivalBudget { get; } = TimeSpan.FromSeconds(20);
/// <summary>
/// Waits until the last seeded machine of the demo tree and the last seeded
/// card of acme's task board have both replicated to <paramref name="peer"/>,
/// or <paramref name="budget"/> elapses.
/// </summary>
/// <param name="peer">The peer region.</param>
/// <param name="budget">The longest wait.</param>
/// <param name="cancellationToken">Cancels the wait.</param>
/// <returns>Whether the data arrived.</returns>
public static async Task<bool> WaitForSeedOnPeerAsync(SampleRegion peer, TimeSpan budget, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(peer);
var grains = peer.Services.GetRequiredService<IGrainFactory>();
var expected = new (ILattice Tree, string Key)[]
{
(grains.GetGrain<ILattice>(SampleIdentities.FactoryFloorTree), SampleSeeder.MachineKey(SampleIdentities.MachineCount - 1)),
(grains.GetGrain<ILattice>(SampleSeeder.TaskBoardTree(SampleIdentities.AcmeTenant)), SampleSeeder.TaskKey(SampleSeeder.AcmeTasks[^1].Id)),
};
using var budgetSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
budgetSource.CancelAfter(budget);
using var poll = new PeriodicTimer(TimeSpan.FromMilliseconds(200));
try
{
do
{
using (LatticeSystemOrigin.Enter())
{
var arrived = true;
foreach (var (tree, key) in expected)
{
arrived &= await tree.GetAsync(key, budgetSource.Token).ConfigureAwait(false) is not null;
}
if (arrived)
{
return true;
}
}
}
while (await poll.WaitForNextTickAsync(budgetSource.Token).ConfigureAwait(false));
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// The budget elapsed.
}
return false;
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (Writer is not null)
{
await Writer.DisposeAsync().ConfigureAwait(false);
}
foreach (var region in Regions)
{
await region.DisposeAsync().ConfigureAwait(false);
}
}
private void Log(string line)
{
lock (_seedLogLock)
{
_seedLog.Add(line);
}
}
}
ExplorerSampleOptions.cs
using System.Diagnostics.CodeAnalysis;
using Orleans.Lattice.Membership;
namespace Orleans.Lattice.Samples.Explorer;
/// <summary>
/// How the sample runs: the full two-region estate with tenancy (the default),
/// or <c>--minimal</c> for the single-cluster experience; which region the
/// Explorer connects to; and the identity-directory and group-merge settings
/// read from the environment.
/// </summary>
internal sealed class ExplorerSampleOptions
{
/// <summary>The switch that keeps the single-cluster experience.</summary>
public const string MinimalSwitch = "--minimal";
/// <summary>The switch that picks the region the Explorer connects to; it takes <c>east</c> or <c>west</c>.</summary>
public const string ExplorerRegionSwitch = "--explorer-region";
/// <summary>The switch that pauses the link to the peer region as soon as the seeded data has reached it.</summary>
public const string PeerPausedSwitch = "--peer-paused";
/// <summary>The switch that picks the identity the console signs in as automatically, or <c>none</c>.</summary>
public const string SignInAsSwitch = "--sign-in-as";
/// <summary>The <see cref="SignInAsSwitch"/> value that turns the automatic sign-in off.</summary>
public const string NoSignIn = "none";
/// <summary>The switch that shifts every port by a number, for when the default ports are taken.</summary>
public const string PortOffsetSwitch = "--port-offset";
/// <summary>The largest <see cref="PortOffsetSwitch"/> accepted, which keeps every port below 65536.</summary>
public const int MaxPortOffset = 30000;
/// <summary>The Entra tenant variable; set it with the other two to use the Entra identity directory.</summary>
public const string EntraTenantIdVariable = "LATTICE_ENTRA_TENANT_ID";
/// <summary>The Entra client-id variable.</summary>
public const string EntraClientIdVariable = "LATTICE_ENTRA_CLIENT_ID";
/// <summary>The Entra client-secret variable.</summary>
public const string EntraClientSecretVariable = "LATTICE_ENTRA_CLIENT_SECRET";
/// <summary>The group-merge mode variable: <c>Union</c> (default), <c>TokenOnly</c> or <c>DirectoryOnly</c>.</summary>
public const string MergeModeVariable = "LATTICE_MEMBERSHIP_MERGE_MODE";
/// <summary>The usage line printed with a rejected argument.</summary>
public const string Usage = "Usage: dotnet run [-- [--minimal] [--explorer-region east|west] [--sign-in-as <user>|none] [--peer-paused] [--port-offset <n>]]";
/// <summary>Whether to run one single-region cluster without tenancy, as the sample did before the estate.</summary>
public bool Minimal { get; init; }
/// <summary>The region the Explorer console connects to.</summary>
public string ExplorerRegion { get; init; } = SampleIdentities.EastRegion;
/// <summary>
/// The identity the console signs in as automatically, or <see langword="null"/>
/// to leave it signed out so the sign-in dialog picks the identity. Signing out
/// does not stick while an automatic sign-in is set: the console signs straight
/// back in.
/// </summary>
public string? SignInAs { get; init; } = SampleIdentities.Administrator;
/// <summary>Whether to pause the link to the peer region once the seeded data has reached it.</summary>
public bool StartPeerPaused { get; init; }
/// <summary>The Entra registration backing the identity directory, or <see langword="null"/> for the static roster.</summary>
public SampleEntraDirectory? Entra { get; init; }
/// <summary>Whether locally-defined membership contributes to authorization.</summary>
public SubjectGroupMergeMode GroupMergeMode { get; init; } = SubjectGroupMergeMode.Union;
/// <summary>The ports the sample binds.</summary>
public SamplePorts Ports { get; init; } = SamplePorts.Default;
/// <summary>
/// The Explorer console's persisted configuration file. The sample deletes it
/// on start, so every run connects to the region it was asked for.
/// </summary>
public string ExplorerConfigPath { get; init; } = Path.Combine(AppContext.BaseDirectory, "explorer-sample-config.json");
/// <summary>How often the background writer updates one replicated key.</summary>
public TimeSpan WriterInterval { get; init; } = TimeSpan.FromSeconds(1);
/// <summary>
/// Reads the command line and the environment, rejecting anything it does not
/// recognise so a typo never silently runs a different sample.
/// </summary>
/// <param name="args">The command-line arguments.</param>
/// <param name="environment">Reads an environment variable, returning <see langword="null"/> when it is unset.</param>
/// <param name="options">The options, when the input is valid.</param>
/// <param name="error">Why the input was rejected, when it is not.</param>
/// <returns>Whether the input was valid.</returns>
public static bool TryParse(
IReadOnlyList<string> args,
Func<string, string?> environment,
[NotNullWhen(true)] out ExplorerSampleOptions? options,
[NotNullWhen(false)] out string? error)
{
ArgumentNullException.ThrowIfNull(args);
ArgumentNullException.ThrowIfNull(environment);
options = null;
var minimal = false;
var peerPaused = false;
var portOffset = 0;
string? region = null;
var signInAs = SampleIdentities.Administrator;
for (var i = 0; i < args.Count; i++)
{
var arg = args[i];
if (string.Equals(arg, MinimalSwitch, StringComparison.OrdinalIgnoreCase))
{
minimal = true;
}
else if (string.Equals(arg, PeerPausedSwitch, StringComparison.OrdinalIgnoreCase))
{
peerPaused = true;
}
else if (string.Equals(arg, ExplorerRegionSwitch, StringComparison.OrdinalIgnoreCase))
{
if (i + 1 >= args.Count)
{
error = $"{ExplorerRegionSwitch} needs a region: {SampleIdentities.EastRegion} or {SampleIdentities.WestRegion}.";
return false;
}
region = args[++i].Trim().ToLowerInvariant();
if (region is not (SampleIdentities.EastRegion or SampleIdentities.WestRegion))
{
error = $"{ExplorerRegionSwitch} '{args[i]}' is not a region: use {SampleIdentities.EastRegion} or {SampleIdentities.WestRegion}.";
return false;
}
}
else if (string.Equals(arg, SignInAsSwitch, StringComparison.OrdinalIgnoreCase))
{
var value = i + 1 < args.Count ? args[++i].Trim() : string.Empty;
if (value.Length == 0 || value.StartsWith('-') || value.Contains(':', StringComparison.Ordinal) || value.Any(char.IsWhiteSpace))
{
error = $"{SignInAsSwitch} needs a user name, such as {SampleIdentities.AcmeAdmin}, or {NoSignIn}.";
return false;
}
signInAs = value;
}
else if (string.Equals(arg, PortOffsetSwitch, StringComparison.OrdinalIgnoreCase))
{
if (i + 1 >= args.Count
|| !int.TryParse(args[++i], System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out portOffset)
|| portOffset > MaxPortOffset)
{
error = $"{PortOffsetSwitch} needs a whole number from 0 to {MaxPortOffset}.";
return false;
}
}
else
{
error = $"'{arg}' is not recognised. {Usage}";
return false;
}
}
if (minimal && (peerPaused || region == SampleIdentities.WestRegion))
{
error = $"{MinimalSwitch} runs one region with no peer, so it cannot be combined with {PeerPausedSwitch} or {ExplorerRegionSwitch} {SampleIdentities.WestRegion}.";
return false;
}
if (!TryReadEntra(environment, out var entra, out var entraError))
{
error = entraError;
return false;
}
var mergeModeVariable = environment(MergeModeVariable);
var mergeMode = SubjectGroupMergeMode.Union;
if (!string.IsNullOrWhiteSpace(mergeModeVariable)
&& (!Enum.TryParse(mergeModeVariable, ignoreCase: true, out mergeMode) || !Enum.IsDefined(mergeMode)))
{
error = $"{MergeModeVariable} '{mergeModeVariable}' is not recognised. Set it to Union, TokenOnly or DirectoryOnly, or unset it to use Union.";
return false;
}
options = new ExplorerSampleOptions
{
Minimal = minimal,
ExplorerRegion = region ?? SampleIdentities.EastRegion,
SignInAs = string.Equals(signInAs, NoSignIn, StringComparison.OrdinalIgnoreCase) ? null : signInAs,
StartPeerPaused = peerPaused,
Entra = entra,
GroupMergeMode = mergeMode,
Ports = SamplePorts.Default.Offset(portOffset),
};
error = null;
return true;
}
// Half-configuring Entra is rejected, so a partial configuration never
// silently degrades to the static directory.
private static bool TryReadEntra(
Func<string, string?> environment,
out SampleEntraDirectory? entra,
[NotNullWhen(false)] out string? error)
{
var tenantId = environment(EntraTenantIdVariable);
var clientId = environment(EntraClientIdVariable);
var clientSecret = environment(EntraClientSecretVariable);
var set = (string.IsNullOrWhiteSpace(tenantId) ? 0 : 1)
+ (string.IsNullOrWhiteSpace(clientId) ? 0 : 1)
+ (string.IsNullOrWhiteSpace(clientSecret) ? 0 : 1);
entra = set == 3 ? new SampleEntraDirectory(tenantId!, clientId!, clientSecret!) : null;
if (set is > 0 and < 3)
{
error = $"Entra directory mode is half-configured. Set all of {EntraTenantIdVariable}, {EntraClientIdVariable} and {EntraClientSecretVariable} to back the Access directory with your Entra tenant, or unset all three to use the built-in static directory. See samples/Explorer/README.md.";
return false;
}
error = null;
return true;
}
}
PeerLink.cs
using Microsoft.AspNetCore.Http;
namespace Orleans.Lattice.Samples.Explorer;
/// <summary>
/// The link between the two regions, and the switch that pauses it so the
/// Replication area can show a link going <c>Lagging</c> and then <c>Stalled</c>.
/// </summary>
/// <remarks>
/// Each region's replication receiver runs behind <see cref="InvokeAsync"/>.
/// While the link is paused every cross-region replication call - live push,
/// snapshot bootstrap and saga control, in both directions - is refused with
/// <c>503 Service Unavailable</c>, which the sender's gRPC client reads as the
/// peer being unreachable. Nothing else on the endpoint is touched: the
/// Explorer's own facade calls keep working, so the console can watch the
/// backlog grow. Resuming lets the senders catch up from where they stopped.
/// </remarks>
internal sealed class PeerLink
{
/// <summary>
/// The path prefix every cross-region replication gRPC service shares
/// (<c>LatticeReplication</c>, <c>LatticeRemoteSnapshot</c> and
/// <c>LatticeSaga</c>). The replication <em>control and status</em> facades
/// the Explorer calls live under <c>/orleans.lattice.api.replication</c> and
/// are never matched.
/// </summary>
public const string ReplicationPathPrefix = "/orleans.lattice.replication.";
private readonly Lock _gate = new();
private volatile bool _paused;
/// <summary>Raised after the link is paused or resumed, with the new state.</summary>
public event Action<bool>? Changed;
/// <summary>Whether cross-region replication is currently refused.</summary>
public bool IsPaused => _paused;
/// <summary>Pauses the link, or resumes it when it is paused.</summary>
/// <returns>Whether the link is paused afterwards.</returns>
public bool Toggle()
{
bool paused;
lock (_gate)
{
paused = !_paused;
_paused = paused;
}
Changed?.Invoke(paused);
return paused;
}
/// <summary>Pauses the link; does nothing when it is already paused.</summary>
/// <returns>Whether this call paused it.</returns>
public bool Pause() => Set(paused: true);
/// <summary>Resumes the link; does nothing when it is not paused.</summary>
/// <returns>Whether this call resumed it.</returns>
public bool Resume() => Set(paused: false);
/// <summary>Whether <paramref name="path"/> is a cross-region replication call.</summary>
/// <param name="path">The request path.</param>
public static bool IsReplicationPath(PathString path) =>
path.HasValue && path.Value!.StartsWith(ReplicationPathPrefix, StringComparison.Ordinal);
/// <summary>
/// Refuses a cross-region replication call while the link is paused, and
/// passes every other request to <paramref name="next"/>.
/// </summary>
/// <param name="context">The request.</param>
/// <param name="next">The rest of the pipeline.</param>
public Task InvokeAsync(HttpContext context, RequestDelegate next)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(next);
if (_paused && IsReplicationPath(context.Request.Path))
{
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
return Task.CompletedTask;
}
return next(context);
}
private bool Set(bool paused)
{
lock (_gate)
{
if (_paused == paused)
{
return false;
}
_paused = paused;
}
Changed?.Invoke(paused);
return true;
}
}
ReplicationWriter.cs
using System.Text;
namespace Orleans.Lattice.Samples.Explorer;
/// <summary>
/// A small background writer that keeps the replication links busy, so the
/// Replication area shows live contact, backlog and health. Each tick it
/// overwrites one key per target, cycling through a fixed key set: the trees
/// stay the same size however long the sample runs.
/// </summary>
internal sealed class ReplicationWriter : IAsyncDisposable
{
private readonly IReadOnlyList<ReplicationWriterTarget> _targets;
private readonly TimeSpan _interval;
private readonly CancellationTokenSource _stop = new();
private Task? _loop;
private long _ticks;
/// <summary>Creates a writer; nothing is written until <see cref="Start"/> or <see cref="WriteOnceAsync"/>.</summary>
/// <param name="targets">The trees written, one key each per tick.</param>
/// <param name="interval">The time between ticks.</param>
public ReplicationWriter(IReadOnlyList<ReplicationWriterTarget> targets, TimeSpan interval)
{
ArgumentNullException.ThrowIfNull(targets);
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(interval, TimeSpan.Zero);
_targets = targets;
_interval = interval;
}
/// <summary>The number of ticks written so far.</summary>
public long Ticks => Interlocked.Read(ref _ticks);
/// <summary>The key a target writes on tick <paramref name="tick"/>: one of its <see cref="ReplicationWriterTarget.KeyCount"/> keys, in turn.</summary>
/// <param name="target">The target.</param>
/// <param name="tick">The zero-based tick.</param>
public static string KeyFor(ReplicationWriterTarget target, long tick)
{
ArgumentNullException.ThrowIfNull(target);
ArgumentOutOfRangeException.ThrowIfNegative(tick);
return $"{target.KeyPrefix}{tick % target.KeyCount:D3}";
}
/// <summary>Starts writing on a timer. Calling it again does nothing.</summary>
public void Start() => _loop ??= RunAsync(_stop.Token);
/// <summary>Writes one tick: one key per target.</summary>
/// <param name="cancellationToken">Cancels the tick.</param>
public async Task WriteOnceAsync(CancellationToken cancellationToken = default)
{
var tick = Interlocked.Increment(ref _ticks) - 1;
var value = Encoding.UTF8.GetBytes($"status-{tick}");
// A trusted co-hosted writer, like the seeding: system-origin bypasses
// the deny-by-default gate for this process's own writes.
using var _ = LatticeSystemOrigin.Enter();
for (var i = 0; i < _targets.Count; i++)
{
var target = _targets[i];
await target.Tree.SetAsync(KeyFor(target, tick), value, cancellationToken).ConfigureAwait(false);
}
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await _stop.CancelAsync().ConfigureAwait(false);
if (_loop is not null)
{
await _loop.ConfigureAwait(false);
}
_stop.Dispose();
}
private async Task RunAsync(CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(_interval);
try
{
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
{
try
{
await WriteOnceAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// A write that fails (a silo still starting or already
// stopping) is skipped; the next tick tries again.
}
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Stopped.
}
}
}
ReplicationWriterTarget.cs
namespace Orleans.Lattice.Samples.Explorer;
/// <summary>One tree the <see cref="ReplicationWriter"/> writes, and the fixed key set it cycles through.</summary>
/// <param name="Region">The region the tree is written in.</param>
/// <param name="Tree">The tree.</param>
/// <param name="KeyPrefix">The prefix of every key written.</param>
/// <param name="KeyCount">How many distinct keys are written, in turn; the tree never holds more.</param>
internal sealed record ReplicationWriterTarget(string Region, ILattice Tree, string KeyPrefix, int KeyCount)
{
/// <summary>The key set's size, which must be positive.</summary>
public int KeyCount { get; } = KeyCount > 0
? KeyCount
: throw new ArgumentOutOfRangeException(nameof(KeyCount), KeyCount, "A writer target needs at least one key.");
}
SampleBanner.cs
using Orleans.Lattice.Membership;
using Orleans.Lattice.Samples.Explorer.TaskBoard;
namespace Orleans.Lattice.Samples.Explorer;
/// <summary>The console output a started sample prints: every URL, every sample identity and how to drive the estate.</summary>
internal static class SampleBanner
{
/// <summary>Writes the banner for a started <paramref name="sample"/>.</summary>
/// <param name="writer">Where the banner goes.</param>
/// <param name="sample">The started sample.</param>
/// <param name="startup">How long the start took.</param>
public static void Write(TextWriter writer, ExplorerSample sample, TimeSpan startup)
{
ArgumentNullException.ThrowIfNull(writer);
ArgumentNullException.ThrowIfNull(sample);
var estate = sample.West is not null;
writer.WriteLine();
writer.WriteLine(estate
? "Orleans.Lattice Explorer sample - two regions, tenancy and replication in one process"
: "Orleans.Lattice Explorer sample - one region (--minimal)");
writer.WriteLine($"Started in {startup.TotalSeconds:0.0}s.");
writer.WriteLine();
writer.WriteLine("URLs");
writer.WriteLine($" Explorer console {sample.Console.Url} (connected to region '{sample.ConsoleRegion.Id}')");
foreach (var region in sample.Regions)
{
writer.WriteLine($" Region {region.Id,-11} gRPC {region.Plan.GrpcEndpoint} (silo port {region.Plan.SiloPort}, gateway port {region.Plan.GatewayPort})");
}
writer.WriteLine();
writer.WriteLine(sample.Console.SignInAs is { } signInAs
? $"Sample identities (any password signs in; the console signs in as '{signInAs}' automatically - {ExplorerSampleOptions.SignInAsSwitch} picks another)"
: $"Sample identities (any password signs in; the console starts signed out - use its Sign in dialog)");
writer.WriteLine($" {SampleIdentities.Administrator,-15} bootstrap administrator and platform operator: every area");
if (estate)
{
writer.WriteLine($" {SampleIdentities.AcmeAdmin,-15} tenant admin of '{SampleIdentities.AcmeTenant}': the tenant-scoped view at /t/{SampleIdentities.AcmeTenant}");
writer.WriteLine($" {SampleIdentities.GlobexAdmin,-15} tenant admin of '{SampleIdentities.GlobexTenant}': the tenant-scoped view at /t/{SampleIdentities.GlobexTenant}");
}
writer.WriteLine($" {SampleIdentities.Alice,-15} groups '{SampleIdentities.OperatorsGroup}' and '{SampleIdentities.TaskEditorsGroup}'");
writer.WriteLine($" {SampleIdentities.Bob,-15} group '{SampleIdentities.TaskViewersGroup}'");
writer.WriteLine($" {SampleIdentities.Carol,-15} group '{SampleIdentities.VisitorsGroup}'");
writer.WriteLine(sample.Options.Entra is null
? " Identity directory: static in-memory roster - the Access create form fails closed on any id not in it."
: " Identity directory: Microsoft Entra (Graph) - the Access picker and validated create use your tenant.");
writer.WriteLine(sample.Options.GroupMergeMode == SubjectGroupMergeMode.TokenOnly
? " Group-merge mode: TokenOnly - local membership is inert, so Access renders group editing disabled."
: $" Group-merge mode: {sample.Options.GroupMergeMode} - set LATTICE_MEMBERSHIP_MERGE_MODE=TokenOnly to see merge-mode gating.");
writer.WriteLine();
writer.WriteLine("Seeded");
foreach (var line in sample.SeedLog)
{
writer.WriteLine(" " + line);
}
writer.WriteLine();
writer.WriteLine($"The '{TaskBoardApp.Slug}' app is in Apps > Catalogue; see samples/Explorer/Apps/TaskBoard/README.md.");
writer.WriteLine("Telemetry is hidden: it needs a Prometheus-compatible metrics backend, which this sample does not run.");
if (estate)
{
writer.WriteLine($"A background writer updates '{SampleIdentities.FactoryFloorTree}' in both regions every {sample.Options.WriterInterval.TotalSeconds:0.#}s.");
writer.WriteLine(sample.PeerLink.IsPaused
? "The peer link is PAUSED (--peer-paused): links go Lagging, then Stalled. Press P to resume it."
: "Press P to pause the peer link (links go Lagging, then Stalled) and P again to resume it.");
writer.WriteLine($"Run with {ExplorerSampleOptions.ExplorerRegionSwitch} {SampleIdentities.WestRegion} to point the console at the west region, or {ExplorerSampleOptions.MinimalSwitch} for one region.");
}
writer.WriteLine("Press Ctrl+C to stop.");
}
}
SampleCircuitDiagnostics.cs
using Microsoft.AspNetCore.Components.Server;
using Microsoft.Extensions.Logging.Console;
namespace Orleans.Lattice.Samples.Explorer;
/// <summary>
/// Makes a console circuit fault diagnosable (issue #4011). The sample keeps its console
/// quiet - it clears every logging provider - so a circuit the framework terminated used
/// to leave nothing behind but the browser's "unhandled exception on the current circuit".
/// The console region now writes exactly one kind of record to the terminal: Blazor's own
/// Error record of an unhandled circuit exception, with its stack trace. In Development
/// the browser is also sent the detail (<see cref="CircuitOptions.DetailedErrors"/>).
/// </summary>
/// <remarks>
/// The record is the framework's, carrying the exception and the circuit id; the sample
/// adds no message of its own and logs no user input.
/// </remarks>
internal static class SampleCircuitDiagnostics
{
/// <summary>The category prefix Blazor Server logs circuit faults under.</summary>
public const string CircuitCategory = "Microsoft.AspNetCore.Components.Server.Circuits";
/// <summary>Adds the terminal log of circuit faults, and nothing else, to a region's logging.</summary>
/// <param name="logging">The console region's logging, with every provider already cleared.</param>
public static void ConfigureLogging(ILoggingBuilder logging)
{
ArgumentNullException.ThrowIfNull(logging);
logging.AddSimpleConsole(console => console.ColorBehavior = LoggerColorBehavior.Disabled);
logging.SetMinimumLevel(LogLevel.None);
logging.AddFilter(CircuitCategory, LogLevel.Error);
}
/// <summary>In Development, sends a circuit fault's detail to the browser as well.</summary>
/// <param name="services">The console region's services.</param>
/// <param name="environment">The console region's environment.</param>
public static void ConfigureCircuits(IServiceCollection services, IHostEnvironment environment)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(environment);
if (environment.IsDevelopment())
{
services.Configure<CircuitOptions>(circuit => circuit.DetailedErrors = true);
}
}
}
SampleConsolePlan.cs
namespace Orleans.Lattice.Samples.Explorer;
/// <summary>Where the Explorer console is served, which region's gRPC endpoint it dials and who it signs in as.</summary>
/// <param name="WebPort">The HTTP port the console is served on.</param>
/// <param name="Endpoint">The gRPC endpoint of the region the console connects to.</param>
/// <param name="ConfigPath">The console's persisted configuration file.</param>
/// <param name="SignInAs">The identity the console signs in as automatically, or <see langword="null"/> for none.</param>
internal sealed record SampleConsolePlan(int WebPort, Uri Endpoint, string ConfigPath, string? SignInAs = SampleIdentities.Administrator)
{
/// <summary>The console's address.</summary>
public Uri Url => new($"http://localhost:{WebPort}/");
}
Remaining files
This sample is too large to inline in full. The remaining 29 file(s) are in the repository:
- SampleEntraDirectory.cs
- SampleExplorerEnvironment.cs
- SampleIdentities.cs
- SamplePorts.cs
- SampleRegion.cs
- SampleRegionPeer.cs
- SampleRegionPlan.cs
- SampleSeeder.cs
- SampleSharedBackupSink.cs
- test/ConsoleCircuit.cs
- test/DemoBasicAuthenticatorTests.cs
- test/DirectorySpine.cs
- test/EstateSmokeTests.Apps.cs
- test/EstateSmokeTests.cs
- test/EstateSmokeTests.Tenancy.cs
- test/EstateSmokeTests.TenantSwitcher.cs
- test/Explorer.Tests.csproj
- test/ExplorerSampleOptionsTests.cs
- test/MinimalSmokeTests.cs
- test/PeerLinkTests.cs
- test/ReplicationWriterTests.cs
- test/SampleCircuitDiagnosticsTests.cs
- test/SampleExplorerEnvironmentTests.cs
- test/SamplePortsTests.cs
- test/SampleRegionPlanTests.cs
- test/SampleSeederTests.cs
- test/SampleSharedBackupSinkTests.cs
- test/SampleTestHost.cs
- test/TenancyRegionsSmokeTests.cs