diff --git a/docs/cli/aspire.md b/docs/cli/aspire.md index e3f8149..eab3627 100644 --- a/docs/cli/aspire.md +++ b/docs/cli/aspire.md @@ -12,6 +12,13 @@ AppHost can click a button on the running service and watch the output stream in Because Marten, Wolverine, and Polecat all build on the same JasperFx command infrastructure, a single package lights this up for the entire Critter Stack. +The package offers two complementary features: + +- **On-demand commands** — `WithJasperFxCommands()` adds buttons you click against the *running* + service. +- **Startup gates** — `WithJasperFxStartup()` runs provisioning verbs to completion *before* the + service starts (see [Startup gates](#startup-gates) below). + ## Installation Add the package to your Aspire **AppHost** project (not the service itself): @@ -135,6 +142,74 @@ changes beyond the `RunJasperFxCommands(args)` it already calls. - The buttons are disabled unless the resource is running, so the child process always has something to run against. Override per verb with `JasperFxCommandRegistration.UpdateState`. +## Startup gates + +The on-demand buttons above run against a service that is *already running*. The complementary need +is to run a provisioning verb **before** the service starts — apply database schema / event store / +transports, or pre-generate runtime code so there's no first-request codegen latency. .NET Aspire +models this with run-to-completion resources and `WaitForCompletion` (the canonical "run migrations +before the app" pattern), and `WithJasperFxStartup` makes it a one-liner against the existing project: + +```cs +var db = builder.AddPostgres("pg").AddDatabase("appdb"); + +builder.AddProject("api") + .WithReference(db) + .WaitFor(db) + .WithJasperFxStartup("resources", "setup"); // runs to completion before "api" starts +``` + +Each gate is a **first-class Aspire resource** pointing at the same project with the verb as +arguments. Because it is a real resource (not an AppHost callback), Aspire injects connection strings +and environment into it natively — there is no child-process spawn or environment trick here. The gate +inherits the parent's references, so you declare `WithReference`/`WaitFor` **once on the service, +before** `WithJasperFxStartup`. + +When `arguments` is omitted, the provisioning verbs default sensibly: `resources` → `setup`, +`codegen` → `write`. + +### Fail fast + +A gate that exits non-zero leaves the service **blocked** and the failure visible in the dashboard +with the gate's streamed logs — you never start a service against un-provisioned infrastructure. This +is the default and the whole point. + +### Several gates and ordering + +Use the fluent form to declare multiple gates. They run **sequentially in declaration order** (each +waiting for the previous) unless a gate opts into `Parallel`: + +```cs +api.WithJasperFxStartup(c => +{ + c.Run("resources", "setup"); // gate 1 + c.Run("codegen", "write", g => g.Parallel = true); // independent — runs concurrently + c.Check(); // check-env, blocking (opt-in) +}); +``` + +### `check-env` as an opt-in gate + +`check-env` is **not** a startup gate unless you ask for it — many teams treat environment checks as +advisory and silently blocking startup would be surprising. Opt in with `c.Check()` (fluent form) or +`WithJasperFxStartup("check-env")`; a failed check then blocks startup like any other gate. Make a gate +advisory (runs but never blocks) with `BlockOnFailure = false`. + +### Published / deploy-time behavior + +Gates run in all environments by default, since "provision/migrate on deploy" is a common, valid use. +Because migrating in production is a deliberate policy for some teams, make a gate +environment-conditional with `RunWhen`: + +```cs +api.WithJasperFxStartup("resources", "setup", + gate: g => g.RunWhen = ctx => ctx.IsRunMode); // local only, not in a published deployment +``` + +> Each gate runs the target project with verb arguments, which relies on the standard JasperFx +> bootstrap (`RunJasperFxCommands(args)`) so the process executes the verb and exits instead of +> starting the long-running host. This is already the default for Marten/Wolverine/Polecat apps. + ## Requirements - The target must be a **project resource** (`AddProject`) — the integration locates the project diff --git a/src/AspireSample/AspireSample.AppHost/Program.cs b/src/AspireSample/AspireSample.AppHost/Program.cs index 522efaa..916c49c 100644 --- a/src/AspireSample/AspireSample.AppHost/Program.cs +++ b/src/AspireSample/AspireSample.AppHost/Program.cs @@ -8,12 +8,19 @@ builder.AddProject("api") .WithReference(appdb) .WaitFor(appdb) - // The whole point: JasperFx command buttons on the "api" resource tile. IncludeMutatingCommands + // A1 — on-demand JasperFx command buttons on the "api" resource tile. IncludeMutatingCommands // also adds "Apply resources" / "Rebuild projections" / "Write generated code", each gated by a // confirmation prompt. .WithJasperFxCommands(opts => { opts.IncludeMutatingCommands = true; + }) + // A2 — startup gates: run-to-completion resources that finish BEFORE the api starts. References + // and WaitFor above are declared first so each gate inherits them (connection string + wait-for-db). + .WithJasperFxStartup(c => + { + c.Run("resources", "setup"); // provision stateful resources before the api boots + c.Check(); // verify the DB connection (blocking) before the api boots }); builder.Build().Run(); diff --git a/src/AspireSample/README.md b/src/AspireSample/README.md index 430ba95..41e0200 100644 --- a/src/AspireSample/README.md +++ b/src/AspireSample/README.md @@ -14,7 +14,10 @@ Postgres hosting), so the core build/CI doesn't carry it. Build and run it on de value reached the process — so a successful `check-env` proves the JasperFx.Aspire env-resolution mechanic worked end to end. - **AspireSample.AppHost** — the Aspire AppHost. Adds a Postgres container + `appdb` database, runs - the API project, and calls `.WithJasperFxCommands(opts => opts.IncludeMutatingCommands = true)`. + the API project, and demonstrates both features: + - **A1** — `.WithJasperFxCommands(opts => opts.IncludeMutatingCommands = true)` (on-demand buttons). + - **A2** — `.WithJasperFxStartup(...)` startup gates (`resources setup` then a blocking `check-env`) + that run to completion before the `api` resource starts. ## Run it (manual dashboard verification) diff --git a/src/JasperFx.Aspire.Tests/JasperFxStartupExtensionsTests.cs b/src/JasperFx.Aspire.Tests/JasperFxStartupExtensionsTests.cs new file mode 100644 index 0000000..295e237 --- /dev/null +++ b/src/JasperFx.Aspire.Tests/JasperFxStartupExtensionsTests.cs @@ -0,0 +1,174 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using JasperFx.Aspire; +using Shouldly; + +namespace JasperFx.Aspire.Tests; + +public class JasperFxStartupExtensionsTests +{ + // AddProject(name, path) validates the file exists, so point at a real (throwaway) csproj. + private static readonly string ProjectPath = CreateTempProject(); + + private static string CreateTempProject() + { + var dir = Path.Combine(Path.GetTempPath(), "jasperfx-aspire-tests"); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, "Api.csproj"); + if (!File.Exists(path)) + { + File.WriteAllText(path, + "net10.0"); + } + + return path; + } + + private static IResourceBuilder Parent(out IDistributedApplicationBuilder builder) + { + builder = DistributedApplication.CreateBuilder([]); + return builder.AddProject("api", ProjectPath); + } + + private static IResource Gate(IDistributedApplicationBuilder builder, string name) + => builder.Resources.Single(r => r.Name == name); + + private static bool WaitsForCompletionOn(IResource waiter, IResource awaited) + => waiter.Annotations.OfType() + .Any(w => ReferenceEquals(w.Resource, awaited) && w.WaitType == WaitType.WaitForCompletion); + + [Fact] + public void creates_a_gate_resource_named_after_parent_verb_and_argument() + { + var api = Parent(out var builder); + + api.WithJasperFxStartup("resources", "setup"); + + builder.Resources.ShouldContain(r => r.Name == "api-resources-setup"); + } + + [Fact] + public void the_parent_waits_for_completion_of_the_gate() + { + var api = Parent(out var builder); + + api.WithJasperFxStartup("resources", "setup"); + + var gate = Gate(builder, "api-resources-setup"); + WaitsForCompletionOn(api.Resource, gate).ShouldBeTrue(); + } + + [Fact] + public void the_gate_points_at_the_same_project_as_the_parent() + { + var api = Parent(out var builder); + + api.WithJasperFxStartup("resources", "setup"); + + var gate = Gate(builder, "api-resources-setup"); + gate.TryGetLastAnnotation(out var metadata).ShouldBeTrue(); + metadata!.ProjectPath.ShouldBe(ProjectPath); + } + + [Fact] + public void the_gate_clones_the_parents_environment_references() + { + var api = Parent(out var builder); + api.WithEnvironment("MARKER", "from-parent"); + + // capture the parent's reference annotations as they stand before the gate is wired + var parentEnv = api.Resource.Annotations.OfType().ToArray(); + + api.WithJasperFxStartup("resources", "setup"); + + var gate = Gate(builder, "api-resources-setup"); + var gateEnv = gate.Annotations.OfType().ToArray(); + foreach (var annotation in parentEnv) + { + gateEnv.ShouldContain(annotation); + } + } + + [Fact] + public void gates_run_sequentially_in_declaration_order_by_default() + { + var api = Parent(out var builder); + + api.WithJasperFxStartup(c => + { + c.Run("resources", "setup"); + c.Run("codegen", "write"); + }); + + var first = Gate(builder, "api-resources-setup"); + var second = Gate(builder, "api-codegen-write"); + + // the second gate waits for the first + WaitsForCompletionOn(second, first).ShouldBeTrue(); + // and the parent waits for both + WaitsForCompletionOn(api.Resource, first).ShouldBeTrue(); + WaitsForCompletionOn(api.Resource, second).ShouldBeTrue(); + } + + [Fact] + public void a_parallel_gate_does_not_chain_after_the_previous_gate() + { + var api = Parent(out var builder); + + api.WithJasperFxStartup(c => + { + c.Run("resources", "setup"); + c.Run("codegen", "write", g => g.Parallel = true); + }); + + var first = Gate(builder, "api-resources-setup"); + var parallel = Gate(builder, "api-codegen-write"); + + // the parallel gate is NOT chained after the first + WaitsForCompletionOn(parallel, first).ShouldBeFalse(); + // but the parent still waits for it + WaitsForCompletionOn(api.Resource, parallel).ShouldBeTrue(); + } + + [Fact] + public void run_when_false_skips_the_gate_entirely() + { + var api = Parent(out var builder); + + api.WithJasperFxStartup("resources", "setup", gate: g => g.RunWhen = _ => false); + + builder.Resources.ShouldNotContain(r => r.Name == "api-resources-setup"); + } + + [Fact] + public void an_advisory_gate_runs_but_does_not_block_startup() + { + var api = Parent(out var builder); + + api.WithJasperFxStartup("check-env", gate: g => g.BlockOnFailure = false); + + var gate = Gate(builder, "api-check-env"); // it still exists / runs + WaitsForCompletionOn(api.Resource, gate).ShouldBeFalse(); // but the parent doesn't wait on it + } + + [Fact] + public void custom_resource_name_is_honored() + { + var api = Parent(out var builder); + + api.WithJasperFxStartup("resources", "setup", gate: g => g.ResourceName = "provision-db"); + + builder.Resources.ShouldContain(r => r.Name == "provision-db"); + } + + [Fact] + public void check_helper_adds_a_blocking_check_env_gate() + { + var api = Parent(out var builder); + + api.WithJasperFxStartup(c => c.Check()); + + var gate = Gate(builder, "api-check-env"); + WaitsForCompletionOn(api.Resource, gate).ShouldBeTrue(); + } +} diff --git a/src/JasperFx.Aspire.Tests/JasperFxStartupHelperTests.cs b/src/JasperFx.Aspire.Tests/JasperFxStartupHelperTests.cs new file mode 100644 index 0000000..aa7e4f3 --- /dev/null +++ b/src/JasperFx.Aspire.Tests/JasperFxStartupHelperTests.cs @@ -0,0 +1,59 @@ +using JasperFx.Aspire; +using Shouldly; + +namespace JasperFx.Aspire.Tests; + +public class JasperFxStartupHelperTests +{ + [Theory] + [InlineData("resources", "setup")] + [InlineData("codegen", "write")] + [InlineData("check-env", null)] + [InlineData("projections", null)] + public void default_gate_arguments(string verb, string? expected) + { + JasperFxAspireStartupExtensions.DefaultGateArguments(verb).ShouldBe(expected); + } + + [Fact] + public void build_gate_name_includes_parent_verb_and_argument() + { + JasperFxAspireStartupExtensions.BuildGateName("api", "resources", "setup") + .ShouldBe("api-resources-setup"); + } + + [Fact] + public void build_gate_name_with_no_argument() + { + JasperFxAspireStartupExtensions.BuildGateName("api", "check-env", null) + .ShouldBe("api-check-env"); + } + + [Fact] + public void build_gate_name_is_lowercased() + { + JasperFxAspireStartupExtensions.BuildGateName("Api", "Resources", "Setup") + .ShouldBe("api-resources-setup"); + } + + [Fact] + public void build_args_prepends_the_verb() + { + JasperFxAspireStartupExtensions.BuildArgs("resources", "setup") + .ShouldBe(["resources", "setup"]); + } + + [Fact] + public void build_args_with_no_argument() + { + JasperFxAspireStartupExtensions.BuildArgs("check-env", null) + .ShouldBe(["check-env"]); + } + + [Fact] + public void build_args_splits_multi_token_arguments() + { + JasperFxAspireStartupExtensions.BuildArgs("projections", "rebuild Orders") + .ShouldBe(["projections", "rebuild", "Orders"]); + } +} diff --git a/src/JasperFx.Aspire/JasperFxAspireStartupExtensions.cs b/src/JasperFx.Aspire/JasperFxAspireStartupExtensions.cs new file mode 100644 index 0000000..855742f --- /dev/null +++ b/src/JasperFx.Aspire/JasperFxAspireStartupExtensions.cs @@ -0,0 +1,156 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; + +namespace JasperFx.Aspire; + +/// +/// AppHost extension methods that run a JasperFx app's provisioning verbs (resources setup, +/// codegen write, opt-in check-env) as run-to-completion Aspire resources that finish +/// before the owning service starts, wired via WaitForCompletion. The orchestration-time +/// complement to . +/// +public static class JasperFxAspireStartupExtensions +{ + /// + /// Add a JasperFx verb as a run-to-completion startup gate that the owning service waits on before + /// starting. When is null a sensible default is used for known + /// provisioning verbs (resourcessetup, codegenwrite). + /// + public static IResourceBuilder WithJasperFxStartup( + this IResourceBuilder builder, + string verb, + string? arguments = null, + Action? gate = null) + where T : IResourceWithEnvironment, IResourceWithWaitSupport + { + return ApplyGates(builder, [new JasperFxGateSpec(verb, arguments, gate)]); + } + + /// + /// Declare several JasperFx startup gates with ordering control. Gates run in declaration order + /// (each waiting for the previous) unless a gate opts into . + /// + public static IResourceBuilder WithJasperFxStartup( + this IResourceBuilder builder, + Action configure) + where T : IResourceWithEnvironment, IResourceWithWaitSupport + { + var startup = new JasperFxStartupBuilder(); + configure(startup); + return ApplyGates(builder, startup.Specs); + } + + private static IResourceBuilder ApplyGates( + IResourceBuilder parent, IReadOnlyList specs) + where T : IResourceWithEnvironment, IResourceWithWaitSupport + { + if (!parent.Resource.TryGetLastAnnotation(out var projectMetadata)) + { + throw new InvalidOperationException( + $"WithJasperFxStartup requires a project resource; '{parent.Resource.Name}' has no project metadata. " + + "Add the gate to a resource created with AddProject(...)."); + } + + // Snapshot the parent's reference/env annotations ONCE, before any gate wiring. WaitForCompletion + // adds WaitAnnotations to the parent as we go; cloning from a pre-wiring snapshot keeps those from + // leaking onto later gates. + var envAnnotations = parent.Resource.Annotations.OfType().ToArray(); + var waitAnnotations = parent.Resource.Annotations.OfType().ToArray(); + var relationshipAnnotations = parent.Resource.Annotations.OfType().ToArray(); + + var executionContext = parent.ApplicationBuilder.ExecutionContext; + IResourceBuilder? previousSequentialGate = null; + + foreach (var spec in specs) + { + var gate = new JasperFxStartupGate(); + spec.Configure?.Invoke(gate); + + if (gate.RunWhen != null && !gate.RunWhen(executionContext)) + { + continue; // environment-gated out (e.g. local-only) + } + + var arguments = spec.Arguments ?? DefaultGateArguments(spec.Verb); + var gateName = gate.ResourceName ?? BuildGateName(parent.Resource.Name, spec.Verb, arguments); + + var gateBuilder = parent.ApplicationBuilder + .AddProject(gateName, projectMetadata.ProjectPath) + .WithArgs(BuildArgs(spec.Verb, arguments)); + + CloneReferences(gateBuilder.Resource, envAnnotations, waitAnnotations, relationshipAnnotations); + + gate.ConfigureGate?.Invoke(gateBuilder); + + // Sequential ordering: this gate waits for the previously declared (non-parallel) gate. + if (!gate.Parallel && previousSequentialGate != null) + { + gateBuilder.WaitForCompletion(previousSequentialGate); + } + + // The owning service waits for the gate to finish with exit 0 (fail fast). An advisory gate + // (BlockOnFailure = false) still runs but does not block startup. + if (gate.BlockOnFailure) + { + parent.WaitForCompletion(gateBuilder); + } + + if (!gate.Parallel) + { + previousSequentialGate = gateBuilder; + } + } + + return parent; + } + + private static void CloneReferences( + IResource gate, + IEnumerable envAnnotations, + IEnumerable waitAnnotations, + IEnumerable relationshipAnnotations) + { + // The gate is the same binary as the parent, so it needs the same connection strings / env and + // should wait for the same dependencies. Re-apply the parent's reference annotations. + foreach (var annotation in envAnnotations) + { + gate.Annotations.Add(annotation); + } + foreach (var annotation in waitAnnotations) + { + gate.Annotations.Add(annotation); + } + foreach (var annotation in relationshipAnnotations) + { + gate.Annotations.Add(annotation); + } + } + + // Default arguments for the provisioning verbs used as startup gates. Distinct from A1's catalog + // defaults (where `codegen` defaults to the read-only `preview`); a startup gate wants `codegen write`. + internal static string? DefaultGateArguments(string verb) => verb.ToLowerInvariant() switch + { + "resources" => "setup", + "codegen" => "write", + _ => null + }; + + internal static string BuildGateName(string parentName, string verb, string? arguments) + { + var parts = new List { parentName, verb }; + parts.AddRange(Tokenize(arguments)); + return string.Join('-', parts).ToLowerInvariant(); + } + + internal static string[] BuildArgs(string verb, string? arguments) + { + var args = new List { verb }; + args.AddRange(Tokenize(arguments)); + return args.ToArray(); + } + + private static IEnumerable Tokenize(string? value) + => string.IsNullOrWhiteSpace(value) + ? [] + : value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); +} diff --git a/src/JasperFx.Aspire/JasperFxStartupBuilder.cs b/src/JasperFx.Aspire/JasperFxStartupBuilder.cs new file mode 100644 index 0000000..4705126 --- /dev/null +++ b/src/JasperFx.Aspire/JasperFxStartupBuilder.cs @@ -0,0 +1,33 @@ +namespace JasperFx.Aspire; + +/// +/// Fluent builder for declaring several JasperFx startup gates with ordering control. Gates run in +/// the order they are declared here (unless a gate opts into ). +/// +public sealed class JasperFxStartupBuilder +{ + internal List Specs { get; } = []; + + /// + /// Declare a startup gate for a JasperFx verb (e.g. Run("resources", "setup")). When + /// is null a sensible default is used for known provisioning verbs + /// (resourcessetup, codegenwrite). + /// + public JasperFxStartupBuilder Run(string verb, string? arguments = null, Action? gate = null) + { + Specs.Add(new JasperFxGateSpec(verb, arguments, gate)); + return this; + } + + /// + /// Declare a check-env startup gate. Blocks startup on a failed environment check + /// ( defaults to true). + /// + public JasperFxStartupBuilder Check(Action? gate = null) + { + Specs.Add(new JasperFxGateSpec("check-env", null, gate)); + return this; + } +} + +internal sealed record JasperFxGateSpec(string Verb, string? Arguments, Action? Configure); diff --git a/src/JasperFx.Aspire/JasperFxStartupGate.cs b/src/JasperFx.Aspire/JasperFxStartupGate.cs new file mode 100644 index 0000000..fa2e7d4 --- /dev/null +++ b/src/JasperFx.Aspire/JasperFxStartupGate.cs @@ -0,0 +1,43 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; + +namespace JasperFx.Aspire; + +/// +/// Configures a single JasperFx startup gate — a run-to-completion Aspire resource that runs a +/// JasperFx verb (e.g. resources setup) and that the owning service waits on before starting. +/// +public sealed class JasperFxStartupGate +{ + /// + /// Override the gate's Aspire resource name. Defaults to "<parent>-<verb>-<arg>" + /// (e.g. "api-resources-setup"). + /// + public string? ResourceName { get; set; } + + /// + /// Run this gate independently rather than chaining it after the previously declared gate. The + /// owning service still waits for it, but it can run concurrently with the other gates. Defaults + /// to false (gates run sequentially in declaration order). + /// + public bool Parallel { get; set; } + + /// + /// Block the owning service from starting if this gate exits non-zero (fail fast). Defaults to + /// true. Set to false for an advisory gate that runs but does not block startup. + /// + public bool BlockOnFailure { get; set; } = true; + + /// + /// Only create the gate when this predicate returns true for the current execution context — e.g. + /// g.RunWhen = ctx => ctx.IsRunMode to run a gate locally but not in a published deployment. + /// Defaults to always running. + /// + public Func? RunWhen { get; set; } + + /// + /// Escape hatch to further configure the gate's underlying project resource (extra references, + /// environment, args, …) after JasperFx.Aspire has built it. + /// + public Action>? ConfigureGate { get; set; } +} diff --git a/src/JasperFx.Aspire/README.md b/src/JasperFx.Aspire/README.md index af25d6a..bf65d5a 100644 --- a/src/JasperFx.Aspire/README.md +++ b/src/JasperFx.Aspire/README.md @@ -61,6 +61,44 @@ success/failure toast. - **Mutating verbs** (`codegen write`, `resources`, `projections`) require explicit opt-in (`IncludeMutatingCommands = true`) and prompt for confirmation before running. +## Startup gates + +The companion to the on-demand buttons: run a JasperFx provisioning verb as a **run-to-completion +resource that finishes _before_ the service starts**, wired via Aspire's `WaitForCompletion`. This is +the canonical "apply schema / pre-generate code before the app boots" pattern, as a one-liner against +the existing project: + +```csharp +var db = builder.AddPostgres("pg").AddDatabase("appdb"); + +builder.AddProject("api") + .WithReference(db) + .WaitFor(db) + .WithJasperFxStartup("resources", "setup"); // gate finishes before "api" starts +``` + +Each gate is a first-class Aspire resource pointing at the **same project** with the verb as args, so +Aspire injects connection strings/environment natively — no callback or child-process trick. The gate +inherits the parent's references (declare them *before* `WithJasperFxStartup`). A gate that exits +non-zero blocks the service from starting (fail fast). + +Declare several gates with ordering control (they run sequentially in declaration order unless marked +`Parallel`): + +```csharp +api.WithJasperFxStartup(c => +{ + c.Run("resources", "setup"); // gate 1 + c.Run("codegen", "write", g => g.Parallel = true); // runs independently + c.Check(); // check-env, blocking, opt-in +}); +``` + +- `check-env` is only a gate when you opt in (via `Check()` or `WithJasperFxStartup("check-env")`); a + failed check then blocks startup. +- Gates run in all environments by default. Make one environment-conditional with `RunWhen`, e.g. + `g.RunWhen = ctx => ctx.IsRunMode` to run it locally but not in a published deployment. + ## Requirements - .NET Aspire 9.2+ (built and tested against Aspire 13.x).