Skip to content
12 changes: 11 additions & 1 deletion docs/docs/examples/aspire.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,19 @@ public class AppFixture : AspireFixture<Projects.MyAppHost>
```

:::tip
Resources are removed by exact name (case-sensitive) after the builder is created but before the app is built, so they never start. Unrecognised names are silently ignored.
Resources are removed by exact name (case-sensitive) after the builder is created but before the app is built, so they never start. Unrecognised names are skipped with a log message.
:::

Removed resources can still have dependents. TUnit detaches the kept resources from them, so the kept resources start without them:

- Child resources of a removed resource (for example a database on a removed server) are removed too.
- `WaitFor` and `WaitForCompletion` on a removed resource are dropped.
- Environment variables and arguments that reference a removed resource are not set. This covers `WithReference` (endpoints and connection strings) and endpoint values passed to `WithEnvironment` or `WithArgs`.
- A value that combines a removed resource with other parts, such as a URL built from a kept and a removed endpoint, is dropped whole, because it cannot be resolved.
- If a dropped argument follows an option name such as `--optional-url` added by the same `WithArgs` call, the option name is dropped too, so the next argument does not become its value. Arguments after a `--` separator are never paired. To keep a standalone flag that comes just before a referenced value, add the flag with a separate `WithArgs` call.

Each change is written to the fixture's progress log. The kept resource must work without the removed dependency. For example, it must not require a connection string that is no longer set.

### Timeouts

```csharp
Expand Down
27 changes: 10 additions & 17 deletions src/TUnit.Aspire.Core/AspireFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,12 @@ protected virtual void ConfigureBuilder(IDistributedApplicationTestingBuilder bu

/// <summary>
/// Resources to be removed from the <see cref="DistributedApplicationTestingBuilder"/>.
/// <para>
/// Children of a removed resource (for example a database on a removed server) are removed too.
/// Kept resources lose their <c>WaitFor</c> edges on removed resources, and environment variables
/// or arguments that reference a removed resource (from <c>WithReference</c>, endpoint or
/// connection-string values) are not set. Each change is logged through <see cref="LogProgress"/>.
/// </para>
/// </summary>
protected virtual IEnumerable<string> ResourcesToRemove() => [];

Expand Down Expand Up @@ -678,6 +684,9 @@ private async Task InitializeCoreAsync()
LogProgress($"Creating distributed application builder for {typeof(TAppHost).Name}...");
var builder = await DistributedApplicationTestingBuilder.CreateAsync<TAppHost>(Args, ConfigureAppHost, RunCancellationToken);
ConfigureBuilder(builder);

// After ConfigureBuilder so edges the user adds are detached too. Removal wraps the
// environment and args callbacks present now; ones added later are not filtered.
RemoveResources(builder);

// Configure OTLP endpoint on project resources AFTER user's ConfigureBuilder
Expand Down Expand Up @@ -768,23 +777,7 @@ await BuildDiagnosticsAndAttachAsync(_app, notificationService, headline,
}

private void RemoveResources(IDistributedApplicationTestingBuilder builder)
{
foreach (var name in ResourcesToRemove())
{
var resource =
builder.Resources.SingleOrDefault(r =>
string.Equals(r.Name, name, StringComparison.Ordinal));

if (resource is not null)
{
builder.Resources.Remove(resource);
}
else
{
LogProgress($"ResourcesToRemove: resource '{name}' not found (skipped).");
}
}
}
=> ResourceRemoval.Remove(builder.Resources, ResourcesToRemove(), LogProgress);

/// <summary>
/// Disposes the Aspire distributed application. Override to add custom cleanup:
Expand Down
229 changes: 229 additions & 0 deletions src/TUnit.Aspire.Core/ResourceRemoval.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
using Aspire.Hosting.ApplicationModel;

namespace TUnit.Aspire;

/// <summary>
/// Removes resources from an Aspire app model and detaches the edges that kept resources still
/// hold on them. Removing a resource from the model alone leaves <c>WaitFor</c> and
/// <c>WithReference</c> edges behind: a kept resource then waits forever for a resource that never
/// starts, or blocks resolving an endpoint or connection string that is never allocated (#7284).
/// </summary>
internal static class ResourceRemoval
{
// Bounds recursion through nested ReferenceExpressions; real value graphs are a few levels deep.
private const int MaxReferenceDepth = 32;

public static void Remove(IList<IResource> resources, IEnumerable<string> names, Action<string> log)
{
var removed = new HashSet<IResource>(ReferenceEqualityComparer.Instance);

foreach (var name in names)
{
var resource = resources.SingleOrDefault(r => string.Equals(r.Name, name, StringComparison.Ordinal));

if (resource is not null)
{
removed.Add(resource);
}
else
{
log($"ResourcesToRemove: resource '{name}' not found (skipped).");
}
}

if (removed.Count == 0)
{
return;
}

AddOrphanedChildren(resources, removed, log);

foreach (var resource in removed)
{
resources.Remove(resource);
}

foreach (var resource in resources)
{
DetachEdges(resource, removed, log);
}
}

/// <summary>
/// A child (for example a database on a removed server) cannot run without its parent, so it
/// goes too. Repeats until stable so grandchildren follow.
/// </summary>
private static void AddOrphanedChildren(IList<IResource> resources, HashSet<IResource> removed, Action<string> log)
{
bool added;
do
{
added = false;
foreach (var resource in resources)
{
if (resource is IResourceWithParent child
&& removed.Contains(child.Parent)
&& removed.Add(resource))
{
log($"ResourcesToRemove: also removing '{resource.Name}' because its parent '{child.Parent.Name}' is removed.");
added = true;
}
}
} while (added);
}

private static void DetachEdges(IResource resource, HashSet<IResource> removed, Action<string> log)
{
var annotations = resource.Annotations;

for (var i = annotations.Count - 1; i >= 0; i--)
{
switch (annotations[i])
{
case WaitAnnotation wait when removed.Contains(wait.Resource):
annotations.RemoveAt(i);
log($"ResourcesToRemove: dropped wait on removed resource '{wait.Resource.Name}' from '{resource.Name}'.");
break;
case EndpointReferenceAnnotation endpointReference when removed.Contains(endpointReference.Resource):
annotations.RemoveAt(i);
log($"ResourcesToRemove: dropped endpoint reference to removed resource '{endpointReference.Resource.Name}' from '{resource.Name}'.");
break;
case ResourceRelationshipAnnotation relationship when removed.Contains(relationship.Resource):
annotations.RemoveAt(i);
log($"ResourcesToRemove: dropped relationship to removed resource '{relationship.Resource.Name}' from '{resource.Name}'.");
break;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
case EnvironmentCallbackAnnotation environment:
annotations[i] = WrapEnvironmentCallback(environment, resource, removed, log);
break;
case CommandLineArgsCallbackAnnotation args:
annotations[i] = WrapArgsCallback(args, resource, removed, log);
break;
}
}
}

/// <summary>
/// <c>WithReference</c> and <c>WithEnvironment</c> callbacks capture the removed resource in a
/// closure, so they cannot be detached by annotation. Run them, then drop each value that
/// references a removed resource before Aspire tries to resolve it.
/// </summary>
private static EnvironmentCallbackAnnotation WrapEnvironmentCallback(
EnvironmentCallbackAnnotation original, IResource resource, HashSet<IResource> removed, Action<string> log)
{
return new EnvironmentCallbackAnnotation(async context =>
{
await original.Callback(context).ConfigureAwait(false);

List<string>? toRemove = null;
foreach (var (key, value) in context.EnvironmentVariables)
{
if (ReferencesRemoved(value, removed, 0))
{
(toRemove ??= []).Add(key);
}
}

if (toRemove is null)
{
return;
}

foreach (var key in toRemove)
{
context.EnvironmentVariables.Remove(key);
log($"ResourcesToRemove: dropped environment variable '{key}' from '{resource.Name}' because it references a removed resource.");
}
});
}

private static CommandLineArgsCallbackAnnotation WrapArgsCallback(
CommandLineArgsCallbackAnnotation original, IResource resource, HashSet<IResource> removed, Action<string> log)
{
return new CommandLineArgsCallbackAnnotation(async context =>
{
// A callback can replace or rebuild earlier arguments, so the whole list is scanned. An option is
// only paired with a value when this callback added it: an argument object that was in the list
// before the callback ran belongs to an earlier WithArgs call, wherever it now sits.
var before = new HashSet<object>(context.Args, ReferenceEqualityComparer.Instance);
await original.Callback(context).ConfigureAwait(false);

for (var i = context.Args.Count - 1; i >= 0; i--)
{
var argument = context.Args[i];
if (!ReferencesRemoved(argument, removed, 0))
{
continue;
}

context.Args.RemoveAt(i);

// "--name value": dropping only the value would make the next argument the option's value.
// A dropped "--name=value" is an option itself, so it is never the previous option's value,
// and after a "--" separator every argument is positional.
if (i > 0
&& !before.Contains(context.Args[i - 1])
Comment thread
thomhurst marked this conversation as resolved.
Resolved
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
&& !IsOption(argument)
&& IsOptionName(context.Args[i - 1])
&& !HasSeparatorBefore(context.Args, i - 1))
{
var option = (string)context.Args[i - 1];
context.Args.RemoveAt(i - 1);
i--;
log($"ResourcesToRemove: dropped argument '{option}' and its value from '{resource.Name}' because the value references a removed resource.");
}
else
{
log($"ResourcesToRemove: dropped argument {i} from '{resource.Name}' because it references a removed resource.");
}
}
});
}

// "--url=value" is one argument and is dropped whole, so only a bare option name pairs with the next argument.
private static bool IsOptionName(object? argument)
=> argument is string { Length: > 1 } text && text[0] == '-' && text != "--" && !text.Contains('=');

private static bool HasSeparatorBefore(IList<object> args, int index)
{
for (var i = 0; i < index; i++)
{
if (args[i] is "--")
{
return true;
}
}

return false;
}

private static bool IsOption(object? argument) => argument switch
{
string text => text.StartsWith('-'),
ReferenceExpression expression => expression.Format.StartsWith('-'),
_ => false,
};

private static bool ReferencesRemoved(object? value, HashSet<IResource> removed, int depth)
{
switch (value)
{
// Stop at resources: a kept resource is valid even if it was built from other values.
case IResource referenced:
return removed.Contains(referenced);
case ConnectionStringReference connectionString when removed.Contains(connectionString.Resource):
return true;
case IValueWithReferences withReferences when depth < MaxReferenceDepth:
foreach (var reference in withReferences.References)
{
if (ReferencesRemoved(reference, removed, depth + 1))
{
return true;
}
}

return false;
default:
return false;
}
}
}
Loading
Loading