Skip to content
Open
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ artifacts/
# Tye
.tye/

# Aspire CLI's local AppHost selection
/aspire.config.json
/tools/ComputeSeparation/AppHost/aspire.config.json

# ASP.NET Scaffolding
ScaffoldingReadMe.txt

Expand Down
4 changes: 4 additions & 0 deletions Azure.Functions.Host.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,8 @@
<Project Path="test/Functions.Rpc.Client.Tests/Functions.Rpc.Client.Tests.csproj" />
<Project Path="test/Functions.WorkerProxy.Tests/Functions.WorkerProxy.Tests.csproj" />
</Folder>
<Folder Name="/tools/ComputeSeparation/">
<Project Path="tools/ComputeSeparation/AppHost/ComputeSeparation.AppHost.csproj" />
<Project Path="tools/ComputeSeparation/SampleIsolatedApp/SampleIsolatedApp.csproj" />
</Folder>
</Solution>
5 changes: 2 additions & 3 deletions eng/ci/templates/jobs/run-linux-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@ jobs:

variables:
localImage: functions-worker-proxy-ci:latest
# This solution will expand as the remaining compute-separation projects are introduced.
solution: Azure.Functions.Host.slnx

steps:
- template: /eng/ci/templates/install-dotnet.yml@self
Expand All @@ -30,7 +28,8 @@ jobs:
inputs:
command: test
arguments: -v m -c release
projects: $(solution)
# Build the product test graphs without bootstrapping the manual Aspire tool.
projects: test/Functions.*.Tests/*.csproj

- script: sudo apt-get update && sudo apt-get install --yes clang zlib1g-dev
displayName: Install Native AOT prerequisites
Expand Down
171 changes: 171 additions & 0 deletions tools/ComputeSeparation/AppHost/ComposeSession.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.

using System.Diagnostics;
using System.Runtime.ExceptionServices;

namespace Azure.Functions.ComputeSeparation.AppHost;

/// <summary>
/// Owns one Compose group's stop and removal lifecycle independently of other groups.
/// </summary>
internal sealed class ComposeSession(
string repositoryRoot,
string composeFile,
string projectName,
IReadOnlyDictionary<string, string> cleanupEnvironment,
bool removeOnStop = false) : IAsyncDisposable
{
public const string ProjectNameVariable = "COMPOSE_PROJECT_NAME";
public const string GenerationVariable = "COMPOSE_RUN_GENERATION";

private readonly Lock _lock = new();
private Task? _stopTask = Task.CompletedTask;
private Task? _disposeTask;
private string _generation = Guid.NewGuid().ToString("N");

public string ComposeFile { get; } = Path.Combine(repositoryRoot, "tools", "ComputeSeparation", composeFile);

public string ProjectName { get; } = projectName;

public string Generation
{
get
{
lock (_lock)
{
return _generation;
}
}
}

public async Task BeforeStartAsync(CancellationToken cancellationToken)
{
Task previousStop;
lock (_lock)
{
ObjectDisposedException.ThrowIf(_disposeTask is not null, this);
previousStop = StopAsync();
}

await previousStop.WaitAsync(cancellationToken);

cancellationToken.ThrowIfCancellationRequested();
lock (_lock)
{
ObjectDisposedException.ThrowIf(_disposeTask is not null, this);
_generation = Guid.NewGuid().ToString("N");
_stopTask = null;
}
}

public Task StopRunAsync(string? generation)
{
lock (_lock)
{
// A stopped snapshot belongs to one run, even if a replacement has already started.
if (!string.Equals(generation, _generation, StringComparison.Ordinal))
{
return Task.CompletedTask;
}

return StopAsync();
}
}

public ValueTask DisposeAsync()
{
lock (_lock)
{
return new(_disposeTask ??= removeOnStop ? StopAsync() : CleanupAsync(StopAsync, RemoveAsync));
}
}

public static async Task CleanupAsync(params Func<Task>[] actions)
{
List<Exception> failures = [];
foreach (Func<Task> action in actions)
{
try
{
await action();
}
catch (Exception exception)
{
failures.Add(exception);
}
}

if (failures.Count == 1)
{
ExceptionDispatchInfo.Capture(failures[0]).Throw();
}
else if (failures.Count > 1)
{
throw new AggregateException("Compose cleanup failed.", failures);
}
}

private Task StopAsync()
{
lock (_lock)
{
if (_stopTask is null || _stopTask.IsFaulted || _stopTask.IsCanceled)
{
// The runtime retains its shared network until all worker pods have been removed.
_stopTask = removeOnStop ? RemoveAsync() : RunAsync(["stop"]);
}

return _stopTask;
}
}

private Task RemoveAsync() => RunAsync(["down", "--remove-orphans", "--rmi", "local"]);

private async Task RunAsync(string[] arguments)
{
ProcessStartInfo startInfo = new("docker")
{
WorkingDirectory = repositoryRoot,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
foreach (string argument in new[] { "compose", "--project-name", ProjectName, "--file", ComposeFile }.Concat(arguments))
{
startInfo.ArgumentList.Add(argument);
}

// Compose validates interpolation even for down; these values never create resources.
foreach ((string key, string value) in cleanupEnvironment)
{
startInfo.Environment[key] = value;
}

using Process process = Process.Start(startInfo) ?? throw new InvalidOperationException("Could not start Docker Compose cleanup.");
Task<string> stdout = process.StandardOutput.ReadToEndAsync();
Task<string> stderr = process.StandardError.ReadToEndAsync();
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(45));
try
{
await process.WaitForExitAsync(timeout.Token);
}
catch (OperationCanceledException) when (timeout.IsCancellationRequested)
{
if (!process.HasExited)
{
process.Kill(entireProcessTree: true);
}

await process.WaitForExitAsync();
throw new TimeoutException($"Docker Compose cleanup timed out for '{ProjectName}'. {await stderr}");
}

string output = await stdout;
string error = await stderr;
if (process.ExitCode != 0)
{
throw new InvalidOperationException($"Docker Compose cleanup failed for '{ProjectName}' ({process.ExitCode}). {error}{output}");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<Project Sdk="Aspire.AppHost.Sdk/13.5.3">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>Azure.Functions.ComputeSeparation.AppHost</RootNamespace>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AspireUseCliBundle>true</AspireUseCliBundle>
<!-- Resolve the matching CLI rather than using an unrelated version installed on PATH. -->
<AspireCliInvocationMode>DnxPinned</AspireCliInvocationMode>
<!-- Launch AppHost directly so the selected VS/dotnet launch profile controls this local session. -->
<ASPIRE_SUPPRESS_CLI_RUN_HOOK>true</ASPIRE_SUPPRESS_CLI_RUN_HOOK>
<AspireCliBundlePath>$([System.IO.Path]::Combine('$(ArtifactsPath)', 'aspire', '$(AspireHostingSDKVersion)', '$(NETCoreSdkRuntimeIdentifier)'))</AspireCliBundlePath>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Aspire.Hosting" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\..\src\Functions.Host\Functions.Host.csproj" />
<ProjectReference Include="..\..\..\src\Functions.WorkerProxy\Functions.WorkerProxy.csproj" />
<ProjectReference Include="..\SampleIsolatedApp\SampleIsolatedApp.csproj" />
</ItemGroup>

<!-- Bundle discovery accepts older installations, so keep the paired runtime tooling in this worktree's build output. -->
<Target Name="PrepareAspireCliBundle"
BeforeTargets="ResolveAspireCliBundlePaths"
Condition="'$(DesignTimeBuild)' != 'true' and !Exists('$(AspireCliBundlePath)/.aspire-bundle-version')">
<Exec Command="dotnet dnx --yes aspire.cli@$(AspireHostingSDKVersion) -- setup --install-path &quot;$(AspireCliBundlePath)&quot;"
Timeout="240000" />
</Target>

</Project>
51 changes: 51 additions & 0 deletions tools/ComputeSeparation/AppHost/ContainerTopology.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.

namespace Azure.Functions.ComputeSeparation.AppHost;

/// <summary>
/// Owns the runtime and its default worker pod, removing the pod before the runtime's network.
/// </summary>
internal sealed class ContainerTopology : IAsyncDisposable
{
private readonly Lock _disposeLock = new();
private Task? _disposeTask;

public ContainerTopology(string repositoryRoot)
{
string prefix = $"functions-aspire-{Guid.NewGuid():N}";
NetworkName = $"{prefix}-network";
ProxyAlias = $"{prefix}-proxy-1";
Runtime = new(repositoryRoot, "runtime.compose.yaml", $"{prefix}-runtime", new Dictionary<string, string>
{
["COMPUTE_NETWORK_NAME"] = NetworkName,
["COMPUTE_HOST_PORT"] = "0"
});
WorkerPod = new(repositoryRoot, "worker-pod.compose.yaml", $"{prefix}-worker-1", new Dictionary<string, string>
{
["COMPUTE_NETWORK_NAME"] = NetworkName,
["WORKER_PROXY_ALIAS"] = ProxyAlias,
["WORKER_PROXY_MANAGEMENT_PORT"] = "0",
["WORKER_ID"] = "cleanup",
["WORKER_REQUEST_ID"] = "cleanup"
}, removeOnStop: true);
}

public string NetworkName { get; }

public string ProxyAlias { get; }

public ComposeSession Runtime { get; }

public ComposeSession WorkerPod { get; }

public ValueTask DisposeAsync()
{
lock (_disposeLock)
{
return new(_disposeTask ??= ComposeSession.CleanupAsync(
() => WorkerPod.DisposeAsync().AsTask(),
() => Runtime.DisposeAsync().AsTask()));
}
}
}
33 changes: 33 additions & 0 deletions tools/ComputeSeparation/AppHost/HarnessRunDirectory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.

namespace Azure.Functions.ComputeSeparation.AppHost;

/// <summary>
/// Owns an empty script root, logs, and Host-generated file secrets for one local run.
/// </summary>
internal sealed class HarnessRunDirectory : IDisposable
{
private readonly DirectoryInfo _directory = Directory.CreateTempSubdirectory("functions-aspire-");

public HarnessRunDirectory()
{
ScriptPath = Path.Combine(_directory.FullName, "app");
LogPath = Path.Combine(_directory.FullName, "logs");
SecretsPath = Path.Combine(_directory.FullName, "secrets");
Directory.CreateDirectory(ScriptPath);
Directory.CreateDirectory(LogPath);
Directory.CreateDirectory(SecretsPath);
}

public string ScriptPath { get; }

public string LogPath { get; }

public string SecretsPath { get; }

public void Dispose()
{
_directory.Delete(recursive: true);
}
}
Loading
Loading