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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions docs/cli/aspire.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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<Projects.Api>("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<T>`) — the integration locates the project
Expand Down
9 changes: 8 additions & 1 deletion src/AspireSample/AspireSample.AppHost/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,19 @@
builder.AddProject<Projects.AspireSample_Api>("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();
5 changes: 4 additions & 1 deletion src/AspireSample/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
174 changes: 174 additions & 0 deletions src/JasperFx.Aspire.Tests/JasperFxStartupExtensionsTests.cs
Original file line number Diff line number Diff line change
@@ -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,
"<Project Sdk=\"Microsoft.NET.Sdk\"><PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup></Project>");
}

return path;
}

private static IResourceBuilder<ProjectResource> 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<WaitAnnotation>()
.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<IProjectMetadata>(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<EnvironmentCallbackAnnotation>().ToArray();

api.WithJasperFxStartup("resources", "setup");

var gate = Gate(builder, "api-resources-setup");
var gateEnv = gate.Annotations.OfType<EnvironmentCallbackAnnotation>().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();
}
}
59 changes: 59 additions & 0 deletions src/JasperFx.Aspire.Tests/JasperFxStartupHelperTests.cs
Original file line number Diff line number Diff line change
@@ -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"]);
}
}
Loading
Loading