126 lines
5.8 KiB
Markdown
Raw Normal View History

# AppHost and with_app
`AppHost` creates a scenario whose system under test is supplied by application deployments instead of an outer managed node topology.
The core scenario engine models one `Application` and a uniform cluster of its nodes. For a composed stack containing a binary, an additional cluster, or several applications, start from `AppHost::scenario()` and register the stack with `.with_app(...)`. Workloads, expectations, run duration, and teardown follow the lifecycle in [Scenario Model and Lifecycle](scenario-model.md).
---
## The Zero-Node Scenario
`AppHost::scenario()` returns a `ScenarioBuilder<AppHostEnv>` seeded with `AppHostTopology`:
| Type | Role |
|------|------|
| `AppHostTopology` | Deployment descriptor with `node_count() == 0`. The outer scenario manages no nodes. |
| `AppHostEnv` | Null environment: `NodeClient = ()`, and `build_node_client` always errors. Clients come from app handles instead. |
| `AppHostScenarioBuilder` | Alias for `ScenarioBuilder<AppHostEnv>`. |
| `AppHostLocalDeployer` | Alias for `ProcessDeployer<AppHostEnv>` — the local deployer that executes the scenario. |
Because the outer topology is empty, app deployments create the processes and clusters used by the run.
```rust,ignore
use testing_framework_app::{AppHost, AppHostLocalDeployer, AppScenarioBuilderExt};
use testing_framework_core::scenario::Deployer;
let mut scenario = AppHost::scenario()
.with_app(KvLocalApp::nodes(3))
.with_run_duration(Duration::from_secs(5))
.with_workload(KvAppHostConvergence::new(3))
.build()?;
let deployer = AppHostLocalDeployer::default();
let runner = deployer.deploy(&scenario).await?;
runner.run(&mut scenario).await?;
```
The runnable `kvstore_app_host_convergence` binary uses this structure:
```bash
cargo run -p kvstore-examples --bin kvstore_app_host_convergence
```
---
## How with_app Runs
`AppScenarioBuilderExt::with_app(app)` wraps your [`AppDeployment`](app-deployment.md) in an `AppDeploymentFactory` and registers it as a runtime extension factory, the same lifecycle hook covered in [Runtime Extensions](runtime-extensions.md). Going through the extension mechanism ties managed deployment cleanup to the scenario lifetime and makes exposed handles available during the run.
```mermaid
flowchart LR
B["with_app(app)"] --> F[AppDeploymentFactory]
F -->|prepare| C[DeployContext]
C -->|"deploy(root app)"| H["handles + cleanup"]
H --> R[AppRuntime extension]
R -->|require_app| W[Workloads]
H:::hd
R:::hd
W:::sc
classDef hd stroke:#4caf7d,stroke-width:2.5px;
classDef sc stroke:#9b6dd6,stroke-width:2.5px;
```
During scenario preparation the factory:
1. Clones your app (this is why the factory requires `Clone`) and builds a fresh `DeployContext`.
2. Runs the root deployment's `deploy`, which may deploy and expose child apps.
3. Auto-exposes the returned root handle if the deployment did not expose one of that type itself (`!ctx.contains::<A::Handle>()`).
4. Transfers the handle registry and cleanup stack into an `AppRuntime` extension.
If any step fails, the partially built context is dropped and every resource deployed so far is released (see [Handle Ownership and Teardown](handles-teardown.md)).
A scenario accepts one `with_app` registration. Every `AppDeploymentFactory` produces the same extension type (`AppRuntime`), and the runtime rejects duplicate extension types. A second registration fails during preparation with `duplicate runtime extension type registered: AppRuntime`. Compose several applications inside one root `AppDeployment` and expose the child handles from there, as shown in [Composing Heterogeneous Stacks](composing-stacks.md).
---
## with_app Outside AppHost
`with_app` is defined for every scenario builder, not only `AppHostScenarioBuilder`. On a regular uniform-cluster scenario, an "existing cluster" preset can wrap the outer scenario's deployment and node clients in a typed handle without deploying another resource. The OpenRaft example uses this pattern:
```rust,ignore
// examples/openraft_kv/testing/integration/src/scenario.rs
fn with_existing_openraft_kv_app(app: OpenRaftKvExistingClusterApp) -> Self {
OpenRaftKvScenarioBuilder::with_deployment(app.topology())
.with_app(app)
.with_cluster_observer()
}
```
Here the scenario still manages a uniform OpenRaft cluster, and the app layer just gives workloads a typed `OpenRaftKvCluster` handle over it.
---
## Retrieving Handles in Workloads
Workloads never see the deploy context. They retrieve exposed handles through `AppRunContextExt`, implemented on `RunContext<E>`:
| Method | Returns |
|--------|---------|
| `app::<T>()` | `Option<T>` — default handle for `T`, if exposed |
| `app_named::<T>(name)` | `Option<T>` — named handle for `T` |
| `require_app::<T>()` | `Result<T, DynError>` — errors if missing |
| `require_app_named::<T>(name)` | `Result<T, DynError>` — errors if missing |
```rust,ignore
use testing_framework_app::AppRunContextExt;
async fn start(&self, ctx: &RunContext<AppHostEnv>) -> Result<(), DynError> {
let cluster = ctx.require_app::<LocalAppCluster<KvEnv>>()?;
cluster.restart_node("node-0").await?;
cluster.wait_node_ready("node-0").await?;
Ok(())
}
```
Workloads normally use the `require_*` variants so that a missing handle produces a typed error containing the requested handle type.
Every retrieval clones the handle. Handles are normally small access values backed by `Arc`; scenario cleanup still determines managed resource lifetime.
---
## Where to Go Next
- [AppDeployment and DeployContext](app-deployment.md): implementing the deployment itself.
- [One Binary: LocalProcessApp](local-process-app.md) and [Uniform Child Clusters: LocalAppCluster](local-app-cluster.md): the two built-in building blocks.
- [Backend Scope](app-backend-scope.md): why AppHost scenarios run on the local deployer today.