From 8bb6aef9d48cc1ea702284ab1c9f8b61f8998616 Mon Sep 17 00:00:00 2001 From: Gabriel Ramos Date: Wed, 26 Aug 2026 16:39:50 -0300 Subject: [PATCH 1/5] fixes for portal extension zones --- .../PortalExtensionReactApp/package.json | 1 + cli/cli/Services/ProjectService.cs | 10 +++--- .../Services/Web/WebLocalRegistryService.cs | 31 +++++++++++++------ 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/cli/beamable.templates/templates/PortalExtensionReactApp/package.json b/cli/beamable.templates/templates/PortalExtensionReactApp/package.json index 8b2bf3d4a2..5bacab3d76 100644 --- a/cli/beamable.templates/templates/PortalExtensionReactApp/package.json +++ b/cli/beamable.templates/templates/PortalExtensionReactApp/package.json @@ -6,6 +6,7 @@ "beamable": { "version": "1.0.0", "portalExtension": true, + "serviceScope": "realm", "microserviceDependencies": [], "mounts": [ { diff --git a/cli/cli/Services/ProjectService.cs b/cli/cli/Services/ProjectService.cs index 9451430980..5fc32f12de 100644 --- a/cli/cli/Services/ProjectService.cs +++ b/cli/cli/Services/ProjectService.cs @@ -293,11 +293,13 @@ public async Task CreateNewPortalExtension(NewPortalExtensionCom root[Beamable.Common.Constants.Features.PortalExtension.EXTENSION_NAME_PROPERTY_NAME] = JToken.FromObject(args.ProjectName.Value); - // A zone extension marks its backing service as zone-scoped via beamable.serviceScope; the run - // path (BeamoLocalSystem_PortalExtension) reads this and boots the service as a ZoneMicroservice. - if (args.IsZone && root["beamable"] is JObject beamable) + // Stamp the extension's scope on beamable.serviceScope. A zone extension marks its backing + // service as zone-scoped (the run path BeamoLocalSystem_PortalExtension reads this and boots + // the service as a ZoneMicroservice); a realm extension records "realm" explicitly. The Portal + // reads the same field to place the extension's hub at org (zone) vs realm level. + if (root["beamable"] is JObject beamable) { - beamable["serviceScope"] = "zone"; + beamable["serviceScope"] = args.IsZone ? "zone" : "realm"; } File.WriteAllText(packagePath, root.ToString(Newtonsoft.Json.Formatting.Indented)); diff --git a/cli/cli/Services/Web/WebLocalRegistryService.cs b/cli/cli/Services/Web/WebLocalRegistryService.cs index 7f3032026d..f6f6c567be 100644 --- a/cli/cli/Services/Web/WebLocalRegistryService.cs +++ b/cli/cli/Services/Web/WebLocalRegistryService.cs @@ -36,6 +36,9 @@ public class WebLocalRegistryService public const string DefaultRegistry = "http://localhost:4873"; public const string DefaultCdn = "http://localhost:4874"; + /// The public npm registry a released @beamable pin must always resolve against. + public const string PublicRegistry = "https://registry.npmjs.org/"; + /// Directory inside the product repo holding the registry's docker-compose file. public const string LocaldevDirName = "portal-localdev"; @@ -251,23 +254,31 @@ public static string ReadPinnedVersion(string packageJsonPath, string package) } /// - /// The npm arguments needed to install a project whose @beamable/portal-toolkit pin is a local - /// developer build — i.e. --registry <local> plus its auth token. Returns an empty string - /// for every other project, so a normal install is completely untouched. - /// - /// - /// Required, not an optimisation: a local-dev version exists only on the local registry, so a plain - /// npm install resolves it against npmjs, 404s, and fails the build. Routing the *whole* install - /// at the local registry is correct because it proxies everything else to npmjs (see + /// The npm arguments needed to install a project's @beamable/portal-toolkit pin, chosen from the + /// pinned version: + /// + /// + /// A local developer build (0.0.123-*) exists only on the local registry, so the whole install is + /// routed there with --registry <local> plus its auth token. Required, not an optimisation: + /// a plain npm install would resolve it against npmjs, 404, and fail the build. Routing everything + /// at the local registry is fine because it proxies the rest to npmjs (see /// portal-localdev/verdaccio/config.yml). - /// + /// + /// + /// A released build pins the @beamable scope at the public npm registry with + /// --@beamable:registry=<public>. A user's machine may have a corporate proxy or a private + /// registry configured for @beamable in their npmrc that has never heard of the package, so pinning + /// the scope to npmjs keeps a normal install from failing there. + /// + /// /// public static string InstallArgsFor(string projectDir, string registryUrl = DefaultRegistry) { var pinned = ReadPinnedVersion(Path.Combine(projectDir, "package.json"), ToolkitPackage); if (!IsLocalDevVersion(pinned)) { - return string.Empty; + Log.Verbose($"[{projectDir}] pins the released {ToolkitPackage}@{pinned}; forcing the @beamable scope at [{PublicRegistry}]"); + return $" --@beamable:registry={PublicRegistry}"; } Log.Verbose($"[{projectDir}] pins the local build {ToolkitPackage}@{pinned}; installing from [{registryUrl}]"); From 79a1e6c0acda966fd0eea56366a81324c2d1e8d1 Mon Sep 17 00:00:00 2001 From: Gabriel Ramos Date: Fri, 4 Sep 2026 14:35:51 -0300 Subject: [PATCH 2/5] local stack maven setup failure prevention --- .../LocalStack/LocalStackUpCommand.cs | 268 ++++++++++++++++++ .../Services/LocalStack/LocalStackTemplate.cs | 4 +- cli/tests/LocalStackDiscoveryTests.cs | 84 ++++++ 3 files changed, 354 insertions(+), 2 deletions(-) diff --git a/cli/cli/Commands/LocalStack/LocalStackUpCommand.cs b/cli/cli/Commands/LocalStack/LocalStackUpCommand.cs index 95ae8d9f6c..79445f22ee 100644 --- a/cli/cli/Commands/LocalStack/LocalStackUpCommand.cs +++ b/cli/cli/Commands/LocalStack/LocalStackUpCommand.cs @@ -4,6 +4,7 @@ using System.CommandLine; using System.Diagnostics; using System.Reflection; +using System.Text; using System.Text.RegularExpressions; using ServiceLogs = Beamable.Common.Constants.Features.Services.Logs; @@ -281,6 +282,15 @@ public override async Task Handle(LocalStackUpCommandArgs args) // runs `mvn install -U` and refills ~/.m2, escaping the trap. EnsureScalaCoreInLocalMavenRepo(config, autoBuild); + // Second half of the offline-classpath trap. EnsureScalaCoreInLocalMavenRepo above guarantees `core` is in + // ~/.m2, but the per-service launcher's `mvn -o dependency:build-classpath` ALSO needs Maven to resolve the + // `dependency` plugin PREFIX offline — which requires the maven-dependency-plugin's plugin-group metadata + // (org/apache/maven/plugins/maven-metadata-*.xml) to be cached. On a machine where that was never cached (or + // was cached as a miss), the offline resolve dies with "No plugin found for prefix 'dependency'" even with + // core present — and `--build` does NOT fix it, because the reactor resolves every plugin by explicit + // version and never touches the prefix metadata. Probe it, and prime it online once if it is missing. + EnsureMavenDependencyPluginResolvableOffline(config, args.build); + // Say WHY a build is being run that was not asked for. "Building the Scala reactor" out of nowhere on a // plain `up` is confusing unless it names the module that has never been compiled. foreach (var step in autoBuild) @@ -1233,6 +1243,264 @@ public static LocalStackStep ShouldForceBuildScalaForMissingCore(LocalStackConfi return buildScala; } + /// + /// Inputs for the offline maven-dependency-plugin probe, resolved from the manifest by + /// . Public so the decision (should we probe at all, and against which + /// module) can be unit-tested without launching Maven. + /// + public class MavenDependencyProbePlan + { + /// True when there is a Scala launch step and enough resolved inputs to run the probe. + public bool shouldProbe; + + /// The mvn command the probe runs — the toolchain's pinned path, or bare mvn. + public string mvnCommand; + + /// The BeamableBackend working directory the probe runs in. + public string scalaDir; + + /// The module the probe targets (e.g. tools/account), mirroring the launcher's -pl. + public string probeModule; + + /// When is false, the reason — for tests and tracing. + public string skipReason; + } + + /// + /// Pure decision for : reads the manifest and decides + /// whether the offline maven-dependency-plugin probe should run, and against which module. No filesystem check + /// (beyond string validity) and no process launch, so it is unit-testable. The probe is only relevant when a + /// scala: * service will launch — those are the steps whose launcher runs mvn -o + /// dependency:build-classpath. The module is derived from the launch step's name (scala: account → + /// tools/account), exactly the -pl tools/$SVC the launcher uses. + /// + public static MavenDependencyProbePlan PlanDependencyPluginProbe(LocalStackConfig config) + { + var plan = new MavenDependencyProbePlan(); + + if (config?.steps == null) + { + plan.skipReason = "no manifest steps"; + return plan; + } + + var scalaLaunch = config.steps.FirstOrDefault(s => + s != null && s.enabled && !string.IsNullOrEmpty(s.name) + && s.name.StartsWith("scala: ", StringComparison.OrdinalIgnoreCase)); + if (scalaLaunch == null) + { + plan.skipReason = "no scala launch step"; + return plan; + } + + var svc = scalaLaunch.name.Substring("scala: ".Length).Trim(); + if (string.IsNullOrEmpty(svc)) + { + plan.skipReason = "scala launch step has no service name"; + return plan; + } + plan.probeModule = "tools/" + svc; + + var scalaDir = config.repos?.scalaDir; + if (string.IsNullOrWhiteSpace(scalaDir) + || scalaDir.Contains(LocalStackConfigIO.EditPlaceholder, StringComparison.Ordinal)) + { + plan.skipReason = "no BeamableBackend path on the manifest"; + return plan; + } + plan.scalaDir = scalaDir; + + var mvn = LocalStackConfigIO.Substitute("${maven}", config); + if (string.IsNullOrWhiteSpace(mvn)) + { + plan.skipReason = "no maven command resolved"; + return plan; + } + plan.mvnCommand = mvn; + + plan.shouldProbe = true; + return plan; + } + + /// + /// Ensures the per-service launcher's offline mvn -o dependency:build-classpath can resolve the + /// dependency plugin prefix. Probes it offline; if that fails, primes the plugin once ONLINE (which + /// downloads the plugin-group metadata and clears any cached "not found" miss), re-probes, and — if it still + /// cannot be made to work (no network, a blocked Maven repo) — throws with the exact manual fix. See the call + /// site in for why --build alone does not cover this. + /// + private static void EnsureMavenDependencyPluginResolvableOffline(LocalStackConfig config, bool forceBuild) + { + var plan = PlanDependencyPluginProbe(config); + if (!plan.shouldProbe) return; + + // A manifest can name a BeamableBackend path that isn't checked out on this machine — nothing to run mvn in. + if (!Directory.Exists(plan.scalaDir)) return; + + // Fast path: a previous run already wrote a non-empty classpath cache, which means the offline resolve + // demonstrably works here — skip the maven probe so a healthy `up` pays nothing. `--build` wipes that cache + // later in Handle, so always probe on a build: it is exactly the run where priming matters. + if (!forceBuild && HasNonEmptyScalaClasspathCache()) return; + + var offline = RunMavenDependencyGoalProbe(config, plan, online: false); + if (!offline.started) return; // couldn't even launch mvn — let the normal launch path surface that + if (offline.exitCode == 0) return; // resolves offline — healthy, nothing to do + + Log.Information( + "[maven] the Scala services resolve their classpath with an offline 'mvn dependency:build-classpath', " + + "but Maven can't resolve the 'dependency' plugin prefix offline yet (its plugin metadata isn't cached). " + + $"Priming it once online (mvn -U dependency:help in {plan.scalaDir}) so the services can launch — this " + + "does not rebuild anything."); + + var online = RunMavenDependencyGoalProbe(config, plan, online: true); + if (online.started && online.exitCode == 0) + { + var reprobe = RunMavenDependencyGoalProbe(config, plan, online: false); + if (reprobe.started && reprobe.exitCode == 0) + { + Log.Information("[maven] maven-dependency-plugin is now resolvable offline — the Scala classpath " + + "caches will regenerate on launch."); + return; + } + } + + throw new CliException(BuildDependencyPluginRemediation(plan)); + } + + /// Outcome of one dependency:help probe: whether mvn started at all, its exit code, and its + /// combined output (for the remediation message). + private readonly struct MvnProbeOutcome + { + public MvnProbeOutcome(bool started, int exitCode, string output) + { + this.started = started; + this.exitCode = exitCode; + this.output = output; + } + + public readonly bool started; + public readonly int exitCode; + public readonly string output; + } + + /// + /// Runs dependency:help — the cheapest goal that still forces prefix resolution of + /// maven-dependency-plugin, i.e. the exact resolution that fails as "No plugin found for prefix 'dependency'". + /// Offline (-o) reproduces the launcher's own resolve; online adds -U to invalidate a cached + /// "not found" miss and fetch the plugin-group metadata that makes subsequent offline resolves succeed. Runs + /// under the same PATH/JAVA_HOME as the reactor so it hits the same ~/.m2 and JDK. + /// + private static MvnProbeOutcome RunMavenDependencyGoalProbe(LocalStackConfig config, MavenDependencyProbePlan plan, bool online) + { + var mvnArgs = new List(); + if (!online) mvnArgs.Add("-o"); + mvnArgs.Add("-q"); + if (online) mvnArgs.Add("-U"); + mvnArgs.Add("-pl"); + mvnArgs.Add(plan.probeModule); + mvnArgs.Add("dependency:help"); + + var psi = new ProcessStartInfo + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = plan.scalaDir, + }; + + if (OperatingSystem.IsWindows()) + { + // mvn is a .cmd batch file — Process.Start can't run it directly, so go through cmd.exe. The doubled + // quotes are cmd's rule for "everything after /s /c is one command that carries its own quotes". + psi.FileName = "cmd.exe"; + psi.Arguments = $"/s /c \"\"{plan.mvnCommand}\" {string.Join(" ", mvnArgs)}\""; + } + else + { + psi.FileName = plan.mvnCommand; + foreach (var a in mvnArgs) psi.ArgumentList.Add(a); + } + + ApplyToolchainEnvironment(psi, config); + + var output = new StringBuilder(); + try + { + var proc = Process.Start(psi); + if (proc == null) return new MvnProbeOutcome(false, -1, string.Empty); + + proc.OutputDataReceived += (_, e) => { if (e.Data != null) lock (output) output.AppendLine(e.Data); }; + proc.ErrorDataReceived += (_, e) => { if (e.Data != null) lock (output) output.AppendLine(e.Data); }; + proc.BeginOutputReadLine(); + proc.BeginErrorReadLine(); + + // The offline resolve is fast; the online prime may download plugin-group metadata. Bound both so a + // wedged mvn can't hang `up` forever. + var timeoutMs = (int)(online ? TimeSpan.FromMinutes(5) : TimeSpan.FromMinutes(2)).TotalMilliseconds; + if (!proc.WaitForExit(timeoutMs)) + { + try { proc.Kill(entireProcessTree: true); } catch { /* ignore */ } + return new MvnProbeOutcome(false, -1, output.ToString()); + } + + proc.WaitForExit(); // let the async output readers flush + return new MvnProbeOutcome(true, proc.ExitCode, output.ToString()); + } + catch (Exception) + { + // mvn not found / not runnable — "couldn't probe", not the trap this method detects. + return new MvnProbeOutcome(false, -1, output.ToString()); + } + } + + /// + /// True when a previous run left a non-empty cp-*.txt in the shared Scala classpath cache + /// (<temp>/beam-scala-cp) — proof the offline resolve works on this machine, so the probe can be + /// skipped. Mirrors the temp location the launcher scripts write to and that up wipes on --build. + /// + private static bool HasNonEmptyScalaClasspathCache() + { + try + { + var dir = Path.Combine(Path.GetTempPath(), "beam-scala-cp"); + if (!Directory.Exists(dir)) return false; + foreach (var f in Directory.EnumerateFiles(dir, "cp-*.txt")) + { + try { if (new FileInfo(f).Length > 0) return true; } catch { /* unreadable — ignore */ } + } + } + catch { /* ignore */ } + return false; + } + + /// + /// The fail-fast message when the offline resolve is broken and the automatic online prime could not fix it. + /// Names the real cause, says plainly that --build does not fix it, and gives the exact online command + /// plus the cached-miss cleanup — the same guidance the per-service launcher's empty-cache guard prints. + /// + private static string BuildDependencyPluginRemediation(MavenDependencyProbePlan plan) + { + var m2Plugin = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? "~", + ".m2", "repository", "org", "apache", "maven", "plugins", "maven-dependency-plugin"); + var mvnDisplay = plan.mvnCommand?.Contains(' ') == true ? $"\"{plan.mvnCommand}\"" : plan.mvnCommand; + + return + "The Scala services can't start: Maven can't resolve the 'dependency' plugin prefix offline, so each " + + "service's classpath step ('mvn -o dependency:build-classpath') fails with \"No plugin found for prefix " + + "'dependency'\" — even though com.kickstand:core is present in ~/.m2.\n\n" + + "'beam local up --build' does NOT fix this: the reactor resolves plugins by version, never by prefix, so " + + "it never caches the plugin-prefix metadata this needs. Beam tried to prime it online automatically and " + + "could not (usually no network, or a blocked Maven repo).\n\n" + + "Fix it by priming the plugin once, online, then re-run 'beam local up':\n" + + $" cd \"{plan.scalaDir}\"\n" + + $" {mvnDisplay} -U dependency:build-classpath -pl {plan.probeModule}\n\n" + + "If it still fails, delete the cached-miss markers and retry:\n" + + $" (Windows) del /s \"{m2Plugin}\\*.lastUpdated\"\n" + + $" (macOS/Linux) rm {m2Plugin.Replace('\\', '/')}/*/*.lastUpdated"; + } + /// /// if a non-Docker process is squatting 127.0.0.1:27015 it shadows the docker-published mongo_master, so /// every host process' localhost:27015 Mongo connection times out and the gateway + scala services diff --git a/cli/cli/Services/LocalStack/LocalStackTemplate.cs b/cli/cli/Services/LocalStack/LocalStackTemplate.cs index c7ac369738..c341dfcac7 100644 --- a/cli/cli/Services/LocalStack/LocalStackTemplate.cs +++ b/cli/cli/Services/LocalStack/LocalStackTemplate.cs @@ -939,7 +939,7 @@ private static string ScalaLaunchShell(string svc, string mainClass, string jvmA "{ [ -s \"$CPF\" ] && [ \"$CPF\" -nt core/pom.xml ]; } || JAVA_HOME=\"$JHOME\" \"" + MavenToken + "\" -q -o -pl tools/$SVC -am dependency:build-classpath -Dmdep.outputFile=\"$CPF\" || true; " + // An empty cache means the mvn above failed. Launching anyway starts a JVM with only the module's own // classes on the classpath, which dies deep in classloading — say what actually went wrong instead. - "[ -s \"$CPF\" ] || { echo \"beam: classpath cache $CPF is empty — offline 'mvn dependency:build-classpath' failed for tools/$SVC. The usual cause is that com.kickstand:core is not in your local Maven repository (~/.m2/repository/com/kickstand/core/1.0-SNAPSHOT/). Fix: (1) run 'beam local up --build' (the reactor uses -U and 'mvn install', which invalidates any cached miss and writes core to ~/.m2). If that ALSO fails, (2) delete ~/.m2/repository/com/kickstand/ (locally-built artifacts only, safe to remove) and re-run 'beam local up --build'.\" >&2; exit 1; }; " + + "[ -s \"$CPF\" ] || { echo \"beam: classpath cache $CPF is empty — offline 'mvn dependency:build-classpath' failed for tools/$SVC. The usual cause is that com.kickstand:core is not in your local Maven repository (~/.m2/repository/com/kickstand/core/1.0-SNAPSHOT/). Fix: (1) run 'beam local up --build' (the reactor uses -U and 'mvn install', which invalidates any cached miss and writes core to ~/.m2). If that ALSO fails, (2) delete ~/.m2/repository/com/kickstand/ (locally-built artifacts only, safe to remove) and re-run 'beam local up --build'. Second cause: Maven cannot resolve the 'dependency' plugin prefix OFFLINE (its plugin metadata was never cached) — '--build' does NOT fix that. Fix: run once online from this repo: mvn -U dependency:build-classpath -pl tools/$SVC ; then re-run 'beam local up'. If it still fails, delete ~/.m2/repository/org/apache/maven/plugins/maven-dependency-plugin/*/*.lastUpdated and retry.\" >&2; exit 1; }; " + "CP=\"tools/$SVC/target/classes:core/target/classes:$JAR:$(cat \"$CPF\")\"; " + // $JVM_ARGS unquoted on purpose: it must word-split into separate flags. "exec \"$JHOME/bin/java\" $JVM_ARGS -cp \"$CP\" \"$MAIN\""; @@ -1004,7 +1004,7 @@ private static string ScalaLaunchPowerShell(string svc, string mainClass, string "$deps = ''", "if (Test-Path $cpf) { $raw = Get-Content $cpf -Raw; if ($raw) { $deps = $raw.Trim() } }", "if (-not $deps) {", - " Write-Host \"beam: classpath cache $cpf is empty - offline 'mvn dependency:build-classpath' failed for tools/$svc. The usual cause is that com.kickstand:core is not in your local Maven repository ($env:USERPROFILE\\.m2\\repository\\com\\kickstand\\core\\1.0-SNAPSHOT\\). Fix: (1) run 'beam local up --build' (the reactor uses -U and 'mvn install', which invalidates any cached miss and writes core to ~/.m2). If that ALSO fails, (2) delete $env:USERPROFILE\\.m2\\repository\\com\\kickstand\\ (locally-built artifacts only, safe to remove) and re-run 'beam local up --build'.\"", + " Write-Host \"beam: classpath cache $cpf is empty - offline 'mvn dependency:build-classpath' failed for tools/$svc. The usual cause is that com.kickstand:core is not in your local Maven repository ($env:USERPROFILE\\.m2\\repository\\com\\kickstand\\core\\1.0-SNAPSHOT\\). Fix: (1) run 'beam local up --build' (the reactor uses -U and 'mvn install', which invalidates any cached miss and writes core to ~/.m2). If that ALSO fails, (2) delete $env:USERPROFILE\\.m2\\repository\\com\\kickstand\\ (locally-built artifacts only, safe to remove) and re-run 'beam local up --build'. Second cause: Maven cannot resolve the 'dependency' plugin prefix OFFLINE (its plugin metadata was never cached) - '--build' does NOT fix that. Fix: run once online from this repo: mvn -U dependency:build-classpath -pl tools/$svc ; then re-run 'beam local up'. If it still fails, delete $env:USERPROFILE\\.m2\\repository\\org\\apache\\maven\\plugins\\maven-dependency-plugin\\*\\*.lastUpdated and retry.\"", " exit 1 }", "$cp = \"tools/$svc/target/classes;core/target/classes;$jar;\" + $deps", "& \"$jhome\\bin\\java.exe\" @jvmArgs -cp $cp $main", diff --git a/cli/tests/LocalStackDiscoveryTests.cs b/cli/tests/LocalStackDiscoveryTests.cs index f1ee96c9e6..5c072fc7a5 100644 --- a/cli/tests/LocalStackDiscoveryTests.cs +++ b/cli/tests/LocalStackDiscoveryTests.cs @@ -713,6 +713,90 @@ public void MissingCoreInM2_LeftAlone_WhenNoScalaLaunchStep() Assert.That(result, Is.Null); } + // ---------------------------------------------------------------------------------- + // Offline maven-dependency-plugin probe (the second half of the negative-cache trap: + // core is present, but `mvn -o dependency:build-classpath` can't resolve the `dependency` + // plugin prefix offline, so every scala service fails and --build never fixes it). + // ---------------------------------------------------------------------------------- + + private static LocalStackConfig ProbeConfig(string scalaDir, string mavenHome, params (string name, bool enabled)[] steps) => + new LocalStackConfig + { + repos = new LocalStackRepos { scalaDir = scalaDir }, + toolchain = mavenHome == null ? null : new LocalStackToolchain { maven = mavenHome }, + steps = steps.Select(s => new LocalStackStep { name = s.name, enabled = s.enabled }).ToList() + }; + + [Test] + public void PlanDependencyPluginProbe_DerivesModuleFromScalaLaunchStep() + { + // A scala service will launch, so the launcher's offline classpath resolve will run — the probe is relevant. + // The module it targets mirrors the launcher's `-pl tools/$SVC`, derived from the step name after "scala: ". + var config = ProbeConfig(Path.GetTempPath(), Path.Combine(Path.GetTempPath(), "mvn-home"), + ("build: scala", true), ("scala: account", true), ("scala: dbflake", true)); + + var plan = LocalStackUpCommand.PlanDependencyPluginProbe(config); + + Assert.That(plan.shouldProbe, Is.True, plan.skipReason); + Assert.That(plan.probeModule, Is.EqualTo("tools/account"), "first scala launch step wins"); + Assert.That(plan.scalaDir, Is.EqualTo(Path.GetTempPath())); + Assert.That(plan.mvnCommand, Does.Contain("mvn"), "resolves the toolchain's mvn from ${maven}"); + } + + [Test] + public void PlanDependencyPluginProbe_SkipsWhenNoScalaLaunchStep() + { + // No scala service means no launcher, so the offline classpath resolve never runs — don't pay for a probe. + var config = ProbeConfig(Path.GetTempPath(), Path.Combine(Path.GetTempPath(), "mvn-home"), + ("build: scala", true), ("docker: api deps + caddy", true)); + + var plan = LocalStackUpCommand.PlanDependencyPluginProbe(config); + + Assert.That(plan.shouldProbe, Is.False); + Assert.That(plan.skipReason, Is.EqualTo("no scala launch step")); + } + + [Test] + public void PlanDependencyPluginProbe_SkipsWhenScalaStepIsDisabled() + { + // A disabled scala step won't launch, so it must not drag the probe in. + var config = ProbeConfig(Path.GetTempPath(), Path.Combine(Path.GetTempPath(), "mvn-home"), + ("scala: account", false)); + + var plan = LocalStackUpCommand.PlanDependencyPluginProbe(config); + + Assert.That(plan.shouldProbe, Is.False); + Assert.That(plan.skipReason, Is.EqualTo("no scala launch step")); + } + + [Test] + public void PlanDependencyPluginProbe_SkipsWhenScalaDirIsAPlaceholder() + { + // An un-edited manifest carries the EditPlaceholder for repo paths; there's no real dir to run mvn in. + var config = ProbeConfig("<" + "EDIT-ME" + ">", Path.Combine(Path.GetTempPath(), "mvn-home"), + ("scala: account", true)); + // Force the actual placeholder token in case the constant differs from the guess above. + config.repos.scalaDir = LocalStackConfigIO.EditPlaceholder; + + var plan = LocalStackUpCommand.PlanDependencyPluginProbe(config); + + Assert.That(plan.shouldProbe, Is.False); + Assert.That(plan.skipReason, Is.EqualTo("no BeamableBackend path on the manifest")); + } + + [Test] + public void PlanDependencyPluginProbe_ResolvesBareMvn_WithoutAToolchain() + { + // No toolchain (never ran `beam local setup`): ${maven} falls back to the bare command, which is still a + // valid thing to probe with — it resolves via PATH just like the reactor step would. + var config = ProbeConfig(Path.GetTempPath(), mavenHome: null, ("scala: account", true)); + + var plan = LocalStackUpCommand.PlanDependencyPluginProbe(config); + + Assert.That(plan.shouldProbe, Is.True, plan.skipReason); + Assert.That(plan.mvnCommand, Does.Contain("mvn")); + } + // ---------------------------------------------------------------------------------- // In-memory manifest migration for pre-Maven-cache-fix arguments // ---------------------------------------------------------------------------------- From b729c505559b5619dd445a5c78effef1c8f4b6ad Mon Sep 17 00:00:00 2001 From: Gabriel Ramos Date: Fri, 4 Sep 2026 14:36:06 -0300 Subject: [PATCH 3/5] new zone portal extension fixes --- .../PortalExtensionReactAppZone/package.json | 2 +- .../Project/NewPortalExtensionCommand.cs | 21 +++++++++- .../beam-create-portal-extension.md.scriban | 14 +++++++ .../PortalExtensionCommandTests.cs | 40 +++++++++++++++++++ 4 files changed, 75 insertions(+), 2 deletions(-) diff --git a/cli/beamable.templates/templates/PortalExtensionReactAppZone/package.json b/cli/beamable.templates/templates/PortalExtensionReactAppZone/package.json index b6b665584c..6ea73d4b93 100644 --- a/cli/beamable.templates/templates/PortalExtensionReactAppZone/package.json +++ b/cli/beamable.templates/templates/PortalExtensionReactAppZone/package.json @@ -10,7 +10,7 @@ "microserviceDependencies": [], "mounts": [ { - "page": "page-path", + "page": ":cid/page-path", "selector": "#extension-page", "args": {}, "navLabel": "", diff --git a/cli/cli/Commands/Project/NewPortalExtensionCommand.cs b/cli/cli/Commands/Project/NewPortalExtensionCommand.cs index 2d50197b69..5ed5e7bce3 100644 --- a/cli/cli/Commands/Project/NewPortalExtensionCommand.cs +++ b/cli/cli/Commands/Project/NewPortalExtensionCommand.cs @@ -60,7 +60,7 @@ public override void Configure() AddOption(new Option( aliases: new string[] { "--mount-page" }, - description: "The portal page to mount on. For page extensions use routePrefix + your custom route; for component extensions use the page path. Run 'portal extension list-extension-options' to see all valid values"), + description: "The portal page to mount on. For page extensions use routePrefix + your custom route; for component extensions use the page path. A --zone full-page extension is auto-prefixed with ':cid/' (it renders at org level). Run 'portal extension list-extension-options' to see all valid values"), binder: (args, i) => args.mountPage = i); AddOption(new Option( @@ -146,6 +146,19 @@ private static Dictionary ParseFilterOverrides(string[] filters) return result; } + // A zone full-page extension renders at the org level (/:customerId/*), so its stored + // `page` must begin with a `:cid` param segment — the Portal's org-level matcher + // (OrgExtensionPage) matches the whole `/` path against it, while the sidebar + // nav/hub derivation strips the leading param. Realm extensions mount under the realm + // route and need no such prefix. Prepend `:cid/` unless the author already supplied a + // leading param segment (any `:name`); tolerate a leading slash. + internal static string EnsureZonePagePrefix(string page) + { + var trimmed = (page ?? string.Empty).TrimStart('/'); + var firstSeg = trimmed.Split('/')[0]; + return firstSeg.StartsWith(":") ? trimmed : $":cid/{trimmed}"; + } + public override async Task Handle(NewPortalExtensionCommandArgs args) { // Validate required arg pairs before any expensive I/O so errors surface immediately @@ -195,6 +208,12 @@ public override async Task Handle(NewPortalExtensionCommandArgs args) if (resolvedSelector.type == "page") { + // A zone extension lives at the org level, so its full-page mount needs the + // `:cid` prefix the Portal's org matcher expects. Do this before persisting the + // page below (and after selector resolution so component mounts are untouched). + if (args.IsZone) + args.mountPage = EnsureZonePagePrefix(args.mountPage); + // Full-page (hub) extensions need a nav group and a display label. The hub hierarchy comes // from the page path itself (e.g. "cars" vs "cars/ferrari"); the nav group is a separate way // to organize extensions within a hub, and is required for page extensions. diff --git a/cli/cli/Docs/SkillTemplates/beam-create-portal-extension.md.scriban b/cli/cli/Docs/SkillTemplates/beam-create-portal-extension.md.scriban index dedf086ea7..169f0cc8ff 100644 --- a/cli/cli/Docs/SkillTemplates/beam-create-portal-extension.md.scriban +++ b/cli/cli/Docs/SkillTemplates/beam-create-portal-extension.md.scriban @@ -70,6 +70,19 @@ substitute a real hub name: replaced by your hub path, not literally prepended.) For full-page extensions the `--mount-selector` is always auto-assigned from the page slot, so you never pass it. +#### Zone (`--zone`) extensions render at the org level + +A `--zone` extension is scoped to `cid.zid` and lives **above realms**, so its hub and +pages render at the **org level** (`/:customerId/*`) rather than inside a realm. For the +Portal's org-level matcher to resolve them, the stored `page` must begin with a `:cid` +param segment — e.g. `:cid/dashboards` (hub) and `:cid/dashboards/overview` (page). + +**You do not write the `:cid/` yourself.** Pass the hub path exactly as for a realm +extension (`--mount-page "dashboards"` or `"dashboards/overview"`); when `--zone` is set, +the CLI prepends `:cid/` automatically. Only if you author the manifest's `page` by hand +must you include it — a zone `page` missing the `:cid/` prefix makes the hub appear in the +picker while its pages render *"No extension is registered for this page."* + ### 2. Create the extension For a **page extension** (new navigation page): @@ -147,6 +160,7 @@ Read the generated TypeScript files in the extension's `node_modules` to discove - **Always pass `-q` (quiet mode)** when executing from MCP to avoid interactive prompts that will hang. - **Page extensions need `--mount-group` and `--mount-label`** for the sidebar entry. Component extensions do not use these. - **Mount page format differs by type**: page extensions use `routePrefix + custom-route`, component extensions use the exact `path` value. +- **Zone extensions render at the org level** and their mount `page` must start with `:cid/`. With `--zone` the CLI prepends this automatically — pass the bare hub path. If you edit the manifest by hand, a missing `:cid/` makes the hub show in the picker but its pages render "No extension is registered for this page." - **In React, import components from `@beamable/portal-toolkit/react`** — do NOT use `` web component tags directly in TSX. Use the typed React forwarders instead. - **Use Beamable components, not raw HTML.** Portal extensions render inside the Portal UI. Read the generated files in `node_modules/@beamable/portal-toolkit/src/generated/` for available components. - **The `--service-directory` for portal extensions is `extensions/`**, not `services/`. diff --git a/cli/tests/PortalExtensionTests/PortalExtensionCommandTests.cs b/cli/tests/PortalExtensionTests/PortalExtensionCommandTests.cs index fac240e925..a0568b4ea6 100644 --- a/cli/tests/PortalExtensionTests/PortalExtensionCommandTests.cs +++ b/cli/tests/PortalExtensionTests/PortalExtensionCommandTests.cs @@ -266,6 +266,46 @@ public void NewPortalExtension_ZoneExtension_ScaffoldsZoneTemplate() "the zone template must register via the zone-scoped API"); } + [Test] + public void NewPortalExtension_ZoneExtension_PrefixesPageWithCid() + { + InitWorkspace(); + SetupBeamoServiceMock(); + MockRemotePortalConfig(); + + Run("project", "new", "portal-extension", "TestZonePage", "--quiet", + "--mount-page", "my-zone-page", + "--mount-group", "TestGroup", + "--mount-label", "TestLabel", + "--template", "react", + "--zone"); + + var packageJson = BFile.ReadAllText("extensions/TestZonePage/package.json"); + Assert.That(packageJson, Does.Contain("\":cid/my-zone-page\""), + "a zone full-page extension renders at org level, so its page must gain a leading :cid segment"); + } + + [Test] + public void NewPortalExtension_ZoneExtension_DoesNotDoublePrefixExplicitCid() + { + InitWorkspace(); + SetupBeamoServiceMock(); + MockRemotePortalConfig(); + + Run("project", "new", "portal-extension", "TestZonePreprefixed", "--quiet", + "--mount-page", ":cid/my-zone-page", + "--mount-group", "TestGroup", + "--mount-label", "TestLabel", + "--template", "react", + "--zone"); + + var packageJson = BFile.ReadAllText("extensions/TestZonePreprefixed/package.json"); + Assert.That(packageJson, Does.Contain("\":cid/my-zone-page\""), + "an author-supplied :cid prefix must be preserved"); + Assert.That(packageJson, Does.Not.Contain(":cid/:cid/"), + "the prefix must not be applied twice when already present"); + } + [Test] public void NewPortalExtension_PageExtension_PassesThroughHubPath() { From 148513f9cf862651f957c0368343dd34335068f6 Mon Sep 17 00:00:00 2001 From: Gabriel Ramos Date: Fri, 11 Sep 2026 15:51:41 -0300 Subject: [PATCH 4/5] fixes in creating zone portal extensions --- .../PortalExtensionReactAppZone/package.json | 2 +- .../Project/NewPortalExtensionCommand.cs | 23 ++++-------------- .../beam-create-portal-extension.md.scriban | 19 +++++++-------- .../PortalExtensionCommandTests.cs | 24 ++++++++++--------- 4 files changed, 27 insertions(+), 41 deletions(-) diff --git a/cli/beamable.templates/templates/PortalExtensionReactAppZone/package.json b/cli/beamable.templates/templates/PortalExtensionReactAppZone/package.json index 6ea73d4b93..b6b665584c 100644 --- a/cli/beamable.templates/templates/PortalExtensionReactAppZone/package.json +++ b/cli/beamable.templates/templates/PortalExtensionReactAppZone/package.json @@ -10,7 +10,7 @@ "microserviceDependencies": [], "mounts": [ { - "page": ":cid/page-path", + "page": "page-path", "selector": "#extension-page", "args": {}, "navLabel": "", diff --git a/cli/cli/Commands/Project/NewPortalExtensionCommand.cs b/cli/cli/Commands/Project/NewPortalExtensionCommand.cs index 5ed5e7bce3..2509c8625c 100644 --- a/cli/cli/Commands/Project/NewPortalExtensionCommand.cs +++ b/cli/cli/Commands/Project/NewPortalExtensionCommand.cs @@ -60,7 +60,7 @@ public override void Configure() AddOption(new Option( aliases: new string[] { "--mount-page" }, - description: "The portal page to mount on. For page extensions use routePrefix + your custom route; for component extensions use the page path. A --zone full-page extension is auto-prefixed with ':cid/' (it renders at org level). Run 'portal extension list-extension-options' to see all valid values"), + description: "The portal page to mount on. For page extensions use routePrefix + your custom route; for component extensions use the page path. A --zone extension is declared with a zone-relative page path and renders inside the portal's zone view (/zones/:zid/) — the portal owns the prefix. Run 'portal extension list-extension-options' to see all valid values"), binder: (args, i) => args.mountPage = i); AddOption(new Option( @@ -146,19 +146,6 @@ private static Dictionary ParseFilterOverrides(string[] filters) return result; } - // A zone full-page extension renders at the org level (/:customerId/*), so its stored - // `page` must begin with a `:cid` param segment — the Portal's org-level matcher - // (OrgExtensionPage) matches the whole `/` path against it, while the sidebar - // nav/hub derivation strips the leading param. Realm extensions mount under the realm - // route and need no such prefix. Prepend `:cid/` unless the author already supplied a - // leading param segment (any `:name`); tolerate a leading slash. - internal static string EnsureZonePagePrefix(string page) - { - var trimmed = (page ?? string.Empty).TrimStart('/'); - var firstSeg = trimmed.Split('/')[0]; - return firstSeg.StartsWith(":") ? trimmed : $":cid/{trimmed}"; - } - public override async Task Handle(NewPortalExtensionCommandArgs args) { // Validate required arg pairs before any expensive I/O so errors surface immediately @@ -208,11 +195,9 @@ public override async Task Handle(NewPortalExtensionCommandArgs args) if (resolvedSelector.type == "page") { - // A zone extension lives at the org level, so its full-page mount needs the - // `:cid` prefix the Portal's org matcher expects. Do this before persisting the - // page below (and after selector resolution so component mounts are untouched). - if (args.IsZone) - args.mountPage = EnsureZonePagePrefix(args.mountPage); + // A zone extension's mount page is stored zone-relative and verbatim — the portal + // auto-assumes the full `:cid/zones/:zid/` prefix when rendering it in the zone view, + // so the CLI must not prepend anything here. // Full-page (hub) extensions need a nav group and a display label. The hub hierarchy comes // from the page path itself (e.g. "cars" vs "cars/ferrari"); the nav group is a separate way diff --git a/cli/cli/Docs/SkillTemplates/beam-create-portal-extension.md.scriban b/cli/cli/Docs/SkillTemplates/beam-create-portal-extension.md.scriban index 169f0cc8ff..33b05a4b34 100644 --- a/cli/cli/Docs/SkillTemplates/beam-create-portal-extension.md.scriban +++ b/cli/cli/Docs/SkillTemplates/beam-create-portal-extension.md.scriban @@ -70,18 +70,17 @@ substitute a real hub name: replaced by your hub path, not literally prepended.) For full-page extensions the `--mount-selector` is always auto-assigned from the page slot, so you never pass it. -#### Zone (`--zone`) extensions render at the org level +#### Zone (`--zone`) extensions render in the zone view A `--zone` extension is scoped to `cid.zid` and lives **above realms**, so its hub and -pages render at the **org level** (`/:customerId/*`) rather than inside a realm. For the -Portal's org-level matcher to resolve them, the stored `page` must begin with a `:cid` -param segment — e.g. `:cid/dashboards` (hub) and `:cid/dashboards/overview` (page). +pages render in the Portal's **zone view** under `/zones/:zid/` rather than inside a realm. -**You do not write the `:cid/` yourself.** Pass the hub path exactly as for a realm -extension (`--mount-page "dashboards"` or `"dashboards/overview"`); when `--zone` is set, -the CLI prepends `:cid/` automatically. Only if you author the manifest's `page` by hand -must you include it — a zone `page` missing the `:cid/` prefix makes the hub appear in the -picker while its pages render *"No extension is registered for this page."* +**Declare the mount page zone-relative** — exactly as for a realm extension +(`--mount-page "dashboards"` or `"dashboards/overview"`). The Portal owns the routing +prefix: it auto-assumes the full `:cid/zones/:zid/` prefix when rendering a zone +extension, so the stored `page` in `package.json` is the zone-relative path with **no** +`:cid/` segment. Do not add a `:cid/` prefix yourself, whether via `--mount-page` or by +editing the manifest's `page` by hand. ### 2. Create the extension @@ -160,7 +159,7 @@ Read the generated TypeScript files in the extension's `node_modules` to discove - **Always pass `-q` (quiet mode)** when executing from MCP to avoid interactive prompts that will hang. - **Page extensions need `--mount-group` and `--mount-label`** for the sidebar entry. Component extensions do not use these. - **Mount page format differs by type**: page extensions use `routePrefix + custom-route`, component extensions use the exact `path` value. -- **Zone extensions render at the org level** and their mount `page` must start with `:cid/`. With `--zone` the CLI prepends this automatically — pass the bare hub path. If you edit the manifest by hand, a missing `:cid/` makes the hub show in the picker but its pages render "No extension is registered for this page." +- **Zone extensions render in the Portal's zone view** under `/zones/:zid/`. Declare their mount `page` **zone-relative** (e.g. `dashboards` or `dashboards/overview`) — the Portal owns the `:cid/zones/:zid/` prefix and auto-assumes it. Do not add a `:cid/` prefix, via `--mount-page` or by hand-editing the manifest. - **In React, import components from `@beamable/portal-toolkit/react`** — do NOT use `` web component tags directly in TSX. Use the typed React forwarders instead. - **Use Beamable components, not raw HTML.** Portal extensions render inside the Portal UI. Read the generated files in `node_modules/@beamable/portal-toolkit/src/generated/` for available components. - **The `--service-directory` for portal extensions is `extensions/`**, not `services/`. diff --git a/cli/tests/PortalExtensionTests/PortalExtensionCommandTests.cs b/cli/tests/PortalExtensionTests/PortalExtensionCommandTests.cs index a0568b4ea6..9dfc6c0a9f 100644 --- a/cli/tests/PortalExtensionTests/PortalExtensionCommandTests.cs +++ b/cli/tests/PortalExtensionTests/PortalExtensionCommandTests.cs @@ -267,7 +267,7 @@ public void NewPortalExtension_ZoneExtension_ScaffoldsZoneTemplate() } [Test] - public void NewPortalExtension_ZoneExtension_PrefixesPageWithCid() + public void NewPortalExtension_ZoneExtension_StoresPageZoneRelative() { InitWorkspace(); SetupBeamoServiceMock(); @@ -281,29 +281,31 @@ public void NewPortalExtension_ZoneExtension_PrefixesPageWithCid() "--zone"); var packageJson = BFile.ReadAllText("extensions/TestZonePage/package.json"); - Assert.That(packageJson, Does.Contain("\":cid/my-zone-page\""), - "a zone full-page extension renders at org level, so its page must gain a leading :cid segment"); + Assert.That(packageJson, Does.Contain("\"my-zone-page\""), + "a zone extension's page is declared zone-relative and stored verbatim"); + Assert.That(packageJson, Does.Not.Contain(":cid/"), + "the portal owns the :cid/zones/:zid/ prefix, so the CLI must not prepend :cid/"); } [Test] - public void NewPortalExtension_ZoneExtension_DoesNotDoublePrefixExplicitCid() + public void NewPortalExtension_ZoneTemplate_DefaultPageIsZoneRelative() { InitWorkspace(); SetupBeamoServiceMock(); MockRemotePortalConfig(); - Run("project", "new", "portal-extension", "TestZonePreprefixed", "--quiet", - "--mount-page", ":cid/my-zone-page", + // Scaffold from the zone template without overriding the mount page, then inspect the + // template's seeded default. The zone template must ship a zone-relative default page. + Run("project", "new", "portal-extension", "TestZoneDefault", "--quiet", + "--mount-page", "zone-default", "--mount-group", "TestGroup", "--mount-label", "TestLabel", "--template", "react", "--zone"); - var packageJson = BFile.ReadAllText("extensions/TestZonePreprefixed/package.json"); - Assert.That(packageJson, Does.Contain("\":cid/my-zone-page\""), - "an author-supplied :cid prefix must be preserved"); - Assert.That(packageJson, Does.Not.Contain(":cid/:cid/"), - "the prefix must not be applied twice when already present"); + var packageJson = BFile.ReadAllText("extensions/TestZoneDefault/package.json"); + Assert.That(packageJson, Does.Not.Contain(":cid/"), + "the zone template default page must be zone-relative, without a :cid/ prefix"); } [Test] From 4aaf82f1be0491e60f22829cb3070b30ccac1d5f Mon Sep 17 00:00:00 2001 From: Gabriel Ramos Date: Fri, 11 Sep 2026 15:51:57 -0300 Subject: [PATCH 5/5] fix discovery service not finding zone portal extensions --- cli/cli/Services/DiscoveryService.cs | 73 ++++++++--- .../DiscoveryTests/DiscoveryServiceTests.cs | 115 ++++++++++++++++++ 2 files changed, 174 insertions(+), 14 deletions(-) create mode 100644 cli/tests/DiscoveryTests/DiscoveryServiceTests.cs diff --git a/cli/cli/Services/DiscoveryService.cs b/cli/cli/Services/DiscoveryService.cs index b0cbc1a7ab..1dc1f65cb7 100644 --- a/cli/cli/Services/DiscoveryService.cs +++ b/cli/cli/Services/DiscoveryService.cs @@ -557,16 +557,57 @@ await WebsocketUtil.RunServerNotificationListenLoop(handle, message => } + /// + /// Resolves the local manifest definition and beamoId for a service discovered on the network by the + /// name it broadcasts. Every service broadcasts under its beamoId except a portal extension, whose + /// backing server broadcasts a synthetic runtime name (BeamPortalExtension_<beamoId>_<guid>); + /// this reconciles that back to the extension definition so zone detection, metadata, and the surfaced + /// name behave the same as they do for a microservice. Without it a zone-scoped portal extension is + /// dropped by host discovery — its broadcast pid is the zone id (never the realm pid) and the unresolved + /// name defeats the IsZoneScoped escape hatch — so it never appears in beam project ps nor + /// gets stopped by beam project stop. + /// + /// true if a local definition was found for the broadcast name. + public static bool TryResolveBroadcastDefinition(BeamoLocalManifest manifest, string broadcastServiceName, out BeamoServiceDefinition definition, out string beamoId) + { + if (manifest.TryGetDefinition(broadcastServiceName, out definition)) + { + beamoId = broadcastServiceName; + return true; + } + + foreach (var candidate in manifest.ServiceDefinitions) + { + if (candidate.Protocol != BeamoProtocolType.PortalExtension) + { + continue; + } + + if (BeamoLocalSystem.IsMatchingPortalExtensionService(broadcastServiceName, candidate.BeamoId)) + { + definition = candidate; + beamoId = candidate.BeamoId; + return true; + } + } + + definition = null; + beamoId = broadcastServiceName; + return false; + } + public Task StartHostDiscoveryTask(CancellationToken token, ConcurrentQueue evtQueue) { return Task.Run(async () => { try { - // Keyed by (processId, serviceName) so multiple BeamServer instances hosted inside + // Keyed by (processId, beamoId) so multiple BeamServer instances hosted inside // the same OS process — e.g. several portal extensions started by one `beam project run` — // each register independently. Keying by processId alone hid every extension after the first. - var processIdToEntry = new Dictionary<(int processId, string serviceName), HostServiceDescriptor>(); + // The beamoId is resolved from the broadcast serviceName below (a portal extension broadcasts a + // synthetic runtime name, not its beamoId). + var processIdToEntry = new Dictionary<(int processId, string beamoId), HostServiceDescriptor>(); var socketListener = new Socket(SocketType.Dgram, ProtocolType.Udp); var ed = new IPEndPoint(System.Net.IPAddress.Any, Beamable.Common.Constants.Features.Services.DISCOVERY_PORT); @@ -672,27 +713,31 @@ public Task StartHostDiscoveryTask(CancellationToken token, ConcurrentQueue) in the pid slot, which never equals the realm pid — so surface it by its - // local manifest definition instead (the workspace only defines its own zone projects), - // keyed off the serviceName the broadcast carries. + // (the raw zid) in the pid slot, which never equals the realm pid — so surface it by its + // local manifest definition instead (the workspace only defines its own zone projects). if (service.cid != _appContext.Cid) continue; - var isLocalZoneService = - _localSystem.BeamoManifest.TryGetDefinition(service.serviceName, out var discoveredDef) && - discoveredDef.IsZoneScoped; + // A service broadcasts under its MicroserviceName. For a portal extension that is a + // synthetic runtime name (BeamPortalExtension__), not the beamoId the local + // manifest is keyed by — so resolve it back to the manifest definition before it drives + // zone detection, metadata, and the surfaced service name. For every other service the + // broadcast name already is the beamoId, so this resolves to itself. + TryResolveBroadcastDefinition(_localSystem.BeamoManifest, service.serviceName, out var discoveredDef, out var resolvedBeamoId); + + var isLocalZoneService = discoveredDef != null && discoveredDef.IsZoneScoped; if (!isLocalZoneService && service.pid != _appContext.Pid) continue; - var entryKey = (service.processId, service.serviceName); + var entryKey = (service.processId, resolvedBeamoId); if (!processIdToEntry.ContainsKey(entryKey)) { var groups = Array.Empty(); var fedConfig = default(FederationsConfig); - if (_localSystem.BeamoManifest.TryGetDefinition(service.serviceName, out var definition)) + if (discoveredDef != null) { - groups = definition.ServiceGroupTags; - fedConfig = definition.FederationsConfig.Federations; + groups = discoveredDef.ServiceGroupTags; + fedConfig = discoveredDef.FederationsConfig.Federations; } var feds = fedConfig?.Select(kvp => @@ -703,7 +748,7 @@ public Task StartHostDiscoveryTask(CancellationToken token, ConcurrentQueue { var fedKey = FederationUtils.BuildLocalSettingKey(v.Interface, kvp.Key); - if (_localSystem.BeamoManifest.HttpMicroserviceLocalProtocols[service.serviceName].Settings.TryGetSetting(fedKey, out var settingsJsonVal)) + if (_localSystem.BeamoManifest.HttpMicroserviceLocalProtocols[resolvedBeamoId].Settings.TryGetSetting(fedKey, out var settingsJsonVal)) return settingsJsonVal; return "{}"; }).ToArray() @@ -712,7 +757,7 @@ public Task StartHostDiscoveryTask(CancellationToken token, ConcurrentQueue +/// Unit tests for , the reconciliation that +/// lets host discovery map the name a service broadcasts back to its local manifest definition. This is +/// what makes a zone-scoped portal extension visible to beam project ps / stoppable by +/// beam project stop: its backing server broadcasts a synthetic runtime name, and without this +/// resolution the IsZoneScoped escape hatch in host discovery never engages. +/// +public class DiscoveryServiceTests +{ + private static BeamoLocalManifest ManifestWith(params BeamoServiceDefinition[] definitions) + { + return new BeamoLocalManifest + { + ServiceDefinitions = new List(definitions), + HttpMicroserviceLocalProtocols = new BeamoLocalProtocolMap(), + EmbeddedMongoDbLocalProtocols = new BeamoLocalProtocolMap(), + }; + } + + private static string BroadcastName(string beamoId) => + $"BeamPortalExtension_{beamoId}_{Guid.NewGuid()}"; + + [Test] + public void ResolvesMicroservice_ByExactName() + { + var manifest = ManifestWith(new BeamoServiceDefinition + { + BeamoId = "MyService", + Protocol = BeamoProtocolType.HttpMicroservice, + }); + + var found = DiscoveryService.TryResolveBroadcastDefinition(manifest, "MyService", out var def, out var beamoId); + + Assert.That(found, Is.True, "a microservice broadcasts under its beamoId and must resolve directly"); + Assert.That(beamoId, Is.EqualTo("MyService")); + Assert.That(def, Is.Not.Null); + } + + [Test] + public void ResolvesRealmPortalExtension_FromMangledBroadcastName() + { + var manifest = ManifestWith(new BeamoServiceDefinition + { + BeamoId = "MyExt", + Protocol = BeamoProtocolType.PortalExtension, + ServiceScope = null, + }); + + var found = DiscoveryService.TryResolveBroadcastDefinition(manifest, BroadcastName("MyExt"), out var def, out var beamoId); + + Assert.That(found, Is.True, "a portal extension's synthetic runtime name must resolve back to its beamoId"); + Assert.That(beamoId, Is.EqualTo("MyExt"), "the surfaced name must be the beamoId, not the mangled runtime name"); + Assert.That(def, Is.Not.Null); + Assert.That(def.IsZoneScoped, Is.False, "a realm extension is not zone-scoped"); + } + + [Test] + public void ResolvesZonePortalExtension_AndReportsZoneScoped() + { + // This is the regression case: a zone-scoped portal extension broadcasts a mangled runtime name and a + // zone id in its pid slot. Host discovery only keeps it (past the realm-pid filter) if this resolution + // finds the definition and surfaces IsZoneScoped. + var manifest = ManifestWith(new BeamoServiceDefinition + { + BeamoId = "MyZoneExt", + Protocol = BeamoProtocolType.PortalExtension, + ServiceScope = "zone", + }); + + var found = DiscoveryService.TryResolveBroadcastDefinition(manifest, BroadcastName("MyZoneExt"), out var def, out var beamoId); + + Assert.That(found, Is.True, "a zone portal extension must resolve so host discovery can keep it"); + Assert.That(beamoId, Is.EqualTo("MyZoneExt")); + Assert.That(def, Is.Not.Null); + Assert.That(def.IsZoneScoped, Is.True, "the resolved definition must report zone scope so the realm-pid filter is bypassed"); + } + + [Test] + public void PicksCorrectExtension_WhenMultipleExist() + { + var manifest = ManifestWith( + new BeamoServiceDefinition { BeamoId = "ExtA", Protocol = BeamoProtocolType.PortalExtension, ServiceScope = "zone" }, + new BeamoServiceDefinition { BeamoId = "ExtB", Protocol = BeamoProtocolType.PortalExtension, ServiceScope = null }); + + var found = DiscoveryService.TryResolveBroadcastDefinition(manifest, BroadcastName("ExtB"), out var def, out var beamoId); + + Assert.That(found, Is.True); + Assert.That(beamoId, Is.EqualTo("ExtB"), "resolution must match the extension embedded in the broadcast name"); + Assert.That(def.IsZoneScoped, Is.False); + } + + [Test] + public void ReturnsFalse_ForUnknownName() + { + var manifest = ManifestWith(new BeamoServiceDefinition + { + BeamoId = "Known", + Protocol = BeamoProtocolType.HttpMicroservice, + }); + + var found = DiscoveryService.TryResolveBroadcastDefinition(manifest, BroadcastName("Unknown"), out var def, out var beamoId); + + Assert.That(found, Is.False, "a broadcast that matches no local definition must not resolve"); + Assert.That(def, Is.Null); + Assert.That(beamoId, Does.StartWith("BeamPortalExtension_Unknown_"), + "when unresolved, the raw broadcast name is passed through unchanged"); + } +}