// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Collections.Immutable;
using System.Security.Cryptography;
using Aspire.Hosting.Native.Authoring;
using Aspire.Hosting.Native.Runtime;
namespace Aspire.Hosting.Native.Api;
/// <summary>Provides application creation and explicitly delegated connection entry points.</summary>
[AspireExport]
internal sealed partial class NativeApplicationServer
{
private readonly RuntimeInvitations _invitations;
internal NativeApplicationLifetime ApplicationLifetime { get; } = new();
public NativeApplicationServer() : this(UnavailableWorkloadExecutor.Instance)
{
}
public NativeApplicationServer(IWorkloadExecutor executor) : this(executor, new())
{
}
public NativeApplicationServer(IWorkloadExecutor executor, NativeRuntimeOptions options)
{
options.Validate();
_invitations = new(executor, options);
}
/// <summary>Opens an application session owned by this connection.</summary>
[AspireExport]
public CompositionSession OpenApplication() => new(_invitations);
/// <summary>Opens a directly RPC-backed run-mode authoring builder.</summary>
[AspireExport]
public RpcBuilder CreateRpcBuilder() => new(OpenApplication());
/// <summary>Owns a connection-scoped run lifetime that drains before host shutdown.</summary>
[AspireExport]
public ApplicationRunLifetime CreateApplicationLifetime() => ApplicationLifetime.CreateRun();
/// <summary>Creates a server-owned workspace whose runtime survives AppHost disconnection.</summary>
[AspireExport]
public ApplicationWorkspace CreateApplicationWorkspace() => _invitations.CreateWorkspace();
/// <summary>Resumes a workspace through a one-use invitation, never a caller-supplied resource name.</summary>
[AspireExport]
public ApplicationWorkspace JoinApplicationWorkspace(string invitation) => _invitations.Claim<ApplicationWorkspace>(invitation);
/// <summary>Redeems a one-use invitation to own one resource execution.</summary>
[AspireExport]
public ResourceExecution JoinResourceExecution(string invitation) => _invitations.Claim<ResourceExecution>(invitation);
/// <summary>Redeems a one-use invitation to observe and interact with one application execution.</summary>
[AspireExport]
public ApplicationObserver JoinApplicationObserver(string invitation) => _invitations.Claim<ApplicationObserver>(invitation);
internal void AttachProviderLifecycleDispatcher(IProviderLifecycleDispatcher dispatcher) =>
_invitations.Options.ProviderLifecycleDispatcher = dispatcher;
internal void Close() => _invitations.Close();
internal Task DrainAsync() => _invitations.DrainAsync();
internal ApplicationObserver? GetApplicationObserver() => _invitations.GetApplicationObserver();
}
/// <summary>Controls delegation within one sealed application generation.</summary>
[AspireExport]
internal sealed class ApplicationExecution : ICapabilityLifetime
{
private readonly Composition _composition;
private readonly RuntimeInvitations _invitations;
internal RuntimeGeneration Runtime { get; }
public bool IsRevoked => _composition.IsRetired || Runtime.IsRetired;
internal ApplicationExecution(Composition composition, RuntimeInvitations invitations)
{
_composition = composition;
_invitations = invitations;
Runtime = new RuntimeGeneration(composition.Model.Seal(), invitations.Executor, invitations.Options);
invitations.Track(Runtime);
}
internal ApplicationExecution(Composition composition, RuntimeInvitations invitations, RuntimeGeneration runtime)
{
_composition = composition;
_invitations = invitations;
Runtime = runtime;
}
/// <summary>Delegates exclusive execution authority for this declaration to an integration connection.</summary>
[AspireExport]
public string InviteResourceExecution(DeclaredResource resource)
{
if (!ReferenceEquals(_composition, resource.Composition))
{
throw new ArgumentException("The resource belongs to another composition.", nameof(resource));
}
ObjectDisposedException.ThrowIf(IsRevoked, this);
var declaration = resource.Composition.Model.ReadResource(resource.Handle);
var handle = Runtime.ResolveResource(declaration.Name, declaration.TypeId);
return _invitations.Issue(this, () => new ResourceExecution(Runtime.ClaimResource(handle)));
}
/// <summary>Delegates observation, command invocation, and interaction responses for this execution.</summary>
[AspireExport]
public string InviteApplicationObserver() => _invitations.Issue(this, () => new ApplicationObserver(Runtime));
/// <summary>Reads the started application's authoritative readiness without issuing an observer invitation.</summary>
[AspireExport]
public ApplicationObservations ReadApplicationState() => ApplicationObserver.Copy(Runtime.Read());
/// <summary>Awaits native dependency and protocol readiness with an explicit bounded deadline.</summary>
[AspireExport]
public Task WaitApplicationReady(int timeoutMilliseconds) => Runtime.WaitDeclaredApplicationAsync(timeoutMilliseconds);
/// <summary>Awaits a declared one-shot resource's terminal state; false explicitly reports failure.</summary>
[AspireExport]
public Task<bool> WaitResourceCompletion(DeclaredResource resource, int timeoutMilliseconds)
{
if (!ReferenceEquals(_composition, resource.Composition))
{
throw new ArgumentException("The resource belongs to another composition.", nameof(resource));
}
var declaration = resource.Composition.Model.ReadResource(resource.Handle);
var runtimeHandle = Runtime.ResolveResource(declaration.Name, declaration.TypeId);
return Runtime.WaitDeclaredResourceCompletionAsync(runtimeHandle.ResourceId, timeoutMilliseconds);
}
internal void Retire() => Runtime.Dispose();
}
/// <summary>Supplies resource-scoped runtime capabilities directly to an integration.</summary>
[AspireExport]
internal sealed partial class ResourceExecution(RuntimeResourceOwner owner) : ICapabilityLifetime, ICapabilityOwner
{
public bool IsRevoked => owner.IsRevoked;
/// <summary>Starts a standard container through the server-owned workload executor.</summary>
[AspireExport]
public async Task<StartedWorkload> StartContainer(ContainerLaunch container)
{
var endpoint = await owner.StartWorkload(ContainerPlan(container)).ConfigureAwait(false);
return new StartedWorkload { Host = endpoint.Host, Port = endpoint.Port, InstanceId = endpoint.InstanceId };
}
/// <summary>Replaces this resource's container only after its previous workload has been removed.</summary>
[AspireExport]
public async Task<StartedWorkload> RestartContainer(long revision, ContainerLaunch container)
{
var endpoint = await owner.ReplaceWorkloadAsync(revision, ContainerPlan(container)).ConfigureAwait(false);
return new StartedWorkload { Host = endpoint.Host, Port = endpoint.Port, InstanceId = endpoint.InstanceId };
}
private static ContainerWorkload ContainerPlan(ContainerLaunch container)
{
ArgumentNullException.ThrowIfNull(container);
ValidateLaunch(container.Environment, container.Arguments);
ArgumentException.ThrowIfNullOrWhiteSpace(container.Image);
if (container.Image.Length > 2048 || container.TargetPort is < 0 or > 65535)
{
throw new ArgumentException("Invalid container launch.");
}
return new ContainerWorkload(container.Image, container.TargetPort,
container.Environment.Select(variable => new WorkloadEnvironment(variable.Name, variable.Value)).ToImmutableArray(),
container.Arguments.ToImmutableArray());
}
/// <summary>Starts a standard executable through the server-owned workload executor.</summary>
[AspireExport]
public async Task<StartedWorkload> StartExecutable(ExecutableLaunch executable)
{
var endpoint = await owner.StartWorkload(ExecutablePlan(executable)).ConfigureAwait(false);
return new StartedWorkload { Host = endpoint.Host, Port = endpoint.Port, InstanceId = endpoint.InstanceId };
}
/// <summary>Replaces this resource's executable only after its previous workload has been removed.</summary>
[AspireExport]
public async Task<StartedWorkload> RestartExecutable(long revision, ExecutableLaunch executable)
{
var endpoint = await owner.ReplaceWorkloadAsync(revision, ExecutablePlan(executable)).ConfigureAwait(false);
return new StartedWorkload { Host = endpoint.Host, Port = endpoint.Port, InstanceId = endpoint.InstanceId };
}
private static ExecutableWorkload ExecutablePlan(ExecutableLaunch executable)
{
ArgumentNullException.ThrowIfNull(executable);
ValidateLaunch(executable.Environment, executable.Arguments);
if (!Path.IsPathFullyQualified(executable.ExecutablePath) || !File.Exists(executable.ExecutablePath) ||
!Path.IsPathFullyQualified(executable.WorkingDirectory) || !Directory.Exists(executable.WorkingDirectory) ||
executable.PortEnvironmentVariable is null || executable.PortEnvironmentVariable.Length > 128 ||
executable.PortEnvironmentVariable.Contains('=') ||
executable.Environment.Any(variable => variable.Name == executable.PortEnvironmentVariable))
{
throw new ArgumentException("Executables require an existing absolute executable path and working directory.");
}
return new ExecutableWorkload(executable.ExecutablePath, executable.WorkingDirectory, executable.PortEnvironmentVariable,
executable.Environment.Select(variable => new WorkloadEnvironment(variable.Name, variable.Value)).ToImmutableArray(),
executable.Arguments.ToImmutableArray());
}
private static void ValidateLaunch(LaunchEnvironment[] environment, string[] arguments)
{
ArgumentNullException.ThrowIfNull(environment);
ArgumentNullException.ThrowIfNull(arguments);
if (environment.Length > 64 || arguments.Length > 128 ||
arguments.Any(argument => argument is null || argument.Length > 8192) ||
environment.Any(variable => variable is null || string.IsNullOrEmpty(variable.Name) ||
variable.Name.Length > 128 || variable.Name.Contains('=') ||
variable.Value is null || variable.Value.Length > 8192) ||
environment.Select(variable => variable.Name).Distinct(StringComparer.Ordinal).Count() != environment.Length)
{
throw new ArgumentException("Invalid workload environment or arguments.");
}
}
/// <summary>Publishes the observed lifecycle, health, and URLs for this resource execution.</summary>
[AspireExport]
public void PublishObservation(ResourceObservation observation)
{
ArgumentNullException.ThrowIfNull(observation);
ArgumentNullException.ThrowIfNull(observation.State);
ArgumentNullException.ThrowIfNull(observation.Urls);
owner.Publish(RuntimeStates.ParseObservation(observation.State), observation.Healthy, observation.Urls.ToImmutableArray());
}
/// <summary>Appends an ordered resource console log entry.</summary>
[AspireExport]
public void AppendResourceLog(string stream, string message) => owner.AppendLog(RuntimeStates.ParseLogStream(stream), message);
/// <summary>Reads the desired configuration revision, dependencies, and application status for this execution.</summary>
[AspireExport]
public ResourceConfigurationRevision ReadResourceConfiguration() => CopyConfiguration(owner.ReadConfiguration());
/// <summary>Reads observations only for this resource's declared readiness dependencies.</summary>
[AspireExport]
public ApplicationObservations ReadResourceDependencies() => ApplicationObserver.Copy(owner.ReadDependencies());
/// <summary>Waits for a newer desired configuration or a bounded timeout without blocking other RPC work.</summary>
[AspireExport]
public async Task<ResourceConfigurationRevision> WaitResourceConfiguration(long revision, int timeoutMilliseconds) =>
CopyConfiguration(await owner.WaitConfigurationAsync(revision, timeoutMilliseconds).ConfigureAwait(false));
/// <summary>Acknowledges the current desired configuration or records an explicit integration failure.</summary>
[AspireExport]
public void CompleteResourceConfiguration(long revision, RuntimeOperationResult result)
{
ArgumentNullException.ThrowIfNull(result);
owner.CompleteConfiguration(revision, RuntimeStates.ParseConfigurationResult(result.Status), result.Message);
}
private static ResourceConfigurationRevision CopyConfiguration(RuntimeConfigurationRevision configuration) => new()
{
Revision = configuration.Revision,
AppliedRevision = configuration.AppliedRevision,
Status = configuration.Status.ToWire(),
Message = configuration.Message,
Properties = configuration.Properties.Select(entry =>
new ResourceConfigurationProperty { Name = entry.Name, Value = entry.Value }).ToArray(),
Dependencies = configuration.Dependencies.Select(handle => handle.ResourceId.ToString()).ToArray()
};
/// <summary>Registers a command that this integration can execute.</summary>
[AspireExport]
public void DefineResourceCommand(string name, string displayName) => owner.DefineCommand(name, displayName);
/// <summary>Reads outstanding command invocations authorized for this resource.</summary>
[AspireExport]
public PendingResourceCommands ReadResourceCommands() => new()
{
Commands = owner.ReadCommands().Select(command => new ResourceCommandRequest
{
RequestId = command.Id.ToString(),
Name = command.Name
}).ToArray()
};
/// <summary>Waits for command work or a bounded timeout without blocking resource observations.</summary>
[AspireExport]
public async Task<PendingResourceCommands> WaitResourceCommands(int timeoutMilliseconds) => new()
{
Commands = (await owner.WaitCommandsAsync(timeoutMilliseconds).ConfigureAwait(false)).Select(command => new ResourceCommandRequest
{
RequestId = command.Id.ToString(),
Name = command.Name
}).ToArray()
};
/// <summary>Completes a command owned by this resource execution; request IDs alone confer no authority.</summary>
[AspireExport]
public void CompleteResourceCommand(string requestId, RuntimeOperationResult result)
{
ArgumentNullException.ThrowIfNull(result);
ArgumentNullException.ThrowIfNull(result.Status);
ArgumentNullException.ThrowIfNull(result.Message);
owner.CompleteCommand(Guid.Parse(requestId), RuntimeStates.ParseCommandResult(result.Status), result.Message);
}
/// <summary>Requests user confirmation without holding the resource execution or RPC dispatch gate.</summary>
[AspireExport]
public RuntimeOperation RequestConfirmation(string message) => new(owner.RequestInteraction(message), this);
public void Close() => owner.Dispose();
}
/// <summary>Projects execution observations and routes user actions without granting integration writer authority.</summary>
[AspireExport]
internal sealed class ApplicationObserver(RuntimeGeneration runtime) : ICapabilityLifetime
{
public bool IsRevoked => runtime.IsRetired;
internal DateTimeOffset CreatedAt => runtime.CreatedAt;
internal RuntimeSnapshot ReadNativeObservations() => runtime.Read();
internal RuntimeLogBatch ReadNativeLogs(Guid resourceId, long afterSequence) => runtime.ReadLogs(resourceId, afterSequence);
/// <summary>Reads the current immutable resource observation view.</summary>
[AspireExport]
public ApplicationObservations ReadResourceObservations() => Copy(runtime.Read());
/// <summary>Waits for a newer observation version or a bounded timeout.</summary>
[AspireExport]
public async Task<ApplicationObservations> WaitResourceObservations(long version, int timeoutMilliseconds) =>
Copy(await runtime.WaitForChangeAsync(version, timeoutMilliseconds).ConfigureAwait(false));
/// <summary>Reads ordered console logs with an explicit indication when retained history has been truncated.</summary>
[AspireExport]
public ResourceLogs ReadResourceLogs(string resourceId, long afterSequence)
{
var batch = runtime.ReadLogs(Guid.Parse(resourceId), afterSequence);
return new ResourceLogs
{
LastSequence = batch.LastSequence,
Truncated = batch.Truncated,
Entries = batch.Entries.Select(line => new ResourceLog
{
Sequence = line.Sequence,
Timestamp = line.Timestamp.ToString("O"),
Stream = line.Stream.ToWire(),
Message = line.Message
}).ToArray()
};
}
/// <summary>Requests execution of a registered resource command.</summary>
[AspireExport]
public RuntimeOperation InvokeResourceCommand(string resourceName, string commandName) =>
new(runtime.RequestCommand(resourceName, commandName), this);
/// <summary>Reads pending user-confirmation requests for this application execution.</summary>
[AspireExport]
public PendingConfirmations ReadConfirmations() => new()
{
Requests = runtime.ReadInteractions().Select(prompt => new ConfirmationRequest
{
RequestId = prompt.Id.ToString(),
ResourceName = prompt.ResourceName,
Message = prompt.Message
}).ToArray()
};
/// <summary>Responds once to a pending confirmation within this application execution.</summary>
[AspireExport]
public void RespondConfirmation(string requestId, bool accepted) => runtime.RespondInteraction(Guid.Parse(requestId), accepted);
internal static ApplicationObservations Copy(RuntimeSnapshot snapshot) => new()
{
GenerationId = snapshot.GenerationId.ToString(),
Version = snapshot.Version,
Resources = snapshot.Resources.Select(resource => new ResourceObservationSnapshot
{
ResourceId = resource.ResourceId.ToString(),
Name = resource.Name,
TypeId = resource.TypeId,
State = resource.State.ToWire(),
Healthy = resource.Healthy,
Urls = resource.Urls.ToArray(),
ConfigurationRevision = resource.ConfigurationRevision,
AppliedConfigurationRevision = resource.AppliedConfigurationRevision,
ConfigurationStatus = resource.ConfigurationStatus.ToWire(),
Commands = resource.Commands.Select(command => new ResourceCommandDefinition
{
Name = command.Name,
DisplayName = command.DisplayName
}).ToArray()
}).ToArray()
};
}
/// <summary>Provides completion of a command or interaction without exposing framework tasks as handles.</summary>
[AspireExport]
internal sealed class RuntimeOperation(RuntimeRequest request, ICapabilityLifetime owner) : ICapabilityLifetime
{
public bool IsRevoked => owner.IsRevoked;
internal Task<RuntimeRequestResult> AwaitNativeOperation() => request.Completion;
/// <summary>Awaits command or interaction completion, including cancellation on execution retirement.</summary>
[AspireExport]
public async Task<RuntimeOperationResult> AwaitRuntimeOperation()
{
var result = await request.Completion.ConfigureAwait(false);
return new RuntimeOperationResult { Status = result.Status.ToWire(), Message = result.Message };
}
}
/// <summary>Bounds short-lived, one-use delegation grants within a server instance.</summary>
internal sealed class RuntimeInvitations
{
private readonly Lock _gate = new();
private readonly Dictionary<string, Invitation> _pending = new(StringComparer.Ordinal);
private bool _closed;
private readonly List<RuntimeGeneration> _executions = [];
private readonly List<ApplicationWorkspace> _workspaces = [];
private ApplicationObserver? _observer;
public IWorkloadExecutor Executor { get; }
public NativeRuntimeOptions Options { get; }
public RuntimeInvitations() : this(UnavailableWorkloadExecutor.Instance)
{
}
public RuntimeInvitations(IWorkloadExecutor executor) : this(executor, new())
{
}
public RuntimeInvitations(IWorkloadExecutor executor, NativeRuntimeOptions options)
{
Executor = executor;
Options = options;
}
public ApplicationWorkspace CreateWorkspace()
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_closed, this);
_workspaces.RemoveAll(workspace => workspace.IsRevoked);
if (_workspaces.Count >= 1024)
{
throw new InvalidOperationException("The workspace capacity was reached.");
}
var workspace = new ApplicationWorkspace(this);
_workspaces.Add(workspace);
return workspace;
}
}
public void Track(RuntimeGeneration execution)
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_closed, this);
_executions.RemoveAll(previous => previous.IsRetired && previous.Completion.IsCompletedSuccessfully);
if (_executions.Count >= 1024)
{
execution.Dispose();
throw new InvalidOperationException("The execution capacity was reached.");
}
_executions.Add(execution);
_observer = new ApplicationObserver(execution);
}
}
public ApplicationObserver? GetApplicationObserver()
{
lock (_gate)
{
return _observer is { IsRevoked: false } ? _observer : null;
}
}
public Task DrainAsync()
{
lock (_gate)
{
return Task.WhenAll(_executions.Select(execution => execution.Completion));
}
}
public string Issue<T>(ICapabilityLifetime execution, Func<T> create) where T : class
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_closed || execution.IsRevoked, this);
RemoveExpired();
if (_pending.Count >= 1024)
{
throw new InvalidOperationException("The invitation capacity was reached.");
}
var token = RandomNumberGenerator.GetHexString(64, lowercase: true);
_pending.Add(token, new Invitation<T>(execution, create, Options.TimeProvider.GetTimestamp()));
return token;
}
}
public T Claim<T>(string token) where T : class
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_closed, this);
RemoveExpired();
if (!_pending.TryGetValue(token, out var invitation) || invitation is not Invitation<T> typed)
{
throw new ArgumentException("The invitation is unknown, expired, or not applicable.", nameof(token));
}
_pending.Remove(token);
return typed.Create();
}
}
public void Close()
{
ApplicationWorkspace[] workspaces;
lock (_gate)
{
_closed = true;
_pending.Clear();
workspaces = _workspaces.ToArray();
}
// Workspace commits acquire the invitation gate while holding their own
// gate. Close them outside it to avoid lock-order inversion during shutdown.
foreach (var workspace in workspaces)
{
workspace.Close();
}
lock (_gate)
{
foreach (var execution in _executions)
{
execution.Dispose();
}
}
}
private void RemoveExpired()
{
var now = Options.TimeProvider.GetTimestamp();
foreach (var (token, invitation) in _pending.ToArray())
{
if (invitation.Execution.IsRevoked || Options.TimeProvider.GetElapsedTime(invitation.Created, now) >= TimeSpan.FromMinutes(1))
{
_pending.Remove(token);
}
}
}
private abstract record Invitation(ICapabilityLifetime Execution, long Created);
private sealed record Invitation<T>(ICapabilityLifetime Execution, Func<T> Create, long Created) : Invitation(Execution, Created);
}
[AspireDto]
internal sealed class ResourceConfigurationRevision
{
public required long Revision { get; init; }
public required long AppliedRevision { get; init; }
public required string Status { get; init; }
public required string Message { get; init; }
public required ResourceConfigurationProperty[] Properties { get; init; }
public required string[] Dependencies { get; init; }
}
[AspireDto]
internal sealed class ResourceObservation
{
public required string State { get; init; }
public required bool Healthy { get; init; }
public required string[] Urls { get; init; }
}
[AspireDto]
internal sealed class ApplicationObservations
{
public required long Version { get; init; }
public required string GenerationId { get; init; }
public required ResourceObservationSnapshot[] Resources { get; init; }
}
[AspireDto]
internal sealed class ResourceObservationSnapshot
{
public required string ResourceId { get; init; }
public required string Name { get; init; }
public required string TypeId { get; init; }
public required string State { get; init; }
public required bool Healthy { get; init; }
public required string[] Urls { get; init; }
public required ResourceCommandDefinition[] Commands { get; init; }
public required long ConfigurationRevision { get; init; }
public required long AppliedConfigurationRevision { get; init; }
public required string ConfigurationStatus { get; init; }
}
[AspireDto]
internal sealed class ResourceCommandDefinition
{
public required string Name { get; init; }
public required string DisplayName { get; init; }
}
[AspireDto]
internal sealed class ResourceLogs
{
public required long LastSequence { get; init; }
public required bool Truncated { get; init; }
public required ResourceLog[] Entries { get; init; }
}
[AspireDto]
internal sealed class ResourceLog
{
public required long Sequence { get; init; }
public required string Timestamp { get; init; }
public required string Stream { get; init; }
public required string Message { get; init; }
}
[AspireDto]
internal sealed class PendingResourceCommands
{
public required ResourceCommandRequest[] Commands { get; init; }
}
[AspireDto]
internal sealed class ResourceCommandRequest
{
public required string RequestId { get; init; }
public required string Name { get; init; }
}
[AspireDto]
internal sealed class PendingConfirmations
{
public required ConfirmationRequest[] Requests { get; init; }
}
[AspireDto]
internal sealed class ConfirmationRequest
{
public required string RequestId { get; init; }
public required string ResourceName { get; init; }
public required string Message { get; init; }
}
[AspireDto]
internal sealed class RuntimeOperationResult
{
public required string Status { get; init; }
public required string Message { get; init; }
}
[AspireDto]
internal sealed class ContainerLaunch
{
public required string Image { get; init; }
public required int TargetPort { get; init; }
public required LaunchEnvironment[] Environment { get; init; }
public required string[] Arguments { get; init; }
}
[AspireDto]
internal sealed class ExecutableLaunch
{
public required string ExecutablePath { get; init; }
public required string WorkingDirectory { get; init; }
public required string PortEnvironmentVariable { get; init; }
public required LaunchEnvironment[] Environment { get; init; }
public required string[] Arguments { get; init; }
}
[AspireDto]
internal sealed class LaunchEnvironment
{
public required string Name { get; init; }
public required string Value { get; init; }
}
[AspireDto]
internal sealed class StartedWorkload
{
public required string Host { get; init; }
public required int Port { get; init; }
public required string InstanceId { get; init; }
}