| File: Cli\NativeCliProtocol.cs | Web Access |
| Project: src\src\Aspire.Hosting.Native.Server\Aspire.Hosting.Native.Server.csproj (Aspire.Hosting.Native.Server) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Net.Sockets; using System.Runtime.CompilerServices; using System.Text.Json; using Aspire.Hosting.Native.Api; using Aspire.Hosting.Native.Server; using Aspire.Hosting.Native.Runtime; using Aspire.Hosting.Native.Rpc; using Aspire.Hosting.Native.Transport; using Aspire.Shared; namespace Aspire.Hosting.Native.Cli; /// <summary>Implements the existing CLI run backchannel without a native-specific client.</summary> internal sealed class NativeCliProtocol : IDisposable { private readonly Socket _listener; private readonly NativeApplicationServer _server; private readonly Action _stop; private readonly string _path; private readonly Func<CancellationToken, Task<NativeCliDashboardUrls>> _dashboard; private readonly NativeCliLogBuffer _logs; private readonly NativeServerOptions _options; private readonly INativeBackchannelRpc _rpc; private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously); private int _disposed; public NativeCliProtocol(string path, NativeApplicationServer server, Action stop, Func<CancellationToken, Task<NativeCliDashboardUrls>> dashboard, NativeCliLogBuffer logs, NativeServerOptions options, INativeBackchannelRpc rpc) { if (!Path.IsPathFullyQualified(path) || File.Exists(path)) { throw new ArgumentException("The backchannel requires a fresh absolute socket path.", nameof(path)); } _path = path; _server = server; _stop = stop; _dashboard = dashboard; _logs = logs; options.Validate(); _options = options; _rpc = rpc; _listener = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); try { SocketPermissionHelper.Bind(_listener, path); _listener.Listen(options.MaximumConnections); } catch { // Bind can succeed before the helper fails to set socket permissions. var bound = _listener.IsBound; _listener.Dispose(); if (bound) { File.Delete(path); } throw; } } internal bool IsAppHostReady => _ready.Task.IsCompletedSuccessfully; internal Task WaitForAppHostReadyAsync(CancellationToken cancellationToken) => _ready.Task.WaitAsync(cancellationToken); public Task RunAsync(CancellationToken cancellationToken) => SocketConnectionListener.RunAsync(_listener, _options.MaximumConnections, (stream, token) => _rpc.ServeAsync(stream, DispatchAsync, token), cancellationToken); private async Task<NativeBackchannelReply> DispatchAsync(NativeCliRequest request, CancellationToken cancellationToken) { if (request.Method == "GetPipelineStepsAsync") { // The existing CLI sends [{ "step": "deploy", "traceContext": ... }]. // A null step requests the complete topologically ordered pipeline. if (request.Params is not { ValueKind: JsonValueKind.Array } arguments || arguments.GetArrayLength() != 1 || arguments[0].ValueKind != JsonValueKind.Object) { throw new ArgumentException("Pipeline inspection requires one request object."); } var target = arguments[0].TryGetProperty("step", out var step) && step.ValueKind != JsonValueKind.Null ? step.GetString() : null; var steps = await _server.Publishing.GetStepsAsync(target, cancellationToken).ConfigureAwait(false); return NativeBackchannelReply.Value(new NativePipelineSteps(steps.Select(step => new NativePipelineStep(step.Name, step.DependsOn, step.Description, step.Tags, step.ResourceName)).ToArray()), NativeCliJsonContext.Default.NativePipelineSteps); } if (request.Params is { ValueKind: not JsonValueKind.Null } parameters && (parameters.ValueKind != JsonValueKind.Array || parameters.GetArrayLength() != 0)) { throw new ArgumentException("This CLI method takes no arguments."); } switch (request.Method) { case "GetCapabilitiesAsync": return NativeBackchannelReply.Value(new[] { "baseline.v2", "pipeline-steps.v2" }, NativeCliJsonContext.Default.StringArray); case "NotifyAppHostReadyAsync": _ready.TrySetResult(); return NativeBackchannelReply.Value((string?)null, NativeCliJsonContext.Default.String); case "RequestStopAsync": return NativeBackchannelReply.Value((string?)null, NativeCliJsonContext.Default.String) with { // Publishing guests must remain alive until the CLI consumes // completion. Their exit then owns normal session shutdown. AfterResponse = _server.Publishing.WaitsForStop ? _server.Publishing.Stop : _stop }; case "GetPublishingActivitiesAsync": return NativeBackchannelReply.Streaming(_server.Publishing.ReadAsync(cancellationToken), NativeCliJsonContext.Default.NativePublishingActivity); case "GetDashboardUrlsAsync": return NativeBackchannelReply.Value(await _dashboard(cancellationToken).ConfigureAwait(false), NativeCliJsonContext.Default.NativeCliDashboardUrls); case "GetResourceStatesAsync": return NativeBackchannelReply.Streaming(ResourceStatesAsync(cancellationToken), NativeCliJsonContext.Default.NativeCliResourceState); case "GetAppHostLogEntriesAsync": return NativeBackchannelReply.Streaming(_logs.ReadAsync(cancellationToken), NativeCliJsonContext.Default.NativeCliLogEntry); default: throw new MissingMethodException(); } } private async IAsyncEnumerable<NativeCliResourceState> ResourceStatesAsync([EnumeratorCancellation] CancellationToken cancellationToken) { ApplicationObserver? previous = null; long version = -1; while (true) { cancellationToken.ThrowIfCancellationRequested(); var observer = _server.GetApplicationObserver(); if (observer is null) { previous = null; version = -1; await Task.Delay(_options.RetryInterval, _options.TimeProvider, cancellationToken).ConfigureAwait(false); continue; } RuntimeSnapshot snapshot; try { snapshot = observer.ReadNativeObservations(); if (ReferenceEquals(previous, observer) && snapshot.Version == version) { var interval = Math.Max((int)_options.Runtime.ObservationInterval.TotalMilliseconds, 1); await observer.WaitResourceObservations(version, interval).WaitAsync(cancellationToken).ConfigureAwait(false); continue; } } catch (ObjectDisposedException) { continue; } previous = observer; version = snapshot.Version; foreach (var resource in snapshot.Resources) { yield return new NativeCliResourceState(resource.Name, resource.TypeId, resource.State.ToWire(), resource.Urls.ToArray(), resource.Healthy ? "Healthy" : "Unhealthy"); } } } public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) != 0) { return; } _listener.Dispose(); File.Delete(_path); } }