File: ApplicationModel\TerminalAnnotation.cs
Web Access
Project: src\src\Aspire.Hosting\Aspire.Hosting.csproj (Aspire.Hosting)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
 
namespace Aspire.Hosting.ApplicationModel;
 
#pragma warning disable ASPIRETERMINAL001 // Internal annotation backing the experimental terminal configuration API.
 
/// <summary>
/// Tracks terminal configuration and per-replica terminal hosts for a resource.
/// </summary>
[DebuggerDisplay("Type = {GetType().Name,nq}, IsInitialized = {IsInitialized}, ReplicaCount = {TerminalHosts.Count}")]
internal sealed class TerminalAnnotation : IResourceAnnotation
{
    // Starts as Array.Empty<TerminalHostResource>() (the default for [] in C#) so the
    // TerminalHosts collection is always non-null and safely enumerable, even before
    // BeforeStartEvent has had a chance to materialize the per-replica hosts. Consumers
    // (DCP creators, dashboard data, backchannel) must guard with a Count check or an
    // index-bounds check; all of them already do.
    private IReadOnlyList<TerminalHostResource> _terminalHosts = [];
 
    /// <summary>
    /// Initializes a new instance of the <see cref="TerminalAnnotation"/> class. The
    /// per-replica <see cref="TerminalHostResource"/>s are filled in later via
    /// <see cref="Initialize"/> from a <see cref="BeforeStartEvent"/> handler so the
    /// final <see cref="ReplicaAnnotation"/> count is always honoured.
    /// </summary>
    /// <param name="options">The terminal options for this annotation.</param>
    public TerminalAnnotation(TerminalOptions options)
    {
        ArgumentNullException.ThrowIfNull(options);
 
        Options = options;
    }
 
    /// <summary>
    /// Gets the terminal options for this annotation.
    /// </summary>
    public TerminalOptions Options { get; }
 
    /// <summary>
    /// Gets the hidden per-replica terminal host resources that bridge PTY traffic for
    /// the annotated resource. Indexed by parent replica index (0..N-1 where N is the
    /// parent's replica count at <see cref="BeforeStartEvent"/> time). Empty until
    /// <see cref="Initialize"/> has been called.
    /// </summary>
    public IReadOnlyList<TerminalHostResource> TerminalHosts => _terminalHosts;
 
    /// <summary>
    /// Gets a value indicating whether <see cref="Initialize"/> has been called yet.
    /// Production code initializes during <see cref="BeforeStartEvent"/>; tests that
    /// inspect <see cref="TerminalHosts"/> need to publish that event manually first.
    /// </summary>
    public bool IsInitialized { get; private set; }
 
    /// <summary>
    /// Populates <see cref="TerminalHosts"/> exactly once. Called by the
    /// <see cref="BeforeStartEvent"/> subscriber installed by
    /// <see cref="TerminalResourceBuilderExtensions.WithTerminal{T}(IResourceBuilder{T}, Action{TerminalOptions}?)"/>.
    /// </summary>
    /// <exception cref="InvalidOperationException">Thrown when called more than once.</exception>
    internal void Initialize(IReadOnlyList<TerminalHostResource> terminalHosts)
    {
        ArgumentNullException.ThrowIfNull(terminalHosts);
 
        if (IsInitialized)
        {
            throw new InvalidOperationException("TerminalAnnotation has already been initialized.");
        }
 
        if (terminalHosts.Count == 0)
        {
            throw new ArgumentException("At least one terminal host is required.", nameof(terminalHosts));
        }
 
        for (var i = 0; i < terminalHosts.Count; i++)
        {
            if (terminalHosts[i] is null)
            {
                throw new ArgumentException($"Terminal host at index {i} is null.", nameof(terminalHosts));
            }
        }
 
        _terminalHosts = terminalHosts;
        IsInitialized = true;
    }
}
 
#pragma warning restore ASPIRETERMINAL001
 
/// <summary>
/// Options for configuring a terminal session.
/// </summary>
[Experimental("ASPIRETERMINAL001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public sealed class TerminalOptions
{
    private int _columns = 120;
    private int _rows = 30;
 
    /// <summary>
    /// Gets or sets the initial number of columns for the terminal. The value must be greater than zero. Defaults to 120.
    /// </summary>
    /// <exception cref="ArgumentOutOfRangeException">Thrown when set to zero or a negative value.</exception>
    public int Columns
    {
        get => _columns;
        set
        {
            ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value);
            _columns = value;
        }
    }
 
    /// <summary>
    /// Gets or sets the initial number of rows for the terminal. The value must be greater than zero. Defaults to 30.
    /// </summary>
    /// <exception cref="ArgumentOutOfRangeException">Thrown when set to zero or a negative value.</exception>
    public int Rows
    {
        get => _rows;
        set
        {
            ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value);
            _rows = value;
        }
    }
 
    /// <summary>
    /// Gets or sets a value indicating whether the per-replica terminal host resources
    /// (named <c>{parent}-terminalhost-{index}</c>) should appear in the resource list.
    /// </summary>
    /// <remarks>
    /// Defaults to <c>false</c>: terminal host resources are hidden from the dashboard
    /// and CLI resource list because they are an implementation detail of the
    /// <see cref="TerminalResourceBuilderExtensions.WithTerminal{T}(IResourceBuilder{T}, Action{TerminalOptions}?)"/>
    /// feature, not something the user explicitly added to their app model.
    /// <para>
    /// Set to <c>true</c> when diagnosing terminal-host startup / connectivity issues so
    /// the host's state, exit code, logs, and (eventually) telemetry are visible alongside
    /// the parent resource. This is useful when investigating cases like "DCP never dialed
    /// the producer UDS" or "the host crashed during recycle".
    /// </para>
    /// </remarks>
    public bool ShowTerminalHost { get; set; }
}